Aller au contenu

Data fetching & admin API errors

appQueryClient, processHttpResult validation, the admin-API error pipeline driving the global toast and modal submit wrappers.

Hard rules

  • Single appQueryClient from @bricks-common/bo/core, provided at app root. Don't instantiate a parallel client.
  • 401: createAdminAxios({ onUnauthorized }) accepts () => void | Promise<void>. Sign out (Better Auth) before redirect. Concurrent 401s share one lock. Mutations drain (60s cap) so in-flight writes finish first. onUnauthorized itself is capped at 3s — hung signOut still redirects to /login.
  • QueryClient placement follows the router. Projects (TanStack Router): expose it on __root.tsx via createRootRouteWithContext and read it in loaders from Route.useRouteContext() — never useEffect-fetch on mount. Invest (React Router): keep it on Providers only — no loader prefetch.
  • Defaults: staleTime: 0, gcTime: 5 days, retry: 0 (queries + mutations). Override per call site only when the operation genuinely needs to retry.
  • Services just throw. No try/catch on a specific code, no 'available' | 'unavailable' discriminated returns, no *_INELIGIBLE_CODE constants. Let the error propagate; the global handler toasts.
  • Don't reach into error.response.data?.message / ?.code. useSubmitWithError + buildAdminApiErrorMessage cover every case.
  • Error code translations live in api-communication-bricksoffice, not the consumer app. src/translations/fr.json under error-api.<code>.
  • Request payloads + shared primitives single-sourced in @bricks-common/api-communication-bricksoffice — the API imports them too. Don't re-declare in a controller or in the app. Stricter server guard → strengthen the shared validator (same output type), don't fork. Status: api-communication-bricksoffice/src/VALIDATOR-MUTUALIZATION.md. Same for admin enum labels consumed by BO and API Slack: as const satisfies Record<Enum, string> next to the enum.
  • New bricksoffice endpoint bodies: Zod *BodySchema (z.infer → *Body) so discriminated unions survive .d.ts emit; leave existing idtlt *Payload until touched. Forms stay idtlt (form.mdc).
  • HTTP client via context. <HttpClientProvider client={adminAxios}> at the consumer root; services read useHttpClient(). No axios prop drilling, no module-scope singleton import.
  • Authenticated file downloads go through the http client, not a bare <a href> — the attachment sits behind the auth cookie on another origin. httpClient.get<Blob>(path, { responseType: 'blob' }), then triggerBrowserDownload + extractFilenameFromHeader from @bricks-common/bo/utils: the API names its own attachments and exposes Content-Disposition for that, so never rebuild the filename client-side. A blob error body defeats buildAdminApiErrorMessage, so such a mutation owns its toast (meta: { silent: true } + notifyError). Canonical: bricksoffice-notaire useDownloadEcheancierPdf.
  • Gate project-scoped queries with enabled, never skipToken alone. With staleTime: 0, an unconditional fetch on a globally-mounted host refetches on every window focus, so id-gated / always-mounted queries must be gated. But skipToken doesn't make a query inactive, and invalidateProjectScopedQueries invalidates every <module>Keys.all (the bare prefix an id-gated hook falls back to when its id is null) — so a lone skipToken query gets force-refetched and throws "Attempted to invoke queryFn when set to skipToken". Always pair them: queryFn: id != null ? () => … : skipToken (types the queryFn for the absent-id branch, kills focus-refetch) and enabled: id != null (keeps the disabled query inactive so the broad invalidate skips it). Applies to id-gated hooks (canonical useSpvLemonwayBalance) and shared keys alike (projectKeys.list() via useProjects).

The error pipeline

Admin API returns { statusCode, message: "<kebab-code>", … } on every 4xx/5xx — message is the code, not text. Codes translate via adminTranslations['error-api'][code] from @bricks-common/api-communication-bricksoffice; fall back to raw code, then errors.generic.

Wired three ways: - Mutations toast via the global MutationCache.onError on appQueryClient. - Queries going through processHttpResult toast the same (default showErrorNotification: true). - Mutations whose mutationFn unwraps through processHttpResult pass showErrorNotification: false — the MutationCache already toasts, otherwise the same error shows twice. - Modal submits wrap with withGlobalErrorHandling (default) or useSubmitWithError.

Helpers from @bricks-common/bo/core

