base commit

This commit is contained in:
@kirill.komarov
2026-04-29 19:29:24 +05:00
parent bfc9661d22
commit f26223091a
22 changed files with 987 additions and 931 deletions
+26
View File
@@ -0,0 +1,26 @@
export const ORDER_STATUSES = [
'DRAFT',
'PENDING_PAYMENT',
'PAID',
'IN_PROGRESS',
'SHIPPED',
'DONE',
'CANCELLED',
]
export const ORDER_STATUS_TRANSITIONS = {
DRAFT: new Set(['PENDING_PAYMENT', 'CANCELLED']),
PENDING_PAYMENT: new Set(['PAID', 'CANCELLED']),
PAID: new Set(['IN_PROGRESS', 'CANCELLED']),
IN_PROGRESS: new Set(['SHIPPED', 'CANCELLED']),
SHIPPED: new Set(['DONE']),
DONE: new Set([]),
CANCELLED: new Set([]),
}
export function canTransitionOrderStatus(from, to) {
if (from === to) return true
const allowed = ORDER_STATUS_TRANSITIONS[from]
return Boolean(allowed?.has(to))
}
+26 -857
View File
@@ -1,861 +1,30 @@
import { prisma } from '../lib/prisma.js'
import crypto from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
import { hashPassword, normalizeEmail } from '../lib/auth.js'
function slugify(input) {
return input
.toLowerCase()
.trim()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-а-яё]/gi, '')
}
function safeExtFromFilename(filename) {
const ext = path.extname(String(filename || '')).toLowerCase()
const allowed = new Set(['.png', '.jpg', '.jpeg', '.webp'])
return allowed.has(ext) ? ext : null
}
function parseMaterialsInput(input) {
if (Array.isArray(input)) {
return input
.map((x) => String(x || '').trim())
.filter(Boolean)
.slice(0, 30)
}
if (typeof input === 'string') {
const s = input.trim()
if (!s) return []
// поддержка: "хлопок, дерево"
return s
.split(',')
.map((x) => x.trim())
.filter(Boolean)
.slice(0, 30)
}
return []
}
function materialsFromDb(materials) {
if (Array.isArray(materials)) return materials
try {
const v = JSON.parse(String(materials || '[]'))
return Array.isArray(v) ? v.map((x) => String(x || '').trim()).filter(Boolean) : []
} catch {
return []
}
}
function mapProductForApi(p) {
return {
...p,
materials: materialsFromDb(p.materials),
}
}
import {
mapProductForApi,
parseMaterialsInput,
safeExtFromFilename,
slugify,
} from './api/_product-helpers.js'
import { registerAdminCategoryRoutes } from './api/admin-categories.js'
import { registerAdminOrderRoutes } from './api/admin-orders.js'
import { registerAdminProductRoutes } from './api/admin-products.js'
import { registerAdminReviewRoutes } from './api/admin-reviews.js'
import { registerAdminUserRoutes } from './api/admin-users.js'
import { registerPublicCatalogRoutes } from './api/public-catalog.js'
import { registerPublicReviewRoutes } from './api/public-reviews.js'
export async function registerApiRoutes(fastify) {
fastify.get('/api/categories', async () => {
return prisma.category.findMany({ orderBy: { sort: 'asc' } })
await registerPublicCatalogRoutes(fastify, { mapProductForApi })
await registerPublicReviewRoutes(fastify)
await registerAdminProductRoutes(fastify, {
slugify,
safeExtFromFilename,
parseMaterialsInput,
mapProductForApi,
})
fastify.get('/api/products', async (request, reply) => {
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,
})
return { items: items.map(mapProductForApi), 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
}
return mapProductForApi(product)
})
// ---- Отзывы к товарам ----
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 items = await prisma.review.findMany({
where,
include: { user: { select: { id: true, name: true, email: true } } },
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
})
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: 'Отзыв слишком длинный' })
try {
const created = await prisma.review.create({
data: {
productId,
userId,
rating: Math.floor(rating),
text: text && text.length ? text : null,
status: 'pending',
},
})
return reply.code(201).send({ item: created })
} catch {
return reply.code(409).send({ error: 'Вы уже оставляли отзыв на этот товар' })
}
},
)
// ---- Админ (тот же фронт, другой раздел) ----
fastify.get(
'/api/admin/products',
{ preHandler: [fastify.verifyAdmin] },
async () => {
const items = await prisma.product.findMany({
include: { category: true, images: { orderBy: { sort: 'asc' } } },
orderBy: { updatedAt: 'desc' },
})
return items.map(mapProductForApi)
},
)
fastify.post(
'/api/admin/uploads',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
if (!request.isMultipart()) {
reply.code(400).send({ error: 'Ожидается multipart/form-data' })
return
}
const uploadsDir = path.join(process.cwd(), 'uploads')
await fs.promises.mkdir(uploadsDir, { recursive: true })
const urls = []
const parts = request.parts()
for await (const part of parts) {
if (part.type !== 'file') continue
const ext = safeExtFromFilename(part.filename)
if (!ext) {
reply.code(400).send({ error: 'Разрешены только файлы: png, jpg, jpeg, webp' })
return
}
const id = crypto.randomUUID()
const fileName = `${id}${ext}`
const fullPath = path.join(uploadsDir, fileName)
await fs.promises.writeFile(fullPath, await part.toBuffer())
urls.push(`/uploads/${fileName}`)
}
return { urls }
},
)
fastify.post(
'/api/admin/products',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const body = request.body ?? {}
const title = String(body.title ?? '').trim()
if (!title) {
reply.code(400).send({ error: 'Укажите название' })
return
}
const slug =
String(body.slug ?? '').trim() || slugify(title) || `item-${Date.now()}`
const categoryId = String(body.categoryId ?? '').trim()
if (!categoryId) {
reply.code(400).send({ error: 'Укажите категорию' })
return
}
const priceCents = Number(body.priceCents)
if (!Number.isFinite(priceCents) || priceCents < 0) {
reply.code(400).send({ error: 'Некорректная цена (priceCents ≥ 0)' })
return
}
const inStock =
body.inStock === undefined || body.inStock === null ? true : Boolean(body.inStock)
const leadTimeDaysRaw = body.leadTimeDays
const leadTimeDays =
leadTimeDaysRaw === undefined || leadTimeDaysRaw === null || leadTimeDaysRaw === ''
? null
: Number(leadTimeDaysRaw)
if (!inStock) {
if (!Number.isFinite(leadTimeDays) || leadTimeDays <= 0) {
reply.code(400).send({ error: 'Если "под заказ", укажите срок исполнения (дней) > 0' })
return
}
}
const exists = await prisma.product.findUnique({ where: { slug } })
if (exists) {
reply.code(409).send({ error: 'Такой slug уже занят' })
return
}
let quantity = null
if (!(body.quantity === undefined || body.quantity === null || body.quantity === '')) {
const n = Number(body.quantity)
if (!Number.isFinite(n) || n < 0) {
reply.code(400).send({ error: 'Некорректное количество (quantity ≥ 0)' })
return
}
quantity = Math.floor(n)
}
const product = await prisma.product.create({
data: {
title,
slug,
shortDescription: body.shortDescription ? String(body.shortDescription) : null,
description: body.description ? String(body.description) : null,
quantity,
materials: JSON.stringify(parseMaterialsInput(body.materials)),
priceCents: Math.round(priceCents),
imageUrl: body.imageUrl ? String(body.imageUrl) : null,
published: Boolean(body.published),
inStock,
leadTimeDays: inStock ? null : Math.round(leadTimeDays),
categoryId,
images: Array.isArray(body.imageUrls)
? {
create: body.imageUrls
.map((u) => String(u || '').trim())
.filter(Boolean)
.slice(0, 10)
.map((u, idx) => ({ url: u, sort: idx })),
}
: undefined,
},
include: { category: true, images: { orderBy: { sort: 'asc' } } },
})
reply.code(201).send(mapProductForApi(product))
},
)
fastify.patch(
'/api/admin/products/:id',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const { id } = request.params
const body = request.body ?? {}
const existing = await prisma.product.findUnique({ where: { id } })
if (!existing) {
reply.code(404).send({ error: 'Товар не найден' })
return
}
const data = {}
if (body.title !== undefined) data.title = String(body.title).trim()
if (body.slug !== undefined) {
const s = String(body.slug).trim()
if (s && s !== existing.slug) {
const clash = await prisma.product.findFirst({
where: { slug: s, NOT: { id } },
})
if (clash) {
reply.code(409).send({ error: 'Такой slug уже занят' })
return
}
data.slug = s
}
}
if (body.shortDescription !== undefined) {
data.shortDescription = body.shortDescription ? String(body.shortDescription) : null
}
if (body.description !== undefined) {
data.description = body.description ? String(body.description) : null
}
if (body.quantity !== undefined) {
const v = body.quantity
if (v === null || v === '') {
data.quantity = null
} else {
const n = Number(v)
if (!Number.isFinite(n) || n < 0) {
reply.code(400).send({ error: 'Некорректное количество (quantity ≥ 0)' })
return
}
data.quantity = Math.floor(n)
}
}
if (body.materials !== undefined) {
data.materials = JSON.stringify(parseMaterialsInput(body.materials))
}
if (body.priceCents !== undefined) {
const p = Number(body.priceCents)
if (!Number.isFinite(p) || p < 0) {
reply.code(400).send({ error: 'Некорректная цена' })
return
}
data.priceCents = Math.round(p)
}
if (body.imageUrl !== undefined) {
data.imageUrl = body.imageUrl ? String(body.imageUrl) : null
}
if (body.published !== undefined) data.published = Boolean(body.published)
if (body.categoryId !== undefined) data.categoryId = String(body.categoryId)
if (body.inStock !== undefined) data.inStock = Boolean(body.inStock)
if (body.leadTimeDays !== undefined) {
const v = body.leadTimeDays
const n = v === null || v === '' ? null : Number(v)
if (n !== null && (!Number.isFinite(n) || n <= 0)) {
reply.code(400).send({ error: 'Срок исполнения должен быть числом дней > 0' })
return
}
data.leadTimeDays = n === null ? null : Math.round(n)
}
const nextInStock = data.inStock ?? existing.inStock
const nextLead = data.leadTimeDays ?? existing.leadTimeDays
if (!nextInStock && (!Number.isFinite(nextLead) || nextLead === null || nextLead <= 0)) {
reply.code(400).send({ error: 'Если "под заказ", укажите срок исполнения (дней) > 0' })
return
}
if (nextInStock && data.leadTimeDays !== undefined) {
data.leadTimeDays = null
}
const imagesUpdate =
body.imageUrls !== undefined
? {
deleteMany: {},
create: Array.isArray(body.imageUrls)
? body.imageUrls
.map((u) => String(u || '').trim())
.filter(Boolean)
.slice(0, 10)
.map((u, idx) => ({ url: u, sort: idx }))
: [],
}
: undefined
const product = await prisma.product.update({
where: { id },
data: {
...data,
images: imagesUpdate,
},
include: { category: true, images: { orderBy: { sort: 'asc' } } },
})
return mapProductForApi(product)
},
)
fastify.delete(
'/api/admin/products/:id',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const { id } = request.params
try {
await prisma.product.delete({ where: { id } })
reply.code(204).send()
} catch {
reply.code(404).send({ error: 'Товар не найден' })
}
},
)
fastify.post(
'/api/admin/categories',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const body = request.body ?? {}
const name = String(body.name ?? '').trim()
if (!name) {
reply.code(400).send({ error: 'Укажите название категории' })
return
}
const slug = String(body.slug ?? '').trim() || slugify(name) || `cat-${Date.now()}`
const sort =
body.sort !== undefined && body.sort !== null && body.sort !== ''
? Number(body.sort)
: undefined
const exists = await prisma.category.findUnique({ where: { slug } })
if (exists) {
reply.code(409).send({ error: 'Такой slug уже занят' })
return
}
const category = await prisma.category.create({
data: {
name,
slug,
sort: Number.isFinite(sort) ? Math.round(sort) : 0,
},
})
reply.code(201).send(category)
},
)
// ---- Админ: заказы ----
function canTransition(from, to) {
if (from === to) return true
const allowed = {
DRAFT: new Set(['PENDING_PAYMENT', 'CANCELLED']),
PENDING_PAYMENT: new Set(['PAID', 'CANCELLED']),
PAID: new Set(['IN_PROGRESS', 'CANCELLED']),
IN_PROGRESS: new Set(['SHIPPED', 'CANCELLED']),
SHIPPED: new Set(['DONE']),
DONE: new Set([]),
CANCELLED: new Set([]),
}
return Boolean(allowed[from]?.has(to))
}
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 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) : 20
if (pageSize > 100) return reply.code(400).send({ error: 'pageSize должен быть ≤ 100' })
const where = {}
if (status) where.status = status
if (q) {
where.OR = [
{ id: { contains: q } },
{ user: { email: { contains: q } } },
]
}
const total = await prisma.order.count({ where })
const items = await prisma.order.findMany({
where,
include: { user: { select: { id: true, email: true } }, items: true },
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
})
return {
items: items.map((o) => ({
id: o.id,
status: o.status,
totalCents: o.totalCents,
currency: o.currency,
createdAt: o.createdAt,
updatedAt: o.updatedAt,
user: o.user,
itemsCount: o.items.reduce((s, i) => s + i.qty, 0),
})),
total,
page,
pageSize,
}
},
)
fastify.get(
'/api/admin/orders/:id',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const { id } = request.params
const order = await prisma.order.findUnique({
where: { id },
include: {
user: { select: { id: true, email: true, name: true, phone: true } },
items: true,
messages: { orderBy: { createdAt: 'asc' } },
},
})
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
return { item: order }
},
)
fastify.patch(
'/api/admin/orders/:id/status',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const { id } = request.params
const next = String(request.body?.status || '').trim()
if (!next) return reply.code(400).send({ error: 'status обязателен' })
const existing = await prisma.order.findUnique({ where: { id } })
if (!existing) return reply.code(404).send({ error: 'Заказ не найден' })
if (!canTransition(existing.status, next)) {
return reply.code(409).send({ error: `Нельзя сменить статус ${existing.status}${next}` })
}
const updated = await prisma.order.update({ where: { id }, data: { status: next } })
return { item: updated }
},
)
fastify.post(
'/api/admin/orders/:id/messages',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const { id } = request.params
const text = String(request.body?.text || '').trim()
if (!text) return reply.code(400).send({ error: 'Сообщение пустое' })
if (text.length > 2000) return reply.code(400).send({ error: 'Сообщение слишком длинное' })
const order = await prisma.order.findUnique({ where: { id } })
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
const msg = await prisma.orderMessage.create({ data: { orderId: id, authorType: 'admin', text } })
return reply.code(201).send({ item: msg })
},
)
// ---- Админ: отзывы (модерация) ----
fastify.get(
'/api/admin/reviews',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const status = typeof request.query?.status === 'string' ? request.query.status.trim() : 'pending'
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) : 20
if (pageSize > 100) return reply.code(400).send({ error: 'pageSize должен быть ≤ 100' })
const where = status ? { status } : {}
const total = await prisma.review.count({ where })
const items = await prisma.review.findMany({
where,
include: {
user: { select: { id: true, email: true, name: true } },
product: { select: { id: true, title: true } },
},
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
})
return { items, total, page, pageSize }
},
)
fastify.patch(
'/api/admin/reviews/:id',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const { id } = request.params
const action = String(request.body?.action || '').trim()
if (action !== 'approve' && action !== 'reject') {
return reply.code(400).send({ error: 'action должен быть approve или reject' })
}
const existing = await prisma.review.findUnique({ where: { id } })
if (!existing) return reply.code(404).send({ error: 'Отзыв не найден' })
const updated = await prisma.review.update({
where: { id },
data: {
status: action === 'approve' ? 'approved' : 'rejected',
moderatedAt: new Date(),
},
})
return { item: updated }
},
)
// ---- Админ: пользователи ----
fastify.get(
'/api/admin/users',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const qRaw = request.query?.q
const q = typeof qRaw === 'string' ? qRaw.trim() : ''
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) : 20
if (pageSize > 100) {
reply.code(400).send({ error: 'pageSize должен быть ≤ 100' })
return
}
const where = q
? {
OR: [
{ email: { contains: q } },
{ name: { contains: q } },
],
}
: undefined
const total = await prisma.user.count({ where })
const users = await prisma.user.findMany({
where,
select: {
id: true,
email: true,
name: true,
passwordHash: true,
createdAt: true,
updatedAt: true,
},
orderBy: { updatedAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
})
const items = users.map((u) => ({
id: u.id,
email: u.email,
name: u.name,
hasPassword: Boolean(u.passwordHash),
createdAt: u.createdAt,
updatedAt: u.updatedAt,
}))
return { items, total, page, pageSize }
},
)
fastify.post(
'/api/admin/users',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const body = request.body ?? {}
const email = normalizeEmail(body.email)
if (!email || !email.includes('@')) {
reply.code(400).send({ error: 'Некорректная почта' })
return
}
const nameRaw = body.name
const name = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim()
if (name !== null && name.length > 40) {
reply.code(400).send({ error: 'Имя/ник максимум 40 символов' })
return
}
const password = body.password ? String(body.password) : ''
if (password && password.length < 8) {
reply.code(400).send({ error: 'Пароль минимум 8 символов' })
return
}
const exists = await prisma.user.findUnique({ where: { email } })
if (exists) {
reply.code(409).send({ error: 'Почта уже занята' })
return
}
const passwordHash = password ? await hashPassword(password) : null
const user = await prisma.user.create({
data: {
email,
name: name && name.length ? name : null,
passwordHash: passwordHash ?? undefined,
},
})
reply.code(201).send({
id: user.id,
email: user.email,
name: user.name,
hasPassword: Boolean(user.passwordHash),
createdAt: user.createdAt,
updatedAt: user.updatedAt,
})
},
)
fastify.patch(
'/api/admin/users/:id',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const { id } = request.params
const body = request.body ?? {}
const existing = await prisma.user.findUnique({ where: { id } })
if (!existing) {
reply.code(404).send({ error: 'Пользователь не найден' })
return
}
const data = {}
if (body.email !== undefined) {
const email = normalizeEmail(body.email)
if (!email || !email.includes('@')) {
reply.code(400).send({ error: 'Некорректная почта' })
return
}
if (email !== existing.email) {
const clash = await prisma.user.findUnique({ where: { email } })
if (clash) {
reply.code(409).send({ error: 'Почта уже занята' })
return
}
data.email = email
}
}
if (body.name !== undefined) {
const nameRaw = body.name
const name = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim()
if (name !== null && name.length > 40) {
reply.code(400).send({ error: 'Имя/ник максимум 40 символов' })
return
}
data.name = name && name.length ? name : null
}
if (body.password !== undefined) {
const password = body.password ? String(body.password) : ''
if (password) {
if (password.length < 8) {
reply.code(400).send({ error: 'Пароль минимум 8 символов' })
return
}
data.passwordHash = await hashPassword(password)
}
}
const user = await prisma.user.update({ where: { id }, data })
return {
id: user.id,
email: user.email,
name: user.name,
hasPassword: Boolean(user.passwordHash),
createdAt: user.createdAt,
updatedAt: user.updatedAt,
}
},
)
fastify.delete(
'/api/admin/users/:id',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const { id } = request.params
try {
await prisma.user.delete({ where: { id } })
reply.code(204).send()
} catch {
reply.code(404).send({ error: 'Пользователь не найден' })
}
},
)
await registerAdminCategoryRoutes(fastify, { slugify })
await registerAdminOrderRoutes(fastify)
await registerAdminReviewRoutes(fastify)
await registerAdminUserRoutes(fastify)
}
+52
View File
@@ -0,0 +1,52 @@
import path from 'node:path'
export function slugify(input) {
return String(input || '')
.toLowerCase()
.trim()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-а-яё]/gi, '')
}
export function safeExtFromFilename(filename) {
const ext = path.extname(String(filename || '')).toLowerCase()
const allowed = new Set(['.png', '.jpg', '.jpeg', '.webp'])
return allowed.has(ext) ? ext : null
}
export function parseMaterialsInput(input) {
if (Array.isArray(input)) {
return input
.map((x) => String(x || '').trim())
.filter(Boolean)
.slice(0, 30)
}
if (typeof input === 'string') {
const s = input.trim()
if (!s) return []
return s
.split(',')
.map((x) => x.trim())
.filter(Boolean)
.slice(0, 30)
}
return []
}
export function materialsFromDb(materials) {
if (Array.isArray(materials)) return materials
try {
const v = JSON.parse(String(materials || '[]'))
return Array.isArray(v) ? v.map((x) => String(x || '').trim()).filter(Boolean) : []
} catch {
return []
}
}
export function mapProductForApi(p) {
return {
...p,
materials: materialsFromDb(p.materials),
}
}
+32
View File
@@ -0,0 +1,32 @@
import { prisma } from '../../lib/prisma.js'
export async function registerAdminCategoryRoutes(fastify, { slugify } = {}) {
fastify.post(
'/api/admin/categories',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const body = request.body ?? {}
const name = String(body.name ?? '').trim()
if (!name) {
reply.code(400).send({ error: 'Укажите название категории' })
return
}
const slug = String(body.slug ?? '').trim() || slugify(name) || `cat-${Date.now()}`
const sort = body.sort !== undefined && body.sort !== null && body.sort !== '' ? Number(body.sort) : undefined
const exists = await prisma.category.findUnique({ where: { slug } })
if (exists) {
reply.code(409).send({ error: 'Такой slug уже занят' })
return
}
const category = await prisma.category.create({
data: {
name,
slug,
sort: Number.isFinite(sort) ? Math.round(sort) : 0,
},
})
reply.code(201).send(category)
},
)
}
+108
View File
@@ -0,0 +1,108 @@
import { prisma } from '../../lib/prisma.js'
import { canTransitionOrderStatus } from '../../lib/order-status.js'
export async function registerAdminOrderRoutes(fastify) {
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 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) : 20
if (pageSize > 100) return reply.code(400).send({ error: 'pageSize должен быть ≤ 100' })
const where = {}
if (status) where.status = status
if (q) {
where.OR = [{ id: { contains: q } }, { user: { email: { contains: q } } }]
}
const total = await prisma.order.count({ where })
const items = await prisma.order.findMany({
where,
include: { user: { select: { id: true, email: true } }, items: true },
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
})
return {
items: items.map((o) => ({
id: o.id,
status: o.status,
totalCents: o.totalCents,
currency: o.currency,
createdAt: o.createdAt,
updatedAt: o.updatedAt,
user: o.user,
itemsCount: o.items.reduce((s, i) => s + i.qty, 0),
})),
total,
page,
pageSize,
}
},
)
fastify.get(
'/api/admin/orders/:id',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const { id } = request.params
const order = await prisma.order.findUnique({
where: { id },
include: {
user: { select: { id: true, email: true, name: true, phone: true } },
items: true,
messages: { orderBy: { createdAt: 'asc' } },
},
})
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
return { item: order }
},
)
fastify.patch(
'/api/admin/orders/:id/status',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const { id } = request.params
const next = String(request.body?.status || '').trim()
if (!next) return reply.code(400).send({ error: 'status обязателен' })
const existing = await prisma.order.findUnique({ where: { id } })
if (!existing) return reply.code(404).send({ error: 'Заказ не найден' })
if (!canTransitionOrderStatus(existing.status, next)) {
return reply.code(409).send({ error: `Нельзя сменить статус ${existing.status}${next}` })
}
const updated = await prisma.order.update({ where: { id }, data: { status: next } })
return { item: updated }
},
)
fastify.post(
'/api/admin/orders/:id/messages',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const { id } = request.params
const text = String(request.body?.text || '').trim()
if (!text) return reply.code(400).send({ error: 'Сообщение пустое' })
if (text.length > 2000) return reply.code(400).send({ error: 'Сообщение слишком длинное' })
const order = await prisma.order.findUnique({ where: { id } })
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
const msg = await prisma.orderMessage.create({ data: { orderId: id, authorType: 'admin', text } })
return reply.code(201).send({ item: msg })
},
)
}
+250
View File
@@ -0,0 +1,250 @@
import crypto from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
import { prisma } from '../../lib/prisma.js'
export async function registerAdminProductRoutes(
fastify,
{ slugify, safeExtFromFilename, parseMaterialsInput, mapProductForApi } = {},
) {
fastify.get(
'/api/admin/products',
{ preHandler: [fastify.verifyAdmin] },
async () => {
const items = await prisma.product.findMany({
include: { category: true, images: { orderBy: { sort: 'asc' } } },
orderBy: { updatedAt: 'desc' },
})
return items.map(mapProductForApi)
},
)
fastify.post(
'/api/admin/uploads',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
if (!request.isMultipart()) {
reply.code(400).send({ error: 'Ожидается multipart/form-data' })
return
}
const uploadsDir = path.join(process.cwd(), 'uploads')
await fs.promises.mkdir(uploadsDir, { recursive: true })
const urls = []
const parts = request.parts()
for await (const part of parts) {
if (part.type !== 'file') continue
const ext = safeExtFromFilename(part.filename)
if (!ext) {
reply.code(400).send({ error: 'Разрешены только файлы: png, jpg, jpeg, webp' })
return
}
const id = crypto.randomUUID()
const fileName = `${id}${ext}`
const fullPath = path.join(uploadsDir, fileName)
await fs.promises.writeFile(fullPath, await part.toBuffer())
urls.push(`/uploads/${fileName}`)
}
return { urls }
},
)
fastify.post(
'/api/admin/products',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const body = request.body ?? {}
const title = String(body.title ?? '').trim()
if (!title) {
reply.code(400).send({ error: 'Укажите название' })
return
}
const slug = String(body.slug ?? '').trim() || slugify(title) || `item-${Date.now()}`
const categoryId = String(body.categoryId ?? '').trim()
if (!categoryId) {
reply.code(400).send({ error: 'Укажите категорию' })
return
}
const priceCents = Number(body.priceCents)
if (!Number.isFinite(priceCents) || priceCents < 0) {
reply.code(400).send({ error: 'Некорректная цена (priceCents ≥ 0)' })
return
}
const inStock = body.inStock === undefined || body.inStock === null ? true : Boolean(body.inStock)
const leadTimeDaysRaw = body.leadTimeDays
const leadTimeDays =
leadTimeDaysRaw === undefined || leadTimeDaysRaw === null || leadTimeDaysRaw === '' ? null : Number(leadTimeDaysRaw)
if (!inStock) {
if (!Number.isFinite(leadTimeDays) || leadTimeDays <= 0) {
reply.code(400).send({ error: 'Если "под заказ", укажите срок исполнения (дней) > 0' })
return
}
}
const exists = await prisma.product.findUnique({ where: { slug } })
if (exists) {
reply.code(409).send({ error: 'Такой slug уже занят' })
return
}
let quantity = null
if (!(body.quantity === undefined || body.quantity === null || body.quantity === '')) {
const n = Number(body.quantity)
if (!Number.isFinite(n) || n < 0) {
reply.code(400).send({ error: 'Некорректное количество (quantity ≥ 0)' })
return
}
quantity = Math.floor(n)
}
const product = await prisma.product.create({
data: {
title,
slug,
shortDescription: body.shortDescription ? String(body.shortDescription) : null,
description: body.description ? String(body.description) : null,
quantity,
materials: JSON.stringify(parseMaterialsInput(body.materials)),
priceCents: Math.round(priceCents),
imageUrl: body.imageUrl ? String(body.imageUrl) : null,
published: Boolean(body.published),
inStock,
leadTimeDays: inStock ? null : Math.round(leadTimeDays),
categoryId,
images: Array.isArray(body.imageUrls)
? {
create: body.imageUrls
.map((u) => String(u || '').trim())
.filter(Boolean)
.slice(0, 10)
.map((u, idx) => ({ url: u, sort: idx })),
}
: undefined,
},
include: { category: true, images: { orderBy: { sort: 'asc' } } },
})
reply.code(201).send(mapProductForApi(product))
},
)
fastify.patch(
'/api/admin/products/:id',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const { id } = request.params
const body = request.body ?? {}
const existing = await prisma.product.findUnique({ where: { id } })
if (!existing) {
reply.code(404).send({ error: 'Товар не найден' })
return
}
const data = {}
if (body.title !== undefined) data.title = String(body.title).trim()
if (body.slug !== undefined) {
const s = String(body.slug).trim()
if (s && s !== existing.slug) {
const clash = await prisma.product.findFirst({ where: { slug: s, NOT: { id } } })
if (clash) {
reply.code(409).send({ error: 'Такой slug уже занят' })
return
}
data.slug = s
}
}
if (body.shortDescription !== undefined) {
data.shortDescription = body.shortDescription ? String(body.shortDescription) : null
}
if (body.description !== undefined) {
data.description = body.description ? String(body.description) : null
}
if (body.quantity !== undefined) {
const v = body.quantity
if (v === null || v === '') {
data.quantity = null
} else {
const n = Number(v)
if (!Number.isFinite(n) || n < 0) {
reply.code(400).send({ error: 'Некорректное количество (quantity ≥ 0)' })
return
}
data.quantity = Math.floor(n)
}
}
if (body.materials !== undefined) {
data.materials = JSON.stringify(parseMaterialsInput(body.materials))
}
if (body.priceCents !== undefined) {
const p = Number(body.priceCents)
if (!Number.isFinite(p) || p < 0) {
reply.code(400).send({ error: 'Некорректная цена' })
return
}
data.priceCents = Math.round(p)
}
if (body.imageUrl !== undefined) {
data.imageUrl = body.imageUrl ? String(body.imageUrl) : null
}
if (body.published !== undefined) data.published = Boolean(body.published)
if (body.categoryId !== undefined) data.categoryId = String(body.categoryId)
if (body.inStock !== undefined) data.inStock = Boolean(body.inStock)
if (body.leadTimeDays !== undefined) {
const v = body.leadTimeDays
const n = v === null || v === '' ? null : Number(v)
if (n !== null && (!Number.isFinite(n) || n <= 0)) {
reply.code(400).send({ error: 'Срок исполнения должен быть числом дней > 0' })
return
}
data.leadTimeDays = n === null ? null : Math.round(n)
}
const nextInStock = data.inStock ?? existing.inStock
const nextLead = data.leadTimeDays ?? existing.leadTimeDays
if (!nextInStock && (!Number.isFinite(nextLead) || nextLead === null || nextLead <= 0)) {
reply.code(400).send({ error: 'Если "под заказ", укажите срок исполнения (дней) > 0' })
return
}
if (nextInStock && data.leadTimeDays !== undefined) {
data.leadTimeDays = null
}
const imagesUpdate =
body.imageUrls !== undefined
? {
deleteMany: {},
create: Array.isArray(body.imageUrls)
? body.imageUrls
.map((u) => String(u || '').trim())
.filter(Boolean)
.slice(0, 10)
.map((u, idx) => ({ url: u, sort: idx }))
: [],
}
: undefined
const product = await prisma.product.update({
where: { id },
data: { ...data, images: imagesUpdate },
include: { category: true, images: { orderBy: { sort: 'asc' } } },
})
return mapProductForApi(product)
},
)
fastify.delete(
'/api/admin/products/:id',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const { id } = request.params
try {
await prisma.product.delete({ where: { id } })
reply.code(204).send()
} catch {
reply.code(404).send({ error: 'Товар не найден' })
}
},
)
}
+60
View File
@@ -0,0 +1,60 @@
import { prisma } from '../../lib/prisma.js'
export async function registerAdminReviewRoutes(fastify) {
fastify.get(
'/api/admin/reviews',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const status = typeof request.query?.status === 'string' ? request.query.status.trim() : 'pending'
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) : 20
if (pageSize > 100) return reply.code(400).send({ error: 'pageSize должен быть ≤ 100' })
const where = status ? { status } : {}
const total = await prisma.review.count({ where })
const items = await prisma.review.findMany({
where,
include: {
user: { select: { id: true, email: true, name: true } },
product: { select: { id: true, title: true } },
},
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
})
return { items, total, page, pageSize }
},
)
fastify.patch(
'/api/admin/reviews/:id',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const { id } = request.params
const action = String(request.body?.action || '').trim()
if (action !== 'approve' && action !== 'reject') {
return reply.code(400).send({ error: 'action должен быть approve или reject' })
}
const existing = await prisma.review.findUnique({ where: { id } })
if (!existing) return reply.code(404).send({ error: 'Отзыв не найден' })
const updated = await prisma.review.update({
where: { id },
data: {
status: action === 'approve' ? 'approved' : 'rejected',
moderatedAt: new Date(),
},
})
return { item: updated }
},
)
}
+189
View File
@@ -0,0 +1,189 @@
import { prisma } from '../../lib/prisma.js'
import { hashPassword, normalizeEmail } from '../../lib/auth.js'
export async function registerAdminUserRoutes(fastify) {
fastify.get(
'/api/admin/users',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const qRaw = request.query?.q
const q = typeof qRaw === 'string' ? qRaw.trim() : ''
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) : 20
if (pageSize > 100) {
reply.code(400).send({ error: 'pageSize должен быть ≤ 100' })
return
}
const where = q
? {
OR: [{ email: { contains: q } }, { name: { contains: q } }],
}
: undefined
const total = await prisma.user.count({ where })
const users = await prisma.user.findMany({
where,
select: {
id: true,
email: true,
name: true,
passwordHash: true,
createdAt: true,
updatedAt: true,
},
orderBy: { updatedAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
})
const items = users.map((u) => ({
id: u.id,
email: u.email,
name: u.name,
hasPassword: Boolean(u.passwordHash),
createdAt: u.createdAt,
updatedAt: u.updatedAt,
}))
return { items, total, page, pageSize }
},
)
fastify.post(
'/api/admin/users',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const body = request.body ?? {}
const email = normalizeEmail(body.email)
if (!email || !email.includes('@')) {
reply.code(400).send({ error: 'Некорректная почта' })
return
}
const nameRaw = body.name
const name = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim()
if (name !== null && name.length > 40) {
reply.code(400).send({ error: 'Имя/ник максимум 40 символов' })
return
}
const password = body.password ? String(body.password) : ''
if (password && password.length < 8) {
reply.code(400).send({ error: 'Пароль минимум 8 символов' })
return
}
const exists = await prisma.user.findUnique({ where: { email } })
if (exists) {
reply.code(409).send({ error: 'Почта уже занята' })
return
}
const passwordHash = password ? await hashPassword(password) : null
const user = await prisma.user.create({
data: {
email,
name: name && name.length ? name : null,
passwordHash: passwordHash ?? undefined,
},
})
reply.code(201).send({
id: user.id,
email: user.email,
name: user.name,
hasPassword: Boolean(user.passwordHash),
createdAt: user.createdAt,
updatedAt: user.updatedAt,
})
},
)
fastify.patch(
'/api/admin/users/:id',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const { id } = request.params
const body = request.body ?? {}
const existing = await prisma.user.findUnique({ where: { id } })
if (!existing) {
reply.code(404).send({ error: 'Пользователь не найден' })
return
}
const data = {}
if (body.email !== undefined) {
const email = normalizeEmail(body.email)
if (!email || !email.includes('@')) {
reply.code(400).send({ error: 'Некорректная почта' })
return
}
if (email !== existing.email) {
const clash = await prisma.user.findUnique({ where: { email } })
if (clash) {
reply.code(409).send({ error: 'Почта уже занята' })
return
}
data.email = email
}
}
if (body.name !== undefined) {
const nameRaw = body.name
const name = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim()
if (name !== null && name.length > 40) {
reply.code(400).send({ error: 'Имя/ник максимум 40 символов' })
return
}
data.name = name && name.length ? name : null
}
if (body.password !== undefined) {
const password = body.password ? String(body.password) : ''
if (password) {
if (password.length < 8) {
reply.code(400).send({ error: 'Пароль минимум 8 символов' })
return
}
data.passwordHash = await hashPassword(password)
}
}
const user = await prisma.user.update({ where: { id }, data })
return {
id: user.id,
email: user.email,
name: user.name,
hasPassword: Boolean(user.passwordHash),
createdAt: user.createdAt,
updatedAt: user.updatedAt,
}
},
)
fastify.delete(
'/api/admin/users/:id',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const { id } = request.params
try {
await prisma.user.delete({ where: { id } })
reply.code(204).send()
} catch {
reply.code(404).send({ error: 'Пользователь не найден' })
}
},
)
}
+83
View File
@@ -0,0 +1,83 @@
import { prisma } from '../../lib/prisma.js'
export async function registerPublicCatalogRoutes(fastify, { mapProductForApi } = {}) {
fastify.get('/api/categories', async () => {
return prisma.category.findMany({ orderBy: { sort: 'asc' } })
})
fastify.get('/api/products', async (request, reply) => {
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,
})
return { items: items.map(mapProductForApi), 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
}
return mapProductForApi(product)
})
}
+67
View File
@@ -0,0 +1,67 @@
import { prisma } from '../../lib/prisma.js'
export async function registerPublicReviewRoutes(fastify) {
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 items = await prisma.review.findMany({
where,
include: { user: { select: { id: true, name: true, email: true } } },
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
})
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: 'Отзыв слишком длинный' })
try {
const created = await prisma.review.create({
data: {
productId,
userId,
rating: Math.floor(rating),
text: text && text.length ? text : null,
status: 'pending',
},
})
return reply.code(201).send({ item: created })
} catch {
return reply.code(409).send({ error: 'Вы уже оставляли отзыв на этот товар' })
}
},
)
}