Skip to main content

API layer

All ERP HTTP traffic flows through one axios instance built in frontend/src/api/client.ts; the customer portal has its own smaller client (below). Endpoint semantics (pagination, error shape, auth header) are documented API-side in ../api/conventions.md.

client.ts anatomy​

Base URL​

export const API_BASE_URL = import.meta.env.VITE_API_URL ?? '';

VITE_API_URL defaults to empty, so requests use relative URLs (/api/employees) and go to the same origin — in Docker that's nginx, which reverse-proxies /api/ and /ws to the backend, so there are no CORS hops in normal use. The value is baked in at image build time; for the host-HMR loop you set it to http://localhost:8081 instead (see README.md and ../onboarding.md).

Custom paramsSerializer​

The instance serializes query params itself: arrays are appended as repeated keys (sort=a&sort=b — what Spring expects for multi-sort), scalars are stringified, and anything else (objects, null, undefined) is silently dropped. That last part matters for grid pages, which build param objects with many optional filter fields.

Request interceptor​

Reads store.getState().auth.token — the Redux store imported directly, no React involved (state.md explains why auth lives in Redux) — and sets Authorization: Bearer <token> when present.

Response interceptor: the silent-refresh 401 flow​

Mechanics worth knowing, all visible in client.ts:

  • _retried once-per-request flag. Each failed request is retried at most once after a refresh; a second 401 (refresh raced a revocation, permissions changed) falls through to logout instead of looping.
  • One shared in-flight refreshPromise. As the code comment explains: refresh tokens are single-use and rotated on every call, with reuse treated as theft by the backend's RefreshTokenService (family reuse-detection). If several requests 401ed simultaneously and each called refresh independently, the second call would present an already-used token — indistinguishable from a replay attack — and the backend would kill the whole token family, logging the user out. Serializing every concurrent 401 through a single promise (refreshPromise ??= …, cleared in finally) makes bursts safe. Backend side: ../security/authentication.md.
  • Logout on refresh failure. If there is no refresh token or the refresh call fails, the interceptor dispatches logout(), which clears state and the motorph.auth localStorage entry; ProtectedRoute then bounces to /login.
  • Auth endpoints are exempt. /api/auth/login, /refresh and /logout never trigger refresh-and-retry — they carry their own credential (or none), and a 401 from login just means "wrong password".

Error handling​

The backend's error body is ApiErrorResponse { message, status, time } (declared in api/types.ts; shape documented in ../api/conventions.md). api/errors.ts exports the one helper everyone uses:

getApiErrorMessage(error, 'Failed to save employee')

It returns the server's message when the error is an axios error carrying one, else the fallback. The convention is to feed the result to a toast in mutation onError handlers — see forms.md for the pattern in situ.

The api/ module convention​

frontend/src/api/ holds ~60 modules, one per backend domain (employees.ts, payroll.ts, job-requisitions.ts, …). Each is deliberately thin:

  • exports typed request/param interfaces and calls apiClient with typed responses — DTO types live in the module or in api/types.ts;
  • no caching, state, or React imports — that's the job of the hooks/api/ React Query layer (state.md) or the grid's fetchPage (ag-grid.md);
  • one function per endpoint, named after the operation (listEmployees, createEmployee, updateEmployeeStatus).

api/employees.ts is a good reference — including how AG-Grid-style filter params (lastNameSearch, hireDateFrom/To, fields projection) are expressed as a typed params object.

The portal client​

The customer portal uses a separate instance, api/portalClient.ts: same API_BASE_URL, but its request interceptor reads the token from the Zustand usePortalAuthStore (not Redux), and its response interceptor does no refresh at all — on any 401 it logs the portal session out and hard-redirects to /portal/login (the portal token has no refresh rotation; see ../api/auth.md).

Known wart — a three-file import cycle. api/portal.ts imports portalApiClient from portalClient.ts; portalClient.ts imports usePortalAuthStore from store/portalAuthStore.ts; and portalAuthStore.ts imports the PortalUser type back from api/portal.ts. The closing edge is type-only, so nothing breaks at runtime, but the cycle is real in the module graph and is a standing refactor candidate: moving PortalUser into a shared types module would break it.