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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 } }