751 lines
27 KiB
TypeScript
751 lines
27 KiB
TypeScript
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<string, Record<string, string>>,
|
||
)) {
|
||
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<string, Record<string, { abbr: string; name: string }>> = {
|
||
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<string, readonly string[]> = {
|
||
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<string, string>();
|
||
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<typeof createDatabase>["db"],
|
||
recipe: SeedRecipe,
|
||
ingredientMap: Map<string, IngredientCalcInfo>,
|
||
sourceRegistryId: string | null,
|
||
slugToId: Map<string, string>,
|
||
): Promise<string> {
|
||
// Deterministisk näringsberäkning
|
||
const sources = new Map<string, IngredientNutritionSource>();
|
||
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<Allergen>();
|
||
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);
|
||
});
|