# 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](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/`](../../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 `<Suspense>` 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.
