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 { try { 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, })), }; } catch (err) { // Rethrown as-is — `wrapAsyncHandler`/the error middleware (which // already logs it, see `error-logger.ts`) is what actually handles it, // this service layer just isn't allowed a bare `await` per the repo's // async/try-catch convention. throw err; } } /** * 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) { try { const startDate = weekStart.toJSDate(); const existing = await prisma.planning.findFirst({ where: { houseId, startDate }, }); if (existing) { return existing; } return await prisma.planning.create({ data: { houseId, startDate, finishDate: weekStart.plus({ days: 6 }).toJSDate(), }, }); } catch (err) { throw err; // see getPlanningForDate()'s catch comment above } } /** * 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 { try { 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, }; } catch (err) { throw err; // see getPlanningForDate()'s catch comment above } } /** * 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 { try { 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 } }); } catch (err) { throw err; // see getPlanningForDate()'s catch comment above } }