* Implement confirm/cancel in customer portal (GRO-50)
Backend:
- Add POST /api/portal/appointments/:id/confirm endpoint
- Validates impersonation session auth and ownership
- Rejects past/in-progress, non-pending, or already-cancelled/completed
- Sets confirmationStatus="confirmed", confirmedAt, updatedAt
- Add POST /api/portal/appointments/:id/cancel endpoint
- Same auth/ownership pattern
- Rejects past/in-progress or already-cancelled/completed
- Sets status="cancelled", confirmationStatus="cancelled", cancelledAt, updatedAt
Frontend (Appointments.tsx):
- Add confirmationStatus field to Appointment type and mock data
- Add ConfirmationSection component: shows status badge + confirm button
- Add CancelAppointmentButton: wires to cancel API with loading/error state
- Wire existing Cancel button to CancelAppointmentButton
- Show confirmation status badge in expanded view for upcoming appointments
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* feat(gro-48): row-level data scoping for groomer role (RBAC Phase 2)
Filter query results at the route handler level when staff role is groomer:
- GET /api/appointments: WHERE staffId = groomer OR batherStaffId = groomer
- GET /api/appointments/🆔 403 if not assigned to groomer (as staff or bather)
- GET /api/clients: Clients with ≥1 appointment for this groomer (via exists subquery)
- GET /api/clients/🆔 403 if no appointment linkage
- GET /api/pets: Pets owned by groomer-linked clients (via exists subquery)
- GET /api/pets/:petId: 403 if no appointment linkage
Managers and receptionists: no change.
Added exists to @groombook/db exports (was missing from re-export).
Added groomerIsolation unit tests for role guard and filter logic.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* fix(gro-50): add portal confirm/cancel tests and fix ConfirmationSection state
- Add test coverage for POST /portal/appointments/:id/confirm endpoint
- Add test coverage for POST /portal/appointments/:id/cancel endpoint
- Fix ConfirmationSection not updating local status after successful confirm
- Remove unused onCancel prop from ConfirmationSection call site
- Fix Appointments.test.tsx missing confirmationStatus field
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* test(gro-50): add ConfirmationSection UI component tests
Add tests for the ConfirmationSection component:
- Renders correct badge for each confirmationStatus state
- Shows/hides Confirm button based on status
- Calls confirm API with correct headers
- Handles sessionId null case
- Shows error messages for 401/403/422 responses
- Shows loading state while confirming
- Shows success message briefly after confirm
- Does not call API if user cancels confirm dialog
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* fix(gro-48): address QA review feedback — staffRow?.role and portal TS guards
- appointments.ts: use staffRow?.role (consistent with clients.ts/pets.ts)
to handle undefined staff context safely
- portal.ts: add null guards on .returning() results for confirm and cancel
endpoints (TS18048: 'updated' is possibly undefined)
- All 188 tests passing; TypeScript typecheck clean
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* fix(gro66): use specific selector for banner visibility assertion
Replace ambiguous `getByText("STAFF VIEW")` that matched both the
ImpersonationBanner and the CustomerPortal watermark with a precise
`getByTestId("impersonation-banner")` selector to eliminate strict
mode violations.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* fix(gro-66): add missing afterEach to vitest import
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* fix(gro-48): add icalToken to MANAGER mock after rebase
After rebasing onto origin/main (which added icalToken to the staff
schema via GRO-107), the MANAGER mock in groomerIsolation.test.ts was
missing the new required field. Added icalToken: null to the MANAGER
constant. factories.ts is clean (no duplicate icalToken after rebase).
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* fix(gro-47): add non-null assertions on Drizzle RETURNING results
Drizzle's update().returning() types the array element as T | undefined.
After the if (!appt) guard, updated is still typed as possibly undefined
because RETURNING can succeed with no rows. Add ! assertions since
we already guard with the existence check.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
---------
Co-authored-by: Flea Flicker <fleaflicker@groombook.ai>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Flea Flicker <flea-flicker@paperclip.ing>
Closes#119
- Debounced typeahead search on clients/pets pages
- Auto-select client from GlobalSearch highlight param
- Mobile-friendly with big touch targets
- 141-line test suite, all 70 web tests pass
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* feat: add customer-facing appointment notes (GRO-106)
- Migration 0014: add customer_notes column to appointments
- Schema update: add customerNotes field to appointments table
- Factory update: include customerNotes in buildAppointment
- Portal route: PATCH /api/portal/appointments/:id/notes
- Ownership validation via impersonation session
- Future-only validation (no edits after start)
- 500 character limit
- Register portal router in index.ts
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* Fix confirmationToken leak and add unit tests for portal notes endpoint
- Return only id, customerNotes, updatedAt instead of full appointment row
- Add comprehensive unit tests covering auth, ownership, time-gating, and validation
- Fix: confirmationToken no longer returned to portal session
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* feat: add customer notes UI to portal and staff views (GRO-178)
- Add customerNotes field to Appointment type
- Add read-only customer notes display in staff appointment detail modal
- Add customer notes textarea with save, char counter (500 max), and disabled state
- Wire up PATCH /api/portal/appointments/:id/notes in portal UI
- Update mockData with customerNotes field
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* fix: address QA review feedback - null check and portal route auth
- Add null check after db.update().returning() in portal notes endpoint
- Move portal router registration before auth middleware so clients can access it
- Remove unused ENDED_SESSION variable from test file
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* fix(portal): address QA review - isUpcoming time parsing and session header
- Fixed parseTimeTo24Hour to handle 12-hour AM/PM format correctly
- Added X-Impersonation-Session-Id header to CustomerNotesSection fetch
- Added comprehensive tests for CustomerNotesSection and time parsing
- Fixed TypeScript strict null checks for parseTimeTo24Hour
Fixes QA review issues:
- isUpcoming() now correctly parses 12-hour time format
- CustomerNotesSection sends session ID header for auth
- Added unit tests for new UI component
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* fix: thread sessionId as prop instead of sessionStorage
CustomerNotesSection was reading sessionStorage for the impersonation
session ID, but CustomerPortal stores it in React state. Pass sessionId
as a prop through AppointmentsSection and AppointmentCard instead.
Also update tests to pass sessionId prop and add test for null sessionId
case.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
---------
Co-authored-by: Scrubs McBarkley <scrubs@groombook.app>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: groombook-cto[bot] <269737991+groombook-cto[bot]@users.noreply.github.com>
- Fix gradient regression in ReportCards.tsx: use distinct color stops
(--color-accent-lighter → --color-accent-light) to restore subtle gradient
- Fix BrandingContext meta tag accumulation: cache ref with useRef instead
of querying DOM on every render to prevent duplicate elements on remount
- Add BrandingContext.test.tsx: verify CSS vars applied, theme-color meta
created/updated, and no duplicate meta tags on rerender
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Import ImpersonationSession from @groombook/types (component was updated in #78)
- Remove stale tests: "shows customer name" and "returns null when inactive"
(component no longer renders customer name or checks session.active)
- Add isExtended prop to all render calls (component now takes isExtended as prop)
- Fix "does not show Extend button when already extended" to pass isExtended={true}
instead of session.extended (prop was extracted from session in #78)
- Fix clients.test.ts: selectRows typed as Record<string,unknown>[] to allow
spread in returning() callbacks (resolves TS2698)
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Closes#81
- Add window.location.href = '/admin/clients' after clearing session
state in handleEnd so staff are sent back to the admin panel
- Add a test that verifies the redirect fires when End Session is clicked
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Replaces the local impersonationReducer (mock-based) with real API calls
to the /api/impersonation/sessions endpoints added in PR #75.
Changes:
- CustomerPortal: reads ?sessionId= param via useSearchParams, fetches
real session on mount, calls /extend and /end on user action, logs
page views to /sessions/:id/log. Removes demo sidebar button.
- ImpersonationBanner: updated to use ImpersonationSession from
@groombook/types instead of the old mockData shape. Accepts isExtended
prop to control Extend button visibility.
- AuditLogViewer: now fetches from /api/impersonation/sessions/:id/audit-log
instead of receiving auditLog[] as a prop. Handles loading/error states.
- Clients.tsx: "View as Customer" button now POSTs to
/api/impersonation/sessions first, then navigates to /?sessionId=<id>.
Handles 409 (existing active session) by reusing it.
- mockData.ts: removed ImpersonationSession and AuditEntry interfaces
(now live in @groombook/types).
- test/setup.ts: set NODE_ENV=test for React 19 + testing-library compat.
- portal.test.tsx: 13 new tests covering banner, audit log viewer, and
portal session loading behavior (20 total pass).
Closes#76
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* feat: add customizable business branding (name, logo, colors)
Add admin settings for business branding with name, logo upload, and
color scheme via CSS custom properties. Includes database migration,
API endpoints, admin settings page, and dynamic branding in both
admin nav and customer portal.
Closes#61
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: address review feedback on branding PR
- Replace dynamic import with static import for @groombook/db in public branding endpoint
- Restore active nav item background highlight (bg-stone-100) in CustomerPortal
- Remove non-null assertion in settings route, add proper error handling
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* chore: trigger CI
* fix: resolve lint error and test failure for branding feature
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: update E2E tests for branding changes
- Update navigation test to expect "GroomBook" (default branding) instead
of hardcoded "Paws & Reflect" since CustomerPortal now uses dynamic branding
- Add /api/branding mock to shared E2E fixtures so BrandingProvider resolves
immediately in all tests, preventing unhandled fetch interference
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: GroomBook CTO <cto@groombook.dev>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: GroomBook CTO <cto@groombook.app>
* Add dev/demo login selector for quick user switching
When AUTH_DISABLED=true, the app now shows a login selector page that
lists staff members and clients from the database. Selecting a user
sets a localStorage-based session and sends X-Dev-User-Id header on
all API requests. A persistent bottom bar shows the active persona
with a "Switch user" link.
- API: /api/dev/config (public) and /api/dev/users (auth-disabled only)
- API: auth middleware reads X-Dev-User-Id header when auth is disabled
- Frontend: DevLoginSelector page, DevSessionIndicator bar
- Frontend: fetch interceptor injects X-Dev-User-Id on /api/* calls
- Tests: 7 passing (5 nav + 2 dev login)
Closes#60
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* fix(e2e): seed dev user in localStorage to prevent login redirect
E2E tests were failing because the dev login selector redirects to
/login when AUTH_DISABLED=true and no dev user is in localStorage.
Added a shared Playwright fixture that pre-seeds localStorage with
a default dev user before each test.
Also rebased onto latest main to resolve merge conflict in App.test.tsx.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* fix(e2e): mock /api/dev/config to bypass auth redirect in tests
The fixture now also mocks /api/dev/config to return authDisabled: false,
preventing the app from entering the redirect flow during E2E tests.
Previously only seeded localStorage, but the async config fetch from the
real Docker API was still triggering the redirect check.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
---------
Co-authored-by: Groom Book CTO <cto@groombook.app>
Co-authored-by: Paperclip <noreply@paperclip.ing>
* Improve admin UI visual design — polish look and feel
- Sticky nav bar with subtle shadow, branded GroomBook wordmark, green gradient Book button
- Consistent brand green (#4f8a6f) for primary buttons across all admin pages
- Tables wrapped in white cards with rounded corners and soft shadows
- Uppercase table headers with better spacing and hierarchy
- Input/button border-radius increased to 6px for softer feel
- Global CSS: button transitions, input focus states with brand green ring, subtle card shadows
- Background changed from plain white to light gray (#f0f2f5) for depth
- Reports: polished stat cards with shadows, refined section headers, card-wrapped tables
- Custom scrollbar styling for a cleaner look
Closesgroombook/groombook#58
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* Fix test selectors for branded nav text
- Use regex /Groom\s*Book/ to match split-element brand text
- Use getByRole("link") for Book CTA to avoid matching brand <strong>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* Fix brand text test to handle split-element rendering
The nav brand was changed to <span>Groom</span>Book for color styling,
but getByText with a regex can't match text split across child elements.
Use a custom text matcher that checks the STRONG element's textContent.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* Fix E2E tests for split-element brand name
The brand is now <span>Groom</span>Book (no space), so Playwright's
getByText needs "GroomBook" instead of "Groom Book".
Co-Authored-By: Paperclip <noreply@paperclip.ing>
---------
Co-authored-by: Groom Book CTO <cto@groombook.dev>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Groom Book CTO <cto@groombook.app>
* feat: flip routing — customer portal at /, admin at /admin
Move all admin dashboard routes under /admin prefix and mount the
customer portal at root (/). This gives customers clean, shareable
URLs while staff bookmark /admin.
- Admin routes: /admin, /admin/clients, /admin/services, etc.
- Customer portal: / (root)
- Admin nav "Customer Portal" link points to / for staff preview
- Updated tests for new route structure and fixed React 19 act compat
Closes#56
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* fix(e2e): update tests for routing flip — admin at /admin, portal at /
All E2E tests now use /admin prefix for admin routes (clients, services,
staff, invoices, reports, book). Adds customer portal smoke test at /.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(e2e): use specific locator for customer portal test
getByText('Paws & Reflect') matched 3 elements causing strict mode
violation. Scope to navigation role for unique match.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Groom Book CTO <cto@groombook.dev>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Extract slot generation from book.ts into pure utility for unit testing.
Add 8 API unit tests and 4 web component tests with coverage thresholds.
Closes#39
Co-Authored-By: Paperclip <noreply@paperclip.ing>