initial: server + shared

This commit is contained in:
admin
2026-06-11 13:41:38 +05:00
commit 65da047e7c
148 changed files with 15900 additions and 0 deletions
+253
View File
@@ -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
View File
@@ -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' })
})
})
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { escapeHtml } from '../escape-html.js'
describe('escapeHtml', () => {
it('escapes & < > "', () => {
expect(escapeHtml('&<>"')).toBe('&amp;&lt;&gt;&quot;')
})
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('&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;')
})
})
+27
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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$/)
})
})
+32
View File
@@ -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)
})
})
+257
View File
@@ -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()
})
})
+12
View File
@@ -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 })
}
}
}
+106
View File
@@ -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
View File
@@ -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,
},
})
}
}
+20
View File
@@ -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
}
+11
View File
@@ -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)
}
+64
View File
@@ -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 }
}
}
+8
View File
@@ -0,0 +1,8 @@
/** Минимальное экранирование для безопасного HTML из пользовательского ввода. */
export function escapeHtml(input) {
return String(input ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
+12
View File
@@ -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
}
+9
View File
@@ -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()
}
+143
View File
@@ -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`
}
+96
View File
@@ -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
View File
@@ -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
},
}
+69
View File
@@ -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)
},
}
+7
View File
@@ -0,0 +1,7 @@
import { EventEmitter } from 'node:events'
export function createEventBus() {
const bus = new EventEmitter()
bus.setMaxListeners(50)
return bus
}
+105
View File
@@ -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 },
})
}
+134
View File
@@ -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
View File
@@ -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Ожидает оплаты.`
}
+5
View File
@@ -0,0 +1,5 @@
export {
ORDER_STATUSES,
getNextAdminStatuses,
canTransitionAdminOrderStatus,
} from '../../../shared/constants/order-status.js'
+9
View File
@@ -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
}
+51
View File
@@ -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')
}
+13
View File
@@ -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}`
}
+88
View File
@@ -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}`
}
+50
View File
@@ -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} МБ).`
}
+23
View File
@@ -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
}
+182
View File
@@ -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
}
+296
View File
@@ -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()
}
})
})
+259
View File
@@ -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)
})
})
+44
View File
@@ -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: 'Недостаточно прав' })
}
})
}
+132
View File
@@ -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))
})
}
+24
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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()
})
})
+238
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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()
})
})
+42
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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')
})
})
+55
View File
@@ -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
}
+138
View File
@@ -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()
}),
)
}
+134
View File
@@ -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()
})
}
+182
View File
@@ -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 })
})
}
+264
View File
@@ -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: 'Товар не найден' })
}
})
}
+69
View File
@@ -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,
}
})
}
+65
View File
@@ -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 }
})
}
+168
View File
@@ -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: 'Пользователь не найден' })
}
})
}
+78
View File
@@ -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
View File
@@ -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 }
})
}
+108
View File
@@ -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,
})),
}
}),
)
}
+162
View File
@@ -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)
})
}
+152
View File
@@ -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: 'Вы уже оставляли отзыв на этот товар' })
}
})
}
+35
View File
@@ -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 }
})
}
+49
View File
@@ -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 }
})
}
+81
View File
@@ -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`)
})
}
+239
View File
@@ -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) }
})
}
+343
View File
@@ -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)
})
}
+149
View File
@@ -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)
})
}
+81
View File
@@ -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' })
}
})
}
+199
View File
@@ -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 }
}),
)
}
+93
View File
@@ -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()
}),
)
}
+144
View File
@@ -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 }
}),
)
}
+279
View File
@@ -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' }
}),
)
}
+154
View File
@@ -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' }
}
}),
)
}
+31
View File
@@ -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 }
})
}
+61
View File
@@ -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 }
})
}