batchCooking/apps/api/src/modules/recipe/recipe.service.ts
Nicolas b49ef5bb40
Some checks failed
CI / lint (push) Successful in 1m37s
CI / intent-service-test (push) Successful in 5m11s
CI / build (push) Successful in 1m9s
CI / e2e (push) Failing after 7m13s
CI / test (push) Failing after 18m46s
CI / build-android (push) Has been skipped
feat(recipes): signaler une technique absente du catalogue (#130)
Calque le pattern « ingrédient hors-catalogue » sur `TechStep` : cinq
colonnes additives (`isPlaceholder`/`displayName`/`createdById`/`createdAt`/
`reviewedAt`), migration purement additive, FK `createdById → user_profiles`
en `SET NULL`.

Correction d'étape : `submitTechStepCorrectionSchema` accepte
`correctedTechStepName` (mutuellement exclusif de `correctedTechStepId`) ;
`submitTechStepCorrection` crée une `TechStep` placeholder dans la
transaction et poursuit comme si son id avait été fourni. La correction
s'applique immédiatement (`source: manual`) ; nouvel event analytics
`tech_step.placeholder_proposed`.

`TechStepView` gagne `isPlaceholder`/`displayName` ; `GET
/reference/tech-steps` exclut les placeholders. Nouveau helper web
`techStepLabel` (`displayName ?? t(catalog.techSteps.<key>)`), utilisé par
`StepDescription` et le popover. Le popover a une échappatoire « Ma
technique n'est pas dans la liste » (champ libre).

Admin : `admin-tech-step-catalog.service.ts` + routes
`GET/PATCH /admin/api/catalog/tech-step-placeholders` (liste plate — les
propositions de technique sont rares) ; `CatalogPage` gagne un sélecteur
Ingrédients / Techniques.

Tests : Mocha (proposition placeholder, rejet id+nom, exclusion catalogue,
liste + mark-reviewed admin) + Cypress (composant popover, e2e admin
catalogue). Assertions `.techStep` existantes mises à jour pour les 2
nouveaux champs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 15:40:35 +02:00

1051 lines
43 KiB
TypeScript

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<DietView[]> | null = null;
function loadReferenceDiets(): Promise<DietView[]> {
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<DietView | undefined> {
return (await loadReferenceDiets()).find((diet) => diet.key === "glutenFree");
}
type RecipeWithDetails = Prisma.RecipeGetPayload<{
include: ReturnType<typeof recipeInclude>;
}>;
/** 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<RecipeWithDetails["ingredients"][number]["unit"]>;
/** 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<number, AllergyView>();
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<Prisma.RecipeWhereInput> {
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<RecipeSummaryView[]> {
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<RecipeView> {
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<RecipeView> {
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<RecipeView> {
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<T> {
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<T extends { description: string }>(
steps: T[],
locale: string,
): Promise<StepWithTechSteps<T>[]> {
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:<uuid>` 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<RecipeView> {
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<RecipeView> {
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<void> {
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<void> {
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<void> {
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<void> {
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<RecipeWithDetails> {
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<void> {
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<void> {
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<void> {
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
}
}