diff --git a/libs/event-emitter/src/event-emitter.module.ts b/libs/event-emitter/src/event-emitter.module.ts index 7ad4b8e..02bd75d 100644 --- a/libs/event-emitter/src/event-emitter.module.ts +++ b/libs/event-emitter/src/event-emitter.module.ts @@ -1,8 +1,6 @@ import { DynamicModule, Logger, Module, OnModuleInit } from '@nestjs/common'; import { EventEmitterService } from './event-emitter.service'; import { RedmineEventsGateway } from './events/redmine-events.gateway'; -import { ServeStaticModule } from '@nestjs/serve-static'; -import { join } from 'path'; import MainConfig from './configs/main-config'; import { ConfigModule } from '@nestjs/config'; import { RedmineDataLoader } from './redmine-data-loader/redmine-data-loader'; @@ -41,10 +39,6 @@ export class EventEmitterModule implements OnModuleInit { UsersService, IssuesService, TimestampEnhancer, - { - provide: 'ENHANCERS', - useValue: params?.enhancers || null, - }, EnhancerService, ], exports: [ @@ -75,23 +69,25 @@ export class EventEmitterModule implements OnModuleInit { onModuleInit() { const queue = this.redmineEventsGateway.getIssuesChangesQueue(); const subj = queue.queue; - subj.subscribe(async (issues: any) => { - this.logger.debug(`Changed issues = ${JSON.stringify(issues)}`); + subj.subscribe(async (issues: RedmineTypes.Issue[]) => { + this.logger.debug( + `Changed issues - ` + + issues.map((i) => `#${i.id} (${i.subject})`).join(', '), + ); for (let i = 0; i < issues.length; i++) { const issue: RedmineTypes.Issue = issues[i]; try { - this.logger.debug( - `Save issue #${issue.id} - ${JSON.stringify(issue)}`, - ); + this.logger.debug(`Save issue #${issue.id} (${issue.subject})`); const response = await this.redmineIssuesCacheWriterService.saveIssue( issue, ); this.logger.debug( - `Save issue #${issue.id} response = ${JSON.stringify(response)}`, + // eslint-disable-next-line prettier/prettier + `Save ${(response.prev) ? 'exists' : 'new'} issue #${issue.id} - count of changes = ${response.journalsDiff.length}`, ); } catch (ex) { this.logger.error(`Saving issue error - ${ex}`, null, { diff --git a/libs/event-emitter/src/events/redmine-events.gateway.ts b/libs/event-emitter/src/events/redmine-events.gateway.ts index 408c9bc..6240c2e 100644 --- a/libs/event-emitter/src/events/redmine-events.gateway.ts +++ b/libs/event-emitter/src/events/redmine-events.gateway.ts @@ -18,6 +18,7 @@ import { WebhookConfigItemModel } from '../models/webhook-config-item-model'; import { RssListener } from '../rsslistener/rsslistener'; import { EventsListener } from './events-listener'; import { Logger } from '@nestjs/common'; +import { RedmineTypes } from '../models/redmine-types'; type IssuesChangesQueueParams = { updateInterval: number; @@ -48,14 +49,14 @@ export class RedmineEventsGateway { issuesChanges( // eslint-disable-next-line @typescript-eslint/no-unused-vars @MessageBody() data: any, - ): Observable> { + ): Observable> { return this.issuesChangesObservable; } - private issuesChangesQueue: Queue; - getIssuesChangesQueue(): Queue { + private issuesChangesQueue: Queue; + getIssuesChangesQueue(): Queue { if (!this.issuesChangesQueue) { - this.issuesChangesQueue = new Queue( + this.issuesChangesQueue = new Queue( this.issuesChangesQueueParams.updateInterval, this.issuesChangesQueueParams.itemsLimit, async (issueNumbers) => { diff --git a/libs/event-emitter/src/issue-cache-writer/redmine-issues-cache-writer.service.ts b/libs/event-emitter/src/issue-cache-writer/redmine-issues-cache-writer.service.ts index 0c38f2b..f28a769 100644 --- a/libs/event-emitter/src/issue-cache-writer/redmine-issues-cache-writer.service.ts +++ b/libs/event-emitter/src/issue-cache-writer/redmine-issues-cache-writer.service.ts @@ -17,13 +17,11 @@ export class RedmineIssuesCacheWriterService { async saveIssue(issue: RedmineTypes.Issue): Promise { this.logger.debug( - `Saving issue ${issue?.id || '-'} - ${ - issue?.subject || '-' - }, issue data = ${JSON.stringify(issue)}`, + `Saving issue ${issue?.id || '-'} (${issue?.subject || '-'})`, ); const id = Number(issue['id']); let prevIssue: (nano.DocumentGetResponse & RedmineTypes.Issue) | null; - const issueDb = await Issues.getDatasource(); + const issueDb = await this.issues.getDatasource(); if (!issueDb) { throw `CouchDb datasource must defined`; } @@ -46,7 +44,7 @@ export class RedmineIssuesCacheWriterService { journalsDiff: this.getJournalsDiff(prevIssue, newIssue), }; this.logger.debug( - `Saving issue success ${issue?.id || '-'} - ${issue?.subject || '-'}`, + `Saving issue success #${issue?.id || '-'} - ${issue?.subject || '-'}`, ); this.subject.next(res); return res; diff --git a/libs/event-emitter/src/issue-enhancers/enhancer.service.ts b/libs/event-emitter/src/issue-enhancers/enhancer.service.ts index 6ec07cc..1fe0065 100644 --- a/libs/event-emitter/src/issue-enhancers/enhancer.service.ts +++ b/libs/event-emitter/src/issue-enhancers/enhancer.service.ts @@ -1,31 +1,19 @@ -import { Inject, Injectable, Logger } from '@nestjs/common'; -import { ModuleRef } from '@nestjs/core'; +import { Injectable, Logger } from '@nestjs/common'; import { RedmineTypes } from '../models/redmine-types'; import { IssueEnhancerInterface } from './issue-enhancer-interface'; @Injectable() export class EnhancerService { private logger = new Logger(EnhancerService.name); - private initilialized = false; + private enhancers: IssueEnhancerInterface[] = []; - constructor( - private moduleRef: ModuleRef, - @Inject('ENHANCERS') - private enhancers: IssueEnhancerInterface[], - ) {} - - private init(): void { - if (!this.initilialized) { - this.logger.log(`Initialize EnhancerService start`); - this.enhancers.forEach((e) => e.init(this.moduleRef)); - this.logger.log(`Initialize EnhancerService finished`); - } + addEnhancer(enh: IssueEnhancerInterface[]): void { + this.enhancers.push(...enh); } async enhanceIssue( issue: RedmineTypes.Issue & Record, ): Promise> { - this.init(); for (let i = 0; i < this.enhancers.length; i++) { const enhancer = this.enhancers[i]; // eslint-disable-next-line prettier/prettier diff --git a/libs/event-emitter/src/issue-enhancers/issue-enhancer-interface.ts b/libs/event-emitter/src/issue-enhancers/issue-enhancer-interface.ts index c951602..dd79d0b 100644 --- a/libs/event-emitter/src/issue-enhancers/issue-enhancer-interface.ts +++ b/libs/event-emitter/src/issue-enhancers/issue-enhancer-interface.ts @@ -1,9 +1,7 @@ -import { ModuleRef } from '@nestjs/core'; import { RedmineTypes } from '../models/redmine-types'; export interface IssueEnhancerInterface { name: string; - init(moduleRef: ModuleRef); enhance( issue: RedmineTypes.Issue, ): Promise>; diff --git a/libs/event-emitter/src/issue-enhancers/timestamps-enhancer.ts b/libs/event-emitter/src/issue-enhancers/timestamps-enhancer.ts index 254e4a0..b662d93 100644 --- a/libs/event-emitter/src/issue-enhancers/timestamps-enhancer.ts +++ b/libs/event-emitter/src/issue-enhancers/timestamps-enhancer.ts @@ -6,10 +6,6 @@ import { IssueEnhancerInterface } from './issue-enhancer-interface'; export class TimestampEnhancer implements IssueEnhancerInterface { name = 'timestamp'; - init() { - return; - } - async enhance( issue: RedmineTypes.Issue, ): Promise> { diff --git a/libs/event-emitter/src/models/module-params.ts b/libs/event-emitter/src/models/module-params.ts index bf1fd0a..2eafb13 100644 --- a/libs/event-emitter/src/models/module-params.ts +++ b/libs/event-emitter/src/models/module-params.ts @@ -1,7 +1,5 @@ -import { IssueEnhancerInterface } from '../issue-enhancers/issue-enhancer-interface'; import { MainConfigModel } from './main-config-model'; export type ModuleParams = { config?: MainConfigModel; - enhancers?: IssueEnhancerInterface[]; }; diff --git a/libs/event-emitter/src/users/users.service.ts b/libs/event-emitter/src/users/users.service.ts index 0bacaab..47d542b 100644 --- a/libs/event-emitter/src/users/users.service.ts +++ b/libs/event-emitter/src/users/users.service.ts @@ -46,6 +46,32 @@ export class UsersService { ); } + async findUserByName( + firstname: string, + lastname: string, + ): Promise { + const userFromMemoryCache = this.memoryCache.find((item) => { + return item.firstname === firstname && item.lastname === lastname; + }); + if (userFromMemoryCache) { + return RedmineTypes.CreateUser(userFromMemoryCache); + } + const usersDb = await this.users.getDatasource(); + const res = await usersDb.find({ + selector: { + firstname: firstname, + lastname: lastname, + }, + limit: 1, + }); + if (!res && !res.docs && !res.docs[0]) { + return null; + } + const userFromDb = res.docs[0]; + this.memoryCache.set(userFromDb.id, userFromDb); + return RedmineTypes.CreateUser(userFromDb); + } + private async getUserFromRedmine( userId: number, ): Promise { diff --git a/libs/event-emitter/src/utils/memory-cache.ts b/libs/event-emitter/src/utils/memory-cache.ts index b7433a4..fa7e8fb 100644 --- a/libs/event-emitter/src/utils/memory-cache.ts +++ b/libs/event-emitter/src/utils/memory-cache.ts @@ -39,6 +39,22 @@ export class MemoryCache { } } + find(fn: (item: T, key: K) => boolean): (T & Timestamped) | null { + for (const key in this.memoryCache) { + if (Object.prototype.hasOwnProperty.call(this.memoryCache, key)) { + const item = this.memoryCache[key]; + if (TimestampIsTimeouted(item, this.timeout)) { + delete this.memoryCache[key]; + continue; + } + if (fn(item, key as any)) { + return item; + } + } + } + return null; + } + private startAutoclean() { setTimeout(() => { this.cleanTimeouted(); diff --git a/src/app.module.ts b/src/app.module.ts index 89bd93a..6469bd1 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,38 +1,58 @@ import { EventEmitterModule } from '@app/event-emitter'; +import { RedmineIssuesCacheWriterService } from '@app/event-emitter/issue-cache-writer/redmine-issues-cache-writer.service'; +import { EnhancerService } from '@app/event-emitter/issue-enhancers/enhancer.service'; import { TimestampEnhancer } from '@app/event-emitter/issue-enhancers/timestamps-enhancer'; import { MainController } from '@app/event-emitter/main/main.controller'; -import { UsersService } from '@app/event-emitter/users/users.service'; -import { Module } from '@nestjs/common'; +import { Logger, Module, OnModuleInit } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import configuration from './configs/app'; import { CurrentUserEnhancer } from './issue-enhancers/current-user-enhancer'; import { CustomFieldsEnhancer } from './issue-enhancers/custom-fields-enhancer'; +import { PersonalNotificationsService } from './notifications/personal-notifications.service'; @Module({ imports: [ EventEmitterModule.register({ config: configuration().redmineIssueEventEmitterConfig, - enhancers: [ - new TimestampEnhancer(), - new CustomFieldsEnhancer(), - new CurrentUserEnhancer(), - ], }), ConfigModule.forRoot({ load: [configuration] }), ], controllers: [AppController, MainController], providers: [ AppService, - UsersService, CustomFieldsEnhancer, CurrentUserEnhancer, + PersonalNotificationsService, ], }) -export class AppModule { +export class AppModule implements OnModuleInit { + private logger = new Logger(AppModule.name); + constructor( + private personalNotificationsService: PersonalNotificationsService, + private redmineIssuesCacheWriterService: RedmineIssuesCacheWriterService, + private enhancerService: EnhancerService, private timestampEnhancer: TimestampEnhancer, private customFieldsEnhancer: CustomFieldsEnhancer, + private currentUserEnhancer: CurrentUserEnhancer, ) {} + + onModuleInit() { + this.enhancerService.addEnhancer([ + this.timestampEnhancer, + this.customFieldsEnhancer, + this.currentUserEnhancer, + ]); + this.personalNotificationsService.$messages.subscribe((message) => { + // eslint-disable-next-line prettier/prettier + this.logger.log(`Get personal message ${JSON.stringify(message.message)} for recipients ${JSON.stringify(message.recipients)}`); + }); + this.redmineIssuesCacheWriterService.subject.subscribe( + async (saveResult) => { + await this.personalNotificationsService.analize(saveResult); + }, + ); + } } diff --git a/src/issue-enhancers/current-user-enhancer.ts b/src/issue-enhancers/current-user-enhancer.ts index 392619b..2b4a15b 100644 --- a/src/issue-enhancers/current-user-enhancer.ts +++ b/src/issue-enhancers/current-user-enhancer.ts @@ -20,10 +20,6 @@ export class CurrentUserEnhancer implements IssueEnhancerInterface { Rejected: 'dev', }; - init() { - return; - } - async enhance( issue: RedmineTypes.Issue, ): Promise> { diff --git a/src/issue-enhancers/custom-fields-enhancer.ts b/src/issue-enhancers/custom-fields-enhancer.ts index 4473337..ac50d81 100644 --- a/src/issue-enhancers/custom-fields-enhancer.ts +++ b/src/issue-enhancers/custom-fields-enhancer.ts @@ -2,17 +2,11 @@ import { IssueEnhancerInterface } from '@app/event-emitter/issue-enhancers/issue import { RedmineTypes } from '@app/event-emitter/models/redmine-types'; import { UsersService } from '@app/event-emitter/users/users.service'; import { Injectable } from '@nestjs/common'; -import { ModuleRef } from '@nestjs/core'; - @Injectable() export class CustomFieldsEnhancer implements IssueEnhancerInterface { name = 'custom-fields'; - private usersService: UsersService; - - init(moduleRef: ModuleRef): void { - this.usersService = moduleRef.get(UsersService); - } + constructor(private usersService: UsersService) {} async enhance( issue: RedmineTypes.Issue, diff --git a/src/models/personal-parsed-message.model.ts b/src/models/personal-parsed-message.model.ts new file mode 100644 index 0000000..346d343 --- /dev/null +++ b/src/models/personal-parsed-message.model.ts @@ -0,0 +1,5 @@ +export class PersonalParsedMessage { + sender: number; + message: string; + recipients: number[]; +} diff --git a/src/notifications/personal-notifications.service.ts b/src/notifications/personal-notifications.service.ts new file mode 100644 index 0000000..2642aac --- /dev/null +++ b/src/notifications/personal-notifications.service.ts @@ -0,0 +1,75 @@ +import { RedmineTypes } from '@app/event-emitter/models/redmine-types'; +import { SaveResponse } from '@app/event-emitter/models/save-response'; +import { UsersService } from '@app/event-emitter/users/users.service'; +import { Injectable, Logger } from '@nestjs/common'; +import { Subject } from 'rxjs'; +import { PersonalParsedMessage } from 'src/models/personal-parsed-message.model'; + +@Injectable() +export class PersonalNotificationsService { + private userNameRe = /@([\wА-Яа-яЁё]+) ([\wА-Яа-яЁё]+)@/g; + private logger = new Logger(PersonalNotificationsService.name); + + $messages = new Subject(); + + constructor(private usersService: UsersService) {} + + async analize(data: SaveResponse): Promise { + this.logger.debug( + `Analize personal messages for issue ` + + `#${data.current.id} (${data.current.subject}) start`, + ); + const pMessages = data.journalsDiff.map(async (journal) => { + const message = await this.getMessage(journal); + if (message) { + this.logger.log( + `Found personal message in issue #${data.current.id} ` + + `(${data.current.subject}) - ` + + `message = ${JSON.stringify(message.message)} ` + + `from sender ${message.sender} ` + + `for recipients ${JSON.stringify(message.recipients)}`, + ); + this.$messages.next(message); + } + return message; + }); + const res = (await Promise.all(pMessages)).filter((m) => Boolean(m)); + this.logger.debug( + `Analize personal messages for issue ` + + `#${data.current.id} (${data.current.subject}) finished`, + ); + return res; + } + + private async getMessage( + journal: RedmineTypes.Journal, + ): Promise { + if (!journal.notes) { + return null; + } + const notes = journal.notes; + const results = notes.matchAll(this.userNameRe); + const recipients: number[] = []; + let result = results.next(); + while (!result.done) { + if (result.value && result.value[1] && result.value[2]) { + const firstname = result.value[1]; + const lastname = result.value[2]; + const user = await this.usersService.findUserByName( + firstname, + lastname, + ); + if (user) recipients.push(user.id); + } + result = results.next(); + } + if (recipients.length > 0) { + return { + message: notes, + sender: journal.user.id, + recipients: recipients, + }; + } + return null; + } +}