feat: refactor inventory and recipe services for improved error handling and code reuse; add systematic backend review plan
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:
@@ -14,6 +14,26 @@ type InventoryQuery = {
|
||||
export class InventoryService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
private throwInventoryItemNotFound(id: number): never {
|
||||
throw new NotFoundException(`Inventory item with id ${id} not found`);
|
||||
}
|
||||
|
||||
private async findInventoryItemByIdOrThrow(id: number) {
|
||||
const existing = await this.prisma.inventoryItem.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
this.throwInventoryItemNotFound(id);
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
private async ensureProductExists(productId: number) {
|
||||
const product = await this.prisma.product.findUnique({ where: { id: productId } });
|
||||
if (!product) {
|
||||
throw new NotFoundException('Product not found');
|
||||
}
|
||||
return product;
|
||||
}
|
||||
|
||||
async findAll(query?: InventoryQuery) {
|
||||
const where: Prisma.InventoryItemWhereInput = {};
|
||||
const orderBy: Prisma.InventoryItemOrderByWithRelationInput[] = [];
|
||||
@@ -46,16 +66,7 @@ export class InventoryService {
|
||||
}
|
||||
|
||||
async consume(id: number, data: ConsumeInventoryDto) {
|
||||
const existing = await this.prisma.inventoryItem.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
product: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException(`Inventory item with id ${id} not found`);
|
||||
}
|
||||
const existing = await this.findInventoryItemByIdOrThrow(id);
|
||||
|
||||
const currentQuantity = Number(existing.quantity);
|
||||
const newQuantity = Math.max(0, currentQuantity - data.amountUsed);
|
||||
@@ -84,13 +95,7 @@ export class InventoryService {
|
||||
}
|
||||
|
||||
async findConsumptionHistory(id: number) {
|
||||
const existing = await this.prisma.inventoryItem.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException(`Inventory item with id ${id} not found`);
|
||||
}
|
||||
await this.findInventoryItemByIdOrThrow(id);
|
||||
|
||||
return this.prisma.inventoryConsumption.findMany({
|
||||
where: {
|
||||
@@ -129,13 +134,7 @@ export class InventoryService {
|
||||
}
|
||||
|
||||
async create(data: CreateInventoryDto) {
|
||||
const product = await this.prisma.product.findUnique({
|
||||
where: { id: data.productId },
|
||||
});
|
||||
|
||||
if (!product) {
|
||||
throw new NotFoundException('Product not found');
|
||||
}
|
||||
await this.ensureProductExists(data.productId);
|
||||
|
||||
return this.prisma.inventoryItem.create({
|
||||
data: {
|
||||
@@ -161,22 +160,10 @@ export class InventoryService {
|
||||
}
|
||||
|
||||
async update(id: number, data: UpdateInventoryDto) {
|
||||
const existing = await this.prisma.inventoryItem.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException(`Inventory item with id ${id} not found`);
|
||||
}
|
||||
await this.findInventoryItemByIdOrThrow(id);
|
||||
|
||||
if (typeof data.productId === 'number') {
|
||||
const product = await this.prisma.product.findUnique({
|
||||
where: { id: data.productId },
|
||||
});
|
||||
|
||||
if (!product) {
|
||||
throw new NotFoundException('Product not found');
|
||||
}
|
||||
await this.ensureProductExists(data.productId);
|
||||
}
|
||||
|
||||
const updateData: Prisma.InventoryItemUpdateInput = {};
|
||||
@@ -241,10 +228,7 @@ export class InventoryService {
|
||||
}
|
||||
|
||||
async remove(id: number) {
|
||||
const existing = await this.prisma.inventoryItem.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
throw new NotFoundException(`Inventory item with id ${id} not found`);
|
||||
}
|
||||
await this.findInventoryItemByIdOrThrow(id);
|
||||
return this.prisma.inventoryItem.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
@@ -68,11 +68,8 @@ export class MealPlanService {
|
||||
return this.prisma.mealPlanEntry.delete({ where: { id: entry.id } });
|
||||
}
|
||||
|
||||
/** Samlad ingredienslista för ett datumintervall */
|
||||
async shoppingList(userId: number, from: string, to: string) {
|
||||
const entries = await this.findByRange(userId, from, to);
|
||||
|
||||
// Summera ingredienser per produkt+enhet (skalat per portionsantal)
|
||||
/** Aggregerar ingredienser per produkt+enhet från matplansposter, skalat per portionsantal */
|
||||
private aggregateIngredients(entries: Awaited<ReturnType<typeof this.findByRange>>) {
|
||||
const map = new Map<string, { productId: number; name: string; quantity: number; unit: string }>();
|
||||
for (const entry of entries) {
|
||||
const recipeServings = (entry.recipe as any).servings as number | null;
|
||||
@@ -80,8 +77,8 @@ export class MealPlanService {
|
||||
const scale = recipeServings && entryServings ? entryServings / recipeServings : 1;
|
||||
for (const ing of entry.recipe.ingredients) {
|
||||
const key = `${ing.product.id}-${ing.unit}`;
|
||||
const existing = map.get(key);
|
||||
const qty = Number(ing.quantity) * scale;
|
||||
const existing = map.get(key);
|
||||
if (existing) {
|
||||
existing.quantity += qty;
|
||||
} else {
|
||||
@@ -94,8 +91,13 @@ export class MealPlanService {
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
return Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name, 'sv'));
|
||||
/** Samlad ingredienslista för ett datumintervall */
|
||||
async shoppingList(userId: number, from: string, to: string) {
|
||||
const entries = await this.findByRange(userId, from, to);
|
||||
return this.aggregateIngredients(entries).sort((a, b) => a.name.localeCompare(b.name, 'sv'));
|
||||
}
|
||||
|
||||
/** Jämför veckans ingrediensbehov mot inventariet */
|
||||
@@ -109,32 +111,16 @@ export class MealPlanService {
|
||||
});
|
||||
const pantryProductIds = new Set(pantryItems.map((p) => p.productId));
|
||||
|
||||
// Aggregera ingredienser per produkt+enhet (skalat per portionsantal)
|
||||
const map = new Map<string, { productId: number; name: string; required: number; unit: string }>();
|
||||
for (const entry of entries) {
|
||||
const recipeServings = (entry.recipe as any).servings as number | null;
|
||||
const entryServings = (entry as any).servings as number | null;
|
||||
const scale = recipeServings && entryServings ? entryServings / recipeServings : 1;
|
||||
for (const ing of entry.recipe.ingredients) {
|
||||
const key = `${ing.product.id}-${ing.unit}`;
|
||||
const qty = Number(ing.quantity) * scale;
|
||||
const existing = map.get(key);
|
||||
if (existing) {
|
||||
existing.required += qty;
|
||||
} else {
|
||||
map.set(key, {
|
||||
productId: ing.product.id,
|
||||
name: ing.product.canonicalName || ing.product.name,
|
||||
required: qty,
|
||||
unit: ing.unit,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const aggregated = this.aggregateIngredients(entries).map((item) => ({
|
||||
productId: item.productId,
|
||||
name: item.name,
|
||||
required: item.quantity,
|
||||
unit: item.unit,
|
||||
}));
|
||||
|
||||
// Kontrollera inventariet för varje ingrediens
|
||||
const result = await Promise.all(
|
||||
Array.from(map.values()).map(async (item) => {
|
||||
aggregated.map(async (item) => {
|
||||
// Pantry-varor anses alltid tillgängliga — visa inte i inköpslistan
|
||||
if (pantryProductIds.has(item.productId)) {
|
||||
return {
|
||||
|
||||
@@ -236,10 +236,7 @@ export class ProductsService {
|
||||
}
|
||||
|
||||
async permanentDelete(id: number) {
|
||||
const product = await this.prisma.product.findUnique({ where: { id } });
|
||||
if (!product) {
|
||||
throw new NotFoundException(`Product with id ${id} not found`);
|
||||
}
|
||||
await this.findOne(id);
|
||||
// Ta bort beroenden först
|
||||
await this.prisma.productTag.deleteMany({ where: { productId: id } });
|
||||
await this.prisma.userProduct.deleteMany({ where: { productId: id } });
|
||||
@@ -262,30 +259,21 @@ export class ProductsService {
|
||||
});
|
||||
}
|
||||
|
||||
private async findProductByIdOrThrow(id: number, label: string) {
|
||||
const product = await this.prisma.product.findUnique({ where: { id } });
|
||||
if (!product) {
|
||||
throw new NotFoundException(`${label} product with id ${id} not found`);
|
||||
}
|
||||
return product;
|
||||
}
|
||||
|
||||
async merge(sourceProductId: number, targetProductId: number) {
|
||||
if (sourceProductId === targetProductId) {
|
||||
throw new Error('sourceProductId och targetProductId kan inte vara samma');
|
||||
}
|
||||
|
||||
const source = await this.prisma.product.findUnique({
|
||||
where: { id: sourceProductId },
|
||||
});
|
||||
|
||||
if (!source) {
|
||||
throw new NotFoundException(
|
||||
`Source product with id ${sourceProductId} not found`,
|
||||
);
|
||||
}
|
||||
|
||||
const target = await this.prisma.product.findUnique({
|
||||
where: { id: targetProductId },
|
||||
});
|
||||
|
||||
if (!target) {
|
||||
throw new NotFoundException(
|
||||
`Target product with id ${targetProductId} not found`,
|
||||
);
|
||||
}
|
||||
const source = await this.findProductByIdOrThrow(sourceProductId, 'Source');
|
||||
const target = await this.findProductByIdOrThrow(targetProductId, 'Target');
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const movedInventoryCount = await tx.inventoryItem.updateMany({
|
||||
@@ -318,12 +306,8 @@ export class ProductsService {
|
||||
|
||||
const [source, target, sourceInventoryCount, targetInventoryCount] =
|
||||
await Promise.all([
|
||||
this.prisma.product.findUnique({
|
||||
where: { id: sourceProductId },
|
||||
}),
|
||||
this.prisma.product.findUnique({
|
||||
where: { id: targetProductId },
|
||||
}),
|
||||
this.findProductByIdOrThrow(sourceProductId, 'Source'),
|
||||
this.findProductByIdOrThrow(targetProductId, 'Target'),
|
||||
this.prisma.inventoryItem.count({
|
||||
where: { productId: sourceProductId },
|
||||
}),
|
||||
@@ -332,18 +316,6 @@ export class ProductsService {
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!source) {
|
||||
throw new NotFoundException(
|
||||
`Source product with id ${sourceProductId} not found`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
throw new NotFoundException(
|
||||
`Target product with id ${targetProductId} not found`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
source: {
|
||||
...source,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateReceiptAliasDto } from './dto/create-receipt-alias.dto';
|
||||
|
||||
@@ -25,59 +25,48 @@ export class ReceiptAliasService {
|
||||
|
||||
async upsert(dto: CreateReceiptAliasDto, userId: number, role: string) {
|
||||
const normalized = dto.receiptName.toLowerCase().trim();
|
||||
|
||||
const wantsGlobal = dto.isGlobal === true;
|
||||
|
||||
if (wantsGlobal && role !== 'admin') {
|
||||
throw new ForbiddenException('Endast admin kan skapa globala alias');
|
||||
}
|
||||
|
||||
if (wantsGlobal) {
|
||||
const existing = await this.prisma.receiptAlias.findFirst({
|
||||
where: { receiptName: normalized, isGlobal: true },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return this.prisma.receiptAlias.update({
|
||||
where: { id: existing.id },
|
||||
data: { productId: dto.productId },
|
||||
});
|
||||
}
|
||||
|
||||
return this.prisma.receiptAlias.create({
|
||||
data: {
|
||||
receiptName: normalized,
|
||||
productId: dto.productId,
|
||||
isGlobal: true,
|
||||
ownerId: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
return this.upsertAliasRecord(
|
||||
normalized,
|
||||
dto.productId,
|
||||
wantsGlobal ? null : userId,
|
||||
wantsGlobal,
|
||||
);
|
||||
}
|
||||
|
||||
private async upsertAliasRecord(
|
||||
receiptName: string,
|
||||
productId: number,
|
||||
ownerId: number | null,
|
||||
isGlobal: boolean,
|
||||
) {
|
||||
const existing = await this.prisma.receiptAlias.findFirst({
|
||||
where: { receiptName: normalized, ownerId: userId, isGlobal: false },
|
||||
where: isGlobal
|
||||
? { receiptName, isGlobal: true }
|
||||
: { receiptName, ownerId, isGlobal: false },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return this.prisma.receiptAlias.update({
|
||||
where: { id: existing.id },
|
||||
data: { productId: dto.productId },
|
||||
data: { productId },
|
||||
});
|
||||
}
|
||||
|
||||
return this.prisma.receiptAlias.create({
|
||||
data: {
|
||||
receiptName: normalized,
|
||||
productId: dto.productId,
|
||||
ownerId: userId,
|
||||
isGlobal: false,
|
||||
},
|
||||
data: { receiptName, productId, ownerId, isGlobal },
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: number, userId: number, role: string) {
|
||||
const alias = await this.prisma.receiptAlias.findUnique({ where: { id } });
|
||||
if (!alias) {
|
||||
return this.prisma.receiptAlias.delete({ where: { id } });
|
||||
throw new NotFoundException(`Aliaspost med id ${id} hittades inte`);
|
||||
}
|
||||
|
||||
const canDelete =
|
||||
|
||||
@@ -31,6 +31,31 @@ export class RecipesService {
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
private throwRecipeNotFound(id: number): never {
|
||||
throw new NotFoundException(`Recipe with id ${id} not found`);
|
||||
}
|
||||
|
||||
private async findRecipeByIdOrThrow(id: number) {
|
||||
const recipe = await this.prisma.recipe.findUnique({ where: { id } });
|
||||
if (!recipe) {
|
||||
this.throwRecipeNotFound(id);
|
||||
}
|
||||
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 assertRecipeOwnedByUser(recipe: { ownerId: number | null }, userId: number, id: number) {
|
||||
if (recipe.ownerId !== userId) {
|
||||
this.throwRecipeNotFound(id);
|
||||
}
|
||||
}
|
||||
|
||||
async getInventoryPreview(id: number, userId: number) {
|
||||
const recipe = await this.prisma.recipe.findFirst({
|
||||
where: {
|
||||
@@ -218,19 +243,8 @@ export class RecipesService {
|
||||
}
|
||||
|
||||
async update(id: number, updateRecipeDto: CreateRecipeDto, userId: number) {
|
||||
// Verifiera att receptet finns och att användaren äger det
|
||||
const existingRecipe = await this.prisma.recipe.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!existingRecipe) {
|
||||
throw new NotFoundException(`Recipe with id ${id} not found`);
|
||||
}
|
||||
|
||||
// Tillåt uppdatering om användaren är ägare ELLER om receptet är publikt utan ägare
|
||||
if (existingRecipe.ownerId !== null && existingRecipe.ownerId !== userId) {
|
||||
throw new NotFoundException(`Recipe with id ${id} not found`);
|
||||
}
|
||||
const existingRecipe = await this.findRecipeByIdOrThrow(id);
|
||||
this.assertRecipeEditableByUser(existingRecipe, userId, id);
|
||||
|
||||
// Ta bort gamla ingredienser
|
||||
await this.prisma.recipeIngredient.deleteMany({
|
||||
@@ -269,17 +283,8 @@ export class RecipesService {
|
||||
}
|
||||
|
||||
async remove(id: number, userId: number) {
|
||||
const existingRecipe = await this.prisma.recipe.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!existingRecipe) {
|
||||
throw new NotFoundException(`Recipe with id ${id} not found`);
|
||||
}
|
||||
|
||||
if (existingRecipe.ownerId !== null && existingRecipe.ownerId !== userId) {
|
||||
throw new NotFoundException(`Recipe with id ${id} not found`);
|
||||
}
|
||||
const existingRecipe = await this.findRecipeByIdOrThrow(id);
|
||||
this.assertRecipeEditableByUser(existingRecipe, userId, id);
|
||||
|
||||
await this.prisma.recipeIngredient.deleteMany({ where: { recipeId: id } });
|
||||
await this.prisma.recipe.delete({ where: { id } });
|
||||
@@ -295,13 +300,8 @@ export class RecipesService {
|
||||
}
|
||||
|
||||
async updateImage(id: number, sourceUrl: string, userId: number) {
|
||||
const existingRecipe = await this.prisma.recipe.findUnique({ where: { id } });
|
||||
if (!existingRecipe) {
|
||||
throw new NotFoundException(`Recipe with id ${id} not found`);
|
||||
}
|
||||
if (existingRecipe.ownerId !== userId) {
|
||||
throw new NotFoundException(`Recipe with id ${id} not found`);
|
||||
}
|
||||
const existingRecipe = await this.findRecipeByIdOrThrow(id);
|
||||
this.assertRecipeOwnedByUser(existingRecipe, userId, id);
|
||||
|
||||
const imageUrl = await downloadAndOptimizeImage(sourceUrl, IMAGE_DEST_DIR);
|
||||
|
||||
@@ -317,10 +317,8 @@ export class RecipesService {
|
||||
}
|
||||
|
||||
async setVisibility(id: number, userId: number, isPublic: boolean) {
|
||||
const existingRecipe = await this.prisma.recipe.findUnique({ where: { id } });
|
||||
if (!existingRecipe || existingRecipe.ownerId !== userId) {
|
||||
throw new NotFoundException(`Recipe with id ${id} not found`);
|
||||
}
|
||||
const existingRecipe = await this.findRecipeByIdOrThrow(id);
|
||||
this.assertRecipeOwnedByUser(existingRecipe, userId, id);
|
||||
|
||||
if (isPublic) {
|
||||
const owner = await this.prisma.user.findUnique({
|
||||
@@ -344,13 +342,8 @@ export class RecipesService {
|
||||
}
|
||||
|
||||
async shareWithUser(id: number, ownerId: number, username: string) {
|
||||
const recipe = await this.prisma.recipe.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, ownerId: true },
|
||||
});
|
||||
if (!recipe || recipe.ownerId !== ownerId) {
|
||||
throw new NotFoundException(`Recipe with id ${id} not found`);
|
||||
}
|
||||
const recipe = await this.findRecipeByIdOrThrow(id);
|
||||
this.assertRecipeOwnedByUser(recipe, ownerId, id);
|
||||
|
||||
const owner = await this.prisma.user.findUnique({
|
||||
where: { id: ownerId },
|
||||
@@ -381,13 +374,8 @@ export class RecipesService {
|
||||
}
|
||||
|
||||
async unshareWithUser(id: number, ownerId: number, username: string) {
|
||||
const recipe = await this.prisma.recipe.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, ownerId: true },
|
||||
});
|
||||
if (!recipe || recipe.ownerId !== ownerId) {
|
||||
throw new NotFoundException(`Recipe with id ${id} not found`);
|
||||
}
|
||||
const recipe = await this.findRecipeByIdOrThrow(id);
|
||||
this.assertRecipeOwnedByUser(recipe, ownerId, id);
|
||||
|
||||
const targetUser = await this.prisma.user.findUnique({
|
||||
where: { username },
|
||||
|
||||
Reference in New Issue
Block a user