import type { FastifyInstance } from "fastify"; import { and, desc, eq, gt, ilike, inArray, isNull, lte, or, sql } from "drizzle-orm"; import { markMilestone, schema } from "@app/database"; import { cookRecipeInputSchema, createUserRecipeInputSchema, idParamSchema, rateRecipeInputSchema, recipeQuerySchema, substitutionQuerySchema, } from "@app/validation"; import { checkRecipeSafety, deriveRecipeAllergens, scaleIngredients, type IngredientSafetyInfo, } from "@app/recipe-engine"; import { allocateFefo, classifyExpiry } from "@app/inventory-engine"; import { computeRecipeNutrition, scaleNutrition } from "@app/nutrition-engine"; import { errors, parse } from "../lib/errors.js"; import { loadLocalePreferences } from "../lib/localeContext.js"; import { languageCandidates, resolveIngredientNames, resolveRecipeTranslation, userLanguageTag, } from "../lib/contentLanguage.js"; import { emitEvent, getActiveHouseholdId, requireActiveHousehold, todayIso, } from "../lib/helpers.js"; import { requireFeature } from "../lib/entitlements.js"; import { completeCookingSession } from "../lib/cooking.js"; /** Recept: sök, detalj, betyg, favoriter, "jag har lagat", substitutioner, användarrecept. */ export async function recipeRoutes(app: FastifyInstance) { const auth = { preHandler: [app.authenticate] }; app.get("/v1/recipes", auth, async (req) => { const q = parse(recipeQuerySchema, req.query); const conditions = [eq(schema.recipes.status, "published")]; if (q.search) { conditions.push( or( ilike(schema.recipes.titleSv, `%${q.search}%`), ilike(schema.recipes.descriptionSv, `%${q.search}%`), )!, ); } if (q.cuisine) conditions.push(eq(schema.recipes.cuisine, q.cuisine)); if (q.mealType) conditions.push(sql`${q.mealType} = ANY(${schema.recipes.mealTypes})`); if (q.tags) for (const tag of q.tags) conditions.push(sql`${tag} = ANY(${schema.recipes.tags})`); if (q.method) conditions.push(sql`${q.method} = ANY(${schema.recipes.methods})`); if (q.maxTotalMinutes) conditions.push(lte(schema.recipes.totalTimeMinutes, q.maxTotalMinutes)); if (q.difficulty) conditions.push(eq(schema.recipes.difficulty, q.difficulty)); if (q.creatorUserId) conditions.push(eq(schema.recipes.creatorUserId, q.creatorUserId)); if (q.maxKcalPerPortion) { conditions.push( sql`(${schema.recipes.nutritionPerPortion}->>'kcal')::float <= ${q.maxKcalPerPortion}`, ); } if (q.minProteinPerPortion) { conditions.push( sql`(${schema.recipes.nutritionPerPortion}->>'proteinG')::float >= ${q.minProteinPerPortion}`, ); } if (q.maxCostMinorPerPortion) { conditions.push(lte(schema.recipes.estimatedCostMinorPerPortion, q.maxCostMinorPerPortion)); } // Deterministisk allergifiltrering på databasnivå (spec §61.2) if (q.excludeAllergens) { for (const allergen of q.excludeAllergens) { conditions.push(sql`NOT (${allergen} = ANY(${schema.recipes.allergens}))`); } } const orderBy = q.sort === "rating" ? desc(schema.recipes.ratingAverage) : q.sort === "cooked" ? desc(schema.recipes.cookCount) : q.sort === "newest" ? desc(schema.recipes.createdAt) : q.sort === "time" ? schema.recipes.totalTimeMinutes : q.sort === "cost" ? schema.recipes.estimatedCostMinorPerPortion : desc(schema.recipes.cookCount); const rows = await app.db .select({ id: schema.recipes.id, slug: schema.recipes.slug, titleSv: schema.recipes.titleSv, descriptionSv: schema.recipes.descriptionSv, cuisine: schema.recipes.cuisine, mealTypes: schema.recipes.mealTypes, tags: schema.recipes.tags, totalTimeMinutes: schema.recipes.totalTimeMinutes, portions: schema.recipes.portions, nutritionPerPortion: schema.recipes.nutritionPerPortion, allergens: schema.recipes.allergens, spiceLevel: schema.recipes.spiceLevel, estimatedCostMinorPerPortion: schema.recipes.estimatedCostMinorPerPortion, costCurrency: sql`'SEK'`, difficulty: schema.recipes.difficulty, imageUrls: schema.recipes.imageUrls, ratingAverage: schema.recipes.ratingAverage, ratingCount: schema.recipes.ratingCount, cookCount: schema.recipes.cookCount, verificationStatus: schema.recipes.verificationStatus, creatorDisplayName: schema.recipes.creatorDisplayName, variantType: schema.recipes.variantType, }) .from(schema.recipes) .where(and(...conditions)) .orderBy(orderBy) .limit(q.limit) .offset(q.offset); // Titlar på användarens språk där publicerad översättning finns (i18n M3). const languageTag = await userLanguageTag(app.db, req.userId); const candidates = languageCandidates(languageTag); let titleMap = new Map(); if (!candidates.includes("sv") && rows.length > 0) { const translations = await app.db .select({ recipeId: schema.recipeTranslations.recipeId, languageTag: schema.recipeTranslations.languageTag, title: schema.recipeTranslations.title, }) .from(schema.recipeTranslations) .where( and( inArray( schema.recipeTranslations.recipeId, rows.map((r) => r.id), ), inArray(schema.recipeTranslations.languageTag, candidates), eq(schema.recipeTranslations.status, "published"), ), ); for (const candidate of [...candidates].reverse()) { for (const tr of translations) if (tr.languageTag === candidate) titleMap.set(tr.recipeId, tr.title); } } return { recipes: rows.map((r) => ({ ...r, title: titleMap.get(r.id) ?? r.titleSv })), language: candidates.includes("sv") ? "sv" : languageTag, }; }); app.get("/v1/recipes/:id", auth, async (req) => { const { id } = parse(idParamSchema, req.params); const recipe = await loadFullRecipe(app, id); // Personlig säkerhetskontroll – deterministisk (spec §61.2). const [prefs] = await app.db .select() .from(schema.userPreferences) .where(eq(schema.userPreferences.userId, req.userId)) .limit(1); let safety: { safe: boolean; violations: unknown[] } = { safe: true, violations: [] }; if (prefs) { const info = await ingredientSafetyMap( app, recipe.ingredients.map((i) => i.canonicalIngredientId), ); const violations = checkRecipeSafety( { ingredients: recipe.ingredients.map((i) => ({ canonicalIngredientId: i.canonicalIngredientId, optional: i.optional, })), spiceLevel: recipe.spiceLevel, }, { allergens: prefs.allergens, dietPattern: prefs.dietPattern, religiousRule: prefs.religiousRule, avoidIngredientIds: prefs.avoidIngredientIds, spiceLevelMax: prefs.spiceLevelMax, }, info, ); safety = { safe: !violations.some((v) => v.severity === "blocker"), violations }; } // Varianter (spec §16) const variants = await app.db .select({ id: schema.recipes.id, titleSv: schema.recipes.titleSv, variantType: schema.recipes.variantType, }) .from(schema.recipes) .where( and( or( eq(schema.recipes.variantOfRecipeId, id), recipe.variantOfRecipeId ? eq(schema.recipes.id, recipe.variantOfRecipeId) : sql`false`, ), eq(schema.recipes.status, "published"), ), ); const [myRating] = await app.db .select() .from(schema.recipeRatings) .where( and(eq(schema.recipeRatings.recipeId, id), eq(schema.recipeRatings.userId, req.userId)), ) .limit(1); const [favorite] = await app.db .select() .from(schema.recipeFavorites) .where( and(eq(schema.recipeFavorites.recipeId, id), eq(schema.recipeFavorites.userId, req.userId)), ) .limit(1); // Innehållsspråk (i18n-spec §13–14): publicerad översättning om användarens // språk inte är svenska; annars svensk källa. Struktur ändras aldrig. const householdId = await getActiveHouseholdId(app.db, req.userId); if (householdId) { void markMilestone(app.db, householdId, "firstRecipeRecommendationViewedAt"); } const languageTag = await userLanguageTag(app.db, req.userId); const translation = await resolveRecipeTranslation(app.db, id, languageTag); const ingredientNames = await resolveIngredientNames( app.db, recipe.ingredients.map((i) => i.canonicalIngredientId), languageTag, ); return { ...recipe, language: translation?.language ?? "sv", title: translation?.title ?? recipe.titleSv, description: translation ? translation.description : recipe.descriptionSv, storageGuidance: translation ? translation.storageGuidance : recipe.storageGuidanceSv, ingredients: recipe.ingredients.map((i) => ({ ...i, displayName: ingredientNames.get(i.canonicalIngredientId) ?? i.displayNameSv, })), steps: recipe.steps.map((s) => { const ts = translation?.steps.get(s.stepNumber); return { ...s, instruction: ts?.instruction ?? s.instructionSv, tip: ts?.tip ?? s.tip }; }), safety, variants, myRating: myRating ?? null, isFavorite: Boolean(favorite), }; }); /** Skala recept (Cooking Mode: "skala till sex personer"). */ app.get("/v1/recipes/:id/scaled", auth, async (req) => { const { id } = parse(idParamSchema, req.params); const portions = Number((req.query as { portions?: string }).portions ?? 4); if (!Number.isInteger(portions) || portions < 1 || portions > 24) { throw errors.badRequest("portions måste vara 1–24."); } const recipe = await loadFullRecipe(app, id); const scaled = scaleIngredients( recipe.ingredients.map((i) => ({ canonicalIngredientId: i.canonicalIngredientId, displayNameSv: i.displayNameSv, quantity: i.quantity, unit: i.unit, optional: i.optional, })), recipe.portions, portions, ); return { recipeId: id, portions, ingredients: scaled, nutritionPerPortion: recipe.nutritionPerPortion, }; }); app.post("/v1/recipes/:id/rate", auth, async (req) => { const { id } = parse(idParamSchema, req.params); const input = parse(rateRecipeInputSchema, req.body); await loadFullRecipe(app, id); await app.db .insert(schema.recipeRatings) .values({ recipeId: id, userId: req.userId, stars: input.stars, feedbackTags: input.feedbackTags, comment: input.comment ?? null, }) .onConflictDoUpdate({ target: [schema.recipeRatings.recipeId, schema.recipeRatings.userId], set: { stars: input.stars, feedbackTags: input.feedbackTags, comment: input.comment ?? null, updatedAt: new Date(), }, }); // Uppdatera aggregat const [agg] = await app.db .select({ avg: sql`avg(${schema.recipeRatings.stars})`, count: sql`count(*)`, }) .from(schema.recipeRatings) .where(eq(schema.recipeRatings.recipeId, id)); await app.db .update(schema.recipes) .set({ ratingAverage: agg ? Number(agg.avg) : null, ratingCount: agg ? Number(agg.count) : 0, }) .where(eq(schema.recipes.id, id)); // Smaksignaler ur feedback (spec §30) – explicit användarsignal. const tagToAxis: Record< string, { axis: "spice" | "salt" | "acid" | "creaminess"; dir: number } > = { too_spicy: { axis: "spice", dir: -1 }, too_mild: { axis: "spice", dir: 1 }, too_salty: { axis: "salt", dir: -1 }, too_sour: { axis: "acid", dir: -1 }, too_little_sauce: { axis: "creaminess", dir: 1 }, }; for (const tag of input.feedbackTags) { const mapping = tagToAxis[tag]; if (mapping) { await app.db.insert(schema.tasteSignals).values({ userId: req.userId, axis: mapping.axis, direction: mapping.dir, strength: 0.7, origin: "user_stated", refRecipeId: id, }); } } await emitEvent(app.db, { type: "RECIPE_RATED", payload: { recipeId: id, stars: input.stars, feedbackTags: input.feedbackTags }, userId: req.userId, correlationId: req.correlationId, }); return { ok: true }; }); app.post("/v1/recipes/:id/favorite", auth, async (req) => { const { id } = parse(idParamSchema, req.params); await app.db .insert(schema.recipeFavorites) .values({ recipeId: id, userId: req.userId }) .onConflictDoNothing(); await app.db .update(schema.recipes) .set({ favoriteCount: sql`${schema.recipes.favoriteCount} + 1` }) .where(eq(schema.recipes.id, id)); const householdId = await getActiveHouseholdId(app.db, req.userId); if (householdId) { void markMilestone(app.db, householdId, "firstRecipeSavedOrStartedAt"); } return { ok: true }; }); app.delete("/v1/recipes/:id/favorite", auth, async (req) => { const { id } = parse(idParamSchema, req.params); await app.db .delete(schema.recipeFavorites) .where( and(eq(schema.recipeFavorites.recipeId, id), eq(schema.recipeFavorites.userId, req.userId)), ); await app.db .update(schema.recipes) .set({ favoriteCount: sql`GREATEST(${schema.recipes.favoriteCount} - 1, 0)` }) .where(eq(schema.recipes.id, id)); return { ok: true }; }); app.get("/v1/recipes/favorites/mine", auth, async (req) => { const rows = await app.db .select({ id: schema.recipes.id, titleSv: schema.recipes.titleSv, totalTimeMinutes: schema.recipes.totalTimeMinutes, nutritionPerPortion: schema.recipes.nutritionPerPortion, imageUrls: schema.recipes.imageUrls, }) .from(schema.recipeFavorites) .innerJoin(schema.recipes, eq(schema.recipeFavorites.recipeId, schema.recipes.id)) .where(eq(schema.recipeFavorites.userId, req.userId)) .orderBy(desc(schema.recipeFavorites.createdAt)); return { recipes: rows }; }); /** * "Jag har lagat detta" (spec §23): förslag på lagerdragning (FEFO), * måltidslogg per ätare, matlådor, events. Kärnflödet i hela appen. */ app.post("/v1/recipes/:id/cook", auth, async (req) => { const { id } = parse(idParamSchema, req.params); const input = parse(cookRecipeInputSchema, req.body); await loadFullRecipe(app, id); const householdId = await requireActiveHousehold(app.db, req.userId); // Bakåtkompatibel shortcut: skapa session + complete direkt. const [session] = await app.db .insert(schema.cookingSessions) .values({ recipeId: id, householdId, startedByUserId: req.userId, status: "started", plannedPortions: input.portionsCooked, plannedMealType: input.mealType, startedAt: new Date(), }) .returning(); const result = await completeCookingSession(app, session!, req.userId, input, req.correlationId, { emitStartedEvent: true, }); return { sessionId: session!.id, ...result }; }); /** Substitutionsförslag för en ingrediens (spec §20). */ app.get("/v1/substitutions", auth, async (req) => { const q = parse(substitutionQuerySchema, req.query); const subs = await app.db .select({ sub: schema.substitutions, toName: schema.canonicalIngredients.nameSv, }) .from(schema.substitutions) .innerJoin( schema.canonicalIngredients, eq(schema.substitutions.toIngredientId, schema.canonicalIngredients.id), ) .where(eq(schema.substitutions.fromIngredientId, q.fromIngredientId)) .orderBy(desc(schema.substitutions.priority)); return { substitutions: subs.map((s) => ({ ...s.sub, toNameSv: s.toName, contextWarning: q.context && s.sub.notRecommendedFor.includes(q.context) ? `Rekommenderas inte för ${q.context}.` : null, })), }; }); /** * Ingrediens-sök (manuell registrering, spec §9; i18n M2). * Söker i svenska namn/alias OCH i publicerade översättningar för * användarens språk; svaret bär namn upplöst till användarens språk. */ app.get("/v1/ingredients", auth, async (req) => { const search = String((req.query as { search?: string }).search ?? "").trim(); const languageTag = await userLanguageTag(app.db, req.userId); const candidates = languageCandidates(languageTag); // i18n M7: accentokänsligt (unaccent) + tolerant mot stavfel (pg_trgm). // "creme" hittar "crème fraiche", "jordgubar" hittar "jordgubbar". const pattern = `%${search}%`; const translationMatch = sql`EXISTS ( SELECT 1 FROM ingredient_translations it WHERE it.ingredient_id = ${schema.canonicalIngredients.id} AND it.status = 'published' AND it.language_tag IN ${candidates} AND (unaccent(it.name) ILIKE unaccent(${pattern}) OR similarity(it.name, ${search}) > 0.35 OR EXISTS (SELECT 1 FROM unnest(it.aliases) ta WHERE unaccent(ta) ILIKE unaccent(${pattern}))) )`; const conditions = search ? or( sql`unaccent(${schema.canonicalIngredients.nameSv}) ILIKE unaccent(${pattern})`, sql`similarity(${schema.canonicalIngredients.nameSv}, ${search}) > 0.35`, sql`EXISTS (SELECT 1 FROM unnest(${schema.canonicalIngredients.aliases}) a WHERE unaccent(a) ILIKE unaccent(${pattern}))`, ...(candidates.includes("sv") ? [] : [translationMatch]), ) : undefined; const rows = await app.db .select({ id: schema.canonicalIngredients.id, nameSv: schema.canonicalIngredients.nameSv, category: schema.canonicalIngredients.category, defaultUnit: schema.canonicalIngredients.defaultUnit, allergens: schema.canonicalIngredients.allergens, }) .from(schema.canonicalIngredients) .where(conditions) .orderBy( search ? sql`similarity(${schema.canonicalIngredients.nameSv}, ${search}) DESC, ${schema.canonicalIngredients.nameSv}` : schema.canonicalIngredients.nameSv, ) .limit(30); const names = await resolveIngredientNames( app.db, rows.map((r) => r.id), languageTag, ); return { ingredients: rows.map((r) => ({ ...r, name: names.get(r.id) ?? r.nameSv })), language: candidates.includes("sv") ? "sv" : languageTag, }; }); /** * Marknadsprofil för näringsvisning + allergenframhävning (i18n M6). * Fallback: EU. Styr endast VISNING – säkerhetsfiltrering per användare * (spec §61.2) påverkas aldrig av marknadsprofilen. */ app.get("/v1/i18n/nutrition-profile", auth, async (req) => { const requested = String((req.query as { region?: string }).region ?? "").toUpperCase(); const region = requested || (await loadLocalePreferences(app.db, req.userId)).regionCode.toUpperCase(); const [profile] = (await app.db .select() .from(schema.nutritionDisplayProfiles) .where(eq(schema.nutritionDisplayProfiles.regionCode, region)) .limit(1)) ?? []; const [fallback] = profile ? [profile] : await app.db .select() .from(schema.nutritionDisplayProfiles) .where(eq(schema.nutritionDisplayProfiles.regionCode, "EU")) .limit(1); const effective = fallback!; const allergens = await app.db .select({ allergen: schema.allergenMarketRules.allergen }) .from(schema.allergenMarketRules) .where(eq(schema.allergenMarketRules.regionCode, effective.regionCode)); return { regionCode: effective.regionCode, requestedRegion: region, energyDisplay: effective.energyDisplay, saltDisplay: effective.saltDisplay, energyLabelKey: effective.energyLabelKey, highlightAllergens: allergens.map((a) => a.allergen).sort(), }; }); /** Enhetsetiketter per språk (i18n M2) – för klienter som inte vill hårdkoda. */ app.get("/v1/i18n/units", auth, async (req) => { const languageTag = String((req.query as { languageTag?: string }).languageTag ?? "sv"); const candidates = languageCandidates(languageTag); const rows = await app.db .select() .from(schema.unitTranslations) .where(inArray(schema.unitTranslations.languageTag, candidates)); const byUnit = new Map(); for (const candidate of [...candidates].reverse()) for (const r of rows) if (r.languageTag === candidate) byUnit.set(r.unitCode, r); return { languageTag, units: [...byUnit.values()].map((r) => ({ unitCode: r.unitCode, abbreviation: r.abbreviation, name: r.name, })), }; }); /** * Användarrecept (spec §35): strukturerat direkt, eller fritext som AAMOS * strukturerar. Näring/allergener beräknas ALLTID deterministiskt här. * Publiceringsflöde: submitted → AI-kontroll → moderation → published. */ app.post("/v1/recipes", auth, async (req, reply) => { await requireFeature(app.db, req.userId, "communityPublish", "Egna recept"); const input = parse(createUserRecipeInputSchema, req.body); const [user] = await app.db .select({ displayName: schema.users.displayName }) .from(schema.users) .where(eq(schema.users.id, req.userId)) .limit(1); let structured: { titleSv: string; descriptionSv: string; ingredients: Array<{ canonicalIngredientId: string; displayNameSv: string; quantity: number; unit: string; optional: boolean; }>; steps: Array<{ instructionSv: string; timerSeconds?: number | null; temperatureC?: number | null; }>; prepMin: number; cookMin: number; portions: number; cuisine: string; mealTypes: string[]; tags: string[]; methods: string[]; equipment: string[]; difficulty: string; spiceLevel: number; }; if (input.mode === "free_text") { const result = await app.aamos.runTask( "STRUCTURE_RECIPE_TEXT", { text: input.text, marketLocale: "sv-SE" }, { correlationId: req.correlationId, localeContext: await ( await import("../lib/localeContext.js") ).getLocaleContext(app.db, req.userId), }, ); if (result.status !== "ok" || !result.output) { throw errors.badRequest( "Receptet kunde inte tolkas automatiskt just nu. Prova strukturerad inmatning.", ); } const out = result.output; const ingredients = out.ingredients .filter((i) => i.canonicalIngredientId != null && i.quantity != null && i.unit != null) .map((i) => ({ canonicalIngredientId: i.canonicalIngredientId!, displayNameSv: i.displayNameSv, quantity: i.quantity!, unit: i.unit!, optional: i.optional, })); if (ingredients.length === 0) { throw errors.badRequest( "Inga ingredienser kunde tolkas säkert. Komplettera och försök igen.", ); } structured = { titleSv: out.titleSv ?? "Mitt recept", descriptionSv: out.descriptionSv ?? "", ingredients, steps: out.steps, prepMin: out.prepTimeMinutes ?? 15, cookMin: out.cookTimeMinutes ?? 20, portions: out.portions ?? 4, cuisine: out.suggestedCuisine ?? "international", mealTypes: out.suggestedMealTypes.length > 0 ? out.suggestedMealTypes : ["dinner"], tags: [], methods: [], equipment: [], difficulty: "easy", spiceLevel: 0, }; } else { structured = { titleSv: input.titleSv, descriptionSv: input.descriptionSv, ingredients: input.ingredients.map((i) => ({ canonicalIngredientId: i.canonicalIngredientId, displayNameSv: i.displayNameSv, quantity: i.quantity, unit: i.unit, optional: i.optional, })), steps: input.steps.map((s) => ({ instructionSv: s.instructionSv, timerSeconds: s.timerSeconds ?? null, temperatureC: s.temperatureC ?? null, })), prepMin: input.prepTimeMinutes, cookMin: input.cookTimeMinutes, portions: input.portions, cuisine: input.cuisine, mealTypes: input.mealTypes, tags: input.tags, methods: input.methods, equipment: input.equipment, difficulty: input.difficulty, spiceLevel: input.spiceLevel, }; } // Deterministisk näring + allergener (spec §61.1–2) const ingredientIds = structured.ingredients.map((i) => i.canonicalIngredientId); const dbIngredients = await app.db .select() .from(schema.canonicalIngredients) .where(inArray(schema.canonicalIngredients.id, ingredientIds)); const sourceMap = new Map( dbIngredients.map((i) => [ i.id, { nutritionPer100: i.nutritionPer100, densityGPerMl: i.densityGPerMl, gramsPerPiece: i.gramsPerPiece, }, ]), ); const missing = ingredientIds.filter((id) => !sourceMap.has(id)); if (missing.length > 0) { throw errors.badRequest(`Okända ingredienser: ${missing.join(", ")}`, { missing }); } const nutrition = computeRecipeNutrition( structured.ingredients.map((i) => ({ canonicalIngredientId: i.canonicalIngredientId, quantity: i.quantity, unit: i.unit as never, optional: i.optional, })), structured.portions, sourceMap, ); const safetyInfo = await ingredientSafetyMap(app, ingredientIds); const allergens = deriveRecipeAllergens( structured.ingredients.filter((i) => !i.optional).map((i) => i.canonicalIngredientId), safetyInfo, ); const slug = `${structured.titleSv .toLowerCase() .replace(/[åä]/g, "a") .replace(/ö/g, "o") .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, "") .slice(0, 60)}-${Date.now().toString(36)}`; const [recipe] = await app.db .insert(schema.recipes) .values({ slug, titleSv: structured.titleSv, descriptionSv: structured.descriptionSv, cuisine: structured.cuisine as never, mealTypes: structured.mealTypes as never, tags: structured.tags, methods: structured.methods, equipment: structured.equipment, difficulty: structured.difficulty as never, prepTimeMinutes: structured.prepMin, cookTimeMinutes: structured.cookMin, totalTimeMinutes: structured.prepMin + structured.cookMin, portions: structured.portions, nutritionPerPortion: nutrition.perPortion, allergens, spiceLevel: structured.spiceLevel, dna: { cuisine: structured.cuisine as never, vegetables: [], flavorProfile: [], spiceLevel: structured.spiceLevel, method: (structured.methods[0] ?? "stovetop") as never, timeMinutes: structured.prepMin + structured.cookMin, calories: nutrition.perPortion.kcal, proteinGrams: Math.round(nutrition.perPortion.proteinG), }, status: "submitted", verificationStatus: "unverified", sourceType: "user_generated", creatorUserId: req.userId, creatorDisplayName: user?.displayName ?? "Okänd", }) .returning(); await app.db.insert(schema.recipeIngredients).values( structured.ingredients.map((i, idx) => ({ recipeId: recipe!.id, canonicalIngredientId: i.canonicalIngredientId, displayNameSv: i.displayNameSv, quantity: i.quantity, unit: i.unit as never, optional: i.optional, sortOrder: idx, })), ); await app.db.insert(schema.recipeSteps).values( structured.steps.map((s, idx) => ({ recipeId: recipe!.id, stepNumber: idx + 1, instructionSv: s.instructionSv, timerSeconds: s.timerSeconds ?? null, temperatureC: s.temperatureC ?? null, })), ); // AI-kontroll + moderering sker asynkront i workern (spec §35 steg 2–4). await app.jobQueue.add("MODERATE_RECIPE", { jobType: "MODERATE_RECIPE", recipeId: recipe!.id, correlationId: req.correlationId, }); await emitEvent(app.db, { type: "RECIPE_CREATED", payload: { recipeId: recipe!.id, sourceType: "user_generated" }, userId: req.userId, correlationId: req.correlationId, }); return reply.status(201).send({ recipe, uncomputableIngredients: nutrition.uncomputableIngredientIds, message: "Receptet är inskickat och granskas innan publicering.", }); }); } export async function loadFullRecipe(app: FastifyInstance, id: string) { const [recipe] = await app.db .select() .from(schema.recipes) .where(eq(schema.recipes.id, id)) .limit(1); if (!recipe) throw errors.notFound("Receptet finns inte."); const ingredients = await app.db .select() .from(schema.recipeIngredients) .where(eq(schema.recipeIngredients.recipeId, id)) .orderBy(schema.recipeIngredients.sortOrder); const steps = await app.db .select() .from(schema.recipeSteps) .where(eq(schema.recipeSteps.recipeId, id)) .orderBy(schema.recipeSteps.stepNumber); return { ...recipe, ingredients, steps }; } async function ingredientSafetyMap( app: FastifyInstance, ids: string[], ): Promise> { if (ids.length === 0) return new Map(); const rows = await app.db .select() .from(schema.canonicalIngredients) .where(inArray(schema.canonicalIngredients.id, ids)); return new Map( rows.map((r) => [ r.id, { id: r.id, allergens: r.allergens, isVegan: r.isVegan, isVegetarian: r.isVegetarian, containsGluten: r.containsGluten, containsLactose: r.containsLactose, isPork: r.isPork, isBeef: r.isBeef, isAlcohol: r.isAlcohol, }, ]), ); }