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) }