Files
baya-monorepo/docs/rules/client/services.md
T
2026-07-30 02:26:52 +03:30

13 KiB
Raw Blame History

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', 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 §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 constructionPriceBreakdown 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 recomputedCountdownTimer 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 for the token cookies and the session lifecycle.