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 — coarse grouping (viandes, légumes, /// épices...) 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. 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, this enum just gives it a type-safe column to live in. /// `@default(EPICERIE_DIVERS)` 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 { CEREALES_FECULENTS LEGUMINEUSES VIANDES_VOLAILLES POISSONS_FRUITS_DE_MER PRODUITS_LAITIERS_OEUFS LEGUMES FRUITS FRUITS_SECS_OLEAGINEUX CONDIMENTS_SAUCES EPICES_HERBES SUCRE_PATISSERIE CUISINE_ITALIENNE CUISINE_ASIATIQUE CUISINE_MEXICAINE MAGHREB_MOYEN_ORIENT PAINS_SANDWICHS EPICERIE_DIVERS LIQUIDES_BOISSONS } model Ingredient { id Int @id @default(autoincrement()) name String @unique icon String? category IngredientCategory @default(EPICERIE_DIVERS) 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") }