Ajoute le chaînon manquant entre le catalogue de recettes et le planning hebdomadaire : - Backend : `PlanningItem.portions` (nouvelle colonne + migration), `POST /planning/items` / `DELETE /planning/items/:id` (créent la semaine de planning à la volée si besoin), `GET /recipes` gagne les filtres `ingredientIds`/`dietIds` (ET) en plus de `suitableForHousehold` (déjà préparé). - Frontend : nouveau `Dialog` générique (premier modal de l'app), `RecipePickerDialog` qui réutilise le même affichage que le catalogue (`RecipeTabs`/`RecipeTable`) avec recherche par nom, filtre ingrédients, filtre régime alimentaire, toggle "convient à tout le foyer", puis une étape de saisie du nombre de portions. - `PlanningPage` : le bouton "+" de chaque case ouvre le dialog, le bouton "✕" retire la recette (optimiste, avec rollback si l'appel échoue), les portions s'affichent sur chaque chip. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
296 lines
12 KiB
TypeScript
296 lines
12 KiB
TypeScript
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 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<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");
|
|
}
|
|
|
|
/** Permanently deletes the current profile, after re-verifying its password — rejects with `INVALID_CREDENTIALS` if it's wrong. */
|
|
public deleteAccount(password: string): Promise<void> {
|
|
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<PlanningView | null> {
|
|
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<PlanningItemView> {
|
|
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<void> {
|
|
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<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");
|
|
}
|
|
|
|
/** Reference list of ingredients, each resolved to its allergens — static, non-administrable (recipe form's ingredient picker). Public — no session required. */
|
|
public getIngredients(): Promise<IngredientView[]> {
|
|
return this.request("/reference/ingredients");
|
|
}
|
|
|
|
/**
|
|
* 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<RecipeSummaryView[]> {
|
|
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<RecipeView> {
|
|
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<RecipeView> {
|
|
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<RecipeView> {
|
|
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<void> {
|
|
return this.request(`/recipes/${id}`, { method: "DELETE" });
|
|
}
|
|
|
|
/** Favorites a recipe for the current user — idempotent. */
|
|
public addFavoriteRecipe(id: number): Promise<void> {
|
|
return this.request(`/recipes/${id}/favorite`, { method: "POST" });
|
|
}
|
|
|
|
/** Unfavorites a recipe for the current user — idempotent. */
|
|
public removeFavoriteRecipe(id: number): Promise<void> {
|
|
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<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 }) });
|
|
}
|
|
|
|
/** 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<HouseView> {
|
|
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<HouseView> {
|
|
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<void> {
|
|
return this.request("/house/leave", { method: "POST" });
|
|
}
|
|
|
|
/** Deletes the current user's household outright — every member loses it. Admin-only. */
|
|
public deleteHouse(): Promise<void> {
|
|
return this.request("/house/current", { method: "DELETE" });
|
|
}
|
|
|
|
/** Removes one specific member from the current user's household. Admin-only. */
|
|
public removeHouseMember(memberId: number): Promise<HouseView> {
|
|
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<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 }),
|
|
});
|
|
}
|
|
|
|
/** Fetches the current user's personally disliked ingredient ids — a taste preference, distinct from `getAllergyIds` (medical). */
|
|
public getDislikedIngredientIds(): Promise<number[]> {
|
|
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<number[]> {
|
|
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<PreferencesView> {
|
|
return this.request("/preferences");
|
|
}
|
|
|
|
/** Sets the current user's theme preference. */
|
|
public updatePreferences(theme: ThemePreference): Promise<PreferencesView> {
|
|
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();
|