batchCooking/apps/web/src/api/client.ts
Nicolas e512c33ffc Web: composants partagés foyer/régime/allergènes (step 3/6)
- ApiClient: getDiets/getAllergies (référence), getCurrentHouse/
  renameHouse, updateDiet, getAllergyIds/updateAllergyIds.
- features/profile/: HouseNameField, DietSelect (toujours une option
  "aucun régime" -> null, étape skippable), AllergySelect (checkboxes
  en grille + fieldset/legend, pas un <select multiple> — plus
  tapable/accessible, notamment sur mobile). Tous "dumb"/contrôlés :
  reçoivent leurs données (diets/allergies) en props plutôt que de les
  fetcher eux-mêmes — le fetch/état de chargement reste à la page
  appelante.
- profile-forms.scss partagé par les trois (même split que
  features/auth/auth-form.scss vs Login/SignupPage : styles de champs
  ici, layout de page dans chaque page consommatrice).

Pas encore utilisés (aucune page ne les importe) — le wizard
d'inscription (étape suivante) et la page /foyer les cablent.
2026-08-16 23:22:19 +02:00

146 lines
5.3 KiB
TypeScript

import {
type AllergyView,
type ApiErrorResponse,
type DietView,
ErrorCode,
type HouseView,
type LoginInput,
type PlanningView,
type SafeUserProfile,
type SignupInput,
} from "@batch-cooking/shared";
/** Base URL of the API, configurable via `VITE_API_URL` (see `.env.example`). */
const API_BASE_URL: string = import.meta.env.VITE_API_URL ?? "http://localhost:3000";
/**
* Thrown by {@link ApiClient} whenever the API responds with a non-2xx
* status. Carries the same {@link ErrorCode} the API returned, so callers
* can branch on `error.code` (and UI code can look up its label via
* `ErrorMessageService.getLabel(error.code)`) instead of parsing text.
*/
export class ApiError extends Error {
/** HTTP status code of the failed response. */
public readonly status: number;
/** Machine-readable error code — see {@link ErrorCode}. */
public readonly code: ErrorCode;
/** Per-field validation messages, present only when `code` is `VALIDATION_ERROR`. */
public readonly fieldErrors?: Record<string, string[] | undefined>;
public constructor(status: number, body: ApiErrorResponse) {
super(body.message);
this.name = "ApiError";
this.status = status;
this.code = body.code;
this.fieldErrors = body.details;
}
}
/**
* Thin fetch wrapper around the auth endpoints. A class (rather than plain
* functions) so it reads as a cohesive service and stays easy to extend
* (e.g. swapping the transport, adding request interceptors) without
* touching every call site. Used as a single shared instance (`apiClient`,
* exported below) — it's stateless, so there's no reason for more than one.
*/
export class ApiClient {
/**
* Performs a JSON request against the API and returns the parsed body.
*
* @throws {ApiError} if the response status is not in the 2xx range.
*/
private async request<TResponseBody>(
path: string,
options: RequestInit = {},
): Promise<TResponseBody> {
const response = await fetch(`${API_BASE_URL}${path}`, {
...options,
// Required for the httpOnly session cookie to be sent/received — the
// API and the web app run on different origins.
credentials: "include",
headers: { "Content-Type": "application/json", ...options.headers },
});
if (!response.ok) {
const body = (await response.json().catch(() => null)) as ApiErrorResponse | null;
// Fallback for a response that couldn't even be parsed as JSON — no
// hardcoded string, always the real enum member.
throw new ApiError(
response.status,
body ?? { code: ErrorCode.INTERNAL_ERROR, message: "Something went wrong" },
);
}
// 204 No Content (e.g. logout) has no body to parse.
if (response.status === 204) {
return undefined as TResponseBody;
}
return response.json() as Promise<TResponseBody>;
}
/** Creates a profile (+ household) and starts a session. */
public signup(input: SignupInput): Promise<SafeUserProfile> {
return this.request("/auth/signup", { method: "POST", body: JSON.stringify(input) });
}
/** Verifies credentials and starts a session. */
public login(input: LoginInput): Promise<SafeUserProfile> {
return this.request("/auth/login", { method: "POST", body: JSON.stringify(input) });
}
/** Ends the current session. */
public logout(): Promise<void> {
return this.request("/auth/logout", { method: "POST" });
}
/** Fetches the currently authenticated profile — rejects with `NOT_AUTHENTICATED` if there's no session. */
public me(): Promise<SafeUserProfile> {
return this.request("/auth/me");
}
/** Fetches the current user's household's planning for today, or `null` if there isn't one yet. */
public getCurrentPlanning(): Promise<PlanningView | null> {
return this.request("/planning/current");
}
/** Reference list of dietary regimes to pick from (signup wizard, `/foyer`). Public — no session required. */
public getDiets(): Promise<DietView[]> {
return this.request("/reference/diets");
}
/** Reference list of selectable allergens (signup wizard, `/foyer`). Public — no session required. */
public getAllergies(): Promise<AllergyView[]> {
return this.request("/reference/allergies");
}
/** Fetches the current user's household. */
public getCurrentHouse(): Promise<HouseView | null> {
return this.request("/house/current");
}
/** Renames the current user's household. */
public renameHouse(name: string): Promise<HouseView> {
return this.request("/house/current", { method: "PATCH", body: JSON.stringify({ name }) });
}
/** Sets (or clears, with `null`) the current user's dietary regime. */
public updateDiet(dietId: number | null): Promise<SafeUserProfile> {
return this.request("/profile/diet", { method: "PATCH", body: JSON.stringify({ dietId }) });
}
/** Fetches the current user's selected allergen ids. */
public getAllergyIds(): Promise<number[]> {
return this.request("/profile/allergies");
}
/** Replaces the current user's full allergen selection (not a merge — send the complete list). */
public updateAllergyIds(allergyIds: number[]): Promise<number[]> {
return this.request("/profile/allergies", {
method: "PATCH",
body: JSON.stringify({ allergyIds }),
});
}
}
/** Single shared instance — this client is stateless, no need for one per caller. */
export const apiClient = new ApiClient();