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>
240 lines
9.9 KiB
TypeScript
240 lines
9.9 KiB
TypeScript
import { HttpError } from "@batch-cooking/error-tools";
|
|
import {
|
|
type BrowsableSourceItemView,
|
|
type CreateRecipeInput,
|
|
type DraftRecipeIngredientView,
|
|
type DraftRecipeStepView,
|
|
ErrorCode,
|
|
type RecipeImportDraftView,
|
|
type RecipeView,
|
|
} from "@batch-cooking/shared";
|
|
import { prisma } from "../../db/prisma.js";
|
|
import { findImportedRecipeIds } from "../../db/recipe-source-sync.js";
|
|
import {
|
|
type IngredientMatchEntry,
|
|
type UnitMatchEntry,
|
|
loadIngredientCatalog,
|
|
loadUnitCatalog,
|
|
} from "../../lib/ingredient-matcher.js";
|
|
import { type RecipeSourceAdapter, markAlreadyImported } from "../../lib/recipe-source-adapter.js";
|
|
import { RecipeSourceError } from "../../lib/recipe-source-errors.js";
|
|
import { getRecipeSource } from "../../lib/recipe-source-registry.js";
|
|
import { translateRecipeIngredients } from "../../lib/recipe-translation.js";
|
|
import { loadTechStepMappingRules, matchTechStepSpans } from "../../lib/tech-step-matcher.js";
|
|
import { getHouseSourceIds } from "../house/house.service.js";
|
|
import { createImportedRecipe } from "../recipe/recipe.service.js";
|
|
import { getIngredients, getUnits } from "../reference/reference.service.js";
|
|
|
|
/**
|
|
* Browsing, previewing, and importing a household's *enabled* external
|
|
* recipe sources (`HouseSource`) — the "onglet Sources" feature (see the
|
|
* project plan). Browsing lists what a source offers
|
|
* (`RecipeSourceAdapter.list()`); previewing fully translates one item
|
|
* (`translateRecipeIngredients`, `matchTechStepSpans` — same building
|
|
* blocks `recipe.service.ts` uses at real save time) without persisting
|
|
* it; importing (`importSourceItem`) is the only function here that
|
|
* actually saves — by the time it's called, the caller (the review screen)
|
|
* has already resolved every ingredient to a real catalog id, same as a
|
|
* manual `POST /recipes`.
|
|
*/
|
|
|
|
/**
|
|
* `sourceKey` must both exist as a `Source` (household-enabled, via
|
|
* `HouseSource`) *and* still be a registered adapter (`recipe-source-registry.ts`)
|
|
* — the two can drift apart (a `Source` row outlives its adapter being
|
|
* unregistered, exactly what `jsonLdRecipe` was cleaned up from — see
|
|
* `sources/index.ts`), so both are checked. Either failure looks like "this
|
|
* source doesn't exist" to the caller (404 `SOURCE_NOT_FOUND`), same
|
|
* "don't distinguish not-found from not-visible" posture `recipe.service.ts`
|
|
* takes for a recipe the viewer can't see.
|
|
*
|
|
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
|
|
* @throws {HttpError} `404 SOURCE_NOT_FOUND` if `sourceKey` doesn't match a source enabled for this household.
|
|
*/
|
|
async function assertSourceEnabled(
|
|
houseId: number | null,
|
|
sourceKey: string,
|
|
): Promise<{ adapter: RecipeSourceAdapter; sourceId: number }> {
|
|
const enabledSourceIds = await getHouseSourceIds(houseId);
|
|
const source = await prisma.source.findUnique({ where: { key: sourceKey } });
|
|
if (!source || !enabledSourceIds.includes(source.id)) {
|
|
throw new HttpError(
|
|
404,
|
|
ErrorCode.SOURCE_NOT_FOUND,
|
|
`Source "${sourceKey}" is not enabled for this household`,
|
|
);
|
|
}
|
|
const adapter = getRecipeSource(sourceKey);
|
|
if (!adapter) {
|
|
throw new HttpError(
|
|
404,
|
|
ErrorCode.SOURCE_NOT_FOUND,
|
|
`Source "${sourceKey}" has no registered adapter`,
|
|
);
|
|
}
|
|
return { adapter, sourceId: source.id };
|
|
}
|
|
|
|
/**
|
|
* One page of `sourceKey`'s own catalog, each item flagged with whether
|
|
* it's already been imported (and, if so, its real `Recipe` id — see
|
|
* `findImportedRecipeIds`).
|
|
*
|
|
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
|
|
* @throws {HttpError} `404 SOURCE_NOT_FOUND` if `sourceKey` doesn't match a source enabled for this household.
|
|
*/
|
|
export async function browseSource(
|
|
sourceKey: string,
|
|
houseId: number | null,
|
|
params: { query?: string; cursor?: string },
|
|
): Promise<{ items: BrowsableSourceItemView[]; nextCursor: string | null }> {
|
|
const { adapter } = await assertSourceEnabled(houseId, sourceKey);
|
|
const result = await adapter.list({ query: params.query, cursor: params.cursor });
|
|
|
|
const importedRecipeIds = await findImportedRecipeIds(
|
|
prisma,
|
|
sourceKey,
|
|
result.items.map((item) => item.externalId),
|
|
);
|
|
const marked = markAlreadyImported(result.items, new Set(importedRecipeIds.keys()));
|
|
|
|
return {
|
|
items: marked.map((item) => ({
|
|
externalId: item.externalId,
|
|
title: item.title,
|
|
picture: item.picture,
|
|
url: item.url,
|
|
alreadyImported: item.alreadyImported,
|
|
recipeId: importedRecipeIds.get(item.externalId) ?? null,
|
|
})),
|
|
nextCursor: result.nextCursor,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Fully translates one source item into an unsaved {@link RecipeImportDraftView}
|
|
* — fetches + parses it (`fetchDetail`/`parse`), then resolves its
|
|
* ingredients/units (`translateRecipeIngredients`) and detects each step's
|
|
* techniques with their exact matched span (`matchTechStepSpans`, the same
|
|
* function `recipe.service.ts` uses at real save time — see its doc
|
|
* comment), all against `adapter.locale`'s catalogs. Ingredient/unit
|
|
* matching itself only has English data today (see `ingredient-matcher.ts`);
|
|
* a non-English-locale source simply gets `ingredient`/`unit: null` on
|
|
* every line, the same graceful "no matching-language data" degradation
|
|
* `translateRecipe` already has.
|
|
*
|
|
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
|
|
* @throws {HttpError} `404 SOURCE_NOT_FOUND` if `sourceKey` doesn't match a source enabled for this household.
|
|
* @throws {HttpError} `404 RECIPE_NOT_FOUND` if `externalId` couldn't be fetched or parsed into a usable recipe (a `RecipeSourceError` — `recipe-source-errors.ts` — from the adapter).
|
|
*/
|
|
export async function previewSourceItem(
|
|
sourceKey: string,
|
|
externalId: string,
|
|
houseId: number | null,
|
|
): Promise<RecipeImportDraftView> {
|
|
const { adapter } = await assertSourceEnabled(houseId, sourceKey);
|
|
|
|
let parsed: ReturnType<typeof adapter.parse>;
|
|
try {
|
|
const raw = await adapter.fetchDetail(externalId);
|
|
parsed = adapter.parse(raw);
|
|
} catch (err) {
|
|
if (err instanceof RecipeSourceError) {
|
|
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, err.message);
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
const [techStepMappings, ingredientCatalog, unitCatalog, techStepsByKey] = await Promise.all([
|
|
loadTechStepMappingRules(adapter.locale),
|
|
adapter.locale === "en" ? loadIngredientCatalog() : Promise.resolve<IngredientMatchEntry[]>([]),
|
|
adapter.locale === "en" ? loadUnitCatalog() : Promise.resolve<UnitMatchEntry[]>([]),
|
|
prisma.techStep.findMany({ select: { id: true, key: true } }),
|
|
]);
|
|
const techStepById = new Map(techStepsByKey.map((techStep) => [techStep.id, techStep]));
|
|
|
|
const translatedIngredients = translateRecipeIngredients(
|
|
parsed.ingredients,
|
|
ingredientCatalog,
|
|
unitCatalog,
|
|
);
|
|
const [ingredientViews, unitViews] = await Promise.all([getIngredients(), getUnits()]);
|
|
const ingredientById = new Map(ingredientViews.map((view) => [view.id, view]));
|
|
const unitById = new Map(unitViews.map((view) => [view.id, view]));
|
|
|
|
const ingredients: DraftRecipeIngredientView[] = translatedIngredients.map((ingredient) => ({
|
|
rawText: ingredient.rawText,
|
|
quantity: ingredient.quantity,
|
|
ingredient:
|
|
ingredient.ingredientId !== null
|
|
? (ingredientById.get(ingredient.ingredientId) ?? null)
|
|
: null,
|
|
unit: ingredient.unitId !== null ? (unitById.get(ingredient.unitId) ?? null) : null,
|
|
}));
|
|
|
|
const steps: DraftRecipeStepView[] = parsed.steps.map((step) => ({
|
|
description: step.description,
|
|
picture: step.picture,
|
|
techSteps: matchTechStepSpans(step.description, techStepMappings).flatMap((match) => {
|
|
const techStep = techStepById.get(match.techStepId);
|
|
return techStep ? [{ techStep, start: match.start, end: match.end }] : [];
|
|
}),
|
|
}));
|
|
|
|
return {
|
|
sourceKey,
|
|
externalId,
|
|
name: parsed.name,
|
|
description: parsed.description,
|
|
picture: parsed.picture,
|
|
portions: parsed.portions,
|
|
sourceUrl: parsed.sourceUrl,
|
|
ingredients,
|
|
steps,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Finalizes an import — the review screen (pre-filled from
|
|
* {@link previewSourceItem}'s draft, unresolved ingredients fixed up by
|
|
* the user via the normal `IngredientPicker`) submits `input` as a
|
|
* regular {@link CreateRecipeInput}, exactly like a manually-authored
|
|
* recipe. This just adds two things `createRecipe` itself can't:
|
|
* confirming `externalId` isn't already imported (the DB's own
|
|
* `@@unique([sourceId, externalId])` would reject a second attempt too,
|
|
* but as a raw constraint violation — checking first gives a clean,
|
|
* expected error instead), and stamping `sourceId`/`externalId` plus
|
|
* matching techniques against the source's own locale
|
|
* (`createImportedRecipe`, `recipe.service.ts`).
|
|
*
|
|
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
|
|
* @throws {HttpError} `404 SOURCE_NOT_FOUND` if `sourceKey` doesn't match a source enabled for this household.
|
|
* @throws {HttpError} `409 RECIPE_ALREADY_IMPORTED` if `externalId` was already imported from this source.
|
|
* @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 importSourceItem(
|
|
sourceKey: string,
|
|
externalId: string,
|
|
input: CreateRecipeInput,
|
|
authorId: number,
|
|
authorHouseId: number | null,
|
|
): Promise<RecipeView> {
|
|
const { adapter, sourceId } = await assertSourceEnabled(authorHouseId, sourceKey);
|
|
|
|
const alreadyImported = await findImportedRecipeIds(prisma, sourceKey, [externalId]);
|
|
if (alreadyImported.has(externalId)) {
|
|
throw new HttpError(
|
|
409,
|
|
ErrorCode.RECIPE_ALREADY_IMPORTED,
|
|
`"${externalId}" from source "${sourceKey}" is already imported`,
|
|
);
|
|
}
|
|
|
|
return createImportedRecipe(input, authorId, authorHouseId, {
|
|
sourceId,
|
|
externalId,
|
|
locale: adapter.locale,
|
|
});
|
|
}
|