import { HttpError } from "@batch-cooking/error-tools"; import { type AllergyView, type CreateRecipeInput, type DietView, ErrorCode, type IngredientView, type RecipeSummaryView, type RecipeTab, type RecipeView, type UpdateRecipeInput, } from "@batch-cooking/shared"; import type { Prisma } from "@prisma/client"; import { prisma } from "../../db/prisma.js"; /** Prisma `include` for every query that needs a full {@link RecipeView} — ingredients resolved to their reference data + allergens, steps in order, diet tags, and whether `viewerId` has favorited it. Parameterized by viewer since `favoritedBy` is per-viewer, not a static shape. */ function recipeInclude(viewerId: number) { return { ingredients: { include: { ingredient: { include: { allergies: { include: { allergy: { include: { category: true } } } }, diets: { include: { diet: true } }, }, }, }, }, steps: { orderBy: { order: "asc" } }, diets: { include: { diet: true } }, favoritedBy: { where: { userProfileId: viewerId } }, } satisfies Prisma.RecipeInclude; } type RecipeWithDetails = Prisma.RecipeGetPayload<{ include: ReturnType }>; type IngredientWithDetails = RecipeWithDetails["ingredients"][number]["ingredient"]; /** Shapes a Prisma `Ingredient` (with its `allergies`/`diets` relations included) into the public {@link IngredientView} — same aplattening as `reference.service.ts`'s `getIngredients`. */ 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 })), }; } function toDietView(diet: { id: number; key: string }): DietView { return { id: diet.id, key: diet.key }; } /** 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. */ function toRecipeSummaryView(recipe: RecipeWithDetails): RecipeSummaryView { const allergens = aggregateAllergens( recipe.ingredients.map((recipeIngredient) => toIngredientView(recipeIngredient.ingredient)), ); return { id: recipe.id, name: recipe.name, description: recipe.description, picture: recipe.picture, authorId: recipe.authorId, visibility: recipe.visibility, allergens, diets: recipe.diets.map((recipeDiet) => toDietView(recipeDiet.diet)), isFavorite: recipe.favoritedBy.length > 0, }; } /** Shapes a Prisma `Recipe` (with {@link recipeInclude} included) into the public {@link RecipeView}. */ function toRecipeView(recipe: RecipeWithDetails): RecipeView { const ingredients = recipe.ingredients.map((recipeIngredient) => ({ ingredient: toIngredientView(recipeIngredient.ingredient), quantity: Number(recipeIngredient.quantity), unit: recipeIngredient.unit, })); return { ...toRecipeSummaryView(recipe), ingredients, steps: recipe.steps.map((step) => ({ id: step.id, description: step.description, picture: step.picture, order: step.order, })), }; } /** * 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 }] : []), ], }; } /** * `Recipe` rows that avoid every member of `houseId`'s declared allergens * and, for every member with a declared regime, are tagged with that * regime — the planning recipe picker's "convient à tout le foyer" toggle * (`suitableForHousehold` on `listRecipes`). Computed server-side (a small * extra query to gather the household's members' allergy/regime ids) * 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 (404, never 403) — nothing here should let * one member infer another's medical/dietary info from the shape of a * filtered list. Deliberately excludes `UserProfileDislikedIngredient` — * the schema already treats disliked ingredients as a taste preference, * not a safety constraint (see that model's doc comment), so it doesn't * belong in a filter framed around what's safe/appropriate to serve. */ async function suitableForHouseholdWhere(houseId: number): Promise { const members = await prisma.userProfile.findMany({ where: { houseId }, select: { dietId: true, allergies: { select: { allergyId: true } } }, }); const requiredDietIds = [ ...new Set(members.map((m) => m.dietId).filter((id): id is number => id !== null)), ]; const excludedAllergyIds = [ ...new Set(members.flatMap((m) => m.allergies.map((a) => a.allergyId))), ]; const conditions: Prisma.RecipeWhereInput[] = []; if (requiredDietIds.length > 0) { // Every diet declared by a member must be among this recipe's tags — // not "at least one", since a recipe suiting a vegetarian member // doesn't automatically suit a gluten-free one too. conditions.push({ AND: requiredDietIds.map((dietId) => ({ diets: { some: { dietId } } })) }); } if (excludedAllergyIds.length > 0) { conditions.push({ ingredients: { none: { ingredient: { allergies: { some: { allergyId: { in: excludedAllergyIds } } } } }, }, }); } return { AND: conditions }; } /** * 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 suitableForHouseholdWhere}. */ 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 must be tagged with *every* one of these diet ids (AND, same reasoning) — the planning recipe picker's regime filter. */ 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 { const { search, suitableForHousehold, ingredientIds, dietIds } = filters; const conditions: Prisma.RecipeWhereInput[] = []; if (search) { conditions.push({ name: { contains: search, mode: "insensitive" } }); } // No-op without a household — nothing to filter against, same posture as // the `foyer` tab returning everything it can rather than throwing. if (suitableForHousehold && viewerHouseId !== null) { conditions.push(await suitableForHouseholdWhere(viewerHouseId)); } if (ingredientIds && ingredientIds.length > 0) { // One condition per required id (AND) — a recipe must carry all of // them, not just one, same "every one, not any one" posture as // suitableForHouseholdWhere's requiredDietIds. conditions.push({ AND: ingredientIds.map((ingredientId) => ({ ingredients: { some: { ingredientId } } })), }); } if (dietIds && dietIds.length > 0) { conditions.push({ AND: dietIds.map((dietId) => ({ diets: { some: { dietId } } })) }); } 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 = await prisma.recipe.findMany({ where: { AND: conditions }, include: recipeInclude(viewerId), orderBy: { name: "asc" }, }); return recipes.map(toRecipeSummaryView); } /** * 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 { 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); } /** * Creates a recipe with its ingredients, ordered steps and diet tags 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). * `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 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet. */ export async function createRecipe( input: CreateRecipeInput, authorId: number, authorHouseId: number | null, ): Promise { await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId)); await assertDietsExist(input.dietIds); const created = await prisma.recipe.create({ data: { name: input.name, description: input.description ?? null, picture: input.picture ?? null, authorId, authorHouseId, visibility: input.visibility, ingredients: { create: input.ingredients.map((ingredient) => ({ ingredientId: ingredient.ingredientId, quantity: ingredient.quantity, unit: ingredient.unit, })), }, steps: { create: input.steps.map((step, index) => ({ description: step.description, picture: step.picture ?? null, order: index, })), }, diets: { create: input.dietIds.map((dietId) => ({ dietId })) }, }, include: recipeInclude(authorId), }); return toRecipeView(created); } /** * Replaces a recipe's whole content — name/description/picture/visibility * and the complete ingredient/step/diet lists (not a partial merge: a line * missing from `input` is removed, same contract as `PATCH * /profile/allergies`). `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 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet. */ export async function updateRecipe( id: number, input: UpdateRecipeInput, viewerId: number, viewerHouseId: number | null, ): Promise { await assertIsAuthor(id, viewerId, viewerHouseId); await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId)); await assertDietsExist(input.dietIds); await prisma.$transaction([ prisma.recipeIngredient.deleteMany({ where: { recipeId: id } }), prisma.step.deleteMany({ where: { recipeId: id } }), prisma.recipeDiet.deleteMany({ where: { recipeId: id } }), prisma.recipe.update({ where: { id }, data: { name: input.name, description: input.description ?? null, picture: input.picture ?? null, visibility: input.visibility, ingredients: { create: input.ingredients.map((ingredient) => ({ ingredientId: ingredient.ingredientId, quantity: ingredient.quantity, unit: ingredient.unit, })), }, steps: { create: input.steps.map((step, index) => ({ description: step.description, picture: step.picture ?? null, order: index, })), }, diets: { create: input.dietIds.map((dietId) => ({ dietId })) }, }, }), ]); return toRecipeView(await findRecipeOrThrow(id, viewerId)); } /** * 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 { 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 } }); } /** * 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 { 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 }, }); } /** 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 { await prisma.recipeFavorite.deleteMany({ where: { userProfileId: viewerId, recipeId: id } }); } /** * 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 { const recipe = await findRecipeOrThrow(id, viewerId); if (!canView(recipe, viewerId, viewerHouseId)) { throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`); } } /** 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 { 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; } /** 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 { 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"); } } /** Throws `404 INGREDIENT_NOT_FOUND` if any of `ingredientIds` doesn't match a reference `Ingredient` row. */ async function assertIngredientsExist(ingredientIds: number[]): Promise { 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(", ")}`, ); } } /** Throws `404 DIET_NOT_FOUND` if any of `dietIds` doesn't match a reference `Diet` row. */ async function assertDietsExist(dietIds: number[]): Promise { const uniqueIds = [...new Set(dietIds)]; if (uniqueIds.length === 0) return; const found = await prisma.diet.findMany({ where: { id: { in: uniqueIds } }, select: { id: true }, }); if (found.length !== uniqueIds.length) { const foundIds = new Set(found.map((diet) => diet.id)); const missing = uniqueIds.filter((id) => !foundIds.has(id)); throw new HttpError(404, ErrorCode.DIET_NOT_FOUND, `Diet(s) not found: ${missing.join(", ")}`); } }