From dfeb6fd1ac030a0d68b735ad2fb304a6a6f945d6 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sun, 16 Aug 2026 20:46:44 +0200 Subject: [PATCH] API: GET /planning/current (home planning + sidebar, step 1/5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - packages/shared: PlanningView/PlanningItemView, exported. - apps/api: planning module (service + route), mounted at /planning. GET /planning/current returns the authenticated user's household's planning covering today, or null (no error) when there isn't one yet — the expected state until planning creation exists. - Tests: Mocha (apps/api/test/planning.test.ts) + Cucumber (features/planning.feature), same conventions as auth. - packages/express-tools: fixed AsyncRequestHandler/wrapAsyncHandler's Locals generic constraint (Record -> Record, matching Express's own Response) — the first endpoint combining requireAuth/AuthLocals with an async handler exposed that the stricter constraint rejected plain interfaces Response itself accepts fine. - Docs: README.md ("Planning" section) + specs/backend-architecture.md. First commit of the home-page-after-login feature (see plan discussed in chat) — frontend layout/routing/HomePage follow in subsequent commits on this same branch/PR. --- README.md | 18 ++++ apps/api/features/planning.feature | 24 +++++ .../step-definitions/planning.steps.ts | 59 ++++++++++ apps/api/src/app.ts | 2 + .../src/modules/planning/planning.routes.ts | 21 ++++ .../src/modules/planning/planning.service.ts | 60 +++++++++++ apps/api/test/planning.test.ts | 101 ++++++++++++++++++ packages/express-tools/src/async-handler.ts | 14 ++- packages/shared/src/index.ts | 1 + packages/shared/src/types/planning.ts | 28 +++++ specs/backend-architecture.md | 16 +++ 11 files changed, 342 insertions(+), 2 deletions(-) create mode 100644 apps/api/features/planning.feature create mode 100644 apps/api/features/step-definitions/planning.steps.ts create mode 100644 apps/api/src/modules/planning/planning.routes.ts create mode 100644 apps/api/src/modules/planning/planning.service.ts create mode 100644 apps/api/test/planning.test.ts create mode 100644 packages/shared/src/types/planning.ts diff --git a/README.md b/README.md index d5bfbc7..7eba520 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,24 @@ provisionne un vrai Postgres de service (`.github/workflows/ci.yml`) et exécute > (via le navigateur ou curl) contre le serveur de dev, un run de tests en parallèle > efface tes données de test sans prévenir. Pas un bug, juste à savoir. +## Planning (apps/api) + +- `GET /planning/current` — nécessite le cookie de session (401 sinon). Renvoie le + planning du foyer de l'utilisateur connecté qui couvre la date du jour (`Planning` + dont `start_date <= aujourd'hui <= finish_date`), items inclus avec leur recette + résolue en `{ id, name }` — ou `null` s'il n'y en a aucun (foyer sans planning en + cours, ou profil sans foyer). `null` est une réponse **valide** (200), pas une + erreur : aujourd'hui rien ne permet encore de créer un planning (le module « Calcul + batch-cooking », voir [specs/batch-cooking-architecture.md](specs/batch-cooking-architecture.md), + reste à construire), donc c'est l'état attendu tant que ce module n'existe pas. +- Type de réponse partagé : `PlanningView` (`packages/shared/src/types/planning.ts`), + consommé tel quel par `apps/web`. + +Détail de `AsyncRequestHandler`/`wrapAsyncHandler` (`packages/express-tools`) — +premier endpoint à combiner `requireAuth`/`AuthLocals` avec un handler async, ce qui +a mis au jour une contrainte générique trop stricte, corrigée à la source : +[specs/backend-architecture.md](specs/backend-architecture.md). + ## Page de connexion / inscription (apps/web) - `src/api/client.ts` — `ApiClient` (classe, instance unique exportée `apiClient`) : diff --git a/apps/api/features/planning.feature b/apps/api/features/planning.feature new file mode 100644 index 0000000..5810a91 --- /dev/null +++ b/apps/api/features/planning.feature @@ -0,0 +1,24 @@ +Feature: Household weekly planning + As a signed-in user + I want to see my household's current planning + So that I know what meals are planned this week + + Scenario: A visitor without a session cannot view the planning + When I request the current planning + Then the response status should be 401 + And the response error code should be "NOT_AUTHENTICATED" + + Scenario: A signed-in user with no planning yet sees an empty state + Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple" + And I log in with email "alice@example.com" and password "correct-horse-battery-staple" + When I request the current planning + Then the response status should be 200 + And the current planning response should be empty + + Scenario: A signed-in user sees their household's current planning + Given a profile already exists with email "alice@example.com" and password "correct-horse-battery-staple" + And I log in with email "alice@example.com" and password "correct-horse-battery-staple" + And my household has a planning covering today with recipe "Ratatouille" on "monday" for "dinner" + When I request the current planning + Then the response status should be 200 + And the current planning response should include recipe "Ratatouille" on "monday" for "dinner" diff --git a/apps/api/features/step-definitions/planning.steps.ts b/apps/api/features/step-definitions/planning.steps.ts new file mode 100644 index 0000000..67a25b1 --- /dev/null +++ b/apps/api/features/step-definitions/planning.steps.ts @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import { Given, Then, When } from "@cucumber/cucumber"; +import { prisma } from "../../src/db/prisma.js"; +import type { CustomWorld } from "../support/world.js"; + +When("I request the current planning", async function (this: CustomWorld) { + this.response = await this.agent.get("/planning/current"); +}); + +Then("the current planning response should be empty", function (this: CustomWorld) { + assert.equal(this.response.body, null); +}); + +// Creates the planning/recipe rows directly via Prisma rather than through +// the API — there's no "create a planning" endpoint yet (see +// specs/batch-cooking-architecture.md, "Calcul batch-cooking" is still +// TODO), so this is the only way to get a household into a state where it +// has one. Reads the household off the already-authenticated agent (via +// `GET /auth/me`) rather than taking it as a step argument, since the +// scenario never names it explicitly. +Given( + "my household has a planning covering today with recipe {string} on {string} for {string}", + async function (this: CustomWorld, recipeName: string, weekDay: string, meal: string) { + const me = await this.agent.get("/auth/me"); + const houseId: number = me.body.houseId; + + const recipe = await prisma.recipe.create({ data: { name: recipeName } }); + const today = new Date(); + const planning = await prisma.planning.create({ + data: { + houseId, + startDate: new Date( + Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() - 2), + ), + finishDate: new Date( + Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() + 2), + ), + }, + }); + await prisma.planningItem.create({ + data: { planningId: planning.id, weekDay, meal, recipeId: recipe.id }, + }); + }, +); + +Then( + "the current planning response should include recipe {string} on {string} for {string}", + function (this: CustomWorld, recipeName: string, weekDay: string, meal: string) { + const items = this.response.body.items as Array<{ + weekDay: string; + meal: string; + recipe: { name: string }; + }>; + const item = items.find((i) => i.recipe.name === recipeName); + assert.ok(item, `expected an item with recipe "${recipeName}", got ${JSON.stringify(items)}`); + assert.equal(item.weekDay, weekDay); + assert.equal(item.meal, meal); + }, +); diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 8e5c08a..63baf0f 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -4,6 +4,7 @@ import { ErrorCode } from "@batch-cooking/shared"; import type { Express, Request, Response } from "express"; import { env } from "./config/env.js"; import { authRouter } from "./modules/auth/auth.routes.js"; +import { planningRouter } from "./modules/planning/planning.routes.js"; /** * Builds the API's `ExpressServer`: standard middleware, routes, and the @@ -22,6 +23,7 @@ export function createServer(): ExpressServer { }); server.mountRouter("/auth", authRouter); + server.mountRouter("/planning", planningRouter); // No route matched — same shape as every other error response, via the // shared ErrorCode contract, so clients never special-case 404s. diff --git a/apps/api/src/modules/planning/planning.routes.ts b/apps/api/src/modules/planning/planning.routes.ts new file mode 100644 index 0000000..278cf2e --- /dev/null +++ b/apps/api/src/modules/planning/planning.routes.ts @@ -0,0 +1,21 @@ +import { wrapAsyncHandler } from "@batch-cooking/express-tools"; +import { Router } from "express"; +import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js"; +import { getCurrentPlanning } from "./planning.service.js"; + +/** Router mounted at `/planning` in app.ts. */ +export const planningRouter = Router(); + +/** + * Returns the authenticated user's household's planning for today, or + * `null` if none exists yet — a valid, common response, not an error (see + * {@link getCurrentPlanning}). + */ +planningRouter.get( + "/current", + requireAuth, + wrapAsyncHandler(async (_req, res) => { + const planning = await getCurrentPlanning(res.locals.userProfile.houseId); + res.status(200).json(planning); + }), +); diff --git a/apps/api/src/modules/planning/planning.service.ts b/apps/api/src/modules/planning/planning.service.ts new file mode 100644 index 0000000..67eda25 --- /dev/null +++ b/apps/api/src/modules/planning/planning.service.ts @@ -0,0 +1,60 @@ +import type { PlanningView } from "@batch-cooking/shared"; +import { prisma } from "../../db/prisma.js"; + +/** + * Finds the household's planning that covers today's date and shapes it + * into a {@link PlanningView} (recipes resolved to `{id, name}`). + * + * Returns `null` for two distinct, both entirely normal states — a `house_id` + * of `null` (a profile always gets a house at signup today, but the column + * is nullable) and "no planning row covers today" (the expected case until + * planning creation is built) — neither is an error, so both collapse to + * the same "nothing to show yet" result rather than throwing. + */ +export async function getCurrentPlanning(houseId: number | null): Promise { + if (houseId === null) { + return null; + } + + // `startDate`/`finishDate` are `@db.Date` columns (no time-of-day + // component) — compare against today's date at UTC midnight so the + // comparison lines up with how Postgres stores/returns them, regardless + // of the server's local timezone. + const today = new Date(); + const todayDateOnly = new Date( + Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()), + ); + + const planning = await prisma.planning.findFirst({ + where: { + houseId, + startDate: { lte: todayDateOnly }, + finishDate: { gte: todayDateOnly }, + }, + // 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, + recipe: item.recipe, + })), + }; +} diff --git a/apps/api/test/planning.test.ts b/apps/api/test/planning.test.ts new file mode 100644 index 0000000..f83395e --- /dev/null +++ b/apps/api/test/planning.test.ts @@ -0,0 +1,101 @@ +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 { 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 }), + }; +} + +describe("Planning", () => { + const app = createApp(); + + beforeEach(async () => { + await resetDatabase(); + }); + + after(async () => { + await prisma.$disconnect(); + }); + + describe("GET /planning/current", () => { + it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => { + const res = await request(app).get("/planning/current"); + + expect(res.status).to.equal(401); + expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED); + }); + + it("returns null when the household has no planning covering today", async () => { + const agent = request.agent(app); + await agent.post("/auth/signup").send(buildSignupPayload()); + + const res = await agent.get("/planning/current"); + + expect(res.status).to.equal(200); + expect(res.body).to.equal(null); + }); + + it("returns the household's planning covering today, with recipes resolved", async () => { + const agent = request.agent(app); + const signupRes = await agent.post("/auth/signup").send(buildSignupPayload()); + const houseId: number = signupRes.body.houseId; + + const recipe = await prisma.recipe.create({ data: { name: "Ratatouille" } }); + const today = new Date(); + const planning = await prisma.planning.create({ + data: { + houseId, + startDate: new Date( + Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() - 2), + ), + finishDate: new Date( + Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() + 2), + ), + }, + }); + await prisma.planningItem.create({ + data: { planningId: planning.id, weekDay: "monday", meal: "dinner", recipeId: recipe.id }, + }); + + const res = await agent.get("/planning/current"); + + expect(res.status).to.equal(200); + expect(res.body.id).to.equal(planning.id); + expect(res.body.items).to.have.length(1); + expect(res.body.items[0]).to.include({ weekDay: "monday", meal: "dinner" }); + expect(res.body.items[0].recipe).to.include({ id: recipe.id, name: "Ratatouille" }); + }); + + it("returns null when the household's planning does not cover today", async () => { + const agent = request.agent(app); + const signupRes = await agent.post("/auth/signup").send(buildSignupPayload()); + const houseId: number = signupRes.body.houseId; + + // A planning entirely in the past — shouldn't be picked up as "current". + await prisma.planning.create({ + data: { + houseId, + startDate: new Date(Date.UTC(2000, 0, 1)), + finishDate: new Date(Date.UTC(2000, 0, 7)), + }, + }); + + const res = await agent.get("/planning/current"); + + expect(res.status).to.equal(200); + expect(res.body).to.equal(null); + }); + }); +}); diff --git a/packages/express-tools/src/async-handler.ts b/packages/express-tools/src/async-handler.ts index 1dcd692..0406e9e 100644 --- a/packages/express-tools/src/async-handler.ts +++ b/packages/express-tools/src/async-handler.ts @@ -5,10 +5,19 @@ import type { NextFunction, Request, RequestHandler, Response } from "express"; * Only `ResBody`/`Locals` are made generic (what this codebase actually * varies per-route) — params/request-body/query stay at Express's own * internal defaults, same as an unparameterized `Request`. + * + * `Locals` is constrained to `Record`, matching Express's own + * `Response` exactly (see `@types/express-serve-static- + * core`) rather than the stricter `Record`: a plain + * `interface` (e.g. `AuthLocals` in `require-auth.ts`) has no index + * signature, so under `unknown` it fails this generic's constraint even + * though it's assignable to `Response`'s own `Locals` param directly — + * `any` is what lets that structural gap close. */ export type AsyncRequestHandler< ResBody = unknown, - Locals extends Record = Record, + // biome-ignore lint/suspicious/noExplicitAny: mirrors Express's own Response> constraint (see comment above) — `unknown` here would reject plain interfaces like AuthLocals that Response itself accepts fine. + Locals extends Record = Record, > = (req: Request, res: Response, next: NextFunction) => Promise; /** @@ -26,7 +35,8 @@ export type AsyncRequestHandler< */ export function wrapAsyncHandler< ResBody = unknown, - Locals extends Record = Record, + // biome-ignore lint/suspicious/noExplicitAny: same constraint as AsyncRequestHandler above, for the same reason. + Locals extends Record = Record, >(handler: AsyncRequestHandler): RequestHandler { return (req, res, next) => { handler(req, res as Response, next).catch(next); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 98ed583..f0dc809 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -6,4 +6,5 @@ export * from "./errors/error-codes.js"; export * from "./schemas/auth.js"; export * from "./tools/assert-is-never.js"; +export * from "./types/planning.js"; export * from "./types/user-profile.js"; diff --git a/packages/shared/src/types/planning.ts b/packages/shared/src/types/planning.ts new file mode 100644 index 0000000..74a7c99 --- /dev/null +++ b/packages/shared/src/types/planning.ts @@ -0,0 +1,28 @@ +/** + * A single meal slot within a household's planning, with its recipe + * resolved to just enough info for display (id + name) — a caller needing + * more than that fetches the recipe itself separately. + */ +export interface PlanningItemView { + id: number; + /** Day of the week this item falls on (free-form for now — no enum exists yet, see schema.prisma). */ + weekDay: string; + /** Which meal of the day this item is for (free-form for now, same reason). */ + meal: string; + recipe: { + id: number; + name: string; + }; +} + +/** + * A household's planning for a date range, as returned by the API. Dates + * are ISO 8601 strings (JSON has no native date type) — parse with + * `new Date(...)` client-side if arithmetic is needed. + */ +export interface PlanningView { + id: number; + startDate: string; + finishDate: string; + items: PlanningItemView[]; +} diff --git a/specs/backend-architecture.md b/specs/backend-architecture.md index a0f39be..89ce0da 100644 --- a/specs/backend-architecture.md +++ b/specs/backend-architecture.md @@ -54,6 +54,22 @@ Sans ça, une exception dans un handler `async` ne remonte jamais tout seule au middleware d'erreur d'Express — chaque route devait faire son propre `try { ... } catch (err) { next(err); }`. `wrapAsyncHandler` l'automatise. +### `AsyncRequestHandler`/`wrapAsyncHandler` — `Locals` contraint par `Record`, pas `unknown` + +Le paramètre générique `Locals` est contraint par `Record`, à +l'identique du propre `Response` d'Express +(`@types/express-serve-static-core`) — volontairement, pas `Record` (plus strict, ce qui serait la contrainte "par défaut" attendue). +Raison concrète : une `interface` sans signature d'index (ex. `AuthLocals` +dans `require-auth.ts`) échoue la contrainte générique sous `unknown` alors +qu'elle s'assigne très bien à `Response`'s own `Locals` param directement — +observé en committant `wrapAsyncHandler(...)` sur +`GET /planning/current` (premier endpoint à combiner authentification et +handler async). `any` referme cet écart structurel ; les deux occurrences +portent un commentaire `biome-ignore lint/suspicious/noExplicitAny` expliquant +pourquoi (le lint interdit `any` par défaut, à raison, mais ce cas précis +imite un type de la lib standard Express qui fait le même choix). + ### `createErrorMiddleware` — adaptateur Express pour `packages/error-tools` Voir [error-handling.md](./error-handling.md) pour le détail. `HttpError` et