Etend le flux de correction existant (TechStepCorrectionPopover) pour que l'utilisateur associe lui-meme des ingredients (avec quantite/unite) et des ustensiles a la technique qu'il corrige, avec le meme marquage source: "manual" que la technique elle-meme. Backend : - submitTechStepCorrectionSchema (packages/shared) accepte des tableaux ingredients/utensils optionnels, chacun avec son propre span [start,end) selectionne par l'utilisateur. Omis = ne touche pas aux metadonnees existantes ; tableau (meme vide) = remplace tout ce qui existait sur cette occurrence (auto ET manuel precedent - decision validee avec l'utilisateur). - applyManualCorrection (recipe-tech-step-correction.service.ts) ecrit les nouvelles lignes StepTechStepIngredient/StepTechStepUtensil apres avoir vide celles de l'occurrence via deleteMany - meme chemin de code que ce soit une creation ou une mise a jour de la technique. - Nouveaux asserts d'existence (ingredient/unite/ustensile) + validation de span, nouveau code d'erreur UTENSIL_NOT_FOUND. - source ajoute a StepTechStepIngredientView/StepTechStepUtensilView (le calque manquait ce que la colonne DB portait deja). Frontend : - TechStepCorrectionPopover passe d'un clic = soumission immediate a un flux selection-puis-confirmation, avec deux nouvelles sections Ingredients/Ustensiles pre-remplies avec l'existant. - Ajouter un ingredient/ustensile demande une selection de texte dediee dans la description encore visible (StepDescription geree via un nouvel etat pendingSpanRequest/resolvedMetadataSpan) - pas de raccourci sur le span de la correction elle-meme. - Nouveau CatalogSearchPicker.tsx, plus leger que IngredientPicker pour ce contexte de popover, reutilise pour les deux catalogues. - getUtensils() ajoute a apiClient. Tests : nouveaux cas Mocha (attache/remplace/omission/validations) dans recipe-tech-step-correction.test.ts, TechStepCorrectionPopover.cy.tsx etendu avec le nouveau flux, recipes.ts (e2e) ajuste au clic Valider supplementaire. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
409 lines
18 KiB
TypeScript
409 lines
18 KiB
TypeScript
import {
|
|
type AddPlanningItemInput,
|
|
type AllergyView,
|
|
type ApiErrorResponse,
|
|
type BrowsableSourceItemView,
|
|
type CreateRecipeInput,
|
|
type DietView,
|
|
ErrorCode,
|
|
type HouseView,
|
|
type IngredientView,
|
|
type LoginInput,
|
|
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" });
|
|
}
|
|
|
|
/**
|
|
* 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}`);
|
|
}
|
|
|
|
/** 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();
|