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; 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( path: string, options: RequestInit = {}, ): Promise { 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; } /** Creates a profile (+ household) and starts a session. */ public signup(input: SignupInput): Promise { return this.request("/auth/signup", { method: "POST", body: JSON.stringify(input) }); } /** Verifies credentials and starts a session. */ public login(input: LoginInput): Promise { return this.request("/auth/login", { method: "POST", body: JSON.stringify(input) }); } /** Ends the current session. */ public logout(): Promise { return this.request("/auth/logout", { method: "POST" }); } /** Fetches the currently authenticated profile — rejects with `NOT_AUTHENTICATED` if there's no session. */ public me(): Promise { 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 { return this.request("/planning/current"); } /** Reference list of dietary regimes to pick from (signup wizard, `/foyer`). Public — no session required. */ public getDiets(): Promise { return this.request("/reference/diets"); } /** Reference list of selectable allergens (signup wizard, `/foyer`). Public — no session required. */ public getAllergies(): Promise { return this.request("/reference/allergies"); } /** Fetches the current user's household. */ public getCurrentHouse(): Promise { return this.request("/house/current"); } /** Renames the current user's household. */ public renameHouse(name: string): Promise { 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 { return this.request("/profile/diet", { method: "PATCH", body: JSON.stringify({ dietId }) }); } /** Fetches the current user's selected allergen ids. */ public getAllergyIds(): Promise { 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 { 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();