test commit
This commit is contained in:
@@ -1,16 +1,12 @@
|
||||
import {
|
||||
mapProductForApi,
|
||||
parseMaterialsInput,
|
||||
slugify,
|
||||
} from './api/_product-helpers.js'
|
||||
import { registerAdminGalleryRoutes } from './api/admin-gallery.js'
|
||||
import { mapProductForApi, parseMaterialsInput, slugify } from './api/_product-helpers.js'
|
||||
import { registerAdminNotificationRoutes } from './api/admin/notifications.js'
|
||||
import { registerAdminCategoryRoutes } from './api/admin-categories.js'
|
||||
import { registerCatalogSliderRoutes } from './api/catalog-slider.js'
|
||||
import { registerAdminGalleryRoutes } from './api/admin-gallery.js'
|
||||
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 { registerAdminNotificationRoutes } from './api/admin/notifications.js'
|
||||
import { registerCatalogSliderRoutes } from './api/catalog-slider.js'
|
||||
import { registerInfoPageRoutes } from './api/info-page.js'
|
||||
import { registerPublicCatalogRoutes } from './api/public-catalog.js'
|
||||
import { registerPublicReviewRoutes } from './api/public-reviews.js'
|
||||
@@ -33,4 +29,3 @@ export async function registerApiRoutes(fastify) {
|
||||
await registerAdminUserRoutes(fastify)
|
||||
await registerAdminNotificationRoutes(fastify)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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: 'Товар не найден' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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: 'Пользователь не найден' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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: 'Блок не найден' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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: 'Вы уже оставляли отзыв на этот товар' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+93
-131
@@ -1,177 +1,139 @@
|
||||
import {
|
||||
issueEmailCode,
|
||||
normalizeEmail,
|
||||
verifyEmailCode,
|
||||
} from "../lib/auth.js";
|
||||
import { prisma } from "../lib/prisma.js";
|
||||
import { NOTIFICATION_EVENTS } from "../../../shared/constants/notification-events.js";
|
||||
import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js'
|
||||
import { issueEmailCode, normalizeEmail, verifyEmailCode } from '../lib/auth.js'
|
||||
import { prisma } from '../lib/prisma.js'
|
||||
|
||||
function mapUserForClient(user) {
|
||||
const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL);
|
||||
const userEmail = normalizeEmail(user.email);
|
||||
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) => {
|
||||
const email = normalizeEmail(request.body?.email);
|
||||
if (!email || !email.includes("@"))
|
||||
return reply.code(400).send({ error: "Некорректная почта" });
|
||||
fastify.post('/api/auth/request-code', async (request, reply) => {
|
||||
const email = normalizeEmail(request.body?.email)
|
||||
if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
|
||||
|
||||
const code = await issueEmailCode({ email, purpose: "login" });
|
||||
const code = await issueEmailCode({ email, purpose: 'login' })
|
||||
|
||||
const adminEmail = process.env.ADMIN_EMAIL?.trim().toLowerCase();
|
||||
const isAdmin = email === adminEmail;
|
||||
const adminEmail = process.env.ADMIN_EMAIL?.trim().toLowerCase()
|
||||
const isAdmin = email === adminEmail
|
||||
|
||||
request.server.eventBus.emit(NOTIFICATION_EVENTS.AUTH_CODE_REQUESTED, {
|
||||
email,
|
||||
code,
|
||||
isAdmin,
|
||||
});
|
||||
})
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
fastify.post("/api/auth/verify-code", async (request, reply) => {
|
||||
const email = normalizeEmail(request.body?.email);
|
||||
const code = String(request.body?.code || "").trim();
|
||||
if (!email || !email.includes("@"))
|
||||
return reply.code(400).send({ error: "Некорректная почта" });
|
||||
if (!code || code.length !== 6)
|
||||
return reply.code(400).send({ error: "Код должен быть из 6 цифр" });
|
||||
fastify.post('/api/auth/verify-code', async (request, reply) => {
|
||||
const email = normalizeEmail(request.body?.email)
|
||||
const code = String(request.body?.code || '').trim()
|
||||
if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
|
||||
if (!code || code.length !== 6) return reply.code(400).send({ error: 'Код должен быть из 6 цифр' })
|
||||
|
||||
const ok = await verifyEmailCode({ email, purpose: "login", code });
|
||||
if (!ok)
|
||||
return reply.code(401).send({ error: "Неверный или истёкший код" });
|
||||
const ok = await verifyEmailCode({ email, purpose: 'login', code })
|
||||
if (!ok) return reply.code(401).send({ error: 'Неверный или истёкший код' })
|
||||
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email },
|
||||
update: {},
|
||||
create: { email },
|
||||
});
|
||||
})
|
||||
|
||||
// Ensure notification preference exists
|
||||
await prisma.notificationPreference.upsert({
|
||||
where: { userId: user.id },
|
||||
create: { userId: user.id, globalEnabled: true },
|
||||
update: {},
|
||||
});
|
||||
})
|
||||
|
||||
const token = fastify.jwt.sign({ sub: user.id, email: user.email });
|
||||
return { token, user: mapUserForClient(user) };
|
||||
});
|
||||
const token = fastify.jwt.sign({ sub: user.id, email: user.email })
|
||||
return { token, user: mapUserForClient(user) }
|
||||
})
|
||||
|
||||
fastify.get(
|
||||
"/api/me",
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request) => {
|
||||
const userId = request.user.sub;
|
||||
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) return { user: null };
|
||||
return { user: mapUserForClient(user) };
|
||||
},
|
||||
);
|
||||
fastify.get('/api/me', { preHandler: [fastify.authenticate] }, async (request) => {
|
||||
const userId = request.user.sub
|
||||
const user = await prisma.user.findUnique({ where: { id: userId } })
|
||||
if (!user) return { user: null }
|
||||
return { user: mapUserForClient(user) }
|
||||
})
|
||||
|
||||
fastify.post(
|
||||
"/api/me/change-email/request-code",
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user.sub;
|
||||
const newEmail = normalizeEmail(request.body?.newEmail);
|
||||
if (!newEmail || !newEmail.includes("@"))
|
||||
return reply.code(400).send({ error: "Некорректная почта" });
|
||||
fastify.post('/api/me/change-email/request-code', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const newEmail = normalizeEmail(request.body?.newEmail)
|
||||
if (!newEmail || !newEmail.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
|
||||
|
||||
const exists = await prisma.user.findUnique({
|
||||
where: { email: newEmail },
|
||||
});
|
||||
if (exists)
|
||||
return reply.code(409).send({ error: "Эта почта уже занята" });
|
||||
const exists = await prisma.user.findUnique({
|
||||
where: { email: newEmail },
|
||||
})
|
||||
if (exists) return reply.code(409).send({ error: 'Эта почта уже занята' })
|
||||
|
||||
await issueEmailCode({
|
||||
email: newEmail,
|
||||
purpose: "change_email",
|
||||
userId,
|
||||
});
|
||||
return { ok: true };
|
||||
},
|
||||
);
|
||||
await issueEmailCode({
|
||||
email: newEmail,
|
||||
purpose: 'change_email',
|
||||
userId,
|
||||
})
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
fastify.post(
|
||||
"/api/me/change-email/verify",
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user.sub;
|
||||
const newEmail = normalizeEmail(request.body?.newEmail);
|
||||
const code = String(request.body?.code || "").trim();
|
||||
if (!newEmail || !newEmail.includes("@"))
|
||||
return reply.code(400).send({ error: "Некорректная почта" });
|
||||
if (!code || code.length !== 6)
|
||||
return reply.code(400).send({ error: "Код должен быть из 6 цифр" });
|
||||
fastify.post('/api/me/change-email/verify', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const newEmail = normalizeEmail(request.body?.newEmail)
|
||||
const code = String(request.body?.code || '').trim()
|
||||
if (!newEmail || !newEmail.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
|
||||
if (!code || code.length !== 6) return reply.code(400).send({ error: 'Код должен быть из 6 цифр' })
|
||||
|
||||
const exists = await prisma.user.findUnique({
|
||||
where: { email: newEmail },
|
||||
});
|
||||
if (exists)
|
||||
return reply.code(409).send({ error: "Эта почта уже занята" });
|
||||
const exists = await prisma.user.findUnique({
|
||||
where: { email: newEmail },
|
||||
})
|
||||
if (exists) return reply.code(409).send({ error: 'Эта почта уже занята' })
|
||||
|
||||
const ok = await verifyEmailCode({
|
||||
email: newEmail,
|
||||
purpose: "change_email",
|
||||
code,
|
||||
userId,
|
||||
});
|
||||
if (!ok)
|
||||
return reply.code(401).send({ error: "Неверный или истёкший код" });
|
||||
const ok = await verifyEmailCode({
|
||||
email: newEmail,
|
||||
purpose: 'change_email',
|
||||
code,
|
||||
userId,
|
||||
})
|
||||
if (!ok) return reply.code(401).send({ error: 'Неверный или истёкший код' })
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { email: newEmail },
|
||||
});
|
||||
return { user: mapUserForClient(user) };
|
||||
},
|
||||
);
|
||||
const user = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { email: newEmail },
|
||||
})
|
||||
return { user: mapUserForClient(user) }
|
||||
})
|
||||
|
||||
fastify.patch(
|
||||
"/api/me/profile",
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user.sub;
|
||||
const nameRaw = request.body?.name;
|
||||
const name =
|
||||
nameRaw === null || nameRaw === undefined
|
||||
? null
|
||||
: String(nameRaw).trim();
|
||||
const phoneRaw = request.body?.phone;
|
||||
const phone =
|
||||
phoneRaw === null || phoneRaw === undefined
|
||||
? null
|
||||
: String(phoneRaw).trim();
|
||||
fastify.patch('/api/me/profile', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const nameRaw = request.body?.name
|
||||
const name = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim()
|
||||
const phoneRaw = request.body?.phone
|
||||
const phone = phoneRaw === null || phoneRaw === undefined ? null : String(phoneRaw).trim()
|
||||
|
||||
if (name !== null && name.length > 40)
|
||||
return reply.code(400).send({ error: "Имя/ник максимум 40 символов" });
|
||||
if (phone !== null) {
|
||||
const compact = phone.replace(/[\s()-]/g, "");
|
||||
if (compact.length > 20)
|
||||
return reply.code(400).send({ error: "Телефон слишком длинный" });
|
||||
if (compact.length && !/^\+?\d{7,20}$/.test(compact)) {
|
||||
return reply.code(400).send({ error: "Некорректный телефон" });
|
||||
}
|
||||
if (name !== null && name.length > 40) return reply.code(400).send({ error: 'Имя/ник максимум 40 символов' })
|
||||
if (phone !== null) {
|
||||
const compact = phone.replace(/[\s()-]/g, '')
|
||||
if (compact.length > 20) return reply.code(400).send({ error: 'Телефон слишком длинный' })
|
||||
if (compact.length && !/^\+?\d{7,20}$/.test(compact)) {
|
||||
return reply.code(400).send({ error: 'Некорректный телефон' })
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
name: name && name.length ? name : null,
|
||||
phone: phone && phone.length ? phone : null,
|
||||
},
|
||||
});
|
||||
return { user: mapUserForClient(updated) };
|
||||
},
|
||||
);
|
||||
const updated = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
name: name && name.length ? name : null,
|
||||
phone: phone && phone.length ? phone : null,
|
||||
},
|
||||
})
|
||||
return { user: mapUserForClient(updated) }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// server/src/routes/uploads-resized.js
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { findOriginalFile, getOrCreateResized, SUPPORTED_FORMATS, VALID_WIDTHS } from '../lib/image-resize.js'
|
||||
|
||||
const CACHE_CONTROL_IMMUTABLE = 'public, max-age=31536000, immutable'
|
||||
@@ -18,7 +17,8 @@ export function registerUploadsResized(fastify) {
|
||||
|
||||
// Parse: [subdir/]filename.format
|
||||
const parts = rawPath.split('/')
|
||||
let filename, subdir = ''
|
||||
let filename,
|
||||
subdir = ''
|
||||
|
||||
if (parts.length > 1) {
|
||||
subdir = parts.slice(0, -1).join('/') + '/'
|
||||
|
||||
+126
-142
@@ -25,7 +25,8 @@ function validateAddressPayload(body, reply) {
|
||||
|
||||
const commentRaw = body?.comment
|
||||
const comment = commentRaw === null || commentRaw === undefined ? null : String(commentRaw).trim()
|
||||
if (comment !== null && comment.length > 200) return reply.code(400).send({ error: 'Комментарий максимум 200 символов' })
|
||||
if (comment !== null && comment.length > 200)
|
||||
return reply.code(400).send({ error: 'Комментарий максимум 200 символов' })
|
||||
|
||||
const lat = Number(body?.lat)
|
||||
const lng = Number(body?.lng)
|
||||
@@ -44,150 +45,133 @@ function validateAddressPayload(body, reply) {
|
||||
}
|
||||
|
||||
export async function registerUserAddressRoutes(fastify) {
|
||||
fastify.get(
|
||||
'/api/me/addresses',
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request) => {
|
||||
const userId = request.user.sub
|
||||
const items = await prisma.shippingAddress.findMany({
|
||||
where: { userId },
|
||||
orderBy: [{ isDefault: 'desc' }, { updatedAt: 'desc' }],
|
||||
})
|
||||
return { items }
|
||||
},
|
||||
)
|
||||
fastify.get('/api/me/addresses', { preHandler: [fastify.authenticate] }, async (request) => {
|
||||
const userId = request.user.sub
|
||||
const items = await prisma.shippingAddress.findMany({
|
||||
where: { userId },
|
||||
orderBy: [{ isDefault: 'desc' }, { updatedAt: 'desc' }],
|
||||
})
|
||||
return { items }
|
||||
})
|
||||
|
||||
fastify.post(
|
||||
'/api/me/addresses',
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const validated = validateAddressPayload(request.body, reply)
|
||||
if (!validated) return
|
||||
fastify.post('/api/me/addresses', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const validated = validateAddressPayload(request.body, reply)
|
||||
if (!validated) return
|
||||
|
||||
const isDefault = Boolean(request.body?.isDefault)
|
||||
const created = await prisma.$transaction(async (tx) => {
|
||||
if (isDefault) {
|
||||
await tx.shippingAddress.updateMany({ where: { userId, isDefault: true }, data: { isDefault: false } })
|
||||
}
|
||||
return tx.shippingAddress.create({
|
||||
data: {
|
||||
userId,
|
||||
...validated,
|
||||
isDefault,
|
||||
},
|
||||
})
|
||||
})
|
||||
return reply.code(201).send({ item: created })
|
||||
},
|
||||
)
|
||||
|
||||
fastify.patch(
|
||||
'/api/me/addresses/:id',
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const { id } = request.params
|
||||
const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } })
|
||||
if (!existing) return reply.code(404).send({ error: 'Адрес не найден' })
|
||||
|
||||
const body = request.body ?? {}
|
||||
const data = {}
|
||||
|
||||
if (body.label !== undefined) {
|
||||
const labelRaw = body.label
|
||||
const label = labelRaw === null || labelRaw === undefined ? null : String(labelRaw).trim()
|
||||
if (label !== null && label.length > 40) return reply.code(400).send({ error: 'Метка адреса максимум 40 символов' })
|
||||
data.label = label && label.length ? label : null
|
||||
}
|
||||
|
||||
if (body.recipientName !== undefined) {
|
||||
const v = String(body.recipientName || '').trim()
|
||||
if (!v) return reply.code(400).send({ error: 'Укажите ФИО получателя' })
|
||||
if (v.length > 80) return reply.code(400).send({ error: 'ФИО получателя максимум 80 символов' })
|
||||
data.recipientName = v
|
||||
}
|
||||
|
||||
if (body.recipientPhone !== undefined) {
|
||||
const v = normalizePhoneLite(body.recipientPhone)
|
||||
if (!v) return reply.code(400).send({ error: 'Укажите телефон получателя' })
|
||||
if (!/^\+?\d{7,20}$/.test(v)) return reply.code(400).send({ error: 'Некорректный телефон получателя' })
|
||||
data.recipientPhone = v
|
||||
}
|
||||
|
||||
if (body.addressLine !== undefined) {
|
||||
const v = String(body.addressLine || '').trim()
|
||||
if (!v) return reply.code(400).send({ error: 'Укажите адрес' })
|
||||
if (v.length > 200) return reply.code(400).send({ error: 'Адрес максимум 200 символов' })
|
||||
data.addressLine = v
|
||||
}
|
||||
|
||||
if (body.comment !== undefined) {
|
||||
const commentRaw = body.comment
|
||||
const comment = commentRaw === null || commentRaw === undefined ? null : String(commentRaw).trim()
|
||||
if (comment !== null && comment.length > 200) return reply.code(400).send({ error: 'Комментарий максимум 200 символов' })
|
||||
data.comment = comment && comment.length ? comment : null
|
||||
}
|
||||
|
||||
if (body.lat !== undefined) {
|
||||
const lat = Number(body.lat)
|
||||
if (!Number.isFinite(lat) || lat < -90 || lat > 90) return reply.code(400).send({ error: 'Некорректная широта' })
|
||||
data.lat = lat
|
||||
}
|
||||
|
||||
if (body.lng !== undefined) {
|
||||
const lng = Number(body.lng)
|
||||
if (!Number.isFinite(lng) || lng < -180 || lng > 180) return reply.code(400).send({ error: 'Некорректная долгота' })
|
||||
data.lng = lng
|
||||
}
|
||||
|
||||
const setDefault = body.isDefault === true
|
||||
const updated = await prisma.$transaction(async (tx) => {
|
||||
if (setDefault) {
|
||||
await tx.shippingAddress.updateMany({ where: { userId, isDefault: true }, data: { isDefault: false } })
|
||||
}
|
||||
return tx.shippingAddress.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...data,
|
||||
...(setDefault ? { isDefault: true } : {}),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
return { item: updated }
|
||||
},
|
||||
)
|
||||
|
||||
fastify.delete(
|
||||
'/api/me/addresses/:id',
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const { id } = request.params
|
||||
const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } })
|
||||
if (!existing) return reply.code(404).send({ error: 'Адрес не найден' })
|
||||
|
||||
await prisma.shippingAddress.delete({ where: { id } })
|
||||
return reply.code(204).send()
|
||||
},
|
||||
)
|
||||
|
||||
fastify.post(
|
||||
'/api/me/addresses/:id/default',
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const { id } = request.params
|
||||
const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } })
|
||||
if (!existing) return reply.code(404).send({ error: 'Адрес не найден' })
|
||||
|
||||
const updated = await prisma.$transaction(async (tx) => {
|
||||
const isDefault = Boolean(request.body?.isDefault)
|
||||
const created = await prisma.$transaction(async (tx) => {
|
||||
if (isDefault) {
|
||||
await tx.shippingAddress.updateMany({ where: { userId, isDefault: true }, data: { isDefault: false } })
|
||||
return tx.shippingAddress.update({ where: { id }, data: { isDefault: true } })
|
||||
}
|
||||
return tx.shippingAddress.create({
|
||||
data: {
|
||||
userId,
|
||||
...validated,
|
||||
isDefault,
|
||||
},
|
||||
})
|
||||
})
|
||||
return reply.code(201).send({ item: created })
|
||||
})
|
||||
|
||||
return { item: updated }
|
||||
},
|
||||
)
|
||||
fastify.patch('/api/me/addresses/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const { id } = request.params
|
||||
const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } })
|
||||
if (!existing) return reply.code(404).send({ error: 'Адрес не найден' })
|
||||
|
||||
const body = request.body ?? {}
|
||||
const data = {}
|
||||
|
||||
if (body.label !== undefined) {
|
||||
const labelRaw = body.label
|
||||
const label = labelRaw === null || labelRaw === undefined ? null : String(labelRaw).trim()
|
||||
if (label !== null && label.length > 40)
|
||||
return reply.code(400).send({ error: 'Метка адреса максимум 40 символов' })
|
||||
data.label = label && label.length ? label : null
|
||||
}
|
||||
|
||||
if (body.recipientName !== undefined) {
|
||||
const v = String(body.recipientName || '').trim()
|
||||
if (!v) return reply.code(400).send({ error: 'Укажите ФИО получателя' })
|
||||
if (v.length > 80) return reply.code(400).send({ error: 'ФИО получателя максимум 80 символов' })
|
||||
data.recipientName = v
|
||||
}
|
||||
|
||||
if (body.recipientPhone !== undefined) {
|
||||
const v = normalizePhoneLite(body.recipientPhone)
|
||||
if (!v) return reply.code(400).send({ error: 'Укажите телефон получателя' })
|
||||
if (!/^\+?\d{7,20}$/.test(v)) return reply.code(400).send({ error: 'Некорректный телефон получателя' })
|
||||
data.recipientPhone = v
|
||||
}
|
||||
|
||||
if (body.addressLine !== undefined) {
|
||||
const v = String(body.addressLine || '').trim()
|
||||
if (!v) return reply.code(400).send({ error: 'Укажите адрес' })
|
||||
if (v.length > 200) return reply.code(400).send({ error: 'Адрес максимум 200 символов' })
|
||||
data.addressLine = v
|
||||
}
|
||||
|
||||
if (body.comment !== undefined) {
|
||||
const commentRaw = body.comment
|
||||
const comment = commentRaw === null || commentRaw === undefined ? null : String(commentRaw).trim()
|
||||
if (comment !== null && comment.length > 200)
|
||||
return reply.code(400).send({ error: 'Комментарий максимум 200 символов' })
|
||||
data.comment = comment && comment.length ? comment : null
|
||||
}
|
||||
|
||||
if (body.lat !== undefined) {
|
||||
const lat = Number(body.lat)
|
||||
if (!Number.isFinite(lat) || lat < -90 || lat > 90) return reply.code(400).send({ error: 'Некорректная широта' })
|
||||
data.lat = lat
|
||||
}
|
||||
|
||||
if (body.lng !== undefined) {
|
||||
const lng = Number(body.lng)
|
||||
if (!Number.isFinite(lng) || lng < -180 || lng > 180)
|
||||
return reply.code(400).send({ error: 'Некорректная долгота' })
|
||||
data.lng = lng
|
||||
}
|
||||
|
||||
const setDefault = body.isDefault === true
|
||||
const updated = await prisma.$transaction(async (tx) => {
|
||||
if (setDefault) {
|
||||
await tx.shippingAddress.updateMany({ where: { userId, isDefault: true }, data: { isDefault: false } })
|
||||
}
|
||||
return tx.shippingAddress.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...data,
|
||||
...(setDefault ? { isDefault: true } : {}),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
return { item: updated }
|
||||
})
|
||||
|
||||
fastify.delete('/api/me/addresses/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const { id } = request.params
|
||||
const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } })
|
||||
if (!existing) return reply.code(404).send({ error: 'Адрес не найден' })
|
||||
|
||||
await prisma.shippingAddress.delete({ where: { id } })
|
||||
return reply.code(204).send()
|
||||
})
|
||||
|
||||
fastify.post('/api/me/addresses/:id/default', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const { id } = request.params
|
||||
const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } })
|
||||
if (!existing) return reply.code(404).send({ error: 'Адрес не найден' })
|
||||
|
||||
const updated = await prisma.$transaction(async (tx) => {
|
||||
await tx.shippingAddress.updateMany({ where: { userId, isDefault: true }, data: { isDefault: false } })
|
||||
return tx.shippingAddress.update({ where: { id }, data: { isDefault: true } })
|
||||
})
|
||||
|
||||
return { item: updated }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,92 +1,76 @@
|
||||
import { prisma } from '../lib/prisma.js'
|
||||
|
||||
export async function registerUserCartRoutes(fastify) {
|
||||
fastify.get(
|
||||
'/api/me/cart',
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request) => {
|
||||
const userId = request.user.sub
|
||||
const items = await prisma.cartItem.findMany({
|
||||
where: { userId },
|
||||
include: { product: { include: { category: true, images: { orderBy: { sort: 'asc' } } } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
return {
|
||||
items: items.map((x) => ({
|
||||
id: x.id,
|
||||
qty: x.qty,
|
||||
product: x.product,
|
||||
})),
|
||||
}
|
||||
},
|
||||
)
|
||||
fastify.get('/api/me/cart', { preHandler: [fastify.authenticate] }, async (request) => {
|
||||
const userId = request.user.sub
|
||||
const items = await prisma.cartItem.findMany({
|
||||
where: { userId },
|
||||
include: { product: { include: { category: true, images: { orderBy: { sort: 'asc' } } } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
return {
|
||||
items: items.map((x) => ({
|
||||
id: x.id,
|
||||
qty: x.qty,
|
||||
product: x.product,
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
fastify.post(
|
||||
'/api/me/cart/items',
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const productId = String(request.body?.productId || '').trim()
|
||||
const qtyRaw = request.body?.qty
|
||||
const qty = qtyRaw === undefined || qtyRaw === null || qtyRaw === '' ? 1 : Number(qtyRaw)
|
||||
fastify.post('/api/me/cart/items', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const productId = String(request.body?.productId || '').trim()
|
||||
const qtyRaw = request.body?.qty
|
||||
const qty = qtyRaw === undefined || qtyRaw === null || qtyRaw === '' ? 1 : Number(qtyRaw)
|
||||
|
||||
if (!productId) return reply.code(400).send({ error: 'productId обязателен' })
|
||||
if (!Number.isFinite(qty) || qty <= 0) return reply.code(400).send({ error: 'qty должен быть > 0' })
|
||||
if (!productId) return reply.code(400).send({ error: 'productId обязателен' })
|
||||
if (!Number.isFinite(qty) || qty <= 0) return reply.code(400).send({ error: 'qty должен быть > 0' })
|
||||
|
||||
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 available = product.inStock ? product.quantity : 1
|
||||
const existing = await prisma.cartItem.findUnique({ where: { userId_productId: { userId, productId } } })
|
||||
const nextQty = (existing?.qty ?? 0) + Math.floor(qty)
|
||||
if (nextQty > available) return reply.code(409).send({ error: `Доступно: ${available} шт.` })
|
||||
const available = product.inStock ? product.quantity : 1
|
||||
const existing = await prisma.cartItem.findUnique({ where: { userId_productId: { userId, productId } } })
|
||||
const nextQty = (existing?.qty ?? 0) + Math.floor(qty)
|
||||
if (nextQty > available) return reply.code(409).send({ error: `Доступно: ${available} шт.` })
|
||||
|
||||
const item = await prisma.cartItem.upsert({
|
||||
where: { userId_productId: { userId, productId } },
|
||||
update: { qty: nextQty },
|
||||
create: { userId, productId, qty: nextQty },
|
||||
})
|
||||
return reply.code(201).send({ item })
|
||||
},
|
||||
)
|
||||
const item = await prisma.cartItem.upsert({
|
||||
where: { userId_productId: { userId, productId } },
|
||||
update: { qty: nextQty },
|
||||
create: { userId, productId, qty: nextQty },
|
||||
})
|
||||
return reply.code(201).send({ item })
|
||||
})
|
||||
|
||||
fastify.patch(
|
||||
'/api/me/cart/items/:id',
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const { id } = request.params
|
||||
const qtyRaw = request.body?.qty
|
||||
const qty = Number(qtyRaw)
|
||||
if (!Number.isFinite(qty) || qty < 0) return reply.code(400).send({ error: 'qty должен быть ≥ 0' })
|
||||
fastify.patch('/api/me/cart/items/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const { id } = request.params
|
||||
const qtyRaw = request.body?.qty
|
||||
const qty = Number(qtyRaw)
|
||||
if (!Number.isFinite(qty) || qty < 0) return reply.code(400).send({ error: 'qty должен быть ≥ 0' })
|
||||
|
||||
const existing = await prisma.cartItem.findFirst({ where: { id, userId }, include: { product: true } })
|
||||
if (!existing) return reply.code(404).send({ error: 'Позиция корзины не найдена' })
|
||||
const existing = await prisma.cartItem.findFirst({ where: { id, userId }, include: { product: true } })
|
||||
if (!existing) return reply.code(404).send({ error: 'Позиция корзины не найдена' })
|
||||
|
||||
if (qty === 0) {
|
||||
await prisma.cartItem.delete({ where: { id } })
|
||||
return reply.code(204).send()
|
||||
}
|
||||
|
||||
const available = existing.product.inStock ? existing.product.quantity : 1
|
||||
const nextQty = Math.floor(qty)
|
||||
if (nextQty > available) return reply.code(409).send({ error: `Доступно: ${available} шт.` })
|
||||
|
||||
const updated = await prisma.cartItem.update({ where: { id }, data: { qty: nextQty } })
|
||||
return { item: updated }
|
||||
},
|
||||
)
|
||||
|
||||
fastify.delete(
|
||||
'/api/me/cart/items/:id',
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const { id } = request.params
|
||||
const existing = await prisma.cartItem.findFirst({ where: { id, userId } })
|
||||
if (!existing) return reply.code(404).send({ error: 'Позиция корзины не найдена' })
|
||||
if (qty === 0) {
|
||||
await prisma.cartItem.delete({ where: { id } })
|
||||
return reply.code(204).send()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const available = existing.product.inStock ? existing.product.quantity : 1
|
||||
const nextQty = Math.floor(qty)
|
||||
if (nextQty > available) return reply.code(409).send({ error: `Доступно: ${available} шт.` })
|
||||
|
||||
const updated = await prisma.cartItem.update({ where: { id }, data: { qty: nextQty } })
|
||||
return { item: updated }
|
||||
})
|
||||
|
||||
fastify.delete('/api/me/cart/items/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const { id } = request.params
|
||||
const existing = await prisma.cartItem.findFirst({ where: { id, userId } })
|
||||
if (!existing) return reply.code(404).send({ error: 'Позиция корзины не найдена' })
|
||||
await prisma.cartItem.delete({ where: { id } })
|
||||
return reply.code(204).send()
|
||||
})
|
||||
}
|
||||
|
||||
+115
-143
@@ -1,155 +1,127 @@
|
||||
import { prisma } from "../lib/prisma.js";
|
||||
import { NOTIFICATION_EVENTS } from "../../../shared/constants/notification-events.js";
|
||||
import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js'
|
||||
import { prisma } from '../lib/prisma.js'
|
||||
|
||||
export async function registerUserMessageRoutes(fastify) {
|
||||
fastify.get(
|
||||
"/api/me/orders/:id/messages",
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user.sub;
|
||||
const { id } = request.params;
|
||||
const order = await prisma.order.findFirst({ where: { id, userId } });
|
||||
if (!order) return reply.code(404).send({ error: "Заказ не найден" });
|
||||
const items = await prisma.orderMessage.findMany({
|
||||
where: { orderId: id },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
return { items };
|
||||
},
|
||||
);
|
||||
fastify.get('/api/me/orders/:id/messages', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const { id } = request.params
|
||||
const order = await prisma.order.findFirst({ where: { id, userId } })
|
||||
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||
const items = await prisma.orderMessage.findMany({
|
||||
where: { orderId: id },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
return { items }
|
||||
})
|
||||
|
||||
fastify.post(
|
||||
"/api/me/orders/:id/messages",
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user.sub;
|
||||
const { id } = request.params;
|
||||
const order = await prisma.order.findFirst({ where: { id, userId } });
|
||||
if (!order) return reply.code(404).send({ error: "Заказ не найден" });
|
||||
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 msg = await prisma.orderMessage.create({
|
||||
data: { orderId: id, authorType: "user", text },
|
||||
});
|
||||
fastify.post('/api/me/orders/:id/messages', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const { id } = request.params
|
||||
const order = await prisma.order.findFirst({ where: { id, userId } })
|
||||
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||
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 msg = await prisma.orderMessage.create({
|
||||
data: { orderId: id, authorType: 'user', text },
|
||||
})
|
||||
|
||||
request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_MESSAGE_SENT, {
|
||||
orderId: id,
|
||||
authorType: "user",
|
||||
messageId: msg.id,
|
||||
preview: text,
|
||||
});
|
||||
request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_MESSAGE_SENT, {
|
||||
orderId: id,
|
||||
authorType: 'user',
|
||||
messageId: msg.id,
|
||||
preview: text,
|
||||
})
|
||||
|
||||
return reply.code(201).send({ item: msg });
|
||||
},
|
||||
);
|
||||
return reply.code(201).send({ item: msg })
|
||||
})
|
||||
|
||||
fastify.get(
|
||||
"/api/me/messages/unread-count",
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request) => {
|
||||
const userId = request.user.sub;
|
||||
const orders = await prisma.order.findMany({
|
||||
where: { userId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (orders.length === 0) return { count: 0 };
|
||||
fastify.get('/api/me/messages/unread-count', { preHandler: [fastify.authenticate] }, async (request) => {
|
||||
const userId = request.user.sub
|
||||
const orders = await prisma.order.findMany({
|
||||
where: { userId },
|
||||
select: { id: true },
|
||||
})
|
||||
if (orders.length === 0) return { count: 0 }
|
||||
|
||||
const readStates = await prisma.userOrderMessageReadState.findMany({
|
||||
where: { userId },
|
||||
});
|
||||
const lastReadByOrder = new Map(
|
||||
readStates.map((r) => [r.orderId, r.lastReadAt]),
|
||||
);
|
||||
const readStates = await prisma.userOrderMessageReadState.findMany({
|
||||
where: { userId },
|
||||
})
|
||||
const lastReadByOrder = new Map(readStates.map((r) => [r.orderId, r.lastReadAt]))
|
||||
|
||||
let count = 0;
|
||||
for (const o of orders) {
|
||||
const lastRead = lastReadByOrder.get(o.id) ?? new Date(0);
|
||||
const n = await prisma.orderMessage.count({
|
||||
where: {
|
||||
orderId: o.id,
|
||||
authorType: "admin",
|
||||
createdAt: { gt: lastRead },
|
||||
},
|
||||
});
|
||||
count += n;
|
||||
}
|
||||
return { count };
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
"/api/me/conversations",
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request) => {
|
||||
const userId = request.user.sub;
|
||||
const orders = await prisma.order.findMany({
|
||||
where: { userId, messages: { some: {} } },
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
deliveryType: true,
|
||||
messages: {
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 1,
|
||||
select: { text: true, createdAt: true },
|
||||
},
|
||||
},
|
||||
orderBy: { updatedAt: "desc" },
|
||||
});
|
||||
|
||||
const readStates = await prisma.userOrderMessageReadState.findMany({
|
||||
where: { userId },
|
||||
});
|
||||
const lastReadByOrder = new Map(
|
||||
readStates.map((r) => [r.orderId, r.lastReadAt]),
|
||||
);
|
||||
|
||||
const items = [];
|
||||
for (const o of orders) {
|
||||
const lastMsg = o.messages[0];
|
||||
if (!lastMsg) continue;
|
||||
const lastRead = lastReadByOrder.get(o.id) ?? new Date(0);
|
||||
const unreadCount = await prisma.orderMessage.count({
|
||||
where: {
|
||||
orderId: o.id,
|
||||
authorType: "admin",
|
||||
createdAt: { gt: lastRead },
|
||||
},
|
||||
});
|
||||
items.push({
|
||||
let count = 0
|
||||
for (const o of orders) {
|
||||
const lastRead = lastReadByOrder.get(o.id) ?? new Date(0)
|
||||
const n = await prisma.orderMessage.count({
|
||||
where: {
|
||||
orderId: o.id,
|
||||
status: o.status,
|
||||
deliveryType: o.deliveryType,
|
||||
lastMessageAt: lastMsg.createdAt,
|
||||
preview:
|
||||
lastMsg.text.length > 280
|
||||
? `${lastMsg.text.slice(0, 277)}…`
|
||||
: lastMsg.text,
|
||||
unreadCount,
|
||||
});
|
||||
}
|
||||
return { items };
|
||||
},
|
||||
);
|
||||
authorType: 'admin',
|
||||
createdAt: { gt: lastRead },
|
||||
},
|
||||
})
|
||||
count += n
|
||||
}
|
||||
return { count }
|
||||
})
|
||||
|
||||
fastify.post(
|
||||
"/api/me/orders/:id/messages/read",
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user.sub;
|
||||
const { id } = request.params;
|
||||
const order = await prisma.order.findFirst({ where: { id, userId } });
|
||||
if (!order) return reply.code(404).send({ error: "Заказ не найден" });
|
||||
fastify.get('/api/me/conversations', { preHandler: [fastify.authenticate] }, async (request) => {
|
||||
const userId = request.user.sub
|
||||
const orders = await prisma.order.findMany({
|
||||
where: { userId, messages: { some: {} } },
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
deliveryType: true,
|
||||
messages: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 1,
|
||||
select: { text: true, createdAt: true },
|
||||
},
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
})
|
||||
|
||||
const now = new Date();
|
||||
await prisma.userOrderMessageReadState.upsert({
|
||||
where: { userId_orderId: { userId, orderId: id } },
|
||||
create: { userId, orderId: id, lastReadAt: now },
|
||||
update: { lastReadAt: now },
|
||||
});
|
||||
return { ok: true };
|
||||
},
|
||||
);
|
||||
const readStates = await prisma.userOrderMessageReadState.findMany({
|
||||
where: { userId },
|
||||
})
|
||||
const lastReadByOrder = new Map(readStates.map((r) => [r.orderId, r.lastReadAt]))
|
||||
|
||||
const items = []
|
||||
for (const o of orders) {
|
||||
const lastMsg = o.messages[0]
|
||||
if (!lastMsg) continue
|
||||
const lastRead = lastReadByOrder.get(o.id) ?? new Date(0)
|
||||
const unreadCount = await prisma.orderMessage.count({
|
||||
where: {
|
||||
orderId: o.id,
|
||||
authorType: 'admin',
|
||||
createdAt: { gt: lastRead },
|
||||
},
|
||||
})
|
||||
items.push({
|
||||
orderId: o.id,
|
||||
status: o.status,
|
||||
deliveryType: o.deliveryType,
|
||||
lastMessageAt: lastMsg.createdAt,
|
||||
preview: lastMsg.text.length > 280 ? `${lastMsg.text.slice(0, 277)}…` : lastMsg.text,
|
||||
unreadCount,
|
||||
})
|
||||
}
|
||||
return { items }
|
||||
})
|
||||
|
||||
fastify.post('/api/me/orders/:id/messages/read', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const { id } = request.params
|
||||
const order = await prisma.order.findFirst({ where: { id, userId } })
|
||||
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||
|
||||
const now = new Date()
|
||||
await prisma.userOrderMessageReadState.upsert({
|
||||
where: { userId_orderId: { userId, orderId: id } },
|
||||
create: { userId, orderId: id, lastReadAt: now },
|
||||
update: { lastReadAt: now },
|
||||
})
|
||||
return { ok: true }
|
||||
})
|
||||
}
|
||||
|
||||
+214
-255
@@ -1,312 +1,271 @@
|
||||
import { isDeliveryCarrier } from "../lib/delivery-carrier.js";
|
||||
import { prisma } from "../lib/prisma.js";
|
||||
import { NOTIFICATION_EVENTS } from "../../../shared/constants/notification-events.js";
|
||||
import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js'
|
||||
import { isDeliveryCarrier } from '../lib/delivery-carrier.js'
|
||||
import { prisma } from '../lib/prisma.js'
|
||||
|
||||
export async function registerUserOrderRoutes(fastify) {
|
||||
// ---- Создание заказа (checkout) ----
|
||||
|
||||
fastify.post(
|
||||
"/api/me/orders",
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user.sub;
|
||||
const deliveryTypeRaw = request.body?.deliveryType;
|
||||
const deliveryType =
|
||||
deliveryTypeRaw === undefined ||
|
||||
deliveryTypeRaw === null ||
|
||||
deliveryTypeRaw === ""
|
||||
? "delivery"
|
||||
: String(deliveryTypeRaw).trim();
|
||||
fastify.post('/api/me/orders', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const deliveryTypeRaw = request.body?.deliveryType
|
||||
const deliveryType =
|
||||
deliveryTypeRaw === undefined || deliveryTypeRaw === null || deliveryTypeRaw === ''
|
||||
? 'delivery'
|
||||
: String(deliveryTypeRaw).trim()
|
||||
|
||||
const addressId = String(request.body?.addressId || "").trim();
|
||||
const commentRaw = request.body?.comment;
|
||||
const comment =
|
||||
commentRaw === null || commentRaw === undefined
|
||||
? null
|
||||
: String(commentRaw).trim();
|
||||
const addressId = String(request.body?.addressId || '').trim()
|
||||
const commentRaw = request.body?.comment
|
||||
const comment = commentRaw === null || commentRaw === undefined ? null : String(commentRaw).trim()
|
||||
|
||||
const paymentMethodRaw = request.body?.paymentMethod;
|
||||
const paymentMethod =
|
||||
paymentMethodRaw === undefined ||
|
||||
paymentMethodRaw === null ||
|
||||
paymentMethodRaw === ""
|
||||
? "online"
|
||||
: String(paymentMethodRaw).trim();
|
||||
if (paymentMethod !== "online" && paymentMethod !== "on_pickup") {
|
||||
return reply
|
||||
.code(400)
|
||||
.send({ error: "paymentMethod должен быть online | on_pickup" });
|
||||
const paymentMethodRaw = request.body?.paymentMethod
|
||||
const paymentMethod =
|
||||
paymentMethodRaw === undefined || paymentMethodRaw === null || paymentMethodRaw === ''
|
||||
? 'online'
|
||||
: String(paymentMethodRaw).trim()
|
||||
if (paymentMethod !== 'online' && paymentMethod !== 'on_pickup') {
|
||||
return reply.code(400).send({ error: 'paymentMethod должен быть online | on_pickup' })
|
||||
}
|
||||
|
||||
if (deliveryType !== 'delivery' && deliveryType !== 'pickup') {
|
||||
return reply.code(400).send({ error: 'deliveryType должен быть delivery | pickup' })
|
||||
}
|
||||
|
||||
const carrierRaw = request.body?.deliveryCarrier
|
||||
let deliveryCarrier = null
|
||||
if (deliveryType === 'delivery') {
|
||||
const carrierStr =
|
||||
carrierRaw === undefined || carrierRaw === null || carrierRaw === '' ? '' : String(carrierRaw).trim()
|
||||
if (!isDeliveryCarrier(carrierStr)) {
|
||||
return reply.code(400).send({
|
||||
error: 'deliveryCarrier обязателен для доставки: RUSSIAN_POST | OZON_PVZ | YANDEX_PVZ | FIVE_POST',
|
||||
})
|
||||
}
|
||||
deliveryCarrier = carrierStr
|
||||
}
|
||||
|
||||
if (deliveryType !== "delivery" && deliveryType !== "pickup") {
|
||||
return reply
|
||||
.code(400)
|
||||
.send({ error: "deliveryType должен быть delivery | pickup" });
|
||||
if (paymentMethod === 'on_pickup' && deliveryType !== 'pickup') {
|
||||
return reply.code(400).send({
|
||||
error: 'Оплата при получении доступна только для самовывоза',
|
||||
})
|
||||
}
|
||||
|
||||
let address = null
|
||||
if (deliveryType === 'delivery') {
|
||||
if (!addressId) return reply.code(400).send({ error: 'Выберите адрес доставки' })
|
||||
address = await prisma.shippingAddress.findFirst({
|
||||
where: { id: addressId, userId },
|
||||
})
|
||||
if (!address) return reply.code(404).send({ error: 'Адрес не найден' })
|
||||
}
|
||||
|
||||
const cartItems = await prisma.cartItem.findMany({
|
||||
where: { userId },
|
||||
include: { product: true },
|
||||
})
|
||||
if (cartItems.length === 0) return reply.code(400).send({ error: 'Корзина пуста' })
|
||||
|
||||
for (const ci of cartItems) {
|
||||
const available = ci.product.inStock ? ci.product.quantity : 1
|
||||
if (ci.qty > available) {
|
||||
return reply.code(409).send({
|
||||
error: `Недостаточно товара: "${ci.product.title}". Доступно: ${available} шт.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const carrierRaw = request.body?.deliveryCarrier;
|
||||
let deliveryCarrier = null;
|
||||
if (deliveryType === "delivery") {
|
||||
const carrierStr =
|
||||
carrierRaw === undefined || carrierRaw === null || carrierRaw === ""
|
||||
? ""
|
||||
: String(carrierRaw).trim();
|
||||
if (!isDeliveryCarrier(carrierStr)) {
|
||||
return reply.code(400).send({
|
||||
error:
|
||||
"deliveryCarrier обязателен для доставки: RUSSIAN_POST | OZON_PVZ | YANDEX_PVZ | FIVE_POST",
|
||||
});
|
||||
}
|
||||
deliveryCarrier = carrierStr;
|
||||
}
|
||||
const itemsPayload = cartItems.map((ci) => ({
|
||||
productId: ci.productId,
|
||||
qty: ci.qty,
|
||||
titleSnapshot: ci.product.title,
|
||||
priceCentsSnapshot: ci.product.priceCents,
|
||||
}))
|
||||
|
||||
if (paymentMethod === "on_pickup" && deliveryType !== "pickup") {
|
||||
return reply
|
||||
.code(400)
|
||||
.send({
|
||||
error: "Оплата при получении доступна только для самовывоза",
|
||||
});
|
||||
}
|
||||
const itemsSubtotalCents = itemsPayload.reduce((sum, i) => sum + i.priceCentsSnapshot * i.qty, 0)
|
||||
const deliveryFeeCents = deliveryType === 'delivery' ? 50000 : 0
|
||||
const totalCents = itemsSubtotalCents + deliveryFeeCents
|
||||
|
||||
let address = null;
|
||||
if (deliveryType === "delivery") {
|
||||
if (!addressId)
|
||||
return reply.code(400).send({ error: "Выберите адрес доставки" });
|
||||
address = await prisma.shippingAddress.findFirst({
|
||||
where: { id: addressId, userId },
|
||||
});
|
||||
if (!address) return reply.code(404).send({ error: "Адрес не найден" });
|
||||
}
|
||||
const addressSnapshotJson =
|
||||
deliveryType === 'pickup'
|
||||
? JSON.stringify({ deliveryType: 'pickup' })
|
||||
: JSON.stringify({
|
||||
deliveryType: 'delivery',
|
||||
id: address.id,
|
||||
label: address.label,
|
||||
recipientName: address.recipientName,
|
||||
recipientPhone: address.recipientPhone,
|
||||
addressLine: address.addressLine,
|
||||
comment: address.comment,
|
||||
lat: address.lat,
|
||||
lng: address.lng,
|
||||
})
|
||||
|
||||
const cartItems = await prisma.cartItem.findMany({
|
||||
where: { userId },
|
||||
include: { product: true },
|
||||
});
|
||||
if (cartItems.length === 0)
|
||||
return reply.code(400).send({ error: "Корзина пуста" });
|
||||
let initialStatus = 'PENDING_PAYMENT'
|
||||
let deliveryFeeLocked = true
|
||||
if (paymentMethod === 'on_pickup') {
|
||||
initialStatus = 'IN_PROGRESS'
|
||||
} else if (deliveryType === 'delivery') {
|
||||
initialStatus = 'PENDING_PAYMENT'
|
||||
deliveryFeeLocked = false
|
||||
}
|
||||
|
||||
for (const ci of cartItems) {
|
||||
const available = ci.product.inStock ? ci.product.quantity : 1;
|
||||
if (ci.qty > available) {
|
||||
return reply
|
||||
.code(409)
|
||||
.send({
|
||||
error: `Недостаточно товара: "${ci.product.title}". Доступно: ${available} шт.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
let created
|
||||
try {
|
||||
created = await prisma.$transaction(async (tx) => {
|
||||
for (const ci of cartItems) {
|
||||
if (!ci.product.inStock) continue
|
||||
|
||||
const itemsPayload = cartItems.map((ci) => ({
|
||||
productId: ci.productId,
|
||||
qty: ci.qty,
|
||||
titleSnapshot: ci.product.title,
|
||||
priceCentsSnapshot: ci.product.priceCents,
|
||||
}));
|
||||
|
||||
const itemsSubtotalCents = itemsPayload.reduce(
|
||||
(sum, i) => sum + i.priceCentsSnapshot * i.qty,
|
||||
0,
|
||||
);
|
||||
const deliveryFeeCents = deliveryType === "delivery" ? 50000 : 0;
|
||||
const totalCents = itemsSubtotalCents + deliveryFeeCents;
|
||||
|
||||
const addressSnapshotJson =
|
||||
deliveryType === "pickup"
|
||||
? JSON.stringify({ deliveryType: "pickup" })
|
||||
: JSON.stringify({
|
||||
deliveryType: "delivery",
|
||||
id: address.id,
|
||||
label: address.label,
|
||||
recipientName: address.recipientName,
|
||||
recipientPhone: address.recipientPhone,
|
||||
addressLine: address.addressLine,
|
||||
comment: address.comment,
|
||||
lat: address.lat,
|
||||
lng: address.lng,
|
||||
});
|
||||
|
||||
let initialStatus = "PENDING_PAYMENT";
|
||||
let deliveryFeeLocked = true;
|
||||
if (paymentMethod === "on_pickup") {
|
||||
initialStatus = "IN_PROGRESS";
|
||||
} else if (deliveryType === "delivery") {
|
||||
initialStatus = "PENDING_PAYMENT";
|
||||
deliveryFeeLocked = false;
|
||||
}
|
||||
|
||||
let created;
|
||||
try {
|
||||
created = await prisma.$transaction(async (tx) => {
|
||||
for (const ci of cartItems) {
|
||||
if (!ci.product.inStock) continue;
|
||||
|
||||
const res = await tx.product.updateMany({
|
||||
where: { id: ci.productId, quantity: { gte: ci.qty } },
|
||||
data: { quantity: { decrement: ci.qty } },
|
||||
});
|
||||
if (res.count !== 1) {
|
||||
throw new Error(`Недостаточно товара: "${ci.product.title}"`);
|
||||
}
|
||||
const res = await tx.product.updateMany({
|
||||
where: { id: ci.productId, quantity: { gte: ci.qty } },
|
||||
data: { quantity: { decrement: ci.qty } },
|
||||
})
|
||||
if (res.count !== 1) {
|
||||
throw new Error(`Недостаточно товара: "${ci.product.title}"`)
|
||||
}
|
||||
}
|
||||
|
||||
const order = await tx.order.create({
|
||||
data: {
|
||||
userId,
|
||||
status: initialStatus,
|
||||
deliveryFeeLocked,
|
||||
deliveryType,
|
||||
deliveryCarrier,
|
||||
paymentMethod,
|
||||
itemsSubtotalCents,
|
||||
deliveryFeeCents,
|
||||
totalCents,
|
||||
currency: "RUB",
|
||||
addressSnapshotJson,
|
||||
comment: comment && comment.length ? comment : null,
|
||||
items: {
|
||||
create: itemsPayload.map((i) => ({
|
||||
productId: i.productId,
|
||||
qty: i.qty,
|
||||
titleSnapshot: i.titleSnapshot,
|
||||
priceCentsSnapshot: i.priceCentsSnapshot,
|
||||
})),
|
||||
},
|
||||
const order = await tx.order.create({
|
||||
data: {
|
||||
userId,
|
||||
status: initialStatus,
|
||||
deliveryFeeLocked,
|
||||
deliveryType,
|
||||
deliveryCarrier,
|
||||
paymentMethod,
|
||||
itemsSubtotalCents,
|
||||
deliveryFeeCents,
|
||||
totalCents,
|
||||
currency: 'RUB',
|
||||
addressSnapshotJson,
|
||||
comment: comment && comment.length ? comment : null,
|
||||
items: {
|
||||
create: itemsPayload.map((i) => ({
|
||||
productId: i.productId,
|
||||
qty: i.qty,
|
||||
titleSnapshot: i.titleSnapshot,
|
||||
priceCentsSnapshot: i.priceCentsSnapshot,
|
||||
})),
|
||||
},
|
||||
});
|
||||
await tx.cartItem.deleteMany({ where: { userId } });
|
||||
return order;
|
||||
});
|
||||
} catch (e) {
|
||||
return reply
|
||||
.code(409)
|
||||
.send({
|
||||
error: (e instanceof Error && e.message) || "Недостаточно товара",
|
||||
});
|
||||
}
|
||||
},
|
||||
})
|
||||
await tx.cartItem.deleteMany({ where: { userId } })
|
||||
return order
|
||||
})
|
||||
} catch (e) {
|
||||
return reply.code(409).send({
|
||||
error: (e instanceof Error && e.message) || 'Недостаточно товара',
|
||||
})
|
||||
}
|
||||
|
||||
// Emit notification events
|
||||
request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_CREATED, {
|
||||
orderId: created.id,
|
||||
userId,
|
||||
totalCents: created.totalCents,
|
||||
itemsCount: cartItems.length,
|
||||
deliveryType: created.deliveryType,
|
||||
});
|
||||
// Emit notification events
|
||||
request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_CREATED, {
|
||||
orderId: created.id,
|
||||
userId,
|
||||
totalCents: created.totalCents,
|
||||
itemsCount: cartItems.length,
|
||||
deliveryType: created.deliveryType,
|
||||
})
|
||||
|
||||
// Also emit admin notification
|
||||
request.server.eventBus.emit("order:created:admin", {
|
||||
orderId: created.id,
|
||||
userId,
|
||||
userEmail: request.user.email || "",
|
||||
totalCents: created.totalCents,
|
||||
itemsCount: cartItems.length,
|
||||
deliveryType: created.deliveryType,
|
||||
});
|
||||
// Also emit admin notification
|
||||
request.server.eventBus.emit('order:created:admin', {
|
||||
orderId: created.id,
|
||||
userId,
|
||||
userEmail: request.user.email || '',
|
||||
totalCents: created.totalCents,
|
||||
itemsCount: cartItems.length,
|
||||
deliveryType: created.deliveryType,
|
||||
})
|
||||
|
||||
return reply.code(201).send({ orderId: created.id });
|
||||
},
|
||||
);
|
||||
return reply.code(201).send({ orderId: created.id })
|
||||
})
|
||||
|
||||
fastify.get('/api/me/orders', { preHandler: [fastify.authenticate] }, async (request) => {
|
||||
const userId = request.user.sub
|
||||
const orders = await prisma.order.findMany({
|
||||
where: { userId },
|
||||
include: { items: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
return {
|
||||
items: orders.map((o) => ({
|
||||
id: o.id,
|
||||
status: o.status,
|
||||
totalCents: o.totalCents,
|
||||
currency: o.currency,
|
||||
createdAt: o.createdAt,
|
||||
updatedAt: o.updatedAt,
|
||||
itemsCount: o.items.reduce((s, i) => s + i.qty, 0),
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
fastify.get('/api/me/orders/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const { id } = request.params
|
||||
const order = await prisma.order.findFirst({
|
||||
where: { id, userId },
|
||||
include: { items: true, messages: { orderBy: { createdAt: 'asc' } } },
|
||||
})
|
||||
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||
return { item: order }
|
||||
})
|
||||
|
||||
fastify.get(
|
||||
"/api/me/orders",
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request) => {
|
||||
const userId = request.user.sub;
|
||||
const orders = await prisma.order.findMany({
|
||||
where: { userId },
|
||||
include: { items: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
return {
|
||||
items: orders.map((o) => ({
|
||||
id: o.id,
|
||||
status: o.status,
|
||||
totalCents: o.totalCents,
|
||||
currency: o.currency,
|
||||
createdAt: o.createdAt,
|
||||
updatedAt: o.updatedAt,
|
||||
itemsCount: o.items.reduce((s, i) => s + i.qty, 0),
|
||||
})),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
"/api/me/orders/:id",
|
||||
'/api/me/orders/:id/review-eligibility',
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user.sub;
|
||||
const { id } = request.params;
|
||||
const order = await prisma.order.findFirst({
|
||||
where: { id, userId },
|
||||
include: { items: true, messages: { orderBy: { createdAt: "asc" } } },
|
||||
});
|
||||
if (!order) return reply.code(404).send({ error: "Заказ не найден" });
|
||||
return { item: order };
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
"/api/me/orders/:id/review-eligibility",
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user.sub;
|
||||
const { id } = request.params;
|
||||
const userId = request.user.sub
|
||||
const { id } = request.params
|
||||
const order = await prisma.order.findFirst({
|
||||
where: { id, userId },
|
||||
include: { items: true },
|
||||
});
|
||||
if (!order) return reply.code(404).send({ error: "Заказ не найден" });
|
||||
if (order.status !== "DONE") {
|
||||
return { canReview: false, items: [] };
|
||||
})
|
||||
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||
if (order.status !== 'DONE') {
|
||||
return { canReview: false, items: [] }
|
||||
}
|
||||
|
||||
const uniq = new Map();
|
||||
const uniq = new Map()
|
||||
for (const it of order.items) {
|
||||
if (!uniq.has(it.productId)) {
|
||||
uniq.set(it.productId, {
|
||||
productId: it.productId,
|
||||
title: it.titleSnapshot,
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
const productIds = [...uniq.keys()];
|
||||
const productIds = [...uniq.keys()]
|
||||
const existing = await prisma.review.findMany({
|
||||
where: { userId, productId: { in: productIds } },
|
||||
select: { productId: true },
|
||||
});
|
||||
const reviewed = new Set(existing.map((r) => r.productId));
|
||||
})
|
||||
const reviewed = new Set(existing.map((r) => r.productId))
|
||||
return {
|
||||
canReview: true,
|
||||
items: [...uniq.values()].map((x) => ({
|
||||
...x,
|
||||
hasReview: reviewed.has(x.productId),
|
||||
})),
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
fastify.post(
|
||||
"/api/me/orders/:id/confirm-received",
|
||||
'/api/me/orders/:id/confirm-received',
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user.sub;
|
||||
const { id } = request.params;
|
||||
const order = await prisma.order.findFirst({ where: { id, userId } });
|
||||
if (!order) return reply.code(404).send({ error: "Заказ не найден" });
|
||||
const userId = request.user.sub
|
||||
const { id } = request.params
|
||||
const order = await prisma.order.findFirst({ where: { id, userId } })
|
||||
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||
|
||||
const okDelivery =
|
||||
order.deliveryType === "delivery" && order.status === "SHIPPED";
|
||||
const okPickup =
|
||||
order.deliveryType === "pickup" && order.status === "READY_FOR_PICKUP";
|
||||
const okDelivery = order.deliveryType === 'delivery' && order.status === 'SHIPPED'
|
||||
const okPickup = order.deliveryType === 'pickup' && order.status === 'READY_FOR_PICKUP'
|
||||
if (!okDelivery && !okPickup) {
|
||||
return reply
|
||||
.code(409)
|
||||
.send({ error: "Сейчас нельзя подтвердить получение заказа" });
|
||||
return reply.code(409).send({ error: 'Сейчас нельзя подтвердить получение заказа' })
|
||||
}
|
||||
|
||||
await prisma.order.update({ where: { id }, data: { status: "DONE" } });
|
||||
return { ok: true, status: "DONE" };
|
||||
await prisma.order.update({ where: { id }, data: { status: 'DONE' } })
|
||||
return { ok: true, status: 'DONE' }
|
||||
},
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,142 +1,114 @@
|
||||
import { prisma } from "../lib/prisma.js";
|
||||
import { escapeHtml } from "../lib/escape-html.js";
|
||||
import { getOtherUploadMaxFileBytes } from "../lib/upload-limits.js";
|
||||
import { saveImageBufferToUploads } from "../lib/upload-images.js";
|
||||
import { NOTIFICATION_EVENTS } from "../../../shared/constants/notification-events.js";
|
||||
import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js'
|
||||
import { escapeHtml } from '../lib/escape-html.js'
|
||||
import { prisma } from '../lib/prisma.js'
|
||||
import { saveImageBufferToUploads } from '../lib/upload-images.js'
|
||||
import { getOtherUploadMaxFileBytes } from '../lib/upload-limits.js'
|
||||
|
||||
export async function registerUserPaymentRoutes(fastify) {
|
||||
fastify.post(
|
||||
"/api/me/orders/:id/pay",
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user.sub;
|
||||
const { id } = request.params;
|
||||
const order = await prisma.order.findFirst({ where: { id, userId } });
|
||||
if (!order) return reply.code(404).send({ error: "Заказ не найден" });
|
||||
fastify.post('/api/me/orders/:id/pay', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const { id } = request.params
|
||||
const order = await prisma.order.findFirst({ where: { id, userId } })
|
||||
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||
|
||||
const paymentMethod = order.paymentMethod ?? "online";
|
||||
if (paymentMethod === "on_pickup") {
|
||||
return reply
|
||||
.code(409)
|
||||
.send({
|
||||
error:
|
||||
"Для этого заказа оплата при получении — кнопка оплаты не нужна.",
|
||||
});
|
||||
}
|
||||
const paymentMethod = order.paymentMethod ?? 'online'
|
||||
if (paymentMethod === 'on_pickup') {
|
||||
return reply.code(409).send({
|
||||
error: 'Для этого заказа оплата при получении — кнопка оплаты не нужна.',
|
||||
})
|
||||
}
|
||||
|
||||
if (order.status !== "PENDING_PAYMENT") {
|
||||
return reply
|
||||
.code(409)
|
||||
.send({ error: "Сейчас нельзя выполнить оплату для этого заказа" });
|
||||
}
|
||||
if (order.status !== 'PENDING_PAYMENT') {
|
||||
return reply.code(409).send({ error: 'Сейчас нельзя выполнить оплату для этого заказа' })
|
||||
}
|
||||
|
||||
if (!request.isMultipart()) {
|
||||
return reply
|
||||
.code(400)
|
||||
.send({
|
||||
error:
|
||||
"Отправьте multipart/form-data: поле detail и/или файл receipt",
|
||||
});
|
||||
}
|
||||
if (!request.isMultipart()) {
|
||||
return reply.code(400).send({
|
||||
error: 'Отправьте multipart/form-data: поле detail и/или файл receipt',
|
||||
})
|
||||
}
|
||||
|
||||
let detail = "";
|
||||
let receiptBuffer = null;
|
||||
let receiptFilename = "";
|
||||
try {
|
||||
const otherLimit = getOtherUploadMaxFileBytes();
|
||||
const parts = request.parts({
|
||||
limits: {
|
||||
fileSize: otherLimit,
|
||||
files: 2,
|
||||
},
|
||||
});
|
||||
for await (const part of parts) {
|
||||
if (part.file) {
|
||||
if (part.fieldname === "receipt") {
|
||||
if (receiptBuffer !== null) {
|
||||
return reply
|
||||
.code(400)
|
||||
.send({ error: "Допускается один файл receipt" });
|
||||
}
|
||||
receiptBuffer = await part.toBuffer();
|
||||
receiptFilename = part.filename ?? "receipt";
|
||||
let detail = ''
|
||||
let receiptBuffer = null
|
||||
let receiptFilename = ''
|
||||
try {
|
||||
const otherLimit = getOtherUploadMaxFileBytes()
|
||||
const parts = request.parts({
|
||||
limits: {
|
||||
fileSize: otherLimit,
|
||||
files: 2,
|
||||
},
|
||||
})
|
||||
for await (const part of parts) {
|
||||
if (part.file) {
|
||||
if (part.fieldname === 'receipt') {
|
||||
if (receiptBuffer !== null) {
|
||||
return reply.code(400).send({ error: 'Допускается один файл receipt' })
|
||||
}
|
||||
} else if (part.fieldname === "detail") {
|
||||
detail = String(part.value ?? "").trim();
|
||||
receiptBuffer = await part.toBuffer()
|
||||
receiptFilename = part.filename ?? 'receipt'
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const msg =
|
||||
err instanceof Error ? err.message : "Не удалось разобрать форму";
|
||||
return reply.code(400).send({ error: msg });
|
||||
}
|
||||
|
||||
const hasDetail = detail.length > 0;
|
||||
const hasReceipt = receiptBuffer !== null && receiptBuffer.length > 0;
|
||||
|
||||
if (!hasDetail && !hasReceipt) {
|
||||
return reply
|
||||
.code(400)
|
||||
.send({
|
||||
error: "Укажите текст о платеже и/или прикрепите изображение чека",
|
||||
});
|
||||
}
|
||||
|
||||
const maxDetail = 2000;
|
||||
if (detail.length > maxDetail) {
|
||||
return reply
|
||||
.code(400)
|
||||
.send({ error: `Текст не длиннее ${maxDetail} символов` });
|
||||
}
|
||||
|
||||
let attachmentUrl = null;
|
||||
if (hasReceipt) {
|
||||
try {
|
||||
attachmentUrl = await saveImageBufferToUploads(
|
||||
receiptFilename,
|
||||
receiptBuffer,
|
||||
);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Не удалось сохранить файл";
|
||||
const statusCode =
|
||||
err &&
|
||||
typeof err === "object" &&
|
||||
"statusCode" in err &&
|
||||
Number.isInteger(err.statusCode)
|
||||
? Number(err.statusCode)
|
||||
: 400;
|
||||
return reply.code(statusCode).send({ error: message });
|
||||
} else if (part.fieldname === 'detail') {
|
||||
detail = String(part.value ?? '').trim()
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Не удалось разобрать форму'
|
||||
return reply.code(400).send({ error: msg })
|
||||
}
|
||||
|
||||
const bodyHtml = hasDetail
|
||||
? `<p>${escapeHtml(detail).replace(/\r\n|\n|\r/g, "<br/>")}</p>`
|
||||
: "";
|
||||
const messageText = `<p><strong>Подтверждение оплаты (перевод ВТБ / Сбербанк)</strong></p>${bodyHtml}`;
|
||||
const hasDetail = detail.length > 0
|
||||
const hasReceipt = receiptBuffer !== null && receiptBuffer.length > 0
|
||||
|
||||
if (!hasDetail && !hasReceipt) {
|
||||
return reply.code(400).send({
|
||||
error: 'Укажите текст о платеже и/или прикрепите изображение чека',
|
||||
})
|
||||
}
|
||||
|
||||
const maxDetail = 2000
|
||||
if (detail.length > maxDetail) {
|
||||
return reply.code(400).send({ error: `Текст не длиннее ${maxDetail} символов` })
|
||||
}
|
||||
|
||||
let attachmentUrl = null
|
||||
if (hasReceipt) {
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.orderMessage.create({
|
||||
data: {
|
||||
orderId: id,
|
||||
authorType: "user",
|
||||
text: messageText,
|
||||
attachmentUrl,
|
||||
},
|
||||
});
|
||||
});
|
||||
attachmentUrl = await saveImageBufferToUploads(receiptFilename, receiptBuffer)
|
||||
} catch (err) {
|
||||
return reply.code(500).send({ error: "Не удалось сохранить оплату" });
|
||||
const message = err instanceof Error ? err.message : 'Не удалось сохранить файл'
|
||||
const statusCode =
|
||||
err && typeof err === 'object' && 'statusCode' in err && Number.isInteger(err.statusCode)
|
||||
? Number(err.statusCode)
|
||||
: 400
|
||||
return reply.code(statusCode).send({ error: message })
|
||||
}
|
||||
}
|
||||
|
||||
request.server.eventBus.emit(NOTIFICATION_EVENTS.PAYMENT_STATUS_CHANGED, {
|
||||
orderId: id,
|
||||
userId,
|
||||
paymentStatus: "pending",
|
||||
});
|
||||
const bodyHtml = hasDetail ? `<p>${escapeHtml(detail).replace(/\r\n|\n|\r/g, '<br/>')}</p>` : ''
|
||||
const messageText = `<p><strong>Подтверждение оплаты (перевод ВТБ / Сбербанк)</strong></p>${bodyHtml}`
|
||||
|
||||
return { ok: true, status: "PENDING_PAYMENT" };
|
||||
},
|
||||
);
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.orderMessage.create({
|
||||
data: {
|
||||
orderId: id,
|
||||
authorType: 'user',
|
||||
text: messageText,
|
||||
attachmentUrl,
|
||||
},
|
||||
})
|
||||
})
|
||||
} catch {
|
||||
return reply.code(500).send({ error: 'Не удалось сохранить оплату' })
|
||||
}
|
||||
|
||||
request.server.eventBus.emit(NOTIFICATION_EVENTS.PAYMENT_STATUS_CHANGED, {
|
||||
orderId: id,
|
||||
userId,
|
||||
paymentStatus: 'pending',
|
||||
})
|
||||
|
||||
return { ok: true, status: 'PENDING_PAYMENT' }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,39 +1,31 @@
|
||||
import { prisma } from '../../lib/prisma.js'
|
||||
import { ensureUserNotificationPreference } from '../../lib/notifications/preferences.js'
|
||||
import { prisma } from '../../lib/prisma.js'
|
||||
|
||||
export async function registerUserNotificationRoutes(fastify) {
|
||||
fastify.get(
|
||||
'/api/me/notifications/settings',
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request) => {
|
||||
const userId = request.user.sub
|
||||
const prefs = await ensureUserNotificationPreference(userId)
|
||||
return { settings: prefs }
|
||||
},
|
||||
)
|
||||
fastify.get('/api/me/notifications/settings', { preHandler: [fastify.authenticate] }, async (request) => {
|
||||
const userId = request.user.sub
|
||||
const prefs = await ensureUserNotificationPreference(userId)
|
||||
return { settings: prefs }
|
||||
})
|
||||
|
||||
fastify.put(
|
||||
'/api/me/notifications/settings',
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request) => {
|
||||
const userId = request.user.sub
|
||||
const body = request.body || {}
|
||||
fastify.put('/api/me/notifications/settings', { preHandler: [fastify.authenticate] }, async (request) => {
|
||||
const userId = request.user.sub
|
||||
const body = request.body || {}
|
||||
|
||||
const data = {}
|
||||
if ('globalEnabled' in body) data.globalEnabled = Boolean(body.globalEnabled)
|
||||
if ('orderCreated' in body) data.orderCreated = Boolean(body.orderCreated)
|
||||
if ('orderStatusChanged' in body) data.orderStatusChanged = Boolean(body.orderStatusChanged)
|
||||
if ('orderMessageReceived' in body) data.orderMessageReceived = Boolean(body.orderMessageReceived)
|
||||
if ('paymentStatusChanged' in body) data.paymentStatusChanged = Boolean(body.paymentStatusChanged)
|
||||
if ('deliveryFeeAdjusted' in body) data.deliveryFeeAdjusted = Boolean(body.deliveryFeeAdjusted)
|
||||
const data = {}
|
||||
if ('globalEnabled' in body) data.globalEnabled = Boolean(body.globalEnabled)
|
||||
if ('orderCreated' in body) data.orderCreated = Boolean(body.orderCreated)
|
||||
if ('orderStatusChanged' in body) data.orderStatusChanged = Boolean(body.orderStatusChanged)
|
||||
if ('orderMessageReceived' in body) data.orderMessageReceived = Boolean(body.orderMessageReceived)
|
||||
if ('paymentStatusChanged' in body) data.paymentStatusChanged = Boolean(body.paymentStatusChanged)
|
||||
if ('deliveryFeeAdjusted' in body) data.deliveryFeeAdjusted = Boolean(body.deliveryFeeAdjusted)
|
||||
|
||||
const prefs = await prisma.notificationPreference.upsert({
|
||||
where: { userId },
|
||||
create: { userId, ...data },
|
||||
update: data,
|
||||
})
|
||||
const prefs = await prisma.notificationPreference.upsert({
|
||||
where: { userId },
|
||||
create: { userId, ...data },
|
||||
update: data,
|
||||
})
|
||||
|
||||
return { settings: prefs }
|
||||
},
|
||||
)
|
||||
return { settings: prefs }
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user