feat(cooking): endpoint GET /cooking-session (plan de cuisine optimise)

Module cooking-session : charge le Planning couvrant ?date= (meme requete
"plage couvrante" + degradation "jamais null" que /shopping-list), mappe
chaque PlanningItem vers l'entree pure de optimizeCookingPlan (ingredients/
unites/techniques/ustensiles resolus via toIngredientView/toUnitView
reutilisees de recipe.service), renvoie OptimizedCookingPlanView.

- cookingSessionPlanningInclude reprend le sous-arbre steps de recipeInclude.
- Route requireAuth, contrat ?date= identique a /shopping-list.
- Monte /cooking-session dans app.ts.
- Tests d'integration Mocha (401, date invalide, plan vide sans foyer /
  sans planning, mutualisation d'une decoupe entre 2 recettes planifiees).
- specs/batch-cooking-architecture.md : module "Calcul batch-cooking" TODO
  -> v1 implementee ; nouvelle section dans backend-architecture.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-08-28 08:56:59 +02:00
parent 83a12d474d
commit 6b60c11408
6 changed files with 474 additions and 12 deletions

View file

@ -6,6 +6,7 @@ import { env } from "./config/env.js";
import { errorLogger } from "./middlewares/error-logger.js";
import { requestLogger } from "./middlewares/request-logger.js";
import { authRouter } from "./modules/auth/auth.routes.js";
import { cookingSessionRouter } from "./modules/cooking-session/cooking-session.routes.js";
import { houseRouter } from "./modules/house/house.routes.js";
import { techStepWorkerRouter } from "./modules/internal/tech-step-worker.routes.js";
import { planningRouter } from "./modules/planning/planning.routes.js";
@ -52,6 +53,7 @@ export function createServer(): ExpressServer {
server.mountRouter("/recipes", recipeRouter);
server.mountRouter("/reference", referenceRouter);
server.mountRouter("/shopping-list", shoppingListRouter);
server.mountRouter("/cooking-session", cookingSessionRouter);
server.mountRouter("/sources", sourcesRouter);
// Serves the built frontend (production Docker image only — see

View file

@ -0,0 +1,37 @@
import { parseDateOnly } from "@batch-cooking/date-tools";
import { HttpError } from "@batch-cooking/error-tools";
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { ErrorCode, getCookingSessionSchema } from "@batch-cooking/shared";
import { Router } from "express";
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
import { getCookingPlanForDate } from "./cooking-session.service.js";
/** Router mounted at `/cooking-session` in app.ts. */
export const cookingSessionRouter = Router();
/**
* Returns the authenticated user's household's optimized cooking plan for
* the week covering `?date=` (`YYYY-MM-DD`) every recipe planned that
* week reorganized into ordered phases (see {@link getCookingPlanForDate}).
* Always `200`, never `null` no household or nothing planned that week
* both come back as a normal `OptimizedCookingPlanView` with empty
* `recipes`/`phases`. Same request contract as `GET /shopping-list`.
*/
cookingSessionRouter.get(
"/",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const input = getCookingSessionSchema.parse(req.query);
const date = parseDateOnly(input.date);
if (date === null) {
throw new HttpError(
400,
ErrorCode.VALIDATION_ERROR,
`Not a real calendar date: ${input.date}`,
);
}
const plan = await getCookingPlanForDate(res.locals.userProfile.houseId, date);
res.status(200).json(plan);
}),
);

View file

