Files
Cibello-app/packages/database/src/seed/run.ts
T

765 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
*
* `--test` eller `SEED_TARGET=test` kör mot TEST_DATABASE_URL.
* Utan flagga används ENDAST DATABASE_URL deploy seedar aldrig testdatabasen.
*/
const isTestTarget = process.argv.includes("--test") || process.env.SEED_TARGET === "test";
const connectionString = isTestTarget ? process.env.TEST_DATABASE_URL : process.env.DATABASE_URL;
if (!connectionString) {
if (isTestTarget) {
throw new Error("Sätt TEST_DATABASE_URL för test-seed.");
}
throw new Error("Sätt DATABASE_URL innan seed körs.");
}
/**
* Näring, allergener och kostnad för recepten beräknas HÄR, deterministiskt,
* ur ingredienserna (spec §61.12) aldrig hårdkodade och aldrig från AI.
*/
async function main() {
const { db, pool } = createDatabase(connectionString);
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);
});