feat: improve notifications - fix auth code tg duplicate, double order notify, add PAID label, expand text, add deliveryFeeAdjusted event
This commit is contained in:
@@ -8,6 +8,7 @@ export interface UserNotificationSettings {
|
|||||||
orderStatusChanged: boolean
|
orderStatusChanged: boolean
|
||||||
orderMessageReceived: boolean
|
orderMessageReceived: boolean
|
||||||
paymentStatusChanged: boolean
|
paymentStatusChanged: boolean
|
||||||
|
deliveryFeeAdjusted: boolean
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const eventFields = [
|
|||||||
{ key: 'orderStatusChanged' as const, label: 'Изменение статуса заказа' },
|
{ key: 'orderStatusChanged' as const, label: 'Изменение статуса заказа' },
|
||||||
{ key: 'orderMessageReceived' as const, label: 'Сообщение в чате заказа' },
|
{ key: 'orderMessageReceived' as const, label: 'Сообщение в чате заказа' },
|
||||||
{ key: 'paymentStatusChanged' as const, label: 'Изменение статуса оплаты' },
|
{ key: 'paymentStatusChanged' as const, label: 'Изменение статуса оплаты' },
|
||||||
|
{ key: 'deliveryFeeAdjusted' as const, label: 'Корректировка стоимости доставки' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export function NotificationsPage() {
|
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;
|
||||||
@@ -281,6 +281,7 @@ model NotificationPreference {
|
|||||||
orderStatusChanged Boolean @default(true)
|
orderStatusChanged Boolean @default(true)
|
||||||
orderMessageReceived Boolean @default(true)
|
orderMessageReceived Boolean @default(true)
|
||||||
paymentStatusChanged Boolean @default(true)
|
paymentStatusChanged Boolean @default(true)
|
||||||
|
deliveryFeeAdjusted Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|||||||
@@ -113,9 +113,26 @@ const {
|
|||||||
ORDER_MESSAGE_ADMIN_REPLY,
|
ORDER_MESSAGE_ADMIN_REPLY,
|
||||||
PAYMENT_STATUS_CHANGED,
|
PAYMENT_STATUS_CHANGED,
|
||||||
AUTH_CODE_REQUESTED,
|
AUTH_CODE_REQUESTED,
|
||||||
|
DELIVERY_FEE_ADJUSTED,
|
||||||
} = NOTIFICATION_EVENTS;
|
} = NOTIFICATION_EVENTS;
|
||||||
|
|
||||||
async function dispatchNotification(eventType, payload) {
|
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);
|
const userTargets = await resolveUserNotificationTargets(eventType, payload);
|
||||||
for (const target of userTargets) {
|
for (const target of userTargets) {
|
||||||
const log = await prisma.notificationLog.create({
|
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(AUTH_CODE_REQUESTED, (payload) => dispatchNotification(AUTH_CODE_REQUESTED, payload));
|
||||||
eventBus.on("order:created:admin", (payload) => dispatchNotification("order:created:admin", payload));
|
eventBus.on("order:created:admin", (payload) => dispatchNotification("order:created:admin", payload));
|
||||||
eventBus.on("review:created", (payload) => dispatchNotification("review:created", payload));
|
eventBus.on("review:created", (payload) => dispatchNotification("review:created", payload));
|
||||||
|
eventBus.on(DELIVERY_FEE_ADJUSTED, (payload) => dispatchNotification(DELIVERY_FEE_ADJUSTED, payload));
|
||||||
|
|
||||||
async function shutdown() {
|
async function shutdown() {
|
||||||
notificationQueue.stop();
|
notificationQueue.stop();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
renderAdminOrderCreatedEmail,
|
renderAdminOrderCreatedEmail,
|
||||||
renderAdminNewReviewEmail,
|
renderAdminNewReviewEmail,
|
||||||
renderAuthCodeEmail,
|
renderAuthCodeEmail,
|
||||||
|
renderDeliveryFeeAdjustedEmail,
|
||||||
} from '../templates/email-templates.js'
|
} from '../templates/email-templates.js'
|
||||||
|
|
||||||
const templateRenderers = {
|
const templateRenderers = {
|
||||||
@@ -19,6 +20,7 @@ const templateRenderers = {
|
|||||||
'orderMessage:sent': renderOrderMessageEmail,
|
'orderMessage:sent': renderOrderMessageEmail,
|
||||||
'review:created': renderAdminNewReviewEmail,
|
'review:created': renderAdminNewReviewEmail,
|
||||||
'auth:codeRequested': renderAuthCodeEmail,
|
'auth:codeRequested': renderAuthCodeEmail,
|
||||||
|
'order:deliveryFeeAdjusted': renderDeliveryFeeAdjustedEmail,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const emailChannel = {
|
export const emailChannel = {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
renderAdminOrderCreatedTg,
|
renderAdminOrderCreatedTg,
|
||||||
renderAdminNewReviewTg,
|
renderAdminNewReviewTg,
|
||||||
renderAuthCodeTg,
|
renderAuthCodeTg,
|
||||||
|
renderDeliveryFeeAdjustedTg,
|
||||||
} from '../templates/telegram-templates.js'
|
} from '../templates/telegram-templates.js'
|
||||||
|
|
||||||
const TELEGRAM_BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN || ''
|
const TELEGRAM_BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN || ''
|
||||||
@@ -19,6 +20,7 @@ const templateRenderers = {
|
|||||||
'orderMessage:sent': renderOrderMessageTg,
|
'orderMessage:sent': renderOrderMessageTg,
|
||||||
'review:created': renderAdminNewReviewTg,
|
'review:created': renderAdminNewReviewTg,
|
||||||
'auth:codeRequested': renderAuthCodeTg,
|
'auth:codeRequested': renderAuthCodeTg,
|
||||||
|
'order:deliveryFeeAdjusted': renderDeliveryFeeAdjustedTg,
|
||||||
}
|
}
|
||||||
|
|
||||||
async function postToTelegram(chatId, text) {
|
async function postToTelegram(chatId, text) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const {
|
|||||||
ORDER_MESSAGE_ADMIN_REPLY,
|
ORDER_MESSAGE_ADMIN_REPLY,
|
||||||
PAYMENT_STATUS_CHANGED,
|
PAYMENT_STATUS_CHANGED,
|
||||||
AUTH_CODE_REQUESTED,
|
AUTH_CODE_REQUESTED,
|
||||||
|
DELIVERY_FEE_ADJUSTED,
|
||||||
} = NOTIFICATION_EVENTS;
|
} = NOTIFICATION_EVENTS;
|
||||||
|
|
||||||
const userEventFieldMap = {
|
const userEventFieldMap = {
|
||||||
@@ -15,10 +16,10 @@ const userEventFieldMap = {
|
|||||||
[ORDER_STATUS_CHANGED]: "orderStatusChanged",
|
[ORDER_STATUS_CHANGED]: "orderStatusChanged",
|
||||||
[ORDER_MESSAGE_ADMIN_REPLY]: "orderMessageReceived",
|
[ORDER_MESSAGE_ADMIN_REPLY]: "orderMessageReceived",
|
||||||
[PAYMENT_STATUS_CHANGED]: "paymentStatusChanged",
|
[PAYMENT_STATUS_CHANGED]: "paymentStatusChanged",
|
||||||
|
[DELIVERY_FEE_ADJUSTED]: "deliveryFeeAdjusted",
|
||||||
};
|
};
|
||||||
|
|
||||||
const adminEventFieldMap = {
|
const adminEventFieldMap = {
|
||||||
[ORDER_CREATED]: "newOrder",
|
|
||||||
[ORDER_MESSAGE_SENT]: "newOrderMessage",
|
[ORDER_MESSAGE_SENT]: "newOrderMessage",
|
||||||
"review:created": "newReview",
|
"review:created": "newReview",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,12 +14,15 @@ function baseLayout(title, body) {
|
|||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderOrderCreatedEmail({ orderId, totalCents, itemsCount }) {
|
export function renderOrderCreatedEmail({ orderId, totalCents, itemsCount, deliveryType }) {
|
||||||
const total = (totalCents / 100).toLocaleString("ru-RU");
|
const total = (totalCents / 100).toLocaleString("ru-RU");
|
||||||
|
const nextAction = deliveryType === "delivery"
|
||||||
|
? "Оплата будет доступна после уточнения стоимости доставки."
|
||||||
|
: "Ожидает оплаты.";
|
||||||
const body = `
|
const body = `
|
||||||
<p>Ваш заказ <b>#${orderId.slice(0, 8)}</b> успешно создан.</p>
|
<p>Ваш заказ <b>#${orderId.slice(0, 8)}</b> успешно создан.</p>
|
||||||
<p>Товаров: ${itemsCount} | Сумма: <b>${total} ₽</b></p>
|
<p>Товаров: ${itemsCount} | Сумма: <b>${total} ₽</b></p>
|
||||||
<p>Мы сообщим вам об изменениях статуса.</p>
|
<p>${nextAction}</p>
|
||||||
`;
|
`;
|
||||||
return { subject: "Заказ создан", html: baseLayout("Заказ создан", body) };
|
return { subject: "Заказ создан", html: baseLayout("Заказ создан", body) };
|
||||||
}
|
}
|
||||||
@@ -32,6 +35,7 @@ export function renderOrderStatusChangedEmail({
|
|||||||
const statusLabels = {
|
const statusLabels = {
|
||||||
DRAFT: "Черновик",
|
DRAFT: "Черновик",
|
||||||
PENDING_PAYMENT: "Ожидает оплаты",
|
PENDING_PAYMENT: "Ожидает оплаты",
|
||||||
|
PAID: "Оплачен",
|
||||||
IN_PROGRESS: "В работе",
|
IN_PROGRESS: "В работе",
|
||||||
READY_FOR_PICKUP: "Готов к выдаче",
|
READY_FOR_PICKUP: "Готов к выдаче",
|
||||||
SHIPPED: "Отправлен",
|
SHIPPED: "Отправлен",
|
||||||
@@ -87,11 +91,16 @@ export function renderAdminOrderCreatedEmail({
|
|||||||
userEmail,
|
userEmail,
|
||||||
totalCents,
|
totalCents,
|
||||||
itemsCount,
|
itemsCount,
|
||||||
|
deliveryType,
|
||||||
}) {
|
}) {
|
||||||
const total = (totalCents / 100).toLocaleString("ru-RU");
|
const total = (totalCents / 100).toLocaleString("ru-RU");
|
||||||
|
const note = deliveryType === "delivery"
|
||||||
|
? '<p>⚠️ <b>Скорректируйте стоимость доставки</b> в админ-панели.</p>'
|
||||||
|
: "";
|
||||||
const body = `
|
const body = `
|
||||||
<p>Новый заказ <b>#${orderId.slice(0, 8)}</b> от <b>${userEmail}</b>.</p>
|
<p>Новый заказ <b>#${orderId.slice(0, 8)}</b> от <b>${userEmail}</b>.</p>
|
||||||
<p>Товаров: ${itemsCount} | Сумма: <b>${total} ₽</b></p>
|
<p>Товаров: ${itemsCount} | Сумма: <b>${total} ₽</b></p>
|
||||||
|
${note}
|
||||||
`;
|
`;
|
||||||
return { subject: "Новый заказ", html: baseLayout("Новый заказ", body) };
|
return { subject: "Новый заказ", html: baseLayout("Новый заказ", body) };
|
||||||
}
|
}
|
||||||
@@ -118,3 +127,16 @@ export function renderAuthCodeEmail({ code }) {
|
|||||||
`;
|
`;
|
||||||
return { subject: "Код входа", html: baseLayout("Код входа", body) };
|
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')
|
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 }) {
|
export function renderOrderStatusChangedTg({ orderId, oldStatus, newStatus }) {
|
||||||
const labels = {
|
const labels = {
|
||||||
DRAFT: 'Черновик', PENDING_PAYMENT: 'Ожидает оплаты', IN_PROGRESS: 'В работе',
|
DRAFT: 'Черновик', PENDING_PAYMENT: 'Ожидает оплаты', PAID: 'Оплачен', IN_PROGRESS: 'В работе',
|
||||||
READY_FOR_PICKUP: 'Готов к выдаче', SHIPPED: 'Отправлен', DONE: 'Выполнен', CANCELLED: 'Отменён',
|
READY_FOR_PICKUP: 'Готов к выдаче', SHIPPED: 'Отправлен', DONE: 'Выполнен', CANCELLED: 'Отменён',
|
||||||
}
|
}
|
||||||
return `🔄 Заказ #${orderId.slice(0, 8)}\n${labels[oldStatus] || oldStatus} → <b>${labels[newStatus] || newStatus}</b>`
|
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>`
|
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')
|
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 }) {
|
export function renderAdminNewReviewTg({ rating, text, productTitle, userName }) {
|
||||||
@@ -34,3 +38,8 @@ export function renderAdminNewReviewTg({ rating, text, productTitle, userName })
|
|||||||
export function renderAuthCodeTg({ code }) {
|
export function renderAuthCodeTg({ code }) {
|
||||||
return `🔐 Код входа: <b>${code}</b>`
|
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Ожидает оплаты.`
|
||||||
|
}
|
||||||
|
|||||||
@@ -192,6 +192,13 @@ export async function registerAdminOrderRoutes(fastify) {
|
|||||||
deliveryFeeLocked: true,
|
deliveryFeeLocked: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
request.server.eventBus.emit(NOTIFICATION_EVENTS.DELIVERY_FEE_ADJUSTED, {
|
||||||
|
orderId: updated.id,
|
||||||
|
userId: existing.userId,
|
||||||
|
totalCents: updated.totalCents,
|
||||||
|
});
|
||||||
|
|
||||||
return { item: updated };
|
return { item: updated };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -190,6 +190,7 @@ export async function registerUserOrderRoutes(fastify) {
|
|||||||
userId,
|
userId,
|
||||||
totalCents: created.totalCents,
|
totalCents: created.totalCents,
|
||||||
itemsCount: cartItems.length,
|
itemsCount: cartItems.length,
|
||||||
|
deliveryType: created.deliveryType,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Also emit admin notification
|
// Also emit admin notification
|
||||||
@@ -199,6 +200,7 @@ export async function registerUserOrderRoutes(fastify) {
|
|||||||
userEmail: request.user.email || "",
|
userEmail: request.user.email || "",
|
||||||
totalCents: created.totalCents,
|
totalCents: created.totalCents,
|
||||||
itemsCount: cartItems.length,
|
itemsCount: cartItems.length,
|
||||||
|
deliveryType: created.deliveryType,
|
||||||
});
|
});
|
||||||
|
|
||||||
return reply.code(201).send({ orderId: created.id });
|
return reply.code(201).send({ orderId: created.id });
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export async function registerUserNotificationRoutes(fastify) {
|
|||||||
if ('orderStatusChanged' in body) data.orderStatusChanged = Boolean(body.orderStatusChanged)
|
if ('orderStatusChanged' in body) data.orderStatusChanged = Boolean(body.orderStatusChanged)
|
||||||
if ('orderMessageReceived' in body) data.orderMessageReceived = Boolean(body.orderMessageReceived)
|
if ('orderMessageReceived' in body) data.orderMessageReceived = Boolean(body.orderMessageReceived)
|
||||||
if ('paymentStatusChanged' in body) data.paymentStatusChanged = Boolean(body.paymentStatusChanged)
|
if ('paymentStatusChanged' in body) data.paymentStatusChanged = Boolean(body.paymentStatusChanged)
|
||||||
|
if ('deliveryFeeAdjusted' in body) data.deliveryFeeAdjusted = Boolean(body.deliveryFeeAdjusted)
|
||||||
|
|
||||||
const prefs = await prisma.notificationPreference.upsert({
|
const prefs = await prisma.notificationPreference.upsert({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
|
|||||||
+2
@@ -5,6 +5,7 @@ export type NotificationEventType =
|
|||||||
| 'orderMessage:adminReply'
|
| 'orderMessage:adminReply'
|
||||||
| 'payment:statusChanged'
|
| 'payment:statusChanged'
|
||||||
| 'auth:codeRequested'
|
| 'auth:codeRequested'
|
||||||
|
| 'order:deliveryFeeAdjusted'
|
||||||
|
|
||||||
export type NotificationChannel = 'email' | 'telegram'
|
export type NotificationChannel = 'email' | 'telegram'
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ export const NOTIFICATION_EVENTS: {
|
|||||||
ORDER_MESSAGE_ADMIN_REPLY: NotificationEventType
|
ORDER_MESSAGE_ADMIN_REPLY: NotificationEventType
|
||||||
PAYMENT_STATUS_CHANGED: NotificationEventType
|
PAYMENT_STATUS_CHANGED: NotificationEventType
|
||||||
AUTH_CODE_REQUESTED: NotificationEventType
|
AUTH_CODE_REQUESTED: NotificationEventType
|
||||||
|
DELIVERY_FEE_ADJUSTED: NotificationEventType
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NOTIFICATION_CHANNELS: {
|
export const NOTIFICATION_CHANNELS: {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const NOTIFICATION_EVENTS = {
|
|||||||
ORDER_MESSAGE_ADMIN_REPLY: 'orderMessage:adminReply',
|
ORDER_MESSAGE_ADMIN_REPLY: 'orderMessage:adminReply',
|
||||||
PAYMENT_STATUS_CHANGED: 'payment:statusChanged',
|
PAYMENT_STATUS_CHANGED: 'payment:statusChanged',
|
||||||
AUTH_CODE_REQUESTED: 'auth:codeRequested',
|
AUTH_CODE_REQUESTED: 'auth:codeRequested',
|
||||||
|
DELIVERY_FEE_ADJUSTED: 'order:deliveryFeeAdjusted',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NOTIFICATION_CHANNELS = {
|
export const NOTIFICATION_CHANNELS = {
|
||||||
|
|||||||
Reference in New Issue
Block a user