From e4c8d910d4924242ce2367947f1a64a450af85bb Mon Sep 17 00:00:00 2001 From: Nicolas Date: Thu, 20 Aug 2026 16:56:06 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat(recipes):=20=C3=A9cran=20de=20revue=20?= =?UTF-8?q?et=20finalisation=20de=20l'import=20(=C3=A9tape=203/4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/api/src/modules/recipe/recipe.service.ts | 39 +- .../api/src/modules/sources/sources.routes.ts | 24 +- .../src/modules/sources/sources.service.ts | 75 +++- apps/api/test/sources.test.ts | 131 ++++++ apps/web/cypress/e2e/recipe-form.ts | 60 --- apps/web/cypress/e2e/recipe-sources.feature | 27 ++ apps/web/cypress/e2e/recipe-sources.ts | 64 +++ .../support/step_definitions/common.steps.ts | 68 +++ apps/web/src/App.tsx | 2 + apps/web/src/api/client.ts | 12 + .../recipes/SourceItemPreviewPanel.tsx | 7 + apps/web/src/features/recipes/recipes.scss | 53 +++ apps/web/src/locales/fr/translation.json | 13 + apps/web/src/pages/ImportRecipePage.tsx | 394 ++++++++++++++++++ packages/shared/src/errors/error-codes.ts | 2 + 15 files changed, 895 insertions(+), 76 deletions(-) create mode 100644 apps/web/src/pages/ImportRecipePage.tsx diff --git a/apps/api/src/modules/recipe/recipe.service.ts b/apps/api/src/modules/recipe/recipe.service.ts index a4a3dcb..e29b1fb 100644 --- a/apps/api/src/modules/recipe/recipe.service.ts +++ b/apps/api/src/modules/recipe/recipe.service.ts @@ -364,11 +364,46 @@ export async function createRecipe( input: CreateRecipeInput, authorId: number, authorHouseId: number | null, +): Promise { + return createRecipeInternal(input, authorId, authorHouseId, null); +} + +/** + * Finalizes an import from an external source — same validation/creation + * path as {@link createRecipe} (by the time this is called, `input` has + * already been reviewed and every ingredient resolved to a real catalog + * id, same as a manual creation — see `sources.service.ts`'s + * `importSourceItem`, the only caller), plus stamping `sourceId`/ + * `externalId` and matching techniques against `locale` (the source's own + * — e.g. `"en"` for TheMealDB) instead of the hardcoded French default, + * since the step text is still in whatever language the source wrote it + * in. + * + * @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient. + * @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit. + * @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet. + */ +export async function createImportedRecipe( + input: CreateRecipeInput, + authorId: number, + authorHouseId: number | null, + source: { sourceId: number; externalId: string; locale: string }, +): Promise { + return createRecipeInternal(input, authorId, authorHouseId, source); +} + +async function createRecipeInternal( + input: CreateRecipeInput, + authorId: number, + authorHouseId: number | null, + source: { sourceId: number; externalId: string; locale: string } | null, ): Promise { await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId)); await assertUnitsExist(input.ingredients.map((i) => i.unitId)); await assertDietsExist(input.dietIds); - const techStepMappings = await loadTechStepMappingRules(DEFAULT_TECH_STEP_LOCALE); + const techStepMappings = await loadTechStepMappingRules( + source?.locale ?? DEFAULT_TECH_STEP_LOCALE, + ); const created = await prisma.recipe.create({ data: { @@ -379,6 +414,8 @@ export async function createRecipe( authorId, authorHouseId, visibility: input.visibility, + sourceId: source?.sourceId ?? null, + externalId: source?.externalId ?? null, ingredients: { create: input.ingredients.map((ingredient) => ({ ingredientId: ingredient.ingredientId, diff --git a/apps/api/src/modules/sources/sources.routes.ts b/apps/api/src/modules/sources/sources.routes.ts index 1b60a92..edba393 100644 --- a/apps/api/src/modules/sources/sources.routes.ts +++ b/apps/api/src/modules/sources/sources.routes.ts @@ -1,9 +1,9 @@ import { HttpError } from "@batch-cooking/error-tools"; import { wrapAsyncHandler } from "@batch-cooking/express-tools"; -import { ErrorCode, browseSourceSchema } from "@batch-cooking/shared"; +import { ErrorCode, browseSourceSchema, createRecipeSchema } from "@batch-cooking/shared"; import { Router } from "express"; import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js"; -import { browseSource, previewSourceItem } from "./sources.service.js"; +import { browseSource, importSourceItem, previewSourceItem } from "./sources.service.js"; /** * Router mounted at `/sources` in app.ts — browsing/previewing a @@ -49,3 +49,23 @@ sourcesRouter.get( ); }), ); + +sourcesRouter.post( + "/:sourceKey/import/:externalId", + requireAuth, + wrapAsyncHandler(async (req, res) => { + const input = createRecipeSchema.parse(req.body); + const { id: authorId, houseId } = res.locals.userProfile; + res + .status(201) + .json( + await importSourceItem( + requireParam(req.params.sourceKey), + requireParam(req.params.externalId), + input, + authorId, + houseId, + ), + ); + }), +); diff --git a/apps/api/src/modules/sources/sources.service.ts b/apps/api/src/modules/sources/sources.service.ts index 68b5c3e..0fb533b 100644 --- a/apps/api/src/modules/sources/sources.service.ts +++ b/apps/api/src/modules/sources/sources.service.ts @@ -1,10 +1,12 @@ import { HttpError } from "@batch-cooking/error-tools"; import { type BrowsableSourceItemView, + type CreateRecipeInput, type DraftRecipeIngredientView, type DraftRecipeStepView, ErrorCode, type RecipeImportDraftView, + type RecipeView, } from "@batch-cooking/shared"; import { prisma } from "../../db/prisma.js"; import { findImportedRecipeIds } from "../../db/recipe-source-sync.js"; @@ -20,18 +22,20 @@ import { getRecipeSource } from "../../lib/recipe-source-registry.js"; import { translateRecipeIngredients } from "../../lib/recipe-translation.js"; import { loadTechStepMappingRules, matchTechStepSpans } from "../../lib/tech-step-matcher.js"; import { getHouseSourceIds } from "../house/house.service.js"; +import { createImportedRecipe } from "../recipe/recipe.service.js"; import { getIngredients, getUnits } from "../reference/reference.service.js"; /** - * Browsing and previewing a household's *enabled* external recipe sources - * (`HouseSource`) — the read-only half of the "onglet Sources" feature - * (see the project plan). Neither function here saves anything: browsing - * lists what a source offers (`RecipeSourceAdapter.list()`), previewing - * fully translates one item (`translateRecipeIngredients`, - * `matchTechStepSpans` — same building blocks `recipe.service.ts` uses at - * actual save time) without persisting it. Turning a preview into a real - * `Recipe` (with unresolved ingredients reviewed/fixed up first) is a - * later stage of the same plan, not built here. + * Browsing, previewing, and importing a household's *enabled* external + * recipe sources (`HouseSource`) — the "onglet Sources" feature (see the + * project plan). Browsing lists what a source offers + * (`RecipeSourceAdapter.list()`); previewing fully translates one item + * (`translateRecipeIngredients`, `matchTechStepSpans` — same building + * blocks `recipe.service.ts` uses at real save time) without persisting + * it; importing (`importSourceItem`) is the only function here that + * actually saves — by the time it's called, the caller (the review screen) + * has already resolved every ingredient to a real catalog id, same as a + * manual `POST /recipes`. */ /** @@ -50,7 +54,7 @@ import { getIngredients, getUnits } from "../reference/reference.service.js"; async function assertSourceEnabled( houseId: number | null, sourceKey: string, -): Promise { +): Promise<{ adapter: RecipeSourceAdapter; sourceId: number }> { const enabledSourceIds = await getHouseSourceIds(houseId); const source = await prisma.source.findUnique({ where: { key: sourceKey } }); if (!source || !enabledSourceIds.includes(source.id)) { @@ -68,7 +72,7 @@ async function assertSourceEnabled( `Source "${sourceKey}" has no registered adapter`, ); } - return adapter; + return { adapter, sourceId: source.id }; } /** @@ -84,7 +88,7 @@ export async function browseSource( houseId: number | null, params: { query?: string; cursor?: string }, ): Promise<{ items: BrowsableSourceItemView[]; nextCursor: string | null }> { - const adapter = await assertSourceEnabled(houseId, sourceKey); + const { adapter } = await assertSourceEnabled(houseId, sourceKey); const result = await adapter.list({ query: params.query, cursor: params.cursor }); const importedRecipeIds = await findImportedRecipeIds( @@ -128,7 +132,7 @@ export async function previewSourceItem( externalId: string, houseId: number | null, ): Promise { - const adapter = await assertSourceEnabled(houseId, sourceKey); + const { adapter } = await assertSourceEnabled(houseId, sourceKey); let parsed: ReturnType; try { @@ -189,3 +193,48 @@ export async function previewSourceItem( steps, }; } + +/** + * Finalizes an import — the review screen (pre-filled from + * {@link previewSourceItem}'s draft, unresolved ingredients fixed up by + * the user via the normal `IngredientPicker`) submits `input` as a + * regular {@link CreateRecipeInput}, exactly like a manually-authored + * recipe. This just adds two things `createRecipe` itself can't: + * confirming `externalId` isn't already imported (the DB's own + * `@@unique([sourceId, externalId])` would reject a second attempt too, + * but as a raw constraint violation — checking first gives a clean, + * expected error instead), and stamping `sourceId`/`externalId` plus + * matching techniques against the source's own locale + * (`createImportedRecipe`, `recipe.service.ts`). + * + * @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet. + * @throws {HttpError} `404 SOURCE_NOT_FOUND` if `sourceKey` doesn't match a source enabled for this household. + * @throws {HttpError} `409 RECIPE_ALREADY_IMPORTED` if `externalId` was already imported from this source. + * @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient. + * @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit. + * @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet. + */ +export async function importSourceItem( + sourceKey: string, + externalId: string, + input: CreateRecipeInput, + authorId: number, + authorHouseId: number | null, +): Promise { + const { adapter, sourceId } = await assertSourceEnabled(authorHouseId, sourceKey); + + const alreadyImported = await findImportedRecipeIds(prisma, sourceKey, [externalId]); + if (alreadyImported.has(externalId)) { + throw new HttpError( + 409, + ErrorCode.RECIPE_ALREADY_IMPORTED, + `"${externalId}" from source "${sourceKey}" is already imported`, + ); + } + + return createImportedRecipe(input, authorId, authorHouseId, { + sourceId, + externalId, + locale: adapter.locale, + }); +} diff --git a/apps/api/test/sources.test.ts b/apps/api/test/sources.test.ts index 4b496ac..9107128 100644 --- a/apps/api/test/sources.test.ts +++ b/apps/api/test/sources.test.ts @@ -78,6 +78,18 @@ function buildFakeAdapter(key = "fakeSource"): RecipeSourceAdapter<{ externalId: }; } +/** Resolves a reference ingredient's id by its `reference-seed-data.ts` uid (also its DB `key`) — same reasoning as `recipe.test.ts`'s own helper. */ +async function ingredientId(key: string): Promise { + const ingredient = await prisma.ingredient.findFirstOrThrow({ where: { key } }); + return ingredient.id; +} + +/** Resolves a reference unit's id by its `reference-seed-data.ts` uid (also its DB `key`) — same reasoning as {@link ingredientId}. */ +async function unitId(key: string): Promise { + const unit = await prisma.unit.findFirstOrThrow({ where: { key } }); + return unit.id; +} + describe("Sources", () => { const app = createApp(); @@ -251,4 +263,123 @@ describe("Sources", () => { expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND); }); }); + + describe("POST /sources/:sourceKey/import/:externalId", () => { + async function enableFakeSource(): Promise<{ + agent: ReturnType; + sourceId: number; + }> { + const { agent } = await signupWithHouse(); + registerRecipeSource(buildFakeAdapter()); + await syncRecipeSources(prisma); + const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } }); + await agent.patch("/house/current/sources").send({ sourceIds: [source.id] }); + return { agent, sourceId: source.id }; + } + + /** A fully-resolved payload, as the review screen would submit it — every ingredient already has a real ingredientId/unitId, same shape `POST /recipes` accepts. */ + async function buildImportPayload() { + return { + name: "Fake recipe 1 (revue)", + portions: 4, + dietIds: [], + ingredients: [ + { ingredientId: await ingredientId("onion"), quantity: 1, unitId: await unitId("piece") }, + ], + steps: [{ description: "Chop the onions finely" }], + }; + } + + it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => { + const res = await request(app) + .post("/sources/fakeSource/import/1") + .send(await buildImportPayload()); + expect(res.status).to.equal(401); + expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED); + }); + + it("rejects a source the household hasn't enabled with 404 SOURCE_NOT_FOUND", async () => { + const { agent } = await signupWithHouse(); + registerRecipeSource(buildFakeAdapter()); + await syncRecipeSources(prisma); + + const res = await agent.post("/sources/fakeSource/import/1").send(await buildImportPayload()); + + expect(res.status).to.equal(404); + expect(res.body.code).to.equal(ErrorCode.SOURCE_NOT_FOUND); + }); + + it("creates the recipe with sourceId/externalId set, matching techniques against the source's own locale", async () => { + const { agent, sourceId } = await enableFakeSource(); + const chop = await prisma.techStep.findFirstOrThrow({ where: { key: "chop" } }); + + const res = await agent.post("/sources/fakeSource/import/1").send(await buildImportPayload()); + + expect(res.status).to.equal(201); + const created = await prisma.recipe.findUniqueOrThrow({ where: { id: res.body.id } }); + expect(created.sourceId).to.equal(sourceId); + expect(created.externalId).to.equal("1"); + + // The step text is English ("Chop the onions finely") — this only + // matches "chop" if the fake adapter's own locale ("en") was used + // for tech-step matching, not the hardcoded French default (which + // would find nothing in English text — see recipe-translation.test.ts's + // "locales are separate rule sets" test for the same point made the + // other way around). + const step = await prisma.step.findFirstOrThrow({ where: { recipeId: created.id } }); + const stepTechSteps = await prisma.stepTechStep.findMany({ where: { stepId: step.id } }); + expect(stepTechSteps.map((s) => s.techStepId)).to.deep.equal([chop.id]); + }); + + it("rejects a second import of the same item with 409 RECIPE_ALREADY_IMPORTED", async () => { + const { agent } = await enableFakeSource(); + const first = await agent + .post("/sources/fakeSource/import/1") + .send(await buildImportPayload()); + expect(first.status).to.equal(201); + + const second = await agent + .post("/sources/fakeSource/import/1") + .send(await buildImportPayload()); + + expect(second.status).to.equal(409); + expect(second.body.code).to.equal(ErrorCode.RECIPE_ALREADY_IMPORTED); + }); + + it("rejects an unknown ingredientId with 404 INGREDIENT_NOT_FOUND, same as a manual creation", async () => { + const { agent } = await enableFakeSource(); + const payload = await buildImportPayload(); + payload.ingredients[0].ingredientId = 999_999; + + const res = await agent.post("/sources/fakeSource/import/1").send(payload); + + expect(res.status).to.equal(404); + expect(res.body.code).to.equal(ErrorCode.INGREDIENT_NOT_FOUND); + }); + + it("rejects a second household's import of the same item too — the item's identity is global, not per-household", async () => { + // Registers/syncs the adapter once — enableFakeSource() itself does + // this too, and registerRecipeSource() throws on a duplicate key, so + // calling it twice in one test (once per household) isn't an option. + registerRecipeSource(buildFakeAdapter()); + await syncRecipeSources(prisma); + const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } }); + + const { agent: firstAgent } = await signupWithHouse(); + await firstAgent.patch("/house/current/sources").send({ sourceIds: [source.id] }); + const firstImport = await firstAgent + .post("/sources/fakeSource/import/1") + .send(await buildImportPayload()); + expect(firstImport.status).to.equal(201); + + const { agent: secondAgent } = await signupWithHouse(); + await secondAgent.patch("/house/current/sources").send({ sourceIds: [source.id] }); + const secondImport = await secondAgent + .post("/sources/fakeSource/import/1") + .send(await buildImportPayload()); + + expect(secondImport.status).to.equal(409); + expect(secondImport.body.code).to.equal(ErrorCode.RECIPE_ALREADY_IMPORTED); + }); + }); }); diff --git a/apps/web/cypress/e2e/recipe-form.ts b/apps/web/cypress/e2e/recipe-form.ts index 2d22a99..03cb085 100644 --- a/apps/web/cypress/e2e/recipe-form.ts +++ b/apps/web/cypress/e2e/recipe-form.ts @@ -57,66 +57,6 @@ When("I visit the new recipe form without a secure random UUID", () => { }); }); -When("I search the ingredient picker for {string}", (text: string) => { - cy.get("input[placeholder='Rechercher un ingrédient…']").type(text); -}); - -When("I select the ingredient {string} from the picker", (name: string) => { - cy.contains(".ingredient-picker__card", name).click(); -}); - -Then("the ingredient {string} should no longer be in the picker", (name: string) => { - cy.contains(".ingredient-picker__card", name).should("not.exist"); -}); - -Then("the ingredient {string} should be visible in the picker", (name: string) => { - cy.contains(".ingredient-picker__card", name).should("be.visible"); -}); - -Then("the recipe should include the ingredient {string}", (name: string) => { - cy.contains(".ingredient-row__name", name).should("be.visible"); -}); - -Then("there should be {int} ingredient rows", (count: number) => { - cy.get(".ingredient-row").should("have.length", count); -}); - -When("I remove the ingredient {string} from the recipe", (name: string) => { - cy.contains(".ingredient-row", name).find("button[title='Retirer cet ingrédient']").click(); -}); - -When( - "I fill in the ingredient's quantity with {string} and unit {string}", - (quantity: string, unit: string) => { - cy.get(".ingredient-row .ingredient-row__quantity").type(quantity); - cy.get(".ingredient-row .ingredient-row__unit").select(unit); - }, -); - -Then("the ingredient's quantity should be {string}", (quantity: string) => { - cy.get(".ingredient-row .ingredient-row__quantity").should("have.value", quantity); -}); - -When( - "I fill in the last ingredient's quantity with {string} and unit {string}", - (quantity: string, unit: string) => { - cy.get(".ingredient-row .ingredient-row__quantity").last().type(quantity); - cy.get(".ingredient-row .ingredient-row__unit").last().select(unit); - }, -); - -When("I add a step", () => { - cy.contains("button", "Ajouter une étape").click(); -}); - -When("I fill in the step description with {string}", (text: string) => { - cy.get(".step-list-editor__item textarea").type(text); -}); - -Then("there should be {int} step editor items", (count: number) => { - cy.get(".step-list-editor__item").should("have.length", count); -}); - Then( "the recipe creation request should have included name {string}, portions {int}, and ingredient {int} with quantity {int} and unitId {int}", (name: string, portions: number, ingredientId: number, quantity: number, unitId: number) => { diff --git a/apps/web/cypress/e2e/recipe-sources.feature b/apps/web/cypress/e2e/recipe-sources.feature index 2342e6c..8c9c735 100644 --- a/apps/web/cypress/e2e/recipe-sources.feature +++ b/apps/web/cypress/e2e/recipe-sources.feature @@ -44,3 +44,30 @@ Feature: Browsing external recipe sources And I click the source item "Fish Pie" Then the recipe detail panel heading should be "Fish Pie" And I should see the highlighted technique "Cuire" + + Scenario: Reviews an import, resolving an unrecognized ingredient before confirming + Given the recipe catalog contains nothing + And the sources reference list has options + And the household has enabled TheMealDB + And browsing TheMealDB returns some items + And previewing TheMealDB item "9999" is available + And the ingredient and diet catalog is available for import + And importing the previewed item will succeed and return id 99 + When I visit "/recettes" + And I click the button "Sources" + And I click the source item "Fish Pie" + And I click the button "Importer cette recette" + Then the "recipe-name" field should have the value "Fish Pie" + And the recipe should include the ingredient "Oignon" + + When I choose an ingredient for the unresolved line "some mystery paste" + And I select the ingredient "Sel" from the picker + Then the unresolved ingredients section should no longer be shown + And there should be 2 ingredient rows + + When I select unit "unité" for the first ingredient + And I fill in the last ingredient's quantity with "1" and unit "unité" + Then the "Importer" button should not be disabled + When I click the button "Importer" + Then the import request should have included ingredient 2 with quantity 1 and unitId 1 + And the URL should include "/recettes/99" diff --git a/apps/web/cypress/e2e/recipe-sources.ts b/apps/web/cypress/e2e/recipe-sources.ts index 47c130d..c630282 100644 --- a/apps/web/cypress/e2e/recipe-sources.ts +++ b/apps/web/cypress/e2e/recipe-sources.ts @@ -134,3 +134,67 @@ When("I click the source item {string}", (title: string) => { Then("the source item {string} should be marked as already imported", (title: string) => { cy.contains("tr", title).find(".source-item-table__imported-badge").should("be.visible"); }); + +// ImportRecipePage (the review screen) loads its own ingredient/diet/unit +// catalogs the same way RecipeFormPage does — "onion" matches the resolved +// line in "previewing TheMealDB item ... is available" above, "salt" is +// what "some mystery paste" (unresolved in that same fixture) gets +// corrected to in the review-and-import scenario. +Given("the ingredient and diet catalog is available for import", () => { + cy.intercept("GET", "**/reference/ingredients", { + statusCode: 200, + body: [ + { + id: 1, + key: "onion", + icon: "VEGETABLE", + category: "freshProduce", + subcategory: "vegetables", + allergens: [], + diets: [], + }, + { + id: 2, + key: "salt", + icon: "SPICE", + category: "condimentsAndSpices", + subcategory: "spices", + allergens: [], + diets: [], + }, + ], + }); + cy.intercept("GET", "**/reference/diets", { + statusCode: 200, + body: [{ id: 1, key: "omnivore" }], + }); + cy.intercept("GET", "**/reference/units", { + statusCode: 200, + body: [{ id: 1, key: "piece", type: "COUNT", toBaseFactor: 1 }], + }); +}); + +Given("importing the previewed item will succeed and return id {int}", (id: number) => { + cy.intercept("POST", "**/sources/theMealDb/import/9999", { statusCode: 201, body: { id } }).as( + "importRecipe", + ); +}); + +When("I choose an ingredient for the unresolved line {string}", (rawText: string) => { + cy.contains(".import-recipe__unresolved-row", rawText) + .contains("button", "Choisir un ingrédient") + .click(); +}); + +Then("the unresolved ingredients section should no longer be shown", () => { + cy.get(".import-recipe__unresolved").should("not.exist"); +}); + +Then( + "the import request should have included ingredient {int} with quantity {int} and unitId {int}", + (ingredientId: number, quantity: number, unitId: number) => { + cy.wait("@importRecipe") + .its("request.body.ingredients") + .should("include.deep.members", [{ ingredientId, quantity, unitId }]); + }, +); diff --git a/apps/web/cypress/support/step_definitions/common.steps.ts b/apps/web/cypress/support/step_definitions/common.steps.ts index 3eb2c1d..a4ac86a 100644 --- a/apps/web/cypress/support/step_definitions/common.steps.ts +++ b/apps/web/cypress/support/step_definitions/common.steps.ts @@ -159,6 +159,74 @@ Then("the tooltip should show {string}", (label: string) => { cy.get(".tooltip__bubble").contains(label).should("be.visible"); }); +// `IngredientPicker`/`IngredientRow`/`StepListEditor` (features/recipes/) +// back both RecipeFormPage and ImportRecipePage — recipe-form.feature and +// import-recipe.feature both need these. + +When("I search the ingredient picker for {string}", (text: string) => { + cy.get("input[placeholder='Rechercher un ingrédient…']").type(text); +}); + +When("I select the ingredient {string} from the picker", (name: string) => { + cy.contains(".ingredient-picker__card", name).click(); +}); + +Then("the ingredient {string} should no longer be in the picker", (name: string) => { + cy.contains(".ingredient-picker__card", name).should("not.exist"); +}); + +Then("the ingredient {string} should be visible in the picker", (name: string) => { + cy.contains(".ingredient-picker__card", name).should("be.visible"); +}); + +Then("the recipe should include the ingredient {string}", (name: string) => { + cy.contains(".ingredient-row__name", name).should("be.visible"); +}); + +Then("there should be {int} ingredient rows", (count: number) => { + cy.get(".ingredient-row").should("have.length", count); +}); + +When("I remove the ingredient {string} from the recipe", (name: string) => { + cy.contains(".ingredient-row", name).find("button[title='Retirer cet ingrédient']").click(); +}); + +When( + "I fill in the ingredient's quantity with {string} and unit {string}", + (quantity: string, unit: string) => { + cy.get(".ingredient-row .ingredient-row__quantity").type(quantity); + cy.get(".ingredient-row .ingredient-row__unit").select(unit); + }, +); + +Then("the ingredient's quantity should be {string}", (quantity: string) => { + cy.get(".ingredient-row .ingredient-row__quantity").should("have.value", quantity); +}); + +When("I select unit {string} for the first ingredient", (unit: string) => { + cy.get(".ingredient-row .ingredient-row__unit").first().select(unit); +}); + +When( + "I fill in the last ingredient's quantity with {string} and unit {string}", + (quantity: string, unit: string) => { + cy.get(".ingredient-row .ingredient-row__quantity").last().type(quantity); + cy.get(".ingredient-row .ingredient-row__unit").last().select(unit); + }, +); + +When("I add a step", () => { + cy.contains("button", "Ajouter une étape").click(); +}); + +When("I fill in the step description with {string}", (text: string) => { + cy.get(".step-list-editor__item textarea").type(text); +}); + +Then("there should be {int} step editor items", (count: number) => { + cy.get(".step-list-editor__item").should("have.length", count); +}); + Then("the checkbox {string} should be checked", (label: string) => { cy.contains("label", label).find("input[type=checkbox]").should("be.checked"); }); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 2750708..206e7c3 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from "react-router-dom"; import { RedirectIfAuthenticated } from "./features/auth/RedirectIfAuthenticated"; import { RequireAuth } from "./features/auth/RequireAuth"; import { AppLayout } from "./layouts/AppLayout"; +import { ImportRecipePage } from "./pages/ImportRecipePage"; import { LoginPage } from "./pages/LoginPage"; import { PlanningPage } from "./pages/PlanningPage"; import { RecipeFormPage } from "./pages/RecipeFormPage"; @@ -59,6 +60,7 @@ export function App() { (see RecipesPage.tsx). */} } /> } /> + } /> } /> } /> } /> diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 07b9126..38403c7 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -192,6 +192,18 @@ export class ApiClient { 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 { + 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), diff --git a/apps/web/src/features/recipes/SourceItemPreviewPanel.tsx b/apps/web/src/features/recipes/SourceItemPreviewPanel.tsx index f51e448..c9efbdc 100644 --- a/apps/web/src/features/recipes/SourceItemPreviewPanel.tsx +++ b/apps/web/src/features/recipes/SourceItemPreviewPanel.tsx @@ -1,5 +1,6 @@ import type { RecipeImportDraftView } from "@batch-cooking/shared"; import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; import { StepDescription } from "./StepDescription"; import "./recipes.scss"; @@ -71,6 +72,12 @@ export function SourceItemPreviewPanel({ state }: { state: SourceItemPreviewStat
+ + {t("recipes.sources.detail.importButton")} + input[type="checkbox"])` rule already diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index ddcad3c..2ba70e5 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -19,6 +19,7 @@ "INVITE_CODE_NOT_FOUND": "Ce code d'invitation ne correspond à aucun foyer", "RECIPE_NOT_FOUND": "Cette recette n'existe pas", "RECIPE_IN_USE": "Cette recette est encore utilisée dans un planning", + "RECIPE_ALREADY_IMPORTED": "Cette recette a déjà été importée", "INGREDIENT_NOT_FOUND": "Un des ingrédients sélectionnés n'existe pas", "UNIT_NOT_FOUND": "Une des unités sélectionnées n'existe pas", "SOURCE_NOT_FOUND": "Une des sources sélectionnées n'existe pas", @@ -184,11 +185,23 @@ "loading": "Chargement de l'aperçu…", "loadError": "Impossible de charger l'aperçu de cette recette.", "viewSource": "Voir sur le site d'origine", + "importButton": "Importer cette recette", "ingredientsCount_one": "{{count}} ingrédient", "ingredientsCount_other": "{{count}} ingrédients", "unresolvedIngredientsHint": "Certains ingrédients n'ont pas été reconnus automatiquement — ils pourront être corrigés à l'import.", "stepsCount_one": "{{count}} étape", "stepsCount_other": "{{count}} étapes" + }, + "import": { + "title": "Revoir l'import", + "loadError": "Impossible de charger cette recette pour le moment.", + "unresolvedTitle": "Ingrédients à compléter", + "unresolvedHint": "Ces lignes n'ont pas été reconnues automatiquement — choisissez le bon ingrédient, ou retirez-les.", + "resolveButton": "Choisir un ingrédient", + "discardButton": "Retirer cette ligne", + "submit": "Importer", + "submitting": "Import en cours…", + "genericError": "Le formulaire contient des erreurs" } }, "detail": { diff --git a/apps/web/src/pages/ImportRecipePage.tsx b/apps/web/src/pages/ImportRecipePage.tsx new file mode 100644 index 0000000..88fe25a --- /dev/null +++ b/apps/web/src/pages/ImportRecipePage.tsx @@ -0,0 +1,394 @@ +import { + type CreateRecipeInput, + type DietView, + ErrorCode, + type IngredientView, + type RecipeVisibility, + type UnitView, + createRecipeSchema, +} from "@batch-cooking/shared"; +import { type FormEvent, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate, useParams } from "react-router-dom"; +import { ApiError, apiClient } from "../api/client"; +import { DietTagSelect } from "../features/recipes/DietTagSelect"; +import { IngredientPicker } from "../features/recipes/IngredientPicker"; +import { IngredientRow } from "../features/recipes/IngredientRow"; +import { type StepDraft, StepListEditor } from "../features/recipes/StepListEditor"; +import "../features/recipes/recipes.scss"; +import { makeClientKey } from "../lib/client-key"; +import { errorMessageService } from "../services/error-message.service"; + +/** In display order — mirrors `RecipeVisibility` (schema.prisma/shared types). Same list as `RecipeFormPage`. */ +const VISIBILITY_OPTIONS: RecipeVisibility[] = ["PERSONAL", "HOUSE", "PUBLIC"]; + +/** A resolved ingredient line — identical shape to `RecipeFormPage`'s own `IngredientLine`. */ +interface IngredientLine { + key: string; + ingredient: IngredientView; + quantity: string; + unitId: number | null; +} + +/** One draft line that didn't resolve to a real `Ingredient` on preview (`DraftRecipeIngredientView.ingredient === null`) — still needs a person to pick the right one, or discard it, before this recipe can be saved. */ +interface UnresolvedIngredientLine { + key: string; + rawText: string; + quantity: string; +} + +type LoadState = "loading" | "loaded" | "error"; + +/** + * Review screen for finalizing an import — routed at + * `/recettes/importer/:sourceKey/:externalId` (reached from + * `SourceItemPreviewPanel`'s "Importer cette recette" button). Pre-filled + * from `GET /sources/:sourceKey/preview/:externalId` (the same draft the + * preview panel already showed), structurally the same form as + * `RecipeFormPage` — same sub-components (`IngredientRow`, + * `IngredientPicker`, `StepListEditor`, `DietTagSelect`), same + * `CreateRecipeInput` submit shape — plus one thing a manual creation + * never has to handle: ingredient lines the automatic matching + * (`ingredient-matcher.ts`) couldn't resolve. Those render as their own + * "à compléter" list, each needing a real ingredient picked (or the line + * discarded) before the form can submit — never silently drops/guesses one, + * per the product decision this stage was built against (no invalid + * recipe is ever persisted). + * + * Submits to `POST /sources/:sourceKey/import/:externalId` + * (`apiClient.importSourceItem`) instead of `POST /recipes` — the only + * other difference from `RecipeFormPage`'s own submit. + */ +export function ImportRecipePage() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { sourceKey, externalId } = useParams<{ sourceKey: string; externalId: string }>(); + + const [loadState, setLoadState] = useState("loading"); + const [ingredientsCatalog, setIngredientsCatalog] = useState([]); + const [dietsCatalog, setDietsCatalog] = useState([]); + const [unitsCatalog, setUnitsCatalog] = useState([]); + + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [picture, setPicture] = useState(""); + const [portions, setPortions] = useState("4"); + const [visibility, setVisibility] = useState("PERSONAL"); + const [dietIds, setDietIds] = useState([]); + const [ingredientLines, setIngredientLines] = useState([]); + const [unresolvedIngredients, setUnresolvedIngredients] = useState( + [], + ); + // Which unresolved line's picker is currently open — at most one at a + // time (IngredientPicker is a whole browsable grid, not a compact + // popover; showing one per unresolved line at once would be unwieldy). + const [resolvingKey, setResolvingKey] = useState(null); + const [steps, setSteps] = useState([]); + + const [formError, setFormError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + useEffect(() => { + if (sourceKey === undefined || externalId === undefined) { + setLoadState("error"); + return; + } + let cancelled = false; + setLoadState("loading"); + + Promise.all([ + apiClient.getIngredients(), + apiClient.getDiets(), + apiClient.getUnits(), + apiClient.previewSourceItem(sourceKey, externalId), + ]) + .then(([ingredients, diets, units, draft]) => { + if (cancelled) return; + setIngredientsCatalog(ingredients); + setDietsCatalog(diets); + setUnitsCatalog(units); + + setName(draft.name); + setDescription(draft.description ?? ""); + setPicture(draft.picture ?? ""); + setPortions(draft.portions !== null ? String(draft.portions) : "4"); + + const resolved: IngredientLine[] = []; + const unresolved: UnresolvedIngredientLine[] = []; + for (const line of draft.ingredients) { + if (line.ingredient !== null) { + resolved.push({ + key: makeClientKey(), + ingredient: line.ingredient, + quantity: line.quantity !== null ? String(line.quantity) : "", + unitId: line.unit?.id ?? null, + }); + } else { + unresolved.push({ + key: makeClientKey(), + rawText: line.rawText, + quantity: line.quantity !== null ? String(line.quantity) : "", + }); + } + } + setIngredientLines(resolved); + setUnresolvedIngredients(unresolved); + + setSteps( + draft.steps.map((step) => ({ + key: makeClientKey(), + description: step.description, + picture: step.picture ?? "", + })), + ); + setLoadState("loaded"); + }) + .catch(() => { + if (!cancelled) setLoadState("error"); + }); + + return () => { + cancelled = true; + }; + }, [sourceKey, externalId]); + + function addIngredient(ingredient: IngredientView) { + setIngredientLines((lines) => [ + ...lines, + { key: makeClientKey(), ingredient, quantity: "", unitId: null }, + ]); + } + + function updateIngredientLine( + key: string, + patch: Partial>, + ) { + setIngredientLines((lines) => + lines.map((line) => (line.key === key ? { ...line, ...patch } : line)), + ); + } + + function removeIngredientLine(key: string) { + setIngredientLines((lines) => lines.filter((line) => line.key !== key)); + } + + /** Promotes an unresolved line into a real ingredient line, carrying its quantity over — its unit still needs picking, same as a freshly-added ingredient. */ + function resolveIngredient(unresolvedKey: string, ingredient: IngredientView) { + setUnresolvedIngredients((lines) => { + const line = lines.find((l) => l.key === unresolvedKey); + if (line) { + setIngredientLines((resolved) => [ + ...resolved, + { key: makeClientKey(), ingredient, quantity: line.quantity, unitId: null }, + ]); + } + return lines.filter((l) => l.key !== unresolvedKey); + }); + setResolvingKey(null); + } + + function discardUnresolvedIngredient(key: string) { + setUnresolvedIngredients((lines) => lines.filter((line) => line.key !== key)); + setResolvingKey((current) => (current === key ? null : current)); + } + + const canSubmit = + name.trim().length > 0 && + Number.isInteger(Number(portions)) && + Number(portions) > 0 && + ingredientLines.length > 0 && + ingredientLines.every((line) => Number(line.quantity) > 0 && line.unitId !== null) && + unresolvedIngredients.length === 0 && + steps.length > 0 && + steps.every((step) => step.description.trim().length > 0); + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + setFormError(null); + if (sourceKey === undefined || externalId === undefined) return; + + const payload: CreateRecipeInput = { + name: name.trim(), + description: description.trim() || null, + picture: picture.trim() || null, + portions: Number(portions), + visibility, + dietIds, + ingredients: ingredientLines.map((line) => ({ + ingredientId: line.ingredient.id, + quantity: Number(line.quantity), + // `canSubmit` already requires every line to have a unit picked — + // same "?? 0, the schema rejects it if ever reached" reasoning as + // RecipeFormPage's identical submit. + unitId: line.unitId ?? 0, + })), + steps: steps.map((step) => ({ + description: step.description.trim(), + picture: step.picture.trim() || null, + })), + }; + + const result = createRecipeSchema.safeParse(payload); + if (!result.success) { + setFormError(result.error.issues[0]?.message ?? t("recipes.sources.import.genericError")); + return; + } + + setIsSubmitting(true); + try { + const saved = await apiClient.importSourceItem(sourceKey, externalId, result.data); + navigate(`/recettes/${saved.id}`); + } catch (err) { + const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR; + setFormError(errorMessageService.getLabel(code)); + } finally { + setIsSubmitting(false); + } + } + + if (loadState === "loading") { + return ( +
+

{t("recipes.loading")}

+
+ ); + } + + if (loadState === "error") { + return ( +
+

+ {t("recipes.sources.import.loadError")} +

+
+ ); + } + + const selectedIds = ingredientLines.map((line) => line.ingredient.id); + + return ( +
+

{t("recipes.sources.import.title")}

+ + + setName(e.target.value)} /> + + +