Implement admin inventory management features including CRUD operations, merging, filtering, sorting, previewing, and security enhancements. Update documentation and add comprehensive test coverage for security and validation.
Test Suite / test (24.15.0) (push) Has been cancelled

This commit is contained in:
Nils-Johan Gynther
2026-05-10 00:20:25 +02:00
parent 65137b41fb
commit 1709bb1dad
33 changed files with 1879 additions and 71 deletions
+309 -69
View File
@@ -1,5 +1,5 @@
import { ConsumeInventoryDto } from './dto/consume-inventory.dto';
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { CreateInventoryDto } from './dto/create-inventory.dto';
@@ -10,6 +10,11 @@ type InventoryQuery = {
sort?: string;
};
type AdminInventoryQuery = {
userId?: number;
sort?: string;
};
@Injectable()
export class InventoryService {
constructor(private prisma: PrismaService) {}
@@ -32,6 +37,14 @@ export class InventoryService {
throw new NotFoundException(`Inventory item with id ${id} not found`);
}
private async findInventoryItemAnyByIdOrThrow(id: number) {
const existing = await this.prisma.inventoryItem.findUnique({ where: { id } });
if (!existing) {
this.throwInventoryItemNotFound(id);
}
return existing;
}
private async findInventoryItemByIdOrThrow(id: number, userId: number) {
const existing = await this.prisma.inventoryItem.findUnique({ where: { id } });
if (!existing) {
@@ -51,6 +64,100 @@ export class InventoryService {
return product;
}
private async ensureProductExistsAny(productId: number) {
const product = await this.prisma.product.findUnique({ where: { id: productId } });
if (!product) {
throw new NotFoundException('Product not found');
}
return product;
}
private async ensureUserExists(userId: number) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { id: true },
});
if (!user) {
throw new NotFoundException(`User with id ${userId} not found`);
}
}
private buildCreateData(userId: number, data: CreateInventoryDto): Prisma.InventoryItemUncheckedCreateInput {
return {
...data,
userId,
quantity: new Prisma.Decimal(data.quantity),
location: data.location?.trim() || undefined,
brand: data.brand?.trim() || undefined,
origin: data.origin?.trim() || undefined,
receiptName: data.receiptName?.trim() || undefined,
suitableFor: data.suitableFor?.trim() || undefined,
comment: data.comment?.trim() || undefined,
purchaseDate: data.purchaseDate
? new Date(data.purchaseDate)
: undefined,
bestBeforeDate: data.bestBeforeDate
? new Date(data.bestBeforeDate)
: undefined,
};
}
private buildUpdateData(data: UpdateInventoryDto): Prisma.InventoryItemUpdateInput {
const updateData: Prisma.InventoryItemUpdateInput = {};
if (typeof data.productId === 'number') {
updateData.product = {
connect: { id: data.productId },
};
}
if (typeof data.quantity === 'number') {
updateData.quantity = new Prisma.Decimal(data.quantity);
}
if (typeof data.unit === 'string') {
updateData.unit = data.unit.trim();
}
if (typeof data.location === 'string') {
updateData.location = data.location.trim();
}
if (typeof data.brand === 'string') {
updateData.brand = data.brand.trim();
}
if (typeof data.receiptName === 'string') {
updateData.receiptName = data.receiptName.trim();
}
if (typeof data.purchaseDate === 'string') {
updateData.purchaseDate = data.purchaseDate
? new Date(data.purchaseDate)
: null;
}
if (typeof data.bestBeforeDate === 'string') {
updateData.bestBeforeDate = data.bestBeforeDate
? new Date(data.bestBeforeDate)
: null;
}
if (typeof data.opened === 'boolean') {
updateData.opened = data.opened;
}
if (typeof data.suitableFor === 'string') {
updateData.suitableFor = data.suitableFor.trim();
}
if (typeof data.comment === 'string') {
updateData.comment = data.comment.trim();
}
return updateData;
}
async findAll(userId: number, query?: InventoryQuery) {
const where: Prisma.InventoryItemWhereInput = { userId };
const orderBy: Prisma.InventoryItemOrderByWithRelationInput[] = [];
@@ -81,6 +188,42 @@ export class InventoryService {
orderBy,
});
}
async findAllAdmin(query?: AdminInventoryQuery) {
const where: Prisma.InventoryItemWhereInput = {};
const orderBy: Prisma.InventoryItemOrderByWithRelationInput[] = [];
if (typeof query?.userId === 'number' && Number.isFinite(query.userId)) {
where.userId = query.userId;
}
if (query?.sort === 'nameAsc') {
orderBy.push({ product: { name: 'asc' } } as any);
} else if (query?.sort === 'nameDesc') {
orderBy.push({ product: { name: 'desc' } } as any);
} else if (query?.sort === 'quantityDesc') {
orderBy.push({ quantity: 'desc' });
} else if (query?.sort === 'quantityAsc') {
orderBy.push({ quantity: 'asc' });
} else {
orderBy.push({ createdAt: 'desc' });
}
return this.prisma.inventoryItem.findMany({
where,
include: {
user: {
select: {
id: true,
username: true,
email: true,
},
},
product: this.productWithCategoryInclude,
},
orderBy,
});
}
async consume(id: number, userId: number, data: ConsumeInventoryDto) {
const existing = await this.findInventoryItemByIdOrThrow(id, userId);
@@ -155,29 +298,33 @@ export class InventoryService {
await this.ensureProductExists(data.productId, userId);
return this.prisma.inventoryItem.create({
data: {
...data,
userId,
quantity: new Prisma.Decimal(data.quantity),
location: data.location?.trim() || undefined,
brand: data.brand?.trim() || undefined,
origin: data.origin?.trim() || undefined,
receiptName: data.receiptName?.trim() || undefined,
suitableFor: data.suitableFor?.trim() || undefined,
comment: data.comment?.trim() || undefined,
purchaseDate: data.purchaseDate
? new Date(data.purchaseDate)
: undefined,
bestBeforeDate: data.bestBeforeDate
? new Date(data.bestBeforeDate)
: undefined,
},
data: this.buildCreateData(userId, data),
include: {
product: this.productWithCategoryInclude,
},
});
}
async createAdmin(adminUserId: number, data: CreateInventoryDto, targetUserId?: number) {
const effectiveUserId = typeof targetUserId === 'number' ? targetUserId : adminUserId;
await this.ensureUserExists(effectiveUserId);
await this.ensureProductExistsAny(data.productId);
return this.prisma.inventoryItem.create({
data: this.buildCreateData(effectiveUserId, data),
include: {
user: {
select: {
id: true,
username: true,
email: true,
},
},
product: this.productWithCategoryInclude,
},
});
}
async update(id: number, userId: number, data: UpdateInventoryDto) {
await this.findInventoryItemByIdOrThrow(id, userId);
@@ -185,57 +332,7 @@ export class InventoryService {
await this.ensureProductExists(data.productId, userId);
}
const updateData: Prisma.InventoryItemUpdateInput = {};
if (typeof data.productId === 'number') {
updateData.product = {
connect: { id: data.productId },
};
}
if (typeof data.quantity === 'number') {
updateData.quantity = new Prisma.Decimal(data.quantity);
}
if (typeof data.unit === 'string') {
updateData.unit = data.unit.trim();
}
if (typeof data.location === 'string') {
updateData.location = data.location.trim();
}
if (typeof data.brand === 'string') {
updateData.brand = data.brand.trim();
}
if (typeof data.receiptName === 'string') {
updateData.receiptName = data.receiptName.trim();
}
if (typeof data.purchaseDate === 'string') {
updateData.purchaseDate = data.purchaseDate
? new Date(data.purchaseDate)
: null;
}
if (typeof data.bestBeforeDate === 'string') {
updateData.bestBeforeDate = data.bestBeforeDate
? new Date(data.bestBeforeDate)
: null;
}
if (typeof data.opened === 'boolean') {
updateData.opened = data.opened;
}
if (typeof data.suitableFor === 'string') {
updateData.suitableFor = data.suitableFor.trim();
}
if (typeof data.comment === 'string') {
updateData.comment = data.comment.trim();
}
const updateData = this.buildUpdateData(data);
return this.prisma.inventoryItem.update({
where: { id },
@@ -250,4 +347,147 @@ export class InventoryService {
await this.findInventoryItemByIdOrThrow(id, userId);
return this.prisma.inventoryItem.delete({ where: { id } });
}
async updateAdmin(id: number, data: UpdateInventoryDto) {
await this.findInventoryItemAnyByIdOrThrow(id);
if (typeof data.productId === 'number') {
await this.ensureProductExistsAny(data.productId);
}
const updateData = this.buildUpdateData(data);
return this.prisma.inventoryItem.update({
where: { id },
data: updateData,
include: {
user: {
select: {
id: true,
username: true,
email: true,
},
},
product: this.productWithCategoryInclude,
},
});
}
async removeAdmin(id: number) {
await this.findInventoryItemAnyByIdOrThrow(id);
return this.prisma.inventoryItem.delete({ where: { id } });
}
private validateAdminMergeEligibility(
source: { id: number; userId: number; productId: number; unit: string },
target: { id: number; userId: number; productId: number; unit: string },
): string | null {
if (source.userId !== target.userId) {
return 'Cannot merge inventory items from different users';
}
if (source.productId !== target.productId) {
return 'Cannot merge inventory items with different products';
}
if (source.unit.trim().toLowerCase() !== target.unit.trim().toLowerCase()) {
return 'Cannot merge inventory items with different units';
}
return null;
}
async previewMergeAdmin(sourceInventoryId: number, targetInventoryId: number) {
if (sourceInventoryId === targetInventoryId) {
return {
canMerge: false,
reason: 'sourceInventoryId and targetInventoryId cannot be the same',
};
}
const [source, target] = await Promise.all([
this.findInventoryItemAnyByIdOrThrow(sourceInventoryId),
this.findInventoryItemAnyByIdOrThrow(targetInventoryId),
]);
const reason = this.validateAdminMergeEligibility(source, target);
const mergedQuantity = Number(source.quantity) + Number(target.quantity);
return {
canMerge: reason == null,
reason,
source: {
id: source.id,
userId: source.userId,
productId: source.productId,
quantity: Number(source.quantity),
unit: source.unit,
},
target: {
id: target.id,
userId: target.userId,
productId: target.productId,
quantity: Number(target.quantity),
unit: target.unit,
},
outcome: {
mergedQuantity,
mergedUnit: target.unit,
},
};
}
async mergeAdmin(sourceInventoryId: number, targetInventoryId: number) {
if (sourceInventoryId === targetInventoryId) {
throw new BadRequestException('sourceInventoryId and targetInventoryId cannot be the same');
}
const [source, target] = await Promise.all([
this.findInventoryItemAnyByIdOrThrow(sourceInventoryId),
this.findInventoryItemAnyByIdOrThrow(targetInventoryId),
]);
const reason = this.validateAdminMergeEligibility(source, target);
if (reason) {
throw new BadRequestException(reason);
}
const mergedQuantity = Number(source.quantity) + Number(target.quantity);
return this.prisma.$transaction(async (tx) => {
const updated = await tx.inventoryItem.update({
where: { id: target.id },
data: {
quantity: new Prisma.Decimal(mergedQuantity),
location: target.location ?? source.location,
brand: target.brand ?? source.brand,
origin: target.origin ?? source.origin,
receiptName: target.receiptName ?? source.receiptName,
purchaseDate: target.purchaseDate ?? source.purchaseDate,
opened: target.opened ?? source.opened,
suitableFor: target.suitableFor ?? source.suitableFor,
bestBeforeDate: target.bestBeforeDate ?? source.bestBeforeDate,
comment: target.comment ?? source.comment,
},
include: {
user: {
select: {
id: true,
username: true,
email: true,
},
},
product: this.productWithCategoryInclude,
},
});
await tx.inventoryConsumption.updateMany({
where: { inventoryItemId: source.id },
data: { inventoryItemId: target.id },
});
await tx.inventoryItem.delete({ where: { id: source.id } });
return updated;
});
}
}