diff --git a/backend/src/recipes/recipes.service.ts b/backend/src/recipes/recipes.service.ts index 5d245710..534ffaa4 100644 --- a/backend/src/recipes/recipes.service.ts +++ b/backend/src/recipes/recipes.service.ts @@ -6,7 +6,7 @@ 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 { 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'; @@ -44,10 +44,18 @@ export class RecipesService { 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 async assertAndClaimRecipeOwner( + recipe: { id: number; ownerId: number | null }, + userId: number, + ): Promise { + 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); } } @@ -245,42 +253,40 @@ export class RecipesService { async update(id: number, updateRecipeDto: CreateRecipeDto, userId: number) { const existingRecipe = await this.findRecipeByIdOrThrow(id); - this.assertRecipeEditableByUser(existingRecipe, userId, id); + await this.assertAndClaimRecipeOwner(existingRecipe, userId); // Validera att alla produkter är aktiva await this.assertProductsActive(updateRecipeDto.ingredients.map((i) => i.productId)); - // 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 } }, + // 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, + quantity: ingredient.quantity, + unit: ingredient.unit, + note: ingredient.note || null, + })), }, }, - }, + include: { + ingredients: { + include: { + product: { include: { nutrition: true } }, + }, + }, + }, + }); }); return recipe; @@ -288,7 +294,7 @@ export class RecipesService { async remove(id: number, userId: number) { const existingRecipe = await this.findRecipeByIdOrThrow(id); - this.assertRecipeEditableByUser(existingRecipe, userId, id); + await this.assertAndClaimRecipeOwner(existingRecipe, userId); await this.prisma.recipeIngredient.deleteMany({ where: { recipeId: id } }); await this.prisma.recipe.delete({ where: { id } }); @@ -406,10 +412,12 @@ export class RecipesService { // 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. @@ -419,34 +427,41 @@ export class RecipesService { 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 } }, + 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, + quantity: ingredient.quantity, + unit: ingredient.unit, + note: ingredient.note || null, + })), }, }, - }, - }); - - return recipe; + 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 parseMarkdown(dto: ParseMarkdownDto) {