import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../../prisma/prisma.service'; import { AccountingHooksService } from '../accounting-sync/accounting-hooks.service'; import { CatalogCacheService } from './catalog-cache.service'; import { DEFAULT_PAGE_SIZE, ListProductsDto, type ProductSort } from './dto/list-products.dto'; @Injectable() export class CatalogService { constructor( private readonly prisma: PrismaService, private readonly accounting: AccountingHooksService, private readonly catalogCache: CatalogCacheService, ) {} /** * The whole active catalogue, unpaged. * * Kept exactly as it was for every existing caller (the admin panel, the smoke * scripts, installed app builds). New callers should use `findProducts` with a * page — the real catalogue reaches ~16k products, and this response carries * all of them with their variants, images and collections. * * Served from CatalogCacheService; see there for what invalidates it. */ listProducts() { return this.catalogCache.get(() => this.buildProductList()); } /** * The storefront list, paged and filtered server-side. * * Owner, 2026-10-12: the catalogue reaches ~16k products, so returning it * whole is wrong. The filters live here rather than in the apps because a * client cannot filter data it has not been sent — paging the response while * leaving the filter on the client would silently search only the first page. * * Returns `{ rows, total, page, limit }`. With no `page`/`limit` the caller * gets everything that matches, so the filters are usable on their own. * * NOT cached, deliberately. CatalogCacheService holds one built response and * exists because the unpaged query is expensive and every screen asked for * it. A paged, filtered query is neither: it is bounded by `limit` and served * by Product_isActive_createdAt_idx. Caching it would mean a key per filter * combination — unbounded memory for queries that are already cheap, and a * second place for stale rows to hide. If this ever needs a cache it should * be a shared one (Redis), which is also what the single-instance note on * CatalogCacheService points at. */ async findProducts(query: ListProductsDto) { // Specific ids: the cart, favourites and order items each need a handful of // known products. Paging and the filters do not apply. if (query.ids?.length) { const rows = await this.buildProductList({ ids: query.ids }); return { rows, total: rows.length, page: 1, limit: rows.length }; } const paged = query.page !== undefined || query.limit !== undefined; const limit = query.limit ?? DEFAULT_PAGE_SIZE; const page = query.page ?? 1; const where = this.storefrontWhere(query); // Counted with the same `where`, so `total` describes the filtered set and // the app's "N محصول" and end-of-list detection stay truthful. const [total, rows] = await Promise.all([ this.prisma.product.count({ where }), this.buildProductList({ where, orderBy: this.orderFor(query.sort), ...(paged ? { skip: (page - 1) * limit, take: limit } : {}), }), ]); return { rows, total, page: paged ? page : 1, limit: paged ? limit : total }; } /** One product for its own screen, instead of fetching the catalogue to find it. */ async findProductById(id: string) { const [product] = await this.buildProductList({ where: { ...this.storefrontWhere({}), id } }); if (!product) throw new NotFoundException('محصول یافت نشد'); return product; } /** * The filter options the search sheet offers. * * The apps derived these by scanning the whole catalogue in the browser * (SearchView.vue:167-181), which is only possible while the whole catalogue * is in memory. Each is a small aggregate here. */ async facets() { const where = this.storefrontWhere({}); const [categories, brands, collections, gram] = await Promise.all([ this.prisma.category.findMany({ where: { products: { some: where } }, select: { id: true, title: true }, orderBy: { title: 'asc' }, }), this.prisma.brand.findMany({ where: { products: { some: where } }, select: { id: true, title: true }, orderBy: { title: 'asc' }, }), this.prisma.collection.findMany({ where: { products: { some: { product: where } } }, select: { id: true, title: true }, orderBy: { title: 'asc' }, }), this.prisma.productVariant.aggregate({ where: { isActive: true, product: where }, _min: { rashWeightGrams: true }, _max: { rashWeightGrams: true }, }), ]); return { categories, brands, collections, // Strings, like every other weight this API returns (Decimal, not float). gramRange: { min: gram._min.rashWeightGrams?.toString() ?? '0', max: gram._max.rashWeightGrams?.toString() ?? '0', }, }; } /** * The two rules that decide whether a product is on the storefront at all, * plus the caller's filters. Shared so a filtered list, its count, the single * product route and the facets can never disagree about what is visible. * * D27 — a product with no image is never shown in the app. Products also * arrive from the accounting software, which carries no pictures; they appear * once an admin photographs them. */ private storefrontWhere(query: ListProductsDto): Prisma.ProductWhereInput { const where: Prisma.ProductWhereInput = { isActive: true, images: { some: {} } }; if (query.search?.trim()) { // `contains`, as the apps' own title filter did. Case-insensitive is a // no-op for Persian but matches how every other search in this API reads. where.title = { contains: query.search.trim(), mode: 'insensitive' }; } if (query.categoryId) where.categoryId = query.categoryId; if (query.brandId) where.brandId = query.brandId; if (query.collectionId) where.collections = { some: { collectionId: query.collectionId } }; // A product is in range when at least ONE of its variants is, which is what // the app's `limit_gram` filter meant (SearchView.vue:189-195). if (query.minGram !== undefined || query.maxGram !== undefined) { where.variants = { some: { isActive: true, rashWeightGrams: { ...(query.minGram !== undefined ? { gte: query.minGram } : {}), ...(query.maxGram !== undefined ? { lte: query.maxGram } : {}), }, }, }; } return where; } private orderFor(sort: ProductSort | undefined): Prisma.ProductOrderByWithRelationInput { switch (sort) { case 'oldest': return { createdAt: 'asc' }; case 'title': return { title: 'asc' }; default: // Newest first, and the half of Product_isActive_createdAt_idx the list // is indexed for. return { createdAt: 'desc' }; } } private async buildProductList( opts: { where?: Prisma.ProductWhereInput; orderBy?: Prisma.ProductOrderByWithRelationInput; skip?: number; take?: number; ids?: string[]; } = {}, ) { const products = await this.prisma.product.findMany({ // Defaults to the unfiltered storefront rule, so the unpaged // `listProducts()` above behaves exactly as before. where: opts.ids ? { ...this.storefrontWhere({}), id: { in: opts.ids } } : (opts.where ?? { isActive: true, images: { some: {} } }), ...(opts.orderBy ? { orderBy: opts.orderBy } : {}), ...(opts.skip !== undefined ? { skip: opts.skip } : {}), ...(opts.take !== undefined ? { take: opts.take } : {}), include: { // Only orderable variants reach the storefront. An inactive one used to // be listed and then rejected by getVariantOrThrow at checkout, so the // customer could pick a chip and get a 404 on the order. variants: { where: { isActive: true }, // Each variant carries its own gallery picture, so the app can render // the chip without a second request per variant. include: { image: true }, }, // Explicit order: the UI treats images[0] as the product's main photo, // which is only meaningful if the rows come back sorted. images: { orderBy: { sortOrder: 'asc' } }, category: true, brand: true, collections: { include: { collection: true } }, }, }); const stockBlocked = await this.accounting.stockBlocker(); const out = []; for (const p of products) { const { accountingStockWeight, accountingStockCount, accountingStockAt, ...product } = p; // D27 — out of stock in the accounting software: every variant reads as // qty 0, which the apps already render as «ناموجود» and refuse to add to // the cart unless it is a pre-order variant. The stock figures themselves // stay server-side; this endpoint is public. const blocked = stockBlocked({ accountingStockWeight, accountingStockCount, accountingStockAt }); out.push(blocked ? { ...product, variants: product.variants.map((v) => (v.isPreOrder ? v : { ...v, qty: 0 })) } : product); } return out; } async getVariantOrThrow(variantId: string) { const variant = await this.prisma.productVariant.findUnique({ where: { id: variantId }, include: { product: { select: { id: true, accountingStockWeight: true, accountingStockCount: true, accountingStockAt: true, _count: { select: { images: true } }, }, }, }, }); if (!variant || !variant.isActive) { throw new NotFoundException(`Product variant ${variantId} not found or inactive`); } // The same two rules the listing applies, so a stale client cannot order // around them. if (variant.product._count.images === 0) { throw new BadRequestException('این محصول هنوز تصویر ندارد و قابل سفارش نیست'); } // Live from the accounting software at order time; on no answer the last // stored figure decides (liveStock). if (!variant.isPreOrder && (await this.accounting.stockBlocked(await this.accounting.liveStock(variant.product)))) { throw new BadRequestException('این محصول در انبار موجود نیست'); } const { product: _product, ...plain } = variant; return plain; } }