import { prisma } from '../../lib/prisma.js' const PUBLIC_PRODUCTS_QUERY_SCHEMA = { querystring: { type: 'object', properties: { categorySlug: { type: 'string' }, q: { type: 'string' }, sort: { type: 'string', enum: ['', 'price_asc', 'price_desc'] }, page: { type: 'integer', minimum: 1 }, pageSize: { type: 'integer', minimum: 1, maximum: 100 }, priceMin: { type: 'number', minimum: 0 }, priceMax: { type: 'number', minimum: 0 }, }, }, } const EMPTY_REVIEWS_SUMMARY = Object.freeze({ approvedReviewCount: 0, avgRating: null, latestApprovedText: null, }) /** Сводка по одобренным отзывам для списка id товаров (для каталога и карточки товара). */ export async function approvedReviewSummariesForProducts(productIds) { const map = new Map() if (!productIds.length) return map const uniqueIds = [...new Set(productIds)] for (const id of uniqueIds) { map.set(id, { ...EMPTY_REVIEWS_SUMMARY }) } const grouped = await prisma.review.groupBy({ by: ['productId'], where: { productId: { in: uniqueIds }, status: 'approved' }, _count: { _all: true }, _avg: { rating: true }, }) for (const g of grouped) { const avg = g._avg.rating const prev = map.get(g.productId) if (!prev) continue map.set(g.productId, { ...prev, approvedReviewCount: g._count._all, avgRating: avg != null ? Number(avg) : null, }) } const withReviews = [...map.entries()].filter(([, v]) => v.approvedReviewCount > 0).map(([k]) => k) if (!withReviews.length) return map const previewRows = await prisma.review.findMany({ where: { productId: { in: withReviews }, status: 'approved' }, orderBy: { createdAt: 'desc' }, select: { productId: true, text: true }, take: 450, }) const hasPreviewFor = new Set() for (const r of previewRows) { if (hasPreviewFor.has(r.productId)) continue const t = typeof r.text === 'string' ? r.text.trim() : '' if (!t) continue hasPreviewFor.add(r.productId) const prev = map.get(r.productId) if (!prev) continue prev.latestApprovedText = t.length > 160 ? `${t.slice(0, 160)}…` : t if (hasPreviewFor.size === withReviews.length) break } return map } export async function registerPublicCatalogRoutes(fastify) { fastify.get('/api/categories', async () => { return prisma.category.findMany({ orderBy: { sort: 'asc' } }) }) fastify.get('/api/products', { schema: PUBLIC_PRODUCTS_QUERY_SCHEMA }, async (request, reply) => { const { mapProductForApi } = request.server const { categorySlug } = request.query const qRaw = request.query?.q const q = typeof qRaw === 'string' ? qRaw.trim() : '' const sortRaw = request.query?.sort const sort = typeof sortRaw === 'string' ? sortRaw : '' const pageRaw = request.query?.page const pageParsed = typeof pageRaw === 'string' ? Number(pageRaw) : Number(pageRaw) const page = Number.isFinite(pageParsed) && pageParsed > 0 ? Math.floor(pageParsed) : 1 const pageSizeRaw = request.query?.pageSize const pageSizeParsed = typeof pageSizeRaw === 'string' ? Number(pageSizeRaw) : Number(pageSizeRaw) const pageSize = Number.isFinite(pageSizeParsed) && pageSizeParsed > 0 ? Math.floor(pageSizeParsed) : 12 const priceMinRaw = request.query?.priceMin const priceMinParsed = typeof priceMinRaw === 'string' ? Number(priceMinRaw) : Number(priceMinRaw) const priceMin = Number.isFinite(priceMinParsed) && priceMinParsed >= 0 ? Math.floor(priceMinParsed) : null const priceMaxRaw = request.query?.priceMax const priceMaxParsed = typeof priceMaxRaw === 'string' ? Number(priceMaxRaw) : Number(priceMaxRaw) const priceMax = Number.isFinite(priceMaxParsed) && priceMaxParsed >= 0 ? Math.floor(priceMaxParsed) : null const where = { published: true } if (typeof categorySlug === 'string' && categorySlug.length > 0) { where.category = { slug: categorySlug } } if (q) { where.OR = [{ title: { contains: q } }, { shortDescription: { contains: q } }] } const applyPriceFilter = !(priceMin !== null && priceMax !== null && priceMin === 0 && priceMax === 0) if (applyPriceFilter && (priceMin !== null || priceMax !== null)) { if (priceMin !== null && priceMax !== null && priceMax < priceMin) { return reply.code(400).send({ error: 'priceMax должен быть ≥ priceMin' }) } where.priceCents = { ...(priceMin !== null ? { gte: priceMin } : {}), ...(priceMax !== null ? { lte: priceMax } : {}), } } const orderBy = sort === 'price_asc' ? { priceCents: 'asc' } : sort === 'price_desc' ? { priceCents: 'desc' } : { createdAt: 'desc' } const total = await prisma.product.count({ where }) const items = await prisma.product.findMany({ where, include: { category: true, images: { orderBy: { sort: 'asc' } } }, orderBy, skip: (page - 1) * pageSize, take: pageSize, }) const summaries = await approvedReviewSummariesForProducts(items.map((it) => it.id)) return { items: items.map((p) => request.server.mapProductForApi(p, summaries.get(p.id) ?? EMPTY_REVIEWS_SUMMARY)), total, page, pageSize, } }) fastify.get('/api/products/:id', async (request, reply) => { const { id } = request.params const product = await prisma.product.findFirst({ where: { id, published: true }, include: { category: true, images: { orderBy: { sort: 'asc' } } }, }) if (!product) { reply.code(404).send({ error: 'Товар не найден' }) return } const summaries = await approvedReviewSummariesForProducts([product.id]) return request.server.mapProductForApi(product, summaries.get(product.id) ?? EMPTY_REVIEWS_SUMMARY) }) }