222 lines
13 KiB
Markdown
222 lines
13 KiB
Markdown
# 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.
|