Compare commits

...

46 Commits

Author SHA1 Message Date
Flea Flicker 283d0cc8a4 Merge dev into uat: promote GRO-2211/2218/2207 + GRO-2234 portal Book New fixes
CI / Test (pull_request) Successful in 21s
CI / Lint & Typecheck (pull_request) Successful in 27s
CI / Build & Push Docker Image (pull_request) Successful in 14s
# Conflicts:
#	UAT_PLAYBOOK.md
#	src/__tests__/Appointments.test.tsx
#	src/portal/sections/Appointments.tsx
2026-06-08 19:19:26 +00:00
Flea Flicker 1ceac35437 fix(GRO-2234): transparent re-mint on 401 for portal Book New submit (#55)
CI / Test (push) Successful in 23s
CI / Lint & Typecheck (push) Successful in 31s
CI / Build & Push Docker Image (push) Successful in 16s
2026-06-08 19:13:03 +00:00
Flea Flicker bc21d6de09 Promote dev → uat: GRO-2213 portal booking preferredTime HH:MM:SS fix (#52)
CI / Test (push) Successful in 21s
CI / Test (pull_request) Successful in 22s
CI / Lint & Typecheck (push) Successful in 26s
CI / Lint & Typecheck (pull_request) Successful in 28s
CI / Build & Push Docker Image (push) Successful in 25s
CI / Build & Push Docker Image (pull_request) Successful in 20s
2026-06-08 17:36:16 +00:00
Lint Roller 3d0c3c551b fix(portal): show Weight/DoB + Size Category in pet read view (GRO-2207) (#54)
CI / Test (push) Successful in 22s
CI / Test (pull_request) Successful in 18s
CI / Lint & Typecheck (pull_request) Successful in 34s
CI / Build & Push Docker Image (pull_request) Successful in 15s
CI / Lint & Typecheck (push) Failing after 12m56s
CI / Build & Push Docker Image (push) Has been skipped
2026-06-08 17:31:44 +00:00
Flea Flicker c7417dc9e3 docs(uat): add §5.12e Book New preferredTime test cases (GRO-2218) (#53)
CI / Test (push) Failing after 6s
CI / Lint & Typecheck (push) Successful in 21s
CI / Build & Push Docker Image (push) Has been skipped
CI / Test (pull_request) Successful in 18s
CI / Lint & Typecheck (pull_request) Successful in 27s
CI / Build & Push Docker Image (pull_request) Successful in 11s
2026-06-08 16:54:07 +00:00
Flea Flicker 0d52ddd9f0 fix(portal): send preferredTime as HH:MM:SS and format booking slot labels (GRO-2211) (#51)
CI / Test (push) Successful in 18s
CI / Lint & Typecheck (push) Successful in 25s
CI / Build & Push Docker Image (push) Successful in 12s
CI / Test (pull_request) Successful in 22s
CI / Lint & Typecheck (pull_request) Successful in 28s
CI / Build & Push Docker Image (pull_request) Successful in 12s
2026-06-08 16:41:14 +00:00
Flea Flicker 32ef3bca4d Merge pull request 'Promote dev → uat: GRO-2180 portal Appointments ISO startTime fix' (#50) from dev into uat
CI / Lint & Typecheck (pull_request) Failing after 10m47s
CI / Test (push) Failing after 10m51s
CI / Lint & Typecheck (push) Failing after 10m52s
CI / Build & Push Docker Image (push) Has been skipped
CI / Test (pull_request) Failing after 15m38s
CI / Build & Push Docker Image (pull_request) Has been skipped
Merge PR #50: fix(GRO-2180) portal Appointments ISO startTime (dev → uat)

QA-approved (gb_lint); PR CI green after transient runner re-run.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 10:28:50 +00:00
Flea Flicker 2b494c01f8 fix(GRO-2180): portal Appointments handles ISO startTime shape (#49)
CI / Test (push) Successful in 17s
CI / Lint & Typecheck (push) Successful in 22s
CI / Build & Push Docker Image (push) Successful in 14s
CI / Test (pull_request) Successful in 20s
CI / Lint & Typecheck (pull_request) Successful in 26s
CI / Build & Push Docker Image (pull_request) Successful in 10s
Self-merge to dev after green CI (run #115). Phase 1 CI-only gate.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-08 08:26:27 +00:00
Flea Flicker 3397767a01 fix(GRO-2180): normalize portal appointments API shape so /appointments loads
CI / Test (pull_request) Successful in 21s
CI / Lint & Typecheck (pull_request) Successful in 28s
CI / Build & Push Docker Image (pull_request) Successful in 46s
The /api/portal/appointments endpoint returns ISO startTime/endTime plus
nested pet/service/staff objects, but the portal client Appointment type
expected flat date/time/petName fields. isUpcoming() read appt.date/appt.time
(both undefined), so parseTimeTo24Hour(undefined) threw a TypeError; the
useEffect try/catch set the error state and the success-path-only Book New
button became unreachable.

- Add normalizeAppointment() at the fetch boundary mapping the API shape to the
  flat Appointment shape (derives display date/time from startTime, duration
  from the start/end delta), tolerant of the legacy flat shape.
- Prefer absolute startTime in isUpcoming(); fall back to date/time.
- Harden parseTimeTo24Hour against blank/undefined input (no NaN).
- Add Appointment.startTime/endTime to the type.
- Tests: normalizeAppointment + isUpcoming(startTime) + parseTimeTo24Hour safety.
- Update UAT_PLAYBOOK.md §5.12.2 and new §5.12d regression cases.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-08 04:18:35 +00:00
Flea Flicker 47c29ecbc2 Promote to UAT: GRO-2105 BookingFlow/RescheduleFlow availability fix (#47)
CI / Test (push) Successful in 17s
CI / Lint & Typecheck (push) Successful in 23s
CI / Build & Push Docker Image (push) Successful in 19s
CI / Test (pull_request) Failing after 10m34s
CI / Lint & Typecheck (pull_request) Failing after 10m34s
CI / Build & Push Docker Image (pull_request) Has been skipped
2026-06-02 19:17:03 +00:00
Flea Flicker f0c58c193c fix(GRO-2105): include serviceId in BookingFlow/RescheduleFlow availability call (#46)
CI / Test (push) Successful in 22s
CI / Lint & Typecheck (push) Successful in 28s
CI / Test (pull_request) Successful in 25s
CI / Lint & Typecheck (pull_request) Successful in 27s
CI / Build & Push Docker Image (push) Successful in 11s
CI / Build & Push Docker Image (pull_request) Successful in 12s
2026-06-02 19:06:15 +00:00
The Dogfather de7386e47a Promote to UAT: GRO-2094 React bootstrap error instrumentation (#45)
CI / Test (push) Successful in 23s
CI / Lint & Typecheck (push) Successful in 30s
CI / Build & Push Docker Image (push) Successful in 13s
Co-authored-by: The Dogfather <20+gb_dogfather@noreply.git.farh.net>
Co-committed-by: The Dogfather <20+gb_dogfather@noreply.git.farh.net>
2026-06-02 18:42:25 +00:00
The Dogfather 4600dcf950 Merge pull request 'fix(GRO-2094): instrument bootstrap with global error + ErrorBoundary' (#43) from fix/gro-2094-react-blank-mount into dev
CI / Test (push) Successful in 21s
CI / Lint & Typecheck (push) Successful in 32s
CI / Build & Push Docker Image (push) Successful in 15s
CI / Test (pull_request) Successful in 22s
CI / Lint & Typecheck (pull_request) Successful in 28s
CI / Build & Push Docker Image (pull_request) Successful in 14s
2026-06-02 18:32:21 +00:00
Flea Flicker 7daa9c480a fix(GRO-2094): instrument bootstrap with global error + ErrorBoundary
CI / Test (pull_request) Successful in 20s
CI / Lint & Typecheck (pull_request) Successful in 26s
CI / Build & Push Docker Image (pull_request) Successful in 48s
The bundle at /login was executing but the React tree never painted —
no console errors, no fallback UI, just an empty <div id='root'>.
Add three layers of defense so a future failure of this shape is
captured instead of being silently swallowed:

  1. window 'error' and 'unhandledrejection' listeners in main.tsx,
     printing structured context to console.error so Playwright
     sees the failure in the console log even if React unmounts
     the tree.

  2. A top-level <ErrorBoundary> in main.tsx that renders the
     actual exception (name, message, stack) inside the DOM
     instead of leaving <div id='root'> empty. The boundary also
     logs to console.error via componentDidCatch.

  3. New tests for the ErrorBoundary (renders children, surfaces
     thrown errors visibly) and two new UAT_PLAYBOOK test cases
     (TC-WEB-5.1.6 / 5.1.7) that explicitly assert the
     'never-blank-root' invariant on UAT.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-02 17:48:29 +00:00
Scrubs McBarkley 746fad635f Merge pull request 'fix(GRO-2099): show loading state during CustomerPortal SSO bridge bootstrap' (#44) from flea/gro-2099-fix-authed-portal-nav into dev
CI / Test (push) Successful in 22s
CI / Lint & Typecheck (push) Successful in 25s
CI / Build & Push Docker Image (push) Successful in 14s
2026-06-02 16:38:03 +00:00
Flea Flicker f1cf58dc56 fix(GRO-2099): show loading state during CustomerPortal SSO bridge bootstrap
CI / Test (pull_request) Successful in 24s
CI / Lint & Typecheck (pull_request) Successful in 29s
CI / Build & Push Docker Image (pull_request) Successful in 45s
Root cause: `Dashboard.tsx:194` runs its own `!sessionId && !isImpersonating &&
!getDevUser()` auth guard, redirecting to `/login` if `sessionId` is null. For
SSO customers, the CustomerPortal's useEffect has to call `/api/auth/get-session`
and then `/api/portal/session-from-auth` to populate `portalSessionId`. During
that bootstrap window (typically 100-300ms), `sessionId` is null and the guard
fires — redirecting the user to `/login` and breaking the post-sign-in flow.
App.tsx additionally returned `null` at `/login` for authenticated users
(`showCustomerPortal` is false at `/login`), leaving a blank React root even
if the redirect target was /login itself.

Fix:
- `CustomerPortal.tsx`: show a 'Loading…' state (`role=status`) while
  `!initComplete`. The portal chrome and its child sections only mount once
  the bootstrap has resolved, so child auth guards don't fire prematurely.
- `App.tsx`: at `/login` with a valid session, redirect to `/` so the
  customer lands on the portal instead of seeing a blank page.
- `App.tsx`: only return `LoginPage` when at `/login` — other portal
  routes defer the auth check to `CustomerPortal` (the customer SSO bridge
  resolves `portalSessionId` on mount).
- `UAT_PLAYBOOK.md`: add §5.27 with 8 cases covering the bug, the loading
  state, the /login auto-redirect, the unauth fallback, and the groomer /
  impersonation non-regressions.
- `src/__tests__/portal.test.tsx`: add a regression test that asserts the
  loading state is shown during the bridge and the portal nav is NOT in the
  DOM mid-bootstrap.

Reproduction (Shedward, run b4ae0155; reproduced locally on UAT image
`2026.06.01-ec29f71`):
1. From `about:blank`, complete customer SSO as `uat-customer`.
2. `browser_navigate` to `/portal`.
3. Pre-fix: redirected to `/login` with blank React root.
4. Post-fix: URL stays at `/portal`, dashboard renders with customer name.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-02 16:35:16 +00:00
Flea Flicker 903ce2d675 fix(GRO-2089): correct Authentik customer credential source in UAT_PLAYBOOK §5.25 (#42)
CI / Test (push) Successful in 24s
CI / Lint & Typecheck (push) Successful in 29s
CI / Build & Push Docker Image (push) Successful in 15s
fix(GRO-2089): correct Authentik customer credential source in UAT_PLAYBOOK §5.25
2026-06-02 14:48:03 +00:00
The Dogfather ec29f71974 Merge pull request 'Promote to UAT: GRO-2012 RescheduleFlow portalSessionId fallback' (#39) from dev into uat
CI / Test (push) Successful in 21s
CI / Lint & Typecheck (push) Successful in 30s
CI / Build & Push Docker Image (push) Successful in 10s
CI / Test (pull_request) Successful in 21s
CI / Lint & Typecheck (pull_request) Successful in 28s
CI / Build & Push Docker Image (pull_request) Successful in 13s
2026-06-01 17:46:35 +00:00
Flea Flicker f29f1828c8 fix(GRO-2012): pass portalSessionId to RescheduleFlow for SSO bridge customers (#38)
CI / Test (push) Successful in 22s
CI / Lint & Typecheck (push) Successful in 28s
CI / Build & Push Docker Image (push) Successful in 14s
CI / Test (pull_request) Successful in 20s
CI / Lint & Typecheck (pull_request) Successful in 26s
CI / Build & Push Docker Image (pull_request) Successful in 11s
fix(GRO-2012): pass portalSessionId to RescheduleFlow for SSO bridge customers (closes #38)

- src/portal/CustomerPortal.tsx:329 - use portalSessionId fallback for RescheduleFlow
- src/__tests__/portal.test.tsx - new regression test
- UAT_PLAYBOOK.md §5.26 - new test cases

cc @cpfarhood

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-01 17:28:43 +00:00
The Dogfather bd2a0d9516 Merge pull request 'Promote dev -> uat: GRO-2011 login-blank fix (+ GRO-1867)' (#37) from dev into uat
CI / Test (push) Successful in 19s
CI / Lint & Typecheck (push) Successful in 23s
CI / Build & Push Docker Image (push) Successful in 10s
2026-06-01 16:38:14 +00:00
Lint Roller 3d7b247562 fix(GRO-2011): /login renders blank — always fetch setup/status for unauth users (#36)
CI / Test (push) Successful in 21s
CI / Lint & Typecheck (push) Successful in 27s
CI / Build & Push Docker Image (push) Successful in 12s
CI / Test (pull_request) Successful in 20s
CI / Lint & Typecheck (pull_request) Successful in 26s
CI / Build & Push Docker Image (pull_request) Successful in 11s
Co-authored-by: Lint Roller <23+gb_lint@noreply.git.farh.net>
Co-committed-by: Lint Roller <23+gb_lint@noreply.git.farh.net>
2026-06-01 16:36:44 +00:00
Flea Flicker 198053fa31 feat(GRO-1867): bridge Better Auth session to CustomerPortal (#34)
CI / Test (push) Successful in 25s
CI / Lint & Typecheck (push) Successful in 31s
CI / Build & Push Docker Image (push) Successful in 14s
2026-06-01 15:47:41 +00:00
The Dogfather 0e5e9d1f16 Merge pull request 'chore: promote dev → uat (GRO-1829 SW fix)' (#32) from dev into uat
CI / Test (push) Successful in 13s
CI / Lint & Typecheck (push) Successful in 23s
CI / Build & Push Docker Image (push) Successful in 15s
Merge: promote dev → uat (GRO-1829 SW fix)
2026-05-27 02:27:32 +00:00
Scrubs McBarkley 228a3d746c Merge pull request 'fix(GRO-1829): add skipWaiting/clientsClaim to VitePWA workbox config' (#31) from gro-1829-swpwa-fix into dev
CI / Lint & Typecheck (push) Successful in 17s
CI / Test (push) Successful in 17s
CI / Build & Push Docker Image (push) Successful in 31s
CI / Test (pull_request) Successful in 14s
CI / Lint & Typecheck (pull_request) Successful in 17s
CI / Build & Push Docker Image (pull_request) Successful in 15s
2026-05-27 02:22:25 +00:00
Flea Flicker ad9a178c89 fix: add skipWaiting/clientsClaim to VitePWA workbox config
CI / Test (pull_request) Successful in 15s
CI / Lint & Typecheck (pull_request) Successful in 17s
CI / Build & Push Docker Image (pull_request) Successful in 33s
Root cause: SW remained in waiting phase after redeploy, serving stale
precached assets. Without skipWaiting/clientsClaim the old SW persisted
and controlled the page even after a new SW was installed.

Fixes blank-page regression where React never mounted on login.
2026-05-27 02:22:01 +00:00
The Dogfather 9a3b5d88c8 Merge pull request 'fix(GRO-1822): add role check before /admin redirect — customers access portal' (#30) from fix/gro-1822-role-based-redirect into dev
CI / Test (push) Successful in 20s
CI / Lint & Typecheck (push) Successful in 28s
CI / Build & Push Docker Image (push) Successful in 34s
2026-05-27 01:01:59 +00:00
Flea Flicker 4e487db6f1 fix(GRO-1822): add role check before /admin redirect — customers access portal
CI / Test (pull_request) Failing after 14s
CI / Lint & Typecheck (pull_request) Failing after 17s
CI / Build & Push Docker Image (pull_request) Has been skipped
App.tsx lines 389-393 redirected ALL authenticated users to /admin,
breaking customer portal access after SSO login.

Now checks `session.user.role === "staff"` before redirecting.
Customers (role !== "staff") can access the portal at /.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-27 01:01:28 +00:00
The Dogfather 3b4d0f15f6 Merge pull request 'chore: promote dev → uat (GRO-1795 StatusBadge)' (#28) from dev into uat
CI / Lint & Typecheck (push) Successful in 17s
CI / Test (push) Successful in 13s
CI / Build & Push Docker Image (push) Successful in 34s
Merge PR #28: promote dev → uat (GRO-1795 StatusBadge)
2026-05-26 13:23:52 +00:00
The Dogfather 736535a24c Merge pull request 'feat(portal): add StatusBadge to appointment cards (GRO-1795)' (#26) from feature/gro-1165e-booking-status-badge into dev
CI / Test (push) Successful in 15s
CI / Lint & Typecheck (push) Successful in 17s
CI / Lint & Typecheck (pull_request) Successful in 18s
CI / Test (pull_request) Successful in 19s
CI / Build & Push Docker Image (push) Successful in 53s
CI / Build & Push Docker Image (pull_request) Successful in 47s
Merge PR #26: feat(portal): add StatusBadge to appointment cards (GRO-1795)
2026-05-26 13:23:30 +00:00
The Dogfather 87939e5413 Merge pull request 'chore: promote dev → uat (GRO-1794 booking analytics)' (#27) from dev into uat
CI / Test (push) Successful in 19s
CI / Lint & Typecheck (push) Successful in 22s
CI / Build & Push Docker Image (push) Successful in 12s
Merge dev → uat: GRO-1794 booking funnel analytics events
2026-05-26 13:16:39 +00:00
The Dogfather 33a1b3ed7a Merge pull request 'feat(GRO-1794): booking funnel analytics events' (#24) from feature/gro-1165d-booking-analytics into dev
CI / Test (push) Successful in 23s
CI / Lint & Typecheck (push) Successful in 30s
CI / Test (pull_request) Successful in 15s
CI / Lint & Typecheck (pull_request) Successful in 16s
CI / Build & Push Docker Image (push) Successful in 32s
CI / Build & Push Docker Image (pull_request) Successful in 52s
Merge PR #24: feat(GRO-1794): booking funnel analytics events

CTO-approved. QA passed. All CI green.
2026-05-26 13:16:06 +00:00
Flea Flicker 65686c8563 fix(GRO-1795): restore fireEvent and waitFor imports
CI / Test (pull_request) Successful in 15s
CI / Lint & Typecheck (pull_request) Successful in 17s
CI / Build & Push Docker Image (pull_request) Successful in 35s
QA regression: PR #26 removed fireEvent and waitFor from the
@testing-library/react import, breaking 21 test cases and typecheck.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-26 13:12:59 +00:00
Flea Flicker 106d31a95e feat(portal): add StatusBadge to appointment cards
CI / Test (pull_request) Failing after 13s
CI / Lint & Typecheck (pull_request) Failing after 16s
CI / Build & Push Docker Image (pull_request) Has been skipped
Add a StatusBadge component that renders human-readable labels
(Confirmed, Pending, Waitlisted, etc.) with semantic color classes
for appointment cards in the portal. Replaces raw status strings.

- Added STATUS_LABELS map for human-readable status labels
- Updated STATUS_COLORS to use accessible amber/blue tones
- Exported StatusBadge for testing
- Added unit tests for all 7 badge states plus fallback
- Updated UAT_PLAYBOOK.md §5.12c with status badge test cases

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 13:04:02 +00:00
The Dogfather 4e3a038bf3 Merge pull request 'Promote dev → uat (GRO-1793: dynamic time slots)' (#25) from dev into uat
CI / Test (push) Successful in 14s
CI / Lint & Typecheck (push) Successful in 16s
CI / Build & Push Docker Image (push) Failing after 6s
Promote dev → uat: GRO-1793 dynamic portal time slots (#25)
2026-05-26 13:02:16 +00:00
The Dogfather 88ba9915c6 Merge pull request 'GRO-1793: Dynamic portal time slots (replace hardcoded)' (#23) from feature/gro-1165c-dynamic-timeslots into dev
CI / Test (push) Successful in 14s
CI / Lint & Typecheck (push) Successful in 18s
CI / Build & Push Docker Image (push) Successful in 9s
CI / Test (pull_request) Successful in 18s
CI / Lint & Typecheck (pull_request) Successful in 2m33s
CI / Build & Push Docker Image (pull_request) Successful in 8s
GRO-1793: Dynamic portal time slots (replace hardcoded) (#23)

Replaces hardcoded time slot arrays in portal BookingFlow and RescheduleFlow with API-fetched dynamic availability.
2026-05-26 12:56:25 +00:00
Flea Flicker 26cdd69a49 fix(GRO-1793): remove unused act import and add aria-label to date inputs
CI / Test (pull_request) Successful in 14s
CI / Lint & Typecheck (pull_request) Successful in 23s
CI / Build & Push Docker Image (pull_request) Successful in 34s
QA review pointed out:
- Lint error: 'act' imported but never used in test file
- 6 test failures: date input lacked accessible label

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 12:48:55 +00:00
Barcode Betty a873369a9b GRO-1793: Update UAT_PLAYBOOK.md §5.12b — new dynamic time slots tests
CI / Test (pull_request) Failing after 22s
CI / Lint & Typecheck (pull_request) Failing after 28s
CI / Build & Push Docker Image (pull_request) Has been skipped
Added TC-WEB-5.12.5 through TC-WEB-5.12.11 covering BookingFlow and
RescheduleFlow dynamic slot fetching, loading state, error state, and
empty state scenarios.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-26 12:25:54 +00:00
Barcode Betty d78c859c2b Replace hardcoded time slots with dynamic API availability
CI / Lint & Typecheck (pull_request) Failing after 15s
CI / Test (pull_request) Failing after 17s
CI / Build & Push Docker Image (pull_request) Has been skipped
Both BookingFlow and RescheduleFlow in Appointments.tsx now fetch
from /api/book/availability when a date is selected, matching the
public booking wizard behavior. Loading and error states shown.

- Removed hardcoded availableTimes arrays from both flows
- Added useEffect that fetches availability on date change
- Shows "Checking availability…" while loading
- Shows error message on fetch failure
- Shows "No available slots" when API returns empty

Added tests for RescheduleFlow dynamic slot fetching covering:
loading, fetched slots, error, empty, API params, and re-fetch on
date change.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-26 12:23:29 +00:00
Lint Roller 8349ea00de Merge pull request 'promote: dev → uat (GRO-1757 SSO auto-provision fix)' (#19) from dev into uat
CI / Test (push) Successful in 13s
CI / Lint & Typecheck (push) Successful in 33s
CI / Build & Push Docker Image (push) Successful in 14s
CI / Test (pull_request) Successful in 19s
CI / Lint & Typecheck (pull_request) Successful in 24s
CI / Build & Push Docker Image (pull_request) Successful in 15s
promote: dev → uat (GRO-1757 SSO auto-provision fix)
2026-05-25 23:48:10 +00:00
The Dogfather 0306c7fbd9 Merge pull request 'chore(GRO-1592): promote dev→uat SSO session cookie fix' (#16) from promote-uat-gro1592 into uat
CI / Test (push) Successful in 12s
CI / Lint & Typecheck (push) Successful in 18s
CI / Build & Push Docker Image (push) Failing after 39s
2026-05-23 14:13:43 +00:00
Chris Farhood 93da2f1dd8 chore: promote dev→uat for GRO-1592 SSO session cookie fix
CI / Lint & Typecheck (pull_request) Successful in 17s
CI / Test (pull_request) Successful in 18s
CI / Build & Push Docker Image (pull_request) Failing after 41s
- Fixed frontend auth client baseURL fallback to use window.location.origin
- Added UAT test coverage (TC-AUTH-5.3.4)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-23 14:13:12 +00:00
The Dogfather 62cbfe4e43 Merge pull request 'promote: dev → uat (GRO-1173 buffer rules + GRO-1470 pet save persistence)' (#14) from dev into uat
CI / Test (push) Successful in 14s
CI / Lint & Typecheck (push) Successful in 19s
CI / Build & Push Docker Image (push) Successful in 9s
promote: dev → uat (GRO-1173 buffer rules + GRO-1470 pet save persistence) (#14)

Merged-By: The Dogfather (CTO)
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 19:46:41 +00:00
The Dogfather db6a2a1bbf Merge pull request 'promote: dev → uat (Renovate config, GRO-1081)' (#11) from dev into uat
promote: dev → uat (Renovate config, GRO-1081)

Merge PR #11: dev → uat promotion
Includes: chore: add Renovate config (GRO-1081)
2026-05-20 12:42:04 +00:00
The Dogfather 032a3796ba Merge pull request 'chore: promote dev to uat (CI Docker registry fix)' (#10) from dev into uat
chore: promote dev to uat (CI Docker registry fix) (#10)

Promotes GRO-1348 CI registry fix to UAT.
2026-05-20 11:17:21 +00:00
the-dogfather-cto[bot] cac8fc947e chore(GRO-1289): promote dev to uat — add UAT_PLAYBOOK.md
chore(GRO-1289): promote dev to uat — add UAT_PLAYBOOK.md
2026-05-14 21:13:56 +00:00
the-dogfather-cto[bot] 592be1301c chore: promote dev to uat (#3)
chore: promote dev to uat
2026-05-11 13:19:33 +00:00
17 changed files with 1739 additions and 79 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"mcpServers": {
"gitea": {
"type": "http",
"url": "https://git-mcp.farh.net/mcp",
"headers": {
"Authorization": "Bearer ${GITEA_TOKEN}"
}
}
}
}
+148 -1
View File
@@ -53,6 +53,9 @@ export const { signIn, signOut, useSession, changePassword } = authClient;
| TC-WEB-5.1.2 | OIDC redirect | Click OIDC login button | Redirected to OIDC provider, then back to app with session established |
| TC-WEB-5.1.3 | Logout | Click logout button | Session cleared, redirected to login page |
| TC-WEB-5.1.4 | Session indicator | After successful login | User info/initials visible in UI indicating active session |
| TC-WEB-5.1.5 | Unauthenticated `/login` renders the form (GRO-2011) | In a private/incognito window with no session cookie, navigate to UAT `/login` | React root mounts; the GroomBook sign-in card with the OIDC button is visible. Network tab shows `/api/auth/get-session` 200, `/api/setup/status` 200, and the login form is rendered (NOT a blank white viewport). |
| TC-WEB-5.1.6 | Swallowed render error surfaces in DOM (GRO-2094) | Trigger a render-time exception in the React tree (e.g. via temporary throw in a child component on a test build) and load `/login` in a clean context | Either the login form renders normally (happy path) OR the top-level `ErrorBoundary` testid `error-boundary` is visible with a populated `error-boundary-message` pre block showing the exception name/message/stack. **NEVER** a blank `<div id="root">` with no error indicator. Browser console must contain either zero render errors or a `[ErrorBoundary]` line plus the raw exception. |
| TC-WEB-5.1.7 | Global `error` and `unhandledrejection` listeners are wired (GRO-2094) | In a clean browser context, load `/login`, then trigger `setTimeout(() => { throw new Error("synthetic") }, 0)` from the console and `Promise.reject(new Error("synthetic-promise"))` | Browser console shows `[window.error]` and `[unhandledrejection]` log lines with the thrown values. Confirms global listeners are active in production. |
### 5.2 Authentication — VITE_API_URL Set
@@ -179,10 +182,89 @@ export const { signIn, signOut, useSession, changePassword } = authClient;
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-5.12.1 | Client-facing view | Log in as client persona | Customer portal UI displayed |
| TC-WEB-5.12.2 | Appointment list | View client portal appointments | List of client's appointments visible |
| TC-WEB-5.12.2 | Appointment list | View client portal appointments | List of client's appointments visible — each card shows pet name, service, formatted date/time, and groomer (no "Failed to load appointments" error, no blank screen). "Book New" button is visible and clickable. See 5.12d. |
| TC-WEB-5.12.3 | Confirm appointment | Click confirm on pending appointment | Appointment status updated to confirmed |
| TC-WEB-5.12.4 | Cancel appointment | Click cancel on appointment | Appointment marked as cancelled |
#### 5.12b Dynamic Portal Time Slots (GRO-1793, GRO-2105)
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-5.12.5 | BookingFlow dynamic slots | Open Book New, select pet and service, pick a date | `GET /api/book/availability?serviceId=<selected>&date=<picked>`; "Checking availability…" shown while loading; slot list rendered |
| TC-WEB-5.12.6 | BookingFlow slots match wizard | Compare BookingFlow slot times with public booking wizard for same date | Same slots displayed |
| TC-WEB-5.12.7 | BookingFlow error state | Mock API failure on availability fetch (4xx/5xx OR a 200 with non-array body) | "Failed to load time slots" error shown and the page stays interactive (no white screen) |
| TC-WEB-5.12.8 | BookingFlow no slots | Select date with no availability | "No available slots on this date" shown |
| TC-WEB-5.12.9 | RescheduleFlow dynamic slots | Open reschedule, pick a new date | `GET /api/book/availability?serviceId=<appt.serviceId>&date=<picked>`; loading state shown; slot list rendered |
| TC-WEB-5.12.10 | RescheduleFlow error state | Mock API failure on availability fetch (4xx/5xx OR a 200 with non-array body) | "Failed to load time slots" error shown and the page stays interactive (no white screen) |
| TC-WEB-5.12.11 | RescheduleFlow no slots | Select date with no availability | "No available slots on this date" shown |
> **GRO-2105 regression note:** prior to the fix, both `BookingFlow` and
> `RescheduleFlow` called `/api/book/availability` with only `date=…`, so the
> API responded 400 `{error:"serviceId and date are required"}`. The React
> handler then `.map()`'d that error object, throwing `TypeError: ee.map is
> not a function` and wiping `<div id="root">`. The fix ensures both flows
> include `serviceId` in the query string and surface the API's error string
> (or "Failed to load time slots") instead of crashing.
#### 5.12c Waitlist/Booking Status Badges (GRO-1795)
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-5.12.12 | Confirmed badge | View appointment card with confirmed status | Green "Confirmed" badge displayed |
| TC-WEB-5.12.13 | Pending badge | View appointment card with pending status | Amber "Pending" badge displayed |
| TC-WEB-5.12.14 | Waitlisted badge | View appointment card with waitlisted status | Blue "Waitlisted" badge displayed |
| TC-WEB-5.12.15 | Badge uses CSS classes | Inspect badge element | Badge uses CSS variable-based classes (e.g., bg-green-100, text-amber-600), not hardcoded colors |
| TC-WEB-5.12.16 | Badge status from data | Compare badge label to appointment.status field | Badge label matches the API appointment status exactly |
| TC-WEB-5.12.17 | Unknown status fallback | Render badge with unknown status value | Badge renders with the raw status string as label and fallback CSS class |
#### 5.12d Appointment API Shape Normalization (GRO-2180)
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-5.12.18 | Portal appointments load (regression) | Sign in as `uat-customer@groombook.dev`, open `Appointments` | List renders without the "Failed to load appointments. Please try again." error; "Book New" button is visible and clickable |
| TC-WEB-5.12.19 | Card fields populated from API | Inspect an appointment card | Pet name, service, formatted date (e.g. "Mon, Jun 1, 2026"), time (e.g. "10:00 AM"), and groomer name render — derived from the API's `startTime`/`endTime`/nested `pet`/`staff` objects |
| TC-WEB-5.12.20 | Upcoming vs Past split | View both tabs | Future, non-cancelled/non-completed appointments appear under "Upcoming"; past/completed/cancelled under "Past" (classification uses absolute `startTime`) |
| TC-WEB-5.12.21 | Reschedule from card | Expand an upcoming appointment, click Reschedule, pick a date | `GET /api/book/availability?serviceId=<appt.serviceId>&date=<picked>` fires with a non-empty `serviceId` (sourced from the API's nested `service.id`) |
> **GRO-2180 regression note:** `/api/portal/appointments` returns ISO
> `startTime`/`endTime` and nested `pet`/`service`/`staff` objects, but the portal
> client `Appointment` type expected flat `date`/`time`/`petName` fields.
> `isUpcoming()` read `appt.date`/`appt.time` (both `undefined`), so
> `parseTimeTo24Hour(undefined)` threw `TypeError`, the `useEffect` `try/catch`
> set the error state, and the "Book New" button (only rendered in the success
> path) became unreachable. The fix normalizes the API response into the flat
> `Appointment` shape at the fetch boundary (`normalizeAppointment`), prefers the
> absolute `startTime` in `isUpcoming`, and hardens `parseTimeTo24Hour` against
> blank/undefined input.
#### 5.12e Book New `preferredTime` Formatting (GRO-2211, GRO-2213)
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-5.12.22 | Slot buttons show formatted label | Sign in as `uat-customer@groombook.dev`, open `Appointments`, click "Book New", select a pet and service, pick a date with availability | Each time-slot button shows a human-readable label like `10:00 AM` (UTC), never a raw ISO timestamp (e.g. not `2026-06-09T10:00:00.000Z`) |
| TC-WEB-5.12.23 | Confirmation review shows formatted label | Continue the Book New wizard to the Review step | The "Date & Time" summary and the final confirmation both display the formatted slot label (e.g. `10:00 AM`), not a raw ISO string |
| TC-WEB-5.12.24 | Booking submit succeeds (regression) | Complete the Book New wizard and submit the request | Request succeeds with no `500` / `invalid input syntax for type time` error; the booking POST sends `preferredTime` as `HH:MM:SS` (e.g. `10:00:00`); the new appointment appears in the Upcoming list |
| TC-WEB-5.12.25 | Slow-wizard submit succeeds (GRO-2234) | Sign in as `uat-customer@groombook.dev`, open `Appointments`, click "Book New", then deliberately pace the wizard (pet → service → groomer → date/slot → review) so that **>2 minutes** elapse before clicking "Confirm Booking". | Submit returns success — **no** "Failed to book appointment. Please try again." error. In DevTools → Network, if the first `POST /api/portal/waitlist` returns `401`, a `POST /api/portal/session-from-auth` fires immediately after and the booking is retried once with the fresh `X-Impersonation-Session-Id`, then returns 201. The appointment appears in the Upcoming list. |
> **GRO-2234 note:** A deliberately-paced Book New wizard could outlive the
> portal impersonation session, so the final `POST /api/portal/waitlist` returned
> `401 {"error":"Unauthorized"}` ("Failed to book appointment"). The web fix adds
> a transparent one-shot re-mint: on a `401` from the waitlist submit,
> `BookingFlow` calls `POST /api/portal/session-from-auth` (the Better Auth
> cookie is still valid) and retries the submit once with the fresh session id.
> The companion API fix (groombook/api GRO-2234) adds bounded sliding expiration
> so active sessions rarely lapse in the first place.
> **GRO-2211/GRO-2213 note:** The Book New wizard previously rendered the raw
> UTC ISO slot string as the button/confirmation label and submitted that same
> ISO value as `preferredTime`, which the API rejected with
> `invalid input syntax for type time` (HTTP 500). The fix adds shared UTC
> helpers `formatSlotLabel(slot)` (display → `10:00 AM`) and `slotToTime(slot)`
> (payload → `HH:MM:SS`) in `src/portal/sections/Appointments.tsx`, so the
> displayed label and the submitted `preferredTime` both derive from the same
> canonical UTC ISO slot. (The sibling `RescheduleFlow` `startTime` raw-ISO issue
> on a different endpoint is tracked separately and is out of scope here.)
### 5.13 Reports UI
| # | Scenario | Steps | Expected |
@@ -324,6 +406,71 @@ export const { signIn, signOut, useSession, changePassword } = authClient;
| TC-WEB-5.24.12 | No PII in analytics payloads | Fire each event and inspect detail object | Payload contains only: step, flow, timestamp — no names, emails, phone numbers, or pet names |
| TC-WEB-5.24.13 | No-op safe | Trigger analytics with window.dispatchEvent blocked (e.g. CSP) | No error thrown; booking flow completes normally |
### 5.25 Customer Portal — Better Auth SSO Bridge (GRO-1867)
These cases cover the `CustomerPortal` initialisation path that bridges an Authentik / Better Auth session into a portal session via `POST /api/portal/session-from-auth`. The bridge runs after the URL-impersonation (`?sessionId=`) and dev-user paths have been ruled out.
**Pre-conditions:**
- UAT is configured with Authentik SSO. The seeded customer **Authentik** password lives in the `authentik-uat-users-credentials` Secret in the `groombook-uat` namespace (key `uat_customer_password`) — **NOT** in `seed-uat-passwords:customer-password` (that Secret holds the *Better Auth* email+password credential, a separate identity store; see GRO-2089). Pull the Authentik password at the start of every run:
```bash
CUSTOMER_AUTHENTIK=$(kubectl get secret authentik-uat-users-credentials -n groombook-uat \
-o jsonpath='{.data.uat_customer_password}' | base64 -d)
```
The Authentik user is provisioned by Terraform (`infra/terraform/users.tf`); the `lifecycle.ignore_changes = [password]` block means the password is set on initial creation and never auto-rotated, so the value held in the live Secret is the one Authentik itself has. If Authentik rejects it, the user was re-provisioned out-of-band via the Authentik admin UI and the Secret has drifted from the live identity — fix the Secret (or the admin-set password) and re-run.
- `POST /api/portal/session-from-auth` from [GRO-1866](https://paperclip.farhoodlabs.com/GRO/issues/GRO-1866) is deployed on UAT.
- Clear cookies and localStorage between cases unless otherwise noted.
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-5.25.1 | Authenticated customer reaches portal dashboard | 1. From clean state, navigate to UAT `/login`. 2. Click "Sign in with SSO" and complete Authentik flow with a seeded **customer** identity. 3. After callback, land on `/`. | Portal dashboard renders. No redirect to `/login`. No impersonation banner. Top-right greeting reads "Hi, &lt;FirstName&gt;". |
| TC-WEB-5.25.2 | Bridge call sequence | Repeat TC-WEB-5.25.1 with DevTools → Network open and the **All** tab filtered to `/api/`. | In order: `GET /api/auth/get-session` → 200. `POST /api/portal/session-from-auth` → 201 with body `{ sessionId, clientId, clientName }`. |
| TC-WEB-5.25.3 | Subsequent portal calls use the bridged session ID | After TC-WEB-5.25.1 succeeds, navigate to **Appointments**, **My Pets**, **Billing**, **Settings**. Inspect any `/api/portal/*` request in DevTools → Network. | Each portal API call carries an `X-Impersonation-Session-Id` header whose value equals the `sessionId` returned by `session-from-auth` (not a URL-param value). Each call returns 200 (or 404 for genuinely empty collections), never 401. |
| TC-WEB-5.25.4 | No impersonation chrome for the customer's own session | After TC-WEB-5.25.1, scan the portal UI. | No amber border around the page. No "STAFF VIEW" watermark. No "End Impersonation" button in the sidebar. The customer is themselves; only impersonation sessions started via `?sessionId=` show the banner. |
| TC-WEB-5.25.5 | 404 fallback for authenticated user with no client record | 1. Sign in via SSO with an Authentik account whose email is **not** present in `clients`. 2. Land on `/`. | `POST /api/portal/session-from-auth` returns 404. The portal renders a centred card titled **"Portal access not configured"** with the message about contacting the groomer and a **Sign out** button. No redirect loop, no portal chrome. |
| TC-WEB-5.25.6 | 404 fallback Sign-out escape hatch | From TC-WEB-5.25.5 click **Sign out**. | `POST /api/auth/sign-out` fires; browser navigates to `/login`; the Authentik session cookie is cleared. Reloading `/` no longer hits 404 (will show the login page). |
| TC-WEB-5.25.7 | Bridge precedence — impersonation URL wins | 1. Sign in via SSO as a customer. 2. Open a new tab to `https://uat.groombook.dev/?sessionId=<a-valid-staff-impersonation-session-id>`. | The impersonation path runs; the amber banner appears for the impersonated client. The Better Auth bridge is **not** called on this load (`session-from-auth` absent in Network). |
| TC-WEB-5.25.8 | Bridge precedence — dev user wins | In dev mode (e.g. local) with `localStorage["dev-user"]` set to a client persona, navigate to `/`. | The dev-session path runs (`POST /api/portal/dev-session`). The Better Auth bridge is **not** called (`session-from-auth` absent in Network). Staff dev users still redirect to `/admin`. |
| TC-WEB-5.25.9 | Staff Better Auth session does not run the customer bridge | Sign in via SSO with a staff identity. Navigate to `/`. | `App.tsx` routing redirects to `/admin`. `POST /api/portal/session-from-auth` is **not** called. |
| TC-WEB-5.25.10 | Unauthenticated user is sent to login (no infinite loop) | Without signing in, navigate directly to `/`. | `App.tsx` renders the LoginPage. `CustomerPortal` does not render. No `session-from-auth` request is made. |
| TC-WEB-5.25.11 | Session persists across reload via Better Auth cookie | After TC-WEB-5.25.1 succeeds, reload the page. | Portal dashboard re-renders. A fresh `GET /api/auth/get-session` + `POST /api/portal/session-from-auth` pair runs and yields 200/201. Greeting still reads "Hi, &lt;FirstName&gt;". |
### 5.27 Customer Portal — Authenticated HTML-route cold mount (GRO-2099)
These cases guard against the regression where a customer who had just completed SSO sign-in was bounced back to `/login` (with a blank React root) when navigating directly to `/portal`, `/book`, `/schedule`, or even `/login` itself. Root cause: `Dashboard.tsx`'s `!sessionId && !isImpersonating && !getDevUser()` guard fired during the CustomerPortal's bootstrap — before the SSO bridge resolved `portalSessionId` — and redirected to `/login`. The fix: `CustomerPortal` now shows a loading state while the bootstrap is in flight, so the portal chrome and its `!sessionId` child guards do not mount prematurely. App.tsx additionally redirects an authenticated user at `/login` to `/` instead of rendering `null`.
**Pre-conditions:**
- TC-WEB-5.25.1 — TC-WEB-5.25.3 must pass on the build under test.
- Clear cookies and localStorage between cases.
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-5.27.1 | Authenticated customer lands on `/portal` after direct nav | 1. From clean state, complete TC-WEB-5.25.1 (SSO sign-in as a customer). 2. Land on `/`. 3. `browser_navigate` (full page load) directly to `/portal`. | Final URL stays at `/portal`. The React root is non-empty. The portal dashboard renders with the customer's name. No `Navigate to /login` fires. |
| TC-WEB-5.27.2 | Authenticated customer lands on `/book` and `/schedule` after direct nav | From TC-WEB-5.27.1, `browser_navigate` to `/book` then `/schedule` (one fresh navigation each). | Each final URL stays at the navigated path. The portal chrome is visible. The page does not redirect to `/login`. |
| TC-WEB-5.27.3 | Authenticated customer at `/login` is auto-redirected to `/` | From TC-WEB-5.27.1, `browser_navigate` to `/login`. | The browser ends at `/` (not at a blank `/login`). The portal dashboard renders. No blank React root at `/login`. |
| TC-WEB-5.27.4 | Loading state is visible during the bootstrap, no portal chrome flash | 1. With the UAT build under test, open DevTools → Network and throttle to Slow 3G. 2. Sign in via SSO. 3. Land on `/`. | A "Loading…" element (`role="status"`) is briefly visible. The portal nav (Home / Appointments / etc.) is NOT visible during the loading window. No `Navigate to /login` fires during the bootstrap. |
| TC-WEB-5.27.5 | SSO bridge still runs and yields 201 | From TC-WEB-5.27.4 (or TC-WEB-5.27.1), inspect Network. | The same `GET /api/auth/get-session` (200) → `POST /api/portal/session-from-auth` (201) sequence from TC-WEB-5.25.2 still runs. The customer name appears in the greeting. |
| TC-WEB-5.27.6 | Unauthenticated direct nav to `/portal` still ends at `/login` (no regression) | Clear cookies. `browser_navigate` to `/portal`. | The portal briefly shows the loading state, then `CustomerPortal`'s `!session && !portalSessionId` guard redirects to `/login`. The login form renders. No infinite loop. |
| TC-WEB-5.27.7 | Groomer SSO still works (no regression) | 1. From clean state, sign in via SSO as the groomer identity (uat-groomer). 2. Land on `/`. | `App.tsx`'s staff check redirects to `/admin`. The groomer nav renders. No `CustomerPortal` flash. No `/portal` redirect loop. |
| TC-WEB-5.27.8 | Impersonation session still works (no regression) | 1. With an active impersonation session, open `/?sessionId=<id>`. | The amber "STAFF VIEW" chrome renders. The portal loads. No `/login` redirect. |
### 5.26 Customer Portal — RescheduleFlow under SSO Bridge (GRO-2012)
These cases guard against the regression where an SSO-bridge customer (no `?sessionId=` URL param, no impersonation session) could trigger the RescheduleFlow and have `RescheduleFlow` receive `sessionId={null}`, which caused the internal `/api/book/availability` call to send `X-Impersonation-Session-Id: ` (empty) and return 401. The fix: `CustomerPortal` now passes `sessionId={session?.id ?? portalSessionId}` to `<RescheduleFlow>` (matching the fallback `renderSection()` already used).
**Pre-conditions:**
- TC-WEB-5.25.1 — TC-WEB-5.25.3 must pass on the build under test.
- The seeded customer used has at least one upcoming, non-cancelled appointment with `status` ∈ {`pending`, `confirmed`}.
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-5.26.1 | RescheduleFlow receives portalSessionId (no 401) | 1. Complete TC-WEB-5.25.1 (SSO sign-in as a customer). 2. From the dashboard, click **Reschedule** on the next-upcoming appointment. 3. In the RescheduleFlow modal, pick a future date. 4. Open DevTools → Network and filter to `/api/`. | The `GET /api/book/availability?date=<picked>` request includes an `X-Impersonation-Session-Id` header whose value equals the `sessionId` from `session-from-auth`. The request returns 200. The time-slot list populates. No 401. |
| TC-WEB-5.26.2 | RescheduleFlow submit succeeds | From TC-WEB-5.26.1, pick a time slot and confirm. | `POST /api/portal/appointments/<id>/reschedule` (or the equivalent) includes the same `X-Impersonation-Session-Id` value. Returns 200. The modal closes and the appointment card reflects the new time. |
| TC-WEB-5.26.3 | Impersonation flow reschedule is unchanged (no regression) | 1. With an active impersonation session (`?sessionId=<active>`), load `/`. 2. Click **Reschedule** on an appointment. 3. Pick a date. | `GET /api/book/availability` includes `X-Impersonation-Session-Id` equal to the impersonation `sessionId` (not `portalSessionId`). Returns 200. Behaves identically to the pre-fix build. |
| TC-WEB-5.26.4 | No `X-Impersonation-Session-Id` is empty / null | From TC-WEB-5.26.1, inspect every `/api/portal/*` and `/api/book/*` request. | No request has an empty or `null` `X-Impersonation-Session-Id` header. |
## 6. Pass/Fail Criteria
**Pass:**
+4
View File
@@ -34,6 +34,10 @@ export interface Pet {
breed: string | null;
weightKg: number | null;
dateOfBirth: string | null;
/** Portal-shaped serialization of weightKg (GET/PATCH /api/portal/pets). */
weight?: string | number | null;
/** Portal-shaped serialization of dateOfBirth (GET/PATCH /api/portal/pets). */
birthDate?: string | null;
healthAlerts: string | null;
groomingNotes: string | null;
cutStyle: string | null;
+25 -7
View File
@@ -327,11 +327,16 @@ export function App() {
.catch(() => setAuthDisabled(false));
}, []);
// After session is confirmed, check if setup is needed
// After session is confirmed, check if setup is needed.
// Always run the setup/status fetch as soon as the auth state is known — even for
// unauthenticated users, so the `needsSetup` value is in place if they sign in
// mid-session. The unauth branch in the render below is handled before
// `needsSetup` is consulted, so this is safe and avoids a stuck-`null` state.
// See GRO-2011.
useEffect(() => {
if (authDisabled === null || sessionLoading) return;
// Skip if no authenticated session (will redirect to login or dev selector)
if (!authDisabled && !session) return;
// In dev mode, only fetch when a dev user has been selected — otherwise the
// user is mid-redirect to the dev login selector and we don't need setup state.
if (authDisabled && !getDevUser()) return;
fetch("/api/setup/status")
@@ -373,8 +378,12 @@ export function App() {
return <Navigate to="/login" replace />;
}
// Show login BEFORE checking needsSetup (needsSetup is never set for unauthenticated users)
if (!authDisabled && !session) {
// Show login BEFORE checking needsSetup (needsSetup is never set for unauthenticated users).
// At /login with a valid session, fall through so the staff redirect below can
// route staff to /admin and the final render can redirect customers to / (portal).
// Previously, an authenticated customer at /login would see a blank page because
// the final render returns null at /login (showCustomerPortal is false). See GRO-2099.
if (!authDisabled && !session && location.pathname === "/login") {
return <LoginPage />;
}
@@ -386,15 +395,24 @@ export function App() {
return <Navigate to="/setup" replace />;
}
// Redirect authenticated users to /admin (but preserve impersonation flow via ?sessionId=)
// Redirect staff to /admin; allow customers to access portal (preserve impersonation via ?sessionId=)
const searchParams = new URLSearchParams(location.search);
if (!authDisabled && session && !location.pathname.startsWith("/admin") && !searchParams.has("sessionId")) {
const isStaff = session?.user && (session.user as any).role === "staff";
if (!authDisabled && session && !location.pathname.startsWith("/admin") && !searchParams.has("sessionId") && isStaff) {
return <Navigate to="/admin" replace />;
}
// Don't render portal chrome at /login — DevLoginSelector is shown instead
const showCustomerPortal = !location.pathname.startsWith("/admin") && location.pathname !== "/login";
// At /login with a valid session, redirect to the portal root. Without this,
// the final render returns null at /login (showCustomerPortal is false) and
// the user sees a blank page after a successful sign-in. Staff are routed
// to /admin by the earlier staff check. See GRO-2099.
if (!authDisabled && session && location.pathname === "/login") {
return <Navigate to="/" replace />;
}
return (
<BrandingProvider>
{location.pathname.startsWith("/admin") ? (
+77
View File
@@ -0,0 +1,77 @@
import { Component } from "react";
import type { ErrorInfo, ReactNode } from "react";
interface ErrorBoundaryProps {
children: ReactNode;
}
interface ErrorBoundaryState {
error: Error | null;
}
/**
* Top-level ErrorBoundary — renders the error visibly so the actual exception
* appears in the DOM (and therefore in the Playwright snapshot) instead of
* React 18+ unmounting the entire tree to a blank `<div id="root">`.
*
* Background: GRO-2094. The bundle was executing but never painting, with
* the failure swallowed. Surfacing the error here is the first step; the
* real fix is in the underlying component that threw.
*/
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = { error: null };
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo): void {
// Also surface to the console — this is what the test harness greps for.
// eslint-disable-next-line no-console
console.error("[ErrorBoundary] Uncaught render error:", error, info);
}
render() {
if (this.state.error) {
const err = this.state.error;
return (
<div
data-testid="error-boundary"
style={{
padding: "2rem",
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
color: "#7f1d1d",
background: "#fef2f2",
minHeight: "100vh",
boxSizing: "border-box",
}}
>
<h1 style={{ fontSize: 18, margin: "0 0 0.5rem" }}>Something went wrong</h1>
<p style={{ margin: "0 0 1rem", color: "#991b1b" }}>
The app failed to render. The full error is shown below please share this
output when reporting the bug.
</p>
<pre
data-testid="error-boundary-message"
style={{
whiteSpace: "pre-wrap",
wordBreak: "break-word",
background: "#fff",
border: "1px solid #fecaca",
borderRadius: 6,
padding: "0.75rem 1rem",
margin: 0,
fontSize: 13,
lineHeight: 1.4,
}}
>
{err.name}: {err.message}
{"\n\n"}
{err.stack ?? "(no stack)"}
</pre>
</div>
);
}
return this.props.children;
}
}
+59
View File
@@ -121,6 +121,65 @@ describe("App navigation", () => {
});
});
describe("GRO-2011 — setup/status fetch for unauthenticated users", () => {
it("calls /api/setup/status for unauthenticated users so needsSetup is never stuck null", async () => {
const setupStatusCalls: string[] = [];
global.fetch = vi.fn((url: string) => {
if (url === "/api/dev/config") {
return Promise.resolve({
ok: true,
json: async () => ({ authDisabled: false }),
} as Response);
}
if (url === "/api/auth/get-session") {
// Better Auth returns 200 with null session for unauthenticated users.
return Promise.resolve({
ok: true,
json: async () => null,
} as unknown as Response);
}
if (url === "/api/setup/status") {
setupStatusCalls.push(url);
return Promise.resolve({
ok: true,
json: async () => ({ needsSetup: false }),
} as Response);
}
if (url === "/api/branding") {
return Promise.resolve({
ok: true,
json: async () => ({
businessName: "GroomBook",
primaryColor: "#4f8a6f",
accentColor: "#8b7355",
logoBase64: null,
logoMimeType: null,
}),
} as Response);
}
return Promise.resolve({ ok: true, json: async () => [] } as Response);
}) as unknown as typeof fetch;
render(
<MemoryRouter initialEntries={["/login"]}>
<App />
</MemoryRouter>
);
// The login page should be rendered for the unauthenticated user.
await screen.findByText("Sign in to continue");
// Crucially, /api/setup/status must be called even when the user is unauthenticated —
// otherwise `needsSetup` stays null and a later code path can short-circuit to a
// blank page (GRO-2011).
await waitFor(() => {
expect(setupStatusCalls.length).toBeGreaterThanOrEqual(1);
});
expect(setupStatusCalls[0]).toBe("/api/setup/status");
});
});
describe("Dev login selector", () => {
it("redirects to /login when auth is disabled and no user selected", async () => {
global.fetch = vi.fn((url: string) => {
+495 -2
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { parseTimeTo24Hour, isUpcoming, CustomerNotesSection, ConfirmationSection } from "../portal/sections/Appointments.tsx";
import { parseTimeTo24Hour, isUpcoming, normalizeAppointment, CustomerNotesSection, ConfirmationSection, StatusBadge, formatSlotLabel, slotToTime, BookingFlow } from "../portal/sections/Appointments.tsx";
const UPCOMING_APPT = {
id: "appt-1",
@@ -42,6 +42,84 @@ describe("parseTimeTo24Hour", () => {
expect(parseTimeTo24Hour("11:00 PM")).toBe("23:00:00");
expect(parseTimeTo24Hour("12:00 PM")).toBe("12:00:00");
});
it("does not throw on undefined/null/empty input (GRO-2180)", () => {
expect(() => parseTimeTo24Hour(undefined)).not.toThrow();
expect(() => parseTimeTo24Hour(null)).not.toThrow();
expect(parseTimeTo24Hour(undefined)).toBe("00:00:00");
expect(parseTimeTo24Hour("")).toBe("00:00:00");
});
});
// GRO-2180: `/api/portal/appointments` returns ISO `startTime`/`endTime` + nested
// pet/service/staff objects, not the flat date/time/petName shape the UI renders.
describe("normalizeAppointment (API startTime shape — GRO-2180)", () => {
const RAW_API_APPT = {
id: "a0000001-0000-0000-0000-000000000001",
startTime: "2026-06-01T10:00:00.000Z",
endTime: "2026-06-01T10:45:00.000Z",
status: "completed" as const,
confirmationStatus: "confirmed" as const,
customerNotes: "Please be gentle",
notes: null,
pet: { id: "c0000001-0000-0000-0000-000000000001", name: "UAT Pup Alpha", photo: null },
service: { id: "b0000001-0000-0000-0000-000000000001", name: "Full Groom" },
staff: { id: "00000000-0000-0000-0000-000000000004", name: "UAT Staff Groomer" },
};
it("maps nested pet/service/staff and ISO startTime without throwing", () => {
const appt = normalizeAppointment(RAW_API_APPT);
expect(appt.id).toBe("a0000001-0000-0000-0000-000000000001");
expect(appt.petId).toBe("c0000001-0000-0000-0000-000000000001");
expect(appt.serviceId).toBe("b0000001-0000-0000-0000-000000000001");
expect(appt.groomerId).toBe("00000000-0000-0000-0000-000000000004");
expect(appt.petName).toBe("UAT Pup Alpha");
expect(appt.serviceName).toBe("Full Groom");
expect(appt.groomerName).toBe("UAT Staff Groomer");
expect(appt.startTime).toBe("2026-06-01T10:00:00.000Z");
expect(appt.customerNotes).toBe("Please be gentle");
});
it("derives duration in minutes from start/end delta", () => {
expect(normalizeAppointment(RAW_API_APPT).duration).toBe(45);
});
it("produces a date/time pair that does not crash isUpcoming or formatDate", () => {
const appt = normalizeAppointment(RAW_API_APPT);
expect(typeof appt.date).toBe("string");
expect(typeof appt.time).toBe("string");
expect(() => isUpcoming(appt)).not.toThrow();
});
it("classifies a past completed appointment as not upcoming", () => {
expect(isUpcoming(normalizeAppointment(RAW_API_APPT))).toBe(false);
});
it("classifies a future scheduled appointment as upcoming via startTime", () => {
const future = normalizeAppointment({
...RAW_API_APPT,
startTime: "2099-01-01T10:00:00.000Z",
endTime: "2099-01-01T11:00:00.000Z",
status: "confirmed",
});
expect(isUpcoming(future)).toBe(true);
});
it("tolerates null nested objects without throwing", () => {
const appt = normalizeAppointment({
id: "a2",
startTime: "2099-01-01T10:00:00.000Z",
endTime: "2099-01-01T11:00:00.000Z",
status: "scheduled",
pet: null,
service: null,
staff: null,
});
expect(appt.petId).toBe("");
expect(appt.serviceId).toBe("");
expect(appt.groomerId).toBeNull();
expect(appt.petName).toBeUndefined();
});
});
describe("isUpcoming", () => {
@@ -379,4 +457,419 @@ describe("ConfirmationSection", () => {
expect(screen.getByText(/Confirmed!/i)).toBeInTheDocument();
});
});
});
});
describe("StatusBadge", () => {
it("renders Confirmed for confirmed status", () => {
render(<StatusBadge status="confirmed" />);
expect(screen.getByText("Confirmed")).toBeInTheDocument();
});
it("renders Pending for pending status", () => {
render(<StatusBadge status="pending" />);
expect(screen.getByText("Pending")).toBeInTheDocument();
});
it("renders Waitlisted for waitlisted status", () => {
render(<StatusBadge status="waitlisted" />);
expect(screen.getByText("Waitlisted")).toBeInTheDocument();
});
it("renders Completed for completed status", () => {
render(<StatusBadge status="completed" />);
expect(screen.getByText("Completed")).toBeInTheDocument();
});
it("renders Cancelled for cancelled status", () => {
render(<StatusBadge status="cancelled" />);
expect(screen.getByText("Cancelled")).toBeInTheDocument();
});
it("falls back to status string for unknown status", () => {
render(<StatusBadge status="custom-status" />);
expect(screen.getByText("custom-status")).toBeInTheDocument();
});
it("uses correct CSS class for confirmed status", () => {
render(<StatusBadge status="confirmed" />);
const badge = screen.getByText("Confirmed").closest('span');
expect(badge?.className).toContain("bg-green-100");
expect(badge?.className).toContain("text-green-700");
});
it("uses correct CSS class for waitlisted status", () => {
render(<StatusBadge status="waitlisted" />);
const badge = screen.getByText("Waitlisted").closest('span');
expect(badge?.className).toContain("bg-blue-100");
expect(badge?.className).toContain("text-blue-600");
});
it("uses correct CSS class for pending status", () => {
render(<StatusBadge status="pending" />);
const badge = screen.getByText("Pending").closest('span');
expect(badge?.className).toContain("bg-amber-100");
expect(badge?.className).toContain("text-amber-600");
});
it("uses fallback styling for unknown status", () => {
render(<StatusBadge status="unknown" />);
const badge = screen.getByText("unknown").closest('span');
expect(badge?.className).toContain("bg-stone-100");
expect(badge?.className).toContain("text-stone-600");
});
});
describe("RescheduleFlow dynamic time slots", () => {
beforeEach(() => {
vi.clearAllMocks();
global.fetch = vi.fn();
});
const RESCHEDULE_APPT = {
id: "appt-r1",
petId: "pet-1",
petName: "Buddy",
groomerId: "groomer-1",
groomerName: "Sarah",
services: ["Bath & Brush"],
serviceId: "service-1",
addOns: [],
date: "2027-01-01",
time: "10:00 AM",
duration: 60,
price: 50,
status: "confirmed" as const,
notes: "",
customerNotes: "",
confirmationStatus: "confirmed" as const,
};
it("shows loading state while fetching availability", async () => {
vi.mocked(global.fetch).mockReturnValue(new Promise(() => {})); // Never resolves
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
fireEvent.change(dateInput, { target: { value: "2027-01-15" } });
await waitFor(() => {
expect(screen.getByText(/Checking availability/i)).toBeInTheDocument();
});
});
it("displays fetched time slots from API", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ["9:00 AM", "10:00 AM", "2:00 PM"],
} as Response);
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
fireEvent.change(dateInput, { target: { value: "2027-01-15" } });
await waitFor(() => {
expect(screen.getByText("9:00 AM")).toBeInTheDocument();
expect(screen.getByText("10:00 AM")).toBeInTheDocument();
expect(screen.getByText("2:00 PM")).toBeInTheDocument();
});
});
it("shows error state when availability fetch fails", async () => {
vi.mocked(global.fetch).mockRejectedValue(new Error("Network error"));
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
fireEvent.change(dateInput, { target: { value: "2027-01-15" } });
await waitFor(() => {
expect(screen.getByText(/Failed to load time slots/i)).toBeInTheDocument();
});
});
it("shows no slots message when API returns empty array", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => [] as string[],
} as Response);
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
fireEvent.change(dateInput, { target: { value: "2027-01-15" } });
await waitFor(() => {
expect(screen.getByText(/No available slots on this date/i)).toBeInTheDocument();
});
});
it("calls /api/book/availability with the serviceId and selected date", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ["9:00 AM"] as string[],
} as Response);
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
fireEvent.change(dateInput, { target: { value: "2027-02-20" } });
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
"/api/book/availability?serviceId=service-1&date=2027-02-20",
expect.objectContaining({
headers: expect.objectContaining({ "X-Impersonation-Session-Id": "test-session-id" }),
})
);
});
});
it("shows error message when API returns a 4xx error object instead of an array", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: false,
status: 400,
json: async () => ({ error: "serviceId and date are required" }),
} as Response);
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
fireEvent.change(dateInput, { target: { value: "2027-02-20" } });
await waitFor(() => {
expect(screen.getByText(/serviceId and date are required/i)).toBeInTheDocument();
});
});
it("shows generic error when API returns 200 but body is not an array", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({ error: "serviceId and date are required" }),
} as Response);
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
fireEvent.change(dateInput, { target: { value: "2027-02-20" } });
await waitFor(() => {
expect(screen.getByText(/Failed to load time slots/i)).toBeInTheDocument();
});
});
it("re-fetches slots when date changes", async () => {
vi.mocked(global.fetch)
.mockResolvedValueOnce({
ok: true,
json: async () => ["9:00 AM"] as string[],
} as Response)
.mockResolvedValueOnce({
ok: true,
json: async () => ["11:00 AM", "1:00 PM"] as string[],
} as Response);
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
fireEvent.change(dateInput, { target: { value: "2027-01-10" } });
await waitFor(() => expect(screen.getByText("9:00 AM")).toBeInTheDocument());
fireEvent.change(dateInput, { target: { value: "2027-01-15" } });
await waitFor(() => {
expect(screen.getByText("11:00 AM")).toBeInTheDocument();
expect(screen.getByText("1:00 PM")).toBeInTheDocument();
});
});
});
describe("slot helpers (GRO-2213)", () => {
it("formatSlotLabel formats a canonical UTC ISO slot to a UTC clock label", () => {
expect(formatSlotLabel("2026-06-09T10:00:00.000Z")).toBe("10:00 AM");
expect(formatSlotLabel("2026-06-09T14:30:00.000Z")).toBe("2:30 PM");
expect(formatSlotLabel("2026-06-09T09:00:00.000Z")).toBe("9:00 AM");
});
it("formatSlotLabel never echoes a raw ISO string", () => {
expect(formatSlotLabel("2026-06-09T10:00:00.000Z")).not.toMatch(/\d{4}-\d{2}-\d{2}T/);
});
it("formatSlotLabel passes through an already-formatted label unchanged", () => {
expect(formatSlotLabel("10:00 AM")).toBe("10:00 AM");
});
it("slotToTime extracts the UTC HH:MM:SS time component from an ISO slot", () => {
expect(slotToTime("2026-06-09T10:00:00.000Z")).toBe("10:00:00");
expect(slotToTime("2026-06-09T14:30:00.000Z")).toBe("14:30:00");
expect(slotToTime("2026-06-09T10:00:00.000Z")).toMatch(/^\d{2}:\d{2}:\d{2}$/);
});
it("slotToTime guards a value that is already HH:MM:SS", () => {
expect(slotToTime("10:00:00")).toBe("10:00:00");
});
it("slotToTime converts a 12-hour label fallback to HH:MM:SS", () => {
expect(slotToTime("9:00 AM")).toBe("09:00:00");
expect(slotToTime("2:30 PM")).toBe("14:30:00");
});
});
describe("BookingFlow Book New funnel (GRO-2213)", () => {
beforeEach(() => {
vi.clearAllMocks();
global.fetch = vi.fn();
});
function routedFetch(captured: { waitlistBody?: Record<string, unknown> }) {
return (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : input.toString();
if (url.includes("/api/portal/pets")) {
return Promise.resolve({
ok: true,
json: async () => ({ pets: [{ id: "pet-1", name: "Buddy", breed: "Lab" }] }),
} as Response);
}
if (url.includes("/api/portal/services")) {
return Promise.resolve({
ok: true,
json: async () => ({
services: [{ id: "service-1", name: "Bath & Brush", isAddOn: false, duration: 60, price: 50 }],
}),
} as Response);
}
if (url.includes("/api/book/availability")) {
return Promise.resolve({
ok: true,
json: async () => ["2026-06-09T10:00:00.000Z", "2026-06-09T14:30:00.000Z"],
} as Response);
}
if (url.includes("/api/portal/waitlist")) {
captured.waitlistBody = JSON.parse((init?.body as string) ?? "{}");
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
}
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
};
}
it("renders formatted slot labels (not raw ISO) and submits preferredTime as HH:MM:SS", async () => {
const captured: { waitlistBody?: Record<string, unknown> } = {};
vi.mocked(global.fetch).mockImplementation(routedFetch(captured) as typeof fetch);
render(<BookingFlow onClose={() => {}} sessionId="test-session-id" />);
// Step 1 — pick pet (auto-advances to step 2)
await waitFor(() => expect(screen.getByText("Buddy")).toBeInTheDocument());
fireEvent.click(screen.getByText("Buddy"));
// Step 2 — pick service, then Next
await waitFor(() => expect(screen.getByText("Bath & Brush")).toBeInTheDocument());
fireEvent.click(screen.getByText("Bath & Brush"));
fireEvent.click(screen.getByRole("button", { name: /^Next$/ }));
// Step 3 — groomer, Next
await waitFor(() => expect(screen.getByText("First Available")).toBeInTheDocument());
fireEvent.click(screen.getByRole("button", { name: /^Next$/ }));
// Step 4 — date + slot
await waitFor(() => expect(screen.getByLabelText(/date/i)).toBeInTheDocument());
fireEvent.change(screen.getByLabelText(/date/i), { target: { value: "2026-06-09" } });
// Slot button shows the formatted UTC label, never the raw ISO
await waitFor(() => expect(screen.getByText("10:00 AM")).toBeInTheDocument());
expect(screen.queryByText(/2026-06-09T10:00:00/)).not.toBeInTheDocument();
fireEvent.click(screen.getByText("10:00 AM"));
fireEvent.click(screen.getByRole("button", { name: /^Next$/ }));
// Step 5 — review shows the formatted label
await waitFor(() => expect(screen.getByText(/Review & Confirm/i)).toBeInTheDocument());
expect(screen.getByText(/10:00 AM/)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /Confirm Booking/i }));
await waitFor(() => expect(captured.waitlistBody).toBeDefined());
const body = captured.waitlistBody ?? {};
expect(body.preferredTime).toMatch(/^\d{2}:\d{2}:\d{2}$/);
expect(body.preferredTime).toBe("10:00:00");
expect(body.preferredDate).toBe("2026-06-09");
});
it("re-mints the portal session and retries once when waitlist returns 401 (GRO-2234)", async () => {
const calls = { waitlist: 0, remint: 0 };
const waitlistHeaders: string[] = [];
const routed = (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : input.toString();
if (url.includes("/api/portal/pets")) {
return Promise.resolve({
ok: true,
json: async () => ({ pets: [{ id: "pet-1", name: "Buddy", breed: "Lab" }] }),
} as Response);
}
if (url.includes("/api/portal/services")) {
return Promise.resolve({
ok: true,
json: async () => ({
services: [{ id: "service-1", name: "Bath & Brush", isAddOn: false, duration: 60, price: 50 }],
}),
} as Response);
}
if (url.includes("/api/book/availability")) {
return Promise.resolve({
ok: true,
json: async () => ["2026-06-09T10:00:00.000Z"],
} as Response);
}
if (url.includes("/api/portal/session-from-auth")) {
calls.remint += 1;
return Promise.resolve({
ok: true,
json: async () => ({ sessionId: "fresh-session-id", clientId: "c1", clientName: "Jane" }),
} as Response);
}
if (url.includes("/api/portal/waitlist")) {
calls.waitlist += 1;
const headers = (init?.headers ?? {}) as Record<string, string>;
waitlistHeaders.push(headers["X-Impersonation-Session-Id"] ?? "");
// First attempt: session lapsed → 401. Retry after re-mint: success.
if (calls.waitlist === 1) {
return Promise.resolve({ ok: false, status: 401, json: async () => ({ error: "Unauthorized" }) } as Response);
}
return Promise.resolve({ ok: true, status: 201, json: async () => ({}) } as Response);
}
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
};
global.fetch = vi.fn().mockImplementation(routed as typeof fetch);
render(<BookingFlow onClose={() => {}} sessionId="stale-session-id" />);
await waitFor(() => expect(screen.getByText("Buddy")).toBeInTheDocument());
fireEvent.click(screen.getByText("Buddy"));
await waitFor(() => expect(screen.getByText("Bath & Brush")).toBeInTheDocument());
fireEvent.click(screen.getByText("Bath & Brush"));
fireEvent.click(screen.getByRole("button", { name: /^Next$/ }));
await waitFor(() => expect(screen.getByText("First Available")).toBeInTheDocument());
fireEvent.click(screen.getByRole("button", { name: /^Next$/ }));
await waitFor(() => expect(screen.getByLabelText(/date/i)).toBeInTheDocument());
fireEvent.change(screen.getByLabelText(/date/i), { target: { value: "2026-06-09" } });
await waitFor(() => expect(screen.getByText("10:00 AM")).toBeInTheDocument());
fireEvent.click(screen.getByText("10:00 AM"));
fireEvent.click(screen.getByRole("button", { name: /^Next$/ }));
await waitFor(() => expect(screen.getByText(/Review & Confirm/i)).toBeInTheDocument());
fireEvent.click(screen.getByRole("button", { name: /Confirm Booking/i }));
// Re-mint happened exactly once, waitlist retried with the fresh id, and the
// booking succeeded (no error surfaced).
await waitFor(() => expect(calls.waitlist).toBe(2));
expect(calls.remint).toBe(1);
expect(waitlistHeaders).toEqual(["stale-session-id", "fresh-session-id"]);
expect(screen.queryByText(/Failed to book appointment/i)).not.toBeInTheDocument();
});
});
+54
View File
@@ -0,0 +1,54 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import { ErrorBoundary } from "../ErrorBoundary";
function ThrowingChild(): never {
throw new Error("synthetic render-time failure for GRO-2094");
}
function GoodChild() {
return <div data-testid="good-child">ok</div>;
}
describe("ErrorBoundary (GRO-2094)", () => {
let errorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
// React 18+ logs caught render errors to console.error via React's own
// instrumentation; suppress it so test output is clean but capture it
// for an assertion below.
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
errorSpy.mockRestore();
cleanup();
});
it("renders children when nothing throws", () => {
render(
<ErrorBoundary>
<GoodChild />
</ErrorBoundary>
);
expect(screen.getByTestId("good-child")).toBeInTheDocument();
expect(screen.queryByTestId("error-boundary")).not.toBeInTheDocument();
});
it("renders the error visibly when a child throws during render", () => {
render(
<ErrorBoundary>
<ThrowingChild />
</ErrorBoundary>
);
const fallback = screen.getByTestId("error-boundary");
expect(fallback).toBeInTheDocument();
const message = screen.getByTestId("error-boundary-message");
// The actual exception is shown — no more silent blank root.
expect(message.textContent).toContain("synthetic render-time failure for GRO-2094");
// The boundary also calls console.error so it shows up in the Playwright
// console log even if the DOM-rendered fallback is somehow missed.
expect(errorSpy).toHaveBeenCalled();
});
});
+8
View File
@@ -154,4 +154,12 @@ describe("PetForm", () => {
expect(screen.getByText("Anxious")).toBeTruthy();
expect(screen.getByText("Good with kids")).toBeTruthy();
});
// ── Weight pre-fill from portal `weight` key (GRO-2207) ───────────────────────
it("pre-fills weight from the portal `weight` key when weightKg is absent", () => {
const portalPet: Pet = { ...BASE_PET, weightKg: null, weight: "12.50" };
render(<PetForm pet={portalPet} onSave={onSave} onCancel={onCancel} />);
expect(screen.getByDisplayValue(12.5)).toBeTruthy();
});
});
+80
View File
@@ -0,0 +1,80 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { BasicInfoTab, formatSizeCategory } from "../portal/sections/PetProfiles.js";
import type { Pet } from "@groombook/types";
// The portal endpoint (GET /api/portal/pets) serializes DB columns under
// portal-shaped keys: weightKg→weight, dateOfBirth→birthDate. The read view
// must surface those keys (GRO-2207), not the raw staff-side weightKg/dateOfBirth.
const PORTAL_PET: Pet = {
id: "pet-1",
clientId: "client-1",
name: "Pup Alpha",
species: "dog",
breed: "Poodle",
// Staff-shaped keys intentionally null — only the portal keys are populated,
// proving the read view reads `weight`/`birthDate`.
weightKg: null,
dateOfBirth: null,
weight: "12.50",
birthDate: "2022-03-10T00:00:00.000Z",
petSizeCategory: "extra_large",
healthAlerts: null,
groomingNotes: null,
cutStyle: null,
shampooPreference: null,
specialCareNotes: null,
customFields: {},
coatType: null,
preferredCuts: [],
medicalAlerts: [],
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
};
describe("BasicInfoTab read view (GRO-2207)", () => {
it("renders Weight from the portal `weight` key", () => {
render(<BasicInfoTab pet={PORTAL_PET} readOnly />);
expect(screen.getByText("12.50 kg")).toBeInTheDocument();
});
it("renders Date of Birth from the portal `birthDate` key", () => {
render(<BasicInfoTab pet={PORTAL_PET} readOnly />);
expect(screen.getByText("March 10, 2022")).toBeInTheDocument();
});
it("renders Size Category formatted from petSizeCategory", () => {
render(<BasicInfoTab pet={PORTAL_PET} readOnly />);
expect(screen.getByText("Size Category")).toBeInTheDocument();
expect(screen.getByText("Extra Large")).toBeInTheDocument();
});
it("falls back to staff-shaped keys when portal keys are absent", () => {
const staffShaped: Pet = { ...PORTAL_PET, weight: null, birthDate: null, weightKg: 25, dateOfBirth: "2020-01-05T00:00:00.000Z" };
render(<BasicInfoTab pet={staffShaped} readOnly />);
expect(screen.getByText("25 kg")).toBeInTheDocument();
expect(screen.getByText("January 5, 2020")).toBeInTheDocument();
});
it("shows Unknown for missing weight/DoB and size", () => {
const empty: Pet = { ...PORTAL_PET, weight: null, birthDate: null, weightKg: null, dateOfBirth: null, petSizeCategory: null };
render(<BasicInfoTab pet={empty} readOnly />);
// Weight, Date of Birth and Size Category rows all read "Unknown".
expect(screen.getAllByText("Unknown").length).toBeGreaterThanOrEqual(3);
});
});
describe("formatSizeCategory", () => {
it("title-cases each underscore-separated segment", () => {
expect(formatSizeCategory("extra_large")).toBe("Extra Large");
expect(formatSizeCategory("small")).toBe("Small");
expect(formatSizeCategory("medium")).toBe("Medium");
expect(formatSizeCategory("large")).toBe("Large");
});
it("returns Unknown for null/undefined/empty", () => {
expect(formatSizeCategory(null)).toBe("Unknown");
expect(formatSizeCategory(undefined)).toBe("Unknown");
expect(formatSizeCategory("")).toBe("Unknown");
});
});
+300
View File
@@ -5,6 +5,22 @@ import { ImpersonationBanner } from "../portal/ImpersonationBanner.js";
import { AuditLogViewer } from "../portal/AuditLogViewer.js";
import type { ImpersonationSession, ImpersonationAuditLog } from "@groombook/types";
// Spy on the RescheduleFlow so we can assert the sessionId prop it receives
// from CustomerPortal without rendering the full flow UI. The real module is
// still loaded via importActual; only RescheduleFlow is swapped.
const rescheduleFlowSpy = vi.hoisted(() =>
vi.fn((_props: { sessionId: string | null; appointment: { id: string } }) => null)
);
vi.mock("../portal/sections/Appointments.js", async () => {
const actual = await vi.importActual<typeof import("../portal/sections/Appointments.js")>(
"../portal/sections/Appointments.js"
);
return {
...actual,
RescheduleFlow: rescheduleFlowSpy,
};
});
const SESSION: ImpersonationSession = {
id: "sess-1",
staffId: "staff-1",
@@ -313,3 +329,287 @@ describe("CustomerPortal session loading", () => {
Object.defineProperty(window, "location", { value: originalLocation, writable: true });
});
});
// ─── CustomerPortal — Better Auth SSO bridge (GRO-1867) ────────────────────
describe("CustomerPortal SSO bridge", () => {
beforeEach(() => {
// Make sure no dev-user leaks across tests
window.localStorage.clear();
});
const brandingResponse = {
ok: true,
json: async () => ({
businessName: "GroomBook",
primaryColor: "#4f8a6f",
accentColor: "#8b7355",
logoBase64: null,
logoMimeType: null,
}),
} as Response;
it("bridges Better Auth session via /api/portal/session-from-auth and uses returned sessionId", async () => {
global.fetch = vi.fn((input: RequestInfo, init?: RequestInit) => {
const url = typeof input === "string" ? input : input.toString();
if (url === "/api/branding") return Promise.resolve(brandingResponse);
if (url === "/api/auth/get-session") {
return Promise.resolve({
ok: true,
json: async () => ({ user: { email: "customer@example.com", role: "customer" } }),
} as Response);
}
if (url === "/api/portal/session-from-auth" && init?.method === "POST") {
return Promise.resolve({
ok: true,
status: 201,
json: async () => ({ sessionId: "sso-sess-1", clientId: "client-1", clientName: "Jane Doe" }),
} as Response);
}
// Subsequent portal API calls — surface them so we can assert the header
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
}) as unknown as typeof fetch;
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
render(
<MemoryRouter initialEntries={["/"]}>
<CustomerPortal />
</MemoryRouter>
);
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith("/api/auth/get-session", expect.objectContaining({ credentials: "include" }));
});
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
"/api/portal/session-from-auth",
expect.objectContaining({ method: "POST", credentials: "include" })
);
});
// Client greeting reflects the bridged customer name (proof the response was consumed)
await waitFor(() => {
expect(screen.getByText(/Hi, Jane/)).toBeInTheDocument();
});
// The impersonation banner must NOT appear — this is the customer themselves
expect(screen.queryByRole("button", { name: /End Session/i })).not.toBeInTheDocument();
});
it("shows a friendly fallback when session-from-auth returns 404 (no client record)", async () => {
global.fetch = vi.fn((input: RequestInfo) => {
const url = typeof input === "string" ? input : input.toString();
if (url === "/api/branding") return Promise.resolve(brandingResponse);
if (url === "/api/auth/get-session") {
return Promise.resolve({
ok: true,
json: async () => ({ user: { email: "stranger@example.com", role: "customer" } }),
} as Response);
}
if (url === "/api/portal/session-from-auth") {
return Promise.resolve({
ok: false,
status: 404,
json: async () => ({ error: "No client record found for this user" }),
} as Response);
}
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
}) as unknown as typeof fetch;
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
render(
<MemoryRouter initialEntries={["/"]}>
<CustomerPortal />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByText(/Portal access not configured/i)).toBeInTheDocument();
});
expect(screen.getByText(/not linked to a customer record/i)).toBeInTheDocument();
// Sign-out escape hatch is present so the user is not stuck in a loop
expect(screen.getByRole("button", { name: /Sign out/i })).toBeInTheDocument();
});
it("does not call session-from-auth when there is no Better Auth session", async () => {
global.fetch = vi.fn((input: RequestInfo) => {
const url = typeof input === "string" ? input : input.toString();
if (url === "/api/branding") return Promise.resolve(brandingResponse);
if (url === "/api/auth/get-session") {
return Promise.resolve({
ok: true,
json: async () => null,
} as Response);
}
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
}) as unknown as typeof fetch;
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
render(
<MemoryRouter initialEntries={["/"]}>
<CustomerPortal />
</MemoryRouter>
);
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith("/api/auth/get-session", expect.objectContaining({ credentials: "include" }));
});
// Wait one tick to ensure no subsequent bridge call is queued
await new Promise((r) => setTimeout(r, 30));
const bridgeCalls = vi.mocked(global.fetch).mock.calls.filter(
([u]) => typeof u === "string" && u === "/api/portal/session-from-auth"
);
expect(bridgeCalls).toHaveLength(0);
});
it("skips the bridge for staff Better Auth sessions", async () => {
global.fetch = vi.fn((input: RequestInfo) => {
const url = typeof input === "string" ? input : input.toString();
if (url === "/api/branding") return Promise.resolve(brandingResponse);
if (url === "/api/auth/get-session") {
return Promise.resolve({
ok: true,
json: async () => ({ user: { email: "staff@example.com", role: "staff" } }),
} as Response);
}
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
}) as unknown as typeof fetch;
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
render(
<MemoryRouter initialEntries={["/"]}>
<CustomerPortal />
</MemoryRouter>
);
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith("/api/auth/get-session", expect.objectContaining({ credentials: "include" }));
});
await new Promise((r) => setTimeout(r, 30));
const bridgeCalls = vi.mocked(global.fetch).mock.calls.filter(
([u]) => typeof u === "string" && u === "/api/portal/session-from-auth"
);
expect(bridgeCalls).toHaveLength(0);
});
it("passes portalSessionId (not null) to RescheduleFlow for SSO bridge customers (GRO-2012)", async () => {
rescheduleFlowSpy.mockClear();
global.fetch = vi.fn((input: RequestInfo, init?: RequestInit) => {
const url = typeof input === "string" ? input : input.toString();
if (url === "/api/branding") return Promise.resolve(brandingResponse);
if (url === "/api/auth/get-session") {
return Promise.resolve({
ok: true,
json: async () => ({ user: { email: "customer@example.com", role: "customer" } }),
} as Response);
}
if (url === "/api/portal/session-from-auth" && init?.method === "POST") {
return Promise.resolve({
ok: true,
status: 201,
json: async () => ({ sessionId: "sso-sess-1", clientId: "client-1", clientName: "Jane Doe" }),
} as Response);
}
// Dashboard data — return an upcoming appointment so the Reschedule
// button is rendered on the dashboard card.
if (url === "/api/portal/appointments") {
return Promise.resolve({
ok: true,
json: async () => ({
appointments: [
{
id: "appt-1",
date: "2099-01-01",
time: "10:00",
petName: "Buddy",
serviceName: "Bath & Brush",
status: "confirmed",
},
],
}),
} as Response);
}
if (url === "/api/portal/pets") {
return Promise.resolve({ ok: true, json: async () => ({ pets: [] }) } as Response);
}
if (url === "/api/portal/invoices") {
return Promise.resolve({ ok: true, json: async () => ({ invoices: [] }) } as Response);
}
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
}) as unknown as typeof fetch;
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
render(
<MemoryRouter initialEntries={["/"]}>
<CustomerPortal />
</MemoryRouter>
);
// Wait for the Reschedule button to appear on the dashboard card
const rescheduleBtn = await screen.findByRole("button", { name: /^Reschedule$/i });
fireEvent.click(rescheduleBtn);
// RescheduleFlow should have been invoked with the bridged portalSessionId,
// NOT null. Pre-fix, the call would be sessionId={null} for SSO customers.
await waitFor(() => {
expect(rescheduleFlowSpy).toHaveBeenCalled();
});
const lastProps = rescheduleFlowSpy.mock.lastCall?.[0];
expect(lastProps).toBeDefined();
expect(lastProps!.sessionId).toBe("sso-sess-1");
expect(lastProps!.appointment.id).toBe("appt-1");
});
// GRO-2099 regression: the portal chrome (and Dashboard's `!sessionId` guard)
// must NOT render before the SSO bridge resolves. A loading state must be
// shown instead. Previously, the Dashboard's redirect-to-/login guard fired
// mid-bootstrap, leaving the user with a blank page after sign-in.
it("renders a loading state during the SSO bridge (does not flash portal chrome)", async () => {
// Slow bridge: resolve get-session and session-from-auth after a tick so
// we can observe the loading state mid-bootstrap.
let resolveBridge!: (value: Response) => void;
const bridgePromise = new Promise<Response>((resolve) => {
resolveBridge = resolve;
});
global.fetch = vi.fn((input: RequestInfo, init?: RequestInit) => {
const url = typeof input === "string" ? input : input.toString();
if (url === "/api/branding") return Promise.resolve(brandingResponse);
if (url === "/api/auth/get-session") {
return Promise.resolve({
ok: true,
json: async () => ({ user: { email: "customer@example.com", role: "customer" } }),
} as Response);
}
if (url === "/api/portal/session-from-auth" && init?.method === "POST") {
return bridgePromise;
}
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
}) as unknown as typeof fetch;
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
render(
<MemoryRouter initialEntries={["/"]}>
<CustomerPortal />
</MemoryRouter>
);
// Loading state is visible while the bridge is in flight. The portal nav
// (Home / Appointments / etc.) must NOT be present — its presence would
// indicate the chrome is rendering with a null session, which is the
// pre-GRO-2099 bug.
expect(await screen.findByRole("status")).toHaveTextContent(/Loading/i);
expect(screen.queryByText("Home")).not.toBeInTheDocument();
expect(screen.queryByText("Appointments")).not.toBeInTheDocument();
// Resolve the bridge and confirm the portal renders normally.
resolveBridge({
ok: true,
status: 201,
json: async () => ({ sessionId: "sso-sess-1", clientId: "client-1", clientName: "Jane Doe" }),
} as Response);
await waitFor(() => {
expect(screen.getByText(/Hi, Jane/)).toBeInTheDocument();
});
});
});
+37 -3
View File
@@ -2,9 +2,41 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { App } from "./App.js";
import { ErrorBoundary } from "./ErrorBoundary.js";
import { installDevFetchInterceptor } from "./lib/devFetch.js";
import "./index.css";
// --------------------------------------------------------------------
// Global error capture (GRO-2094).
//
// Symptom: React root stays empty at /login — bundle parses, no console
// errors, no error boundary fallback. Some failure is being swallowed
// before it reaches React's commit phase. These listeners make sure any
// thrown error or unhandled promise rejection is at least visible in
// the console (and in the Playwright network/console log) instead of
// vanishing into the void.
// --------------------------------------------------------------------
function reportGlobalError(kind: string, payload: unknown): void {
// eslint-disable-next-line no-console
console.error(`[${kind}]`, payload);
}
window.addEventListener("error", (event) => {
reportGlobalError("window.error", {
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
error: event.error,
});
});
window.addEventListener("unhandledrejection", (event) => {
reportGlobalError("unhandledrejection", {
reason: event.reason,
});
});
installDevFetchInterceptor();
const root = document.getElementById("root");
@@ -12,8 +44,10 @@ if (!root) throw new Error("Root element not found");
createRoot(root).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
<ErrorBoundary>
<BrowserRouter>
<App />
</BrowserRouter>
</ErrorBoundary>
</StrictMode>
);
+120 -6
View File
@@ -43,6 +43,15 @@ export function CustomerPortal() {
// Track whether an impersonation session fetch from URL param is in-flight
// Dashboard will not redirect while this is true, allowing the session to load
const [isImpersonating, setIsImpersonating] = useState(false);
// Portal session ID for real SSO customers (GRO-1867). Populated by the
// Better Auth → /api/portal/session-from-auth bridge below. Carries the
// X-Impersonation-Session-Id header on subsequent portal API calls without
// triggering the impersonation banner (the customer is themselves).
const [portalSessionId, setPortalSessionId] = useState<string | null>(null);
// User-facing message when the SSO bridge cannot resolve a client record
// (e.g. authenticated user with no matching client row). Rendered in place
// of the portal chrome instead of bouncing back to /login.
const [authError, setAuthError] = useState<string | null>(null);
const { branding } = useBranding();
const [searchParams, setSearchParams] = useSearchParams();
@@ -98,10 +107,64 @@ export function CustomerPortal() {
}
})
.finally(() => setInitComplete(true));
} else {
// No valid session: staff dev users and unauthenticated users fall through here
setInitComplete(true);
return;
}
if (devUser && devUser.type === "staff") {
// Staff dev user — fall through; App.tsx redirects to /admin.
setInitComplete(true);
return;
}
// Real SSO customer (GRO-1867): bridge a Better Auth session into a portal
// session via POST /api/portal/session-from-auth. The returned session ID
// is used in the X-Impersonation-Session-Id header for portal API calls.
(async () => {
try {
const sessionResp = await fetch("/api/auth/get-session", { credentials: "include" });
if (!sessionResp.ok) {
setInitComplete(true);
return;
}
let sessionData: { user?: { email?: string; role?: string | null } } | null = null;
try {
sessionData = (await sessionResp.json()) as { user?: { email?: string; role?: string | null } } | null;
} catch {
// Better Auth returns an empty body when there is no session
}
if (!sessionData || !sessionData.user) {
setInitComplete(true);
return;
}
// Staff are routed to /admin by App.tsx; don't run the customer bridge.
if (sessionData.user.role === "staff") {
setInitComplete(true);
return;
}
const bridgeResp = await fetch("/api/portal/session-from-auth", {
method: "POST",
credentials: "include",
});
if (bridgeResp.ok) {
const data = await bridgeResp.json() as { sessionId: string; clientId: string; clientName: string };
setPortalSessionId(data.sessionId);
setClientName(data.clientName);
} else if (bridgeResp.status === 404) {
// Authenticated but no matching client row — show a friendly message
// instead of bouncing back to /login (which would loop indefinitely).
setAuthError(
"Your account is not linked to a customer record. Please contact your groomer to set up portal access."
);
}
// 401/other: fall through; App.tsx render guard will redirect to /login.
} catch {
// Network error — fall through; the render guard will redirect to /login.
} finally {
setInitComplete(true);
}
})();
}, []);
const handleEnd = useCallback(async () => {
@@ -157,7 +220,7 @@ export function CustomerPortal() {
const isReadOnly = session?.status === "active";
const renderSection = () => {
const sessionId = session?.id ?? null;
const sessionId = session?.id ?? portalSessionId;
switch (activeSection) {
case "dashboard":
return <Dashboard onNavigate={handleNavClick} readOnly={!!isReadOnly} sessionId={sessionId} clientName={clientName} onReschedule={handleReschedule} isImpersonating={isImpersonating} />;
@@ -178,12 +241,63 @@ export function CustomerPortal() {
const avatarInitials = (clientName.split(" ")[0] || "G").charAt(0).toUpperCase();
// Show a loading state while the SSO bridge is in progress. The portal chrome
// and its sections (e.g. Dashboard) assume a session is established and run
// their own auth guards — rendering them before the bridge resolves triggers
// a redirect to /login from `Dashboard.tsx`'s `!sessionId` check, breaking the
// post-sign-in flow. Once `initComplete` is true we know whether a session was
// established and can render the correct branch. See GRO-2099.
if (!initComplete) {
return (
<div
className="min-h-screen flex items-center justify-center bg-[#faf8f5]"
role="status"
aria-live="polite"
>
<div className="text-stone-500 text-sm">Loading</div>
</div>
);
}
// After init completes, redirect unauthenticated users to /login and staff to /admin.
// The portal chrome must NEVER be visible to users without a valid client session.
// For client dev users, we stay on the portal even if session is null — the dev-session
// response may not have id set immediately, or there may be timing issues with the
// session state. Dev users are verified via localStorage and the dev-session flow.
if (initComplete && !session) {
// SSO customers are recognised by portalSessionId (set by the Better Auth bridge).
if (!session && !portalSessionId) {
if (authError) {
// GRO-1867: graceful 404 fallback — authenticated user has no client row.
return (
<div
className="min-h-screen flex items-center justify-center bg-[#faf8f5] font-sans px-6"
role="alert"
aria-live="polite"
>
<div className="max-w-md w-full bg-white rounded-xl shadow-sm border border-stone-200 p-8 text-center">
<div className="w-12 h-12 rounded-full bg-amber-100 text-amber-700 flex items-center justify-center mx-auto mb-4">
<Shield size={22} />
</div>
<h1 className="text-lg font-semibold text-stone-800 mb-2">Portal access not configured</h1>
<p className="text-sm text-stone-600 mb-6">{authError}</p>
<button
onClick={async () => {
try {
await fetch("/api/auth/sign-out", { method: "POST", credentials: "include" });
} catch {
// Best-effort sign-out; redirect to /login regardless.
}
window.location.href = "/login";
}}
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-stone-700 bg-stone-100 hover:bg-stone-200 transition-colors"
>
<LogOut size={14} />
Sign out
</button>
</div>
</div>
);
}
const devUser = getDevUser();
if (devUser && devUser.type === "staff") {
return <Navigate to="/admin" replace />;
@@ -230,7 +344,7 @@ export function CustomerPortal() {
<RescheduleFlow
appointment={rescheduleAppointment}
onClose={() => { setShowReschedule(false); setRescheduleAppointment(null); }}
sessionId={session?.id ?? null}
sessionId={session?.id ?? portalSessionId}
/>
)}
+304 -54
View File
@@ -2,11 +2,66 @@ import React, { useState, useEffect } from 'react';
import { Calendar, Clock, Plus, ChevronRight, ChevronDown, Loader2 } from 'lucide-react';
import { ANALYTICS_EVENTS, fireAnalyticsEvent } from '../../lib/analytics';
// ─── Availability fetch helper ───────────────────────────────────────────────
// Returns ISO startTime strings for the given service/date, or an error message.
// Validates HTTP status and that the body is actually an array — the API
// responds with `{error: "..."}` on 4xx, and we must not treat that as slots.
const AVAILABILITY_ERROR_MESSAGE = 'Failed to load time slots';
/**
* Re-mint an SSO-bridge portal session from the active Better Auth session.
* Defense-in-depth for GRO-2234: if a portal call returns 401 mid-flow (the
* impersonation session lapsed during a slow wizard), the customer's Better
* Auth cookie is still valid, so we can transparently obtain a fresh portal
* session id and retry once. Returns the new session id, or null if no Better
* Auth session is available (e.g. staff/dev impersonation paths).
*/
async function remintPortalSession(): Promise<string | null> {
try {
const res = await fetch('/api/portal/session-from-auth', {
method: 'POST',
credentials: 'include',
});
if (!res.ok) return null;
const data = (await res.json().catch(() => ({}))) as { sessionId?: string };
return data.sessionId ?? null;
} catch {
return null;
}
}
async function fetchAvailability(
params: { serviceId: string; date: string },
sessionId: string | null,
): Promise<{ times: string[]; error: string | null }> {
const url = `/api/book/availability?${new URLSearchParams(params).toString()}`;
const headers: Record<string, string> = {};
if (sessionId) headers['X-Impersonation-Session-Id'] = sessionId;
try {
const res = await fetch(url, { headers });
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string };
return { times: [], error: body.error ?? `${AVAILABILITY_ERROR_MESSAGE} (HTTP ${res.status})` };
}
const data: unknown = await res.json();
if (!Array.isArray(data)) {
return { times: [], error: AVAILABILITY_ERROR_MESSAGE };
}
return { times: data as string[], error: null };
} catch {
return { times: [], error: AVAILABILITY_ERROR_MESSAGE };
}
}
export interface Appointment {
id: string;
petId: string;
serviceId: string;
groomerId: string | null;
// Absolute ISO instants as returned by `/api/portal/appointments`. `date`/`time`
// below are the locally-formatted display strings derived from `startTime`.
startTime?: string;
endTime?: string;
date: string;
time: string;
status: 'scheduled' | 'confirmed' | 'pending' | 'waitlisted' | 'completed' | 'cancelled' | 'no-show';
@@ -62,35 +117,168 @@ export function formatDate(dateStr: string): string {
});
}
export function parseTimeTo24Hour(time: string): string {
const parts = time.split(' ');
export function parseTimeTo24Hour(time: string | null | undefined): string {
const parts = (time ?? '').split(' ');
const hoursMinutes = parts[0] ?? '';
const period = parts[1] ?? '';
const [hoursStr, minutesStr] = hoursMinutes.split(':');
const hours = parseInt(hoursStr ?? '0', 10);
const minutes = parseInt(minutesStr ?? '0', 10);
// `|| '0'` (not `?? '0'`) so empty strings from blank/undefined input
// fall back to 0 rather than parsing to NaN.
const hours = parseInt(hoursStr || '0', 10);
const minutes = parseInt(minutesStr || '0', 10);
let hours24 = hours;
if (period === 'PM' && hours !== 12) hours24 += 12;
if (period === 'AM' && hours === 12) hours24 = 0;
return `${hours24.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:00`;
}
// A booking slot is the canonical UTC ISO instant returned by
// `/api/book/availability` (e.g. "2026-06-09T10:00:00.000Z" is the 10:00 UTC
// business slot — see api `src/lib/slots.ts`, which builds them with
// `setUTCHours`). Display label and submit payload both derive from the slot via
// these helpers so they never desync. Always format/extract in UTC: slots are
// generated as UTC business hours, so a browser-local conversion would mislabel
// the slot and diverge from the stored Postgres `time` column.
export function formatSlotLabel(slot: string): string {
const d = new Date(slot);
// Non-ISO input (e.g. an already-formatted "10:00 AM" label) — show as-is.
if (Number.isNaN(d.getTime())) return slot;
return new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true,
timeZone: 'UTC',
}).format(d);
}
// Extracts the UTC `HH:MM:SS` time component the api stores in the Postgres
// `time` column. The api inserts this verbatim, so a full ISO datetime here is
// an `invalid input syntax for type time` 500 (GRO-2211).
export function slotToTime(slot: string): string {
if (/^\d{2}:\d{2}:\d{2}$/.test(slot)) return slot; // already HH:MM:SS
const d = new Date(slot);
if (!Number.isNaN(d.getTime())) {
const hh = String(d.getUTCHours()).padStart(2, '0');
const mm = String(d.getUTCMinutes()).padStart(2, '0');
const ss = String(d.getUTCSeconds()).padStart(2, '0');
return `${hh}:${mm}:${ss}`;
}
// "10:00 AM"-style label fallback.
return parseTimeTo24Hour(slot);
}
export function isUpcoming(appt: Appointment): boolean {
const now = new Date();
const apptDate = new Date(`${appt.date}T${parseTimeTo24Hour(appt.time)}`);
// Prefer the absolute ISO `startTime` from the API; fall back to the
// locally-formatted date/time pair for already-normalized/legacy shapes.
const apptDate = appt.startTime
? new Date(appt.startTime)
: new Date(`${appt.date}T${parseTimeTo24Hour(appt.time)}`);
return apptDate > now && appt.status !== 'cancelled' && appt.status !== 'completed';
}
// ─── API → UI shape normalization ────────────────────────────────────────────
// `/api/portal/appointments` returns ISO `startTime`/`endTime` plus nested
// pet/service/staff objects, NOT the flat `date`/`time`/`petName` shape the
// portal UI renders. Every field below is optional so the legacy flat shape
// (used by tests/fixtures) is tolerated unchanged.
export interface RawApiAppointment {
id: string;
startTime?: string | null;
endTime?: string | null;
status: Appointment['status'];
confirmationStatus?: Appointment['confirmationStatus'];
customerNotes?: string | null;
notes?: string | null;
pet?: { id?: string | null; name?: string | null; photo?: string | null } | null;
service?: { id?: string | null; name?: string | null } | null;
staff?: { id?: string | null; name?: string | null } | null;
// Legacy / already-flat fields
petId?: string;
serviceId?: string;
groomerId?: string | null;
date?: string;
time?: string;
petName?: string;
serviceName?: string;
groomerName?: string;
duration?: number;
price?: number;
addOns?: string[];
}
function toLocalDateString(d: Date): string {
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function toLocalTimeString(d: Date): string {
return d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
}
// Maps a raw API appointment into the flat `Appointment` shape the portal
// renders. Derives display `date`/`time` from the absolute `startTime` and
// `duration` from the start/end delta. Tolerates the legacy flat shape.
export function normalizeAppointment(raw: RawApiAppointment): Appointment {
const start = raw.startTime ? new Date(raw.startTime) : null;
const end = raw.endTime ? new Date(raw.endTime) : null;
const derivedDuration =
start && end ? Math.round((end.getTime() - start.getTime()) / 60000) : undefined;
return {
id: raw.id,
petId: raw.pet?.id ?? raw.petId ?? '',
serviceId: raw.service?.id ?? raw.serviceId ?? '',
groomerId: raw.staff?.id ?? raw.groomerId ?? null,
startTime: raw.startTime ?? undefined,
endTime: raw.endTime ?? undefined,
date: start ? toLocalDateString(start) : raw.date ?? '',
time: start ? toLocalTimeString(start) : raw.time ?? '',
status: raw.status,
petName: raw.pet?.name ?? raw.petName,
serviceName: raw.service?.name ?? raw.serviceName,
groomerName: raw.staff?.name ?? raw.groomerName,
duration: raw.duration ?? derivedDuration,
price: raw.price,
notes: raw.notes ?? undefined,
customerNotes: raw.customerNotes ?? undefined,
addOns: raw.addOns,
confirmationStatus: raw.confirmationStatus,
};
}
const STATUS_COLORS: Record<string, string> = {
confirmed: 'bg-green-100 text-green-700',
pending: 'bg-amber-100 text-amber-700',
waitlisted: 'bg-blue-100 text-blue-700',
pending: 'bg-amber-100 text-amber-600',
waitlisted: 'bg-blue-100 text-blue-600',
completed: 'bg-stone-100 text-stone-600',
cancelled: 'bg-red-100 text-red-600',
'no-show': 'bg-yellow-100 text-yellow-700',
scheduled: 'bg-blue-100 text-blue-700',
scheduled: 'bg-blue-100 text-blue-600',
};
const STATUS_LABELS: Record<string, string> = {
confirmed: 'Confirmed',
pending: 'Pending',
waitlisted: 'Waitlisted',
completed: 'Completed',
cancelled: 'Cancelled',
'no-show': 'No-show',
scheduled: 'Scheduled',
};
export function StatusBadge({ status }: { status: string }) {
const label = STATUS_LABELS[status] ?? status;
const colorClass = STATUS_COLORS[status] ?? 'bg-stone-100 text-stone-600';
return (
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${colorClass}`}>
{label}
</span>
);
}
const CONFIRMATION_STATUS_COLORS: Record<string, string> = {
confirmed: 'bg-green-100 text-green-700',
pending: 'bg-amber-100 text-amber-700',
@@ -124,7 +312,8 @@ export const AppointmentsSection: React.FC<AppointmentsSectionProps> = ({ sessio
if (response.ok) {
const data = await response.json();
const fetchedAppointments: Appointment[] = data.appointments || data || [];
const rawAppointments: RawApiAppointment[] = data.appointments || data || [];
const fetchedAppointments: Appointment[] = rawAppointments.map(normalizeAppointment);
const upcoming = fetchedAppointments.filter((appt) => isUpcoming(appt));
const past = fetchedAppointments.filter((appt) => !isUpcoming(appt));
@@ -298,13 +487,7 @@ function AppointmentCard({
<span>with {appt.groomerName || 'First Available'}</span>
</div>
</div>
<span
className={`px-2 py-0.5 rounded-full text-xs font-medium ${
STATUS_COLORS[appt.status] || ''
}`}
>
{appt.status}
</span>
<StatusBadge status={appt.status} />
{expanded ? (
<ChevronDown size={16} className="text-stone-400" />
) : (
@@ -574,16 +757,36 @@ export function RescheduleFlow({
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [slotsLoading, setSlotsLoading] = useState(false);
const [slotsError, setSlotsError] = useState<string | null>(null);
const [availableTimes, setAvailableTimes] = useState<string[]>([]);
const availableTimes = [
'9:00 AM',
'10:00 AM',
'11:00 AM',
'1:00 PM',
'2:00 PM',
'3:00 PM',
'4:00 PM',
];
useEffect(() => {
if (!selectedDate || !sessionId) {
setAvailableTimes([]);
setSlotsError(null);
return;
}
if (!appt.serviceId) {
setAvailableTimes([]);
setSlotsError('Failed to load time slots');
return;
}
let cancelled = false;
setSlotsLoading(true);
setSlotsError(null);
fetchAvailability({ serviceId: appt.serviceId, date: selectedDate }, sessionId).then(
({ times, error }) => {
if (cancelled) return;
setAvailableTimes(times);
setSlotsError(error);
setSlotsLoading(false);
},
);
return () => {
cancelled = true;
};
}, [selectedDate, sessionId, appt.serviceId]);
async function handleSubmit() {
if (!selectedDate || !selectedTime) return;
@@ -655,6 +858,7 @@ export function RescheduleFlow({
<h3 className="font-medium text-stone-800 mb-3">Pick a New Date & Time</h3>
<input
type="date"
aria-label="Select date"
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}
min={new Date().toISOString().split('T')[0]}
@@ -662,7 +866,12 @@ export function RescheduleFlow({
/>
{selectedDate && (
<div className="grid grid-cols-3 gap-2 mb-4">
{availableTimes.map((time) => (
{slotsLoading && <p className="col-span-3 text-sm text-stone-500 py-2">Checking availability</p>}
{!slotsLoading && slotsError && <p className="col-span-3 text-sm text-red-500 py-2">{slotsError}</p>}
{!slotsLoading && availableTimes.length === 0 && !slotsError && (
<p className="col-span-3 text-sm text-stone-500 py-2">No available slots on this date.</p>
)}
{!slotsLoading && availableTimes.map((time) => (
<button
key={time}
onClick={() => setSelectedTime(time)}
@@ -708,7 +917,7 @@ interface BookingFlowProps {
sessionId: string | null;
}
function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
export function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
const [step, setStep] = useState(1);
const [pets, setPets] = useState<Pet[]>([]);
const [services, setServices] = useState<Service[]>([]);
@@ -729,16 +938,37 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [slotsLoading, setSlotsLoading] = useState(false);
const [slotsError, setSlotsError] = useState<string | null>(null);
const [availableTimes, setAvailableTimes] = useState<string[]>([]);
const availableTimes = [
'9:00 AM',
'10:00 AM',
'11:00 AM',
'1:00 PM',
'2:00 PM',
'3:00 PM',
'4:00 PM',
];
useEffect(() => {
if (!selectedDate || !sessionId) {
setAvailableTimes([]);
setSlotsError(null);
return;
}
const serviceId = selectedServices[0]?.id;
if (!serviceId) {
setAvailableTimes([]);
setSlotsError('Failed to load time slots');
return;
}
let cancelled = false;
setSlotsLoading(true);
setSlotsError(null);
fetchAvailability({ serviceId, date: selectedDate }, sessionId).then(
({ times, error }) => {
if (cancelled) return;
setAvailableTimes(times);
setSlotsError(error);
setSlotsLoading(false);
},
);
return () => {
cancelled = true;
};
}, [selectedDate, sessionId, selectedServices]);
useEffect(() => {
const fetchData = async () => {
@@ -785,26 +1015,40 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
setSubmitting(true);
setError(null);
try {
const response = await fetch('/api/portal/waitlist', {
const payload = JSON.stringify({
petId: selectedPet.id,
serviceId: selectedServices[0]?.id,
serviceIds: selectedServices.map((s) => s.id),
addOnIds: selectedAddOns.map((s) => s.id),
groomerId: selectedGroomer === 'first-available' ? null : selectedGroomer,
preferredDate: selectedDate,
preferredTime: slotToTime(selectedTime),
notes: notes || undefined,
recurring: recurring || undefined,
});
const submitWaitlist = (id: string) =>
fetch('/api/portal/waitlist', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Impersonation-Session-Id': sessionId ?? '',
'X-Impersonation-Session-Id': id,
},
body: JSON.stringify({
petId: selectedPet.id,
serviceId: selectedServices[0]?.id,
serviceIds: selectedServices.map((s) => s.id),
addOnIds: selectedAddOns.map((s) => s.id),
groomerId: selectedGroomer === 'first-available' ? null : selectedGroomer,
preferredDate: selectedDate,
preferredTime: selectedTime,
notes: notes || undefined,
recurring: recurring || undefined,
}),
body: payload,
});
try {
let response = await submitWaitlist(sessionId);
// GRO-2234: a deliberately-paced wizard can outlive the portal session.
// The customer's Better Auth session is still valid, so transparently
// re-mint a fresh portal session and retry once before surfacing an error.
if (response.status === 401) {
const freshSessionId = await remintPortalSession();
if (freshSessionId) {
response = await submitWaitlist(freshSessionId);
}
}
if (response.ok) {
setConfirmed(true);
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_STEP_SUBMIT, { step: "submit", flow: "portal" });
@@ -862,7 +1106,7 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
Appointment Requested!
</h3>
<p className="text-sm text-stone-500 mb-4">
{selectedPet?.name} on {formatDate(selectedDate)} at {selectedTime}
{selectedPet?.name} on {formatDate(selectedDate)} at {formatSlotLabel(selectedTime)}
</p>
<button
onClick={onClose}
@@ -1059,6 +1303,7 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
<h3 className="font-medium text-stone-800 mb-3">Pick Date & Time</h3>
<input
type="date"
aria-label="Select date"
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}
min={new Date().toISOString().split('T')[0]}
@@ -1066,7 +1311,12 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
/>
{selectedDate && (
<div className="grid grid-cols-3 gap-2 mb-4">
{availableTimes.map((time) => (
{slotsLoading && <p className="col-span-3 text-sm text-stone-500 py-2">Checking availability</p>}
{!slotsLoading && slotsError && <p className="col-span-3 text-sm text-red-500 py-2">{slotsError}</p>}
{!slotsLoading && availableTimes.length === 0 && !slotsError && (
<p className="col-span-3 text-sm text-stone-500 py-2">No available slots on this date.</p>
)}
{!slotsLoading && availableTimes.map((time) => (
<button
key={time}
onClick={() => setSelectedTime(time)}
@@ -1076,7 +1326,7 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
: 'border-stone-200 hover:border-stone-300'
}`}
>
{time}
{formatSlotLabel(time)}
</button>
))}
</div>
@@ -1146,7 +1396,7 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
<div className="flex justify-between">
<span className="text-stone-500">Date & Time</span>
<span className="font-medium">
{formatDate(selectedDate)} at {selectedTime}
{formatDate(selectedDate)} at {formatSlotLabel(selectedTime)}
</span>
</div>
{recurring && (
+1 -1
View File
@@ -22,7 +22,7 @@ function newAlert(): Omit<MedicalAlert, "id"> {
export function PetForm({ pet, onSave, onCancel, saving, saveError }: Props) {
const [name, setName] = useState(pet?.name ?? "");
const [breed, setBreed] = useState(pet?.breed ?? "");
const [weight, setWeight] = useState(pet?.weightKg ?? 0);
const [weight, setWeight] = useState(Number(pet?.weight ?? pet?.weightKg ?? 0));
const [notes, setNotes] = useState(pet?.healthAlerts ?? "");
const [coatType, setCoatType] = useState<CoatType | "">((pet?.coatType as CoatType) ?? "");
const [petSizeCategory, setPetSizeCategory] = useState<SizeOption | "">(pet?.petSizeCategory as SizeOption ?? "");
+14 -5
View File
@@ -176,9 +176,9 @@ export function PetProfiles({ sessionId, readOnly }: Props) {
</div>
<div className="flex-1">
<h2 className="text-xl font-semibold text-stone-800">{selectedPet.name}</h2>
<p className="text-stone-500 text-sm">{selectedPet.breed ?? "Unknown breed"} · {selectedPet.weightKg ? `${selectedPet.weightKg} kg` : "Unknown weight"}</p>
<p className="text-stone-500 text-sm">{selectedPet.breed ?? "Unknown breed"} · {(() => { const w = selectedPet.weight ?? selectedPet.weightKg; return w != null && w !== "" ? `${w} kg` : "Unknown weight"; })()}</p>
<p className="text-stone-400 text-xs mt-0.5">
Born {selectedPet.dateOfBirth ? new Date(selectedPet.dateOfBirth).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }) : "Unknown"}
Born {(() => { const d = selectedPet.birthDate ?? selectedPet.dateOfBirth; return d ? new Date(d).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }) : "Unknown"; })()}
</p>
</div>
{!readOnly && (
@@ -222,6 +222,14 @@ export function PetProfiles({ sessionId, readOnly }: Props) {
);
}
export function formatSizeCategory(size?: string | null): string {
if (!size) return "Unknown";
return size
.split("_")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex flex-col sm:flex-row sm:items-center py-2.5 border-b border-stone-100 last:border-0">
@@ -244,7 +252,7 @@ function SeverityBadge({ severity }: { severity: "low" | "medium" | "high" }) {
);
}
function BasicInfoTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
export function BasicInfoTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
const score = pet.temperamentScore;
const flags = pet.temperamentFlags ?? [];
@@ -252,8 +260,9 @@ function BasicInfoTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
<div>
<InfoRow label="Name" value={pet.name} />
<InfoRow label="Breed" value={pet.breed || "Unknown"} />
<InfoRow label="Weight" value={pet.weightKg ? `${pet.weightKg} kg` : "Unknown"} />
<InfoRow label="Date of Birth" value={pet.dateOfBirth ? new Date(pet.dateOfBirth).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }) : "Unknown"} />
<InfoRow label="Weight" value={(() => { const w = pet.weight ?? pet.weightKg; return w != null && w !== "" ? `${w} kg` : "Unknown"; })()} />
<InfoRow label="Date of Birth" value={(() => { const d = pet.birthDate ?? pet.dateOfBirth; return d ? new Date(d).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }) : "Unknown"; })()} />
<InfoRow label="Size Category" value={formatSizeCategory(pet.petSizeCategory)} />
{/* Temperament (staff-set, read-only) */}
{(score != null || flags.length > 0) && (
+2
View File
@@ -39,6 +39,8 @@ export default defineConfig({
],
},
workbox: {
skipWaiting: true,
clientsClaim: true,
globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"],
navigateFallbackDenylist: [
/^\/api\/auth\//,