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:
parent
6302310dba
commit
dfeb6fd1ac
11 changed files with 342 additions and 2 deletions
18
README.md
18
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
|
> (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.
|
> 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)
|
## Page de connexion / inscription (apps/web)
|
||||||
|
|
||||||
- `src/api/client.ts` — `ApiClient` (classe, instance unique exportée `apiClient`) :
|
- `src/api/client.ts` — `ApiClient` (classe, instance unique exportée `apiClient`) :
|
||||||
|
|
|
||||||
24
apps/api/features/planning.feature
Normal file
24
apps/api/features/planning.feature
Normal 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"
|
||||||
59
apps/api/features/step-definitions/planning.steps.ts
Normal file
59
apps/api/features/step-definitions/planning.steps.ts
Normal 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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -4,6 +4,7 @@ import { ErrorCode } from "@batch-cooking/shared";
|
||||||
import type { Express, Request, Response } from "express";
|
import type { Express, Request, Response } from "express";
|
||||||
import { env } from "./config/env.js";
|
import { env } from "./config/env.js";
|
||||||
import { authRouter } from "./modules/auth/auth.routes.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
|
* Builds the API's `ExpressServer`: standard middleware, routes, and the
|
||||||
|
|
@ -22,6 +23,7 @@ export function createServer(): ExpressServer {
|
||||||
});
|
});
|
||||||
|
|
||||||
server.mountRouter("/auth", authRouter);
|
server.mountRouter("/auth", authRouter);
|
||||||
|
server.mountRouter("/planning", planningRouter);
|
||||||
|
|
||||||
// No route matched — same shape as every other error response, via the
|
// No route matched — same shape as every other error response, via the
|
||||||
// shared ErrorCode contract, so clients never special-case 404s.
|
// shared ErrorCode contract, so clients never special-case 404s.
|
||||||
|
|
|
||||||
21
apps/api/src/modules/planning/planning.routes.ts
Normal file
21
apps/api/src/modules/planning/planning.routes.ts
Normal 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);
|
||||||
|
}),
|
||||||
|
);
|
||||||
60
apps/api/src/modules/planning/planning.service.ts
Normal file
60
apps/api/src/modules/planning/planning.service.ts
Normal 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,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
101
apps/api/test/planning.test.ts
Normal file
101
apps/api/test/planning.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -5,10 +5,19 @@ import type { NextFunction, Request, RequestHandler, Response } from "express";
|
||||||
* Only `ResBody`/`Locals` are made generic (what this codebase actually
|
* Only `ResBody`/`Locals` are made generic (what this codebase actually
|
||||||
* varies per-route) — params/request-body/query stay at Express's own
|
* varies per-route) — params/request-body/query stay at Express's own
|
||||||
* internal defaults, same as an unparameterized `Request`.
|
* 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<
|
export type AsyncRequestHandler<
|
||||||
ResBody = unknown,
|
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>;
|
> = (req: Request, res: Response<ResBody, Locals>, next: NextFunction) => Promise<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -26,7 +35,8 @@ export type AsyncRequestHandler<
|
||||||
*/
|
*/
|
||||||
export function wrapAsyncHandler<
|
export function wrapAsyncHandler<
|
||||||
ResBody = unknown,
|
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 {
|
>(handler: AsyncRequestHandler<ResBody, Locals>): RequestHandler {
|
||||||
return (req, res, next) => {
|
return (req, res, next) => {
|
||||||
handler(req, res as Response<ResBody, Locals>, next).catch(next);
|
handler(req, res as Response<ResBody, Locals>, next).catch(next);
|
||||||
|
|
|
||||||
|
|
@ -6,4 +6,5 @@
|
||||||
export * from "./errors/error-codes.js";
|
export * from "./errors/error-codes.js";
|
||||||
export * from "./schemas/auth.js";
|
export * from "./schemas/auth.js";
|
||||||
export * from "./tools/assert-is-never.js";
|
export * from "./tools/assert-is-never.js";
|
||||||
|
export * from "./types/planning.js";
|
||||||
export * from "./types/user-profile.js";
|
export * from "./types/user-profile.js";
|
||||||
|
|
|
||||||
28
packages/shared/src/types/planning.ts
Normal file
28
packages/shared/src/types/planning.ts
Normal 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[];
|
||||||
|
}
|
||||||
|
|
@ -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
|
middleware d'erreur d'Express — chaque route devait faire son propre
|
||||||
`try { ... } catch (err) { next(err); }`. `wrapAsyncHandler` l'automatise.
|
`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`
|
### `createErrorMiddleware` — adaptateur Express pour `packages/error-tools`
|
||||||
|
|
||||||
Voir [error-handling.md](./error-handling.md) pour le détail. `HttpError` et
|
Voir [error-handling.md](./error-handling.md) pour le détail. `HttpError` et
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue