Documentation
IntroductionArchitectureMonorepo & ToolingRunning Locally
Screen Flow MapUser JourneysPersonas & RolesWeb ScreensClient Portal
Convex OverviewData ModelFunctions ReferenceAuth & SessionsRoles & PermissionsJobs & Providers
Web Design SystemAdmin Console DesignPatterns & Conventions
Web AppiOS AppMarketing Website
Testing & HarnessCI Pipeline

Client Portal

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.

The portal as the pet parent sees it — no staff chrome, everything hangs off one live overview query
The portal as the pet parent sees it — no staff chrome, everything hangs off one live overview query

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.addCard dev vault; Stripe SetupIntent flow in production via providers.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.sendMessage creates 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:

  1. On the client detail, View portal as client calls portal.startImpersonation (requires clients.manage, writes an audit log entry).
  2. The portal opens with ?as={clientId}; every portal function accepts an optional asClientId that is honored only for staff with clients.manage.
  3. A banner marks the impersonated view; Exit returns to the client detail.

Entry points

  • Direct login (pet parents).
  • Public directory: /our-customers cards 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.