Client Portal
The customer-facing half of the product: /o/{slug}/portal. Pet parents sign in at the same /login door as staff; users whose org membership is the customer role land here. The portal is the web twin of the iOS companion app.
Design intent
Self-service without staff touches. The Playwright harness proves the flagship journey end to end with zero staff involvement: save a card → book a stay → pay the deposit.

What it renders
The portal deliberately bypasses the staff shell — the org layout returns bare children when the path includes /portal, so clients never see staff chrome. Everything hangs off a single live query, portal.myOverview:
- My pets — profiles, vaccine status, photos.
- Upcoming stays — visits with dates and status.
- Book a stay — bookable products (boarding/services); booking creates the visit and its order with the deposit in one mutation (
portal.bookVisit). - Orders & balances — recent orders; pay with a wallet card (
portal.payOrder). - Wallet — saved cards (
portal.addCarddev vault; Stripe SetupIntent flow in production viaproviders.stripe). - Agreements to sign — active required agreements; self-sign with typed name (
portal.signAgreement). - Vet records — upload documents to own pets (
portal.generateUploadUrl→ storage POST →portal.attachDocument). - Messages — the client's single thread with the facility (
portal.myThread,portal.sendMessagecreates the thread on first use).
Security model
Portal functions are org-scoped like everything else (same orgQuery/orgMutation seam) but resolve the caller's own client record via myClient() — the link is clients.userId. There is no per-function policy gating beyond membership; instead every read/write is constrained to the caller's own client, pets, orders, and thread.
Staff impersonation
Staff diagnose portal issues by seeing exactly what the client sees:
- On the client detail, View portal as client calls
portal.startImpersonation(requiresclients.manage, writes an audit log entry). - The portal opens with
?as={clientId}; every portal function accepts an optionalasClientIdthat is honored only for staff withclients.manage. - A banner marks the impersonated view; Exit returns to the client detail.
Entry points
- Direct login (pet parents).
- Public directory:
/our-customerscards link to each org's portal. - Staff sidebar footer: "Customer portal" link.
- Staff impersonation from a client record.
Relationship to the iOS app
The iOS app is the same persona and the same backend seam: its AppStore intent methods map 1:1 to the portal mutations (bookVisit → orders.create + visits.create, recordPayment → payments.charge, sendMessage → messages.send, signAgreement → signatures.create). The portal is the reference implementation for customer-facing behavior.