чуть не забыл

This commit is contained in:
Alex Ant
2025-07-03 18:03:31 +05:00
parent 9da2fcfbca
commit b9ae66e557
108 changed files with 6365 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
---
description:
globs:
alwaysApply: true
---
ВАЖНО!!! Мы работаем в МОНО-репозитории pnpm, любая установка пакетов производится ЧЕРЕЗ ФИЛЬТР компонента в корне: pnpm add glob --filter notificator2
+316
View File
@@ -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
View File
@@ -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 типы
+14
View File
@@ -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=
+19
View File
@@ -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 не передается на фронтенд
@@ -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'] || '';
}
}
+177
View File
@@ -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/`.
+37
View File
@@ -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
View File
@@ -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;
}
+91
View File
@@ -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
View File
@@ -0,0 +1,4 @@
export * from './types';
export * from './base/defaults';
export { WorkflowBuilder } from './base/workflow-builder';
export * from './workflows';
+25
View File
@@ -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
View File
@@ -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;
+2
View File
@@ -0,0 +1,2 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
+4
View File
@@ -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>>;
+31
View File
@@ -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();
+26
View File
@@ -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;
}
}
+9
View File
@@ -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();
+16
View File
@@ -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"]
}
+121
View File
@@ -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
+1
View File
@@ -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 среде и может масштабироваться в зависимости от нагрузки.
+474
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
export declare class AppModule {
}
+51
View File
@@ -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
+1
View File
@@ -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"}
+2
View File
@@ -0,0 +1,2 @@
#!/usr/bin/env ts-node
export {};
+147
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1 @@
import 'reflect-metadata';
+36
View File
@@ -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
View File
@@ -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 {
}
+34
View File
@@ -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":";;;;;;;;;;;;;;;;;;;;;;AAAA,2CAAoD;AACpD,+CAAwD;AACxD,yDAAoD;AAG7C,IAAM,cAAc,sBAApB,MAAM,cAAc;IAGzB,YAA6B,cAA8B;QAA9B,mBAAc,GAAd,cAAc,CAAgB;QAF1C,WAAM,GAAG,IAAI,eAAM,CAAC,gBAAc,CAAC,IAAI,CAAC,CAAC;IAEI,CAAC;IAMzD,4BAA4B;;YAChC,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,2DAA2D,CAAC,CAAC;YAE7E,IAAI,CAAC;gBACH,MAAM,YAAY,GAAG,MAAM,IAAI,CAAC,cAAc,CAAC,4BAA4B,CAAC,EAAE,CAAC,CAAC;gBAChF,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,6CAA6C,YAAY,uBAAuB,CAAC,CAAC;YACpG,CAAC;YAAC,OAAO,KAAU,EAAE,CAAC;gBACpB,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,gCAAgC,EAAE,KAAK,CAAC,OAAO,CAAC,CAAC;YACrE,CAAC;QACH,CAAC;KAAA;IAMK,cAAc;;YAClB,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,oDAAoD,CAAC,CAAC;YAEtE,IAAI,CAAC;gBACH,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,cAAc,CAAC,oBAAoB,EAAE,CAAC;gBAE/D,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,2CAA2C,CAAC,CAAC;gBAC7D,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,sBAAsB,KAAK,CAAC,KAAK,EAAE,CAAC,CAAC;gBACrD,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,gBAAgB,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC;gBAChD,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,kBAAkB,KAAK,CAAC,QAAQ,EAAE,CAAC,CAAC;gBACpD,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,gCAAgC,KAAK,CAAC,WAAW,EAAE,CAAC,CAAC;gBAErE,MAAM,gBAAgB,GAAG,KAAK,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,MAAM,GAAG,KAAK,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC;gBACjG,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,iCAAiC,gBAAgB,GAAG,CAAC,CAAC;YACxE,CAAC;YAAC,OAAO,KAAU,EAAE,CAAC;gBACpB,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,8BAA8B,EAAE,KAAK,CAAC,OAAO,CAAC,CAAC;YACnE,CAAC;QACH,CAAC;KAAA;IAKK,aAAa,CAAC,aAAqB;;YACvC,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,yCAAyC,aAAa,UAAU,CAAC,CAAC;YAElF,IAAI,CAAC;gBACH,MAAM,YAAY,GAAG,MAAM,IAAI,CAAC,cAAc,CAAC,4BAA4B,CAAC,aAAa,CAAC,CAAC;gBAC3F,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,qCAAqC,YAAY,uBAAuB,CAAC,CAAC;gBAC1F,OAAO,YAAY,CAAC;YACtB,CAAC;YAAC,OAAO,KAAU,EAAE,CAAC;gBACpB,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,wBAAwB,EAAE,KAAK,CAAC,OAAO,CAAC,CAAC;gBAC3D,MAAM,KAAK,CAAC;YACd,CAAC;QACH,CAAC;KAAA;CACF,CAAA;AA1DY,wCAAc;AASnB;IADL,IAAA,eAAI,EAAC,yBAAc,CAAC,gBAAgB,CAAC;;;;kEAUrC;AAMK;IADL,IAAA,eAAI,EAAC,YAAY,CAAC;;;;oDAkBlB;yBAzCU,cAAc;IAD1B,IAAA,mBAAU,GAAE;qCAIkC,iCAAc;GAHhD,cAAc,CA0D1B"}
@@ -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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"}
+22
View File
@@ -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>;
}
+181
View File
@@ -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
+24
View File
@@ -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;
}
+3
View File
@@ -0,0 +1,3 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
//# sourceMappingURL=novu.types.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"novu.types.js","sourceRoot":"","sources":["../../src/types/novu.types.ts"],"names":[],"mappings":""}
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"watch": [
"dist",
"../notifications/dist"
],
"ext": "js,json",
"exec": "node dist/main.js"
}
+92
View File
@@ -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"
}
}
+38
View File
@@ -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 {}
+167
View File
@@ -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,
},
};
}
}
+5
View File
@@ -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';
+31
View File
@@ -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