feat(recipes): signaler une technique absente du catalogue (#130) #138

Merged
kyuno merged 2 commits from feat/missing-tech-steps into main 2026-09-09 16:25:28 +02:00
29 changed files with 1070 additions and 162 deletions

View file

@ -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;

View file

@ -134,6 +134,9 @@ model UserProfile {
/// Placeholder `Ingredient` rows this profile created by typing a free-text /// Placeholder `Ingredient` rows this profile created by typing a free-text
/// ingredient the catalog didn't cover — see `Ingredient.isPlaceholder`. /// ingredient the catalog didn't cover — see `Ingredient.isPlaceholder`.
createdIngredientPlaceholders Ingredient[] @relation("PlaceholderCreator") 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}. /// Contact-form messages this profile has sent — see {@link ContactMessage}.
contactMessages ContactMessage[] contactMessages ContactMessage[]
@ -718,6 +721,33 @@ model TechStep {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
key String @unique 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:<uuid>`, 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.<key>`). 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[] steps StepTechStep[]
/// Corrections where this technique was the *previous* (possibly wrong) /// Corrections where this technique was the *previous* (possibly wrong)
/// match — see `StepTechStepCorrection.previousTechStepId`. /// match — see `StepTechStepCorrection.previousTechStepId`.
@ -728,7 +758,10 @@ model TechStep {
/// Training-corpus suggestions targeting this technique — see /// Training-corpus suggestions targeting this technique — see
/// `TechStepTrainingSuggestion`. /// `TechStepTrainingSuggestion`.
trainingSuggestions 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") @@map("tech_step")
} }

View file

@ -1,5 +1,10 @@
import { wrapAsyncHandler } from "@batch-cooking/express-tools"; 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 { Router } from "express";
import { requireAdmin } from "../../middlewares/require-admin.js"; import { requireAdmin } from "../../middlewares/require-admin.js";
import { import {
@ -7,12 +12,17 @@ import {
markPlaceholdersReviewed, markPlaceholdersReviewed,
pruneOrphanPlaceholders, pruneOrphanPlaceholders,
} from "./admin-catalog.service.js"; } 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 * Router mounted at `/admin/api/catalog` (via `admin.routes.ts`) — every route
* behind {@link requireAdmin}. Surfaces the off-catalog ingredient * behind {@link requireAdmin}. Surfaces the off-catalog "placeholders" users
* "placeholders" users typed when the seeded catalog fell short, so a * typed when the seeded catalog fell short — free-text ingredients on a
* maintainer can see what's missing and mark gaps as handled. * 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(); export const adminCatalogRouter = Router();
@ -42,3 +52,29 @@ adminCatalogRouter.post(
res.status(200).json(await pruneOrphanPlaceholders()); 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),
),
);
}),
);

View file

@ -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<TechStepPlaceholderView[]> {
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<number, string>();
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
}
}

View file

