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>
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>
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>
- 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>
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>
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>
- 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.
- 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
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>
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>
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>
- 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>
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
* 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>
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>
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.
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.
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>
- 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>
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>
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>
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>
- 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>
- 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>
- 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>