Rend opérationnel le squelette TechStep/TechStepMapping/Step.techStepId présent dans le schéma depuis le premier commit mais jamais implémenté : - TechStep gagne un `key` unique (camelCase, même convention que Diet/Unit) ; TechStepMapping gagne un `locale` pour pouvoir porter plusieurs jeux de règles de matching par langue. - Catalogue statique de 25 techniques françaises courantes (Cuire, Frire, Déglacer, Mijoter, ...), chacune associée à une ou plusieurs expressions régulières + un poids, seedées de façon idempotente dans reference-seed-data.ts. - Nouveau moteur de matching (apps/api/src/lib/tech-step-matcher.ts) : normalisation accents/casse (NFD) puis test des expressions, résolution du meilleur match par poids. Pur et testé unitairement. - Câblé dans recipe.service.ts : à la création/modification d'une recette, chaque étape voit son techStepId calculé automatiquement à partir de sa description (locale "fr" en dur pour l'instant, faute de préférence de langue utilisateur dans l'app). - Reste backend-only : StepView n'expose pas encore techStepId, conformément au commentaire existant. - Endpoint GET /reference/tech-steps + TechStepView, en cohérence avec les autres catalogues de référence. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
||
|---|---|---|
| .claude | ||
| .github/workflows | ||
| apps | ||
| packages | ||
| specs | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| .nvmrc | ||
| biome.json | ||
| docker-compose.yml | ||
| package.json | ||
| pnpm-lock.yaml | ||
| pnpm-workspace.yaml | ||
| README.md | ||
| tsconfig.base.json | ||
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é entreapietweb: schémas zod (signupSchema,loginSchema), types (SafeUserProfile), et le contrat d'erreurs (ErrorCodenumé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 pasexpress) :HttpError,ErrorHandlerService. Séparé d'express-toolspré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(adapteErrorHandlerServicedeerror-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 (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.
Prérequis
- Node.js 22 (voir
.nvmrc) - pnpm 10 (
corepack enablepuiscorepack 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 renvoieP1000: Authentication failedalors que les identifiants sont corrects). Dans ce cas, metsPOSTGRES_PORT=5433(ou autre) dans ton.envet adapte le port dans laDATABASE_URLdeapps/api/.env.
Toujours cibler
postgres, jamaisdocker compose up -dtout court. Le mêmedocker-compose.ymldéfinit aussi le serviceapp(voir Déploiement) — celui que Portainer construit en production. Sans nom de service,docker compose up -ddémarre les deux : ça déclenche unpnpm installsur tout le monorepo (donc aussi lecypressd'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 viapnpm 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 incorrectPOST /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 version0.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 que0.31.2fonctionne parfaitement avec la même API. Si tu montes la version, revérifie concrètement (argon2.hash(...)dans unnode -e) avant de merger, unpnpm buildqui 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:apipartagent la même base Postgres locale. Lancerpnpm testvideuser_profiles/house(TRUNCATE ... CASCADE, voirtest-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 (Planningdontstart_date <= aujourd'hui <= finish_date), items inclus avec leur recette résolue en{ id, name }— ounulls'il n'y en a aucun (foyer sans planning en cours, ou profil sans foyer).nullest 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 parapps/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 deCategory.name— la tableallergyelle-même ne porte pas de nom, voirschema.prisma— chaque allergène = uneCategory+ une uniqueAllergysous 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é.GETrenvoienullsi 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_FOUNDsi le profil n'a pas de foyer).PATCH /profile/diet { dietId: number | null }— régime du profil connecté ;nullefface le régime (étape "skippable" du parcours).404 DIET_NOT_FOUNDsidietIdne 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_FOUNDsi 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éeapiClient) : enveloppefetchvers 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 viaVITE_API_URL(voir.env.example).src/features/auth/AuthContext.tsx— état d'auth global ; appelleGET /auth/meau 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é,/loginet/signupredirigent 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 viaErrorMessageService(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 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.
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.AllergySelectutilise une grille de cases à cocher dans un<fieldset>/<legend>plutôt qu'un<select multiple>— bien plus repérable/tapable, notamment sur mobile. Prend unlegenden 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 seulGET /reference/allergies, mais la sélection (allergyIds) reste une seule liste d'IDs partagée entre les deux groupes (une seulePATCH /profile/allergies).src/pages/onboarding/— wizard de 3 écrans lancé une fois juste après l'inscription (OnboardingHouseholdPage→OnboardingDietPage→OnboardingAllergensPage, 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-levelRequireAuth, pas nichées sousAppLayout: 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 handleronChangede chaque champ, jamais depuis unuseEffectgénérique qui observerait la valeur — un tel effect se déclencherait aussi au chargement initial (quand leGETpeuple 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 surmainaussi bien que sur une branche de feature — pas un problème introduit par une modification du code.pnpm --filter web e2efonctionne 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:apien 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.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, 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.