@ -89,7 +89,12 @@ function toOptimizerRecipe(item: PlanningItemWithSteps): OptimizerRecipeInput {
unit: line.unit === null ? null : toUnitView(line.unit), unit: line.unit === null ? null : toUnitView(line.unit),
})); }));
return { 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, order: techStep.order,
ingredients, ingredients,
utensils: techStep.utensils.map(({ utensil }) => ({ id: utensil.id, key: utensil.key })), utensils: techStep.utensils.map(({ utensil }) => ({ id: utensil.id, key: utensil.key })),

View file

@ -1,3 +1,4 @@
import { randomUUID } from "node:crypto";
import { HttpError } from "@batch-cooking/error-tools"; import { HttpError } from "@batch-cooking/error-tools";
import { import {
ErrorCode, ErrorCode,
@ -380,10 +381,20 @@ function toCorrectionView(correction: CorrectionWithTechSteps): StepTechStepCorr
start: correction.start, start: correction.start,
end: correction.end, end: correction.end,
previousTechStep: correction.previousTechStep 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, : null,
correctedTechStep: correction.correctedTechStep 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, : null,
createdAt: correction.createdAt.toISOString(), createdAt: correction.createdAt.toISOString(),
}; };
@ -449,83 +460,109 @@ export async function submitTechStepCorrection(
); );
await assertUtensilsExist((input.utensils ?? []).map((u) => u.utensilId)); await assertUtensilsExist((input.utensils ?? []).map((u) => u.utensilId));
const { correction, techSteps } = await prisma.$transaction(async (tx) => { const { correction, techSteps, placeholderTechStepId } = await prisma.$transaction(
const createdCorrection = await tx.stepTechStepCorrection.create({ async (tx) => {
data: { // A technique the catalog doesn't have (issue #130) — mint a
stepId: step.id, // placeholder `TechStep` for it, in the same transaction as the
correctorId, // correction that references it, then proceed exactly as if its id had
start: input.start, // been sent as `correctedTechStepId`. Same shape as
end: input.end, // `recipe.service.ts`'s `resolveIngredientLines` does for a
previousTechStepId: input.previousTechStepId ?? null, // free-text ingredient.
correctedTechStepId: input.correctedTechStepId ?? null, const placeholderTechStepId =
}, input.correctedTechStepName !== undefined
include: correctionInclude, ? (
}); 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( const createdCorrection = await tx.stepTechStepCorrection.create({
tx, data: {
step.id, stepId: step.id,
{ start: input.start, end: input.end }, correctorId,
input.previousTechStepId ?? null, start: input.start,
input.correctedTechStepId ?? null, end: input.end,
input.ingredients === undefined && previousTechStepId: input.previousTechStepId ?? null,
input.utensils === undefined && correctedTechStepId: effectiveCorrectedTechStepId,
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 } }, include: correctionInclude,
temperatures: true, });
},
});
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", { analytics.recordEvent("tech_step.correction_submitted", {
actorId: correctorId, actorId: correctorId,
@ -533,9 +570,20 @@ export async function submitTechStepCorrection(
recipeId, recipeId,
stepId, stepId,
previousTechStepId: input.previousTechStepId ?? null, 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) }; return { correction: toCorrectionView(correction), techSteps: toStepTechStepViews(techSteps) };
} catch (err) { } catch (err) {

View file

@ -211,7 +211,12 @@ export function toStepTechStepViews(
} = stepTechStep; } = stepTechStep;
if (start === null || end === null) continue; if (start === null || end === null) continue;
views.push({ views.push({
techStep: { id: techStep.id, key: techStep.key }, techStep: {
id: techStep.id,
key: techStep.key,
isPlaceholder: techStep.isPlaceholder,
displayName: techStep.displayName,
},
start, start,
end, end,
// `source` is a plain DB `String`, not a Prisma enum (see // `source` is a plain DB `String`, not a Prisma enum (see

View file

@ -77,11 +77,21 @@ export async function getUnits(): Promise<UnitView[]> {
/** /**
* All reference cooking techniques, ordered by key (see {@link getDiets} * All reference cooking techniques, ordered by key (see {@link getDiets}
* for why) — small, static list (see `reference-seed-data.ts`'s * 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<TechStepView[]> { export async function getTechSteps(): Promise<TechStepView[]> {
try { 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) { } catch (err) {
throw err; // see getDiets()'s catch comment above throw err; // see getDiets()'s catch comment above
} }

View file

@ -180,9 +180,20 @@ export async function previewSourceItem(
), ),
loadIngredientCatalog(adapter.locale), loadIngredientCatalog(adapter.locale),
loadUnitCatalog(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( const translatedIngredients = translateRecipeIngredients(
parsed.ingredients, parsed.ingredients,

View file

@ -139,3 +139,118 @@ describe("Admin catalog — off-catalog ingredients", () => {
expect(await prisma.ingredient.count({ where: { isPlaceholder: true } })).to.equal(1); 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<number> {
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);
});
});

View file

@ -469,7 +469,12 @@ describe("Recipes", () => {
// response itself carries exactly what to highlight, not just the id. // response itself carries exactly what to highlight, not just the id.
const resStep = res.body.steps[0]; const resStep = res.body.steps[0];
expect(resStep.techSteps).to.have.length(1); 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]; const { start, end } = resStep.techSteps[0];
expect(description.slice(start, end).toLowerCase()).to.equal("mijoter"); expect(description.slice(start, end).toLowerCase()).to.equal("mijoter");
}); });

View file

@ -109,7 +109,12 @@ describe("Recipe tech-step corrections", () => {
expect(res.status).to.equal(201); expect(res.status).to.equal(201);
expect(res.body.correction.previousTechStep).to.equal(null); 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.start).to.equal(6);
expect(res.body.correction.end).to.equal(13); expect(res.body.correction.end).to.equal(13);
// The step's real technique sequence reflects the correction right // 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`). // `applyManualCorrection`, `recipe-tech-step-correction.service.ts`).
expect(res.body.techSteps).to.deep.equal([ expect(res.body.techSteps).to.deep.equal([
{ {
techStep: { id: simmerId, key: "simmer" }, techStep: { id: simmerId, key: "simmer", isPlaceholder: false, displayName: null },
start: 6, start: 6,
end: 13, end: 13,
source: "manual", 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 () => { 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 { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId); const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
@ -145,13 +200,23 @@ describe("Recipe tech-step corrections", () => {
.send({ start: 6, end: 13, previousTechStepId: simmerId, correctedTechStepId: boilId }); .send({ start: 6, end: 13, previousTechStepId: simmerId, correctedTechStepId: boilId });
expect(res.status).to.equal(201); expect(res.status).to.equal(201);
expect(res.body.correction.previousTechStep).to.deep.equal({ id: simmerId, key: "simmer" }); expect(res.body.correction.previousTechStep).to.deep.equal({
expect(res.body.correction.correctedTechStep).to.deep.equal({ id: boilId, key: "boil" }); 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 // Still exactly one entry — the relabel updated the existing row
// rather than adding a second one alongside it. // rather than adding a second one alongside it.
expect(res.body.techSteps).to.deep.equal([ expect(res.body.techSteps).to.deep.equal([
{ {
techStep: { id: boilId, key: "boil" }, techStep: { id: boilId, key: "boil", isPlaceholder: false, displayName: null },
start: 6, start: 6,
end: 13, end: 13,
source: "manual", source: "manual",
@ -299,7 +364,7 @@ describe("Recipe tech-step corrections", () => {
expect(res.status).to.equal(201); expect(res.status).to.equal(201);
expect(res.body.techSteps).to.deep.equal([ expect(res.body.techSteps).to.deep.equal([
{ {
techStep: { id: simmerId, key: "simmer" }, techStep: { id: simmerId, key: "simmer", isPlaceholder: false, displayName: null },
start: 6, start: 6,
end: 13, end: 13,
source: "manual", source: "manual",
@ -535,7 +600,7 @@ describe("Recipe tech-step corrections", () => {
expect(res.status).to.equal(201); expect(res.status).to.equal(201);
expect(res.body.techSteps).to.deep.equal([ expect(res.body.techSteps).to.deep.equal([
{ {
techStep: { id: preheatId, key: "preheat" }, techStep: { id: preheatId, key: "preheat", isPlaceholder: false, displayName: null },
start: 0, start: 0,
end: 11, end: 11,
source: "manual", source: "manual",
@ -721,8 +786,18 @@ describe("Recipe tech-step corrections", () => {
expect(res.status).to.equal(200); expect(res.status).to.equal(200);
expect(res.body).to.have.length(2); expect(res.body).to.have.length(2);
expect(res.body[0].correctedTechStep).to.deep.equal({ id: boilId, key: "boil" }); expect(res.body[0].correctedTechStep).to.deep.equal({
expect(res.body[1].correctedTechStep).to.deep.equal({ id: simmerId, key: "simmer" }); 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 () => { it("returns an empty list when nothing has been submitted yet", async () => {

View file

@ -148,7 +148,30 @@ describe("Reference data", () => {
// number — this catalog has grown since (26 -> 74) and will again. // number — this catalog has grown since (26 -> 74) and will again.
expect(res.body).to.have.length(TECH_STEPS.length); expect(res.body).to.have.length(TECH_STEPS.length);
expect(res.body.map((t: { key: string }) => t.key)).to.include("simmer"); 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 () => { it("orders techniques alphabetically by key", async () => {

View file

@ -339,7 +339,12 @@ describe("Sources", () => {
const [step] = res.body.steps; const [step] = res.body.steps;
expect(step.description).to.equal("Chop the onions finely"); expect(step.description).to.equal("Chop the onions finely");
expect(step.techSteps).to.have.length(1); 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( expect(
step.description.slice(step.techSteps[0].start, step.techSteps[0].end).toLowerCase(), step.description.slice(step.techSteps[0].start, step.techSteps[0].end).toLowerCase(),
).to.equal("chop"); ).to.equal("chop");
@ -382,6 +387,8 @@ describe("Sources", () => {
expect(res.body.steps[0].techSteps[0].techStep).to.deep.equal({ expect(res.body.steps[0].techSteps[0].techStep).to.deep.equal({
id: simmer.id, id: simmer.id,
key: "simmer", key: "simmer",
isPlaceholder: false,
displayName: null,
}); });
}); });

View file

@ -194,6 +194,41 @@ describe("TechStepCorrectionPopover", () => {
cy.get("@onSubmitted").should("have.been.calledOnce"); 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(<Harness />);
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", () => { 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", { cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
statusCode: 201, statusCode: 201,

View file

@ -78,6 +78,45 @@ describe("Admin catalog — off-catalog ingredients", () => {
cy.get("@getPlaceholders.all").should("have.length.greaterThan", 1); 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", () => { it("switches to the reviewed archive tab", () => {
// Regex, not a glob: the two calls differ only by the `?reviewed=true` // 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 // query, and minimatch's `?` is itself a wildcard — a glob can't tell

View file

@ -39,7 +39,7 @@ describe("Admin layout", () => {
cy.intercept("GET", "**/admin/api/catalog/placeholders*", { statusCode: 200, body: [] }); cy.intercept("GET", "**/admin/api/catalog/placeholders*", { statusCode: 200, body: [] });
cy.contains("nav a", "Catalogue").click(); cy.contains("nav a", "Catalogue").click();
cy.url().should("include", "/admin/catalogue"); 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.contains("nav a", "Tableau de bord").click();
cy.url().should("eq", `${Cypress.config().baseUrl}/admin`); cy.url().should("eq", `${Cypress.config().baseUrl}/admin`);

View file

@ -11,10 +11,12 @@ import {
ErrorCode, ErrorCode,
type ForgejoIntegrationStatus, type ForgejoIntegrationStatus,
type MarkPlaceholdersReviewedInput, type MarkPlaceholdersReviewedInput,
type MarkTechStepPlaceholdersReviewedInput,
type MetricsView, type MetricsView,
type PruneOrphansResultView, type PruneOrphansResultView,
type RetrainRequestInput, type RetrainRequestInput,
type RetrainResultView, type RetrainResultView,
type TechStepPlaceholderView,
type TourTargetView, type TourTargetView,
type TrainingDataSnippetView, type TrainingDataSnippetView,
type TrainingSuggestionAdminView, type TrainingSuggestionAdminView,
@ -195,6 +197,21 @@ export class AdminApiClient {
return this._request("/catalog/placeholders/prune-orphans", { method: "POST" }); 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<TechStepPlaceholderView[]> {
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). */ /** Every guided-tour scenario — enabled *and* disabled — with its ordered steps (issue #44). */
public getTourScenarios(): Promise<AdminTourScenarioView[]> { public getTourScenarios(): Promise<AdminTourScenarioView[]> {
return this._request("/tours"); return this._request("/tours");

View file

@ -889,6 +889,26 @@
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
padding: 0; 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 { &__remove-match {

View file

@ -9,6 +9,7 @@ import { Tooltip } from "../../../components/ui/Tooltip";
import { useTour } from "../../tour/TourContext"; import { useTour } from "../../tour/TourContext";
import { splitDescriptionSegments } from "./highlight-tech-steps"; import { splitDescriptionSegments } from "./highlight-tech-steps";
import { TechStepCorrectionPopover } from "./TechStepCorrectionPopover"; import { TechStepCorrectionPopover } from "./TechStepCorrectionPopover";
import { techStepLabel } from "./tech-step-label";
import { temperatureLabel } from "./temperature-label"; import { temperatureLabel } from "./temperature-label";
import { type TextSelectionRange, useTextSelection } from "./use-text-selection"; import { type TextSelectionRange, useTextSelection } from "./use-text-selection";
@ -318,11 +319,12 @@ export function StepDescription({
const techStep = segment.techStep; const techStep = segment.techStep;
const isManual = segment.source === "manual"; 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 const tooltipLabel = isManual
? t("recipes.techStepCorrection.manualTooltip", { ? t("recipes.techStepCorrection.manualTooltip", { technique: techLabel })
technique: t(`catalog.techSteps.${techStep.key}`), : techLabel;
})
: t(`catalog.techSteps.${techStep.key}`);
return ( return (
<Tooltip key={key} content={tooltipLabel}> <Tooltip key={key} content={tooltipLabel}>
{/* A real <button>, not a <mark>, so it's natively focusable {/* A real <button>, not a <mark>, so it's natively focusable

View file

@ -15,6 +15,7 @@ import { ApiError, apiClient } from "../../../api/client";
import { errorMessageService } from "../../../services/error-message.service"; import { errorMessageService } from "../../../services/error-message.service";
import { ingredientLabel } from "../ingredients/ingredient-label"; import { ingredientLabel } from "../ingredients/ingredient-label";
import { CatalogSearchPicker } from "./CatalogSearchPicker"; import { CatalogSearchPicker } from "./CatalogSearchPicker";
import { techStepLabel } from "./tech-step-label";
import { temperatureLabel } from "./temperature-label"; import { temperatureLabel } from "./temperature-label";
import type { TextSelectionRange } from "./use-text-selection"; import type { TextSelectionRange } from "./use-text-selection";
@ -358,6 +359,13 @@ export function TechStepCorrectionPopover({
const popoverRef = useRef<HTMLDivElement>(null); const popoverRef = useRef<HTMLDivElement>(null);
const [techSteps, setTechSteps] = useState<TechStepView[] | null>(null); const [techSteps, setTechSteps] = useState<TechStepView[] | null>(null);
const [selectedTechStepId, setSelectedTechStepId] = useState<number | null>(previousTechStepId); const [selectedTechStepId, setSelectedTechStepId] = useState<number | null>(previousTechStepId);
// A free-text technique the catalog doesn't have (issue #130) — set from
// the "pas dans la liste" sub-form. Mutually exclusive with
// `selectedTechStepId`: whichever is set is what `confirm()` submits (as
// `correctedTechStepName` vs `correctedTechStepId`).
const [proposedTechStepName, setProposedTechStepName] = useState<string | null>(null);
const [isNamingMissingTechnique, setIsNamingMissingTechnique] = useState(false);
const [missingTechniqueDraft, setMissingTechniqueDraft] = useState("");
// The technique picker hides behind "Changer" once something's already // The technique picker hides behind "Changer" once something's already
// picked (see this component's own doc comment) — starts open only when // picked (see this component's own doc comment) — starts open only when
// there's nothing to show yet (a brand-new selection). // there's nothing to show yet (a brand-new selection).
@ -506,7 +514,7 @@ export function TechStepCorrectionPopover({
} }
async function confirm() { async function confirm() {
if (selectedTechStepId === null) return; if (selectedTechStepId === null && proposedTechStepName === null) return;
setIsSubmitting(true); setIsSubmitting(true);
setError(null); setError(null);
try { try {
@ -514,7 +522,11 @@ export function TechStepCorrectionPopover({
start: range.start, start: range.start,
end: range.end, end: range.end,
previousTechStepId, previousTechStepId,
correctedTechStepId: selectedTechStepId, // Exactly one of these — a catalog id, or free text for a technique
// the catalog is missing (issue #130).
...(proposedTechStepName !== null
? { correctedTechStepName: proposedTechStepName }
: { correctedTechStepId: selectedTechStepId ?? undefined }),
...(metadataTouched ...(metadataTouched
? { ? {
ingredients: pendingIngredients.map(({ quote: _quote, ...rest }) => rest), ingredients: pendingIngredients.map(({ quote: _quote, ...rest }) => rest),
@ -616,10 +628,14 @@ export function TechStepCorrectionPopover({
const ingredientById = new Map((catalogs?.ingredients ?? []).map((i) => [i.id, i])); const ingredientById = new Map((catalogs?.ingredients ?? []).map((i) => [i.id, i]));
const unitById = new Map((catalogs?.units ?? []).map((u) => [u.id, u])); const unitById = new Map((catalogs?.units ?? []).map((u) => [u.id, u]));
const utensilById = new Map((catalogs?.utensils ?? []).map((u) => [u.id, u])); const utensilById = new Map((catalogs?.utensils ?? []).map((u) => [u.id, u]));
const selectedTechStep =
selectedTechStepId !== null ? techSteps?.find((ts) => ts.id === selectedTechStepId) : undefined;
const currentTechniqueLabel = const currentTechniqueLabel =
selectedTechStepId !== null proposedTechStepName !== null
? t(`catalog.techSteps.${techSteps?.find((ts) => ts.id === selectedTechStepId)?.key ?? ""}`) ? proposedTechStepName
: null; : selectedTechStep
? techStepLabel(selectedTechStep, t)
: null;
return ( return (
<div className="tech-step-correction-popover" ref={popoverRef}> <div className="tech-step-correction-popover" ref={popoverRef}>
@ -652,7 +668,11 @@ export function TechStepCorrectionPopover({
<button <button
type="button" type="button"
className="tech-step-correction-popover__technique-change" className="tech-step-correction-popover__technique-change"
onClick={() => setIsChangingTechnique(true)} onClick={() => {
setIsChangingTechnique(true);
setProposedTechStepName(null);
setIsNamingMissingTechnique(false);
}}
> >
{t("recipes.techStepCorrection.changeTechnique")} {t("recipes.techStepCorrection.changeTechnique")}
</button> </button>
@ -670,19 +690,61 @@ export function TechStepCorrectionPopover({
</div> </div>
{(currentTechniqueLabel === null || isChangingTechnique) && ( {(currentTechniqueLabel === null || isChangingTechnique) && (
<CatalogSearchPicker <>
items={techSteps.map((techStep) => ({ <CatalogSearchPicker
id: techStep.id, items={techSteps.map((techStep) => ({
label: t(`catalog.techSteps.${techStep.key}`), id: techStep.id,
}))} label: techStepLabel(techStep, t),
selectedId={selectedTechStepId ?? undefined} }))}
onSelect={(id) => { selectedId={selectedTechStepId ?? undefined}
setSelectedTechStepId(id); onSelect={(id) => {
setIsChangingTechnique(false); setSelectedTechStepId(id);
}} setProposedTechStepName(null);
placeholder={t("recipes.techStepCorrection.searchTechniquePlaceholder")} setIsNamingMissingTechnique(false);
emptyLabel={t("recipes.techStepCorrection.noTechniqueFound")} 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 ? (
<div className="tech-step-correction-popover__missing-technique">
<input
type="text"
value={missingTechniqueDraft}
onChange={(e) => setMissingTechniqueDraft(e.target.value)}
placeholder={t("recipes.techStepCorrection.missingTechniquePlaceholder")}
aria-label={t("recipes.techStepCorrection.missingTechniqueLabel")}
/>
<button
type="button"
className="tech-step-correction-popover__confirm-mini"
disabled={missingTechniqueDraft.trim().length === 0}
onClick={() => {
setProposedTechStepName(missingTechniqueDraft.trim());
setSelectedTechStepId(null);
setIsNamingMissingTechnique(false);
setIsChangingTechnique(false);
}}
>
{t("recipes.techStepCorrection.missingTechniqueAdd")}
</button>
</div>
) : (
<button
type="button"
className="tech-step-correction-popover__technique-change"
onClick={() => {
setMissingTechniqueDraft("");
setIsNamingMissingTechnique(true);
}}
>
{t("recipes.techStepCorrection.techniqueNotInList")}
</button>
)}
</>
)} )}
</section> </section>
@ -993,7 +1055,9 @@ export function TechStepCorrectionPopover({
type="button" type="button"
className="tech-step-correction-popover__confirm-button" className="tech-step-correction-popover__confirm-button"
onClick={confirm} onClick={confirm}
disabled={isSubmitting || selectedTechStepId === null} disabled={
isSubmitting || (selectedTechStepId === null && proposedTechStepName === null)
}
> >
{t("recipes.techStepCorrection.confirm")} {t("recipes.techStepCorrection.confirm")}
</button> </button>

View file

@ -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<TechStepView, "key" | "isPlaceholder" | "displayName">,
t: (key: string) => string,
): string {
return techStep.isPlaceholder && techStep.displayName !== null
? techStep.displayName
: t(`catalog.techSteps.${techStep.key}`);
}

View file

@ -219,6 +219,10 @@
"noTechniqueSelected": "Aucune technique sélectionnée.", "noTechniqueSelected": "Aucune technique sélectionnée.",
"searchTechniquePlaceholder": "Rechercher une technique…", "searchTechniquePlaceholder": "Rechercher une technique…",
"noTechniqueFound": "Aucune technique trouvée.", "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", "ingredientsSection": "Ingrédients",
"utensilsSection": "Ustensiles", "utensilsSection": "Ustensiles",
"temperatureSection": "Température", "temperatureSection": "Température",
@ -1373,9 +1377,14 @@
} }
}, },
"catalog": { "catalog": {
"title": "Ingrédients hors-catalogue", "title": "Éléments 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.", "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.", "empty": "Aucun ingrédient hors-catalogue.",
"techStepEmpty": "Aucune technique proposée à revoir.",
"kind": {
"ingredients": "Ingrédients",
"techSteps": "Techniques"
},
"tab": { "tab": {
"pending": "À traiter", "pending": "À traiter",
"reviewed": "Traités" "reviewed": "Traités"

View file

@ -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 { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { adminApiClient } from "../../../api/admin-client"; import { adminApiClient } from "../../../api/admin-client";
@ -6,25 +6,82 @@ import "../admin-page.scss";
import "./catalog-page.scss"; import "./catalog-page.scss";
import { type CatalogTab, formatDate, reviewedParam, splitSpellings } from "./catalog"; import { type CatalogTab, formatDate, reviewedParam, splitSpellings } from "./catalog";
type CatalogState = type IngredientState =
| { status: "loading" } | { status: "loading" }
| { status: "loaded"; groups: CatalogPlaceholderGroupView[] } | { status: "loaded"; groups: CatalogPlaceholderGroupView[] }
| { status: "error" }; | { 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 * Off-catalog review. Two lists a maintainer works through to see what the
* free text users typed when the seeded catalog fell short — see * seeded catalog is missing: the free-text **ingredients** users typed on a
* `Ingredient.isPlaceholder` in the API schema), grouped by normalized * recipe line (grouped by normalized name), and the free-text **techniques**
* name, so a maintainer sees what the catalog is missing and how many * users proposed while correcting a step (issue #130, a flat list — that's
* recipes are waiting on it. Actions are deliberately minimal: mark a gap * rarer). Actions are deliberately minimal: mark a gap as handled (and, for
* as handled, or purge rows no recipe references any more. Actually adding * ingredients, purge rows no recipe references any more). Adding the real
* the catalog entry stays a manual edit of `reference-seed-data.ts` + the * catalog entry stays a manual edit of `reference-seed-data.ts` + the locale
* locale files. * files.
*/ */
export function CatalogPage() { export function CatalogPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const [kind, setKind] = useState<CatalogKind>("ingredients");
const [tab, setTab] = useState<CatalogTab>("pending"); const [tab, setTab] = useState<CatalogTab>("pending");
const [state, setState] = useState<CatalogState>({ status: "loading" });
return (
<div className="admin-page">
<h1 className="admin-page__title">{t("admin.catalog.title")}</h1>
<p className="admin-page__lead">{t("admin.catalog.lead")}</p>
<div className="catalog-kinds" role="tablist">
{(["ingredients", "techSteps"] as const).map((value) => (
<button
key={value}
type="button"
role="tab"
aria-selected={kind === value}
className={kind === value ? "active" : undefined}
onClick={() => setKind(value)}
>
{t(`admin.catalog.kind.${value}`)}
</button>
))}
</div>
<div className="catalog-tabs" role="tablist">
{(["pending", "reviewed"] as const).map((value) => (
<button
key={value}
type="button"
role="tab"
aria-selected={tab === value}
className={tab === value ? "active" : undefined}
onClick={() => setTab(value)}
>
{t(`admin.catalog.tab.${value}`)}
</button>
))}
</div>
{kind === "ingredients" ? (
<IngredientPlaceholders tab={tab} />
) : (
<TechStepPlaceholders tab={tab} />
)}
</div>
);
}
/** 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<IngredientState>({ status: "loading" });
const [pendingIds, setPendingIds] = useState<number[] | null>(null); const [pendingIds, setPendingIds] = useState<number[] | null>(null);
const [pruneMessage, setPruneMessage] = useState<string | null>(null); const [pruneMessage, setPruneMessage] = useState<string | null>(null);
const [isPruning, setIsPruning] = useState(false); const [isPruning, setIsPruning] = useState(false);
@ -78,25 +135,8 @@ export function CatalogPage() {
} }
return ( return (
<div className="admin-page"> <>
<h1 className="admin-page__title">{t("admin.catalog.title")}</h1>
<p className="admin-page__lead">{t("admin.catalog.lead")}</p>
<div className="catalog-toolbar"> <div className="catalog-toolbar">
<div className="catalog-tabs" role="tablist">
{(["pending", "reviewed"] as const).map((value) => (
<button
key={value}
type="button"
role="tab"
aria-selected={tab === value}
className={tab === value ? "active" : undefined}
onClick={() => setTab(value)}
>
{t(`admin.catalog.tab.${value}`)}
</button>
))}
</div>
<button type="button" onClick={pruneOrphans} disabled={isPruning}> <button type="button" onClick={pruneOrphans} disabled={isPruning}>
{isPruning ? t("admin.catalog.pruning") : t("admin.catalog.pruneOrphans")} {isPruning ? t("admin.catalog.pruning") : t("admin.catalog.pruneOrphans")}
</button> </button>
@ -124,7 +164,90 @@ export function CatalogPage() {
))} ))}
</ul> </ul>
))} ))}
</div> </>
);
}
/** 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<TechStepState>({ status: "loading" });
const [busyId, setBusyId] = useState<number | null>(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 <p className="admin-page__placeholder">{t("admin.common.loading")}</p>;
}
if (state.status === "error") {
return <p className="admin-page__placeholder">{t("admin.common.loadError")}</p>;
}
if (state.rows.length === 0) {
return <p className="admin-page__placeholder">{t("admin.catalog.techStepEmpty")}</p>;
}
return (
<ul className="catalog-list">
{state.rows.map((row) => {
const firstSeen = formatDate(row.createdAt);
return (
<li key={row.id} className="catalog-card">
<div className="catalog-card__head">
<h2 className="catalog-card__name">{row.displayName}</h2>
<span className="catalog-card__count">
{t("admin.catalog.recipeCount", { count: row.recipeCount })}
</span>
</div>
{row.sampleRecipes.length > 0 && (
<p className="catalog-card__recipes">
{t("admin.catalog.seenIn")}{" "}
{row.sampleRecipes.map((recipe) => recipe.name).join(", ")}
</p>
)}
<div className="catalog-card__foot">
{firstSeen && (
<span className="catalog-card__seen">
{t("admin.catalog.firstSeen", { date: firstSeen })}
</span>
)}
{row.reviewedAt === null && (
<button
type="button"
onClick={() => markReviewed(row.id)}
disabled={busyId === row.id}
>
{busyId === row.id ? t("admin.catalog.marking") : t("admin.catalog.markReviewed")}
</button>
)}
</div>
</li>
);
})}
</ul>
); );
} }

View file

@ -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 { .catalog-tabs {
display: flex; display: flex;
gap: var(--space-xs); gap: var(--space-xs);
margin-bottom: var(--space-md);
button { button {
padding: var(--space-sm) var(--space-md); padding: var(--space-sm) var(--space-md);

View file

@ -129,6 +129,26 @@ export const markPlaceholdersReviewedSchema = z.object({
/** Inferred TS type for {@link markPlaceholdersReviewedSchema}. */ /** Inferred TS type for {@link markPlaceholdersReviewedSchema}. */
export type MarkPlaceholdersReviewedInput = z.infer<typeof markPlaceholdersReviewedSchema>; export type MarkPlaceholdersReviewedInput = z.infer<typeof markPlaceholdersReviewedSchema>;
/**
* 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<typeof listTechStepPlaceholdersQuerySchema>;
/** 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) ---------- // --- 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. */ /** 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. */

View file

@ -235,6 +235,13 @@ const manualStepTechStepTemperatureInputSchema = z
* `description` length to validate `start`/`end` against, which this shape * `description` length to validate `start`/`end` against, which this shape
* alone can't see. * 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 * `ingredients`/`utensils`/`temperatures` let the user attach metadata to
* the technique they're asserting (`correctedTechStepId`), same * the technique they're asserting (`correctedTechStepId`), same
* `source: "manual"` distinction the technique itself gets. The three * `source: "manual"` distinction the technique itself gets. The three
@ -260,6 +267,8 @@ export const submitTechStepCorrectionSchema = z
end: z.number().int().nonnegative(), end: z.number().int().nonnegative(),
previousTechStepId: z.number().int().positive().nullable().optional(), previousTechStepId: z.number().int().positive().nullable().optional(),
correctedTechStepId: 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(), ingredients: z.array(manualStepTechStepIngredientInputSchema).optional(),
utensils: z.array(manualStepTechStepUtensilInputSchema).optional(), utensils: z.array(manualStepTechStepUtensilInputSchema).optional(),
temperatures: z.array(manualStepTechStepTemperatureInputSchema).optional(), temperatures: z.array(manualStepTechStepTemperatureInputSchema).optional(),
@ -269,10 +278,20 @@ export const submitTechStepCorrectionSchema = z
path: ["end"], path: ["end"],
}) })
.refine( .refine(
(input) => (input) => input.correctedTechStepId == null || input.correctedTechStepName === undefined,
(input.previousTechStepId ?? null) !== null || (input.correctedTechStepId ?? null) !== null,
{ {
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"], path: ["correctedTechStepId"],
}, },
) )
@ -281,9 +300,10 @@ export const submitTechStepCorrectionSchema = z
(input.ingredients === undefined && (input.ingredients === undefined &&
input.utensils === undefined && input.utensils === undefined &&
input.temperatures === 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"], path: ["correctedTechStepId"],
}, },
); );

View file

@ -228,3 +228,25 @@ export interface CatalogPlaceholderGroupView {
export interface PruneOrphansResultView { export interface PruneOrphansResultView {
deleted: number; 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 }[];
}

View file

@ -203,11 +203,24 @@ export interface UnitView {
* *
* `key` is a stable English camelCase uid (e.g. `"simmer"`), not a display * `key` is a stable English camelCase uid (e.g. `"simmer"`), not a display
* label — resolved via `t(\`catalog.techSteps.${key}\`)`, same as * 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 { export interface TechStepView {
id: number; id: number;
key: string; 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;
} }
/** /**