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>
571 lines
22 KiB
TypeScript
571 lines
22 KiB
TypeScript
import { HttpError } from "@batch-cooking/error-tools";
|
|
import {
|
|
ErrorCode,
|
|
type StepTechStepCorrectionView,
|
|
type SubmitTechStepCorrectionInput,
|
|
type SubmitTechStepCorrectionResult,
|
|
} from "@batch-cooking/shared";
|
|
import type { Prisma } from "@prisma/client";
|
|
import { prisma } from "../../db/prisma.js";
|
|
import { analytics } from "../../lib/analytics.service.js";
|
|
import { assertRecipeVisible, toStepTechStepViews } from "./recipe.service.js";
|
|
|
|
/**
|
|
* User-submitted corrections to a step's detected techniques
|
|
* (`StepTechStepCorrection` in schema.prisma) — kept in its own module
|
|
* rather than folded into `recipe.service.ts`, same "one file per concern"
|
|
* split that file itself follows for `tech-step-matcher.ts`. Deliberately
|
|
* open to *any* viewer who can see the recipe, not just its author (unlike
|
|
* every write path in `recipe.service.ts`, which uses `assertIsAuthor`) —
|
|
* correcting a mislabeled technique isn't editing the recipe's own
|
|
* content, and restricting it to authors would starve the training-data
|
|
* feedback loop (`services/tech-step-llm-worker`) of the volume it needs.
|
|
*/
|
|
|
|
type CorrectionWithTechSteps = Prisma.StepTechStepCorrectionGetPayload<{
|
|
include: { previousTechStep: true; correctedTechStep: true };
|
|
}>;
|
|
|
|
const correctionInclude = {
|
|
previousTechStep: true,
|
|
correctedTechStep: true,
|
|
} satisfies Prisma.StepTechStepCorrectionInclude;
|
|
|
|
/**
|
|
* Loads `stepId`'s current `description` (a correction needs its full text
|
|
* now, not just its length — see {@link submitTechStepCorrection}'s
|
|
* `raw` derivation for a manual temperature mention), or throws —
|
|
* `404 STEP_NOT_FOUND` if no such step exists, or if it exists but doesn't
|
|
* belong to `recipeId` (the route's own `:id`/`:stepId` nesting is
|
|
* meaningless otherwise — a request naming a real step under the wrong
|
|
* recipe should look identical to naming one that doesn't exist, same
|
|
* "don't leak which part was wrong" posture `assertRecipeVisible` already
|
|
* 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(
|
|
recipeId: number,
|
|
stepId: number,
|
|
viewerId: number,
|
|
viewerHouseId: number | null,
|
|
): Promise<{ id: number; description: string }> {
|
|
try {
|
|
const step = await prisma.step.findUnique({
|
|
where: { id: stepId },
|
|
select: { id: true, recipeId: true, description: true },
|
|
});
|
|
if (!step || step.recipeId !== recipeId) {
|
|
throw new HttpError(404, ErrorCode.STEP_NOT_FOUND, `Step ${stepId} not found`);
|
|
}
|
|
await assertRecipeVisible(step.recipeId, viewerId, viewerHouseId);
|
|
return { id: step.id, description: step.description };
|
|
} catch (err) {
|
|
throw err; // see recipe.service.ts's equivalent catch comment
|
|
}
|
|
}
|
|
|
|
/** Throws `404 TECH_STEP_NOT_FOUND` if any id in `ids` doesn't match a reference `TechStep` row — same shape as `recipe.service.ts`'s `assertIngredientsExist`/`assertUnitsExist` for the recipe payload's own reference ids. */
|
|
async function assertTechStepsExist(ids: number[]): Promise<void> {
|
|
try {
|
|
if (ids.length === 0) return;
|
|
const found = await prisma.techStep.findMany({
|
|
where: { id: { in: ids } },
|
|
select: { id: true },
|
|
});
|
|
const foundIds = new Set(found.map((techStep) => techStep.id));
|
|
const missing = ids.filter((id) => !foundIds.has(id));
|
|
if (missing.length > 0) {
|
|
throw new HttpError(
|
|
404,
|
|
ErrorCode.TECH_STEP_NOT_FOUND,
|
|
`TechStep ids not found: ${missing.join(", ")}`,
|
|
);
|
|
}
|
|
} catch (err) {
|
|
throw err; // see loadVisibleStepOrThrow's catch comment
|
|
}
|
|
}
|
|
|
|
/** Throws `404 INGREDIENT_NOT_FOUND` if any id in `ids` doesn't match a reference `Ingredient` row — same shape as {@link assertTechStepsExist}, checking `input.ingredients[].ingredientId` instead. */
|
|
async function assertIngredientsExist(ids: number[]): Promise<void> {
|
|
try {
|
|
const uniqueIds = [...new Set(ids)];
|
|
if (uniqueIds.length === 0) return;
|
|
const found = await prisma.ingredient.findMany({
|
|
where: { id: { in: uniqueIds } },
|
|
select: { id: true },
|
|
});
|
|
const foundIds = new Set(found.map((ingredient) => ingredient.id));
|
|
const missing = uniqueIds.filter((id) => !foundIds.has(id));
|
|
if (missing.length > 0) {
|
|
throw new HttpError(
|
|
404,
|
|
ErrorCode.INGREDIENT_NOT_FOUND,
|
|
`Ingredient ids not found: ${missing.join(", ")}`,
|
|
);
|
|
}
|
|
} catch (err) {
|
|
throw err; // see loadVisibleStepOrThrow's catch comment
|
|
}
|
|
}
|
|
|
|
/** Throws `404 UNIT_NOT_FOUND` if any id in `ids` doesn't match a reference `Unit` row — same shape as {@link assertIngredientsExist}, checking `input.ingredients[].unitId` instead. */
|
|
async function assertUnitsExist(ids: number[]): Promise<void> {
|
|
try {
|
|
const uniqueIds = [...new Set(ids)];
|
|
if (uniqueIds.length === 0) return;
|
|
const found = await prisma.unit.findMany({
|
|
where: { id: { in: uniqueIds } },
|
|
select: { id: true },
|
|
});
|
|
const foundIds = new Set(found.map((unit) => unit.id));
|
|
const missing = uniqueIds.filter((id) => !foundIds.has(id));
|
|
if (missing.length > 0) {
|
|
throw new HttpError(
|
|
404,
|
|
ErrorCode.UNIT_NOT_FOUND,
|
|
`Unit ids not found: ${missing.join(", ")}`,
|
|
);
|
|
}
|
|
} catch (err) {
|
|
throw err; // see loadVisibleStepOrThrow's catch comment
|
|
}
|
|
}
|
|
|
|
/** Throws `404 UTENSIL_NOT_FOUND` if any id in `ids` doesn't match a reference `Utensil` row — same shape as {@link assertIngredientsExist}, checking `input.utensils[].utensilId` instead. */
|
|
async function assertUtensilsExist(ids: number[]): Promise<void> {
|
|
try {
|
|
const uniqueIds = [...new Set(ids)];
|
|
if (uniqueIds.length === 0) return;
|
|
const found = await prisma.utensil.findMany({
|
|
where: { id: { in: uniqueIds } },
|
|
select: { id: true },
|
|
});
|
|
const foundIds = new Set(found.map((utensil) => utensil.id));
|
|
const missing = uniqueIds.filter((id) => !foundIds.has(id));
|
|
if (missing.length > 0) {
|
|
throw new HttpError(
|
|
404,
|
|
ErrorCode.UTENSIL_NOT_FOUND,
|
|
`Utensil ids not found: ${missing.join(", ")}`,
|
|
);
|
|
}
|
|
} catch (err) {
|
|
throw err; // see loadVisibleStepOrThrow's catch comment
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Renumbers every one of `stepId`'s `StepTechStep` rows' `order` by
|
|
* ascending `start` (nulls-still-possible legacy rows, see that model's
|
|
* schema doc comment, sort last) — the dense, reading-order 0-based
|
|
* sequence `@@id([stepId, order])` requires, regardless of whether a
|
|
* caller just inserted, updated, or deleted a row. Simpler and less
|
|
* error-prone than shifting only the affected neighbors' `order` by hand.
|
|
*
|
|
* Two passes, through a disjoint negative range first: updating straight
|
|
* into the final 0..N-1 positions in one pass risks a transient
|
|
* `(stepId, order)` collision (e.g. the row destined for `order: 0` isn't
|
|
* necessarily the one already sitting there) — `order` is always `>= 0`
|
|
* in real usage, so a negative range can never collide with a live row.
|
|
*
|
|
* Exported for `scripts/backfill-tech-steps.ts` to reuse after it
|
|
* recomputes just the `"auto"` subset of a step's rows, so the combined
|
|
* `"auto"` + `"manual"` sequence still ends up in one coherent
|
|
* reading-order.
|
|
*/
|
|
export async function renumberStepTechSteps(
|
|
tx: Prisma.TransactionClient,
|
|
stepId: number,
|
|
): Promise<void> {
|
|
const rows = await tx.stepTechStep.findMany({ where: { stepId } });
|
|
const sorted = [...rows].sort(
|
|
(a, b) => (a.start ?? Number.POSITIVE_INFINITY) - (b.start ?? Number.POSITIVE_INFINITY),
|
|
);
|
|
for (const [index, row] of sorted.entries()) {
|
|
await tx.stepTechStep.update({
|
|
where: { stepId_order: { stepId, order: row.order } },
|
|
data: { order: -(index + 1) },
|
|
});
|
|
}
|
|
for (const [index] of sorted.entries()) {
|
|
await tx.stepTechStep.update({
|
|
where: { stepId_order: { stepId, order: -(index + 1) } },
|
|
data: { order: index },
|
|
});
|
|
}
|
|
}
|
|
|
|
/** One ingredient/utensil mention the viewer themselves selected, ready to persist — see {@link applyManualCorrection}'s own doc comment for the "manual replaces all" semantics these are written under. */
|
|
interface ManualIngredientMention {
|
|
ingredientId: number;
|
|
quantity: number | null;
|
|
unitId: number | null;
|
|
start: number;
|
|
end: number;
|
|
}
|
|
interface ManualUtensilMention {
|
|
utensilId: number;
|
|
start: 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`
|
|
* sequence, immediately — not just recorded as a pending suggestion for
|
|
* `services/tech-step-llm-worker` to eventually process (see
|
|
* `StepTechStepCorrection`'s schema doc comment; this is *in addition to*
|
|
* that offline feedback loop, not instead of it). `previousTechStepId`/
|
|
* `correctedTechStepId` mean exactly what they do on
|
|
* `StepTechStepCorrection` itself (`SubmitTechStepCorrectionInput`'s doc
|
|
* comment, `packages/shared`):
|
|
*
|
|
* - `correctedTechStepId` set (add or relabel): a `"manual"` row is
|
|
* written at the correction's own `[start, end)` — updating the
|
|
* existing entry in place when one matching `previousTechStepId`
|
|
* overlaps this span, otherwise inserting a new one. No `contextStart`/
|
|
* `contextEnd` — a correction only ever carries the tight span the user
|
|
* themselves selected/clicked, nothing wider to highlight around it.
|
|
* - `previousTechStepId` alone (remove, `correctedTechStepId: null`): the
|
|
* matching existing entry is deleted outright (cascading away any
|
|
* ingredient/utensil metadata attached to it, auto or manual — nothing
|
|
* left to attach metadata to once the technique itself is gone). A
|
|
* no-op if none matches (nothing to remove).
|
|
*
|
|
* `metadata`, when given (only ever alongside a real `correctedTechStepId`
|
|
* — enforced by `submitTechStepCorrectionSchema`, not re-checked here),
|
|
* replaces *every* `StepTechStepIngredient`/`StepTechStepUtensil`/
|
|
* `StepTechStepTemperature` row on this occurrence — `source: "auto"` (the
|
|
* classifier's own detection) and any earlier `"manual"` set alike — with
|
|
* the newly-submitted one. This is "le manuel remplace tout" (confirmed
|
|
* with the user): the resolved `order` this technique ends up at
|
|
* (whichever branch above produced it) is the same `techStepOrder` all
|
|
* three metadata tables key on, so the same `deleteMany` + `createMany`
|
|
* pair below is correct whether this call just updated an existing row
|
|
* (which may already carry auto-detected metadata) or created a brand new
|
|
* one (nothing to delete yet — a no-op `deleteMany`, not a special case).
|
|
*
|
|
* Runs inside the same transaction {@link submitTechStepCorrection} uses
|
|
* for the audit-trail insert, so a request never leaves any of these
|
|
* effects (the permanent correction record, the live sequence change, the
|
|
* metadata replacement) only partially applied.
|
|
*/
|
|
async function applyManualCorrection(
|
|
tx: Prisma.TransactionClient,
|
|
stepId: number,
|
|
span: { start: number; end: number },
|
|
previousTechStepId: number | null,
|
|
correctedTechStepId: number | null,
|
|
metadata?: {
|
|
ingredients: ManualIngredientMention[];
|
|
utensils: ManualUtensilMention[];
|
|
temperatures: ManualTemperatureMention[];
|
|
},
|
|
): Promise<void> {
|
|
const existing = await tx.stepTechStep.findMany({ where: { stepId } });
|
|
|
|
const target =
|
|
previousTechStepId !== null
|
|
? existing.find(
|
|
(row) =>
|
|
row.techStepId === previousTechStepId &&
|
|
row.start !== null &&
|
|
row.end !== null &&
|
|
row.start < span.end &&
|
|
span.start < row.end,
|
|
)
|
|
: undefined;
|
|
|
|
if (correctedTechStepId !== null) {
|
|
const order = target
|
|
? target.order
|
|
: existing.reduce((max, row) => Math.max(max, row.order), -1) + 1;
|
|
if (target) {
|
|
await tx.stepTechStep.update({
|
|
where: { stepId_order: { stepId, order } },
|
|
data: {
|
|
techStepId: correctedTechStepId,
|
|
start: span.start,
|
|
end: span.end,
|
|
contextStart: null,
|
|
contextEnd: null,
|
|
source: "manual",
|
|
},
|
|
});
|
|
} else {
|
|
await tx.stepTechStep.create({
|
|
data: {
|
|
stepId,
|
|
techStepId: correctedTechStepId,
|
|
order,
|
|
start: span.start,
|
|
end: span.end,
|
|
source: "manual",
|
|
},
|
|
});
|
|
}
|
|
|
|
if (metadata !== undefined) {
|
|
await tx.stepTechStepIngredient.deleteMany({ where: { stepId, techStepOrder: order } });
|
|
await tx.stepTechStepUtensil.deleteMany({ where: { stepId, techStepOrder: order } });
|
|
if (metadata.ingredients.length > 0) {
|
|
await tx.stepTechStepIngredient.createMany({
|
|
data: metadata.ingredients.map((ingredient) => ({
|
|
stepId,
|
|
techStepOrder: order,
|
|
ingredientId: ingredient.ingredientId,
|
|
quantity: ingredient.quantity,
|
|
unitId: ingredient.unitId,
|
|
start: ingredient.start,
|
|
end: ingredient.end,
|
|
source: "manual",
|
|
})),
|
|
});
|
|
}
|
|
if (metadata.utensils.length > 0) {
|
|
await tx.stepTechStepUtensil.createMany({
|
|
data: metadata.utensils.map((utensil) => ({
|
|
stepId,
|
|
techStepOrder: order,
|
|
utensilId: utensil.utensilId,
|
|
start: utensil.start,
|
|
end: utensil.end,
|
|
source: "manual",
|
|
})),
|
|
});
|
|
}
|
|
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) {
|
|
await tx.stepTechStep.delete({ where: { stepId_order: { stepId, order: target.order } } });
|
|
}
|
|
|
|
await renumberStepTechSteps(tx, stepId);
|
|
}
|
|
|
|
function toCorrectionView(correction: CorrectionWithTechSteps): StepTechStepCorrectionView {
|
|
return {
|
|
id: correction.id,
|
|
start: correction.start,
|
|
end: correction.end,
|
|
previousTechStep: correction.previousTechStep
|
|
? { id: correction.previousTechStep.id, key: correction.previousTechStep.key }
|
|
: null,
|
|
correctedTechStep: correction.correctedTechStep
|
|
? { id: correction.correctedTechStep.id, key: correction.correctedTechStep.key }
|
|
: null,
|
|
createdAt: correction.createdAt.toISOString(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Records one correction to `stepId`'s detected techniques, submitted by
|
|
* `correctorId`, and immediately applies its effect to the step's real
|
|
* `StepTechStep` sequence (a `"manual"`-tagged row — see
|
|
* {@link applyManualCorrection}) — see
|
|
* {@link SubmitTechStepCorrectionInput}'s doc comment (`packages/shared`)
|
|
* for what `previousTechStepId`/`correctedTechStepId` each mean. The audit
|
|
* record itself is never edited/deleted afterward (see
|
|
* `StepTechStepCorrection`'s schema doc comment) — only the live sequence
|
|
* changes on a later correction to the same span.
|
|
*
|
|
* @throws {HttpError} `404 STEP_NOT_FOUND`/`404 RECIPE_NOT_FOUND` — see
|
|
* {@link loadVisibleStepOrThrow}. `400 INVALID_CORRECTION_SPAN` if
|
|
* `start`/`end` (the correction's own span, or any of
|
|
* `input.ingredients`/`input.utensils`/`input.temperatures`' own spans)
|
|
* fall outside the step's current `description` (it may have been
|
|
* edited since the user last saw it). `404 TECH_STEP_NOT_FOUND`/
|
|
* `404 INGREDIENT_NOT_FOUND`/`404 UNIT_NOT_FOUND`/`404 UTENSIL_NOT_FOUND`
|
|
* if any referenced id
|
|
* doesn't exist.
|
|
*/
|
|
export async function submitTechStepCorrection(
|
|
recipeId: number,
|
|
stepId: number,
|
|
input: SubmitTechStepCorrectionInput,
|
|
correctorId: number,
|
|
viewerHouseId: number | null,
|
|
): Promise<SubmitTechStepCorrectionResult> {
|
|
try {
|
|
const step = await loadVisibleStepOrThrow(recipeId, stepId, correctorId, viewerHouseId);
|
|
const descriptionLength = step.description.length;
|
|
|
|
const spans = [
|
|
{ start: input.start, end: input.end },
|
|
...(input.ingredients ?? []),
|
|
...(input.utensils ?? []),
|
|
...(input.temperatures ?? []),
|
|
];
|
|
for (const span of spans) {
|
|
if (span.start >= descriptionLength || span.end > descriptionLength) {
|
|
throw new HttpError(
|
|
400,
|
|
ErrorCode.INVALID_CORRECTION_SPAN,
|
|
`Span [${span.start}, ${span.end}) falls outside step ${stepId}'s description (length ${descriptionLength})`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const techStepIds = [input.previousTechStepId, input.correctedTechStepId].filter(
|
|
(id): id is number => id !== null && id !== undefined,
|
|
);
|
|
await assertTechStepsExist(techStepIds);
|
|
await assertIngredientsExist((input.ingredients ?? []).map((i) => i.ingredientId));
|
|
await assertUnitsExist(
|
|
(input.ingredients ?? []).flatMap((i) =>
|
|
i.unitId !== null && i.unitId !== undefined ? [i.unitId] : [],
|
|
),
|
|
);
|
|
await assertUtensilsExist((input.utensils ?? []).map((u) => u.utensilId));
|
|
|
|
const { correction, techSteps } = await prisma.$transaction(async (tx) => {
|
|
const createdCorrection = await tx.stepTechStepCorrection.create({
|
|
data: {
|
|
stepId: step.id,
|
|
correctorId,
|
|
start: input.start,
|
|
end: input.end,
|
|
previousTechStepId: input.previousTechStepId ?? null,
|
|
correctedTechStepId: input.correctedTechStepId ?? null,
|
|
},
|
|
include: correctionInclude,
|
|
});
|
|
|
|
await applyManualCorrection(
|
|
tx,
|
|
step.id,
|
|
{ start: input.start, end: input.end },
|
|
input.previousTechStepId ?? null,
|
|
input.correctedTechStepId ?? null,
|
|
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 };
|
|
});
|
|
|
|
analytics.recordEvent("tech_step.correction_submitted", {
|
|
actorId: correctorId,
|
|
context: {
|
|
recipeId,
|
|
stepId,
|
|
previousTechStepId: input.previousTechStepId ?? null,
|
|
correctedTechStepId: input.correctedTechStepId ?? null,
|
|
},
|
|
});
|
|
|
|
return { correction: toCorrectionView(correction), techSteps: toStepTechStepViews(techSteps) };
|
|
} catch (err) {
|
|
throw err; // see loadVisibleStepOrThrow's catch comment
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Every correction submitted so far for `stepId`, most recent first —
|
|
* mainly useful for a user checking what's already been submitted (by
|
|
* anyone) for a span before adding another (see `StepTechStepCorrectionView`'s
|
|
* doc comment, `packages/shared`).
|
|
*
|
|
* @throws {HttpError} `404 STEP_NOT_FOUND`/`404 RECIPE_NOT_FOUND` — see {@link loadVisibleStepOrThrow}.
|
|
*/
|
|
export async function listTechStepCorrections(
|
|
recipeId: number,
|
|
stepId: number,
|
|
viewerId: number,
|
|
viewerHouseId: number | null,
|
|
): Promise<StepTechStepCorrectionView[]> {
|
|
try {
|
|
const step = await loadVisibleStepOrThrow(recipeId, stepId, viewerId, viewerHouseId);
|
|
const corrections = await prisma.stepTechStepCorrection.findMany({
|
|
where: { stepId: step.id },
|
|
orderBy: { createdAt: "desc" },
|
|
include: correctionInclude,
|
|
});
|
|
return corrections.map(toCorrectionView);
|
|
} catch (err) {
|
|
throw err; // see loadVisibleStepOrThrow's catch comment
|
|
}
|
|
}
|