[C28-26][@ant] refactor: полное удаление Novu из контроллера
Typecheck / desktop (pull_request) Successful in 13m42s
Typecheck / controller (pull_request) Successful in 13m4s

Контроллер больше не зависит от внешнего 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:
coopops
2026-06-02 13:40:15 +00:00
parent 0afb73a506
commit 4bb9a317ca
58 changed files with 97 additions and 2665 deletions
-1
View File
@@ -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",
-4
View File
@@ -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);
}
// Создаем нового кандидата в репозитории
@@ -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,
@@ -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,
@@ -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()
@@ -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 успешна');
}
}
@@ -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 {}
@@ -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()}`;
}
}
@@ -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,
@@ -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}`);
}
}
}
}
}
@@ -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
);
}
}
}
/**
@@ -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,
@@ -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);
}
// Добавляем в массив членов для отправки в блокчейн
@@ -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;
}
}
@@ -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,
@@ -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,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');
@@ -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[];
}
@@ -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 {}
@@ -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
@@ -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) {
@@ -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;
}
@@ -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;
}
@@ -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,
@@ -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 могли появляться буквальные &quot;
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-сущности (&quot;), из‑за чего они буквально попадали в 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 -9
View File
@@ -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 -9
View File
@@ -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 };
-22
View File
@@ -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