batchCooking/specs/error-handling.md
Nicolas 3a5dc83bf9 Address second review round: interface comments, res.locals, ExpressServer, assertIsNever
Five more explicit review points, on the same PR branch.

## Every interface key commented

Audited all 6 interfaces in the codebase. Two had partially-commented
members (violates the "every key gets /** */" rule): AuthResult
(apps/api/auth.service.ts) and SafeUserProfile (packages/shared) — both
now fully commented. The other four (AuthTokenPayload,
AuthContextValue, ErrorHandlingResult, ApiErrorResponse) were already
compliant.

## Removed the Express namespace augmentation

apps/api/src/types/express.d.ts (renamed to express-request.augment.ts
in the last round) is gone entirely. requireAuth now attaches the
authenticated profile to `res.locals.userProfile` — Express's own
built-in per-request mechanism for exactly this — typed via a new
AuthLocals interface and `Response<unknown, AuthLocals>`, instead of a
project-wide `declare global` silently changing every Request's type
whether or not it went through the middleware.

## ErrorHandlerService confirmed framework-agnostic

It already had zero Express import. Documented this explicitly (in the
package's index.ts and the new backend-architecture.md spec) as a
deliberate split: ErrorHandlerService is framework-agnostic (would work
behind Fastify too), ExpressServer/createErrorMiddleware are the actual
Express integration layer.

## packages/express-tools: server init + route/middleware utilities

New ExpressServer class, modeled on the pattern shared as a reference
(adapted, not copied 1:1 — deliberately left out the reference's custom
runtime param-type-validation system, since zod already does that job
in this codebase and running two parallel validation mechanisms would
be redundant, not "propre"):
- setupCore() — the common cors/json/cookie-parser stack
- addRoute() — registers a route, warns+skips instead of silently
  double-registering the same method+path
- addMiddleware() / mountRouter() / setErrorHandler()
- listen()
- .instance — the raw Express app, for supertest

Also added wrapAsyncHandler() — forwards a thrown/rejected error from an
async handler to next(err) automatically, removing the manual
try/catch/next(err) every route needed.

apps/api/src/app.ts now builds via ExpressServer (createServer(),
consumed by both server.ts's .listen() and createApp()'s .instance for
tests). auth.routes.ts's signup/login handlers use wrapAsyncHandler
instead of manual try/catch. cookie-parser/cors moved out of apps/api's
own dependencies entirely — they're express-tools' concern now.

## assertIsNever (packages/shared/src/tools/)

Exhaustiveness-check helper for switch/if-chains over a union: takes a
`never`-typed value and throws, so a forgotten case in a later-added
union member becomes a compile error instead of a silent runtime
fallthrough. Verified for real (not just written and assumed correct):
wrote a throwaway switch missing a case and confirmed `tsc` rejects it
with the exact expected error, then deleted the scratch file. No
existing switch/if-chain over a union in the codebase yet to retrofit
it into — noted as ready for when one appears (e.g. the not-yet-built
batch-cooking calculation module or recipe-import pipeline).

## specs/ updated

New specs/backend-architecture.md — ExpressServer, wrapAsyncHandler,
the res.locals decision (with the "why not declare global" reasoning
spelled out), assertIsNever. error-handling.md and
frontend-architecture.md cross-link to it instead of duplicating.
README covers the same, briefly.

## Verification

Full lint/mocha/cucumber/build green. Re-ran `node dist/server.js`
standalone (mirrors Docker, no tsx) after the ExpressServer refactor:
/health, a 404 (numeric 4040), and a real signup + GET /me round trip
confirming res.locals-based auth actually works at runtime, not just
that tsc accepts the types.
2026-08-16 17:00:23 +02:00

158 lines
7.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Gestion des erreurs — Projet Batch-cooking
> Documentation du contrat d'erreurs partagé entre `apps/api` et `apps/web`.
---
## Vue d'ensemble
Quatre pièces travaillent ensemble pour que **toute** erreur, du serveur jusqu'à
l'affichage utilisateur, passe par un chemin unique et prévisible :
- **`packages/shared`** — le contrat : `ErrorCode` (énumération **numérique** de
tous les codes d'erreur métier) et `ApiErrorResponse` (forme JSON de toute
réponse d'erreur de l'API). Ni l'API ni le web ne définissent leur propre liste
de codes, et aucune valeur n'est jamais codée en dur ailleurs (toujours
`ErrorCode.XXX`, jamais un nombre/une chaîne littérale).
- **`packages/express-tools`** — package séparé pour l'outillage Express générique
(réutilisable par n'importe quel service Express du monorepo, pas seulement
`apps/api`) : `HttpError`, `ErrorHandlerService`, `createErrorMiddleware`.
- **`apps/api`** — consomme `express-tools` : lève des `HttpError`, le middleware
d'erreur final n'est qu'un appel à `createErrorMiddleware(errorHandlerService)`.
- **`apps/web` → `ErrorMessageService`** — associe chaque `ErrorCode` à une clé de
traduction, résolue via **i18next** (fichiers de locale sous `src/locales/`).
Les composants n'écrivent jamais de texte d'erreur en dur.
```mermaid
flowchart LR
subgraph TOOLS["packages/express-tools"]
HTTPERR["HttpError"]
EHS["ErrorHandlerService.handle()"]
MW["createErrorMiddleware()"]
end
subgraph API["apps/api"]
THROW["Route / service<br/>throw new HttpError(status, code, message)"]
THROW --> EHS
MW -->|"app.use(...)"| EHS
end
EHS -->|"JSON: { code, message, details? }"| HTTP["Réponse HTTP"]
subgraph WEB["apps/web"]
CLIENT["ApiClient<br/>lève ApiError(status, code, ...)"]
EMS["ErrorMessageService.getLabel(code)"]
I18N["i18next<br/>locales/fr/translation.json"]
UI["Composant (LoginPage, SignupPage...)"]
CLIENT --> EMS --> I18N --> UI
end
HTTP --> CLIENT
SHARED[("packages/shared<br/>ErrorCode (numérique), ApiErrorResponse")]
SHARED -. contrat .-> THROW
SHARED -. contrat .-> CLIENT
SHARED -. contrat .-> EMS
style SHARED fill:none,stroke:#888,stroke-width:1px
style TOOLS fill:none,stroke:#888,stroke-width:1px
```
---
## Le contrat (`packages/shared/src/errors/error-codes.ts`)
```ts
enum ErrorCode {
VALIDATION_ERROR = 4000,
EMAIL_ALREADY_IN_USE = 4001,
INVALID_CREDENTIALS = 4010,
NOT_AUTHENTICATED = 4011,
NOT_FOUND = 4040,
INTERNAL_ERROR = 5000,
}
interface ApiErrorResponse {
code: ErrorCode;
message: string; // anglais, dev-facing — jamais affiché tel quel côté UI
details?: Record<string, string[] | undefined>; // uniquement pour VALIDATION_ERROR
}
```
**Codes numériques, groupés par famille** (comme les codes HTTP) : `4000``4099`
validation, `4010``4019` authentification, `4040``4049` ressource introuvable,
`5000``5099` interne. Le numéro donne une indication de la catégorie même sans
regarder l'enum.
**Règle** : `message` est destiné aux logs/au débogage (toujours en anglais, jamais
localisé). Le texte affiché à l'utilisateur vient **toujours** de
`ErrorMessageService.getLabel(code)` côté client, jamais de `message` directement.
Et **aucune valeur `ErrorCode` n'est jamais écrite en dur** (ni en nombre, ni en
chaîne) — toujours une référence `ErrorCode.XXX`, y compris dans les tests/mocks.
Pour ajouter un nouveau cas d'erreur :
1. Ajouter le membre dans `ErrorCode`, dans la bonne plage numérique.
2. Le lever via `new HttpError(status, ErrorCode.XXX, "message dev-facing")`.
3. Ajouter sa traduction dans **chaque** fichier `apps/web/src/locales/*/translation.json`, sous `errors.XXX`.
---
## `packages/express-tools` — les pièces liées aux erreurs
`packages/express-tools` contient aussi `ExpressServer` (init serveur,
enregistrement de routes/middlewares) et `wrapAsyncHandler` — voir
[backend-architecture.md](./backend-architecture.md) pour le détail complet du
package. Les pièces qui concernent spécifiquement les erreurs :
- **`http-error.ts`** — `HttpError` : erreur typée portant `status` (code HTTP) et
`code` (`ErrorCode`). C'est ce que lèvent les routes/services au lieu de
construire une réponse HTTP à la main.
- **`error-handler.service.ts`** — `ErrorHandlerService` : un seul point qui sait
transformer n'importe quelle erreur JS (`ZodError`, `HttpError`, n'importe quoi
d'autre) en `{ status, body }`. Le cas générique (`INTERNAL_ERROR`, 500) logue
l'erreur côté serveur sans jamais exposer de détail interne au client.
**N'importe pas `express`** — c'est un service générique, indépendant du
framework HTTP, qui fonctionnerait à l'identique derrière Fastify ou autre.
- **`error-middleware.ts`** — `createErrorMiddleware(service)` : construit le
middleware d'erreur Express (signature à 4 arguments) à partir du service —
c'est LUI la vraie couche Express, `ErrorHandlerService` reste agnostique.
Build réel (`tsc` → `dist/`, comme `packages/shared`) : consommé en JS compilé,
pas en TS brut — voir la note dans
[frontend-architecture.md](./frontend-architecture.md#note-sur-les-fichiers-dts)
sur pourquoi ça compte pour un runtime Node pur (Docker).
## Côté API (`apps/api`)
- **`app.ts`** — le middleware d'erreur final est enregistré via
`server.setErrorHandler(createErrorMiddleware(errorHandlerService))` (voir
[backend-architecture.md](./backend-architecture.md) pour `ExpressServer`) ;
aucune logique de mapping n'y vit directement, tout est dans `express-tools`.
- Les modules métier (`modules/auth/auth.service.ts`, `middlewares/require-auth.ts`)
importent `HttpError` depuis `@batch-cooking/express-tools` et `ErrorCode` depuis
`@batch-cooking/shared`.
## Côté Web (`apps/web`)
- **`api/client.ts`** — `ApiClient` : lève `ApiError` (porteur de `status`, `code`,
`fieldErrors`) pour toute réponse non-2xx.
- **`services/error-message.service.ts`** — `ErrorMessageService` : convertit le
`ErrorCode` numérique reçu en nom de membre (`ErrorCode[code]`, ex. `4001`
`"EMAIL_ALREADY_IN_USE"`), puis délègue la traduction à **i18next**
(`i18n.t(\`errors.${memberName}\`)`). N'a pas sa propre table de libellés — c'est
i18next + les fichiers de locale qui la portent.
- **`i18n/i18n.ts`** + **`locales/fr/translation.json`** — configuration et
ressources i18next. Ajouter une langue = ajouter une entrée `resources.<lng>`
pointant vers un nouveau fichier de locale, sans toucher un seul composant.
- Les pages (`LoginPage`, `SignupPage`) attrapent `ApiError`, récupèrent `err.code`,
et appellent `errorMessageService.getLabel(err.code)` pour l'afficher — jamais
`err.message`.
## Validation côté formulaire (distincte du contrat d'erreurs API)
Les schémas zod partagés (`packages/shared/src/schemas/auth.ts`) portent leurs
propres messages en français, utilisés pour la validation **avant** l'appel réseau
(retour instantané, aucun aller-retour serveur). C'est un mécanisme séparé du
contrat `ErrorCode`/i18next : ces messages ne quittent jamais le navigateur, et ne
vivent pas dans les fichiers de locale (ils sont dans `packages/shared`, consommé
aussi par l'API qui ne dépend pas d'i18next).