# batchCooking

## Structure

Monorepo pnpm workspaces :

- `apps/api` — backend Express/TypeScript : auth, foyer, planning (grille de la
  semaine), catalogue de recettes (favoris/perso/foyer/publique), import de
  recettes depuis des sources externes, préférences (thème, régime, allergies,
  ingrédients détestés). Tests Mocha (base Postgres réelle, isolée de la base
  de dev — voir plus bas).
- `apps/web` — frontend React/Vite/TypeScript, prêt à être embarqué par
  Capacitor plus tard. Espace connecté complet (planning, recettes, réglages)
  derrière une sidebar, wizard d'inscription, thème clair/sombre/système.
- `packages/shared` — code partagé entre `api` et `web` : schémas zod, types
  (`RecipeView`, `PlanningView`, `HouseView`, `SafeUserProfile`...), le contrat
  d'erreurs (`ErrorCode` numérique, `ApiErrorResponse`, voir
  [specs/error-handling.md](specs/error-handling.md)) et les libellés anglais
  du catalogue d'ingrédients (`data/catalog-labels-en.ts`, utilisés par le
  matching de recettes importées — voir plus bas) — même règles des deux
  côtés, pas de risque de dérive entre front et back.
- `packages/error-tools` — gestion des erreurs, **indépendante de tout framework
  HTTP** (n'importe pas `express`) : `HttpError`, `ErrorHandlerService`. Séparé
  d'`express-tools` précisément parce que rien ici ne dépend d'Express. Détail :
  [specs/error-handling.md](specs/error-handling.md).
- `packages/express-tools` — outillage Express générique et réutilisable : `ExpressServer`
  (init serveur, routes, middlewares), `wrapAsyncHandler`, `createErrorMiddleware`
  (adapte `ErrorHandlerService` de `error-tools` à Express) — séparé d'`apps/api`,
  pas de logique métier. Détail : [specs/backend-architecture.md](specs/backend-architecture.md).
- `packages/date-tools` — utilitaires de date partagés (Luxon) : convention
  "date-only = minuit UTC" (`parseDateOnly`/`formatDateOnly`/`toDateOnly`),
  calcul de semaine lundi-first (`getWeekStart`/`addWeeks`/`buildCalendarMonth`)
  — utilisés à la fois par `apps/api` (validation de date de planning) et
  `apps/web` (grille/navigateur de semaine).

`packages/shared`, `packages/error-tools`, `packages/express-tools` et
`packages/date-tools` ont un vrai build (`tsc` → `dist/`, voir leur
`package.json`) : consommés en JS compilé, pas en TS brut — nécessaire pour un
runtime Node pur (Docker, pas de transpilation à la volée), voir la note dans
[specs/frontend-architecture.md](specs/frontend-architecture.md#note-sur-les-fichiers-dts).

## Prérequis

- Node.js 22 (voir `.nvmrc`)
- pnpm 10 (`corepack enable` puis `corepack use pnpm@10.12.4`, ou installation manuelle)
- Docker (pour Postgres en local)
- Python 3.12+ et [`uv`](https://docs.astral.sh/uv/) (pour
  `services/tech-step-intent-service` en dev natif — requis, voir plus bas)

## Installation

```bash
pnpm install
cp .env.example .env
cp apps/api/.env.example apps/api/.env
cp apps/web/.env.example apps/web/.env
```

Puis **édite ces deux `.env`** pour renseigner de vrais `POSTGRES_USER`/`POSTGRES_PASSWORD`
(et la `DATABASE_URL` correspondante dans `apps/api/.env`) : les fichiers `.env.example`
ne contiennent volontairement aucun identifiant réel (juste `changeme`), et
`docker-compose.yml` refuse de démarrer tant que `POSTGRES_USER`/`PASSWORD`/`DB` ne
sont pas définis dans `.env` — pas de valeur par défaut en dur dans les fichiers commités.
Même règle pour `apps/api/.env` : `JWT_SECRET` est **requis, sans défaut** (génère le
tien, voir le commentaire dans `apps/api/.env.example`).

Si tu comptes lancer `pnpm --filter api test` (voir [Qualité / Tests](#qualité--tests)),
crée aussi `apps/api/.env.test` — voir la section dédiée plus bas.

### Cypress : téléchargement du binaire

`pnpm install` installe le package `cypress` mais **pas forcément son binaire** (le
téléchargement du `.exe`/binaire natif peut être ignoré selon l'environnement où
`pnpm install` a été lancé — ex. un environnement sandboxé/CI dont le cache ne
correspond pas à celui de ta machine). Si `pnpm --filter web e2e` échoue avec une
erreur du type :

```
No version of Cypress is installed in: ...\AppData\Local\Cypress\Cache\...
Please reinstall Cypress by running: cypress install
```

lance simplement, depuis ta machine :

```bash
pnpm --filter web exec cypress install
```

(à faire une seule fois par machine ; le binaire est mis en cache localement,
hors du repo).

## Développement

```bash
# Base de données Postgres locale
docker compose up -d postgres

# Applique le schéma (première fois / après un changement de prisma/schema.prisma)
pnpm --filter api exec prisma migrate dev

# Peuple les données de référence (régimes, allergènes, ingrédients, unités,
# techniques...) — automatique après `prisma migrate reset`, sinon à la main :
pnpm --filter api prisma:seed

# Microservice de détection des techniques (spaCy) — requis, `pnpm dev:api`
# ne peut plus détecter aucune technique de cuisine sans lui. Lance-le en
# premier et laisse-le tourner : il s'entraîne lui-même à chaque démarrage
# (~11 minutes pour le corpus actuel, voir son propre README) avant de
# répondre quoi que ce soit sur /health.
cd services/tech-step-intent-service
uv sync
cp .env.example .env  # édite-le : même INTENT_SERVICE_SECRET que apps/api/.env
uv run uvicorn intent_service.main:app --reload --port 8000
cd ../..

# Backend (http://localhost:3000)
pnpm dev:api

# Frontend (http://localhost:5173)
pnpm dev:web
```

> **Conflit de port possible sur `5432`** : si tu as déjà un Postgres natif installé
> sur ta machine (service Windows, Homebrew, etc.), il peut occuper le port 5432 et
> intercepter les connexions à la place du conteneur Docker (symptôme : Prisma
> renvoie `P1000: Authentication failed` alors que les identifiants sont corrects).
> Dans ce cas, mets `POSTGRES_PORT=5433` (ou autre) dans ton `.env` **et** adapte le
> port dans la `DATABASE_URL` de `apps/api/.env`.

> **Toujours cibler `postgres`, jamais `docker compose up -d` tout court.** Le même
> `docker-compose.yml` définit aussi le service `app` (voir [Déploiement](#déploiement)) —
> celui que Portainer construit en production. Sans nom de service, `docker compose up -d`
> démarre les deux : ça déclenche un `pnpm install` sur tout le monorepo (donc aussi le
> `cypress` d'`apps/web`, avec son téléchargement de binaire) rien que pour builder une
> image dont le dev local n'a pas besoin (on sert le front/back directement via
> `pnpm dev:web`/`pnpm dev:api`, pas ce conteneur).

## Qualité / Tests

Conventions de code (classes vs objets littéraux, préfixe `_` sur les membres
privés, règles Biome actives, logs côté serveur, etc.) :
[specs/dev-conventions.md](specs/dev-conventions.md).

```bash
pnpm lint                     # Biome (lint + format check)
pnpm lint:fix                 # Biome --write
pnpm test                     # tests unitaires/intégration (Mocha, apps/api)
pnpm --filter web e2e         # tests e2e (Cypress + Cucumber, démarre le serveur dev automatiquement)
pnpm --filter web cy:run:component  # tests de composant UI isolés (Cypress component testing)
pnpm build                    # build de tous les workspaces
```

La CI GitHub Actions (`.github/workflows/ci.yml`) exécute cinq jobs indépendants (`lint`, `test`, `intent-service-test`, `build`, `e2e` — ce dernier lance aussi `cy:run:component`) en parallèle, sur chaque push (toutes branches) et sur chaque PR vers `main` — pas de chaînage entre eux, chacun apparaît comme son propre check. `test` démarre `services/tech-step-intent-service` en arrière-plan (voir ce fichier) puisque la suite Mocha ne mocke jamais un service interne. Voir aussi [Déploiement](#déploiement) pour le pipeline de release (`.github/workflows/release.yml`).

### Base de test isolée de la base de dev (`apps/api`)

`pnpm --filter api test` exécute une `TRUNCATE ... CASCADE` sur presque tout le
schéma **avant chaque test** (`test-support/reset-db.ts`). Pour ne jamais
risquer de vider une vraie base de dev locale, `NODE_ENV=test` (posé par le
script `test`) fait charger `apps/api/.env.test` au lieu de `.env` — un
fichier **à créer toi-même**, pas fourni automatiquement :

```bash
cp apps/api/.env.test.example apps/api/.env.test
# puis édite-le : mêmes identifiants Postgres que ton .env, mais une base
# différente (ex. batchcooking_test) — .env.test.example documente les
# commandes exactes pour la créer et lui appliquer le schéma.
```

Un garde-fou (`assertRunningAgainstTestDatabase()`) refuse d'exécuter
`resetDatabase()` si `DATABASE_URL` ne contient ni `"test"` ni `"ci"` — la
seule base qu'il doit rejeter est ta vraie base de dev.

`services/tech-step-intent-service` doit aussi tourner en local avant
`pnpm --filter api test` — les tests touchant `tech-step-matcher.ts` passent
par le vrai service (jamais un mock, voir
[specs/dev-conventions.md](specs/dev-conventions.md)) et échouent avec une
erreur de connexion, pas une assertion utile, s'il n'est pas démarré. Voir la
section [Développement](#développement) ci-dessus.

## Déploiement

Une seule image Docker (`apps/api/Dockerfile`) sert à la fois l'API et le frontend
buildé — plus de conteneur nginx séparé pour `apps/web`. Le stage `build` compile
`apps/api` **et** `apps/web` (`pnpm --filter web build`), le stage `runtime` copie
le résultat (`apps/web/dist`) à côté de l'API ; au démarrage, `apps/api/src/app.ts`
sert ce dossier statique (fallback SPA compris, pour le routing react-router côté
client) via `FRONTEND_DIST_DIR` — voir `packages/express-tools/src/express-server.ts`
(`serveStaticFrontend`). Cette variable n'est renseignée que dans l'image Docker :
en dev natif (`pnpm dev:api`), elle reste vide et `pnpm dev:web` continue de servir
le frontend via son propre serveur Vite (HMR), sur un port séparé, comme avant.

Le `CMD` de l'image enchaîne trois étapes, chacune dans son propre processus
`node` : `prisma migrate deploy` (applique les migrations), puis
`node dist/scripts/seed-runtime.js` (seed des données de référence **et**
synchronisation de la table `sources` depuis le registre d'adaptateurs de code
— nécessaire à chaque démarrage : le registre en mémoire peuplé par
`server.ts` ne survit pas au changement de processus, voir
[specs/backend-architecture.md](specs/backend-architecture.md#sources-externes--adaptateur-registre-synchronisation)),
puis `node dist/server.js`. Les trois étapes sont sûres/idempotentes à
répéter à chaque redémarrage du conteneur.

Le duo `postgres`/`app` de `docker-compose.yml` n'expose donc qu'un seul port
applicatif, `APP_PORT` (défaut `3000`) — plus de `WEB_PORT`/`CORS_ORIGIN` à
coordonner entre deux origines, le frontend et l'API sont désormais servis
depuis la même origine. Les deux autres services du fichier
(`tech-step-intent-service`, `tech-step-llm-worker`) n'exposent eux aucun port
au host — voir leurs propres README pour leur rôle.

**Pas de registre d'image** dans cette configuration : l'instance **Portainer** de
production est reliée directement au dépôt Git et reconstruit elle-même
`docker-compose.yml`/`apps/api/Dockerfile` à chaque déploiement — la CI ne pousse
donc aucune image nulle part.

### Release (`.github/workflows/release.yml`)

Déclenchée par un tag `vX.Y.Z` :

```bash
git tag vX.Y.Z
git push --tags
```

Le pipeline enchaîne trois jobs : `sanity-build` (build de l'image Docker sans
push, juste pour vérifier qu'elle build encore à ce tag avant de laisser Portainer
redéployer dessus), `github-release` (crée une Release GitHub avec changelog
auto-généré à partir des PRs mergées), puis `notify-portainer` — envoie une requête
au webhook de redeploy de Portainer si le secret de dépôt `PORTAINER_WEBHOOK_URL`
est configuré (sinon Portainer se resynchronise simplement à son prochain
polling Git). Pour l'activer : récupérer l'URL du webhook depuis les réglages du
stack Portainer, puis l'ajouter comme secret GitHub `PORTAINER_WEBHOOK_URL`.

## Auth (apps/api)

Inscription (création de profil + foyer) et connexion, JWT dans un cookie httpOnly.

- `POST /auth/signup` — `{ firstName, lastName, email, password }` → crée le foyer
  (`house`) et le profil (`user_profiles`) en une transaction, pose le cookie de
  session, renvoie le profil (201)
- `POST /auth/login` — `{ email, password }` → pose le cookie de session, renvoie le
  profil (200) ; message d'erreur volontairement générique (401) que ce soit
  l'email ou le mot de passe qui soit incorrect
- `POST /auth/logout` — efface le cookie (204)
- `GET /auth/me` — profil courant, nécessite le cookie de session (401 sinon)
- `DELETE /auth/me` — supprime définitivement le compte après re-saisie du mot
  de passe (`{ password }`, 401 `INVALID_CREDENTIALS` si incorrect) ; gère le
  départ/transfert d'adminship du foyer avant suppression (voir Foyer plus bas)

Mots de passe hachés avec argon2. `UserProfile.tokenVersion` existe pour
invalider les JWT déjà émis (ex. futur changement de mot de passe) mais rien
ne l'incrémente encore — pas de route de changement d'email/mot de passe
aujourd'hui, seulement la suppression de compte.

> **argon2 : version pinnée à `0.31.2`, pas de `^`.** La version `0.45.1` (dernière au
> moment de l'écriture) segfault au runtime sur au moins une configuration Windows —
> reproduit de façon stable (bash sandboxé, bash non-sandboxé, PowerShell), alors que
> `0.31.2` fonctionne parfaitement avec la même API. Si tu montes la version, revérifie
> concrètement (`argon2.hash(...)` dans un `node -e`) avant de merger, un `pnpm build`
> qui passe ne suffit pas à détecter un crash runtime.

Les tests (Mocha) tournent avec un coût argon2 réduit
(`NODE_ENV=test`, voir `auth.service.ts`) — le coût par défaut est volontairement
élevé (sécurité), ce qui rendrait la suite de tests lente/instable sinon. La CI
provisionne un vrai Postgres de service (`.github/workflows/ci.yml`) et exécute
`prisma migrate deploy` avant les tests.

## Foyer — création, invitation, admin, sources externes (apps/api)

Un foyer (`house`) a un admin (`adminId`) et un code d'invitation à 8
caractères (`inviteCode`, alphabet sans caractères ambigus `0`/`O`/`1`/`I`).

- `GET`/`PATCH /house/current` — foyer courant. `PATCH { name }` ouvert à tout membre.
- `POST /house` — crée un foyer (l'appelant devient admin) ; `POST /house/join
  { inviteCode }` — rejoint un foyer existant. Les deux 409 `ALREADY_HAS_HOUSE`
  si le profil a déjà un foyer.
- `POST /house/leave` — quitte le foyer courant. Si le partant était l'admin,
  l'adminship passe au membre restant le plus ancien ; si plus personne ne
  reste, le foyer est supprimé (un foyer ne peut jamais rester sans admin).
- `DELETE /house/current` — supprime le foyer (403 `NOT_HOUSE_ADMIN` si appelé
  par un non-admin). `DELETE /house/members/:id` — retire un membre (admin
  seulement, pas de self-retrait par cette route, utiliser `/leave`).
- `GET`/`PATCH /house/current/sources` — quelles sources externes de recettes
  (voir plus bas) le foyer voit dans son catalogue — `{ sourceIds: number[] }`,
  remplace (pas de fusion), opt-in (aucune source activée par défaut).

Détail complet (génération du code, transfert d'adminship) :
[specs/backend-architecture.md](specs/backend-architecture.md#house--foyer-adminship-code-dinvitation-sources-activées).

## Planning (apps/api)

- `GET /planning?date=YYYY-MM-DD` — planning de la semaine (lundi→dimanche)
  couvrant `date`, pour le foyer de l'utilisateur connecté — `PlanningView |
  null` (`null` = pas de foyer, ou aucun planning pour cette semaine, deux cas
  normaux confondus, jamais une erreur).
- `POST /planning/items` — ajoute une recette à un créneau :
  `{ date, weekDay, meal, recipeId, portions }`. `portions` est saisi
  indépendamment du rendement propre de la recette (`Recipe.portions`) — un
  créneau peut mettre à l'échelle.
- `DELETE /planning/items/:id` — retire un item du planning.

Le planning d'une semaine est créé à la demande (première recette ajoutée),
jamais en avance.

## Recettes — catalogue, favoris, import depuis une source externe (apps/api)

- `GET /recipes?tab=favoris|perso|foyer|publique&search=&suitableForHousehold=&ingredientIds=&dietIds=`
  — catalogue filtré par onglet + filtres optionnels. `PERSONAL`/`HOUSE`/`PUBLIC`
  (`Recipe.visibility`) contrôlent qui peut **lire** une recette (jamais qui
  peut l'éditer, toujours réservé à l'auteur) ; les recettes issues d'une
  source externe non activée pour le foyer du viewer sont masquées de tous les
  onglets.
- `GET /recipes/:id`, `POST /recipes`, `PATCH /recipes/:id`,
  `DELETE /recipes/:id` (409 `RECIPE_IN_USE` si encore référencée par un
  planning), `POST`/`DELETE /recipes/:id/favorite`.
- **Import depuis une source externe** (`/sources`) : `GET
  /sources/:sourceKey/browse` (parcourir), `GET
  /sources/:sourceKey/preview/:externalId` (prévisualiser sans sauvegarder —
  ingrédients/unités/techniques déjà résolus contre les catalogues), `POST
  /sources/:sourceKey/import/:externalId` (finaliser — même payload qu'une
  création manuelle). Un item de source n'est sauvegardé qu'en conséquence de
  son ajout au planning (import transparent si tout est résolu) ou d'une revue
  manuelle (ingrédients ambigus à choisir à la main) — jamais un bouton
  "importer" isolé. Une seule source concrète aujourd'hui : **TheMealDB**
  (API officielle, catalogue anglais).

Détail complet (adaptateurs, algorithmes de matching ingrédients/techniques,
synchronisation de la table `sources`) :
[specs/backend-architecture.md](specs/backend-architecture.md#sources-externes--adaptateur-registre-synchronisation).

## Données de référence — régimes, allergènes, ingrédients, unités, techniques (apps/api)

- `GET /reference/diets`, `/allergies`, `/ingredients`, `/units`,
  `/tech-steps`, `/sources` — tous **publics** (pas de `requireAuth`) : ce sont
  des données de référence, pas des données de foyer, et le wizard
  d'inscription doit pouvoir les lire avant qu'un compte n'existe.

Données seedées via `apps/api/src/db/reference-seed-data.ts` (`pnpm --filter
api prisma:seed`, ou automatiquement après `prisma migrate reset`) — jamais
créées/éditées/supprimées via l'API applicative. `key`/`name` sont `@unique`
pour permettre un seed idempotent (`upsert`). Le catalogue d'ingrédients
(400+) est organisé en 7 rayons/sous-catégories façon supermarché français, et
chaque allergène est classé `ALLERGY` (immunitaire) ou `INTOLERANCE`
(Gluten/Sulfites). Détail complet du schéma :
[specs/batch-cooking-modele.md](specs/batch-cooking-modele.md).

## Foyer & profil — régime, allergènes, ingrédients détestés (apps/api)

Nécessitent tous une session (`requireAuth`) — données propres à
l'utilisateur/au foyer, pas des données de référence.

- `PATCH /profile/diet { dietId: number | null }` — régime du profil connecté ;
  `null` efface le régime.
- `GET`/`PATCH /profile/allergies` — allergènes/intolérances (medical), liste
  d'IDs, remplace (pas de fusion).
- `GET`/`PATCH /profile/disliked-ingredients` — ingrédients personnellement
  "pas aimés" (**goût, pas médical** — ne déclenche jamais un avertissement de
  sécurité, juste un rappel discret sur la fiche recette), même contrat de
  remplacement.
- `GET`/`PATCH /preferences { theme: "LIGHT"|"DARK"|"SYSTEM" }` — préférence
  d'affichage, upsert (pas de ligne tant que rien n'a été choisi, défaut
  `SYSTEM`).

`apps/api/src/lib/safe-profile.ts` centralise le retrait du `passwordHash`
(`toSafeProfile`).

## Page de connexion / inscription (apps/web)

- `src/api/client.ts` — `ApiClient` (classe, instance unique exportée
  `apiClient`) : enveloppe `fetch` vers l'API (`credentials: "include"`, requis
  pour que le cookie de session httpOnly parte/revienne — l'API et le front
  sont sur des origines différentes). URL configurable via `VITE_API_URL`
  (voir `.env.example`).
- `src/features/auth/AuthContext.tsx` — état d'auth global ; appelle `GET
  /auth/me` au chargement pour restaurer la session depuis le cookie ;
  `deleteAccount()` pour la suppression de compte.
- `src/features/auth/RequireAuth.tsx` / `RedirectIfAuthenticated.tsx` — gardes
  de route (react-router-dom) : l'espace connecté exige d'être connecté,
  `/login` et `/signup` redirigent vers `/` si on l'est déjà.
- `src/pages/{Login,Signup}Page.tsx` — validation client instantanée via les
  schémas zod partagés (`packages/shared`), erreurs API traduites via
  `ErrorMessageService` (voir ci-dessous).

Détail de l'organisation complète (dossiers, routing, SCSS/theming) :
[specs/frontend-architecture.md](specs/frontend-architecture.md).

## Sidebar, planning, recettes & sections (apps/web)

Une fois connecté, l'utilisateur atterrit sur `src/layouts/AppLayout.tsx` —
sidebar (nav Planning/Recettes/Liste de courses, sous-menu Paramètres
repliable, menu compte en pied) et `<Outlet />` pour la route active — montée
une seule fois comme route parente de tout l'espace authentifié (`App.tsx`).

- **`/` — `PlanningPage`** : grille complète de la semaine (7 jours × 5
  repas), navigation par semaine avec mini-calendrier, ajout via
  `RecipePickerDialog` (parcourir le catalogue **et** les sources externes,
  prévisualiser avant de confirmer, import transparent en un clic si la
  recette d'une source n'est pas encore résolue automatiquement, sinon revue
  intégrée dans le même dialogue).
- **`/recettes`** (+ `/recettes/:id`, `/recettes/sources/:sourceKey/:externalId`)
  — `RecipesPage`, vue maître-détail : onglets favoris/perso/foyer/publique
  **plus un onglet par source externe activée pour le foyer**, tableau +
  panneau de détail (surlignage des techniques détectées avec infobulle, icônes
  d'ingrédients génériques, badges régime/allergènes/reproductible).
  `/recettes/nouvelle` et `/recettes/:id/modifier` (`RecipeFormPage`) pour la
  création/édition manuelle.
- **`/liste-de-courses`** — toujours un stub (`ComingSoonPage`), le module
  « Calcul batch-cooking » reste `TODO` (voir
  [specs/batch-cooking-architecture.md](specs/batch-cooking-architecture.md)).
- **`/parametres/*`** — Compte (identité + suppression), Préférences
  (régime/allergies/ingrédients détestés), Foyer (création/invitation,
  membres, sources activées), Préférences utilisateur (thème
  clair/sombre/système), Crédits (attribution des icônes CC BY 4.0).

Détail complet (pourquoi une seule route parente, le flux d'import détaillé,
les composants UI partagés `Dialog`/`Checkbox`/`Radio`/`Tooltip`) :
[specs/frontend-architecture.md](specs/frontend-architecture.md).

## Parcours d'inscription — onboarding (apps/web)

Wizard de 4 écrans lancé une fois juste après l'inscription :
`/onboarding/regime` → `/onboarding/foyer` → `/onboarding/sources`
(conditionnelle, sautée si aucun foyer n'a été créé/rejoint à l'étape
précédente) → `/onboarding/allergenes`. Chaque étape a un unique bouton
"Continuer" qui envoie la valeur courante (y compris "aucune" pour
régime/allergènes) — pas de bouton "Passer" séparé, skip implicite. Routes
top-level `RequireAuth`, **pas** nichées sous `AppLayout` : wizard plein écran
sans sidebar, même langage visuel que `/login`/`/signup`. Les mêmes réglages
restent modifiables à tout moment depuis `/parametres/*` (hot saving, pas de
bouton "Enregistrer" — chaque champ sauvegarde peu après la dernière
modification).

Tests Cypress (`apps/web/cypress/e2e/*.cy.ts` et `*.feature` +
`@badeball/cypress-cucumber-preprocessor`) : mockent l'API via `cy.intercept`
plutôt que de dépendre d'un vrai backend — le job e2e de la CI ne provisionne
pas de Postgres/API, seulement le serveur de dev Vite. Le comportement réel de
l'API est couvert par la suite Mocha d'`apps/api` (contre une vraie base).
Détail du dispositif de test (Gherkin + steps partagés, tests de composant) :
[specs/frontend-architecture.md](specs/frontend-architecture.md#tests-cypress--cucumber).

> **Cypress ne peut pas tourner en local dans un environnement Windows sandboxé** :
> Chromium/Electron headless plante au lancement du process GPU
> (`GPU process isn't usable`), reproductible sur `main` aussi bien que sur une
> branche de feature — pas un problème introduit par une modification du code.
> `pnpm --filter web e2e` fonctionne normalement en CI (GitHub Actions) et sur une
> machine de dev classique ; dans cet environnement précis, vérifier manuellement via
> le serveur de dev (`pnpm dev:web` + `pnpm dev:api` en local, pas le conteneur
> Docker — voir [Déploiement](#déploiement) — qui sert le frontend buildé, pas le
> serveur de dev Vite).

## Gestion des erreurs (API ↔ web)

Contrat d'erreurs partagé via `packages/shared` (`ErrorCode`, énumération
**numérique** groupée par famille — `4000` validation, `401x` auth, `402x`
conflit/état invalide, `403x` autorisation, `404x` not found, `500x` interne
— et `ApiErrorResponse`) : l'API renvoie toujours
`{ code, message, details? }` (message en anglais, dev-facing — jamais affiché tel
quel), et le client traduit `code` en libellé français via **i18next**
(`ErrorMessageService`, `apps/web/src/services/error-message.service.ts` →
`apps/web/src/locales/fr/translation.json`). Côté API, `ErrorHandlerService`
(`packages/error-tools`) et `createErrorMiddleware` (`packages/express-tools`)
centralisent la transformation de toute erreur levée en réponse HTTP conforme —
aucune valeur `ErrorCode` codée en dur nulle part (toujours `ErrorCode.XXX`, y
compris dans les mocks Cypress).

Détail complet (schéma, liste des ~19 codes actuels, exemples, comment ajouter
un nouveau code d'erreur) : [specs/error-handling.md](specs/error-handling.md).

Le profil authentifié (`requireAuth`) passe par `res.locals.userProfile`
(typé via `AuthLocals`), pas par une augmentation du namespace global Express —
voir [specs/backend-architecture.md](specs/backend-architecture.md) pour le détail
et le pourquoi.

`packages/shared` fournit aussi `assertIsNever` (vérification d'exhaustivité de
switch/if-chain sur une union, erreur de **compilation** si un cas est oublié) —
voir [specs/backend-architecture.md](specs/backend-architecture.md#packagesshared--assertisnever).

## i18n

**i18next** + **react-i18next** — tout le texte affiché (formulaires, boutons,
erreurs) vient de fichiers de locale JSON (`apps/web/src/locales/<lng>/translation.json`),
jamais codé en dur dans un composant. Une seule langue existe aujourd'hui (`fr`) ;
en ajouter une est une question de fichier de locale, pas de code. Les
libellés des tables de référence (régimes, allergènes, ingrédients, unités,
techniques) vivent aussi dans ce fichier (`catalog.*`, par `key` stable de
`schema.prisma`), jamais stockés en base. Détail :
[specs/frontend-architecture.md](specs/frontend-architecture.md#i18n-internationalisation).

## Thème clair / sombre / système (apps/web)

Préférence par utilisateur, persistée côté serveur (`GET`/`PATCH
/preferences`, pas `localStorage`). Tokens de design en custom properties CSS
(`apps/web/src/styles/_theme.scss`) redéfinies sous `[data-theme="dark"]`
(choix explicite) ou sous `prefers-color-scheme: dark` quand aucun
`data-theme` n'est posé (choix "système", le défaut). Détail :
[specs/frontend-architecture.md](specs/frontend-architecture.md#thème-clairsombresystème).

## Données de test (faker.js)

`apps/api` utilise [`@faker-js/faker`](https://fakerjs.dev/) pour toutes les données
de test dans `test/*.test.ts` (Mocha) — jamais de nom/email qui ressemble à une
vraie personne en dur dans un fixture.
