import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { CreateProductDto } from '../dto/create-product.dto'; import { FindProductsDto } from '../dto/find-products.dto'; import { ProductRepository } from '../repositories/product.repository'; import { CategoryRepository } from '../repositories/category.repository'; import { EntityManager } from '@mikro-orm/postgresql'; import { RequiredEntityData, FilterQuery } from '@mikro-orm/core'; import { Product } from '../entities/product.entity'; import { CategoryMessage, ProductMessage, RestMessage } from 'src/common/enums/message.enum'; import { Favorite } from '../entities/favorite.entity'; import { Variant } from '../entities/variant.entity'; import { ShopService } from 'src/modules/shops/providers/shops.service'; @Injectable() export class ProductService { constructor( private readonly productRepository: ProductRepository, private readonly categoryRepository: CategoryRepository, private readonly shopService: ShopService, private readonly em: EntityManager, ) { } async create(shopId: string, dto: CreateProductDto) { const { categoryId, variants, ...rest } = dto; const shop = await this.shopService.findOrFail(shopId); if (!shop) { throw new NotFoundException(RestMessage.NOT_FOUND); } const category = await this.categoryRepository.findOne({ id: categoryId, shop: { id: shopId } }); if (!category) { throw new NotFoundException(CategoryMessage.NOT_FOUND); } const product = await this.em.transactional(async em => { // prepare data with defaults for required fields so repository.create typing is satisfied const data: RequiredEntityData = { desc: rest.desc, isActive: rest.isActive ?? true, discount: rest.discount ?? 0, isSpecialOffer: rest.isSpecialOffer ?? false, order: rest.order ?? null, // map single-title/content DTO to localized fields title: rest.title, attribute: rest.attribute, // numeric/array fields price: rest.price ?? 0, images: rest.images ?? undefined, shop: shop, category: category, }; const product = em.create(Product, data); em.persist(product) for (const variant of variants) { const variantRecord = em.create(Variant, { product, value: variant.value, stock: variant.stock, price: variant.price, }) em.persist(variantRecord) } await em.flush(); return product; }); return product; } findAll(restId: string, dto: FindProductsDto) { return this.productRepository.findAllPaginated(restId, dto); } async findPublicById(productId: string, userId?: string): Promise { const product = await this.findOrFail(productId) let isFavorite = false; if (userId) { isFavorite = (await this.em.count(Favorite, { user: { id: userId }, product: { id: productId } })) > 0; } return { ...product, isFavorite, }; } async findAdminById(shopId: string, productId: string): Promise { const product = await this.findOrFail(productId) if (product.shop.id !== shopId) throw new NotFoundException(ProductMessage.NOT_FOUND); return product; } async findByShop(slug: string): Promise { const shop = await this.shopService.findOrFailBySlug( slug ); const queryFilter: FilterQuery = { shop: { slug }, isActive: true, } as unknown as FilterQuery; return this.productRepository.find(queryFilter, { populate: ['category'], orderBy: { order: 'asc' } }); } async update(restId: string, productId: string, dto: Partial): Promise { const { categoryId, variants, ...rest } = dto; const product = await this.findOrFail(productId) // attach new categories if provided (adds, does not attempt to preserve/remove existing ones) if (categoryId) { const category = await this.categoryRepository.findOne({ id: categoryId, shop: { id: restId } }); if (!category) { throw new NotFoundException(CategoryMessage.NOT_FOUND); } this.em.assign(product, { category: category }); } if (variants) { // Get existing variants for this product const existingVariants = product.variants // Create a map of existing variants by id for easy lookup const existingVariantsMap = new Map(existingVariants.map((v: any) => [v.id, v])); // Track which existing variants are being updated (to avoid deletion) const updatedVariantIds = new Set(); for (const variant of variants) { const { id, value, stock, price } = variant; if (id) { // Update existing variant const existingVariant = existingVariantsMap.get(id); if (!existingVariant) { throw new NotFoundException('Variant not found'); } // Update the variant fields this.em.assign(existingVariant, { value, stock, price }); updatedVariantIds.add(id); } else { // Create new variant const variantRecord = this.em.create(Variant, { product, value, stock, price, }); this.em.persist(variantRecord); } } // Delete variants that exist but were not included in the update for (const existingVariant of existingVariants) { if (!updatedVariantIds.has((existingVariant as any).id)) { this.em.remove(existingVariant); } } } // assign other fields from DTO (excluding dailyStock handled below) this.em.assign(product, rest); await this.em.persistAndFlush(product); return product; } async remove(restId: string, id: string): Promise { const product = await this.findOrFail(id) if (product.shop.id !== restId) { throw new BadRequestException("Product doesnt belongs to you") } product.deletedAt = new Date(); await this.em.flush(); } async toggleFavorite(userId: string, productId: string) { const favorite = await this.em.findOne(Favorite, { user: userId, product: productId }); if (favorite) { return this.em.removeAndFlush(favorite); } const newFavorite = this.em.create(Favorite, { user: userId, product: productId, }); return this.em.persistAndFlush(newFavorite); } async getMyFavorites(userId: string, restId: string) { return this.em.find(Favorite, { user: userId, product: { shop: { id: restId } } }, { populate: ['product'] }); } /** * Helper */ async findOrFail(productId: string) { const product = await this.productRepository.findOne({ id: productId, isActive: true }, { populate: ['category', 'variants', 'shop'] }); if (!product) throw new NotFoundException(ProductMessage.NOT_FOUND); return product } }