Le code avait beaucoup évolué depuis la dernière mise à jour de la documentation (sources externes, import de recettes, planning en grille, pages de paramètres, thème, tests Cucumber...) sans que README.md/specs/*.md ne suivent. Tour complet du code (backend + frontend) et réécriture : - specs/batch-cooking-modele.md : schéma de données réécrit depuis schema.prisma (foyer/admin/invitation, sources, catalogue ingrédients/unités, techniques détectées, visibilité des recettes). - specs/backend-architecture.md : foyer, préférences/goûts, planning, référence, sources externes (adaptateurs/registre/sync), matching ingrédients/techniques, isolation base de test, suppression de compte. - specs/frontend-architecture.md : routing complet, sidebar/paramètres, thème, planning + picker, catalogue + import, composants UI partagés, tests Cypress+Cucumber. - specs/batch-cooking-architecture.md : module Import passe de TODO à implémenté. - specs/error-handling.md : liste complète des ~19 codes d'erreur. - README.md : réécriture pour refléter tout ce qui précède, plus la note (dangereusement obsolète) sur le partage base de test/dev — le fix existe déjà (apps/api/.env.test), la doc décrivait encore le bug.
201 lines
10 KiB
Markdown
201 lines
10 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/error-tools`** — package séparé, **indépendant de tout framework
|
||
HTTP** (n'importe pas `express`) : `HttpError`, `ErrorHandlerService`. Le mapping
|
||
« erreur → `{ status, body }` » n'a rien de spécifique à Express, donc il ne vit
|
||
pas dans `express-tools`.
|
||
- **`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`) : `createErrorMiddleware` (adapte `ErrorHandlerService` à l'API
|
||
Express), `ExpressServer`, `wrapAsyncHandler`.
|
||
- **`apps/api`** — consomme les deux : lève des `HttpError` (`error-tools`), le
|
||
middleware d'erreur final n'est qu'un appel à
|
||
`createErrorMiddleware(errorHandlerService)` (`express-tools`).
|
||
- **`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 ERRTOOLS["packages/error-tools"]
|
||
HTTPERR["HttpError"]
|
||
EHS["ErrorHandlerService.handle()"]
|
||
end
|
||
|
||
subgraph TOOLS["packages/express-tools"]
|
||
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 ERRTOOLS 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, // body/query invalide (zod)
|
||
EMAIL_ALREADY_IN_USE = 4001, // signup avec un email déjà utilisé
|
||
|
||
INVALID_CREDENTIALS = 4010, // login : email ou mot de passe incorrect (jamais lequel)
|
||
NOT_AUTHENTICATED = 4011, // cookie de session manquant/invalide/périmé
|
||
|
||
ALREADY_HAS_HOUSE = 4020, // POST /house ou /house/join alors qu'on a déjà un foyer
|
||
RECIPE_IN_USE = 4021, // DELETE /recipes/:id encore référencée par un PlanningItem
|
||
RECIPE_ALREADY_IMPORTED = 4022, // POST /sources/:key/import/:id déjà importé (sourceId+externalId)
|
||
|
||
NOT_HOUSE_ADMIN = 4030, // action réservée à l'admin du foyer (delete, removeMember)
|
||
NOT_RECIPE_AUTHOR = 4031, // PATCH/DELETE /recipes/:id par quelqu'un d'autre que l'auteur
|
||
|
||
NOT_FOUND = 4040, // aucune route ne correspond
|
||
HOUSE_NOT_FOUND = 4041, // le profil n'a pas (encore) de foyer
|
||
DIET_NOT_FOUND = 4042, // dietId inconnu
|
||
ALLERGY_NOT_FOUND = 4043, // allergyId inconnu
|
||
INVITE_CODE_NOT_FOUND = 4044, // POST /house/join avec un code invalide
|
||
RECIPE_NOT_FOUND = 4045, // id inconnu, ou recette non visible par l'appelant
|
||
INGREDIENT_NOT_FOUND = 4046, // ingredientId inconnu
|
||
PLANNING_ITEM_NOT_FOUND = 4047, // DELETE /planning/items/:id inconnu
|
||
UNIT_NOT_FOUND = 4048, // unitId inconnu
|
||
SOURCE_NOT_FOUND = 4049, // sourceKey non activé pour le foyer, ou sans adaptateur enregistré
|
||
|
||
INTERNAL_ERROR = 5000, // catch-all, toujours loggé côté serveur
|
||
}
|
||
|
||
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, `4020`–`4029` conflit/état invalide,
|
||
`4030`–`4039` autorisation (authentifié mais pas autorisé), `4040`–`4049`
|
||
ressource introuvable, `5000`–`5099` interne. Le numéro donne une indication de
|
||
la catégorie même sans regarder l'enum. Toujours **404**, jamais **403**, pour un
|
||
« not found » qui cacherait en fait un problème de visibilité (`RECIPE_NOT_FOUND`
|
||
sur une recette `PERSONAL`/`HOUSE` d'autrui, `SOURCE_NOT_FOUND` sur une source
|
||
non activée) — l'existence de la ressource ne doit pas fuiter ; `403` (`NOT_HOUSE_ADMIN`,
|
||
`NOT_RECIPE_AUTHOR`) est réservé aux cas où l'existence de la ressource est déjà
|
||
connue de l'appelant et où seule l'action est refusée.
|
||
|
||
**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/error-tools` — les pièces liées aux erreurs, indépendantes du framework
|
||
|
||
- **`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. C'est
|
||
précisément pour ça qu'il vit dans son propre package plutôt que dans
|
||
`express-tools` : rien ici ne dépend d'Express, donc rien ici n'a sa place dans
|
||
un package *express*-tools.
|
||
|
||
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).
|
||
|
||
## `packages/express-tools` — l'adaptateur Express
|
||
|
||
`packages/express-tools` contient `ExpressServer` (init serveur, enregistrement
|
||
de routes/middlewares) et `wrapAsyncHandler` — voir
|
||
[backend-architecture.md](./backend-architecture.md) pour le détail complet du
|
||
package. La pièce qui concerne spécifiquement les erreurs :
|
||
|
||
- **`error-middleware.ts`** — `createErrorMiddleware(service: ErrorHandlerService)` :
|
||
construit le middleware d'erreur Express (signature à 4 arguments) à partir
|
||
d'un `ErrorHandlerService` importé de `@batch-cooking/error-tools` — c'est LUI
|
||
la vraie couche Express, `ErrorHandlerService` reste agnostique. `express-tools`
|
||
dépend de `error-tools`, jamais l'inverse.
|
||
|
||
## 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 `error-tools`.
|
||
- Les modules métier (`modules/auth/auth.service.ts`, `middlewares/require-auth.ts`)
|
||
importent `HttpError` depuis `@batch-cooking/error-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).
|