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")
  /// See `Planning.createdAt` — same admin-metrics-only timestamp, added by
  /// the `admin_metrics` migration for the dashboard's signup curve. No
  /// application code reads it (auth doesn't need it).
  createdAt    DateTime @default(now()) @map("created_at")

  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")
  /// When this planning row was first created. Added by the `admin_metrics`
  /// migration purely for the admin dashboard's activity curves — no
  /// application code reads it. Rows that predate the migration all get the
  /// migration's own timestamp (same acceptable one-off skew as the
  /// `ingredient_unit_catalog` migration), which is fine for a trend chart.
  createdAt  DateTime @default(now()) @map("created_at")

  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
  /// See `Planning.createdAt` — same admin-metrics-only timestamp.
  createdAt  DateTime @default(now()) @map("created_at")

  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)
  /// See `Planning.createdAt` — same admin-metrics-only timestamp, added by
  /// the `admin_metrics` migration for the dashboard's "recipes created"
  /// curve. No application code reads it.
  createdAt     DateTime         @default(now()) @map("created_at")

  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[]
  /// Mentions of this ingredient detected in a step's free text alongside a
  /// technique — see `StepTechStepIngredient`.
  stepTechSteps StepTechStepIngredient[]

  @@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[]
  /// Ingredient mentions detected alongside a technique in a step's free
  /// text (e.g. "50g" resolved against this `Unit`) — see
  /// `StepTechStepIngredient`. Distinct from `recipeIngredients` above
  /// (the recipe's structured ingredient list): a step can mention a
  /// quantity+unit that was never itself an ingredient list line.
  stepTechStepIngredients StepTechStepIngredient[]

  @@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 spaCy-based model (`services/tech-step-intent-service`) 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
/// that service'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")
}

/// `key` is `@unique`, same bare id+key shape as `TechStep` — no
/// categorization taxonomy like `Ingredient` needed yet, and no matching
/// data of its own here either: unlike `TechStep` (whose matching synonyms
/// used to live in TS and were moved into
/// `services/tech-step-intent-service`'s `training_data.py`), this catalog
/// was *born* owned by that service (`utensil_vocabulary.py`) since nothing
/// pre-existing needed it — this row only exists to be a stable id/key
/// `StepTechStepUtensil` references, and to carry a French label
/// (`apps/web`'s `catalog.utensils.<key>`, see `reference-seed-data.ts`'s
/// `UTENSILS`).
model Utensil {
  id  Int    @id @default(autoincrement())
  key String @unique

  steps StepTechStepUtensil[]

  @@map("utensil")
}

/// 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)
  /// Ingredients mentioned in the same clause as this technique occurrence
  /// — see `StepTechStepIngredient`.
  ingredients StepTechStepIngredient[]
  /// Utensils mentioned in the same clause as this technique occurrence —
  /// see `StepTechStepUtensil`.
  utensils    StepTechStepUtensil[]

  @@id([stepId, order])
  @@map("step_tech_step")
}

/// An ingredient mention found in the same *clause* as one `StepTechStep`
/// occurrence (`tech-step-matcher.ts`'s `matchTechStepSpans` — clauses are
/// already the unit a technique is judged on, see that file's doc comment,
/// so "same clause" is the association rule, no dependency-parsing needed).
/// `quantity`/`unitId` are best-effort, populated only when a leading
/// numeric expression immediately preceding the ingredient mention resolved
/// against the `Unit` catalog (`ingredient-matcher.ts`'s
/// `findIngredientMentions`) — both `null` when the clause names the
/// ingredient with no quantity ("ajouter le sel"). `start`/`end` are the
/// ingredient mention's own span in `Step.description`, same `[start, end)`
/// convention as `StepTechStep.start`/`end`. `source` mirrors
/// `StepTechStep.source` (`"auto"` today, room for a future user
/// correction without a shape change).
model StepTechStepIngredient {
  id            Int      @id @default(autoincrement())
  stepId        Int      @map("step_id")
  techStepOrder Int      @map("tech_step_order")
  ingredientId  Int      @map("ingredient_id")
  quantity      Decimal? @db.Decimal(10, 2)
  unitId        Int?     @map("unit_id")
  start         Int
  end           Int
  source        String   @default("auto")

  stepTechStep StepTechStep @relation(fields: [stepId, techStepOrder], references: [stepId, order], onDelete: Cascade)
  ingredient   Ingredient   @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
  unit         Unit?        @relation(fields: [unitId], references: [id])

  @@map("step_tech_step_ingredient")
}

