generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } // ----------------------------------------------------------------------------- // Users & household // See specs/batch-cooking-modele.md for the source data model documentation. // ----------------------------------------------------------------------------- model House { id Int @id @default(autoincrement()) name String /// The member who administers this household — created it, or inherited /// adminship when the previous admin left/deleted their account (see /// `house.service.ts`'s `leaveCurrentHouse`). Always set: a house is /// deleted outright once it would otherwise have no admin left. adminId Int @map("admin_id") /// Shareable code another user enters via `POST /house/join` to become a /// member — see `house.service.ts`'s generator for the charset/length. inviteCode String @unique @map("invite_code") admin UserProfile @relation("HouseAdmin", fields: [adminId], references: [id]) members UserProfile[] @relation("HouseMember") plannings Planning[] /// Recipes whose author belonged to this household when they created /// them — see `Recipe.authorHouseId`. authoredRecipes Recipe[] @@map("house") } /// `name` is `@unique` — not in the original spec doc, added so the seed /// script (prisma/seed.ts) can `upsert` by name and stay idempotent/safe to /// re-run, and so two reference rows can never silently duplicate the same /// regime. model Diet { id Int @id @default(autoincrement()) name String @unique users UserProfile[] recipes RecipeDiet[] ingredients IngredientDiet[] @@map("diet") } /// Not in the original spec doc — a category is either a true (IgE-mediated) /// allergy or a non-immune intolerance; the UI groups selectable allergens /// into two separate lists (`AllergySelect`, apps/web) instead of one flat /// "allergies & intolérances" list. enum AllergenKind { ALLERGY INTOLERANCE } /// Enumeration-style table, meant to grow over time (e.g. allergy nuances). /// `name` is `@unique` for the same reason as `Diet.name` above. `kind` is /// also not in the original spec doc — see {@link AllergenKind}. model Category { id Int @id @default(autoincrement()) name String @unique kind AllergenKind @default(ALLERGY) allergies Allergy[] @@map("category") } model Allergy { id Int @id @default(autoincrement()) categoryId Int @map("cat_id") category Category @relation(fields: [categoryId], references: [id]) users UserProfileAllergy[] ingredients IngredientAllergy[] @@map("allergy") } model UserProfile { id Int @id @default(autoincrement()) firstName String @map("first_name") lastName String @map("last_name") email String @unique /// argon2 hash of the account password. Not in the original spec doc — /// added for authentication (login page / profile creation). passwordHash String @map("password_hash") /// Bumped to invalidate previously-issued JWTs (e.g. on password change). /// Not in the original spec doc — required for stateless JWT auth. tokenVersion Int @default(0) @map("token_version") houseId Int? @map("house_id") dietId Int? @map("diet_id") house House? @relation("HouseMember", fields: [houseId], references: [id], onDelete: SetNull) diet Diet? @relation(fields: [dietId], references: [id], onDelete: SetNull) allergies UserProfileAllergy[] /// Ingredients this profile personally dislikes — a taste preference, not /// a medical constraint (see {@link UserProfileDislikedIngredient} and /// `allergies` above for the distinct medical list). dislikedIngredients UserProfileDislikedIngredient[] /// Recipes authored by this profile — see `Recipe.authorId`. authoredRecipes Recipe[] /// Recipes this profile has favorited — see {@link RecipeFavorite}. favoriteRecipes RecipeFavorite[] /// Households this profile administers. In practice at most one — a /// profile can only ever belong to (and thus admin) a single household at /// a time — but Prisma models the admin side of a one-to-many FK as a /// list regardless of that real-world cardinality. administeredHouses House[] @relation("HouseAdmin") preferences UserPreference? @@map("user_profiles") } /// Explicit join table for the user_profiles <-> ingredient "disliked" /// association — same shape as `UserProfileAllergy`, but a personal taste /// preference rather than a medical restriction: not surfaced as a safety /// warning, just a reminder on a recipe's detail view (see /// `RecipeView`/`RecipeDetailPanel`, apps/web). model UserProfileDislikedIngredient { userProfileId Int @map("user_profile_id") ingredientId Int @map("ingredient_id") userProfile UserProfile @relation(fields: [userProfileId], references: [id], onDelete: Cascade) ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade) @@id([userProfileId, ingredientId]) @@map("user_profile_disliked_ingredient") } /// Not in the original spec doc — personalization settings (theme for now, /// meant to grow), one row per profile, created on demand (see /// `preferences.service.ts`) rather than at signup — same "absent means the /// default" philosophy as `dietId`/allergies. enum ThemePreference { LIGHT DARK /// Follow the OS/browser preference — the default. Not "no row yet" (that /// case is handled in the service layer) but an explicit choice to track /// the system, distinguishable from a user who hasn't decided yet if this /// model ever needs that distinction. SYSTEM } model UserPreference { /// Both the primary key and the FK — a strict 1-1 with UserProfile, no /// separate auto-incrementing id (a profile has at most one preferences row). userProfileId Int @id @map("user_profile_id") theme ThemePreference @default(SYSTEM) userProfile UserProfile @relation(fields: [userProfileId], references: [id], onDelete: Cascade) @@map("user_preference") } /// Explicit join table for the user_profiles <-> allergy association /// (documented in the spec as a plain many-to-many, no extra fields). model UserProfileAllergy { userProfileId Int @map("user_profile_id") allergyId Int @map("allergy_id") userProfile UserProfile @relation(fields: [userProfileId], references: [id], onDelete: Cascade) allergy Allergy @relation(fields: [allergyId], references: [id], onDelete: Cascade) @@id([userProfileId, allergyId]) @@map("user_profile_allergy") } // ----------------------------------------------------------------------------- // Planning // ----------------------------------------------------------------------------- model Planning { id Int @id @default(autoincrement()) startDate DateTime @map("start_date") @db.Date finishDate DateTime @map("finish_date") @db.Date houseId Int @map("house_id") house House @relation(fields: [houseId], references: [id], onDelete: Cascade) items PlanningItem[] @@map("planning") } model PlanningItem { id Int @id @default(autoincrement()) planningId Int @map("planning_id") weekDay String @map("week_day") meal String recipeId Int @map("recipe_id") planning Planning @relation(fields: [planningId], references: [id], onDelete: Cascade) recipe Recipe @relation(fields: [recipeId], references: [id]) @@map("planning_item") } // ----------------------------------------------------------------------------- // Recipes // ----------------------------------------------------------------------------- model Source { id Int @id @default(autoincrement()) name String url String? recipes Recipe[] @@map("sources") } /// Not in the original spec doc — who can *read* a recipe. Controls only /// visibility, never editing: a recipe can only ever be edited/deleted by /// its `author`, whatever this is set to (see `recipe.service.ts`). enum RecipeVisibility { /// Visible to its author only. PERSONAL /// Visible to `authorHouseId`'s members (a snapshot of the author's /// household *at creation time* — see `Recipe.authorHouseId`). HOUSE /// Visible to every signed-in user — the "shared catalog" behavior the /// very first version of this feature shipped with. PUBLIC } model Recipe { id Int @id @default(autoincrement()) name String sourceId Int? @map("source_id") description String? picture String? /// Creator — not in the original spec doc, required once recipes carry a /// visibility level (`PERSONAL`/`HOUSE` need someone to scope against). authorId Int @map("author_id") /// The author's household *at the time this recipe was created* — a /// snapshot (same idea as `Planning.houseId`), not a live lookup: it /// doesn't follow the author if they later change household. `null` if /// the author had no household yet. authorHouseId Int? @map("author_house_id") visibility RecipeVisibility @default(PERSONAL) author UserProfile @relation(fields: [authorId], references: [id]) authorHouse House? @relation(fields: [authorHouseId], references: [id], onDelete: SetNull) source Source? @relation(fields: [sourceId], references: [id], onDelete: SetNull) ingredients RecipeIngredient[] steps Step[] planningItems PlanningItem[] favoritedBy RecipeFavorite[] diets RecipeDiet[] /// Ingredients for which this recipe is offered as a make-it-yourself alternative. alternateFor Ingredient[] @relation("IngredientAlternateRecipe") @@map("recipe") } /// Explicit join table for the user_profiles <-> recipe "favorited" /// association — same shape as `UserProfileAllergy`. Per-user, not /// per-household: two members of the same household can favorite different /// recipes independently. model RecipeFavorite { userProfileId Int @map("user_profile_id") recipeId Int @map("recipe_id") userProfile UserProfile @relation(fields: [userProfileId], references: [id], onDelete: Cascade) recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade) @@id([userProfileId, recipeId]) @@map("recipe_favorite") } /// Explicit join table for the recipe <-> diet "associated regime" tags /// (e.g. a recipe can be tagged both `Végétarien` and `Sans gluten`) — a /// manual reminder set by whoever creates/edits the recipe, not computed /// from its ingredients. model RecipeDiet { recipeId Int @map("recipe_id") dietId Int @map("diet_id") recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade) diet Diet @relation(fields: [dietId], references: [id], onDelete: Cascade) @@id([recipeId, dietId]) @@map("recipe_diet") } /// `name` is `@unique` — not in the original spec doc, added so the seed /// script (reference-seed-data.ts) can `upsert` by name and stay /// idempotent/safe to re-run, same reason as `Diet.name`/`Category.name`. /// Ingredients are reference data (like Diet/Allergy): seeded, never /// created/edited/deleted through the API. /// Not in the original spec doc — supermarket-aisle grouping ("rayons") so /// the ingredient picker (apps/web) can offer category browsing, not just /// free-text search: with 400+ reference ingredients, search alone doesn't /// scale to actually *finding* one. Reworked from an earlier, less /// intuitive scheme (cuisine-of-origin categories mixed in with aisle-style /// ones, e.g. a "cuisine italienne" bucket sitting next to "légumes" — /// meant an ingredient's category depended on which one you thought of /// first) into how a French grocery store is actually laid out: 7 aisles, /// each with a couple of {@link IngredientSubcategory} racks for finer /// browsing once "Épicerie sèche" alone would be 100+ items deep. Mirrors /// `reference-seed-data.ts`'s `INGREDIENT_GROUPS` keys exactly — that file /// is the single source of truth for which ingredient belongs to which /// (category, subcategory) pair, these enums just give it type-safe /// columns to live in. `@default(EPICERIE_SECHE)` exists only so this /// column can be added `NOT NULL` to a table that may already have rows — /// the seed script corrects every row's real category on the very next /// run, this default is never the intended value for a real ingredient. enum IngredientCategory { /// 🥦 Légumes, fruits, herbes fraîches. PRODUITS_FRAIS /// 🥩 Viandes, volailles, poissons, crustacés & fruits de mer. BOUCHERIE_POISSONNERIE /// 🥫 Féculents, légumineuses, graines & fruits secs, et le reste des /// produits secs/en conserve qui ne rentre dans aucune autre case /// (algues séchées, champignons séchés…). EPICERIE_SECHE /// 🍞 Pains et pâtes à cuire (crues, à enfourner). BOULANGERIE /// 🧈 Produits laitiers, œufs, alternatives végétales (laits végétaux, /// tofu…). CREMERIE_FROMAGE /// 🧂 Épices, sauces, assaisonnements (huiles, vinaigres, alcools de /// cuisine…). CONDIMENTS_EPICES /// 🍳 Bases de préparation (farines, bouillons, eau), épaississants /// (levures, fécules, gélatine), sucres. AIDES_CULINAIRES } /// Finer-grained rack within one {@link IngredientCategory} aisle — see /// that enum's doc comment for why this two-level scheme replaced a flat /// list. Each value belongs to exactly one category by construction (see /// `reference-seed-data.ts`'s `INGREDIENT_GROUPS`, not enforced at the /// database level — Postgres enums can't express that relationship, same /// tradeoff already accepted for `IngredientCategory` itself). /// `@default(AUTRES)` — same NOT-NULL-migration-safety-net reasoning as /// `IngredientCategory`'s default, never the intended value for a real row. enum IngredientSubcategory { // --- Produits frais ------------------------------------------------------ LEGUMES FRUITS HERBES_FRAICHES // --- Boucherie & poissonnerie --------------------------------------------- VIANDES VOLAILLES POISSONS CRUSTACES_FRUITS_DE_MER // --- Épicerie sèche -------------------------------------------------------- FECULENTS LEGUMINEUSES GRAINES_FRUITS_SECS /// Catch-all for dried/tinned pantry items that don't fit the three /// subcategories above — dried seaweed, dried mushrooms, tinned bamboo /// shoots/water chestnuts… AUTRES // --- Boulangerie ------------------------------------------------------- PAINS /// Raw, uncooked doughs meant to be baked (puff pastry, shortcrust…) — /// distinct from `PAINS` (already-baked bread). PATES_A_CUIRE // --- Crémerie & fromage -------------------------------------------------- PRODUITS_LAITIERS OEUFS /// Plant-based dairy/meat substitutes — coconut/almond/oat "milk", tofu. ALTERNATIVES // --- Condiments & épices ------------------------------------------------- EPICES SAUCES /// Oils, vinegars, citrus juices, cooking alcohols/wines — liquids that /// season rather than form the base of a dish. ASSAISONNEMENTS // --- Aides culinaires ---------------------------------------------------- /// Flours, stocks/broths, canned tomato bases, water — the literal base /// a recipe is built on. BASES /// Leavening/gelling/thickening agents — yeast, baking soda, cornstarch, /// gelatin. EPAISSISSANTS SUCRES } model Ingredient { id Int @id @default(autoincrement()) name String @unique icon String? category IngredientCategory @default(EPICERIE_SECHE) subcategory IngredientSubcategory @default(AUTRES) alternateRecipeId Int? @map("alternate_recipe") alternateRecipe Recipe? @relation("IngredientAlternateRecipe", fields: [alternateRecipeId], references: [id], onDelete: SetNull) recipes RecipeIngredient[] allergies IngredientAllergy[] /// Profiles that personally dislike this ingredient — see {@link UserProfileDislikedIngredient}. dislikedBy UserProfileDislikedIngredient[] /// Diet regimes this ingredient is compatible with — see {@link IngredientDiet}. diets IngredientDiet[] @@map("ingredients") } /// Explicit join table for the ingredients <-> diet regime association — /// which regimes (Végétarien, Végan, Pescétarien…) this ingredient is safe /// for, so the picker (apps/web's `IngredientPicker`/`IngredientRow`) can /// flag e.g. an ingredient as vegan without the user having to open its /// packaging. Seeded by category in `reference-seed-data.ts` (most /// ingredients in a category share the same compatible regimes, with /// per-item overrides for exceptions — meat cuts, dairy, seafood…), same as /// `IngredientAllergy`. Deliberately omits `Omnivore` (every ingredient is /// trivially compatible — storing it would be pure noise) and `Sans gluten` /// (already fully derivable from whether `IngredientAllergy` links this /// ingredient to the `Gluten` allergen — a second, hand-maintained source /// for the same fact would only risk drifting out of sync with it). model IngredientDiet { ingredientId Int @map("ingredient_id") dietId Int @map("diet_id") ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade) diet Diet @relation(fields: [dietId], references: [id], onDelete: Cascade) @@id([ingredientId, dietId]) @@map("ingredient_diet") } /// Explicit join table for the ingredients <-> allergy association — not in /// the original spec doc, added so the recipe catalog can surface which /// allergens an ingredient (and by extension a recipe) carries. Same shape /// as `UserProfileAllergy`. model IngredientAllergy { ingredientId Int @map("ingredient_id") allergyId Int @map("allergy_id") ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade) allergy Allergy @relation(fields: [allergyId], references: [id], onDelete: Cascade) @@id([ingredientId, allergyId]) @@map("ingredient_allergy") } /// recipe <-> ingredients association. The spec documents this as a plain /// many-to-many, but a shopping list / batch-cooking calculation needs a /// quantity per recipe, so this join table carries quantity + unit /// (project decision, not in the original spec doc). model RecipeIngredient { recipeId Int @map("recipe_id") ingredientId Int @map("ingredient_id") quantity Decimal @db.Decimal(10, 2) unit String recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade) ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade) @@id([recipeId, ingredientId]) @@map("recipe_ingredient") } model TechStep { id Int @id @default(autoincrement()) steps Step[] mappings TechStepMapping[] @@map("tech_step") } /// Used by the recipe-import pipeline to auto-detect which technique a raw /// instruction step corresponds to (expression = text pattern, weight = match score). model TechStepMapping { id Int @id @default(autoincrement()) techStepId Int @map("tech_step_id") expression String weight Int techStep TechStep @relation(fields: [techStepId], references: [id], onDelete: Cascade) @@map("tech_step_mapping") } /// Modeled as one-to-many (a step belongs to exactly one recipe), not the /// many-to-many noted in the spec doc: `order` only makes sense scoped to a /// single recipe, which isn't reconcilable with steps being shared across /// recipes. See specs/batch-cooking-modele.md for the original wording. model Step { id Int @id @default(autoincrement()) recipeId Int @map("recipe_id") description String picture String? order Int techStepId Int? @map("tech_step_id") recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade) techStep TechStep? @relation(fields: [techStepId], references: [id], onDelete: SetNull) @@map("step") }