feat(recipes): permet de corriger la température d'un tech-step
Jusqu'ici, submitTechStepCorrectionSchema (POST /recipes/:id/steps/:stepId/ corrections) acceptait ingredients/utensils pour compléter les métadonnées d'une technique corrigée, mais aucun champ temperatures — StepTechStepTemperature n'était jamais écrit que par toStepTechStepCreateInputs, lors d'un create/update complet de recette. La température ajoutée en #22 n'avait jamais eu son propre circuit de correction manuelle. - packages/shared/src/schemas/recipe.ts : nouveau manualStepTechStepTemperatureInputSchema (celsius/gasMark/qualitative mutuellement exclusifs — exactement un des trois — même span start/end que ingredients/utensils). Le champ temperatures rejoint ingredients/ utensils dans le même lot combiné (les trois voyagent ensemble : dès que l'un est présent, les trois sont résolus, un absent devient [] donc effacé — jamais une fusion partielle catégorie par catégorie). - recipe-tech-step-correction.service.ts : applyManualCorrection écrit désormais StepTechStepTemperature (delete+recreate, même logique "le manuel remplace tout" que les deux autres tables). raw n'est jamais envoyé par le client — dérivé de description.slice(start, end), pour ne pas faire confiance à une copie cliente qui pourrait diverger du texte réel. loadVisibleStepOrThrow retourne désormais le texte complet de la description (pas seulement sa longueur), nécessaire pour cette dérivation. - specs/backend-architecture.md + specs/batch-cooking-modele.md : les deux routes de correction et step_tech_step_temperature n'étaient documentées nulle part — comblé au passage. - Tests Mocha ajoutés (celsius/gasMark/qualitative, rejet si zéro ou plusieurs champs renseignés, remplacement sans accumulation, dérivation de raw, span hors limites). Périmètre : back-end uniquement (voir la maquette proposée précédemment). Le popover de correction (apps/web) ne sera mis à jour que dans une PR suivante, empilée sur celle-ci. Vérifié : pnpm lint + build (shared, api) clean. Mocha non exécuté — pas de Postgres/Docker accessible dans cette session (ni Bash ni PowerShell). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
cb43c9c6c5
commit
5d4ed0dffb
5 changed files with 367 additions and 46 deletions
|
|
@ -32,22 +32,24 @@ const correctionInclude = {
|
||||||
} satisfies Prisma.StepTechStepCorrectionInclude;
|
} satisfies Prisma.StepTechStepCorrectionInclude;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads `stepId`'s current `description` length (the only thing a
|
* Loads `stepId`'s current `description` (a correction needs its full text
|
||||||
* correction needs from the step itself), or throws — `404 STEP_NOT_FOUND`
|
* now, not just its length — see {@link submitTechStepCorrection}'s
|
||||||
* if no such step exists, or if it exists but doesn't belong to `recipeId`
|
* `raw` derivation for a manual temperature mention), or throws —
|
||||||
* (the route's own `:id`/`:stepId` nesting is meaningless otherwise — a
|
* `404 STEP_NOT_FOUND` if no such step exists, or if it exists but doesn't
|
||||||
* request naming a real step under the wrong recipe should look identical
|
* belong to `recipeId` (the route's own `:id`/`:stepId` nesting is
|
||||||
* to naming one that doesn't exist, same "don't leak which part was wrong"
|
* meaningless otherwise — a request naming a real step under the wrong
|
||||||
* posture `assertRecipeVisible` already has for visibility). Otherwise
|
* recipe should look identical to naming one that doesn't exist, same
|
||||||
* whatever {@link assertRecipeVisible} throws (`404 RECIPE_NOT_FOUND`,
|
* "don't leak which part was wrong" posture `assertRecipeVisible` already
|
||||||
* never `403`) if the recipe exists but isn't visible to the viewer.
|
* has for visibility). Otherwise whatever {@link assertRecipeVisible}
|
||||||
|
* throws (`404 RECIPE_NOT_FOUND`, never `403`) if the recipe exists but
|
||||||
|
* isn't visible to the viewer.
|
||||||
*/
|
*/
|
||||||
async function loadVisibleStepOrThrow(
|
async function loadVisibleStepOrThrow(
|
||||||
recipeId: number,
|
recipeId: number,
|
||||||
stepId: number,
|
stepId: number,
|
||||||
viewerId: number,
|
viewerId: number,
|
||||||
viewerHouseId: number | null,
|
viewerHouseId: number | null,
|
||||||
): Promise<{ id: number; descriptionLength: number }> {
|
): Promise<{ id: number; description: string }> {
|
||||||
try {
|
try {
|
||||||
const step = await prisma.step.findUnique({
|
const step = await prisma.step.findUnique({
|
||||||
where: { id: stepId },
|
where: { id: stepId },
|
||||||
|
|
@ -57,7 +59,7 @@ async function loadVisibleStepOrThrow(
|
||||||
throw new HttpError(404, ErrorCode.STEP_NOT_FOUND, `Step ${stepId} not found`);
|
throw new HttpError(404, ErrorCode.STEP_NOT_FOUND, `Step ${stepId} not found`);
|
||||||
}
|
}
|
||||||
await assertRecipeVisible(step.recipeId, viewerId, viewerHouseId);
|
await assertRecipeVisible(step.recipeId, viewerId, viewerHouseId);
|
||||||
return { id: step.id, descriptionLength: step.description.length };
|
return { id: step.id, description: step.description };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err; // see recipe.service.ts's equivalent catch comment
|
throw err; // see recipe.service.ts's equivalent catch comment
|
||||||
}
|
}
|
||||||
|
|
@ -208,6 +210,21 @@ interface ManualUtensilMention {
|
||||||
start: number;
|
start: number;
|
||||||
end: number;
|
end: number;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Same as {@link ManualIngredientMention}, for a temperature — `raw` is
|
||||||
|
* already resolved by the caller ({@link submitTechStepCorrection}, which
|
||||||
|
* has the step's full `description` text to slice) rather than trusted
|
||||||
|
* from the client; see `manualStepTechStepTemperatureInputSchema`'s own
|
||||||
|
* doc comment (`packages/shared`) for why.
|
||||||
|
*/
|
||||||
|
interface ManualTemperatureMention {
|
||||||
|
celsius: number | null;
|
||||||
|
gasMark: number | null;
|
||||||
|
qualitative: "low" | "medium" | "high" | null;
|
||||||
|
raw: string;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies a correction's *effect* on `stepId`'s real `StepTechStep`
|
* Applies a correction's *effect* on `stepId`'s real `StepTechStep`
|
||||||
|
|
@ -233,16 +250,16 @@ interface ManualUtensilMention {
|
||||||
*
|
*
|
||||||
* `metadata`, when given (only ever alongside a real `correctedTechStepId`
|
* `metadata`, when given (only ever alongside a real `correctedTechStepId`
|
||||||
* — enforced by `submitTechStepCorrectionSchema`, not re-checked here),
|
* — enforced by `submitTechStepCorrectionSchema`, not re-checked here),
|
||||||
* replaces *every* `StepTechStepIngredient`/`StepTechStepUtensil` row on
|
* replaces *every* `StepTechStepIngredient`/`StepTechStepUtensil`/
|
||||||
* this occurrence — `source: "auto"` (the classifier's own detection) and
|
* `StepTechStepTemperature` row on this occurrence — `source: "auto"` (the
|
||||||
* any earlier `"manual"` set alike — with the newly-submitted one. This is
|
* classifier's own detection) and any earlier `"manual"` set alike — with
|
||||||
* "le manuel remplace tout" (confirmed with the user): the resolved
|
* the newly-submitted one. This is "le manuel remplace tout" (confirmed
|
||||||
* `order` this technique ends up at (whichever branch above produced it)
|
* with the user): the resolved `order` this technique ends up at
|
||||||
* is the same `techStepOrder` both metadata tables key on, so the same
|
* (whichever branch above produced it) is the same `techStepOrder` all
|
||||||
* `deleteMany` + `createMany` pair below is correct whether this call just
|
* three metadata tables key on, so the same `deleteMany` + `createMany`
|
||||||
* updated an existing row (which may already carry auto-detected
|
* pair below is correct whether this call just updated an existing row
|
||||||
* metadata) or created a brand new one (nothing to delete yet — a no-op
|
* (which may already carry auto-detected metadata) or created a brand new
|
||||||
* `deleteMany`, not a special case).
|
* one (nothing to delete yet — a no-op `deleteMany`, not a special case).
|
||||||
*
|
*
|
||||||
* Runs inside the same transaction {@link submitTechStepCorrection} uses
|
* Runs inside the same transaction {@link submitTechStepCorrection} uses
|
||||||
* for the audit-trail insert, so a request never leaves any of these
|
* for the audit-trail insert, so a request never leaves any of these
|
||||||
|
|
@ -255,7 +272,11 @@ async function applyManualCorrection(
|
||||||
span: { start: number; end: number },
|
span: { start: number; end: number },
|
||||||
previousTechStepId: number | null,
|
previousTechStepId: number | null,
|
||||||
correctedTechStepId: number | null,
|
correctedTechStepId: number | null,
|
||||||
metadata?: { ingredients: ManualIngredientMention[]; utensils: ManualUtensilMention[] },
|
metadata?: {
|
||||||
|
ingredients: ManualIngredientMention[];
|
||||||
|
utensils: ManualUtensilMention[];
|
||||||
|
temperatures: ManualTemperatureMention[];
|
||||||
|
},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const existing = await tx.stepTechStep.findMany({ where: { stepId } });
|
const existing = await tx.stepTechStep.findMany({ where: { stepId } });
|
||||||
|
|
||||||
|
|
@ -329,6 +350,22 @@ async function applyManualCorrection(
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
await tx.stepTechStepTemperature.deleteMany({ where: { stepId, techStepOrder: order } });
|
||||||
|
if (metadata.temperatures.length > 0) {
|
||||||
|
await tx.stepTechStepTemperature.createMany({
|
||||||
|
data: metadata.temperatures.map((temperature) => ({
|
||||||
|
stepId,
|
||||||
|
techStepOrder: order,
|
||||||
|
celsius: temperature.celsius,
|
||||||
|
gasMark: temperature.gasMark,
|
||||||
|
qualitative: temperature.qualitative,
|
||||||
|
raw: temperature.raw,
|
||||||
|
start: temperature.start,
|
||||||
|
end: temperature.end,
|
||||||
|
source: "manual",
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (target) {
|
} else if (target) {
|
||||||
await tx.stepTechStep.delete({ where: { stepId_order: { stepId, order: target.order } } });
|
await tx.stepTechStep.delete({ where: { stepId_order: { stepId, order: target.order } } });
|
||||||
|
|
@ -366,10 +403,11 @@ function toCorrectionView(correction: CorrectionWithTechSteps): StepTechStepCorr
|
||||||
* @throws {HttpError} `404 STEP_NOT_FOUND`/`404 RECIPE_NOT_FOUND` — see
|
* @throws {HttpError} `404 STEP_NOT_FOUND`/`404 RECIPE_NOT_FOUND` — see
|
||||||
* {@link loadVisibleStepOrThrow}. `400 INVALID_CORRECTION_SPAN` if
|
* {@link loadVisibleStepOrThrow}. `400 INVALID_CORRECTION_SPAN` if
|
||||||
* `start`/`end` (the correction's own span, or any of
|
* `start`/`end` (the correction's own span, or any of
|
||||||
* `input.ingredients`/`input.utensils`' own spans) fall outside the
|
* `input.ingredients`/`input.utensils`/`input.temperatures`' own spans)
|
||||||
* step's current `description` (it may have been edited since the user
|
* fall outside the step's current `description` (it may have been
|
||||||
* last saw it). `404 TECH_STEP_NOT_FOUND`/`404 INGREDIENT_NOT_FOUND`/
|
* edited since the user last saw it). `404 TECH_STEP_NOT_FOUND`/
|
||||||
* `404 UNIT_NOT_FOUND`/`404 UTENSIL_NOT_FOUND` if any referenced id
|
* `404 INGREDIENT_NOT_FOUND`/`404 UNIT_NOT_FOUND`/`404 UTENSIL_NOT_FOUND`
|
||||||
|
* if any referenced id
|
||||||
* doesn't exist.
|
* doesn't exist.
|
||||||
*/
|
*/
|
||||||
export async function submitTechStepCorrection(
|
export async function submitTechStepCorrection(
|
||||||
|
|
@ -381,18 +419,20 @@ export async function submitTechStepCorrection(
|
||||||
): Promise<SubmitTechStepCorrectionResult> {
|
): Promise<SubmitTechStepCorrectionResult> {
|
||||||
try {
|
try {
|
||||||
const step = await loadVisibleStepOrThrow(recipeId, stepId, correctorId, viewerHouseId);
|
const step = await loadVisibleStepOrThrow(recipeId, stepId, correctorId, viewerHouseId);
|
||||||
|
const descriptionLength = step.description.length;
|
||||||
|
|
||||||
const spans = [
|
const spans = [
|
||||||
{ start: input.start, end: input.end },
|
{ start: input.start, end: input.end },
|
||||||
...(input.ingredients ?? []),
|
...(input.ingredients ?? []),
|
||||||
...(input.utensils ?? []),
|
...(input.utensils ?? []),
|
||||||
|
...(input.temperatures ?? []),
|
||||||
];
|
];
|
||||||
for (const span of spans) {
|
for (const span of spans) {
|
||||||
if (span.start >= step.descriptionLength || span.end > step.descriptionLength) {
|
if (span.start >= descriptionLength || span.end > descriptionLength) {
|
||||||
throw new HttpError(
|
throw new HttpError(
|
||||||
400,
|
400,
|
||||||
ErrorCode.INVALID_CORRECTION_SPAN,
|
ErrorCode.INVALID_CORRECTION_SPAN,
|
||||||
`Span [${span.start}, ${span.end}) falls outside step ${stepId}'s description (length ${step.descriptionLength})`,
|
`Span [${span.start}, ${span.end}) falls outside step ${stepId}'s description (length ${descriptionLength})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -428,7 +468,9 @@ export async function submitTechStepCorrection(
|
||||||
{ start: input.start, end: input.end },
|
{ start: input.start, end: input.end },
|
||||||
input.previousTechStepId ?? null,
|
input.previousTechStepId ?? null,
|
||||||
input.correctedTechStepId ?? null,
|
input.correctedTechStepId ?? null,
|
||||||
input.ingredients === undefined && input.utensils === undefined
|
input.ingredients === undefined &&
|
||||||
|
input.utensils === undefined &&
|
||||||
|
input.temperatures === undefined
|
||||||
? undefined
|
? undefined
|
||||||
: {
|
: {
|
||||||
ingredients: (input.ingredients ?? []).map((ingredient) => ({
|
ingredients: (input.ingredients ?? []).map((ingredient) => ({
|
||||||
|
|
@ -443,6 +485,16 @@ export async function submitTechStepCorrection(
|
||||||
start: utensil.start,
|
start: utensil.start,
|
||||||
end: utensil.end,
|
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,
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -518,6 +518,191 @@ describe("Recipe tech-step corrections", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("POST /recipes/:id/steps/:stepId/corrections — temperature metadata", () => {
|
||||||
|
it("attaches a manually-selected celsius temperature, deriving raw from the step's own description", async () => {
|
||||||
|
const { agent, profileId } = await signup();
|
||||||
|
const description = "Préchauffez à 180°C.";
|
||||||
|
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId, description);
|
||||||
|
const preheatId = await techStepId("preheat");
|
||||||
|
|
||||||
|
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
|
||||||
|
start: 0,
|
||||||
|
end: 11,
|
||||||
|
correctedTechStepId: preheatId,
|
||||||
|
temperatures: [{ celsius: 180, start: 14, end: 19 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).to.equal(201);
|
||||||
|
expect(res.body.techSteps).to.deep.equal([
|
||||||
|
{
|
||||||
|
techStep: { id: preheatId, key: "preheat" },
|
||||||
|
start: 0,
|
||||||
|
end: 11,
|
||||||
|
source: "manual",
|
||||||
|
ingredients: [],
|
||||||
|
utensils: [],
|
||||||
|
temperatures: [
|
||||||
|
{
|
||||||
|
celsius: 180,
|
||||||
|
gasMark: null,
|
||||||
|
qualitative: null,
|
||||||
|
// Not sent by the client — derived server-side from the span
|
||||||
|
// against the step's real description (see
|
||||||
|
// `ManualTemperatureMention`'s doc comment).
|
||||||
|
raw: "180°C",
|
||||||
|
start: 14,
|
||||||
|
end: 19,
|
||||||
|
source: "manual",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("attaches a manually-selected qualitative temperature", async () => {
|
||||||
|
const { agent, profileId } = await signup();
|
||||||
|
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
|
||||||
|
const simmerId = await techStepId("simmer");
|
||||||
|
|
||||||
|
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
|
||||||
|
start: 6,
|
||||||
|
end: 13,
|
||||||
|
correctedTechStepId: simmerId,
|
||||||
|
temperatures: [{ qualitative: "low", start: 0, end: 5 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).to.equal(201);
|
||||||
|
expect(res.body.techSteps[0].temperatures).to.deep.equal([
|
||||||
|
{
|
||||||
|
celsius: null,
|
||||||
|
gasMark: null,
|
||||||
|
qualitative: "low",
|
||||||
|
raw: "Faire",
|
||||||
|
start: 0,
|
||||||
|
end: 5,
|
||||||
|
source: "manual",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("attaches a manually-selected gas-mark temperature", async () => {
|
||||||
|
const { agent, profileId } = await signup();
|
||||||
|
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
|
||||||
|
const simmerId = await techStepId("simmer");
|
||||||
|
|
||||||
|
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
|
||||||
|
start: 6,
|
||||||
|
end: 13,
|
||||||
|
correctedTechStepId: simmerId,
|
||||||
|
temperatures: [{ gasMark: 6, start: 0, end: 5 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).to.equal(201);
|
||||||
|
expect(res.body.techSteps[0].temperatures[0]).to.deep.equal({
|
||||||
|
celsius: null,
|
||||||
|
gasMark: 6,
|
||||||
|
qualitative: null,
|
||||||
|
raw: "Faire",
|
||||||
|
start: 0,
|
||||||
|
end: 5,
|
||||||
|
source: "manual",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a temperature with more than one of celsius/gasMark/qualitative set 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"),
|
||||||
|
temperatures: [{ celsius: 180, qualitative: "high", start: 0, end: 5 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).to.equal(400);
|
||||||
|
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a temperature with none of celsius/gasMark/qualitative set 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"),
|
||||||
|
temperatures: [{ start: 0, end: 5 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).to.equal(400);
|
||||||
|
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces both auto-detected and previously-manual temperatures on the same occurrence — never accumulates", async () => {
|
||||||
|
const { agent, profileId } = await signup();
|
||||||
|
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
|
||||||
|
const simmerId = await techStepId("simmer");
|
||||||
|
const boilId = await techStepId("boil");
|
||||||
|
|
||||||
|
await agent
|
||||||
|
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
|
||||||
|
.send({ start: 6, end: 13, correctedTechStepId: simmerId });
|
||||||
|
// Simulate an auto-detected temperature already sitting on the
|
||||||
|
// occurrence, exactly as `toStepTechStepCreateInputs` would have
|
||||||
|
// written one at save time.
|
||||||
|
await prisma.stepTechStepTemperature.create({
|
||||||
|
data: {
|
||||||
|
stepId,
|
||||||
|
techStepOrder: 0,
|
||||||
|
celsius: 100,
|
||||||
|
raw: "100°C",
|
||||||
|
start: 0,
|
||||||
|
end: 5,
|
||||||
|
source: "auto",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
|
||||||
|
start: 6,
|
||||||
|
end: 13,
|
||||||
|
previousTechStepId: simmerId,
|
||||||
|
correctedTechStepId: boilId,
|
||||||
|
temperatures: [{ gasMark: 6, start: 0, end: 5 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).to.equal(201);
|
||||||
|
expect(res.body.techSteps).to.have.length(1);
|
||||||
|
expect(res.body.techSteps[0].temperatures).to.deep.equal([
|
||||||
|
{
|
||||||
|
celsius: null,
|
||||||
|
gasMark: 6,
|
||||||
|
qualitative: null,
|
||||||
|
raw: "Faire",
|
||||||
|
start: 0,
|
||||||
|
end: 5,
|
||||||
|
source: "manual",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a temperature span past the end of the step's description with 400 INVALID_CORRECTION_SPAN", async () => {
|
||||||
|
const { agent, profileId } = await signup();
|
||||||
|
const description = "Court.";
|
||||||
|
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId, description);
|
||||||
|
|
||||||
|
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
|
||||||
|
start: 0,
|
||||||
|
end: description.length,
|
||||||
|
correctedTechStepId: await techStepId("simmer"),
|
||||||
|
temperatures: [{ celsius: 180, start: 0, end: description.length + 10 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).to.equal(400);
|
||||||
|
expect(res.body.code).to.equal(ErrorCode.INVALID_CORRECTION_SPAN);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("GET /recipes/:id/steps/:stepId/corrections", () => {
|
describe("GET /recipes/:id/steps/:stepId/corrections", () => {
|
||||||
it("returns every correction submitted for the step, most recent first", async () => {
|
it("returns every correction submitted for the step, most recent first", async () => {
|
||||||
const { agent, profileId } = await signup();
|
const { agent, profileId } = await signup();
|
||||||
|
|
|
||||||
|
|
@ -175,6 +175,48 @@ const manualStepTechStepUtensilInputSchema = z
|
||||||
path: ["end"],
|
path: ["end"],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A temperature mention the user points at while correcting a technique —
|
||||||
|
* same `start`/`end` span convention as
|
||||||
|
* {@link manualStepTechStepIngredientInputSchema}/
|
||||||
|
* {@link manualStepTechStepUtensilInputSchema}. Exactly one of
|
||||||
|
* `celsius`/`gasMark`/`qualitative` must be set: the same mutual-exclusivity
|
||||||
|
* invariant the auto-detection pipeline already produces (see
|
||||||
|
* `StepTechStepTemperatureView`'s doc comment, `packages/shared/src/types/recipe.ts`)
|
||||||
|
* — a manual entry can't represent "both 180 °C and Thermostat 6" any more
|
||||||
|
* than a detected one can.
|
||||||
|
*
|
||||||
|
* No `raw` field here, unlike `StepTechStepTemperatureView`/the Prisma
|
||||||
|
* column: an auto-detected mention's `raw` is the exact text a regex
|
||||||
|
* matched, but a manual one is built from a structured value the user
|
||||||
|
* picked (a number, or a qualitative level), not matched text — the
|
||||||
|
* service derives it from the step's own `description.slice(start, end)`
|
||||||
|
* instead of trusting a client-supplied copy that could drift from what's
|
||||||
|
* actually at that span.
|
||||||
|
*/
|
||||||
|
const manualStepTechStepTemperatureInputSchema = z
|
||||||
|
.object({
|
||||||
|
celsius: z.number().nullable().optional(),
|
||||||
|
gasMark: z.number().int().positive().nullable().optional(),
|
||||||
|
qualitative: z.enum(["low", "medium", "high"]).nullable().optional(),
|
||||||
|
start: z.number().int().nonnegative(),
|
||||||
|
end: z.number().int().nonnegative(),
|
||||||
|
})
|
||||||
|
.refine((temperature) => temperature.end > temperature.start, {
|
||||||
|
message: "end must be greater than start",
|
||||||
|
path: ["end"],
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(temperature) =>
|
||||||
|
[temperature.celsius, temperature.gasMark, temperature.qualitative].filter(
|
||||||
|
(value) => value !== null && value !== undefined,
|
||||||
|
).length === 1,
|
||||||
|
{
|
||||||
|
message: "exactly one of celsius/gasMark/qualitative is required",
|
||||||
|
path: ["celsius"],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Payload accepted by `POST /recipes/:id/steps/:stepId/corrections` — a
|
* Payload accepted by `POST /recipes/:id/steps/:stepId/corrections` — a
|
||||||
* user asserting what technique a `[start, end)` span of a step's
|
* user asserting what technique a `[start, end)` span of a step's
|
||||||
|
|
@ -188,16 +230,22 @@ const manualStepTechStepUtensilInputSchema = 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.
|
||||||
*
|
*
|
||||||
* `ingredients`/`utensils` let the user attach metadata to the technique
|
* `ingredients`/`utensils`/`temperatures` let the user attach metadata to
|
||||||
* they're asserting (`correctedTechStepId`), same `source: "manual"`
|
* the technique they're asserting (`correctedTechStepId`), same
|
||||||
* distinction the technique itself gets. **Omitted (`undefined`) means
|
* `source: "manual"` distinction the technique itself gets. The three
|
||||||
* "leave whatever metadata already exists on this occurrence alone" —
|
* travel as **one combined bundle, not three independent ones**: **omitted
|
||||||
* an explicit array, even `[]`, means "this is now the complete set,
|
* (`undefined`) on all three at once** means "leave whatever metadata
|
||||||
* replace everything that was there" (auto-detected included; see
|
* already exists on this occurrence alone" — as soon as *any one* of them
|
||||||
* `applyManualCorrection`'s own doc comment). This is why neither field
|
* is present, all three are resolved (an absent one defaults to `[]`) and
|
||||||
* has a `.default([])`: that would silently turn every plain relabel into
|
* **replace everything that was there** for that category, auto-detected
|
||||||
* a metadata wipe.** Only meaningful alongside a real `correctedTechStepId`
|
* included (see `applyManualCorrection`'s own doc comment). This is why
|
||||||
* — enforced by this schema's own refine below, since there's no live
|
* none of the three has a `.default([])`: that would silently turn every
|
||||||
|
* plain relabel into a metadata wipe. A caller touching just one category
|
||||||
|
* (e.g. adding a temperature) must still resend the other two's current
|
||||||
|
* full state alongside it — same contract `TechStepCorrectionPopover.tsx`
|
||||||
|
* already follows for ingredients/utensils, just extended to a third
|
||||||
|
* category. Only meaningful alongside a real `correctedTechStepId` —
|
||||||
|
* enforced by this schema's own refine below, since there's no live
|
||||||
* `StepTechStep` row to attach to otherwise (removing a match, or a
|
* `StepTechStep` row to attach to otherwise (removing a match, or a
|
||||||
* request with neither id set).
|
* request with neither id set).
|
||||||
*/
|
*/
|
||||||
|
|
@ -209,6 +257,7 @@ export const submitTechStepCorrectionSchema = z
|
||||||
correctedTechStepId: z.number().int().positive().nullable().optional(),
|
correctedTechStepId: z.number().int().positive().nullable().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(),
|
||||||
})
|
})
|
||||||
.refine((input) => input.end > input.start, {
|
.refine((input) => input.end > input.start, {
|
||||||
message: "end must be greater than start",
|
message: "end must be greater than start",
|
||||||
|
|
@ -224,10 +273,12 @@ export const submitTechStepCorrectionSchema = z
|
||||||
)
|
)
|
||||||
.refine(
|
.refine(
|
||||||
(input) =>
|
(input) =>
|
||||||
(input.ingredients === undefined && input.utensils === undefined) ||
|
(input.ingredients === undefined &&
|
||||||
|
input.utensils === undefined &&
|
||||||
|
input.temperatures === undefined) ||
|
||||||
(input.correctedTechStepId ?? null) !== null,
|
(input.correctedTechStepId ?? null) !== null,
|
||||||
{
|
{
|
||||||
message: "ingredients/utensils require a correctedTechStepId to attach to",
|
message: "ingredients/utensils/temperatures require a correctedTechStepId to attach to",
|
||||||
path: ["correctedTechStepId"],
|
path: ["correctedTechStepId"],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -587,6 +587,24 @@ ne persiste rien.
|
||||||
| `PATCH /recipes/:id` | `updateRecipe` (remplacement complet, pas de fusion partielle) |
|
| `PATCH /recipes/:id` | `updateRecipe` (remplacement complet, pas de fusion partielle) |
|
||||||
| `DELETE /recipes/:id` | `deleteRecipe` (409 `RECIPE_IN_USE` si référencée par un `PlanningItem`) |
|
| `DELETE /recipes/:id` | `deleteRecipe` (409 `RECIPE_IN_USE` si référencée par un `PlanningItem`) |
|
||||||
| `POST`/`DELETE /recipes/:id/favorite` | `addFavorite`/`removeFavorite` (idempotents) |
|
| `POST`/`DELETE /recipes/:id/favorite` | `addFavorite`/`removeFavorite` (idempotents) |
|
||||||
|
| `POST /recipes/:id/steps/:stepId/corrections` | `submitTechStepCorrection` (`recipe-tech-step-correction.service.ts`) — ouvert à tout viewer qui voit la recette, pas seulement son auteur |
|
||||||
|
| `GET /recipes/:id/steps/:stepId/corrections` | `listTechStepCorrections` — historique brut des corrections soumises pour l'étape |
|
||||||
|
|
||||||
|
**Corrections de tech-step** — une correction porte sur un `[start, end)` de
|
||||||
|
`Step.description` (`previousTechStepId`/`correctedTechStepId`, voir
|
||||||
|
[batch-cooking-modele.md](./batch-cooking-modele.md#step-step-et-techniques-détectées)).
|
||||||
|
Elle enregistre systématiquement une ligne d'audit `StepTechStepCorrection`
|
||||||
|
(jamais modifiée ensuite) **et** applique l'effet immédiatement sur la
|
||||||
|
séquence réelle `StepTechStep` de l'étape — pas seulement une suggestion en
|
||||||
|
attente pour `services/tech-step-llm-worker`. `ingredients`/`utensils`/
|
||||||
|
`temperatures` peuvent accompagner un `correctedTechStepId` réel pour
|
||||||
|
attacher/remplacer les métadonnées de cette occurrence ; les trois voyagent
|
||||||
|
comme **un seul lot** — dès que l'une est présente dans la requête, les
|
||||||
|
trois sont résolues (une absente devient `[]`, donc effacée), jamais une
|
||||||
|
fusion partielle catégorie par catégorie. `temperatures[]` exige
|
||||||
|
exactement un de `celsius`/`gasMark`/`qualitative` par entrée (même
|
||||||
|
contrainte que la détection automatique) ; son `raw` n'est jamais envoyé
|
||||||
|
par le client — dérivé côté service de `description.slice(start, end)`.
|
||||||
|
|
||||||
**Visibilité** (`RecipeVisibility` — `PERSONAL`/`HOUSE`/`PUBLIC`, voir
|
**Visibilité** (`RecipeVisibility` — `PERSONAL`/`HOUSE`/`PUBLIC`, voir
|
||||||
[batch-cooking-modele.md](./batch-cooking-modele.md)) contrôle uniquement la
|
[batch-cooking-modele.md](./batch-cooking-modele.md)) contrôle uniquement la
|
||||||
|
|
|
||||||
|
|
@ -379,13 +379,28 @@ dans `Step.description`, utilisé pour le surlignage côté web
|
||||||
Chaque `step_tech_step` porte en plus les métadonnées trouvées dans sa propre
|
Chaque `step_tech_step` porte en plus les métadonnées trouvées dans sa propre
|
||||||
clause : `step_tech_step_ingredient` (ingrédient résolu contre le catalogue
|
clause : `step_tech_step_ingredient` (ingrédient résolu contre le catalogue
|
||||||
`ingredients` existant, `quantity`/`unit_id` optionnels quand une quantité a
|
`ingredients` existant, `quantity`/`unit_id` optionnels quand une quantité a
|
||||||
pu être extraite juste avant la mention) et `step_tech_step_utensil`
|
pu être extraite juste avant la mention), `step_tech_step_utensil`
|
||||||
(ustensile résolu contre un nouveau catalogue `utensil`, même forme
|
(ustensile résolu contre un nouveau catalogue `utensil`, même forme
|
||||||
minimale `id`/`key` que `tech_step` — voir
|
minimale `id`/`key` que `tech_step`), et `step_tech_step_temperature`
|
||||||
|
(température de la clause — `celsius`/`gas_mark`/`qualitative`
|
||||||
|
mutuellement exclusifs, un seul non-null par ligne ; `raw` conserve le
|
||||||
|
texte exact matché, ex. `"180°C"`/`"th. 6"`/`"feu doux"` ; `qualitative`
|
||||||
|
vaut `"low"`/`"medium"`/`"high"`, contrainte au niveau TypeScript
|
||||||
|
seulement — pas d'enum Postgres ici, contrairement à `AllergenKind`/
|
||||||
|
`ThemePreference`/`UnitType` ailleurs dans ce schéma). Les trois référencent
|
||||||
|
`step_tech_step` par sa clé composite `(step_id, order)`, `onDelete:
|
||||||
|
Cascade` comme le reste de cette chaîne — voir
|
||||||
[backend-architecture.md](./backend-architecture.md#détection-des-techniques--tech-step-matcherts)
|
[backend-architecture.md](./backend-architecture.md#détection-des-techniques--tech-step-matcherts)
|
||||||
pour comment chacun est détecté). Les deux référencent `step_tech_step` par
|
pour comment chacune est détectée.
|
||||||
sa clé composite `(step_id, order)`, `onDelete: Cascade` comme le reste de
|
|
||||||
cette chaîne.
|
`source` (`"auto"` | `"manual"`, même contrainte TypeScript-seule que
|
||||||
|
`qualitative` ci-dessus) marque, sur `step_tech_step` et ses trois tables de
|
||||||
|
métadonnées, si la ligne vient du classifieur ou d'une correction manuelle
|
||||||
|
soumise par un utilisateur (`POST /recipes/:id/steps/:stepId/corrections`,
|
||||||
|
voir [backend-architecture.md](./backend-architecture.md#recipe)) — cette
|
||||||
|
route remplace intégralement les trois tables de métadonnées d'une
|
||||||
|
occurrence dès que l'une d'elles est touchée ("le manuel remplace tout"),
|
||||||
|
jamais un ajout partiel.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue