Some checks failed
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>
1051 lines
43 KiB
TypeScript
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
|
|
}
|
|
}
|