Rend opérationnel le squelette TechStep/TechStepMapping/Step.techStepId présent dans le schéma depuis le premier commit mais jamais implémenté : - TechStep gagne un `key` unique (camelCase, même convention que Diet/Unit) ; TechStepMapping gagne un `locale` pour pouvoir porter plusieurs jeux de règles de matching par langue. - Catalogue statique de 25 techniques françaises courantes (Cuire, Frire, Déglacer, Mijoter, ...), chacune associée à une ou plusieurs expressions régulières + un poids, seedées de façon idempotente dans reference-seed-data.ts. - Nouveau moteur de matching (apps/api/src/lib/tech-step-matcher.ts) : normalisation accents/casse (NFD) puis test des expressions, résolution du meilleur match par poids. Pur et testé unitairement. - Câblé dans recipe.service.ts : à la création/modification d'une recette, chaque étape voit son techStepId calculé automatiquement à partir de sa description (locale "fr" en dur pour l'instant, faute de préférence de langue utilisateur dans l'app). - Reste backend-only : StepView n'expose pas encore techStepId, conformément au commentaire existant. - Endpoint GET /reference/tech-steps + TechStepView, en cohérence avec les autres catalogues de référence. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
607 lines
25 KiB
Text
607 lines
25 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?
|
|
/// 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.<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")
|
|
}
|
|
|
|
/// 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.<key>` (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")
|
|
}
|
|
|
|
/// `key` is `@unique` — same convention as `Diet`/`Unit`: a stable English
|
|
/// camelCase uid (e.g. `"simmer"`), not the display label — the French
|
|
/// label lives in `apps/web`'s `locales/fr/translation.json` under
|
|
/// `catalog.techSteps.<key>` (see `reference-seed-data.ts`'s `TECH_STEPS`).
|
|
model TechStep {
|
|
id Int @id @default(autoincrement())
|
|
key String @unique
|
|
|
|
steps Step[]
|
|
mappings TechStepMapping[]
|
|
|
|
@@map("tech_step")
|
|
}
|
|
|
|
/// Used by `tech-step-matcher.ts` to auto-detect which technique a recipe
|
|
/// step's description corresponds to (expression = regex pattern tested
|
|
/// against the description, weight = tie-break score when several
|
|
/// mappings match). `locale` (e.g. `"fr"`) lets the same TechStep carry
|
|
/// one matching rule set per language — the matcher is always called with
|
|
/// a target locale and only considers mappings for that locale.
|
|
model TechStepMapping {
|
|
id Int @id @default(autoincrement())
|
|
techStepId Int @map("tech_step_id")
|
|
locale String
|
|
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")
|
|
}
|