batchCooking/specs/batch-cooking-modele.md
Nicolas 30f975d29a
All checks were successful
CI / lint (push) Successful in 3m16s
CI / build (push) Successful in 3m29s
CI / intent-service-test (push) Successful in 4m39s
CI / e2e (push) Successful in 10m24s
CI / test (push) Successful in 18m48s
CI / build-android (push) Has been skipped
feat(admin): crée un ticket Forgejo depuis un message de contact (#81)
Depuis /admin/messages, un admin peut créer une issue Forgejo à partir
d'un message utilisateur : bouton « Créer un ticket » → modale pré-remplie
éditable → l'issue est créée via l'API Forgejo et le lien (#N) est
mémorisé sur le message. Première intégration Forgejo d'apps/api.

- Modèle : `forgejoIssueNumber` / `forgejoIssueUrl` sur `ContactMessage`
  (migration `20260908130000_contact_message_forgejo_link`, 2 colonnes
  nullables, aucun backfill).
- Config : `FORGEJO_BASE_URL` / `FORGEJO_TOKEN` / `FORGEJO_REPO`, toutes
  `.optional()` — **fail-closed** (même posture que `ADMIN_JWT_SECRET`) :
  bouton masqué + endpoint `503` tant que les trois ne sont pas là.
  Documentées dans `.env.example` ; valeurs de test dans `ci.yml`.
- `ForgejoService` (`lib/forgejo/forgejo.service.ts`, singleton `forgejo`) :
  `isConfigured()` + `createIssue({ title, body })` → `POST .../issues`,
  en-tête `Authorization: token <pat>`, lit `{ number, html_url }` ;
  erreur réseau / non-2xx / JSON invalide ⇒ `HttpError 502
  FORGEJO_REQUEST_FAILED`.
- `admin-contact` : `POST /admin/api/contact/:id/forgejo-issue`
  (`createForgejoIssueFromMessage` — ordre 503 → 404 → 409 → Forgejo →
  écriture BDD ; un échec Forgejo ne touche pas la BDD) et
  `GET /admin/api/contact/forgejo/status`.
- Contrat partagé : `createForgejoIssueSchema` (title 1–255, body 1–60000),
  `ContactMessageAdminView` étendu, `ForgejoIntegrationStatus`, nouveaux
  `ErrorCode` (`FORGEJO_NOT_CONFIGURED`=5001, `FORGEJO_REQUEST_FAILED`=5002,
  `CONTACT_MESSAGE_ALREADY_LINKED`=4026, `CONTACT_MESSAGE_NOT_FOUND`=4055)
  + labels i18n.
- Front : `MessagesPage` — sonde `forgejo/status` au montage, colonne
  « Ticket » (lien `#N` si lié, bouton sinon, « non configurée » sinon),
  `CreateForgejoIssueModal` + helpers purs `messages.ts`
  (`buildIssueTitle` / `buildIssueBody`).
- Tests : `forgejo.service.test.ts` (stub `globalThis.fetch`),
  `admin-contact.test.ts` étendu (nominal / 503 / 404 / 409 / 502 +
  ligne intacte / 400), `admin-messages.cy.ts` étendu (modale pré-remplie,
  lien après création, masquage si non configuré).
- Docs : `backend-architecture.md`, `batch-cooking-modele.md`.

Closes #81.

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

485 lines
21 KiB
Markdown

# Modèle de données — Projet Batch-cooking
> Documentation du schéma de données de l'application de planification de batch-cooking.
> Source de vérité : `apps/api/prisma/schema.prisma` (abondamment commenté en anglais,
> chaque écart avec ce document ou avec le doc spec d'origine y est expliqué en place —
> ce fichier en est un résumé navigable, pas un remplacement).
---
## Vue d'ensemble
Le modèle s'articule désormais autour de cinq grands pôles (le pôle « Recettes »
a beaucoup grossi depuis la première version : sources externes, catalogue
d'ingrédients/unités normalisé, techniques détectées, visibilité) :
- **Utilisateurs & foyer** — `UserProfile`, `House`, `Diet`, `Category`, `Allergy`,
`UserPreference` (thème)
- **Planification** — `Planning`, `PlanningItem`
- **Recettes** — `Recipe`, `RecipeIngredient`, `Step`, `TechStep`,
`StepTechStep`, `RecipeDiet`, `RecipeFavorite`
- **Métadonnées d'action** — `Utensil`, `StepTechStepIngredient`,
`StepTechStepUtensil` (ingrédients/quantités/ustensiles associés à une
technique détectée, voir plus bas)
- **Sources externes** — `Source`, `HouseSource`
- **Catalogue ingrédients/unités** — `Ingredient`, `Unit`, `IngredientDiet`,
`IngredientAllergy`, `UserProfileDislikedIngredient`
---
## Schéma entité-relation
```mermaid
erDiagram
USER_PROFILE }o--o| HOUSE : "membre de"
HOUSE ||--|| USER_PROFILE : "admin (adminId)"
USER_PROFILE }o--o| DIET : "suit"
USER_PROFILE ||--o| USER_PREFERENCE : "thème"
HOUSE ||--o{ PLANNING : "planifie"
PLANNING ||--o{ PLANNING_ITEM : "contient"
PLANNING_ITEM }o--|| RECIPE : "utilise"
RECIPE }o--o| SOURCE : "vient de"
RECIPE }o--|| USER_PROFILE : "auteur (authorId)"
RECIPE }o--o| HOUSE : "foyer auteur (authorHouseId)"
HOUSE ||--o{ HOUSE_SOURCE : "sources activées"
SOURCE ||--o{ HOUSE_SOURCE : ""
CATEGORY ||--o{ ALLERGY : "classe"
USER_PROFILE }o--o{ ALLERGY : "a"
USER_PROFILE }o--o{ RECIPE : "favoris"
USER_PROFILE }o--o{ INGREDIENT : "n'aime pas"
RECIPE ||--o{ RECIPE_INGREDIENT : "compose de"
RECIPE_INGREDIENT }o--|| INGREDIENT : ""
RECIPE_INGREDIENT }o--|| UNIT : ""
RECIPE }o--o{ DIET : "tags régime (RecipeDiet)"
INGREDIENT }o--o{ DIET : "compatible avec"
INGREDIENT }o--o{ ALLERGY : "contient"
RECIPE ||--o{ STEP : "compose de"
STEP }o--o{ TECH_STEP : "techniques détectées (StepTechStep)"
TECH_STEP ||--o{ TECH_STEP_MAPPING : "règles de détection"
USER_PROFILE {
int id PK
string firstName
string lastName
string email UK
string passwordHash
int tokenVersion
int houseId FK
int dietId FK
}
HOUSE {
int id PK
string name
int adminId FK
string inviteCode UK
}
USER_PREFERENCE {
int userProfileId PK_FK
enum theme "LIGHT | DARK | SYSTEM"
}
DIET {
int id PK
string key UK
}
ALLERGY {
int id PK
int categoryId FK
}
CATEGORY {
int id PK
string key UK
enum kind "ALLERGY | INTOLERANCE"
}
PLANNING {
int id PK
date startDate
date finishDate
int houseId FK
}
PLANNING_ITEM {
int id PK
int planningId FK
string weekDay
string meal
int recipeId FK
int portions
int portionsRequested
int sourcePlanningItemId FK "nullable, auto-relation"
int discardedPortions
}
SOURCE {
int id PK
string key UK
string name
string url
bool official
string iconUrl
}
HOUSE_SOURCE {
int houseId PK_FK
int sourceId PK_FK
}
RECIPE {
int id PK
string name
int sourceId FK
string externalId
string description
string picture
int portions
int authorId FK
int authorHouseId FK
enum visibility "PERSONAL | HOUSE | PUBLIC"
}
RECIPE_INGREDIENT {
int recipeId PK_FK
int ingredientId PK_FK
decimal quantity
int unitId FK
}
RECIPE_DIET {
int recipeId PK_FK
int dietId PK_FK
}
RECIPE_FAVORITE {
int userProfileId PK_FK
int recipeId PK_FK
}
INGREDIENT {
int id PK
string key UK
enum icon
enum category
enum subcategory
bool reproducible
}
UNIT {
int id PK
string key UK
enum type "MASS | VOLUME | COUNT"
decimal toBaseFactor
}
STEP {
int id PK
int recipeId FK
string description
string picture
int order
}
TECH_STEP {
int id PK
string key UK
}
TECH_STEP_MAPPING {
int id PK
int techStepId FK
string locale
string expression
int weight
}
STEP_TECH_STEP {
int stepId PK_FK
int techStepId PK_FK
int order PK
int start
int end
}
```
*(Rendu sur les visualiseurs markdown compatibles mermaid — GitHub, VS Code, Obsidian, etc.
Champ `PK_FK` = à la fois clé primaire et clé étrangère, `UK` = unique.)*
---
## Utilisateurs & foyer
### `user_profiles` (`UserProfile`)
| Champ | Description |
|---|---|
| `id` | Identifiant |
| `firstName` / `lastName` | Nom |
| `email` | Unique |
| `passwordHash` | Hash argon2 du mot de passe |
| `tokenVersion` | Compteur incrémenté pour invalider les JWT déjà émis (ex. changement de mot de passe) — mécanisme provisionné, aucun code ne l'incrémente encore aujourd'hui (pas de changement de mot de passe implémenté, voir [backend-architecture.md](./backend-architecture.md)) |
| `houseId` | FK → `house`, nullable |
| `dietId` | FK → `diet`, nullable |
Relations supplémentaires par rapport au schéma d'origine : `dislikedIngredients`
(m2m vers `Ingredient`, préférence de goût — voir plus bas), `authoredRecipes`,
`favoriteRecipes`, `administeredHouses` (le foyer dont ce profil est admin, au
plus un dans les faits), `preferences` (1-1 vers `UserPreference`).
### `house` (`House`)
| Champ | Description |
|---|---|
| `id` | Identifiant |
| `name` | Nom du foyer |
| `adminId` | FK → `user_profiles` — le membre qui administre ce foyer (créateur, ou héritier d'adminship si l'admin précédent est parti — voir [backend-architecture.md](./backend-architecture.md#house--foyer-adminship-code-dinvitation-sources-activées)) |
| `inviteCode` | Code à 8 caractères, unique, généré pour rejoindre le foyer (`POST /house/join`) |
### `user_preference` (`UserPreference`)
1-1 avec `user_profiles` (`userProfileId` est à la fois clé primaire et clé
étrangère — un profil a au plus une ligne). `theme` (`LIGHT` / `DARK` / `SYSTEM`,
défaut `SYSTEM`) — préférence d'affichage, créée à la demande (pas au signup),
même logique "absence = valeur par défaut" que `dietId`/allergies. Voir
[frontend-architecture.md](./frontend-architecture.md#thème-clairsombresystème).
### `diet` (`Diet`)
| Champ | Description |
|---|---|
| `id` | Identifiant |
| `key` | Slug anglais stable, unique (ex. `"vegetarian"`) — le libellé affiché vit dans `apps/web/src/locales/fr/translation.json` sous `catalog.diets.<key>`, jamais dans cette table |
### `category` / `allergy` (`Category`, `Allergy`)
Un allergène = une `Category` (le nom réel, `key` unique) + une `Allergy`
(juste un id + FK vers sa catégorie). `Category.kind` (`ALLERGY` | `INTOLERANCE`)
distingue réaction immunitaire classique de réaction non-immunitaire (seuls
Gluten et Sulfites sont `INTOLERANCE`). 14 allergènes seedés (règlement UE
1169/2011, annexe II).
---
## Planification
### `planning` (`Planning`)
| Champ | Description |
|---|---|
| `id` | Identifiant |
| `startDate` / `finishDate` | Lundi → dimanche de la semaine couverte |
| `houseId` | FK → `house` |
Une ligne par semaine et par foyer, créée à la demande (`findOrCreatePlanningForWeek`,
voir [backend-architecture.md](./backend-architecture.md#planning-semaine-item-portions-import-à-la-volée)),
pas en avance.
### `planning_item` (`PlanningItem`)
| Champ | Description |
|---|---|
| `id` | Identifiant |
| `planningId` | FK → `planning` |
| `weekDay` | Jour de la semaine |
| `meal` | Repas concerné |
| `recipeId` | FK → `recipe` (pas de cascade — voir `RECIPE_IN_USE` dans [error-handling.md](./error-handling.md)) |
| `portions` | Portions réellement **préparées** pour ce créneau — toujours un multiple entier de `Recipe.portions` (on cuisine des lots entiers). Dérivé de `portionsRequested` + le rendement de la recette via `computeBatchedPortions` (`@batch-cooking/shared`) dans `addPlanningItem`, jamais saisi tel quel |
| `portionsRequested` | Portions que ce créneau doit réellement nourrir — le seul nombre saisi à l'ajout. `≤ portions` pour un item frais ; l'écart est le **pool de restes** de cet item |
| `sourcePlanningItemId` | Auto-FK, non nul uniquement sur un item « restes » : l'item d'origine dont il consomme le pool. `onDelete: Cascade` |
| `discardedPortions` | Portions du pool de cet item explicitement jetées (bouton « jeter » du panier). `0` par défaut, toujours `0` sur un item « restes » |
---
## Recettes
### `sources` (`Source`) et `house_source` (`HouseSource`)
`Source` est le catalogue des sources d'import concrètes (un site/une API par
ligne), synchronisé automatiquement depuis le registre d'adaptateurs en code
(`recipe-source-registry.ts`) plutôt que maintenu à la main — voir
[backend-architecture.md](./backend-architecture.md#sources-externes--adaptateur-registre-synchronisation).
| Champ | Description |
|---|---|
| `id` | Identifiant |
| `key` | Slug stable, unique — doit correspondre à `RecipeSourceAdapter.key` |
| `name` | Nom affiché |
| `url` | Optionnel |
| `official` | API officielle vs scraping non-officiel |
| `iconUrl` | Logo/favicon, optionnel |
`HouseSource` (`houseId`, `sourceId`, clé composite) : quelles sources un foyer
a choisi d'activer — opt-in, aucune ligne = désactivé. Un nouveau foyer démarre
sans aucune source activée.
### `recipe` (`Recipe`)
| Champ | Description |
|---|---|
| `id` | Identifiant |
| `name` | Nom de la recette |
| `sourceId` | FK → `sources`, nullable (`null` = recette créée à la main) |
| `externalId` | Identifiant côté source, nullable — avec `sourceId`, `@@unique([sourceId, externalId])` empêche d'importer deux fois le même item (Postgres traite chaque `NULL` comme distinct, donc les recettes manuelles ne se percutent jamais entre elles) |
| `description` / `picture` | Optionnels |
| `portions` | Rendement "tel qu'écrit" par la recette |
| `authorId` | FK → `user_profiles` — requis dès qu'une recette porte un niveau de visibilité |
| `authorHouseId` | FK → `house`, nullable — **instantané** du foyer de l'auteur *au moment de la création* (comme `Planning.houseId`), ne suit pas l'auteur s'il change de foyer ensuite |
| `visibility` | `PERSONAL` (auteur seul) / `HOUSE` (membres de `authorHouseId`) / `PUBLIC` (tout utilisateur connecté) — contrôle uniquement la **lecture**, jamais l'édition (toujours réservée à l'auteur, voir `NOT_RECIPE_AUTHOR`) |
Relations : `ingredients` (`RecipeIngredient`, m2m avec quantité/unité),
`steps` (`Step`, one-to-many — pas many-to-many comme documenté dans le doc
spec d'origine : `order` n'a de sens que dans le cadre d'une seule recette),
`favoritedBy` (`RecipeFavorite`), `diets` (`RecipeDiet`, tags manuels, pas
calculés depuis les ingrédients).
### `ingredients` (`Ingredient`) et catalogue associé
Table de référence (seedée, jamais créée/éditée/supprimée via l'API), comme
`Diet`/`Allergy` — **sauf** les lignes *placeholder* (voir `isPlaceholder`
ci-dessous), seules lignes de cette table jamais issues du seed.
| Champ | Description |
|---|---|
| `id` | Identifiant |
| `key` | Slug unique — libellé dans `catalog.ingredients.<key>` (locale) ; pour un placeholder, `placeholder:<uuid>` (jamais de libellé i18n) |
| `icon` | `IngredientIcon` — ~20 pictogrammes génériques par *type* de chose (légume, bouteille d'huile, fromage…), pas un emoji par ingrédient (437 rejetés comme peu pro) — voir `apps/web/src/features/recipes/ingredients/ingredient-icons.tsx` |
| `category` / `subcategory` | `IngredientCategory` (7 rayons) / `IngredientSubcategory` (racks plus fins) — organisation "rayon de supermarché français" pour permettre le parcours par catégorie dans le picker (400+ ingrédients, la recherche seule ne suffit pas) |
| `reproducible` | Vrai si raisonnablement faisable maison (un pain burger, une béchamel) plutôt qu'un achat systématique — juste un flag, pas un lien vers une recette précise (un ancien `alternateRecipeId` jamais câblé a été retiré) |
| `isPlaceholder` / `displayName` / `createdById` / `createdAt` / `reviewedAt` | **Ingrédient hors-catalogue** : quand le catalogue ne couvre pas un ingrédient, l'utilisateur peut saisir un texte libre sur une ligne de recette (`placeholderName` dans le payload `POST/PATCH /recipes`) — l'API crée alors une ligne `Ingredient` `isPlaceholder=true`, `displayName` = le texte, `createdById`/`createdAt` renseignés. `RecipeIngredient` la référence comme n'importe quel ingrédient, mais `GET /reference/ingredients` et `ingredient-matcher.ts` l'excluent (jamais parcourable/matchable). `reviewedAt` est posé quand un admin a traité le manque via `/admin/api/catalog/*`. La promotion en vraie entrée catalogue (édition de `reference-seed-data.ts` + locales) reste **manuelle**. |
`IngredientDiet` (m2m, régimes compatibles — omet volontairement `Omnivore`
et `Sans gluten`, ce dernier dérivable de `IngredientAllergy`) et
`IngredientAllergy` (m2m, allergènes contenus) complètent le catalogue.
`UserProfileDislikedIngredient` (m2m profil ↔ ingrédient) est une préférence
de **goût personnelle**, pas médicale — jamais un avertissement de sécurité,
juste un rappel sur la fiche recette (`RecipeDetailPanel`).
### `unit` (`Unit`)
Catalogue normalisé et fini des unités de mesure — remplace l'ancien texte
libre (`"g"`, `"grammes"`, `"G"`…) qui ne pouvait jamais être sommé de façon
fiable.
| Champ | Description |
|---|---|
| `id` | Identifiant |
| `key` | Slug unique (ex. `"tablespoon"`) — libellé dans `catalog.units.<key>` |
| `type` | `MASS` / `VOLUME` / `COUNT` — seules deux unités du même type sont mutuellement convertibles |
| `toBaseFactor` | Combien d'unités de base (gramme pour MASS, millilitre pour VOLUME, elle-même pour COUNT) équivaut une unité de ce type — pose les bases d'une future conversion (ex. sommer "500g" + "0.5kg"), pas encore construite |
### `step` (`Step`) et techniques détectées
| Champ | Description |
|---|---|
| `id` | Identifiant |
| `recipeId` | FK → `recipe` |
| `description` | Texte de l'étape |
| `picture` | Optionnel |
| `order` | Position dans la recette |
`tech_step` (`TechStep`, `key` unique, ex. `"simmer"`) est le catalogue des
techniques (mijoter, préchauffer…) — juste un id/clé stable référencé par
`step_tech_step`. Les données de détection elles-mêmes (synonymes + phrases
d'exemple par langue) vivent en code dans le microservice spaCy lui-même
(`services/tech-step-intent-service/intent_service/training_data.py`), pas
dans une table ni côté `apps/api` — l'ancienne
`tech_step_mapping` (`TechStepMapping`, une regex par technique/locale) a
été supprimée une fois constaté que les regex ne généralisaient jamais
au-delà de leur propre vocabulaire — voir
[backend-architecture.md](./backend-architecture.md#détection-des-techniques--tech-step-matcherts).
`step_tech_step` (`StepTechStep`) est la **séquence ordonnée** des techniques
détectées pour une étape — une instruction peut en impliquer plusieurs (ex.
"faire chauffer une noix de beurre" = `preheat` + `melt`), d'où une table de
liaison avec `order` plutôt qu'un simple FK nullable `Step.techStepId`
(remplacé suite à une revue de code). `start`/`end` (nullable, non rétro-remplis
— une ligne d'avant l'ajout de ces colonnes n'a simplement pas de
surlignage tant que sa recette n'est pas resauvegardée) sont le span détecté
dans `Step.description`, utilisé pour le surlignage côté web
(`highlight-tech-steps.ts`).
Chaque `step_tech_step` porte en plus les métadonnées trouvées dans sa propre
clause : `step_tech_step_ingredient` (ingrédient résolu contre le catalogue
`ingredients` existant, `quantity`/`unit_id` optionnels quand une quantité a
pu être extraite juste avant la mention), `step_tech_step_utensil`
(ustensile résolu contre un nouveau catalogue `utensil`, même forme
minimale `id`/`key` que `tech_step`), et `step_tech_step_temperature`
(température de la clause — `celsius`/`gas_mark`/`qualitative`
mutuellement exclusifs, un seul non-null par ligne ; `raw` conserve le
texte exact matché, ex. `"180°C"`/`"th. 6"`/`"feu doux"` ; `qualitative`
vaut `"low"`/`"medium"`/`"high"`, contrainte au niveau TypeScript
seulement — pas d'enum Postgres ici, contrairement à `AllergenKind`/
`ThemePreference`/`UnitType` ailleurs dans ce schéma). Les trois référencent
`step_tech_step` par sa clé composite `(step_id, order)`, `onDelete:
Cascade` comme le reste de cette chaîne — voir
[backend-architecture.md](./backend-architecture.md#détection-des-techniques--tech-step-matcherts)
pour comment chacune est détectée.
`source` (`"auto"` | `"manual"`, même contrainte TypeScript-seule que
`qualitative` ci-dessus) marque, sur `step_tech_step` et ses trois tables de
métadonnées, si la ligne vient du classifieur ou d'une correction manuelle
soumise par un utilisateur (`POST /recipes/:id/steps/:stepId/corrections`,
voir [backend-architecture.md](./backend-architecture.md#recipe)) — cette
route remplace intégralement les trois tables de métadonnées d'une
occurrence dès que l'une d'elles est touchée ("le manuel remplace tout"),
jamais un ajout partiel.
---
## Contact
### `contact_message` (`ContactMessage`)
Message envoyé via le formulaire de contact de l'app (issue #80) : un
utilisateur **connecté** signale un bug, propose une amélioration
(`type` = `"bug" | "suggestion" | "other"`, `String` adossé à la const
partagée `CONTACT_MESSAGE_TYPES`, pas d'enum Postgres) ou écrit un
message libre. Écrit une fois, jamais modifié ensuite ; consulté en
**lecture seule** côté admin (`GET /admin/api/contact`, voir
[backend-architecture.md](./backend-architecture.md)).
- `authorId` → `user_profiles`, **nullable** avec `onDelete: SetNull`
(même choix que `ingredients.createdById`) : un signalement garde son
intérêt même si le compte qui l'a envoyé est supprimé.
- `pageUrl` / `userAgent` / `appVersion` : contexte technique capturé à
l'envoi, tous nullables. `userAgent` est lu de l'en-tête de requête côté
API, jamais du corps.
- `forgejoIssueNumber` / `forgejoIssueUrl` (issue #81) : nullables, posés
ensemble quand un admin crée une issue Forgejo depuis le message
(`POST /admin/api/contact/:id/forgejo-issue`). Non-null ⇒ le bouton
« Créer un ticket » disparaît (un message ⇒ au plus une issue).
`forgejoIssueUrl` est l'URL html stockée telle quelle.
- `@@index([createdAt])` — la vue admin trie par date décroissante.
---
## Relations
### Many-to-one (clés étrangères)
| Table source | Champ FK | Table cible |
|---|---|---|
| `user_profiles` | `houseId` | `house` |
| `user_profiles` | `dietId` | `diet` |
| `house` | `adminId` | `user_profiles` |
| `planning` | `houseId` | `house` |
| `planning_item` | `planningId` | `planning` |
| `planning_item` | `recipeId` | `recipe` |
| `allergy` | `categoryId` | `category` |
| `recipe` | `sourceId` | `sources` |
| `recipe` | `authorId` | `user_profiles` |
| `recipe` | `authorHouseId` | `house` |
| `step` | `recipeId` | `recipe` |
| `contact_message` | `authorId` | `user_profiles` (nullable, `SET NULL`) |
### Many-to-many (tables de jointure explicites, avec ou sans champ additionnel)
| Table A | Table B | Table de jointure | Détail |
|---|---|---|---|
| `user_profiles` | `allergy` | `user_profile_allergy` | Simple |
| `user_profiles` | `ingredients` | `user_profile_disliked_ingredient` | Simple — préférence de goût |
| `user_profiles` | `recipe` | `recipe_favorite` | Simple |
| `house` | `sources` | `house_source` | Simple — opt-in |
| `recipe` | `ingredients` | `recipe_ingredient` | Porte `quantity` + `unitId` |
| `recipe` | `diet` | `recipe_diet` | Simple — tags manuels |
| `ingredients` | `diet` | `ingredient_diet` | Simple |
| `ingredients` | `allergy` | `ingredient_allergy` | Simple |
| `step` | `tech_step` | `step_tech_step` | Porte `order` + `start`/`end` (span détecté) |
---
## Règles de modélisation
- Toute relation qualifiée d'**« association »** entre deux tables est une relation **many-to-many**.
- `category`, `diet`, `ingredients`, `unit`, `tech_step`, `sources` sont des tables de
référence/énumération : seedées (`apps/api/src/db/reference-seed-data.ts`),
jamais créées/éditées/supprimées via l'API applicative. `key`/`name` y sont
`@unique` pour permettre un seed idempotent (`upsert`).
- Chaque écart avec le document de conception d'origine (colonnes ajoutées,
cardinalité changée, table nouvelle) est expliqué en commentaire directement
dans `schema.prisma`, à l'endroit concerné — s'y référer en cas de doute,
ce document est un résumé, pas la source de vérité.