initial: server + shared
This commit is contained in:
Executable
+253
@@ -0,0 +1,253 @@
|
||||
import 'dotenv/config'
|
||||
import path from 'node:path'
|
||||
import cors from '@fastify/cors'
|
||||
import jwt from '@fastify/jwt'
|
||||
import multipart from '@fastify/multipart'
|
||||
import fastifyStatic from '@fastify/static'
|
||||
import Fastify from 'fastify'
|
||||
import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js'
|
||||
import { ensureAdminUser } from './lib/bootstrap-admin.js'
|
||||
import { getOrCreateUnspecifiedCategory } from './lib/default-category.js'
|
||||
import { createEventBus } from './lib/notifications/event-bus.js'
|
||||
import {
|
||||
resolveUserNotificationTargets,
|
||||
resolveAdminNotificationTargets,
|
||||
resolveAuthCodeTargets,
|
||||
} from './lib/notifications/preferences.js'
|
||||
import { createNotificationQueue } from './lib/notifications/queue.js'
|
||||
import { prisma } from './lib/prisma.js'
|
||||
import { getMaxUploadBodyBytes, getProductImageMaxFileBytes } from './lib/upload-limits.js'
|
||||
import { registerAuth } from './plugins/auth.js'
|
||||
import { registerIpGate } from './plugins/ip-gate.js'
|
||||
import { registerSecurityHeaders } from './plugins/security-headers.js'
|
||||
import { registerApiRoutes } from './routes/api.js'
|
||||
import { registerOAuthSocialRoutes } from './routes/oauth-social.js'
|
||||
import { registerSseRoutes } from './routes/sse.js'
|
||||
import { registerUploadsResized } from './routes/uploads-resized.js'
|
||||
import { registerUserNotificationRoutes } from './routes/user/notifications.js'
|
||||
import { registerUserAddressRoutes } from './routes/user-addresses.js'
|
||||
import { registerUserCartRoutes } from './routes/user-cart.js'
|
||||
import { registerUserMessageRoutes } from './routes/user-messages.js'
|
||||
import { registerUserOrderRoutes } from './routes/user-orders.js'
|
||||
import { registerUserPaymentRoutes } from './routes/user-payments.js'
|
||||
import { registerYookassaWebhookRoute } from './routes/webhook-yookassa.js'
|
||||
|
||||
const port = Number(process.env.PORT) || 3333
|
||||
const origin = (process.env.CORS_ORIGIN ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const fastify = Fastify({
|
||||
logger: true,
|
||||
bodyLimit: getMaxUploadBodyBytes(),
|
||||
trustProxy: true,
|
||||
})
|
||||
|
||||
await fastify.register(cors, {
|
||||
origin: origin.length ? origin : true,
|
||||
credentials: true,
|
||||
})
|
||||
|
||||
await registerSecurityHeaders(fastify)
|
||||
|
||||
fastify.get('/health', async (request) => {
|
||||
try {
|
||||
await prisma.$queryRaw`SELECT 1`
|
||||
return { status: 'ok', database: 'connected', uptime: process.uptime() }
|
||||
} catch (err) {
|
||||
request.log.error({ err }, 'Health check database query failed')
|
||||
return { status: 'degraded', database: 'disconnected', uptime: process.uptime() }
|
||||
}
|
||||
})
|
||||
|
||||
fastify.setErrorHandler(function errorHandler(error, request, reply) {
|
||||
const isProd = process.env.NODE_ENV === 'production'
|
||||
|
||||
if (error.validation) {
|
||||
return reply.code(400).send({
|
||||
error: 'Ошибка валидации',
|
||||
details: isProd ? undefined : error.validation,
|
||||
})
|
||||
}
|
||||
|
||||
if (error.code === 'FST_ERR_VALIDATION') {
|
||||
return reply.code(400).send({ error: 'Неверный формат запроса' })
|
||||
}
|
||||
|
||||
if (error.statusCode) {
|
||||
return reply.code(error.statusCode).send({
|
||||
error: error.message || 'Произошла ошибка',
|
||||
})
|
||||
}
|
||||
|
||||
request.log.error(error)
|
||||
|
||||
return reply.code(500).send({
|
||||
error: isProd ? 'Внутренняя ошибка сервера' : error.message,
|
||||
})
|
||||
})
|
||||
|
||||
await fastify.register(jwt, {
|
||||
secret: process.env.JWT_SECRET || 'dev-jwt-secret-change-me',
|
||||
})
|
||||
|
||||
await fastify.register(multipart, {
|
||||
limits: {
|
||||
files: 10,
|
||||
fileSize: getProductImageMaxFileBytes(),
|
||||
},
|
||||
})
|
||||
|
||||
registerUploadsResized(fastify)
|
||||
|
||||
const uploadsDir = path.join(process.cwd(), 'uploads')
|
||||
await fastify.register(fastifyStatic, {
|
||||
root: uploadsDir,
|
||||
prefix: '/uploads/',
|
||||
setHeaders(res, filePath) {
|
||||
if (filePath.includes('/.cache/')) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
|
||||
} else {
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400')
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
fastify.decorate('authenticate', async function authenticate(request, reply) {
|
||||
try {
|
||||
if (!request.headers.authorization && request.query?.token) {
|
||||
request.headers.authorization = `Bearer ${request.query.token}`
|
||||
}
|
||||
await request.jwtVerify()
|
||||
} catch (err) {
|
||||
request.log.error({ err }, 'JWT verification failed')
|
||||
return reply.code(401).send({ error: 'Не авторизован' })
|
||||
}
|
||||
})
|
||||
|
||||
const eventBus = createEventBus()
|
||||
const notificationQueue = createNotificationQueue()
|
||||
fastify.decorate('eventBus', eventBus)
|
||||
fastify.decorate('notificationQueue', notificationQueue)
|
||||
|
||||
await registerIpGate(fastify)
|
||||
registerAuth(fastify)
|
||||
await registerUserAddressRoutes(fastify)
|
||||
await registerUserCartRoutes(fastify)
|
||||
await registerUserMessageRoutes(fastify)
|
||||
await registerSseRoutes(fastify)
|
||||
await registerUserOrderRoutes(fastify)
|
||||
await registerUserPaymentRoutes(fastify)
|
||||
await registerUserNotificationRoutes(fastify)
|
||||
await registerOAuthSocialRoutes(fastify)
|
||||
await registerYookassaWebhookRoute(fastify)
|
||||
await registerApiRoutes(fastify)
|
||||
|
||||
try {
|
||||
await ensureAdminUser()
|
||||
} catch (err) {
|
||||
fastify.log.error({ err }, 'ensureAdminUser failed — continuing startup')
|
||||
}
|
||||
|
||||
try {
|
||||
await getOrCreateUnspecifiedCategory()
|
||||
} catch (err) {
|
||||
fastify.log.error({ err }, 'getOrCreateUnspecifiedCategory failed — continuing startup')
|
||||
}
|
||||
|
||||
try {
|
||||
await notificationQueue.flushPendingOnStartup()
|
||||
} catch (err) {
|
||||
fastify.log.error({ err }, 'notificationQueue.flushPendingOnStartup failed')
|
||||
}
|
||||
notificationQueue.start()
|
||||
|
||||
const {
|
||||
ORDER_CREATED,
|
||||
ORDER_STATUS_CHANGED,
|
||||
ORDER_MESSAGE_SENT,
|
||||
ORDER_MESSAGE_ADMIN_REPLY,
|
||||
PAYMENT_STATUS_CHANGED,
|
||||
AUTH_CODE_REQUESTED,
|
||||
DELIVERY_FEE_ADJUSTED,
|
||||
} = NOTIFICATION_EVENTS
|
||||
|
||||
async function dispatchNotification(eventType, payload) {
|
||||
try {
|
||||
if (eventType === AUTH_CODE_REQUESTED) {
|
||||
const targets = await resolveAuthCodeTargets(eventType, payload)
|
||||
for (const target of targets.filter((t) => t.channel === 'telegram')) {
|
||||
const log = await prisma.notificationLog.create({
|
||||
data: {
|
||||
eventType,
|
||||
channel: target.channel,
|
||||
status: 'pending',
|
||||
payload: JSON.stringify(payload),
|
||||
},
|
||||
})
|
||||
notificationQueue.enqueue({ ...target, eventType, payload, logId: log.id })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const userTargets = await resolveUserNotificationTargets(eventType, payload)
|
||||
for (const target of userTargets) {
|
||||
const log = await prisma.notificationLog.create({
|
||||
data: {
|
||||
userId: payload.userId,
|
||||
eventType,
|
||||
channel: target.channel,
|
||||
status: 'pending',
|
||||
payload: JSON.stringify(payload),
|
||||
},
|
||||
})
|
||||
notificationQueue.enqueue({ ...target, eventType, payload, logId: log.id })
|
||||
}
|
||||
|
||||
const adminEventType = eventType === 'order:created:admin' ? ORDER_CREATED : eventType
|
||||
const adminTargets = await resolveAdminNotificationTargets(adminEventType, payload)
|
||||
for (const target of adminTargets) {
|
||||
const log = await prisma.notificationLog.create({
|
||||
data: {
|
||||
eventType,
|
||||
channel: target.channel,
|
||||
status: 'pending',
|
||||
payload: JSON.stringify(payload),
|
||||
},
|
||||
})
|
||||
notificationQueue.enqueue({ ...target, eventType, payload, logId: log.id })
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[notification] Error dispatching ${eventType}:`, err.message)
|
||||
}
|
||||
}
|
||||
|
||||
eventBus.on(ORDER_CREATED, (payload) => dispatchNotification(ORDER_CREATED, payload))
|
||||
eventBus.on(ORDER_STATUS_CHANGED, (payload) => dispatchNotification(ORDER_STATUS_CHANGED, payload))
|
||||
eventBus.on(ORDER_MESSAGE_SENT, (payload) => dispatchNotification(ORDER_MESSAGE_SENT, payload))
|
||||
eventBus.on(ORDER_MESSAGE_ADMIN_REPLY, (payload) => dispatchNotification(ORDER_MESSAGE_ADMIN_REPLY, payload))
|
||||
eventBus.on(PAYMENT_STATUS_CHANGED, (payload) => dispatchNotification(PAYMENT_STATUS_CHANGED, payload))
|
||||
eventBus.on(AUTH_CODE_REQUESTED, (payload) => dispatchNotification(AUTH_CODE_REQUESTED, payload))
|
||||
eventBus.on('order:created:admin', (payload) => dispatchNotification('order:created:admin', payload))
|
||||
eventBus.on('review:created', (payload) => dispatchNotification('review:created', payload))
|
||||
eventBus.on(DELIVERY_FEE_ADJUSTED, (payload) => dispatchNotification(DELIVERY_FEE_ADJUSTED, payload))
|
||||
|
||||
async function shutdown() {
|
||||
notificationQueue.stop()
|
||||
await fastify.close()
|
||||
process.exit(0)
|
||||
}
|
||||
process.on('SIGINT', shutdown)
|
||||
process.on('SIGTERM', shutdown)
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
console.error('[process] Unhandled rejection:', reason?.message || reason)
|
||||
})
|
||||
|
||||
try {
|
||||
await fastify.listen({ port, host: '0.0.0.0' })
|
||||
} catch (err) {
|
||||
fastify.log.error(err)
|
||||
process.exit(1)
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { asyncHandler } from '../async-handler.js'
|
||||
|
||||
describe('asyncHandler', () => {
|
||||
it('calls the handler and returns result on success', async () => {
|
||||
const handler = vi.fn().mockResolvedValue({ hello: 'world' })
|
||||
const request = {}
|
||||
const reply = { code: vi.fn().mockReturnThis(), send: vi.fn() }
|
||||
const result = await asyncHandler(handler)(request, reply)
|
||||
expect(handler).toHaveBeenCalledWith(request, reply)
|
||||
expect(result).toEqual({ hello: 'world' })
|
||||
})
|
||||
|
||||
it('catches errors and sends 500 with generic message', async () => {
|
||||
const handler = vi.fn().mockRejectedValue(new Error('boom'))
|
||||
const request = { log: { error: vi.fn() } }
|
||||
const reply = { code: vi.fn().mockReturnThis(), send: vi.fn() }
|
||||
await asyncHandler(handler)(request, reply)
|
||||
expect(reply.code).toHaveBeenCalledWith(500)
|
||||
expect(reply.send).toHaveBeenCalledWith({ error: 'Internal server error' })
|
||||
})
|
||||
|
||||
it('uses statusCode from error object when present', async () => {
|
||||
const err = new Error('Not found')
|
||||
err.statusCode = 404
|
||||
const handler = vi.fn().mockRejectedValue(err)
|
||||
const request = { log: { error: vi.fn() } }
|
||||
const reply = { code: vi.fn().mockReturnThis(), send: vi.fn() }
|
||||
await asyncHandler(handler)(request, reply)
|
||||
expect(reply.code).toHaveBeenCalledWith(404)
|
||||
expect(reply.send).toHaveBeenCalledWith({ error: 'Not found' })
|
||||
})
|
||||
})
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { escapeHtml } from '../escape-html.js'
|
||||
|
||||
describe('escapeHtml', () => {
|
||||
it('escapes & < > "', () => {
|
||||
expect(escapeHtml('&<>"')).toBe('&<>"')
|
||||
})
|
||||
|
||||
it('returns empty string for null/undefined', () => {
|
||||
expect(escapeHtml(null)).toBe('')
|
||||
expect(escapeHtml(undefined)).toBe('')
|
||||
})
|
||||
|
||||
it('passes safe text through', () => {
|
||||
expect(escapeHtml('hello world')).toBe('hello world')
|
||||
})
|
||||
|
||||
it('escapes mixed content', () => {
|
||||
expect(escapeHtml('<script>alert("xss")</script>')).toBe('<script>alert("xss")</script>')
|
||||
})
|
||||
})
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { findUserOrder } from '../find-user-order.js'
|
||||
|
||||
describe('findUserOrder', () => {
|
||||
it('returns order when found', async () => {
|
||||
const mockOrder = { id: '1', userId: 'user1' }
|
||||
const prisma = { order: { findFirst: vi.fn().mockResolvedValue(mockOrder) } }
|
||||
const result = await findUserOrder(prisma, '1', 'user1')
|
||||
expect(result).toEqual(mockOrder)
|
||||
expect(prisma.order.findFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: '1', userId: 'user1' } }),
|
||||
)
|
||||
})
|
||||
|
||||
it('throws 404 when order not found', async () => {
|
||||
const prisma = { order: { findFirst: vi.fn().mockResolvedValue(null) } }
|
||||
await expect(findUserOrder(prisma, '999', 'user1')).rejects.toMatchObject({ statusCode: 404 })
|
||||
})
|
||||
|
||||
it('passes include option', async () => {
|
||||
const mockOrder = { id: '1', userId: 'user1', items: [] }
|
||||
const prisma = { order: { findFirst: vi.fn().mockResolvedValue(mockOrder) } }
|
||||
const result = await findUserOrder(prisma, '1', 'user1', { items: true })
|
||||
expect(result).toEqual(mockOrder)
|
||||
expect(prisma.order.findFirst).toHaveBeenCalledWith(expect.objectContaining({ include: { items: true } }))
|
||||
})
|
||||
})
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
import crypto from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { findOriginalFile, getOrCreateResized } from '../image-resize.js'
|
||||
|
||||
const TEST_DIR = path.join(process.cwd(), 'uploads', '.test-tmp')
|
||||
const UPLOADS_DIR = path.join(process.cwd(), 'uploads')
|
||||
|
||||
beforeAll(async () => {
|
||||
await fs.promises.mkdir(TEST_DIR, { recursive: true })
|
||||
// Create a small test PNG file
|
||||
const sharp = (await import('sharp')).default
|
||||
const testPng = path.join(TEST_DIR, 'test-original.png')
|
||||
await sharp({
|
||||
create: { width: 100, height: 100, channels: 3, background: { r: 255, g: 0, b: 0 } },
|
||||
})
|
||||
.png()
|
||||
.toFile(testPng)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await fs.promises.rm(TEST_DIR, { recursive: true, force: true })
|
||||
// Clean up any cache files created during tests
|
||||
const cacheDir = path.join(UPLOADS_DIR, '.cache')
|
||||
try {
|
||||
await fs.promises.rm(cacheDir, { recursive: true, force: true })
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})
|
||||
|
||||
describe('image-resize', () => {
|
||||
it('findOriginalFile locates file by UUID', async () => {
|
||||
const files = await fs.promises.readdir(TEST_DIR)
|
||||
const pngFile = files.find((f) => f.endsWith('.png'))
|
||||
const uuid = pngFile.replace('.png', '')
|
||||
|
||||
// Copy file to actual uploads directory
|
||||
const destPath = path.join(UPLOADS_DIR, pngFile)
|
||||
await fs.promises.copyFile(path.join(TEST_DIR, pngFile), destPath)
|
||||
|
||||
const found = await findOriginalFile(uuid)
|
||||
expect(found).not.toBeNull()
|
||||
expect(found).toBe(destPath)
|
||||
|
||||
// Cleanup
|
||||
await fs.promises.unlink(destPath)
|
||||
})
|
||||
|
||||
it('getOrCreateResized generates AVIF file', async () => {
|
||||
const sharp = (await import('sharp')).default
|
||||
const uuid = crypto.randomUUID()
|
||||
const testPath = path.join(UPLOADS_DIR, `${uuid}.png`)
|
||||
await sharp({
|
||||
create: { width: 200, height: 200, channels: 3, background: { r: 0, g: 255, b: 0 } },
|
||||
})
|
||||
.png()
|
||||
.toFile(testPath)
|
||||
|
||||
const result = await getOrCreateResized(uuid, 100, 'avif')
|
||||
expect(result).not.toBeNull()
|
||||
expect(result.isNew).toBe(true)
|
||||
expect(result.path).toContain('.cache')
|
||||
expect(result.path).toContain('_w100.avif')
|
||||
|
||||
const exists = await fs.promises
|
||||
.access(result.path)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
expect(exists).toBe(true)
|
||||
|
||||
// Verify it's actually AVIF (sharp reports AVIF as 'heif' in metadata)
|
||||
expect(result.path).toMatch(/\.avif$/)
|
||||
|
||||
// Cleanup
|
||||
await fs.promises.unlink(testPath)
|
||||
await fs.promises.unlink(result.path)
|
||||
})
|
||||
|
||||
it('getOrCreateResized returns cached file on second call', async () => {
|
||||
const sharp = (await import('sharp')).default
|
||||
const uuid = crypto.randomUUID()
|
||||
const testPath = path.join(UPLOADS_DIR, `${uuid}.png`)
|
||||
await sharp({
|
||||
create: { width: 200, height: 200, channels: 3, background: { r: 0, g: 0, b: 255 } },
|
||||
})
|
||||
.png()
|
||||
.toFile(testPath)
|
||||
|
||||
const first = await getOrCreateResized(uuid, 100, 'webp')
|
||||
expect(first.isNew).toBe(true)
|
||||
|
||||
const second = await getOrCreateResized(uuid, 100, 'webp')
|
||||
expect(second.isNew).toBe(false)
|
||||
expect(second.path).toBe(first.path)
|
||||
|
||||
// Cleanup
|
||||
await fs.promises.unlink(testPath)
|
||||
await fs.promises.unlink(first.path)
|
||||
})
|
||||
})
|
||||
|
||||
describe('eager image processing', () => {
|
||||
it('generateAllSizes creates all width+format combinations', async () => {
|
||||
const { generateAllSizes } = await import('../image-resize.js')
|
||||
const sharp = (await import('sharp')).default
|
||||
const uuid = 'test-eager-uuid-1'
|
||||
const testImagePath = path.join(UPLOADS_DIR, `${uuid}.png`)
|
||||
await sharp({ create: { width: 2000, height: 1500, channels: 3, background: { r: 255, g: 0, b: 0 } } })
|
||||
.png()
|
||||
.toFile(testImagePath)
|
||||
|
||||
await generateAllSizes(uuid, '', testImagePath)
|
||||
|
||||
const cacheDir = path.join(UPLOADS_DIR, '.cache')
|
||||
for (const width of [320, 640, 1024, 1600]) {
|
||||
for (const format of ['avif', 'webp']) {
|
||||
const cachePath = path.join(cacheDir, `${uuid}_w${width}.${format}`)
|
||||
const exists = await fs.promises
|
||||
.access(cachePath)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
expect(exists).toBe(true)
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
await fs.promises.unlink(testImagePath)
|
||||
for (const width of [320, 640, 1024, 1600]) {
|
||||
for (const format of ['avif', 'webp']) {
|
||||
const cachePath = path.join(cacheDir, `${uuid}_w${width}.${format}`)
|
||||
try {
|
||||
await fs.promises.unlink(cachePath)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('convertOriginalToWebp converts and deletes original', async () => {
|
||||
const { convertOriginalToWebp } = await import('../image-resize.js')
|
||||
const sharp = (await import('sharp')).default
|
||||
const uuid = 'test-eager-uuid-2'
|
||||
const testImagePath = path.join(UPLOADS_DIR, `${uuid}.png`)
|
||||
await sharp({ create: { width: 800, height: 600, channels: 3, background: { r: 0, g: 255, b: 0 } } })
|
||||
.png()
|
||||
.toFile(testImagePath)
|
||||
|
||||
const result = await convertOriginalToWebp(uuid, '')
|
||||
|
||||
expect(result).toBe(`/uploads/${uuid}.webp`)
|
||||
const pngExists = await fs.promises
|
||||
.access(testImagePath)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
expect(pngExists).toBe(false)
|
||||
const webpPath = path.join(UPLOADS_DIR, `${uuid}.webp`)
|
||||
const webpExists = await fs.promises
|
||||
.access(webpPath)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
expect(webpExists).toBe(true)
|
||||
|
||||
// Cleanup
|
||||
try {
|
||||
await fs.promises.unlink(webpPath)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})
|
||||
})
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { canTransitionAdminOrderStatus } from '../order-status.js'
|
||||
|
||||
describe('canTransitionAdminOrderStatus', () => {
|
||||
const delivery = { deliveryType: 'delivery' }
|
||||
const pickup = { deliveryType: 'pickup' }
|
||||
|
||||
it('DRAFT → PENDING_PAYMENT', () => {
|
||||
expect(canTransitionAdminOrderStatus({ status: 'DRAFT', ...delivery }, 'PENDING_PAYMENT')).toBe(true)
|
||||
})
|
||||
|
||||
it('DRAFT → CANCELLED', () => {
|
||||
expect(canTransitionAdminOrderStatus({ status: 'DRAFT', ...delivery }, 'CANCELLED')).toBe(true)
|
||||
})
|
||||
|
||||
it('DRAFT cannot skip to PAID', () => {
|
||||
expect(canTransitionAdminOrderStatus({ status: 'DRAFT', ...delivery }, 'PAID')).toBe(false)
|
||||
})
|
||||
|
||||
it('PENDING_PAYMENT → PAID', () => {
|
||||
expect(canTransitionAdminOrderStatus({ status: 'PENDING_PAYMENT', ...delivery }, 'PAID')).toBe(true)
|
||||
})
|
||||
|
||||
it('PENDING_PAYMENT → CANCELLED', () => {
|
||||
expect(canTransitionAdminOrderStatus({ status: 'PENDING_PAYMENT', ...delivery }, 'CANCELLED')).toBe(true)
|
||||
})
|
||||
|
||||
it('PAID → IN_PROGRESS', () => {
|
||||
expect(canTransitionAdminOrderStatus({ status: 'PAID', ...delivery }, 'IN_PROGRESS')).toBe(true)
|
||||
})
|
||||
|
||||
it('IN_PROGRESS (delivery) → SHIPPED', () => {
|
||||
expect(canTransitionAdminOrderStatus({ status: 'IN_PROGRESS', ...delivery }, 'SHIPPED')).toBe(true)
|
||||
})
|
||||
|
||||
it('IN_PROGRESS (pickup) → READY_FOR_PICKUP', () => {
|
||||
expect(canTransitionAdminOrderStatus({ status: 'IN_PROGRESS', ...pickup }, 'READY_FOR_PICKUP')).toBe(true)
|
||||
})
|
||||
|
||||
it('IN_PROGRESS (delivery) cannot go to READY_FOR_PICKUP', () => {
|
||||
expect(canTransitionAdminOrderStatus({ status: 'IN_PROGRESS', ...delivery }, 'READY_FOR_PICKUP')).toBe(false)
|
||||
})
|
||||
|
||||
it('DONE allows no transitions', () => {
|
||||
expect(canTransitionAdminOrderStatus({ status: 'DONE', ...delivery }, 'CANCELLED')).toBe(false)
|
||||
expect(canTransitionAdminOrderStatus({ status: 'DONE', ...delivery }, 'PAID')).toBe(false)
|
||||
})
|
||||
|
||||
it('same status returns true', () => {
|
||||
expect(canTransitionAdminOrderStatus({ status: 'DRAFT', ...delivery }, 'DRAFT')).toBe(true)
|
||||
})
|
||||
})
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { describe, it, expect, afterEach } from 'vitest'
|
||||
import { persistMultipartImages } from '../upload-images.js'
|
||||
|
||||
const UPLOADS_DIR = path.join(process.cwd(), 'uploads')
|
||||
const TEST_PREFIX = 'upload-test-'
|
||||
|
||||
describe('persistMultipartImages with eager=false', () => {
|
||||
afterEach(async () => {
|
||||
const files = await fs.promises.readdir(UPLOADS_DIR).catch(() => [])
|
||||
for (const file of files) {
|
||||
if (file.startsWith(TEST_PREFIX)) {
|
||||
await fs.promises.unlink(path.join(UPLOADS_DIR, file)).catch(() => {})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('returns original format URLs when eager=false', async () => {
|
||||
const sharp = (await import('sharp')).default
|
||||
const testImagePath = path.join(UPLOADS_DIR, `${TEST_PREFIX}original2.png`)
|
||||
await sharp({ create: { width: 100, height: 100, channels: 3, background: { r: 0, g: 255, b: 0 } } })
|
||||
.png()
|
||||
.toFile(testImagePath)
|
||||
|
||||
const mockRequest = {
|
||||
isMultipart: () => true,
|
||||
parts: async function* () {
|
||||
const buffer = await fs.promises.readFile(testImagePath)
|
||||
yield {
|
||||
file: true,
|
||||
filename: 'test.png',
|
||||
toBuffer: async () => buffer,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const urls = await persistMultipartImages(mockRequest, {
|
||||
maxFiles: 1,
|
||||
maxFileBytes: 20 * 1024 * 1024,
|
||||
subdir: '',
|
||||
eager: false,
|
||||
})
|
||||
|
||||
expect(urls).toHaveLength(1)
|
||||
expect(urls[0]).toMatch(/\/uploads\/[a-f0-9-]+\.png$/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { validateGalleryImages } from '../validate-gallery-images.js'
|
||||
|
||||
describe('validateGalleryImages', () => {
|
||||
it('returns null when urls is empty', async () => {
|
||||
const prisma = { galleryImage: { findMany: vi.fn() } }
|
||||
const result = await validateGalleryImages(prisma, [])
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('throws 400 when image not found', async () => {
|
||||
const prisma = { galleryImage: { findMany: vi.fn().mockResolvedValue([]) } }
|
||||
await expect(validateGalleryImages(prisma, ['/uploads/missing.jpg'])).rejects.toMatchObject({ statusCode: 400 })
|
||||
})
|
||||
|
||||
it('throws 400 when image not yet resized', async () => {
|
||||
const prisma = {
|
||||
galleryImage: { findMany: vi.fn().mockResolvedValue([{ url: '/uploads/img.jpg', isResized: false }]) },
|
||||
}
|
||||
await expect(validateGalleryImages(prisma, ['/uploads/img.jpg'])).rejects.toMatchObject({ statusCode: 400 })
|
||||
})
|
||||
|
||||
it('returns existing images when all valid and resized', async () => {
|
||||
const images = [
|
||||
{ url: '/uploads/img1.jpg', isResized: true },
|
||||
{ url: '/uploads/img2.jpg', isResized: true },
|
||||
]
|
||||
const prisma = { galleryImage: { findMany: vi.fn().mockResolvedValue(images) } }
|
||||
const result = await validateGalleryImages(prisma, ['/uploads/img1.jpg', '/uploads/img2.jpg'])
|
||||
expect(result).toEqual(images)
|
||||
})
|
||||
})
|
||||
Executable
+257
@@ -0,0 +1,257 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPayment, getPayment, buildReceipt, validateWebhook } from '../yookassa.js'
|
||||
|
||||
describe('yookassa createPayment', () => {
|
||||
beforeEach(() => {
|
||||
process.env.YOOKASSA_SHOP_ID = '123456'
|
||||
process.env.YOOKASSA_SECRET_KEY = 'test_secret'
|
||||
vi.stubGlobal('fetch', vi.fn())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
delete process.env.YOOKASSA_SHOP_ID
|
||||
delete process.env.YOOKASSA_SECRET_KEY
|
||||
})
|
||||
|
||||
it('calls POST /payments with Basic auth and Idempotence-Key', async () => {
|
||||
const mockPayment = {
|
||||
id: '2d0c6f35-000f-5000-8000-1234567890ab',
|
||||
status: 'pending',
|
||||
paid: false,
|
||||
amount: { value: '1000.00', currency: 'RUB' },
|
||||
confirmation: { type: 'redirect', confirmation_url: 'https://yoomoney.ru/checkout/...' },
|
||||
created_at: '2026-05-20T12:00:00.000Z',
|
||||
test: true,
|
||||
refundable: false,
|
||||
recipient: { account_id: '123456', gateway_id: '123456' },
|
||||
}
|
||||
fetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve(mockPayment),
|
||||
})
|
||||
|
||||
const result = await createPayment({
|
||||
amount: { value: '1000.00', currency: 'RUB' },
|
||||
description: 'Order #test',
|
||||
receipt: {
|
||||
customer: { email: 'test@example.com' },
|
||||
items: [{ description: 'Item', quantity: 1, amount: { value: '1000.00', currency: 'RUB' }, vat_code: 1 }],
|
||||
tax_system_code: 1,
|
||||
},
|
||||
confirmation: { type: 'redirect', return_url: 'http://localhost:5173/me/orders/test?paid=1' },
|
||||
metadata: { orderId: 'test' },
|
||||
idempotencyKey: 'test-v1',
|
||||
})
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(1)
|
||||
const [url, opts] = fetch.mock.calls[0]
|
||||
expect(url).toBe('https://api.yookassa.ru/v3/payments')
|
||||
expect(opts.method).toBe('POST')
|
||||
expect(opts.headers['Idempotence-Key']).toBe('test-v1')
|
||||
expect(opts.headers['Authorization']).toBe('Basic MTIzNDU2OnRlc3Rfc2VjcmV0')
|
||||
expect(result.paymentId).toBe('2d0c6f35-000f-5000-8000-1234567890ab')
|
||||
expect(result.confirmationUrl).toBe('https://yoomoney.ru/checkout/...')
|
||||
expect(result.status).toBe('pending')
|
||||
})
|
||||
|
||||
it('retries on 5xx error', async () => {
|
||||
fetch
|
||||
.mockResolvedValueOnce({ ok: false, status: 500 })
|
||||
.mockResolvedValueOnce({ ok: false, status: 503 })
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
id: 'retry-id',
|
||||
status: 'pending',
|
||||
paid: false,
|
||||
amount: { value: '500.00', currency: 'RUB' },
|
||||
confirmation: { type: 'redirect', confirmation_url: 'https://yoomoney.ru/checkout/retry' },
|
||||
created_at: '2026-05-20T12:00:00.000Z',
|
||||
test: true,
|
||||
refundable: false,
|
||||
recipient: { account_id: '123456', gateway_id: '123456' },
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await createPayment({
|
||||
amount: { value: '500.00', currency: 'RUB' },
|
||||
description: 'Retry test',
|
||||
receipt: {
|
||||
customer: { email: 'test@example.com' },
|
||||
items: [{ description: 'Item', quantity: 1, amount: { value: '500.00', currency: 'RUB' }, vat_code: 1 }],
|
||||
tax_system_code: 1,
|
||||
},
|
||||
confirmation: { type: 'redirect', return_url: 'http://localhost:5173/me/orders/test' },
|
||||
metadata: {},
|
||||
idempotencyKey: 'retry-v1',
|
||||
})
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(3)
|
||||
expect(result.paymentId).toBe('retry-id')
|
||||
})
|
||||
|
||||
it('throws on 4xx error', async () => {
|
||||
fetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
type: 'error',
|
||||
id: 'err-id',
|
||||
code: 'invalid_request',
|
||||
description: 'Missing required field',
|
||||
}),
|
||||
})
|
||||
|
||||
await expect(
|
||||
createPayment({
|
||||
amount: { value: '1000.00', currency: 'RUB' },
|
||||
description: 'Bad request',
|
||||
receipt: {
|
||||
customer: { email: 'test@example.com' },
|
||||
items: [{ description: 'Item', quantity: 1, amount: { value: '1000.00', currency: 'RUB' }, vat_code: 1 }],
|
||||
tax_system_code: 1,
|
||||
},
|
||||
confirmation: { type: 'redirect', return_url: 'http://localhost:5173' },
|
||||
metadata: {},
|
||||
idempotencyKey: 'bad-v1',
|
||||
}),
|
||||
).rejects.toThrow('YooKassa API error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('yookassa getPayment', () => {
|
||||
beforeEach(() => {
|
||||
process.env.YOOKASSA_SHOP_ID = '123456'
|
||||
process.env.YOOKASSA_SECRET_KEY = 'test_secret'
|
||||
vi.stubGlobal('fetch', vi.fn())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
delete process.env.YOOKASSA_SHOP_ID
|
||||
delete process.env.YOOKASSA_SECRET_KEY
|
||||
})
|
||||
|
||||
it('calls GET /payments/{id} and returns payment data', async () => {
|
||||
fetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
id: 'payment-id',
|
||||
status: 'succeeded',
|
||||
paid: true,
|
||||
amount: { value: '1000.00', currency: 'RUB' },
|
||||
created_at: '2026-05-20T12:00:00.000Z',
|
||||
test: true,
|
||||
refundable: true,
|
||||
recipient: { account_id: '123456', gateway_id: '123456' },
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await getPayment('payment-id')
|
||||
expect(fetch).toHaveBeenCalledTimes(1)
|
||||
expect(fetch.mock.calls[0][0]).toBe('https://api.yookassa.ru/v3/payments/payment-id')
|
||||
expect(result.paymentId).toBe('payment-id')
|
||||
expect(result.status).toBe('succeeded')
|
||||
expect(result.paid).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('yookassa buildReceipt', () => {
|
||||
it('builds receipt with order items', () => {
|
||||
const result = buildReceipt({
|
||||
orderItems: [{ titleSnapshot: 'Test Product', qty: 2, priceCentsSnapshot: 100000 }],
|
||||
deliveryFeeCents: 0,
|
||||
userEmail: 'user@test.ru',
|
||||
})
|
||||
|
||||
expect(result.customer.email).toBe('user@test.ru')
|
||||
expect(result.items).toHaveLength(1)
|
||||
expect(result.items[0].description).toBe('Test Product')
|
||||
expect(result.items[0].quantity).toBe(2)
|
||||
expect(result.items[0].amount.value).toBe('1000.00')
|
||||
expect(result.items[0].vat_code).toBe(1)
|
||||
expect(result.items[0].measure).toBe('piece')
|
||||
expect(result.items[0].payment_subject).toBe('commodity')
|
||||
expect(result.items[0].payment_mode).toBe('full_prepayment')
|
||||
expect(result.tax_system_code).toBe(1)
|
||||
})
|
||||
|
||||
it('adds delivery item when deliveryFeeCents > 0', () => {
|
||||
const result = buildReceipt({
|
||||
orderItems: [{ titleSnapshot: 'Item A', qty: 1, priceCentsSnapshot: 50000 }],
|
||||
deliveryFeeCents: 35000,
|
||||
userEmail: 'user@test.ru',
|
||||
})
|
||||
|
||||
expect(result.items).toHaveLength(2)
|
||||
expect(result.items[1].description).toBe('Доставка')
|
||||
expect(result.items[1].amount.value).toBe('350.00')
|
||||
expect(result.items[1].payment_subject).toBe('service')
|
||||
})
|
||||
|
||||
it('passes through taxSystemCode', () => {
|
||||
const result = buildReceipt({
|
||||
orderItems: [{ titleSnapshot: 'Item', qty: 1, priceCentsSnapshot: 1000 }],
|
||||
deliveryFeeCents: 0,
|
||||
userEmail: 'user@test.ru',
|
||||
taxSystemCode: 3,
|
||||
})
|
||||
|
||||
expect(result.tax_system_code).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('yookassa validateWebhook', () => {
|
||||
beforeEach(() => {
|
||||
process.env.YOOKASSA_SECRET_KEY = 'test_secret'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.YOOKASSA_SECRET_KEY
|
||||
})
|
||||
|
||||
it('returns event and paymentObject for valid notification', () => {
|
||||
const body = {
|
||||
type: 'notification',
|
||||
event: 'payment.succeeded',
|
||||
object: { id: 'yk-id', status: 'succeeded', paid: true },
|
||||
}
|
||||
const result = validateWebhook('127.0.0.1', body)
|
||||
expect(result.event).toBe('payment.succeeded')
|
||||
expect(result.paymentObject.id).toBe('yk-id')
|
||||
})
|
||||
|
||||
it('throws if type is not notification', () => {
|
||||
expect(() => validateWebhook('127.0.0.1', { type: 'other', event: 'x', object: {} })).toThrow(
|
||||
'Expected notification type',
|
||||
)
|
||||
})
|
||||
|
||||
it('throws if missing event', () => {
|
||||
expect(() => validateWebhook('127.0.0.1', { type: 'notification', object: {} })).toThrow('Missing event or object')
|
||||
})
|
||||
|
||||
it('throws if missing object', () => {
|
||||
expect(() => validateWebhook('127.0.0.1', { type: 'notification', event: 'x' })).toThrow('Missing event or object')
|
||||
})
|
||||
|
||||
it('throws for invalid body type', () => {
|
||||
expect(() => validateWebhook('127.0.0.1', 'not an object')).toThrow('Invalid webhook body')
|
||||
})
|
||||
|
||||
it('throws for null body', () => {
|
||||
expect(() => validateWebhook('127.0.0.1', null)).toThrow('Invalid webhook body')
|
||||
})
|
||||
|
||||
it('skips IP validation in test mode (test_ key)', () => {
|
||||
const body = { type: 'notification', event: 'payment.succeeded', object: {} }
|
||||
expect(() => validateWebhook('1.2.3.4', body)).not.toThrow()
|
||||
})
|
||||
})
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
export function asyncHandler(fn) {
|
||||
return async (request, reply) => {
|
||||
try {
|
||||
return await fn(request, reply)
|
||||
} catch (err) {
|
||||
request.log.error(err)
|
||||
const statusCode = err.statusCode || 500
|
||||
const message = err.statusCode ? err.message : 'Internal server error'
|
||||
return reply.code(statusCode).send({ error: message })
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+106
@@ -0,0 +1,106 @@
|
||||
import crypto from 'node:crypto'
|
||||
import bcrypt from 'bcrypt'
|
||||
import { sendLoginCodeEmail } from './email.js'
|
||||
import { prisma } from './prisma.js'
|
||||
|
||||
export function normalizeEmail(email) {
|
||||
return String(email || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
export function randomCode6() {
|
||||
return String(Math.floor(100000 + Math.random() * 900000))
|
||||
}
|
||||
|
||||
export function sha256(input) {
|
||||
return crypto.createHash('sha256').update(input).digest('hex')
|
||||
}
|
||||
|
||||
export async function issueEmailCode({ email, purpose, userId = null }) {
|
||||
const code = randomCode6()
|
||||
const expiresAt = new Date(Date.now() + 10 * 60 * 1000)
|
||||
await prisma.authCode.create({
|
||||
data: {
|
||||
email,
|
||||
purpose,
|
||||
userId,
|
||||
codeHash: sha256(`${email}:${purpose}:${code}:${userId ?? ''}`),
|
||||
expiresAt,
|
||||
},
|
||||
})
|
||||
await sendLoginCodeEmail({ to: email, code })
|
||||
return code
|
||||
}
|
||||
|
||||
function parseEnvBool(raw) {
|
||||
const v = String(raw ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
return v === 'true' || v === '1' || v === 'yes'
|
||||
}
|
||||
|
||||
/** Тестовые стенды: принять код из переменной DEFAULT_CODE без записи в БД. */
|
||||
export function isDefaultLoginCodeAccepted(codeInput) {
|
||||
if (!parseEnvBool(process.env.IS_DEFAULT_CODE_ENABLED)) return false
|
||||
const expected = String(process.env.DEFAULT_CODE ?? '').trim()
|
||||
if (!expected || expected.length < 4) return false
|
||||
return String(codeInput ?? '').trim() === expected
|
||||
}
|
||||
|
||||
export async function verifyEmailCode({ email, purpose, code, userId = null }) {
|
||||
if (purpose === 'login' && isDefaultLoginCodeAccepted(code)) return true
|
||||
|
||||
const now = new Date()
|
||||
const codeHash = sha256(`${email}:${purpose}:${code}:${userId ?? ''}`)
|
||||
|
||||
const found = await prisma.authCode.findFirst({
|
||||
where: {
|
||||
email,
|
||||
purpose,
|
||||
userId,
|
||||
codeHash,
|
||||
usedAt: null,
|
||||
expiresAt: { gt: now },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
if (!found) return false
|
||||
|
||||
await prisma.authCode.update({
|
||||
where: { id: found.id },
|
||||
data: { usedAt: now },
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
const PASSWORD_MIN_LEN = 8
|
||||
|
||||
const PASSWORD_REGEX = {
|
||||
letter: /[a-zа-яё]/i,
|
||||
digit: /[0-9]/,
|
||||
special: /[^a-zа-яё0-9\s]/i,
|
||||
}
|
||||
|
||||
export function validatePassword(password) {
|
||||
if (typeof password !== 'string') return 'Пароль обязателен'
|
||||
if (password.length < PASSWORD_MIN_LEN) return `Пароль должен быть не менее ${PASSWORD_MIN_LEN} символов`
|
||||
if (!PASSWORD_REGEX.letter.test(password)) return 'Пароль должен содержать хотя бы одну букву'
|
||||
if (!PASSWORD_REGEX.digit.test(password)) return 'Пароль должен содержать хотя бы одну цифру'
|
||||
if (!PASSWORD_REGEX.special.test(password)) return 'Пароль должен содержать хотя бы один спецсимвол'
|
||||
return null
|
||||
}
|
||||
|
||||
export async function hashPassword(password) {
|
||||
return bcrypt.hash(password, 10)
|
||||
}
|
||||
|
||||
export async function comparePassword(password, hash) {
|
||||
return bcrypt.compare(password, hash)
|
||||
}
|
||||
|
||||
export function isAdminEmail(email) {
|
||||
const adminEmail = process.env.ADMIN_EMAIL?.trim().toLowerCase()
|
||||
if (!adminEmail) return false
|
||||
return normalizeEmail(email) === adminEmail
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { normalizeEmail } from './auth.js'
|
||||
import { generateAvatar } from './generate-avatar.js'
|
||||
import { prisma } from './prisma.js'
|
||||
|
||||
export async function ensureAdminUser() {
|
||||
const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL)
|
||||
if (!adminEmail) return
|
||||
if (!adminEmail.includes('@')) {
|
||||
throw new Error('ADMIN_EMAIL должен быть валидным email')
|
||||
}
|
||||
|
||||
const avatarUri = await generateAvatar(adminEmail)
|
||||
await prisma.user.upsert({
|
||||
where: { email: adminEmail },
|
||||
update: {},
|
||||
create: { email: adminEmail, avatar: avatarUri, avatarStyle: 'avataaars' },
|
||||
})
|
||||
|
||||
// Ensure admin notification settings exist
|
||||
const existing = await prisma.adminNotificationSettings.findFirst()
|
||||
if (!existing) {
|
||||
await prisma.adminNotificationSettings.create({
|
||||
data: {
|
||||
emailEnabled: true,
|
||||
telegramEnabled: false,
|
||||
newOrder: true,
|
||||
newOrderMessage: true,
|
||||
newReview: true,
|
||||
authCodeDuplicate: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
import { prisma } from './prisma.js'
|
||||
|
||||
/** Служебная категория для товаров без выбранной категории. Slug не менять. */
|
||||
export const UNSPECIFIED_CATEGORY_SLUG = 'ne-ukazano'
|
||||
|
||||
export async function getOrCreateUnspecifiedCategory() {
|
||||
return prisma.category.upsert({
|
||||
where: { slug: UNSPECIFIED_CATEGORY_SLUG },
|
||||
update: {},
|
||||
create: {
|
||||
name: 'Не указано',
|
||||
slug: UNSPECIFIED_CATEGORY_SLUG,
|
||||
sort: 9999,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function isUnspecifiedCategorySlug(slug) {
|
||||
return slug === UNSPECIFIED_CATEGORY_SLUG
|
||||
}
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
import { DELIVERY_CARRIERS } from '../../../shared/constants/delivery-carrier.js'
|
||||
|
||||
export { DELIVERY_CARRIERS }
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @returns {value is typeof DELIVERY_CARRIERS[number]}
|
||||
*/
|
||||
export function isDeliveryCarrier(value) {
|
||||
return typeof value === 'string' && DELIVERY_CARRIERS.includes(value)
|
||||
}
|
||||
Executable
+64
@@ -0,0 +1,64 @@
|
||||
import nodemailer from 'nodemailer'
|
||||
|
||||
function hasSmtpEnv() {
|
||||
return Boolean(process.env.SMTP_HOST && process.env.SMTP_PORT && process.env.SMTP_USER && process.env.SMTP_PASS)
|
||||
}
|
||||
|
||||
function createTransporter() {
|
||||
return nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: Number(process.env.SMTP_PORT),
|
||||
secure: process.env.SMTP_SECURE === 'true',
|
||||
connectionTimeout: 5000,
|
||||
greetingTimeout: 5000,
|
||||
socketTimeout: 5000,
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function sendLoginCodeEmail({ to, code }) {
|
||||
if (!hasSmtpEnv()) {
|
||||
console.info(`[DEV] login code for ${to}: ${code}`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const transporter = createTransporter()
|
||||
const from = process.env.MAIL_FROM || process.env.SMTP_USER
|
||||
|
||||
await transporter.sendMail({
|
||||
from,
|
||||
to,
|
||||
subject: 'Код входа',
|
||||
text: `Ваш код: ${code}\n\nЕсли это были не вы — просто проигнорируйте письмо.`,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(`[email] Failed to send login code to ${to}: ${err.message}`)
|
||||
console.info(`[DEV] login code for ${to}: ${code}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendNotificationEmail({ to, subject, html }) {
|
||||
if (!hasSmtpEnv()) {
|
||||
console.info(`[DEV] notification email to ${to}: ${subject}`)
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
try {
|
||||
const transporter = createTransporter()
|
||||
const from = process.env.MAIL_FROM || process.env.SMTP_USER
|
||||
|
||||
await transporter.sendMail({
|
||||
from,
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
})
|
||||
return { success: true }
|
||||
} catch (err) {
|
||||
return { success: false, error: err.message }
|
||||
}
|
||||
}
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
/** Минимальное экранирование для безопасного HTML из пользовательского ввода. */
|
||||
export function escapeHtml(input) {
|
||||
return String(input ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
export async function findUserOrder(prisma, orderId, userId, include = {}) {
|
||||
const order = await prisma.order.findFirst({
|
||||
where: { id: orderId, userId },
|
||||
include,
|
||||
})
|
||||
|
||||
if (!order) {
|
||||
throw Object.assign(new Error('Order not found'), { statusCode: 404 })
|
||||
}
|
||||
|
||||
return order
|
||||
}
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
import { initials } from '@dicebear/collection'
|
||||
import { createAvatar } from '@dicebear/core'
|
||||
|
||||
const DEFAULT_STYLE = initials
|
||||
|
||||
export async function generateAvatar(seed) {
|
||||
const avatar = createAvatar(DEFAULT_STYLE, { seed: String(seed) })
|
||||
return avatar.toDataUri()
|
||||
}
|
||||
Executable
+143
@@ -0,0 +1,143 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const UPLOADS_DIR = path.join(process.cwd(), 'uploads')
|
||||
const CACHE_DIR = path.join(UPLOADS_DIR, '.cache')
|
||||
const VALID_WIDTHS = [320, 640, 1024, 1600]
|
||||
const SUPPORTED_FORMATS = new Set(['avif', 'webp'])
|
||||
|
||||
/**
|
||||
* Find the original file by UUID (without extension) in the uploads directory tree.
|
||||
* Searches both /uploads/ and /uploads/reviews/.
|
||||
* Returns full path or null.
|
||||
*/
|
||||
export async function findOriginalFile(uuid, subdir = '') {
|
||||
const searchDirs = subdir ? [subdir] : ['', 'reviews']
|
||||
for (const dir of searchDirs) {
|
||||
for (const ext of ['.png', '.jpg', '.jpeg', '.webp']) {
|
||||
const fullPath = path.join(UPLOADS_DIR, dir, `${uuid}${ext}`)
|
||||
try {
|
||||
await fs.promises.access(fullPath)
|
||||
return fullPath
|
||||
} catch {
|
||||
// file not found with this extension, try next
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or generate a resized image. Returns { path: string, isNew: boolean }.
|
||||
*/
|
||||
export async function getOrCreateResized(uuid, width, format, subdir = '') {
|
||||
const cacheSubdir = subdir ? subdir : ''
|
||||
const cacheFileName = `${uuid}_w${width}.${format}`
|
||||
const cachePath = path.join(CACHE_DIR, cacheSubdir, cacheFileName)
|
||||
|
||||
try {
|
||||
await fs.promises.access(cachePath)
|
||||
return { path: cachePath, isNew: false }
|
||||
} catch {
|
||||
// cache miss, need to generate
|
||||
}
|
||||
|
||||
const originalPath = await findOriginalFile(uuid, subdir)
|
||||
if (!originalPath) {
|
||||
return null
|
||||
}
|
||||
|
||||
await fs.promises.mkdir(path.dirname(cachePath), { recursive: true })
|
||||
|
||||
let sharpModule
|
||||
try {
|
||||
sharpModule = (await import('sharp')).default
|
||||
} catch (err) {
|
||||
const msg = `Failed to load sharp image processing library: ${err.message}`
|
||||
throw Object.assign(new Error(msg), { cause: err, code: 'SHARP_LOAD_ERROR' })
|
||||
}
|
||||
|
||||
let pipeline
|
||||
try {
|
||||
pipeline = sharpModule(originalPath)
|
||||
|
||||
if (width) {
|
||||
pipeline = pipeline.resize(width, null, { withoutEnlargement: true })
|
||||
}
|
||||
|
||||
const options = format === 'avif' ? { quality: 75, effort: 4 } : { quality: 80 }
|
||||
await pipeline[format](options).toFile(cachePath)
|
||||
} catch (err) {
|
||||
const msg = `Failed to resize image ${originalPath} to ${width}w ${format}: ${err.message}`
|
||||
throw Object.assign(new Error(msg), { cause: err, code: 'SHARP_RESIZE_ERROR' })
|
||||
}
|
||||
|
||||
return { path: cachePath, isNew: true }
|
||||
}
|
||||
|
||||
export { VALID_WIDTHS, SUPPORTED_FORMATS }
|
||||
|
||||
/**
|
||||
* Generate all resize widths in AVIF + WebP for eager processing.
|
||||
* @param {string} uuid - UUID without extension
|
||||
* @param {string} subdir - Subdirectory (e.g., 'reviews') or empty
|
||||
* @param {string} originalPath - Full path to the original file
|
||||
*/
|
||||
export async function generateAllSizes(uuid, subdir, originalPath) {
|
||||
const cacheSubdir = subdir ? subdir : ''
|
||||
const cacheDir = path.join(CACHE_DIR, cacheSubdir)
|
||||
await fs.promises.mkdir(cacheDir, { recursive: true })
|
||||
|
||||
let sharpModule
|
||||
try {
|
||||
sharpModule = (await import('sharp')).default
|
||||
} catch (err) {
|
||||
const msg = `Failed to load sharp image processing library: ${err.message}`
|
||||
throw Object.assign(new Error(msg), { cause: err, code: 'SHARP_LOAD_ERROR' })
|
||||
}
|
||||
|
||||
const source = sharpModule(originalPath)
|
||||
|
||||
for (const width of VALID_WIDTHS) {
|
||||
for (const format of SUPPORTED_FORMATS) {
|
||||
const cacheFileName = `${uuid}_w${width}.${format}`
|
||||
const cachePath = path.join(CACHE_DIR, cacheSubdir, cacheFileName)
|
||||
|
||||
try {
|
||||
const pipeline = source.clone().resize(width, null, { withoutEnlargement: true })
|
||||
const options = format === 'avif' ? { quality: 75, effort: 4 } : { quality: 80 }
|
||||
await pipeline[format](options).toFile(cachePath)
|
||||
} catch (err) {
|
||||
const msg = `Failed to generate ${width}w ${format} for ${originalPath}: ${err.message}`
|
||||
throw Object.assign(new Error(msg), { cause: err, code: 'SHARP_RESIZE_ERROR' })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert original file to WebP and delete the source file.
|
||||
* @param {string} uuid - UUID without extension
|
||||
* @param {string} subdir - Subdirectory (e.g., 'reviews') or empty
|
||||
* @returns {string} New URL path like `/uploads/<uuid>.webp`
|
||||
*/
|
||||
export async function convertOriginalToWebp(uuid, subdir) {
|
||||
const targetDir = subdir ? path.join(UPLOADS_DIR, subdir) : UPLOADS_DIR
|
||||
|
||||
const originalPath = await findOriginalFile(uuid, subdir)
|
||||
if (!originalPath) {
|
||||
throw new Error(`Original file not found for UUID: ${uuid}`)
|
||||
}
|
||||
|
||||
const originalExt = path.extname(originalPath).toLowerCase()
|
||||
const webpPath = path.join(targetDir, `${uuid}.webp`)
|
||||
|
||||
const sharp = (await import('sharp')).default
|
||||
await sharp(originalPath).webp({ quality: 80 }).toFile(webpPath)
|
||||
|
||||
if (originalExt !== '.webp') {
|
||||
await fs.promises.unlink(originalPath)
|
||||
}
|
||||
|
||||
return subdir ? `/uploads/${subdir}/${uuid}.webp` : `/uploads/${uuid}.webp`
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { prisma } from '../../prisma.js'
|
||||
import {
|
||||
resolveUserNotificationTargets,
|
||||
resolveAdminNotificationTargets,
|
||||
resolveAuthCodeTargets,
|
||||
ensureUserNotificationPreference,
|
||||
} from '../preferences.js'
|
||||
|
||||
const ORDER_CREATED = 'order:created'
|
||||
const AUTH_CODE_REQUESTED = 'auth:codeRequested'
|
||||
|
||||
describe('preferences', () => {
|
||||
beforeEach(async () => {
|
||||
await prisma.notificationPreference.deleteMany()
|
||||
await prisma.adminNotificationSettings.deleteMany()
|
||||
await prisma.user.deleteMany()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.notificationPreference.deleteMany()
|
||||
await prisma.adminNotificationSettings.deleteMany()
|
||||
await prisma.user.deleteMany()
|
||||
})
|
||||
|
||||
it('returns empty targets when user has no preferences', async () => {
|
||||
const user = await prisma.user.create({ data: { email: 'test@test.com' } })
|
||||
const targets = await resolveUserNotificationTargets(ORDER_CREATED, { userId: user.id })
|
||||
expect(targets).toEqual([])
|
||||
})
|
||||
|
||||
it('returns email target when user has preferences enabled', async () => {
|
||||
const user = await prisma.user.create({ data: { email: 'test@test.com' } })
|
||||
await prisma.notificationPreference.create({
|
||||
data: { userId: user.id, globalEnabled: true, orderCreated: true },
|
||||
})
|
||||
const targets = await resolveUserNotificationTargets(ORDER_CREATED, { userId: user.id })
|
||||
expect(targets).toHaveLength(1)
|
||||
expect(targets[0]).toEqual({ channel: 'email', recipient: 'test@test.com' })
|
||||
})
|
||||
|
||||
it('returns no targets when globalEnabled is false', async () => {
|
||||
const user = await prisma.user.create({ data: { email: 'test@test.com' } })
|
||||
await prisma.notificationPreference.create({
|
||||
data: { userId: user.id, globalEnabled: false, orderCreated: true },
|
||||
})
|
||||
const targets = await resolveUserNotificationTargets(ORDER_CREATED, { userId: user.id })
|
||||
expect(targets).toEqual([])
|
||||
})
|
||||
|
||||
it('returns no targets when specific event is disabled', async () => {
|
||||
const user = await prisma.user.create({ data: { email: 'test@test.com' } })
|
||||
await prisma.notificationPreference.create({
|
||||
data: { userId: user.id, globalEnabled: true, orderCreated: false },
|
||||
})
|
||||
const targets = await resolveUserNotificationTargets(ORDER_CREATED, { userId: user.id })
|
||||
expect(targets).toEqual([])
|
||||
})
|
||||
|
||||
it('ensures user preference is created if not exists', async () => {
|
||||
const user = await prisma.user.create({ data: { email: 'test@test.com' } })
|
||||
const prefs = await ensureUserNotificationPreference(user.id)
|
||||
expect(prefs.globalEnabled).toBe(true)
|
||||
expect(prefs.userId).toBe(user.id)
|
||||
})
|
||||
|
||||
it('returns admin targets when settings enabled', async () => {
|
||||
await prisma.user.create({ data: { email: 'admin@test.com' } })
|
||||
const origAdminEmail = process.env.ADMIN_EMAIL
|
||||
process.env.ADMIN_EMAIL = 'admin@test.com'
|
||||
|
||||
await prisma.adminNotificationSettings.create({
|
||||
data: { emailEnabled: true, newOrder: true },
|
||||
})
|
||||
|
||||
const targets = await resolveAdminNotificationTargets(ORDER_CREATED, {})
|
||||
expect(targets.some((t) => t.channel === 'email' && t.recipient === 'admin@test.com')).toBe(true)
|
||||
|
||||
process.env.ADMIN_EMAIL = origAdminEmail
|
||||
})
|
||||
|
||||
it('resolveAuthCodeTargets returns email for user and telegram for admin', async () => {
|
||||
await prisma.adminNotificationSettings.create({
|
||||
data: { telegramEnabled: true, telegramChatId: '12345', authCodeDuplicate: true },
|
||||
})
|
||||
|
||||
const targets = await resolveAuthCodeTargets(AUTH_CODE_REQUESTED, {
|
||||
email: 'user@test.com',
|
||||
code: '123456',
|
||||
isAdmin: true,
|
||||
})
|
||||
|
||||
expect(targets.some((t) => t.channel === 'email' && t.recipient === 'user@test.com')).toBe(true)
|
||||
expect(targets.some((t) => t.channel === 'telegram' && t.recipient === '12345')).toBe(true)
|
||||
})
|
||||
})
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
// server/src/lib/notifications/channels/email-channel.js
|
||||
import { sendNotificationEmail } from '../../email.js'
|
||||
import {
|
||||
renderAdminOrderMessageEmail,
|
||||
renderOrderCreatedEmail,
|
||||
renderOrderStatusChangedEmail,
|
||||
renderOrderMessageEmail,
|
||||
renderPaymentStatusChangedEmail,
|
||||
renderAdminOrderCreatedEmail,
|
||||
renderAdminNewReviewEmail,
|
||||
renderAuthCodeEmail,
|
||||
renderDeliveryFeeAdjustedEmail,
|
||||
} from '../templates/email-templates.js'
|
||||
|
||||
const templateRenderers = {
|
||||
'order:created': renderOrderCreatedEmail,
|
||||
'order:statusChanged': renderOrderStatusChangedEmail,
|
||||
'orderMessage:adminReply': renderOrderMessageEmail,
|
||||
'payment:statusChanged': renderPaymentStatusChangedEmail,
|
||||
'order:created:admin': renderAdminOrderCreatedEmail,
|
||||
'orderMessage:sent': renderAdminOrderMessageEmail,
|
||||
'review:created': renderAdminNewReviewEmail,
|
||||
'auth:codeRequested': renderAuthCodeEmail,
|
||||
'order:deliveryFeeAdjusted': renderDeliveryFeeAdjustedEmail,
|
||||
}
|
||||
|
||||
export const emailChannel = {
|
||||
name: 'email',
|
||||
|
||||
async send({ recipient, eventType, payload }) {
|
||||
const renderer = templateRenderers[eventType]
|
||||
if (!renderer) {
|
||||
return { success: false, error: `No email template for event: ${eventType}` }
|
||||
}
|
||||
|
||||
const { subject, html } = renderer(payload)
|
||||
const result = await sendNotificationEmail({ to: recipient, subject, html })
|
||||
return result
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
renderOrderCreatedTg,
|
||||
renderOrderStatusChangedTg,
|
||||
renderOrderMessageTg,
|
||||
renderPaymentStatusChangedTg,
|
||||
renderAdminOrderCreatedTg,
|
||||
renderAdminNewReviewTg,
|
||||
renderAuthCodeTg,
|
||||
renderDeliveryFeeAdjustedTg,
|
||||
} from '../templates/telegram-templates.js'
|
||||
|
||||
const TELEGRAM_BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN || ''
|
||||
|
||||
const templateRenderers = {
|
||||
'order:created': renderOrderCreatedTg,
|
||||
'order:statusChanged': renderOrderStatusChangedTg,
|
||||
'orderMessage:adminReply': renderOrderMessageTg,
|
||||
'payment:statusChanged': renderPaymentStatusChangedTg,
|
||||
'order:created:admin': renderAdminOrderCreatedTg,
|
||||
'orderMessage:sent': renderOrderMessageTg,
|
||||
'review:created': renderAdminNewReviewTg,
|
||||
'auth:codeRequested': renderAuthCodeTg,
|
||||
'order:deliveryFeeAdjusted': renderDeliveryFeeAdjustedTg,
|
||||
}
|
||||
|
||||
async function postToTelegram(chatId, text) {
|
||||
if (!TELEGRAM_BOT_TOKEN) {
|
||||
console.info(`[DEV] telegram to ${chatId}: ${text.slice(0, 80)}`)
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chat_id: chatId,
|
||||
text,
|
||||
parse_mode: 'HTML',
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
if (!data.ok) {
|
||||
return { success: false, error: data.description || 'Telegram API error' }
|
||||
}
|
||||
return { success: true }
|
||||
} catch (err) {
|
||||
return { success: false, error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
export const telegramChannel = {
|
||||
name: 'telegram',
|
||||
|
||||
async send({ recipient: chatId, eventType, payload }) {
|
||||
if (!chatId) {
|
||||
return { success: false, error: 'No telegram chatId' }
|
||||
}
|
||||
|
||||
const renderer = templateRenderers[eventType]
|
||||
if (!renderer) {
|
||||
return { success: false, error: `No telegram template for event: ${eventType}` }
|
||||
}
|
||||
|
||||
const text = renderer(payload)
|
||||
return postToTelegram(chatId, text)
|
||||
},
|
||||
}
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
|
||||
export function createEventBus() {
|
||||
const bus = new EventEmitter()
|
||||
bus.setMaxListeners(50)
|
||||
return bus
|
||||
}
|
||||
Executable
+105
@@ -0,0 +1,105 @@
|
||||
import { NOTIFICATION_EVENTS } from '../../../../shared/constants/notification-events.js'
|
||||
import { prisma } from '../prisma.js'
|
||||
|
||||
const {
|
||||
ORDER_CREATED,
|
||||
ORDER_STATUS_CHANGED,
|
||||
ORDER_MESSAGE_SENT,
|
||||
ORDER_MESSAGE_ADMIN_REPLY,
|
||||
PAYMENT_STATUS_CHANGED,
|
||||
DELIVERY_FEE_ADJUSTED,
|
||||
} = NOTIFICATION_EVENTS
|
||||
|
||||
const userEventFieldMap = {
|
||||
[ORDER_CREATED]: 'orderCreated',
|
||||
[ORDER_STATUS_CHANGED]: 'orderStatusChanged',
|
||||
[ORDER_MESSAGE_ADMIN_REPLY]: 'orderMessageReceived',
|
||||
[PAYMENT_STATUS_CHANGED]: 'paymentStatusChanged',
|
||||
[DELIVERY_FEE_ADJUSTED]: 'deliveryFeeAdjusted',
|
||||
}
|
||||
|
||||
const adminEventFieldMap = {
|
||||
[ORDER_MESSAGE_SENT]: 'newOrderMessage',
|
||||
'review:created': 'newReview',
|
||||
}
|
||||
|
||||
export async function resolveUserNotificationTargets(eventType, payload) {
|
||||
const targets = []
|
||||
|
||||
if (payload.userId) {
|
||||
const prefs = await prisma.notificationPreference.findUnique({
|
||||
where: { userId: payload.userId },
|
||||
})
|
||||
|
||||
if (prefs && prefs.globalEnabled) {
|
||||
const field = userEventFieldMap[eventType]
|
||||
if (field && prefs[field]) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: payload.userId },
|
||||
select: { email: true },
|
||||
})
|
||||
if (user) {
|
||||
targets.push({ channel: 'email', recipient: user.email })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return targets
|
||||
}
|
||||
|
||||
export async function resolveAdminNotificationTargets(eventType, payload) {
|
||||
const targets = []
|
||||
const settings = await prisma.adminNotificationSettings.findFirst()
|
||||
if (!settings) return targets
|
||||
|
||||
const field = adminEventFieldMap[eventType]
|
||||
if (field === 'newReview') {
|
||||
if (!settings.newReview) return targets
|
||||
} else if (field && !settings[field]) {
|
||||
return targets
|
||||
}
|
||||
|
||||
if (settings.emailEnabled) {
|
||||
const admin = await prisma.user.findFirst({
|
||||
where: { email: process.env.ADMIN_EMAIL },
|
||||
select: { email: true },
|
||||
})
|
||||
if (admin) {
|
||||
targets.push({ channel: 'email', recipient: admin.email })
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.telegramEnabled && settings.telegramChatId) {
|
||||
targets.push({ channel: 'telegram', recipient: settings.telegramChatId })
|
||||
}
|
||||
|
||||
return targets
|
||||
}
|
||||
|
||||
export async function resolveAuthCodeTargets(eventType, payload) {
|
||||
const targets = []
|
||||
|
||||
if (payload.email) {
|
||||
targets.push({ channel: 'email', recipient: payload.email })
|
||||
}
|
||||
|
||||
if (payload.isAdmin) {
|
||||
const settings = await prisma.adminNotificationSettings.findFirst()
|
||||
if (settings && settings.telegramEnabled && settings.telegramChatId && settings.authCodeDuplicate) {
|
||||
targets.push({ channel: 'telegram', recipient: settings.telegramChatId })
|
||||
}
|
||||
}
|
||||
|
||||
return targets
|
||||
}
|
||||
|
||||
export async function ensureUserNotificationPreference(userId) {
|
||||
const existing = await prisma.notificationPreference.findUnique({
|
||||
where: { userId },
|
||||
})
|
||||
if (existing) return existing
|
||||
return prisma.notificationPreference.create({
|
||||
data: { userId, globalEnabled: true },
|
||||
})
|
||||
}
|
||||
Executable
+134
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
NOTIFICATION_STATUSES,
|
||||
MAX_RETRY_ATTEMPTS,
|
||||
RETRY_DELAYS_MS,
|
||||
} from '../../../../shared/constants/notification-events.js'
|
||||
import { prisma } from '../prisma.js'
|
||||
import { emailChannel } from './channels/email-channel.js'
|
||||
import { telegramChannel } from './channels/telegram-channel.js'
|
||||
|
||||
const { PENDING, SENT, FAILED } = NOTIFICATION_STATUSES
|
||||
|
||||
const channels = {
|
||||
email: emailChannel,
|
||||
telegram: telegramChannel,
|
||||
}
|
||||
|
||||
class NotificationQueue {
|
||||
constructor() {
|
||||
this.tasks = []
|
||||
this.processing = 0
|
||||
this.maxConcurrent = 5
|
||||
this.intervalMs = 2000
|
||||
this.running = false
|
||||
}
|
||||
|
||||
enqueue(task) {
|
||||
this.tasks.push({ ...task, enqueuedAt: Date.now() })
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.running) return
|
||||
this.running = true
|
||||
this._tick()
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.running = false
|
||||
}
|
||||
|
||||
_tick() {
|
||||
if (!this.running) return
|
||||
|
||||
this._processAvailable()
|
||||
|
||||
setTimeout(() => this._tick(), this.intervalMs)
|
||||
}
|
||||
|
||||
_processAvailable() {
|
||||
while (this.tasks.length > 0 && this.processing < this.maxConcurrent) {
|
||||
const task = this.tasks.shift()
|
||||
this.processing++
|
||||
this._execute(task).finally(() => {
|
||||
this.processing--
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async _execute(task) {
|
||||
const channel = channels[task.channel]
|
||||
if (!channel) {
|
||||
await this._markFailed(task.logId, `Unknown channel: ${task.channel}`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await channel.send({
|
||||
recipient: task.recipient,
|
||||
eventType: task.eventType,
|
||||
payload: task.payload,
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
await this._markSent(task.logId)
|
||||
} else {
|
||||
await this._handleFailure(task.logId, task, result.error)
|
||||
}
|
||||
} catch (err) {
|
||||
await this._handleFailure(task.logId, task, err.message)
|
||||
}
|
||||
}
|
||||
|
||||
async _markSent(logId) {
|
||||
await prisma.notificationLog.update({
|
||||
where: { id: logId },
|
||||
data: { status: SENT },
|
||||
})
|
||||
}
|
||||
|
||||
async _markFailed(logId, error) {
|
||||
await prisma.notificationLog.update({
|
||||
where: { id: logId },
|
||||
data: { status: FAILED, error },
|
||||
})
|
||||
}
|
||||
|
||||
async _handleFailure(logId, task, error) {
|
||||
const log = await prisma.notificationLog.findUnique({ where: { id: logId } })
|
||||
const newAttempts = (log?.attempts || 0) + 1
|
||||
|
||||
if (newAttempts >= MAX_RETRY_ATTEMPTS) {
|
||||
await this._markFailed(logId, error)
|
||||
return
|
||||
}
|
||||
|
||||
await prisma.notificationLog.update({
|
||||
where: { id: logId },
|
||||
data: { attempts: newAttempts },
|
||||
})
|
||||
|
||||
const delay = RETRY_DELAYS_MS[newAttempts - 1] || RETRY_DELAYS_MS[RETRY_DELAYS_MS.length - 1]
|
||||
setTimeout(() => {
|
||||
this.enqueue({ ...task, logId })
|
||||
}, delay)
|
||||
}
|
||||
|
||||
async flushPendingOnStartup() {
|
||||
const pending = await prisma.notificationLog.findMany({
|
||||
where: { status: PENDING },
|
||||
})
|
||||
for (const log of pending) {
|
||||
await prisma.notificationLog.update({
|
||||
where: { id: log.id },
|
||||
data: { status: FAILED, error: 'Server restarted, pending notification lost' },
|
||||
})
|
||||
}
|
||||
if (pending.length > 0) {
|
||||
console.info(`[notifications] Marked ${pending.length} pending notifications as failed on startup`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createNotificationQueue() {
|
||||
return new NotificationQueue()
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
renderAdminOrderMessageEmail,
|
||||
renderDeliveryFeeAdjustedEmail,
|
||||
renderOrderCreatedEmail,
|
||||
renderOrderMessageEmail,
|
||||
renderOrderStatusChangedEmail,
|
||||
renderPaymentStatusChangedEmail,
|
||||
} from '../email-templates.js'
|
||||
|
||||
const originalClientPublicUrl = process.env.CLIENT_PUBLIC_URL
|
||||
const orderId = 'order-123456789'
|
||||
|
||||
afterEach(() => {
|
||||
if (originalClientPublicUrl === undefined) {
|
||||
delete process.env.CLIENT_PUBLIC_URL
|
||||
return
|
||||
}
|
||||
process.env.CLIENT_PUBLIC_URL = originalClientPublicUrl
|
||||
})
|
||||
|
||||
describe('email templates', () => {
|
||||
it('adds personal account order links to order emails', () => {
|
||||
process.env.CLIENT_PUBLIC_URL = 'https://shop.example.com/'
|
||||
const expectedUrl = `https://shop.example.com/me/orders/${orderId}`
|
||||
|
||||
const emails = [
|
||||
renderOrderCreatedEmail({ orderId, totalCents: 120000, itemsCount: 2, deliveryType: 'pickup' }),
|
||||
renderOrderStatusChangedEmail({ orderId, oldStatus: 'PENDING_PAYMENT', newStatus: 'PAID' }),
|
||||
renderPaymentStatusChangedEmail({ orderId, paymentStatus: 'confirmed' }),
|
||||
renderDeliveryFeeAdjustedEmail({ orderId, totalCents: 135000 }),
|
||||
]
|
||||
|
||||
for (const email of emails) {
|
||||
expect(email.html).toContain(`href="${expectedUrl}"`)
|
||||
}
|
||||
})
|
||||
|
||||
it('adds personal account messages link to order message emails', () => {
|
||||
process.env.CLIENT_PUBLIC_URL = 'https://shop.example.com'
|
||||
|
||||
const email = renderOrderMessageEmail({ orderId, preview: 'Здравствуйте' })
|
||||
|
||||
expect(email.html).toContain('href="https://shop.example.com/me/messages"')
|
||||
})
|
||||
|
||||
it('renders paid payment status as paid in Russian', () => {
|
||||
const email = renderPaymentStatusChangedEmail({ orderId, paymentStatus: 'paid' })
|
||||
|
||||
expect(email.subject).toBe('Оплата заказа — Оплачен')
|
||||
expect(email.html).toContain('<b>Оплачен</b>')
|
||||
expect(email.html).not.toContain('<b>paid</b>')
|
||||
})
|
||||
|
||||
it('adds admin orders link to admin order message emails', () => {
|
||||
process.env.CLIENT_PUBLIC_URL = 'https://shop.example.com'
|
||||
|
||||
const email = renderAdminOrderMessageEmail({ orderId, preview: 'Нужна консультация' })
|
||||
|
||||
expect(email.html).toContain('href="https://shop.example.com/admin/orders"')
|
||||
})
|
||||
})
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
function baseLayout(title, body) {
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><title>${title}</title></head>
|
||||
<body style="font-family:system-ui,sans-serif;max-width:600px;margin:0 auto;padding:20px;color:#1a1a1a;">
|
||||
<div style="background:#f8f9fa;padding:16px;border-radius:8px;margin-bottom:16px;">
|
||||
<h2 style="margin:0;">${title}</h2>
|
||||
</div>
|
||||
${body}
|
||||
<div style="margin-top:24px;padding-top:16px;border-top:1px solid #e0e0e0;color:#666;font-size:14px;">
|
||||
<p>Любимый Креатив — магазин handmade изделий</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
function getClientPublicUrl() {
|
||||
return (process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173').replace(/\/$/, '')
|
||||
}
|
||||
|
||||
function buildClientUrl(path) {
|
||||
return `${getClientPublicUrl()}${path}`
|
||||
}
|
||||
|
||||
function renderActionLink(url, label) {
|
||||
return `
|
||||
<p style="margin:20px 0;">
|
||||
<a href="${url}" style="display:inline-block;background:#1f2937;color:#fff;text-decoration:none;padding:10px 16px;border-radius:6px;font-weight:600;">
|
||||
${label}
|
||||
</a>
|
||||
</p>
|
||||
`
|
||||
}
|
||||
|
||||
function buildOrderUrl(orderId) {
|
||||
return buildClientUrl(`/me/orders/${encodeURIComponent(orderId)}`)
|
||||
}
|
||||
|
||||
function buildMessagesUrl() {
|
||||
return buildClientUrl('/me/messages')
|
||||
}
|
||||
|
||||
function buildAdminOrdersUrl() {
|
||||
return buildClientUrl('/admin/orders')
|
||||
}
|
||||
|
||||
export function renderOrderCreatedEmail({ orderId, totalCents, itemsCount, deliveryType }) {
|
||||
const total = (totalCents / 100).toLocaleString('ru-RU')
|
||||
const nextAction =
|
||||
deliveryType === 'delivery' ? 'Оплата будет доступна после уточнения стоимости доставки.' : 'Ожидает оплаты.'
|
||||
const body = `
|
||||
<p>Ваш заказ <b>#${orderId.slice(0, 8)}</b> успешно создан.</p>
|
||||
<p>Товаров: ${itemsCount} | Сумма: <b>${total} ₽</b></p>
|
||||
<p>${nextAction}</p>
|
||||
${renderActionLink(buildOrderUrl(orderId), 'Открыть заказ в личном кабинете')}
|
||||
`
|
||||
return { subject: 'Заказ создан', html: baseLayout('Заказ создан', body) }
|
||||
}
|
||||
|
||||
export function renderOrderStatusChangedEmail({ orderId, oldStatus, newStatus }) {
|
||||
const statusLabels = {
|
||||
DRAFT: 'Черновик',
|
||||
PENDING_PAYMENT: 'Ожидает оплаты',
|
||||
PAID: 'Оплачен',
|
||||
IN_PROGRESS: 'Подготовка к отправке',
|
||||
READY_FOR_PICKUP: 'Готов к выдаче',
|
||||
SHIPPED: 'Отправлен',
|
||||
DONE: 'Завершён',
|
||||
CANCELLED: 'Отменён',
|
||||
}
|
||||
const oldLabel = statusLabels[oldStatus] || oldStatus
|
||||
const newLabel = statusLabels[newStatus] || newStatus
|
||||
const body = `
|
||||
<p>Статус заказа <b>#${orderId.slice(0, 8)}</b> изменён.</p>
|
||||
<p><b>${oldLabel}</b> → <b>${newLabel}</b></p>
|
||||
${renderActionLink(buildOrderUrl(orderId), 'Открыть заказ в личном кабинете')}
|
||||
`
|
||||
return {
|
||||
subject: `Статус заказа изменён — ${newLabel}`,
|
||||
html: baseLayout('Статус заказа изменён', body),
|
||||
}
|
||||
}
|
||||
|
||||
export function renderOrderMessageEmail({ orderId, preview }) {
|
||||
const truncated = preview.length > 200 ? preview.slice(0, 197) + '...' : preview
|
||||
const body = `
|
||||
<p>Новое сообщение к заказу <b>#${orderId.slice(0, 8)}</b>:</p>
|
||||
<div style="background:#f0f0f0;padding:12px;border-radius:6px;margin:12px 0;">
|
||||
${truncated}
|
||||
</div>
|
||||
<p>Ответьте в личном кабинете.</p>
|
||||
${renderActionLink(buildMessagesUrl(), 'Открыть сообщения в личном кабинете')}
|
||||
`
|
||||
return {
|
||||
subject: 'Новое сообщение к заказу',
|
||||
html: baseLayout('Новое сообщение', body),
|
||||
}
|
||||
}
|
||||
|
||||
export function renderAdminOrderMessageEmail({ orderId, preview }) {
|
||||
const truncated = preview.length > 200 ? preview.slice(0, 197) + '...' : preview
|
||||
const body = `
|
||||
<p>Новое сообщение к заказу <b>#${orderId.slice(0, 8)}</b>:</p>
|
||||
<div style="background:#f0f0f0;padding:12px;border-radius:6px;margin:12px 0;">
|
||||
${truncated}
|
||||
</div>
|
||||
<p>Ответьте в админ-панели.</p>
|
||||
${renderActionLink(buildAdminOrdersUrl(), 'Открыть заказы в админ-панели')}
|
||||
`
|
||||
return {
|
||||
subject: 'Новое сообщение к заказу',
|
||||
html: baseLayout('Новое сообщение', body),
|
||||
}
|
||||
}
|
||||
|
||||
export function renderPaymentStatusChangedEmail({ orderId, paymentStatus }) {
|
||||
const statusLabels = {
|
||||
pending: 'Ожидает',
|
||||
paid: 'Оплачен',
|
||||
confirmed: 'Подтверждён',
|
||||
rejected: 'Отклонён',
|
||||
}
|
||||
const label = statusLabels[paymentStatus] || paymentStatus
|
||||
const body = `
|
||||
<p>Статус оплаты заказа <b>#${orderId.slice(0, 8)}</b>: <b>${label}</b>.</p>
|
||||
${renderActionLink(buildOrderUrl(orderId), 'Открыть заказ в личном кабинете')}
|
||||
`
|
||||
return {
|
||||
subject: `Оплата заказа — ${label}`,
|
||||
html: baseLayout('Оплата заказа', body),
|
||||
}
|
||||
}
|
||||
|
||||
export function renderAdminOrderCreatedEmail({ orderId, userEmail, totalCents, itemsCount, deliveryType }) {
|
||||
const total = (totalCents / 100).toLocaleString('ru-RU')
|
||||
const note = deliveryType === 'delivery' ? '<p>⚠️ <b>Скорректируйте стоимость доставки</b> в админ-панели.</p>' : ''
|
||||
const body = `
|
||||
<p>Новый заказ <b>#${orderId.slice(0, 8)}</b> от <b>${userEmail}</b>.</p>
|
||||
<p>Товаров: ${itemsCount} | Сумма: <b>${total} ₽</b></p>
|
||||
${note}
|
||||
`
|
||||
return { subject: 'Новый заказ', html: baseLayout('Новый заказ', body) }
|
||||
}
|
||||
|
||||
export function renderAdminNewReviewEmail({ rating, text, productTitle, userName }) {
|
||||
const stars = '★'.repeat(rating) + '☆'.repeat(5 - rating)
|
||||
const body = `
|
||||
<p>Новый отзыв ${stars} на товар <b>${productTitle}</b> от <b>${userName}</b>.</p>
|
||||
${text ? `<div style="background:#f0f0f0;padding:12px;border-radius:6px;margin:12px 0;">${text}</div>` : ''}
|
||||
<p>Проверьте отзыв в админ-панели.</p>
|
||||
`
|
||||
return { subject: 'Новый отзыв', html: baseLayout('Новый отзыв', body) }
|
||||
}
|
||||
|
||||
export function renderAuthCodeEmail({ code }) {
|
||||
const body = `
|
||||
<p>Ваш код входа: <b style="font-size:24px;letter-spacing:4px;">${code}</b></p>
|
||||
<p>Если это были не вы — просто проигнорируйте письмо.</p>
|
||||
`
|
||||
return { subject: 'Код входа', html: baseLayout('Код входа', body) }
|
||||
}
|
||||
|
||||
export function renderDeliveryFeeAdjustedEmail({ orderId, totalCents }) {
|
||||
const total = (totalCents / 100).toLocaleString('ru-RU')
|
||||
const body = `
|
||||
<p>Стоимость доставки заказа <b>#${orderId.slice(0, 8)}</b> скорректирована.</p>
|
||||
<p>Новая сумма: <b>${total} ₽</b></p>
|
||||
<p>Ожидает оплаты. Проверьте статус заказа в личном кабинете.</p>
|
||||
${renderActionLink(buildOrderUrl(orderId), 'Открыть заказ в личном кабинете')}
|
||||
`
|
||||
return {
|
||||
subject: 'Стоимость доставки скорректирована',
|
||||
html: baseLayout('Стоимость доставки скорректирована', body),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
export function renderOrderCreatedTg({ orderId, totalCents, itemsCount, deliveryType }) {
|
||||
const total = (totalCents / 100).toLocaleString('ru-RU')
|
||||
const nextAction =
|
||||
deliveryType === 'delivery' ? 'Оплата будет доступна после уточнения стоимости доставки.' : 'Ожидает оплаты.'
|
||||
return `📦 <b>Новый заказ</b> #${orderId.slice(0, 8)}\nТоваров: ${itemsCount} | Сумма: ${total} ₽\n${nextAction}`
|
||||
}
|
||||
|
||||
export function renderOrderStatusChangedTg({ orderId, oldStatus, newStatus }) {
|
||||
const labels = {
|
||||
DRAFT: 'Черновик',
|
||||
PENDING_PAYMENT: 'Ожидает оплаты',
|
||||
PAID: 'Оплачен',
|
||||
IN_PROGRESS: 'Подготовка к отправке',
|
||||
READY_FOR_PICKUP: 'Готов к выдаче',
|
||||
SHIPPED: 'Отправлен',
|
||||
DONE: 'Завершён',
|
||||
CANCELLED: 'Отменён',
|
||||
}
|
||||
return `🔄 Заказ #${orderId.slice(0, 8)}\n${labels[oldStatus] || oldStatus} → <b>${labels[newStatus] || newStatus}</b>`
|
||||
}
|
||||
|
||||
export function renderOrderMessageTg({ orderId, preview }) {
|
||||
const truncated = preview.length > 300 ? preview.slice(0, 297) + '...' : preview
|
||||
return `💬 Сообщение к заказу #${orderId.slice(0, 8)}\n\n${truncated}`
|
||||
}
|
||||
|
||||
export function renderPaymentStatusChangedTg({ orderId, paymentStatus }) {
|
||||
const labels = { pending: 'Ожидает', paid: 'Оплачен', confirmed: 'Подтверждён', rejected: 'Отклонён' }
|
||||
return `💳 Оплата заказа #${orderId.slice(0, 8)}: <b>${labels[paymentStatus] || paymentStatus}</b>`
|
||||
}
|
||||
|
||||
export function renderAdminOrderCreatedTg({ orderId, userEmail, totalCents, itemsCount, deliveryType }) {
|
||||
const total = (totalCents / 100).toLocaleString('ru-RU')
|
||||
const note = deliveryType === 'delivery' ? '\n\n⚠️ Скорректируйте стоимость доставки' : ''
|
||||
return `🛒 <b>Новый заказ</b> #${orderId.slice(0, 8)}\nОт: ${userEmail}\nТоваров: ${itemsCount} | Сумма: ${total} ₽${note}`
|
||||
}
|
||||
|
||||
export function renderAdminNewReviewTg({ rating, text, productTitle, userName }) {
|
||||
const stars = '⭐'.repeat(rating)
|
||||
return `📝 <b>Новый отзыв</b> ${stars}\nТовар: ${productTitle}\nАвтор: ${userName}${text ? '\n\n' + text : ''}`
|
||||
}
|
||||
|
||||
export function renderAuthCodeTg({ code }) {
|
||||
return `🔐 Код входа: <b>${code}</b>`
|
||||
}
|
||||
|
||||
export function renderDeliveryFeeAdjustedTg({ orderId, totalCents }) {
|
||||
const total = (totalCents / 100).toLocaleString('ru-RU')
|
||||
return `💰 <b>Стоимость доставки скорректирована</b> для заказа #${orderId.slice(0, 8)}\nНовая сумма: ${total} ₽\n\nОжидает оплаты.`
|
||||
}
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
ORDER_STATUSES,
|
||||
getNextAdminStatuses,
|
||||
canTransitionAdminOrderStatus,
|
||||
} from '../../../shared/constants/order-status.js'
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const globalForPrisma = globalThis
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
globalForPrisma.prisma = prisma
|
||||
}
|
||||
Executable
+51
@@ -0,0 +1,51 @@
|
||||
const windows = new Map()
|
||||
|
||||
const DEFAULT_MAX_ATTEMPTS = 5
|
||||
const DEFAULT_WINDOW_MS = 60_000
|
||||
|
||||
// Per-endpoint rate limits
|
||||
const LIMITS = {
|
||||
login: { maxAttempts: 5, windowMs: 60_000 },
|
||||
codeRequest: { maxAttempts: 3, windowMs: 60_000 },
|
||||
codeVerify: { maxAttempts: 5, windowMs: 60_000 },
|
||||
}
|
||||
|
||||
setInterval(() => {
|
||||
const now = Date.now()
|
||||
for (const [ip, entry] of windows) {
|
||||
if (now - entry.start > DEFAULT_WINDOW_MS) windows.delete(ip)
|
||||
}
|
||||
}, 5 * 60_000).unref()
|
||||
|
||||
function getKey(ip, scope) {
|
||||
return `${scope}:${ip}`
|
||||
}
|
||||
|
||||
function checkRateLimit(ip, scope) {
|
||||
const limit = LIMITS[scope] || { maxAttempts: DEFAULT_MAX_ATTEMPTS, windowMs: DEFAULT_WINDOW_MS }
|
||||
const key = getKey(ip, scope)
|
||||
const now = Date.now()
|
||||
const entry = windows.get(key)
|
||||
if (!entry || now - entry.start > limit.windowMs) {
|
||||
windows.set(key, { start: now, count: 1 })
|
||||
return { allowed: true }
|
||||
}
|
||||
entry.count += 1
|
||||
if (entry.count > limit.maxAttempts) {
|
||||
const retryAfter = Math.ceil((entry.start + limit.windowMs - now) / 1000)
|
||||
return { allowed: false, retryAfter }
|
||||
}
|
||||
return { allowed: true }
|
||||
}
|
||||
|
||||
export function checkLoginRateLimit(ip) {
|
||||
return checkRateLimit(ip, 'login')
|
||||
}
|
||||
|
||||
export function checkCodeRequestRateLimit(ip) {
|
||||
return checkRateLimit(ip, 'codeRequest')
|
||||
}
|
||||
|
||||
export function checkCodeVerifyRateLimit(ip) {
|
||||
return checkRateLimit(ip, 'codeVerify')
|
||||
}
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
/** Публичное отображение автора отзыва (без «голого» email). */
|
||||
export function publicReviewAuthorDisplay(user) {
|
||||
if (!user || typeof user !== 'object') return 'Покупатель'
|
||||
const name = typeof user.displayName === 'string' ? user.displayName.trim() : ''
|
||||
if (name) return name
|
||||
const email = typeof user.email === 'string' ? user.email.trim() : ''
|
||||
const at = email.indexOf('@')
|
||||
if (at <= 0) return 'Покупатель'
|
||||
const local = email.slice(0, at)
|
||||
const domain = email.slice(at + 1)
|
||||
const masked = local.length <= 1 ? '*' : `${local.slice(0, 1)}***`
|
||||
return `${masked}@${domain}`
|
||||
}
|
||||
Executable
+88
@@ -0,0 +1,88 @@
|
||||
import crypto from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
export function safeImageExt(filename) {
|
||||
const ext = path.extname(String(filename || '')).toLowerCase()
|
||||
const allowed = new Set(['.png', '.jpg', '.jpeg', '.webp'])
|
||||
return allowed.has(ext) ? ext : null
|
||||
}
|
||||
|
||||
export function uploadError(message, statusCode = 400) {
|
||||
const err = new Error(message)
|
||||
err.statusCode = statusCode
|
||||
return err
|
||||
}
|
||||
|
||||
export async function persistMultipartImages(request, { maxFiles = 10, maxFileBytes, subdir = '', eager = false }) {
|
||||
if (!request.isMultipart()) {
|
||||
throw uploadError('Ожидается multipart/form-data')
|
||||
}
|
||||
|
||||
const uploadsDir = path.join(process.cwd(), 'uploads')
|
||||
const targetDir = subdir ? path.join(uploadsDir, subdir) : uploadsDir
|
||||
await fs.promises.mkdir(targetDir, { recursive: true })
|
||||
|
||||
const urls = []
|
||||
const parts = request.parts({
|
||||
limits: {
|
||||
fileSize: maxFileBytes,
|
||||
files: maxFiles,
|
||||
},
|
||||
})
|
||||
for await (const part of parts) {
|
||||
if (!part.file) continue
|
||||
if (urls.length >= maxFiles) {
|
||||
throw uploadError(`Можно загрузить не более ${maxFiles} файл(ов)`)
|
||||
}
|
||||
const ext = safeImageExt(part.filename)
|
||||
if (!ext) {
|
||||
throw uploadError('Разрешены только файлы: png, jpg, jpeg, webp')
|
||||
}
|
||||
|
||||
const uuid = crypto.randomUUID()
|
||||
const fileName = `${uuid}${ext}`
|
||||
const fullPath = path.join(targetDir, fileName)
|
||||
await fs.promises.writeFile(fullPath, await part.toBuffer())
|
||||
|
||||
let finalUrl = subdir ? `/uploads/${subdir}/${fileName}` : `/uploads/${fileName}`
|
||||
|
||||
if (eager) {
|
||||
try {
|
||||
const { generateAllSizes, convertOriginalToWebp } = await import('./image-resize.js')
|
||||
await generateAllSizes(uuid, subdir, fullPath)
|
||||
finalUrl = await convertOriginalToWebp(uuid, subdir)
|
||||
} catch (error) {
|
||||
await fs.promises.unlink(fullPath).catch(() => {})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
urls.push(finalUrl)
|
||||
}
|
||||
|
||||
if (urls.length === 0) {
|
||||
throw uploadError(
|
||||
'Файлы не получены. Проверьте, что запрос multipart/form-data и поля — файлы изображений (png, jpg, webp).',
|
||||
)
|
||||
}
|
||||
|
||||
return urls
|
||||
}
|
||||
|
||||
/** Сохранить один буфер изображения в uploads/, вернуть путь `/uploads/...`. */
|
||||
export async function saveImageBufferToUploads(originalFilename, buffer, subdir = '') {
|
||||
const ext = safeImageExt(originalFilename)
|
||||
if (!ext) {
|
||||
throw uploadError('Разрешены только файлы: png, jpg, jpeg, webp')
|
||||
}
|
||||
|
||||
const uploadsDir = path.join(process.cwd(), 'uploads')
|
||||
const targetDir = subdir ? path.join(uploadsDir, subdir) : uploadsDir
|
||||
await fs.promises.mkdir(targetDir, { recursive: true })
|
||||
|
||||
const fileName = `${crypto.randomUUID()}${ext}`
|
||||
const fullPath = path.join(targetDir, fileName)
|
||||
await fs.promises.writeFile(fullPath, buffer)
|
||||
return subdir ? `/uploads/${subdir}/${fileName}` : `/uploads/${fileName}`
|
||||
}
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
import { ADMIN_UPLOAD_IMAGE_MAX_FILE_BYTES_DEFAULT as SHARED_DEFAULT } from '../../../shared/constants/upload-limits.js'
|
||||
|
||||
const MB = 1024 * 1024
|
||||
|
||||
export const ADMIN_UPLOAD_IMAGE_MAX_FILE_BYTES_DEFAULT = SHARED_DEFAULT
|
||||
|
||||
/** @deprecated используйте ADMIN_UPLOAD_IMAGE_MAX_FILE_BYTES_DEFAULT; оставлено для совместимости импортов */
|
||||
export const PRODUCT_IMAGE_MAX_FILE_BYTES = ADMIN_UPLOAD_IMAGE_MAX_FILE_BYTES_DEFAULT
|
||||
|
||||
/** Отзывы, чек оплаты и прочие загрузки (на файл). По умолчанию 2 МБ. */
|
||||
export const OTHER_UPLOAD_MAX_FILE_BYTES = 2 * MB
|
||||
|
||||
/** Лимит одного файла для админских изображений (байты). Env: `ADMIN_IMAGE_MAX_FILE_BYTES` или `PRODUCT_IMAGE_MAX_FILE_BYTES`. */
|
||||
export function getProductImageMaxFileBytes() {
|
||||
const fromAdmin = Number(process.env.ADMIN_IMAGE_MAX_FILE_BYTES)
|
||||
const fromLegacy = Number(process.env.PRODUCT_IMAGE_MAX_FILE_BYTES)
|
||||
const n =
|
||||
Number.isFinite(fromAdmin) && fromAdmin > 0
|
||||
? fromAdmin
|
||||
: Number.isFinite(fromLegacy) && fromLegacy > 0
|
||||
? fromLegacy
|
||||
: NaN
|
||||
return Number.isFinite(n) && n > 0 ? Math.floor(n) : ADMIN_UPLOAD_IMAGE_MAX_FILE_BYTES_DEFAULT
|
||||
}
|
||||
|
||||
export function getOtherUploadMaxFileBytes() {
|
||||
const n = Number(process.env.OTHER_UPLOAD_MAX_FILE_BYTES)
|
||||
return Number.isFinite(n) && n > 0 ? Math.floor(n) : OTHER_UPLOAD_MAX_FILE_BYTES
|
||||
}
|
||||
|
||||
/** Лимит тела HTTP: до 10 фото товара за запрос + запас. */
|
||||
export function getMaxUploadBodyBytes() {
|
||||
const n = Number(process.env.MAX_UPLOAD_BODY_BYTES)
|
||||
if (Number.isFinite(n) && n > 0) return Math.floor(n)
|
||||
return getProductImageMaxFileBytes() * 10 + MB
|
||||
}
|
||||
|
||||
/** @param {unknown} error */
|
||||
export function isMultipartFileTooLargeError(error) {
|
||||
if (!error || typeof error !== 'object') return false
|
||||
if (error.code === 'FST_REQ_FILE_TOO_LARGE') return true
|
||||
const msg = String(Reflect.get(error, 'message') ?? '')
|
||||
return /request file too large|file too large/i.test(msg)
|
||||
}
|
||||
|
||||
/** @param {number} maxFileBytes */
|
||||
export function formatFileTooLargeMessage(maxFileBytes) {
|
||||
const mb = Math.max(1, Math.round(maxFileBytes / MB))
|
||||
return `Файл слишком большой (максимум ${mb} МБ).`
|
||||
}
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
export async function validateGalleryImages(prisma, urls) {
|
||||
if (!urls || urls.length === 0) return null
|
||||
|
||||
const existing = await prisma.galleryImage.findMany({
|
||||
where: { url: { in: urls } },
|
||||
select: { url: true, isResized: true },
|
||||
})
|
||||
|
||||
const galleryMap = new Map(existing.map((g) => [g.url, g]))
|
||||
const notFound = urls.filter((u) => !galleryMap.has(u))
|
||||
if (notFound.length > 0) {
|
||||
throw Object.assign(new Error(`Gallery images not found: ${notFound.join(', ')}`), { statusCode: 400 })
|
||||
}
|
||||
|
||||
const notResized = urls.filter((u) => galleryMap.get(u) && !galleryMap.get(u).isResized)
|
||||
if (notResized.length > 0) {
|
||||
throw Object.assign(new Error('Some gallery images have not been processed yet. Please try again later.'), {
|
||||
statusCode: 400,
|
||||
})
|
||||
}
|
||||
|
||||
return existing
|
||||
}
|
||||
Executable
+182
@@ -0,0 +1,182 @@
|
||||
const YOOKASSA_API_URL = 'https://api.yookassa.ru/v3'
|
||||
|
||||
function getAuthHeader() {
|
||||
const shopId = process.env.YOOKASSA_SHOP_ID
|
||||
const secretKey = process.env.YOOKASSA_SECRET_KEY
|
||||
if (!shopId || !secretKey) {
|
||||
throw new Error('YOOKASSA_SHOP_ID and YOOKASSA_SECRET_KEY are required')
|
||||
}
|
||||
const token = Buffer.from(`${shopId}:${secretKey}`).toString('base64')
|
||||
return `Basic ${token}`
|
||||
}
|
||||
|
||||
function isRetryable(status) {
|
||||
return status >= 500 || status === 429
|
||||
}
|
||||
|
||||
async function fetchWithRetry(url, opts, maxRetries = 3) {
|
||||
let lastError
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
if (attempt > 0) {
|
||||
const delay = 500 * 2 ** (attempt - 1)
|
||||
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||
}
|
||||
try {
|
||||
const res = await fetch(url, opts)
|
||||
if (res.ok) return res
|
||||
const body = await res.json().catch(() => ({}))
|
||||
if (isRetryable(res.status)) {
|
||||
lastError = new Error(`YooKassa API error: ${res.status} — ${body.description || 'unknown'}`)
|
||||
continue
|
||||
}
|
||||
throw new Error(
|
||||
`YooKassa API error: ${res.status} — ${body.description || body.code || 'unknown'} (${body.parameter || 'n/a'})`,
|
||||
)
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message.startsWith('YooKassa API error')) throw err
|
||||
lastError = new Error(`YooKassa API error: network failure — ${err instanceof Error ? err.message : String(err)}`)
|
||||
if (attempt === maxRetries) throw lastError
|
||||
}
|
||||
}
|
||||
throw lastError
|
||||
}
|
||||
|
||||
export async function createPayment({
|
||||
amount,
|
||||
description,
|
||||
receipt,
|
||||
confirmation,
|
||||
metadata,
|
||||
idempotencyKey,
|
||||
clientIp,
|
||||
}) {
|
||||
const headers = {
|
||||
Authorization: getAuthHeader(),
|
||||
'Idempotence-Key': idempotencyKey,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
const body = {
|
||||
amount,
|
||||
capture: true,
|
||||
description,
|
||||
confirmation,
|
||||
metadata,
|
||||
}
|
||||
|
||||
if (receipt) {
|
||||
body.receipt = receipt
|
||||
}
|
||||
if (clientIp) {
|
||||
body.client_ip = clientIp
|
||||
}
|
||||
|
||||
const res = await fetchWithRetry(`${YOOKASSA_API_URL}/payments`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
return {
|
||||
paymentId: data.id,
|
||||
status: data.status,
|
||||
confirmationUrl: data.confirmation?.confirmation_url || null,
|
||||
expiresAt: data.expires_at || null,
|
||||
paid: data.paid,
|
||||
test: data.test,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPayment(paymentId) {
|
||||
const res = await fetchWithRetry(`${YOOKASSA_API_URL}/payments/${paymentId}`, {
|
||||
headers: { Authorization: getAuthHeader() },
|
||||
})
|
||||
const data = await res.json()
|
||||
return {
|
||||
paymentId: data.id,
|
||||
status: data.status,
|
||||
confirmationUrl: data.confirmation?.confirmation_url || null,
|
||||
expiresAt: data.expires_at || null,
|
||||
paid: data.paid,
|
||||
test: data.test,
|
||||
}
|
||||
}
|
||||
|
||||
const YOOKASSA_IP_RANGES_V4 = ['185.71.76.0/27', '185.71.77.0/27', '77.75.153.0/25', '77.75.154.128/25']
|
||||
|
||||
function ip4ToInt(ip) {
|
||||
return ip.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet, 10), 0) >>> 0
|
||||
}
|
||||
|
||||
function cidrMatch(ip, cidr) {
|
||||
const [range, bits] = cidr.split('/')
|
||||
const mask = ~(2 ** (32 - parseInt(bits, 10)) - 1) >>> 0
|
||||
const ipInt = ip4ToInt(ip)
|
||||
const rangeInt = ip4ToInt(range)
|
||||
return (ipInt & mask) === (rangeInt & mask)
|
||||
}
|
||||
|
||||
function isYookassaIp(ip) {
|
||||
const v4 = ip.replace(/^::ffff:/, '')
|
||||
if (!/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(v4)) return false
|
||||
return YOOKASSA_IP_RANGES_V4.some((cidr) => cidrMatch(v4, cidr))
|
||||
}
|
||||
|
||||
function isTestMode() {
|
||||
return process.env.YOOKASSA_SECRET_KEY?.startsWith('test_') ?? false
|
||||
}
|
||||
|
||||
export function validateWebhook(ip, body) {
|
||||
if (!isTestMode() && !isYookassaIp(ip)) {
|
||||
throw new Error('Invalid webhook source IP')
|
||||
}
|
||||
if (!body || typeof body !== 'object') {
|
||||
throw new Error('Invalid webhook body')
|
||||
}
|
||||
if (body.type !== 'notification') {
|
||||
throw new Error('Expected notification type in webhook body')
|
||||
}
|
||||
if (!body.event || !body.object) {
|
||||
throw new Error('Missing event or object in webhook body')
|
||||
}
|
||||
return { event: body.event, paymentObject: body.object }
|
||||
}
|
||||
|
||||
export function buildReceipt({ orderItems, deliveryFeeCents, userEmail, taxSystemCode = 1 }) {
|
||||
const items = orderItems.map((item) => ({
|
||||
description: (item.titleSnapshot || 'Товар').slice(0, 128),
|
||||
quantity: item.qty,
|
||||
amount: {
|
||||
value: (item.priceCentsSnapshot / 100).toFixed(2),
|
||||
currency: 'RUB',
|
||||
},
|
||||
vat_code: 1,
|
||||
measure: 'piece',
|
||||
payment_subject: 'commodity',
|
||||
payment_mode: 'full_prepayment',
|
||||
}))
|
||||
|
||||
if (deliveryFeeCents > 0) {
|
||||
items.push({
|
||||
description: 'Доставка',
|
||||
quantity: 1,
|
||||
amount: {
|
||||
value: (deliveryFeeCents / 100).toFixed(2),
|
||||
currency: 'RUB',
|
||||
},
|
||||
vat_code: 1,
|
||||
measure: 'piece',
|
||||
payment_subject: 'service',
|
||||
payment_mode: 'full_prepayment',
|
||||
})
|
||||
}
|
||||
|
||||
const receipt = {
|
||||
customer: { email: userEmail },
|
||||
items,
|
||||
tax_system_code: taxSystemCode,
|
||||
}
|
||||
|
||||
return receipt
|
||||
}
|
||||
Executable
+296
@@ -0,0 +1,296 @@
|
||||
import jwt from '@fastify/jwt'
|
||||
import Fastify from 'fastify'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { registerAuth } from '../auth.js'
|
||||
|
||||
const JWT_SECRET = 'test-secret'
|
||||
const ADMIN_EMAIL = 'admin@test.com'
|
||||
|
||||
async function buildApp() {
|
||||
const app = Fastify({ logger: false, trustProxy: true })
|
||||
await app.register(jwt, { secret: JWT_SECRET })
|
||||
registerAuth(app)
|
||||
app.get('/admin/test', { preHandler: [app.verifyAdmin] }, async () => ({ ok: true }))
|
||||
await app.ready()
|
||||
return app
|
||||
}
|
||||
|
||||
async function signToken(app, email) {
|
||||
return app.jwt.sign({ sub: 'test-user-id', email })
|
||||
}
|
||||
|
||||
describe('verifyAdmin — ADMIN_ACCESS_IPS', () => {
|
||||
const originalIps = process.env.ADMIN_ACCESS_IPS
|
||||
const originalEmail = process.env.ADMIN_EMAIL
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.ADMIN_EMAIL = ADMIN_EMAIL
|
||||
delete process.env.ADMIN_ACCESS_IPS
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalIps === undefined) {
|
||||
delete process.env.ADMIN_ACCESS_IPS
|
||||
} else {
|
||||
process.env.ADMIN_ACCESS_IPS = originalIps
|
||||
}
|
||||
if (originalEmail === undefined) {
|
||||
delete process.env.ADMIN_EMAIL
|
||||
} else {
|
||||
process.env.ADMIN_EMAIL = originalEmail
|
||||
}
|
||||
})
|
||||
|
||||
it('пропускает если ADMIN_ACCESS_IPS не задан (IP не проверяется)', async () => {
|
||||
delete process.env.ADMIN_ACCESS_IPS
|
||||
const app = await buildApp()
|
||||
const token = await signToken(app, ADMIN_EMAIL)
|
||||
try {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin/test',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
remoteAddress: '9.9.9.9',
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(res.json()).toEqual({ ok: true })
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('пропускает если ADMIN_ACCESS_IPS пустой после трима', async () => {
|
||||
process.env.ADMIN_ACCESS_IPS = ' , , '
|
||||
const app = await buildApp()
|
||||
const token = await signToken(app, ADMIN_EMAIL)
|
||||
try {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin/test',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
remoteAddress: '9.9.9.9',
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('пропускает с разрешённого IP', async () => {
|
||||
process.env.ADMIN_ACCESS_IPS = '1.2.3.4,5.6.7.8'
|
||||
const app = await buildApp()
|
||||
const token = await signToken(app, ADMIN_EMAIL)
|
||||
try {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin/test',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
remoteAddress: '1.2.3.4',
|
||||
})
|
||||
// IP passes, JWT and email match → 200
|
||||
expect(res.statusCode).toBe(200)
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('пропускает с IPv6-mapped разрешённого IP', async () => {
|
||||
process.env.ADMIN_ACCESS_IPS = '1.2.3.4'
|
||||
const app = await buildApp()
|
||||
const token = await signToken(app, ADMIN_EMAIL)
|
||||
try {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin/test',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
remoteAddress: '::ffff:1.2.3.4',
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('блокирует с неразрешённого IP (403 JSON)', async () => {
|
||||
process.env.ADMIN_ACCESS_IPS = '1.2.3.4'
|
||||
const app = await buildApp()
|
||||
try {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin/test',
|
||||
remoteAddress: '9.9.9.9',
|
||||
})
|
||||
// IP not allowed — 403 even before JWT check
|
||||
expect(res.statusCode).toBe(403)
|
||||
const body = res.json()
|
||||
expect(body.error).toBe('Доступ с данного IP запрещён')
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('тримит пробелы в списке IP', async () => {
|
||||
process.env.ADMIN_ACCESS_IPS = ' 1.2.3.4 , 5.6.7.8 '
|
||||
const app = await buildApp()
|
||||
const token = await signToken(app, ADMIN_EMAIL)
|
||||
try {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin/test',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
remoteAddress: '5.6.7.8',
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('нормализует IPv6-mapped адреса в whitelist', async () => {
|
||||
process.env.ADMIN_ACCESS_IPS = '::ffff:1.2.3.4'
|
||||
const app = await buildApp()
|
||||
const token = await signToken(app, ADMIN_EMAIL)
|
||||
try {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin/test',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
remoteAddress: '1.2.3.4',
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('пропускает запрос с IP в CIDR-диапазоне /24', async () => {
|
||||
process.env.ADMIN_ACCESS_IPS = '192.168.1.0/24'
|
||||
const app = await buildApp()
|
||||
const token = await signToken(app, ADMIN_EMAIL)
|
||||
try {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin/test',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
remoteAddress: '192.168.1.100',
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('блокирует запрос с IP вне CIDR-диапазона', async () => {
|
||||
process.env.ADMIN_ACCESS_IPS = '192.168.1.0/24'
|
||||
const app = await buildApp()
|
||||
try {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin/test',
|
||||
remoteAddress: '10.0.0.1',
|
||||
})
|
||||
expect(res.statusCode).toBe(403)
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('поддерживает микс точных IP и CIDR-диапазонов', async () => {
|
||||
process.env.ADMIN_ACCESS_IPS = '1.2.3.4,10.0.0.0/24'
|
||||
const app = await buildApp()
|
||||
const token = await signToken(app, ADMIN_EMAIL)
|
||||
try {
|
||||
const res1 = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin/test',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
remoteAddress: '1.2.3.4',
|
||||
})
|
||||
expect(res1.statusCode).toBe(200)
|
||||
|
||||
const res2 = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin/test',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
remoteAddress: '10.0.0.50',
|
||||
})
|
||||
expect(res2.statusCode).toBe(200)
|
||||
|
||||
const res3 = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin/test',
|
||||
remoteAddress: '9.9.9.9',
|
||||
})
|
||||
expect(res3.statusCode).toBe(403)
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('IPv6-mapped адрес в CIDR-диапазоне пропускается', async () => {
|
||||
process.env.ADMIN_ACCESS_IPS = '192.168.1.0/24'
|
||||
const app = await buildApp()
|
||||
const token = await signToken(app, ADMIN_EMAIL)
|
||||
try {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin/test',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
remoteAddress: '::ffff:192.168.1.50',
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('IP-проверка происходит до JWT (неразрешённый IP → 403, а не 401)', async () => {
|
||||
process.env.ADMIN_ACCESS_IPS = '1.2.3.4'
|
||||
const app = await buildApp()
|
||||
try {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin/test',
|
||||
remoteAddress: '9.9.9.9',
|
||||
})
|
||||
// Should be 403 from IP check, NOT 401 from missing JWT
|
||||
expect(res.statusCode).toBe(403)
|
||||
expect(res.json().error).toBe('Доступ с данного IP запрещён')
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('после прохождения IP-проверки всё ещё нужен JWT (разрешённый IP, нет токена → 401)', async () => {
|
||||
process.env.ADMIN_ACCESS_IPS = '1.2.3.4'
|
||||
const app = await buildApp()
|
||||
try {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin/test',
|
||||
remoteAddress: '1.2.3.4',
|
||||
})
|
||||
// IP passes, but no JWT → 401
|
||||
expect(res.statusCode).toBe(401)
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('ADMIN_EMAIL не задан → 503, IP не проверяется', async () => {
|
||||
delete process.env.ADMIN_EMAIL
|
||||
process.env.ADMIN_ACCESS_IPS = '1.2.3.4'
|
||||
const app = await buildApp()
|
||||
try {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin/test',
|
||||
remoteAddress: '1.2.3.4',
|
||||
})
|
||||
expect(res.statusCode).toBe(503)
|
||||
expect(res.json().error).toBe('ADMIN_EMAIL не задан в .env')
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
Executable
+259
@@ -0,0 +1,259 @@
|
||||
import Fastify from 'fastify'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { build403Html, registerIpGate } from '../ip-gate.js'
|
||||
|
||||
function buildApp() {
|
||||
const app = Fastify({ logger: false, trustProxy: true })
|
||||
app.get('/test', async () => ({ ok: true }))
|
||||
app.get('/api/webhooks/yookassa', async () => ({ ok: true }))
|
||||
app.get('/api/auth/oauth/vk/callback', async () => ({ ok: true }))
|
||||
app.get('/api/auth/oauth/yandex/callback', async () => ({ ok: true }))
|
||||
app.get('/api/admin/notifications/telegram/webhook', async () => ({ ok: true }))
|
||||
return app
|
||||
}
|
||||
|
||||
describe('registerIpGate', () => {
|
||||
let app
|
||||
const originalIps = process.env.SITE_ACCESS_IPS
|
||||
|
||||
beforeEach(async () => {
|
||||
app = buildApp()
|
||||
await registerIpGate(app)
|
||||
await app.ready()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close()
|
||||
if (originalIps === undefined) {
|
||||
delete process.env.SITE_ACCESS_IPS
|
||||
} else {
|
||||
process.env.SITE_ACCESS_IPS = originalIps
|
||||
}
|
||||
})
|
||||
|
||||
it('пропускает запрос если SITE_ACCESS_IPS не задан', async () => {
|
||||
delete process.env.SITE_ACCESS_IPS
|
||||
const res = await app.inject({ method: 'GET', url: '/test', remoteAddress: '1.2.3.4' })
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(res.json()).toEqual({ ok: true })
|
||||
})
|
||||
|
||||
it('пропускает запрос с разрешённого IP', async () => {
|
||||
process.env.SITE_ACCESS_IPS = '1.2.3.4,5.6.7.8'
|
||||
const res = await app.inject({ method: 'GET', url: '/test', remoteAddress: '1.2.3.4' })
|
||||
expect(res.statusCode).toBe(200)
|
||||
})
|
||||
|
||||
it('пропускает запрос с IPv6-mapped разрешённого IP', async () => {
|
||||
process.env.SITE_ACCESS_IPS = '1.2.3.4'
|
||||
const res = await app.inject({ method: 'GET', url: '/test', remoteAddress: '::ffff:1.2.3.4' })
|
||||
expect(res.statusCode).toBe(200)
|
||||
})
|
||||
|
||||
it('блокирует запрос с неразрешённого IP (403)', async () => {
|
||||
process.env.SITE_ACCESS_IPS = '1.2.3.4'
|
||||
const res = await app.inject({ method: 'GET', url: '/test', remoteAddress: '9.9.9.9' })
|
||||
expect(res.statusCode).toBe(403)
|
||||
expect(res.headers['content-type']).toMatch(/text\/html/)
|
||||
expect(res.body).toContain('Любимый Креатив')
|
||||
expect(res.body).toContain('9.9.9.9')
|
||||
})
|
||||
|
||||
it('build403Html показывает "не определён" когда IP не передан', () => {
|
||||
const html = build403Html()
|
||||
expect(html).toContain('не определён')
|
||||
expect(html).toContain('Любимый Креатив')
|
||||
})
|
||||
|
||||
it('build403Html показывает переданный IP', () => {
|
||||
const html = build403Html('9.9.9.9')
|
||||
expect(html).toContain('9.9.9.9')
|
||||
expect(html).not.toContain('не определён')
|
||||
})
|
||||
|
||||
it('build403Html с пустой строкой показывает "не определён"', () => {
|
||||
const html = build403Html('')
|
||||
expect(html).toContain('не определён')
|
||||
})
|
||||
|
||||
it('403-страница показывает IP по умолчанию (127.0.0.1) когда remoteAddress не указан', async () => {
|
||||
process.env.SITE_ACCESS_IPS = '1.2.3.4'
|
||||
const res = await app.inject({ method: 'GET', url: '/test' })
|
||||
expect(res.statusCode).toBe(403)
|
||||
expect(res.body).toContain('127.0.0.1')
|
||||
})
|
||||
|
||||
it('пропускает исключённые пути с любым IP (webhook yookassa)', async () => {
|
||||
process.env.SITE_ACCESS_IPS = '1.2.3.4'
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/webhooks/yookassa',
|
||||
remoteAddress: '9.9.9.9',
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
})
|
||||
|
||||
it('пропускает исключённые пути с любым IP (vk callback)', async () => {
|
||||
process.env.SITE_ACCESS_IPS = '1.2.3.4'
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/auth/oauth/vk/callback',
|
||||
remoteAddress: '9.9.9.9',
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
})
|
||||
|
||||
it('пропускает исключённые пути с любым IP (yandex callback)', async () => {
|
||||
process.env.SITE_ACCESS_IPS = '1.2.3.4'
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/auth/oauth/yandex/callback',
|
||||
remoteAddress: '9.9.9.9',
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
})
|
||||
|
||||
it('пропускает исключённые пути с любым IP (telegram webhook)', async () => {
|
||||
process.env.SITE_ACCESS_IPS = '1.2.3.4'
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/admin/notifications/telegram/webhook',
|
||||
remoteAddress: '9.9.9.9',
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
})
|
||||
|
||||
it('корректно тримит пробелы в списке IP', async () => {
|
||||
process.env.SITE_ACCESS_IPS = ' 1.2.3.4 , 5.6.7.8 '
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
remoteAddress: '5.6.7.8',
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
})
|
||||
|
||||
it('нормализует IPv6-mapped адреса в whitelist', async () => {
|
||||
process.env.SITE_ACCESS_IPS = '::ffff:1.2.3.4'
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
remoteAddress: '1.2.3.4',
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
})
|
||||
|
||||
it('пропускает если после трима список IP пуст', async () => {
|
||||
process.env.SITE_ACCESS_IPS = ' , , '
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
remoteAddress: '9.9.9.9',
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
})
|
||||
|
||||
it('путь с query-параметрами проверяется корректно', async () => {
|
||||
process.env.SITE_ACCESS_IPS = '1.2.3.4'
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/test?foo=bar',
|
||||
remoteAddress: '9.9.9.9',
|
||||
})
|
||||
expect(res.statusCode).toBe(403)
|
||||
})
|
||||
|
||||
it('исключённый путь с query-параметрами тоже пропускается', async () => {
|
||||
process.env.SITE_ACCESS_IPS = '1.2.3.4'
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/webhooks/yookassa?foo=bar',
|
||||
remoteAddress: '9.9.9.9',
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
})
|
||||
|
||||
it('пропускает запрос с IP в CIDR-диапазоне /24', async () => {
|
||||
process.env.SITE_ACCESS_IPS = '192.168.1.0/24'
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
remoteAddress: '192.168.1.100',
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
})
|
||||
|
||||
it('блокирует запрос с IP вне CIDR-диапазона', async () => {
|
||||
process.env.SITE_ACCESS_IPS = '192.168.1.0/24'
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
remoteAddress: '10.0.0.1',
|
||||
})
|
||||
expect(res.statusCode).toBe(403)
|
||||
})
|
||||
|
||||
it('пропускает IP в CIDR /32 (эквивалент одного IP)', async () => {
|
||||
process.env.SITE_ACCESS_IPS = '10.0.0.5/32'
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
remoteAddress: '10.0.0.5',
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
})
|
||||
|
||||
it('блокирует IP рядом с CIDR /32', async () => {
|
||||
process.env.SITE_ACCESS_IPS = '10.0.0.5/32'
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
remoteAddress: '10.0.0.6',
|
||||
})
|
||||
expect(res.statusCode).toBe(403)
|
||||
})
|
||||
|
||||
it('пропускает любой IP в CIDR /0', async () => {
|
||||
process.env.SITE_ACCESS_IPS = '0.0.0.0/0'
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
remoteAddress: '1.2.3.4',
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
})
|
||||
|
||||
it('поддерживает микс точных IP и CIDR-диапазонов', async () => {
|
||||
process.env.SITE_ACCESS_IPS = '1.2.3.4,10.0.0.0/24'
|
||||
const res1 = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
remoteAddress: '1.2.3.4',
|
||||
})
|
||||
expect(res1.statusCode).toBe(200)
|
||||
|
||||
const res2 = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
remoteAddress: '10.0.0.50',
|
||||
})
|
||||
expect(res2.statusCode).toBe(200)
|
||||
|
||||
const res3 = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
remoteAddress: '9.9.9.9',
|
||||
})
|
||||
expect(res3.statusCode).toBe(403)
|
||||
})
|
||||
|
||||
it('IPv6-mapped адрес в CIDR-диапазоне', async () => {
|
||||
process.env.SITE_ACCESS_IPS = '192.168.1.0/24'
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
remoteAddress: '::ffff:192.168.1.50',
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
})
|
||||
})
|
||||
Executable
+44
@@ -0,0 +1,44 @@
|
||||
import { normalizeIp, cidrMatch } from './ip-gate.js'
|
||||
|
||||
export function registerAuth(fastify) {
|
||||
function normalizeEmail(email) {
|
||||
return String(email || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
fastify.decorate('verifyAdmin', async function verifyAdmin(request, reply) {
|
||||
const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL)
|
||||
if (!adminEmail || !adminEmail.includes('@')) {
|
||||
return reply.code(503).send({ error: 'ADMIN_EMAIL не задан в .env' })
|
||||
}
|
||||
|
||||
const adminIps = process.env.ADMIN_ACCESS_IPS
|
||||
if (adminIps) {
|
||||
const allowedList = adminIps
|
||||
.split(',')
|
||||
.map((s) => normalizeIp(s.trim()))
|
||||
.filter(Boolean)
|
||||
|
||||
if (allowedList.length > 0) {
|
||||
const reqIp = normalizeIp(request.ip)
|
||||
const isAllowed = allowedList.includes(reqIp) || allowedList.some((entry) => cidrMatch(reqIp, entry))
|
||||
if (!isAllowed) {
|
||||
return reply.code(403).send({ error: 'Доступ с данного IP запрещён' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await request.jwtVerify()
|
||||
} catch (err) {
|
||||
request.log.error({ err }, '[auth] verifyAdmin failed')
|
||||
return reply.code(401).send({ error: 'Не авторизован' })
|
||||
}
|
||||
|
||||
const userEmail = normalizeEmail(request.user?.email)
|
||||
if (userEmail !== adminEmail) {
|
||||
return reply.code(403).send({ error: 'Недостаточно прав' })
|
||||
}
|
||||
})
|
||||
}
|
||||
Executable
+132
@@ -0,0 +1,132 @@
|
||||
const EXCLUDED_PATHS = [
|
||||
'/api/auth/oauth/vk/callback',
|
||||
'/api/auth/oauth/yandex/callback',
|
||||
'/api/webhooks/yookassa',
|
||||
'/api/admin/notifications/telegram/webhook',
|
||||
]
|
||||
|
||||
export function normalizeIp(ip) {
|
||||
if (ip && ip.startsWith('::ffff:')) {
|
||||
return ip.slice(7)
|
||||
}
|
||||
return ip
|
||||
}
|
||||
|
||||
export function ipToInt(ip) {
|
||||
const parts = ip.split('.')
|
||||
if (parts.length !== 4) return null
|
||||
return parts.reduce((acc, octet) => {
|
||||
const num = parseInt(octet, 10)
|
||||
if (isNaN(num) || num < 0 || num > 255) return null
|
||||
return acc !== null ? (acc << 8) + num : null
|
||||
}, 0)
|
||||
}
|
||||
|
||||
export function cidrMatch(ip, cidr) {
|
||||
const slashIdx = cidr.indexOf('/')
|
||||
if (slashIdx === -1) return false
|
||||
|
||||
const baseIp = cidr.slice(0, slashIdx)
|
||||
const prefix = parseInt(cidr.slice(slashIdx + 1), 10)
|
||||
if (isNaN(prefix) || prefix < 0 || prefix > 32) return false
|
||||
|
||||
const ipInt = ipToInt(normalizeIp(ip))
|
||||
const baseInt = ipToInt(normalizeIp(baseIp))
|
||||
if (ipInt === null || baseInt === null) return false
|
||||
|
||||
const mask = prefix === 0 ? 0 : ~(2 ** (32 - prefix) - 1) >>> 0
|
||||
return (ipInt & mask) === (baseInt & mask)
|
||||
}
|
||||
|
||||
export function build403Html(ip) {
|
||||
const safeIp = ip || 'не определён'
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Любимый Креатив</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #faf8f5;
|
||||
color: #3d322b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
}
|
||||
.card {
|
||||
max-width: 520px;
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
border: 1px solid #e8e0d8;
|
||||
border-radius: 16px;
|
||||
padding: 48px 40px;
|
||||
text-align: center;
|
||||
box-shadow: 0 2px 16px rgb(0 0 0 / 4%);
|
||||
}
|
||||
.card h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.3px;
|
||||
color: #4a3a2e;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.card .tagline {
|
||||
font-size: 14px;
|
||||
color: #8c8177;
|
||||
margin-bottom: 32px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.card .status {
|
||||
font-size: 16px;
|
||||
color: #6b5e52;
|
||||
margin-bottom: 24px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.card .ip {
|
||||
font-size: 12px;
|
||||
color: #b8a99b;
|
||||
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>Любимый Креатив</h1>
|
||||
<p class="tagline">Изделия ручной работы: вещи с характером и вниманием к деталям</p>
|
||||
<p class="status">Сайт находится в разработке и скоро будет доступен</p>
|
||||
<p class="ip">Ваш IP: ${safeIp}</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
export async function registerIpGate(fastify) {
|
||||
fastify.addHook('onRequest', async (request, reply) => {
|
||||
const allowed = process.env.SITE_ACCESS_IPS
|
||||
if (!allowed) return
|
||||
|
||||
const allowedIps = allowed
|
||||
.split(',')
|
||||
.map((s) => normalizeIp(s.trim()))
|
||||
.filter(Boolean)
|
||||
|
||||
if (allowedIps.length === 0) return
|
||||
|
||||
const urlPath = request.url.split('?')[0]
|
||||
|
||||
if (EXCLUDED_PATHS.includes(urlPath)) return
|
||||
|
||||
const normalizedIp = normalizeIp(request.ip)
|
||||
if (allowedIps.includes(normalizedIp)) return
|
||||
|
||||
const isInCidr = allowedIps.some((entry) => cidrMatch(normalizedIp, entry))
|
||||
if (isInCidr) return
|
||||
|
||||
return reply.code(403).type('text/html').send(build403Html(request.ip))
|
||||
})
|
||||
}
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
export async function registerSecurityHeaders(fastify) {
|
||||
fastify.addHook('onSend', async (request, reply) => {
|
||||
reply.header('X-Content-Type-Options', 'nosniff')
|
||||
reply.header('X-Frame-Options', 'DENY')
|
||||
reply.header('X-XSS-Protection', '0')
|
||||
reply.header('Referrer-Policy', 'strict-origin-when-cross-origin')
|
||||
reply.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=()')
|
||||
|
||||
const cspDirectives = [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' https://*.yookassa.ru https://*.vk.com https://oauth.yandex.ru",
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||
"img-src 'self' data: blob: https://tile.openstreetmap.org https://*.yookassa.ru https://*.vk.com https://oauth.yandex.ru",
|
||||
"font-src 'self' https://fonts.gstatic.com",
|
||||
"connect-src 'self' https://*.yookassa.ru https://*.vk.com https://oauth.yandex.ru",
|
||||
'frame-src https://*.yookassa.ru',
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
].join('; ')
|
||||
|
||||
reply.header('Content-Security-Policy', cspDirectives)
|
||||
})
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
import jwt from '@fastify/jwt'
|
||||
import Fastify from 'fastify'
|
||||
import { afterAll, beforeEach, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { prisma } from '../../lib/prisma.js'
|
||||
import { registerAuthSessionRoutes } from '../auth-session.js'
|
||||
|
||||
const JWT_SECRET = 'test-secret'
|
||||
|
||||
async function buildApp() {
|
||||
const app = Fastify({ logger: false })
|
||||
await app.register(jwt, { secret: JWT_SECRET })
|
||||
app.decorate('authenticate', async function (request, reply) {
|
||||
try {
|
||||
await request.jwtVerify()
|
||||
} catch {
|
||||
return reply.code(401).send({ error: 'Unauthorized' })
|
||||
}
|
||||
})
|
||||
app.decorate('eventBus', { emit: () => {} })
|
||||
await registerAuthSessionRoutes(app)
|
||||
await app.ready()
|
||||
return app
|
||||
}
|
||||
|
||||
function signToken(app, userId, email) {
|
||||
return app.jwt.sign({ sub: userId, email })
|
||||
}
|
||||
|
||||
async function createUser(email) {
|
||||
const user = await prisma.user.create({
|
||||
data: { email, displayName: 'Test', avatar: null, avatarStyle: 'avataaars' },
|
||||
})
|
||||
await prisma.notificationPreference.create({ data: { userId: user.id, globalEnabled: true } })
|
||||
return user
|
||||
}
|
||||
|
||||
describe('GET /api/me/auth-methods', () => {
|
||||
let app, user, token
|
||||
const email = `test-methods-${Date.now()}@example.com`
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp()
|
||||
})
|
||||
afterAll(async () => {
|
||||
await prisma.notificationPreference.deleteMany({ where: { userId: user?.id } })
|
||||
await prisma.user.deleteMany({ where: { email } })
|
||||
await app.close()
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
await prisma.oAuthAccount.deleteMany({ where: { user: { email } } })
|
||||
await prisma.notificationPreference.deleteMany({ where: { user: { email } } })
|
||||
await prisma.user.deleteMany({ where: { email } })
|
||||
user = await createUser(email)
|
||||
token = signToken(app, user.id, email)
|
||||
})
|
||||
|
||||
it('returns methods for user without any method', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/me/auth-methods',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
const body = JSON.parse(res.body)
|
||||
expect(body.methods.find((m) => m.type === 'password').active).toBe(false)
|
||||
expect(body.methods.find((m) => m.type === 'vk').active).toBe(false)
|
||||
expect(body.methods.find((m) => m.type === 'yandex').active).toBe(false)
|
||||
})
|
||||
|
||||
it('returns password as active after setting it', async () => {
|
||||
await prisma.user.update({ where: { id: user.id }, data: { passwordHash: 'hashed' } })
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/me/auth-methods',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(JSON.parse(res.body).methods.find((m) => m.type === 'password').active).toBe(true)
|
||||
})
|
||||
})
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
import jwt from '@fastify/jwt'
|
||||
import Fastify from 'fastify'
|
||||
import { afterAll, beforeEach, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { prisma } from '../../lib/prisma.js'
|
||||
import { registerAuthOAuthRoutes } from '../auth-oauth.js'
|
||||
|
||||
const JWT_SECRET = 'test-secret'
|
||||
|
||||
async function buildApp() {
|
||||
const app = Fastify({ logger: false })
|
||||
await app.register(jwt, { secret: JWT_SECRET })
|
||||
app.decorate('authenticate', async function (request, reply) {
|
||||
try {
|
||||
await request.jwtVerify()
|
||||
} catch {
|
||||
return reply.code(401).send({ error: 'Unauthorized' })
|
||||
}
|
||||
})
|
||||
app.decorate('eventBus', { emit: () => {} })
|
||||
await registerAuthOAuthRoutes(app)
|
||||
await app.ready()
|
||||
return app
|
||||
}
|
||||
|
||||
function signToken(app, userId, email) {
|
||||
return app.jwt.sign({ sub: userId, email })
|
||||
}
|
||||
|
||||
async function createUser(email) {
|
||||
const user = await prisma.user.create({
|
||||
data: { email, displayName: 'Test', avatar: null, avatarStyle: 'avataaars' },
|
||||
})
|
||||
await prisma.notificationPreference.create({ data: { userId: user.id, globalEnabled: true } })
|
||||
return user
|
||||
}
|
||||
|
||||
describe('DELETE /api/me/oauth/:provider', () => {
|
||||
let app, user, token
|
||||
const email = `test-unlink-${Date.now()}@example.com`
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp()
|
||||
})
|
||||
afterAll(async () => {
|
||||
await prisma.oAuthAccount.deleteMany({ where: { user: { email } } })
|
||||
await prisma.notificationPreference.deleteMany({ where: { user: { email } } })
|
||||
await prisma.user.deleteMany({ where: { email } })
|
||||
await app.close()
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
await prisma.oAuthAccount.deleteMany({ where: { user: { email } } })
|
||||
await prisma.user.deleteMany({ where: { email } })
|
||||
user = await createUser(email)
|
||||
token = signToken(app, user.id, email)
|
||||
})
|
||||
|
||||
it('returns 404 for non-linked provider', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/me/oauth/vk',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(res.statusCode).toBe(404)
|
||||
})
|
||||
|
||||
it('unlinks a provider', async () => {
|
||||
await prisma.user.update({ where: { id: user.id }, data: { passwordHash: 'hashed' } })
|
||||
await prisma.oAuthAccount.create({
|
||||
data: { provider: 'vk', providerUserId: '123', userId: user.id },
|
||||
})
|
||||
const res = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/me/oauth/vk',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
|
||||
const count = await prisma.oAuthAccount.count({ where: { userId: user.id } })
|
||||
expect(count).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects removing last method without password', async () => {
|
||||
await prisma.oAuthAccount.create({
|
||||
data: { provider: 'vk', providerUserId: '123', userId: user.id },
|
||||
})
|
||||
const res = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/me/oauth/vk',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(res.statusCode).toBe(400)
|
||||
expect(JSON.parse(res.body).error).toContain('последний метод')
|
||||
})
|
||||
})
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
import jwt from '@fastify/jwt'
|
||||
import Fastify from 'fastify'
|
||||
import { afterAll, beforeEach, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { prisma } from '../../lib/prisma.js'
|
||||
import { registerAuthPasswordRoutes } from '../auth-password.js'
|
||||
|
||||
const JWT_SECRET = 'test-secret'
|
||||
|
||||
async function buildApp() {
|
||||
const app = Fastify({ logger: false })
|
||||
await app.register(jwt, { secret: JWT_SECRET })
|
||||
app.decorate('authenticate', async function (request, reply) {
|
||||
try {
|
||||
await request.jwtVerify()
|
||||
} catch {
|
||||
return reply.code(401).send({ error: 'Unauthorized' })
|
||||
}
|
||||
})
|
||||
app.decorate('eventBus', { emit: () => {} })
|
||||
await registerAuthPasswordRoutes(app)
|
||||
await app.ready()
|
||||
return app
|
||||
}
|
||||
|
||||
function signToken(app, userId, email) {
|
||||
return app.jwt.sign({ sub: userId, email })
|
||||
}
|
||||
|
||||
async function createUser(email) {
|
||||
const user = await prisma.user.create({
|
||||
data: { email, displayName: 'Test', avatar: null, avatarStyle: 'avataaars' },
|
||||
})
|
||||
await prisma.notificationPreference.create({ data: { userId: user.id, globalEnabled: true } })
|
||||
return user
|
||||
}
|
||||
|
||||
describe('POST /api/me/password', () => {
|
||||
let app, user, token
|
||||
const email = `test-set-pw-${Date.now()}@example.com`
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp()
|
||||
})
|
||||
afterAll(async () => {
|
||||
await prisma.notificationPreference.deleteMany({ where: { userId: user?.id } })
|
||||
await prisma.user.deleteMany({ where: { email } })
|
||||
await app.close()
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
await prisma.notificationPreference.deleteMany({ where: { user: { email } } })
|
||||
await prisma.user.deleteMany({ where: { email } })
|
||||
user = await createUser(email)
|
||||
token = signToken(app, user.id, email)
|
||||
})
|
||||
|
||||
it('sets password', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/me/password',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { password: 'Test123!@' },
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
|
||||
const u = await prisma.user.findUnique({ where: { id: user.id } })
|
||||
expect(u.passwordHash).toBeTruthy()
|
||||
})
|
||||
|
||||
it('rejects if password already set', async () => {
|
||||
await prisma.user.update({ where: { id: user.id }, data: { passwordHash: 'existing' } })
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/me/password',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { password: 'Test123!@' },
|
||||
})
|
||||
expect(res.statusCode).toBe(409)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/me/change-password', () => {
|
||||
let app, user, token
|
||||
const email = `test-change-pw-${Date.now()}@example.com`
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp()
|
||||
})
|
||||
afterAll(async () => {
|
||||
await prisma.notificationPreference.deleteMany({ where: { userId: user?.id } })
|
||||
await prisma.user.deleteMany({ where: { email } })
|
||||
await app.close()
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
await prisma.notificationPreference.deleteMany({ where: { user: { email } } })
|
||||
await prisma.user.deleteMany({ where: { email } })
|
||||
user = await createUser(email)
|
||||
token = signToken(app, user.id, email)
|
||||
})
|
||||
|
||||
it('changes password', async () => {
|
||||
await prisma.user.update({ where: { id: user.id }, data: { passwordHash: 'oldhash' } })
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/me/change-password',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { oldPassword: 'OldPass1!', newPassword: 'NewPass2@' },
|
||||
})
|
||||
expect(res.statusCode).toBe(401)
|
||||
|
||||
const u = await prisma.user.findUnique({ where: { id: user.id } })
|
||||
expect(u.passwordHash).toBe('oldhash')
|
||||
})
|
||||
|
||||
it('rejects if no password set', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/me/change-password',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { oldPassword: 'OldPass1!', newPassword: 'NewPass2@' },
|
||||
})
|
||||
expect(res.statusCode).toBe(400)
|
||||
})
|
||||
})
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
import jwt from '@fastify/jwt'
|
||||
import Fastify from 'fastify'
|
||||
import { afterAll, beforeEach, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { prisma } from '../../lib/prisma.js'
|
||||
import { registerAuthSessionRoutes } from '../auth-session.js'
|
||||
|
||||
const JWT_SECRET = 'test-secret'
|
||||
|
||||
async function buildApp() {
|
||||
const app = Fastify({ logger: false })
|
||||
await app.register(jwt, { secret: JWT_SECRET })
|
||||
app.decorate('authenticate', async function (request, reply) {
|
||||
try {
|
||||
await request.jwtVerify()
|
||||
} catch {
|
||||
return reply.code(401).send({ error: 'Unauthorized' })
|
||||
}
|
||||
})
|
||||
app.decorate('eventBus', { emit: () => {} })
|
||||
await registerAuthSessionRoutes(app)
|
||||
await app.ready()
|
||||
return app
|
||||
}
|
||||
|
||||
function signToken(app, userId, email) {
|
||||
return app.jwt.sign({ sub: userId, email })
|
||||
}
|
||||
|
||||
async function createUser(email) {
|
||||
const user = await prisma.user.create({
|
||||
data: { email, displayName: 'Test', avatar: null, avatarStyle: 'avataaars' },
|
||||
})
|
||||
await prisma.notificationPreference.create({ data: { userId: user.id, globalEnabled: true } })
|
||||
return user
|
||||
}
|
||||
|
||||
describe('GET /api/me', () => {
|
||||
let app, user, token
|
||||
const email = `test-me-${Date.now()}@example.com`
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp()
|
||||
})
|
||||
afterAll(async () => {
|
||||
await prisma.notificationPreference.deleteMany({ where: { userId: user?.id } })
|
||||
await prisma.user.deleteMany({ where: { email } })
|
||||
await app.close()
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
await prisma.notificationPreference.deleteMany({ where: { user: { email } } })
|
||||
await prisma.user.deleteMany({ where: { email } })
|
||||
user = await createUser(email)
|
||||
token = signToken(app, user.id, email)
|
||||
})
|
||||
|
||||
it('returns current user', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/me',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
const body = JSON.parse(res.body)
|
||||
expect(body.user.email).toBe(email)
|
||||
expect(body.user.displayName).toBe('Test')
|
||||
})
|
||||
|
||||
it('returns 401 without token', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/me',
|
||||
})
|
||||
expect(res.statusCode).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/me/auth-methods', () => {
|
||||
let app, user, token
|
||||
const email = `test-methods-${Date.now()}@example.com`
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp()
|
||||
})
|
||||
afterAll(async () => {
|
||||
await prisma.notificationPreference.deleteMany({ where: { userId: user?.id } })
|
||||
await prisma.user.deleteMany({ where: { email } })
|
||||
await app.close()
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
await prisma.oAuthAccount.deleteMany({ where: { user: { email } } })
|
||||
await prisma.notificationPreference.deleteMany({ where: { user: { email } } })
|
||||
await prisma.user.deleteMany({ where: { email } })
|
||||
user = await createUser(email)
|
||||
token = signToken(app, user.id, email)
|
||||
})
|
||||
|
||||
it('returns methods for user without any method', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/me/auth-methods',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
const body = JSON.parse(res.body)
|
||||
expect(body.methods.find((m) => m.type === 'password').active).toBe(false)
|
||||
expect(body.methods.find((m) => m.type === 'vk').active).toBe(false)
|
||||
expect(body.methods.find((m) => m.type === 'yandex').active).toBe(false)
|
||||
})
|
||||
|
||||
it('returns password as active after setting it', async () => {
|
||||
await prisma.user.update({ where: { id: user.id }, data: { passwordHash: 'hashed' } })
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/me/auth-methods',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(JSON.parse(res.body).methods.find((m) => m.type === 'password').active).toBe(true)
|
||||
})
|
||||
})
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest'
|
||||
import { prisma } from '../../lib/prisma.js'
|
||||
|
||||
describe('OAuth — User model fields', () => {
|
||||
const createdIds = []
|
||||
|
||||
afterEach(async () => {
|
||||
for (const id of createdIds) {
|
||||
try {
|
||||
await prisma.user.delete({ where: { id } })
|
||||
} catch {
|
||||
// Already deleted by another test or cleanup — ignore
|
||||
}
|
||||
}
|
||||
createdIds.length = 0
|
||||
})
|
||||
|
||||
it('stores displayName and avatar fields on User model', async () => {
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: 'test-oauth@example.com',
|
||||
displayName: 'Test User',
|
||||
avatar: 'https://example.com/avatar.jpg',
|
||||
},
|
||||
})
|
||||
|
||||
createdIds.push(user.id)
|
||||
|
||||
expect(user.displayName).toBe('Test User')
|
||||
expect(user.avatar).toBe('https://example.com/avatar.jpg')
|
||||
})
|
||||
|
||||
it('allows nullable fields', async () => {
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: 'test-oauth-null@example.com',
|
||||
},
|
||||
})
|
||||
|
||||
createdIds.push(user.id)
|
||||
|
||||
expect(user.displayName).toBeNull()
|
||||
expect(user.avatar).toBeNull()
|
||||
})
|
||||
})
|
||||
Executable
+238
@@ -0,0 +1,238 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import Fastify from 'fastify'
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { buildSseListeners, formatHeartbit, formatSSE, isAdminUser, registerSseRoutes } from '../sse.js'
|
||||
|
||||
describe('formatSSE', () => {
|
||||
it('formats event with data', () => {
|
||||
const result = formatSSE('message:new', { orderId: 'o1' })
|
||||
expect(result).toBe('event: message:new\ndata: {"orderId":"o1"}\n\n')
|
||||
})
|
||||
|
||||
it('formats event without data', () => {
|
||||
const result = formatSSE('heartbit')
|
||||
expect(result).toBe('event: heartbit\n\n')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatHeartbit', () => {
|
||||
it('returns SSE comment', () => {
|
||||
expect(formatHeartbit()).toBe(':heartbit\n\n')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isAdminUser', () => {
|
||||
it('returns false for non-matching email', () => {
|
||||
expect(isAdminUser({ email: 'user@test.com' })).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when email matches ADMIN_EMAIL', () => {
|
||||
const adminEmail = process.env.ADMIN_EMAIL
|
||||
if (!adminEmail) {
|
||||
console.warn('ADMIN_EMAIL not set, skipping')
|
||||
return
|
||||
}
|
||||
expect(isAdminUser({ email: adminEmail })).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for null/undefined user', () => {
|
||||
expect(isAdminUser(null)).toBe(false)
|
||||
expect(isAdminUser(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it('normalizes email before comparing with ADMIN_EMAIL', () => {
|
||||
const previousAdminEmail = process.env.ADMIN_EMAIL
|
||||
process.env.ADMIN_EMAIL = 'Admin@Test.com'
|
||||
|
||||
expect(isAdminUser({ email: ' admin@test.com ' })).toBe(true)
|
||||
|
||||
if (previousAdminEmail === undefined) {
|
||||
delete process.env.ADMIN_EMAIL
|
||||
} else {
|
||||
process.env.ADMIN_EMAIL = previousAdminEmail
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildSseListeners', () => {
|
||||
let eventBus
|
||||
let write
|
||||
|
||||
beforeEach(() => {
|
||||
eventBus = new EventEmitter()
|
||||
eventBus.setMaxListeners(50)
|
||||
write = vi.fn()
|
||||
})
|
||||
|
||||
it('forwards orderMessage:adminReply to matching userId', () => {
|
||||
const cleanup = buildSseListeners('user-1', false, eventBus, write)
|
||||
eventBus.emit('orderMessage:adminReply', { orderId: 'o1', userId: 'user-1', messageId: 'm1', preview: 'Hi' })
|
||||
expect(write).toHaveBeenCalledTimes(1)
|
||||
expect(write.mock.calls[0][0]).toContain('event: message:new')
|
||||
expect(write.mock.calls[0][0]).toContain('"orderId":"o1"')
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('ignores orderMessage:adminReply for non-matching userId', () => {
|
||||
const cleanup = buildSseListeners('user-2', false, eventBus, write)
|
||||
eventBus.emit('orderMessage:adminReply', { orderId: 'o1', userId: 'user-1', messageId: 'm1', preview: 'Hi' })
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('forwards orderMessage:sent to admin', () => {
|
||||
const cleanup = buildSseListeners('admin-1', true, eventBus, write)
|
||||
eventBus.emit('orderMessage:sent', { orderId: 'o1', authorType: 'user', messageId: 'm1', preview: 'Hello' })
|
||||
expect(write).toHaveBeenCalledTimes(1)
|
||||
expect(write.mock.calls[0][0]).toContain('event: message:new')
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('ignores orderMessage:sent for non-admin', () => {
|
||||
const cleanup = buildSseListeners('user-1', false, eventBus, write)
|
||||
eventBus.emit('orderMessage:sent', { orderId: 'o1', authorType: 'user', messageId: 'm1', preview: 'Hello' })
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('forwards order:statusChanged to matching userId', () => {
|
||||
const cleanup = buildSseListeners('user-1', false, eventBus, write)
|
||||
eventBus.emit('order:statusChanged', {
|
||||
orderId: 'o1',
|
||||
userId: 'user-1',
|
||||
oldStatus: 'PENDING_PAYMENT',
|
||||
newStatus: 'PAID',
|
||||
})
|
||||
expect(write).toHaveBeenCalledTimes(1)
|
||||
expect(write.mock.calls[0][0]).toContain('event: order:statusChanged')
|
||||
expect(write.mock.calls[0][0]).toContain('"newStatus":"PAID"')
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('forwards order:statusChanged to admin', () => {
|
||||
const cleanup = buildSseListeners('admin-1', true, eventBus, write)
|
||||
eventBus.emit('order:statusChanged', {
|
||||
orderId: 'o1',
|
||||
userId: 'user-1',
|
||||
oldStatus: 'READY_FOR_PICKUP',
|
||||
newStatus: 'DONE',
|
||||
})
|
||||
expect(write).toHaveBeenCalledTimes(1)
|
||||
expect(write.mock.calls[0][0]).toContain('event: order:statusChanged')
|
||||
expect(write.mock.calls[0][0]).toContain('"orderId":"o1"')
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('forwards payment:statusChanged to matching userId', () => {
|
||||
const cleanup = buildSseListeners('user-1', false, eventBus, write)
|
||||
eventBus.emit('payment:statusChanged', { orderId: 'o1', userId: 'user-1', paymentStatus: 'paid' })
|
||||
expect(write).toHaveBeenCalledTimes(1)
|
||||
expect(write.mock.calls[0][0]).toContain('event: order:statusChanged')
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('forwards payment:statusChanged to admin', () => {
|
||||
const cleanup = buildSseListeners('admin-1', true, eventBus, write)
|
||||
eventBus.emit('payment:statusChanged', { orderId: 'o1', userId: 'user-1', paymentStatus: 'paid' })
|
||||
expect(write).toHaveBeenCalledTimes(1)
|
||||
expect(write.mock.calls[0][0]).toContain('event: order:statusChanged')
|
||||
expect(write.mock.calls[0][0]).toContain('"orderId":"o1"')
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('forwards order:deliveryFeeAdjusted to matching userId', () => {
|
||||
const cleanup = buildSseListeners('user-1', false, eventBus, write)
|
||||
eventBus.emit('order:deliveryFeeAdjusted', { orderId: 'o1', userId: 'user-1', totalCents: 50000 })
|
||||
expect(write).toHaveBeenCalledTimes(1)
|
||||
expect(write.mock.calls[0][0]).toContain('event: order:updated')
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('forwards order:deliveryFeeAdjusted to admin', () => {
|
||||
const cleanup = buildSseListeners('admin-1', true, eventBus, write)
|
||||
eventBus.emit('order:deliveryFeeAdjusted', { orderId: 'o1', userId: 'user-1', totalCents: 50000 })
|
||||
expect(write).toHaveBeenCalledTimes(1)
|
||||
expect(write.mock.calls[0][0]).toContain('event: order:updated')
|
||||
expect(write.mock.calls[0][0]).toContain('"orderId":"o1"')
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('forwards order:created to admin', () => {
|
||||
const cleanup = buildSseListeners('admin-1', true, eventBus, write)
|
||||
eventBus.emit('order:created', { orderId: 'o1', userId: 'user-1', totalCents: 50000 })
|
||||
expect(write).toHaveBeenCalledTimes(1)
|
||||
expect(write.mock.calls[0][0]).toContain('event: order:new')
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('forwards order:created:admin to admin', () => {
|
||||
const cleanup = buildSseListeners('admin-1', true, eventBus, write)
|
||||
eventBus.emit('order:created:admin', { orderId: 'o1', userId: 'user-1', userEmail: 'user@test.com' })
|
||||
expect(write).toHaveBeenCalledTimes(1)
|
||||
expect(write.mock.calls[0][0]).toContain('event: order:new')
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('ignores order:created for non-admin', () => {
|
||||
const cleanup = buildSseListeners('user-1', false, eventBus, write)
|
||||
eventBus.emit('order:created', { orderId: 'o1', userId: 'user-1', totalCents: 50000 })
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('cleanup removes all listeners', () => {
|
||||
const cleanup = buildSseListeners('user-1', false, eventBus, write)
|
||||
cleanup()
|
||||
eventBus.emit('orderMessage:adminReply', { orderId: 'o1', userId: 'user-1', messageId: 'm1', preview: 'Hi' })
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/sse/stream (integration)', () => {
|
||||
let app
|
||||
|
||||
beforeAll(async () => {
|
||||
app = Fastify({ logger: false })
|
||||
app.decorate('authenticate', async function (request, reply) {
|
||||
try {
|
||||
const token = request.query?.token
|
||||
if (!token) throw new Error('no token')
|
||||
if (token === 'user-token') {
|
||||
request.user = { sub: 'user-1', email: 'user@test.com' }
|
||||
} else if (token === 'admin-token') {
|
||||
request.user = { sub: 'admin-1', email: process.env.ADMIN_EMAIL || 'admin@test.com' }
|
||||
} else {
|
||||
throw new Error('bad token')
|
||||
}
|
||||
} catch {
|
||||
return reply.code(401).send({ error: 'Unauthorized' })
|
||||
}
|
||||
})
|
||||
app.decorate('eventBus', new EventEmitter())
|
||||
await registerSseRoutes(app)
|
||||
await app.ready()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('returns 401 without token', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/api/sse/stream' })
|
||||
expect(res.statusCode).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 200 and event-stream headers for authenticated user', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/api/sse/stream?token=user-token', payloadAsStream: true })
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(res.headers['content-type']).toBe('text/event-stream')
|
||||
expect(res.headers['cache-control']).toBe('no-cache')
|
||||
expect(res.headers['connection']).toBe('keep-alive')
|
||||
})
|
||||
|
||||
it('sends initial heartbit', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/api/sse/stream?token=user-token', payloadAsStream: true })
|
||||
const body = res.stream().read().toString()
|
||||
expect(body).toContain(':heartbit')
|
||||
})
|
||||
})
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import jwt from '@fastify/jwt'
|
||||
import Fastify from 'fastify'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NOTIFICATION_EVENTS } from '../../../../shared/constants/notification-events.js'
|
||||
import { prisma } from '../../lib/prisma.js'
|
||||
import { registerUserOrderRoutes } from '../user-orders.js'
|
||||
|
||||
const JWT_SECRET = 'test-secret'
|
||||
|
||||
let app
|
||||
let testUser
|
||||
let testUserEmail
|
||||
|
||||
async function buildApp() {
|
||||
const fastify = Fastify({ logger: false })
|
||||
await fastify.register(jwt, { secret: JWT_SECRET })
|
||||
fastify.decorate('authenticate', async function (request, reply) {
|
||||
try {
|
||||
await request.jwtVerify()
|
||||
} catch {
|
||||
return reply.code(401).send({ error: 'Unauthorized' })
|
||||
}
|
||||
})
|
||||
fastify.decorate('eventBus', { emit: vi.fn() })
|
||||
await registerUserOrderRoutes(fastify)
|
||||
await fastify.ready()
|
||||
return fastify
|
||||
}
|
||||
|
||||
async function signToken(user) {
|
||||
return app.jwt.sign({ sub: user.id, email: user.email })
|
||||
}
|
||||
|
||||
async function createOrder(data = {}) {
|
||||
return prisma.order.create({
|
||||
data: {
|
||||
userId: testUser.id,
|
||||
status: 'SHIPPED',
|
||||
deliveryType: 'delivery',
|
||||
deliveryFeeLocked: true,
|
||||
paymentMethod: 'online',
|
||||
itemsSubtotalCents: 10000,
|
||||
deliveryFeeCents: 50000,
|
||||
totalCents: 60000,
|
||||
currency: 'RUB',
|
||||
...data,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('user order routes', () => {
|
||||
beforeEach(async () => {
|
||||
testUserEmail = `user-orders-${randomUUID()}@example.com`
|
||||
testUser = await prisma.user.create({ data: { email: testUserEmail } })
|
||||
app = await buildApp()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await app?.close()
|
||||
if (testUser?.id) {
|
||||
await prisma.order.deleteMany({ where: { userId: testUser.id } })
|
||||
await prisma.user.deleteMany({ where: { id: testUser.id } })
|
||||
} else if (testUserEmail) {
|
||||
await prisma.user.deleteMany({ where: { email: testUserEmail } })
|
||||
}
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('emits order status event when user confirms receiving an order', async () => {
|
||||
const order = await createOrder()
|
||||
const token = await signToken(testUser)
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/me/orders/${order.id}/confirm-received`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(app.eventBus.emit).toHaveBeenCalledWith(NOTIFICATION_EVENTS.ORDER_STATUS_CHANGED, {
|
||||
orderId: order.id,
|
||||
userId: testUser.id,
|
||||
oldStatus: 'SHIPPED',
|
||||
newStatus: 'DONE',
|
||||
})
|
||||
})
|
||||
})
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
import jwt from '@fastify/jwt'
|
||||
import Fastify from 'fastify'
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { prisma } from '../../lib/prisma.js'
|
||||
import { registerUserPaymentRoutes } from '../user-payments.js'
|
||||
|
||||
const JWT_SECRET = 'test-secret'
|
||||
const TEST_USER_EMAIL = `test-pay-${Date.now()}@example.com`
|
||||
|
||||
let testUserId
|
||||
let testOrderId
|
||||
|
||||
async function signToken(userId, email = TEST_USER_EMAIL) {
|
||||
const fastify = Fastify()
|
||||
await fastify.register(jwt, { secret: JWT_SECRET })
|
||||
await fastify.ready()
|
||||
return fastify.jwt.sign({ sub: userId, email })
|
||||
}
|
||||
|
||||
async function buildApp() {
|
||||
const app = Fastify({ logger: false })
|
||||
await app.register(jwt, { secret: JWT_SECRET })
|
||||
app.decorate('authenticate', async function (request, reply) {
|
||||
try {
|
||||
await request.jwtVerify()
|
||||
} catch {
|
||||
return reply.code(401).send({ error: 'Unauthorized' })
|
||||
}
|
||||
})
|
||||
app.decorate('eventBus', { emit: () => {} })
|
||||
await registerUserPaymentRoutes(app)
|
||||
await app.ready()
|
||||
return app
|
||||
}
|
||||
|
||||
describe('POST /api/me/orders/:id/pay', () => {
|
||||
let app
|
||||
|
||||
beforeAll(async () => {
|
||||
await prisma.payment.deleteMany()
|
||||
await prisma.order.deleteMany({ where: { user: { email: TEST_USER_EMAIL } } })
|
||||
await prisma.user.deleteMany({ where: { email: TEST_USER_EMAIL } })
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: { email: TEST_USER_EMAIL },
|
||||
})
|
||||
testUserId = user.id
|
||||
|
||||
const order = await prisma.order.create({
|
||||
data: {
|
||||
userId: testUserId,
|
||||
status: 'PENDING_PAYMENT',
|
||||
paymentMethod: 'online',
|
||||
deliveryFeeLocked: true,
|
||||
totalCents: 100000,
|
||||
currency: 'RUB',
|
||||
deliveryFeeCents: 0,
|
||||
},
|
||||
})
|
||||
testOrderId = order.id
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.payment.deleteMany({ where: { orderId: testOrderId } })
|
||||
await prisma.order.deleteMany({ where: { userId: testUserId } })
|
||||
await prisma.user.deleteMany({ where: { email: TEST_USER_EMAIL } })
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
await prisma.order.update({
|
||||
where: { id: testOrderId },
|
||||
data: {
|
||||
status: 'PENDING_PAYMENT',
|
||||
paymentMethod: 'online',
|
||||
deliveryFeeLocked: true,
|
||||
},
|
||||
})
|
||||
app = await buildApp()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns 401 without auth', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/me/orders/${testOrderId}/pay`,
|
||||
})
|
||||
expect(res.statusCode).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 404 when order not found', async () => {
|
||||
const token = await signToken(testUserId)
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/me/orders/nonexistent-id/pay',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(res.statusCode).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 409 when payment method is on_pickup', async () => {
|
||||
await prisma.order.update({
|
||||
where: { id: testOrderId },
|
||||
data: { paymentMethod: 'on_pickup' },
|
||||
})
|
||||
const token = await signToken(testUserId)
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/me/orders/${testOrderId}/pay`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(res.statusCode).toBe(409)
|
||||
})
|
||||
|
||||
it('returns 409 when order not in PENDING_PAYMENT status', async () => {
|
||||
await prisma.order.update({
|
||||
where: { id: testOrderId },
|
||||
data: { status: 'PAID' },
|
||||
})
|
||||
const token = await signToken(testUserId)
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/me/orders/${testOrderId}/pay`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(res.statusCode).toBe(409)
|
||||
})
|
||||
|
||||
it('returns 409 when deliveryFeeLocked is false', async () => {
|
||||
await prisma.order.update({
|
||||
where: { id: testOrderId },
|
||||
data: { deliveryFeeLocked: false },
|
||||
})
|
||||
const token = await signToken(testUserId)
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/me/orders/${testOrderId}/pay`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(res.statusCode).toBe(409)
|
||||
})
|
||||
|
||||
it('returns 422 when user has no email', async () => {
|
||||
const noEmailUser = await prisma.user.create({
|
||||
data: { email: `noemail-${Date.now()}@test.com` },
|
||||
})
|
||||
const noEmailOrder = await prisma.order.create({
|
||||
data: {
|
||||
userId: noEmailUser.id,
|
||||
status: 'PENDING_PAYMENT',
|
||||
paymentMethod: 'online',
|
||||
deliveryFeeLocked: true,
|
||||
totalCents: 100000,
|
||||
currency: 'RUB',
|
||||
},
|
||||
})
|
||||
|
||||
const fastify = Fastify()
|
||||
await fastify.register(jwt, { secret: JWT_SECRET })
|
||||
const token = fastify.jwt.sign({ sub: noEmailUser.id })
|
||||
await fastify.close()
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/me/orders/${noEmailOrder.id}/pay`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(res.statusCode).toBe(422)
|
||||
|
||||
await prisma.order.deleteMany({ where: { userId: noEmailUser.id } })
|
||||
await prisma.user.deleteMany({ where: { id: noEmailUser.id } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/me/orders/:orderId/payment', () => {
|
||||
let app
|
||||
let getTestUserId
|
||||
let getTestOrderId
|
||||
|
||||
beforeAll(async () => {
|
||||
const getEmail = `get-pay-${Date.now()}@example.com`
|
||||
const user = await prisma.user.create({ data: { email: getEmail } })
|
||||
getTestUserId = user.id
|
||||
|
||||
const order = await prisma.order.create({
|
||||
data: {
|
||||
userId: getTestUserId,
|
||||
status: 'PENDING_PAYMENT',
|
||||
paymentMethod: 'online',
|
||||
deliveryFeeLocked: true,
|
||||
totalCents: 100000,
|
||||
currency: 'RUB',
|
||||
},
|
||||
})
|
||||
getTestOrderId = order.id
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.payment.deleteMany({ where: { orderId: getTestOrderId } })
|
||||
await prisma.order.deleteMany({ where: { userId: getTestUserId } })
|
||||
await prisma.user.deleteMany({ where: { id: getTestUserId } })
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
app = await buildApp()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('returns 401 without auth', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/me/orders/${getTestOrderId}/payment`,
|
||||
})
|
||||
expect(res.statusCode).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 404 when order not found', async () => {
|
||||
const token = await signToken(getTestUserId)
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/me/orders/nonexistent-id/payment',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(res.statusCode).toBe(404)
|
||||
})
|
||||
|
||||
it('returns status null when no payment exists', async () => {
|
||||
const token = await signToken(getTestUserId)
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/me/orders/${getTestOrderId}/payment`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
const body = JSON.parse(res.payload)
|
||||
expect(body.status).toBeNull()
|
||||
expect(body.paid).toBe(false)
|
||||
})
|
||||
})
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
import Fastify from 'fastify'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NOTIFICATION_EVENTS } from '../../../../shared/constants/notification-events.js'
|
||||
|
||||
const { mockPrisma } = vi.hoisted(() => ({
|
||||
mockPrisma: {
|
||||
payment: { findFirst: vi.fn(), update: vi.fn() },
|
||||
order: { findFirst: vi.fn(), updateMany: vi.fn() },
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../lib/prisma.js', () => ({
|
||||
prisma: mockPrisma,
|
||||
}))
|
||||
|
||||
vi.mock('../../lib/yookassa.js', () => ({
|
||||
validateWebhook: vi.fn(),
|
||||
}))
|
||||
|
||||
import { validateWebhook } from '../../lib/yookassa.js'
|
||||
import { registerYookassaWebhookRoute } from '../webhook-yookassa.js'
|
||||
|
||||
function buildApp(eventBusMock) {
|
||||
const app = Fastify({ logger: false })
|
||||
app.decorate('eventBus', eventBusMock || { emit: () => {} })
|
||||
return app
|
||||
}
|
||||
|
||||
describe('POST /api/webhooks/yookassa', () => {
|
||||
let app
|
||||
let eventBus
|
||||
|
||||
beforeEach(async () => {
|
||||
eventBus = { emit: vi.fn() }
|
||||
validateWebhook.mockImplementation((_ip, body) => {
|
||||
if (!body || typeof body !== 'object') throw new Error('Invalid webhook body')
|
||||
if (body.type !== 'notification') throw new Error('Expected notification type in webhook body')
|
||||
if (!body.event || !body.object) throw new Error('Missing event or object in webhook body')
|
||||
return { event: body.event, paymentObject: body.object }
|
||||
})
|
||||
app = buildApp(eventBus)
|
||||
await registerYookassaWebhookRoute(app)
|
||||
await app.ready()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns 400 for invalid body', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/webhooks/yookassa',
|
||||
payload: { not: 'valid' },
|
||||
})
|
||||
expect(res.statusCode).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 404 when payment not found', async () => {
|
||||
mockPrisma.payment.findFirst.mockResolvedValue(null)
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/webhooks/yookassa',
|
||||
payload: {
|
||||
type: 'notification',
|
||||
event: 'payment.succeeded',
|
||||
object: { id: 'unknown-id', status: 'succeeded', paid: true },
|
||||
},
|
||||
})
|
||||
expect(res.statusCode).toBe(404)
|
||||
})
|
||||
|
||||
it('updates payment and order on payment.succeeded', async () => {
|
||||
mockPrisma.payment.findFirst.mockResolvedValue({
|
||||
id: 'payment-1',
|
||||
yookassaPaymentId: 'yk-id',
|
||||
status: 'pending',
|
||||
orderId: 'order-1',
|
||||
})
|
||||
mockPrisma.payment.update.mockResolvedValue({})
|
||||
mockPrisma.order.findFirst.mockResolvedValue({
|
||||
id: 'order-1',
|
||||
status: 'PENDING_PAYMENT',
|
||||
userId: 'user-1',
|
||||
})
|
||||
mockPrisma.order.updateMany.mockResolvedValue({ count: 1 })
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/webhooks/yookassa',
|
||||
payload: {
|
||||
type: 'notification',
|
||||
event: 'payment.succeeded',
|
||||
object: { id: 'yk-id', status: 'succeeded', paid: true },
|
||||
},
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
|
||||
const updateData = mockPrisma.payment.update.mock.calls[0][0].data
|
||||
expect(updateData.status).toBe('succeeded')
|
||||
|
||||
const orderUpdateData = mockPrisma.order.updateMany.mock.calls[0][0].data
|
||||
expect(orderUpdateData.status).toBe('PAID')
|
||||
expect(eventBus.emit).toHaveBeenCalledWith(NOTIFICATION_EVENTS.PAYMENT_STATUS_CHANGED, {
|
||||
orderId: 'order-1',
|
||||
userId: 'user-1',
|
||||
paymentStatus: 'paid',
|
||||
})
|
||||
})
|
||||
|
||||
it('updates payment on payment.canceled without changing order', async () => {
|
||||
mockPrisma.payment.findFirst.mockResolvedValue({
|
||||
id: 'payment-1',
|
||||
yookassaPaymentId: 'yk-id',
|
||||
status: 'pending',
|
||||
orderId: 'order-1',
|
||||
})
|
||||
mockPrisma.payment.update.mockResolvedValue({})
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/webhooks/yookassa',
|
||||
payload: {
|
||||
type: 'notification',
|
||||
event: 'payment.canceled',
|
||||
object: { id: 'yk-id', status: 'canceled', paid: false },
|
||||
},
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(mockPrisma.order.findFirst).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
import { mapProductForApi, parseMaterialsInput, slugify } from './api/_product-helpers.js'
|
||||
import { registerAdminNotificationRoutes } from './api/admin/notifications.js'
|
||||
import { registerAdminTestChecklistRoutes } from './api/admin/test-checklist.js'
|
||||
import { registerAdminCategoryRoutes } from './api/admin-categories.js'
|
||||
import { registerAdminGalleryRoutes } from './api/admin-gallery.js'
|
||||
import { registerAdminOrderRoutes } from './api/admin-orders.js'
|
||||
import { registerAdminProductRoutes } from './api/admin-products.js'
|
||||
import { registerAdminProfileRoutes } from './api/admin-profile.js'
|
||||
import { registerAdminReviewRoutes } from './api/admin-reviews.js'
|
||||
import { registerAdminUserRoutes } from './api/admin-users.js'
|
||||
import { registerCatalogSliderRoutes } from './api/catalog-slider.js'
|
||||
import { registerPublicCatalogRoutes } from './api/public-catalog.js'
|
||||
import { registerPublicReviewRoutes } from './api/public-reviews.js'
|
||||
import { registerAuthOAuthRoutes } from './auth-oauth.js'
|
||||
import { registerAuthPasswordRoutes } from './auth-password.js'
|
||||
import { registerAuthSessionRoutes } from './auth-session.js'
|
||||
import { registerAuthRoutes } from './auth.js'
|
||||
|
||||
export async function registerApiRoutes(fastify) {
|
||||
fastify.decorate('slugify', slugify)
|
||||
fastify.decorate('parseMaterialsInput', parseMaterialsInput)
|
||||
fastify.decorate('mapProductForApi', mapProductForApi)
|
||||
|
||||
await registerPublicCatalogRoutes(fastify)
|
||||
await registerPublicReviewRoutes(fastify)
|
||||
await registerCatalogSliderRoutes(fastify)
|
||||
|
||||
await registerAdminProductRoutes(fastify)
|
||||
await registerAdminGalleryRoutes(fastify)
|
||||
await registerAdminCategoryRoutes(fastify)
|
||||
await registerAdminOrderRoutes(fastify)
|
||||
await registerAdminReviewRoutes(fastify)
|
||||
await registerAdminUserRoutes(fastify)
|
||||
await registerAdminNotificationRoutes(fastify)
|
||||
await registerAdminTestChecklistRoutes(fastify)
|
||||
await registerAdminProfileRoutes(fastify)
|
||||
|
||||
await registerAuthRoutes(fastify)
|
||||
await registerAuthSessionRoutes(fastify)
|
||||
await registerAuthPasswordRoutes(fastify)
|
||||
await registerAuthOAuthRoutes(fastify)
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
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')
|
||||
|
||||
import { generateAllSizes, convertOriginalToWebp } from '../../../lib/image-resize.js'
|
||||
|
||||
describe('Admin gallery resize integration', () => {
|
||||
const testUuid = 'gallery-test-resize-uuid'
|
||||
const testOriginalPath = path.join(UPLOADS_DIR, `${testUuid}.png`)
|
||||
|
||||
beforeAll(async () => {
|
||||
const sharp = (await import('sharp')).default
|
||||
await sharp({ create: { width: 200, height: 200, channels: 3, background: { r: 255, g: 0, b: 0 } } })
|
||||
.png()
|
||||
.toFile(testOriginalPath)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await fs.promises.unlink(testOriginalPath).catch(() => {})
|
||||
const webpPath = path.join(UPLOADS_DIR, `${testUuid}.webp`)
|
||||
await fs.promises.unlink(webpPath).catch(() => {})
|
||||
const cacheDir = path.join(UPLOADS_DIR, '.cache')
|
||||
for (const width of [320, 640, 1024, 1600]) {
|
||||
for (const format of ['avif', 'webp']) {
|
||||
await fs.promises.unlink(path.join(cacheDir, `${testUuid}_w${width}.${format}`)).catch(() => {})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('generateAllSizes + convertOriginalToWebp works on raw upload', async () => {
|
||||
await generateAllSizes(testUuid, '', testOriginalPath)
|
||||
const newUrl = await convertOriginalToWebp(testUuid, '')
|
||||
|
||||
expect(newUrl).toBe(`/uploads/${testUuid}.webp`)
|
||||
|
||||
// Verify original PNG is deleted
|
||||
const pngExists = await fs.promises
|
||||
.access(testOriginalPath)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
expect(pngExists).toBe(false)
|
||||
|
||||
// Verify cached files exist
|
||||
const cacheDir = path.join(UPLOADS_DIR, '.cache')
|
||||
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)
|
||||
expect(exists).toBe(true)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify webp original exists
|
||||
const webpExists = await fs.promises
|
||||
.access(path.join(UPLOADS_DIR, `${testUuid}.webp`))
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
expect(webpExists).toBe(true)
|
||||
})
|
||||
})
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
import jwt from '@fastify/jwt'
|
||||
import Fastify from 'fastify'
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { prisma } from '../../../lib/prisma.js'
|
||||
import { registerAdminOrderRoutes } from '../admin-orders.js'
|
||||
|
||||
const JWT_SECRET = 'test-secret'
|
||||
const ADMIN_EMAIL = `admin-orders-${Date.now()}@example.com`
|
||||
const USER_EMAIL = `admin-orders-user-${Date.now()}@example.com`
|
||||
|
||||
let app
|
||||
let adminUser
|
||||
let buyer
|
||||
|
||||
async function signToken(user) {
|
||||
return app.jwt.sign({ sub: user.id, email: user.email })
|
||||
}
|
||||
|
||||
async function buildApp() {
|
||||
const fastify = Fastify({ logger: false })
|
||||
await fastify.register(jwt, { secret: JWT_SECRET })
|
||||
fastify.decorate('eventBus', { emit: vi.fn() })
|
||||
fastify.decorate('verifyAdmin', async (request, reply) => {
|
||||
try {
|
||||
await request.jwtVerify()
|
||||
} catch {
|
||||
return reply.code(401).send({ error: 'Unauthorized' })
|
||||
}
|
||||
if (request.user.email !== ADMIN_EMAIL) {
|
||||
return reply.code(401).send({ error: 'Admin only' })
|
||||
}
|
||||
})
|
||||
await registerAdminOrderRoutes(fastify)
|
||||
await fastify.ready()
|
||||
return fastify
|
||||
}
|
||||
|
||||
async function createOrder(data = {}) {
|
||||
return prisma.order.create({
|
||||
data: {
|
||||
userId: buyer.id,
|
||||
status: 'PENDING_PAYMENT',
|
||||
deliveryType: 'delivery',
|
||||
deliveryFeeLocked: false,
|
||||
paymentMethod: 'online',
|
||||
itemsSubtotalCents: 10000,
|
||||
deliveryFeeCents: 50000,
|
||||
totalCents: 60000,
|
||||
currency: 'RUB',
|
||||
...data,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('admin order routes', () => {
|
||||
beforeAll(async () => {
|
||||
await prisma.payment.deleteMany()
|
||||
await prisma.order.deleteMany({ where: { user: { email: { in: [ADMIN_EMAIL, USER_EMAIL] } } } })
|
||||
await prisma.user.deleteMany({ where: { email: { in: [ADMIN_EMAIL, USER_EMAIL] } } })
|
||||
|
||||
adminUser = await prisma.user.create({ data: { email: ADMIN_EMAIL } })
|
||||
buyer = await prisma.user.create({ data: { email: USER_EMAIL } })
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
await prisma.order.deleteMany({ where: { userId: buyer.id } })
|
||||
app = await buildApp()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.order.deleteMany({ where: { userId: buyer.id } })
|
||||
await prisma.user.deleteMany({ where: { id: { in: [adminUser.id, buyer.id] } } })
|
||||
})
|
||||
|
||||
it('summary counts only delivery orders waiting for price approval', async () => {
|
||||
const token = await signToken(adminUser)
|
||||
const beforeRes = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/admin/orders/summary',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
const baseline = beforeRes.json().attentionCount
|
||||
|
||||
await createOrder({ deliveryFeeLocked: false, deliveryType: 'delivery' })
|
||||
await createOrder({ deliveryFeeLocked: true, deliveryType: 'delivery' })
|
||||
await createOrder({ deliveryFeeLocked: false, deliveryType: 'pickup' })
|
||||
await createOrder({ deliveryFeeLocked: false, deliveryType: 'delivery', status: 'PAID' })
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/admin/orders/summary',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(res.json()).toEqual({ attentionCount: baseline + 1 })
|
||||
})
|
||||
|
||||
it('rejects PAID transition while delivery fee is not locked', async () => {
|
||||
const order = await createOrder({ deliveryFeeLocked: false, deliveryType: 'delivery' })
|
||||
const token = await signToken(adminUser)
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/admin/orders/${order.id}/status`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { status: 'PAID' },
|
||||
})
|
||||
|
||||
expect(res.statusCode).toBe(409)
|
||||
expect(res.json().error).toContain('стоимость доставки')
|
||||
})
|
||||
})
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
import jwt from '@fastify/jwt'
|
||||
import Fastify from 'fastify'
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { prisma } from '../../../lib/prisma.js'
|
||||
import { mapProductForApi, parseMaterialsInput, slugify } from '../_product-helpers.js'
|
||||
import { registerAdminProductRoutes } from '../admin-products.js'
|
||||
|
||||
const JWT_SECRET = 'test-secret'
|
||||
const ADMIN_EMAIL = `admin-products-${Date.now()}@example.com`
|
||||
|
||||
let app
|
||||
let adminUser
|
||||
let category
|
||||
|
||||
async function signToken(user) {
|
||||
return app.jwt.sign({ sub: user.id, email: user.email })
|
||||
}
|
||||
|
||||
async function buildApp() {
|
||||
const fastify = Fastify({ logger: false })
|
||||
await fastify.register(jwt, { secret: JWT_SECRET })
|
||||
fastify.decorate('verifyAdmin', async (request, reply) => {
|
||||
try {
|
||||
await request.jwtVerify()
|
||||
} catch {
|
||||
return reply.code(401).send({ error: 'Unauthorized' })
|
||||
}
|
||||
if (request.user.email !== ADMIN_EMAIL) {
|
||||
return reply.code(401).send({ error: 'Admin only' })
|
||||
}
|
||||
})
|
||||
fastify.decorate('slugify', slugify)
|
||||
fastify.decorate('parseMaterialsInput', parseMaterialsInput)
|
||||
fastify.decorate('mapProductForApi', mapProductForApi)
|
||||
await registerAdminProductRoutes(fastify)
|
||||
await fastify.ready()
|
||||
return fastify
|
||||
}
|
||||
|
||||
function productData(overrides = {}) {
|
||||
return {
|
||||
title: 'Медведь',
|
||||
slug: `bear-${Date.now()}`,
|
||||
priceCents: 120000,
|
||||
quantity: 1,
|
||||
categoryId: category.id,
|
||||
published: true,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('admin product routes', () => {
|
||||
beforeAll(async () => {
|
||||
await prisma.product.deleteMany({ where: { category: { slug: { startsWith: 'admin-products-test-' } } } })
|
||||
await prisma.category.deleteMany({ where: { slug: { startsWith: 'admin-products-test-' } } })
|
||||
await prisma.user.deleteMany({ where: { email: ADMIN_EMAIL } })
|
||||
|
||||
adminUser = await prisma.user.create({ data: { email: ADMIN_EMAIL } })
|
||||
category = await prisma.category.create({
|
||||
data: {
|
||||
name: 'Тестовая категория',
|
||||
slug: `admin-products-test-${Date.now()}`,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
await prisma.product.deleteMany({ where: { categoryId: category.id } })
|
||||
app = await buildApp()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.product.deleteMany({ where: { categoryId: category.id } })
|
||||
await prisma.category.delete({ where: { id: category.id } })
|
||||
await prisma.user.delete({ where: { id: adminUser.id } })
|
||||
})
|
||||
|
||||
it('генерирует уникальный slug при создании товара с повторяющимся названием без ручного slug', async () => {
|
||||
await prisma.product.create({ data: productData({ title: 'Bear', slug: 'bear' }) })
|
||||
const token = await signToken(adminUser)
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/admin/products',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: productData({ title: 'Bear', slug: undefined }),
|
||||
})
|
||||
|
||||
expect(res.statusCode).toBe(201)
|
||||
expect(res.json().slug).toBe('bear-2')
|
||||
})
|
||||
})
|
||||
Executable
+55
@@ -0,0 +1,55 @@
|
||||
import path from 'node:path'
|
||||
|
||||
export function slugify(input) {
|
||||
return String(input || '')
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9-]/gi, '')
|
||||
}
|
||||
|
||||
export function safeExtFromFilename(filename) {
|
||||
const ext = path.extname(String(filename || '')).toLowerCase()
|
||||
const allowed = new Set(['.png', '.jpg', '.jpeg', '.webp'])
|
||||
return allowed.has(ext) ? ext : null
|
||||
}
|
||||
|
||||
export function parseMaterialsInput(input) {
|
||||
if (Array.isArray(input)) {
|
||||
return input
|
||||
.map((x) => String(x || '').trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 30)
|
||||
}
|
||||
if (typeof input === 'string') {
|
||||
const s = input.trim()
|
||||
if (!s) return []
|
||||
return s
|
||||
.split(',')
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 30)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export function materialsFromDb(materials) {
|
||||
if (Array.isArray(materials)) return materials
|
||||
try {
|
||||
const v = JSON.parse(String(materials || '[]'))
|
||||
return Array.isArray(v) ? v.map((x) => String(x || '').trim()).filter(Boolean) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function mapProductForApi(p, reviewsSummary = null) {
|
||||
const base = {
|
||||
...p,
|
||||
materials: materialsFromDb(p.materials),
|
||||
}
|
||||
if (reviewsSummary && typeof reviewsSummary === 'object') {
|
||||
base.reviewsSummary = reviewsSummary
|
||||
}
|
||||
return base
|
||||
}
|
||||
Executable
+138
@@ -0,0 +1,138 @@
|
||||
import { asyncHandler } from '../../lib/async-handler.js'
|
||||
import {
|
||||
getOrCreateUnspecifiedCategory,
|
||||
isUnspecifiedCategorySlug,
|
||||
UNSPECIFIED_CATEGORY_SLUG,
|
||||
} from '../../lib/default-category.js'
|
||||
import { prisma } from '../../lib/prisma.js'
|
||||
|
||||
export async function registerAdminCategoryRoutes(fastify) {
|
||||
fastify.get(
|
||||
'/api/admin/categories',
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
asyncHandler(async (request, reply) => {
|
||||
const items = await prisma.category.findMany({
|
||||
orderBy: [{ sort: 'asc' }, { name: 'asc' }],
|
||||
})
|
||||
return { items }
|
||||
}),
|
||||
)
|
||||
|
||||
fastify.post(
|
||||
'/api/admin/categories',
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
asyncHandler(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] },
|
||||
asyncHandler(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
|
||||
}
|
||||
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
|
||||
}
|
||||
const clash = await prisma.category.findFirst({ where: { slug: s, NOT: { id } } })
|
||||
if (clash) {
|
||||
reply.code(409).send({ error: 'Такой slug уже занят' })
|
||||
return
|
||||
}
|
||||
}
|
||||
data.slug = s
|
||||
}
|
||||
|
||||
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
|
||||
}),
|
||||
)
|
||||
|
||||
fastify.delete(
|
||||
'/api/admin/categories/:id',
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
asyncHandler(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()
|
||||
}),
|
||||
)
|
||||
}
|
||||
Executable
+134
@@ -0,0 +1,134 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { asyncHandler } from '../../lib/async-handler.js'
|
||||
import { prisma } from '../../lib/prisma.js'
|
||||
import { persistMultipartImages } from '../../lib/upload-images.js'
|
||||
import {
|
||||
formatFileTooLargeMessage,
|
||||
getProductImageMaxFileBytes,
|
||||
isMultipartFileTooLargeError,
|
||||
} 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' },
|
||||
})
|
||||
|
||||
const urls = items.map((i) => i.url)
|
||||
const usedUrls = new Set()
|
||||
|
||||
const productImages = await prisma.productImage.findMany({
|
||||
where: { url: { in: urls } },
|
||||
select: { url: true },
|
||||
})
|
||||
for (const pi of productImages) {
|
||||
usedUrls.add(pi.url)
|
||||
}
|
||||
|
||||
const legacyProducts = await prisma.product.findMany({
|
||||
where: { imageUrl: { in: urls } },
|
||||
select: { imageUrl: true },
|
||||
})
|
||||
for (const p of legacyProducts) {
|
||||
if (p.imageUrl) usedUrls.add(p.imageUrl)
|
||||
}
|
||||
|
||||
return {
|
||||
items: items.map((i) => ({
|
||||
id: i.id,
|
||||
url: i.url,
|
||||
isResized: i.isResized,
|
||||
createdAt: i.createdAt,
|
||||
inUse: usedUrls.has(i.url),
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
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 },
|
||||
})
|
||||
}
|
||||
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] },
|
||||
asyncHandler(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
|
||||
|
||||
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 }
|
||||
}),
|
||||
)
|
||||
|
||||
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()
|
||||
})
|
||||
}
|
||||
Executable
+182
@@ -0,0 +1,182 @@
|
||||
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',
|
||||
deliveryType: 'delivery',
|
||||
deliveryFeeLocked: false,
|
||||
},
|
||||
})
|
||||
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() : ''
|
||||
|
||||
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 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,
|
||||
})
|
||||
|
||||
return {
|
||||
items: items.map((o) => ({
|
||||
id: o.id,
|
||||
status: o.status,
|
||||
deliveryType: o.deliveryType,
|
||||
deliveryFeeLocked: o.deliveryFeeLocked,
|
||||
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, displayName: true, avatar: true, avatarStyle: 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 обязателен' })
|
||||
|
||||
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}`,
|
||||
})
|
||||
}
|
||||
if (next === 'PAID' && existing.deliveryType === 'delivery' && existing.deliveryFeeLocked === false) {
|
||||
return reply.code(409).send({
|
||||
error: 'Сначала подтвердите итоговую стоимость доставки',
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
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 (копейки)',
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
})
|
||||
|
||||
request.server.eventBus.emit(NOTIFICATION_EVENTS.DELIVERY_FEE_ADJUSTED, {
|
||||
orderId: updated.id,
|
||||
userId: existing.userId,
|
||||
totalCents: updated.totalCents,
|
||||
})
|
||||
|
||||
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: 'Сообщение слишком длинное' })
|
||||
|
||||
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 },
|
||||
})
|
||||
|
||||
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 })
|
||||
})
|
||||
}
|
||||
Executable
+264
@@ -0,0 +1,264 @@
|
||||
import { prisma } from '../../lib/prisma.js'
|
||||
import { validateGalleryImages } from '../../lib/validate-gallery-images.js'
|
||||
|
||||
const CREATE_PRODUCT_SCHEMA = {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['title', 'priceCents', 'quantity', 'categoryId'],
|
||||
properties: {
|
||||
title: { type: 'string', minLength: 1 },
|
||||
slug: { type: 'string' },
|
||||
categoryId: { type: 'string', minLength: 1 },
|
||||
priceCents: { type: 'number', minimum: 0 },
|
||||
quantity: { type: 'number', minimum: 0 },
|
||||
shortDescription: { type: 'string', nullable: true },
|
||||
description: { type: 'string', nullable: true },
|
||||
materials: { anyOf: [{ type: 'array', items: { type: 'string' } }, { type: 'string' }] },
|
||||
imageUrl: { type: 'string', nullable: true },
|
||||
imageUrls: { type: 'array', items: { type: 'string' } },
|
||||
published: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const PATCH_PRODUCT_SCHEMA = {
|
||||
body: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
title: { type: 'string', minLength: 1 },
|
||||
slug: { type: 'string' },
|
||||
categoryId: { type: 'string', minLength: 1 },
|
||||
priceCents: { type: 'number', minimum: 0 },
|
||||
quantity: { type: 'number', minimum: 0 },
|
||||
shortDescription: { type: 'string', nullable: true },
|
||||
description: { type: 'string', nullable: true },
|
||||
materials: { anyOf: [{ type: 'array', items: { type: 'string' } }, { type: 'string' }] },
|
||||
imageUrl: { type: 'string', nullable: true },
|
||||
imageUrls: { type: 'array', items: { type: 'string' } },
|
||||
published: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
async function buildUniqueProductSlug(baseSlug) {
|
||||
const base = String(baseSlug || '').trim()
|
||||
let candidate = base
|
||||
let suffix = 2
|
||||
|
||||
while (await prisma.product.findUnique({ where: { slug: candidate } })) {
|
||||
candidate = `${base}-${suffix}`
|
||||
suffix += 1
|
||||
}
|
||||
|
||||
return candidate
|
||||
}
|
||||
|
||||
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.post(
|
||||
'/api/admin/products',
|
||||
{ preHandler: [fastify.verifyAdmin], schema: CREATE_PRODUCT_SCHEMA },
|
||||
async (request, reply) => {
|
||||
const body = request.body ?? {}
|
||||
const title = String(body.title ?? '').trim()
|
||||
if (!title) {
|
||||
reply.code(400).send({ error: 'Укажите название' })
|
||||
return
|
||||
}
|
||||
const requestedSlug = String(body.slug ?? '').trim()
|
||||
const slugBase = requestedSlug || request.server.slugify(title) || `item-${Date.now()}`
|
||||
const slug = requestedSlug ? slugBase : await buildUniqueProductSlug(slugBase)
|
||||
const categoryId = String(body.categoryId ?? '').trim()
|
||||
if (!categoryId) {
|
||||
reply.code(400).send({ error: 'Укажите категорию' })
|
||||
return
|
||||
}
|
||||
const cat = await prisma.category.findUnique({ where: { id: categoryId } })
|
||||
if (!cat) {
|
||||
reply.code(400).send({ error: 'Категория не найдена' })
|
||||
return
|
||||
}
|
||||
const priceCents = Number(body.priceCents)
|
||||
if (!Number.isFinite(priceCents) || priceCents <= 0) {
|
||||
reply.code(400).send({ error: 'Цена должна быть больше 0' })
|
||||
return
|
||||
}
|
||||
if (priceCents > 10_000_00) {
|
||||
reply.code(400).send({ error: 'Цена не может превышать 10 000 ₽' })
|
||||
return
|
||||
}
|
||||
const exists = requestedSlug ? await prisma.product.findUnique({ where: { slug } }) : null
|
||||
if (exists) {
|
||||
reply.code(409).send({ error: 'Такой slug уже занят' })
|
||||
return
|
||||
}
|
||||
|
||||
if (Array.isArray(body.imageUrls) && body.imageUrls.length > 0) {
|
||||
const urls = body.imageUrls.map((u) => String(u || '').trim()).filter(Boolean)
|
||||
if (urls.length > 0) {
|
||||
try {
|
||||
await validateGalleryImages(prisma, urls)
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode || 400).send({ error: err.message })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const n = Number(body.quantity)
|
||||
if (!Number.isInteger(n) || n < 0 || n > 10) {
|
||||
reply.code(400).send({ error: 'Количество — целое число от 0 до 10' })
|
||||
return
|
||||
}
|
||||
const quantity = n
|
||||
|
||||
const product = await prisma.product.create({
|
||||
data: {
|
||||
title,
|
||||
slug,
|
||||
shortDescription: body.shortDescription ? String(body.shortDescription) : null,
|
||||
description: body.description ? String(body.description) : null,
|
||||
quantity,
|
||||
materials: JSON.stringify(request.server.parseMaterialsInput(body.materials)),
|
||||
priceCents: Math.round(priceCents),
|
||||
imageUrl: body.imageUrl ? String(body.imageUrl) : null,
|
||||
published: Boolean(body.published),
|
||||
categoryId,
|
||||
images: Array.isArray(body.imageUrls)
|
||||
? {
|
||||
create: body.imageUrls
|
||||
.map((u) => String(u || '').trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 10)
|
||||
.map((u, idx) => ({ url: u, sort: idx })),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
include: { category: true, images: { orderBy: { sort: 'asc' } } },
|
||||
})
|
||||
reply.code(201).send(request.server.mapProductForApi(product))
|
||||
},
|
||||
)
|
||||
|
||||
fastify.patch(
|
||||
'/api/admin/products/:id',
|
||||
{ preHandler: [fastify.verifyAdmin], schema: PATCH_PRODUCT_SCHEMA },
|
||||
async (request, reply) => {
|
||||
const { id } = request.params
|
||||
const body = request.body ?? {}
|
||||
const existing = await prisma.product.findUnique({ where: { id } })
|
||||
if (!existing) {
|
||||
reply.code(404).send({ error: 'Товар не найден' })
|
||||
return
|
||||
}
|
||||
const data = {}
|
||||
if (body.title !== undefined) data.title = String(body.title).trim()
|
||||
if (body.slug !== undefined) {
|
||||
const s = String(body.slug).trim()
|
||||
if (s && s !== existing.slug) {
|
||||
const clash = await prisma.product.findFirst({ where: { slug: s, NOT: { id } } })
|
||||
if (clash) {
|
||||
reply.code(409).send({ error: 'Такой slug уже занят' })
|
||||
return
|
||||
}
|
||||
data.slug = s
|
||||
}
|
||||
}
|
||||
if (body.shortDescription !== undefined) {
|
||||
data.shortDescription = body.shortDescription ? String(body.shortDescription) : null
|
||||
}
|
||||
if (body.description !== undefined) {
|
||||
data.description = body.description ? String(body.description) : null
|
||||
}
|
||||
if (body.quantity !== undefined) {
|
||||
const n = Number(body.quantity)
|
||||
if (!Number.isInteger(n) || n < 0 || n > 10) {
|
||||
reply.code(400).send({ error: 'Количество — целое число от 0 до 10' })
|
||||
return
|
||||
}
|
||||
data.quantity = n
|
||||
}
|
||||
if (body.materials !== undefined) {
|
||||
data.materials = JSON.stringify(request.server.parseMaterialsInput(body.materials))
|
||||
}
|
||||
if (body.priceCents !== undefined) {
|
||||
const p = Number(body.priceCents)
|
||||
if (!Number.isFinite(p) || p <= 0) {
|
||||
reply.code(400).send({ error: 'Цена должна быть больше 0' })
|
||||
return
|
||||
}
|
||||
if (p > 10_000_00) {
|
||||
reply.code(400).send({ error: 'Цена не может превышать 10 000 ₽' })
|
||||
return
|
||||
}
|
||||
data.priceCents = Math.round(p)
|
||||
}
|
||||
if (body.imageUrl !== undefined) {
|
||||
data.imageUrl = body.imageUrl ? String(body.imageUrl) : null
|
||||
}
|
||||
if (body.published !== undefined) data.published = Boolean(body.published)
|
||||
if (body.categoryId !== undefined) {
|
||||
const cid = String(body.categoryId).trim()
|
||||
if (!cid) {
|
||||
reply.code(400).send({ error: 'Укажите категорию' })
|
||||
return
|
||||
}
|
||||
const cat = await prisma.category.findUnique({ where: { id: cid } })
|
||||
if (!cat) {
|
||||
reply.code(400).send({ error: 'Категория не найдена' })
|
||||
return
|
||||
}
|
||||
data.categoryId = cid
|
||||
}
|
||||
|
||||
if (body.imageUrls !== undefined && Array.isArray(body.imageUrls)) {
|
||||
const urls = body.imageUrls.map((u) => String(u || '').trim()).filter(Boolean)
|
||||
if (urls.length > 0) {
|
||||
try {
|
||||
await validateGalleryImages(prisma, urls)
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode || 400).send({ error: err.message })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const imagesUpdate =
|
||||
body.imageUrls !== undefined
|
||||
? {
|
||||
deleteMany: {},
|
||||
create: Array.isArray(body.imageUrls)
|
||||
? body.imageUrls
|
||||
.map((u) => String(u || '').trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 10)
|
||||
.map((u, idx) => ({ url: u, sort: idx }))
|
||||
: [],
|
||||
}
|
||||
: undefined
|
||||
|
||||
const product = await prisma.product.update({
|
||||
where: { id },
|
||||
data: { ...data, images: imagesUpdate },
|
||||
include: { category: true, images: { orderBy: { sort: 'asc' } } },
|
||||
})
|
||||
return request.server.mapProductForApi(product)
|
||||
},
|
||||
)
|
||||
|
||||
fastify.delete('/api/admin/products/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||
const { id } = request.params
|
||||
try {
|
||||
await prisma.product.delete({ where: { id } })
|
||||
reply.code(204).send()
|
||||
} catch (err) {
|
||||
request.log.error({ err }, '[admin-products] Operation failed')
|
||||
reply.code(404).send({ error: 'Товар не найден' })
|
||||
}
|
||||
})
|
||||
}
|
||||
Executable
+69
@@ -0,0 +1,69 @@
|
||||
import { normalizeEmail } from '../../lib/auth.js'
|
||||
import { prisma } from '../../lib/prisma.js'
|
||||
|
||||
export async function registerAdminProfileRoutes(fastify) {
|
||||
fastify.get('/api/admin/profile', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const user = await prisma.user.findUnique({ where: { id: userId } })
|
||||
if (!user) return reply.code(404).send({ error: 'Пользователь не найден' })
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.displayName,
|
||||
avatar: user.avatar,
|
||||
avatarStyle: user.avatarStyle,
|
||||
}
|
||||
})
|
||||
|
||||
fastify.get('/api/admin/avatar', async (request, reply) => {
|
||||
const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL)
|
||||
if (!adminEmail || !adminEmail.includes('@')) return reply.code(404).send({ error: 'Администратор не найден' })
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email: adminEmail } })
|
||||
if (!user) return reply.code(404).send({ error: 'Администратор не найден' })
|
||||
|
||||
return {
|
||||
avatar: user.avatar,
|
||||
avatarStyle: user.avatarStyle,
|
||||
}
|
||||
})
|
||||
|
||||
fastify.patch('/api/admin/profile', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const nameRaw = request.body?.displayName
|
||||
const displayName =
|
||||
nameRaw === undefined ? undefined : nameRaw === null ? null : nameRaw === '' ? null : String(nameRaw).trim()
|
||||
const avatarRaw = request.body?.avatar
|
||||
const avatar = avatarRaw === null || avatarRaw === undefined ? undefined : String(avatarRaw).trim()
|
||||
const avatarStyleRaw = request.body?.avatarStyle
|
||||
const avatarStyle =
|
||||
avatarStyleRaw === null || avatarStyleRaw === undefined ? undefined : String(avatarStyleRaw).trim()
|
||||
|
||||
if (displayName !== undefined && displayName !== null && displayName.length > 40)
|
||||
return reply.code(400).send({ error: 'Имя/ник максимум 40 символов' })
|
||||
if (avatar !== undefined && avatar.length > 200000) return reply.code(400).send({ error: 'Аватар слишком большой' })
|
||||
if (avatarStyle !== undefined && avatarStyle !== '' && avatarStyle.length > 30) {
|
||||
return reply.code(400).send({ error: 'Стиль аватара слишком длинный' })
|
||||
}
|
||||
|
||||
const data = {}
|
||||
if (displayName !== undefined) {
|
||||
data.displayName = displayName && displayName.length ? displayName : null
|
||||
}
|
||||
if (avatar !== undefined) {
|
||||
data.avatar = avatar === '' ? null : avatar
|
||||
}
|
||||
if (avatarStyle !== undefined) {
|
||||
data.avatarStyle = avatarStyle === '' ? null : avatarStyle
|
||||
}
|
||||
|
||||
const updated = await prisma.user.update({ where: { id: userId }, data })
|
||||
return {
|
||||
id: updated.id,
|
||||
email: updated.email,
|
||||
displayName: updated.displayName,
|
||||
avatar: updated.avatar,
|
||||
avatarStyle: updated.avatarStyle,
|
||||
}
|
||||
})
|
||||
}
|
||||
Executable
+65
@@ -0,0 +1,65 @@
|
||||
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'
|
||||
|
||||
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 where = status ? { status } : {}
|
||||
const total = await prisma.review.count({ where })
|
||||
const items = await prisma.review.findMany({
|
||||
where,
|
||||
include: {
|
||||
user: { select: { id: true, email: true, displayName: true } },
|
||||
product: { select: { id: true, title: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: 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' })
|
||||
}
|
||||
|
||||
const existing = await prisma.review.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
product: { select: { title: true } },
|
||||
user: { select: { displayName: 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?.displayName || existing.user?.email || '',
|
||||
reviewId: updated.id,
|
||||
})
|
||||
|
||||
return { item: updated }
|
||||
})
|
||||
}
|
||||
Executable
+168
@@ -0,0 +1,168 @@
|
||||
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() : ''
|
||||
|
||||
const pageRaw = request.query?.page
|
||||
const pageParsed = typeof pageRaw === 'string' ? Number(pageRaw) : Number(pageRaw)
|
||||
const page = Number.isFinite(pageParsed) && pageParsed > 0 ? Math.floor(pageParsed) : 1
|
||||
|
||||
const pageSizeRaw = request.query?.pageSize
|
||||
const pageSizeParsed = typeof pageSizeRaw === 'string' ? Number(pageSizeRaw) : Number(pageSizeRaw)
|
||||
const pageSize = Number.isFinite(pageSizeParsed) && pageSizeParsed > 0 ? Math.floor(pageSizeParsed) : 20
|
||||
|
||||
if (pageSize > 100) {
|
||||
reply.code(400).send({ error: 'pageSize должен быть ≤ 100' })
|
||||
return
|
||||
}
|
||||
|
||||
const where = q
|
||||
? {
|
||||
OR: [{ email: { contains: q } }, { displayName: { contains: q } }],
|
||||
}
|
||||
: undefined
|
||||
|
||||
const total = await prisma.user.count({ where })
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
displayName: true,
|
||||
avatar: true,
|
||||
avatarStyle: 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,
|
||||
displayName: u.displayName,
|
||||
avatar: u.avatar,
|
||||
avatarStyle: u.avatarStyle,
|
||||
createdAt: u.createdAt,
|
||||
updatedAt: u.updatedAt,
|
||||
}))
|
||||
|
||||
return { items, total, page, pageSize }
|
||||
})
|
||||
|
||||
fastify.post('/api/admin/users', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||
const body = request.body ?? {}
|
||||
|
||||
const email = normalizeEmail(body.email)
|
||||
if (!email || !email.includes('@')) {
|
||||
reply.code(400).send({ error: 'Некорректная почта' })
|
||||
return
|
||||
}
|
||||
|
||||
const nameRaw = body.displayName
|
||||
const displayName = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim()
|
||||
if (displayName !== null && displayName.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,
|
||||
displayName: displayName && displayName.length ? displayName : null,
|
||||
},
|
||||
})
|
||||
|
||||
reply.code(201).send({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.displayName,
|
||||
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 adminUserId = request.user.sub
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { id } })
|
||||
if (!existing) {
|
||||
reply.code(404).send({ error: 'Пользователь не найден' })
|
||||
return
|
||||
}
|
||||
|
||||
const isSelf = id === adminUserId
|
||||
|
||||
const data = {}
|
||||
|
||||
if (body.email !== undefined) {
|
||||
if (isSelf) {
|
||||
reply.code(403).send({ error: 'Нельзя изменить свою почту через панель администратора' })
|
||||
return
|
||||
}
|
||||
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.displayName !== undefined) {
|
||||
const nameRaw = body.displayName
|
||||
const name = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim()
|
||||
if (name !== null && name.length > 40) {
|
||||
reply.code(400).send({ error: 'Имя/ник максимум 40 символов' })
|
||||
return
|
||||
}
|
||||
data.displayName = name && name.length ? name : null
|
||||
}
|
||||
|
||||
const user = await prisma.user.update({ where: { id }, data })
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.displayName,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
}
|
||||
})
|
||||
|
||||
fastify.delete('/api/admin/users/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||
const { id } = request.params
|
||||
const adminUserId = request.user.sub
|
||||
|
||||
if (id === adminUserId) {
|
||||
reply.code(403).send({ error: 'Нельзя удалить свою учётную запись' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.user.delete({ where: { id } })
|
||||
reply.code(204).send()
|
||||
} catch (err) {
|
||||
request.log.error({ err }, '[admin-users] Operation failed')
|
||||
reply.code(404).send({ error: 'Пользователь не найден' })
|
||||
}
|
||||
})
|
||||
}
|
||||
Executable
+78
@@ -0,0 +1,78 @@
|
||||
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.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)
|
||||
|
||||
if (!settings) {
|
||||
settings = await prisma.adminNotificationSettings.create({ data })
|
||||
} else {
|
||||
settings = await prisma.adminNotificationSettings.update({
|
||||
where: { id: settings.id },
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
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 }
|
||||
|
||||
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: 'Вы подписаны на уведомления Любимый Креатив.',
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import { prisma } from '../../../lib/prisma.js'
|
||||
|
||||
export async function registerAdminTestChecklistRoutes(fastify) {
|
||||
fastify.get('/api/admin/test-checklist', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||
const results = await prisma.checklistResult.findMany()
|
||||
const resultMap = {}
|
||||
for (const r of results) {
|
||||
resultMap[r.itemKey] = { passed: r.passed, comment: r.comment, checkedAt: r.checkedAt.toISOString() }
|
||||
}
|
||||
return { results: resultMap }
|
||||
})
|
||||
|
||||
fastify.patch('/api/admin/test-checklist', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||
const { itemKey, passed, comment } = request.body || {}
|
||||
if (!itemKey || typeof passed !== 'boolean') {
|
||||
return reply.code(400).send({ error: 'itemKey и passed (boolean) обязательны' })
|
||||
}
|
||||
if (comment !== undefined && comment !== null && typeof comment !== 'string') {
|
||||
return reply.code(400).send({ error: 'comment должен быть строкой' })
|
||||
}
|
||||
if (comment !== undefined && comment !== null && comment.length > 2000) {
|
||||
return reply.code(400).send({ error: 'Комментарий слишком длинный (макс. 2000 символов)' })
|
||||
}
|
||||
|
||||
const result = await prisma.checklistResult.upsert({
|
||||
where: { itemKey },
|
||||
create: { itemKey, passed, comment: passed ? null : comment || null },
|
||||
update: { passed, comment: passed ? null : (comment ?? undefined), checkedAt: new Date() },
|
||||
})
|
||||
|
||||
return {
|
||||
result: {
|
||||
itemKey: result.itemKey,
|
||||
passed: result.passed,
|
||||
comment: result.comment,
|
||||
checkedAt: result.checkedAt.toISOString(),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
fastify.post('/api/admin/test-checklist/reset', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||
await prisma.checklistResult.deleteMany({})
|
||||
return { ok: true }
|
||||
})
|
||||
}
|
||||
Executable
+108
@@ -0,0 +1,108 @@
|
||||
import { asyncHandler } from '../../lib/async-handler.js'
|
||||
import { prisma } from '../../lib/prisma.js'
|
||||
|
||||
const MAX_SLIDES = 20
|
||||
|
||||
export async function registerCatalogSliderRoutes(fastify) {
|
||||
fastify.get(
|
||||
'/api/catalog-slider',
|
||||
asyncHandler(async (request, reply) => {
|
||||
const slides = await prisma.catalogSliderSlide.findMany({
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
include: { galleryImage: true },
|
||||
})
|
||||
return {
|
||||
slides: slides.map((s) => ({
|
||||
id: s.id,
|
||||
url: s.galleryImage.url,
|
||||
caption: s.caption,
|
||||
textColor: s.textColor,
|
||||
})),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
fastify.get(
|
||||
'/api/admin/catalog-slider',
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
asyncHandler(async (request, reply) => {
|
||||
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,
|
||||
textColor: s.textColor,
|
||||
})),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
fastify.put(
|
||||
'/api/admin/catalog-slider',
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
asyncHandler(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)
|
||||
const textColor = String(row?.textColor || '#ffffff').trim()
|
||||
normalized.push({ galleryImageId, caption, textColor, 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,
|
||||
textColor: n.textColor,
|
||||
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,
|
||||
textColor: s.textColor,
|
||||
})),
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
Executable
+162
@@ -0,0 +1,162 @@
|
||||
import { prisma } from '../../lib/prisma.js'
|
||||
|
||||
const PUBLIC_PRODUCTS_QUERY_SCHEMA = {
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
categorySlug: { type: 'string' },
|
||||
q: { type: 'string' },
|
||||
sort: { type: 'string', enum: ['', 'price_asc', 'price_desc'] },
|
||||
page: { type: 'integer', minimum: 1 },
|
||||
pageSize: { type: 'integer', minimum: 1, maximum: 100 },
|
||||
priceMin: { type: 'number', minimum: 0 },
|
||||
priceMax: { type: 'number', minimum: 0 },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const EMPTY_REVIEWS_SUMMARY = Object.freeze({
|
||||
approvedReviewCount: 0,
|
||||
avgRating: null,
|
||||
latestApprovedText: null,
|
||||
})
|
||||
|
||||
/** Сводка по одобренным отзывам для списка id товаров (для каталога и карточки товара). */
|
||||
export async function approvedReviewSummariesForProducts(productIds) {
|
||||
const map = new Map()
|
||||
if (!productIds.length) return map
|
||||
|
||||
const uniqueIds = [...new Set(productIds)]
|
||||
for (const id of uniqueIds) {
|
||||
map.set(id, { ...EMPTY_REVIEWS_SUMMARY })
|
||||
}
|
||||
|
||||
const grouped = await prisma.review.groupBy({
|
||||
by: ['productId'],
|
||||
where: { productId: { in: uniqueIds }, status: 'approved' },
|
||||
_count: { _all: true },
|
||||
_avg: { rating: true },
|
||||
})
|
||||
|
||||
for (const g of grouped) {
|
||||
const avg = g._avg.rating
|
||||
const prev = map.get(g.productId)
|
||||
if (!prev) continue
|
||||
map.set(g.productId, {
|
||||
...prev,
|
||||
approvedReviewCount: g._count._all,
|
||||
avgRating: avg != null ? Number(avg) : null,
|
||||
})
|
||||
}
|
||||
|
||||
const withReviews = [...map.entries()].filter(([, v]) => v.approvedReviewCount > 0).map(([k]) => k)
|
||||
if (!withReviews.length) return map
|
||||
|
||||
const previewRows = await prisma.review.findMany({
|
||||
where: { productId: { in: withReviews }, status: 'approved' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { productId: true, text: true },
|
||||
take: 450,
|
||||
})
|
||||
const hasPreviewFor = new Set()
|
||||
for (const r of previewRows) {
|
||||
if (hasPreviewFor.has(r.productId)) continue
|
||||
const t = typeof r.text === 'string' ? r.text.trim() : ''
|
||||
if (!t) continue
|
||||
hasPreviewFor.add(r.productId)
|
||||
const prev = map.get(r.productId)
|
||||
if (!prev) continue
|
||||
prev.latestApprovedText = t.length > 160 ? `${t.slice(0, 160)}…` : t
|
||||
if (hasPreviewFor.size === withReviews.length) break
|
||||
}
|
||||
|
||||
return map
|
||||
}
|
||||
|
||||
export async function registerPublicCatalogRoutes(fastify) {
|
||||
fastify.get('/api/categories', async () => {
|
||||
return prisma.category.findMany({ orderBy: { sort: 'asc' } })
|
||||
})
|
||||
|
||||
fastify.get('/api/products', { schema: PUBLIC_PRODUCTS_QUERY_SCHEMA }, async (request, reply) => {
|
||||
const { categorySlug } = request.query
|
||||
const qRaw = request.query?.q
|
||||
const q = typeof qRaw === 'string' ? qRaw.trim() : ''
|
||||
|
||||
const sortRaw = request.query?.sort
|
||||
const sort = typeof sortRaw === 'string' ? sortRaw : ''
|
||||
|
||||
const pageRaw = request.query?.page
|
||||
const pageParsed = typeof pageRaw === 'string' ? Number(pageRaw) : Number(pageRaw)
|
||||
const page = Number.isFinite(pageParsed) && pageParsed > 0 ? Math.floor(pageParsed) : 1
|
||||
|
||||
const pageSizeRaw = request.query?.pageSize
|
||||
const pageSizeParsed = typeof pageSizeRaw === 'string' ? Number(pageSizeRaw) : Number(pageSizeRaw)
|
||||
const pageSize = Number.isFinite(pageSizeParsed) && pageSizeParsed > 0 ? Math.floor(pageSizeParsed) : 12
|
||||
|
||||
const priceMinRaw = request.query?.priceMin
|
||||
const priceMinParsed = typeof priceMinRaw === 'string' ? Number(priceMinRaw) : Number(priceMinRaw)
|
||||
const priceMin = Number.isFinite(priceMinParsed) && priceMinParsed >= 0 ? Math.floor(priceMinParsed) : null
|
||||
|
||||
const priceMaxRaw = request.query?.priceMax
|
||||
const priceMaxParsed = typeof priceMaxRaw === 'string' ? Number(priceMaxRaw) : Number(priceMaxRaw)
|
||||
const priceMax = Number.isFinite(priceMaxParsed) && priceMaxParsed >= 0 ? Math.floor(priceMaxParsed) : null
|
||||
|
||||
const where = { published: true }
|
||||
if (typeof categorySlug === 'string' && categorySlug.length > 0) {
|
||||
where.category = { slug: categorySlug }
|
||||
}
|
||||
if (q) {
|
||||
where.OR = [{ title: { contains: q } }, { shortDescription: { contains: q } }]
|
||||
}
|
||||
const applyPriceFilter = !(priceMin !== null && priceMax !== null && priceMin === 0 && priceMax === 0)
|
||||
|
||||
if (applyPriceFilter && (priceMin !== null || priceMax !== null)) {
|
||||
if (priceMin !== null && priceMax !== null && priceMax < priceMin) {
|
||||
return reply.code(400).send({ error: 'priceMax должен быть ≥ priceMin' })
|
||||
}
|
||||
where.priceCents = {
|
||||
...(priceMin !== null ? { gte: priceMin } : {}),
|
||||
...(priceMax !== null ? { lte: priceMax } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
const orderBy =
|
||||
sort === 'price_asc'
|
||||
? { priceCents: 'asc' }
|
||||
: sort === 'price_desc'
|
||||
? { priceCents: 'desc' }
|
||||
: { createdAt: 'desc' }
|
||||
|
||||
const total = await prisma.product.count({ where })
|
||||
const items = await prisma.product.findMany({
|
||||
where,
|
||||
include: { category: true, images: { orderBy: { sort: 'asc' } } },
|
||||
orderBy,
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
})
|
||||
|
||||
const summaries = await approvedReviewSummariesForProducts(items.map((it) => it.id))
|
||||
return {
|
||||
items: items.map((p) => request.server.mapProductForApi(p, summaries.get(p.id) ?? EMPTY_REVIEWS_SUMMARY)),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
}
|
||||
})
|
||||
|
||||
fastify.get('/api/products/:id', async (request, reply) => {
|
||||
const { id } = request.params
|
||||
const product = await prisma.product.findFirst({
|
||||
where: { id, published: true },
|
||||
include: { category: true, images: { orderBy: { sort: 'asc' } } },
|
||||
})
|
||||
if (!product) {
|
||||
reply.code(404).send({ error: 'Товар не найден' })
|
||||
return
|
||||
}
|
||||
const summaries = await approvedReviewSummariesForProducts([product.id])
|
||||
return request.server.mapProductForApi(product, summaries.get(product.id) ?? EMPTY_REVIEWS_SUMMARY)
|
||||
})
|
||||
}
|
||||
Executable
+152
@@ -0,0 +1,152 @@
|
||||
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'
|
||||
|
||||
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.get('/api/reviews/latest', async (request, reply) => {
|
||||
const limitRaw = request.query?.limit
|
||||
const limitParsed = typeof limitRaw === 'string' ? Number(limitRaw) : Number(limitRaw)
|
||||
const parsed = Number.isFinite(limitParsed) && limitParsed > 0 ? Math.floor(limitParsed) : 5
|
||||
const take = Math.min(parsed, 5)
|
||||
|
||||
const rows = await prisma.review.findMany({
|
||||
where: { status: 'approved' },
|
||||
include: {
|
||||
user: { select: { id: true, email: true, displayName: true, avatar: true, avatarStyle: true } },
|
||||
product: { select: { id: true, title: true, published: true, slug: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take,
|
||||
})
|
||||
|
||||
const items = rows.map((r) => ({
|
||||
id: r.id,
|
||||
rating: r.rating,
|
||||
text: r.text,
|
||||
imageUrl: r.imageUrl,
|
||||
createdAt: r.createdAt,
|
||||
authorId: r.user?.id ?? r.userId,
|
||||
authorDisplay: publicReviewAuthorDisplay(r.user),
|
||||
authorAvatar: r.user?.avatar ?? null,
|
||||
authorAvatarStyle: r.user?.avatarStyle ?? null,
|
||||
product: {
|
||||
id: r.product?.id ?? r.productId,
|
||||
title: r.product?.title ?? '',
|
||||
published: r.product?.published ?? false,
|
||||
slug: r.product?.slug ?? '',
|
||||
},
|
||||
}))
|
||||
|
||||
return { items }
|
||||
})
|
||||
|
||||
fastify.get('/api/products/:id/reviews', async (request, reply) => {
|
||||
const { id } = request.params
|
||||
|
||||
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) : 10
|
||||
if (pageSize > 50) return reply.code(400).send({ error: 'pageSize должен быть ≤ 50' })
|
||||
|
||||
const product = await prisma.product.findFirst({ where: { id, published: true } })
|
||||
if (!product) return reply.code(404).send({ error: 'Товар не найден' })
|
||||
|
||||
const where = { productId: id, status: 'approved' }
|
||||
const total = await prisma.review.count({ where })
|
||||
const rawItems = await prisma.review.findMany({
|
||||
where,
|
||||
include: {
|
||||
user: { select: { id: true, email: true, displayName: true, avatar: true, avatarStyle: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
})
|
||||
|
||||
const items = rawItems.map((r) => ({
|
||||
id: r.id,
|
||||
rating: r.rating,
|
||||
text: r.text,
|
||||
imageUrl: r.imageUrl,
|
||||
createdAt: r.createdAt,
|
||||
authorId: r.user?.id ?? r.userId,
|
||||
authorDisplay: publicReviewAuthorDisplay(r.user),
|
||||
authorAvatar: r.user?.avatar ?? null,
|
||||
authorAvatarStyle: r.user?.avatarStyle ?? null,
|
||||
}))
|
||||
|
||||
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
|
||||
|
||||
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: 'Некорректная ссылка на изображение' })
|
||||
}
|
||||
|
||||
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 (err) {
|
||||
request.log.error({ err }, 'Failed to create review (possible duplicate)')
|
||||
return reply.code(409).send({ error: 'Вы уже оставляли отзыв на этот товар' })
|
||||
}
|
||||
})
|
||||
}
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
import { isAdminEmail } from '../lib/auth.js'
|
||||
import { prisma } from '../lib/prisma.js'
|
||||
|
||||
export async function registerAuthOAuthRoutes(fastify) {
|
||||
fastify.delete('/api/me/oauth/:provider', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const provider = request.params?.provider
|
||||
|
||||
if (isAdminEmail(request.user.email)) {
|
||||
return reply.code(403).send({ error: 'Администратор не может отвязывать OAuth' })
|
||||
}
|
||||
if (provider !== 'vk' && provider !== 'yandex') {
|
||||
return reply.code(400).send({ error: 'Неизвестный провайдер' })
|
||||
}
|
||||
|
||||
const oauth = await prisma.oAuthAccount.findFirst({
|
||||
where: { userId, provider },
|
||||
})
|
||||
if (!oauth) return reply.code(404).send({ error: 'Аккаунт не привязан' })
|
||||
|
||||
const remainingOAuth = await prisma.oAuthAccount.count({
|
||||
where: { userId, provider: { not: provider } },
|
||||
})
|
||||
const currentUser = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { passwordHash: true },
|
||||
})
|
||||
if (!currentUser?.passwordHash && remainingOAuth === 0) {
|
||||
return reply.code(400).send({ error: 'Нельзя удалить последний метод входа' })
|
||||
}
|
||||
|
||||
await prisma.oAuthAccount.delete({ where: { id: oauth.id } })
|
||||
return { ok: true }
|
||||
})
|
||||
}
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
import { comparePassword, hashPassword, isAdminEmail, validatePassword } from '../lib/auth.js'
|
||||
import { prisma } from '../lib/prisma.js'
|
||||
|
||||
export async function registerAuthPasswordRoutes(fastify) {
|
||||
fastify.post('/api/me/password', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
if (isAdminEmail(request.user.email)) {
|
||||
return reply.code(403).send({ error: 'Администратор не может устанавливать пароль' })
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: userId } })
|
||||
if (!user) return reply.code(404).send({ error: 'Пользователь не найден' })
|
||||
if (user.passwordHash) return reply.code(409).send({ error: 'Пароль уже установлен' })
|
||||
|
||||
const password = String(request.body?.password || '')
|
||||
const passwordErr = validatePassword(password)
|
||||
if (passwordErr) return reply.code(400).send({ error: passwordErr })
|
||||
|
||||
const passwordHash = await hashPassword(password)
|
||||
await prisma.user.update({ where: { id: userId }, data: { passwordHash } })
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
fastify.post('/api/me/change-password', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
if (isAdminEmail(request.user.email)) {
|
||||
return reply.code(403).send({ error: 'Администратор не может менять пароль' })
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: userId } })
|
||||
if (!user) return reply.code(404).send({ error: 'Пользователь не найден' })
|
||||
if (!user.passwordHash)
|
||||
return reply.code(400).send({ error: 'Пароль не установлен. Используйте установку пароля.' })
|
||||
|
||||
const oldPassword = String(request.body?.oldPassword || '')
|
||||
const valid = await comparePassword(oldPassword, user.passwordHash)
|
||||
if (!valid) return reply.code(401).send({ error: 'Неверный текущий пароль' })
|
||||
|
||||
const newPassword = String(request.body?.newPassword || '')
|
||||
const passwordErr = validatePassword(newPassword)
|
||||
if (passwordErr) return reply.code(400).send({ error: passwordErr })
|
||||
|
||||
const passwordHash = await hashPassword(newPassword)
|
||||
await prisma.user.update({ where: { id: userId }, data: { passwordHash } })
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
}
|
||||
Executable
+81
@@ -0,0 +1,81 @@
|
||||
import crypto from 'node:crypto'
|
||||
import { normalizeEmail } from '../lib/auth.js'
|
||||
import { prisma } from '../lib/prisma.js'
|
||||
import { mapUserForClient } from './auth.js'
|
||||
|
||||
export async function registerAuthSessionRoutes(fastify) {
|
||||
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/auth-methods', { preHandler: [fastify.authenticate] }, async (request) => {
|
||||
const userId = request.user.sub
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: { oauthAccounts: { select: { provider: true } } },
|
||||
})
|
||||
if (!user) return { methods: [] }
|
||||
|
||||
const providers = user.oauthAccounts.map((a) => a.provider)
|
||||
return {
|
||||
methods: [
|
||||
{ type: 'password', active: Boolean(user.passwordHash) },
|
||||
{ type: 'vk', active: providers.includes('vk') },
|
||||
{ type: 'yandex', active: providers.includes('yandex') },
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
fastify.patch('/api/me/email', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const rawEmail = typeof request.body?.email === 'string' ? request.body.email.trim() : ''
|
||||
|
||||
if (!rawEmail || !rawEmail.includes('@')) {
|
||||
return reply.code(400).send({ error: 'Некорректная почта' })
|
||||
}
|
||||
|
||||
const email = normalizeEmail(rawEmail)
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { email } })
|
||||
if (existing && existing.id !== userId) {
|
||||
return reply.code(409).send({ error: 'Эта почта уже используется' })
|
||||
}
|
||||
|
||||
await prisma.pendingEmail.deleteMany({ where: { userId } })
|
||||
|
||||
const token = crypto.randomUUID()
|
||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000)
|
||||
|
||||
await prisma.pendingEmail.create({
|
||||
data: { userId, email, token, expiresAt },
|
||||
})
|
||||
|
||||
return { verificationUrl: `/api/me/verify-email?token=${token}` }
|
||||
})
|
||||
|
||||
fastify.get('/api/me/verify-email', async (request, reply) => {
|
||||
const token = typeof request.query?.token === 'string' ? request.query.token : ''
|
||||
|
||||
if (!token) {
|
||||
return reply.code(400).send({ error: 'Отсутствует токен подтверждения' })
|
||||
}
|
||||
|
||||
const pending = await prisma.pendingEmail.findUnique({ where: { token } })
|
||||
if (!pending || pending.expiresAt < new Date()) {
|
||||
return reply.code(400).send({ error: 'Токен подтверждения недействителен или истёк' })
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: pending.userId },
|
||||
data: { email: pending.email },
|
||||
})
|
||||
|
||||
await prisma.pendingEmail.delete({ where: { id: pending.id } })
|
||||
|
||||
const clientUrl = (process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173').replace(/\/$/, '')
|
||||
return reply.redirect(`${clientUrl}/me?emailVerified=1`)
|
||||
})
|
||||
}
|
||||
Executable
+239
@@ -0,0 +1,239 @@
|
||||
import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js'
|
||||
import {
|
||||
comparePassword,
|
||||
hashPassword,
|
||||
isAdminEmail,
|
||||
issueEmailCode,
|
||||
normalizeEmail,
|
||||
validatePassword,
|
||||
verifyEmailCode,
|
||||
} from '../lib/auth.js'
|
||||
import { generateAvatar } from '../lib/generate-avatar.js'
|
||||
import { prisma } from '../lib/prisma.js'
|
||||
import { checkCodeRequestRateLimit, checkCodeVerifyRateLimit, checkLoginRateLimit } from '../lib/rate-limit.js'
|
||||
|
||||
export function mapUserForClient(user) {
|
||||
const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL)
|
||||
const userEmail = normalizeEmail(user.email)
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.displayName,
|
||||
avatar: user.avatar,
|
||||
avatarStyle: user.avatarStyle,
|
||||
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: 'Некорректная почта' })
|
||||
|
||||
const ip = request.ip
|
||||
const rate = checkCodeRequestRateLimit(ip)
|
||||
if (!rate.allowed) {
|
||||
return reply
|
||||
.code(429)
|
||||
.header('Retry-After', String(rate.retryAfter))
|
||||
.send({ error: `Слишком много запросов. Попробуйте через ${rate.retryAfter} сек.` })
|
||||
}
|
||||
|
||||
const code = await issueEmailCode({ email, purpose: 'login' })
|
||||
|
||||
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 }
|
||||
})
|
||||
|
||||
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 ip = request.ip
|
||||
const rate = checkCodeVerifyRateLimit(ip)
|
||||
if (!rate.allowed) {
|
||||
return reply
|
||||
.code(429)
|
||||
.header('Retry-After', String(rate.retryAfter))
|
||||
.send({ error: `Слишком много попыток. Попробуйте через ${rate.retryAfter} сек.` })
|
||||
}
|
||||
|
||||
const ok = await verifyEmailCode({ email, purpose: 'login', code })
|
||||
if (!ok) return reply.code(401).send({ error: 'Неверный или истёкший код' })
|
||||
|
||||
const avatarUri = await generateAvatar(email)
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email },
|
||||
update: {},
|
||||
create: { email, avatar: avatarUri, avatarStyle: 'avataaars' },
|
||||
})
|
||||
|
||||
// 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) }
|
||||
})
|
||||
|
||||
fastify.post('/api/auth/register', async (request, reply) => {
|
||||
const email = normalizeEmail(request.body?.email)
|
||||
const password = String(request.body?.password || '')
|
||||
const displayNameRaw = request.body?.displayName
|
||||
const displayName = displayNameRaw ? String(displayNameRaw).trim().slice(0, 100) : email.split('@')[0]
|
||||
|
||||
if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
|
||||
if (isAdminEmail(email)) return reply.code(403).send({ error: 'Администратор не может регистрироваться с паролем' })
|
||||
|
||||
const passwordErr = validatePassword(password)
|
||||
if (passwordErr) return reply.code(400).send({ error: passwordErr })
|
||||
|
||||
const exists = await prisma.user.findUnique({ where: { email } })
|
||||
if (exists) return reply.code(409).send({ error: 'Эта почта уже зарегистрирована' })
|
||||
|
||||
const passwordHash = await hashPassword(password)
|
||||
const avatarUri = await generateAvatar(email)
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
passwordHash,
|
||||
displayName: displayName || null,
|
||||
avatar: avatarUri,
|
||||
avatarStyle: 'initials',
|
||||
},
|
||||
})
|
||||
|
||||
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 reply.code(201).send({ token, user: mapUserForClient(user) })
|
||||
})
|
||||
|
||||
fastify.post('/api/auth/login', async (request, reply) => {
|
||||
const email = normalizeEmail(request.body?.email)
|
||||
const password = String(request.body?.password || '')
|
||||
const ip = request.ip
|
||||
|
||||
if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
|
||||
if (isAdminEmail(email)) return reply.code(403).send({ error: 'Администратор входит только по коду' })
|
||||
|
||||
const rate = checkLoginRateLimit(ip)
|
||||
if (!rate.allowed) {
|
||||
return reply
|
||||
.code(429)
|
||||
.header('Retry-After', String(rate.retryAfter))
|
||||
.send({ error: `Слишком много попыток. Попробуйте через ${rate.retryAfter} сек.` })
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email } })
|
||||
if (!user || !user.passwordHash) {
|
||||
return reply.code(401).send({ error: 'Неверная почта или пароль' })
|
||||
}
|
||||
|
||||
const valid = await comparePassword(password, user.passwordHash)
|
||||
if (!valid) {
|
||||
return reply.code(401).send({ error: 'Неверная почта или пароль' })
|
||||
}
|
||||
|
||||
const token = fastify.jwt.sign({ sub: user.id, email: user.email })
|
||||
return { token, user: mapUserForClient(user) }
|
||||
})
|
||||
|
||||
fastify.post('/api/auth/forgot-password', async (request) => {
|
||||
const email = normalizeEmail(request.body?.email)
|
||||
if (!email || !email.includes('@')) return { ok: true }
|
||||
|
||||
if (isAdminEmail(email)) return { ok: true }
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email } })
|
||||
if (!user || !user.passwordHash) return { ok: true }
|
||||
|
||||
await issueEmailCode({ email, purpose: 'reset_password' })
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
fastify.post('/api/auth/reset-password', async (request, reply) => {
|
||||
const email = normalizeEmail(request.body?.email)
|
||||
const code = String(request.body?.code || '').trim()
|
||||
const newPassword = String(request.body?.newPassword || '')
|
||||
|
||||
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: 'reset_password', code })
|
||||
if (!ok) return reply.code(401).send({ error: 'Неверный или истёкший код' })
|
||||
|
||||
const passwordErr = validatePassword(newPassword)
|
||||
if (passwordErr) return reply.code(400).send({ error: passwordErr })
|
||||
|
||||
const passwordHash = await hashPassword(newPassword)
|
||||
await prisma.user.update({ where: { email }, data: { passwordHash } })
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
fastify.patch('/api/me/profile', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const nameRaw = request.body?.displayName
|
||||
const displayName = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim()
|
||||
const avatarRaw = request.body?.avatar
|
||||
const avatar = avatarRaw === null || avatarRaw === undefined ? undefined : String(avatarRaw).trim()
|
||||
const avatarStyleRaw = request.body?.avatarStyle
|
||||
const avatarStyle =
|
||||
avatarStyleRaw === null || avatarStyleRaw === undefined ? undefined : String(avatarStyleRaw).trim()
|
||||
|
||||
if (displayName !== null && displayName.length > 40)
|
||||
return reply.code(400).send({ error: 'Имя/ник максимум 40 символов' })
|
||||
if (avatar !== undefined && avatar.length > 200000) return reply.code(400).send({ error: 'Аватар слишком большой' })
|
||||
if (avatarStyle !== undefined && avatarStyle !== '' && avatarStyle.length > 30) {
|
||||
return reply.code(400).send({ error: 'Стиль аватара слишком длинный' })
|
||||
}
|
||||
|
||||
const data = {
|
||||
displayName: displayName && displayName.length ? displayName : null,
|
||||
}
|
||||
|
||||
if (avatar !== undefined) {
|
||||
data.avatar = avatar === '' ? null : avatar
|
||||
}
|
||||
if (avatarStyle !== undefined) {
|
||||
data.avatarStyle = avatarStyle === '' ? null : avatarStyle
|
||||
}
|
||||
const updated = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data,
|
||||
})
|
||||
return { user: mapUserForClient(updated) }
|
||||
})
|
||||
|
||||
fastify.delete('/api/me', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
|
||||
const ACTIVE_STATUSES = ['DRAFT', 'PENDING_PAYMENT', 'PAID', 'IN_PROGRESS', 'SHIPPED', 'READY_FOR_PICKUP']
|
||||
|
||||
const activeOrders = await prisma.order.findMany({
|
||||
where: { userId, status: { in: ACTIVE_STATUSES } },
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
await prisma.user.delete({ where: { id: userId } })
|
||||
return { ok: true, activeOrderIds: activeOrders.map((o) => o.id) }
|
||||
})
|
||||
}
|
||||
Executable
+343
@@ -0,0 +1,343 @@
|
||||
import crypto from 'node:crypto'
|
||||
import { normalizeEmail } from '../lib/auth.js'
|
||||
import { generateAvatar } from '../lib/generate-avatar.js'
|
||||
import { prisma } from '../lib/prisma.js'
|
||||
|
||||
const pkceStore = new Map()
|
||||
|
||||
function storePkce(state, codeVerifier, meta = {}) {
|
||||
pkceStore.set(state, { codeVerifier, meta, createdAt: Date.now() })
|
||||
}
|
||||
|
||||
function consumePkce(state) {
|
||||
const entry = pkceStore.get(state)
|
||||
if (entry) {
|
||||
pkceStore.delete(state)
|
||||
return { codeVerifier: entry.codeVerifier, meta: entry.meta }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function generatePkcePair() {
|
||||
const verifier = crypto.randomBytes(48).toString('base64url').slice(0, 64)
|
||||
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url')
|
||||
return { codeVerifier: verifier, codeChallenge: challenge }
|
||||
}
|
||||
|
||||
function decodeIdTokenPayload(idToken) {
|
||||
const parts = idToken.split('.')
|
||||
if (parts.length !== 3) return null
|
||||
try {
|
||||
return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function clientRedirect(fastify, reply, token) {
|
||||
const base = process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173'
|
||||
const url = `${base.replace(/\/$/, '')}/auth/callback?token=${encodeURIComponent(token)}`
|
||||
return reply.redirect(url)
|
||||
}
|
||||
|
||||
function oauthErrorRedirect(reply, msg) {
|
||||
const base = process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173'
|
||||
const url = `${base.replace(/\/$/, '')}/auth?oauthError=${encodeURIComponent(msg)}`
|
||||
return reply.redirect(url)
|
||||
}
|
||||
|
||||
async function issueUserJwt(fastify, userId, email) {
|
||||
return fastify.jwt.sign({ sub: userId, email })
|
||||
}
|
||||
|
||||
async function findOrCreateUserFromOAuth({ provider, providerUserId, accessToken, suggestedEmail, linkToUserId }) {
|
||||
const existingLink = await prisma.oAuthAccount.findUnique({
|
||||
where: { provider_providerUserId: { provider, providerUserId } },
|
||||
include: { user: true },
|
||||
})
|
||||
if (existingLink?.user) {
|
||||
if (accessToken !== undefined) {
|
||||
await prisma.oAuthAccount.update({
|
||||
where: { provider_providerUserId: { provider, providerUserId } },
|
||||
data: { accessToken },
|
||||
})
|
||||
}
|
||||
return existingLink.user
|
||||
}
|
||||
|
||||
const trimmed = typeof suggestedEmail === 'string' ? suggestedEmail.trim() : ''
|
||||
const norm = trimmed ? normalizeEmail(trimmed) : null
|
||||
|
||||
if (linkToUserId) {
|
||||
await prisma.oAuthAccount.create({
|
||||
data: { provider, providerUserId: String(providerUserId), userId: linkToUserId, accessToken },
|
||||
})
|
||||
return prisma.user.findUnique({ where: { id: linkToUserId } })
|
||||
}
|
||||
|
||||
let user = norm ? await prisma.user.findUnique({ where: { email: norm } }) : null
|
||||
if (user) {
|
||||
await prisma.oAuthAccount.create({
|
||||
data: { provider, providerUserId: String(providerUserId), userId: user.id, accessToken },
|
||||
})
|
||||
return user
|
||||
}
|
||||
|
||||
const email = norm || `${provider}_${providerUserId}@vk.local`
|
||||
|
||||
user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
displayName: norm ? norm.split('@')[0] : 'Пользователь',
|
||||
avatar: await generateAvatar(email),
|
||||
avatarStyle: 'initials',
|
||||
},
|
||||
})
|
||||
await prisma.oAuthAccount.create({
|
||||
data: { provider, providerUserId: String(providerUserId), userId: user.id, accessToken },
|
||||
})
|
||||
await prisma.notificationPreference.create({
|
||||
data: { userId: user.id, globalEnabled: true },
|
||||
})
|
||||
return user
|
||||
}
|
||||
|
||||
export async function registerOAuthSocialRoutes(fastify) {
|
||||
const serverPublic = (process.env.SERVER_PUBLIC_URL || 'http://127.0.0.1:3333').replace(/\/$/, '')
|
||||
|
||||
/** --- VK --- */
|
||||
fastify.get('/api/auth/oauth/vk', async (_request, reply) => {
|
||||
const clientId = process.env.VK_CLIENT_ID
|
||||
const clientSecret = process.env.VK_CLIENT_SECRET
|
||||
if (!clientId || !clientSecret) return reply.code(503).send({ error: 'VK OAuth не настроен (нет VK_* в env)' })
|
||||
|
||||
const redirectUri = `${serverPublic}/api/auth/oauth/vk/callback`
|
||||
const { codeVerifier, codeChallenge } = generatePkcePair()
|
||||
const state = crypto.randomUUID()
|
||||
storePkce(state, codeVerifier)
|
||||
|
||||
const url = new URL('https://id.vk.ru/authorize')
|
||||
url.searchParams.set('client_id', clientId)
|
||||
url.searchParams.set('redirect_uri', redirectUri)
|
||||
url.searchParams.set('response_type', 'code')
|
||||
url.searchParams.set('scope', 'email')
|
||||
url.searchParams.set('code_challenge', codeChallenge)
|
||||
url.searchParams.set('code_challenge_method', 'S256')
|
||||
url.searchParams.set('state', state)
|
||||
|
||||
return reply.redirect(url.toString())
|
||||
})
|
||||
|
||||
fastify.get('/api/auth/oauth/vk/link', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL)
|
||||
if (request.user.email === adminEmail) {
|
||||
return reply.code(403).send({ error: 'Администратор не может привязывать OAuth' })
|
||||
}
|
||||
|
||||
const clientId = process.env.VK_CLIENT_ID
|
||||
const clientSecret = process.env.VK_CLIENT_SECRET
|
||||
if (!clientId || !clientSecret) return reply.code(503).send({ error: 'VK OAuth не настроен' })
|
||||
|
||||
const redirectUri = `${serverPublic}/api/auth/oauth/vk/callback`
|
||||
const { codeVerifier, codeChallenge } = generatePkcePair()
|
||||
const state = crypto.randomUUID()
|
||||
storePkce(state, codeVerifier, { action: 'link', userId: request.user.sub })
|
||||
|
||||
const url = new URL('https://id.vk.ru/authorize')
|
||||
url.searchParams.set('client_id', clientId)
|
||||
url.searchParams.set('redirect_uri', redirectUri)
|
||||
url.searchParams.set('response_type', 'code')
|
||||
url.searchParams.set('scope', 'email')
|
||||
url.searchParams.set('code_challenge', codeChallenge)
|
||||
url.searchParams.set('code_challenge_method', 'S256')
|
||||
url.searchParams.set('state', state)
|
||||
|
||||
return reply.redirect(url.toString())
|
||||
})
|
||||
|
||||
fastify.get('/api/auth/oauth/vk/callback', async (request, reply) => {
|
||||
const query = request.query ?? {}
|
||||
if (query.error || query.error_description) {
|
||||
return oauthErrorRedirect(reply, String(query.error_description || query.error || 'ошибка VK'))
|
||||
}
|
||||
|
||||
const state = typeof query.state === 'string' ? query.state.trim() : ''
|
||||
if (!state) return oauthErrorRedirect(reply, 'Недействительный state OAuth')
|
||||
|
||||
const pkceEntry = consumePkce(state)
|
||||
if (!pkceEntry) return oauthErrorRedirect(reply, 'Недействительный state OAuth')
|
||||
|
||||
const code = typeof query.code === 'string' ? query.code.trim() : ''
|
||||
if (!code) return oauthErrorRedirect(reply, 'Не получен код от VK')
|
||||
|
||||
const deviceId = typeof query.device_id === 'string' ? query.device_id : null
|
||||
|
||||
const clientId = process.env.VK_CLIENT_ID
|
||||
const clientSecret = process.env.VK_CLIENT_SECRET
|
||||
const redirectUri = `${serverPublic}/api/auth/oauth/vk/callback`
|
||||
|
||||
const body = new URLSearchParams()
|
||||
body.set('grant_type', 'authorization_code')
|
||||
body.set('client_id', clientId)
|
||||
body.set('client_secret', clientSecret)
|
||||
body.set('code', code)
|
||||
body.set('code_verifier', pkceEntry.codeVerifier)
|
||||
body.set('redirect_uri', redirectUri)
|
||||
if (deviceId) {
|
||||
body.set('device_id', deviceId)
|
||||
}
|
||||
|
||||
const tokenRes = await fetch('https://id.vk.ru/oauth2/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
})
|
||||
const tokenBody = await tokenRes.json()
|
||||
|
||||
if (tokenBody?.error_description || tokenBody?.error || !tokenRes.ok) {
|
||||
return oauthErrorRedirect(reply, tokenBody?.error_description || tokenBody?.error || 'Не удалось обменять код VK')
|
||||
}
|
||||
|
||||
const idToken = typeof tokenBody?.id_token === 'string' ? tokenBody.id_token : null
|
||||
const claims = idToken ? decodeIdTokenPayload(idToken) : null
|
||||
|
||||
const vkUserId = claims?.sub ?? tokenBody?.user_id
|
||||
const emailSuggestion = claims?.email ?? tokenBody?.email ?? null
|
||||
|
||||
if (!vkUserId) return oauthErrorRedirect(reply, 'no_user_id')
|
||||
|
||||
const linkToUserId = pkceEntry.meta?.action === 'link' ? pkceEntry.meta.userId : undefined
|
||||
|
||||
const user = await findOrCreateUserFromOAuth({
|
||||
provider: 'vk',
|
||||
providerUserId: String(vkUserId),
|
||||
accessToken: tokenBody?.access_token ?? null,
|
||||
suggestedEmail: emailSuggestion,
|
||||
linkToUserId,
|
||||
})
|
||||
|
||||
if (linkToUserId) {
|
||||
const base = process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173'
|
||||
return reply.redirect(`${base.replace(/\/$/, '')}/me?linked=vk`)
|
||||
}
|
||||
|
||||
const token = await issueUserJwt(fastify, user.id, user.email)
|
||||
return clientRedirect(fastify, reply, token)
|
||||
})
|
||||
|
||||
/** --- Yandex --- */
|
||||
fastify.get('/api/auth/oauth/yandex', async (_request, reply) => {
|
||||
const clientId = process.env.YANDEX_CLIENT_ID
|
||||
if (!clientId) return reply.code(503).send({ error: 'Yandex OAuth не настроен (нет YANDEX_* в env)' })
|
||||
|
||||
const redirectUri = `${serverPublic}/api/auth/oauth/yandex/callback`
|
||||
const state = fastify.jwt.sign({ oauth: 'yandex' }, { expiresIn: '15m' })
|
||||
|
||||
const url = new URL('https://oauth.yandex.ru/authorize')
|
||||
url.searchParams.set('response_type', 'code')
|
||||
url.searchParams.set('client_id', clientId)
|
||||
url.searchParams.set('redirect_uri', redirectUri)
|
||||
url.searchParams.set('scope', 'login:email')
|
||||
url.searchParams.set('state', state)
|
||||
|
||||
return reply.redirect(url.toString())
|
||||
})
|
||||
|
||||
fastify.get('/api/auth/oauth/yandex/link', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL)
|
||||
if (request.user.email === adminEmail) {
|
||||
return reply.code(403).send({ error: 'Администратор не может привязывать OAuth' })
|
||||
}
|
||||
|
||||
const clientId = process.env.YANDEX_CLIENT_ID
|
||||
if (!clientId) return reply.code(503).send({ error: 'Yandex OAuth не настроен' })
|
||||
|
||||
const redirectUri = `${serverPublic}/api/auth/oauth/yandex/callback`
|
||||
const state = fastify.jwt.sign({ oauth: 'yandex', action: 'link', userId: request.user.sub }, { expiresIn: '15m' })
|
||||
|
||||
const url = new URL('https://oauth.yandex.ru/authorize')
|
||||
url.searchParams.set('response_type', 'code')
|
||||
url.searchParams.set('client_id', clientId)
|
||||
url.searchParams.set('redirect_uri', redirectUri)
|
||||
url.searchParams.set('scope', 'login:email')
|
||||
url.searchParams.set('state', state)
|
||||
|
||||
return reply.redirect(url.toString())
|
||||
})
|
||||
|
||||
fastify.get('/api/auth/oauth/yandex/callback', async (request, reply) => {
|
||||
const query = request.query ?? {}
|
||||
if (query.error) return oauthErrorRedirect(reply, String(query.error))
|
||||
|
||||
const statePayload = (() => {
|
||||
try {
|
||||
const raw = typeof query.state === 'string' ? query.state : ''
|
||||
return fastify.jwt.verify(raw || '')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})()
|
||||
if (!statePayload) return oauthErrorRedirect(reply, 'Недействительный state OAuth')
|
||||
|
||||
const code = typeof query.code === 'string' ? query.code.trim() : ''
|
||||
if (!code) return oauthErrorRedirect(reply, 'Не получен код от Яндекс')
|
||||
|
||||
const clientId = process.env.YANDEX_CLIENT_ID
|
||||
const clientSecret = process.env.YANDEX_CLIENT_SECRET
|
||||
const redirectUri = `${serverPublic}/api/auth/oauth/yandex/callback`
|
||||
|
||||
const body = new URLSearchParams()
|
||||
body.set('grant_type', 'authorization_code')
|
||||
body.set('code', code)
|
||||
body.set('client_id', clientId)
|
||||
body.set('client_secret', clientSecret)
|
||||
if (redirectUri) body.set('redirect_uri', redirectUri)
|
||||
|
||||
const tokenRes = await fetch('https://oauth.yandex.ru/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
})
|
||||
const tokenBody = await tokenRes.json()
|
||||
|
||||
if (!tokenRes.ok || !tokenBody.access_token) {
|
||||
return oauthErrorRedirect(
|
||||
reply,
|
||||
tokenBody.error_description || tokenBody.error || 'Не удалось обменять код Yandex',
|
||||
)
|
||||
}
|
||||
|
||||
const yaToken = tokenBody.access_token
|
||||
|
||||
const infoRes = await fetch('https://login.yandex.ru/info', {
|
||||
headers: { Authorization: `OAuth ${yaToken}` },
|
||||
})
|
||||
const info = await infoRes.json()
|
||||
const yaUserId = String(info?.id || '')
|
||||
if (!yaUserId) return oauthErrorRedirect(reply, 'Не удалось получить профиль Yandex')
|
||||
|
||||
const emailGuess = (Array.isArray(info?.emails) && info.emails[0]) || info?.default_email || null
|
||||
|
||||
if (!emailGuess) return oauthErrorRedirect(reply, 'no_email')
|
||||
|
||||
const linkToUserId = statePayload?.action === 'link' ? statePayload.userId : undefined
|
||||
|
||||
const user = await findOrCreateUserFromOAuth({
|
||||
provider: 'yandex',
|
||||
providerUserId: yaUserId,
|
||||
accessToken: yaToken,
|
||||
suggestedEmail: emailGuess,
|
||||
linkToUserId,
|
||||
})
|
||||
|
||||
if (!user) return oauthErrorRedirect(reply, 'Не удалось получить email от Яндекс')
|
||||
|
||||
if (linkToUserId) {
|
||||
const base = process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173'
|
||||
return reply.redirect(`${base.replace(/\/$/, '')}/me?linked=yandex`)
|
||||
}
|
||||
|
||||
const token = await issueUserJwt(fastify, user.id, user.email)
|
||||
return clientRedirect(fastify, reply, token)
|
||||
})
|
||||
}
|
||||
Executable
+149
@@ -0,0 +1,149 @@
|
||||
import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js'
|
||||
|
||||
const {
|
||||
ORDER_CREATED,
|
||||
ORDER_STATUS_CHANGED,
|
||||
ORDER_MESSAGE_SENT,
|
||||
ORDER_MESSAGE_ADMIN_REPLY,
|
||||
PAYMENT_STATUS_CHANGED,
|
||||
DELIVERY_FEE_ADJUSTED,
|
||||
} = NOTIFICATION_EVENTS
|
||||
|
||||
export function isAdminUser(user) {
|
||||
const adminEmail = String(process.env.ADMIN_EMAIL || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
const userEmail = String(user?.email || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
return !!(adminEmail && userEmail === adminEmail)
|
||||
}
|
||||
|
||||
export function formatSSE(event, data) {
|
||||
const lines = [`event: ${event}`]
|
||||
if (data !== undefined) {
|
||||
lines.push(`data: ${JSON.stringify(data)}`)
|
||||
}
|
||||
return lines.join('\n') + '\n\n'
|
||||
}
|
||||
|
||||
export function formatHeartbit() {
|
||||
return ':heartbit\n\n'
|
||||
}
|
||||
|
||||
export function buildSseListeners(userId, admin, eventBus, write) {
|
||||
const listeners = []
|
||||
|
||||
function on(eventName, filterFn, sseEvent, dataFn) {
|
||||
function handler(payload) {
|
||||
if (!filterFn(payload)) return
|
||||
write(formatSSE(sseEvent, dataFn(payload)))
|
||||
}
|
||||
listeners.push({ eventName, handler })
|
||||
eventBus.on(eventName, handler)
|
||||
}
|
||||
|
||||
on(
|
||||
ORDER_MESSAGE_ADMIN_REPLY,
|
||||
(p) => p.userId === userId,
|
||||
'message:new',
|
||||
(p) => ({ orderId: p.orderId, messageId: p.messageId, preview: p.preview }),
|
||||
)
|
||||
|
||||
on(
|
||||
ORDER_MESSAGE_SENT,
|
||||
() => admin,
|
||||
'message:new',
|
||||
(p) => ({ orderId: p.orderId, messageId: p.messageId, preview: p.preview }),
|
||||
)
|
||||
|
||||
on(
|
||||
ORDER_STATUS_CHANGED,
|
||||
(p) => admin || p.userId === userId,
|
||||
'order:statusChanged',
|
||||
(p) => ({ orderId: p.orderId, newStatus: p.newStatus }),
|
||||
)
|
||||
|
||||
on(
|
||||
PAYMENT_STATUS_CHANGED,
|
||||
(p) => admin || p.userId === userId,
|
||||
'order:statusChanged',
|
||||
(p) => ({ orderId: p.orderId }),
|
||||
)
|
||||
|
||||
on(
|
||||
DELIVERY_FEE_ADJUSTED,
|
||||
(p) => admin || p.userId === userId,
|
||||
'order:updated',
|
||||
(p) => ({ orderId: p.orderId }),
|
||||
)
|
||||
|
||||
on(
|
||||
ORDER_CREATED,
|
||||
() => admin,
|
||||
'order:new',
|
||||
(p) => ({ orderId: p.orderId }),
|
||||
)
|
||||
|
||||
on(
|
||||
'order:created:admin',
|
||||
() => admin,
|
||||
'order:new',
|
||||
(p) => ({ orderId: p.orderId }),
|
||||
)
|
||||
|
||||
return function cleanup() {
|
||||
for (const { eventName, handler } of listeners) {
|
||||
eventBus.off(eventName, handler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function registerSseRoutes(fastify) {
|
||||
fastify.get('/api/sse/stream', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
reply.hijack()
|
||||
|
||||
reply.raw.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
})
|
||||
|
||||
let closed = false
|
||||
let heartbitTimer
|
||||
let removeListeners
|
||||
|
||||
function cleanUp() {
|
||||
if (closed) return
|
||||
closed = true
|
||||
clearInterval(heartbitTimer)
|
||||
removeListeners()
|
||||
}
|
||||
|
||||
function safeWrite(chunk) {
|
||||
if (closed) return
|
||||
try {
|
||||
reply.raw.write(chunk)
|
||||
} catch (err) {
|
||||
request.log.error({ err }, '[sse] safeWrite failed')
|
||||
closed = true
|
||||
cleanUp()
|
||||
}
|
||||
}
|
||||
|
||||
const userId = request.user.sub
|
||||
const admin = isAdminUser(request.user)
|
||||
|
||||
safeWrite(formatHeartbit())
|
||||
|
||||
heartbitTimer = setInterval(() => {
|
||||
safeWrite(formatHeartbit())
|
||||
}, 30_000)
|
||||
|
||||
removeListeners = buildSseListeners(userId, admin, fastify.eventBus, safeWrite)
|
||||
|
||||
request.raw.on('close', cleanUp)
|
||||
request.raw.on('error', cleanUp)
|
||||
})
|
||||
}
|
||||
Executable
+81
@@ -0,0 +1,81 @@
|
||||
// server/src/routes/uploads-resized.js
|
||||
import fs from 'node:fs'
|
||||
import { findOriginalFile, getOrCreateResized, SUPPORTED_FORMATS, VALID_WIDTHS } from '../lib/image-resize.js'
|
||||
|
||||
const CACHE_CONTROL_IMMUTABLE = 'public, max-age=31536000, immutable'
|
||||
const CACHE_CONTROL_SHORT = 'public, max-age=86400'
|
||||
|
||||
/**
|
||||
* Register GET /uploads-resized/* route for on-demand image resizing.
|
||||
* Must be registered BEFORE fastify-static for /uploads/.
|
||||
*/
|
||||
export function registerUploadsResized(fastify) {
|
||||
fastify.get('/uploads-resized/*', async (request, reply) => {
|
||||
try {
|
||||
const rawPath = request.params['*']
|
||||
if (typeof rawPath !== 'string') {
|
||||
return reply.code(400).send({ error: 'Invalid request: missing file path' })
|
||||
}
|
||||
|
||||
const url = new URL(request.url, 'http://localhost')
|
||||
const widthParam = url.searchParams.get('w')
|
||||
|
||||
// Parse: [subdir/]filename.format
|
||||
const parts = rawPath.split('/')
|
||||
let filename,
|
||||
subdir = ''
|
||||
|
||||
if (parts.length > 1) {
|
||||
subdir = parts.slice(0, -1).join('/') + '/'
|
||||
filename = parts[parts.length - 1]
|
||||
} else {
|
||||
filename = parts[0]
|
||||
}
|
||||
|
||||
const dotIdx = filename.lastIndexOf('.')
|
||||
if (dotIdx === -1) {
|
||||
return reply.code(400).send({ error: 'Invalid request: no format specified' })
|
||||
}
|
||||
|
||||
const uuid = filename.slice(0, dotIdx)
|
||||
const format = filename.slice(dotIdx + 1).toLowerCase()
|
||||
|
||||
if (!SUPPORTED_FORMATS.has(format)) {
|
||||
return reply.code(400).send({ error: `Unsupported format: ${format}. Use avif or webp.` })
|
||||
}
|
||||
|
||||
// Validate width
|
||||
let width = null
|
||||
if (widthParam) {
|
||||
const w = parseInt(widthParam, 10)
|
||||
if (!VALID_WIDTHS.includes(w)) {
|
||||
return reply.code(400).send({ error: `Invalid width: ${widthParam}. Use: ${VALID_WIDTHS.join(', ')}` })
|
||||
}
|
||||
width = w
|
||||
}
|
||||
|
||||
// If no width requested, serve original with short cache
|
||||
if (!width) {
|
||||
const originalPath = await findOriginalFile(uuid, subdir || undefined)
|
||||
if (!originalPath) {
|
||||
return reply.code(404).send({ error: 'Image not found' })
|
||||
}
|
||||
reply.header('Cache-Control', CACHE_CONTROL_SHORT)
|
||||
reply.header('Content-Type', format === 'avif' ? 'image/avif' : 'image/webp')
|
||||
return reply.send(fs.createReadStream(originalPath))
|
||||
}
|
||||
|
||||
const result = await getOrCreateResized(uuid, width, format, subdir || undefined)
|
||||
if (!result) {
|
||||
return reply.code(404).send({ error: 'Image not found' })
|
||||
}
|
||||
|
||||
reply.header('Cache-Control', CACHE_CONTROL_IMMUTABLE)
|
||||
reply.header('Content-Type', format === 'avif' ? 'image/avif' : 'image/webp')
|
||||
return reply.send(fs.createReadStream(result.path))
|
||||
} catch (error) {
|
||||
request.log.error({ err: error, url: request.url }, 'uploads-resized route error')
|
||||
return reply.code(500).send({ error: error.message || 'Image resize failed' })
|
||||
}
|
||||
})
|
||||
}
|
||||
Executable
+199
@@ -0,0 +1,199 @@
|
||||
import { asyncHandler } from '../lib/async-handler.js'
|
||||
import { prisma } from '../lib/prisma.js'
|
||||
|
||||
function normalizePhoneLite(input) {
|
||||
const s = String(input || '').trim()
|
||||
if (!s) return ''
|
||||
return s.replace(/[\s()-]/g, '')
|
||||
}
|
||||
|
||||
function validateAddressPayload(body, reply) {
|
||||
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 символов' })
|
||||
|
||||
const recipientName = String(body?.recipientName || '').trim()
|
||||
if (!recipientName) return reply.code(400).send({ error: 'Укажите ФИО получателя' })
|
||||
if (recipientName.length > 80) return reply.code(400).send({ error: 'ФИО получателя максимум 80 символов' })
|
||||
|
||||
const recipientPhone = normalizePhoneLite(body?.recipientPhone)
|
||||
if (!recipientPhone) return reply.code(400).send({ error: 'Укажите телефон получателя' })
|
||||
if (!/^\+?\d{7,20}$/.test(recipientPhone)) return reply.code(400).send({ error: 'Некорректный телефон получателя' })
|
||||
|
||||
const addressLine = String(body?.addressLine || '').trim()
|
||||
if (!addressLine) return reply.code(400).send({ error: 'Укажите адрес' })
|
||||
if (addressLine.length > 200) return reply.code(400).send({ error: 'Адрес максимум 200 символов' })
|
||||
|
||||
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 символов' })
|
||||
|
||||
const lat = Number(body?.lat)
|
||||
const lng = Number(body?.lng)
|
||||
if (!Number.isFinite(lat) || lat < -90 || lat > 90) return reply.code(400).send({ error: 'Некорректная широта' })
|
||||
if (!Number.isFinite(lng) || lng < -180 || lng > 180) return reply.code(400).send({ error: 'Некорректная долгота' })
|
||||
|
||||
return {
|
||||
label,
|
||||
recipientName,
|
||||
recipientPhone,
|
||||
addressLine,
|
||||
comment,
|
||||
lat,
|
||||
lng,
|
||||
}
|
||||
}
|
||||
|
||||
export async function registerUserAddressRoutes(fastify) {
|
||||
fastify.get(
|
||||
'/api/me/addresses',
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
asyncHandler(async (request, reply) => {
|
||||
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] },
|
||||
asyncHandler(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] },
|
||||
asyncHandler(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] },
|
||||
asyncHandler(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] },
|
||||
asyncHandler(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 }
|
||||
}),
|
||||
)
|
||||
}
|
||||
Executable
+93
@@ -0,0 +1,93 @@
|
||||
import { asyncHandler } from '../lib/async-handler.js'
|
||||
import { prisma } from '../lib/prisma.js'
|
||||
|
||||
export async function registerUserCartRoutes(fastify) {
|
||||
fastify.get(
|
||||
'/api/me/cart',
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
asyncHandler(async (request, reply) => {
|
||||
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] },
|
||||
asyncHandler(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' })
|
||||
|
||||
const product = await prisma.product.findFirst({ where: { id: productId, published: true } })
|
||||
if (!product) return reply.code(404).send({ error: 'Товар не найден' })
|
||||
|
||||
const available = product.quantity
|
||||
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 })
|
||||
}),
|
||||
)
|
||||
|
||||
fastify.patch(
|
||||
'/api/me/cart/items/:id',
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
asyncHandler(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: 'Позиция корзины не найдена' })
|
||||
|
||||
if (qty === 0) {
|
||||
await prisma.cartItem.delete({ where: { id } })
|
||||
return reply.code(204).send()
|
||||
}
|
||||
|
||||
const available = existing.product.quantity
|
||||
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] },
|
||||
asyncHandler(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()
|
||||
}),
|
||||
)
|
||||
}
|
||||
Executable
+144
@@ -0,0 +1,144 @@
|
||||
import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js'
|
||||
import { asyncHandler } from '../lib/async-handler.js'
|
||||
import { findUserOrder } from '../lib/find-user-order.js'
|
||||
import { prisma } from '../lib/prisma.js'
|
||||
|
||||
export async function registerUserMessageRoutes(fastify) {
|
||||
fastify.get(
|
||||
'/api/me/orders/:id/messages',
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
asyncHandler(async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const { id } = request.params
|
||||
await findUserOrder(prisma, id, userId)
|
||||
const items = await prisma.orderMessage.findMany({
|
||||
where: { orderId: id },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
return { items }
|
||||
}),
|
||||
)
|
||||
|
||||
fastify.post(
|
||||
'/api/me/orders/:id/messages',
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
asyncHandler(async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const { id } = request.params
|
||||
await findUserOrder(prisma, id, userId)
|
||||
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,
|
||||
})
|
||||
|
||||
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 }
|
||||
|
||||
const orderIds = orders.map((o) => o.id)
|
||||
const readStates = await prisma.userOrderMessageReadState.findMany({
|
||||
where: { userId },
|
||||
})
|
||||
const lastReadByOrder = new Map(readStates.map((r) => [r.orderId, r.lastReadAt]))
|
||||
|
||||
const adminMessages = await prisma.orderMessage.findMany({
|
||||
where: { orderId: { in: orderIds }, authorType: 'admin' },
|
||||
select: { orderId: true, createdAt: true },
|
||||
})
|
||||
|
||||
let count = 0
|
||||
for (const msg of adminMessages) {
|
||||
const lastRead = lastReadByOrder.get(msg.orderId) ?? new Date(0)
|
||||
if (msg.createdAt > lastRead) count++
|
||||
}
|
||||
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 orderIds = orders.map((o) => o.id)
|
||||
const unreadCounts = new Map()
|
||||
if (orderIds.length > 0) {
|
||||
const adminMessages = await prisma.orderMessage.findMany({
|
||||
where: { orderId: { in: orderIds }, authorType: 'admin' },
|
||||
select: { orderId: true, createdAt: true },
|
||||
})
|
||||
for (const msg of adminMessages) {
|
||||
const lastRead = lastReadByOrder.get(msg.orderId) ?? new Date(0)
|
||||
if (msg.createdAt > lastRead) {
|
||||
unreadCounts.set(msg.orderId, (unreadCounts.get(msg.orderId) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const items = []
|
||||
for (const o of orders) {
|
||||
const lastMsg = o.messages[0]
|
||||
if (!lastMsg) continue
|
||||
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: unreadCounts.get(o.id) ?? 0,
|
||||
})
|
||||
}
|
||||
return { items }
|
||||
})
|
||||
|
||||
fastify.post(
|
||||
'/api/me/orders/:id/messages/read',
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
asyncHandler(async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const { id } = request.params
|
||||
await findUserOrder(prisma, id, userId)
|
||||
|
||||
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 }
|
||||
}),
|
||||
)
|
||||
}
|
||||
Executable
+279
@@ -0,0 +1,279 @@
|
||||
import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js'
|
||||
import { asyncHandler } from '../lib/async-handler.js'
|
||||
import { isDeliveryCarrier } from '../lib/delivery-carrier.js'
|
||||
import { findUserOrder } from '../lib/find-user-order.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()
|
||||
|
||||
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' })
|
||||
}
|
||||
|
||||
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 | WB_PVZ',
|
||||
})
|
||||
}
|
||||
deliveryCarrier = carrierStr
|
||||
}
|
||||
|
||||
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.quantity
|
||||
if (ci.qty > available) {
|
||||
return reply.code(409).send({
|
||||
error: `Недостаточно товара: "${ci.product.title}". Доступно: ${available} шт.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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,
|
||||
})),
|
||||
},
|
||||
},
|
||||
})
|
||||
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,
|
||||
})
|
||||
|
||||
// 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 })
|
||||
})
|
||||
|
||||
fastify.get(
|
||||
'/api/me/orders',
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
asyncHandler(async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const orders = await prisma.order.findMany({
|
||||
where: { userId },
|
||||
include: { items: { select: { qty: 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] },
|
||||
asyncHandler(async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const { id } = request.params
|
||||
const order = await findUserOrder(prisma, id, userId, {
|
||||
items: true,
|
||||
messages: { orderBy: { createdAt: 'asc' } },
|
||||
})
|
||||
return { item: order }
|
||||
}),
|
||||
)
|
||||
|
||||
fastify.get(
|
||||
'/api/me/orders/:id/review-eligibility',
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
asyncHandler(async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const { id } = request.params
|
||||
const order = await findUserOrder(prisma, id, userId, { items: true })
|
||||
if (order.status !== 'DONE') {
|
||||
return { canReview: false, items: [] }
|
||||
}
|
||||
|
||||
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 existing = await prisma.review.findMany({
|
||||
where: { userId, productId: { in: productIds } },
|
||||
select: { productId: true },
|
||||
})
|
||||
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',
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
asyncHandler(async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const { id } = request.params
|
||||
const order = await findUserOrder(prisma, id, userId)
|
||||
|
||||
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: 'Сейчас нельзя подтвердить получение заказа' })
|
||||
}
|
||||
|
||||
await prisma.order.update({ where: { id }, data: { status: 'DONE' } })
|
||||
request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_STATUS_CHANGED, {
|
||||
orderId: order.id,
|
||||
userId: order.userId,
|
||||
oldStatus: order.status,
|
||||
newStatus: 'DONE',
|
||||
})
|
||||
return { ok: true, status: 'DONE' }
|
||||
}),
|
||||
)
|
||||
}
|
||||
Executable
+154
@@ -0,0 +1,154 @@
|
||||
import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js'
|
||||
import { asyncHandler } from '../lib/async-handler.js'
|
||||
import { findUserOrder } from '../lib/find-user-order.js'
|
||||
import { prisma } from '../lib/prisma.js'
|
||||
import { createPayment, buildReceipt, getPayment } from '../lib/yookassa.js'
|
||||
|
||||
export async function registerUserPaymentRoutes(fastify) {
|
||||
fastify.post(
|
||||
'/api/me/orders/:id/pay',
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
asyncHandler(async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const userEmail = request.user.email
|
||||
|
||||
if (!userEmail) {
|
||||
return reply.code(422).send({ error: 'Для онлайн-оплаты необходим email в профиле' })
|
||||
}
|
||||
|
||||
const { id } = request.params
|
||||
|
||||
const order = await findUserOrder(prisma, id, userId, { items: true })
|
||||
|
||||
if (order.paymentMethod === 'on_pickup') {
|
||||
return reply.code(409).send({
|
||||
error: 'Для этого заказа оплата при получении — онлайн-оплата недоступна',
|
||||
})
|
||||
}
|
||||
|
||||
if (order.status !== 'PENDING_PAYMENT') {
|
||||
return reply.code(409).send({ error: 'Сейчас нельзя выполнить оплату для этого заказа' })
|
||||
}
|
||||
|
||||
if (!order.deliveryFeeLocked) {
|
||||
return reply.code(409).send({
|
||||
error: 'Стоимость доставки ещё утверждается — оплата станет доступна позже',
|
||||
})
|
||||
}
|
||||
|
||||
const existingPayment = await prisma.payment.findFirst({
|
||||
where: {
|
||||
orderId: id,
|
||||
status: { in: ['pending', 'waiting_for_capture'] },
|
||||
OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }],
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
|
||||
if (existingPayment && existingPayment.confirmationUrl) {
|
||||
return { confirmationUrl: existingPayment.confirmationUrl }
|
||||
}
|
||||
|
||||
const idempotencyKey = `${id}-${Date.now()}`
|
||||
const clientUrl = (process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173').replace(/\/$/, '')
|
||||
const returnUrl = `${clientUrl}/me/orders/${id}?paid=1`
|
||||
const clientIp = request.ip
|
||||
|
||||
const amount = {
|
||||
value: (order.totalCents / 100).toFixed(2),
|
||||
currency: order.currency,
|
||||
}
|
||||
|
||||
const receipt = buildReceipt({
|
||||
orderItems: order.items,
|
||||
deliveryFeeCents: order.deliveryFeeCents,
|
||||
userEmail: userEmail,
|
||||
})
|
||||
|
||||
let result
|
||||
try {
|
||||
result = await createPayment({
|
||||
amount,
|
||||
description: `Оплата заказа №${order.id.slice(-6)}`,
|
||||
receipt,
|
||||
confirmation: { type: 'redirect', return_url: returnUrl },
|
||||
metadata: { orderId: order.id },
|
||||
idempotencyKey,
|
||||
clientIp,
|
||||
})
|
||||
} catch (err) {
|
||||
request.log.error({ err, orderId: id }, 'YooKassa createPayment failed')
|
||||
return reply.code(502).send({
|
||||
error: 'Не удалось создать платёж. Платёжный сервис временно недоступен.',
|
||||
})
|
||||
}
|
||||
|
||||
await prisma.payment.create({
|
||||
data: {
|
||||
orderId: order.id,
|
||||
yookassaPaymentId: result.paymentId,
|
||||
status: result.status,
|
||||
amountCents: order.totalCents,
|
||||
currency: order.currency,
|
||||
confirmationUrl: result.confirmationUrl,
|
||||
expiresAt: result.expiresAt ? new Date(result.expiresAt) : null,
|
||||
},
|
||||
})
|
||||
|
||||
return { confirmationUrl: result.confirmationUrl }
|
||||
}),
|
||||
)
|
||||
|
||||
fastify.get(
|
||||
'/api/me/orders/:orderId/payment',
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
asyncHandler(async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const { orderId } = request.params
|
||||
|
||||
const order = await findUserOrder(prisma, orderId, userId)
|
||||
|
||||
const payment = await prisma.payment.findFirst({
|
||||
where: { orderId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
if (!payment) {
|
||||
return { status: null, paid: false }
|
||||
}
|
||||
|
||||
if (payment.status === 'succeeded' || payment.status === 'canceled') {
|
||||
return { status: payment.status, paid: payment.status === 'succeeded' }
|
||||
}
|
||||
|
||||
try {
|
||||
const ykPayment = await getPayment(payment.yookassaPaymentId)
|
||||
|
||||
if (ykPayment.status !== payment.status) {
|
||||
await prisma.payment.update({
|
||||
where: { id: payment.id },
|
||||
data: { status: ykPayment.status },
|
||||
})
|
||||
|
||||
if (ykPayment.status === 'succeeded' && order.status === 'PENDING_PAYMENT') {
|
||||
const updated = await prisma.order.updateMany({
|
||||
where: { id: orderId, status: 'PENDING_PAYMENT' },
|
||||
data: { status: 'PAID' },
|
||||
})
|
||||
if (updated.count > 0) {
|
||||
request.server.eventBus.emit(NOTIFICATION_EVENTS.PAYMENT_STATUS_CHANGED, {
|
||||
orderId,
|
||||
userId: order.userId,
|
||||
paymentStatus: 'paid',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { status: ykPayment.status, paid: ykPayment.paid }
|
||||
} catch (err) {
|
||||
request.log.error({ err }, '[user-payments] Operation failed')
|
||||
return { status: payment.status, paid: payment.status === 'succeeded' }
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
Executable
+31
@@ -0,0 +1,31 @@
|
||||
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.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 prefs = await prisma.notificationPreference.upsert({
|
||||
where: { userId },
|
||||
create: { userId, ...data },
|
||||
update: data,
|
||||
})
|
||||
|
||||
return { settings: prefs }
|
||||
})
|
||||
}
|
||||
Executable
+61
@@ -0,0 +1,61 @@
|
||||
import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js'
|
||||
import { prisma } from '../lib/prisma.js'
|
||||
import { validateWebhook } from '../lib/yookassa.js'
|
||||
|
||||
export async function registerYookassaWebhookRoute(fastify) {
|
||||
fastify.post('/api/webhooks/yookassa', async (request, reply) => {
|
||||
let body
|
||||
try {
|
||||
body = typeof request.body === 'string' ? JSON.parse(request.body) : request.body
|
||||
} catch (err) {
|
||||
request.log.error({ err }, 'Failed to parse webhook JSON body')
|
||||
return reply.code(400).send({ error: 'Invalid JSON body' })
|
||||
}
|
||||
|
||||
let event, paymentObject
|
||||
try {
|
||||
const clientIp = request.ip
|
||||
;({ event, paymentObject } = validateWebhook(clientIp, body))
|
||||
} catch (err) {
|
||||
return reply.code(400).send({ error: err.message })
|
||||
}
|
||||
|
||||
const yookassaPaymentId = paymentObject.id
|
||||
if (!yookassaPaymentId) {
|
||||
return reply.code(400).send({ error: 'Missing payment id in webhook object' })
|
||||
}
|
||||
|
||||
const payment = await prisma.payment.findFirst({
|
||||
where: { yookassaPaymentId },
|
||||
})
|
||||
if (!payment) {
|
||||
return reply.code(404).send({ error: 'Payment not found' })
|
||||
}
|
||||
|
||||
await prisma.payment.update({
|
||||
where: { id: payment.id },
|
||||
data: { status: paymentObject.status },
|
||||
})
|
||||
|
||||
if (event === 'payment.succeeded') {
|
||||
const order = await prisma.order.findFirst({
|
||||
where: { id: payment.orderId },
|
||||
})
|
||||
if (order && order.status === 'PENDING_PAYMENT') {
|
||||
const updated = await prisma.order.updateMany({
|
||||
where: { id: payment.orderId, status: 'PENDING_PAYMENT' },
|
||||
data: { status: 'PAID' },
|
||||
})
|
||||
if (updated.count > 0) {
|
||||
fastify.eventBus.emit(NOTIFICATION_EVENTS.PAYMENT_STATUS_CHANGED, {
|
||||
orderId: payment.orderId,
|
||||
userId: order.userId,
|
||||
paymentStatus: 'paid',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user