Etend le flux de correction existant (TechStepCorrectionPopover) pour que l'utilisateur associe lui-meme des ingredients (avec quantite/unite) et des ustensiles a la technique qu'il corrige, avec le meme marquage source: "manual" que la technique elle-meme. Backend : - submitTechStepCorrectionSchema (packages/shared) accepte des tableaux ingredients/utensils optionnels, chacun avec son propre span [start,end) selectionne par l'utilisateur. Omis = ne touche pas aux metadonnees existantes ; tableau (meme vide) = remplace tout ce qui existait sur cette occurrence (auto ET manuel precedent - decision validee avec l'utilisateur). - applyManualCorrection (recipe-tech-step-correction.service.ts) ecrit les nouvelles lignes StepTechStepIngredient/StepTechStepUtensil apres avoir vide celles de l'occurrence via deleteMany - meme chemin de code que ce soit une creation ou une mise a jour de la technique. - Nouveaux asserts d'existence (ingredient/unite/ustensile) + validation de span, nouveau code d'erreur UTENSIL_NOT_FOUND. - source ajoute a StepTechStepIngredientView/StepTechStepUtensilView (le calque manquait ce que la colonne DB portait deja). Frontend : - TechStepCorrectionPopover passe d'un clic = soumission immediate a un flux selection-puis-confirmation, avec deux nouvelles sections Ingredients/Ustensiles pre-remplies avec l'existant. - Ajouter un ingredient/ustensile demande une selection de texte dediee dans la description encore visible (StepDescription geree via un nouvel etat pendingSpanRequest/resolvedMetadataSpan) - pas de raccourci sur le span de la correction elle-meme. - Nouveau CatalogSearchPicker.tsx, plus leger que IngredientPicker pour ce contexte de popover, reutilise pour les deux catalogues. - getUtensils() ajoute a apiClient. Tests : nouveaux cas Mocha (attache/remplace/omission/validations) dans recipe-tech-step-correction.test.ts, TechStepCorrectionPopover.cy.tsx etendu avec le nouveau flux, recipes.ts (e2e) ajuste au clic Valider supplementaire. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
332 lines
14 KiB
TypeScript
332 lines
14 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 {
|
|
loadIngredientCatalog,
|
|
loadUnitCatalog,
|
|
} 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, getUtensils } 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 has data for `"en"`/`"fr"` today (see `ingredient-matcher.ts`);
|
|
* `loadIngredientCatalog`/`loadUnitCatalog` are always called with
|
|
* `adapter.locale` directly, never specially skipped for a particular
|
|
* one — a source whose locale has no label table of its own just gets back
|
|
* empty catalogs from those two loaders, so every line's `ingredient`/
|
|
* `unit` end up `null` the same way, 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> {
|
|
try {
|
|
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 [stepsWithTechStepMatches, ingredientCatalog, unitCatalog, techStepsByKey] =
|
|
await Promise.all([
|
|
Promise.all(
|
|
parsed.steps.map(async (step) => ({
|
|
step,
|
|
matches: await techStepClassifier.matchTechStepSpans(step.description, adapter.locale),
|
|
})),
|
|
),
|
|
loadIngredientCatalog(adapter.locale),
|
|
loadUnitCatalog(adapter.locale),
|
|
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,
|
|
adapter.locale,
|
|
);
|
|
const [ingredientViews, unitViews, utensilViews] = await Promise.all([
|
|
getIngredients(),
|
|
getUnits(),
|
|
getUtensils(),
|
|
]);
|
|
const ingredientById = new Map(ingredientViews.map((view) => [view.id, view]));
|
|
const unitById = new Map(unitViews.map((view) => [view.id, view]));
|
|
const utensilById = new Map(utensilViews.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",
|
|
ingredients: match.ingredients.flatMap((mention) => {
|
|
const ingredient = ingredientById.get(mention.ingredientId);
|
|
// Same drift guard as `techStep` above — an ingredientId
|
|
// the matcher resolved but that's since vanished from the
|
|
// catalog is dropped rather than shown with a hole in it.
|
|
if (!ingredient) return [];
|
|
return [
|
|
{
|
|
ingredient,
|
|
quantity: mention.quantity,
|
|
unit: mention.unitId !== null ? (unitById.get(mention.unitId) ?? null) : null,
|
|
start: mention.start,
|
|
end: mention.end,
|
|
// Same reasoning as this match's own `source` above — a draft preview only ever holds live classifier output.
|
|
source: "auto" as const,
|
|
},
|
|
];
|
|
}),
|
|
utensils: match.utensils.flatMap((mention) => {
|
|
const utensil = utensilById.get(mention.utensilId);
|
|
return utensil
|
|
? [{ utensil, start: mention.start, end: mention.end, source: "auto" as const }]
|
|
: [];
|
|
}),
|
|
},
|
|
]
|
|
: [];
|
|
}),
|
|
}));
|
|
|
|
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<RecipeView> {
|
|
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
|
|
}
|
|
}
|