Files
recipe-app/backend/src/recipes/recipes.service.ts
T
2026-05-04 21:28:06 +02:00

528 lines
17 KiB
TypeScript

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,
};
}
}