diff --git a/.claude/skills/repas-front-dev/SKILL.md b/.claude/skills/repas-front-dev/SKILL.md index c863530c..8a58e7ac 100644 --- a/.claude/skills/repas-front-dev/SKILL.md +++ b/.claude/skills/repas-front-dev/SKILL.md @@ -91,6 +91,10 @@ Utiliser `` (ADR 0018) : la première lettre Passer par `useCopyRecipeAction` (`src/features/recipes/`) : la copie ne navigue pas, un message confirme, le bouton de la recette passe à « Copiée » (`copiedRecipes`, en mémoire, vidé par `forgetAccountData`). Sur la fiche, le jeu d'actions dépend de la propriété déduite de SES recettes (`mine | other | unknown`) : jamais d'action tant qu'elle est inconnue, « Copier » seul pour un autre, `PlanButton`/Modifier/Supprimer pour soi (ADR 0017, amendement du 2026-10-30). +### 4.6 La version commune d'une recette Marmiton + +Voir `CLAUDE.md` (« Version commune… », MAR-005) : `useRecipeStance` pour savoir à qui est une fiche, `findOwnCopy` pour la copie de l'administrateur, `useDeleteRecipe(true)` quand l'écran dit lui-même ses refus. Mot à l'écran : « version commune ». + ## 5. Definition of Done - [ ] `make check` vert (lint, format, `api-check`, types, tests) ; `make quality` avant la PR ; seuil de couverture non abaissé diff --git a/CLAUDE.md b/CLAUDE.md index 1cf6179e..e5b526ec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,6 +89,7 @@ L'image de production ne contient ni nom de domaine ni secret ; ne jamais en ajo - **Pâques et registre de saisons** ([ADR 0014](docs/adr/0014-theme-saisonnier-paques-et-registre-de-saisons.md)) : « Prairie & œufs » du dimanche de Pâques moins 21 jours au lundi de Pâques inclus (date CALCULÉE par `easterSunday()`, recopiée dans `public/theme-init.js`) ; `season.ts` porte un **registre** `DEFINITIONS` (clé, couleur de barre, règle de dates) dont dérivent `Season`, `SEASONS` (ordre de l'année), `currentSeason()` et le tableau équivalent de `theme-init.js` : une saison nouvelle = une entrée dans chacun, ses fichiers, ses blocs CSS, ses libellés, une ligne de la table de `season.spec.ts`, une du tableau de couleurs de `seasonTokens.spec.ts` (bandeau et fond, clair et sombre) et une de la boucle « pour chaque saison » de `e2e/theme-season.spec.ts`. - **Galette & crêpes et Saint-Valentin** ([ADR 0015](docs/adr/0015-themes-de-la-galette-et-de-la-saint-valentin.md)) : « Or & bleu roi » (`galette`, du 6 janvier au 2 février) et « Rose & cœur » (`valentin`, du 7 au 14 février), deux entrées du registre `DEFINITIONS` (`SEASONS` : `galette, valentin, paques, halloween, noel`) sans aucune autre mécanique ; leur or et leur rose clair ne portent jamais de texte. - **Été et automne** ([ADR 0016](docs/adr/0016-themes-de-l-ete-et-de-l-automne.md)) : « Soleil & mer » (`ete`, du 21 juin au 31 août) et « Forêt & feuilles » (`automne`, du 1er septembre au 14 octobre, la veille d'Halloween), deux entrées du registre `DEFINITIONS` (`SEASONS` : `galette, valentin, paques, ete, automne, halloween, noel`) ; leurs scènes n'ont ni soleil ni nuage (ils vivent dans le héros et le coin) et leur accent ne porte jamais de texte. +- **Version commune d'une recette Marmiton (MAR-005, ADR 0037 du back, amendement du 2026-11-08)** : la recette Marmiton sans propriétaire est modifiée et supprimée par l'ADMINISTRATEUR seul, avec l'éditeur ordinaire ; le mot à l'écran est « version commune » (jamais « neutre », « sans propriétaire » ni « originale »). `recipeStance.ts` / `useRecipeStance` disent à qui est une fiche (`mine | other | common | unknown`) : un utilisateur ordinaire suit `canEdit` du back sans attendre la liste de ses recettes ; pour l'administrateur (`canEdit` vrai partout) la liste de SES recettes tranche, et la version commune est une recette Marmiton (`sourceUrl`) qui n'est la copie d'aucune autre (`originalId` nul), la fiche n'en disant pas plus. Sa copie : le back ne rend que `alreadyCopied` (liste « Recettes des autres », administrateur seul) ; `ownCopy.ts` la retrouve parmi ses recettes du type, de même nom, confirmée par `originalId` (« Ma copie » et « Ta copie » ; introuvable : la version commune s'ouvre). Sur la fiche de la commune : bandeau, Modifier seul bouton principal, Copier ou Ta copie, « Supprimer cette recette » tout en bas, pas d'ajout à la liste, de favori ni d'interrupteur « secrète » ; sur SA copie, l'administrateur a « Ouvrir la version commune » (`originalId`). Un 409 à la suppression (la commune est dans une liste de courses) ouvre sa propre fenêtre (`useDeleteRecipe(true)`, hors message commun des écritures). **L'éditeur ajoute aux options l'ingrédient de chaque ligne existante absent de la liste** (nom et rayon de la ligne) : les ingrédients du propriétaire réservé ne sont dans la liste de personne. Tests : `RecipeCommon.spec.ts`, `recipeStance.spec.ts`, `e2e/recipe-common.spec.ts`. - **Pas de chaîne en dur** dans un composant : textes dans `vue-i18n` (français seul). - **Pas de logique métier** dans les composants : elle reste dans le back. Le front affiche, saisit, appelle l'API et relit l'état. - **Types de l'API générés, jamais écrits à la main** : un changement de contrat doit casser la compilation, pas la production. diff --git a/api/contract.json b/api/contract.json index fc558a6c..dc2b3806 100644 --- a/api/contract.json +++ b/api/contract.json @@ -1,5 +1,5 @@ { "repository": "kcharignon/repas", - "ref": "e2e0f8cc3c7f4016afe6d34893bf6fce7c8419ee", + "ref": "a39f9cf114e138f638f65751b552dfb3411bbcd3", "path": "docs/api/openapi.json" } diff --git a/api/openapi.json b/api/openapi.json index 3eaeb41f..c7113f63 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -1594,7 +1594,7 @@ "Recipes" ], "summary": "Remplace une recette de l'utilisateur : nom, personnes, type et lignes (les lignes absentes du corps sont supprim\u00e9es). Un ancien slug est remplac\u00e9 par celui de l'ingr\u00e9dient actuel de l'auteur, comme \u00e0 la cr\u00e9ation", - "description": "Le champ facultatif `secret` rend la recette secr\u00e8te (seul son auteur la voit) ou de nouveau visible : absent ou `null`, il reste inchang\u00e9. Seul l'auteur le change. Les copies d\u00e9j\u00e0 faites par d'autres restent \u00e0 eux.", + "description": "Le champ facultatif `secret` rend la recette secr\u00e8te (seul son auteur la voit) ou de nouveau visible : absent ou `null`, il reste inchang\u00e9. Seul l'auteur le change. Les copies d\u00e9j\u00e0 faites par d'autres restent \u00e0 eux. Une recette SANS PROPRI\u00c9TAIRE (la recette Marmiton neutre : `GET /recipes/other-people`, `fromMarmiton`) n'est modifi\u00e9e que par un administrateur, directement, pour corriger un import rat\u00e9 avant que d'autres la copient : les slugs d'ingr\u00e9dients re\u00e7us sont ceux de l'administrateur (ses alias), traduits en ingr\u00e9dients du propri\u00e9taire r\u00e9serv\u00e9 (un ingr\u00e9dient r\u00e9serv\u00e9 d\u00e9j\u00e0 sur une ligne reste tel quel ; un r\u00e9serv\u00e9 de m\u00eame origine ou de m\u00eame nom est r\u00e9utilis\u00e9, sinon celui de l'administrateur est copi\u00e9 avec ses conversions propres) ; son tag Marmiton et son lien d'origine ne changent pas, sa version augmente, les copies d\u00e9j\u00e0 faites ne changent pas, et elle ne devient jamais secr\u00e8te.", "operationId": "put_api_update_recipe", "parameters": [ { @@ -1624,7 +1624,7 @@ "description": "Recette modifi\u00e9e" }, "403": { - "description": "La recette appartient \u00e0 un autre utilisateur (la recette secr\u00e8te d'un autre, que son auteur seul voit, est inconnue pour tous, administrateur compris : `404`). `RECIPE_SECRET_FORBIDDEN` : un administrateur qui modifie la recette ordinaire d'un autre envoie un `secret` diff\u00e9rent de l'actuel, rien n'est \u00e9crit" + "description": "La recette appartient \u00e0 un autre utilisateur, ou n'a pas de propri\u00e9taire (recette Marmiton neutre) et l'utilisateur n'est pas administrateur (la recette secr\u00e8te d'un autre, que son auteur seul voit, est inconnue pour tous, administrateur compris : `404`). `RECIPE_SECRET_FORBIDDEN` : un administrateur qui modifie la recette d'un autre (ou sans propri\u00e9taire) envoie un `secret` diff\u00e9rent de l'actuel, rien n'est \u00e9crit" }, "404": { "description": "Recette, type, ingr\u00e9dient ou unit\u00e9 inconnu (un ingr\u00e9dient personnalis\u00e9 d'un autre utilisateur l'est aussi)" @@ -1642,6 +1642,7 @@ "Recipes" ], "summary": "Supprime une recette de l'utilisateur, avec ses lignes", + "description": "Une recette SANS PROPRI\u00c9TAIRE (la recette Marmiton neutre) n'est supprim\u00e9e que par un administrateur, pour repartir d'un import rat\u00e9 : son tag Marmiton est lib\u00e9r\u00e9 (un nouvel import de la m\u00eame page recr\u00e9e une neutre neuve, la page est relue), les copies d\u00e9j\u00e0 faites restent \u00e0 leurs auteurs, telles quelles, avec leur lien d'origine. Elle ne se supprime pas tant qu'elle est dans une liste de courses (`409`).", "operationId": "delete_api_remove_recipe", "parameters": [ { @@ -1661,7 +1662,7 @@ "description": "Recette supprim\u00e9e" }, "403": { - "description": "La recette appartient \u00e0 un autre utilisateur (la recette secr\u00e8te d'un autre est inconnue pour tous, administrateur compris : `404`)" + "description": "La recette appartient \u00e0 un autre utilisateur, ou n'a pas de propri\u00e9taire (recette Marmiton neutre) et l'utilisateur n'est pas administrateur (la recette secr\u00e8te d'un autre est inconnue pour tous, administrateur compris : `404`)" }, "404": { "description": "Recette inconnue" @@ -2361,7 +2362,7 @@ "Recipes" ], "summary": "Recettes (non secr\u00e8tes) des autres utilisateurs que l'utilisateur authentifi\u00e9 n'a pas encore copi\u00e9es : tri\u00e9es par cat\u00e9gorie, ou cherch\u00e9es par nom du plus pertinent au moins pertinent", - "description": "Une recette secr\u00e8te (`secret`, ADR 0033) n'y est jamais : ni dans `member`, ni dans `totalItems`, ni dans la recherche. Sans `q`, les recettes sont tri\u00e9es par cat\u00e9gorie (l'ordre des types de recette), puis par nom, puis par identifiant. Avec `q`, seules les recettes dont le nom correspond sont rendues, du plus pertinent au moins pertinent (Jaro-Winkler, ADR 0019), \u00e0 pertinence \u00e9gale par nom puis par identifiant, toutes cat\u00e9gories confondues ; chaque recette porte sa cat\u00e9gorie (`typeSlug`, `typeName`). 200 recettes au plus par r\u00e9ponse (`totalItems` donne le vrai nombre). Sans autre param\u00e8tre, la premi\u00e8re page de 200 : `limit` et `offset` facultatifs lisent la suite.", + "description": "Une recette secr\u00e8te (`secret`, ADR 0033) n'y est jamais : ni dans `member`, ni dans `totalItems`, ni dans la recherche. Sans `q`, les recettes sont tri\u00e9es par cat\u00e9gorie (l'ordre des types de recette), puis par nom, puis par identifiant. Avec `q`, seules les recettes dont le nom correspond sont rendues, du plus pertinent au moins pertinent (Jaro-Winkler, ADR 0019), \u00e0 pertinence \u00e9gale par nom puis par identifiant, toutes cat\u00e9gories confondues ; chaque recette porte sa cat\u00e9gorie (`typeSlug`, `typeName`). 200 recettes au plus par r\u00e9ponse (`totalItems` donne le vrai nombre). Sans autre param\u00e8tre, la premi\u00e8re page de 200 : `limit` et `offset` facultatifs lisent la suite. Un administrateur y voit TOUJOURS les recettes sans propri\u00e9taire (`fromMarmiton`), m\u00eame celles qu'il a d\u00e9j\u00e0 copi\u00e9es (`alreadyCopied`), pour les corriger ou les supprimer ; la page, le total et la recherche des autres utilisateurs ne changent pas.", "operationId": "get_api_list_other_people_recipes", "parameters": [ { @@ -2669,6 +2670,82 @@ } } }, + "/api/v1/ingredients/{slug}/parent": { + "put": { + "tags": [ + "Ingredients" + ], + "summary": "D\u00e9coupe un ingr\u00e9dient : il devient une PARTIE d'un autre (\u00ab Jaune d'\u0153uf \u00bb est une partie d'\u00ab \u0152uf \u00bb), ou change de parent ou de ratio", + "description": "Seul le propri\u00e9taire de l'ingr\u00e9dient le d\u00e9coupe, un administrateur compris (`403` pour l'ingr\u00e9dient d'un autre) ; le parent est \u00e0 l'utilisateur lui aussi, sinon il est inconnu (`404`). Une partie reste un ingr\u00e9dient ordinaire (recettes, conversions) mais n'est jamais une ligne de la liste de courses : elle se plie dans son parent. Un seul parent, profondeur 1 : un parent n'est pas une partie et une partie n'a pas de partie ; dix parties au plus par parent. Idempotent : refaire le m\u00eame appel, ou ne changer que le ratio, n'est jamais refus\u00e9. Rien n'est \u00e9crit sur un refus. Les listes en cours ne sont pas recalcul\u00e9es.", + "operationId": "put_api_set_ingredient_parent", + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetIngredientParentPayload" + } + } + } + }, + "responses": { + "204": { + "description": "Ingr\u00e9dient d\u00e9coup\u00e9 (ou d\u00e9j\u00e0 d\u00e9coup\u00e9 ainsi)" + }, + "403": { + "description": "L'ingr\u00e9dient appartient \u00e0 un autre utilisateur (un administrateur n'a pas de passe-droit)" + }, + "404": { + "description": "Ingr\u00e9dient inconnu, ou parent inconnu ou \u00e0 un autre utilisateur" + }, + "409": { + "description": "L'ingr\u00e9dient a une ligne dans une liste de courses non termin\u00e9e (`INGREDIENT_PART_IN_LIST`) : une partie n'est jamais une ligne de liste ; seulement quand le parent change" + }, + "422": { + "description": "Corps invalide, ou un cas m\u00e9tier : un ingr\u00e9dient n'est pas sa propre partie (`INGREDIENT_PART_OF_ITSELF`) ; le parent est d\u00e9j\u00e0 une partie ou l'ingr\u00e9dient a d\u00e9j\u00e0 des parties (`INGREDIENT_PART_CHAIN`) ; le parent a d\u00e9j\u00e0 dix parties (`INGREDIENT_PART_LIMIT_REACHED`) ; le ratio n'est pas un nombre de 0,000001 \u00e0 1 000 000 (`INGREDIENT_PART_RATIO_INVALID`, pr\u00e9c\u00e9d\u00e9 de `ratio: ` quand c'est le corps qui le refuse)" + } + } + }, + "delete": { + "tags": [ + "Ingredients" + ], + "summary": "Recolle une partie : l'ingr\u00e9dient redevient un ingr\u00e9dient ordinaire (sans parent ni ratio)", + "description": "Seul le propri\u00e9taire de l'ingr\u00e9dient le recolle, un administrateur compris (`403` pour l'ingr\u00e9dient d'un autre). Idempotent : un ingr\u00e9dient qui n'est pas une partie r\u00e9pond `204` sans rien changer. Toujours permis, m\u00eame quand une liste de courses contient l'ingr\u00e9dient.", + "operationId": "delete_api_remove_ingredient_parent", + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "L'ingr\u00e9dient est ordinaire (il l'\u00e9tait d\u00e9j\u00e0, ou il vient de l'\u00eatre)" + }, + "403": { + "description": "L'ingr\u00e9dient appartient \u00e0 un autre utilisateur (un administrateur n'a pas de passe-droit)" + }, + "404": { + "description": "Ingr\u00e9dient inconnu" + } + } + } + }, "/api/v1/shopping-lists/active/ingredients/{slug}/decrease": { "post": { "tags": [ @@ -4786,6 +4863,28 @@ }, "type": "object" }, + "SetIngredientParentPayload": { + "required": [ + "parent", + "ratio" + ], + "properties": { + "parent": { + "description": "Slug du parent : l'ingr\u00e9dient dont l'ingr\u00e9dient de l'URL fait partie. \u00c0 l'utilisateur lui aussi (le parent d'un autre est inconnu), et lui-m\u00eame n'est pas une partie", + "type": "string", + "maxLength": 255, + "example": "oeuf-0a1b2c3d" + }, + "ratio": { + "description": "Combien d'unit\u00e9s par d\u00e9faut de la partie font UNE unit\u00e9 de recette du parent (1 \u0153uf = 1 jaune : 1 ; une gousse de vanille = 2 demi-gousses : 2), de 0,000001 \u00e0 1 000 000", + "type": "number", + "maximum": 1000000, + "minimum": 1.0e-6, + "example": 1 + } + }, + "type": "object" + }, "UpdateServingMealPayload": { "required": [ "serving" @@ -5779,6 +5878,18 @@ "description": "Chemin ou URL de l'image du rayon de l'ingr\u00e9dient", "type": "string", "example": "images/department/cremerie.png" + }, + "partOfSlug": { + "description": "Identifiant du parent quand l'ingr\u00e9dient est une PARTIE d'un autre (ADR 0038 : \u00ab Jaune d'\u0153uf \u00bb pour \u00ab \u0152uf \u00bb), `null` pour un ingr\u00e9dient ordinaire. Cl\u00e9 toujours \u00e9crite", + "type": "string", + "example": "oeuf-0a1b2c3d", + "nullable": true + }, + "partOfName": { + "description": "Nom du parent quand l'ingr\u00e9dient est une PARTIE d'un autre, `null` pour un ingr\u00e9dient ordinaire. Cl\u00e9 toujours \u00e9crite", + "type": "string", + "example": "\u0152uf", + "nullable": true } }, "type": "object" @@ -5914,6 +6025,18 @@ "description": "Nom du rayon de l'ingr\u00e9dient", "type": "string", "example": "Surgel\u00e9" + }, + "partOfSlug": { + "description": "Identifiant du parent quand l'ingr\u00e9dient est une PARTIE d'un autre (ADR 0038 : \u00ab Jaune d'\u0153uf \u00bb pour \u00ab \u0152uf \u00bb), `null` pour un ingr\u00e9dient ordinaire. Cl\u00e9 toujours \u00e9crite", + "type": "string", + "example": "oeuf-0a1b2c3d", + "nullable": true + }, + "partOfName": { + "description": "Nom du parent quand l'ingr\u00e9dient est une PARTIE d'un autre, `null` pour un ingr\u00e9dient ordinaire. Cl\u00e9 toujours \u00e9crite", + "type": "string", + "example": "\u0152uf", + "nullable": true } }, "type": "object" @@ -6287,6 +6410,62 @@ }, "type": "object" }, + "IngredientPartDTO": { + "required": [ + "slug", + "name", + "ratio", + "unitName" + ], + "properties": { + "slug": { + "description": "Identifiant de la partie (slug)", + "type": "string", + "example": "jaune-d-oeuf-0a1b2c3d" + }, + "name": { + "type": "string", + "example": "Jaune d'\u0153uf" + }, + "ratio": { + "description": "Combien d'unit\u00e9s par d\u00e9faut de la partie font UNE unit\u00e9 de recette du parent (1 \u0153uf = 1 jaune : 1)", + "type": "number", + "format": "float", + "example": 1 + }, + "unitName": { + "description": "Nom de l'unit\u00e9 d'achat de la partie (celle dont parle le ratio)", + "type": "string", + "example": "unit\u00e9" + } + }, + "type": "object" + }, + "IngredientPartOfDTO": { + "required": [ + "slug", + "name", + "ratio" + ], + "properties": { + "slug": { + "description": "Identifiant du parent (slug)", + "type": "string", + "example": "oeuf-0a1b2c3d" + }, + "name": { + "type": "string", + "example": "\u0152uf" + }, + "ratio": { + "description": "Combien d'unit\u00e9s par d\u00e9faut de cette partie font UNE unit\u00e9 de recette du parent (1 \u0153uf = 1 jaune : 1)", + "type": "number", + "format": "float", + "example": 1 + } + }, + "type": "object" + }, "IngredientDTO": { "required": [ "slug", @@ -6305,7 +6484,8 @@ "listCount", "isBase", "canSetBase", - "listDestination" + "listDestination", + "parts" ], "properties": { "slug": { @@ -6394,6 +6574,22 @@ }, "listDestination": { "$ref": "#/components/schemas/ListDestinationDTO" + }, + "parts": { + "description": "Les PARTIES de l'ingr\u00e9dient (ADR 0038 : \u00ab Jaune d'\u0153uf \u00bb et \u00ab Blanc d'\u0153uf \u00bb pour \u00ab \u0152uf \u00bb), tri\u00e9es par nom puis identifiant ; `[]` pour un ingr\u00e9dient sans partie. Cl\u00e9 toujours \u00e9crite. Une partie n'est jamais une ligne de la liste de courses : elle se plie dans son parent", + "type": "array", + "items": { + "$ref": "#/components/schemas/IngredientPartDTO" + } + }, + "partOf": { + "description": "Le parent de l'ingr\u00e9dient quand c'est une PARTIE d'un autre ; `null` pour un ingr\u00e9dient ordinaire. Cl\u00e9 toujours \u00e9crite", + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/IngredientPartOfDTO" + } + ] } }, "type": "object" @@ -6612,6 +6808,12 @@ "description": "Nom du rayon de l'ingr\u00e9dient", "type": "string", "example": "Surgel\u00e9" + }, + "partOfName": { + "description": "Nom du parent quand l'ingr\u00e9dient de la ligne est une PARTIE d'un autre (ADR 0038 : \u00ab \u0152uf \u00bb pour \u00ab Jaune d'\u0153uf \u00bb), `null` pour un ingr\u00e9dient ordinaire. Cl\u00e9 toujours \u00e9crite", + "type": "string", + "example": "\u0152uf", + "nullable": true } }, "type": "object" @@ -6626,7 +6828,8 @@ "secret", "favorite", "rows", - "version" + "version", + "canEdit" ], "properties": { "id": { @@ -6679,6 +6882,17 @@ "type": "string", "example": "https://www.marmiton.org/recettes/recette_tarte-au-citron-meringuee_22082.aspx", "nullable": true + }, + "originalId": { + "description": "L'identifiant de la recette dont celle-ci est la copie, si cette recette existe encore et que l'utilisateur la voit ; `null` pour une recette originale, ou dont l'original a \u00e9t\u00e9 supprim\u00e9 (ou est devenu secret). Toujours \u00e9crit : le front en tire un lien vers l'original", + "type": "string", + "example": "3f1c2a7e-8d41-4c62-9b0a-5e7d2f6a1b34", + "nullable": true + }, + "canEdit": { + "description": "Vrai si l'utilisateur modifie (`PUT /recipes/{id}`) cette recette : la sienne, ou n'importe quelle recette qu'il lit s'il est administrateur - dont une recette SANS PROPRI\u00c9TAIRE (la neutre d'une recette Marmiton, que lui seul modifie et supprime). Faux pour la recette d'un autre. Toujours \u00e9crit, calcul\u00e9 sans requ\u00eate de plus", + "type": "boolean", + "example": true } }, "type": "object" @@ -6713,6 +6927,32 @@ }, "type": "object" }, + "ShoppingListRowIncludeDTO": { + "required": [ + "name", + "quantity", + "unitSymbol" + ], + "properties": { + "name": { + "description": "Nom de la partie", + "type": "string", + "example": "Jaune d'\u0153uf" + }, + "quantity": { + "description": "Quantit\u00e9 de la partie dans la liste, dans l'unit\u00e9 d'achat de la partie", + "type": "number", + "format": "float", + "example": 2 + }, + "unitSymbol": { + "description": "Symbole de l'unit\u00e9 d'achat de la partie (peut \u00eatre vide)", + "type": "string", + "example": "" + } + }, + "type": "object" + }, "ShoppingListRowDTO": { "required": [ "id", @@ -6723,7 +6963,8 @@ "ingredientSlug", "unitSlug", "departmentSlug", - "departmentName" + "departmentName", + "includes" ], "properties": { "id": { @@ -6769,6 +7010,13 @@ "description": "Nom du rayon de l'ingr\u00e9dient", "type": "string", "example": "Surgel\u00e9" + }, + "includes": { + "description": "Les parties d'ingr\u00e9dient que cette ligne contient (ADR 0038) : \u00ab Comprend 2 jaunes d'\u0153uf et 2 blancs d'\u0153uf \u00bb pour une ligne \u00ab \u0152uf 5 \u00bb. Tri\u00e9es par nom, `[]` quand la ligne ne contient aucune partie (cl\u00e9 toujours \u00e9crite)", + "type": "array", + "items": { + "$ref": "#/components/schemas/ShoppingListRowIncludeDTO" + } } }, "type": "object" @@ -7196,7 +7444,8 @@ "name", "typeName", "typeSlug", - "fromMarmiton" + "fromMarmiton", + "alreadyCopied" ], "properties": { "id": { @@ -7222,6 +7471,11 @@ "description": "Vrai pour une recette Marmiton neutre (ADR 0037 d\u00e9cision 11) : sans propri\u00e9taire, visible de tous, qu'on ne fait que copier ; le front en tire la pastille \u00ab Marmiton \u00bb. Toujours \u00e9crit", "type": "boolean", "example": false + }, + "alreadyCopied": { + "description": "Vrai si l'utilisateur a d\u00e9j\u00e0 une copie de cette recette. Seul un administrateur re\u00e7oit une recette sans propri\u00e9taire d\u00e9j\u00e0 copi\u00e9e (il la modifie ou la supprime) : pour lui, le front ne propose pas \u00ab Copier \u00bb en double. Toujours \u00e9crit ; toujours `false` pour un autre utilisateur, dont la liste ne contient que ce qu'il n'a pas copi\u00e9", + "type": "boolean", + "example": false } }, "type": "object" diff --git a/e2e/recipe-common.spec.ts b/e2e/recipe-common.spec.ts new file mode 100644 index 00000000..ee353e1c --- /dev/null +++ b/e2e/recipe-common.spec.ts @@ -0,0 +1,122 @@ +// La version commune d'une recette Marmiton, corrigée ou supprimée par l'administrateur (MAR-005, ADR 0037 du back) : le seul parcours de bout +// en bout, sans back ni compte (l'API est simulée par `page.route`). « Recettes des autres » (phrase d'aide, « Ma copie »), la fiche, l'éditeur +// (le nom des lignes dont l'ingrédient n'est dans la liste de personne) et le refus d'une suppression (409). Au téléphone (Pixel 7), chaque écran +// dans les DEUX thèmes de la même visite (axe WCAG AA, contraste compris). Le détail est dans `RecipeCommon.spec.ts` (jsdom). +import { expect, test } from './support/test' +import { axeBothThemes } from './support/screen' + +const LINK = 'https://www.marmiton.org/recettes/recette_the-tarte-au-citron-meringuee_22082.aspx' +const row = (slug: string, name: string, quantity: number) => ({ + ingredientSlug: slug, + ingredientName: name, + ingredientImage: '', + quantity, + unitSlug: 'gramme', + unitSymbol: 'g', + unitName: 'Gramme', + purchaseUnitSlug: 'gramme', + purchaseUnitName: 'Gramme', + convertible: true, + departmentSlug: 'divers', + departmentName: 'Divers', +}) +const common = { + id: 'c1', + name: 'Tarte au citron meringuée', + serving: 6, + typeSlug: 'dessert', + typeName: 'Dessert', + version: 3, + secret: false, + favorite: false, + sourceUrl: LINK, + originalId: null, + canEdit: true, + rows: [row('farine-reservee', 'Farine', 250), row('sucre-reserve', 'Sucre semoule', 70)], +} + +test('la version commune : « Ma copie », la fiche, l’éditeur et une suppression refusée', async ({ + page, +}) => { + await page.addInitScript(() => localStorage.setItem('repas.session', '1')) + await page.route( + (url) => url.pathname.startsWith('/api/'), + async (route) => { + const request = route.request() + const key = `${request.method()} ${new URL(request.url()).pathname}` + const reply = (body: unknown, status = 200) => + route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) }) + + if (key === 'POST /api/auth/refresh') return reply({ token: 'jeton' }) + if (key === 'GET /api/v1/me') + return reply({ + id: 'a1', + email: 'admin@example.com', + roles: ['ROLE_USER', 'ROLE_ADMIN'], + isAdmin: true, + defaultServing: 4, + }) + if (key === 'GET /api/v1/recipe-types') + return reply({ member: [{ slug: 'dessert', name: 'Dessert', image: '', order: 1 }] }) + if (key === 'GET /api/v1/recipes/other-people') + return reply({ + member: [ + { + id: 'c1', + name: common.name, + typeName: 'Dessert', + typeSlug: 'dessert', + fromMarmiton: true, + alreadyCopied: true, + }, + ], + totalItems: 1, + }) + if (key === 'GET /api/v1/recipes/c1') return reply(common) + if (key === 'GET /api/v1/recipes/m1') + return reply({ ...common, id: 'm1', originalId: 'c1', version: 1 }) + if (key === 'GET /api/v1/recipe-types/dessert/recipes') + return reply({ member: [{ id: 'm1', name: common.name, secret: false }], totalItems: 1 }) + if (key === 'GET /api/v1/ingredients') return reply({ member: [], totalItems: 0 }) + if (key.startsWith('GET /api/v1/ingredients/') && key.endsWith('/units')) + return reply({ member: [{ slug: 'gramme', name: 'Gramme' }], totalItems: 1 }) + if (key === 'DELETE /api/v1/recipes/c1') + return reply({ error: { message: 'RECIPE_USED_IN_SHOPPING_LIST' } }, 409) + + return reply({ error: { message: `Appel non prévu : ${key}` } }, 500) + }, + ) + + await page.goto('/recettes/autres') + await expect(page.getByText('Les recettes Marmiton sont communes.')).toBeVisible() + await expect(page.getByText('Tu as déjà ta copie')).toBeVisible() + const mine = page.getByRole('button', { name: /Ouvrir ma copie/ }) + expect((await mine.boundingBox())?.height ?? 0).toBeGreaterThanOrEqual(44) + await axeBothThemes(page, 'commune-liste') + + await page.getByRole('link', { name: /Tarte au citron meringuée/ }).click() + await expect(page.getByRole('heading', { level: 2, name: 'Version commune' })).toBeVisible() + await expect(page.getByRole('link', { name: 'Modifier' })).toBeVisible() + await expect(page.getByRole('link', { name: 'Ta copie' })).toBeVisible() + const remove = page.getByRole('button', { name: 'Supprimer cette recette' }) + await expect(remove).toBeVisible() + expect((await remove.boundingBox())?.height ?? 0).toBeGreaterThanOrEqual(44) + await axeBothThemes(page, 'commune-fiche') + + await remove.click() + await page.getByRole('dialog').getByRole('button', { name: 'Supprimer', exact: true }).click() + await expect(page.getByRole('heading', { name: 'Suppression impossible' })).toBeVisible() + await axeBothThemes(page, 'commune-impossible') + await page.getByRole('dialog').getByRole('button', { name: 'Fermer' }).last().click() + + await page.getByRole('link', { name: 'Modifier' }).click() + await expect(page.getByText('Tu modifies la version commune.')).toBeVisible() + await expect(page.getByRole('combobox', { name: 'Ingrédient 1', exact: true })).toHaveValue( + 'Farine', + ) + await expect(page.getByRole('combobox', { name: 'Ingrédient 2', exact: true })).toHaveValue( + 'Sucre semoule', + ) + await expect(page.getByText('Recette secrète')).toHaveCount(0) + await axeBothThemes(page, 'commune-editeur') +}) diff --git a/src/api/schema.d.ts b/src/api/schema.d.ts index 9f190f2b..cc29a3d3 100644 --- a/src/api/schema.d.ts +++ b/src/api/schema.d.ts @@ -538,11 +538,14 @@ export interface paths { get: operations["get_api_get_recipe"]; /** * Remplace une recette de l'utilisateur : nom, personnes, type et lignes (les lignes absentes du corps sont supprimées). Un ancien slug est remplacé par celui de l'ingrédient actuel de l'auteur, comme à la création - * @description Le champ facultatif `secret` rend la recette secrète (seul son auteur la voit) ou de nouveau visible : absent ou `null`, il reste inchangé. Seul l'auteur le change. Les copies déjà faites par d'autres restent à eux. + * @description Le champ facultatif `secret` rend la recette secrète (seul son auteur la voit) ou de nouveau visible : absent ou `null`, il reste inchangé. Seul l'auteur le change. Les copies déjà faites par d'autres restent à eux. Une recette SANS PROPRIÉTAIRE (la recette Marmiton neutre : `GET /recipes/other-people`, `fromMarmiton`) n'est modifiée que par un administrateur, directement, pour corriger un import raté avant que d'autres la copient : les slugs d'ingrédients reçus sont ceux de l'administrateur (ses alias), traduits en ingrédients du propriétaire réservé (un ingrédient réservé déjà sur une ligne reste tel quel ; un réservé de même origine ou de même nom est réutilisé, sinon celui de l'administrateur est copié avec ses conversions propres) ; son tag Marmiton et son lien d'origine ne changent pas, sa version augmente, les copies déjà faites ne changent pas, et elle ne devient jamais secrète. */ put: operations["put_api_update_recipe"]; post?: never; - /** Supprime une recette de l'utilisateur, avec ses lignes */ + /** + * Supprime une recette de l'utilisateur, avec ses lignes + * @description Une recette SANS PROPRIÉTAIRE (la recette Marmiton neutre) n'est supprimée que par un administrateur, pour repartir d'un import raté : son tag Marmiton est libéré (un nouvel import de la même page recrée une neutre neuve, la page est relue), les copies déjà faites restent à leurs auteurs, telles quelles, avec leur lien d'origine. Elle ne se supprime pas tant qu'elle est dans une liste de courses (`409`). + */ delete: operations["delete_api_remove_recipe"]; options?: never; head?: never; @@ -739,7 +742,7 @@ export interface paths { }; /** * Recettes (non secrètes) des autres utilisateurs que l'utilisateur authentifié n'a pas encore copiées : triées par catégorie, ou cherchées par nom du plus pertinent au moins pertinent - * @description Une recette secrète (`secret`, ADR 0033) n'y est jamais : ni dans `member`, ni dans `totalItems`, ni dans la recherche. Sans `q`, les recettes sont triées par catégorie (l'ordre des types de recette), puis par nom, puis par identifiant. Avec `q`, seules les recettes dont le nom correspond sont rendues, du plus pertinent au moins pertinent (Jaro-Winkler, ADR 0019), à pertinence égale par nom puis par identifiant, toutes catégories confondues ; chaque recette porte sa catégorie (`typeSlug`, `typeName`). 200 recettes au plus par réponse (`totalItems` donne le vrai nombre). Sans autre paramètre, la première page de 200 : `limit` et `offset` facultatifs lisent la suite. + * @description Une recette secrète (`secret`, ADR 0033) n'y est jamais : ni dans `member`, ni dans `totalItems`, ni dans la recherche. Sans `q`, les recettes sont triées par catégorie (l'ordre des types de recette), puis par nom, puis par identifiant. Avec `q`, seules les recettes dont le nom correspond sont rendues, du plus pertinent au moins pertinent (Jaro-Winkler, ADR 0019), à pertinence égale par nom puis par identifiant, toutes catégories confondues ; chaque recette porte sa catégorie (`typeSlug`, `typeName`). 200 recettes au plus par réponse (`totalItems` donne le vrai nombre). Sans autre paramètre, la première page de 200 : `limit` et `offset` facultatifs lisent la suite. Un administrateur y voit TOUJOURS les recettes sans propriétaire (`fromMarmiton`), même celles qu'il a déjà copiées (`alreadyCopied`), pour les corriger ou les supprimer ; la page, le total et la recherche des autres utilisateurs ne changent pas. */ get: operations["get_api_list_other_people_recipes"]; put?: never; @@ -842,6 +845,30 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/ingredients/{slug}/parent": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Découpe un ingrédient : il devient une PARTIE d'un autre (« Jaune d'œuf » est une partie d'« Œuf »), ou change de parent ou de ratio + * @description Seul le propriétaire de l'ingrédient le découpe, un administrateur compris (`403` pour l'ingrédient d'un autre) ; le parent est à l'utilisateur lui aussi, sinon il est inconnu (`404`). Une partie reste un ingrédient ordinaire (recettes, conversions) mais n'est jamais une ligne de la liste de courses : elle se plie dans son parent. Un seul parent, profondeur 1 : un parent n'est pas une partie et une partie n'a pas de partie ; dix parties au plus par parent. Idempotent : refaire le même appel, ou ne changer que le ratio, n'est jamais refusé. Rien n'est écrit sur un refus. Les listes en cours ne sont pas recalculées. + */ + put: operations["put_api_set_ingredient_parent"]; + post?: never; + /** + * Recolle une partie : l'ingrédient redevient un ingrédient ordinaire (sans parent ni ratio) + * @description Seul le propriétaire de l'ingrédient le recolle, un administrateur compris (`403` pour l'ingrédient d'un autre). Idempotent : un ingrédient qui n'est pas une partie répond `204` sans rien changer. Toujours permis, même quand une liste de courses contient l'ingrédient. + */ + delete: operations["delete_api_remove_ingredient_parent"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/shopping-lists/active/ingredients/{slug}/decrease": { parameters: { query?: never; @@ -1699,6 +1726,18 @@ export interface components { */ transferTo: string; }; + SetIngredientParentPayload: { + /** + * @description Slug du parent : l'ingrédient dont l'ingrédient de l'URL fait partie. À l'utilisateur lui aussi (le parent d'un autre est inconnu), et lui-même n'est pas une partie + * @example oeuf-0a1b2c3d + */ + parent: string; + /** + * @description Combien d'unités par défaut de la partie font UNE unité de recette du parent (1 œuf = 1 jaune : 1 ; une gousse de vanille = 2 demi-gousses : 2), de 0,000001 à 1 000 000 + * @example 1 + */ + ratio: number; + }; UpdateServingMealPayload: { /** * @description Nombre de personnes du repas, de 1 à 100 @@ -2205,6 +2244,16 @@ export interface components { * @example images/department/cremerie.png */ departmentImage: string; + /** + * @description Identifiant du parent quand l'ingrédient est une PARTIE d'un autre (ADR 0038 : « Jaune d'œuf » pour « Œuf »), `null` pour un ingrédient ordinaire. Clé toujours écrite + * @example oeuf-0a1b2c3d + */ + partOfSlug?: string | null; + /** + * @description Nom du parent quand l'ingrédient est une PARTIE d'un autre, `null` pour un ingrédient ordinaire. Clé toujours écrite + * @example Œuf + */ + partOfName?: string | null; }; ConversionDTO: { /** @@ -2291,6 +2340,16 @@ export interface components { * @example Surgelé */ departmentName: string; + /** + * @description Identifiant du parent quand l'ingrédient est une PARTIE d'un autre (ADR 0038 : « Jaune d'œuf » pour « Œuf »), `null` pour un ingrédient ordinaire. Clé toujours écrite + * @example oeuf-0a1b2c3d + */ + partOfSlug?: string | null; + /** + * @description Nom du parent quand l'ingrédient est une PARTIE d'un autre, `null` pour un ingrédient ordinaire. Clé toujours écrite + * @example Œuf + */ + partOfName?: string | null; }; IngredientConversionDTO: { /** @@ -2520,6 +2579,41 @@ export interface components { /** @description Nom du type de liste choisi ; `null` hors du mode `TYPE`. Clé toujours écrite */ typeName?: string | null; }; + IngredientPartDTO: { + /** + * @description Identifiant de la partie (slug) + * @example jaune-d-oeuf-0a1b2c3d + */ + slug: string; + /** @example Jaune d'œuf */ + name: string; + /** + * Format: float + * @description Combien d'unités par défaut de la partie font UNE unité de recette du parent (1 œuf = 1 jaune : 1) + * @example 1 + */ + ratio: number; + /** + * @description Nom de l'unité d'achat de la partie (celle dont parle le ratio) + * @example unité + */ + unitName: string; + }; + IngredientPartOfDTO: { + /** + * @description Identifiant du parent (slug) + * @example oeuf-0a1b2c3d + */ + slug: string; + /** @example Œuf */ + name: string; + /** + * Format: float + * @description Combien d'unités par défaut de cette partie font UNE unité de recette du parent (1 œuf = 1 jaune : 1) + * @example 1 + */ + ratio: number; + }; IngredientDTO: { /** * @description Identifiant de l'ingrédient (slug) @@ -2586,6 +2680,10 @@ export interface components { /** @description Le lecteur peut poser le marqueur « de base » sur cet ingrédient : il est le propriétaire du catalogue et l'ingrédient est à lui. Toujours écrit, jamais `null` : le front s'appuie sur ce champ, pas sur le rôle administrateur */ canSetBase: boolean; listDestination: components["schemas"]["ListDestinationDTO"]; + /** @description Les PARTIES de l'ingrédient (ADR 0038 : « Jaune d'œuf » et « Blanc d'œuf » pour « Œuf »), triées par nom puis identifiant ; `[]` pour un ingrédient sans partie. Clé toujours écrite. Une partie n'est jamais une ligne de la liste de courses : elle se plie dans son parent */ + parts: components["schemas"]["IngredientPartDTO"][]; + /** @description Le parent de l'ingrédient quand c'est une PARTIE d'un autre ; `null` pour un ingrédient ordinaire. Clé toujours écrite */ + partOf?: components["schemas"]["IngredientPartOfDTO"] | null; }; MergeIngredientDTO: { /** @@ -2723,6 +2821,11 @@ export interface components { * @example Surgelé */ departmentName: string; + /** + * @description Nom du parent quand l'ingrédient de la ligne est une PARTIE d'un autre (ADR 0038 : « Œuf » pour « Jaune d'œuf »), `null` pour un ingrédient ordinaire. Clé toujours écrite + * @example Œuf + */ + partOfName?: string | null; }; RecipeDTO: { /** @@ -2766,6 +2869,16 @@ export interface components { * @example https://www.marmiton.org/recettes/recette_tarte-au-citron-meringuee_22082.aspx */ sourceUrl?: string | null; + /** + * @description L'identifiant de la recette dont celle-ci est la copie, si cette recette existe encore et que l'utilisateur la voit ; `null` pour une recette originale, ou dont l'original a été supprimé (ou est devenu secret). Toujours écrit : le front en tire un lien vers l'original + * @example 3f1c2a7e-8d41-4c62-9b0a-5e7d2f6a1b34 + */ + originalId?: string | null; + /** + * @description Vrai si l'utilisateur modifie (`PUT /recipes/{id}`) cette recette : la sienne, ou n'importe quelle recette qu'il lit s'il est administrateur - dont une recette SANS PROPRIÉTAIRE (la neutre d'une recette Marmiton, que lui seul modifie et supprime). Faux pour la recette d'un autre. Toujours écrit, calculé sans requête de plus + * @example true + */ + canEdit: boolean; }; RecipeTypeDTO: { /** @@ -2786,6 +2899,24 @@ export interface components { */ order: number; }; + ShoppingListRowIncludeDTO: { + /** + * @description Nom de la partie + * @example Jaune d'œuf + */ + name: string; + /** + * Format: float + * @description Quantité de la partie dans la liste, dans l'unité d'achat de la partie + * @example 2 + */ + quantity: number; + /** + * @description Symbole de l'unité d'achat de la partie (peut être vide) + * @example + */ + unitSymbol: string; + }; ShoppingListRowDTO: { /** * @description Identifiant de la ligne @@ -2827,6 +2958,8 @@ export interface components { * @example Surgelé */ departmentName: string; + /** @description Les parties d'ingrédient que cette ligne contient (ADR 0038) : « Comprend 2 jaunes d'œuf et 2 blancs d'œuf » pour une ligne « Œuf 5 ». Triées par nom, `[]` quand la ligne ne contient aucune partie (clé toujours écrite) */ + includes: components["schemas"]["ShoppingListRowIncludeDTO"][]; }; ShoppingListDepartmentDTO: { /** @@ -3081,6 +3214,11 @@ export interface components { * @example false */ fromMarmiton: boolean; + /** + * @description Vrai si l'utilisateur a déjà une copie de cette recette. Seul un administrateur reçoit une recette sans propriétaire déjà copiée (il la modifie ou la supprime) : pour lui, le front ne propose pas « Copier » en double. Toujours écrit ; toujours `false` pour un autre utilisateur, dont la liste ne contient que ce qu'il n'a pas copié + * @example false + */ + alreadyCopied: boolean; }; OtherPeopleRecipesDTO: { /** @description Les recettes de cette page, 200 au plus : sans recherche triées par catégorie (l'ordre des types) puis par nom, avec une recherche par pertinence décroissante, toutes catégories confondues */ @@ -5276,7 +5414,7 @@ export interface operations { }; content?: never; }; - /** @description La recette appartient à un autre utilisateur (la recette secrète d'un autre, que son auteur seul voit, est inconnue pour tous, administrateur compris : `404`). `RECIPE_SECRET_FORBIDDEN` : un administrateur qui modifie la recette ordinaire d'un autre envoie un `secret` différent de l'actuel, rien n'est écrit */ + /** @description La recette appartient à un autre utilisateur, ou n'a pas de propriétaire (recette Marmiton neutre) et l'utilisateur n'est pas administrateur (la recette secrète d'un autre, que son auteur seul voit, est inconnue pour tous, administrateur compris : `404`). `RECIPE_SECRET_FORBIDDEN` : un administrateur qui modifie la recette d'un autre (ou sans propriétaire) envoie un `secret` différent de l'actuel, rien n'est écrit */ 403: { headers: { [name: string]: unknown; @@ -5327,7 +5465,7 @@ export interface operations { }; content?: never; }; - /** @description La recette appartient à un autre utilisateur (la recette secrète d'un autre est inconnue pour tous, administrateur compris : `404`) */ + /** @description La recette appartient à un autre utilisateur, ou n'a pas de propriétaire (recette Marmiton neutre) et l'utilisateur n'est pas administrateur (la recette secrète d'un autre est inconnue pour tous, administrateur compris : `404`) */ 403: { headers: { [name: string]: unknown; @@ -6281,6 +6419,92 @@ export interface operations { }; }; }; + put_api_set_ingredient_parent: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SetIngredientParentPayload"]; + }; + }; + responses: { + /** @description Ingrédient découpé (ou déjà découpé ainsi) */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description L'ingrédient appartient à un autre utilisateur (un administrateur n'a pas de passe-droit) */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Ingrédient inconnu, ou parent inconnu ou à un autre utilisateur */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description L'ingrédient a une ligne dans une liste de courses non terminée (`INGREDIENT_PART_IN_LIST`) : une partie n'est jamais une ligne de liste ; seulement quand le parent change */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Corps invalide, ou un cas métier : un ingrédient n'est pas sa propre partie (`INGREDIENT_PART_OF_ITSELF`) ; le parent est déjà une partie ou l'ingrédient a déjà des parties (`INGREDIENT_PART_CHAIN`) ; le parent a déjà dix parties (`INGREDIENT_PART_LIMIT_REACHED`) ; le ratio n'est pas un nombre de 0,000001 à 1 000 000 (`INGREDIENT_PART_RATIO_INVALID`, précédé de `ratio: ` quand c'est le corps qui le refuse) */ + 422: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete_api_remove_ingredient_parent: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description L'ingrédient est ordinaire (il l'était déjà, ou il vient de l'être) */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description L'ingrédient appartient à un autre utilisateur (un administrateur n'a pas de passe-droit) */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Ingrédient inconnu */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; post_api_decrease_ingredient: { parameters: { query?: never; diff --git a/src/features/recipes/OtherRecipeItem.vue b/src/features/recipes/OtherRecipeItem.vue index 5e056cd7..ff8c9db0 100644 --- a/src/features/recipes/OtherRecipeItem.vue +++ b/src/features/recipes/OtherRecipeItem.vue @@ -1,20 +1,44 @@