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 { 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) {
|
||||
|
||||
Reference in New Issue
Block a user