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.
158 lines
7.2 KiB
Markdown
158 lines
7.2 KiB
Markdown
# 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).
|