diff --git a/.gitignore b/.gitignore index e637fba..2148fa8 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,5 @@ lerna-debug.log* configs/main-config.jsonc configs/issue-event-emitter-config.jsonc tmp/* +configs/redmine-statuses-config.jsonc +configs/redmine-status-changes-config.jsonc diff --git a/configs/main-config.jsonc.dist b/configs/main-config.jsonc.dist new file mode 100644 index 0000000..90c7a6b --- /dev/null +++ b/configs/main-config.jsonc.dist @@ -0,0 +1,7 @@ +{ + "couchDb": { + "dbs": { + "changes": "" + } + } +} \ No newline at end of file diff --git a/configs/redmine-status-changes-config.jsonc.dist b/configs/redmine-status-changes-config.jsonc.dist new file mode 100644 index 0000000..d79d57f --- /dev/null +++ b/configs/redmine-status-changes-config.jsonc.dist @@ -0,0 +1,17 @@ +[ + { + "default": false, + "from": "New", + "to": "In Progress", + "messages": [ + { + "recipient": "", + // Handlebars - template engine + "changes_message": "{{qa.name}} got issue #{{issue_id}} after development {{dev.name}}", + "notification_message": "{{ issue_tracker }} #{{ issue_id }} {{ issue_subject }}:\n{{dev.name}} finished development. You can test issue.\n\n{{journal_note}}" + } + // ... + ] + } + // ... +] \ No newline at end of file diff --git a/configs/redmine-statuses-config.jsonc.dist b/configs/redmine-statuses-config.jsonc.dist new file mode 100644 index 0000000..a8804fc --- /dev/null +++ b/configs/redmine-statuses-config.jsonc.dist @@ -0,0 +1,16 @@ +[ + { + "id": 1, + "name": "New" + }, + { + "id": 2, + "name": "In Progress" + }, + { + "id": 3, + "name": "Closed", + "is_closed": true + } + // ... +] \ No newline at end of file diff --git a/libs/event-emitter/LICENSE b/libs/event-emitter/LICENSE new file mode 100644 index 0000000..c0a1501 --- /dev/null +++ b/libs/event-emitter/LICENSE @@ -0,0 +1,7 @@ +Copyright 2022 Gnedov Pavel + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/libs/event-emitter/src/utils/timestamp-converter.ts b/libs/event-emitter/src/utils/timestamp-converter.ts new file mode 100644 index 0000000..5a10f3d --- /dev/null +++ b/libs/event-emitter/src/utils/timestamp-converter.ts @@ -0,0 +1,9 @@ +/* eslint-disable @typescript-eslint/no-namespace */ +export namespace TimestampConverter { + export function toTimestamp(date: string): number { + return new Date(date).getTime(); + } + export function toString(date: number): string { + return new Date(date).toISOString(); + } +} diff --git a/package-lock.json b/package-lock.json index d5b4a29..cc76f76 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@nestjs/serve-static": "^2.2.2", "@nestjs/websockets": "^8.4.4", "axios": "^0.27.2", + "handlebars": "^4.7.7", "imap-simple": "^5.1.0", "nano": "^10.0.0", "reflect-metadata": "^0.1.13", @@ -4372,6 +4373,26 @@ "dev": true, "license": "ISC" }, + "node_modules/handlebars": { + "version": "4.7.7", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.7.tgz", + "integrity": "sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA==", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.0", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, "node_modules/has": { "version": "1.0.3", "license": "MIT", @@ -5987,7 +6008,6 @@ }, "node_modules/neo-async": { "version": "2.6.2", - "dev": true, "license": "MIT" }, "node_modules/node-emoji": { @@ -6984,7 +7004,6 @@ }, "node_modules/source-map": { "version": "0.6.1", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -7624,6 +7643,18 @@ "node": ">=4.2.0" } }, + "node_modules/uglify-js": { + "version": "3.16.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.16.3.tgz", + "integrity": "sha512-uVbFqx9vvLhQg0iBaau9Z75AxWJ8tqM9AV890dIZCLApF4rTcyHwmAvLeEdYRs+BzYWu8Iw81F79ah0EfTXbaw==", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/universalify": { "version": "2.0.0", "dev": true, @@ -7946,6 +7977,11 @@ "node": ">=0.10.0" } }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==" + }, "node_modules/wrap-ansi": { "version": "7.0.0", "dev": true, @@ -10922,6 +10958,18 @@ "version": "4.2.10", "dev": true }, + "handlebars": { + "version": "4.7.7", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.7.tgz", + "integrity": "sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA==", + "requires": { + "minimist": "^1.2.5", + "neo-async": "^2.6.0", + "source-map": "^0.6.1", + "uglify-js": "^3.1.4", + "wordwrap": "^1.0.0" + } + }, "has": { "version": "1.0.3", "requires": { @@ -12013,8 +12061,7 @@ "version": "0.6.3" }, "neo-async": { - "version": "2.6.2", - "dev": true + "version": "2.6.2" }, "node-emoji": { "version": "1.11.0", @@ -12610,8 +12657,7 @@ } }, "source-map": { - "version": "0.6.1", - "dev": true + "version": "0.6.1" }, "source-map-support": { "version": "0.5.21", @@ -12989,6 +13035,12 @@ "version": "4.6.3", "dev": true }, + "uglify-js": { + "version": "3.16.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.16.3.tgz", + "integrity": "sha512-uVbFqx9vvLhQg0iBaau9Z75AxWJ8tqM9AV890dIZCLApF4rTcyHwmAvLeEdYRs+BzYWu8Iw81F79ah0EfTXbaw==", + "optional": true + }, "universalify": { "version": "2.0.0", "dev": true @@ -13199,6 +13251,11 @@ "version": "1.2.3", "dev": true }, + "wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==" + }, "wrap-ansi": { "version": "7.0.0", "dev": true, diff --git a/package.json b/package.json index a61e0d0..e5cf9db 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@nestjs/serve-static": "^2.2.2", "@nestjs/websockets": "^8.4.4", "axios": "^0.27.2", + "handlebars": "^4.7.7", "imap-simple": "^5.1.0", "nano": "^10.0.0", "reflect-metadata": "^0.1.13", diff --git a/src/app.module.ts b/src/app.module.ts index 6469bd1..9c40454 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -5,12 +5,19 @@ import { TimestampEnhancer } from '@app/event-emitter/issue-enhancers/timestamps import { MainController } from '@app/event-emitter/main/main.controller'; import { Logger, Module, OnModuleInit } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; +import { switchMap } from 'rxjs'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import configuration from './configs/app'; +import { RedminePublicUrlConverter } from './converters/redmine-public-url.converter'; +import { Changes } from './couchdb-datasources/changes'; import { CurrentUserEnhancer } from './issue-enhancers/current-user-enhancer'; import { CustomFieldsEnhancer } from './issue-enhancers/custom-fields-enhancer'; import { PersonalNotificationsService } from './notifications/personal-notifications.service'; +import { StatusChangeNotificationsService } from './notifications/status-change-notifications.service'; +import { ChangesCacheWriterService } from './changes-cache-writer/changes-cache-writer.service'; +import { Issues } from '@app/event-emitter/couchdb-datasources/issues'; +import { Users } from '@app/event-emitter/couchdb-datasources/users'; @Module({ imports: [ @@ -25,6 +32,10 @@ import { PersonalNotificationsService } from './notifications/personal-notificat CustomFieldsEnhancer, CurrentUserEnhancer, PersonalNotificationsService, + StatusChangeNotificationsService, + Changes, + RedminePublicUrlConverter, + ChangesCacheWriterService, ], }) export class AppModule implements OnModuleInit { @@ -37,22 +48,80 @@ export class AppModule implements OnModuleInit { private timestampEnhancer: TimestampEnhancer, private customFieldsEnhancer: CustomFieldsEnhancer, private currentUserEnhancer: CurrentUserEnhancer, + private statusChangeNotificationsService: StatusChangeNotificationsService, + private changesCacheWriterService: ChangesCacheWriterService, ) {} onModuleInit() { + Issues.getDatasource(); + Users.getDatasource(); + Changes.getDatasource(); + 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.statusChangeNotificationsService.$changes.subscribe((change) => { + this.logger.log( + `Get status changes messages for ` + + `issue_id = ${change.issue_id}, ` + + `messages = ${JSON.stringify( + change.messages.map((m) => m.change_message).filter((m) => !!m), + )}`, + ); + }); + this.redmineIssuesCacheWriterService.subject.subscribe( async (saveResult) => { await this.personalNotificationsService.analize(saveResult); }, ); + + this.redmineIssuesCacheWriterService.subject + .pipe( + switchMap(async (saveResult) => { + this.logger.debug( + `Save result process started, issue_id = ${saveResult.current.id}`, + ); + return saveResult; + }), + ) + .pipe( + switchMap(async (saveResult) => { + this.logger.debug(`personalNotificationsService.analize started`); + await this.personalNotificationsService.analize(saveResult); + this.logger.debug('personalNotificationsService.analize successed'); + return saveResult; + }), + switchMap(async (saveResult) => { + // eslint-disable-next-line prettier/prettier + this.logger.debug(`statusChangeNotificationsService.getChanges started`); + const changes = + await this.statusChangeNotificationsService.getChanges(saveResult); + // eslint-disable-next-line prettier/prettier + this.logger.debug(`statusChangeNotificationsService.getChanges successed`); + return { changes, saveResult }; + }), + switchMap(async (args) => { + this.logger.debug(`Save changes in couchdb started`); + const promises = args.changes.map((c) => + this.changesCacheWriterService.saveChange(c), + ); + await Promise.all(promises); + this.logger.debug('Save changes in couchdb successed'); + return args; + }), + ) + .subscribe(async (args) => { + this.logger.debug( + `Save result process success finished, issue_id = ${args.saveResult.current.id}`, + ); + }); } } diff --git a/src/changes-cache-writer/changes-cache-writer.service.ts b/src/changes-cache-writer/changes-cache-writer.service.ts new file mode 100644 index 0000000..375dad1 --- /dev/null +++ b/src/changes-cache-writer/changes-cache-writer.service.ts @@ -0,0 +1,54 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { randomUUID } from 'crypto'; +import nano from 'nano'; +import { Subject } from 'rxjs'; +import { Changes } from 'src/couchdb-datasources/changes'; +import { Change } from 'src/models/change.model'; + +@Injectable() +export class ChangesCacheWriterService { + private logger = new Logger(ChangesCacheWriterService.name); + + subject = new Subject(); + + constructor(private changes: Changes) {} + + async saveChange(change: Change): Promise { + this.logger.debug( + `saveChange started, ` + + `issue_id = ${change.issue_id}, ` + + `initiator.name = ${change.initiator.name}`, + ); + if (!change) { + this.logger.debug(`saveChange successed, no data for saving`); + return; + } + const changesDb = await this.changes.getDatasource(); + if (!changesDb) { + this.logger.error(`saveChange failed, changesDb is undefined or null`); + return; + } + const item: Change & nano.MaybeDocument = { ...change }; + item._id = this.getId(); + if (!item) { + this.logger.debug(`saveChange successed, no data for saving`); + return; + } + try { + await changesDb.insert(item); + } catch (ex) { + this.logger.error(`saveChange failed, error = ${ex}`); + return; + } + this.subject.next(change); + this.logger.debug( + `saveChange successed, ` + + `issue_id = ${change.issue_id}, ` + + `initiator.name = ${change.initiator.name}`, + ); + } + + private getId(): string { + return randomUUID(); + } +} diff --git a/src/configs/app.ts b/src/configs/app.ts index 64e2c38..1177c8b 100644 --- a/src/configs/app.ts +++ b/src/configs/app.ts @@ -1,23 +1,36 @@ import RedmineIssueEventEmitterConfigLoader from '@app/event-emitter/configs/main-config'; +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { parse } from 'jsonc-parser'; +import { AppConfig } from 'src/models/app-config.model'; +import RedmineStatusesConfigLoader from './statuses.config'; +import RedmineStatusChangesConfigLoader from './status-changes.config'; const redmineIssueEventEmitterConfig = RedmineIssueEventEmitterConfigLoader(); +const redmineStatusesConfig = RedmineStatusesConfigLoader(); +const redmineStatusChanges = RedmineStatusChangesConfigLoader(); -const appConfig = { - couchDbUrl: - process.env['ELTEX_REDMINE_HELPER_COUCHDB_URL'] || 'http://localhost:5984', +let appConfig: AppConfig; - dbs: { - issues: - process.env['ELTEX_REDMINE_HELPER_COUCHDB_ISSUES_DB_NAME'] || - 'redmine_issues', - users: - process.env['ELTEX_REDMINE_HELPER_COUCHDB_USERS_DB_NAME'] || - 'redmine_users', - }, +export default (): AppConfig => { + if (appConfig) { + return appConfig; + } - redmineIssueEventEmitterConfig: redmineIssueEventEmitterConfig, -}; + const userDefinedConfigPath = process.env['ELTEX_REDMINE_HELPER_CONFIG_PATH']; + const defaultConfigPath = join('configs', 'main-config.jsonc'); + const configPath = userDefinedConfigPath || defaultConfigPath; + + const rawData = readFileSync(configPath, { encoding: 'utf-8' }); + + const data = parse(rawData); + + appConfig = { + ...data, + redmineStatuses: redmineStatusesConfig, + redmineIssueEventEmitterConfig: redmineIssueEventEmitterConfig, + redmineStatusChanges: redmineStatusChanges, + }; -export default () => { return appConfig; }; diff --git a/src/configs/status-changes.config.ts b/src/configs/status-changes.config.ts new file mode 100644 index 0000000..1035e6b --- /dev/null +++ b/src/configs/status-changes.config.ts @@ -0,0 +1,26 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { StatusChangesConfig } from 'src/models/status-changes-config.model'; +import { parse } from 'jsonc-parser'; + +let statusChanges: StatusChangesConfig.Config; + +export default (): StatusChangesConfig.Config => { + if (statusChanges) { + return statusChanges; + } + + const userDefinedConfigPath = + process.env['ELTEX_REDMINE_HELPER_STATUS_CHANGES_CONFIG_PATH']; + const defaultConfigPath = join( + 'configs', + 'redmine-status-changes-config.jsonc', + ); + const configPath = userDefinedConfigPath || defaultConfigPath; + + const rawData = readFileSync(configPath, { encoding: 'utf-8' }); + + statusChanges = parse(rawData); + + return statusChanges; +}; diff --git a/src/configs/statuses.config.ts b/src/configs/statuses.config.ts new file mode 100644 index 0000000..e3eb714 --- /dev/null +++ b/src/configs/statuses.config.ts @@ -0,0 +1,23 @@ +import { StatusesConfig } from 'src/models/statuses-config.model'; +import { parse } from 'jsonc-parser'; +import { join } from 'path'; +import { readFileSync } from 'fs'; + +let redmineStatues: StatusesConfig.Config; + +export default (): StatusesConfig.Config => { + if (redmineStatues) { + return redmineStatues; + } + + const userDefinedConfigPath = + process.env['ELTEX_REDMINE_HELPER_STATUSES_CONFIG_PATH']; + const defaultConfigPath = join('configs', 'redmine-statuses-config.jsonc'); + const configPath = userDefinedConfigPath || defaultConfigPath; + + const rawData = readFileSync(configPath, { encoding: 'utf-8' }); + + redmineStatues = parse(rawData); + + return redmineStatues; +}; diff --git a/src/converters/redmine-public-url.converter.ts b/src/converters/redmine-public-url.converter.ts new file mode 100644 index 0000000..1abf0b1 --- /dev/null +++ b/src/converters/redmine-public-url.converter.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class RedminePublicUrlConverter { + private redminePublicUrlPrefix: string; + + constructor(private configService: ConfigService) { + this.redminePublicUrlPrefix = this.configService.get( + 'redmineIssueEventEmitterConfig.redmineUrlPublic', + ); + } + + convert(issueId: number | string): string { + return `${this.redminePublicUrlPrefix}/issues/${issueId}`; + } +} diff --git a/src/couchdb-datasources/changes.ts b/src/couchdb-datasources/changes.ts new file mode 100644 index 0000000..c90a12b --- /dev/null +++ b/src/couchdb-datasources/changes.ts @@ -0,0 +1,35 @@ +import { CouchDb } from '@app/event-emitter/couchdb-datasources/couchdb'; +import { Injectable, Logger } from '@nestjs/common'; +import nano from 'nano'; +import { Change } from 'src/models/change.model'; +import configuration from '../configs/app'; + +const config = configuration(); + +@Injectable() +export class Changes { + private static logger = new Logger(Changes.name); + private static changesDb = null; + private static initilized = false; + + static async getDatasource(): Promise> { + if (Changes.initilized) { + return Changes.changesDb; + } + Changes.initilized = true; + const n = CouchDb.getCouchDb(); + const changesDbName = config.couchDb.dbs.changes; + const dbs = await n.db.list(); + if (!dbs.includes(changesDbName)) { + await n.db.create(changesDbName); + } + Changes.changesDb = await n.db.use(changesDbName); + Changes.initilized = true; + Changes.logger.log(`Connected to changes db - ${changesDbName}`); + return Changes.changesDb; + } + + async getDatasource(): Promise> { + return await Changes.getDatasource(); + } +} diff --git a/src/issue-enhancers/current-user-enhancer.ts b/src/issue-enhancers/current-user-enhancer.ts index 2b4a15b..e48e2e0 100644 --- a/src/issue-enhancers/current-user-enhancer.ts +++ b/src/issue-enhancers/current-user-enhancer.ts @@ -6,6 +6,8 @@ import { Injectable } from '@nestjs/common'; export class CurrentUserEnhancer implements IssueEnhancerInterface { name = 'current-user'; + // TODO: Переместить правила в конфиг + private rules = { New: 'dev', 'In Progress': 'dev', diff --git a/src/models/app-config.model.ts b/src/models/app-config.model.ts new file mode 100644 index 0000000..de21ac0 --- /dev/null +++ b/src/models/app-config.model.ts @@ -0,0 +1,14 @@ +import { MainConfigModel } from '@app/event-emitter/models/main-config-model'; +import { StatusChangesConfig } from './status-changes-config.model'; +import { StatusesConfig } from './statuses-config.model'; + +export type AppConfig = { + redmineIssueEventEmitterConfig: MainConfigModel; + redmineStatuses: StatusesConfig.Config; + redmineStatusChanges: StatusChangesConfig.Config; + couchDb: { + dbs: { + changes: string; + }; + }; +}; diff --git a/src/models/change-message.model.ts b/src/models/change-message.model.ts new file mode 100644 index 0000000..b3c890e --- /dev/null +++ b/src/models/change-message.model.ts @@ -0,0 +1,7 @@ +import { RedmineTypes } from '@app/event-emitter/models/redmine-types'; + +export type ChangeMessage = { + change_message?: string | null; + notification_message?: string | null; + recipient?: RedmineTypes.PublicUser | null; +}; diff --git a/src/models/change.model.ts b/src/models/change.model.ts new file mode 100644 index 0000000..affdbba --- /dev/null +++ b/src/models/change.model.ts @@ -0,0 +1,27 @@ +import { RedmineTypes } from '@app/event-emitter/models/redmine-types'; +import { ChangeMessage } from './change-message.model'; + +export class Change { + initiator?: RedmineTypes.PublicUser; + dev?: RedmineTypes.PublicUser; + cr?: RedmineTypes.PublicUser; + qa?: RedmineTypes.PublicUser; + current_user?: RedmineTypes.PublicUser; + author?: RedmineTypes.PublicUser; + issue_id: number; + issue_url: string; + issue_tracker: string; + issue_subject: string; + journal_note?: string; + old_status?: { + id?: number; + name?: string; + } | null; + new_status?: { + id?: number; + name?: string; + }; + created_on: string; + created_on_timestamp: number | null; + messages: ChangeMessage[]; +} diff --git a/src/models/status-changes-config.model.ts b/src/models/status-changes-config.model.ts new file mode 100644 index 0000000..251833a --- /dev/null +++ b/src/models/status-changes-config.model.ts @@ -0,0 +1,18 @@ +/* eslint-disable @typescript-eslint/no-namespace */ +export namespace StatusChangesConfig { + export type Message = { + recipient: string; + changes_message: string; + notification_message: string; + }; + + export type Item = { + default: boolean; + new_issue?: boolean; + from: string; + to: string; + messages: Message[]; + }; + + export type Config = Item[]; +} diff --git a/src/models/statuses-config.model.ts b/src/models/statuses-config.model.ts new file mode 100644 index 0000000..2b90a0c --- /dev/null +++ b/src/models/statuses-config.model.ts @@ -0,0 +1,10 @@ +/* eslint-disable @typescript-eslint/no-namespace */ +export namespace StatusesConfig { + export type Item = { + id: number; + name: string; + is_closed?: boolean; + }; + + export type Config = Item[]; +} diff --git a/src/notifications/status-change-notifications.service.ts b/src/notifications/status-change-notifications.service.ts new file mode 100644 index 0000000..23063ea --- /dev/null +++ b/src/notifications/status-change-notifications.service.ts @@ -0,0 +1,229 @@ +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 { TimestampConverter } from '@app/event-emitter/utils/timestamp-converter'; +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { RedminePublicUrlConverter } from 'src/converters/redmine-public-url.converter'; +import { Change } from 'src/models/change.model'; +import { StatusChangesConfig } from 'src/models/status-changes-config.model'; +import { StatusesConfig } from 'src/models/statuses-config.model'; +import Handlebars from 'handlebars'; +import { ChangeMessage } from 'src/models/change-message.model'; +import { Subject } from 'rxjs'; + +@Injectable() +export class StatusChangeNotificationsService { + private logger = new Logger(StatusChangeNotificationsService.name); + private statuses: StatusesConfig.Config; + private statusChanges: StatusChangesConfig.Config; + + $changes = new Subject(); + + constructor( + private usersService: UsersService, + private config: ConfigService, + private redminePublicUrlConverter: RedminePublicUrlConverter, + ) { + this.statuses = this.config.get('redmineStatuses'); + this.statusChanges = this.config.get( + 'redmineStatusChanges', + ); + this.logger.debug( + `StatusChangeNotificationsService created, ` + + `statuses = ${JSON.stringify(this.statuses.map((s) => s.name))}, ` + + `statusChanges.length = ${this.statusChanges.length}, ` + + `statusChanges = ` + + // eslint-disable-next-line prettier/prettier + `${JSON.stringify(this.statusChanges.map((c) => `${c.from} -> ${c.to}`))}`, + ); + } + + async getChanges(saveResponse: SaveResponse): Promise { + this.logger.debug( + `Analize change statuses for issue #${saveResponse.current.id} ` + + `(${saveResponse.current.subject}) for journalsDiff.length = ${saveResponse.journalsDiff.length} start`, + ); + + const changes: Change[] = []; + + const issue = saveResponse.current; + + if (!saveResponse.prev) { + const newChange = await this.getMessagesForNewIssue(issue); + changes.push(newChange); + } + + for (let i = 0; i < saveResponse.journalsDiff.length; i++) { + const journal = saveResponse.journalsDiff[i]; + const change = await this.getMessagesForChangeStatus(issue, journal); + if (change) { + changes.push(change); + } + } + + this.logger.debug( + `Analize change statuses for issue #${saveResponse.current.id} ` + + `(${saveResponse.current.subject}) for journalsDiff.length = ${saveResponse.journalsDiff.length} finished`, + ); + + changes.forEach((c) => this.$changes.next(c)); + + return changes; + } + + private getStatusChangeDetails( + journal: RedmineTypes.Journal, + ): RedmineTypes.JournalDetail | null { + if (!journal?.details || journal?.details.length <= 0) return null; + const details: RedmineTypes.JournalDetail[] = journal?.details; + return ( + details.find((d) => { + return d.name === 'status_id'; + }) || null + ); + } + + private async getMessagesForChangeStatus( + issue: RedmineTypes.Issue & Record, + journal: RedmineTypes.Journal, + ): Promise { + const statusChangeDetails = this.getStatusChangeDetails(journal); + if (!statusChangeDetails) return null; + const change: Change = { + initiator: await this.usersService.getUser(journal?.user?.id), + dev: await this.usersService.getUser(issue?.dev?.id), + qa: await this.usersService.getUser(issue?.qa?.id), + cr: await this.usersService.getUser(issue?.cr?.id), + current_user: await this.usersService.getUser(issue?.current_user?.id), + author: await this.usersService.getUser(issue?.author?.id), + old_status: this.findStatusById(statusChangeDetails.old_value), + new_status: this.findStatusById(statusChangeDetails.new_value), + issue_id: issue.id, + issue_url: this.redminePublicUrlConverter.convert(issue.id), + issue_tracker: issue.tracker?.name || '', + issue_subject: issue.subject || '', + created_on: journal.created_on, + created_on_timestamp: TimestampConverter.toTimestamp(journal.created_on), + journal_note: journal.notes || '', + messages: [], + }; + const messages = await this.generateMessages(statusChangeDetails, change); + if (messages) { + change.messages = messages; + } + return change; + } + + private async getMessagesForNewIssue( + issue: RedmineTypes.Issue & Record, + ): Promise { + const changeParams = this.statusChanges.find((p) => p.new_issue); + const change: Change = { + initiator: await this.usersService.getUser(issue.author.id), + dev: await this.usersService.getUser(issue?.dev?.id), + qa: await this.usersService.getUser(issue?.qa?.id), + cr: await this.usersService.getUser(issue?.cr?.id), + current_user: await this.usersService.getUser(issue?.current_user?.id), + author: await this.usersService.getUser(issue?.author?.id), + old_status: this.findStatusById(issue.status.id), + new_status: this.findStatusById(issue.status.id), + issue_id: issue.id, + issue_url: this.redminePublicUrlConverter.convert(issue.id), + issue_tracker: issue.tracker?.name || '', + issue_subject: issue.subject || '', + created_on: issue.created_on, + created_on_timestamp: TimestampConverter.toTimestamp(issue.created_on), + journal_note: '', + messages: [], + }; + const filledMessages = await Promise.all( + changeParams.messages.map(async (messageParams: any) => { + return await this.generateMessage(messageParams, change); + }), + ); + change.messages = filledMessages.filter((m) => Boolean(m)); + return change; + } + + private findStatusById(id: number | string): StatusesConfig.Item | null { + if ( + typeof id === 'string' && + !Number.isNaN(id) && + Number.isFinite(Number(id)) + ) { + id = Number(id); + } + return this.statuses.find((s) => s.id === id) || null; + } + + private async generateMessages( + detail: RedmineTypes.JournalDetail, + change: Change, + ): Promise { + const oldStatus = this.findStatusById(detail.old_value); + const newStatus = this.findStatusById(detail.new_value); + if (!oldStatus || !newStatus) return null; + const changeParams = this.findChangeParams(oldStatus.name, newStatus.name); + if (!changeParams || !changeParams.messages) return null; + const filledMessages = await Promise.all( + changeParams.messages.map(async (messageParams: any) => { + return await this.generateMessage(messageParams, change); + }), + ); + return filledMessages.filter((m) => Boolean(m)); + } + + private findChangeParams( + oldStatus: string, + newStatus: string, + ): StatusChangesConfig.Item | null { + let foundParam: StatusChangesConfig.Item | null = null; + foundParam = this.statusChanges.find( + (p) => p.from == oldStatus && p.to == newStatus, + ); + if (!foundParam) { + foundParam = this.statusChanges.find((p) => !p.from && p.to == newStatus); + } + if (!foundParam) { + foundParam = this.statusChanges.find((p) => p.from == newStatus && !p.to); + } + if (!foundParam) { + foundParam = this.statusChanges.find((p) => p.default); + } + return foundParam; + } + + private async generateMessage( + messageParams: StatusChangesConfig.Message, + change: Change, + ): Promise { + if (!messageParams) return null; + const recipientUser = await this.usersService.getUser( + change[messageParams.recipient]?.id, + ); + if (!recipientUser) return null; + + let changeMessage = null; + if (messageParams.changes_message) { + const changeMessageTemplate = Handlebars.compile( + messageParams.changes_message, + ); + changeMessage = changeMessageTemplate(change); + } + + let notificationMessage = null; + if (messageParams.notification_message) { + const notificationMessageTemplate = Handlebars.compile( + messageParams.notification_message, + ); + notificationMessage = notificationMessageTemplate(change); + } + + return { + recipient: recipientUser, + change_message: changeMessage, + notification_message: notificationMessage, + }; + } +}