Backend : - `createRecipe` refactorisé en fine enveloppe autour d'un nouvel helper interne `createRecipeInternal`, paramétré par une source d'import optionnelle ; nouvelle fonction exportée `createImportedRecipe` qui réutilise toute la validation ingrédients/unités/diets et le matching des tech steps, sans dupliquer cette logique. - La locale de l'adaptateur source est propagée jusqu'au chargement des `TechStepMapping`, pour que le texte anglais (TheMealDB, etc.) soit matché contre le bon jeu de règles au lieu du défaut français. - Nouvel endpoint `POST /sources/:sourceKey/import/:externalId` — valide le payload via `createRecipeSchema` (même schéma qu'une création manuelle) et persiste une vraie `Recipe` liée à la source (`sourceId`/`externalId`). - Nouveau code d'erreur `RECIPE_ALREADY_IMPORTED` (4022) quand l'item a déjà été importé pour ce foyer. Frontend : - `ImportRecipePage` (nouvelle page, `/recettes/importer/:sourceKey/:externalId`) — pré-remplit le formulaire depuis `previewSourceItem`, en miroir de `RecipeFormPage` (mêmes sous-composants : `IngredientRow`, `IngredientPicker`, `StepListEditor`, `DietTagSelect`). Ajoute une section dédiée aux lignes d'ingrédients non résolues automatiquement : l'utilisateur choisit un ingrédient réel via l'`IngredientPicker` existant ou retire la ligne — aucune recette invalide n'est jamais soumise, le bouton d'import reste désactivé tant qu'il en reste. - `SourceItemPreviewPanel` gagne un lien « Importer cette recette » vers cet écran. Tests : - Mocha (`apps/api/test/sources.test.ts`) : 6 nouveaux tests sur `POST /sources/:sourceKey/import/:externalId` (payload valide, ingrédient/unité inconnus, déjà importé, deux foyers distincts, locale de la source respectée pour les tech steps). 282 tests passent au total, aucune régression. - Cypress : nouveau scénario Gherkin bout-en-bout dans `recipe-sources.feature` (parcourir → prévisualiser → importer → résoudre un ingrédient non reconnu → confirmer → atterrir sur la recette sauvegardée). Steps d'édition d'ingrédients/étapes génériques déplacés de `recipe-form.ts` vers `cypress/support/step_definitions/common.steps.ts`, réutilisables par ce nouveau scénario. Suite : étape 4 (ajouter au planning déclenche l'import si nécessaire). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
662 lines
27 KiB
TypeScript
662 lines
27 KiB
TypeScript
import { HttpError } from "@batch-cooking/error-tools";
|
|
import {
|
|
type AllergyView,
|
|
type CreateRecipeInput,
|
|
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 { loadTechStepMappingRules, matchTechStepSpans } from "../../lib/tech-step-matcher.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 `loadTechStepMappingRules` 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 + 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 } },
|
|
},
|
|
},
|
|
unit: true,
|
|
},
|
|
},
|
|
steps: {
|
|
orderBy: { order: "asc" },
|
|
include: { techSteps: { orderBy: { order: "asc" }, include: { techStep: true } } },
|
|
},
|
|
diets: { include: { diet: true } },
|
|
favoritedBy: { where: { userProfileId: viewerId } },
|
|
} satisfies Prisma.RecipeInclude;
|
|
}
|
|
|
|
type RecipeWithDetails = Prisma.RecipeGetPayload<{ include: ReturnType<typeof recipeInclude> }>;
|
|
type IngredientWithDetails = RecipeWithDetails["ingredients"][number]["ingredient"];
|
|
type UnitWithDetails = RecipeWithDetails["ingredients"][number]["unit"];
|
|
|
|
/** Shapes a Prisma `Unit` row into the public {@link UnitView} — same "Decimal → number" conversion `reference.service.ts`'s `getUnits` does. */
|
|
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 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<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. */
|
|
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,
|
|
portions: recipe.portions,
|
|
authorId: recipe.authorId,
|
|
visibility: recipe.visibility,
|
|
allergens,
|
|
diets: recipe.diets.map((recipeDiet) => toDietView(recipeDiet.diet)),
|
|
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 this
|
|
* column 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.
|
|
*/
|
|
function toStepTechStepViews(
|
|
techSteps: RecipeWithDetails["steps"][number]["techSteps"],
|
|
): StepTechStepView[] {
|
|
const views: StepTechStepView[] = [];
|
|
for (const stepTechStep of techSteps) {
|
|
if (stepTechStep.start === null || stepTechStep.end === null) continue;
|
|
views.push({
|
|
techStep: { id: stepTechStep.techStep.id, key: stepTechStep.techStep.key },
|
|
start: stepTechStep.start,
|
|
end: stepTechStep.end,
|
|
});
|
|
}
|
|
return views;
|
|
}
|
|
|
|
/** 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: toUnitView(recipeIngredient.unit),
|
|
}));
|
|
return {
|
|
...toRecipeSummaryView(recipe),
|
|
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 }]
|
|
: []),
|
|
],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* `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<Prisma.RecipeWhereInput> {
|
|
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 };
|
|
}
|
|
|
|
/**
|
|
* `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> {
|
|
const enabledSourceIds =
|
|
houseId === null
|
|
? []
|
|
: (await prisma.houseSource.findMany({ where: { houseId }, select: { sourceId: true } })).map(
|
|
(row) => row.sourceId,
|
|
);
|
|
return { OR: [{ sourceId: null }, { sourceId: { in: enabledSourceIds } }] };
|
|
}
|
|
|
|
/**
|
|
* 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<RecipeSummaryView[]> {
|
|
const { search, suitableForHousehold, ingredientIds, dietIds } = filters;
|
|
const conditions: Prisma.RecipeWhereInput[] = [await sourceVisibilityWhere(viewerHouseId)];
|
|
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<RecipeView> {
|
|
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 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit.
|
|
* @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<RecipeView> {
|
|
return createRecipeInternal(input, authorId, authorHouseId, null);
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
* @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet.
|
|
*/
|
|
export async function createImportedRecipe(
|
|
input: CreateRecipeInput,
|
|
authorId: number,
|
|
authorHouseId: number | null,
|
|
source: { sourceId: number; externalId: string; locale: string },
|
|
): Promise<RecipeView> {
|
|
return createRecipeInternal(input, authorId, authorHouseId, source);
|
|
}
|
|
|
|
async function createRecipeInternal(
|
|
input: CreateRecipeInput,
|
|
authorId: number,
|
|
authorHouseId: number | null,
|
|
source: { sourceId: number; externalId: string; locale: string } | null,
|
|
): Promise<RecipeView> {
|
|
await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId));
|
|
await assertUnitsExist(input.ingredients.map((i) => i.unitId));
|
|
await assertDietsExist(input.dietIds);
|
|
const techStepMappings = await loadTechStepMappingRules(
|
|
source?.locale ?? DEFAULT_TECH_STEP_LOCALE,
|
|
);
|
|
|
|
const created = await prisma.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: input.ingredients.map((ingredient) => ({
|
|
ingredientId: ingredient.ingredientId,
|
|
quantity: ingredient.quantity,
|
|
unitId: ingredient.unitId,
|
|
})),
|
|
},
|
|
steps: {
|
|
create: input.steps.map((step, index) => ({
|
|
description: step.description,
|
|
picture: step.picture ?? null,
|
|
order: index,
|
|
techSteps: {
|
|
create: matchTechStepSpans(step.description, techStepMappings).map((match, order) => ({
|
|
techStepId: match.techStepId,
|
|
start: match.start,
|
|
end: match.end,
|
|
order,
|
|
})),
|
|
},
|
|
})),
|
|
},
|
|
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 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit.
|
|
* @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<RecipeView> {
|
|
await assertIsAuthor(id, viewerId, viewerHouseId);
|
|
await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId));
|
|
await assertUnitsExist(input.ingredients.map((i) => i.unitId));
|
|
await assertDietsExist(input.dietIds);
|
|
const techStepMappings = await loadTechStepMappingRules(DEFAULT_TECH_STEP_LOCALE);
|
|
|
|
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,
|
|
portions: input.portions,
|
|
visibility: input.visibility,
|
|
ingredients: {
|
|
create: input.ingredients.map((ingredient) => ({
|
|
ingredientId: ingredient.ingredientId,
|
|
quantity: ingredient.quantity,
|
|
unitId: ingredient.unitId,
|
|
})),
|
|
},
|
|
steps: {
|
|
create: input.steps.map((step, index) => ({
|
|
description: step.description,
|
|
picture: step.picture ?? null,
|
|
order: index,
|
|
techSteps: {
|
|
create: matchTechStepSpans(step.description, techStepMappings).map(
|
|
(match, order) => ({
|
|
techStepId: match.techStepId,
|
|
start: match.start,
|
|
end: match.end,
|
|
order,
|
|
}),
|
|
),
|
|
},
|
|
})),
|
|
},
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<RecipeWithDetails> {
|
|
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<void> {
|
|
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<void> {
|
|
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 UNIT_NOT_FOUND` if any of `unitIds` doesn't match a reference `Unit` row. */
|
|
async function assertUnitsExist(unitIds: number[]): Promise<void> {
|
|
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(", ")}`);
|
|
}
|
|
}
|
|
|
|
/** Throws `404 DIET_NOT_FOUND` if any of `dietIds` doesn't match a reference `Diet` row. */
|
|
async function assertDietsExist(dietIds: number[]): Promise<void> {
|
|
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(", ")}`);
|
|
}
|
|
}
|