Files
baya-monorepo/archive/docs/rules/client/services.md
T
2026-08-02 20:01:31 +03:30

222 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.