import { randomUUID } from "node:crypto"; import { HttpError } from "@batch-cooking/error-tools"; import { type AllergyView, type CreateRecipeInput, computeRecipeDietViews, type DietView, ErrorCode, type IngredientView, type RecipeSummaryView, type RecipeTab, type RecipeView, type StepTechStepView, type UnitView, type UpdateRecipeInput, } from "@batch-cooking/shared"; import type { Prisma } from "@prisma/client"; import { prisma } from "../../db/prisma.js"; import { analytics } from "../../lib/analytics.service.js"; import { type TechStepMatch, techStepClassifier, } from "../../lib/recipe-matching/tech-step-matcher.js"; import { toStepTechStepCreateInputs } from "../../lib/recipe-matching/tech-step-persistence.js"; // No user-language preference exists anywhere in the app yet (a single // "fr" translation file, no locale field on User/UserProfile) — steps are // matched against this hardcoded locale for now. See // `tech-step-matcher.ts`'s `TechStepClassifierService` for why the locale // is a parameter rather than baked into that module. const DEFAULT_TECH_STEP_LOCALE = "fr"; /** Prisma `include` for every query that needs a full {@link RecipeView} — ingredients resolved to their reference data (name, allergens, diet compatibilities), steps in order, and whether `viewerId` has favorited it. Parameterized by viewer since `favoritedBy` is per-viewer, not a static shape. A recipe's own regimes are no longer stored (issue #131 dropped `RecipeDiet`) — they're computed from the ingredient views by {@link computeRecipeDietViews}. */ function recipeInclude(viewerId: number) { return { ingredients: { include: { ingredient: { include: { allergies: { include: { allergy: { include: { category: true } } }, }, diets: { include: { diet: true } }, }, }, unit: true, }, }, steps: { orderBy: { order: "asc" }, include: { techSteps: { orderBy: { order: "asc" }, include: { techStep: true, // Same `allergies`/`diets` nesting as this function's own // top-level `ingredients` include above — reused by // `toIngredientView` so a mentioned ingredient resolves to the // exact same `IngredientView` shape as the recipe's main // ingredient list, not a second, thinner shape. ingredients: { include: { ingredient: { include: { allergies: { include: { allergy: { include: { category: true } } } }, diets: { include: { diet: true } }, }, }, unit: true, }, }, utensils: { include: { utensil: true } }, temperatures: true, }, }, }, }, favoritedBy: { where: { userProfileId: viewerId } }, } satisfies Prisma.RecipeInclude; } /** * Every reference `Diet` row (`{ id, key }`), loaded once and memoized — * `Diet` is seeded reference data that never changes at runtime, so caching * it for the process's lifetime is safe. Used to attach a real `DietView` to * the derived gluten-free regime (see {@link loadGlutenFreeDiet}) and to * resolve the planning picker's `dietIds` filter / a household's members' * regimes for the post-filter in {@link listRecipes}. */ let referenceDietsPromise: Promise | null = null; function loadReferenceDiets(): Promise { if (referenceDietsPromise === null) { referenceDietsPromise = prisma.diet.findMany({ select: { id: true, key: true } }); } return referenceDietsPromise; } /** The reference `glutenFree` {@link DietView} — {@link computeRecipeDietViews} needs it to give the derived gluten-free regime a real `{ id, key }` (no `IngredientDiet` row references it, unlike the other regimes). `undefined` only if the seed somehow lacks the row, in which case gluten-free is simply omitted. */ async function loadGlutenFreeDiet(): Promise { return (await loadReferenceDiets()).find((diet) => diet.key === "glutenFree"); } type RecipeWithDetails = Prisma.RecipeGetPayload<{ include: ReturnType; }>; /** Exported — `shopping-list.service.ts` fetches its own, narrower ingredient include (no need for a whole `RecipeWithDetails`) but shapes the same `allergies`/`diets` nesting, so it reuses {@link toIngredientView} directly instead of re-deriving this type. */ export type IngredientWithDetails = RecipeWithDetails["ingredients"][number]["ingredient"]; /** Exported — see {@link IngredientWithDetails}, same reuse by `shopping-list.service.ts`. `NonNullable` since a `RecipeIngredient.unit` is now `null` on a "selon le goût" line — callers guard that before calling {@link toUnitView}. */ export type UnitWithDetails = NonNullable; /** Shapes a Prisma `Unit` row into the public {@link UnitView} — same "Decimal → number" conversion `reference.service.ts`'s `getUnits` does. Exported — reused as-is by `shopping-list.service.ts` (a shopping list resolves the same reference data, no need for a second copy of this mapping). */ export function toUnitView(unit: UnitWithDetails): UnitView { return { id: unit.id, key: unit.key, type: unit.type, toBaseFactor: Number(unit.toBaseFactor), }; } /** Shapes a Prisma `Ingredient` (with its `allergies`/`diets` relations included) into the public {@link IngredientView} — same flattening as `reference.service.ts`'s `getIngredients`. Exported — see {@link toUnitView}'s doc comment, same reuse by `shopping-list.service.ts`. */ export function toIngredientView(ingredient: IngredientWithDetails): IngredientView { return { id: ingredient.id, key: ingredient.key, icon: ingredient.icon, category: ingredient.category, subcategory: ingredient.subcategory, reproducible: ingredient.reproducible, allergens: ingredient.allergies.map(({ allergy }) => ({ id: allergy.id, key: allergy.category.key, kind: allergy.category.kind, })), diets: ingredient.diets.map(({ diet }) => ({ id: diet.id, key: diet.key })), // A placeholder line round-trips as a normal `Ingredient` with a real // id — the frontend tells it apart by this flag (badge, no // allergen/diet info) and shows `displayName` verbatim instead of // looking up an i18n label that doesn't exist for a `placeholder:` key. isPlaceholder: ingredient.isPlaceholder, displayName: ingredient.displayName, }; } /** Deduplicates allergens (by id) across every ingredient of a recipe, for the aggregated "contains" badge — see {@link RecipeSummaryView.allergens}. */ function aggregateAllergens(ingredients: IngredientView[]): AllergyView[] { const byId = new Map(); for (const ingredient of ingredients) { for (const allergen of ingredient.allergens) { byId.set(allergen.id, allergen); } } return [...byId.values()]; } /** Shapes a Prisma `Recipe` (with {@link recipeInclude} included) into the lighter {@link RecipeSummaryView} used by the catalog table — everything `toRecipeView` also needs, factored out since the full detail view is a strict superset. `glutenFreeDiet` (see {@link loadGlutenFreeDiet}) is threaded through only so {@link computeRecipeDietViews} can attach a real `DietView` to the derived gluten-free regime. */ function toRecipeSummaryView( recipe: RecipeWithDetails, glutenFreeDiet: DietView | undefined, ): RecipeSummaryView { const ingredientViews = recipe.ingredients.map((recipeIngredient) => toIngredientView(recipeIngredient.ingredient), ); return { id: recipe.id, name: recipe.name, description: recipe.description, picture: recipe.picture, portions: recipe.portions, authorId: recipe.authorId, visibility: recipe.visibility, allergens: aggregateAllergens(ingredientViews), // Computed from the ingredients, not stored — see `RecipeSummaryView.diets`. diets: computeRecipeDietViews(ingredientViews, glutenFreeDiet), isFavorite: recipe.favoritedBy.length > 0, }; } /** * Shapes a step's `StepTechStep` rows into {@link StepTechStepView}s — a row * whose `start`/`end` is still `null` (a pre-existing row saved before that * column pair existed, not yet recomputed by a resave — see the schema doc * comment on `StepTechStep`) is dropped rather than surfaced with a null * span, so the frontend only ever deals with real, highlightable matches. * `contextStart`/`contextEnd` are treated more leniently — a row with a * real keyword span but no context (saved before *that* column pair * existed) still has a perfectly good match to show, just without the * wider highlight, so those two are included only when both are present * rather than dropping the whole entry over a still-missing "nice to have". * * Exported — also called by `recipe-tech-step-correction.service.ts` to * shape the fresh `StepTechStep` sequence it returns right after applying * a manual correction, so both places convert the exact same way rather * than risking two slightly different views of the same rows. */ export function toStepTechStepViews( techSteps: RecipeWithDetails["steps"][number]["techSteps"], ): StepTechStepView[] { const views: StepTechStepView[] = []; for (const stepTechStep of techSteps) { const { start, end, contextStart, contextEnd, techStep, source, ingredients, utensils, temperatures, } = stepTechStep; if (start === null || end === null) continue; views.push({ techStep: { id: techStep.id, key: techStep.key, isPlaceholder: techStep.isPlaceholder, displayName: techStep.displayName, }, start, end, // `source` is a plain DB `String`, not a Prisma enum (see // `StepTechStep`'s schema doc comment) — narrowed here rather than // trusting the column's own type, so a value this app never wrote // (a manual DB edit, a future migration gone wrong) degrades to the // safer "auto" reading instead of surfacing an invalid // `StepTechStepView.source` to the frontend. source: source === "manual" ? "manual" : "auto", ...(contextStart !== null && contextEnd !== null ? { contextStart, contextEnd } : {}), ingredients: ingredients.map((stepTechStepIngredient) => ({ ingredient: toIngredientView(stepTechStepIngredient.ingredient), quantity: stepTechStepIngredient.quantity === null ? null : Number(stepTechStepIngredient.quantity), unit: stepTechStepIngredient.unit === null ? null : toUnitView(stepTechStepIngredient.unit), start: stepTechStepIngredient.start, end: stepTechStepIngredient.end, // Same narrowing posture as the technique's own `source` above. source: stepTechStepIngredient.source === "manual" ? "manual" : "auto", })), utensils: utensils.map((stepTechStepUtensil) => ({ utensil: { id: stepTechStepUtensil.utensil.id, key: stepTechStepUtensil.utensil.key }, start: stepTechStepUtensil.start, end: stepTechStepUtensil.end, source: stepTechStepUtensil.source === "manual" ? "manual" : "auto", })), temperatures: temperatures.map((stepTechStepTemperature) => ({ // `celsius` comes back as a Prisma `Decimal` — plain `number` here, // same treatment as `StepTechStepIngredient.quantity` above. celsius: stepTechStepTemperature.celsius === null ? null : Number(stepTechStepTemperature.celsius), gasMark: stepTechStepTemperature.gasMark, qualitative: stepTechStepTemperature.qualitative === "low" || stepTechStepTemperature.qualitative === "medium" || stepTechStepTemperature.qualitative === "high" ? stepTechStepTemperature.qualitative : null, raw: stepTechStepTemperature.raw, start: stepTechStepTemperature.start, end: stepTechStepTemperature.end, // Same narrowing posture as the technique's own `source` above. source: stepTechStepTemperature.source === "manual" ? "manual" : "auto", })), }); } return views; } /** Shapes a Prisma `Recipe` (with {@link recipeInclude} included) into the public {@link RecipeView}. `glutenFreeDiet` — see {@link toRecipeSummaryView}. */ function toRecipeView(recipe: RecipeWithDetails, glutenFreeDiet: DietView | undefined): RecipeView { const ingredients = recipe.ingredients.map((recipeIngredient) => ({ ingredient: toIngredientView(recipeIngredient.ingredient), // Both `null` on a "selon le goût" seasoning line — see `RecipeIngredientView`. quantity: recipeIngredient.quantity === null ? null : Number(recipeIngredient.quantity), unit: recipeIngredient.unit === null ? null : toUnitView(recipeIngredient.unit), })); return { ...toRecipeSummaryView(recipe, glutenFreeDiet), ingredients, steps: recipe.steps.map((step) => ({ id: step.id, description: step.description, picture: step.picture, order: step.order, techSteps: toStepTechStepViews(step.techSteps), })), }; } /** * True if `viewerId`/`viewerHouseId` may *read* this recipe — the author * always can, whatever the current visibility (even a `HOUSE` recipe if * they've since left that household — access to your own creations never * regresses). Otherwise follows `visibility` as documented on * `RecipeVisibility` in schema.prisma. */ function canView( recipe: { authorId: number; authorHouseId: number | null; visibility: string; }, viewerId: number, viewerHouseId: number | null, ): boolean { if (recipe.authorId === viewerId) return true; if (recipe.visibility === "PUBLIC") return true; if (recipe.visibility === "HOUSE") { return viewerHouseId !== null && recipe.authorHouseId === viewerHouseId; } return false; } /** `Recipe` rows `viewerId`/`viewerHouseId` may read at all — the shared base every tab (except `perso`, which is already narrower) further restricts. Mirrors {@link canView} as a query filter. */ function visibleToViewerWhere( viewerId: number, viewerHouseId: number | null, ): Prisma.RecipeWhereInput { return { OR: [ { authorId: viewerId }, { visibility: "PUBLIC" }, ...(viewerHouseId !== null ? [{ visibility: "HOUSE" as const, authorHouseId: viewerHouseId }] : []), ], }; } /** * The two halves of the planning recipe picker's "convient à tout le foyer" * toggle (`suitableForHousehold` on `listRecipes`): * - `allergenWhere` — a `Prisma.RecipeWhereInput` excluding any recipe that * contains an allergen declared by a member (still expressible in SQL). * - `requiredDietKeys` — every regime declared by a member (`omnivore` * dropped: it constrains nothing). A recipe must satisfy *all* of them — * a recipe suiting a vegetarian member doesn't automatically suit a * gluten-free one. Applied as a post-filter in {@link listRecipes} on the * *computed* `RecipeSummaryView.diets`, since regimes are no longer a * stored column to query (issue #131). * * Computed server-side (a small extra query for the members' allergy/regime * data) rather than exposed to the client as raw per-member data: a * member's allergies/regime are private the same way visibility already * keeps a recipe's existence private. Deliberately excludes * `UserProfileDislikedIngredient` — a taste preference, not a safety * constraint (see that model's doc comment). */ async function suitableForHousehold( houseId: number, ): Promise<{ allergenWhere: Prisma.RecipeWhereInput; requiredDietKeys: string[] }> { try { const members = await prisma.userProfile.findMany({ where: { houseId }, select: { diet: { select: { key: true } }, allergies: { select: { allergyId: true } }, }, }); const requiredDietKeys = [ ...new Set( members .map((member) => member.diet?.key) .filter((key): key is string => key !== undefined && key !== "omnivore"), ), ]; const excludedAllergyIds = [ ...new Set(members.flatMap((member) => member.allergies.map((allergy) => allergy.allergyId))), ]; const allergenWhere: Prisma.RecipeWhereInput = excludedAllergyIds.length > 0 ? { ingredients: { none: { ingredient: { allergies: { some: { allergyId: { in: excludedAllergyIds } } } }, }, }, } : {}; return { allergenWhere, requiredDietKeys }; } catch (err) { // Rethrown as-is — `wrapAsyncHandler`/the error middleware (which // already logs it, see `error-logger.ts`) is what actually handles it, // this service layer just isn't allowed a bare `await`/`async` body // without a try/catch per the repo's convention. throw err; } } /** * `Recipe` rows a household is allowed to see given which sources it has * enabled (`HouseSource`, schema.prisma — opt-in, no row means hidden). * Applied unconditionally in {@link listRecipes}, across every tab: a * manually-authored recipe (`sourceId` `null`) is always visible, this * only ever hides a recipe that came from an external source the viewer's * household hasn't turned on. A viewer with no household yet * (`houseId === null`) has nothing enabled by construction (there's no * household row for `HouseSource` to reference), so every sourced recipe * is hidden for them until they join or create one and configure it. */ async function sourceVisibilityWhere(houseId: number | null): Promise { try { const enabledSourceIds = houseId === null ? [] : ( await prisma.houseSource.findMany({ where: { houseId }, select: { sourceId: true }, }) ).map((row) => row.sourceId); return { OR: [{ sourceId: null }, { sourceId: { in: enabledSourceIds } }] }; } catch (err) { throw err; // see suitableForHousehold()'s catch comment above } } /** * Optional narrowing filters for {@link listRecipes}, on top of the * mandatory `tab`/`viewerId`/`viewerHouseId` — grouped into one object * rather than a growing list of positional optional params now that the * planning recipe picker adds two more on top of `search`/ * `suitableForHousehold`. */ export interface ListRecipesFilters { /** Case-insensitive name substring. */ search?: string; /** The planning recipe picker's "convient à tout le foyer" toggle — see {@link suitableForHousehold}. */ suitableForHousehold?: boolean; /** Recipe must carry *every* one of these ingredient ids (AND, not "any of") — the planning recipe picker's ingredient filter. */ ingredientIds?: number[]; /** Recipe's *computed* regimes must include *every* one of these diet ids (AND, same reasoning) — the planning recipe picker's regime filter. Applied as a post-filter now that regimes aren't a stored column (issue #131). */ dietIds?: number[]; } /** * The recipes visible to `viewerId` under one catalog tab, alphabetically, * optionally filtered further (see {@link ListRecipesFilters}). No * "toutes" tab — every recipe a viewer can see falls under exactly one of * `perso`/`foyer`/`publique` (its own visibility); `favoris` is an * orthogonal, cross-cutting filter on top (and re-applies * {@link visibleToViewerWhere} in case access to a previously-favorited * recipe has since changed, e.g. leaving the house that granted it). */ export async function listRecipes( viewerId: number, viewerHouseId: number | null, tab: RecipeTab, filters: ListRecipesFilters = {}, ): Promise { try { const { search, suitableForHousehold: suitable, ingredientIds, dietIds } = filters; const conditions: Prisma.RecipeWhereInput[] = [await sourceVisibilityWhere(viewerHouseId)]; if (search) { conditions.push({ name: { contains: search, mode: "insensitive" } }); } // Regimes are computed from ingredients, not a stored column (issue // #131), so the household-regime and `dietIds` filters can't be SQL // anymore — they run as post-filters on the mapped summaries below. // Everything that still *can* be a `where` stays one. let requiredDietKeys: string[] = []; // No-op without a household — nothing to filter against, same posture as // the `foyer` tab returning everything it can rather than throwing. if (suitable && viewerHouseId !== null) { const household = await suitableForHousehold(viewerHouseId); conditions.push(household.allergenWhere); requiredDietKeys = household.requiredDietKeys; } if (ingredientIds && ingredientIds.length > 0) { // One condition per required id (AND) — a recipe must carry all of // them, not just one. conditions.push({ AND: ingredientIds.map((ingredientId) => ({ ingredients: { some: { ingredientId } }, })), }); } switch (tab) { case "favoris": conditions.push({ favoritedBy: { some: { userProfileId: viewerId } } }); conditions.push(visibleToViewerWhere(viewerId, viewerHouseId)); break; case "perso": conditions.push({ visibility: "PERSONAL", authorId: viewerId }); break; case "foyer": // No household — nothing can carry this viewer's authorHouseId. if (viewerHouseId === null) return []; conditions.push({ visibility: "HOUSE", authorHouseId: viewerHouseId }); break; case "publique": conditions.push({ visibility: "PUBLIC" }); break; } const [recipes, referenceDiets] = await Promise.all([ prisma.recipe.findMany({ where: { AND: conditions }, include: recipeInclude(viewerId), orderBy: { name: "asc" }, }), loadReferenceDiets(), ]); const glutenFreeDiet = referenceDiets.find((diet) => diet.key === "glutenFree"); let summaries = recipes.map((recipe) => toRecipeSummaryView(recipe, glutenFreeDiet)); // Post-filters on the *computed* `diets` (see the comment where the SQL // conditions are built). "Every one, not any one" for both. if (requiredDietKeys.length > 0) { summaries = summaries.filter((summary) => requiredDietKeys.every((key) => summary.diets.some((diet) => diet.key === key)), ); } if (dietIds && dietIds.length > 0) { // `omnivore` in the filter would match nothing (it's never emitted) — // treat it as "no constraint" rather than silently returning an empty list. const requiredIds = dietIds.filter( (id) => referenceDiets.find((diet) => diet.id === id)?.key !== "omnivore", ); summaries = summaries.filter((summary) => requiredIds.every((id) => summary.diets.some((diet) => diet.id === id)), ); } return summaries; } catch (err) { throw err; // see suitableForHousehold()'s catch comment above } } /** * A single recipe's full detail. * * @throws {HttpError} `404 RECIPE_NOT_FOUND` if `id` doesn't match any recipe, or if it does but `viewerId` isn't allowed to see it (never `403` — a `PERSONAL`/`HOUSE` recipe belonging to someone else should look indistinguishable from a nonexistent one). */ export async function getRecipe( id: number, viewerId: number, viewerHouseId: number | null, ): Promise { try { const recipe = await findRecipeOrThrow(id, viewerId); if (!canView(recipe, viewerId, viewerHouseId)) { throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`); } return toRecipeView(recipe, await loadGlutenFreeDiet()); } catch (err) { throw err; // see suitableForHousehold()'s catch comment above } } /** * Creates a recipe with its ingredients and ordered steps in one go — * steps' `order` is derived from their position in `input.steps`, * ingredients reference existing reference `Ingredient` rows by id (see * `GET /reference/ingredients`; there's no way to create one here). Its * regimes aren't stored — they're computed from the ingredients on read * (issue #131). `authorId`/`authorHouseId` are fixed at creation and never * change on later edits (see {@link updateRecipe}). * * @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient. * @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit. */ export async function createRecipe( input: CreateRecipeInput, authorId: number, authorHouseId: number | null, ): Promise { try { return await createRecipeInternal(input, authorId, authorHouseId, null); } catch (err) { throw err; // see suitableForHousehold()'s catch comment above } } /** * Finalizes an import from an external source — same validation/creation * path as {@link createRecipe} (by the time this is called, `input` has * already been reviewed and every ingredient resolved to a real catalog * id, same as a manual creation — see `sources.service.ts`'s * `importSourceItem`, the only caller), plus stamping `sourceId`/ * `externalId` and matching techniques against `locale` (the source's own * — e.g. `"en"` for TheMealDB) instead of the hardcoded French default, * since the step text is still in whatever language the source wrote it * in. * * @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient. * @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit. */ export async function createImportedRecipe( input: CreateRecipeInput, authorId: number, authorHouseId: number | null, source: { sourceId: number; externalId: string; locale: string }, ): Promise { try { return await createRecipeInternal(input, authorId, authorHouseId, source); } catch (err) { throw err; // see suitableForHousehold()'s catch comment above } } /** One input step, bundled with its own technique matches — see {@link matchStepsTechSteps}. */ interface StepWithTechSteps { step: T; matches: TechStepMatch[]; } /** * Matches every one of `steps`' technique sequence against `locale`, in * parallel, each bundled back with its own originating step (rather than * returned as a same-length array callers would have to re-zip with * `steps` by index — `noUncheckedIndexedAccess` makes that genuinely * awkward for no benefit, since every match list is only ever read back * once) — the shared prep step {@link createRecipeInternal}/ * {@link updateRecipe} both need before building their (synchronous) * Prisma `create` payload, now that matching itself is async * (`techStepClassifier`, a trained model rather than a pure regex test — * see `tech-step-matcher.ts`). */ async function matchStepsTechSteps( steps: T[], locale: string, ): Promise[]> { return Promise.all( steps.map(async (step) => ({ step, matches: await techStepClassifier.matchTechStepSpans(step.description, locale), })), ); } /** One resolved ingredient line — every `placeholderName` has been turned into a real (placeholder) `ingredientId`, ready for a `RecipeIngredient` create. `quantity`/`unitId` are both `null` for a "selon le goût" seasoning line. */ interface ResolvedIngredientLine { ingredientId: number; quantity: number | null; unitId: number | null; } /** * Turns each `input.ingredients` line into a {@link ResolvedIngredientLine}: * a line that already carries an `ingredientId` (a catalog pick, or an * existing placeholder round-tripping through an edit) passes through * unchanged; a `placeholderName` line gets a fresh placeholder `Ingredient` * row (`isPlaceholder: true`, a generated `placeholder:` key, the * typed text as `displayName`, stamped with `authorId`/now) created via * `tx` — so it rolls back together with the recipe if anything later in the * same transaction fails, never leaving an orphan behind. * * Returns the created placeholders separately so the caller can emit one * `ingredient.placeholder_created` analytics event per row *after* the * transaction commits (the recipe id it wants in the event context doesn't * exist yet in here). */ async function resolveIngredientLines( lines: CreateRecipeInput["ingredients"], authorId: number, tx: Prisma.TransactionClient, ): Promise<{ resolved: ResolvedIngredientLine[]; createdPlaceholders: { id: number; name: string }[]; }> { try { const resolved: ResolvedIngredientLine[] = []; const createdPlaceholders: { id: number; name: string }[] = []; for (const line of lines) { if (line.ingredientId !== undefined) { resolved.push({ ingredientId: line.ingredientId, quantity: line.quantity ?? null, unitId: line.unitId ?? null, }); continue; } // `recipeIngredientInputSchema`'s refine guarantees the other branch. const name = (line.placeholderName ?? "").trim(); const placeholder = await tx.ingredient.create({ data: { key: `placeholder:${randomUUID()}`, isPlaceholder: true, displayName: name, createdById: authorId, createdAt: new Date(), }, select: { id: true }, }); resolved.push({ ingredientId: placeholder.id, quantity: line.quantity ?? null, unitId: line.unitId ?? null, }); createdPlaceholders.push({ id: placeholder.id, name }); } return { resolved, createdPlaceholders }; } catch (err) { throw err; // see suitableForHousehold()'s catch comment above } } async function createRecipeInternal( input: CreateRecipeInput, authorId: number, authorHouseId: number | null, source: { sourceId: number; externalId: string; locale: string } | null, ): Promise { try { await assertIngredientsExist( input.ingredients.map((i) => i.ingredientId).filter((id): id is number => id !== undefined), ); await assertUnitsExist( input.ingredients.flatMap((i) => (i.unitId === undefined ? [] : [i.unitId])), ); // Matched up front (one call per step, in parallel) rather than inline // inside the `steps.create` map below — `techStepClassifier` is async // (a trained model, not a pure regex test), so its result has to // already be in hand by the time this synchronous Prisma payload is // built. const stepsWithTechSteps = await matchStepsTechSteps( input.steps, source?.locale ?? DEFAULT_TECH_STEP_LOCALE, ); // One transaction so the free-text placeholder `Ingredient` rows and the // recipe that references them commit together — a failed recipe create // must never leave orphan placeholders behind. const { created, createdPlaceholders } = await prisma.$transaction(async (tx) => { const { resolved, createdPlaceholders } = await resolveIngredientLines( input.ingredients, authorId, tx, ); const created = await tx.recipe.create({ data: { name: input.name, description: input.description ?? null, picture: input.picture ?? null, portions: input.portions, authorId, authorHouseId, visibility: input.visibility, sourceId: source?.sourceId ?? null, externalId: source?.externalId ?? null, ingredients: { create: resolved.map((line) => ({ ingredientId: line.ingredientId, quantity: line.quantity, unitId: line.unitId, })), }, steps: { create: stepsWithTechSteps.map(({ step, matches }, index) => ({ description: step.description, picture: step.picture ?? null, order: index, // Technique rows + their clause metadata (ingredients / // utensils / temperatures) — see `toStepTechStepCreateInputs`. // `updateRecipe` below builds the exact same payload from the // same helper so a resave never drops what a create persisted. techSteps: { create: toStepTechStepCreateInputs(matches) }, })), }, }, include: recipeInclude(authorId), }); return { created, createdPlaceholders }; }); analytics.recordEvent(source === null ? "recipe.created" : "recipe.imported", { actorId: authorId, context: { recipeId: created.id, sourceId: source?.sourceId ?? null }, }); for (const placeholder of createdPlaceholders) { analytics.recordEvent("ingredient.placeholder_created", { actorId: authorId, context: { name: placeholder.name, ingredientId: placeholder.id, recipeId: created.id }, }); } return toRecipeView(created, await loadGlutenFreeDiet()); } catch (err) { throw err; // see suitableForHousehold()'s catch comment above } } /** * Replaces a recipe's whole content — name/description/picture/visibility * and the complete ingredient/step lists (not a partial merge: a line * missing from `input` is removed, same contract as `PATCH * /profile/allergies`). Regimes aren't part of the payload — they're * recomputed from the ingredients on the next read (issue #131). * `authorId`/`authorHouseId` are untouched — editing never transfers * ownership. * * @throws {HttpError} `404 RECIPE_NOT_FOUND` if `id` doesn't match any recipe visible to `viewerId`. * @throws {HttpError} `403 NOT_RECIPE_AUTHOR` if `viewerId` isn't this recipe's author. * @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient. * @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit. */ export async function updateRecipe( id: number, input: UpdateRecipeInput, viewerId: number, viewerHouseId: number | null, ): Promise { try { await assertIsAuthor(id, viewerId, viewerHouseId); await assertIngredientsExist( input.ingredients .map((i) => i.ingredientId) .filter((ingredientId): ingredientId is number => ingredientId !== undefined), ); await assertUnitsExist( input.ingredients.flatMap((i) => (i.unitId === undefined ? [] : [i.unitId])), ); const stepsWithTechSteps = await matchStepsTechSteps(input.steps, DEFAULT_TECH_STEP_LOCALE); // Interactive transaction (not the array form) so any new free-text // placeholder rows are created in the same atomic unit as the // delete+recreate of the recipe's content. An *existing* placeholder // line round-trips by its real `ingredientId` and is left untouched; // only a brand-new `placeholderName` line creates a row here. const createdPlaceholders = await prisma.$transaction(async (tx) => { await tx.recipeIngredient.deleteMany({ where: { recipeId: id } }); await tx.step.deleteMany({ where: { recipeId: id } }); const { resolved, createdPlaceholders } = await resolveIngredientLines( input.ingredients, viewerId, tx, ); await tx.recipe.update({ where: { id }, data: { name: input.name, description: input.description ?? null, picture: input.picture ?? null, portions: input.portions, visibility: input.visibility, ingredients: { create: resolved.map((line) => ({ ingredientId: line.ingredientId, quantity: line.quantity, unitId: line.unitId, })), }, steps: { create: stepsWithTechSteps.map(({ step, matches }, index) => ({ description: step.description, picture: step.picture ?? null, order: index, // Same builder as `createRecipeInternal` — a resave must // persist the clause metadata (ingredients / utensils / // temperatures), not just the technique span. Omitting it // here is what left every edited recipe with temperature-less // `preheat` steps, so `cooking-optimizer.ts` could never spot // an oven-temperature conflict between two of them. techSteps: { create: toStepTechStepCreateInputs(matches) }, })), }, }, }); return createdPlaceholders; }); for (const placeholder of createdPlaceholders) { analytics.recordEvent("ingredient.placeholder_created", { actorId: viewerId, context: { name: placeholder.name, ingredientId: placeholder.id, recipeId: id }, }); } return toRecipeView(await findRecipeOrThrow(id, viewerId), await loadGlutenFreeDiet()); } catch (err) { throw err; // see suitableForHousehold()'s catch comment above } } /** * Deletes a recipe outright — its ingredients/steps/diet tags/favorites * cascade away (see `onDelete: Cascade` in schema.prisma). * * @throws {HttpError} `404 RECIPE_NOT_FOUND` if `id` doesn't match any recipe visible to `viewerId`. * @throws {HttpError} `403 NOT_RECIPE_AUTHOR` if `viewerId` isn't this recipe's author. * @throws {HttpError} `409 RECIPE_IN_USE` if the recipe is still referenced by a `PlanningItem` — `PlanningItem.recipeId` has no cascade of its own on purpose (removing a recipe shouldn't silently blow a hole in a planning), so this is surfaced as a normal, actionable conflict rather than a raw FK violation. */ export async function deleteRecipe( id: number, viewerId: number, viewerHouseId: number | null, ): Promise { try { await assertIsAuthor(id, viewerId, viewerHouseId); const usedInPlanning = await prisma.planningItem.findFirst({ where: { recipeId: id }, }); if (usedInPlanning) { throw new HttpError( 409, ErrorCode.RECIPE_IN_USE, "Recipe is still used by at least one planning item", ); } await prisma.recipe.delete({ where: { id } }); } catch (err) { throw err; // see suitableForHousehold()'s catch comment above } } /** * Favorites a recipe for `viewerId` — idempotent (favoriting an * already-favorited recipe is a no-op, not an error). * * @throws {HttpError} `404 RECIPE_NOT_FOUND` if `id` doesn't match any recipe visible to `viewerId` — favoriting something you can't see isn't a valid action. */ export async function addFavorite( id: number, viewerId: number, viewerHouseId: number | null, ): Promise { try { const recipe = await findRecipeOrThrow(id, viewerId); if (!canView(recipe, viewerId, viewerHouseId)) { throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`); } await prisma.recipeFavorite.upsert({ where: { userProfileId_recipeId: { userProfileId: viewerId, recipeId: id }, }, update: {}, create: { userProfileId: viewerId, recipeId: id }, }); } catch (err) { throw err; // see suitableForHousehold()'s catch comment above } } /** Unfavorites a recipe for `viewerId` — idempotent, no error if it wasn't favorited (or doesn't exist/isn't visible: unfavoriting is always safe, nothing to leak). */ export async function removeFavorite(id: number, viewerId: number): Promise { try { await prisma.recipeFavorite.deleteMany({ where: { userProfileId: viewerId, recipeId: id }, }); } catch (err) { throw err; // see suitableForHousehold()'s catch comment above } } /** * Guard for other modules that need to confirm a recipe is visible to a * viewer before referencing it (e.g. `planning.service.ts`'s * `addPlanningItem`, before creating a `PlanningItem` pointing at it) — * exported rather than duplicating {@link canView} at the call site. * * @throws {HttpError} `404 RECIPE_NOT_FOUND` if `id` doesn't match any recipe, or does but isn't visible to `viewerId` — never `403`, same reasoning as `getRecipe`. */ export async function assertRecipeVisible( id: number, viewerId: number, viewerHouseId: number | null, ): Promise { try { const recipe = await findRecipeOrThrow(id, viewerId); if (!canView(recipe, viewerId, viewerHouseId)) { throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`); } } catch (err) { throw err; // see suitableForHousehold()'s catch comment above } } /** Re-fetches a recipe by id (with {@link recipeInclude}), or throws `404 RECIPE_NOT_FOUND` — the shared "load or reject" step for every recipe endpoint. Does *not* check visibility on its own — callers combine it with {@link canView} (read paths) or {@link assertIsAuthor} (write paths). */ async function findRecipeOrThrow(id: number, viewerId: number): Promise { try { const recipe = await prisma.recipe.findUnique({ where: { id }, include: recipeInclude(viewerId), }); if (!recipe) { throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`); } return recipe; } catch (err) { throw err; // see suitableForHousehold()'s catch comment above } } /** Shared "load, check visible, check authored by viewer" guard for the write paths (`updateRecipe`/`deleteRecipe`). */ async function assertIsAuthor( id: number, viewerId: number, viewerHouseId: number | null, ): Promise { try { const recipe = await findRecipeOrThrow(id, viewerId); if (!canView(recipe, viewerId, viewerHouseId)) { throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`); } if (recipe.authorId !== viewerId) { throw new HttpError(403, ErrorCode.NOT_RECIPE_AUTHOR, "Only the recipe's author can do this"); } } catch (err) { throw err; // see suitableForHousehold()'s catch comment above } } /** Throws `404 INGREDIENT_NOT_FOUND` if any of `ingredientIds` doesn't match a reference `Ingredient` row. */ async function assertIngredientsExist(ingredientIds: number[]): Promise { try { const uniqueIds = [...new Set(ingredientIds)]; const found = await prisma.ingredient.findMany({ where: { id: { in: uniqueIds } }, select: { id: true }, }); if (found.length !== uniqueIds.length) { const foundIds = new Set(found.map((ingredient) => ingredient.id)); const missing = uniqueIds.filter((id) => !foundIds.has(id)); throw new HttpError( 404, ErrorCode.INGREDIENT_NOT_FOUND, `Ingredient(s) not found: ${missing.join(", ")}`, ); } } catch (err) { throw err; // see suitableForHousehold()'s catch comment above } } /** Throws `404 UNIT_NOT_FOUND` if any of `unitIds` doesn't match a reference `Unit` row. */ async function assertUnitsExist(unitIds: number[]): Promise { try { const uniqueIds = [...new Set(unitIds)]; const found = await prisma.unit.findMany({ where: { id: { in: uniqueIds } }, select: { id: true }, }); if (found.length !== uniqueIds.length) { const foundIds = new Set(found.map((unit) => unit.id)); const missing = uniqueIds.filter((id) => !foundIds.has(id)); throw new HttpError( 404, ErrorCode.UNIT_NOT_FOUND, `Unit(s) not found: ${missing.join(", ")}`, ); } } catch (err) { throw err; // see suitableForHousehold()'s catch comment above } }