From b49ef5bb40783d330d771f27cefa86cf7f2248e7 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Wed, 9 Sep 2026 15:40:35 +0200 Subject: [PATCH 1/2] feat(recipes): signaler une technique absente du catalogue (#130) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Calque le pattern « ingrédient hors-catalogue » sur `TechStep` : cinq colonnes additives (`isPlaceholder`/`displayName`/`createdById`/`createdAt`/ `reviewedAt`), migration purement additive, FK `createdById → user_profiles` en `SET NULL`. Correction d'étape : `submitTechStepCorrectionSchema` accepte `correctedTechStepName` (mutuellement exclusif de `correctedTechStepId`) ; `submitTechStepCorrection` crée une `TechStep` placeholder dans la transaction et poursuit comme si son id avait été fourni. La correction s'applique immédiatement (`source: manual`) ; nouvel event analytics `tech_step.placeholder_proposed`. `TechStepView` gagne `isPlaceholder`/`displayName` ; `GET /reference/tech-steps` exclut les placeholders. Nouveau helper web `techStepLabel` (`displayName ?? t(catalog.techSteps.)`), utilisé par `StepDescription` et le popover. Le popover a une échappatoire « Ma technique n'est pas dans la liste » (champ libre). Admin : `admin-tech-step-catalog.service.ts` + routes `GET/PATCH /admin/api/catalog/tech-step-placeholders` (liste plate — les propositions de technique sont rares) ; `CatalogPage` gagne un sélecteur Ingrédients / Techniques. Tests : Mocha (proposition placeholder, rejet id+nom, exclusion catalogue, liste + mark-reviewed admin) + Cypress (composant popover, e2e admin catalogue). Assertions `.techStep` existantes mises à jour pour les 2 nouveaux champs. Co-Authored-By: Claude Sonnet 5 --- .../migration.sql | 16 ++ apps/api/prisma/schema.prisma | 33 +++ .../src/modules/admin/admin-catalog.routes.ts | 44 +++- .../admin/admin-tech-step-catalog.service.ts | 87 ++++++++ .../cooking-session.service.ts | 7 +- .../recipe-tech-step-correction.service.ts | 202 +++++++++++------- apps/api/src/modules/recipe/recipe.service.ts | 7 +- .../modules/reference/reference.service.ts | 14 +- .../src/modules/sources/sources.service.ts | 15 +- apps/api/test/admin-catalog.test.ts | 115 ++++++++++ apps/api/test/recipe.test.ts | 7 +- .../recipe-tech-step-correction.test.ts | 93 +++++++- apps/api/test/reference.test.ts | 25 ++- .../TechStepCorrectionPopover.cy.tsx | 35 +++ apps/web/cypress/e2e/admin-catalog.cy.ts | 39 ++++ apps/web/src/api/admin-client.ts | 17 ++ apps/web/src/features/recipes/recipes.scss | 20 ++ .../recipes/steps/StepDescription.tsx | 10 +- .../steps/TechStepCorrectionPopover.tsx | 104 +++++++-- .../features/recipes/steps/tech-step-label.ts | 20 ++ apps/web/src/locales/fr/translation.json | 13 +- .../src/pages/admin/catalog/CatalogPage.tsx | 183 +++++++++++++--- .../src/pages/admin/catalog/catalog-page.scss | 28 +++ packages/shared/src/schemas/admin.ts | 20 ++ packages/shared/src/schemas/recipe.ts | 30 ++- packages/shared/src/types/admin.ts | 22 ++ packages/shared/src/types/reference.ts | 15 +- 27 files changed, 1061 insertions(+), 160 deletions(-) create mode 100644 apps/api/prisma/migrations/20260909140000_tech_step_placeholder/migration.sql create mode 100644 apps/api/src/modules/admin/admin-tech-step-catalog.service.ts create mode 100644 apps/web/src/features/recipes/steps/tech-step-label.ts diff --git a/apps/api/prisma/migrations/20260909140000_tech_step_placeholder/migration.sql b/apps/api/prisma/migrations/20260909140000_tech_step_placeholder/migration.sql new file mode 100644 index 0000000..ac5257a --- /dev/null +++ b/apps/api/prisma/migrations/20260909140000_tech_step_placeholder/migration.sql @@ -0,0 +1,16 @@ +-- AlterTable: off-catalog technique "placeholder" rows (issue #130, puce 3 +-- de #3) — a user names a technique the catalog is missing while correcting a +-- step. Every existing row is a real seeded catalog entry, so the flag +-- defaults to false and the four new nullable columns stay NULL for them — +-- no backfill needed. Mirrors the `ingredients` placeholder migration. +ALTER TABLE "tech_step" ADD COLUMN "is_placeholder" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "tech_step" ADD COLUMN "display_name" TEXT; +ALTER TABLE "tech_step" ADD COLUMN "created_by_id" INTEGER; +ALTER TABLE "tech_step" ADD COLUMN "created_at" TIMESTAMP(3); +ALTER TABLE "tech_step" ADD COLUMN "reviewed_at" TIMESTAMP(3); + +-- CreateIndex +CREATE INDEX "tech_step_is_placeholder_idx" ON "tech_step"("is_placeholder"); + +-- AddForeignKey +ALTER TABLE "tech_step" ADD CONSTRAINT "tech_step_created_by_id_fkey" FOREIGN KEY ("created_by_id") REFERENCES "user_profiles"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index eb943f4..113b0d1 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -134,6 +134,9 @@ model UserProfile { /// Placeholder `Ingredient` rows this profile created by typing a free-text /// ingredient the catalog didn't cover — see `Ingredient.isPlaceholder`. createdIngredientPlaceholders Ingredient[] @relation("PlaceholderCreator") + /// Placeholder `TechStep` rows this profile proposed while correcting a step + /// — see `TechStep.isPlaceholder`. + createdTechStepPlaceholders TechStep[] @relation("TechStepPlaceholderCreator") /// Contact-form messages this profile has sent — see {@link ContactMessage}. contactMessages ContactMessage[] @@ -718,6 +721,33 @@ model TechStep { id Int @id @default(autoincrement()) key String @unique + /// `true` = a "placeholder" technique: a free-text technique a user typed + /// while correcting a step, because nothing in the seeded catalog matched + /// (issue #131 for the ingredient precedent this mirrors — see + /// `Ingredient.isPlaceholder`). Such a row has `displayName` non-null, a + /// generated `key` (`placeholder:`, never an i18n label), no + /// training data of its own, and is excluded from + /// `GET /reference/tech-steps`. It is still referenced by `StepTechStep` + /// like any other technique (the correction that created it applies + /// immediately), but a maintainer reviews it at + /// `/admin/catalogue` (Techniques tab) and promotes it into the real + /// catalog by hand (`reference-seed-data.ts` + `tech-step-training-data.ts`). + isPlaceholder Boolean @default(false) @map("is_placeholder") + /// The exact text the user typed — `null` for a real catalog row (whose + /// label lives in i18n under `catalog.techSteps.`). Non-null iff + /// `isPlaceholder`, enforced service-side. + displayName String? @map("display_name") + /// Profile that first proposed this technique — context for the admin + /// review. `onDelete: SetNull` so deleting an account never blocks on the + /// recipes still using its placeholder. `null` for a real catalog row. + createdById Int? @map("created_by_id") + /// When this placeholder was proposed. `null` for a real catalog row. + createdAt DateTime? @map("created_at") + /// Stamped once a maintainer has triaged this proposal + /// (`PATCH /admin/api/catalog/tech-step-placeholders/mark-reviewed`). + /// `null` while pending / for a real catalog row. + reviewedAt DateTime? @map("reviewed_at") + steps StepTechStep[] /// Corrections where this technique was the *previous* (possibly wrong) /// match — see `StepTechStepCorrection.previousTechStepId`. @@ -728,7 +758,10 @@ model TechStep { /// Training-corpus suggestions targeting this technique — see /// `TechStepTrainingSuggestion`. trainingSuggestions TechStepTrainingSuggestion[] + /// The profile that proposed this row when it is a placeholder — see `createdById`. + createdBy UserProfile? @relation("TechStepPlaceholderCreator", fields: [createdById], references: [id], onDelete: SetNull) + @@index([isPlaceholder]) @@map("tech_step") } diff --git a/apps/api/src/modules/admin/admin-catalog.routes.ts b/apps/api/src/modules/admin/admin-catalog.routes.ts index 6376be7..fbda12c 100644 --- a/apps/api/src/modules/admin/admin-catalog.routes.ts +++ b/apps/api/src/modules/admin/admin-catalog.routes.ts @@ -1,5 +1,10 @@ import { wrapAsyncHandler } from "@batch-cooking/express-tools"; -import { listPlaceholdersQuerySchema, markPlaceholdersReviewedSchema } from "@batch-cooking/shared"; +import { + listPlaceholdersQuerySchema, + listTechStepPlaceholdersQuerySchema, + markPlaceholdersReviewedSchema, + markTechStepPlaceholdersReviewedSchema, +} from "@batch-cooking/shared"; import { Router } from "express"; import { requireAdmin } from "../../middlewares/require-admin.js"; import { @@ -7,12 +12,17 @@ import { markPlaceholdersReviewed, pruneOrphanPlaceholders, } from "./admin-catalog.service.js"; +import { + listTechStepPlaceholders, + markTechStepPlaceholdersReviewed, +} from "./admin-tech-step-catalog.service.js"; /** * Router mounted at `/admin/api/catalog` (via `admin.routes.ts`) — every route - * behind {@link requireAdmin}. Surfaces the off-catalog ingredient - * "placeholders" users typed when the seeded catalog fell short, so a - * maintainer can see what's missing and mark gaps as handled. + * behind {@link requireAdmin}. Surfaces the off-catalog "placeholders" users + * typed when the seeded catalog fell short — free-text ingredients on a + * recipe line, and free-text techniques while correcting a step (issue #130) + * — so a maintainer can see what's missing and mark gaps as handled. */ export const adminCatalogRouter = Router(); @@ -42,3 +52,29 @@ adminCatalogRouter.post( res.status(200).json(await pruneOrphanPlaceholders()); }), ); + +// --- Missing techniques (issue #130) -------------------------------------- + +adminCatalogRouter.get( + "/tech-step-placeholders", + requireAdmin, + wrapAsyncHandler(async (req, res) => { + res + .status(200) + .json(await listTechStepPlaceholders(listTechStepPlaceholdersQuerySchema.parse(req.query))); + }), +); + +adminCatalogRouter.patch( + "/tech-step-placeholders/mark-reviewed", + requireAdmin, + wrapAsyncHandler(async (req, res) => { + res + .status(200) + .json( + await markTechStepPlaceholdersReviewed( + markTechStepPlaceholdersReviewedSchema.parse(req.body), + ), + ); + }), +); diff --git a/apps/api/src/modules/admin/admin-tech-step-catalog.service.ts b/apps/api/src/modules/admin/admin-tech-step-catalog.service.ts new file mode 100644 index 0000000..0c22969 --- /dev/null +++ b/apps/api/src/modules/admin/admin-tech-step-catalog.service.ts @@ -0,0 +1,87 @@ +import type { + ListTechStepPlaceholdersQuery, + MarkTechStepPlaceholdersReviewedInput, + TechStepPlaceholderView, +} from "@batch-cooking/shared"; +import { prisma } from "../../db/prisma.js"; + +/** + * The missing-technique review (issue #130) — the tech-step counterpart of + * `admin-catalog.service.ts`'s off-catalog *ingredient* review. Kept a flat + * list rather than grouping near-duplicate spellings the way that one does: + * correcting a technique — let alone typing one the catalog is missing — is + * rare enough that a handful of raw rows is all a maintainer ever sees here. + */ + +/** Prisma `include` for the placeholder query — a bounded set of the `StepTechStep` rows pointing at this technique, each resolved to just its recipe id/name for the count + "seen in…" preview. */ +const placeholderInclude = { + steps: { + take: 50, + include: { step: { select: { recipe: { select: { id: true, name: true } } } } }, + }, +} as const; + +/** + * Every placeholder `TechStep` (see `TechStep.isPlaceholder` in + * schema.prisma), most-impactful first (most distinct recipes, then name). + * + * `query.reviewed` selects which side to show: omitted / `"false"` drops + * rows already triaged (`reviewedAt` set) — the default working list; + * `"true"` keeps only those. + */ +export async function listTechStepPlaceholders( + query: ListTechStepPlaceholdersQuery, +): Promise { + try { + const rows = await prisma.techStep.findMany({ + where: { isPlaceholder: true }, + include: placeholderInclude, + orderBy: { createdAt: "asc" }, + }); + + const wantReviewed = query.reviewed === "true"; + return rows + .filter((row) => (row.reviewedAt !== null) === wantReviewed) + .map((row) => { + const recipeById = new Map(); + for (const link of row.steps) { + recipeById.set(link.step.recipe.id, link.step.recipe.name); + } + return { + id: row.id, + displayName: row.displayName ?? "", + createdAt: row.createdAt?.toISOString() ?? null, + reviewedAt: row.reviewedAt?.toISOString() ?? null, + recipeCount: recipeById.size, + sampleRecipes: [...recipeById.entries()].slice(0, 5).map(([id, name]) => ({ id, name })), + }; + }) + .sort( + (a, b) => b.recipeCount - a.recipeCount || a.displayName.localeCompare(b.displayName, "fr"), + ); + } catch (err) { + throw err; // see recipe.service.ts's equivalent catch comment + } +} + +/** + * Stamps `reviewedAt` on the given placeholder `TechStep` ids — a + * maintainer has seen this proposal (and, if it warranted it, promoted it + * into `reference-seed-data.ts` + `tech-step-training-data.ts` by hand; this + * endpoint never touches the catalog itself). Scoped to + * `isPlaceholder: true` so a stray real id is a silent no-op. Returns how + * many rows were actually stamped. + */ +export async function markTechStepPlaceholdersReviewed( + input: MarkTechStepPlaceholdersReviewedInput, +): Promise<{ reviewed: number }> { + try { + const { count } = await prisma.techStep.updateMany({ + where: { id: { in: input.techStepIds }, isPlaceholder: true }, + data: { reviewedAt: new Date() }, + }); + return { reviewed: count }; + } catch (err) { + throw err; // see recipe.service.ts's equivalent catch comment + } +} diff --git a/apps/api/src/modules/cooking-session/cooking-session.service.ts b/apps/api/src/modules/cooking-session/cooking-session.service.ts index 9cc170e..5e99879 100644 --- a/apps/api/src/modules/cooking-session/cooking-session.service.ts +++ b/apps/api/src/modules/cooking-session/cooking-session.service.ts @@ -89,7 +89,12 @@ function toOptimizerRecipe(item: PlanningItemWithSteps): OptimizerRecipeInput { unit: line.unit === null ? null : toUnitView(line.unit), })); return { - techStep: { id: techStep.techStep.id, key: techStep.techStep.key }, + techStep: { + id: techStep.techStep.id, + key: techStep.techStep.key, + isPlaceholder: techStep.techStep.isPlaceholder, + displayName: techStep.techStep.displayName, + }, order: techStep.order, ingredients, utensils: techStep.utensils.map(({ utensil }) => ({ id: utensil.id, key: utensil.key })), diff --git a/apps/api/src/modules/recipe/recipe-tech-step-correction.service.ts b/apps/api/src/modules/recipe/recipe-tech-step-correction.service.ts index a9e4319..caeb186 100644 --- a/apps/api/src/modules/recipe/recipe-tech-step-correction.service.ts +++ b/apps/api/src/modules/recipe/recipe-tech-step-correction.service.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import { HttpError } from "@batch-cooking/error-tools"; import { ErrorCode, @@ -380,10 +381,20 @@ function toCorrectionView(correction: CorrectionWithTechSteps): StepTechStepCorr start: correction.start, end: correction.end, previousTechStep: correction.previousTechStep - ? { id: correction.previousTechStep.id, key: correction.previousTechStep.key } + ? { + id: correction.previousTechStep.id, + key: correction.previousTechStep.key, + isPlaceholder: correction.previousTechStep.isPlaceholder, + displayName: correction.previousTechStep.displayName, + } : null, correctedTechStep: correction.correctedTechStep - ? { id: correction.correctedTechStep.id, key: correction.correctedTechStep.key } + ? { + id: correction.correctedTechStep.id, + key: correction.correctedTechStep.key, + isPlaceholder: correction.correctedTechStep.isPlaceholder, + displayName: correction.correctedTechStep.displayName, + } : null, createdAt: correction.createdAt.toISOString(), }; @@ -449,83 +460,109 @@ export async function submitTechStepCorrection( ); await assertUtensilsExist((input.utensils ?? []).map((u) => u.utensilId)); - const { correction, techSteps } = await prisma.$transaction(async (tx) => { - const createdCorrection = await tx.stepTechStepCorrection.create({ - data: { - stepId: step.id, - correctorId, - start: input.start, - end: input.end, - previousTechStepId: input.previousTechStepId ?? null, - correctedTechStepId: input.correctedTechStepId ?? null, - }, - include: correctionInclude, - }); + const { correction, techSteps, placeholderTechStepId } = await prisma.$transaction( + async (tx) => { + // A technique the catalog doesn't have (issue #130) — mint a + // placeholder `TechStep` for it, in the same transaction as the + // correction that references it, then proceed exactly as if its id had + // been sent as `correctedTechStepId`. Same shape as + // `recipe.service.ts`'s `resolveIngredientLines` does for a + // free-text ingredient. + const placeholderTechStepId = + input.correctedTechStepName !== undefined + ? ( + await tx.techStep.create({ + data: { + key: `placeholder:${randomUUID()}`, + isPlaceholder: true, + displayName: input.correctedTechStepName, + createdById: correctorId, + createdAt: new Date(), + }, + select: { id: true }, + }) + ).id + : null; + const effectiveCorrectedTechStepId = + placeholderTechStepId ?? input.correctedTechStepId ?? null; - await applyManualCorrection( - tx, - step.id, - { start: input.start, end: input.end }, - input.previousTechStepId ?? null, - input.correctedTechStepId ?? null, - input.ingredients === undefined && - input.utensils === undefined && - input.temperatures === undefined - ? undefined - : { - ingredients: (input.ingredients ?? []).map((ingredient) => ({ - ingredientId: ingredient.ingredientId, - quantity: ingredient.quantity ?? null, - unitId: ingredient.unitId ?? null, - start: ingredient.start, - end: ingredient.end, - })), - utensils: (input.utensils ?? []).map((utensil) => ({ - utensilId: utensil.utensilId, - start: utensil.start, - end: utensil.end, - })), - // `raw` is derived here (not sent by the client) — see - // `ManualTemperatureMention`'s own doc comment. - temperatures: (input.temperatures ?? []).map((temperature) => ({ - celsius: temperature.celsius ?? null, - gasMark: temperature.gasMark ?? null, - qualitative: temperature.qualitative ?? null, - raw: step.description.slice(temperature.start, temperature.end), - start: temperature.start, - end: temperature.end, - })), - }, - ); - - // Same nested `ingredients`/`utensils`/`temperatures` include as - // `recipe.service.ts`'s `recipeInclude` — `toStepTechStepViews` - // (reused below) expects it, so the fresh sequence read right after a - // manual correction resolves exactly the same way a normal - // `GET /recipes/:id` would. - const freshTechSteps = await tx.stepTechStep.findMany({ - where: { stepId: step.id }, - orderBy: { order: "asc" }, - include: { - techStep: true, - ingredients: { - include: { - ingredient: { - include: { - allergies: { include: { allergy: { include: { category: true } } } }, - diets: { include: { diet: true } }, - }, - }, - unit: true, - }, + const createdCorrection = await tx.stepTechStepCorrection.create({ + data: { + stepId: step.id, + correctorId, + start: input.start, + end: input.end, + previousTechStepId: input.previousTechStepId ?? null, + correctedTechStepId: effectiveCorrectedTechStepId, }, - utensils: { include: { utensil: true } }, - temperatures: true, - }, - }); + include: correctionInclude, + }); - return { correction: createdCorrection, techSteps: freshTechSteps }; - }); + await applyManualCorrection( + tx, + step.id, + { start: input.start, end: input.end }, + input.previousTechStepId ?? null, + effectiveCorrectedTechStepId, + input.ingredients === undefined && + input.utensils === undefined && + input.temperatures === undefined + ? undefined + : { + ingredients: (input.ingredients ?? []).map((ingredient) => ({ + ingredientId: ingredient.ingredientId, + quantity: ingredient.quantity ?? null, + unitId: ingredient.unitId ?? null, + start: ingredient.start, + end: ingredient.end, + })), + utensils: (input.utensils ?? []).map((utensil) => ({ + utensilId: utensil.utensilId, + start: utensil.start, + end: utensil.end, + })), + // `raw` is derived here (not sent by the client) — see + // `ManualTemperatureMention`'s own doc comment. + temperatures: (input.temperatures ?? []).map((temperature) => ({ + celsius: temperature.celsius ?? null, + gasMark: temperature.gasMark ?? null, + qualitative: temperature.qualitative ?? null, + raw: step.description.slice(temperature.start, temperature.end), + start: temperature.start, + end: temperature.end, + })), + }, + ); + + // Same nested `ingredients`/`utensils`/`temperatures` include as + // `recipe.service.ts`'s `recipeInclude` — `toStepTechStepViews` + // (reused below) expects it, so the fresh sequence read right after a + // manual correction resolves exactly the same way a normal + // `GET /recipes/:id` would. + const freshTechSteps = await tx.stepTechStep.findMany({ + where: { stepId: step.id }, + orderBy: { order: "asc" }, + include: { + techStep: true, + ingredients: { + include: { + ingredient: { + include: { + allergies: { include: { allergy: { include: { category: true } } } }, + diets: { include: { diet: true } }, + }, + }, + unit: true, + }, + }, + utensils: { include: { utensil: true } }, + temperatures: true, + }, + }); + + return { correction: createdCorrection, techSteps: freshTechSteps, placeholderTechStepId }; + }, + ); analytics.recordEvent("tech_step.correction_submitted", { actorId: correctorId, @@ -533,9 +570,20 @@ export async function submitTechStepCorrection( recipeId, stepId, previousTechStepId: input.previousTechStepId ?? null, - correctedTechStepId: input.correctedTechStepId ?? null, + correctedTechStepId: correction.correctedTechStepId, }, }); + if (placeholderTechStepId !== null) { + analytics.recordEvent("tech_step.placeholder_proposed", { + actorId: correctorId, + context: { + recipeId, + stepId, + techStepId: placeholderTechStepId, + name: input.correctedTechStepName ?? "", + }, + }); + } return { correction: toCorrectionView(correction), techSteps: toStepTechStepViews(techSteps) }; } catch (err) { diff --git a/apps/api/src/modules/recipe/recipe.service.ts b/apps/api/src/modules/recipe/recipe.service.ts index 436ca01..7c470f5 100644 --- a/apps/api/src/modules/recipe/recipe.service.ts +++ b/apps/api/src/modules/recipe/recipe.service.ts @@ -211,7 +211,12 @@ export function toStepTechStepViews( } = stepTechStep; if (start === null || end === null) continue; views.push({ - techStep: { id: techStep.id, key: techStep.key }, + techStep: { + id: techStep.id, + key: techStep.key, + isPlaceholder: techStep.isPlaceholder, + displayName: techStep.displayName, + }, start, end, // `source` is a plain DB `String`, not a Prisma enum (see diff --git a/apps/api/src/modules/reference/reference.service.ts b/apps/api/src/modules/reference/reference.service.ts index 1dcea17..976191d 100644 --- a/apps/api/src/modules/reference/reference.service.ts +++ b/apps/api/src/modules/reference/reference.service.ts @@ -77,11 +77,21 @@ export async function getUnits(): Promise { /** * All reference cooking techniques, ordered by key (see {@link getDiets} * for why) — small, static list (see `reference-seed-data.ts`'s - * `TECH_STEPS`). Not consumed by the recipe UI yet — see {@link TechStepView}. + * `TECH_STEPS`). + * + * Excludes placeholder rows (`TechStep.isPlaceholder` — the free-text + * techniques users propose when the catalog falls short, issue #130): this + * is the *browsable* catalog, and a placeholder is a per-correction + * stand-in, not a real entry anyone should pick again. */ export async function getTechSteps(): Promise { try { - return await prisma.techStep.findMany({ orderBy: { key: "asc" } }); + const techSteps = await prisma.techStep.findMany({ + where: { isPlaceholder: false }, + select: { id: true, key: true }, + orderBy: { key: "asc" }, + }); + return techSteps.map((techStep) => ({ ...techStep, isPlaceholder: false, displayName: null })); } catch (err) { throw err; // see getDiets()'s catch comment above } diff --git a/apps/api/src/modules/sources/sources.service.ts b/apps/api/src/modules/sources/sources.service.ts index bd2d803..016030a 100644 --- a/apps/api/src/modules/sources/sources.service.ts +++ b/apps/api/src/modules/sources/sources.service.ts @@ -180,9 +180,20 @@ export async function previewSourceItem( ), loadIngredientCatalog(adapter.locale), loadUnitCatalog(adapter.locale), - prisma.techStep.findMany({ select: { id: true, key: true } }), + // Real catalog techniques only — a draft preview is never a saved + // recipe, so it can't carry a user-proposed placeholder technique + // (issue #130); `isPlaceholder`/`displayName` are the neutral values. + prisma.techStep.findMany({ + where: { isPlaceholder: false }, + select: { id: true, key: true }, + }), ]); - const techStepById = new Map(techStepsByKey.map((techStep) => [techStep.id, techStep])); + const techStepById = new Map( + techStepsByKey.map((techStep) => [ + techStep.id, + { ...techStep, isPlaceholder: false, displayName: null }, + ]), + ); const translatedIngredients = translateRecipeIngredients( parsed.ingredients, diff --git a/apps/api/test/admin-catalog.test.ts b/apps/api/test/admin-catalog.test.ts index b1c79d6..a539b81 100644 --- a/apps/api/test/admin-catalog.test.ts +++ b/apps/api/test/admin-catalog.test.ts @@ -139,3 +139,118 @@ describe("Admin catalog — off-catalog ingredients", () => { expect(await prisma.ingredient.count({ where: { isPlaceholder: true } })).to.equal(1); }); }); + +/** + * `/admin/api/catalog/tech-step-placeholders` — the missing-technique review + * (issue #130). Flat list, `mark-reviewed` stamps `reviewedAt`; every route + * behind `requireAdmin`. + */ +describe("Admin catalog — missing techniques", () => { + const app = createApp(); + + beforeEach(async () => { + await resetDatabase(); + }); + + after(async () => { + await prisma.$disconnect(); + }); + + async function adminAgent() { + const { email, password } = await seedAdmin(); + const agent = request.agent(app); + await agent.post("/admin/api/auth/login").send({ email, password }); + return agent; + } + + /** A `PUBLIC` recipe whose single step carries a placeholder technique named `name`. Returns the placeholder `TechStep` id. */ + async function seedPlaceholderTechnique(name: string): Promise { + const author = await prisma.userProfile.create({ + data: { + firstName: "T", + lastName: "A", + email: `${faker.string.uuid()}@example.test`, + passwordHash: "x", + }, + }); + const techStep = await prisma.techStep.create({ + data: { + key: `placeholder:${faker.string.uuid()}`, + isPlaceholder: true, + displayName: name, + createdById: author.id, + createdAt: new Date(), + }, + }); + await prisma.recipe.create({ + data: { + name: faker.lorem.words(3), + authorId: author.id, + portions: 2, + visibility: "PUBLIC", + steps: { + create: [ + { + description: "Une étape.", + order: 0, + techSteps: { + create: [{ techStepId: techStep.id, order: 0, start: 0, end: 4, source: "manual" }], + }, + }, + ], + }, + }, + }); + return techStep.id; + } + + it("rejects both routes without an admin session", async () => { + expect((await request(app).get("/admin/api/catalog/tech-step-placeholders")).status).to.equal( + 401, + ); + expect( + ( + await request(app) + .patch("/admin/api/catalog/tech-step-placeholders/mark-reviewed") + .send({ techStepIds: [1] }) + ).status, + ).to.equal(401); + }); + + it("lists proposed techniques with their recipe count, impact-ordered", async () => { + await seedPlaceholderTechnique("Flamber au chalumeau"); + await seedPlaceholderTechnique("Confire à basse température"); + + const agent = await adminAgent(); + const res = await agent.get("/admin/api/catalog/tech-step-placeholders"); + + expect(res.status).to.equal(200); + expect(res.body).to.have.length(2); + expect(res.body.map((r: { displayName: string }) => r.displayName)).to.have.members([ + "Flamber au chalumeau", + "Confire à basse température", + ]); + expect(res.body[0].recipeCount).to.equal(1); + expect(res.body[0].reviewedAt).to.equal(null); + }); + + it("mark-reviewed stamps reviewedAt and moves the row to the reviewed list", async () => { + const id = await seedPlaceholderTechnique("Snacker à la plancha"); + const agent = await adminAgent(); + + const patched = await agent + .patch("/admin/api/catalog/tech-step-placeholders/mark-reviewed") + .send({ techStepIds: [id] }); + expect(patched.status).to.equal(200); + expect(patched.body.reviewed).to.equal(1); + expect( + (await prisma.techStep.findUniqueOrThrow({ where: { id } })).reviewedAt, + ).to.be.an.instanceOf(Date); + + expect((await agent.get("/admin/api/catalog/tech-step-placeholders")).body).to.have.length(0); + const reviewed = await agent + .get("/admin/api/catalog/tech-step-placeholders") + .query({ reviewed: "true" }); + expect(reviewed.body).to.have.length(1); + }); +}); diff --git a/apps/api/test/recipe.test.ts b/apps/api/test/recipe.test.ts index 57ac0b2..4f78ed8 100644 --- a/apps/api/test/recipe.test.ts +++ b/apps/api/test/recipe.test.ts @@ -469,7 +469,12 @@ describe("Recipes", () => { // response itself carries exactly what to highlight, not just the id. const resStep = res.body.steps[0]; expect(resStep.techSteps).to.have.length(1); - expect(resStep.techSteps[0].techStep).to.deep.equal({ id: simmer, key: "simmer" }); + expect(resStep.techSteps[0].techStep).to.deep.equal({ + id: simmer, + key: "simmer", + isPlaceholder: false, + displayName: null, + }); const { start, end } = resStep.techSteps[0]; expect(description.slice(start, end).toLowerCase()).to.equal("mijoter"); }); diff --git a/apps/api/test/recipe/recipe-tech-step-correction.test.ts b/apps/api/test/recipe/recipe-tech-step-correction.test.ts index 757b2c3..e93db21 100644 --- a/apps/api/test/recipe/recipe-tech-step-correction.test.ts +++ b/apps/api/test/recipe/recipe-tech-step-correction.test.ts @@ -109,7 +109,12 @@ describe("Recipe tech-step corrections", () => { expect(res.status).to.equal(201); expect(res.body.correction.previousTechStep).to.equal(null); - expect(res.body.correction.correctedTechStep).to.deep.equal({ id: simmerId, key: "simmer" }); + expect(res.body.correction.correctedTechStep).to.deep.equal({ + id: simmerId, + key: "simmer", + isPlaceholder: false, + displayName: null, + }); expect(res.body.correction.start).to.equal(6); expect(res.body.correction.end).to.equal(13); // The step's real technique sequence reflects the correction right @@ -117,7 +122,7 @@ describe("Recipe tech-step corrections", () => { // `applyManualCorrection`, `recipe-tech-step-correction.service.ts`). expect(res.body.techSteps).to.deep.equal([ { - techStep: { id: simmerId, key: "simmer" }, + techStep: { id: simmerId, key: "simmer", isPlaceholder: false, displayName: null }, start: 6, end: 13, source: "manual", @@ -128,6 +133,56 @@ describe("Recipe tech-step corrections", () => { ]); }); + it("proposes a placeholder technique via correctedTechStepName when the catalog is missing one (issue #130)", async () => { + const { agent, profileId } = await signup(); + const { recipeId, stepId } = await createPublicRecipeWithStep(profileId); + + const res = await agent + .post(`/recipes/${recipeId}/steps/${stepId}/corrections`) + .send({ start: 6, end: 13, correctedTechStepName: "Flamber au chalumeau" }); + + expect(res.status).to.equal(201); + // A placeholder `TechStep` was minted and the correction points at it. + expect(res.body.correction.correctedTechStep.isPlaceholder).to.equal(true); + expect(res.body.correction.correctedTechStep.displayName).to.equal("Flamber au chalumeau"); + // ...and the step's live sequence carries it straight away. + expect(res.body.techSteps).to.have.length(1); + expect(res.body.techSteps[0].techStep.isPlaceholder).to.equal(true); + expect(res.body.techSteps[0].techStep.displayName).to.equal("Flamber au chalumeau"); + expect(res.body.techSteps[0].source).to.equal("manual"); + + const row = await prisma.techStep.findUniqueOrThrow({ + where: { id: res.body.techSteps[0].techStep.id }, + }); + expect(row.isPlaceholder).to.equal(true); + expect(row.key).to.match(/^placeholder:/); + expect(row.createdById).to.equal(profileId); + expect(row.createdAt).to.be.an.instanceOf(Date); + expect(row.reviewedAt).to.equal(null); + // It never leaks into the browsable catalog. + const reference = await agent.get("/reference/tech-steps"); + expect( + reference.body.some( + (t: { displayName?: string }) => t.displayName === "Flamber au chalumeau", + ), + ).to.equal(false); + }); + + it("rejects sending both correctedTechStepId and correctedTechStepName with 400 VALIDATION_ERROR", async () => { + const { agent, profileId } = await signup(); + const { recipeId, stepId } = await createPublicRecipeWithStep(profileId); + + const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({ + start: 6, + end: 13, + correctedTechStepId: await techStepId("simmer"), + correctedTechStepName: "Autre chose", + }); + + expect(res.status).to.equal(400); + expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR); + }); + it("records a correction relabeling an existing match (both ids set), updating the existing techSteps entry in place", async () => { const { agent, profileId } = await signup(); const { recipeId, stepId } = await createPublicRecipeWithStep(profileId); @@ -145,13 +200,23 @@ describe("Recipe tech-step corrections", () => { .send({ start: 6, end: 13, previousTechStepId: simmerId, correctedTechStepId: boilId }); expect(res.status).to.equal(201); - expect(res.body.correction.previousTechStep).to.deep.equal({ id: simmerId, key: "simmer" }); - expect(res.body.correction.correctedTechStep).to.deep.equal({ id: boilId, key: "boil" }); + expect(res.body.correction.previousTechStep).to.deep.equal({ + id: simmerId, + key: "simmer", + isPlaceholder: false, + displayName: null, + }); + expect(res.body.correction.correctedTechStep).to.deep.equal({ + id: boilId, + key: "boil", + isPlaceholder: false, + displayName: null, + }); // Still exactly one entry — the relabel updated the existing row // rather than adding a second one alongside it. expect(res.body.techSteps).to.deep.equal([ { - techStep: { id: boilId, key: "boil" }, + techStep: { id: boilId, key: "boil", isPlaceholder: false, displayName: null }, start: 6, end: 13, source: "manual", @@ -299,7 +364,7 @@ describe("Recipe tech-step corrections", () => { expect(res.status).to.equal(201); expect(res.body.techSteps).to.deep.equal([ { - techStep: { id: simmerId, key: "simmer" }, + techStep: { id: simmerId, key: "simmer", isPlaceholder: false, displayName: null }, start: 6, end: 13, source: "manual", @@ -535,7 +600,7 @@ describe("Recipe tech-step corrections", () => { expect(res.status).to.equal(201); expect(res.body.techSteps).to.deep.equal([ { - techStep: { id: preheatId, key: "preheat" }, + techStep: { id: preheatId, key: "preheat", isPlaceholder: false, displayName: null }, start: 0, end: 11, source: "manual", @@ -721,8 +786,18 @@ describe("Recipe tech-step corrections", () => { expect(res.status).to.equal(200); expect(res.body).to.have.length(2); - expect(res.body[0].correctedTechStep).to.deep.equal({ id: boilId, key: "boil" }); - expect(res.body[1].correctedTechStep).to.deep.equal({ id: simmerId, key: "simmer" }); + expect(res.body[0].correctedTechStep).to.deep.equal({ + id: boilId, + key: "boil", + isPlaceholder: false, + displayName: null, + }); + expect(res.body[1].correctedTechStep).to.deep.equal({ + id: simmerId, + key: "simmer", + isPlaceholder: false, + displayName: null, + }); }); it("returns an empty list when nothing has been submitted yet", async () => { diff --git a/apps/api/test/reference.test.ts b/apps/api/test/reference.test.ts index 3d48a2f..db4b6c5 100644 --- a/apps/api/test/reference.test.ts +++ b/apps/api/test/reference.test.ts @@ -148,7 +148,30 @@ describe("Reference data", () => { // number — this catalog has grown since (26 -> 74) and will again. expect(res.body).to.have.length(TECH_STEPS.length); expect(res.body.map((t: { key: string }) => t.key)).to.include("simmer"); - expect(res.body[0]).to.have.keys(["id", "key"]); + expect(res.body[0]).to.have.keys(["id", "key", "isPlaceholder", "displayName"]); + expect(res.body.every((t: { isPlaceholder: boolean }) => t.isPlaceholder === false)).to.equal( + true, + ); + }); + + it("excludes placeholder techniques users proposed while correcting a step (issue #130)", async () => { + await prisma.techStep.create({ + data: { + key: "placeholder:test-1", + isPlaceholder: true, + displayName: "Flamber au chalumeau", + }, + }); + + const res = await request(app).get("/reference/tech-steps"); + + expect(res.status).to.equal(200); + expect(res.body).to.have.length(TECH_STEPS.length); + expect( + res.body.some( + (t: { displayName: string | null }) => t.displayName === "Flamber au chalumeau", + ), + ).to.equal(false); }); it("orders techniques alphabetically by key", async () => { diff --git a/apps/web/cypress/component/TechStepCorrectionPopover.cy.tsx b/apps/web/cypress/component/TechStepCorrectionPopover.cy.tsx index ee7a14f..e4f5015 100644 --- a/apps/web/cypress/component/TechStepCorrectionPopover.cy.tsx +++ b/apps/web/cypress/component/TechStepCorrectionPopover.cy.tsx @@ -194,6 +194,41 @@ describe("TechStepCorrectionPopover", () => { cy.get("@onSubmitted").should("have.been.calledOnce"); }); + it("proposes a technique the catalog is missing via the 'pas dans la liste' escape hatch (issue #130)", () => { + cy.intercept("POST", "**/recipes/2/steps/2/corrections", { + statusCode: 201, + body: { + id: 1, + start: 0, + end: 5, + previousTechStep: null, + correctedTechStep: { + id: 99, + key: "placeholder:x", + isPlaceholder: true, + displayName: "Flamber au chalumeau", + }, + createdAt: new Date().toISOString(), + }, + }).as("submitCorrection"); + cy.mount(); + cy.wait("@getTechSteps"); + + cy.contains("button", "Ma technique n'est pas dans la liste").click(); + cy.get(".tech-step-correction-popover__missing-technique input").type("Flamber au chalumeau"); + cy.contains(".tech-step-correction-popover__missing-technique button", "Ajouter").click(); + + cy.contains(".tech-step-correction-popover__chosen-technique", "Flamber au chalumeau"); + cy.contains("button", "Valider").should("not.be.disabled").click(); + + cy.wait("@submitCorrection").its("request.body").should("deep.equal", { + start: 0, + end: 5, + previousTechStepId: null, + correctedTechStepName: "Flamber au chalumeau", + }); + }); + it("adds an ingredient with quantity/unit via the span-selection flow, included in the submitted request", () => { cy.intercept("POST", "**/recipes/2/steps/2/corrections", { statusCode: 201, diff --git a/apps/web/cypress/e2e/admin-catalog.cy.ts b/apps/web/cypress/e2e/admin-catalog.cy.ts index cb1ed04..644b69c 100644 --- a/apps/web/cypress/e2e/admin-catalog.cy.ts +++ b/apps/web/cypress/e2e/admin-catalog.cy.ts @@ -78,6 +78,45 @@ describe("Admin catalog — off-catalog ingredients", () => { cy.get("@getPlaceholders.all").should("have.length.greaterThan", 1); }); + it("switches to the Techniques kind and lists proposed missing techniques (issue #130)", () => { + cy.intercept("GET", "**/admin/api/catalog/placeholders*", { statusCode: 200, body: [] }); + cy.intercept("GET", "**/admin/api/catalog/tech-step-placeholders*", { + statusCode: 200, + body: [ + { + id: 5, + displayName: "Flamber au chalumeau", + createdAt: "2026-09-01T10:00:00.000Z", + reviewedAt: null, + recipeCount: 2, + sampleRecipes: [ + { id: 1, name: "Crème brûlée" }, + { id: 2, name: "Tarte flambée" }, + ], + }, + ], + }).as("getTechStepPlaceholders"); + cy.intercept("PATCH", "**/admin/api/catalog/tech-step-placeholders/mark-reviewed", { + statusCode: 200, + body: { reviewed: 1 }, + }).as("markTechStepReviewed"); + + cy.visit("/admin/catalogue"); + cy.contains(".catalog-kinds button", "Techniques").click(); + cy.wait("@getTechStepPlaceholders"); + + cy.contains(".catalog-card", "Flamber au chalumeau") + .should("contain.text", "2 recette") + .and("contain.text", "Crème brûlée"); + + cy.contains(".catalog-card", "Flamber au chalumeau") + .contains("button", "Marquer comme traité") + .click(); + cy.wait("@markTechStepReviewed") + .its("request.body") + .should("deep.equal", { techStepIds: [5] }); + }); + it("switches to the reviewed archive tab", () => { // Regex, not a glob: the two calls differ only by the `?reviewed=true` // query, and minimatch's `?` is itself a wildcard — a glob can't tell diff --git a/apps/web/src/api/admin-client.ts b/apps/web/src/api/admin-client.ts index 86acb03..1b51bae 100644 --- a/apps/web/src/api/admin-client.ts +++ b/apps/web/src/api/admin-client.ts @@ -11,10 +11,12 @@ import { ErrorCode, type ForgejoIntegrationStatus, type MarkPlaceholdersReviewedInput, + type MarkTechStepPlaceholdersReviewedInput, type MetricsView, type PruneOrphansResultView, type RetrainRequestInput, type RetrainResultView, + type TechStepPlaceholderView, type TourTargetView, type TrainingDataSnippetView, type TrainingSuggestionAdminView, @@ -195,6 +197,21 @@ export class AdminApiClient { return this._request("/catalog/placeholders/prune-orphans", { method: "POST" }); } + /** Free-text techniques users proposed while correcting a step, when the catalog was missing one (issue #130). Same `reviewed` tri-state as {@link getPlaceholders}. */ + public getTechStepPlaceholders(reviewed?: "true" | "false"): Promise { + return this._request(`/catalog/tech-step-placeholders${query({ reviewed })}`); + } + + /** Marks the given placeholder technique ids as triaged (`reviewedAt`). */ + public markTechStepPlaceholdersReviewed( + body: MarkTechStepPlaceholdersReviewedInput, + ): Promise<{ reviewed: number }> { + return this._request("/catalog/tech-step-placeholders/mark-reviewed", { + method: "PATCH", + body: JSON.stringify(body), + }); + } + /** Every guided-tour scenario — enabled *and* disabled — with its ordered steps (issue #44). */ public getTourScenarios(): Promise { return this._request("/tours"); diff --git a/apps/web/src/features/recipes/recipes.scss b/apps/web/src/features/recipes/recipes.scss index ccbb704..74bf2ef 100644 --- a/apps/web/src/features/recipes/recipes.scss +++ b/apps/web/src/features/recipes/recipes.scss @@ -889,6 +889,26 @@ font-weight: 600; cursor: pointer; padding: 0; + align-self: flex-start; + } + + // "Ma technique n'est pas dans la liste" sub-form (issue #130) — a name + // field + a small confirm, shown in place of the "not in list" link once + // it's clicked. + &__missing-technique { + display: flex; + gap: var(--space-xs); + + input { + flex: 1; + min-width: 0; + padding: var(--space-xs) var(--space-sm); + border: 1px solid var(--color-border); + border-radius: var(--radius-base); + background: var(--color-surface); + color: var(--color-text); + font: inherit; + } } &__remove-match { diff --git a/apps/web/src/features/recipes/steps/StepDescription.tsx b/apps/web/src/features/recipes/steps/StepDescription.tsx index eb79903..0ba66bf 100644 --- a/apps/web/src/features/recipes/steps/StepDescription.tsx +++ b/apps/web/src/features/recipes/steps/StepDescription.tsx @@ -9,6 +9,7 @@ import { Tooltip } from "../../../components/ui/Tooltip"; import { useTour } from "../../tour/TourContext"; import { splitDescriptionSegments } from "./highlight-tech-steps"; import { TechStepCorrectionPopover } from "./TechStepCorrectionPopover"; +import { techStepLabel } from "./tech-step-label"; import { temperatureLabel } from "./temperature-label"; import { type TextSelectionRange, useTextSelection } from "./use-text-selection"; @@ -318,11 +319,12 @@ export function StepDescription({ const techStep = segment.techStep; const isManual = segment.source === "manual"; + // `displayName` for a user-proposed technique the catalog is + // missing (issue #130), the i18n label otherwise. + const techLabel = techStepLabel(techStep, t); const tooltipLabel = isManual - ? t("recipes.techStepCorrection.manualTooltip", { - technique: t(`catalog.techSteps.${techStep.key}`), - }) - : t(`catalog.techSteps.${techStep.key}`); + ? t("recipes.techStepCorrection.manualTooltip", { technique: techLabel }) + : techLabel; return ( {/* A real @@ -670,19 +690,61 @@ export function TechStepCorrectionPopover({ {(currentTechniqueLabel === null || isChangingTechnique) && ( - ({ - id: techStep.id, - label: t(`catalog.techSteps.${techStep.key}`), - }))} - selectedId={selectedTechStepId ?? undefined} - onSelect={(id) => { - setSelectedTechStepId(id); - setIsChangingTechnique(false); - }} - placeholder={t("recipes.techStepCorrection.searchTechniquePlaceholder")} - emptyLabel={t("recipes.techStepCorrection.noTechniqueFound")} - /> + <> + ({ + id: techStep.id, + label: techStepLabel(techStep, t), + }))} + selectedId={selectedTechStepId ?? undefined} + onSelect={(id) => { + setSelectedTechStepId(id); + setProposedTechStepName(null); + setIsNamingMissingTechnique(false); + setIsChangingTechnique(false); + }} + placeholder={t("recipes.techStepCorrection.searchTechniquePlaceholder")} + emptyLabel={t("recipes.techStepCorrection.noTechniqueFound")} + /> + {/* Escape hatch for a technique the catalog is missing (issue + #130) — the correction still applies immediately, and the + proposed name lands in the admin catalogue-gap review. */} + {isNamingMissingTechnique ? ( +
+ setMissingTechniqueDraft(e.target.value)} + placeholder={t("recipes.techStepCorrection.missingTechniquePlaceholder")} + aria-label={t("recipes.techStepCorrection.missingTechniqueLabel")} + /> + +
+ ) : ( + + )} + )} @@ -993,7 +1055,9 @@ export function TechStepCorrectionPopover({ type="button" className="tech-step-correction-popover__confirm-button" onClick={confirm} - disabled={isSubmitting || selectedTechStepId === null} + disabled={ + isSubmitting || (selectedTechStepId === null && proposedTechStepName === null) + } > {t("recipes.techStepCorrection.confirm")} diff --git a/apps/web/src/features/recipes/steps/tech-step-label.ts b/apps/web/src/features/recipes/steps/tech-step-label.ts new file mode 100644 index 0000000..e465d98 --- /dev/null +++ b/apps/web/src/features/recipes/steps/tech-step-label.ts @@ -0,0 +1,20 @@ +import type { TechStepView } from "@batch-cooking/shared"; + +/** + * The display label for a technique — its French catalog label + * (`t(\`catalog.techSteps.${key}\`)`) for a real seeded technique, or the + * user's verbatim text for a **placeholder** (`isPlaceholder`, the free text + * a user typed while correcting a step when the catalog was missing the + * technique — issue #130, `TechStep.isPlaceholder` in schema.prisma), which + * has no i18n key at all. Same `displayName ?? t(...)` split as + * `ingredients/ingredient-label.ts`; `t` is passed in so this stays a plain + * function. + */ +export function techStepLabel( + techStep: Pick, + t: (key: string) => string, +): string { + return techStep.isPlaceholder && techStep.displayName !== null + ? techStep.displayName + : t(`catalog.techSteps.${techStep.key}`); +} diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index f0f7f36..16c01fc 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -219,6 +219,10 @@ "noTechniqueSelected": "Aucune technique sélectionnée.", "searchTechniquePlaceholder": "Rechercher une technique…", "noTechniqueFound": "Aucune technique trouvée.", + "techniqueNotInList": "Ma technique n'est pas dans la liste", + "missingTechniquePlaceholder": "Nommez la technique manquante…", + "missingTechniqueLabel": "Technique manquante", + "missingTechniqueAdd": "Ajouter", "ingredientsSection": "Ingrédients", "utensilsSection": "Ustensiles", "temperatureSection": "Température", @@ -1373,9 +1377,14 @@ } }, "catalog": { - "title": "Ingrédients hors-catalogue", - "lead": "Ingrédients saisis en texte libre par les utilisateurs parce que le catalogue ne les couvrait pas. Regroupés par nom normalisé — à promouvoir dans reference-seed-data.ts + les locales, à la main.", + "title": "Éléments hors-catalogue", + "lead": "Saisis en texte libre par les utilisateurs parce que le catalogue ne les couvrait pas — à promouvoir dans reference-seed-data.ts + les locales, à la main.", "empty": "Aucun ingrédient hors-catalogue.", + "techStepEmpty": "Aucune technique proposée à revoir.", + "kind": { + "ingredients": "Ingrédients", + "techSteps": "Techniques" + }, "tab": { "pending": "À traiter", "reviewed": "Traités" diff --git a/apps/web/src/pages/admin/catalog/CatalogPage.tsx b/apps/web/src/pages/admin/catalog/CatalogPage.tsx index 9087f28..c91962b 100644 --- a/apps/web/src/pages/admin/catalog/CatalogPage.tsx +++ b/apps/web/src/pages/admin/catalog/CatalogPage.tsx @@ -1,4 +1,4 @@ -import type { CatalogPlaceholderGroupView } from "@batch-cooking/shared"; +import type { CatalogPlaceholderGroupView, TechStepPlaceholderView } from "@batch-cooking/shared"; import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { adminApiClient } from "../../../api/admin-client"; @@ -6,25 +6,82 @@ import "../admin-page.scss"; import "./catalog-page.scss"; import { type CatalogTab, formatDate, reviewedParam, splitSpellings } from "./catalog"; -type CatalogState = +type IngredientState = | { status: "loading" } | { status: "loaded"; groups: CatalogPlaceholderGroupView[] } | { status: "error" }; +type TechStepState = + | { status: "loading" } + | { status: "loaded"; rows: TechStepPlaceholderView[] } + | { status: "error" }; + +/** Which catalogue gap the page is reviewing — off-catalog ingredients, or techniques users proposed while correcting a step (issue #130). */ +type CatalogKind = "ingredients" | "techSteps"; + /** - * Off-catalog ingredient review. Lists every placeholder `Ingredient` (the - * free text users typed when the seeded catalog fell short — see - * `Ingredient.isPlaceholder` in the API schema), grouped by normalized - * name, so a maintainer sees what the catalog is missing and how many - * recipes are waiting on it. Actions are deliberately minimal: mark a gap - * as handled, or purge rows no recipe references any more. Actually adding - * the catalog entry stays a manual edit of `reference-seed-data.ts` + the - * locale files. + * Off-catalog review. Two lists a maintainer works through to see what the + * seeded catalog is missing: the free-text **ingredients** users typed on a + * recipe line (grouped by normalized name), and the free-text **techniques** + * users proposed while correcting a step (issue #130, a flat list — that's + * rarer). Actions are deliberately minimal: mark a gap as handled (and, for + * ingredients, purge rows no recipe references any more). Adding the real + * catalog entry stays a manual edit of `reference-seed-data.ts` + the locale + * files. */ export function CatalogPage() { const { t } = useTranslation(); + const [kind, setKind] = useState("ingredients"); const [tab, setTab] = useState("pending"); - const [state, setState] = useState({ status: "loading" }); + + return ( +
+

{t("admin.catalog.title")}

+

{t("admin.catalog.lead")}

+ +
+ {(["ingredients", "techSteps"] as const).map((value) => ( + + ))} +
+ +
+ {(["pending", "reviewed"] as const).map((value) => ( + + ))} +
+ + {kind === "ingredients" ? ( + + ) : ( + + )} +
+ ); +} + +/** The off-catalog ingredient list — grouped by normalized name, with a prune-orphans action. */ +function IngredientPlaceholders({ tab }: { tab: CatalogTab }) { + const { t } = useTranslation(); + const [state, setState] = useState({ status: "loading" }); const [pendingIds, setPendingIds] = useState(null); const [pruneMessage, setPruneMessage] = useState(null); const [isPruning, setIsPruning] = useState(false); @@ -78,25 +135,8 @@ export function CatalogPage() { } return ( -
-

{t("admin.catalog.title")}

-

{t("admin.catalog.lead")}

- + <>
-
- {(["pending", "reviewed"] as const).map((value) => ( - - ))} -
@@ -124,7 +164,90 @@ export function CatalogPage() { ))} ))} -
+ + ); +} + +/** The proposed-technique list (issue #130) — a flat list, each row markable as reviewed. */ +function TechStepPlaceholders({ tab }: { tab: CatalogTab }) { + const { t } = useTranslation(); + const [state, setState] = useState({ status: "loading" }); + const [busyId, setBusyId] = useState(null); + const requestSeq = useRef(0); + + const load = useCallback((forTab: CatalogTab) => { + const seq = ++requestSeq.current; + setState({ status: "loading" }); + adminApiClient + .getTechStepPlaceholders(reviewedParam(forTab)) + .then((rows) => { + if (seq === requestSeq.current) setState({ status: "loaded", rows }); + }) + .catch(() => { + if (seq === requestSeq.current) setState({ status: "error" }); + }); + }, []); + + useEffect(() => { + load(tab); + }, [tab, load]); + + function markReviewed(id: number) { + setBusyId(id); + adminApiClient + .markTechStepPlaceholdersReviewed({ techStepIds: [id] }) + .then(() => load(tab)) + .catch(() => load(tab)) + .finally(() => setBusyId(null)); + } + + if (state.status === "loading") { + return

{t("admin.common.loading")}

; + } + if (state.status === "error") { + return

{t("admin.common.loadError")}

; + } + if (state.rows.length === 0) { + return

{t("admin.catalog.techStepEmpty")}

; + } + return ( +
    + {state.rows.map((row) => { + const firstSeen = formatDate(row.createdAt); + return ( +
  • +
    +

    {row.displayName}

    + + {t("admin.catalog.recipeCount", { count: row.recipeCount })} + +
    + {row.sampleRecipes.length > 0 && ( +

    + {t("admin.catalog.seenIn")}{" "} + {row.sampleRecipes.map((recipe) => recipe.name).join(", ")} +

    + )} +
    + {firstSeen && ( + + {t("admin.catalog.firstSeen", { date: firstSeen })} + + )} + {row.reviewedAt === null && ( + + )} +
    +
  • + ); + })} +
); } diff --git a/apps/web/src/pages/admin/catalog/catalog-page.scss b/apps/web/src/pages/admin/catalog/catalog-page.scss index 43f6d94..448f6eb 100644 --- a/apps/web/src/pages/admin/catalog/catalog-page.scss +++ b/apps/web/src/pages/admin/catalog/catalog-page.scss @@ -37,9 +37,37 @@ } } +// Ingrédients / Techniques switch (issue #130) — sits above the +// pending/reviewed tabs; a bolder segmented control so the two levels read +// as distinct. +.catalog-kinds { + display: flex; + gap: var(--space-xs); + margin-bottom: var(--space-sm); + + button { + padding: var(--space-xs) var(--space-md); + font-family: var(--font-display); + font-size: var(--font-size-sm); + font-weight: 700; + letter-spacing: 0.02em; + color: var(--color-text-muted); + background: none; + border: none; + border-bottom: 2.5px solid transparent; + cursor: pointer; + + &.active { + color: var(--color-primary); + border-color: var(--color-primary); + } + } +} + .catalog-tabs { display: flex; gap: var(--space-xs); + margin-bottom: var(--space-md); button { padding: var(--space-sm) var(--space-md); diff --git a/packages/shared/src/schemas/admin.ts b/packages/shared/src/schemas/admin.ts index ac23d65..7f6bfb8 100644 --- a/packages/shared/src/schemas/admin.ts +++ b/packages/shared/src/schemas/admin.ts @@ -129,6 +129,26 @@ export const markPlaceholdersReviewedSchema = z.object({ /** Inferred TS type for {@link markPlaceholdersReviewedSchema}. */ export type MarkPlaceholdersReviewedInput = z.infer; +/** + * Query params for `GET /admin/api/catalog/tech-step-placeholders` — the + * missing technique review (issue #130). Same tri-state `reviewed` as + * {@link listPlaceholdersQuerySchema}. + */ +export const listTechStepPlaceholdersQuerySchema = z.object({ + reviewed: z.enum(["true", "false"]).optional(), +}); +/** Inferred TS type for {@link listTechStepPlaceholdersQuerySchema}. */ +export type ListTechStepPlaceholdersQuery = z.infer; + +/** Body of `PATCH /admin/api/catalog/tech-step-placeholders/mark-reviewed` — stamps `reviewedAt` on the given placeholder `TechStep` ids. */ +export const markTechStepPlaceholdersReviewedSchema = z.object({ + techStepIds: z.array(z.number().int().positive()).min(1).max(500), +}); +/** Inferred TS type for {@link markTechStepPlaceholdersReviewedSchema}. */ +export type MarkTechStepPlaceholdersReviewedInput = z.infer< + typeof markTechStepPlaceholdersReviewedSchema +>; + // --- Guided-tour scenario CRUD (`/admin/api/tours`, issue #44) ---------- /** One step of a scenario, as sent by the admin editor. `route` is `null` to stay on the previous step's route; `title`/`content` are free text. */ diff --git a/packages/shared/src/schemas/recipe.ts b/packages/shared/src/schemas/recipe.ts index 3696144..4cbbff5 100644 --- a/packages/shared/src/schemas/recipe.ts +++ b/packages/shared/src/schemas/recipe.ts @@ -235,6 +235,13 @@ const manualStepTechStepTemperatureInputSchema = z * `description` length to validate `start`/`end` against, which this shape * alone can't see. * + * `correctedTechStepName` is the escape hatch for a technique the catalog + * doesn't have at all (issue #130): free text instead of an id. The service + * creates a placeholder `TechStep` (`isPlaceholder: true`) for it and then + * proceeds exactly as if `correctedTechStepId` had named that new row. + * Mutually exclusive with `correctedTechStepId`; either one satisfies every + * "a corrected technique is required" check below. + * * `ingredients`/`utensils`/`temperatures` let the user attach metadata to * the technique they're asserting (`correctedTechStepId`), same * `source: "manual"` distinction the technique itself gets. The three @@ -260,6 +267,8 @@ export const submitTechStepCorrectionSchema = z end: z.number().int().nonnegative(), previousTechStepId: z.number().int().positive().nullable().optional(), correctedTechStepId: z.number().int().positive().nullable().optional(), + /** Free-text technique the catalog is missing — see this schema's doc comment. Mutually exclusive with `correctedTechStepId`. */ + correctedTechStepName: z.string().trim().min(1).max(80).optional(), ingredients: z.array(manualStepTechStepIngredientInputSchema).optional(), utensils: z.array(manualStepTechStepUtensilInputSchema).optional(), temperatures: z.array(manualStepTechStepTemperatureInputSchema).optional(), @@ -269,10 +278,20 @@ export const submitTechStepCorrectionSchema = z path: ["end"], }) .refine( - (input) => - (input.previousTechStepId ?? null) !== null || (input.correctedTechStepId ?? null) !== null, + (input) => input.correctedTechStepId == null || input.correctedTechStepName === undefined, { - message: "at least one of previousTechStepId/correctedTechStepId is required", + message: "provide correctedTechStepId or correctedTechStepName, not both", + path: ["correctedTechStepName"], + }, + ) + .refine( + (input) => + (input.previousTechStepId ?? null) !== null || + (input.correctedTechStepId ?? null) !== null || + input.correctedTechStepName !== undefined, + { + message: + "at least one of previousTechStepId/correctedTechStepId/correctedTechStepName is required", path: ["correctedTechStepId"], }, ) @@ -281,9 +300,10 @@ export const submitTechStepCorrectionSchema = z (input.ingredients === undefined && input.utensils === undefined && input.temperatures === undefined) || - (input.correctedTechStepId ?? null) !== null, + (input.correctedTechStepId ?? null) !== null || + input.correctedTechStepName !== undefined, { - message: "ingredients/utensils/temperatures require a correctedTechStepId to attach to", + message: "ingredients/utensils/temperatures require a corrected technique to attach to", path: ["correctedTechStepId"], }, ); diff --git a/packages/shared/src/types/admin.ts b/packages/shared/src/types/admin.ts index 9c25095..978b583 100644 --- a/packages/shared/src/types/admin.ts +++ b/packages/shared/src/types/admin.ts @@ -228,3 +228,25 @@ export interface CatalogPlaceholderGroupView { export interface PruneOrphansResultView { deleted: number; } + +/** + * One row of `GET /admin/api/catalog/tech-step-placeholders` — a placeholder + * `TechStep` (see `TechStep.isPlaceholder`, issue #130): a technique a user + * typed while correcting a step because the catalog was missing it. A flat + * list, not grouped like the ingredient placeholders — correcting a + * technique (let alone hitting a missing one) is rare enough that + * near-duplicate spellings aren't the problem there. + */ +export interface TechStepPlaceholderView { + id: number; + /** The exact text the user typed. */ + displayName: string; + /** ISO 8601, or `null` for a row created before the column existed. */ + createdAt: string | null; + /** ISO 8601 once a maintainer has triaged it, else `null`. */ + reviewedAt: string | null; + /** Distinct recipes whose steps currently carry this placeholder technique. */ + recipeCount: number; + /** Up to 5 of those recipes, for a "seen in…" preview. */ + sampleRecipes: { id: number; name: string }[]; +} diff --git a/packages/shared/src/types/reference.ts b/packages/shared/src/types/reference.ts index 26f0d9a..00946aa 100644 --- a/packages/shared/src/types/reference.ts +++ b/packages/shared/src/types/reference.ts @@ -203,11 +203,24 @@ export interface UnitView { * * `key` is a stable English camelCase uid (e.g. `"simmer"`), not a display * label — resolved via `t(\`catalog.techSteps.${key}\`)`, same as - * {@link DietView.key}. + * {@link DietView.key} — **except** for a placeholder (see `isPlaceholder`), + * whose label is `displayName` verbatim. Consumers resolve the label as + * `displayName ?? t(\`catalog.techSteps.${key}\`)` (helper: `apps/web`'s + * `features/recipes/steps/tech-step-label.ts`). */ export interface TechStepView { id: number; key: string; + /** + * `true` = a free-text technique a user typed while correcting a step + * because the catalog had nothing matching (issue #130) — see + * `TechStep.isPlaceholder` in schema.prisma. Never returned by + * `GET /reference/tech-steps` (the browsable catalog excludes them); only + * ever seen on a step's own detected-technique sequence. + */ + isPlaceholder: boolean; + /** The user-typed name when `isPlaceholder` — `null` for a real catalog technique (its label is in i18n). */ + displayName: string | null; } /** -- 2.45.2 From 380f22d9883c247056d45a033ef6f208d109a224 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Wed, 9 Sep 2026 16:04:57 +0200 Subject: [PATCH 2/2] test: aligne les assertions restantes sur TechStepView { isPlaceholder, displayName } (#130) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux specs n'avaient pas été mises à jour quand `sources.service` s'est mis à renvoyer un `techStep` complet et que le titre du catalogue admin est passé de « Ingrédients hors-catalogue » à « Éléments hors-catalogue » : - `sources.test.ts` : les deux `deep.equal({ id, key })` sur le techStep d'une étape de preview attendent maintenant aussi `isPlaceholder: false` et `displayName: null`. - `admin-layout.cy.ts` : l'assertion du `h1` de /admin/catalogue suit le nouveau libellé. Co-Authored-By: Claude Sonnet 5 --- apps/api/test/sources/sources.test.ts | 9 ++++++++- apps/web/cypress/e2e/admin-layout.cy.ts | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/api/test/sources/sources.test.ts b/apps/api/test/sources/sources.test.ts index 20e58e9..cc2ce77 100644 --- a/apps/api/test/sources/sources.test.ts +++ b/apps/api/test/sources/sources.test.ts @@ -339,7 +339,12 @@ describe("Sources", () => { const [step] = res.body.steps; expect(step.description).to.equal("Chop the onions finely"); expect(step.techSteps).to.have.length(1); - expect(step.techSteps[0].techStep).to.deep.equal({ id: chop.id, key: "chop" }); + expect(step.techSteps[0].techStep).to.deep.equal({ + id: chop.id, + key: "chop", + isPlaceholder: false, + displayName: null, + }); expect( step.description.slice(step.techSteps[0].start, step.techSteps[0].end).toLowerCase(), ).to.equal("chop"); @@ -382,6 +387,8 @@ describe("Sources", () => { expect(res.body.steps[0].techSteps[0].techStep).to.deep.equal({ id: simmer.id, key: "simmer", + isPlaceholder: false, + displayName: null, }); }); diff --git a/apps/web/cypress/e2e/admin-layout.cy.ts b/apps/web/cypress/e2e/admin-layout.cy.ts index ffdca2b..c296670 100644 --- a/apps/web/cypress/e2e/admin-layout.cy.ts +++ b/apps/web/cypress/e2e/admin-layout.cy.ts @@ -39,7 +39,7 @@ describe("Admin layout", () => { cy.intercept("GET", "**/admin/api/catalog/placeholders*", { statusCode: 200, body: [] }); cy.contains("nav a", "Catalogue").click(); cy.url().should("include", "/admin/catalogue"); - cy.contains("h1", "Ingrédients hors-catalogue").should("be.visible"); + cy.contains("h1", "Éléments hors-catalogue").should("be.visible"); cy.contains("nav a", "Tableau de bord").click(); cy.url().should("eq", `${Cypress.config().baseUrl}/admin`); -- 2.45.2