batchCooking/apps/web/src/api/client.ts
Nicolas 1bc9304cdc
All checks were successful
CI / lint (push) Successful in 4m35s
CI / build (push) Successful in 4m51s
CI / intent-service-test (push) Successful in 5m42s
CI / e2e (push) Successful in 10m29s
CI / test (push) Successful in 17m43s
feat(planning): glisser-déposer un reste sur un autre créneau
Dernière PR (4/4) du chantier « gestion des restes » (issue #34).

`@dnd-kit/core` (+ `@dnd-kit/utilities`) — première et seule utilisation
de DnD de l'app. `PlanningPage` monte un `<DndContext>` (Mouse + Touch +
Keyboard sensors, distance d'activation 5px pour ne pas voler le clic
« Jeter ») autour de la grille et du panier.

- `LeftoversBasket` : chaque ligne a une poignée `⠿` `useDraggable` — le
  nœud draggable est la poignée elle-même (petite), pas toute la `<li>`,
  pour qu'une détection de collision au pointeur (`pointerWithin`, repli
  `closestCenter` pour le clavier) résolve proprement vers la seule case
  sous le curseur. `data` porte `sourceItemId`/`recipeName`/`available`.
- `MealCell` : `useDroppable` (`data: { date, weekDay, meal }`),
  surbrillance `--drop-over` au survol. Un item « restes »
  (`sourceItem !== null`) porte un badge ♻️ sur sa pastille.
- Au drop, `onDragEnd` ouvre `LeftoverAssignDialog` (quantité, défaut =
  disponible, plafonné) → `POST /planning/items/leftover`
  (`apiClient.assignLeftover`) → nouvel item patché dans `planning.items`.

Tests : `planning-leftovers.feature` (Gherkin) — parcours complet drag →
confirmation quantité → pastille ♻️ visible → compteur du panier à jour ;
+ jet d'un reste depuis le panier. Le drag est piloté par événements
souris bruts (mousedown poignée → mousemove ×2 → mouseup cible).
Vérifié localement : 2/2, et aucune régression sur
`leftovers-basket.cy.ts` / `planning-page.cy.ts` / `planning.feature`.

i18n : `planning.leftovers.*` (badge, poignée, dialogue). Docs :
`frontend-architecture.md`.

Closes #34

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 00:47:49 +02:00

437 lines
19 KiB
TypeScript

