batchCooking/specs/backend-architecture.md
Nicolas dfeb6fd1ac 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.
2026-08-16 20:46:44 +02:00

154 lines
6.8 KiB
Markdown

# Architecture backend — Projet Batch-cooking
> Documentation de l'organisation d'`apps/api` et de l'outillage partagé
> (`packages/express-tools`, `packages/error-tools`, `packages/shared`).
---
## `packages/express-tools` — outillage Express générique
Package séparé, réutilisable par n'importe quel service Express du monorepo (pas
seulement `apps/api`) : pas de logique métier, juste de l'infra Express.
### `ExpressServer` — init serveur, routes, middlewares
Enveloppe une application Express derrière une API typée, au lieu que chaque
service refasse le même `express()` à la main :
```ts
const server = new ExpressServer();
server.setupCore({ corsOrigin: env.CORS_ORIGIN }); // cors + json + cookie-parser
server.addRoute("get", "/health", (_req, res) => res.status(200).json({ status: "ok" }));
server.mountRouter("/auth", authRouter);
server.addMiddleware(notFoundHandler);
server.setErrorHandler(createErrorMiddleware(errorHandlerService));
server.listen(port, () => console.log(`Listening on ${port}`));
```
- `setupCore(options)` — middleware stack commun (CORS avec credentials, JSON,
cookies).
- `addRoute(method, path, ...handlers)` — enregistre une route ; avertit et
ignore au lieu d'écraser silencieusement si la même route (méthode + chemin)
est déjà enregistrée.
- `addMiddleware` / `mountRouter` / `setErrorHandler` — ajout de middleware
générique, montage d'un `Router` complet, middleware d'erreur final (4
arguments — doit être ajouté en dernier).
- `.instance` — l'app Express brute, nécessaire pour les outils de test
(supertest) qui attendent une instance `Express`, pas le wrapper.
- `.listen(port, onListening?)` — démarre le serveur.
`apps/api/src/app.ts` expose deux fonctions : `createServer(): ExpressServer`
(utilisée par `server.ts`, qui appelle `.listen()`) et `createApp(): Express`
(= `createServer().instance`, utilisée par les tests).
### `wrapAsyncHandler` — plus de try/catch répété dans les routes
```ts
router.post("/signup", wrapAsyncHandler(async (req, res) => {
const profile = await signup(req.body); // une erreur/rejet ici va automatiquement à next()
res.status(201).json(profile);
}));
```
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
`ErrorHandlerService` vivent dans **`packages/error-tools`**, pas ici :
`ErrorHandlerService` **n'a aucune dépendance à Express** — c'est un service
générique `erreur → { status, body }` qui fonctionnerait à l'identique derrière
Fastify ou n'importe quel autre framework, donc il n'a rien à faire dans un
package *express*-tools. `ExpressServer` et `createErrorMiddleware` (ici) sont
la vraie couche Express : elles adaptent des pièces indépendantes du framework
(`ErrorHandlerService`, importé depuis `@batch-cooking/error-tools`) à l'API
d'Express.
---
## Auth : `res.locals`, pas d'augmentation du namespace Express
`requireAuth` (`apps/api/src/middlewares/require-auth.ts`) attache le profil
authentifié à **`res.locals.userProfile`**, typé via l'interface `AuthLocals` :
```ts
export interface AuthLocals {
userProfile: SafeUserProfile;
}
export async function requireAuth(req: Request, res: Response<unknown, AuthLocals>, next: NextFunction) {
// ...
res.locals.userProfile = safeProfile;
next();
}
```
Un handler derrière ce middleware type sa réponse `Response<unknown, AuthLocals>`
et lit `res.locals.userProfile` sans cast :
```ts
authRouter.get("/me", requireAuth, (_req, res: Response<unknown, AuthLocals>) => {
res.status(200).json(res.locals.userProfile);
});
```
**Pourquoi pas `declare global { namespace Express { interface Request {...} } }`**
(l'approche initialement utilisée, retirée depuis) : `res.locals` est le
mécanisme natif d'Express prévu exactement pour ça (faire passer des données
d'un middleware au handler suivant), typé par route via un paramètre
générique — pas une augmentation globale et permanente qui change
silencieusement le type de **toutes** les `Request` du projet, qu'elles soient
passées par ce middleware ou non.
---
## `packages/shared` — `assertIsNever`
`packages/shared/src/tools/assert-is-never.ts` — vérification d'exhaustivité
pour un `switch`/`if`-chain sur une union :
```ts
switch (shape.kind) {
case "circle": return Math.PI * shape.radius ** 2;
case "square": return shape.side ** 2;
default: return assertIsNever(shape); // erreur de compilation si un cas manque
}
```
Si un membre de l'union n'est pas traité par une branche précédente, `shape`
n'est plus de type `never` au niveau du `default`**erreur de compilation**
(vérifié : `tsc` rejette bien un cas manquant). Lève aussi une vraie erreur au
runtime, en filet de sécurité si une valeur invalide échappe au système de
types (ex. donnée externe non validée).
Pas encore de point d'usage réel dans le code métier actuel (aucun
switch/if-chain exhaustif sur une union n'existe encore) — prêt à l'emploi dès
qu'un cas s'y prête (le module « Calcul batch-cooking » ou le pipeline d'import
de recette, tous deux encore à construire, en auront probablement).
---
## Pas de fichiers `.d.ts` écrits à la main
Voir [frontend-architecture.md](./frontend-architecture.md#note-sur-les-fichiers-dts)
pour le détail côté `apps/web`. Côté `apps/api` : aucune augmentation de type
globale (`declare global`) n'est utilisée — voir la section `res.locals`
ci-dessus, qui est précisément ce qui aurait nécessité ce genre de fichier.