# 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](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](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/shared`, `packages/error-tools` et `packages/express-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) ## 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`). ### 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 # 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 ```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, 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](#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` : ```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) 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](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](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` (`AllergenKind` — `ALLERGY` | `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.ts` — `profile.service.ts` le réutilise aussi. ## 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. - `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](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 `` 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 `ComingSoonPage` — `Foyer & 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](specs/frontend-architecture.md#applayout--sidebar-commune-à-lespace-connecté). ## 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 `
`/`` plutôt qu'un `