diff --git a/configs/eccm-config.jsonc.dist b/configs/eccm-config.jsonc.dist index 3975be1..729d9f0 100644 --- a/configs/eccm-config.jsonc.dist +++ b/configs/eccm-config.jsonc.dist @@ -7,5 +7,9 @@ "name": "", "people": [""] } - ] + ], + "dailyTime": { + "hour": 9, + "minute": 0 + } } \ No newline at end of file diff --git a/configs/main-config.jsonc.dist b/configs/main-config.jsonc.dist index e558201..9e26c4d 100644 --- a/configs/main-config.jsonc.dist +++ b/configs/main-config.jsonc.dist @@ -3,8 +3,10 @@ "dbs": { "changes": "", "userMetaInfo": "", + "eccmDailyReports": "" } }, "telegramBotToken": "", - "personalMessageTemplate": "" + "personalMessageTemplate": "", + "periodValidityNotification": 43200 // 12h } \ No newline at end of file diff --git a/libs/event-emitter/src/rsslistener/rsslistener.ts b/libs/event-emitter/src/rsslistener/rsslistener.ts index 2d3aa23..3669cdb 100644 --- a/libs/event-emitter/src/rsslistener/rsslistener.ts +++ b/libs/event-emitter/src/rsslistener/rsslistener.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { RssListenerDefaultParams, RssListenerParams, @@ -14,6 +14,8 @@ const parser = new Parser(); @Injectable() export class RssListener implements EventsListener { + private logger = new Logger(RssListener.name); + issues = new BehaviorSubject([]); private updateTimeout; @@ -56,7 +58,15 @@ export class RssListener implements EventsListener { const url = subscription.url; const regexp = new RegExp(subscription.issueNumberParser); const subjectParser = CreateSubjectsParserByRegExp(regexp); - const feed = await parser.parseURL(url); + let feed; + try { + feed = await parser.parseURL(url); + } catch (ex) { + this.logger.error( + `Error at load data from rss by url ${url} with error message ${ex.message}`, + ); + return []; + } const issueNumbers: number[] = feed.items .filter((item) => { const itemDate = new Date(item.pubDate); diff --git a/package-lock.json b/package-lock.json index bc367dd..63fd4b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@nestjs/core": "^8.0.0", "@nestjs/platform-express": "^8.0.0", "@nestjs/platform-socket.io": "^8.4.4", + "@nestjs/schedule": "^2.1.0", "@nestjs/serve-static": "^2.2.2", "@nestjs/websockets": "^8.4.4", "axios": "^0.27.2", @@ -21,6 +22,7 @@ "handlebars": "^4.7.7", "hbs": "^4.2.0", "imap-simple": "^5.1.0", + "luxon": "^3.1.0", "nano": "^10.0.0", "node-telegram-bot-api": "^0.59.0", "reflect-metadata": "^0.1.13", @@ -34,8 +36,10 @@ "@nestjs/schematics": "^8.0.0", "@nestjs/testing": "^8.0.0", "@types/cache-manager": "^4.0.1", + "@types/cron": "^2.0.0", "@types/express": "^4.17.13", "@types/jest": "27.4.0", + "@types/luxon": "^3.1.0", "@types/node": "^16.0.0", "@types/node-telegram-bot-api": "^0.57.1", "@types/supertest": "^2.0.11", @@ -1628,6 +1632,20 @@ "rxjs": "^7.1.0" } }, + "node_modules/@nestjs/schedule": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-2.1.0.tgz", + "integrity": "sha512-4Xaw56WiW3VsxEPPnj/iDtfjcO+sUZyYAeRxD0gnF5havncxjAnv52Iw7UH3DuzzUA784xPGgGje3Fq0Gu925g==", + "dependencies": { + "cron": "2.0.0", + "uuid": "8.3.2" + }, + "peerDependencies": { + "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0", + "@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0", + "reflect-metadata": "^0.1.12" + } + }, "node_modules/@nestjs/schematics": { "version": "8.0.11", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-8.0.11.tgz", @@ -1984,6 +2002,16 @@ "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.12.tgz", "integrity": "sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw==" }, + "node_modules/@types/cron": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/cron/-/cron-2.0.0.tgz", + "integrity": "sha512-xZM08fqvwIXgghtPVkSPKNgC+JoMQ2OHazEvyTKnNf7aWu1aB6/4lBbQFrb03Td2cUGG7ITzMv3mFYnMu6xRaQ==", + "dev": true, + "dependencies": { + "@types/luxon": "*", + "@types/node": "*" + } + }, "node_modules/@types/eslint": { "version": "8.4.5", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.5.tgz", @@ -2088,6 +2116,12 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/luxon": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.1.0.tgz", + "integrity": "sha512-gCd/HcCgjqSxfMrgtqxCgYk/22NBQfypwFUG7ZAyG/4pqs51WLTcUzVp1hqTbieDYeHS3WoVEh2Yv/2l+7B0Vg==", + "dev": true + }, "node_modules/@types/mime": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-3.0.1.tgz", @@ -3632,6 +3666,22 @@ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "dev": true }, + "node_modules/cron": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/cron/-/cron-2.0.0.tgz", + "integrity": "sha512-RPeRunBCFr/WEo7WLp8Jnm45F/ziGJiHVvVQEBSDTSGu6uHW49b2FOP2O14DcXlGJRLhwE7TIoDzHHK4KmlL6g==", + "dependencies": { + "luxon": "^1.23.x" + } + }, + "node_modules/cron/node_modules/luxon": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-1.28.0.tgz", + "integrity": "sha512-TfTiyvZhwBYM/7QdAVDh+7dBTBA29v4ik0Ce9zda3Mnf8on1S5KJI8P2jKFZ8+5C0jhmr0KwJEO/Wdpm0VeWJQ==", + "engines": { + "node": "*" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -7195,6 +7245,14 @@ "node": ">=12" } }, + "node_modules/luxon": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.1.0.tgz", + "integrity": "sha512-7w6hmKC0/aoWnEsmPCu5Br54BmbmUp5GfcqBxQngRcXJ+q5fdfjEzn7dxmJh2YdDhgW8PccYtlWKSv4tQkrTQg==", + "engines": { + "node": ">=12" + } + }, "node_modules/macos-release": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/macos-release/-/macos-release-2.5.0.tgz", @@ -11518,6 +11576,15 @@ "tslib": "2.4.0" } }, + "@nestjs/schedule": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-2.1.0.tgz", + "integrity": "sha512-4Xaw56WiW3VsxEPPnj/iDtfjcO+sUZyYAeRxD0gnF5havncxjAnv52Iw7UH3DuzzUA784xPGgGje3Fq0Gu925g==", + "requires": { + "cron": "2.0.0", + "uuid": "8.3.2" + } + }, "@nestjs/schematics": { "version": "8.0.11", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-8.0.11.tgz", @@ -11797,6 +11864,16 @@ "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.12.tgz", "integrity": "sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw==" }, + "@types/cron": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/cron/-/cron-2.0.0.tgz", + "integrity": "sha512-xZM08fqvwIXgghtPVkSPKNgC+JoMQ2OHazEvyTKnNf7aWu1aB6/4lBbQFrb03Td2cUGG7ITzMv3mFYnMu6xRaQ==", + "dev": true, + "requires": { + "@types/luxon": "*", + "@types/node": "*" + } + }, "@types/eslint": { "version": "8.4.5", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.5.tgz", @@ -11901,6 +11978,12 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "@types/luxon": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.1.0.tgz", + "integrity": "sha512-gCd/HcCgjqSxfMrgtqxCgYk/22NBQfypwFUG7ZAyG/4pqs51WLTcUzVp1hqTbieDYeHS3WoVEh2Yv/2l+7B0Vg==", + "dev": true + }, "@types/mime": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-3.0.1.tgz", @@ -13102,6 +13185,21 @@ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "dev": true }, + "cron": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/cron/-/cron-2.0.0.tgz", + "integrity": "sha512-RPeRunBCFr/WEo7WLp8Jnm45F/ziGJiHVvVQEBSDTSGu6uHW49b2FOP2O14DcXlGJRLhwE7TIoDzHHK4KmlL6g==", + "requires": { + "luxon": "^1.23.x" + }, + "dependencies": { + "luxon": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-1.28.0.tgz", + "integrity": "sha512-TfTiyvZhwBYM/7QdAVDh+7dBTBA29v4ik0Ce9zda3Mnf8on1S5KJI8P2jKFZ8+5C0jhmr0KwJEO/Wdpm0VeWJQ==" + } + } + }, "cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -15760,6 +15858,11 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.13.2.tgz", "integrity": "sha512-VJL3nIpA79TodY/ctmZEfhASgqekbT574/c4j3jn4bKXbSCnTTCH/KltZyvL2GlV+tGSMtsWyem8DCX7qKTMBA==" }, + "luxon": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.1.0.tgz", + "integrity": "sha512-7w6hmKC0/aoWnEsmPCu5Br54BmbmUp5GfcqBxQngRcXJ+q5fdfjEzn7dxmJh2YdDhgW8PccYtlWKSv4tQkrTQg==" + }, "macos-release": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/macos-release/-/macos-release-2.5.0.tgz", diff --git a/package.json b/package.json index 4bfddfc..c2455af 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@nestjs/core": "^8.0.0", "@nestjs/platform-express": "^8.0.0", "@nestjs/platform-socket.io": "^8.4.4", + "@nestjs/schedule": "^2.1.0", "@nestjs/serve-static": "^2.2.2", "@nestjs/websockets": "^8.4.4", "axios": "^0.27.2", @@ -33,6 +34,7 @@ "handlebars": "^4.7.7", "hbs": "^4.2.0", "imap-simple": "^5.1.0", + "luxon": "^3.1.0", "nano": "^10.0.0", "node-telegram-bot-api": "^0.59.0", "reflect-metadata": "^0.1.13", @@ -46,8 +48,10 @@ "@nestjs/schematics": "^8.0.0", "@nestjs/testing": "^8.0.0", "@types/cache-manager": "^4.0.1", + "@types/cron": "^2.0.0", "@types/express": "^4.17.13", "@types/jest": "27.4.0", + "@types/luxon": "^3.1.0", "@types/node": "^16.0.0", "@types/node-telegram-bot-api": "^0.57.1", "@types/supertest": "^2.0.11", diff --git a/src/app.module.ts b/src/app.module.ts index d2cddd0..34a48ae 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -4,7 +4,7 @@ import { EnhancerService } from '@app/event-emitter/issue-enhancers/enhancer.ser import { TimestampEnhancer } from '@app/event-emitter/issue-enhancers/timestamps-enhancer'; import { MainController } from '@app/event-emitter/main/main.controller'; import { CacheModule, Logger, Module, OnModuleInit } from '@nestjs/common'; -import { ConfigModule, ConfigService } from '@nestjs/config'; +import { ConfigModule } from '@nestjs/config'; import { switchMap, tap } from 'rxjs'; import { AppController } from './app.controller'; import { AppService } from './app.service'; @@ -24,11 +24,14 @@ import { UserMetaInfo } from './couchdb-datasources/user-meta-info'; import { PersonalNotificationAdapterService } from './notifications/adapters/personal-notification.adapter/personal-notification.adapter.service'; import { StatusChangeAdapterService } from './notifications/adapters/status-change.adapter.service'; import { CurrentIssuesEccmReportService } from './reports/current-issues-eccm.report.service'; -import { CurrentIssuesBotHandlerService } from './telegram-bot/handlers/current-issues.bot-handler.service'; +import { CurrentIssuesEccmBotHandlerService } from './telegram-bot/handlers/current-issues-eccm.bot-handler.service'; import { CurrentIssuesEccmReportController } from './reports/current-issues-eccm.report.controller'; import { DailyEccmReportController } from './reports/daily-eccm.report.controller'; import { DailyEccmReportService } from './reports/daily-eccm.report.service'; import { ChangesService } from './changes/changes.service'; +import { DailyEccmReportsDatasource } from './couchdb-datasources/daily-eccm-reports.datasource'; +import { ScheduleModule } from '@nestjs/schedule'; +import { DailyEccmReportTask } from './reports/daily-eccm.report.task'; @Module({ imports: [ @@ -39,6 +42,7 @@ import { ChangesService } from './changes/changes.service'; CacheModule.register({ isGlobal: true, }), + ScheduleModule.forRoot(), ], controllers: [ AppController, @@ -61,9 +65,11 @@ import { ChangesService } from './changes/changes.service'; PersonalNotificationAdapterService, StatusChangeAdapterService, CurrentIssuesEccmReportService, - CurrentIssuesBotHandlerService, + CurrentIssuesEccmBotHandlerService, DailyEccmReportService, ChangesService, + DailyEccmReportsDatasource, + DailyEccmReportTask, ], }) export class AppModule implements OnModuleInit { @@ -88,6 +94,7 @@ export class AppModule implements OnModuleInit { Users.getDatasource(); Changes.getDatasource(); UserMetaInfo.getDatasource(); + DailyEccmReportsDatasource.getDatasource(); this.enhancerService.addEnhancer([ this.timestampEnhancer, @@ -104,20 +111,8 @@ export class AppModule implements OnModuleInit { ); this.personalNotificationAdapterService.send(resp); }); - this.statusChangeNotificationsService.$changes.subscribe((change) => { - const messages = change.messages - .map((m) => m.change_message) - .filter((m) => !!m); - const notifications = change.messages - .map((m) => m.notification_message) - .filter((m) => !!m); - this.logger.log( - `Get status changes messages for ` + - `issue_id = ${change.issue_id}, ` + - `messages = ${JSON.stringify(messages)}, ` + - `notifications = ${JSON.stringify(notifications)}`, - ); - this.statusChangeAdapterService.send(change); + this.statusChangeNotificationsService.$batchChanges.subscribe((changes) => { + this.statusChangeAdapterService.batchSend(changes); }); this.redmineIssuesCacheWriterService.subject diff --git a/src/couchdb-datasources/daily-eccm-reports.datasource.ts b/src/couchdb-datasources/daily-eccm-reports.datasource.ts new file mode 100644 index 0000000..8ff3f22 --- /dev/null +++ b/src/couchdb-datasources/daily-eccm-reports.datasource.ts @@ -0,0 +1,41 @@ +import { CouchDb } from '@app/event-emitter/couchdb-datasources/couchdb'; +import { Injectable, Logger } from '@nestjs/common'; +import nano from 'nano'; +import { DailyEccmReport } from 'src/reports/daily-eccm.report.service'; +import configuration from '../configs/app'; + +const config = configuration(); + +@Injectable() +export class DailyEccmReportsDatasource { + private static logger = new Logger(DailyEccmReportsDatasource.name); + private static db = null; + private static initilized = false; + + static async getDatasource(): Promise< + nano.DocumentScope + > { + if (DailyEccmReportsDatasource.initilized) { + return DailyEccmReportsDatasource.db; + } + DailyEccmReportsDatasource.initilized = true; + const n = CouchDb.getCouchDb(); + const dbName = config.couchDb.dbs.eccmDailyReports; + const dbs = await n.db.list(); + if (!dbs.includes(dbName)) { + await n.db.create(dbName); + } + DailyEccmReportsDatasource.db = await n.db.use(dbName); + DailyEccmReportsDatasource.initilized = true; + DailyEccmReportsDatasource.logger.log( + `Connected to eccm-daily-reports db - ${dbName}`, + ); + return DailyEccmReportsDatasource.db; + } + + async getDatasource(): Promise< + nano.DocumentScope + > { + return await DailyEccmReportsDatasource.getDatasource(); + } +} diff --git a/src/models/app-config.model.ts b/src/models/app-config.model.ts index be968a9..b1352af 100644 --- a/src/models/app-config.model.ts +++ b/src/models/app-config.model.ts @@ -13,7 +13,9 @@ export type AppConfig = { dbs: { changes: string; userMetaInfo: string; + eccmDailyReports: string; }; }; telegramBotToken: string; + periodValidityNotification: number; }; diff --git a/src/models/eccm-config.model.ts b/src/models/eccm-config.model.ts index 37317b7..74b3ab9 100644 --- a/src/models/eccm-config.model.ts +++ b/src/models/eccm-config.model.ts @@ -10,5 +10,9 @@ export namespace EccmConfig { projectName: string; currentIssuesStatuses: string[]; groups: UserGroup[]; + dailyTime: { + hour: number; + minute: number; + }; }; } diff --git a/src/notifications/adapters/status-change.adapter.service.ts b/src/notifications/adapters/status-change.adapter.service.ts index 06ccba6..9e9ecaa 100644 --- a/src/notifications/adapters/status-change.adapter.service.ts +++ b/src/notifications/adapters/status-change.adapter.service.ts @@ -1,11 +1,40 @@ import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import TelegramBot from 'node-telegram-bot-api'; import { Change } from 'src/models/change.model'; import { TelegramBotService } from 'src/telegram-bot/telegram-bot.service'; +// eslint-disable-next-line @typescript-eslint/no-namespace +namespace StatusChangeAdapter { + export type MsgFromBatch = { + initiatorId: number; + recipientId: number; + issueId: number; + createdAt: number; + msg: string; + options?: TelegramBot.SendMessageOptions; + }; +} + @Injectable() export class StatusChangeAdapterService { - constructor(private telegramBotService: TelegramBotService) {} + private periodValidityNotification: number; + constructor( + private telegramBotService: TelegramBotService, + private configService: ConfigService, + ) { + this.periodValidityNotification = this.configService.get( + 'periodValidityNotification', + ); + } + + // TODO: Удалить устаревший метод send + + /** + * @deprecated + * @see StatusChangeAdapterService.batchSend + */ async send(change: Change): Promise { const promises = change.messages.map((m) => { if (!m || !m.recipient || !m.recipient.id || !m.notification_message) { @@ -19,4 +48,64 @@ export class StatusChangeAdapterService { }); return await Promise.all(promises); } + + async batchSend(changes: Change[]): Promise { + const messages = this.getMessages(changes).map((item) => { + item.options = { parse_mode: 'HTML' }; + return item; + }); + for (let i = 0; i < messages.length; i++) { + const message = messages[i]; + await this.telegramBotService.sendMessageByRedmineId( + message.recipientId, + message.msg, + message.options, + ); + } + } + + private getMessages(changes: Change[]): StatusChangeAdapter.MsgFromBatch[] { + const res: StatusChangeAdapter.MsgFromBatch[] = []; + const store: Record = {}; + const nowTimestamp = new Date().getTime(); + for (let i = 0; i < changes.length; i++) { + const change = changes[i]; + if ( + nowTimestamp - change.created_on_timestamp > + this.periodValidityNotification + ) { + continue; + } + for (let j = 0; j < change.messages.length; j++) { + const message = change.messages[j]; + if (!message.notification_message) continue; + if (change.initiator.id == message.recipient.id) continue; + const item: StatusChangeAdapter.MsgFromBatch = { + initiatorId: change.initiator.id, + recipientId: message.recipient.id, + createdAt: change.created_on_timestamp, + issueId: change.issue_id, + msg: message.notification_message, + }; + const key = this.keyForMsgFromBatch(item); + if ( + !store[key] || + (store[key] && store[key].createdAt < item.createdAt) + ) { + store[key] = item; + } + } + } + for (const key in store) { + if (Object.prototype.hasOwnProperty.call(store, key)) { + const item = store[key]; + res.push(item); + } + } + return res; + } + + private keyForMsgFromBatch(item: StatusChangeAdapter.MsgFromBatch): string { + return `${item.issueId}-${item.recipientId}`; + } } diff --git a/src/notifications/status-change-notifications.service.ts b/src/notifications/status-change-notifications.service.ts index 23063ea..7c9e525 100644 --- a/src/notifications/status-change-notifications.service.ts +++ b/src/notifications/status-change-notifications.service.ts @@ -19,6 +19,7 @@ export class StatusChangeNotificationsService { private statusChanges: StatusChangesConfig.Config; $changes = new Subject(); + $batchChanges = new Subject(); constructor( private usersService: UsersService, @@ -68,6 +69,7 @@ export class StatusChangeNotificationsService { ); changes.forEach((c) => this.$changes.next(c)); + this.$batchChanges.next(changes); return changes; } diff --git a/src/reports/daily-eccm.report.controller.ts b/src/reports/daily-eccm.report.controller.ts index 8721885..3c9f971 100644 --- a/src/reports/daily-eccm.report.controller.ts +++ b/src/reports/daily-eccm.report.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Query, Render } from '@nestjs/common'; +import { Controller, Get, Param, Query, Render } from '@nestjs/common'; import { DailyEccmReport, DailyEccmReportService, @@ -10,28 +10,61 @@ export class DailyEccmReportController { @Get() @Render('daily-eccm-report') + async getReportDefault( + @Query('from') from: string, + @Query('to') to: string, + ): Promise { + return await this.getReport(from, to); + } + + @Get('/delete/:name') + async deleteReport(@Param('name') name: string): Promise { + return await this.dailyEccmReportService.deleteReport(name); + } + + @Get('/load/:name') + @Render('daily-eccm-report') + async loadReport( + @Param('name') name: string, + ): Promise { + return await this.dailyEccmReportService.loadReport(name); + } + + @Get('/load/:name/raw') + async loadReportRawData( + @Param('name') name: string, + ): Promise { + return await this.dailyEccmReportService.loadReport(name); + } + + @Get('/generate') + @Render('daily-eccm-report') async getReport( @Query('from') from: string, @Query('to') to: string, + @Query('name') name?: string, + @Query('overwrite') overwrite?: boolean, ): Promise { - const now = new Date().toISOString(); - return await this.dailyEccmReportService.generateReport({ - from: from, - to: to, - reportDate: now, - }); + return await this.getReportRawData(from, to, name, overwrite); } - @Get('/raw') + @Get('/generate/raw') async getReportRawData( @Query('from') from: string, @Query('to') to: string, + @Query('name') name?: string, + @Query('overwrite') overwrite?: boolean, ): Promise { - const now = new Date().toISOString(); - return await this.dailyEccmReportService.generateReport({ - from: from, - to: to, - reportDate: now, - }); + const params = this.dailyEccmReportService.generateParams(from, to, name); + const data = await this.dailyEccmReportService.generateReport(params); + if (name) { + const saveResult = await this.dailyEccmReportService.saveReport( + data, + overwrite, + ); + return saveResult ? data : null; + } else { + return data; + } } } diff --git a/src/reports/daily-eccm.report.service.ts b/src/reports/daily-eccm.report.service.ts index f249cd8..45010e6 100644 --- a/src/reports/daily-eccm.report.service.ts +++ b/src/reports/daily-eccm.report.service.ts @@ -10,6 +10,10 @@ import { TimestampConverter } from '@app/event-emitter/utils/timestamp-converter import { IssuesService } from '@app/event-emitter/issues/issues.service'; import { UsersService } from '@app/event-emitter/users/users.service'; import { GetParentsHint } from '@app/event-emitter/utils/get-parents-hint'; +import { DailyEccmReportsDatasource } from 'src/couchdb-datasources/daily-eccm-reports.datasource'; +import { Timestamped } from '@app/event-emitter/models/timestamped'; +import { TimestampNowFill } from '@app/event-emitter/utils/timestamp-now-fill'; +import { DateTime } from 'luxon'; // eslint-disable-next-line @typescript-eslint/no-namespace export namespace DailyEccmReport { @@ -96,7 +100,9 @@ export namespace DailyEccmReport { export type Params = { from: string; to: string; - reportDate: string; + name: string; + project: string; + versions: string[]; }; export type IssueAndChanges = { @@ -140,28 +146,32 @@ export namespace DailyEccmReport { private eccmUsers: RedmineTypes.User[], private issuesService: IssuesService, ) { - const users: Models.UserReport[] = this.eccmUsers.map((user) => { - if (!user) { - this.logger.error(`Not found user data`); - return; - } - const u: Models.User = { - id: user.id, - lastname: user.lastname, - firstname: user.firstname, - }; - return { - user: u, - activities: [], - issuesGroupedByStatus: [], - } as Models.UserReport; - }).filter((user) => Boolean(user)); + const users: Models.UserReport[] = this.eccmUsers + .map((user) => { + if (!user) { + this.logger.error(`Not found user data`); + return; + } + const u: Models.User = { + id: user.id, + lastname: user.lastname, + firstname: user.firstname, + }; + return { + user: u, + activities: [], + issuesGroupedByStatus: [], + } as Models.UserReport; + }) + .filter((user) => Boolean(user)); this.report = { byUsers: users, params: { from: '', to: '', - reportDate: '', + name: '', + project: '', + versions: [], }, }; } @@ -173,7 +183,9 @@ export namespace DailyEccmReport { setParams(params: Models.Params): void { this.report.params.from = params.from; this.report.params.to = params.to; - this.report.params.reportDate = params.reportDate; + this.report.params.name = params.name; + this.report.params.versions = [...params.versions]; + this.report.params.project = params.project; } async setCurrentIssues(issues: RedmineTypes.Issue[]): Promise { @@ -355,6 +367,7 @@ export class DailyEccmReportService { private changesService: ChangesService, private issuesService: IssuesService, private usersService: UsersService, + private dailyEccmReportsDatasource: DailyEccmReportsDatasource, ) { this.eccmConfig = this.configService.get('redmineEccm'); } @@ -389,6 +402,83 @@ export class DailyEccmReportService { return report.get(); } + generateParams( + from: string, + to: string, + name?: string, + ): DailyEccmReport.Models.Params { + const fromDate = DateTime.fromISO(from); + if (!fromDate.isValid) throw new Error('from is invalid date'); + const toDate = DateTime.fromISO(to); + if (!toDate.isValid) throw new Error('to is invalid date'); + let nameValue: string | null = name || null; + if (!nameValue) { + nameValue = DateTime.now().toFormat('yyyy-MM-dd'); + } + return { + from: fromDate.toISO(), + to: toDate.toISO(), + name: nameValue, + project: this.eccmConfig.projectName, + versions: [...this.eccmConfig.currentVersions], + }; + } + + async saveReport( + report: DailyEccmReport.Models.Report, + overwrite?: boolean, + ): Promise { + const name = report.params.name; + const existsReport = await this.loadReport(name); + if (existsReport && !overwrite) { + return false; + } + + type ReportType = nano.DocumentGetResponse & + DailyEccmReport.Models.Report & + Timestamped; + + const newReport: ReportType = TimestampNowFill({ + ...report, + _id: name, + _rev: existsReport?._rev, + }); + + const datasource = await this.dailyEccmReportsDatasource.getDatasource(); + await datasource.insert(newReport); + + return true; + } + + async loadReport( + name: string, + ): Promise< + | (DailyEccmReport.Models.Report & nano.DocumentGetResponse & Timestamped) + | null + > { + const datasource = await this.dailyEccmReportsDatasource.getDatasource(); + let resp: any = null; + try { + resp = await datasource.get(name); + } catch (ex) { + this.logger.warn( + `Cannot load report ${name} with error message ${ex.message}`, + ); + } + return resp; + } + + async deleteReport(name: string): Promise { + const datasource = await this.dailyEccmReportsDatasource.getDatasource(); + const report = await this.loadReport(name); + let res = false; + try { + await datasource.destroy(report._id, report._rev); + res = true; + } catch (ex) {} + return res; + } + private async createEmptyReport(): Promise { const usersList: string[] = this.eccmConfig.groups.reduce((acc, group) => { group.people.forEach((fullname) => { diff --git a/src/reports/daily-eccm.report.task.ts b/src/reports/daily-eccm.report.task.ts new file mode 100644 index 0000000..b986936 --- /dev/null +++ b/src/reports/daily-eccm.report.task.ts @@ -0,0 +1,67 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { DailyEccmReportService } from './daily-eccm.report.service'; +import { DateTime, Duration } from 'luxon'; +import { ConfigService } from '@nestjs/config'; + +// eslint-disable-next-line @typescript-eslint/no-namespace +namespace Models { + export type Time = { + hour: number; + minute: number; + }; +} + +@Injectable() +export class DailyEccmReportTask { + private logger = new Logger(DailyEccmReportTask.name); + private eccmDailyTime: Models.Time; + + constructor( + private dailyEccmReportService: DailyEccmReportService, + private configService: ConfigService, + ) { + this.eccmDailyTime = this.configService.get( + 'redmineEccm.dailyTime', + ); + } + + @Cron('25 9,10 1-5 * *') + async generateReport(): Promise { + this.logger.log(`Generate daily eccm report by cron task started`); + const now = DateTime.now(); + const toDate = DateTime.local( + now.year, + now.month, + now.day, + this.eccmDailyTime.hour, + this.eccmDailyTime.minute, + ); + let duration: Duration; + if (now.weekday == 1) { + duration = Duration.fromObject({ days: -3 }); + } else { + duration = Duration.fromObject({ days: -1 }); + } + const fromDate = toDate.plus(duration); + const name = now.toFormat('yyyy-MM-dd'); + const params = this.dailyEccmReportService.generateParams( + fromDate.toJSDate().toISOString(), + toDate.toJSDate().toISOString(), + name, + ); + + const reportData = await this.dailyEccmReportService.generateReport(params); + const saveResult = await this.dailyEccmReportService.saveReport( + reportData, + true, + ); + this.logger.log( + `Generate daily eccm report by cron task ` + + `finished with params = ${JSON.stringify(params)} ` + + `and result = ${saveResult}`, + ); + + return; + } +} diff --git a/src/telegram-bot/handlers/current-issues.bot-handler.service.ts b/src/telegram-bot/handlers/current-issues-eccm.bot-handler.service.ts similarity index 72% rename from src/telegram-bot/handlers/current-issues.bot-handler.service.ts rename to src/telegram-bot/handlers/current-issues-eccm.bot-handler.service.ts index 78f9e60..e4228c0 100644 --- a/src/telegram-bot/handlers/current-issues.bot-handler.service.ts +++ b/src/telegram-bot/handlers/current-issues-eccm.bot-handler.service.ts @@ -2,17 +2,23 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import TelegramBot from 'node-telegram-bot-api'; import { EccmConfig } from 'src/models/eccm-config.model'; -import { CurrentIssuesEccmReport, CurrentIssuesEccmReportService } from 'src/reports/current-issues-eccm.report.service'; +import { + CurrentIssuesEccmReport, + CurrentIssuesEccmReportService, +} from 'src/reports/current-issues-eccm.report.service'; import { UserMetaInfoService } from 'src/user-meta-info/user-meta-info.service'; import { TelegramBotService } from '../telegram-bot.service'; +import { TelegramBotHandlerInterface } from '../telegram.bot-handler.interface'; @Injectable() -export class CurrentIssuesBotHandlerService { - private forName = /\/current_issues_eccm (.+) (.+)/; +export class CurrentIssuesEccmBotHandlerService + implements TelegramBotHandlerInterface +{ + private forName = /\/current_issues_eccm (.+)/; private forCurrentUser = /\/current_issues_eccm/; private service: TelegramBotService; private eccmConfig: EccmConfig.Config; - private logger = new Logger(CurrentIssuesBotHandlerService.name); + private logger = new Logger(CurrentIssuesEccmBotHandlerService.name); constructor( private configService: ConfigService, @@ -26,7 +32,7 @@ export class CurrentIssuesBotHandlerService { if (!this.service) { this.service = service; } - bot.onText(/\/current_issues_eccm/, async (msg) => { + bot.onText(this.forCurrentUser, async (msg) => { const userMetaInfo = await this.userMetaInfoService.findByTelegramId( msg.chat.id, ); @@ -44,4 +50,8 @@ export class CurrentIssuesBotHandlerService { }); }); } + + getHelpMsg(): string { + return '/current_issues_eccm - ваши текущие задачи из проекта ECCM'; + } } diff --git a/src/telegram-bot/telegram-bot.service.ts b/src/telegram-bot/telegram-bot.service.ts index 37da5fa..5c5dfe2 100644 --- a/src/telegram-bot/telegram-bot.service.ts +++ b/src/telegram-bot/telegram-bot.service.ts @@ -5,7 +5,8 @@ import TelegramBot from 'node-telegram-bot-api'; import { UserMetaInfoService } from 'src/user-meta-info/user-meta-info.service'; import axios from 'axios'; import { UserMetaInfoModel } from 'src/models/user-meta-info.model'; -import { CurrentIssuesBotHandlerService } from './handlers/current-issues.bot-handler.service'; +import { CurrentIssuesEccmBotHandlerService } from './handlers/current-issues-eccm.bot-handler.service'; +import { TelegramBotHandlerInterface } from './telegram.bot-handler.interface'; @Injectable() export class TelegramBotService { @@ -16,16 +17,19 @@ export class TelegramBotService { private registerRe = /\/register (\d+) (.+)/; + private handlers: TelegramBotHandlerInterface[] = []; + constructor( private userMetaInfoService: UserMetaInfoService, private usersService: UsersService, private configService: ConfigService, - private currentIssuesBotHandlerService: CurrentIssuesBotHandlerService, + private currentIssuesBotHandlerService: CurrentIssuesEccmBotHandlerService, ) { this.telegramBotToken = this.configService.get('telegramBotToken'); this.redminePublicUrlPrefix = this.configService.get('redmineUrlPublic'); this.initTelegramBot(); + this.handlers.push(this.currentIssuesBotHandlerService); } private async initTelegramBot(): Promise { @@ -51,19 +55,22 @@ export class TelegramBotService { msg.chat.id, ); let helpMessage: string; + /* eslint-disable */ if (userMetaInfo) { - // eslint-disable-next-line prettier/prettier + const handlersMessages = this.handlers + .map((handler) => handler.getHelpMsg()) + .join('\n'); helpMessage = [ - `/current_issues - мои текущие задачи`, + handlersMessages, `/help` ].join('\n'); } else { - // eslint-disable-next-line prettier/prettier helpMessage = [ `/register `, - `/help` + `/help`, ].join('\n'); } + /* eslint-enable */ this.logger.debug( `Sent help message for telegramChatId = ${msg.chat.id}, ` + `message = ${helpMessage}`, diff --git a/src/telegram-bot/telegram.bot-handler.interface.ts b/src/telegram-bot/telegram.bot-handler.interface.ts new file mode 100644 index 0000000..cc20fc3 --- /dev/null +++ b/src/telegram-bot/telegram.bot-handler.interface.ts @@ -0,0 +1,7 @@ +import TelegramBot from 'node-telegram-bot-api'; +import { TelegramBotService } from './telegram-bot.service'; + +export interface TelegramBotHandlerInterface { + init(service: TelegramBotService, bot: TelegramBot): Promise; + getHelpMsg(): string; +} diff --git a/views/daily-eccm-report.hbs b/views/daily-eccm-report.hbs index 6b3d305..02c752f 100644 --- a/views/daily-eccm-report.hbs +++ b/views/daily-eccm-report.hbs @@ -10,7 +10,9 @@
  • От - {{this.params.from}}
  • До - {{this.params.to}}
  • -
  • Дата выгрузки - {{this.params.reportDate}}
  • +
  • Имя отчёта - {{this.params.name}}
  • +
  • Имя проекта - {{this.params.project}}
  • +
  • Версии - {{this.params.versions}}

Отчёт по работникам

@@ -36,7 +38,7 @@
    {{#each this.activities}}
  • - {{>redmineIssueAHref issue=this.issue}} (приоритет {{this.issue.priority.name}}; версия {{this.issue.fixed_version.name}}) - {{this.issue.subject}} + {{>redmineIssueAHref issue=this.issue}} (приоритет {{this.issue.priority.name}}; версия {{this.issue.fixed_version.name}}; статус {{this.issue.status.name}}) - {{this.issue.subject}}
      {{#each this.changes}}
    • {{this.created_on}}: {{this.change_message}}