diff --git a/server/src/index.js b/server/src/index.js index c5bf3be..4e8e07e 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -8,6 +8,15 @@ import path from 'node:path' import { ensureAdminUser } from './lib/bootstrap-admin.js' import { getOrCreateUnspecifiedCategory } from './lib/default-category.js' import { getMaxUploadBodyBytes, getProductImageMaxFileBytes } from './lib/upload-limits.js' +import { createEventBus } from './lib/notifications/event-bus.js' +import { createNotificationQueue } from './lib/notifications/queue.js' +import { prisma } from './lib/prisma.js' +import { + resolveUserNotificationTargets, + resolveAdminNotificationTargets, + resolveAuthCodeTargets, +} from './lib/notifications/preferences.js' +import { NOTIFICATION_EVENTS, NOTIFICATION_CHANNELS } from './shared/constants/notification-events.js' import { registerAuth } from './plugins/auth.js' import { registerApiRoutes } from './routes/api.js' import { registerAuthRoutes } from './routes/auth.js' @@ -16,6 +25,7 @@ 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 { registerUserNotificationRoutes } from './routes/user/notifications.js' import { registerOAuthSocialRoutes } from './routes/oauth-social.js' import { registerUploadsResized } from './routes/uploads-resized.js' @@ -42,7 +52,6 @@ await fastify.register(jwt, { await fastify.register(multipart, { limits: { files: 10, - /** Совпадает с лимитом одного файла для `POST /api/admin/gallery/upload` (галерея). */ fileSize: getProductImageMaxFileBytes(), }, }) @@ -70,6 +79,11 @@ fastify.decorate('authenticate', async function authenticate(request, reply) { } }) +const eventBus = createEventBus() +const notificationQueue = createNotificationQueue() +fastify.decorate('eventBus', eventBus) +fastify.decorate('notificationQueue', notificationQueue) + registerAuth(fastify) await registerAuthRoutes(fastify) await registerUserAddressRoutes(fastify) @@ -77,12 +91,70 @@ await registerUserCartRoutes(fastify) await registerUserMessageRoutes(fastify) await registerUserOrderRoutes(fastify) await registerUserPaymentRoutes(fastify) +await registerUserNotificationRoutes(fastify) await registerOAuthSocialRoutes(fastify) await registerApiRoutes(fastify) await ensureAdminUser() await getOrCreateUnspecifiedCategory() -fastify.get('/health', async () => ({ ok: true })) +await notificationQueue.flushPendingOnStartup() +notificationQueue.start() + +const { + ORDER_CREATED, + ORDER_STATUS_CHANGED, + ORDER_MESSAGE_SENT, + ORDER_MESSAGE_ADMIN_REPLY, + PAYMENT_STATUS_CHANGED, + AUTH_CODE_REQUESTED, +} = NOTIFICATION_EVENTS + +async function dispatchNotification(eventType, payload) { + 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, dispatchNotification) +eventBus.on(ORDER_STATUS_CHANGED, dispatchNotification) +eventBus.on(ORDER_MESSAGE_SENT, dispatchNotification) +eventBus.on(ORDER_MESSAGE_ADMIN_REPLY, dispatchNotification) +eventBus.on(PAYMENT_STATUS_CHANGED, dispatchNotification) +eventBus.on(AUTH_CODE_REQUESTED, dispatchNotification) +eventBus.on('order:created:admin', dispatchNotification) +eventBus.on('review:created', dispatchNotification) + +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' }) diff --git a/server/src/routes/api.js b/server/src/routes/api.js index b0c30c7..ebeac37 100644 --- a/server/src/routes/api.js +++ b/server/src/routes/api.js @@ -10,6 +10,7 @@ import { registerAdminOrderRoutes } from './api/admin-orders.js' import { registerAdminProductRoutes } from './api/admin-products.js' import { registerAdminReviewRoutes } from './api/admin-reviews.js' import { registerAdminUserRoutes } from './api/admin-users.js' +import { registerAdminNotificationRoutes } from './api/admin/notifications.js' import { registerInfoPageRoutes } from './api/info-page.js' import { registerPublicCatalogRoutes } from './api/public-catalog.js' import { registerPublicReviewRoutes } from './api/public-reviews.js' @@ -30,5 +31,6 @@ export async function registerApiRoutes(fastify) { await registerAdminOrderRoutes(fastify) await registerAdminReviewRoutes(fastify) await registerAdminUserRoutes(fastify) + await registerAdminNotificationRoutes(fastify) } diff --git a/server/src/routes/api/admin/notifications.js b/server/src/routes/api/admin/notifications.js new file mode 100644 index 0000000..44b5bcd --- /dev/null +++ b/server/src/routes/api/admin/notifications.js @@ -0,0 +1,89 @@ +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: 'Вы подписаны на уведомления Craftshop.', + }), + }) + } + + return { ok: true } + }, + ) +} diff --git a/server/src/routes/user/notifications.js b/server/src/routes/user/notifications.js new file mode 100644 index 0000000..a984659 --- /dev/null +++ b/server/src/routes/user/notifications.js @@ -0,0 +1,38 @@ +import { prisma } from '../../lib/prisma.js' +import { ensureUserNotificationPreference } from '../../lib/notifications/preferences.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) + + const prefs = await prisma.notificationPreference.upsert({ + where: { userId }, + create: { userId, ...data }, + update: data, + }) + + return { settings: prefs } + }, + ) +}