feat: refactor recipe ownership logic; auto-claim ownerless recipes and ensure atomic updates for ingredients
Test Suite / test (24.15.0) (push) Has been cancelled

This commit is contained in:
Nils-Johan Gynther
2026-05-04 21:33:33 +02:00
parent b7c857732c
commit b52205c8c3
+77 -62
View File
@@ -6,7 +6,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { CreateRecipeDto } from './dto/create-recipe.dto'; import { CreateRecipeDto } from './dto/create-recipe.dto';
import { ParseMarkdownDto } from './dto/parse-markdown.dto'; import { ParseMarkdownDto } from './dto/parse-markdown.dto';
import { downloadAndOptimizeImage } from '../common/utils/download-image'; 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'; import { normalizeUnit, getUnitType, convertUnit, canConvert } from '../common/utils/units';
const IMAGE_DEST_DIR = process.env.IMAGE_DEST_DIR || '/app/recipe-images'; const IMAGE_DEST_DIR = process.env.IMAGE_DEST_DIR || '/app/recipe-images';
@@ -44,10 +44,18 @@ export class RecipesService {
return recipe; return recipe;
} }
private assertRecipeEditableByUser(recipe: { ownerId: number | null }, userId: number, id: number) { private async assertAndClaimRecipeOwner(
// Legacy behavior: ownerless recipes are editable to preserve existing semantics. recipe: { id: number; ownerId: number | null },
if (recipe.ownerId !== null && recipe.ownerId !== userId) { userId: number,
this.throwRecipeNotFound(id); ): 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);
} }
} }
@@ -245,42 +253,40 @@ export class RecipesService {
async update(id: number, updateRecipeDto: CreateRecipeDto, userId: number) { async update(id: number, updateRecipeDto: CreateRecipeDto, userId: number) {
const existingRecipe = await this.findRecipeByIdOrThrow(id); const existingRecipe = await this.findRecipeByIdOrThrow(id);
this.assertRecipeEditableByUser(existingRecipe, userId, id); await this.assertAndClaimRecipeOwner(existingRecipe, userId);
// Validera att alla produkter är aktiva // Validera att alla produkter är aktiva
await this.assertProductsActive(updateRecipeDto.ingredients.map((i) => i.productId)); await this.assertProductsActive(updateRecipeDto.ingredients.map((i) => i.productId));
// Ta bort gamla ingredienser // Transaktionsblock: ta bort gamla + skapa nya ingredienser atomärt
await this.prisma.recipeIngredient.deleteMany({ const recipe = await this.prisma.$transaction(async (tx) => {
where: { recipeId: id }, await tx.recipeIngredient.deleteMany({ where: { recipeId: id } });
}); return tx.recipe.update({
where: { id },
// Uppdatera receptet och lägg till nya ingredienser data: {
const recipe = await this.prisma.recipe.update({ name: updateRecipeDto.name,
where: { id }, description: updateRecipeDto.description || null,
data: { instructions: updateRecipeDto.instructions || null,
name: updateRecipeDto.name, servings: updateRecipeDto.servings ?? null,
description: updateRecipeDto.description || null, ...(updateRecipeDto.isPublic !== undefined && { isPublic: updateRecipeDto.isPublic }),
instructions: updateRecipeDto.instructions || null, ...(updateRecipeDto.imageUrl !== undefined && { imageUrl: updateRecipeDto.imageUrl || null }),
servings: updateRecipeDto.servings ?? null, ingredients: {
...(updateRecipeDto.isPublic !== undefined && { isPublic: updateRecipeDto.isPublic }), create: updateRecipeDto.ingredients.map((ingredient) => ({
...(updateRecipeDto.imageUrl !== undefined && { imageUrl: updateRecipeDto.imageUrl || null }), productId: ingredient.productId,
ingredients: { quantity: ingredient.quantity,
create: updateRecipeDto.ingredients.map((ingredient) => ({ unit: ingredient.unit,
productId: ingredient.productId, note: ingredient.note || null,
quantity: ingredient.quantity, })),
unit: ingredient.unit,
note: ingredient.note || null,
})),
},
},
include: {
ingredients: {
include: {
product: { include: { nutrition: true } },
}, },
}, },
}, include: {
ingredients: {
include: {
product: { include: { nutrition: true } },
},
},
},
});
}); });
return recipe; return recipe;
@@ -288,7 +294,7 @@ export class RecipesService {
async remove(id: number, userId: number) { async remove(id: number, userId: number) {
const existingRecipe = await this.findRecipeByIdOrThrow(id); 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.recipeIngredient.deleteMany({ where: { recipeId: id } });
await this.prisma.recipe.delete({ where: { 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 // Om imageUrl är en extern URL — ladda ner och optimera
let imageUrl: string | null = createRecipeDto.imageUrl || null; let imageUrl: string | null = createRecipeDto.imageUrl || null;
let downloadedImagePath: string | null = null;
if (imageUrl && imageUrl.startsWith('http')) { if (imageUrl && imageUrl.startsWith('http')) {
const externalImageUrl = imageUrl; const externalImageUrl = imageUrl;
try { try {
imageUrl = await downloadAndOptimizeImage(imageUrl, IMAGE_DEST_DIR); imageUrl = await downloadAndOptimizeImage(imageUrl, IMAGE_DEST_DIR);
downloadedImagePath = imageUrl;
} catch (err) { } catch (err) {
console.warn('[RecipesService] Kunde inte ladda ner receptbild:', err); console.warn('[RecipesService] Kunde inte ladda ner receptbild:', err);
// Behåll extern URL som fallback så bild fortfarande visas. // 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'}`); this.logger.log(`[create] Final imageUrl persisted to DB: ${imageUrl ?? 'null'}`);
const recipe = await this.prisma.recipe.create({ try {
data: { const recipe = await this.prisma.recipe.create({
name: createRecipeDto.name, data: {
description: createRecipeDto.description || null, name: createRecipeDto.name,
instructions: createRecipeDto.instructions || null, description: createRecipeDto.description || null,
imageUrl, instructions: createRecipeDto.instructions || null,
servings: createRecipeDto.servings ?? null, imageUrl,
ownerId: userId, servings: createRecipeDto.servings ?? null,
isPublic: false, ownerId: userId,
ingredients: { isPublic: false,
create: createRecipeDto.ingredients.map((ingredient) => ({ ingredients: {
productId: ingredient.productId, create: createRecipeDto.ingredients.map((ingredient) => ({
quantity: ingredient.quantity, productId: ingredient.productId,
unit: ingredient.unit, quantity: ingredient.quantity,
note: ingredient.note || null, unit: ingredient.unit,
})), note: ingredient.note || null,
}, })),
},
include: {
ingredients: {
include: {
product: { include: { nutrition: true } },
}, },
}, },
}, include: {
}); ingredients: {
include: {
return recipe; 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) { async parseMarkdown(dto: ParseMarkdownDto) {