[C28-26][@ant] refactor: полное удаление Novu из контроллера
Контроллер больше не зависит от внешнего Novu (ADR-001, DEC-025): - Удалены infrastructure/novu/* (адаптеры + модуль), порты NovuWorkflow/ NovuCredentials + старый notification.port (NotificationPort→NovuAdapter), NotificationDomainService, DeviceTokenService, webhook-приёмник Novu, novu.utils. - subscriber_hash переведён на HMAC по SERVER_SECRET (utils/subscriber-hash.util), identity своя; subscriber_id (адресация Центра) сохранён — backfill cron оставлен без Novu-синхронизации. - Развязаны AccountDomainService/account+participant-interactor/subscription-service от Novu-вызовов; NovuModule/NotificationDomainModule убраны из 7+5 модулей. - config.novu + NOVU_* env + @novu/api удалены; lockfile обновлён (desktop @novu/js снимется синхронно с эпиком 6). - @coopenomics/notifications: удалён sync/ (внешняя синхронизация), bin/scripts/deps; остался каталог типов + шаблоны; пакет пересобран. - grep -i novu по controller/src пуст; tsc = baseline (0 новых ошибок). Boot/grep-ERROR верификация — за оператором (поднять контроллер нельзя из фоновой сессии). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -97,7 +97,6 @@
|
||||
"@nestjs/schedule": "^6.0.1",
|
||||
"@nestjs/throttler": "^6.3.0",
|
||||
"@nestjs/typeorm": "^10.0.2",
|
||||
"@novu/api": "^1.4.0",
|
||||
"@octokit/rest": "^22.0.1",
|
||||
"@sentry/nestjs": "^10.32.1",
|
||||
"@sentry/node": "^10.32.1",
|
||||
|
||||
@@ -12,7 +12,6 @@ import config from '~/config/config';
|
||||
import { BlockchainModule } from './infrastructure/blockchain/blockchain.module';
|
||||
import { GeneratorInfrastructureModule } from './infrastructure/generator/generator.module';
|
||||
import { RedisModule } from './infrastructure/redis/redis.module';
|
||||
import { NovuModule } from './infrastructure/novu/novu.module';
|
||||
import { EventsInfrastructureModule } from './infrastructure/events/events.module';
|
||||
import { FreeDecisionInfrastructureModule } from './infrastructure/free-decision/free-decision-infrastructure.module';
|
||||
import { DecisionTrackingInfrastructureModule } from './infrastructure/decision-tracking/decision-tracking-infrastructure.module';
|
||||
@@ -36,7 +35,6 @@ import { DesktopDomainModule } from './domain/desktop/desktop-domain.module';
|
||||
import { MeetDomainModule } from './domain/meet/meet-domain.module';
|
||||
import { GatewayDomainModule } from './domain/gateway/gateway-domain.module';
|
||||
import { VaultDomainModule } from './domain/vault/vault-domain.module';
|
||||
import { NotificationDomainModule } from './domain/notification/notification-domain.module';
|
||||
import { LedgerDomainModule } from './domain/ledger/ledger-domain.module';
|
||||
import { ProcessRegistryDomainModule } from './domain/process-registry/process-registry-domain.module';
|
||||
import { ParserDomainModule } from './domain/parser/parser-domain.module';
|
||||
@@ -102,7 +100,6 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
|
||||
BlockchainModule,
|
||||
GeneratorInfrastructureModule,
|
||||
RedisModule,
|
||||
NovuModule,
|
||||
EventsInfrastructureModule,
|
||||
FreeDecisionInfrastructureModule,
|
||||
DecisionTrackingInfrastructureModule,
|
||||
@@ -134,7 +131,6 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
|
||||
MeetDomainModule,
|
||||
GatewayDomainModule,
|
||||
VaultDomainModule,
|
||||
NotificationDomainModule,
|
||||
LedgerDomainModule,
|
||||
ProcessRegistryDomainModule,
|
||||
ParserDomainModule,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { AccountResolver } from './resolvers/account.resolver';
|
||||
import { AccountService } from './services/account.service';
|
||||
import { AccountInteractor } from './interactors/account.interactor';
|
||||
import { AccountDomainModule } from '~/domain/account/account-domain.module';
|
||||
import { NotificationDomainModule } from '~/domain/notification/notification-domain.module';
|
||||
import { UserDomainModule } from '~/domain/user/user-domain.module';
|
||||
import { TokenApplicationModule } from '~/application/token/token-application.module';
|
||||
import { EventsInfrastructureModule } from '~/infrastructure/events/events.module';
|
||||
@@ -11,7 +10,6 @@ import { EventsInfrastructureModule } from '~/infrastructure/events/events.modul
|
||||
@Module({
|
||||
imports: [
|
||||
AccountDomainModule,
|
||||
NotificationDomainModule,
|
||||
forwardRef(() => UserDomainModule),
|
||||
forwardRef(() => TokenApplicationModule),
|
||||
EventsInfrastructureModule,
|
||||
|
||||
@@ -6,10 +6,6 @@ import { HttpApiError } from '~/utils/httpApiError';
|
||||
import { UserDomainService, USER_DOMAIN_SERVICE } from '~/domain/user/services/user-domain.service';
|
||||
import { TokenApplicationService } from '~/application/token/services/token-application.service';
|
||||
import { GENERATOR_PORT, GeneratorPort } from '~/domain/document/ports/generator.port';
|
||||
import {
|
||||
NOTIFICATION_DOMAIN_SERVICE,
|
||||
NotificationDomainService,
|
||||
} from '~/domain/notification/services/notification-domain.service';
|
||||
import { EventsService } from '~/infrastructure/events/events.service';
|
||||
import type { GetAccountsInputDomainInterface } from '~/domain/account/interfaces/get-accounts-input.interface';
|
||||
import type {
|
||||
@@ -56,7 +52,6 @@ export class AccountInteractor {
|
||||
private readonly searchPrivateAccountsRepository: SearchPrivateAccountsRepository,
|
||||
@Inject(ACCOUNT_BLOCKCHAIN_PORT) private readonly accountBlockchainPort: AccountBlockchainPort,
|
||||
@Inject(CANDIDATE_REPOSITORY) private readonly candidateRepository: CandidateRepository,
|
||||
@Inject(NOTIFICATION_DOMAIN_SERVICE) private readonly notificationDomainService: NotificationDomainService,
|
||||
private readonly eventsService: EventsService,
|
||||
@Inject(GENERATOR_PORT) private readonly generatorPort: GeneratorPort,
|
||||
private readonly tokenApplicationService: TokenApplicationService,
|
||||
@@ -167,25 +162,6 @@ export class AccountInteractor {
|
||||
throw new Error('Не получены входные данные для обновления');
|
||||
}
|
||||
|
||||
// Novu: не блокируем ответ мутации — догоняем подписчика в фоне
|
||||
try {
|
||||
const account = await this.getAccount(user.username);
|
||||
void this.notificationDomainService
|
||||
.createSubscriberFromAccount(account)
|
||||
.then(() => {
|
||||
this.logger.log(`Подписчик NOVU обновлён для ${data.username}`);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const stack = error instanceof Error ? error.stack : undefined;
|
||||
this.logger.error(`Ошибка обновления подписчика NOVU для ${data.username}: ${message}`, stack);
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const stack = error instanceof Error ? error.stack : undefined;
|
||||
this.logger.error(`Ошибка подготовки синхронизации NOVU для ${data.username}: ${message}`, stack);
|
||||
}
|
||||
|
||||
// Получаем финальный аккаунт
|
||||
const account = await this.getAccount(user.username);
|
||||
|
||||
@@ -226,11 +202,11 @@ export class AccountInteractor {
|
||||
const user = await this.createUser({ ...data, role: 'user' });
|
||||
const tokens = await this.tokenApplicationService.generateAuthTokens(user.id);
|
||||
|
||||
// Настраиваем подписчика NOVU
|
||||
// Настраиваем identity получателя
|
||||
try {
|
||||
await this.accountDomainService.setupNotificationSubscriber(user.username, 'регистрации');
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Ошибка настройки подписчика NOVU при регистрации ${data.username}: ${error.message}`, error.stack);
|
||||
this.logger.error(`Ошибка настройки identity получателя при регистрации ${data.username}: ${error.message}`, error.stack);
|
||||
}
|
||||
|
||||
// Создаем нового кандидата в репозитории
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ export class DecisionNotificationService implements OnModuleInit {
|
||||
const userEmail = user.provider_account?.email;
|
||||
const subscriberId = user.provider_account?.subscriber_id?.trim();
|
||||
if (!subscriberId) {
|
||||
this.logger.warn(`subscriber_id пользователя ${username} не найден — пропуск Novu`);
|
||||
this.logger.warn(`subscriber_id пользователя ${username} не найден`);
|
||||
return;
|
||||
}
|
||||
if (!userEmail) {
|
||||
|
||||
@@ -7,7 +7,6 @@ import { PaymentController } from './controllers/payment.controller';
|
||||
import { GatewayInteractor } from './interactors/gateway.interactor';
|
||||
import { GatewayNotificationHandler } from './handlers/gateway-notification.handler';
|
||||
import { GatewayDomainModule } from '~/domain/gateway/gateway-domain.module';
|
||||
import { NovuModule } from '~/infrastructure/novu/novu.module';
|
||||
import { UserDomainModule } from '~/domain/user/user-domain.module';
|
||||
import { AccountInfrastructureModule } from '~/infrastructure/account/account-infrastructure.module';
|
||||
import { SystemModule } from '~/application/system/system.module';
|
||||
@@ -19,7 +18,6 @@ import { RedisModule } from '~/infrastructure/redis/redis.module';
|
||||
imports: [
|
||||
forwardRef(() => GatewayDomainModule),
|
||||
forwardRef(() => GatewayInfrastructureModule),
|
||||
NovuModule,
|
||||
UserInfrastructureModule,
|
||||
UserDomainModule,
|
||||
AccountInfrastructureModule,
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ export class PaymentNotificationService implements OnModuleInit {
|
||||
const userEmail = user.provider_account?.email;
|
||||
const subscriberId = user.provider_account?.subscriber_id?.trim();
|
||||
if (!subscriberId) {
|
||||
this.logger.warn(`subscriber_id пользователя ${payment.username} не найден — пропуск Novu`);
|
||||
this.logger.warn(`subscriber_id пользователя ${payment.username} не найден`);
|
||||
return;
|
||||
}
|
||||
if (!userEmail) {
|
||||
|
||||
@@ -6,7 +6,6 @@ import { MeetEventService } from './services/meet-event.service';
|
||||
import { MeetInteractor } from './interactors/meet.interactor';
|
||||
import { AgendaNotificationService } from '../agenda/services/agenda-notification.service';
|
||||
import { DecisionNotificationService } from '../agenda/services/decision-notification.service';
|
||||
import { NovuModule } from '~/infrastructure/novu/novu.module';
|
||||
import { MeetDomainModule } from '~/domain/meet/meet-domain.module';
|
||||
import { UserDomainModule } from '~/domain/user/user-domain.module';
|
||||
import { UserInfrastructureModule } from '~/infrastructure/user/user-infrastructure.module';
|
||||
@@ -20,7 +19,6 @@ import { DocumentDomainModule } from '~/domain/document/document.module';
|
||||
DocumentModule,
|
||||
DocumentDomainModule,
|
||||
BlockchainModule,
|
||||
NovuModule,
|
||||
MeetDomainModule,
|
||||
UserInfrastructureModule,
|
||||
UserDomainModule,
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ import { WebPushChannelAdapter } from './channels/web-push-channel.adapter';
|
||||
*
|
||||
* `@Global` — порт cross-cutting: ~12 consumer-модулей (agenda/wallet/gateway/meet/
|
||||
* participant/chairman/chatcoop/…) инжектят `NOTIFICATION_PORT` без импорта этого
|
||||
* модуля у себя (эпик 4, drop-in миграция с `NOVU_WORKFLOW_PORT`). Ср. глобальный
|
||||
* модуля у себя (эпик 4, drop-in миграция со старого порта уведомлений). Ср. глобальный
|
||||
* `NOTIFICATION_SUBSCRIPTION_PORT` из typeorm.module.
|
||||
*/
|
||||
@Global()
|
||||
|
||||
-78
@@ -1,78 +0,0 @@
|
||||
import { Controller, Post, Body, Headers, Logger, HttpCode, HttpStatus, BadRequestException } from '@nestjs/common';
|
||||
import { NotificationWebhookService } from '../services/notification-webhook.service';
|
||||
import type { WebhookPayloadDomainInterface } from '~/domain/notification/interfaces/webhook-payload-domain.interface';
|
||||
import { WebhookValidationUtil } from '~/utils/webhook-validation.util';
|
||||
import config from '~/config/config';
|
||||
|
||||
/**
|
||||
* REST контроллер для обработки webhook'ов от NOVU Push Webhook
|
||||
*/
|
||||
@Controller('notifications/webhook')
|
||||
export class NotificationWebhookController {
|
||||
private readonly logger = new Logger(NotificationWebhookController.name);
|
||||
private readonly secretKey: string;
|
||||
|
||||
constructor(private readonly notificationWebhookService: NotificationWebhookService) {
|
||||
// Получаем секретный ключ из конфигурации
|
||||
this.secretKey = config.novu.webhook_secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка webhook'а от NOVU Push Webhook
|
||||
* @param payload Данные webhook'а
|
||||
* @param novuSignature Подпись от NOVU
|
||||
* @returns Promise<{ success: boolean; message?: string }>
|
||||
*/
|
||||
@Post('push')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async handlePushWebhook(
|
||||
@Body() payload: WebhookPayloadDomainInterface,
|
||||
@Headers('x-novu-signature') novuSignature: string
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
this.logger.log('Получен webhook от NOVU Push Webhook');
|
||||
this.logger.debug('Payload:', JSON.stringify(payload, null, 2));
|
||||
|
||||
try {
|
||||
// Валидируем подпись HMAC
|
||||
this.validateHmacSignature(payload, novuSignature);
|
||||
|
||||
// Обрабатываем webhook
|
||||
const result = await this.notificationWebhookService.processWebhook(payload);
|
||||
|
||||
this.logger.log('Webhook успешно обработан');
|
||||
return {
|
||||
success: true,
|
||||
message: 'Webhook processed successfully',
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Ошибка обработки webhook: ${error.message}`, error.stack);
|
||||
|
||||
if (error.message.includes('Invalid signature')) {
|
||||
throw new BadRequestException('Invalid webhook signature');
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Валидация HMAC подписи webhook'а
|
||||
* @param payload Данные webhook'а
|
||||
* @param novuSignature Подпись от NOVU
|
||||
* @returns void
|
||||
*/
|
||||
private validateHmacSignature(payload: WebhookPayloadDomainInterface, novuSignature: string): void {
|
||||
if (!novuSignature) {
|
||||
throw new Error('Missing x-novu-signature header');
|
||||
}
|
||||
// Валидируем подпись с помощью утилиты
|
||||
const isValid = WebhookValidationUtil.validateHmacSignatureSafe(payload, novuSignature, this.secretKey);
|
||||
|
||||
if (!isValid) {
|
||||
this.logger.warn('Валидация подписи webhook не удалась');
|
||||
throw new Error('Invalid signature');
|
||||
}
|
||||
|
||||
this.logger.debug('Валидация подписи webhook успешна');
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -132,7 +132,7 @@ export class NotificationInteractor {
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправить уведомление пользователю через NOVU workflow
|
||||
* Отправить уведомление пользователю
|
||||
* Доменная логика проверяет наличие активных подписок
|
||||
* @param username Username пользователя
|
||||
* @param workflowName Имя воркфлоу
|
||||
@@ -163,7 +163,7 @@ export class NotificationInteractor {
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправить уведомление нескольким пользователям через NOVU workflow
|
||||
* Отправить уведомление нескольким пользователям
|
||||
* Доменная логика проверяет наличие активных подписок для каждого пользователя
|
||||
* @param usernames Массив username пользователей
|
||||
* @param workflowName Имя воркфлоу
|
||||
@@ -203,7 +203,7 @@ export class NotificationInteractor {
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправить уведомление всем пользователям через NOVU workflow
|
||||
* Отправить уведомление всем пользователям
|
||||
* Доменная логика проверяет наличие активных подписок
|
||||
* @param workflowName Имя воркфлоу
|
||||
* @param payload Данные уведомления
|
||||
|
||||
@@ -5,30 +5,20 @@ import { SubscriptionService } from './services/subscription.service';
|
||||
import { NotificationService } from './services/notification.service';
|
||||
import { NotificationInteractor } from './interactors/notification.interactor';
|
||||
import { CleanupService } from './services/cleanup.service';
|
||||
import { NotificationWebhookController } from './controllers/notification-webhook.controller';
|
||||
import { NotificationWebhookService } from './services/notification-webhook.service';
|
||||
import { DeviceTokenService } from './services/device-token.service';
|
||||
import { NotificationSenderService } from './services/notification-sender.service';
|
||||
import { NotificationEventService } from './services/notification-event.service';
|
||||
import { WebPushService } from './services/web-push.service';
|
||||
import { NotificationDomainModule } from '~/domain/notification/notification-domain.module';
|
||||
import { AccountDomainModule } from '~/domain/account/account-domain.module';
|
||||
import { UserDomainModule } from '~/domain/user/user-domain.module';
|
||||
import { NovuCredentialsAdapter } from '~/infrastructure/novu/novu-credentials.adapter';
|
||||
import { NovuWorkflowAdapter } from '~/infrastructure/novu/novu-workflow.adapter';
|
||||
import { NovuAdapter } from '~/infrastructure/novu/novu.adapter';
|
||||
import { NOVU_CREDENTIALS_PORT } from '~/domain/notification/interfaces/novu-credentials.port';
|
||||
import { NOVU_WORKFLOW_PORT } from '~/domain/notification/interfaces/novu-workflow.port';
|
||||
import { NOTIFICATION_PORT } from '~/domain/notification/interfaces/notification.port';
|
||||
|
||||
/**
|
||||
* Модуль приложения для управления уведомлениями
|
||||
* Включает веб-пуш подписки, webhook обработку и NOVU интеграцию
|
||||
* Модуль приложения для управления уведомлениями.
|
||||
* Веб-пуш подписки (web_push_subscriptions), резолверы и фасады отправки.
|
||||
* Доставка по каналам — Центр уведомлений (NotificationCenterModule).
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [NotificationDomainModule, AccountDomainModule, UserDomainModule],
|
||||
controllers: [NotificationWebhookController],
|
||||
imports: [AccountDomainModule, UserDomainModule],
|
||||
providers: [
|
||||
SubscriptionResolver,
|
||||
NotificationResolver,
|
||||
@@ -36,36 +26,16 @@ import { NOTIFICATION_PORT } from '~/domain/notification/interfaces/notification
|
||||
NotificationService,
|
||||
NotificationInteractor,
|
||||
CleanupService,
|
||||
NotificationWebhookService,
|
||||
DeviceTokenService,
|
||||
NotificationSenderService,
|
||||
NotificationEventService,
|
||||
WebPushService,
|
||||
// Биндинги портов к адаптерам
|
||||
{
|
||||
provide: NOTIFICATION_PORT,
|
||||
useClass: NovuAdapter,
|
||||
},
|
||||
{
|
||||
provide: NOVU_CREDENTIALS_PORT,
|
||||
useClass: NovuCredentialsAdapter,
|
||||
},
|
||||
{
|
||||
provide: NOVU_WORKFLOW_PORT,
|
||||
useClass: NovuWorkflowAdapter,
|
||||
},
|
||||
],
|
||||
exports: [
|
||||
CleanupService,
|
||||
NotificationWebhookService,
|
||||
NotificationService,
|
||||
DeviceTokenService,
|
||||
NotificationSenderService,
|
||||
WebPushService,
|
||||
NotificationInteractor,
|
||||
NOTIFICATION_PORT,
|
||||
NOVU_CREDENTIALS_PORT,
|
||||
NOVU_WORKFLOW_PORT,
|
||||
],
|
||||
})
|
||||
export class NotificationModule {}
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ import { RolesGuard } from '~/application/auth/guards/roles.guard';
|
||||
import { AuthRoles } from '~/application/auth/decorators/auth.decorator';
|
||||
|
||||
/**
|
||||
* Резолвер для управления уведомлениями через Novu
|
||||
* Резолвер для управления уведомлениями и подписками
|
||||
*/
|
||||
@Resolver()
|
||||
export class NotificationResolver {
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { NOVU_CREDENTIALS_PORT, type NovuCredentialsPort } from '~/domain/notification/interfaces/novu-credentials.port';
|
||||
import {
|
||||
CreateDeviceTokenDomainInterface,
|
||||
DeviceTokenDomainInterface,
|
||||
NotificationProviderEnum,
|
||||
} from '~/domain/notification/interfaces/device-token-domain.interface';
|
||||
import type { UpdateCredentialsDomainInterface } from '~/domain/notification/interfaces/device-token-domain.interface';
|
||||
import { generateHashFromString } from '~/utils/generate-hash.util';
|
||||
import { NotificationInteractor } from '../interactors/notification.interactor';
|
||||
import type { WebPushSubscriptionDto } from '~/application/notification/dto/web-push-subscription.dto';
|
||||
import { ACCOUNT_DOMAIN_SERVICE, AccountDomainService } from '~/domain/account/services/account-domain.service';
|
||||
|
||||
/**
|
||||
* Сервис управления device tokens для push уведомлений
|
||||
* Синхронизирует device tokens с веб-пуш подписками в NOVU
|
||||
*/
|
||||
@Injectable()
|
||||
export class DeviceTokenService {
|
||||
private readonly logger = new Logger(DeviceTokenService.name);
|
||||
private readonly pushWebhookProviderId: NotificationProviderEnum;
|
||||
|
||||
constructor(
|
||||
@Inject(NOVU_CREDENTIALS_PORT)
|
||||
private readonly novuCredentialsPort: NovuCredentialsPort,
|
||||
private readonly notificationInteractor: NotificationInteractor,
|
||||
@Inject(ACCOUNT_DOMAIN_SERVICE)
|
||||
private readonly accountDomainService: AccountDomainService
|
||||
) {
|
||||
// Используем PUSH_WEBHOOK провайдер для веб-пуш уведомлений
|
||||
this.pushWebhookProviderId = NotificationProviderEnum.PUSH_WEBHOOK;
|
||||
}
|
||||
|
||||
/**
|
||||
* Создать device token для пользователя на основе веб-пуш подписки
|
||||
* @param username Username пользователя
|
||||
* @param subscription Веб-пуш подписка
|
||||
* @returns Promise<DeviceTokenDomainInterface>
|
||||
*/
|
||||
async createDeviceTokenFromSubscription(
|
||||
username: string,
|
||||
subscription: WebPushSubscriptionDto
|
||||
): Promise<DeviceTokenDomainInterface> {
|
||||
this.logger.log(`Создание device token для пользователя: ${username}`);
|
||||
|
||||
// Получаем аккаунт пользователя для получения правильного subscriber_id
|
||||
const account = await this.accountDomainService.getAccount(username);
|
||||
|
||||
if (!account.provider_account?.subscriber_id) {
|
||||
throw new Error(`Не найден subscriber_id для пользователя ${username}`);
|
||||
}
|
||||
|
||||
// Генерируем уникальный device token на основе endpoint подписки
|
||||
const deviceToken = this.generateDeviceTokenFromEndpoint(subscription.endpoint);
|
||||
|
||||
const createDeviceTokenData: CreateDeviceTokenDomainInterface = {
|
||||
token: deviceToken,
|
||||
username,
|
||||
providerId: this.pushWebhookProviderId,
|
||||
integrationIdentifier: this.generateIntegrationIdentifier(),
|
||||
deviceInfo: {
|
||||
endpoint: subscription.endpoint,
|
||||
userAgent: subscription.userAgent,
|
||||
// Можно добавить больше информации из subscription
|
||||
},
|
||||
};
|
||||
|
||||
// Создаем device token в домене
|
||||
const deviceTokenDomain: DeviceTokenDomainInterface = {
|
||||
...createDeviceTokenData,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
// Обновляем credentials в NOVU с правильным subscriber_id
|
||||
await this.updateNovuCredentials(account.provider_account.subscriber_id, [deviceToken]);
|
||||
|
||||
this.logger.log(`Device token создан для пользователя: ${account.provider_account.subscriber_id}`);
|
||||
return deviceTokenDomain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновить device tokens для пользователя
|
||||
* @param subscriberId Subscriber ID пользователя
|
||||
* @param deviceTokens Список device tokens
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
async updateDeviceTokensForUser(subscriberId: string, deviceTokens: string[]): Promise<void> {
|
||||
this.logger.log(`Обновление device tokens для пользователя: ${subscriberId}, токенов: ${deviceTokens.length}`);
|
||||
|
||||
await this.updateNovuCredentials(subscriberId, deviceTokens);
|
||||
|
||||
this.logger.log(`Device tokens обновлены для пользователя: ${subscriberId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавить device token для пользователя
|
||||
* @param username Username пользователя
|
||||
* @param deviceToken Device token для добавления
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
async addDeviceTokenForUser(username: string, deviceToken: string): Promise<void> {
|
||||
this.logger.log(`Добавление device token для пользователя: ${username}`);
|
||||
|
||||
// Получаем аккаунт пользователя для получения правильного subscriber_id
|
||||
const account = await this.accountDomainService.getAccount(username);
|
||||
|
||||
if (!account.provider_account?.subscriber_id) {
|
||||
throw new Error(`Не найден subscriber_id для пользователя ${username}`);
|
||||
}
|
||||
|
||||
await this.novuCredentialsPort.addDeviceToken(
|
||||
account.provider_account.subscriber_id,
|
||||
this.pushWebhookProviderId,
|
||||
deviceToken,
|
||||
this.generateIntegrationIdentifier()
|
||||
);
|
||||
|
||||
this.logger.log(`Device token добавлен для пользователя: ${account.provider_account.subscriber_id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Удалить device token для пользователя
|
||||
* @param username Username пользователя
|
||||
* @param deviceToken Device token для удаления
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
async removeDeviceTokenForUser(username: string, deviceToken: string): Promise<void> {
|
||||
this.logger.log(`Удаление device token для пользователя: ${username}`);
|
||||
|
||||
// Получаем аккаунт пользователя для получения правильного subscriber_id
|
||||
const account = await this.accountDomainService.getAccount(username);
|
||||
|
||||
if (!account.provider_account?.subscriber_id) {
|
||||
throw new Error(`Не найден subscriber_id для пользователя ${username}`);
|
||||
}
|
||||
|
||||
await this.novuCredentialsPort.removeDeviceToken(
|
||||
account.provider_account.subscriber_id,
|
||||
this.pushWebhookProviderId,
|
||||
deviceToken
|
||||
);
|
||||
|
||||
this.logger.log(`Device token удален для пользователя: ${account.provider_account.subscriber_id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить device tokens для пользователя
|
||||
* @param username Username пользователя
|
||||
* @returns Promise<DeviceTokenDomainInterface[]>
|
||||
*/
|
||||
async getUserDeviceTokens(username: string): Promise<DeviceTokenDomainInterface[]> {
|
||||
this.logger.debug(`Получение device tokens для пользователя: ${username}`);
|
||||
|
||||
// Получаем аккаунт пользователя для получения правильного subscriber_id
|
||||
const account = await this.accountDomainService.getAccount(username);
|
||||
|
||||
if (!account.provider_account?.subscriber_id) {
|
||||
throw new Error(`Не найден subscriber_id для пользователя ${username}`);
|
||||
}
|
||||
|
||||
const deviceTokens = await this.novuCredentialsPort.getSubscriberCredentials(
|
||||
account.provider_account.subscriber_id,
|
||||
this.pushWebhookProviderId
|
||||
);
|
||||
|
||||
return deviceTokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Синхронизировать device tokens с активными веб-пуш подписками
|
||||
* @param username Username пользователя
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
async syncDeviceTokensWithSubscriptions(username: string): Promise<void> {
|
||||
this.logger.log(`Синхронизация device tokens для пользователя: ${username}`);
|
||||
|
||||
try {
|
||||
// Получаем аккаунт пользователя для получения правильного subscriber_id
|
||||
const account = await this.accountDomainService.getAccount(username);
|
||||
|
||||
if (!account.provider_account?.subscriber_id) {
|
||||
throw new Error(`Не найден subscriber_id для пользователя ${username}`);
|
||||
}
|
||||
|
||||
// Получаем активные веб-пуш подписки пользователя через доменный интерактор
|
||||
const subscriptions = await this.notificationInteractor.getUserSubscriptions(username);
|
||||
|
||||
this.logger.log(`Найдено ${subscriptions.length} активных подписок для пользователя: ${username}`);
|
||||
|
||||
// Генерируем device tokens на основе активных подписок
|
||||
const deviceTokens = subscriptions.map((subscription) => this.generateDeviceTokenFromEndpoint(subscription.endpoint));
|
||||
|
||||
if (deviceTokens.length > 0) {
|
||||
// Обновляем device tokens в NOVU с правильным subscriber_id
|
||||
await this.updateDeviceTokensForUser(account.provider_account.subscriber_id, deviceTokens);
|
||||
this.logger.log(
|
||||
`Синхронизировано ${deviceTokens.length} device tokens для пользователя: ${account.provider_account.subscriber_id}`
|
||||
);
|
||||
} else {
|
||||
// Если нет активных подписок, очищаем device tokens
|
||||
this.logger.log(`Нет активных подписок для пользователя ${username}, очищаем device tokens в NOVU`);
|
||||
await this.novuCredentialsPort.deleteSubscriberCredentials(
|
||||
account.provider_account.subscriber_id,
|
||||
this.pushWebhookProviderId
|
||||
);
|
||||
this.logger.log(
|
||||
`Очищены device tokens для пользователя без активных подписок: ${account.provider_account.subscriber_id}`
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Ошибка синхронизации device tokens для пользователя ${username}: ${error.message}`, error.stack);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Очистить все device tokens для пользователя
|
||||
* @param username Username пользователя
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
async clearUserDeviceTokens(username: string): Promise<void> {
|
||||
this.logger.log(`Очистка device tokens для пользователя: ${username}`);
|
||||
|
||||
// Получаем аккаунт пользователя для получения правильного subscriber_id
|
||||
const account = await this.accountDomainService.getAccount(username);
|
||||
|
||||
if (!account.provider_account?.subscriber_id) {
|
||||
throw new Error(`Не найден subscriber_id для пользователя ${username}`);
|
||||
}
|
||||
|
||||
await this.novuCredentialsPort.deleteSubscriberCredentials(
|
||||
account.provider_account.subscriber_id,
|
||||
this.pushWebhookProviderId
|
||||
);
|
||||
|
||||
this.logger.log(`Device tokens очищены для пользователя: ${account.provider_account.subscriber_id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновить credentials в NOVU
|
||||
* @param subscriberId Subscriber ID пользователя
|
||||
* @param deviceTokens Список device tokens
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
private async updateNovuCredentials(subscriberId: string, deviceTokens: string[]): Promise<void> {
|
||||
const credentialsData: UpdateCredentialsDomainInterface = {
|
||||
subscriberId,
|
||||
providerId: this.pushWebhookProviderId,
|
||||
deviceTokens,
|
||||
integrationIdentifier: this.generateIntegrationIdentifier(),
|
||||
};
|
||||
|
||||
await this.novuCredentialsPort.updateSubscriberCredentials(credentialsData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Генерировать device token на основе endpoint веб-пуш подписки
|
||||
* @param endpoint Endpoint веб-пуш подписки
|
||||
* @returns string
|
||||
*/
|
||||
private generateDeviceTokenFromEndpoint(endpoint: string): string {
|
||||
// Для NOVU Push Webhook device token может быть любой строкой
|
||||
// Используем hash от endpoint для уникальности
|
||||
return generateHashFromString(endpoint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Генерировать идентификатор интеграции
|
||||
* @returns string
|
||||
*/
|
||||
private generateIntegrationIdentifier(): string {
|
||||
// Генерируем уникальный идентификатор интеграции
|
||||
return `push-webhook-${Date.now()}`;
|
||||
}
|
||||
}
|
||||
+7
-18
@@ -1,21 +1,15 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { NOTIFICATION_PORT, type NotificationPort } from '~/domain/notification/interfaces/notify.port';
|
||||
import type { NotifyResult } from '~/domain/notification/interfaces/notify-input.domain.interface';
|
||||
import { DeviceTokenService } from './device-token.service';
|
||||
import type { WorkflowActorDomainInterface } from '~/domain/notification/interfaces/workflow-trigger-domain.interface';
|
||||
import { ACCOUNT_DOMAIN_SERVICE, AccountDomainService } from '~/domain/account/services/account-domain.service';
|
||||
import {
|
||||
NOTIFICATION_DOMAIN_SERVICE,
|
||||
NotificationDomainService,
|
||||
} from '~/domain/notification/services/notification-domain.service';
|
||||
import config from '~/config/config';
|
||||
|
||||
/**
|
||||
* Сервис отправки уведомлений пользователям через Центр уведомлений (DC v3).
|
||||
*
|
||||
* Тонкая обёртка `username → notify()`: резолвит получателя из аккаунта и ставит
|
||||
* уведомление в очередь Центра. Broadcast/cancel/status (Novu-измы) убраны — не
|
||||
* использовались и не имеют аналога в локальной модели Центра.
|
||||
* уведомление в очередь Центра.
|
||||
*/
|
||||
@Injectable()
|
||||
export class NotificationSenderService {
|
||||
@@ -24,11 +18,8 @@ export class NotificationSenderService {
|
||||
constructor(
|
||||
@Inject(NOTIFICATION_PORT)
|
||||
private readonly notificationPort: NotificationPort,
|
||||
private readonly deviceTokenService: DeviceTokenService,
|
||||
@Inject(ACCOUNT_DOMAIN_SERVICE)
|
||||
private readonly accountDomainService: AccountDomainService,
|
||||
@Inject(NOTIFICATION_DOMAIN_SERVICE)
|
||||
private readonly notificationDomainService: NotificationDomainService
|
||||
private readonly accountDomainService: AccountDomainService
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -47,19 +38,17 @@ export class NotificationSenderService {
|
||||
this.logger.log(`Отправка уведомления пользователю: ${username}, тип: ${workflowName}`);
|
||||
|
||||
const account = await this.accountDomainService.getAccount(username);
|
||||
const recipient = this.notificationDomainService.buildWorkflowRecipientFromAccount(account);
|
||||
if (!recipient) {
|
||||
throw new Error(
|
||||
`Не удалось сформировать получателя для ${username}: нет subscriber_id или email в профиле`
|
||||
);
|
||||
const subscriberId = account.provider_account?.subscriber_id?.trim();
|
||||
if (!subscriberId) {
|
||||
throw new Error(`Не удалось сформировать получателя для ${username}: нет subscriber_id в профиле`);
|
||||
}
|
||||
|
||||
return this.notificationPort.notify({
|
||||
coopname: config.coopname,
|
||||
workflowId: workflowName,
|
||||
to: {
|
||||
subscriberId: recipient.subscriberId,
|
||||
email: recipient.email,
|
||||
subscriberId,
|
||||
email: account.provider_account?.email,
|
||||
username,
|
||||
},
|
||||
payload,
|
||||
|
||||
-176
@@ -1,176 +0,0 @@
|
||||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { SubscriptionService } from './subscription.service';
|
||||
import { WebPushService } from './web-push.service';
|
||||
import { DeviceTokenService } from './device-token.service';
|
||||
import { generateHashFromString } from '~/utils/generate-hash.util';
|
||||
import type {
|
||||
WebhookPayloadDomainInterface,
|
||||
WebhookProcessResultDomainInterface,
|
||||
} from '~/domain/notification/interfaces/webhook-payload-domain.interface';
|
||||
import type { NotificationPayloadDomainInterface } from '~/domain/notification/interfaces/notification-payload-domain.interface';
|
||||
import { USER_REPOSITORY, UserRepository } from '~/domain/user/repositories/user.repository';
|
||||
|
||||
/**
|
||||
* Сервис для обработки webhook'ов от NOVU
|
||||
* Обрабатывает входящие push уведомления и доставляет их клиентам
|
||||
*/
|
||||
@Injectable()
|
||||
export class NotificationWebhookService {
|
||||
private readonly logger = new Logger(NotificationWebhookService.name);
|
||||
|
||||
constructor(
|
||||
private readonly webPushSubscriptionService: SubscriptionService,
|
||||
private readonly webPushService: WebPushService,
|
||||
private readonly deviceTokenService: DeviceTokenService,
|
||||
@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Обработка webhook'а от NOVU Push Webhook
|
||||
* @param payload Данные webhook'а
|
||||
* @returns Promise<WebhookProcessResultDomainInterface>
|
||||
*/
|
||||
async processWebhook(payload: WebhookPayloadDomainInterface): Promise<WebhookProcessResultDomainInterface> {
|
||||
this.logger.log(`Обработка webhook: ${payload.title}`);
|
||||
this.logger.debug('Получатели:', payload.target);
|
||||
|
||||
const processedTokens: string[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
try {
|
||||
// Проходим по всем target токенам
|
||||
for (const deviceToken of payload.target) {
|
||||
try {
|
||||
await this.processDeviceToken(deviceToken, payload);
|
||||
processedTokens.push(deviceToken);
|
||||
this.logger.debug(`Токен обработан: ${deviceToken}`);
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Ошибка обработки токена ${deviceToken}: ${error.message}`, error.stack);
|
||||
errors.push(`Token ${deviceToken}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const result: WebhookProcessResultDomainInterface = {
|
||||
success: processedTokens.length > 0,
|
||||
message: `Обработано ${processedTokens.length} из ${payload.target.length} токенов`,
|
||||
processedTokens,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
};
|
||||
|
||||
this.logger.log(`Webhook обработан: ${result.message}`);
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Критическая ошибка обработки webhook: ${error.message}`, error.stack);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: `Критическая ошибка: ${error.message}`,
|
||||
processedTokens: [],
|
||||
errors: [error.message],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка конкретного device token
|
||||
* @param deviceToken Device token получателя
|
||||
* @param payload Данные webhook'а
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
private async processDeviceToken(deviceToken: string, payload: WebhookPayloadDomainInterface): Promise<void> {
|
||||
this.logger.debug(`Обработка device token: ${deviceToken}`);
|
||||
|
||||
// Извлекаем данные подписчика из payload
|
||||
const subscriberData = payload.payload?.subscriber;
|
||||
|
||||
if (!subscriberData) {
|
||||
this.logger.warn(`Нет данных подписчика для токена: ${deviceToken}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ищем пользователя по subscriber_id в БД
|
||||
// subscriberId содержит формат "coopname:random_string"
|
||||
this.logger.debug(`Поиск пользователя по subscriber_id: ${subscriberData.subscriberId}`);
|
||||
|
||||
const user = await this.userRepository.findBySubscriberId(subscriberData.subscriberId);
|
||||
|
||||
if (!user) {
|
||||
this.logger.warn(`Пользователь не найден по subscriber_id: ${subscriberData.subscriberId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const username = user.username;
|
||||
this.logger.debug(`Найден пользователь по subscriber_id: ${username}`);
|
||||
|
||||
// Создаем уведомление для отправки через веб-пуш
|
||||
const notificationPayload: NotificationPayloadDomainInterface = {
|
||||
title: payload.title,
|
||||
body: payload.content,
|
||||
data: {
|
||||
// Передаем дополнительные данные
|
||||
...payload.payload,
|
||||
source: 'novu-webhook',
|
||||
deviceToken,
|
||||
},
|
||||
// Дополнительные опции уведомления
|
||||
icon: payload.overrides?.data?.icon,
|
||||
badge: payload.overrides?.data?.badge,
|
||||
image: payload.overrides?.data?.image,
|
||||
url: payload.overrides?.data?.url,
|
||||
tag: payload.overrides?.data?.tag,
|
||||
requireInteraction: payload.overrides?.data?.requireInteraction,
|
||||
silent: payload.overrides?.data?.silent,
|
||||
actions: payload.overrides?.data?.actions,
|
||||
vibrate: payload.overrides?.data?.vibrate,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
this.logger.log(`Отправка уведомления пользователю: ${username} (subscriberId: ${subscriberData.subscriberId})`);
|
||||
|
||||
// Получаем активные подписки пользователя
|
||||
const subscriptions = await this.webPushSubscriptionService.getUserSubscriptions(username);
|
||||
|
||||
if (subscriptions.length === 0) {
|
||||
this.logger.warn(`Нет активных подписок для пользователя: ${username}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Отправляем уведомления на все активные подписки пользователя
|
||||
const sendResult = await this.webPushService.sendNotificationToMultiple(subscriptions, notificationPayload);
|
||||
|
||||
this.logger.log(
|
||||
`Результат отправки для пользователя ${username}: ` + `отправлено ${sendResult.sent}, ошибок ${sendResult.failed}`
|
||||
);
|
||||
|
||||
// Деактивируем недействительные подписки
|
||||
if (sendResult.invalidSubscriptions.length > 0) {
|
||||
// Получаем все активные подписки для поиска недействительных
|
||||
const allSubscriptions = await this.webPushSubscriptionService.getUserSubscriptions(username);
|
||||
|
||||
for (const subscriptionId of sendResult.invalidSubscriptions) {
|
||||
try {
|
||||
// Находим подписку по ID для получения endpoint
|
||||
const targetSubscription = allSubscriptions.find((sub) => sub.id === subscriptionId);
|
||||
|
||||
if (targetSubscription) {
|
||||
// Генерируем device token для этой подписки
|
||||
const deviceToken = generateHashFromString(targetSubscription.endpoint);
|
||||
|
||||
// Удаляем device token из NOVU перед деактивацией
|
||||
try {
|
||||
await this.deviceTokenService.removeDeviceTokenForUser(username, deviceToken);
|
||||
this.logger.log(`Device token удален для недействительной подписки: ${subscriptionId}`);
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Ошибка удаления device token для подписки ${subscriptionId}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
await this.webPushSubscriptionService.deactivateSubscriptionById(subscriptionId);
|
||||
this.logger.log(`Деактивирована недействительная подписка: ${subscriptionId}`);
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Ошибка деактивации подписки ${subscriptionId}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-50
@@ -5,17 +5,12 @@ import { WebPushSubscriptionDto } from '../dto/web-push-subscription.dto';
|
||||
import { CreateSubscriptionResponse } from '../dto/create-subscription-response.dto';
|
||||
import { SubscriptionStatsDto } from '../dto/subscription-stats.dto';
|
||||
import { CreateSubscriptionInput } from '../dto/create-subscription-input.dto';
|
||||
import { DeviceTokenService } from './device-token.service';
|
||||
import { generateHashFromString } from '~/utils/generate-hash.util';
|
||||
|
||||
@Injectable()
|
||||
export class SubscriptionService {
|
||||
private readonly logger = new Logger(SubscriptionService.name);
|
||||
|
||||
constructor(
|
||||
private readonly notificationInteractor: NotificationInteractor,
|
||||
private readonly deviceTokenService: DeviceTokenService
|
||||
) {}
|
||||
constructor(private readonly notificationInteractor: NotificationInteractor) {}
|
||||
|
||||
/**
|
||||
* Создать веб-пуш подписку
|
||||
@@ -28,18 +23,6 @@ export class SubscriptionService {
|
||||
const domainInput = inputInstance.toDomainInterface();
|
||||
const subscription = await this.notificationInteractor.createOrUpdateSubscription(domainInput);
|
||||
|
||||
// Синхронизируем device tokens с NOVU после создания подписки
|
||||
try {
|
||||
await this.deviceTokenService.syncDeviceTokensWithSubscriptions(input.username);
|
||||
this.logger.log(`Device tokens синхронизированы для пользователя: ${input.username}`);
|
||||
} catch (error: any) {
|
||||
this.logger.error(
|
||||
`Ошибка синхронизации device tokens для пользователя ${input.username}: ${error.message}`,
|
||||
error.stack
|
||||
);
|
||||
// Не прерываем выполнение, так как подписка уже создана
|
||||
}
|
||||
|
||||
const subscriptionDto = WebPushSubscriptionDto.fromDomainEntity(subscription);
|
||||
|
||||
return new CreateSubscriptionResponse({
|
||||
@@ -64,39 +47,7 @@ export class SubscriptionService {
|
||||
*/
|
||||
async deactivateSubscriptionById(subscriptionId: string): Promise<void> {
|
||||
this.logger.log(`Деактивация подписки с ID: ${subscriptionId}`);
|
||||
|
||||
// Получаем подписку перед деактивацией для получения username и endpoint
|
||||
const allSubscriptions = await this.notificationInteractor.getAllActiveSubscriptions();
|
||||
const targetSubscription = allSubscriptions.find((sub) => sub.id === subscriptionId);
|
||||
|
||||
if (targetSubscription) {
|
||||
// Генерируем device token для этой подписки (аналогично логике в DeviceTokenService)
|
||||
const deviceToken = generateHashFromString(targetSubscription.endpoint);
|
||||
|
||||
// Удаляем device token из NOVU перед деактивацией подписки
|
||||
try {
|
||||
await this.deviceTokenService.removeDeviceTokenForUser(targetSubscription.username, deviceToken);
|
||||
this.logger.log(`Device token удален для подписки: ${subscriptionId}`);
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Ошибка удаления device token для подписки ${subscriptionId}: ${error.message}`, error.stack);
|
||||
// Продолжаем выполнение, даже если удаление device token не удалось
|
||||
}
|
||||
}
|
||||
|
||||
await this.notificationInteractor.deactivateSubscriptionById(subscriptionId);
|
||||
|
||||
// Синхронизируем оставшиеся device tokens с NOVU после деактивации подписки
|
||||
if (targetSubscription) {
|
||||
try {
|
||||
await this.deviceTokenService.syncDeviceTokensWithSubscriptions(targetSubscription.username);
|
||||
this.logger.log(`Device tokens синхронизированы для пользователя: ${targetSubscription.username}`);
|
||||
} catch (error: any) {
|
||||
this.logger.error(
|
||||
`Ошибка синхронизации device tokens для пользователя ${targetSubscription.username}: ${error.message}`,
|
||||
error.stack
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+2
-7
@@ -21,10 +21,6 @@ import { ISignedDocumentDomainInterface } from '~/domain/document/interfaces/sig
|
||||
import { GatewayInteractorPort, GATEWAY_INTERACTOR_PORT } from '~/domain/wallet/ports/gateway-interactor.port';
|
||||
import type { CreateInitialPaymentInputDomainInterface } from '~/domain/gateway/interfaces/create-initial-payment-input-domain.interface';
|
||||
import { PaymentDomainEntity } from '~/domain/gateway/entities/payment-domain.entity';
|
||||
import {
|
||||
NOTIFICATION_DOMAIN_SERVICE,
|
||||
NotificationDomainService,
|
||||
} from '~/domain/notification/services/notification-domain.service';
|
||||
import { NotificationSenderService } from '~/application/notification/services/notification-sender.service';
|
||||
import { Workflows } from '@coopenomics/notifications';
|
||||
import {
|
||||
@@ -50,7 +46,6 @@ export class ParticipantInteractor {
|
||||
@Inject(CANDIDATE_REPOSITORY) private readonly candidateRepository: CandidateRepository,
|
||||
@Inject(GATEWAY_INTERACTOR_PORT)
|
||||
private readonly gatewayInteractorPort: GatewayInteractorPort,
|
||||
@Inject(NOTIFICATION_DOMAIN_SERVICE) private readonly notificationDomainService: NotificationDomainService,
|
||||
private readonly notificationSenderService: NotificationSenderService,
|
||||
@Inject(forwardRef(() => DOCUMENT_VALIDATION_SERVICE))
|
||||
private readonly documentValidationService: DocumentValidationService,
|
||||
@@ -300,11 +295,11 @@ export class ParticipantInteractor {
|
||||
|
||||
await this.accountDomainService.addProviderAccount(newAccount);
|
||||
|
||||
// Настраиваем подписчика NOVU для участника
|
||||
// Настраиваем identity получателя для участника
|
||||
try {
|
||||
await this.accountDomainService.setupNotificationSubscriber(data.username, 'участника');
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Ошибка настройки подписчика NOVU для участника ${data.username}: ${error.message}`, error.stack);
|
||||
this.logger.error(`Ошибка настройки identity получателя для участника ${data.username}: ${error.message}`, error.stack);
|
||||
}
|
||||
|
||||
await this.accountDomainService.addParticipantAccount({
|
||||
|
||||
@@ -3,11 +3,9 @@ import { ParticipantResolver } from './resolvers/participant.resolver';
|
||||
import { ParticipantService } from './services/participant.service';
|
||||
import { ParticipantNotificationService } from './services/participant-notification.service';
|
||||
import { ParticipantInteractor } from './interactors/participant.interactor';
|
||||
import { NovuModule } from '~/infrastructure/novu/novu.module';
|
||||
import { DocumentDomainModule } from '~/domain/document/document.module';
|
||||
import { AccountDomainModule } from '~/domain/account/account-domain.module';
|
||||
import { GatewayDomainModule } from '~/domain/gateway/gateway-domain.module';
|
||||
import { NotificationDomainModule } from '~/domain/notification/notification-domain.module';
|
||||
import { NotificationModule } from '~/application/notification/notification.module';
|
||||
import { TokenApplicationModule } from '~/application/token/token-application.module';
|
||||
import { UserDomainModule } from '~/domain/user/user-domain.module';
|
||||
@@ -17,12 +15,10 @@ import { AccountInfrastructureModule } from '~/infrastructure/account/account-in
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
NovuModule,
|
||||
DocumentDomainModule,
|
||||
AccountDomainModule,
|
||||
GatewayDomainModule,
|
||||
GatewayInfrastructureModule,
|
||||
NotificationDomainModule,
|
||||
NotificationModule,
|
||||
TokenApplicationModule,
|
||||
UserDomainModule,
|
||||
|
||||
+2
-2
@@ -36,7 +36,7 @@ export class ParticipantNotificationService implements OnModuleInit {
|
||||
const userEmail = user.provider_account?.email;
|
||||
const subscriberId = user.provider_account?.subscriber_id?.trim();
|
||||
if (!subscriberId) {
|
||||
this.logger.warn(`subscriber_id пользователя ${username} не найден — пропуск Novu`);
|
||||
this.logger.warn(`subscriber_id пользователя ${username} не найден`);
|
||||
return;
|
||||
}
|
||||
if (!userEmail) {
|
||||
@@ -92,7 +92,7 @@ export class ParticipantNotificationService implements OnModuleInit {
|
||||
const chairmanEmail = chairman.provider_account?.email;
|
||||
const chairmanSubscriberId = chairman.provider_account?.subscriber_id?.trim();
|
||||
if (!chairmanSubscriberId) {
|
||||
this.logger.warn(`subscriber_id председателя ${chairman.username} не найден — пропуск Novu`);
|
||||
this.logger.warn(`subscriber_id председателя ${chairman.username} не найден`);
|
||||
return;
|
||||
}
|
||||
if (!chairmanEmail) {
|
||||
|
||||
@@ -176,7 +176,7 @@ export class InstallInteractor {
|
||||
try {
|
||||
await this.accountDomainService.setupNotificationSubscriber(username, 'члена совета');
|
||||
} catch (error: any) {
|
||||
logger.error(`Ошибка настройки подписчика NOVU для члена совета ${username}: ${error.message}`, error.stack);
|
||||
logger.error(`Ошибка настройки identity получателя для члена совета ${username}: ${error.message}`, error.stack);
|
||||
}
|
||||
|
||||
// Добавляем в массив членов для отправки в блокчейн
|
||||
|
||||
+1
-1
@@ -47,7 +47,7 @@ export class WalletNotificationService implements OnModuleInit {
|
||||
const chairmanEmail = chairman.provider_account?.email;
|
||||
const chairmanSubscriberId = chairman.provider_account?.subscriber_id?.trim();
|
||||
if (!chairmanSubscriberId) {
|
||||
this.logger.warn(`subscriber_id председателя ${chairman.username} не найден — пропуск Novu`);
|
||||
this.logger.warn(`subscriber_id председателя ${chairman.username} не найден`);
|
||||
return;
|
||||
}
|
||||
if (!chairmanEmail) {
|
||||
|
||||
@@ -5,7 +5,6 @@ import { WalletInteractor } from './interactors/wallet.interactor';
|
||||
import { WalletNotificationService } from './services/wallet-notification.service';
|
||||
import { ProgramWalletSyncService } from './services/program-wallet-sync.service';
|
||||
import { ProgramWalletInitService } from './services/program-wallet-init.service';
|
||||
import { NovuModule } from '~/infrastructure/novu/novu.module';
|
||||
import { GatewayDomainModule } from '~/domain/gateway/gateway-domain.module';
|
||||
import { UserDomainModule } from '~/domain/user/user-domain.module';
|
||||
import { AccountInfrastructureModule } from '~/infrastructure/account/account-infrastructure.module';
|
||||
@@ -19,7 +18,6 @@ import { WALLET_DOMAIN_PORT } from '~/domain/wallet/ports/wallet-domain.port';
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
NovuModule,
|
||||
GatewayDomainModule,
|
||||
GatewayInfrastructureModule,
|
||||
UserInfrastructureModule,
|
||||
|
||||
@@ -25,8 +25,6 @@ const SCHEMA_GEN_ENV_DEFAULTS: Record<string, string> = {
|
||||
REDIS_PASSWORD: '',
|
||||
BLOCKCHAIN_RPC: 'http://127.0.0.1:8888',
|
||||
CHAIN_ID: 'cf057bbfb72640471fd910bcb67639c22df9f238706fab5919ce743a1f9efa38',
|
||||
NOVU_APP_ID: 'schema-gen',
|
||||
NOVU_API_KEY: 'schema-gen',
|
||||
VAPID_PUBLIC_KEY: 'BM_schema_gen_placeholder_public_key____________________________',
|
||||
VAPID_PRIVATE_KEY: 'schema_gen_placeholder_private_key',
|
||||
MATRIX_ADMIN_USERNAME: 'schema-gen',
|
||||
@@ -124,12 +122,6 @@ const envVarsSchema = z.object({
|
||||
.default('1000')
|
||||
.transform((val) => parseInt(val, 10)),
|
||||
|
||||
// Параметры NOVU
|
||||
NOVU_APP_ID: z.string().min(1, { message: 'Не должно быть пустым' }),
|
||||
NOVU_BACKEND_URL: z.string().min(1, { message: 'Не должно быть пустым' }).default('https://novu.coopenomics.world/api'),
|
||||
NOVU_SOCKET_URL: z.string().min(1, { message: 'Не должно быть пустым' }).default('https://novu.coopenomics.world/ws'),
|
||||
NOVU_API_KEY: z.string().min(1, { message: 'Не должно быть пустым' }),
|
||||
NOVU_WEBHOOK_SECRET: z.string().min(1, { message: 'Не должно быть пустым' }).default('default-webhook-secret'),
|
||||
|
||||
// Параметры VAPID для web push
|
||||
VAPID_PUBLIC_KEY: z.string().min(1, { message: 'VAPID_PUBLIC_KEY не должен быть пустым' }),
|
||||
@@ -280,13 +272,6 @@ export default {
|
||||
port: envVars.data.REDIS_PORT,
|
||||
password: envVars.data.REDIS_PASSWORD,
|
||||
},
|
||||
novu: {
|
||||
app_id: envVars.data.NOVU_APP_ID,
|
||||
backend_url: envVars.data.NOVU_BACKEND_URL,
|
||||
socket_url: envVars.data.NOVU_SOCKET_URL,
|
||||
api_key: envVars.data.NOVU_API_KEY,
|
||||
webhook_secret: envVars.data.NOVU_WEBHOOK_SECRET,
|
||||
},
|
||||
vapid: {
|
||||
public_key: envVars.data.VAPID_PUBLIC_KEY,
|
||||
private_key: envVars.data.VAPID_PRIVATE_KEY,
|
||||
|
||||
@@ -8,13 +8,12 @@ import {
|
||||
} from './services/notification-subscriber-sync.service';
|
||||
import { AccountRoleEventService } from './services/account-role-event.service';
|
||||
import { ParticipantStatusSyncService } from './services/participant-status-sync.service';
|
||||
import { NotificationDomainModule } from '~/domain/notification/notification-domain.module';
|
||||
import { TokenApplicationModule } from '~/application/token/token-application.module';
|
||||
import { UserDomainModule } from '~/domain/user/user-domain.module';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [NotificationDomainModule, TokenApplicationModule, UserDomainModule],
|
||||
imports: [TokenApplicationModule, UserDomainModule],
|
||||
providers: [
|
||||
AccountDomainService,
|
||||
NotificationSubscriberSyncService,
|
||||
|
||||
@@ -14,17 +14,13 @@ import { INDIVIDUAL_REPOSITORY, IndividualRepository } from '~/domain/common/rep
|
||||
import type { IndividualDomainInterface } from '~/domain/common/interfaces/individual-domain.interface';
|
||||
import type { OrganizationDomainInterface } from '~/domain/common/interfaces/organization-domain.interface';
|
||||
import type { EntrepreneurDomainInterface } from '~/domain/common/interfaces/entrepreneur-domain.interface';
|
||||
import { generateSubscriberHash } from '~/utils/novu.utils';
|
||||
import { generateSubscriberHash } from '~/utils/subscriber-hash.util';
|
||||
import { UserDomainService, USER_DOMAIN_SERVICE } from '~/domain/user/services/user-domain.service';
|
||||
import { HttpApiError } from '~/utils/httpApiError';
|
||||
import httpStatus from 'http-status';
|
||||
import { USER_REPOSITORY, UserRepository } from '~/domain/user/repositories/user.repository';
|
||||
import { randomUUID } from 'crypto';
|
||||
import type { Cooperative } from 'cooptypes';
|
||||
import {
|
||||
NOTIFICATION_DOMAIN_SERVICE,
|
||||
NotificationDomainService,
|
||||
} from '~/domain/notification/services/notification-domain.service';
|
||||
import { userStatus } from '~/types';
|
||||
import type { PrivateAccountDomainInterface } from '../interfaces/private-account-domain.interface';
|
||||
import { AccountType } from '~/application/account/enum/account-type.enum';
|
||||
@@ -39,7 +35,6 @@ export class AccountDomainService {
|
||||
@Inject(ORGANIZATION_REPOSITORY) private readonly organizationRepository: OrganizationRepository,
|
||||
@Inject(INDIVIDUAL_REPOSITORY) private readonly individualRepository: IndividualRepository,
|
||||
@Inject(ENTREPRENEUR_REPOSITORY) private readonly entrepreneurRepository: EntrepreneurRepository,
|
||||
@Inject(NOTIFICATION_DOMAIN_SERVICE) private readonly notificationDomainService: NotificationDomainService,
|
||||
@Inject(GENERATOR_PORT) private readonly generatorPort: GeneratorPort,
|
||||
@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository,
|
||||
@Inject(USER_DOMAIN_SERVICE) private readonly userDomainService: UserDomainService
|
||||
@@ -121,44 +116,26 @@ export class AccountDomainService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Настраивает подписчика уведомлений для пользователя
|
||||
* Генерирует subscriber_id и subscriber_hash, обновляет пользователя и создает подписчика NOVU
|
||||
* Настраивает identity получателя уведомлений: генерирует immutable `subscriber_id`
|
||||
* (адресация Центра уведомлений) и `subscriber_hash`, сохраняет в профиль пользователя.
|
||||
* @param username Имя пользователя
|
||||
* @param context Контекст для логирования (например, "регистрации", "обновления")
|
||||
*/
|
||||
async setupNotificationSubscriber(username: string, context = 'пользователя'): Promise<void> {
|
||||
this.logger.log(`Настройка подписчика уведомлений для ${context} ${username}`);
|
||||
this.logger.log(`Настройка identity получателя уведомлений для ${context} ${username}`);
|
||||
|
||||
try {
|
||||
// Генерируем subscriber_id и subscriber_hash для NOVU
|
||||
const subscriberId = await this.userDomainService.generateSubscriberId(config.coopname);
|
||||
const subscriberHash = generateSubscriberHash(subscriberId);
|
||||
|
||||
// Обновляем пользователя с subscriber данными
|
||||
await this.userRepository.updateByUsername(username, {
|
||||
subscriber_id: subscriberId,
|
||||
subscriber_hash: subscriberHash,
|
||||
});
|
||||
|
||||
const account = await this.getAccount(username);
|
||||
// HTTP к Novu не должен задерживать ответ API: синхронизация подписчика — в фоне
|
||||
void this.notificationDomainService
|
||||
.createSubscriberFromAccount(account)
|
||||
.then(() => {
|
||||
this.logger.log(`Подписчик NOVU успешно создан для ${context} ${username}`);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const stack = error instanceof Error ? error.stack : undefined;
|
||||
this.logger.error(
|
||||
`Ошибка создания подписчика NOVU для ${context} ${username}: ${message}`,
|
||||
stack,
|
||||
);
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const stack = error instanceof Error ? error.stack : undefined;
|
||||
this.logger.error(`Ошибка настройки подписчика NOVU для ${context} ${username}: ${message}`, stack);
|
||||
this.logger.error(`Ошибка настройки identity получателя для ${context} ${username}: ${message}`, stack);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
+12
-39
@@ -1,32 +1,30 @@
|
||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy, Inject } from '@nestjs/common';
|
||||
import cron from 'node-cron';
|
||||
import { generateSubscriberHash } from '~/utils/novu.utils';
|
||||
import { generateSubscriberHash } from '~/utils/subscriber-hash.util';
|
||||
import config from '~/config/config';
|
||||
import { NotificationDomainService } from '~/domain/notification/services/notification-domain.service';
|
||||
import { AccountDomainService } from './account-domain.service';
|
||||
import { USER_DOMAIN_SERVICE, UserDomainService } from '~/domain/user/services/user-domain.service';
|
||||
|
||||
const NOVU_CREATE_SUBSCRIBER_TIMEOUT_MS = 20_000;
|
||||
|
||||
/**
|
||||
* Backfill identity получателя уведомлений: догенерирует `subscriber_id`/`subscriber_hash`
|
||||
* пользователям, у которых их нет (создание аккаунта ставит их в обычном потоке;
|
||||
* этот cron — страховка для legacy/сбойных профилей). `subscriber_id` — immutable
|
||||
* адресация Центра уведомлений; внешней синхронизации подписчиков больше нет.
|
||||
*/
|
||||
@Injectable()
|
||||
export class NotificationSubscriberSyncService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(NotificationSubscriberSyncService.name);
|
||||
private isProcessing = false;
|
||||
private cronJob: cron.ScheduledTask | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly notificationDomainService: NotificationDomainService,
|
||||
private readonly accountDomainService: AccountDomainService,
|
||||
@Inject(USER_DOMAIN_SERVICE) private readonly userDomainService: UserDomainService
|
||||
) {}
|
||||
constructor(@Inject(USER_DOMAIN_SERVICE) private readonly userDomainService: UserDomainService) {}
|
||||
|
||||
onModuleInit() {
|
||||
// Синхронизация подписчиков каждые 30 минут
|
||||
// Backfill identity каждые 30 минут
|
||||
this.cronJob = cron.schedule('*/30 * * * *', async () => {
|
||||
await this.syncNotificationSubscribers();
|
||||
});
|
||||
|
||||
this.logger.log('node-cron задача для синхронизации подписчиков уведомлений запущена (каждые 30 минут)');
|
||||
this.logger.log('node-cron backfill subscriber_id запущен (каждые 30 минут)');
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
@@ -83,45 +81,20 @@ export class NotificationSubscriberSyncService implements OnModuleInit, OnModule
|
||||
}
|
||||
|
||||
/**
|
||||
* Настраивает подписчика для конкретного пользователя
|
||||
* Генерирует и сохраняет identity получателя (`subscriber_id`/`subscriber_hash`).
|
||||
* @param username Имя пользователя
|
||||
*/
|
||||
private async setupSubscriberForUser(username: string): Promise<void> {
|
||||
try {
|
||||
// Генерируем subscriber_id и subscriber_hash
|
||||
const subscriberId = await this.userDomainService.generateSubscriberId(config.coopname);
|
||||
const subscriberHash = generateSubscriberHash(subscriberId);
|
||||
|
||||
// Обновляем пользователя с subscriber данными
|
||||
await this.userDomainService.updateUserByUsername(username, {
|
||||
subscriber_id: subscriberId,
|
||||
subscriber_hash: subscriberHash,
|
||||
});
|
||||
|
||||
const account = await this.accountDomainService.getAccount(username);
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
this.notificationDomainService.createSubscriberFromAccount(account),
|
||||
new Promise<never>((_, reject) => {
|
||||
setTimeout(
|
||||
() => reject(new Error('NOVU_CREATE_TIMEOUT')),
|
||||
NOVU_CREATE_SUBSCRIBER_TIMEOUT_MS,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message === 'NOVU_CREATE_TIMEOUT') {
|
||||
this.logger.warn(
|
||||
`Novu: таймаут ${NOVU_CREATE_SUBSCRIBER_TIMEOUT_MS} мс при createSubscriber для ${username}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Не удалось настроить подписчика для пользователя ${username}: ${error.message}`);
|
||||
this.logger.error(`Не удалось настроить identity получателя для ${username}: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { ProviderDomainService } from './provider-domain.service';
|
||||
import { AccountDomainModule } from '~/domain/account/account-domain.module';
|
||||
import { AccountModule } from '~/application/account/account.module';
|
||||
import { AccountInteractor } from '~/application/account/interactors/account.interactor';
|
||||
import { NotificationDomainModule } from '~/domain/notification/notification-domain.module';
|
||||
import { UserDomainModule } from '~/domain/user/user-domain.module';
|
||||
import { TokenApplicationModule } from '~/application/token/token-application.module';
|
||||
import { EventsInfrastructureModule } from '~/infrastructure/events/events.module';
|
||||
@@ -15,7 +14,6 @@ import { GatewayInfrastructureModule } from '~/infrastructure/gateway/gateway-in
|
||||
imports: [
|
||||
AccountDomainModule,
|
||||
forwardRef(() => AccountModule),
|
||||
NotificationDomainModule,
|
||||
forwardRef(() => UserDomainModule),
|
||||
forwardRef(() => TokenApplicationModule),
|
||||
EventsInfrastructureModule,
|
||||
|
||||
-140
@@ -1,140 +0,0 @@
|
||||
/**
|
||||
* Enum для типов провайдеров уведомлений
|
||||
* Доменный enum, независимый от NOVU
|
||||
*/
|
||||
export enum NotificationProviderEnum {
|
||||
SLACK = 'slack',
|
||||
DISCORD = 'discord',
|
||||
MSTEAMS = 'msteams',
|
||||
MATTERMOST = 'mattermost',
|
||||
RYVER = 'ryver',
|
||||
ZULIP = 'zulip',
|
||||
GRAFANA_ON_CALL = 'grafana-on-call',
|
||||
GETSTREAM = 'getstream',
|
||||
ROCKET_CHAT = 'rocket-chat',
|
||||
WHATSAPP_BUSINESS = 'whatsapp-business',
|
||||
FCM = 'fcm',
|
||||
APNS = 'apns',
|
||||
EXPO = 'expo',
|
||||
ONE_SIGNAL = 'one-signal',
|
||||
PUSHPAD = 'pushpad',
|
||||
PUSH_WEBHOOK = 'push-webhook',
|
||||
PUSHER_BEAMS = 'pusher-beams',
|
||||
}
|
||||
|
||||
/**
|
||||
* Доменный интерфейс для device token
|
||||
* Представляет токен устройства для push уведомлений в NOVU
|
||||
*/
|
||||
export interface DeviceTokenDomainInterface {
|
||||
/**
|
||||
* Уникальный идентификатор токена устройства
|
||||
* Для NOVU Push Webhook это может быть любая строка
|
||||
*/
|
||||
token: string;
|
||||
|
||||
/**
|
||||
* Username пользователя (связь с веб-пуш подпиской)
|
||||
*/
|
||||
username: string;
|
||||
|
||||
/**
|
||||
* Тип провайдера для токена
|
||||
*/
|
||||
providerId: NotificationProviderEnum;
|
||||
|
||||
/**
|
||||
* Дополнительные данные для интеграции
|
||||
*/
|
||||
integrationIdentifier?: string;
|
||||
|
||||
/**
|
||||
* Метаданные устройства
|
||||
*/
|
||||
deviceInfo?: {
|
||||
userAgent?: string;
|
||||
platform?: string;
|
||||
browser?: string;
|
||||
endpoint?: string; // endpoint веб-пуш подписки
|
||||
};
|
||||
|
||||
/**
|
||||
* Дата создания токена
|
||||
*/
|
||||
createdAt: Date;
|
||||
|
||||
/**
|
||||
* Дата последнего обновления токена
|
||||
*/
|
||||
updatedAt: Date;
|
||||
|
||||
/**
|
||||
* Активен ли токен
|
||||
*/
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Интерфейс для создания нового device token
|
||||
*/
|
||||
export interface CreateDeviceTokenDomainInterface {
|
||||
/**
|
||||
* Уникальный идентификатор токена устройства
|
||||
*/
|
||||
token: string;
|
||||
|
||||
/**
|
||||
* Username пользователя
|
||||
*/
|
||||
username: string;
|
||||
|
||||
/**
|
||||
* Тип провайдера для токена
|
||||
*/
|
||||
providerId: NotificationProviderEnum;
|
||||
|
||||
/**
|
||||
* Дополнительные данные для интеграции
|
||||
*/
|
||||
integrationIdentifier?: string;
|
||||
|
||||
/**
|
||||
* Метаданные устройства
|
||||
*/
|
||||
deviceInfo?: {
|
||||
userAgent?: string;
|
||||
platform?: string;
|
||||
browser?: string;
|
||||
endpoint?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Интерфейс для обновления credentials в NOVU
|
||||
*/
|
||||
export interface UpdateCredentialsDomainInterface {
|
||||
/**
|
||||
* ID подписчика в NOVU
|
||||
*/
|
||||
subscriberId: string;
|
||||
|
||||
/**
|
||||
* ID провайдера
|
||||
*/
|
||||
providerId: NotificationProviderEnum;
|
||||
|
||||
/**
|
||||
* Массив device tokens для подписчика
|
||||
*/
|
||||
deviceTokens: string[];
|
||||
|
||||
/**
|
||||
* Дополнительные данные для credentials
|
||||
*/
|
||||
additionalData?: Record<string, any>;
|
||||
|
||||
/**
|
||||
* Идентификатор интеграции
|
||||
*/
|
||||
integrationIdentifier?: string;
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
export interface NotificationSubscriberData {
|
||||
subscriberId: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
locale?: string;
|
||||
timezone?: string;
|
||||
data?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface NotificationPort {
|
||||
/**
|
||||
* Создает подписчика в системе уведомлений
|
||||
* @param subscriber Данные подписчика
|
||||
*/
|
||||
createSubscriber(subscriber: NotificationSubscriberData): Promise<void>;
|
||||
|
||||
/**
|
||||
* Обновляет подписчика в системе уведомлений
|
||||
* @param subscriber Данные подписчика
|
||||
*/
|
||||
updateSubscriber(subscriber: NotificationSubscriberData): Promise<void>;
|
||||
|
||||
/**
|
||||
* Получает подписчика по ID
|
||||
* @param subscriberId ID подписчика
|
||||
*/
|
||||
getSubscriber(subscriberId: string): Promise<NotificationSubscriberData | null>;
|
||||
|
||||
/**
|
||||
* Удаляет подписчика
|
||||
* @param subscriberId ID подписчика
|
||||
*/
|
||||
deleteSubscriber(subscriberId: string): Promise<void>;
|
||||
}
|
||||
|
||||
export const NOTIFICATION_PORT = Symbol('NotificationPort');
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Доменные типы единого входа уведомлений (Центр уведомлений DC v3).
|
||||
*
|
||||
* Заменяет привязанный к Novu `WorkflowTriggerDomainInterface`: domain-сервис
|
||||
* Заменяет `WorkflowTriggerDomainInterface`: domain-сервис
|
||||
* описывает ЧТО и КОМУ отправить, не зная про каналы и провайдеров. Маршрутизацию
|
||||
* по каналам и доставку выполняет реализация {@link NotificationPort}.
|
||||
*/
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { NotifyInput, NotifyResult } from './notify-input.domain.interface'
|
||||
* пишет строки в `notification_outbox` (транзакционный outbox), фактическую
|
||||
* отправку по каналам выполняет фоновый worker.
|
||||
*
|
||||
* Заменяет `NovuWorkflowPort.triggerWorkflow`. Старый порт сохраняется до
|
||||
* Единственный порт постановки уведомлений в очередь доставки.
|
||||
* завершения миграции consumer-сервисов (эпик 4) и удаляется в эпике 5.
|
||||
*/
|
||||
export interface NotificationPort {
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import type {
|
||||
UpdateCredentialsDomainInterface,
|
||||
DeviceTokenDomainInterface,
|
||||
NotificationProviderEnum,
|
||||
} from './device-token-domain.interface';
|
||||
|
||||
/**
|
||||
* Порт для управления NOVU credentials
|
||||
* Определяет интерфейс для работы с device tokens подписчиков
|
||||
*/
|
||||
export interface NovuCredentialsPort {
|
||||
/**
|
||||
* Обновить credentials подписчика (добавить device tokens)
|
||||
* @param credentialsData Данные для обновления credentials
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
updateSubscriberCredentials(credentialsData: UpdateCredentialsDomainInterface): Promise<void>;
|
||||
|
||||
/**
|
||||
* Получить credentials подписчика
|
||||
* @param subscriberId ID подписчика
|
||||
* @param providerId ID провайдера
|
||||
* @returns Promise<DeviceTokenDomainInterface[]>
|
||||
*/
|
||||
getSubscriberCredentials(
|
||||
subscriberId: string,
|
||||
providerId: NotificationProviderEnum
|
||||
): Promise<DeviceTokenDomainInterface[]>;
|
||||
|
||||
/**
|
||||
* Удалить credentials подписчика для определенного провайдера
|
||||
* @param subscriberId ID подписчика
|
||||
* @param providerId ID провайдера
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
deleteSubscriberCredentials(subscriberId: string, providerId: NotificationProviderEnum): Promise<void>;
|
||||
|
||||
/**
|
||||
* Удалить конкретный device token из credentials подписчика
|
||||
* @param subscriberId ID подписчика
|
||||
* @param providerId ID провайдера
|
||||
* @param deviceToken Device token для удаления
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
removeDeviceToken(subscriberId: string, providerId: NotificationProviderEnum, deviceToken: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Добавить device token в credentials подписчика
|
||||
* @param subscriberId ID подписчика
|
||||
* @param providerId ID провайдера
|
||||
* @param deviceToken Device token для добавления
|
||||
* @param integrationIdentifier Идентификатор интеграции
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
addDeviceToken(
|
||||
subscriberId: string,
|
||||
providerId: NotificationProviderEnum,
|
||||
deviceToken: string,
|
||||
integrationIdentifier?: string
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Символ для инъекции зависимости
|
||||
*/
|
||||
export const NOVU_CREDENTIALS_PORT = Symbol('NovuCredentialsPort');
|
||||
@@ -1,36 +0,0 @@
|
||||
import type {
|
||||
WorkflowTriggerDomainInterface,
|
||||
WorkflowTriggerResultDomainInterface,
|
||||
} from './workflow-trigger-domain.interface';
|
||||
|
||||
/**
|
||||
* Порт для работы с NOVU workflow trigger
|
||||
* Определяет интерфейс для отправки уведомлений через воркфлоу
|
||||
*/
|
||||
export interface NovuWorkflowPort {
|
||||
/**
|
||||
* Запустить воркфлоу для отправки уведомления
|
||||
* @param triggerData Данные для запуска воркфлоу
|
||||
* @returns Promise<WorkflowTriggerResultDomainInterface>
|
||||
*/
|
||||
triggerWorkflow(triggerData: WorkflowTriggerDomainInterface): Promise<WorkflowTriggerResultDomainInterface>;
|
||||
|
||||
/**
|
||||
* Отменить триггер воркфлоу
|
||||
* @param transactionId ID транзакции для отмены
|
||||
* @returns Promise<boolean>
|
||||
*/
|
||||
cancelTriggeredWorkflow(transactionId: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Получить статус выполнения воркфлоу
|
||||
* @param transactionId ID транзакции
|
||||
* @returns Promise<any>
|
||||
*/
|
||||
getWorkflowStatus(transactionId: string): Promise<any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Символ для инъекции зависимости
|
||||
*/
|
||||
export const NOVU_WORKFLOW_PORT = Symbol('NovuWorkflowPort');
|
||||
-208
@@ -1,208 +0,0 @@
|
||||
/**
|
||||
* Доменный интерфейс для payload webhook от NOVU Push Webhook
|
||||
* Основан на документации NOVU Push Webhook
|
||||
*/
|
||||
export interface WebhookPayloadDomainInterface {
|
||||
/**
|
||||
* Массив target токенов получателей
|
||||
*/
|
||||
target: string[];
|
||||
|
||||
/**
|
||||
* Заголовок уведомления
|
||||
*/
|
||||
title: string;
|
||||
|
||||
/**
|
||||
* Содержание уведомления
|
||||
*/
|
||||
content: string;
|
||||
|
||||
/**
|
||||
* Дополнительные параметры для кастомизации
|
||||
*/
|
||||
overrides?: {
|
||||
data?: Record<string, any>;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
/**
|
||||
* Данные payload от воркфлоу
|
||||
*/
|
||||
payload?: WebhookPayloadDataDomainInterface;
|
||||
}
|
||||
|
||||
/**
|
||||
* Доменный интерфейс для данных payload в webhook
|
||||
*/
|
||||
export interface WebhookPayloadDataDomainInterface {
|
||||
/**
|
||||
* Источник воркфлоу
|
||||
*/
|
||||
__source?: string;
|
||||
|
||||
/**
|
||||
* Данные подписчика
|
||||
*/
|
||||
subscriber?: WebhookSubscriberDomainInterface;
|
||||
|
||||
/**
|
||||
* Данные шага воркфлоу
|
||||
*/
|
||||
step?: WebhookStepDomainInterface;
|
||||
|
||||
/**
|
||||
* Кастомные данные от воркфлоу
|
||||
*/
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Доменный интерфейс для подписчика в webhook
|
||||
*/
|
||||
export interface WebhookSubscriberDomainInterface {
|
||||
/**
|
||||
* ID подписчика в NOVU
|
||||
*/
|
||||
_id: string;
|
||||
|
||||
/**
|
||||
* ID организации
|
||||
*/
|
||||
_organizationId: string;
|
||||
|
||||
/**
|
||||
* ID окружения
|
||||
*/
|
||||
_environmentId: string;
|
||||
|
||||
/**
|
||||
* Имя
|
||||
*/
|
||||
firstName?: string;
|
||||
|
||||
/**
|
||||
* Фамилия
|
||||
*/
|
||||
lastName?: string;
|
||||
|
||||
/**
|
||||
* Телефон
|
||||
*/
|
||||
phone?: string;
|
||||
|
||||
/**
|
||||
* ID подписчика (username)
|
||||
*/
|
||||
subscriberId: string;
|
||||
|
||||
/**
|
||||
* Email
|
||||
*/
|
||||
email?: string;
|
||||
|
||||
/**
|
||||
* Каналы коммуникации
|
||||
*/
|
||||
channels?: WebhookChannelDomainInterface[];
|
||||
|
||||
/**
|
||||
* Дополнительные данные подписчика
|
||||
*/
|
||||
data?: Record<string, any>;
|
||||
|
||||
/**
|
||||
* Флаг удаления
|
||||
*/
|
||||
deleted: boolean;
|
||||
|
||||
/**
|
||||
* Дата создания
|
||||
*/
|
||||
createdAt: string;
|
||||
|
||||
/**
|
||||
* Дата обновления
|
||||
*/
|
||||
updatedAt: string;
|
||||
|
||||
/**
|
||||
* Версия документа
|
||||
*/
|
||||
__v: number;
|
||||
|
||||
/**
|
||||
* ID подписчика (дубликат _id)
|
||||
*/
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Доменный интерфейс для канала коммуникации в webhook
|
||||
*/
|
||||
export interface WebhookChannelDomainInterface {
|
||||
/**
|
||||
* Credentials для канала
|
||||
*/
|
||||
credentials: {
|
||||
/**
|
||||
* Device tokens для push уведомлений
|
||||
*/
|
||||
deviceTokens: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* ID интеграции
|
||||
*/
|
||||
_integrationId: string;
|
||||
|
||||
/**
|
||||
* ID провайдера
|
||||
*/
|
||||
providerId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Доменный интерфейс для шага воркфлоу в webhook
|
||||
*/
|
||||
export interface WebhookStepDomainInterface {
|
||||
/**
|
||||
* Флаг digest
|
||||
*/
|
||||
digest: boolean;
|
||||
|
||||
/**
|
||||
* События
|
||||
*/
|
||||
events: any[];
|
||||
|
||||
/**
|
||||
* Общее количество
|
||||
*/
|
||||
total_count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Доменный интерфейс для результата обработки webhook
|
||||
*/
|
||||
export interface WebhookProcessResultDomainInterface {
|
||||
/**
|
||||
* Успешно ли обработан webhook
|
||||
*/
|
||||
success: boolean;
|
||||
|
||||
/**
|
||||
* Сообщение результата
|
||||
*/
|
||||
message?: string;
|
||||
|
||||
/**
|
||||
* Обработанные device tokens
|
||||
*/
|
||||
processedTokens?: string[];
|
||||
|
||||
/**
|
||||
* Ошибки обработки
|
||||
*/
|
||||
errors?: string[];
|
||||
}
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Доменный интерфейс для триггера NOVU workflow
|
||||
* Доменный интерфейс актора/получателя уведомления
|
||||
* Представляет данные для запуска воркфлоу уведомлений
|
||||
*/
|
||||
export interface WorkflowTriggerDomainInterface {
|
||||
@@ -39,7 +39,7 @@ export interface WorkflowTriggerDomainInterface {
|
||||
*/
|
||||
export interface WorkflowRecipientDomainInterface {
|
||||
/**
|
||||
* ID подписчика в NOVU
|
||||
* ID подписчика
|
||||
*/
|
||||
subscriberId: string;
|
||||
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { NotificationDomainService, NOTIFICATION_DOMAIN_SERVICE } from './services/notification-domain.service';
|
||||
|
||||
/**
|
||||
* Доменный модуль для управления уведомлениями
|
||||
* Включает интеракторы и сервисы для работы с уведомлениями
|
||||
*/
|
||||
@Module({
|
||||
imports: [],
|
||||
providers: [
|
||||
NotificationDomainService,
|
||||
{
|
||||
provide: NOTIFICATION_DOMAIN_SERVICE,
|
||||
useExisting: NotificationDomainService,
|
||||
},
|
||||
],
|
||||
exports: [NotificationDomainService, NOTIFICATION_DOMAIN_SERVICE],
|
||||
})
|
||||
export class NotificationDomainModule {}
|
||||
-151
@@ -1,151 +0,0 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { NOTIFICATION_PORT, NotificationPort, NotificationSubscriberData } from '../interfaces/notification.port';
|
||||
import type { AccountDomainEntity } from '~/domain/account/entities/account-domain.entity';
|
||||
import type { WorkflowRecipientDomainInterface } from '../interfaces/workflow-trigger-domain.interface';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationDomainService {
|
||||
private readonly logger = new Logger(NotificationDomainService.name);
|
||||
|
||||
constructor(@Inject(NOTIFICATION_PORT) private readonly notificationPort: NotificationPort) {}
|
||||
|
||||
/**
|
||||
* Создает подписчика уведомлений из данных аккаунта
|
||||
* @param account Данные аккаунта пользователя
|
||||
*/
|
||||
async createSubscriberFromAccount(account: AccountDomainEntity): Promise<void> {
|
||||
if (!account.provider_account) {
|
||||
this.logger.warn(`Нет данных provider_account для пользователя ${account.username}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const subscriber = this.buildSubscriberData(account);
|
||||
|
||||
// Проверяем минимальные требования для создания подписчика
|
||||
if (!subscriber.email) {
|
||||
this.logger.warn(`Нет email для создания подписчика ${account.username}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Если нет имени и названия организации, все равно создаем подписчика
|
||||
if (!subscriber.firstName && !subscriber.data?.org_name) {
|
||||
this.logger.warn(`Создаем подписчика ${account.username} с минимальными данными (только email)`);
|
||||
}
|
||||
|
||||
this.logger.log(`Создание подписчика уведомлений для ${account.username}`);
|
||||
|
||||
await this.notificationPort.createSubscriber(subscriber);
|
||||
this.logger.log(`Подписчик ${account.username} успешно создан`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновить подписчика уведомлений из аккаунта
|
||||
* @param account Данные аккаунта
|
||||
*/
|
||||
async updateSubscriberFromAccount(account: AccountDomainEntity): Promise<void> {
|
||||
const subscriber = this.buildSubscriberData(account);
|
||||
|
||||
// Проверяем минимальные требования для обновления подписчика
|
||||
if (!subscriber.email) {
|
||||
this.logger.warn(`Нет email для обновления подписчика ${account.username}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(`Обновление подписчика уведомлений для ${account.username}`);
|
||||
|
||||
await this.notificationPort.updateSubscriber(subscriber);
|
||||
this.logger.log(`Подписчик ${account.username} успешно обновлен`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Формирует данные подписчика из аккаунта
|
||||
* @param account Данные аккаунта
|
||||
*/
|
||||
/**
|
||||
* Получатель для Novu trigger: subscriberId + email (и имя), иначе email-канал падает с
|
||||
* «Subscriber missing email address», если в Novu только «пустой» профиль под этим id.
|
||||
*/
|
||||
buildWorkflowRecipientFromAccount(account: AccountDomainEntity): WorkflowRecipientDomainInterface | null {
|
||||
if (!account.provider_account?.subscriber_id) {
|
||||
this.logger.warn(`Нет subscriber_id для аккаунта ${account.username}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const sub = this.buildSubscriberData(account);
|
||||
const email = sub.email?.trim();
|
||||
if (!email) {
|
||||
this.logger.warn(`Нет email для Novu-триггера (аккаунт ${account.username})`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
subscriberId: sub.subscriberId,
|
||||
email,
|
||||
firstName: sub.firstName || undefined,
|
||||
lastName: sub.lastName || undefined,
|
||||
data: {
|
||||
username: account.username,
|
||||
...sub.data,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private buildSubscriberData(account: AccountDomainEntity): NotificationSubscriberData {
|
||||
let email = '';
|
||||
let phone = '';
|
||||
let firstName = '';
|
||||
let lastName = '';
|
||||
const data: Record<string, string> = {};
|
||||
|
||||
// Получаем email из provider_account
|
||||
if (account.provider_account?.email) {
|
||||
email = account.provider_account.email;
|
||||
}
|
||||
|
||||
// Определяем тип аккаунта и извлекаем данные
|
||||
if (account.private_account) {
|
||||
const type = account.private_account.type;
|
||||
|
||||
if (type === 'individual' && account.private_account.individual_data) {
|
||||
const ind = account.private_account.individual_data;
|
||||
firstName = ind.first_name || '';
|
||||
lastName = ind.last_name || '';
|
||||
email = ind.email || email;
|
||||
phone = ind.phone || '';
|
||||
if (ind.middle_name) data.middleName = ind.middle_name;
|
||||
} else if (type === 'organization' && account.private_account.organization_data) {
|
||||
const org = account.private_account.organization_data;
|
||||
email = org.email || email;
|
||||
phone = org.phone || '';
|
||||
data.org_name = org.full_name || '';
|
||||
|
||||
// Данные представителя организации
|
||||
if (org.represented_by) {
|
||||
firstName = org.represented_by.first_name || '';
|
||||
lastName = org.represented_by.last_name || '';
|
||||
if (org.represented_by.middle_name) data.middleName = org.represented_by.middle_name;
|
||||
}
|
||||
} else if (type === 'entrepreneur' && account.private_account.entrepreneur_data) {
|
||||
const ent = account.private_account.entrepreneur_data;
|
||||
firstName = ent.first_name || '';
|
||||
lastName = ent.last_name || '';
|
||||
email = ent.email || email;
|
||||
phone = ent.phone || '';
|
||||
if (ent.middle_name) data.middleName = ent.middle_name;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
subscriberId: account.provider_account?.subscriber_id || account.username,
|
||||
email,
|
||||
phone,
|
||||
firstName,
|
||||
lastName,
|
||||
locale: 'ru_RU',
|
||||
timezone: 'Europe/Moscow',
|
||||
data,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const NOTIFICATION_DOMAIN_SERVICE = Symbol('NOTIFICATION_DOMAIN_SERVICE');
|
||||
@@ -314,7 +314,7 @@ export class UserDomainService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Генерирует уникальный subscriber_id для NOVU с проверкой на дублирование
|
||||
* Генерирует уникальный subscriber_id с проверкой на дублирование
|
||||
* @param coopname Название кооператива
|
||||
* @param maxRetries Максимальное количество попыток (по умолчанию 5)
|
||||
* @returns Уникальный subscriber_id
|
||||
|
||||
+1
-1
@@ -63,7 +63,7 @@ export class ApprovalNotificationService implements OnModuleInit {
|
||||
const chairmanEmail = chairman.provider_account?.email;
|
||||
const chairmanSubscriberId = chairman.provider_account?.subscriber_id?.trim();
|
||||
if (!chairmanSubscriberId) {
|
||||
this.logger.warn(`subscriber_id председателя ${chairman.username} не найден — пропуск Novu`);
|
||||
this.logger.warn(`subscriber_id председателя ${chairman.username} не найден`);
|
||||
return;
|
||||
}
|
||||
if (!chairmanEmail) {
|
||||
|
||||
+1
-1
@@ -103,7 +103,7 @@ export class ApprovalResponseNotificationService implements OnModuleInit {
|
||||
const authorSubscriberId = authorAccount.provider_account?.subscriber_id?.trim();
|
||||
|
||||
if (!authorSubscriberId) {
|
||||
this.logger.warn(`subscriber_id автора запроса ${authorUsername} не найден — пропуск Novu`);
|
||||
this.logger.warn(`subscriber_id автора запроса ${authorUsername} не найден`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -103,7 +103,7 @@ export class DecisionExpiredNotificationService implements OnModuleInit, OnModul
|
||||
const subscriberId = userAccount.provider_account?.subscriber_id?.trim();
|
||||
|
||||
if (!subscriberId) {
|
||||
this.logger.warn(`subscriber_id пайщика ${username} не найден — пропуск Novu`);
|
||||
this.logger.warn(`subscriber_id пайщика ${username} не найден`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+10
-10
@@ -13,10 +13,10 @@ import config from '~/config/config';
|
||||
import { DateUtils } from '~/shared/utils/date-utils';
|
||||
import { isEligibleForActiveCoopCalendarBroadcast } from '~/domain/account/utils/participant-mass-notification.util';
|
||||
|
||||
type CalendarNovuRecipient = { username: string; email: string; subscriberId: string };
|
||||
type CalendarRecipient = { username: string; email: string; subscriberId: string };
|
||||
|
||||
/**
|
||||
* Реализация {@link InterCoopCalendarEventNotificationPort}: Novu по выбранным получателям.
|
||||
* Реализация {@link InterCoopCalendarEventNotificationPort}: по выбранным получателям.
|
||||
* Комнаты capital_project + projectHash — только с подтверждённым допуском к проекту (Capital / inter).
|
||||
* Остальные комнаты — все подходящие пайщики со статусом active в Mono (не registered без активации).
|
||||
*/
|
||||
@@ -59,11 +59,11 @@ export class ChatcoopCalendarEventNotificationService implements InterCoopCalend
|
||||
}
|
||||
|
||||
/** Рассылка по кооперативу: только active в Mono + email + subscriber_id. */
|
||||
private async listRecipientsCoopWideActiveOnly(): Promise<CalendarNovuRecipient[]> {
|
||||
private async listRecipientsCoopWideActiveOnly(): Promise<CalendarRecipient[]> {
|
||||
const batchSize = 100;
|
||||
let currentPage = 1;
|
||||
let hasMorePages = true;
|
||||
const allAccounts: CalendarNovuRecipient[] = [];
|
||||
const allAccounts: CalendarRecipient[] = [];
|
||||
|
||||
while (hasMorePages) {
|
||||
const accountsPage = await this.accountPort.getAccounts(
|
||||
@@ -78,7 +78,7 @@ export class ChatcoopCalendarEventNotificationService implements InterCoopCalend
|
||||
email: account.provider_account?.email?.trim() ?? '',
|
||||
subscriberId: account.provider_account?.subscriber_id?.trim() ?? '',
|
||||
}))
|
||||
.filter((m): m is CalendarNovuRecipient => m.email.includes('@') && m.subscriberId !== '');
|
||||
.filter((m): m is CalendarRecipient => m.email.includes('@') && m.subscriberId !== '');
|
||||
|
||||
allAccounts.push(...mappings);
|
||||
|
||||
@@ -93,9 +93,9 @@ export class ChatcoopCalendarEventNotificationService implements InterCoopCalend
|
||||
}
|
||||
|
||||
/** Комната проекта Capital: допуск из appendix + active + email + subscriber_id. */
|
||||
private async listRecipientsForCapitalProjectRoom(projectHash: string): Promise<CalendarNovuRecipient[]> {
|
||||
private async listRecipientsForCapitalProjectRoom(projectHash: string): Promise<CalendarRecipient[]> {
|
||||
const usernames = await this.projectCapitalClearance.listUsernamesWithConfirmedProjectClearance(projectHash);
|
||||
const recipients: CalendarNovuRecipient[] = [];
|
||||
const recipients: CalendarRecipient[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const raw of usernames) {
|
||||
@@ -128,9 +128,9 @@ export class ChatcoopCalendarEventNotificationService implements InterCoopCalend
|
||||
return recipients;
|
||||
}
|
||||
|
||||
private async resolveCalendarNovuRecipients(
|
||||
private async resolveCalendarRecipients(
|
||||
input: InterCoopCalendarEventNotificationInput
|
||||
): Promise<CalendarNovuRecipient[]> {
|
||||
): Promise<CalendarRecipient[]> {
|
||||
const ph = input.projectHash?.trim();
|
||||
if (input.roomKind === 'capital_project' && ph) {
|
||||
return this.listRecipientsForCapitalProjectRoom(ph);
|
||||
@@ -160,7 +160,7 @@ export class ChatcoopCalendarEventNotificationService implements InterCoopCalend
|
||||
workflowId: string,
|
||||
input: InterCoopCalendarEventNotificationInput
|
||||
): Promise<void> {
|
||||
const users = await this.resolveCalendarNovuRecipients(input);
|
||||
const users = await this.resolveCalendarRecipients(input);
|
||||
if (users.length === 0) {
|
||||
this.logger.warn(
|
||||
'Нет получателей для уведомления календаря (active Mono + email + subscriber_id; для комнаты проекта — также допуск Capital)'
|
||||
|
||||
@@ -19,7 +19,6 @@ import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { z } from 'zod';
|
||||
import { AccountInfrastructureModule } from '~/infrastructure/account/account-infrastructure.module';
|
||||
import { NovuModule } from '~/infrastructure/novu/novu.module';
|
||||
import { INTER_COOP_CALENDAR_EVENT_NOTIFICATION } from '@coopenomics/inter';
|
||||
import { ChatcoopCalendarEventNotificationService } from './application/services/chatcoop-calendar-event-notification.service';
|
||||
import { ExtensionDomainRepository } from '~/domain/extension/repositories/extension-domain.repository';
|
||||
@@ -516,7 +515,6 @@ export class ChatCoopPlugin extends BaseExtModule {
|
||||
ChatCoopDatabaseModule,
|
||||
ConfigModule,
|
||||
AccountInfrastructureModule,
|
||||
NovuModule,
|
||||
],
|
||||
controllers: [
|
||||
LiveKitWebhookController,
|
||||
|
||||
+13
-13
@@ -10,7 +10,7 @@ import { ACCOUNT_DATA_PORT, AccountDataPort } from '~/domain/account/ports/accou
|
||||
import { Workflows } from '@coopenomics/notifications';
|
||||
import { isEligibleForParticipantMassNotification } from '~/domain/account/utils/participant-mass-notification.util';
|
||||
|
||||
type MeetNovuRecipient = { username: string; email: string; subscriberId: string };
|
||||
type MeetRecipient = { username: string; email: string; subscriberId: string };
|
||||
|
||||
/**
|
||||
* Сервис для отправки уведомлений о собраниях через workflow
|
||||
@@ -62,7 +62,7 @@ export class MeetWorkflowNotificationService implements OnModuleInit {
|
||||
|
||||
/**
|
||||
* Текст из meet_pre только для workflow «до/у начала»: meet-initial, meet-reminder-start, meet-started.
|
||||
* В reminder-end, restart, ended поле details в схемах Novu нет — не передаём, иначе ошибка валидации.
|
||||
* В reminder-end, restart, ended поле details в схемах каталога нет — не передаём, иначе ошибка валидации.
|
||||
*/
|
||||
private async meetDetailsPayloadPart(hash: string): Promise<{ details?: string }> {
|
||||
const pre = await this.meetPreRepository.findByHash(hash);
|
||||
@@ -70,13 +70,13 @@ export class MeetWorkflowNotificationService implements OnModuleInit {
|
||||
return trimmed ? { details: trimmed } : {};
|
||||
}
|
||||
|
||||
/** Пайщики с email и Novu subscriber_id (как в остальной системе). */
|
||||
private async getMeetNovuRecipients(): Promise<MeetNovuRecipient[]> {
|
||||
/** Пайщики с email и subscriber_id (как в остальной системе). */
|
||||
private async getMeetRecipients(): Promise<MeetRecipient[]> {
|
||||
try {
|
||||
const batchSize = 100;
|
||||
let currentPage = 1;
|
||||
let hasMorePages = true;
|
||||
let allAccounts: MeetNovuRecipient[] = [];
|
||||
let allAccounts: MeetRecipient[] = [];
|
||||
|
||||
while (hasMorePages) {
|
||||
const accountsPage = await this.accountPort.getAccounts(
|
||||
@@ -95,7 +95,7 @@ export class MeetWorkflowNotificationService implements OnModuleInit {
|
||||
email: account.provider_account?.email?.trim() ?? '',
|
||||
subscriberId: account.provider_account?.subscriber_id?.trim() ?? '',
|
||||
}))
|
||||
.filter((m): m is MeetNovuRecipient => m.email.includes('@') && m.subscriberId !== '');
|
||||
.filter((m): m is MeetRecipient => m.email.includes('@') && m.subscriberId !== '');
|
||||
|
||||
allAccounts = [...allAccounts, ...mappings];
|
||||
|
||||
@@ -108,14 +108,14 @@ export class MeetWorkflowNotificationService implements OnModuleInit {
|
||||
|
||||
return allAccounts;
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Ошибка при получении получателей Novu для собраний: ${error.message}`, error.stack);
|
||||
this.logger.error(`Ошибка при получении получателей для собраний: ${error.message}`, error.stack);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Начальное уведомление при появлении собрания в статусе WAITING_FOR_OPENING
|
||||
async sendInitialNotification(meet: TrackedMeet): Promise<void> {
|
||||
const users = await this.getMeetNovuRecipients();
|
||||
const users = await this.getMeetRecipients();
|
||||
if (users.length === 0) return;
|
||||
|
||||
const coopShortName = await this.getCoopShortName();
|
||||
@@ -166,7 +166,7 @@ export class MeetWorkflowNotificationService implements OnModuleInit {
|
||||
|
||||
// 2. Уведомление за N минут до начала собрания
|
||||
async sendThreeDaysBeforeStartNotification(meet: TrackedMeet): Promise<void> {
|
||||
const users = await this.getMeetNovuRecipients();
|
||||
const users = await this.getMeetRecipients();
|
||||
if (users.length === 0) return;
|
||||
|
||||
const coopShortName = await this.getCoopShortName();
|
||||
@@ -219,7 +219,7 @@ export class MeetWorkflowNotificationService implements OnModuleInit {
|
||||
|
||||
// 3. Уведомление о начале собрания (при переходе в статус VOTING_IN_PROGRESS)
|
||||
async sendStartNotification(meet: TrackedMeet): Promise<void> {
|
||||
const users = await this.getMeetNovuRecipients();
|
||||
const users = await this.getMeetRecipients();
|
||||
if (users.length === 0) return;
|
||||
|
||||
const coopShortName = await this.getCoopShortName();
|
||||
@@ -266,7 +266,7 @@ export class MeetWorkflowNotificationService implements OnModuleInit {
|
||||
|
||||
// 4. Уведомление за N минут до окончания собрания
|
||||
async sendOneDayBeforeEndNotification(meet: TrackedMeet): Promise<void> {
|
||||
const users = await this.getMeetNovuRecipients();
|
||||
const users = await this.getMeetRecipients();
|
||||
if (users.length === 0) return;
|
||||
|
||||
const coopShortName = await this.getCoopShortName();
|
||||
@@ -321,7 +321,7 @@ export class MeetWorkflowNotificationService implements OnModuleInit {
|
||||
|
||||
// 5. Уведомление о назначении новой даты для повторного собрания
|
||||
async sendRestartNotification(meet: TrackedMeet): Promise<void> {
|
||||
const users = await this.getMeetNovuRecipients();
|
||||
const users = await this.getMeetRecipients();
|
||||
if (users.length === 0) return;
|
||||
|
||||
const coopShortName = await this.getCoopShortName();
|
||||
@@ -372,7 +372,7 @@ export class MeetWorkflowNotificationService implements OnModuleInit {
|
||||
|
||||
// 6. Уведомление о разных вариантах завершения собрания
|
||||
async sendEndNotification(meet: TrackedMeet): Promise<void> {
|
||||
const users = await this.getMeetNovuRecipients();
|
||||
const users = await this.getMeetRecipients();
|
||||
if (users.length === 0) return;
|
||||
|
||||
const coopShortName = await this.getCoopShortName();
|
||||
|
||||
@@ -21,7 +21,6 @@ import { NotificationSenderService } from './notification-sender.service';
|
||||
import { MeetTrackerService } from './meet-tracker.service';
|
||||
import { MeetWorkflowNotificationService } from './meet-workflow-notification.service';
|
||||
import { AccountDomainEntity } from '~/domain/account/entities/account-domain.entity';
|
||||
import { NovuModule } from '~/infrastructure/novu/novu.module';
|
||||
|
||||
@Injectable()
|
||||
export class ParticipantPlugin extends BaseExtModule implements OnModuleDestroy {
|
||||
@@ -115,7 +114,6 @@ export class ParticipantPlugin extends BaseExtModule implements OnModuleDestroy
|
||||
imports: [
|
||||
AccountInfrastructureModule,
|
||||
MeetInfrastructureModule, // Импортируем инфраструктурные модули для портов
|
||||
NovuModule, // Добавляем NovuModule для workflow уведомлений
|
||||
],
|
||||
providers: [NotificationSenderService, MeetTrackerService, MeetWorkflowNotificationService, ParticipantPlugin],
|
||||
exports: [ParticipantPlugin],
|
||||
|
||||
@@ -1,353 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Novu } from '@novu/api';
|
||||
import { ChatOrPushProviderEnum } from '@novu/api/models/components';
|
||||
import config from '~/config/config';
|
||||
import type { NovuCredentialsPort } from '~/domain/notification/interfaces/novu-credentials.port';
|
||||
import {
|
||||
UpdateCredentialsDomainInterface,
|
||||
DeviceTokenDomainInterface,
|
||||
NotificationProviderEnum,
|
||||
} from '~/domain/notification/interfaces/device-token-domain.interface';
|
||||
|
||||
/**
|
||||
* Адаптер для управления NOVU credentials
|
||||
* Реализует работу с device tokens подписчиков через @novu/api
|
||||
*/
|
||||
@Injectable()
|
||||
export class NovuCredentialsAdapter implements NovuCredentialsPort {
|
||||
private readonly logger = new Logger(NovuCredentialsAdapter.name);
|
||||
private readonly novu: Novu;
|
||||
|
||||
constructor() {
|
||||
// Инициализируем Novu SDK
|
||||
this.novu = new Novu({
|
||||
secretKey: config.novu.api_key,
|
||||
serverURL: config.novu.backend_url,
|
||||
});
|
||||
|
||||
this.logger.log(`NOVU Credentials адаптер инициализирован с @novu/api`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновить credentials подписчика
|
||||
* @param credentialsData Данные для обновления credentials
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
async updateSubscriberCredentials(credentialsData: UpdateCredentialsDomainInterface): Promise<void> {
|
||||
this.logger.log(
|
||||
`Обновление credentials для подписчика: ${credentialsData.subscriberId}, провайдер: ${credentialsData.providerId}`
|
||||
);
|
||||
|
||||
try {
|
||||
const novuProviderId = this.convertToNovuProvider(credentialsData.providerId);
|
||||
|
||||
// Сначала удаляем все существующие credentials для этого провайдера
|
||||
try {
|
||||
await this.novu.subscribers.credentials.delete(credentialsData.subscriberId, novuProviderId);
|
||||
this.logger.debug(`Существующие credentials удалены для подписчика: ${credentialsData.subscriberId}`);
|
||||
} catch (deleteError: any) {
|
||||
// Если credentials не существуют, это не ошибка
|
||||
if (!this.isNotFoundError(deleteError)) {
|
||||
this.logger.warn(`Не удалось удалить существующие credentials: ${deleteError.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Добавляем новые credentials, если есть токены
|
||||
if (credentialsData.deviceTokens && credentialsData.deviceTokens.length > 0) {
|
||||
const updateData = {
|
||||
providerId: novuProviderId,
|
||||
credentials: {
|
||||
deviceTokens: credentialsData.deviceTokens,
|
||||
integrationIdentifier: credentialsData.integrationIdentifier,
|
||||
...credentialsData.additionalData,
|
||||
},
|
||||
};
|
||||
|
||||
await this.novu.subscribers.credentials.append(updateData, credentialsData.subscriberId);
|
||||
this.logger.log(`Credentials обновлены для подписчика: ${credentialsData.subscriberId}`);
|
||||
} else {
|
||||
this.logger.log(`Credentials очищены для подписчика: ${credentialsData.subscriberId} (нет токенов)`);
|
||||
}
|
||||
|
||||
// Показываем текущие каналы подписчика для контроля синхронизации
|
||||
await this.logSubscriberChannels(credentialsData.subscriberId);
|
||||
} catch (error: any) {
|
||||
console.dir(error?.response?.data || error, { depth: null });
|
||||
this.logger.error(
|
||||
`Ошибка обновления credentials для подписчика ${credentialsData.subscriberId}: ${error.message}`,
|
||||
error.stack
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить credentials подписчика
|
||||
* @param subscriberId ID подписчика
|
||||
* @param providerId ID провайдера
|
||||
* @returns Promise<DeviceTokenDomainInterface[]>
|
||||
*/
|
||||
async getSubscriberCredentials(
|
||||
subscriberId: string,
|
||||
providerId: NotificationProviderEnum
|
||||
): Promise<DeviceTokenDomainInterface[]> {
|
||||
this.logger.debug(`Получение credentials для подписчика: ${subscriberId}, провайдер: ${providerId}`);
|
||||
|
||||
try {
|
||||
// Получаем подписчика через retrieve
|
||||
const subscriber = (await this.novu.subscribers.retrieve(subscriberId)).result;
|
||||
|
||||
// Преобразуем доменный enum в NOVU enum для поиска
|
||||
const novuProviderId = this.convertToNovuProvider(providerId);
|
||||
|
||||
// Находим канал с указанным providerId
|
||||
const channel = subscriber.channels?.find((ch: any) => ch.providerId === novuProviderId);
|
||||
|
||||
if (!channel || !channel.credentials?.deviceTokens) {
|
||||
this.logger.debug(`Credentials не найдены для подписчика: ${subscriberId}, провайдер: ${providerId}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Преобразуем device tokens в доменные объекты
|
||||
return channel.credentials.deviceTokens.map((token: string) =>
|
||||
this.mapToDeviceTokenDomain(token, subscriberId, providerId, channel.integrationIdentifier)
|
||||
);
|
||||
} catch (error: any) {
|
||||
if (this.isNotFoundError(error)) {
|
||||
this.logger.debug(`Подписчик не найден: ${subscriberId}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
this.logger.error(`Ошибка получения credentials для подписчика ${subscriberId}: ${error.message}`, error.stack);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Удалить credentials подписчика для определенного провайдера
|
||||
* @param subscriberId ID подписчика
|
||||
* @param providerId ID провайдера
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
async deleteSubscriberCredentials(subscriberId: string, providerId: NotificationProviderEnum): Promise<void> {
|
||||
this.logger.log(`Удаление credentials для подписчика: ${subscriberId}, провайдер: ${providerId}`);
|
||||
|
||||
try {
|
||||
const novuProviderId = this.convertToNovuProvider(providerId);
|
||||
await this.novu.subscribers.credentials.delete(subscriberId, novuProviderId);
|
||||
|
||||
this.logger.log(`Credentials удалены для подписчика: ${subscriberId}`);
|
||||
|
||||
// Показываем текущие каналы подписчика для контроля синхронизации
|
||||
await this.logSubscriberChannels(subscriberId);
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Ошибка удаления credentials для подписчика ${subscriberId}: ${error.message}`, error.stack);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Удалить конкретный device token из credentials подписчика
|
||||
* @param subscriberId ID подписчика
|
||||
* @param providerId ID провайдера
|
||||
* @param deviceToken Device token для удаления
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
async removeDeviceToken(subscriberId: string, providerId: NotificationProviderEnum, deviceToken: string): Promise<void> {
|
||||
this.logger.log(`Удаление device token для подписчика: ${subscriberId}, провайдер: ${providerId}`);
|
||||
|
||||
try {
|
||||
// Получаем текущие credentials
|
||||
const currentCredentials = await this.getSubscriberCredentials(subscriberId, providerId);
|
||||
|
||||
// Фильтруем токены, исключая удаляемый
|
||||
const updatedTokens = currentCredentials.map((cred) => cred.token).filter((token) => token !== deviceToken);
|
||||
|
||||
// Обновляем credentials с новым списком токенов
|
||||
await this.updateSubscriberCredentials({
|
||||
subscriberId,
|
||||
providerId,
|
||||
deviceTokens: updatedTokens,
|
||||
});
|
||||
|
||||
this.logger.log(`Device token удален для подписчика: ${subscriberId}`);
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Ошибка удаления device token для подписчика ${subscriberId}: ${error.message}`, error.stack);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавить device token в credentials подписчика
|
||||
* @param subscriberId ID подписчика
|
||||
* @param providerId ID провайдера
|
||||
* @param deviceToken Device token для добавления
|
||||
* @param integrationIdentifier Идентификатор интеграции
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
async addDeviceToken(
|
||||
subscriberId: string,
|
||||
providerId: NotificationProviderEnum,
|
||||
deviceToken: string,
|
||||
integrationIdentifier?: string
|
||||
): Promise<void> {
|
||||
this.logger.log(`Добавление device token для подписчика: ${subscriberId}, провайдер: ${providerId}`);
|
||||
|
||||
try {
|
||||
// Получаем текущие credentials
|
||||
const currentCredentials = await this.getSubscriberCredentials(subscriberId, providerId);
|
||||
|
||||
// Получаем текущие токены
|
||||
const currentTokens = currentCredentials.map((cred) => cred.token);
|
||||
|
||||
// Добавляем новый токен, если его еще нет
|
||||
if (!currentTokens.includes(deviceToken)) {
|
||||
currentTokens.push(deviceToken);
|
||||
}
|
||||
|
||||
// Обновляем credentials с новым списком токенов
|
||||
await this.updateSubscriberCredentials({
|
||||
subscriberId,
|
||||
providerId,
|
||||
deviceTokens: currentTokens,
|
||||
integrationIdentifier,
|
||||
});
|
||||
|
||||
this.logger.log(`Device token добавлен для подписчика: ${subscriberId}`);
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Ошибка добавления device token для подписчика ${subscriberId}: ${error.message}`, error.stack);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Преобразует доменный enum провайдера в NOVU enum
|
||||
* @param domainProvider Доменный провайдер
|
||||
* @returns NOVU провайдер
|
||||
*/
|
||||
private convertToNovuProvider(domainProvider: NotificationProviderEnum): ChatOrPushProviderEnum {
|
||||
// Преобразуем доменный enum в NOVU enum
|
||||
switch (domainProvider) {
|
||||
case NotificationProviderEnum.SLACK:
|
||||
return ChatOrPushProviderEnum.Slack;
|
||||
case NotificationProviderEnum.DISCORD:
|
||||
return ChatOrPushProviderEnum.Discord;
|
||||
case NotificationProviderEnum.MSTEAMS:
|
||||
return ChatOrPushProviderEnum.Msteams;
|
||||
case NotificationProviderEnum.MATTERMOST:
|
||||
return ChatOrPushProviderEnum.Mattermost;
|
||||
case NotificationProviderEnum.RYVER:
|
||||
return ChatOrPushProviderEnum.Ryver;
|
||||
case NotificationProviderEnum.ZULIP:
|
||||
return ChatOrPushProviderEnum.Zulip;
|
||||
case NotificationProviderEnum.GRAFANA_ON_CALL:
|
||||
return ChatOrPushProviderEnum.GrafanaOnCall;
|
||||
case NotificationProviderEnum.GETSTREAM:
|
||||
return ChatOrPushProviderEnum.Getstream;
|
||||
case NotificationProviderEnum.ROCKET_CHAT:
|
||||
return ChatOrPushProviderEnum.RocketChat;
|
||||
case NotificationProviderEnum.WHATSAPP_BUSINESS:
|
||||
return ChatOrPushProviderEnum.WhatsappBusiness;
|
||||
case NotificationProviderEnum.FCM:
|
||||
return ChatOrPushProviderEnum.Fcm;
|
||||
case NotificationProviderEnum.APNS:
|
||||
return ChatOrPushProviderEnum.Apns;
|
||||
case NotificationProviderEnum.EXPO:
|
||||
return ChatOrPushProviderEnum.Expo;
|
||||
case NotificationProviderEnum.ONE_SIGNAL:
|
||||
return ChatOrPushProviderEnum.OneSignal;
|
||||
case NotificationProviderEnum.PUSHPAD:
|
||||
return ChatOrPushProviderEnum.Pushpad;
|
||||
case NotificationProviderEnum.PUSH_WEBHOOK:
|
||||
return ChatOrPushProviderEnum.PushWebhook;
|
||||
case NotificationProviderEnum.PUSHER_BEAMS:
|
||||
return ChatOrPushProviderEnum.PusherBeams;
|
||||
default:
|
||||
throw new Error(`Неизвестный провайдер: ${domainProvider}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, является ли ошибка "не найдено"
|
||||
* @param error Ошибка
|
||||
*/
|
||||
private isNotFoundError(error: any): boolean {
|
||||
const status = error?.response?.status || error?.status;
|
||||
return status === 404;
|
||||
}
|
||||
|
||||
/**
|
||||
* Преобразует device token в доменный интерфейс
|
||||
* @param token Device token
|
||||
* @param subscriberId ID подписчика
|
||||
* @param providerId ID провайдера
|
||||
* @param integrationIdentifier Идентификатор интеграции
|
||||
* @returns DeviceTokenDomainInterface
|
||||
*/
|
||||
private mapToDeviceTokenDomain(
|
||||
token: string,
|
||||
subscriberId: string,
|
||||
providerId: NotificationProviderEnum,
|
||||
integrationIdentifier?: string
|
||||
): DeviceTokenDomainInterface {
|
||||
return {
|
||||
token,
|
||||
username: subscriberId,
|
||||
providerId,
|
||||
integrationIdentifier,
|
||||
deviceInfo: {
|
||||
// Дополнительная информация об устройстве может быть добавлена позже
|
||||
},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
isActive: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает подписчика и показывает его каналы в консоли для мониторинга синхронизации
|
||||
* @param subscriberId ID подписчика
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
private async logSubscriberChannels(subscriberId: string): Promise<void> {
|
||||
try {
|
||||
const subscriber = (await this.novu.subscribers.retrieve(subscriberId)).result;
|
||||
|
||||
this.logger.log(`📱 Каналы подписчика: ${subscriberId}`);
|
||||
|
||||
if (!subscriber.channels || subscriber.channels.length === 0) {
|
||||
this.logger.log(` ❌ Каналы отсутствуют`);
|
||||
return;
|
||||
}
|
||||
|
||||
subscriber.channels.forEach((channel: any, index: number) => {
|
||||
this.logger.log(` 📢 Канал ${index + 1}:`);
|
||||
this.logger.log(` • Провайдер: ${channel.providerId}`);
|
||||
this.logger.log(` • Интеграция: ${channel.integrationIdentifier || 'не указана'}`);
|
||||
|
||||
if (channel.credentials?.deviceTokens) {
|
||||
this.logger.log(` • Device tokens (${channel.credentials.deviceTokens.length}):`);
|
||||
channel.credentials.deviceTokens.forEach((token: string, tokenIndex: number) => {
|
||||
// Показываем первые и последние 8 символов токена для безопасности
|
||||
const maskedToken =
|
||||
token.length > 16 ? `${token.substring(0, 8)}...${token.substring(token.length - 8)}` : token;
|
||||
this.logger.log(` ${tokenIndex + 1}. ${maskedToken}`);
|
||||
});
|
||||
} else {
|
||||
this.logger.log(` • Device tokens: отсутствуют`);
|
||||
}
|
||||
|
||||
if (channel.credentials && Object.keys(channel.credentials).length > 0) {
|
||||
const otherCredentials = Object.keys(channel.credentials).filter((key) => key !== 'deviceTokens');
|
||||
if (otherCredentials.length > 0) {
|
||||
this.logger.log(` • Другие credentials: ${otherCredentials.join(', ')}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.logger.log(`📱 Всего каналов: ${subscriber.channels.length}`);
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Ошибка при получении каналов подписчика ${subscriberId}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import config from '~/config/config';
|
||||
import type { NovuWorkflowPort } from '~/domain/notification/interfaces/novu-workflow.port';
|
||||
import type {
|
||||
WorkflowTriggerDomainInterface,
|
||||
WorkflowTriggerResultDomainInterface,
|
||||
} from '~/domain/notification/interfaces/workflow-trigger-domain.interface';
|
||||
|
||||
/**
|
||||
* Адаптер для работы с NOVU workflow trigger
|
||||
* Реализует отправку уведомлений через NOVU API
|
||||
*/
|
||||
@Injectable()
|
||||
export class NovuWorkflowAdapter implements NovuWorkflowPort {
|
||||
private readonly logger = new Logger(NovuWorkflowAdapter.name);
|
||||
private readonly novuBaseUrl: string;
|
||||
private readonly apiKey: string;
|
||||
|
||||
constructor() {
|
||||
this.apiKey = config.novu.api_key;
|
||||
|
||||
// Настраиваем базовый URL для NOVU API
|
||||
let baseUrl = config.novu.backend_url;
|
||||
if (!baseUrl.endsWith('/v1')) {
|
||||
baseUrl = baseUrl.replace(/\/+$/, '') + '/v1';
|
||||
}
|
||||
this.novuBaseUrl = baseUrl;
|
||||
|
||||
this.logger.log(`NOVU Workflow адаптер инициализирован. URL: ${this.novuBaseUrl}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Запустить воркфлоу для отправки уведомления
|
||||
* @param triggerData Данные для запуска воркфлоу
|
||||
* @returns Promise<WorkflowTriggerResultDomainInterface>
|
||||
*/
|
||||
async triggerWorkflow(triggerData: WorkflowTriggerDomainInterface): Promise<WorkflowTriggerResultDomainInterface> {
|
||||
this.logger.log(`Запуск воркфлоу: ${triggerData.name}`);
|
||||
|
||||
try {
|
||||
// Убираем кавычки из строк payload — иначе в теме письма Novu могли появляться буквальные "
|
||||
const sanitizedTriggerData = {
|
||||
...triggerData,
|
||||
payload: this.sanitizePayload(triggerData.payload),
|
||||
};
|
||||
|
||||
const response: AxiosResponse = await axios.post(`${this.novuBaseUrl}/events/trigger`, sanitizedTriggerData, {
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
|
||||
this.logger.log(`Воркфлоу запущен: ${triggerData.name}, transactionId: ${response.data.transactionId}`);
|
||||
|
||||
return this.mapToTriggerResult(response.data);
|
||||
} catch (error: any) {
|
||||
this.logger.error(
|
||||
`Ошибка запуска воркфлоу ${triggerData.name}: ${error.message} ${error.response?.data}`,
|
||||
error.stack
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Отменить триггер воркфлоу
|
||||
* @param transactionId ID транзакции для отмены
|
||||
* @returns Promise<boolean>
|
||||
*/
|
||||
async cancelTriggeredWorkflow(transactionId: string): Promise<boolean> {
|
||||
this.logger.log(`Отмена воркфлоу: ${transactionId}`);
|
||||
|
||||
try {
|
||||
await axios.delete(`${this.novuBaseUrl}/events/trigger/${transactionId}`, {
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
|
||||
this.logger.log(`Воркфлоу отменен: ${transactionId}`);
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Ошибка отмены воркфлоу ${transactionId}: ${error.message}`, error.stack);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить статус выполнения воркфлоу
|
||||
* @param transactionId ID транзакции
|
||||
* @returns Promise<any>
|
||||
*/
|
||||
async getWorkflowStatus(transactionId: string): Promise<any> {
|
||||
this.logger.debug(`Получение статуса воркфлоу: ${transactionId}`);
|
||||
|
||||
try {
|
||||
const response: AxiosResponse = await axios.get(`${this.novuBaseUrl}/events/trigger/${transactionId}`, {
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Ошибка получения статуса воркфлоу ${transactionId}: ${error.message}`, error.stack);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет кавычки из строковых полей payload перед отправкой в Novu.
|
||||
* Раньше подставлялись HTML-сущности ("), из‑за чего они буквально попадали в subject письма.
|
||||
*/
|
||||
private sanitizePayload(payload: unknown): unknown {
|
||||
if (typeof payload === 'string') {
|
||||
return payload.replace(/["'\u201c\u201d\u201e\u00ab\u00bb]/g, '');
|
||||
}
|
||||
|
||||
if (Array.isArray(payload)) {
|
||||
return payload.map((item) => this.sanitizePayload(item));
|
||||
}
|
||||
|
||||
if (payload !== null && typeof payload === 'object') {
|
||||
const source = payload as Record<string, unknown>;
|
||||
const sanitized: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(source)) {
|
||||
sanitized[key] = this.sanitizePayload(source[key]);
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает заголовки для запросов к NOVU API
|
||||
*/
|
||||
private getHeaders(): Record<string, string> {
|
||||
return {
|
||||
Authorization: `ApiKey ${this.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Преобразует ответ NOVU API в доменный интерфейс результата
|
||||
* @param novuResponse Ответ от NOVU API
|
||||
* @returns WorkflowTriggerResultDomainInterface
|
||||
*/
|
||||
private mapToTriggerResult(novuResponse: any): WorkflowTriggerResultDomainInterface {
|
||||
return {
|
||||
acknowledged: novuResponse.acknowledged || false,
|
||||
status: novuResponse.status || 'processed',
|
||||
error: novuResponse.error || undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Novu } from '@novu/api';
|
||||
import config from '~/config/config';
|
||||
import { NotificationPort, NotificationSubscriberData } from '~/domain/notification/interfaces/notification.port';
|
||||
|
||||
@Injectable()
|
||||
export class NovuAdapter implements NotificationPort {
|
||||
private readonly logger = new Logger(NovuAdapter.name);
|
||||
private readonly novu: Novu;
|
||||
|
||||
constructor() {
|
||||
// Инициализируем Novu SDK
|
||||
this.novu = new Novu({
|
||||
secretKey: config.novu.api_key,
|
||||
serverURL: config.novu.backend_url,
|
||||
});
|
||||
|
||||
this.logger.log(`NOVU адаптер инициализирован с @novu/api`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Создает подписчика в NOVU
|
||||
* @param subscriber Данные подписчика
|
||||
*/
|
||||
async createSubscriber(subscriber: NotificationSubscriberData): Promise<void> {
|
||||
this.logger.log(`Создание подписчика: ${subscriber.subscriberId}`);
|
||||
|
||||
try {
|
||||
await this.novu.subscribers.create(subscriber);
|
||||
this.logger.log(`Подписчик создан: ${subscriber.subscriberId}`);
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Ошибка создания подписчика ${subscriber.subscriberId}: ${error.message}`, error.stack);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет подписчика в NOVU
|
||||
* @param subscriber Данные подписчика
|
||||
*/
|
||||
async updateSubscriber(subscriber: NotificationSubscriberData): Promise<void> {
|
||||
this.logger.log(`Обновление подписчика: ${subscriber.subscriberId}`);
|
||||
|
||||
try {
|
||||
await this.novu.subscribers.patch(subscriber, subscriber.subscriberId);
|
||||
this.logger.log(`Подписчик обновлен: ${subscriber.subscriberId}`);
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Ошибка обновления подписчика ${subscriber.subscriberId}: ${error.message}`, error.stack);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает подписчика по ID
|
||||
* @param subscriberId ID подписчика
|
||||
*/
|
||||
async getSubscriber(subscriberId: string): Promise<NotificationSubscriberData | null> {
|
||||
try {
|
||||
const response = await this.novu.subscribers.retrieve(subscriberId);
|
||||
const subscriber = response.result;
|
||||
|
||||
if (!subscriber) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Преобразуем SubscriberResponseDto в NotificationSubscriberData
|
||||
const result: NotificationSubscriberData = {
|
||||
subscriberId: subscriber.subscriberId,
|
||||
email: subscriber.email || '', // Преобразуем null/undefined в пустую строку
|
||||
firstName: subscriber.firstName || undefined,
|
||||
lastName: subscriber.lastName || undefined,
|
||||
locale: subscriber.locale || undefined,
|
||||
phone: subscriber.phone || undefined,
|
||||
timezone: subscriber.timezone || undefined,
|
||||
data: subscriber.data || undefined,
|
||||
};
|
||||
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
// Если подписчик не найден - возвращаем null
|
||||
const status = error?.response?.status || error?.status;
|
||||
if (status === 404 || status === 400) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.logger.error(`Ошибка получения подписчика ${subscriberId}: ${error.message}`, error.stack);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет подписчика
|
||||
* @param subscriberId ID подписчика
|
||||
*/
|
||||
async deleteSubscriber(subscriberId: string): Promise<void> {
|
||||
try {
|
||||
await this.novu.subscribers.delete(subscriberId);
|
||||
this.logger.log(`Подписчик удален: ${subscriberId}`);
|
||||
} catch (error: any) {
|
||||
// Игнорируем ошибки "не найдено" при удалении
|
||||
const status = error?.response?.status || error?.status;
|
||||
if (status !== 404 && status !== 400) {
|
||||
this.logger.error(`Ошибка удаления подписчика ${subscriberId}: ${error.message}`, error.stack);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { NovuAdapter } from './novu.adapter';
|
||||
import { NovuWorkflowAdapter } from './novu-workflow.adapter';
|
||||
import { NovuCredentialsAdapter } from './novu-credentials.adapter';
|
||||
import { NOTIFICATION_PORT } from '~/domain/notification/interfaces/notification.port';
|
||||
import { NOVU_WORKFLOW_PORT } from '~/domain/notification/interfaces/novu-workflow.port';
|
||||
import { NOVU_CREDENTIALS_PORT } from '~/domain/notification/interfaces/novu-credentials.port';
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: NOTIFICATION_PORT,
|
||||
useClass: NovuAdapter,
|
||||
},
|
||||
{
|
||||
provide: NOVU_WORKFLOW_PORT,
|
||||
useClass: NovuWorkflowAdapter,
|
||||
},
|
||||
{
|
||||
provide: NOVU_CREDENTIALS_PORT,
|
||||
useClass: NovuCredentialsAdapter,
|
||||
},
|
||||
],
|
||||
exports: [NOTIFICATION_PORT, NOVU_WORKFLOW_PORT, NOVU_CREDENTIALS_PORT],
|
||||
})
|
||||
export class NovuModule {}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { createHmac } from 'crypto';
|
||||
import config from '~/config/config';
|
||||
|
||||
/**
|
||||
* Генерирует HMAC hash для subscriber_id
|
||||
* @param subscriberId - ID подписчика для которого генерится hash
|
||||
*/
|
||||
export function generateSubscriberHash(subscriberId: string): string {
|
||||
// Используем server_secret как ключ для HMAC
|
||||
const hmac = createHmac('sha256', config.novu.api_key);
|
||||
hmac.update(subscriberId);
|
||||
return hmac.digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет валидность subscriber hash
|
||||
* @param subscriberId - ID подписчика
|
||||
* @param hash - Проверяемый hash
|
||||
*/
|
||||
export function validateSubscriberHash(subscriberId: string, hash: string): boolean {
|
||||
const expectedHash = generateSubscriberHash(subscriberId);
|
||||
return expectedHash === hash;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createHmac } from 'crypto';
|
||||
import config from '~/config/config';
|
||||
|
||||
/**
|
||||
* HMAC-hash для `subscriber_id`.
|
||||
*
|
||||
* Ключ — `SERVER_SECRET` кооператива (identity своя, без внешнего провайдера).
|
||||
* Hash сопровождает `subscriber_id` в профиле пользователя.
|
||||
*/
|
||||
export function generateSubscriberHash(subscriberId: string): string {
|
||||
const hmac = createHmac('sha256', config.server_secret);
|
||||
hmac.update(subscriberId);
|
||||
return hmac.digest('hex');
|
||||
}
|
||||
|
||||
/** Проверка валидности subscriber-hash. */
|
||||
export function validateSubscriberHash(subscriberId: string, hash: string): boolean {
|
||||
return generateSubscriberHash(subscriberId) === hash;
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* Утилитарный класс для валидации webhook'ов от NOVU
|
||||
* Реализует проверку HMAC подписи согласно документации NOVU
|
||||
*/
|
||||
export class WebhookValidationUtil {
|
||||
/**
|
||||
* Валидация HMAC подписи webhook'а от NOVU
|
||||
* @param payload Данные webhook'а (как строка или объект)
|
||||
* @param receivedSignature Подпись из заголовка x-novu-signature
|
||||
* @param secretKey Секретный ключ для проверки подписи
|
||||
* @returns boolean - true если подпись валидна
|
||||
*/
|
||||
static validateHmacSignature(
|
||||
payload: string | Record<string, any>,
|
||||
receivedSignature: string,
|
||||
secretKey: string
|
||||
): boolean {
|
||||
if (!receivedSignature || !secretKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Преобразуем payload в строку, если это объект
|
||||
const payloadString = typeof payload === 'string' ? payload : JSON.stringify(payload);
|
||||
|
||||
// Вычисляем HMAC SHA256
|
||||
const expectedSignature = crypto.createHmac('sha256', secretKey).update(payloadString, 'utf-8').digest('hex');
|
||||
|
||||
// Сравниваем подписи
|
||||
return receivedSignature === expectedSignature;
|
||||
}
|
||||
|
||||
/**
|
||||
* Безопасное сравнение строк (защита от timing attacks)
|
||||
* @param signature1 Первая подпись
|
||||
* @param signature2 Вторая подпись
|
||||
* @returns boolean - true если подписи равны
|
||||
*/
|
||||
static safeCompare(signature1: string, signature2: string): boolean {
|
||||
if (signature1.length !== signature2.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let result = 0;
|
||||
for (let i = 0; i < signature1.length; i++) {
|
||||
result |= signature1.charCodeAt(i) ^ signature2.charCodeAt(i);
|
||||
}
|
||||
|
||||
return result === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Валидация HMAC подписи webhook'а с безопасным сравнением
|
||||
* @param payload Данные webhook'а (как строка или объект)
|
||||
* @param receivedSignature Подпись из заголовка x-novu-signature
|
||||
* @param secretKey Секретный ключ для проверки подписи
|
||||
* @returns boolean - true если подпись валидна
|
||||
*/
|
||||
static validateHmacSignatureSafe(
|
||||
payload: string | Record<string, any>,
|
||||
receivedSignature: string,
|
||||
secretKey: string
|
||||
): boolean {
|
||||
if (!receivedSignature || !secretKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Преобразуем payload в строку, если это объект
|
||||
const payloadString = typeof payload === 'string' ? payload : JSON.stringify(payload);
|
||||
|
||||
// Вычисляем HMAC SHA256
|
||||
const expectedSignature = crypto.createHmac('sha256', secretKey).update(payloadString, 'utf-8').digest('hex');
|
||||
|
||||
// Безопасное сравнение подписей
|
||||
return this.safeCompare(receivedSignature, expectedSignature);
|
||||
}
|
||||
|
||||
/**
|
||||
* Генерация HMAC подписи для тестирования
|
||||
* @param payload Данные для подписи
|
||||
* @param secretKey Секретный ключ
|
||||
* @returns string - HMAC подпись
|
||||
*/
|
||||
static generateHmacSignature(payload: string | Record<string, any>, secretKey: string): string {
|
||||
const payloadString = typeof payload === 'string' ? payload : JSON.stringify(payload);
|
||||
|
||||
return crypto.createHmac('sha256', secretKey).update(payloadString, 'utf-8').digest('hex');
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,7 @@
|
||||
import { defineBuildConfig } from 'unbuild'
|
||||
|
||||
export default defineBuildConfig({
|
||||
entries: [
|
||||
'src/index',
|
||||
// Standalone CLI-runner для синхронизации Novu workflows.
|
||||
// Раньше был tsc-собранный `dist/sync/sync-runner.js` с
|
||||
// extension-less ESM-import'ами, запускавшийся только через
|
||||
// `tsx` (devDep). Через unbuild делаем bundle с резолвингом
|
||||
// импортов — запускается чистым `node` без devDeps.
|
||||
'src/sync/sync-runner',
|
||||
],
|
||||
entries: ['src/index'],
|
||||
declaration: true,
|
||||
clean: false,
|
||||
failOnWarn: false,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@coopenomics/notifications",
|
||||
"version": "2026.5.30-alpha-2",
|
||||
"description": "Библиотека типобезопасных workflow-уведомлений для Novu",
|
||||
"description": "Каталог типобезопасных типов уведомлений и шаблонов Центра уведомлений",
|
||||
"type": "module",
|
||||
"private": false,
|
||||
"main": "dist/index.cjs",
|
||||
@@ -22,14 +22,9 @@
|
||||
"build": "unbuild",
|
||||
"dev": "unbuild --watch",
|
||||
"clean": "rm -rf dist",
|
||||
"sync": "node ./dist/sync/sync-runner.cjs",
|
||||
"sync:dev": "node ./dist/sync/sync-runner.cjs --dev",
|
||||
"sync:watch": "node ./dist/sync/sync-runner.cjs --dev",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.7",
|
||||
"dotenv": "^17.1.0",
|
||||
"transliteration": "^2.3.5",
|
||||
"zod": "^3.22.4",
|
||||
"zod-to-json-schema": "^3.22.5"
|
||||
@@ -43,9 +38,6 @@
|
||||
"unbuild": "^2.0.0",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"bin": {
|
||||
"novu-sync": "./dist/sync/sync-runner.cjs"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
|
||||
@@ -1,246 +0,0 @@
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import {
|
||||
Types,
|
||||
Workflows
|
||||
} 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 getAllWorkflows(): Promise<any[]> {
|
||||
try {
|
||||
const response = await this.client.get('/v2/workflows', {
|
||||
params: {
|
||||
limit: 10000
|
||||
}
|
||||
});
|
||||
|
||||
return response.data.data.workflows || [];
|
||||
} catch (error: any) {
|
||||
console.error('Ошибка получения списка воркфлоу:', console.dir(error.response?.data || error.message, {depth: null}));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Создать новый воркфлоу
|
||||
*/
|
||||
async createWorkflow(data: Types.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: Types.NovuWorkflowData): Promise<any> {
|
||||
try {
|
||||
// Для обновления ВСЕГДА передаем origin: "external" (как в testFramework2.ts)
|
||||
const updateData = { ...data, origin: 'novu-cloud' as const };
|
||||
const response = await this.client.put(`/v2/workflows/${workflowId}`, updateData);
|
||||
// console.log('response', response.data);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error(`Ошибка обновления воркфлоу ${workflowId}:`, error.response?.data || error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Удалить воркфлоу по ID
|
||||
*/
|
||||
async deleteWorkflow(workflowId: string): Promise<void> {
|
||||
try {
|
||||
await this.client.delete(`/v2/workflows/${workflowId}`);
|
||||
console.log(`Удален воркфлоу: ${workflowId}`);
|
||||
} catch (error: any) {
|
||||
if (error.response?.status === 404) {
|
||||
console.log(`Воркфлоу ${workflowId} не найден (уже удален)`);
|
||||
return;
|
||||
}
|
||||
console.error(`Ошибка удаления воркфлоу ${workflowId}:`, error.response?.data || error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Создать или обновить воркфлоу (upsert)
|
||||
*/
|
||||
async upsertWorkflow(workflow: Types.WorkflowDefinition): Promise<any> {
|
||||
try {
|
||||
console.log(`Проверяем воркфлоу: ${workflow.workflowId}`);
|
||||
|
||||
const existingWorkflow = await this.getWorkflow(workflow.workflowId);
|
||||
|
||||
const novuData: Types.NovuWorkflowData = {
|
||||
name: workflow.name,
|
||||
workflowId: workflow.workflowId,
|
||||
description: workflow.description,
|
||||
payloadSchema: workflow.payloadSchema,
|
||||
steps: workflow.steps,
|
||||
preferences: workflow.preferences,
|
||||
tags: workflow.tags,
|
||||
};
|
||||
|
||||
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 deleteAllWorkflows(): Promise<void> {
|
||||
console.log('Получаем список всех воркфлоу для удаления...');
|
||||
|
||||
try {
|
||||
const workflows = await this.getAllWorkflows();
|
||||
console.log(`Найдено ${workflows.length} воркфлоу для удаления`);
|
||||
|
||||
if (workflows.length === 0) {
|
||||
console.log('Нет воркфлоу для удаления');
|
||||
return;
|
||||
}
|
||||
|
||||
const errors: string[] = [];
|
||||
let deletedCount = 0;
|
||||
|
||||
for (const workflow of workflows) {
|
||||
try {
|
||||
await this.deleteWorkflow(workflow.workflowId || workflow._id);
|
||||
deletedCount++;
|
||||
} catch (error: any) {
|
||||
const errorMessage = `Ошибка удаления воркфлоу ${workflow.workflowId || workflow._id}: ${error.message}`;
|
||||
console.error(`✗ ${errorMessage}`);
|
||||
errors.push(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nРезультат удаления:`);
|
||||
console.log(`✅ Удалено: ${deletedCount}`);
|
||||
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} из ${workflows.length} воркфлоу`);
|
||||
}
|
||||
|
||||
console.log('✅ Все существующие воркфлоу удалены успешно');
|
||||
} catch (error: any) {
|
||||
console.error('❌ Критическая ошибка при удалении воркфлоу:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Создать или обновить все воркфлоу (с предварительным удалением существующих)
|
||||
*/
|
||||
async upsertAllWorkflows(): Promise<void> {
|
||||
console.log('🚀 Начинаем полную синхронизацию воркфлоу...');
|
||||
|
||||
// Шаг 1: Удаляем все существующие воркфлоу
|
||||
// try {
|
||||
// await this.deleteAllWorkflows();
|
||||
// } catch (error: any) {
|
||||
// console.error('❌ Ошибка при удалении существующих воркфлоу:', error.message);
|
||||
// throw error;
|
||||
// }
|
||||
|
||||
// Шаг 2: Создаем новые воркфлоу
|
||||
console.log(`\n📝 Начинаем создание ${Workflows.allWorkflows.length} новых воркфлоу...`);
|
||||
|
||||
const errors: string[] = [];
|
||||
let successCount = 0;
|
||||
|
||||
for (const workflow of Workflows.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} из ${Workflows.allWorkflows.length} воркфлоу`);
|
||||
}
|
||||
|
||||
console.log('✅ Все воркфлоу синхронизированы успешно');
|
||||
}
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { NovuSyncService } from './novu-sync.service';
|
||||
import { existsSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { watch } from 'chokidar';
|
||||
import dotenv from 'dotenv';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
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 __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
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 (import.meta.url === `file://${process.argv[1]}`) {
|
||||
main().catch((error) => {
|
||||
console.error('❌ Критическая ошибка:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
export { runSync, NovuSyncService };
|
||||
Generated
-22
@@ -334,9 +334,6 @@ importers:
|
||||
'@nestjs/typeorm':
|
||||
specifier: ^10.0.2
|
||||
version: 10.0.2(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22)(reflect-metadata@0.1.14)(rxjs@7.8.2)(typeorm@0.3.20(ioredis@5.10.1)(pg@8.20.0)(ts-node@10.9.2(@swc/core@1.15.33(@swc/helpers@0.5.19))(@types/node@20.19.37)(typescript@5.9.3)))
|
||||
'@novu/api':
|
||||
specifier: ^1.4.0
|
||||
version: 1.8.0
|
||||
'@octokit/rest':
|
||||
specifier: ^22.0.1
|
||||
version: 22.0.1
|
||||
@@ -1209,12 +1206,6 @@ importers:
|
||||
|
||||
components/notifications:
|
||||
dependencies:
|
||||
axios:
|
||||
specifier: ^1.7.7
|
||||
version: 1.13.6
|
||||
dotenv:
|
||||
specifier: ^17.1.0
|
||||
version: 17.3.1
|
||||
transliteration:
|
||||
specifier: ^2.3.5
|
||||
version: 2.6.1
|
||||
@@ -5410,15 +5401,6 @@ packages:
|
||||
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
'@novu/api@1.8.0':
|
||||
resolution: {integrity: sha512-ba/+fogM+a380AfJ2SSGBQSnHJe0H2Io2NWOf7LeJwFhWIjiYlNAZ1N5DaFv+jNRkaKh33jXDoWWkqqgQUu5lw==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@modelcontextprotocol/sdk': '>=1.5.0 <1.10.0'
|
||||
peerDependenciesMeta:
|
||||
'@modelcontextprotocol/sdk':
|
||||
optional: true
|
||||
|
||||
'@novu/js@3.14.1':
|
||||
resolution: {integrity: sha512-Og9IrPPunw3XleaMl8nkV0vSu0rGXE75CO/CP/upEQWpbK+KvMZbN6rF9+KTrcO1EDG6DwNjI+jhZO83KaIQNg==}
|
||||
|
||||
@@ -24411,10 +24393,6 @@ snapshots:
|
||||
'@nodelib/fs.scandir': 2.1.5
|
||||
fastq: 1.20.1
|
||||
|
||||
'@novu/api@1.8.0':
|
||||
dependencies:
|
||||
zod: 3.25.76
|
||||
|
||||
'@novu/js@3.14.1(react@19.2.4)':
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.7.6
|
||||
|
||||
Reference in New Issue
Block a user