чуть не забыл
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
ВАЖНО!!! Мы работаем в МОНО-репозитории pnpm, любая установка пакетов производится ЧЕРЕЗ ФИЛЬТР компонента в корне: pnpm add glob --filter notificator2
|
||||
@@ -0,0 +1,316 @@
|
||||
# 🚀 Notificator2 Web Push Service - Implementation Complete
|
||||
|
||||
## ✅ Что реализовано
|
||||
|
||||
Создан полноценный сервис для web-push уведомлений на базе NestJS с интеграцией NOVU, который полностью заменяет Firebase для push уведомлений и предоставляет все необходимые возможности для работы с NOVU.
|
||||
|
||||
## 📦 Архитектура компонентов
|
||||
|
||||
### База данных (PostgreSQL + TypeORM)
|
||||
- **Entity**: `PushSubscription` для хранения подписок пользователей
|
||||
- **Автоматические миграции** и управление схемой БД
|
||||
- **Индексы** для оптимизации запросов по userId и endpoint
|
||||
- **UUID** как первичный ключ для безопасности
|
||||
|
||||
### Сервисы
|
||||
|
||||
#### 🔐 WebPushService
|
||||
**Основной сервис для web-push функциональности:**
|
||||
- Автогенерация и управление VAPID ключами
|
||||
- CRUD операции с push подписками
|
||||
- Отправка уведомлений с полной поддержкой Web Push API
|
||||
- Автоматическая деактивация недействительных подписок
|
||||
- Статистика и мониторинг
|
||||
|
||||
#### 🔗 NovuWebPushService
|
||||
**Интеграция с NOVU:**
|
||||
- Создание NOVU workflows с поддержкой web-push
|
||||
- Обработка webhook событий от NOVU
|
||||
- Template processing с поддержкой переменных
|
||||
- Fallback отправка через web-push API
|
||||
|
||||
#### 🧹 CleanupService
|
||||
**Автоматическое обслуживание:**
|
||||
- Cron job для ежедневной очистки (2:00 AM)
|
||||
- Еженедельная статистика (воскресенье, 12:00)
|
||||
- Ручное управление очисткой через API
|
||||
|
||||
### Контроллеры
|
||||
|
||||
#### 📱 PushController (`/api/push/`)
|
||||
**Основной API для web-push:**
|
||||
```
|
||||
GET /vapid-public-key - Получение VAPID ключа
|
||||
POST /subscribe - Подписка на уведомления
|
||||
DELETE /unsubscribe - Отписка от уведомлений
|
||||
POST /send - Отправка уведомления
|
||||
GET /subscriptions/:userId - Подписки пользователя
|
||||
POST /test/:userId - Тестовое уведомление
|
||||
GET /stats - Статистика подписок
|
||||
DELETE /cleanup - Очистка старых подписок
|
||||
```
|
||||
|
||||
#### 🎯 NovuWebhookController (`/api/novu/`)
|
||||
**NOVU интеграция:**
|
||||
```
|
||||
POST /webhook/push - Webhook для NOVU событий
|
||||
POST /subscribe - Подписка через NOVU
|
||||
GET /integration/config - Конфигурация интеграции
|
||||
POST /test - Тест интеграции
|
||||
POST /workflow/create - Создание workflow
|
||||
```
|
||||
|
||||
## 🎯 Возможности
|
||||
|
||||
### Web Push Features
|
||||
- ✅ **Полная поддержка Web Push API**: title, body, icon, badge, image, actions
|
||||
- ✅ **VAPID аутентификация**: автогенерация ключей
|
||||
- ✅ **Rich notifications**: кнопки, изображения, custom data
|
||||
- ✅ **Множественные подписки** на пользователя
|
||||
- ✅ **Автоматическая очистка** недействительных подписок
|
||||
|
||||
### NOVU Integration
|
||||
- ✅ **Webhook интеграция**: автообработка NOVU событий
|
||||
- ✅ **Workflow создание**: программное создание workflows
|
||||
- ✅ **Template поддержка**: переменные в уведомлениях
|
||||
- ✅ **Dual отправка**: через NOVU + прямая отправка как fallback
|
||||
|
||||
### Мониторинг и обслуживание
|
||||
- ✅ **Автоматизация**: cron jobs для очистки и статистики
|
||||
- ✅ **Статистика**: активные/неактивные подписки, уникальные пользователи
|
||||
- ✅ **Логирование**: подробные логи всех операций
|
||||
- ✅ **Health checks**: проверка работоспособности системы
|
||||
|
||||
## 🛠️ Настройка и запуск
|
||||
|
||||
### 1. Переменные окружения (.env)
|
||||
```env
|
||||
# Приложение
|
||||
PORT=3000
|
||||
CORS_ORIGIN=*
|
||||
|
||||
# NOVU
|
||||
NOVU_API_KEY=your_novu_api_key
|
||||
NOVU_API_URL=https://api.novu.co
|
||||
|
||||
# VAPID (генерируются автоматически)
|
||||
VAPID_PUBLIC_KEY=
|
||||
VAPID_PRIVATE_KEY=
|
||||
VAPID_SUBJECT=mailto:admin@coopenomics.io
|
||||
|
||||
# PostgreSQL
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_USERNAME=postgres
|
||||
DB_PASSWORD=postgres
|
||||
DB_DATABASE=notificator
|
||||
DB_SYNCHRONIZE=true
|
||||
```
|
||||
|
||||
### 2. Запуск
|
||||
```bash
|
||||
# В корне проекта
|
||||
cd components/notificator2
|
||||
|
||||
# Установка зависимостей
|
||||
pnpm install
|
||||
|
||||
# Сборка
|
||||
pnpm build
|
||||
|
||||
# Запуск в production
|
||||
pnpm start:prod
|
||||
|
||||
# Или для разработки
|
||||
pnpm start:dev
|
||||
```
|
||||
|
||||
### 3. База данных
|
||||
```sql
|
||||
-- Создать БД в PostgreSQL
|
||||
CREATE DATABASE notificator;
|
||||
```
|
||||
|
||||
## 💻 Интеграция с фронтендом
|
||||
|
||||
### Service Worker (public/sw.js)
|
||||
```javascript
|
||||
self.addEventListener('push', function(event) {
|
||||
const payload = JSON.parse(event.data.text());
|
||||
const options = {
|
||||
body: payload.body,
|
||||
icon: payload.icon,
|
||||
badge: payload.badge,
|
||||
image: payload.image,
|
||||
actions: payload.actions,
|
||||
data: payload.data,
|
||||
tag: payload.tag,
|
||||
requireInteraction: payload.requireInteraction
|
||||
};
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(payload.title, options)
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', function(event) {
|
||||
event.notification.close();
|
||||
if (event.action === 'view' || !event.action) {
|
||||
const url = event.notification.data?.url || '/';
|
||||
event.waitUntil(clients.openWindow(url));
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Подписка пользователя
|
||||
```javascript
|
||||
async function subscribeToPush(userId) {
|
||||
// 1. Регистрация Service Worker
|
||||
const registration = await navigator.serviceWorker.register('/sw.js');
|
||||
|
||||
// 2. Получение VAPID ключа
|
||||
const { publicKey } = await fetch('/api/push/vapid-public-key')
|
||||
.then(r => r.json());
|
||||
|
||||
// 3. Создание подписки
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: publicKey
|
||||
});
|
||||
|
||||
// 4. Отправка на сервер
|
||||
await fetch('/api/push/subscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userId, subscription })
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Отправка уведомления
|
||||
```javascript
|
||||
// Через прямой API
|
||||
await fetch('/api/push/send', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
userId: 'user123',
|
||||
title: 'Новое сообщение',
|
||||
body: 'У вас есть новое сообщение',
|
||||
icon: '/icons/message.png',
|
||||
url: '/messages',
|
||||
actions: [
|
||||
{ action: 'view', title: 'Открыть' },
|
||||
{ action: 'dismiss', title: 'Закрыть' }
|
||||
]
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
## 🔗 Интеграция с NOVU
|
||||
|
||||
### 1. Настройка webhook в NOVU
|
||||
- URL: `https://your-domain.com/api/novu/webhook/push`
|
||||
- События: `workflow.step.push.sent`
|
||||
|
||||
### 2. Создание workflow с push поддержкой
|
||||
```javascript
|
||||
// Через API сервиса
|
||||
await fetch('/api/novu/workflow/create', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
workflowId: 'user-notifications',
|
||||
workflowName: 'Уведомления пользователей'
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Триггер через NOVU SDK
|
||||
```javascript
|
||||
import { Novu } from '@novu/node';
|
||||
const novu = new Novu(process.env.NOVU_API_KEY);
|
||||
|
||||
await novu.trigger('user-notifications', {
|
||||
to: { subscriberId: 'user123' },
|
||||
payload: {
|
||||
title: 'Новое уведомление',
|
||||
body: 'Описание уведомления',
|
||||
icon: '/icons/notification.png',
|
||||
url: '/dashboard'
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## 📊 Мониторинг и API
|
||||
|
||||
### Получение статистики
|
||||
```bash
|
||||
curl http://localhost:3000/api/push/stats
|
||||
```
|
||||
|
||||
**Ответ:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"total": 150,
|
||||
"active": 120,
|
||||
"inactive": 30,
|
||||
"uniqueUsers": 85
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Тестирование
|
||||
```bash
|
||||
# Отправить тестовое уведомление
|
||||
curl -X POST http://localhost:3000/api/push/test/user123
|
||||
|
||||
# Проверить NOVU интеграцию
|
||||
curl -X POST http://localhost:3000/api/novu/test \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"userId": "user123"}'
|
||||
```
|
||||
|
||||
## 🔒 Безопасность
|
||||
|
||||
- ✅ **VAPID аутентификация** для всех push уведомлений
|
||||
- ✅ **Валидация данных** на всех endpoints
|
||||
- ✅ **CORS настройки** для безопасного API доступа
|
||||
- ✅ **Автоматическая очистка** недействительных подписок
|
||||
- ✅ **Webhook подпись валидация** (готово к настройке для production)
|
||||
|
||||
## ⚡ Производительность
|
||||
|
||||
- ✅ **Индексы БД** для быстрых запросов
|
||||
- ✅ **Batch отправка** уведомлений
|
||||
- ✅ **Connection pooling** для PostgreSQL
|
||||
- ✅ **Graceful error handling** с retry логикой
|
||||
- ✅ **Автоматическая очистка** для поддержания производительности
|
||||
|
||||
## 🎉 Результат
|
||||
|
||||
**Полностью функциональный web-push сервис готов к production использованию!**
|
||||
|
||||
### Для разработчиков:
|
||||
- 📚 Простое REST API с TypeScript типизацией
|
||||
- 🧪 Тестовые скрипты и подробная документация
|
||||
- 🔧 Гибкая настройка и конфигурация
|
||||
|
||||
### Для пользователей:
|
||||
- 📱 Кроссплатформенные push уведомления
|
||||
- 🎨 Rich notifications с кнопками и изображениями
|
||||
- ⚡ Быстрая и надежная доставка
|
||||
|
||||
### Для администраторов:
|
||||
- 📊 Автоматическое обслуживание и статистика
|
||||
- 🔍 Подробные логи и мониторинг
|
||||
- ⚙️ Масштабируемая архитектура
|
||||
|
||||
### Для NOVU интеграции:
|
||||
- 🔗 Полная интеграция с workflow системой
|
||||
- 📨 Webhook поддержка для автоматической отправки
|
||||
- 🔄 Fallback механизмы для надежности
|
||||
|
||||
**Сервис полностью заменяет Firebase для push уведомлений и предоставляет все необходимые возможности для интеграции с NOVU!** 🚀
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
# 📋 Резюме создания компонентов Notifications
|
||||
|
||||
## ✅ Что создано
|
||||
|
||||
### 1. 📚 Библиотека `@monocoop/notifications`
|
||||
**Расположение:** `components/notifications/`
|
||||
|
||||
**Функции:**
|
||||
- ✅ Типизированные интерфейсы для workflow с Zod
|
||||
- ✅ Builder паттерн для создания workflow
|
||||
- ✅ Базовые шаблоны для email, in-app, push, SMS
|
||||
- ✅ Автоматическая конвертация Zod схем в JSON Schema для Novu
|
||||
- ✅ Экспорт всех workflow для использования в других пакетах
|
||||
|
||||
**Структура:**
|
||||
```
|
||||
src/
|
||||
├── types/ # Базовые типы и интерфейсы
|
||||
├── base/ # Утилиты и настройки по умолчанию
|
||||
├── workflows/ # Папки с workflow
|
||||
│ └── welcome/ # Пример приветственного workflow
|
||||
└── index.ts # Главный экспорт
|
||||
```
|
||||
|
||||
### 2. 🚀 NestJS приложение `@monocoop/notificator2`
|
||||
**Расположение:** `components/notificator2/`
|
||||
|
||||
**Функции:**
|
||||
- ✅ Автоматический upsert всех workflow в Novu при запуске
|
||||
- ✅ RESTful API для триггера уведомлений
|
||||
- ✅ Типизированная валидация payload
|
||||
- ✅ Health check endpoints
|
||||
- ✅ Использует библиотеку notifications для типов
|
||||
|
||||
**API Endpoints:**
|
||||
- `GET /api/notifications/health` - Health check
|
||||
- `GET /api/notifications/workflows` - Список workflow
|
||||
- `POST /api/notifications/trigger` - Универсальный триггер
|
||||
- `POST /api/notifications/trigger/welcome` - Триггер welcome workflow
|
||||
- `POST /api/notifications/workflows/upsert-all` - Принудительный upsert
|
||||
|
||||
## 🔧 Как использовать
|
||||
|
||||
### 1. Настройка библиотеки notifications
|
||||
```bash
|
||||
cd components/notifications
|
||||
pnpm install # или npm install
|
||||
pnpm build # для компиляции TypeScript
|
||||
```
|
||||
|
||||
### 2. Настройка notificator2
|
||||
```bash
|
||||
cd components/notificator2
|
||||
pnpm install # или npm install
|
||||
|
||||
# Настройка окружения
|
||||
cp .env.example .env
|
||||
# Добавить NOVU_API_KEY в .env
|
||||
|
||||
# Запуск
|
||||
pnpm start:dev
|
||||
```
|
||||
|
||||
### 3. Добавление нового workflow
|
||||
|
||||
**В библиотеке notifications:**
|
||||
```typescript
|
||||
// components/notifications/src/workflows/order/order-workflow.ts
|
||||
export const orderWorkflow = WorkflowBuilder
|
||||
.create<OrderPayload>()
|
||||
.name('Order Confirmation')
|
||||
.workflowId('order-confirmation')
|
||||
.payloadSchema(orderPayloadSchema)
|
||||
.addSteps([...])
|
||||
.build();
|
||||
```
|
||||
|
||||
**Регистрация в index.ts:**
|
||||
```typescript
|
||||
// components/notifications/src/workflows/index.ts
|
||||
import { orderWorkflow } from './order';
|
||||
|
||||
export const allWorkflows = [
|
||||
welcomeWorkflow,
|
||||
orderWorkflow, // ← добавить новый workflow
|
||||
];
|
||||
```
|
||||
|
||||
### 4. Использование API
|
||||
|
||||
**Триггер welcome workflow:**
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/notifications/trigger/welcome \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"subscriberId": "user-123",
|
||||
"email": "user@example.com",
|
||||
"payload": {
|
||||
"userName": "Иван Иванов",
|
||||
"userEmail": "user@example.com",
|
||||
"age": 25
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
## 🎯 Преимущества архитектуры
|
||||
|
||||
1. **Типобезопасность** - Zod схемы обеспечивают валидацию на уровне TypeScript и runtime
|
||||
2. **Разделение ответственности** - Библиотека содержит только типы, сервер - логику
|
||||
3. **Расширяемость** - Легко добавлять новые workflow
|
||||
4. **Автоматизация** - Workflow автоматически синхронизируются с Novu
|
||||
5. **Переиспользование** - Типы можно использовать в других частях системы
|
||||
|
||||
## 🔄 Workflow при запуске
|
||||
|
||||
1. **Запуск notificator2** →
|
||||
2. **Чтение всех workflow из библиотеки** →
|
||||
3. **Проверка существования в Novu** →
|
||||
4. **Создание/обновление workflow** →
|
||||
5. **Готов к приёму запросов на триггеры**
|
||||
|
||||
## 📁 Структура как в testFramework2.ts
|
||||
|
||||
Вся логика из `testFramework2.ts` была перенесена в structured архитектуру:
|
||||
- ✅ `buildWorkflowData` → `WorkflowBuilder`
|
||||
- ✅ `baseSteps` → `createEmailStep`, `createInAppStep`, etc.
|
||||
- ✅ `upsertWorkflow` → `NovuService.upsertWorkflow`
|
||||
- ✅ `triggerWorkflow` → `NovuService.triggerWorkflow`
|
||||
- ✅ Типизация payload → Zod схемы + TypeScript типы
|
||||
@@ -0,0 +1,14 @@
|
||||
|
||||
# =============================================================================
|
||||
# PUSH NOTIFICATIONS
|
||||
# =============================================================================
|
||||
# URL сервиса notificator2
|
||||
NOTIFICATOR2_URL=http://localhost:3001
|
||||
|
||||
# Секретный ключ для аутентификации запросов к notificator2
|
||||
SERVER_SECRET=your-super-secret-key-here
|
||||
|
||||
NOVU_APP_ID=
|
||||
NOVU_BACKEND_URL=https://novu.coopenomics.world/api
|
||||
NOVU_SOCKET_URL=https://novu.coopenomics.world/ws
|
||||
NOVU_API_KEY=
|
||||
@@ -0,0 +1,19 @@
|
||||
// jest.setup.js
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
// Мокаем config если нужно
|
||||
jest.mock('~/config/config', () => ({
|
||||
default: {
|
||||
jwt: {
|
||||
secret: 'test-secret',
|
||||
accessExpirationMinutes: '60m',
|
||||
},
|
||||
coopname: 'test-coop',
|
||||
novu: {
|
||||
backend_url: 'http://localhost:3000',
|
||||
socket_url: 'ws://localhost:3000',
|
||||
api_key: 'test-api-key',
|
||||
app_id: 'test-app-id',
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1 @@
|
||||
export { default as config } from './config';
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { Socket } from 'socket.io';
|
||||
import config from '~/config/config';
|
||||
import { User } from '~/models';
|
||||
import type { MonoAccountDomainInterface } from '~/domain/account/interfaces/mono-account-domain.interface';
|
||||
|
||||
@Injectable()
|
||||
export class WebSocketJwtAuthGuard implements CanActivate {
|
||||
constructor(private readonly jwtService: JwtService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
try {
|
||||
const client: Socket = context.switchToWs().getClient();
|
||||
|
||||
// Извлекаем токен из разных источников
|
||||
const token =
|
||||
client.handshake.auth?.token ||
|
||||
client.handshake.headers?.authorization?.replace('Bearer ', '') ||
|
||||
client.handshake.query?.token;
|
||||
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Проверяем JWT токен
|
||||
const decoded = this.jwtService.verify(token, { secret: config.jwt.secret });
|
||||
const user = (await User.findById(decoded.sub)) as MonoAccountDomainInterface;
|
||||
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Сохраняем пользователя в контекст сокета
|
||||
client.data.user = user;
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('WebSocket JWT Guard error:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
# Модуль Notifications
|
||||
|
||||
Этот модуль предоставляет прокси-функциональность для работы с NOVU через собственный бэкенд с контролем аутентификации.
|
||||
|
||||
## Функциональность
|
||||
|
||||
- **HTTP Прокси**: Проксирует все HTTP запросы к NOVU API с проверкой аутентификации
|
||||
- **WebSocket Прокси**: Проксирует WebSocket соединения к NOVU с проверкой аутентификации
|
||||
- **Контроль доступа**: Проверяет что пользователь может получать только свои уведомления
|
||||
|
||||
## Компоненты
|
||||
|
||||
### NotificationsController
|
||||
|
||||
- Обрабатывает HTTP запросы к `/notifications/*`
|
||||
- Проверяет JWT токен пользователя
|
||||
- Валидирует subscriberId (формат: `{coopname}-{username}`)
|
||||
- Проксирует запросы к NOVU API
|
||||
|
||||
### NotificationsGateway
|
||||
|
||||
- Обрабатывает WebSocket соединения на `/notifications`
|
||||
- Проверяет JWT токен в handshake
|
||||
- Создает прокси-соединение к NOVU WebSocket
|
||||
- Перенаправляет события между клиентом и NOVU
|
||||
|
||||
### NotificationsService
|
||||
|
||||
- Валидирует права доступа пользователя
|
||||
- Проверяет соответствие subscriberId текущему пользователю
|
||||
- Извлекает subscriberId из различных источников (headers, query params)
|
||||
|
||||
## Настройка
|
||||
|
||||
Необходимые переменные окружения:
|
||||
|
||||
```env
|
||||
NOVU_APP_ID=your-novu-app-id
|
||||
NOVU_BACKEND_URL=https://api.novu.co
|
||||
NOVU_SOCKET_URL=https://ws.novu.co
|
||||
NOVU_API_KEY=your-novu-api-key
|
||||
```
|
||||
|
||||
## Использование на фронтенде
|
||||
|
||||
```javascript
|
||||
// Подключение через наш бэкенд
|
||||
const novu = new NovuUI({
|
||||
options: {
|
||||
applicationIdentifier: env.NOVU_APP_ID,
|
||||
subscriberId: `${coopname}-${username}`,
|
||||
backendUrl: `${env.BACKEND_URL}/notifications`,
|
||||
socketUrl: `${env.BACKEND_URL}/notifications`,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Безопасность
|
||||
|
||||
- Все запросы проходят через JWT аутентификацию
|
||||
- SubscriberId проверяется на соответствие формату `{coopname}-{username}`
|
||||
- Пользователь может получать только свои уведомления
|
||||
- API ключ NOVU не передается на фронтенд
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
import { Controller, All, Req, Res, UseGuards, HttpStatus, Next } from '@nestjs/common';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { createProxyMiddleware, RequestHandler } from 'http-proxy-middleware';
|
||||
import config from '~/config/config';
|
||||
import { GqlJwtAuthGuard } from '~/modules/auth/guards/graphql-jwt-auth.guard';
|
||||
import { NotificationsService } from '../services/notifications.service';
|
||||
import { CurrentUser } from '~/modules/auth/decorators/current-user.decorator';
|
||||
import type { MonoAccountDomainInterface } from '~/domain/account/interfaces/mono-account-domain.interface';
|
||||
|
||||
// Type guard для проверки что объект является Express Response БЕЗ ANY
|
||||
function isExpressResponse(obj: unknown): obj is Response {
|
||||
return (
|
||||
obj !== null && obj !== undefined && typeof obj === 'object' && 'status' in obj && 'json' in obj && 'headersSent' in obj
|
||||
);
|
||||
}
|
||||
|
||||
@Controller('notifications')
|
||||
@UseGuards(GqlJwtAuthGuard)
|
||||
export class NotificationsController {
|
||||
private proxy: RequestHandler;
|
||||
|
||||
constructor(private readonly notificationsService: NotificationsService) {
|
||||
// Создаем прокси middleware для NOVU
|
||||
this.proxy = createProxyMiddleware({
|
||||
target: config.novu.backend_url,
|
||||
changeOrigin: true,
|
||||
pathRewrite: {
|
||||
'^/notifications': '', // Убираем префикс /notifications
|
||||
},
|
||||
headers: {
|
||||
Authorization: `ApiKey ${config.novu.api_key}`,
|
||||
},
|
||||
on: {
|
||||
error: (err: Error, req: Request, res: unknown) => {
|
||||
console.error('Proxy error:', err);
|
||||
// Используем type guard для проверки типа БЕЗ ANY
|
||||
if (isExpressResponse(res) && !res.headersSent) {
|
||||
res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
|
||||
error: 'Ошибка при обращении к сервису уведомлений',
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
}) as RequestHandler;
|
||||
}
|
||||
|
||||
@All('*')
|
||||
async proxyToNovu(
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
@Next() next: NextFunction,
|
||||
@CurrentUser() user: MonoAccountDomainInterface
|
||||
) {
|
||||
try {
|
||||
// Проверяем что пользователь авторизован
|
||||
if (!user || !user.username) {
|
||||
return res.status(HttpStatus.UNAUTHORIZED).json({
|
||||
error: 'Требуется аутентификация',
|
||||
});
|
||||
}
|
||||
|
||||
// Извлекаем subscriberId из различных источников
|
||||
const subscriberId = this.notificationsService.extractSubscriberId(req.headers, req.query);
|
||||
|
||||
// ВАЖНО: Проверяем что subscriberId соответствует текущему пользователю
|
||||
if (subscriberId) {
|
||||
// Ожидаемый формат subscriberId: "{coopname}-{username}"
|
||||
const expectedSubscriberId = `${config.coopname}-${user.username}`;
|
||||
|
||||
if (subscriberId !== expectedSubscriberId) {
|
||||
console.warn(`Access denied: user ${user.username} tried to access subscriberId ${subscriberId}`);
|
||||
return res.status(HttpStatus.FORBIDDEN).json({
|
||||
error: `Доступ запрещен. Можно получать только свои уведомления (${expectedSubscriberId})`,
|
||||
});
|
||||
}
|
||||
|
||||
// Дополнительная валидация через сервис
|
||||
this.notificationsService.validateAccess(user, subscriberId);
|
||||
}
|
||||
|
||||
// Модифицируем заголовки для передачи в NOVU
|
||||
req.headers['x-novu-application-identifier'] = config.novu.app_id;
|
||||
|
||||
// Убираем заголовок authorization чтобы не передавать JWT в NOVU
|
||||
delete req.headers['authorization'];
|
||||
|
||||
// Проксируем запрос к NOVU
|
||||
this.proxy(req, res, next);
|
||||
} catch (error: unknown) {
|
||||
console.error('Notification proxy error:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Ошибка доступа к уведомлениям';
|
||||
res.status(HttpStatus.FORBIDDEN).json({
|
||||
error: errorMessage,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import {
|
||||
WebSocketGateway,
|
||||
OnGatewayConnection,
|
||||
OnGatewayDisconnect,
|
||||
OnGatewayInit,
|
||||
SubscribeMessage,
|
||||
ConnectedSocket,
|
||||
} from '@nestjs/websockets';
|
||||
import { Injectable, Logger, UseGuards } from '@nestjs/common';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import { io } from 'socket.io-client';
|
||||
import type { Socket as ClientSocket } from 'socket.io-client';
|
||||
import config from '~/config/config';
|
||||
import { NotificationsService } from '../services/notifications.service';
|
||||
import { CurrentUser } from '~/modules/auth/decorators/current-user.decorator';
|
||||
import { WebSocketJwtAuthGuard } from '~/modules/auth/guards/websocket-jwt-auth.guard';
|
||||
import type { MonoAccountDomainInterface } from '~/domain/account/interfaces/mono-account-domain.interface';
|
||||
|
||||
@WebSocketGateway({
|
||||
cors: {
|
||||
origin: '*',
|
||||
methods: ['GET', 'POST'],
|
||||
credentials: true,
|
||||
},
|
||||
namespace: '/notifications',
|
||||
})
|
||||
@Injectable()
|
||||
@UseGuards(WebSocketJwtAuthGuard)
|
||||
export class NotificationsGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
|
||||
private readonly logger = new Logger(NotificationsGateway.name);
|
||||
private novuConnections = new Map<string, ClientSocket>();
|
||||
private userSockets = new Map<string, Socket>();
|
||||
|
||||
constructor(private readonly notificationsService: NotificationsService) {}
|
||||
|
||||
afterInit() {
|
||||
this.logger.log('Notifications WebSocket Gateway initialized');
|
||||
}
|
||||
|
||||
async handleConnection(client: Socket, ...args: any[]) {
|
||||
// Пользователь уже аутентифицирован через WebSocketJwtAuthGuard
|
||||
const user = client.data.user as MonoAccountDomainInterface;
|
||||
|
||||
if (!user) {
|
||||
this.logger.warn('User not found in socket context after guard');
|
||||
client.disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
const subscriberId = client.handshake.query?.subscriberId as string;
|
||||
if (!subscriberId) {
|
||||
this.logger.warn('Client connected without subscriberId');
|
||||
client.disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверяем права доступа
|
||||
const expectedSubscriberId = `${config.coopname}-${user.username}`;
|
||||
if (subscriberId !== expectedSubscriberId) {
|
||||
this.logger.warn(`Access denied: user ${user.username} tried to connect with subscriberId ${subscriberId}`);
|
||||
client.disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.notificationsService.validateAccess(user, subscriberId);
|
||||
|
||||
client.data.subscriberId = subscriberId;
|
||||
|
||||
this.logger.log(`Client connected: ${subscriberId}`);
|
||||
this.userSockets.set(subscriberId, client);
|
||||
|
||||
await this.createNovuConnection(subscriberId, client);
|
||||
} catch (error) {
|
||||
this.logger.error('Connection validation error:', error);
|
||||
client.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
async handleDisconnect(client: Socket) {
|
||||
const subscriberId = client.data?.subscriberId;
|
||||
|
||||
if (subscriberId) {
|
||||
this.logger.log(`Client disconnected: ${subscriberId}`);
|
||||
|
||||
const novuConnection = this.novuConnections.get(subscriberId);
|
||||
if (novuConnection) {
|
||||
novuConnection.disconnect();
|
||||
this.novuConnections.delete(subscriberId);
|
||||
}
|
||||
|
||||
this.userSockets.delete(subscriberId);
|
||||
}
|
||||
}
|
||||
|
||||
private async createNovuConnection(subscriberId: string, clientSocket: Socket) {
|
||||
try {
|
||||
const novuSocket = io(config.novu.socket_url, {
|
||||
auth: {
|
||||
applicationIdentifier: config.novu.app_id,
|
||||
subscriberId: subscriberId,
|
||||
},
|
||||
transports: ['websocket'],
|
||||
});
|
||||
|
||||
novuSocket.on('connect', () => {
|
||||
this.logger.log(`Connected to NOVU for subscriber: ${subscriberId}`);
|
||||
});
|
||||
|
||||
novuSocket.on('disconnect', () => {
|
||||
this.logger.log(`Disconnected from NOVU for subscriber: ${subscriberId}`);
|
||||
});
|
||||
|
||||
novuSocket.on('error', (error) => {
|
||||
this.logger.error(`NOVU error for subscriber ${subscriberId}:`, error);
|
||||
clientSocket.emit('error', { message: 'Ошибка соединения с сервисом уведомлений' });
|
||||
});
|
||||
|
||||
novuSocket.onAny((event, ...args) => {
|
||||
this.logger.debug(`Proxying event from NOVU: ${event} for subscriber: ${subscriberId}`);
|
||||
clientSocket.emit(event, ...args);
|
||||
});
|
||||
|
||||
this.novuConnections.set(subscriberId, novuSocket);
|
||||
|
||||
clientSocket.onAny((event, ...args) => {
|
||||
if (!['connect', 'disconnect', 'error'].includes(event)) {
|
||||
this.logger.debug(`Proxying event to NOVU: ${event} for subscriber: ${subscriberId}`);
|
||||
novuSocket.emit(event, ...args);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('Error creating NOVU connection:', error);
|
||||
clientSocket.emit('error', { message: 'Ошибка при создании соединения с сервисом уведомлений' });
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeMessage('ping')
|
||||
handlePing(@ConnectedSocket() client: Socket) {
|
||||
client.emit('pong');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { NotificationsController } from './controllers/notifications.controller';
|
||||
import { NotificationsGateway } from './gateways/notifications.gateway';
|
||||
import { NotificationsService } from './services/notifications.service';
|
||||
import { DomainModule } from '~/domain/domain.module';
|
||||
import { AuthModule } from '~/modules/auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [DomainModule, AuthModule],
|
||||
controllers: [NotificationsController],
|
||||
providers: [NotificationsGateway, NotificationsService],
|
||||
exports: [NotificationsService],
|
||||
})
|
||||
export class NotificationsModule {}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { NotificationsController } from './controllers/notifications.controller';
|
||||
import { NotificationsService } from './services/notifications.service';
|
||||
import { NotificationsGateway } from './gateways/notifications.gateway';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import config from '~/config/config';
|
||||
import type { MonoAccountDomainInterface } from '~/domain/account/interfaces/mono-account-domain.interface';
|
||||
|
||||
describe('NotificationsModule', () => {
|
||||
let controller: NotificationsController;
|
||||
let service: NotificationsService;
|
||||
let gateway: NotificationsGateway;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [NotificationsController],
|
||||
providers: [
|
||||
NotificationsService,
|
||||
NotificationsGateway,
|
||||
{
|
||||
provide: JwtService,
|
||||
useValue: {
|
||||
verify: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<NotificationsController>(NotificationsController);
|
||||
service = module.get<NotificationsService>(NotificationsService);
|
||||
gateway = module.get<NotificationsGateway>(NotificationsGateway);
|
||||
});
|
||||
|
||||
it('должен создать контроллер', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
it('должен создать сервис', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('должен создать gateway', () => {
|
||||
expect(gateway).toBeDefined();
|
||||
});
|
||||
|
||||
describe('NotificationsService', () => {
|
||||
it('должен валидировать правильный subscriberId', () => {
|
||||
const user = { username: 'testuser' } as MonoAccountDomainInterface;
|
||||
const subscriberId = `${config.coopname}-testuser`;
|
||||
|
||||
expect(() => service.validateAccess(user, subscriberId)).not.toThrow();
|
||||
});
|
||||
|
||||
it('должен выбрасывать ошибку для неправильного subscriberId', () => {
|
||||
const user = { username: 'testuser' } as MonoAccountDomainInterface;
|
||||
const subscriberId = `${config.coopname}-anotheruser`;
|
||||
|
||||
expect(() => service.validateAccess(user, subscriberId)).toThrow();
|
||||
});
|
||||
|
||||
it('должен извлекать subscriberId из заголовков', () => {
|
||||
const headers = { 'x-novu-subscriber-id': 'test-subscriber' };
|
||||
const query = {};
|
||||
|
||||
const result = service.extractSubscriberId(headers, query);
|
||||
expect(result).toBe('test-subscriber');
|
||||
});
|
||||
|
||||
it('должен извлекать subscriberId из query параметров', () => {
|
||||
const headers = {};
|
||||
const query = { subscriberId: 'test-subscriber' };
|
||||
|
||||
const result = service.extractSubscriberId(headers, query);
|
||||
expect(result).toBe('test-subscriber');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Injectable, ForbiddenException } from '@nestjs/common';
|
||||
import config from '~/config/config';
|
||||
import type { MonoAccountDomainInterface } from '~/domain/account/interfaces/mono-account-domain.interface';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationsService {
|
||||
/**
|
||||
* Проверяет права доступа пользователя к уведомлениям
|
||||
* @param user - текущий пользователь из JWT
|
||||
* @param subscriberId - ID подписчика из запроса
|
||||
* @returns true если доступ разрешен
|
||||
*/
|
||||
validateAccess(user: MonoAccountDomainInterface, subscriberId: string): boolean {
|
||||
if (!user || !user.username) {
|
||||
throw new ForbiddenException('Требуется аутентификация');
|
||||
}
|
||||
|
||||
// Ожидаемый формат subscriberId: "{coopname}-{username}"
|
||||
const expectedSubscriberId = `${config.coopname}-${user.username}`;
|
||||
|
||||
if (subscriberId !== expectedSubscriberId) {
|
||||
throw new ForbiddenException(
|
||||
`Доступ запрещен. Ожидается subscriberId: ${expectedSubscriberId}, получен: ${subscriberId}`
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет subscriberId из заголовков или параметров
|
||||
* @param headers - заголовки запроса
|
||||
* @param query - параметры запроса
|
||||
* @returns subscriberId из запроса
|
||||
*/
|
||||
extractSubscriberId(headers: Record<string, any>, query: Record<string, any>): string {
|
||||
console.log('extractSubscriberId', headers, query);
|
||||
// Проверяем различные возможные места где может быть subscriberId
|
||||
return headers['x-novu-subscriber-id'] || query.subscriberId || query.subscriber_id || headers['subscriber-id'] || '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
# Notifications Library - Типизированные Workflow для Novu
|
||||
|
||||
Библиотека для создания типизированных workflow уведомлений с использованием Zod схем и паттерна Builder.
|
||||
|
||||
## Особенности
|
||||
|
||||
- 🔒 **Типобезопасность** - Полная типизация payload с Zod
|
||||
- 🏗️ **Builder Pattern** - Удобное создание workflow
|
||||
- 📝 **Декларативный API** - Простое описание шагов уведомлений
|
||||
- 🔧 **Расширяемость** - Легкое добавление новых типов workflow
|
||||
- ⚡ **Валидация** - Автоматическая валидация данных
|
||||
|
||||
## Установка
|
||||
|
||||
```bash
|
||||
cd components/notifications
|
||||
pnpm install
|
||||
```
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
### 1. Создание нового workflow
|
||||
|
||||
```typescript
|
||||
import { z } from 'zod';
|
||||
import { WorkflowBuilder, createEmailStep, createInAppStep } from '@coopenomics/notifications';
|
||||
|
||||
// Определяем схему данных
|
||||
const myPayloadSchema = z.object({
|
||||
userName: z.string(),
|
||||
userEmail: z.string().email(),
|
||||
orderTotal: z.number(),
|
||||
});
|
||||
|
||||
type MyPayload = z.infer<typeof myPayloadSchema>;
|
||||
|
||||
// Создаем workflow
|
||||
export const orderConfirmationWorkflow = WorkflowBuilder
|
||||
.create<MyPayload>()
|
||||
.name('Order Confirmation')
|
||||
.workflowId('order-confirmation')
|
||||
.description('Подтверждение заказа')
|
||||
.payloadSchema(myPayloadSchema)
|
||||
.addSteps([
|
||||
createEmailStep(
|
||||
'order-email',
|
||||
'Заказ подтвержден - {{payload.userName}}',
|
||||
'Здравствуйте, {{payload.userName}}! Ваш заказ на сумму {{payload.orderTotal}} подтвержден.'
|
||||
),
|
||||
createInAppStep(
|
||||
'order-notification',
|
||||
'Заказ обработан',
|
||||
'Заказ успешно обработан и скоро будет отправлен.'
|
||||
),
|
||||
])
|
||||
.build();
|
||||
```
|
||||
|
||||
### 2. Регистрация workflow
|
||||
|
||||
Добавьте ваш workflow в `src/workflows/index.ts`:
|
||||
|
||||
```typescript
|
||||
import { orderConfirmationWorkflow } from './order-confirmation';
|
||||
|
||||
export const allWorkflows: WorkflowDefinition[] = [
|
||||
welcomeWorkflow,
|
||||
orderConfirmationWorkflow, // ← добавляем новый workflow
|
||||
];
|
||||
```
|
||||
|
||||
### 3. Использование в коде
|
||||
|
||||
```typescript
|
||||
import { orderConfirmationWorkflow } from '@coopenomics/notifications';
|
||||
|
||||
// Валидация данных
|
||||
const payload = orderConfirmationWorkflow.payloadZodSchema.parse({
|
||||
userName: 'Иван Иванов',
|
||||
userEmail: 'ivan@example.com',
|
||||
orderTotal: 1500,
|
||||
});
|
||||
|
||||
// Данные для отправки в Novu
|
||||
const novuData = orderConfirmationWorkflow.payloadSchema;
|
||||
```
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
src/
|
||||
├── types/ # Базовые типы и интерфейсы
|
||||
├── base/ # Базовые утилиты и настройки
|
||||
│ ├── defaults.ts # Настройки по умолчанию
|
||||
│ └── workflow-builder.ts # Builder для создания workflow
|
||||
└── workflows/ # Папки с workflow
|
||||
├── welcome/ # Приветственные уведомления
|
||||
├── order/ # Уведомления о заказах
|
||||
└── index.ts # Экспорт всех workflow
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### WorkflowBuilder
|
||||
|
||||
Основной класс для создания workflow:
|
||||
|
||||
```typescript
|
||||
const workflow = WorkflowBuilder
|
||||
.create<PayloadType>()
|
||||
.name('Workflow Name') // Название workflow
|
||||
.workflowId('unique-workflow-id') // Уникальный ID
|
||||
.description('Описание workflow') // Описание (опционально)
|
||||
.payloadSchema(zodSchema) // Zod схема для валидации
|
||||
.addStep(step) // Добавить шаг
|
||||
.addSteps([step1, step2]) // Добавить несколько шагов
|
||||
.origin('external') // Источник (опционально)
|
||||
.build(); // Собрать workflow
|
||||
```
|
||||
|
||||
### Вспомогательные функции
|
||||
|
||||
```typescript
|
||||
// Email уведомление
|
||||
createEmailStep(name, subject, body)
|
||||
|
||||
// In-app уведомление
|
||||
createInAppStep(name, subject, body, avatar?)
|
||||
|
||||
// Push уведомление
|
||||
createPushStep(name, title, body)
|
||||
|
||||
// SMS уведомление
|
||||
createSmsStep(name, body)
|
||||
```
|
||||
|
||||
## Типы workflow
|
||||
|
||||
### Email
|
||||
- `subject` - Тема письма
|
||||
- `body` - HTML содержимое
|
||||
- `editorType` - 'html' | 'text'
|
||||
|
||||
### In-App
|
||||
- `subject` - Заголовок уведомления
|
||||
- `body` - Текст уведомления
|
||||
- `avatar` - URL аватара (опционально)
|
||||
|
||||
### Push
|
||||
- `subject` - Заголовок push-уведомления
|
||||
- `body` - Текст push-уведомления
|
||||
|
||||
### SMS
|
||||
- `body` - Текст SMS
|
||||
|
||||
## Шаблонизация
|
||||
|
||||
Используйте Handlebars синтаксис для динамических данных:
|
||||
|
||||
```typescript
|
||||
// Простая подстановка
|
||||
"Привет, {{payload.userName}}!"
|
||||
|
||||
// Условные блоки
|
||||
"{{#payload.age}}Ваш возраст: {{payload.age}}{{/payload.age}}"
|
||||
|
||||
// Вложенные объекты
|
||||
"{{payload.order.total}} руб."
|
||||
```
|
||||
|
||||
## Сборка
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Результат сборки будет в папке `dist/`.
|
||||
@@ -0,0 +1,37 @@
|
||||
import { PreferencesConfig, ChannelsConfig, ChannelConfig } from '../types';
|
||||
export declare const createChannelConfig: (enabled: boolean, readOnly?: boolean) => ChannelConfig;
|
||||
export declare const createDefaultChannelsConfig: () => ChannelsConfig;
|
||||
export declare const createDefaultPreferences: () => PreferencesConfig;
|
||||
export declare const createEmailStep: (name: string, subject: string, body: string) => {
|
||||
name: string;
|
||||
type: "email";
|
||||
controlValues: {
|
||||
subject: string;
|
||||
body: string;
|
||||
editorType: "html";
|
||||
};
|
||||
};
|
||||
export declare const createInAppStep: (name: string, subject: string, body: string, avatar?: string) => {
|
||||
name: string;
|
||||
type: "in_app";
|
||||
controlValues: {
|
||||
subject: string;
|
||||
body: string;
|
||||
avatar: string;
|
||||
};
|
||||
};
|
||||
export declare const createPushStep: (name: string, title: string, body: string) => {
|
||||
name: string;
|
||||
type: "push";
|
||||
controlValues: {
|
||||
subject: string;
|
||||
body: string;
|
||||
};
|
||||
};
|
||||
export declare const createSmsStep: (name: string, body: string) => {
|
||||
name: string;
|
||||
type: "sms";
|
||||
controlValues: {
|
||||
body: string;
|
||||
};
|
||||
};
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.createSmsStep = exports.createPushStep = exports.createInAppStep = exports.createEmailStep = exports.createDefaultPreferences = exports.createDefaultChannelsConfig = exports.createChannelConfig = void 0;
|
||||
// Базовая конфигурация канала
|
||||
const createChannelConfig = (enabled, readOnly = false) => ({
|
||||
enabled,
|
||||
readOnly,
|
||||
});
|
||||
exports.createChannelConfig = createChannelConfig;
|
||||
// Базовая конфигурация каналов
|
||||
const createDefaultChannelsConfig = () => ({
|
||||
email: (0, exports.createChannelConfig)(true),
|
||||
sms: (0, exports.createChannelConfig)(false),
|
||||
in_app: (0, exports.createChannelConfig)(true),
|
||||
push: (0, exports.createChannelConfig)(false),
|
||||
chat: (0, exports.createChannelConfig)(false),
|
||||
});
|
||||
exports.createDefaultChannelsConfig = createDefaultChannelsConfig;
|
||||
// Базовые preferences для воркфлоу
|
||||
const createDefaultPreferences = () => ({
|
||||
user: {
|
||||
all: (0, exports.createChannelConfig)(true),
|
||||
channels: (0, exports.createDefaultChannelsConfig)(),
|
||||
},
|
||||
workflow: {
|
||||
all: (0, exports.createChannelConfig)(true),
|
||||
channels: (0, exports.createDefaultChannelsConfig)(),
|
||||
},
|
||||
});
|
||||
exports.createDefaultPreferences = createDefaultPreferences;
|
||||
// Вспомогательные функции для создания шагов
|
||||
const createEmailStep = (name, subject, body) => ({
|
||||
name,
|
||||
type: 'email',
|
||||
controlValues: {
|
||||
subject,
|
||||
body,
|
||||
editorType: 'html',
|
||||
},
|
||||
});
|
||||
exports.createEmailStep = createEmailStep;
|
||||
const createInAppStep = (name, subject, body, avatar) => ({
|
||||
name,
|
||||
type: 'in_app',
|
||||
controlValues: {
|
||||
subject,
|
||||
body,
|
||||
avatar: avatar || 'https://novu.coopenomics.world/images/bell.svg',
|
||||
},
|
||||
});
|
||||
exports.createInAppStep = createInAppStep;
|
||||
const createPushStep = (name, title, body) => ({
|
||||
name,
|
||||
type: 'push',
|
||||
controlValues: {
|
||||
subject: title,
|
||||
body,
|
||||
},
|
||||
});
|
||||
exports.createPushStep = createPushStep;
|
||||
const createSmsStep = (name, body) => ({
|
||||
name,
|
||||
type: 'sms',
|
||||
controlValues: {
|
||||
body,
|
||||
},
|
||||
});
|
||||
exports.createSmsStep = createSmsStep;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { z } from 'zod';
|
||||
import { WorkflowDefinition, BaseWorkflowPayload, WorkflowStep, NovuWorkflowData, NovuOrigin } from '../types';
|
||||
export declare class WorkflowBuilder<T extends BaseWorkflowPayload> {
|
||||
private _name;
|
||||
private _workflowId;
|
||||
private _description?;
|
||||
private _steps;
|
||||
private _payloadZodSchema?;
|
||||
private _origin?;
|
||||
static create<T extends BaseWorkflowPayload>(): WorkflowBuilder<T>;
|
||||
name(name: string): this;
|
||||
workflowId(id: string): this;
|
||||
description(description: string): this;
|
||||
origin(origin: NovuOrigin): this;
|
||||
addStep(step: WorkflowStep): this;
|
||||
addSteps(steps: WorkflowStep[]): this;
|
||||
payloadSchema(schema: z.ZodSchema<T>): this;
|
||||
build(): WorkflowDefinition<T>;
|
||||
toNovuData(): NovuWorkflowData;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.WorkflowBuilder = void 0;
|
||||
const zod_to_json_schema_1 = require("zod-to-json-schema");
|
||||
const defaults_1 = require("./defaults");
|
||||
class WorkflowBuilder {
|
||||
constructor() {
|
||||
this._name = '';
|
||||
this._workflowId = '';
|
||||
this._steps = [];
|
||||
}
|
||||
static create() {
|
||||
return new WorkflowBuilder();
|
||||
}
|
||||
name(name) {
|
||||
this._name = name;
|
||||
return this;
|
||||
}
|
||||
workflowId(id) {
|
||||
this._workflowId = id;
|
||||
return this;
|
||||
}
|
||||
description(description) {
|
||||
this._description = description;
|
||||
return this;
|
||||
}
|
||||
origin(origin) {
|
||||
this._origin = origin;
|
||||
return this;
|
||||
}
|
||||
addStep(step) {
|
||||
this._steps.push(step);
|
||||
return this;
|
||||
}
|
||||
addSteps(steps) {
|
||||
this._steps.push(...steps);
|
||||
return this;
|
||||
}
|
||||
payloadSchema(schema) {
|
||||
this._payloadZodSchema = schema;
|
||||
return this;
|
||||
}
|
||||
build() {
|
||||
if (!this._name)
|
||||
throw new Error('Workflow name is required');
|
||||
if (!this._workflowId)
|
||||
throw new Error('Workflow ID is required');
|
||||
if (!this._payloadZodSchema)
|
||||
throw new Error('Payload schema is required');
|
||||
// Преобразуем Zod схему в JSON Schema для Novu
|
||||
const jsonSchema = (0, zod_to_json_schema_1.zodToJsonSchema)(this._payloadZodSchema);
|
||||
// Правильная типизация: zodToJsonSchema может вернуть разные типы схем
|
||||
const payloadSchema = {
|
||||
type: jsonSchema.type || 'object',
|
||||
properties: jsonSchema.properties || {},
|
||||
required: Array.isArray(jsonSchema.required) ? jsonSchema.required : [],
|
||||
};
|
||||
const workflow = {
|
||||
name: this._name,
|
||||
workflowId: this._workflowId,
|
||||
description: this._description,
|
||||
payloadSchema,
|
||||
steps: this._steps,
|
||||
preferences: (0, defaults_1.createDefaultPreferences)(),
|
||||
payloadZodSchema: this._payloadZodSchema,
|
||||
};
|
||||
// Добавляем origin только если он указан
|
||||
if (this._origin) {
|
||||
workflow.origin = this._origin;
|
||||
}
|
||||
return workflow;
|
||||
}
|
||||
// Конвертация в формат для Novu API
|
||||
toNovuData() {
|
||||
const workflow = this.build();
|
||||
const novuData = {
|
||||
name: workflow.name,
|
||||
workflowId: workflow.workflowId,
|
||||
description: workflow.description,
|
||||
payloadSchema: workflow.payloadSchema,
|
||||
steps: workflow.steps,
|
||||
preferences: workflow.preferences,
|
||||
};
|
||||
// Добавляем origin только если он указан
|
||||
if (workflow.origin) {
|
||||
novuData.origin = workflow.origin;
|
||||
}
|
||||
return novuData;
|
||||
}
|
||||
}
|
||||
exports.WorkflowBuilder = WorkflowBuilder;
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export * from './types';
|
||||
export * from './base/defaults';
|
||||
export { WorkflowBuilder } from './base/workflow-builder';
|
||||
export * from './workflows';
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
||||
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.WorkflowBuilder = void 0;
|
||||
// Экспортируем типы
|
||||
__exportStar(require("./types"), exports);
|
||||
// Экспортируем базовые утилиты
|
||||
__exportStar(require("./base/defaults"), exports);
|
||||
var workflow_builder_1 = require("./base/workflow-builder");
|
||||
Object.defineProperty(exports, "WorkflowBuilder", { enumerable: true, get: function () { return workflow_builder_1.WorkflowBuilder; } });
|
||||
// Экспортируем все воркфлоу
|
||||
__exportStar(require("./workflows"), exports);
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
import { z } from 'zod';
|
||||
export interface ChannelConfig {
|
||||
enabled: boolean;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
export interface ChannelsConfig {
|
||||
email: ChannelConfig;
|
||||
sms: ChannelConfig;
|
||||
in_app: ChannelConfig;
|
||||
push: ChannelConfig;
|
||||
chat: ChannelConfig;
|
||||
}
|
||||
export interface PreferencesConfig {
|
||||
user: {
|
||||
all: ChannelConfig;
|
||||
channels: ChannelsConfig;
|
||||
};
|
||||
workflow: {
|
||||
all: ChannelConfig;
|
||||
channels: ChannelsConfig;
|
||||
};
|
||||
}
|
||||
export interface StepControlValues {
|
||||
subject?: string;
|
||||
body?: string;
|
||||
title?: string;
|
||||
content?: string;
|
||||
avatar?: string;
|
||||
editorType?: 'html' | 'text';
|
||||
[key: string]: any;
|
||||
}
|
||||
export interface WorkflowStep {
|
||||
name: string;
|
||||
type: 'email' | 'sms' | 'in_app' | 'push' | 'chat' | 'delay' | 'digest';
|
||||
controlValues: StepControlValues;
|
||||
}
|
||||
export interface BaseWorkflowPayload {
|
||||
[key: string]: any;
|
||||
}
|
||||
export interface PayloadSchema {
|
||||
type: string;
|
||||
properties: Record<string, any>;
|
||||
required: string[];
|
||||
}
|
||||
export type NovuOrigin = 'novu-cloud' | 'novu-cloud-v1' | 'external';
|
||||
export interface WorkflowDefinition<T extends BaseWorkflowPayload = BaseWorkflowPayload> {
|
||||
name: string;
|
||||
workflowId: string;
|
||||
description?: string;
|
||||
payloadSchema: PayloadSchema;
|
||||
steps: WorkflowStep[];
|
||||
preferences: PreferencesConfig;
|
||||
origin?: NovuOrigin;
|
||||
payloadZodSchema: z.ZodSchema<T>;
|
||||
}
|
||||
export interface NovuWorkflowData {
|
||||
name: string;
|
||||
workflowId: string;
|
||||
description?: string;
|
||||
payloadSchema: PayloadSchema;
|
||||
steps: WorkflowStep[];
|
||||
preferences: PreferencesConfig;
|
||||
origin?: NovuOrigin;
|
||||
}
|
||||
export interface WorkflowTriggerData<T extends BaseWorkflowPayload> {
|
||||
workflowId: string;
|
||||
subscriberId: string;
|
||||
payload: T;
|
||||
actor?: {
|
||||
subscriberId: string;
|
||||
email?: string;
|
||||
};
|
||||
}
|
||||
export type WorkflowStepType = WorkflowStep['type'];
|
||||
export type ChannelType = keyof ChannelsConfig;
|
||||
@@ -0,0 +1,2 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
@@ -0,0 +1,4 @@
|
||||
import { WorkflowDefinition } from '../types';
|
||||
export * from './welcome';
|
||||
export declare const allWorkflows: WorkflowDefinition[];
|
||||
export declare const workflowsById: Record<string, WorkflowDefinition<import("../types").BaseWorkflowPayload>>;
|
||||
@@ -0,0 +1,31 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
||||
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.workflowsById = exports.allWorkflows = void 0;
|
||||
// Импортируем все воркфлоу
|
||||
__exportStar(require("./welcome"), exports);
|
||||
// Импорты воркфлоу для регистрации
|
||||
const welcome_1 = require("./welcome");
|
||||
// Массив всех воркфлоу для автоматической регистрации
|
||||
exports.allWorkflows = [
|
||||
welcome_1.welcomeWorkflow,
|
||||
// Здесь будут добавляться новые воркфлоу
|
||||
];
|
||||
// Экспортируем воркфлоу по ID для удобного доступа
|
||||
exports.workflowsById = exports.allWorkflows.reduce((acc, workflow) => {
|
||||
acc[workflow.workflowId] = workflow;
|
||||
return acc;
|
||||
}, {});
|
||||
@@ -0,0 +1,3 @@
|
||||
export { welcomeWorkflow } from './welcome-workflow';
|
||||
export { WelcomeWorkflowPayload, welcomePayloadSchema } from './types';
|
||||
export type { WelcomePayload } from './types';
|
||||
@@ -0,0 +1,7 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.welcomePayloadSchema = exports.welcomeWorkflow = void 0;
|
||||
var welcome_workflow_1 = require("./welcome-workflow");
|
||||
Object.defineProperty(exports, "welcomeWorkflow", { enumerable: true, get: function () { return welcome_workflow_1.welcomeWorkflow; } });
|
||||
var types_1 = require("./types");
|
||||
Object.defineProperty(exports, "welcomePayloadSchema", { enumerable: true, get: function () { return types_1.welcomePayloadSchema; } });
|
||||
@@ -0,0 +1,18 @@
|
||||
import { z } from 'zod';
|
||||
import { BaseWorkflowPayload } from '../../types';
|
||||
export declare const welcomePayloadSchema: z.ZodObject<{
|
||||
userName: z.ZodString;
|
||||
userEmail: z.ZodString;
|
||||
age: z.ZodOptional<z.ZodNumber>;
|
||||
}, "strip", z.ZodTypeAny, {
|
||||
userName: string;
|
||||
userEmail: string;
|
||||
age?: number | undefined;
|
||||
}, {
|
||||
userName: string;
|
||||
userEmail: string;
|
||||
age?: number | undefined;
|
||||
}>;
|
||||
export type WelcomePayload = z.infer<typeof welcomePayloadSchema>;
|
||||
export interface WelcomeWorkflowPayload extends BaseWorkflowPayload, WelcomePayload {
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.welcomePayloadSchema = void 0;
|
||||
const zod_1 = require("zod");
|
||||
// Схема для welcome воркфлоу
|
||||
exports.welcomePayloadSchema = zod_1.z.object({
|
||||
userName: zod_1.z.string(),
|
||||
userEmail: zod_1.z.string().email(),
|
||||
age: zod_1.z.number().optional(),
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import { WorkflowDefinition } from '../../types';
|
||||
import { WelcomeWorkflowPayload } from './types';
|
||||
export declare const welcomeWorkflow: WorkflowDefinition<WelcomeWorkflowPayload>;
|
||||
@@ -0,0 +1,18 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.welcomeWorkflow = void 0;
|
||||
const workflow_builder_1 = require("../../base/workflow-builder");
|
||||
const defaults_1 = require("../../base/defaults");
|
||||
const types_1 = require("./types");
|
||||
exports.welcomeWorkflow = workflow_builder_1.WorkflowBuilder
|
||||
.create()
|
||||
.name('Welcome Workflow')
|
||||
.workflowId('welcome-workflow')
|
||||
.description('Приветственные уведомления для новых пользователей')
|
||||
.payloadSchema(types_1.welcomePayloadSchema)
|
||||
.addSteps([
|
||||
(0, defaults_1.createEmailStep)('welcome-email', 'Добро пожаловать, {{payload.userName}}!', 'Здравствуй, {{payload.userName}}! Ваш email: {{payload.userEmail}}. {{payload.age}}Ваш возраст: {{payload.age}} лет.{{payload.age}}'),
|
||||
(0, defaults_1.createInAppStep)('welcome-notification', 'Добро пожаловать в систему', 'Привет, {{payload.userName}}! Проверьте ваш email {{payload.userEmail}} для получения дополнительной информации.'),
|
||||
(0, defaults_1.createPushStep)('welcome-push', 'Добро пожаловать, {{payload.userName}}!', 'Это приветственное push-уведомление для {{payload.userEmail}}.'),
|
||||
])
|
||||
.build();
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@coopenomics/notifications",
|
||||
"version": "1.0.0",
|
||||
"description": "Typesafe notification workflows library for Novu",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3.22.4",
|
||||
"zod-to-json-schema": "^3.22.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { PreferencesConfig, ChannelsConfig, ChannelConfig } from '../types';
|
||||
|
||||
// Базовая конфигурация канала
|
||||
export const createChannelConfig = (enabled: boolean, readOnly = false): ChannelConfig => ({
|
||||
enabled,
|
||||
readOnly,
|
||||
});
|
||||
|
||||
// Базовая конфигурация каналов
|
||||
export const createDefaultChannelsConfig = (): ChannelsConfig => ({
|
||||
email: createChannelConfig(true),
|
||||
sms: createChannelConfig(false),
|
||||
in_app: createChannelConfig(true),
|
||||
push: createChannelConfig(false),
|
||||
chat: createChannelConfig(false),
|
||||
});
|
||||
|
||||
// Базовые preferences для воркфлоу
|
||||
export const createDefaultPreferences = (): PreferencesConfig => ({
|
||||
user: {
|
||||
all: createChannelConfig(true),
|
||||
channels: createDefaultChannelsConfig(),
|
||||
},
|
||||
workflow: {
|
||||
all: createChannelConfig(true),
|
||||
channels: createDefaultChannelsConfig(),
|
||||
},
|
||||
});
|
||||
|
||||
// Вспомогательные функции для создания шагов
|
||||
export const createEmailStep = (name: string, subject: string, body: string) => ({
|
||||
name,
|
||||
type: 'email' as const,
|
||||
controlValues: {
|
||||
subject,
|
||||
body,
|
||||
editorType: 'html' as const,
|
||||
},
|
||||
});
|
||||
|
||||
export const createInAppStep = (name: string, subject: string, body: string, avatar?: string) => ({
|
||||
name,
|
||||
type: 'in_app' as const,
|
||||
controlValues: {
|
||||
subject,
|
||||
body,
|
||||
avatar: avatar || 'https://novu.coopenomics.world/images/bell.svg',
|
||||
},
|
||||
});
|
||||
|
||||
export const createPushStep = (name: string, title: string, body: string) => ({
|
||||
name,
|
||||
type: 'push' as const,
|
||||
controlValues: {
|
||||
subject: title,
|
||||
body,
|
||||
},
|
||||
});
|
||||
|
||||
export const createSmsStep = (name: string, body: string) => ({
|
||||
name,
|
||||
type: 'sms' as const,
|
||||
controlValues: {
|
||||
body,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { z } from 'zod';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
import {
|
||||
WorkflowDefinition,
|
||||
BaseWorkflowPayload,
|
||||
WorkflowStep,
|
||||
PayloadSchema,
|
||||
NovuWorkflowData,
|
||||
NovuOrigin
|
||||
} from '../types';
|
||||
import { createDefaultPreferences } from './defaults';
|
||||
|
||||
export class WorkflowBuilder<T extends BaseWorkflowPayload> {
|
||||
private _name: string = '';
|
||||
private _workflowId: string = '';
|
||||
private _description?: string;
|
||||
private _steps: WorkflowStep[] = [];
|
||||
private _payloadZodSchema?: z.ZodSchema<T>;
|
||||
private _origin?: NovuOrigin;
|
||||
|
||||
static create<T extends BaseWorkflowPayload>(): WorkflowBuilder<T> {
|
||||
return new WorkflowBuilder<T>();
|
||||
}
|
||||
|
||||
name(name: string): this {
|
||||
this._name = name;
|
||||
return this;
|
||||
}
|
||||
|
||||
workflowId(id: string): this {
|
||||
this._workflowId = id;
|
||||
return this;
|
||||
}
|
||||
|
||||
description(description: string): this {
|
||||
this._description = description;
|
||||
return this;
|
||||
}
|
||||
|
||||
origin(origin: NovuOrigin): this {
|
||||
this._origin = origin;
|
||||
return this;
|
||||
}
|
||||
|
||||
addStep(step: WorkflowStep): this {
|
||||
this._steps.push(step);
|
||||
return this;
|
||||
}
|
||||
|
||||
addSteps(steps: WorkflowStep[]): this {
|
||||
this._steps.push(...steps);
|
||||
return this;
|
||||
}
|
||||
|
||||
payloadSchema(schema: z.ZodSchema<T>): this {
|
||||
this._payloadZodSchema = schema;
|
||||
return this;
|
||||
}
|
||||
|
||||
build(): WorkflowDefinition<T> {
|
||||
if (!this._name) throw new Error('Workflow name is required');
|
||||
if (!this._workflowId) throw new Error('Workflow ID is required');
|
||||
if (!this._payloadZodSchema) throw new Error('Payload schema is required');
|
||||
|
||||
// Преобразуем Zod схему в JSON Schema для Novu
|
||||
const jsonSchema = zodToJsonSchema(this._payloadZodSchema);
|
||||
|
||||
// Правильная типизация: zodToJsonSchema может вернуть разные типы схем
|
||||
const payloadSchema: PayloadSchema = {
|
||||
type: (jsonSchema as any).type || 'object',
|
||||
properties: (jsonSchema as any).properties || {},
|
||||
required: Array.isArray((jsonSchema as any).required) ? (jsonSchema as any).required : [],
|
||||
};
|
||||
|
||||
const workflow: WorkflowDefinition<T> = {
|
||||
name: this._name,
|
||||
workflowId: this._workflowId,
|
||||
description: this._description,
|
||||
payloadSchema,
|
||||
steps: this._steps,
|
||||
preferences: createDefaultPreferences(),
|
||||
payloadZodSchema: this._payloadZodSchema,
|
||||
};
|
||||
|
||||
// Добавляем origin только если он указан
|
||||
if (this._origin) {
|
||||
workflow.origin = this._origin;
|
||||
}
|
||||
|
||||
return workflow;
|
||||
}
|
||||
|
||||
// Конвертация в формат для Novu API
|
||||
toNovuData(): NovuWorkflowData {
|
||||
const workflow = this.build();
|
||||
const novuData: NovuWorkflowData = {
|
||||
name: workflow.name,
|
||||
workflowId: workflow.workflowId,
|
||||
description: workflow.description,
|
||||
payloadSchema: workflow.payloadSchema,
|
||||
steps: workflow.steps,
|
||||
preferences: workflow.preferences,
|
||||
};
|
||||
|
||||
// Добавляем origin только если он указан
|
||||
if (workflow.origin) {
|
||||
novuData.origin = workflow.origin;
|
||||
}
|
||||
|
||||
return novuData;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Экспортируем типы
|
||||
export * from './types';
|
||||
|
||||
// Экспортируем базовые утилиты
|
||||
export * from './base/defaults';
|
||||
export { WorkflowBuilder } from './base/workflow-builder';
|
||||
|
||||
// Экспортируем все воркфлоу
|
||||
export * from './workflows';
|
||||
@@ -0,0 +1,97 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// Базовые типы для каналов уведомлений
|
||||
export interface ChannelConfig {
|
||||
enabled: boolean;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface ChannelsConfig {
|
||||
email: ChannelConfig;
|
||||
sms: ChannelConfig;
|
||||
in_app: ChannelConfig;
|
||||
push: ChannelConfig;
|
||||
chat: ChannelConfig;
|
||||
}
|
||||
|
||||
export interface PreferencesConfig {
|
||||
user: {
|
||||
all: ChannelConfig;
|
||||
channels: ChannelsConfig;
|
||||
};
|
||||
workflow: {
|
||||
all: ChannelConfig;
|
||||
channels: ChannelsConfig;
|
||||
};
|
||||
}
|
||||
|
||||
// Базовые типы для шагов воркфлоу
|
||||
export interface StepControlValues {
|
||||
subject?: string;
|
||||
body?: string;
|
||||
title?: string;
|
||||
content?: string;
|
||||
avatar?: string;
|
||||
editorType?: 'html' | 'text';
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface WorkflowStep {
|
||||
name: string;
|
||||
type: 'email' | 'sms' | 'in_app' | 'push' | 'chat' | 'delay' | 'digest';
|
||||
controlValues: StepControlValues;
|
||||
}
|
||||
|
||||
// Базовый интерфейс для payload воркфлоу
|
||||
export interface BaseWorkflowPayload {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// Интерфейс для схемы payload (JSON Schema)
|
||||
export interface PayloadSchema {
|
||||
type: string;
|
||||
properties: Record<string, any>;
|
||||
required: string[];
|
||||
}
|
||||
|
||||
// Допустимые значения для origin в Novu
|
||||
export type NovuOrigin = 'novu-cloud' | 'novu-cloud-v1' | 'external';
|
||||
|
||||
// Главный интерфейс для воркфлоу
|
||||
export interface WorkflowDefinition<T extends BaseWorkflowPayload = BaseWorkflowPayload> {
|
||||
name: string;
|
||||
workflowId: string;
|
||||
description?: string;
|
||||
payloadSchema: PayloadSchema;
|
||||
steps: WorkflowStep[];
|
||||
preferences: PreferencesConfig;
|
||||
origin?: NovuOrigin; // Делаем optional
|
||||
// Типизированная схема для валидации payload
|
||||
payloadZodSchema: z.ZodSchema<T>;
|
||||
}
|
||||
|
||||
// Интерфейс для создания воркфлоу в Novu (без origin для создания)
|
||||
export interface NovuWorkflowData {
|
||||
name: string;
|
||||
workflowId: string;
|
||||
description?: string;
|
||||
payloadSchema: PayloadSchema;
|
||||
steps: WorkflowStep[];
|
||||
preferences: PreferencesConfig;
|
||||
origin?: NovuOrigin; // Optional - только для обновлений
|
||||
}
|
||||
|
||||
// Интерфейс для триггера воркфлоу
|
||||
export interface WorkflowTriggerData<T extends BaseWorkflowPayload> {
|
||||
workflowId: string;
|
||||
subscriberId: string;
|
||||
payload: T;
|
||||
actor?: {
|
||||
subscriberId: string;
|
||||
email?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Утилитарные типы
|
||||
export type WorkflowStepType = WorkflowStep['type'];
|
||||
export type ChannelType = keyof ChannelsConfig;
|
||||
@@ -0,0 +1,19 @@
|
||||
import { WorkflowDefinition } from '../types';
|
||||
|
||||
// Импортируем все воркфлоу
|
||||
export * from './welcome';
|
||||
|
||||
// Импорты воркфлоу для регистрации
|
||||
import { welcomeWorkflow } from './welcome';
|
||||
|
||||
// Массив всех воркфлоу для автоматической регистрации
|
||||
export const allWorkflows: WorkflowDefinition[] = [
|
||||
welcomeWorkflow,
|
||||
// Здесь будут добавляться новые воркфлоу
|
||||
];
|
||||
|
||||
// Экспортируем воркфлоу по ID для удобного доступа
|
||||
export const workflowsById = allWorkflows.reduce((acc, workflow) => {
|
||||
acc[workflow.workflowId] = workflow;
|
||||
return acc;
|
||||
}, {} as Record<string, WorkflowDefinition>);
|
||||
@@ -0,0 +1,3 @@
|
||||
export { welcomeWorkflow } from './welcome-workflow';
|
||||
export { WelcomeWorkflowPayload, welcomePayloadSchema } from './types';
|
||||
export type { WelcomePayload } from './types';
|
||||
@@ -0,0 +1,13 @@
|
||||
import { z } from 'zod';
|
||||
import { BaseWorkflowPayload } from '../../types';
|
||||
|
||||
// Схема для welcome воркфлоу
|
||||
export const welcomePayloadSchema = z.object({
|
||||
userName: z.string(),
|
||||
userEmail: z.string().email(),
|
||||
age: z.number().optional(),
|
||||
});
|
||||
|
||||
export type WelcomePayload = z.infer<typeof welcomePayloadSchema>;
|
||||
|
||||
export interface WelcomeWorkflowPayload extends BaseWorkflowPayload, WelcomePayload {}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { WorkflowDefinition } from '../../types';
|
||||
import { WorkflowBuilder } from '../../base/workflow-builder';
|
||||
import { createEmailStep, createInAppStep, createPushStep } from '../../base/defaults';
|
||||
import { WelcomeWorkflowPayload, welcomePayloadSchema } from './types';
|
||||
|
||||
export const welcomeWorkflow: WorkflowDefinition<WelcomeWorkflowPayload> = WorkflowBuilder
|
||||
.create<WelcomeWorkflowPayload>()
|
||||
.name('Welcome Workflow')
|
||||
.workflowId('welcome-workflow')
|
||||
.description('Приветственные уведомления для новых пользователей')
|
||||
.payloadSchema(welcomePayloadSchema)
|
||||
.addSteps([
|
||||
createEmailStep(
|
||||
'welcome-email',
|
||||
'Добро пожаловать, {{payload.userName}}!',
|
||||
'Здравствуй, {{payload.userName}}! Ваш email: {{payload.userEmail}}. {{payload.age}}Ваш возраст: {{payload.age}} лет.{{payload.age}}'
|
||||
),
|
||||
createInAppStep(
|
||||
'welcome-notification',
|
||||
'Добро пожаловать в систему',
|
||||
'Привет, {{payload.userName}}! Проверьте ваш email {{payload.userEmail}} для получения дополнительной информации.'
|
||||
),
|
||||
createPushStep(
|
||||
'welcome-push',
|
||||
'Добро пожаловать, {{payload.userName}}!',
|
||||
'Это приветственное push-уведомление для {{payload.userEmail}}.'
|
||||
),
|
||||
])
|
||||
.build();
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
# =============================================================================
|
||||
# NOTIFICATOR2 ENVIRONMENT CONFIGURATION
|
||||
# =============================================================================
|
||||
|
||||
# Основные настройки приложения
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
API_PREFIX=api
|
||||
|
||||
# CORS настройки
|
||||
CORS_ORIGIN=http://localhost:3000,http://localhost:3001,http://localhost:8080
|
||||
|
||||
# =============================================================================
|
||||
# DATABASE CONFIGURATION (PostgreSQL)
|
||||
# =============================================================================
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_USERNAME=postgres
|
||||
DB_PASSWORD=your_password
|
||||
DB_DATABASE=notificator2_db
|
||||
|
||||
# Опционально для production
|
||||
# DB_URL=postgresql://username:password@localhost:5432/notificator2_db
|
||||
# DB_SSL=true
|
||||
# DB_SYNCHRONIZE=false
|
||||
|
||||
# =============================================================================
|
||||
# NOVU INTEGRATION
|
||||
# =============================================================================
|
||||
NOVU_API_KEY=your_novu_api_key_here
|
||||
NOVU_API_URL=https://api.novu.co
|
||||
|
||||
# Опционально для кастомной установки NOVU
|
||||
# NOVU_API_URL=https://your-custom-novu-instance.com
|
||||
|
||||
# =============================================================================
|
||||
# WEB PUSH CONFIGURATION
|
||||
# =============================================================================
|
||||
|
||||
# VAPID ключи для Web Push (если не указаны - генерируются автоматически)
|
||||
# VAPID_PUBLIC_KEY=your_vapid_public_key
|
||||
# VAPID_PRIVATE_KEY=your_vapid_private_key
|
||||
|
||||
# VAPID subject (обязательно)
|
||||
VAPID_SUBJECT=mailto:admin@coopenomics.io
|
||||
|
||||
# Настройки уведомлений
|
||||
WEB_PUSH_DEFAULT_ICON=/icons/default-icon.png
|
||||
WEB_PUSH_DEFAULT_BADGE=/icons/badge.png
|
||||
|
||||
# TTL для push уведомлений (в секундах, по умолчанию 24 часа)
|
||||
WEB_PUSH_TTL=86400
|
||||
|
||||
# =============================================================================
|
||||
# CLEANUP & MAINTENANCE
|
||||
# =============================================================================
|
||||
|
||||
# Количество дней для автоматической очистки неактивных подписок
|
||||
CLEANUP_INACTIVE_DAYS=30
|
||||
|
||||
# Включить автоматическую очистку по расписанию
|
||||
CLEANUP_CRON_ENABLED=true
|
||||
|
||||
# Настройки cron для очистки (по умолчанию каждый день в 2:00)
|
||||
CLEANUP_CRON_SCHEDULE="0 2 * * *"
|
||||
|
||||
# Настройки cron для статистики (по умолчанию каждое воскресенье в 12:00)
|
||||
STATS_CRON_SCHEDULE="0 12 * * 0"
|
||||
|
||||
# =============================================================================
|
||||
# LOGGING
|
||||
# =============================================================================
|
||||
|
||||
# Уровень логирования
|
||||
LOG_LEVEL=info
|
||||
|
||||
# Логирование в файл (опционально)
|
||||
# LOG_FILE=logs/notificator2.log
|
||||
# LOG_MAX_SIZE=10MB
|
||||
# LOG_MAX_FILES=5
|
||||
|
||||
# =============================================================================
|
||||
# SECURITY
|
||||
# =============================================================================
|
||||
|
||||
# JWT Secret (если используется аутентификация)
|
||||
# JWT_SECRET=your_jwt_secret_here
|
||||
|
||||
# API Rate Limiting
|
||||
# RATE_LIMIT_TTL=60
|
||||
# RATE_LIMIT_LIMIT=100
|
||||
|
||||
# =============================================================================
|
||||
# MONITORING & HEALTH CHECK
|
||||
# =============================================================================
|
||||
|
||||
# Включить мониторинг производительности
|
||||
MONITORING_ENABLED=true
|
||||
|
||||
# Timeout для health check (в миллисекундах)
|
||||
HEALTH_CHECK_TIMEOUT=5000
|
||||
|
||||
# =============================================================================
|
||||
# DEVELOPMENT SETTINGS
|
||||
# =============================================================================
|
||||
|
||||
# Включить подробное логирование запросов в development
|
||||
DEBUG_REQUESTS=true
|
||||
|
||||
# Включить hot reload в development
|
||||
HOT_RELOAD=true
|
||||
|
||||
# Fake push уведомления в development (не отправлять реальные push)
|
||||
FAKE_PUSH_IN_DEV=false
|
||||
|
||||
# =============================================================================
|
||||
# SERVER AUTHENTICATION
|
||||
# =============================================================================
|
||||
# Секретный ключ для аутентификации запросов от controller
|
||||
SERVER_SECRET=your-super-secret-key-here
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
.env
|
||||
@@ -0,0 +1,274 @@
|
||||
# Implementation Summary: Web Push Notification Service
|
||||
|
||||
## Обзор реализации
|
||||
|
||||
Создан полноценный сервис для web-push уведомлений на базе NestJS с интеграцией NOVU. Сервис предоставляет все необходимые компоненты для работы с push уведомлениями без использования Firebase.
|
||||
|
||||
## Архитектура и компоненты
|
||||
|
||||
### 🗂️ База данных (TypeORM + PostgreSQL)
|
||||
|
||||
**Entity: `PushSubscription`**
|
||||
- Хранение push подписок пользователей
|
||||
- Автоматическое управление активностью подписок
|
||||
- Индексы для оптимизации запросов
|
||||
- UUID в качестве первичного ключа
|
||||
- Поддержка множественных подписок на пользователя
|
||||
|
||||
**Поля:**
|
||||
- `id`: UUID подписки
|
||||
- `userId`: Идентификатор пользователя
|
||||
- `endpoint`: URL endpoint для push уведомлений
|
||||
- `p256dhKey`, `authKey`: Ключи шифрования
|
||||
- `userAgent`: Информация о браузере
|
||||
- `isActive`: Статус активности подписки
|
||||
- `createdAt`, `updatedAt`: Временные метки
|
||||
|
||||
### 🔧 Сервисы
|
||||
|
||||
#### **WebPushService**
|
||||
Основной сервис для работы с web-push:
|
||||
|
||||
- **VAPID управление**: Автогенерация и настройка VAPID ключей
|
||||
- **Управление подписками**: Создание, обновление, деактивация
|
||||
- **Отправка уведомлений**: Поддержка всех стандартных полей web-push
|
||||
- **Автоочистка**: Удаление недействительных подписок
|
||||
- **Статистика**: Мониторинг активности подписок
|
||||
|
||||
**Ключевые методы:**
|
||||
```typescript
|
||||
- getVapidPublicKey(): string
|
||||
- saveSubscription(userId, subscription, userAgent?)
|
||||
- sendNotificationToUser(userId, payload)
|
||||
- sendNotificationToAll(payload)
|
||||
- getSubscriptionStats()
|
||||
- cleanupInactiveSubscriptions(days)
|
||||
```
|
||||
|
||||
#### **NovuWebPushService**
|
||||
Сервис интеграции с NOVU:
|
||||
|
||||
- **Workflow интеграция**: Создание NOVU workflows с push поддержкой
|
||||
- **Webhook обработка**: Автоматическая отправка при NOVU событиях
|
||||
- **Template обработка**: Поддержка переменных в уведомлениях
|
||||
- **Fallback отправка**: Дублирование через web-push API
|
||||
|
||||
#### **CleanupService**
|
||||
Автоматизированное обслуживание:
|
||||
|
||||
- **Cron задачи**: Автоочистка каждый день в 2:00 AM
|
||||
- **Статистика**: Еженедельные отчеты по воскресеньям
|
||||
- **Ручное управление**: API для принудительной очистки
|
||||
|
||||
### 🎮 Контроллеры
|
||||
|
||||
#### **PushController (`/api/push`)**
|
||||
Основной API для управления push уведомлениями:
|
||||
|
||||
**Endpoints:**
|
||||
- `GET /vapid-public-key` - Получение VAPID ключа
|
||||
- `POST /subscribe` - Подписка на уведомления
|
||||
- `DELETE /unsubscribe` - Отписка
|
||||
- `POST /send` - Отправка уведомления
|
||||
- `GET /subscriptions/:userId` - Подписки пользователя
|
||||
- `POST /test/:userId` - Тестовое уведомление
|
||||
- `GET /stats` - Статистика подписок
|
||||
- `DELETE /cleanup` - Очистка старых подписок
|
||||
|
||||
#### **NovuWebhookController (`/api/novu`)**
|
||||
API для интеграции с NOVU:
|
||||
|
||||
**Endpoints:**
|
||||
- `POST /webhook/push` - Webhook для NOVU событий
|
||||
- `POST /subscribe` - Подписка через NOVU
|
||||
- `GET /integration/config` - Конфигурация интеграции
|
||||
- `POST /test` - Тест интеграции
|
||||
- `POST /workflow/create` - Создание workflow
|
||||
|
||||
### 📦 Модули
|
||||
|
||||
#### **WebPushModule**
|
||||
Объединяет все компоненты web-push функциональности:
|
||||
- Импорт TypeORM сущностей
|
||||
- Регистрация всех сервисов и контроллеров
|
||||
- Экспорт основных сервисов для использования в других модулях
|
||||
|
||||
#### **AppModule** (обновлен)
|
||||
- Добавлена конфигурация TypeORM для PostgreSQL
|
||||
- Интеграция WebPushModule
|
||||
- Настройки подключения к БД через переменные окружения
|
||||
|
||||
## Возможности и функционал
|
||||
|
||||
### ✨ Web Push Features
|
||||
|
||||
1. **Полная поддержка Web Push API**:
|
||||
- Title, body, icon, badge, image
|
||||
- Actions (кнопки в уведомлении)
|
||||
- Custom data payload
|
||||
- TTL, urgency, topic settings
|
||||
|
||||
2. **VAPID аутентификация**:
|
||||
- Автогенерация ключей при первом запуске
|
||||
- Безопасная передача публичного ключа клиентам
|
||||
- Настройка subject для идентификации
|
||||
|
||||
3. **Управление подписками**:
|
||||
- Множественные подписки на пользователя
|
||||
- Автоматическая деактивация недействительных endpoint'ов
|
||||
- Поддержка различных браузеров и устройств
|
||||
|
||||
### 🔗 NOVU Integration
|
||||
|
||||
1. **Webhook интеграция**:
|
||||
- Автоматическая обработка NOVU событий
|
||||
- Поддержка template переменных
|
||||
- Валидация входящих данных
|
||||
|
||||
2. **Workflow создание**:
|
||||
- Программное создание workflows с push поддержкой
|
||||
- Определение payload схем
|
||||
- Настройка preferences
|
||||
|
||||
3. **Dual отправка**:
|
||||
- Через NOVU систему
|
||||
- Прямая отправка как fallback
|
||||
|
||||
### 📊 Мониторинг и обслуживание
|
||||
|
||||
1. **Автоматизация**:
|
||||
- Ежедневная очистка неактивных подписок
|
||||
- Еженедельная статистика
|
||||
- Автоматическая деактивация недоступных endpoint'ов
|
||||
|
||||
2. **Статистика**:
|
||||
- Общее количество подписок
|
||||
- Активные/неактивные подписки
|
||||
- Уникальные пользователи
|
||||
- Процент активности
|
||||
|
||||
3. **Логирование**:
|
||||
- Детальные логи всех операций
|
||||
- Разные уровни важности
|
||||
- Трекинг ошибок отправки
|
||||
|
||||
## Настройка и деплой
|
||||
|
||||
### Переменные окружения
|
||||
|
||||
```env
|
||||
# Основные настройки
|
||||
PORT=3000
|
||||
CORS_ORIGIN=*
|
||||
|
||||
# NOVU
|
||||
NOVU_API_KEY=your_api_key
|
||||
NOVU_API_URL=https://api.novu.co
|
||||
|
||||
# VAPID (генерируются автоматически)
|
||||
VAPID_PUBLIC_KEY=
|
||||
VAPID_PRIVATE_KEY=
|
||||
VAPID_SUBJECT=mailto:admin@coopenomics.io
|
||||
|
||||
# PostgreSQL
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_USERNAME=postgres
|
||||
DB_PASSWORD=postgres
|
||||
DB_DATABASE=notificator
|
||||
DB_SYNCHRONIZE=true
|
||||
DB_LOGGING=false
|
||||
DB_SSL=false
|
||||
```
|
||||
|
||||
### Развертывание
|
||||
|
||||
1. **База данных**: Создать PostgreSQL базу `notificator`
|
||||
2. **Переменные**: Настроить .env файл
|
||||
3. **Запуск**: `pnpm install && pnpm build && pnpm start:prod`
|
||||
4. **VAPID ключи**: При первом запуске будут сгенерированы автоматически
|
||||
|
||||
## Интеграция с фронтендом
|
||||
|
||||
### Service Worker
|
||||
```javascript
|
||||
// Обработка push событий
|
||||
self.addEventListener('push', function(event) {
|
||||
const payload = JSON.parse(event.data.text());
|
||||
const options = {
|
||||
body: payload.body,
|
||||
icon: payload.icon,
|
||||
badge: payload.badge,
|
||||
actions: payload.actions,
|
||||
data: payload.data
|
||||
};
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(payload.title, options)
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### Подписка
|
||||
```javascript
|
||||
// Получение VAPID ключа и подписка
|
||||
const { publicKey } = await fetch('/api/push/vapid-public-key').then(r => r.json());
|
||||
const registration = await navigator.serviceWorker.register('/sw.js');
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: publicKey
|
||||
});
|
||||
|
||||
// Отправка подписки на сервер
|
||||
await fetch('/api/push/subscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userId: 'user123', subscription })
|
||||
});
|
||||
```
|
||||
|
||||
## Безопасность и производительность
|
||||
|
||||
### Безопасность
|
||||
- ✅ VAPID аутентификация
|
||||
- ✅ Валидация всех входящих данных
|
||||
- ✅ Автоматическая очистка недействительных подписок
|
||||
- ✅ CORS настройки
|
||||
- ✅ Webhook подпись валидация (готов к настройке)
|
||||
|
||||
### Производительность
|
||||
- ✅ Индексы БД для быстрых запросов
|
||||
- ✅ Batch отправка уведомлений
|
||||
- ✅ Автоматическая очистка старых данных
|
||||
- ✅ Connection pooling для БД
|
||||
- ✅ Graceful error handling
|
||||
|
||||
## Результат
|
||||
|
||||
Создан production-ready сервис web-push уведомлений со следующими возможностями:
|
||||
|
||||
🎯 **Для разработчиков**:
|
||||
- Простое REST API для интеграции
|
||||
- TypeScript типизация
|
||||
- Подробная документация
|
||||
- Тестовые скрипты
|
||||
|
||||
🎯 **Для пользователей**:
|
||||
- Кроссплатформенные push уведомления
|
||||
- Поддержка rich notifications
|
||||
- Быстрая доставка
|
||||
- Надежная работа
|
||||
|
||||
🎯 **Для администраторов**:
|
||||
- Автоматическое обслуживание
|
||||
- Подробная статистика
|
||||
- Гибкая настройка
|
||||
- Мониторинг производительности
|
||||
|
||||
🎯 **Для NOVU интеграции**:
|
||||
- Webhook поддержка
|
||||
- Workflow создание
|
||||
- Template processing
|
||||
- Fallback механизмы
|
||||
|
||||
Сервис готов к использованию в production среде и может масштабироваться в зависимости от нагрузки.
|
||||
@@ -0,0 +1,474 @@
|
||||
# Notificator2 - Web Push Notification Service
|
||||
|
||||
Сервис для управления web-push уведомлениями с интеграцией NOVU. Предоставляет полный функционал для регистрации push подписок, отправки уведомлений и интеграции с NOVU workflows.
|
||||
|
||||
## Возможности
|
||||
|
||||
- 🔐 **VAPID ключи**: Автоматическая генерация и управление VAPID ключами
|
||||
- 📱 **Push подписки**: Регистрация, управление и автоматическая очистка подписок
|
||||
- 🔗 **Интеграция NOVU**: Полная интеграция с NOVU workflows и webhooks
|
||||
- 📊 **Статистика**: Мониторинг активности подписок
|
||||
- 🗂️ **База данных**: Хранение подписок в PostgreSQL
|
||||
- ⏰ **Автоматизация**: Cron jobs для очистки и статистики
|
||||
|
||||
## Установка и настройка
|
||||
|
||||
### 1. Переменные окружения
|
||||
|
||||
Создайте файл `.env` на основе `.env.example`:
|
||||
|
||||
```env
|
||||
# Порт приложения
|
||||
PORT=3000
|
||||
|
||||
# Настройки CORS
|
||||
CORS_ORIGIN=*
|
||||
|
||||
# NOVU настройки
|
||||
NOVU_API_KEY=your_novu_api_key_here
|
||||
NOVU_API_URL=https://api.novu.co
|
||||
|
||||
# VAPID ключи для web-push (генерируются автоматически при первом запуске)
|
||||
VAPID_PUBLIC_KEY=
|
||||
VAPID_PRIVATE_KEY=
|
||||
VAPID_SUBJECT=mailto:admin@coopenomics.io
|
||||
|
||||
# База данных PostgreSQL
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_USERNAME=postgres
|
||||
DB_PASSWORD=postgres
|
||||
DB_DATABASE=notificator
|
||||
DB_SYNCHRONIZE=true
|
||||
DB_LOGGING=false
|
||||
DB_SSL=false
|
||||
|
||||
# Окружение
|
||||
NODE_ENV=development
|
||||
```
|
||||
|
||||
### 2. База данных
|
||||
|
||||
Убедитесь, что PostgreSQL запущен и создайте базу данных:
|
||||
|
||||
```sql
|
||||
CREATE DATABASE notificator;
|
||||
```
|
||||
|
||||
### 3. Запуск сервиса
|
||||
|
||||
```bash
|
||||
# Установка зависимостей
|
||||
pnpm install
|
||||
|
||||
# Развертывание (production)
|
||||
pnpm start:prod
|
||||
|
||||
# Разработка
|
||||
pnpm start:dev
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Web Push API
|
||||
|
||||
#### Получить VAPID публичный ключ
|
||||
```
|
||||
GET /api/push/vapid-public-key
|
||||
```
|
||||
|
||||
**Ответ:**
|
||||
```json
|
||||
{
|
||||
"publicKey": "BKr7x...",
|
||||
"applicationServerKey": "BKr7x..."
|
||||
}
|
||||
```
|
||||
|
||||
#### Подписаться на push уведомления
|
||||
```
|
||||
POST /api/push/subscribe
|
||||
```
|
||||
|
||||
**Тело запроса:**
|
||||
```json
|
||||
{
|
||||
"userId": "user123",
|
||||
"subscription": {
|
||||
"endpoint": "https://fcm.googleapis.com/fcm/send/...",
|
||||
"keys": {
|
||||
"p256dh": "BKr7x...",
|
||||
"auth": "abc123..."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Отписаться от push уведомлений
|
||||
```
|
||||
DELETE /api/push/unsubscribe
|
||||
```
|
||||
|
||||
**Тело запроса:**
|
||||
```json
|
||||
{
|
||||
"endpoint": "https://fcm.googleapis.com/fcm/send/..."
|
||||
}
|
||||
```
|
||||
|
||||
#### Отправить push уведомление
|
||||
```
|
||||
POST /api/push/send
|
||||
```
|
||||
|
||||
**Тело запроса:**
|
||||
```json
|
||||
{
|
||||
"userId": "user123",
|
||||
"title": "Заголовок уведомления",
|
||||
"body": "Текст уведомления",
|
||||
"icon": "/icons/notification.png",
|
||||
"badge": "/icons/badge.png",
|
||||
"image": "/images/notification-image.jpg",
|
||||
"url": "/dashboard",
|
||||
"tag": "notification-tag",
|
||||
"requireInteraction": false,
|
||||
"silent": false,
|
||||
"actions": [
|
||||
{
|
||||
"action": "view",
|
||||
"title": "Открыть"
|
||||
},
|
||||
{
|
||||
"action": "dismiss",
|
||||
"title": "Закрыть"
|
||||
}
|
||||
],
|
||||
"data": {
|
||||
"customField": "value"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Получить статистику подписок
|
||||
```
|
||||
GET /api/push/stats
|
||||
```
|
||||
|
||||
**Ответ:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"total": 150,
|
||||
"active": 120,
|
||||
"inactive": 30,
|
||||
"uniqueUsers": 85
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### NOVU Integration API
|
||||
|
||||
#### Webhook для обработки NOVU событий
|
||||
```
|
||||
POST /api/novu/webhook/push
|
||||
```
|
||||
|
||||
#### Создать workflow с web-push поддержкой
|
||||
```
|
||||
POST /api/novu/workflow/create
|
||||
```
|
||||
|
||||
**Тело запроса:**
|
||||
```json
|
||||
{
|
||||
"workflowId": "user-notification",
|
||||
"workflowName": "Уведомления пользователя"
|
||||
}
|
||||
```
|
||||
|
||||
#### Тест интеграции
|
||||
```
|
||||
POST /api/novu/test
|
||||
```
|
||||
|
||||
**Тело запроса:**
|
||||
```json
|
||||
{
|
||||
"userId": "user123",
|
||||
"workflowId": "test-workflow"
|
||||
}
|
||||
```
|
||||
|
||||
## Интеграция с фронтендом
|
||||
|
||||
### Регистрация Service Worker
|
||||
|
||||
```javascript
|
||||
// public/sw.js
|
||||
self.addEventListener('push', function(event) {
|
||||
const options = {
|
||||
body: event.data ? event.data.text() : 'Новое уведомление',
|
||||
icon: '/icons/notification-icon.png',
|
||||
badge: '/icons/badge.png',
|
||||
tag: 'notification',
|
||||
requireInteraction: false,
|
||||
actions: [
|
||||
{
|
||||
action: 'view',
|
||||
title: 'Открыть',
|
||||
icon: '/icons/view.png'
|
||||
},
|
||||
{
|
||||
action: 'dismiss',
|
||||
title: 'Закрыть',
|
||||
icon: '/icons/close.png'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(event.data.text());
|
||||
options.body = payload.body;
|
||||
options.icon = payload.icon || options.icon;
|
||||
options.badge = payload.badge || options.badge;
|
||||
options.tag = payload.tag || options.tag;
|
||||
options.requireInteraction = payload.requireInteraction || options.requireInteraction;
|
||||
options.actions = payload.actions || options.actions;
|
||||
options.data = payload.data;
|
||||
} catch (e) {
|
||||
// Используем дефолтные настройки
|
||||
}
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(payload.title || 'Уведомление', options)
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', function(event) {
|
||||
event.notification.close();
|
||||
|
||||
if (event.action === 'view' || !event.action) {
|
||||
const url = event.notification.data?.url || '/';
|
||||
event.waitUntil(
|
||||
clients.openWindow(url)
|
||||
);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Подписка на уведомления
|
||||
|
||||
```javascript
|
||||
// Регистрация и подписка
|
||||
async function subscribeToPush(userId) {
|
||||
try {
|
||||
// Регистрируем Service Worker
|
||||
const registration = await navigator.serviceWorker.register('/sw.js');
|
||||
|
||||
// Получаем VAPID ключ
|
||||
const response = await fetch('/api/push/vapid-public-key');
|
||||
const { publicKey } = await response.json();
|
||||
|
||||
// Подписываемся
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: publicKey
|
||||
});
|
||||
|
||||
// Отправляем подписку на сервер
|
||||
await fetch('/api/push/subscribe', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
userId: userId,
|
||||
subscription: subscription
|
||||
})
|
||||
});
|
||||
|
||||
console.log('Подписка на push уведомления успешна');
|
||||
} catch (error) {
|
||||
console.error('Ошибка подписки:', error);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Интеграция с NOVU
|
||||
|
||||
### 1. Настройка webhook в NOVU
|
||||
|
||||
В панели NOVU добавьте webhook endpoint:
|
||||
- URL: `https://your-domain.com/api/novu/webhook/push`
|
||||
- События: `workflow.step.push.sent`
|
||||
|
||||
### 2. Создание workflow с push поддержкой
|
||||
|
||||
```javascript
|
||||
// Создание workflow через API
|
||||
const workflow = await fetch('/api/novu/workflow/create', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
workflowId: 'user-notifications',
|
||||
workflowName: 'Уведомления пользователей'
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Триггер workflow
|
||||
|
||||
```javascript
|
||||
// Через NOVU SDK
|
||||
import { Novu } from '@novu/node';
|
||||
|
||||
const novu = new Novu(process.env.NOVU_API_KEY);
|
||||
|
||||
await novu.trigger('user-notifications', {
|
||||
to: {
|
||||
subscriberId: 'user123',
|
||||
email: 'user@example.com'
|
||||
},
|
||||
payload: {
|
||||
title: 'Новое сообщение',
|
||||
body: 'У вас есть новое сообщение в системе',
|
||||
icon: '/icons/message.png',
|
||||
url: '/messages'
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Тестирование
|
||||
|
||||
### CLI для тестирования
|
||||
|
||||
Сервис включает в себя удобный CLI для тестирования всех функций:
|
||||
|
||||
```bash
|
||||
# Интерактивный режим (по умолчанию)
|
||||
pnpm test:push
|
||||
|
||||
# Проверить health check
|
||||
pnpm test:push:health
|
||||
|
||||
# Показать справку
|
||||
pnpm test:push:help
|
||||
|
||||
# Полное тестирование (нужен user ID)
|
||||
pnpm test:push:full -- --user-id user123
|
||||
|
||||
# Интерактивный режим с verbose выводом
|
||||
pnpm test:push -- --verbose interactive
|
||||
```
|
||||
|
||||
#### Доступные команды CLI:
|
||||
|
||||
- `health` - Проверить доступность API
|
||||
- `vapid` - Получить VAPID публичный ключ
|
||||
- `subscribe -u <userId>` - Создать тестовую подписку
|
||||
- `notify -u <userId>` - Отправить тестовое уведомление
|
||||
- `stats` - Получить статистику подписок
|
||||
- `novu -u <userId>` - Тестировать NOVU webhook
|
||||
- `full -u <userId>` - Полное тестирование всех функций
|
||||
- `interactive` - Интерактивный режим
|
||||
|
||||
#### Опции:
|
||||
|
||||
- `-a, --api-base <url>` - База URL API (по умолчанию: http://localhost:3000/api)
|
||||
- `-v, --verbose` - Подробный вывод запросов и ответов
|
||||
- `-u, --user-id <id>` - ID пользователя для тестирования
|
||||
|
||||
### Примеры использования
|
||||
|
||||
```bash
|
||||
# Тестирование с другим API endpoint
|
||||
pnpm test:push -- --api-base https://my-api.com/api health
|
||||
|
||||
# Полное тестирование с verbose выводом
|
||||
pnpm test:push -- --verbose --user-id test-user full
|
||||
|
||||
# Интерактивный режим с кастомными настройками
|
||||
pnpm test:push -- --api-base http://localhost:4000/api --verbose interactive
|
||||
```
|
||||
|
||||
## Мониторинг и обслуживание
|
||||
|
||||
### Автоматические задачи
|
||||
|
||||
- **Очистка неактивных подписок**: Каждый день в 2:00 AM
|
||||
- **Еженедельная статистика**: Каждое воскресенье в 12:00 PM
|
||||
|
||||
### Ручная очистка
|
||||
|
||||
```bash
|
||||
# Очистить подписки старше 30 дней
|
||||
curl -X DELETE "http://localhost:3000/api/push/cleanup?days=30"
|
||||
|
||||
# Или через CLI
|
||||
pnpm test:push -- stats
|
||||
```
|
||||
|
||||
### Логирование
|
||||
|
||||
Сервис использует встроенную систему логирования NestJS. Все операции логируются с соответствующими уровнями:
|
||||
|
||||
- `LOG`: Обычные операции
|
||||
- `WARN`: Предупреждения (отсутствующие подписки, etc.)
|
||||
- `ERROR`: Ошибки отправки, проблемы с БД
|
||||
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
┌─────────────────────┐ ┌─────────────────────┐
|
||||
│ Frontend App │ │ NOVU │
|
||||
│ (Service Worker) │ │ (Workflows) │
|
||||
└─────────┬───────────┘ └─────────┬───────────┘
|
||||
│ │
|
||||
│ Push Subscription │ Webhooks
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Notificator2 Service │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ • WebPushService (VAPID, Subscriptions) │
|
||||
│ • NovuWebPushService (Integration) │
|
||||
│ • CleanupService (Maintenance) │
|
||||
└─────────────────┬───────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ PostgreSQL │
|
||||
│ (Push Subscriptions) │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Безопасность
|
||||
|
||||
- ✅ VAPID ключи для аутентификации
|
||||
- ✅ Валидация всех входящих данных
|
||||
- ✅ Автоматическая деактивация недействительных подписок
|
||||
- ✅ CORS настройки
|
||||
- ✅ Webhook подпись валидация (рекомендуется для production)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### VAPID ключи не генерируются
|
||||
Проверьте, что в переменных окружения нет пустых `VAPID_PUBLIC_KEY` и `VAPID_PRIVATE_KEY`. При первом запуске они должны быть пустыми или отсутствовать.
|
||||
|
||||
### Push уведомления не доходят
|
||||
1. Проверьте, что Service Worker зарегистрирован
|
||||
2. Убедитесь, что пользователь дал разрешение на уведомления
|
||||
3. Проверьте активность подписки через `/api/push/subscriptions/:userId`
|
||||
|
||||
### Ошибки интеграции с NOVU
|
||||
1. Проверьте правильность `NOVU_API_KEY`
|
||||
2. Убедитесь, что webhook endpoint доступен из интернета
|
||||
3. Проверьте логи сервиса для детальной информации об ошибках
|
||||
|
||||
## Лицензия
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,2 @@
|
||||
export declare class AppModule {
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
"use strict";
|
||||
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
||||
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
||||
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
||||
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
||||
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.AppModule = void 0;
|
||||
const common_1 = require("@nestjs/common");
|
||||
const config_1 = require("@nestjs/config");
|
||||
const typeorm_1 = require("@nestjs/typeorm");
|
||||
const schedule_1 = require("@nestjs/schedule");
|
||||
const notification_module_1 = require("./modules/notification.module");
|
||||
const web_push_module_1 = require("./modules/web-push.module");
|
||||
const push_subscription_entity_1 = require("./entities/push-subscription.entity");
|
||||
let AppModule = class AppModule {
|
||||
};
|
||||
exports.AppModule = AppModule;
|
||||
exports.AppModule = AppModule = __decorate([
|
||||
(0, common_1.Module)({
|
||||
imports: [
|
||||
config_1.ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: process.env.ENV_PATH || '.env',
|
||||
}),
|
||||
typeorm_1.TypeOrmModule.forRootAsync({
|
||||
imports: [config_1.ConfigModule],
|
||||
inject: [config_1.ConfigService],
|
||||
useFactory: (configService) => ({
|
||||
type: 'postgres',
|
||||
host: configService.get('DB_HOST', 'localhost'),
|
||||
port: configService.get('DB_PORT', 5432),
|
||||
username: configService.get('DB_USERNAME', 'postgres'),
|
||||
password: configService.get('DB_PASSWORD', 'postgres'),
|
||||
database: configService.get('DB_DATABASE', 'notificator'),
|
||||
entities: [push_subscription_entity_1.PushSubscription],
|
||||
synchronize: configService.get('DB_SYNCHRONIZE', true),
|
||||
logging: configService.get('DB_LOGGING', false),
|
||||
ssl: configService.get('DB_SSL', false) ? {
|
||||
rejectUnauthorized: false,
|
||||
} : false,
|
||||
}),
|
||||
}),
|
||||
schedule_1.ScheduleModule.forRoot(),
|
||||
notification_module_1.NotificationModule,
|
||||
web_push_module_1.WebPushModule,
|
||||
],
|
||||
})
|
||||
], AppModule);
|
||||
//# sourceMappingURL=app.module.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"app.module.js","sourceRoot":"","sources":["../src/app.module.ts"],"names":[],"mappings":";;;;;;;;;AAAA,2CAAwC;AACxC,2CAA6D;AAC7D,6CAAgD;AAChD,+CAAkD;AAClD,uEAAmE;AACnE,+DAA0D;AAC1D,kFAAuE;AA+BhE,IAAM,SAAS,GAAf,MAAM,SAAS;CAAG,CAAA;AAAZ,8BAAS;oBAAT,SAAS;IA7BrB,IAAA,eAAM,EAAC;QACN,OAAO,EAAE;YACP,qBAAY,CAAC,OAAO,CAAC;gBACnB,QAAQ,EAAE,IAAI;gBACd,WAAW,EAAE,OAAO,CAAC,GAAG,CAAC,QAAQ,IAAI,MAAM;aAC5C,CAAC;YACF,uBAAa,CAAC,YAAY,CAAC;gBACzB,OAAO,EAAE,CAAC,qBAAY,CAAC;gBACvB,MAAM,EAAE,CAAC,sBAAa,CAAC;gBACvB,UAAU,EAAE,CAAC,aAA4B,EAAE,EAAE,CAAC,CAAC;oBAC7C,IAAI,EAAE,UAAU;oBAChB,IAAI,EAAE,aAAa,CAAC,GAAG,CAAS,SAAS,EAAE,WAAW,CAAC;oBACvD,IAAI,EAAE,aAAa,CAAC,GAAG,CAAS,SAAS,EAAE,IAAI,CAAC;oBAChD,QAAQ,EAAE,aAAa,CAAC,GAAG,CAAS,aAAa,EAAE,UAAU,CAAC;oBAC9D,QAAQ,EAAE,aAAa,CAAC,GAAG,CAAS,aAAa,EAAE,UAAU,CAAC;oBAC9D,QAAQ,EAAE,aAAa,CAAC,GAAG,CAAS,aAAa,EAAE,aAAa,CAAC;oBACjE,QAAQ,EAAE,CAAC,2CAAgB,CAAC;oBAC5B,WAAW,EAAE,aAAa,CAAC,GAAG,CAAU,gBAAgB,EAAE,IAAI,CAAC;oBAC/D,OAAO,EAAE,aAAa,CAAC,GAAG,CAAU,YAAY,EAAE,KAAK,CAAC;oBACxD,GAAG,EAAE,aAAa,CAAC,GAAG,CAAU,QAAQ,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC;wBACjD,kBAAkB,EAAE,KAAK;qBAC1B,CAAC,CAAC,CAAC,KAAK;iBACV,CAAC;aACH,CAAC;YACF,yBAAc,CAAC,OAAO,EAAE;YACxB,wCAAkB;YAClB,+BAAa;SACd;KACF,CAAC;GACW,SAAS,CAAG"}
|
||||
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env ts-node
|
||||
export {};
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env ts-node
|
||||
"use strict";
|
||||
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
||||
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
||||
return new (P || (P = Promise))(function (resolve, reject) {
|
||||
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
||||
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
||||
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
||||
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
||||
});
|
||||
};
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const commander_1 = require("commander");
|
||||
const inquirer_1 = __importDefault(require("inquirer"));
|
||||
const chalk_1 = __importDefault(require("chalk"));
|
||||
const node_fetch_1 = __importDefault(require("node-fetch"));
|
||||
class NotificatorTestCLI {
|
||||
constructor(apiBase = 'http://localhost:3000/api', verbose = false) {
|
||||
this.apiBase = apiBase;
|
||||
this.verbose = verbose;
|
||||
}
|
||||
log(message, type = 'info') {
|
||||
const colors = {
|
||||
info: chalk_1.default.blue,
|
||||
success: chalk_1.default.green,
|
||||
error: chalk_1.default.red,
|
||||
warn: chalk_1.default.yellow,
|
||||
};
|
||||
console.log(colors[type](message));
|
||||
}
|
||||
makeRequest(method, endpoint, data) {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
const url = `${this.apiBase}${endpoint}`;
|
||||
if (this.verbose) {
|
||||
this.log(`→ ${method} ${url}`, 'info');
|
||||
if (data) {
|
||||
console.log(chalk_1.default.gray(JSON.stringify(data, null, 2)));
|
||||
}
|
||||
}
|
||||
try {
|
||||
const options = {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
if (data) {
|
||||
options.body = JSON.stringify(data);
|
||||
}
|
||||
const response = yield (0, node_fetch_1.default)(url, options);
|
||||
const result = yield response.json();
|
||||
if (this.verbose) {
|
||||
this.log(`← ${response.status} ${response.statusText}`, response.ok ? 'success' : 'error');
|
||||
console.log(chalk_1.default.gray(JSON.stringify(result, null, 2)));
|
||||
}
|
||||
return {
|
||||
success: response.ok,
|
||||
data: result,
|
||||
status: response.status,
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
if (this.verbose) {
|
||||
this.log(`✗ ${error.message}`, 'error');
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
testHealthCheck() {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
console.log(chalk_1.default.blue('\n🔍 Проверка health check...'));
|
||||
const result = yield this.makeRequest('GET', '/notifications/health');
|
||||
if (result.success) {
|
||||
this.log('✅ Health check успешен', 'success');
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
this.log(`❌ Health check неудачен: ${result.error || JSON.stringify(result.data)}`, 'error');
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
interactiveMode() {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
console.log(chalk_1.default.green('🧪 Интерактивное тестирование Notificator2\n'));
|
||||
const { action } = yield inquirer_1.default.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'action',
|
||||
message: 'Выберите действие:',
|
||||
choices: [
|
||||
{ name: '🔍 Health check', value: 'health' },
|
||||
{ name: '❌ Выход', value: 'exit' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
if (action === 'exit') {
|
||||
console.log(chalk_1.default.yellow('👋 До свидания!'));
|
||||
return;
|
||||
}
|
||||
if (action === 'health') {
|
||||
yield this.testHealthCheck();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
const program = new commander_1.Command();
|
||||
program
|
||||
.name('notificator2-test')
|
||||
.description('CLI для тестирования Notificator2 сервиса')
|
||||
.version('1.0.0');
|
||||
program
|
||||
.option('-a, --api-base <url>', 'База API URL', 'http://localhost:3000/api')
|
||||
.option('-v, --verbose', 'Подробный вывод');
|
||||
program
|
||||
.command('health')
|
||||
.description('Проверить health check')
|
||||
.action(() => __awaiter(void 0, void 0, void 0, function* () {
|
||||
const options = program.opts();
|
||||
const cli = new NotificatorTestCLI(options.apiBase, options.verbose);
|
||||
yield cli.testHealthCheck();
|
||||
}));
|
||||
program
|
||||
.command('interactive')
|
||||
.alias('i')
|
||||
.description('Интерактивный режим')
|
||||
.action(() => __awaiter(void 0, void 0, void 0, function* () {
|
||||
const options = program.opts();
|
||||
const cli = new NotificatorTestCLI(options.apiBase, options.verbose);
|
||||
yield cli.interactiveMode();
|
||||
}));
|
||||
if (process.argv.length === 2) {
|
||||
const options = program.opts();
|
||||
const cli = new NotificatorTestCLI(options.apiBase, options.verbose);
|
||||
cli.interactiveMode().catch(console.error);
|
||||
}
|
||||
else {
|
||||
program.parse();
|
||||
}
|
||||
//# sourceMappingURL=test-push.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"test-push.js","sourceRoot":"","sources":["../../src/cli/test-push.ts"],"names":[],"mappings":"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"}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { NovuWebPushService } from '../services/novu-webpush.service';
|
||||
import { WebPushService } from '../services/web-push.service';
|
||||
export interface NovuWebhookPayload {
|
||||
event: string;
|
||||
data: {
|
||||
subscriber: {
|
||||
subscriberId: string;
|
||||
email?: string;
|
||||
};
|
||||
workflow: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
step: {
|
||||
type: string;
|
||||
template: any;
|
||||
};
|
||||
payload: any;
|
||||
};
|
||||
webhookId: string;
|
||||
timestamp: string;
|
||||
}
|
||||
export declare class NovuWebhookController {
|
||||
private readonly novuWebPushService;
|
||||
private readonly webPushService;
|
||||
private readonly logger;
|
||||
constructor(novuWebPushService: NovuWebPushService, webPushService: WebPushService);
|
||||
handlePushWebhook(payload: NovuWebhookPayload, signature?: string): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
workflowId?: undefined;
|
||||
userId?: undefined;
|
||||
} | {
|
||||
success: boolean;
|
||||
message: string;
|
||||
workflowId: string;
|
||||
userId: string;
|
||||
}>;
|
||||
subscribeViaNovu(body: {
|
||||
userId: string;
|
||||
subscription: any;
|
||||
email?: string;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
userId: string;
|
||||
email: string;
|
||||
}>;
|
||||
getIntegrationConfig(): {
|
||||
name: string;
|
||||
identifier: string;
|
||||
logoFileName: string;
|
||||
channel: string;
|
||||
credentials: ({
|
||||
key: string;
|
||||
displayName: string;
|
||||
type: string;
|
||||
required: boolean;
|
||||
value: string;
|
||||
} | {
|
||||
key: string;
|
||||
displayName: string;
|
||||
type: string;
|
||||
required: boolean;
|
||||
value?: undefined;
|
||||
})[];
|
||||
docReference: string;
|
||||
comingSoon: boolean;
|
||||
betaVersion: boolean;
|
||||
nesting: boolean;
|
||||
supportedFeatures: {
|
||||
digest: boolean;
|
||||
delay: boolean;
|
||||
title: boolean;
|
||||
body: boolean;
|
||||
avatar: boolean;
|
||||
actions: boolean;
|
||||
};
|
||||
};
|
||||
testWebhook(body: {
|
||||
userId: string;
|
||||
title?: string;
|
||||
body?: string;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
userId: string;
|
||||
}>;
|
||||
private isValidWebhook;
|
||||
private extractTemplateValue;
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
"use strict";
|
||||
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
||||
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
||||
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
||||
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
||||
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
||||
};
|
||||
var __metadata = (this && this.__metadata) || function (k, v) {
|
||||
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
||||
};
|
||||
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
||||
return function (target, key) { decorator(target, key, paramIndex); }
|
||||
};
|
||||
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
||||
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
||||
return new (P || (P = Promise))(function (resolve, reject) {
|
||||
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
||||
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
||||
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
||||
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
||||
});
|
||||
};
|
||||
var NovuWebhookController_1;
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.NovuWebhookController = void 0;
|
||||
const common_1 = require("@nestjs/common");
|
||||
const novu_webpush_service_1 = require("../services/novu-webpush.service");
|
||||
const web_push_service_1 = require("../services/web-push.service");
|
||||
let NovuWebhookController = NovuWebhookController_1 = class NovuWebhookController {
|
||||
constructor(novuWebPushService, webPushService) {
|
||||
this.novuWebPushService = novuWebPushService;
|
||||
this.webPushService = webPushService;
|
||||
this.logger = new common_1.Logger(NovuWebhookController_1.name);
|
||||
}
|
||||
handlePushWebhook(payload, signature) {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
|
||||
try {
|
||||
this.logger.log(`Получен NOVU webhook: ${payload.event} для пользователя ${payload.data.subscriber.subscriberId}`);
|
||||
if (!this.isValidWebhook(payload, signature)) {
|
||||
throw new common_1.BadRequestException('Невалидный webhook');
|
||||
}
|
||||
if (payload.data.step.type !== 'push') {
|
||||
this.logger.warn(`Пропускаем событие ${payload.event} с типом ${payload.data.step.type}`);
|
||||
return { success: true, message: 'Событие пропущено (не push)' };
|
||||
}
|
||||
const userId = payload.data.subscriber.subscriberId;
|
||||
const workflowId = payload.data.workflow.id;
|
||||
const pushPayload = {
|
||||
title: this.extractTemplateValue((_a = payload.data.step.template.content) === null || _a === void 0 ? void 0 : _a.title, payload.data.payload),
|
||||
body: this.extractTemplateValue((_b = payload.data.step.template.content) === null || _b === void 0 ? void 0 : _b.body, payload.data.payload),
|
||||
icon: this.extractTemplateValue((_c = payload.data.step.template.content) === null || _c === void 0 ? void 0 : _c.icon, payload.data.payload),
|
||||
badge: this.extractTemplateValue((_d = payload.data.step.template.content) === null || _d === void 0 ? void 0 : _d.badge, payload.data.payload),
|
||||
image: this.extractTemplateValue((_e = payload.data.step.template.content) === null || _e === void 0 ? void 0 : _e.image, payload.data.payload),
|
||||
url: this.extractTemplateValue((_g = (_f = payload.data.step.template.content) === null || _f === void 0 ? void 0 : _f.data) === null || _g === void 0 ? void 0 : _g.url, payload.data.payload),
|
||||
tag: this.extractTemplateValue((_h = payload.data.step.template.content) === null || _h === void 0 ? void 0 : _h.tag, payload.data.payload),
|
||||
requireInteraction: (_j = payload.data.step.template.content) === null || _j === void 0 ? void 0 : _j.requireInteraction,
|
||||
silent: (_k = payload.data.step.template.content) === null || _k === void 0 ? void 0 : _k.silent,
|
||||
data: Object.assign(Object.assign({}, payload.data.payload), { workflowId, webhookId: payload.webhookId }),
|
||||
};
|
||||
yield this.novuWebPushService.sendWebPushFromNovu(userId, pushPayload, workflowId);
|
||||
return {
|
||||
success: true,
|
||||
message: `Push уведомление отправлено пользователю ${userId}`,
|
||||
workflowId,
|
||||
userId,
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
this.logger.error('Ошибка обработки NOVU webhook:', error.message);
|
||||
throw new common_1.BadRequestException(error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
subscribeViaNovu(body) {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
try {
|
||||
const { userId, subscription, email } = body;
|
||||
yield this.webPushService.saveSubscription(userId, subscription);
|
||||
yield this.novuWebPushService.syncUserSubscriptionsWithNovu(userId);
|
||||
this.logger.log(`Пользователь ${userId} подписался через NOVU интеграцию`);
|
||||
return {
|
||||
success: true,
|
||||
message: 'Подписка создана и синхронизирована с NOVU',
|
||||
userId,
|
||||
email,
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
this.logger.error('Ошибка подписки через NOVU:', error.message);
|
||||
throw new common_1.BadRequestException(error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
getIntegrationConfig() {
|
||||
return this.novuWebPushService.getWebPushIntegrationConfig();
|
||||
}
|
||||
testWebhook(body) {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
const { userId, title = '🧪 Тест webhook', body: msgBody = 'Тестовое уведомление через webhook' } = body;
|
||||
const testPayload = {
|
||||
title,
|
||||
body: msgBody,
|
||||
icon: '/icons/test-icon.png',
|
||||
badge: '/icons/badge.png',
|
||||
url: '/dashboard',
|
||||
tag: 'webhook-test',
|
||||
data: {
|
||||
type: 'webhook-test',
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
};
|
||||
try {
|
||||
yield this.novuWebPushService.sendWebPushFromNovu(userId, testPayload, 'test-webhook');
|
||||
return {
|
||||
success: true,
|
||||
message: `Тестовое уведомление отправлено пользователю ${userId}`,
|
||||
userId,
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
this.logger.error('Ошибка теста webhook:', error.message);
|
||||
throw new common_1.BadRequestException(error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
isValidWebhook(payload, signature) {
|
||||
return !!(payload &&
|
||||
payload.data &&
|
||||
payload.data.subscriber &&
|
||||
payload.data.subscriber.subscriberId &&
|
||||
payload.data.workflow &&
|
||||
payload.data.step);
|
||||
}
|
||||
extractTemplateValue(template, payload) {
|
||||
if (!template || typeof template !== 'string') {
|
||||
return template;
|
||||
}
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (match, key) => {
|
||||
return payload[key] || match;
|
||||
});
|
||||
}
|
||||
};
|
||||
exports.NovuWebhookController = NovuWebhookController;
|
||||
__decorate([
|
||||
(0, common_1.Post)('webhook/push'),
|
||||
__param(0, (0, common_1.Body)()),
|
||||
__param(1, (0, common_1.Headers)('x-novu-signature')),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [Object, String]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], NovuWebhookController.prototype, "handlePushWebhook", null);
|
||||
__decorate([
|
||||
(0, common_1.Post)('subscribe'),
|
||||
__param(0, (0, common_1.Body)()),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [Object]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], NovuWebhookController.prototype, "subscribeViaNovu", null);
|
||||
__decorate([
|
||||
(0, common_1.Get)('integration/config'),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", []),
|
||||
__metadata("design:returntype", void 0)
|
||||
], NovuWebhookController.prototype, "getIntegrationConfig", null);
|
||||
__decorate([
|
||||
(0, common_1.Post)('test'),
|
||||
__param(0, (0, common_1.Body)()),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [Object]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], NovuWebhookController.prototype, "testWebhook", null);
|
||||
exports.NovuWebhookController = NovuWebhookController = NovuWebhookController_1 = __decorate([
|
||||
(0, common_1.Controller)('novu'),
|
||||
__metadata("design:paramtypes", [novu_webpush_service_1.NovuWebPushService,
|
||||
web_push_service_1.WebPushService])
|
||||
], NovuWebhookController);
|
||||
//# sourceMappingURL=novu-webhook.controller.js.map
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,77 @@
|
||||
import { WebPushService, PushSubscriptionData } from '../services/web-push.service';
|
||||
export interface SubscribePushDto {
|
||||
userId: string;
|
||||
subscription: PushSubscriptionData;
|
||||
}
|
||||
export interface SendNotificationDto {
|
||||
userId?: string;
|
||||
title: string;
|
||||
body: string;
|
||||
icon?: string;
|
||||
badge?: string;
|
||||
image?: string;
|
||||
data?: any;
|
||||
actions?: Array<{
|
||||
action: string;
|
||||
title: string;
|
||||
icon?: string;
|
||||
}>;
|
||||
tag?: string;
|
||||
requireInteraction?: boolean;
|
||||
silent?: boolean;
|
||||
vibrate?: number[];
|
||||
}
|
||||
export declare class PushController {
|
||||
private readonly webPushService;
|
||||
private readonly logger;
|
||||
constructor(webPushService: WebPushService);
|
||||
getVapidPublicKey(): {
|
||||
publicKey: string;
|
||||
applicationServerKey: string;
|
||||
};
|
||||
subscribe(dto: SubscribePushDto, userAgent?: string): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
subscriptionId: string;
|
||||
}>;
|
||||
unsubscribe(body: {
|
||||
endpoint: string;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
}>;
|
||||
getUserSubscriptions(userId: string): Promise<{
|
||||
success: boolean;
|
||||
data: {
|
||||
id: string;
|
||||
endpoint: string;
|
||||
userAgent: string;
|
||||
createdAt: Date;
|
||||
isActive: boolean;
|
||||
}[];
|
||||
}>;
|
||||
sendNotification(dto: SendNotificationDto): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
}>;
|
||||
sendTestNotification(userId: string): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
}>;
|
||||
getStats(): Promise<{
|
||||
success: boolean;
|
||||
data: {
|
||||
total: number;
|
||||
active: number;
|
||||
inactive: number;
|
||||
uniqueUsers: number;
|
||||
};
|
||||
}>;
|
||||
cleanup(days?: string): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
deletedCount: number;
|
||||
}>;
|
||||
private validateSubscription;
|
||||
private validateNotificationPayload;
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
"use strict";
|
||||
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
||||
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
||||
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
||||
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
||||
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
||||
};
|
||||
var __metadata = (this && this.__metadata) || function (k, v) {
|
||||
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
||||
};
|
||||
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
||||
return function (target, key) { decorator(target, key, paramIndex); }
|
||||
};
|
||||
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
||||
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
||||
return new (P || (P = Promise))(function (resolve, reject) {
|
||||
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
||||
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
||||
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
||||
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
||||
});
|
||||
};
|
||||
var PushController_1;
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.PushController = void 0;
|
||||
const common_1 = require("@nestjs/common");
|
||||
const web_push_service_1 = require("../services/web-push.service");
|
||||
let PushController = PushController_1 = class PushController {
|
||||
constructor(webPushService) {
|
||||
this.webPushService = webPushService;
|
||||
this.logger = new common_1.Logger(PushController_1.name);
|
||||
}
|
||||
getVapidPublicKey() {
|
||||
const publicKey = this.webPushService.getVapidPublicKey();
|
||||
if (!publicKey) {
|
||||
throw new common_1.BadRequestException('VAPID ключи не настроены. Проверьте конфигурацию сервера.');
|
||||
}
|
||||
return {
|
||||
publicKey,
|
||||
applicationServerKey: publicKey,
|
||||
};
|
||||
}
|
||||
subscribe(dto, userAgent) {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
try {
|
||||
this.validateSubscription(dto.subscription);
|
||||
const subscription = yield this.webPushService.saveSubscription(dto.userId, dto.subscription, userAgent);
|
||||
this.logger.log(`Пользователь ${dto.userId} подписался на push уведомления`);
|
||||
return {
|
||||
success: true,
|
||||
message: 'Подписка на push уведомления успешно создана',
|
||||
subscriptionId: subscription.id,
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
this.logger.error('Ошибка при подписке на push уведомления:', error.message);
|
||||
throw new common_1.BadRequestException(error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
unsubscribe(body) {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
try {
|
||||
if (!body.endpoint) {
|
||||
throw new common_1.BadRequestException('Endpoint обязателен для отписки');
|
||||
}
|
||||
yield this.webPushService.removeSubscription(body.endpoint);
|
||||
this.logger.log(`Подписка отключена: ${body.endpoint.substring(0, 50)}...`);
|
||||
return {
|
||||
success: true,
|
||||
message: 'Подписка успешно отключена',
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
this.logger.error('Ошибка при отписке от push уведомлений:', error.message);
|
||||
throw new common_1.BadRequestException(error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
getUserSubscriptions(userId) {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
const subscriptions = yield this.webPushService.getUserSubscriptions(userId);
|
||||
return {
|
||||
success: true,
|
||||
data: subscriptions.map(sub => ({
|
||||
id: sub.id,
|
||||
endpoint: sub.endpoint.substring(0, 50) + '...',
|
||||
userAgent: sub.userAgent,
|
||||
createdAt: sub.createdAt,
|
||||
isActive: sub.isActive,
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
sendNotification(dto) {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
try {
|
||||
this.validateNotificationPayload(dto);
|
||||
const payload = {
|
||||
title: dto.title,
|
||||
body: dto.body,
|
||||
icon: dto.icon,
|
||||
badge: dto.badge,
|
||||
image: dto.image,
|
||||
data: dto.data,
|
||||
actions: dto.actions,
|
||||
tag: dto.tag,
|
||||
requireInteraction: dto.requireInteraction,
|
||||
silent: dto.silent,
|
||||
timestamp: Date.now(),
|
||||
vibrate: dto.vibrate,
|
||||
};
|
||||
if (dto.userId) {
|
||||
yield this.webPushService.sendNotificationToUser(dto.userId, payload);
|
||||
this.logger.log(`Push уведомление отправлено пользователю ${dto.userId}`);
|
||||
}
|
||||
else {
|
||||
yield this.webPushService.sendNotificationToAll(payload);
|
||||
this.logger.log('Push уведомление отправлено всем пользователям');
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
message: dto.userId
|
||||
? `Уведомление отправлено пользователю ${dto.userId}`
|
||||
: 'Уведомление отправлено всем пользователям',
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
this.logger.error('Ошибка при отправке push уведомления:', error.message);
|
||||
throw new common_1.BadRequestException(error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
sendTestNotification(userId) {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
const payload = {
|
||||
title: '🔔 Тестовое уведомление',
|
||||
body: 'Ваши push уведомления работают корректно!',
|
||||
icon: '/icons/notification-icon.png',
|
||||
badge: '/icons/badge-icon.png',
|
||||
tag: 'test-notification',
|
||||
data: {
|
||||
type: 'test',
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
action: 'view',
|
||||
title: 'Открыть приложение',
|
||||
},
|
||||
{
|
||||
action: 'dismiss',
|
||||
title: 'Закрыть',
|
||||
},
|
||||
],
|
||||
};
|
||||
yield this.webPushService.sendNotificationToUser(userId, payload);
|
||||
return {
|
||||
success: true,
|
||||
message: `Тестовое уведомление отправлено пользователю ${userId}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
getStats() {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
const stats = yield this.webPushService.getSubscriptionStats();
|
||||
return {
|
||||
success: true,
|
||||
data: stats,
|
||||
};
|
||||
});
|
||||
}
|
||||
cleanup(days) {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
const olderThanDays = days ? parseInt(days) : 30;
|
||||
if (isNaN(olderThanDays) || olderThanDays < 1) {
|
||||
throw new common_1.BadRequestException('Параметр days должен быть положительным числом');
|
||||
}
|
||||
const deletedCount = yield this.webPushService.cleanupInactiveSubscriptions(olderThanDays);
|
||||
return {
|
||||
success: true,
|
||||
message: `Очищено ${deletedCount} неактивных подписок старше ${olderThanDays} дней`,
|
||||
deletedCount,
|
||||
};
|
||||
});
|
||||
}
|
||||
validateSubscription(subscription) {
|
||||
var _a, _b;
|
||||
if (!subscription.endpoint) {
|
||||
throw new Error('Endpoint подписки обязателен');
|
||||
}
|
||||
if (!((_a = subscription.keys) === null || _a === void 0 ? void 0 : _a.p256dh) || !((_b = subscription.keys) === null || _b === void 0 ? void 0 : _b.auth)) {
|
||||
throw new Error('Ключи p256dh и auth обязательны');
|
||||
}
|
||||
try {
|
||||
new URL(subscription.endpoint);
|
||||
}
|
||||
catch (_c) {
|
||||
throw new Error('Невалидный endpoint URL');
|
||||
}
|
||||
}
|
||||
validateNotificationPayload(dto) {
|
||||
var _a, _b;
|
||||
if (!((_a = dto.title) === null || _a === void 0 ? void 0 : _a.trim())) {
|
||||
throw new Error('Заголовок уведомления обязателен');
|
||||
}
|
||||
if (!((_b = dto.body) === null || _b === void 0 ? void 0 : _b.trim())) {
|
||||
throw new Error('Текст уведомления обязателен');
|
||||
}
|
||||
if (dto.title.length > 100) {
|
||||
throw new Error('Заголовок уведомления не должен превышать 100 символов');
|
||||
}
|
||||
if (dto.body.length > 500) {
|
||||
throw new Error('Текст уведомления не должен превышать 500 символов');
|
||||
}
|
||||
}
|
||||
};
|
||||
exports.PushController = PushController;
|
||||
__decorate([
|
||||
(0, common_1.Get)('vapid-public-key'),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", []),
|
||||
__metadata("design:returntype", void 0)
|
||||
], PushController.prototype, "getVapidPublicKey", null);
|
||||
__decorate([
|
||||
(0, common_1.Post)('subscribe'),
|
||||
__param(0, (0, common_1.Body)()),
|
||||
__param(1, (0, common_1.Headers)('user-agent')),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [Object, String]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], PushController.prototype, "subscribe", null);
|
||||
__decorate([
|
||||
(0, common_1.Delete)('unsubscribe'),
|
||||
__param(0, (0, common_1.Body)()),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [Object]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], PushController.prototype, "unsubscribe", null);
|
||||
__decorate([
|
||||
(0, common_1.Get)('subscriptions/:userId'),
|
||||
__param(0, (0, common_1.Param)('userId')),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [String]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], PushController.prototype, "getUserSubscriptions", null);
|
||||
__decorate([
|
||||
(0, common_1.Post)('send'),
|
||||
__param(0, (0, common_1.Body)()),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [Object]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], PushController.prototype, "sendNotification", null);
|
||||
__decorate([
|
||||
(0, common_1.Post)('test/:userId'),
|
||||
__param(0, (0, common_1.Param)('userId')),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [String]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], PushController.prototype, "sendTestNotification", null);
|
||||
__decorate([
|
||||
(0, common_1.Get)('stats'),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", []),
|
||||
__metadata("design:returntype", Promise)
|
||||
], PushController.prototype, "getStats", null);
|
||||
__decorate([
|
||||
(0, common_1.Delete)('cleanup'),
|
||||
__param(0, (0, common_1.Query)('days')),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [String]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], PushController.prototype, "cleanup", null);
|
||||
exports.PushController = PushController = PushController_1 = __decorate([
|
||||
(0, common_1.Controller)('push'),
|
||||
__metadata("design:paramtypes", [web_push_service_1.WebPushService])
|
||||
], PushController);
|
||||
//# sourceMappingURL=push.controller.js.map
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,18 @@
|
||||
export declare class PushSubscription {
|
||||
id: string;
|
||||
userId: string;
|
||||
endpoint: string;
|
||||
p256dhKey: string;
|
||||
authKey: string;
|
||||
userAgent: string;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
toWebPushSubscription(): {
|
||||
endpoint: string;
|
||||
keys: {
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use strict";
|
||||
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
||||
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
||||
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
||||
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
||||
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
||||
};
|
||||
var __metadata = (this && this.__metadata) || function (k, v) {
|
||||
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.PushSubscription = void 0;
|
||||
const typeorm_1 = require("typeorm");
|
||||
let PushSubscription = class PushSubscription {
|
||||
toWebPushSubscription() {
|
||||
return {
|
||||
endpoint: this.endpoint,
|
||||
keys: {
|
||||
p256dh: this.p256dhKey,
|
||||
auth: this.authKey,
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
exports.PushSubscription = PushSubscription;
|
||||
__decorate([
|
||||
(0, typeorm_1.PrimaryGeneratedColumn)('uuid'),
|
||||
__metadata("design:type", String)
|
||||
], PushSubscription.prototype, "id", void 0);
|
||||
__decorate([
|
||||
(0, typeorm_1.Column)('varchar', { length: 255 }),
|
||||
__metadata("design:type", String)
|
||||
], PushSubscription.prototype, "userId", void 0);
|
||||
__decorate([
|
||||
(0, typeorm_1.Column)('text'),
|
||||
__metadata("design:type", String)
|
||||
], PushSubscription.prototype, "endpoint", void 0);
|
||||
__decorate([
|
||||
(0, typeorm_1.Column)('varchar', { length: 255 }),
|
||||
__metadata("design:type", String)
|
||||
], PushSubscription.prototype, "p256dhKey", void 0);
|
||||
__decorate([
|
||||
(0, typeorm_1.Column)('varchar', { length: 255 }),
|
||||
__metadata("design:type", String)
|
||||
], PushSubscription.prototype, "authKey", void 0);
|
||||
__decorate([
|
||||
(0, typeorm_1.Column)('varchar', { length: 100, nullable: true }),
|
||||
__metadata("design:type", String)
|
||||
], PushSubscription.prototype, "userAgent", void 0);
|
||||
__decorate([
|
||||
(0, typeorm_1.Column)('boolean', { default: true }),
|
||||
__metadata("design:type", Boolean)
|
||||
], PushSubscription.prototype, "isActive", void 0);
|
||||
__decorate([
|
||||
(0, typeorm_1.CreateDateColumn)(),
|
||||
__metadata("design:type", Date)
|
||||
], PushSubscription.prototype, "createdAt", void 0);
|
||||
__decorate([
|
||||
(0, typeorm_1.UpdateDateColumn)(),
|
||||
__metadata("design:type", Date)
|
||||
], PushSubscription.prototype, "updatedAt", void 0);
|
||||
exports.PushSubscription = PushSubscription = __decorate([
|
||||
(0, typeorm_1.Entity)('push_subscriptions'),
|
||||
(0, typeorm_1.Index)(['userId'], { unique: false }),
|
||||
(0, typeorm_1.Index)(['endpoint'], { unique: true })
|
||||
], PushSubscription);
|
||||
//# sourceMappingURL=push-subscription.entity.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"push-subscription.entity.js","sourceRoot":"","sources":["../../src/entities/push-subscription.entity.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,qCAA4G;AAKrG,IAAM,gBAAgB,GAAtB,MAAM,gBAAgB;IA6B3B,qBAAqB;QACnB,OAAO;YACL,QAAQ,EAAE,IAAI,CAAC,QAAQ;YACvB,IAAI,EAAE;gBACJ,MAAM,EAAE,IAAI,CAAC,SAAS;gBACtB,IAAI,EAAE,IAAI,CAAC,OAAO;aACnB;SACF,CAAC;IACJ,CAAC;CACF,CAAA;AAtCY,4CAAgB;AAE3B;IADC,IAAA,gCAAsB,EAAC,MAAM,CAAC;;4CACpB;AAGX;IADC,IAAA,gBAAM,EAAC,SAAS,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC;;gDACpB;AAGf;IADC,IAAA,gBAAM,EAAC,MAAM,CAAC;;kDACE;AAGjB;IADC,IAAA,gBAAM,EAAC,SAAS,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC;;mDACjB;AAGlB;IADC,IAAA,gBAAM,EAAC,SAAS,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC;;iDACnB;AAGhB;IADC,IAAA,gBAAM,EAAC,SAAS,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;;mDACjC;AAGlB;IADC,IAAA,gBAAM,EAAC,SAAS,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;;kDACnB;AAGlB;IADC,IAAA,0BAAgB,GAAE;8BACR,IAAI;mDAAC;AAGhB;IADC,IAAA,0BAAgB,GAAE;8BACR,IAAI;mDAAC;2BA1BL,gBAAgB;IAH5B,IAAA,gBAAM,EAAC,oBAAoB,CAAC;IAC5B,IAAA,eAAK,EAAC,CAAC,QAAQ,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC;IACpC,IAAA,eAAK,EAAC,CAAC,UAAU,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC;GACzB,gBAAgB,CAsC5B"}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export * from './services/novu.service';
|
||||
export * from './modules/notification.module';
|
||||
export * from './modules/notification.controller';
|
||||
export * from './types/novu.types';
|
||||
export * from './app.module';
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
||||
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
__exportStar(require("./services/novu.service"), exports);
|
||||
__exportStar(require("./modules/notification.module"), exports);
|
||||
__exportStar(require("./modules/notification.controller"), exports);
|
||||
__exportStar(require("./types/novu.types"), exports);
|
||||
__exportStar(require("./app.module"), exports);
|
||||
//# sourceMappingURL=index.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,0DAAwC;AACxC,gEAA8C;AAC9C,oEAAkD;AAClD,qDAAmC;AACnC,+CAA6B"}
|
||||
+1
@@ -0,0 +1 @@
|
||||
import 'reflect-metadata';
|
||||
Vendored
+36
@@ -0,0 +1,36 @@
|
||||
"use strict";
|
||||
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
||||
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
||||
return new (P || (P = Promise))(function (resolve, reject) {
|
||||
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
||||
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
||||
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
||||
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
||||
});
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
require("reflect-metadata");
|
||||
const core_1 = require("@nestjs/core");
|
||||
const app_module_1 = require("./app.module");
|
||||
const common_1 = require("@nestjs/common");
|
||||
function bootstrap() {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
const logger = new common_1.Logger('Bootstrap');
|
||||
const app = yield core_1.NestFactory.create(app_module_1.AppModule);
|
||||
app.enableCors({
|
||||
origin: process.env.CORS_ORIGIN || '*',
|
||||
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
|
||||
credentials: true,
|
||||
});
|
||||
app.setGlobalPrefix('api');
|
||||
const port = process.env.PORT || 3000;
|
||||
yield app.listen(port);
|
||||
logger.log(`🚀 Приложение запущено на http://localhost:${port}`);
|
||||
logger.log(`📡 API доступно на http://localhost:${port}/api`);
|
||||
});
|
||||
}
|
||||
bootstrap().catch((error) => {
|
||||
console.error('Ошибка запуска приложения:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
//# sourceMappingURL=main.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"main.js","sourceRoot":"","sources":["../src/main.ts"],"names":[],"mappings":";;;;;;;;;;;AAAA,4BAA0B;AAC1B,uCAA2C;AAC3C,6CAAyC;AACzC,2CAAwC;AAExC,SAAe,SAAS;;QACtB,MAAM,MAAM,GAAG,IAAI,eAAM,CAAC,WAAW,CAAC,CAAC;QAEvC,MAAM,GAAG,GAAG,MAAM,kBAAW,CAAC,MAAM,CAAC,sBAAS,CAAC,CAAC;QAGhD,GAAG,CAAC,UAAU,CAAC;YACb,MAAM,EAAE,OAAO,CAAC,GAAG,CAAC,WAAW,IAAI,GAAG;YACtC,OAAO,EAAE,gCAAgC;YACzC,WAAW,EAAE,IAAI;SAClB,CAAC,CAAC;QAGH,GAAG,CAAC,eAAe,CAAC,KAAK,CAAC,CAAC;QAE3B,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,IAAI,CAAC;QACtC,MAAM,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;QAEvB,MAAM,CAAC,GAAG,CAAC,8CAA8C,IAAI,EAAE,CAAC,CAAC;QACjE,MAAM,CAAC,GAAG,CAAC,uCAAuC,IAAI,MAAM,CAAC,CAAC;IAChE,CAAC;CAAA;AAED,SAAS,EAAE,CAAC,KAAK,CAAC,CAAC,KAAK,EAAE,EAAE;IAC1B,OAAO,CAAC,KAAK,CAAC,4BAA4B,EAAE,KAAK,CAAC,CAAC;IACnD,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC,CAAC,CAAC"}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { NovuService } from '../services/novu.service';
|
||||
export declare class NotificationController {
|
||||
private readonly novuService;
|
||||
private readonly logger;
|
||||
constructor(novuService: NovuService);
|
||||
getHealth(): Promise<{
|
||||
status: string;
|
||||
timestamp: string;
|
||||
service: string;
|
||||
}>;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use strict";
|
||||
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
||||
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
||||
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
||||
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
||||
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
||||
};
|
||||
var __metadata = (this && this.__metadata) || function (k, v) {
|
||||
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
||||
};
|
||||
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
||||
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
||||
return new (P || (P = Promise))(function (resolve, reject) {
|
||||
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
||||
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
||||
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
||||
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
||||
});
|
||||
};
|
||||
var NotificationController_1;
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.NotificationController = void 0;
|
||||
const common_1 = require("@nestjs/common");
|
||||
const novu_service_1 = require("../services/novu.service");
|
||||
let NotificationController = NotificationController_1 = class NotificationController {
|
||||
constructor(novuService) {
|
||||
this.novuService = novuService;
|
||||
this.logger = new common_1.Logger(NotificationController_1.name);
|
||||
}
|
||||
getHealth() {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
const isHealthy = yield this.novuService.getHealth();
|
||||
return {
|
||||
status: isHealthy ? 'ok' : 'error',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'notificator'
|
||||
};
|
||||
});
|
||||
}
|
||||
};
|
||||
exports.NotificationController = NotificationController;
|
||||
__decorate([
|
||||
(0, common_1.Get)('health'),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", []),
|
||||
__metadata("design:returntype", Promise)
|
||||
], NotificationController.prototype, "getHealth", null);
|
||||
exports.NotificationController = NotificationController = NotificationController_1 = __decorate([
|
||||
(0, common_1.Controller)('notifications'),
|
||||
__metadata("design:paramtypes", [novu_service_1.NovuService])
|
||||
], NotificationController);
|
||||
//# sourceMappingURL=notification.controller.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"notification.controller.js","sourceRoot":"","sources":["../../src/modules/notification.controller.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;AAAA,2CAAyD;AACzD,2DAAuD;AAGhD,IAAM,sBAAsB,8BAA5B,MAAM,sBAAsB;IAGjC,YAA6B,WAAwB;QAAxB,gBAAW,GAAX,WAAW,CAAa;QAFpC,WAAM,GAAG,IAAI,eAAM,CAAC,wBAAsB,CAAC,IAAI,CAAC,CAAC;IAEV,CAAC;IAGnD,SAAS;;YACb,MAAM,SAAS,GAAG,MAAM,IAAI,CAAC,WAAW,CAAC,SAAS,EAAE,CAAC;YACrD,OAAO;gBACL,MAAM,EAAE,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,OAAO;gBAClC,SAAS,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE;gBACnC,OAAO,EAAE,aAAa;aACvB,CAAC;QACJ,CAAC;KAAA;CACF,CAAA;AAdY,wDAAsB;AAM3B;IADL,IAAA,YAAG,EAAC,QAAQ,CAAC;;;;uDAQb;iCAbU,sBAAsB;IADlC,IAAA,mBAAU,EAAC,eAAe,CAAC;qCAIgB,0BAAW;GAH1C,sBAAsB,CAclC"}
|
||||
@@ -0,0 +1,2 @@
|
||||
export declare class NotificationModule {
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use strict";
|
||||
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
||||
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
||||
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
||||
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
||||
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.NotificationModule = void 0;
|
||||
const common_1 = require("@nestjs/common");
|
||||
const config_1 = require("@nestjs/config");
|
||||
const novu_service_1 = require("../services/novu.service");
|
||||
const notification_controller_1 = require("./notification.controller");
|
||||
let NotificationModule = class NotificationModule {
|
||||
};
|
||||
exports.NotificationModule = NotificationModule;
|
||||
exports.NotificationModule = NotificationModule = __decorate([
|
||||
(0, common_1.Module)({
|
||||
imports: [config_1.ConfigModule],
|
||||
controllers: [notification_controller_1.NotificationController],
|
||||
providers: [novu_service_1.NovuService],
|
||||
exports: [novu_service_1.NovuService],
|
||||
})
|
||||
], NotificationModule);
|
||||
//# sourceMappingURL=notification.module.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"notification.module.js","sourceRoot":"","sources":["../../src/modules/notification.module.ts"],"names":[],"mappings":";;;;;;;;;AAAA,2CAAwC;AACxC,2CAA8C;AAC9C,2DAAuD;AACvD,uEAAmE;AAQ5D,IAAM,kBAAkB,GAAxB,MAAM,kBAAkB;CAAG,CAAA;AAArB,gDAAkB;6BAAlB,kBAAkB;IAN9B,IAAA,eAAM,EAAC;QACN,OAAO,EAAE,CAAC,qBAAY,CAAC;QACvB,WAAW,EAAE,CAAC,gDAAsB,CAAC;QACrC,SAAS,EAAE,CAAC,0BAAW,CAAC;QACxB,OAAO,EAAE,CAAC,0BAAW,CAAC;KACvB,CAAC;GACW,kBAAkB,CAAG"}
|
||||
@@ -0,0 +1,2 @@
|
||||
export declare class WebPushModule {
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use strict";
|
||||
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
||||
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
||||
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
||||
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
||||
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.WebPushModule = void 0;
|
||||
const common_1 = require("@nestjs/common");
|
||||
const typeorm_1 = require("@nestjs/typeorm");
|
||||
const config_1 = require("@nestjs/config");
|
||||
const web_push_service_1 = require("../services/web-push.service");
|
||||
const novu_webpush_service_1 = require("../services/novu-webpush.service");
|
||||
const cleanup_service_1 = require("../services/cleanup.service");
|
||||
const push_controller_1 = require("../controllers/push.controller");
|
||||
const novu_webhook_controller_1 = require("../controllers/novu-webhook.controller");
|
||||
const push_subscription_entity_1 = require("../entities/push-subscription.entity");
|
||||
const novu_service_1 = require("../services/novu.service");
|
||||
let WebPushModule = class WebPushModule {
|
||||
};
|
||||
exports.WebPushModule = WebPushModule;
|
||||
exports.WebPushModule = WebPushModule = __decorate([
|
||||
(0, common_1.Module)({
|
||||
imports: [
|
||||
config_1.ConfigModule,
|
||||
typeorm_1.TypeOrmModule.forFeature([push_subscription_entity_1.PushSubscription]),
|
||||
],
|
||||
controllers: [push_controller_1.PushController, novu_webhook_controller_1.NovuWebhookController],
|
||||
providers: [web_push_service_1.WebPushService, novu_webpush_service_1.NovuWebPushService, novu_service_1.NovuService, cleanup_service_1.CleanupService],
|
||||
exports: [web_push_service_1.WebPushService, novu_webpush_service_1.NovuWebPushService],
|
||||
})
|
||||
], WebPushModule);
|
||||
//# sourceMappingURL=web-push.module.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"web-push.module.js","sourceRoot":"","sources":["../../src/modules/web-push.module.ts"],"names":[],"mappings":";;;;;;;;;AAAA,2CAAwC;AACxC,6CAAgD;AAChD,2CAA8C;AAC9C,mEAA8D;AAC9D,2EAAsE;AACtE,iEAA6D;AAC7D,oEAAgE;AAChE,oFAA+E;AAC/E,mFAAwE;AACxE,2DAAuD;AAWhD,IAAM,aAAa,GAAnB,MAAM,aAAa;CAAG,CAAA;AAAhB,sCAAa;wBAAb,aAAa;IATzB,IAAA,eAAM,EAAC;QACN,OAAO,EAAE;YACP,qBAAY;YACZ,uBAAa,CAAC,UAAU,CAAC,CAAC,2CAAgB,CAAC,CAAC;SAC7C;QACD,WAAW,EAAE,CAAC,gCAAc,EAAE,+CAAqB,CAAC;QACpD,SAAS,EAAE,CAAC,iCAAc,EAAE,yCAAkB,EAAE,0BAAW,EAAE,gCAAc,CAAC;QAC5E,OAAO,EAAE,CAAC,iCAAc,EAAE,yCAAkB,CAAC;KAC9C,CAAC;GACW,aAAa,CAAG"}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { WebPushService } from './web-push.service';
|
||||
export declare class CleanupService {
|
||||
private readonly webPushService;
|
||||
private readonly logger;
|
||||
constructor(webPushService: WebPushService);
|
||||
cleanupInactiveSubscriptions(): Promise<void>;
|
||||
logWeeklyStats(): Promise<void>;
|
||||
manualCleanup(olderThanDays: number): Promise<number>;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
"use strict";
|
||||
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
||||
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
||||
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
||||
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
||||
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
||||
};
|
||||
var __metadata = (this && this.__metadata) || function (k, v) {
|
||||
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
||||
};
|
||||
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
||||
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
||||
return new (P || (P = Promise))(function (resolve, reject) {
|
||||
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
||||
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
||||
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
||||
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
||||
});
|
||||
};
|
||||
var CleanupService_1;
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.CleanupService = void 0;
|
||||
const common_1 = require("@nestjs/common");
|
||||
const schedule_1 = require("@nestjs/schedule");
|
||||
const web_push_service_1 = require("./web-push.service");
|
||||
let CleanupService = CleanupService_1 = class CleanupService {
|
||||
constructor(webPushService) {
|
||||
this.webPushService = webPushService;
|
||||
this.logger = new common_1.Logger(CleanupService_1.name);
|
||||
}
|
||||
cleanupInactiveSubscriptions() {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
this.logger.log('Запуск автоматической очистки неактивных push подписок...');
|
||||
try {
|
||||
const deletedCount = yield this.webPushService.cleanupInactiveSubscriptions(30);
|
||||
this.logger.log(`Автоматическая очистка завершена. Удалено ${deletedCount} неактивных подписок.`);
|
||||
}
|
||||
catch (error) {
|
||||
this.logger.error('Ошибка автоматической очистки:', error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
logWeeklyStats() {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
this.logger.log('Генерация еженедельной статистики push подписок...');
|
||||
try {
|
||||
const stats = yield this.webPushService.getSubscriptionStats();
|
||||
this.logger.log(`📊 Еженедельная статистика push подписок:`);
|
||||
this.logger.log(` Всего подписок: ${stats.total}`);
|
||||
this.logger.log(` Активных: ${stats.active}`);
|
||||
this.logger.log(` Неактивных: ${stats.inactive}`);
|
||||
this.logger.log(` Уникальных пользователей: ${stats.uniqueUsers}`);
|
||||
const activePercentage = stats.total > 0 ? ((stats.active / stats.total) * 100).toFixed(1) : '0';
|
||||
this.logger.log(` Процент активных подписок: ${activePercentage}%`);
|
||||
}
|
||||
catch (error) {
|
||||
this.logger.error('Ошибка генерации статистики:', error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
manualCleanup(olderThanDays) {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
this.logger.log(`Запуск ручной очистки подписок старше ${olderThanDays} дней...`);
|
||||
try {
|
||||
const deletedCount = yield this.webPushService.cleanupInactiveSubscriptions(olderThanDays);
|
||||
this.logger.log(`Ручная очистка завершена. Удалено ${deletedCount} неактивных подписок.`);
|
||||
return deletedCount;
|
||||
}
|
||||
catch (error) {
|
||||
this.logger.error('Ошибка ручной очистки:', error.message);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
exports.CleanupService = CleanupService;
|
||||
__decorate([
|
||||
(0, schedule_1.Cron)(schedule_1.CronExpression.EVERY_DAY_AT_2AM),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", []),
|
||||
__metadata("design:returntype", Promise)
|
||||
], CleanupService.prototype, "cleanupInactiveSubscriptions", null);
|
||||
__decorate([
|
||||
(0, schedule_1.Cron)('0 12 * * 0'),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", []),
|
||||
__metadata("design:returntype", Promise)
|
||||
], CleanupService.prototype, "logWeeklyStats", null);
|
||||
exports.CleanupService = CleanupService = CleanupService_1 = __decorate([
|
||||
(0, common_1.Injectable)(),
|
||||
__metadata("design:paramtypes", [web_push_service_1.WebPushService])
|
||||
], CleanupService);
|
||||
//# sourceMappingURL=cleanup.service.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"cleanup.service.js","sourceRoot":"","sources":["../../src/services/cleanup.service.ts"],"names":[],"mappings":"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"}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { WebPushService } from './web-push.service';
|
||||
export interface NovuWebPushPayload {
|
||||
title: string;
|
||||
body: string;
|
||||
icon?: string;
|
||||
badge?: string;
|
||||
image?: string;
|
||||
url?: string;
|
||||
data?: any;
|
||||
actions?: Array<{
|
||||
action: string;
|
||||
title: string;
|
||||
icon?: string;
|
||||
url?: string;
|
||||
}>;
|
||||
tag?: string;
|
||||
requireInteraction?: boolean;
|
||||
silent?: boolean;
|
||||
vibrate?: number[];
|
||||
}
|
||||
export declare class NovuWebPushService {
|
||||
private readonly webPushService;
|
||||
private readonly logger;
|
||||
constructor(webPushService: WebPushService);
|
||||
sendWebPushFromNovu(userId: string, payload: NovuWebPushPayload, workflowId?: string): Promise<void>;
|
||||
getWebPushIntegrationConfig(): {
|
||||
name: string;
|
||||
identifier: string;
|
||||
logoFileName: string;
|
||||
channel: string;
|
||||
credentials: ({
|
||||
key: string;
|
||||
displayName: string;
|
||||
type: string;
|
||||
required: boolean;
|
||||
value: string;
|
||||
} | {
|
||||
key: string;
|
||||
displayName: string;
|
||||
type: string;
|
||||
required: boolean;
|
||||
value?: undefined;
|
||||
})[];
|
||||
docReference: string;
|
||||
comingSoon: boolean;
|
||||
betaVersion: boolean;
|
||||
nesting: boolean;
|
||||
supportedFeatures: {
|
||||
digest: boolean;
|
||||
delay: boolean;
|
||||
title: boolean;
|
||||
body: boolean;
|
||||
avatar: boolean;
|
||||
actions: boolean;
|
||||
};
|
||||
};
|
||||
syncUserSubscriptionsWithNovu(userId: string): Promise<{
|
||||
userId: string;
|
||||
subscriptionsCount: number;
|
||||
syncedAt: Date;
|
||||
}>;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
"use strict";
|
||||
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
||||
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
||||
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
||||
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
||||
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
||||
};
|
||||
var __metadata = (this && this.__metadata) || function (k, v) {
|
||||
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
||||
};
|
||||
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
||||
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
||||
return new (P || (P = Promise))(function (resolve, reject) {
|
||||
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
||||
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
||||
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
||||
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
||||
});
|
||||
};
|
||||
var NovuWebPushService_1;
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.NovuWebPushService = void 0;
|
||||
const common_1 = require("@nestjs/common");
|
||||
const web_push_service_1 = require("./web-push.service");
|
||||
let NovuWebPushService = NovuWebPushService_1 = class NovuWebPushService {
|
||||
constructor(webPushService) {
|
||||
this.webPushService = webPushService;
|
||||
this.logger = new common_1.Logger(NovuWebPushService_1.name);
|
||||
}
|
||||
sendWebPushFromNovu(userId, payload, workflowId) {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
var _a;
|
||||
try {
|
||||
const notificationPayload = {
|
||||
title: payload.title,
|
||||
body: payload.body,
|
||||
icon: payload.icon || '/icons/default-icon.png',
|
||||
badge: payload.badge || '/icons/badge.png',
|
||||
image: payload.image,
|
||||
data: Object.assign(Object.assign({}, payload.data), { url: payload.url, workflowId, timestamp: Date.now() }),
|
||||
actions: (_a = payload.actions) === null || _a === void 0 ? void 0 : _a.map(action => ({
|
||||
action: action.action,
|
||||
title: action.title,
|
||||
icon: action.icon,
|
||||
})),
|
||||
tag: payload.tag,
|
||||
requireInteraction: payload.requireInteraction,
|
||||
silent: payload.silent,
|
||||
timestamp: Date.now(),
|
||||
vibrate: payload.vibrate,
|
||||
};
|
||||
yield this.webPushService.sendNotificationToUser(userId, notificationPayload);
|
||||
this.logger.log(`Web-push уведомление отправлено через NOVU для пользователя ${userId}, workflow: ${workflowId}`);
|
||||
}
|
||||
catch (error) {
|
||||
this.logger.error(`Ошибка отправки web-push через NOVU для пользователя ${userId}:`, error.message);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
getWebPushIntegrationConfig() {
|
||||
const vapidPublicKey = this.webPushService.getVapidPublicKey();
|
||||
return {
|
||||
name: 'Web Push (Custom)',
|
||||
identifier: 'web-push-custom',
|
||||
logoFileName: 'web-push.png',
|
||||
channel: 'push',
|
||||
credentials: [
|
||||
{
|
||||
key: 'publicKey',
|
||||
displayName: 'VAPID Public Key',
|
||||
type: 'string',
|
||||
required: true,
|
||||
value: vapidPublicKey,
|
||||
},
|
||||
{
|
||||
key: 'privateKey',
|
||||
displayName: 'VAPID Private Key',
|
||||
type: 'secret',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'subject',
|
||||
displayName: 'VAPID Subject',
|
||||
type: 'string',
|
||||
required: true,
|
||||
value: 'mailto:admin@coopenomics.io',
|
||||
},
|
||||
],
|
||||
docReference: 'https://developer.mozilla.org/en-US/docs/Web/API/Push_API',
|
||||
comingSoon: false,
|
||||
betaVersion: false,
|
||||
nesting: false,
|
||||
supportedFeatures: {
|
||||
digest: true,
|
||||
delay: true,
|
||||
title: true,
|
||||
body: true,
|
||||
avatar: true,
|
||||
actions: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
syncUserSubscriptionsWithNovu(userId) {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
try {
|
||||
const subscriptions = yield this.webPushService.getUserSubscriptions(userId);
|
||||
this.logger.log(`Синхронизированы подписки для пользователя ${userId}: ${subscriptions.length} активных`);
|
||||
return {
|
||||
userId,
|
||||
subscriptionsCount: subscriptions.length,
|
||||
syncedAt: new Date(),
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
this.logger.error(`Ошибка синхронизации подписок для пользователя ${userId}:`, error.message);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
exports.NovuWebPushService = NovuWebPushService;
|
||||
exports.NovuWebPushService = NovuWebPushService = NovuWebPushService_1 = __decorate([
|
||||
(0, common_1.Injectable)(),
|
||||
__metadata("design:paramtypes", [web_push_service_1.WebPushService])
|
||||
], NovuWebPushService);
|
||||
//# sourceMappingURL=novu-webpush.service.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"novu-webpush.service.js","sourceRoot":"","sources":["../../src/services/novu-webpush.service.ts"],"names":[],"mappings":"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"}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { WorkflowDefinition, BaseWorkflowPayload, NovuWorkflowData } from '@coopenomics/notifications';
|
||||
import { NovuApiResponse } from '../types/novu.types';
|
||||
export declare class NovuService implements OnModuleInit {
|
||||
private configService;
|
||||
private readonly logger;
|
||||
private readonly client;
|
||||
private readonly config;
|
||||
constructor(configService: ConfigService);
|
||||
onModuleInit(): Promise<void>;
|
||||
getWorkflow(workflowId: string): Promise<any>;
|
||||
createWorkflow(data: NovuWorkflowData): Promise<any>;
|
||||
updateWorkflow(workflowId: string, data: NovuWorkflowData): Promise<any>;
|
||||
upsertWorkflow(workflow: WorkflowDefinition): Promise<any>;
|
||||
upsertAllWorkflows(): Promise<void>;
|
||||
triggerWorkflow<T extends BaseWorkflowPayload>(workflowId: string, subscriberId: string, payload: T, email?: string, actor?: {
|
||||
subscriberId: string;
|
||||
email?: string;
|
||||
}): Promise<NovuApiResponse>;
|
||||
getHealth(): Promise<boolean>;
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
"use strict";
|
||||
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
||||
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
||||
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
||||
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
||||
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
||||
};
|
||||
var __metadata = (this && this.__metadata) || function (k, v) {
|
||||
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
||||
};
|
||||
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
||||
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
||||
return new (P || (P = Promise))(function (resolve, reject) {
|
||||
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
||||
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
||||
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
||||
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
||||
});
|
||||
};
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
var NovuService_1;
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.NovuService = void 0;
|
||||
const common_1 = require("@nestjs/common");
|
||||
const config_1 = require("@nestjs/config");
|
||||
const axios_1 = __importDefault(require("axios"));
|
||||
const notifications_1 = require("@coopenomics/notifications");
|
||||
let NovuService = NovuService_1 = class NovuService {
|
||||
constructor(configService) {
|
||||
this.configService = configService;
|
||||
this.logger = new common_1.Logger(NovuService_1.name);
|
||||
this.config = {
|
||||
apiKey: this.configService.get('NOVU_API_KEY') || '',
|
||||
apiUrl: this.configService.get('NOVU_API_URL') || 'https://api.novu.co',
|
||||
};
|
||||
if (!this.config.apiKey) {
|
||||
throw new Error('NOVU_API_KEY is required');
|
||||
}
|
||||
this.client = axios_1.default.create({
|
||||
baseURL: this.config.apiUrl,
|
||||
headers: {
|
||||
'Authorization': `ApiKey ${this.config.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
onModuleInit() {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
this.logger.log('Инициализация Novu сервиса...');
|
||||
yield this.upsertAllWorkflows();
|
||||
});
|
||||
}
|
||||
getWorkflow(workflowId) {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
var _a;
|
||||
try {
|
||||
const response = yield this.client.get(`/v2/workflows/${workflowId}`);
|
||||
return response.data;
|
||||
}
|
||||
catch (error) {
|
||||
if (((_a = error.response) === null || _a === void 0 ? void 0 : _a.status) === 404) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
createWorkflow(data) {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
var _a;
|
||||
try {
|
||||
const createData = Object.assign({}, data);
|
||||
delete createData.origin;
|
||||
const response = yield this.client.post('/v2/workflows', createData);
|
||||
return response.data;
|
||||
}
|
||||
catch (error) {
|
||||
this.logger.error(`Ошибка создания воркфлоу ${data.workflowId}:`, ((_a = error.response) === null || _a === void 0 ? void 0 : _a.data) || error.message);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
updateWorkflow(workflowId, data) {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
var _a;
|
||||
try {
|
||||
const updateData = Object.assign(Object.assign({}, data), { origin: 'external' });
|
||||
const response = yield this.client.put(`/v2/workflows/${workflowId}`, updateData);
|
||||
return response.data;
|
||||
}
|
||||
catch (error) {
|
||||
this.logger.error(`Ошибка обновления воркфлоу ${workflowId}:`, ((_a = error.response) === null || _a === void 0 ? void 0 : _a.data) || error.message);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
upsertWorkflow(workflow) {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
try {
|
||||
this.logger.log(`Проверяем воркфлоу: ${workflow.workflowId}`);
|
||||
const existingWorkflow = yield this.getWorkflow(workflow.workflowId);
|
||||
const novuData = {
|
||||
name: workflow.name,
|
||||
workflowId: workflow.workflowId,
|
||||
description: workflow.description,
|
||||
payloadSchema: workflow.payloadSchema,
|
||||
steps: workflow.steps,
|
||||
preferences: workflow.preferences,
|
||||
};
|
||||
if (existingWorkflow) {
|
||||
this.logger.log(`Обновляем воркфлоу: ${workflow.workflowId}`);
|
||||
return yield this.updateWorkflow(workflow.workflowId, novuData);
|
||||
}
|
||||
else {
|
||||
this.logger.log(`Создаём воркфлоу: ${workflow.workflowId}`);
|
||||
return yield this.createWorkflow(novuData);
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
this.logger.error(`Ошибка upsert воркфлоу ${workflow.workflowId}:`, error.message);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
upsertAllWorkflows() {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
this.logger.log(`Начинаем upsert ${notifications_1.allWorkflows.length} воркфлоу...`);
|
||||
for (const workflow of notifications_1.allWorkflows) {
|
||||
try {
|
||||
yield this.upsertWorkflow(workflow);
|
||||
this.logger.log(`✓ Воркфлоу ${workflow.workflowId} успешно обработан`);
|
||||
}
|
||||
catch (error) {
|
||||
this.logger.error(`✗ Ошибка обработки воркфлоу ${workflow.workflowId}:`, error.message);
|
||||
}
|
||||
}
|
||||
this.logger.log('Завершён upsert всех воркфлоу');
|
||||
});
|
||||
}
|
||||
triggerWorkflow(workflowId, subscriberId, payload, email, actor) {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
var _a;
|
||||
try {
|
||||
const triggerData = {
|
||||
name: workflowId,
|
||||
to: {
|
||||
subscriberId,
|
||||
email,
|
||||
},
|
||||
payload,
|
||||
actor,
|
||||
};
|
||||
const response = yield this.client.post('/v1/events/trigger', triggerData);
|
||||
return response.data;
|
||||
}
|
||||
catch (error) {
|
||||
this.logger.error(`Ошибка триггера воркфлоу ${workflowId}:`, ((_a = error.response) === null || _a === void 0 ? void 0 : _a.data) || error.message);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
getHealth() {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
try {
|
||||
const response = yield this.client.get('/v1/health-check');
|
||||
return response.status === 200;
|
||||
}
|
||||
catch (_a) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
exports.NovuService = NovuService;
|
||||
exports.NovuService = NovuService = NovuService_1 = __decorate([
|
||||
(0, common_1.Injectable)(),
|
||||
__metadata("design:paramtypes", [config_1.ConfigService])
|
||||
], NovuService);
|
||||
//# sourceMappingURL=novu.service.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"novu.service.js","sourceRoot":"","sources":["../../src/services/novu.service.ts"],"names":[],"mappings":"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"}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Repository } from 'typeorm';
|
||||
import { PushSubscription } from '../entities/push-subscription.entity';
|
||||
export interface PushSubscriptionData {
|
||||
endpoint: string;
|
||||
keys: {
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
};
|
||||
}
|
||||
export interface NotificationPayload {
|
||||
title: string;
|
||||
body: string;
|
||||
icon?: string;
|
||||
badge?: string;
|
||||
image?: string;
|
||||
data?: any;
|
||||
actions?: Array<{
|
||||
action: string;
|
||||
title: string;
|
||||
icon?: string;
|
||||
}>;
|
||||
tag?: string;
|
||||
requireInteraction?: boolean;
|
||||
silent?: boolean;
|
||||
timestamp?: number;
|
||||
vibrate?: number[];
|
||||
}
|
||||
export declare class WebPushService implements OnModuleInit {
|
||||
private configService;
|
||||
private pushSubscriptionRepository;
|
||||
private readonly logger;
|
||||
constructor(configService: ConfigService, pushSubscriptionRepository: Repository<PushSubscription>);
|
||||
onModuleInit(): Promise<void>;
|
||||
private initializeWebPush;
|
||||
private generateVapidKeys;
|
||||
getVapidPublicKey(): string;
|
||||
saveSubscription(userId: string, subscription: PushSubscriptionData, userAgent?: string): Promise<PushSubscription>;
|
||||
removeSubscription(endpoint: string): Promise<void>;
|
||||
getUserSubscriptions(userId: string): Promise<PushSubscription[]>;
|
||||
sendNotificationToUser(userId: string, payload: NotificationPayload): Promise<void>;
|
||||
sendNotificationToSubscriptions(subscriptions: PushSubscription[], payload: NotificationPayload): Promise<void>;
|
||||
sendNotificationToAll(payload: NotificationPayload): Promise<void>;
|
||||
getSubscriptionStats(): Promise<{
|
||||
total: number;
|
||||
active: number;
|
||||
inactive: number;
|
||||
uniqueUsers: number;
|
||||
}>;
|
||||
cleanupInactiveSubscriptions(olderThanDays?: number): Promise<number>;
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
||||
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
||||
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
||||
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
||||
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
||||
};
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
var __metadata = (this && this.__metadata) || function (k, v) {
|
||||
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
||||
};
|
||||
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
||||
return function (target, key) { decorator(target, key, paramIndex); }
|
||||
};
|
||||
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
||||
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
||||
return new (P || (P = Promise))(function (resolve, reject) {
|
||||
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
||||
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
||||
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
||||
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
||||
});
|
||||
};
|
||||
var WebPushService_1;
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.WebPushService = void 0;
|
||||
const common_1 = require("@nestjs/common");
|
||||
const config_1 = require("@nestjs/config");
|
||||
const typeorm_1 = require("@nestjs/typeorm");
|
||||
const typeorm_2 = require("typeorm");
|
||||
const webpush = __importStar(require("web-push"));
|
||||
const push_subscription_entity_1 = require("../entities/push-subscription.entity");
|
||||
let WebPushService = WebPushService_1 = class WebPushService {
|
||||
constructor(configService, pushSubscriptionRepository) {
|
||||
this.configService = configService;
|
||||
this.pushSubscriptionRepository = pushSubscriptionRepository;
|
||||
this.logger = new common_1.Logger(WebPushService_1.name);
|
||||
}
|
||||
onModuleInit() {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
yield this.initializeWebPush();
|
||||
});
|
||||
}
|
||||
initializeWebPush() {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
const vapidPublicKey = this.configService.get('VAPID_PUBLIC_KEY');
|
||||
const vapidPrivateKey = this.configService.get('VAPID_PRIVATE_KEY');
|
||||
const vapidSubject = this.configService.get('VAPID_SUBJECT') || 'mailto:admin@coopenomics.io';
|
||||
if (!vapidPublicKey || !vapidPrivateKey) {
|
||||
this.logger.warn('VAPID ключи не настроены. Генерирую новые...');
|
||||
yield this.generateVapidKeys();
|
||||
return;
|
||||
}
|
||||
webpush.setVapidDetails(vapidSubject, vapidPublicKey, vapidPrivateKey);
|
||||
this.logger.log('Web-push сервис инициализирован с существующими VAPID ключами');
|
||||
});
|
||||
}
|
||||
generateVapidKeys() {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
const vapidKeys = webpush.generateVAPIDKeys();
|
||||
this.logger.log('Сгенерированы новые VAPID ключи:');
|
||||
this.logger.log(`VAPID_PUBLIC_KEY=${vapidKeys.publicKey}`);
|
||||
this.logger.log(`VAPID_PRIVATE_KEY=${vapidKeys.privateKey}`);
|
||||
this.logger.warn('ВНИМАНИЕ: Добавьте эти ключи в переменные окружения и перезапустите сервис!');
|
||||
const vapidSubject = this.configService.get('VAPID_SUBJECT') || 'mailto:admin@coopenomics.io';
|
||||
webpush.setVapidDetails(vapidSubject, vapidKeys.publicKey, vapidKeys.privateKey);
|
||||
});
|
||||
}
|
||||
getVapidPublicKey() {
|
||||
return this.configService.get('VAPID_PUBLIC_KEY') || '';
|
||||
}
|
||||
saveSubscription(userId, subscription, userAgent) {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
const existingSubscription = yield this.pushSubscriptionRepository.findOne({
|
||||
where: { endpoint: subscription.endpoint }
|
||||
});
|
||||
if (existingSubscription) {
|
||||
existingSubscription.userId = userId;
|
||||
existingSubscription.p256dhKey = subscription.keys.p256dh;
|
||||
existingSubscription.authKey = subscription.keys.auth;
|
||||
existingSubscription.userAgent = userAgent;
|
||||
existingSubscription.isActive = true;
|
||||
return yield this.pushSubscriptionRepository.save(existingSubscription);
|
||||
}
|
||||
const newSubscription = this.pushSubscriptionRepository.create({
|
||||
userId,
|
||||
endpoint: subscription.endpoint,
|
||||
p256dhKey: subscription.keys.p256dh,
|
||||
authKey: subscription.keys.auth,
|
||||
userAgent,
|
||||
isActive: true,
|
||||
});
|
||||
return yield this.pushSubscriptionRepository.save(newSubscription);
|
||||
});
|
||||
}
|
||||
removeSubscription(endpoint) {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
yield this.pushSubscriptionRepository.update({ endpoint }, { isActive: false });
|
||||
});
|
||||
}
|
||||
getUserSubscriptions(userId) {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
return yield this.pushSubscriptionRepository.find({
|
||||
where: { userId, isActive: true }
|
||||
});
|
||||
});
|
||||
}
|
||||
sendNotificationToUser(userId, payload) {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
const subscriptions = yield this.getUserSubscriptions(userId);
|
||||
if (subscriptions.length === 0) {
|
||||
this.logger.warn(`Нет активных push подписок для пользователя ${userId}`);
|
||||
return;
|
||||
}
|
||||
yield this.sendNotificationToSubscriptions(subscriptions, payload);
|
||||
});
|
||||
}
|
||||
sendNotificationToSubscriptions(subscriptions, payload) {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
const promises = subscriptions.map((subscription) => __awaiter(this, void 0, void 0, function* () {
|
||||
try {
|
||||
const webPushSubscription = subscription.toWebPushSubscription();
|
||||
yield webpush.sendNotification(webPushSubscription, JSON.stringify(payload), {
|
||||
TTL: 24 * 60 * 60,
|
||||
urgency: 'normal',
|
||||
topic: payload.tag,
|
||||
});
|
||||
this.logger.debug(`Push уведомление отправлено: ${subscription.endpoint.substring(0, 50)}...`);
|
||||
}
|
||||
catch (error) {
|
||||
this.logger.error(`Ошибка отправки push уведомления для подписки ${subscription.id}:`, error.message);
|
||||
if (error.statusCode === 410 || error.statusCode === 404) {
|
||||
yield this.pushSubscriptionRepository.update({ id: subscription.id }, { isActive: false });
|
||||
this.logger.warn(`Подписка ${subscription.id} деактивирована (endpoint недоступен)`);
|
||||
}
|
||||
}
|
||||
}));
|
||||
yield Promise.allSettled(promises);
|
||||
});
|
||||
}
|
||||
sendNotificationToAll(payload) {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
const subscriptions = yield this.pushSubscriptionRepository.find({
|
||||
where: { isActive: true }
|
||||
});
|
||||
if (subscriptions.length === 0) {
|
||||
this.logger.warn('Нет активных push подписок');
|
||||
return;
|
||||
}
|
||||
this.logger.log(`Отправка push уведомления ${subscriptions.length} подпискам`);
|
||||
yield this.sendNotificationToSubscriptions(subscriptions, payload);
|
||||
});
|
||||
}
|
||||
getSubscriptionStats() {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
const total = yield this.pushSubscriptionRepository.count();
|
||||
const active = yield this.pushSubscriptionRepository.count({
|
||||
where: { isActive: true }
|
||||
});
|
||||
const inactive = total - active;
|
||||
const uniqueUsers = yield this.pushSubscriptionRepository
|
||||
.createQueryBuilder('subscription')
|
||||
.select('COUNT(DISTINCT subscription.userId)', 'count')
|
||||
.where('subscription.isActive = :isActive', { isActive: true })
|
||||
.getRawOne();
|
||||
return {
|
||||
total,
|
||||
active,
|
||||
inactive,
|
||||
uniqueUsers: parseInt(uniqueUsers.count),
|
||||
};
|
||||
});
|
||||
}
|
||||
cleanupInactiveSubscriptions() {
|
||||
return __awaiter(this, arguments, void 0, function* (olderThanDays = 30) {
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - olderThanDays);
|
||||
const result = yield this.pushSubscriptionRepository
|
||||
.createQueryBuilder()
|
||||
.delete()
|
||||
.where('isActive = :isActive AND updatedAt < :cutoffDate', {
|
||||
isActive: false,
|
||||
cutoffDate
|
||||
})
|
||||
.execute();
|
||||
const deletedCount = result.affected || 0;
|
||||
this.logger.log(`Очищено ${deletedCount} неактивных подписок старше ${olderThanDays} дней`);
|
||||
return deletedCount;
|
||||
});
|
||||
}
|
||||
};
|
||||
exports.WebPushService = WebPushService;
|
||||
exports.WebPushService = WebPushService = WebPushService_1 = __decorate([
|
||||
(0, common_1.Injectable)(),
|
||||
__param(1, (0, typeorm_1.InjectRepository)(push_subscription_entity_1.PushSubscription)),
|
||||
__metadata("design:paramtypes", [config_1.ConfigService,
|
||||
typeorm_2.Repository])
|
||||
], WebPushService);
|
||||
//# sourceMappingURL=web-push.service.js.map
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,24 @@
|
||||
import { BaseWorkflowPayload } from '@coopenomics/notifications';
|
||||
export interface NovuConfig {
|
||||
apiKey: string;
|
||||
apiUrl: string;
|
||||
}
|
||||
export interface NovuTriggerRequest<T extends BaseWorkflowPayload> {
|
||||
name: string;
|
||||
to: {
|
||||
subscriberId: string;
|
||||
email?: string;
|
||||
};
|
||||
payload: T;
|
||||
actor?: {
|
||||
subscriberId: string;
|
||||
email?: string;
|
||||
};
|
||||
}
|
||||
export interface NovuApiResponse {
|
||||
data?: any;
|
||||
error?: string;
|
||||
acknowledged?: boolean;
|
||||
status?: string;
|
||||
transactionId?: string;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
//# sourceMappingURL=novu.types.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"novu.types.js","sourceRoot":"","sources":["../../src/types/novu.types.ts"],"names":[],"mappings":""}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"watch": [
|
||||
"dist",
|
||||
"../notifications/dist"
|
||||
],
|
||||
"ext": "js,json",
|
||||
"exec": "node dist/main.js"
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"name": "@coopenomics/notificator2",
|
||||
"version": "1.0.0",
|
||||
"description": "NestJS application for managing Novu workflows and push notifications",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"dev": "nodemon",
|
||||
"test:push": "ts-node src/cli/test-push.ts",
|
||||
"test:push:interactive": "ts-node src/cli/test-push.ts interactive",
|
||||
"test:push:health": "ts-node src/cli/test-push.ts health",
|
||||
"test:push:full": "ts-node src/cli/test-push.ts full --user-id",
|
||||
"test:push:help": "ts-node src/cli/test-push.ts --help"
|
||||
},
|
||||
"bin": {
|
||||
"notificator2-test": "./dist/cli/test-push.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@coopenomics/notifications": "workspace:*",
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/config": "^3.0.0",
|
||||
"@nestjs/core": "^10.0.0",
|
||||
"@nestjs/platform-express": "^10.0.0",
|
||||
"@nestjs/schedule": "^4.0.0",
|
||||
"@nestjs/typeorm": "^10.0.2",
|
||||
"@types/inquirer": "^9.0.7",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"axios": "^1.6.0",
|
||||
"chalk": "^4.1.2",
|
||||
"commander": "^12.1.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"inquirer": "^12.4.3",
|
||||
"node-fetch": "^3.3.2",
|
||||
"pg": "^8.16.3",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rxjs": "^7.8.1",
|
||||
"typeorm": "0.3.20",
|
||||
"web-push": "^3.6.7",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.0.0",
|
||||
"@nestjs/schematics": "^10.0.0",
|
||||
"@nestjs/testing": "^10.0.0",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/jest": "^29.5.2",
|
||||
"@types/node": "^20.3.1",
|
||||
"@types/pg": "^8.15.4",
|
||||
"@types/supertest": "^2.0.12",
|
||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
"eslint": "^8.42.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"jest": "^29.5.0",
|
||||
"prettier": "^3.0.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^6.3.3",
|
||||
"ts-jest": "^29.1.0",
|
||||
"ts-loader": "^9.4.3",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.1.3"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { NotificationModule } from './modules/notification.module';
|
||||
import { WebPushModule } from './modules/web-push.module';
|
||||
import { PushSubscription } from './entities/push-subscription.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: process.env.ENV_PATH || '.env',
|
||||
}),
|
||||
TypeOrmModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
type: 'postgres',
|
||||
host: configService.get<string>('DB_HOST', 'localhost'),
|
||||
port: configService.get<number>('DB_PORT', 5432),
|
||||
username: configService.get<string>('DB_USERNAME', 'postgres'),
|
||||
password: configService.get<string>('DB_PASSWORD', 'postgres'),
|
||||
database: configService.get<string>('DB_DATABASE', 'notificator'),
|
||||
entities: [PushSubscription],
|
||||
synchronize: configService.get<boolean>('DB_SYNCHRONIZE', true),
|
||||
logging: configService.get<boolean>('DB_LOGGING', false),
|
||||
ssl: configService.get<boolean>('DB_SSL', false) ? {
|
||||
rejectUnauthorized: false,
|
||||
} : false,
|
||||
}),
|
||||
}),
|
||||
ScheduleModule.forRoot(),
|
||||
NotificationModule,
|
||||
WebPushModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
Executable
+167
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env ts-node
|
||||
|
||||
import { Command } from 'commander';
|
||||
import inquirer from 'inquirer';
|
||||
import chalk from 'chalk';
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
interface ApiResponse {
|
||||
success: boolean;
|
||||
data?: any;
|
||||
error?: string;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
interface TestOptions {
|
||||
apiBase?: string;
|
||||
userId?: string;
|
||||
verbose?: boolean;
|
||||
}
|
||||
|
||||
class NotificatorTestCLI {
|
||||
private apiBase: string;
|
||||
private verbose: boolean;
|
||||
|
||||
constructor(apiBase: string = 'http://localhost:3000/api', verbose: boolean = false) {
|
||||
this.apiBase = apiBase;
|
||||
this.verbose = verbose;
|
||||
}
|
||||
|
||||
private log(message: string, type: 'info' | 'success' | 'error' | 'warn' = 'info') {
|
||||
const colors = {
|
||||
info: chalk.blue,
|
||||
success: chalk.green,
|
||||
error: chalk.red,
|
||||
warn: chalk.yellow,
|
||||
};
|
||||
console.log(colors[type](message));
|
||||
}
|
||||
|
||||
private async makeRequest(
|
||||
method: string,
|
||||
endpoint: string,
|
||||
data?: any,
|
||||
): Promise<ApiResponse> {
|
||||
const url = `${this.apiBase}${endpoint}`;
|
||||
|
||||
if (this.verbose) {
|
||||
this.log(`→ ${method} ${url}`, 'info');
|
||||
if (data) {
|
||||
console.log(chalk.gray(JSON.stringify(data, null, 2)));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const options: any = {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
|
||||
if (data) {
|
||||
options.body = JSON.stringify(data);
|
||||
}
|
||||
|
||||
const response = await fetch(url, options);
|
||||
const result = await response.json();
|
||||
|
||||
if (this.verbose) {
|
||||
this.log(`← ${response.status} ${response.statusText}`, response.ok ? 'success' : 'error');
|
||||
console.log(chalk.gray(JSON.stringify(result, null, 2)));
|
||||
}
|
||||
|
||||
return {
|
||||
success: response.ok,
|
||||
data: result,
|
||||
status: response.status,
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (this.verbose) {
|
||||
this.log(`✗ ${error.message}`, 'error');
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async testHealthCheck(): Promise<boolean> {
|
||||
console.log(chalk.blue('\n🔍 Проверка health check...'));
|
||||
const result = await this.makeRequest('GET', '/notifications/health');
|
||||
|
||||
if (result.success) {
|
||||
this.log('✅ Health check успешен', 'success');
|
||||
return true;
|
||||
} else {
|
||||
this.log(`❌ Health check неудачен: ${result.error || JSON.stringify(result.data)}`, 'error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async interactiveMode(): Promise<void> {
|
||||
console.log(chalk.green('🧪 Интерактивное тестирование Notificator2\n'));
|
||||
|
||||
const { action } = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'action',
|
||||
message: 'Выберите действие:',
|
||||
choices: [
|
||||
{ name: '🔍 Health check', value: 'health' },
|
||||
{ name: '❌ Выход', value: 'exit' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
if (action === 'exit') {
|
||||
console.log(chalk.yellow('👋 До свидания!'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'health') {
|
||||
await this.testHealthCheck();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CLI Setup
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name('notificator2-test')
|
||||
.description('CLI для тестирования Notificator2 сервиса')
|
||||
.version('1.0.0');
|
||||
|
||||
program
|
||||
.option('-a, --api-base <url>', 'База API URL', 'http://localhost:3000/api')
|
||||
.option('-v, --verbose', 'Подробный вывод');
|
||||
|
||||
program
|
||||
.command('health')
|
||||
.description('Проверить health check')
|
||||
.action(async () => {
|
||||
const options = program.opts<TestOptions>();
|
||||
const cli = new NotificatorTestCLI(options.apiBase, options.verbose);
|
||||
await cli.testHealthCheck();
|
||||
});
|
||||
|
||||
program
|
||||
.command('interactive')
|
||||
.alias('i')
|
||||
.description('Интерактивный режим')
|
||||
.action(async () => {
|
||||
const options = program.opts<TestOptions>();
|
||||
const cli = new NotificatorTestCLI(options.apiBase, options.verbose);
|
||||
await cli.interactiveMode();
|
||||
});
|
||||
|
||||
// Если команда не указана, запускаем интерактивный режим
|
||||
if (process.argv.length === 2) {
|
||||
const options = program.opts<TestOptions>();
|
||||
const cli = new NotificatorTestCLI(options.apiBase, options.verbose);
|
||||
cli.interactiveMode().catch(console.error);
|
||||
} else {
|
||||
program.parse();
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { Controller, Post, Body, Headers, Logger, BadRequestException, Get } from '@nestjs/common';
|
||||
import { NovuWebPushService, NovuWebPushPayload } from '../services/novu-webpush.service';
|
||||
import { WebPushService } from '../services/web-push.service';
|
||||
|
||||
export interface NovuWebhookPayload {
|
||||
event: string;
|
||||
data: {
|
||||
subscriber: {
|
||||
subscriberId: string;
|
||||
email?: string;
|
||||
};
|
||||
workflow: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
step: {
|
||||
type: string;
|
||||
template: any;
|
||||
};
|
||||
payload: any;
|
||||
};
|
||||
webhookId: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
@Controller('novu')
|
||||
export class NovuWebhookController {
|
||||
private readonly logger = new Logger(NovuWebhookController.name);
|
||||
|
||||
constructor(
|
||||
private readonly novuWebPushService: NovuWebPushService,
|
||||
private readonly webPushService: WebPushService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Webhook для обработки push уведомлений от NOVU
|
||||
*/
|
||||
@Post('webhook/push')
|
||||
async handlePushWebhook(
|
||||
@Body() payload: NovuWebhookPayload,
|
||||
@Headers('x-novu-signature') signature?: string,
|
||||
) {
|
||||
try {
|
||||
this.logger.log(`Получен NOVU webhook: ${payload.event} для пользователя ${payload.data.subscriber.subscriberId}`);
|
||||
|
||||
// Валидация webhook'а (в production следует проверять подпись)
|
||||
if (!this.isValidWebhook(payload, signature)) {
|
||||
throw new BadRequestException('Невалидный webhook');
|
||||
}
|
||||
|
||||
// Обрабатываем только push уведомления
|
||||
if (payload.data.step.type !== 'push') {
|
||||
this.logger.warn(`Пропускаем событие ${payload.event} с типом ${payload.data.step.type}`);
|
||||
return { success: true, message: 'Событие пропущено (не push)' };
|
||||
}
|
||||
|
||||
const userId = payload.data.subscriber.subscriberId;
|
||||
const workflowId = payload.data.workflow.id;
|
||||
|
||||
// Извлекаем данные для push уведомления из template
|
||||
const pushPayload: NovuWebPushPayload = {
|
||||
title: this.extractTemplateValue(payload.data.step.template.content?.title, payload.data.payload),
|
||||
body: this.extractTemplateValue(payload.data.step.template.content?.body, payload.data.payload),
|
||||
icon: this.extractTemplateValue(payload.data.step.template.content?.icon, payload.data.payload),
|
||||
badge: this.extractTemplateValue(payload.data.step.template.content?.badge, payload.data.payload),
|
||||
image: this.extractTemplateValue(payload.data.step.template.content?.image, payload.data.payload),
|
||||
url: this.extractTemplateValue(payload.data.step.template.content?.data?.url, payload.data.payload),
|
||||
tag: this.extractTemplateValue(payload.data.step.template.content?.tag, payload.data.payload),
|
||||
requireInteraction: payload.data.step.template.content?.requireInteraction,
|
||||
silent: payload.data.step.template.content?.silent,
|
||||
data: {
|
||||
...payload.data.payload,
|
||||
workflowId,
|
||||
webhookId: payload.webhookId,
|
||||
},
|
||||
};
|
||||
|
||||
await this.novuWebPushService.sendWebPushFromNovu(userId, pushPayload, workflowId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Push уведомление отправлено пользователю ${userId}`,
|
||||
workflowId,
|
||||
userId,
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.error('Ошибка обработки NOVU webhook:', error.message);
|
||||
throw new BadRequestException(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoint для регистрации push подписки через NOVU
|
||||
*/
|
||||
@Post('subscribe')
|
||||
async subscribeViaNovu(@Body() body: {
|
||||
userId: string;
|
||||
subscription: any;
|
||||
email?: string;
|
||||
}) {
|
||||
try {
|
||||
const { userId, subscription, email } = body;
|
||||
|
||||
// Сохраняем подписку
|
||||
await this.webPushService.saveSubscription(userId, subscription);
|
||||
|
||||
// Синхронизируем с NOVU
|
||||
await this.novuWebPushService.syncUserSubscriptionsWithNovu(userId);
|
||||
|
||||
this.logger.log(`Пользователь ${userId} подписался через NOVU интеграцию`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Подписка создана и синхронизирована с NOVU',
|
||||
userId,
|
||||
email,
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.error('Ошибка подписки через NOVU:', error.message);
|
||||
throw new BadRequestException(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить конфигурацию web-push интеграции для NOVU
|
||||
*/
|
||||
@Get('integration/config')
|
||||
getIntegrationConfig() {
|
||||
return this.novuWebPushService.getWebPushIntegrationConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверить работу webhook'а
|
||||
*/
|
||||
@Post('test')
|
||||
async testWebhook(@Body() body: {
|
||||
userId: string;
|
||||
title?: string;
|
||||
body?: string;
|
||||
}) {
|
||||
const { userId, title = '🧪 Тест webhook', body: msgBody = 'Тестовое уведомление через webhook' } = body;
|
||||
|
||||
const testPayload: NovuWebPushPayload = {
|
||||
title,
|
||||
body: msgBody,
|
||||
icon: '/icons/test-icon.png',
|
||||
badge: '/icons/badge.png',
|
||||
url: '/dashboard',
|
||||
tag: 'webhook-test',
|
||||
data: {
|
||||
type: 'webhook-test',
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await this.novuWebPushService.sendWebPushFromNovu(userId, testPayload, 'test-webhook');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Тестовое уведомление отправлено пользователю ${userId}`,
|
||||
userId,
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.error('Ошибка теста webhook:', error.message);
|
||||
throw new BadRequestException(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Валидация webhook'а (в production следует проверять HMAC подпись)
|
||||
*/
|
||||
private isValidWebhook(payload: NovuWebhookPayload, signature?: string): boolean {
|
||||
// TODO: Реализовать проверку подписи webhook'а
|
||||
// Пример:
|
||||
// const expectedSignature = createHmac('sha256', process.env.NOVU_WEBHOOK_SECRET)
|
||||
// .update(JSON.stringify(payload))
|
||||
// .digest('hex');
|
||||
// return signature === expectedSignature;
|
||||
|
||||
// Пока просто проверяем базовую структуру
|
||||
return !!(
|
||||
payload &&
|
||||
payload.data &&
|
||||
payload.data.subscriber &&
|
||||
payload.data.subscriber.subscriberId &&
|
||||
payload.data.workflow &&
|
||||
payload.data.step
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлечение значения из template с подстановкой переменных
|
||||
*/
|
||||
private extractTemplateValue(template: string | undefined, payload: any): string | undefined {
|
||||
if (!template || typeof template !== 'string') {
|
||||
return template;
|
||||
}
|
||||
|
||||
// Простая замена переменных формата {{variable}}
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (match, key) => {
|
||||
return payload[key] || match;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Delete,
|
||||
Get,
|
||||
Body,
|
||||
Param,
|
||||
Headers,
|
||||
BadRequestException,
|
||||
Logger,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { WebPushService, PushSubscriptionData, NotificationPayload } from '../services/web-push.service';
|
||||
|
||||
export interface SubscribePushDto {
|
||||
userId: string;
|
||||
subscription: PushSubscriptionData;
|
||||
}
|
||||
|
||||
export interface SendNotificationDto {
|
||||
userId?: string;
|
||||
title: string;
|
||||
body: string;
|
||||
icon?: string;
|
||||
badge?: string;
|
||||
image?: string;
|
||||
data?: any;
|
||||
actions?: Array<{
|
||||
action: string;
|
||||
title: string;
|
||||
icon?: string;
|
||||
}>;
|
||||
tag?: string;
|
||||
requireInteraction?: boolean;
|
||||
silent?: boolean;
|
||||
vibrate?: number[];
|
||||
}
|
||||
|
||||
@Controller('push')
|
||||
export class PushController {
|
||||
private readonly logger = new Logger(PushController.name);
|
||||
|
||||
constructor(private readonly webPushService: WebPushService) {}
|
||||
|
||||
/**
|
||||
* Получить публичный VAPID ключ для клиента
|
||||
*/
|
||||
@Get('vapid-public-key')
|
||||
getVapidPublicKey() {
|
||||
const publicKey = this.webPushService.getVapidPublicKey();
|
||||
|
||||
if (!publicKey) {
|
||||
throw new BadRequestException('VAPID ключи не настроены. Проверьте конфигурацию сервера.');
|
||||
}
|
||||
|
||||
return {
|
||||
publicKey,
|
||||
applicationServerKey: publicKey, // Alias для совместимости
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Подписать пользователя на push уведомления
|
||||
*/
|
||||
@Post('subscribe')
|
||||
async subscribe(
|
||||
@Body() dto: SubscribePushDto,
|
||||
@Headers('user-agent') userAgent?: string,
|
||||
) {
|
||||
try {
|
||||
this.validateSubscription(dto.subscription);
|
||||
|
||||
const subscription = await this.webPushService.saveSubscription(
|
||||
dto.userId,
|
||||
dto.subscription,
|
||||
userAgent,
|
||||
);
|
||||
|
||||
this.logger.log(`Пользователь ${dto.userId} подписался на push уведомления`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Подписка на push уведомления успешно создана',
|
||||
subscriptionId: subscription.id,
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.error('Ошибка при подписке на push уведомления:', error.message);
|
||||
throw new BadRequestException(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Отписать пользователя от push уведомлений
|
||||
*/
|
||||
@Delete('unsubscribe')
|
||||
async unsubscribe(@Body() body: { endpoint: string }) {
|
||||
try {
|
||||
if (!body.endpoint) {
|
||||
throw new BadRequestException('Endpoint обязателен для отписки');
|
||||
}
|
||||
|
||||
await this.webPushService.removeSubscription(body.endpoint);
|
||||
|
||||
this.logger.log(`Подписка отключена: ${body.endpoint.substring(0, 50)}...`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Подписка успешно отключена',
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.error('Ошибка при отписке от push уведомлений:', error.message);
|
||||
throw new BadRequestException(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить подписки пользователя
|
||||
*/
|
||||
@Get('subscriptions/:userId')
|
||||
async getUserSubscriptions(@Param('userId') userId: string) {
|
||||
const subscriptions = await this.webPushService.getUserSubscriptions(userId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: subscriptions.map(sub => ({
|
||||
id: sub.id,
|
||||
endpoint: sub.endpoint.substring(0, 50) + '...', // Скрываем полный endpoint в целях безопасности
|
||||
userAgent: sub.userAgent,
|
||||
createdAt: sub.createdAt,
|
||||
isActive: sub.isActive,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправить push уведомление пользователю
|
||||
*/
|
||||
@Post('send')
|
||||
async sendNotification(@Body() dto: SendNotificationDto) {
|
||||
try {
|
||||
this.validateNotificationPayload(dto);
|
||||
|
||||
const payload: NotificationPayload = {
|
||||
title: dto.title,
|
||||
body: dto.body,
|
||||
icon: dto.icon,
|
||||
badge: dto.badge,
|
||||
image: dto.image,
|
||||
data: dto.data,
|
||||
actions: dto.actions,
|
||||
tag: dto.tag,
|
||||
requireInteraction: dto.requireInteraction,
|
||||
silent: dto.silent,
|
||||
timestamp: Date.now(),
|
||||
vibrate: dto.vibrate,
|
||||
};
|
||||
|
||||
if (dto.userId) {
|
||||
await this.webPushService.sendNotificationToUser(dto.userId, payload);
|
||||
this.logger.log(`Push уведомление отправлено пользователю ${dto.userId}`);
|
||||
} else {
|
||||
await this.webPushService.sendNotificationToAll(payload);
|
||||
this.logger.log('Push уведомление отправлено всем пользователям');
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: dto.userId
|
||||
? `Уведомление отправлено пользователю ${dto.userId}`
|
||||
: 'Уведомление отправлено всем пользователям',
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.error('Ошибка при отправке push уведомления:', error.message);
|
||||
throw new BadRequestException(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправить тестовое push уведомление
|
||||
*/
|
||||
@Post('test/:userId')
|
||||
async sendTestNotification(@Param('userId') userId: string) {
|
||||
const payload: NotificationPayload = {
|
||||
title: '🔔 Тестовое уведомление',
|
||||
body: 'Ваши push уведомления работают корректно!',
|
||||
icon: '/icons/notification-icon.png',
|
||||
badge: '/icons/badge-icon.png',
|
||||
tag: 'test-notification',
|
||||
data: {
|
||||
type: 'test',
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
action: 'view',
|
||||
title: 'Открыть приложение',
|
||||
},
|
||||
{
|
||||
action: 'dismiss',
|
||||
title: 'Закрыть',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await this.webPushService.sendNotificationToUser(userId, payload);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Тестовое уведомление отправлено пользователю ${userId}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить статистику push подписок
|
||||
*/
|
||||
@Get('stats')
|
||||
async getStats() {
|
||||
const stats = await this.webPushService.getSubscriptionStats();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: stats,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Очистить неактивные подписки
|
||||
*/
|
||||
@Delete('cleanup')
|
||||
async cleanup(@Query('days') days?: string) {
|
||||
const olderThanDays = days ? parseInt(days) : 30;
|
||||
|
||||
if (isNaN(olderThanDays) || olderThanDays < 1) {
|
||||
throw new BadRequestException('Параметр days должен быть положительным числом');
|
||||
}
|
||||
|
||||
const deletedCount = await this.webPushService.cleanupInactiveSubscriptions(olderThanDays);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Очищено ${deletedCount} неактивных подписок старше ${olderThanDays} дней`,
|
||||
deletedCount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Валидация данных подписки
|
||||
*/
|
||||
private validateSubscription(subscription: PushSubscriptionData) {
|
||||
if (!subscription.endpoint) {
|
||||
throw new Error('Endpoint подписки обязателен');
|
||||
}
|
||||
|
||||
if (!subscription.keys?.p256dh || !subscription.keys?.auth) {
|
||||
throw new Error('Ключи p256dh и auth обязательны');
|
||||
}
|
||||
|
||||
// Проверяем, что endpoint выглядит как валидный URL
|
||||
try {
|
||||
new URL(subscription.endpoint);
|
||||
} catch {
|
||||
throw new Error('Невалидный endpoint URL');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Валидация данных уведомления
|
||||
*/
|
||||
private validateNotificationPayload(dto: SendNotificationDto) {
|
||||
if (!dto.title?.trim()) {
|
||||
throw new Error('Заголовок уведомления обязателен');
|
||||
}
|
||||
|
||||
if (!dto.body?.trim()) {
|
||||
throw new Error('Текст уведомления обязателен');
|
||||
}
|
||||
|
||||
if (dto.title.length > 100) {
|
||||
throw new Error('Заголовок уведомления не должен превышать 100 символов');
|
||||
}
|
||||
|
||||
if (dto.body.length > 500) {
|
||||
throw new Error('Текст уведомления не должен превышать 500 символов');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm';
|
||||
|
||||
@Entity('push_subscriptions')
|
||||
@Index(['userId'], { unique: false })
|
||||
@Index(['endpoint'], { unique: true })
|
||||
export class PushSubscription {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column('varchar', { length: 255 })
|
||||
userId: string;
|
||||
|
||||
@Column('text')
|
||||
endpoint: string;
|
||||
|
||||
@Column('varchar', { length: 255 })
|
||||
p256dhKey: string;
|
||||
|
||||
@Column('varchar', { length: 255 })
|
||||
authKey: string;
|
||||
|
||||
@Column('varchar', { length: 100, nullable: true })
|
||||
userAgent: string;
|
||||
|
||||
@Column('boolean', { default: true })
|
||||
isActive: boolean;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
|
||||
// Удобный метод для получения подписки в формате web-push
|
||||
toWebPushSubscription() {
|
||||
return {
|
||||
endpoint: this.endpoint,
|
||||
keys: {
|
||||
p256dh: this.p256dhKey,
|
||||
auth: this.authKey,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './services/novu.service';
|
||||
export * from './modules/notification.module';
|
||||
export * from './modules/notification.controller';
|
||||
export * from './types/novu.types';
|
||||
export * from './app.module';
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'reflect-metadata';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
async function bootstrap() {
|
||||
const logger = new Logger('Bootstrap');
|
||||
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
// Настройка CORS
|
||||
app.enableCors({
|
||||
origin: process.env.CORS_ORIGIN || '*',
|
||||
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
// Глобальный префикс API
|
||||
app.setGlobalPrefix('api');
|
||||
|
||||
const port = process.env.PORT || 3000;
|
||||
await app.listen(port);
|
||||
|
||||
logger.log(`🚀 Приложение запущено на http://localhost:${port}`);
|
||||
logger.log(`📡 API доступно на http://localhost:${port}/api`);
|
||||
}
|
||||
|
||||
bootstrap().catch((error) => {
|
||||
console.error('Ошибка запуска приложения:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Controller, Get, Logger } from '@nestjs/common';
|
||||
import { NovuService } from '../services/novu.service';
|
||||
|
||||
@Controller('notifications')
|
||||
export class NotificationController {
|
||||
private readonly logger = new Logger(NotificationController.name);
|
||||
|
||||
constructor(private readonly novuService: NovuService) {}
|
||||
|
||||
@Get('health')
|
||||
async getHealth() {
|
||||
const isHealthy = await this.novuService.getHealth();
|
||||
return {
|
||||
status: isHealthy ? 'ok' : 'error',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'notificator'
|
||||
};
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user