Backend : - `createRecipe` refactorisé en fine enveloppe autour d'un nouvel helper interne `createRecipeInternal`, paramétré par une source d'import optionnelle ; nouvelle fonction exportée `createImportedRecipe` qui réutilise toute la validation ingrédients/unités/diets et le matching des tech steps, sans dupliquer cette logique. - La locale de l'adaptateur source est propagée jusqu'au chargement des `TechStepMapping`, pour que le texte anglais (TheMealDB, etc.) soit matché contre le bon jeu de règles au lieu du défaut français. - Nouvel endpoint `POST /sources/:sourceKey/import/:externalId` — valide le payload via `createRecipeSchema` (même schéma qu'une création manuelle) et persiste une vraie `Recipe` liée à la source (`sourceId`/`externalId`). - Nouveau code d'erreur `RECIPE_ALREADY_IMPORTED` (4022) quand l'item a déjà été importé pour ce foyer. Frontend : - `ImportRecipePage` (nouvelle page, `/recettes/importer/:sourceKey/:externalId`) — pré-remplit le formulaire depuis `previewSourceItem`, en miroir de `RecipeFormPage` (mêmes sous-composants : `IngredientRow`, `IngredientPicker`, `StepListEditor`, `DietTagSelect`). Ajoute une section dédiée aux lignes d'ingrédients non résolues automatiquement : l'utilisateur choisit un ingrédient réel via l'`IngredientPicker` existant ou retire la ligne — aucune recette invalide n'est jamais soumise, le bouton d'import reste désactivé tant qu'il en reste. - `SourceItemPreviewPanel` gagne un lien « Importer cette recette » vers cet écran. Tests : - Mocha (`apps/api/test/sources.test.ts`) : 6 nouveaux tests sur `POST /sources/:sourceKey/import/:externalId` (payload valide, ingrédient/unité inconnus, déjà importé, deux foyers distincts, locale de la source respectée pour les tech steps). 282 tests passent au total, aucune régression. - Cypress : nouveau scénario Gherkin bout-en-bout dans `recipe-sources.feature` (parcourir → prévisualiser → importer → résoudre un ingrédient non reconnu → confirmer → atterrir sur la recette sauvegardée). Steps d'édition d'ingrédients/étapes génériques déplacés de `recipe-form.ts` vers `cypress/support/step_definitions/common.steps.ts`, réutilisables par ce nouveau scénario. Suite : étape 4 (ajouter au planning déclenche l'import si nécessaire). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
85 lines
4.5 KiB
TypeScript
85 lines
4.5 KiB
TypeScript
/**
|
||
* Enumeration of every business/domain error code the API can return.
|
||
*
|
||
* This is the single source of truth for error identification across the
|
||
* whole monorepo: `apps/api` throws errors carrying one of these codes,
|
||
* and `apps/web` maps each code to a localized, user-facing label (see
|
||
* `apps/web/src/services/error-message.service.ts`, backed by i18next
|
||
* locale files under `apps/web/src/locales/`). Neither side should ever
|
||
* hardcode a raw error value that the other side has to guess at — always
|
||
* reference `ErrorCode.XXX`, never a bare number/string.
|
||
*
|
||
* Numeric values (not string codes): grouped by category so the number
|
||
* itself hints at the kind of failure, similar in spirit to HTTP status
|
||
* code families —
|
||
* - `4000`–`4099`: request validation
|
||
* - `4010`–`4019`: authentication
|
||
* - `4020`–`4029`: conflicting/invalid state transition
|
||
* - `4030`–`4039`: authorization (caller authenticated, but not allowed to)
|
||
* - `4040`–`4049`: not found
|
||
* - `5000`–`5099`: internal/unexpected
|
||
*
|
||
* When adding a new failure case in the API:
|
||
* 1. Add a new member here, in the right range, with the next free number.
|
||
* 2. Throw it via `HttpError` (`@batch-cooking/express-tools`).
|
||
* 3. Add its translation key to every locale file under
|
||
* `apps/web/src/locales` (one `translation.json` per language).
|
||
*/
|
||
export enum ErrorCode {
|
||
/** Request body/query failed zod schema validation. */
|
||
VALIDATION_ERROR = 4000,
|
||
/** Signup attempted with an email that already has a profile. */
|
||
EMAIL_ALREADY_IN_USE = 4001,
|
||
/** Login failed — wrong email or wrong password (never say which). */
|
||
INVALID_CREDENTIALS = 4010,
|
||
/** Request required a session cookie/JWT that is missing, invalid, or stale. */
|
||
NOT_AUTHENTICATED = 4011,
|
||
/** `POST /house` or `POST /house/join` attempted while the profile already belongs to a household. */
|
||
ALREADY_HAS_HOUSE = 4020,
|
||
/** `DELETE /recipes/:id` attempted on a recipe still referenced by at least one `PlanningItem`. */
|
||
RECIPE_IN_USE = 4021,
|
||
/** `POST /sources/:sourceKey/import/:externalId` attempted on an item already imported (a `Recipe` already exists for that `sourceId`/`externalId` pair). */
|
||
RECIPE_ALREADY_IMPORTED = 4022,
|
||
/** A household action reserved to its admin (delete the household, remove a member) attempted by a non-admin member. */
|
||
NOT_HOUSE_ADMIN = 4030,
|
||
/** `PATCH /recipes/:id` or `DELETE /recipes/:id` attempted by someone other than the recipe's author — visibility controls reading, not writing. */
|
||
NOT_RECIPE_AUTHOR = 4031,
|
||
/** No route/resource matches the request. */
|
||
NOT_FOUND = 4040,
|
||
/** The profile making the request has no household yet (`houseId` is `null`). */
|
||
HOUSE_NOT_FOUND = 4041,
|
||
/** A `dietId` was given that doesn't match any reference `Diet` row. */
|
||
DIET_NOT_FOUND = 4042,
|
||
/** One or more `allergyIds` don't match any reference `Allergy` row. */
|
||
ALLERGY_NOT_FOUND = 4043,
|
||
/** `POST /house/join`'s `inviteCode` doesn't match any household. */
|
||
INVITE_CODE_NOT_FOUND = 4044,
|
||
/** `GET /recipes/:id`, `PATCH /recipes/:id` or `DELETE /recipes/:id` given an id that doesn't match any recipe. */
|
||
RECIPE_NOT_FOUND = 4045,
|
||
/** A recipe payload's `ingredientId` doesn't match any reference `Ingredient` row. */
|
||
INGREDIENT_NOT_FOUND = 4046,
|
||
/** `DELETE /planning/items/:id` given an id that doesn't match any planning item visible to the caller's household. */
|
||
PLANNING_ITEM_NOT_FOUND = 4047,
|
||
/** A recipe payload's `unitId` doesn't match any reference `Unit` row. */
|
||
UNIT_NOT_FOUND = 4048,
|
||
/** `PATCH /house/current/sources`'s `sourceIds` contains one that doesn't match any reference `Source` row. */
|
||
SOURCE_NOT_FOUND = 4049,
|
||
/** Unexpected/unhandled failure — the catch-all, always logged server-side. */
|
||
INTERNAL_ERROR = 5000,
|
||
}
|
||
|
||
/**
|
||
* Shape of every JSON error body the API returns, whatever the failure.
|
||
* Kept intentionally small and stable: `code` is what clients should
|
||
* branch on, `message` is a human-readable (English, developer-facing)
|
||
* description useful for logs/debugging — never shown to end users as-is,
|
||
* since end-user-facing text is localized client-side from `code`.
|
||
*/
|
||
export interface ApiErrorResponse {
|
||
/** Machine-readable error identifier — see {@link ErrorCode}. */
|
||
code: ErrorCode;
|
||
/** Developer-facing description (English). Not localized, not for UI display. */
|
||
message: string;
|
||
/** Present only for VALIDATION_ERROR: per-field error messages from zod. */
|
||
details?: Record<string, string[] | undefined>;
|
||
}
|