66 KiB
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):
/// Настройки оповещений пользователя
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:
notificationPreferences NotificationPreference?
- Step 2: Run the migration
Run:
cd server && npx prisma migrate dev --name add_notification_system
Expected: Migration created and applied successfully.
- Step 3: Commit
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
// 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
// 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
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
// 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
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
// server/src/lib/notifications/templates/email-templates.js
function baseLayout(title, body) {
return `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>${title}</title></head>
<body style="font-family:system-ui,sans-serif;max-width:600px;margin:0 auto;padding:20px;color:#1a1a1a;">
<div style="background:#f8f9fa;padding:16px;border-radius:8px;margin-bottom:16px;">
<h2 style="margin:0;">${title}</h2>
</div>
${body}
<div style="margin-top:24px;padding-top:16px;border-top:1px solid #e0e0e0;color:#666;font-size:14px;">
<p>Craftshop — магазин handmade изделий</p>
</div>
</body>
</html>`
}
export function renderOrderCreatedEmail({ orderId, totalCents, itemsCount }) {
const total = (totalCents / 100).toLocaleString('ru-RU')
const body = `
<p>Ваш заказ <b>#${orderId.slice(0, 8)}</b> успешно создан.</p>
<p>Товаров: ${itemsCount} | Сумма: <b>${total} ₽</b></p>
<p>Мы сообщим вам об изменениях статуса.</p>
`
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 = `
<p>Статус заказа <b>#${orderId.slice(0, 8)}</b> изменён.</p>
<p><b>${oldLabel}</b> → <b>${newLabel}</b></p>
`
return { subject: `Статус заказа изменён — ${newLabel}`, html: baseLayout('Статус заказа изменён', body) }
}
export function renderOrderMessageEmail({ orderId, preview }) {
const truncated = preview.length > 200 ? preview.slice(0, 197) + '...' : preview
const body = `
<p>Новое сообщение к заказу <b>#${orderId.slice(0, 8)}</b>:</p>
<div style="background:#f0f0f0;padding:12px;border-radius:6px;margin:12px 0;">
${truncated}
</div>
<p>Ответьте в личном кабинете.</p>
`
return { subject: 'Новое сообщение к заказу', html: baseLayout('Новое сообщение', body) }
}
export function renderPaymentStatusChangedEmail({ orderId, paymentStatus }) {
const statusLabels = {
pending: 'Ожидает',
confirmed: 'Подтверждён',
rejected: 'Отклонён',
}
const label = statusLabels[paymentStatus] || paymentStatus
const body = `
<p>Статус оплаты заказа <b>#${orderId.slice(0, 8)}</b>: <b>${label}</b>.</p>
`
return { subject: `Оплата заказа — ${label}`, html: baseLayout('Оплата заказа', body) }
}
export function renderAdminOrderCreatedEmail({ orderId, userEmail, totalCents, itemsCount }) {
const total = (totalCents / 100).toLocaleString('ru-RU')
const body = `
<p>Новый заказ <b>#${orderId.slice(0, 8)}</b> от <b>${userEmail}</b>.</p>
<p>Товаров: ${itemsCount} | Сумма: <b>${total} ₽</b></p>
`
return { subject: 'Новый заказ', html: baseLayout('Новый заказ', body) }
}
export function renderAdminNewReviewEmail({ rating, text, productTitle, userName }) {
const stars = '★'.repeat(rating) + '☆'.repeat(5 - rating)
const body = `
<p>Новый отзыв ${stars} на товар <b>${productTitle}</b> от <b>${userName}</b>.</p>
${text ? `<div style="background:#f0f0f0;padding:12px;border-radius:6px;margin:12px 0;">${text}</div>` : ''}
<p>Проверьте отзыв в админ-панели.</p>
`
return { subject: 'Новый отзыв', html: baseLayout('Новый отзыв', body) }
}
export function renderAuthCodeEmail({ code }) {
const body = `
<p>Ваш код входа: <b style="font-size:24px;letter-spacing:4px;">${code}</b></p>
<p>Если это были не вы — просто проигнорируйте письмо.</p>
`
return { subject: 'Код входа', html: baseLayout('Код входа', body) }
}
- Step 2: Commit
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
// server/src/lib/notifications/templates/telegram-templates.js
export function renderOrderCreatedTg({ orderId, totalCents, itemsCount }) {
const total = (totalCents / 100).toLocaleString('ru-RU')
return `📦 <b>Новый заказ</b> #${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} → <b>${labels[newStatus] || newStatus}</b>`
}
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)}: <b>${labels[paymentStatus] || paymentStatus}</b>`
}
export function renderAdminOrderCreatedTg({ orderId, userEmail, totalCents, itemsCount }) {
const total = (totalCents / 100).toLocaleString('ru-RU')
return `🛒 <b>Новый заказ</b> #${orderId.slice(0, 8)}\nОт: ${userEmail}\nТоваров: ${itemsCount} | Сумма: ${total} ₽`
}
export function renderAdminNewReviewTg({ rating, text, productTitle, userName }) {
const stars = '⭐'.repeat(rating)
return `📝 <b>Новый отзыв</b> ${stars}\nТовар: ${productTitle}\nАвтор: ${userName}${text ? '\n\n' + text : ''}`
}
export function renderAuthCodeTg({ code }) {
return `🔐 Код входа: <b>${code}</b>`
}
- Step 2: Commit
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:
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
// 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
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
// 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
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
// 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
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
// 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
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
// 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
// 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:
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:
import { registerAdminNotificationRoutes } from './api/admin/notifications.js'
Inside registerApiRoutes, add after await registerAdminUserRoutes(fastify):
await registerAdminNotificationRoutes(fastify)
- Step 5: Commit
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:createdin user-orders.js
In server/src/routes/user-orders.js, add import at top:
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:
// 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:statusChangedin admin-orders.js
In server/src/routes/api/admin-orders.js, add import:
import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js'
After the status update (line 110-111), replace the return:
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:adminReplyin admin-orders.js
In the POST /api/admin/orders/:id/messages handler, after creating the message (line 158), replace the return:
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:sentin user-messages.js
In server/src/routes/user-messages.js, add import:
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:
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:createdin admin-reviews.js
In server/src/routes/api/admin-reviews.js, add import:
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:
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:
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:
import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js'
In POST /api/auth/request-code, after await issueEmailCode(...), add:
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:
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:
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:codeRequestedin auth.js
In server/src/routes/auth.js, add import:
import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js'
In POST /api/auth/request-code, after await issueEmailCode(...), replace the return:
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:statusChangedin user-payments.js
In server/src/routes/user-payments.js, add import:
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:
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:
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:
import { prisma } from '../lib/prisma.js'
(Prisma is already imported in auth.js, check — yes it is.)
- Step 10: Commit
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:
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
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
// 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
cd server && npm test -- --run src/lib/notifications/__tests__/preferences.test.js
Expected: All tests pass.
- Step 3: Commit
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
// 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<UserNotificationSettings>,
): Promise<{ settings: UserNotificationSettings }> {
const { data } = await apiClient.put('/api/me/notifications/settings', settings)
return data
}
export async function fetchAdminNotificationSettings(): Promise<{ settings: AdminNotificationSettings }> {
const { data } = await apiClient.get('/api/admin/notifications/settings')
return data
}
export async function updateAdminNotificationSettings(
settings: Partial<AdminNotificationSettings>,
): Promise<{ settings: AdminNotificationSettings }> {
const { data } = await apiClient.put('/api/admin/notifications/settings', settings)
return data
}
- Step 2: Commit
git add client/src/entities/notification/api/notifications-api.ts
git commit -m "feat: add notification API client functions"
Task 15: Client — User notification settings page
Files:
-
Create:
client/src/pages/me/ui/sections/NotificationsPage.tsx -
Modify:
client/src/pages/me/ui/MeLayoutPage.tsx -
Step 1: Create the NotificationsPage component
// client/src/pages/me/ui/sections/NotificationsPage.tsx
import { useState } from 'react'
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import FormControlLabel from '@mui/material/FormControlLabel'
import Switch from '@mui/material/Switch'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
fetchUserNotificationSettings,
updateUserNotificationSettings,
} from '@/entities/notification/api/notifications-api'
const eventFields = [
{ key: 'orderCreated' as const, label: 'Заказ создан' },
{ key: 'orderStatusChanged' as const, label: 'Изменение статуса заказа' },
{ key: 'orderMessageReceived' as const, label: 'Сообщение в чате заказа' },
{ key: 'paymentStatusChanged' as const, label: 'Изменение статуса оплаты' },
]
export function NotificationsPage() {
const queryClient = useQueryClient()
const [error, setError] = useState<string | null>(null)
const { data, isLoading } = useQuery({
queryKey: ['me', 'notifications', 'settings'],
queryFn: fetchUserNotificationSettings,
})
const mutation = useMutation({
mutationFn: updateUserNotificationSettings,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['me', 'notifications', 'settings'] })
},
onError: (err: { response?: { data?: { error?: string } } }) => {
setError(err.response?.data?.error || 'Ошибка сохранения')
},
})
if (isLoading) return <Typography>Загрузка...</Typography>
const settings = data?.settings
if (!settings) return <Alert severity="error">Не удалось загрузить настройки</Alert>
const handleToggle = (field: string, value: boolean) => {
setError(null)
mutation.mutate({ [field]: value } as Record<string, boolean>)
}
return (
<Box>
<Typography variant="h4" gutterBottom>
Оповещения
</Typography>
<Typography color="text.secondary" sx={{ mb: 3 }}>
Настройте, какие уведомления вы хотите получать на почту.
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<Stack spacing={3} sx={{ maxWidth: 480 }}>
<Box>
<FormControlLabel
control={
<Switch
checked={settings.globalEnabled}
onChange={(e) => handleToggle('globalEnabled', e.target.checked)}
/>
}
label={<Typography sx={{ fontWeight: 600 }}>Получать оповещения</Typography>}
/>
<Typography variant="body2" color="text.secondary" sx={{ ml: 4 }}>
Включите, чтобы получать уведомления о заказах на почту.
</Typography>
</Box>
<Box sx={{ pl: 4 }}>
{eventFields.map(({ key, label }) => (
<FormControlLabel
key={key}
control={
<Switch
checked={settings[key]}
disabled={!settings.globalEnabled}
onChange={(e) => handleToggle(key, e.target.checked)}
/>
}
label={label}
/>
))}
</Box>
</Stack>
</Box>
)
}
- Step 2: Add notifications route and nav item to MeLayoutPage
In client/src/pages/me/ui/MeLayoutPage.tsx:
Add import:
import { NotificationsPage } from '@/pages/me/ui/sections/NotificationsPage'
Add Bell icon import from lucide-react:
import { MapPin, MessageCircle, Settings, SlidersHorizontal, Truck, Bell } from 'lucide-react'
Add nav item to the navItems array:
{ to: '/me/notifications', label: 'Оповещения', icon: <Bell /> },
Add route in the <Routes> block:
<Route path="notifications" element={<NotificationsPage />} />
Place it after the settings route and before the * catch-all.
- Step 3: Commit
git add client/src/pages/me/ui/sections/NotificationsPage.tsx client/src/pages/me/ui/MeLayoutPage.tsx
git commit -m "feat: add user notification settings page"
Task 16: Client — Admin notification settings
Files:
-
Create:
client/src/pages/admin-layout/ui/AdminNotificationsPage.tsx -
Modify:
client/src/pages/admin-layout/index.ts(or wherever admin routes are defined) -
Step 1: Explore admin layout structure
First, read the admin layout files to understand the routing pattern:
# Read the admin layout entry point
cat client/src/pages/admin-layout/index.ts
# Read the admin layout UI
ls client/src/pages/admin-layout/ui/
Based on the existing pattern, create the admin notifications page.
- Step 2: Create AdminNotificationsPage
// client/src/pages/admin-layout/ui/AdminNotificationsPage.tsx
import { useState } from 'react'
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import FormControlLabel from '@mui/material/FormControlLabel'
import Stack from '@mui/material/Stack'
import Switch from '@mui/material/Switch'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
fetchAdminNotificationSettings,
updateAdminNotificationSettings,
} from '@/entities/notification/api/notifications-api'
export function AdminNotificationsPage() {
const queryClient = useQueryClient()
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)
const { data, isLoading } = useQuery({
queryKey: ['admin', 'notifications', 'settings'],
queryFn: fetchAdminNotificationSettings,
})
const mutation = useMutation({
mutationFn: updateAdminNotificationSettings,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'notifications', 'settings'] })
setSuccess(true)
setTimeout(() => setSuccess(false), 3000)
},
onError: (err: { response?: { data?: { error?: string } } }) => {
setError(err.response?.data?.error || 'Ошибка сохранения')
},
})
if (isLoading) return <Typography>Загрузка...</Typography>
const s = data?.settings
if (!s) return <Alert severity="error">Не удалось загрузить настройки</Alert>
const save = (updates: Record<string, unknown>) => {
setError(null)
mutation.mutate(updates)
}
return (
<Box>
<Typography variant="h4" gutterBottom>
Оповещения
</Typography>
<Typography color="text.secondary" sx={{ mb: 3 }}>
Настройка оповещений администратора.
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{success && (
<Alert severity="success" sx={{ mb: 2 }}>
Настройки сохранены
</Alert>
)}
<Stack spacing={3} sx={{ maxWidth: 560 }}>
{/* Email */}
<Box>
<Typography variant="h6" gutterBottom>
Email
</Typography>
<FormControlLabel
control={
<Switch
checked={s.emailEnabled}
onChange={(e) => save({ emailEnabled: e.target.checked })}
/>
}
label="Получать уведомления на почту"
/>
</Box>
{/* Telegram */}
<Box>
<Typography variant="h6" gutterBottom>
Telegram
</Typography>
<FormControlLabel
control={
<Switch
checked={s.telegramEnabled}
onChange={(e) => save({ telegramEnabled: e.target.checked })}
/>
}
label="Получать уведомления в Telegram"
/>
{s.telegramEnabled && (
<Box sx={{ mt: 1, ml: 4 }}>
<TextField
label="Telegram Chat ID"
value={s.telegramChatId || ''}
onChange={(e) => save({ telegramChatId: e.target.value })}
helperText="Заполняется автоматически при /start бота"
fullWidth
size="small"
/>
</Box>
)}
</Box>
{/* Event types */}
<Box>
<Typography variant="h6" gutterBottom>
Типы уведомлений
</Typography>
<Stack spacing={1}>
<FormControlLabel
control={
<Switch
checked={s.newOrder}
onChange={(e) => save({ newOrder: e.target.checked })}
/>
}
label="Новый заказ"
/>
<FormControlLabel
control={
<Switch
checked={s.newOrderMessage}
onChange={(e) => save({ newOrderMessage: e.target.checked })}
/>
}
label="Сообщение в заказе"
/>
<FormControlLabel
control={
<Switch
checked={s.newReview}
onChange={(e) => save({ newReview: e.target.checked })}
/>
}
label="Новый отзыв"
/>
<FormControlLabel
control={
<Switch
checked={s.authCodeDuplicate}
onChange={(e) => save({ authCodeDuplicate: e.target.checked })}
/>
}
label="Дублировать код входа в Telegram"
/>
</Stack>
</Box>
</Stack>
</Box>
)
}
- Step 3: Add admin notifications route to AdminLayoutPage
In client/src/pages/admin-layout/ui/AdminLayoutPage.tsx:
Add import:
import { Bell } from 'lucide-react'
import { AdminNotificationsPage } from './AdminNotificationsPage'
Add nav item to the navItems array (after the 'info' item):
{ to: '/admin/notifications', label: 'Оповещения', icon: <Bell /> },
Add route in the <Routes> block (before the * catch-all):
<Route path="notifications" element={<AdminNotificationsPage />} />
- Step 4: Commit
git add client/src/pages/admin-layout/ui/AdminNotificationsPage.tsx
git commit -m "feat: add admin notification settings page"
Task 17: Client — run lint, typecheck, build
- Step 1: Run client lint
cd client && npm run lint
Fix any errors.
- Step 2: Run client build (typecheck)
cd client && npm run build
Fix any type errors.
- Step 3: Commit any fixes
git add -A
git commit -m "fix: resolve lint and type errors in notification system"
Task 18: Update .env.example
Files:
-
Modify:
server/.env.example -
Step 1: Add TELEGRAM_BOT_TOKEN to .env.example
Add to server/.env.example:
# Telegram Bot (optional — для оповещений админа)
TELEGRAM_BOT_TOKEN=
- Step 2: Commit
git add server/.env.example
git commit -m "docs: add TELEGRAM_BOT_TOKEN to env example"
Task 19: Final verification
- Step 1: Run server tests
cd server && npm test
Expected: All pass.
- Step 2: Run client tests
cd client && npm test
Expected: All pass.
- Step 3: Run client build
cd client && npm run build
Expected: Success.