No description
Find a file
Nicolas 30fffd59ad fix(web): corrige l'e2e onboarding cassé par l'étape sources
GET /reference/sources n'était intercepté par aucun scénario Cypress
menant à la création/jonction d'un foyer — la requête réelle restait
en attente indéfiniment, laissant OnboardingSourcesPage bloqué sur
/onboarding/sources au lieu de s'auto-sauter vers /onboarding/allergenes.

- Ajout du Given "the sources reference list is empty" (même pattern
  que les intercepts diets/allergies existants), câblé dans les deux
  scénarios qui créent/rejoignent un foyer.
- Mise à jour de l'assertion "Étape 3 sur 3" → "Étape 4 sur 4" : un
  foyer étant créé dans ce scénario, l'étape allergènes affiche
  désormais le total dynamique (4 étapes) comme prévu.
- OnboardingSourcesPage.tsx : redirige aussi vers /onboarding/allergenes
  en cas d'échec réseau sur getSources(), pas seulement quand la liste
  est vide — le wizard ne doit pas bloquer l'utilisateur sur une étape
  optionnelle à cause d'un problème transitoire.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 09:47:17 +02:00
.claude Apply "Mise en Place" visual identity to auth/home screens (#8) 2026-08-16 19:43:13 +02:00
.github/workflows refactor: sépare les tests Cypress en parcours utilisateur / layout / composants (#25) 2026-08-19 14:31:17 +02:00
apps fix(web): corrige l'e2e onboarding cassé par l'étape sources 2026-08-20 09:47:17 +02:00
packages feat(recipes): préférences de sources par foyer + distinction officielle/non-officielle 2026-08-20 09:22:54 +02:00
specs Web: allergies/intolérances séparées + hot saving sur /foyer (step 8/8) 2026-08-17 00:09:09 +02:00
.dockerignore Add Docker packaging for local functional review (api + web) (#5) 2026-08-16 14:13:51 +02:00
.env.example fix(api): make the session cookie's Secure flag overridable 2026-08-17 23:49:05 +02:00
.gitignore chore(web): session de polish global — version, checkbox, danger zone, icônes (#20) 2026-08-18 20:52:12 +02:00
.nvmrc Scaffold generic pnpm monorepo (api + web + shared) (#1) 2026-08-16 10:55:13 +02:00
biome.json Scaffold generic pnpm monorepo (api + web + shared) (#1) 2026-08-16 10:55:13 +02:00
docker-compose.yml fix(api): make the session cookie's Secure flag overridable 2026-08-17 23:49:05 +02:00
package.json API: signup/login (profile creation + JWT auth) (#4) 2026-08-16 13:45:23 +02:00
pnpm-lock.yaml refactor: sépare les tests Cypress en parcours utilisateur / layout / composants (#25) 2026-08-19 14:31:17 +02:00
pnpm-workspace.yaml Scaffold generic pnpm monorepo (api + web + shared) (#1) 2026-08-16 10:55:13 +02:00
README.md chore(api): retire l'intégration Cucumber/Gherkin 2026-08-19 12:41:17 +02:00
tsconfig.base.json Scaffold generic pnpm monorepo (api + web + shared) (#1) 2026-08-16 10:55:13 +02:00

batchCooking

Structure

Monorepo pnpm workspaces :

  • apps/api — backend Express/TypeScript (squelette générique : healthcheck, config env, Prisma non modélisé, tests Mocha)
  • apps/web — frontend React/Vite/TypeScript, prêt à être embarqué par Capacitor plus tard. Page de connexion/inscription en place ; le reste est encore un squelette générique.
  • packages/shared — code partagé entre api et web : schémas zod (signupSchema, loginSchema), types (SafeUserProfile), et le contrat d'erreurs (ErrorCode numérique, ApiErrorResponse, voir specs/error-handling.md) — 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.
  • 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.

packages/shared, packages/error-tools et packages/express-tools ont un vrai build (tscdist/, 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.

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)

Installation

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

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 :

pnpm --filter web exec cypress install

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

Développement

# 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

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

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, démarre le serveur dev automatiquement)
pnpm build                     # build de tous les workspaces

La CI GitHub Actions (.github/workflows/ci.yml) exécute quatre jobs indépendants (lint, test, build, e2e) 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. Voir aussi Déploiement pour le pipeline de release (.github/workflows/release.yml).

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.

docker-compose.yml ne définit donc que deux services : postgres et app (un seul port, 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).

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 :

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)

Mots de passe hachés avec argon2. Le hash est indépendant du foyer : un profil crée toujours son propre foyer à l'inscription (rejoindre un foyer existant n'est pas encore implémenté).

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.

Les tests automatisés et pnpm dev:api partagent la même base Postgres locale. Lancer pnpm test vide user_profiles/house (TRUNCATE ... CASCADE, voir test-support/reset-db.ts) — si tu es en train de tester manuellement à la main (via le navigateur ou curl) contre le serveur de dev, un run de tests en parallèle efface tes données de test sans prévenir. Pas un bug, juste à savoir.

Planning (apps/api)

  • GET /planning/current — nécessite le cookie de session (401 sinon). Renvoie le planning du foyer de l'utilisateur connecté qui couvre la date du jour (Planning dont start_date <= aujourd'hui <= finish_date), items inclus avec leur recette résolue en { id, name } — ou null s'il n'y en a aucun (foyer sans planning en cours, ou profil sans foyer). null est une réponse valide (200), pas une erreur : aujourd'hui rien ne permet encore de créer un planning (le module « Calcul batch-cooking », voir specs/batch-cooking-architecture.md, reste à construire), donc c'est l'état attendu tant que ce module n'existe pas.
  • Type de réponse partagé : PlanningView (packages/shared/src/types/planning.ts), consommé tel quel par apps/web.

Détail de AsyncRequestHandler/wrapAsyncHandler (packages/express-tools) — premier endpoint à combiner requireAuth/AuthLocals avec un handler async, ce qui a mis au jour une contrainte générique trop stricte, corrigée à la source : specs/backend-architecture.md.

Données de référence — régimes & allergènes (apps/api)

  • GET /reference/diets — liste des régimes alimentaires (Diet, 5 valeurs seedées).
  • GET /reference/allergies — liste des allergènes sélectionnables, { id, name } (le nom vient de Category.name — la table allergy elle-même ne porte pas de nom, voir schema.prisma — chaque allergène = une Category + une unique Allergy sous cette catégorie).

Les deux sont 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 (donc une session) n'existe.

Données seedées via apps/api/prisma/seed.ts (pnpm --filter api prisma:seed, ou automatiquement après prisma migrate reset — config prisma.seed dans package.json). La logique réelle (listes + upsert idempotent) vit dans src/db/reference-seed-data.ts, partagée avec test-support/reset-db.ts : chaque test repart d'une base avec ces données de référence, pas de tables vides — nécessaire pour tester dietId/allergyIds sur de vraies lignes.

Diet.name et Category.name sont @unique — ajouté à ce schéma (pas dans le doc spec d'origine) précisément pour permettre cet upsert idempotent par nom.

Liste des 14 allergènes : ceux du règlement UE 1169/2011 (annexe II) — liste standard, pas inventée.

Allergies vs intolérances (retour fonctionnel, pas dans le doc spec d'origine) : Category.kind (AllergenKindALLERGY | INTOLERANCE) classe chaque allergène. Seuls Gluten et Sulfites sont en INTOLERANCE (réaction non-immunitaire documentée) ; les 12 autres en ALLERGY (réaction immunitaire classique). Classifié par substance, pas par utilisateur — un même foyer ne peut pas déclarer "allergie au lait" pour un membre et "intolérance au lait" pour un autre ; a suffi pour le besoin exprimé, à revoir si ça devient un problème réel. GET /reference/allergies renvoie kind dans chaque AllergyView ; PATCH /profile/allergies ne change pas (une seule liste d'IDs, kind ne sert qu'à grouper l'affichage côté client).

Foyer & profil — nom, régime, allergènes (apps/api)

Nécessitent tous une session (requireAuth) — contrairement aux endpoints de référence ci-dessus, ce sont des données propres à l'utilisateur/au foyer.

  • GET/PATCH /house/current — foyer de l'utilisateur connecté. GET renvoie null si le profil n'a pas encore de foyer (cas théorique : le signup en crée toujours un) ; PATCH { name } le renomme (404 HOUSE_NOT_FOUND si le profil n'a pas de foyer).
  • PATCH /profile/diet { dietId: number | null } — régime du profil connecté ; null efface le régime (étape "skippable" du parcours). 404 DIET_NOT_FOUND si dietId ne correspond à aucun régime de référence.
  • GET/PATCH /profile/allergies — allergènes/intolérances du profil connecté, sous forme de liste d'IDs (number[]). PATCH { allergyIds } remplace l'ensemble (pas une fusion — le client renvoie toujours la sélection complète, cohérent avec un composant de multi-sélection). 404 ALLERGY_NOT_FOUND si un ID ne correspond à aucun allergène de référence.

apps/api/src/lib/safe-profile.ts centralise le retrait du passwordHash (toSafeProfile), auparavant dupliqué dans auth.service.ts et require-auth.tsprofile.service.ts le réutilise aussi.

Page de connexion / inscription (apps/web)

  • src/api/client.tsApiClient (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.
  • src/features/auth/RequireAuth.tsx / RedirectIfAuthenticated.tsx — gardes de route (react-router-dom) : / exige d'être connecté, /login et /signup redirigent vers / si on l'est déjà.
  • src/pages/{Login,Signup,Home}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.

Accueil, sidebar & sections (apps/web)

Une fois connecté, l'utilisateur atterrit sur src/layouts/AppLayout.tsx — sidebar (nav Planning/Recettes/Liste de courses/Foyer & profil + nom/déconnexion en pied) et <Outlet /> pour la route active — montée une seule fois comme route parente de tout l'espace authentifié (App.tsx), pas dupliquée par page. src/pages/HomePage.tsx (routée sur /) affiche le planning de la semaine du foyer (GET /planning/current, voir plus haut) avec ses états chargement/erreur/vide/rempli ; Recettes et Liste de courses n'ont pas encore de backend dédié et rendent pour l'instant le même composant ComingSoonPageFoyer & profil (src/pages/HouseholdPage.tsx), lui, est une vraie page (voir section suivante). Détail complet (pourquoi une seule route parente, pourquoi un composant stub partagé) : specs/frontend-architecture.md.

Parcours profil — foyer, régime, allergènes (apps/web)

  • src/features/profile/HouseNameField, DietSelect, AllergySelect : champs contrôlés et "dumb" (reçoivent leurs données en props, ne fetchent rien eux-mêmes), partagés par les deux surfaces ci-dessous. AllergySelect utilise une grille de cases à cocher dans un <fieldset>/<legend> plutôt qu'un <select multiple> — bien plus repérable/tapable, notamment sur mobile. Prend un legend en prop (pas un libellé fixe interne) : le même composant est rendu deux fois par chaque page consommatrice — une fois pour les allergies (AllergyView.kind === "ALLERGY"), une fois pour les intolérances ("INTOLERANCE") — les deux listes filtrées côté client à partir d'un seul GET /reference/allergies, mais la sélection (allergyIds) reste une seule liste d'IDs partagée entre les deux groupes (une seule PATCH /profile/allergies).
  • src/pages/onboarding/ — wizard de 3 écrans lancé une fois juste après l'inscription (OnboardingHouseholdPageOnboardingDietPageOnboardingAllergensPage, routes /onboarding/{foyer,regime,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.
  • src/pages/HouseholdPage.tsx (routée sur /foyer) — mêmes réglages, modifiables à tout moment. Hot saving (retour fonctionnel) : pas de bouton "Enregistrer", chaque section sauvegarde automatiquement peu après la dernière modification — nom du foyer et allergènes/intolérances debouncés (respectivement 600ms/500ms, pour ne pas spammer l'API à chaque frappe/case cochée), régime sauvegardé immédiatement (sélection discrète, pas de saisie continue). Déclenché depuis le handler onChange de chaque champ, jamais depuis un useEffect générique qui observerait la valeur — un tel effect se déclencherait aussi au chargement initial (quand le GET peuple le même state), sans moyen propre de distinguer "vient d'être chargé" de "vient d'être modifié par l'utilisateur".

Piège trouvé en testant dans le navigateur : RedirectIfAuthenticated (garde de /login//signup) réagissait à chaque changement de user, pas seulement à la vérification initiale — un navigate() explicite dans le gestionnaire de soumission d'un formulaire qu'elle protège (ex. SignupPage après signup(), qui met user à jour) entre alors en course avec le propre <Navigate> de la garde. Invisible tant que les deux ciblaient "/", devenu un vrai bug dès que SignupPage a dû rediriger ailleurs (/onboarding/foyer). Fix : la décision de redirection est verrouillée une seule fois, au moment où isLoading passe à false, plus jamais réévaluée après.

Autre piège, même méthode : HouseholdPage initialisait le régime affiché depuis useAuth().user.dietId (un instantané jamais rafraîchi après une modification faite directement via apiClient, qui ne touche pas AuthContext) — revenait à l'ancienne valeur après un aller-retour de navigation SPA sans rechargement complet. Fix : la page fetch son propre profil frais (apiClient.me()) au montage, et AuthContext.refreshUser() (nouveau) est appelé après une sauvegarde réussie du régime pour que le reste de l'app reste cohérent aussi.

Tests Cypress (apps/web/cypress/e2e/*.cy.ts) : 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).

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 — 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, 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.tsapps/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, exemples, comment ajouter un nouveau code d'erreur) : 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 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.

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. Détail : specs/frontend-architecture.md.

Données de test (faker.js)

apps/api utilise @faker-js/faker 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.