# Notification System Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build an event-driven notification system with email (users) and email + Telegram (admin), in-memory queue with retry, and UI for managing notification preferences. **Architecture:** Event Emitter → Queue → Channels (email/telegram). Preferences stored in DB, checked before sending. NotificationLog tracks all delivery attempts. **Tech Stack:** Node.js EventEmitter, nodemailer (existing), Telegram Bot API (fetch), Prisma, MUI (client), @tanstack/react-query. --- ## File Map | File | Action | Responsibility | |---|---|---| | `server/prisma/schema.prisma` | Modify | Add 3 new models | | `server/src/lib/notifications/event-bus.js` | Create | Central EventEmitter | | `server/src/lib/notifications/queue.js` | Create | In-memory queue + worker | | `server/src/lib/notifications/channels/email-channel.js` | Create | Email delivery via nodemailer | | `server/src/lib/notifications/channels/telegram-channel.js` | Create | Telegram delivery via Bot API | | `server/src/lib/notifications/templates/email-templates.js` | Create | HTML email templates | | `server/src/lib/notifications/templates/telegram-templates.js` | Create | Telegram message templates | | `server/src/lib/notifications/preferences.js` | Create | Resolve recipients based on preferences | | `server/src/lib/email.js` | Modify | Add `sendNotificationEmail()` | | `server/src/lib/bootstrap-admin.js` | Modify | Create AdminNotificationSettings on admin bootstrap | | `server/src/lib/auth.js` | Modify | Emit `auth:codeRequested` event | | `server/src/routes/api.js` | Modify | Register notification routes | | `server/src/routes/api/admin/notifications.js` | Create | Admin notification settings API | | `server/src/routes/user/notifications.js` | Create | User notification settings API | | `server/src/routes/user-orders.js` | Modify | Emit `order:created` | | `server/src/routes/api/admin-orders.js` | Modify | Emit `order:statusChanged`, `orderMessage:adminReply` | | `server/src/routes/user-messages.js` | Modify | Emit `orderMessage:sent` | | `server/src/routes/user-payments.js` | Modify | Emit `payment:statusChanged` | | `server/src/index.js` | Modify | Initialize eventBus, queue, register user notification routes | | `shared/constants/notification-events.js` | Create | Event type constants | | `shared/constants/notification-events.d.ts` | Create | TypeScript definitions | | `client/src/entities/notification/api/notifications-api.ts` | Create | API client functions | | `client/src/pages/me/ui/sections/NotificationsPage.tsx` | Create | User notification settings UI | | `client/src/pages/me/ui/MeLayoutPage.tsx` | Modify | Add notifications route + nav item | | `client/src/pages/admin-layout/ui/` | Modify | Add admin notification settings | --- ### Task 1: Database Schema — Add notification models **Files:** - Modify: `server/prisma/schema.prisma` - [ ] **Step 1: Add three new models to the Prisma schema** Add these models at the end of `server/prisma/schema.prisma` (after `InfoPageBlock`): ```prisma /// Настройки оповещений пользователя model NotificationPreference { id String @id @default(cuid()) userId String @unique globalEnabled Boolean @default(true) orderCreated Boolean @default(true) orderStatusChanged Boolean @default(true) orderMessageReceived Boolean @default(true) paymentStatusChanged Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId]) } /// Настройки оповещений админа model AdminNotificationSettings { id String @id @default(cuid()) emailEnabled Boolean @default(true) telegramEnabled Boolean @default(false) telegramChatId String? newOrder Boolean @default(true) newOrderMessage Boolean @default(true) newReview Boolean @default(true) authCodeDuplicate Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } /// Лог отправки оповещений model NotificationLog { id String @id @default(cuid()) userId String? eventType String channel String status String error String? payload String attempts Int @default(0) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt user User? @relation(fields: [userId], references: [id], onDelete: SetNull) @@index([status, createdAt]) @@index([userId, createdAt]) } ``` Also add the `notificationPreferences` relation to the `User` model. Find the `User` model and add: ```prisma notificationPreferences NotificationPreference? ``` - [ ] **Step 2: Run the migration** Run: ```bash cd server && npx prisma migrate dev --name add_notification_system ``` Expected: Migration created and applied successfully. - [ ] **Step 3: Commit** ```bash git add server/prisma/schema.prisma server/prisma/migrations/ git commit -m "feat: add notification system database models" ``` --- ### Task 2: Event type constants (shared) **Files:** - Create: `shared/constants/notification-events.js` - Create: `shared/constants/notification-events.d.ts` - [ ] **Step 1: Create the JS constants file** ```js // shared/constants/notification-events.js /** @typedef {'order:created' | 'order:statusChanged' | 'orderMessage:sent' | 'orderMessage:adminReply' | 'payment:statusChanged' | 'auth:codeRequested'} NotificationEventType */ const NOTIFICATION_EVENTS = { ORDER_CREATED: 'order:created', ORDER_STATUS_CHANGED: 'order:statusChanged', ORDER_MESSAGE_SENT: 'orderMessage:sent', ORDER_MESSAGE_ADMIN_REPLY: 'orderMessage:adminReply', PAYMENT_STATUS_CHANGED: 'payment:statusChanged', AUTH_CODE_REQUESTED: 'auth:codeRequested', } const NOTIFICATION_CHANNELS = { EMAIL: 'email', TELEGRAM: 'telegram', } const NOTIFICATION_STATUSES = { PENDING: 'pending', SENT: 'sent', FAILED: 'failed', } const MAX_RETRY_ATTEMPTS = 3 const RETRY_DELAYS_MS = [5_000, 30_000, 120_000] module.exports = { NOTIFICATION_EVENTS, NOTIFICATION_CHANNELS, NOTIFICATION_STATUSES, MAX_RETRY_ATTEMPTS, RETRY_DELAYS_MS, } ``` - [ ] **Step 2: Create the TypeScript definition file** ```ts // shared/constants/notification-events.d.ts export type NotificationEventType = | 'order:created' | 'order:statusChanged' | 'orderMessage:sent' | 'orderMessage:adminReply' | 'payment:statusChanged' | 'auth:codeRequested' export type NotificationChannel = 'email' | 'telegram' export type NotificationStatus = 'pending' | 'sent' | 'failed' export const NOTIFICATION_EVENTS: { ORDER_CREATED: NotificationEventType ORDER_STATUS_CHANGED: NotificationEventType ORDER_MESSAGE_SENT: NotificationEventType ORDER_MESSAGE_ADMIN_REPLY: NotificationEventType PAYMENT_STATUS_CHANGED: NotificationEventType AUTH_CODE_REQUESTED: NotificationEventType } export const NOTIFICATION_CHANNELS: { EMAIL: NotificationChannel TELEGRAM: NotificationChannel } export const NOTIFICATION_STATUSES: { PENDING: NotificationStatus SENT: NotificationStatus FAILED: NotificationStatus } export const MAX_RETRY_ATTEMPTS: number export const RETRY_DELAYS_MS: number[] ``` - [ ] **Step 3: Commit** ```bash git add shared/constants/notification-events.js shared/constants/notification-events.d.ts git commit -m "feat: add notification event type constants" ``` --- ### Task 3: Event Bus **Files:** - Create: `server/src/lib/notifications/event-bus.js` - [ ] **Step 1: Create the event bus module** ```js // server/src/lib/notifications/event-bus.js import { EventEmitter } from 'node:events' export function createEventBus() { const bus = new EventEmitter() bus.setMaxListeners(50) return bus } ``` - [ ] **Step 2: Commit** ```bash git add server/src/lib/notifications/event-bus.js git commit -m "feat: add notification event bus" ``` --- ### Task 4: Email Templates **Files:** - Create: `server/src/lib/notifications/templates/email-templates.js` - [ ] **Step 1: Create email templates** ```js // server/src/lib/notifications/templates/email-templates.js function baseLayout(title, body) { return `
Craftshop — магазин handmade изделий
Ваш заказ #${orderId.slice(0, 8)} успешно создан.
Товаров: ${itemsCount} | Сумма: ${total} ₽
Мы сообщим вам об изменениях статуса.
` return { subject: 'Заказ создан', html: baseLayout('Заказ создан', body) } } export function renderOrderStatusChangedEmail({ orderId, oldStatus, newStatus }) { const statusLabels = { DRAFT: 'Черновик', PENDING_PAYMENT: 'Ожидает оплаты', IN_PROGRESS: 'В работе', READY_FOR_PICKUP: 'Готов к выдаче', SHIPPED: 'Отправлен', DONE: 'Выполнен', CANCELLED: 'Отменён', } const oldLabel = statusLabels[oldStatus] || oldStatus const newLabel = statusLabels[newStatus] || newStatus const body = `Статус заказа #${orderId.slice(0, 8)} изменён.
${oldLabel} → ${newLabel}
` return { subject: `Статус заказа изменён — ${newLabel}`, html: baseLayout('Статус заказа изменён', body) } } export function renderOrderMessageEmail({ orderId, preview }) { const truncated = preview.length > 200 ? preview.slice(0, 197) + '...' : preview const body = `Новое сообщение к заказу #${orderId.slice(0, 8)}:
Ответьте в личном кабинете.
` return { subject: 'Новое сообщение к заказу', html: baseLayout('Новое сообщение', body) } } export function renderPaymentStatusChangedEmail({ orderId, paymentStatus }) { const statusLabels = { pending: 'Ожидает', confirmed: 'Подтверждён', rejected: 'Отклонён', } const label = statusLabels[paymentStatus] || paymentStatus const body = `Статус оплаты заказа #${orderId.slice(0, 8)}: ${label}.
` return { subject: `Оплата заказа — ${label}`, html: baseLayout('Оплата заказа', body) } } export function renderAdminOrderCreatedEmail({ orderId, userEmail, totalCents, itemsCount }) { const total = (totalCents / 100).toLocaleString('ru-RU') const body = `Новый заказ #${orderId.slice(0, 8)} от ${userEmail}.
Товаров: ${itemsCount} | Сумма: ${total} ₽
` return { subject: 'Новый заказ', html: baseLayout('Новый заказ', body) } } export function renderAdminNewReviewEmail({ rating, text, productTitle, userName }) { const stars = '★'.repeat(rating) + '☆'.repeat(5 - rating) const body = `Новый отзыв ${stars} на товар ${productTitle} от ${userName}.
${text ? `Проверьте отзыв в админ-панели.
` return { subject: 'Новый отзыв', html: baseLayout('Новый отзыв', body) } } export function renderAuthCodeEmail({ code }) { const body = `Ваш код входа: ${code}
Если это были не вы — просто проигнорируйте письмо.
` return { subject: 'Код входа', html: baseLayout('Код входа', body) } } ``` - [ ] **Step 2: Commit** ```bash git add server/src/lib/notifications/templates/email-templates.js git commit -m "feat: add email templates for notifications" ``` --- ### Task 5: Telegram Templates **Files:** - Create: `server/src/lib/notifications/templates/telegram-templates.js` - [ ] **Step 1: Create Telegram message templates** ```js // server/src/lib/notifications/templates/telegram-templates.js export function renderOrderCreatedTg({ orderId, totalCents, itemsCount }) { const total = (totalCents / 100).toLocaleString('ru-RU') return `📦 Новый заказ #${orderId.slice(0, 8)}\nТоваров: ${itemsCount} | Сумма: ${total} ₽` } export function renderOrderStatusChangedTg({ orderId, oldStatus, newStatus }) { const labels = { DRAFT: 'Черновик', PENDING_PAYMENT: 'Ожидает оплаты', IN_PROGRESS: 'В работе', READY_FOR_PICKUP: 'Готов к выдаче', SHIPPED: 'Отправлен', DONE: 'Выполнен', CANCELLED: 'Отменён', } return `🔄 Заказ #${orderId.slice(0, 8)}\n${labels[oldStatus] || oldStatus} → ${labels[newStatus] || newStatus}` } export function renderOrderMessageTg({ orderId, preview }) { const truncated = preview.length > 300 ? preview.slice(0, 297) + '...' : preview return `💬 Сообщение к заказу #${orderId.slice(0, 8)}\n\n${truncated}` } export function renderPaymentStatusChangedTg({ orderId, paymentStatus }) { const labels = { pending: 'Ожидает', confirmed: 'Подтверждён', rejected: 'Отклонён' } return `💳 Оплата заказа #${orderId.slice(0, 8)}: ${labels[paymentStatus] || paymentStatus}` } export function renderAdminOrderCreatedTg({ orderId, userEmail, totalCents, itemsCount }) { const total = (totalCents / 100).toLocaleString('ru-RU') return `🛒 Новый заказ #${orderId.slice(0, 8)}\nОт: ${userEmail}\nТоваров: ${itemsCount} | Сумма: ${total} ₽` } export function renderAdminNewReviewTg({ rating, text, productTitle, userName }) { const stars = '⭐'.repeat(rating) return `📝 Новый отзыв ${stars}\nТовар: ${productTitle}\nАвтор: ${userName}${text ? '\n\n' + text : ''}` } export function renderAuthCodeTg({ code }) { return `🔐 Код входа: ${code}` } ``` - [ ] **Step 2: Commit** ```bash git add server/src/lib/notifications/templates/telegram-templates.js git commit -m "feat: add Telegram message templates" ``` --- ### Task 6: Email Channel **Files:** - Modify: `server/src/lib/email.js` - Create: `server/src/lib/notifications/channels/email-channel.js` - [ ] **Step 1: Extend email.js with sendNotificationEmail** Replace the entire content of `server/src/lib/email.js`: ```js import nodemailer from 'nodemailer' function hasSmtpEnv() { return Boolean(process.env.SMTP_HOST && process.env.SMTP_PORT && process.env.SMTP_USER && process.env.SMTP_PASS) } function createTransporter() { return nodemailer.createTransport({ host: process.env.SMTP_HOST, port: Number(process.env.SMTP_PORT), secure: process.env.SMTP_SECURE === 'true', auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS, }, }) } export async function sendLoginCodeEmail({ to, code }) { if (!hasSmtpEnv()) { console.log(`[DEV] login code for ${to}: ${code}`) return } const transporter = createTransporter() const from = process.env.MAIL_FROM || process.env.SMTP_USER await transporter.sendMail({ from, to, subject: 'Код входа', text: `Ваш код: ${code}\n\nЕсли это были не вы — просто проигнорируйте письмо.`, }) } export async function sendNotificationEmail({ to, subject, html }) { if (!hasSmtpEnv()) { console.log(`[DEV] notification email to ${to}: ${subject}`) return { success: true } } try { const transporter = createTransporter() const from = process.env.MAIL_FROM || process.env.SMTP_USER await transporter.sendMail({ from, to, subject, html, }) return { success: true } } catch (err) { return { success: false, error: err.message } } } ``` - [ ] **Step 2: Create the email channel adapter** ```js // server/src/lib/notifications/channels/email-channel.js import { sendNotificationEmail } from '../../email.js' import { renderOrderCreatedEmail, renderOrderStatusChangedEmail, renderOrderMessageEmail, renderPaymentStatusChangedEmail, renderAdminOrderCreatedEmail, renderAdminNewReviewEmail, renderAuthCodeEmail, } from '../templates/email-templates.js' const templateRenderers = { 'order:created': renderOrderCreatedEmail, 'order:statusChanged': renderOrderStatusChangedEmail, 'orderMessage:adminReply': renderOrderMessageEmail, 'payment:statusChanged': renderPaymentStatusChangedEmail, 'order:created:admin': renderAdminOrderCreatedEmail, 'orderMessage:sent': renderOrderMessageEmail, 'review:created': renderAdminNewReviewEmail, 'auth:codeRequested': renderAuthCodeEmail, } export const emailChannel = { name: 'email', async send({ recipient, eventType, payload }) { const renderer = templateRenderers[eventType] if (!renderer) { return { success: false, error: `No email template for event: ${eventType}` } } const { subject, html } = renderer(payload) const result = await sendNotificationEmail({ to: recipient, subject, html }) return result }, } ``` - [ ] **Step 3: Commit** ```bash git add server/src/lib/email.js server/src/lib/notifications/channels/email-channel.js git commit -m "feat: add email notification channel" ``` --- ### Task 7: Telegram Channel **Files:** - Create: `server/src/lib/notifications/channels/telegram-channel.js` - [ ] **Step 1: Create the Telegram channel adapter** ```js // server/src/lib/notifications/channels/telegram-channel.js import { renderOrderCreatedTg, renderOrderStatusChangedTg, renderOrderMessageTg, renderPaymentStatusChangedTg, renderAdminOrderCreatedTg, renderAdminNewReviewTg, renderAuthCodeTg, } from '../templates/telegram-templates.js' const TELEGRAM_BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN || '' const templateRenderers = { 'order:created': renderOrderCreatedTg, 'order:statusChanged': renderOrderStatusChangedTg, 'orderMessage:adminReply': renderOrderMessageTg, 'payment:statusChanged': renderPaymentStatusChangedTg, 'order:created:admin': renderAdminOrderCreatedTg, 'orderMessage:sent': renderOrderMessageTg, 'review:created': renderAdminNewReviewTg, 'auth:codeRequested': renderAuthCodeTg, } async function postToTelegram(chatId, text) { if (!TELEGRAM_BOT_TOKEN) { console.log(`[DEV] telegram to ${chatId}: ${text.slice(0, 80)}`) return { success: true } } try { const res = await fetch(`https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chat_id: chatId, text, parse_mode: 'HTML', }), }) const data = await res.json() if (!data.ok) { return { success: false, error: data.description || 'Telegram API error' } } return { success: true } } catch (err) { return { success: false, error: err.message } } } export const telegramChannel = { name: 'telegram', async send({ recipient: chatId, eventType, payload }) { if (!chatId) { return { success: false, error: 'No telegram chatId' } } const renderer = templateRenderers[eventType] if (!renderer) { return { success: false, error: `No telegram template for event: ${eventType}` } } const text = renderer(payload) return postToTelegram(chatId, text) }, } ``` - [ ] **Step 2: Commit** ```bash git add server/src/lib/notifications/channels/telegram-channel.js git commit -m "feat: add Telegram notification channel" ``` --- ### Task 8: Preferences Resolver **Files:** - Create: `server/src/lib/notifications/preferences.js` - [ ] **Step 1: Create the preferences module** ```js // server/src/lib/notifications/preferences.js import { prisma } from '../prisma.js' import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js' const { ORDER_CREATED, ORDER_STATUS_CHANGED, ORDER_MESSAGE_SENT, ORDER_MESSAGE_ADMIN_REPLY, PAYMENT_STATUS_CHANGED, AUTH_CODE_REQUESTED, } = NOTIFICATION_EVENTS const userEventFieldMap = { [ORDER_CREATED]: 'orderCreated', [ORDER_STATUS_CHANGED]: 'orderStatusChanged', [ORDER_MESSAGE_ADMIN_REPLY]: 'orderMessageReceived', [PAYMENT_STATUS_CHANGED]: 'paymentStatusChanged', } const adminEventFieldMap = { [ORDER_CREATED]: 'newOrder', [ORDER_MESSAGE_SENT]: 'newOrderMessage', 'review:created': 'newReview', } export async function resolveUserNotificationTargets(eventType, payload) { const targets = [] if (payload.userId) { const prefs = await prisma.notificationPreference.findUnique({ where: { userId: payload.userId }, }) if (prefs && prefs.globalEnabled) { const field = userEventFieldMap[eventType] if (field && prefs[field]) { const user = await prisma.user.findUnique({ where: { id: payload.userId }, select: { email: true } }) if (user) { targets.push({ channel: 'email', recipient: user.email }) } } } } return targets } export async function resolveAdminNotificationTargets(eventType, payload) { const targets = [] const settings = await prisma.adminNotificationSettings.findFirst() if (!settings) return targets const field = adminEventFieldMap[eventType] if (field === 'newReview') { if (!settings.newReview) return targets } else if (field && !settings[field]) { return targets } if (settings.emailEnabled) { const admin = await prisma.user.findFirst({ where: { email: process.env.ADMIN_EMAIL }, select: { email: true }, }) if (admin) { targets.push({ channel: 'email', recipient: admin.email }) } } if (settings.telegramEnabled && settings.telegramChatId) { targets.push({ channel: 'telegram', recipient: settings.telegramChatId }) } return targets } export async function resolveAuthCodeTargets(eventType, payload) { const targets = [] // User always gets email if (payload.email) { targets.push({ channel: 'email', recipient: payload.email }) } // Admin gets telegram duplicate if enabled if (payload.isAdmin) { const settings = await prisma.adminNotificationSettings.findFirst() if (settings && settings.telegramEnabled && settings.telegramChatId && settings.authCodeDuplicate) { targets.push({ channel: 'telegram', recipient: settings.telegramChatId }) } } return targets } export async function ensureUserNotificationPreference(userId) { const existing = await prisma.notificationPreference.findUnique({ where: { userId } }) if (existing) return existing return prisma.notificationPreference.create({ data: { userId, globalEnabled: true }, }) } ``` - [ ] **Step 2: Commit** ```bash git add server/src/lib/notifications/preferences.js git commit -m "feat: add notification preferences resolver" ``` --- ### Task 9: Queue + Worker **Files:** - Create: `server/src/lib/notifications/queue.js` - [ ] **Step 1: Create the queue module** ```js // server/src/lib/notifications/queue.js import { prisma } from '../prisma.js' import { NOTIFICATION_STATUSES, MAX_RETRY_ATTEMPTS, RETRY_DELAYS_MS } from '../../../shared/constants/notification-events.js' import { emailChannel } from './channels/email-channel.js' import { telegramChannel } from './channels/telegram-channel.js' const { PENDING, SENT, FAILED } = NOTIFICATION_STATUSES const channels = { email: emailChannel, telegram: telegramChannel, } class NotificationQueue { constructor() { this.tasks = [] this.processing = 0 this.maxConcurrent = 5 this.intervalMs = 2000 this.running = false } enqueue(task) { this.tasks.push({ ...task, enqueuedAt: Date.now() }) } start() { if (this.running) return this.running = true this._tick() } stop() { this.running = false } _tick() { if (!this.running) return this._processAvailable() setTimeout(() => this._tick(), this.intervalMs) } _processAvailable() { while (this.tasks.length > 0 && this.processing < this.maxConcurrent) { const task = this.tasks.shift() this.processing++ this._execute(task).finally(() => { this.processing-- }) } } async _execute(task) { const channel = channels[task.channel] if (!channel) { await this._markFailed(task.logId, `Unknown channel: ${task.channel}`) return } try { const result = await channel.send({ recipient: task.recipient, eventType: task.eventType, payload: task.payload, }) if (result.success) { await this._markSent(task.logId) } else { await this._handleFailure(task.logId, task, result.error) } } catch (err) { await this._handleFailure(task.logId, task, err.message) } } async _markSent(logId) { await prisma.notificationLog.update({ where: { id: logId }, data: { status: SENT }, }) } async _markFailed(logId, error) { await prisma.notificationLog.update({ where: { id: logId }, data: { status: FAILED, error }, }) } async _handleFailure(logId, task, error) { const log = await prisma.notificationLog.findUnique({ where: { id: logId } }) const newAttempts = (log?.attempts || 0) + 1 if (newAttempts >= MAX_RETRY_ATTEMPTS) { await this._markFailed(logId, error) return } await prisma.notificationLog.update({ where: { id: logId }, data: { attempts: newAttempts }, }) const delay = RETRY_DELAYS_MS[newAttempts - 1] || RETRY_DELAYS_MS[RETRY_DELAYS_MS.length - 1] setTimeout(() => { this.enqueue({ ...task, logId }) }, delay) } async flushPendingOnStartup() { const pending = await prisma.notificationLog.findMany({ where: { status: PENDING }, }) for (const log of pending) { await prisma.notificationLog.update({ where: { id: log.id }, data: { status: FAILED, error: 'Server restarted, pending notification lost' }, }) } if (pending.length > 0) { console.log(`[notifications] Marked ${pending.length} pending notifications as failed on startup`) } } } export function createNotificationQueue() { return new NotificationQueue() } ``` - [ ] **Step 2: Commit** ```bash git add server/src/lib/notifications/queue.js git commit -m "feat: add notification queue with retry worker" ``` --- ### Task 10: Wire up EventBus in server index and register routes **Files:** - Modify: `server/src/index.js` - Modify: `server/src/routes/api.js` - Create: `server/src/routes/api/admin/notifications.js` - Create: `server/src/routes/user/notifications.js` - [ ] **Step 1: Create admin notification settings API route** ```js // server/src/routes/api/admin/notifications.js 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 } }, ) // Telegram webhook handler for /start command 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 }, }) } // Send confirmation back to user via Telegram 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 } }, ) } ``` - [ ] **Step 2: Create user notification settings API route** ```js // server/src/routes/user/notifications.js 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 } }, ) } ``` - [ ] **Step 3: Modify index.js to initialize notifications** Replace `server/src/index.js` content: ```js import 'dotenv/config' import Fastify from 'fastify' import cors from '@fastify/cors' import jwt from '@fastify/jwt' import multipart from '@fastify/multipart' import fastifyStatic from '@fastify/static' 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' 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 { registerUserNotificationRoutes } from './routes/user/notifications.js' import { registerOAuthSocialRoutes } from './routes/oauth-social.js' import { registerUploadsResized } from './routes/uploads-resized.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(), }) await fastify.register(cors, { origin: origin.length ? origin : true, credentials: true, }) 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 { await request.jwtVerify() } catch { return reply.code(401).send({ error: 'Не авторизован' }) } }) // Initialize notification system const eventBus = createEventBus() const notificationQueue = createNotificationQueue() fastify.decorate('eventBus', eventBus) fastify.decorate('notificationQueue', notificationQueue) registerAuth(fastify) await registerAuthRoutes(fastify) await registerUserAddressRoutes(fastify) 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() // Flush stale pending notifications and start queue await notificationQueue.flushPendingOnStartup() notificationQueue.start() // Register notification event listeners 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) { // User-targeted notifications 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 }) } // Admin notifications (for order:created:admin, orderMessage:sent, review:created) 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) // Graceful shutdown 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) } ``` - [ ] **Step 4: Register admin notification routes in api.js** Add to `server/src/routes/api.js`: Import at top: ```js import { registerAdminNotificationRoutes } from './api/admin/notifications.js' ``` Inside `registerApiRoutes`, add after `await registerAdminUserRoutes(fastify)`: ```js await registerAdminNotificationRoutes(fastify) ``` - [ ] **Step 5: Commit** ```bash git add server/src/index.js server/src/routes/api.js server/src/routes/api/admin/notifications.js server/src/routes/user/notifications.js git commit -m "feat: wire up notification system in server" ``` --- ### Task 11: Emit events from existing routes **Files:** - Modify: `server/src/routes/user-orders.js` - Modify: `server/src/routes/api/admin-orders.js` - Modify: `server/src/routes/user-messages.js` - Modify: `server/src/routes/user-payments.js` - Modify: `server/src/routes/auth.js` - Modify: `server/src/lib/auth.js` - [ ] **Step 1: Emit `order:created` in user-orders.js** In `server/src/routes/user-orders.js`, add import at top: ```js import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js' ``` After the `return reply.code(201).send({ orderId: created.id })` line (line 159), before the return, add event emission. Replace line 159: ```js // Emit notification event request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_CREATED, { orderId: created.id, userId, totalCents: created.totalCents, itemsCount: cartItems.length, }) // Also emit admin notification request.server.eventBus.emit('order:created:admin', { orderId: created.id, userId, userEmail: request.user.email || '', totalCents: created.totalCents, itemsCount: cartItems.length, }) return reply.code(201).send({ orderId: created.id }) ``` - [ ] **Step 2: Emit `order:statusChanged` in admin-orders.js** In `server/src/routes/api/admin-orders.js`, add import: ```js import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js' ``` After the status update (line 110-111), replace the return: ```js const updated = await prisma.order.update({ where: { id }, data: { status: next } }) request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_STATUS_CHANGED, { orderId: updated.id, userId: existing.userId, oldStatus: existing.status, newStatus: next, }) return { item: updated } ``` - [ ] **Step 3: Emit `orderMessage:adminReply` in admin-orders.js** In the `POST /api/admin/orders/:id/messages` handler, after creating the message (line 158), replace the return: ```js const msg = await prisma.orderMessage.create({ data: { orderId: id, authorType: 'admin', text } }) request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_MESSAGE_ADMIN_REPLY, { orderId: id, userId: order.userId, messageId: msg.id, preview: text, }) return reply.code(201).send({ item: msg }) ``` - [ ] **Step 4: Emit `orderMessage:sent` in user-messages.js** In `server/src/routes/user-messages.js`, add import: ```js import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js' ``` In the `POST /api/me/orders/:id/messages` handler, after creating the message (line 28), replace the return: ```js const msg = await prisma.orderMessage.create({ data: { orderId: id, authorType: 'user', text } }) request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_MESSAGE_SENT, { orderId: id, authorType: 'user', messageId: msg.id, preview: text, }) return reply.code(201).send({ item: msg }) ``` - [ ] **Step 5: Wire up event listeners in index.js** Already done in Task 10 Step 3 — the event listeners are registered in `index.js` after `notificationQueue.start()`. No additional changes needed here. - [ ] **Step 6: Emit `review:created` in admin-reviews.js** In `server/src/routes/api/admin-reviews.js`, add import: ```js import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js' ``` In the `PATCH /api/admin/reviews/:id` handler, after the review is updated (after line 54), before the return: ```js const updated = await prisma.review.update({ where: { id }, data: { status: action === 'approve' ? 'approved' : 'rejected', moderatedAt: new Date(), }, }) request.server.eventBus.emit('review:created', { rating: updated.rating, text: updated.text || '', productTitle: existing.product?.title || '', userName: existing.user?.name || existing.user?.email || '', reviewId: updated.id, }) return { item: updated } ``` Note: The existing query doesn't include product and user relations. We need to fetch them. Replace the existing review fetch: ```js const existing = await prisma.review.findUnique({ where: { id }, include: { product: { select: { title: true } }, user: { select: { name: true, email: true } } }, }) ``` In `server/src/lib/auth.js`, modify `issueEmailCode` to accept and use the eventBus. Since `issueEmailCode` is called from routes that have access to `request`, we need a different approach. Instead, emit the event in the route handler. In `server/src/routes/auth.js`, add import: ```js import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js' ``` In `POST /api/auth/request-code`, after `await issueEmailCode(...)`, add: ```js await issueEmailCode({ email, purpose: 'login' }) // Check if this is admin const adminEmail = process.env.ADMIN_EMAIL?.trim().toLowerCase() const isAdmin = email === adminEmail request.server.eventBus.emit(NOTIFICATION_EVENTS.AUTH_CODE_REQUESTED, { email, code: 'emitted-via-issueEmailCode', // code is not available here, handled separately isAdmin, }) return { ok: true } ``` Actually, the code is generated inside `issueEmailCode`. We need to refactor `issueEmailCode` to return the code. Modify `server/src/lib/auth.js`: Replace `issueEmailCode`: ```js export async function issueEmailCode({ email, purpose, userId = null }) { const code = randomCode6() const expiresAt = new Date(Date.now() + 10 * 60 * 1000) await prisma.authCode.create({ data: { email, purpose, userId, codeHash: sha256(`${email}:${purpose}:${code}:${userId ?? ''}`), expiresAt, }, }) await sendLoginCodeEmail({ to: email, code }) return code } ``` Then in `server/src/routes/auth.js`, the `request-code` handler: ```js fastify.post('/api/auth/request-code', async (request, reply) => { const email = normalizeEmail(request.body?.email) if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) const code = await issueEmailCode({ email, purpose: 'login' }) const adminEmail = process.env.ADMIN_EMAIL?.trim().toLowerCase() const isAdmin = email === adminEmail request.server.eventBus.emit(NOTIFICATION_EVENTS.AUTH_CODE_REQUESTED, { email, code, isAdmin, }) return { ok: true } }) ``` - [ ] **Step 7: Emit `auth:codeRequested` in auth.js** In `server/src/routes/auth.js`, add import: ```js import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js' ``` In `POST /api/auth/request-code`, after `await issueEmailCode(...)`, replace the return: ```js const code = await issueEmailCode({ email, purpose: 'login' }) const adminEmail = process.env.ADMIN_EMAIL?.trim().toLowerCase() const isAdmin = email === adminEmail request.server.eventBus.emit(NOTIFICATION_EVENTS.AUTH_CODE_REQUESTED, { email, code, isAdmin, }) return { ok: true } ``` - [ ] **Step 8: Emit `payment:statusChanged` in user-payments.js** In `server/src/routes/user-payments.js`, add import: ```js import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js' ``` After the payment message is created (after the `prisma.$transaction` block, before `return { ok: true }`), add: ```js request.server.eventBus.emit(NOTIFICATION_EVENTS.PAYMENT_STATUS_CHANGED, { orderId: id, userId, paymentStatus: 'pending', }) ``` - [ ] **Step 9: Create user notification preference on user creation** In `server/src/routes/auth.js`, in `POST /api/auth/verify-code`, after user upsert (line 34-38), add: ```js const user = await prisma.user.upsert({ where: { email }, update: {}, create: { email }, }) // Ensure notification preference exists await prisma.notificationPreference.upsert({ where: { userId: user.id }, create: { userId: user.id, globalEnabled: true }, update: {}, }) ``` Add import at top: ```js import { prisma } from '../lib/prisma.js' ``` (Prisma is already imported in auth.js, check — yes it is.) - [ ] **Step 10: Commit** ```bash git add server/src/routes/user-orders.js server/src/routes/api/admin-orders.js server/src/routes/user-messages.js server/src/routes/user-payments.js server/src/routes/auth.js server/src/routes/api/admin-reviews.js server/src/lib/auth.js git commit -m "feat: emit notification events from existing routes" ``` --- ### Task 12: Update bootstrap-admin to create AdminNotificationSettings **Files:** - Modify: `server/src/lib/bootstrap-admin.js` - [ ] **Step 1: Add AdminNotificationSettings creation** Replace `server/src/lib/bootstrap-admin.js`: ```js import { normalizeEmail } from './auth.js' import { prisma } from './prisma.js' export async function ensureAdminUser() { const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL) if (!adminEmail) return if (!adminEmail.includes('@')) { throw new Error('ADMIN_EMAIL должен быть валидным email') } const admin = await prisma.user.upsert({ where: { email: adminEmail }, update: {}, create: { email: adminEmail }, }) // Ensure admin notification settings exist const existing = await prisma.adminNotificationSettings.findFirst() if (!existing) { await prisma.adminNotificationSettings.create({ data: { emailEnabled: true, telegramEnabled: false, newOrder: true, newOrderMessage: true, newReview: true, authCodeDuplicate: false, }, }) } } ``` - [ ] **Step 2: Commit** ```bash git add server/src/lib/bootstrap-admin.js git commit -m "feat: create admin notification settings on bootstrap" ``` --- ### Task 13: Server tests **Files:** - Create: `server/src/lib/notifications/__tests__/preferences.test.js` - Create: `server/src/lib/notifications/__tests__/queue.test.js` - [ ] **Step 1: Create preferences test** ```js // server/src/lib/notifications/__tests__/preferences.test.js import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { prisma } from '../../prisma.js' import { resolveUserNotificationTargets, resolveAdminNotificationTargets, resolveAuthCodeTargets, ensureUserNotificationPreference, } from '../preferences.js' import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js' describe('preferences', () => { beforeEach(async () => { await prisma.notificationPreference.deleteMany() await prisma.adminNotificationSettings.deleteMany() await prisma.user.deleteMany() }) afterEach(async () => { await prisma.notificationPreference.deleteMany() await prisma.adminNotificationSettings.deleteMany() await prisma.user.deleteMany() }) it('returns empty targets when user has no preferences', async () => { const user = await prisma.user.create({ data: { email: 'test@test.com' } }) const targets = await resolveUserNotificationTargets(NOTIFICATION_EVENTS.ORDER_CREATED, { userId: user.id }) expect(targets).toEqual([]) }) it('returns email target when user has preferences enabled', async () => { const user = await prisma.user.create({ data: { email: 'test@test.com' } }) await prisma.notificationPreference.create({ data: { userId: user.id, globalEnabled: true, orderCreated: true }, }) const targets = await resolveUserNotificationTargets(NOTIFICATION_EVENTS.ORDER_CREATED, { userId: user.id }) expect(targets).toHaveLength(1) expect(targets[0]).toEqual({ channel: 'email', recipient: 'test@test.com' }) }) it('returns no targets when globalEnabled is false', async () => { const user = await prisma.user.create({ data: { email: 'test@test.com' } }) await prisma.notificationPreference.create({ data: { userId: user.id, globalEnabled: false, orderCreated: true }, }) const targets = await resolveUserNotificationTargets(NOTIFICATION_EVENTS.ORDER_CREATED, { userId: user.id }) expect(targets).toEqual([]) }) it('returns no targets when specific event is disabled', async () => { const user = await prisma.user.create({ data: { email: 'test@test.com' } }) await prisma.notificationPreference.create({ data: { userId: user.id, globalEnabled: true, orderCreated: false }, }) const targets = await resolveUserNotificationTargets(NOTIFICATION_EVENTS.ORDER_CREATED, { userId: user.id }) expect(targets).toEqual([]) }) it('ensures user preference is created if not exists', async () => { const user = await prisma.user.create({ data: { email: 'test@test.com' } }) const prefs = await ensureUserNotificationPreference(user.id) expect(prefs.globalEnabled).toBe(true) expect(prefs.userId).toBe(user.id) }) it('returns admin targets when settings enabled', async () => { const admin = await prisma.user.create({ data: { email: 'admin@test.com' } }) const origAdminEmail = process.env.ADMIN_EMAIL process.env.ADMIN_EMAIL = 'admin@test.com' await prisma.adminNotificationSettings.create({ data: { emailEnabled: true, newOrder: true }, }) const targets = await resolveAdminNotificationTargets(NOTIFICATION_EVENTS.ORDER_CREATED, {}) expect(targets.some((t) => t.channel === 'email' && t.recipient === 'admin@test.com')).toBe(true) process.env.ADMIN_EMAIL = origAdminEmail }) it('resolveAuthCodeTargets returns email for user and telegram for admin', async () => { await prisma.adminNotificationSettings.create({ data: { telegramEnabled: true, telegramChatId: '12345', authCodeDuplicate: true }, }) const targets = await resolveAuthCodeTargets(NOTIFICATION_EVENTS.AUTH_CODE_REQUESTED, { email: 'user@test.com', code: '123456', isAdmin: true, }) expect(targets.some((t) => t.channel === 'email' && t.recipient === 'user@test.com')).toBe(true) expect(targets.some((t) => t.channel === 'telegram' && t.recipient === '12345')).toBe(true) }) }) ``` - [ ] **Step 2: Run server tests** ```bash cd server && npm test -- --run src/lib/notifications/__tests__/preferences.test.js ``` Expected: All tests pass. - [ ] **Step 3: Commit** ```bash git add server/src/lib/notifications/__tests__/preferences.test.js git commit -m "test: add notification preferences tests" ``` --- ### Task 14: Client — API layer **Files:** - Create: `client/src/entities/notification/api/notifications-api.ts` - [ ] **Step 1: Create the notifications API client** ```ts // client/src/entities/notification/api/notifications-api.ts import { apiClient } from '@/shared/api/client' export interface UserNotificationSettings { id: string userId: string globalEnabled: boolean orderCreated: boolean orderStatusChanged: boolean orderMessageReceived: boolean paymentStatusChanged: boolean createdAt: string updatedAt: string } export interface AdminNotificationSettings { id: string emailEnabled: boolean telegramEnabled: boolean telegramChatId: string | null newOrder: boolean newOrderMessage: boolean newReview: boolean authCodeDuplicate: boolean createdAt: string updatedAt: string } export async function fetchUserNotificationSettings(): Promise<{ settings: UserNotificationSettings }> { const { data } = await apiClient.get('/api/me/notifications/settings') return data } export async function updateUserNotificationSettings( settings: Partial