feat: improve notifications - fix auth code tg duplicate, double order notify, add PAID label, expand text, add deliveryFeeAdjusted event

This commit is contained in:
Kirill
2026-05-18 14:48:54 +05:00
parent 2f67c37502
commit d0b3c97803
17 changed files with 729 additions and 8 deletions
@@ -8,6 +8,7 @@ export interface UserNotificationSettings {
orderStatusChanged: boolean
orderMessageReceived: boolean
paymentStatusChanged: boolean
deliveryFeeAdjusted: boolean
createdAt: string
updatedAt: string
}
@@ -16,6 +16,7 @@ const eventFields = [
{ key: 'orderStatusChanged' as const, label: 'Изменение статуса заказа' },
{ key: 'orderMessageReceived' as const, label: 'Сообщение в чате заказа' },
{ key: 'paymentStatusChanged' as const, label: 'Изменение статуса оплаты' },
{ key: 'deliveryFeeAdjusted' as const, label: 'Корректировка стоимости доставки' },
]
export function NotificationsPage() {
@@ -0,0 +1,517 @@
# Улучшение системы оповещений — План реализации
> **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:** Исправить дублирование, добавить недостающие лейблы, расширить текст оповещений и починить авторизационный код в Telegram.
**Architecture:** Изменения затрагивают shared-константы, серверные шаблоны и роуты, а также клиентский UI. Новая миграция Prisma. Новое событие `order:deliveryFeeAdjusted`.
**Tech Stack:** Fastify + Prisma + SQLite, React + Vite + MUI, shared-константы.
**Build order (из AGENTS.md):**
```bash
cd server && npm run db:migrate # если schema.prisma изменён
cd server && npm test # серверные тесты сначала
cd client && npm run lint && npm run format:check && npm test # потом клиент
cd client && npm run build # полная проверка типов + сборка
```
---
### Task 1: Починить dispatchNotification для AUTH_CODE_REQUESTED
**Файлы:**
- Modify: `server/src/index.js`
- [ ] **Step 1: Изменить dispatchNotification для AUTH_CODE_REQUESTED**
В `server/src/index.js`, внутри `dispatchNotification`, добавить отдельную ветку для `AUTH_CODE_REQUESTED`, использующую `resolveAuthCodeTargets`:
```js
async function dispatchNotification(eventType, payload) {
if (eventType === AUTH_CODE_REQUESTED) {
const targets = await resolveAuthCodeTargets(eventType, payload);
for (const target of targets) {
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 });
}
return;
}
const userTargets = await resolveUserNotificationTargets(eventType, payload);
// ... остальной код без изменений
```
- [ ] **Step 2: Запустить серверные тесты**
Run: `cd server && npm test`
Expected: все тесты проходят.
- [ ] **Step 3: Проверить линтер**
Run: `cd server && npm run lint`
Expected: no errors (линтера на сервере может не быть, просто проверить что файл валидный JS)
---
### Task 2: Убрать дублирование new order для админа
**Файлы:**
- Modify: `server/src/lib/notifications/preferences.js`
- [ ] **Step 1: Удалить ORDER_CREATED из adminEventFieldMap**
```js
const adminEventFieldMap = {
[ORDER_MESSAGE_SENT]: 'newOrderMessage',
'review:created': 'newReview',
};
```
- [ ] **Step 2: Запустить тесты**
Run: `cd server && npm test`
---
### Task 3: Добавить PAID в лейблы статусов
**Файлы:**
- Modify: `server/src/lib/notifications/templates/telegram-templates.js`
- Modify: `server/src/lib/notifications/templates/email-templates.js`
- [ ] **Step 1: Добавить PAID в telegram-templates.js**
```js
export function renderOrderStatusChangedTg({ orderId, oldStatus, newStatus }) {
const labels = {
DRAFT: 'Черновик', PENDING_PAYMENT: 'Ожидает оплаты', PAID: 'Оплачен', IN_PROGRESS: 'В работе',
READY_FOR_PICKUP: 'Готов к выдаче', SHIPPED: 'Отправлен', DONE: 'Выполнен', CANCELLED: 'Отменён',
}
return `🔄 Заказ #${orderId.slice(0, 8)}\n${labels[oldStatus] || oldStatus} → <b>${labels[newStatus] || newStatus}</b>`
}
```
- [ ] **Step 2: Добавить PAID в email-templates.js**
```js
const statusLabels = {
DRAFT: "Черновик",
PENDING_PAYMENT: "Ожидает оплаты",
PAID: "Оплачен",
IN_PROGRESS: "В работе",
READY_FOR_PICKUP: "Готов к выдаче",
SHIPPED: "Отправлен",
DONE: "Выполнен",
CANCELLED: "Отменён",
};
```
- [ ] **Step 3: Запустить тесты**
Run: `cd server && npm test`
---
### Task 4: Добавить deliveryType в ORDER_CREATED payload и обновить шаблоны пользователя
**Файлы:**
- Modify: `server/src/routes/user-orders.js`
- Modify: `server/src/lib/notifications/templates/telegram-templates.js`
- Modify: `server/src/lib/notifications/templates/email-templates.js`
- [ ] **Step 1: Добавить deliveryType в ORDER_CREATED payload**
```js
request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_CREATED, {
orderId: created.id,
userId,
totalCents: created.totalCents,
itemsCount: cartItems.length,
deliveryType: created.deliveryType,
});
```
- [ ] **Step 2: Обновить renderOrderCreatedTg**
```js
export function renderOrderCreatedTg({ orderId, totalCents, itemsCount, deliveryType }) {
const total = (totalCents / 100).toLocaleString('ru-RU')
const nextAction = deliveryType === 'delivery'
? 'Оплата будет доступна после уточнения стоимости доставки.'
: 'Ожидает оплаты.'
return `📦 <b>Новый заказ</b> #${orderId.slice(0, 8)}\nТоваров: ${itemsCount} | Сумма: ${total}\n${nextAction}`
}
```
- [ ] **Step 3: Обновить renderOrderCreatedEmail**
```js
export function renderOrderCreatedEmail({ orderId, totalCents, itemsCount, deliveryType }) {
const total = (totalCents / 100).toLocaleString("ru-RU");
const nextAction = deliveryType === "delivery"
? "Оплата будет доступна после уточнения стоимости доставки."
: "Ожидает оплаты.";
const body = `
<p>Ваш заказ <b>#${orderId.slice(0, 8)}</b> успешно создан.</p>
<p>Товаров: ${itemsCount} | Сумма: <b>${total} ₽</b></p>
<p>${nextAction}</p>
`;
return { subject: "Заказ создан", html: baseLayout("Заказ создан", body) };
}
```
- [ ] **Step 4: Запустить тесты**
Run: `cd server && npm test`
---
### Task 5: Добавить deliveryType в order:created:admin payload и обновить шаблоны админа
**Файлы:**
- Modify: `server/src/routes/user-orders.js`
- Modify: `server/src/lib/notifications/templates/telegram-templates.js`
- Modify: `server/src/lib/notifications/templates/email-templates.js`
- [ ] **Step 1: Добавить deliveryType в order:created:admin payload**
```js
request.server.eventBus.emit('order:created:admin', {
orderId: created.id,
userId,
userEmail: request.user.email || '',
totalCents: created.totalCents,
itemsCount: cartItems.length,
deliveryType: created.deliveryType,
});
```
- [ ] **Step 2: Обновить renderAdminOrderCreatedTg**
```js
export function renderAdminOrderCreatedTg({ orderId, userEmail, totalCents, itemsCount, deliveryType }) {
const total = (totalCents / 100).toLocaleString('ru-RU')
const note = deliveryType === 'delivery' ? '\n\n⚠️ Скорректируйте стоимость доставки' : ''
return `🛒 <b>Новый заказ</b> #${orderId.slice(0, 8)}\nОт: ${userEmail}\nТоваров: ${itemsCount} | Сумма: ${total}${note}`
}
```
- [ ] **Step 3: Обновить renderAdminOrderCreatedEmail**
```js
export function renderAdminOrderCreatedEmail({
orderId,
userEmail,
totalCents,
itemsCount,
deliveryType,
}) {
const total = (totalCents / 100).toLocaleString("ru-RU");
const note = deliveryType === "delivery"
? '<p>⚠️ <b>Скорректируйте стоимость доставки</b> в админ-панели.</p>'
: "";
const body = `
<p>Новый заказ <b>#${orderId.slice(0, 8)}</b> от <b>${userEmail}</b>.</p>
<p>Товаров: ${itemsCount} | Сумма: <b>${total} ₽</b></p>
${note}
`;
return { subject: "Новый заказ", html: baseLayout("Новый заказ", body) };
}
```
- [ ] **Step 4: Запустить тесты**
Run: `cd server && npm test`
---
### Task 6: Добавить новое событие order:deliveryFeeAdjusted — Shared и Prisma
**Файлы:**
- Modify: `shared/constants/notification-events.js`
- Modify: `shared/constants/notification-events.d.ts`
- Modify: `server/prisma/schema.prisma`
- Modify: `server/src/lib/notifications/preferences.js`
- Modify: `server/src/routes/user/notifications.js`
- Modify: `server/src/index.js`
- Modify: `server/src/routes/api/admin-orders.js`
- [ ] **Step 1: Добавить DELIVERY_FEE_ADJUSTED в shared/constants/notification-events.js**
```js
export 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',
DELIVERY_FEE_ADJUSTED: 'order:deliveryFeeAdjusted',
}
```
- [ ] **Step 2: Добавить тип в shared/constants/notification-events.d.ts**
```ts
export type NotificationEventType =
| 'order:created'
| 'order:statusChanged'
| 'orderMessage:sent'
| 'orderMessage:adminReply'
| 'payment:statusChanged'
| 'auth:codeRequested'
| 'order:deliveryFeeAdjusted'
```
И добавить в объект:
```ts
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
DELIVERY_FEE_ADJUSTED: NotificationEventType
}
```
- [ ] **Step 3: Добавить поле deliveryFeeAdjusted в Prisma schema**
```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)
deliveryFeeAdjusted Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
```
- [ ] **Step 4: Создать миграцию Prisma**
Run: `cd server && npx prisma migrate dev --name add_delivery_fee_adjusted --create-only`
Затем применить: `cd server && npx prisma migrate deploy`
- [ ] **Step 5: Добавить DELIVERY_FEE_ADJUSTED в userEventFieldMap**
```js
const userEventFieldMap = {
[ORDER_CREATED]: 'orderCreated',
[ORDER_STATUS_CHANGED]: 'orderStatusChanged',
[ORDER_MESSAGE_ADMIN_REPLY]: 'orderMessageReceived',
[PAYMENT_STATUS_CHANGED]: 'paymentStatusChanged',
[DELIVERY_FEE_ADJUSTED]: 'deliveryFeeAdjusted',
};
```
Импорт в `preferences.js`:
```js
const {
ORDER_CREATED,
ORDER_STATUS_CHANGED,
ORDER_MESSAGE_SENT,
ORDER_MESSAGE_ADMIN_REPLY,
PAYMENT_STATUS_CHANGED,
AUTH_CODE_REQUESTED,
DELIVERY_FEE_ADJUSTED,
} = NOTIFICATION_EVENTS;
```
- [ ] **Step 6: Добавить поле deliveryFeeAdjusted в API пользователя**
В `server/src/routes/user/notifications.js`:
```js
if ('deliveryFeeAdjusted' in body) data.deliveryFeeAdjusted = Boolean(body.deliveryFeeAdjusted)
```
- [ ] **Step 7: Добавить эмит события при корректировке стоимости доставки**
В `server/src/routes/api/admin-orders.js`, в конце `PATCH /:id/delivery-fee`:
```js
request.server.eventBus.emit(NOTIFICATION_EVENTS.DELIVERY_FEE_ADJUSTED, {
orderId: updated.id,
userId: existing.userId,
totalCents: updated.totalCents,
});
```
Добавить импорт `NOTIFICATION_EVENTS` (уже есть в файле).
- [ ] **Step 8: Зарегистрировать событие в dispatchNotification**
В `server/src/index.js`, в секцию eventBus.on:
```js
eventBus.on(DELIVERY_FEE_ADJUSTED, (payload) => dispatchNotification(DELIVERY_FEE_ADJUSTED, payload));
```
Добавить в деструктуризацию:
```js
const {
ORDER_CREATED,
ORDER_STATUS_CHANGED,
ORDER_MESSAGE_SENT,
ORDER_MESSAGE_ADMIN_REPLY,
PAYMENT_STATUS_CHANGED,
AUTH_CODE_REQUESTED,
DELIVERY_FEE_ADJUSTED,
} = NOTIFICATION_EVENTS;
```
- [ ] **Step 9: Запустить тесты**
Run: `cd server && npm test`
---
### Task 7: Добавить рендереры для deliveryFeeAdjusted
**Файлы:**
- Modify: `server/src/lib/notifications/templates/telegram-templates.js`
- Modify: `server/src/lib/notifications/templates/email-templates.js`
- Modify: `server/src/lib/notifications/channels/telegram-channel.js`
- Modify: `server/src/lib/notifications/channels/email-channel.js`
- [ ] **Step 1: Добавить renderDeliveryFeeAdjustedTg**
В `telegram-templates.js`:
```js
export function renderDeliveryFeeAdjustedTg({ orderId, totalCents }) {
const total = (totalCents / 100).toLocaleString('ru-RU')
return `💰 <b>Стоимость доставки скорректирована</b> для заказа #${orderId.slice(0, 8)}\nНовая сумма: ${total}\n\nОжидает оплаты.`
}
```
- [ ] **Step 2: Зарегистрировать в telegram-channel.js**
```js
import {
renderOrderCreatedTg,
renderOrderStatusChangedTg,
renderOrderMessageTg,
renderPaymentStatusChangedTg,
renderAdminOrderCreatedTg,
renderAdminNewReviewTg,
renderAuthCodeTg,
renderDeliveryFeeAdjustedTg,
} from '../templates/telegram-templates.js'
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,
'order:deliveryFeeAdjusted': renderDeliveryFeeAdjustedTg,
}
```
- [ ] **Step 3: Добавить renderDeliveryFeeAdjustedEmail**
В `email-templates.js`:
```js
export function renderDeliveryFeeAdjustedEmail({ orderId, totalCents }) {
const total = (totalCents / 100).toLocaleString("ru-RU");
const body = `
<p>Стоимость доставки заказа <b>#${orderId.slice(0, 8)}</b> скорректирована.</p>
<p>Новая сумма: <b>${total} ₽</b></p>
<p>Ожидает оплаты. Проверьте статус заказа в личном кабинете.</p>
`;
return {
subject: "Стоимость доставки скорректирована",
html: baseLayout("Стоимость доставки скорректирована", body),
};
}
```
- [ ] **Step 4: Зарегистрировать в email-channel.js**
```js
import {
renderOrderCreatedEmail,
renderOrderStatusChangedEmail,
renderOrderMessageEmail,
renderPaymentStatusChangedEmail,
renderAdminOrderCreatedEmail,
renderAdminNewReviewEmail,
renderAuthCodeEmail,
renderDeliveryFeeAdjustedEmail,
} 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,
'order:deliveryFeeAdjusted': renderDeliveryFeeAdjustedEmail,
}
```
- [ ] **Step 5: Запустить тесты**
Run: `cd server && npm test`
---
### Task 8: Добавить переключатель deliveryFeeAdjusted в UI пользователя
**Файлы:**
- Modify: `client/src/pages/me/ui/sections/NotificationsPage.tsx`
- [ ] **Step 1: Добавить переключатель в NotificationsPage.tsx**
```tsx
const eventFields = [
{ key: 'orderCreated' as const, label: 'Заказ создан' },
{ key: 'orderStatusChanged' as const, label: 'Изменение статуса заказа' },
{ key: 'orderMessageReceived' as const, label: 'Сообщение в чате заказа' },
{ key: 'paymentStatusChanged' as const, label: 'Изменение статуса оплаты' },
{ key: 'deliveryFeeAdjusted' as const, label: 'Корректировка стоимости доставки' },
]
```
- [ ] **Step 2: Проверить линтер и формат**
Run: `cd client && npm run lint && npm run format:check`
---
### Task 9: Финальная проверка
- [ ] **Step 1: Запустить все серверные тесты**
Run: `cd server && npm test`
- [ ] **Step 2: Запустить все клиентские проверки**
Run: `cd client && npm run lint && npm run format:check && npm test`
- [ ] **Step 3: Полная сборка клиента**
Run: `cd client && npm run build`
@@ -0,0 +1,112 @@
# Улучшение системы оповещений
**Дата:** 2026-05-18
## Проблемы
1. **Дублирование кода входа в Telegram** — настройка `authCodeDuplicate` не работает, т.к. `resolveAuthCodeTargets` импортирован, но не вызывается в диспетчере уведомлений.
2. **Двойное уведомление о новом заказе**`order:created` и `order:created:admin` оба проходят через `resolveAdminNotificationTargets('order:created')`, что даёт 2 одинаковых оповещения админу.
3. **Пропущен `PAID` в русских лейблах статусов** — в `telegram-templates.js` и `email-templates.js` нет ключа `PAID`.
4. **Текст оповещений слишком краткий** — нет подсказок о следующих действиях для пользователя и админа.
## Решения
### 1. Дублирование кода входа (гибридный подход)
- Письмо с кодом входа уходит **мгновенно** через прямой вызов `sendLoginCodeEmail` в `issueEmailCode` (без изменений).
- В `dispatchNotification` для `AUTH_CODE_REQUESTED` используется отдельный резолвер `resolveAuthCodeTargets` вместо общих `resolveUserNotificationTargets` / `resolveAdminNotificationTargets`.
- `resolveAuthCodeTargets` уже правильно реализован: отправляет email пользователю, а Telegram — только если `payload.isAdmin === true` и включена настройка `authCodeDuplicate`.
**Изменяемые файлы:**
- `server/src/index.js` — добавить условие в `dispatchNotification` для `AUTH_CODE_REQUESTED`
### 2. Двойное уведомление о новом заказе
- Удалить `ORDER_CREATED` из `adminEventFieldMap` в `preferences.js`.
- Админ получает уведомление о новом заказе только через событие `'order:created:admin'`.
**Изменяемые файлы:**
- `server/src/lib/notifications/preferences.js` — удалить `[ORDER_CREATED]: "newOrder"` из `adminEventFieldMap`
### 3. Добавление PAID в лейблы статусов
- `PAID: 'Оплачен'` в `telegram-templates.js` и `email-templates.js`.
**Изменяемые файлы:**
- `server/src/lib/notifications/templates/telegram-templates.js`
- `server/src/lib/notifications/templates/email-templates.js`
### 4. Расширение текста оповещений
#### 4a. Пользователь: Заказ создан
В событие `ORDER_CREATED` передаётся `deliveryType`:
- `deliveryType === 'pickup'`*"Ваш заказ №XXX успешно создан. Товаров: X | Сумма: XXX ₽. Ожидает оплаты."*
- `deliveryType === 'delivery'`*"Ваш заказ №XXX успешно создан. Товаров: X | Сумма: XXX ₽. Оплата будет доступна после уточнения стоимости доставки."*
**Изменяемые файлы:**
- `server/src/routes/user-orders.js` — добавить `deliveryType` в payload события `ORDER_CREATED`
- `server/src/lib/notifications/templates/telegram-templates.js` — обновить `renderOrderCreatedTg`
- `server/src/lib/notifications/templates/email-templates.js` — обновить `renderOrderCreatedEmail`
#### 4b. Новое событие: deliveryFeeAdjusted
При корректировке стоимости доставки (`PATCH /api/admin/orders/:id/delivery-fee`) отправлять пользователю уведомление:
- Текст: *"Стоимость доставки скорректирована. Ожидает оплаты."*
Событие: `'order:deliveryFeeAdjusted'` — добавляется в `NOTIFICATION_EVENTS`.
**Изменяемые файлы:**
- `shared/constants/notification-events.js` — добавить `DELIVERY_FEE_ADJUSTED`
- `shared/constants/notification-events.d.ts` — добавить `'order:deliveryFeeAdjusted'`
- `server/src/lib/notifications/preferences.js` — добавить `DELIVERY_FEE_ADJUSTED` в `userEventFieldMap`
- `server/src/routes/api/admin-orders.js` — emit `DELIVERY_FEE_ADJUSTED` после обновления
- `server/src/routes/user/notifications.js` — добавить поле в API
- `client/src/pages/me/ui/sections/NotificationsPage.tsx` — добавить переключатель
- `server/src/lib/notifications/templates/telegram-templates.js` — новый рендерер
- `server/src/lib/notifications/templates/email-templates.js` — новый рендерер
- `server/src/lib/notifications/channels/telegram-channel.js` — зарегистрировать шаблон
- `server/src/lib/notifications/channels/email-channel.js` — зарегистрировать шаблон
- `server/src/index.js` — подписаться на событие
Также нужно добавить миграцию Prisma для поля `deliveryFeeAdjusted` в `NotificationPreference`.
#### 4c. Админ: Новый заказ
В событие `'order:created:admin'` передаётся `deliveryType`:
- `deliveryType === 'delivery'` → добавить *"Скорректируйте стоимость доставки"*
- `deliveryType === 'pickup'` → как сейчас
**Изменяемые файлы:**
- `server/src/routes/user-orders.js` — добавить `deliveryType` в payload `'order:created:admin'`
- `server/src/lib/notifications/templates/telegram-templates.js` — обновить `renderAdminOrderCreatedTg`
- `server/src/lib/notifications/templates/email-templates.js` — обновить `renderAdminOrderCreatedEmail`
## Схема данных
### NotificationPreference (добавить поле)
```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)
deliveryFeeAdjusted Boolean @default(true) // НОВОЕ
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
```
## Обработка ошибок
- Если при delivery-fee эмите не указан `userId` — событие не отправляется.
- Если рендерер не найден для события — `telegram-channel.js` и `email-channel.js` логируют warning (существующее поведение).
## Тестирование
- `server/src/lib/notifications/__tests__/preferences.test.js` — добавить тест для `resolveAuthCodeTargets` в контексте `dispatchNotification`
- Проверить, что после удаления `ORDER_CREATED` из `adminEventFieldMap` админ не получает дубликат
@@ -0,0 +1,22 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_NotificationPreference" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"globalEnabled" BOOLEAN NOT NULL DEFAULT true,
"orderCreated" BOOLEAN NOT NULL DEFAULT true,
"orderStatusChanged" BOOLEAN NOT NULL DEFAULT true,
"orderMessageReceived" BOOLEAN NOT NULL DEFAULT true,
"paymentStatusChanged" BOOLEAN NOT NULL DEFAULT true,
"deliveryFeeAdjusted" BOOLEAN NOT NULL DEFAULT true,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "NotificationPreference_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_NotificationPreference" ("createdAt", "globalEnabled", "id", "orderCreated", "orderMessageReceived", "orderStatusChanged", "paymentStatusChanged", "updatedAt", "userId") SELECT "createdAt", "globalEnabled", "id", "orderCreated", "orderMessageReceived", "orderStatusChanged", "paymentStatusChanged", "updatedAt", "userId" FROM "NotificationPreference";
DROP TABLE "NotificationPreference";
ALTER TABLE "new_NotificationPreference" RENAME TO "NotificationPreference";
CREATE UNIQUE INDEX "NotificationPreference_userId_key" ON "NotificationPreference"("userId");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
+1
View File
@@ -281,6 +281,7 @@ model NotificationPreference {
orderStatusChanged Boolean @default(true)
orderMessageReceived Boolean @default(true)
paymentStatusChanged Boolean @default(true)
deliveryFeeAdjusted Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
+18
View File
@@ -113,9 +113,26 @@ const {
ORDER_MESSAGE_ADMIN_REPLY,
PAYMENT_STATUS_CHANGED,
AUTH_CODE_REQUESTED,
DELIVERY_FEE_ADJUSTED,
} = NOTIFICATION_EVENTS;
async function dispatchNotification(eventType, payload) {
if (eventType === AUTH_CODE_REQUESTED) {
const targets = await resolveAuthCodeTargets(eventType, payload);
for (const target of targets.filter((t) => t.channel === 'telegram')) {
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 });
}
return;
}
const userTargets = await resolveUserNotificationTargets(eventType, payload);
for (const target of userTargets) {
const log = await prisma.notificationLog.create({
@@ -157,6 +174,7 @@ eventBus.on(PAYMENT_STATUS_CHANGED, (payload) => dispatchNotification(PAYMENT_ST
eventBus.on(AUTH_CODE_REQUESTED, (payload) => dispatchNotification(AUTH_CODE_REQUESTED, payload));
eventBus.on("order:created:admin", (payload) => dispatchNotification("order:created:admin", payload));
eventBus.on("review:created", (payload) => dispatchNotification("review:created", payload));
eventBus.on(DELIVERY_FEE_ADJUSTED, (payload) => dispatchNotification(DELIVERY_FEE_ADJUSTED, payload));
async function shutdown() {
notificationQueue.stop();
@@ -8,6 +8,7 @@ import {
renderAdminOrderCreatedEmail,
renderAdminNewReviewEmail,
renderAuthCodeEmail,
renderDeliveryFeeAdjustedEmail,
} from '../templates/email-templates.js'
const templateRenderers = {
@@ -19,6 +20,7 @@ const templateRenderers = {
'orderMessage:sent': renderOrderMessageEmail,
'review:created': renderAdminNewReviewEmail,
'auth:codeRequested': renderAuthCodeEmail,
'order:deliveryFeeAdjusted': renderDeliveryFeeAdjustedEmail,
}
export const emailChannel = {
@@ -6,6 +6,7 @@ import {
renderAdminOrderCreatedTg,
renderAdminNewReviewTg,
renderAuthCodeTg,
renderDeliveryFeeAdjustedTg,
} from '../templates/telegram-templates.js'
const TELEGRAM_BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN || ''
@@ -19,6 +20,7 @@ const templateRenderers = {
'orderMessage:sent': renderOrderMessageTg,
'review:created': renderAdminNewReviewTg,
'auth:codeRequested': renderAuthCodeTg,
'order:deliveryFeeAdjusted': renderDeliveryFeeAdjustedTg,
}
async function postToTelegram(chatId, text) {
+2 -1
View File
@@ -8,6 +8,7 @@ const {
ORDER_MESSAGE_ADMIN_REPLY,
PAYMENT_STATUS_CHANGED,
AUTH_CODE_REQUESTED,
DELIVERY_FEE_ADJUSTED,
} = NOTIFICATION_EVENTS;
const userEventFieldMap = {
@@ -15,10 +16,10 @@ const userEventFieldMap = {
[ORDER_STATUS_CHANGED]: "orderStatusChanged",
[ORDER_MESSAGE_ADMIN_REPLY]: "orderMessageReceived",
[PAYMENT_STATUS_CHANGED]: "paymentStatusChanged",
[DELIVERY_FEE_ADJUSTED]: "deliveryFeeAdjusted",
};
const adminEventFieldMap = {
[ORDER_CREATED]: "newOrder",
[ORDER_MESSAGE_SENT]: "newOrderMessage",
"review:created": "newReview",
};
@@ -14,12 +14,15 @@ function baseLayout(title, body) {
</html>`;
}
export function renderOrderCreatedEmail({ orderId, totalCents, itemsCount }) {
export function renderOrderCreatedEmail({ orderId, totalCents, itemsCount, deliveryType }) {
const total = (totalCents / 100).toLocaleString("ru-RU");
const nextAction = deliveryType === "delivery"
? "Оплата будет доступна после уточнения стоимости доставки."
: "Ожидает оплаты.";
const body = `
<p>Ваш заказ <b>#${orderId.slice(0, 8)}</b> успешно создан.</p>
<p>Товаров: ${itemsCount} | Сумма: <b>${total} ₽</b></p>
<p>Мы сообщим вам об изменениях статуса.</p>
<p>${nextAction}</p>
`;
return { subject: "Заказ создан", html: baseLayout("Заказ создан", body) };
}
@@ -32,6 +35,7 @@ export function renderOrderStatusChangedEmail({
const statusLabels = {
DRAFT: "Черновик",
PENDING_PAYMENT: "Ожидает оплаты",
PAID: "Оплачен",
IN_PROGRESS: "В работе",
READY_FOR_PICKUP: "Готов к выдаче",
SHIPPED: "Отправлен",
@@ -87,11 +91,16 @@ export function renderAdminOrderCreatedEmail({
userEmail,
totalCents,
itemsCount,
deliveryType,
}) {
const total = (totalCents / 100).toLocaleString("ru-RU");
const note = deliveryType === "delivery"
? '<p>⚠️ <b>Скорректируйте стоимость доставки</b> в админ-панели.</p>'
: "";
const body = `
<p>Новый заказ <b>#${orderId.slice(0, 8)}</b> от <b>${userEmail}</b>.</p>
<p>Товаров: ${itemsCount} | Сумма: <b>${total} ₽</b></p>
${note}
`;
return { subject: "Новый заказ", html: baseLayout("Новый заказ", body) };
}
@@ -118,3 +127,16 @@ export function renderAuthCodeEmail({ code }) {
`;
return { subject: "Код входа", html: baseLayout("Код входа", body) };
}
export function renderDeliveryFeeAdjustedEmail({ orderId, totalCents }) {
const total = (totalCents / 100).toLocaleString("ru-RU");
const body = `
<p>Стоимость доставки заказа <b>#${orderId.slice(0, 8)}</b> скорректирована.</p>
<p>Новая сумма: <b>${total} ₽</b></p>
<p>Ожидает оплаты. Проверьте статус заказа в личном кабинете.</p>
`;
return {
subject: "Стоимость доставки скорректирована",
html: baseLayout("Стоимость доставки скорректирована", body),
};
}
@@ -1,11 +1,14 @@
export function renderOrderCreatedTg({ orderId, totalCents, itemsCount }) {
export function renderOrderCreatedTg({ orderId, totalCents, itemsCount, deliveryType }) {
const total = (totalCents / 100).toLocaleString('ru-RU')
return `📦 <b>Новый заказ</b> #${orderId.slice(0, 8)}\nТоваров: ${itemsCount} | Сумма: ${total}`
const nextAction = deliveryType === 'delivery'
? 'Оплата будет доступна после уточнения стоимости доставки.'
: 'Ожидает оплаты.'
return `📦 <b>Новый заказ</b> #${orderId.slice(0, 8)}\nТоваров: ${itemsCount} | Сумма: ${total}\n${nextAction}`
}
export function renderOrderStatusChangedTg({ orderId, oldStatus, newStatus }) {
const labels = {
DRAFT: 'Черновик', PENDING_PAYMENT: 'Ожидает оплаты', IN_PROGRESS: 'В работе',
DRAFT: 'Черновик', PENDING_PAYMENT: 'Ожидает оплаты', PAID: 'Оплачен', IN_PROGRESS: 'В работе',
READY_FOR_PICKUP: 'Готов к выдаче', SHIPPED: 'Отправлен', DONE: 'Выполнен', CANCELLED: 'Отменён',
}
return `🔄 Заказ #${orderId.slice(0, 8)}\n${labels[oldStatus] || oldStatus} → <b>${labels[newStatus] || newStatus}</b>`
@@ -21,9 +24,10 @@ export function renderPaymentStatusChangedTg({ orderId, paymentStatus }) {
return `💳 Оплата заказа #${orderId.slice(0, 8)}: <b>${labels[paymentStatus] || paymentStatus}</b>`
}
export function renderAdminOrderCreatedTg({ orderId, userEmail, totalCents, itemsCount }) {
export function renderAdminOrderCreatedTg({ orderId, userEmail, totalCents, itemsCount, deliveryType }) {
const total = (totalCents / 100).toLocaleString('ru-RU')
return `🛒 <b>Новый заказ</b> #${orderId.slice(0, 8)}\nОт: ${userEmail}\nТоваров: ${itemsCount} | Сумма: ${total}`
const note = deliveryType === 'delivery' ? '\n\n⚠️ Скорректируйте стоимость доставки' : ''
return `🛒 <b>Новый заказ</b> #${orderId.slice(0, 8)}\nОт: ${userEmail}\nТоваров: ${itemsCount} | Сумма: ${total}${note}`
}
export function renderAdminNewReviewTg({ rating, text, productTitle, userName }) {
@@ -34,3 +38,8 @@ export function renderAdminNewReviewTg({ rating, text, productTitle, userName })
export function renderAuthCodeTg({ code }) {
return `🔐 Код входа: <b>${code}</b>`
}
export function renderDeliveryFeeAdjustedTg({ orderId, totalCents }) {
const total = (totalCents / 100).toLocaleString('ru-RU')
return `💰 <b>Стоимость доставки скорректирована</b> для заказа #${orderId.slice(0, 8)}\nНовая сумма: ${total}\n\nОжидает оплаты.`
}
+7
View File
@@ -192,6 +192,13 @@ export async function registerAdminOrderRoutes(fastify) {
deliveryFeeLocked: true,
},
});
request.server.eventBus.emit(NOTIFICATION_EVENTS.DELIVERY_FEE_ADJUSTED, {
orderId: updated.id,
userId: existing.userId,
totalCents: updated.totalCents,
});
return { item: updated };
},
);
+2
View File
@@ -190,6 +190,7 @@ export async function registerUserOrderRoutes(fastify) {
userId,
totalCents: created.totalCents,
itemsCount: cartItems.length,
deliveryType: created.deliveryType,
});
// Also emit admin notification
@@ -199,6 +200,7 @@ export async function registerUserOrderRoutes(fastify) {
userEmail: request.user.email || "",
totalCents: created.totalCents,
itemsCount: cartItems.length,
deliveryType: created.deliveryType,
});
return reply.code(201).send({ orderId: created.id });
+1
View File
@@ -25,6 +25,7 @@ export async function registerUserNotificationRoutes(fastify) {
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)
if ('deliveryFeeAdjusted' in body) data.deliveryFeeAdjusted = Boolean(body.deliveryFeeAdjusted)
const prefs = await prisma.notificationPreference.upsert({
where: { userId },
+2
View File
@@ -5,6 +5,7 @@ export type NotificationEventType =
| 'orderMessage:adminReply'
| 'payment:statusChanged'
| 'auth:codeRequested'
| 'order:deliveryFeeAdjusted'
export type NotificationChannel = 'email' | 'telegram'
@@ -17,6 +18,7 @@ export const NOTIFICATION_EVENTS: {
ORDER_MESSAGE_ADMIN_REPLY: NotificationEventType
PAYMENT_STATUS_CHANGED: NotificationEventType
AUTH_CODE_REQUESTED: NotificationEventType
DELIVERY_FEE_ADJUSTED: NotificationEventType
}
export const NOTIFICATION_CHANNELS: {
+1
View File
@@ -7,6 +7,7 @@ export const NOTIFICATION_EVENTS = {
ORDER_MESSAGE_ADMIN_REPLY: 'orderMessage:adminReply',
PAYMENT_STATUS_CHANGED: 'payment:statusChanged',
AUTH_CODE_REQUESTED: 'auth:codeRequested',
DELIVERY_FEE_ADJUSTED: 'order:deliveryFeeAdjusted',
}
export const NOTIFICATION_CHANNELS = {