GET /shopping-list?date= (shopping-list.service.ts/.routes.ts) somme les ingrédients de chaque recette planifiée sur la semaine, mis à l'échelle par les portions de chaque créneau (PlanningItem.portions / Recipe.portions), regroupés par paire (ingredientId, unitId) — jamais null contrairement à GET /planning, une semaine vide redescend en items: []. Côté web, ShoppingListPage rend cette liste groupée par rayon (même IngredientCategory que IngredientPicker), triée alphabétiquement en français à l'intérieur d'un rayon (shopping-list.ts, logique pure extraite du composant). WeekNavigator (flèches + calendrier) est extrait de PlanningPage vers features/planning/ pour être partagé entre les deux pages ; ses libellés migrent de planning.* vers common.weekNav.*/ common.calendar.*/common.days.*, plus génériques pour une page qui n'est plus seulement le planning. ComingSoonPage retiré (plus aucun appelant, Liste de courses avait le dernier stub restant). Tests : Mocha (agrégation, mise à l'échelle par portions, unités non fusionnées) + Cucumber (shopping-list.feature : liste vide, groupement/tri, navigation de semaine) + mise à jour de layout.cy.ts/planning-page.cy.ts pour le nouveau rendu. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
628 lines
34 KiB
Markdown
628 lines
34 KiB
Markdown
# 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<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](./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<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 :
|
||
|
||
```ts
|
||
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/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 »".
|
||
|
||
---
|
||
|
||
## Liste de courses — agrégation des ingrédients planifiés
|
||
|
||
Router `/shopping-list` (`shopping-list.routes.ts`/`.service.ts`),
|
||
`requireAuth` — un seul endpoint : `GET /shopping-list?date=YYYY-MM-DD` →
|
||
`getShoppingListForDate` → `ShoppingListView`. Même contrat `?date=` que
|
||
`GET /planning` (même schéma de requête shape-only, validation calendaire
|
||
réelle via `date-tools`'s `parseDateOnly`), même requête "plage couvrante"
|
||
(`startDate <= date <= finishDate`) que `getPlanningForDate` — mais
|
||
**jamais `null`** : pas de foyer, ou aucun `Planning` ne couvre la semaine,
|
||
retombent tous deux sur un `ShoppingListView` normal à `items: []` plutôt
|
||
qu'un état à part que le frontend devrait distinguer.
|
||
|
||
**Agrégation** (`aggregateShoppingList`, pure/synchrone — testable sans base)
|
||
— pour chaque `PlanningItem` de la semaine, chaque ligne
|
||
`RecipeIngredient` de sa recette est mise à l'échelle
|
||
(`quantity × PlanningItem.portions / Recipe.portions`, cf.
|
||
`PlanningItem.portions`'s doc comment dans schema.prisma) puis sommée dans
|
||
une `Map` clée par **`(ingredientId, unitId)`** — pas juste `ingredientId` :
|
||
la même ligne d'ingrédient dans deux unités différentes (ex. une recette en
|
||
grammes, une autre en kilogrammes pour le même ingrédient) reste deux lignes
|
||
séparées, aucune conversion inter-unités n'étant construite (voir
|
||
`UnitView.toBaseFactor`'s doc comment, `packages/shared`). L'ordre final
|
||
(par `Ingredient.key`) n'est là que pour un JSON déterministe en test — le
|
||
frontend retrie par rayon/libellé traduit pour l'affichage (voir
|
||
[frontend-architecture.md](./frontend-architecture.md), section "Liste de
|
||
courses").
|
||
|
||
`shopping-list.service.ts` réutilise directement `recipe.service.ts`'s
|
||
`toIngredientView`/`toUnitView` (exportées pour cette raison) plutôt que de
|
||
re-dupliquer le même mapping Prisma → vue publique — sa propre requête
|
||
Prisma ne charge qu'un sous-ensemble de `Recipe` (juste `portions` +
|
||
`ingredients`, pas `steps`/`diets`/`favoritedBy`) mais avec exactement la
|
||
même forme imbriquée `ingredient.allergies`/`ingredient.diets` que
|
||
`recipe.service.ts`'s `recipeInclude`, donc les deux fonctions s'appliquent
|
||
telles quelles par typage structurel.
|
||
|
||
---
|
||
|
||
## `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-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.ts`** — `key: "theMealDb"`, `official: true`, `locale: "en"`.
|
||
API publique gratuite (`https://www.themealdb.com/api/json/v1/${API_KEY}`,
|
||
`THE_MEAL_DB_API_KEY` env var, défaut `"1"` = clé de test partagée
|
||
documentée par TheMealDB). `list()` n'a qu'une recherche (`/search.php?s=`),
|
||
pas de vrai "tout parcourir" côté gratuit — une requête vide renvoie un
|
||
petit échantillon fixe (~25 recettes), non paginé (`nextCursor` toujours
|
||
`null`). `parse()` reconstruit les ingrédients depuis les paires plates
|
||
`strIngredient1..20`/`strMeasure1..20`.
|
||
- **`json-ld-recipe.ts`** — `key: "jsonLdRecipe"`, `official: false`,
|
||
scraper générique schema.org/`Recipe` (extraction regex des blocs
|
||
`<script type="application/ld+json">`, gère objet nu / tableau de types
|
||
mixtes / wrapper `@graph`). **Volontairement pas enregistré** dans
|
||
`sources/index.ts` (commit "la source générique JSON-LD n'apparaît plus
|
||
comme source") : c'est un parseur générique pensé pour être spécialisé par
|
||
un futur adaptateur dédié à un site précis, pas une `Source` activable en
|
||
tant que telle — personne ne peut "faire confiance" à un mécanisme de
|
||
parsing générique de la même façon qu'à un site nommé. `list()` renvoie
|
||
toujours vide (pas de catalogue à parcourir) ; `fetchDetail`'s `externalId`
|
||
est directement l'URL cible, pas un id issu d'un `list()` préalable.
|
||
|
||
`apps/api/src/sources/index.ts`'s `registerAllRecipeSources()` n'enregistre
|
||
aujourd'hui que TheMealDB — appelé explicitement par `server.ts`/`seed*`,
|
||
**jamais** par `app.ts` (que chaque test récupère via supertest ; y enregistrer
|
||
un adaptateur réel ferait dépendre sa présence de l'ordre des tests).
|
||
|
||
### Endpoints (`apps/api/src/modules/sources/`, `/sources`, `requireAuth`)
|
||
|
||
| Route | Fonction |
|
||
|---|---|
|
||
| `GET /sources/:sourceKey/browse?query=&cursor=` | `browseSource` — une page du catalogue de la source, chaque item annoté `alreadyImported`/`recipeId` |
|
||
| `GET /sources/:sourceKey/preview/:externalId` | `previewSourceItem` — traduit entièrement un item en `RecipeImportDraftView` **sans le sauvegarder** |
|
||
| `POST /sources/:sourceKey/import/:externalId` | `importSourceItem` — finalise l'import, `input` = un `CreateRecipeInput` normal (mêmes règles qu'une création manuelle) |
|
||
|
||
`sourceKey` doit à la fois exister comme `Source` **activée pour le foyer**
|
||
(`HouseSource`) et avoir un adaptateur toujours enregistré (les deux peuvent
|
||
diverger — voir `syncRecipeSources` plus haut) ; l'un ou l'autre manquant
|
||
ressort en 404 `SOURCE_NOT_FOUND`, sans distinguer les deux cas côté client.
|
||
`previewSourceItem`/`importSourceItem` réutilisent exactement les mêmes
|
||
briques que la sauvegarde normale d'une recette (`translateRecipeIngredients`,
|
||
`matchTechStepSpans` — voir plus bas), la seule différence étant que preview
|
||
ne persiste rien.
|
||
|
||
---
|
||
|
||
## Recettes — visibilité, catalogue, traduction/matching
|
||
|
||
### `recipe` module (`/recipes`, `requireAuth`)
|
||
|
||
| Route | Fonction |
|
||
|---|---|
|
||
| `GET /recipes?tab=&search=&suitableForHousehold=&ingredientIds=&dietIds=` | `listRecipes` |
|
||
| `GET /recipes/:id` | `getRecipe` |
|
||
| `POST /recipes` | `createRecipe` |
|
||
| `PATCH /recipes/:id` | `updateRecipe` (remplacement complet, pas de fusion partielle) |
|
||
| `DELETE /recipes/:id` | `deleteRecipe` (409 `RECIPE_IN_USE` si référencée par un `PlanningItem`) |
|
||
| `POST`/`DELETE /recipes/:id/favorite` | `addFavorite`/`removeFavorite` (idempotents) |
|
||
|
||
**Visibilité** (`RecipeVisibility` — `PERSONAL`/`HOUSE`/`PUBLIC`, voir
|
||
[batch-cooking-modele.md](./batch-cooking-modele.md)) contrôle uniquement la
|
||
**lecture** — l'édition/suppression reste toujours réservée à l'auteur
|
||
(`NOT_RECIPE_AUTHOR`, 403). L'auteur voit toujours sa propre recette, quelle
|
||
que soit sa visibilité actuelle (même une `HOUSE` recipe après avoir quitté ce
|
||
foyer). Un id invisible pour l'appelant ressort en 404, jamais 403 — son
|
||
existence ne doit pas fuiter.
|
||
|
||
**Quatre onglets réels** (`RecipeTab` — `favoris`/`perso`/`foyer`/`publique`,
|
||
pas de `"toutes"` : toute recette visible tombe sous exactement un des trois
|
||
premiers via sa propre `visibility`, `favoris` est un filtre transverse
|
||
orthogonal). Filtres optionnels en plus (`ListRecipesFilters`) : `search`,
|
||
`suitableForHousehold` (recette qui évite tous les allergènes déclarés du
|
||
foyer et respecte le régime de chaque membre qui en a un — calculé
|
||
serveur-side, jamais exposé en données brutes par membre : les
|
||
allergies/régimes d'un membre restent privés, même logique que la visibilité
|
||
404-jamais-403), `ingredientIds`/`dietIds` (ET logique — la recette doit
|
||
porter *chacun*, pas au moins un).
|
||
|
||
**Filtrage par sources activées** (`sourceVisibilityWhere`) — appliqué à
|
||
**chaque** onglet : une recette manuelle (`sourceId` `null`) est toujours
|
||
visible, seule une recette issue d'une source externe non activée pour le
|
||
foyer du viewer est masquée. Sans foyer, rien n'est activé par construction
|
||
(pas de ligne `HouseSource` à référencer) — toute recette sourcée est
|
||
invisible tant que le profil n'a pas rejoint/créé de foyer.
|
||
|
||
### Détection des techniques — `tech-step-matcher.ts`
|
||
|
||
Historiquement une table `TechStepMapping` de regex par technique/locale
|
||
(`weight` pour départager les chevauchements) — remplacée par un pipeline
|
||
`node-nlp` (`TechStepClassifierService`) une fois constaté que les regex ne
|
||
généralisaient jamais au-delà de leur propre vocabulaire : une étape décrivant
|
||
la fonte du beurre comme "jusqu'à ce que le beurre ait disparu dans la poêle"
|
||
ne contient aucun verbe sur lequel une regex pourrait s'ancrer, alors que le
|
||
sens est sans ambiguïté. `TechStepMapping` a été supprimée (migration
|
||
`20260821130000_drop_tech_step_mapping`) — plus aucune table n'est
|
||
interrogée/éditée à l'exécution, les données de matching vivent en code
|
||
(`tech-step-training-data.ts`).
|
||
|
||
`normalizeText` (décomposition NFD + suppression des diacritiques + minuscule)
|
||
reste utilisée par `ingredient-matcher.ts`, mais n'intervient plus dans la
|
||
détection des techniques elle-même — node-nlp gère sa propre normalisation
|
||
par langue.
|
||
|
||
**Pipeline en 3 étapes** (`TechStepClassifierService.matchTechStepSpans`) :
|
||
1. **NER** (entités enum node-nlp, `synonyms` de `TECH_STEP_TRAINING_DATA`)
|
||
trouve chaque mention *candidate* d'une technique dans la description
|
||
entière, avec sa position exacte — équivalent mécanique des anciennes
|
||
regex, en listes de synonymes plutôt qu'en patterns écrits à la main.
|
||
`ner.threshold: 1` (exact après normalisation, pas de tolérance floue
|
||
Levenshtein) — le défaut à 0.8 faisait matcher "faire" (verbe auxiliaire
|
||
omniprésent en français) contre le synonyme "frire" de `fry` par pure
|
||
proximité de chaîne, un faux positif détecté en calibrant contre le
|
||
corpus réel.
|
||
2. La description est découpée en clauses autour de ces candidats
|
||
(`splitIntoClauses`, pure/testable sans modèle) — une étape nommant deux
|
||
techniques a besoin que chacune soit jugée sur son propre contexte, pas
|
||
la phrase entière classée d'un bloc.
|
||
3. **Classification d'intention NLP** (le même `NlpManager`, entraîné sur les
|
||
`utterances` de `TECH_STEP_TRAINING_DATA`) classe chaque clause
|
||
individuellement — c'est ce qui apporte la compréhension du **sens** :
|
||
le corpus d'entraînement mélange volontairement des tournures ancrées sur
|
||
le mot-clé et des paraphrases qui ne l'emploient jamais (ex. "jusqu'à ce
|
||
que le beurre ait disparu" pour `melt`), donc le verdict final d'une
|
||
clause vient de ce que le modèle reconnaît comme *signifiant* la
|
||
technique, pas du mot littéral qui a déclenché son découpage. En dessous
|
||
de `CONFIDENCE_THRESHOLD` (0.65 — ajusté empiriquement contre le corpus
|
||
réel, voir `test/tech-step-matcher.test.ts`), retombe sur la technique
|
||
impliquée par l'ancre NER de la clause plutôt que d'abandonner un match
|
||
clairement ancré sur un mot-clé juste parce qu'un petit modèle n'est pas
|
||
assez confiant.
|
||
|
||
Entraînement (`_train`) et résolution `TechStep.key -> id` sont mémoïsés une
|
||
seule fois sur le singleton partagé `techStepClassifier` (jamais par requête).
|
||
Le tout premier appel réel à `NlpManager.process()` déclenche aussi le
|
||
chargement paresseux des ressources par langue de node-nlp (plusieurs
|
||
secondes, mesuré) — `server.ts` appelle `techStepClassifier.warmUp()` avant
|
||
d'accepter du trafic pour que ce ne soit jamais la première vraie requête qui
|
||
attend.
|
||
|
||
**Deux pièges rencontrés en construisant ce pipeline**, tous deux corrigés
|
||
dans le code (pas juste contournés) :
|
||
- `db/prisma.ts` construisait `new PrismaClient()` sans jamais importer
|
||
`config/env.ts` — dans le run de test complet, un *autre* fichier
|
||
chargeait toujours `config/env.ts` (donc `.env.test`) en premier par pur
|
||
hasard d'ordre de résolution des modules ; lancer un seul fichier de test
|
||
isolément pouvait faire gagner la course au chargement `.env` interne de
|
||
Prisma (le chemin `.env` de dev, baké dans le client généré) — silencieux
|
||
tant que `resetDatabase()` ne throw pas (heureusement son garde-fou le
|
||
fait). Fixé en import `config/env.js` pour effet de bord tout en haut de
|
||
`prisma.ts`, avant `new PrismaClient()`.
|
||
- `NlpManager` a `autoSave`/`autoLoad: true` par défaut — persiste le
|
||
modèle entraîné dans un fichier `model.nlp` (cwd du process) et le
|
||
recharge *au lieu de* ré-entraîner au prochain démarrage s'il existe déjà.
|
||
Un modèle obsolète sur disque masquerait silencieusement toute mise à
|
||
jour de `TECH_STEP_TRAINING_DATA`/`CONFIDENCE_THRESHOLD`. Les deux sont
|
||
explicitement à `false` dans le constructeur de `TechStepClassifierService`.
|
||
|
||
### Résolution ingrédients/unités — `ingredient-matcher.ts`
|
||
|
||
**Anglais uniquement** aujourd'hui (commit "matching anglais pour les tech
|
||
steps et les ingrédients") :
|
||
- `matchIngredientName` tokenise (minuscule + suppression diacritiques +
|
||
découpage sur non-lettres + un "stemming" naïf de pluriel, pas un vrai
|
||
stemmer linguistique) le texte libre et chaque libellé du catalogue
|
||
(`INGREDIENT_LABELS_EN`, `packages/shared`), cherche chaque libellé comme
|
||
**sous-séquence contiguë de tokens** dans le nom — le plus long (le plus
|
||
spécifique) l'emporte ("chicken breast" bat "chicken"), égalité départagée
|
||
par l'id le plus petit (déterminisme).
|
||
- `matchUnit` compare des tokens entiers (pas de sous-chaîne — "cup" ne doit
|
||
pas matcher à l'intérieur d'un mot plus long sans rapport).
|
||
- `extractQuantity` extrait un nombre/une fraction/un nombre mixte en tête de
|
||
texte libre (`"1 1/2"` → 1.5) si la source n'a pas fourni de quantité
|
||
structurée.
|
||
|
||
### Orchestration — `recipe-translation.ts`
|
||
|
||
`translateRecipe(recipe, locale)` — matche toujours les techniques contre
|
||
`locale`, mais ne charge/matche les catalogues ingrédient/unité **que si
|
||
`locale === "en"`** ; pour toute autre langue, `ingredientId`/`unitId` restent
|
||
`null` sur chaque ligne (dégradation "pas de données dans cette langue").
|
||
Piège documenté explicitement : une source anglaise (TheMealDB) traduite
|
||
contre les mappings `"fr"` obtiendrait un `techStepIds` **vide** sur chaque
|
||
étape (aucune règle de mapping anglaise n'existe encore) — cette étape
|
||
n'invente rien. `sources.service.ts`'s `previewSourceItem` (brouillon, non
|
||
sauvegardé) et `recipe.service.ts`'s `createRecipe`/`updateRecipe`/
|
||
`createImportedRecipe` (sauvegarde réelle) partagent exactement les mêmes
|
||
briques.
|
||
|
||
---
|
||
|
||
## Isolation de la base de test
|
||
|
||
`pnpm test` (`apps/api`) exécute une `TRUNCATE ... CASCADE` sur presque tout
|
||
le schéma **avant chaque test** (`test-support/reset-db.ts`'s
|
||
`resetDatabase()`) — auparavant partagée avec la base de dev via un seul
|
||
`.env`, ce qui a un jour vidé un vrai foyer/compte de dev en cours de test
|
||
(irrécupérable, `TRUNCATE`, pas de sauvegarde). Fix, trois pièces :
|
||
|
||
1. `config/env.ts` charge `.env.test` au lieu de `.env` quand
|
||
`NODE_ENV=test` (posé par `cross-env` dans le script `"test"` de
|
||
`apps/api/package.json`, avant que ce module ne s'exécute).
|
||
2. `resetDatabase()` appelle désormais `assertRunningAgainstTestDatabase()` en
|
||
tout premier : lève si `NODE_ENV !== "test"`, **et** si `DATABASE_URL` ne
|
||
contient ni `"test"` ni `"ci"` — la branche `"ci"` a dû être ajoutée après
|
||
coup, la base de CI s'appelant `batchcooking_ci` (pas `batchcooking_test`),
|
||
ce que le garde-fou initial rejetait à tort (282 tests en échec). Le seul
|
||
nom que ce garde-fou doit encore rejeter est la vraie base de dev,
|
||
`batchcooking`.
|
||
3. Nouveaux fichiers `apps/api/.env.test` (local, gitignored) et
|
||
`.env.test.example` (committé, template + commandes pour créer la base) —
|
||
même serveur/identifiants Postgres que `.env`, juste un nom de base
|
||
différent.
|
||
|
||
**À faire une fois par machine** avant `pnpm test` : créer `apps/api/.env.test`
|
||
pointant vers une base **différente** (ex. `batchcooking_test`) — voir
|
||
`.env.test.example` pour les commandes exactes (`CREATE DATABASE`, `prisma
|
||
migrate deploy`). Ce fichier n'est pas créé automatiquement, contrairement à
|
||
`.env`.
|
||
|
||
En CI (`.github/workflows/ci.yml`), le job `test` provisionne son propre
|
||
service `postgres:16-alpine` (`POSTGRES_DB: batchcooking_ci`) et définit
|
||
`DATABASE_URL` au niveau du workflow — exactement le cas que la branche
|
||
`"ci"` du garde-fou existe pour accepter.
|
||
|
||
---
|
||
|
||
## Compte — suppression, pas encore de changement d'email/mot de passe
|
||
|
||
`DELETE /auth/me` (`auth.routes.ts`, `requireAuth`) — supprime définitivement
|
||
le profil courant après **revérification du mot de passe**
|
||
(`deleteAccountSchema`, `packages/shared/src/schemas/account.ts`) : 401
|
||
`INVALID_CREDENTIALS` si le mot de passe ne correspond pas (`argon2.verify`),
|
||
sinon `leaveCurrentHouse` (gère transfert d'adminship/suppression du foyer,
|
||
voir plus haut) puis suppression du profil (cascade `UserProfileAllergy` via
|
||
`onDelete: Cascade`). Aucune route de changement d'email/mot de passe n'existe
|
||
encore — `AccountSettingsPage` (web) n'affiche l'identité qu'en lecture seule.
|
||
|
||
`UserProfile.tokenVersion` (bump prévu pour invalider les JWT déjà émis, ex. à
|
||
un futur changement de mot de passe) existe déjà dans le schéma et est vérifié
|
||
à chaque requête par `requireAuth`, mais **rien ne l'incrémente encore** —
|
||
c'est une infrastructure posée à l'avance, pas encore câblée à une
|
||
fonctionnalité réelle.
|
||
|
||
---
|
||
|
||
## Pas de fichiers `.d.ts` écrits à la main
|
||
|
||
Voir [frontend-architecture.md](./frontend-architecture.md#note-sur-les-fichiers-dts)
|
||
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.
|