13 KiB
Client services and data
The fetch layer, the services/{domain} pattern, caching, and the money rules that make the UI honest.
Last verified: 2026-07-30 against commit
d3ec723.
1. Fetch only through the two primitives
| File | Use from | Behaviour |
|---|---|---|
lib/api/client.ts |
hooks, client components | clientFetch<T> — throws ApiError on error; silent-refreshes and retries once on 401 |
lib/api/server.ts |
RSCs, Server Actions | serverFetch<T> — throws ApiError on error |
lib/api/errors.ts |
anywhere | the ApiError class (status, message, code) |
lib/api/types.ts |
anywhere | ApiEnvelope<T> + unwrap(), Paginated<T>, PageParams |
lib/api/refresh.ts |
internal | attemptTokenRefresh — the single-flight refresh clientFetch's 401 branch uses |
Never call fetch() directly in a component, hook, or service. Domain calls live in
src/services/{domain}/apis/.
The clientFetch error contract
| Status | What happens |
|---|---|
| 401 | Toast "session expired", clear cookies, redirect to login. No throw — the page navigates away |
| 403 | Toast "forbidden", throw ApiError |
| 5xx | Toast "server error", throw ApiError |
| Other 4xx | Throw ApiError, no toast — the calling hook owns the user-facing message |
| Network failure | Toast "network error", throw ApiError |
So: never toast 401/403/5xx inside a hook. Only a domain-specific 4xx earns an onError toast. A
mutation that handles only onSuccess is still a defect — see components.md §6.
serverFetch throws on every error and toasts nothing (the server can't fire browser events). The RSC
caller decides whether to notFound(), redirect(), or let it reach an error boundary.
Never mix clientFetch and serverFetch in one file. Keep clientApi.ts and serverApi.ts separate;
Next enforces the environment boundary at build time.
The wire envelope
The server wraps every response in ApiEnvelope<T> — { isSuccess, statusCode, message, requestId, data },
camelCase. clientFetch returns the raw body, so a real clientApi reads the payload via unwrap().
Types mirror the wire exactly and are derived from the published contract in
docs/integration/ — never guessed. If a shape you need doesn't exist, say so
and mock behind the seam meanwhile (§3).
2. The services/{domain} pattern
Every one of the 22 domains has the same shape. Copy auth or patients.
services/{domain}/
├── types.ts wire types + the domain's `Api` interface — this interface IS the seam
├── keys.ts the React Query key factory, hierarchical
├── constants.ts the mock toggle + staleTime values (when the domain has a mock)
├── apis/
│ ├── clientApi.ts real, wraps clientFetch, unwraps the envelope
│ ├── mockApi.ts in-memory, same interface
│ ├── serverApi.ts serverFetch — only when an RSC needs it
│ └── index.ts selects real vs mock by config — the one line hooks import
├── hooks/
│ └── use{Action}.ts one hook per file — useQuery (deliberate staleTime) or useMutation (invalidates)
└── index.ts the barrel: re-exports HOOKS ONLY
Two hard boundaries on the barrels:
- No top-level
src/services/index.ts. An import must name its domain:import { useLogin } from '@/services/auth', neverfrom '@/services'. - A domain barrel exports hooks only — never
types,keys, orapis/*. Reaching past the hooks is how a component ends up depending on a mock's internals.
Caching is deliberate, not incidental
- Set a
staleTimeon reads, so revisiting a screen doesn't refetch. - Mutations invalidate the affected list key (
queryClient.invalidateQueries) orsetQueryData— never leave the cache stale. Seeservices/patients/hooks/*. - Reference data is cached for the whole session. Rarely-changing lookups use an Infinite
staleTimeplus a shared hierarchical key factory, so each level is fetched once and served from cache across every consumer — never refetched on a dropdown open. Two domains do this:geography(the province→city→district hierarchy,geographyKeys) andcatalog(admin-seeded categories and a category's option groups,CATALOG_REFERENCE_*). Reuse the pattern; do not reinvent per-consumer fetching. - Contrast with mutable lists — addresses, coverage areas, the nurse's own variant list — which invalidate on every mutation.
- The filter object IS the query key.
searchcanonicalizes its filter set into the key (canonicalizeSearchFilters), so identical or reverted filters reuse cache with zero network;keepPreviousDataavoids flashing. Filters and page belong in the URL, which is what makes the cache key shareable and the back button work. - Admin and partner queue pages use
useAdminListState(@/hooks) for URL-synced worklist state: draft-vs-applied filters plus page, withapply/applyFilters/clear/goToPage. It isuseSearchParams-based, so a caller needs a<Suspense>boundary. - Prefer RSC prefetch or
initialDatawhere it removes a client round-trip.
Re-render cost is part of correctness
Stable references (useCallback/useMemo only where it pays), select to subscribe to a slice rather than
a whole query, state colocated as low as it can go and lifted only when genuinely shared. Don't put
fast-changing state in a high context provider — a 1-second countdown belongs inside the component that
displays it, which is exactly what CountdownTimer does.
3. The mock seam
When a backend endpoint isn't live, implement the domain's Api interface twice — a real clientApi.ts
and an in-memory mockApi.ts — and select in apis/index.ts by a config flag (USE_{DOMAIN}_MOCK). Hooks
import the selected api; the swap is one line and touches no caller. Record every mock in
docs/status/ per code-quality.md §4.
Current state — 15 real, 7 mocked
Real (USE_*_MOCK = false): auth, geography, patients, profiles, nurse (bank), addresses,
serviceAreas, catalog, search, bookingRequests, bookings, payment, reviews, notifications,
tickets.
Still mocked, each blocked on a named contract gap:
| Domain | Blocked on |
|---|---|
verification |
the admin verification queue |
refunds |
the admin refund preview |
payouts |
the admin payout preview |
admin |
the RBAC role endpoints |
bnpl |
provider options / schedule / wallet installments |
partnerCenter |
the portal split reads + a /me centre signal |
patientRecords |
endpoints exist, but the client family-record id model is string vs the wire's int — the customer-edit PUT is write-unsafe until reconciled |
The open REQ numbers behind these live in docs/status/backlog.md. Before flipping a domain to real, check
that its clientApi.ts actually consumes the fields the server serves — flipping the flag is necessary but
not sufficient.
One coupled seam: EVV_GPS_MODE auto-selects off (real navigator.geolocation) once
USE_BOOKINGS_MOCK is false, so you don't get mock coordinates against real bookings.
4. Money and time on the client
The client displays money. It does not compute it.
| Rule | Why |
|---|---|
Money crosses the wire as an IRR digit string and is parsed with integer-safe BigInt helpers (formatIrrToToman / formatIrr / parseIrr in @/utils). Money is never a float |
IRR aggregates exceed JS's safe integer range, and float coercion on money is a correctness bug, not a rounding one |
A breakdown reconciles by construction — PriceBreakdown dev-guards console.error when rows don't sum to the total |
A total the user can't derive from the rows they were shown is a trust failure |
| The client never computes a rate, an aggregate, a payout date, or a holiday shift | These are server truth. A commission rate is snapshotted server-side at compute time; a review aggregate is recomputed from source; a payout date shifts off the bank-closure calendar |
A server-frozen deadline is rendered, never recomputed — CountdownTimer takes the UTC instant and counts down to it |
A client that recomputes a deadline from a config value will disagree with the server the moment the config changes |
| The signed net payable balance is never clamped — a negative reads as an explicit "owed back" state (magnitude only, never a bare minus) | Clamping to zero tells a nurse they owe nothing when they do |
| Toman is display-only; the boundary conversion happens once, at the field | Mixing units in the middle is how a price ends up 10× off |
Dates arrive as UTC ISO and display through formatShamsiDate(Time). Shamsi is a client concern |
Except bank-closure math, which the server owns |
Money-path mechanics
- The caller owns the per-attempt
Idempotency-Keyon payment initiate. Per attempt, not per booking. - Poll only while non-terminal, with backoff and bounded attempts.
usePaymentOutcome,useBnplOrder,useRefundStatusanduseBookingRequestall stop at a terminal state. - A 409 on the money path is benign convergence, never a toast. It means the server already did what you asked.
invalidations.tsis the one post-capture cache transition per money domain — an explicit list of the request/booking/summary/outcome keys that change. Never a blanket refetch.
5. Non-negotiable data rules
These encode business invariants, not preferences. Breaking one leaks data or misreports money.
Clinical data
is_internalis never modelled in the user-app ticket types. Both mappers drop an internal message — a server-strip mimic — and there is no internal affordance anywhere in a user-facing screen. The admin ticket types carryisInternal; the user types deliberately do not.- The customer must never fire the care-instructions query. The two-stage disclosure gate is proved by a
test on
BookingDetailView. - A nurse's care-record access is append-only. The nurse surface never wires the customer-edit mutation.
- Access-denied is a first-class, non-leaking state, gated before any clinical fetch — not an error rendered after a 403 came back with a body in it.
- Clinical text is never logged, never put in
localStorage, never put in a query string.
Visibility and trust
- A
pending_moderationreview is never injected into a public list or aggregate, and the client never computes the aggregate. - Every search result is verified-by-invariant — the server's index only contains searchable rows, so the UI never re-filters. If an unverified nurse appears, that is a server bug, not something to paper over client-side.
districtId = nullmeans whole-city — a real coverage choice, not missing data. Treating it as absent drops a nurse's entire coverage.- A notification's
data_jsonis a typed contract.parseNotificationData(type, dataJson)returns a discriminated union, tolerates snake/camel, and degrades to{ kind: 'none' }on anything malformed, unknown, or missing an id. Never trust the blob; never index into it directly.
6. Cookies
App and auth state goes through the cookie manager only — never document.cookie, never js-cookie
directly, never localStorage or sessionStorage.
| File | Import from | Holds |
|---|---|---|
lib/cookies/constants.ts |
anywhere, via the barrel | COOKIE_NAMES, CookieOptions, AUTH_*_COOKIE_OPTIONS, COLOR_SCHEME_COOKIE_OPTIONS |
lib/cookies/server.ts |
RSCs, Server Actions, Route Handlers only | getServerCookie, getThemeMode, setServerCookie |
lib/cookies/client.ts |
client components / effects only | getClientCookie, setClientCookie, deleteClientCookie, getColorSchemeCookie |
lib/cookies/index.ts |
anywhere | re-exports constants.ts only — a safe barrel |
Import constants via the barrel (import { COOKIE_NAMES } from '@/lib/cookies') and the server/client
utilities directly from their file. Never import server.ts in a client component or client.ts in an
RSC.
COOKIE_NAMES.COLOR_SCHEME = 'color-scheme' is the single source of truth for the theme cookie name — do not
redeclare it anywhere. CookieOptions.maxAge is in seconds (converted to an expires: Date internally).
Never read localStorage or document.cookie in a render function — use an effect, or read server-side
via next/headers.
See auth.md for the token cookies and the session lifecycle.