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, loadIngredientCatalog, loadUnitCatalog, type UnitMatchEntry, } from "../../lib/recipe-matching/ingredient-matcher.js"; import { mergeDuplicateIngredients, translateRecipeIngredients, } from "../../lib/recipe-matching/recipe-translation.js"; import { techStepClassifier } from "../../lib/recipe-matching/tech-step-matcher.js"; import { markAlreadyImported, type RecipeSourceAdapter, } from "../../lib/recipe-sources/recipe-source-adapter.js"; import { RecipeSourceError } from "../../lib/recipe-sources/recipe-source-errors.js"; import { getRecipeSource } from "../../lib/recipe-sources/recipe-source-registry.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 }> { try { 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 }; } 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` per the repo's // async/try-catch convention. throw err; } } /** * 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 }> { try { 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, }; } catch (err) { throw err; // see assertSourceEnabled()'s catch comment above } } /** * 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 { try { const { adapter } = await assertSourceEnabled(houseId, sourceKey); let parsed: ReturnType; 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 [stepsWithTechStepMatches, ingredientCatalog, unitCatalog, techStepsByKey] = await Promise.all([ Promise.all( parsed.steps.map(async (step) => ({ step, matches: await techStepClassifier.matchTechStepSpans(step.description, adapter.locale), })), ), adapter.locale === "en" ? loadIngredientCatalog() : Promise.resolve([]), adapter.locale === "en" ? loadUnitCatalog() : Promise.resolve([]), 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])); // A source's raw ingredient lines aren't deduplicated by the matcher — // two different lines (e.g. "Egg Yolks"/"Eggs") can resolve to the same // catalog ingredient. Folded into one line per ingredient (quantities // summed where that's safe) before the draft ever reaches the review // screen, rather than surfacing the recipe with two rows for "Œuf" and // making the person sort it out — see issue #53's follow-up. const mergedIngredients = mergeDuplicateIngredients(translatedIngredients, unitViews); const ingredients: DraftRecipeIngredientView[] = mergedIngredients.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[] = stepsWithTechStepMatches.map(({ step, matches }) => ({ description: step.description, picture: step.picture, techSteps: matches.flatMap((match) => { const techStep = techStepById.get(match.techStepId); return techStep ? [ { techStep, start: match.start, end: match.end, contextStart: match.contextStart, contextEnd: match.contextEnd, // A draft preview has no persisted `StepTechStep` row to // read a real `source` from at all (it isn't a saved // recipe yet — see `DraftRecipeStepView`'s own doc // comment) — always the classifier's own live match, // never a correction, so always "auto". source: "auto", }, ] : []; }), })); return { sourceKey, externalId, name: parsed.name, description: parsed.description, picture: parsed.picture, portions: parsed.portions, sourceUrl: parsed.sourceUrl, ingredients, steps, }; } catch (err) { throw err; // see assertSourceEnabled()'s catch comment above } } /** * 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 { try { 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 await createImportedRecipe(input, authorId, authorHouseId, { sourceId, externalId, locale: adapter.locale, }); } catch (err) { throw err; // see assertSourceEnabled()'s catch comment above } }