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

2231 lines
66 KiB
Markdown
Raw Blame History

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