base commit
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
mapProductForApi,
|
||||
parseMaterialsInput,
|
||||
safeExtFromFilename,
|
||||
slugify,
|
||||
} from './api/_product-helpers.js'
|
||||
import { registerAdminCategoryRoutes } from './api/admin-categories.js'
|
||||
@@ -9,16 +8,17 @@ 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 { registerInfoPageRoutes } from './api/info-page.js'
|
||||
import { registerPublicCatalogRoutes } from './api/public-catalog.js'
|
||||
import { registerPublicReviewRoutes } from './api/public-reviews.js'
|
||||
|
||||
export async function registerApiRoutes(fastify) {
|
||||
await registerPublicCatalogRoutes(fastify, { mapProductForApi })
|
||||
await registerPublicReviewRoutes(fastify)
|
||||
await registerInfoPageRoutes(fastify)
|
||||
|
||||
await registerAdminProductRoutes(fastify, {
|
||||
slugify,
|
||||
safeExtFromFilename,
|
||||
parseMaterialsInput,
|
||||
mapProductForApi,
|
||||
})
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import crypto from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { prisma } from '../../lib/prisma.js'
|
||||
import { persistMultipartImages } from '../../lib/upload-images.js'
|
||||
|
||||
export async function registerAdminProductRoutes(
|
||||
fastify,
|
||||
{ slugify, safeExtFromFilename, parseMaterialsInput, mapProductForApi } = {},
|
||||
{ slugify, parseMaterialsInput, mapProductForApi } = {},
|
||||
) {
|
||||
fastify.get(
|
||||
'/api/admin/products',
|
||||
@@ -23,32 +21,17 @@ export async function registerAdminProductRoutes(
|
||||
'/api/admin/uploads',
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
async (request, reply) => {
|
||||
if (!request.isMultipart()) {
|
||||
reply.code(400).send({ error: 'Ожидается multipart/form-data' })
|
||||
return
|
||||
try {
|
||||
const urls = await persistMultipartImages(request, { maxFiles: 10 })
|
||||
return { urls }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Не удалось загрузить файлы'
|
||||
const statusCode =
|
||||
error && typeof error === 'object' && 'statusCode' in error && Number.isInteger(error.statusCode)
|
||||
? Number(error.statusCode)
|
||||
: 400
|
||||
return reply.code(statusCode).send({ error: message })
|
||||
}
|
||||
|
||||
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 }
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { prisma } from '../../lib/prisma.js'
|
||||
import { hashPassword, normalizeEmail } from '../../lib/auth.js'
|
||||
import { normalizeEmail } from '../../lib/auth.js'
|
||||
|
||||
export async function registerAdminUserRoutes(fastify) {
|
||||
fastify.get(
|
||||
@@ -36,7 +36,6 @@ export async function registerAdminUserRoutes(fastify) {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
passwordHash: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
@@ -48,7 +47,6 @@ export async function registerAdminUserRoutes(fastify) {
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
name: u.name,
|
||||
hasPassword: Boolean(u.passwordHash),
|
||||
createdAt: u.createdAt,
|
||||
updatedAt: u.updatedAt,
|
||||
}))
|
||||
@@ -76,24 +74,16 @@ export async function registerAdminUserRoutes(fastify) {
|
||||
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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -101,7 +91,6 @@ export async function registerAdminUserRoutes(fastify) {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
hasPassword: Boolean(user.passwordHash),
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
})
|
||||
@@ -149,23 +138,11 @@ export async function registerAdminUserRoutes(fastify) {
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { prisma } from '../../lib/prisma.js'
|
||||
|
||||
function validateBlockPayload(body, reply) {
|
||||
const key = String(body?.key || '').trim()
|
||||
const title = String(body?.title || '').trim()
|
||||
const content = String(body?.body || '').trim()
|
||||
const sort = Number(body?.sort ?? 0)
|
||||
const published = body?.published === undefined ? true : Boolean(body.published)
|
||||
|
||||
if (!key) return reply.code(400).send({ error: 'key обязателен' })
|
||||
if (!/^[a-z0-9_-]{2,60}$/i.test(key)) {
|
||||
return reply.code(400).send({ error: 'key должен быть 2-60 символов: буквы, цифры, "_" или "-"' })
|
||||
}
|
||||
if (!title) return reply.code(400).send({ error: 'title обязателен' })
|
||||
if (title.length > 120) return reply.code(400).send({ error: 'title максимум 120 символов' })
|
||||
if (!content) return reply.code(400).send({ error: 'body обязателен' })
|
||||
if (content.length > 5000) return reply.code(400).send({ error: 'body максимум 5000 символов' })
|
||||
if (!Number.isFinite(sort) || Math.abs(sort) > 10_000) return reply.code(400).send({ error: 'Некорректный sort' })
|
||||
|
||||
return { key, title, body: content, sort: Math.trunc(sort), published }
|
||||
}
|
||||
|
||||
export async function registerInfoPageRoutes(fastify) {
|
||||
fastify.get('/api/info-page/blocks', async () => {
|
||||
const items = await prisma.infoPageBlock.findMany({
|
||||
where: { published: true },
|
||||
orderBy: [{ sort: 'asc' }, { createdAt: 'asc' }],
|
||||
})
|
||||
return { items }
|
||||
})
|
||||
|
||||
fastify.get(
|
||||
'/api/admin/info-page/blocks',
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
async () => {
|
||||
const items = await prisma.infoPageBlock.findMany({ orderBy: [{ sort: 'asc' }, { createdAt: 'asc' }] })
|
||||
return { items }
|
||||
},
|
||||
)
|
||||
|
||||
fastify.post(
|
||||
'/api/admin/info-page/blocks',
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
async (request, reply) => {
|
||||
const validated = validateBlockPayload(request.body, reply)
|
||||
if (!validated) return
|
||||
|
||||
try {
|
||||
const item = await prisma.infoPageBlock.create({ data: validated })
|
||||
return reply.code(201).send({ item })
|
||||
} catch {
|
||||
return reply.code(409).send({ error: 'Блок с таким key уже существует' })
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
fastify.patch(
|
||||
'/api/admin/info-page/blocks/:id',
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
async (request, reply) => {
|
||||
const { id } = request.params
|
||||
const existing = await prisma.infoPageBlock.findUnique({ where: { id } })
|
||||
if (!existing) return reply.code(404).send({ error: 'Блок не найден' })
|
||||
|
||||
const body = request.body ?? {}
|
||||
const data = {}
|
||||
if (body.key !== undefined) {
|
||||
const key = String(body.key || '').trim()
|
||||
if (!key) return reply.code(400).send({ error: 'key обязателен' })
|
||||
if (!/^[a-z0-9_-]{2,60}$/i.test(key)) {
|
||||
return reply.code(400).send({ error: 'key должен быть 2-60 символов: буквы, цифры, "_" или "-"' })
|
||||
}
|
||||
data.key = key
|
||||
}
|
||||
if (body.title !== undefined) {
|
||||
const title = String(body.title || '').trim()
|
||||
if (!title) return reply.code(400).send({ error: 'title обязателен' })
|
||||
if (title.length > 120) return reply.code(400).send({ error: 'title максимум 120 символов' })
|
||||
data.title = title
|
||||
}
|
||||
if (body.body !== undefined) {
|
||||
const content = String(body.body || '').trim()
|
||||
if (!content) return reply.code(400).send({ error: 'body обязателен' })
|
||||
if (content.length > 5000) return reply.code(400).send({ error: 'body максимум 5000 символов' })
|
||||
data.body = content
|
||||
}
|
||||
if (body.sort !== undefined) {
|
||||
const sort = Number(body.sort)
|
||||
if (!Number.isFinite(sort) || Math.abs(sort) > 10_000) return reply.code(400).send({ error: 'Некорректный sort' })
|
||||
data.sort = Math.trunc(sort)
|
||||
}
|
||||
if (body.published !== undefined) {
|
||||
data.published = Boolean(body.published)
|
||||
}
|
||||
|
||||
try {
|
||||
const item = await prisma.infoPageBlock.update({ where: { id }, data })
|
||||
return { item }
|
||||
} catch {
|
||||
return reply.code(409).send({ error: 'Блок с таким key уже существует' })
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
fastify.delete(
|
||||
'/api/admin/info-page/blocks/:id',
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
async (request, reply) => {
|
||||
const { id } = request.params
|
||||
try {
|
||||
await prisma.infoPageBlock.delete({ where: { id } })
|
||||
return reply.code(204).send()
|
||||
} catch {
|
||||
return reply.code(404).send({ error: 'Блок не найден' })
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,27 @@
|
||||
import { publicReviewAuthorDisplay } from '../../lib/review-display.js'
|
||||
import { prisma } from '../../lib/prisma.js'
|
||||
import { persistMultipartImages } from '../../lib/upload-images.js'
|
||||
|
||||
export async function registerPublicReviewRoutes(fastify) {
|
||||
fastify.post(
|
||||
'/api/reviews/upload-image',
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const urls = await persistMultipartImages(request, { maxFiles: 1 })
|
||||
if (urls.length !== 1) return reply.code(400).send({ error: 'Нужно прикрепить 1 изображение' })
|
||||
return { url: urls[0] }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Не удалось загрузить изображение'
|
||||
const statusCode =
|
||||
error && typeof error === 'object' && 'statusCode' in error && Number.isInteger(error.statusCode)
|
||||
? Number(error.statusCode)
|
||||
: 400
|
||||
return reply.code(statusCode).send({ error: message })
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
fastify.get('/api/reviews/latest', async (request, reply) => {
|
||||
const limitRaw = request.query?.limit
|
||||
const limitParsed = typeof limitRaw === 'string' ? Number(limitRaw) : Number(limitRaw)
|
||||
@@ -22,6 +42,7 @@ export async function registerPublicReviewRoutes(fastify) {
|
||||
id: r.id,
|
||||
rating: r.rating,
|
||||
text: r.text,
|
||||
imageUrl: r.imageUrl,
|
||||
createdAt: r.createdAt,
|
||||
authorDisplay: publicReviewAuthorDisplay(r.user),
|
||||
productId: r.productId,
|
||||
@@ -60,6 +81,7 @@ export async function registerPublicReviewRoutes(fastify) {
|
||||
id: r.id,
|
||||
rating: r.rating,
|
||||
text: r.text,
|
||||
imageUrl: r.imageUrl,
|
||||
createdAt: r.createdAt,
|
||||
authorDisplay: publicReviewAuthorDisplay(r.user),
|
||||
}))
|
||||
@@ -84,6 +106,12 @@ export async function registerPublicReviewRoutes(fastify) {
|
||||
const textRaw = request.body?.text
|
||||
const text = textRaw === null || textRaw === undefined ? null : String(textRaw).trim()
|
||||
if (text !== null && text.length > 1000) return reply.code(400).send({ error: 'Отзыв слишком длинный' })
|
||||
const imageUrlRaw = request.body?.imageUrl
|
||||
const imageUrl = imageUrlRaw === null || imageUrlRaw === undefined ? null : String(imageUrlRaw).trim()
|
||||
if (imageUrl !== null && imageUrl.length > 300) return reply.code(400).send({ error: 'Ссылка на фото слишком длинная' })
|
||||
if (imageUrl !== null && imageUrl.length > 0 && !imageUrl.startsWith('/uploads/')) {
|
||||
return reply.code(400).send({ error: 'Некорректная ссылка на изображение' })
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await prisma.review.create({
|
||||
@@ -92,6 +120,7 @@ export async function registerPublicReviewRoutes(fastify) {
|
||||
userId,
|
||||
rating: Math.floor(rating),
|
||||
text: text && text.length ? text : null,
|
||||
imageUrl: imageUrl && imageUrl.length ? imageUrl : null,
|
||||
status: 'pending',
|
||||
},
|
||||
})
|
||||
|
||||
+17
-61
@@ -1,5 +1,17 @@
|
||||
import { prisma } from '../lib/prisma.js'
|
||||
import { hashPassword, issueEmailCode, normalizeEmail, verifyEmailCode, verifyPassword } from '../lib/auth.js'
|
||||
import { issueEmailCode, normalizeEmail, verifyEmailCode } from '../lib/auth.js'
|
||||
|
||||
function mapUserForClient(user) {
|
||||
const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL)
|
||||
const userEmail = normalizeEmail(user.email)
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
phone: user.phone,
|
||||
isAdmin: Boolean(adminEmail) && userEmail === adminEmail,
|
||||
}
|
||||
}
|
||||
|
||||
export async function registerAuthRoutes(fastify) {
|
||||
fastify.post('/api/auth/request-code', async (request, reply) => {
|
||||
@@ -27,38 +39,7 @@ export async function registerAuthRoutes(fastify) {
|
||||
})
|
||||
|
||||
const token = fastify.jwt.sign({ sub: user.id, email: user.email })
|
||||
return { token, user: { id: user.id, email: user.email, name: user.name, phone: user.phone } }
|
||||
})
|
||||
|
||||
fastify.post('/api/auth/register', async (request, reply) => {
|
||||
const email = normalizeEmail(request.body?.email)
|
||||
const password = String(request.body?.password || '')
|
||||
if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
|
||||
if (password.length < 8) return reply.code(400).send({ error: 'Пароль минимум 8 символов' })
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { email } })
|
||||
if (existing) return reply.code(409).send({ error: 'Пользователь уже существует' })
|
||||
|
||||
const passwordHash = await hashPassword(password)
|
||||
const user = await prisma.user.create({ data: { email, passwordHash } })
|
||||
const token = fastify.jwt.sign({ sub: user.id, email: user.email })
|
||||
return reply.code(201).send({ token, user: { id: user.id, email: user.email, name: user.name, phone: user.phone } })
|
||||
})
|
||||
|
||||
fastify.post('/api/auth/login', async (request, reply) => {
|
||||
const email = normalizeEmail(request.body?.email)
|
||||
const password = String(request.body?.password || '')
|
||||
if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
|
||||
if (!password) return reply.code(400).send({ error: 'Укажите пароль' })
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email } })
|
||||
if (!user?.passwordHash) return reply.code(401).send({ error: 'Неверные данные' })
|
||||
|
||||
const ok = await verifyPassword(password, user.passwordHash)
|
||||
if (!ok) return reply.code(401).send({ error: 'Неверные данные' })
|
||||
|
||||
const token = fastify.jwt.sign({ sub: user.id, email: user.email })
|
||||
return { token, user: { id: user.id, email: user.email, name: user.name, phone: user.phone } }
|
||||
return { token, user: mapUserForClient(user) }
|
||||
})
|
||||
|
||||
fastify.get(
|
||||
@@ -68,7 +49,7 @@ export async function registerAuthRoutes(fastify) {
|
||||
const userId = request.user.sub
|
||||
const user = await prisma.user.findUnique({ where: { id: userId } })
|
||||
if (!user) return { user: null }
|
||||
return { user: { id: user.id, email: user.email, name: user.name, phone: user.phone } }
|
||||
return { user: mapUserForClient(user) }
|
||||
},
|
||||
)
|
||||
|
||||
@@ -108,32 +89,7 @@ export async function registerAuthRoutes(fastify) {
|
||||
where: { id: userId },
|
||||
data: { email: newEmail },
|
||||
})
|
||||
return { user: { id: user.id, email: user.email, name: user.name, phone: user.phone } }
|
||||
},
|
||||
)
|
||||
|
||||
fastify.patch(
|
||||
'/api/me/password',
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const currentPassword = request.body?.currentPassword ? String(request.body.currentPassword) : ''
|
||||
const newPassword = String(request.body?.newPassword || '')
|
||||
|
||||
if (newPassword.length < 8) return reply.code(400).send({ error: 'Новый пароль минимум 8 символов' })
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: userId } })
|
||||
if (!user) return reply.code(404).send({ error: 'Пользователь не найден' })
|
||||
|
||||
if (user.passwordHash) {
|
||||
if (!currentPassword) return reply.code(400).send({ error: 'Укажите текущий пароль' })
|
||||
const ok = await verifyPassword(currentPassword, user.passwordHash)
|
||||
if (!ok) return reply.code(401).send({ error: 'Текущий пароль неверный' })
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(newPassword)
|
||||
const updated = await prisma.user.update({ where: { id: userId }, data: { passwordHash } })
|
||||
return { user: { id: updated.id, email: updated.email, name: updated.name, phone: updated.phone } }
|
||||
return { user: mapUserForClient(user) }
|
||||
},
|
||||
)
|
||||
|
||||
@@ -160,7 +116,7 @@ export async function registerAuthRoutes(fastify) {
|
||||
where: { id: userId },
|
||||
data: { name: name && name.length ? name : null, phone: phone && phone.length ? phone : null },
|
||||
})
|
||||
return { user: { id: updated.id, email: updated.email, name: updated.name, phone: updated.phone } }
|
||||
return { user: mapUserForClient(updated) }
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user