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") } /// `key` is `@unique` — not in the original spec doc, added so the seed /// script (prisma/seed.ts) can `upsert` by key and stay idempotent/safe to /// re-run, and so two reference rows can never silently duplicate the same /// regime. A stable English camelCase uid (e.g. `"vegetarian"`), not the /// display label — the label itself lives in `apps/web`'s /// `locales/fr/translation.json` under `catalog.diets.` (see /// `reference-seed-data.ts`'s `DIETS`), so it can be edited/translated /// without ever touching this column or the rows that reference it by id. model Diet { id Int @id @default(autoincrement()) key 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). /// `key` is `@unique` for the same reason as `Diet.key` above — a stable /// slug (`catalog.allergens.` in `apps/web`'s locale file), not the /// display label. `kind` is also not in the original spec doc — see /// {@link AllergenKind}. model Category { id Int @id @default(autoincrement()) key 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") portions Int 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? /// How many portions this recipe yields as written (its ingredient /// quantities/steps assume this count) — distinct from /// `PlanningItem.portions`, which is how many to actually prepare for one /// planning slot and now defaults to this value client-side but is still /// entered/stored independently (a planning slot may scale the recipe /// up/down). portions Int /// 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[] @@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") } /// `key` is `@unique` — not in the original spec doc, added so the seed /// script (reference-seed-data.ts) can `upsert` by key and stay /// idempotent/safe to re-run, same reason as `Diet.key`/`Category.key`. A /// stable slug (`catalog.ingredients.` in `apps/web`'s locale file), /// not the display label. /// 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(dryGoods)` 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 { /// 🥦 Vegetables, fruits, fresh herbs. freshProduce /// 🥩 Meats, poultry, fish, shellfish & seafood. meatAndSeafood /// 🥫 Starches, legumes, nuts & seeds, and the rest of the dry/tinned /// goods that don't fit any other bucket (dried seaweed, dried /// mushrooms…). dryGoods /// 🍞 Breads and raw dough (uncooked, ready to bake). bakery /// 🧈 Dairy, eggs, plant-based alternatives (plant milks, tofu…). dairyAndCheese /// 🧂 Spices, sauces, seasonings (oils, vinegars, cooking alcohols…). condimentsAndSpices /// 🍳 Prep bases (flours, stocks, water), thickeners (yeasts, starches, /// gelatin), sugars. cookingEssentials } /// 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(other)` — same NOT-NULL-migration-safety-net reasoning as /// `IngredientCategory`'s default, never the intended value for a real row. enum IngredientSubcategory { // --- freshProduce ---------------------------------------------------------- vegetables fruits freshHerbs // --- meatAndSeafood ---------------------------------------------------------- meats poultry fish shellfish // --- dryGoods ---------------------------------------------------------------- starches legumes nutsAndSeeds /// Catch-all for dried/tinned pantry items that don't fit the three /// subcategories above — dried seaweed, dried mushrooms, tinned bamboo /// shoots/water chestnuts… other // --- bakery -------------------------------------------------------------- breads /// Raw, uncooked doughs meant to be baked (puff pastry, shortcrust…) — /// distinct from `breads` (already-baked bread). rawDough // --- dairyAndCheese ------------------------------------------------------ dairy eggs /// Plant-based dairy/meat substitutes — coconut/almond/oat "milk", tofu. plantBasedAlternatives // --- condimentsAndSpices --------------------------------------------------- spices sauces /// Oils, vinegars, citrus juices, cooking alcohols/wines — liquids that /// season rather than form the base of a dish. seasonings // --- cookingEssentials ----------------------------------------------------- /// 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. thickeners sugars } /// Generic pictogram *type* for an ingredient — not in the original spec /// doc. Started as a free-text emoji column (one character per ingredient, /// 437 different ones), which the product decision recorded in chat /// rejected as unprofessional/inconsistent. Rather than 437 hand-drawn SVG /// icons (unrealistic), ingredients share a small vocabulary of ~20 /// generic shapes grouped by *what kind of thing* they are — a vegetable, /// a bottle of oil, a wedge of cheese — regardless of which specific /// ingredient. `apps/web`'s `features/recipes/ingredient-icons.tsx` maps /// each value to its actual SVG (matching the app's hand-drawn line-icon /// style, never emoji — see that file for the full reasoning and the /// exact `reference-seed-data.ts` assignment per ingredient). /// `@default(JAR)` — same NOT-NULL-migration-safety-net reasoning as /// `IngredientCategory`'s default, never the intended value for a real row. enum IngredientIcon { VEGETABLE FRUIT HERB MEAT POULTRY FISH SHELLFISH GRAIN LEGUME NUT_SEED BREAD DOUGH MILK CHEESE EGG SPROUT SPICE JAR BOTTLE DRINK STOCK_POT SUGAR } model Ingredient { id Int @id @default(autoincrement()) key String @unique icon IngredientIcon @default(JAR) category IngredientCategory @default(dryGoods) subcategory IngredientSubcategory @default(other) /// Whether this ingredient is reasonably makeable at home (a burger bun, /// a béchamel) rather than something you'd only ever buy (a raw /// vegetable, a specific cut of meat) — surfaced in the recipe form as a /// badge/link nudging the author to go check the recipe catalog for a /// "make it yourself" recipe (see `apps/web`'s `IngredientRow`/ /// `IngredientPicker`). Deliberately just a flag, not a link to a /// specific recipe — replaces an earlier, never-wired-up /// `alternateRecipeId` FK (product decision discussed in chat: no /// ingredient↔recipe linking in the database, the UI only pre-fills the /// catalog's own search with this ingredient's name). reproducible Boolean @default(false) 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") } /// Which physical quantity a {@link Unit} measures — only units of the same /// type are ever mutually convertible via `toBaseFactor` (grams and /// kilograms both measure MASS; a "pincée" and a "gousse" are both COUNT /// but converting between *them* would need per-ingredient data no catalog /// entry alone can provide, so COUNT units just don't convert to each /// other, each stands alone with `toBaseFactor = 1`). enum UnitType { MASS VOLUME COUNT } /// `key` is `@unique` — same idempotent-seed/no-duplicate reasoning as /// `Diet.key`. A stable English camelCase uid (e.g. `"tablespoon"`), not the /// display label — the label lives in `apps/web`'s /// `locales/fr/translation.json` under `catalog.units.` (see /// `reference-seed-data.ts`'s `UNITS`). /// /// Not in the original spec doc — `RecipeIngredient.unit` used to be free /// text ("g", "grammes", "G"…), which can never be reliably summed/converted /// (a future shopping list can't tell "g" and "grammes" are the same unit). /// This closes that off: `unit` is now a normalized, finite catalog. /// `toBaseFactor` is how many of this type's base unit (gram for MASS, /// milliliter for VOLUME, itself for COUNT) one of this unit equals — /// laying the groundwork for a future conversion feature (e.g. summing /// "500g" + "0.5kg" of the same ingredient into "1kg") without building /// that feature itself yet. model Unit { id Int @id @default(autoincrement()) key String @unique type UnitType toBaseFactor Decimal @default(1) @db.Decimal(12, 4) @map("to_base_factor") recipeIngredients RecipeIngredient[] @@map("unit") } /// 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) unitId Int @map("unit_id") recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade) ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade) unit Unit @relation(fields: [unitId], references: [id]) @@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") }