initial: server + shared
This commit is contained in:
Executable
+152
@@ -0,0 +1,152 @@
|
||||
import { prisma } from '../../lib/prisma.js'
|
||||
import { publicReviewAuthorDisplay } from '../../lib/review-display.js'
|
||||
import { persistMultipartImages } from '../../lib/upload-images.js'
|
||||
import {
|
||||
formatFileTooLargeMessage,
|
||||
getOtherUploadMaxFileBytes,
|
||||
isMultipartFileTooLargeError,
|
||||
} from '../../lib/upload-limits.js'
|
||||
|
||||
export async function registerPublicReviewRoutes(fastify) {
|
||||
fastify.post('/api/reviews/upload-image', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
try {
|
||||
const urls = await persistMultipartImages(request, {
|
||||
maxFiles: 1,
|
||||
maxFileBytes: getOtherUploadMaxFileBytes(),
|
||||
subdir: 'reviews',
|
||||
})
|
||||
if (urls.length !== 1) return reply.code(400).send({ error: 'Нужно прикрепить 1 изображение' })
|
||||
return { url: urls[0] }
|
||||
} catch (error) {
|
||||
let message = error instanceof Error ? error.message : 'Не удалось загрузить изображение'
|
||||
let statusCode =
|
||||
error && typeof error === 'object' && 'statusCode' in error && Number.isInteger(error.statusCode)
|
||||
? Number(error.statusCode)
|
||||
: 400
|
||||
if (isMultipartFileTooLargeError(error)) {
|
||||
message = formatFileTooLargeMessage(getOtherUploadMaxFileBytes())
|
||||
statusCode = 413
|
||||
}
|
||||
return reply.code(statusCode).send({ error: message })
|
||||
}
|
||||
})
|
||||
|
||||
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' },
|
||||
include: {
|
||||
user: { select: { id: true, email: true, displayName: true, avatar: true, avatarStyle: true } },
|
||||
product: { select: { id: true, title: true, published: true, slug: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take,
|
||||
})
|
||||
|
||||
const items = rows.map((r) => ({
|
||||
id: r.id,
|
||||
rating: r.rating,
|
||||
text: r.text,
|
||||
imageUrl: r.imageUrl,
|
||||
createdAt: r.createdAt,
|
||||
authorId: r.user?.id ?? r.userId,
|
||||
authorDisplay: publicReviewAuthorDisplay(r.user),
|
||||
authorAvatar: r.user?.avatar ?? null,
|
||||
authorAvatarStyle: r.user?.avatarStyle ?? null,
|
||||
product: {
|
||||
id: r.product?.id ?? r.productId,
|
||||
title: r.product?.title ?? '',
|
||||
published: r.product?.published ?? false,
|
||||
slug: r.product?.slug ?? '',
|
||||
},
|
||||
}))
|
||||
|
||||
return { items }
|
||||
})
|
||||
|
||||
fastify.get('/api/products/:id/reviews', async (request, reply) => {
|
||||
const { id } = request.params
|
||||
|
||||
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) : 10
|
||||
if (pageSize > 50) return reply.code(400).send({ error: 'pageSize должен быть ≤ 50' })
|
||||
|
||||
const product = await prisma.product.findFirst({ where: { id, published: true } })
|
||||
if (!product) return reply.code(404).send({ error: 'Товар не найден' })
|
||||
|
||||
const where = { productId: id, status: 'approved' }
|
||||
const total = await prisma.review.count({ where })
|
||||
const rawItems = await prisma.review.findMany({
|
||||
where,
|
||||
include: {
|
||||
user: { select: { id: true, email: true, displayName: true, avatar: true, avatarStyle: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
})
|
||||
|
||||
const items = rawItems.map((r) => ({
|
||||
id: r.id,
|
||||
rating: r.rating,
|
||||
text: r.text,
|
||||
imageUrl: r.imageUrl,
|
||||
createdAt: r.createdAt,
|
||||
authorId: r.user?.id ?? r.userId,
|
||||
authorDisplay: publicReviewAuthorDisplay(r.user),
|
||||
authorAvatar: r.user?.avatar ?? null,
|
||||
authorAvatarStyle: r.user?.avatarStyle ?? null,
|
||||
}))
|
||||
|
||||
return { items, total, page, pageSize }
|
||||
})
|
||||
|
||||
fastify.post('/api/products/:id/reviews', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const { id: productId } = request.params
|
||||
|
||||
const product = await prisma.product.findFirst({ where: { id: productId, published: true } })
|
||||
if (!product) return reply.code(404).send({ error: 'Товар не найден' })
|
||||
|
||||
const rating = Number(request.body?.rating)
|
||||
if (!Number.isFinite(rating) || rating < 1 || rating > 5) {
|
||||
return reply.code(400).send({ error: 'rating должен быть от 1 до 5' })
|
||||
}
|
||||
const textRaw = request.body?.text
|
||||
const text = textRaw === null || textRaw === undefined ? null : String(textRaw).trim()
|
||||
if (text !== null && text.length > 1000) return reply.code(400).send({ error: 'Отзыв слишком длинный' })
|
||||
const imageUrlRaw = request.body?.imageUrl
|
||||
const imageUrl = imageUrlRaw === null || imageUrlRaw === undefined ? null : String(imageUrlRaw).trim()
|
||||
if (imageUrl !== null && imageUrl.length > 300)
|
||||
return reply.code(400).send({ error: 'Ссылка на фото слишком длинная' })
|
||||
if (imageUrl !== null && imageUrl.length > 0 && !imageUrl.startsWith('/uploads/')) {
|
||||
return reply.code(400).send({ error: 'Некорректная ссылка на изображение' })
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await prisma.review.create({
|
||||
data: {
|
||||
productId,
|
||||
userId,
|
||||
rating: Math.floor(rating),
|
||||
text: text && text.length ? text : null,
|
||||
imageUrl: imageUrl && imageUrl.length ? imageUrl : null,
|
||||
status: 'pending',
|
||||
},
|
||||
})
|
||||
return reply.code(201).send({ item: created })
|
||||
} catch (err) {
|
||||
request.log.error({ err }, 'Failed to create review (possible duplicate)')
|
||||
return reply.code(409).send({ error: 'Вы уже оставляли отзыв на этот товар' })
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user