Compare commits
2 commits
main
...
feat/unify
| Author | SHA1 | Date | |
|---|---|---|---|
| bc1f14f1cc | |||
| 4f509865c1 |
9 changed files with 1633 additions and 421 deletions
392
README.md
392
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -551,6 +551,13 @@ export const INGREDIENT_GROUPS: Array<{
|
|||
{ uid: "vealCutlet", allergenUids: [] },
|
||||
{ uid: "porkTenderloin", allergenUids: [] },
|
||||
{ uid: "porkChop", allergenUids: [] },
|
||||
// Ground/minced meats, requested alongside `groundBeef` (already
|
||||
// seeded) — one per red-meat type the catalog otherwise only offers
|
||||
// as a whole cut. Ground poultry (turkey/chicken) lives in the
|
||||
// `poultry` subcategory below, next to their whole-cut counterparts.
|
||||
{ uid: "groundVeal", allergenUids: [] },
|
||||
{ uid: "groundPork", allergenUids: [] },
|
||||
{ uid: "groundLamb", allergenUids: [] },
|
||||
{ uid: "lamb", allergenUids: [] },
|
||||
{ uid: "legOfLamb", allergenUids: [] },
|
||||
{ uid: "baconLardons", allergenUids: [] },
|
||||
|
|
@ -601,7 +608,9 @@ export const INGREDIENT_GROUPS: Array<{
|
|||
defaultIcon: "POULTRY",
|
||||
items: [
|
||||
{ uid: "chicken", allergenUids: [] },
|
||||
{ uid: "groundChicken", allergenUids: [] },
|
||||
{ uid: "turkey", allergenUids: [] },
|
||||
{ uid: "groundTurkey", allergenUids: [] },
|
||||
{ uid: "duck", allergenUids: [] },
|
||||
{ uid: "duckBreast", allergenUids: [] },
|
||||
// Ciqual 2025 additions — see the VIANDES group above.
|
||||
|
|
@ -937,7 +946,11 @@ export const INGREDIENT_GROUPS: Array<{
|
|||
subcategory: "eggs",
|
||||
defaultDiets: ["vegetarian", "pescatarian"],
|
||||
defaultIcon: "EGG",
|
||||
items: [{ uid: "egg", allergenUids: ["eggs"] }],
|
||||
items: [
|
||||
{ uid: "egg", allergenUids: ["eggs"] },
|
||||
{ uid: "eggYolk", allergenUids: ["eggs"] },
|
||||
{ uid: "eggWhite", allergenUids: ["eggs"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "dairyAndCheese",
|
||||
|
|
@ -997,6 +1010,7 @@ export const INGREDIENT_GROUPS: Array<{
|
|||
{ uid: "fiveSpice", allergenUids: [] },
|
||||
{ uid: "garamMasala", allergenUids: [] },
|
||||
{ uid: "corianderSeeds", allergenUids: [] },
|
||||
{ uid: "groundCoriander", allergenUids: [] },
|
||||
{ uid: "cardamom", allergenUids: [] },
|
||||
{ uid: "fenugreek", allergenUids: [] },
|
||||
{ uid: "jalapeno", allergenUids: [] },
|
||||
|
|
|
|||
|
|
@ -547,6 +547,9 @@
|
|||
"vealCutlet": "Escalope de veau",
|
||||
"porkTenderloin": "Filet mignon de porc",
|
||||
"porkChop": "Côte de porc",
|
||||
"groundVeal": "Veau haché",
|
||||
"groundPork": "Porc haché",
|
||||
"groundLamb": "Agneau haché",
|
||||
"lamb": "Agneau",
|
||||
"legOfLamb": "Gigot d'agneau",
|
||||
"baconLardons": "Lardons",
|
||||
|
|
@ -585,7 +588,9 @@
|
|||
"beefMuzzle": "Museau de bœuf",
|
||||
"grisonsDriedBeef": "Viande des Grisons",
|
||||
"chicken": "Poulet",
|
||||
"groundChicken": "Poulet haché",
|
||||
"turkey": "Dinde",
|
||||
"groundTurkey": "Dinde hachée",
|
||||
"duck": "Canard",
|
||||
"duckBreast": "Magret de canard",
|
||||
"quail": "Caille",
|
||||
|
|
@ -793,6 +798,8 @@
|
|||
"kefir": "Kéfir",
|
||||
"greekYogurt": "Yaourt à la grecque",
|
||||
"egg": "Œuf",
|
||||
"eggYolk": "Jaune d'œuf",
|
||||
"eggWhite": "Blanc d'œuf",
|
||||
"coconutMilk": "Lait de coco",
|
||||
"coconutCream": "Crème de coco",
|
||||
"almondMilk": "Lait d'amande",
|
||||
|
|
@ -834,6 +841,7 @@
|
|||
"fiveSpice": "Cinq épices",
|
||||
"garamMasala": "Garam masala",
|
||||
"corianderSeeds": "Graines de coriandre",
|
||||
"groundCoriander": "Coriandre en poudre",
|
||||
"cardamom": "Cardamome",
|
||||
"fenugreek": "Fenugrec",
|
||||
"jalapeno": "Piment jalapeño",
|
||||
|
|
|
|||
|
|
@ -137,6 +137,9 @@ export const INGREDIENT_LABELS_EN: Record<string, string> = {
|
|||
vealCutlet: "Veal cutlet",
|
||||
porkTenderloin: "Pork tenderloin",
|
||||
porkChop: "Pork chop",
|
||||
groundVeal: "Ground veal",
|
||||
groundPork: "Ground pork",
|
||||
groundLamb: "Ground lamb",
|
||||
lamb: "Lamb",
|
||||
legOfLamb: "Leg of lamb",
|
||||
baconLardons: "Bacon lardons",
|
||||
|
|
@ -177,7 +180,9 @@ export const INGREDIENT_LABELS_EN: Record<string, string> = {
|
|||
|
||||
// Poultry
|
||||
chicken: "Chicken",
|
||||
groundChicken: "Ground chicken",
|
||||
turkey: "Turkey",
|
||||
groundTurkey: "Ground turkey",
|
||||
duck: "Duck",
|
||||
duckBreast: "Duck breast",
|
||||
quail: "Quail",
|
||||
|
|
@ -403,6 +408,8 @@ export const INGREDIENT_LABELS_EN: Record<string, string> = {
|
|||
|
||||
// Eggs
|
||||
egg: "Egg",
|
||||
eggYolk: "Egg yolk",
|
||||
eggWhite: "Egg white",
|
||||
|
||||
// Plant-based alternatives
|
||||
coconutMilk: "Coconut milk",
|
||||
|
|
@ -448,6 +455,7 @@ export const INGREDIENT_LABELS_EN: Record<string, string> = {
|
|||
fiveSpice: "Five-spice powder",
|
||||
garamMasala: "Garam masala",
|
||||
corianderSeeds: "Coriander seeds",
|
||||
groundCoriander: "Ground coriander",
|
||||
cardamom: "Cardamom",
|
||||
fenugreek: "Fenugreek",
|
||||
jalapeno: "Jalapeño",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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é.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Reference in a new issue