Files
shop-server/server/src/index.js
T
2026-05-24 15:10:24 +05:00

230 lines
7.6 KiB
JavaScript

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 () => {
try {
await prisma.$queryRaw`SELECT 1`
return { status: 'ok', database: 'connected', uptime: process.uptime() }
} catch {
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 {
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)
await ensureAdminUser()
await getOrCreateUnspecifiedCategory()
await notificationQueue.flushPendingOnStartup()
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) {
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 })
}
}
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)
try {
await fastify.listen({ port, host: '0.0.0.0' })
} catch (err) {
fastify.log.error(err)
process.exit(1)
}