batchCooking/apps/api/src/modules/recipe/recipe.routes.ts
Nicolas 8bdbfda3ae feat(planning): dialog de sélection de recette, création de planning, assignation avec portions
Ajoute le chaînon manquant entre le catalogue de recettes et le planning
hebdomadaire :

- Backend : `PlanningItem.portions` (nouvelle colonne + migration),
  `POST /planning/items` / `DELETE /planning/items/:id` (créent la
  semaine de planning à la volée si besoin), `GET /recipes` gagne les
  filtres `ingredientIds`/`dietIds` (ET) en plus de `suitableForHousehold`
  (déjà préparé).
- Frontend : nouveau `Dialog` générique (premier modal de l'app),
  `RecipePickerDialog` qui réutilise le même affichage que le catalogue
  (`RecipeTabs`/`RecipeTable`) avec recherche par nom, filtre ingrédients,
  filtre régime alimentaire, toggle "convient à tout le foyer", puis une
  étape de saisie du nombre de portions.
- `PlanningPage` : le bouton "+" de chaque case ouvre le dialog, le
  bouton "✕" retire la recette (optimiste, avec rollback si l'appel
  échoue), les portions s'affichent sur chaque chip.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 14:44:02 +02:00

111 lines
3.3 KiB
TypeScript

import { HttpError } from "@batch-cooking/error-tools";
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import {
ErrorCode,
createRecipeSchema,
listRecipesSchema,
updateRecipeSchema,
} from "@batch-cooking/shared";
import { Router } from "express";
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
import {
addFavorite,
createRecipe,
deleteRecipe,
getRecipe,
listRecipes,
removeFavorite,
updateRecipe,
} from "./recipe.service.js";
/** Router mounted at `/recipes` in app.ts. Every route requires a session — the catalog is shared across households, not public (same reasoning as `planning`/`house`: it's app content, not signup-time reference data). */
export const recipeRouter = Router();
/** Parses and validates an `:id` route param, shared by every route below that targets one recipe. */
function parseRecipeId(rawId: string | undefined): number {
const id = Number(rawId);
if (!Number.isInteger(id)) {
throw new HttpError(400, ErrorCode.VALIDATION_ERROR, "id must be an integer");
}
return id;
}
recipeRouter.get(
"/",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const input = listRecipesSchema.parse(req.query);
const { id: viewerId, houseId } = res.locals.userProfile;
res.status(200).json(
await listRecipes(viewerId, houseId, input.tab, {
search: input.search,
suitableForHousehold: input.suitableForHousehold,
ingredientIds: input.ingredientIds,
dietIds: input.dietIds,
}),
);
}),
);
recipeRouter.get(
"/:id",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const id = parseRecipeId(req.params.id);
const { id: viewerId, houseId } = res.locals.userProfile;
res.status(200).json(await getRecipe(id, viewerId, houseId));
}),
);
recipeRouter.post(
"/",
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 createRecipe(input, authorId, houseId));
}),
);
recipeRouter.patch(
"/:id",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const id = parseRecipeId(req.params.id);
const input = updateRecipeSchema.parse(req.body);
const { id: viewerId, houseId } = res.locals.userProfile;
res.status(200).json(await updateRecipe(id, input, viewerId, houseId));
}),
);
recipeRouter.delete(
"/:id",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const id = parseRecipeId(req.params.id);
const { id: viewerId, houseId } = res.locals.userProfile;
await deleteRecipe(id, viewerId, houseId);
res.status(204).end();
}),
);
recipeRouter.post(
"/:id/favorite",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const id = parseRecipeId(req.params.id);
const { id: viewerId, houseId } = res.locals.userProfile;
await addFavorite(id, viewerId, houseId);
res.status(204).end();
}),
);
recipeRouter.delete(
"/:id/favorite",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const id = parseRecipeId(req.params.id);
await removeFavorite(id, res.locals.userProfile.id);
res.status(204).end();
}),
);