2231 lines
66 KiB
Markdown
2231 lines
66 KiB
Markdown
# 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.
|