API: GET /planning/current (home planning + sidebar, step 1/5)

- 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<string, unknown> -> Record<string,
  any>, matching Express's own Response<ResBody, LocalsObj>) — 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.
This commit is contained in:
Nicolas 2026-08-16 20:46:44 +02:00
parent 6302310dba
commit dfeb6fd1ac
11 changed files with 342 additions and 2 deletions

View file

@ -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`) :

View file

@ -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"

View file

@ -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);
},
);

View file

@ -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.

View file

@ -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<unknown, AuthLocals>(async (_req, res) => {
const planning = await getCurrentPlanning(res.locals.userProfile.houseId);
res.status(200).json(planning);
}),
);

View file

@ -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<PlanningView | null> {
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,
})),
};
}

View file

@ -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);
});
});
});

View file

@ -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<string, any>`, matching Express's own
* `Response<ResBody, LocalsObj>` exactly (see `@types/express-serve-static-
* core`) rather than the stricter `Record<string, unknown>`: 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<string, unknown> = Record<string, unknown>,
// biome-ignore lint/suspicious/noExplicitAny: mirrors Express's own Response<ResBody, LocalsObj extends Record<string, any>> constraint (see comment above) — `unknown` here would reject plain interfaces like AuthLocals that Response itself accepts fine.
Locals extends Record<string, any> = Record<string, any>,
> = (req: Request, res: Response<ResBody, Locals>, next: NextFunction) => Promise<void>;
/**
@ -26,7 +35,8 @@ export type AsyncRequestHandler<
*/
export function wrapAsyncHandler<
ResBody = unknown,
Locals extends Record<string, unknown> = Record<string, unknown>,
// biome-ignore lint/suspicious/noExplicitAny: same constraint as AsyncRequestHandler above, for the same reason.
Locals extends Record<string, any> = Record<string, any>,
>(handler: AsyncRequestHandler<ResBody, Locals>): RequestHandler {
return (req, res, next) => {
handler(req, res as Response<ResBody, Locals>, next).catch(next);

View file

@ -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";

View file

@ -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[];
}

View file

@ -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<string, any>`, pas `unknown`
Le paramètre générique `Locals` est contraint par `Record<string, any>`, à
l'identique du propre `Response<ResBody, LocalsObj>` d'Express
(`@types/express-serve-static-core`) — volontairement, pas `Record<string,
unknown>` (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<unknown, AuthLocals>(...)` 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