import { ConsumeInventoryDto } from './dto/consume-inventory.dto'; 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'; import { UpdateInventoryDto } from './dto/update-inventory.dto'; import { convertUnit, normalizeUnit } from '../common/utils/units'; type InventoryQuery = { location?: string; sort?: string; }; type AdminInventoryQuery = { userId?: number; sort?: string; }; @Injectable() export class InventoryService { constructor(private prisma: PrismaService) {} private readonly productWithCategoryInclude = { include: { categoryRef: { include: { parent: { include: { parent: true, }, }, }, }, }, }; private throwInventoryItemNotFound(id: number): never { 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) { this.throwInventoryItemNotFound(id); } if (existing.userId !== userId) { throw new ForbiddenException(`Inventory item with id ${id} does not belong to current user`); } return existing; } private async ensureProductExists(productId: number, userId: number) { const product = await this.prisma.product.findFirst({ where: { id: productId, ownerId: userId } }); if (!product) { throw new NotFoundException('Product not found for current user'); } 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, originCountries: data.originCountries || 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.origin === 'string') { updateData.origin = data.origin.trim(); } if (Array.isArray(data.originCountries)) { updateData.originCountries = data.originCountries; } 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[] = []; if (query?.location) { where.location = query.location; } if (query?.sort === 'bestBeforeAsc') { orderBy.push({ bestBeforeDate: 'asc' }); } else if (query?.sort === 'bestBeforeDesc') { orderBy.push({ bestBeforeDate: 'desc' }); } else if (query?.sort === 'nameAsc') { orderBy.push({ product: { name: 'asc' } } as any); } else if (query?.sort === 'purchaseDateAsc') { orderBy.push({ purchaseDate: 'asc' }); } else if (query?.sort === 'purchaseDateDesc') { orderBy.push({ purchaseDate: 'desc' }); } else { orderBy.push({ createdAt: 'desc' }); } return this.prisma.inventoryItem.findMany({ where, include: { product: this.productWithCategoryInclude, }, 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); const currentQuantity = Number(existing.quantity); const newQuantity = Math.max(0, currentQuantity - data.amountUsed); return this.prisma.$transaction(async (tx) => { const updatedItem = await tx.inventoryItem.update({ where: { id }, data: { quantity: new Prisma.Decimal(newQuantity), }, include: { product: this.productWithCategoryInclude, }, }); await tx.inventoryConsumption.create({ data: { inventoryItemId: id, amountUsed: new Prisma.Decimal(data.amountUsed), comment: data.comment?.trim() || null, }, }); return updatedItem; }); } async findConsumptionHistory(id: number, userId: number) { await this.findInventoryItemByIdOrThrow(id, userId); return this.prisma.inventoryConsumption.findMany({ where: { inventoryItemId: id, }, select: { id: true, inventoryItemId: true, amountUsed: true, comment: true, createdAt: true, inventoryItem: { select: { unit: true }, }, }, orderBy: { createdAt: 'desc', }, }); } async findExpiring(userId: number) { const now = new Date(); return this.prisma.inventoryItem.findMany({ where: { userId, bestBeforeDate: { not: null, gte: now, }, }, include: { product: true, }, orderBy: [{ bestBeforeDate: 'asc' }, { createdAt: 'desc' }], }); } async create(userId: number, data: CreateInventoryDto) { await this.ensureProductExists(data.productId, userId); return this.prisma.inventoryItem.create({ 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); if (typeof data.productId === 'number') { await this.ensureProductExists(data.productId, userId); } const updateData = this.buildUpdateData(data); return this.prisma.inventoryItem.update({ where: { id }, data: updateData, include: { product: this.productWithCategoryInclude, }, }); } async remove(id: number, userId: number) { await this.findInventoryItemByIdOrThrow(id, userId); return this.prisma.inventoryItem.delete({ where: { id } }); } async bulkDelete(userId: number, ids: number[]) { const uniqueIds = [...new Set(ids)]; if (uniqueIds.length === 0) { throw new BadRequestException('No inventory ids supplied'); } const items = await this.prisma.inventoryItem.findMany({ where: { id: { in: uniqueIds }, userId }, select: { id: true }, }); if (items.length !== uniqueIds.length) { throw new ForbiddenException('One or more inventory items are missing or do not belong to current user'); } const result = await this.prisma.inventoryItem.deleteMany({ where: { id: { in: uniqueIds }, userId }, }); return { deletedCount: result.count }; } async mergeMany(userId: number, ids: number[], targetUnit?: string) { const uniqueIds = [...new Set(ids)]; if (uniqueIds.length < 2) { throw new BadRequestException('At least two inventory items are required to merge'); } const items = await this.prisma.inventoryItem.findMany({ where: { id: { in: uniqueIds }, userId }, include: { product: this.productWithCategoryInclude, }, orderBy: { createdAt: 'asc' }, }); if (items.length !== uniqueIds.length) { throw new ForbiddenException('One or more inventory items are missing or do not belong to current user'); } const firstProductId = items[0].productId; if (items.some((item) => item.productId !== firstProductId)) { throw new BadRequestException('Selected inventory items must belong to the same product'); } const normalizedUnits = new Set(items.map((item) => normalizeUnit(item.unit))); const resolvedTargetUnitRaw = targetUnit?.trim(); if (!resolvedTargetUnitRaw && normalizedUnits.size > 1) { throw new BadRequestException('targetUnit is required when merging different units'); } const resolvedTargetUnit = normalizeUnit( resolvedTargetUnitRaw && resolvedTargetUnitRaw.length > 0 ? resolvedTargetUnitRaw : items[0].unit, ); if (!items.some((item) => normalizeUnit(item.unit) === resolvedTargetUnit)) { throw new BadRequestException('targetUnit must match one of the selected item units'); } let mergedQuantity = 0; for (const item of items) { const quantity = Number(item.quantity); try { mergedQuantity += convertUnit(quantity, item.unit, resolvedTargetUnit); } catch { throw new BadRequestException( `Cannot merge item ${item.id}: incompatible unit "${item.unit}" for target unit "${resolvedTargetUnit}"`, ); } } const target = items.find((item) => normalizeUnit(item.unit) === resolvedTargetUnit) ?? items[0]; const sourceItems = items.filter((item) => item.id !== target.id); const firstNonNull = (values: (T | null | undefined)[]): T | null => { for (const value of values) { if (value !== null && value !== undefined) { return value; } } return null; }; return this.prisma.$transaction(async (tx) => { const updated = await tx.inventoryItem.update({ where: { id: target.id }, data: { quantity: new Prisma.Decimal(mergedQuantity), unit: resolvedTargetUnit, location: target.location ?? firstNonNull(sourceItems.map((s) => s.location)), brand: target.brand ?? firstNonNull(sourceItems.map((s) => s.brand)), origin: target.origin ?? firstNonNull(sourceItems.map((s) => s.origin)), receiptName: target.receiptName ?? firstNonNull(sourceItems.map((s) => s.receiptName)), purchaseDate: target.purchaseDate ?? firstNonNull(sourceItems.map((s) => s.purchaseDate)), opened: target.opened, suitableFor: target.suitableFor ?? firstNonNull(sourceItems.map((s) => s.suitableFor)), bestBeforeDate: target.bestBeforeDate ?? firstNonNull(sourceItems.map((s) => s.bestBeforeDate)), comment: target.comment ?? firstNonNull(sourceItems.map((s) => s.comment)), }, include: { product: this.productWithCategoryInclude, }, }); const sourceIds = sourceItems.map((item) => item.id); if (sourceIds.length > 0) { await tx.inventoryConsumption.updateMany({ where: { inventoryItemId: { in: sourceIds } }, data: { inventoryItemId: target.id }, }); await tx.inventoryItem.deleteMany({ where: { id: { in: sourceIds }, userId }, }); } return updated; }); } private async moveInventoryItemToPantryCore(item: { id: number; userId: number; productId: number; location: string | null; }) { const existingPantryItem = await this.prisma.pantryItem.findUnique({ where: { userId_productId: { userId: item.userId, productId: item.productId, }, }, }); if (existingPantryItem) { throw new BadRequestException('Produkten finns redan i baslagret'); } return this.prisma.$transaction(async (tx) => { const pantryItem = await tx.pantryItem.create({ data: { userId: item.userId, productId: item.productId, location: item.location?.trim() || null, }, include: { product: true }, }); await tx.inventoryItem.delete({ where: { id: item.id } }); return pantryItem; }); } async moveToPantry(id: number, userId: number) { const item = await this.findInventoryItemByIdOrThrow(id, userId); return this.moveInventoryItemToPantryCore(item); } async moveToPantryAdmin(id: number) { const item = await this.findInventoryItemAnyByIdOrThrow(id); return this.moveInventoryItemToPantryCore(item); } 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; }); } }