Files
shop-server/docs/superpowers/plans/2026-05-18-notification-system.md
T
2026-05-18 11:45:51 +05:00

66 KiB
Raw Blame History

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:created in 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:statusChanged in 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:adminReply in 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:sent in 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:created in 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:codeRequested in 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:statusChanged in 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.