diff --git a/src/app.module.ts b/src/app.module.ts index 6469bd1..02f2906 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -8,9 +8,12 @@ import { ConfigModule } from '@nestjs/config'; 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'; @Module({ imports: [ @@ -25,6 +28,9 @@ import { PersonalNotificationsService } from './notifications/personal-notificat CustomFieldsEnhancer, CurrentUserEnhancer, PersonalNotificationsService, + StatusChangeNotificationsService, + Changes, + RedminePublicUrlConverter, ], }) export class AppModule implements OnModuleInit { diff --git a/src/models/change-message.model.ts b/src/models/change-message.model.ts index ec1f881..b3c890e 100644 --- a/src/models/change-message.model.ts +++ b/src/models/change-message.model.ts @@ -1,7 +1,7 @@ import { RedmineTypes } from '@app/event-emitter/models/redmine-types'; export type ChangeMessage = { - changes_message?: string | null; + change_message?: string | null; notification_message?: string | null; recipient?: RedmineTypes.PublicUser | null; }; diff --git a/src/notifications/status-change-notifications.service.ts b/src/notifications/status-change-notifications.service.ts new file mode 100644 index 0000000..51d517f --- /dev/null +++ b/src/notifications/status-change-notifications.service.ts @@ -0,0 +1,180 @@ +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'; + +@Injectable() +export class StatusChangeNotificationsService { + private logger = new Logger(StatusChangeNotificationsService.name); + private statuses: StatusesConfig.Config; + private statusChanges: StatusChangesConfig.Config; + + 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}`, + ); + } + + 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; + + 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`, + ); + 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: any, + journal: any, + ): 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 findStatusById(id: number | string): StatusesConfig.Item | null { + if (typeof id === 'string' && !Number.isNaN(id) && Number.isFinite(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) => 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, + }; + } +}