test: add notification preferences tests

This commit is contained in:
Kirill
2026-05-18 11:44:49 +05:00
parent 1d36f6a31b
commit 912724082e
7 changed files with 3746 additions and 2 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,149 @@
# Admin Image Redesign — Separate Upload, Resize & Attach
## Problem
Current admin image flow bundles three concerns into one `POST /api/admin/uploads` call:
1. Upload file to disk
2. Eager resize (generate all `.cache` sizes + convert original to WebP)
3. Register in gallery (upsert GalleryImage)
This prevents the admin from uploading raw images and deciding later when to process them. Photos attached to products are always "ready", but the admin has no control over when processing happens.
## Goal
Separate the concerns into three explicit steps:
1. **Upload** — file lands in gallery, no processing
2. **Resize** — admin triggers image processing per image
3. **Attach** — only processed images can be attached to products / slider
## Prisma Schema Change
Add `isResized` field to `GalleryImage`:
```prisma
model GalleryImage {
id String @id @default(cuid())
url String @unique
isResized Boolean @default(false)
createdAt DateTime @default(now())
catalogSliderSlides CatalogSliderSlide[]
}
```
**Existing data**: after deploy, run a one-time migration script:
```ts
await prisma.galleryImage.updateMany({
where: { isResized: false },
data: { isResized: true },
})
```
Existing images are already on disk in their processed state (WebP + `.cache`), so marking them `isResized = true` is correct.
## API Routes
### New: `POST /api/admin/gallery/upload`
- Multipart file upload
- Saves to `/uploads/<uuid>.<ext>` (original extension preserved, NO WebP conversion)
- Creates `GalleryImage { url, isResized: false }`
- Returns `{ url: string }`
### New: `POST /api/admin/gallery/:id/resize`
- Reads original from `/uploads/<uuid>.<ext>`
- Calls `convertOriginalToWebp` (converts to `/uploads/<uuid>.webp`, deletes original)
- Calls `generateAllSizes` (populates `.cache/`)
- Updates `GalleryImage.url` to `/uploads/<uuid>.webp`, sets `isResized = true`
- Returns `{ url: string }`
- Errors if already resized (409) or image not found (404)
### Modified: `GET /api/admin/gallery`
- Already returns all fields via Prisma — just add `isResized` to the response
- No endpoint changes needed; client type updates only
### Modified: `POST /api/admin/uploads` → **REMOVED**
- The old combined upload endpoint is deleted
- It was only used by admin product form
### Modified: `POST /api/admin/products` / `PATCH /api/admin/products/:id`
- Validate that all passed `imageUrls` have `isResized = true` in GalleryImage
- If any image is not resized → `400 Bad Request` with explanation
### No changes to:
- `DELETE /api/admin/gallery/:id` (already works correctly)
- `PUT /api/admin/catalog-slider` (slider picks from GalleryImage — handled by gallery endpoint filter)
- `GET /uploads-resized/` (on-demand resizer unchanged)
- All public routes
## Admin UI — Gallery Page
**Upload**: Stays as `<input type="file" multiple>` → calls new `POST /api/admin/gallery/upload`.
**Gallery card**: Each image now shows:
- OptimizedImage preview (on-demand resizer still works for display)
- Status badge: "Не обработано" (if `!isResized`) or "Готово" (if `isResized`)
- If `!isResized`: a "Resize" button visible
- If `isResized`: "Resize" hidden, delete button remains
- Existing delete behaviour unchanged (checks usage before deletion)
**GalleryGrid**: Updated to accept and render `isResized` property, conditionally show resize button.
**React Query**: Add `resizeGalleryImage` mutation + `uploadGalleryImages` mutation.
## Admin UI — Product Form
- Remove direct file upload from `AdminProductsPage` (the `<input>` that calls `uploadAdminProductImages`)
- Keep only "Выбрать из галереи" dialog
- Gallery selection dialog: filter to show only `isResized = true` images
- Existing preview/sort/delete within product card unchanged
## Admin UI — Slider Section
- `GallerySliderSection` already uses gallery for selection
- When picking an image for a slide, filter to `isResized = true`
## Data Flow Summary
```
1. Upload
[Admin] → POST /api/admin/gallery/upload → /uploads/<uuid>.png
→ GalleryImage { url: "/uploads/<uuid>.png", isResized: false }
2. Resize (triggered manually in gallery)
[Admin] → POST /api/admin/gallery/:id/resize
→ convertOriginalToWebp → /uploads/<uuid>.webp
→ generateAllSizes → .cache/<uuid>_w{320,640,1024,1600}.{avif,webp}
→ GalleryImage { url: "/uploads/<uuid>.webp", isResized: true }
3. Attach to product / slider
[Admin] → product form / slider form
→ gallery picker shows only isResized = true images
→ write chosen URLs to ProductImage / CatalogSliderSlide
```
## Error Handling
- Resize of already-resized image → 409 Conflict
- Resize of missing file → 404 Not Found
- Attach unprocessed image to product → 400 Bad Request with message
- Upload invalid file type → 400 (existing validation reused)
- Upload over size limit → 413 (existing validation reused)
## Testing
### Server
- Upload endpoint: file saved, no processing, GalleryImage created with `isResized: false`
- Resize endpoint: original converted to WebP, .cache populated, `isResized` flipped to `true`
- Product creation: rejects imageUrls with `isResized: false`
- Gallery GET: includes `isResized` field
### Client
- Gallery page: badge visible for unprocessed, hidden for processed
- Resize button click → mutation → refetch → updated state
- Product form: no upload button, only "from gallery" picker
- Gallery picker in product/slider: unprocessed images hidden or disabled
## Rollout
1. Deploy server changes first (schema migration + new routes, remove old upload route)
2. One-time migration to mark existing images as resized
3. Deploy client changes
@@ -0,0 +1,221 @@
# Design: Notification System
**Date:** 2026-05-18
**Status:** Draft — awaiting review
## 1. Overview
Система оповещений для craftshop: email для пользователей, email + Telegram для админа.
Архитектура — event-driven с in-memory очередью и retry. Задел на подключение новых каналов (WhatsApp, Viber, push).
## 2. Architecture
### 2.1 Components
```
server/src/
lib/
email.js ← расширяется: sendNotificationEmail()
notifications/
event-bus.js ← EventEmitter, центральный хаб
queue.js ← in-memory очередь + воркер
channels/
email-channel.js ← отправка email через nodemailer
telegram-channel.js ← отправка через Telegram Bot API
templates/
email-templates.js ← HTML-шаблоны писем
telegram-templates.js ← форматированные сообщения TG
preferences.js ← CRUD настроек оповещений
routes/
api/
admin/
notifications.js ← GET/PUT настройки админа
user/
notifications.js ← GET/PUT настройки пользователя
```
### 2.2 Database (Prisma)
#### NotificationPreference (настройки пользователя)
| Field | Type | Description |
|---|---|---|
| id | String (cuid) | Primary key |
| userId | String (cuid) | FK → User (unique) |
| globalEnabled | Boolean | Главный переключатель |
| orderCreated | Boolean | Заказ создан |
| orderStatusChanged | Boolean | Статус заказа изменён |
| orderMessageReceived | Boolean | Новое сообщение в чате заказа |
| paymentStatusChanged | Boolean | Статус оплаты изменён |
| createdAt | DateTime | |
| updatedAt | DateTime | |
#### AdminNotificationSettings (настройки админа)
| Field | Type | Description |
|---|---|---|
| id | String (cuid) | Primary key |
| emailEnabled | Boolean | Email вкл/выкл |
| telegramEnabled | Boolean | Telegram вкл/выкл |
| telegramChatId | String? | ID чата админа с ботом |
| newOrder | Boolean | Новый заказ |
| newOrderMessage | Boolean | Новое сообщение в заказе |
| newReview | Boolean | Новый отзыв |
| authCodeDuplicate | Boolean | Дублировать код входа в TG |
| createdAt | DateTime | |
| updatedAt | DateTime | |
#### NotificationLog (лог отправки)
| Field | Type | Description |
|---|---|---|
| id | String (cuid) | Primary key |
| userId | String? | FK → User (null для админа) |
| eventType | String | Тип события |
| channel | String | 'email' | 'telegram' |
| status | String | 'pending' | 'sent' | 'failed' |
| error | String? | Текст ошибки |
| payload | Json | Данные события |
| attempts | Int | Количество попыток |
| createdAt | DateTime | |
| updatedAt | DateTime | |
### 2.3 Queue
- In-memory массив задач
- Воркер: `setInterval` каждые 2 секунды, максимум 5 параллельных отправок
- Retry: 3 попытки с задержкой 5с, 30с, 120с
- При рестарте сервера: все `pending` записи помечаются как `failed`
## 3. Events
| Event | Triggered in | Payload | Recipients |
|---|---|---|---|
| `order:created` | user-orders.js | orderId, userId, orderData | User (orderCreated), Admin (newOrder) |
| `order:statusChanged` | admin-orders.js | orderId, userId, oldStatus, newStatus | User (orderStatusChanged) |
| `orderMessage:sent` | user-messages.js | orderId, authorType, messageId | Admin (newOrderMessage) |
| `orderMessage:adminReply` | admin-orders.js | orderId, userId, messageId | User (orderMessageReceived) |
| `payment:statusChanged` | user-payments.js | orderId, userId, paymentStatus | User (paymentStatusChanged) |
| `auth:codeRequested` | auth.js | email, code, isAdmin | User (email), Admin (authCodeDuplicate if isAdmin) |
## 4. Data Flow
```
Роут → eventBus.emit(eventType, payload)
→ preferences.resolveRecipients(eventType, payload)
→ для каждого получателя:
→ NotificationLog.create({ status: 'pending' })
→ queue.enqueue({ recipient, channel, eventType, payload })
→ ответ API (без ожидания отправки)
Воркер (каждые 2с, до 5 параллельно):
→ queue.dequeue()
→ channel.send(job)
→ NotificationLog.update({ status: 'sent' | 'failed', attempts++ })
→ если failed и attempts < 3 → re-enqueue с delay
```
## 5. Channel Interface
Каждый канал реализует:
```js
{
name: 'email' | 'telegram',
send(job: { recipient, payload, template }): Promise<{ success: boolean, error?: string }>
}
```
### 5.1 Email Channel
- Использует существующий nodemailer transporter из `email.js`
- `sendNotificationEmail({ to, subject, html })`
- HTML-шаблоны в `email-templates.js`
### 5.2 Telegram Channel
- Telegram Bot API: `POST https://api.telegram.org/bot<TOKEN>/sendMessage`
- `node-telegram-bot-api` или прямой fetch
- Форматирование: HTML parse mode
- Шаблоны в `telegram-templates.js`
## 6. Client-Side
### 6.1 User Notification Settings Page
- Route: `/me/notifications`
- MUI переключатели:
- Главный toggle "Получать оповещения" (globalEnabled)
- При включённом: 4 toggles для каждого типа события
- При выключенном: все остальные toggles disabled
- Сохранение через `apiClient` + `@tanstack/react-query` mutation + invalidate
### 6.2 Admin Notification Settings
- Встраивается в существующую админку
- Toggle email, toggle telegram
- Если telegram включён — поле telegramChatId (заполняется автоматически при /start бота)
- Toggle для каждого типа события + toggle дублирования кода входа
## 7. Error Handling
| Scenario | Behavior |
|---|---|
| SMTP/Telegram недоступен | Retry 3 раза (5с → 30с → 120с), затем failed |
| Невалидный email / chatId | Сразу failed, без retry |
| Ошибка рендера шаблона | failed, лог в NotificationLog.error |
| Сервер рестарт | pending → failed при старте воркера |
## 8. Security
- `TELEGRAM_BOT_TOKEN` — только в `.dev_env`, не коммитится
- Telegram chatId запоминается при `/start` от админа
- Настройки пользователя — только через `fastify.authenticate`
- Настройки админа — только через `fastify.verifyAdmin`
## 9. Extensibility
### Adding a new channel (WhatsApp, Viber, push)
1. Новый файл в `channels/` с интерфейсом `{ name, send(job) }`
2. Регистрация в `queue.js`
3. Никакие другие файлы не меняются
### Adding a new event type
1. Добавить в константы типов событий
2. Добавить поле в `NotificationPreference` / `AdminNotificationSettings`
3. Эмитить через `eventBus.emit()` в нужном роуте
### Adding new recipients (broadcasts)
- `NotificationLog.userId` nullable — поддерживает системные события
- Очередь поддерживает batch-задачи
## 10. Environment Variables
| Variable | Description | Required |
|---|---|---|
| SMTP_HOST | SMTP сервер | Да (для email) |
| SMTP_PORT | SMTP порт | Да |
| SMTP_SECURE | SSL/TLS | Да |
| SMTP_USER | SMTP логин | Да |
| SMTP_PASS | SMTP пароль | Да |
| MAIL_FROM | From address | Да |
| TELEGRAM_BOT_TOKEN | Токен Telegram бота | Для Telegram канала |
## 11. Implementation Notes
- `eventBus` декорируется на fastify instance (как `slugify`, `parseMaterialsInput`)
- `bootstrap-admin.js` создаёт `AdminNotificationSettings` при создании админа
- При создании пользователя — создаётся `NotificationPreference` с defaults (всё включено)
- Существующий `sendLoginCodeEmail` остаётся, добавляется `sendNotificationEmail`
## 12. Telegram Bot — Setup Flow
1. Админ запускает бота командой `/start`
2. Бот проверяет, что sender — админ (сверка email через webhook или ручной ввод `TELEGRAM_ADMIN_CHAT_ID` в `.dev_env`)
3. Если совпадает — сохраняет `chatId` в `AdminNotificationSettings.telegramChatId`
4. Если `telegramChatId` уже установлен — бот просто подтверждает подписку
**Fallback:** если webhook не настроен, админ вручную вписывает свой chatId в настройки админки.
@@ -0,0 +1,94 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { prisma } from '../../prisma.js'
import {
resolveUserNotificationTargets,
resolveAdminNotificationTargets,
resolveAuthCodeTargets,
ensureUserNotificationPreference,
} from '../preferences.js'
import { NOTIFICATION_EVENTS } from '../../../../shared/constants/notification-events.js'
describe('preferences', () => {
beforeEach(async () => {
await prisma.notificationPreference.deleteMany()
await prisma.adminNotificationSettings.deleteMany()
await prisma.user.deleteMany()
})
afterEach(async () => {
await prisma.notificationPreference.deleteMany()
await prisma.adminNotificationSettings.deleteMany()
await prisma.user.deleteMany()
})
it('returns empty targets when user has no preferences', async () => {
const user = await prisma.user.create({ data: { email: 'test@test.com' } })
const targets = await resolveUserNotificationTargets(NOTIFICATION_EVENTS.ORDER_CREATED, { userId: user.id })
expect(targets).toEqual([])
})
it('returns email target when user has preferences enabled', async () => {
const user = await prisma.user.create({ data: { email: 'test@test.com' } })
await prisma.notificationPreference.create({
data: { userId: user.id, globalEnabled: true, orderCreated: true },
})
const targets = await resolveUserNotificationTargets(NOTIFICATION_EVENTS.ORDER_CREATED, { userId: user.id })
expect(targets).toHaveLength(1)
expect(targets[0]).toEqual({ channel: 'email', recipient: 'test@test.com' })
})
it('returns no targets when globalEnabled is false', async () => {
const user = await prisma.user.create({ data: { email: 'test@test.com' } })
await prisma.notificationPreference.create({
data: { userId: user.id, globalEnabled: false, orderCreated: true },
})
const targets = await resolveUserNotificationTargets(NOTIFICATION_EVENTS.ORDER_CREATED, { userId: user.id })
expect(targets).toEqual([])
})
it('returns no targets when specific event is disabled', async () => {
const user = await prisma.user.create({ data: { email: 'test@test.com' } })
await prisma.notificationPreference.create({
data: { userId: user.id, globalEnabled: true, orderCreated: false },
})
const targets = await resolveUserNotificationTargets(NOTIFICATION_EVENTS.ORDER_CREATED, { userId: user.id })
expect(targets).toEqual([])
})
it('ensures user preference is created if not exists', async () => {
const user = await prisma.user.create({ data: { email: 'test@test.com' } })
const prefs = await ensureUserNotificationPreference(user.id)
expect(prefs.globalEnabled).toBe(true)
expect(prefs.userId).toBe(user.id)
})
it('returns admin targets when settings enabled', async () => {
const admin = await prisma.user.create({ data: { email: 'admin@test.com' } })
const origAdminEmail = process.env.ADMIN_EMAIL
process.env.ADMIN_EMAIL = 'admin@test.com'
await prisma.adminNotificationSettings.create({
data: { emailEnabled: true, newOrder: true },
})
const targets = await resolveAdminNotificationTargets(NOTIFICATION_EVENTS.ORDER_CREATED, {})
expect(targets.some((t) => t.channel === 'email' && t.recipient === 'admin@test.com')).toBe(true)
process.env.ADMIN_EMAIL = origAdminEmail
})
it('resolveAuthCodeTargets returns email for user and telegram for admin', async () => {
await prisma.adminNotificationSettings.create({
data: { telegramEnabled: true, telegramChatId: '12345', authCodeDuplicate: true },
})
const targets = await resolveAuthCodeTargets(NOTIFICATION_EVENTS.AUTH_CODE_REQUESTED, {
email: 'user@test.com',
code: '123456',
isAdmin: true,
})
expect(targets.some((t) => t.channel === 'email' && t.recipient === 'user@test.com')).toBe(true)
expect(targets.some((t) => t.channel === 'telegram' && t.recipient === '12345')).toBe(true)
})
})
+1 -1
View File
@@ -1,5 +1,5 @@
import { prisma } from '../prisma.js'
import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js'
import { NOTIFICATION_EVENTS } from '../../../../shared/constants/notification-events.js'
const {
ORDER_CREATED,
+1 -1
View File
@@ -1,5 +1,5 @@
import { prisma } from '../prisma.js'
import { NOTIFICATION_STATUSES, MAX_RETRY_ATTEMPTS, RETRY_DELAYS_MS } from '../../../shared/constants/notification-events.js'
import { NOTIFICATION_STATUSES, MAX_RETRY_ATTEMPTS, RETRY_DELAYS_MS } from '../../../../shared/constants/notification-events.js'
import { emailChannel } from './channels/email-channel.js'
import { telegramChannel } from './channels/telegram-channel.js'