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
Test Suite / test (24.15.0) (push) Has been cancelled
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user