batchCooking/apps/api/prisma/schema.prisma
Nicolas 77b55d7aeb refactor(catalog): authoring 100% anglais — uid camelCase, plus de français dans le code
Le catalogue de référence (diets/allergènes/ingrédients) était écrit en
français dans reference-seed-data.ts, avec une table de correspondance
séparée (catalog-en-keys.ts, 666 lignes, ~563 entrées) traduisant chaque
libellé français vers une clé anglaise snake_case, elle-même utilisée pour
peupler la colonne `key` en base et régénérer translation.json. Décision :
remplacer par un authoring 100% anglais camelCase directement dans le seed
— plus de détour, plus de table de correspondance.

- `Ingredient.name`/`allergenNames`/`dietNames` → `uid`/`allergenUids`/
  `dietUids`, valeurs en camelCase directement (ex: "Tomate" → "tomato",
  "Fruits à coque" → "treeNuts").
- `IngredientCategory`/`IngredientSubcategory` (enums Prisma) renommés du
  français SCREAMING_SNAKE_CASE (`PRODUITS_FRAIS`, `LEGUMES`...) vers
  l'anglais camelCase (`freshProduce`, `vegetables`...) — même mécanique
  de migration que le renommage d'enum précédent
  (20260818113250_ingredient_taxonomy_rework) : nouvelle colonne avec
  valeur par défaut sûre, jamais de cast direct (aucune valeur commune
  entre ancien et nouvel enum), seedReferenceData() corrige chaque ligne
  au démarrage suivant.
- Migration `20260819180000_catalog_camel_case_uids` : renomme les clés
  existantes (diet/category/ingredients, même mécanique que
  20260818193000_catalog_keys_to_english) + swap des deux enums. Un cas
  particulier corrigé à la main : "sesame_seeds" était à la fois la clé
  d'un allergène (Category) et d'un ingrédient qui se référence lui-même
  ("Graines de sésame") — les deux tables ont besoin de leur propre
  UPDATE.
- `catalog-en-keys.ts`, `slugify.ts`, `generate-catalog-i18n.ts`,
  `validate-catalog-en-keys.ts` — supprimés (plus de raison d'être).
  Conséquence assumée : `translation.json` n'est plus régénéré
  automatiquement, c'est désormais la seule source du texte FR, tenue à
  jour à la main en parallèle du uid (même clé qui les relie).
- `packages/shared/src/types/reference.ts`, `apps/web`'s
  `ingredient-icons.tsx` (CATEGORY_ICON/SUBCATEGORY_ICON), fixtures
  Cypress codées en dur — mis à jour avec les nouveaux noms.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 20:40:55 +02:00

551 lines
22 KiB
Text

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.<key>` (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.<key>` 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?
/// 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.<key>` 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")
}
/// 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")
}