import { config as loadDotenv } from "dotenv"; import { existsSync } from "node:fs"; import path from "node:path"; // Ladda .env från paketet ELLER monorepo-roten (pnpm --filter sätter cwd till paketet). for (const candidate of [".env", "../.env", "../../.env"]) { const p = path.resolve(process.cwd(), candidate); if (existsSync(p)) { loadDotenv({ path: p }); break; } } import { computeRecipeNutrition, toGrams, type IngredientNutritionSource, } from "@app/nutrition-engine"; import { BRAND, type Allergen, type RecipeDNA } from "@app/shared-types"; import { createDatabase } from "../client.js"; import * as schema from "../schema/index.js"; import { SEED_INGREDIENTS } from "./data/ingredients.js"; import INGREDIENT_TRANSLATIONS from "./data/ingredient-translations.json" with { type: "json" }; import { SEED_RECIPES, type SeedRecipe } from "./data/recipes.js"; import { SEED_SUBSTITUTIONS } from "./data/substitutions.js"; import { SEED_SEASON_EVENTS } from "./data/seasonEvents.js"; /** * Seed-körning. Idempotent: onConflictDoNothing/Update där det är säkert. * * Näring, allergener och kostnad för recepten beräknas HÄR, deterministiskt, * ur ingredienserna (spec §61.1–2) – aldrig hårdkodade och aldrig från AI. */ async function main() { const { db, pool } = createDatabase(); console.log("[seed] Startar …"); // 1. Kanoniska ingredienser for (const ing of SEED_INGREDIENTS) { await db .insert(schema.canonicalIngredients) .values({ id: ing.id, nameSv: ing.nameSv, nameEn: ing.nameEn, aliases: ing.aliases, category: ing.category, defaultUnit: ing.defaultUnit, densityGPerMl: ing.densityGPerMl ?? null, gramsPerPiece: ing.gramsPerPiece ?? null, allergens: ing.allergens, isVegan: ing.isVegan, isVegetarian: ing.isVegetarian, containsGluten: ing.containsGluten, containsLactose: ing.containsLactose, isPork: ing.isPork, isBeef: ing.isBeef, isAlcohol: ing.isAlcohol, nutritionPer100: ing.nutritionPer100, nutritionProvenance: ing.nutritionProvenance, peakSeasons: ing.peakSeasons, shelfLifeGuidance: ing.shelfLifeGuidance ?? null, defaultPriceMinorPerKg: ing.defaultPriceMinorPerKg ?? null, }) .onConflictDoUpdate({ target: schema.canonicalIngredients.id, set: { nameSv: ing.nameSv, nutritionPer100: ing.nutritionPer100, updatedAt: new Date(), }, }); } console.log(`[seed] ${SEED_INGREDIENTS.length} ingredienser`); // 1b. Ingrediensöversättningar (i18n M2 + D-031): en ur nameEn; es/it/de/fr // ur ingredient-translations.json. Rader per språk – aldrig nya kolumner. // Seed-källa = publicerad direkt; native-granskning rekommenderas före lansering. const translationRows: { ingredientId: string; languageTag: string; name: string }[] = SEED_INGREDIENTS.map((ing) => ({ ingredientId: ing.id, languageTag: "en", name: ing.nameEn })); for (const [languageTag, names] of Object.entries( INGREDIENT_TRANSLATIONS as Record>, )) { for (const [ingredientId, name] of Object.entries(names)) { translationRows.push({ ingredientId, languageTag, name }); } } for (const row of translationRows) { await db .insert(schema.ingredientTranslations) .values({ ...row, aliases: [], source: "seed", status: "published" }) .onConflictDoUpdate({ target: [ schema.ingredientTranslations.ingredientId, schema.ingredientTranslations.languageTag, ], set: { name: row.name, updatedAt: new Date() }, }); } console.log(`[seed] ${translationRows.length} ingrediensöversättningar (11 språk)`); // 1c. Enhetsetiketter per språk (i18n M2). Visning – lagring är alltid koderna. const UNIT_LABELS: Record> = { sv: { GRAM: { abbr: "g", name: "gram" }, KILOGRAM: { abbr: "kg", name: "kilogram" }, MILLILITER: { abbr: "ml", name: "milliliter" }, DECILITER: { abbr: "dl", name: "deciliter" }, LITER: { abbr: "l", name: "liter" }, TEASPOON: { abbr: "tsk", name: "tesked" }, TABLESPOON: { abbr: "msk", name: "matsked" }, CUP_US: { abbr: "cup", name: "cup (US)" }, FLUID_OUNCE_US: { abbr: "fl oz", name: "fluid ounce (US)" }, OUNCE: { abbr: "oz", name: "ounce" }, POUND: { abbr: "lb", name: "pound" }, COUNT: { abbr: "st", name: "styck" }, PORTION: { abbr: "portion", name: "portion" }, PINCH: { abbr: "krm", name: "kryddmått" }, SLICE: { abbr: "skiva", name: "skiva" }, CLOVE: { abbr: "klyfta", name: "klyfta" }, CAN: { abbr: "burk", name: "burk" }, PACKAGE: { abbr: "paket", name: "paket" }, }, en: { GRAM: { abbr: "g", name: "gram" }, KILOGRAM: { abbr: "kg", name: "kilogram" }, MILLILITER: { abbr: "ml", name: "milliliter" }, DECILITER: { abbr: "dl", name: "deciliter" }, LITER: { abbr: "l", name: "liter" }, TEASPOON: { abbr: "tsp", name: "teaspoon" }, TABLESPOON: { abbr: "tbsp", name: "tablespoon" }, CUP_US: { abbr: "cup", name: "cup (US)" }, FLUID_OUNCE_US: { abbr: "fl oz", name: "fluid ounce (US)" }, OUNCE: { abbr: "oz", name: "ounce" }, POUND: { abbr: "lb", name: "pound" }, COUNT: { abbr: "pcs", name: "pieces" }, PORTION: { abbr: "serving", name: "serving" }, PINCH: { abbr: "pinch", name: "pinch" }, SLICE: { abbr: "slice", name: "slice" }, CLOVE: { abbr: "clove", name: "clove" }, CAN: { abbr: "can", name: "can" }, PACKAGE: { abbr: "pack", name: "package" }, }, es: { GRAM: { abbr: "g", name: "gramo" }, KILOGRAM: { abbr: "kg", name: "kilogramo" }, MILLILITER: { abbr: "ml", name: "mililitro" }, DECILITER: { abbr: "dl", name: "decilitro" }, LITER: { abbr: "l", name: "litro" }, TEASPOON: { abbr: "cdta", name: "cucharadita" }, TABLESPOON: { abbr: "cda", name: "cucharada" }, CUP_US: { abbr: "taza", name: "taza (US)" }, FLUID_OUNCE_US: { abbr: "fl oz", name: "onza líquida (US)" }, OUNCE: { abbr: "oz", name: "onza" }, POUND: { abbr: "lb", name: "libra" }, COUNT: { abbr: "ud", name: "unidad" }, PORTION: { abbr: "ración", name: "ración" }, PINCH: { abbr: "pizca", name: "pizca" }, SLICE: { abbr: "rebanada", name: "rebanada" }, CLOVE: { abbr: "diente", name: "diente" }, CAN: { abbr: "lata", name: "lata" }, PACKAGE: { abbr: "paquete", name: "paquete" }, }, it: { GRAM: { abbr: "g", name: "grammo" }, KILOGRAM: { abbr: "kg", name: "chilogrammo" }, MILLILITER: { abbr: "ml", name: "millilitro" }, DECILITER: { abbr: "dl", name: "decilitro" }, LITER: { abbr: "l", name: "litro" }, TEASPOON: { abbr: "cucchiaino", name: "cucchiaino" }, TABLESPOON: { abbr: "cucchiaio", name: "cucchiaio" }, CUP_US: { abbr: "tazza", name: "tazza (US)" }, FLUID_OUNCE_US: { abbr: "fl oz", name: "oncia liquida (US)" }, OUNCE: { abbr: "oz", name: "oncia" }, POUND: { abbr: "lb", name: "libbra" }, COUNT: { abbr: "pz", name: "pezzo" }, PORTION: { abbr: "porzione", name: "porzione" }, PINCH: { abbr: "pizzico", name: "pizzico" }, SLICE: { abbr: "fetta", name: "fetta" }, CLOVE: { abbr: "spicchio", name: "spicchio" }, CAN: { abbr: "lattina", name: "lattina" }, PACKAGE: { abbr: "confezione", name: "confezione" }, }, de: { GRAM: { abbr: "g", name: "Gramm" }, KILOGRAM: { abbr: "kg", name: "Kilogramm" }, MILLILITER: { abbr: "ml", name: "Milliliter" }, DECILITER: { abbr: "dl", name: "Deziliter" }, LITER: { abbr: "l", name: "Liter" }, TEASPOON: { abbr: "TL", name: "Teelöffel" }, TABLESPOON: { abbr: "EL", name: "Esslöffel" }, CUP_US: { abbr: "Cup", name: "Cup (US)" }, FLUID_OUNCE_US: { abbr: "fl oz", name: "Flüssigunze (US)" }, OUNCE: { abbr: "oz", name: "Unze" }, POUND: { abbr: "lb", name: "Pfund" }, COUNT: { abbr: "Stk", name: "Stück" }, PORTION: { abbr: "Portion", name: "Portion" }, PINCH: { abbr: "Prise", name: "Prise" }, SLICE: { abbr: "Scheibe", name: "Scheibe" }, CLOVE: { abbr: "Zehe", name: "Zehe" }, CAN: { abbr: "Dose", name: "Dose" }, PACKAGE: { abbr: "Packung", name: "Packung" }, }, fr: { GRAM: { abbr: "g", name: "gramme" }, KILOGRAM: { abbr: "kg", name: "kilogramme" }, MILLILITER: { abbr: "ml", name: "millilitre" }, DECILITER: { abbr: "dl", name: "décilitre" }, LITER: { abbr: "l", name: "litre" }, TEASPOON: { abbr: "c. à c.", name: "cuillère à café" }, TABLESPOON: { abbr: "c. à s.", name: "cuillère à soupe" }, CUP_US: { abbr: "cup", name: "cup (US)" }, FLUID_OUNCE_US: { abbr: "fl oz", name: "once liquide (US)" }, OUNCE: { abbr: "oz", name: "once" }, POUND: { abbr: "lb", name: "livre" }, COUNT: { abbr: "pcs", name: "pièce" }, PORTION: { abbr: "portion", name: "portion" }, PINCH: { abbr: "pincée", name: "pincée" }, SLICE: { abbr: "tranche", name: "tranche" }, CLOVE: { abbr: "gousse", name: "gousse" }, CAN: { abbr: "boîte", name: "boîte" }, PACKAGE: { abbr: "paquet", name: "paquet" }, }, da: { GRAM: { abbr: "g", name: "gram" }, KILOGRAM: { abbr: "kg", name: "kilogram" }, MILLILITER: { abbr: "ml", name: "milliliter" }, DECILITER: { abbr: "dl", name: "deciliter" }, LITER: { abbr: "l", name: "liter" }, TEASPOON: { abbr: "tsk", name: "teskefuld" }, TABLESPOON: { abbr: "spsk", name: "spiseskefuld" }, CUP_US: { abbr: "cup", name: "cup (US)" }, FLUID_OUNCE_US: { abbr: "fl oz", name: "fluid ounce (US)" }, OUNCE: { abbr: "oz", name: "ounce" }, POUND: { abbr: "lb", name: "pund" }, COUNT: { abbr: "stk", name: "styk" }, PORTION: { abbr: "portion", name: "portion" }, PINCH: { abbr: "knsp", name: "knivspids" }, SLICE: { abbr: "skive", name: "skive" }, CLOVE: { abbr: "fed", name: "fed" }, CAN: { abbr: "dåse", name: "dåse" }, PACKAGE: { abbr: "pakke", name: "pakke" }, }, nb: { GRAM: { abbr: "g", name: "gram" }, KILOGRAM: { abbr: "kg", name: "kilogram" }, MILLILITER: { abbr: "ml", name: "milliliter" }, DECILITER: { abbr: "dl", name: "desiliter" }, LITER: { abbr: "l", name: "liter" }, TEASPOON: { abbr: "ts", name: "teskje" }, TABLESPOON: { abbr: "ss", name: "spiseskje" }, CUP_US: { abbr: "cup", name: "cup (US)" }, FLUID_OUNCE_US: { abbr: "fl oz", name: "fluid ounce (US)" }, OUNCE: { abbr: "oz", name: "unse" }, POUND: { abbr: "lb", name: "pund" }, COUNT: { abbr: "stk", name: "stykk" }, PORTION: { abbr: "porsjon", name: "porsjon" }, PINCH: { abbr: "knivsodd", name: "knivsodd" }, SLICE: { abbr: "skive", name: "skive" }, CLOVE: { abbr: "båt", name: "båt" }, CAN: { abbr: "boks", name: "boks" }, PACKAGE: { abbr: "pakke", name: "pakke" }, }, fi: { GRAM: { abbr: "g", name: "gramma" }, KILOGRAM: { abbr: "kg", name: "kilogramma" }, MILLILITER: { abbr: "ml", name: "millilitra" }, DECILITER: { abbr: "dl", name: "desilitra" }, LITER: { abbr: "l", name: "litra" }, TEASPOON: { abbr: "tl", name: "teelusikka" }, TABLESPOON: { abbr: "rkl", name: "ruokalusikka" }, CUP_US: { abbr: "cup", name: "cup (US)" }, FLUID_OUNCE_US: { abbr: "fl oz", name: "nesteunssi (US)" }, OUNCE: { abbr: "oz", name: "unssi" }, POUND: { abbr: "lb", name: "pauna" }, COUNT: { abbr: "kpl", name: "kappale" }, PORTION: { abbr: "annos", name: "annos" }, PINCH: { abbr: "hyppysellinen", name: "hyppysellinen" }, SLICE: { abbr: "viipale", name: "viipale" }, CLOVE: { abbr: "kynsi", name: "kynsi" }, CAN: { abbr: "tölkki", name: "tölkki" }, PACKAGE: { abbr: "paketti", name: "paketti" }, }, nl: { GRAM: { abbr: "g", name: "gram" }, KILOGRAM: { abbr: "kg", name: "kilogram" }, MILLILITER: { abbr: "ml", name: "milliliter" }, DECILITER: { abbr: "dl", name: "deciliter" }, LITER: { abbr: "l", name: "liter" }, TEASPOON: { abbr: "tl", name: "theelepel" }, TABLESPOON: { abbr: "el", name: "eetlepel" }, CUP_US: { abbr: "cup", name: "cup (US)" }, FLUID_OUNCE_US: { abbr: "fl oz", name: "fluid ounce (US)" }, OUNCE: { abbr: "oz", name: "ounce" }, POUND: { abbr: "lb", name: "pond" }, COUNT: { abbr: "st", name: "stuk" }, PORTION: { abbr: "portie", name: "portie" }, PINCH: { abbr: "snufje", name: "snufje" }, SLICE: { abbr: "plak", name: "plak" }, CLOVE: { abbr: "teentje", name: "teentje" }, CAN: { abbr: "blik", name: "blik" }, PACKAGE: { abbr: "pak", name: "pak" }, }, pl: { GRAM: { abbr: "g", name: "gram" }, KILOGRAM: { abbr: "kg", name: "kilogram" }, MILLILITER: { abbr: "ml", name: "mililitr" }, DECILITER: { abbr: "dl", name: "decylitr" }, LITER: { abbr: "l", name: "litr" }, TEASPOON: { abbr: "łyżeczka", name: "łyżeczka" }, TABLESPOON: { abbr: "łyżka", name: "łyżka" }, CUP_US: { abbr: "cup", name: "cup (US)" }, FLUID_OUNCE_US: { abbr: "fl oz", name: "uncja płynu (US)" }, OUNCE: { abbr: "oz", name: "uncja" }, POUND: { abbr: "lb", name: "funt" }, COUNT: { abbr: "szt.", name: "sztuka" }, PORTION: { abbr: "porcja", name: "porcja" }, PINCH: { abbr: "szczypta", name: "szczypta" }, SLICE: { abbr: "plaster", name: "plaster" }, CLOVE: { abbr: "ząbek", name: "ząbek" }, CAN: { abbr: "puszka", name: "puszka" }, PACKAGE: { abbr: "opakowanie", name: "opakowanie" }, }, pt: { GRAM: { abbr: "g", name: "grama" }, KILOGRAM: { abbr: "kg", name: "quilograma" }, MILLILITER: { abbr: "ml", name: "mililitro" }, DECILITER: { abbr: "dl", name: "decilitro" }, LITER: { abbr: "l", name: "litro" }, TEASPOON: { abbr: "c. chá", name: "colher de chá" }, TABLESPOON: { abbr: "c. sopa", name: "colher de sopa" }, CUP_US: { abbr: "cup", name: "cup (US)" }, FLUID_OUNCE_US: { abbr: "fl oz", name: "onça líquida (US)" }, OUNCE: { abbr: "oz", name: "onça" }, POUND: { abbr: "lb", name: "libra" }, COUNT: { abbr: "un", name: "unidade" }, PORTION: { abbr: "dose", name: "dose" }, PINCH: { abbr: "pitada", name: "pitada" }, SLICE: { abbr: "fatia", name: "fatia" }, CLOVE: { abbr: "dente", name: "dente" }, CAN: { abbr: "lata", name: "lata" }, PACKAGE: { abbr: "embalagem", name: "embalagem" }, }, }; let unitLabelCount = 0; for (const [languageTag, labels] of Object.entries(UNIT_LABELS)) { for (const [unitCode, label] of Object.entries(labels)) { await db .insert(schema.unitTranslations) .values({ unitCode: unitCode as (typeof schema.unitTranslations.$inferInsert)["unitCode"], languageTag, abbreviation: label.abbr, name: label.name, }) .onConflictDoUpdate({ target: [schema.unitTranslations.unitCode, schema.unitTranslations.languageTag], set: { abbreviation: label.abbr, name: label.name }, }); unitLabelCount++; } } console.log(`[seed] ${unitLabelCount} enhetsetiketter (12 språk)`); // 1d. Marknadsprofiler för näringsvisning + allergenframhävning (i18n M6). const NUTRITION_PROFILES = [ { regionCode: "EU", energyDisplay: "both" as const, saltDisplay: "salt" as const, energyLabelKey: "nutrition.energy", }, { regionCode: "SE", energyDisplay: "both" as const, saltDisplay: "salt" as const, energyLabelKey: "nutrition.energy", }, { regionCode: "GB", energyDisplay: "both" as const, saltDisplay: "salt" as const, energyLabelKey: "nutrition.energy", }, { regionCode: "US", energyDisplay: "kcal" as const, saltDisplay: "sodium" as const, energyLabelKey: "nutrition.calories", }, { regionCode: "CA", energyDisplay: "kcal" as const, saltDisplay: "sodium" as const, energyLabelKey: "nutrition.calories", }, ]; for (const p of NUTRITION_PROFILES) { await db .insert(schema.nutritionDisplayProfiles) .values(p) .onConflictDoUpdate({ target: schema.nutritionDisplayProfiles.regionCode, set: { ...p, updatedAt: new Date() }, }); } // EU/EES + GB: 14 deklarationspliktiga. US (FDA Big 9): utan selleri/senap/lupin/ // sulfiter/blötdjur. CA (Health Canada): som EU utan selleri och lupin. const EU14 = [ "gluten", "crustaceans", "eggs", "fish", "peanuts", "soy", "milk", "tree_nuts", "celery", "mustard", "sesame", "sulphites", "lupin", "molluscs", ] as const; const US9 = [ "gluten", "crustaceans", "eggs", "fish", "peanuts", "soy", "milk", "tree_nuts", "sesame", ] as const; const CA = [ "gluten", "crustaceans", "eggs", "fish", "peanuts", "soy", "milk", "tree_nuts", "mustard", "sesame", "sulphites", "molluscs", ] as const; const MARKET_ALLERGENS: Record = { EU: EU14, SE: EU14, GB: EU14, US: US9, CA, }; let ruleCount = 0; for (const [regionCode, allergens] of Object.entries(MARKET_ALLERGENS)) { for (const allergen of allergens) { await db .insert(schema.allergenMarketRules) .values({ regionCode, allergen: allergen as (typeof EU14)[number], mustHighlight: true }) .onConflictDoNothing(); ruleCount++; } } console.log(`[seed] ${NUTRITION_PROFILES.length} näringsprofiler + ${ruleCount} allergenregler`); // Uppslagskarta för beräkningar const ingredientMap = new Map( SEED_INGREDIENTS.map((i) => [ i.id, { nutritionPer100: i.nutritionPer100, densityGPerMl: i.densityGPerMl ?? null, gramsPerPiece: i.gramsPerPiece ?? null, allergens: i.allergens, priceMinorPerKg: i.defaultPriceMinorPerKg ?? null, }, ]), ); // 2. Source registry-post för redaktionella recept (spec §15) const [registry] = await db .insert(schema.recipeSourceRegistry) .values({ sourceName: `${BRAND.name} Redaktion`, license: "proprietary", rightToStore: true, rightToModify: true, rightToDisplay: true, attributionRequired: false, commercialUse: true, notes: "Egna originalrecept. Fullständiga rättigheter.", }) .returning(); // 3. Recept i två pass (varianter behöver grundreceptets id) const slugToId = new Map(); const basePass = SEED_RECIPES.filter((r) => !r.variantOfSlug); const variantPass = SEED_RECIPES.filter((r) => r.variantOfSlug); for (const recipe of [...basePass, ...variantPass]) { const id = await insertRecipe(db, recipe, ingredientMap, registry?.id ?? null, slugToId); slugToId.set(recipe.slug, id); } console.log(`[seed] ${SEED_RECIPES.length} recept`); // 4. Substitutioner for (const sub of SEED_SUBSTITUTIONS) { await db .insert(schema.substitutions) .values({ id: sub.id, fromIngredientId: sub.from, toIngredientId: sub.to, ratio: sub.ratio, instructionsSv: sub.instructionsSv ?? null, bestFor: sub.bestFor, notRecommendedFor: sub.notRecommendedFor, flavorImpactSv: sub.flavorImpactSv ?? null, textureImpactSv: sub.textureImpactSv ?? null, priority: sub.priority ?? 0, }) .onConflictDoNothing(); } console.log(`[seed] ${SEED_SUBSTITUTIONS.length} substitutioner`); // 5. Säsongsevents for (const ev of SEED_SEASON_EVENTS) { await db .insert(schema.seasonEvents) .values({ id: ev.id, slug: ev.slug, nameSv: ev.nameSv, market: ev.market, dateRule: ev.dateRule, leadDays: ev.leadDays, foodTags: ev.foodTags, recipeSlugs: ev.recipeSlugs, priority: ev.priority, active: true, }) .onConflictDoNothing(); } console.log(`[seed] ${SEED_SEASON_EVENTS.length} säsongsevents`); // 6. Feature flags – Launch Core på, Advanced bakom flaggor (spec §1, Del 3) const flags: Array<{ key: string; enabled: boolean; descriptionSv: string }> = [ { key: "community_publishing", enabled: false, descriptionSv: "Publicering av användarrecept" }, { key: "week_plan_ai", enabled: true, descriptionSv: "AI-assisterad veckoplan" }, { key: "plate_photo_analysis", enabled: true, descriptionSv: "Tallriksfoto och portionsuppskattning", }, { key: "receipt_scanning", enabled: true, descriptionSv: "Kvittoskanning" }, { key: "pantry_forecast", enabled: false, descriptionSv: "Pantry Forecast-notiser" }, { key: "health_integration", enabled: false, descriptionSv: "Apple Health / Health Connect" }, { key: "weather_context", enabled: false, descriptionSv: "Väderbaserade förslag" }, { key: "creator_rankings", enabled: false, descriptionSv: "Topplistor och gamification" }, { key: "food_memories", enabled: false, descriptionSv: "Långsiktiga matminnen" }, { key: "voice_input", enabled: false, descriptionSv: "Röstinmatning" }, { key: "ai_rerank", enabled: false, descriptionSv: "AAMOS-omrankning av rekommendationer" }, ]; for (const flag of flags) { await db .insert(schema.featureFlags) .values({ key: flag.key, enabled: flag.enabled, descriptionSv: flag.descriptionSv, rolloutPercent: 100, }) .onConflictDoNothing(); } console.log(`[seed] ${flags.length} feature flags`); console.log("[seed] Klart."); await pool.end(); } type IngredientCalcInfo = { nutritionPer100: IngredientNutritionSource["nutritionPer100"]; densityGPerMl: number | null; gramsPerPiece: number | null; allergens: Allergen[]; priceMinorPerKg: number | null; }; async function insertRecipe( db: ReturnType["db"], recipe: SeedRecipe, ingredientMap: Map, sourceRegistryId: string | null, slugToId: Map, ): Promise { // Deterministisk näringsberäkning const sources = new Map(); for (const ri of recipe.ingredients) { const info = ingredientMap.get(ri.ing); if (!info) throw new Error(`Recept ${recipe.slug}: okänd ingrediens ${ri.ing}`); sources.set(ri.ing, { nutritionPer100: info.nutritionPer100, densityGPerMl: info.densityGPerMl, gramsPerPiece: info.gramsPerPiece, }); } const calcIngredients = recipe.ingredients.map((ri) => ({ canonicalIngredientId: ri.ing, quantity: ri.qty, unit: ri.unit, optional: ri.optional ?? false, })); const nutrition = computeRecipeNutrition(calcIngredients, recipe.portions, sources); if (nutrition.uncomputableIngredientIds.length > 0) { throw new Error( `Recept ${recipe.slug}: kunde inte beräkna näring för ${nutrition.uncomputableIngredientIds.join(", ")}`, ); } // Deterministisk allergenhärledning const allergens = new Set(); for (const ri of recipe.ingredients) { if (ri.optional) continue; for (const a of ingredientMap.get(ri.ing)?.allergens ?? []) allergens.add(a); } // Kostnadsuppskattning ur schablonpriser let costTotal = 0; let costComputable = true; for (const ri of recipe.ingredients) { if (ri.optional) continue; const info = ingredientMap.get(ri.ing)!; const grams = toGrams(ri.qty, ri.unit, { densityGPerMl: info.densityGPerMl, gramsPerPiece: info.gramsPerPiece, }); if (grams == null || info.priceMinorPerKg == null) { costComputable = false; continue; } costTotal += (grams / 1000) * info.priceMinorPerKg; } // costTotal är i minor units (priceMinorPerKg) – avrunda till heltal per portion. const costPerPortion = costComputable && recipe.portions > 0 ? Math.round(costTotal / recipe.portions) : null; const dna: RecipeDNA = { cuisine: recipe.cuisine, ...(recipe.dnaProtein ? { protein: recipe.dnaProtein } : {}), ...(recipe.dnaCarb ? { carbohydrate: recipe.dnaCarb } : {}), vegetables: recipe.dnaVegetables, flavorProfile: recipe.dnaFlavor, spiceLevel: recipe.spiceLevel, method: recipe.methods[0] ?? "stovetop", timeMinutes: recipe.prepMin + recipe.cookMin, calories: nutrition.perPortion.kcal, proteinGrams: Math.round(nutrition.perPortion.proteinG), }; const variantOfRecipeId = recipe.variantOfSlug ? (slugToId.get(recipe.variantOfSlug) ?? null) : null; const [row] = await db .insert(schema.recipes) .values({ slug: recipe.slug, titleSv: recipe.titleSv, descriptionSv: recipe.descriptionSv, country: recipe.country ?? null, cuisine: recipe.cuisine, mealTypes: recipe.mealTypes, tags: recipe.tags, methods: recipe.methods, equipment: recipe.equipment, difficulty: recipe.difficulty, prepTimeMinutes: recipe.prepMin, cookTimeMinutes: recipe.cookMin, totalTimeMinutes: recipe.prepMin + recipe.cookMin, portions: recipe.portions, nutritionPerPortion: nutrition.perPortion, allergens: [...allergens].sort(), spiceLevel: recipe.spiceLevel, estimatedCostMinorPerPortion: costPerPortion, storageGuidanceSv: recipe.storageGuidanceSv ?? null, mealPrepFriendly: recipe.mealPrepFriendly, freezerFriendly: recipe.freezerFriendly, peakSeasons: recipe.peakSeasons, holidayTags: recipe.holidayTags, dna, variantType: recipe.variantType ?? "standard", variantOfRecipeId, status: "published", verificationStatus: "editorial", sourceType: "own_editorial", sourceRegistryId, creatorDisplayName: `${BRAND.name} Redaktion`, }) .onConflictDoUpdate({ target: schema.recipes.slug, set: { nutritionPerPortion: nutrition.perPortion, allergens: [...allergens].sort(), dna, updatedAt: new Date(), }, }) .returning(); const recipeId = row!.id; // Ingredienser + steg: rensa och skriv om (idempotent seed) const { eq } = await import("drizzle-orm"); await db.delete(schema.recipeIngredients).where(eq(schema.recipeIngredients.recipeId, recipeId)); await db.delete(schema.recipeSteps).where(eq(schema.recipeSteps.recipeId, recipeId)); await db.insert(schema.recipeIngredients).values( recipe.ingredients.map((ri, idx) => ({ recipeId, canonicalIngredientId: ri.ing, displayNameSv: ri.nameSv, quantity: ri.qty, unit: ri.unit, note: ri.note ?? null, optional: ri.optional ?? false, groupName: ri.group ?? null, sortOrder: idx, })), ); await db.insert(schema.recipeSteps).values( recipe.steps.map((step, idx) => ({ recipeId, stepNumber: idx + 1, instructionSv: step.text, timerSeconds: step.timerSeconds ?? null, temperatureC: step.temperatureC ?? null, tip: step.tip ?? null, })), ); return recipeId; } main().catch((err) => { console.error("[seed] MISSLYCKADES:", err); process.exit(1); });