# 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` — throws `ApiError` on error; silent-refreshes and retries once on 401 | | `lib/api/server.ts` | RSCs, Server Actions | `serverFetch` — throws `ApiError` on error | | `lib/api/errors.ts` | anywhere | the `ApiError` class (`status`, `message`, `code`) | | `lib/api/types.ts` | anywhere | `ApiEnvelope` + `unwrap()`, `Paginated`, `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](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` — `{ 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/`](../../integration/index.md) — 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'`, never `from '@/services'`. - **A domain barrel exports hooks only** — never `types`, `keys`, or `apis/*`. Reaching past the hooks is how a component ends up depending on a mock's internals. ### Caching is deliberate, not incidental - Set a **`staleTime`** on reads, so revisiting a screen doesn't refetch. - Mutations **invalidate** the affected list key (`queryClient.invalidateQueries`) or `setQueryData` — never leave the cache stale. See `services/patients/hooks/*`. - **Reference data is cached for the whole session.** Rarely-changing lookups use an **Infinite `staleTime`** plus 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`) and `catalog` (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.** `search` canonicalizes its filter set into the key (`canonicalizeSearchFilters`), so identical or reverted filters reuse cache with zero network; `keepPreviousData` avoids 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, with `apply`/`applyFilters`/`clear`/`goToPage`. It is `useSearchParams`-based, so a caller needs a `` boundary. - Prefer RSC prefetch or `initialData` where 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](../shared/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-Key`** on payment initiate. Per *attempt*, not per booking. - **Poll only while non-terminal**, with backoff and bounded attempts. `usePaymentOutcome`, `useBnplOrder`, `useRefundStatus` and `useBookingRequest` all 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.ts` is 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_internal` is 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 carry `isInternal`; 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_moderation` review 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 = null` means whole-city** — a real coverage choice, not missing data. Treating it as absent drops a nurse's entire coverage. - **A notification's `data_json` is 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](auth.md) for the token cookies and the session lifecycle.