feat(recipes): signaler une technique absente du catalogue (#130) #138
29 changed files with 1070 additions and 162 deletions
|
|
@ -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;
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })),
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
20
apps/web/src/features/recipes/steps/tech-step-label.ts
Normal file
20
apps/web/src/features/recipes/steps/tech-step-label.ts
Normal 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}`);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 }[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in a new issue