feat: wire up notification system in server

This commit is contained in:
Kirill
2026-05-18 11:36:19 +05:00
parent 3f83a9be8e
commit e73a0ae09a
4 changed files with 203 additions and 2 deletions
+74 -2
View File
@@ -8,6 +8,15 @@ import path from 'node:path'
import { ensureAdminUser } from './lib/bootstrap-admin.js' import { ensureAdminUser } from './lib/bootstrap-admin.js'
import { getOrCreateUnspecifiedCategory } from './lib/default-category.js' import { getOrCreateUnspecifiedCategory } from './lib/default-category.js'
import { getMaxUploadBodyBytes, getProductImageMaxFileBytes } from './lib/upload-limits.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 { registerAuth } from './plugins/auth.js'
import { registerApiRoutes } from './routes/api.js' import { registerApiRoutes } from './routes/api.js'
import { registerAuthRoutes } from './routes/auth.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 { registerUserMessageRoutes } from './routes/user-messages.js'
import { registerUserOrderRoutes } from './routes/user-orders.js' import { registerUserOrderRoutes } from './routes/user-orders.js'
import { registerUserPaymentRoutes } from './routes/user-payments.js' import { registerUserPaymentRoutes } from './routes/user-payments.js'
import { registerUserNotificationRoutes } from './routes/user/notifications.js'
import { registerOAuthSocialRoutes } from './routes/oauth-social.js' import { registerOAuthSocialRoutes } from './routes/oauth-social.js'
import { registerUploadsResized } from './routes/uploads-resized.js' import { registerUploadsResized } from './routes/uploads-resized.js'
@@ -42,7 +52,6 @@ await fastify.register(jwt, {
await fastify.register(multipart, { await fastify.register(multipart, {
limits: { limits: {
files: 10, files: 10,
/** Совпадает с лимитом одного файла для `POST /api/admin/gallery/upload` (галерея). */
fileSize: getProductImageMaxFileBytes(), 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) registerAuth(fastify)
await registerAuthRoutes(fastify) await registerAuthRoutes(fastify)
await registerUserAddressRoutes(fastify) await registerUserAddressRoutes(fastify)
@@ -77,12 +91,70 @@ await registerUserCartRoutes(fastify)
await registerUserMessageRoutes(fastify) await registerUserMessageRoutes(fastify)
await registerUserOrderRoutes(fastify) await registerUserOrderRoutes(fastify)
await registerUserPaymentRoutes(fastify) await registerUserPaymentRoutes(fastify)
await registerUserNotificationRoutes(fastify)
await registerOAuthSocialRoutes(fastify) await registerOAuthSocialRoutes(fastify)
await registerApiRoutes(fastify) await registerApiRoutes(fastify)
await ensureAdminUser() await ensureAdminUser()
await getOrCreateUnspecifiedCategory() 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 { try {
await fastify.listen({ port, host: '0.0.0.0' }) await fastify.listen({ port, host: '0.0.0.0' })
+2
View File
@@ -10,6 +10,7 @@ import { registerAdminOrderRoutes } from './api/admin-orders.js'
import { registerAdminProductRoutes } from './api/admin-products.js' import { registerAdminProductRoutes } from './api/admin-products.js'
import { registerAdminReviewRoutes } from './api/admin-reviews.js' import { registerAdminReviewRoutes } from './api/admin-reviews.js'
import { registerAdminUserRoutes } from './api/admin-users.js' import { registerAdminUserRoutes } from './api/admin-users.js'
import { registerAdminNotificationRoutes } from './api/admin/notifications.js'
import { registerInfoPageRoutes } from './api/info-page.js' import { registerInfoPageRoutes } from './api/info-page.js'
import { registerPublicCatalogRoutes } from './api/public-catalog.js' import { registerPublicCatalogRoutes } from './api/public-catalog.js'
import { registerPublicReviewRoutes } from './api/public-reviews.js' import { registerPublicReviewRoutes } from './api/public-reviews.js'
@@ -30,5 +31,6 @@ export async function registerApiRoutes(fastify) {
await registerAdminOrderRoutes(fastify) await registerAdminOrderRoutes(fastify)
await registerAdminReviewRoutes(fastify) await registerAdminReviewRoutes(fastify)
await registerAdminUserRoutes(fastify) await registerAdminUserRoutes(fastify)
await registerAdminNotificationRoutes(fastify)
} }
@@ -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 }
},
)
}
+38
View File
@@ -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 }
},
)
}