batchCooking/specs/backend-architecture.md
kyuno053 5d63ff9ea9
fix: corrige les bugs ouverts du repo (import TheMealDB, sidebar mobile) + doc (#59)
* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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<void>`) — 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 12:16:22 +02:00

29 KiB

Architecture backend — Projet Batch-cooking

Documentation de l'organisation d'apps/api et de l'outillage partagé (packages/express-tools, packages/error-tools, packages/shared).


packages/express-tools — outillage Express générique

Package séparé, réutilisable par n'importe quel service Express du monorepo (pas seulement apps/api) : pas de logique métier, juste de l'infra Express.

ExpressServer — init serveur, routes, middlewares

Enveloppe une application Express derrière une API typée, au lieu que chaque service refasse le même express() à la main :

const server = new ExpressServer();
server.setupCore({ corsOrigin: env.CORS_ORIGIN }); // cors + json + cookie-parser
server.addRoute("get", "/health", (_req, res) => res.status(200).json({ status: "ok" }));
server.mountRouter("/auth", authRouter);
server.addMiddleware(notFoundHandler);
server.setErrorHandler(createErrorMiddleware(errorHandlerService));
server.listen(port, () => console.log(`Listening on ${port}`));
  • setupCore(options) — middleware stack commun (CORS avec credentials, JSON, cookies).
  • addRoute(method, path, ...handlers) — enregistre une route ; avertit et ignore au lieu d'écraser silencieusement si la même route (méthode + chemin) est déjà enregistrée.
  • addMiddleware / mountRouter / setErrorHandler — ajout de middleware générique, montage d'un Router complet, middleware d'erreur final (4 arguments — doit être ajouté en dernier).
  • .instance — l'app Express brute, nécessaire pour les outils de test (supertest) qui attendent une instance Express, pas le wrapper.
  • .listen(port, onListening?) — démarre le serveur.

apps/api/src/app.ts expose deux fonctions : createServer(): ExpressServer (utilisée par server.ts, qui appelle .listen()) et createApp(): Express (= createServer().instance, utilisée par les tests).

wrapAsyncHandler — plus de try/catch répété dans les routes

router.post("/signup", wrapAsyncHandler(async (req, res) => {
  const profile = await signup(req.body); // une erreur/rejet ici va automatiquement à next()
  res.status(201).json(profile);
}));

Sans ça, une exception dans un handler async ne remonte jamais tout seule au middleware d'erreur d'Express — chaque route devait faire son propre try { ... } catch (err) { next(err); }. wrapAsyncHandler l'automatise.

AsyncRequestHandler/wrapAsyncHandlerLocals contraint par Record<string, any>, pas unknown

Le paramètre générique Locals est contraint par Record<string, any>, à l'identique du propre Response<ResBody, LocalsObj> d'Express (@types/express-serve-static-core) — volontairement, pas Record<string, 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 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).

createErrorMiddleware — adaptateur Express pour packages/error-tools

Voir error-handling.md pour le détail. HttpError et ErrorHandlerService vivent dans packages/error-tools, pas ici : ErrorHandlerService n'a aucune dépendance à Express — c'est un service générique erreur → { status, body } qui fonctionnerait à l'identique derrière Fastify ou n'importe quel autre framework, donc il n'a rien à faire dans un package express-tools. ExpressServer et createErrorMiddleware (ici) sont la vraie couche Express : elles adaptent des pièces indépendantes du framework (ErrorHandlerService, importé depuis @batch-cooking/error-tools) à l'API d'Express.


Auth : res.locals, pas d'augmentation du namespace Express

requireAuth (apps/api/src/middlewares/require-auth.ts) attache le profil authentifié à res.locals.userProfile, typé via l'interface AuthLocals :

export interface AuthLocals {
  userProfile: SafeUserProfile;
}

export async function requireAuth(req: Request, res: Response<unknown, AuthLocals>, next: NextFunction) {
  // ...
  res.locals.userProfile = safeProfile;
  next();
}

Un handler derrière ce middleware type sa réponse Response<unknown, AuthLocals> et lit res.locals.userProfile sans cast :

authRouter.get("/me", requireAuth, (_req, res: Response<unknown, AuthLocals>) => {
  res.status(200).json(res.locals.userProfile);
});

Pourquoi pas declare global { namespace Express { interface Request {...} } } (l'approche initialement utilisée, retirée depuis) : res.locals est le mécanisme natif d'Express prévu exactement pour ça (faire passer des données d'un middleware au handler suivant), typé par route via un paramètre générique — pas une augmentation globale et permanente qui change silencieusement le type de toutes les Request du projet, qu'elles soient passées par ce middleware ou non.


packages/sharedassertIsNever

packages/shared/src/tools/assert-is-never.ts — vérification d'exhaustivité pour un switch/if-chain sur une union :

switch (shape.kind) {
  case "circle": return Math.PI * shape.radius ** 2;
  case "square": return shape.side ** 2;
  default: return assertIsNever(shape); // erreur de compilation si un cas manque
}

Si un membre de l'union n'est pas traité par une branche précédente, shape n'est plus de type never au niveau du defaulterreur de compilation (vérifié : tsc rejette bien un cas manquant). Lève aussi une vraie erreur au runtime, en filet de sécurité si une valeur invalide échappe au système de types (ex. donnée externe non validée).

Pas encore de point d'usage réel dans le code métier actuel (aucun switch/if-chain exhaustif sur une union n'existe encore) — prêt à l'emploi dès qu'un cas s'y prête (le module « Calcul batch-cooking » ou le pipeline d'import 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'invitationgenerateInviteCode() 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, 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. 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-DDgetPlanningForDatePlanningView | null. null recouvre deux états normaux confondus : pas de foyer, ou aucun Planning ne couvre cette date — jamais une erreur.
  • POST /planning/itemsaddPlanningItem, 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/:idremovePlanningItem, 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 semainefindOrCreatePlanningForWeek 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, 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 (AllergyCategory{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) est implémenté. Pièces principales :

RecipeSourceAdapter (apps/api/src/lib/recipe-sources/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.tskey: "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.tskey: "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é (RecipeVisibilityPERSONAL/HOUSE/PUBLIC, voir 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 (RecipeTabfavoris/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 pour le détail côté apps/web. Côté apps/api : aucune augmentation de type globale (declare global) n'est utilisée — voir la section res.locals ci-dessus, qui est précisément ce qui aurait nécessité ce genre de fichier.