# 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`, `TechStepMapping`, `StepTechStep`, `RecipeDiet`, `RecipeFavorite` - **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 } 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.`, 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` | Nombre de portions **pour ce créneau précis** — indépendant de `Recipe.portions` (le rendement "tel qu'écrit" de la recette) : un créneau peut mettre l'échelle à la hausse/baisse. Le picker web pré-remplit depuis `Recipe.portions` mais stocke une valeur propre | --- ## 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`. | Champ | Description | |---|---| | `id` | Identifiant | | `key` | Slug unique — libellé dans `catalog.ingredients.` (locale) | | `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é) | `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.` | | `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…). `tech_step_mapping` (`TechStepMapping`) porte les règles de détection : `expression` (regex testée contre la description), `weight` (départage en cas de règles concurrentes), `locale` (une même technique peut avoir un jeu de règles par langue — 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`). --- ## 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` | | `tech_step_mapping` | `techStepId` | `tech_step` | ### 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é.