Etend le flux de correction existant (TechStepCorrectionPopover) pour que l'utilisateur associe lui-meme des ingredients (avec quantite/unite) et des ustensiles a la technique qu'il corrige, avec le meme marquage source: "manual" que la technique elle-meme. Backend : - submitTechStepCorrectionSchema (packages/shared) accepte des tableaux ingredients/utensils optionnels, chacun avec son propre span [start,end) selectionne par l'utilisateur. Omis = ne touche pas aux metadonnees existantes ; tableau (meme vide) = remplace tout ce qui existait sur cette occurrence (auto ET manuel precedent - decision validee avec l'utilisateur). - applyManualCorrection (recipe-tech-step-correction.service.ts) ecrit les nouvelles lignes StepTechStepIngredient/StepTechStepUtensil apres avoir vide celles de l'occurrence via deleteMany - meme chemin de code que ce soit une creation ou une mise a jour de la technique. - Nouveaux asserts d'existence (ingredient/unite/ustensile) + validation de span, nouveau code d'erreur UTENSIL_NOT_FOUND. - source ajoute a StepTechStepIngredientView/StepTechStepUtensilView (le calque manquait ce que la colonne DB portait deja). Frontend : - TechStepCorrectionPopover passe d'un clic = soumission immediate a un flux selection-puis-confirmation, avec deux nouvelles sections Ingredients/Ustensiles pre-remplies avec l'existant. - Ajouter un ingredient/ustensile demande une selection de texte dediee dans la description encore visible (StepDescription geree via un nouvel etat pendingSpanRequest/resolvedMetadataSpan) - pas de raccourci sur le span de la correction elle-meme. - Nouveau CatalogSearchPicker.tsx, plus leger que IngredientPicker pour ce contexte de popover, reutilise pour les deux catalogues. - getUtensils() ajoute a apiClient. Tests : nouveaux cas Mocha (attache/remplace/omission/validations) dans recipe-tech-step-correction.test.ts, TechStepCorrectionPopover.cy.tsx etendu avec le nouveau flux, recipes.ts (e2e) ajuste au clic Valider supplementaire. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
506 lines
20 KiB
TypeScript
506 lines
20 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 { 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` length (the only thing a
|
|
* correction needs from the step itself), 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; descriptionLength: number }> {
|
|
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, descriptionLength: step.description.length };
|
|
} 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;
|
|
}
|
|
|
|
/**
|
|
* 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` 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` both 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[] },
|
|
): 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",
|
|
})),
|
|
});
|
|
}
|
|
}
|
|
} 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`' 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 spans = [
|
|
{ start: input.start, end: input.end },
|
|
...(input.ingredients ?? []),
|
|
...(input.utensils ?? []),
|
|
];
|
|
for (const span of spans) {
|
|
if (span.start >= step.descriptionLength || span.end > step.descriptionLength) {
|
|
throw new HttpError(
|
|
400,
|
|
ErrorCode.INVALID_CORRECTION_SPAN,
|
|
`Span [${span.start}, ${span.end}) falls outside step ${stepId}'s description (length ${step.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
|
|
? 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,
|
|
})),
|
|
},
|
|
);
|
|
|
|
// Same nested `ingredients`/`utensils` 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 } },
|
|
},
|
|
});
|
|
|
|
return { correction: createdCorrection, techSteps: freshTechSteps };
|
|
});
|
|
|
|
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
|
|
}
|
|
}
|