617 lines
20 KiB
JavaScript
617 lines
20 KiB
JavaScript
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),
|
||
}
|
||
}
|
||
|
||
export async function registerApiRoutes(fastify) {
|
||
fastify.get('/api/categories', async () => {
|
||
return prisma.category.findMany({ orderBy: { sort: 'asc' } })
|
||
})
|
||
|
||
fastify.get('/api/products', async (request) => {
|
||
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/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)
|
||
},
|
||
)
|
||
|
||
// ---- Админ: пользователи ----
|
||
|
||
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: 'Пользователь не найден' })
|
||
}
|
||
},
|
||
)
|
||
}
|