# 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).
