9 fichiers à plat -> recipe-sources/ (recipe-source-adapter, recipe-source- errors, recipe-source-registry) et recipe-matching/ (recipe-translation, ingredient-matcher, tech-step-matcher). jwt.ts, safe-profile.ts et logger.service.ts restent à la racine de lib/ (pas de sous-domaine partagé avec les autres). Chemins relatifs corrigés dans les fichiers déplacés (profondeur +1 vers db/) et chez tous leurs importeurs (modules/sources, modules/recipe, sources/*, db/recipe-source-sync.ts, 12 fichiers de test), doc mise à jour (specs/backend-architecture.md, specs/batch-cooking-architecture.md). Vérifié : tsc --noEmit, biome check, build complet, 303 tests API. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
546 lines
29 KiB
Markdown
546 lines
29 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 »".
|
|
|
|
---
|
|
|
|
## `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`
|
|
|
|
`normalizeText` : décomposition NFD + suppression des diacritiques combinants
|
|
+ minuscule (ex. "Déglacer" → "deglacer"), appliquée à la fois au texte de
|
|
l'étape et aux expressions des mappings — permet d'écrire les expressions
|
|
françaises accentuées naturellement dans `reference-seed-data.ts` tout en
|
|
matchant indépendamment des accents/de la casse.
|
|
|
|
`matchTechStepSpans(description, mappings)` — algorithme en 4 étapes :
|
|
1. teste chaque `expression` (source de regex) contre la description
|
|
normalisée ;
|
|
2. pour une même technique, ne garde que le meilleur candidat (`weight` le
|
|
plus élevé, égalité départagée par la position la plus précoce) ;
|
|
3. entre techniques **différentes** dont les spans se chevauchent encore
|
|
(ex. `cook` générique matchant dans "cuire au four", plus spécifique
|
|
`bake`), résolution gloutonne par poids décroissant — un candidat n'est
|
|
accepté que s'il ne chevauche aucun déjà accepté (ce qui permet à des
|
|
techniques non-chevauchantes de coexister dans une même phrase, tout en
|
|
éliminant un match redondant) ;
|
|
4. tri final par position de départ.
|
|
|
|
`start`/`end` renvoyés sont des offsets dans le texte **normalisé**, réutilisés
|
|
tels quels contre le texte **original** pour le surlignage — repose sur
|
|
l'hypothèse documentée (et acceptée) que la décomposition NFD n'augmente
|
|
jamais le nombre de caractères d'un texte français en pratique.
|
|
`loadTechStepMappingRules(locale)` est la seule pièce qui touche la base — à
|
|
appeler une fois par requête, pas par étape.
|
|
|
|
### Résolution ingrédients/unités — `ingredient-matcher.ts`
|
|
|
|
**Anglais uniquement** aujourd'hui (commit "matching anglais pour les tech
|
|
steps et les ingrédients") :
|
|
- `matchIngredientName` tokenise (minuscule + suppression diacritiques +
|
|
découpage sur non-lettres + un "stemming" naïf de pluriel, pas un vrai
|
|
stemmer linguistique) le texte libre et chaque libellé du catalogue
|
|
(`INGREDIENT_LABELS_EN`, `packages/shared`), cherche chaque libellé comme
|
|
**sous-séquence contiguë de tokens** dans le nom — le plus long (le plus
|
|
spécifique) l'emporte ("chicken breast" bat "chicken"), égalité départagée
|
|
par l'id le plus petit (déterminisme).
|
|
- `matchUnit` compare des tokens entiers (pas de sous-chaîne — "cup" ne doit
|
|
pas matcher à l'intérieur d'un mot plus long sans rapport).
|
|
- `extractQuantity` extrait un nombre/une fraction/un nombre mixte en tête de
|
|
texte libre (`"1 1/2"` → 1.5) si la source n'a pas fourni de quantité
|
|
structurée.
|
|
|
|
### Orchestration — `recipe-translation.ts`
|
|
|
|
`translateRecipe(recipe, locale)` — matche toujours les techniques contre
|
|
`locale`, mais ne charge/matche les catalogues ingrédient/unité **que si
|
|
`locale === "en"`** ; pour toute autre langue, `ingredientId`/`unitId` restent
|
|
`null` sur chaque ligne (dégradation "pas de données dans cette langue").
|
|
Piège documenté explicitement : une source anglaise (TheMealDB) traduite
|
|
contre les mappings `"fr"` obtiendrait un `techStepIds` **vide** sur chaque
|
|
étape (aucune règle de mapping anglaise n'existe encore) — cette étape
|
|
n'invente rien. `sources.service.ts`'s `previewSourceItem` (brouillon, non
|
|
sauvegardé) et `recipe.service.ts`'s `createRecipe`/`updateRecipe`/
|
|
`createImportedRecipe` (sauvegarde réelle) partagent exactement les mêmes
|
|
briques.
|
|
|
|
---
|
|
|
|
## Isolation de la base de test
|
|
|
|
`pnpm test` (`apps/api`) exécute une `TRUNCATE ... CASCADE` sur presque tout
|
|
le schéma **avant chaque test** (`test-support/reset-db.ts`'s
|
|
`resetDatabase()`) — auparavant partagée avec la base de dev via un seul
|
|
`.env`, ce qui a un jour vidé un vrai foyer/compte de dev en cours de test
|
|
(irrécupérable, `TRUNCATE`, pas de sauvegarde). Fix, trois pièces :
|
|
|
|
1. `config/env.ts` charge `.env.test` au lieu de `.env` quand
|
|
`NODE_ENV=test` (posé par `cross-env` dans le script `"test"` de
|
|
`apps/api/package.json`, avant que ce module ne s'exécute).
|
|
2. `resetDatabase()` appelle désormais `assertRunningAgainstTestDatabase()` en
|
|
tout premier : lève si `NODE_ENV !== "test"`, **et** si `DATABASE_URL` ne
|
|
contient ni `"test"` ni `"ci"` — la branche `"ci"` a dû être ajoutée après
|
|
coup, la base de CI s'appelant `batchcooking_ci` (pas `batchcooking_test`),
|
|
ce que le garde-fou initial rejetait à tort (282 tests en échec). Le seul
|
|
nom que ce garde-fou doit encore rejeter est la vraie base de dev,
|
|
`batchcooking`.
|
|
3. Nouveaux fichiers `apps/api/.env.test` (local, gitignored) et
|
|
`.env.test.example` (committé, template + commandes pour créer la base) —
|
|
même serveur/identifiants Postgres que `.env`, juste un nom de base
|
|
différent.
|
|
|
|
**À faire une fois par machine** avant `pnpm test` : créer `apps/api/.env.test`
|
|
pointant vers une base **différente** (ex. `batchcooking_test`) — voir
|
|
`.env.test.example` pour les commandes exactes (`CREATE DATABASE`, `prisma
|
|
migrate deploy`). Ce fichier n'est pas créé automatiquement, contrairement à
|
|
`.env`.
|
|
|
|
En CI (`.github/workflows/ci.yml`), le job `test` provisionne son propre
|
|
service `postgres:16-alpine` (`POSTGRES_DB: batchcooking_ci`) et définit
|
|
`DATABASE_URL` au niveau du workflow — exactement le cas que la branche
|
|
`"ci"` du garde-fou existe pour accepter.
|
|
|
|
---
|
|
|
|
## Compte — suppression, pas encore de changement d'email/mot de passe
|
|
|
|
`DELETE /auth/me` (`auth.routes.ts`, `requireAuth`) — supprime définitivement
|
|
le profil courant après **revérification du mot de passe**
|
|
(`deleteAccountSchema`, `packages/shared/src/schemas/account.ts`) : 401
|
|
`INVALID_CREDENTIALS` si le mot de passe ne correspond pas (`argon2.verify`),
|
|
sinon `leaveCurrentHouse` (gère transfert d'adminship/suppression du foyer,
|
|
voir plus haut) puis suppression du profil (cascade `UserProfileAllergy` via
|
|
`onDelete: Cascade`). Aucune route de changement d'email/mot de passe n'existe
|
|
encore — `AccountSettingsPage` (web) n'affiche l'identité qu'en lecture seule.
|
|
|
|
`UserProfile.tokenVersion` (bump prévu pour invalider les JWT déjà émis, ex. à
|
|
un futur changement de mot de passe) existe déjà dans le schéma et est vérifié
|
|
à chaque requête par `requireAuth`, mais **rien ne l'incrémente encore** —
|
|
c'est une infrastructure posée à l'avance, pas encore câblée à une
|
|
fonctionnalité réelle.
|
|
|
|
---
|
|
|
|
## Pas de fichiers `.d.ts` écrits à la main
|
|
|
|
Voir [frontend-architecture.md](./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.
|