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>
71 lines
2.4 KiB
TypeScript
71 lines
2.4 KiB
TypeScript
import { HttpError } from "@batch-cooking/error-tools";
|
|
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
|
import { ErrorCode, browseSourceSchema, createRecipeSchema } from "@batch-cooking/shared";
|
|
import { Router } from "express";
|
|
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
|
import { browseSource, importSourceItem, previewSourceItem } from "./sources.service.js";
|
|
|
|
/**
|
|
* Router mounted at `/sources` in app.ts — browsing/previewing a
|
|
* household's *enabled* external recipe sources (see `sources.service.ts`).
|
|
* Every route requires a session, same posture as `/recipes`/`/house`: this
|
|
* is app content scoped to the viewer's household, not signup-time
|
|
* reference data (contrast `/reference/sources`, which just lists what
|
|
* exists, public, no auth needed).
|
|
*/
|
|
export const sourcesRouter = Router();
|
|
|
|
/** Route params are typed `string | undefined` by Express even for a segment that always matches when the route does — this just satisfies TS, the branch is unreachable in practice. */
|
|
function requireParam(value: string | undefined): string {
|
|
if (value === undefined) {
|
|
throw new HttpError(400, ErrorCode.VALIDATION_ERROR, "Missing route parameter");
|
|
}
|
|
return value;
|
|
}
|
|
|
|
sourcesRouter.get(
|
|
"/:sourceKey/browse",
|
|
requireAuth,
|
|
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
|
const input = browseSourceSchema.parse(req.query);
|
|
const { houseId } = res.locals.userProfile;
|
|
res.status(200).json(await browseSource(requireParam(req.params.sourceKey), houseId, input));
|
|
}),
|
|
);
|
|
|
|
sourcesRouter.get(
|
|
"/:sourceKey/preview/:externalId",
|
|
requireAuth,
|
|
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
|
const { houseId } = res.locals.userProfile;
|
|
res
|
|
.status(200)
|
|
.json(
|
|
await previewSourceItem(
|
|
requireParam(req.params.sourceKey),
|
|
requireParam(req.params.externalId),
|
|
houseId,
|
|
),
|
|
);
|
|
}),
|
|
);
|
|
|
|
sourcesRouter.post(
|
|
"/:sourceKey/import/:externalId",
|
|
requireAuth,
|
|
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
|
const input = createRecipeSchema.parse(req.body);
|
|
const { id: authorId, houseId } = res.locals.userProfile;
|
|
res
|
|
.status(201)
|
|
.json(
|
|
await importSourceItem(
|
|
requireParam(req.params.sourceKey),
|
|
requireParam(req.params.externalId),
|
|
input,
|
|
authorId,
|
|
houseId,
|
|
),
|
|
);
|
|
}),
|
|
);
|