@ -0,0 +1,168 @@
import { type DateTime, getWeekStart, toDateOnly } from "@batch-cooking/date-tools";
import type { CookingTaskIngredientView, OptimizedCookingPlanView } from "@batch-cooking/shared";
import type { Prisma } from "@prisma/client";
import { prisma } from "../../db/prisma.js";
import {
type OptimizerRecipeInput,
type OptimizerStepInput,
optimizeCookingPlan,
} from "../../lib/recipe-matching/cooking-optimizer.js";
import { toIngredientView, toUnitView } from "../recipe/recipe.service.js";
/**
* Prisma `include` for a `Planning` query that needs, for every item, its
* recipe's ordered steps with the full detected-technique tree the raw
* material the optimizer works on (see `cooking-optimizer.ts`). It's the
* `steps` sub-tree of `recipe.service.ts`'s own `recipeInclude`, resolved
* the same way so {@link toIngredientView}/{@link toUnitView} can be reused
* as-is; deliberately narrower than a full `RecipeView` fetch (no
* diets/favorites/recipe-level ingredient list the optimizer reads
* quantities off the technique clauses, not the recipe header).
*/
function cookingSessionPlanningInclude() {
return {
items: {
include: {
recipe: {
select: {
id: true,
name: true,
portions: true,
steps: {
orderBy: { order: "asc" },
include: {
techSteps: {
orderBy: { order: "asc" },
include: {
techStep: true,
ingredients: {
include: {
ingredient: {
include: {
allergies: { include: { allergy: { include: { category: true } } } },
diets: { include: { diet: true } },
},
},
unit: true,
},
},
utensils: { include: { utensil: true } },
},
},
},
},
},
},
},
},
} satisfies Prisma.PlanningInclude;
}
type PlanningWithSteps = Prisma.PlanningGetPayload<{
include: ReturnType<typeof cookingSessionPlanningInclude>;
}>;
type PlanningItemWithSteps = PlanningWithSteps["items"][number];
/**
* Maps one planning item's recipe (with {@link cookingSessionPlanningInclude})
* to the optimizer's pure input shape ingredient/unit/technique/utensil
* rows resolved to their reference views here so the optimizer itself never
* touches Prisma. `Decimal` quantities become plain numbers (same
* `Number(...)` conversion as `recipe.service.ts`'s own view mappers); an
* unresolved-unit line keeps `unit: null`.
*/
function toOptimizerRecipe(item: PlanningItemWithSteps): OptimizerRecipeInput {
const steps: OptimizerStepInput[] = item.recipe.steps.map((step) => ({
stepId: step.id,
order: step.order,
description: step.description,
techSteps: step.techSteps.map((techStep) => {
const ingredients: CookingTaskIngredientView[] = techStep.ingredients.map((line) => ({
ingredient: toIngredientView(line.ingredient),
quantity: line.quantity === null ? null : Number(line.quantity),
unit: line.unit === null ? null : toUnitView(line.unit),
}));
return {
techStep: { id: techStep.techStep.id, key: techStep.techStep.key },
order: techStep.order,
ingredients,
utensils: techStep.utensils.map(({ utensil }) => ({ id: utensil.id, key: utensil.key })),
};
}),
}));
return {
recipeId: item.recipe.id,
name: item.recipe.name,
// The slot's own portion count vs. the recipe's as-written yield — the
// optimizer scales technique-clause quantities by the ratio, same
// reasoning as `shopping-list.service.ts`'s `aggregateShoppingList`.
portions: item.portions,
recipePortions: item.recipe.portions,
steps,
};
}
/**
* Builds the household's optimized cooking plan for the week covering
* `date` every recipe planned that week, reorganized into ordered phases
* that pool shared prep and float passive cooks into the background (see
* `cooking-optimizer.ts`). `date` follows the same convention as
* `planning.service.ts`'s `getPlanningForDate` (a caller-parsed `?date=`,
* not necessarily a Monday).
*
* Like `getShoppingListForDate` and unlike `getPlanningForDate`, this
* **never** returns `null` no household and "no planning covers this week
* yet" both degrade to an empty `phases`/`recipes` on an otherwise normal
* {@link OptimizedCookingPlanView} (the week's date range is always
* computable from `date` alone).
*/
export async function getCookingPlanForDate(
houseId: number | null,
date: DateTime,
): Promise<OptimizedCookingPlanView> {
try {
const weekStart = getWeekStart(toDateOnly(date));
const weekFinish = weekStart.plus({ days: 6 });
const emptyPlan: OptimizedCookingPlanView = {
startDate: weekStart.toJSDate().toISOString(),
finishDate: weekFinish.toJSDate().toISOString(),
recipes: [],
phases: [],
};
if (houseId === null) {
return emptyPlan;
}
// Same "covering range" lookup as getShoppingListForDate — see
// getPlanningForDate's doc comment for the UTC-midnight `Date` rationale.
const dateOnly = toDateOnly(date).toJSDate();
const planning = await prisma.planning.findFirst({
where: {
houseId,
startDate: { lte: dateOnly },
finishDate: { gte: dateOnly },
},
orderBy: { startDate: "desc" },
include: cookingSessionPlanningInclude(),
});
if (!planning) {
return emptyPlan;
}
const { recipes, phases } = optimizeCookingPlan(planning.items.map(toOptimizerRecipe));
return {
startDate: planning.startDate.toISOString(),
finishDate: planning.finishDate.toISOString(),
recipes,
phases,
};
} catch (err) {
// Rethrown as-is — `wrapAsyncHandler`/the error middleware handles it,
// this service layer just isn't allowed a bare `await` per the repo's
// async/try-catch convention.
throw err;
}
}

