Les corrections utilisateur (via TechStepCorrectionPopover) sont
désormais écrites directement dans StepTechStep, avec une colonne
`source` ("auto" | "manual") qui les distingue des matches du
classifieur NLP :
- Migration `step_tech_step_source` ajoutant `source` (défaut "auto")
- `applyManualCorrection`/`renumberStepTechSteps` dans
recipe-tech-step-correction.service.ts : une correction met à jour
ou crée l'entrée StepTechStep concernée (source "manual"), la
réponse de l'endpoint inclut désormais le techSteps à jour du step
(SubmitTechStepCorrectionResult), pas seulement l'audit de
correction
- backfill-tech-steps.ts préserve les entrées "manual" existantes :
seules les entrées "auto" sont recalculées, et un nouveau match
auto chevauchant une correction manuelle est ignoré plutôt
qu'inséré en doublon — vérifié en base réelle (une correction
manuelle survit intacte à un backfill complet)
- Le front distingue visuellement les deux (StepDescription.tsx,
recipes.scss : `.step-tech-step--manual`, couleur Turmeric au lieu
de Basil), avec un tooltip "(correction manuelle)" et un indicateur
de découvrabilité de la fonctionnalité dans RecipeDetailPanel
Corrige aussi deux bugs trouvés en testant en conditions réelles :
- StepDescription.tsx : le clic sur un highlight existant lisait la
variable `offset` (mutable, partagée par la boucle) au lieu d'une
valeur capturée, envoyant un `end` erroné (fin de la description
entière au lieu du span du mot cliqué)
- backfill-tech-steps.ts : le garde `import.meta.url ===
file://${process.argv[1]}` ne matche jamais sur Windows (chemins à
antislash), le script ne faisait donc rien en exécution directe ;
remplacé par `pathToFileURL(process.argv[1]).href`
335 tests apps/api passants, 40/40 composants Cypress, 75/76 e2e
Cypress (1 flake pré-existant sans rapport, non touché ici).
812 lines
36 KiB
Text
812 lines
36 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[]
|
|
/// Which recipe sources this household sees in its recipe tabs — see `HouseSource`.
|
|
enabledSources HouseSource[]
|
|
|
|
@@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?
|
|
/// Tech-step corrections this profile has submitted (any profile that can
|
|
/// view a recipe may correct its tech-step matches, not just its author —
|
|
/// see `StepTechStepCorrection.correctorId`).
|
|
techStepCorrections StepTechStepCorrection[]
|
|
|
|
@@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
|
|
// -----------------------------------------------------------------------------
|
|
|
|
/// Catalog of implemented recipe sources (specific websites/APIs the
|
|
/// import pipeline knows how to talk to) — one row per adapter registered
|
|
/// in `apps/api/src/lib/recipe-source-registry.ts`, kept in sync by
|
|
/// `syncRecipeSources` (`apps/api/src/db/recipe-source-sync.ts`) rather
|
|
/// than hand-maintained like `DIETS`/`UNITS` (`reference-seed-data.ts`):
|
|
/// the adapter registry is the actual source of truth for "which sources
|
|
/// exist", this table just mirrors it so `Recipe.sourceId` has something
|
|
/// to point at. `key` matches `RecipeSourceAdapter.key` — same stable
|
|
/// English camelCase uid convention as `Diet.key`/`Unit.key`/`TechStep.key`.
|
|
/// Empty until a concrete adapter is registered (none exists yet, see
|
|
/// recipe-source-adapter.ts).
|
|
model Source {
|
|
id Int @id @default(autoincrement())
|
|
key String @unique
|
|
name String
|
|
url String?
|
|
/// Whether this is an official API (the site/publisher provides
|
|
/// structured recipe data itself) or unofficial web scraping (we parse
|
|
/// HTML the site never committed to a stable shape for) — mirrors
|
|
/// `RecipeSourceAdapter.official` (recipe-source-adapter.ts), synced the
|
|
/// same way as `key`/`name`. Surfaced to households picking which
|
|
/// sources to enable (see `HouseSource`) so scraped content is never
|
|
/// mistaken for an official feed.
|
|
official Boolean
|
|
/// The source's own logo/favicon URL, shown next to its name in
|
|
/// `SourceSelect` (apps/web) — mirrors `RecipeSourceAdapter.iconUrl`,
|
|
/// synced the same way as `name`/`official`. `null` if the source has
|
|
/// none worth showing.
|
|
iconUrl String? @map("icon_url")
|
|
|
|
recipes Recipe[]
|
|
enabledHouses HouseSource[]
|
|
|
|
@@map("sources")
|
|
}
|
|
|
|
/// Which sources a household has chosen to see recipes from — opt-in: no
|
|
/// row means disabled. A newly created household starts with nothing
|
|
/// enabled (see the household-creation step in the signup wizard, and the
|
|
/// household settings page for changing this later); every recipe catalog
|
|
/// tab (`recipe.service.ts`'s `listRecipes`) filters out recipes whose
|
|
/// `sourceId` isn't in this list for the viewer's household — a
|
|
/// manually-authored recipe (`sourceId` `null`) is never affected, this
|
|
/// only ever hides recipes that came from an external source.
|
|
model HouseSource {
|
|
houseId Int @map("house_id")
|
|
sourceId Int @map("source_id")
|
|
|
|
house House @relation(fields: [houseId], references: [id], onDelete: Cascade)
|
|
source Source @relation(fields: [sourceId], references: [id], onDelete: Cascade)
|
|
|
|
@@id([houseId, sourceId])
|
|
@@map("house_source")
|
|
}
|
|
|
|
/// 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")
|
|
/// The item's identifier on `source` (`RecipeSourceListItem.externalId`,
|
|
/// recipe-source-adapter.ts) — `null` for a manually-authored recipe,
|
|
/// alongside `sourceId` being `null`. Together with `sourceId`, this is
|
|
/// what `findImportedExternalIds` (recipe-source-sync.ts) checks against
|
|
/// to tell an already-imported source item apart from a new one when
|
|
/// browsing (see `markAlreadyImported`, recipe-source-adapter.ts) — the
|
|
/// `@@unique([sourceId, externalId])` below is what actually prevents
|
|
/// importing the same source recipe twice (Postgres treats each `NULL`
|
|
/// as distinct, so manually-authored recipes never collide with each
|
|
/// other here).
|
|
externalId String? @map("external_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[]
|
|
|
|
@@unique([sourceId, externalId])
|
|
@@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) @map("to_base_factor") @db.Decimal(12, 4)
|
|
|
|
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`).
|
|
///
|
|
/// Matching a step's free text against these (`tech-step-matcher.ts`'s
|
|
/// `TechStepClassifierService`) used to go through a DB-backed
|
|
/// `TechStepMapping` table of per-locale regex expressions — replaced with
|
|
/// a node-nlp model trained from in-code data
|
|
/// (`tech-step-training-data.ts`) once regexes turned out unable to
|
|
/// generalize past their own literal vocabulary. Nothing queries/edits
|
|
/// that matching data at runtime anymore (it only ever feeds the
|
|
/// classifier's one-time training pass), so it no longer needs a table of
|
|
/// its own — this row now only exists to be a stable id/key other tables
|
|
/// (`StepTechStep`) reference.
|
|
model TechStep {
|
|
id Int @id @default(autoincrement())
|
|
key String @unique
|
|
|
|
steps StepTechStep[]
|
|
/// Corrections where this technique was the *previous* (possibly wrong)
|
|
/// match — see `StepTechStepCorrection.previousTechStepId`.
|
|
correctionsAsPrevious StepTechStepCorrection[] @relation("PreviousTechStep")
|
|
/// Corrections where this technique was the *corrected* (user-asserted)
|
|
/// match — see `StepTechStepCorrection.correctedTechStepId`.
|
|
correctionsAsCorrected StepTechStepCorrection[] @relation("CorrectedTechStep")
|
|
/// Training-corpus suggestions targeting this technique — see
|
|
/// `TechStepTrainingSuggestion`.
|
|
trainingSuggestions TechStepTrainingSuggestion[]
|
|
|
|
@@map("tech_step")
|
|
}
|
|
|
|
/// 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
|
|
|
|
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
|
techSteps StepTechStep[]
|
|
/// User-submitted corrections to this step's detected techniques — see
|
|
/// `StepTechStepCorrection`.
|
|
corrections StepTechStepCorrection[]
|
|
|
|
@@map("step")
|
|
}
|
|
|
|
/// A single step's *ordered sequence* of detected techniques — one
|
|
/// instruction can genuinely involve more than one (e.g. "Dans une poêle
|
|
/// chaude, faire chauffer une noix de beurre" is both `preheat` and
|
|
/// `melt`), which is why this replaced the original single nullable
|
|
/// `Step.techStepId` FK (per PR review feedback on the first version of
|
|
/// this feature). `order` is the position within *this step* (0-based, in
|
|
/// the order `matchTechStepSpans` — `tech-step-matcher.ts` — detected the
|
|
/// techniques in the description), not a global ordering across different
|
|
/// steps of the recipe (that's `Step.order`).
|
|
///
|
|
/// `start`/`end` are the tight matched *keyword* span within
|
|
/// `Step.description` (see `TechStepMatch`, `tech-step-matcher.ts`) — what
|
|
/// the recipe detail view highlights strongly, with a tooltip.
|
|
/// `contextStart`/`contextEnd` are the wider *clause* the keyword was found
|
|
/// in (e.g. "Dans une poêle chaude" around a `preheat` keyword of "poêle
|
|
/// chaude") — always contains `start`/`end` — what the detail view
|
|
/// highlights more subtly around it, so both "the exact trigger word(s)"
|
|
/// and "how much of the sentence is about this technique" are visible.
|
|
/// Nullable, **not backfilled**: adding them `NOT NULL` without a default
|
|
/// would fail outright against any pre-existing row, the same mistake the
|
|
/// `ingredient_unit_catalog` migration made against real prod data. A row
|
|
/// from before a column existed just has no span for it (no highlight)
|
|
/// until its recipe is next saved, which recomputes every step's
|
|
/// techniques from scratch (`recipe.service.ts`'s `updateRecipe` deletes
|
|
/// and recreates every `Step`/`StepTechStep`, never a partial patch) —
|
|
/// graceful degradation, not a permanent gap.
|
|
///
|
|
/// `source` distinguishes a `"manual"` row — written immediately when a
|
|
/// user submits a `StepTechStepCorrection` that asserts a technique
|
|
/// (`recipe-tech-step-correction.service.ts`'s `applyManualCorrection`),
|
|
/// not just recorded as a pending suggestion — from an `"auto"` row the
|
|
/// classifier itself produced (`tech-step-matcher.ts`). Both kinds coexist
|
|
/// in the same ordered sequence; the detail view (`apps/web`) renders them
|
|
/// with a different highlight color so a viewer can tell which is which.
|
|
/// `backfillTechSteps` (`scripts/backfill-tech-steps.ts`) only ever
|
|
/// deletes/recreates `"auto"` rows — a `"manual"` row survives a
|
|
/// classifier/corpus change until a user (or a future moderation feature)
|
|
/// explicitly changes it again.
|
|
model StepTechStep {
|
|
stepId Int @map("step_id")
|
|
techStepId Int @map("tech_step_id")
|
|
order Int
|
|
start Int?
|
|
end Int?
|
|
contextStart Int? @map("context_start")
|
|
contextEnd Int? @map("context_end")
|
|
source String @default("auto")
|
|
|
|
step Step @relation(fields: [stepId], references: [id], onDelete: Cascade)
|
|
techStep TechStep @relation(fields: [techStepId], references: [id], onDelete: Cascade)
|
|
|
|
@@id([stepId, order])
|
|
@@map("step_tech_step")
|
|
}
|
|
|
|
/// One user-submitted correction to a `Step`'s detected techniques —
|
|
/// captures ADD (a missing technique the classifier didn't find),
|
|
/// REMOVE (a wrong technique it did), or RELABEL (both) as a single shape:
|
|
/// `previousTechStepId` is the (possibly absent) match being corrected,
|
|
/// `correctedTechStepId` is what the user asserts instead (absent means
|
|
/// "no technique belongs here"). Both `null` at once is invalid (nothing
|
|
/// would have changed) — enforced service-side, not by the schema, same
|
|
/// posture as other cross-field invariants in this codebase (e.g.
|
|
/// `RecipeIngredientView`'s no-duplicate-ingredient check).
|
|
///
|
|
/// `start`/`end` are the user's selected `[start, end)` span within
|
|
/// `Step.description` (`String.prototype.slice` convention, same as
|
|
/// `StepTechStep`) — what they highlighted before assigning a technique to
|
|
/// it, not necessarily identical to any existing `StepTechStep` span.
|
|
///
|
|
/// Never edited/deleted once created (an audit trail of what was actually
|
|
/// submitted) — only `consumedAt` changes, stamped once
|
|
/// `services/tech-step-llm-worker` has turned this correction into a
|
|
/// `TechStepTrainingSuggestion` for a maintainer to review, so the same
|
|
/// correction isn't proposed twice on the next scheduled run.
|
|
model StepTechStepCorrection {
|
|
id Int @id @default(autoincrement())
|
|
stepId Int @map("step_id")
|
|
/// Any profile that could *view* the recipe when they submitted this, not
|
|
/// necessarily its author — see `assertRecipeVisible`,
|
|
/// `recipe.service.ts`.
|
|
correctorId Int @map("corrector_id")
|
|
start Int
|
|
end Int
|
|
previousTechStepId Int? @map("previous_tech_step_id")
|
|
correctedTechStepId Int? @map("corrected_tech_step_id")
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
consumedAt DateTime? @map("consumed_at")
|
|
|
|
step Step @relation(fields: [stepId], references: [id], onDelete: Cascade)
|
|
corrector UserProfile @relation(fields: [correctorId], references: [id], onDelete: Cascade)
|
|
previousTechStep TechStep? @relation("PreviousTechStep", fields: [previousTechStepId], references: [id], onDelete: SetNull)
|
|
correctedTechStep TechStep? @relation("CorrectedTechStep", fields: [correctedTechStepId], references: [id], onDelete: SetNull)
|
|
trainingSuggestions TechStepTrainingSuggestion[]
|
|
|
|
@@map("step_tech_step_correction")
|
|
}
|
|
|
|
/// A candidate addition to `TECH_STEP_TRAINING_DATA`
|
|
/// (`tech-step-training-data.ts`), proposed by `services/tech-step-llm-worker`
|
|
/// from one of two sources (`sourceType`):
|
|
///
|
|
/// - `"correction"` — a user's `StepTechStepCorrection`, turned into
|
|
/// suggested synonyms/utterances by the worker's LLM
|
|
/// (`transform-corrections` job).
|
|
/// - `"llm_audit"` — a low-confidence NLP clause on an *existing* recipe the
|
|
/// worker periodically samples and re-judges with its LLM
|
|
/// (`audit-low-confidence` job); no `sourceCorrectionId` in this case.
|
|
///
|
|
/// Deliberately never auto-applied to `tech-step-training-data.ts` — a
|
|
/// maintainer reviews `status: "pending"` rows (see
|
|
/// `list-pending-training-suggestions.ts`) and edits that file by hand,
|
|
/// same "generated suggestion, human-reviewed source of truth" split as a
|
|
/// linter's autofix vs. a human-authored diff. `retrain-tech-steps.ts` then
|
|
/// flips `status` to `"applied"`/`"rejected"` once a maintainer has acted on
|
|
/// a batch, so the same suggestion isn't reviewed twice.
|
|
///
|
|
/// `suggestedSynonyms`/`suggestedUtterances` are native Postgres arrays
|
|
/// (`String[]`), not a join table — unlike this schema's other list-shaped
|
|
/// data (`RecipeDiet`, `UserProfileAllergy`...), these strings are free text
|
|
/// proposed once for a human to read, not ids referencing another catalog
|
|
/// table, so there's nothing for a join table to normalize against.
|
|
model TechStepTrainingSuggestion {
|
|
id Int @id @default(autoincrement())
|
|
techStepId Int @map("tech_step_id")
|
|
locale String
|
|
suggestedSynonyms String[] @map("suggested_synonyms")
|
|
suggestedUtterances String[] @map("suggested_utterances")
|
|
sourceType String @map("source_type")
|
|
sourceCorrectionId Int? @map("source_correction_id")
|
|
status String @default("pending")
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
|
|
techStep TechStep @relation(fields: [techStepId], references: [id])
|
|
sourceCorrection StepTechStepCorrection? @relation(fields: [sourceCorrectionId], references: [id], onDelete: SetNull)
|
|
|
|
@@map("tech_step_training_suggestion")
|
|
}
|