base commit
This commit is contained in:
@@ -43,10 +43,14 @@ export function materialsFromDb(materials) {
|
||||
}
|
||||
}
|
||||
|
||||
export function mapProductForApi(p) {
|
||||
return {
|
||||
export function mapProductForApi(p, reviewsSummary = null) {
|
||||
const base = {
|
||||
...p,
|
||||
materials: materialsFromDb(p.materials),
|
||||
}
|
||||
if (reviewsSummary && typeof reviewsSummary === 'object') {
|
||||
base.reviewsSummary = reviewsSummary
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
import { prisma } from '../../lib/prisma.js'
|
||||
import { canTransitionOrderStatus } from '../../lib/order-status.js'
|
||||
import { canTransitionAdminOrderStatus } from '../../lib/order-status.js'
|
||||
|
||||
export async function registerAdminOrderRoutes(fastify) {
|
||||
fastify.get(
|
||||
'/api/admin/orders/summary',
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
async () => {
|
||||
const attentionCount = await prisma.order.count({
|
||||
where: { status: { in: ['PENDING_PAYMENT', 'PAYMENT_VERIFICATION'] } },
|
||||
})
|
||||
return { attentionCount }
|
||||
},
|
||||
)
|
||||
|
||||
fastify.get(
|
||||
'/api/admin/orders',
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
async (request, reply) => {
|
||||
const status = typeof request.query?.status === 'string' ? request.query.status.trim() : ''
|
||||
const q = typeof request.query?.q === 'string' ? request.query.q.trim() : ''
|
||||
const deliveryTypeRaw = request.query?.deliveryType
|
||||
const deliveryType = typeof deliveryTypeRaw === 'string' ? deliveryTypeRaw.trim() : ''
|
||||
|
||||
const pageRaw = request.query?.page
|
||||
const pageParsed = typeof pageRaw === 'string' ? Number(pageRaw) : Number(pageRaw)
|
||||
@@ -20,6 +33,12 @@ export async function registerAdminOrderRoutes(fastify) {
|
||||
|
||||
const where = {}
|
||||
if (status) where.status = status
|
||||
if (deliveryType) {
|
||||
if (deliveryType !== 'delivery' && deliveryType !== 'pickup') {
|
||||
return reply.code(400).send({ error: 'deliveryType должен быть delivery | pickup' })
|
||||
}
|
||||
where.deliveryType = deliveryType
|
||||
}
|
||||
if (q) {
|
||||
where.OR = [{ id: { contains: q } }, { user: { email: { contains: q } } }]
|
||||
}
|
||||
@@ -37,6 +56,7 @@ export async function registerAdminOrderRoutes(fastify) {
|
||||
items: items.map((o) => ({
|
||||
id: o.id,
|
||||
status: o.status,
|
||||
deliveryType: o.deliveryType,
|
||||
totalCents: o.totalCents,
|
||||
currency: o.currency,
|
||||
createdAt: o.createdAt,
|
||||
@@ -79,7 +99,7 @@ export async function registerAdminOrderRoutes(fastify) {
|
||||
|
||||
const existing = await prisma.order.findUnique({ where: { id } })
|
||||
if (!existing) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||
if (!canTransitionOrderStatus(existing.status, next)) {
|
||||
if (!canTransitionAdminOrderStatus(existing, next)) {
|
||||
return reply.code(409).send({ error: `Нельзя сменить статус ${existing.status} → ${next}` })
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,63 @@
|
||||
import { prisma } from '../../lib/prisma.js'
|
||||
|
||||
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, { mapProductForApi } = {}) {
|
||||
fastify.get('/api/categories', async () => {
|
||||
return prisma.category.findMany({ orderBy: { sort: 'asc' } })
|
||||
@@ -9,6 +67,8 @@ export async function registerPublicCatalogRoutes(fastify, { mapProductForApi }
|
||||
const { categorySlug } = request.query
|
||||
const qRaw = request.query?.q
|
||||
const q = typeof qRaw === 'string' ? qRaw.trim() : ''
|
||||
const availabilityRaw = request.query?.availability
|
||||
const availability = typeof availabilityRaw === 'string' ? availabilityRaw.trim() : ''
|
||||
|
||||
const sortRaw = request.query?.sort
|
||||
const sort = typeof sortRaw === 'string' ? sortRaw : ''
|
||||
@@ -29,13 +89,21 @@ export async function registerPublicCatalogRoutes(fastify, { mapProductForApi }
|
||||
const priceMaxParsed = typeof priceMaxRaw === 'string' ? Number(priceMaxRaw) : Number(priceMaxRaw)
|
||||
const priceMax = Number.isFinite(priceMaxParsed) && priceMaxParsed >= 0 ? Math.floor(priceMaxParsed) : null
|
||||
|
||||
const where = { published: true, quantity: { gt: 0 } }
|
||||
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 } }]
|
||||
}
|
||||
if (availability === 'in_stock') {
|
||||
where.inStock = true
|
||||
where.quantity = { gt: 0 }
|
||||
} else if (availability === 'made_to_order') {
|
||||
where.inStock = false
|
||||
} else if (availability && availability !== 'all') {
|
||||
return reply.code(400).send({ error: 'availability должен быть all | in_stock | made_to_order' })
|
||||
}
|
||||
const applyPriceFilter = !(priceMin !== null && priceMax !== null && priceMin === 0 && priceMax === 0)
|
||||
|
||||
if (applyPriceFilter && (priceMin !== null || priceMax !== null)) {
|
||||
@@ -64,20 +132,27 @@ export async function registerPublicCatalogRoutes(fastify, { mapProductForApi }
|
||||
take: pageSize,
|
||||
})
|
||||
|
||||
return { items: items.map(mapProductForApi), total, page, pageSize }
|
||||
const summaries = await approvedReviewSummariesForProducts(items.map((it) => it.id))
|
||||
return {
|
||||
items: items.map((p) => 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, quantity: { gt: 0 } },
|
||||
where: { id, published: true },
|
||||
include: { category: true, images: { orderBy: { sort: 'asc' } } },
|
||||
})
|
||||
if (!product) {
|
||||
reply.code(404).send({ error: 'Товар не найден' })
|
||||
return
|
||||
}
|
||||
return mapProductForApi(product)
|
||||
const summaries = await approvedReviewSummariesForProducts([product.id])
|
||||
return mapProductForApi(product, summaries.get(product.id) ?? EMPTY_REVIEWS_SUMMARY)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,36 @@
|
||||
import { publicReviewAuthorDisplay } from '../../lib/review-display.js'
|
||||
import { prisma } from '../../lib/prisma.js'
|
||||
|
||||
export async function registerPublicReviewRoutes(fastify) {
|
||||
fastify.get('/api/reviews/latest', async (request, reply) => {
|
||||
const limitRaw = request.query?.limit
|
||||
const limitParsed = typeof limitRaw === 'string' ? Number(limitRaw) : Number(limitRaw)
|
||||
const parsed = Number.isFinite(limitParsed) && limitParsed > 0 ? Math.floor(limitParsed) : 5
|
||||
const take = Math.min(parsed, 5)
|
||||
|
||||
const rows = await prisma.review.findMany({
|
||||
where: { status: 'approved', product: { published: true } },
|
||||
include: {
|
||||
user: { select: { email: true, name: true } },
|
||||
product: { select: { id: true, title: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take,
|
||||
})
|
||||
|
||||
const items = rows.map((r) => ({
|
||||
id: r.id,
|
||||
rating: r.rating,
|
||||
text: r.text,
|
||||
createdAt: r.createdAt,
|
||||
authorDisplay: publicReviewAuthorDisplay(r.user),
|
||||
productId: r.productId,
|
||||
productTitle: r.product?.title ?? '',
|
||||
}))
|
||||
|
||||
return { items }
|
||||
})
|
||||
|
||||
fastify.get('/api/products/:id/reviews', async (request, reply) => {
|
||||
const { id } = request.params
|
||||
|
||||
@@ -18,14 +48,22 @@ export async function registerPublicReviewRoutes(fastify) {
|
||||
|
||||
const where = { productId: id, status: 'approved' }
|
||||
const total = await prisma.review.count({ where })
|
||||
const items = await prisma.review.findMany({
|
||||
const rawItems = await prisma.review.findMany({
|
||||
where,
|
||||
include: { user: { select: { id: true, name: true, email: true } } },
|
||||
include: { user: { select: { email: true, name: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
})
|
||||
|
||||
const items = rawItems.map((r) => ({
|
||||
id: r.id,
|
||||
rating: r.rating,
|
||||
text: r.text,
|
||||
createdAt: r.createdAt,
|
||||
authorDisplay: publicReviewAuthorDisplay(r.user),
|
||||
}))
|
||||
|
||||
return { items, total, page, pageSize }
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user