batchCooking/apps/api/src/modules/sources/sources.service.ts
Nicolas e4c8d910d4 feat(recipes): écran de revue et finalisation de l'import (étape 3/4)
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>
2026-08-20 16:56:06 +02:00

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,
});
}