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
/// 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:<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[]
/// 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")
}

View file

@ -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),
),
);
}),
);

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),
}));
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 })),

View file

@ -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,7 +460,32 @@ export async function submitTechStepCorrection(
);
await assertUtensilsExist((input.utensils ?? []).map((u) => u.utensilId));
const { correction, techSteps } = await prisma.$transaction(async (tx) => {
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;
const createdCorrection = await tx.stepTechStepCorrection.create({
data: {
stepId: step.id,
@ -457,7 +493,7 @@ export async function submitTechStepCorrection(
start: input.start,
end: input.end,
previousTechStepId: input.previousTechStepId ?? null,
correctedTechStepId: input.correctedTechStepId ?? null,
correctedTechStepId: effectiveCorrectedTechStepId,
},
include: correctionInclude,
});
@ -467,7 +503,7 @@ export async function submitTechStepCorrection(
step.id,
{ start: input.start, end: input.end },
input.previousTechStepId ?? null,
input.correctedTechStepId ?? null,
effectiveCorrectedTechStepId,
input.ingredients === undefined &&
input.utensils === undefined &&
input.temperatures === undefined
@ -524,8 +560,9 @@ export async function submitTechStepCorrection(
},
});
return { correction: createdCorrection, techSteps: freshTechSteps };
});
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) {

View file

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

View file

@ -77,11 +77,21 @@ export async function getUnits(): Promise<UnitView[]> {
/**
* 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<TechStepView[]> {
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
}

View file

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

View file

@ -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<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.
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");
});

View file

@ -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 () => {

View file

@ -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 () => {

View file

@ -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,
});
});

View file

@ -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(<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", () => {
cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
statusCode: 201,

View file

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

View file

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

View file

@ -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<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). */
public getTourScenarios(): Promise<AdminTourScenarioView[]> {
return this._request("/tours");

View file

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

View file

@ -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 (
<Tooltip key={key} content={tooltipLabel}>
{/* 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 { ingredientLabel } from "../ingredients/ingredient-label";
import { CatalogSearchPicker } from "./CatalogSearchPicker";
import { techStepLabel } from "./tech-step-label";
import { temperatureLabel } from "./temperature-label";
import type { TextSelectionRange } from "./use-text-selection";
@ -358,6 +359,13 @@ export function TechStepCorrectionPopover({
const popoverRef = useRef<HTMLDivElement>(null);
const [techSteps, setTechSteps] = useState<TechStepView[] | null>(null);
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
// picked (see this component's own doc comment) — starts open only when
// there's nothing to show yet (a brand-new selection).
@ -506,7 +514,7 @@ export function TechStepCorrectionPopover({
}
async function confirm() {
if (selectedTechStepId === null) return;
if (selectedTechStepId === null && proposedTechStepName === null) return;
setIsSubmitting(true);
setError(null);
try {
@ -514,7 +522,11 @@ export function TechStepCorrectionPopover({
start: range.start,
end: range.end,
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
? {
ingredients: pendingIngredients.map(({ quote: _quote, ...rest }) => rest),
@ -616,9 +628,13 @@ export function TechStepCorrectionPopover({
const ingredientById = new Map((catalogs?.ingredients ?? []).map((i) => [i.id, i]));
const unitById = new Map((catalogs?.units ?? []).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 =
selectedTechStepId !== null
? t(`catalog.techSteps.${techSteps?.find((ts) => ts.id === selectedTechStepId)?.key ?? ""}`)
proposedTechStepName !== null
? proposedTechStepName
: selectedTechStep
? techStepLabel(selectedTechStep, t)
: null;
return (
@ -652,7 +668,11 @@ export function TechStepCorrectionPopover({
<button
type="button"
className="tech-step-correction-popover__technique-change"
onClick={() => setIsChangingTechnique(true)}
onClick={() => {
setIsChangingTechnique(true);
setProposedTechStepName(null);
setIsNamingMissingTechnique(false);
}}
>
{t("recipes.techStepCorrection.changeTechnique")}
</button>
@ -670,19 +690,61 @@ export function TechStepCorrectionPopover({
</div>
{(currentTechniqueLabel === null || isChangingTechnique) && (
<>
<CatalogSearchPicker
items={techSteps.map((techStep) => ({
id: techStep.id,
label: t(`catalog.techSteps.${techStep.key}`),
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 ? (
<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>
@ -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")}
</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.",
"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"

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 { 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<CatalogKind>("ingredients");
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 [pruneMessage, setPruneMessage] = useState<string | null>(null);
const [isPruning, setIsPruning] = useState(false);
@ -78,25 +135,8 @@ export function CatalogPage() {
}
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-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}>
{isPruning ? t("admin.catalog.pruning") : t("admin.catalog.pruneOrphans")}
</button>
@ -124,7 +164,90 @@ export function CatalogPage() {
))}
</ul>
))}
</>
);
}
/** 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 {
display: flex;
gap: var(--space-xs);
margin-bottom: var(--space-md);
button {
padding: var(--space-sm) var(--space-md);

View file

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

View file

@ -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 }[];
}

View file

@ -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;
}
/**