Files
shop-server/server/src/routes/api.js
T
@kirill.komarov f6b6959268 base commit
2026-04-29 17:32:21 +05:00

617 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: 'Пользователь не найден' })
}
},
)
}