pwa push notifications

This commit is contained in:
Alex Ant
2025-07-10 16:44:42 +05:00
parent 046146c4d8
commit f588312ad3
23 changed files with 1552 additions and 4 deletions
@@ -0,0 +1,82 @@
import { DataSource } from 'typeorm';
import config from '~/config/config';
export default {
name: 'Создание таблицы веб-пуш подписок',
validUntil: new Date(), // Текущая дата, миграция больше не будет применяться
async up() {
console.log('Выполнение миграции: Создание таблицы веб-пуш подписок');
try {
// Создаем подключение к PostgreSQL
const dataSource = new DataSource({
type: 'postgres',
host: config.postgres.host,
port: Number(config.postgres.port),
username: config.postgres.username,
password: config.postgres.password,
database: config.postgres.database,
});
await dataSource.initialize();
// Создаем таблицу web_push_subscriptions
await dataSource.query(`
CREATE TABLE IF NOT EXISTS web_push_subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"userId" VARCHAR(255) NOT NULL,
endpoint TEXT NOT NULL,
"p256dhKey" VARCHAR(255) NOT NULL,
"authKey" VARCHAR(255) NOT NULL,
"userAgent" VARCHAR(100),
"isActive" BOOLEAN DEFAULT true,
"createdAt" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`);
// Создаем индексы
await dataSource.query(`
CREATE INDEX IF NOT EXISTS idx_web_push_subscriptions_userid
ON web_push_subscriptions ("userId");
`);
await dataSource.query(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_web_push_subscriptions_endpoint
ON web_push_subscriptions (endpoint);
`);
// Создаем триггер для автоматического обновления updatedAt
await dataSource.query(`
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW."updatedAt" = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
`);
await dataSource.query(`
DROP TRIGGER IF EXISTS update_web_push_subscriptions_updated_at
ON web_push_subscriptions;
`);
await dataSource.query(`
CREATE TRIGGER update_web_push_subscriptions_updated_at
BEFORE UPDATE ON web_push_subscriptions
FOR EACH ROW
EXECUTE FUNCTION update_updated_at();
`);
await dataSource.destroy();
console.log('Миграция завершена: Таблица web_push_subscriptions создана');
return true;
} catch (error) {
console.error('Ошибка выполнения миграции:', error);
return false;
}
},
};
@@ -0,0 +1,97 @@
import type { WebPushSubscriptionDomainInterface } from '../interfaces/web-push-subscription-domain.interface';
/**
* Доменная сущность для веб-пуш подписки
* Содержит бизнес-логику работы с подписками
*/
export class WebPushSubscriptionDomainEntity implements WebPushSubscriptionDomainInterface {
public readonly id: string;
public readonly userId: string;
public readonly endpoint: string;
public readonly p256dhKey: string;
public readonly authKey: string;
public readonly userAgent?: string;
public readonly isActive: boolean;
public readonly createdAt: Date;
public readonly updatedAt: Date;
constructor(data: WebPushSubscriptionDomainInterface) {
this.id = data.id;
this.userId = data.userId;
this.endpoint = data.endpoint;
this.p256dhKey = data.p256dhKey;
this.authKey = data.authKey;
this.userAgent = data.userAgent;
this.isActive = data.isActive;
this.createdAt = data.createdAt;
this.updatedAt = data.updatedAt;
}
/**
* Преобразует подписку в формат для отправки через web-push библиотеку
* @returns Объект подписки в формате web-push
*/
toWebPushSubscription(): { endpoint: string; keys: { p256dh: string; auth: string } } {
return {
endpoint: this.endpoint,
keys: {
p256dh: this.p256dhKey,
auth: this.authKey,
},
};
}
/**
* Проверяет, активна ли подписка
* @returns true, если подписка активна
*/
isSubscriptionActive(): boolean {
return this.isActive;
}
/**
* Проверяет, принадлежит ли подписка указанному пользователю
* @param userId ID пользователя
* @returns true, если подписка принадлежит пользователю
*/
belongsToUser(userId: string): boolean {
return this.userId === userId;
}
/**
* Получает сокращенный endpoint для логирования
* @returns Сокращенный endpoint
*/
getShortEndpoint(): string {
return this.endpoint.substring(0, 50) + '...';
}
/**
* Создает копию сущности с обновленными данными
* @param updates Обновления для сущности
* @returns Новая сущность с обновленными данными
*/
update(updates: Partial<WebPushSubscriptionDomainInterface>): WebPushSubscriptionDomainEntity {
return new WebPushSubscriptionDomainEntity({
...this,
...updates,
updatedAt: new Date(),
});
}
/**
* Деактивирует подписку
* @returns Новая сущность с деактивированной подпиской
*/
deactivate(): WebPushSubscriptionDomainEntity {
return this.update({ isActive: false });
}
/**
* Активирует подписку
* @returns Новая сущность с активированной подпиской
*/
activate(): WebPushSubscriptionDomainEntity {
return this.update({ isActive: true });
}
}
@@ -0,0 +1,208 @@
import { Injectable, Logger, Inject } from '@nestjs/common';
import { WebPushSubscriptionDomainEntity } from '../entities/web-push-subscription-domain.entity';
import { WebPushSubscriptionPort, WEB_PUSH_SUBSCRIPTION_PORT } from '../interfaces/web-push-subscription.port';
import type {
CreateSubscriptionInputDomainInterface,
SubscriptionStatsDomainInterface,
NotificationPayloadDomainInterface,
} from '../interfaces/web-push-subscription-domain.interface';
/**
* Доменный интерактор для управления веб-пуш подписками
* Содержит основную бизнес-логику работы с подписками
*/
@Injectable()
export class WebPushSubscriptionDomainInteractor {
private readonly logger = new Logger(WebPushSubscriptionDomainInteractor.name);
constructor(
@Inject(WEB_PUSH_SUBSCRIPTION_PORT)
private readonly webPushSubscriptionPort: WebPushSubscriptionPort
) {}
/**
* Создать или обновить веб-пуш подписку
* @param data Входные данные для создания подписки
* @returns Promise<WebPushSubscriptionDomainEntity>
*/
async createOrUpdateSubscription(data: CreateSubscriptionInputDomainInterface): Promise<WebPushSubscriptionDomainEntity> {
this.logger.log(`Создание/обновление подписки для пользователя ${data.userId}`);
// Валидация входных данных
this.validateSubscriptionData(data);
// Проверяем, существует ли уже подписка с таким endpoint
const existingSubscription = await this.webPushSubscriptionPort.findByEndpoint(data.subscription.endpoint);
if (existingSubscription) {
// Обновляем существующую подписку
const updatedSubscription = await this.webPushSubscriptionPort.updateSubscription(data.subscription.endpoint, {
userId: data.userId,
p256dhKey: data.subscription.keys.p256dh,
authKey: data.subscription.keys.auth,
userAgent: data.userAgent,
});
this.logger.log(`Подписка обновлена: ${updatedSubscription.id}`);
return new WebPushSubscriptionDomainEntity(updatedSubscription);
}
// Создаем новую подписку
const newSubscription = await this.webPushSubscriptionPort.saveSubscription({
userId: data.userId,
endpoint: data.subscription.endpoint,
p256dhKey: data.subscription.keys.p256dh,
authKey: data.subscription.keys.auth,
userAgent: data.userAgent,
});
this.logger.log(`Новая подписка создана: ${newSubscription.id}`);
return new WebPushSubscriptionDomainEntity(newSubscription);
}
/**
* Получить все активные подписки пользователя
* @param userId ID пользователя
* @returns Promise<WebPushSubscriptionDomainEntity[]>
*/
async getUserSubscriptions(userId: string): Promise<WebPushSubscriptionDomainEntity[]> {
this.logger.debug(`Получение подписок для пользователя ${userId}`);
const subscriptions = await this.webPushSubscriptionPort.getUserSubscriptions(userId);
return subscriptions.map((subscription) => new WebPushSubscriptionDomainEntity(subscription));
}
/**
* Получить все активные подписки
* @returns Promise<WebPushSubscriptionDomainEntity[]>
*/
async getAllActiveSubscriptions(): Promise<WebPushSubscriptionDomainEntity[]> {
this.logger.debug('Получение всех активных подписок');
const subscriptions = await this.webPushSubscriptionPort.getAllActiveSubscriptions();
return subscriptions.map((subscription) => new WebPushSubscriptionDomainEntity(subscription));
}
/**
* Деактивировать подписку по endpoint
* @param endpoint Endpoint подписки
* @returns Promise<void>
*/
async deactivateSubscription(endpoint: string): Promise<void> {
this.logger.log(`Деактивация подписки с endpoint: ${endpoint.substring(0, 50)}...`);
await this.webPushSubscriptionPort.deactivateSubscription(endpoint);
}
/**
* Деактивировать подписку по ID
* @param subscriptionId ID подписки
* @returns Promise<void>
*/
async deactivateSubscriptionById(subscriptionId: string): Promise<void> {
this.logger.log(`Деактивация подписки с ID: ${subscriptionId}`);
await this.webPushSubscriptionPort.deactivateSubscriptionById(subscriptionId);
}
/**
* Очистить неактивные подписки
* @param olderThanDays Количество дней
* @returns Promise<number> Количество удаленных подписок
*/
async cleanupInactiveSubscriptions(olderThanDays: number = 30): Promise<number> {
this.logger.log(`Очистка неактивных подписок старше ${olderThanDays} дней`);
const deletedCount = await this.webPushSubscriptionPort.cleanupInactiveSubscriptions(olderThanDays);
this.logger.log(`Очищено ${deletedCount} неактивных подписок`);
return deletedCount;
}
/**
* Получить статистику подписок
* @returns Promise<SubscriptionStatsDomainInterface>
*/
async getSubscriptionStats(): Promise<SubscriptionStatsDomainInterface> {
this.logger.debug('Получение статистики подписок');
return await this.webPushSubscriptionPort.getSubscriptionStats();
}
/**
* Отправить уведомление пользователю
* Примечание: Пока не реализована отправка, только заглушка для интерфейса
* @param userId ID пользователя
* @param payload Данные уведомления
* @returns Promise<void>
*/
async sendNotificationToUser(userId: string, payload: NotificationPayloadDomainInterface): Promise<void> {
this.logger.log(`Отправка уведомления пользователю ${userId}: ${payload.title}`);
// TODO: Реализовать отправку уведомлений через интеграцию с NOVU
// Пока просто логируем, что уведомление должно быть отправлено
const subscriptions = await this.getUserSubscriptions(userId);
if (subscriptions.length === 0) {
this.logger.warn(`Нет активных подписок для пользователя ${userId}`);
return;
}
this.logger.log(`Найдено ${subscriptions.length} активных подписок для пользователя ${userId}`);
// Здесь будет интеграция с NOVU для отправки уведомлений
}
/**
* Отправить уведомление всем пользователям
* Примечание: Пока не реализована отправка, только заглушка для интерфейса
* @param payload Данные уведомления
* @returns Promise<void>
*/
async sendNotificationToAll(payload: NotificationPayloadDomainInterface): Promise<void> {
this.logger.log(`Отправка уведомления всем пользователям: ${payload.title}`);
// TODO: Реализовать отправку уведомлений через интеграцию с NOVU
// Пока просто логируем, что уведомление должно быть отправлено
const subscriptions = await this.getAllActiveSubscriptions();
if (subscriptions.length === 0) {
this.logger.warn('Нет активных подписок');
return;
}
this.logger.log(`Найдено ${subscriptions.length} активных подписок для отправки`);
// Здесь будет интеграция с NOVU для отправки уведомлений
}
/**
* Валидация данных подписки
* @param data Данные подписки
*/
private validateSubscriptionData(data: CreateSubscriptionInputDomainInterface): void {
if (!data.userId || typeof data.userId !== 'string') {
throw new Error('userId обязателен и должен быть строкой');
}
if (!data.subscription || typeof data.subscription !== 'object') {
throw new Error('subscription обязателен и должен быть объектом');
}
if (!data.subscription.endpoint || typeof data.subscription.endpoint !== 'string') {
throw new Error('endpoint обязателен и должен быть строкой');
}
if (!data.subscription.keys || typeof data.subscription.keys !== 'object') {
throw new Error('keys обязательны и должны быть объектом');
}
if (!data.subscription.keys.p256dh || typeof data.subscription.keys.p256dh !== 'string') {
throw new Error('p256dh ключ обязателен и должен быть строкой');
}
if (!data.subscription.keys.auth || typeof data.subscription.keys.auth !== 'string') {
throw new Error('auth ключ обязателен и должен быть строкой');
}
}
}
@@ -0,0 +1,83 @@
/**
* Доменный интерфейс для данных веб-пуш подписки
*/
export interface WebPushSubscriptionDomainInterface {
id: string;
userId: string;
endpoint: string;
p256dhKey: string;
authKey: string;
userAgent?: string;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
}
/**
* Доменный интерфейс для создания веб-пуш подписки
*/
export interface CreateWebPushSubscriptionDomainInterface {
userId: string;
endpoint: string;
p256dhKey: string;
authKey: string;
userAgent?: string;
}
/**
* Доменный интерфейс для данных подписки из клиента
*/
export interface WebPushSubscriptionDataDomainInterface {
endpoint: string;
keys: {
p256dh: string;
auth: string;
};
}
/**
* Доменный интерфейс для полезной нагрузки уведомления
*/
export interface NotificationPayloadDomainInterface {
title: string;
body: string;
icon?: string;
badge?: string;
image?: string;
url?: string;
tag?: string;
requireInteraction?: boolean;
silent?: boolean;
actions?: NotificationActionDomainInterface[];
data?: Record<string, any>;
vibrate?: number[];
timestamp?: number;
}
/**
* Доменный интерфейс для действий уведомления
*/
export interface NotificationActionDomainInterface {
action: string;
title: string;
icon?: string;
}
/**
* Доменный интерфейс для статистики подписок
*/
export interface SubscriptionStatsDomainInterface {
total: number;
active: number;
inactive: number;
uniqueUsers: number;
}
/**
* Доменный интерфейс для входных данных создания подписки
*/
export interface CreateSubscriptionInputDomainInterface {
userId: string;
subscription: WebPushSubscriptionDataDomainInterface;
userAgent?: string;
}
@@ -0,0 +1,81 @@
import type {
WebPushSubscriptionDomainInterface,
CreateWebPushSubscriptionDomainInterface,
SubscriptionStatsDomainInterface,
} from './web-push-subscription-domain.interface';
/**
* Порт для работы с веб-пуш подписками
* Определяет интерфейс для взаимодействия с репозиторием подписок
*/
export interface WebPushSubscriptionPort {
/**
* Сохранить веб-пуш подписку
* @param data Данные подписки
* @returns Promise<WebPushSubscriptionDomainInterface>
*/
saveSubscription(data: CreateWebPushSubscriptionDomainInterface): Promise<WebPushSubscriptionDomainInterface>;
/**
* Найти подписку по endpoint
* @param endpoint Endpoint подписки
* @returns Promise<WebPushSubscriptionDomainInterface | null>
*/
findByEndpoint(endpoint: string): Promise<WebPushSubscriptionDomainInterface | null>;
/**
* Получить все активные подписки пользователя
* @param userId ID пользователя
* @returns Promise<WebPushSubscriptionDomainInterface[]>
*/
getUserSubscriptions(userId: string): Promise<WebPushSubscriptionDomainInterface[]>;
/**
* Получить все активные подписки
* @returns Promise<WebPushSubscriptionDomainInterface[]>
*/
getAllActiveSubscriptions(): Promise<WebPushSubscriptionDomainInterface[]>;
/**
* Деактивировать подписку по endpoint
* @param endpoint Endpoint подписки
* @returns Promise<void>
*/
deactivateSubscription(endpoint: string): Promise<void>;
/**
* Деактивировать подписку по ID
* @param id ID подписки
* @returns Promise<void>
*/
deactivateSubscriptionById(id: string): Promise<void>;
/**
* Удалить неактивные подписки старше указанного количества дней
* @param olderThanDays Количество дней
* @returns Promise<number> Количество удаленных подписок
*/
cleanupInactiveSubscriptions(olderThanDays: number): Promise<number>;
/**
* Получить статистику подписок
* @returns Promise<SubscriptionStatsDomainInterface>
*/
getSubscriptionStats(): Promise<SubscriptionStatsDomainInterface>;
/**
* Обновить подписку
* @param endpoint Endpoint подписки
* @param data Данные для обновления
* @returns Promise<WebPushSubscriptionDomainInterface>
*/
updateSubscription(
endpoint: string,
data: Partial<CreateWebPushSubscriptionDomainInterface>
): Promise<WebPushSubscriptionDomainInterface>;
}
/**
* Токен для внедрения зависимости
*/
export const WEB_PUSH_SUBSCRIPTION_PORT = Symbol('WebPushSubscriptionPort');
@@ -1,5 +1,6 @@
import { Global, Module } from '@nestjs/common';
import { NotificationDomainService, NOTIFICATION_DOMAIN_SERVICE } from './services/notification-domain.service';
import { WebPushSubscriptionDomainInteractor } from './interactors/web-push-subscription-domain.interactor';
import { InfrastructureModule } from '~/infrastructure/infrastructure.module';
@Global()
@@ -11,7 +12,8 @@ import { InfrastructureModule } from '~/infrastructure/infrastructure.module';
provide: NOTIFICATION_DOMAIN_SERVICE,
useExisting: NotificationDomainService,
},
WebPushSubscriptionDomainInteractor,
],
exports: [NotificationDomainService, NOTIFICATION_DOMAIN_SERVICE],
exports: [NotificationDomainService, NOTIFICATION_DOMAIN_SERVICE, WebPushSubscriptionDomainInteractor],
})
export class NotificationDomainModule {}
@@ -1,12 +1,20 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { NOTIFICATION_PORT, NotificationPort, NotificationSubscriberData } from '../interfaces/notification.port';
import { WebPushSubscriptionDomainInteractor } from '../interactors/web-push-subscription-domain.interactor';
import type { AccountDomainEntity } from '~/domain/account/entities/account-domain.entity';
import type {
CreateSubscriptionInputDomainInterface,
NotificationPayloadDomainInterface,
} from '../interfaces/web-push-subscription-domain.interface';
@Injectable()
export class NotificationDomainService {
private readonly logger = new Logger(NotificationDomainService.name);
constructor(@Inject(NOTIFICATION_PORT) private readonly notificationPort: NotificationPort) {}
constructor(
@Inject(NOTIFICATION_PORT) private readonly notificationPort: NotificationPort,
private readonly webPushSubscriptionDomainInteractor: WebPushSubscriptionDomainInteractor
) {}
/**
* Создает подписчика уведомлений из данных аккаунта
@@ -37,6 +45,86 @@ export class NotificationDomainService {
}
}
/**
* Создать веб-пуш подписку для пользователя
* @param data Данные для создания подписки
*/
async createWebPushSubscription(data: CreateSubscriptionInputDomainInterface): Promise<void> {
this.logger.log(`Создание веб-пуш подписки для пользователя ${data.userId}`);
try {
await this.webPushSubscriptionDomainInteractor.createOrUpdateSubscription(data);
this.logger.log(`Веб-пуш подписка создана для пользователя ${data.userId}`);
} catch (error: any) {
this.logger.error(`Ошибка создания веб-пуш подписки для ${data.userId}: ${error.message}`, error.stack);
throw error;
}
}
/**
* Отправить push уведомление пользователю
* @param userId ID пользователя
* @param payload Данные уведомления
*/
async sendPushNotificationToUser(userId: string, payload: NotificationPayloadDomainInterface): Promise<void> {
this.logger.log(`Отправка push уведомления пользователю ${userId}: ${payload.title}`);
try {
await this.webPushSubscriptionDomainInteractor.sendNotificationToUser(userId, payload);
this.logger.log(`Push уведомление отправлено пользователю ${userId}`);
} catch (error: any) {
this.logger.error(`Ошибка отправки push уведомления пользователю ${userId}: ${error.message}`, error.stack);
throw error;
}
}
/**
* Отправить push уведомление всем пользователям
* @param payload Данные уведомления
*/
async sendPushNotificationToAll(payload: NotificationPayloadDomainInterface): Promise<void> {
this.logger.log(`Отправка push уведомления всем пользователям: ${payload.title}`);
try {
await this.webPushSubscriptionDomainInteractor.sendNotificationToAll(payload);
this.logger.log(`Push уведомление отправлено всем пользователям`);
} catch (error: any) {
this.logger.error(`Ошибка отправки push уведомления всем пользователям: ${error.message}`, error.stack);
throw error;
}
}
/**
* Получить статистику веб-пуш подписок
*/
async getWebPushSubscriptionStats() {
this.logger.debug('Получение статистики веб-пуш подписок');
try {
return await this.webPushSubscriptionDomainInteractor.getSubscriptionStats();
} catch (error: any) {
this.logger.error(`Ошибка получения статистики подписок: ${error.message}`, error.stack);
throw error;
}
}
/**
* Очистить неактивные веб-пуш подписки
* @param olderThanDays Количество дней
*/
async cleanupInactiveWebPushSubscriptions(olderThanDays = 30): Promise<number> {
this.logger.log(`Очистка неактивных веб-пуш подписок старше ${olderThanDays} дней`);
try {
const deletedCount = await this.webPushSubscriptionDomainInteractor.cleanupInactiveSubscriptions(olderThanDays);
this.logger.log(`Очищено ${deletedCount} неактивных веб-пуш подписок`);
return deletedCount;
} catch (error: any) {
this.logger.error(`Ошибка очистки неактивных подписок: ${error.message}`, error.stack);
throw error;
}
}
/**
* Формирует данные подписчика из аккаунта
* @param account Данные аккаунта
@@ -0,0 +1,76 @@
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
import { WebPushSubscriptionDomainEntity } from '~/domain/notification/entities/web-push-subscription-domain.entity';
import type { WebPushSubscriptionDomainInterface } from '~/domain/notification/interfaces/web-push-subscription-domain.interface';
@Entity('web_push_subscriptions')
@Index(['userId'], { unique: false })
@Index(['endpoint'], { unique: true })
export class WebPushSubscriptionEntity implements WebPushSubscriptionDomainInterface {
@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;
constructor(data?: WebPushSubscriptionDomainEntity) {
if (data) {
this.id = data.id;
this.userId = data.userId;
this.endpoint = data.endpoint;
this.p256dhKey = data.p256dhKey;
this.authKey = data.authKey;
this.userAgent = data.userAgent;
this.isActive = data.isActive;
this.createdAt = data.createdAt;
this.updatedAt = data.updatedAt;
}
}
/**
* Метод для преобразования ORM-сущности в доменную сущность
*/
toDomainEntity(): WebPushSubscriptionDomainEntity {
return new WebPushSubscriptionDomainEntity(this);
}
/**
* Статический метод для создания ORM-сущности из доменной сущности
*/
static fromDomainEntity(domainEntity: WebPushSubscriptionDomainEntity): WebPushSubscriptionEntity {
return new WebPushSubscriptionEntity(domainEntity);
}
/**
* Удобный метод для получения подписки в формате web-push
*/
toWebPushSubscription(): { endpoint: string; keys: { p256dh: string; auth: string } } {
return {
endpoint: this.endpoint,
keys: {
p256dh: this.p256dhKey,
auth: this.authKey,
},
};
}
}
@@ -0,0 +1,147 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { WebPushSubscriptionEntity } from '../entities/web-push-subscription.entity';
import { WebPushSubscriptionPort } from '~/domain/notification/interfaces/web-push-subscription.port';
import { WebPushSubscriptionDomainEntity } from '~/domain/notification/entities/web-push-subscription-domain.entity';
import type {
WebPushSubscriptionDomainInterface,
CreateWebPushSubscriptionDomainInterface,
SubscriptionStatsDomainInterface,
} from '~/domain/notification/interfaces/web-push-subscription-domain.interface';
@Injectable()
export class TypeOrmWebPushSubscriptionRepository implements WebPushSubscriptionPort {
constructor(
@InjectRepository(WebPushSubscriptionEntity)
private readonly ormRepo: Repository<WebPushSubscriptionEntity>
) {}
/**
* Сохранить веб-пуш подписку
*/
async saveSubscription(data: CreateWebPushSubscriptionDomainInterface): Promise<WebPushSubscriptionDomainInterface> {
const entity = this.ormRepo.create({
userId: data.userId,
endpoint: data.endpoint,
p256dhKey: data.p256dhKey,
authKey: data.authKey,
userAgent: data.userAgent,
isActive: true,
});
const savedEntity = await this.ormRepo.save(entity);
return savedEntity.toDomainEntity();
}
/**
* Найти подписку по endpoint
*/
async findByEndpoint(endpoint: string): Promise<WebPushSubscriptionDomainInterface | null> {
const entity = await this.ormRepo.findOne({ where: { endpoint } });
return entity ? entity.toDomainEntity() : null;
}
/**
* Получить все активные подписки пользователя
*/
async getUserSubscriptions(userId: string): Promise<WebPushSubscriptionDomainInterface[]> {
const entities = await this.ormRepo.find({
where: { userId, isActive: true },
});
return entities.map((entity) => entity.toDomainEntity());
}
/**
* Получить все активные подписки
*/
async getAllActiveSubscriptions(): Promise<WebPushSubscriptionDomainInterface[]> {
const entities = await this.ormRepo.find({
where: { isActive: true },
});
return entities.map((entity) => entity.toDomainEntity());
}
/**
* Деактивировать подписку по endpoint
*/
async deactivateSubscription(endpoint: string): Promise<void> {
await this.ormRepo.update({ endpoint }, { isActive: false });
}
/**
* Деактивировать подписку по ID
*/
async deactivateSubscriptionById(id: string): Promise<void> {
await this.ormRepo.update({ id }, { isActive: false });
}
/**
* Удалить неактивные подписки старше указанного количества дней
*/
async cleanupInactiveSubscriptions(olderThanDays: number): Promise<number> {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - olderThanDays);
const result = await this.ormRepo
.createQueryBuilder()
.delete()
.where('isActive = :isActive AND updatedAt < :cutoffDate', {
isActive: false,
cutoffDate,
})
.execute();
return result.affected || 0;
}
/**
* Получить статистику подписок
*/
async getSubscriptionStats(): Promise<SubscriptionStatsDomainInterface> {
const total = await this.ormRepo.count();
const active = await this.ormRepo.count({
where: { isActive: true },
});
const inactive = total - active;
const uniqueUsersResult = await this.ormRepo
.createQueryBuilder('subscription')
.select('COUNT(DISTINCT subscription.userId)', 'count')
.where('subscription.isActive = :isActive', { isActive: true })
.getRawOne();
return {
total,
active,
inactive,
uniqueUsers: parseInt(uniqueUsersResult.count),
};
}
/**
* Обновить подписку
*/
async updateSubscription(
endpoint: string,
data: Partial<CreateWebPushSubscriptionDomainInterface>
): Promise<WebPushSubscriptionDomainInterface> {
const existingEntity = await this.ormRepo.findOne({ where: { endpoint } });
if (!existingEntity) {
throw new Error('Подписка не найдена');
}
// Обновляем только переданные поля
if (data.userId !== undefined) existingEntity.userId = data.userId;
if (data.p256dhKey !== undefined) existingEntity.p256dhKey = data.p256dhKey;
if (data.authKey !== undefined) existingEntity.authKey = data.authKey;
if (data.userAgent !== undefined) existingEntity.userAgent = data.userAgent;
// Активируем подписку при обновлении
existingEntity.isActive = true;
const savedEntity = await this.ormRepo.save(existingEntity);
return savedEntity.toDomainEntity();
}
}
@@ -24,6 +24,9 @@ import { TypeOrmMeetProcessedRepository } from './repositories/typeorm-meet-proc
import { PaymentEntity } from './entities/payment.entity';
import { PAYMENT_REPOSITORY } from '~/domain/gateway/repositories/payment.repository';
import { TypeOrmPaymentRepository } from './repositories/typeorm-payment.repository';
import { WebPushSubscriptionEntity } from './entities/web-push-subscription.entity';
import { WEB_PUSH_SUBSCRIPTION_PORT } from '~/domain/notification/interfaces/web-push-subscription.port';
import { TypeOrmWebPushSubscriptionRepository } from './repositories/typeorm-web-push-subscription.repository';
@Global()
@Module({
@@ -47,6 +50,7 @@ import { TypeOrmPaymentRepository } from './repositories/typeorm-payment.reposit
MigrationEntity,
CandidateEntity,
PaymentEntity,
WebPushSubscriptionEntity,
]),
],
providers: [
@@ -78,6 +82,10 @@ import { TypeOrmPaymentRepository } from './repositories/typeorm-payment.reposit
provide: PAYMENT_REPOSITORY,
useClass: TypeOrmPaymentRepository,
},
{
provide: WEB_PUSH_SUBSCRIPTION_PORT,
useClass: TypeOrmWebPushSubscriptionRepository,
},
],
exports: [
NestTypeOrmModule,
@@ -88,6 +96,7 @@ import { TypeOrmPaymentRepository } from './repositories/typeorm-payment.reposit
MIGRATION_REPOSITORY,
CANDIDATE_REPOSITORY,
PAYMENT_REPOSITORY,
WEB_PUSH_SUBSCRIPTION_PORT,
],
})
export class TypeOrmModule {}
@@ -19,6 +19,7 @@ import { DesktopModule } from './desktop/desktop.module';
import { MeetModule } from './meet/meet.module';
import { GatewayModule } from './gateway/gateway.module';
import { WalletModule } from './wallet/wallet.module';
import { WebPushSubscriptionModule } from './web-push-subscription/web-push-subscription.module';
@Module({
imports: [
AccountModule,
@@ -40,6 +41,7 @@ import { WalletModule } from './wallet/wallet.module';
MeetModule,
GatewayModule,
WalletModule,
WebPushSubscriptionModule,
],
exports: [
AccountModule,
@@ -61,6 +63,7 @@ import { WalletModule } from './wallet/wallet.module';
MeetModule,
GatewayModule,
WalletModule,
WebPushSubscriptionModule,
],
})
export class ApplicationModule {}
@@ -0,0 +1,51 @@
import { InputType, Field } from '@nestjs/graphql';
import { IsString, IsNotEmpty, ValidateNested, IsOptional } from 'class-validator';
import { Type } from 'class-transformer';
import type {
CreateSubscriptionInputDomainInterface,
WebPushSubscriptionDataDomainInterface,
} from '~/domain/notification/interfaces/web-push-subscription-domain.interface';
@InputType('WebPushSubscriptionKeys')
export class WebPushSubscriptionKeysDTO {
@Field(() => String, { description: 'P256DH ключ для веб-пуш подписки' })
@IsString()
@IsNotEmpty()
p256dh!: string;
@Field(() => String, { description: 'Auth ключ для веб-пуш подписки' })
@IsString()
@IsNotEmpty()
auth!: string;
}
@InputType('WebPushSubscriptionData')
export class WebPushSubscriptionDataDTO implements WebPushSubscriptionDataDomainInterface {
@Field(() => String, { description: 'Endpoint для веб-пуш подписки' })
@IsString()
@IsNotEmpty()
endpoint!: string;
@Field(() => WebPushSubscriptionKeysDTO, { description: 'Ключи для веб-пуш подписки' })
@ValidateNested()
@Type(() => WebPushSubscriptionKeysDTO)
keys!: WebPushSubscriptionKeysDTO;
}
@InputType('CreateWebPushSubscriptionInput')
export class CreateWebPushSubscriptionInputDTO implements CreateSubscriptionInputDomainInterface {
@Field(() => String, { description: 'ID пользователя' })
@IsString()
@IsNotEmpty()
userId!: string;
@Field(() => WebPushSubscriptionDataDTO, { description: 'Данные веб-пуш подписки' })
@ValidateNested()
@Type(() => WebPushSubscriptionDataDTO)
subscription!: WebPushSubscriptionDataDTO;
@Field(() => String, { description: 'User Agent браузера', nullable: true })
@IsOptional()
@IsString()
userAgent?: string;
}
@@ -0,0 +1,57 @@
import { ObjectType, Field, ID } from '@nestjs/graphql';
import { WebPushSubscriptionDomainEntity } from '~/domain/notification/entities/web-push-subscription-domain.entity';
import type { WebPushSubscriptionDomainInterface } from '~/domain/notification/interfaces/web-push-subscription-domain.interface';
@ObjectType('WebPushSubscription')
export class WebPushSubscriptionDTO implements WebPushSubscriptionDomainInterface {
@Field(() => ID, { description: 'Уникальный ID подписки' })
id!: string;
@Field(() => String, { description: 'ID пользователя' })
userId!: string;
@Field(() => String, { description: 'Endpoint для веб-пуш подписки' })
endpoint!: string;
@Field(() => String, { description: 'P256DH ключ для веб-пуш подписки' })
p256dhKey!: string;
@Field(() => String, { description: 'Auth ключ для веб-пуш подписки' })
authKey!: string;
@Field(() => String, { description: 'User Agent браузера', nullable: true })
userAgent?: string;
@Field(() => Boolean, { description: 'Активна ли подписка' })
isActive!: boolean;
@Field(() => Date, { description: 'Дата создания подписки' })
createdAt!: Date;
@Field(() => Date, { description: 'Дата последнего обновления подписки' })
updatedAt!: Date;
constructor(domainEntity: WebPushSubscriptionDomainEntity) {
this.id = domainEntity.id;
this.userId = domainEntity.userId;
this.endpoint = domainEntity.endpoint;
this.p256dhKey = domainEntity.p256dhKey;
this.authKey = domainEntity.authKey;
this.userAgent = domainEntity.userAgent;
this.isActive = domainEntity.isActive;
this.createdAt = domainEntity.createdAt;
this.updatedAt = domainEntity.updatedAt;
}
}
@ObjectType('CreateWebPushSubscriptionResponse')
export class CreateWebPushSubscriptionResponseDTO {
@Field(() => Boolean, { description: 'Успешно ли создана подписка' })
success!: boolean;
@Field(() => String, { description: 'Сообщение о результате операции' })
message!: string;
@Field(() => WebPushSubscriptionDTO, { description: 'Данные созданной подписки' })
subscription!: WebPushSubscriptionDTO;
}
@@ -0,0 +1,52 @@
import { Resolver, Mutation, Args, Query } from '@nestjs/graphql';
import { UseGuards } from '@nestjs/common';
import { WebPushSubscriptionService } from '../services/web-push-subscription.service';
import { CreateWebPushSubscriptionInputDTO } from '../dto/create-subscription-input.dto';
import { WebPushSubscriptionDTO, CreateWebPushSubscriptionResponseDTO } from '../dto/web-push-subscription.dto';
import { GqlJwtAuthGuard } from '~/modules/auth/guards/graphql-jwt-auth.guard';
import { RolesGuard } from '~/modules/auth/guards/roles.guard';
import { AuthRoles } from '~/modules/auth/decorators/auth.decorator';
/**
* GraphQL резолвер для управления веб-пуш подписками
*/
@Resolver(() => WebPushSubscriptionDTO)
export class WebPushSubscriptionResolver {
constructor(private readonly webPushSubscriptionService: WebPushSubscriptionService) {}
/**
* Создать веб-пуш подписку
* @param input Входные данные для создания подписки
* @returns Promise<CreateWebPushSubscriptionResponseDTO>
*/
@Mutation(() => CreateWebPushSubscriptionResponseDTO, {
name: 'createWebPushSubscription',
description: 'Создать веб-пуш подписку для пользователя',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman', 'member'])
async createSubscription(
@Args('input', { type: () => CreateWebPushSubscriptionInputDTO })
input: CreateWebPushSubscriptionInputDTO
): Promise<CreateWebPushSubscriptionResponseDTO> {
return await this.webPushSubscriptionService.createSubscription(input);
}
/**
* Получить все подписки пользователя
* @param userId ID пользователя
* @returns Promise<WebPushSubscriptionDTO[]>
*/
@Query(() => [WebPushSubscriptionDTO], {
name: 'getUserWebPushSubscriptions',
description: 'Получить все веб-пуш подписки пользователя',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman', 'member'])
async getUserSubscriptions(
@Args('userId', { type: () => String, description: 'ID пользователя' })
userId: string
): Promise<WebPushSubscriptionDTO[]> {
return await this.webPushSubscriptionService.getUserSubscriptions(userId);
}
}
@@ -0,0 +1,125 @@
import { Injectable, Logger } from '@nestjs/common';
import { WebPushSubscriptionDomainInteractor } from '~/domain/notification/interactors/web-push-subscription-domain.interactor';
import { CreateWebPushSubscriptionInputDTO } from '../dto/create-subscription-input.dto';
import { WebPushSubscriptionDTO, CreateWebPushSubscriptionResponseDTO } from '../dto/web-push-subscription.dto';
/**
* Сервис приложения для управления веб-пуш подписками
* Делегирует выполнение доменному интерактору и преобразует данные в DTO
*/
@Injectable()
export class WebPushSubscriptionService {
private readonly logger = new Logger(WebPushSubscriptionService.name);
constructor(private readonly webPushSubscriptionDomainInteractor: WebPushSubscriptionDomainInteractor) {}
/**
* Создать или обновить веб-пуш подписку
* @param input Входные данные для создания подписки
* @returns Promise<CreateWebPushSubscriptionResponseDTO>
*/
async createSubscription(input: CreateWebPushSubscriptionInputDTO): Promise<CreateWebPushSubscriptionResponseDTO> {
this.logger.log(`Создание подписки для пользователя ${input.userId}`);
try {
// Валидация входных данных
this.validateSubscriptionInput(input);
// Делегируем выполнение доменному интерактору
const subscriptionEntity = await this.webPushSubscriptionDomainInteractor.createOrUpdateSubscription(input);
// Преобразуем доменную сущность в DTO
const subscriptionDTO = new WebPushSubscriptionDTO(subscriptionEntity);
this.logger.log(`Подписка успешно создана для пользователя ${input.userId}`);
return {
success: true,
message: 'Подписка на push уведомления успешно создана',
subscription: subscriptionDTO,
};
} catch (error: any) {
this.logger.error(`Ошибка при создании подписки для пользователя ${input.userId}: ${error.message}`, error.stack);
return {
success: false,
message: `Ошибка при создании подписки: ${error.message}`,
subscription: null as any, // В случае ошибки возвращаем null
};
}
}
/**
* Получить все подписки пользователя
* @param userId ID пользователя
* @returns Promise<WebPushSubscriptionDTO[]>
*/
async getUserSubscriptions(userId: string): Promise<WebPushSubscriptionDTO[]> {
this.logger.debug(`Получение подписок для пользователя ${userId}`);
try {
const subscriptionEntities = await this.webPushSubscriptionDomainInteractor.getUserSubscriptions(userId);
return subscriptionEntities.map((entity) => new WebPushSubscriptionDTO(entity));
} catch (error: any) {
this.logger.error(`Ошибка при получении подписок для пользователя ${userId}: ${error.message}`, error.stack);
throw error;
}
}
/**
* Деактивировать подписку по endpoint
* @param endpoint Endpoint подписки
* @returns Promise<boolean>
*/
async deactivateSubscription(endpoint: string): Promise<boolean> {
this.logger.log(`Деактивация подписки с endpoint: ${endpoint.substring(0, 50)}...`);
try {
await this.webPushSubscriptionDomainInteractor.deactivateSubscription(endpoint);
this.logger.log(`Подписка деактивирована: ${endpoint.substring(0, 50)}...`);
return true;
} catch (error: any) {
this.logger.error(`Ошибка при деактивации подписки: ${error.message}`, error.stack);
throw error;
}
}
/**
* Валидация входных данных для создания подписки
* @param input Входные данные
*/
private validateSubscriptionInput(input: CreateWebPushSubscriptionInputDTO): void {
if (!input.userId || input.userId.trim() === '') {
throw new Error('ID пользователя обязателен');
}
if (!input.subscription) {
throw new Error('Данные подписки обязательны');
}
if (!input.subscription.endpoint || input.subscription.endpoint.trim() === '') {
throw new Error('Endpoint подписки обязателен');
}
if (!input.subscription.keys) {
throw new Error('Ключи подписки обязательны');
}
if (!input.subscription.keys.p256dh || input.subscription.keys.p256dh.trim() === '') {
throw new Error('P256DH ключ обязателен');
}
if (!input.subscription.keys.auth || input.subscription.keys.auth.trim() === '') {
throw new Error('Auth ключ обязателен');
}
// Проверка формата endpoint
try {
new URL(input.subscription.endpoint);
} catch {
throw new Error('Endpoint должен быть валидным URL');
}
}
}
@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { WebPushSubscriptionService } from './services/web-push-subscription.service';
import { WebPushSubscriptionResolver } from './resolvers/web-push-subscription.resolver';
import { NotificationDomainModule } from '~/domain/notification/notification-domain.module';
/**
* Модуль приложения для управления веб-пуш подписками
*/
@Module({
imports: [
NotificationDomainModule, // Импортируем доменный модуль уведомлений
],
providers: [WebPushSubscriptionService, WebPushSubscriptionResolver],
exports: [WebPushSubscriptionService],
})
export class WebPushSubscriptionModule {}
+2
View File
@@ -0,0 +1,2 @@
NOVU_API_KEY=
NOVU_API_URL=
+3
View File
@@ -0,0 +1,3 @@
.env
node_modules
dist
+12 -1
View File
@@ -7,16 +7,27 @@
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"clean": "rm -rf dist"
"clean": "rm -rf dist",
"sync": "tsx src/sync/sync-runner.ts",
"sync:dev": "tsx src/sync/sync-runner.ts --dev",
"sync:watch": "tsx src/sync/sync-runner.ts --dev"
},
"dependencies": {
"axios": "^1.7.7",
"dotenv": "^17.1.0",
"zod": "^3.22.4",
"zod-to-json-schema": "^3.22.5"
},
"devDependencies": {
"@types/chokidar": "^2.1.3",
"@types/node": "^20.0.0",
"chokidar": "^3.6.0",
"tsx": "^4.0.0",
"typescript": "^5.0.0"
},
"bin": {
"novu-sync": "./dist/sync/sync-runner.js"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
@@ -0,0 +1,54 @@
# Синхронизация Novu Workflows
Эта папка содержит серверную логику для синхронизации воркфлоу с Novu API.
## Файлы
- `novu-sync.service.ts` - Сервис для работы с Novu API
- `sync-runner.ts` - Скрипт для запуска синхронизации
- `README.md` - Этот файл
## Использование
### Production режим (один раз)
```bash
pnpm run sync
```
### Development режим (watch)
```bash
pnpm run sync:dev
# или
pnpm run sync:watch
```
## Переменные окружения
Требуются следующие переменные:
- `NOVU_API_KEY` - API ключ для Novu
- `NOVU_API_URL` - URL Novu API (по умолчанию: https://api.novu.co)
## Режимы работы
### Production
- Запускается один раз
- Синхронизирует все воркфлоу
- Завершается после выполнения
### Development
- Запускается и остается активным
- Отслеживает изменения в папках `workflows/` и `types/`
- Автоматически перезапускает синхронизацию при изменениях
- Дебаунс 1 секунда для избежания множественных запусков
## Как работает
1. Загружает все воркфлоу из `../workflows/index.ts`
2. Для каждого воркфлоу:
- Проверяет существование в Novu API
- Создает новый или обновляет существующий
3. Логирует результаты операций
## Важно
⚠️ Этот модуль предназначен только для серверного использования и не должен импортироваться на фронтенде!
@@ -0,0 +1,150 @@
import axios, { AxiosInstance } from 'axios';
import {
WorkflowDefinition,
NovuWorkflowData,
allWorkflows
} from '../index';
export interface NovuSyncConfig {
apiKey: string;
apiUrl: string;
}
export class NovuSyncService {
private readonly client: AxiosInstance;
private readonly config: NovuSyncConfig;
constructor(config: NovuSyncConfig) {
this.config = config;
if (!this.config.apiKey) {
throw new Error('NOVU_API_KEY is required');
}
if (!this.config.apiUrl) {
throw new Error('NOVU_API_URL is required');
}
this.client = axios.create({
baseURL: this.config.apiUrl,
headers: {
'Authorization': `ApiKey ${this.config.apiKey}`,
'Content-Type': 'application/json',
},
});
}
/**
* Получить информацию о воркфлоу
*/
async getWorkflow(workflowId: string): Promise<any> {
try {
const response = await this.client.get(`/v2/workflows/${workflowId}`);
return response.data;
} catch (error: any) {
if (error.response?.status === 404) {
return null;
}
throw error;
}
}
/**
* Создать новый воркфлоу
*/
async createWorkflow(data: NovuWorkflowData): Promise<any> {
try {
// Для создания НЕ передаем origin (как в testFramework2.ts)
const createData = { ...data };
delete createData.origin;
const response = await this.client.post('/v2/workflows', createData);
return response.data;
} catch (error: any) {
console.error(`Ошибка создания воркфлоу ${data.workflowId}:`, error.response?.data || error.message);
throw error;
}
}
/**
* Обновить существующий воркфлоу
*/
async updateWorkflow(workflowId: string, data: NovuWorkflowData): Promise<any> {
try {
// Для обновления ВСЕГДА передаем origin: "external" (как в testFramework2.ts)
const updateData = { ...data, origin: 'external' as const };
const response = await this.client.put(`/v2/workflows/${workflowId}`, updateData);
return response.data;
} catch (error: any) {
console.error(`Ошибка обновления воркфлоу ${workflowId}:`, error.response?.data || error.message);
throw error;
}
}
/**
* Создать или обновить воркфлоу (upsert)
*/
async upsertWorkflow(workflow: WorkflowDefinition): Promise<any> {
try {
console.log(`Проверяем воркфлоу: ${workflow.workflowId}`);
const existingWorkflow = await this.getWorkflow(workflow.workflowId);
const novuData: NovuWorkflowData = {
name: workflow.name,
workflowId: workflow.workflowId,
description: workflow.description,
payloadSchema: workflow.payloadSchema,
steps: workflow.steps,
preferences: workflow.preferences,
};
if (existingWorkflow) {
console.log(`Обновляем воркфлоу: ${workflow.workflowId}`);
return await this.updateWorkflow(workflow.workflowId, novuData);
} else {
console.log(`Создаём воркфлоу: ${workflow.workflowId}`);
return await this.createWorkflow(novuData);
}
} catch (error: any) {
console.error(`Ошибка upsert воркфлоу ${workflow.workflowId}:`, error.message);
throw error;
}
}
/**
* Создать или обновить все воркфлоу
*/
async upsertAllWorkflows(): Promise<void> {
console.log(`Начинаем upsert ${allWorkflows.length} воркфлоу...`);
const errors: string[] = [];
let successCount = 0;
for (const workflow of allWorkflows) {
try {
await this.upsertWorkflow(workflow);
console.log(`✓ Воркфлоу ${workflow.workflowId} успешно обработан`);
successCount++;
} catch (error: any) {
const errorMessage = `Ошибка обработки воркфлоу ${workflow.workflowId}: ${error.message}`;
console.error(`${errorMessage}`);
errors.push(errorMessage);
}
}
console.log(`\nРезультат синхронизации:`);
console.log(`✅ Успешно: ${successCount}`);
console.log(`❌ Ошибки: ${errors.length}`);
if (errors.length > 0) {
console.log(`\nСписок ошибок:`);
errors.forEach((error, index) => {
console.log(`${index + 1}. ${error}`);
});
throw new Error(`Синхронизация завершилась с ошибками: ${errors.length} из ${allWorkflows.length} воркфлоу`);
}
console.log('✅ Все воркфлоу синхронизированы успешно');
}
}
@@ -0,0 +1,151 @@
#!/usr/bin/env node
import { NovuSyncService } from './novu-sync.service';
import { existsSync } from 'fs';
import { join } from 'path';
import { watch } from 'chokidar';
import dotenv from 'dotenv';
dotenv.config();
// Конфигурация из переменных окружения
const config = {
apiKey: process.env.NOVU_API_KEY || '',
apiUrl: process.env.NOVU_API_URL || '',
};
async function runSync() {
console.log('🔄 Запуск синхронизации воркфлоу...');
try {
// Проверяем конфигурацию
if (!config.apiKey) {
throw new Error('❌ NOVU_API_KEY не установлен в переменных окружения');
}
if (!config.apiUrl) {
throw new Error('❌ NOVU_API_URL не установлен в переменных окружения');
}
const syncService = new NovuSyncService(config);
await syncService.upsertAllWorkflows();
console.log('✅ Синхронизация завершена успешно');
return true;
} catch (error: any) {
console.error('❌ Ошибка синхронизации:', error.message);
return false;
}
}
async function main() {
const args = process.argv.slice(2);
const isDev = args.includes('--dev') || process.env.NODE_ENV === 'development';
if (isDev) {
console.log('📡 Режим разработки: отслеживаем изменения...');
// Запускаем синхронизацию сразу
const initialSyncSuccess = await runSync();
if (!initialSyncSuccess) {
console.error('❌ Первоначальная синхронизация не удалась');
process.exit(1);
}
// Отслеживаем изменения в файлах воркфлоу и типов
const workflowsPath = join(__dirname, '../workflows');
const typesPath = join(__dirname, '../types');
const watchPaths = [];
if (existsSync(workflowsPath)) {
watchPaths.push(workflowsPath);
}
if (existsSync(typesPath)) {
watchPaths.push(typesPath);
}
if (watchPaths.length > 0) {
console.log('👀 Отслеживаем изменения в:', watchPaths.join(', '));
let syncTimeout: NodeJS.Timeout | null = null;
const watcher = watch(watchPaths, {
ignored: /node_modules/,
persistent: true,
ignoreInitial: true,
});
watcher.on('change', (path: string) => {
console.log(`📝 Изменен файл: ${path}`);
// Дебаунс для избежания множественных запусков
if (syncTimeout) {
clearTimeout(syncTimeout);
}
syncTimeout = setTimeout(async () => {
console.log('⏰ Запускаем синхронизацию...');
const success = await runSync();
if (!success) {
console.error('⚠️ Синхронизация не удалась, но продолжаем отслеживание...');
}
}, 1000);
});
watcher.on('add', (path: string) => {
console.log(` Добавлен файл: ${path}`);
if (syncTimeout) {
clearTimeout(syncTimeout);
}
syncTimeout = setTimeout(async () => {
console.log('⏰ Запускаем синхронизацию...');
const success = await runSync();
if (!success) {
console.error('⚠️ Синхронизация не удалась, но продолжаем отслеживание...');
}
}, 1000);
});
watcher.on('unlink', (path: string) => {
console.log(` Удален файл: ${path}`);
if (syncTimeout) {
clearTimeout(syncTimeout);
}
syncTimeout = setTimeout(async () => {
console.log('⏰ Запускаем синхронизацию...');
const success = await runSync();
if (!success) {
console.error('⚠️ Синхронизация не удалась, но продолжаем отслеживание...');
}
}, 1000);
});
console.log('💡 Нажмите Ctrl+C для выхода');
// Держим процесс живым
process.on('SIGINT', () => {
console.log('\n👋 Выход из режима разработки');
watcher.close();
process.exit(0);
});
} else {
console.log('⚠️ Папки для отслеживания не найдены');
process.exit(1);
}
} else {
// Production режим - запускаем один раз
const success = await runSync();
process.exit(success ? 0 : 1);
}
}
if (require.main === module) {
main().catch((error) => {
console.error('❌ Критическая ошибка:', error);
process.exit(1);
});
}
export { runSync, NovuSyncService };
@@ -12,7 +12,7 @@ export const welcomeWorkflow: WorkflowDefinition<WelcomeWorkflowPayload> = Workf
.addSteps([
createEmailStep(
'welcome-email',
'Добро пожаловать, {{payload.userName}}!',
'Добро пожаловать, {{payload.userName}}',
'Здравствуй, {{payload.userName}}! Ваш email: {{payload.userEmail}}. {{payload.age}}Ваш возраст: {{payload.age}} лет.{{payload.age}}'
),
createInAppStep(