/// A utensil mention found in the same clause as one `StepTechStep`
/// occurrence — same association rule as `StepTechStepIngredient` (see its
/// doc comment). Detected by
/// `services/tech-step-intent-service`'s own utensil `PhraseMatcher`
/// (`intent_service/utensil_vocabulary.py`), returned alongside technique
/// entities in `POST /v1/process` and filtered to this clause's span by
/// `tech-step-matcher.ts`.
model StepTechStepUtensil {
  id            Int    @id @default(autoincrement())
  stepId        Int    @map("step_id")
  techStepOrder Int    @map("tech_step_order")
  utensilId     Int    @map("utensil_id")
  start         Int
  end           Int
  source        String @default("auto")

  stepTechStep StepTechStep @relation(fields: [stepId, techStepOrder], references: [stepId, order], onDelete: Cascade)
  utensil      Utensil      @relation(fields: [utensilId], references: [id], onDelete: Cascade)

  @@map("step_tech_step_utensil")
}

/// 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")
}

// -----------------------------------------------------------------------------
// Admin application
// A separate operations app (usage metrics, microservice monitoring, tech-step
// correction triage) — see specs/backend-architecture.md's "admin" section.
// -----------------------------------------------------------------------------

/// An operator of the admin application (`apps/admin-web` / the `/admin/*`
/// API surface). Deliberately its **own** table with **no relation** to
/// `UserProfile`: admin access is a completely separate concern from being
/// an end user of the recipe app — a person can be one, both or neither,
/// and the two auth mechanisms (`requireAdmin` vs `requireAuth`, distinct
/// cookies, distinct JWT secrets) never overlap. No self-service signup —
/// the first row is created out-of-band by `src/scripts/create-admin.ts`,
/// and (for now) there's no in-app admin-management UI.
model AdminUser {
  id           Int       @id @default(autoincrement())
  email        String    @unique
  /// argon2 hash of the password — same hashing as `UserProfile.passwordHash`
  /// (`auth.service.ts`'s `hashOptions`, cheaper cost under NODE_ENV=test).
  passwordHash String    @map("password_hash")
  /// Display name shown in the admin UI's account menu.
  name         String
  /// Bumped to invalidate previously-issued admin JWTs — same mechanism as
  /// `UserProfile.tokenVersion`, checked on every request by `requireAdmin`.
  tokenVersion Int       @default(0) @map("token_version")
  createdAt    DateTime  @default(now()) @map("created_at")
  /// Stamped on every successful login — a cheap "is this account still in
  /// use" signal for the operator managing admins by hand.
  lastLoginAt  DateTime? @map("last_login_at")

  @@map("admin_users")
}

/// One recorded product event, for the admin dashboard's usage metrics.
/// Written fire-and-forget by `lib/analytics.service.ts`'s `recordEvent`
/// from a handful of key service methods (signup, recipe import/create,
/// planning add, cooking-session open, tech-step correction, shopping-list
/// view) — never on the request's critical path, so a failed insert is
/// logged and swallowed, never surfaced to the user.
///
/// `type` is a free `String` (`"user.signup"`, `"recipe.imported"`…), not
/// an enum: adding a new event to instrument is a one-line call site
/// change with **no migration**. `actorId` is a `UserProfile.id` when
/// `actorType == "user"` but carries **no FK** — an event is an immutable
/// historical fact that must outlive the account it describes (a deleted
/// user's signup still counts on the curve). `context` is a small free
/// JSON blob (`{ sourceKey, recipeId, … }`) for slicing later; nothing
/// queries into it today.
model AnalyticsEvent {
  id        Int      @id @default(autoincrement())
  type      String
  actorType String   @map("actor_type")
  actorId   Int?     @map("actor_id")
  context   Json?
  createdAt DateTime @default(now()) @map("created_at")

  @@index([type, createdAt])
  @@map("analytics_events")
}

/// Liveness/last-run record for a background worker that has no inbound
/// HTTP surface of its own — one row per worker (`workerKey`, today only
/// `"tech-step-llm-worker"`). The worker POSTs `/internal/tech-steps/heartbeat`
/// (`requireInternalWorker`) on boot, on every scheduler tick, and after
/// each job; the admin monitoring board reads this to show the worker as
/// up / stale / down and to surface its last job result. Upserted, never
/// accumulated — only the latest state matters.
model WorkerHeartbeat {
  workerKey  String    @id @map("worker_key")
  lastSeenAt DateTime  @map("last_seen_at")
  /// Set only by a `"job"` heartbeat — the last time the worker actually ran a job (vs. just a tick proving it's alive).
  lastRunAt  DateTime? @map("last_run_at")
  /// Small JSON summary of that last job (`{ job, ok, counts }`).
  lastResult Json?     @map("last_result")

  @@map("worker_heartbeats")
}
