diff --git a/client/src/entities/notification/api/notifications-api.ts b/client/src/entities/notification/api/notifications-api.ts index aa4ff47..c75126f 100644 --- a/client/src/entities/notification/api/notifications-api.ts +++ b/client/src/entities/notification/api/notifications-api.ts @@ -8,6 +8,7 @@ export interface UserNotificationSettings { orderStatusChanged: boolean orderMessageReceived: boolean paymentStatusChanged: boolean + deliveryFeeAdjusted: boolean createdAt: string updatedAt: string } diff --git a/client/src/pages/me/ui/sections/NotificationsPage.tsx b/client/src/pages/me/ui/sections/NotificationsPage.tsx index 2458020..5e3ab82 100644 --- a/client/src/pages/me/ui/sections/NotificationsPage.tsx +++ b/client/src/pages/me/ui/sections/NotificationsPage.tsx @@ -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() { diff --git a/docs/superpowers/plans/2026-05-18-notifications-improvements.md b/docs/superpowers/plans/2026-05-18-notifications-improvements.md new file mode 100644 index 0000000..f78db65 --- /dev/null +++ b/docs/superpowers/plans/2026-05-18-notifications-improvements.md @@ -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} → ${labels[newStatus] || newStatus}` +} +``` + +- [ ] **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 `📦 Новый заказ #${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 = ` +

Ваш заказ #${orderId.slice(0, 8)} успешно создан.

+

Товаров: ${itemsCount} | Сумма: ${total} ₽

+

${nextAction}

+ `; + 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 `🛒 Новый заказ #${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" + ? '

⚠️ Скорректируйте стоимость доставки в админ-панели.

' + : ""; + const body = ` +

Новый заказ #${orderId.slice(0, 8)} от ${userEmail}.

+

Товаров: ${itemsCount} | Сумма: ${total} ₽

+ ${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 `💰 Стоимость доставки скорректирована для заказа #${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 = ` +

Стоимость доставки заказа #${orderId.slice(0, 8)} скорректирована.

+

Новая сумма: ${total} ₽

+

Ожидает оплаты. Проверьте статус заказа в личном кабинете.

+ `; + 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` diff --git a/docs/superpowers/specs/2026-05-18-notifications-improvements-design.md b/docs/superpowers/specs/2026-05-18-notifications-improvements-design.md new file mode 100644 index 0000000..7917a18 --- /dev/null +++ b/docs/superpowers/specs/2026-05-18-notifications-improvements-design.md @@ -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` админ не получает дубликат diff --git a/server/prisma/migrations/20260518093815_add_delivery_fee_adjusted/migration.sql b/server/prisma/migrations/20260518093815_add_delivery_fee_adjusted/migration.sql new file mode 100644 index 0000000..1ace285 --- /dev/null +++ b/server/prisma/migrations/20260518093815_add_delivery_fee_adjusted/migration.sql @@ -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; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 8cf163b..2e34bc3 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -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 diff --git a/server/src/index.js b/server/src/index.js index 7cdac4e..2ff3dc3 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -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(); diff --git a/server/src/lib/notifications/channels/email-channel.js b/server/src/lib/notifications/channels/email-channel.js index 8911ea3..bba6e5d 100644 --- a/server/src/lib/notifications/channels/email-channel.js +++ b/server/src/lib/notifications/channels/email-channel.js @@ -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 = { diff --git a/server/src/lib/notifications/channels/telegram-channel.js b/server/src/lib/notifications/channels/telegram-channel.js index 9d324f1..dafcbe6 100644 --- a/server/src/lib/notifications/channels/telegram-channel.js +++ b/server/src/lib/notifications/channels/telegram-channel.js @@ -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) { diff --git a/server/src/lib/notifications/preferences.js b/server/src/lib/notifications/preferences.js index e67159f..fab4f7c 100644 --- a/server/src/lib/notifications/preferences.js +++ b/server/src/lib/notifications/preferences.js @@ -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", }; diff --git a/server/src/lib/notifications/templates/email-templates.js b/server/src/lib/notifications/templates/email-templates.js index 7bcfa08..de66674 100644 --- a/server/src/lib/notifications/templates/email-templates.js +++ b/server/src/lib/notifications/templates/email-templates.js @@ -14,12 +14,15 @@ function baseLayout(title, body) { `; } -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 = `

Ваш заказ #${orderId.slice(0, 8)} успешно создан.

Товаров: ${itemsCount} | Сумма: ${total} ₽

-

Мы сообщим вам об изменениях статуса.

+

${nextAction}

`; 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" + ? '

