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.` (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.` in `apps/web`'s locale file), not the /// display label. `kind` is also not in the original spec doc — see /// {@link AllergenKind}. model Category { id Int @id @default(autoincrement()) key String @unique kind AllergenKind @default(ALLERGY) allergies Allergy[] @@map("category") } model Allergy { id Int @id @default(autoincrement()) categoryId Int @map("cat_id") category Category @relation(fields: [categoryId], references: [id]) users UserProfileAllergy[] ingredients IngredientAllergy[] @@map("allergy") } model UserProfile { id Int @id @default(autoincrement()) firstName String @map("first_name") lastName String @map("last_name") email String @unique /// argon2 hash of the account password. Not in the original spec doc — /// added for authentication (login page / profile creation). passwordHash String @map("password_hash") /// Bumped to invalidate previously-issued JWTs (e.g. on password change). /// Not in the original spec doc — required for stateless JWT auth. tokenVersion Int @default(0) @map("token_version") houseId Int? @map("house_id") dietId Int? @map("diet_id") house House? @relation("HouseMember", fields: [houseId], references: [id], onDelete: SetNull) diet Diet? @relation(fields: [dietId], references: [id], onDelete: SetNull) allergies UserProfileAllergy[] /// Ingredients this profile personally dislikes — a taste preference, not /// a medical constraint (see {@link UserProfileDislikedIngredient} and /// `allergies` above for the distinct medical list). dislikedIngredients UserProfileDislikedIngredient[] /// Recipes authored by this profile — see `Recipe.authorId`. authoredRecipes Recipe[] /// Recipes this profile has favorited — see {@link RecipeFavorite}. favoriteRecipes RecipeFavorite[] /// Households this profile administers. In practice at most one — a /// profile can only ever belong to (and thus admin) a single household at /// a time — but Prisma models the admin side of a one-to-many FK as a /// list regardless of that real-world cardinality. administeredHouses House[] @relation("HouseAdmin") preferences UserPreference? @@map("user_profiles") } /// Explicit join table for the user_profiles <-> ingredient "disliked" /// association — same shape as `UserProfileAllergy`, but a personal taste /// preference rather than a medical restriction: not surfaced as a safety /// warning, just a reminder on a recipe's detail view (see /// `RecipeView`/`RecipeDetailPanel`, apps/web). model UserProfileDislikedIngredient { userProfileId Int @map("user_profile_id") ingredientId Int @map("ingredient_id") userProfile UserProfile @relation(fields: [userProfileId], references: [id], onDelete: Cascade) ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade) @@id([userProfileId, ingredientId]) @@map("user_profile_disliked_ingredient") } /// Not in the original spec doc — personalization settings (theme for now, /// meant to grow), one row per profile, created on demand (see /// `preferences.service.ts`) rather than at signup — same "absent means the /// default" philosophy as `dietId`/allergies. enum ThemePreference { LIGHT DARK /// Follow the OS/browser preference — the default. Not "no row yet" (that /// case is handled in the service layer) but an explicit choice to track /// the system, distinguishable from a user who hasn't decided yet if this /// model ever needs that distinction. SYSTEM } model UserPreference { /// Both the primary key and the FK — a strict 1-1 with UserProfile, no /// separate auto-incrementing id (a profile has at most one preferences row). userProfileId Int @id @map("user_profile_id") theme ThemePreference @default(SYSTEM) userProfile UserProfile @relation(fields: [userProfileId], references: [id], onDelete: Cascade) @@map("user_preference") } /// Explicit join table for the user_profiles <-> allergy association /// (documented in the spec as a plain many-to-many, no extra fields). model UserProfileAllergy { userProfileId Int @map("user_profile_id") allergyId Int @map("allergy_id") userProfile UserProfile @relation(fields: [userProfileId], references: [id], onDelete: Cascade) allergy Allergy @relation(fields: [allergyId], references: [id], onDelete: Cascade) @@id([userProfileId, allergyId]) @@map("user_profile_allergy") } // ----------------------------------------------------------------------------- // Planning // ----------------------------------------------------------------------------- model Planning { id Int @id @default(autoincrement()) startDate DateTime @map("start_date") @db.Date finishDate DateTime @map("finish_date") @db.Date houseId Int @map("house_id") house House @relation(fields: [houseId], references: [id], onDelete: Cascade) items PlanningItem[] @@map("planning") } model PlanningItem { id Int @id @default(autoincrement()) planningId Int @map("planning_id") weekDay String @map("week_day") meal String recipeId Int @map("recipe_id") portions Int planning Planning @relation(fields: [planningId], references: [id], onDelete: Cascade) recipe Recipe @relation(fields: [recipeId], references: [id]) @@map("planning_item") } // ----------------------------------------------------------------------------- // Recipes // ----------------------------------------------------------------------------- /// 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 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.` 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.` (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.` (see `reference-seed-data.ts`'s `TECH_STEPS`). model TechStep { id Int @id @default(autoincrement()) key String @unique steps StepTechStep[] mappings TechStepMapping[] @@map("tech_step") } /// Used by `tech-step-matcher.ts` to auto-detect which technique a recipe /// step's description corresponds to (expression = regex pattern tested /// against the description, weight = tie-break score when several /// mappings match, or overlap-resolution score when two mappings match the /// same span of text — see `matchTechSteps`). `locale` (e.g. `"fr"`) lets /// the same TechStep carry one matching rule set per language — the /// matcher is always called with a target locale and only considers /// mappings for that locale. model TechStepMapping { id Int @id @default(autoincrement()) techStepId Int @map("tech_step_id") locale String expression String weight Int techStep TechStep @relation(fields: [techStepId], references: [id], onDelete: Cascade) @@map("tech_step_mapping") } /// Modeled as one-to-many (a step belongs to exactly one recipe), not the /// many-to-many noted in the spec doc: `order` only makes sense scoped to a /// single recipe, which isn't reconcilable with steps being shared across /// recipes. See specs/batch-cooking-modele.md for the original wording. model Step { id Int @id @default(autoincrement()) recipeId Int @map("recipe_id") description String picture String? order Int recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade) techSteps StepTechStep[] @@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 `matchTechSteps` — `tech-step-matcher.ts` — detected the /// techniques in the description), not a global ordering across different /// steps of the recipe (that's `Step.order`). model StepTechStep { stepId Int @map("step_id") techStepId Int @map("tech_step_id") order Int 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") }