pwa push notifications
This commit is contained in:
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
+97
@@ -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 });
|
||||
}
|
||||
}
|
||||
+208
@@ -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 ключ обязателен и должен быть строкой');
|
||||
}
|
||||
}
|
||||
}
|
||||
+83
@@ -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;
|
||||
}
|
||||
+81
@@ -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 {}
|
||||
|
||||
+89
-1
@@ -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 Данные аккаунта
|
||||
|
||||
+76
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
+147
@@ -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 {}
|
||||
|
||||
+51
@@ -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;
|
||||
}
|
||||
+57
@@ -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;
|
||||
}
|
||||
+52
@@ -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);
|
||||
}
|
||||
}
|
||||
+125
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
@@ -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 {}
|
||||
@@ -0,0 +1,2 @@
|
||||
NOVU_API_KEY=
|
||||
NOVU_API_URL=
|
||||
@@ -0,0 +1,3 @@
|
||||
.env
|
||||
node_modules
|
||||
dist
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user