- 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.
154 lines
6.8 KiB
Markdown
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.
|