View file

@ -0,0 +1,204 @@
import type { DateTime } from "@batch-cooking/date-tools";
import { ErrorCode, type SignupInput } from "@batch-cooking/shared";
import { faker } from "@faker-js/faker";
import { expect } from "chai";
import request from "supertest";
import { createApp } from "../src/app.js";
import { prisma } from "../src/db/prisma.js";
import { TEST_REFERENCE_DATE } from "../test-support/reference-date.js";
import { resetDatabase } from "../test-support/reset-db.js";
/** See `auth.test.ts` — same rationale for generating rather than hardcoding. */
function buildSignupPayload(): SignupInput {
const firstName = faker.person.firstName();
const lastName = faker.person.lastName();
return {
firstName,
lastName,
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
password: faker.internet.password({ length: 16 }),
};
}
/** `toISODate()` only returns `null` for an invalid `DateTime` — never the always-valid values here. */
function isoDate(date: DateTime): string {
const iso = date.toISODate();
if (iso === null) throw new Error("Unexpectedly invalid DateTime in a test helper");
return iso;
}
/** The fixed test "today", as the `YYYY-MM-DD` string the `?date=` query expects. */
function today(): string {
return isoDate(TEST_REFERENCE_DATE);
}
/** Resolves a reference row's id by its `reference-seed-data.ts` uid (also its DB `key`) — same helpers as `recipe.test.ts`. */
async function ingredientId(key: string): Promise<number> {
return (await prisma.ingredient.findFirstOrThrow({ where: { key } })).id;
}
async function unitId(key: string): Promise<number> {
return (await prisma.unit.findFirstOrThrow({ where: { key } })).id;
}
async function techStepId(key: string): Promise<number> {
return (await prisma.techStep.findFirstOrThrow({ where: { key } })).id;
}
describe("Cooking session", () => {
const app = createApp();
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
describe("GET /cooking-session", () => {
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
const res = await request(app).get("/cooking-session").query({ date: today() });
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
it("rejects a malformed date with 400 VALIDATION_ERROR", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent.get("/cooking-session").query({ date: "not-a-date" });
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("returns an empty plan when the profile has no household", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent.get("/cooking-session").query({ date: today() });
expect(res.status).to.equal(200);
expect(res.body.recipes).to.deep.equal([]);
expect(res.body.phases).to.deep.equal([]);
});
it("returns an empty plan when no planning covers that week", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
await agent.post("/house").send({ name: "Chez moi" });
const res = await agent.get("/cooking-session").query({ date: today() });
expect(res.status).to.equal(200);
expect(res.body.phases).to.deep.equal([]);
});
it("pools an identical prep step from two planned recipes into one merged-prep task", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
const houseId: number = houseRes.body.id;
const authorId: number = houseRes.body.adminId;
const onionId = await ingredientId("onion");
const pieceId = await unitId("piece");
const chopId = await techStepId("chop");
const simmerId = await techStepId("simmer");
/** A recipe: one pure-prep "chop onion" step, then one simmer step. */
async function makeRecipe(name: string, onionQty: number) {
return prisma.recipe.create({
data: {
name,
authorId,
portions: 4,
steps: {
create: [
{
order: 0,
description: "Émincer les oignons",
techSteps: {
create: [
{
techStepId: chopId,
order: 0,
ingredients: {
create: [
{
ingredientId: onionId,
quantity: onionQty,
unitId: pieceId,
start: 0,
end: 1,
},
],
},
},
],
},
},
{
order: 1,
description: "Faire mijoter",
techSteps: { create: [{ techStepId: simmerId, order: 0 }] },
},
],
},
},
});
}
const soupe = await makeRecipe("Soupe", 2);
const tarte = await makeRecipe("Tarte", 3);
const planning = await prisma.planning.create({
data: {
houseId,
startDate: TEST_REFERENCE_DATE.minus({ days: 2 }).toJSDate(),
finishDate: TEST_REFERENCE_DATE.plus({ days: 2 }).toJSDate(),
},
});
await prisma.planningItem.createMany({
data: [
{
planningId: planning.id,
weekDay: "lundi",
meal: "dejeuner",
recipeId: soupe.id,
portions: 4,
},
{
planningId: planning.id,
weekDay: "mardi",
meal: "diner",
recipeId: tarte.id,
portions: 4,
},
],
});
const res = await agent.get("/cooking-session").query({ date: today() });
expect(res.status).to.equal(200);
expect(res.body.recipes.map((r: { name: string }) => r.name)).to.have.members([
"Soupe",
"Tarte",
]);
const mise = res.body.phases[0];
expect(mise.kind).to.equal("mise-en-place");
const merged = mise.tasks.filter((t: { kind: string }) => t.kind === "merged-prep");
expect(merged).to.have.length(1);
expect(merged[0].technique.key).to.equal("chop");
expect(merged[0].ingredients[0].ingredient.key).to.equal("onion");
expect(merged[0].ingredients[0].quantity).to.equal(5);
expect(merged[0].sourceRecipes).to.have.length(2);
// The simmer steps land in a later phase, and one shows as background.
const later = res.body.phases.slice(1);
const backgrounds = later.flatMap((p: { background: unknown[] }) => p.background);
expect(backgrounds.length).to.be.greaterThan(0);
});
});
});

