test commit

This commit is contained in:
Kirill
2026-05-19 11:25:23 +05:00
parent f8867f6457
commit 5adbe9baa7
81 changed files with 6549 additions and 3108 deletions
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import fs from 'node:fs'
import path from 'node:path'
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
const UPLOADS_DIR = path.join(process.cwd(), 'uploads')
@@ -36,7 +36,10 @@ describe('Admin gallery resize integration', () => {
expect(newUrl).toBe(`/uploads/${testUuid}.webp`)
// Verify original PNG is deleted
const pngExists = await fs.promises.access(testOriginalPath).then(() => true).catch(() => false)
const pngExists = await fs.promises
.access(testOriginalPath)
.then(() => true)
.catch(() => false)
expect(pngExists).toBe(false)
// Verify cached files exist
@@ -44,13 +47,19 @@ describe('Admin gallery resize integration', () => {
for (const width of [320, 640, 1024, 1600]) {
for (const format of ['avif', 'webp']) {
const cachePath = path.join(cacheDir, `${testUuid}_w${width}.${format}`)
const exists = await fs.promises.access(cachePath).then(() => true).catch(() => false)
const exists = await fs.promises
.access(cachePath)
.then(() => true)
.catch(() => false)
expect(exists).toBe(true)
}
}
// Verify webp original exists
const webpExists = await fs.promises.access(path.join(UPLOADS_DIR, `${testUuid}.webp`)).then(() => true).catch(() => false)
const webpExists = await fs.promises
.access(path.join(UPLOADS_DIR, `${testUuid}.webp`))
.then(() => true)
.catch(() => false)
expect(webpExists).toBe(true)
})
})
@@ -53,4 +53,3 @@ export function mapProductForApi(p, reviewsSummary = null) {
}
return base
}
+99 -115
View File
@@ -6,132 +6,116 @@ import {
import { prisma } from '../../lib/prisma.js'
export async function registerAdminCategoryRoutes(fastify) {
fastify.get(
'/api/admin/categories',
{ preHandler: [fastify.verifyAdmin] },
async () => {
const items = await prisma.category.findMany({
orderBy: [{ sort: 'asc' }, { name: 'asc' }],
})
return { items }
},
)
fastify.get('/api/admin/categories', { preHandler: [fastify.verifyAdmin] }, async () => {
const items = await prisma.category.findMany({
orderBy: [{ sort: 'asc' }, { name: 'asc' }],
})
return { items }
})
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() || request.server.slugify(name) || `cat-${Date.now()}`
if (isUnspecifiedCategorySlug(slug)) {
reply.code(400).send({ error: `Slug «${UNSPECIFIED_CATEGORY_SLUG}» зарезервирован` })
return
}
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.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() || request.server.slugify(name) || `cat-${Date.now()}`
if (isUnspecifiedCategorySlug(slug)) {
reply.code(400).send({ error: `Slug «${UNSPECIFIED_CATEGORY_SLUG}» зарезервирован` })
return
}
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.patch(
'/api/admin/categories/:id',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const { id } = request.params
const body = request.body ?? {}
const existing = await prisma.category.findUnique({ where: { id } })
if (!existing) {
reply.code(404).send({ error: 'Категория не найдена' })
fastify.patch('/api/admin/categories/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
const { id } = request.params
const body = request.body ?? {}
const existing = await prisma.category.findUnique({ where: { id } })
if (!existing) {
reply.code(404).send({ error: 'Категория не найдена' })
return
}
const data = {}
if (body.name !== undefined) data.name = String(body.name ?? '').trim()
if (body.sort !== undefined) {
const s = Number(body.sort)
if (!Number.isFinite(s)) {
reply.code(400).send({ error: 'Некорректный sort' })
return
}
const data = {}
if (body.name !== undefined) data.name = String(body.name ?? '').trim()
if (body.sort !== undefined) {
const s = Number(body.sort)
if (!Number.isFinite(s)) {
reply.code(400).send({ error: 'Некорректный sort' })
data.sort = Math.round(s)
}
if (body.slug !== undefined) {
const s = String(body.slug ?? '').trim()
if (isUnspecifiedCategorySlug(existing.slug) && s !== UNSPECIFIED_CATEGORY_SLUG) {
reply.code(400).send({ error: 'Нельзя сменить slug служебной категории «Не указано»' })
return
}
if (!s) {
reply.code(400).send({ error: 'Slug не может быть пустым' })
return
}
if (s !== existing.slug) {
if (isUnspecifiedCategorySlug(s)) {
reply.code(400).send({ error: `Slug «${UNSPECIFIED_CATEGORY_SLUG}» зарезервирован` })
return
}
data.sort = Math.round(s)
}
if (body.slug !== undefined) {
const s = String(body.slug ?? '').trim()
if (isUnspecifiedCategorySlug(existing.slug) && s !== UNSPECIFIED_CATEGORY_SLUG) {
reply.code(400).send({ error: 'Нельзя сменить slug служебной категории «Не указано»' })
const clash = await prisma.category.findFirst({ where: { slug: s, NOT: { id } } })
if (clash) {
reply.code(409).send({ error: 'Такой slug уже занят' })
return
}
if (!s) {
reply.code(400).send({ error: 'Slug не может быть пустым' })
return
}
if (s !== existing.slug) {
if (isUnspecifiedCategorySlug(s)) {
reply.code(400).send({ error: `Slug «${UNSPECIFIED_CATEGORY_SLUG}» зарезервирован` })
return
}
const clash = await prisma.category.findFirst({ where: { slug: s, NOT: { id } } })
if (clash) {
reply.code(409).send({ error: 'Такой slug уже занят' })
return
}
}
data.slug = s
}
data.slug = s
}
if (Object.keys(data).length === 0) {
return existing
}
if (data.name !== undefined && !data.name) {
reply.code(400).send({ error: 'Укажите название' })
return
}
if (Object.keys(data).length === 0) {
return existing
}
if (data.name !== undefined && !data.name) {
reply.code(400).send({ error: 'Укажите название' })
return
}
const updated = await prisma.category.update({ where: { id }, data })
return updated
},
)
const updated = await prisma.category.update({ where: { id }, data })
return updated
})
fastify.delete(
'/api/admin/categories/:id',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const { id } = request.params
const existing = await prisma.category.findUnique({ where: { id } })
if (!existing) {
reply.code(404).send({ error: 'Категория не найдена' })
return
}
if (isUnspecifiedCategorySlug(existing.slug)) {
reply.code(409).send({ error: 'Служебную категорию «Не указано» нельзя удалить' })
return
}
fastify.delete('/api/admin/categories/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
const { id } = request.params
const existing = await prisma.category.findUnique({ where: { id } })
if (!existing) {
reply.code(404).send({ error: 'Категория не найдена' })
return
}
if (isUnspecifiedCategorySlug(existing.slug)) {
reply.code(409).send({ error: 'Служебную категорию «Не указано» нельзя удалить' })
return
}
const fallback = await getOrCreateUnspecifiedCategory()
await prisma.$transaction([
prisma.product.updateMany({
where: { categoryId: id },
data: { categoryId: fallback.id },
}),
prisma.category.delete({ where: { id } }),
])
return reply.code(204).send()
},
)
const fallback = await getOrCreateUnspecifiedCategory()
await prisma.$transaction([
prisma.product.updateMany({
where: { categoryId: id },
data: { categoryId: fallback.id },
}),
prisma.category.delete({ where: { id } }),
])
return reply.code(204).send()
})
}
+88 -104
View File
@@ -9,114 +9,98 @@ import {
} from '../../lib/upload-limits.js'
export async function registerAdminGalleryRoutes(fastify) {
fastify.get(
'/api/admin/gallery',
{ preHandler: [fastify.verifyAdmin] },
async () => {
const items = await prisma.galleryImage.findMany({
orderBy: { createdAt: 'desc' },
fastify.get('/api/admin/gallery', { preHandler: [fastify.verifyAdmin] }, async () => {
const items = await prisma.galleryImage.findMany({
orderBy: { createdAt: 'desc' },
})
return { items }
})
fastify.post('/api/admin/gallery/upload', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
try {
const urls = await persistMultipartImages(request, {
maxFiles: 10,
maxFileBytes: getProductImageMaxFileBytes(),
subdir: '',
eager: false,
})
return { items }
},
)
fastify.post(
'/api/admin/gallery/upload',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
try {
const urls = await persistMultipartImages(request, {
maxFiles: 10,
maxFileBytes: getProductImageMaxFileBytes(),
subdir: '',
eager: false,
for (const url of urls) {
await prisma.galleryImage.create({
data: { url, isResized: false },
})
for (const url of urls) {
await prisma.galleryImage.create({
data: { url, isResized: false },
})
}
return { urls }
} catch (error) {
let message = error instanceof Error ? error.message : 'Не удалось загрузить файлы'
let statusCode =
error && typeof error === 'object' && 'statusCode' in error && Number.isInteger(error.statusCode)
? Number(error.statusCode)
: 400
if (isMultipartFileTooLargeError(error)) {
message = formatFileTooLargeMessage(getProductImageMaxFileBytes())
statusCode = 413
}
return reply.code(statusCode).send({ error: message })
}
},
)
fastify.post(
'/api/admin/gallery/:id/resize',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const { id } = request.params
const row = await prisma.galleryImage.findUnique({ where: { id } })
if (!row) {
return reply.code(404).send({ error: 'Изображение не найдено' })
return { urls }
} catch (error) {
let message = error instanceof Error ? error.message : 'Не удалось загрузить файлы'
let statusCode =
error && typeof error === 'object' && 'statusCode' in error && Number.isInteger(error.statusCode)
? Number(error.statusCode)
: 400
if (isMultipartFileTooLargeError(error)) {
message = formatFileTooLargeMessage(getProductImageMaxFileBytes())
statusCode = 413
}
if (row.isResized) {
return reply.code(409).send({ error: 'Изображение уже обработано' })
return reply.code(statusCode).send({ error: message })
}
})
fastify.post('/api/admin/gallery/:id/resize', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
const { id } = request.params
const row = await prisma.galleryImage.findUnique({ where: { id } })
if (!row) {
return reply.code(404).send({ error: 'Изображение не найдено' })
}
if (row.isResized) {
return reply.code(409).send({ error: 'Изображение уже обработано' })
}
const urlParts = row.url.replace(/^\//, '').split('/')
const fileName = urlParts[urlParts.length - 1]
const uuid = path.parse(fileName).name
try {
const { generateAllSizes, convertOriginalToWebp } = await import('../../lib/image-resize.js')
const fullPath = path.join(process.cwd(), urlParts.slice(0, -1).join('/'), fileName)
await generateAllSizes(uuid, '', fullPath)
const newUrl = await convertOriginalToWebp(uuid, '')
await prisma.galleryImage.update({
where: { id },
data: { url: newUrl, isResized: true },
})
return { url: newUrl }
} catch (error) {
request.log.error(error, 'Resize failed')
return reply.code(500).send({ error: 'Ошибка обработки изображения' })
}
})
fastify.delete('/api/admin/gallery/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
const { id } = request.params
const row = await prisma.galleryImage.findUnique({ where: { id } })
if (!row) {
return reply.code(404).send({ error: 'Не найдено' })
}
const usedInImages = await prisma.productImage.count({ where: { url: row.url } })
const usedAsLegacy = await prisma.product.count({ where: { imageUrl: row.url } })
if (usedInImages > 0 || usedAsLegacy > 0) {
return reply.code(409).send({ error: 'Изображение используется в карточке товара' })
}
const relative = row.url.replace(/^\//, '')
const filePath = path.join(process.cwd(), relative)
try {
await fs.unlink(filePath)
} catch (err) {
if (err && typeof err === 'object' && 'code' in err && err.code !== 'ENOENT') {
throw err
}
}
const urlParts = row.url.replace(/^\//, '').split('/')
const fileName = urlParts[urlParts.length - 1]
const uuid = path.parse(fileName).name
try {
const { generateAllSizes, convertOriginalToWebp } = await import('../../lib/image-resize.js')
const fullPath = path.join(process.cwd(), urlParts.slice(0, -1).join('/'), fileName)
await generateAllSizes(uuid, '', fullPath)
const newUrl = await convertOriginalToWebp(uuid, '')
await prisma.galleryImage.update({
where: { id },
data: { url: newUrl, isResized: true },
})
return { url: newUrl }
} catch (error) {
request.log.error(error, 'Resize failed')
return reply.code(500).send({ error: 'Ошибка обработки изображения' })
}
},
)
fastify.delete(
'/api/admin/gallery/:id',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const { id } = request.params
const row = await prisma.galleryImage.findUnique({ where: { id } })
if (!row) {
return reply.code(404).send({ error: 'Не найдено' })
}
const usedInImages = await prisma.productImage.count({ where: { url: row.url } })
const usedAsLegacy = await prisma.product.count({ where: { imageUrl: row.url } })
if (usedInImages > 0 || usedAsLegacy > 0) {
return reply.code(409).send({ error: 'Изображение используется в карточке товара' })
}
const relative = row.url.replace(/^\//, '')
const filePath = path.join(process.cwd(), relative)
try {
await fs.unlink(filePath)
} catch (err) {
if (err && typeof err === 'object' && 'code' in err && err.code !== 'ENOENT') {
throw err
}
}
await prisma.galleryImage.delete({ where: { id } })
return reply.code(204).send()
},
)
await prisma.galleryImage.delete({ where: { id } })
return reply.code(204).send()
})
}
+146 -210
View File
@@ -1,236 +1,172 @@
import { prisma } from "../../lib/prisma.js";
import { canTransitionAdminOrderStatus } from "../../lib/order-status.js";
import { NOTIFICATION_EVENTS } from "../../../../shared/constants/notification-events.js";
import { NOTIFICATION_EVENTS } from '../../../../shared/constants/notification-events.js'
import { canTransitionAdminOrderStatus } from '../../lib/order-status.js'
import { prisma } from '../../lib/prisma.js'
export async function registerAdminOrderRoutes(fastify) {
fastify.get(
"/api/admin/orders/summary",
{ preHandler: [fastify.verifyAdmin] },
async () => {
const attentionCount = await prisma.order.count({
where: {
status: "PENDING_PAYMENT",
},
});
return { attentionCount };
},
);
fastify.get('/api/admin/orders/summary', { preHandler: [fastify.verifyAdmin] }, async () => {
const attentionCount = await prisma.order.count({
where: {
status: 'PENDING_PAYMENT',
},
})
return { attentionCount }
})
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 deliveryTypeRaw = request.query?.deliveryType;
const deliveryType =
typeof deliveryTypeRaw === "string" ? deliveryTypeRaw.trim() : "";
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 deliveryTypeRaw = request.query?.deliveryType
const deliveryType = typeof deliveryTypeRaw === 'string' ? deliveryTypeRaw.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 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 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 (deliveryType) {
if (deliveryType !== "delivery" && deliveryType !== "pickup") {
return reply
.code(400)
.send({ error: "deliveryType должен быть delivery | pickup" });
}
where.deliveryType = deliveryType;
}
if (q) {
where.OR = [
{ id: { contains: q } },
{ user: { email: { contains: q } } },
];
const where = {}
if (status) where.status = status
if (deliveryType) {
if (deliveryType !== 'delivery' && deliveryType !== 'pickup') {
return reply.code(400).send({ error: 'deliveryType должен быть delivery | pickup' })
}
where.deliveryType = deliveryType
}
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,
});
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,
deliveryType: o.deliveryType,
deliveryCarrier: o.deliveryCarrier,
paymentMethod: o.paymentMethod,
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,
};
},
);
return {
items: items.map((o) => ({
id: o.id,
status: o.status,
deliveryType: o.deliveryType,
deliveryCarrier: o.deliveryCarrier,
paymentMethod: o.paymentMethod,
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.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 обязателен" });
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 (!canTransitionAdminOrderStatus(existing, next)) {
return reply
.code(409)
.send({
error: `Нельзя сменить статус ${existing.status}${next}`,
});
}
const existing = await prisma.order.findUnique({ where: { id } })
if (!existing) return reply.code(404).send({ error: 'Заказ не найден' })
if (!canTransitionAdminOrderStatus(existing, next)) {
return reply.code(409).send({
error: `Нельзя сменить статус ${existing.status}${next}`,
})
}
const updated = await prisma.order.update({
where: { id },
data: { status: next },
});
const updated = await prisma.order.update({
where: { id },
data: { status: next },
})
request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_STATUS_CHANGED, {
orderId: updated.id,
userId: existing.userId,
oldStatus: existing.status,
newStatus: next,
});
request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_STATUS_CHANGED, {
orderId: updated.id,
userId: existing.userId,
oldStatus: existing.status,
newStatus: next,
})
return { item: updated };
},
);
return { item: updated }
})
fastify.patch(
"/api/admin/orders/:id/delivery-fee",
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const { id } = request.params;
const feeRaw = request.body?.deliveryFeeCents;
const parsed =
typeof feeRaw === "string"
? Number.parseInt(feeRaw, 10)
: typeof feeRaw === "number"
? feeRaw
: NaN;
if (!Number.isInteger(parsed) || parsed < 0) {
return reply
.code(400)
.send({
error: "deliveryFeeCents должно быть целым числом ≥ 0 (копейки)",
});
}
fastify.patch('/api/admin/orders/:id/delivery-fee', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
const { id } = request.params
const feeRaw = request.body?.deliveryFeeCents
const parsed = typeof feeRaw === 'string' ? Number.parseInt(feeRaw, 10) : typeof feeRaw === 'number' ? feeRaw : NaN
if (!Number.isInteger(parsed) || parsed < 0) {
return reply.code(400).send({
error: 'deliveryFeeCents должно быть целым числом ≥ 0 (копейки)',
})
}
const existing = await prisma.order.findUnique({ where: { id } });
if (!existing) return reply.code(404).send({ error: "Заказ не найден" });
if (
existing.status !== "PENDING_PAYMENT" ||
existing.deliveryFeeLocked !== false
) {
return reply
.code(409)
.send({
error:
"Корректировка доставки доступна только пока стоимость не утверждена",
});
}
const existing = await prisma.order.findUnique({ where: { id } })
if (!existing) return reply.code(404).send({ error: 'Заказ не найден' })
if (existing.status !== 'PENDING_PAYMENT' || existing.deliveryFeeLocked !== false) {
return reply.code(409).send({
error: 'Корректировка доставки доступна только пока стоимость не утверждена',
})
}
const totalCents = existing.itemsSubtotalCents + parsed;
const updated = await prisma.order.update({
where: { id },
data: {
deliveryFeeCents: parsed,
totalCents,
deliveryFeeLocked: true,
},
});
const totalCents = existing.itemsSubtotalCents + parsed
const updated = await prisma.order.update({
where: { id },
data: {
deliveryFeeCents: parsed,
totalCents,
deliveryFeeLocked: true,
},
})
request.server.eventBus.emit(NOTIFICATION_EVENTS.DELIVERY_FEE_ADJUSTED, {
orderId: updated.id,
userId: existing.userId,
totalCents: updated.totalCents,
});
request.server.eventBus.emit(NOTIFICATION_EVENTS.DELIVERY_FEE_ADJUSTED, {
orderId: updated.id,
userId: existing.userId,
totalCents: updated.totalCents,
})
return { item: updated };
},
);
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: "Сообщение слишком длинное" });
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 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 },
});
const msg = await prisma.orderMessage.create({
data: { orderId: id, authorType: 'admin', text },
})
request.server.eventBus.emit(
NOTIFICATION_EVENTS.ORDER_MESSAGE_ADMIN_REPLY,
{
orderId: id,
userId: order.userId,
messageId: msg.id,
preview: text,
},
);
request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_MESSAGE_ADMIN_REPLY, {
orderId: id,
userId: order.userId,
messageId: msg.id,
preview: text,
})
return reply.code(201).send({ item: msg });
},
);
return reply.code(201).send({ item: msg })
})
}
+22 -26
View File
@@ -40,17 +40,13 @@ const PATCH_PRODUCT_SCHEMA = {
}
export async function registerAdminProductRoutes(fastify) {
fastify.get(
'/api/admin/products',
{ preHandler: [fastify.verifyAdmin] },
async (request) => {
const items = await prisma.product.findMany({
include: { category: true, images: { orderBy: { sort: 'asc' } } },
orderBy: { updatedAt: 'desc' },
})
return items.map((p) => request.server.mapProductForApi(p))
},
)
fastify.get('/api/admin/products', { preHandler: [fastify.verifyAdmin] }, async (request) => {
const items = await prisma.product.findMany({
include: { category: true, images: { orderBy: { sort: 'asc' } } },
orderBy: { updatedAt: 'desc' },
})
return items.map((p) => request.server.mapProductForApi(p))
})
fastify.post(
'/api/admin/products',
@@ -102,7 +98,9 @@ export async function registerAdminProductRoutes(fastify) {
return reply.code(400).send({ error: 'Некоторые изображения не найдены в галерее' })
}
if (notResized.length > 0) {
return reply.code(400).send({ error: 'Изображения должны быть обработаны (resize) перед прикреплением к товару' })
return reply
.code(400)
.send({ error: 'Изображения должны быть обработаны (resize) перед прикреплением к товару' })
}
}
}
@@ -227,7 +225,9 @@ export async function registerAdminProductRoutes(fastify) {
return reply.code(400).send({ error: 'Некоторые изображения не найдены в галерее' })
}
if (notResized.length > 0) {
return reply.code(400).send({ error: 'Изображения должны быть обработаны (resize) перед прикреплением к товару' })
return reply
.code(400)
.send({ error: 'Изображения должны быть обработаны (resize) перед прикреплением к товару' })
}
}
}
@@ -255,17 +255,13 @@ export async function registerAdminProductRoutes(fastify) {
},
)
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.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: 'Товар не найден' })
}
})
}
+54 -79
View File
@@ -1,90 +1,65 @@
import { prisma } from "../../lib/prisma.js";
import { NOTIFICATION_EVENTS } from "../../../../shared/constants/notification-events.js";
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";
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 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 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,
});
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 };
},
);
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" });
}
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 },
include: {
product: { select: { title: true } },
user: { select: { name: true, email: true } },
},
});
if (!existing) return reply.code(404).send({ error: "Отзыв не найден" });
const existing = await prisma.review.findUnique({
where: { id },
include: {
product: { select: { title: true } },
user: { select: { name: true, email: true } },
},
})
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(),
},
});
request.server.eventBus.emit("review:created", {
rating: updated.rating,
text: updated.text || "",
productTitle: existing.product?.title || "",
userName: existing.user?.name || existing.user?.email || "",
reviewId: updated.id,
});
const updated = await prisma.review.update({
where: { id },
data: {
status: action === 'approve' ? 'approved' : 'rejected',
moderatedAt: new Date(),
},
})
request.server.eventBus.emit('review:created', {
rating: updated.rating,
text: updated.text || '',
productTitle: existing.product?.title || '',
userName: existing.user?.name || existing.user?.email || '',
reviewId: updated.id,
})
return { item: updated };
},
);
return { item: updated }
})
}
+122 -139
View File
@@ -1,166 +1,149 @@
import { prisma } from '../../lib/prisma.js'
import { normalizeEmail } from '../../lib/auth.js'
import { prisma } from '../../lib/prisma.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() : ''
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 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
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
}
if (pageSize > 100) {
reply.code(400).send({ error: 'pageSize должен быть ≤ 100' })
return
}
const where = q
? {
OR: [{ email: { contains: q } }, { name: { contains: q } }],
}
: undefined
const where = q
? {
OR: [{ email: { contains: q } }, { name: { contains: q } }],
}
: undefined
const total = await prisma.user.count({ where })
const total = await prisma.user.count({ where })
const users = await prisma.user.findMany({
where,
select: {
id: true,
email: true,
name: 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,
createdAt: u.createdAt,
updatedAt: u.updatedAt,
}))
const users = await prisma.user.findMany({
where,
select: {
id: true,
email: true,
name: 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,
createdAt: u.createdAt,
updatedAt: u.updatedAt,
}))
return { items, total, page, pageSize }
},
)
return { items, total, page, pageSize }
})
fastify.post(
'/api/admin/users',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const body = request.body ?? {}
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 exists = await prisma.user.findUnique({ where: { email } })
if (exists) {
reply.code(409).send({ error: 'Почта уже занята' })
return
}
const user = await prisma.user.create({
data: {
email,
name: name && name.length ? name : null,
},
})
reply.code(201).send({
id: user.id,
email: user.email,
name: user.name,
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
}
const exists = await prisma.user.findUnique({ where: { email } })
if (exists) {
reply.code(409).send({ error: 'Почта уже занята' })
return
}
const user = await prisma.user.update({ where: { id }, data })
return {
id: user.id,
email: user.email,
name: user.name,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
}
})
const user = await prisma.user.create({
data: {
email,
name: name && name.length ? name : null,
},
})
reply.code(201).send({
id: user.id,
email: user.email,
name: user.name,
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
}
const user = await prisma.user.update({ where: { id }, data })
return {
id: user.id,
email: user.email,
name: user.name,
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: 'Пользователь не найден' })
}
},
)
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: 'Пользователь не найден' })
}
})
}
+56 -73
View File
@@ -1,95 +1,78 @@
import { prisma } from "../../../lib/prisma.js";
import { prisma } from '../../../lib/prisma.js'
export async function registerAdminNotificationRoutes(fastify) {
fastify.get(
"/api/admin/notifications/settings",
{ preHandler: [fastify.verifyAdmin] },
async () => {
let settings = await prisma.adminNotificationSettings.findFirst();
if (!settings) {
settings = await prisma.adminNotificationSettings.create({
data: {
emailEnabled: true,
telegramEnabled: false,
newOrder: true,
newOrderMessage: true,
newReview: true,
authCodeDuplicate: false,
},
});
}
return { settings };
},
);
fastify.get('/api/admin/notifications/settings', { preHandler: [fastify.verifyAdmin] }, async () => {
let settings = await prisma.adminNotificationSettings.findFirst()
if (!settings) {
settings = await prisma.adminNotificationSettings.create({
data: {
emailEnabled: true,
telegramEnabled: false,
newOrder: true,
newOrderMessage: true,
newReview: true,
authCodeDuplicate: false,
},
})
}
return { settings }
})
fastify.put(
"/api/admin/notifications/settings",
{ preHandler: [fastify.verifyAdmin] },
async (request) => {
const body = request.body || {};
let settings = await prisma.adminNotificationSettings.findFirst();
fastify.put('/api/admin/notifications/settings', { preHandler: [fastify.verifyAdmin] }, async (request) => {
const body = request.body || {}
let settings = await prisma.adminNotificationSettings.findFirst()
const data = {};
if ("emailEnabled" in body)
data.emailEnabled = Boolean(body.emailEnabled);
if ("telegramEnabled" in body)
data.telegramEnabled = Boolean(body.telegramEnabled);
if ("telegramChatId" in body)
data.telegramChatId = body.telegramChatId || null;
if ("newOrder" in body) data.newOrder = Boolean(body.newOrder);
if ("newOrderMessage" in body)
data.newOrderMessage = Boolean(body.newOrderMessage);
if ("newReview" in body) data.newReview = Boolean(body.newReview);
if ("authCodeDuplicate" in body)
data.authCodeDuplicate = Boolean(body.authCodeDuplicate);
const data = {}
if ('emailEnabled' in body) data.emailEnabled = Boolean(body.emailEnabled)
if ('telegramEnabled' in body) data.telegramEnabled = Boolean(body.telegramEnabled)
if ('telegramChatId' in body) data.telegramChatId = body.telegramChatId || null
if ('newOrder' in body) data.newOrder = Boolean(body.newOrder)
if ('newOrderMessage' in body) data.newOrderMessage = Boolean(body.newOrderMessage)
if ('newReview' in body) data.newReview = Boolean(body.newReview)
if ('authCodeDuplicate' in body) data.authCodeDuplicate = Boolean(body.authCodeDuplicate)
if (!settings) {
settings = await prisma.adminNotificationSettings.create({ data });
} else {
settings = await prisma.adminNotificationSettings.update({
where: { id: settings.id },
data,
});
}
if (!settings) {
settings = await prisma.adminNotificationSettings.create({ data })
} else {
settings = await prisma.adminNotificationSettings.update({
where: { id: settings.id },
data,
})
}
return { settings };
},
);
return { settings }
})
fastify.post("/api/admin/notifications/telegram/webhook", async (request) => {
const update = request.body || {};
const message = update.message;
if (!message || !message.text || message.text !== "/start")
return { ok: true };
fastify.post('/api/admin/notifications/telegram/webhook', async (request) => {
const update = request.body || {}
const message = update.message
if (!message || !message.text || message.text !== '/start') return { ok: true }
const chatId = String(message.chat.id);
const settings = await prisma.adminNotificationSettings.findFirst();
const chatId = String(message.chat.id)
const settings = await prisma.adminNotificationSettings.findFirst()
if (settings) {
await prisma.adminNotificationSettings.update({
where: { id: settings.id },
data: { telegramChatId: chatId },
});
})
} else {
await prisma.adminNotificationSettings.create({
data: { telegramChatId: chatId },
});
})
}
if (process.env.TELEGRAM_BOT_TOKEN) {
await fetch(
`https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMessage`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
chat_id: chatId,
text: "Вы подписаны на уведомления Любимый Креатив.",
}),
},
);
await fetch(`https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: chatId,
text: 'Вы подписаны на уведомления Любимый Креатив.',
}),
})
}
return { ok: true };
});
return { ok: true }
})
}
+67 -75
View File
@@ -17,83 +17,75 @@ export async function registerCatalogSliderRoutes(fastify) {
}
})
fastify.get(
'/api/admin/catalog-slider',
{ preHandler: [fastify.verifyAdmin] },
async () => {
const slides = await prisma.catalogSliderSlide.findMany({
orderBy: { sortOrder: 'asc' },
include: { galleryImage: true },
})
return {
slides: slides.map((s) => ({
id: s.id,
galleryImageId: s.galleryImageId,
url: s.galleryImage.url,
caption: s.caption,
})),
}
},
)
fastify.get('/api/admin/catalog-slider', { preHandler: [fastify.verifyAdmin] }, async () => {
const slides = await prisma.catalogSliderSlide.findMany({
orderBy: { sortOrder: 'asc' },
include: { galleryImage: true },
})
return {
slides: slides.map((s) => ({
id: s.id,
galleryImageId: s.galleryImageId,
url: s.galleryImage.url,
caption: s.caption,
})),
}
})
fastify.put(
'/api/admin/catalog-slider',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const body = request.body ?? {}
const rawSlides = body.slides
if (!Array.isArray(rawSlides)) {
return reply.code(400).send({ error: 'Ожидается slides: массив' })
}
if (rawSlides.length > MAX_SLIDES) {
return reply.code(400).send({ error: `Не более ${MAX_SLIDES} слайдов` })
}
fastify.put('/api/admin/catalog-slider', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
const body = request.body ?? {}
const rawSlides = body.slides
if (!Array.isArray(rawSlides)) {
return reply.code(400).send({ error: 'Ожидается slides: массив' })
}
if (rawSlides.length > MAX_SLIDES) {
return reply.code(400).send({ error: `Не более ${MAX_SLIDES} слайдов` })
}
const seenGalleryIds = new Set()
const normalized = []
for (let i = 0; i < rawSlides.length; i++) {
const row = rawSlides[i]
const galleryImageId = String(row?.galleryImageId ?? '').trim()
if (!galleryImageId) {
return reply.code(400).send({ error: `Слайд ${i + 1}: укажите galleryImageId` })
}
if (seenGalleryIds.has(galleryImageId)) {
return reply.code(400).send({ error: 'Одно изображение нельзя добавить дважды' })
}
seenGalleryIds.add(galleryImageId)
const img = await prisma.galleryImage.findUnique({ where: { id: galleryImageId } })
if (!img) {
return reply.code(400).send({ error: `Изображение не найдено: ${galleryImageId}` })
}
const caption = row?.caption == null ? '' : String(row.caption).slice(0, 500)
normalized.push({ galleryImageId, caption, sortOrder: i })
const seenGalleryIds = new Set()
const normalized = []
for (let i = 0; i < rawSlides.length; i++) {
const row = rawSlides[i]
const galleryImageId = String(row?.galleryImageId ?? '').trim()
if (!galleryImageId) {
return reply.code(400).send({ error: `Слайд ${i + 1}: укажите galleryImageId` })
}
await prisma.$transaction(async (tx) => {
await tx.catalogSliderSlide.deleteMany({})
for (const n of normalized) {
await tx.catalogSliderSlide.create({
data: {
sortOrder: n.sortOrder,
caption: n.caption,
galleryImageId: n.galleryImageId,
},
})
}
})
const slides = await prisma.catalogSliderSlide.findMany({
orderBy: { sortOrder: 'asc' },
include: { galleryImage: true },
})
return {
slides: slides.map((s) => ({
id: s.id,
galleryImageId: s.galleryImageId,
url: s.galleryImage.url,
caption: s.caption,
})),
if (seenGalleryIds.has(galleryImageId)) {
return reply.code(400).send({ error: 'Одно изображение нельзя добавить дважды' })
}
},
)
seenGalleryIds.add(galleryImageId)
const img = await prisma.galleryImage.findUnique({ where: { id: galleryImageId } })
if (!img) {
return reply.code(400).send({ error: `Изображение не найдено: ${galleryImageId}` })
}
const caption = row?.caption == null ? '' : String(row.caption).slice(0, 500)
normalized.push({ galleryImageId, caption, sortOrder: i })
}
await prisma.$transaction(async (tx) => {
await tx.catalogSliderSlide.deleteMany({})
for (const n of normalized) {
await tx.catalogSliderSlide.create({
data: {
sortOrder: n.sortOrder,
caption: n.caption,
galleryImageId: n.galleryImageId,
},
})
}
})
const slides = await prisma.catalogSliderSlide.findMany({
orderBy: { sortOrder: 'asc' },
include: { galleryImage: true },
})
return {
slides: slides.map((s) => ({
id: s.id,
galleryImageId: s.galleryImageId,
url: s.galleryImage.url,
caption: s.caption,
})),
}
})
}
+63 -79
View File
@@ -29,90 +29,74 @@ export async function registerInfoPageRoutes(fastify) {
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.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
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 уже существует' })
}
},
)
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: 'Блок не найден' })
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)
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 уже существует' })
}
},
)
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: 'Блок не найден' })
}
},
)
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: 'Блок не найден' })
}
})
}
-2
View File
@@ -79,7 +79,6 @@ export async function registerPublicCatalogRoutes(fastify) {
})
fastify.get('/api/products', { schema: PUBLIC_PRODUCTS_QUERY_SCHEMA }, async (request, reply) => {
const { mapProductForApi } = request.server
const { categorySlug } = request.query
const qRaw = request.query?.q
const q = typeof qRaw === 'string' ? qRaw.trim() : ''
@@ -161,4 +160,3 @@ export async function registerPublicCatalogRoutes(fastify) {
return request.server.mapProductForApi(product, summaries.get(product.id) ?? EMPTY_REVIEWS_SUMMARY)
})
}
+58 -66
View File
@@ -1,39 +1,35 @@
import { publicReviewAuthorDisplay } from '../../lib/review-display.js'
import { prisma } from '../../lib/prisma.js'
import { publicReviewAuthorDisplay } from '../../lib/review-display.js'
import { persistMultipartImages } from '../../lib/upload-images.js'
import {
formatFileTooLargeMessage,
getOtherUploadMaxFileBytes,
isMultipartFileTooLargeError,
} from '../../lib/upload-limits.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,
maxFileBytes: getOtherUploadMaxFileBytes(),
subdir: 'reviews',
})
if (urls.length !== 1) return reply.code(400).send({ error: 'Нужно прикрепить 1 изображение' })
return { url: urls[0] }
} catch (error) {
let message = error instanceof Error ? error.message : 'Не удалось загрузить изображение'
let statusCode =
error && typeof error === 'object' && 'statusCode' in error && Number.isInteger(error.statusCode)
? Number(error.statusCode)
: 400
if (isMultipartFileTooLargeError(error)) {
message = formatFileTooLargeMessage(getOtherUploadMaxFileBytes())
statusCode = 413
}
return reply.code(statusCode).send({ error: message })
fastify.post('/api/reviews/upload-image', { preHandler: [fastify.authenticate] }, async (request, reply) => {
try {
const urls = await persistMultipartImages(request, {
maxFiles: 1,
maxFileBytes: getOtherUploadMaxFileBytes(),
subdir: 'reviews',
})
if (urls.length !== 1) return reply.code(400).send({ error: 'Нужно прикрепить 1 изображение' })
return { url: urls[0] }
} catch (error) {
let message = error instanceof Error ? error.message : 'Не удалось загрузить изображение'
let statusCode =
error && typeof error === 'object' && 'statusCode' in error && Number.isInteger(error.statusCode)
? Number(error.statusCode)
: 400
if (isMultipartFileTooLargeError(error)) {
message = formatFileTooLargeMessage(getOtherUploadMaxFileBytes())
statusCode = 413
}
},
)
return reply.code(statusCode).send({ error: message })
}
})
fastify.get('/api/reviews/latest', async (request, reply) => {
const limitRaw = request.query?.limit
@@ -102,46 +98,42 @@ export async function registerPublicReviewRoutes(fastify) {
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
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 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: 'Отзыв слишком длинный' })
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: 'Некорректная ссылка на изображение' })
}
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: 'Отзыв слишком длинный' })
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({
data: {
productId,
userId,
rating: Math.floor(rating),
text: text && text.length ? text : null,
imageUrl: imageUrl && imageUrl.length ? imageUrl : null,
status: 'pending',
},
})
return reply.code(201).send({ item: created })
} catch {
return reply.code(409).send({ error: 'Вы уже оставляли отзыв на этот товар' })
}
},
)
try {
const created = await prisma.review.create({
data: {
productId,
userId,
rating: Math.floor(rating),
text: text && text.length ? text : null,
imageUrl: imageUrl && imageUrl.length ? imageUrl : null,
status: 'pending',
},
})
return reply.code(201).send({ item: created })
} catch {
return reply.code(409).send({ error: 'Вы уже оставляли отзыв на этот товар' })
}
})
}