# 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 : ```ts 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 ```ts 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`/`wrapAsyncHandler` — `Locals` contraint par `Record`, pas `unknown` Le paramètre générique `Locals` est contraint par `Record`, à l'identique du propre `Response` d'Express (`@types/express-serve-static-core`) — volontairement, pas `Record` (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(...)` 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](./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` : ```ts export interface AuthLocals { userProfile: SafeUserProfile; } export async function requireAuth(req: Request, res: Response, next: NextFunction) { // ... res.locals.userProfile = safeProfile; next(); } ``` Un handler derrière ce middleware type sa réponse `Response` et lit `res.locals.userProfile` sans cast : ```ts authRouter.get("/me", requireAuth, (_req, res: Response) => { 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/shared` — `assertIsNever` `packages/shared/src/tools/assert-is-never.ts` — vérification d'exhaustivité pour un `switch`/`if`-chain sur une union : ```ts 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 `default` → **erreur 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'invitation** — `generateInviteCode()` tire 8 caractères dans `INVITE_CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"` : majuscules + chiffres, **sans** les caractères visuellement ambigus (`0`/`O`/`1`/`I`) — pensé pour être lu sur un écran et retapé sur un autre. Les collisions ne sont pas pré-vérifiées (33⁸ possibilités, astronomiquement improbable) mais gérées par réessai (jusqu'à 5 tentatives) sur la violation de contrainte unique Postgres (`P2002`) plutôt que supposées impossibles. **Départ et transfert d'adminship** (`leaveCurrentHouse`) — si le membre qui part est l'admin, l'adminship est transférée au membre restant le plus ancien (id le plus petit) ; s'il ne reste personne, le foyer est supprimé (plannings en cascade). **Un foyer ne peut jamais rester sans admin.** Cette fonction est aussi appelée par `auth.service.ts`'s `deleteAccount` avant la suppression du profil. **Sources activées** (`HouseSource`, table de jointure `houseId`/`sourceId`) — `getHouseSourceIds`/`updateHouseSources` en gèrent le contenu. **Aucune ligne au départ pour un nouveau foyer** — opt-in, pas "aucune préférence exprimée". `recipe.service.ts`'s `listRecipes` filtre chaque onglet du catalogue contre cet ensemble (voir plus bas). Détail complet du flux de sources : [batch-cooking-architecture.md](./batch-cooking-architecture.md), section "Module « Import d'une recette »". **Codes d'erreur** : `HOUSE_NOT_FOUND` (4041), `ALREADY_HAS_HOUSE` (4020), `INVITE_CODE_NOT_FOUND` (4044), `NOT_HOUSE_ADMIN` (4030), `SOURCE_NOT_FOUND` (4049, `sourceId` inconnu dans `updateHouseSources`). Note : si le `houseId` d'un profil pointe vers un foyer qui n'existe plus (état interne incohérent), l'échec du lookup interne lève une `Error` brute (→ 500), volontairement **pas** une `HttpError` — ce cas signale une incohérence interne, pas un "not found" normal qu'un client pourrait déclencher. --- ## `preferences` (thème) et `/profile/disliked-ingredients` (goûts) **`preferences`** (`preferences.routes.ts`/`.service.ts`, `/preferences`, `requireAuth`) : `GET /preferences` → `{ theme }` (défaut `"SYSTEM"` si aucune ligne `UserPreference` n'existe encore — pas de création à la volée pour un simple `GET`) ; `PATCH /preferences` → `{ theme: "LIGHT"|"DARK"|"SYSTEM" }`, **upsert** de `UserPreference` (`userProfileId` est à la fois clé primaire et étrangère, 1-1 strict avec `UserProfile`). **Ingrédients détestés** vivent sous `/profile`, **pas** `/preferences` : `GET`/`PATCH /profile/disliked-ingredients` (`profile.routes.ts`), remplace (pas de fusion), chaque id validé contre `Ingredient` (404 `INGREDIENT_NOT_FOUND` sinon). Explicitement distinct de `GET`/`PATCH /profile/allergies` : une préférence de **goût**, jamais un avertissement de sécurité — voir la note sur `UserProfileDislikedIngredient` dans [batch-cooking-modele.md](./batch-cooking-modele.md#ingredients-ingredient-et-catalogue-associé). Géré depuis `PreferencesPage` côté web (`/parametres/preferences`). --- ## `planning` — semaine, item, portions, import à la volée Router `/planning` (`planning.routes.ts`/`.service.ts`), `requireAuth`. - `GET /planning?date=YYYY-MM-DD` → `getPlanningForDate` → `PlanningView | null`. `null` recouvre **deux** états normaux confondus : pas de foyer, ou aucun `Planning` ne couvre cette date — jamais une erreur. - `POST /planning/items` → `addPlanningItem`, 201. Body `addPlanningItemSchema` = `{ date, weekDay, meal, recipeId, portions }` (`weekDay`/`meal` sont des enums `WEEK_DAYS`/`MEALS` de `packages/shared`, réellement validés ici — pas juste une convention documentée). `recipeId` doit exister et être visible par l'appelant (`assertRecipeVisible`, `recipe.service.ts`) → 404 `RECIPE_NOT_FOUND` sinon. - `DELETE /planning/items/:id` → `removePlanningItem`, 204. **`PlanningItem.portions`** est saisi **indépendamment** de `Recipe.portions` (le rendement "tel qu'écrit" de la recette) — un créneau peut mettre à l'échelle. Le picker web pré-remplit depuis `Recipe.portions` mais envoie toujours sa propre valeur. **Création de la semaine** — `findOrCreatePlanningForWeek` est le seul endroit qui crée une ligne `Planning`, retrouvée par `startDate` exact (un lundi, via `@batch-cooking/date-tools`'s `getWeekStart`), lundi→dimanche. Lookup et création **ne sont pas transactionnels** ensemble — pas de contrainte unique `(houseId, startDate)` — une course pourrait donc créer deux lignes pour la même semaine vide ; accepté à l'échelle actuelle du projet plutôt que d'ajouter une migration + boucle retry-on-conflict. **"Ajouter au planning déclenche l'import si besoin"** — c'est une **orchestration côté frontend**, pas une fonctionnalité backend combinée : il n'existe aucune route "importer + ajouter au planning" en un seul appel. Le web (`RecipePickerDialog.tsx`) appelle simplement `POST /sources/:sourceKey/import/:externalId` (voir plus bas) puis `POST /planning/items` l'un après l'autre — les deux routes existaient déjà et se suffisent à elles-mêmes, aucun changement backend n'a été nécessaire pour cette feature. Détail du flux complet : [batch-cooking-architecture.md](./batch-cooking-architecture.md), section "Module « Import d'une recette »". --- ## `reference` — catalogues publics (pas de session requise) Router `/reference` (`reference.routes.ts`/`.service.ts`) — **toutes les routes sont publiques**, pas de `requireAuth` : ce sont des données de référence, pas des données de foyer, et le wizard d'inscription doit pouvoir les lire avant qu'une session n'existe. | Route | Contenu | |---|---| | `GET /reference/diets` | régimes alimentaires, triés par `key` | | `GET /reference/allergies` | allergènes/intolérances (`Allergy` → `Category{key, kind}`) | | `GET /reference/ingredients` | catalogue d'ingrédients, avec `allergens[]`/`diets[]` résolus | | `GET /reference/units` | unités de mesure, `toBaseFactor` (Decimal → number) | | `GET /reference/tech-steps` | techniques (pas encore consommé par l'UI recette elle-même — groundwork) | | `GET /reference/sources` | sources d'import enregistrées, triées par **`name`** (pas `key` — c'est le vrai libellé affiché, un nom propre, pas une clé à traduire) | Toutes seedées via `apps/api/src/db/reference-seed-data.ts` (voir le README pour la commande de seed) — jamais créées/éditées/supprimées via l'API applicative. --- ## Sources externes — adaptateur, registre, synchronisation Le module « Import d'une recette » du plan initial (voir [batch-cooking-architecture.md](./batch-cooking-architecture.md)) est implémenté. Pièces principales : ### `RecipeSourceAdapter` (`apps/api/src/lib/recipe-source-adapter.ts`) Contrat générique que chaque source concrète implémente : `list(params)` (parcours paginé, `query`/`cursor` optionnels), `fetchDetail(externalId)` (contenu brut d'un item), `parse(raw)` (pur, synchrone, testable sans réseau — transforme le brut en `ParsedRecipe` normalisé : ingrédients/étapes en texte libre, pas encore résolus contre les catalogues). `official` (API officielle vs scraping non-officiel) et `locale` (langue du contenu produit par la source, pas une préférence utilisateur) n'ont pas de valeur par défaut — chaque auteur d'adaptateur doit choisir consciemment. `markAlreadyImported` annote une page de résultats en comparant les `externalId` à un ensemble déjà importé — étape pure et séparée, l'adaptateur ne connaît jamais la base de données. ### Registre (`recipe-source-registry.ts`) Map en mémoire `key → adapter`, volontairement **pas** persistée en base — un adaptateur *est* du code (la logique de fetch/parse d'un site ne peut pas vivre dans une ligne de base). `registerRecipeSource` lève si la clé est déjà prise (deux adaptateurs qui s'écraseraient silencieusement serait un bug). `clearRecipeSources` n'est utilisée que par les tests, pour l'isolation (même rôle que `resetDatabase()` côté base). ### Synchronisation (`apps/api/src/db/recipe-source-sync.ts`) `syncRecipeSources(prisma)` upsert une ligne `Source` par adaptateur du registre — **ne supprime jamais** une `Source` dont l'adaptateur a disparu du registre (une recette déjà importée doit continuer à citer sa source). `findImportedRecipeIds(prisma, sourceKey, externalIds)` renvoie une `Map ` des items déjà importés (map vide si `sourceKey` n'a pas encore de ligne `Source` — jamais une erreur). **Quand ça tourne** : - `server.ts` appelle `registerAllRecipeSources()` au démarrage (peuple uniquement le registre en mémoire de **ce** processus). - `prisma/seed.ts` (dev, `pnpm --filter api prisma:seed` / `prisma migrate reset`) enregistre les adaptateurs puis seed + synchronise. - `apps/api/src/scripts/seed-runtime.ts` — équivalent pour l'image de production, invoqué dans le `CMD` du `Dockerfile` : `prisma migrate deploy && node dist/scripts/seed-runtime.js && node dist/server.js`. **Nécessaire** car chaque maillon du `CMD` est un **processus `node` séparé** : sans cette étape dédiée, le registre peuplé par `server.ts` ne touchait jamais la base en production, et `GET /reference/sources` renvoyait silencieusement `[]` (toute la section "Sources" de `HouseholdSettingsPage` restait invisible) — bug corrigé par le commit "synchronise les sources en base au démarrage de l'image de prod". Vit sous `src/` (pas `prisma/`) précisément pour être compilé dans `dist` par `tsc`, l'image runtime n'embarquant que `dist`, pas `src`. - `test-support/reset-db.ts`'s `resetDatabase()` appelle aussi `syncRecipeSources` en dernier, après le seed de référence. ### Adaptateurs concrets (`apps/api/src/sources/`) - **`the-meal-db.ts`** — `key: "theMealDb"`, `official: true`, `locale: "en"`. API publique gratuite (`https://www.themealdb.com/api/json/v1/${API_KEY}`, `THE_MEAL_DB_API_KEY` env var, défaut `"1"` = clé de test partagée documentée par TheMealDB). `list()` n'a qu'une recherche (`/search.php?s=`), pas de vrai "tout parcourir" côté gratuit — une requête vide renvoie un petit échantillon fixe (~25 recettes), non paginé (`nextCursor` toujours `null`). `parse()` reconstruit les ingrédients depuis les paires plates `strIngredient1..20`/`strMeasure1..20`. - **`json-ld-recipe.ts`** — `key: "jsonLdRecipe"`, `official: false`, scraper générique schema.org/`Recipe` (extraction regex des blocs `