View file

@ -296,6 +296,57 @@ telles quelles par typage structurel.
---
## Cooking session — optimisation des étapes planifiées
Router `/cooking-session` (`cooking-session.routes.ts`/`.service.ts`),
`requireAuth` — un seul endpoint : `GET /cooking-session?date=YYYY-MM-DD`
`getCookingPlanForDate``OptimizedCookingPlanView`. Même contrat `?date=`
que `GET /shopping-list` (schéma shape-only + `parseDateOnly`), même requête
"plage couvrante" que `getPlanningForDate`, et **jamais `null`** de la même
façon que la liste de courses : pas de foyer / aucun `Planning` couvrant la
semaine ⇒ `recipes: []`, `phases: []`.
C'est la première brique du module « Calcul batch-cooking »
([batch-cooking-architecture.md](./batch-cooking-architecture.md)),
jusqu'ici `TODO`. Le service ne fait que **charger + façonner** : sa requête
Prisma (`cookingSessionPlanningInclude`) reprend le sous-arbre `steps` de
`recipe.service.ts`'s `recipeInclude` (steps ordonnés → `StepTechStep`
ordonnés → `techStep` + `ingredients` résolus + `utensils`), puis
`toOptimizerRecipe` mappe chaque `PlanningItem` vers l'entrée pure de
l'optimiseur (ingrédients/unités/techniques/ustensiles déjà en vues de
référence via `toIngredientView`/`toUnitView` réutilisées — même raison que
`shopping-list.service.ts`). Un `PlanningItem` = une entrée d'optimiseur,
même si deux créneaux pointent la même recette à des portions différentes
(deux vraies préparations ; la mutualisation de la découpe les regroupe
quand même).
**`optimizeCookingPlan`** (`lib/recipe-matching/cooking-optimizer.ts`,
pure/synchrone — testable sans base, même split que `ingredient-matcher.ts`
/ `tech-step-matcher.ts`) réorganise les recettes en **phases ordonnées** :
- **Mutualisation de la mise en place** : une technique de découpe
(`PREP_TECHNIQUES` : `chop`/`peel`/`mince`/`julienne`/…) appliquée au même
ingrédient dans une étape *purement prep* (toutes ses techniques sont des
`PREP_TECHNIQUES`) de **≥ 2 recettes** est regroupée en une tâche
`merged-prep` ; les étapes d'origine sont *absorbées* (ne produisent plus
de tâche). Une découpe présente dans une seule recette reste inline (juste
classée en mise en place). Les découpes à l'intérieur d'une étape de
cuisson ne sont pas mutualisées en v1.
- **Parallélisme** : `TECHNIQUE_ATTENTION` classe chaque étape en `SETUP`
(préchauffage, eau à ébullition — poussé en mise en place), `PASSIVE`
(mijoter, braiser, cuire au four, mariner… — non surveillé une fois
lancé) ou `ACTIVE` (défaut). Les phases de cuisson interclassent les
recettes (une « prochaine étape de chaque recette » par phase) ; une
étape `PASSIVE` fait patienter sa recette une phase et s'affiche en
`background` des phases suivantes tant que sa consommatrice n'est pas
remontée.
Quantités mises à l'échelle par `PlanningItem.portions / Recipe.portions`
comme la liste de courses. Sommes d'ingrédients uniquement à unité
identique (aucune conversion — même posture que `ShoppingListItemView`).
---
## `reference` — catalogues publics (pas de session requise)
Router `/reference` (`reference.routes.ts`/`.service.ts`) — **toutes les

