feat(recipes): signaler une technique absente du catalogue (#130) #138
27 changed files with 1061 additions and 160 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
|
/// Placeholder `Ingredient` rows this profile created by typing a free-text
|
||||||
/// ingredient the catalog didn't cover — see `Ingredient.isPlaceholder`.
|
/// ingredient the catalog didn't cover — see `Ingredient.isPlaceholder`.
|
||||||
createdIngredientPlaceholders Ingredient[] @relation("PlaceholderCreator")
|
createdIngredientPlaceholders Ingredient[] @relation("PlaceholderCreator")
|
||||||
|
/// Placeholder `TechStep` rows this profile proposed while correcting a step
|
||||||
|
/// — see `TechStep.isPlaceholder`.
|
||||||
|
createdTechStepPlaceholders TechStep[] @relation("TechStepPlaceholderCreator")
|
||||||
/// Contact-form messages this profile has sent — see {@link ContactMessage}.
|
/// Contact-form messages this profile has sent — see {@link ContactMessage}.
|
||||||
contactMessages ContactMessage[]
|
contactMessages ContactMessage[]
|
||||||
|
|
||||||
|
|
@ -718,6 +721,33 @@ model TechStep {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
key String @unique
|
key String @unique
|
||||||
|
|
||||||
|
/// `true` = a "placeholder" technique: a free-text technique a user typed
|
||||||
|
/// while correcting a step, because nothing in the seeded catalog matched
|
||||||
|
/// (issue #131 for the ingredient precedent this mirrors — see
|
||||||
|
/// `Ingredient.isPlaceholder`). Such a row has `displayName` non-null, a
|
||||||
|
/// generated `key` (`placeholder:<uuid>`, never an i18n label), no
|
||||||
|
/// training data of its own, and is excluded from
|
||||||
|
/// `GET /reference/tech-steps`. It is still referenced by `StepTechStep`
|
||||||
|
/// like any other technique (the correction that created it applies
|
||||||
|
/// immediately), but a maintainer reviews it at
|
||||||
|
/// `/admin/catalogue` (Techniques tab) and promotes it into the real
|
||||||
|
/// catalog by hand (`reference-seed-data.ts` + `tech-step-training-data.ts`).
|
||||||
|
isPlaceholder Boolean @default(false) @map("is_placeholder")
|
||||||
|
/// The exact text the user typed — `null` for a real catalog row (whose
|
||||||
|
/// label lives in i18n under `catalog.techSteps.<key>`). Non-null iff
|
||||||
|
/// `isPlaceholder`, enforced service-side.
|
||||||
|
displayName String? @map("display_name")
|
||||||
|
/// Profile that first proposed this technique — context for the admin
|
||||||
|
/// review. `onDelete: SetNull` so deleting an account never blocks on the
|
||||||
|
/// recipes still using its placeholder. `null` for a real catalog row.
|
||||||
|
createdById Int? @map("created_by_id")
|
||||||
|
/// When this placeholder was proposed. `null` for a real catalog row.
|
||||||
|
createdAt DateTime? @map("created_at")
|
||||||
|
/// Stamped once a maintainer has triaged this proposal
|
||||||
|
/// (`PATCH /admin/api/catalog/tech-step-placeholders/mark-reviewed`).
|
||||||
|
/// `null` while pending / for a real catalog row.
|
||||||
|
reviewedAt DateTime? @map("reviewed_at")
|
||||||
|
|
||||||
steps StepTechStep[]
|
steps StepTechStep[]
|
||||||
/// Corrections where this technique was the *previous* (possibly wrong)
|
/// Corrections where this technique was the *previous* (possibly wrong)
|
||||||
/// match — see `StepTechStepCorrection.previousTechStepId`.
|
/// match — see `StepTechStepCorrection.previousTechStepId`.
|
||||||
|
|
@ -728,7 +758,10 @@ model TechStep {
|
||||||
/// Training-corpus suggestions targeting this technique — see
|
/// Training-corpus suggestions targeting this technique — see
|
||||||
/// `TechStepTrainingSuggestion`.
|
/// `TechStepTrainingSuggestion`.
|
||||||
trainingSuggestions TechStepTrainingSuggestion[]
|
trainingSuggestions TechStepTrainingSuggestion[]
|
||||||
|
/// The profile that proposed this row when it is a placeholder — see `createdById`.
|
||||||
|
createdBy UserProfile? @relation("TechStepPlaceholderCreator", fields: [createdById], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([isPlaceholder])
|
||||||
@@map("tech_step")
|
@@map("tech_step")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||||
import { listPlaceholdersQuerySchema, markPlaceholdersReviewedSchema } from "@batch-cooking/shared";
|
import {
|
||||||
|
listPlaceholdersQuerySchema,
|
||||||
|
listTechStepPlaceholdersQuerySchema,
|
||||||
|
markPlaceholdersReviewedSchema,
|
||||||
|
markTechStepPlaceholdersReviewedSchema,
|
||||||
|
} from "@batch-cooking/shared";
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { requireAdmin } from "../../middlewares/require-admin.js";
|
import { requireAdmin } from "../../middlewares/require-admin.js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -7,12 +12,17 @@ import {
|
||||||
markPlaceholdersReviewed,
|
markPlaceholdersReviewed,
|
||||||
pruneOrphanPlaceholders,
|
pruneOrphanPlaceholders,
|
||||||
} from "./admin-catalog.service.js";
|
} from "./admin-catalog.service.js";
|
||||||
|
import {
|
||||||
|
listTechStepPlaceholders,
|
||||||
|
markTechStepPlaceholdersReviewed,
|
||||||
|
} from "./admin-tech-step-catalog.service.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Router mounted at `/admin/api/catalog` (via `admin.routes.ts`) — every route
|
* Router mounted at `/admin/api/catalog` (via `admin.routes.ts`) — every route
|
||||||
* behind {@link requireAdmin}. Surfaces the off-catalog ingredient
|
* behind {@link requireAdmin}. Surfaces the off-catalog "placeholders" users
|
||||||
* "placeholders" users typed when the seeded catalog fell short, so a
|
* typed when the seeded catalog fell short — free-text ingredients on a
|
||||||
* maintainer can see what's missing and mark gaps as handled.
|
* recipe line, and free-text techniques while correcting a step (issue #130)
|
||||||
|
* — so a maintainer can see what's missing and mark gaps as handled.
|
||||||
*/
|
*/
|
||||||
export const adminCatalogRouter = Router();
|
export const adminCatalogRouter = Router();
|
||||||
|
|
||||||
|
|
@ -42,3 +52,29 @@ adminCatalogRouter.post(
|
||||||
res.status(200).json(await pruneOrphanPlaceholders());
|
res.status(200).json(await pruneOrphanPlaceholders());
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// --- Missing techniques (issue #130) --------------------------------------
|
||||||
|
|
||||||
|
adminCatalogRouter.get(
|
||||||
|
"/tech-step-placeholders",
|
||||||
|
requireAdmin,
|
||||||
|
wrapAsyncHandler(async (req, res) => {
|
||||||
|
res
|
||||||
|
.status(200)
|
||||||
|
.json(await listTechStepPlaceholders(listTechStepPlaceholdersQuerySchema.parse(req.query)));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
adminCatalogRouter.patch(
|
||||||
|
"/tech-step-placeholders/mark-reviewed",
|
||||||
|
requireAdmin,
|
||||||
|
wrapAsyncHandler(async (req, res) => {
|
||||||
|
res
|
||||||
|
.status(200)
|
||||||
|
.json(
|
||||||
|
await markTechStepPlaceholdersReviewed(
|
||||||
|
markTechStepPlaceholdersReviewedSchema.parse(req.body),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -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),
|
unit: line.unit === null ? null : toUnitView(line.unit),
|
||||||
}));
|
}));
|
||||||
return {
|
return {
|
||||||
techStep: { id: techStep.techStep.id, key: techStep.techStep.key },
|
techStep: {
|
||||||
|
id: techStep.techStep.id,
|
||||||
|
key: techStep.techStep.key,
|
||||||
|
isPlaceholder: techStep.techStep.isPlaceholder,
|
||||||
|
displayName: techStep.techStep.displayName,
|
||||||
|
},
|
||||||
order: techStep.order,
|
order: techStep.order,
|
||||||
ingredients,
|
ingredients,
|
||||||
utensils: techStep.utensils.map(({ utensil }) => ({ id: utensil.id, key: utensil.key })),
|
utensils: techStep.utensils.map(({ utensil }) => ({ id: utensil.id, key: utensil.key })),
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import { HttpError } from "@batch-cooking/error-tools";
|
import { HttpError } from "@batch-cooking/error-tools";
|
||||||
import {
|
import {
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
|
|
@ -380,10 +381,20 @@ function toCorrectionView(correction: CorrectionWithTechSteps): StepTechStepCorr
|
||||||
start: correction.start,
|
start: correction.start,
|
||||||
end: correction.end,
|
end: correction.end,
|
||||||
previousTechStep: correction.previousTechStep
|
previousTechStep: correction.previousTechStep
|
||||||
? { id: correction.previousTechStep.id, key: correction.previousTechStep.key }
|
? {
|
||||||
|
id: correction.previousTechStep.id,
|
||||||
|
key: correction.previousTechStep.key,
|
||||||
|
isPlaceholder: correction.previousTechStep.isPlaceholder,
|
||||||
|
displayName: correction.previousTechStep.displayName,
|
||||||
|
}
|
||||||
: null,
|
: null,
|
||||||
correctedTechStep: correction.correctedTechStep
|
correctedTechStep: correction.correctedTechStep
|
||||||
? { id: correction.correctedTechStep.id, key: correction.correctedTechStep.key }
|
? {
|
||||||
|
id: correction.correctedTechStep.id,
|
||||||
|
key: correction.correctedTechStep.key,
|
||||||
|
isPlaceholder: correction.correctedTechStep.isPlaceholder,
|
||||||
|
displayName: correction.correctedTechStep.displayName,
|
||||||
|
}
|
||||||
: null,
|
: null,
|
||||||
createdAt: correction.createdAt.toISOString(),
|
createdAt: correction.createdAt.toISOString(),
|
||||||
};
|
};
|
||||||
|
|
@ -449,83 +460,109 @@ export async function submitTechStepCorrection(
|
||||||
);
|
);
|
||||||
await assertUtensilsExist((input.utensils ?? []).map((u) => u.utensilId));
|
await assertUtensilsExist((input.utensils ?? []).map((u) => u.utensilId));
|
||||||
|
|
||||||
const { correction, techSteps } = await prisma.$transaction(async (tx) => {
|
const { correction, techSteps, placeholderTechStepId } = await prisma.$transaction(
|
||||||
const createdCorrection = await tx.stepTechStepCorrection.create({
|
async (tx) => {
|
||||||
data: {
|
// A technique the catalog doesn't have (issue #130) — mint a
|
||||||
stepId: step.id,
|
// placeholder `TechStep` for it, in the same transaction as the
|
||||||
correctorId,
|
// correction that references it, then proceed exactly as if its id had
|
||||||
start: input.start,
|
// been sent as `correctedTechStepId`. Same shape as
|
||||||
end: input.end,
|
// `recipe.service.ts`'s `resolveIngredientLines` does for a
|
||||||
previousTechStepId: input.previousTechStepId ?? null,
|
// free-text ingredient.
|
||||||
correctedTechStepId: input.correctedTechStepId ?? null,
|
const placeholderTechStepId =
|
||||||
},
|
input.correctedTechStepName !== undefined
|
||||||
include: correctionInclude,
|
? (
|
||||||
});
|
await tx.techStep.create({
|
||||||
|
data: {
|
||||||
|
key: `placeholder:${randomUUID()}`,
|
||||||
|
isPlaceholder: true,
|
||||||
|
displayName: input.correctedTechStepName,
|
||||||
|
createdById: correctorId,
|
||||||
|
createdAt: new Date(),
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
).id
|
||||||
|
: null;
|
||||||
|
const effectiveCorrectedTechStepId =
|
||||||
|
placeholderTechStepId ?? input.correctedTechStepId ?? null;
|
||||||
|
|
||||||
await applyManualCorrection(
|
const createdCorrection = await tx.stepTechStepCorrection.create({
|
||||||
tx,
|
data: {
|
||||||
step.id,
|
stepId: step.id,
|
||||||
{ start: input.start, end: input.end },
|
correctorId,
|
||||||
input.previousTechStepId ?? null,
|
start: input.start,
|
||||||
input.correctedTechStepId ?? null,
|
end: input.end,
|
||||||
input.ingredients === undefined &&
|
previousTechStepId: input.previousTechStepId ?? null,
|
||||||
input.utensils === undefined &&
|
correctedTechStepId: effectiveCorrectedTechStepId,
|
||||||
input.temperatures === undefined
|
|
||||||
? undefined
|
|
||||||
: {
|
|
||||||
ingredients: (input.ingredients ?? []).map((ingredient) => ({
|
|
||||||
ingredientId: ingredient.ingredientId,
|
|
||||||
quantity: ingredient.quantity ?? null,
|
|
||||||
unitId: ingredient.unitId ?? null,
|
|
||||||
start: ingredient.start,
|
|
||||||
end: ingredient.end,
|
|
||||||
})),
|
|
||||||
utensils: (input.utensils ?? []).map((utensil) => ({
|
|
||||||
utensilId: utensil.utensilId,
|
|
||||||
start: utensil.start,
|
|
||||||
end: utensil.end,
|
|
||||||
})),
|
|
||||||
// `raw` is derived here (not sent by the client) — see
|
|
||||||
// `ManualTemperatureMention`'s own doc comment.
|
|
||||||
temperatures: (input.temperatures ?? []).map((temperature) => ({
|
|
||||||
celsius: temperature.celsius ?? null,
|
|
||||||
gasMark: temperature.gasMark ?? null,
|
|
||||||
qualitative: temperature.qualitative ?? null,
|
|
||||||
raw: step.description.slice(temperature.start, temperature.end),
|
|
||||||
start: temperature.start,
|
|
||||||
end: temperature.end,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Same nested `ingredients`/`utensils`/`temperatures` include as
|
|
||||||
// `recipe.service.ts`'s `recipeInclude` — `toStepTechStepViews`
|
|
||||||
// (reused below) expects it, so the fresh sequence read right after a
|
|
||||||
// manual correction resolves exactly the same way a normal
|
|
||||||
// `GET /recipes/:id` would.
|
|
||||||
const freshTechSteps = await tx.stepTechStep.findMany({
|
|
||||||
where: { stepId: step.id },
|
|
||||||
orderBy: { order: "asc" },
|
|
||||||
include: {
|
|
||||||
techStep: true,
|
|
||||||
ingredients: {
|
|
||||||
include: {
|
|
||||||
ingredient: {
|
|
||||||
include: {
|
|
||||||
allergies: { include: { allergy: { include: { category: true } } } },
|
|
||||||
diets: { include: { diet: true } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
unit: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
utensils: { include: { utensil: true } },
|
include: correctionInclude,
|
||||||
temperatures: true,
|
});
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return { correction: createdCorrection, techSteps: freshTechSteps };
|
await applyManualCorrection(
|
||||||
});
|
tx,
|
||||||
|
step.id,
|
||||||
|
{ start: input.start, end: input.end },
|
||||||
|
input.previousTechStepId ?? null,
|
||||||
|
effectiveCorrectedTechStepId,
|
||||||
|
input.ingredients === undefined &&
|
||||||
|
input.utensils === undefined &&
|
||||||
|
input.temperatures === undefined
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
ingredients: (input.ingredients ?? []).map((ingredient) => ({
|
||||||
|
ingredientId: ingredient.ingredientId,
|
||||||
|
quantity: ingredient.quantity ?? null,
|
||||||
|
unitId: ingredient.unitId ?? null,
|
||||||
|
start: ingredient.start,
|
||||||
|
end: ingredient.end,
|
||||||
|
})),
|
||||||
|
utensils: (input.utensils ?? []).map((utensil) => ({
|
||||||
|
utensilId: utensil.utensilId,
|
||||||
|
start: utensil.start,
|
||||||
|
end: utensil.end,
|
||||||
|
})),
|
||||||
|
// `raw` is derived here (not sent by the client) — see
|
||||||
|
// `ManualTemperatureMention`'s own doc comment.
|
||||||
|
temperatures: (input.temperatures ?? []).map((temperature) => ({
|
||||||
|
celsius: temperature.celsius ?? null,
|
||||||
|
gasMark: temperature.gasMark ?? null,
|
||||||
|
qualitative: temperature.qualitative ?? null,
|
||||||
|
raw: step.description.slice(temperature.start, temperature.end),
|
||||||
|
start: temperature.start,
|
||||||
|
end: temperature.end,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Same nested `ingredients`/`utensils`/`temperatures` include as
|
||||||
|
// `recipe.service.ts`'s `recipeInclude` — `toStepTechStepViews`
|
||||||
|
// (reused below) expects it, so the fresh sequence read right after a
|
||||||
|
// manual correction resolves exactly the same way a normal
|
||||||
|
// `GET /recipes/:id` would.
|
||||||
|
const freshTechSteps = await tx.stepTechStep.findMany({
|
||||||
|
where: { stepId: step.id },
|
||||||
|
orderBy: { order: "asc" },
|
||||||
|
include: {
|
||||||
|
techStep: true,
|
||||||
|
ingredients: {
|
||||||
|
include: {
|
||||||
|
ingredient: {
|
||||||
|
include: {
|
||||||
|
allergies: { include: { allergy: { include: { category: true } } } },
|
||||||
|
diets: { include: { diet: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
unit: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
utensils: { include: { utensil: true } },
|
||||||
|
temperatures: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { correction: createdCorrection, techSteps: freshTechSteps, placeholderTechStepId };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
analytics.recordEvent("tech_step.correction_submitted", {
|
analytics.recordEvent("tech_step.correction_submitted", {
|
||||||
actorId: correctorId,
|
actorId: correctorId,
|
||||||
|
|
@ -533,9 +570,20 @@ export async function submitTechStepCorrection(
|
||||||
recipeId,
|
recipeId,
|
||||||
stepId,
|
stepId,
|
||||||
previousTechStepId: input.previousTechStepId ?? null,
|
previousTechStepId: input.previousTechStepId ?? null,
|
||||||
correctedTechStepId: input.correctedTechStepId ?? null,
|
correctedTechStepId: correction.correctedTechStepId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (placeholderTechStepId !== null) {
|
||||||
|
analytics.recordEvent("tech_step.placeholder_proposed", {
|
||||||
|
actorId: correctorId,
|
||||||
|
context: {
|
||||||
|
recipeId,
|
||||||
|
stepId,
|
||||||
|
techStepId: placeholderTechStepId,
|
||||||
|
name: input.correctedTechStepName ?? "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return { correction: toCorrectionView(correction), techSteps: toStepTechStepViews(techSteps) };
|
return { correction: toCorrectionView(correction), techSteps: toStepTechStepViews(techSteps) };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -211,7 +211,12 @@ export function toStepTechStepViews(
|
||||||
} = stepTechStep;
|
} = stepTechStep;
|
||||||
if (start === null || end === null) continue;
|
if (start === null || end === null) continue;
|
||||||
views.push({
|
views.push({
|
||||||
techStep: { id: techStep.id, key: techStep.key },
|
techStep: {
|
||||||
|
id: techStep.id,
|
||||||
|
key: techStep.key,
|
||||||
|
isPlaceholder: techStep.isPlaceholder,
|
||||||
|
displayName: techStep.displayName,
|
||||||
|
},
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
// `source` is a plain DB `String`, not a Prisma enum (see
|
// `source` is a plain DB `String`, not a Prisma enum (see
|
||||||
|
|
|
||||||
|
|
@ -77,11 +77,21 @@ export async function getUnits(): Promise<UnitView[]> {
|
||||||
/**
|
/**
|
||||||
* All reference cooking techniques, ordered by key (see {@link getDiets}
|
* All reference cooking techniques, ordered by key (see {@link getDiets}
|
||||||
* for why) — small, static list (see `reference-seed-data.ts`'s
|
* for why) — small, static list (see `reference-seed-data.ts`'s
|
||||||
* `TECH_STEPS`). Not consumed by the recipe UI yet — see {@link TechStepView}.
|
* `TECH_STEPS`).
|
||||||
|
*
|
||||||
|
* Excludes placeholder rows (`TechStep.isPlaceholder` — the free-text
|
||||||
|
* techniques users propose when the catalog falls short, issue #130): this
|
||||||
|
* is the *browsable* catalog, and a placeholder is a per-correction
|
||||||
|
* stand-in, not a real entry anyone should pick again.
|
||||||
*/
|
*/
|
||||||
export async function getTechSteps(): Promise<TechStepView[]> {
|
export async function getTechSteps(): Promise<TechStepView[]> {
|
||||||
try {
|
try {
|
||||||
return await prisma.techStep.findMany({ orderBy: { key: "asc" } });
|
const techSteps = await prisma.techStep.findMany({
|
||||||
|
where: { isPlaceholder: false },
|
||||||
|
select: { id: true, key: true },
|
||||||
|
orderBy: { key: "asc" },
|
||||||
|
});
|
||||||
|
return techSteps.map((techStep) => ({ ...techStep, isPlaceholder: false, displayName: null }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err; // see getDiets()'s catch comment above
|
throw err; // see getDiets()'s catch comment above
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -180,9 +180,20 @@ export async function previewSourceItem(
|
||||||
),
|
),
|
||||||
loadIngredientCatalog(adapter.locale),
|
loadIngredientCatalog(adapter.locale),
|
||||||
loadUnitCatalog(adapter.locale),
|
loadUnitCatalog(adapter.locale),
|
||||||
prisma.techStep.findMany({ select: { id: true, key: true } }),
|
// Real catalog techniques only — a draft preview is never a saved
|
||||||
|
// recipe, so it can't carry a user-proposed placeholder technique
|
||||||
|
// (issue #130); `isPlaceholder`/`displayName` are the neutral values.
|
||||||
|
prisma.techStep.findMany({
|
||||||
|
where: { isPlaceholder: false },
|
||||||
|
select: { id: true, key: true },
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
const techStepById = new Map(techStepsByKey.map((techStep) => [techStep.id, techStep]));
|
const techStepById = new Map(
|
||||||
|
techStepsByKey.map((techStep) => [
|
||||||
|
techStep.id,
|
||||||
|
{ ...techStep, isPlaceholder: false, displayName: null },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
const translatedIngredients = translateRecipeIngredients(
|
const translatedIngredients = translateRecipeIngredients(
|
||||||
parsed.ingredients,
|
parsed.ingredients,
|
||||||
|
|
|
||||||
|
|
@ -139,3 +139,118 @@ describe("Admin catalog — off-catalog ingredients", () => {
|
||||||
expect(await prisma.ingredient.count({ where: { isPlaceholder: true } })).to.equal(1);
|
expect(await prisma.ingredient.count({ where: { isPlaceholder: true } })).to.equal(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `/admin/api/catalog/tech-step-placeholders` — the missing-technique review
|
||||||
|
* (issue #130). Flat list, `mark-reviewed` stamps `reviewedAt`; every route
|
||||||
|
* behind `requireAdmin`.
|
||||||
|
*/
|
||||||
|
describe("Admin catalog — missing techniques", () => {
|
||||||
|
const app = createApp();
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await resetDatabase();
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function adminAgent() {
|
||||||
|
const { email, password } = await seedAdmin();
|
||||||
|
const agent = request.agent(app);
|
||||||
|
await agent.post("/admin/api/auth/login").send({ email, password });
|
||||||
|
return agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A `PUBLIC` recipe whose single step carries a placeholder technique named `name`. Returns the placeholder `TechStep` id. */
|
||||||
|
async function seedPlaceholderTechnique(name: string): Promise<number> {
|
||||||
|
const author = await prisma.userProfile.create({
|
||||||
|
data: {
|
||||||
|
firstName: "T",
|
||||||
|
lastName: "A",
|
||||||
|
email: `${faker.string.uuid()}@example.test`,
|
||||||
|
passwordHash: "x",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const techStep = await prisma.techStep.create({
|
||||||
|
data: {
|
||||||
|
key: `placeholder:${faker.string.uuid()}`,
|
||||||
|
isPlaceholder: true,
|
||||||
|
displayName: name,
|
||||||
|
createdById: author.id,
|
||||||
|
createdAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.recipe.create({
|
||||||
|
data: {
|
||||||
|
name: faker.lorem.words(3),
|
||||||
|
authorId: author.id,
|
||||||
|
portions: 2,
|
||||||
|
visibility: "PUBLIC",
|
||||||
|
steps: {
|
||||||
|
create: [
|
||||||
|
{
|
||||||
|
description: "Une étape.",
|
||||||
|
order: 0,
|
||||||
|
techSteps: {
|
||||||
|
create: [{ techStepId: techStep.id, order: 0, start: 0, end: 4, source: "manual" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return techStep.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("rejects both routes without an admin session", async () => {
|
||||||
|
expect((await request(app).get("/admin/api/catalog/tech-step-placeholders")).status).to.equal(
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await request(app)
|
||||||
|
.patch("/admin/api/catalog/tech-step-placeholders/mark-reviewed")
|
||||||
|
.send({ techStepIds: [1] })
|
||||||
|
).status,
|
||||||
|
).to.equal(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists proposed techniques with their recipe count, impact-ordered", async () => {
|
||||||
|
await seedPlaceholderTechnique("Flamber au chalumeau");
|
||||||
|
await seedPlaceholderTechnique("Confire à basse température");
|
||||||
|
|
||||||
|
const agent = await adminAgent();
|
||||||
|
const res = await agent.get("/admin/api/catalog/tech-step-placeholders");
|
||||||
|
|
||||||
|
expect(res.status).to.equal(200);
|
||||||
|
expect(res.body).to.have.length(2);
|
||||||
|
expect(res.body.map((r: { displayName: string }) => r.displayName)).to.have.members([
|
||||||
|
"Flamber au chalumeau",
|
||||||
|
"Confire à basse température",
|
||||||
|
]);
|
||||||
|
expect(res.body[0].recipeCount).to.equal(1);
|
||||||
|
expect(res.body[0].reviewedAt).to.equal(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mark-reviewed stamps reviewedAt and moves the row to the reviewed list", async () => {
|
||||||
|
const id = await seedPlaceholderTechnique("Snacker à la plancha");
|
||||||
|
const agent = await adminAgent();
|
||||||
|
|
||||||
|
const patched = await agent
|
||||||
|
.patch("/admin/api/catalog/tech-step-placeholders/mark-reviewed")
|
||||||
|
.send({ techStepIds: [id] });
|
||||||
|
expect(patched.status).to.equal(200);
|
||||||
|
expect(patched.body.reviewed).to.equal(1);
|
||||||
|
expect(
|
||||||
|
(await prisma.techStep.findUniqueOrThrow({ where: { id } })).reviewedAt,
|
||||||
|
).to.be.an.instanceOf(Date);
|
||||||
|
|
||||||
|
expect((await agent.get("/admin/api/catalog/tech-step-placeholders")).body).to.have.length(0);
|
||||||
|
const reviewed = await agent
|
||||||
|
.get("/admin/api/catalog/tech-step-placeholders")
|
||||||
|
.query({ reviewed: "true" });
|
||||||
|
expect(reviewed.body).to.have.length(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -469,7 +469,12 @@ describe("Recipes", () => {
|
||||||
// response itself carries exactly what to highlight, not just the id.
|
// response itself carries exactly what to highlight, not just the id.
|
||||||
const resStep = res.body.steps[0];
|
const resStep = res.body.steps[0];
|
||||||
expect(resStep.techSteps).to.have.length(1);
|
expect(resStep.techSteps).to.have.length(1);
|
||||||
expect(resStep.techSteps[0].techStep).to.deep.equal({ id: simmer, key: "simmer" });
|
expect(resStep.techSteps[0].techStep).to.deep.equal({
|
||||||
|
id: simmer,
|
||||||
|
key: "simmer",
|
||||||
|
isPlaceholder: false,
|
||||||
|
displayName: null,
|
||||||
|
});
|
||||||
const { start, end } = resStep.techSteps[0];
|
const { start, end } = resStep.techSteps[0];
|
||||||
expect(description.slice(start, end).toLowerCase()).to.equal("mijoter");
|
expect(description.slice(start, end).toLowerCase()).to.equal("mijoter");
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,12 @@ describe("Recipe tech-step corrections", () => {
|
||||||
|
|
||||||
expect(res.status).to.equal(201);
|
expect(res.status).to.equal(201);
|
||||||
expect(res.body.correction.previousTechStep).to.equal(null);
|
expect(res.body.correction.previousTechStep).to.equal(null);
|
||||||
expect(res.body.correction.correctedTechStep).to.deep.equal({ id: simmerId, key: "simmer" });
|
expect(res.body.correction.correctedTechStep).to.deep.equal({
|
||||||
|
id: simmerId,
|
||||||
|
key: "simmer",
|
||||||
|
isPlaceholder: false,
|
||||||
|
displayName: null,
|
||||||
|
});
|
||||||
expect(res.body.correction.start).to.equal(6);
|
expect(res.body.correction.start).to.equal(6);
|
||||||
expect(res.body.correction.end).to.equal(13);
|
expect(res.body.correction.end).to.equal(13);
|
||||||
// The step's real technique sequence reflects the correction right
|
// The step's real technique sequence reflects the correction right
|
||||||
|
|
@ -117,7 +122,7 @@ describe("Recipe tech-step corrections", () => {
|
||||||
// `applyManualCorrection`, `recipe-tech-step-correction.service.ts`).
|
// `applyManualCorrection`, `recipe-tech-step-correction.service.ts`).
|
||||||
expect(res.body.techSteps).to.deep.equal([
|
expect(res.body.techSteps).to.deep.equal([
|
||||||
{
|
{
|
||||||
techStep: { id: simmerId, key: "simmer" },
|
techStep: { id: simmerId, key: "simmer", isPlaceholder: false, displayName: null },
|
||||||
start: 6,
|
start: 6,
|
||||||
end: 13,
|
end: 13,
|
||||||
source: "manual",
|
source: "manual",
|
||||||
|
|
@ -128,6 +133,56 @@ describe("Recipe tech-step corrections", () => {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("proposes a placeholder technique via correctedTechStepName when the catalog is missing one (issue #130)", async () => {
|
||||||
|
const { agent, profileId } = await signup();
|
||||||
|
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
|
||||||
|
|
||||||
|
const res = await agent
|
||||||
|
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
|
||||||
|
.send({ start: 6, end: 13, correctedTechStepName: "Flamber au chalumeau" });
|
||||||
|
|
||||||
|
expect(res.status).to.equal(201);
|
||||||
|
// A placeholder `TechStep` was minted and the correction points at it.
|
||||||
|
expect(res.body.correction.correctedTechStep.isPlaceholder).to.equal(true);
|
||||||
|
expect(res.body.correction.correctedTechStep.displayName).to.equal("Flamber au chalumeau");
|
||||||
|
// ...and the step's live sequence carries it straight away.
|
||||||
|
expect(res.body.techSteps).to.have.length(1);
|
||||||
|
expect(res.body.techSteps[0].techStep.isPlaceholder).to.equal(true);
|
||||||
|
expect(res.body.techSteps[0].techStep.displayName).to.equal("Flamber au chalumeau");
|
||||||
|
expect(res.body.techSteps[0].source).to.equal("manual");
|
||||||
|
|
||||||
|
const row = await prisma.techStep.findUniqueOrThrow({
|
||||||
|
where: { id: res.body.techSteps[0].techStep.id },
|
||||||
|
});
|
||||||
|
expect(row.isPlaceholder).to.equal(true);
|
||||||
|
expect(row.key).to.match(/^placeholder:/);
|
||||||
|
expect(row.createdById).to.equal(profileId);
|
||||||
|
expect(row.createdAt).to.be.an.instanceOf(Date);
|
||||||
|
expect(row.reviewedAt).to.equal(null);
|
||||||
|
// It never leaks into the browsable catalog.
|
||||||
|
const reference = await agent.get("/reference/tech-steps");
|
||||||
|
expect(
|
||||||
|
reference.body.some(
|
||||||
|
(t: { displayName?: string }) => t.displayName === "Flamber au chalumeau",
|
||||||
|
),
|
||||||
|
).to.equal(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects sending both correctedTechStepId and correctedTechStepName with 400 VALIDATION_ERROR", async () => {
|
||||||
|
const { agent, profileId } = await signup();
|
||||||
|
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
|
||||||
|
|
||||||
|
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
|
||||||
|
start: 6,
|
||||||
|
end: 13,
|
||||||
|
correctedTechStepId: await techStepId("simmer"),
|
||||||
|
correctedTechStepName: "Autre chose",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).to.equal(400);
|
||||||
|
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||||
|
});
|
||||||
|
|
||||||
it("records a correction relabeling an existing match (both ids set), updating the existing techSteps entry in place", async () => {
|
it("records a correction relabeling an existing match (both ids set), updating the existing techSteps entry in place", async () => {
|
||||||
const { agent, profileId } = await signup();
|
const { agent, profileId } = await signup();
|
||||||
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
|
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
|
||||||
|
|
@ -145,13 +200,23 @@ describe("Recipe tech-step corrections", () => {
|
||||||
.send({ start: 6, end: 13, previousTechStepId: simmerId, correctedTechStepId: boilId });
|
.send({ start: 6, end: 13, previousTechStepId: simmerId, correctedTechStepId: boilId });
|
||||||
|
|
||||||
expect(res.status).to.equal(201);
|
expect(res.status).to.equal(201);
|
||||||
expect(res.body.correction.previousTechStep).to.deep.equal({ id: simmerId, key: "simmer" });
|
expect(res.body.correction.previousTechStep).to.deep.equal({
|
||||||
expect(res.body.correction.correctedTechStep).to.deep.equal({ id: boilId, key: "boil" });
|
id: simmerId,
|
||||||
|
key: "simmer",
|
||||||
|
isPlaceholder: false,
|
||||||
|
displayName: null,
|
||||||
|
});
|
||||||
|
expect(res.body.correction.correctedTechStep).to.deep.equal({
|
||||||
|
id: boilId,
|
||||||
|
key: "boil",
|
||||||
|
isPlaceholder: false,
|
||||||
|
displayName: null,
|
||||||
|
});
|
||||||
// Still exactly one entry — the relabel updated the existing row
|
// Still exactly one entry — the relabel updated the existing row
|
||||||
// rather than adding a second one alongside it.
|
// rather than adding a second one alongside it.
|
||||||
expect(res.body.techSteps).to.deep.equal([
|
expect(res.body.techSteps).to.deep.equal([
|
||||||
{
|
{
|
||||||
techStep: { id: boilId, key: "boil" },
|
techStep: { id: boilId, key: "boil", isPlaceholder: false, displayName: null },
|
||||||
start: 6,
|
start: 6,
|
||||||
end: 13,
|
end: 13,
|
||||||
source: "manual",
|
source: "manual",
|
||||||
|
|
@ -299,7 +364,7 @@ describe("Recipe tech-step corrections", () => {
|
||||||
expect(res.status).to.equal(201);
|
expect(res.status).to.equal(201);
|
||||||
expect(res.body.techSteps).to.deep.equal([
|
expect(res.body.techSteps).to.deep.equal([
|
||||||
{
|
{
|
||||||
techStep: { id: simmerId, key: "simmer" },
|
techStep: { id: simmerId, key: "simmer", isPlaceholder: false, displayName: null },
|
||||||
start: 6,
|
start: 6,
|
||||||
end: 13,
|
end: 13,
|
||||||
source: "manual",
|
source: "manual",
|
||||||
|
|
@ -535,7 +600,7 @@ describe("Recipe tech-step corrections", () => {
|
||||||
expect(res.status).to.equal(201);
|
expect(res.status).to.equal(201);
|
||||||
expect(res.body.techSteps).to.deep.equal([
|
expect(res.body.techSteps).to.deep.equal([
|
||||||
{
|
{
|
||||||
techStep: { id: preheatId, key: "preheat" },
|
techStep: { id: preheatId, key: "preheat", isPlaceholder: false, displayName: null },
|
||||||
start: 0,
|
start: 0,
|
||||||
end: 11,
|
end: 11,
|
||||||
source: "manual",
|
source: "manual",
|
||||||
|
|
@ -721,8 +786,18 @@ describe("Recipe tech-step corrections", () => {
|
||||||
|
|
||||||
expect(res.status).to.equal(200);
|
expect(res.status).to.equal(200);
|
||||||
expect(res.body).to.have.length(2);
|
expect(res.body).to.have.length(2);
|
||||||
expect(res.body[0].correctedTechStep).to.deep.equal({ id: boilId, key: "boil" });
|
expect(res.body[0].correctedTechStep).to.deep.equal({
|
||||||
expect(res.body[1].correctedTechStep).to.deep.equal({ id: simmerId, key: "simmer" });
|
id: boilId,
|
||||||
|
key: "boil",
|
||||||
|
isPlaceholder: false,
|
||||||
|
displayName: null,
|
||||||
|
});
|
||||||
|
expect(res.body[1].correctedTechStep).to.deep.equal({
|
||||||
|
id: simmerId,
|
||||||
|
key: "simmer",
|
||||||
|
isPlaceholder: false,
|
||||||
|
displayName: null,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns an empty list when nothing has been submitted yet", async () => {
|
it("returns an empty list when nothing has been submitted yet", async () => {
|
||||||
|
|
|
||||||
|
|
@ -148,7 +148,30 @@ describe("Reference data", () => {
|
||||||
// number — this catalog has grown since (26 -> 74) and will again.
|
// number — this catalog has grown since (26 -> 74) and will again.
|
||||||
expect(res.body).to.have.length(TECH_STEPS.length);
|
expect(res.body).to.have.length(TECH_STEPS.length);
|
||||||
expect(res.body.map((t: { key: string }) => t.key)).to.include("simmer");
|
expect(res.body.map((t: { key: string }) => t.key)).to.include("simmer");
|
||||||
expect(res.body[0]).to.have.keys(["id", "key"]);
|
expect(res.body[0]).to.have.keys(["id", "key", "isPlaceholder", "displayName"]);
|
||||||
|
expect(res.body.every((t: { isPlaceholder: boolean }) => t.isPlaceholder === false)).to.equal(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes placeholder techniques users proposed while correcting a step (issue #130)", async () => {
|
||||||
|
await prisma.techStep.create({
|
||||||
|
data: {
|
||||||
|
key: "placeholder:test-1",
|
||||||
|
isPlaceholder: true,
|
||||||
|
displayName: "Flamber au chalumeau",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app).get("/reference/tech-steps");
|
||||||
|
|
||||||
|
expect(res.status).to.equal(200);
|
||||||
|
expect(res.body).to.have.length(TECH_STEPS.length);
|
||||||
|
expect(
|
||||||
|
res.body.some(
|
||||||
|
(t: { displayName: string | null }) => t.displayName === "Flamber au chalumeau",
|
||||||
|
),
|
||||||
|
).to.equal(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("orders techniques alphabetically by key", async () => {
|
it("orders techniques alphabetically by key", async () => {
|
||||||
|
|
|
||||||
|
|
@ -194,6 +194,41 @@ describe("TechStepCorrectionPopover", () => {
|
||||||
cy.get("@onSubmitted").should("have.been.calledOnce");
|
cy.get("@onSubmitted").should("have.been.calledOnce");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("proposes a technique the catalog is missing via the 'pas dans la liste' escape hatch (issue #130)", () => {
|
||||||
|
cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
|
||||||
|
statusCode: 201,
|
||||||
|
body: {
|
||||||
|
id: 1,
|
||||||
|
start: 0,
|
||||||
|
end: 5,
|
||||||
|
previousTechStep: null,
|
||||||
|
correctedTechStep: {
|
||||||
|
id: 99,
|
||||||
|
key: "placeholder:x",
|
||||||
|
isPlaceholder: true,
|
||||||
|
displayName: "Flamber au chalumeau",
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
}).as("submitCorrection");
|
||||||
|
cy.mount(<Harness />);
|
||||||
|
cy.wait("@getTechSteps");
|
||||||
|
|
||||||
|
cy.contains("button", "Ma technique n'est pas dans la liste").click();
|
||||||
|
cy.get(".tech-step-correction-popover__missing-technique input").type("Flamber au chalumeau");
|
||||||
|
cy.contains(".tech-step-correction-popover__missing-technique button", "Ajouter").click();
|
||||||
|
|
||||||
|
cy.contains(".tech-step-correction-popover__chosen-technique", "Flamber au chalumeau");
|
||||||
|
cy.contains("button", "Valider").should("not.be.disabled").click();
|
||||||
|
|
||||||
|
cy.wait("@submitCorrection").its("request.body").should("deep.equal", {
|
||||||
|
start: 0,
|
||||||
|
end: 5,
|
||||||
|
previousTechStepId: null,
|
||||||
|
correctedTechStepName: "Flamber au chalumeau",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("adds an ingredient with quantity/unit via the span-selection flow, included in the submitted request", () => {
|
it("adds an ingredient with quantity/unit via the span-selection flow, included in the submitted request", () => {
|
||||||
cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
|
cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
|
||||||
statusCode: 201,
|
statusCode: 201,
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,45 @@ describe("Admin catalog — off-catalog ingredients", () => {
|
||||||
cy.get("@getPlaceholders.all").should("have.length.greaterThan", 1);
|
cy.get("@getPlaceholders.all").should("have.length.greaterThan", 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("switches to the Techniques kind and lists proposed missing techniques (issue #130)", () => {
|
||||||
|
cy.intercept("GET", "**/admin/api/catalog/placeholders*", { statusCode: 200, body: [] });
|
||||||
|
cy.intercept("GET", "**/admin/api/catalog/tech-step-placeholders*", {
|
||||||
|
statusCode: 200,
|
||||||
|
body: [
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
displayName: "Flamber au chalumeau",
|
||||||
|
createdAt: "2026-09-01T10:00:00.000Z",
|
||||||
|
reviewedAt: null,
|
||||||
|
recipeCount: 2,
|
||||||
|
sampleRecipes: [
|
||||||
|
{ id: 1, name: "Crème brûlée" },
|
||||||
|
{ id: 2, name: "Tarte flambée" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).as("getTechStepPlaceholders");
|
||||||
|
cy.intercept("PATCH", "**/admin/api/catalog/tech-step-placeholders/mark-reviewed", {
|
||||||
|
statusCode: 200,
|
||||||
|
body: { reviewed: 1 },
|
||||||
|
}).as("markTechStepReviewed");
|
||||||
|
|
||||||
|
cy.visit("/admin/catalogue");
|
||||||
|
cy.contains(".catalog-kinds button", "Techniques").click();
|
||||||
|
cy.wait("@getTechStepPlaceholders");
|
||||||
|
|
||||||
|
cy.contains(".catalog-card", "Flamber au chalumeau")
|
||||||
|
.should("contain.text", "2 recette")
|
||||||
|
.and("contain.text", "Crème brûlée");
|
||||||
|
|
||||||
|
cy.contains(".catalog-card", "Flamber au chalumeau")
|
||||||
|
.contains("button", "Marquer comme traité")
|
||||||
|
.click();
|
||||||
|
cy.wait("@markTechStepReviewed")
|
||||||
|
.its("request.body")
|
||||||
|
.should("deep.equal", { techStepIds: [5] });
|
||||||
|
});
|
||||||
|
|
||||||
it("switches to the reviewed archive tab", () => {
|
it("switches to the reviewed archive tab", () => {
|
||||||
// Regex, not a glob: the two calls differ only by the `?reviewed=true`
|
// Regex, not a glob: the two calls differ only by the `?reviewed=true`
|
||||||
// query, and minimatch's `?` is itself a wildcard — a glob can't tell
|
// query, and minimatch's `?` is itself a wildcard — a glob can't tell
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,12 @@ import {
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
type ForgejoIntegrationStatus,
|
type ForgejoIntegrationStatus,
|
||||||
type MarkPlaceholdersReviewedInput,
|
type MarkPlaceholdersReviewedInput,
|
||||||
|
type MarkTechStepPlaceholdersReviewedInput,
|
||||||
type MetricsView,
|
type MetricsView,
|
||||||
type PruneOrphansResultView,
|
type PruneOrphansResultView,
|
||||||
type RetrainRequestInput,
|
type RetrainRequestInput,
|
||||||
type RetrainResultView,
|
type RetrainResultView,
|
||||||
|
type TechStepPlaceholderView,
|
||||||
type TourTargetView,
|
type TourTargetView,
|
||||||
type TrainingDataSnippetView,
|
type TrainingDataSnippetView,
|
||||||
type TrainingSuggestionAdminView,
|
type TrainingSuggestionAdminView,
|
||||||
|
|
@ -195,6 +197,21 @@ export class AdminApiClient {
|
||||||
return this._request("/catalog/placeholders/prune-orphans", { method: "POST" });
|
return this._request("/catalog/placeholders/prune-orphans", { method: "POST" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Free-text techniques users proposed while correcting a step, when the catalog was missing one (issue #130). Same `reviewed` tri-state as {@link getPlaceholders}. */
|
||||||
|
public getTechStepPlaceholders(reviewed?: "true" | "false"): Promise<TechStepPlaceholderView[]> {
|
||||||
|
return this._request(`/catalog/tech-step-placeholders${query({ reviewed })}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Marks the given placeholder technique ids as triaged (`reviewedAt`). */
|
||||||
|
public markTechStepPlaceholdersReviewed(
|
||||||
|
body: MarkTechStepPlaceholdersReviewedInput,
|
||||||
|
): Promise<{ reviewed: number }> {
|
||||||
|
return this._request("/catalog/tech-step-placeholders/mark-reviewed", {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Every guided-tour scenario — enabled *and* disabled — with its ordered steps (issue #44). */
|
/** Every guided-tour scenario — enabled *and* disabled — with its ordered steps (issue #44). */
|
||||||
public getTourScenarios(): Promise<AdminTourScenarioView[]> {
|
public getTourScenarios(): Promise<AdminTourScenarioView[]> {
|
||||||
return this._request("/tours");
|
return this._request("/tours");
|
||||||
|
|
|
||||||
|
|
@ -889,6 +889,26 @@
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Ma technique n'est pas dans la liste" sub-form (issue #130) — a name
|
||||||
|
// field + a small confirm, shown in place of the "not in list" link once
|
||||||
|
// it's clicked.
|
||||||
|
&__missing-technique {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
|
||||||
|
input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: var(--space-xs) var(--space-sm);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__remove-match {
|
&__remove-match {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { Tooltip } from "../../../components/ui/Tooltip";
|
||||||
import { useTour } from "../../tour/TourContext";
|
import { useTour } from "../../tour/TourContext";
|
||||||
import { splitDescriptionSegments } from "./highlight-tech-steps";
|
import { splitDescriptionSegments } from "./highlight-tech-steps";
|
||||||
import { TechStepCorrectionPopover } from "./TechStepCorrectionPopover";
|
import { TechStepCorrectionPopover } from "./TechStepCorrectionPopover";
|
||||||
|
import { techStepLabel } from "./tech-step-label";
|
||||||
import { temperatureLabel } from "./temperature-label";
|
import { temperatureLabel } from "./temperature-label";
|
||||||
import { type TextSelectionRange, useTextSelection } from "./use-text-selection";
|
import { type TextSelectionRange, useTextSelection } from "./use-text-selection";
|
||||||
|
|
||||||
|
|
@ -318,11 +319,12 @@ export function StepDescription({
|
||||||
|
|
||||||
const techStep = segment.techStep;
|
const techStep = segment.techStep;
|
||||||
const isManual = segment.source === "manual";
|
const isManual = segment.source === "manual";
|
||||||
|
// `displayName` for a user-proposed technique the catalog is
|
||||||
|
// missing (issue #130), the i18n label otherwise.
|
||||||
|
const techLabel = techStepLabel(techStep, t);
|
||||||
const tooltipLabel = isManual
|
const tooltipLabel = isManual
|
||||||
? t("recipes.techStepCorrection.manualTooltip", {
|
? t("recipes.techStepCorrection.manualTooltip", { technique: techLabel })
|
||||||
technique: t(`catalog.techSteps.${techStep.key}`),
|
: techLabel;
|
||||||
})
|
|
||||||
: t(`catalog.techSteps.${techStep.key}`);
|
|
||||||
return (
|
return (
|
||||||
<Tooltip key={key} content={tooltipLabel}>
|
<Tooltip key={key} content={tooltipLabel}>
|
||||||
{/* A real <button>, not a <mark>, so it's natively focusable
|
{/* A real <button>, not a <mark>, so it's natively focusable
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import { ApiError, apiClient } from "../../../api/client";
|
||||||
import { errorMessageService } from "../../../services/error-message.service";
|
import { errorMessageService } from "../../../services/error-message.service";
|
||||||
import { ingredientLabel } from "../ingredients/ingredient-label";
|
import { ingredientLabel } from "../ingredients/ingredient-label";
|
||||||
import { CatalogSearchPicker } from "./CatalogSearchPicker";
|
import { CatalogSearchPicker } from "./CatalogSearchPicker";
|
||||||
|
import { techStepLabel } from "./tech-step-label";
|
||||||
import { temperatureLabel } from "./temperature-label";
|
import { temperatureLabel } from "./temperature-label";
|
||||||
import type { TextSelectionRange } from "./use-text-selection";
|
import type { TextSelectionRange } from "./use-text-selection";
|
||||||
|
|
||||||
|
|
@ -358,6 +359,13 @@ export function TechStepCorrectionPopover({
|
||||||
const popoverRef = useRef<HTMLDivElement>(null);
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
const [techSteps, setTechSteps] = useState<TechStepView[] | null>(null);
|
const [techSteps, setTechSteps] = useState<TechStepView[] | null>(null);
|
||||||
const [selectedTechStepId, setSelectedTechStepId] = useState<number | null>(previousTechStepId);
|
const [selectedTechStepId, setSelectedTechStepId] = useState<number | null>(previousTechStepId);
|
||||||
|
// A free-text technique the catalog doesn't have (issue #130) — set from
|
||||||
|
// the "pas dans la liste" sub-form. Mutually exclusive with
|
||||||
|
// `selectedTechStepId`: whichever is set is what `confirm()` submits (as
|
||||||
|
// `correctedTechStepName` vs `correctedTechStepId`).
|
||||||
|
const [proposedTechStepName, setProposedTechStepName] = useState<string | null>(null);
|
||||||
|
const [isNamingMissingTechnique, setIsNamingMissingTechnique] = useState(false);
|
||||||
|
const [missingTechniqueDraft, setMissingTechniqueDraft] = useState("");
|
||||||
// The technique picker hides behind "Changer" once something's already
|
// The technique picker hides behind "Changer" once something's already
|
||||||
// picked (see this component's own doc comment) — starts open only when
|
// picked (see this component's own doc comment) — starts open only when
|
||||||
// there's nothing to show yet (a brand-new selection).
|
// there's nothing to show yet (a brand-new selection).
|
||||||
|
|
@ -506,7 +514,7 @@ export function TechStepCorrectionPopover({
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirm() {
|
async function confirm() {
|
||||||
if (selectedTechStepId === null) return;
|
if (selectedTechStepId === null && proposedTechStepName === null) return;
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
|
|
@ -514,7 +522,11 @@ export function TechStepCorrectionPopover({
|
||||||
start: range.start,
|
start: range.start,
|
||||||
end: range.end,
|
end: range.end,
|
||||||
previousTechStepId,
|
previousTechStepId,
|
||||||
correctedTechStepId: selectedTechStepId,
|
// Exactly one of these — a catalog id, or free text for a technique
|
||||||
|
// the catalog is missing (issue #130).
|
||||||
|
...(proposedTechStepName !== null
|
||||||
|
? { correctedTechStepName: proposedTechStepName }
|
||||||
|
: { correctedTechStepId: selectedTechStepId ?? undefined }),
|
||||||
...(metadataTouched
|
...(metadataTouched
|
||||||
? {
|
? {
|
||||||
ingredients: pendingIngredients.map(({ quote: _quote, ...rest }) => rest),
|
ingredients: pendingIngredients.map(({ quote: _quote, ...rest }) => rest),
|
||||||
|
|
@ -616,10 +628,14 @@ export function TechStepCorrectionPopover({
|
||||||
const ingredientById = new Map((catalogs?.ingredients ?? []).map((i) => [i.id, i]));
|
const ingredientById = new Map((catalogs?.ingredients ?? []).map((i) => [i.id, i]));
|
||||||
const unitById = new Map((catalogs?.units ?? []).map((u) => [u.id, u]));
|
const unitById = new Map((catalogs?.units ?? []).map((u) => [u.id, u]));
|
||||||
const utensilById = new Map((catalogs?.utensils ?? []).map((u) => [u.id, u]));
|
const utensilById = new Map((catalogs?.utensils ?? []).map((u) => [u.id, u]));
|
||||||
|
const selectedTechStep =
|
||||||
|
selectedTechStepId !== null ? techSteps?.find((ts) => ts.id === selectedTechStepId) : undefined;
|
||||||
const currentTechniqueLabel =
|
const currentTechniqueLabel =
|
||||||
selectedTechStepId !== null
|
proposedTechStepName !== null
|
||||||
? t(`catalog.techSteps.${techSteps?.find((ts) => ts.id === selectedTechStepId)?.key ?? ""}`)
|
? proposedTechStepName
|
||||||
: null;
|
: selectedTechStep
|
||||||
|
? techStepLabel(selectedTechStep, t)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="tech-step-correction-popover" ref={popoverRef}>
|
<div className="tech-step-correction-popover" ref={popoverRef}>
|
||||||
|
|
@ -652,7 +668,11 @@ export function TechStepCorrectionPopover({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="tech-step-correction-popover__technique-change"
|
className="tech-step-correction-popover__technique-change"
|
||||||
onClick={() => setIsChangingTechnique(true)}
|
onClick={() => {
|
||||||
|
setIsChangingTechnique(true);
|
||||||
|
setProposedTechStepName(null);
|
||||||
|
setIsNamingMissingTechnique(false);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{t("recipes.techStepCorrection.changeTechnique")}
|
{t("recipes.techStepCorrection.changeTechnique")}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -670,19 +690,61 @@ export function TechStepCorrectionPopover({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(currentTechniqueLabel === null || isChangingTechnique) && (
|
{(currentTechniqueLabel === null || isChangingTechnique) && (
|
||||||
<CatalogSearchPicker
|
<>
|
||||||
items={techSteps.map((techStep) => ({
|
<CatalogSearchPicker
|
||||||
id: techStep.id,
|
items={techSteps.map((techStep) => ({
|
||||||
label: t(`catalog.techSteps.${techStep.key}`),
|
id: techStep.id,
|
||||||
}))}
|
label: techStepLabel(techStep, t),
|
||||||
selectedId={selectedTechStepId ?? undefined}
|
}))}
|
||||||
onSelect={(id) => {
|
selectedId={selectedTechStepId ?? undefined}
|
||||||
setSelectedTechStepId(id);
|
onSelect={(id) => {
|
||||||
setIsChangingTechnique(false);
|
setSelectedTechStepId(id);
|
||||||
}}
|
setProposedTechStepName(null);
|
||||||
placeholder={t("recipes.techStepCorrection.searchTechniquePlaceholder")}
|
setIsNamingMissingTechnique(false);
|
||||||
emptyLabel={t("recipes.techStepCorrection.noTechniqueFound")}
|
setIsChangingTechnique(false);
|
||||||
/>
|
}}
|
||||||
|
placeholder={t("recipes.techStepCorrection.searchTechniquePlaceholder")}
|
||||||
|
emptyLabel={t("recipes.techStepCorrection.noTechniqueFound")}
|
||||||
|
/>
|
||||||
|
{/* Escape hatch for a technique the catalog is missing (issue
|
||||||
|
#130) — the correction still applies immediately, and the
|
||||||
|
proposed name lands in the admin catalogue-gap review. */}
|
||||||
|
{isNamingMissingTechnique ? (
|
||||||
|
<div className="tech-step-correction-popover__missing-technique">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={missingTechniqueDraft}
|
||||||
|
onChange={(e) => setMissingTechniqueDraft(e.target.value)}
|
||||||
|
placeholder={t("recipes.techStepCorrection.missingTechniquePlaceholder")}
|
||||||
|
aria-label={t("recipes.techStepCorrection.missingTechniqueLabel")}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tech-step-correction-popover__confirm-mini"
|
||||||
|
disabled={missingTechniqueDraft.trim().length === 0}
|
||||||
|
onClick={() => {
|
||||||
|
setProposedTechStepName(missingTechniqueDraft.trim());
|
||||||
|
setSelectedTechStepId(null);
|
||||||
|
setIsNamingMissingTechnique(false);
|
||||||
|
setIsChangingTechnique(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("recipes.techStepCorrection.missingTechniqueAdd")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tech-step-correction-popover__technique-change"
|
||||||
|
onClick={() => {
|
||||||
|
setMissingTechniqueDraft("");
|
||||||
|
setIsNamingMissingTechnique(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("recipes.techStepCorrection.techniqueNotInList")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -993,7 +1055,9 @@ export function TechStepCorrectionPopover({
|
||||||
type="button"
|
type="button"
|
||||||
className="tech-step-correction-popover__confirm-button"
|
className="tech-step-correction-popover__confirm-button"
|
||||||
onClick={confirm}
|
onClick={confirm}
|
||||||
disabled={isSubmitting || selectedTechStepId === null}
|
disabled={
|
||||||
|
isSubmitting || (selectedTechStepId === null && proposedTechStepName === null)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{t("recipes.techStepCorrection.confirm")}
|
{t("recipes.techStepCorrection.confirm")}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
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.",
|
"noTechniqueSelected": "Aucune technique sélectionnée.",
|
||||||
"searchTechniquePlaceholder": "Rechercher une technique…",
|
"searchTechniquePlaceholder": "Rechercher une technique…",
|
||||||
"noTechniqueFound": "Aucune technique trouvée.",
|
"noTechniqueFound": "Aucune technique trouvée.",
|
||||||
|
"techniqueNotInList": "Ma technique n'est pas dans la liste",
|
||||||
|
"missingTechniquePlaceholder": "Nommez la technique manquante…",
|
||||||
|
"missingTechniqueLabel": "Technique manquante",
|
||||||
|
"missingTechniqueAdd": "Ajouter",
|
||||||
"ingredientsSection": "Ingrédients",
|
"ingredientsSection": "Ingrédients",
|
||||||
"utensilsSection": "Ustensiles",
|
"utensilsSection": "Ustensiles",
|
||||||
"temperatureSection": "Température",
|
"temperatureSection": "Température",
|
||||||
|
|
@ -1373,9 +1377,14 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"title": "Ingrédients hors-catalogue",
|
"title": "Éléments hors-catalogue",
|
||||||
"lead": "Ingrédients saisis en texte libre par les utilisateurs parce que le catalogue ne les couvrait pas. Regroupés par nom normalisé — à promouvoir dans reference-seed-data.ts + les locales, à la main.",
|
"lead": "Saisis en texte libre par les utilisateurs parce que le catalogue ne les couvrait pas — à promouvoir dans reference-seed-data.ts + les locales, à la main.",
|
||||||
"empty": "Aucun ingrédient hors-catalogue.",
|
"empty": "Aucun ingrédient hors-catalogue.",
|
||||||
|
"techStepEmpty": "Aucune technique proposée à revoir.",
|
||||||
|
"kind": {
|
||||||
|
"ingredients": "Ingrédients",
|
||||||
|
"techSteps": "Techniques"
|
||||||
|
},
|
||||||
"tab": {
|
"tab": {
|
||||||
"pending": "À traiter",
|
"pending": "À traiter",
|
||||||
"reviewed": "Traités"
|
"reviewed": "Traités"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { CatalogPlaceholderGroupView } from "@batch-cooking/shared";
|
import type { CatalogPlaceholderGroupView, TechStepPlaceholderView } from "@batch-cooking/shared";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { adminApiClient } from "../../../api/admin-client";
|
import { adminApiClient } from "../../../api/admin-client";
|
||||||
|
|
@ -6,25 +6,82 @@ import "../admin-page.scss";
|
||||||
import "./catalog-page.scss";
|
import "./catalog-page.scss";
|
||||||
import { type CatalogTab, formatDate, reviewedParam, splitSpellings } from "./catalog";
|
import { type CatalogTab, formatDate, reviewedParam, splitSpellings } from "./catalog";
|
||||||
|
|
||||||
type CatalogState =
|
type IngredientState =
|
||||||
| { status: "loading" }
|
| { status: "loading" }
|
||||||
| { status: "loaded"; groups: CatalogPlaceholderGroupView[] }
|
| { status: "loaded"; groups: CatalogPlaceholderGroupView[] }
|
||||||
| { status: "error" };
|
| { status: "error" };
|
||||||
|
|
||||||
|
type TechStepState =
|
||||||
|
| { status: "loading" }
|
||||||
|
| { status: "loaded"; rows: TechStepPlaceholderView[] }
|
||||||
|
| { status: "error" };
|
||||||
|
|
||||||
|
/** Which catalogue gap the page is reviewing — off-catalog ingredients, or techniques users proposed while correcting a step (issue #130). */
|
||||||
|
type CatalogKind = "ingredients" | "techSteps";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Off-catalog ingredient review. Lists every placeholder `Ingredient` (the
|
* Off-catalog review. Two lists a maintainer works through to see what the
|
||||||
* free text users typed when the seeded catalog fell short — see
|
* seeded catalog is missing: the free-text **ingredients** users typed on a
|
||||||
* `Ingredient.isPlaceholder` in the API schema), grouped by normalized
|
* recipe line (grouped by normalized name), and the free-text **techniques**
|
||||||
* name, so a maintainer sees what the catalog is missing and how many
|
* users proposed while correcting a step (issue #130, a flat list — that's
|
||||||
* recipes are waiting on it. Actions are deliberately minimal: mark a gap
|
* rarer). Actions are deliberately minimal: mark a gap as handled (and, for
|
||||||
* as handled, or purge rows no recipe references any more. Actually adding
|
* ingredients, purge rows no recipe references any more). Adding the real
|
||||||
* the catalog entry stays a manual edit of `reference-seed-data.ts` + the
|
* catalog entry stays a manual edit of `reference-seed-data.ts` + the locale
|
||||||
* locale files.
|
* files.
|
||||||
*/
|
*/
|
||||||
export function CatalogPage() {
|
export function CatalogPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [kind, setKind] = useState<CatalogKind>("ingredients");
|
||||||
const [tab, setTab] = useState<CatalogTab>("pending");
|
const [tab, setTab] = useState<CatalogTab>("pending");
|
||||||
const [state, setState] = useState<CatalogState>({ status: "loading" });
|
|
||||||
|
return (
|
||||||
|
<div className="admin-page">
|
||||||
|
<h1 className="admin-page__title">{t("admin.catalog.title")}</h1>
|
||||||
|
<p className="admin-page__lead">{t("admin.catalog.lead")}</p>
|
||||||
|
|
||||||
|
<div className="catalog-kinds" role="tablist">
|
||||||
|
{(["ingredients", "techSteps"] as const).map((value) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={kind === value}
|
||||||
|
className={kind === value ? "active" : undefined}
|
||||||
|
onClick={() => setKind(value)}
|
||||||
|
>
|
||||||
|
{t(`admin.catalog.kind.${value}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="catalog-tabs" role="tablist">
|
||||||
|
{(["pending", "reviewed"] as const).map((value) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={tab === value}
|
||||||
|
className={tab === value ? "active" : undefined}
|
||||||
|
onClick={() => setTab(value)}
|
||||||
|
>
|
||||||
|
{t(`admin.catalog.tab.${value}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{kind === "ingredients" ? (
|
||||||
|
<IngredientPlaceholders tab={tab} />
|
||||||
|
) : (
|
||||||
|
<TechStepPlaceholders tab={tab} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The off-catalog ingredient list — grouped by normalized name, with a prune-orphans action. */
|
||||||
|
function IngredientPlaceholders({ tab }: { tab: CatalogTab }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [state, setState] = useState<IngredientState>({ status: "loading" });
|
||||||
const [pendingIds, setPendingIds] = useState<number[] | null>(null);
|
const [pendingIds, setPendingIds] = useState<number[] | null>(null);
|
||||||
const [pruneMessage, setPruneMessage] = useState<string | null>(null);
|
const [pruneMessage, setPruneMessage] = useState<string | null>(null);
|
||||||
const [isPruning, setIsPruning] = useState(false);
|
const [isPruning, setIsPruning] = useState(false);
|
||||||
|
|
@ -78,25 +135,8 @@ export function CatalogPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-page">
|
<>
|
||||||
<h1 className="admin-page__title">{t("admin.catalog.title")}</h1>
|
|
||||||
<p className="admin-page__lead">{t("admin.catalog.lead")}</p>
|
|
||||||
|
|
||||||
<div className="catalog-toolbar">
|
<div className="catalog-toolbar">
|
||||||
<div className="catalog-tabs" role="tablist">
|
|
||||||
{(["pending", "reviewed"] as const).map((value) => (
|
|
||||||
<button
|
|
||||||
key={value}
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
aria-selected={tab === value}
|
|
||||||
className={tab === value ? "active" : undefined}
|
|
||||||
onClick={() => setTab(value)}
|
|
||||||
>
|
|
||||||
{t(`admin.catalog.tab.${value}`)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<button type="button" onClick={pruneOrphans} disabled={isPruning}>
|
<button type="button" onClick={pruneOrphans} disabled={isPruning}>
|
||||||
{isPruning ? t("admin.catalog.pruning") : t("admin.catalog.pruneOrphans")}
|
{isPruning ? t("admin.catalog.pruning") : t("admin.catalog.pruneOrphans")}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -124,7 +164,90 @@ export function CatalogPage() {
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
))}
|
))}
|
||||||
</div>
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The proposed-technique list (issue #130) — a flat list, each row markable as reviewed. */
|
||||||
|
function TechStepPlaceholders({ tab }: { tab: CatalogTab }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [state, setState] = useState<TechStepState>({ status: "loading" });
|
||||||
|
const [busyId, setBusyId] = useState<number | null>(null);
|
||||||
|
const requestSeq = useRef(0);
|
||||||
|
|
||||||
|
const load = useCallback((forTab: CatalogTab) => {
|
||||||
|
const seq = ++requestSeq.current;
|
||||||
|
setState({ status: "loading" });
|
||||||
|
adminApiClient
|
||||||
|
.getTechStepPlaceholders(reviewedParam(forTab))
|
||||||
|
.then((rows) => {
|
||||||
|
if (seq === requestSeq.current) setState({ status: "loaded", rows });
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (seq === requestSeq.current) setState({ status: "error" });
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load(tab);
|
||||||
|
}, [tab, load]);
|
||||||
|
|
||||||
|
function markReviewed(id: number) {
|
||||||
|
setBusyId(id);
|
||||||
|
adminApiClient
|
||||||
|
.markTechStepPlaceholdersReviewed({ techStepIds: [id] })
|
||||||
|
.then(() => load(tab))
|
||||||
|
.catch(() => load(tab))
|
||||||
|
.finally(() => setBusyId(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.status === "loading") {
|
||||||
|
return <p className="admin-page__placeholder">{t("admin.common.loading")}</p>;
|
||||||
|
}
|
||||||
|
if (state.status === "error") {
|
||||||
|
return <p className="admin-page__placeholder">{t("admin.common.loadError")}</p>;
|
||||||
|
}
|
||||||
|
if (state.rows.length === 0) {
|
||||||
|
return <p className="admin-page__placeholder">{t("admin.catalog.techStepEmpty")}</p>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ul className="catalog-list">
|
||||||
|
{state.rows.map((row) => {
|
||||||
|
const firstSeen = formatDate(row.createdAt);
|
||||||
|
return (
|
||||||
|
<li key={row.id} className="catalog-card">
|
||||||
|
<div className="catalog-card__head">
|
||||||
|
<h2 className="catalog-card__name">{row.displayName}</h2>
|
||||||
|
<span className="catalog-card__count">
|
||||||
|
{t("admin.catalog.recipeCount", { count: row.recipeCount })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{row.sampleRecipes.length > 0 && (
|
||||||
|
<p className="catalog-card__recipes">
|
||||||
|
{t("admin.catalog.seenIn")}{" "}
|
||||||
|
{row.sampleRecipes.map((recipe) => recipe.name).join(", ")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="catalog-card__foot">
|
||||||
|
{firstSeen && (
|
||||||
|
<span className="catalog-card__seen">
|
||||||
|
{t("admin.catalog.firstSeen", { date: firstSeen })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{row.reviewedAt === null && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => markReviewed(row.id)}
|
||||||
|
disabled={busyId === row.id}
|
||||||
|
>
|
||||||
|
{busyId === row.id ? t("admin.catalog.marking") : t("admin.catalog.markReviewed")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,37 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ingrédients / Techniques switch (issue #130) — sits above the
|
||||||
|
// pending/reviewed tabs; a bolder segmented control so the two levels read
|
||||||
|
// as distinct.
|
||||||
|
.catalog-kinds {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: var(--space-xs) var(--space-md);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2.5px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: var(--color-primary);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.catalog-tabs {
|
.catalog-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-xs);
|
gap: var(--space-xs);
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
|
||||||
button {
|
button {
|
||||||
padding: var(--space-sm) var(--space-md);
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,26 @@ export const markPlaceholdersReviewedSchema = z.object({
|
||||||
/** Inferred TS type for {@link markPlaceholdersReviewedSchema}. */
|
/** Inferred TS type for {@link markPlaceholdersReviewedSchema}. */
|
||||||
export type MarkPlaceholdersReviewedInput = z.infer<typeof markPlaceholdersReviewedSchema>;
|
export type MarkPlaceholdersReviewedInput = z.infer<typeof markPlaceholdersReviewedSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query params for `GET /admin/api/catalog/tech-step-placeholders` — the
|
||||||
|
* missing technique review (issue #130). Same tri-state `reviewed` as
|
||||||
|
* {@link listPlaceholdersQuerySchema}.
|
||||||
|
*/
|
||||||
|
export const listTechStepPlaceholdersQuerySchema = z.object({
|
||||||
|
reviewed: z.enum(["true", "false"]).optional(),
|
||||||
|
});
|
||||||
|
/** Inferred TS type for {@link listTechStepPlaceholdersQuerySchema}. */
|
||||||
|
export type ListTechStepPlaceholdersQuery = z.infer<typeof listTechStepPlaceholdersQuerySchema>;
|
||||||
|
|
||||||
|
/** Body of `PATCH /admin/api/catalog/tech-step-placeholders/mark-reviewed` — stamps `reviewedAt` on the given placeholder `TechStep` ids. */
|
||||||
|
export const markTechStepPlaceholdersReviewedSchema = z.object({
|
||||||
|
techStepIds: z.array(z.number().int().positive()).min(1).max(500),
|
||||||
|
});
|
||||||
|
/** Inferred TS type for {@link markTechStepPlaceholdersReviewedSchema}. */
|
||||||
|
export type MarkTechStepPlaceholdersReviewedInput = z.infer<
|
||||||
|
typeof markTechStepPlaceholdersReviewedSchema
|
||||||
|
>;
|
||||||
|
|
||||||
// --- Guided-tour scenario CRUD (`/admin/api/tours`, issue #44) ----------
|
// --- Guided-tour scenario CRUD (`/admin/api/tours`, issue #44) ----------
|
||||||
|
|
||||||
/** One step of a scenario, as sent by the admin editor. `route` is `null` to stay on the previous step's route; `title`/`content` are free text. */
|
/** One step of a scenario, as sent by the admin editor. `route` is `null` to stay on the previous step's route; `title`/`content` are free text. */
|
||||||
|
|
|
||||||
|
|
@ -235,6 +235,13 @@ const manualStepTechStepTemperatureInputSchema = z
|
||||||
* `description` length to validate `start`/`end` against, which this shape
|
* `description` length to validate `start`/`end` against, which this shape
|
||||||
* alone can't see.
|
* alone can't see.
|
||||||
*
|
*
|
||||||
|
* `correctedTechStepName` is the escape hatch for a technique the catalog
|
||||||
|
* doesn't have at all (issue #130): free text instead of an id. The service
|
||||||
|
* creates a placeholder `TechStep` (`isPlaceholder: true`) for it and then
|
||||||
|
* proceeds exactly as if `correctedTechStepId` had named that new row.
|
||||||
|
* Mutually exclusive with `correctedTechStepId`; either one satisfies every
|
||||||
|
* "a corrected technique is required" check below.
|
||||||
|
*
|
||||||
* `ingredients`/`utensils`/`temperatures` let the user attach metadata to
|
* `ingredients`/`utensils`/`temperatures` let the user attach metadata to
|
||||||
* the technique they're asserting (`correctedTechStepId`), same
|
* the technique they're asserting (`correctedTechStepId`), same
|
||||||
* `source: "manual"` distinction the technique itself gets. The three
|
* `source: "manual"` distinction the technique itself gets. The three
|
||||||
|
|
@ -260,6 +267,8 @@ export const submitTechStepCorrectionSchema = z
|
||||||
end: z.number().int().nonnegative(),
|
end: z.number().int().nonnegative(),
|
||||||
previousTechStepId: z.number().int().positive().nullable().optional(),
|
previousTechStepId: z.number().int().positive().nullable().optional(),
|
||||||
correctedTechStepId: z.number().int().positive().nullable().optional(),
|
correctedTechStepId: z.number().int().positive().nullable().optional(),
|
||||||
|
/** Free-text technique the catalog is missing — see this schema's doc comment. Mutually exclusive with `correctedTechStepId`. */
|
||||||
|
correctedTechStepName: z.string().trim().min(1).max(80).optional(),
|
||||||
ingredients: z.array(manualStepTechStepIngredientInputSchema).optional(),
|
ingredients: z.array(manualStepTechStepIngredientInputSchema).optional(),
|
||||||
utensils: z.array(manualStepTechStepUtensilInputSchema).optional(),
|
utensils: z.array(manualStepTechStepUtensilInputSchema).optional(),
|
||||||
temperatures: z.array(manualStepTechStepTemperatureInputSchema).optional(),
|
temperatures: z.array(manualStepTechStepTemperatureInputSchema).optional(),
|
||||||
|
|
@ -269,10 +278,20 @@ export const submitTechStepCorrectionSchema = z
|
||||||
path: ["end"],
|
path: ["end"],
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(input) =>
|
(input) => input.correctedTechStepId == null || input.correctedTechStepName === undefined,
|
||||||
(input.previousTechStepId ?? null) !== null || (input.correctedTechStepId ?? null) !== null,
|
|
||||||
{
|
{
|
||||||
message: "at least one of previousTechStepId/correctedTechStepId is required",
|
message: "provide correctedTechStepId or correctedTechStepName, not both",
|
||||||
|
path: ["correctedTechStepName"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(input) =>
|
||||||
|
(input.previousTechStepId ?? null) !== null ||
|
||||||
|
(input.correctedTechStepId ?? null) !== null ||
|
||||||
|
input.correctedTechStepName !== undefined,
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"at least one of previousTechStepId/correctedTechStepId/correctedTechStepName is required",
|
||||||
path: ["correctedTechStepId"],
|
path: ["correctedTechStepId"],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -281,9 +300,10 @@ export const submitTechStepCorrectionSchema = z
|
||||||
(input.ingredients === undefined &&
|
(input.ingredients === undefined &&
|
||||||
input.utensils === undefined &&
|
input.utensils === undefined &&
|
||||||
input.temperatures === undefined) ||
|
input.temperatures === undefined) ||
|
||||||
(input.correctedTechStepId ?? null) !== null,
|
(input.correctedTechStepId ?? null) !== null ||
|
||||||
|
input.correctedTechStepName !== undefined,
|
||||||
{
|
{
|
||||||
message: "ingredients/utensils/temperatures require a correctedTechStepId to attach to",
|
message: "ingredients/utensils/temperatures require a corrected technique to attach to",
|
||||||
path: ["correctedTechStepId"],
|
path: ["correctedTechStepId"],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -228,3 +228,25 @@ export interface CatalogPlaceholderGroupView {
|
||||||
export interface PruneOrphansResultView {
|
export interface PruneOrphansResultView {
|
||||||
deleted: number;
|
deleted: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row of `GET /admin/api/catalog/tech-step-placeholders` — a placeholder
|
||||||
|
* `TechStep` (see `TechStep.isPlaceholder`, issue #130): a technique a user
|
||||||
|
* typed while correcting a step because the catalog was missing it. A flat
|
||||||
|
* list, not grouped like the ingredient placeholders — correcting a
|
||||||
|
* technique (let alone hitting a missing one) is rare enough that
|
||||||
|
* near-duplicate spellings aren't the problem there.
|
||||||
|
*/
|
||||||
|
export interface TechStepPlaceholderView {
|
||||||
|
id: number;
|
||||||
|
/** The exact text the user typed. */
|
||||||
|
displayName: string;
|
||||||
|
/** ISO 8601, or `null` for a row created before the column existed. */
|
||||||
|
createdAt: string | null;
|
||||||
|
/** ISO 8601 once a maintainer has triaged it, else `null`. */
|
||||||
|
reviewedAt: string | null;
|
||||||
|
/** Distinct recipes whose steps currently carry this placeholder technique. */
|
||||||
|
recipeCount: number;
|
||||||
|
/** Up to 5 of those recipes, for a "seen in…" preview. */
|
||||||
|
sampleRecipes: { id: number; name: string }[];
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -203,11 +203,24 @@ export interface UnitView {
|
||||||
*
|
*
|
||||||
* `key` is a stable English camelCase uid (e.g. `"simmer"`), not a display
|
* `key` is a stable English camelCase uid (e.g. `"simmer"`), not a display
|
||||||
* label — resolved via `t(\`catalog.techSteps.${key}\`)`, same as
|
* label — resolved via `t(\`catalog.techSteps.${key}\`)`, same as
|
||||||
* {@link DietView.key}.
|
* {@link DietView.key} — **except** for a placeholder (see `isPlaceholder`),
|
||||||
|
* whose label is `displayName` verbatim. Consumers resolve the label as
|
||||||
|
* `displayName ?? t(\`catalog.techSteps.${key}\`)` (helper: `apps/web`'s
|
||||||
|
* `features/recipes/steps/tech-step-label.ts`).
|
||||||
*/
|
*/
|
||||||
export interface TechStepView {
|
export interface TechStepView {
|
||||||
id: number;
|
id: number;
|
||||||
key: string;
|
key: string;
|
||||||
|
/**
|
||||||
|
* `true` = a free-text technique a user typed while correcting a step
|
||||||
|
* because the catalog had nothing matching (issue #130) — see
|
||||||
|
* `TechStep.isPlaceholder` in schema.prisma. Never returned by
|
||||||
|
* `GET /reference/tech-steps` (the browsable catalog excludes them); only
|
||||||
|
* ever seen on a step's own detected-technique sequence.
|
||||||
|
*/
|
||||||
|
isPlaceholder: boolean;
|
||||||
|
/** The user-typed name when `isPlaceholder` — `null` for a real catalog technique (its label is in i18n). */
|
||||||
|
displayName: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue