import { BadRequestException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { ContentStatus, MediaEntityType, Prisma } from '@prisma/client'; import { AuthUser } from '../auth/auth.types'; import { PermissionsService } from '../auth/permissions.service'; import { PrismaService } from '../prisma/prisma.service'; import { TenantService } from '../tenant/tenant.service'; import { BatchCreateStoreItemsDto, BatchUpdateStoreItemDiscountsDto, BatchUpdateFestivalRewardsDto, CreateStoreItemVariantDto, ListPublicStoreItemsDto, ListStoreItemsDto, SyncProductStoreItemVariantsDto, UpdateStoreItemVariantDto, } from './dto/store-items.dto'; const variantInclude = { storeItem: { include: { product: { include: { featuredMedia: true, }, }, }, }, selections: { include: { variation: true, option: true, }, }, } satisfies Prisma.StoreItemVariantInclude; type VariantWithRelations = Prisma.StoreItemVariantGetPayload<{ include: typeof variantInclude; }>; @Injectable() export class StoreItemsService { constructor( private readonly prisma: PrismaService, private readonly permissions: PermissionsService, private readonly tenant: TenantService, ) {} async list( businessIdRaw: string, query: ListStoreItemsDto, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); await this.assertPermission(businessId, actor.id, 'products.read'); const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; const skip = (page - 1) * pageSize; const where = { businessId }; const [items, total] = await Promise.all([ this.prisma.storeItemVariant.findMany({ where, orderBy: [{ createdAt: 'desc' }], skip, take: pageSize, include: variantInclude, }), this.prisma.storeItemVariant.count({ where }), ]); const productIds = [ ...new Set(items.map((item) => item.storeItem.productId)), ]; const [galleryByProduct, stockByProduct] = await Promise.all([ this.loadFirstGalleryUrls(businessId, productIds), this.loadStockTotals(businessId, productIds), ]); return { items: items.map((item) => this.serializeVariant(item, { galleryUrl: galleryByProduct.get(item.storeItem.productId.toString()) ?? null, productTotalStock: stockByProduct.get(item.storeItem.productId.toString()) ?? 0, }), ), total, page, pageSize, }; } async listPublic(host: string, query: ListPublicStoreItemsDto) { const business = await this.tenant.resolveBusinessByDomain(host); const businessId = business.id; const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; const skip = (page - 1) * pageSize; const where = await this.buildPublicVariantWhere(businessId, query); const [items, total] = await Promise.all([ this.prisma.storeItemVariant.findMany({ where, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }], skip, take: pageSize, include: variantInclude, }), this.prisma.storeItemVariant.count({ where }), ]); const productIds = [...new Set(items.map((item) => item.storeItem.productId))]; const [galleryByProduct, stockByProduct] = await Promise.all([ this.loadFirstGalleryUrls(businessId, productIds), this.loadStockTotals(businessId, productIds), ]); return { items: items.map((item) => this.serializeVariant(item, { galleryUrl: galleryByProduct.get(item.storeItem.productId.toString()) ?? null, productTotalStock: stockByProduct.get(item.storeItem.productId.toString()) ?? 0, }), ), total, page, pageSize, }; } async getPublicByProduct(host: string, productIdRaw: string) { const business = await this.tenant.resolveBusinessByDomain(host); const businessId = business.id; const productId = BigInt(productIdRaw); await this.assertPublishedProductExists(businessId, productId); const storeItem = await this.prisma.storeItem.findFirst({ where: { businessId, productId, isActive: true }, include: { variants: { where: { isActive: true }, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }], include: variantInclude, }, }, }); if (!storeItem) { return { storeItem: null }; } const [galleryByProduct, stockByProduct] = await Promise.all([ this.loadFirstGalleryUrls(businessId, [productId]), this.loadStockTotals(businessId, [productId]), ]); return { storeItem: { id: storeItem.id.toString(), productId: storeItem.productId.toString(), isActive: storeItem.isActive, sortOrder: storeItem.sortOrder, createdAt: storeItem.createdAt, updatedAt: storeItem.updatedAt, variants: storeItem.variants.map((variant) => this.serializeVariant(variant, { galleryUrl: galleryByProduct.get(productId.toString()) ?? null, productTotalStock: stockByProduct.get(productId.toString()) ?? 0, }), ), }, }; } async getPublicVariant(host: string, variantIdRaw: string) { const business = await this.tenant.resolveBusinessByDomain(host); const businessId = business.id; const variantId = BigInt(variantIdRaw); const variant = await this.prisma.storeItemVariant.findFirst({ where: { id: variantId, businessId, isActive: true, storeItem: { isActive: true, product: { status: ContentStatus.published }, }, }, include: variantInclude, }); if (!variant) { throw new NotFoundException('Store item variant not found'); } const productId = variant.storeItem.productId; const [galleryByProduct, stockByProduct] = await Promise.all([ this.loadFirstGalleryUrls(businessId, [productId]), this.loadStockTotals(businessId, [productId]), ]); return { variant: this.serializeVariant(variant, { galleryUrl: galleryByProduct.get(productId.toString()) ?? null, productTotalStock: stockByProduct.get(productId.toString()) ?? 0, }), }; } async getByProduct( businessIdRaw: string, productIdRaw: string, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); const productId = BigInt(productIdRaw); await this.assertPermission(businessId, actor.id, 'products.read'); const storeItem = await this.prisma.storeItem.findFirst({ where: { businessId, productId }, include: { variants: { orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }], include: variantInclude, }, }, }); if (!storeItem) { return { storeItem: null }; } const [galleryByProduct, stockByProduct] = await Promise.all([ this.loadFirstGalleryUrls(businessId, [productId]), this.loadStockTotals(businessId, [productId]), ]); return { storeItem: { id: storeItem.id.toString(), productId: storeItem.productId.toString(), isActive: storeItem.isActive, sortOrder: storeItem.sortOrder, createdAt: storeItem.createdAt, updatedAt: storeItem.updatedAt, variants: storeItem.variants.map((variant) => this.serializeVariant(variant, { galleryUrl: galleryByProduct.get(productId.toString()) ?? null, productTotalStock: stockByProduct.get(productId.toString()) ?? 0, }), ), }, }; } async batchCreateVariants( businessIdRaw: string, dto: BatchCreateStoreItemsDto, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); const productId = BigInt(dto.productId); await this.assertPermission(businessId, actor.id, 'products.update'); if (!dto.items.length) { throw new BadRequestException('At least one variant is required'); } await this.assertProductWithCategory(businessId, productId); const [categoryVariations, productValues, existingVariants] = await this.loadVariantContext(businessId, productId); const variationMap = new Map( categoryVariations.map((variation) => [variation.id.toString(), variation]), ); const allowedOptionIds = new Set( productValues.map((item) => item.optionId.toString()), ); const existingKeys = new Set( existingVariants.map((variant) => this.selectionKey(variant.selections.map((s) => s.optionId.toString())), ), ); const batchKeys = new Set(); for (const item of dto.items) { this.validateVariantItem( item, variationMap, allowedOptionIds, existingKeys, batchKeys, ); } const created = await this.prisma.$transaction(async (tx) => { const storeItem = await this.ensureStoreItem(businessId, productId, tx); const baseSortOrder = await tx.storeItemVariant.count({ where: { storeItemId: storeItem.id }, }); const results: VariantWithRelations[] = []; for (const [index, item] of dto.items.entries()) { const variant = await tx.storeItemVariant.create({ data: { businessId, storeItemId: storeItem.id, price: item.price ?? null, stockQuantity: item.stockQuantity ?? null, sortOrder: baseSortOrder + index, }, }); for (const selection of item.selections.filter((entry) => entry.optionId?.trim())) { await tx.storeItemVariantSelection.create({ data: { variantId: variant.id, variationId: BigInt(selection.variationId), optionId: BigInt(selection.optionId), }, }); } results.push( await tx.storeItemVariant.findUniqueOrThrow({ where: { id: variant.id }, include: variantInclude, }), ); } return results; }); const [galleryByProduct, stockByProduct] = await Promise.all([ this.loadFirstGalleryUrls(businessId, [productId]), this.loadStockTotals(businessId, [productId]), ]); return { message: 'Store item variants created successfully', items: created.map((item) => this.serializeVariant(item, { galleryUrl: galleryByProduct.get(productId.toString()) ?? null, productTotalStock: stockByProduct.get(productId.toString()) ?? 0, }), ), }; } async updateVariant( businessIdRaw: string, variantIdRaw: string, dto: UpdateStoreItemVariantDto, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); const variantId = BigInt(variantIdRaw); await this.assertPermission(businessId, actor.id, 'products.update'); const existing = await this.prisma.storeItemVariant.findFirst({ where: { id: variantId, businessId }, }); if (!existing) { throw new NotFoundException('Store item variant not found'); } const price = dto.price ?? (existing.price === null ? null : Number(existing.price)); const discountedPrice = dto.discountedPrice === undefined ? existing.compareAtPrice === null ? null : Number(existing.compareAtPrice) : dto.discountedPrice; if (discountedPrice !== null && price !== null && discountedPrice >= price) { throw new BadRequestException('Discounted price must be lower than the regular price'); } const updated = await this.prisma.storeItemVariant.update({ where: { id: variantId }, data: { ...(dto.price !== undefined ? { price: dto.price } : {}), ...(dto.stockQuantity !== undefined ? { stockQuantity: dto.stockQuantity } : {}), ...(dto.discountedPrice !== undefined ? { compareAtPrice: dto.discountedPrice } : {}), ...(dto.isFestival !== undefined ? { isFestival: dto.isFestival } : {}), }, include: variantInclude, }); const productId = updated.storeItem.productId; const [galleryByProduct, stockByProduct] = await Promise.all([ this.loadFirstGalleryUrls(businessId, [productId]), this.loadStockTotals(businessId, [productId]), ]); return { message: 'Store item variant updated successfully', item: this.serializeVariant(updated, { galleryUrl: galleryByProduct.get(productId.toString()) ?? null, productTotalStock: stockByProduct.get(productId.toString()) ?? 0, }), }; } async updateDiscounts( businessIdRaw: string, dto: BatchUpdateStoreItemDiscountsDto, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); const productId = BigInt(dto.productId); await this.assertPermission(businessId, actor.id, 'products.update'); const variants = await this.prisma.storeItemVariant.findMany({ where: { businessId, storeItem: { productId } }, }); const variantMap = new Map(variants.map((item) => [item.id.toString(), item])); for (const entry of dto.items) { const variant = variantMap.get(entry.id); if (!variant) { throw new BadRequestException(`Variant "${entry.id}" not found for this product`); } const price = variant.price === null ? null : Number(variant.price); if ( entry.discountedPrice !== null && entry.discountedPrice !== undefined && price !== null && entry.discountedPrice >= price ) { throw new BadRequestException( `Discounted price for "${entry.id}" must be lower than the regular price`, ); } } await this.prisma.$transaction( dto.items.map((entry) => this.prisma.storeItemVariant.update({ where: { id: BigInt(entry.id) }, data: { compareAtPrice: entry.discountedPrice === undefined ? undefined : entry.discountedPrice, }, }), ), ); const refreshed = await this.getVariantsForProduct(businessId, productId); const [galleryByProduct, stockByProduct] = await Promise.all([ this.loadFirstGalleryUrls(businessId, [productId]), this.loadStockTotals(businessId, [productId]), ]); return { message: 'Variant discounts updated successfully', items: refreshed.map((item) => this.serializeVariant(item, { galleryUrl: galleryByProduct.get(productId.toString()) ?? null, productTotalStock: stockByProduct.get(productId.toString()) ?? 0, }), ), }; } async syncProductVariants( businessIdRaw: string, dto: SyncProductStoreItemVariantsDto, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); const productId = BigInt(dto.productId); await this.assertPermission(businessId, actor.id, 'products.update'); if (!dto.items.length && !dto.removedIds.length) { throw new BadRequestException('No variant changes to save'); } await this.assertProductWithCategory(businessId, productId); const [categoryVariations, productValues, existingVariants] = await this.loadVariantContext(businessId, productId); const variationMap = new Map( categoryVariations.map((variation) => [variation.id.toString(), variation]), ); const allowedOptionIds = new Set( productValues.map((item) => item.optionId.toString()), ); const variantMap = new Map( existingVariants.map((variant) => [variant.id.toString(), variant]), ); for (const removedId of dto.removedIds) { if (!variantMap.has(removedId)) { throw new BadRequestException(`Variant "${removedId}" not found for this product`); } } const survivingVariants = existingVariants.filter( (variant) => !dto.removedIds.includes(variant.id.toString()), ); const existingKeys = new Map( survivingVariants.map((variant) => [ variant.id.toString(), this.selectionKey(variant.selections.map((selection) => selection.optionId.toString())), ]), ); const batchKeys = new Set(); for (const item of dto.items) { const createDto: CreateStoreItemVariantDto = { selections: item.selections, price: item.price, stockQuantity: item.stockQuantity, }; if (item.id && !variantMap.has(item.id)) { throw new BadRequestException(`Variant "${item.id}" not found for this product`); } this.validateVariantItem( createDto, variationMap, allowedOptionIds, new Set( [...existingKeys.entries()] .filter(([id]) => id !== item.id) .map(([, key]) => key), ), batchKeys, ); const key = this.selectionKey( item.selections.filter((selection) => selection.optionId?.trim()).map((s) => s.optionId), ); if (item.id) { existingKeys.set(item.id, key); } } await this.prisma.$transaction(async (tx) => { const storeItem = await this.ensureStoreItem(businessId, productId, tx); const baseSortOrder = await tx.storeItemVariant.count({ where: { storeItemId: storeItem.id }, }); for (const removedId of dto.removedIds) { await tx.storeItemVariant.delete({ where: { id: BigInt(removedId) } }); } let createIndex = 0; for (const item of dto.items) { const activeSelections = item.selections.filter((selection) => selection.optionId?.trim()); if (item.id) { const variantId = BigInt(item.id); await tx.storeItemVariant.update({ where: { id: variantId }, data: { price: item.price, stockQuantity: item.stockQuantity, }, }); await tx.storeItemVariantSelection.deleteMany({ where: { variantId } }); for (const selection of activeSelections) { await tx.storeItemVariantSelection.create({ data: { variantId, variationId: BigInt(selection.variationId), optionId: BigInt(selection.optionId), }, }); } continue; } const variant = await tx.storeItemVariant.create({ data: { businessId, storeItemId: storeItem.id, price: item.price, stockQuantity: item.stockQuantity, sortOrder: baseSortOrder + createIndex, }, }); createIndex += 1; for (const selection of activeSelections) { await tx.storeItemVariantSelection.create({ data: { variantId: variant.id, variationId: BigInt(selection.variationId), optionId: BigInt(selection.optionId), }, }); } } }); const refreshed = await this.getVariantsForProduct(businessId, productId); const [galleryByProduct, stockByProduct] = await Promise.all([ this.loadFirstGalleryUrls(businessId, [productId]), this.loadStockTotals(businessId, [productId]), ]); return { message: 'Store item variants updated successfully', items: refreshed.map((item) => this.serializeVariant(item, { galleryUrl: galleryByProduct.get(productId.toString()) ?? null, productTotalStock: stockByProduct.get(productId.toString()) ?? 0, }), ), }; } async updateFestivalRewards( businessIdRaw: string, dto: BatchUpdateFestivalRewardsDto, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); const productId = BigInt(dto.productId); await this.assertPermission(businessId, actor.id, 'products.update'); const variants = await this.prisma.storeItemVariant.findMany({ where: { businessId, storeItem: { productId } }, }); const variantMap = new Map(variants.map((item) => [item.id.toString(), item])); for (const entry of dto.items) { if (!variantMap.has(entry.id)) { throw new BadRequestException(`Variant "${entry.id}" not found for this product`); } if (entry.rewardPoints !== null && entry.rewardPoints !== undefined && entry.rewardPoints < 0) { throw new BadRequestException('Reward points cannot be negative'); } } await this.prisma.$transaction( dto.items.map((entry) => this.prisma.storeItemVariant.update({ where: { id: BigInt(entry.id) }, data: { rewardPoints: entry.rewardPoints ?? null, isFestival: Boolean(entry.rewardPoints && entry.rewardPoints > 0), }, }), ), ); const refreshed = await this.getVariantsForProduct(businessId, productId); const [galleryByProduct, stockByProduct] = await Promise.all([ this.loadFirstGalleryUrls(businessId, [productId]), this.loadStockTotals(businessId, [productId]), ]); return { message: 'Festival reward points updated successfully', items: refreshed.map((item) => this.serializeVariant(item, { galleryUrl: galleryByProduct.get(productId.toString()) ?? null, productTotalStock: stockByProduct.get(productId.toString()) ?? 0, }), ), }; } async removeVariant( businessIdRaw: string, variantIdRaw: string, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); const variantId = BigInt(variantIdRaw); await this.assertPermission(businessId, actor.id, 'products.update'); const existing = await this.prisma.storeItemVariant.findFirst({ where: { id: variantId, businessId }, }); if (!existing) { throw new NotFoundException('Store item variant not found'); } await this.prisma.storeItemVariant.delete({ where: { id: variantId } }); return { message: 'Store item variant deleted successfully' }; } async removeByProduct( businessIdRaw: string, productIdRaw: string, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); const productId = BigInt(productIdRaw); await this.assertPermission(businessId, actor.id, 'products.update'); const product = await this.prisma.product.findFirst({ where: { id: productId, businessId }, }); if (!product) { throw new NotFoundException('Product not found'); } const result = await this.prisma.storeItem.deleteMany({ where: { businessId, productId }, }); if (result.count === 0) { throw new NotFoundException('No store item found for this product'); } return { message: 'Store item removed successfully' }; } private async getVariantsForProduct(businessId: bigint, productId: bigint) { return this.prisma.storeItemVariant.findMany({ where: { businessId, storeItem: { productId } }, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }], include: variantInclude, }); } private async loadVariantContext(businessId: bigint, productId: bigint) { const categoryId = await this.getProductCategoryId(businessId, productId); if (!categoryId) { throw new BadRequestException( 'Assign a category to this product before managing store item variants', ); } return Promise.all([ this.prisma.categoryVariation.findMany({ where: { businessId, categoryId }, include: { options: true }, }), this.prisma.productVariationValue.findMany({ where: { productId } }), this.prisma.storeItemVariant.findMany({ where: { businessId, storeItem: { productId } }, include: { selections: true }, }), ]); } private async assertProductWithCategory(businessId: bigint, productId: bigint) { const product = await this.prisma.product.findFirst({ where: { id: productId, businessId }, }); if (!product) { throw new NotFoundException('Product not found'); } const categoryId = await this.getProductCategoryId(businessId, productId); if (!categoryId) { throw new BadRequestException( 'Assign a category to this product before creating store item variants', ); } } private async ensureStoreItem( businessId: bigint, productId: bigint, tx: Prisma.TransactionClient = this.prisma, ) { const existing = await tx.storeItem.findUnique({ where: { businessId_productId: { businessId, productId } }, }); if (existing) { return existing; } return tx.storeItem.create({ data: { businessId, productId }, }); } private async loadFirstGalleryUrls(businessId: bigint, productIds: bigint[]) { if (productIds.length === 0) { return new Map(); } const attachments = await this.prisma.mediaAttachment.findMany({ where: { businessId, entityType: MediaEntityType.product, entityId: { in: productIds }, isFeatured: false, }, orderBy: [{ entityId: 'asc' }, { sortOrder: 'asc' }], include: { media: true }, }); const map = new Map(); for (const attachment of attachments) { const key = attachment.entityId.toString(); if (!map.has(key)) { map.set(key, attachment.media.publicUrl); } } return map; } private async loadStockTotals(businessId: bigint, productIds: bigint[]) { if (productIds.length === 0) { return new Map(); } const storeItems = await this.prisma.storeItem.findMany({ where: { businessId, productId: { in: productIds } }, select: { id: true, productId: true }, }); if (storeItems.length === 0) { return new Map(); } const rows = await this.prisma.storeItemVariant.groupBy({ by: ['storeItemId'], where: { businessId, storeItemId: { in: storeItems.map((item) => item.id) }, }, _sum: { stockQuantity: true }, }); const productByStoreItem = new Map( storeItems.map((item) => [item.id.toString(), item.productId.toString()]), ); return new Map( rows.map((row) => [ productByStoreItem.get(row.storeItemId.toString()) ?? row.storeItemId.toString(), row._sum.stockQuantity ?? 0, ]), ); } private validateVariantItem( item: CreateStoreItemVariantDto, variationMap: Map, allowedOptionIds: Set, existingKeys: Set, batchKeys: Set, ) { const seenVariationIds = new Set(); const activeSelections = item.selections.filter((selection) => selection.optionId?.trim()); for (const selection of activeSelections) { if (seenVariationIds.has(selection.variationId)) { throw new BadRequestException( `Duplicate variation "${selection.variationId}" in one variant`, ); } seenVariationIds.add(selection.variationId); const variation = variationMap.get(selection.variationId); if (!variation) { throw new BadRequestException( `Variation "${selection.variationId}" is not defined for this product category`, ); } const validOptionIds = new Set( variation.options.map((option) => option.id.toString()), ); if (!validOptionIds.has(selection.optionId)) { throw new BadRequestException( `Option "${selection.optionId}" does not belong to variation "${variation.name}"`, ); } if (allowedOptionIds.size > 0 && !allowedOptionIds.has(selection.optionId)) { throw new BadRequestException( `Option "${selection.optionId}" is not enabled for this product`, ); } } const key = this.selectionKey(activeSelections.map((selection) => selection.optionId)); if (existingKeys.has(key)) { throw new BadRequestException('A variant with this variation combination already exists'); } if (batchKeys.has(key)) { throw new BadRequestException('Duplicate variation combination in request'); } existingKeys.add(key); batchKeys.add(key); if (item.price != null && item.price < 0) { throw new BadRequestException('Price cannot be negative'); } if (item.stockQuantity != null && item.stockQuantity < 0) { throw new BadRequestException('Stock cannot be negative'); } } private selectionKey(optionIds: string[]) { return [...optionIds].sort().join(':'); } private serializeVariant( variant: VariantWithRelations, extras: { galleryUrl: string | null; productTotalStock: number }, ) { const product = variant.storeItem.product; const content = this.asRecord(product.content); const selections = variant.selections.map((selection) => ({ variationId: selection.variation.id.toString(), variationName: selection.variation.name, optionId: selection.option.id.toString(), value: selection.option.label, })); const price = variant.price === null ? null : Number(variant.price); const discountedPrice = variant.compareAtPrice === null ? null : Number(variant.compareAtPrice); const thumbnailUrl = product.featuredMedia?.publicUrl ?? null; const productImage = thumbnailUrl ?? extras.galleryUrl ?? null; return { id: variant.id.toString(), storeItemId: variant.storeItemId.toString(), productId: product.id.toString(), productTitle: product.title, productNameFa: (content.nameFa as string | null | undefined) ?? '', productImage, productTotalStock: extras.productTotalStock, selections, label: selections.map((item) => item.value).join(' ยท ') || product.title, price, discountedPrice: discountedPrice !== null && price !== null && discountedPrice < price ? discountedPrice : null, stockQuantity: variant.stockQuantity, rewardPoints: variant.rewardPoints, isFestival: variant.isFestival, sortOrder: variant.sortOrder, createdAt: variant.createdAt, }; } private asRecord(value: unknown): Record { if (value && typeof value === 'object' && !Array.isArray(value)) { return value as Record; } return {}; } private async getProductCategoryId(businessId: bigint, productId: bigint) { const assignment = await this.prisma.categoryAssignment.findFirst({ where: { businessId, entityType: MediaEntityType.product, entityId: productId, }, }); return assignment?.categoryId ?? null; } private async buildPublicVariantWhere( businessId: bigint, query: ListPublicStoreItemsDto, ): Promise { let productIds: bigint[] | undefined; if (query.productId) { productIds = [BigInt(query.productId)]; } else if (query.categoryId) { const assignments = await this.prisma.categoryAssignment.findMany({ where: { businessId, categoryId: BigInt(query.categoryId), entityType: MediaEntityType.product, }, select: { entityId: true }, }); productIds = assignments.map((item) => item.entityId); if (productIds.length === 0) { return { id: { in: [] } }; } } const name = query.name?.trim(); return { businessId, isActive: true, storeItem: { isActive: true, product: { status: ContentStatus.published, ...(query.brandId ? { brandId: BigInt(query.brandId) } : {}), ...(productIds ? { id: { in: productIds } } : {}), ...(name ? { OR: [ { title: { contains: name, mode: 'insensitive' } }, { content: { path: ['nameFa'], string_contains: name, }, }, ], } : {}), }, }, ...(query.inStock ? { stockQuantity: { gt: 0 } } : {}), ...(query.isFestival ? { isFestival: true } : {}), ...(query.minPrice !== undefined || query.maxPrice !== undefined ? { price: { ...(query.minPrice !== undefined ? { gte: query.minPrice } : {}), ...(query.maxPrice !== undefined ? { lte: query.maxPrice } : {}), }, } : {}), }; } private async assertPublishedProductExists(businessId: bigint, productId: bigint) { const product = await this.prisma.product.findFirst({ where: { id: productId, businessId, status: ContentStatus.published, }, select: { id: true }, }); if (!product) { throw new NotFoundException('Product not found'); } } private async assertPermission( businessId: bigint, userId: bigint, permission: string, ) { const allowed = await this.permissions.hasBusinessPermission( userId, businessId, permission, ); if (!allowed) { throw new ForbiddenException( `Missing permission: ${permission} for this business`, ); } } }