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
+30 -15
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,18 +253,15 @@ 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({
// Uppdatera receptet och lägg till nya ingredienser
const recipe = await this.prisma.recipe.update({
where: { id }, where: { id },
data: { data: {
name: updateRecipeDto.name, name: updateRecipeDto.name,
@@ -282,13 +287,14 @@ export class RecipesService {
}, },
}, },
}); });
});
return recipe; return recipe;
} }
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,6 +427,7 @@ 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'}`);
try {
const recipe = await this.prisma.recipe.create({ const recipe = await this.prisma.recipe.create({
data: { data: {
name: createRecipeDto.name, name: createRecipeDto.name,
@@ -445,8 +454,14 @@ export class RecipesService {
}, },
}, },
}); });
return recipe; 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) {