Initial commit (unpacked platform)

This commit is contained in:
Sven (AAMOS AI)
2026-08-05 19:21:11 +07:00
commit ac5340195a
314 changed files with 57584 additions and 0 deletions
+750
View File
@@ -0,0 +1,750 @@
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.12) 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);
});