import {
type AddPlanningItemInput,
type AllergyView,
type ApiErrorResponse,
type AssignLeftoverInput,
type BrowsableSourceItemView,
type CreateRecipeInput,
type DietView,
ErrorCode,
type HouseView,
type IngredientView,
type LoginInput,
type OptimizedCookingPlanView,
type PlanningItemView,
type PlanningView,
type PreferencesView,
type RecipeImportDraftView,
type RecipeSummaryView,
type RecipeTab,
type RecipeView,
type SafeUserProfile,
type ShoppingListView,
type SignupInput,
type SourceView,
type StepTechStepCorrectionView,
type SubmitTechStepCorrectionInput,
type SubmitTechStepCorrectionResult,
type TechStepView,
type ThemePreference,
type UnitView,
type UpdateRecipeInput,
type UtensilView,
} 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> {
try {
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 (await response.json()) as TResponseBody;
} catch (err) {
// Rethrown as-is — every caller already handles/surfaces API failures
// its own way (an `ApiError` catch, a `.catch()` chain — see
// `error-message.service.ts`), this is just the one place the
// fetch/`await` itself has to sit inside a try/catch per the repo's
// convention.
throw err;
}
}
/** Creates a profile and starts a session — no household yet, that's an optional step of the onboarding wizard. */
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" });
}
/** Throws away planning item `id`'s whole remaining leftover pool (`discardedPortions += available`) — idempotent, returns the updated item. Rejects with `PLANNING_ITEM_NOT_FOUND`. */
public discardLeftover(id: number): Promise<PlanningItemView> {
return this._request(`/planning/items/${id}/discard-leftover`, { method: "PATCH" });
}
/** Serves `portions` of a source item's leftover pool on a slot, creating a "leftovers" planning item (same recipe as the source). Rejects with `LEFTOVER_QUANTITY_EXCEEDED` (over the pool) or `PLANNING_ITEM_NOT_FOUND`. */
public assignLeftover(input: AssignLeftoverInput): Promise<PlanningItemView> {
return this._request("/planning/items/leftover", {
method: "POST",
body: JSON.stringify(input),
});
}
/**
* Fetches the current user's household's shopping list for the week
* covering `date` (`YYYY-MM-DD`, e.g. from `date-tools`'s
* `formatDateOnly`) — every ingredient across that week's planned
* recipes, summed. Unlike {@link getPlanningForWeek}, never resolves to
* `null`: no household or nothing planned that week both come back as a
* normal list with an empty `items` array.
*/
public getShoppingListForWeek(date: string): Promise<ShoppingListView> {
return this._request(`/shopping-list?date=${date}`);
}
/**
* Fetches the current user's household's optimized cooking plan for the
* week covering `date` (`YYYY-MM-DD`) — every recipe planned that week
* reorganized into ordered phases (shared prep pooled, passive cooks
* floated into the background). Like {@link getShoppingListForWeek} and
* unlike {@link getPlanningForWeek}, never resolves to `null`: no
* household or nothing planned both come back as a normal plan with
* empty `recipes`/`phases`.
*/
public getCookingPlanForWeek(date: string): Promise<OptimizedCookingPlanView> {
return this._request(`/cooking-session?date=${date}`);
}
/** 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");
}
/** 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<UnitView[]> {
return this._request("/reference/units");
}
/** Reference list of detected cooking techniques — static, non-administrable (`TechStepCorrectionPopover`'s technique picker). Public — no session required. */
public getTechSteps(): Promise<TechStepView[]> {
return this._request("/reference/tech-steps");
}
/** Reference list of cooking utensils — static, non-administrable (`TechStepCorrectionPopover`'s utensil picker, once a technique is selected). Public — no session required. */
public getUtensils(): Promise<UtensilView[]> {
return this._request("/reference/utensils");
}
/** Reference list of implemented recipe sources (onboarding wizard's source step, `/parametres/foyer`) — empty until a concrete source is registered. Public — no session required. */
public getSources(): Promise<SourceView[]> {
return this._request("/reference/sources");
}
/** One page of `sourceKey`'s own catalog (recipe catalog's "Sources" tab), each item flagged with whether it's already been imported. Rejects with `SOURCE_NOT_FOUND` unless the viewer's household has this source enabled (`/parametres/foyer`). */
public browseSource(
sourceKey: string,
params: { query?: string; cursor?: string } = {},
): Promise<{ items: BrowsableSourceItemView[]; nextCursor: string | null }> {
const search = new URLSearchParams();
if (params.query) search.set("query", params.query);
if (params.cursor) search.set("cursor", params.cursor);
const queryString = search.toString();
return this._request(`/sources/${sourceKey}/browse${queryString ? `?${queryString}` : ""}`);
}
/** Fully translates one not-yet-saved source item (ingredients/units/techniques resolved where possible) — nothing is persisted. Rejects with `RECIPE_NOT_FOUND` if the source couldn't fetch/parse it. */
public previewSourceItem(sourceKey: string, externalId: string): Promise<RecipeImportDraftView> {
return this._request(`/sources/${sourceKey}/preview/${encodeURIComponent(externalId)}`);
}
/** Finalizes an import — `input` is a fully-resolved `CreateRecipeInput`, exactly like a manual `createRecipe()` call (the review screen, `ImportRecipePage`, is what makes sure of that before calling this). Rejects with `RECIPE_ALREADY_IMPORTED` if this item was imported since the preview was fetched. */
public importSourceItem(
sourceKey: string,
externalId: string,
input: CreateRecipeInput,
): Promise<RecipeView> {
return this._request(`/sources/${sourceKey}/import/${encodeURIComponent(externalId)}`, {
method: "POST",
body: JSON.stringify(input),
});
}
/**
* 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" });
}
/** Submits a correction to one of `stepId`'s detected techniques — see `SubmitTechStepCorrectionInput`'s doc comment (`packages/shared`) for what `previousTechStepId`/`correctedTechStepId` each mean. Open to any viewer who can see the recipe, not just its author. The response's `techSteps` is the step's fresh, immediately up-to-date technique sequence — see `SubmitTechStepCorrectionResult`'s doc comment. */
public submitTechStepCorrection(
recipeId: number,
stepId: number,
input: SubmitTechStepCorrectionInput,
): Promise<SubmitTechStepCorrectionResult> {
return this._request(`/recipes/${recipeId}/steps/${stepId}/corrections`, {
method: "POST",
body: JSON.stringify(input),
});
}
/** Every correction submitted so far for `stepId`, most recent first. */
public getTechStepCorrections(
recipeId: number,
stepId: number,
): Promise<StepTechStepCorrectionView[]> {
return this._request(`/recipes/${recipeId}/steps/${stepId}/corrections`);
}
/** 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" });
}
/** Fetches the current user's household's enabled recipe-source ids — which sources show up in its recipe tabs. Rejects with `HOUSE_NOT_FOUND` if they have no household yet. */
public getHouseSourceIds(): Promise<number[]> {
return this._request("/house/current/sources");
}
/** Replaces the current user's household's full enabled-source selection (not a merge — send the complete list; empty hides every external source). Rejects with `HOUSE_NOT_FOUND`/`SOURCE_NOT_FOUND`. */
public updateHouseSourceIds(sourceIds: number[]): Promise<number[]> {
return this._request("/house/current/sources", {
method: "PATCH",
body: JSON.stringify({ sourceIds }),
});
}
/** 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();