import { ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; 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'; import { parseRecipeMarkdown } from '../common/utils/recipe-parser'; import { normalizeUnit, getUnitType, convertUnit, canConvert } from '../common/utils/units'; const IMAGE_DEST_DIR = process.env.IMAGE_DEST_DIR || '/app/recipe-images'; @Injectable() export class RecipesService { private readonly logger = new Logger(RecipesService.name); constructor(private readonly prisma: PrismaService) {} private throwRecipeNotFound(id: number): never { throw new NotFoundException(`Recipe with id ${id} not found`); } private async findRecipeByIdOrThrow(id: number) { const recipe = await this.prisma.recipe.findUnique({ where: { id } }); if (!recipe) { this.throwRecipeNotFound(id); } return recipe; } private assertRecipeEditableByUser(recipe: { ownerId: number | null }, userId: number, id: number) { // Legacy behavior: ownerless recipes are editable to preserve existing semantics. if (recipe.ownerId !== null && recipe.ownerId !== userId) { this.throwRecipeNotFound(id); } } private assertRecipeOwnedByUser(recipe: { ownerId: number | null }, userId: number, id: number) { if (recipe.ownerId !== userId) { this.throwRecipeNotFound(id); } } async getInventoryPreview(id: number, userId: number) { const recipe = await this.prisma.recipe.findFirst({ where: { id, OR: [ { isPublic: true }, { ownerId: userId }, { shares: { some: { userId } } }, ], }, 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 = convertUnit( Number(item.quantity), item.unit, ingredient.unit, ); 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 canConvertUnits = canConvert(item.unit, ingredient.unit); let convertedQuantity = 0; if (canConvertUnits) { try { convertedQuantity = convertUnit(Number(item.quantity), item.unit, ingredient.unit); } catch { convertedQuantity = 0; } } return { id: item.id, quantity: item.quantity, unit: item.unit, location: item.location, convertedQuantity: canConvertUnits ? convertedQuantity : 0, canConvert: canConvertUnits, }; }), 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(userId: number) { return this.prisma.recipe.findMany({ where: { OR: [ { isPublic: true }, { ownerId: userId }, { shares: { some: { userId } } }, ], }, include: { ingredients: { include: { product: { include: { nutrition: true } }, }, }, owner: { select: { id: true, username: true } }, shares: { select: { userId: true } }, }, }); } async findOne(id: number, userId: number) { const recipe = await this.prisma.recipe.findFirst({ where: { id, OR: [ { isPublic: true }, { ownerId: userId }, { shares: { some: { userId } } }, ], }, include: { ingredients: { include: { product: { include: { nutrition: true } }, }, }, owner: { select: { id: true, username: true } }, shares: { select: { userId: true } }, }, }); if (!recipe) { throw new NotFoundException(`Recipe with id ${id} not found`); } return recipe; } async update(id: number, updateRecipeDto: CreateRecipeDto, userId: number) { const existingRecipe = await this.findRecipeByIdOrThrow(id); this.assertRecipeEditableByUser(existingRecipe, userId, id); // 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.isPublic !== undefined && { isPublic: updateRecipeDto.isPublic }), ...(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, userId: number) { const existingRecipe = await this.findRecipeByIdOrThrow(id); this.assertRecipeEditableByUser(existingRecipe, userId, id); await this.prisma.recipeIngredient.deleteMany({ where: { recipeId: id } }); await this.prisma.recipe.delete({ where: { id } }); // Radera lokal bildfil om den finns (undviker orphan-filer på disk). if (existingRecipe.imageUrl?.startsWith('/images/')) { const filename = path.basename(existingRecipe.imageUrl); const filePath = path.join(IMAGE_DEST_DIR, filename); await fs.unlink(filePath).catch(() => { // Filen kanske redan är borttagen — ignorera felet. }); } } async updateImage(id: number, sourceUrl: string, userId: number) { const existingRecipe = await this.findRecipeByIdOrThrow(id); this.assertRecipeOwnedByUser(existingRecipe, userId, id); const imageUrl = await downloadAndOptimizeImage(sourceUrl, IMAGE_DEST_DIR); return this.prisma.recipe.update({ where: { id }, data: { imageUrl }, include: { ingredients: { include: { product: { include: { nutrition: true } } } }, owner: { select: { id: true, username: true } }, shares: { select: { userId: true } }, }, }); } async setVisibility(id: number, userId: number, isPublic: boolean) { const existingRecipe = await this.findRecipeByIdOrThrow(id); this.assertRecipeOwnedByUser(existingRecipe, userId, id); if (isPublic) { const owner = await this.prisma.user.findUnique({ where: { id: userId }, select: { canShareRecipes: true }, }); if (!owner?.canShareRecipes) { throw new ForbiddenException('Du har inte behörighet att dela recept.'); } } return this.prisma.recipe.update({ where: { id }, data: { isPublic }, include: { ingredients: { include: { product: { include: { nutrition: true } } } }, owner: { select: { id: true, username: true } }, shares: { select: { userId: true } }, }, }); } async shareWithUser(id: number, ownerId: number, username: string) { const recipe = await this.findRecipeByIdOrThrow(id); this.assertRecipeOwnedByUser(recipe, ownerId, id); const owner = await this.prisma.user.findUnique({ where: { id: ownerId }, select: { canShareRecipes: true }, }); if (!owner?.canShareRecipes) { throw new ForbiddenException('Du har inte behörighet att dela recept.'); } const targetUser = await this.prisma.user.findUnique({ where: { username }, select: { id: true }, }); if (!targetUser) { throw new NotFoundException(`User ${username} not found`); } if (targetUser.id === ownerId) { return this.findOne(id, ownerId); } await this.prisma.recipeShare.upsert({ where: { recipeId_userId: { recipeId: id, userId: targetUser.id } }, create: { recipeId: id, userId: targetUser.id }, update: {}, }); return this.findOne(id, ownerId); } async unshareWithUser(id: number, ownerId: number, username: string) { const recipe = await this.findRecipeByIdOrThrow(id); this.assertRecipeOwnedByUser(recipe, ownerId, id); const targetUser = await this.prisma.user.findUnique({ where: { username }, select: { id: true }, }); if (!targetUser) { throw new NotFoundException(`User ${username} not found`); } await this.prisma.recipeShare.deleteMany({ where: { recipeId: id, userId: targetUser.id }, }); return this.findOne(id, ownerId); } async create(createRecipeDto: CreateRecipeDto, userId: number) { this.logger.log( `[create] Incoming imageUrl from client: ${createRecipeDto.imageUrl ?? 'null'}`, ); // Om imageUrl är en extern URL — ladda ner och optimera let imageUrl: string | null = createRecipeDto.imageUrl || null; if (imageUrl && imageUrl.startsWith('http')) { const externalImageUrl = imageUrl; try { imageUrl = await downloadAndOptimizeImage(imageUrl, IMAGE_DEST_DIR); } catch (err) { console.warn('[RecipesService] Kunde inte ladda ner receptbild:', err); // Behåll extern URL som fallback så bild fortfarande visas. imageUrl = externalImageUrl; } } this.logger.log(`[create] Final imageUrl persisted to DB: ${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, ownerId: userId, isPublic: false, 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) { // Delegera markdown-parsning till microservice-importer const importerUrl = process.env.IMPORTER_SERVICE_URL || 'http://importer-api:3001'; let parsed: ParsedRecipe; try { const response = await fetch(`${importerUrl}/api/recipes/parse-markdown`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ markdown: dto.markdown }), }); if (!response.ok) { throw new Error(`Importer svarade ${response.status}`); } parsed = (await response.json()) as ParsedRecipe; } catch (err) { this.logger.error(`Kunde inte nå importer-api för parse-markdown: ${err}`); // Fallback: använd lokal parser vid driftavbrott 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, }; } }