* chore(web): session de polish global — version, checkbox, danger zone, icônes
- Affiche le numéro de version (package.json, injecté via Vite) en bas de
la sidebar, masqué en mode collapse et en mobile.
- Factorise les checkbox/radio dupliqués (AllergySelect, DietTagSelect,
IngredientPicker, UserPreferencesPage) en composants partagés
CheckboxOption/RadioOption (components/ui/), et inverse le layout pour
que la case soit à gauche du label.
- Teinte la "zone de danger" de suppression de compte en rouge (fond +
bordure), pas seulement le bouton.
- Migre les icônes de navigation générale vers lucide-react (nav-icons.tsx
devient un fichier de ré-export) ; les pictogrammes d'ingrédients métier
restent en SVG custom (pas d'équivalents fins côté lucide).
Vérifié : pnpm build, pnpm lint, pnpm --filter web e2e (43/43), et
vérification visuelle manuelle (sidebar desktop/collapsed/mobile, light/dark).
* feat(web): icônes d'ingrédients depuis foodiconpack.com + page de crédits
- Remplace 19 des 22 pictogrammes génériques d'ingrédients par des icônes
curées du pack gratuit "Common ingredient icons"/"Common Utensils" de
foodiconpack.com (CC BY 4.0) : carotte, pomme, basilic, bœuf, poulet,
saumon, crevette, riz, pois chiches, amandes, lait, cheddar, œufs,
cannelle, miel, huile d'olive, bière, marmite, sucre.
- BREAD/DOUGH/SPROUT restent en SVG custom : pas d'équivalent net dans le
pack (packs "ingrédients"/"ustensiles"/"plats"/"boissons" vérifiés).
Architecture inchangée : `icon` reste un enum de 22 valeurs partagées en
base (pas de migration, pas de mapping par ingrédient — cf. le
commentaire du fichier sur l'historique emoji→enum générique).
- Nouveau wrapper FilledIcon (fill="currentColor", viewBox 2048) à côté du
wrapper Icon existant (stroke) — les deux stylent au même endroit via
CSS, donc le mélange des 19+3 icônes reste visuellement homogène.
- Ajoute /parametres/credits (CreditsPage) créditant foodiconpack.com et
liant la licence CC BY 4.0, requis par la licence des icônes utilisées ;
nouvelle entrée de nav "Crédits" (icône lucide Info).
Vérifié : pnpm build, pnpm lint, pnpm --filter web e2e (43/43), et
vérification visuelle (grille des 22 icônes dans le picker, page crédits).
* feat(web,api): zone dangereuse rouge, préférences élargies, onglet favoris par défaut, e2e recettes, catalogue en uid+i18n
- Zone dangereuse (compte) : le bouton "Supprimer mon compte" est rouge.
- Pages préférences/paramétrage : contenu centré et élargi (32rem -> 56rem)
au lieu de coller à gauche sur un écran large.
- Page recettes : l'onglet "Favoris" est sélectionné par défaut.
- Ajout de apps/web/cypress/e2e/recipes.cy.ts (onglets, recherche, sélection
master-detail, favori, suppression, lien nouvelle recette).
- Catalogue de référence (ingrédients/régimes/allergènes) : la colonne
`name` (le libellé français, utilisé comme clé unique) devient `key`, un
slug stable et opaque au sens produit (ex. "vegetarien", "boeuf_hache").
Le libellé lui-même déménage entièrement côté client, dans
apps/web/src/locales/fr/translation.json sous le namespace `catalog.*`,
résolu via `t(\`catalog.ingredients.${key}\`)` etc. — même schéma que
IngredientCategory/IngredientSubcategory. Migration Prisma
(rename + backfill des ~456 lignes déjà seedées), seed/service/tests API
et composants web mis à jour en conséquence.
- apps/api/src/utils/slugify.ts + scripts/generate-catalog-i18n.ts
(regénère le fichier de traduction depuis reference-seed-data.ts).
- 102 tests Mocha + 32 scénarios Cucumber passent contre la base migrée.
Note : cypress run plante dans cet environnement (le processus GPU
Chromium/Electron crash même headless, indépendamment des flags) — les
recipes.cy.ts n'ont pas pu être exécutés ici ; vérifiés par lecture du code
source des composants visés et par un passage manuel dans le navigateur de
prévisualisation.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(api): les uids du catalogue sont en anglais, pas des slugs français
reference-seed-data.ts reste rédigé en français (c'est juste le libellé
d'autoring, jamais stocké/exposé), mais la clé stable (`Diet.key`/
`Category.key`/`Ingredient.key`) qu'on en dérive doit elle-même être un
identifiant anglais, indépendant de la langue d'autoring — pas juste le
même texte français passé à slugify().
- apps/api/src/db/catalog-en-keys.ts : dictionnaire écrit à la main
(label français -> clé anglaise) pour les 5 régimes, 14 allergènes et
437 ingrédients ; getEnglishKey() lève une erreur explicite si un
nouvel élément n'a pas encore d'entrée plutôt que de retomber sur un
slug français silencieux.
- scripts/validate-catalog-en-keys.ts : vérifie que chaque diet/allergène/
ingrédient de reference-seed-data.ts a une entrée, et que les clés
anglaises résultantes sont uniques (437/437, 14/14, 5/5 — zéro manquant,
zéro collision).
- reference-seed-data.ts et scripts/generate-catalog-i18n.ts utilisent
désormais getEnglishKey() au lieu de slugify(nom français).
- Nouvelle migration (20260818193000_catalog_keys_to_english) qui
remappe les lignes déjà seedées avec un slug français (par la migration
précédente) vers leur clé anglaise définitive.
- apps/web/src/locales/fr/translation.json régénéré : catalog.* est
maintenant indexé par clé anglaise ("vegetarian", "eggs",
"ground_beef"...), toujours avec le libellé français en valeur.
- Tests/step-definitions mis à jour (getEnglishKey() au lieu de
slugify()) ; 102 tests Mocha + 32 scénarios Cucumber passent contre la
base migrée. Vérifié aussi en direct via GET /reference/diets et
/reference/allergies.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(web): crypto.randomUUID plante hors contexte sécurisé, empêchant d'associer un ingrédient
Écran noir + "TypeError: crypto.randomUUID is not a function" au clic sur
une carte d'ingrédient dans le formulaire de recette. crypto.randomUUID()
n'est défini que dans un "contexte sécurisé" (https, ou littéralement le
host "localhost") — il est absent sur une IP locale (test sur un vrai
appareil), dans une WebView Capacitor (l'enrobage mobile prévu pour cette
app), ou en http sur un vrai domaine. RecipeFormPage/StepListEditor s'en
servaient pour générer l'identité React (`key`) de chaque ligne
d'ingrédient/étape en brouillon.
- apps/web/src/lib/client-key.ts : remplace par un générateur qui ne
touche jamais `crypto` — un compteur + Math.random suffit, cette valeur
n'a besoin d'être unique que le temps de la session de rendu, jamais
envoyée au serveur.
- apps/web/cypress/e2e/recipe-form.cy.ts : couvre l'association d'un
ingrédient (recherche, sélection, exclusion du picker une fois
sélectionné, retrait), la création et l'édition d'une recette, et un
test de non-régression dédié qui supprime crypto.randomUUID avant le
chargement de la page (comme le ferait un vrai contexte non sécurisé)
pour vérifier que l'ajout de plusieurs ingrédients/étapes ne plante
plus.
Vérifié en direct dans le navigateur de prévisualisation en supprimant
crypto.randomUUID à la main (reproduit le crash), puis en confirmant que
l'ajout d'ingrédient fonctionne à nouveau après le correctif. cypress run
ne peut toujours pas s'exécuter dans cet environnement (voir le commit
précédent) — non exécutés avec Cypress lui-même, mais vérifiés par
lecture des sélecteurs réels et rejoués à la main dans le navigateur.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(ci): corrige les specs Cypress cassées par le refactor uid+i18n, applique biome
- onboarding.cy.ts / preferences.cy.ts / recipes.cy.ts mockaient encore
GET /reference/diets|allergies avec l'ancienne forme {id, name}. Depuis
les deux derniers commits l'API renvoie {id, key} (uid anglais) et le
composant résout le libellé via i18n (t(`catalog.diets.${key}`)) — avec
key manquant, ça affichait littéralement "catalog.diets.undefined" au
lieu de "Végétarien"/"Omnivore"/etc., faisant échouer cy.select()/
cy.contains() dans ces 3 specs. Corrigé pour mocker {key: "vegetarian"},
{key: "peanuts"}, etc.
- recipes.cy.ts : le test "shows a not-found message" utilisait le
mauvais code d'erreur (4041 au lieu de ErrorCode.RECIPE_NOT_FOUND =
4045), donc RecipeDetailPanel tombait dans son état d'erreur générique
au lieu du message "Cette recette n'existe pas." — bug dans mon propre
test, sans rapport avec le refactor.
- pnpm lint (biome) : les fichiers touchés par le refactor précédent
avaient quelques soucis de formatage/tri d'imports (des sed multi-
fichiers, pas d'édition via l'outil habituel) — corrigés par
`biome check --write`.
Vérifié : ces 3 specs + recipe-form.cy.ts passent maintenant dans le job
CI GitHub Actions (Linux, Cypress s'y exécute réellement — contrairement
à cet environnement Windows sandboxé, voir les commits précédents) ; 102
tests Mocha + 32 scénarios Cucumber toujours au vert en local.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
545 lines
22 KiB
Text
545 lines
22 KiB
Text
generator client {
|
|
provider = "prisma-client-js"
|
|
}
|
|
|
|
datasource db {
|
|
provider = "postgresql"
|
|
url = env("DATABASE_URL")
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Users & household
|
|
// See specs/batch-cooking-modele.md for the source data model documentation.
|
|
// -----------------------------------------------------------------------------
|
|
|
|
model House {
|
|
id Int @id @default(autoincrement())
|
|
name String
|
|
/// The member who administers this household — created it, or inherited
|
|
/// adminship when the previous admin left/deleted their account (see
|
|
/// `house.service.ts`'s `leaveCurrentHouse`). Always set: a house is
|
|
/// deleted outright once it would otherwise have no admin left.
|
|
adminId Int @map("admin_id")
|
|
/// Shareable code another user enters via `POST /house/join` to become a
|
|
/// member — see `house.service.ts`'s generator for the charset/length.
|
|
inviteCode String @unique @map("invite_code")
|
|
|
|
admin UserProfile @relation("HouseAdmin", fields: [adminId], references: [id])
|
|
members UserProfile[] @relation("HouseMember")
|
|
plannings Planning[]
|
|
/// Recipes whose author belonged to this household when they created
|
|
/// them — see `Recipe.authorHouseId`.
|
|
authoredRecipes Recipe[]
|
|
|
|
@@map("house")
|
|
}
|
|
|
|
/// `key` is `@unique` — not in the original spec doc, added so the seed
|
|
/// script (prisma/seed.ts) can `upsert` by key and stay idempotent/safe to
|
|
/// re-run, and so two reference rows can never silently duplicate the same
|
|
/// regime. A stable slug (e.g. `"vegetarien"`), 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` and
|
|
/// `utils/slugify.ts`), 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")
|
|
|
|
planning Planning @relation(fields: [planningId], references: [id], onDelete: Cascade)
|
|
recipe Recipe @relation(fields: [recipeId], references: [id])
|
|
|
|
@@map("planning_item")
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Recipes
|
|
// -----------------------------------------------------------------------------
|
|
|
|
model Source {
|
|
id Int @id @default(autoincrement())
|
|
name String
|
|
url String?
|
|
|
|
recipes Recipe[]
|
|
|
|
@@map("sources")
|
|
}
|
|
|
|
/// Not in the original spec doc — who can *read* a recipe. Controls only
|
|
/// visibility, never editing: a recipe can only ever be edited/deleted by
|
|
/// its `author`, whatever this is set to (see `recipe.service.ts`).
|
|
enum RecipeVisibility {
|
|
/// Visible to its author only.
|
|
PERSONAL
|
|
/// Visible to `authorHouseId`'s members (a snapshot of the author's
|
|
/// household *at creation time* — see `Recipe.authorHouseId`).
|
|
HOUSE
|
|
/// Visible to every signed-in user — the "shared catalog" behavior the
|
|
/// very first version of this feature shipped with.
|
|
PUBLIC
|
|
}
|
|
|
|
model Recipe {
|
|
id Int @id @default(autoincrement())
|
|
name String
|
|
sourceId Int? @map("source_id")
|
|
description String?
|
|
picture String?
|
|
/// Creator — not in the original spec doc, required once recipes carry a
|
|
/// visibility level (`PERSONAL`/`HOUSE` need someone to scope against).
|
|
authorId Int @map("author_id")
|
|
/// The author's household *at the time this recipe was created* — a
|
|
/// snapshot (same idea as `Planning.houseId`), not a live lookup: it
|
|
/// doesn't follow the author if they later change household. `null` if
|
|
/// the author had no household yet.
|
|
authorHouseId Int? @map("author_house_id")
|
|
visibility RecipeVisibility @default(PERSONAL)
|
|
|
|
author UserProfile @relation(fields: [authorId], references: [id])
|
|
authorHouse House? @relation(fields: [authorHouseId], references: [id], onDelete: SetNull)
|
|
source Source? @relation(fields: [sourceId], references: [id], onDelete: SetNull)
|
|
ingredients RecipeIngredient[]
|
|
steps Step[]
|
|
planningItems PlanningItem[]
|
|
favoritedBy RecipeFavorite[]
|
|
diets RecipeDiet[]
|
|
/// Ingredients for which this recipe is offered as a make-it-yourself alternative.
|
|
alternateFor Ingredient[] @relation("IngredientAlternateRecipe")
|
|
|
|
@@map("recipe")
|
|
}
|
|
|
|
/// Explicit join table for the user_profiles <-> recipe "favorited"
|
|
/// association — same shape as `UserProfileAllergy`. Per-user, not
|
|
/// per-household: two members of the same household can favorite different
|
|
/// recipes independently.
|
|
model RecipeFavorite {
|
|
userProfileId Int @map("user_profile_id")
|
|
recipeId Int @map("recipe_id")
|
|
|
|
userProfile UserProfile @relation(fields: [userProfileId], references: [id], onDelete: Cascade)
|
|
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
|
|
|
@@id([userProfileId, recipeId])
|
|
@@map("recipe_favorite")
|
|
}
|
|
|
|
/// Explicit join table for the recipe <-> diet "associated regime" tags
|
|
/// (e.g. a recipe can be tagged both `Végétarien` and `Sans gluten`) — a
|
|
/// manual reminder set by whoever creates/edits the recipe, not computed
|
|
/// from its ingredients.
|
|
model RecipeDiet {
|
|
recipeId Int @map("recipe_id")
|
|
dietId Int @map("diet_id")
|
|
|
|
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
|
diet Diet @relation(fields: [dietId], references: [id], onDelete: Cascade)
|
|
|
|
@@id([recipeId, dietId])
|
|
@@map("recipe_diet")
|
|
}
|
|
|
|
/// `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(EPICERIE_SECHE)` 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 {
|
|
/// 🥦 Légumes, fruits, herbes fraîches.
|
|
PRODUITS_FRAIS
|
|
/// 🥩 Viandes, volailles, poissons, crustacés & fruits de mer.
|
|
BOUCHERIE_POISSONNERIE
|
|
/// 🥫 Féculents, légumineuses, graines & fruits secs, et le reste des
|
|
/// produits secs/en conserve qui ne rentre dans aucune autre case
|
|
/// (algues séchées, champignons séchés…).
|
|
EPICERIE_SECHE
|
|
/// 🍞 Pains et pâtes à cuire (crues, à enfourner).
|
|
BOULANGERIE
|
|
/// 🧈 Produits laitiers, œufs, alternatives végétales (laits végétaux,
|
|
/// tofu…).
|
|
CREMERIE_FROMAGE
|
|
/// 🧂 Épices, sauces, assaisonnements (huiles, vinaigres, alcools de
|
|
/// cuisine…).
|
|
CONDIMENTS_EPICES
|
|
/// 🍳 Bases de préparation (farines, bouillons, eau), épaississants
|
|
/// (levures, fécules, gélatine), sucres.
|
|
AIDES_CULINAIRES
|
|
}
|
|
|
|
/// 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(AUTRES)` — same NOT-NULL-migration-safety-net reasoning as
|
|
/// `IngredientCategory`'s default, never the intended value for a real row.
|
|
enum IngredientSubcategory {
|
|
// --- Produits frais ------------------------------------------------------
|
|
LEGUMES
|
|
FRUITS
|
|
HERBES_FRAICHES
|
|
// --- Boucherie & poissonnerie ---------------------------------------------
|
|
VIANDES
|
|
VOLAILLES
|
|
POISSONS
|
|
CRUSTACES_FRUITS_DE_MER
|
|
// --- Épicerie sèche --------------------------------------------------------
|
|
FECULENTS
|
|
LEGUMINEUSES
|
|
GRAINES_FRUITS_SECS
|
|
/// Catch-all for dried/tinned pantry items that don't fit the three
|
|
/// subcategories above — dried seaweed, dried mushrooms, tinned bamboo
|
|
/// shoots/water chestnuts…
|
|
AUTRES
|
|
// --- Boulangerie -------------------------------------------------------
|
|
PAINS
|
|
/// Raw, uncooked doughs meant to be baked (puff pastry, shortcrust…) —
|
|
/// distinct from `PAINS` (already-baked bread).
|
|
PATES_A_CUIRE
|
|
// --- Crémerie & fromage --------------------------------------------------
|
|
PRODUITS_LAITIERS
|
|
OEUFS
|
|
/// Plant-based dairy/meat substitutes — coconut/almond/oat "milk", tofu.
|
|
ALTERNATIVES
|
|
// --- Condiments & épices -------------------------------------------------
|
|
EPICES
|
|
SAUCES
|
|
/// Oils, vinegars, citrus juices, cooking alcohols/wines — liquids that
|
|
/// season rather than form the base of a dish.
|
|
ASSAISONNEMENTS
|
|
// --- Aides culinaires ----------------------------------------------------
|
|
/// 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.
|
|
EPAISSISSANTS
|
|
SUCRES
|
|
}
|
|
|
|
/// 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(EPICERIE_SECHE)
|
|
subcategory IngredientSubcategory @default(AUTRES)
|
|
alternateRecipeId Int? @map("alternate_recipe")
|
|
|
|
alternateRecipe Recipe? @relation("IngredientAlternateRecipe", fields: [alternateRecipeId], references: [id], onDelete: SetNull)
|
|
recipes RecipeIngredient[]
|
|
allergies IngredientAllergy[]
|
|
/// Profiles that personally dislike this ingredient — see {@link UserProfileDislikedIngredient}.
|
|
dislikedBy UserProfileDislikedIngredient[]
|
|
/// Diet regimes this ingredient is compatible with — see {@link IngredientDiet}.
|
|
diets IngredientDiet[]
|
|
|
|
@@map("ingredients")
|
|
}
|
|
|
|
/// Explicit join table for the ingredients <-> diet regime association —
|
|
/// which regimes (Végétarien, Végan, Pescétarien…) this ingredient is safe
|
|
/// for, so the picker (apps/web's `IngredientPicker`/`IngredientRow`) can
|
|
/// flag e.g. an ingredient as vegan without the user having to open its
|
|
/// packaging. Seeded by category in `reference-seed-data.ts` (most
|
|
/// ingredients in a category share the same compatible regimes, with
|
|
/// per-item overrides for exceptions — meat cuts, dairy, seafood…), same as
|
|
/// `IngredientAllergy`. Deliberately omits `Omnivore` (every ingredient is
|
|
/// trivially compatible — storing it would be pure noise) and `Sans gluten`
|
|
/// (already fully derivable from whether `IngredientAllergy` links this
|
|
/// ingredient to the `Gluten` allergen — a second, hand-maintained source
|
|
/// for the same fact would only risk drifting out of sync with it).
|
|
model IngredientDiet {
|
|
ingredientId Int @map("ingredient_id")
|
|
dietId Int @map("diet_id")
|
|
|
|
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
|
|
diet Diet @relation(fields: [dietId], references: [id], onDelete: Cascade)
|
|
|
|
@@id([ingredientId, dietId])
|
|
@@map("ingredient_diet")
|
|
}
|
|
|
|
/// Explicit join table for the ingredients <-> allergy association — not in
|
|
/// the original spec doc, added so the recipe catalog can surface which
|
|
/// allergens an ingredient (and by extension a recipe) carries. Same shape
|
|
/// as `UserProfileAllergy`.
|
|
model IngredientAllergy {
|
|
ingredientId Int @map("ingredient_id")
|
|
allergyId Int @map("allergy_id")
|
|
|
|
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
|
|
allergy Allergy @relation(fields: [allergyId], references: [id], onDelete: Cascade)
|
|
|
|
@@id([ingredientId, allergyId])
|
|
@@map("ingredient_allergy")
|
|
}
|
|
|
|
/// recipe <-> ingredients association. The spec documents this as a plain
|
|
/// many-to-many, but a shopping list / batch-cooking calculation needs a
|
|
/// quantity per recipe, so this join table carries quantity + unit
|
|
/// (project decision, not in the original spec doc).
|
|
model RecipeIngredient {
|
|
recipeId Int @map("recipe_id")
|
|
ingredientId Int @map("ingredient_id")
|
|
quantity Decimal @db.Decimal(10, 2)
|
|
unit String
|
|
|
|
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
|
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
|
|
|
|
@@id([recipeId, ingredientId])
|
|
@@map("recipe_ingredient")
|
|
}
|
|
|
|
model TechStep {
|
|
id Int @id @default(autoincrement())
|
|
|
|
steps Step[]
|
|
mappings TechStepMapping[]
|
|
|
|
@@map("tech_step")
|
|
}
|
|
|
|
/// Used by the recipe-import pipeline to auto-detect which technique a raw
|
|
/// instruction step corresponds to (expression = text pattern, weight = match score).
|
|
model TechStepMapping {
|
|
id Int @id @default(autoincrement())
|
|
techStepId Int @map("tech_step_id")
|
|
expression String
|
|
weight Int
|
|
|
|
techStep TechStep @relation(fields: [techStepId], references: [id], onDelete: Cascade)
|
|
|
|
@@map("tech_step_mapping")
|
|
}
|
|
|
|
/// Modeled as one-to-many (a step belongs to exactly one recipe), not the
|
|
/// many-to-many noted in the spec doc: `order` only makes sense scoped to a
|
|
/// single recipe, which isn't reconcilable with steps being shared across
|
|
/// recipes. See specs/batch-cooking-modele.md for the original wording.
|
|
model Step {
|
|
id Int @id @default(autoincrement())
|
|
recipeId Int @map("recipe_id")
|
|
description String
|
|
picture String?
|
|
order Int
|
|
techStepId Int? @map("tech_step_id")
|
|
|
|
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
|
techStep TechStep? @relation(fields: [techStepId], references: [id], onDelete: SetNull)
|
|
|
|
@@map("step")
|
|
}
|