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>
167 lines
6.5 KiB
TypeScript
167 lines
6.5 KiB
TypeScript
import { type DateTime, getWeekStart, toDateOnly } from "@batch-cooking/date-tools";
|
|
import { HttpError } from "@batch-cooking/error-tools";
|
|
import {
|
|
type AddPlanningItemInput,
|
|
ErrorCode,
|
|
type PlanningItemView,
|
|
type PlanningView,
|
|
} from "@batch-cooking/shared";
|
|
import { prisma } from "../../db/prisma.js";
|
|
import { assertRecipeVisible } from "../recipe/recipe.service.js";
|
|
|
|
/**
|
|
* Finds the household's planning that covers `date` and shapes it into a
|
|
* {@link PlanningView} (recipes resolved to `{id, name}`). `date` is
|
|
* whatever the caller wants "now" to mean — the current `/planning` route
|
|
* passes a `date-tools`-parsed `?date=` query param, letting a caller look
|
|
* up any week's planning, not just the one covering today.
|
|
*
|
|
* Returns `null` for two distinct, both entirely normal states — a
|
|
* `houseId` of `null` (the profile has no household yet — households are no
|
|
* longer created automatically at signup, see `auth.service.ts`) and "no
|
|
* planning row covers this date yet" (e.g. a week nobody has added a recipe
|
|
* to via {@link addPlanningItem}) — neither is an error, so both collapse
|
|
* to the same "nothing to show yet" result rather than throwing.
|
|
*/
|
|
export async function getPlanningForDate(
|
|
houseId: number | null,
|
|
date: DateTime,
|
|
): Promise<PlanningView | null> {
|
|
if (houseId === null) {
|
|
return null;
|
|
}
|
|
|
|
// `startDate`/`finishDate` are `@db.Date` columns (no time-of-day
|
|
// component) — comparing against a UTC-midnight JS `Date` lines up with
|
|
// how Postgres stores/returns them, regardless of the server's local
|
|
// timezone.
|
|
const dateOnly = toDateOnly(date).toJSDate();
|
|
|
|
const planning = await prisma.planning.findFirst({
|
|
where: {
|
|
houseId,
|
|
startDate: { lte: dateOnly },
|
|
finishDate: { gte: dateOnly },
|
|
},
|
|
// A household should never have two plannings covering the same day,
|
|
// but nothing in the schema enforces that yet — pick the most recently
|
|
// started one rather than letting the query fail if it ever happens.
|
|
orderBy: { startDate: "desc" },
|
|
include: {
|
|
items: {
|
|
include: { recipe: { select: { id: true, name: true } } },
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!planning) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
id: planning.id,
|
|
startDate: planning.startDate.toISOString(),
|
|
finishDate: planning.finishDate.toISOString(),
|
|
items: planning.items.map((item) => ({
|
|
id: item.id,
|
|
weekDay: item.weekDay,
|
|
meal: item.meal,
|
|
portions: item.portions,
|
|
recipe: item.recipe,
|
|
})),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Finds the household's `Planning` covering the week `weekStart` (a Monday)
|
|
* starts, creating it on the fly (spanning the full Monday-to-Sunday week)
|
|
* if none exists yet. Matched by exact `startDate`, not a covering range
|
|
* like {@link getPlanningForDate} — that query is for "what covers today",
|
|
* this one is for "the row `addPlanningItem` should attach to", so it needs
|
|
* to land on the same row a second add to the same week would reuse rather
|
|
* than risk matching some other overlapping planning.
|
|
*
|
|
* Not wrapped in a transaction with the `findFirst` — no unique constraint
|
|
* exists on `(houseId, startDate)` (see {@link getPlanningForDate}'s doc
|
|
* comment on the same gap), so two concurrent first-adds to an empty week
|
|
* could each create their own `Planning` row. Accepted at this project's
|
|
* scale rather than adding a migration + retry-on-conflict loop for it.
|
|
*/
|
|
async function findOrCreatePlanningForWeek(houseId: number, weekStart: DateTime) {
|
|
const startDate = weekStart.toJSDate();
|
|
const existing = await prisma.planning.findFirst({ where: { houseId, startDate } });
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
return prisma.planning.create({
|
|
data: { houseId, startDate, finishDate: weekStart.plus({ days: 6 }).toJSDate() },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Adds a recipe to one (day, meal) slot of `houseId`'s planning for the
|
|
* week containing `date`, creating that week's `Planning` row first if it
|
|
* doesn't exist yet (see {@link findOrCreatePlanningForWeek}) — this is the
|
|
* only place a `Planning` row gets created at all today, there's no
|
|
* separate "create an empty planning" action. `date` is a caller-parsed
|
|
* `DateTime` (see `planning.routes.ts`, which validates `input.date` the
|
|
* same way `GET /planning` validates its own `?date=`, before calling
|
|
* here) rather than the raw `input.date` string — only its week matters,
|
|
* not the exact day.
|
|
*
|
|
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if `houseId` is `null` (the caller has no household).
|
|
* @throws {HttpError} `404 RECIPE_NOT_FOUND` if `input.recipeId` doesn't match a recipe visible to `viewerId` — see `recipe.service.ts`'s `assertRecipeVisible`.
|
|
*/
|
|
export async function addPlanningItem(
|
|
houseId: number | null,
|
|
viewerId: number,
|
|
viewerHouseId: number | null,
|
|
date: DateTime,
|
|
input: AddPlanningItemInput,
|
|
): Promise<PlanningItemView> {
|
|
if (houseId === null) {
|
|
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
|
}
|
|
await assertRecipeVisible(input.recipeId, viewerId, viewerHouseId);
|
|
|
|
const weekStart = getWeekStart(toDateOnly(date));
|
|
const planning = await findOrCreatePlanningForWeek(houseId, weekStart);
|
|
|
|
const item = await prisma.planningItem.create({
|
|
data: {
|
|
planningId: planning.id,
|
|
weekDay: input.weekDay,
|
|
meal: input.meal,
|
|
recipeId: input.recipeId,
|
|
portions: input.portions,
|
|
},
|
|
include: { recipe: { select: { id: true, name: true } } },
|
|
});
|
|
|
|
return {
|
|
id: item.id,
|
|
weekDay: item.weekDay,
|
|
meal: item.meal,
|
|
portions: item.portions,
|
|
recipe: item.recipe,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Removes one planning item outright. The parent `Planning` row is left in
|
|
* place even if this was its last item — an empty planning is a normal,
|
|
* already-handled state on the read side (`getPlanningForDate`'s grid just
|
|
* shows every slot's "+"), not worth a cleanup pass here.
|
|
*
|
|
* @throws {HttpError} `404 PLANNING_ITEM_NOT_FOUND` if `id` doesn't match any planning item, or does but belongs to a planning outside `houseId` — never `403`, same "don't confirm what exists" reasoning as `RECIPE_NOT_FOUND` elsewhere.
|
|
*/
|
|
export async function removePlanningItem(id: number, houseId: number | null): Promise<void> {
|
|
const item = await prisma.planningItem.findUnique({
|
|
where: { id },
|
|
include: { planning: true },
|
|
});
|
|
if (!item || houseId === null || item.planning.houseId !== houseId) {
|
|
throw new HttpError(404, ErrorCode.PLANNING_ITEM_NOT_FOUND, `Planning item ${id} not found`);
|
|
}
|
|
await prisma.planningItem.delete({ where: { id } });
|
|
}
|