batchCooking/specs/error-handling.md
Nicolas 4f509865c1 docs: met à jour README et specs/ avec l'état réel du code
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.
2026-08-21 08:24:58 +02:00

10 KiB
Raw Permalink Blame History

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/webErrorMessageService — 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.
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)

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) : 40004099 validation, 40104019 authentification, 40204029 conflit/état invalide, 40304039 autorisation (authentifié mais pas autorisé), 40404049 ressource introuvable, 50005099 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.tsHttpError : 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.tsErrorHandlerService : 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 (tscdist/, comme packages/shared) : consommé en JS compilé, pas en TS brut — voir la note dans frontend-architecture.md 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 pour le détail complet du package. La pièce qui concerne spécifiquement les erreurs :

  • error-middleware.tscreateErrorMiddleware(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 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.tsApiClient : lève ApiError (porteur de status, code, fieldErrors) pour toute réponse non-2xx.
  • services/error-message.service.tsErrorMessageService : 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).