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 { 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,18 +253,15 @@ 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({
// 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,
@@ -282,13 +287,14 @@ export class RecipesService {
},
},
});
});
return recipe;
}
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,6 +427,7 @@ export class RecipesService {
this.logger.log(`[create] Final imageUrl persisted to DB: ${imageUrl ?? 'null'}`);
try {
const recipe = await this.prisma.recipe.create({
data: {
name: createRecipeDto.name,
@@ -445,8 +454,14 @@ export class RecipesService {
},
},
});
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) {