diff --git a/.gitignore b/.gitignore index 1dce6ce..43607ab 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,5 @@ configs/redmine-status-changes-config.jsonc configs/eccm-versions-config.jsonc configs/eccm-config.jsonc configs/current-user-rules.jsonc +configs/simple-kanban-board-config.jsonc +configs/kanban-boards/ diff --git a/configs/main-config.jsonc.dist b/configs/main-config.jsonc.dist index 9e26c4d..4d5c643 100644 --- a/configs/main-config.jsonc.dist +++ b/configs/main-config.jsonc.dist @@ -3,7 +3,8 @@ "dbs": { "changes": "", "userMetaInfo": "", - "eccmDailyReports": "" + "eccmDailyReports": "", + "eccmDailyReportsUserComments": "" } }, "telegramBotToken": "", diff --git a/configs/simple-kanban-board-config.jsonc.dist b/configs/simple-kanban-board-config.jsonc.dist new file mode 100644 index 0000000..d1f1c8a --- /dev/null +++ b/configs/simple-kanban-board-config.jsonc.dist @@ -0,0 +1,3 @@ +{ + "path": "" +} \ No newline at end of file diff --git a/libs/event-emitter/src/configs/dynamic-loader.ts b/libs/event-emitter/src/configs/dynamic-loader.ts new file mode 100644 index 0000000..b357d00 --- /dev/null +++ b/libs/event-emitter/src/configs/dynamic-loader.ts @@ -0,0 +1,27 @@ +import { CacheTTL, Injectable, Logger } from '@nestjs/common'; +import { readFileSync, existsSync } from 'fs'; +import { join } from 'path'; + +@Injectable() +export class DynamicLoader { + private logger = new Logger(DynamicLoader.name); + + @CacheTTL(60) + load( + file: string, + opts: { path: string; ext: string; parser: (raw: any) => any }, + ): any { + const fullFileName = join(opts.path, `${file}.${opts.ext}`); + if (!existsSync(fullFileName)) return null; + let rawData: any; + let data: any; + try { + rawData = readFileSync(fullFileName, { encoding: 'utf-8' }); + data = opts.parser(rawData); + } catch (ex) { + this.logger.error(`Error at config read - ${ex}`); + return null; + } + return data; + } +} diff --git a/libs/event-emitter/src/converters/redmine-public-url.converter.ts b/libs/event-emitter/src/converters/redmine-public-url.converter.ts new file mode 100644 index 0000000..596e74d --- /dev/null +++ b/libs/event-emitter/src/converters/redmine-public-url.converter.ts @@ -0,0 +1,70 @@ +import { RedmineTypes } from '@app/event-emitter/models/redmine-types'; +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('redmineUrlPublic'); + } + + convert(issueId: number | string): string { + return `${this.redminePublicUrlPrefix}/issues/${issueId}`; + } + + getUrl(issueId: number | string): string { + return this.convert(issueId); + } + + getHtmlHref(issue: RedmineTypes.Issue): string { + const url = this.getUrl(issue.id); + return `${issue.tracker.name} #${issue.id}`; + } + + getMinHtmlHref(issueId: number | string): string { + const url = this.getUrl(issueId); + return `#${issueId}`; + } + + /** + * Обогащение текста с идентификаторами задач html ссылками на эти задачи + * + * Например текст `"Эта ошибка будет решена в рамках задач #123 и #456"` + * будет заменён на `"Эта ошибка будет решена в рамках задач + * #123 и + * #456"` + * + * @param text + * @param linkGenerator функция замены отдельного идентификатора на html ссылку. По умолчанию + * будет использоваться собственная функция this.getMinHtmlHref + * @see convert + */ + enrichTextWithIssues( + text: string, + linkGenerator?: (issueId: number | string) => string, + ): string { + const generator = linkGenerator + ? linkGenerator + : (issueId) => this.getMinHtmlHref(issueId); + + const regexp = /^\d+/; + + const parts = text.split('#'); + for (let i = 0; i < parts.length; i++) { + let part = parts[i]; + const match = part.match(regexp); + if (!match) { + continue; + } + const issueId = match[0]; + const replacment = generator(issueId); + part = part.replace(new RegExp(`^${issueId}`), replacment); + parts[i] = part; + } + + return parts.join(''); + } +} diff --git a/libs/event-emitter/src/event-emitter.module.ts b/libs/event-emitter/src/event-emitter.module.ts index 02bd75d..f5b16f2 100644 --- a/libs/event-emitter/src/event-emitter.module.ts +++ b/libs/event-emitter/src/event-emitter.module.ts @@ -18,6 +18,15 @@ import { IssuesService } from './issues/issues.service'; import { IssuesController } from './issues/issues.controller'; import { TimestampEnhancer } from './issue-enhancers/timestamps-enhancer'; import { EnhancerService } from './issue-enhancers/enhancer.service'; +import { ProjectDashboardService } from './project-dashboard/project-dashboard.service'; +import { RootIssueSubTreesWidgetService } from './project-dashboard/widgets/root-issue-subtrees.widget.service'; +import { DynamicLoader } from './configs/dynamic-loader'; +import { RedminePublicUrlConverter } from './converters/redmine-public-url.converter'; +import { IssueUrlEnhancer } from './issue-enhancers/issue-url-enhancer'; +import { ListIssuesByUsersWidgetService } from './project-dashboard/widgets/list-issues-by-users.widget.service'; +import { ListIssuesByUsersLikeJiraWidgetService } from './project-dashboard/widgets/list-issues-by-users-like-jira.widget.service'; +import { TimePassedHighlightEnhancer } from './issue-enhancers/time-passed-highlight-enhancer'; +import { ListIssuesByFieldsWidgetService } from './project-dashboard/widgets/list-issues-by-fields.widget.service'; @Module({}) export class EventEmitterModule implements OnModuleInit { @@ -40,6 +49,15 @@ export class EventEmitterModule implements OnModuleInit { IssuesService, TimestampEnhancer, EnhancerService, + ProjectDashboardService, + RootIssueSubTreesWidgetService, + DynamicLoader, + RedminePublicUrlConverter, + IssueUrlEnhancer, + ListIssuesByUsersWidgetService, + ListIssuesByUsersLikeJiraWidgetService, + TimePassedHighlightEnhancer, + ListIssuesByFieldsWidgetService, ], exports: [ EventEmitterService, @@ -54,6 +72,15 @@ export class EventEmitterModule implements OnModuleInit { IssuesService, TimestampEnhancer, EnhancerService, + ProjectDashboardService, + RootIssueSubTreesWidgetService, + DynamicLoader, + RedminePublicUrlConverter, + IssueUrlEnhancer, + ListIssuesByUsersWidgetService, + ListIssuesByUsersLikeJiraWidgetService, + TimePassedHighlightEnhancer, + ListIssuesByFieldsWidgetService, ], controllers: [MainController, UsersController, IssuesController], }; diff --git a/libs/event-emitter/src/issue-enhancers/issue-url-enhancer.ts b/libs/event-emitter/src/issue-enhancers/issue-url-enhancer.ts new file mode 100644 index 0000000..8cebbf8 --- /dev/null +++ b/libs/event-emitter/src/issue-enhancers/issue-url-enhancer.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@nestjs/common'; +import { RedminePublicUrlConverter } from '../converters/redmine-public-url.converter'; +import { RedmineTypes } from '../models/redmine-types'; +import { IssueEnhancerInterface } from './issue-enhancer-interface'; + +@Injectable() +export class IssueUrlEnhancer implements IssueEnhancerInterface { + name = 'issue-url'; + + constructor(private redminePublicUrlConverter: RedminePublicUrlConverter) {} + + async enhance( + issue: RedmineTypes.Issue, + ): Promise> { + const res: RedmineTypes.Issue & Record = issue; + if (!issue || !issue.id) return issue; + res['url'] = { + url: this.redminePublicUrlConverter.getUrl(issue.id), + fullHref: this.redminePublicUrlConverter.getHtmlHref(issue), + minHref: this.redminePublicUrlConverter.getMinHtmlHref(issue.id), + }; + return res; + } +} diff --git a/libs/event-emitter/src/issue-enhancers/tag-styled-enhancer.ts b/libs/event-emitter/src/issue-enhancers/tag-styled-enhancer.ts new file mode 100644 index 0000000..d938e4c --- /dev/null +++ b/libs/event-emitter/src/issue-enhancers/tag-styled-enhancer.ts @@ -0,0 +1,86 @@ +/* eslint-disable @typescript-eslint/no-namespace */ +import { Injectable, Logger } from '@nestjs/common'; +import { RedmineTypes } from '../models/redmine-types'; +import { IssueEnhancerInterface } from './issue-enhancer-interface'; + +export namespace TagStyledEnhancerNs { + /** + * * key - tag name, + * * value - css style for tag + */ + export type Styles = Record; + + export type StyledTag = { + tag: string; + style: string; + }; + + export type TagsParams = { + tagsKeyName: string; + styles: Styles; + defaultStyle: string; + styledTagsKeyName: string; + }; + + export type ConfigWithTagsStyles = { + tags?: TagsParams; + [key: string]: any; + }; + + export function CreateTagStyledEnhancerForConfig( + cfg: ConfigWithTagsStyles, + ): TagStyledEnhancer | null { + if (!cfg || !cfg.tags) return null; + return new TagStyledEnhancer( + (issue: RedmineTypes.ExtendedIssue) => { + if (!issue) return []; + if ( + typeof issue[cfg.tags.tagsKeyName] === 'object' && + issue[cfg.tags.tagsKeyName].length > 0 + ) { + return issue[cfg.tags.tagsKeyName]; + } else { + return []; + } + }, + cfg.tags.styles, + cfg.tags.defaultStyle, + cfg.tags.styledTagsKeyName, + ); + } +} + +export class TagStyledEnhancer implements IssueEnhancerInterface { + name = 'tag-styled'; + + constructor( + private tagsGetter: (issue: RedmineTypes.ExtendedIssue) => string[], + private styles: TagStyledEnhancerNs.Styles, + private defaultStyle: string, + private keyName: string, + ) {} + + async enhance( + issue: RedmineTypes.ExtendedIssue, + ): Promise { + if (!issue) return issue; + const tags = this.tagsGetter(issue); + const styles = [] as TagStyledEnhancerNs.StyledTag[]; + for (let i = 0; i < tags.length; i++) { + const tagName = tags[i]; + if (this.styles[tagName]) { + styles.push({ + tag: tagName, + style: this.styles[tagName], + }); + } else { + styles.push({ + tag: tagName, + style: this.defaultStyle, + }); + } + } + issue[this.keyName] = styles; + return issue; + } +} diff --git a/libs/event-emitter/src/issue-enhancers/time-passed-highlight-enhancer.ts b/libs/event-emitter/src/issue-enhancers/time-passed-highlight-enhancer.ts new file mode 100644 index 0000000..4bd6a17 --- /dev/null +++ b/libs/event-emitter/src/issue-enhancers/time-passed-highlight-enhancer.ts @@ -0,0 +1,68 @@ +/* eslint-disable @typescript-eslint/no-namespace */ +import { Injectable } from '@nestjs/common'; +import { RedmineTypes } from '../models/redmine-types'; +import { TimestampConverter } from '../utils/timestamp-converter'; +import { IssueEnhancerInterface } from './issue-enhancer-interface'; + +export namespace TimePassedHighlightEnhancerNs { + export type PriorityRules = { + /** time in seconds */ + timePassed: number; + priority: string; + }; +} + +@Injectable() +export class TimePassedHighlightEnhancer implements IssueEnhancerInterface { + name = 'activity-to-priority'; + + private rules: TimePassedHighlightEnhancerNs.PriorityRules[] = [ + { + timePassed: 60 * 60, // 1 час + priority: 'hot', + }, + { + timePassed: 24 * 60 * 60, // 1 день, + priority: 'warm', + }, + { + timePassed: 7 * 24 * 60 * 60, // 1 неделя + priority: 'comfort', + }, + { + timePassed: 14 * 24 * 60 * 60, // 2 недели + priority: 'breezy', + }, + ]; + + private otherPriority = 'cold'; + + private keyNameForCssClass = 'timePassedClass'; + + constructor() { + this.rules = this.rules.sort((a, b) => { + return a.timePassed - b.timePassed; + }); + } + + async enhance( + issue: RedmineTypes.ExtendedIssue, + ): Promise { + const nowTimestamp = new Date().getTime(); + if (!issue?.updated_on) return issue; + for (let i = 0; i < this.rules.length; i++) { + const rule = this.rules[i]; + if ( + nowTimestamp - TimestampConverter.toTimestamp(issue.updated_on) <= + rule.timePassed * 1000 + ) { + issue[this.keyNameForCssClass] = rule.priority; + break; + } + } + if (!issue[this.keyNameForCssClass]) { + issue[this.keyNameForCssClass] = this.otherPriority; + } + return issue; + } +} diff --git a/libs/event-emitter/src/issues/issues.service.ts b/libs/event-emitter/src/issues/issues.service.ts index 6b04cd7..2cc5ef1 100644 --- a/libs/event-emitter/src/issues/issues.service.ts +++ b/libs/event-emitter/src/issues/issues.service.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-namespace */ import { RedmineTypes } from '../models/redmine-types'; import { CacheTTL, Injectable, Logger } from '@nestjs/common'; import { Issues } from '../couchdb-datasources/issues'; @@ -8,10 +9,17 @@ import { MemoryCache } from '../utils/memory-cache'; import nano from 'nano'; import { UNLIMITED } from '../consts/consts'; import { GetParentsHint } from '../utils/get-parents-hint'; +import { TreeIssuesStore } from '../utils/tree-issues-store'; export const ISSUE_MEMORY_CACHE_LIFETIME = 30 * 1000; const ISSUE_MEMORY_CACHE_AUTOCLEAN_INTERVAL = 1000 * 60 * 5; +export namespace IssuesServiceNs { + export type IssuesLoader = ( + ids: number[], + ) => Promise>; +} + @Injectable() export class IssuesService { private logger = new Logger(IssuesService.name); @@ -143,4 +151,29 @@ export class IssuesService { return null; } } + + createDynamicIssuesLoader(): IssuesServiceNs.IssuesLoader { + const fn = async ( + ids: number[], + ): Promise> => { + const issues = await this.getIssues(ids); + const res = {} as Record; + for (let i = 0; i < issues.length; i++) { + const issue = issues[i]; + res[issue.id] = issue; + } + return res; + }; + return fn; + } + + async getIssuesWithChildren( + rootIssue: RedmineTypes.Issue, + ): Promise { + const treeIssuesStore = new TreeIssuesStore(); + treeIssuesStore.setRootIssue(rootIssue); + const loader = this.createDynamicIssuesLoader(); + await treeIssuesStore.fillData(loader); + return treeIssuesStore.getIssuesWithChildren(); + } } diff --git a/libs/event-emitter/src/models/redmine-types.ts b/libs/event-emitter/src/models/redmine-types.ts index 12f1d9d..00471c3 100644 --- a/libs/event-emitter/src/models/redmine-types.ts +++ b/libs/event-emitter/src/models/redmine-types.ts @@ -44,7 +44,7 @@ export module RedmineTypes { author: IdAndName; assigned_to?: IdAndName; category: IdAndName; - fixed_version: IdAndName; + fixed_version?: IdAndName; subject: string; description: string; start_date: string; @@ -61,6 +61,8 @@ export module RedmineTypes { parent?: { id: number }; }; + export type ExtendedIssue = Issue & Record; + // eslint-disable-next-line @typescript-eslint/prefer-namespace-keyword, @typescript-eslint/no-namespace export module Unknown { export const num = -1; diff --git a/libs/event-emitter/src/project-dashboard/project-dashboard.service.ts b/libs/event-emitter/src/project-dashboard/project-dashboard.service.ts new file mode 100644 index 0000000..78446ff --- /dev/null +++ b/libs/event-emitter/src/project-dashboard/project-dashboard.service.ts @@ -0,0 +1,152 @@ +/* eslint-disable @typescript-eslint/no-namespace */ +import { Injectable } from '@nestjs/common'; +import { RedmineTypes } from '../models/redmine-types'; + +export namespace ProjectDashboard { + export namespace Models { + export type Params = { + projectName: string; + workers: Worker[]; + filter: FilterDefination[]; + statuses: Status[]; + }; + + export type Worker = { + id?: number; + firstname?: string; + lastname?: string; + name?: string; + }; + + export enum FilterTypes { + TREE = 'TREE', + LIST = 'LIST', + DYNAMIC_LIST = 'DYNAMIC_LIST', + VERSION = 'VERSION', + } + + export namespace FilterParams { + export type Tree = { + rootIssueId: number; + subtrees: SubTree[]; + showOthers: boolean; + }; + export type SubTree = { + title: string; + issueId: number; + }; + export type List = { + issueIds: number[]; + }; + export type Version = { + version: string; + }; + export type DynamicList = { + selector: Record; + }; + export type AnyFilterParams = Tree | List | DynamicList | Version; + } + + export type FilterParams = Record; + + export namespace FilterResults { + export type Tree = List; + export type List = { status: string; issues: RedmineTypes.Issue[] }[]; + export type DynamicList = List; + export type Version = List; + export type AnyFilterResults = List | Tree | DynamicList | Version; + } + + export type FilterResult = Record[]; + + export type AnalyticFunction = { + functionName: string; + }; + + export type FilterDefination = { + type: FilterTypes; + title: string; + params: FilterParams.AnyFilterParams; + }; + + export type FilterWithResults = { + params: FilterDefination; + results: FilterResults.AnyFilterResults; + }; + + export type Status = { + name: string; + closed: boolean; + }; + } + + export function CheckWorker(worker: Models.Worker): boolean { + return Boolean( + (typeof worker.id === 'number' && worker.id >= 0) || + (worker.firstname && worker.lastname) || + worker.name, + ); + } + + export class SingleProject { + // TODO: code for SingleProject + constructor(private params: Models.Params) { + return; + } + } + + export namespace Widgets { + // Чё будет делать виджет? + // * рендер - из параметров будет создавать данные с какими-либо расчётами + + export interface Widget { + render( + filterParams: Models.FilterParams, + dashboardParams: Models.Params, + ): Models.FilterResult; + } + + export class List implements Widget { + render( + filterParams: Models.FilterParams, + dashboardParams: Models.Params, + ): Models.FilterResult { + throw new Error('Method not implemented.'); + } + } + + export class DynamicList implements Widget { + render( + filterParams: Models.FilterParams, + dashboardParams: Models.Params, + ): Models.FilterResult { + throw new Error('Method not implemented.'); + } + } + + export class Tree implements Widget { + render( + filterParams: Models.FilterParams, + dashboardParams: Models.Params, + ): Models.FilterResult { + throw new Error('Method not implemented.'); + } + } + + export class Version implements Widget { + render( + filterParams: Models.FilterParams, + dashboardParams: Models.Params, + ): Models.FilterResult { + throw new Error('Method not implemented.'); + } + } + } +} + +@Injectable() +export class ProjectDashboardService { + constructor() { + return; + } +} diff --git a/libs/event-emitter/src/project-dashboard/widget-interface.ts b/libs/event-emitter/src/project-dashboard/widget-interface.ts new file mode 100644 index 0000000..2f00e79 --- /dev/null +++ b/libs/event-emitter/src/project-dashboard/widget-interface.ts @@ -0,0 +1,4 @@ +export interface WidgetInterface { + isMyConfig(widgetParams: W): boolean; + render(widgetParams: W, dashboardParams: D): Promise; +} diff --git a/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-fields.widget.service.ts b/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-fields.widget.service.ts new file mode 100644 index 0000000..59d3792 --- /dev/null +++ b/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-fields.widget.service.ts @@ -0,0 +1,133 @@ +/* eslint-disable @typescript-eslint/no-namespace */ +import { IssueUrlEnhancer } from '@app/event-emitter/issue-enhancers/issue-url-enhancer'; +import { TagStyledEnhancerNs } from '@app/event-emitter/issue-enhancers/tag-styled-enhancer'; +import { TimePassedHighlightEnhancer } from '@app/event-emitter/issue-enhancers/time-passed-highlight-enhancer'; +import { + IssuesService, + IssuesServiceNs, +} from '@app/event-emitter/issues/issues.service'; +import { RedmineTypes } from '@app/event-emitter/models/redmine-types'; +import { FlatIssuesStore } from '@app/event-emitter/utils/flat-issues-store'; +import { GetValueFromObjectByKey } from '@app/event-emitter/utils/get-value-from-object-by-key'; +import { TreeIssuesStore } from '@app/event-emitter/utils/tree-issues-store'; +import { Injectable, Logger } from '@nestjs/common'; +import nano from 'nano'; +import { WidgetInterface } from '../widget-interface'; + +export namespace ListIssuesByFieldsWidgetNs { + export type Params = { + fromRootIssueId?: number; + fromQuery?: nano.MangoQuery; + fields: Field[]; + delimiter: string; + sort?: boolean; + statuses: string[]; + } & TagStyledEnhancerNs.ConfigWithTagsStyles; + + export type Field = { + path: string; + default: string; + }; +} + +type Params = ListIssuesByFieldsWidgetNs.Params; + +@Injectable() +export class ListIssuesByFieldsWidgetService + implements WidgetInterface +{ + private logger = new Logger(ListIssuesByFieldsWidgetService.name); + private issuesLoader: IssuesServiceNs.IssuesLoader; + + constructor( + private issuesService: IssuesService, + private timePassedHighlightEnhancer: TimePassedHighlightEnhancer, + private issueUrlEnhancer: IssueUrlEnhancer, + ) { + this.issuesLoader = this.issuesService.createDynamicIssuesLoader(); + } + + isMyConfig(): boolean { + return true; + } + + async render(widgetParams: Params): Promise { + let store: FlatIssuesStore; + if (widgetParams.fromRootIssueId) { + store = await this.getListFromRoot(widgetParams.fromRootIssueId); + } else if (widgetParams.fromQuery) { + store = await this.getListByQuery(widgetParams.fromQuery); + } else { + const errMsg = `Wrong widgetParams value`; + this.logger.error(errMsg); + throw new Error(errMsg); + } + await store.enhanceIssues([ + this.timePassedHighlightEnhancer, + this.issueUrlEnhancer, + TagStyledEnhancerNs.CreateTagStyledEnhancerForConfig(widgetParams), + ]); + const grouped = store.groupByStatusWithExtra((issue) => { + return this.getGroupFromIssue(issue, widgetParams); + }, widgetParams.statuses); + let res = [] as any[]; + for (const key in grouped) { + if (Object.prototype.hasOwnProperty.call(grouped, key)) { + const data = grouped[key]; + res.push({ + data: data, + metainfo: this.createMetaInfo(key), + }); + } + } + if (widgetParams.sort) { + res = res.sort((a, b) => { + return a.metainfo.title.localeCompare(b.metainfo.title); + }); + } + return res; + } + + private async getListFromRoot(issueId: number): Promise { + const treeStore = new TreeIssuesStore(); + const rootIssue = await this.issuesService.getIssue(issueId); + treeStore.setRootIssue(rootIssue); + await treeStore.fillData(this.issuesLoader); + return treeStore.getFlatStore(); + } + + private async getListByQuery( + query: nano.MangoQuery, + ): Promise { + const rawData = await this.issuesService.find(query); + const store = new FlatIssuesStore(); + for (let i = 0; i < rawData.length; i++) { + const issue = rawData[i]; + store.push(issue); + } + return store; + } + + private getGroupFromIssue( + issue: RedmineTypes.ExtendedIssue, + params: Params, + ): string | null { + if (!issue) return null; + const values = [] as string[]; + for (let i = 0; i < params.fields.length; i++) { + const field = params.fields[i]; + const valueResult = GetValueFromObjectByKey(issue, field.path); + const value: string = valueResult.result + ? valueResult.result + : field.default; + values.push(value); + } + return values.join(params.delimiter); + } + + private createMetaInfo(title: string): Record { + return { + title: title, + }; + } +} diff --git a/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users-like-jira.widget.service.ts b/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users-like-jira.widget.service.ts new file mode 100644 index 0000000..173f447 --- /dev/null +++ b/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users-like-jira.widget.service.ts @@ -0,0 +1,121 @@ +/* eslint-disable @typescript-eslint/no-namespace */ +import { IssueUrlEnhancer } from '@app/event-emitter/issue-enhancers/issue-url-enhancer'; +import { TagStyledEnhancerNs } from '@app/event-emitter/issue-enhancers/tag-styled-enhancer'; +import { TimePassedHighlightEnhancer } from '@app/event-emitter/issue-enhancers/time-passed-highlight-enhancer'; +import { + IssuesService, + IssuesServiceNs, +} from '@app/event-emitter/issues/issues.service'; +import { FlatIssuesStore } from '@app/event-emitter/utils/flat-issues-store'; +import { GetValueFromObjectByKey } from '@app/event-emitter/utils/get-value-from-object-by-key'; +import { TreeIssuesStore } from '@app/event-emitter/utils/tree-issues-store'; +import { Injectable, Logger } from '@nestjs/common'; +import nano from 'nano'; +import { WidgetInterface } from '../widget-interface'; + +export namespace ListIssuesByUsersLikeJiraWidgetNs { + export namespace Models { + export type Params = { + fromRootIssueId?: number; + fromQuery?: nano.MangoQuery; + userKeys: string[]; + userSort?: boolean; + statuses: string[]; + } & TagStyledEnhancerNs.ConfigWithTagsStyles; + } +} + +type Params = ListIssuesByUsersLikeJiraWidgetNs.Models.Params; + +@Injectable() +export class ListIssuesByUsersLikeJiraWidgetService + implements WidgetInterface +{ + private logger = new Logger(ListIssuesByUsersLikeJiraWidgetService.name); + private issuesLoader: IssuesServiceNs.IssuesLoader; + + constructor( + private issuesService: IssuesService, + private timePassedHighlightEnhancer: TimePassedHighlightEnhancer, + private issueUrlEnhancer: IssueUrlEnhancer, + ) { + this.issuesLoader = this.issuesService.createDynamicIssuesLoader(); + } + + isMyConfig(): boolean { + return true; + } + + async render(widgetParams: Params): Promise { + let store: FlatIssuesStore; + if (widgetParams.fromRootIssueId) { + store = await this.getListFromRoot(widgetParams.fromRootIssueId); + } else if (widgetParams.fromQuery) { + store = await this.getListByQuery(widgetParams.fromQuery); + } else { + const errMsg = `Wrong widgetParams value`; + this.logger.error(errMsg); + throw new Error(errMsg); + } + await store.enhanceIssues([ + this.timePassedHighlightEnhancer, + this.issueUrlEnhancer, + TagStyledEnhancerNs.CreateTagStyledEnhancerForConfig(widgetParams), + ]); + const grouped = store.groupByStatusWithExtraToMultipleStories((issue) => { + const users = [] as string[]; + for (let i = 0; i < widgetParams.userKeys.length; i++) { + const userKey = widgetParams.userKeys[i]; + const userValue = GetValueFromObjectByKey(issue, userKey); + if (userValue.result) { + users.push(userValue.result); + } else { + users.push('Unknown Unknown'); + } + } + return users; + }, widgetParams.statuses); + let res = [] as any[]; + for (const user in grouped) { + if (Object.prototype.hasOwnProperty.call(grouped, user)) { + const data = grouped[user]; + res.push({ + data: data, + metainfo: this.createMetaInfo(user), + }); + } + } + if (widgetParams.userSort) { + res = res.sort((a, b) => { + return a.metainfo.title.localeCompare(b.metainfo.title); + }); + } + return res; + } + + private async getListFromRoot(issueId: number): Promise { + const treeStore = new TreeIssuesStore(); + const rootIssue = await this.issuesService.getIssue(issueId); + treeStore.setRootIssue(rootIssue); + await treeStore.fillData(this.issuesLoader); + return treeStore.getFlatStore(); + } + + private async getListByQuery( + query: nano.MangoQuery, + ): Promise { + const rawData = await this.issuesService.find(query); + const store = new FlatIssuesStore(); + for (let i = 0; i < rawData.length; i++) { + const issue = rawData[i]; + store.push(issue); + } + return store; + } + + private createMetaInfo(user: string): Record { + return { + title: user, + }; + } +} diff --git a/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users.widget.service.ts b/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users.widget.service.ts new file mode 100644 index 0000000..9d3fefe --- /dev/null +++ b/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users.widget.service.ts @@ -0,0 +1,133 @@ +/* eslint-disable @typescript-eslint/no-namespace */ +import { IssueUrlEnhancer } from '@app/event-emitter/issue-enhancers/issue-url-enhancer'; +import { TagStyledEnhancerNs } from '@app/event-emitter/issue-enhancers/tag-styled-enhancer'; +import { TimePassedHighlightEnhancer } from '@app/event-emitter/issue-enhancers/time-passed-highlight-enhancer'; +import { + IssuesService, + IssuesServiceNs, +} from '@app/event-emitter/issues/issues.service'; +import { RedmineTypes } from '@app/event-emitter/models/redmine-types'; +import { FlatIssuesStore } from '@app/event-emitter/utils/flat-issues-store'; +import { GetValueFromObjectByKey } from '@app/event-emitter/utils/get-value-from-object-by-key'; +import { TreeIssuesStore } from '@app/event-emitter/utils/tree-issues-store'; +import { Injectable, Logger } from '@nestjs/common'; +import nano from 'nano'; +import { WidgetInterface } from '../widget-interface'; + +export namespace ListIssuesByUsersWidgetNs { + export namespace Models { + export type Params = { + fromRootIssueId?: number; + fromQuery?: nano.MangoQuery; + userKey: string; + userSort?: boolean; + statuses: string[]; + } & TagStyledEnhancerNs.ConfigWithTagsStyles; + + export type FindResult = { + result?: any; + error?: FindErrors; + }; + + export enum FindErrors { + NOT_FOUND = 'NOT_FOUND', + } + } +} + +type Params = ListIssuesByUsersWidgetNs.Models.Params; +type ExtendedIssue = RedmineTypes.Issue & Record; +type FindResult = ListIssuesByUsersWidgetNs.Models.FindResult; + +@Injectable() +export class ListIssuesByUsersWidgetService + implements WidgetInterface +{ + private logger = new Logger(ListIssuesByUsersWidgetService.name); + private issuesLoader: IssuesServiceNs.IssuesLoader; + + constructor( + private issuesService: IssuesService, + private timePassedHighlightEnhancer: TimePassedHighlightEnhancer, + private issueUrlEnhancer: IssueUrlEnhancer, + ) { + this.issuesLoader = this.issuesService.createDynamicIssuesLoader(); + } + + isMyConfig(): boolean { + return true; + } + + async render(widgetParams: Params): Promise { + let store: FlatIssuesStore; + if (widgetParams.fromRootIssueId) { + store = await this.getListFromRoot(widgetParams.fromRootIssueId); + } else if (widgetParams.fromQuery) { + store = await this.getListByQuery(widgetParams.fromQuery); + } else { + const errMsg = `Wrong widgetParams value`; + this.logger.error(errMsg); + throw new Error(errMsg); + } + await store.enhanceIssues([ + this.timePassedHighlightEnhancer, + this.issueUrlEnhancer, + TagStyledEnhancerNs.CreateTagStyledEnhancerForConfig(widgetParams), + ]); + const grouped = store.groupByStatusWithExtra((issue) => { + const res = this.getUserValueByKey(issue, widgetParams.userKey); + return res.result || 'Unknown'; + }, widgetParams.statuses); + let res = [] as any[]; + for (const user in grouped) { + if (Object.prototype.hasOwnProperty.call(grouped, user)) { + const data = grouped[user]; + res.push({ + data: data, + metainfo: this.createMetaInfo(user), + }); + } + } + if (widgetParams.userSort) { + res = res.sort((a, b) => { + return a.metainfo.title.localeCompare(b.metainfo.title); + }); + } + return res; + } + + private async getListFromRoot(issueId: number): Promise { + const treeStore = new TreeIssuesStore(); + const rootIssue = await this.issuesService.getIssue(issueId); + treeStore.setRootIssue(rootIssue); + await treeStore.fillData(this.issuesLoader); + return treeStore.getFlatStore(); + } + + private async getListByQuery( + query: nano.MangoQuery, + ): Promise { + const rawData = await this.issuesService.find(query); + const store = new FlatIssuesStore(); + for (let i = 0; i < rawData.length; i++) { + const issue = rawData[i]; + store.push(issue); + } + return store; + } + + private getUserValueByKey(issue: ExtendedIssue, key: string): FindResult { + const value = GetValueFromObjectByKey(issue, key); + if (value.result) { + return { result: value.result }; + } else { + return { error: ListIssuesByUsersWidgetNs.Models.FindErrors.NOT_FOUND }; + } + } + + private createMetaInfo(user: string): Record { + return { + title: user, + }; + } +} diff --git a/libs/event-emitter/src/project-dashboard/widgets/root-issue-subtrees.widget.service.ts b/libs/event-emitter/src/project-dashboard/widgets/root-issue-subtrees.widget.service.ts new file mode 100644 index 0000000..a28f7ac --- /dev/null +++ b/libs/event-emitter/src/project-dashboard/widgets/root-issue-subtrees.widget.service.ts @@ -0,0 +1,97 @@ +/* eslint-disable @typescript-eslint/no-namespace */ +import { IssueUrlEnhancer } from '@app/event-emitter/issue-enhancers/issue-url-enhancer'; +import { TagStyledEnhancerNs } from '@app/event-emitter/issue-enhancers/tag-styled-enhancer'; +import { TimePassedHighlightEnhancer } from '@app/event-emitter/issue-enhancers/time-passed-highlight-enhancer'; +import { + IssuesService, + IssuesServiceNs, +} from '@app/event-emitter/issues/issues.service'; +import { + TreeIssuesStore, + TreeIssuesStoreNs, +} from '@app/event-emitter/utils/tree-issues-store'; +import { Injectable } from '@nestjs/common'; +import { WidgetInterface } from '../widget-interface'; + +export namespace RootIssueSubTreesWidgetNs { + export namespace Models { + export type Params = { + rootIssueId: number; + parentsAsGroups?: boolean; + groups?: GroupCfg; + statuses: string[]; + } & TagStyledEnhancerNs.ConfigWithTagsStyles; + + export type GroupCfg = { + fromIssues: Group[]; + fromIssuesIncluded: boolean; + showOthers: boolean; + }; + + export type Group = { + name: string; + issueId: number; + }; + } +} + +type Params = RootIssueSubTreesWidgetNs.Models.Params; + +@Injectable() +export class RootIssueSubTreesWidgetService + implements WidgetInterface +{ + private issuesLoader: IssuesServiceNs.IssuesLoader; + + constructor( + private issuesService: IssuesService, + private timePassedHighlightEnhancer: TimePassedHighlightEnhancer, + private issueUrlEnhancer: IssueUrlEnhancer, + ) { + this.issuesLoader = this.issuesService.createDynamicIssuesLoader(); + } + + isMyConfig(): boolean { + return true; + } + + async render(widgetParams: Params): Promise { + const treeStore = new TreeIssuesStore(); + const rootIssue = await this.issuesService.getIssue( + widgetParams.rootIssueId, + ); + treeStore.setRootIssue(rootIssue); + await treeStore.fillData(this.issuesLoader); + await treeStore.enhanceIssues([ + this.timePassedHighlightEnhancer, + this.issueUrlEnhancer, + TagStyledEnhancerNs.CreateTagStyledEnhancerForConfig(widgetParams), + ]); + let stories: TreeIssuesStoreNs.Models.GetFlatStories.Result; + if (widgetParams.parentsAsGroups) { + stories = treeStore.getFlatStoriesByParents(); + } else if (widgetParams.groups) { + const fromIssues = widgetParams.groups.fromIssues.map((g) => g.issueId); + stories = treeStore.getFlatStories( + fromIssues, + widgetParams.groups.fromIssuesIncluded, + widgetParams.groups.showOthers, + ); + for (let i = 0; i < stories.length; i++) { + const store = stories[i]; + const fromIssueInfo = widgetParams.groups.fromIssues.find((i) => { + return i.issueId == store.metainfo?.rootIssue?.id; + }); + if (fromIssueInfo) { + store.metainfo.title = `${fromIssueInfo.name}: ${store.metainfo.title}`; + } + } + } + return stories.map((s) => { + return { + data: s.store.groupByStatus(widgetParams.statuses), + metainfo: s.metainfo, + }; + }); + } +} diff --git a/libs/event-emitter/src/utils/flat-issues-store.ts b/libs/event-emitter/src/utils/flat-issues-store.ts new file mode 100644 index 0000000..dd0d7f9 --- /dev/null +++ b/libs/event-emitter/src/utils/flat-issues-store.ts @@ -0,0 +1,234 @@ +/* eslint-disable @typescript-eslint/no-namespace */ +import { IssueEnhancerInterface } from '../issue-enhancers/issue-enhancer-interface'; +import { IssuesServiceNs } from '../issues/issues.service'; +import { RedmineTypes } from '../models/redmine-types'; + +export namespace FlatIssuesStoreNs { + export type IssuesLoader = IssuesServiceNs.IssuesLoader; + + export namespace Models { + export type ByStatus = { + status: string; + count: number; + issues: RedmineTypes.Issue[]; + }; + + export type ByStatuses = ByStatus[]; + + export enum FindErrors { + NOT_FOUND = 'NOT_FOUND', + NOT_LOADED = 'NOT_LOADED', + } + + export type FindResult = { + data?: RedmineTypes.Issue; + error?: FindErrors; + }; + } +} + +export class FlatIssuesStore { + private issues: Record = {}; + + push(issue: number | string | RedmineTypes.Issue): void { + let id: any; + let data: any; + + if ( + typeof issue === 'number' || + (typeof issue === 'string' && Number.isFinite(Number(issue))) + ) { + id = Number(issue); + data = null; + } else if (typeof issue === 'object' && typeof issue['id'] === 'number') { + id = issue['id']; + data = issue; + } + + if ( + typeof id === 'number' && + !Object.prototype.hasOwnProperty.call(this.issues, id) + ) { + this.issues[id] = data; + } + } + + async fillData(loader: FlatIssuesStoreNs.IssuesLoader): Promise { + const ids = [] as number[]; + for (const id in this.issues) { + if (Object.prototype.hasOwnProperty.call(this.issues, id)) { + const issue = this.issues[id]; + if (!issue) { + ids.push(Number(id)); + } + } + } + const data = await loader(ids); + for (const id in data) { + if (Object.prototype.hasOwnProperty.call(data, id)) { + const issue = data[id]; + if (issue) { + this.issues[id] = issue; + } + } + } + return; + } + + async enhanceIssues( + enhancers: (IssueEnhancerInterface | null)[], + ): Promise { + for (const issueId in this.issues) { + if (Object.prototype.hasOwnProperty.call(this.issues, issueId)) { + let issue = this.issues[issueId]; + for (let i = 0; i < enhancers.length; i++) { + const enhancer = enhancers[i]; + if (!enhancer) continue; + issue = await enhancer.enhance(issue); + this.issues[issueId] = issue; + } + } + } + } + + getIds(): number[] { + return Object.keys(this.issues).map((i) => Number(i)); + } + + getIssues(): RedmineTypes.Issue[] { + return Object.values(this.issues).filter((i) => !!i); + } + + hasIssue(id: number): boolean { + return Object.prototype.hasOwnProperty.call(this.issues, id); + } + + loadedIssue(id: number): boolean { + return this.hasIssue(id) && !!this.issues[id]; + } + + getIssue(id: number): FlatIssuesStoreNs.Models.FindResult { + if (!this.hasIssue(id)) { + return { error: FlatIssuesStoreNs.Models.FindErrors.NOT_FOUND }; + } else if (!this.issues[id]) { + return { error: FlatIssuesStoreNs.Models.FindErrors.NOT_LOADED }; + } else { + return { data: this.issues[id] }; + } + } + + isFullLoaded(): boolean { + return Object.values(this.issues).indexOf(null) < 0; + } + + groupBy( + iteratee: (issue: RedmineTypes.Issue) => string | number, + ): Record { + const res = {} as Record; + const items = this.getIssues(); + for (let i = 0; i < items.length; i++) { + const issue = items[i]; + const key = iteratee(issue); + if (!Object.prototype.hasOwnProperty.call(res, key)) { + res[key] = []; + } + res[key].push(issue); + } + return res; + } + + groupByToStories( + iteratee: (issue: RedmineTypes.Issue) => string | number, + ): Record { + const res = {} as Record; + const rawData = this.groupBy(iteratee); + for (const key in rawData) { + if (Object.prototype.hasOwnProperty.call(rawData, key)) { + const issues = rawData[key]; + res[key] = new FlatIssuesStore(); + for (let i = 0; i < issues.length; i++) { + const issue = issues[i]; + res[key].push(issue); + } + } + } + return res; + } + + groupByStatus(statuses: string[]): FlatIssuesStoreNs.Models.ByStatuses { + const res = [] as FlatIssuesStoreNs.Models.ByStatuses; + for (let i = 0; i < statuses.length; i++) { + const status = statuses[i]; + res.push({ status: status, count: 0, issues: [] }); + } + const groupedIssues = this.groupBy((issue) => issue.status.name); + for (const status in groupedIssues) { + if (Object.prototype.hasOwnProperty.call(groupedIssues, status)) { + const issues = groupedIssues[status]; + const foundItem = res.find((i) => i.status === status); + if (!foundItem) continue; + foundItem.issues.push(...issues); + } + } + for (let i = 0; i < res.length; i++) { + const item = res[i]; + item.count = item.issues.length; + } + return res; + } + + groupByStatusWithExtra( + iteratee: (issue: RedmineTypes.Issue) => string | number, + statuses: string[], + ): Record { + const res = {} as Record< + string | number, + FlatIssuesStoreNs.Models.ByStatuses + >; + const groupedIssues = this.groupByToStories(iteratee); + for (const key in groupedIssues) { + if (Object.prototype.hasOwnProperty.call(groupedIssues, key)) { + const store = groupedIssues[key]; + res[key] = store.groupByStatus(statuses); + } + } + return res; + } + + groupByToMultipleStories( + iteratee: (issue: RedmineTypes.Issue) => (string | number)[], + ): Record { + const res = {} as Record; + const items = this.getIssues(); + for (let i = 0; i < items.length; i++) { + const issue = items[i]; + const keys = iteratee(issue); + for (let j = 0; j < keys.length; j++) { + const key = keys[j]; + if (!Object.prototype.hasOwnProperty.call(res, key)) { + res[key] = new FlatIssuesStore(); + } + res[key].push(issue); + } + } + return res; + } + + groupByStatusWithExtraToMultipleStories( + iteratee: (issue: RedmineTypes.Issue) => (string | number)[], + statuses: string[], + ): Record { + const res = {} as Record< + string | number, + FlatIssuesStoreNs.Models.ByStatuses + >; + const groupedIssues = this.groupByToMultipleStories(iteratee); + for (const key in groupedIssues) { + if (Object.prototype.hasOwnProperty.call(groupedIssues, key)) { + const store = groupedIssues[key]; + res[key] = store.groupByStatus(statuses); + } + } + return res; + } +} diff --git a/libs/event-emitter/src/utils/get-value-from-object-by-key.ts b/libs/event-emitter/src/utils/get-value-from-object-by-key.ts new file mode 100644 index 0000000..412b845 --- /dev/null +++ b/libs/event-emitter/src/utils/get-value-from-object-by-key.ts @@ -0,0 +1,15 @@ +export function GetValueFromObjectByKey( + obj: any, + key: string, +): { result?: any; error?: string } { + const keys = key.split('.'); + let res: any = obj; + for (let i = 0; i < keys.length; i++) { + const k = keys[i]; + if (!res.hasOwnProperty(k)) { + return { error: 'NOT_FOUND' }; + } + res = res[k]; + } + return { result: res }; +} diff --git a/libs/event-emitter/src/utils/tree-issues-store.ts b/libs/event-emitter/src/utils/tree-issues-store.ts new file mode 100644 index 0000000..53258e3 --- /dev/null +++ b/libs/event-emitter/src/utils/tree-issues-store.ts @@ -0,0 +1,213 @@ +/* eslint-disable @typescript-eslint/no-namespace */ +import { Logger } from '@nestjs/common'; +import { IssueEnhancerInterface } from '../issue-enhancers/issue-enhancer-interface'; +import { RedmineTypes } from '../models/redmine-types'; +import { FlatIssuesStore, FlatIssuesStoreNs } from './flat-issues-store'; + +export namespace TreeIssuesStoreNs { + export namespace Models { + export namespace GetFlatStories { + export type Item = { + metainfo: Record; + store: FlatIssuesStore; + }; + + export type Result = Item[]; + } + } +} + +export class TreeIssuesStore { + private logger = new Logger(TreeIssuesStore.name); + private rootIssue: RedmineTypes.Issue; + private flatStore: FlatIssuesStore; + + setRootIssue(issue: RedmineTypes.Issue): void { + this.rootIssue = issue; + this.prepareFlatIssuesStore(); + this.logger.debug(`Set root issue_id - ${JSON.stringify(issue.id)}`); + } + + async fillData(loader: FlatIssuesStoreNs.IssuesLoader): Promise { + await this.flatStore.fillData(loader); + } + + async enhanceIssues(enhancers: IssueEnhancerInterface[]): Promise { + await this.flatStore.enhanceIssues(enhancers); + } + + getFlatStore(): FlatIssuesStore { + return this.flatStore; + } + + private prepareFlatIssuesStore(): void { + this.flatStore = new FlatIssuesStore(); + this.flatStore.push(this.rootIssue); + if (this.rootIssue.children && this.rootIssue.children.length > 0) { + this.fillChildrenFlatIssuesStore(this.rootIssue.children); + } + } + + private fillChildrenFlatIssuesStore( + childrenIssues: RedmineTypes.Children, + ): void { + for (let i = 0; i < childrenIssues.length; i++) { + const issue = childrenIssues[i]; + this.flatStore.push(issue.id); + if (issue.children && issue.children.length > 0) { + this.fillChildrenFlatIssuesStore(issue.children); + } + } + } + + isFullLoaded(): boolean { + return this.flatStore.isFullLoaded(); + } + + getFlatStories( + fromIssues: number[], + fromIssuesIncluded: boolean, + showOthers: boolean, + ): TreeIssuesStoreNs.Models.GetFlatStories.Result { + const res = [] as TreeIssuesStoreNs.Models.GetFlatStories.Result; + + for (let i = 0; i < fromIssues.length; i++) { + const fromIssue = this.flatStore.getIssue(fromIssues[i]); + if (!fromIssue.data) continue; + const store = new FlatIssuesStore(); + this.putIssuesToStore( + store, + fromIssue.data, + fromIssues, + fromIssuesIncluded, + ); + res.push({ + store: store, + metainfo: this.createMetaInfo(fromIssue.data), + }); + } + + if (showOthers) { + const othersStore = new FlatIssuesStore(); + res.push({ + store: othersStore, + metainfo: this.createMetaInfo(this.rootIssue), + }); + this.putIssuesToStore( + othersStore, + this.rootIssue, + fromIssues, + fromIssuesIncluded, + ); + } + + return res; + } + + private putIssuesToStore( + store: FlatIssuesStore, + rootIssue: RedmineTypes.Issue, + fromIssues: number[], + fromIssuesIncluded: boolean, + ): void { + if (fromIssuesIncluded) { + store.push(rootIssue); + } + if (!rootIssue.children || rootIssue.children.length <= 0) { + return; + } + for (let i = 0; i < rootIssue.children.length; i++) { + const childIssue = rootIssue.children[i]; + const issueData = this.flatStore.getIssue(childIssue.id); + if (!issueData.data) continue; + if (fromIssues.indexOf(issueData.data.id) < 0) { + store.push(issueData.data); + this.putIssuesToStore( + store, + issueData.data, + fromIssues, + fromIssuesIncluded, + ); + } else { + if (fromIssuesIncluded == false) { + store.push(issueData.data); + } + } + } + } + + private createMetaInfo(issue: RedmineTypes.Issue): Record { + return { + title: `${issue.tracker.name} #${issue.id} - ${issue.subject}`, + rootIssue: { + id: issue.id, + tracker: issue.tracker, + subject: issue.subject, + }, + }; + } + + getParents(issue: RedmineTypes.Issue): RedmineTypes.Issue[] { + const res = [] as RedmineTypes.Issue[]; + let parentId: number; + let parentIssueData: FlatIssuesStoreNs.Models.FindResult; + let parentIssue: RedmineTypes.Issue; + parentId = issue.parent?.id; + while (parentId) { + parentIssueData = this.flatStore.getIssue(parentId); + parentIssue = parentIssueData.data; + if (!parentIssue) break; + res.push(parentIssue); + parentId = parentIssue.parent?.id; + } + return res; + } + + getFlatStoriesByParents(): TreeIssuesStoreNs.Models.GetFlatStories.Result { + const stories = [] as TreeIssuesStoreNs.Models.GetFlatStories.Result; + return this.fillFlatStoriesByParents(stories, this.rootIssue); + } + + private fillFlatStoriesByParents( + stories: TreeIssuesStoreNs.Models.GetFlatStories.Result, + rootIssue: RedmineTypes.Issue, + ): TreeIssuesStoreNs.Models.GetFlatStories.Result { + if (!rootIssue.children || rootIssue.children.length <= 0) { + return stories; + } + const store = new FlatIssuesStore(); + stories.push({ + store: store, + metainfo: this.createMetaInfo(rootIssue), + }); + for (let i = 0; i < rootIssue.children.length; i++) { + const child = rootIssue.children[i]; + const childIssueData = this.flatStore.getIssue(child.id); + if (!childIssueData.data) continue; + store.push(childIssueData.data); + this.fillFlatStoriesByParents(stories, childIssueData.data); + } + return stories; + } + + getIssuesWithChildren(): RedmineTypes.Issue[] { + return this.fillIssuesWithChildren(this.rootIssue, []); + } + + private fillIssuesWithChildren( + issue: RedmineTypes.Issue, + data: RedmineTypes.Issue[], + ): RedmineTypes.Issue[] { + if (!issue || !issue.children || issue.children.length <= 0) return; + data.push(issue); + for (let i = 0; i < issue.children.length; i++) { + const childIssueResult = this.getFlatStore().getIssue( + issue.children[i].id, + ); + if (!childIssueResult || !childIssueResult.data) continue; + const childIssue = childIssueResult.data; + this.fillIssuesWithChildren(childIssue, data); + } + return data; + } +} diff --git a/package-lock.json b/package-lock.json index 63fd4b1..1c17d5d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "handlebars": "^4.7.7", "hbs": "^4.2.0", "imap-simple": "^5.1.0", + "jsonc-parser": "^3.2.0", "luxon": "^3.1.0", "nano": "^10.0.0", "node-telegram-bot-api": "^0.59.0", @@ -198,6 +199,12 @@ "node": ">=8.0.0" } }, + "node_modules/@angular-devkit/schematics/node_modules/jsonc-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.0.0.tgz", + "integrity": "sha512-fQzRfAbIBnR0IQvftw9FJveWiHp72Fg20giDrHz6TdfB12UH/uue0D3hm57UB5KgAVuniLMCaS8P1IMj9NR7cA==", + "dev": true + }, "node_modules/@angular-devkit/schematics/node_modules/rxjs": { "version": "6.6.7", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", @@ -1707,6 +1714,12 @@ "yarn": ">= 1.13.0" } }, + "node_modules/@nestjs/schematics/node_modules/jsonc-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.0.0.tgz", + "integrity": "sha512-fQzRfAbIBnR0IQvftw9FJveWiHp72Fg20giDrHz6TdfB12UH/uue0D3hm57UB5KgAVuniLMCaS8P1IMj9NR7cA==", + "dev": true + }, "node_modules/@nestjs/schematics/node_modules/rxjs": { "version": "6.6.7", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", @@ -7091,10 +7104,9 @@ } }, "node_modules/jsonc-parser": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.0.0.tgz", - "integrity": "sha512-fQzRfAbIBnR0IQvftw9FJveWiHp72Fg20giDrHz6TdfB12UH/uue0D3hm57UB5KgAVuniLMCaS8P1IMj9NR7cA==", - "dev": true + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz", + "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==" }, "node_modules/jsonfile": { "version": "6.1.0", @@ -10448,6 +10460,12 @@ "rxjs": "6.6.7" }, "dependencies": { + "jsonc-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.0.0.tgz", + "integrity": "sha512-fQzRfAbIBnR0IQvftw9FJveWiHp72Fg20giDrHz6TdfB12UH/uue0D3hm57UB5KgAVuniLMCaS8P1IMj9NR7cA==", + "dev": true + }, "rxjs": { "version": "6.6.7", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", @@ -11625,6 +11643,12 @@ "rxjs": "6.6.7" } }, + "jsonc-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.0.0.tgz", + "integrity": "sha512-fQzRfAbIBnR0IQvftw9FJveWiHp72Fg20giDrHz6TdfB12UH/uue0D3hm57UB5KgAVuniLMCaS8P1IMj9NR7cA==", + "dev": true + }, "rxjs": { "version": "6.6.7", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", @@ -15740,10 +15764,9 @@ "dev": true }, "jsonc-parser": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.0.0.tgz", - "integrity": "sha512-fQzRfAbIBnR0IQvftw9FJveWiHp72Fg20giDrHz6TdfB12UH/uue0D3hm57UB5KgAVuniLMCaS8P1IMj9NR7cA==", - "dev": true + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz", + "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==" }, "jsonfile": { "version": "6.1.0", diff --git a/package.json b/package.json index c2455af..a871bf2 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "handlebars": "^4.7.7", "hbs": "^4.2.0", "imap-simple": "^5.1.0", + "jsonc-parser": "^3.2.0", "luxon": "^3.1.0", "nano": "^10.0.0", "node-telegram-bot-api": "^0.59.0", diff --git a/src/app.module.ts b/src/app.module.ts index 34a48ae..a6f0289 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -3,13 +3,18 @@ import { RedmineIssuesCacheWriterService } from '@app/event-emitter/issue-cache- 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 { CacheModule, Logger, Module, OnModuleInit } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; +import { + CacheModule, + Inject, + Logger, + Module, + OnModuleInit, +} from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; import { switchMap, tap } 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'; @@ -32,6 +37,14 @@ 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'; +import { DailyEccmReportsUserCommentsDatasource } from './couchdb-datasources/daily-eccm-reports-user-comments.datasource'; +import { DailyEccmUserCommentsService } from './reports/daily-eccm-user-comments.service'; +import { SetDailyEccmUserCommentBotHandlerService } from './telegram-bot/handlers/set-daily-eccm-user-comment.bot-handler.service'; +import { DailyEccmWithExtraDataService } from './reports/daily-eccm-with-extra-data.service'; +import { SimpleKanbanBoardController } from './dashboards/simple-kanban-board.controller'; +import { IssueUrlEnhancer } from '@app/event-emitter/issue-enhancers/issue-url-enhancer'; +import { IssuesByTagsWidgetService } from './dashboards/widgets/issues-by-tags.widget.service'; +import { CategoryMergeToTagsEnhancer } from './issue-enhancers/category-merge-to-tags-enhancer'; @Module({ imports: [ @@ -49,6 +62,7 @@ import { DailyEccmReportTask } from './reports/daily-eccm.report.task'; MainController, CurrentIssuesEccmReportController, DailyEccmReportController, + SimpleKanbanBoardController, ], providers: [ AppService, @@ -57,7 +71,6 @@ import { DailyEccmReportTask } from './reports/daily-eccm.report.task'; PersonalNotificationsService, StatusChangeNotificationsService, Changes, - RedminePublicUrlConverter, ChangesCacheWriterService, TelegramBotService, UserMetaInfoService, @@ -70,6 +83,21 @@ import { DailyEccmReportTask } from './reports/daily-eccm.report.task'; ChangesService, DailyEccmReportsDatasource, DailyEccmReportTask, + DailyEccmReportsUserCommentsDatasource, + DailyEccmUserCommentsService, + SetDailyEccmUserCommentBotHandlerService, + DailyEccmWithExtraDataService, + IssuesByTagsWidgetService, + { + provide: 'CATEGORY_MERGE_TO_TAGS_ENHANCER', + useFactory: (configService: ConfigService) => { + const eccmProjectName = configService.get( + 'redmineEccm.projectName', + ); + return new CategoryMergeToTagsEnhancer([eccmProjectName]); + }, + inject: [ConfigService], + }, ], }) export class AppModule implements OnModuleInit { @@ -82,11 +110,17 @@ export class AppModule implements OnModuleInit { private timestampEnhancer: TimestampEnhancer, private customFieldsEnhancer: CustomFieldsEnhancer, private currentUserEnhancer: CurrentUserEnhancer, + private issueUrlEnhancer: IssueUrlEnhancer, private statusChangeNotificationsService: StatusChangeNotificationsService, private changesCacheWriterService: ChangesCacheWriterService, private telegramBotService: TelegramBotService, private personalNotificationAdapterService: PersonalNotificationAdapterService, private statusChangeAdapterService: StatusChangeAdapterService, + private dailyEccmUserCommentsService: DailyEccmUserCommentsService, + private setDailyEccmUserCommentBotHandlerService: SetDailyEccmUserCommentBotHandlerService, + + @Inject('CATEGORY_MERGE_TO_TAGS_ENHANCER') + private categoryMergeToTagsEnhancer: CategoryMergeToTagsEnhancer, ) {} onModuleInit() { @@ -95,11 +129,14 @@ export class AppModule implements OnModuleInit { Changes.getDatasource(); UserMetaInfo.getDatasource(); DailyEccmReportsDatasource.getDatasource(); + DailyEccmReportsUserCommentsDatasource.getDatasource(); this.enhancerService.addEnhancer([ this.timestampEnhancer, this.customFieldsEnhancer, this.currentUserEnhancer, + this.issueUrlEnhancer, + this.categoryMergeToTagsEnhancer, ]); this.personalNotificationsService.$messages.subscribe((resp) => { @@ -158,5 +195,19 @@ export class AppModule implements OnModuleInit { `Save result process success finished, issue_id = ${args.saveResult.current.id}`, ); }); + + this.initDailyEccmUserCommentsPipeline(); + } + + private initDailyEccmUserCommentsPipeline(): void { + this.setDailyEccmUserCommentBotHandlerService.$messages.subscribe( + (data) => { + this.dailyEccmUserCommentsService.setComment( + data.userId, + data.date, + data.comment, + ); + }, + ); } } diff --git a/src/configs/app.ts b/src/configs/app.ts index 9919b6c..89c3e6c 100644 --- a/src/configs/app.ts +++ b/src/configs/app.ts @@ -7,12 +7,14 @@ import RedmineStatusesConfigLoader from './statuses.config'; import RedmineStatusChangesConfigLoader from './status-changes.config'; import RedmineEccmConfig from './eccm.config'; import RedmineCurrentUserRulesConfig from './current-user-rules.config'; +import SimpleKanbanBoardConfig from './simple-kanban-board.config'; const redmineIssueEventEmitterConfig = RedmineIssueEventEmitterConfigLoader(); const redmineStatusesConfig = RedmineStatusesConfigLoader(); const redmineStatusChanges = RedmineStatusChangesConfigLoader(); const redmineEccm = RedmineEccmConfig(); const redmineCurrentUserRules = RedmineCurrentUserRulesConfig(); +const simpleKanbanBoard = SimpleKanbanBoardConfig(); let appConfig: AppConfig; @@ -36,6 +38,7 @@ export default (): AppConfig => { redmineStatusChanges: redmineStatusChanges, redmineEccm: redmineEccm, redmineCurrentUserRules: redmineCurrentUserRules, + simpleKanbanBoard: simpleKanbanBoard, }; return appConfig; diff --git a/src/configs/simple-kanban-board.config.ts b/src/configs/simple-kanban-board.config.ts new file mode 100644 index 0000000..311a20c --- /dev/null +++ b/src/configs/simple-kanban-board.config.ts @@ -0,0 +1,27 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { parse } from 'jsonc-parser'; + +export type SimpleKanbanBoardConfig = { + path: string; +}; + +let config: SimpleKanbanBoardConfig; + +export default (): SimpleKanbanBoardConfig => { + if (config) { + return config; + } + + const userDefinedConfigPath = + process.env['ELTEX_REDMINE_HELPER_KANBAN_SIMPLE_BOARD_CONFIG_PATH']; + const defaultConfigPath = join('configs', 'simple-kanban-board-config.jsonc'); + + const configPath = userDefinedConfigPath || defaultConfigPath; + + const rawData = readFileSync(configPath, { encoding: 'utf-8' }); + + config = parse(rawData); + + return config; +}; diff --git a/src/consts/date-time.consts.ts b/src/consts/date-time.consts.ts new file mode 100644 index 0000000..ef91cba --- /dev/null +++ b/src/consts/date-time.consts.ts @@ -0,0 +1 @@ +export const ISO_DATE_FORMAT = 'yyyy-MM-dd'; diff --git a/src/converters/redmine-public-url.converter.ts b/src/converters/redmine-public-url.converter.ts deleted file mode 100644 index da7b4d2..0000000 --- a/src/converters/redmine-public-url.converter.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { RedmineTypes } from '@app/event-emitter/models/redmine-types'; -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}`; - } - - getUrl(issueId: number | string): string { - return this.convert(issueId); - } - - getHtmlHref(issue: RedmineTypes.Issue): string { - const url = this.getUrl(issue.id); - return `${issue.tracker.name} #${issue.id}`; - } -} diff --git a/src/couchdb-datasources/daily-eccm-reports-user-comments.datasource.ts b/src/couchdb-datasources/daily-eccm-reports-user-comments.datasource.ts new file mode 100644 index 0000000..57b15b5 --- /dev/null +++ b/src/couchdb-datasources/daily-eccm-reports-user-comments.datasource.ts @@ -0,0 +1,43 @@ +import { CouchDb } from '@app/event-emitter/couchdb-datasources/couchdb'; +import nano from 'nano'; +import { Injectable, Logger } from '@nestjs/common'; +import configuration from '../configs/app'; +import { DailyEccmUserComments } from 'src/reports/daily-eccm-user-comments.service'; + +const config = configuration(); + +@Injectable() +export class DailyEccmReportsUserCommentsDatasource { + private static logger = new Logger( + DailyEccmReportsUserCommentsDatasource.name, + ); + private static db = null; + private static initilized = false; + + static async getDatasource(): Promise< + nano.DocumentScope + > { + if (DailyEccmReportsUserCommentsDatasource.initilized) { + return DailyEccmReportsUserCommentsDatasource.db; + } + DailyEccmReportsUserCommentsDatasource.initilized = true; + const n = CouchDb.getCouchDb(); + const dbName = config.couchDb.dbs.eccmDailyReportsUserComments; + const dbs = await n.db.list(); + if (!dbs.includes(dbName)) { + await n.db.create(dbName); + } + DailyEccmReportsUserCommentsDatasource.db = await n.db.use(dbName); + DailyEccmReportsUserCommentsDatasource.initilized = true; + DailyEccmReportsUserCommentsDatasource.logger.log( + `Connected to eccm_daily_reports_user_comments db - ${dbName}`, + ); + return DailyEccmReportsUserCommentsDatasource.db; + } + + async getDatasource(): Promise< + nano.DocumentScope + > { + return await DailyEccmReportsUserCommentsDatasource.getDatasource(); + } +} diff --git a/src/dashboards/simple-kanban-board.controller.ts b/src/dashboards/simple-kanban-board.controller.ts new file mode 100644 index 0000000..0bed6b5 --- /dev/null +++ b/src/dashboards/simple-kanban-board.controller.ts @@ -0,0 +1,132 @@ +import { DynamicLoader } from '@app/event-emitter/configs/dynamic-loader'; +import { RedmineEventsGateway } from '@app/event-emitter/events/redmine-events.gateway'; +import { IssuesService } from '@app/event-emitter/issues/issues.service'; +import { ListIssuesByFieldsWidgetService } from '@app/event-emitter/project-dashboard/widgets/list-issues-by-fields.widget.service'; +import { ListIssuesByUsersLikeJiraWidgetService } from '@app/event-emitter/project-dashboard/widgets/list-issues-by-users-like-jira.widget.service'; +import { ListIssuesByUsersWidgetService } from '@app/event-emitter/project-dashboard/widgets/list-issues-by-users.widget.service'; +import { + RootIssueSubTreesWidgetNs, + RootIssueSubTreesWidgetService, +} from '@app/event-emitter/project-dashboard/widgets/root-issue-subtrees.widget.service'; +import { TreeIssuesStore } from '@app/event-emitter/utils/tree-issues-store'; +import { Controller, Get, Logger, Param, Render } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { parse } from 'jsonc-parser'; +import { IssuesByTagsWidgetService } from './widgets/issues-by-tags.widget.service'; + +@Controller('simple-kanban-board') +export class SimpleKanbanBoardController { + private logger = new Logger(SimpleKanbanBoardController.name); + private path: string; + + constructor( + private rootIssueSubTreesWidgetService: RootIssueSubTreesWidgetService, + private dynamicLoader: DynamicLoader, + private configService: ConfigService, + private listIssuesByUsersWidgetService: ListIssuesByUsersWidgetService, + private listIssuesByUsersLikeJiraWidgetService: ListIssuesByUsersLikeJiraWidgetService, + private issuesByTagsWidgetService: IssuesByTagsWidgetService, + private redmineEventsGateway: RedmineEventsGateway, + private listIssuesByFieldsWidgetService: ListIssuesByFieldsWidgetService, + private issuesService: IssuesService, + ) { + this.path = this.configService.get('simpleKanbanBoard.path'); + } + + @Get('/tree/:name/raw') + async getRawData(@Param('name') name: string): Promise { + const cfg = this.dynamicLoader.load(name, { + path: this.path, + ext: 'jsonc', + parser: parse, + }); + return await this.rootIssueSubTreesWidgetService.render(cfg); + } + + @Get('/tree/:name') + @Render('simple-kanban-board') + async get(@Param('name') name: string): Promise { + return await this.getRawData(name); + } + + @Get('/tree/:name/refresh') + async refreshTree(@Param('name') name: string): Promise { + const cfg = this.dynamicLoader.load(name, { + path: this.path, + ext: 'jsonc', + parser: parse, + }); + const rootIssue = await this.issuesService.getIssueFromCache( + cfg.rootIssueId, + ); + const issues = await this.issuesService.getIssuesWithChildren(rootIssue); + const issuesIds = issues.map((i) => i.id); + this.logger.debug(`Issues for tree refresh - ${issuesIds}`); + this.redmineEventsGateway.addIssues(issuesIds); + return { success: true }; + } + + @Get('/by-users/:name/raw') + async getByUsersRawData(@Param('name') name: string): Promise { + const cfg = this.dynamicLoader.load(name, { + path: this.path, + ext: 'jsonc', + parser: parse, + }); + return await this.listIssuesByUsersWidgetService.render(cfg); + } + + @Get('/by-users/:name') + @Render('simple-kanban-board') + async getByUsers(@Param('name') name: string): Promise { + return await this.getByUsersRawData(name); + } + + @Get('/by-users-like-jira/:name/raw') + async getByUsersLikeJiraRawData(@Param('name') name: string): Promise { + const cfg = this.dynamicLoader.load(name, { + path: this.path, + ext: 'jsonc', + parser: parse, + }); + return await this.listIssuesByUsersLikeJiraWidgetService.render(cfg); + } + + @Get('/by-users-like-jira/:name') + @Render('simple-kanban-board') + async getByUsersLikeJira(@Param('name') name: string): Promise { + return await this.getByUsersLikeJiraRawData(name); + } + + @Get('/by-tags/:name/raw') + async getByTagsRawData(@Param('name') name: string): Promise { + const cfg = this.dynamicLoader.load(name, { + path: this.path, + ext: 'jsonc', + parser: parse, + }); + return await this.issuesByTagsWidgetService.render(cfg); + } + + @Get('/by-tags/:name') + @Render('simple-kanban-board') + async getByTags(@Param('name') name: string): Promise { + return await this.getByTagsRawData(name); + } + + @Get('/by-fields/:name/raw') + async getByFieldsRawData(@Param('name') name: string): Promise { + const cfg = this.dynamicLoader.load(name, { + path: this.path, + ext: 'jsonc', + parser: parse, + }); + return await this.listIssuesByFieldsWidgetService.render(cfg); + } + + @Get('/by-fields/:name') + @Render('simple-kanban-board') + async getByFields(@Param('name') name: string): Promise { + return await this.getByFieldsRawData(name); + } +} diff --git a/src/dashboards/widgets/issues-by-tags.widget.service.ts b/src/dashboards/widgets/issues-by-tags.widget.service.ts new file mode 100644 index 0000000..c199eb1 --- /dev/null +++ b/src/dashboards/widgets/issues-by-tags.widget.service.ts @@ -0,0 +1,129 @@ +/* eslint-disable @typescript-eslint/no-namespace */ +import { IssueUrlEnhancer } from '@app/event-emitter/issue-enhancers/issue-url-enhancer'; +import { TagStyledEnhancerNs } from '@app/event-emitter/issue-enhancers/tag-styled-enhancer'; +import { TimePassedHighlightEnhancer } from '@app/event-emitter/issue-enhancers/time-passed-highlight-enhancer'; +import { + IssuesService, + IssuesServiceNs, +} from '@app/event-emitter/issues/issues.service'; +import { WidgetInterface } from '@app/event-emitter/project-dashboard/widget-interface'; +import { FlatIssuesStore } from '@app/event-emitter/utils/flat-issues-store'; +import { GetValueFromObjectByKey } from '@app/event-emitter/utils/get-value-from-object-by-key'; +import { TreeIssuesStore } from '@app/event-emitter/utils/tree-issues-store'; +import { Injectable, Logger } from '@nestjs/common'; +import nano from 'nano'; + +export namespace IssuesByTagsWidgetNs { + export type Params = { + fromRootIssueId?: number; + fromQuery?: nano.MangoQuery; + tagsSort?: boolean; + showEmptyTags?: string; + statuses: string[]; + } & TagStyledEnhancerNs.ConfigWithTagsStyles; +} + +type Params = IssuesByTagsWidgetNs.Params; + +@Injectable() +export class IssuesByTagsWidgetService + implements WidgetInterface +{ + private logger = new Logger(IssuesByTagsWidgetService.name); + private issuesLoader: IssuesServiceNs.IssuesLoader; + + constructor( + private issuesService: IssuesService, + private timePassedHighlightEnhancer: TimePassedHighlightEnhancer, + private issueUrlEnhancer: IssueUrlEnhancer, + ) { + this.issuesLoader = this.issuesService.createDynamicIssuesLoader(); + } + + isMyConfig(): boolean { + return true; + } + + async render(widgetParams: Params): Promise { + let store: FlatIssuesStore; + if (widgetParams.fromRootIssueId) { + store = await this.getListFromRoot(widgetParams.fromRootIssueId); + } else if (widgetParams.fromQuery) { + store = await this.getListByQuery(widgetParams.fromQuery); + } else { + const errMsg = `Wrong widgetParams value`; + this.logger.error(errMsg); + throw new Error(errMsg); + } + await store.enhanceIssues([ + this.timePassedHighlightEnhancer, + this.issueUrlEnhancer, + TagStyledEnhancerNs.CreateTagStyledEnhancerForConfig(widgetParams), + ]); + const grouped = store.groupByStatusWithExtraToMultipleStories((issue) => { + if (!issue || !widgetParams.tags || !widgetParams.tags.tagsKeyName) { + return []; + } + const tagsResult = GetValueFromObjectByKey( + issue, + widgetParams.tags.tagsKeyName, + ); + if ( + (tagsResult.error == 'NOT_FOUND' || + (tagsResult.result && tagsResult.result.length <= 0)) && + widgetParams.showEmptyTags + ) { + return [widgetParams.showEmptyTags]; + } + if ( + typeof tagsResult.result !== 'object' || + tagsResult.result.length <= 0 + ) { + return []; + } + return tagsResult.result; + }, widgetParams.statuses); + let res = [] as any[]; + for (const tag in grouped) { + if (Object.prototype.hasOwnProperty.call(grouped, tag)) { + const data = grouped[tag]; + res.push({ + data: data, + metainfo: this.createMetaInfo(tag), + }); + } + } + if (widgetParams.tagsSort) { + res = res.sort((a, b) => { + return a.metainfo.title.localeCompare(b.metainfo.title); + }); + } + return res; + } + + private async getListFromRoot(issueId: number): Promise { + const treeStore = new TreeIssuesStore(); + const rootIssue = await this.issuesService.getIssue(issueId); + treeStore.setRootIssue(rootIssue); + await treeStore.fillData(this.issuesLoader); + return treeStore.getFlatStore(); + } + + private async getListByQuery( + query: nano.MangoQuery, + ): Promise { + const rawData = await this.issuesService.find(query); + const store = new FlatIssuesStore(); + for (let i = 0; i < rawData.length; i++) { + const issue = rawData[i]; + store.push(issue); + } + return store; + } + + private createMetaInfo(tag: string): Record { + return { + title: tag, + }; + } +} diff --git a/src/issue-enhancers/category-merge-to-tags-enhancer.ts b/src/issue-enhancers/category-merge-to-tags-enhancer.ts new file mode 100644 index 0000000..989975e --- /dev/null +++ b/src/issue-enhancers/category-merge-to-tags-enhancer.ts @@ -0,0 +1,34 @@ +import { IssueEnhancerInterface } from '@app/event-emitter/issue-enhancers/issue-enhancer-interface'; +import { RedmineTypes } from '@app/event-emitter/models/redmine-types'; +import { Injectable, Logger } from '@nestjs/common'; + +@Injectable() +export class CategoryMergeToTagsEnhancer implements IssueEnhancerInterface { + name = 'category-merge-to-tags'; + + private logger = new Logger(CategoryMergeToTagsEnhancer.name); + + constructor(private forProjects: string[]) { + this.logger.debug(`Enhancer created for ${forProjects}`); + } + + async enhance( + issue: RedmineTypes.ExtendedIssue, + ): Promise { + if (!issue || !issue?.project?.name || this.forProjects.indexOf(issue.project.name) < 0) { + return issue; + } + if (!issue.tags || !this.isArray(issue.tags)) { + issue.tags = []; + } + const category = issue?.category?.name?.toLowerCase()?.replaceAll(' ', '_'); + if (category && issue.tags.indexOf(category) < 0) { + issue.tags.push(category); + } + return issue; + } + + private isArray(a: any): boolean { + return typeof a == 'object' && typeof a.length === 'number'; + } +} diff --git a/src/issue-enhancers/custom-fields-enhancer.ts b/src/issue-enhancers/custom-fields-enhancer.ts index ac50d81..1694800 100644 --- a/src/issue-enhancers/custom-fields-enhancer.ts +++ b/src/issue-enhancers/custom-fields-enhancer.ts @@ -50,7 +50,10 @@ export class CustomFieldsEnhancer implements IssueEnhancerInterface { const tags = customFields.find((cf) => cf.name === 'Tags'); if (tags && tags.value) { - res.tags = tags.value.split(/[ ,;]/); + res.tags = tags.value + .split(/[ ,;]/) + .map((s) => s.trim()) + .filter((s) => !!s); } const sp = customFields.find( diff --git a/src/models/app-config.model.ts b/src/models/app-config.model.ts index b1352af..4ff91ba 100644 --- a/src/models/app-config.model.ts +++ b/src/models/app-config.model.ts @@ -1,4 +1,5 @@ import { MainConfigModel } from '@app/event-emitter/models/main-config-model'; +import { SimpleKanbanBoardConfig } from 'src/configs/simple-kanban-board.config'; import { EccmConfig } from './eccm-config.model'; import { StatusChangesConfig } from './status-changes-config.model'; import { StatusesConfig } from './statuses-config.model'; @@ -9,11 +10,13 @@ export type AppConfig = { redmineStatusChanges: StatusChangesConfig.Config; redmineEccm: EccmConfig.Config; redmineCurrentUserRules: Record; + simpleKanbanBoard: SimpleKanbanBoardConfig; couchDb: { dbs: { changes: string; userMetaInfo: string; eccmDailyReports: string; + eccmDailyReportsUserComments: string; }; }; telegramBotToken: string; diff --git a/src/notifications/adapters/personal-notification.adapter/personal-notification.adapter.service.ts b/src/notifications/adapters/personal-notification.adapter/personal-notification.adapter.service.ts index 6d946ea..a581dac 100644 --- a/src/notifications/adapters/personal-notification.adapter/personal-notification.adapter.service.ts +++ b/src/notifications/adapters/personal-notification.adapter/personal-notification.adapter.service.ts @@ -4,7 +4,7 @@ import { ConfigService } from '@nestjs/config'; import { IssueAndPersonalParsedMessageModel } from 'src/models/issue-and-personal-parsed-message.model'; import { TelegramBotService } from 'src/telegram-bot/telegram-bot.service'; import Handlebars from 'handlebars'; -import { RedminePublicUrlConverter } from 'src/converters/redmine-public-url.converter'; +import { RedminePublicUrlConverter } from '@app/event-emitter/converters/redmine-public-url.converter'; @Injectable() export class PersonalNotificationAdapterService { diff --git a/src/notifications/adapters/status-change.adapter.service.ts b/src/notifications/adapters/status-change.adapter.service.ts index 9e9ecaa..b70271b 100644 --- a/src/notifications/adapters/status-change.adapter.service.ts +++ b/src/notifications/adapters/status-change.adapter.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import TelegramBot from 'node-telegram-bot-api'; import { Change } from 'src/models/change.model'; @@ -18,6 +18,7 @@ namespace StatusChangeAdapter { @Injectable() export class StatusChangeAdapterService { + private logger = new Logger(StatusChangeAdapterService.name); private periodValidityNotification: number; constructor( @@ -54,6 +55,7 @@ export class StatusChangeAdapterService { item.options = { parse_mode: 'HTML' }; return item; }); + this.logger.debug(`Change messages for sending to telegram - ${JSON.stringify(messages)}`); for (let i = 0; i < messages.length; i++) { const message = messages[i]; await this.telegramBotService.sendMessageByRedmineId( @@ -74,6 +76,12 @@ export class StatusChangeAdapterService { nowTimestamp - change.created_on_timestamp > this.periodValidityNotification ) { + this.logger.debug( + `Skipping sending due to the prescription ` + + `of the origin of the event - ` + + `issue_id = ${change.issue_id}, ` + + `messages = ${change.messages.map((m) => m.notification_message).filter((m) => !!m)}` + ); continue; } for (let j = 0; j < change.messages.length; j++) { diff --git a/src/notifications/status-change-notifications.service.ts b/src/notifications/status-change-notifications.service.ts index 7c9e525..b1ca1b9 100644 --- a/src/notifications/status-change-notifications.service.ts +++ b/src/notifications/status-change-notifications.service.ts @@ -4,13 +4,13 @@ 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'; +import { RedminePublicUrlConverter } from '@app/event-emitter/converters/redmine-public-url.converter'; @Injectable() export class StatusChangeNotificationsService { diff --git a/src/reports/current-issues-eccm.report.service.ts b/src/reports/current-issues-eccm.report.service.ts index 13489ac..ce3b0c7 100644 --- a/src/reports/current-issues-eccm.report.service.ts +++ b/src/reports/current-issues-eccm.report.service.ts @@ -6,9 +6,9 @@ import { UNLIMITED } from '@app/event-emitter/consts/consts'; import { RedmineTypes } from '@app/event-emitter/models/redmine-types'; import nano from 'nano'; import Handlebars from 'handlebars'; -import { RedminePublicUrlConverter } from 'src/converters/redmine-public-url.converter'; import { EccmConfig } from 'src/models/eccm-config.model'; import { IssuesService } from '@app/event-emitter/issues/issues.service'; +import { RedminePublicUrlConverter } from '@app/event-emitter/converters/redmine-public-url.converter'; // eslint-disable-next-line @typescript-eslint/no-namespace export namespace CurrentIssuesEccmReport { diff --git a/src/reports/daily-eccm-user-comments.service.ts b/src/reports/daily-eccm-user-comments.service.ts new file mode 100644 index 0000000..e5d2a09 --- /dev/null +++ b/src/reports/daily-eccm-user-comments.service.ts @@ -0,0 +1,93 @@ +/* eslint-disable @typescript-eslint/no-namespace */ +import { UNLIMITED } from '@app/event-emitter/consts/consts'; +import { Timestamped } from '@app/event-emitter/models/timestamped'; +import { TimestampNowFill } from '@app/event-emitter/utils/timestamp-now-fill'; +import { Injectable } from '@nestjs/common'; +import nano from 'nano'; +import { DailyEccmReportsUserCommentsDatasource } from 'src/couchdb-datasources/daily-eccm-reports-user-comments.datasource'; + +export namespace DailyEccmUserComments { + export namespace Models { + export type Item = { + userId: number; + date: string; + comment: string; + }; + + export type CouchDbItem = Item & Timestamped & nano.DocumentGetResponse; + } +} + +@Injectable() +export class DailyEccmUserCommentsService { + constructor(private datasource: DailyEccmReportsUserCommentsDatasource) {} + + async setComment( + userId: number, + date: string, + comment: string, + ): Promise { + const key = this.getKey(userId, date); + const ds = await this.datasource.getDatasource(); + let existsItem: any; + try { + existsItem = await ds.get(key); + } catch (ex) { + existsItem = null; + } + const item: DailyEccmUserComments.Models.CouchDbItem = TimestampNowFill({ + userId: userId, + date: date, + comment: comment, + _id: key, + _rev: existsItem?._rev, + }); + await ds.insert(item); + } + + async loadComment(userId: number, date: string): Promise { + const key = this.getKey(userId, date); + const ds = await this.datasource.getDatasource(); + try { + const res: any = await ds.get(key); + return res.comment; + } catch (ex) { + return null; + } + } + + async loadComments( + userIds: number[], + date: string, + ): Promise> { + const query: nano.MangoQuery = { + limit: UNLIMITED, + selector: { + userId: { + $in: userIds, + }, + date: { + $eq: date, + }, + }, + }; + const ds = await this.datasource.getDatasource(); + const resp = await ds.find(query); + if (!resp || !resp.docs || resp.docs.length <= 0) { + return []; + } + const items = resp.docs; + const res: Record = {}; + for (const key in items) { + if (Object.prototype.hasOwnProperty.call(items, key)) { + const item = items[key]; + res[item.userId] = item.comment; + } + } + return res; + } + + private getKey(userId: number, date: string): string { + return `${date} - ${userId}`; + } +} diff --git a/src/reports/daily-eccm-with-extra-data.service.ts b/src/reports/daily-eccm-with-extra-data.service.ts new file mode 100644 index 0000000..35ebaf7 --- /dev/null +++ b/src/reports/daily-eccm-with-extra-data.service.ts @@ -0,0 +1,43 @@ +import { RedminePublicUrlConverter } from '@app/event-emitter/converters/redmine-public-url.converter'; +import { Timestamped } from '@app/event-emitter/models/timestamped'; +import { Injectable } from '@nestjs/common'; +import nano from 'nano'; +import { DailyEccmUserCommentsService } from './daily-eccm-user-comments.service'; +import { + DailyEccmReport, + DailyEccmReportService, +} from './daily-eccm.report.service'; + +@Injectable() +export class DailyEccmWithExtraDataService { + constructor( + private dailyEccmReportService: DailyEccmReportService, + private dailyEccmUserCommentsService: DailyEccmUserCommentsService, + private redminePublicUrlConverter: RedminePublicUrlConverter, + ) {} + + async loadReport( + name: string, + ): Promise< + | (DailyEccmReport.Models.Report & nano.DocumentGetResponse & Timestamped) + | null + > { + const baseReportData = await this.dailyEccmReportService.loadReport(name); + if (!baseReportData) return null; + const userIds = baseReportData.byUsers.map((item) => item.user.id); + const userComments = await this.dailyEccmUserCommentsService.loadComments( + userIds, + name, + ); + for (let i = 0; i < baseReportData.byUsers.length; i++) { + const byUser = baseReportData.byUsers[i]; + if (userComments[byUser.user.id]) { + byUser.dailyMessage = + this.redminePublicUrlConverter.enrichTextWithIssues( + userComments[byUser.user.id], + ); + } + } + return baseReportData; + } +} diff --git a/src/reports/daily-eccm.report.controller.ts b/src/reports/daily-eccm.report.controller.ts index 3c9f971..1c05b14 100644 --- a/src/reports/daily-eccm.report.controller.ts +++ b/src/reports/daily-eccm.report.controller.ts @@ -1,4 +1,5 @@ import { Controller, Get, Param, Query, Render } from '@nestjs/common'; +import { DailyEccmWithExtraDataService } from './daily-eccm-with-extra-data.service'; import { DailyEccmReport, DailyEccmReportService, @@ -6,7 +7,10 @@ import { @Controller('daily-eccm') export class DailyEccmReportController { - constructor(private dailyEccmReportService: DailyEccmReportService) {} + constructor( + private dailyEccmReportService: DailyEccmReportService, + private dailyEccmWithExtraDataService: DailyEccmWithExtraDataService, + ) {} @Get() @Render('daily-eccm-report') @@ -67,4 +71,19 @@ export class DailyEccmReportController { return data; } } + + @Get('/load/:name/extended/raw') + async loadExtendedReportRawData( + @Param('name') name: string, + ): Promise { + return await this.dailyEccmWithExtraDataService.loadReport(name); + } + + @Get('/load/:name/extended') + @Render('daily-eccm-report-extended') + async loadExtendedReport( + @Param('name') name: string, + ): Promise { + return await this.dailyEccmWithExtraDataService.loadReport(name); + } } diff --git a/src/reports/daily-eccm.report.service.ts b/src/reports/daily-eccm.report.service.ts index 45010e6..b17051e 100644 --- a/src/reports/daily-eccm.report.service.ts +++ b/src/reports/daily-eccm.report.service.ts @@ -14,6 +14,7 @@ import { DailyEccmReportsDatasource } from 'src/couchdb-datasources/daily-eccm-r import { Timestamped } from '@app/event-emitter/models/timestamped'; import { TimestampNowFill } from '@app/event-emitter/utils/timestamp-now-fill'; import { DateTime } from 'luxon'; +import { ISO_DATE_FORMAT } from 'src/consts/date-time.consts'; // eslint-disable-next-line @typescript-eslint/no-namespace export namespace DailyEccmReport { @@ -413,7 +414,7 @@ export class DailyEccmReportService { 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'); + nameValue = DateTime.now().toFormat(ISO_DATE_FORMAT); } return { from: fromDate.toISO(), diff --git a/src/reports/daily-eccm.report.task.ts b/src/reports/daily-eccm.report.task.ts index b986936..dc3f16b 100644 --- a/src/reports/daily-eccm.report.task.ts +++ b/src/reports/daily-eccm.report.task.ts @@ -26,7 +26,7 @@ export class DailyEccmReportTask { ); } - @Cron('25 9,10 1-5 * *') + @Cron('25 9,10 * * 1-5') async generateReport(): Promise { this.logger.log(`Generate daily eccm report by cron task started`); const now = DateTime.now(); diff --git a/src/telegram-bot/handlers/set-daily-eccm-user-comment.bot-handler.service.ts b/src/telegram-bot/handlers/set-daily-eccm-user-comment.bot-handler.service.ts new file mode 100644 index 0000000..4c67092 --- /dev/null +++ b/src/telegram-bot/handlers/set-daily-eccm-user-comment.bot-handler.service.ts @@ -0,0 +1,102 @@ +/* eslint-disable @typescript-eslint/no-namespace */ +import { TelegramBotService } from '../telegram-bot.service'; +import { TelegramBotHandlerInterface } from '../telegram.bot-handler.interface'; +import TelegramBot from 'node-telegram-bot-api'; +import { Injectable, Logger } from '@nestjs/common'; +import { UserMetaInfoService } from 'src/user-meta-info/user-meta-info.service'; +import { Subject } from 'rxjs'; +import { DateTime } from 'luxon'; +import { ISO_DATE_FORMAT } from 'src/consts/date-time.consts'; + +export namespace SetDailyEccmUserCommentBotHandler { + export namespace Models { + export type SetDailyEccmUserComment = { + userId: number; + date: string; + comment: string; + }; + } +} + +@Injectable() +export class SetDailyEccmUserCommentBotHandlerService + implements TelegramBotHandlerInterface +{ + private service: TelegramBotService; + private regexp = /\/set_daily_eccm_user_comment.*/g; + private logger = new Logger(SetDailyEccmUserCommentBotHandlerService.name); + + $messages = + new Subject(); + + constructor(private userMetaInfoService: UserMetaInfoService) { + return; + } + + async init(service: TelegramBotService, bot: TelegramBot): Promise { + if (!this.service) { + this.service = service; + } + bot.onText(this.regexp, async (msg) => { + const userMetaInfo = await this.userMetaInfoService.findByTelegramId( + msg.chat.id, + ); + if (!userMetaInfo) { + this.logger.error(`User for telegram id = ${msg.chat.id} not found`); + } + const redmineUserId = userMetaInfo.user_id; + const data = this.parseData(msg.text); + if (data) { + this.logger.debug( + `Setting user comment for daily eccm ` + + `by redmineUserId = ${redmineUserId}, ` + + `full text - ${msg.text}, ` + + `parsed data - ${JSON.stringify(data)}`, + ); + this.$messages.next({ + userId: redmineUserId, + date: data.date, + comment: data.msg, + }); + } else { + this.logger.error( + `For some reason, it was not possible to get data from an incoming message - ${msg.text}`, + ); + } + }); + } + + getHelpMsg(): string { + return ( + `/set_daily_eccm_user_comment ` + + `[date=yyyy-MM-dd] <комментарий> ` + + `- дополнительный комментарий для дейли` + ); + } + + private parseData(src: string): { date: string; msg: string } | null { + let text = src; + + text = text.replace('/set_daily_eccm_user_comment', '').trim(); + if (!text) { + return null; + } + + const dateMatch = text.match(/^date=[\d-]+/); + if (!dateMatch) { + return { date: DateTime.now().toFormat(ISO_DATE_FORMAT), msg: text }; + } + + const datePart = dateMatch[0]; + let dateRaw = datePart.replace('date=', ''); + text = text.replace('date=', '').trim(); + const date = DateTime.fromFormat(dateRaw, ISO_DATE_FORMAT); + if (!date.isValid) { + this.logger.error(`Wrong date in message - ${src}`); + return null; + } + text = text.replace(dateRaw, '').trim(); + dateRaw = date.toFormat(ISO_DATE_FORMAT); + return { date: dateRaw, msg: text }; + } +} diff --git a/src/telegram-bot/telegram-bot.service.ts b/src/telegram-bot/telegram-bot.service.ts index 5c5dfe2..2254fa3 100644 --- a/src/telegram-bot/telegram-bot.service.ts +++ b/src/telegram-bot/telegram-bot.service.ts @@ -7,6 +7,7 @@ import axios from 'axios'; import { UserMetaInfoModel } from 'src/models/user-meta-info.model'; import { CurrentIssuesEccmBotHandlerService } from './handlers/current-issues-eccm.bot-handler.service'; import { TelegramBotHandlerInterface } from './telegram.bot-handler.interface'; +import { SetDailyEccmUserCommentBotHandlerService } from './handlers/set-daily-eccm-user-comment.bot-handler.service'; @Injectable() export class TelegramBotService { @@ -24,17 +25,24 @@ export class TelegramBotService { private usersService: UsersService, private configService: ConfigService, private currentIssuesBotHandlerService: CurrentIssuesEccmBotHandlerService, + private setDailyEccmUserCommentBotHandlerService: SetDailyEccmUserCommentBotHandlerService, ) { this.telegramBotToken = this.configService.get('telegramBotToken'); this.redminePublicUrlPrefix = this.configService.get('redmineUrlPublic'); - this.initTelegramBot(); + this.initTelegramBot().catch((ex) => { + this.logger.error(`Error at init telegram bot - ${ex}`); + }); this.handlers.push(this.currentIssuesBotHandlerService); + this.handlers.push(this.setDailyEccmUserCommentBotHandlerService); } private async initTelegramBot(): Promise { const Telegram = await require('node-telegram-bot-api'); + if (!this.telegramBotToken) return; + this.logger.debug('Telegram bot instance creation ... '); this.bot = new Telegram(this.telegramBotToken, { polling: true }); + this.logger.debug('Telegram bot instance created'); this.bot.onText(/\/start/, async (msg) => { await this.showHelpMessage(msg); }); @@ -47,10 +55,20 @@ export class TelegramBotService { this.bot.onText(/\/leave/, async (msg) => { await this.leave(msg); }); - this.currentIssuesBotHandlerService.init(this, this.bot); + this.bot.on('polling_error', (error) => { + this.logger.error(`polling_error from telegram bot instance - ${error}`); + }); + this.bot.on('webhook_error', (error) => { + this.logger.error(`webhook_error from telegram bot instance - ${error}`); + }); + for (let i = 0; i < this.handlers.length; i++) { + const handler = this.handlers[i]; + await handler.init(this, this.bot); + } } private async showHelpMessage(msg: TelegramBot.Message): Promise { + if (!this.telegramBotToken) return; const userMetaInfo = await this.userMetaInfoService.findByTelegramId( msg.chat.id, ); @@ -75,7 +93,11 @@ export class TelegramBotService { `Sent help message for telegramChatId = ${msg.chat.id}, ` + `message = ${helpMessage}`, ); - this.bot.sendMessage(msg.chat.id, helpMessage); + try { + this.bot.sendMessage(msg.chat.id, helpMessage); + } catch (ex) { + this.logger.error(`Error at send help message - ${ex?.message}`); + } } async sendMessageByRedmineId( @@ -83,6 +105,7 @@ export class TelegramBotService { msg: string, options?: TelegramBot.SendMessageOptions, ): Promise { + if (!this.telegramBotToken) return false; const userMetaInfo = await this.userMetaInfoService.findByRedmineId( redmineId, ); @@ -104,6 +127,7 @@ export class TelegramBotService { msg: string, options?: TelegramBot.SendMessageOptions, ): Promise { + if (!this.telegramBotToken) return false; const user = await this.usersService.findUserByName(firstname, lastname); if (!user) return false; return await this.sendMessageByRedmineId(user.id, msg, options); diff --git a/tsconfig.json b/tsconfig.json index 85879f4..0408fb2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,7 @@ "emitDecoratorMetadata": true, "experimentalDecorators": true, "allowSyntheticDefaultImports": true, - "target": "es2017", + "target": "es2021", "sourceMap": true, "outDir": "./dist", "baseUrl": "./", diff --git a/views/daily-eccm-report-extended.hbs b/views/daily-eccm-report-extended.hbs new file mode 100644 index 0000000..ec346da --- /dev/null +++ b/views/daily-eccm-report-extended.hbs @@ -0,0 +1,57 @@ + + + + + Daily Eccm Report + + +
+ Параметры отчёта +
    +
  • От - {{this.params.from}}
  • +
  • До - {{this.params.to}}
  • +
  • Имя отчёта - {{this.params.name}}
  • +
  • Имя проекта - {{this.params.project}}
  • +
  • Версии - {{this.params.versions}}
  • +
+
+

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

+ {{#each this.byUsers}} + +

{{this.user.firstname}} {{this.user.lastname}}

+ + {{#if this.dailyMessage}} +

Комментарий

+
{{{this.dailyMessage}}}
+ {{/if}} + +

Текущие задачи

+
    + {{#each this.issuesGroupedByStatus}} +
  • + {{this.status.name}}: +
      + {{#each this.issues}} +
    • {{>redmineIssueAHref issue=this.issue}} (приоритет {{this.issue.priority.name}}; версия {{this.issue.fixed_version.name}}) - {{this.issue.subject}}
    • + {{/each}} +
    +
  • + {{/each}} +
+ +

Активности за период

+
    + {{#each this.activities}} +
  • + {{>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}}
    • + {{/each}} +
    +
  • + {{/each}} +
+ {{/each}} + + \ No newline at end of file diff --git a/views/simple-kanban-board.hbs b/views/simple-kanban-board.hbs new file mode 100644 index 0000000..377130b --- /dev/null +++ b/views/simple-kanban-board.hbs @@ -0,0 +1,103 @@ + + + + + + ECCM 1.10 Kanban board + + + + + {{#each this}} + {{#if this.metainfo}} +

{{this.metainfo.title}} #

+
+ {{#each this.data}} +
+
{{this.status}}
+ {{#each this.issues}} +
+ +
Исп.: {{this.current_user.name}}
+
Прогресс: {{this.done_ration}}
+
Трудозатраты: {{this.total_spent_hours}} / {{this.total_estimated_hours}}
+ {{#if this.styledTags}} +
+ Tags: + {{#each this.styledTags}} + {{this.tag}} + {{/each}} +
+ {{/if}} +
+ {{/each}} +
+ {{/each}} +
+ {{/if}} + {{/each}} + + \ No newline at end of file