Files
recipe-app/backend/src/recipes/recipes.service.ts
T
2026-05-06 07:25:42 +02:00

798 lines
27 KiB
TypeScript

import { BadRequestException, 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 { AiService } from '../ai/ai.service';
import { CreateRecipeDto } from './dto/create-recipe.dto';
import { CreateIngredientDto } from './dto/create-ingredient.dto';
import { ParseMarkdownDto } from './dto/parse-markdown.dto';
import { downloadAndOptimizeImage } from '../common/utils/download-image';
import { parseRecipeMarkdown, ParsedRecipe, ParsedIngredient } 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';
export interface AiRecipeSuggestion {
name: string;
description: string;
mainIngredients: string[];
missingIngredients: string[];
estimatedTime: string;
}
@Injectable()
export class RecipesService {
private readonly logger = new Logger(RecipesService.name);
constructor(
private readonly prisma: PrismaService,
private readonly aiService: AiService,
) {}
private throwRecipeNotFound(id: number): never {
throw new NotFoundException(`Recipe with id ${id} not found`);
}
private async assertProductsActive(productIds: number[]): Promise<void> {
if (productIds.length === 0) return;
const activeProducts = await this.prisma.product.findMany({
where: { id: { in: productIds }, isActive: true },
select: { id: true },
});
if (activeProducts.length !== productIds.length) {
const foundIds = new Set(activeProducts.map((p) => p.id));
const missing = productIds.filter((id) => !foundIds.has(id));
throw new BadRequestException(
`En eller flera ingrediensprodukter är inaktiva eller finns inte: ${missing.join(', ')}`,
);
}
}
private async findRecipeByIdOrThrow(id: number) {
const recipe = await this.prisma.recipe.findUnique({ where: { id } });
if (!recipe) {
this.throwRecipeNotFound(id);
}
return recipe;
}
private async assertAndClaimRecipeOwner(
recipe: { id: number; ownerId: number | null },
userId: number,
): Promise<void> {
if (recipe.ownerId === null) {
// Auto-claim ownerless legacy recipe för den redigerande användaren.
await this.prisma.recipe.update({
where: { id: recipe.id },
data: { ownerId: userId },
});
} else if (recipe.ownerId !== userId) {
this.throwRecipeNotFound(recipe.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`);
}
// Hämta användarens pantry-produkter (stapelvaror — alltid tillgängliga)
const pantryItems = await this.prisma.pantryItem.findMany({
where: { userId },
select: { productId: true },
});
const pantryProductIds = new Set(pantryItems.map((p) => p.productId));
const ingredientPreviews = await Promise.all(
recipe.ingredients.map(async (ingredient: any) => {
if (!ingredient.productId || !ingredient.product) {
return {
ingredientId: ingredient.id,
productId: null,
productName: ingredient.rawName || 'Okänd ingrediens',
requiredQuantity: Number(ingredient.quantity ?? 0),
requiredUnit: ingredient.unit || '',
note: ingredient.note,
availableQuantity: 0,
availableUnit: ingredient.unit || '',
matchingInventoryItems: [],
otherInventoryItems: [],
status: 'missing' as const,
fromPantry: false,
missingQuantity: Number(ingredient.quantity ?? 0),
};
}
// Täcks ingrediensen av pantry (inkl. alternativ)?
const coveredByPantry =
pantryProductIds.has(ingredient.productId) ||
(Array.isArray(ingredient.alternativeProductIds) &&
ingredient.alternativeProductIds.some((altId: number) =>
pantryProductIds.has(altId),
));
if (coveredByPantry) {
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: Number(ingredient.quantity),
availableUnit: ingredient.unit,
matchingInventoryItems: [],
otherInventoryItems: [],
status: 'enough' as const,
fromPantry: true,
missingQuantity: 0,
};
}
const inventoryItems = await this.prisma.inventoryItem.findMany({
where: {
productId: {
in: [
ingredient.productId,
...(Array.isArray(ingredient.alternativeProductIds)
? ingredient.alternativeProductIds
: []),
],
},
},
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,
fromPantry: false,
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'),
pantryCount: ingredientPreviews.filter((i: any) => i.fromPantry).length,
};
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);
await this.assertAndClaimRecipeOwner(existingRecipe, userId);
// Validera att alla produkter är aktiva
await this.assertProductsActive(
updateRecipeDto.ingredients
.map((i) => i.productId)
.filter((id): id is number => typeof id === 'number'),
);
// Transaktionsblock: ta bort gamla + skapa nya ingredienser atomärt
const recipe = await this.prisma.$transaction(async (tx) => {
await tx.recipeIngredient.deleteMany({ where: { recipeId: id } });
return tx.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 ?? null,
rawName: ingredient.rawName,
rawLine: ingredient.rawLine ?? null,
quantity: ingredient.quantity ?? null,
unit: ingredient.unit?.trim() ? ingredient.unit : null,
note: ingredient.note || null,
alternativeProductIds: ingredient.alternativeProductIds ?? [],
matchConfidence: ingredient.matchConfidence ?? null,
matchSource: ingredient.matchSource ?? null,
})),
},
},
include: {
ingredients: {
include: {
product: { include: { nutrition: true } },
},
},
},
});
});
return recipe;
}
async remove(id: number, userId: number) {
const existingRecipe = await this.findRecipeByIdOrThrow(id);
await this.assertAndClaimRecipeOwner(existingRecipe, userId);
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) {
// Validera att alla produkter är aktiva
await this.assertProductsActive(
createRecipeDto.ingredients
.map((i) => i.productId)
.filter((id): id is number => typeof id === '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;
let downloadedImagePath: string | null = null;
if (imageUrl && imageUrl.startsWith('http')) {
const externalImageUrl = imageUrl;
try {
imageUrl = await downloadAndOptimizeImage(imageUrl, IMAGE_DEST_DIR);
downloadedImagePath = imageUrl;
} 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'}`);
try {
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 ?? null,
rawName: ingredient.rawName,
rawLine: ingredient.rawLine ?? null,
quantity: ingredient.quantity ?? null,
unit: ingredient.unit?.trim() ? ingredient.unit : null,
note: ingredient.note || null,
alternativeProductIds: ingredient.alternativeProductIds ?? [],
matchConfidence: ingredient.matchConfidence ?? null,
matchSource: ingredient.matchSource ?? null,
})),
},
},
include: {
ingredients: {
include: {
product: { include: { nutrition: true } },
},
},
},
});
return recipe;
} catch (err) {
// Rensa upp nedladdad bildfil om receptsparandet misslyckas
if (downloadedImagePath) {
await fs.unlink(path.join(IMAGE_DEST_DIR, path.basename(downloadedImagePath))).catch(() => {});
}
throw err;
}
}
async addIngredient(id: number, ingredient: CreateIngredientDto, userId: number) {
const recipe = await this.findRecipeByIdOrThrow(id);
await this.assertRecipeOwnedByUser(recipe, userId, id);
await this.assertProductsActive([ingredient.productId]);
return this.prisma.recipeIngredient.create({
data: {
productId: ingredient.productId,
quantity: ingredient.quantity,
unit: ingredient.unit,
note: ingredient.note || null,
recipeId: id,
},
include: {
product: { include: { nutrition: true } },
},
});
}
async suggestRecipesFromInventory(userId: number): Promise<{ suggestions: AiRecipeSuggestion[] }> {
// Hämta inventory-items
const inventoryItems = await this.prisma.inventoryItem.findMany({
include: { product: { select: { canonicalName: true, name: true } } },
orderBy: { bestBeforeDate: 'asc' },
});
// Hämta pantry-items (stapelvaror)
const pantryItems = await this.prisma.pantryItem.findMany({
where: { userId },
include: { product: { select: { canonicalName: true, name: true } } },
});
if (inventoryItems.length === 0 && pantryItems.length === 0) {
return { suggestions: [] };
}
// Bygg ingrediens-sammanfattning
const inventoryLines = inventoryItems.map((item) => {
const name = item.product.canonicalName || item.product.name;
return `- ${item.quantity} ${item.unit} ${name}`;
});
const pantryLines = pantryItems.map((item) => {
const name = item.product.canonicalName || item.product.name;
return `- ${name} (stapelvara, alltid tillgänglig)`;
});
const ingredientSummary = [
inventoryLines.length > 0 ? 'Jag har följande i kylen/skafferiet:' : '',
...inventoryLines,
pantryLines.length > 0 ? '\nStapelvaror (alltid tillgängliga):' : '',
...pantryLines,
]
.filter(Boolean)
.join('\n');
const apiKey = process.env.MISTRAL_API_KEY;
if (!apiKey) {
this.logger.warn('MISTRAL_API_KEY saknas — kan inte generera receptförslag');
return { suggestions: [] };
}
const systemPrompt = `Du är en hjälpsam matlagningsassistent för en svensk livsmedelsapp.
Din uppgift är att föreslå recept baserat på vad användaren har hemma.
Regler:
1. Föreslå 3-5 recept som kan lagas med de tillgängliga ingredienserna.
2. Recepten ska vara realistiska och genomförbara.
3. Det är OK om några få vanliga ingredienser saknas (t.ex. salt, olja, kryddor).
4. Svara ENDAST med giltig JSON i detta exakta format:
{
"suggestions": [
{
"name": "Receptnamn",
"description": "Kort beskrivning på 1-2 meningar",
"mainIngredients": ["ingrediens1", "ingrediens2", "ingrediens3"],
"missingIngredients": ["eventuellt saknad ingrediens"],
"estimatedTime": "30 min"
}
]
}`;
const userPrompt = ingredientSummary;
let raw = '';
try {
const response = await fetch('https://api.mistral.ai/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: 'mistral-small-latest',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
max_tokens: 1500,
temperature: 0.7,
response_format: { type: 'json_object' },
}),
});
if (!response.ok) {
this.logger.error(`Mistral API-fel vid receptförslag: ${response.status}`);
return { suggestions: [] };
}
const data = await response.json() as { choices: { message: { content: string } }[] };
raw = data.choices?.[0]?.message?.content ?? '{}';
} catch (err) {
this.logger.error(`Kunde inte nå Mistral för receptförslag: ${err}`);
return { suggestions: [] };
}
try {
const parsed = JSON.parse(raw) as { suggestions?: AiRecipeSuggestion[] };
return { suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions : [] };
} catch {
this.logger.error(`Kunde inte parsa AI-svar för receptförslag: ${raw}`);
return { suggestions: [] };
}
}
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) => {
// Kör matchning mot alla alternativ och slå ihop suggestions
const alternatives = ingredient.alternatives?.length > 1
? ingredient.alternatives
: [ingredient.rawName];
const scoreProduct = (query: string) => 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);
// Slå ihop suggestions från alla alternativ, deduplicera på productId, ta topp 5
const seenIds = new Set<number>();
const scored = alternatives
.flatMap((alt) => scoreProduct(normalize(alt)))
.filter((s) => {
if (seenIds.has(s.product.id)) return false;
seenIds.add(s.product.id);
return true;
})
.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,
rawLine: ingredient.rawName,
alternatives: ingredient.alternatives ?? [],
quantity: ingredient.quantity,
unit: ingredient.unit,
note: ingredient.note,
suggestions: scored,
};
});
return {
name: parsed.name,
description: parsed.description,
instructions: parsed.instructions,
ingredients: ingredientsWithSuggestions,
};
}
}