batchCooking/apps/api/src/modules/planning/planning.routes.ts
Nicolas 1c62625c47
All checks were successful
CI / lint (push) Successful in 52s
CI / build (push) Successful in 1m30s
CI / e2e (push) Successful in 5m38s
CI / intent-service-test (push) Successful in 3m32s
CI / test (push) Successful in 14m17s
feat(planning): attribution et jet des restes + exclusion liste de courses / cuisine
PR 2/4 du chantier « gestion des restes » (issue #34).

Deux nouvelles routes sur `/planning` :
- `POST /planning/items/leftover` → `assignLeftoverPlanningItem`, 201.
  Crée un item « restes » (`sourcePlanningItemId` non nul) sur un créneau,
  même recette que la source (`recipeId` copié, jamais envoyé par le
  client). `portions` revérifié serveur contre le pool réellement
  disponible via `computeAvailableLeftover` → 409
  `LEFTOVER_QUANTITY_EXCEEDED` (nouveau code, 4024) si dépassé ; 404
  `PLANNING_ITEM_NOT_FOUND` si source inconnue / d'un autre foyer / elle-
  même un item « restes » (pas de restes-sur-restes).
- `PATCH /planning/items/:id/discard-leftover` → `discardLeftover`, 200.
  Jette tout le pool restant (`discardedPortions += disponible`) ;
  idempotent (rien à jeter → renvoyé inchangé, aucun évènement).

Exclusion : `shopping-list.service.ts` et `cooking-session.service.ts`
filtrent `items: { where: { sourcePlanningItemId: null } }` dans leur
`include` — un créneau qui ne fait que resservir un plat déjà cuisiné n'a
rien à acheter ni à cuisiner. `getPlanningForDate` ne filtre pas (la
grille affiche ces items).

Analytics : `planning.leftover_assigned` / `planning.leftover_discarded`.

Le mapping `PlanningItem` → `PlanningItemView` est factorisé
(`toPlanningItemView`) — 4 chemins le partagent désormais.

Tests (Mocha, DB) : `assignLeftoverPlanningItem` (succès + `recipeId`
copié, 409 sur dépassement, 404 source étrangère/inconnue/« restes »,
cascade à la suppression de la source) ; `discardLeftover` (jette le
disponible, idempotent, ne jette pas ce qui est déjà servi, 404) ;
non-régression liste de courses et session de cuisine ignorant les items
« restes ».

Réf #34

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 23:53:25 +02:00

130 lines
4.2 KiB
TypeScript

import { parseDateOnly } from "@batch-cooking/date-tools";
import { HttpError } from "@batch-cooking/error-tools";
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import {
addPlanningItemSchema,
assignLeftoverSchema,
ErrorCode,
getPlanningByDateSchema,
} from "@batch-cooking/shared";
import { Router } from "express";
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
import {
addPlanningItem,
assignLeftoverPlanningItem,
discardLeftover,
getPlanningForDate,
removePlanningItem,
} from "./planning.service.js";
/** Router mounted at `/planning` in app.ts. */
export const planningRouter = Router();
/**
* Returns the authenticated user's household's planning covering `?date=`
* (`YYYY-MM-DD`), or `null` if none exists yet — a valid, common response,
* not an error (see {@link getPlanningForDate}). Used both for "today"
* (the planning page's initial load) and for any other week the planning
* page's week navigator/calendar picks.
*/
planningRouter.get(
"/",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const input = getPlanningByDateSchema.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 planning = await getPlanningForDate(res.locals.userProfile.houseId, date);
res.status(200).json(planning);
}),
);
/** Parses and validates the `:id` route param shared by every `/items/:id` route below. */
function parsePlanningItemId(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;
}
/**
* Adds a recipe to one (day, meal) slot of the authenticated user's
* household's planning, creating that week's `Planning` row on the fly if
* needed (see {@link addPlanningItem}).
*/
planningRouter.post(
"/items",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const input = addPlanningItemSchema.parse(req.body);
const date = parseDateOnly(input.date);
if (date === null) {
throw new HttpError(
400,
ErrorCode.VALIDATION_ERROR,
`Not a real calendar date: ${input.date}`,
);
}
const { id: viewerId, houseId } = res.locals.userProfile;
const item = await addPlanningItem(houseId, viewerId, houseId, date, input);
res.status(201).json(item);
}),
);
/**
* Serves part of another planning item's leftover pool on a slot — creates
* a "leftovers" `PlanningItem` (see {@link assignLeftoverPlanningItem}).
* `POST /items/leftover`, not `/items/:id/...`: the `:id` here would be the
* *target* slot, which doesn't exist yet — the source is in the body.
*/
planningRouter.post(
"/items/leftover",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const input = assignLeftoverSchema.parse(req.body);
const date = parseDateOnly(input.date);
if (date === null) {
throw new HttpError(
400,
ErrorCode.VALIDATION_ERROR,
`Not a real calendar date: ${input.date}`,
);
}
const { id: viewerId, houseId } = res.locals.userProfile;
const item = await assignLeftoverPlanningItem(houseId, viewerId, date, input);
res.status(201).json(item);
}),
);
/** Throws away item `:id`'s whole remaining leftover pool (idempotent — see {@link discardLeftover}). */
planningRouter.patch(
"/items/:id/discard-leftover",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const id = parsePlanningItemId(req.params.id);
const { id: viewerId, houseId } = res.locals.userProfile;
const item = await discardLeftover(id, viewerId, houseId);
res.status(200).json(item);
}),
);
/** Removes one recipe from a planning slot. */
planningRouter.delete(
"/items/:id",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const id = parsePlanningItemId(req.params.id);
await removePlanningItem(id, res.locals.userProfile.houseId);
res.status(204).end();
}),
);