import { type AddPlanningItemInput, type AllergyView, type ApiErrorResponse, type CreateRecipeInput, type DietView, ErrorCode, type HouseView, type IngredientView, type LoginInput, type PlanningItemView, type PlanningView, type PreferencesView, type RecipeSummaryView, type RecipeTab, type RecipeView, type SafeUserProfile, type SignupInput, type ThemePreference, type UnitView, type UpdateRecipeInput, } from "@batch-cooking/shared"; /** * Base URL of the API, configurable via `VITE_API_URL` (see `.env.example`). * Defaults to `""` (same origin as the page) — correct for the production * Docker image, where the API serves this very frontend build (see * apps/api/Dockerfile), so a relative path already reaches it. Native dev * (`pnpm dev:web`) overrides this via `VITE_API_URL=http://localhost:3000` * in `apps/web/.env`, since the Vite dev server (5173) and the API (3000) * are on different origins there. */ const API_BASE_URL: string = import.meta.env.VITE_API_URL ?? ""; /** * 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"); } /** Permanently deletes the current profile, after re-verifying its password — rejects with `INVALID_CREDENTIALS` if it's wrong. */ public deleteAccount(password: string): Promise { return this.request("/auth/me", { method: "DELETE", body: JSON.stringify({ password }) }); } /** * Fetches the current user's household's planning covering `date` * (`YYYY-MM-DD`, e.g. from `date-tools`'s `formatDateOnly`), or `null` if * there isn't one for that week yet. */ public getPlanningForWeek(date: string): Promise { return this.request(`/planning?date=${date}`); } /** * Adds a recipe to one (day, meal) slot of the household's planning for * the week containing `input.date`, creating that week's planning on the * fly if it doesn't exist yet — rejects with `HOUSE_NOT_FOUND` (no * household) or `RECIPE_NOT_FOUND` (the recipe isn't visible to the * caller). */ public addPlanningItem(input: AddPlanningItemInput): Promise { return this.request("/planning/items", { method: "POST", body: JSON.stringify(input) }); } /** Removes one recipe from a planning slot — rejects with `PLANNING_ITEM_NOT_FOUND`. */ public removePlanningItem(id: number): Promise { return this.request(`/planning/items/${id}`, { method: "DELETE" }); } /** 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"); } /** Reference list of ingredients, each resolved to its allergens — static, non-administrable (recipe form's ingredient picker). Public — no session required. */ public getIngredients(): Promise { return this.request("/reference/ingredients"); } /** Reference list of recipe ingredient units (g, kg, cuillère à soupe…) — static, non-administrable (recipe form's per-ingredient unit select). Public — no session required. */ public getUnits(): Promise { return this.request("/reference/units"); } /** * One catalog tab (favoris/perso/foyer/publique — see `RecipeTab`), * optionally narrowed further — `search` (name substring), * `suitableForHousehold` (the planning recipe picker's "convient à tout * le foyer" toggle), `ingredientIds`/`dietIds` (that same picker's * ingredient/regime filters — a recipe must carry *every* id listed). */ public listRecipes( tab: RecipeTab, filters: { search?: string; suitableForHousehold?: boolean; ingredientIds?: number[]; dietIds?: number[]; } = {}, ): Promise { const params = new URLSearchParams({ tab }); if (filters.search) params.set("search", filters.search); if (filters.suitableForHousehold) params.set("suitableForHousehold", "true"); for (const id of filters.ingredientIds ?? []) params.append("ingredientIds", String(id)); for (const id of filters.dietIds ?? []) params.append("dietIds", String(id)); return this.request(`/recipes?${params.toString()}`); } /** Fetches one recipe's full detail — rejects with `RECIPE_NOT_FOUND` if `id` doesn't match any recipe. */ public getRecipe(id: number): Promise { return this.request(`/recipes/${id}`); } /** Adds a recipe to the catalog — rejects with `INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient. */ public createRecipe(input: CreateRecipeInput): Promise { return this.request("/recipes", { method: "POST", body: JSON.stringify(input) }); } /** Replaces a recipe's full content (not a partial merge) — same rejections as {@link createRecipe}, plus `RECIPE_NOT_FOUND`. */ public updateRecipe(id: number, input: UpdateRecipeInput): Promise { return this.request(`/recipes/${id}`, { method: "PATCH", body: JSON.stringify(input) }); } /** Removes a recipe from the catalog outright — rejects with `RECIPE_IN_USE` if it's still referenced by a planning item. */ public deleteRecipe(id: number): Promise { return this.request(`/recipes/${id}`, { method: "DELETE" }); } /** Favorites a recipe for the current user — idempotent. */ public addFavoriteRecipe(id: number): Promise { return this.request(`/recipes/${id}/favorite`, { method: "POST" }); } /** Unfavorites a recipe for the current user — idempotent. */ public removeFavoriteRecipe(id: number): Promise { return this.request(`/recipes/${id}/favorite`, { method: "DELETE" }); } /** Fetches the current user's household (with its member list), or `null` if they don't have one yet. */ 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 }) }); } /** Creates a new household, with the caller as its admin — rejects with `ALREADY_HAS_HOUSE` if they already belong to one. */ public createHouse(name: string): Promise { return this.request("/house", { method: "POST", body: JSON.stringify({ name }) }); } /** Joins an existing household by invite code — rejects with `ALREADY_HAS_HOUSE`/`INVITE_CODE_NOT_FOUND`. */ public joinHouse(inviteCode: string): Promise { return this.request("/house/join", { method: "POST", body: JSON.stringify({ inviteCode }) }); } /** Removes the current user from their household — hands off adminship or deletes the household if they were its last member (see the API's `house.service.ts`). */ public leaveHouse(): Promise { return this.request("/house/leave", { method: "POST" }); } /** Deletes the current user's household outright — every member loses it. Admin-only. */ public deleteHouse(): Promise { return this.request("/house/current", { method: "DELETE" }); } /** Removes one specific member from the current user's household. Admin-only. */ public removeHouseMember(memberId: number): Promise { return this.request(`/house/members/${memberId}`, { method: "DELETE" }); } /** 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 }), }); } /** Fetches the current user's personally disliked ingredient ids — a taste preference, distinct from `getAllergyIds` (medical). */ public getDislikedIngredientIds(): Promise { return this.request("/profile/disliked-ingredients"); } /** Replaces the current user's full disliked-ingredient selection (not a merge — send the complete list). */ public updateDislikedIngredientIds(dislikedIngredientIds: number[]): Promise { return this.request("/profile/disliked-ingredients", { method: "PATCH", body: JSON.stringify({ dislikedIngredientIds }), }); } /** Fetches the current user's personalization preferences — `theme` defaults to `"SYSTEM"` if never set. */ public getPreferences(): Promise { return this.request("/preferences"); } /** Sets the current user's theme preference. */ public updatePreferences(theme: ThemePreference): Promise { return this.request("/preferences", { method: "PATCH", body: JSON.stringify({ theme }) }); } } /** Single shared instance — this client is stateless, no need for one per caller. */ export const apiClient = new ApiClient();