Files
tehran_taj/apps/api/src/modules/catalog/catalog.service.ts
T
s.esmaeiliandClaude Opus 5 cbb93dec5d feat(catalog): page and filter the storefront server-side (steps 1-2)
The real catalogue reaches ~16k products and GET /catalog/products had no
paging parameters at all: it returned every active product with its
variants, images, category, brand and collections in one response. The
apps' PAGE_SIZE = 20 was only a render window over that full array, so
scrolling made zero requests. The reference paged this server-side; the
port had moved it to the client (STUB-03), so this is a return to
reference behaviour.

Step 1 — indexes, which were not optional. Product carried NO index
whatsoever (not isActive, categoryId, brandId or createdAt) and
ProductVariant had none on productId, so every per-product include
scanned its whole table. Adding take/skip on top of that would have
paged the response without making the query faster, which is the exact
failure this change exists to avoid. Product_isActive_createdAt_idx is
composite so one index serves the filter, the default ordering and the
OFFSET walk.

CREATE INDEX, not CONCURRENTLY: the deploy image's migration runner
wraps each file in a transaction, which CONCURRENTLY cannot join (D15).
Every statement is IF NOT EXISTS because both migration paths run it.

Step 2 — the routes. Paging is opt-in, which is the whole
backward-compatibility story: with no query parameters the route returns
the same plain array from the same cache as before, so the admin panel,
the smoke scripts and installed app builds are untouched. Pass page,
limit or any filter and it becomes { rows, total, page, limit }.

The filters had to move too, not just the paging. The apps filtered
title, category, brand and gram range in the browser over the full
array; paging the response while leaving that in place would have made
search quietly WRONG — searching only the first page and reporting
"محصولی پیدا نشد" for products that exist. So every filter is built into
the Prisma query, and count uses the same where clause, so `total`
describes the set the app is actually scrolling.

Three routes added so the apps can stop fetching everything to find one
thing (wired up in step 3): ?ids=a,b,c for the cart, favourites and
order items; /:id for the product screen, which today downloads the
whole catalogue to find one row; /facets for the filter sheet's options,
which the apps currently derive by scanning the catalogue in the
browser.

Deliberately not cached: CatalogCacheService exists because the unpaged
query is expensive and every screen wanted it. A paged, filtered query
is neither — bounded by limit and index-served — and caching it would
need a key per filter combination, i.e. unbounded memory for cheap
queries plus a second place for stale rows to hide.

D27's two storefront rules (active, has an image) live in one shared
where clause, so the list, its count, the single-product route, by-ids
and the facets can never disagree about what is visible. limit and ids
are both capped at 100; an uncapped limit is just the old "give me
everything" query spelled differently.

17 new cases, 329/329 overall. All 17 passed on the first run, which is
not evidence by itself, so one filter was deliberately broken to confirm
the suite catches it (1 failed/16 passed) and then restored.

Not measured on real data — the database container was not running, so
16k-row behaviour is reasoned from the query plan, not timed. Worth one
EXPLAIN ANALYZE on a full dataset before step 4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 15:01:32 +03:30

262 lines
10 KiB
TypeScript

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;
}
}