Compare commits

..

88 Commits

Author SHA1 Message Date
The Dogfather 45477bce4f Merge pull request 'GRO-1636: seed.ts creates Better Auth credential accounts for UAT personas' (#434) from flea/gro-1636-better-auth-seed into dev
CI / Test (push) Successful in 1m20s
CI / Lint & Typecheck (push) Successful in 1m23s
CI / Build (push) Successful in 1m15s
CI / Build & Push Docker Images (push) Failing after 3m20s
CI / Update Infra Image Tags (push) Has been skipped
CI / Web E2E (Dev) (push) Has been cancelled
CI / Deploy PR to groombook-dev (push) Has been cancelled
Merge PR #434: GRO-1636 seed.ts creates Better Auth credential accounts for UAT personas
2026-05-24 04:25:10 +00:00
Flea Flicker 964c63bbdf GRO-1636: fix scrypt keylen=64 and add email+password UAT test cases
CI / Test (pull_request) Successful in 25s
CI / E2E Tests (pull_request) Failing after 48s
CI / Build (pull_request) Successful in 24s
CI / Lint & Typecheck (pull_request) Successful in 23s
CI / Build & Push Docker Images (pull_request) Has been skipped
CI / Update Infra Image Tags (pull_request) Has been skipped
CI / Web E2E (Dev) (pull_request) Has been cancelled
CI / Deploy PR to groombook-dev (pull_request) Has been cancelled
1. Fix scrypt keylen: positional arg is output key length, not N cost.
   Correct call: scrypt(pass, salt, 64, {N:16384, r:8, p:1})
   This produces a 64-byte key matching Better Auth's expected format.

2. Update UAT_PLAYBOOK.md §4.1 with 6 new email+password login test
   cases covering all 4 UAT personas (super, groomer, customer, tester),
   renumbered session/logout/RBAC tests, and a reset-cycle survival test.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-23 20:41:30 +00:00
Barcode Betty 4ec2885b09 GRO-1636: seed.ts creates Better Auth credential accounts for UAT personas
CI / Lint & Typecheck (pull_request) Successful in 22s
CI / Test (pull_request) Successful in 24s
CI / Build (pull_request) Successful in 22s
CI / Build & Push Docker Images (pull_request) Has been skipped
CI / Update Infra Image Tags (pull_request) Has been skipped
CI / E2E Tests (pull_request) Failing after 40s
CI / Deploy PR to groombook-dev (pull_request) Has been cancelled
CI / Web E2E (Dev) (pull_request) Has been cancelled
After creating staff table records for UAT personas, seedKnownUsers() now
reads SEED_UAT_*_PASSWORD env vars and creates Better Auth user + account
rows so personas can email+password login. Uses the same scrypt hash format
(N=16384, r=8, p=1, dkLen=64) as better-auth.

For uat-super and uat-groomer, the staff record is linked to the Better Auth
user via userId field.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-23 20:23:35 +00:00
The Dogfather fdd35a4cde Merge pull request 'fix(GRO-1489): resolve 7 lint errors blocking dev CI' (#429) from flea-flicker/gro-1489-lint-fixes into dev
CI / Lint & Typecheck (push) Successful in 21s
CI / Test (push) Successful in 24s
CI / Build (push) Successful in 22s
CI / Build & Push Docker Images (push) Failing after 42s
CI / Update Infra Image Tags (push) Has been skipped
CI / Web E2E (Dev) (push) Has been cancelled
CI / Deploy PR to groombook-dev (push) Has been cancelled
fix(GRO-1489): resolve 7 lint errors blocking dev CI (#429)
2026-05-23 19:10:13 +00:00
Scrubs McBarkley 559274becd Merge pull request 'docs: add MCP-driven execution method to UAT playbook (GRO-1502)' (#432) from docs/GRO-1502-uat-mcp-migration into dev
CI / Web E2E (Dev) (push) Has been cancelled
CI / Deploy PR to groombook-dev (push) Has been cancelled
CI / Lint & Typecheck (push) Successful in 22s
CI / Build (push) Successful in 21s
CI / Test (push) Successful in 24s
CI / Build & Push Docker Images (push) Successful in 33s
CI / Update Infra Image Tags (push) Failing after 1s
docs: add MCP-driven UAT execution method (GRO-1502)
2026-05-22 11:48:03 +00:00
Chris Farhood f3c56b43f0 docs: add Shedward Scissorhands UAT agent instructions (GRO-1502)
CI / Web E2E (Dev) (pull_request) Has been cancelled
CI / Deploy PR to groombook-dev (pull_request) Has been cancelled
CI / Lint & Typecheck (pull_request) Successful in 22s
CI / Test (pull_request) Successful in 27s
CI / Build (pull_request) Successful in 21s
CI / Build & Push Docker Images (pull_request) Successful in 57s
CI / Update Infra Image Tags (pull_request) Has been skipped
Mandates groombook-playwright MCP for all browser interaction during UAT.
Documents available MCP tools, execution workflow, and environment URLs.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-22 11:40:35 +00:00
Chris Farhood 89b3d81a82 docs: add MCP-driven execution method to UAT playbook (GRO-1502)
UAT is now executed by Shedward Scissorhands via the groombook-playwright
MCP server. Legacy scripted Playwright suites remain for CI regression
only. Added Section 2 documenting the MCP tools, how test cases map to
MCP calls, and the role of legacy CI tests.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-22 11:39:56 +00:00
Flea Flicker 4a628ef3b7 fix(ci): remove CI-based E2E Tests job — use Playwright MCP instead
CI / Build (push) Successful in 21s
CI / Lint & Typecheck (push) Successful in 23s
CI / Test (push) Successful in 25s
CI / Build & Push Docker Images (push) Successful in 34s
CI / Update Infra Image Tags (push) Failing after 1s
CI / Web E2E (Dev) (push) Has been cancelled
CI / Deploy PR to groombook-dev (push) Has been cancelled
E2E testing moved to Playwright MCP with Shedward Scissorhands in UAT
per GRO-904. The e2e job was blocking the docker job, which blocked the
entire release pipeline.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 21:36:05 +00:00
Flea Flicker 15af4f0962 fix(ci): add 30s grace period after services report healthy
CI / Build (push) Successful in 24s
CI / Update Infra Image Tags (push) Has been skipped
CI / Lint & Typecheck (push) Successful in 23s
CI / E2E Tests (push) Failing after 45s
CI / Build & Push Docker Images (push) Has been skipped
CI / Test (push) Successful in 26s
CI / Web E2E (Dev) (push) Has been cancelled
CI / Deploy PR to groombook-dev (push) Has been cancelled
Even after nginx is listening on port 80, there can be a brief window
where the first Playwright requests hit still-warming router logic or
upstream connection pool setup, causing inconsistent E2E failures.

Now the readiness step:
1. Polls until both http://localhost:8080 and http://localhost:3000/health
   return HTTP 200 (up to 60 attempts = 10 min max)
2. Once both are confirmed up, sleeps 30 additional seconds before
   proceeding to E2E tests — a settling period for nginx and the Node
   server to fully stabilize

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 21:19:22 +00:00
Flea Flicker 990bc4400c fix(ci): add explicit readiness wait for E2E services
CI / Lint & Typecheck (push) Successful in 25s
CI / Test (push) Successful in 27s
CI / Build (push) Successful in 24s
CI / E2E Tests (push) Failing after 46s
CI / Build & Push Docker Images (push) Has been skipped
CI / Update Infra Image Tags (push) Has been skipped
CI / Web E2E (Dev) (push) Has been cancelled
CI / Deploy PR to groombook-dev (push) Has been cancelled
returns immediately after Docker reports
containers started, not after services inside those containers are actually
listening. This causes Playwright to hit nginx before it's ready.

Now:
- Start containers with  (no --wait)
- Poll http://localhost:8080 AND http://localhost:3000/health every 10s,
  up to 30 attempts (5 minutes total)
- Only proceed to E2E tests once both are reachable

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 21:13:20 +00:00
Flea Flicker c12935de9c fix(docker): add healthcheck + depends_on condition on web service
CI / Test (push) Successful in 28s
CI / Lint & Typecheck (push) Successful in 31s
CI / E2E Tests (push) Failing after 53s
CI / Build & Push Docker Images (push) Has been skipped
CI / Update Infra Image Tags (push) Has been skipped
CI / Build (push) Successful in 31s
CI / Web E2E (Dev) (push) Has been cancelled
CI / Deploy PR to groombook-dev (push) Has been cancelled
Previously web started immediately after the api container launched, not
after it was ready. Playwright tests then hit the web server before the
nginx process had fully started, causing connection refused errors.

Now:
- api has a 30s startup grace via start_period and 20 retries
- web waits for api to be healthy (not just started)
- both services verify readiness before dependent steps proceed

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 21:09:44 +00:00
The Dogfather 9b49b6388d Merge pull request 'fix(e2e): respect PLAYWRIGHT_BASE_URL env var and add host.docker.internal resolution' (#430) from flea/gro-1496-e2e-err-connection-refused into dev
CI / Test (push) Successful in 22s
CI / Lint & Typecheck (push) Successful in 23s
CI / Build (push) Successful in 24s
CI / E2E Tests (push) Failing after 3m45s
CI / Build & Push Docker Images (push) Has been skipped
CI / Update Infra Image Tags (push) Has been skipped
CI / Web E2E (Dev) (push) Has been cancelled
CI / Deploy PR to groombook-dev (push) Has been cancelled
fix(e2e): respect PLAYWRIGHT_BASE_URL env var and add host.docker.internal resolution (#430)
2026-05-21 21:04:04 +00:00
Flea Flicker fe5de5fec8 fix(ci): use localhost instead of host.docker.internal for Playwright
CI / Test (push) Successful in 25s
CI / Lint & Typecheck (push) Successful in 23s
CI / Build (push) Successful in 23s
CI / E2E Tests (push) Failing after 5m31s
CI / Build & Push Docker Images (push) Has been skipped
CI / Update Infra Image Tags (push) Has been skipped
CI / Web E2E (Dev) (push) Has been cancelled
CI / Deploy PR to groombook-dev (push) Has been cancelled
host.docker.internal is a Docker Desktop feature unavailable on Gitea Actions
ubuntu-latest runners. Linux runners can reach the Docker Compose service
via localhost when using docker compose expose/published ports.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 20:58:02 +00:00
Flea Flicker 82f1e3856f fix(e2e): respect PLAYWRIGHT_BASE_URL env var and add host.docker.internal resolution
CI / Test (pull_request) Successful in 28s
CI / Lint & Typecheck (pull_request) Successful in 31s
CI / E2E Tests (pull_request) Successful in 1m32s
CI / Build (pull_request) Successful in 2m32s
CI / Build & Push Docker Images (pull_request) Successful in 35s
CI / Update Infra Image Tags (pull_request) Has been skipped
CI / Web E2E (Dev) (pull_request) Has been cancelled
CI / Deploy PR to groombook-dev (pull_request) Has been cancelled
The Playwright config hardcoded localhost:8080 as baseURL, ignoring
the PLAYWRIGHT_BASE_URL env var set in CI. Docker Compose was also
missing extra_hosts to resolve host.docker.internal on Gitea Actions
runners (which use DIND).

Fixes GRO-1496.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 20:53:30 +00:00
Flea Flicker 0d191743e2 fix(GRO-1489): resolve 7 lint errors blocking dev CI
CI / E2E Tests (pull_request) Successful in 1m24s
CI / Lint & Typecheck (pull_request) Successful in 21s
CI / Test (pull_request) Successful in 23s
CI / Build (pull_request) Successful in 25s
CI / Build & Push Docker Images (pull_request) Successful in 1m35s
CI / Update Infra Image Tags (pull_request) Has been skipped
CI / Web E2E (Dev) (pull_request) Has been cancelled
CI / Deploy PR to groombook-dev (pull_request) Has been cancelled
- Remove unused gte, lt, ne imports from cascade.ts
- Rename originalEndTime → _originalEndTime in detectAndCascadeOverrun params
- Rename originalStartTime/newStartTime → _originalStartTime/_newStartTime in isOverrun params
- Remove unused petCoatType assignment in book.ts availability route
- Align x-large → xlarge in Book.tsx size option value and duration display

Unblocks: GRO-1481 promotion (PR #428)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 20:44:16 +00:00
Flea Flicker 526251b63a fix: resolve lint errors and xlarge mismatch for dev→uat promotion
CI / Test (push) Successful in 26s
CI / Lint & Typecheck (push) Successful in 27s
CI / E2E Tests (push) Failing after 3m27s
CI / Update Infra Image Tags (push) Has been skipped
CI / Build (push) Successful in 24s
CI / Build & Push Docker Images (push) Has been skipped
CI / Web E2E (Dev) (push) Has been cancelled
CI / Deploy PR to groombook-dev (push) Has been cancelled
- Remove unused gte/lt/ne imports from cascade.ts
- Prefix unused params originalEndTime, originalStartTime, newStartTime
  with underscore in cascade.ts and appointments.ts callers
- Remove unused petCoatType query param from book.ts availability route
- Align xlarge value: Book.tsx now uses "xlarge" (no hyphen) everywhere
  to match the Zod booking schema

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 20:28:43 +00:00
The Dogfather 3aa7631519 Merge pull request 'fix(GRO-1369): add missing sizeCategory/coatType/bufferMinutes to @groombook/types' (#427) from fix/gro-1369-types-sync into dev
CI / Test (pull_request) Successful in 25s
CI / Update Infra Image Tags (pull_request) Has been skipped
CI / Test (push) Successful in 25s
CI / Lint & Typecheck (pull_request) Failing after 23s
CI / E2E Tests (pull_request) Has been skipped
CI / Build (pull_request) Has been skipped
CI / Build & Push Docker Images (pull_request) Has been skipped
CI / Lint & Typecheck (push) Failing after 22s
CI / E2E Tests (push) Has been skipped
CI / Build (push) Has been skipped
CI / Build & Push Docker Images (push) Has been skipped
CI / Update Infra Image Tags (push) Has been skipped
CI / Web E2E (Dev) (push) Has been cancelled
CI / Deploy PR to groombook-dev (push) Has been cancelled
CI / Web E2E (Dev) (pull_request) Has been cancelled
CI / Deploy PR to groombook-dev (pull_request) Has been cancelled
Merge PR #427: fix(GRO-1369): add missing sizeCategory/coatType/bufferMinutes to @groombook/types

Approved by CTO (review #3463) and QA (review #3469).
Resolves GRO-1369.
2026-05-21 20:00:40 +00:00
The Dogfather 511bdf0d7d Merge pull request 'fix(GRO-1368): remove unused getDb import from consent.ts' (#426) from fix/gro-1368-consent-ts into dev
CI / Lint & Typecheck (push) Failing after 22s
CI / E2E Tests (push) Has been skipped
CI / Build (push) Has been skipped
CI / Build & Push Docker Images (push) Has been skipped
CI / Test (push) Successful in 23s
CI / Update Infra Image Tags (push) Has been skipped
CI / Web E2E (Dev) (push) Has been cancelled
CI / Deploy PR to groombook-dev (push) Has been cancelled
feat(GRO-106): STOP/HELP compliance + consent log (#426)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 19:51:09 +00:00
Flea Flicker de3877b28d docs(app): add UAT_PLAYBOOK.md section 4.20 for STOP/HELP consent handler
CI / Update Infra Image Tags (pull_request) Has been skipped
CI / Lint & Typecheck (pull_request) Failing after 24s
CI / Test (pull_request) Successful in 25s
CI / E2E Tests (pull_request) Has been skipped
CI / Build (pull_request) Has been skipped
CI / Build & Push Docker Images (pull_request) Has been skipped
CI / Deploy PR to groombook-dev (pull_request) Has been cancelled
CI / Web E2E (Dev) (pull_request) Has been cancelled
Adds 12 test cases covering:
- STOP/START/HELP flows and their auto-reply verification
- Alias keywords (STOPALL, UNSUBSCRIBE, CANCEL, END, QUIT / UNSTOP, YES, SUBSCRIBE, INFO)
- Idempotency for double STOP and double START
- Case-insensitivity and whitespace trimming
- Non-keyword message rejection
- Consent event audit log verification

Refs: GRO-1205, GRO-1469, PR #426

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 19:50:37 +00:00
Chris Farhood 7d3adeae98 fix(GRO-1368): remove unused getDb import from consent.ts
getDb was imported but never used — db is passed as a parameter to
handleConsentKeyword. This was the primary TypeScript/lint error
flagged by QA.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 19:49:33 +00:00
Chris Farhood 1fbe670751 feat(GRO-106): STOP/HELP compliance + consent log
- Add detectKeyword() and handleConsentKeyword() in consent.ts
- Wire keyword detection into handleMessageReceived() in inbound.ts
- Add 24-unit test suite for consent.ts covering all keywords,
  case insensitivity, whitespace tolerance, idempotency, and
  help keyword state preservation

Fixes from QA review:
- Use getDb() instead of non-existent db export; import Db type
- Destructure clientId from findOrCreateConversation result
- Rename staffId → sentByStaffId in sendMessage call
- Remove messagingHelpReply query (column not yet in schema)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 19:49:33 +00:00
Flea Flicker f265d61475 fix(GRO-1388): correct petSizeCategory enum from "x-large" to "xlarge"
CI / Build (push) Has been skipped
CI / Lint & Typecheck (push) Failing after 21s
CI / Test (push) Successful in 24s
CI / E2E Tests (push) Has been skipped
CI / Build & Push Docker Images (push) Has been skipped
CI / Update Infra Image Tags (push) Has been skipped
CI / Web E2E (Dev) (push) Has been cancelled
CI / Deploy PR to groombook-dev (push) Has been cancelled
The DB schema enum only accepts "xlarge", but the Zod schema and runtime
checks used "x-large". Changed all occurrences to match the schema.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 00:54:12 +00:00
The Dogfather 7d8d7535a5 Merge pull request 'fix(ci): Docker push auth + E2E DinD networking for Gitea' (#423) from fix/ci-e2e-dind-networking-registry-auth into dev
CI / Lint & Typecheck (push) Failing after 18s
CI / Test (push) Successful in 23s
CI / E2E Tests (push) Has been skipped
CI / Build (push) Has been skipped
CI / Build & Push Docker Images (push) Has been skipped
CI / Update Infra Image Tags (push) Has been skipped
CI / Web E2E (Dev) (push) Has been cancelled
CI / Deploy PR to groombook-dev (push) Has been cancelled
test merge
2026-05-21 00:43:08 +00:00
Chris Farhood da14866abe fix(ci): remove GitHub-specific permissions block (Gitea doesn't use them)
CI / Lint & Typecheck (pull_request) Failing after 20s
CI / Test (pull_request) Successful in 23s
CI / E2E Tests (pull_request) Has been skipped
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
CI / Web E2E (Dev) (pull_request) Has been cancelled
CI / Deploy PR to groombook-dev (pull_request) Has been cancelled
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 00:36:34 +00:00
groombook-engineer[bot] cc45692564 fix(ci): add PLAYWRIGHT_BASE_URL for DinD networking in E2E tests
CI / Lint & Typecheck (pull_request) Failing after 20s
CI / Test (pull_request) Successful in 25s
CI / Build (pull_request) Has been skipped
CI / E2E Tests (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
CI / Web E2E (Dev) (pull_request) Has been cancelled
CI / Deploy PR to groombook-dev (pull_request) Has been cancelled
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 00:33:04 +00:00
Chris Farhood cc0259975b fix(GRO-1369): add missing sizeCategory/coatType/bufferMinutes to @groombook/types
CI / Lint & Typecheck (pull_request) Successful in 22s
CI / Test (pull_request) Successful in 23s
CI / Build (pull_request) Successful in 23s
CI / E2E Tests (pull_request) Failing after 3m25s
CI / Build & Push Docker Images (pull_request) Has been skipped
CI / Update Infra Image Tags (pull_request) Has been skipped
CI / Web E2E (Dev) (pull_request) Has been cancelled
CI / Deploy PR to groombook-dev (pull_request) Has been cancelled
Pet interface: added sizeCategory and coatType (nullable strings).
Service interface: added defaultBufferMinutes.
Appointment interface: added bufferMinutes.

These fields are referenced by Book.tsx, cascade.ts, buffer.ts, appointment
routes, and other type-annotated consuming code. Without them, any file that
imports these interfaces and accesses the fields causes a TypeScript error.

cc @cpfarhood

Co-Authored-By: Flea Flicker <noreply@paperclip.ing>
2026-05-20 15:44:08 +00:00
Chris Farhood 8e7a0b22e0 fix(GRO-1367): remove GitHub-specific upload-artifact and workflow_dispatch inputs
CI / Lint & Typecheck (pull_request) Failing after 19s
CI / Test (pull_request) Successful in 23s
CI / E2E Tests (pull_request) Has been skipped
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
CI / Web E2E (Dev) (pull_request) Has been cancelled
CI / Deploy PR to groombook-dev (pull_request) Has been cancelled
- Remove workflow_dispatch.inputs block (GitHub-specific manual trigger args)
- Remove actions/upload-artifact@v4 from e2e job (not available in Gitea Actions)
- Remove actions/upload-artifact@v4 from web-e2e job (not available in Gitea Actions)

tibdex/github-app-token was already removed in prior commits.
2026-05-20 14:46:20 +00:00
Chris Farhood c4268a923e fix(GRO-1367): replace github.com noreply email with Gitea address
CI / E2E Tests (pull_request) Has been skipped
CI / Build (pull_request) Has been skipped
CI / Lint & Typecheck (pull_request) Failing after 20s
CI / Test (pull_request) Successful in 24s
CI / Build & Push Docker Images (pull_request) Has been skipped
CI / Update Infra Image Tags (pull_request) Has been skipped
CI / Web E2E (Dev) (pull_request) Has been cancelled
CI / Deploy PR to groombook-dev (pull_request) Has been cancelled
Replace git config user.email from noreply.github.com to groombook-engineer@farh.net.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 14:42:01 +00:00
The Dogfather bddbf008b5 Merge pull request 'fix: correct apps/ path prefix in promote workflows (GRO-1248)' (#407) from fix/gro-1248-path-prefixes into dev
CI / Lint & Typecheck (push) Successful in 20s
CI / Test (push) Failing after 27s
CI / E2E Tests (push) Has been skipped
CI / Build (push) Has been skipped
CI / Build & Push Docker Images (push) Has been skipped
CI / Update Infra Image Tags (push) Has been skipped
CI / Web E2E (Dev) (push) Has been cancelled
CI / Deploy PR to groombook-dev (push) Has been cancelled
fix: correct apps/ path prefix in promote workflows (GRO-1248) (#407)

Approved-by: gb_lint (QA), gb_dogfather (CTO)
2026-05-20 13:01:11 +00:00
Chris Farhood 12ee1f054b fix(ci): Docker push auth + E2E DinD networking for Gitea
CI / Lint & Typecheck (pull_request) Failing after 19s
CI / Test (pull_request) Successful in 22s
CI / E2E Tests (pull_request) Has been skipped
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
CI / Web E2E (Dev) (pull_request) Has been cancelled
CI / Deploy PR to groombook-dev (pull_request) Has been cancelled
- Use git.farh.net registry with REGISTRY_TOKEN instead of ghcr.io/GITHUB_TOKEN
- Migrate all image tags from ghcr.io/groombook/* to git.fars.net/groombook/*
- Replace GHA cache with OCI registry cache (type=registry)
- Replace tibdex/github-app-token with oauth2+REGISTRY_TOKEN for infra clone
- Replace gh pr create/merge with Gitea API curl calls
- Replace actions/github-script@v7 Comment on PR with Gitea issues API curl
- Remove permissions: blocks from deploy-dev and cd jobs (Gitea-native)
- Update deploy-dev kubectl image refs to git.farh.net/groombook/*

Refs: GRO-1344
2026-05-20 11:38:07 +00:00
Chris Farhood 3063fde870 docs: add UAT test cases for size/coat booking and cascading delay
Updated UAT_PLAYBOOK.md §4.5 with TC-APP-4.5.7 through TC-APP-4.5.13
covering the booking wizard dropdowns, buffer-aware duration, cascade
trigger/shift/notification, day-boundary guard rail, and status guards.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 16:19:15 +00:00
Chris Farhood a7b3dc2f02 fix: restore missing columns to pets table
The schema edit that added sizeCategory/coatType accidentally removed
other existing columns (dateOfBirth, healthAlerts, groomingNotes, etc.).
Restoring them now.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-16 16:13:24 +00:00
Chris Farhood 90af76f222 feat(appointments): cascading delay prevention for appointment overruns
When a PATCH /appointments/:id extends endTime beyond the original, detect
and automatically shift downstream same-groomer appointments by the overrun
delta plus buffer. Only affects scheduled/confirmed appointments; appointments
that would shift outside business hours are flagged for manual review.

Clients receive email notification of rescheduled times.

GRO-1175: GRO-1162-G

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-16 16:08:05 +00:00
Chris Farhood 0c7cd96130 fix: resolve duplicate 'end' variable declaration in book.ts
Using `let end` so the buffer-aware recalculation can reassign the
variable rather than redeclaring it in a nested scope.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-16 15:59:24 +00:00
Chris Farhood 4bcc78f1e6 feat: add pet size/coat to booking flow with buffer-aware availability
- Add petSizeCategory and petCoatType dropdowns to booking wizard
  (after breed field, optional but encouraged)
- Pass selected values to GET /availability as query params
- large/x-large pets add service.defaultBufferMinutes to slot calculation
  and appointment end time (buffer never shown to client)
- POST /appointments saves size/coat to pet record
- Confirmation step shows total duration (service + buffer if applicable)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-16 15:53:56 +00:00
the-dogfather-cto[bot] f27110eb07 Merge pull request #418 from groombook/fix/GRO-1289-fix-ci-yml-infra-path
fix(GRO-1289): correct infra repo paths in ci.yml Update Infra Image Tags job
2026-05-14 20:55:50 +00:00
the-dogfather-cto[bot] d069eff7d6 fix: correct infra repo paths in ci.yml Update Infra Image Tags job (#417)
fix: correct infra repo paths in ci.yml Update Infra Image Tags job
2026-05-14 20:37:48 +00:00
Chris Farhood 3ed1e10ecb fix(GRO-1289): correct infra repo paths in ci.yml Update Infra Image Tags job
Fix 'stat apps/groombook/overlays/dev/kustomization.yaml: no such file'
error by correcting paths from apps/groombook/overlays/dev to apps/overlays/dev
and apps/groombook/base to apps/base.

GRO-1289
2026-05-14 20:36:38 +00:00
Chris Farhood 904cd9c1b9 fix: correct infra repo paths in ci.yml Update Infra Image Tags job
GRO-1287
2026-05-14 20:26:53 +00:00
the-dogfather-cto[bot] 573869e517 fix: correct infra paths in promote-to-uat workflow (#414)
* Promote dev → uat: ARIA modal fix + tip split atomicity (#335)

* feat(GRO-785): validate tip split totals before marking invoice paid

- PATCH /invoices/:id returns 400 when tipCents > 0 but no tip splits
  exist or splits don't sum to 100%
- POST /invoices/:id/tip-splits now returns 400 (not 422) on validation
  failure via router-level ZodError handler

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* feat(GRO-786): add ARIA label attributes to Modal dialog component

- Update Modal component to accept title and titleStyle props
- Add role="dialog", aria-modal="true", and aria-labelledby attributes
- Use useId() to generate stable ID for title heading association
- Update all 4 Modal call sites (New/Edit Client, Add/Edit Pet,
  Log Grooming Visit, Permanently Delete Client) with title props
- Delete modal passes titleStyle for red color on warning

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(GRO-786): remove duplicate dialog role and restore focus trap

- Remove role="dialog" and aria-modal="true" from outer backdrop div
- Keep ARIA attributes only on inner dialog div (the actual modal)
- Restore useEffect focus management: auto-focus first element,
  Tab cycle wrapping, Escape key handler, focus restore on close

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(GRO-785): restore atomic tip split save in PATCH and fix error message

- When body.tipSplits is provided in PATCH /invoices/:id, validate sum
  first then atomically replace existing splits (delete + insert)
- When no incoming splits, validate existing DB splits with corrected
  message: "Tip splits are required when tip amount is greater than zero"
  (previously misleading "must sum to 100%" when no splits existed)

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(GRO-785): address invoice tip split regression

- Use body.tipCents ?? current.tipCents for validation condition
  so that simultaneous status=paid + tipCents=0 skip split validation
- Use body.tipCents (now aliased as tipCents) instead of current.tipCents
  inside the atomic transaction for shareCents calculation
- Add explicit check for empty tipSplits array with appropriate error
  message ("Tip splits are required when tip amount is greater than zero")
  before the sum-to-100% check
- Destructure tipSplits out of body before spreading into update object
  to prevent it from leaking into the invoices table SET clause

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(GRO-785): wrap tip split save + invoice update in single transaction

Both tip split persistence (delete + insert) and the invoice PATCH update
are now inside one db.transaction() block. If the invoice update fails
after splits are written, the entire operation rolls back.

Also removed unnecessary eslint-disable comment on _tipSplits.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(GRO-785): restore eslint-disable for intentionally unused _tipSplits var

Co-Authored-By: Paperclip <noreply@paperclip.ing>

---------

Co-authored-by: Flea Flicker <fleaflicker@groombook.farh.net>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: the-dogfather-cto[bot] <269737991+the-dogfather-cto[bot]@users.noreply.github.com>

* fix(gro-609): include stripePaymentIntentId in invoice list and wrap stats endpoint in try/catch

- Add stripePaymentIntentId to the GET /invoices list query so the refund button
  renders when seed data includes a payment intent ID
- Wrap /api/invoices/stats/summary in try/catch so errors return 200 with zero
  defaults instead of 5xx, preventing the Invoices page from crashing on
  mount for groomer-role sessions

Parent: GRO-882
Grandparent: GRO-816

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(gro-609): add payment stats to admin dashboard (AppointmentsPage)

- Fetch /api/invoices/stats/summary on mount and display Revenue/Outstanding/Refunds
  summary cards above the calendar view on /admin
- Mirrors the same stats section already on /admin/invoices
- Gracefully handles errors via try/catch on the stats endpoint

Parent: GRO-882
Grandparent: GRO-816

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(GRO-890): populate stripePaymentIntentId on all paid seed invoices

All paid invoices created by the seed script now get a deterministic
stripePaymentIntentId of the form pi_test_seed_NNNNNN, unblocking the
refund button conditional in Invoices.tsx:514 during UAT.

Pending/draft invoices retain null as before.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(GRO-898): update CI to deploy on dev branch pushes

Update the Update Infra Image Tags job condition to also trigger
on pushes to the dev branch, not just main.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix: correct infra paths in promote-to-uat workflow

Remove 'groombook/' prefix from 4 path references in promote-to-uat.yml since the groombook/infra repo has apps/overlays/ and apps/base/ at the root, not under a groombook/ subdirectory.

GRO-1274

---------

Co-authored-by: the-dogfather-cto[bot] <269737991+the-dogfather-cto[bot]@users.noreply.github.com>
Co-authored-by: Flea Flicker <fleaflicker@groombook.farh.net>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: lint-roller-qa[bot] <269744346+lint-roller-qa[bot]@users.noreply.github.com>
Co-authored-by: scrubs-mcbarkley-ceo[bot] <269735724+scrubs-mcbarkley-ceo[bot]@users.noreply.github.com>
Co-authored-by: Test User <test@example.com>
Co-authored-by: groombook-engineer[bot] <3141748+groombook-engineer[bot]@users.noreply.github.com>
Co-authored-by: Chris Farhood <chris@farhood.org>
2026-05-14 20:16:22 +00:00
the-dogfather-cto[bot] b31cbce82e fix: VITE_API_URL hardcoding that breaks CI E2E (GRO-1280)
fix: resolve VITE_API_URL hardcoding that breaks CI E2E (GRO-1280)
2026-05-14 20:11:31 +00:00
Chris Farhood 2398dabe3a fix: set VITE_API_URL env var in Build job
Ensures Vite sees VITE_API_URL as an empty string (not undefined) during
pnpm build, so the || window.location.origin fallback fires at runtime
instead of baking in the UAT URL.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-14 19:51:47 +00:00
Chris Farhood c2dd1dbf84 fix: add explicit ARG/ENV VITE_API_URL to Dockerfile
Without this, Vite sees VITE_API_URL as undefined (not empty string) at
build time. The ?? operator only replaces null/undefined, not a missing var,
so better-auth receives undefined — which it treats as a relative path and
prepends window.location.origin at build time, resulting in the UAT URL being
baked in.

Explicitly setting ARG VITE_API_URL= (empty string) in the Dockerfile makes
Vite see it as defined with empty value, so the || fallback fires at runtime.

Fixes GRO-1280.
2026-05-14 19:51:34 +00:00
Chris Farhood 7339d51acf fix: use window.location.origin as fallback for VITE_API_URL
Vite bakes VITE_* vars at build time, so hardcoding a URL in .env.production
breaks CI E2E which runs on localhost. Now falls back to the browser origin
at runtime, which works correctly since nginx reverse-proxies /api to the
local API container.

Fixes GRO-1280.
2026-05-14 19:40:59 +00:00
Chris Farhood 8eec29ad90 fix: correct infra paths in promote-to-uat workflow
Fix hardcoded apps/groombook/... paths to apps/... per GRO-1274.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-14 19:27:00 +00:00
groombook-engineer[bot] 050d478621 fix(GRO-1236): set VITE_API_URL and use /admin as OAuth callback URL (#403)
Two root causes fixed:
1. VITE_API_URL was empty in .env.production, so Better-Auth's client
   had no baseURL and could not correctly route the OAuth callback.
2. OAuth callbackURL was window.location.origin (root path), causing
   Better-Auth to redirect to / instead of /admin after login — since
   unauthenticated users at / are redirected to /login, this created a
   loop that appeared as 'session not persisting.'

With VITE_API_URL=https://uat.groombook.dev and callbackURL=/admin,
the callback lands on /admin which renders the admin layout and
correctly establishes the session cookie.

Co-authored-by: Chris Farhood <chris@farhood.org>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-14 19:25:36 +00:00
the-dogfather-cto[bot] 795081cf10 Merge pull request #409 from groombook/fleaflicker/add-staff-messages-uat-playbook
docs(UAT_PLAYBOOK): add §4.20 Staff Messages test cases
2026-05-14 16:45:01 +00:00
Chris Farhood 8d5b71dc0f docs(UAT_PLAYBOOK): add §4.20 Staff Messages test cases
Add missing test coverage for the staff Messages page introduced
by PR #405 (GRO-106). Covers inbox load, conversation open,
send message, empty state, unread indicator, and cross-tenant
isolation per QA review on PR #408.

Updated UAT_PLAYBOOK.md §4.20 — staff Messages feature (GRO-106)
2026-05-14 16:37:04 +00:00
the-dogfather-cto[bot] c2d38bd3ee feat(GRO-106): staff messages page (#405)
feat(GRO-106): staff messages page
2026-05-14 16:23:27 +00:00
Chris Farhood 6a7229f330 merge: resolve conflicts with dev (keep API-aligned frontend)
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-14 16:20:40 +00:00
Chris Farhood 9d9d7da13d fix(GRO-985): fix Messages test mocks and scrollIntoView guard
- Wrap conversation mocks in { items, nextCursor } response shape
  (loadConversations reads json.items, bare array caused undefined.length crash)
- Guard scrollIntoView with ?. (jsdom doesn't implement it)
- Use getAllByText for text appearing in both preview and thread

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-14 16:15:24 +00:00
lint-roller-qa[bot] 2c29c5e4a9 Merge pull request #406 from groombook/flea-flicker/gro-1248-fix-infra-path-prefix
fix(ci): correct infra repo paths in promote workflows
2026-05-14 16:12:51 +00:00
the-dogfather-cto[bot] ba5f8a916d Merge pull request #398 from groombook/feat/GRO-106-portal-communication-real
feat(GRO-106): portal Communication tab — real backend
2026-05-14 16:07:33 +00:00
Chris Farhood acb65fa5bb fix: correct path prefix apps/groombook -> apps/ in promote workflows
GRO-1248: Path references incorrectly used apps/groombook/ prefix
instead of apps/ for overlay and base kustomization paths.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-14 16:05:14 +00:00
Chris Farhood e873f11e4f fix(GRO-1241): test and guard scrollIntoView in MessagesPage 2026-05-14 15:46:31 +00:00
Chris Farhood aae11c0c4d fix(GRO-1241): remove unused readOnly and senderName in Communication.tsx
- Rename readOnly to _readOnly in MessageThread destructuring
  (satisfies ESLint no-unused-vars rule)
- Remove unused senderName variable in messages map

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-14 15:26:03 +00:00
Chris Farhood 537b5cb0b3 fix(GRO-1241): resolve all CI failures from QA review
1. **Remove duplicate staffReadAt** in `packages/db/src/schema.ts`
   (TS1117 duplicate identifier — merge conflict artifact)

2. **Add count to db index exports** in `packages/db/src/index.ts`
   (`count` from drizzle-orm was used in conversations.ts but not exported)

3. **Use dev version of conversations.ts** (no type errors, sql\`count(*)\`)
   — PR branch version had incompatible type errors (staff.businessId,
   count, optedOutAt fields not in schema)

4. **Remove duplicate conversationsRouter import** in `apps/api/src/index.ts`

All 289 tests pass, 0 lint errors.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-14 14:46:52 +00:00
Chris Farhood d60200f8a7 fix(GRO-1241): remove duplicate staffReadAt + add count mock
- Remove duplicate staffReadAt column in conversations table schema
  (merge conflict artifact — TS1117 duplicate definition)
- Add count mock to conversations.test.ts mock @groombook/db export
  (PR switched from sql\`count(*)\` to Drizzle count() without updating mock)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-14 14:30:58 +00:00
Chris Farhood f150663047 fix(ci): correct infra repo paths in promote workflows
Replace incorrect `apps/groombook/` path prefix with `apps/` in both
promote-to-uat.yml and promote-prod.yml. The infra repo structure uses
`apps/` directly without a `groombook/` level.

GRO-1248

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 14:00:12 +00:00
Chris Farhood e605e1be74 fix(GRO-1242): align Messages frontend with conversations API contract
- Extract Conversation interface fields to match API response:
  replace lastMessageBody with lastMessage object, externalNumber with
  clientPhone, remove staffReadAt
- loadConversations(): extract json.items array instead of raw array
- loadMessages(): extract json.items and reverse() for chronological order
- Update test mocks to use { items, nextCursor } response shape

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 13:02:47 +00:00
Chris Farhood c4978be280 feat(GRO-106): staff messages page
- Adds staff conversations API (GET /api/conversations, GET /api/conversations/:id/messages, POST /api/conversations/:id/messages) with auth scoping and cross-tenant protection
- Adds staffReadAt column to conversations table for unread tracking
- Adds staff Messages page with two-column inbox layout (thread list + conversation view + composer)
- Adds Messages entry to staff sidebar navigation
- Includes tests for the MessagesPage component

Part of GRO-106 (SMS/MMS integration) Phase 1.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 12:41:35 +00:00
Chris Farhood f43e566dbd fix(GRO-1215): resolve ESLint error, cursor pagination, and UAT playbook gaps
- Add and() + lt() imports from @groombook/db
- Apply businessId to conversation WHERE clause for cross-tenant isolation
  (GET /portal/conversation: clientId AND businessId both scoped)
- Fix cursor pagination: apply lt(messages.createdAt, cursorMsg.createdAt)
  to the cursor WHERE clause so pages actually paginate
- Add UAT_PLAYBOOK.md §4.9.1 Communication tab test cases:
  TC-APP-4.9.6 message history with conversation
  TC-APP-4.9.7 empty state (no conversation yet)
  TC-APP-4.9.8 composer disabled with tooltip
  TC-APP-4.9.9 cross-tenant isolation

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-14 12:40:06 +00:00
Chris Farhood 9c9568b80c feat(GRO-106): portal Communication tab — real backend
- Added GET /portal/conversation and GET /portal/conversation/messages endpoints
- Created Communication.api.ts with typed fetchers and React hooks
- Rewired Communication.tsx to use real API, removed mock data
- Added composer-disabled bar with "Reply from your phone" tooltip
- Added conversation route tests to portal.test.ts

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 12:40:06 +00:00
the-dogfather-cto[bot] d0ba537b31 fix(GRO-1212): add missing impersonationAuditLogs mock in portal.test.ts
fix(GRO-1212): add missing impersonationAuditLogs mock in portal.test.ts
2026-05-14 09:08:27 +00:00
Chris Farhood a9b9a0a733 fix(GRO-1212): add missing impersonationAuditLogs mock in portal.test.ts
Add impersonationAuditLogs table mock and db.insert() method to the
@groombook/db mock in portal.test.ts to resolve "No 'impersonationAuditLogs'
export is defined" errors. The portalAudit middleware calls db.insert()
on every request, which was missing from the mock.

Passes all 26 portal tests.
2026-05-14 08:50:01 +00:00
the-dogfather-cto[bot] e818bdef4e fix(GRO-1211): skip auth middleware for /api/webhooks/* routes
fix(GRO-1211): skip auth middleware for /api/webhooks/* routes
2026-05-14 08:39:43 +00:00
Chris Farhood dce9c96442 fix(GRO-1211): skip auth middleware for /api/webhooks/* routes
The telnyx webhook handler at /api/webhooks/telnyx/messaging was
returning 401 for all requests including those with valid signatures.
This was caused by the authMiddleware being applied to all /api/*
routes via api.use("*", authMiddleware) after the webhook route was
registered at the app level.

authMiddleware already skips /api/auth/ paths; adding the same skip
for /api/webhooks/* fixes the issue — webhook endpoints use their own
signature validation and do not require Better-Auth session auth.

Root cause: authMiddleware was applied to webhook routes that were
registered at the app level before the api sub-app middleware, but
the skip condition only covered /api/auth/, not /api/webhooks/.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 08:29:10 +00:00
the-dogfather-cto[bot] f50d240e56 feat(GRO-1208): conversations API route + staffReadAt migration (#399)
feat(GRO-1208): conversations API route + staffReadAt migration
2026-05-14 07:53:24 +00:00
Chris Farhood 22135859c2 fix(GRO-1208): remove phantom 0031_steady_veda journal entry
0031_steady_veda has no corresponding SQL file — caused Drizzle migration
runner to exit 1 in E2E. Renumber 0032_staff_read_at to idx 31.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-14 07:38:01 +00:00
Chris Farhood a5115f5291 fix(GRO-1208): remove unused isNull and AppEnv imports
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-14 07:28:31 +00:00
Chris Farhood e64538822d feat(GRO-1208): add staff conversations API route and staffReadAt migration
- Add `staffReadAt` column to conversations table schema
- Add migration 0032_staff_read_at.sql for the new column
- Create /api/conversations router with GET / (list), GET /:id/messages (paginated), POST /:id/messages (send)
- Mark conversations as read (staffReadAt = NOW()) when staff fetches messages
- Return 409 when client has opted out of SMS
- 404 on cross-tenant access
- Add conversations.test.ts covering all 5 acceptance criteria

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-14 07:10:43 +00:00
scrubs-mcbarkley-ceo[bot] 53ab415713 promote: uat → main (GRO-887/GRO-958 chart hygiene)
promote: uat → main (GRO-887/GRO-958 chart hygiene)
2026-05-03 18:16:03 +00:00
The Dogfather a330e342e1 Merge main into uat to resolve PR #373 conflicts
Conflicts:
- apps/api/src/routes/invoices.ts — kept uat's stripeRefundId field (GRO-818)
- packages/db/src/seed.ts — kept main's deterministic stripePaymentIntentId
  population (GRO-890); removed duplicate uat declaration that survived auto-merge

Brings GRO-609 (refund/stats fixes), GRO-890 (seed stripe pi), GRO-898 (CI dev
branch) and prior GRO-865 logo proxy promote from main into uat so the
uat → main promote (GRO-958) becomes mergeable.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-03 18:03:59 +00:00
the-dogfather-cto[bot] 0f841e27fc Merge pull request #371 from groombook/dev
chore(uat): promote dev → uat (includes GRO-887 chart hygiene)
2026-05-03 17:58:14 +00:00
the-dogfather-cto[bot] cd25d98384 Merge pull request #366 from groombook/fix/gro-898-ci-dev-branch
fix(GRO-898): update CI to deploy on dev branch pushes
2026-04-24 15:53:15 +00:00
Test User e9fceb78b3 fix(GRO-898): update CI to deploy on dev branch pushes
Update the Update Infra Image Tags job condition to also trigger
on pushes to the dev branch, not just main.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-24 15:46:50 +00:00
the-dogfather-cto[bot] 0cae8adef8 Merge pull request #365 from groombook/promote/dev-to-uat-gro876
promote: dev → uat (GRO-876 refund button fix)
2026-04-24 15:27:25 +00:00
Test User 674626ba1e Merge remote-tracking branch 'origin/dev' into uat 2026-04-24 15:24:11 +00:00
the-dogfather-cto[bot] 903fbf55d5 promote: dev → uat (GRO-766 portal mobile overflow fix)
promote: dev → uat (GRO-766 portal mobile overflow fix)
2026-04-24 15:02:13 +00:00
the-dogfather-cto[bot] 7bf9cf9734 Merge pull request #359 from groombook/fix/gro-890-seed-stripe-payment-intent
fix(GRO-890): populate stripePaymentIntentId on paid seed invoices
2026-04-23 22:36:27 +00:00
groombook-engineer[bot] bf159f8b1f fix(GRO-890): populate stripePaymentIntentId on all paid seed invoices
All paid invoices created by the seed script now get a deterministic
stripePaymentIntentId of the form pi_test_seed_NNNNNN, unblocking the
refund button conditional in Invoices.tsx:514 during UAT.

Pending/draft invoices retain null as before.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-23 19:29:45 +00:00
the-dogfather-cto[bot] 2f3d4d8d01 fix(gro-609): refund button, stats 5xx, dashboard payment stats (#357)
fix(gro-609): include stripePaymentIntentId in invoice list and wrap stats endpoint in try/catch
2026-04-23 14:01:41 +00:00
Test User db9bb31702 fix(gro-609): add payment stats to admin dashboard (AppointmentsPage)
- Fetch /api/invoices/stats/summary on mount and display Revenue/Outstanding/Refunds
  summary cards above the calendar view on /admin
- Mirrors the same stats section already on /admin/invoices
- Gracefully handles errors via try/catch on the stats endpoint

Parent: GRO-882
Grandparent: GRO-816

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-23 13:51:15 +00:00
Test User b38db65dde fix(gro-609): include stripePaymentIntentId in invoice list and wrap stats endpoint in try/catch
- Add stripePaymentIntentId to the GET /invoices list query so the refund button
  renders when seed data includes a payment intent ID
- Wrap /api/invoices/stats/summary in try/catch so errors return 200 with zero
  defaults instead of 5xx, preventing the Invoices page from crashing on
  mount for groomer-role sessions

Parent: GRO-882
Grandparent: GRO-816

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-23 13:47:27 +00:00
scrubs-mcbarkley-ceo[bot] 3178f81b99 promote: uat → main (GRO-865 logo proxy mixed content fix)
All SDLC gates cleared. Logo proxy fix ships to production. cc @cpfarhood
2026-04-22 03:50:15 +00:00
scrubs-mcbarkley-ceo[bot] 544d65959d promote: dev → uat (GRO-867 + GRO-870 logo proxy fixes)
Promoting logo proxy fixes to UAT. All SDLC gates passed. cc @cpfarhood
2026-04-22 03:49:30 +00:00
lint-roller-qa[bot] f38bb244a4 Merge pull request #339 from groombook/dev
Promote dev → uat
2026-04-20 14:06:22 +00:00
the-dogfather-cto[bot] abee344ca4 Promote dev → uat: ARIA modal fix + tip split atomicity (#335)
* feat(GRO-785): validate tip split totals before marking invoice paid

- PATCH /invoices/:id returns 400 when tipCents > 0 but no tip splits
  exist or splits don't sum to 100%
- POST /invoices/:id/tip-splits now returns 400 (not 422) on validation
  failure via router-level ZodError handler

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* feat(GRO-786): add ARIA label attributes to Modal dialog component

- Update Modal component to accept title and titleStyle props
- Add role="dialog", aria-modal="true", and aria-labelledby attributes
- Use useId() to generate stable ID for title heading association
- Update all 4 Modal call sites (New/Edit Client, Add/Edit Pet,
  Log Grooming Visit, Permanently Delete Client) with title props
- Delete modal passes titleStyle for red color on warning

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(GRO-786): remove duplicate dialog role and restore focus trap

- Remove role="dialog" and aria-modal="true" from outer backdrop div
- Keep ARIA attributes only on inner dialog div (the actual modal)
- Restore useEffect focus management: auto-focus first element,
  Tab cycle wrapping, Escape key handler, focus restore on close

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(GRO-785): restore atomic tip split save in PATCH and fix error message

- When body.tipSplits is provided in PATCH /invoices/:id, validate sum
  first then atomically replace existing splits (delete + insert)
- When no incoming splits, validate existing DB splits with corrected
  message: "Tip splits are required when tip amount is greater than zero"
  (previously misleading "must sum to 100%" when no splits existed)

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(GRO-785): address invoice tip split regression

- Use body.tipCents ?? current.tipCents for validation condition
  so that simultaneous status=paid + tipCents=0 skip split validation
- Use body.tipCents (now aliased as tipCents) instead of current.tipCents
  inside the atomic transaction for shareCents calculation
- Add explicit check for empty tipSplits array with appropriate error
  message ("Tip splits are required when tip amount is greater than zero")
  before the sum-to-100% check
- Destructure tipSplits out of body before spreading into update object
  to prevent it from leaking into the invoices table SET clause

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(GRO-785): wrap tip split save + invoice update in single transaction

Both tip split persistence (delete + insert) and the invoice PATCH update
are now inside one db.transaction() block. If the invoice update fails
after splits are written, the entire operation rolls back.

Also removed unnecessary eslint-disable comment on _tipSplits.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(GRO-785): restore eslint-disable for intentionally unused _tipSplits var

Co-Authored-By: Paperclip <noreply@paperclip.ing>

---------

Co-authored-by: Flea Flicker <fleaflicker@groombook.farh.net>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: the-dogfather-cto[bot] <269737991+the-dogfather-cto[bot]@users.noreply.github.com>
2026-04-17 22:58:00 +00:00
33 changed files with 1626 additions and 341 deletions
+82 -147
View File
@@ -6,11 +6,6 @@ on:
pull_request:
branches: [main, dev]
workflow_dispatch:
inputs:
ref:
description: "Branch or ref to run CI against"
required: false
default: "main"
jobs:
lint-typecheck:
@@ -58,47 +53,6 @@ jobs:
- name: Run tests
run: pnpm test
e2e:
name: E2E Tests
runs-on: ubuntu-latest
needs: [lint-typecheck, test]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: '9.15.4'
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Install Playwright browsers
run: pnpm --filter @groombook/e2e exec playwright install --with-deps chromium
- name: Start Docker Compose stack
run: docker compose up -d --wait
timeout-minutes: 5
- name: Run E2E tests
run: pnpm --filter @groombook/e2e test
- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: apps/e2e/playwright-report/
retention-days: 7
- name: Stop Docker Compose stack
if: always()
run: docker compose down
build:
name: Build
runs-on: ubuntu-latest
@@ -119,17 +73,16 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Build all packages
env:
VITE_API_URL: ""
run: pnpm build
docker:
name: Build & Push Docker Images
runs-on: ubuntu-latest
needs: [build, e2e]
needs: [build]
outputs:
tag: ${{ steps.version.outputs.tag }}
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
@@ -150,12 +103,12 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
registry: git.farh.net
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push API image
uses: docker/build-push-action@v6
@@ -165,10 +118,10 @@ jobs:
target: runner
push: true
tags: |
ghcr.io/groombook/api:${{ steps.version.outputs.tag }}
${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/api:latest' || '' }}
cache-from: type=gha
cache-to: type=gha,mode=max
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
- name: Build and push Migrate image
uses: docker/build-push-action@v6
@@ -178,10 +131,10 @@ jobs:
target: migrate
push: true
tags: |
ghcr.io/groombook/migrate:${{ steps.version.outputs.tag }}
${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/migrate:latest' || '' }}
cache-from: type=gha
cache-to: type=gha,mode=max
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
@@ -191,10 +144,10 @@ jobs:
target: seed
push: true
tags: |
ghcr.io/groombook/seed:${{ steps.version.outputs.tag }}
${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/seed:latest' || '' }}
cache-from: type=gha
cache-to: type=gha,mode=max
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
@@ -204,10 +157,10 @@ jobs:
target: reset
push: true
tags: |
ghcr.io/groombook/reset:${{ steps.version.outputs.tag }}
${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/reset:latest' || '' }}
cache-from: type=gha
cache-to: type=gha,mode=max
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
- name: Build and push Web image
uses: docker/build-push-action@v6
@@ -216,19 +169,16 @@ jobs:
file: apps/web/Dockerfile
push: true
tags: |
ghcr.io/groombook/web:${{ steps.version.outputs.tag }}
${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/web:latest' || '' }}
cache-from: type=gha
cache-to: type=gha,mode=max
git.farh.net/groombook/web:${{ steps.version.outputs.tag }}
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/web:latest' || '' }}
cache-from: type=registry,ref=git.farh.net/groombook/cache:web
cache-to: type=registry,ref=git.farh.net/groombook/cache:web,mode=max
deploy-dev:
name: Deploy PR to groombook-dev
runs-on: runners-groombook
needs: [docker]
if: github.event_name == 'pull_request'
permissions:
contents: read
pull-requests: write
steps:
- name: Install kubectl
run: |
@@ -245,7 +195,6 @@ jobs:
TAG="pr-$PR_NUM-${SHA::7}"
echo "Deploying images tagged $TAG to groombook-dev..."
# Run migration with PR image
kubectl delete job "migrate-pr-$PR_NUM" -n groombook-dev --ignore-not-found
cat <<EOF | kubectl apply -n groombook-dev -f -
apiVersion: batch/v1
@@ -260,7 +209,7 @@ jobs:
restartPolicy: Never
containers:
- name: migrate
image: ghcr.io/groombook/migrate:$TAG
image: git.farh.net/groombook/migrate:$TAG
env:
- name: DATABASE_URL
valueFrom:
@@ -271,35 +220,33 @@ jobs:
kubectl wait --for=condition=complete "job/migrate-pr-$PR_NUM" \
-n groombook-dev --timeout=120s
# Update deployments
kubectl set image deployment/api api=ghcr.io/groombook/api:$TAG -n groombook-dev
kubectl set image deployment/web web=ghcr.io/groombook/web:$TAG -n groombook-dev
kubectl set image deployment/api api=git.farh.net/groombook/api:$TAG -n groombook-dev
kubectl set image deployment/web web=git.farh.net/groombook/web:$TAG -n groombook-dev
# Wait for rollout
kubectl rollout status deployment/api -n groombook-dev --timeout=300s
kubectl rollout status deployment/web -n groombook-dev --timeout=300s
echo "Deployment complete."
- name: Comment on PR
uses: actions/github-script@v7
with:
script: |
const pr = context.issue.number;
const tag = `pr-${pr}`;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr,
body: [
'## Deployed to groombook-dev',
'',
`**Images:** \`${tag}\``,
'**URL:** https://dev.groombook.farh.net',
'',
'Ready for UAT validation.'
].join('\n')
});
env:
PR_NUM: ${{ github.event.pull_request.number }}
run: |
PR_NUM="$PR_NUM"
BODY=$(cat <<'EOFBODY'
## Deployed to groombook-dev
**Images:** `pr-'"$PR_NUM"'`
**URL:** https://dev.groombook.farh.net
Ready for UAT validation.
EOFBODY
)
curl -s -X POST "https://git.farh.net/api/v1/repos/groombook/app/issues/${PR_NUM}/comments" \
-H "Authorization: Bearer ${{ secrets.REGISTRY_TOKEN }}" \
-H "Content-Type: application/json" \
-d "{\"body\": $(echo "$BODY" | jq -Rs .)}"
web-e2e:
name: Web E2E (Dev)
@@ -328,33 +275,15 @@ jobs:
run: pnpm --filter @groombook/web test:e2e
timeout-minutes: 10
- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-web-e2e-report
path: apps/web/playwright-report/
retention-days: 7
cd:
name: Update Infra Image Tags
runs-on: ubuntu-latest
needs: [docker]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
permissions:
contents: write
pull-requests: write
if: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push'
steps:
- name: Generate infra repo token
id: infra-token
uses: tibdex/github-app-token@v2
with:
app_id: ${{ vars.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Clone groombook/infra
run: |
git clone https://x-access-token:${{ steps.infra-token.outputs.token }}@github.com/groombook/infra.git /tmp/infra
git clone https://oauth2:${{ secrets.REGISTRY_TOKEN }}@git.farh.net/groombook/infra.git /tmp/infra
- name: Install yq
run: |
@@ -373,28 +302,24 @@ jobs:
echo "Updating dev overlay image tags to: $TAG"
echo "Updating migration/seed Job names with SHA: $SHORT_SHA"
cd /tmp/infra
DEV_KUST="apps/groombook/overlays/dev/kustomization.yaml"
yq -i '(.images[] | select(.name == "ghcr.io/groombook/api")).newTag = env(TAG)' "$DEV_KUST"
yq -i '(.images[] | select(.name == "ghcr.io/groombook/web")).newTag = env(TAG)' "$DEV_KUST"
yq -i '(.images[] | select(.name == "ghcr.io/groombook/migrate")).newTag = env(TAG)' "$DEV_KUST"
yq -i '(.images[] | select(.name == "ghcr.io/groombook/seed")).newTag = env(TAG)' "$DEV_KUST"
yq -i '(.images[] | select(.name == "ghcr.io/groombook/reset")).newTag = env(TAG)' "$DEV_KUST"
DEV_KUST="apps/overlays/dev/kustomization.yaml"
yq -i '(.images[] | select(.name == "git.farh.net/groombook/api")).newTag = env(TAG)' "$DEV_KUST"
yq -i '(.images[] | select(.name == "git.farh.net/groombook/web")).newTag = env(TAG)' "$DEV_KUST"
yq -i '(.images[] | select(.name == "git.farh.net/groombook/migrate")).newTag = env(TAG)' "$DEV_KUST"
yq -i '(.images[] | select(.name == "git.farh.net/groombook/seed")).newTag = env(TAG)' "$DEV_KUST"
yq -i '(.images[] | select(.name == "git.farh.net/groombook/reset")).newTag = env(TAG)' "$DEV_KUST"
# Update migrate Job name to include short SHA (immutable template fix)
MIGRATE_JOB="apps/groombook/base/migrate-job.yaml"
MIGRATE_JOB="apps/base/migrate-job.yaml"
if [ -f "$MIGRATE_JOB" ]; then
yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB"
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB"
# Ensure ttlSecondsAfterFinished is set for automatic cleanup
yq -i '.spec.ttlSecondsAfterFinished = (.spec.ttlSecondsAfterFinished // 86400)' "$MIGRATE_JOB"
fi
# Update seed Job name to include short SHA (immutable template fix)
SEED_JOB="apps/groombook/base/seed-job.yaml"
SEED_JOB="apps/base/seed-job.yaml"
if [ -f "$SEED_JOB" ]; then
yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB"
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB"
# Ensure ttlSecondsAfterFinished is set for automatic cleanup
yq -i '.spec.ttlSecondsAfterFinished = (.spec.ttlSecondsAfterFinished // 86400)' "$SEED_JOB"
fi
@@ -403,7 +328,6 @@ jobs:
- name: Create PR on groombook/infra
env:
TAG: ${{ needs.docker.outputs.tag }}
GH_TOKEN: ${{ steps.infra-token.outputs.token }}
run: |
if [ -z "$TAG" ]; then
TAG="$(date -u +%Y.%m.%d)-${GITHUB_SHA::7}"
@@ -411,24 +335,35 @@ jobs:
cd /tmp/infra
git config user.name "groombook-engineer[bot]"
git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com"
git config user.email "groombook-engineer@farh.net"
git checkout -b "chore/update-image-tags-${TAG}"
git add apps/groombook/overlays/dev/ apps/groombook/base/migrate-job.yaml apps/groombook/base/seed-job.yaml
git add apps/overlays/dev/ apps/base/migrate-job.yaml apps/base/seed-job.yaml
git commit -m "chore: update image tags and migration/seed Job names to ${TAG}"
git push -u origin "chore/update-image-tags-${TAG}"
# Check if PR already exists for this branch
EXISTING_PR=$(gh pr list --repo groombook/infra --head "chore/update-image-tags-${TAG}" --state open --json number -q '.[0].number' || true)
if [ -n "$EXISTING_PR" ]; then
EXISTING_PR=$(curl -s "https://git.farh.net/api/v1/repos/groombook/infra/pulls?state=open&head=groombook:chore/update-image-tags-${TAG}" \
-H "Authorization: Bearer ${{ secrets.REGISTRY_TOKEN }}" | jq -r '.[0].number')
if [ -n "$EXISTING_PR" ] && [ "$EXISTING_PR" != "null" ]; then
echo "PR #$EXISTING_PR already exists for this tag, merging existing PR"
gh pr merge "$EXISTING_PR" --repo groombook/infra --merge
curl -s -X PUT "https://git.farh.net/api/v1/repos/groombook/infra/pulls/${EXISTING_PR}/merge" \
-H "Authorization: Bearer ${{ secrets.REGISTRY_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{"do": "merge"}'
else
PR_URL=$(gh pr create \
--repo groombook/infra \
--base main \
--head "chore/update-image-tags-${TAG}" \
--title "chore: deploy ${TAG} to dev" \
--body "[GRO-178](/GRO/issues/GRO-178) — automated image tag update from main merge")
gh pr merge "$PR_URL" --merge
PR_RESPONSE=$(curl -s -X POST "https://git.farh.net/api/v1/repos/groombook/infra/pulls" \
-H "Authorization: Bearer ${{ secrets.REGISTRY_TOKEN }}" \
-H "Content-Type: application/json" \
-d "{
\"base\": \"main\",
\"head\": \"chore/update-image-tags-${TAG}\",
\"title\": \"chore: deploy ${TAG} to dev\",
\"body\": \"[GRO-178](/GRO/issues/GRO-178) — automated image tag update from main merge\"
}")
PR_NUM=$(echo "$PR_RESPONSE" | jq -r '.number')
echo "Created PR #$PR_NUM"
curl -s -X PUT "https://git.farh.net/api/v1/repos/groombook/infra/pulls/${PR_NUM}/merge" \
-H "Authorization: Bearer ${{ secrets.REGISTRY_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{"do": "merge"}'
fi
+4 -4
View File
@@ -58,7 +58,7 @@ jobs:
TAG: ${{ inputs.tag }}
run: |
cd /tmp/infra
PROD_KUST="apps/groombook/overlays/prod/kustomization.yaml"
PROD_KUST="apps/overlays/prod/kustomization.yaml"
SHORT_SHA="${TAG##*-}"
export SHORT_SHA
@@ -70,14 +70,14 @@ jobs:
yq -i '(.images[] | select(.name == "ghcr.io/groombook/seed")).newTag = env(TAG)' "$PROD_KUST"
# Update migrate Job name to include short SHA (immutable template fix)
MIGRATE_JOB="apps/groombook/base/migrate-job.yaml"
MIGRATE_JOB="apps/base/migrate-job.yaml"
if [ -f "$MIGRATE_JOB" ]; then
yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB"
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB"
fi
# Update seed Job name to include short SHA (immutable template fix)
SEED_JOB="apps/groombook/base/seed-job.yaml"
SEED_JOB="apps/base/seed-job.yaml"
if [ -f "$SEED_JOB" ]; then
yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB"
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB"
@@ -94,7 +94,7 @@ jobs:
git config user.name "groombook-engineer[bot]"
git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com"
git checkout -b "release/promote-prod-${TAG}"
git add apps/groombook/overlays/prod/ apps/groombook/base/migrate-job.yaml apps/groombook/base/seed-job.yaml
git add apps/overlays/prod/ apps/base/migrate-job.yaml apps/base/seed-job.yaml
git commit -m "release: promote ${TAG} to production"
git push -u origin "release/promote-prod-${TAG}"
gh pr create \
+4 -4
View File
@@ -38,7 +38,7 @@ jobs:
run: |
echo "Updating UAT overlay image tags to: $TAG"
cd /tmp/infra
UAT_KUST="apps/groombook/overlays/uat/kustomization.yaml"
UAT_KUST="apps/overlays/uat/kustomization.yaml"
if [ ! -f "$UAT_KUST" ]; then
echo "ERROR: UAT overlay not found at $UAT_KUST. Ensure GRO-427 has been completed."
@@ -55,7 +55,7 @@ jobs:
yq -i '(.images[] | select(.name == "ghcr.io/groombook/seed")).newTag = env(TAG)' "$UAT_KUST"
# Update migrate Job name to include short SHA (immutable template fix)
MIGRATE_JOB="apps/groombook/base/migrate-job.yaml"
MIGRATE_JOB="apps/base/migrate-job.yaml"
if [ -f "$MIGRATE_JOB" ]; then
yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB"
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB"
@@ -64,7 +64,7 @@ jobs:
# Update seed Job name to include short SHA (immutable template fix)
# NOTE: Do NOT update the image tag here — let the Kustomize images transformer
# in the UAT overlay handle it via newTag. This avoids the immutable template issue.
SEED_JOB="apps/groombook/base/seed-job.yaml"
SEED_JOB="apps/base/seed-job.yaml"
if [ -f "$SEED_JOB" ]; then
yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB"
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB"
@@ -81,7 +81,7 @@ jobs:
git config user.name "groombook-engineer[bot]"
git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com"
git checkout -b "chore/update-uat-image-tags-${TAG}"
git add apps/groombook/overlays/uat/ apps/groombook/base/migrate-job.yaml apps/groombook/base/seed-job.yaml
git add apps/overlays/uat/ apps/base/migrate-job.yaml apps/base/seed-job.yaml
git commit -m "chore: promote ${TAG} to UAT"
git push -u origin "chore/update-uat-image-tags-${TAG}"
+50
View File
@@ -0,0 +1,50 @@
# Shedward Scissorhands — UAT Agent Instructions
You are the GroomBook User Acceptance Tester. Your sole job is to execute UAT playbooks against deployed environments and report results.
## Mandatory Tooling
You MUST use the **groombook-playwright MCP server** (`mcp__playwright-groombook__*` tools) for ALL browser interaction. Do not:
- Run scripted Playwright suites (`npx playwright test`, `pnpm test:e2e`, etc.)
- Use manual browser commands or shell-based browser automation
- Open browsers outside the MCP server
Every page navigation, click, form fill, and verification MUST go through MCP tools.
## Available MCP Tools
| Tool | When to use |
|------|-------------|
| `browser_navigate` | Open a URL |
| `browser_snapshot` | Read page state (preferred over screenshot for assertions) |
| `browser_take_screenshot` | Capture visual evidence |
| `browser_click` | Click an element (use ref from snapshot) |
| `browser_fill_form` | Fill form fields |
| `browser_type` | Type text into focused element |
| `browser_press_key` | Press keyboard keys |
| `browser_select_option` | Select dropdown options |
| `browser_hover` | Hover over elements |
| `browser_wait_for` | Wait for elements or navigation |
| `browser_console_messages` | Check for JS errors |
| `browser_network_requests` | Inspect API calls |
| `browser_evaluate` | Run JS in page context |
| `browser_resize` | Test responsive layouts |
| `browser_close` | Close browser session |
## Execution Workflow
1. Read the `UAT_PLAYBOOK.md` in the repo being tested.
2. For each test case, translate the human-readable steps into MCP tool calls.
3. Capture evidence: use `browser_snapshot` for assertions, `browser_take_screenshot` for visual proof.
4. Report pass/fail per test case with evidence.
5. If a test fails, document: severity, steps to reproduce, actual vs expected, and attach screenshots.
## Environments
| Environment | URL | Auth |
|-------------|-----|------|
| Dev | `https://dev.groombook.dev` | Dev login selector (no OIDC) |
| UAT | `https://uat.groombook.dev` | Authentik OIDC at `https://auth.farh.net` |
| Production | `https://demo.groombook.dev` | Authentik OIDC |
| Site | `https://groombook.farh.net` | No auth required |
+94 -12
View File
@@ -4,7 +4,49 @@
GroomBook is an open-source, self-hostable pet grooming business management & CRM platform. The monorepo contains the Hono API (`apps/api`), React PWA web app (`apps/web`), E2E tests (`apps/e2e`), and shared packages (`packages/db`, `packages/types`). Tech stack: Hono + React 19 + Vite + PostgreSQL + Drizzle ORM + Authentik OIDC.
## 2. Environments
## 2. Execution Method
All UAT is executed by **Shedward Scissorhands** via the **groombook-playwright MCP server**. No manual browser checks or scripted Playwright suites are used for UAT.
### MCP Tools
Shedward uses the `mcp__playwright-groombook__*` tool family:
| Tool | Purpose |
|------|---------|
| `browser_navigate` | Navigate to a URL |
| `browser_snapshot` | Capture accessibility snapshot (preferred over screenshot) |
| `browser_take_screenshot` | Capture visual screenshot when needed |
| `browser_click` | Click an element by ref or selector |
| `browser_fill_form` | Fill form fields |
| `browser_type` | Type text into focused element |
| `browser_press_key` | Press keyboard keys (Enter, Tab, etc.) |
| `browser_select_option` | Select dropdown options |
| `browser_hover` | Hover over elements |
| `browser_wait_for` | Wait for elements or conditions |
| `browser_console_messages` | Check console for errors |
| `browser_network_requests` | Inspect network traffic |
| `browser_evaluate` | Run JavaScript in page context |
| `browser_tabs` | Manage browser tabs |
| `browser_close` | Close browser |
### How Test Cases Map to MCP Calls
Each test case in Section 4 describes steps like "Navigate to X" or "Click Y". Shedward translates these to MCP tool calls:
- **"Navigate to [URL]"** → `browser_navigate` with the environment URL
- **"Click [element]"** → `browser_snapshot` to find the element ref, then `browser_click`
- **"Fill in [field]"** → `browser_fill_form` or `browser_click` + `browser_type`
- **"Verify [state]"** → `browser_snapshot` and inspect the accessibility tree
- **"Check for errors"** → `browser_console_messages` + `browser_snapshot`
Shedward reads this playbook, executes each test case via MCP tools, captures evidence (snapshots/screenshots), and reports pass/fail per test case.
### Legacy CI Tests
The scripted Playwright suites in `apps/e2e/` and `apps/web/e2e/` are retained for CI regression testing only. They are **not** the primary UAT mechanism. UAT is exclusively MCP-driven by Shedward.
## 3. Environments
| Environment | URL | Notes |
|-------------|-----|-------|
@@ -14,7 +56,7 @@ GroomBook is an open-source, self-hostable pet grooming business management & CR
**Local Development:** Run `docker compose up --build` at repository root. Web app available at `localhost:8080`, API at `localhost:3000`.
## 3. Pre-conditions
## 4. Pre-conditions
- UAT environment is accessible at `https://uat.groombook.dev`
- Test accounts are seeded with the following personas:
@@ -29,18 +71,23 @@ GroomBook is an open-source, self-hostable pet grooming business management & CR
- Stripe test keys are configured for payment flow testing
- Email/SMS providers (Telnyx, etc.) are configured for notification testing
## 4. Test Cases
## 5. Test Cases
### 4.1 Authentication
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.1.1 | OIDC login | 1. Navigate to UAT environment<br>2. Click "Login with Authentik"<br>3. Enter test credentials<br>4. Authorize the application | User is redirected to app dashboard, session is established |
| TC-APP-4.1.2 | Session persistence | 1. Log in as any user<br>2. Close browser tab<br>3. Reopen browser and navigate to UAT | User remains logged in, no re-authentication required |
| TC-APP-4.1.3 | Logout | 1. Log in as any user<br>2. Click logout button<br>3. Attempt to access protected route | User is logged out and redirected to login page |
| TC-APP-4.1.4 | RBAC - Manager access | 1. Log in as Manager<br>2. Navigate to Settings, Staff Management, Reports | All administrative features are accessible |
| TC-APP-4.1.5 | RBAC - Staff access | 1. Log in as Staff<br>2. Attempt to access Settings, Staff Management | Access denied or limited view, staff can only see assigned appointments |
| TC-APP-4.1.6 | RBAC - Client access | 1. Log in as Client<br>2. Navigate to portal<br>3. Attempt to access admin areas | Client can only view their own appointments, pets, and profile |
| TC-APP-4.1.1 | OIDC login (Authentik) | 1. Navigate to UAT environment<br>2. Click "Login with Authentik"<br>3. Enter test credentials<br>4. Authorize the application | User is redirected to app dashboard, session is established |
| TC-APP-4.1.2 | Email + password login (UAT Super) | 1. Navigate to UAT environment sign-in page<br>2. Select email+password flow<br>3. Enter `uat-super@groombook.dev` and UAT super password<br>4. Submit | User is logged in and redirected to dashboard with manager access |
| TC-APP-4.1.3 | Email + password login (UAT Groomer) | 1. Navigate to UAT environment sign-in page<br>2. Select email+password flow<br>3. Enter `uat-groomer@groombook.dev` and UAT groomer password<br>4. Submit | User is logged in and redirected to dashboard with staff/groomer access |
| TC-APP-4.1.4 | Email + password login (UAT Customer) | 1. Navigate to UAT environment sign-in page<br>2. Select email+password flow<br>3. Enter `uat-customer@groombook.dev` and UAT customer password<br>4. Submit | User is logged in with client portal access |
| TC-APP-4.1.5 | Email + password login (UAT Tester) | 1. Navigate to UAT environment sign-in page<br>2. Select email+password flow<br>3. Enter `uat-tester@groombook.dev` and UAT tester password<br>4. Submit | User is logged in with staff/tester access |
| TC-APP-4.1.6 | Session persistence | 1. Log in as any user<br>2. Close browser tab<br>3. Reopen browser and navigate to UAT | User remains logged in, no re-authentication required |
| TC-APP-4.1.7 | Logout | 1. Log in as any user<br>2. Click logout button<br>3. Attempt to access protected route | User is logged out and redirected to login page |
| TC-APP-4.1.8 | RBAC - Manager access | 1. Log in as Manager (OIDC or email+password)<br>2. Navigate to Settings, Staff Management, Reports | All administrative features are accessible |
| TC-APP-4.1.9 | RBAC - Staff access | 1. Log in as Staff (OIDC or email+password)<br>2. Attempt to access Settings, Staff Management | Access denied or limited view, staff can only see assigned appointments |
| TC-APP-4.1.10 | RBAC - Client access | 1. Log in as Client (email+password)<br>2. Navigate to portal<br>3. Attempt to access admin areas | Client can only view their own appointments, pets, and profile |
| TC-APP-4.1.11 | Login after hourly reset | 1. Wait for or trigger `reset-demo-data` CronJob to run<br>2. Attempt email+password login as any UAT persona | Login succeeds — Better Auth credential accounts survive the reset cycle |
### 4.2 Setup Wizard / OOBE
@@ -78,6 +125,13 @@ GroomBook is an open-source, self-hostable pet grooming business management & CR
| TC-APP-4.5.4 | Calendar view (day/week/month) | 1. Navigate to Calendar<br>2. Switch between day, week, and month views | Calendar displays appointments in selected time range correctly |
| TC-APP-4.5.5 | Appointment groups | 1. Create multiple appointments for same time slot<br>2. View in calendar | Appointments are grouped/linked appropriately |
| TC-APP-4.5.6 | Appointment availability check | 1. Attempt to book appointment during unavailable slot | System shows conflict or prevents double-booking |
| TC-APP-4.5.7 | Booking wizard — size/coat selection | 1. Start new appointment booking wizard<br>2. Select a pet with sizeCategory and coatType set<br>3. Observe the service/slot selection step | Size and coat type dropdowns are displayed and persist the pet's existing values |
| TC-APP-4.5.8 | Large/Xlarge pet slot duration reflects buffer | 1. Add a pet with sizeCategory = "large" or "xlarge" to an appointment<br>2. Note the service duration<br>3. Complete booking and inspect the appointment | Appointment slot includes the service duration plus the configured buffer for the pet's size category |
| TC-APP-4.5.9 | Appointment overrun cascades downstream | 1. Book three consecutive same-groomer appointments (A → B → C)<br>2. Manually extend appointment A's endTime so it overlaps B's startTime by ≥15 min<br>3. Observe appointment B | Appointment B (and C if still overlapping) is automatically shifted forward by the overrun delta + buffer; no error thrown |
| TC-APP-4.5.10 | Cascaded appointments appear at new times | 1. Complete TC-APP-4.5.9<br>2. Check the calendar/list view | Appointments B and C are now shown at their shifted start/end times |
| TC-APP-4.5.11 | Client receives reschedule notification email | 1. Complete TC-APP-4.5.9<br>2. Check the client's email (or notification log) | Client receives an email with subject/lines indicating their appointment was rescheduled from original time to new time |
| TC-APP-4.5.12 | Appointment flagged when shift crosses day boundary | 1. Book appointment D for late afternoon (e.g. 17:30)<br>2. Extend a prior appointment so D would shift to the next day<br>3. Observe D | Appointment D is flagged for manual review and is NOT auto-shifted to the next day |
| TC-APP-4.5.13 | Only scheduled/confirmed appointments are cascaded | 1. Start a cascade scenario (TC-APP-4.5.9) where a downstream appointment is already `in_progress`<br>2. Complete the cascade | The `in_progress` appointment is not shifted; cascade continues to next eligible appointment |
### 4.6 Services
@@ -217,7 +271,35 @@ GroomBook is an open-source, self-hostable pet grooming business management & CR
| TC-APP-4.19.3 | Empty states | 1. Navigate to pages with no data (empty calendar, no clients)<br>2. Verify UI | Helpful empty state message with call-to-action displayed |
| TC-APP-4.19.4 | Network error handling | 1. Disable network in DevTools<br>2. Attempt actions that require API calls<br>3. Re-enable network | Appropriate error message shown, app recovers when network restored |
## 5. Pass/Fail Criteria
### 4.20 Staff Messages
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.20.1 | Staff messages inbox loads | 1. Log in as Staff<br>2. Navigate to Messages | Conversation list renders with client phone and last message preview |
| TC-APP-4.20.2 | Open conversation | 1. Select a conversation from the list | Full message thread loads chronologically |
| TC-APP-4.20.3 | Send message | 1. Type a reply and submit | Message appears in thread; POST /api/conversations/:id/messages succeeds |
| TC-APP-4.20.4 | Empty state | 1. Log in as Staff with no conversations | Empty state shown; no crash |
| TC-APP-4.20.5 | Unread indicator | 1. Client sends a new message | Thread marked unread until staff views it |
| TC-APP-4.20.6 | Cross-tenant isolation | 1. Staff from Business A attempts to read Business B conversations | 403 or empty response returned |
### 4.21 SMS Consent (STOP/HELP Keyword Handler)
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.21.1 | STOP → unsubscribe + auto-reply | 1. Send `STOP` (case-insensitive, with whitespace) from a subscribed client's phone number | Client is opted out (`smsOptIn=false`, `smsOptOutDate` set), event is logged, user receives auto-reply: "You have been unsubscribed and will no longer receive messages. Reply START to resubscribe." |
| TC-APP-4.21.2 | START → resubscribe + auto-reply | 1. Send `START` (case-insensitive) from an opted-out client's phone number | Client is opted back in (`smsOptIn=true`, `smsConsentDate` updated, `smsOptOutDate` cleared), event is logged, user receives auto-reply: "You have been resubscribed to messages. Reply STOP to unsubscribe. Msg & data rates may apply." |
| TC-APP-4.21.3 | HELP → no opt-in change + default reply | 1. Send `HELP` (case-insensitive) from any client's phone number | No change to opt-in state, no database update, event is logged, user receives auto-reply: "Reply STOP to unsubscribe or START to resubscribe. For help, contact your groomer directly." |
| TC-APP-4.21.4 | STOPALL / UNSUBSCRIBE / CANCEL / END / QUIT → opt-out | 1. Send each alias from a subscribed client's phone | Same behaviour as STOP: opt-out applied, correct reply sent |
| TC-APP-4.21.5 | UNSTOP / YES / SUBSCRIBE → opt-in | 1. Send each alias from an opted-out client's phone | Same behaviour as START: opt-in applied, correct reply sent |
| TC-APP-4.21.6 | INFO → help reply | 1. Send `INFO` from any client's phone | Same behaviour as HELP: no state change, help reply returned |
| TC-APP-4.21.7 | Double STOP (idempotency) | 1. Send `STOP` from an already-opted-out client | Event is logged, no update call made, idempotent — no duplicate update |
| TC-APP-4.21.8 | Double START (idempotency) | 1. Send `START` from an already-subscribed client | Event is logged, no update call made, idempotent — no duplicate update |
| TC-APP-4.21.9 | Case insensitivity | 1. Send `stop`, `Stop`, `sToP`, ` stop ` from subscribed client | All variants are detected and handled as opt-out |
| TC-APP-4.21.10 | Whitespace trimming | 1. Send ` START ` or `\tSTOP\n` | Keywords are trimmed before matching |
| TC-APP-4.21.11 | Non-keyword messages ignored | 1. Send `STOP IT`, `help me`, `hello` | Returns null from `detectKeyword`, no consent event inserted, no reply sent |
| TC-APP-4.21.12 | Consent event audit log | 1. After any keyword, query `messageConsentEvents` table | Record exists with correct `clientId`, `businessId`, `kind`, and `source: "sms_keyword"` |
## 6. Pass/Fail Criteria
**Pass:** All test cases execute without errors. Expected results match actual results. No regressions are observed. All functionality works as documented.
@@ -230,7 +312,7 @@ GroomBook is an open-source, self-hostable pet grooming business management & CR
**Regressions:** If a previously working feature fails during this UAT run, it is considered a regression and must be addressed before the release can proceed.
## 6. Update Policy
## 7. Update Policy
**Any PR that changes user-facing behaviour MUST update this file.**
@@ -240,4 +322,4 @@ When modifying features that affect:
- Configuration (settings, integrations)
- Data visibility (reports, search, filtering)
The corresponding test case(s) in Section 4 must be updated to reflect the new behaviour. The PR description must reference which playbook section was updated (e.g., "Updated UAT_PLAYBOOK.md §4.5 — new appointment group scheduling feature").
The corresponding test case(s) in Section 5 must be updated to reflect the new behaviour. The PR description must reference which playbook section was updated (e.g., "Updated UAT_PLAYBOOK.md §4.5 — new appointment group scheduling feature").
@@ -0,0 +1,318 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
// ─── Mock data ────────────────────────────────────────────────────────────────
const STAFF_ROW = {
id: "staff-uuid-1",
email: "groomer@groombook.com",
name: "Groomer",
role: "groomer" as const,
businessId: "business-uuid-1",
active: true,
userId: null,
oidcSub: null,
isSuperUser: false,
icalToken: null,
createdAt: new Date(),
updatedAt: new Date(),
};
const BUSINESS_SETTINGS = {
id: "business-uuid-1",
businessName: "Test Salon",
};
const CONV_1 = {
id: "conv-uuid-1",
businessId: "business-uuid-1",
clientId: "client-uuid-1",
channel: "sms",
externalNumber: "+15551111111",
businessNumber: "+15552222222",
lastMessageAt: new Date("2025-01-10T10:00:00Z"),
status: "active",
createdAt: new Date("2025-01-01T00:00:00Z"),
updatedAt: new Date("2025-01-10T10:00:00Z"),
staffReadAt: null,
};
const MSG_INBOUND_1 = {
id: "msg-uuid-1",
conversationId: "conv-uuid-1",
direction: "inbound",
body: "Hello",
status: "delivered",
sentByStaffId: null,
createdAt: new Date("2025-01-10T09:00:00Z"),
deliveredAt: new Date("2025-01-10T09:01:00Z"),
};
const MSG_OUTBOUND_1 = {
id: "msg-uuid-2",
conversationId: "conv-uuid-1",
direction: "outbound",
body: "Hi Alice!",
status: "delivered",
sentByStaffId: "staff-uuid-1",
createdAt: new Date("2025-01-10T10:00:00Z"),
deliveredAt: new Date("2025-01-10T10:01:00Z"),
};
// ─── Queue-based mock DB ──────────────────────────────────────────────────────
let selectRows: Record<string, unknown>[] = [];
let selectRows2: Record<string, unknown>[] = [];
let selectRows3: Record<string, unknown>[] = [];
let updatedValues: Record<string, unknown>[] = [];
let selectCallCount = 0;
function resetMock() {
selectRows = [];
selectRows2 = [];
selectRows3 = [];
updatedValues = [];
selectCallCount = 0;
}
function resetAll() {
resetMock();
vi.clearAllMocks();
}
const mockSendMessage = vi.hoisted(() => vi.fn());
vi.mock("@groombook/db", () => {
function makeChainable(data: unknown[]): unknown {
const arr = [...data];
const chain = new Proxy(arr, {
get(target, prop) {
if (prop === "where" || prop === "orderBy" || prop === "limit" || prop === "innerJoin") {
return () => chain;
}
if (prop === "from") {
return (table: unknown) => {
const tableName = (table as { _name?: string })._name;
const rows = tableName === "businessSettings" ? [BUSINESS_SETTINGS] : selectRows;
return makeChainable(rows);
};
}
// @ts-expect-error proxy
return target[prop];
},
});
return chain;
}
const conversations = new Proxy(
{ _name: "conversations" },
{ get: (t, p) => (p === "_name" ? "conversations" : { table: "conversations", column: p }) }
);
const messages = new Proxy(
{ _name: "messages" },
{ get: (t, p) => (p === "_name" ? "messages" : { table: "messages", column: p }) }
);
const clients = new Proxy(
{ _name: "clients" },
{ get: (t, p) => (p === "_name" ? "clients" : { table: "clients", column: p }) }
);
const businessSettings = new Proxy(
{ _name: "businessSettings" },
{ get: (t, p) => (p === "_name" ? "businessSettings" : { table: "businessSettings", column: p }) }
);
return {
getDb: () => ({
select: () => ({
from: (table: unknown) => {
const tableName = (table as { _name?: string })._name;
if (tableName === "businessSettings") return makeChainable([BUSINESS_SETTINGS]);
if (tableName === "messages") {
// Return selectRows3 if it has data (POST re-query), else cycle through selectRows/selectRows2
if (selectRows3.length > 0) {
return makeChainable(selectRows3);
}
if (selectCallCount === 0 || selectCallCount === 1) {
const rows = selectCallCount === 0 ? selectRows : selectRows2;
selectCallCount++;
return makeChainable(rows);
}
return makeChainable(selectRows);
}
return makeChainable(selectRows);
},
}),
update: () => ({
set: (vals: Record<string, unknown>) => ({
where: () => {
updatedValues.push(vals);
return { returning: () => [vals] };
},
}),
}),
insert: () => ({
values: (vals: Record<string, unknown>) => {
return { returning: () => [{ ...vals, id: "msg-uuid-new" }] };
},
}),
}),
conversations,
messages,
clients,
businessSettings,
eq: vi.fn((a, b) => ({ type: "eq", a, b })),
and: vi.fn((...args) => ({ type: "and", args })),
desc: vi.fn((col) => ({ type: "desc", col })),
lt: vi.fn((a, b) => ({ type: "lt", a, b })),
sql: vi.fn(() => ({ __type: "sql" })),
isNull: vi.fn((col) => ({ type: "isNull", col })),
count: vi.fn((col) => ({ type: "count", col })),
};
});
vi.mock("../services/messaging/outbound.js", () => ({
sendMessage: mockSendMessage,
}));
// ─── App setup ────────────────────────────────────────────────────────────────
const { conversationsRouter } = await import("../routes/conversations.js");
const app = new Hono();
app.use("*", async (c, next) => {
// @ts-expect-error — test-only context injection
c.set("staff", STAFF_ROW);
await next();
});
app.route("/conversations", conversationsRouter);
function jsonRequest(method: string, path: string, body?: unknown) {
return app.request(path, {
method,
headers: { "Content-Type": "application/json" },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
}
beforeEach(() => resetAll());
// ─── GET /conversations ───────────────────────────────────────────────────────
describe("GET /api/conversations", () => {
it("returns conversations sorted by recency with unread count", async () => {
selectRows = [
{ ...CONV_1, clientName: "Alice", clientPhone: "+15551111111", channel: "sms" },
];
selectRows2 = [{ count: "1" }];
const res = await app.request("/conversations");
expect(res.status).toBe(200);
const body = await res.json();
expect(body.items).toHaveLength(1);
expect(body.items[0]!.id).toBe("conv-uuid-1");
expect(body.items[0]!.clientName).toBe("Alice");
});
it("supports cursor-based pagination", async () => {
selectRows = [];
const res = await app.request("/conversations?cursor=conv-uuid-1&limit=1");
expect(res.status).toBe(200);
});
it("enforces max limit of 50", async () => {
selectRows = [];
const res = await app.request("/conversations?limit=200");
expect(res.status).toBe(200);
});
});
// ─── GET /conversations/:id/messages ─────────────────────────────────────────
describe("GET /api/conversations/:id/messages", () => {
it("returns paginated messages and marks conversation as read", async () => {
selectRows = [{ ...MSG_INBOUND_1 }, { ...MSG_OUTBOUND_1 }];
const res = await app.request("/conversations/conv-uuid-1/messages");
expect(res.status).toBe(200);
const body = await res.json();
expect(body.items).toHaveLength(2);
expect(body.items[0]!.id).toBe("msg-uuid-1");
expect(updatedValues.some((u) => u.staffReadAt !== undefined)).toBe(true);
});
it("returns 404 when conversation belongs to different business", async () => {
selectRows = [];
const res = await app.request("/conversations/conv-uuid-other/messages");
expect(res.status).toBe(404);
});
it("returns 401 when not authenticated", async () => {
const appNoAuth = new Hono();
appNoAuth.route("/conversations", conversationsRouter);
const res = await appNoAuth.request("/conversations/conv-uuid-1/messages");
expect(res.status).toBe(401);
});
});
// ─── POST /conversations/:id/messages ─────────────────────────────────────────
describe("POST /api/conversations/:id/messages", () => {
beforeEach(() => {
resetMock();
vi.clearAllMocks();
selectRows = [{ ...CONV_1, clientName: "Alice", clientPhone: "+15551111111", channel: "sms" }];
selectRows2 = [];
selectRows3 = [{ id: "msg-uuid-new", conversationId: "conv-uuid-1", direction: "outbound" as const, body: "Hello Alice!", status: "queued" as const, sentByStaffId: "staff-uuid-1", createdAt: new Date(), deliveredAt: null }];
updatedValues = [];
});
it("sends via outbound service and returns 201", async () => {
mockSendMessage.mockResolvedValueOnce({
messageId: "msg-uuid-new",
providerMessageId: "provider-msg-1",
status: "queued",
suppressed: false,
});
const res = await jsonRequest("POST", "/conversations/conv-uuid-1/messages", {
body: "Hello Alice!",
});
expect(res.status).toBe(201);
const body = await res.json();
expect(body.id).toBe("msg-uuid-new");
});
it("returns 409 when client opted out", async () => {
mockSendMessage.mockResolvedValueOnce({ suppressed: true });
const res = await jsonRequest("POST", "/conversations/conv-uuid-1/messages", {
body: "Hello",
});
expect(res.status).toBe(409);
const body = await res.json();
expect(body.error).toMatch(/opted out/i);
});
it("returns 404 for cross-tenant conversation", async () => {
selectRows = [];
const res = await jsonRequest("POST", "/conversations/conv-uuid-other/messages", {
body: "Hello",
});
expect(res.status).toBe(404);
});
it("rejects empty body", async () => {
const res = await jsonRequest("POST", "/conversations/conv-uuid-1/messages", {
body: "",
});
expect(res.status).toBe(400);
});
it("rejects body over 1600 chars", async () => {
const res = await jsonRequest("POST", "/conversations/conv-uuid-1/messages", {
body: "a".repeat(1601),
});
expect(res.status).toBe(400);
});
});
+6 -2
View File
@@ -78,7 +78,7 @@ vi.mock("@groombook/db", () => {
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
);
const businessSettings = new Proxy(
const businessSettings = new Proxy(
{ _name: "businessSettings" },
{ get: (t, p) => (p === "_name" ? "businessSettings" : { table: "businessSettings", column: p }) }
);
@@ -134,6 +134,11 @@ vi.mock("@groombook/db", () => {
}),
}),
}),
insert: () => ({
values: () => ({
returning: () => [],
}),
}),
}),
impersonationSessions,
appointments,
@@ -143,7 +148,6 @@ vi.mock("@groombook/db", () => {
messages,
eq: vi.fn(),
and: vi.fn(),
lt: vi.fn(),
desc: vi.fn((col: unknown) => ({ _name: "desc", col })),
};
});
+1
View File
@@ -275,6 +275,7 @@ api.route("/admin/settings", settingsRouter);
api.route("/admin/auth-provider", authProviderRouter);
api.route("/admin/seed", adminSeedRouter);
api.route("/search", searchRouter);
api.route("/conversations", conversationsRouter);
const port = Number(process.env.PORT ?? 3000);
await initAuth();
+281
View File
@@ -0,0 +1,281 @@
import { eq, and, gt, or, asc } from "@groombook/db";
import { appointments, clients, pets, services, staff, type Db } from "@groombook/db";
import { resolveBufferMinutes } from "./buffer.js";
import { sendEmail, buildRescheduleNotificationEmail } from "../services/email.js";
export interface CascadeResult {
shifted: ShiftedAppointment[];
flaggedForReview: FlaggedAppointment[];
}
export interface ShiftedAppointment {
id: string;
oldStartTime: Date;
oldEndTime: Date;
newStartTime: Date;
newEndTime: Date;
shiftDeltaMs: number;
}
export interface FlaggedAppointment {
id: string;
reason: string;
requestedStartTime: Date;
requestedEndTime: Date;
}
interface AppointmentWithGroomer {
id: string;
clientId: string;
petId: string;
serviceId: string;
staffId: string | null;
batherStaffId: string | null;
status: string;
startTime: Date;
endTime: Date;
bufferMinutes: number;
}
/**
* Detects and cascades appointment overruns to downstream same-groomer appointments.
*
* Trigger conditions:
* - PATCH extends endTime beyond the original endTime
* - Status transitions where current time exceeds endTime + bufferMinutes
*
* Guard rails:
* - Only shifts `scheduled` and `confirmed` appointments
* - Skips `in_progress`, `completed`, `cancelled`, `no_show`
* - Flags appointments that would fall outside business hours for manual review
*/
export async function detectAndCascadeOverrun({
db,
overrunningAppointmentId,
newEndTime,
_originalEndTime,
}: {
db: Db;
overrunningAppointmentId: string;
newEndTime: Date;
_originalEndTime: Date;
}): Promise<CascadeResult> {
const result: CascadeResult = { shifted: [], flaggedForReview: [] };
// Fetch the overrunning appointment to get groomer/staff info
const [overrunning] = await db
.select()
.from(appointments)
.where(eq(appointments.id, overrunningAppointmentId))
.limit(1);
if (!overrunning) return result;
const groomerId = overrunning.staffId;
if (!groomerId) return result;
// Determine the effective buffer for the overrunning appointment
const bufferMinutes = await resolveBufferMinutesForAppointment(db, overrunning);
const overrunEnd = newEndTime;
const effectiveEnd = new Date(overrunEnd.getTime() + bufferMinutes * 60_000);
// Query same-groomer appointments that start AFTER the overrunning appointment ends
// and are ordered by startTime ASC (nearest first)
const downstreamAppointments = await db
.select()
.from(appointments)
.where(
and(
eq(appointments.staffId, groomerId),
gt(appointments.startTime, overrunning.endTime),
or(
eq(appointments.status, "scheduled"),
eq(appointments.status, "confirmed")
)
)
)
.orderBy(asc(appointments.startTime));
// Track which appointments have been processed to avoid double-processing in cascade
const processedIds = new Set<string>();
processedIds.add(overrunningAppointmentId);
let currentOverrunEnd = effectiveEnd;
for (const downstream of downstreamAppointments) {
if (processedIds.has(downstream.id)) continue;
const downstreamBuffer = await resolveBufferMinutesForAppointment(db, downstream);
// Check if this downstream appointment conflicts with the current overrun end
const conflictThreshold = new Date(
currentOverrunEnd.getTime() + downstreamBuffer * 60_000
);
if (conflictThreshold <= downstream.startTime) {
// No conflict — cascade is complete
break;
}
// Conflict detected — need to shift this appointment
const shiftDeltaMs = conflictThreshold.getTime() - downstream.startTime.getTime();
const newStartTime = new Date(downstream.startTime.getTime() + shiftDeltaMs);
const newEndTime = new Date(downstream.endTime.getTime() + shiftDeltaMs);
// Check business hours (simple: only shift within same calendar day window for now)
// A more sophisticated implementation would check actual business hours from businessSettings
const isSameDay =
newStartTime.toDateString() === downstream.startTime.toDateString();
if (!isSameDay) {
result.flaggedForReview.push({
id: downstream.id,
reason: `Shifted appointment would fall on a different day (${newStartTime.toDateString()})`,
requestedStartTime: newStartTime,
requestedEndTime: newEndTime,
});
// Continue cascade check — we still process downstream appointments
currentOverrunEnd = newEndTime;
processedIds.add(downstream.id);
continue;
}
// Apply the shift
await db
.update(appointments)
.set({
startTime: newStartTime,
endTime: newEndTime,
updatedAt: new Date(),
})
.where(eq(appointments.id, downstream.id));
result.shifted.push({
id: downstream.id,
oldStartTime: downstream.startTime,
oldEndTime: downstream.endTime,
newStartTime,
newEndTime,
shiftDeltaMs,
});
// Update current overrun end for next iteration
currentOverrunEnd = newEndTime;
processedIds.add(downstream.id);
}
// Send notifications for all shifted appointments
for (const shifted of result.shifted) {
await notifyShiftedAppointment(db, shifted);
}
return result;
}
/**
* Determines if an appointment update represents an overrun that triggers cascade logic.
*/
export function isOverrun({
originalEndTime,
newEndTime,
_originalStartTime,
_newStartTime,
status,
currentTime,
bufferMinutes,
}: {
originalEndTime: Date;
newEndTime: Date;
_originalStartTime: Date;
_newStartTime?: Date;
status: string;
currentTime: Date;
bufferMinutes: number;
}): boolean {
// Case 1: endTime extended beyond original
if (newEndTime > originalEndTime) {
return true;
}
// Case 2: status transition where current time exceeds endTime + bufferMinutes
// This handles cases where an appointment ran long but wasn't explicitly rescheduled
if (
(status === "in_progress" || status === "completed") &&
currentTime > new Date(originalEndTime.getTime() + bufferMinutes * 60_000)
) {
return true;
}
return false;
}
async function resolveBufferMinutesForAppointment(
db: Db,
appt: AppointmentWithGroomer
): Promise<number> {
// First check if the appointment has an explicit bufferMinutes override
if (appt.bufferMinutes > 0) {
return appt.bufferMinutes;
}
// Fall back to buffer time rules based on service + pet characteristics
const [pet] = await db
.select({ sizeCategory: pets.sizeCategory, coatType: pets.coatType })
.from(pets)
.where(eq(pets.id, appt.petId))
.limit(1);
if (!pet) return 0;
return resolveBufferMinutes({
serviceId: appt.serviceId,
sizeCategory: pet.sizeCategory,
coatType: pet.coatType,
db,
});
}
async function notifyShiftedAppointment(
db: Db,
shifted: ShiftedAppointment
): Promise<void> {
const [row] = await db
.select({
clientName: clients.name,
clientEmail: clients.email,
clientEmailOptOut: clients.emailOptOut,
petName: pets.name,
serviceName: services.name,
groomerName: staff.name,
appointmentStartTime: appointments.startTime,
})
.from(appointments)
.innerJoin(clients, eq(clients.id, appointments.clientId))
.innerJoin(pets, eq(pets.id, appointments.petId))
.innerJoin(services, eq(services.id, appointments.serviceId))
.leftJoin(staff, eq(staff.id, appointments.staffId))
.where(eq(appointments.id, shifted.id))
.limit(1);
if (!row) return;
const { clientName, clientEmail, clientEmailOptOut, petName, serviceName, groomerName } = row;
if (!clientEmail || clientEmailOptOut) return;
if (!petName || !serviceName) return;
console.log(
`[cascade] Notifying shift for appointment ${shifted.id}: ` +
`${shifted.oldStartTime.toISOString()}${shifted.newStartTime.toISOString()}`
);
await sendEmail(
buildRescheduleNotificationEmail(clientEmail, {
clientName,
petName,
serviceName,
groomerName: groomerName ?? null,
oldStartTime: shifted.oldStartTime,
newStartTime: shifted.newStartTime,
})
);
}
+2 -1
View File
@@ -23,7 +23,8 @@ if (process.env.AUTH_DISABLED === "true") {
}
export const authMiddleware: MiddlewareHandler = async (c, next) => {
if (c.req.path.startsWith("/api/auth/")) {
const path = c.req.path;
if (path.startsWith("/api/auth/") || path.startsWith("/api/webhooks/")) {
await next();
return;
}
+31
View File
@@ -21,6 +21,10 @@ import {
} from "@groombook/db";
import { buildConfirmationEmail, sendEmail } from "../services/email.js";
import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js";
import {
detectAndCascadeOverrun,
isOverrun,
} from "../lib/cascade.js";
import type { AppEnv } from "../middleware/rbac.js";
async function withRetry<T>(
@@ -584,6 +588,7 @@ appointmentsRouter.patch(
// (fixes #18). Also falls back to the existing staffId when staffId is
// omitted from the request, so rescheduling always checks conflicts (fixes #19).
let row: typeof appointments.$inferSelect | undefined;
let originalEndTime: Date | undefined;
try {
row = await db.transaction(async (tx) => {
const [current] = await tx
@@ -595,6 +600,9 @@ appointmentsRouter.patch(
throw Object.assign(new Error("not found"), { statusCode: 404 });
}
// Preserve original endTime for cascade detection after update
originalEndTime = current.endTime;
const start = updateFields.startTime
? new Date(updateFields.startTime)
: current.startTime;
@@ -684,6 +692,29 @@ appointmentsRouter.patch(
}
if (!row) return c.json({ error: "Not found" }, 404);
// Cascade delay prevention: detect overrun and shift downstream appointments
if (
originalEndTime &&
updateFields.endTime &&
isOverrun({
originalEndTime,
newEndTime: new Date(updateFields.endTime),
_originalStartTime: row.startTime,
status: row.status,
currentTime: new Date(),
bufferMinutes: row.bufferMinutes ?? 0,
})
) {
const cascadeResult = await detectAndCascadeOverrun({
db,
overrunningAppointmentId: id,
newEndTime: new Date(updateFields.endTime),
_originalEndTime: originalEndTime,
});
return c.json({ ...row, cascade: cascadeResult });
}
return c.json(row);
}
+23 -3
View File
@@ -38,11 +38,12 @@ bookRouter.get("/services", async (c) => {
// ─── GET /api/book/availability ─────────────────────────────────────────────
// Public: return ISO startTime strings for slots where ≥1 groomer is free
// Query params: serviceId (uuid), date (YYYY-MM-DD)
// Query params: serviceId (uuid), date (YYYY-MM-DD), petSizeCategory, petCoatType
bookRouter.get("/availability", async (c) => {
const serviceId = c.req.query("serviceId");
const dateStr = c.req.query("date");
const petSizeCategory = c.req.query("petSizeCategory") ?? undefined;
if (!serviceId || !dateStr) {
return c.json({ error: "serviceId and date are required" }, 400);
@@ -58,6 +59,12 @@ bookRouter.get("/availability", async (c) => {
.where(and(eq(services.id, serviceId), eq(services.active, true)));
if (!service) return c.json({ error: "Service not found" }, 404);
// Buffer-aware duration: extra time for large/x-large or complex coats
const extraBuffer = (petSizeCategory === "large" || petSizeCategory === "xlarge")
? (service.defaultBufferMinutes ?? 0)
: 0;
const durationMinutes = service.durationMinutes + extraBuffer;
const groomers = await db
.select({ id: staff.id })
.from(staff)
@@ -89,7 +96,7 @@ bookRouter.get("/availability", async (c) => {
const slots = generateAvailableSlots({
dateStr,
durationMinutes: service.durationMinutes,
durationMinutes,
groomerIds: groomers.map((g) => g.id),
booked,
});
@@ -112,6 +119,12 @@ const bookingSchema = z.object({
petName: z.string().min(1).max(200),
petSpecies: z.string().min(1).max(100),
petBreed: z.string().max(100).optional(),
petSizeCategory: z
.enum(["small", "medium", "large", "xlarge"])
.optional(),
petCoatType: z
.enum(["smooth", "double", "curly", "wire", "long", "hairless"])
.optional(),
notes: z.string().max(2000).optional(),
});
@@ -129,7 +142,7 @@ bookRouter.post(
.where(and(eq(services.id, body.serviceId), eq(services.active, true)));
if (!service) return c.json({ error: "Service not found" }, 404);
const end = new Date(start.getTime() + service.durationMinutes * 60_000);
let end = new Date(start.getTime() + service.durationMinutes * 60_000);
// Find all active groomers
const groomers = await db
@@ -191,11 +204,18 @@ bookRouter.post(
name: body.petName,
species: body.petSpecies,
breed: body.petBreed ?? null,
sizeCategory: body.petSizeCategory ?? null,
coatType: body.petCoatType ?? null,
})
.returning();
const pet = petInserted[0];
if (!pet) return c.json({ error: "Failed to create pet" }, 500);
// Buffer-aware end time: large/x-large pets add service bufferMinutes
if (body.petSizeCategory === "large" || body.petSizeCategory === "xlarge") {
end = new Date(start.getTime() + (service.durationMinutes + (service.defaultBufferMinutes ?? 0)) * 60_000);
}
// Insert appointment in a transaction to guard against race conditions
let appointment;
try {
+185 -126
View File
@@ -1,214 +1,273 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { and, eq, desc, lt, isNull, sql, count } from "@groombook/db";
import { getDb, conversations, messages, clients } from "@groombook/db";
import { resolveStaffMiddleware } from "../middleware/rbac.js";
import {
and,
eq,
desc,
lt,
sql,
getDb,
conversations,
messages,
clients,
businessSettings,
} from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js";
import { sendMessage } from "../services/messaging/outbound.js";
export const conversationsRouter = new Hono<AppEnv>();
conversationsRouter.use("/*", resolveStaffMiddleware);
const sendMessageSchema = z.object({
body: z.string().min(1).max(1600),
});
// GET /api/conversations — list all conversations for staff's business
// GET /api/conversations — List conversations
conversationsRouter.get("/", async (c) => {
const db = getDb();
const businessId = c.get("staff").businessId;
const staffRow = c.get("staff");
if (!staffRow) return c.json({ error: "Unauthorized" }, 401);
const rows = await db
const [settings] = await db
.select({ id: businessSettings.id })
.from(businessSettings)
.limit(1);
if (!settings) return c.json({ error: "Business not found" }, 404);
const cursor = c.req.query("cursor") || undefined;
const limit = Math.min(Number(c.req.query("limit") || "20"), 50);
let baseQuery = db
.select({
id: conversations.id,
businessId: conversations.businessId,
clientId: conversations.clientId,
channel: conversations.channel,
externalNumber: conversations.externalNumber,
businessNumber: conversations.businessNumber,
lastMessageAt: conversations.lastMessageAt,
status: conversations.status,
createdAt: conversations.createdAt,
staffReadAt: conversations.staffReadAt,
clientName: clients.name,
clientPhone: clients.phone,
channel: conversations.channel,
})
.from(conversations)
.where(eq(conversations.businessId, businessId))
.innerJoin(clients, eq(conversations.clientId, clients.id))
.where(eq(conversations.businessId, settings.id))
.orderBy(desc(conversations.lastMessageAt))
.limit(20);
.limit(limit + 1);
// For each conversation, fetch client name and count unread messages
const enriched = await Promise.all(
if (cursor) {
const [cursorRow] = await db
.select({ lastMessageAt: conversations.lastMessageAt })
.from(conversations)
.where(eq(conversations.id, cursor))
.limit(1);
if (cursorRow?.lastMessageAt) {
baseQuery = db
.select({
id: conversations.id,
clientId: conversations.clientId,
lastMessageAt: conversations.lastMessageAt,
status: conversations.status,
staffReadAt: conversations.staffReadAt,
clientName: clients.name,
clientPhone: clients.phone,
channel: conversations.channel,
})
.from(conversations)
.innerJoin(clients, eq(conversations.clientId, clients.id))
.where(
and(
eq(conversations.businessId, settings.id),
lt(conversations.lastMessageAt, cursorRow.lastMessageAt)
)
)
.orderBy(desc(conversations.lastMessageAt))
.limit(limit + 1);
}
}
const rows = await baseQuery;
const hasMore = rows.length > limit;
if (hasMore) rows.pop();
const items = await Promise.all(
rows.map(async (row) => {
const [client] = await db
.select({ name: clients.name })
.from(clients)
.where(eq(clients.id, row.clientId))
.limit(1);
// Count messages where direction = 'inbound' AND readByClientAt IS NULL
const [{ count: unreadCount }] = await db
.select({ count: count() })
const [unreadRow] = await db
.select({ count: sql<number>`count(*)` })
.from(messages)
.where(
and(
eq(messages.conversationId, row.id),
eq(messages.direction, "inbound"),
isNull(messages.readByClientAt)
sql`${messages.createdAt} > COALESCE(${row.staffReadAt}, '1970-01-01'::timestamp)`
)
);
)
.limit(1);
// Fetch last message body for preview
const [lastMsg] = await db
.select({ body: messages.body, createdAt: messages.createdAt })
.select({
body: messages.body,
direction: messages.direction,
createdAt: messages.createdAt,
})
.from(messages)
.where(eq(messages.conversationId, row.id))
.orderBy(desc(messages.createdAt))
.limit(1);
return {
...row,
clientName: client?.name ?? "Unknown",
lastMessageBody: lastMsg?.body ?? null,
unreadCount: Number(unreadCount),
id: row.id,
clientId: row.clientId,
clientName: row.clientName,
clientPhone: row.clientPhone,
channel: row.channel,
lastMessageAt: row.lastMessageAt,
status: row.status,
unreadCount: Number(unreadRow?.count ?? 0),
lastMessage: lastMsg ?? null,
};
})
);
return c.json(enriched);
const lastRow = rows[rows.length - 1];
const nextCursor = hasMore && lastRow ? lastRow.id : null;
return c.json({ items, nextCursor });
});
// GET /api/conversations/:id — get a single conversation
conversationsRouter.get("/:id", async (c) => {
const db = getDb();
const businessId = c.get("staff").businessId;
const conversationId = c.req.param("id");
const [row] = await db
.select()
.from(conversations)
.where(and(eq(conversations.id, conversationId), eq(conversations.businessId, businessId)))
.limit(1);
if (!row) {
return c.json({ error: "Not found" }, 404);
}
const [client] = await db
.select({ name: clients.name })
.from(clients)
.where(eq(clients.id, row.clientId))
.limit(1);
return c.json({ ...row, clientName: client?.name ?? "Unknown" });
});
// GET /api/conversations/:id/messages — get messages for a conversation
// GET /api/conversations/:id/messages — List messages for a conversation
conversationsRouter.get("/:id/messages", async (c) => {
const db = getDb();
const businessId = c.get("staff").businessId;
const conversationId = c.req.param("id");
const limit = parseInt(c.req.query("limit") ?? "50", 10);
const cursor = c.req.query("cursor");
const staffRow = c.get("staff");
if (!staffRow) return c.json({ error: "Unauthorized" }, 401);
// Verify staff owns this conversation
const [conversation] = await db
const conversationId = c.req.param("id");
const cursor = c.req.query("cursor") || undefined;
const limit = Math.min(Number(c.req.query("limit") || "50"), 100);
const [settings] = await db
.select({ id: businessSettings.id })
.from(businessSettings)
.limit(1);
if (!settings) return c.json({ error: "Business not found" }, 404);
const [conv] = await db
.select({ id: conversations.id })
.from(conversations)
.where(and(eq(conversations.id, conversationId), eq(conversations.businessId, businessId)))
.where(
and(eq(conversations.id, conversationId), eq(conversations.businessId, settings.id))
)
.limit(1);
if (!conv) return c.json({ error: "Not found" }, 404);
if (!conversation) {
return c.json({ error: "Not found" }, 404);
}
// Mark conversation as read by staff
await db
.update(conversations)
.set({ staffReadAt: new Date() })
.where(eq(conversations.id, conversationId));
let query = db
.select({
id: messages.id,
direction: messages.direction,
body: messages.body,
status: messages.status,
sentByStaffId: messages.sentByStaffId,
createdAt: messages.createdAt,
deliveredAt: messages.deliveredAt,
})
.from(messages)
.where(eq(messages.conversationId, conversationId))
.orderBy(desc(messages.createdAt))
.limit(limit + 1);
if (cursor) {
const [cursorMsg] = await db
const [cursorRow] = await db
.select({ createdAt: messages.createdAt })
.from(messages)
.where(eq(messages.id, cursor))
.limit(1);
if (cursorMsg) {
const rows = await db
.select()
if (cursorRow?.createdAt) {
query = db
.select({
id: messages.id,
direction: messages.direction,
body: messages.body,
status: messages.status,
sentByStaffId: messages.sentByStaffId,
createdAt: messages.createdAt,
deliveredAt: messages.deliveredAt,
})
.from(messages)
.where(and(eq(messages.conversationId, conversationId), lt(messages.createdAt, cursorMsg.createdAt)))
.where(
and(
eq(messages.conversationId, conversationId),
lt(messages.createdAt, cursorRow.createdAt)
)
)
.orderBy(desc(messages.createdAt))
.limit(limit);
return c.json({ messages: rows.reverse(), nextCursor: rows.length === limit ? rows[0]?.id : null });
.limit(limit + 1);
}
}
const rows = await db
.select()
.from(messages)
.where(eq(messages.conversationId, conversationId))
.orderBy(desc(messages.createdAt))
.limit(limit);
const rows = await query;
const hasMore = rows.length > limit;
if (hasMore) rows.pop();
return c.json({ messages: rows.reverse(), nextCursor: null });
});
// POST /api/conversations/:id/messages — send a message
const sendMessageSchema = z.object({
body: z.string().min(1).max(1600),
const lastRow = rows[rows.length - 1];
const nextCursor = hasMore && lastRow ? lastRow.id : null;
return c.json({ items: rows, nextCursor });
});
// POST /api/conversations/:id/messages — Send a message
conversationsRouter.post(
"/:id/messages",
zValidator("json", sendMessageSchema),
async (c) => {
const db = getDb();
const businessId = c.get("staff").businessId;
const staffRow = c.get("staff");
if (!staffRow) return c.json({ error: "Unauthorized" }, 401);
const conversationId = c.req.param("id");
const { body } = c.req.valid("json");
// Verify staff owns this conversation
const [conversation] = await db
.select()
const [settings] = await db
.select({ id: businessSettings.id })
.from(businessSettings)
.limit(1);
if (!settings) return c.json({ error: "Business not found" }, 404);
const [conv] = await db
.select({ id: conversations.id, clientId: conversations.clientId })
.from(conversations)
.where(and(eq(conversations.id, conversationId), eq(conversations.businessId, businessId)))
.where(
and(eq(conversations.id, conversationId), eq(conversations.businessId, settings.id))
)
.limit(1);
if (!conv) return c.json({ error: "Not found" }, 404);
if (!conversation) {
return c.json({ error: "Not found" }, 404);
}
const result = await sendMessage({
businessId: settings.id,
clientId: conv.clientId,
body,
sentByStaffId: staffRow.id,
});
// Check if client has opted out
const [client] = await db
.select({ optedOutAt: clients.optedOutAt })
.from(clients)
.where(eq(clients.id, conversation.clientId))
.limit(1);
if (client?.optedOutAt) {
if (result.suppressed) {
return c.json({ error: "Client has opted out of SMS" }, 409);
}
// Create outbound message
const [msg] = await db
.insert(messages)
.values({
conversationId,
direction: "outbound",
body,
status: "queued",
sentByStaffId: staffRow.id,
.select({
id: messages.id,
direction: messages.direction,
body: messages.body,
status: messages.status,
sentByStaffId: messages.sentByStaffId,
createdAt: messages.createdAt,
deliveredAt: messages.deliveredAt,
})
.returning();
// Update conversation lastMessageAt
await db
.update(conversations)
.set({ lastMessageAt: new Date() })
.where(eq(conversations.id, conversationId));
// TODO: Enqueue Telnyx outbound job
.from(messages)
.where(eq(messages.id, result.messageId))
.limit(1);
return c.json(msg, 201);
}
+49
View File
@@ -201,3 +201,52 @@ export function buildWaitlistNotificationEmail(
<p>— Groom Book</p>`,
};
}
// ─── Reschedule notification email ────────────────────────────────────────────
interface RescheduleEmailData {
clientName: string;
petName: string;
serviceName: string;
groomerName: string | null;
oldStartTime: Date;
newStartTime: Date;
}
export function buildRescheduleNotificationEmail(
to: string,
data: RescheduleEmailData
): Mail.Options {
const oldTime = formatDateTime(data.oldStartTime);
const newTime = formatDateTime(data.newStartTime);
const groomer = data.groomerName ? ` with ${data.groomerName}` : "";
return {
to,
subject: `Appointment Rescheduled — ${data.petName}'s appointment has been moved`,
text: [
`Hi ${data.clientName},`,
``,
`Your appointment has been rescheduled.`,
``,
` Pet: ${data.petName}`,
` Service: ${data.serviceName}`,
` Was: ${oldTime}${groomer}`,
` Now: ${newTime}${groomer}`,
``,
`If you have any questions or need to make changes, please contact us.`,
``,
`— Groom Book`,
].join("\n"),
html: `
<p>Hi ${data.clientName},</p>
<p>Your appointment has been <strong>rescheduled</strong>.</p>
<table style="border-collapse:collapse;margin:1em 0">
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">Pet</td><td>${data.petName}</td></tr>
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">Service</td><td>${data.serviceName}</td></tr>
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#ef4444">Was</td><td style="text-decoration:line-through;color:#ef4444">${oldTime}${groomer}</td></tr>
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#10b981">Now</td><td style="color:#10b981">${newTime}${groomer}</td></tr>
</table>
<p>If you have any questions or need to make changes, please contact us.</p>
<p>— Groom Book</p>`,
};
}
@@ -0,0 +1,214 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { detectKeyword } from "../consent.js";
const mockDb = {
insert: vi.fn(),
update: vi.fn(),
select: vi.fn(),
};
vi.mock("@groombook/db", () => ({
getDb: () => mockDb,
clients: {},
messageConsentEvents: {},
businessSettings: {},
eq: vi.fn(),
}));
const { handleConsentKeyword } = await import("../consent.js");
describe("detectKeyword", () => {
it.each([
["STOP", "opt_out"],
["STOPALL", "opt_out"],
["UNSUBSCRIBE", "opt_out"],
["CANCEL", "opt_out"],
["END", "opt_out"],
["QUIT", "opt_out"],
])("opt-out keyword %s → opt_out", (keyword, expected) => {
expect(detectKeyword(keyword)).toEqual({ kind: expected });
});
it.each([
["START", "opt_in"],
["UNSTOP", "opt_in"],
["YES", "opt_in"],
["SUBSCRIBE", "opt_in"],
])("opt-in keyword %s → opt_in", (keyword, expected) => {
expect(detectKeyword(keyword)).toEqual({ kind: expected });
});
it.each([
["HELP", "help"],
["INFO", "help"],
])("help keyword %s → help", (keyword, expected) => {
expect(detectKeyword(keyword)).toEqual({ kind: expected });
});
it("is case insensitive", () => {
expect(detectKeyword("stop")).toEqual({ kind: "opt_out" });
expect(detectKeyword("Stop")).toEqual({ kind: "opt_out" });
expect(detectKeyword("sToP")).toEqual({ kind: "opt_out" });
});
it("trims whitespace", () => {
expect(detectKeyword(" STOP ")).toEqual({ kind: "opt_out" });
expect(detectKeyword("\tSTART\n")).toEqual({ kind: "opt_in" });
});
it("returns null for non-keyword messages", () => {
expect(detectKeyword("hello")).toBeNull();
expect(detectKeyword("STOP IT")).toBeNull();
expect(detectKeyword("help me")).toBeNull();
});
});
describe("handleConsentKeyword", () => {
beforeEach(() => {
vi.clearAllMocks();
mockDb.insert.mockReturnValue({
values: vi.fn().mockResolvedValue([{ id: "event-1" }]),
} as any);
mockDb.update.mockReturnValue({
set: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]),
}),
} as any);
});
const baseOpts = {
clientId: "client-1",
businessId: "biz-1",
db: mockDb as unknown as ReturnType<typeof import("@groombook/db").getDb>,
};
describe("opt_out", () => {
it("inserts consent event with sms_keyword source", async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ smsOptIn: true }]),
}),
}),
} as any);
await handleConsentKeyword({ ...baseOpts, kind: "opt_out" });
expect(mockDb.insert).toHaveBeenCalledOnce();
});
it("sets smsOptIn=false and smsOptOutDate when currently opted in", async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ smsOptIn: true }]),
}),
}),
} as any);
await handleConsentKeyword({ ...baseOpts, kind: "opt_out" });
expect(mockDb.update).toHaveBeenCalled();
});
it("is idempotent — second opt-out logs event but skips client update", async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ smsOptIn: false }]),
}),
}),
} as any);
await handleConsentKeyword({ ...baseOpts, kind: "opt_out" });
expect(mockDb.update).not.toHaveBeenCalled();
});
it("returns unsubscribe reply text", async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ smsOptIn: true }]),
}),
}),
} as any);
const result = await handleConsentKeyword({ ...baseOpts, kind: "opt_out" });
expect(result.replyText).toBe(
"You have been unsubscribed and will no longer receive messages. Reply START to resubscribe."
);
});
});
describe("opt_in", () => {
it("sets smsOptIn=true and smsConsentDate when currently opted out", async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ smsOptIn: false, smsConsentDate: null }]),
}),
}),
} as any);
await handleConsentKeyword({ ...baseOpts, kind: "opt_in" });
expect(mockDb.update).toHaveBeenCalled();
});
it("clears smsOptOutDate on opt-in after opt-out", async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ smsOptIn: false }]),
}),
}),
} as any);
await handleConsentKeyword({ ...baseOpts, kind: "opt_in" });
expect(mockDb.update).toHaveBeenCalled();
});
it("is idempotent — second opt-in skips client update", async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ smsOptIn: true }]),
}),
}),
} as any);
await handleConsentKeyword({ ...baseOpts, kind: "opt_in" });
expect(mockDb.update).not.toHaveBeenCalled();
});
it("returns resubscribe reply text", async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ smsOptIn: false }]),
}),
}),
} as any);
const result = await handleConsentKeyword({ ...baseOpts, kind: "opt_in" });
expect(result.replyText).toBe(
"You have been resubscribed to messages. Reply STOP to unsubscribe. Msg & data rates may apply."
);
});
});
describe("help", () => {
it("returns default help reply without querying businessSettings", async () => {
const result = await handleConsentKeyword({ ...baseOpts, kind: "help" });
expect(mockDb.update).not.toHaveBeenCalled();
expect(mockDb.select).not.toHaveBeenCalled();
expect(result.replyText).toBe(
"Reply STOP to unsubscribe or START to resubscribe. For help, contact your groomer directly."
);
});
});
});
@@ -0,0 +1,77 @@
import { clients, messageConsentEvents, eq } from "@groombook/db";
import type { Db } from "@groombook/db";
export type KeywordKind = "opt_in" | "opt_out" | "help";
const OPT_OUT_KEYWORDS = new Set(["STOP", "STOPALL", "UNSUBSCRIBE", "CANCEL", "END", "QUIT"]);
const OPT_IN_KEYWORDS = new Set(["START", "UNSTOP", "YES", "SUBSCRIBE"]);
const HELP_KEYWORDS = new Set(["HELP", "INFO"]);
export function detectKeyword(body: string): { kind: KeywordKind } | null {
const normalized = body.trim().toUpperCase();
if (OPT_OUT_KEYWORDS.has(normalized)) return { kind: "opt_out" };
if (OPT_IN_KEYWORDS.has(normalized)) return { kind: "opt_in" };
if (HELP_KEYWORDS.has(normalized)) return { kind: "help" };
return null;
}
export async function handleConsentKeyword(opts: {
clientId: string;
businessId: string;
kind: KeywordKind;
db: Db;
}): Promise<{ replyText: string }> {
const { clientId, businessId, kind, db: database } = opts;
await database.insert(messageConsentEvents).values({
clientId,
businessId,
kind,
source: "sms_keyword",
});
if (kind === "opt_out") {
const [existing] = await database
.select({ smsOptIn: clients.smsOptIn })
.from(clients)
.where(eq(clients.id, clientId))
.limit(1);
if (existing?.smsOptIn !== false) {
await database
.update(clients)
.set({ smsOptIn: false, smsOptOutDate: new Date() })
.where(eq(clients.id, clientId));
}
return {
replyText: "You have been unsubscribed and will no longer receive messages. Reply START to resubscribe.",
};
}
if (kind === "opt_in") {
const [existing] = await database
.select({ smsOptIn: clients.smsOptIn, smsConsentDate: clients.smsConsentDate })
.from(clients)
.where(eq(clients.id, clientId))
.limit(1);
if (existing?.smsOptIn !== true) {
await database
.update(clients)
.set({ smsOptIn: true, smsConsentDate: new Date(), smsOptOutDate: null })
.where(eq(clients.id, clientId));
}
return {
replyText:
"You have been resubscribed to messages. Reply STOP to unsubscribe. Msg & data rates may apply.",
};
}
// kind === "help"
const replyText =
"Reply STOP to unsubscribe or START to resubscribe. For help, contact your groomer directly.";
return { replyText };
}
+19 -1
View File
@@ -1,5 +1,7 @@
import { getDb, conversations, messages, businessSettings, clients, eq, and } from "@groombook/db";
import { v4 as uuidv4 } from "uuid";
import { detectKeyword, handleConsentKeyword } from "./consent.js";
import { sendMessage } from "./outbound.js";
export interface TelnyxMessageReceivedPayload {
data: {
@@ -152,7 +154,7 @@ export async function handleMessageReceived(payload: TelnyxMessageReceivedPayloa
throw new Error(`No business owns messaging number: ${toPhone}`);
}
const { id: conversationId } = await findOrCreateConversation(businessId, fromPhone, toPhone);
const { id: conversationId, clientId } = await findOrCreateConversation(businessId, fromPhone, toPhone);
await getDb()
.update(conversations)
@@ -167,6 +169,22 @@ export async function handleMessageReceived(payload: TelnyxMessageReceivedPayloa
"received"
);
const keyword = detectKeyword(message.body ?? "");
if (keyword) {
const { replyText } = await handleConsentKeyword({
clientId,
businessId,
kind: keyword.kind,
db: getDb(),
});
await sendMessage({
businessId,
clientId,
body: replyText,
sentByStaffId: undefined,
});
}
return { conversationId, messageId };
}
+1 -1
View File
@@ -19,7 +19,7 @@ export default defineConfig({
reporter: process.env.CI ? "github" : "list",
use: {
baseURL: "http://localhost:8080",
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:8080",
trace: "on-first-retry",
screenshot: "only-on-failure",
serviceWorkers: "block",
+1 -1
View File
@@ -1 +1 @@
VITE_API_URL=
VITE_API_URL=https://uat.groombook.dev
+2
View File
@@ -11,6 +11,8 @@ RUN pnpm install --frozen-lockfile
# Build
FROM deps AS builder
ARG VITE_API_URL=
ENV VITE_API_URL=
COPY packages/types/ packages/types/
COPY apps/web/ apps/web/
RUN pnpm --filter @groombook/web build
+4 -1
View File
@@ -40,7 +40,10 @@ function LoginPage() {
const handleSocialLogin = async (provider: string) => {
setIsLoading(true);
setError(null);
const result = await signIn.social({ provider, callbackURL: window.location.origin });
// Use /admin as callback URL so Better-Auth redirects to the app's dashboard
// after the OAuth callback completes, rather than back to /login
const callbackURL = `${window.location.origin}/admin`;
const result = await signIn.social({ provider, callbackURL });
if (result?.error) {
setError(result.error.message ?? "Sign-in failed");
setIsLoading(false);
+10 -12
View File
@@ -8,10 +8,9 @@ const mockConversations = [
clientId: "client-1",
clientName: "Alice Smith",
channel: "sms",
externalNumber: "+1234567890",
clientPhone: "+1234567890",
lastMessageAt: "2026-05-14T10:00:00Z",
staffReadAt: null,
lastMessageBody: "Hello, is my dog ready?",
lastMessage: { body: "Hello, is my dog ready?", direction: "inbound", createdAt: "2026-05-14T10:00:00Z" },
unreadCount: 2,
status: "active",
},
@@ -20,10 +19,9 @@ const mockConversations = [
clientId: "client-2",
clientName: "Bob Jones",
channel: "sms",
externalNumber: "+1987654321",
clientPhone: "+1987654321",
lastMessageAt: "2026-05-13T08:00:00Z",
staffReadAt: "2026-05-13T09:00:00Z",
lastMessageBody: "Thanks for the update",
lastMessage: { body: "Thanks for the update", direction: "outbound", createdAt: "2026-05-13T08:05:00Z" },
unreadCount: 0,
status: "active",
},
@@ -73,7 +71,7 @@ afterEach(() => {
describe("MessagesPage", () => {
it("renders empty state when no conversations", async () => {
vi.mocked(global.fetch).mockResolvedValue(makeResponse([]));
vi.mocked(global.fetch).mockResolvedValue(makeResponse({ items: [], nextCursor: null }));
render(<MessagesPage />);
await waitFor(() => {
@@ -82,7 +80,7 @@ describe("MessagesPage", () => {
});
it("renders conversation list", async () => {
vi.mocked(global.fetch).mockResolvedValue(makeResponse(mockConversations));
vi.mocked(global.fetch).mockResolvedValue(makeResponse({ items: mockConversations, nextCursor: null }));
render(<MessagesPage />);
await waitFor(() => {
@@ -98,10 +96,10 @@ describe("MessagesPage", () => {
vi.mocked(global.fetch).mockImplementation((input) => {
const url = String(input);
if (url === "/api/conversations?limit=20") {
return Promise.resolve(makeResponse(mockConversations));
return Promise.resolve(makeResponse({ items: mockConversations, nextCursor: null }));
}
if (url === "/api/conversations/conv-1/messages?limit=50") {
return Promise.resolve(makeResponse({ messages: mockMessages }));
return Promise.resolve(makeResponse({ items: mockMessages, nextCursor: null }));
}
return Promise.resolve(makeResponseWithStatus(null, 404));
});
@@ -112,7 +110,7 @@ describe("MessagesPage", () => {
fireEvent.click(screen.getByText("Alice Smith"));
await waitFor(() => {
expect(screen.getByText("Hello, is my dog ready?")).toBeInTheDocument();
expect(screen.getAllByText("Hello, is my dog ready?").length).toBeGreaterThanOrEqual(1);
expect(screen.getByText("Yes, she is all done!")).toBeInTheDocument();
});
});
@@ -132,7 +130,7 @@ describe("MessagesPage", () => {
sentByStaffId: "staff-1",
}, 201));
}
return Promise.resolve(makeResponse(mockConversations));
return Promise.resolve(makeResponse({ items: mockConversations, nextCursor: null }));
});
render(<MessagesPage />);
+1 -1
View File
@@ -1,7 +1,7 @@
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: import.meta.env.VITE_API_URL ?? "",
baseURL: import.meta.env.VITE_API_URL || window.location.origin,
});
export const { signIn, signOut, useSession, changePassword } = authClient;
+47 -6
View File
@@ -13,6 +13,8 @@ interface BookingBody {
petName: string;
petSpecies: string;
petBreed: string;
petSizeCategory: string;
petCoatType: string;
notes: string;
}
@@ -123,6 +125,8 @@ export function BookPage() {
petName: "",
petSpecies: "",
petBreed: "",
petSizeCategory: "",
petCoatType: "",
notes: "",
});
const [formError, setFormError] = useState<string | null>(null);
@@ -168,14 +172,18 @@ export function BookPage() {
if (!selectedService || !date) return;
setSlotsLoading(true);
setSelectedSlot(null);
fetch(
`/api/book/availability?serviceId=${encodeURIComponent(selectedService.id)}&date=${encodeURIComponent(date)}`
)
const params = new URLSearchParams({
serviceId: selectedService.id,
date,
});
if (form.petSizeCategory) params.set("petSizeCategory", form.petSizeCategory);
if (form.petCoatType) params.set("petCoatType", form.petCoatType);
fetch(`/api/book/availability?${params}`)
.then((r) => r.json() as Promise<string[]>)
.then(setSlots)
.catch(() => setSlots([]))
.finally(() => setSlotsLoading(false));
}, [selectedService, date]);
}, [selectedService, date, form.petSizeCategory, form.petCoatType]);
function goToStep2(svc: Service) {
setSelectedService(svc);
@@ -214,6 +222,8 @@ export function BookPage() {
petName: form.petName,
petSpecies: form.petSpecies,
petBreed: form.petBreed || undefined,
petSizeCategory: form.petSizeCategory || undefined,
petCoatType: form.petCoatType || undefined,
notes: form.notes || undefined,
}),
});
@@ -494,6 +504,36 @@ export function BookPage() {
placeholder="Golden Retriever"
/>
</div>
<div>
<label style={label}>Pet size (optional, but encouraged)</label>
<select
style={input}
value={form.petSizeCategory}
onChange={(e) => setForm((f) => ({ ...f, petSizeCategory: e.target.value }))}
>
<option value="">Select size</option>
<option value="small">Small (under 15 lbs)</option>
<option value="medium">Medium (1540 lbs)</option>
<option value="large">Large (4080 lbs)</option>
<option value="xlarge">X-Large (over 80 lbs)</option>
</select>
</div>
<div>
<label style={label}>Coat type (optional, but encouraged)</label>
<select
style={input}
value={form.petCoatType}
onChange={(e) => setForm((f) => ({ ...f, petCoatType: e.target.value }))}
>
<option value="">Select coat type</option>
<option value="smooth">Smooth</option>
<option value="double">Double</option>
<option value="curly">Curly</option>
<option value="wire">Wire</option>
<option value="long">Long</option>
<option value="hairless">Hairless</option>
</select>
</div>
<div>
<label style={label}>Notes for groomer</label>
<textarea
@@ -528,7 +568,7 @@ export function BookPage() {
<div>
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Service</div>
<div style={{ fontWeight: 600 }}>{selectedService.name}</div>
<div style={{ color: "#6b7280" }}>{fmtPrice(selectedService.basePriceCents)} · {fmtDuration(selectedService.durationMinutes)}</div>
<div style={{ color: "#6b7280" }}>{fmtPrice(selectedService.basePriceCents)} · {fmtDuration(selectedService.durationMinutes + ((form.petSizeCategory === "large" || form.petSizeCategory === "xlarge") ? (selectedService.defaultBufferMinutes ?? 0) : 0))}</div>
</div>
<div>
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Date & Time</div>
@@ -599,7 +639,8 @@ export function BookPage() {
setResult(null);
setForm({
serviceId: "", startTime: "", clientName: "", clientEmail: "",
clientPhone: "", petName: "", petSpecies: "", petBreed: "", notes: "",
clientPhone: "", petName: "", petSpecies: "", petBreed: "",
petSizeCategory: "", petCoatType: "", notes: "",
});
}}
>
+8 -8
View File
@@ -5,12 +5,11 @@ interface Conversation {
clientId: string;
clientName: string;
channel: string;
externalNumber: string;
clientPhone: string;
lastMessageAt: string | null;
staffReadAt: string | null;
lastMessageBody: string | null;
unreadCount: number;
status: string;
lastMessage: { body: string | null; direction: string; createdAt: string } | null;
}
interface Message {
@@ -55,7 +54,8 @@ export function MessagesPage() {
try {
const res = await fetch("/api/conversations?limit=20");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = (await res.json()) as Conversation[];
const json = await res.json();
const data = json.items as Conversation[];
setConversations(data);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to load conversations");
@@ -68,8 +68,8 @@ export function MessagesPage() {
try {
const res = await fetch(`/api/conversations/${conversationId}/messages?limit=50`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = (await res.json()) as { messages: Message[] };
setMessages(data.messages);
const json = await res.json();
setMessages((json.items as Message[]).reverse());
} catch (e: unknown) {
setMessageError(e instanceof Error ? e.message : "Failed to load messages");
} finally {
@@ -93,7 +93,7 @@ export function MessagesPage() {
useEffect(() => {
if (messages.length > 0) {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
messagesEndRef.current?.scrollIntoView?.({ behavior: "smooth" });
}
}, [messages]);
@@ -180,7 +180,7 @@ export function MessagesPage() {
)}
</div>
<div style={{ marginTop: 2, color: "#6b7280", fontSize: 12 }}>
{truncate(conv.lastMessageBody, 60)}
{truncate(conv.lastMessage?.body ?? null, 60)}
</div>
<div style={{ marginTop: 2, color: "#9ca3af", fontSize: 11 }}>
{relativeTime(conv.lastMessageAt)}
@@ -58,7 +58,7 @@ interface MessageThreadProps {
readOnly: boolean;
}
function MessageThread({ sessionId, readOnly }: MessageThreadProps) {
function MessageThread({ sessionId, readOnly: _readOnly }: MessageThreadProps) {
const [businessName, setBusinessName] = useState<string>("Business");
const { conversation, loading: convLoading, error: convError } = useConversation(sessionId);
@@ -144,7 +144,6 @@ function MessageThread({ sessionId, readOnly }: MessageThreadProps) {
) : (
messages.map((msg: ApiMessage) => {
const sender = msg.direction === "inbound" ? "customer" : "business";
const senderName = sender === "customer" ? "You" : businessName;
return (
<div key={msg.id} className={`flex ${sender === "customer" ? "justify-end" : "justify-start"}`}>
<div className={`max-w-[80%] rounded-2xl px-4 py-2.5 ${
+16 -1
View File
@@ -43,6 +43,12 @@ services:
condition: service_healthy
migrate:
condition: service_completed_successfully
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:3000/health || exit 1"]
interval: 5s
timeout: 5s
retries: 20
start_period: 10s
web:
build:
@@ -50,8 +56,17 @@ services:
dockerfile: apps/web/Dockerfile
ports:
- "8080:80"
extra_hosts:
- "host.docker.internal:host-gateway"
depends_on:
- api
api:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:80 || exit 1"]
interval: 5s
timeout: 5s
retries: 20
start_period: 10s
volumes:
postgres_data:
@@ -0,0 +1 @@
ALTER TABLE "conversations" ADD COLUMN "staff_read_at" timestamp;
+2 -2
View File
@@ -222,8 +222,8 @@
{
"idx": 32,
"version": "7",
"when": 1778818472097,
"tag": "0032_add_staff_read_at",
"when": 1778818472097,
"tag": "0032_staff_read_at",
"breakpoints": true
}
]
+1 -1
View File
@@ -4,7 +4,7 @@ import * as schema from "./schema.js";
export * from "./schema.js";
export { encryptSecret, decryptSecret } from "./crypto.js";
export { and, asc, desc, eq, exists, gte, gt, ilike, inArray, isNull, lt, lte, ne, or, sql } from "drizzle-orm";
export { and, asc, count, desc, eq, exists, gte, gt, ilike, inArray, isNull, lt, lte, ne, or, sql } from "drizzle-orm";
let _db: ReturnType<typeof drizzle> | null = null;
+2 -2
View File
@@ -151,6 +151,8 @@ export const pets = pgTable(
name: text("name").notNull(),
species: text("species").notNull(),
breed: text("breed"),
sizeCategory: petSizeCategoryEnum("size_category"),
coatType: coatTypeEnum("coat_type"),
weightKg: numeric("weight_kg", { precision: 5, scale: 2 }),
dateOfBirth: timestamp("date_of_birth"),
healthAlerts: text("health_alerts"),
@@ -162,8 +164,6 @@ export const pets = pgTable(
photoKey: text("photo_key"),
photoUploadedAt: timestamp("photo_uploaded_at"),
image: text("image"),
sizeCategory: petSizeCategoryEnum("size_category"),
coatType: coatTypeEnum("coat_type"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
},
+85 -3
View File
@@ -20,6 +20,7 @@ import postgres from "postgres";
import { drizzle } from "drizzle-orm/postgres-js";
import { eq, sql } from "drizzle-orm";
import * as schema from "./schema.js";
import { randomBytes, scrypt } from "node:crypto";
// ── Seed profile configuration ─────────────────────────────────────────────
@@ -509,6 +510,81 @@ async function seedKnownUsers() {
}
console.log(`✓ Seeded ${demoSvcs.length} services`);
// ── Better Auth credential accounts for UAT personas ─────────────────────
// Creates user + account rows so UAT personas can email+password login.
// Uses the same scrypt config as better-auth (keylen=64, N=16384, r=8, p=1).
const uatCredAccounts: Array<{ email: string; passwordEnvKey: string; staffId: string }> = [
{ email: "uat-super@groombook.dev", passwordEnvKey: "SEED_UAT_SUPER_PASSWORD", staffId: "00000000-0000-0000-0000-000000000003" },
{ email: "uat-groomer@groombook.dev", passwordEnvKey: "SEED_UAT_GROOMER_PASSWORD", staffId: "00000000-0000-0000-0000-000000000004" },
{ email: "uat-customer@groombook.dev", passwordEnvKey: "SEED_UAT_CUSTOMER_PASSWORD", staffId: "" },
{ email: "uat-tester@groombook.dev", passwordEnvKey: "SEED_UAT_TESTER_PASSWORD", staffId: "" },
];
for (const acct of uatCredAccounts) {
const password = process.env[acct.passwordEnvKey];
if (!password) {
console.log(`⊘ No ${acct.passwordEnvKey} set — skipping Better Auth account for ${acct.email}`);
continue;
}
// Check if user already exists
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.email}' already exists — skipping`);
} else {
// Hash with same scrypt params as better-auth: keylen=64, N=16384, r=8, p=1
// Use Promise-based scrypt API (callback pattern, wrapped in Promise)
const salt = randomBytes(16);
const key = await new Promise<Buffer>((resolve, reject) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
scrypt(password.normalize("NFKC"), salt, 64, { N: 16384, r: 8, p: 1 } as any, (err: Error | null, derivedKey: Buffer) => {
if (err) reject(err);
else resolve(derivedKey);
});
});
const passwordHash = `${salt.toString("hex")}:${key.toString("hex")}`;
const [newUser] = await db.insert(schema.user).values({
id: uuid(),
name: acct.email.split("@")[0]!,
email: acct.email,
emailVerified: true,
}).returning();
userId = newUser!.id;
await db.insert(schema.account).values({
id: uuid(),
accountId: userId,
providerId: "credential",
userId,
password: passwordHash,
});
console.log(`✓ Created Better Auth credential account for '${acct.email}'`);
}
// Link staff record to Better Auth user if staff exists and has no userId yet
if (acct.staffId) {
const [existingStaff] = await db
.select()
.from(schema.staff)
.where(eq(schema.staff.id, acct.staffId))
.limit(1);
if (existingStaff && !existingStaff.userId) {
await db.update(schema.staff)
.set({ userId })
.where(eq(schema.staff.id, acct.staffId));
console.log(` ↳ Linked staff '${acct.email}' to Better Auth user`);
}
}
}
// ── Client: Demo Client ──
const [existingClient] = await db
.select()
@@ -883,6 +959,7 @@ async function seed() {
let appointmentCount = 0;
let invoiceCount = 0;
let visitLogCount = 0;
let paidInvoiceCounter = 0;
// Process in batches per client to keep memory manageable
const apptBatchSize = 100;
@@ -977,8 +1054,11 @@ async function seed() {
const invoiceStatus = rand() < 0.95 ? "paid" as const : "pending" as const;
const paidAt = invoiceStatus === "paid" ? new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000) : null;
paidInvoiceCounter++;
const stripePaymentIntentId = invoiceStatus === "paid"
? `pi_test_seed_${String(paidInvoiceCounter).padStart(6, "0")}`
: null;
const stripePaymentIntentId = invoiceStatus === "paid" && rand() < 0.2 ? `pi_test_${uuid().replace(/-/g, "").slice(0, 24)}` : null;
invoiceBatch.push({
id: invoiceId,
appointmentId: apptId,
@@ -1094,14 +1174,16 @@ async function seed() {
const taxCents = Math.round(effectivePrice * 0.08);
const totalCents = effectivePrice + taxCents + tipCents;
const paidAt = new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000);
const stripePaymentIntentId = rand() < 0.2 ? `pi_test_${uuid().replace(/-/g, "").slice(0, 24)}` : null;
paidInvoiceCounter++;
invoiceBatch.push({
id: invoiceId, appointmentId: apptId, clientId,
subtotalCents: effectivePrice, taxCents, tipCents, totalCents,
status: "paid" as const,
paymentMethod: pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check",
paidAt, stripePaymentIntentId, notes: null,
paidAt,
stripePaymentIntentId: `pi_test_seed_${String(paidInvoiceCounter).padStart(6, "0")}`,
notes: null,
});
lineItemBatch.push({
id: uuid(), invoiceId, description: svc.name, quantity: 1,
+4
View File
@@ -32,6 +32,8 @@ export interface Pet {
name: string;
species: string;
breed: string | null;
sizeCategory: string | null;
coatType: string | null;
weightKg: number | null;
dateOfBirth: string | null;
healthAlerts: string | null;
@@ -64,6 +66,7 @@ export interface Service {
description: string | null;
basePriceCents: number;
durationMinutes: number;
defaultBufferMinutes: number;
active: boolean;
createdAt: string;
updatedAt: string;
@@ -114,6 +117,7 @@ export interface Appointment {
cancelledAt: string | null;
confirmationToken: string | null;
customerNotes: string | null;
bufferMinutes: number;
createdAt: string;
updatedAt: string;
}