⚠️ Скорректируйте стоимость доставки в админ-панели.

' + : ""; const body = `

Новый заказ #${orderId.slice(0, 8)} от ${userEmail}.

Товаров: ${itemsCount} | Сумма: ${total} ₽

+ ${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 = ` +

Стоимость доставки заказа #${orderId.slice(0, 8)} скорректирована.

+

Новая сумма: ${total} ₽

+

Ожидает оплаты. Проверьте статус заказа в личном кабинете.

+ `; + return { + subject: "Стоимость доставки скорректирована", + html: baseLayout("Стоимость доставки скорректирована", body), + }; +} diff --git a/server/src/lib/notifications/templates/telegram-templates.js b/server/src/lib/notifications/templates/telegram-templates.js index e4e5120..495c901 100644 --- a/server/src/lib/notifications/templates/telegram-templates.js +++ b/server/src/lib/notifications/templates/telegram-templates.js @@ -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 `📦 Новый заказ #${orderId.slice(0, 8)}\nТоваров: ${itemsCount} | Сумма: ${total} ₽` + const nextAction = deliveryType === 'delivery' + ? 'Оплата будет доступна после уточнения стоимости доставки.' + : 'Ожидает оплаты.' + return `📦 Новый заказ #${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} → ${labels[newStatus] || newStatus}` @@ -21,9 +24,10 @@ export function renderPaymentStatusChangedTg({ orderId, paymentStatus }) { return `💳 Оплата заказа #${orderId.slice(0, 8)}: ${labels[paymentStatus] || paymentStatus}` } -export function renderAdminOrderCreatedTg({ orderId, userEmail, totalCents, itemsCount }) { +export function renderAdminOrderCreatedTg({ orderId, userEmail, totalCents, itemsCount, deliveryType }) { const total = (totalCents / 100).toLocaleString('ru-RU') - return `🛒 Новый заказ #${orderId.slice(0, 8)}\nОт: ${userEmail}\nТоваров: ${itemsCount} | Сумма: ${total} ₽` + const note = deliveryType === 'delivery' ? '\n\n⚠️ Скорректируйте стоимость доставки' : '' + return `🛒 Новый заказ #${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 `🔐 Код входа: ${code}` } + +export function renderDeliveryFeeAdjustedTg({ orderId, totalCents }) { + const total = (totalCents / 100).toLocaleString('ru-RU') + return `💰 Стоимость доставки скорректирована для заказа #${orderId.slice(0, 8)}\nНовая сумма: ${total} ₽\n\nОжидает оплаты.` +} diff --git a/server/src/routes/api/admin-orders.js b/server/src/routes/api/admin-orders.js index 7ea6461..06262a1 100644 --- a/server/src/routes/api/admin-orders.js +++ b/server/src/routes/api/admin-orders.js @@ -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 }; }, ); diff --git a/server/src/routes/user-orders.js b/server/src/routes/user-orders.js index e1792e1..ea4ce3c 100644 --- a/server/src/routes/user-orders.js +++ b/server/src/routes/user-orders.js @@ -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 }); diff --git a/server/src/routes/user/notifications.js b/server/src/routes/user/notifications.js index a984659..c12f393 100644 --- a/server/src/routes/user/notifications.js +++ b/server/src/routes/user/notifications.js @@ -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 }, diff --git a/shared/constants/notification-events.d.ts b/shared/constants/notification-events.d.ts index 1eba015..ef4ab8e 100644 --- a/shared/constants/notification-events.d.ts +++ b/shared/constants/notification-events.d.ts @@ -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: { diff --git a/shared/constants/notification-events.js b/shared/constants/notification-events.js index 82d5578..c22f625 100644 --- a/shared/constants/notification-events.js +++ b/shared/constants/notification-events.js @@ -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 = {