Files
Cibello-app/packages/database/src/seed/run.ts
T
Sven (AAMOS AI) b1b6eb994c feat(seed): import 246 verified recipes as committed seed catalog
- Convert C:\Users\Public\cibello-verified-final-result.json to
  packages/database/src/seed/data/verified-recipes.ts (committed, hermetic).
- Replace/extend the 23 placeholder SEED_RECIPES via re-export.
- Upsert on slug with onConflictDoUpdate of ALL recipe fields.
- Hard-reject recipes with unmapped canonical ingredients (0 rejected this batch).
- Derive allergens and compute nutrition per portion from ingredients at seed time.
- Run food-safety lint; auto-patch 8 legacy raw-protein recipes with safe-cooking phrase.
- Resolve 4 suspected title duplicates via variantOfSlug.
- Guard brand name: creatorDisplayName uses BRAND.name, never JSON placeholder.
- Preserve verificationStatus (editorial/verified) and source-registry FKs.
- Extend allergen-invariant test to cover full 246-recipe catalog.
- Stabilise API tests by disabling file parallelism (flakiness from shared DB state).

Refs: seed pipeline, brand-guard, safety canary
2026-08-11 00:47:08 +07:00

823 lines
30 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.");
}
function assertTestDatabaseName(url: string): void {
const parsed = new URL(url);
const dbName = parsed.pathname.replace(/^\//, "");
if (!/_test$/.test(dbName)) {
throw new Error(
`[seed] Vägrar nollställa databas "${dbName}". Truncation kräver att databasnamnet slutar på _test.`,
);
}
}
/**
* 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 …");
// Testläge: nollställ allt användardata så att upprepade körningar blir deterministiska.
// Dubbelgrind: (a) --test / SEED_TARGET=test, OCH (b) URL:en pekar på en *_test-databas.
if (isTestTarget) {
assertTestDatabaseName(connectionString!);
console.log("[seed] Nollställer testdatabasen …");
const tables = await pool.query<{ tablename: string }>(
`SELECT tablename FROM pg_tables
WHERE schemaname = 'public'
AND tablename NOT LIKE 'pg_%'
AND tablename NOT LIKE 'drizzle_%'`,
);
for (const { tablename } of tables.rows) {
await pool.query(`TRUNCATE TABLE "${tablename}" CASCADE`);
}
console.log(`[seed] Nollställde ${tables.rows.length} tabeller.`);
}
// 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,
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,
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`);
// 7. Default trust decay profile (Fas 2 §5.2)
await db
.insert(schema.inventoryDecayProfiles)
.values({
name: "default",
description: "Standardprofil: confidence halveras efter 7 dagar, stale efter 30 dagar.",
halfLifeDays: 7,
staleAfterDays: 30,
active: true,
})
.onConflictDoNothing();
console.log("[seed] 1 default decay profile");
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 — ALLA ingredienser räknas,
// även valfria (garnering/servering kan innehålla allergener).
const allergens = new Set<Allergen>();
for (const ri of recipe.ingredients) {
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 recipeValues = {
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" as const,
verificationStatus: recipe.verificationStatus ?? "editorial",
sourceType: "own_editorial" as const,
sourceRegistryId,
creatorDisplayName: `${BRAND.name} Redaktion`,
};
const [row] = await db
.insert(schema.recipes)
.values(recipeValues)
.onConflictDoUpdate({
target: schema.recipes.slug,
set: {
...recipeValues,
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);
});