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