test: add notification preferences tests
This commit is contained in:
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 в настройки админки.
|
||||
Reference in New Issue
Block a user