View file

@ -16,7 +16,7 @@ L'application repose sur une architecture **client-serveur** classique :
flowchart TB
subgraph SERVER["Server"]
API["API (REST)"]
CALC["Calcul batch-cooking<br/><i>(TODO)</i>"]
CALC["Calcul batch-cooking<br/><i>v1 implémentée (GET /cooking-session)</i>"]
IMPORT["Import d'une recette<br/><i>implémenté</i>"]
IMP1["Import depuis source<br/>(RecipeSourceAdapter)"]
IMP2["Traduction en étapes<br/>(ingrédients + techniques)"]
@ -38,8 +38,8 @@ flowchart TB
```
*(Le canal websocket envisagé dans la conception d'origine pour le calcul
batch-cooking temps réel n'existe pas encore — rien à documenter tant que ce
module reste TODO ; voir la note plus bas.)*
batch-cooking temps réel n'existe pas encore — le module v1 est un `GET`
recalculé à chaque visite, pas de temps réel ; voir la note plus bas.)*
---
@ -49,7 +49,7 @@ module reste TODO ; voir la note plus bas.)*
Point d'entrée principal pour les échanges entre les clients et le serveur — REST classique, `requireAuth` (cookie JWT httpOnly) sur toute route qui n'est pas une donnée de référence publique. Détail complet des modules : [backend-architecture.md](./backend-architecture.md).
### Module « Calcul batch-cooking »
Logique de calcul du batch-cooking (optimisation du planning/des recettes selon le planning). **Statut : TODO — reste à développer**, avec `packages/shared`'s `assertIsNever` déjà en place comme outil prêt à l'emploi pour ce futur module (voir [backend-architecture.md](./backend-architecture.md#packagesshared--assertisnever)).
Logique de calcul du batch-cooking (optimisation des recettes entre elles selon le planning). **Statut : v1 implémentée**`GET /cooking-session?date=``optimizeCookingPlan` (`apps/api/src/lib/recipe-matching/cooking-optimizer.ts`, pur) réorganise les recettes d'une semaine planifiée en **phases ordonnées** : une « mise en place » qui mutualise la découpe commune (même technique de découpe + même ingrédient dans une étape purement prep de ≥ 2 recettes = une seule tâche), puis des phases de cuisson qui interclassent les recettes en poussant les cuissons passives (mijotage, four…) en tâche de fond. v1 hors périmètre : fusion de cuissons, durées estimées, persistance/progression, canal websocket. Détail : [backend-architecture.md](./backend-architecture.md#cooking-session--optimisation-des-étapes-planifiées).
### Module « Import d'une recette »
**Statut : implémenté.** Pipeline en trois étapes, comme prévu à la conception :
@ -91,15 +91,15 @@ détectées, favoris, visibilité des recettes).
[backend-architecture.md](./backend-architecture.md#liste-de-courses--agrégation-des-ingrédients-planifiés))
— une simple **agrégation** des ingrédients déjà planifiés (somme par
ingrédient/unité, mise à l'échelle par les portions de chaque créneau),
pas une optimisation. Le module « Calcul batch-cooking » lui-même reste
`TODO` : il désigne quelque chose de plus ambitieux qu'une somme
d'ingrédients — optimiser le planning/les recettes entre elles (ex.
mutualiser une préparation entre plusieurs recettes de la semaine), pas
encore défini plus précisément. C'est le principal chantier restant côté
serveur.
pas une optimisation. Le module « Calcul batch-cooking », lui, optimise les
recettes **entre elles** (mutualiser une préparation commune, paralléliser
les cuissons passives) — **v1 implémentée** via `GET /cooking-session`
(voir [backend-architecture.md](./backend-architecture.md#cooking-session--optimisation-des-étapes-planifiées)).
- Le canal websocket envisagé pour la communication temps réel n'a pas encore
été construit — rien ne le remplace aujourd'hui (pas de polling), à
reconsidérer au moment d'attaquer le calcul batch-cooking.
été construit — rien ne le remplace aujourd'hui (pas de polling). Le calcul
batch-cooking v1 est un simple `GET` recalculé à chaque visite (comme la
liste de courses), pas de temps réel ; à reconsidérer si une session de
cuisine partagée/synchronisée est ajoutée.
---