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: 'Пользователь не найден' }) } }, ) }