Files
recipe-app/backend/dist/recipes/recipes.service.js
T
Nils-Johan Gynther 9fe85a719c
Test Suite / test (24.15.0) (push) Has been cancelled
feat: implement recipe analysis service and data models
- Added RecipeAnalysisService to handle recipe ingredient analysis, including methods for checking ingredient availability and calculating quantities.
- Introduced new TypeScript definitions for recipe analysis results, including ingredient status and summary.
- Created corresponding Dart models for recipe analysis, including RecipeIngredientAnalysis, RecipeAnalysisSummary, and RecipeShoppingCandidate.
- Updated Flutter UI to reflect changes in ingredient availability status.
- Fixed color opacity issue in recipe image card.
2026-05-06 07:54:03 +02:00

688 lines
29 KiB
JavaScript

"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var RecipesService_1;
Object.defineProperty(exports, "__esModule", { value: true });
exports.RecipesService = void 0;
const common_1 = require("@nestjs/common");
const fs = require("node:fs/promises");
const path = require("node:path");
const prisma_service_1 = require("../prisma/prisma.service");
const ai_service_1 = require("../ai/ai.service");
const download_image_1 = require("../common/utils/download-image");
const recipe_parser_1 = require("../common/utils/recipe-parser");
const units_1 = require("../common/utils/units");
const IMAGE_DEST_DIR = process.env.IMAGE_DEST_DIR || '/app/recipe-images';
let RecipesService = RecipesService_1 = class RecipesService {
constructor(prisma, aiService) {
this.prisma = prisma;
this.aiService = aiService;
this.logger = new common_1.Logger(RecipesService_1.name);
}
throwRecipeNotFound(id) {
throw new common_1.NotFoundException(`Recipe with id ${id} not found`);
}
normalizeIngredientName(value) {
const trimmed = value.trim();
if (!trimmed)
return trimmed;
return `${trimmed.charAt(0).toUpperCase()}${trimmed.slice(1)}`;
}
async assertProductsActive(productIds) {
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 common_1.BadRequestException(`En eller flera ingrediensprodukter är inaktiva eller finns inte: ${missing.join(', ')}`);
}
}
async findRecipeByIdOrThrow(id) {
const recipe = await this.prisma.recipe.findUnique({ where: { id } });
if (!recipe) {
this.throwRecipeNotFound(id);
}
return recipe;
}
async assertAndClaimRecipeOwner(recipe, userId) {
if (recipe.ownerId === null) {
await this.prisma.recipe.update({
where: { id: recipe.id },
data: { ownerId: userId },
});
}
else if (recipe.ownerId !== userId) {
this.throwRecipeNotFound(recipe.id);
}
}
assertRecipeOwnedByUser(recipe, userId, id) {
if (recipe.ownerId !== userId) {
this.throwRecipeNotFound(id);
}
}
async getInventoryPreview(id, userId) {
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 common_1.NotFoundException(`Recipe with id ${id} not found`);
}
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) => {
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',
fromPantry: false,
missingQuantity: Number(ingredient.quantity ?? 0),
};
}
const requiredUnit = (ingredient.unit ?? '').trim();
const requiredQuantity = Number(ingredient.quantity ?? 0);
const coveredByPantry = pantryProductIds.has(ingredient.productId) ||
(Array.isArray(ingredient.alternativeProductIds) &&
ingredient.alternativeProductIds.some((altId) => pantryProductIds.has(altId)));
if (coveredByPantry) {
return {
ingredientId: ingredient.id,
productId: ingredient.productId,
productName: ingredient.product.canonicalName || ingredient.product.name,
requiredQuantity,
requiredUnit,
note: ingredient.note,
availableQuantity: requiredQuantity,
availableUnit: requiredUnit,
matchingInventoryItems: [],
otherInventoryItems: [],
status: 'enough',
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' },
});
const sameUnitItems = inventoryItems.filter((item) => requiredUnit
? item.unit.trim().toLowerCase() === requiredUnit.toLowerCase()
: true);
const availableSameUnit = sameUnitItems.reduce((sum, item) => sum + Number(item.quantity), 0);
const otherUnitItems = inventoryItems.filter((item) => requiredUnit
? item.unit.trim().toLowerCase() !== requiredUnit.toLowerCase()
: false);
let availableOtherUnit = 0;
for (const item of otherUnitItems) {
try {
const convertedQuantity = (0, units_1.convertUnit)(Number(item.quantity), item.unit, requiredUnit);
availableOtherUnit += convertedQuantity;
}
catch {
}
}
const totalAvailable = availableSameUnit + availableOtherUnit;
let status;
if (totalAvailable >= requiredQuantity) {
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,
requiredUnit,
note: ingredient.note,
availableQuantity: totalAvailable,
availableUnit: requiredUnit,
matchingInventoryItems: sameUnitItems.map((item) => ({
id: item.id,
quantity: item.quantity,
unit: item.unit,
location: item.location,
brand: item.brand || null,
bestBeforeDate: item.bestBeforeDate || null,
})),
otherInventoryItems: otherUnitItems.map((item) => {
const canConvertUnits = requiredUnit ? (0, units_1.canConvert)(item.unit, requiredUnit) : false;
let convertedQuantity = 0;
if (canConvertUnits) {
try {
convertedQuantity = (0, units_1.convertUnit)(Number(item.quantity), item.unit, requiredUnit);
}
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, requiredQuantity - totalAvailable) : 0,
};
}));
const summary = {
totalIngredients: ingredientPreviews.length,
enoughCount: ingredientPreviews.filter((i) => i.status === 'enough').length,
missingCount: ingredientPreviews.filter((i) => i.status === 'missing').length,
unitMismatchCount: ingredientPreviews.filter((i) => i.status === 'unit_mismatch').length,
canCookExactly: ingredientPreviews.every((i) => i.status === 'enough'),
pantryCount: ingredientPreviews.filter((i) => i.fromPantry).length,
};
return {
recipe: {
id: recipe.id,
name: recipe.name,
description: recipe.description,
},
ingredients: ingredientPreviews,
summary,
};
}
async findAll(userId) {
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, userId) {
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 common_1.NotFoundException(`Recipe with id ${id} not found`);
}
return recipe;
}
async update(id, updateRecipeDto, userId) {
const existingRecipe = await this.findRecipeByIdOrThrow(id);
await this.assertAndClaimRecipeOwner(existingRecipe, userId);
await this.assertProductsActive(updateRecipeDto.ingredients
.map((i) => i.productId)
.filter((id) => typeof id === 'number'));
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: this.normalizeIngredientName(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, userId) {
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 } });
if (existingRecipe.imageUrl?.startsWith('/images/')) {
const filename = path.basename(existingRecipe.imageUrl);
const filePath = path.join(IMAGE_DEST_DIR, filename);
await fs.unlink(filePath).catch(() => {
});
}
}
async updateImage(id, sourceUrl, userId) {
const existingRecipe = await this.findRecipeByIdOrThrow(id);
this.assertRecipeOwnedByUser(existingRecipe, userId, id);
const imageUrl = await (0, download_image_1.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, userId, isPublic) {
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 common_1.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, ownerId, username) {
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 common_1.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 common_1.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, ownerId, username) {
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 common_1.NotFoundException(`User ${username} not found`);
}
await this.prisma.recipeShare.deleteMany({
where: { recipeId: id, userId: targetUser.id },
});
return this.findOne(id, ownerId);
}
async create(createRecipeDto, userId) {
await this.assertProductsActive(createRecipeDto.ingredients
.map((i) => i.productId)
.filter((id) => typeof id === 'number'));
this.logger.log(`[create] Incoming imageUrl from client: ${createRecipeDto.imageUrl ?? 'null'}`);
let imageUrl = createRecipeDto.imageUrl || null;
let downloadedImagePath = null;
if (imageUrl && imageUrl.startsWith('http')) {
const externalImageUrl = imageUrl;
try {
imageUrl = await (0, download_image_1.downloadAndOptimizeImage)(imageUrl, IMAGE_DEST_DIR);
downloadedImagePath = imageUrl;
}
catch (err) {
console.warn('[RecipesService] Kunde inte ladda ner receptbild:', err);
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: this.normalizeIngredientName(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) {
if (downloadedImagePath) {
await fs.unlink(path.join(IMAGE_DEST_DIR, path.basename(downloadedImagePath))).catch(() => { });
}
throw err;
}
}
async addIngredient(id, ingredient, userId) {
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) {
const inventoryItems = await this.prisma.inventoryItem.findMany({
include: { product: { select: { canonicalName: true, name: true } } },
orderBy: { bestBeforeDate: 'asc' },
});
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: [] };
}
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();
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);
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) {
const importerUrl = process.env.IMPORTER_SERVICE_URL || 'http://importer-api:3001';
let parsed;
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());
}
catch (err) {
this.logger.error(`Kunde inte nå importer-api för parse-markdown: ${err}`);
parsed = (0, recipe_parser_1.parseRecipeMarkdown)(dto.markdown);
}
const allProducts = await this.prisma.product.findMany({
where: { isActive: true },
select: { id: true, name: true, canonicalName: true, normalizedName: true },
});
const normalize = (s) => s.toLowerCase().trim().replace(/[^a-zåäö0-9\s]/gi, '').replace(/\s+/g, ' ');
const levenshtein = (a, b) => {
const m = a.length;
const n = b.length;
const dp = 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) => {
const alternatives = ingredient.alternatives?.length > 1
? ingredient.alternatives
: [ingredient.rawName];
const scoreProduct = (query) => allProducts
.map((product) => {
const targetName = normalize(product.canonicalName || product.name);
const targetNormalized = normalize(product.normalizedName);
if (targetNormalized === query || targetName === query) {
return { product, score: 100 };
}
if (targetName.includes(query) || query.includes(targetName)) {
return { product, score: 70 };
}
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);
const seenIds = new Set();
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,
};
}
};
exports.RecipesService = RecipesService;
exports.RecipesService = RecipesService = RecipesService_1 = __decorate([
(0, common_1.Injectable)(),
__metadata("design:paramtypes", [prisma_service_1.PrismaService,
ai_service_1.AiService])
], RecipesService);
//# sourceMappingURL=recipes.service.js.map