docs: met à jour README et specs/ avec l'état réel du code

Le code avait beaucoup évolué depuis la dernière mise à jour de la
documentation (sources externes, import de recettes, planning en
grille, pages de paramètres, thème, tests Cucumber...) sans que
README.md/specs/*.md ne suivent. Tour complet du code (backend +
frontend) et réécriture :

- specs/batch-cooking-modele.md : schéma de données réécrit depuis
  schema.prisma (foyer/admin/invitation, sources, catalogue
  ingrédients/unités, techniques détectées, visibilité des recettes).
- specs/backend-architecture.md : foyer, préférences/goûts, planning,
  référence, sources externes (adaptateurs/registre/sync), matching
  ingrédients/techniques, isolation base de test, suppression de compte.
- specs/frontend-architecture.md : routing complet, sidebar/paramètres,
  thème, planning + picker, catalogue + import, composants UI partagés,
  tests Cypress+Cucumber.
- specs/batch-cooking-architecture.md : module Import passe de TODO à
  implémenté.
- specs/error-handling.md : liste complète des ~19 codes d'erreur.
- README.md : réécriture pour refléter tout ce qui précède, plus la
  note (dangereusement obsolète) sur le partage base de test/dev — le
  fix existe déjà (apps/api/.env.test), la doc décrivait encore le bug.
This commit is contained in:
Nicolas 2026-08-21 08:24:58 +02:00
parent 85fd9bae7d
commit 4f509865c1
6 changed files with 1602 additions and 420 deletions

392
README.md
View file

@ -4,13 +4,21 @@
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.
- `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 :
@ -19,11 +27,16 @@ Monorepo pnpm workspaces :
(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` 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
`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
@ -49,6 +62,9 @@ sont pas définis dans `.env` — pas de valeur par défaut en dur dans les fich
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
@ -80,6 +96,10 @@ 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
# Backend (http://localhost:3000)
pnpm dev:api
@ -105,14 +125,34 @@ pnpm dev:web
## 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
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 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`).
La CI GitHub Actions (`.github/workflows/ci.yml`) exécute quatre jobs indépendants (`lint`, `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. 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.
## Déploiement
@ -126,6 +166,16 @@ client) via `FRONTEND_DIST_DIR` — voir `packages/express-tools/src/express-ser
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.
`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
@ -166,10 +216,14 @@ Inscription (création de profil + foyer) et connexion, JWT dans un cookie httpO
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. 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é).
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 —
@ -184,171 +238,178 @@ Les tests (Mocha) tournent avec un coût argon2 réduit
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.
## 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/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`.
- `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.
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).
Le planning d'une semaine est créé à la demande (première recette ajoutée),
jamais en avance.
## Données de référence — régimes & allergènes (apps/api)
## Recettes — catalogue, favoris, import depuis une source externe (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).
- `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).
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.
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 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.
## Données de référence — régimes, allergènes, ingrédients, unités, techniques (apps/api)
`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.
- `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.
Liste des 14 allergènes : ceux du règlement UE 1169/2011 (annexe II) — liste
standard, pas inventée.
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).
**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 — régime, allergènes, ingrédients détestés (apps/api)
## Foyer & profil — nom, régime, allergènes (apps/api)
Nécessitent tous une session (`requireAuth`) — données propres à
l'utilisateur/au foyer, pas des données de référence.
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.
`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`), auparavant dupliqué dans `auth.service.ts` et
`require-auth.ts``profile.service.ts` le réutilise aussi.
(`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.
- `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
- `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).
## Accueil, sidebar & sections (apps/web)
## Sidebar, planning, recettes & 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](specs/frontend-architecture.md#applayout--sidebar-commune-à-lespace-connecté).
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`).
## Parcours profil — foyer, régime, allergènes (apps/web)
- **`/``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).
- `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 (`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-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".
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).
**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.
## Parcours d'inscription — onboarding (apps/web)
**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.
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`) : 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).
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
@ -363,8 +424,9 @@ couvert par la suite Mocha d'`apps/api` (contre une vraie base).
## 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
**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`
@ -374,8 +436,8 @@ 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](specs/error-handling.md).
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 —
@ -391,9 +453,21 @@ voir [specs/backend-architecture.md](specs/backend-architecture.md#packagesshare
**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 :
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

View file

@ -63,9 +63,11 @@ unknown>` (plus strict, ce qui serait la contrainte "par défaut" attendue).
Raison concrète : une `interface` sans signature d'index (ex. `AuthLocals`
dans `require-auth.ts`) échoue la contrainte générique sous `unknown` alors
qu'elle s'assigne très bien à `Response`'s own `Locals` param directement —
observé en committant `wrapAsyncHandler<unknown, AuthLocals>(...)` sur
`GET /planning/current` (premier endpoint à combiner authentification et
handler async). `any` referme cet écart structurel ; les deux occurrences
observé en committant `wrapAsyncHandler<unknown, AuthLocals>(...)` sur ce qui
était alors `GET /planning/current` (premier endpoint à combiner
authentification et handler async — la route a depuis évolué vers
`GET /planning?date=`, voir plus bas, mais la contrainte générique qu'elle a
mise au jour n'a pas bougé). `any` referme cet écart structurel ; les deux occurrences
portent un commentaire `biome-ignore lint/suspicious/noExplicitAny` expliquant
pourquoi (le lint interdit `any` par défaut, à raison, mais ce cas précis
imite un type de la lib standard Express qui fait le même choix).
@ -146,6 +148,396 @@ de recette, tous deux encore à construire, en auront probablement).
---
## `house` — foyer, adminship, code d'invitation, sources activées
Router `/house` (`apps/api/src/modules/house/house.routes.ts` +
`house.service.ts`), toutes les routes derrière `requireAuth`.
| Route | Fonction | Détail |
|---|---|---|
| `GET /house/current` | `getCurrentHouse` | `HouseView \| null` |
| `PATCH /house/current` | `renameHouse` | `{ name }`, ouvert à **tout membre**, pas seulement l'admin |
| `POST /house/` | `createHouse` | 201, crée le foyer avec l'appelant comme `adminId`, génère le code d'invitation |
| `POST /house/join` | `joinHouse` | `{ inviteCode }` (8 caractères exactement) |
| `POST /house/leave` | `leaveCurrentHouse` | 204 |
| `DELETE /house/current` | `deleteHouse` | 204, réservé à l'admin |
| `GET /house/current/sources` | `getHouseSourceIds` | `number[]` d'ids `Source` activés |
| `PATCH /house/current/sources` | `updateHouseSources` | `{ sourceIds: number[] }`, remplace (pas de fusion) |
| `DELETE /house/members/:memberId` | `removeMember` | réservé à l'admin, ne peut pas cibler soi-même |
**Génération du code d'invitation** — `generateInviteCode()` tire 8 caractères
dans `INVITE_CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"` : majuscules +
chiffres, **sans** les caractères visuellement ambigus (`0`/`O`/`1`/`I`) — pensé
pour être lu sur un écran et retapé sur un autre. Les collisions ne sont pas
pré-vérifiées (33⁸ possibilités, astronomiquement improbable) mais gérées par
réessai (jusqu'à 5 tentatives) sur la violation de contrainte unique Postgres
(`P2002`) plutôt que supposées impossibles.
**Départ et transfert d'adminship** (`leaveCurrentHouse`) — si le membre qui
part est l'admin, l'adminship est transférée au membre restant le plus ancien
(id le plus petit) ; s'il ne reste personne, le foyer est supprimé
(plannings en cascade). **Un foyer ne peut jamais rester sans admin.** Cette
fonction est aussi appelée par `auth.service.ts`'s `deleteAccount` avant la
suppression du profil.
**Sources activées** (`HouseSource`, table de jointure `houseId`/`sourceId`) —
`getHouseSourceIds`/`updateHouseSources` en gèrent le contenu. **Aucune ligne
au départ pour un nouveau foyer** — opt-in, pas "aucune préférence exprimée".
`recipe.service.ts`'s `listRecipes` filtre chaque onglet du catalogue contre cet
ensemble (voir plus bas). Détail complet du flux de sources :
[batch-cooking-architecture.md](./batch-cooking-architecture.md), section "Module « Import d'une recette »".
**Codes d'erreur** : `HOUSE_NOT_FOUND` (4041), `ALREADY_HAS_HOUSE` (4020),
`INVITE_CODE_NOT_FOUND` (4044), `NOT_HOUSE_ADMIN` (4030), `SOURCE_NOT_FOUND`
(4049, `sourceId` inconnu dans `updateHouseSources`).
Note : si le `houseId` d'un profil pointe vers un foyer qui n'existe plus (état
interne incohérent), l'échec du lookup interne lève une `Error` brute (→ 500),
volontairement **pas** une `HttpError` — ce cas signale une incohérence
interne, pas un "not found" normal qu'un client pourrait déclencher.
---
## `preferences` (thème) et `/profile/disliked-ingredients` (goûts)
**`preferences`** (`preferences.routes.ts`/`.service.ts`, `/preferences`,
`requireAuth`) : `GET /preferences``{ theme }` (défaut `"SYSTEM"` si aucune
ligne `UserPreference` n'existe encore — pas de création à la volée pour un
simple `GET`) ; `PATCH /preferences``{ theme: "LIGHT"|"DARK"|"SYSTEM" }`,
**upsert** de `UserPreference` (`userProfileId` est à la fois clé primaire et
étrangère, 1-1 strict avec `UserProfile`).
**Ingrédients détestés** vivent sous `/profile`, **pas** `/preferences` :
`GET`/`PATCH /profile/disliked-ingredients` (`profile.routes.ts`), remplace
(pas de fusion), chaque id validé contre `Ingredient` (404
`INGREDIENT_NOT_FOUND` sinon). Explicitement distinct de `GET`/`PATCH
/profile/allergies` : une préférence de **goût**, jamais un avertissement de
sécurité — voir la note sur `UserProfileDislikedIngredient` dans
[batch-cooking-modele.md](./batch-cooking-modele.md#ingredients-ingredient-et-catalogue-associé).
Géré depuis `PreferencesPage` côté web (`/parametres/preferences`).
---
## `planning` — semaine, item, portions, import à la volée
Router `/planning` (`planning.routes.ts`/`.service.ts`), `requireAuth`.
- `GET /planning?date=YYYY-MM-DD``getPlanningForDate` → `PlanningView |
null`. `null` recouvre **deux** états normaux confondus : pas de foyer, ou
aucun `Planning` ne couvre cette date — jamais une erreur.
- `POST /planning/items``addPlanningItem`, 201. Body `addPlanningItemSchema`
= `{ date, weekDay, meal, recipeId, portions }` (`weekDay`/`meal` sont des
enums `WEEK_DAYS`/`MEALS` de `packages/shared`, réellement validés ici — pas
juste une convention documentée). `recipeId` doit exister et être visible par
l'appelant (`assertRecipeVisible`, `recipe.service.ts`) → 404
`RECIPE_NOT_FOUND` sinon.
- `DELETE /planning/items/:id``removePlanningItem`, 204.
**`PlanningItem.portions`** est saisi **indépendamment** de `Recipe.portions`
(le rendement "tel qu'écrit" de la recette) — un créneau peut mettre à
l'échelle. Le picker web pré-remplit depuis `Recipe.portions` mais envoie
toujours sa propre valeur.
**Création de la semaine** — `findOrCreatePlanningForWeek` est le seul
endroit qui crée une ligne `Planning`, retrouvée par `startDate` exact (un
lundi, via `@batch-cooking/date-tools`'s `getWeekStart`), lundi→dimanche.
Lookup et création **ne sont pas transactionnels** ensemble — pas de contrainte
unique `(houseId, startDate)` — une course pourrait donc créer deux lignes pour
la même semaine vide ; accepté à l'échelle actuelle du projet plutôt que
d'ajouter une migration + boucle retry-on-conflict.
**"Ajouter au planning déclenche l'import si besoin"** — c'est une
**orchestration côté frontend**, pas une fonctionnalité backend combinée : il
n'existe aucune route "importer + ajouter au planning" en un seul appel. Le
web (`RecipePickerDialog.tsx`) appelle simplement `POST
/sources/:sourceKey/import/:externalId` (voir plus bas) puis `POST
/planning/items` l'un après l'autre — les deux routes existaient déjà et se
suffisent à elles-mêmes, aucun changement backend n'a été nécessaire pour cette
feature. Détail du flux complet :
[batch-cooking-architecture.md](./batch-cooking-architecture.md), section "Module « Import d'une recette »".
---
## `reference` — catalogues publics (pas de session requise)
Router `/reference` (`reference.routes.ts`/`.service.ts`) — **toutes les
routes sont publiques**, 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'une session n'existe.
| Route | Contenu |
|---|---|
| `GET /reference/diets` | régimes alimentaires, triés par `key` |
| `GET /reference/allergies` | allergènes/intolérances (`Allergy` → `Category{key, kind}`) |
| `GET /reference/ingredients` | catalogue d'ingrédients, avec `allergens[]`/`diets[]` résolus |
| `GET /reference/units` | unités de mesure, `toBaseFactor` (Decimal → number) |
| `GET /reference/tech-steps` | techniques (pas encore consommé par l'UI recette elle-même — groundwork) |
| `GET /reference/sources` | sources d'import enregistrées, triées par **`name`** (pas `key` — c'est le vrai libellé affiché, un nom propre, pas une clé à traduire) |
Toutes seedées via `apps/api/src/db/reference-seed-data.ts` (voir le README
pour la commande de seed) — jamais créées/éditées/supprimées via l'API
applicative.
---
## Sources externes — adaptateur, registre, synchronisation
Le module « Import d'une recette » du plan initial (voir
[batch-cooking-architecture.md](./batch-cooking-architecture.md)) est
implémenté. Pièces principales :
### `RecipeSourceAdapter` (`apps/api/src/lib/recipe-source-adapter.ts`)
Contrat générique que chaque source concrète implémente : `list(params)`
(parcours paginé, `query`/`cursor` optionnels), `fetchDetail(externalId)`
(contenu brut d'un item), `parse(raw)` (pur, synchrone, testable sans réseau —
transforme le brut en `ParsedRecipe` normalisé : ingrédients/étapes en texte
libre, pas encore résolus contre les catalogues). `official` (API officielle
vs scraping non-officiel) et `locale` (langue du contenu produit par la
source, pas une préférence utilisateur) n'ont pas de valeur par défaut —
chaque auteur d'adaptateur doit choisir consciemment. `markAlreadyImported`
annote une page de résultats en comparant les `externalId` à un ensemble déjà
importé — étape pure et séparée, l'adaptateur ne connaît jamais la base de
données.
### Registre (`recipe-source-registry.ts`)
Map en mémoire `key → adapter`, volontairement **pas** persistée en base — un
adaptateur *est* du code (la logique de fetch/parse d'un site ne peut pas
vivre dans une ligne de base). `registerRecipeSource` lève si la clé est déjà
prise (deux adaptateurs qui s'écraseraient silencieusement serait un bug).
`clearRecipeSources` n'est utilisée que par les tests, pour l'isolation
(même rôle que `resetDatabase()` côté base).
### Synchronisation (`apps/api/src/db/recipe-source-sync.ts`)
`syncRecipeSources(prisma)` upsert une ligne `Source` par adaptateur du
registre — **ne supprime jamais** une `Source` dont l'adaptateur a disparu du
registre (une recette déjà importée doit continuer à citer sa source).
`findImportedRecipeIds(prisma, sourceKey, externalIds)` renvoie une `Map
<externalId, recipeId>` des items déjà importés (map vide si `sourceKey` n'a
pas encore de ligne `Source` — jamais une erreur).
**Quand ça tourne** :
- `server.ts` appelle `registerAllRecipeSources()` au démarrage (peuple
uniquement le registre en mémoire de **ce** processus).
- `prisma/seed.ts` (dev, `pnpm --filter api prisma:seed` /
`prisma migrate reset`) enregistre les adaptateurs puis seed + synchronise.
- `apps/api/src/scripts/seed-runtime.ts` — équivalent pour l'image de
production, invoqué dans le `CMD` du `Dockerfile` :
`prisma migrate deploy && node dist/scripts/seed-runtime.js && node
dist/server.js`. **Nécessaire** car chaque maillon du `CMD` est un
**processus `node` séparé** : sans cette étape dédiée, le registre peuplé par
`server.ts` ne touchait jamais la base en production, et `GET
/reference/sources` renvoyait silencieusement `[]` (toute la section
"Sources" de `HouseholdSettingsPage` restait invisible) — bug corrigé par le
commit "synchronise les sources en base au démarrage de l'image de prod".
Vit sous `src/` (pas `prisma/`) précisément pour être compilé dans `dist` par
`tsc`, l'image runtime n'embarquant que `dist`, pas `src`.
- `test-support/reset-db.ts`'s `resetDatabase()` appelle aussi
`syncRecipeSources` en dernier, après le seed de référence.
### Adaptateurs concrets (`apps/api/src/sources/`)
- **`the-meal-db.ts`** — `key: "theMealDb"`, `official: true`, `locale: "en"`.
API publique gratuite (`https://www.themealdb.com/api/json/v1/${API_KEY}`,
`THE_MEAL_DB_API_KEY` env var, défaut `"1"` = clé de test partagée
documentée par TheMealDB). `list()` n'a qu'une recherche (`/search.php?s=`),
pas de vrai "tout parcourir" côté gratuit — une requête vide renvoie un
petit échantillon fixe (~25 recettes), non paginé (`nextCursor` toujours
`null`). `parse()` reconstruit les ingrédients depuis les paires plates
`strIngredient1..20`/`strMeasure1..20`.
- **`json-ld-recipe.ts`** — `key: "jsonLdRecipe"`, `official: false`,
scraper générique schema.org/`Recipe` (extraction regex des blocs
`<script type="application/ld+json">`, gère objet nu / tableau de types
mixtes / wrapper `@graph`). **Volontairement pas enregistré** dans
`sources/index.ts` (commit "la source générique JSON-LD n'apparaît plus
comme source") : c'est un parseur générique pensé pour être spécialisé par
un futur adaptateur dédié à un site précis, pas une `Source` activable en
tant que telle — personne ne peut "faire confiance" à un mécanisme de
parsing générique de la même façon qu'à un site nommé. `list()` renvoie
toujours vide (pas de catalogue à parcourir) ; `fetchDetail`'s `externalId`
est directement l'URL cible, pas un id issu d'un `list()` préalable.
`apps/api/src/sources/index.ts`'s `registerAllRecipeSources()` n'enregistre
aujourd'hui que TheMealDB — appelé explicitement par `server.ts`/`seed*`,
**jamais** par `app.ts` (que chaque test récupère via supertest ; y enregistrer
un adaptateur réel ferait dépendre sa présence de l'ordre des tests).
### Endpoints (`apps/api/src/modules/sources/`, `/sources`, `requireAuth`)
| Route | Fonction |
|---|---|
| `GET /sources/:sourceKey/browse?query=&cursor=` | `browseSource` — une page du catalogue de la source, chaque item annoté `alreadyImported`/`recipeId` |
| `GET /sources/:sourceKey/preview/:externalId` | `previewSourceItem` — traduit entièrement un item en `RecipeImportDraftView` **sans le sauvegarder** |
| `POST /sources/:sourceKey/import/:externalId` | `importSourceItem` — finalise l'import, `input` = un `CreateRecipeInput` normal (mêmes règles qu'une création manuelle) |
`sourceKey` doit à la fois exister comme `Source` **activée pour le foyer**
(`HouseSource`) et avoir un adaptateur toujours enregistré (les deux peuvent
diverger — voir `syncRecipeSources` plus haut) ; l'un ou l'autre manquant
ressort en 404 `SOURCE_NOT_FOUND`, sans distinguer les deux cas côté client.
`previewSourceItem`/`importSourceItem` réutilisent exactement les mêmes
briques que la sauvegarde normale d'une recette (`translateRecipeIngredients`,
`matchTechStepSpans` — voir plus bas), la seule différence étant que preview
ne persiste rien.
---
## Recettes — visibilité, catalogue, traduction/matching
### `recipe` module (`/recipes`, `requireAuth`)
| Route | Fonction |
|---|---|
| `GET /recipes?tab=&search=&suitableForHousehold=&ingredientIds=&dietIds=` | `listRecipes` |
| `GET /recipes/:id` | `getRecipe` |
| `POST /recipes` | `createRecipe` |
| `PATCH /recipes/:id` | `updateRecipe` (remplacement complet, pas de fusion partielle) |
| `DELETE /recipes/:id` | `deleteRecipe` (409 `RECIPE_IN_USE` si référencée par un `PlanningItem`) |
| `POST`/`DELETE /recipes/:id/favorite` | `addFavorite`/`removeFavorite` (idempotents) |
**Visibilité** (`RecipeVisibility` — `PERSONAL`/`HOUSE`/`PUBLIC`, voir
[batch-cooking-modele.md](./batch-cooking-modele.md)) contrôle uniquement la
**lecture** — l'édition/suppression reste toujours réservée à l'auteur
(`NOT_RECIPE_AUTHOR`, 403). L'auteur voit toujours sa propre recette, quelle
que soit sa visibilité actuelle (même une `HOUSE` recipe après avoir quitté ce
foyer). Un id invisible pour l'appelant ressort en 404, jamais 403 — son
existence ne doit pas fuiter.
**Quatre onglets réels** (`RecipeTab` — `favoris`/`perso`/`foyer`/`publique`,
pas de `"toutes"` : toute recette visible tombe sous exactement un des trois
premiers via sa propre `visibility`, `favoris` est un filtre transverse
orthogonal). Filtres optionnels en plus (`ListRecipesFilters`) : `search`,
`suitableForHousehold` (recette qui évite tous les allergènes déclarés du
foyer et respecte le régime de chaque membre qui en a un — calculé
serveur-side, jamais exposé en données brutes par membre : les
allergies/régimes d'un membre restent privés, même logique que la visibilité
404-jamais-403), `ingredientIds`/`dietIds` (ET logique — la recette doit
porter *chacun*, pas au moins un).
**Filtrage par sources activées** (`sourceVisibilityWhere`) — appliqué à
**chaque** onglet : une recette manuelle (`sourceId` `null`) est toujours
visible, seule une recette issue d'une source externe non activée pour le
foyer du viewer est masquée. Sans foyer, rien n'est activé par construction
(pas de ligne `HouseSource` à référencer) — toute recette sourcée est
invisible tant que le profil n'a pas rejoint/créé de foyer.
### Détection des techniques — `tech-step-matcher.ts`
`normalizeText` : décomposition NFD + suppression des diacritiques combinants
+ minuscule (ex. "Déglacer" → "deglacer"), appliquée à la fois au texte de
l'étape et aux expressions des mappings — permet d'écrire les expressions
françaises accentuées naturellement dans `reference-seed-data.ts` tout en
matchant indépendamment des accents/de la casse.
`matchTechStepSpans(description, mappings)` — algorithme en 4 étapes :
1. teste chaque `expression` (source de regex) contre la description
normalisée ;
2. pour une même technique, ne garde que le meilleur candidat (`weight` le
plus élevé, égalité départagée par la position la plus précoce) ;
3. entre techniques **différentes** dont les spans se chevauchent encore
(ex. `cook` générique matchant dans "cuire au four", plus spécifique
`bake`), résolution gloutonne par poids décroissant — un candidat n'est
accepté que s'il ne chevauche aucun déjà accepté (ce qui permet à des
techniques non-chevauchantes de coexister dans une même phrase, tout en
éliminant un match redondant) ;
4. tri final par position de départ.
`start`/`end` renvoyés sont des offsets dans le texte **normalisé**, réutilisés
tels quels contre le texte **original** pour le surlignage — repose sur
l'hypothèse documentée (et acceptée) que la décomposition NFD n'augmente
jamais le nombre de caractères d'un texte français en pratique.
`loadTechStepMappingRules(locale)` est la seule pièce qui touche la base — à
appeler une fois par requête, pas par étape.
### Résolution ingrédients/unités — `ingredient-matcher.ts`
**Anglais uniquement** aujourd'hui (commit "matching anglais pour les tech
steps et les ingrédients") :
- `matchIngredientName` tokenise (minuscule + suppression diacritiques +
découpage sur non-lettres + un "stemming" naïf de pluriel, pas un vrai
stemmer linguistique) le texte libre et chaque libellé du catalogue
(`INGREDIENT_LABELS_EN`, `packages/shared`), cherche chaque libellé comme
**sous-séquence contiguë de tokens** dans le nom — le plus long (le plus
spécifique) l'emporte ("chicken breast" bat "chicken"), égalité départagée
par l'id le plus petit (déterminisme).
- `matchUnit` compare des tokens entiers (pas de sous-chaîne — "cup" ne doit
pas matcher à l'intérieur d'un mot plus long sans rapport).
- `extractQuantity` extrait un nombre/une fraction/un nombre mixte en tête de
texte libre (`"1 1/2"` → 1.5) si la source n'a pas fourni de quantité
structurée.
### Orchestration — `recipe-translation.ts`
`translateRecipe(recipe, locale)` — matche toujours les techniques contre
`locale`, mais ne charge/matche les catalogues ingrédient/unité **que si
`locale === "en"`** ; pour toute autre langue, `ingredientId`/`unitId` restent
`null` sur chaque ligne (dégradation "pas de données dans cette langue").
Piège documenté explicitement : une source anglaise (TheMealDB) traduite
contre les mappings `"fr"` obtiendrait un `techStepIds` **vide** sur chaque
étape (aucune règle de mapping anglaise n'existe encore) — cette étape
n'invente rien. `sources.service.ts`'s `previewSourceItem` (brouillon, non
sauvegardé) et `recipe.service.ts`'s `createRecipe`/`updateRecipe`/
`createImportedRecipe` (sauvegarde réelle) partagent exactement les mêmes
briques.
---
## Isolation de la base de test
`pnpm test` (`apps/api`) exécute une `TRUNCATE ... CASCADE` sur presque tout
le schéma **avant chaque test** (`test-support/reset-db.ts`'s
`resetDatabase()`) — auparavant partagée avec la base de dev via un seul
`.env`, ce qui a un jour vidé un vrai foyer/compte de dev en cours de test
(irrécupérable, `TRUNCATE`, pas de sauvegarde). Fix, trois pièces :
1. `config/env.ts` charge `.env.test` au lieu de `.env` quand
`NODE_ENV=test` (posé par `cross-env` dans le script `"test"` de
`apps/api/package.json`, avant que ce module ne s'exécute).
2. `resetDatabase()` appelle désormais `assertRunningAgainstTestDatabase()` en
tout premier : lève si `NODE_ENV !== "test"`, **et** si `DATABASE_URL` ne
contient ni `"test"` ni `"ci"` — la branche `"ci"` a dû être ajoutée après
coup, la base de CI s'appelant `batchcooking_ci` (pas `batchcooking_test`),
ce que le garde-fou initial rejetait à tort (282 tests en échec). Le seul
nom que ce garde-fou doit encore rejeter est la vraie base de dev,
`batchcooking`.
3. Nouveaux fichiers `apps/api/.env.test` (local, gitignored) et
`.env.test.example` (committé, template + commandes pour créer la base) —
même serveur/identifiants Postgres que `.env`, juste un nom de base
différent.
**À faire une fois par machine** avant `pnpm test` : créer `apps/api/.env.test`
pointant vers une base **différente** (ex. `batchcooking_test`) — voir
`.env.test.example` pour les commandes exactes (`CREATE DATABASE`, `prisma
migrate deploy`). Ce fichier n'est pas créé automatiquement, contrairement à
`.env`.
En CI (`.github/workflows/ci.yml`), le job `test` provisionne son propre
service `postgres:16-alpine` (`POSTGRES_DB: batchcooking_ci`) et définit
`DATABASE_URL` au niveau du workflow — exactement le cas que la branche
`"ci"` du garde-fou existe pour accepter.
---
## Compte — suppression, pas encore de changement d'email/mot de passe
`DELETE /auth/me` (`auth.routes.ts`, `requireAuth`) — supprime définitivement
le profil courant après **revérification du mot de passe**
(`deleteAccountSchema`, `packages/shared/src/schemas/account.ts`) : 401
`INVALID_CREDENTIALS` si le mot de passe ne correspond pas (`argon2.verify`),
sinon `leaveCurrentHouse` (gère transfert d'adminship/suppression du foyer,
voir plus haut) puis suppression du profil (cascade `UserProfileAllergy` via
`onDelete: Cascade`). Aucune route de changement d'email/mot de passe n'existe
encore — `AccountSettingsPage` (web) n'affiche l'identité qu'en lecture seule.
`UserProfile.tokenVersion` (bump prévu pour invalider les JWT déjà émis, ex. à
un futur changement de mot de passe) existe déjà dans le schéma et est vérifié
à chaque requête par `requireAuth`, mais **rien ne l'incrémente encore**
c'est une infrastructure posée à l'avance, pas encore câblée à une
fonctionnalité réelle.
---
## Pas de fichiers `.d.ts` écrits à la main
Voir [frontend-architecture.md](./frontend-architecture.md#note-sur-les-fichiers-dts)

View file

@ -15,17 +15,15 @@ L'application repose sur une architecture **client-serveur** classique :
```mermaid
flowchart TB
subgraph SERVER["Server"]
WS["Web socket"]
API["API"]
API["API (REST)"]
CALC["Calcul batch-cooking<br/><i>(TODO)</i>"]
IMPORT["Import d'une recette"]
IMP1["Import depuis source"]
IMP2["Traduction en étapes"]
IMP3["Sauvegarde"]
IMPORT["Import d'une recette<br/><i>implémenté</i>"]
IMP1["Import depuis source<br/>(RecipeSourceAdapter)"]
IMP2["Traduction en étapes<br/>(ingrédients + techniques)"]
IMP3["Sauvegarde<br/>(sur ajout au planning, ou revue manuelle)"]
DB[("Database<br/>PostgreSQL")]
IMPORT --> IMP1 --> IMP2 --> IMP3 --> DB
CALC --> WS
end
C1["Client 1"]
@ -35,41 +33,66 @@ flowchart TB
API <--> C1
API <--> C2
API <--> C3
WS --> C1
WS --> C2
WS --> C3
style SERVER fill:none,stroke:#888,stroke-width:1px
```
*(Le canal websocket envisagé dans la conception d'origine pour le calcul
batch-cooking temps réel n'existe pas encore — rien à documenter tant que ce
module reste TODO ; voir la note plus bas.)*
---
## Composants
### API
Point d'entrée principal pour les échanges entre les clients et le serveur (requêtes classiques).
### Web socket
Canal de communication temps réel entre le serveur et les clients connectés.
Point d'entrée principal pour les échanges entre les clients et le serveur — REST classique, `requireAuth` (cookie JWT httpOnly) sur toute route qui n'est pas une donnée de référence publique. Détail complet des modules : [backend-architecture.md](./backend-architecture.md).
### Module « Calcul batch-cooking »
Logique de calcul du batch-cooking (optimisation du planning/des recettes selon le planning). **Statut : TODO — reste à développer.**
Logique de calcul du batch-cooking (optimisation du planning/des recettes selon le planning). **Statut : TODO — reste à développer**, avec `packages/shared`'s `assertIsNever` déjà en place comme outil prêt à l'emploi pour ce futur module (voir [backend-architecture.md](./backend-architecture.md#packagesshared--assertisnever)).
### Module « Import d'une recette »
Pipeline d'ajout d'une recette, en trois étapes :
1. **Import depuis source** — récupération de la recette (via `sources`)
2. **Traduction en étapes** — découpage en `step` / `tech_step`
3. **Sauvegarde** — persistance en base de données
**Statut : implémenté.** Pipeline en trois étapes, comme prévu à la conception :
1. **Import depuis source** — chaque source concrète (aujourd'hui : TheMealDB,
API officielle) implémente le contrat `RecipeSourceAdapter`
(`apps/api/src/lib/recipe-source-adapter.ts` — `list`/`fetchDetail`/`parse`),
enregistré dans un registre en mémoire et synchronisé vers la table
`sources`. Un scraper générique JSON-LD/schema.org (`json-ld-recipe.ts`)
existe aussi, en briques réutilisables par un futur adaptateur dédié à un
site précis — volontairement pas lui-même une source sélectionnable.
2. **Traduction en étapes**`recipe-translation.ts` orchestre la résolution
des ingrédients/unités en texte libre contre les catalogues de référence
(`ingredient-matcher.ts`, anglais uniquement pour l'instant) et la
détection des techniques (`tech-step-matcher.ts`, matching par expressions
régulières pondérées, avec résolution des chevauchements).
3. **Sauvegarde** — persistance en base (`recipe.service.ts`'s
`createImportedRecipe`), déclenchée soit automatiquement quand un import
"complet" (aucune ligne à arbitrer) est ajouté au planning, soit après une
revue manuelle (ingrédients non résolus complétés à la main).
Détail complet du flux (endpoints, algorithmes, décisions produit) :
[backend-architecture.md](./backend-architecture.md#sources-externes--adaptateur-registre-synchronisation)
côté API, [frontend-architecture.md](./frontend-architecture.md#recettes--catalogue-favoris-import-depuis-une-source-externe)
côté web.
### Database (PostgreSQL)
Stockage de l'ensemble des données de l'application (voir le modèle de données pour le détail des tables).
Stockage de l'ensemble des données de l'application (voir
[batch-cooking-modele.md](./batch-cooking-modele.md) pour le détail des
tables — le modèle a beaucoup grandi par rapport à la conception d'origine :
sources externes, catalogue d'ingrédients/unités normalisé, techniques
détectées, favoris, visibilité des recettes).
---
## Notes
- Le module de calcul batch-cooking est le principal chantier restant côté serveur (TODO).
- Le websocket est utilisé pour la communication temps réel, en complément de l'API.
- Le module de calcul batch-cooking (et la « Liste de courses », son
débouché naturel — `ShoppingListPage` reste un stub côté web) est le
principal chantier restant côté serveur.
- Le canal websocket envisagé pour la communication temps réel n'a pas encore
été construit — rien ne le remplace aujourd'hui (pas de polling), à
reconsidérer au moment d'attaquer le calcul batch-cooking.
---
@ -78,5 +101,7 @@ Stockage de l'ensemble des données de l'application (voir le modèle de donnée
Documentation d'implémentation (ajoutée au fil des features, complète ce document
conceptuel sans le remplacer) :
- [error-handling.md](./error-handling.md) — contrat d'erreurs partagé entre l'API et le client
- [batch-cooking-modele.md](./batch-cooking-modele.md) — schéma de données complet
- [backend-architecture.md](./backend-architecture.md) — organisation d'`apps/api` (modules, sources, matching)
- [frontend-architecture.md](./frontend-architecture.md) — organisation d'`apps/web`
- [error-handling.md](./error-handling.md) — contrat d'erreurs partagé entre l'API et le client

View file

@ -1,16 +1,26 @@
# Modèle de données — Projet Batch-cooking
> Documentation du schéma de données de l'application de planification de batch-cooking.
> Source de vérité : `apps/api/prisma/schema.prisma` (abondamment commenté en anglais,
> chaque écart avec ce document ou avec le doc spec d'origine y est expliqué en place —
> ce fichier en est un résumé navigable, pas un remplacement).
---
## Vue d'ensemble
Le modèle s'articule autour de trois grands pôles :
Le modèle s'articule désormais autour de cinq grands pôles (le pôle « Recettes »
a beaucoup grossi depuis la première version : sources externes, catalogue
d'ingrédients/unités normalisé, techniques détectées, visibilité) :
- **Utilisateurs & foyer**`user_profiles`, `house`, `diet`, `allergy`, `category`
- **Planification**`planning`, `planning_item`
- **Recettes**`recipe`, `ingredients`, `step`, `tech_step`, `tech_step_mapping`, `sources`
- **Utilisateurs & foyer**`UserProfile`, `House`, `Diet`, `Category`, `Allergy`,
`UserPreference` (thème)
- **Planification**`Planning`, `PlanningItem`
- **Recettes**`Recipe`, `RecipeIngredient`, `Step`, `TechStep`, `TechStepMapping`,
`StepTechStep`, `RecipeDiet`, `RecipeFavorite`
- **Sources externes**`Source`, `HouseSource`
- **Catalogue ingrédients/unités**`Ingredient`, `Unit`, `IngredientDiet`,
`IngredientAllergy`, `UserProfileDislikedIngredient`
---
@ -18,203 +28,344 @@ Le modèle s'articule autour de trois grands pôles :
```mermaid
erDiagram
USER_PROFILES }o--|| HOUSE : "vit dans"
USER_PROFILES }o--|| DIET : "suit"
USER_PROFILE }o--o| HOUSE : "membre de"
HOUSE ||--|| USER_PROFILE : "admin (adminId)"
USER_PROFILE }o--o| DIET : "suit"
USER_PROFILE ||--o| USER_PREFERENCE : "thème"
HOUSE ||--o{ PLANNING : "planifie"
PLANNING ||--o{ PLANNING_ITEM : "contient"
PLANNING_ITEM }o--|| RECIPE : "utilise"
RECIPE }o--|| SOURCES : "vient de"
RECIPE }o--o| SOURCE : "vient de"
RECIPE }o--|| USER_PROFILE : "auteur (authorId)"
RECIPE }o--o| HOUSE : "foyer auteur (authorHouseId)"
HOUSE ||--o{ HOUSE_SOURCE : "sources activées"
SOURCE ||--o{ HOUSE_SOURCE : ""
CATEGORY ||--o{ ALLERGY : "classe"
USER_PROFILES }o--o{ ALLERGY : "a"
RECIPE }o--o{ INGREDIENTS : "compose de"
RECIPE }o--o{ STEP : "compose de"
STEP }o--|| TECH_STEP : "utilise"
TECH_STEP ||--o{ TECH_STEP_MAPPING : "mappe"
INGREDIENTS }o--|| RECIPE : "recette alternative"
USER_PROFILE }o--o{ ALLERGY : "a"
USER_PROFILE }o--o{ RECIPE : "favoris"
USER_PROFILE }o--o{ INGREDIENT : "n'aime pas"
RECIPE ||--o{ RECIPE_INGREDIENT : "compose de"
RECIPE_INGREDIENT }o--|| INGREDIENT : ""
RECIPE_INGREDIENT }o--|| UNIT : ""
RECIPE }o--o{ DIET : "tags régime (RecipeDiet)"
INGREDIENT }o--o{ DIET : "compatible avec"
INGREDIENT }o--o{ ALLERGY : "contient"
RECIPE ||--o{ STEP : "compose de"
STEP }o--o{ TECH_STEP : "techniques détectées (StepTechStep)"
TECH_STEP ||--o{ TECH_STEP_MAPPING : "règles de détection"
USER_PROFILES {
USER_PROFILE {
int id PK
string first_name
string last_name
string email
int house_id FK
int diet_id FK
string firstName
string lastName
string email UK
string passwordHash
int tokenVersion
int houseId FK
int dietId FK
}
HOUSE {
int id PK
string name
int adminId FK
string inviteCode UK
}
PLANNING {
int id PK
date start_date
date finish_date
int house_id FK
}
PLANNING_ITEM {
int id PK
int planning_id FK
string week_day
string meal
int recipe_id FK
USER_PREFERENCE {
int userProfileId PK_FK
enum theme "LIGHT | DARK | SYSTEM"
}
DIET {
int id PK
string name
string key UK
}
ALLERGY {
int id PK
int cat_id FK
int categoryId FK
}
CATEGORY {
int id PK
string name
string key UK
enum kind "ALLERGY | INTOLERANCE"
}
INGREDIENTS {
PLANNING {
int id PK
date startDate
date finishDate
int houseId FK
}
PLANNING_ITEM {
int id PK
int planningId FK
string weekDay
string meal
int recipeId FK
int portions
}
SOURCE {
int id PK
string key UK
string name
string icon
int alternate_recipe FK
string url
bool official
string iconUrl
}
HOUSE_SOURCE {
int houseId PK_FK
int sourceId PK_FK
}
RECIPE {
int id PK
string name
int source_id FK
int sourceId FK
string externalId
string description
string picture
int portions
int authorId FK
int authorHouseId FK
enum visibility "PERSONAL | HOUSE | PUBLIC"
}
RECIPE_INGREDIENT {
int recipeId PK_FK
int ingredientId PK_FK
decimal quantity
int unitId FK
}
RECIPE_DIET {
int recipeId PK_FK
int dietId PK_FK
}
RECIPE_FAVORITE {
int userProfileId PK_FK
int recipeId PK_FK
}
INGREDIENT {
int id PK
string key UK
enum icon
enum category
enum subcategory
bool reproducible
}
UNIT {
int id PK
string key UK
enum type "MASS | VOLUME | COUNT"
decimal toBaseFactor
}
STEP {
int id PK
int recipeId FK
string description
string picture
int order
int tech_step_id FK
}
TECH_STEP {
int id PK
string key UK
}
TECH_STEP_MAPPING {
int id PK
int tech_step_id FK
int techStepId FK
string locale
string expression
int weight
}
SOURCES {
int id PK
string name
string url
STEP_TECH_STEP {
int stepId PK_FK
int techStepId PK_FK
int order PK
int start
int end
}
```
*(Rendu sur les visualiseurs markdown compatibles mermaid — GitHub, VS Code, Obsidian, etc.)*
*(Rendu sur les visualiseurs markdown compatibles mermaid — GitHub, VS Code, Obsidian, etc.
Champ `PK_FK` = à la fois clé primaire et clé étrangère, `UK` = unique.)*
---
## Tables
## Utilisateurs & foyer
### `user_profiles` (`UserProfile`)
### `user_profiles`
| Champ | Description |
|---|---|
| `id` | Identifiant |
| `first_name` | Prénom |
| `last_name` | Nom |
| `email` | Email |
| `house_id` | FK → `house` |
| `diet_id` | FK → `diet` |
| `firstName` / `lastName` | Nom |
| `email` | Unique |
| `passwordHash` | Hash argon2 du mot de passe |
| `tokenVersion` | Compteur incrémenté pour invalider les JWT déjà émis (ex. changement de mot de passe) — mécanisme provisionné, aucun code ne l'incrémente encore aujourd'hui (pas de changement de mot de passe implémenté, voir [backend-architecture.md](./backend-architecture.md)) |
| `houseId` | FK → `house`, nullable |
| `dietId` | FK → `diet`, nullable |
Relations supplémentaires par rapport au schéma d'origine : `dislikedIngredients`
(m2m vers `Ingredient`, préférence de goût — voir plus bas), `authoredRecipes`,
`favoriteRecipes`, `administeredHouses` (le foyer dont ce profil est admin, au
plus un dans les faits), `preferences` (1-1 vers `UserPreference`).
### `house` (`House`)
### `house`
| Champ | Description |
|---|---|
| `id` | Identifiant |
| `name` | Nom du foyer |
| `adminId` | FK → `user_profiles` — le membre qui administre ce foyer (créateur, ou héritier d'adminship si l'admin précédent est parti — voir [backend-architecture.md](./backend-architecture.md#house--foyer-adminship-code-dinvitation-sources-activées)) |
| `inviteCode` | Code à 8 caractères, unique, généré pour rejoindre le foyer (`POST /house/join`) |
### `user_preference` (`UserPreference`)
1-1 avec `user_profiles` (`userProfileId` est à la fois clé primaire et clé
étrangère — un profil a au plus une ligne). `theme` (`LIGHT` / `DARK` / `SYSTEM`,
défaut `SYSTEM`) — préférence d'affichage, créée à la demande (pas au signup),
même logique "absence = valeur par défaut" que `dietId`/allergies. Voir
[frontend-architecture.md](./frontend-architecture.md#thème-clairsombresystème).
### `diet` (`Diet`)
### `diet`
| Champ | Description |
|---|---|
| `id` | Identifiant |
| `name` | Nom du régime alimentaire |
| `key` | Slug anglais stable, unique (ex. `"vegetarian"`) — le libellé affiché vit dans `apps/web/src/locales/fr/translation.json` sous `catalog.diets.<key>`, jamais dans cette table |
### `category` / `allergy` (`Category`, `Allergy`)
Un allergène = une `Category` (le nom réel, `key` unique) + une `Allergy`
(juste un id + FK vers sa catégorie). `Category.kind` (`ALLERGY` | `INTOLERANCE`)
distingue réaction immunitaire classique de réaction non-immunitaire (seuls
Gluten et Sulfites sont `INTOLERANCE`). 14 allergènes seedés (règlement UE
1169/2011, annexe II).
---
## Planification
### `planning` (`Planning`)
### `allergy`
| Champ | Description |
|---|---|
| `id` | Identifiant |
| `cat_id` | FK → `category` |
| `startDate` / `finishDate` | Lundi → dimanche de la semaine couverte |
| `houseId` | FK → `house` |
Associée à `user_profiles` en many-to-many (table de jointure simple, sans champ additionnel).
Une ligne par semaine et par foyer, créée à la demande (`findOrCreatePlanningForWeek`,
voir [backend-architecture.md](./backend-architecture.md#planning-semaine-item-portions-import-à-la-volée)),
pas en avance.
### `planning_item` (`PlanningItem`)
### `category`
| Champ | Description |
|---|---|
| `id` | Identifiant |
| `name` | Nom de la catégorie |
Table d'énumération, destinée à grandir au fil du projet (portera notamment les nuances liées aux allergies).
### `planning`
| Champ | Description |
|---|---|
| `id` | Identifiant |
| `start_date` | Date de début |
| `finish_date` | Date de fin |
| `house_id` | FK → `house` |
### `planning_item`
| Champ | Description |
|---|---|
| `id` | Identifiant |
| `planning_id` | FK → `planning` |
| `week_day` | Jour de la semaine |
| `planningId` | FK → `planning` |
| `weekDay` | Jour de la semaine |
| `meal` | Repas concerné |
| `recipe_id` | FK → `recipe` |
| `recipeId` | FK → `recipe` (pas de cascade — voir `RECIPE_IN_USE` dans [error-handling.md](./error-handling.md)) |
| `portions` | Nombre de portions **pour ce créneau précis** — indépendant de `Recipe.portions` (le rendement "tel qu'écrit" de la recette) : un créneau peut mettre l'échelle à la hausse/baisse. Le picker web pré-remplit depuis `Recipe.portions` mais stocke une valeur propre |
---
## Recettes
### `sources` (`Source`) et `house_source` (`HouseSource`)
`Source` est le catalogue des sources d'import concrètes (un site/une API par
ligne), synchronisé automatiquement depuis le registre d'adaptateurs en code
(`recipe-source-registry.ts`) plutôt que maintenu à la main — voir
[backend-architecture.md](./backend-architecture.md#sources-externes--adaptateur-registre-synchronisation).
| Champ | Description |
|---|---|
| `id` | Identifiant |
| `key` | Slug stable, unique — doit correspondre à `RecipeSourceAdapter.key` |
| `name` | Nom affiché |
| `url` | Optionnel |
| `official` | API officielle vs scraping non-officiel |
| `iconUrl` | Logo/favicon, optionnel |
`HouseSource` (`houseId`, `sourceId`, clé composite) : quelles sources un foyer
a choisi d'activer — opt-in, aucune ligne = désactivé. Un nouveau foyer démarre
sans aucune source activée.
### `recipe` (`Recipe`)
### `recipe`
| Champ | Description |
|---|---|
| `id` | Identifiant |
| `name` | Nom de la recette |
| `source_id` | FK → `sources` |
| `description` | Description |
| `picture` | Image |
| `sourceId` | FK → `sources`, nullable (`null` = recette créée à la main) |
| `externalId` | Identifiant côté source, nullable — avec `sourceId`, `@@unique([sourceId, externalId])` empêche d'importer deux fois le même item (Postgres traite chaque `NULL` comme distinct, donc les recettes manuelles ne se percutent jamais entre elles) |
| `description` / `picture` | Optionnels |
| `portions` | Rendement "tel qu'écrit" par la recette |
| `authorId` | FK → `user_profiles` — requis dès qu'une recette porte un niveau de visibilité |
| `authorHouseId` | FK → `house`, nullable — **instantané** du foyer de l'auteur *au moment de la création* (comme `Planning.houseId`), ne suit pas l'auteur s'il change de foyer ensuite |
| `visibility` | `PERSONAL` (auteur seul) / `HOUSE` (membres de `authorHouseId`) / `PUBLIC` (tout utilisateur connecté) — contrôle uniquement la **lecture**, jamais l'édition (toujours réservée à l'auteur, voir `NOT_RECIPE_AUTHOR`) |
Associée à `ingredients` en many-to-many.
Relations : `ingredients` (`RecipeIngredient`, m2m avec quantité/unité),
`steps` (`Step`, one-to-many — pas many-to-many comme documenté dans le doc
spec d'origine : `order` n'a de sens que dans le cadre d'une seule recette),
`favoritedBy` (`RecipeFavorite`), `diets` (`RecipeDiet`, tags manuels, pas
calculés depuis les ingrédients).
### `ingredients` (`Ingredient`) et catalogue associé
Table de référence (seedée, jamais créée/éditée/supprimée via l'API), comme
`Diet`/`Allergy`.
### `ingredients`
| Champ | Description |
|---|---|
| `id` | Identifiant |
| `name` | Nom |
| `icon` | Icône |
| `alternate_recipe` | FK → `recipe` (recette alternative) |
| `key` | Slug unique — libellé dans `catalog.ingredients.<key>` (locale) |
| `icon` | `IngredientIcon` — ~20 pictogrammes génériques par *type* de chose (légume, bouteille d'huile, fromage…), pas un emoji par ingrédient (437 rejetés comme peu pro) — voir `apps/web/src/features/recipes/ingredient-icons.tsx` |
| `category` / `subcategory` | `IngredientCategory` (7 rayons) / `IngredientSubcategory` (racks plus fins) — organisation "rayon de supermarché français" pour permettre le parcours par catégorie dans le picker (400+ ingrédients, la recherche seule ne suffit pas) |
| `reproducible` | Vrai si raisonnablement faisable maison (un pain burger, une béchamel) plutôt qu'un achat systématique — juste un flag, pas un lien vers une recette précise (un ancien `alternateRecipeId` jamais câblé a été retiré) |
`IngredientDiet` (m2m, régimes compatibles — omet volontairement `Omnivore`
et `Sans gluten`, ce dernier dérivable de `IngredientAllergy`) et
`IngredientAllergy` (m2m, allergènes contenus) complètent le catalogue.
`UserProfileDislikedIngredient` (m2m profil ↔ ingrédient) est une préférence
de **goût personnelle**, pas médicale — jamais un avertissement de sécurité,
juste un rappel sur la fiche recette (`RecipeDetailPanel`).
### `unit` (`Unit`)
Catalogue normalisé et fini des unités de mesure — remplace l'ancien texte
libre (`"g"`, `"grammes"`, `"G"`…) qui ne pouvait jamais être sommé de façon
fiable.
### `step`
| Champ | Description |
|---|---|
| `id` | Identifiant |
| `description` | Description de l'étape |
| `picture` | Image |
| `order` | Ordre dans la recette |
| `tech_step_id` | FK → `tech_step` |
| `key` | Slug unique (ex. `"tablespoon"`) — libellé dans `catalog.units.<key>` |
| `type` | `MASS` / `VOLUME` / `COUNT` — seules deux unités du même type sont mutuellement convertibles |
| `toBaseFactor` | Combien d'unités de base (gramme pour MASS, millilitre pour VOLUME, elle-même pour COUNT) équivaut une unité de ce type — pose les bases d'une future conversion (ex. sommer "500g" + "0.5kg"), pas encore construite |
Associée à `recipe` en many-to-many.
### `step` (`Step`) et techniques détectées
### `tech_step`
| Champ | Description |
|---|---|
| `id` | Identifiant |
| `recipeId` | FK → `recipe` |
| `description` | Texte de l'étape |
| `picture` | Optionnel |
| `order` | Position dans la recette |
### `tech_step_mapping`
| Champ | Description |
|---|---|
| `id` | Identifiant |
| `tech_step_id` | FK → `tech_step` |
| `expression` | Expression |
| `weight` | Poids |
`tech_step` (`TechStep`, `key` unique, ex. `"simmer"`) est le catalogue des
techniques (mijoter, préchauffer…). `tech_step_mapping` (`TechStepMapping`)
porte les règles de détection : `expression` (regex testée contre la
description), `weight` (départage en cas de règles concurrentes), `locale`
(une même technique peut avoir un jeu de règles par langue — voir
[backend-architecture.md](./backend-architecture.md#détection-des-techniques--tech-step-matcherts)).
### `sources`
| Champ | Description |
|---|---|
| `id` | Identifiant |
| `name` | Nom de la source |
| `url` | URL |
`step_tech_step` (`StepTechStep`) est la **séquence ordonnée** des techniques
détectées pour une étape — une instruction peut en impliquer plusieurs (ex.
"faire chauffer une noix de beurre" = `preheat` + `melt`), d'où une table de
liaison avec `order` plutôt qu'un simple FK nullable `Step.techStepId`
(remplacé suite à une revue de code). `start`/`end` (nullable, non rétro-remplis
— une ligne d'avant l'ajout de ces colonnes n'a simplement pas de
surlignage tant que sa recette n'est pas resauvegardée) sont le span détecté
dans `Step.description`, utilisé pour le surlignage côté web
(`highlight-tech-steps.ts`).
---
@ -224,28 +375,43 @@ Associée à `recipe` en many-to-many.
| Table source | Champ FK | Table cible |
|---|---|---|
| `user_profiles` | `house_id` | `house` |
| `user_profiles` | `diet_id` | `diet` |
| `planning` | `house_id` | `house` |
| `planning_item` | `planning_id` | `planning` |
| `planning_item` | `recipe_id` | `recipe` |
| `allergy` | `cat_id` | `category` |
| `ingredients` | `alternate_recipe` | `recipe` |
| `recipe` | `source_id` | `sources` |
| `step` | `tech_step_id` | `tech_step` |
| `tech_step_mapping` | `tech_step_id` | `tech_step` |
| `user_profiles` | `houseId` | `house` |
| `user_profiles` | `dietId` | `diet` |
| `house` | `adminId` | `user_profiles` |
| `planning` | `houseId` | `house` |
| `planning_item` | `planningId` | `planning` |
| `planning_item` | `recipeId` | `recipe` |
| `allergy` | `categoryId` | `category` |
| `recipe` | `sourceId` | `sources` |
| `recipe` | `authorId` | `user_profiles` |
| `recipe` | `authorHouseId` | `house` |
| `step` | `recipeId` | `recipe` |
| `tech_step_mapping` | `techStepId` | `tech_step` |
### Many-to-many (associations)
### Many-to-many (tables de jointure explicites, avec ou sans champ additionnel)
| Table A | Table B | Détail |
|---|---|---|
| `user_profiles` | `allergy` | Table de jointure simple |
| `recipe` | `ingredients` | Composition d'une recette |
| `step` | `recipe` | Étapes d'une recette |
| Table A | Table B | Table de jointure | Détail |
|---|---|---|---|
| `user_profiles` | `allergy` | `user_profile_allergy` | Simple |
| `user_profiles` | `ingredients` | `user_profile_disliked_ingredient` | Simple — préférence de goût |
| `user_profiles` | `recipe` | `recipe_favorite` | Simple |
| `house` | `sources` | `house_source` | Simple — opt-in |
| `recipe` | `ingredients` | `recipe_ingredient` | Porte `quantity` + `unitId` |
| `recipe` | `diet` | `recipe_diet` | Simple — tags manuels |
| `ingredients` | `diet` | `ingredient_diet` | Simple |
| `ingredients` | `allergy` | `ingredient_allergy` | Simple |
| `step` | `tech_step` | `step_tech_step` | Porte `order` + `start`/`end` (span détecté) |
---
## Règles de modélisation
- Toute relation qualifiée d'**« association »** entre deux tables est une relation **many-to-many**.
- `category` est une table d'énumération, amenée à grandir au fur et à mesure du projet.
- `category`, `diet`, `ingredients`, `unit`, `tech_step`, `sources` sont des tables de
référence/énumération : seedées (`apps/api/src/db/reference-seed-data.ts`),
jamais créées/éditées/supprimées via l'API applicative. `key`/`name` y sont
`@unique` pour permettre un seed idempotent (`upsert`).
- Chaque écart avec le document de conception d'origine (colonnes ajoutées,
cardinalité changée, table nouvelle) est expliqué en commentaire directement
dans `schema.prisma`, à l'endroit concerné — s'y référer en cas de doute,
ce document est un résumé, pas la source de vérité.

View file

@ -74,12 +74,31 @@ flowchart LR
```ts
enum ErrorCode {
VALIDATION_ERROR = 4000,
EMAIL_ALREADY_IN_USE = 4001,
INVALID_CREDENTIALS = 4010,
NOT_AUTHENTICATED = 4011,
NOT_FOUND = 4040,
INTERNAL_ERROR = 5000,
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 {
@ -90,9 +109,15 @@ interface ApiErrorResponse {
```
**Codes numériques, groupés par famille** (comme les codes HTTP) : `4000``4099`
validation, `4010``4019` authentification, `4040``4049` ressource introuvable,
`5000``5099` interne. Le numéro donne une indication de la catégorie même sans
regarder l'enum.
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

View file

@ -14,42 +14,68 @@ apps/web/src/
├── i18n/
│ └── i18n.ts # config i18next, importé une fois (main.tsx) pour son effet de bord
├── locales/
│ └── fr/translation.json # libellés français (errors.*, auth.*, layout.*, home.*, recipes.*, shoppingList.*, onboarding.*, household.*)
│ └── fr/translation.json # libellés français (common.*, errors.*, auth.*, layout.*, planning.*, recipes.*, shoppingList.*, onboarding.*, household.*, account.*, preferences.*, userPreferences.*, credits.*, catalog.*)
├── services/
│ └── error-message.service.ts # ErrorMessageService — code d'erreur → clé i18next
├── components/
│ └── ui/ # primitives réutilisables partout, voir plus bas
│ ├── Dialog.tsx + dialog.scss # modale (élément <dialog> natif)
│ ├── Checkbox.tsx / Radio.tsx # "carte sélectionnable" (CheckboxOption/RadioOption)
│ └── Tooltip.tsx + tooltip.scss # infobulle CSS-only
├── features/
│ ├── auth/ # tout ce qui concerne l'authentification
│ │ ├── AuthContext.tsx # état global (profil connecté, login/signup/logout, refreshUser)
│ │ ├── RequireAuth.tsx # garde de route : redirige vers /login si non connecté
│ │ ├── RedirectIfAuthenticated.tsx # garde de route inverse (pour /login, /signup)
│ ├── auth/ # authentification
│ │ ├── AuthContext.tsx # état global (profil connecté, login/signup/logout, refreshUser, deleteAccount)
│ │ ├── RequireAuth.tsx / RedirectIfAuthenticated.tsx # gardes de route
│ │ └── auth-form.scss # styles partagés par LoginPage et SignupPage
│ └── profile/ # champs du parcours foyer/régime/allergènes, voir plus bas
│ ├── HouseNameField.tsx / DietSelect.tsx / AllergySelect.tsx
│ └── profile-forms.scss # styles partagés par les trois
│ ├── theme/
│ │ └── ThemeContext.tsx # clair/sombre/système, voir plus bas
│ ├── profile/ # champs du parcours régime/allergènes/goûts (personnels)
│ │ ├── HouseNameField.tsx / DietSelect.tsx / AllergySelect.tsx / DislikedIngredientsField.tsx
│ │ └── profile-forms.scss
│ ├── house/ # préférences propres au foyer
│ │ ├── SourceSelect.tsx # quelles sources externes le foyer voit
│ │ └── house-forms.scss
│ ├── planning/
│ │ ├── RecipePickerDialog.tsx # dialogue "ajouter au planning" — parcourir/prévisualiser/importer, voir plus bas
│ │ └── recipe-picker-dialog.scss
│ └── recipes/ # catalogue, import, édition — voir plus bas
│ ├── RecipeTable.tsx / RecipeTabs.tsx / RecipeDetailPanel.tsx
│ ├── RecipeSourcesPanel.tsx / SourceItemTable.tsx / useEnabledSources.ts
│ ├── RecipeImportForm.tsx / recipe-import-draft.ts
│ ├── IngredientPicker.tsx / IngredientRow.tsx / StepListEditor.tsx / StepDescription.tsx
│ ├── highlight-tech-steps.ts / ingredient-icons.tsx
│ ├── DietTagSelect.tsx / DietBadges.tsx / AllergenBadges.tsx / ReproducibleBadge.tsx / FavoriteStarButton.tsx
│ └── recipes.scss
├── layouts/
│ └── AppLayout.tsx + .scss # sidebar (nav + user/logout) commune à tout l'espace connecté, voir plus bas
│ ├── AppLayout.tsx + .scss # sidebar (nav, sous-menu Paramètres, menu compte) commune à tout l'espace connecté, voir plus bas
│ └── nav-icons.tsx # ré-export nommé des icônes lucide-react utilisées par la sidebar
├── pages/
│ ├── LoginPage.tsx / .scss (via auth-form.scss, partagé)
│ ├── SignupPage.tsx / .scss (via auth-form.scss, partagé)
│ ├── HomePage.tsx + HomePage.scss # planning de la semaine (routée sur "/")
│ ├── ComingSoonPage.tsx + .scss # placeholder partagé par les sections sans backend encore
│ ├── RecipesPage.tsx / ShoppingListPage.tsx # fines enveloppes autour de ComingSoonPage
│ ├── HouseholdPage.tsx + .scss # foyer/régime/allergènes, éditable à tout moment (routée sur "/foyer")
│ └── onboarding/ # wizard d'inscription (foyer → régime → allergènes), voir plus bas
│ ├── OnboardingHouseholdPage.tsx / OnboardingDietPage.tsx / OnboardingAllergensPage.tsx
│ └── onboarding.scss # styles partagés par les trois
│ ├── LoginPage.tsx / SignupPage.tsx (via auth-form.scss, partagé)
│ ├── PlanningPage.tsx + planning-page.scss # grille de la semaine (routée sur "/"), voir plus bas
│ ├── RecipesPage.tsx # vue maître-détail du catalogue (routée sur /recettes, /recettes/:id, /recettes/sources/:sourceKey/:externalId)
│ ├── RecipeFormPage.tsx # création/édition manuelle (/recettes/nouvelle, /recettes/:id/modifier)
│ ├── ImportRecipePage.tsx # route de secours autonome pour un import (/recettes/importer/:sourceKey/:externalId)
│ ├── ComingSoonPage.tsx + .scss / ShoppingListPage.tsx # placeholder, section sans backend
│ ├── settings/ # ancienne HouseholdPage éclatée en 5 pages, voir plus bas
│ │ ├── AccountSettingsPage.tsx / HouseholdSettingsPage.tsx / PreferencesPage.tsx
│ │ ├── UserPreferencesPage.tsx / CreditsPage.tsx
│ │ └── settings-pages.scss
│ └── onboarding/ # wizard d'inscription (régime → foyer → [sources] → allergènes), voir plus bas
│ ├── OnboardingDietPage.tsx / OnboardingHouseholdPage.tsx / OnboardingSourcesPage.tsx / OnboardingAllergensPage.tsx
│ └── onboarding.scss
├── styles/
│ ├── _theme.scss # tokens de design (couleurs, espacements, typographie)
│ ├── _theme.scss # tokens de design (couleurs, espacements, typographie) — variantes clair/sombre
│ └── global.scss # reset minimal + import du theme — importé une seule fois (main.tsx)
├── lib/
│ └── zod-errors.ts # utilitaire : erreurs zod → { champ: message }
│ ├── zod-errors.ts # utilitaire : erreurs zod → { champ: message }
│ └── client-key.ts # makeClientKey() — identité React locale/éphémère pour une ligne de brouillon (ingrédient/étape en cours d'édition), jamais envoyée au serveur ; volontairement pas crypto.randomUUID() (indisponible hors contexte sécurisé, ex. Capacitor)
├── App.tsx # table de routes
└── main.tsx # point d'entrée : providers (Router, AuthProvider) + imports i18n/CSS globaux
└── main.tsx # point d'entrée : providers (Router, AuthProvider, ThemeProvider) + imports i18n/CSS globaux
```
**Règle de placement des styles** : un style spécifique à un seul composant/page vit
dans un fichier `.scss` au même niveau que ce composant (`HomePage.tsx` +
`HomePage.scss`). Un style partagé par plusieurs composants d'une même feature vit
dans un fichier `.scss` au même niveau que ce composant (`PlanningPage.tsx` +
`planning-page.scss`). Un style partagé par plusieurs composants d'une même feature vit
dans le dossier de la feature (`features/auth/auth-form.scss`, utilisé par
`LoginPage` et `SignupPage`). Seuls le reset et les tokens globaux vivent dans
`styles/`.
@ -68,10 +94,13 @@ flowchart TB
CHECK -->|"401 (pas de session)"| ANON["user = null"]
AUTHED --> LAYOUT["RequireAuth → AppLayout (sidebar)"]
LAYOUT --> ROUTE_HOME["/ → HomePage (planning)"]
LAYOUT --> ROUTE_RECIPES["/recettes → RecipesPage"]
LAYOUT --> ROUTE_HOME["/ → PlanningPage"]
LAYOUT --> ROUTE_RECIPES["/recettes, /recettes/:id,<br/>/recettes/sources/:sourceKey/:externalId → RecipesPage"]
LAYOUT --> ROUTE_RECIPE_FORM["/recettes/nouvelle,<br/>/recettes/:id/modifier → RecipeFormPage"]
LAYOUT --> ROUTE_IMPORT["/recettes/importer/:sourceKey/:externalId → ImportRecipePage"]
LAYOUT --> ROUTE_SHOPPING["/liste-de-courses → ShoppingListPage"]
LAYOUT --> ROUTE_HOUSEHOLD["/foyer → HouseholdPage"]
LAYOUT --> ROUTE_SETTINGS["/parametres/* → pages de paramètres"]
ROUTE_OLD["/foyer"] -->|"redirige"| ROUTE_SETTINGS
AUTHED --> ROUTE_LOGIN_A["/login ou /signup"]
ROUTE_LOGIN_A -->|"RedirectIfAuthenticated"| ROUTE_HOME
@ -94,99 +123,491 @@ flowchart TB
bug trouvé en construisant le wizard d'inscription (voir plus bas) : un
`navigate()` explicite dans le formulaire qu'elle protège entrait en course avec
son propre `<Navigate>`, invisible tant que les deux ciblaient "/".
- **Table de routes complète** (`App.tsx`) : sous l'unique parent
`RequireAuth`+`AppLayout` — `/` (`PlanningPage`), `/recettes` /
`/recettes/:id` / `/recettes/sources/:sourceKey/:externalId` (les trois
rendent **le même composant** `RecipesPage`, voir plus bas), `/recettes/nouvelle`
/ `/recettes/:id/modifier` (`RecipeFormPage`),
`/recettes/importer/:sourceKey/:externalId` (`ImportRecipePage`, route de
secours autonome), `/liste-de-courses` (`ShoppingListPage`, toujours un stub),
et les cinq pages `/parametres/*` (compte, préférences, foyer,
préférences-utilisateur, crédits — voir plus bas). `/foyer` (l'URL de
l'ancienne page combinée) redirige vers `/parametres/foyer` pour ne pas casser
un lien existant.
- **`/onboarding/*`** reste son propre groupe de routes top-level (pas nichées
sous `AppLayout` — wizard plein écran sans sidebar) : `regime``foyer`
`sources` (conditionnelle — seulement si l'étape `foyer` a créé/rejoint un
foyer, sinon on saute directement à l'étape suivante) → `allergenes`.
---
## `AppLayout` — sidebar commune à l'espace connecté
`App.tsx` monte **un seul** `RequireAuth` + `AppLayout` comme route parente de
toutes les routes authentifiées (routes imbriquées `react-router-dom`) :
toutes les routes authentifiées (routes imbriquées `react-router-dom`, voir la
table complète plus haut). `AppLayout` (`layouts/AppLayout.tsx`) rend une
sidebar et un `<main>` qui affiche la route enfant matchée via `<Outlet />`
la garde d'auth et le chrome de navigation ne sont donc écrits qu'une fois, pas
dupliqués par page comme `RequireAuth` l'était individuellement avant cette
feature. En dessous de 640px la sidebar devient une barre horizontale (voir
`AppLayout.scss`) — pertinent tôt puisque l'app est prévue pour être embarquée
par Capacitor plus tard (voir le README racine).
```tsx
<Route element={<RequireAuth><AppLayout /></RequireAuth>}>
<Route path="/" element={<HomePage />} />
<Route path="/recettes" element={<RecipesPage />} />
<Route path="/liste-de-courses" element={<ShoppingListPage />} />
<Route path="/foyer" element={<HouseholdPage />} />
</Route>
```
La sidebar a grandi avec l'app :
`AppLayout` (`layouts/AppLayout.tsx`) rend une sidebar (marque, nav des sections,
nom de l'utilisateur + déconnexion en pied de sidebar) et un `<main>` qui affiche
la route enfant matchée via `<Outlet />` — la garde d'auth et le chrome de
navigation ne sont donc écrits qu'une fois, pas dupliqués par page comme
`RequireAuth` l'était individuellement avant cette feature. En dessous de 640px la
sidebar devient une barre horizontale (voir `AppLayout.scss`) — pertinent tôt
puisque l'app est prévue pour être embarquée par Capacitor plus tard (voir le
README racine).
- **Icônes** (`layouts/nav-icons.tsx`) — ré-export nommé d'icônes
`lucide-react` (remplace un ancien jeu de SVG dessinés à la main) :
`PlanningIcon`, `RecipesIcon`, `ShoppingListIcon`, `SettingsIcon`,
`AccountIcon`, `DietPreferencesIcon`, `HouseholdIcon`,
`UserPreferencesIcon`, `CreditsIcon`, `FavoriteIcon`, `PublicIcon`,
`SourcesIcon`, `SourceLinkIcon`, `ChevronLeftIcon`. Toujours accompagnée d'un
libellé/tooltip, donc `aria-hidden="true"` est posé par chaque appelant.
- **Structure** : bloc marque ("batchCooking" complet, réduit à "bC" en mode
replié), nav principale (Planning `/`, Recettes `/recettes`, Liste de
courses `/liste-de-courses`), un `SettingsMenu` repliable, un `AccountMenu`,
un pied de page avec le numéro de version.
- **Rail repliable**`isCollapsed` persisté dans `localStorage`
(`batchcooking:sidebarCollapsed`) : un simple toggle de classe
(`.app-sidebar.collapsed`) masque les `.label` en CSS ; chaque item garde son
icône et gagne un `title` en tooltip.
- **`SettingsMenu`** — révèle Compte (`/parametres/compte`), Préférences
(`/parametres/preferences`), Foyer (`/parametres/foyer`), Préférences
utilisateur (`/parametres/preferences-utilisateur`), Crédits
(`/parametres/credits`). Ouvert par défaut si la route courante est déjà
sous `/parametres`, replié sinon — indépendant du repli de toute la sidebar.
- **`AccountMenu`** — remplace l'ancien simple "bonjour + déconnexion" : un
menu déroulant depuis l'avatar/nom en pied de sidebar, avec un raccourci vers
`/parametres/compte` et la déconnexion ; se referme après l'une ou l'autre
action.
- La correspondance route↔nav utilise les clés i18n `layout.nav.<clé>` /
`layout.settings.nav.<clé>` — ajouter une entrée de nav est un item de
tableau + une clé de locale, rien d'autre.
### Sections sans backend — `ComingSoonPage`
`Recettes` et `Liste de courses` n'ont pas encore de backend dédié (seuls
`/planning/current` et le parcours foyer/profil ci-dessous existent, voir le
README). Chacune a néanmoins sa propre route/page (`RecipesPage.tsx`, etc. — choix
délibéré pour que construire la vraie fonctionnalité plus tard soit réécrire un
fichier dédié, pas éclater une route générique), mais toutes rendent le même
composant `ComingSoonPage` (`title`/`description`) pour éviter de tripler un même
bloc de markup.
Seule `Liste de courses` (`ShoppingListPage`) n'a pas encore de backend dédié
(le module « Calcul batch-cooking » reste `TODO`, voir
[batch-cooking-architecture.md](./batch-cooking-architecture.md)) et rend le
composant partagé `ComingSoonPage` (`title`/`description`). `Recettes` a
maintenant un vrai backend complet (catalogue, import depuis des sources
externes, favoris — voir plus bas) et ne passe plus par ce stub.
---
## Parcours profil — foyer, régime, allergènes
## Parcours profil — onboarding et pages de paramètres
Deux surfaces, mêmes composants de champ (`features/profile/`) :
Deux surfaces partagent les mêmes composants de champ (`features/profile/` +
`features/house/`) : le wizard d'inscription (une fois) et les pages
`/parametres/*` (à tout moment).
```mermaid
flowchart LR
SIGNUP["SignupPage<br/>(POST /auth/signup)"] --> OB1["/onboarding/foyer"]
OB1 --> OB2["/onboarding/regime"]
OB2 --> OB3["/onboarding/allergenes"]
OB3 --> HOME["/ (home)"]
SIGNUP["SignupPage<br/>(POST /auth/signup)"] --> OB1["/onboarding/regime"]
OB1 --> OB2["/onboarding/foyer"]
OB2 -->|"foyer créé/rejoint"| OB3["/onboarding/sources"]
OB2 -->|"pas de foyer"| OB4["/onboarding/allergenes"]
OB3 --> OB4
OB4 --> HOME["/ (PlanningPage)"]
SIDEBAR["Sidebar : Foyer & profil"] --> SETTINGS["/foyer (HouseholdPage)"]
SETTINGSMENU["Sidebar : Paramètres"] --> S1["/parametres/compte"]
SETTINGSMENU --> S2["/parametres/preferences"]
SETTINGSMENU --> S3["/parametres/foyer"]
SETTINGSMENU --> S4["/parametres/preferences-utilisateur"]
SETTINGSMENU --> S5["/parametres/credits"]
```
- **`pages/onboarding/`** — wizard de 3 écrans, lancé une seule fois juste après
l'inscription. Routes top-level `RequireAuth`, **pas** nichées sous `AppLayout` :
wizard plein écran sans sidebar (`onboarding.scss`, même langage visuel que
`/login`/`/signup`, délibérément un fichier à part plutôt qu'un import de
`auth-form.scss` — même choix que `HomePage.scss` avant elle, voir plus haut).
Chaque étape a un unique bouton "Continuer" qui envoie la valeur courante — pas de
bouton "Passer" séparé, une valeur "aucune"/vide *est* le skip.
- **`pages/HouseholdPage.tsx`** (routée `/foyer`, dans `AppLayout`) — les mêmes
réglages, éditables à tout moment, en **hot saving** (retour fonctionnel : pas de
bouton "Enregistrer"). Chaque section sauvegarde peu après la dernière
modification (nom du foyer et allergènes/intolérances debouncés — 600ms/500ms —
régime immédiat) — 3 ressources API indépendantes (`PATCH /house/current`,
`/profile/diet`, `/profile/allergies`), 3 cycles de sauvegarde indépendants.
Déclenché depuis le handler `onChange` de chaque champ, jamais un `useEffect`
générique sur la valeur — un tel effect se déclencherait aussi au chargement
initial (le `GET` peuple le même state), sans distinction propre entre "vient
d'être chargé" et "vient d'être modifié".
- **`features/profile/`** — `HouseNameField`, `DietSelect`, `AllergySelect` : champs
contrôlés, "dumb" (reçoivent `diets`/`allergies` en props plutôt que de les
fetcher). `AllergySelect` est un `<fieldset>`/`<legend>` + grille de cases à
cocher, pas un `<select multiple>` — bien plus repérable/tapable, notamment sur
mobile (voir la note Capacitor plus haut). Prend un `legend` en prop : chaque
page consommatrice le rend **deux fois** (allergies / intolérances, filtrées
côté client via `AllergyView.kind`), mais la sélection reste une seule liste
d'IDs partagée entre les deux groupes.
- **`pages/onboarding/`** — wizard de **4 écrans** (régime → foyer → sources →
allergènes), lancé une seule fois juste après l'inscription. Routes
top-level `RequireAuth`, **pas** nichées sous `AppLayout` : wizard plein
écran sans sidebar (`onboarding.scss`, même langage visuel que
`/login`/`/signup`). Chaque étape a un unique bouton "Continuer" qui envoie
la valeur courante — pas de bouton "Passer" séparé, une valeur
"aucune"/vide *est* le skip. `/onboarding/sources` est **conditionnelle** :
seulement atteinte si l'étape `foyer` vient de créer/rejoindre un foyer
(`OnboardingHouseholdPage`'s `goToNextStep`) — sautée sinon, directement vers
`/onboarding/allergenes`. `OnboardingSourcesPage` elle-même redirige en
silence vers l'étape suivante si `GET /reference/sources` revient vide ou en
erreur, plutôt que d'afficher une étape sans rien à choisir.
- **Pages `/parametres/*`** (`pages/settings/`, dans `AppLayout`) — l'ancienne
`HouseholdPage` combinée a été **éclatée en 5 pages** dédiées (voir la
section suivante pour le détail de chacune), toutes en **hot saving** (pas
de bouton "Enregistrer" — chaque section sauvegarde peu après la dernière
modification, déclenché depuis le handler `onChange` de chaque champ, jamais
un `useEffect` générique sur la valeur : un tel effect se déclencherait
aussi au chargement initial quand le `GET` peuple le même state, sans
distinction propre entre "vient d'être chargé" et "vient d'être modifié").
- **`features/profile/`** — `HouseNameField`, `DietSelect`, `AllergySelect`,
et **`DislikedIngredientsField`** (nouveau) : champs contrôlés, "dumb"
(reçoivent leurs données en props plutôt que de les fetcher).
`AllergySelect` est un `<fieldset>`/`<legend>` + grille de cases à cocher,
pas un `<select multiple>` — bien plus repérable/tapable, notamment sur
mobile. Prend un `legend` en prop : chaque page consommatrice le rend
**deux fois** (allergies / intolérances, filtrées côté client via
`AllergyView.kind`), mais la sélection reste une seule liste d'IDs partagée
entre les deux groupes. `DislikedIngredientsField` — recherche + ajout +
puces retirables pour la liste personnelle d'ingrédients "pas aimés" ;
réutilise `IngredientPicker` (voir plus bas) tel quel. **Explicitement
distinct d'`AllergySelect`** : une préférence de **goût**, jamais une
contrainte médicale — ne déclenche jamais un avertissement de sécurité,
juste un badge 🚫 discret sur la fiche recette (`RecipeDetailPanel`).
Sauvegardé via `GET`/`PATCH /profile/disliked-ingredients` (remplacement
complet, pas de fusion).
### Deux bugs de state trouvés en testant dans le navigateur
1. **Course entre `navigate()` et `RedirectIfAuthenticated`** — voir la note sur
`RedirectIfAuthenticated` plus haut. `SignupPage` doit maintenant rediriger vers
`/onboarding/foyer`, pas `/`, ce qui a rendu visible une course de state
`/onboarding/regime`, pas `/`, ce qui a rendu visible une course de state
auparavant invisible.
2. **`user.dietId` périmé sur `/foyer`** — `HouseholdPage` initialisait le régime
affiché depuis `useAuth().user.dietId`, un instantané d'`AuthContext` jamais
rafraîchi après une modification faite directement via `apiClient` (qui ne
touche pas le contexte). Une navigation SPA aller-retour sans rechargement
complet ré-affichait donc l'ancienne valeur après une sauvegarde. Fix :
`HouseholdPage` fetch son propre profil frais (`apiClient.me()`) au montage
plutôt que de dépendre du contexte, et `AuthContext.refreshUser()` (nouvelle
méthode, re-fetch `GET /auth/me`) est appelée après une sauvegarde réussie du
régime — pour que le reste de l'app (pas seulement cette page) reste cohérent.
2. **`user.dietId` périmé sur les pages de paramètres** — l'ancienne page
combinée initialisait le régime affiché depuis `useAuth().user.dietId`, un
instantané d'`AuthContext` jamais rafraîchi après une modification faite
directement via `apiClient` (qui ne touche pas le contexte). Une navigation
SPA aller-retour sans rechargement complet ré-affichait donc l'ancienne
valeur après une sauvegarde. Fix, toujours en place dans `PreferencesPage` :
la page fetch son propre profil frais (`apiClient.me()`) au montage plutôt
que de dépendre du contexte, et `AuthContext.refreshUser()` (re-fetch
`GET /auth/me`) est appelée après une sauvegarde réussie du régime — pour
que le reste de l'app (pas seulement cette page) reste cohérent.
---
## Thème (clair/sombre/système)
`features/theme/ThemeContext.tsx` — ce que la note "future switch de thème"
plus bas (SCSS et theming) anticipait est maintenant réel.
- `ThemePreference` = `"LIGHT" | "DARK" | "SYSTEM"` (`packages/shared`'s
`THEME_PREFERENCES`). `ThemeProvider` doit être imbriqué **dans**
`AuthProvider` (il lit `useAuth().user`).
- L'effet qui charge la préférence est indexé sur **`user?.id`**, pas sur
`user` lui-même — `AuthContext`'s `user` change de référence à chaque
`refreshUser()`, ce qui ne doit pas redéclencher un fetch des préférences
(documenté par un commentaire `biome-ignore
lint/correctness/useExhaustiveDependencies`).
- Sans utilisateur : retombe sur `"SYSTEM"`. Avec un utilisateur :
`apiClient.getPreferences()` (`GET /preferences`), échec avalé
silencieusement — même posture "non fatale" que `AuthContext`'s propre
`me()`.
- **`applyTheme(theme)`** : `SYSTEM` **supprime** l'attribut
`document.documentElement.dataset.theme` plutôt que d'y écrire la chaîne
littérale `"system"` — sans attribut `data-theme`, la media query
`prefers-color-scheme` de `_theme.scss` reprend la main naturellement.
`LIGHT`/`DARK` posent `data-theme="light"`/`"dark"`.
- **`setTheme(newTheme)`** : appelle `apiClient.updatePreferences(theme)`
(`PATCH /preferences`) **avant** de mettre à jour l'état local — la
persistance est **côté serveur** (via l'API/la base), contrairement au repli
de la sidebar qui, lui, ne vit que dans `localStorage`.
- Consommé par `UserPreferencesPage` (`/parametres/preferences-utilisateur`)
via `useTheme()` — un simple groupe de boutons radio (`RadioOption` ×
`THEME_PREFERENCES`).
---
## Pages de paramètres (`/parametres/*`)
L'ancienne `HouseholdPage` combinée est éclatée en 5 pages dédiées
(`pages/settings/*.tsx`, styles partagés `settings-pages.scss`) :
- **`AccountSettingsPage`** (`/compte`) — identité en **lecture seule**
(prénom/nom/email, éditer n'est pas encore une fonctionnalité demandée) plus
une "zone dangereuse" : suppression du compte après re-saisie du mot de
passe (`useAuth().deleteAccount(password)` → `DELETE /auth/me`).
- **`PreferencesPage`** (`/preferences`, "Préférences alimentaires") — le
volet **personnel** : régime (`DietSelect`, sauvegarde immédiate +
`refreshUser()`), allergies/intolérances (deux `AllergySelect`, debounce
500ms), et `DislikedIngredientsField` (debounce 500ms) — le pendant
"toujours modifiable" des étapes régime/allergènes du wizard, mêmes
composants de champ.
- **`HouseholdSettingsPage`** (`/foyer`) — le volet **foyer**. Deux layouts
selon `useAuth().user.houseId` :
- **Sans foyer** : créer (`POST /house`) ou rejoindre par code d'invitation
(`POST /house/join`).
- **Avec foyer** : nom renommable (debounce 600ms, `PATCH /house/current`),
code d'invitation affiché + copie presse-papier, liste des membres avec
badge admin, bouton de retrait réservé à l'admin (`DELETE
/house/members/:id`), une section **Sources** (`SourceSelect` — n'importe
quel membre, pas seulement l'admin, peut activer/désactiver quelles
sources externes le foyer voit, debounce 500ms, `PATCH
/house/current/sources`), et soit la suppression du foyer (admin,
confirmation en deux temps, `DELETE /house/current`) soit le départ
(non-admin, sans confirmation puisque ça n'affecte que le partant, `POST
/house/leave`). Recharge `GET /house/current` après chaque mutation
plutôt qu'un patch optimiste — délibéré, ce sont des actions peu
fréquentes/réfléchies.
- **`UserPreferencesPage`** (`/preferences-utilisateur`) — juste le thème
(voir la section précédente). Nom volontairement distinct de `/preferences`
malgré la collision terminologique : "comment l'app se présente" vs "les
contraintes alimentaires du foyer".
- **`CreditsPage`** (`/credits`) — page d'attribution pure, existe pour
satisfaire la licence CC BY 4.0 du jeu d'icônes d'ingrédients
(foodiconpack.com, voir plus bas).
---
## Planning (`/`)
`pages/PlanningPage.tsx` (+ `planning-page.scss`) — remplace l'ancienne
`HomePage`. Grille complète de la semaine, pas un simple tableau du jour :
7 colonnes (jours) × 5 lignes (`petit-dejeuner`, `collation`, `dejeuner`,
`gouter`, `diner``WEEK_DAYS`/`MEALS` de `packages/shared`), avec un
regroupement visuel "moments de la journée" (matin/midi/après-midi/soir) via
une bordure appuyée après `collation`/`dejeuner`/`gouter`.
- **Navigation de semaine**`WeekNavigator` (flèches précédent/suivant,
`addWeeks(weekStart, ±1)` de `@batch-cooking/date-tools`) + un libellé
cliquable ouvrant un `CalendarPopover` (grille mensuelle via
`buildCalendarMonth`, cliquer un jour saute à sa semaine, lundi-first). Un
badge "aujourd'hui" s'affiche quand la semaine visible est la semaine
courante.
- `GET /planning?date=YYYY-MM-DD` renvoie `PlanningView | null``null` est
un état normal (rien à afficher pour cette semaine), pas un message
d'erreur séparé : les boutons "+" de chaque case suffisent à communiquer
l'état vide.
- **Ajouter une recette** — le "+" d'une case ouvre `RecipePickerDialog` pour
ce créneau `(date, weekDay, meal)`, monté **conditionnellement** (seulement
tant que la case est ouverte) pour que son état interne reparte à zéro à
chaque ouverture, sans reset manuel.
- **Mise à jour locale** — après ajout/retrait, `planning.items` est patché
côté client plutôt que refetché (un objet `Planning` factice `id: -1` est
construit si aucun n'existait encore, puisque seul `.items` est jamais lu
sur cette page). Le retrait est **optimiste** (retiré localement
immédiatement, `DELETE /planning/items/:id`, restauré en cas d'échec).
- Chaque recette planifiée s'affiche en pastille `"{nom} · ×{portions}"` avec
un bouton de retrait (✕).
### `RecipePickerDialog` — parcourir, prévisualiser, importer
`features/planning/RecipePickerDialog.tsx` (+ `recipe-picker-dialog.scss`) —
un seul dialogue, jusqu'à 3 étapes, conçu autour d'un principe : **on
prévisualise avant de confirmer**, rien n'est engagé par un simple clic de
ligne.
1. **Parcourir** (étape par défaut) — embarque `RecipeTabs` +
`RecipeTable`/`RecipeDetailPanel` (onglets réguliers) ou
`RecipeSourcesPanel` (onglet d'une source) : exactement l'UI du catalogue
`/recettes`, avec en plus trois filtres propres au contexte "je cherche
quoi cuisiner" (pas juste "je consulte") : un filtre multi-ingrédients
(`IngredientPicker`), un filtre multi-régimes (`DietTagSelect`), et une
case "convient à tout le foyer" (affichée seulement si le viewer a un
foyer) — tous branchés sur les query params de `GET /recipes`. Cliquer une
ligne **sélectionne/prévisualise seulement**, jamais ne valide — un pied de
dialogue épinglé ("Confirmer", actif dès qu'une prévisualisation existe)
est ce qui agit réellement.
2. **Confirmer les portions** (une vraie recette est prévisualisée) — petit
formulaire "combien de portions ?" (pré-rempli depuis
`RecipeSummaryView.portions`), puis `POST /planning/items`.
3. **Revue intégrée** (un item de source *pas encore importé* est prévisualisé
et nécessite une intervention humaine) — rend `RecipeImportForm`
directement à l'intérieur du même `Dialog` (`planningSlot` transmis pour
qu'un import réussi ajoute aussi au planning en un seul submit) : évite de
naviguer vers `ImportRecipePage` et de perdre la recherche/les filtres/le
créneau du picker.
**L'import transparent** — décrit dans le composant comme "la seule action de
toute l'app qui importe vraiment un item de source... puisqu'un item de
source ne devient une vraie Recipe sauvegardée qu'en conséquence du fait que
quelqu'un l'ajoute à son planning" :
1. `GET /sources/:sourceKey/preview/:externalId``RecipeImportDraftView`.
2. `tryBuildCompleteImport(draft)` (`recipe-import-draft.ts`) tente de
construire un `CreateRecipeInput` soumissible **sans aucun formulaire, sans
personne impliquée** — renvoie `null` dès qu'un jugement humain est
nécessaire (une ligne d'ingrédient non résolue, `portions` manquant).
3. Si non-null : `POST /sources/:sourceKey/import/:externalId` puis `POST
/planning/items` — ajouté au créneau **sans écran supplémentaire**, comme
n'importe quelle autre recette.
4. Si `tryBuildCompleteImport` renvoie `null`, ou si l'un des deux appels
échoue : bascule sur l'étape 3 ci-dessus (revue intégrée).
5. Cas limite géré explicitement : si l'import réussit mais que l'ajout au
planning échoue ensuite, la recette est **déjà sauvegardée** — plutôt que
de retenter tout l'import, navigation vers `/recettes/:id` (même repli que
`RecipeImportForm`'s propre submit, voir plus bas).
---
## Recettes — catalogue, favoris, import depuis une source externe
`pages/RecipesPage.tsx` — routée sur `/recettes`, `/recettes/:id` **et**
`/recettes/sources/:sourceKey/:externalId` (le **même composant** pour les
trois) : une vue **maître-détail**, pas une navigation vers une page séparée —
la barre d'onglets + le tableau restent montés, seul le panneau de détail
change avec le paramètre d'URL.
### Onglets — `RecipeTabs.tsx`
Quatre onglets réels, en base — `favoris` / `perso` / `foyer` / `publique`
pas de "toutes" : toute recette tombe sous exactement un des trois derniers
via sa propre `visibility`, `favoris` est un filtre transverse orthogonal.
**Plus un onglet par source externe activée pour le foyer** (chaque source
activée devient sa propre tab) — valeur `"source:<key>"`, icône propre à la
source (`iconUrl` si elle en a un, sinon `SourcesIcon` générique), nom non
traduit (nom propre). Concept **propre au web** : absent du type `RecipeTab`
partagé, l'API n'a pas de valeur `tab=source:...` — parcourir une source est
un endpoint entièrement différent (`GET /sources/:sourceKey/browse`).
`useEnabledSources.ts` (`Promise.all([getSources(), getHouseSourceIds()])`)
calcule la liste des sources activées, partagé par `RecipesPage` et
`RecipePickerDialog`.
### Parcourir une source — `RecipeSourcesPanel.tsx`
Contenu de l'onglet d'une source : sa propre paire maître-détail —
`SourceItemTable` (liste paginée, `GET /sources/:sourceKey/browse?query=&cursor=`,
`nextCursor`) + `RecipeDetailPanel` pour la prévisualisation. Scopé à un seul
`sourceKey` (prop fixe) — remonté avec `key={sourceKey}` en changeant de
source, même convention "monté seulement tant que pertinent" que `Dialog`.
Clic sur une ligne :
- **Déjà importé** (`alreadyImported && recipeId !== null`) : `GET
/recipes/:id`, prévisualisé en état `"loaded"`.
- **Pas encore importé** : `GET /sources/:sourceKey/preview/:externalId`,
prévisualisé en état `"loaded-draft"`.
### Flux d'import complet (parcourir → prévisualiser → revue → sauvegarder)
- **Prévisualisation**`RecipeDetailPanel`'s état `"loaded-draft"` : photo,
icône de lien vers la source (`SourceLinkIcon`, ouvre l'URL d'origine dans
un nouvel onglet), nom, portions, description, étapes (avec surlignage des
techniques, voir plus bas) — **pas** d'actions favori/modifier/supprimer, et
volontairement pas de bouton "importer" manuel : un item de source n'est
sauvegardé qu'en conséquence de son ajout au planning (voir
`RecipePickerDialog` plus haut) ou d'une soumission de revue explicite.
- **Formulaire de revue — `RecipeImportForm.tsx`** — pré-rempli depuis le
brouillon, structurellement identique à `RecipeFormPage` (mêmes
`IngredientRow`/`IngredientPicker`/`StepListEditor`/`DietTagSelect`, même
forme de payload `CreateRecipeInput`), plus une section **"à compléter"**
pour les lignes d'ingrédient non résolues automatiquement
(`ingredient-matcher.ts`, côté API) : chaque ligne montre son texte brut, un
bouton "Choisir un ingrédient" (ouvre `IngredientPicker`, la quantité brute
est conservée) ou un bouton pour l'écarter. `canSubmit` exige zéro ligne non
résolue restante — **aucune recette invalide n'est jamais silencieusement
devinée/abandonnée** (décision produit explicite). Soumet vers `POST
/sources/:sourceKey/import/:externalId` au lieu de `POST /recipes`.
Prop optionnelle `planningSlot` : en cas de succès, appelle aussi `POST
/planning/items` avec les portions du formulaire.
- **`RecipeImportForm` a été extrait d'`ImportRecipePage`** pour que
`RecipePickerDialog` puisse l'embarquer directement comme une de ses étapes
`pages/ImportRecipePage.tsx` (`/recettes/importer/:sourceKey/:externalId`)
n'en est plus que le wrapper d'une **route de secours autonome et
partageable** (favori enregistré, page rechargée en plein milieu du flux),
plus le chemin principal. Elle décode toujours défensivement
`?planningDate=&planningWeekDay=&planningMeal=` (validés contre
`WEEK_DAYS`/`MEALS`) pour ce chemin historique.
### Surlignage des techniques et infobulle
- `highlight-tech-steps.ts`'s `splitDescriptionByTechSteps(description,
techSteps)` découpe une description d'étape en segments texte/technique à
partir des offsets `start`/`end` de chaque `StepTechStepView` (calculés
côté API par `matchTechStepSpans`, voir
[backend-architecture.md](./backend-architecture.md#détection-des-techniques--tech-step-matcherts)).
Trie défensivement par `start` et élimine silencieusement tout span aux
bornes invalides (négatif, hors texte, chevauchant un span déjà accepté) —
dégrade en "ne pas surligner celui-ci" plutôt que de planter/déformer
l'affichage.
- `StepDescription.tsx` rend les segments : texte brut tel quel, technique
entourée d'un vrai `<button type="button">` (pas un `<mark>` — nativement
focusable au clavier/lecteur d'écran) dans un `Tooltip`
(`components/ui/Tooltip.tsx`) dont le contenu est
`t(\`catalog.techSteps.${techStep.key}\`)`.
### Icônes d'ingrédients et badge "reproductible"
- `ingredient-icons.tsx` — ~20 pictogrammes génériques (remplace un ancien
schéma à un emoji par ingrédient, 437 cas, jugé peu professionnel/incohérent
en revue produit), groupés par **type de chose** (légume, bouteille,
fromage…) plutôt que par ingrédient précis. La plupart viennent du pack CC
BY 4.0 de foodiconpack.com (voir `CreditsPage`) via un wrapper `FilledIcon`
(glyphes pleins) ; trois (`BreadIcon`, `DoughIcon`, `SproutIcon`) sans bon
équivalent dans ce pack restent dessinés à la main (wrapper `Icon`,
traits) — dimensionnés en CSS pour que le mélange se lise comme un seul jeu
cohérent. `CATEGORY_ICON`/`SUBCATEGORY_ICON` donnent une icône
*représentative* par catégorie/sous-catégorie pour les lignes de
`IngredientPicker` (un choix éditorial, pas une donnée dérivée).
- `ReproducibleBadge.tsx` — rien si `!reproducible`
(`IngredientView.reproducible`, "raisonnablement faisable maison"). Pastille
simple dans la grille du picker, ou (avec `searchLabel`, dans
`IngredientRow`) un lien `<a>` classique (pas un `<Link>` router,
volontairement, pour ne jamais faire quitter un formulaire de recette en
cours) ouvrant `/recettes?search=<nom>` dans un nouvel onglet.
### Badges régime/allergènes et autres pièces
- `AllergenBadges.tsx` / `DietBadges.tsx` — listes de pastilles simples,
rendent `null` sur un tableau vide. `DietBadges` utilise le token
`--color-tag` (jamais `--color-allergen`) pour rester visuellement distinct
d'un avertissement de sécurité.
- `DietTagSelect.tsx` — fieldset multi-sélection (via `CheckboxOption`) pour
taguer manuellement le(s) régime(s) associé(s) d'une recette — utilisé dans
`RecipeFormPage`, `RecipeImportForm`, et les filtres de `RecipePickerDialog`.
- `RecipeTable.tsx` — tableau du catalogue (photo/nom+marque favori/allergènes/
régimes), clic sur une ligne = sélection (pas de navigation, le détail
s'affiche à côté dans `RecipeDetailPanel`).
- `RecipeDetailPanel.tsx` — union discriminée `"empty" | "loading" | "loaded" |
"loaded-draft" | "not-found" | "error"`. Croise les ingrédients de la recette
avec `dislikedIngredientIds` (préférence de goût du viewer) pour n'afficher
un badge 🚫 que sur les ingrédients concernés. Prop `showActions` (défaut
`true`) masque l'étoile favori + les boutons Modifier/Supprimer dans les
contextes de prévisualisation seule (`RecipePickerDialog`,
`RecipeSourcesPanel`).
- `FavoriteStarButton.tsx` — bascule optimiste (`POST`/`DELETE
/recipes/:id/favorite`, restaurée en cas d'échec).
- `IngredientPicker.tsx` — parcours à deux niveaux catégorie→sous-catégorie
(rayons de supermarché) + recherche + grille de cartes, remplace un ancien
dropdown autocomplete plat (400+ ingrédients, la recherche seule ne suffit
pas). Un menu "options d'affichage" bascule les badges
allergène/régime/reproductible par carte (préférence UI locale, pas
persistée). Réutilisé par `RecipeFormPage`, `RecipeImportForm`, le filtre
ingrédients de `RecipePickerDialog`, et `DislikedIngredientsField`.
- `IngredientRow.tsx` / `StepListEditor.tsx` — ligne d'ingrédient sélectionnée
(icône, nom, quantité, unité, badges, retrait) et éditeur d'étapes ordonné
(boutons monter/descendre, pas de drag-and-drop) du formulaire recette.
- `SourceItemTable.tsx` — tableau de parcours d'une source (photo+nom, badge
"déjà importé" au lieu des colonnes allergènes/régime — un item de source
n'est résolu contre les catalogues qu'à la prévisualisation).
---
## Foyer — sources externes activées
- **`features/house/SourceSelect.tsx`** — grille de cases à cocher pour quelles
sources un foyer voit, partagée telle quelle par `OnboardingSourcesPage` et
`HouseholdSettingsPage`'s section Sources. Chaque ligne : logo optionnel
(`iconUrl`), nom propre (non traduit), badge officiel/non-officiel
(`household.sources.official`/`unofficial`) pour juger la fiabilité d'une
source scrapée vs une API officielle. Sélection vide = état de départ
normal (aucune ligne `HouseSource` = masqué).
---
## Composants UI partagés (`components/ui/`)
- **`Dialog.tsx`** (+ `dialog.scss`) — primitive de modale bâtie sur l'élément
**`<dialog>` natif** (`showModal()`), pas une div `role="dialog"` — piège de
focus, fermeture sur Échap et arrière-plan obtenus gratuitement. Première
modale de l'app (chaque confirmation avant, ex. les zones dangereuses des
pages de paramètres, était une révélation en deux temps inline). Montée
seulement pendant qu'elle est ouverte. Le clic sur l'arrière-plan pour
fermer compare les coordonnées du clic au rectangle du panneau
(`getBoundingClientRect()` — un clic sur l'élément `<dialog>` lui-même se
produit à la fois pour un vrai clic d'arrière-plan *et* pour son propre
padding non rempli, seule la comparaison de coordonnées distingue les deux),
attaché impérativement plutôt que via `onClick` JSX (Échap couvre déjà le
cas clavier). Prop `footer` optionnelle pour un bandeau d'action épinglé
sous le corps défilant — utilisé par `RecipePickerDialog`.
- **`Checkbox.tsx`** (`CheckboxOption`) — factorise le balisage "carte
sélectionnable" (input natif caché + coche + texte) auparavant dupliqué
entre `AllergySelect`, `DietTagSelect`, le menu d'affichage
d'`IngredientPicker`. La classe `is-selected` est posée en JS depuis le
booléen `checked` (un chaînage CSS `:has(:checked)` s'est révélé peu fiable
entre navigateurs).
- **`Radio.tsx`** (`RadioOption<T extends string>`) — pendant `type="radio"`
de `CheckboxOption`, même balisage/apparence, sémantique radio native pour
l'exclusion mutuelle via un `name` partagé — utilisé par le sélecteur de
thème d'`UserPreferencesPage`.
- **`Tooltip.tsx`** (+ `tooltip.scss`) — infobulle **CSS-only** (pas de
librairie de positionnement) : wrapper `position: relative`, affichée via
`:hover`/`:focus-within` (aucun état JS). `children` doit être un seul
élément focusable ; cloné pour y attacher `aria-describedby` (lecteurs
d'écran). Utilisé par `StepDescription.tsx` pour l'infobulle des techniques.
---
@ -214,12 +635,21 @@ JSON, jamais codé en dur dans un composant.
- `i18n/i18n.ts` — initialise l'instance i18next (langue par défaut `fr`), importé
une seule fois pour son effet de bord dans `main.tsx`, avant le premier rendu.
- `locales/fr/translation.json` — toutes les chaînes françaises, organisées par
namespace : `errors.*` (voir [error-handling.md](./error-handling.md)),
`auth.login.*` / `auth.signup.*`, `layout.*` (nav de la sidebar, salutation,
déconnexion — `AppLayout`), `home.*` (planning), `recipes.*` / `shoppingList.*`
(copie des pages stub, voir `ComingSoonPage` plus haut), `onboarding.*` (wizard
d'inscription) et `household.*` (titre + `form.*`, champs partagés par le wizard
et `/foyer`).
namespace de premier niveau : `common` (libellés génériques réutilisés
partout), `errors` (voir [error-handling.md](./error-handling.md)), `auth`
(`auth.login.*`/`auth.signup.*`), `layout` (nav de la sidebar dont
`layout.settings.nav.*` pour le sous-menu Paramètres — `AppLayout`),
`planning` (grille de la semaine, `RecipePickerDialog`), `recipes`
(catalogue, tabs, import), `shoppingList` (page stub, voir `ComingSoonPage`
plus haut), `onboarding` (wizard d'inscription), `household` (titre +
`form.*`, champs partagés par le wizard et `/parametres/foyer`, plus
`household.sources.*` pour le badge officiel/non-officiel), `account` /
`preferences` / `userPreferences` / `credits` (les quatre autres pages de
paramètres), `catalog` (libellés des tables de référence —
`catalog.diets.<key>`, `catalog.allergens.<key>`, `catalog.ingredients.<key>`,
`catalog.units.<key>`, `catalog.techSteps.<key>` — un slug `key` de
`schema.prisma` par entrée, jamais le libellé lui-même stocké en base, voir
[batch-cooking-modele.md](./batch-cooking-modele.md)).
- Dans un composant : `const { t } = useTranslation(); t("auth.login.title")`.
- Ajouter une langue : créer `locales/<lng>/translation.json` avec les mêmes clés,
ajouter `resources.<lng>` dans `i18n/i18n.ts` — aucun composant à toucher.
@ -248,10 +678,13 @@ par un `declare global` sur `Express.Request`.
juste l'API moderne de Sass pour éviter un warning de dépréciation).
- **`styles/_theme.scss`** — tokens de design exposés en **custom properties CSS**
sur `:root` (`--color-primary`, `--space-md`, etc.), pas en simples variables
SCSS : ça les rend disponibles au runtime, pas seulement à la compilation — ce qui
permettrait un futur switch de thème (ex. mode sombre) en redéfinissant juste ces
variables, sans reconstruire les feuilles de style. Toute nouvelle règle CSS doit
référencer `var(--token)`, jamais une couleur/valeur en dur.
SCSS : ça les rend disponibles au runtime, pas seulement à la compilation —
ce qui a permis le switch de thème clair/sombre/système (voir
[Thème](#thème-clairsombresystème) plus haut) en redéfinissant juste ces
variables sous `[data-theme="dark"]` (et sous `prefers-color-scheme: dark`
quand aucun `data-theme` n'est posé, cas `SYSTEM`), sans reconstruire les
feuilles de style. Toute nouvelle règle CSS doit référencer `var(--token)`,
jamais une couleur/valeur en dur.
- **`styles/global.scss`** — importé une seule fois, dans `main.tsx`. Contient
uniquement le reset minimal et l'import du thème (`@use "./theme"`). Rien de
spécifique à une page/un composant n'y va.
@ -261,3 +694,70 @@ par un `declare global` sur `Express.Request`.
sans avoir besoin de `@use` le partiel theme (ce serait un import sans effet,
puisqu'aucun symbole Sass n'en est consommé). Chaque fichier documente en
commentaire à quoi correspond chaque règle un peu non-triviale.
---
## Tests (Cypress + Cucumber)
`apps/web/cypress.config.ts` déclare deux "testing types" indépendants :
- **`e2e`** — `specPattern` couvre à la fois les specs Cypress classiques
(`cypress/e2e/**/*.cy.ts`) **et** des fichiers Gherkin
(`cypress/e2e/**/*.feature`), via `@badeball/cypress-cucumber-preprocessor`
(+ un bundler esbuild).
- **`component`** (nouveau) — `cypress/component/**/*.cy.tsx`, monte un seul
composant UI générique à la fois (`components/ui/*`), sans routeur ni
backend — lancé via le script `cy:run:component` (nouveau, à côté de
`cy:open`/`cy:run`/`e2e`). Premier test de ce type dans le repo : mounter
`CheckboxOption` isolément, sans jamais visiter une page routée complète.
### Motif Gherkin
Pour chaque `.feature` (ex. `planning.feature`), un fichier de steps `.ts` du
même nom dans le même dossier (`planning.ts`) porte les fixtures/steps
**propres à cette feature** (mocks `cy.intercept`, interactions DOM
spécifiques à ce parcours) — délibérément **pas** partagés entre features, le
lookup de steps du préprocesseur Cucumber n'étant pas global à tout
`cypress/e2e/`. Features présentes : `account`, `auth`, `household-settings`,
`onboarding`, `planning`, `preferences`, `recipe-form`, `recipes`,
`recipe-sources`, `user-preferences`.
Les steps réellement partagés (par **toutes** les features) vivent dans
`cypress/support/step_definitions/` : `common.steps.ts` (connexion, navigation
générique — ex. `"I am signed in as {string} {string}"`), plus
`household-mutations.steps.ts`, `profile-mutations.steps.ts`,
`reference-data.steps.ts`. `common.steps.ts` évite délibérément un hook
Cucumber `Before()` : l'enregistrer ferait lire par le runtime navigateur du
préprocesseur un membre d'enum (`messages.HookType.BEFORE_TEST_CASE`) absent
de la version CommonJS de `@cucumber/messages` sur laquelle ce repo est pinné
(`pnpm.overrides`) — chaque scénario planterait avec "Cannot read properties
of undefined". La réinitialisation du profil se fait donc en ligne, dans le
step "I am signed in as..." par lequel commence de toute façon chaque chaîne
de scénario.
### Migration en cours — `.cy.ts` et `.feature` coexistent
Les fichiers `.cy.ts` classiques restants (`account`, `household-settings`,
`layout`, `planning-page`, `preferences`, `recipes`, `sidebar`, `smoke`,
`user-preferences`) ne sont **pas** un découpage définitif voulu — c'est une
migration en cours vers Cucumber. Certains parcours (bascule favori,
suppression de recette) ont déjà été migrés vers un couple `.feature`+`.ts`
dédié, laissant dans le `.cy.ts` d'origine ce qui n'est pas encore migré
(parcours de navigation/affichage plus larges, contrôles structurels de layout,
le check global "redirection si non authentifié" de `smoke.cy.ts`). Les deux
styles tournent dans la même commande `cy:run` puisqu'ils partagent le même
`specPattern`.
Toujours vrai par ailleurs (hérité de l'état précédent) : les tests 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` (voir
[backend-architecture.md](./backend-architecture.md), 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`) — pas un problème introduit par une
> modification du code. `pnpm --filter web e2e` fonctionne normalement en CI
> 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`, pas le
> conteneur Docker qui sert le frontend buildé, pas le serveur Vite).