165 lines
5.8 KiB
JavaScript
165 lines
5.8 KiB
JavaScript
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)
|
|
})
|
|
}
|
|
|