batchCooking/specs/backend-architecture.md
Nicolas a252640fe6
Some checks failed
CI / build (push) Successful in 2m22s
CI / lint (push) Successful in 2m26s
CI / intent-service-test (push) Successful in 4m8s
CI / e2e (push) Successful in 12m40s
CI / test (push) Failing after 15m4s
CI / build-android (push) Has been skipped
feat(contact): modèle + POST /contact + formulaire utilisateur (#80)
Formulaire in-app de remontée de bug / suggestion pour les utilisateurs
connectés (PR 1/2 de l'issue #80).

- Modèle Prisma `ContactMessage` (+ relation inverse sur `UserProfile`) et
  migration `20260908120000_contact_message` — table neuve, aucun backfill.
  `authorId` nullable + `onDelete: SetNull` : un signalement survit à la
  suppression de son compte.
- Contrat partagé `schemas/contact.ts` : `CONTACT_MESSAGE_TYPES`,
  `submitContactMessageSchema` (type enum, message 10-5000 trim, pageUrl /
  appVersion optionnels — jamais le user-agent).
- Module API `contact` : `POST /contact` (`requireAuth`, monté `/contact`),
  `204` sans corps. Le user-agent est lu de l'en-tête de requête, jamais du
  corps. Émet `analytics.recordEvent(contact.submitted, …)`.
- Page `/parametres/contact` (`ContactPage` + `contact.ts` + `.scss`) :
  select de type, textarea, validation cliente via le schéma partagé, états
  succès / erreur (`ErrorMessageService`, jamais `err.message`). Entrée de
  nav « Nous contacter » dans `SETTINGS_ITEMS` + clé i18n + icône.
  `apiClient.submitContactMessage(input)` ajoute `pageUrl` / `appVersion`.
- Tests : `apps/api/test/contact.test.ts` (Mocha — insertion, en-tête UA,
  champs nullables, message trop court / trop long, type inconnu),
  `contact.feature` + steps (Cypress e2e — envoi + confirmation + contexte),
  `contact-page.cy.ts` (rendu + garde `RequireAuth` + label d'erreur).
- Docs : `batch-cooking-modele.md`, `backend-architecture.md`,
  `frontend-architecture.md`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 08:26:47 +02:00

965 lines
55 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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`).
---
## `contact` — formulaire de contact (bug / suggestion)
**`contact`** (`contact.routes.ts`/`.service.ts`, monté `/contact`,
`requireAuth`) : `POST /contact` → `204` sans corps (comme `POST
/auth/logout` — `ApiClient._request` ne traite que `204` comme sans
corps). Le formulaire in-app
(issue #80) par lequel un utilisateur connecté remonte un bug, une
suggestion ou un message libre. Corps validé par
`submitContactMessageSchema` (`packages/shared`) : `type`
(`"bug"|"suggestion"|"other"`), `message` (10–5000 caractères, `trim`),
`pageUrl` / `appVersion` optionnels. Le **user-agent est lu de
`req.headers["user-agent"]`**, jamais du corps. `submitContactMessage`
insère la ligne `ContactMessage` (`authorId` = appelant) puis émet
`analytics.recordEvent("contact.submitted", { actorType: "user", actorId, context: { type } })`
(fire-and-forget) — `admin-metrics` agrège les `AnalyticsEvent` par `type`
génériquement, l'event apparaît dans les séries du dashboard sans code en
plus. La consultation admin (`GET /admin/api/contact`) est décrite dans la
section `admin`.
---
## `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, portionsRequested }` (`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.
- `POST /planning/items/leftover` → `assignLeftoverPlanningItem`, 201. Body
`assignLeftoverSchema` = `{ date, weekDay, meal, sourceItemId, portions }`.
Crée un item « restes » (`sourcePlanningItemId` non nul) sur le créneau
cible, **même recette que la source** (`recipeId` copié, jamais envoyé par
le client). `portions` est revérifié contre le pool réellement disponible
(`computeAvailableLeftover`) → 409 `LEFTOVER_QUANTITY_EXCEEDED` si dépassé,
404 `PLANNING_ITEM_NOT_FOUND` si `sourceItemId` inconnu / d'un autre foyer /
lui-même un item « restes » (pas de restes-sur-restes).
- `PATCH /planning/items/:id/discard-leftover` → `discardLeftover`, 200. Jette
tout le pool encore disponible de l'item (`discardedPortions += disponible`) ;
idempotent (rien à jeter → renvoyé inchangé, aucun évènement). 404 même
garde que `removePlanningItem`.
**Portions demandées vs préparées (issue #34)** — le client n'envoie que
`portionsRequested` (« combien ce créneau doit nourrir »). `addPlanningItem`
en dérive `portions` (ce qui est **réellement préparé**) via
`computeBatchedPortions(recipe.portions, portionsRequested)`
(`@batch-cooking/shared`, pur) : on arrondit au multiple supérieur du
rendement de la recette (`ceil(demandées / recette.portions) × recette.portions`)
— on cuisine des lots entiers, jamais une fraction de plat. L'écart
`portions − portionsRequested` est le **pool de restes** de cet item,
consommé par les items « restes » (`sourcePlanningItemId` pointant vers lui)
et par `discardedPortions`. `getPlanningForDate` renvoie les deux valeurs
plus `discardedPortions` et `sourceItem` dans chaque `PlanningItemView`,
sans `include` Prisma supplémentaire (le front reconstitue `leftoverUses` à
partir du tableau plat `planning.items` pour l'affichage du panier).
**Exclusion des items « restes »** — `shopping-list.service.ts` et
`cooking-session.service.ts` filtrent `items: { where: { sourcePlanningItemId:
null } }` dans leur `include` : un créneau qui ne fait que resservir un plat
déjà cuisiné n'a rien à acheter ni à cuisiner. `getPlanningForDate` **ne**
filtre pas — la grille affiche bien ces items (badge « restes »).
**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.
---
## Cooking session — optimisation des étapes planifiées
Router `/cooking-session` (`cooking-session.routes.ts`/`.service.ts`),
`requireAuth` — un seul endpoint : `GET /cooking-session?date=YYYY-MM-DD` →
`getCookingPlanForDate` → `OptimizedCookingPlanView`. Même contrat `?date=`
que `GET /shopping-list` (schéma shape-only + `parseDateOnly`), même requête
"plage couvrante" que `getPlanningForDate`, et **jamais `null`** de la même
façon que la liste de courses : pas de foyer / aucun `Planning` couvrant la
semaine ⇒ `recipes: []`, `phases: []`.
C'est la première brique du module « Calcul batch-cooking »
([batch-cooking-architecture.md](./batch-cooking-architecture.md)),
jusqu'ici `TODO`. Le service ne fait que **charger + façonner** : sa requête
Prisma (`cookingSessionPlanningInclude`) reprend le sous-arbre `steps` de
`recipe.service.ts`'s `recipeInclude` (steps ordonnés → `StepTechStep`
ordonnés → `techStep` + `ingredients` résolus + `utensils`), puis
`toOptimizerRecipe` mappe chaque `PlanningItem` vers l'entrée pure de
l'optimiseur (ingrédients/unités/techniques/ustensiles déjà en vues de
référence via `toIngredientView`/`toUnitView` réutilisées — même raison que
`shopping-list.service.ts`). Un `PlanningItem` = une entrée d'optimiseur,
même si deux créneaux pointent la même recette à des portions différentes
(deux vraies préparations ; la mutualisation de la découpe les regroupe
quand même).
**`optimizeCookingPlan`** (`lib/recipe-matching/cooking-optimizer.ts`,
pure/synchrone — testable sans base, même split que `ingredient-matcher.ts`
/ `tech-step-matcher.ts`) réorganise les recettes en **phases ordonnées** :
- **Mutualisation de la mise en place** : une technique de découpe
(`PREP_TECHNIQUES` : `chop`/`peel`/`mince`/`julienne`/…) appliquée au même
ingrédient dans une étape *purement prep* (toutes ses techniques sont des
`PREP_TECHNIQUES`) de **≥ 2 recettes** est regroupée en une tâche
`merged-prep` ; les étapes d'origine sont *absorbées* (ne produisent plus
de tâche). Une découpe présente dans une seule recette reste inline (juste
classée en mise en place). Les découpes à l'intérieur d'une étape de
cuisson ne sont pas mutualisées en v1.
- **Parallélisme** : `TECHNIQUE_ATTENTION` classe chaque étape en `SETUP`
(préchauffage, eau à ébullition — poussé en mise en place), `PASSIVE`
(mijoter, braiser, cuire au four, mariner… — non surveillé une fois
lancé) ou `ACTIVE` (défaut). Les phases de cuisson interclassent les
recettes (une « prochaine étape de chaque recette » par phase) ; une
étape `PASSIVE` fait patienter sa recette une phase et s'affiche en
`background` des phases suivantes tant que sa consommatrice n'est pas
remontée.
Quantités mises à l'échelle par `PlanningItem.portions / Recipe.portions`
comme la liste de courses. Sommes d'ingrédients uniquement à unité
identique (aucune conversion — même posture que `ShoppingListItemView`).
---
## `admin` — application d'administration (`/admin/*`)
Surface d'exploitation consommée par les routes `/admin/*` de `apps/web`
(mêmes app/build/origine que le reste du frontend, montées sous `/admin` dans
`App.tsx`, enveloppées de `AdminAuthProvider` + `RequireAdmin`). Vit dans
`apps/api` (qui reste seul propriétaire du schéma) mais avec une
**authentification totalement distincte** de celle des utilisateurs.
Son API est montée sous `/admin/api`, pas bare `/admin` — les deux surfaces
partagent la même origine/le même process (`serveStaticFrontend`, `app.ts`),
donc un mount bare `/admin` mettait chaque route API en conflit direct avec
la page front de même nom (ex. `GET /admin/monitoring` répondait à la place
du SPA sur un rafraîchissement de `/admin/monitoring` — JSON brut au lieu de
la page). Incident réel, corrigé 2026-09-05.
**Auth** (`middlewares/require-admin.ts`, `lib/admin-jwt.ts`) — table
`AdminUser` isolée (aucune relation vers `UserProfile`), cookie
`ADMIN_COOKIE_NAME` (`admin_session`, ≠ `session`), secret `ADMIN_JWT_SECRET`
(≠ `JWT_SECRET`). `requireAdmin` re-check `tokenVersion` en base comme
`requireAuth`, **échoue fermé** si `ADMIN_JWT_SECRET` est absent (posture
`requireInternalWorker`). Aucun signup exposé — le 1ᵉʳ admin est créé
hors-bande par `src/scripts/create-admin.ts` (flags ou `ADMIN_INITIAL_*`).
`res.locals.adminUser` typé `AdminLocals`. Pas de CORS dédié : l'UI admin est
servie par la même origine que le reste de l'app (`CORS_ORIGIN` suffit).
Router agrégateur `modules/admin/admin.routes.ts` monté `/admin/api` :
`/admin/api/auth` (`login`/`logout`/`me`), `/admin/api/metrics` (ci-dessous).
**Métriques** (`admin-metrics.service.ts`, `GET /admin/api/metrics?days=` 7–365,
défaut 30) — `MetricsView` = `snapshot` (des `count`s : utilisateurs, foyers,
foyers actifs, recettes manuelles/importées + ventilation par source,
plannings, créneaux, corrections par état, suggestions par statut/source…) +
`series` (buckets journaliers zéro-remplis) + `events` (rollup
`AnalyticsEvent` par type/jour). Les séries sont dérivées de colonnes
`createdAt` ajoutées par la migration `admin_metrics` à `UserProfile` /
`Recipe` / `Planning` / `PlanningItem` (aucun code applicatif ne les lit ;
lignes préexistantes = timestamp de la migration). `bucketByDay` est
pur/testable sans base.
**Instrumentation** (`lib/analytics.service.ts`) — `analytics.recordEvent(type, { actorId?, context? })`
**fire-and-forget** : retourne `void`, insère détaché, un échec est loggué
`warn` et avalé (jamais de latence ni d'échec sur la requête appelante). `type`
est un `String` libre (`"user.signup"`, `"recipe.imported"`, `"recipe.created"`,
`"planning.item_added"`, `"tech_step.correction_submitted"`,
`"shopping_list.viewed"` aujourd'hui) — ajouter un évènement = un appel d'une
ligne, **sans migration**. `AnalyticsEvent.actorId` n'a **pas** de FK (un
évènement est un fait historique qui survit au compte qu'il décrit).
**Monitoring** (`admin-monitoring.service.ts`'s `getMonitoring()`) — sonde
active, chaque cible bornée à ~2 s, latence mesurée : Postgres
(`SELECT 1`), l'API elle-même (uptime/RSS), `tech-step-intent-service`
(`GET /health`, sans secret), et le **worker LLM** via sa ligne
`WorkerHeartbeat`. `MonitoringView { services: ServiceHealthView[] }`,
statut `up | degraded | down | unknown` ; une sonde `down` ne fait jamais
échouer les autres ni cet appel dans son ensemble.
**Pas de route REST** — poussé en WebSocket. `MonitoringBroadcastService`
(`lib/monitoring-broadcast.service.ts`) sonde une fois au démarrage puis
toutes les 60 s (un seul `setInterval` pour tout le process, pas par
onglet admin ouvert) et diffuse le résultat (`monitoring:update`) à la
room `socket.io` `"monitoring"`. Auth au handshake via
`requireAdminSocket` (`middlewares/require-admin-socket.ts`) — même
vérification que `requireAdmin` (cookie `ADMIN_COOKIE_NAME` +
`verifyAdminToken` + re-check `tokenVersion`, factorisée dans
`resolveAdminFromToken`), lue depuis l'en-tête `Cookie` brut du handshake
(parsé à la main avec le package `cookie` — un handshake `socket.io` ne
passe pas par `cookie-parser`). Le dernier résultat est mis en cache et
rejoué immédiatement à tout admin qui rejoint la room, pour qu'un onglet
fraîchement ouvert n'attende pas la prochaine minute. Le `socket.io`
`Server` s'attache directement au `http.Server` sous-jacent
(`ExpressServer.httpServer`, `packages/express-tools`), pas à Express —
câblé dans `server.ts`, pas `app.ts` (les tests supertest n'ont pas besoin
d'un serveur WebSocket).
Le worker (`services/tech-step-llm-worker`) n'a **aucune** surface HTTP
entrante — il devient observable via `POST /internal/tech-steps/heartbeat`
(`requireInternalWorker`, `tech-step-worker.service.ts`'s
`recordWorkerHeartbeat` → upsert `WorkerHeartbeat`, clé fixe
`"tech-step-llm-worker"`). Le worker l'appelle au boot, à chaque tick du
scheduler, et après chaque job (avec `job`/`ok`/`counts`) — best-effort, un
heartbeat en échec ne casse jamais un run. Seuils d'âge : > 8 j ⇒ `degraded`,
> 21 j ⇒ `down` (cron par défaut hebdomadaire).
**Tri des corrections** (`admin-tech-steps.service.ts`, routes
`/admin/api/tech-steps/*`, `requireAdmin`) — remplace le duo CLI
`list-pending-training-suggestions.ts` / `retrain-tech-steps.ts` :
- `GET /suggestions` — `TechStepTrainingSuggestion` filtrées
(`status`/`sourceType`/`techStepKey`/`locale`), **groupées par technique**,
enrichies du contexte de la correction source (clause = `description.slice`).
- `GET /corrections` — `StepTechStepCorrection` brutes, filtrables
(`consumed`/`hasCorrectedTechStep`), **incluant** les suppressions
`correctedTechStepId: null` invisibles ailleurs.
- `PATCH /suggestions/:id` — édite `suggestedSynonyms`/`suggestedUtterances`
et/ou `status` (`pending|applied|rejected`). Corps vide ⇒ `400`.
- `GET /training-data-snippet?techStepKey=&locale=&status=` — agrège
synonymes/phrases des suggestions retenues en un bloc `training_data.py`
à coller (lecture seule).
- `POST /retrain` — enveloppe `retrain-tech-steps.ts` **sans shell-out** :
`runTechStepEvalSuite()` (gate F1 vs `MIN_OVERALL_F1`), puis si passé
`backfillTechSteps()` + marquage des ids `applied`/`rejected`. Verrou
mémoire process-wide ⇒ `409 RETRAIN_ALREADY_RUNNING` si un retrain tourne
déjà. Gate échoué ⇒ `200` avec `gatePassed: false`, aucun backfill.
**Ne peut ni éditer `training_data.py` ni redémarrer l'intent-service** —
ces deux étapes restent manuelles, l'UI l'affiche en bandeau permanent.
**Ingrédients hors-catalogue** (`admin-catalog.service.ts`, routes
`/admin/api/catalog/*`, `requireAdmin`) — revue des lignes `Ingredient`
`isPlaceholder` (voir la section *Recettes* et `batch-cooking-modele.md`) :
- `GET /placeholders?reviewed=` — tous les placeholders, **regroupés par nom
normalisé** (`normalizePlaceholderName` : minuscules + sans accents +
ponctuation → espace + espaces compressés), un groupe = un manque du
catalogue (`recipeCount`, recettes-échantillon, orthographes vues,
`firstSeenAt`). Défaut / `reviewed=false` : les groupes encore à traiter ;
`reviewed=true` : l'archive des groupes entièrement traités.
- `PATCH /placeholders/mark-reviewed` (`{ ingredientIds }`) — pose `reviewedAt`
(le groupe sort de la liste de travail). N'ajoute **rien** au catalogue.
- `POST /placeholders/prune-orphans` — supprime les placeholders qu'aucune
recette ne référence plus (débris d'une édition de recette). Aussi
disponible en script : `scripts/prune-orphan-placeholders.ts`.
La promotion d'un placeholder en vraie entrée catalogue (édition de
`reference-seed-data.ts` + locales) reste **100 % manuelle** — pas de fusion
ni de génération de snippet depuis l'UI.
---
## `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()` a deux modes : une `query` non vide est
une recherche par nom (`/search.php?s=`, réponse unique non paginée,
`nextCursor: null`) ; une `query` vide/absente ("tout parcourir") **déroule
la liste des catégories** — `/list.php?c=list` une fois, puis
`/filter.php?c={catégorie}` une catégorie à la fois, le `cursor` étant
l'index de catégorie (une catégorie = une "page"). Le palier gratuit n'a
pas de vrai endpoint catalogue global ; `/search.php?s=` (vide) ne renvoie
qu'un échantillon fixe (~25 recettes), donc le scroll infini s'arrêtait
aussitôt (issue #38). `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.
- **`quitoque.ts`** — `key: "quitoque"`, `official: false`, `locale: "fr"`.
Le plus fin des wrappers scrapés (comme `marmiton.ts`) : `fetchDetail`/
`parse` délèguent tels quels à `jsonLdRecipeAdapter` (une page recette
quitoque.fr porte un `Recipe` schema.org, seul élément d'un tableau
JSON-LD de premier niveau, géré nativement par `findRecipeNode`). Seul
`list()` est spécifique : `GET /recettes?page=N` (page 1 implicite si
curseur absent), scrape de cartes `<a class="card__content_header"
href="/recettes/…">` (image réelle dans `data-src`, `href` relatif
résolu contre l'origine). Pas de vraie recherche serveur → `query` est
un filtre sous-chaîne "best effort" sur les titres de la page (même
lacune assumée que `750g.ts`). Fin de liste = page sans aucune carte.
Les copies privées de `rekeySourceError` (marmiton/750g/manger-bouger
en ont chacune une) ne sont pas factorisées.
- **`ohmonbento.ts`** — `key: "ohMonBento"`, `official: false`,
`locale: "fr"`. Le plus simple des adaptateurs scrapés : `fetchDetail`/
`parse` délèguent tels quels à `jsonLdRecipeAdapter` (une page recette
porte un `Recipe` WP Recipe Maker dans un `@graph` Yoast, géré
nativement par `findRecipeNode`). `list()` : recherche WordPress
`GET /?s={query}&paged={N}` — **même URL que la requête soit vide (tout
le catalogue) ou non**, pas de branchement (contrairement à `750g.ts`) —
scrape de cartes `<div class="entry-thumb"><a href="/<slug>/"><img
alt src>` (image dans `src`, pas de lazy-load `data-src` ; `href`
relatif résolu contre l'origine). Une page au-delà de la dernière **404**
(signal de fin propre, comme `marmiton.ts`).
`apps/api/src/sources/index.ts`'s `registerAllRecipeSources()` enregistre
`theMealDb`, `marmiton`, `750g`, `mangerBouger`, `quitoque` et
`ohMonBento` — 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) |
| `POST /recipes/:id/steps/:stepId/corrections` | `submitTechStepCorrection` (`recipe-tech-step-correction.service.ts`) — ouvert à tout viewer qui voit la recette, pas seulement son auteur |
| `GET /recipes/:id/steps/:stepId/corrections` | `listTechStepCorrections` — historique brut des corrections soumises pour l'étape |
**Corrections de tech-step** — une correction porte sur un `[start, end)` de
`Step.description` (`previousTechStepId`/`correctedTechStepId`, voir
[batch-cooking-modele.md](./batch-cooking-modele.md#step-step-et-techniques-détectées)).
Elle enregistre systématiquement une ligne d'audit `StepTechStepCorrection`
(jamais modifiée ensuite) **et** applique l'effet immédiatement sur la
séquence réelle `StepTechStep` de l'étape — pas seulement une suggestion en
attente pour `services/tech-step-llm-worker`. `ingredients`/`utensils`/
`temperatures` peuvent accompagner un `correctedTechStepId` réel pour
attacher/remplacer les métadonnées de cette occurrence ; les trois voyagent
comme **un seul lot** — dès que l'une est présente dans la requête, les
trois sont résolues (une absente devient `[]`, donc effacée), jamais une
fusion partielle catégorie par catégorie. `temperatures[]` exige
exactement un de `celsius`/`gasMark`/`qualitative` par entrée (même
contrainte que la détection automatique) ; son `raw` n'est jamais envoyé
par le client — dérivé côté service de `description.slice(start, end)`.
**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.
**Ingrédients hors-catalogue** — chaque ligne d'ingrédient de `POST`/`PATCH
/recipes` porte **soit** `ingredientId` (catalogue, ou un placeholder
existant qui fait l'aller-retour par son id réel), **soit** `placeholderName`
(texte libre). Pour une ligne `placeholderName`, `createRecipeInternal` /
`updateRecipe` créent, dans le `$transaction` de la recette, une ligne
`Ingredient` `isPlaceholder=true` (`resolveIngredientLines`), puis émettent
`analytics.recordEvent("ingredient.placeholder_created")`. Ces lignes sont
exclues de `GET /reference/ingredients` et de `loadIngredientCatalog`
(`ingredient-matcher.ts`). Retirer une ligne placeholder d'une recette
laisse une ligne `Ingredient` orpheline (non-GC — purge manuelle via
`/admin/api/catalog/placeholders/prune-orphans`). Revue côté admin :
`/admin/api/catalog/*` (section *admin* ci-dessus).
### 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`).
`node-nlp` a ensuite été remplacé à son tour par un microservice Python dédié,
`services/tech-step-intent-service` (spaCy — `PhraseMatcher` + `textcat`),
appelé en HTTP par `TechStepClassifierService` via `IntentServiceClient`
(`intent-service-client.ts`) — `node-nlp` était peu maintenu et tournait
in-process dans l'event loop Node ; spaCy offre un écosystème NLP plus
robuste, dans un processus séparé, avec l'ambition à terme de pouvoir aussi
absorber ce que fait `services/tech-step-llm-worker`. Ce service est
entièrement autonome : `TECH_STEP_TRAINING_DATA` (~74 techniques) vit
désormais dans son propre `training_data.py`, revu par PR comme le reste du
code mais plus poussé par `apps/api` via HTTP — le service s'entraîne
lui-même une seule fois, à son propre démarrage, et ne touche jamais
Postgres (voir son propre README, y compris pour le temps de démarrage —
plusieurs minutes, l'entraînement n'étant jamais persisté sur disque).
`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 — un port Python de cette même fonction
(`intent_service/text_normalization.py`) alimente le composant de
normalisation du pipeline spaCy côté service.
**Pipeline en 3 étapes** (`TechStepClassifierService.matchTechStepSpans`) :
1. **NER** (le `PhraseMatcher` du service, construit depuis les `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. Le matching se fait sur une normalisation
stricte (accents/casse) sans tolérance floue de type Levenshtein — voir
`services/tech-step-intent-service/intent_service/locale_pipeline.py`.
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 `textcat` du service, 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` (voir la constante dans `tech-step-matcher.ts`
pour la valeur courante et comment elle a été calibrée), 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 que le modèle n'est
pas assez confiant.
Résolution `TechStep.key -> id` mémoïsée une seule fois sur le singleton
partagé `techStepClassifier` (jamais par requête) — c'est tout ce
qu'`apps/api` a encore à mémoïser, l'entraînement du modèle lui-même vivant
entièrement côté `services/tech-step-intent-service`. `server.ts` appelle
`techStepClassifier.warmUp()` avant d'accepter du trafic, avec retry/backoff
si `services/tech-step-intent-service` n'est pas encore joignable (le cas
normal en Docker Compose, où `app` attend qu'il soit `healthy` avant même de
démarrer — voir `docker-compose.yml`, et le README de ce service pour
combien de temps ça prend).
**Pièges rencontrés en construisant ce pipeline**, tous 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()`.
- (historique, node-nlp) `NlpManager` avait `autoSave`/`autoLoad: true` par
défaut — persistait le modèle entraîné dans un fichier `model.nlp` (cwd du
process) et le rechargeait *au lieu de* ré-entraîner au prochain démarrage
s'il existait déjà. Un modèle obsolète sur disque aurait masqué
silencieusement toute mise à jour du corpus. Non applicable au service
Python actuel : il réentraîne tout en mémoire à chaque démarrage du
process, sans jamais rien persister sur disque (voir ce service's own
README).
- `nlp.make_doc()` (spaCy) ne fait tourner que le tokenizer, pas les
composants du pipeline — un piège trouvé en construisant le `PhraseMatcher`
du nouveau service : les patterns de synonymes doivent explicitement
repasser par le composant de normalisation, sinon un synonyme accentué
("préchauffer") ne matche jamais sa forme normalisée dans le texte cible
(voir le commentaire dans `locale_pipeline.py`'s `train()`).
**Métadonnées d'action — ingrédients, quantités, ustensiles.** Chaque
occurrence de technique (`TechStepMatch`) porte aussi ce qui a été détecté
dans sa propre *clause* (celle calculée à l'étape 2 ci-dessus) :
- **Ingrédients** — `ingredient-matcher.ts`'s `findIngredientMentions` scanne
le texte de la clause contre le catalogue `Ingredient` *existant*
(`INGREDIENT_LABELS_FR`/`_EN`, `packages/shared` — le même que
`matchIngredientName` utilise déjà pour les listes structurées), plutôt que
de dupliquer ce catalogue côté service Python. Une quantité+unité
immédiatement avant la mention est résolue au mieux (regex ancrée sur la
*fin* du texte précédent, voir `QUANTITY_BEFORE_INGREDIENT_PATTERN`) —
`null`/`null` sinon, jamais une erreur.
- **Ustensiles** — contrairement aux ingrédients, ce catalogue n'existait
nulle part avant cette fonctionnalité : il est né directement côté service
Python (`intent_service/utensil_vocabulary.py`), via un second
`PhraseMatcher` indépendant du premier (pas de `textcat` — un ustensile
mentionné n'a pas besoin d'être interprété, contrairement à une technique).
`POST /v1/process` renvoie donc deux types d'entité discriminés par
`kind: "technique" | "utensil"` dans la même liste `entities`.
Dans les deux cas, l'association à une technique se fait par appartenance à
la même clause — pas d'analyse syntaxique (le `parser` spaCy reste exclu du
pipeline, voir `_EXCLUDED_COMPONENTS`), juste "cette mention tombe dans
`[clause.start, clause.end)`". Persisté comme `StepTechStepIngredient`/
`StepTechStepUtensil`, deux tables référençant `StepTechStep` par sa clé
composite `(stepId, order)`.
### 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.