Helper Use for
appQueryClient the single QueryClient
processHttpResult({ responsePromise, validator }) unwrap axios + validate via idtlt or zod (Validator<T> | z.ZodType<T>); toasts on error by default
withGlobalErrorHandling(asyncFn) wrap a modal submit; swallows rejections so success doesn't run on error
useSubmitWithError() hook variant — { handleSubmit, error, clearError }; reach for it for error.code branching or inline <Alert>
buildAdminApiErrorMessage(error) translate axios error → French (used internally by the global handler)
getAdminApiErrorCode(error) raw kebab code (when branching)

buildAdminApiErrorMessage and getAdminApiErrorCode live in @bricks-common/bo/modules/auth, not in core.

HttpClientProvider / useHttpClient() are at @bricks-common/bo/providers.

Service + mutation pattern

// services/useProject.ts
export const useProject = (id: UUID) => {
  const httpClient = useHttpClient()
  return useQuery({
    queryKey: projectsKeys.detail(id),
    queryFn: (): Promise<ProjectResponse> =>
      processHttpResult({
        responsePromise: httpClient.get(getProjectEndpoint.request.path({ id })),
        validator: projectResponse,
      }),
  })
}

// services/queryKeys.ts — centralized per module
export const projectsKeys = {
  all: ['projects'] as const,
  list: () => [...projectsKeys.all, 'list'] as const,
  detail: (id: UUID) => [...projectsKeys.all, 'detail', id] as const,
}

Mutations always invalidate the list on success.

Server lists key on the whole query object: list: (query) => [...keys.all, 'list', query]. Repeated query params (status=a&status=b) are serialized by createAdminAxios (paramsSerializer: { indexes: null }) — never set it per call.

Project-scoped mutations: invalidateProjectScopedQueries

Why blanket, not per-id precise? Denormalized fields (hasInternalNote, payment status, balance, overdue counts) surface across 6+ tables. We shipped the same stale-data bug repeatedly when invalidating only the home module. Broad invalidation costs a few refetches; under-invalidating shows wrong data.

import { invalidateProjectScopedQueries } from '@/modules/projects/services/invalidations'

onSuccess: () => invalidateProjectScopedQueries(queryClient)

Adding a new project-touching module → extend the helper's list in invalidations.ts. Don't call extra invalidateQueries at each mutation's call site.

Non-project mutations (useCreatePropertyMonthlyUpdate, useUploadYearlyFinancialUpdate, …) keep narrow scoped invalidations.

// Default
const handleSubmit = withGlobalErrorHandling(async (values) => {
  await mutation.mutateAsync(...)
  notifySuccess(m['...']())
  onClose()
})

// When you need the error locally (inline alert, branch on code)
const { handleSubmit, error, clearError } = useSubmitWithError()

For query-level branching (a query error gates UI like a disabled form), prefer query.isError. Inspect the code only when UX needs to distinguish reasons.

For mutations needing an inline or custom toast, opt out with useMutation({ meta: { silent: true } }). If the mutationFn uses processHttpResult, also pass showErrorNotification: false — otherwise that helper toasts and the hook/onError toasts again.

Adding a new endpoint

  1. Add it in projects/common/both/api-communication-bricksoffice/src/endpoints/ (Zod *BodySchema, not a new idtlt *Payload).
  2. Re-export from the package barrel.
  3. Add matching error-api.<code> translations to src/translations/fr.json — otherwise the toast falls back to the raw kebab.
  4. Rebuild the package (pnpm --filter @bricks-common/api-communication-bricksoffice build) before the consumer resolves the new exports.

What NOT to do

  • ❌ try { ... } catch { /* global handler */ } inline in a modal submit — use withGlobalErrorHandling.
  • ❌ try/catch in a service to translate a business code into null / 'unavailable' — let it propagate.
  • ❌ status >= 400 && < 500 catch-all to mean "ineligible" — swallows 401/403/404/422 and hides real failures.
  • ❌ notifyError / toast.error from a mutation onError or call-site unless meta.silent === true (and showErrorNotification: false on processHttpResult) — MutationCache already toasts.
  • ❌ Hand-list invalidateQueries({ queryKey: <module>Keys.all }) in a project-scoped mutation's onSuccess — extend invalidateProjectScopedQueries instead.