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 { 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<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) {
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) {