import { Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { CreateRecipeDto } from './dto/create-recipe.dto'; import { ParseMarkdownDto } from './dto/parse-markdown.dto'; import { downloadAndOptimizeImage } from '../common/utils/download-image'; const IMAGE_DEST_DIR = process.env.IMAGE_DEST_DIR || '/app/recipe-images'; // Lokala typdefiniitioner (tidigare från recipe-document-converter) interface ParsedIngredient { rawName: string; quantity: number; unit: string; note: string | null; } interface ParsedRecipe { name: string; description: string; instructions: string; ingredients: ParsedIngredient[]; } @Injectable() export class RecipesService { // Enhetsklassificering private static readonly WEIGHT_UNITS = ['g', 'kg']; private static readonly VOLUME_UNITS = ['ml', 'dl']; private static readonly PORTION_UNITS = ['tsk', 'msk']; // tesked, matsked private static readonly PIECE_UNITS = ['st']; // stycken // Konverteringsregler för varje enhetstyp private static readonly WEIGHT_CONVERSIONS: Record = { 'g': 1, 'kg': 1000, }; private static readonly VOLUME_CONVERSIONS: Record = { 'ml': 1, 'dl': 100, }; private static readonly PORTION_CONVERSIONS: Record = { 'tsk': 1, 'msk': 3, // 1 matsked ≈ 3 teskedar }; constructor(private readonly prisma: PrismaService) {} /** Normalisera enheter (t.ex. "tesked" → "tsk", "milliliter" → "ml") */ private normalizeUnit(unit: string): string { const normalized = unit.trim().toLowerCase(); const unitAliases: Record = { 'tesked': 'tsk', 'test': 'tsk', 'matsked': 'msk', 'matsled': 'msk', 'milliliter': 'ml', 'deciliter': 'dl', 'gram': 'g', 'kilo': 'kg', 'kilogram': 'kg', 'stycke': 'st', }; return unitAliases[normalized] || normalized; } /** Bestäm vilken enhetstyp en enhet tillhör */ private getUnitCategory(unit: string): string | null { const normalized = this.normalizeUnit(unit); if (RecipesService.WEIGHT_UNITS.includes(normalized)) return 'weight'; if (RecipesService.VOLUME_UNITS.includes(normalized)) return 'volume'; if (RecipesService.PORTION_UNITS.includes(normalized)) return 'portion'; if (RecipesService.PIECE_UNITS.includes(normalized)) return 'piece'; return null; } /** Kontrollera om en enhet är viktbaserad */ private isWeightUnit(unit: string): boolean { return this.getUnitCategory(unit) === 'weight'; } /** Kontrollera om en enhet är volymbaserad */ private isVolumeUnit(unit: string): boolean { return this.getUnitCategory(unit) === 'volume'; } /** * Konverterar kvantitet mellan enheter för en given produkt. * Stödjer vikt (g/kg), volym (ml/dl) och svenska måttenheter (tsk/msk). * Konverterar endast inom samma enhetstyp. * * @throws Error om quantity är negativ/noll, enheter är tomma, eller enheter är inkompatibla */ private convertUnit(quantity: number, fromUnit: string, toUnit: string, productName: string): number { // Input validation if (quantity <= 0) { throw new Error(`Invalid quantity: ${quantity}. Quantity must be positive.`); } if (!fromUnit?.trim()) { throw new Error('From unit cannot be empty.'); } if (!toUnit?.trim()) { throw new Error('To unit cannot be empty.'); } if (!productName?.trim()) { throw new Error('Product name cannot be empty.'); } // Normalisera och kontrollera enheter const normalizedFromUnit = this.normalizeUnit(fromUnit); const normalizedToUnit = this.normalizeUnit(toUnit); // Om enheterna är identiska efter normalisering, returnera direkt if (normalizedFromUnit === normalizedToUnit) { return quantity; } // Bestäm enhetstyp const fromCategory = this.getUnitCategory(normalizedFromUnit); const toCategory = this.getUnitCategory(normalizedToUnit); if (!fromCategory) { throw new Error(`Unknown unit: "${fromUnit}"`); } if (!toCategory) { throw new Error(`Unknown unit: "${toUnit}"`); } // Konvertera endast inom samma enhetstyp if (fromCategory !== toCategory) { throw new Error( `Cannot convert between incompatible unit types: "${fromUnit}" (${fromCategory}) and "${toUnit}" (${toCategory}) for product "${productName}"`, ); } // Hämta rätt konverteringstabll baserat på enhetstyp let conversions: Record; switch (fromCategory) { case 'weight': conversions = RecipesService.WEIGHT_CONVERSIONS; break; case 'volume': conversions = RecipesService.VOLUME_CONVERSIONS; break; case 'portion': conversions = RecipesService.PORTION_CONVERSIONS; break; case 'piece': // Kan inte konvertera stycken return quantity; default: throw new Error(`Unknown unit category: ${fromCategory}`); } // Konvertera via basenhet return (quantity * conversions[normalizedFromUnit]) / conversions[normalizedToUnit]; } // --- ÖVRIGA METODER (findAll, findOne, create) OFÖRÄNDRADE --- async getInventoryPreview(id: number) { const recipe = await this.prisma.recipe.findUnique({ where: { id }, include: { ingredients: { include: { product: true, }, orderBy: { id: 'asc', }, }, }, }); if (!recipe) { throw new NotFoundException(`Recipe with id ${id} not found`); } const ingredientPreviews = await Promise.all( recipe.ingredients.map(async (ingredient: any) => { const inventoryItems = await this.prisma.inventoryItem.findMany({ where: { productId: ingredient.productId }, orderBy: { createdAt: 'desc' }, }); // Hitta inventory-poster med samma enhet const sameUnitItems = inventoryItems.filter( (item: any) => item.unit.trim().toLowerCase() === ingredient.unit.trim().toLowerCase(), ); const availableSameUnit = sameUnitItems.reduce( (sum: number, item: any) => sum + Number(item.quantity), 0, ); // Hitta inventory-poster med annan enhet och konvertera (endast viktbaserade enheter) const otherUnitItems = inventoryItems.filter( (item: any) => item.unit.trim().toLowerCase() !== ingredient.unit.trim().toLowerCase(), ); let availableOtherUnit = 0; for (const item of otherUnitItems) { // Konvertera endast om enheter är kompatibla (samma kategori) try { const convertedQuantity = this.convertUnit( Number(item.quantity), item.unit, ingredient.unit, ingredient.product.name, ); availableOtherUnit += convertedQuantity; } catch { // Om konvertering misslyckas, hoppa över denna post // (t.ex. st kan inte konverteras till g) } } const totalAvailable = availableSameUnit + availableOtherUnit; let status: 'enough' | 'missing' | 'unit_mismatch'; if (totalAvailable >= Number(ingredient.quantity)) { status = 'enough'; } else if (availableSameUnit === 0 && availableOtherUnit > 0) { status = 'unit_mismatch'; } else { status = 'missing'; } return { ingredientId: ingredient.id, productId: ingredient.productId, productName: ingredient.product.canonicalName || ingredient.product.name, requiredQuantity: Number(ingredient.quantity), requiredUnit: ingredient.unit, note: ingredient.note, availableQuantity: totalAvailable, availableUnit: ingredient.unit, matchingInventoryItems: sameUnitItems.map((item: any) => ({ id: item.id, quantity: item.quantity, unit: item.unit, location: item.location, brand: item.brand || null, bestBeforeDate: item.bestBeforeDate || null, })), otherInventoryItems: otherUnitItems.map((item: any) => { // Kolla om konvertering är möjlig (samma enhetskategori) const canConvert = this.getUnitCategory(item.unit) === this.getUnitCategory(ingredient.unit) && this.getUnitCategory(ingredient.unit) !== 'piece'; let convertedQuantity = 0; if (canConvert) { try { convertedQuantity = this.convertUnit(Number(item.quantity), item.unit, ingredient.unit, ingredient.product.name); } catch { convertedQuantity = 0; } } return { id: item.id, quantity: item.quantity, unit: item.unit, location: item.location, convertedQuantity: canConvert ? convertedQuantity : 0, canConvert, }; }), status, missingQuantity: status === 'missing' ? Math.max(0, Number(ingredient.quantity) - totalAvailable) : 0, }; }), ); const summary = { totalIngredients: ingredientPreviews.length, enoughCount: ingredientPreviews.filter((i: any) => i.status === 'enough').length, missingCount: ingredientPreviews.filter((i: any) => i.status === 'missing').length, unitMismatchCount: ingredientPreviews.filter((i: any) => i.status === 'unit_mismatch').length, canCookExactly: ingredientPreviews.every((i: any) => i.status === 'enough'), }; return { recipe: { id: recipe.id, name: recipe.name, description: recipe.description, }, ingredients: ingredientPreviews, summary, }; } async findAll() { return this.prisma.recipe.findMany({ include: { ingredients: { include: { product: { include: { nutrition: true } }, }, }, }, }); } async findOne(id: number) { const recipe = await this.prisma.recipe.findUnique({ where: { id }, include: { ingredients: { include: { product: { include: { nutrition: true } }, }, }, }, }); if (!recipe) { throw new NotFoundException(`Recipe with id ${id} not found`); } return recipe; } async update(id: number, updateRecipeDto: CreateRecipeDto) { // Verifiera att receptet finns const existingRecipe = await this.prisma.recipe.findUnique({ where: { id }, }); if (!existingRecipe) { throw new NotFoundException(`Recipe with id ${id} not found`); } // Ta bort gamla ingredienser await this.prisma.recipeIngredient.deleteMany({ where: { recipeId: id }, }); // Uppdatera receptet och lägg till nya ingredienser const recipe = await this.prisma.recipe.update({ where: { id }, data: { name: updateRecipeDto.name, description: updateRecipeDto.description || null, instructions: updateRecipeDto.instructions || null, servings: updateRecipeDto.servings ?? null, ...(updateRecipeDto.imageUrl !== undefined && { imageUrl: updateRecipeDto.imageUrl || null }), ingredients: { create: updateRecipeDto.ingredients.map((ingredient) => ({ productId: ingredient.productId, quantity: ingredient.quantity, unit: ingredient.unit, note: ingredient.note || null, })), }, }, include: { ingredients: { include: { product: { include: { nutrition: true } }, }, }, }, }); return recipe; } async remove(id: number) { const existingRecipe = await this.prisma.recipe.findUnique({ where: { id }, }); if (!existingRecipe) { throw new NotFoundException(`Recipe with id ${id} not found`); } await this.prisma.recipeIngredient.deleteMany({ where: { recipeId: id } }); await this.prisma.recipe.delete({ where: { id } }); } async updateImage(id: number, sourceUrl: string) { const existingRecipe = await this.prisma.recipe.findUnique({ where: { id } }); if (!existingRecipe) { throw new NotFoundException(`Recipe with id ${id} not found`); } const imageUrl = await downloadAndOptimizeImage(sourceUrl, IMAGE_DEST_DIR); return this.prisma.recipe.update({ where: { id }, data: { imageUrl }, include: { ingredients: { include: { product: { include: { nutrition: true } } } } }, }); } async create(createRecipeDto: CreateRecipeDto) { // Om imageUrl är en extern URL — ladda ner och optimera let imageUrl: string | null = createRecipeDto.imageUrl || null; if (imageUrl && imageUrl.startsWith('http')) { try { imageUrl = await downloadAndOptimizeImage(imageUrl, IMAGE_DEST_DIR); } catch (err) { console.warn('[RecipesService] Kunde inte ladda ner receptbild:', err); imageUrl = null; } } const recipe = await this.prisma.recipe.create({ data: { name: createRecipeDto.name, description: createRecipeDto.description || null, instructions: createRecipeDto.instructions || null, imageUrl, servings: createRecipeDto.servings ?? null, ingredients: { create: createRecipeDto.ingredients.map((ingredient) => ({ productId: ingredient.productId, quantity: ingredient.quantity, unit: ingredient.unit, note: ingredient.note || null, })), }, }, include: { ingredients: { include: { product: { include: { nutrition: true } }, }, }, }, }); return recipe; } async parseMarkdown(dto: ParseMarkdownDto) { const parsed = parseRecipeMarkdown(dto.markdown); const allProducts = await this.prisma.product.findMany({ where: { isActive: true }, select: { id: true, name: true, canonicalName: true, normalizedName: true }, }); // Normalisera en sträng för jämförelse (lowercase, trim, ta bort skiljetecken) const normalize = (s: string) => s.toLowerCase().trim().replace(/[^a-zåäö0-9\s]/gi, '').replace(/\s+/g, ' '); // Enkel Levenshtein-distans const levenshtein = (a: string, b: string): number => { const m = a.length; const n = b.length; const dp: number[][] = Array.from({ length: m + 1 }, (_, i) => Array.from({ length: n + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)), ); for (let i = 1; i <= m; i++) { for (let j = 1; j <= n; j++) { dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]); } } return dp[m][n]; }; const ingredientsWithSuggestions = parsed.ingredients.map((ingredient: ParsedIngredient) => { const query = normalize(ingredient.rawName); const scored = allProducts .map((product) => { const targetName = normalize(product.canonicalName || product.name); const targetNormalized = normalize(product.normalizedName); // Exakt träff på normalizedName prioriteras if (targetNormalized === query || targetName === query) { return { product, score: 100 }; } // Delsträng-match if (targetName.includes(query) || query.includes(targetName)) { return { product, score: 70 }; } // Levenshtein-baserad likhet const dist = levenshtein(query, targetName); const maxLen = Math.max(query.length, targetName.length); const similarity = maxLen === 0 ? 100 : Math.round((1 - dist / maxLen) * 100); return { product, score: similarity }; }) .filter((s) => s.score >= 40) .sort((a, b) => b.score - a.score) .slice(0, 5) .map((s) => ({ productId: s.product.id, productName: s.product.canonicalName || s.product.name, score: s.score, })); return { rawName: ingredient.rawName, quantity: ingredient.quantity, unit: ingredient.unit, note: ingredient.note, suggestions: scored, }; }); return { name: parsed.name, description: parsed.description, instructions: parsed.instructions, ingredients: ingredientsWithSuggestions, }; } } // ============================================================================ // Parser Functions (previously from recipe-document-converter library) // ============================================================================ /** * Parsar ett recept i Markdown-format och extraherar namn, beskrivning, * instruktioner och ingredienser. * * Förväntat format: * # Receptnamn * Beskrivning (valfritt stycke efter titeln) * * ## Ingredienser * - 400 g kycklingfilé * - 2 dl grädde (eller crème fraiche) * * ## Instruktioner * 1. Stek kycklingen … */ function parseRecipeMarkdown(markdown: string): ParsedRecipe { const lines = markdown.split('\n'); let name = ''; let description = ''; let instructions = ''; const ingredients: ParsedIngredient[] = []; let currentSection: 'none' | 'description' | 'ingredients' | 'instructions' = 'none'; const descriptionLines: string[] = []; const instructionLines: string[] = []; for (const line of lines) { const trimmed = line.trim(); // H1 — receptnamn if (/^#\s+/.test(trimmed) && !trimmed.startsWith('##')) { name = trimmed.replace(/^#\s+/, '').trim(); currentSection = 'description'; continue; } // H2 — sektionsrubriker if (/^##\s+/.test(trimmed)) { const heading = trimmed.replace(/^##\s+/, '').trim().toLowerCase(); if (/ingrediens/.test(heading)) { currentSection = 'ingredients'; } else if (/instruktion|tillagning|gör så här|steg|tillväg|metod/.test(heading)) { currentSection = 'instructions'; } else { currentSection = 'none'; } continue; } // Samla rader beroende på sektion switch (currentSection) { case 'description': if (trimmed.length > 0) { descriptionLines.push(trimmed); } break; case 'ingredients': if (/^[-*]\s+/.test(trimmed)) { const ingredientText = trimmed.replace(/^[-*]\s+/, ''); ingredients.push(parseIngredientLine(ingredientText)); } break; case 'instructions': if (trimmed.length > 0) { instructionLines.push(trimmed); } break; } } description = descriptionLines.join('\n'); instructions = instructionLines.join('\n'); return { name, description, instructions, ingredients }; } /** * Parsar en ingrediensrad, t.ex.: * "400 g kycklingfilé" * "2 dl grädde (eller crème fraiche)" * "1 1/2 dl crème fraiche" * "1 polka- eller gulbeta" * "1 kruka basilika" * "salt" */ function parseIngredientLine(text: string): ParsedIngredient { const trimmed = text.trim(); // Kända enheter const knownUnits = [ 'g', 'kg', 'hg', 'mg', 'ml', 'dl', 'l', 'tl', 'st', 'tsk', 'msk', 'krm', 'matsled', 'tesled', 'pris', 'portion', 'port', 'burk', 'förp', 'paket', 'efter smak', 'klyfta', ]; // Extrahera eventuell parentes-not i slutet let note: string | null = null; let main = trimmed; const parenMatch = trimmed.match(/\(([^)]+)\)\s*$/); if (parenMatch) { note = parenMatch[1].trim(); main = trimmed.slice(0, parenMatch.index).trim(); } // Försök matcha bråk först: "1 1/2 dl crème fraiche" eller "1/2 dl" const fractionMatch = main.match(/^(\d+)?\s*(\d+)\s*\/\s*([\d.]+)\s+(\S+)\s+(.*)$/); if (fractionMatch) { let quantity = 0; if (fractionMatch[1]) { quantity = parseFloat(fractionMatch[1]) + parseFloat(fractionMatch[2]) / parseFloat(fractionMatch[3]); } else { quantity = parseFloat(fractionMatch[2]) / parseFloat(fractionMatch[3]); } const candidateUnit = fractionMatch[4].toLowerCase(); if (knownUnits.includes(candidateUnit)) { return { quantity, unit: candidateUnit, rawName: fractionMatch[5].trim(), note, }; } } // Försök matcha "kvantitet enhet namn" — t.ex. "400 g kycklingfilé" eller "2.5 dl grädde" const fullMatch = main.match(/^(\d+(?:[.,]\d+)?)\s+(\S+)\s+(.+)$/); if (fullMatch) { const candidateUnit = fullMatch[2].toLowerCase(); // Validera att det andra ordet är en känd enhet if (knownUnits.includes(candidateUnit)) { return { quantity: parseNumber(fullMatch[1]), unit: candidateUnit, rawName: fullMatch[3].trim(), note, }; } // Om inte känd enhet, behandla som "kvantitet namn" utan enhet return { quantity: parseNumber(fullMatch[1]), unit: 'st', rawName: fullMatch[2] + ' ' + fullMatch[3], note, }; } // Försök matcha "kvantitet namn" utan enhet — t.ex. "3 ägg" const noUnitMatch = main.match(/^(\d+(?:[.,]\d+)?)\s+(.+)$/); if (noUnitMatch) { return { quantity: parseNumber(noUnitMatch[1]), unit: 'st', rawName: noUnitMatch[2].trim(), note, }; } // Bara ett namn, ingen kvantitet — t.ex. "salt" return { quantity: 0, unit: '', rawName: main, note, }; } function parseNumber(s: string): number { return parseFloat(s.replace(',', '.')); }