From 5d63ff9ea950bc5c8f099506f78093a892b328ef Mon Sep 17 00:00:00 2001 From: kyuno053 <31762247+kyuno053@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:16:22 +0200 Subject: [PATCH] fix: corrige les bugs ouverts du repo (import TheMealDB, sidebar mobile) + doc (#59) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(recipes): corrige plusieurs bugs d'import TheMealDB - Les instructions TheMealDB numérotées sur leur propre ligne ("1\n\ntexte...\n\n2\n\ntexte...") créaient des étapes parasites ne contenant qu'un chiffre — filtrées désormais (#52). - Un ingrédient compté sans mot d'unité dans le texte source (ex. "4 Egg Yolks") laissait l'import bloqué sur "Importer" indéfiniment, sans indication visuelle de la ligne en cause — matchUnit retombe maintenant sur l'unité générique "piece" quand une quantité a été extraite, et RecipeImportForm/RecipeFormPage surlignent désormais toute ligne dont l'unité manque, avec un message explicite (#53). - Ajout de INGREDIENT_LABEL_SYNONYMS_EN pour reconnaître des formulations alternatives fréquentes chez les sources anglophones ("vanilla pod" en plus de "vanilla bean") sans élargir INGREDIENT_LABELS_EN à un tableau pour ses ~550 entrées (#54). - Effet de bord découvert en vérifiant #53 de bout en bout : deux lignes source résolues vers le même ingrédient catalogue (ex. "Egg Yolks"/"Eggs" -> "Œuf") faisaient planter la création en 500 (contrainte unique recipe_id+ingredient_id) au lieu d'un 400 propre. createRecipeSchema rejette maintenant les ingredientId en double, et le formulaire d'import surligne les doublons avant même de soumettre. Vérifié de bout en bout dans le navigateur (import réel de la recette "Flan" depuis TheMealDB, jusqu'au planning) en plus des tests ajoutés. Closes #52, #53, #54 Co-Authored-By: Claude Sonnet 5 * fix(layout): la sidebar réduite écrasait la barre mobile `isCollapsed` (rail icône seule sur desktop) persiste dans localStorage indépendamment de la largeur de fenêtre — un utilisateur ayant réduit la sidebar sur desktop puis ouvrant la même session sur mobile (ou réduisant la fenêtre sous 640px) gardait `.app-sidebar.collapsed` (spécificité 0,2,0 : width 4.25rem, flex-direction column), qui l'emportait sur la règle mobile `@media (max-width: 640px)` (spécificité 0,1,0) censée passer la sidebar en barre horizontale pleine largeur. Le bloc `&.collapsed` est maintenant scopé sous `@media (min-width: 641px)` — le complément exact du breakpoint mobile — donc il ne s'applique plus du tout en dessous. Vérifié dans le navigateur : sidebar collapsed=true dans localStorage, viewport 375px — la sidebar calcule bien width: 375px / flex-direction: row (barre horizontale pleine largeur) au lieu de 4.25rem/column. Closes #27 Co-Authored-By: Claude Sonnet 5 * docs(readme): documente GET /planning?date=, plus /planning/current Le README documentait encore `GET /planning/current` (401 sans session, couvre "aujourd'hui"), une route qui n'existe plus — `planning.routes.ts` ne définit que `GET /planning?date=YYYY-MM-DD` depuis l'introduction de la grille de semaine complète. Sans session, `/planning/current` renvoie un 404 générique (route inexistante), pas le 401 documenté. Documente aussi POST/DELETE /planning/items au passage, absents jusqu'ici. Closes #55 Co-Authored-By: Claude Sonnet 5 * docs: met à jour README et specs/ avec l'état réel du code Le code avait beaucoup évolué depuis la dernière mise à jour de la documentation (sources externes, import de recettes, planning en grille, pages de paramètres, thème, tests Cucumber...) sans que README.md/specs/*.md ne suivent. Tour complet du code (backend + frontend) et réécriture : - specs/batch-cooking-modele.md : schéma de données réécrit depuis schema.prisma (foyer/admin/invitation, sources, catalogue ingrédients/unités, techniques détectées, visibilité des recettes). - specs/backend-architecture.md : foyer, préférences/goûts, planning, référence, sources externes (adaptateurs/registre/sync), matching ingrédients/techniques, isolation base de test, suppression de compte. - specs/frontend-architecture.md : routing complet, sidebar/paramètres, thème, planning + picker, catalogue + import, composants UI partagés, tests Cypress+Cucumber. - specs/batch-cooking-architecture.md : module Import passe de TODO à implémenté. - specs/error-handling.md : liste complète des ~19 codes d'erreur. - README.md : réécriture pour refléter tout ce qui précède, plus la note (dangereusement obsolète) sur le partage base de test/dev — le fix existe déjà (apps/api/.env.test), la doc décrivait encore le bug. * feat(ingredients): ajoute jaune/blanc d'oeuf, coriandre en poudre, viandes hachées Complète le catalogue d'ingrédients de référence (seed data) : - jaune d'oeuf / blanc d'oeuf (dairyAndCheese/eggs, aux côtés d'"egg") - coriandre en poudre (condimentsAndSpices/spices, aux côtés de corianderSeeds/freshCilantro déjà présents) - viandes hachées manquantes : veau, porc, agneau (meatAndSeafood/meats, aux côtés de groundBeef déjà présent), dinde et poulet (meatAndSeafood/poultry) Libellés ajoutés dans apps/web/src/locales/fr/translation.json (source d'affichage) et packages/shared/src/data/catalog-labels-en.ts (matching anglais pour l'import de recettes depuis des sources comme TheMealDB). Aucune icône ni régime dédiés : héritent des défauts de leur groupe (EGG/SPICE/MEAT/POULTRY, mêmes dietUids que leurs groupes respectifs). 282 tests apps/api toujours au vert (resetDatabase() reseed le catalogue à chaque test). * fix(i18n): retire le œ ligaturé des libellés français de l'œuf "Œuf"/"Œufs" (ingrédient, sous-catégorie, allergène) et "Jaune/Blanc d'œuf" (ajoutés par #60) s'écrivaient avec le œ ligaturé — remplacé par "oe" (deux lettres) partout où le mot apparaît. Ne touche pas "bœuf" (mot différent, non concerné). Le scénario Cucumber recipe-form.feature qui sélectionne l'ingrédient par son libellé affiché est mis à jour en conséquence. Co-Authored-By: Claude Sonnet 5 * fix(recipes): concatène les ingrédients dupliqués à l'import Suite au retour utilisateur sur #53 (follow-up) : au lieu de bloquer l'import et de demander à l'utilisateur de retirer une ligne en double à la main, deux lignes source qui résolvent vers le même ingrédient catalogue sont désormais fusionnées automatiquement, quantité concaténée (sommée), avant même que l'écran de revue ne s'affiche. - mergeDuplicateIngredients (recipe-translation.ts) : même unité des deux côtés -> somme directe. Unité différente mais même UnitType (MASS/VOLUME) -> conversion via toBaseFactor avant de sommer, exprimée dans l'unité de la première ligne. UnitType différent, ou COUNT des deux côtés (une "pincée" n'est pas une fraction fixe d'une "gousse", cf. le commentaire de UnitView) -> jamais fusionnées, laissées en double (createRecipeSchema/RecipeImportForm continuent de les signaler, filet de sécurité déjà en place). Les lignes non résolues (ingredientId: null) ne sont jamais fusionnées entre elles. - rawText concaténé ("100g Sugar + 45g Sugar") pour la traçabilité. - Branché dans previewSourceItem (sources.service.ts), juste après translateRecipeIngredients — c'est le seul endroit où des doublons peuvent apparaître (la création manuelle ne peut pas en produire, IngredientPicker exclut déjà les ingrédients déjà sélectionnés). Vérifié via l'API en local (import réel de "Flan" depuis TheMealDB) : "100g Sugar"/"45g Sugar" -> une seule ligne Sucre, 145g. Co-Authored-By: Claude Sonnet 5 * chore(lint): upgrade Biome vers 2.x, active noExplicitAny/noConsole/noFloatingPromises `@biomejs/biome` passe de 1.9.4 à 2.5.9 (config migrée via `biome migrate --write`) — nécessaire pour noFloatingPromises, une règle type-aware apparue en 2.0 (nursery). - noExplicitAny : déjà "recommended", actif depuis toujours, aucun changement. - noConsole (biome.json) : bloque tout `console.*` sauf error/warn/info/ debug/table/assert — équivalent à "pas de console.log" sans interdire les niveaux nommés (voir le nouveau log service dans le prochain commit, qui centralise justement ces appels). - noFloatingPromises (nursery) activé explicitement sous `rules.nursery` sans avoir besoin d'activer le domaine "types" au sens large (ça aurait aussi allumé des dizaines d'autres règles type-aware type noUnresolvedImports/noUnnecessaryConditions, hors scope ici). Le reste du diff, c'est soit du reformatage automatique (import sort, 2.x ordonne différemment de 1.9.4 — `biome check --write --unsafe`), soit les corrections des ~20 promesses flottantes que la nouvelle règle a fait remonter : - La plupart sont des `navigate(...)` non attendus (react-router v7 type `navigate` en `void | Promise`) — préfixés `void navigate(...)`, aucun changement de comportement. - Trois chargements initiaux en useEffect (OnboardingAllergensPage, OnboardingDietPage, OnboardingHouseholdPage, HouseholdSettingsPage) n'avaient jamais de `.catch()` du tout — ajouté (dégradation silencieuse vers un état vide/par défaut, même raisonnement que le `.catch()` déjà présent dans OnboardingSourcesPage). - HouseholdSettingsPage : `loadHouse` était une fonction déclarée à chaque render (donc une référence différente à chaque fois) utilisée comme dépendance de useEffect ET passée en callback à des enfants — le useEffect se re-déclenchait donc à chaque re-render provoqué par son propre fetch, un vrai bug de boucle infinie de requêtes que noFloatingPromises a fait remonter indirectement (via useExhaustiveDependencies). Corrigé avec useCallback([]). - RecipeDetailPanel : une clé de liste `${index}-...}` sur une liste statique (draft.steps, sans id stable — DraftRecipeStepView n'en a pas) — biome-ignore justifié, pas de bug réel. - recipe.test.ts : variable `agent` non utilisée, retirée. Vérifié : `pnpm --filter api test` (295/295), `pnpm lint` et `pnpm build` clean sur tout le repo. Co-Authored-By: Claude Sonnet 5 * feat(api): ajoute un log service pour les logs de fonctionnement côté serveur Jusqu'ici, rien ne journalisait quoi que ce soit côté serveur : aucune trace au démarrage à part un console.log ad hoc, et surtout aucune trace des requêtes ni des erreurs gérées par ErrorHandlerService — un 500 en production n'aurait laissé aucune trace exploitable. - LoggerService (apps/api/src/lib/logger.service.ts) — classe (public debug/info/warn/error, private emit), même convention que ErrorHandlerService (packages/error-tools) : instance unique partagée exportée (`export const logger = new LoggerService()`). Émet une ligne JSON structurée par appel (timestamp/level/message + meta), filtrée par seuil selon NODE_ENV (debug complet en dev, warn+ pendant les tests pour ne pas alourdir la sortie de Mocha, info+ en production). Seul endroit du code autorisé à toucher `console` directement (biome-ignore justifié), toujours via une méthode nommée — jamais un console.log nu. - requestLogger (middlewares/request-logger.ts) — une ligne par requête terminée (méthode/chemin/statut/durée), montée en tout premier dans app.ts, avant même setupCore (CORS/JSON/cookies), pour englober tout le pipeline. Niveau déduit du statut (info/warn/error). - errorLogger (middlewares/error-logger.ts) — monté juste avant createErrorMiddleware : réutilise errorHandlerService.handle() (pur/ sans effet de bord) pour classifier l'erreur avant que la vraie réponse ne soit construite, log en warn les 4xx routiniers (validation, 404, 401...) et en error les 5xx/exceptions non prévues (avec la stack). - error-handler.service.ts : retire le `console.error(error)` ad hoc de fromUnknownError — errorLogger voit désormais chaque erreur avant que ce service ne la mappe, donc ce console.error faisait doublon (et loggait en texte brut, pas en JSON structuré). - server.ts : le console.log de démarrage passe par logger.info. Vérifié : pnpm --filter api test (303/303, dont 8 nouveaux tests sur LoggerService), pnpm lint/build clean, testé en live (pnpm dev:api + curl) — logs JSON corrects pour un 200, un 404, un 401. Co-Authored-By: Claude Sonnet 5 * style: préfixe tous les membres private/protected par _ Convention demandée par l'utilisateur : `emit` -> `_emit`, sur toutes les classes du repo, pas seulement le nouveau code. `public` reste sans préfixe. - LoggerService (apps/api) : _minSeverity, _emit. - ApiClient (apps/web) : _request (39 sites d'appel mis à jour). - ErrorHandlerService (packages/error-tools) : _fromZodError, _fromHttpError, _fromUnknownError. - ExpressServer (packages/express-tools) : _app, _registeredRoutes. Aucun changement de comportement — pur renommage interne, aucune méthode private/protected n'était appelée depuis l'extérieur de sa classe. Vérifié : pnpm --filter api test (303/303), pnpm lint/build clean sur tout le repo (apps/api, apps/web, packages/*). Co-Authored-By: Claude Sonnet 5 * docs(specs): documente les conventions de développement du repo Nouveau specs/dev-conventions.md — jusqu'ici ces règles n'existaient que dans l'historique de commits/PR (classes vs objets littéraux pour la logique de service, préfixe _ sur private/protected, règles Biome actives, log service, tests sans mocks de la DB, conventions git/PR...), rien de centralisé pour un futur contributeur (humain ou Claude Code). Référencé depuis README.md, section "Qualité / Tests". Co-Authored-By: Claude Sonnet 5 * refactor(web): regroupe pages/ par section au lieu d'un dossier à plat pages/ mélangeait 8 fichiers directement à sa racine (LoginPage, SignupPage, PlanningPage+scss, RecipesPage, RecipeFormPage, ImportRecipePage, ShoppingListPage, ComingSoonPage+scss) à côté de deux sous-dossiers déjà groupés (onboarding/, settings/) — incohérent, et difficile à parcourir une fois le nombre de pages monté. Un sous-dossier par section routée, même règle que onboarding/settings existants : - pages/auth/ — LoginPage, SignupPage - pages/planning/ — PlanningPage + planning-page.scss - pages/recipes/ — RecipesPage, RecipeFormPage, ImportRecipePage - pages/shopping-list/ — ShoppingListPage ComingSoonPage (+ .scss) déménage vers components/ui/ — ce n'est pas une page routée elle-même (ShoppingListPage l'enveloppe), c'est un composant UI générique réutilisable, sa place est aux côtés de Dialog/Tooltip/etc., pas dans pages/. Chemins relatifs internes de chaque fichier déplacé mis à jour (un niveau de profondeur en plus), imports dans App.tsx repointés, tri Biome réappliqué. specs/frontend-architecture.md mis à jour (arborescence + références de chemin). Vérifié : pnpm build clean (apps/web, 1952 modules), pnpm lint clean sur tout le repo, testé en live dans le navigateur (login/signup, planning, recettes, nouvelle recette, liste de courses, paramètres) — aucune route cassée. Co-Authored-By: Claude Sonnet 5 * refactor(web): regroupe features/recipes/ par sous-domaine au lieu d'un dossier à plat 20 fichiers à plat -> badges/ (DietTagSelect, DietBadges, AllergenBadges, ReproducibleBadge, FavoriteStarButton), ingredients/ (IngredientPicker, IngredientRow, ingredient-icons), steps/ (StepListEditor, StepDescription, highlight-tech-steps), sources/ (RecipeSourcesPanel, SourceItemTable, RecipeImportForm, recipe-import-draft, useEnabledSources). RecipeTable/RecipeTabs/RecipeDetailPanel et recipes.scss restent à la racine (composants transverses aux sous-dossiers, partagés par plusieurs d'entre eux). Chemins relatifs corrigés dans les fichiers déplacés et chez tous leurs importeurs externes (pages/recipes/*, features/planning/ RecipePickerDialog.tsx, features/profile/DislikedIngredientsField.tsx), doc mise à jour (specs/frontend-architecture.md, specs/batch-cooking- modele.md). Vérifié : tsc --noEmit, biome check, build complet, 303 tests API, vérification live navigateur (planning, /recettes, /recettes/nouvelle). Co-Authored-By: Claude Sonnet 5 * refactor(api): regroupe lib/ par sous-domaine au lieu d'un dossier à plat 9 fichiers à plat -> recipe-sources/ (recipe-source-adapter, recipe-source- errors, recipe-source-registry) et recipe-matching/ (recipe-translation, ingredient-matcher, tech-step-matcher). jwt.ts, safe-profile.ts et logger.service.ts restent à la racine de lib/ (pas de sous-domaine partagé avec les autres). Chemins relatifs corrigés dans les fichiers déplacés (profondeur +1 vers db/) et chez tous leurs importeurs (modules/sources, modules/recipe, sources/*, db/recipe-source-sync.ts, 12 fichiers de test), doc mise à jour (specs/backend-architecture.md, specs/batch-cooking-architecture.md). Vérifié : tsc --noEmit, biome check, build complet, 303 tests API. Co-Authored-By: Claude Sonnet 5 * refactor(api): regroupe test/ par sous-domaine, miroir de src/lib/ 18 fichiers à plat -> recipe-matching/ (ingredient-matcher, recipe- translation, tech-step-matcher — miroir de lib/recipe-matching/), recipe-sources/ (json-ld-recipe, recipe-source, recipe-source-sync, the-meal-db — miroir de lib/recipe-sources/), sources/ (sources, sources-index — module + registration src/sources/index.ts). Les tests par domaine API sans regroupement naturel (auth, health, house, logger.service, planning, preferences, profile, recipe, reference) restent à la racine de test/, un fichier par domaine — même logique que jwt.ts/safe-profile.ts restés à la racine de lib/. Chemins relatifs corrigés (../src/ -> ../../src/, ../test-support/ -> ../../test-support/ dans les fichiers déplacés qui appellent resetDatabase). .mocharc.json ("test/**/*.test.ts") couvre déjà les sous-dossiers, aucun changement de config nécessaire. Vérifié : biome check, 303 tests API. Co-Authored-By: Claude Sonnet 5 * feat(convention): impose try/catch autour de chaque await/corps async Nouvelle règle de dev : aucun await nu, et un corps de fonction/méthode async doit intégralement vivre dans un try/catch (pas seulement la ou les lignes qui awaitent). Documentée dans specs/dev-conventions.md avec son périmètre (code applicatif — services/hooks/composants/middlewares — routes *.routes.ts exemptées car déjà couvertes par wrapAsyncHandler ; tests et scripts one-off exemptés aussi). Appliqué rétroactivement à tout le code applicatif qui ne l'était pas déjà : - api : auth/house/profile/preferences/planning/reference/recipe/ sources .service.ts, recipe-source-sync.ts, recipe-translation.ts, ingredient-matcher.ts, tech-step-matcher.ts, json-ld-recipe.ts, the-meal-db.ts — un try/catch par fonction async, rethrow simple (le middleware d'erreur logge déjà tout centralement, voir error-logger.ts) sauf quand un catch avait déjà une logique propre (ex. le retry de createHouse). - web : api/client.ts (_request), AuthContext.tsx, ThemeContext.tsx, AppLayout.tsx (handleLogout), HouseholdSettingsPage.tsx (handleCopy/ handleRemove/handleDelete/handleLeave) — la plupart des handlers de formulaire avaient déjà ce pattern, seuls ceux qui laissaient un await nu ont été corrigés. lint/complexity/noUselessCatch désactivé dans biome.json (interdisait justement le catch-qui-rethrow que cette convention impose). Vérifié : tsc --noEmit (api+web), biome check (0 erreur, repo entier), build complet, 303 tests API, vérification live navigateur (thème, déconnexion, copie du code d'invitation). Co-Authored-By: Claude Sonnet 5 * fix(web): corrige l'import cassé de highlight-tech-steps.cy.tsx Oubli lors du regroupement de features/recipes/ par sous-domaine (refactor(web): regroupe features/recipes/...) : le déplacement de highlight-tech-steps.ts vers features/recipes/steps/ n'avait pas été répercuté dans ce test composant Cypress (hors de apps/web/src, donc raté par la recherche de référence externe à l'époque) — faisait planter le job e2e en CI ("Failed to fetch dynamically imported module"). Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: Claude Sonnet 5 --- README.md | 396 ++++++---- apps/api/src/app.ts | 20 +- apps/api/src/db/recipe-source-sync.ts | 65 +- apps/api/src/db/reference-seed-data.ts | 16 +- apps/api/src/lib/logger.service.ts | 102 +++ .../ingredient-matcher.ts | 58 +- .../recipe-translation.ts | 184 ++++- .../tech-step-matcher.ts | 30 +- .../recipe-source-adapter.ts | 0 .../recipe-source-errors.ts | 0 .../recipe-source-registry.ts | 0 apps/api/src/middlewares/error-logger.ts | 40 + apps/api/src/middlewares/request-logger.ts | 45 ++ apps/api/src/modules/auth/auth.routes.ts | 2 +- apps/api/src/modules/auth/auth.service.ts | 96 ++- apps/api/src/modules/house/house.routes.ts | 2 +- apps/api/src/modules/house/house.service.ts | 370 +++++---- .../src/modules/planning/planning.routes.ts | 2 +- .../src/modules/planning/planning.service.ts | 180 +++-- .../preferences/preferences.service.ts | 30 +- .../src/modules/profile/profile.service.ts | 157 ++-- apps/api/src/modules/recipe/recipe.routes.ts | 2 +- apps/api/src/modules/recipe/recipe.service.ts | 592 +++++++++------ .../modules/reference/reference.service.ts | 129 ++-- .../api/src/modules/sources/sources.routes.ts | 2 +- .../src/modules/sources/sources.service.ts | 272 ++++--- apps/api/src/server.ts | 3 +- apps/api/src/sources/index.ts | 2 +- apps/api/src/sources/json-ld-recipe.ts | 44 +- apps/api/src/sources/the-meal-db.ts | 97 ++- apps/api/test/house.test.ts | 7 +- apps/api/test/logger.service.test.ts | 105 +++ .../ingredient-matcher.test.ts | 41 +- .../recipe-translation.test.ts | 152 +++- .../tech-step-matcher.test.ts | 8 +- .../json-ld-recipe.test.ts | 7 +- .../recipe-source-sync.test.ts | 15 +- .../recipe-source.test.ts | 8 +- .../{ => recipe-sources}/the-meal-db.test.ts | 21 +- apps/api/test/recipe.test.ts | 41 +- apps/api/test/reference.test.ts | 7 +- .../test/{ => sources}/sources-index.test.ts | 4 +- apps/api/test/{ => sources}/sources.test.ts | 95 ++- .../component/highlight-tech-steps.cy.tsx | 2 +- apps/web/cypress/e2e/recipe-form.feature | 6 +- apps/web/cypress/e2e/recipes.cy.ts | 2 +- apps/web/src/App.tsx | 14 +- apps/web/src/api/client.ts | 129 ++-- .../ui}/ComingSoonPage.scss | 0 apps/web/src/components/ui/ComingSoonPage.tsx | 26 + apps/web/src/components/ui/Tooltip.tsx | 2 +- apps/web/src/features/auth/AuthContext.tsx | 40 +- .../features/planning/RecipePickerDialog.tsx | 23 +- .../profile/DislikedIngredientsField.tsx | 4 +- .../features/recipes/RecipeDetailPanel.tsx | 15 +- apps/web/src/features/recipes/RecipeTable.tsx | 4 +- .../recipes/{ => badges}/AllergenBadges.tsx | 2 +- .../recipes/{ => badges}/DietBadges.tsx | 2 +- .../recipes/{ => badges}/DietTagSelect.tsx | 4 +- .../{ => badges}/FavoriteStarButton.tsx | 6 +- .../{ => badges}/ReproducibleBadge.tsx | 2 +- .../{ => ingredients}/IngredientPicker.tsx | 12 +- .../{ => ingredients}/IngredientRow.tsx | 30 +- .../{ => ingredients}/ingredient-icons.tsx | 0 apps/web/src/features/recipes/recipes.scss | 14 + .../{ => sources}/RecipeImportForm.tsx | 51 +- .../{ => sources}/RecipeSourcesPanel.tsx | 6 +- .../recipes/{ => sources}/SourceItemTable.tsx | 2 +- .../{ => sources}/recipe-import-draft.ts | 2 +- .../{ => sources}/useEnabledSources.ts | 2 +- .../recipes/{ => steps}/StepDescription.tsx | 2 +- .../recipes/{ => steps}/StepListEditor.tsx | 4 +- .../{ => steps}/highlight-tech-steps.ts | 0 apps/web/src/features/theme/ThemeContext.tsx | 16 +- apps/web/src/layouts/AppLayout.scss | 98 +-- apps/web/src/layouts/AppLayout.tsx | 11 +- apps/web/src/layouts/nav-icons.tsx | 22 +- apps/web/src/locales/fr/translation.json | 18 +- apps/web/src/pages/ComingSoonPage.tsx | 23 - apps/web/src/pages/{ => auth}/LoginPage.tsx | 12 +- apps/web/src/pages/{ => auth}/SignupPage.tsx | 12 +- .../onboarding/OnboardingAllergensPage.tsx | 10 +- .../pages/onboarding/OnboardingDietPage.tsx | 7 +- .../onboarding/OnboardingHouseholdPage.tsx | 7 +- .../onboarding/OnboardingSourcesPage.tsx | 6 +- .../src/pages/{ => planning}/PlanningPage.tsx | 6 +- .../pages/{ => planning}/planning-page.scss | 0 .../pages/{ => recipes}/ImportRecipePage.tsx | 6 +- .../pages/{ => recipes}/RecipeFormPage.tsx | 30 +- .../src/pages/{ => recipes}/RecipesPage.tsx | 33 +- .../pages/settings/AccountSettingsPage.tsx | 2 +- apps/web/src/pages/settings/CreditsPage.tsx | 2 +- .../pages/settings/HouseholdSettingsPage.tsx | 57 +- .../{ => shopping-list}/ShoppingListPage.tsx | 2 +- biome.json | 36 +- package.json | 2 +- packages/date-tools/src/index.ts | 5 +- .../error-tools/src/error-handler.service.ts | 22 +- packages/express-tools/src/express-server.ts | 32 +- packages/shared/src/data/catalog-labels-en.ts | 26 + packages/shared/src/schemas/recipe.ts | 48 +- pnpm-lock.yaml | 74 +- specs/backend-architecture.md | 398 +++++++++- specs/batch-cooking-architecture.md | 71 +- specs/batch-cooking-modele.md | 414 +++++++--- specs/dev-conventions.md | 275 +++++++ specs/error-handling.md | 43 +- specs/frontend-architecture.md | 717 +++++++++++++++--- 108 files changed, 4687 insertions(+), 1713 deletions(-) create mode 100644 apps/api/src/lib/logger.service.ts rename apps/api/src/lib/{ => recipe-matching}/ingredient-matcher.ts (80%) rename apps/api/src/lib/{ => recipe-matching}/recipe-translation.ts (50%) rename apps/api/src/lib/{ => recipe-matching}/tech-step-matcher.ts (92%) rename apps/api/src/lib/{ => recipe-sources}/recipe-source-adapter.ts (100%) rename apps/api/src/lib/{ => recipe-sources}/recipe-source-errors.ts (100%) rename apps/api/src/lib/{ => recipe-sources}/recipe-source-registry.ts (100%) create mode 100644 apps/api/src/middlewares/error-logger.ts create mode 100644 apps/api/src/middlewares/request-logger.ts create mode 100644 apps/api/test/logger.service.test.ts rename apps/api/test/{ => recipe-matching}/ingredient-matcher.test.ts (82%) rename apps/api/test/{ => recipe-matching}/recipe-translation.test.ts (67%) rename apps/api/test/{ => recipe-matching}/tech-step-matcher.test.ts (98%) rename apps/api/test/{ => recipe-sources}/json-ld-recipe.test.ts (98%) rename apps/api/test/{ => recipe-sources}/recipe-source-sync.test.ts (93%) rename apps/api/test/{ => recipe-sources}/recipe-source.test.ts (96%) rename apps/api/test/{ => recipe-sources}/the-meal-db.test.ts (88%) rename apps/api/test/{ => sources}/sources-index.test.ts (89%) rename apps/api/test/{ => sources}/sources.test.ts (79%) rename apps/web/src/{pages => components/ui}/ComingSoonPage.scss (100%) create mode 100644 apps/web/src/components/ui/ComingSoonPage.tsx rename apps/web/src/features/recipes/{ => badges}/AllergenBadges.tsx (97%) rename apps/web/src/features/recipes/{ => badges}/DietBadges.tsx (97%) rename apps/web/src/features/recipes/{ => badges}/DietTagSelect.tsx (92%) rename apps/web/src/features/recipes/{ => badges}/FavoriteStarButton.tsx (92%) rename apps/web/src/features/recipes/{ => badges}/ReproducibleBadge.tsx (98%) rename apps/web/src/features/recipes/{ => ingredients}/IngredientPicker.tsx (96%) rename apps/web/src/features/recipes/{ => ingredients}/IngredientRow.tsx (59%) rename apps/web/src/features/recipes/{ => ingredients}/ingredient-icons.tsx (100%) rename apps/web/src/features/recipes/{ => sources}/RecipeImportForm.tsx (87%) rename apps/web/src/features/recipes/{ => sources}/RecipeSourcesPanel.tsx (98%) rename apps/web/src/features/recipes/{ => sources}/SourceItemTable.tsx (98%) rename apps/web/src/features/recipes/{ => sources}/recipe-import-draft.ts (100%) rename apps/web/src/features/recipes/{ => sources}/useEnabledSources.ts (96%) rename apps/web/src/features/recipes/{ => steps}/StepDescription.tsx (97%) rename apps/web/src/features/recipes/{ => steps}/StepListEditor.tsx (97%) rename apps/web/src/features/recipes/{ => steps}/highlight-tech-steps.ts (100%) delete mode 100644 apps/web/src/pages/ComingSoonPage.tsx rename apps/web/src/pages/{ => auth}/LoginPage.tsx (92%) rename apps/web/src/pages/{ => auth}/SignupPage.tsx (93%) rename apps/web/src/pages/{ => planning}/PlanningPage.tsx (98%) rename apps/web/src/pages/{ => planning}/planning-page.scss (100%) rename apps/web/src/pages/{ => recipes}/ImportRecipePage.tsx (93%) rename apps/web/src/pages/{ => recipes}/RecipeFormPage.tsx (89%) rename apps/web/src/pages/{ => recipes}/RecipesPage.tsx (94%) rename apps/web/src/pages/{ => shopping-list}/ShoppingListPage.tsx (82%) create mode 100644 specs/dev-conventions.md diff --git a/README.md b/README.md index 73e604c..72cb20e 100644 --- a/README.md +++ b/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 @@ -104,15 +124,39 @@ pnpm dev:web ## Qualité / Tests +Conventions de code (classes vs objets littéraux, préfixe `_` sur les membres +privés, règles Biome actives, logs côté serveur, etc.) : +[specs/dev-conventions.md](specs/dev-conventions.md). + ```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 +170,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 +220,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 +242,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 -`` 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 `` 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 `
`/`` plutôt qu'un - `` + inline + // hint rather than silently keeping the form's submit button disabled + // (see issue #53: an import pre-filled from a source can land here with + // an ingredient resolved but no unit — e.g. a bare count like "4 Egg + // Yolks" — and nothing used to tell the user which line was blocking + // them, or why). + const unitMissing = unitId === null; + const needsAttention = unitMissing || duplicate; return ( -
  • +
  • @@ -46,6 +57,7 @@ export function IngredientRow({ value={unitId ?? ""} onChange={(e) => onUnitChange(Number(e.target.value))} aria-label={t("recipes.form.unitLabel")} + aria-invalid={unitMissing} >
  • ); } diff --git a/apps/web/src/features/recipes/ingredient-icons.tsx b/apps/web/src/features/recipes/ingredients/ingredient-icons.tsx similarity index 100% rename from apps/web/src/features/recipes/ingredient-icons.tsx rename to apps/web/src/features/recipes/ingredients/ingredient-icons.tsx diff --git a/apps/web/src/features/recipes/recipes.scss b/apps/web/src/features/recipes/recipes.scss index aad17c0..26d4e96 100644 --- a/apps/web/src/features/recipes/recipes.scss +++ b/apps/web/src/features/recipes/recipes.scss @@ -923,6 +923,20 @@ border-color: var(--color-error); } } + + // A resolved ingredient with no unit picked — the form's submit stays + // disabled until this is fixed, so it needs to be obvious *which* row is + // the reason why (see issue #53). `__error` takes the row's full width + // (it comes after the `flex-wrap`ped controls above, so it naturally + // drops to its own line). + &--incomplete &__unit { + border-color: var(--color-error); + } + + &__error { + width: 100%; + margin: 0; + } } // --- Ingredient picker (recipe form + disliked-ingredients field) ---------- diff --git a/apps/web/src/features/recipes/RecipeImportForm.tsx b/apps/web/src/features/recipes/sources/RecipeImportForm.tsx similarity index 87% rename from apps/web/src/features/recipes/RecipeImportForm.tsx rename to apps/web/src/features/recipes/sources/RecipeImportForm.tsx index c283436..1f98e66 100644 --- a/apps/web/src/features/recipes/RecipeImportForm.tsx +++ b/apps/web/src/features/recipes/sources/RecipeImportForm.tsx @@ -1,5 +1,6 @@ import { type CreateRecipeInput, + createRecipeSchema, type DietView, ErrorCode, type IngredientView, @@ -9,18 +10,17 @@ import { type RecipeVisibility, type UnitView, type WeekDay, - createRecipeSchema, } from "@batch-cooking/shared"; import { type FormEvent, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { ApiError, apiClient } from "../../api/client"; -import { makeClientKey } from "../../lib/client-key"; -import { errorMessageService } from "../../services/error-message.service"; -import { DietTagSelect } from "./DietTagSelect"; -import { IngredientPicker } from "./IngredientPicker"; -import { IngredientRow } from "./IngredientRow"; -import { type StepDraft, StepListEditor } from "./StepListEditor"; -import "./recipes.scss"; +import { ApiError, apiClient } from "../../../api/client"; +import { makeClientKey } from "../../../lib/client-key"; +import { errorMessageService } from "../../../services/error-message.service"; +import { DietTagSelect } from "../badges/DietTagSelect"; +import { IngredientPicker } from "../ingredients/IngredientPicker"; +import { IngredientRow } from "../ingredients/IngredientRow"; +import { type StepDraft, StepListEditor } from "../steps/StepListEditor"; +import "../recipes.scss"; /** In display order — mirrors `RecipeVisibility` (schema.prisma/shared types). Same list as `RecipeFormPage`. */ const VISIBILITY_OPTIONS: RecipeVisibility[] = ["PERSONAL", "HOUSE", "PUBLIC"]; @@ -212,12 +212,38 @@ export function RecipeImportForm({ setResolvingKey((current) => (current === key ? null : current)); } + // Surfaced next to the submit button (see the `unitMissingHint` per-row + // hint in `IngredientRow` for the same thing at the line level) — without + // this, a pre-filled import whose auto-matched ingredients are otherwise + // complete could leave `canSubmit` false with nothing on the page saying + // why (see issue #53). + const hasIngredientMissingUnit = ingredientLines.some((line) => line.unitId === null); + + // Two raw source lines can independently resolve to the same catalog + // ingredient (e.g. "Egg Yolks" and "Eggs" both matching "Egg") — + // `RecipeIngredient`'s primary key is `(recipeId, ingredientId)`, one row + // per ingredient (schema.prisma), so submitting both would otherwise fail + // (`createRecipeSchema` now rejects it, see its own doc comment). Blocked + // here too, with each duplicate row highlighted, rather than letting the + // user find out only after clicking "Importer". + const duplicateIngredientIds = (() => { + const seen = new Set(); + const duplicates = new Set(); + for (const line of ingredientLines) { + if (seen.has(line.ingredient.id)) duplicates.add(line.ingredient.id); + seen.add(line.ingredient.id); + } + return duplicates; + })(); + const hasDuplicateIngredient = duplicateIngredientIds.size > 0; + const canSubmit = name.trim().length > 0 && Number.isInteger(Number(portions)) && Number(portions) > 0 && ingredientLines.length > 0 && ingredientLines.every((line) => Number(line.quantity) > 0 && line.unitId !== null) && + !hasDuplicateIngredient && unresolvedIngredients.length === 0 && steps.length > 0 && steps.every((step) => step.description.trim().length > 0); @@ -368,6 +394,7 @@ export function RecipeImportForm({ quantity={line.quantity} unitId={line.unitId} unitsCatalog={unitsCatalog} + duplicate={duplicateIngredientIds.has(line.ingredient.id)} onQuantityChange={(quantity) => updateIngredientLine(line.key, { quantity })} onUnitChange={(unitId) => updateIngredientLine(line.key, { unitId })} onRemove={() => removeIngredientLine(line.key)} @@ -424,6 +451,12 @@ export function RecipeImportForm({ {formError &&

    {formError}

    } + {!canSubmit && hasIngredientMissingUnit && ( +

    {t("recipes.form.incompleteIngredientsHint")}

    + )} + {!canSubmit && hasDuplicateIngredient && ( +

    {t("recipes.form.duplicateIngredientsHint")}

    + )}
    diff --git a/apps/web/src/pages/RecipeFormPage.tsx b/apps/web/src/pages/recipes/RecipeFormPage.tsx similarity index 89% rename from apps/web/src/pages/RecipeFormPage.tsx rename to apps/web/src/pages/recipes/RecipeFormPage.tsx index b44878d..b26a1c5 100644 --- a/apps/web/src/pages/RecipeFormPage.tsx +++ b/apps/web/src/pages/recipes/RecipeFormPage.tsx @@ -1,23 +1,23 @@ import { type CreateRecipeInput, + createRecipeSchema, type DietView, ErrorCode, type IngredientView, type RecipeVisibility, type UnitView, - createRecipeSchema, } from "@batch-cooking/shared"; import { type FormEvent, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate, useParams } from "react-router-dom"; -import { ApiError, apiClient } from "../api/client"; -import { DietTagSelect } from "../features/recipes/DietTagSelect"; -import { IngredientPicker } from "../features/recipes/IngredientPicker"; -import { IngredientRow } from "../features/recipes/IngredientRow"; -import { type StepDraft, StepListEditor } from "../features/recipes/StepListEditor"; -import "../features/recipes/recipes.scss"; -import { makeClientKey } from "../lib/client-key"; -import { errorMessageService } from "../services/error-message.service"; +import { ApiError, apiClient } from "../../api/client"; +import { DietTagSelect } from "../../features/recipes/badges/DietTagSelect"; +import { IngredientPicker } from "../../features/recipes/ingredients/IngredientPicker"; +import { IngredientRow } from "../../features/recipes/ingredients/IngredientRow"; +import { type StepDraft, StepListEditor } from "../../features/recipes/steps/StepListEditor"; +import "../../features/recipes/recipes.scss"; +import { makeClientKey } from "../../lib/client-key"; +import { errorMessageService } from "../../services/error-message.service"; /** In display order — mirrors `RecipeVisibility` (schema.prisma/shared types). */ const VISIBILITY_OPTIONS: RecipeVisibility[] = ["PERSONAL", "HOUSE", "PUBLIC"]; @@ -138,6 +138,13 @@ export function RecipeFormPage() { setIngredientLines((lines) => lines.filter((line) => line.key !== key)); } + // Surfaced next to the submit button when it's the reason `canSubmit` is + // false — same "don't leave the button silently disabled" reasoning as + // `RecipeImportForm` (issue #53), just less likely to bite here since a + // manually-added line starts with no unit by design, right where the + // person is already looking. + const hasIngredientMissingUnit = ingredientLines.some((line) => line.unitId === null); + // Gates the submit button — the schema (checked again on submit, see // `handleSubmit`) is the source of truth, this is just instant feedback // that doesn't need a round trip through zod on every keystroke. @@ -189,7 +196,7 @@ export function RecipeFormPage() { recipeId !== null ? await apiClient.updateRecipe(recipeId, result.data) : await apiClient.createRecipe(result.data); - navigate(`/recettes/${saved.id}`); + void navigate(`/recettes/${saved.id}`); } catch (err) { const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR; setFormError(errorMessageService.getLabel(code)); @@ -294,6 +301,9 @@ export function RecipeFormPage() { {formError &&

    {formError}

    } + {!canSubmit && hasIngredientMissingUnit && ( +

    {t("recipes.form.incompleteIngredientsHint")}

    + )}