batchCooking/apps/api/src/db/recipe-source-sync.ts
Nicolas 44ef5e071f feat(recipes): parcourir et prévisualiser les sources externes (étape 1/4)
Première étape du chantier "onglet Sources" (parcourir toutes les
recettes externes des sources activées par le foyer, importées ou non,
et déclencher leur import à l'ajout au planning) — celle-ci pose les
endpoints backend de lecture seule, rien n'est encore sauvegardé.

- RecipeSourceAdapter gagne `locale` (theMealDbAdapter: "en") — nécessaire
  pour que translateRecipe/matchTechStepSpans sachent contre quel jeu de
  TechStepMapping/labels d'ingrédients traduire une source donnée.
- findImportedExternalIds (recipe-source-sync.ts) devient
  findImportedRecipeIds : renvoie une Map<externalId, recipeId> au lieu
  d'un simple Set — son premier vrai appelant (le parcours) a besoin de
  l'id réel pour naviguer directement vers la recette déjà importée, pas
  seulement savoir qu'elle l'est.
- Nouveau module apps/api/src/modules/sources/ :
  - GET /sources/:sourceKey/browse — appelle list() de l'adaptateur,
    flague chaque item alreadyImported/recipeId. Restreint aux sources
    activées par le foyer courant (HouseSource) ; 404 SOURCE_NOT_FOUND
    sinon, même si la source existe (même posture que la visibilité des
    recettes : "pas trouvée" plutôt que "pas autorisée").
  - GET /sources/:sourceKey/preview/:externalId — fetchDetail + parse +
    résolution complète (translateRecipeIngredients, matchTechStepSpans
    avec spans réels) contre la locale de la source, sans rien
    sauvegarder. Ingrédients non résolus → null plutôt qu'une erreur.
- Nouveaux types partagés (packages/shared/src/types/sources.ts) :
  BrowsableSourceItemView, RecipeImportDraftView (+ Draft*View).

Vérifié en conditions réelles contre TheMealDB (recette "Chicken Handi") :
ingrédients résolus avec la bonne quantité/unité (1.2 kg de poulet, 8
gousses d'ail...), non-résolus corrects (huile végétale, piment vert),
et chaque étape avec ses techniques détectées et leurs spans exacts
(cook/fry/plate/setAside sur la même phrase, etc.).

Tests : 276 passing (+8 nouveaux, sources.test.ts). Étape suivante (2/4) :
l'UI de parcours (onglet Sources) — voir le plan de session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 15:51:16 +02:00

73 lines
3.2 KiB
TypeScript

import type { PrismaClient } from "@prisma/client";
import { listRecipeSources } from "../lib/recipe-source-registry.js";
/**
* Upserts one `Source` row (schema.prisma) per adapter currently in
* `recipe-source-registry.ts`, keyed by `adapter.key` — keeps the
* `sources` catalog an exact mirror of "which sources are actually
* implemented in code", rather than a hand-maintained list that can drift
* out of sync the way `DIETS`/`UNITS` (`reference-seed-data.ts`) would if
* copy-pasted here. Call once at startup (`prisma/seed.ts`) and in test
* setup (`test-support/reset-db.ts`), the same place `seedReferenceData`
* runs — kept as its own function rather than folded into that one, since
* it reads from the adapter registry instead of a static array.
*
* Never deletes a `Source` row whose key fell out of the registry (e.g. an
* adapter temporarily removed from code) — a recipe already imported from
* it should keep citing it rather than having `sourceId` silently nulled
* out from under it (see `onDelete: SetNull` on `Recipe.source` in
* schema.prisma, which is what *would* happen on an actual delete).
*
* Safe to call with an empty registry (leaves the `sources` table
* untouched) — the case whenever nothing has called `registerRecipeSource`
* yet, e.g. most test files (see `apps/api/src/sources/index.ts` for where
* the app's own concrete adapters — currently just TheMealDB — register
* themselves at startup).
*/
export async function syncRecipeSources(prisma: PrismaClient): Promise<void> {
for (const adapter of listRecipeSources()) {
await prisma.source.upsert({
where: { key: adapter.key },
update: { name: adapter.name, official: adapter.official, iconUrl: adapter.iconUrl },
create: {
key: adapter.key,
name: adapter.name,
official: adapter.official,
iconUrl: adapter.iconUrl,
},
});
}
}
/**
* Which of `externalIds` already have a `Recipe` imported from the source
* registered under `sourceKey`, mapped to that `Recipe`'s id — the
* DB-touching counterpart to `markAlreadyImported` (recipe-source-adapter.ts),
* which stays pure and takes a plain `ReadonlySet<string>` (this map's
* `.keys()`) rather than querying itself. The id (not just membership) is
* what `sources.service.ts`'s browse endpoint needs to link an
* already-imported item straight to its real `Recipe`, instead of a
* caller having to look it up again. Returns an empty map (not an error)
* for a `sourceKey` with no matching `Source` row — nothing can have been
* imported from a source we don't even have a catalog entry for.
*/
export async function findImportedRecipeIds(
prisma: PrismaClient,
sourceKey: string,
externalIds: string[],
): Promise<Map<string, number>> {
if (externalIds.length === 0) return new Map();
const source = await prisma.source.findUnique({ where: { key: sourceKey } });
if (!source) return new Map();
const imported = await prisma.recipe.findMany({
where: { sourceId: source.id, externalId: { in: externalIds } },
select: { id: true, externalId: true },
});
return new Map(
imported.flatMap((recipe) =>
recipe.externalId !== null ? [[recipe.externalId, recipe.id]] : [],
),
);
}