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 } } /** * 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 }, }); } } /** * 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. A no-op if none matches * (nothing to remove). * * Runs inside the same transaction {@link submitTechStepCorrection} uses * for the audit-trail insert, so a request never leaves the two effects * (the permanent correction record, the live sequence change) only * partially applied. */ async function applyManualCorrection( tx: Prisma.TransactionClient, stepId: number, span: { start: number; end: number }, previousTechStepId: number | null, correctedTechStepId: number | null, ): 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) { if (target) { await tx.stepTechStep.update({ where: { stepId_order: { stepId, order: target.order } }, data: { techStepId: correctedTechStepId, start: span.start, end: span.end, contextStart: null, contextEnd: null, source: "manual", }, }); } else { const nextOrder = existing.reduce((max, row) => Math.max(max, row.order), -1) + 1; await tx.stepTechStep.create({ data: { stepId, techStepId: correctedTechStepId, order: nextOrder, start: span.start, end: span.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` fall outside the step's current `description` (it may * have been edited since the user last saw it). `404 TECH_STEP_NOT_FOUND` * if either tech-step 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); if (input.start >= step.descriptionLength || input.end > step.descriptionLength) { throw new HttpError( 400, ErrorCode.INVALID_CORRECTION_SPAN, `Span [${input.start}, ${input.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); 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, ); const freshTechSteps = await tx.stepTechStep.findMany({ where: { stepId: step.id }, orderBy: { order: "asc" }, include: { techStep: 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 } }