feat: wire up notification system in server
This commit is contained in:
+74
-2
@@ -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' })
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user