import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { normalizeName } from '../common/utils/normalize-name'; import { CreateProductDto } from './dto/create-product.dto'; import { UpdateProductDto } from './dto/update-product.dto'; import { UpsertNutritionDto } from './dto/upsert-nutrition.dto'; import { AiService } from '../ai/ai.service'; import { CategoriesService } from '../categories/categories.service'; @Injectable() export class ProductsService { constructor( private readonly prisma: PrismaService, private readonly aiService: AiService, private readonly categoriesService: CategoriesService, ) {} async findAll(filters?: { tag?: string; subcategory?: string }) { return this.prisma.product.findMany({ where: { isActive: true, isPrivate: false, ...(filters?.subcategory ? { subcategory: filters.subcategory } : {}), ...(filters?.tag ? { tags: { some: { tag: { name: filters.tag } } } } : {}), }, include: { tags: { include: { tag: true } }, nutrition: true, categoryRef: { include: { parent: { include: { parent: true } } } }, }, orderBy: { name: 'asc' }, }); } async findByOwner(userId: number) { return this.prisma.product.findMany({ where: { ownerId: userId, isPrivate: true, isActive: true }, select: { id: true, name: true, canonicalName: true, categoryId: true }, orderBy: { name: 'asc' }, }); } async createPrivate(data: CreateProductDto, userId: number) { const name = data.name.trim(); // Privata produkters normalizedName är prefixade för att undvika kollision const normalizedName = `private:${userId}:${normalizeName(name)}`; const existing = await this.prisma.product.findUnique({ where: { normalizedName }, }); if (existing && existing.isActive) return existing; if (existing) { return this.prisma.product.update({ where: { id: existing.id }, data: { isActive: true, deletedAt: null, name, canonicalName: name }, }); } return this.prisma.product.create({ data: { name, normalizedName, canonicalName: name, isActive: true, isPrivate: true, ownerId: userId, ...(data.categoryId != null ? { categoryId: data.categoryId } : {}), }, }); } async findDuplicateCandidates() { const products = await this.prisma.product.findMany({ where: { isActive: true, }, orderBy: { name: 'asc', }, }); const grouped = new Map(); for (const product of products) { const key = product.normalizedName; if (!grouped.has(key)) { grouped.set(key, []); } grouped.get(key)!.push(product); } return Array.from(grouped.entries()) .filter(([, items]) => items.length > 1) .map(([normalizedName, items]) => ({ normalizedName, count: items.length, products: items, })); } async findOne(id: number) { const product = await this.prisma.product.findUnique({ where: { id }, }); if (!product) { throw new NotFoundException(`Product with id ${id} not found`); } return product; } async create(data: CreateProductDto, ownerId?: number) { const name = data.name.trim(); const normalizedName = normalizeName(name); const existing = await this.prisma.product.findUnique({ where: { normalizedName }, }); if (existing) { if (!existing.isActive) { return this.prisma.product.update({ where: { id: existing.id }, data: { isActive: true, deletedAt: null, name, canonicalName: name, }, }); } return existing; } if (!ownerId) { throw new Error('ownerId är obligatorisk för att skapa en produkt'); } return this.prisma.product.create({ data: { ownerId, name, normalizedName, canonicalName: name, isActive: true, deletedAt: null, ...(data.categoryId != null ? { categoryId: data.categoryId } : {}), }, }); } async update(id: number, data: UpdateProductDto) { await this.findOne(id); const updateData: { name?: string; normalizedName?: string; canonicalName?: string; category?: string | null; subcategory?: string | null; brand?: string | null; categoryId?: number | null; } = {}; if (typeof data.name === 'string') { const name = data.name.trim(); const normalizedName = normalizeName(name); const existing = await this.prisma.product.findUnique({ where: { normalizedName }, }); if (existing && existing.id !== id) { // Om en annan produkt har samma namn, returnera ett tydligt fel throw new Error('Det finns redan en annan produkt med detta namn. Välj ett unikt namn.'); } updateData.name = name; updateData.normalizedName = normalizedName; } if (typeof data.canonicalName === 'string') { updateData.canonicalName = data.canonicalName.trim() || undefined; } if (typeof data.category === 'string') { updateData.category = data.category.trim() || null; } if (typeof data.subcategory === 'string') { updateData.subcategory = data.subcategory.trim() || null; } if (typeof data.brand === 'string') { updateData.brand = data.brand.trim() || null; } if ('categoryId' in data) { updateData.categoryId = data.categoryId ?? null; } return this.prisma.product.update({ where: { id }, data: updateData, include: { tags: { include: { tag: true } }, nutrition: true }, }); } async updateCanonicalName(id: number, canonicalName: string) { await this.findOne(id); const cleaned = canonicalName.trim(); return this.prisma.product.update({ where: { id }, data: { canonicalName: cleaned, }, }); } async findDeleted() { return this.prisma.product.findMany({ where: { isActive: false }, orderBy: { deletedAt: 'desc' }, }); } async remove(id: number) { await this.findOne(id); return this.prisma.product.update({ where: { id }, data: { isActive: false, deletedAt: new Date(), }, }); } async permanentDelete(id: number) { const product = await this.prisma.product.findUnique({ where: { id } }); if (!product) { throw new NotFoundException(`Product with id ${id} not found`); } // Ta bort beroenden först await this.prisma.productTag.deleteMany({ where: { productId: id } }); await this.prisma.userProduct.deleteMany({ where: { productId: id } }); return this.prisma.product.delete({ where: { id } }); } async restore(id: number) { const product = await this.findOne(id); if (product.isActive) { return product; } return this.prisma.product.update({ where: { id }, data: { isActive: true, deletedAt: null, }, }); } 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`, ); } return this.prisma.$transaction(async (tx) => { const movedInventoryCount = await tx.inventoryItem.updateMany({ where: { productId: sourceProductId }, data: { productId: targetProductId }, }); const softDeletedSource = await tx.product.update({ where: { id: sourceProductId }, data: { isActive: false, deletedAt: new Date(), }, }); return { message: 'Products merged successfully', sourceProductId, targetProductId, movedInventoryCount: movedInventoryCount.count, softDeletedSource, }; }); } async previewMerge(sourceProductId: number, targetProductId: number) { if (sourceProductId === targetProductId) { throw new Error('sourceProductId och targetProductId kan inte vara samma'); } const [source, target, sourceInventoryCount, targetInventoryCount] = await Promise.all([ this.prisma.product.findUnique({ where: { id: sourceProductId }, }), this.prisma.product.findUnique({ where: { id: targetProductId }, }), this.prisma.inventoryItem.count({ where: { productId: sourceProductId }, }), this.prisma.inventoryItem.count({ where: { productId: targetProductId }, }), ]); 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, inventoryCount: sourceInventoryCount, }, target: { ...target, inventoryCount: targetInventoryCount, }, outcome: { inventoryItemsToMove: sourceInventoryCount, sourceWillBeSoftDeleted: true, targetWillRemainActive: true, }, }; } async backfillCanonicalNames() { const products = await this.prisma.product.findMany({ where: { canonicalName: null, }, }); const results = await this.prisma.$transaction( products.map((product) => this.prisma.product.update({ where: { id: product.id }, data: { canonicalName: product.name, }, }), ), ); return { message: 'Canonical names backfilled successfully', updatedCount: results.length, products: results, }; } async setTags(productId: number, tagNames: string[]) { await this.findOne(productId); // Skapa taggar som inte finns och hämta ID för alla const tags = await this.prisma.$transaction( tagNames.map((name) => this.prisma.tag.upsert({ where: { name }, create: { name }, update: {}, }), ), ); // Ersätt alla taggkopplingar för produkten await this.prisma.productTag.deleteMany({ where: { productId } }); if (tags.length > 0) { await this.prisma.productTag.createMany({ data: tags.map((tag) => ({ productId, tagId: tag.id })), skipDuplicates: true, }); } return this.prisma.product.findUnique({ where: { id: productId }, include: { tags: { include: { tag: true } }, nutrition: true }, }); } async upsertNutrition(productId: number, data: UpsertNutritionDto) { await this.findOne(productId); return this.prisma.nutrition.upsert({ where: { productId }, create: { productId, ...data }, update: { ...data }, }); } async findAllTags() { return this.prisma.tag.findMany({ orderBy: { name: 'asc' } }); } async resetAll() { await this.prisma.$transaction([ this.prisma.receiptAlias.deleteMany(), this.prisma.inventoryConsumption.deleteMany(), this.prisma.inventoryItem.deleteMany(), this.prisma.pantryItem.deleteMany(), this.prisma.nutrition.deleteMany(), this.prisma.productTag.deleteMany(), this.prisma.tag.deleteMany(), this.prisma.userProduct.deleteMany(), this.prisma.recipeIngredient.deleteMany(), this.prisma.product.deleteMany(), ]); return { ok: true }; } async bulkUpdate(ids: number[], data: { categoryId?: number | null }) { const updateData: Record = {}; if ('categoryId' in data) { updateData.categoryId = data.categoryId; } if (Object.keys(updateData).length === 0) return { updated: 0 }; await this.prisma.product.updateMany({ where: { id: { in: ids } }, data: updateData }); return { updated: ids.length }; } async findUncategorized(): Promise<{ id: number; name: string; canonicalName: string | null }[]> { return this.prisma.product.findMany({ where: { isActive: true, categoryId: null, status: 'active' }, select: { id: true, name: true, canonicalName: true }, orderBy: { name: 'asc' }, }); } async aiCategorizeBulk(productIds?: number[]): Promise<{ productId: number; productName: string; suggestion: object }[]> { const categories = await this.categoriesService.findFlattened(); let products: { id: number; name: string }[]; if (productIds && productIds.length > 0) { const found = await Promise.all(productIds.map((id) => this.findOne(id))); products = found.map((p) => ({ id: p.id, name: p.canonicalName ?? p.name })); } else { products = await this.findUncategorized(); } const results: { productId: number; productName: string; suggestion: object }[] = []; for (const product of products) { const suggestion = await this.aiService.suggestCategory(product.name, categories); results.push({ productId: product.id, productName: product.name, suggestion }); } return results; } async findPending() { return this.prisma.product.findMany({ where: { status: 'pending' }, include: { categoryRef: { include: { parent: true } }, owner: { select: { id: true, username: true } }, }, orderBy: { createdAt: 'desc' }, }); } async createPending(data: CreateProductDto, userId: number) { const name = data.name.trim(); const normalizedName = normalizeName(name); const existing = await this.prisma.product.findUnique({ where: { normalizedName }, }); if (existing) { // Om produkten redan finns (aktiv), returnera den direkt if (existing.isActive && existing.status === 'active') { return existing; } // Om det redan finns ett pending-förslag, returnera det if (existing.status === 'pending') { return existing; } } return this.prisma.product.create({ data: { name, normalizedName, canonicalName: name, isActive: false, status: 'pending', ownerId: userId, }, }); } setStatus(id: number, status: string) { return this.prisma.product.update({ where: { id }, data: { status } }); } }