diff --git a/backend/plugins/frontline_api/src/apollo/resolvers/mutations.ts b/backend/plugins/frontline_api/src/apollo/resolvers/mutations.ts index 4ae959c571..f8abaa64a9 100644 --- a/backend/plugins/frontline_api/src/apollo/resolvers/mutations.ts +++ b/backend/plugins/frontline_api/src/apollo/resolvers/mutations.ts @@ -2,10 +2,12 @@ import { channelMutations } from '@/inbox/graphql/resolvers/mutations/channels'; import { conversationMutations } from '@/inbox/graphql/resolvers/mutations/conversations'; import { integrationMutations } from '@/inbox/graphql/resolvers/mutations/integrations'; import { facebookMutations } from '@/integrations/facebook/graphql/resolvers/mutations'; +import { ticketMutations } from '~/modules/tickets/graphql/resolvers/mutations'; export const mutations = { ...channelMutations, ...conversationMutations, ...integrationMutations, - ...facebookMutations + ...facebookMutations, + ...ticketMutations, }; diff --git a/backend/plugins/frontline_api/src/apollo/resolvers/queries.ts b/backend/plugins/frontline_api/src/apollo/resolvers/queries.ts index cf82b7e3ee..5cbc57bb44 100644 --- a/backend/plugins/frontline_api/src/apollo/resolvers/queries.ts +++ b/backend/plugins/frontline_api/src/apollo/resolvers/queries.ts @@ -2,10 +2,12 @@ import { channelQueries } from '@/inbox/graphql/resolvers/queries/channels'; import { conversationQueries } from '@/inbox/graphql/resolvers/queries/conversations'; import { integrationQueries } from '@/inbox/graphql/resolvers/queries/integrations'; import { facebookQueries } from '@/integrations/facebook/graphql/resolvers/queries'; +import { queries as ticketQueries } from '~/modules/tickets/graphql/resolvers/queries'; export const queries = { ...channelQueries, ...conversationQueries, ...integrationQueries, - ...facebookQueries + ...facebookQueries, + ...ticketQueries, }; diff --git a/backend/plugins/frontline_api/src/apollo/resolvers/resolvers.ts b/backend/plugins/frontline_api/src/apollo/resolvers/resolvers.ts index 5a26cf99c0..1179390c3c 100644 --- a/backend/plugins/frontline_api/src/apollo/resolvers/resolvers.ts +++ b/backend/plugins/frontline_api/src/apollo/resolvers/resolvers.ts @@ -1,5 +1,7 @@ import inboxResolvers from '@/inbox/graphql/resolvers/customResolvers'; +import ticketResolvers from '~/modules/tickets/graphql/resolvers/customResolvers'; export const customResolvers = { ...inboxResolvers, + ...ticketResolvers, }; diff --git a/backend/plugins/frontline_api/src/apollo/schema/extensions.ts b/backend/plugins/frontline_api/src/apollo/schema/extensions.ts new file mode 100644 index 0000000000..62ecc469d2 --- /dev/null +++ b/backend/plugins/frontline_api/src/apollo/schema/extensions.ts @@ -0,0 +1,31 @@ +export const TypeExtensions = ` + extend type User @key(fields: "_id") { + _id: String @external, + conversations: [Conversation] + } + + extend type Branch @key(fields: "_id") { + _id: String @external + } + + extend type Department @key(fields: "_id") { + _id: String @external + } + + extend type Company @key(fields: "_id") { + _id: String @external + } + + extend type Customer @key(fields: "_id") { + _id: String @external + conversations: [Conversation] + } + + extend type Tag @key(fields: "_id") { + _id: String @external + } + + extend type Brand @key(fields: "_id") { + _id: String @external + } +`; diff --git a/backend/plugins/frontline_api/src/apollo/schema/schema.ts b/backend/plugins/frontline_api/src/apollo/schema/schema.ts index 4b8aa7dc37..65ff9116a3 100644 --- a/backend/plugins/frontline_api/src/apollo/schema/schema.ts +++ b/backend/plugins/frontline_api/src/apollo/schema/schema.ts @@ -1,9 +1,9 @@ -// import { TypeExtensions } from '../../modules/inbox/graphql/schemas/extensions'; import { mutations as ChannelsMutations, queries as ChannelsQueries, types as ChannelsTypes, } from '@/inbox/graphql/schemas/channel'; +import { TypeExtensions } from './extensions'; import { mutations as ConversationsMutations, @@ -23,17 +23,26 @@ import { types as FacebookTypes, } from '@/integrations/facebook/graphql/schema/facebook'; +import { + mutations as TicketsMutations, + queries as TicketsQueries, + types as TicketsTypes, +} from '~/modules/tickets/graphql/schemas'; + export const types = ` + ${TypeExtensions} ${ChannelsTypes} ${ConversationsTypes} ${IntegrationsTypes} ${FacebookTypes} + ${TicketsTypes} `; export const queries = ` ${ChannelsQueries} ${ConversationsQueries} ${IntegrationsQueries} ${FacebookQueries} + ${TicketsQueries} `; export const mutations = ` @@ -41,5 +50,6 @@ export const mutations = ` ${ConversationsMutations} ${IntegrationsMutations} ${FacebookMutations} + ${TicketsMutations} `; export default { types, queries, mutations }; diff --git a/backend/plugins/frontline_api/src/connectionResolvers.ts b/backend/plugins/frontline_api/src/connectionResolvers.ts index f13ba6b9ba..92de67db32 100644 --- a/backend/plugins/frontline_api/src/connectionResolvers.ts +++ b/backend/plugins/frontline_api/src/connectionResolvers.ts @@ -1,53 +1,30 @@ -import { createGenerateModels } from 'erxes-api-shared/utils'; -import { IMainContext } from 'erxes-api-shared/core-types'; -import mongoose from 'mongoose'; import { IChannelDocument } from '@/inbox/@types/channels'; -import { IIntegrationDocument } from '@/inbox/@types/integrations'; -import { IConversationDocument } from '@/inbox/@types/conversations'; import { IMessageDocument } from '@/inbox/@types/conversationMessages'; -import { IFacebookIntegrationDocument } from '@/integrations/facebook/@types/integrations'; -import { IFacebookLogDocument } from '@/integrations/facebook/@types/logs'; -import { IFacebookAccountDocument } from '@/integrations/facebook/@types/accounts'; -import { IFacebookCustomerDocument } from '@/integrations/facebook/@types/customers'; -import { IFacebookConversationDocument } from '@/integrations/facebook/@types/conversations'; -import { IFacebookConversationMessageDocument } from '@/integrations/facebook/@types/conversationMessages'; -import { IFacebookCommentConversationDocument } from '@/integrations/facebook/@types/comment_conversations'; -import { IFacebookCommentConversationReplyDocument } from '@/integrations/facebook/@types/comment_conversations_reply'; -import { IFacebookPostConversationDocument } from '@/integrations/facebook/@types/postConversations'; -import { IFacebookConfigDocument } from '@/integrations/facebook/@types/config'; +import { IConversationDocument } from '@/inbox/@types/conversations'; +import { IIntegrationDocument } from '@/inbox/@types/integrations'; import { IChannelModel, loadChannelClass } from '@/inbox/db/models/Channels'; -import { - IIntegrationModel, - loadClass as loadIntegrationClass, -} from '~/modules/inbox/db/models/Integrations'; -import { - IConversationModel, - loadClass as loadConversationClass, -} from '@/inbox/db/models/Conversations'; import { IMessageModel, loadClass as loadMessageClass, } from '@/inbox/db/models/ConversationMessages'; import { - IFacebookIntegrationModel, - loadFacebookIntegrationClass, -} from '@/integrations/facebook/db/models/Integrations'; + IConversationModel, + loadClass as loadConversationClass, +} from '@/inbox/db/models/Conversations'; +import { IFacebookAccountDocument } from '@/integrations/facebook/@types/accounts'; +import { IFacebookCommentConversationDocument } from '@/integrations/facebook/@types/comment_conversations'; +import { IFacebookCommentConversationReplyDocument } from '@/integrations/facebook/@types/comment_conversations_reply'; +import { IFacebookConfigDocument } from '@/integrations/facebook/@types/config'; +import { IFacebookConversationMessageDocument } from '@/integrations/facebook/@types/conversationMessages'; +import { IFacebookConversationDocument } from '@/integrations/facebook/@types/conversations'; +import { IFacebookCustomerDocument } from '@/integrations/facebook/@types/customers'; +import { IFacebookIntegrationDocument } from '@/integrations/facebook/@types/integrations'; +import { IFacebookLogDocument } from '@/integrations/facebook/@types/logs'; +import { IFacebookPostConversationDocument } from '@/integrations/facebook/@types/postConversations'; import { IFacebookAccountModel, loadFacebookAccountClass, } from '@/integrations/facebook/db/models/Accounts'; -import { - IFacebookCustomerModel, - loadFacebookCustomerClass, -} from '@/integrations/facebook/db/models/Customers'; -import { - IFacebookConversationModel, - loadFacebookConversationClass, -} from '@/integrations/facebook/db/models/Conversations'; -import { - IFacebookConversationMessageModel, - loadFacebookConversationMessageClass, -} from '@/integrations/facebook/db/models/ConversationMessages'; import { IFacebookCommentConversationModel, loadFacebookCommentConversationClass, @@ -56,6 +33,26 @@ import { IFacebookCommentConversationReplyModel, loadFacebookCommentConversationReplyClass, } from '@/integrations/facebook/db/models/Comment_conversations_reply'; +import { + IFacebookConfigModel, + loadFacebookConfigClass, +} from '@/integrations/facebook/db/models/Config'; +import { + IFacebookConversationMessageModel, + loadFacebookConversationMessageClass, +} from '@/integrations/facebook/db/models/ConversationMessages'; +import { + IFacebookConversationModel, + loadFacebookConversationClass, +} from '@/integrations/facebook/db/models/Conversations'; +import { + IFacebookCustomerModel, + loadFacebookCustomerClass, +} from '@/integrations/facebook/db/models/Customers'; +import { + IFacebookIntegrationModel, + loadFacebookIntegrationClass, +} from '@/integrations/facebook/db/models/Integrations'; import { IFacebookLogModel, loadFacebookLogClass, @@ -64,10 +61,24 @@ import { IFacebookPostConversationModel, loadFacebookPostConversationClass, } from '@/integrations/facebook/db/models/PostConversations'; +import { IMainContext } from 'erxes-api-shared/core-types'; +import { createGenerateModels } from 'erxes-api-shared/utils'; +import mongoose from 'mongoose'; import { - IFacebookConfigModel, - loadFacebookConfigClass, -} from '@/integrations/facebook/db/models/Config'; + IIntegrationModel, + loadClass as loadIntegrationClass, +} from '~/modules/inbox/db/models/Integrations'; +import { IBoardModel } from '~/modules/tickets/db/models/Boards'; +import { + IChecklistItemModel, + IChecklistModel, +} from '~/modules/tickets/db/models/Checklists'; +import { ICommentModel } from '~/modules/tickets/db/models/Comments'; +import { IPipelineLabelModel } from '~/modules/tickets/db/models/Labels'; +import { IPipelineModel } from '~/modules/tickets/db/models/Pipelines'; +import { IStageModel } from '~/modules/tickets/db/models/Stages'; +import { ITicketModel } from '~/modules/tickets/db/models/Tickets'; +import { loadClasses as loadTicketClasses } from '~/modules/tickets/resolver'; export interface IModels { Channels: IChannelModel; @@ -84,6 +95,15 @@ export interface IModels { FacebookLogs: IFacebookLogModel; FacebookPostConversations: IFacebookPostConversationModel; FacebookConfigs: IFacebookConfigModel; + + Boards: IBoardModel; + Pipelines: IPipelineModel; + Stages: IStageModel; + Tickets: ITicketModel; + PipelineLabels: IPipelineLabelModel; + CheckLists: IChecklistModel; + CheckListItems: IChecklistItemModel; + Comments: ICommentModel; } export interface IContext extends IMainContext { @@ -163,6 +183,9 @@ export const loadClasses = ( IFacebookConfigDocument, IFacebookConfigModel >('facebook_configs', loadFacebookConfigClass(models)); + + loadTicketClasses(models, db); + return models; }; diff --git a/backend/plugins/frontline_api/src/modules/inbox/graphql/schemas/conversation.ts b/backend/plugins/frontline_api/src/modules/inbox/graphql/schemas/conversation.ts index c0ab7633e1..69efd5788c 100644 --- a/backend/plugins/frontline_api/src/modules/inbox/graphql/schemas/conversation.ts +++ b/backend/plugins/frontline_api/src/modules/inbox/graphql/schemas/conversation.ts @@ -1,22 +1,6 @@ import { GQL_CURSOR_PARAM_DEFS } from 'erxes-api-shared/utils'; export const types = ` - extend type User @key(fields: "_id") { - _id: String @external - } - - extend type Customer @key(fields: "_id") { - _id: String @external - conversations: [Conversation] - } - extend type Brand @key(fields: "_id") { - _id: String @external - } - - extend type Tag @key(fields: "_id") { - _id: String @external - } - type Conversation { _id: String! content: String diff --git a/backend/plugins/frontline_api/src/modules/tickets/@types/board.ts b/backend/plugins/frontline_api/src/modules/tickets/@types/board.ts new file mode 100644 index 0000000000..bd9503a66e --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/tickets/@types/board.ts @@ -0,0 +1,16 @@ +import { Document } from 'mongoose'; +import { IPipeline } from '~/modules/tickets/@types/pipeline'; + +export interface IBoard { + name?: string; + order?: number; + userId?: string; + type: string; +} + +export interface IBoardDocument extends IBoard, Document { + _id: string; + + pipelines?: IPipeline[]; + createdAt: Date; +} diff --git a/backend/plugins/frontline_api/src/modules/tickets/@types/checklist.ts b/backend/plugins/frontline_api/src/modules/tickets/@types/checklist.ts new file mode 100644 index 0000000000..d22e3fa98f --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/tickets/@types/checklist.ts @@ -0,0 +1,26 @@ +import { Document } from 'mongoose'; + +export interface IChecklist { + contentType: string; + contentTypeId: string; + title: string; +} + +export interface IChecklistDocument extends IChecklist, Document { + _id: string; + createdUserId: string; + createdDate: Date; +} + +export interface IChecklistItem { + checklistId: string; + content: string; + isChecked: boolean; +} + +export interface IChecklistItemDocument extends IChecklistItem, Document { + _id: string; + order: number; + createdUserId: string; + createdDate: Date; +} diff --git a/backend/plugins/frontline_api/src/modules/tickets/@types/comment.ts b/backend/plugins/frontline_api/src/modules/tickets/@types/comment.ts new file mode 100644 index 0000000000..330861ecf3 --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/tickets/@types/comment.ts @@ -0,0 +1,17 @@ +import { Document } from 'mongoose'; + +export interface IComment { + typeId: string; + type: string; + + content: string; + parentId?: string; + + userId?: string; + userType?: string; +} + +export interface ICommentDocument extends IComment, Document { + _id: string; + createdAt?: Date; +} diff --git a/backend/plugins/frontline_api/src/modules/tickets/@types/label.ts b/backend/plugins/frontline_api/src/modules/tickets/@types/label.ts new file mode 100644 index 0000000000..4bf625869a --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/tickets/@types/label.ts @@ -0,0 +1,14 @@ +import { Document } from 'mongoose'; + +export interface IPipelineLabel { + name: string; + colorCode: string; + pipelineId: string; + createdBy?: string; +} + +export interface IPipelineLabelDocument extends IPipelineLabel, Document { + _id: string; + + createdAt?: Date; +} diff --git a/backend/plugins/frontline_api/src/modules/tickets/@types/pipeline.ts b/backend/plugins/frontline_api/src/modules/tickets/@types/pipeline.ts new file mode 100644 index 0000000000..4d978fe657 --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/tickets/@types/pipeline.ts @@ -0,0 +1,41 @@ +import { Document } from 'mongoose'; + +export enum PipelineVisibility { + PUBLIC = 'public', + PRIVATE = 'private', +} + +export interface IPipeline { + name?: string; + boardId: string; + status?: string; + visibility?: PipelineVisibility; + memberIds?: string[]; + bgColor?: string; + watchedUserIds?: string[]; + startDate?: Date; + endDate?: Date; + metric?: string; + hackScoringType?: string; + templateId?: string; + isCheckDate?: boolean; + isCheckUser?: boolean; + isCheckDepartment?: boolean; + excludeCheckUserIds?: string[]; + numberConfig?: string; + numberSize?: string; + nameConfig?: string; + lastNum?: string; + departmentIds?: string[]; + branchIds?: string[]; + tagId?: string; + order?: number; + userId?: string; + type: string; +} + +export interface IPipelineDocument extends IPipeline, Document { + _id: string; + + createdAt: Date; +} diff --git a/backend/plugins/frontline_api/src/modules/tickets/@types/stage.ts b/backend/plugins/frontline_api/src/modules/tickets/@types/stage.ts new file mode 100644 index 0000000000..d6f2a27a80 --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/tickets/@types/stage.ts @@ -0,0 +1,26 @@ +import { Document } from 'mongoose'; + +export interface IStage { + name?: string; + probability?: string; + pipelineId: string; + visibility?: string; + memberIds?: string[]; + canMoveMemberIds?: string[]; + canEditMemberIds?: string[]; + departmentIds?: string[]; + formId?: string; + status?: string; + code?: string; + age?: number; + defaultTick?: boolean; + order?: number; + userId?: string; + type: string; +} + +export interface IStageDocument extends IStage, Document { + _id: string; + + createdAt: Date; +} diff --git a/backend/plugins/frontline_api/src/modules/tickets/@types/ticket.ts b/backend/plugins/frontline_api/src/modules/tickets/@types/ticket.ts new file mode 100644 index 0000000000..057a70cea3 --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/tickets/@types/ticket.ts @@ -0,0 +1,98 @@ +import { + ICursorPaginateParams, + ICustomField, + IListParams, +} from 'erxes-api-shared/core-types'; +import { Document } from 'mongoose'; +import { IStage } from '~/modules/tickets/@types/stage'; + +export interface ITicket { + name?: string; + // TODO migrate after remove 2row + companyIds?: string[]; + customerIds?: string[]; + startDate?: Date; + closeDate?: Date; + stageChangedDate?: Date; + description?: string; + assignedUserIds?: string[]; + watchedUserIds?: string[]; + notifiedUserIds?: string[]; + labelIds?: string[]; + attachments?: any[]; + stageId: string; + initialStageId?: string; + modifiedAt?: Date; + modifiedBy?: string; + userId?: string; + order?: number; + searchText?: string; + priority?: string; + sourceConversationIds?: string[]; + status?: string; + timeTrack?: { + status: string; + timeSpent: number; + startDate?: string; + }; + customFieldsData?: ICustomField[]; + score?: number; + number?: string; + data?: any; + tagIds?: string[]; + branchIds?: string[]; + departmentIds?: string[]; + parentId?: string; + type?: string; + + source?: string; +} + +export interface ITicketDocument extends ITicket, Document { + _id: string; + + stage?: IStage[]; + createdAt?: Date; +} + +export interface ITicketQueryParams extends IListParams, ICursorPaginateParams { + pipelineId: string; + pipelineIds: string[]; + stageId: string; + _ids?: string; + skip?: number; + limit?: number; + date?: { + month: number; + year: number; + }; + search?: string; + customerIds?: string[]; + companyIds?: string[]; + assignedUserIds?: string[]; + labelIds?: string[]; + userIds?: string[]; + segment?: string; + segmentData?: string; + stageChangedStartDate?: Date; + stageChangedEndDate?: Date; + noSkipArchive?: boolean; + tagIds?: string[]; + number?: string; +} + +export interface IArchiveArgs extends ICursorPaginateParams { + pipelineId: string; + search: string; + userIds?: string[]; + priorities?: string[]; + assignedUserIds?: string[]; + labelIds?: string[]; + productIds?: string[]; + companyIds?: string[]; + customerIds?: string[]; + startDate?: string; + endDate?: string; + sources?: string[]; + hackStages?: string[]; +} diff --git a/backend/plugins/frontline_api/src/modules/tickets/constants.ts b/backend/plugins/frontline_api/src/modules/tickets/constants.ts new file mode 100644 index 0000000000..46ed07499c --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/tickets/constants.ts @@ -0,0 +1,94 @@ +export const TICKET_STATUSES = { + ACTIVE: 'active', + ARCHIVED: 'archived', + ALL: ['active', 'archived'], +}; + +export const VISIBILITIES = { + PUBLIC: 'public', + PRIVATE: 'private', + ALL: ['public', 'private'], +}; + +export const HACK_SCORING_TYPES = { + RICE: 'rice', + ICE: 'ice', + PIE: 'pie', + ALL: ['rice', 'ice', 'pie'], +}; + +export const PROBABILITY = { + TEN: '10%', + TWENTY: '20%', + THIRTY: '30%', + FORTY: '40%', + FIFTY: '50%', + SIXTY: '60%', + SEVENTY: '70%', + EIGHTY: '80%', + NINETY: '90%', + WON: 'Won', + LOST: 'Lost', + DONE: 'Done', + RESOLVED: 'Resolved', + ALL: [ + '10%', + '20%', + '30%', + '40%', + '50%', + '60%', + '70%', + '80%', + '90%', + 'Won', + 'Lost', + 'Done', + 'Resolved', + ], +}; + +export const TIME_TRACK_TYPES = { + STARTED: 'started', + STOPPED: 'stopped', + PAUSED: 'paused', + COMPLETED: 'completed', + ALL: ['started', 'stopped', 'paused', 'completed'], +}; + +export const COMMENT_USER_TYPES = { + TEAM: 'team', + CLIENT: 'client', + EMAIL: 'email', + ALL: ['team', 'client', 'email'], +}; + +export const CLOSE_DATE_TYPES = { + NEXT_DAY: 'nextDay', + NEXT_WEEK: 'nextWeek', + NEXT_MONTH: 'nextMonth', + NO_CLOSE_DATE: 'noCloseDate', + OVERDUE: 'overdue', + ALL: [ + { + name: 'Next day', + value: 'nextDay', + }, + { + name: 'Next week', + value: 'nextWeek', + }, + { + name: 'Next month', + value: 'nextMonth', + }, + { + name: 'No close date', + value: 'noCloseDate', + }, + { + name: 'Overdue', + value: 'overdue', + }, + ], +}; diff --git a/backend/plugins/frontline_api/src/modules/tickets/db/definitions/boards.ts b/backend/plugins/frontline_api/src/modules/tickets/db/definitions/boards.ts new file mode 100644 index 0000000000..f9bac4b0c9 --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/tickets/db/definitions/boards.ts @@ -0,0 +1,18 @@ +import { mongooseStringRandomId } from 'erxes-api-shared/utils'; +import { Schema } from 'mongoose'; + +export const boardSchema = new Schema( + { + _id: mongooseStringRandomId, + name: { type: String, label: 'Name' }, + order: { type: Number, label: 'Order' }, + type: { + type: String, + label: 'Type', + default: 'ticket', + }, + }, + { + timestamps: true, + }, +); diff --git a/backend/plugins/frontline_api/src/modules/tickets/db/definitions/checklists.ts b/backend/plugins/frontline_api/src/modules/tickets/db/definitions/checklists.ts new file mode 100644 index 0000000000..520b46430d --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/tickets/db/definitions/checklists.ts @@ -0,0 +1,39 @@ +import { mongooseStringRandomId } from 'erxes-api-shared/utils'; +import { Schema } from 'mongoose'; + +export const checklistSchema = new Schema( + { + _id: mongooseStringRandomId, + contentType: { + type: String, + label: 'Content type', + index: true, + default: 'ticket', + }, + order: { type: Number }, + contentTypeId: { + type: String, + label: 'Content type item', + index: true, + }, + title: { type: String, label: 'Title' }, + createdUserId: { type: String, label: 'Created by' }, + }, + { + timestamps: true, + }, +); + +export const checklistItemSchema = new Schema( + { + _id: mongooseStringRandomId, + checklistId: { type: String, label: 'Check list', index: true }, + content: { type: String, label: 'Content' }, + isChecked: { type: Boolean, label: 'Is checked' }, + createdUserId: { type: String, label: 'Created by' }, + order: { type: Number }, + }, + { + timestamps: true, + }, +); diff --git a/backend/plugins/frontline_api/src/modules/tickets/db/definitions/comments.ts b/backend/plugins/frontline_api/src/modules/tickets/db/definitions/comments.ts new file mode 100644 index 0000000000..6db50552ca --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/tickets/db/definitions/comments.ts @@ -0,0 +1,24 @@ +import { mongooseStringRandomId } from 'erxes-api-shared/utils'; +import { Schema } from 'mongoose'; +import { COMMENT_USER_TYPES } from '~/modules/tickets/constants'; + +export const commentSchema = new Schema( + { + _id: mongooseStringRandomId, + typeId: { type: String, label: 'Type Id' }, + type: { type: String, label: 'Type' }, + + content: { type: String, label: 'Content' }, + parentId: { type: String, label: 'Parent Id' }, + + userId: { type: String, label: 'User Id' }, + userType: { + type: String, + enum: COMMENT_USER_TYPES.ALL, + label: 'User Type', + }, + }, + { + timestamps: true, + }, +); diff --git a/backend/plugins/frontline_api/src/modules/tickets/db/definitions/labels.ts b/backend/plugins/frontline_api/src/modules/tickets/db/definitions/labels.ts new file mode 100644 index 0000000000..c7e9bec75e --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/tickets/db/definitions/labels.ts @@ -0,0 +1,20 @@ +import { mongooseStringRandomId } from 'erxes-api-shared/utils'; +import { Schema } from 'mongoose'; + +export const pipelineLabelSchema = new Schema( + { + _id: mongooseStringRandomId, + name: { type: String, label: 'Name' }, + colorCode: { type: String, label: 'Color code' }, + pipelineId: { type: String, label: 'Pipeline' }, + createdBy: { type: String, label: 'Created by' }, + }, + { + timestamps: true, + }, +); + +pipelineLabelSchema.index( + { pipelineId: 1, name: 1, colorCode: 1 }, + { unique: true }, +); diff --git a/backend/plugins/frontline_api/src/modules/tickets/db/definitions/pipelines.ts b/backend/plugins/frontline_api/src/modules/tickets/db/definitions/pipelines.ts new file mode 100644 index 0000000000..88c679eac9 --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/tickets/db/definitions/pipelines.ts @@ -0,0 +1,92 @@ +import { mongooseStringRandomId } from 'erxes-api-shared/utils'; +import { Schema } from 'mongoose'; +import { + HACK_SCORING_TYPES, + TICKET_STATUSES, + VISIBILITIES, +} from '~/modules/tickets/constants'; + +export const pipelineSchema = new Schema( + { + _id: mongooseStringRandomId, + name: { type: String, label: 'Name' }, + boardId: { type: String, label: 'Board' }, + tagId: { + type: String, + optional: true, + label: 'Tags', + }, + status: { + type: String, + enum: TICKET_STATUSES.ALL, + default: TICKET_STATUSES.ACTIVE, + label: 'Status', + }, + visibility: { + type: String, + enum: VISIBILITIES.ALL, + default: VISIBILITIES.PUBLIC, + label: 'Visibility', + }, + watchedUserIds: { type: [String], label: 'Watched users' }, + memberIds: { type: [String], label: 'Members' }, + bgColor: { type: String, label: 'Background color' }, + // Growth hack + startDate: { type: Date, optional: true, label: 'Start date' }, + endDate: { type: Date, optional: true, label: 'End date' }, + metric: { type: String, optional: true, label: 'Metric' }, + hackScoringType: { + type: String, + enum: HACK_SCORING_TYPES.ALL, + label: 'Hacking scoring type', + }, + templateId: { type: String, optional: true, label: 'Template' }, + isCheckDate: { + type: Boolean, + optional: true, + label: 'Select the day after the card created date', + }, + isCheckUser: { + type: Boolean, + optional: true, + label: 'Show only the users created or assigned cards', + }, + isCheckDepartment: { + type: Boolean, + optional: true, + label: 'Show only the departments created or assigned cards', + }, + excludeCheckUserIds: { + type: [String], + optional: true, + label: 'Users eligible to see all cards', + }, + numberConfig: { type: String, optional: true, label: 'Number config' }, + numberSize: { type: String, optional: true, label: 'Number count' }, + nameConfig: { type: String, optional: true, label: 'Name config' }, + lastNum: { + type: String, + optional: true, + label: 'Last generated number', + }, + departmentIds: { + type: [String], + optional: true, + label: 'Related departments', + }, + branchIds: { + type: [String], + optional: true, + label: 'Related branches', + }, + order: { type: Number, label: 'Order' }, + type: { + type: String, + label: 'Type', + default: 'ticket', + }, + }, + { + timestamps: true, + }, +); diff --git a/backend/plugins/frontline_api/src/modules/tickets/db/definitions/stages.ts b/backend/plugins/frontline_api/src/modules/tickets/db/definitions/stages.ts new file mode 100644 index 0000000000..c8aaaed3e0 --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/tickets/db/definitions/stages.ts @@ -0,0 +1,56 @@ +import { mongooseStringRandomId } from 'erxes-api-shared/utils'; +import { Schema } from 'mongoose'; +import { + PROBABILITY, + TICKET_STATUSES, + VISIBILITIES, +} from '~/modules/tickets/constants'; + +export const stageSchema = new Schema( + { + _id: mongooseStringRandomId, + name: { type: String, label: 'Name' }, + probability: { + type: String, + enum: PROBABILITY.ALL, + label: 'Probability', + }, + pipelineId: { type: String, label: 'Pipeline' }, + formId: { type: String, label: 'Form' }, + status: { + type: String, + enum: TICKET_STATUSES.ALL, + default: TICKET_STATUSES.ACTIVE, + }, + visibility: { + type: String, + enum: VISIBILITIES.ALL, + default: VISIBILITIES.PUBLIC, + label: 'Visibility', + }, + code: { + type: String, + label: 'Code', + optional: true, + }, + age: { type: Number, optional: true, label: 'Age' }, + memberIds: { type: [String], label: 'Members' }, + canMoveMemberIds: { type: [String], label: 'Can move members' }, + canEditMemberIds: { type: [String], label: 'Can edit members' }, + departmentIds: { type: [String], label: 'Departments' }, + defaultTick: { + type: Boolean, + label: 'Default tick used', + optional: true, + }, + order: { type: Number, label: 'Order' }, + type: { + type: String, + label: 'Type', + default: 'ticket', + }, + }, + { + timestamps: true, + }, +); diff --git a/backend/plugins/frontline_api/src/modules/tickets/db/definitions/tickets.ts b/backend/plugins/frontline_api/src/modules/tickets/db/definitions/tickets.ts new file mode 100644 index 0000000000..e36a826866 --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/tickets/db/definitions/tickets.ts @@ -0,0 +1,125 @@ +import { + attachmentSchema, + customFieldSchema, +} from 'erxes-api-shared/core-modules'; +import { mongooseStringRandomId } from 'erxes-api-shared/utils'; +import { Schema } from 'mongoose'; +import { TICKET_STATUSES, TIME_TRACK_TYPES } from '~/modules/tickets/constants'; + +const timeTrackSchema = new Schema( + { + startDate: { type: String }, + timeSpent: { type: Number }, + status: { + type: String, + enum: TIME_TRACK_TYPES.ALL, + default: TIME_TRACK_TYPES.STOPPED, + }, + }, + { _id: false }, +); + +const relationSchema = new Schema( + { + id: { type: String }, + start: { type: String }, + end: { type: String }, + }, + { _id: false }, +); + +export const ticketSchema = new Schema( + { + _id: mongooseStringRandomId, + parentId: { type: String, optional: true, label: 'Parent Id' }, + type: { type: String, optional: true, label: 'type' }, + userId: { type: String, optional: true, esType: 'keyword' }, + order: { type: Number, index: true }, + name: { type: String, label: 'Name' }, + startDate: { type: Date, label: 'Start date', esType: 'date' }, + closeDate: { type: Date, label: 'Close date', esType: 'date' }, + stageChangedDate: { + type: Date, + label: 'Stage changed date', + esType: 'date', + }, + reminderMinute: { type: Number, label: 'Reminder minute' }, + isComplete: { + type: Boolean, + default: false, + label: 'Is complete', + esType: 'boolean', + }, + description: { type: String, optional: true, label: 'Description' }, + assignedUserIds: { type: [String], esType: 'keyword' }, + watchedUserIds: { type: [String], esType: 'keyword' }, + labelIds: { type: [String], esType: 'keyword' }, + attachments: { type: [attachmentSchema], label: 'Attachments' }, + stageId: { type: String, index: true }, + initialStageId: { + type: String, + optional: true, + }, + modifiedBy: { type: String, esType: 'keyword' }, + searchText: { type: String, optional: true, index: true }, + priority: { type: String, optional: true, label: 'Priority' }, + // TODO remove after migration + sourceConversationId: { type: String, optional: true }, + sourceConversationIds: { type: [String], optional: true }, + timeTrack: { + type: timeTrackSchema, + }, + status: { + type: String, + enum: TICKET_STATUSES.ALL, + default: TICKET_STATUSES.ACTIVE, + label: 'Status', + index: true, + }, + customFieldsData: { + type: [customFieldSchema], + optional: true, + label: 'Custom fields data', + }, + score: { + type: Number, + optional: true, + label: 'Score', + esType: 'number', + }, + number: { + type: String, + unique: true, + sparse: true, + label: 'Item number', + }, + relations: { + type: [relationSchema], + optional: true, + label: 'Related items used for gantt chart', + }, + tagIds: { + type: [String], + optional: true, + index: true, + label: 'Tags', + }, + branchIds: { + type: [String], + optional: true, + index: true, + label: 'Branches', + }, + departmentIds: { + type: [String], + optional: true, + index: true, + label: 'Departments', + }, + + source: { type: String, label: 'Source' }, + }, + { + timestamps: true, + }, +); diff --git a/backend/plugins/frontline_api/src/modules/tickets/db/models/Boards.ts b/backend/plugins/frontline_api/src/modules/tickets/db/models/Boards.ts new file mode 100644 index 0000000000..0404314be5 --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/tickets/db/models/Boards.ts @@ -0,0 +1,90 @@ +import { Model } from 'mongoose'; +import { IModels } from '~/connectionResolvers'; +import { IBoard, IBoardDocument } from '~/modules/tickets/@types/board'; +import { boardSchema } from '~/modules/tickets/db/definitions/boards'; + +export interface IBoardModel extends Model { + getBoard(_id: string): Promise; + createBoard(doc: IBoard): Promise; + updateBoard(_id: string, doc: IBoard): Promise; + removeBoard(_id: string): object; +} + +export const loadBoardClass = (models: IModels) => { + class Board { + /* + * Get a Board + */ + public static async getBoard(_id: string) { + const board = await models.Boards.findOne({ _id }).lean(); + + if (!board) { + throw new Error('Board not found'); + } + + return board; + } + + /** + * Create a board + */ + public static async createBoard(doc: IBoard) { + return models.Boards.create(doc); + } + + /** + * Update Board + */ + public static async updateBoard(_id: string, doc: IBoard) { + await models.Boards.updateOne({ _id }, { $set: doc }); + + return models.Boards.findOne({ _id }); + } + + /** + * Remove Board + */ + public static async removeBoard(_id: string) { + const board = await models.Boards.findOne({ _id }); + + if (!board) { + throw new Error('Board not found'); + } + + const pipelines = await models.Pipelines.find({ boardId: _id }); + + for (const pipeline of pipelines) { + const { _id: pipelineId } = pipeline || {}; + + if (!pipelineId) continue; + + const stageIds = await models.Stages.find({ pipelineId }).distinct( + '_id', + ); + + if (stageIds.length === 0) continue; + + const ticketIds = await models.Tickets.find({ + stageId: { $in: stageIds }, + }).distinct('_id'); + + if (ticketIds.length > 0) { + await models.CheckLists.removeChecklists(ticketIds); + await models.Tickets.deleteMany({ _id: { $in: ticketIds } }); + } + + await models.Stages.deleteMany({ _id: { $in: stageIds } }); + } + + for (const pipeline of pipelines) { + await models.Pipelines.removePipeline(pipeline._id, true); + } + + return models.Boards.deleteOne({ _id }); + } + } + + boardSchema.loadClass(Board); + + return boardSchema; +}; diff --git a/backend/plugins/frontline_api/src/modules/tickets/db/models/Checklists.ts b/backend/plugins/frontline_api/src/modules/tickets/db/models/Checklists.ts new file mode 100644 index 0000000000..3543eb9ada --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/tickets/db/models/Checklists.ts @@ -0,0 +1,202 @@ +import { IUserDocument } from 'erxes-api-shared/core-types'; +import { Model } from 'mongoose'; +import { IModels } from '~/connectionResolvers'; +import { + IChecklist, + IChecklistDocument, + IChecklistItem, + IChecklistItemDocument, +} from '~/modules/tickets/@types/checklist'; +import { + checklistItemSchema, + checklistSchema, +} from '~/modules/tickets/db/definitions/checklists'; + +export interface IChecklistModel extends Model { + getChecklist(_id: string): Promise; + removeChecklists(contentTypeIds: string[]): void; + createChecklist( + { contentTypeId, ...fields }: IChecklist, + user: IUserDocument, + ): Promise; + + updateChecklist(_id: string, doc: IChecklist): Promise; + + removeChecklist(_id: string): void; +} + +export const loadChecklistClass = (models: IModels) => { + class Checklist { + public static async getChecklist(_id: string) { + const checklist = await models.CheckLists.findOne({ _id }); + + if (!checklist) { + throw new Error('Checklist not found'); + } + + return checklist; + } + + public static async removeChecklists(contentTypeIds: string[]) { + const checklists = await models.CheckLists.find({ + contentTypeId: { $in: contentTypeIds }, + }); + + if (checklists && checklists.length === 0) { + return; + } + + const checklistIds = checklists.map((list) => list._id); + + await models.CheckListItems.deleteMany({ + checklistId: { $in: checklistIds }, + }); + + await models.CheckLists.deleteMany({ _id: { $in: checklistIds } }); + } + + /* + * Create new checklist + */ + public static async createChecklist( + { contentTypeId, ...fields }: IChecklist, + user: IUserDocument, + ) { + return await models.CheckLists.create({ + contentTypeId, + createdUserId: user._id, + createdDate: new Date(), + ...fields, + }); + } + + /* + * Update checklist + */ + public static async updateChecklist(_id: string, doc: IChecklist) { + return await models.CheckLists.findOneAndUpdate( + { _id }, + { $set: doc }, + { new: true }, + ); + } + + /* + * Remove checklist + */ + public static async removeChecklist(_id: string) { + const checklist = await models.CheckLists.getChecklist(_id); + + await models.CheckListItems.deleteMany({ + checklistId: checklist._id, + }); + + return await models.CheckLists.findOneAndDelete({ _id }); + } + } + + checklistSchema.loadClass(Checklist); + + return checklistSchema; +}; + +export interface IChecklistItemModel extends Model { + getChecklistItem(_id: string): Promise; + createChecklistItem( + { checklistId, ...fields }: IChecklistItem, + user: IUserDocument, + ): Promise; + updateChecklistItem( + _id: string, + doc: IChecklistItem, + ): Promise; + removeChecklistItem(_id: string): void; + updateItemOrder( + _id: string, + destinationOrder: number, + ): Promise; +} + +export const loadChecklistItemClass = (models: IModels) => { + class ChecklistItem { + public static async getChecklistItem(_id: string) { + const checklistItem = await models.CheckListItems.findOne({ _id }); + + if (!checklistItem) { + throw new Error('Checklist item not found'); + } + + return checklistItem; + } + + /* + * Create new checklistItem + */ + public static async createChecklistItem( + { checklistId, ...fields }: IChecklistItem, + user: IUserDocument, + ) { + const itemsCount = await models.CheckListItems.find({ + checklistId, + }).countDocuments(); + + return await models.CheckListItems.create({ + checklistId, + createdUserId: user._id, + createdDate: new Date(), + order: itemsCount + 1, + ...fields, + }); + } + + /* + * Update checklistItem + */ + public static async updateChecklistItem(_id: string, doc: IChecklistItem) { + return await models.CheckListItems.findOneAndUpdate( + { _id }, + { $set: doc }, + { new: true }, + ); + } + + /* + * Remove checklist + */ + public static async removeChecklistItem(_id: string) { + const checklistItem = await models.CheckListItems.findOneAndDelete({ + _id, + }); + + if (!checklistItem) { + throw new Error(`Checklist's item not found with id ${_id}`); + } + + return checklistItem; + } + + public static async updateItemOrder(_id: string, destinationOrder: number) { + const currentItem = await models.CheckListItems.findOne({ _id }).lean(); + + if (!currentItem) { + throw new Error(`ChecklistItems _id = ${_id} not found`); + } + + await models.CheckListItems.updateOne( + { checklistId: currentItem.checklistId, order: destinationOrder }, + { $set: { order: currentItem.order } }, + ); + + await models.CheckListItems.updateOne( + { _id }, + { $set: { order: destinationOrder } }, + ); + + return models.CheckListItems.findOne({ _id }).lean(); + } + } + + checklistItemSchema.loadClass(ChecklistItem); + + return checklistItemSchema; +}; diff --git a/backend/plugins/frontline_api/src/modules/tickets/db/models/Comments.ts b/backend/plugins/frontline_api/src/modules/tickets/db/models/Comments.ts new file mode 100644 index 0000000000..ea58459f83 --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/tickets/db/models/Comments.ts @@ -0,0 +1,39 @@ +import { Model } from 'mongoose'; +import { IModels } from '~/connectionResolvers'; +import { IComment, ICommentDocument } from '~/modules/tickets/@types/comment'; +import { commentSchema } from '~/modules/tickets/db/definitions/comments'; + +export interface ICommentModel extends Model { + getComment(typeId: string): Promise; + createComment(comment: IComment): Promise; + deleteComment(_id: string): void; +} + +export const loadCommentClass = (models: IModels) => { + class Comment { + /** + * Retreives comment + */ + public static async getComment(typeId: string) { + const comment = await models.Comments.findOne({ typeId }); + + if (!comment) { + throw new Error('Comment not found'); + } + + return comment; + } + + public static async createComment(comment: IComment) { + return models.Comments.create(comment); + } + + public static async deleteComment(_id: string) { + return models.Comments.deleteOne({ _id }); + } + } + + commentSchema.loadClass(Comment); + + return commentSchema; +}; diff --git a/backend/plugins/frontline_api/src/modules/tickets/db/models/Labels.ts b/backend/plugins/frontline_api/src/modules/tickets/db/models/Labels.ts new file mode 100644 index 0000000000..ac7ab15794 --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/tickets/db/models/Labels.ts @@ -0,0 +1,147 @@ +import { Model } from 'mongoose'; +import { IModels } from '~/connectionResolvers'; +import { + IPipelineLabel, + IPipelineLabelDocument, +} from '~/modules/tickets/@types/label'; +import { pipelineLabelSchema } from '~/modules/tickets/db/definitions/labels'; + +interface IFilter extends IPipelineLabel { + _id?: any; +} + +export interface IPipelineLabelModel extends Model { + getPipelineLabel(_id: string): Promise; + createPipelineLabel(doc: IPipelineLabel): Promise; + updatePipelineLabel( + _id: string, + doc: IPipelineLabel, + ): Promise; + removePipelineLabel(_id: string): void; + labelsLabel(targetId: string, labelIds: string[]): void; + validateUniqueness(filter: IFilter, _id?: string): Promise; + labelObject(params: { labelIds: string[]; targetId: string }): void; +} + +export const loadPipelineLabelClass = (models: IModels) => { + class PipelineLabel { + public static async getPipelineLabel(_id: string) { + const pipelineLabel = await models.PipelineLabels.findOne({ _id }); + + if (!pipelineLabel) { + throw new Error('Label not found'); + } + + return pipelineLabel; + } + + /* + * Validates label uniquness + */ + public static async validateUniqueness( + filter: IFilter, + _id?: string, + ): Promise { + if (_id) { + filter._id = { $ne: _id }; + } + + if (await models.PipelineLabels.findOne(filter)) { + return false; + } + + return true; + } + + /* + * Common helper for objects like deal, ticket and growth hack etc ... + */ + public static async labelObject({ + labelIds, + targetId, + }: { + labelIds: string[]; + targetId: string; + }) { + const prevLabelsCount = await models.PipelineLabels.find({ + _id: { $in: labelIds }, + }).countDocuments(); + + if (prevLabelsCount !== labelIds.length) { + throw new Error('Label not found'); + } + + await models.Tickets.updateOne({ _id: targetId }, { $set: { labelIds } }); + } + + /** + * Create a pipeline label + */ + public static async createPipelineLabel(doc: IPipelineLabel) { + const filter: IFilter = { + name: doc.name, + pipelineId: doc.pipelineId, + colorCode: doc.colorCode, + }; + + const isUnique = await models.PipelineLabels.validateUniqueness(filter); + + if (!isUnique) { + throw new Error('Label duplicated'); + } + + return models.PipelineLabels.create(doc); + } + + /** + * Update pipeline label + */ + public static async updatePipelineLabel(_id: string, doc: IPipelineLabel) { + const isUnique = await models.PipelineLabels.validateUniqueness( + { ...doc }, + _id, + ); + + if (!isUnique) { + throw new Error('Label duplicated'); + } + + await models.PipelineLabels.updateOne({ _id }, { $set: doc }); + + return models.PipelineLabels.findOne({ _id }); + } + + /** + * Remove pipeline label + */ + public static async removePipelineLabel(_id: string) { + const pipelineLabel = await models.PipelineLabels.findOne({ _id }); + + if (!pipelineLabel) { + throw new Error('Label not found'); + } + + // delete labelId from collection that used labelId + await models.Tickets.updateMany( + { labelIds: { $in: [pipelineLabel._id] } }, + { $pull: { labelIds: pipelineLabel._id } }, + ); + + return models.PipelineLabels.deleteOne({ _id }); + } + + /** + * Attach a label + */ + public static async labelsLabel(targetId: string, labelIds: string[]) { + await models.PipelineLabels.labelObject({ + labelIds, + targetId, + }); + } + } + + pipelineLabelSchema.loadClass(PipelineLabel); + + return pipelineLabelSchema; +}; diff --git a/backend/plugins/frontline_api/src/modules/tickets/db/models/Pipelines.ts b/backend/plugins/frontline_api/src/modules/tickets/db/models/Pipelines.ts new file mode 100644 index 0000000000..5886880528 --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/tickets/db/models/Pipelines.ts @@ -0,0 +1,169 @@ +import { IOrderInput } from 'erxes-api-shared/core-types'; +import { updateOrder } from 'erxes-api-shared/utils'; +import { Model } from 'mongoose'; +import { IModels } from '~/connectionResolvers'; +import { + IPipeline, + IPipelineDocument, +} from '~/modules/tickets/@types/pipeline'; +import { IStage, IStageDocument } from '~/modules/tickets/@types/stage'; +import { TICKET_STATUSES } from '~/modules/tickets/constants'; +import { pipelineSchema } from '~/modules/tickets/db/definitions/pipelines'; +import { + createOrUpdatePipelineStages, + generateLastNum, +} from '~/modules/tickets/utils'; + +export interface IPipelineModel extends Model { + getPipeline(_id: string): Promise; + createPipeline(doc: IPipeline, stages?: IStage[]): Promise; + updatePipeline( + _id: string, + doc: IPipeline, + stages?: IStage[], + ): Promise; + updateOrder(orders: IOrderInput[]): Promise; + watchPipeline(_id: string, isAdd: boolean, userId: string): void; + removePipeline(_id: string, checked?: boolean): object; + archivePipeline(_id: string, status?: string): object; +} + +export const loadPipelineClass = (models: IModels) => { + class Pipeline { + /* + * Get a pipeline + */ + public static async getPipeline(_id: string) { + const pipeline = await models.Pipelines.findOne({ _id }).lean(); + + if (!pipeline) { + throw new Error('Pipeline not found'); + } + + return pipeline; + } + + /** + * Create a pipeline + */ + public static async createPipeline( + doc: IPipeline, + stages?: IStageDocument[], + ) { + if (doc.numberSize) { + doc.lastNum = await generateLastNum(models, doc); + } + + const pipeline = await models.Pipelines.create(doc); + + if (stages) { + await createOrUpdatePipelineStages(models, stages, pipeline._id); + } + + return pipeline; + } + + /** + * Update a pipeline + */ + public static async updatePipeline( + _id: string, + doc: IPipeline, + stages?: IStageDocument[], + ) { + if (stages) { + await createOrUpdatePipelineStages(models, stages, _id); + } + + if (doc.numberSize) { + const pipeline = await models.Pipelines.getPipeline(_id); + + if (pipeline.numberConfig !== doc.numberConfig) { + doc.lastNum = await generateLastNum(models, doc); + } + } + + return models.Pipelines.findOneAndUpdate( + { _id }, + { $set: doc }, + { new: true }, + ); + } + + /* + * Update given pipelines orders + */ + public static async updateOrder(orders: IOrderInput[]) { + return updateOrder(models.Pipelines, orders); + } + + /** + * Remove a pipeline + */ + public static async removePipeline(_id: string, checked?: boolean) { + const pipeline = await models.Pipelines.getPipeline(_id); + + if (!checked) { + const stageIds = await models.Stages.find({ + pipelineId: pipeline._id, + }).distinct('_id'); + + const ticketIds = await models.Tickets.find({ + stageId: { $in: stageIds }, + }).distinct('_id'); + + await models.CheckLists.removeChecklists(ticketIds); + } + + const stages = await models.Stages.find({ pipelineId: pipeline._id }); + + for (const stage of stages) { + await models.Stages.removeStage(stage._id); + } + + return models.Pipelines.deleteOne({ _id }); + } + + /** + * Archive a pipeline + */ + public static async archivePipeline(_id: string) { + const pipeline = await models.Pipelines.getPipeline(_id); + + const status = + pipeline.status === TICKET_STATUSES.ACTIVE + ? TICKET_STATUSES.ARCHIVED + : TICKET_STATUSES.ACTIVE; + + await models.Pipelines.updateOne({ _id }, { $set: { status } }); + } + + public static async watchPipeline( + _id: string, + isAdd: boolean, + userId: string, + ) { + const pipeline = await models.Pipelines.getPipeline(_id); + + const watchedUserIds = pipeline.watchedUserIds || []; + + if (isAdd) { + watchedUserIds.push(userId); + } else { + const index = watchedUserIds.indexOf(userId); + + watchedUserIds.splice(index, 1); + } + + return await models.Pipelines.updateOne( + { _id }, + { $set: { watchedUserIds } }, + { new: true }, + ); + } + } + + pipelineSchema.loadClass(Pipeline); + + return pipelineSchema; +}; diff --git a/backend/plugins/frontline_api/src/modules/tickets/db/models/Stages.ts b/backend/plugins/frontline_api/src/modules/tickets/db/models/Stages.ts new file mode 100644 index 0000000000..d83b9dc7c8 --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/tickets/db/models/Stages.ts @@ -0,0 +1,105 @@ +import { IOrderInput } from 'erxes-api-shared/core-types'; +import { sendTRPCMessage, updateOrder } from 'erxes-api-shared/utils'; +import { DeleteResult, Model } from 'mongoose'; +import { IModels } from '~/connectionResolvers'; +import { IStage, IStageDocument } from '~/modules/tickets/@types/stage'; +import { stageSchema } from '~/modules/tickets/db/definitions/stages'; + +export interface IStageModel extends Model { + getStage(_id: string): Promise; + createStage(doc: IStage): Promise; + removeStage(_id: string): Promise<{ deletedCount: number; acknowledged: boolean }>; + updateStage(_id: string, doc: IStage): Promise; + updateOrder(orders: IOrderInput[]): Promise; + checkCodeDuplication(code: string): Promise; +} + +export const loadStageClass = (models: IModels) => { + class Stage { + /* + * Get a stage + */ + public static async getStage(_id: string) { + const stage = await models.Stages.findOne({ _id }); + + if (!stage) { + throw new Error('Stage not found'); + } + + return stage; + } + + static async checkCodeDuplication(code: string) { + const stage = await models.Stages.findOne({ + code, + }); + + if (stage) { + throw new Error('Code must be unique'); + } + } + + /** + * Create a stage + */ + public static async createStage(doc: IStage) { + if (doc.code) { + await this.checkCodeDuplication(doc.code); + } + return models.Stages.create(doc); + } + + /** + * Update Stage + */ + public static async updateStage(_id: string, doc: IStage) { + if (doc.code) { + await this.checkCodeDuplication(doc.code); + } + + await models.Stages.updateOne({ _id }, { $set: doc }); + + return models.Stages.findOne({ _id }); + } + + /* + * Update given stages orders + */ + public static async updateOrder(orders: IOrderInput[]) { + return updateOrder(models.Stages, orders); + } + + /** + * Remove Stage + */ + public static async removeStage(_id: string) { + const stage = await models.Stages.getStage(_id); + + const ticketIds = await models.Tickets.find({ + stageId: { $eq: _id }, + }).distinct('_id'); + + await models.CheckLists.removeChecklists(ticketIds); + + await models.Tickets.deleteMany({ stageId: { $in: ticketIds } }); + + if (stage.formId) { + await sendTRPCMessage({ + pluginName: 'core', + method: 'mutation', + module: 'forms', + action: 'removeForm', + input: { + formId: stage.formId, + }, + }); + } + + return await models.Stages.deleteOne({ _id }); + } + } + + stageSchema.loadClass(Stage); + + return stageSchema; +}; diff --git a/backend/plugins/frontline_api/src/modules/tickets/db/models/Tickets.ts b/backend/plugins/frontline_api/src/modules/tickets/db/models/Tickets.ts new file mode 100644 index 0000000000..5302a80a3e --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/tickets/db/models/Tickets.ts @@ -0,0 +1,196 @@ +import { DeleteResult, Model } from 'mongoose'; +import { IModels } from '~/connectionResolvers'; +import { ITicket, ITicketDocument } from '~/modules/tickets/@types/ticket'; +import { ticketSchema } from '~/modules/tickets/db/definitions/tickets'; +import { + boardNumberGenerator, + fillSearchTextItem, +} from '~/modules/tickets/utils'; + +export interface ITicketModel extends Model { + createTicket(doc: ITicket): Promise; + getTicket(_id: string): Promise; + updateTicket(_id: string, doc: ITicket): Promise; + watchTicket(_id: string, isAdd: boolean, userId: string): void; + removeTickets(_ids: string[]): Promise; + createTicketComment( + type: string, + typeId: string, + content: string, + userType: string, + customerId: string, + ): Promise; + updateTimeTracking( + _id: string, + status: string, + timeSpent: number, + startDate: string, + ): Promise; +} + +export const loadTicketClass = (models: IModels) => { + class Ticket { + /** + * Retreives Ticket + */ + public static async getTicket(_id: string) { + const ticket = await models.Tickets.findOne({ _id }); + + if (!ticket) { + throw new Error('Ticket not found'); + } + + return ticket; + } + + /** + * Create a Ticket + */ + public static async createTicket(doc: ITicket) { + if (doc.sourceConversationIds) { + const convertedTicket = await models.Tickets.findOne({ + sourceConversationIds: { $in: doc.sourceConversationIds }, + }); + + if (convertedTicket) { + throw new Error('Already converted a ticket'); + } + } + + const stage = await models.Stages.getStage(doc.stageId); + const pipeline = await models.Pipelines.getPipeline(stage.pipelineId); + + if (pipeline.numberSize) { + const { numberSize, numberConfig = '' } = pipeline; + + const number = await boardNumberGenerator( + models, + numberConfig, + numberSize, + false, + pipeline.type, + ); + + doc.number = number; + } + + const ticket = await models.Tickets.create({ + ...doc, + createdAt: new Date(), + modifiedAt: new Date(), + stageChangedDate: new Date(), + searchText: fillSearchTextItem(doc), + }); + + // update numberConfig of the same configed pipelines + if (doc.number) { + await models.Pipelines.updateMany( + { + numberConfig: pipeline.numberConfig, + type: pipeline.type, + }, + { $set: { lastNum: doc.number } }, + ); + } + + return ticket; + } + + public static async createTicketComment( + type: string, + typeId: string, + content: string, + userType: string, + customerId?: string, + ) { + if (!typeId || !content) { + throw new Error('typeId or content not found'); + } + + return await models.Comments.createComment({ + type, + typeId, + content, + userType, + userId: customerId, + }); + } + + /** + * Update Ticket + */ + public static async updateTicket(_id: string, doc: ITicket) { + const searchText = fillSearchTextItem( + doc, + await models.Tickets.getTicket(_id), + ); + + await models.Tickets.updateOne({ _id }, { $set: doc, searchText }); + + return models.Tickets.findOne({ _id }); + } + + /** + * Watch ticket + */ + public static async watchTicket( + _id: string, + isAdd: boolean, + userId: string, + ) { + const ticket = await models.Tickets.getTicket(_id); + + const watchedUserIds = ticket.watchedUserIds || []; + + if (isAdd) { + watchedUserIds.push(userId); + } else { + const index = watchedUserIds.indexOf(userId); + + watchedUserIds.splice(index, 1); + } + + return await models.Tickets.findOneAndUpdate( + { _id }, + { $set: { watchedUserIds } }, + { new: true }, + ); + } + + public static async removeTickets(_ids: string[]) { + // completely remove all related things + await models.CheckLists.removeChecklists(_ids); + + return models.Tickets.deleteMany({ _id: { $in: _ids } }); + } + + /** + * Update Time Tracking + */ + public static async updateTimeTracking( + _id: string, + status: string, + timeSpent: number, + startDate?: string, + ) { + const doc: { status: string; timeSpent: number; startDate?: string } = { + status, + timeSpent, + }; + + if (startDate) { + doc.startDate = startDate; + } + + return await models.Tickets.findOneAndUpdate( + { _id }, + { $set: { timeTrack: doc } }, + { new: true }, + ); + } + } + + ticketSchema.loadClass(Ticket); + + return ticketSchema; +}; diff --git a/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/customResolvers/board.ts b/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/customResolvers/board.ts new file mode 100644 index 0000000000..2d5eefff85 --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/customResolvers/board.ts @@ -0,0 +1,90 @@ +import { sendTRPCMessage } from 'erxes-api-shared/utils'; +import { IContext } from '~/connectionResolvers'; +import { IBoardDocument } from '~/modules/tickets/@types/board'; + +export default { + async pipelines( + board: IBoardDocument, + _args: undefined, + { user, models }: IContext, + ) { + if (board.pipelines) { + return board.pipelines; + } + + if (user.isOwner) { + return models.Pipelines.find({ + boardId: board._id, + status: { $ne: 'archived' }, + }).lean(); + } + + const userDetail = await sendTRPCMessage({ + pluginName: 'core', + method: 'query', + module: 'users', + action: 'find', + input: { + _id: user._id, + }, + defaultValue: [], + }); + + const userDepartmentIds = userDetail?.departmentIds || []; + const branchIds = userDetail?.branchIds || []; + + const supervisorDepartmentIds = await sendTRPCMessage({ + pluginName: 'core', + method: 'query', + module: 'departments', + action: 'findWithChild', + input: { + query: { + supervisorId: user._id, + }, + fields: { + _id: 1, + }, + }, + defaultValue: [], + }); + + const departmentIds = [ + ...userDepartmentIds, + ...(supervisorDepartmentIds.map((x) => x._id) || []), + ]; + + const query: any = { + $and: [ + { status: { $ne: 'archived' } }, + { boardId: board._id }, + { + $or: [ + { visibility: 'public' }, + { + visibility: 'private', + $or: [{ memberIds: { $in: [user._id] } }, { userId: user._id }], + }, + ], + }, + ], + }; + + if (departmentIds.length > 0) { + query.$and[2].$or.push({ + $and: [ + { visibility: 'private' }, + { departmentIds: { $in: departmentIds } }, + ], + }); + } + + if (branchIds.length > 0) { + query.$and[2].$or.push({ + $and: [{ visibility: 'private' }, { branchIds: { $in: branchIds } }], + }); + } + + return models.Pipelines.find(query).lean(); + }, +}; diff --git a/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/customResolvers/checklist.ts b/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/customResolvers/checklist.ts new file mode 100644 index 0000000000..5e47fa37d8 --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/customResolvers/checklist.ts @@ -0,0 +1,34 @@ +import { IContext } from '~/connectionResolvers'; +import { IChecklistDocument } from '~/modules/tickets/@types/checklist'; + +export default { + async items( + checklist: IChecklistDocument, + _args: undefined, + { models }: IContext, + ) { + return models.CheckListItems.find({ checklistId: checklist._id }).sort({ + order: 1, + }); + }, + + async percent( + checklist: IChecklistDocument, + _args: undefined, + { models }: IContext, + ) { + const items = await models.CheckListItems.find({ + checklistId: checklist._id, + }); + + if (items.length === 0) { + return 0; + } + + const checkedItems = items.filter((item) => { + return item.isChecked; + }); + + return (checkedItems.length / items.length) * 100; + }, +}; diff --git a/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/customResolvers/index.ts b/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/customResolvers/index.ts new file mode 100644 index 0000000000..c625334f72 --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/customResolvers/index.ts @@ -0,0 +1,13 @@ +import TicketsBoard from './board'; +import TicketsChecklist from './checklist'; +import TicketsPipeline from './pipeline'; +import TicketsStage from './stage'; +import Ticket from './ticket'; + +export default { + Ticket, + TicketsBoard, + TicketsChecklist, + TicketsPipeline, + TicketsStage, +}; diff --git a/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/customResolvers/pipeline.ts b/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/customResolvers/pipeline.ts new file mode 100644 index 0000000000..afceec914e --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/customResolvers/pipeline.ts @@ -0,0 +1,77 @@ +import { IContext } from '~/connectionResolvers'; +import { IPipelineDocument } from '~/modules/tickets/@types/pipeline'; +import { VISIBILITIES } from '~/modules/tickets/constants'; +import { generateFilter } from '~/modules/tickets/graphql/resolvers/queries/ticket'; + +export default { + createdUser(pipeline: IPipelineDocument) { + if (!pipeline.userId) { + return; + } + + return { __typename: 'User', _id: pipeline.userId }; + }, + + members(pipeline: IPipelineDocument) { + if (pipeline.visibility === VISIBILITIES.PRIVATE && pipeline.memberIds) { + return pipeline.memberIds.map((memberId) => ({ + __typename: 'User', + _id: memberId, + })); + } + + return []; + }, + + isWatched(pipeline: IPipelineDocument, _args: undefined, { user }: IContext) { + const watchedUserIds = pipeline.watchedUserIds || []; + + if (watchedUserIds.includes(user._id)) { + return true; + } + + return false; + }, + + state(pipeline: IPipelineDocument) { + if (pipeline.startDate && pipeline.endDate) { + const now = new Date().getTime(); + + const startDate = new Date(pipeline.startDate).getTime(); + const endDate = new Date(pipeline.endDate).getTime(); + + if (now > endDate) { + return 'Completed'; + } else if (now < endDate && now > startDate) { + return 'In progress'; + } else { + return 'Not started'; + } + } + + return ''; + }, + + async itemsTotalCount( + pipeline: IPipelineDocument, + _args: undefined, + { user, models, subdomain }: IContext, + ) { + const filter = await generateFilter(models, subdomain, user._id, { + pipelineId: pipeline._id, + }); + + return models.Tickets.find(filter).countDocuments(); + }, + + async tag(pipeline: IPipelineDocument) { + if (!pipeline.tagId) { + return; + } + + return { + __typename: 'Tag', + _id: pipeline.tagId, + }; + }, +}; diff --git a/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/customResolvers/stage.ts b/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/customResolvers/stage.ts new file mode 100644 index 0000000000..907189c3f0 --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/customResolvers/stage.ts @@ -0,0 +1,231 @@ +import { IContext } from '~/connectionResolvers'; +import { IStageDocument } from '~/modules/tickets/@types/stage'; +import { TICKET_STATUSES, VISIBILITIES } from '~/modules/tickets/constants'; +import { generateFilter } from '~/modules/tickets/graphql/resolvers/queries/ticket'; +import { getAmountsMap } from '~/modules/tickets/utils'; + +export default { + async __resolveReference({ _id }, { models }: IContext) { + return models.Stages.findOne({ _id }); + }, + + members(stage: IStageDocument) { + if (stage.visibility === VISIBILITIES.PRIVATE && stage.memberIds) { + return stage.memberIds.map((memberId) => ({ + __typename: 'User', + _id: memberId, + })); + } + + return []; + }, + + async unUsedAmount( + stage: IStageDocument, + _args: undefined, + { user, models }: IContext, + { variableValues: args }, + ) { + return getAmountsMap(models, user, args, stage, false); + }, + + async amount( + stage: IStageDocument, + _args: undefined, + { user, models }: IContext, + { variableValues: args }, + ) { + return getAmountsMap(models, user, args, stage); + }, + + async itemsTotalCount( + stage: IStageDocument, + _args: undefined, + { user, models }: IContext, + { variableValues: args }, + ) { + const filter = await generateFilter( + models, + user._id, + { ...args, stageId: stage._id, pipelineId: stage.pipelineId }, + args.extraParams, + ); + + return models.Tickets.find(filter).countDocuments(); + }, + + /* + * Total count of ticket that are created on this stage initially + */ + async initialTicketTotalCount( + stage: IStageDocument, + _args: undefined, + { user, models }: IContext, + { variableValues: args }, + ) { + const filter = await generateFilter( + models, + user._id, + { ...args, initialStageId: stage._id }, + args.extraParams, + ); + + return models.Tickets.find(filter).countDocuments(); + }, + + /* + * Total count of tickets that are + * 1. created on this stage initially + * 2. moved to other stage which has probability other than Lost + */ + async inProcessTicketsTotalCount( + stage: IStageDocument, + _args: undefined, + { models }: IContext, + ) { + const filter = { + pipelineId: stage.pipelineId, + probability: { $ne: 'Lost' }, + _id: { $ne: stage._id }, + }; + + const tickets = await models.Stages.aggregate([ + { + $match: filter, + }, + { + $lookup: { + from: 'tickets', + let: { stageId: '$_id' }, + pipeline: [ + { + $match: { + $expr: { + $and: [ + { $eq: ['$stageId', '$$stageId'] }, + { $ne: ['$status', TICKET_STATUSES.ARCHIVED] }, + ], + }, + }, + }, + ], + as: 'tickets', + }, + }, + { + $project: { + name: 1, + tickets: 1, + }, + }, + { + $unwind: '$tickets', + }, + { + $match: { + 'tickets.initialStageId': stage._id, + }, + }, + ]); + + return tickets.length; + }, + + async stayedTicketsTotalCount( + stage: IStageDocument, + _args: undefined, + { user, models }: IContext, + { variableValues: args }, + ) { + const filter = await generateFilter( + models, + user._id, + { + ...args, + initialStageId: stage._id, + stageId: stage._id, + pipelineId: stage.pipelineId, + }, + args.extraParams, + ); + + return models.Tickets.find(filter).countDocuments(); + }, + + async compareNextStageTicket( + stage: IStageDocument, + _args: undefined, + { models }: IContext, + ) { + const result: { count?: number; percent?: number } = {}; + + const { order = 1 } = stage; + + const filter = { + order: { $in: [order, order + 1] }, + probability: { $ne: 'Lost' }, + pipelineId: stage.pipelineId, + }; + + const stages = await models.Stages.aggregate([ + { + $match: filter, + }, + { + $lookup: { + from: 'tickets', + let: { stageId: '$_id' }, + pipeline: [ + { + $match: { + $expr: { + $and: [ + { $eq: ['$stageId', '$$stageId'] }, + { $ne: ['$status', TICKET_STATUSES.ARCHIVED] }, + ], + }, + }, + }, + ], + as: 'currentTickets', + }, + }, + { + $lookup: { + from: 'tickets', + let: { stageId: '$_id' }, + pipeline: [ + { + $match: { + $expr: { + $and: [ + { $eq: ['$initialStageId', '$$stageId'] }, + { $ne: ['$status', TICKET_STATUSES.ARCHIVED] }, + ], + }, + }, + }, + ], + as: 'initialTickets', + }, + }, + { + $project: { + order: 1, + currentTicketCount: { $size: '$currentTickets' }, + initialTicketCount: { $size: '$initialTickets' }, + }, + }, + { $sort: { order: 1 } }, + ]); + + if (stages.length === 2) { + const [first, second] = stages; + result.count = first.currentTicketCount - second.currentTicketCount; + result.percent = + (second.initialTicketCount * 100) / first.initialTicketCount; + } + + return result; + }, +}; diff --git a/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/customResolvers/ticket.ts b/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/customResolvers/ticket.ts new file mode 100644 index 0000000000..3c8fd88b86 --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/customResolvers/ticket.ts @@ -0,0 +1,182 @@ +import { sendTRPCMessage } from 'erxes-api-shared/utils'; +import { IContext } from '~/connectionResolvers'; +import { ITicketDocument } from '~/modules/tickets/@types/ticket'; + +export default { + async __resolveReference({ _id }, { models }: IContext) { + return models.Tickets.findOne({ _id }); + }, + + async companies( + ticket: ITicketDocument, + _args: undefined, + _context: IContext, + { isSubscription }, + ) { + // const companyIds = await sendCoreMessage({ + // subdomain, + // action: 'conformities.savedConformity', + // data: { + // mainType: 'ticket', + // mainTypeId: ticket._id, + // relTypes: ['company'], + // }, + // isRPC: true, + // defaultValue: [], + // }); + + // const companies = await sendTRPCMessage({ + // pluginName: 'core', + // module: 'companies', + // action: 'findActiveCompanies', + // input: { + // selector: { + // _id: { $in: ticket.companyIds }, + // }, + // }, + // defaultValue: [], + // }); + + // if (isSubscription) { + // return companies; + // } + + // return (companies || []).map(({ _id }) => ({ __typename: 'Company', _id })); + + return []; + }, + + async customers( + ticket: ITicketDocument, + _args: undefined, + _context: IContext, + { isSubscription }, + ) { + // const customerIds = await sendCoreMessage({ + // subdomain, + // action: 'conformities.savedConformity', + // data: { + // mainType: 'ticket', + // mainTypeId: ticket._id, + // relTypes: ['customer'], + // }, + // isRPC: true, + // defaultValue: [], + // }); + + // const customers = await sendTRPCMessage({ + // pluginName: 'core', + // module: 'customers', + // action: 'findActiveCustomers', + // input: { + // selector: { + // _id: { $in: ticket.customerIds }, + // }, + // }, + // defaultValue: [], + // }); + + // if (isSubscription) { + // return customers; + // } + + // return (customers || []).map(({ _id }) => ({ + // __typename: 'Customer', + // _id, + // })); + + return []; + }, + + async assignedUsers( + ticket: ITicketDocument, + _args: undefined, + _context: IContext, + { isSubscription }, + ) { + if (isSubscription && ticket.assignedUserIds?.length) { + return sendTRPCMessage({ + pluginName: 'core', + module: 'users', + action: 'find', + input: { + query: { + _id: { $in: ticket.assignedUserIds }, + }, + }, + defaultValue: [], + }); + } + + return (ticket.assignedUserIds || []) + .filter((e) => e) + .map((_id) => ({ + __typename: 'User', + _id, + })); + }, + + async pipeline( + ticket: ITicketDocument, + _args: undefined, + { models }: IContext, + ) { + const stage = await models.Stages.getStage(ticket.stageId); + + return models.Pipelines.findOne({ _id: stage.pipelineId }); + }, + + async boardId( + ticket: ITicketDocument, + _args: undefined, + { models }: IContext, + ) { + const stage = await models.Stages.getStage(ticket.stageId); + const pipeline = await models.Pipelines.getPipeline(stage.pipelineId); + const board = await models.Boards.getBoard(pipeline.boardId); + + return board._id; + }, + + async stage(ticket: ITicketDocument, _args: undefined, { models }: IContext) { + return models.Stages.getStage(ticket.stageId); + }, + + async isWatched( + ticket: ITicketDocument, + _args: undefined, + { user }: IContext, + ) { + const watchedUserIds = ticket.watchedUserIds || []; + + if (watchedUserIds.includes(user._id)) { + return true; + } + + return false; + }, + + async labels( + ticket: ITicketDocument, + _args: undefined, + { models }: IContext, + ) { + return models.PipelineLabels.find({ _id: { $in: ticket.labelIds || [] } }); + }, + + async tags(ticket: ITicketDocument) { + if (!ticket.tagIds || ticket.tagIds.length === 0) { + return []; + } + + return ticket.tagIds.map((_id) => ({ __typename: 'Tag', _id })); + }, + + createdUser(ticket: ITicketDocument) { + if (!ticket.userId) { + return; + } + + return { __typename: 'User', _id: ticket.userId }; + }, +}; diff --git a/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/mutations/board.ts b/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/mutations/board.ts new file mode 100644 index 0000000000..39ed93d0ee --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/mutations/board.ts @@ -0,0 +1,110 @@ +import { sendTRPCMessage } from 'erxes-api-shared/utils'; +import { IContext } from '~/connectionResolvers'; +import { IBoard } from '~/modules/tickets/@types/board'; + +export const boardMutations = { + /** + * Create new board + */ + async ticketsBoardsAdd(_root: undefined, doc: IBoard, { models }: IContext) { + return await models.Boards.createBoard(doc); + }, + + /** + * Edit board + */ + async ticketsBoardsEdit( + _root: undefined, + { _id, ...doc }: IBoard & { _id: string }, + { models }: IContext, + ) { + return await models.Boards.updateBoard(_id, doc); + }, + + /** + * Remove board + */ + async ticketsBoardsRemove( + _root: undefined, + { _id }: { _id: string }, + { models }: IContext, + ) { + const board = await models.Boards.getBoard(_id); + + const relatedFieldsGroups = await sendTRPCMessage({ + pluginName: 'core', + method: 'query', + module: 'fieldsGroups', + action: 'find', + input: { + query: { + boardIds: board._id, + }, + }, + defaultValue: [], + }); + + for (const fieldGroup of relatedFieldsGroups) { + const boardIds = fieldGroup.boardIds || []; + fieldGroup.boardIds = boardIds.filter((e) => e !== board._id); + + await sendTRPCMessage({ + pluginName: 'core', + method: 'mutation', + module: 'fieldsGroups', + action: 'updateGroup', + input: { groupId: fieldGroup._id, fieldGroup }, + defaultValue: [], + }); + } + + return await models.Boards.removeBoard(_id); + }, + + async ticketsBoardItemUpdateTimeTracking( + _root: undefined, + { + _id, + status, + timeSpent, + startDate, + }: { + _id: string; + status: string; + timeSpent: number; + startDate: string; + }, + { models }: IContext, + ) { + return models.Boards.updateTimeTracking(_id, status, timeSpent, startDate); + }, + + async ticketsBoardItemsSaveForGanttTimeline( + _root: undefined, + { items, links }: { items: any[]; links: any[] }, + { models }: IContext, + ) { + const bulkOps: any[] = []; + + for (const item of items) { + bulkOps.push({ + updateOne: { + filter: { + _id: item._id, + }, + update: { + $set: { + startDate: item.startDate, + closeDate: item.closeDate, + relations: links.filter((link) => link.start === item._id), + }, + }, + }, + }); + } + + await models.Tickets.bulkWrite(bulkOps); + + return 'Success'; + }, +}; diff --git a/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/mutations/checklist.ts b/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/mutations/checklist.ts new file mode 100644 index 0000000000..6ff1ee8225 --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/mutations/checklist.ts @@ -0,0 +1,129 @@ +import { graphqlPubsub } from 'erxes-api-shared/utils'; +import { IContext } from '~/connectionResolvers'; +import { IChecklist, IChecklistItem } from '~/modules/tickets/@types/checklist'; + +const checklistsChanged = (checklist: IChecklist & { _id: string }) => { + graphqlPubsub.publish( + `ticketsChecklistsChanged:${checklist.contentType}:${checklist.contentTypeId}`, + { + ticketsChecklistsChanged: { + _id: checklist._id, + contentType: checklist.contentType, + contentTypeId: checklist.contentTypeId, + }, + }, + ); +}; + +const ticketsChecklistDetailChanged = (_id: string) => { + graphqlPubsub.publish(`ticketsChecklistDetailChanged:${_id}`, { + ticketsChecklistDetailChanged: { + _id, + }, + }); +}; + +export const checklistMutations = { + /** + * Adds checklist object and also adds an activity log + */ + async ticketsChecklistsAdd( + _root: undefined, + args: IChecklist, + { models, user }: IContext, + ) { + const checklist = await models.CheckLists.createChecklist(args, user); + + checklistsChanged(checklist); + + return checklist; + }, + + /** + * Updates checklist object + */ + async ticketsChecklistsEdit( + _root: undefined, + { _id, ...doc }: IChecklist & { _id: string }, + { models }: IContext, + ) { + const updated = await models.CheckLists.updateChecklist(_id, doc); + + ticketsChecklistDetailChanged(_id); + + return updated; + }, + + /** + * Removes a checklist + */ + async ticketsChecklistsRemove( + _root: undefined, + { _id }: { _id: string }, + { models }: IContext, + ) { + const checklist = await models.CheckLists.getChecklist(_id); + const removed = await models.CheckLists.removeChecklist(_id); + + checklistsChanged(checklist); + + return removed; + }, + + /** + * Adds a checklist item and also adds an activity log + */ + async ticketsChecklistItemsAdd( + _root: undefined, + args: IChecklistItem, + { user, models }: IContext, + ) { + const checklistItem = await models.CheckListItems.createChecklistItem( + args, + user, + ); + + ticketsChecklistDetailChanged(checklistItem.checklistId); + + return checklistItem; + }, + + /** + * Updates a checklist item + */ + async ticketsChecklistItemsEdit( + _root: undefined, + { _id, ...doc }: IChecklistItem & { _id: string }, + { models }: IContext, + ) { + const updated = await models.CheckListItems.updateChecklistItem(_id, doc); + + ticketsChecklistDetailChanged(updated.checklistId); + + return updated; + }, + + /** + * Removes a checklist item + */ + async ticketsChecklistItemsRemove( + _root: undefined, + { _id }: { _id: string }, + { models }: IContext, + ) { + const checklistItem = await models.CheckListItems.getChecklistItem(_id); + const removed = await models.CheckListItems.removeChecklistItem(_id); + + ticketsChecklistDetailChanged(checklistItem.checklistId); + + return removed; + }, + + async ticketsChecklistItemsOrder( + _root: undefined, + { _id, destinationIndex }: { _id: string; destinationIndex: number }, + { models }: IContext, + ) { + return models.CheckListItems.updateItemOrder(_id, destinationIndex); + }, +}; diff --git a/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/mutations/index.ts b/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/mutations/index.ts new file mode 100644 index 0000000000..11bb5d9173 --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/mutations/index.ts @@ -0,0 +1,13 @@ +import { boardMutations } from './board'; +import { checklistMutations } from './checklist'; +import { pipelineLabelMutations } from './label'; +import { stageMutations } from './stage'; +import { ticketMutations } from './ticket'; + +export { + boardMutations, + checklistMutations, + pipelineLabelMutations, + stageMutations, + ticketMutations, +}; diff --git a/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/mutations/label.ts b/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/mutations/label.ts new file mode 100644 index 0000000000..4251d52f3a --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/mutations/label.ts @@ -0,0 +1,55 @@ +import { IContext } from '~/connectionResolvers'; +import { IPipelineLabel } from '~/modules/tickets/@types/label'; + +export const pipelineLabelMutations = { + /** + * Creates a new pipeline label + */ + async ticketsPipelineLabelsAdd( + _root: undefined, + { ...doc }: IPipelineLabel, + { user, models }: IContext, + ) { + return await models.PipelineLabels.createPipelineLabel({ + createdBy: user._id, + ...doc, + }); + }, + + /** + * Edit pipeline label + */ + async ticketsPipelineLabelsEdit( + _root: undefined, + { _id, ...doc }: IPipelineLabel & { _id: string }, + { models }: IContext, + ) { + return await models.PipelineLabels.updatePipelineLabel(_id, doc); + }, + + /** + * Remove pipeline label + */ + async ticketsPipelineLabelsRemove( + _root: undefined, + { _id }: { _id: string }, + { models }: IContext, + ) { + return await models.PipelineLabels.removePipelineLabel(_id); + }, + + /** + * Attach a label + */ + async ticketsPipelineLabelsLabel( + _root: undefined, + { + pipelineId, + targetId, + labelIds, + }: { pipelineId: string; targetId: string; labelIds: string[] }, + { models }: IContext, + ) { + return models.PipelineLabels.labelsLabel(pipelineId, targetId, labelIds); + }, +}; diff --git a/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/mutations/pipeline.ts b/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/mutations/pipeline.ts new file mode 100644 index 0000000000..ce4932cb5c --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/mutations/pipeline.ts @@ -0,0 +1,155 @@ +import { IOrderInput } from 'erxes-api-shared/core-types'; +import { sendTRPCMessage } from 'erxes-api-shared/utils'; +import { IContext } from '~/connectionResolvers'; +import { + IPipeline, + IPipelineDocument, +} from '~/modules/tickets/@types/pipeline'; +import { IStageDocument } from '~/modules/tickets/@types/stage'; +import { checkNumberConfig } from '~/modules/tickets/utils'; + +export const pipelineMutations = { + /** + * Create new pipeline + */ + async ticketsPipelinesAdd( + _root: undefined, + { stages, ...doc }: IPipeline & { stages: IStageDocument[] }, + { user, models }: IContext, + ) { + if (doc.numberConfig || doc.numberSize) { + await checkNumberConfig(doc.numberConfig || '', doc.numberSize || ''); + } + + return await models.Pipelines.createPipeline( + { userId: user._id, ...doc }, + stages, + ); + }, + + /** + * Edit pipeline + */ + async ticketsPipelinesEdit( + _root: undefined, + { _id, stages, ...doc }: IPipelineDocument & { stages: IStageDocument[] }, + { models }: IContext, + ) { + if (doc.numberConfig || doc.numberSize) { + await checkNumberConfig(doc.numberConfig || '', doc.numberSize || ''); + } + + return await models.Pipelines.updatePipeline(_id, doc, stages); + }, + + /** + * Update pipeline orders + */ + async ticketsPipelinesUpdateOrder( + _root: undefined, + { orders }: { orders: IOrderInput[] }, + { models }: IContext, + ) { + return models.Pipelines.updateOrder(orders); + }, + + /** + * Watch pipeline + */ + async ticketsPipelinesWatch( + _root: undefined, + { _id, isAdd }: { _id: string; isAdd: boolean }, + { user, models }: IContext, + ) { + return models.Pipelines.watchPipeline(_id, isAdd, user._id); + }, + + /** + * Remove pipeline + */ + async ticketsPipelinesRemove( + _root: undefined, + { _id }: { _id: string }, + { models }: IContext, + ) { + const pipeline = await models.Pipelines.getPipeline(_id); + + const removed = await models.Pipelines.removePipeline(_id); + + const relatedFieldsGroups = await sendTRPCMessage({ + pluginName: 'core', + method: 'query', + module: 'fieldsGroups', + action: 'find', + input: { + query: { + pipelineIds: pipeline._id, + }, + }, + defaultValue: [], + }); + + for (const fieldGroup of relatedFieldsGroups) { + const pipelineIds = fieldGroup.pipelineIds || []; + fieldGroup.pipelineIds = pipelineIds.filter((e) => e !== pipeline._id); + + await sendTRPCMessage({ + pluginName: 'core', + method: 'mutation', + module: 'fieldsGroups', + action: 'updateGroup', + input: { + groupId: fieldGroup._id, + fieldGroup, + }, + }); + } + + return removed; + }, + + /** + * Archive pipeline + */ + async ticketsPipelinesArchive( + _root: undefined, + { _id, status }: { _id: string; status: string }, + { models }: IContext, + ) { + return await models.Pipelines.archivePipeline(_id, status); + }, + + /** + * Duplicate pipeline + */ + async ticketsPipelinesCopied( + _root: undefined, + { _id }: { _id: string }, + { models }: IContext, + ) { + const sourcePipeline = await models.Pipelines.getPipeline(_id); + const sourceStages = await models.Stages.find({ pipelineId: _id }).lean(); + + const pipelineDoc = { + ...sourcePipeline, + _id: undefined, + status: sourcePipeline.status || 'active', + name: `${sourcePipeline.name}-copied`, + }; + + const copied = await models.Pipelines.createPipeline(pipelineDoc); + + for (const stage of sourceStages) { + const { _id, ...rest } = stage; + + await models.Stages.createStage({ + ...rest, + probability: stage.probability || '10%', + type: copied.type, + pipelineId: copied._id, + }); + } + + return copied; + }, +}; diff --git a/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/mutations/stage.ts b/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/mutations/stage.ts new file mode 100644 index 0000000000..5069bb0db5 --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/mutations/stage.ts @@ -0,0 +1,107 @@ +import { IOrderInput } from 'erxes-api-shared/core-types'; +import { graphqlPubsub } from 'erxes-api-shared/utils'; +import { IContext } from '~/connectionResolvers'; +import { IStage } from '~/modules/tickets/@types/stage'; +import { bulkUpdateOrders } from '~/modules/tickets/utils'; + +export const stageMutations = { + /** + * Update stage orders + */ + async ticketsStagesUpdateOrder( + _root: undefined, + { orders }: { orders: IOrderInput[] }, + { models }: IContext, + ) { + return await models.Stages.updateOrder(orders); + }, + + /** + * Edit stage + */ + async ticketsStagesEdit( + _root: undefined, + { _id, ...doc }: IStage & { _id: string }, + { models }: IContext, + ) { + return await models.Stages.updateStage(_id, doc); + }, + + /** + * Remove stage + */ + async ticketsStagesRemove( + _root: undefined, + { _id }: { _id: string }, + { models }: IContext, + ) { + return await models.Stages.removeStage(_id); + }, + + async ticketsStagesSortItems( + _root: undefined, + { + stageId, + proccessId, + sortType, + }: { + stageId: string; + proccessId: string; + sortType: string; + }, + { models }: IContext, + ) { + const sortTypes = { + 'created-asc': { createdAt: 1 }, + 'created-desc': { createdAt: -1 }, + 'modified-asc': { modifiedAt: 1 }, + 'modified-desc': { modifiedAt: -1 }, + 'close-asc': { closeDate: 1, order: 1 }, + 'close-desc': { closeDate: -1, order: 1 }, + 'alphabetically-asc': { name: 1 }, + }; + + const sort: { [key: string]: any } = sortTypes[sortType]; + + if (sortType === 'close-asc') { + await bulkUpdateOrders({ + collection: models.Tickets, + stageId, + sort, + additionFilter: { closeDate: { $ne: null } }, + }); + await bulkUpdateOrders({ + collection: models.Tickets, + stageId, + sort: { order: 1 }, + additionFilter: { closeDate: null }, + startOrder: 100001, + }); + } else { + const response = await bulkUpdateOrders({ + collection: models.Tickets, + stageId, + sort, + }); + + if (!response) { + return; + } + } + + const stage = await models.Stages.getStage(stageId); + + graphqlPubsub.publish(`ticketsPipelinesChanged:${stage.pipelineId}`, { + ticketsPipelinesChanged: { + _id: stage.pipelineId, + proccessId, + action: 'reOrdered', + data: { + destinationStageId: stageId, + }, + }, + }); + + return 'ok'; + }, +}; diff --git a/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/mutations/ticket.ts b/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/mutations/ticket.ts new file mode 100644 index 0000000000..f779c8db22 --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/mutations/ticket.ts @@ -0,0 +1,587 @@ +import { + checkUserIds, + graphqlPubsub, + sendTRPCMessage, +} from 'erxes-api-shared/utils'; +import { IContext } from '~/connectionResolvers'; +import { ITicket } from '~/modules/tickets/@types/ticket'; +import { TICKET_STATUSES } from '~/modules/tickets/constants'; +import { + checkMovePermission, + copyPipelineLabels, + getNewOrder, + ticketResolver, +} from '~/modules/tickets/utils'; + +export const ticketMutations = { + /** + * Create new ticket + */ + async ticketsAdd( + _root: undefined, + doc: ITicket & { proccessId: string; aboveItemId: string }, + { user, models }: IContext, + ) { + doc.initialStageId = doc.stageId; + doc.watchedUserIds = user && [user._id]; + + const extendedDoc = { + ...doc, + modifiedBy: user && user._id, + userId: user ? user._id : doc.userId, + order: await getNewOrder({ + collection: models.Tickets, + stageId: doc.stageId, + aboveItemId: doc.aboveItemId, + }), + }; + + if (extendedDoc.customFieldsData) { + // clean custom field values + + extendedDoc.customFieldsData = await sendTRPCMessage({ + pluginName: 'core', + method: 'mutation', + module: 'fields', + action: 'prepareCustomFieldsData', + input: { + customFieldsData: extendedDoc.customFieldsData, + }, + defaultValue: [], + }); + } + + const ticket = await models.Tickets.createTicket(extendedDoc); + const stage = await models.Stages.getStage(ticket.stageId); + + // if (user) { + // // const pipeline = await models.Pipelines.getPipeline(stage.pipelineId); + + // // sendNotifications(models, subdomain, { + // // ticket, + // // user, + // // type: `${type}Add`, + // // action: `invited you to the ${pipeline.name}`, + // // content: `'${ticket.name}'.`, + // // contentType: type + // // }); + // } + + graphqlPubsub.publish(`ticketsPipelinesChanged:${stage.pipelineId}`, { + ticketsPipelinesChanged: { + _id: stage.pipelineId, + proccessId: doc.proccessId, + action: 'itemAdd', + data: { + item: ticket, + aboveItemId: doc.aboveItemId, + destinationStageId: stage._id, + }, + }, + }); + + return ticket; + }, + /** + * Edit ticket + */ + async ticketsEdit( + _root: undefined, + { _id, proccessId, ...doc }: ITicket & { _id: string; proccessId: string }, + { user, models, subdomain }: IContext, + ) { + const ticket = await models.Tickets.getTicket(_id); + + const extendedDoc = { + ...doc, + modifiedAt: new Date(), + modifiedBy: user._id, + }; + + const stage = await models.Stages.getStage(ticket.stageId); + + const { canEditMemberIds } = stage; + + if ( + canEditMemberIds && + canEditMemberIds.length > 0 && + !canEditMemberIds.includes(user._id) + ) { + throw new Error('Permission denied'); + } + + if (extendedDoc.customFieldsData) { + // clean custom field values + extendedDoc.customFieldsData = await sendTRPCMessage({ + pluginName: 'core', + method: 'mutation', + module: 'fields', + action: 'prepareCustomFieldsData', + input: { + customFieldsData: extendedDoc.customFieldsData, + }, + defaultValue: [], + }); + } + + const updatedItem = await models.Tickets.updateTicket(_id, extendedDoc); + // labels should be copied to newly moved pipeline + if (doc.stageId) { + await copyPipelineLabels(models, { item: ticket, doc, user }); + } + + // const notificationDoc: IBoardNotificationParams = { + // item: updatedItem, + // user, + // type: `${type}Edit`, + // contentType: type, + // }; + + if (doc.status && ticket.status && ticket.status !== doc.status) { + const activityAction = doc.status === 'active' ? 'activated' : 'archived'; + + // order notification + if (activityAction === 'archived') { + graphqlPubsub.publish(`ticketsPipelinesChanged:${stage.pipelineId}`, { + ticketsPipelinesChanged: { + _id: stage.pipelineId, + proccessId, + action: 'itemRemove', + data: { + ticket, + oldStageId: ticket.stageId, + }, + }, + }); + + return; + } + } + if (doc.assignedUserIds) { + const { addedUserIds, removedUserIds } = checkUserIds( + ticket.assignedUserIds, + doc.assignedUserIds, + ); + + const activityContent = { addedUserIds, removedUserIds }; + + // notificationDoc.invitedUsers = addedUserIds; + // notificationDoc.removedUsers = removedUserIds; + } + + // await sendNotifications(models, subdomain, notificationDoc); + + // if (!notificationDoc.invitedUsers && !notificationDoc.removedUsers) { + // sendCoreMessage({ + // subdomain: 'os', + // action: 'sendMobileNotification', + // data: { + // title: notificationDoc?.item?.name, + // body: `${ + // user?.details?.fullName || user?.details?.shortName + // } has updated`, + // receivers: notificationDoc?.item?.assignedUserIds, + // data: { + // type, + // id: _id, + // }, + // }, + // }); + // } + + // exclude [null] + if (doc.tagIds && doc.tagIds.length) { + doc.tagIds = doc.tagIds.filter((ti) => ti); + } + + const updatedStage = await models.Stages.getStage(updatedItem.stageId); + + if (doc.tagIds || doc.startDate || doc.closeDate || doc.name) { + graphqlPubsub.publish(`ticketsPipelinesChanged:${stage.pipelineId}`, { + ticketsPipelinesChanged: { + _id: stage.pipelineId, + }, + }); + } + + if (updatedStage.pipelineId !== stage.pipelineId) { + graphqlPubsub.publish(`ticketsPipelinesChanged:${stage.pipelineId}`, { + ticketsPipelinesChanged: { + _id: stage.pipelineId, + proccessId, + action: 'itemRemove', + data: { + item: ticket, + oldStageId: stage._id, + }, + }, + }); + graphqlPubsub.publish(`ticketsPipelinesChanged:${stage.pipelineId}`, { + ticketsPipelinesChanged: { + _id: updatedStage.pipelineId, + proccessId, + action: 'itemAdd', + data: { + item: { + ...updatedItem, + ...(await ticketResolver(models, subdomain, user, updatedItem)), + }, + aboveItemId: '', + destinationStageId: updatedStage._id, + }, + }, + }); + } else { + graphqlPubsub.publish(`ticketsPipelinesChanged:${stage.pipelineId}`, { + ticketsPipelinesChanged: { + _id: stage.pipelineId, + proccessId, + action: 'itemUpdate', + data: { + item: { + ...updatedItem, + ...(await ticketResolver(models, subdomain, user, updatedItem)), + }, + }, + }, + }); + } + + if (ticket.stageId === updatedItem.stageId) { + return updatedItem; + } + + // if task moves between stages + // const { content, action } = await itemMover( + // models, + // user._id, + // ticket, + // updatedItem.stageId, + // ); + + // await sendNotifications(models, subdomain, { + // item: updatedItem, + // user, + // type: `${type}Change`, + // content, + // action, + // contentType: type + // }); + + return updatedItem; + }, + + /** + * Change ticket + */ + async ticketsChange( + _root: undefined, + doc: { + proccessId: string; + itemId: string; + aboveItemId?: string; + destinationStageId: string; + sourceStageId: string; + }, + { user, models, subdomain }: IContext, + ) { + const { + proccessId, + itemId, + aboveItemId, + destinationStageId, + sourceStageId, + } = doc; + + const ticket = await models.Tickets.getTicket(itemId); + const stage = await models.Stages.getStage(ticket.stageId); + + const extendedDoc: ITicket = { + modifiedAt: new Date(), + modifiedBy: user._id, + stageId: destinationStageId, + order: await getNewOrder({ + collection: models.Tickets, + stageId: destinationStageId, + aboveItemId, + }), + }; + + if (ticket.stageId !== destinationStageId) { + checkMovePermission(stage, user); + + const destinationStage = await models.Stages.getStage(destinationStageId); + + checkMovePermission(destinationStage, user); + + extendedDoc.stageChangedDate = new Date(); + } + + const updatedItem = await models.Tickets.updateTicket(itemId, extendedDoc); + + // const { content, action } = await itemMover( + // models, + // subdomain, + // user._id, + // ticket, + // destinationStageId, + // ); + + // await sendNotifications(models, subdomain, { + // item, + // user, + // type: `${type}Change`, + // content, + // action, + // contentType: type, + // }); + + // if (ticket?.assignedUserIds && ticket?.assignedUserIds?.length > 0) { + // sendCoreMessage({ + // subdomain: 'os', + // action: 'sendMobileNotification', + // data: { + // title: `${ticket.name}`, + // body: `${user?.details?.fullName || user?.details?.shortName} ${ + // action + content + // }`, + // receivers: ticket?.assignedUserIds, + // data: { + // type, + // id: ticket._id, + // }, + // }, + // }); + // } + + // order notification + const labels = await models.PipelineLabels.find({ + _id: { + $in: ticket.labelIds, + }, + }); + + graphqlPubsub.publish(`ticketsPipelinesChanged:${stage.pipelineId}`, { + ticketsPipelinesChanged: { + _id: stage.pipelineId, + proccessId, + action: 'orderUpdated', + data: { + item: { + ...ticket, + ...(await ticketResolver(models, subdomain, user, ticket)), + labels, + }, + aboveItemId, + destinationStageId, + oldStageId: sourceStageId, + }, + }, + }); + + return ticket; + }, + + /** + * Remove ticket + */ + async ticketsRemove( + _root: undefined, + { _id }: { _id: string }, + { models }: IContext, + ) { + const ticket = await models.Tickets.getTicket(_id); + + // await sendNotifications(models, subdomain, { + // ticket, + // user, + // type: `ticketDelete`, + // action: `deleted ticket:`, + // content: `'${ticket.name}'`, + // contentType: 'ticket' + // }); + + // if (ticket?.assignedUserIds && ticket?.assignedUserIds?.length > 0) { + // sendCoreMessage({ + // subdomain: "os", + // action: "sendMobileNotification", + // data: { + // title: `${ticket.name}`, + // body: `${user?.details?.fullName || user?.details?.shortName} deleted the ticket`, + // receivers: ticket?.assignedUserIds, + // data: { + // type: 'ticket', + // id: ticket._id + // } + // } + // }); + // } + + return await models.Tickets.findOneAndDelete({ _id: ticket._id }); + }, + + /** + * Watch ticket + */ + async ticketsWatch( + _root: undefined, + { _id, isAdd }: { _id: string; isAdd: boolean }, + { user, models }: IContext, + ) { + return models.Tickets.watchTicket(_id, isAdd, user._id); + }, + + async ticketsCopy( + _root: undefined, + { _id, proccessId }: { _id: string; proccessId: string }, + { user, models, subdomain }: IContext, + ) { + const item = await models.Tickets.getTicket(_id); + + const doc = { + ...item, + _id: undefined, + userId: user._id, + modifiedBy: user._id, + watchedUserIds: [user._id], + assignedUserIds: item.assignedUserIds, + name: `${item.name}-copied`, + initialStageId: item.initialStageId, + stageId: item.stageId, + description: item.description, + priority: item.priority, + labelIds: item.labelIds, + order: await getNewOrder({ + collection: models.Tickets, + stageId: item.stageId, + aboveItemId: item._id, + }), + + attachments: (item.attachments || []).map((a) => ({ + url: a.url, + name: a.name, + type: a.type, + size: a.size, + })), + }; + + delete doc.sourceConversationIds; + + for (const param of ['source']) { + doc[param] = item[param]; + } + + const clone = await models.Tickets.create(doc); + + // const companyIds = await getCompanyIds(subdomain, type, _id); + // const customerIds = await getCustomerIds(subdomain, type, _id); + + const originalChecklists = await models.CheckLists.find({ + contentTypeId: item._id, + }).lean(); + + const clonedChecklists = await models.CheckLists.insertMany( + originalChecklists.map((originalChecklist) => ({ + contentTypeId: clone._id, + title: originalChecklist.title, + createdUserId: user._id, + createdDate: new Date(), + })), + { ordered: true }, + ); + + const originalChecklistIdToClonedId = new Map(); + + for (let i = 0; i < originalChecklists.length; i++) { + originalChecklistIdToClonedId.set( + originalChecklists[i]._id, + clonedChecklists[i]._id, + ); + } + + const originalChecklistItems = await models.CheckListItems.find({ + checklistId: { $in: originalChecklists.map((x) => x._id) }, + }).lean(); + + await models.CheckListItems.insertMany( + originalChecklistItems.map(({ content, order, checklistId }) => ({ + checklistId: originalChecklistIdToClonedId.get(checklistId), + isChecked: false, + createdUserId: user._id, + createdDate: new Date(), + content, + order, + })), + { ordered: false }, + ); + + // order notification + const stage = await models.Stages.getStage(clone.stageId); + + graphqlPubsub.publish(`ticketsPipelinesChanged:${stage.pipelineId}`, { + ticketsPipelinesChanged: { + _id: stage.pipelineId, + proccessId, + action: 'itemAdd', + data: { + item: { + ...clone, + ...(await ticketResolver(models, subdomain, user, clone)), + }, + aboveItemId: _id, + destinationStageId: stage._id, + }, + }, + }); + + graphqlPubsub.publish(`ticketsPipelinesChanged:${stage.pipelineId}`, { + ticketsPipelinesChanged: { + _id: stage.pipelineId, + proccessId: Math.random().toString(), + action: 'itemOfConformitiesUpdate', + data: { + item: { + ...item, + }, + }, + }, + }); + + return clone; + }, + + async ticketsArchive( + _root: undefined, + { stageId, proccessId }: { stageId: string; proccessId: string }, + { models }: IContext, + ) { + const tickets = await models.Tickets.find({ + stageId, + status: { $ne: TICKET_STATUSES.ARCHIVED }, + }).lean(); + + await models.Tickets.updateMany( + { stageId }, + { $set: { status: TICKET_STATUSES.ARCHIVED } }, + ); + + // order notification + const stage = await models.Stages.getStage(stageId); + + for (const ticket of tickets) { + graphqlPubsub.publish(`ticketsPipelinesChanged:${stage.pipelineId}`, { + ticketsPipelinesChanged: { + _id: stage.pipelineId, + proccessId, + action: 'itemsRemove', + data: { + item: ticket, + destinationStageId: stage._id, + }, + }, + }); + } + + return 'ok'; + }, +}; diff --git a/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/queries/board.ts b/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/queries/board.ts new file mode 100644 index 0000000000..0d195a713f --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/queries/board.ts @@ -0,0 +1,164 @@ +import { IUserDocument } from 'erxes-api-shared/core-types'; +import { sendTRPCMessage } from 'erxes-api-shared/utils'; +import { IContext } from '~/connectionResolvers'; + +export const boardQueries = { + /** + * Boards list + */ + async ticketsBoards( + _root: undefined, + _args: undefined, + { models }: IContext, + ) { + return models.Boards.find({}).lean(); + }, + + /** + * Boards count + */ + async ticketsBoardCounts( + _root: undefined, + _args: undefined, + { models }: IContext, + ) { + const boards = await models.Boards.find({}) + .sort({ + name: 1, + }) + .lean(); + + const counts: Array<{ _id: string; name: string; count: number }> = []; + + let allCount = 0; + + for (const board of boards) { + const count = await models.Pipelines.find({ + boardId: board._id, + }).countDocuments(); + + counts.push({ + _id: board._id, + name: board.name || '', + count, + }); + + allCount += count; + } + + counts.unshift({ _id: '', name: 'All', count: allCount }); + + return counts; + }, + + /** + * Get last board + */ + async ticketsBoardGetLast( + _root: undefined, + _args: undefined, + { models }: IContext, + ) { + return models.Boards.findOne({}) + .sort({ + createdAt: -1, + }) + .lean(); + }, + + /** + * Board detail + */ + async ticketsBoardDetail( + _root: undefined, + { _id }: { _id: string }, + { models }: IContext, + ) { + return models.Boards.getBoard(_id); + }, + + async ticketsBoardContentTypeDetail( + _root: undefined, + args: { contentType: string; contentId: string; content: any }, + { models }: IContext, + ) { + const { contentType = '', contentId, content } = args; + + switch (contentType.split(':')[1]) { + case 'checklist': + return await models.CheckLists.findOne({ _id: content._id }); + case 'checklistitem': + return await models.CheckListItems.findOne({ _id: content._id }); + case 'ticket': + return await models.Tickets.getTicket(contentId); + default: + } + }, + + async ticketsBoardLogs( + _root: undefined, + args: { action: string; content: any; contentId: string }, + { models }: IContext, + ) { + const { action, content, contentId } = args; + + if (action === 'moved') { + const ticket = await models.Tickets.getTicket(contentId); + + const { oldStageId, destinationStageId } = content; + + const destinationStage = await models.Stages.findOne({ + _id: destinationStageId, + }).lean(); + + const oldStage = await models.Stages.findOne({ _id: oldStageId }).lean(); + + if (destinationStage && oldStage) { + return { + destinationStage: destinationStage.name, + oldStage: oldStage.name, + ticket, + }; + } + + return { + text: content.text, + }; + } + + if (action === 'assignee') { + let addedUsers: IUserDocument[] = []; + let removedUsers: IUserDocument[] = []; + + if (content) { + addedUsers = await sendTRPCMessage({ + pluginName: 'core', + method: 'query', + module: 'users', + action: 'find', + input: { + query: { + _id: { $in: content.addedUserIds }, + }, + }, + defaultValue: [], + }); + + removedUsers = await sendTRPCMessage({ + pluginName: 'core', + method: 'query', + module: 'users', + action: 'find', + input: { + query: { + _id: { $in: content.removedUserIds }, + }, + }, + defaultValue: [], + }); + } + + return { addedUsers, removedUsers }; + } + }, +}; diff --git a/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/queries/checklist.ts b/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/queries/checklist.ts new file mode 100644 index 0000000000..91e4e58dd1 --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/queries/checklist.ts @@ -0,0 +1,28 @@ +import { IContext } from '~/connectionResolvers'; + +export const checklistQueries = { + /** + * Checklists list + */ + async ticketsChecklists( + _root: undefined, + { contentTypeId }: { contentTypeId: string }, + { models }: IContext, + ) { + return models.CheckLists.find({ contentTypeId }).sort({ + createdDate: 1, + order: 1, + }); + }, + + /** + * Checklist + */ + async ticketsChecklistDetail( + _root: undefined, + { _id }: { _id: string }, + { models }: IContext, + ) { + return models.CheckLists.findOne({ _id }).sort({ order: 1 }); + }, +}; diff --git a/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/queries/index.ts b/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/queries/index.ts new file mode 100644 index 0000000000..2c8d58f145 --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/queries/index.ts @@ -0,0 +1,15 @@ +import { boardQueries } from './board'; +import { checklistQueries } from './checklist'; +import { pipelineLabelQueries } from './label'; +import { pipelineQueries } from './pipeline'; +import { stageQueries } from './stage'; +import { ticketQueries } from './ticket'; + +export const queries = { + ...boardQueries, + ...pipelineQueries, + ...checklistQueries, + ...pipelineLabelQueries, + ...stageQueries, + ...ticketQueries, +}; diff --git a/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/queries/label.ts b/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/queries/label.ts new file mode 100644 index 0000000000..4540c0f18b --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/queries/label.ts @@ -0,0 +1,33 @@ +import { IContext } from '~/connectionResolvers'; + +export const pipelineLabelQueries = { + /** + * Pipeline label list + */ + async ticketsPipelineLabels( + _root: undefined, + { pipelineId, pipelineIds }: { pipelineId: string; pipelineIds: string[] }, + { models }: IContext, + ) { + const filter: any = {}; + + filter.pipelineId = pipelineId; + + if (pipelineIds) { + filter.pipelineId = { $in: pipelineIds }; + } + + return models.PipelineLabels.find(filter); + }, + + /** + * Pipeline label detail + */ + async ticketsPipelineLabelDetail( + _root: undefined, + { _id }: { _id: string }, + { models }: IContext, + ) { + return models.PipelineLabels.findOne({ _id }); + }, +}; diff --git a/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/queries/pipeline.ts b/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/queries/pipeline.ts new file mode 100644 index 0000000000..6d377dcb5e --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/queries/pipeline.ts @@ -0,0 +1,164 @@ +import { sendTRPCMessage } from 'erxes-api-shared/utils'; +import { IContext } from '~/connectionResolvers'; + +export const pipelineQueries = { + /** + * Pipelines list + */ + async ticketsPipelines( + _root: undefined, + { + boardId, + isAll, + }: { + boardId: string; + isAll: boolean; + }, + { user, models }: IContext, + ) { + const query: any = + user.isOwner || isAll + ? {} + : { + status: { $ne: 'archived' }, + $or: [ + { visibility: 'public' }, + { + $and: [ + { visibility: 'private' }, + { + $or: [ + { memberIds: { $in: [user._id] } }, + { userId: user._id }, + ], + }, + ], + }, + ], + }; + + if (!user.isOwner && !isAll) { + const userDetail = await sendTRPCMessage({ + pluginName: 'core', + method: 'query', + module: 'users', + action: 'find', + input: { + _id: user._id, + }, + defaultValue: [], + }); + + const departmentIds = userDetail?.departmentIds || []; + + if (Object.keys(query) && departmentIds.length > 0) { + query.$or.push({ + $and: [ + { visibility: 'private' }, + { departmentIds: { $in: departmentIds } }, + ], + }); + } + } + + if (boardId) { + query.boardId = boardId; + } + + return models.Pipelines.find(query) + .sort({ order: 1, createdAt: -1 }) + .lean(); + }, + + async ticketsPipelineStateCount( + _root: undefined, + { boardId, type }: { boardId: string; type: string }, + { models }: IContext, + ) { + const query: any = {}; + + if (boardId) { + query.boardId = boardId; + } + + if (type) { + query.type = type; + } + + const counts: any = {}; + const now = new Date(); + + const notStartedQuery = { + ...query, + startDate: { $gt: now }, + }; + + const notStartedCount = await models.Pipelines.find( + notStartedQuery, + ).countDocuments(); + + counts['Not started'] = notStartedCount; + + const inProgressQuery = { + ...query, + startDate: { $lt: now }, + endDate: { $gt: now }, + }; + + const inProgressCount = await models.Pipelines.find( + inProgressQuery, + ).countDocuments(); + + counts['In progress'] = inProgressCount; + + const completedQuery = { + ...query, + endDate: { $lt: now }, + }; + + const completedCounted = await models.Pipelines.find( + completedQuery, + ).countDocuments(); + + counts.Completed = completedCounted; + + counts.All = notStartedCount + inProgressCount + completedCounted; + + return counts; + }, + + /** + * Pipeline detail + */ + async ticketsPipelineDetail( + _root: undefined, + { _id }: { _id: string }, + { models }: IContext, + ) { + return await models.Pipelines.getPipeline(_id); + }, + + /** + * Pipeline related assigned users + */ + async ticketsPipelineAssignedUsers( + _root: undefined, + { _id }: { _id: string }, + { models }: IContext, + ) { + const pipeline = await models.Pipelines.getPipeline(_id); + + const stageIds = await models.Stages.find({ + pipelineId: pipeline._id, + }).distinct('_id'); + + const assignedUserIds = await models.Tickets.find({ + stageId: { $in: stageIds }, + }).distinct('assignedUserIds'); + + return assignedUserIds.map((userId) => ({ + __typename: 'User', + _id: userId || '', + })); + }, +}; diff --git a/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/queries/stage.ts b/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/queries/stage.ts new file mode 100644 index 0000000000..4ca9a0622c --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/queries/stage.ts @@ -0,0 +1,115 @@ +import { regexSearchText, sendTRPCMessage } from 'erxes-api-shared/utils'; +import { IContext } from '~/connectionResolvers'; +import { TICKET_STATUSES } from '~/modules/tickets/constants'; + +export const stageQueries = { + /** + * Stages list + */ + async ticketsStages( + _root: undefined, + { + pipelineId, + pipelineIds, + isNotLost, + isAll, + }: { + pipelineId: string; + pipelineIds: string[]; + isNotLost: boolean; + isAll: boolean; + }, + { user, models }: IContext, + ) { + const filter: any = {}; + + filter.pipelineId = pipelineId; + + if (pipelineIds) { + filter.pipelineId = { $in: pipelineIds }; + } + + if (isNotLost) { + filter.probability = { $ne: 'Lost' }; + } + + if (!isAll) { + filter.status = { $ne: TICKET_STATUSES.ARCHIVED }; + + filter.$or = [ + { visibility: { $in: ['public', null] } }, + { + $and: [{ visibility: 'private' }, { memberIds: { $in: [user._id] } }], + }, + ]; + + const userDetail = await sendTRPCMessage({ + pluginName: 'core', + method: 'query', + module: 'users', + action: 'findOne', + input: { + _id: user._id, + }, + defaultValue: [], + }); + + const departmentIds = userDetail?.departmentIds || []; + if (departmentIds.length > 0) { + filter.$or.push({ + $and: [ + { visibility: 'private' }, + { departmentIds: { $in: departmentIds } }, + ], + }); + } + } + + return await models.Stages.find(filter) + .sort({ order: 1, createdAt: -1 }) + .lean(); + }, + + /** + * Stage detail + */ + async ticketsStageDetail( + _root: undefined, + { _id }: { _id: string }, + { models }: IContext, + ) { + return models.Stages.findOne({ _id }).lean(); + }, + + /** + * Archived stages + */ + + async ticketsArchivedStages( + _root: undefined, + { pipelineId, search }: { pipelineId: string; search?: string }, + { models }: IContext, + ) { + const filter: any = { pipelineId, status: TICKET_STATUSES.ARCHIVED }; + + if (search) { + Object.assign(filter, regexSearchText(search, 'name')); + } + + return models.Stages.find(filter).sort({ createdAt: -1 }); + }, + + async ticketsArchivedStagesCount( + _root: undefined, + { pipelineId, search }: { pipelineId: string; search?: string }, + { models }: IContext, + ) { + const filter: any = { pipelineId, status: TICKET_STATUSES.ARCHIVED }; + + if (search) { + Object.assign(filter, regexSearchText(search, 'name')); + } + + return models.Stages.countDocuments(filter); + }, +}; diff --git a/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/queries/ticket.ts b/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/queries/ticket.ts new file mode 100644 index 0000000000..5ace48e4e5 --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/tickets/graphql/resolvers/queries/ticket.ts @@ -0,0 +1,551 @@ +import { + cursorPaginate, + regexSearchText, + sendTRPCMessage, +} from 'erxes-api-shared/utils'; +import { IContext, IModels } from '~/connectionResolvers'; +import { + IArchiveArgs, + ITicketDocument, + ITicketQueryParams, +} from '~/modules/tickets/@types/ticket'; +import { TICKET_STATUSES } from '~/modules/tickets/constants'; +import { + calendarFilters, + checkItemPermByUser, + generateArchivedItemsFilter, + generateExtraFilters, + getCloseDateByType, + getItemList, +} from '~/modules/tickets/utils'; + +export const generateFilter = async ( + models: IModels, + currentUserId: string, + args = {} as any, + extraParams?: any, +) => { + args.type = 'ticket'; + const { productIds } = extraParams || args; + + const { + _ids, + pipelineId, + pipelineIds, + stageId, + parentId, + boardIds, + stageCodes, + search, + closeDateType, + assignedUserIds, + initialStageId, + labelIds, + priority, + userIds, + tagIds, + assignedToMe, + startDate, + endDate, + hasStartAndCloseDate, + stageChangedStartDate, + stageChangedEndDate, + noSkipArchive, + number, + branchIds, + departmentIds, + dateRangeFilters, + customFieldsDataFilters, + resolvedDayBetween, + } = args; + + const isListEmpty = (value) => { + return value.length === 1 && value[0].length === 0; + }; + + let filter: any = noSkipArchive + ? {} + : { status: { $ne: TICKET_STATUSES.ARCHIVED }, parentId: undefined }; + + if (parentId) { + filter.parentId = parentId; + } + + if (assignedUserIds) { + // Filter by assigned to no one + const notAssigned = isListEmpty(assignedUserIds); + + filter.assignedUserIds = notAssigned ? [] : { $in: assignedUserIds }; + } + + if (branchIds) { + const branches = await sendTRPCMessage({ + pluginName: 'core', + method: 'query', + module: 'branches', + action: 'findWithChild', + input: { + query: { _id: { $in: branchIds } }, + fields: { _id: 1 }, + }, + defaultValue: [], + }); + + filter.branchIds = { $in: branches.map((item) => item._id) }; + } + + if (departmentIds) { + const departments = await sendTRPCMessage({ + pluginName: 'core', + method: 'query', + module: 'departments', + action: 'findWithChild', + input: { + query: { _id: { $in: departmentIds } }, + fields: { _id: 1 }, + }, + defaultValue: [], + }); + + filter.departmentIds = { $in: departments.map((item) => item._id) }; + } + + if (_ids && _ids.length) { + filter._id = { $in: _ids }; + } + + if (initialStageId) { + filter.initialStageId = initialStageId; + } + + if (closeDateType) { + filter.closeDate = getCloseDateByType(closeDateType); + } + + if (startDate) { + filter.closeDate = { + $gte: new Date(startDate), + }; + } + + if (endDate) { + if (filter.closeDate) { + filter.closeDate.$lte = new Date(endDate); + } else { + filter.closeDate = { + $lte: new Date(endDate), + }; + } + } + + if (dateRangeFilters) { + for (const dateRangeFilter of dateRangeFilters) { + const { name, from, to } = dateRangeFilter; + + if (from) { + filter[name] = { $gte: new Date(from) }; + } + + if (to) { + filter[name] = { ...filter[name], $lte: new Date(to) }; + } + } + } + + if (customFieldsDataFilters) { + for (const { value, name } of customFieldsDataFilters) { + if (Array.isArray(value) && value?.length) { + filter[`customFieldsData.${name}`] = { $in: value }; + } else { + filter[`customFieldsData.${name}`] = value; + } + } + } + + const stageChangedDateFilter: any = {}; + if (stageChangedStartDate) { + stageChangedDateFilter.$gte = new Date(stageChangedStartDate); + } + if (stageChangedEndDate) { + stageChangedDateFilter.$lte = new Date(stageChangedEndDate); + } + if (Object.keys(stageChangedDateFilter).length) { + filter.stageChangedDate = stageChangedDateFilter; + } + + if (search) { + Object.assign(filter, regexSearchText(search)); + } + + if (stageId) { + filter.stageId = stageId; + } else if (pipelineId || pipelineIds) { + let filterPipeline = pipelineId; + + if (pipelineIds) { + filterPipeline = { $in: pipelineIds }; + } + + const stageIds = await models.Stages.find({ + pipelineId: filterPipeline, + status: { $ne: TICKET_STATUSES.ARCHIVED }, + }).distinct('_id'); + + filter.stageId = { $in: stageIds }; + } + + if (boardIds) { + const pipelineIds = await models.Pipelines.find({ + boardId: { $in: boardIds }, + status: { $ne: TICKET_STATUSES.ARCHIVED }, + }).distinct('_id'); + + const filterStages: any = { + pipelineId: { $in: pipelineIds }, + status: { $ne: TICKET_STATUSES.ARCHIVED }, + }; + + if (filter?.stageId?.$in) { + filterStages._id = { $in: filter?.stageId?.$in }; + } + + const stageIds = await models.Stages.find(filterStages).distinct('_id'); + + filter.stageId = { $in: stageIds }; + } + + if (stageCodes) { + const filterStages: any = { code: { $in: stageCodes } }; + + if (filter?.stageId?.$in) { + filterStages._id = { $in: filter?.stageId?.$in }; + } + + const stageIds = await models.Stages.find(filterStages).distinct('_id'); + + filter.stageId = { $in: stageIds }; + } + + if (labelIds) { + const isEmpty = isListEmpty(labelIds); + + filter.labelIds = isEmpty ? { $in: [null, []] } : { $in: labelIds }; + } + + if (priority) { + filter.priority = { $eq: priority }; + } + + if (tagIds) { + filter.tagIds = { $in: tagIds }; + } + + if (pipelineId) { + const pipeline = await models.Pipelines.getPipeline(pipelineId); + const user = await sendTRPCMessage({ + pluginName: 'core', + method: 'query', + module: 'users', + action: 'findOne', + input: { + _id: currentUserId, + }, + defaultValue: {}, + }); + const tmp = + (await sendTRPCMessage({ + pluginName: 'core', + method: 'query', + module: 'departments', + action: 'findWithChild', + input: { + query: { + supervisorId: currentUserId, + }, + fields: { + _id: 1, + }, + }, + defaultValue: [], + })) || []; + + const supervisorDepartmentIds = tmp?.map((x) => x._id) || []; + const pipelineDepartmentIds = pipeline.departmentIds || []; + + const commonIds = + supervisorDepartmentIds.filter((id) => + pipelineDepartmentIds.includes(id), + ) || []; + const isEligibleSeeAllCards = (pipeline.excludeCheckUserIds || []).includes( + currentUserId, + ); + if ( + commonIds?.length > 0 && + (pipeline.isCheckUser || pipeline.isCheckDepartment) && + !isEligibleSeeAllCards + ) { + // current user is supervisor in departments and this pipeline has included that some of user's departments + // so user is eligible to see all cards of people who share same department. + const otherDepartmentUsers = await sendTRPCMessage({ + pluginName: 'core', + method: 'query', + module: 'users', + action: 'find', + input: { + query: { departmentIds: { $in: commonIds } }, + }, + defaultValue: [], + }); + let includeCheckUserIds = otherDepartmentUsers.map((x) => x._id) || []; + includeCheckUserIds = includeCheckUserIds.concat(user._id || []); + + const uqinueCheckUserIds = [ + ...new Set(includeCheckUserIds.concat(currentUserId)), + ]; + + Object.assign(filter, { + $or: [ + { assignedUserIds: { $in: uqinueCheckUserIds } }, + { userId: { $in: uqinueCheckUserIds } }, + ], + }); + } else if ( + (pipeline.isCheckUser || pipeline.isCheckDepartment) && + !isEligibleSeeAllCards + ) { + let includeCheckUserIds: string[] = []; + + if (pipeline.isCheckDepartment) { + const userDepartmentIds = user?.departmentIds || []; + const commonIds = userDepartmentIds.filter((id) => + pipelineDepartmentIds.includes(id), + ); + + const otherDepartmentUsers = await sendTRPCMessage({ + pluginName: 'core', + method: 'query', + module: 'users', + action: 'find', + input: { + query: { departmentIds: { $in: commonIds } }, + }, + defaultValue: [], + }); + + for (const departmentUser of otherDepartmentUsers) { + includeCheckUserIds = [...includeCheckUserIds, departmentUser._id]; + } + + if ( + pipelineDepartmentIds.filter((departmentId) => + userDepartmentIds.includes(departmentId), + ).length + ) { + includeCheckUserIds = includeCheckUserIds.concat(user._id || []); + } + } + + const uqinueCheckUserIds = [ + ...new Set(includeCheckUserIds.concat(currentUserId)), + ]; + + Object.assign(filter, { + $or: [ + { assignedUserIds: { $in: uqinueCheckUserIds } }, + { userId: { $in: uqinueCheckUserIds } }, + ], + }); + } + } + + if (userIds) { + const isEmpty = isListEmpty(userIds); + + filter.userId = isEmpty ? { $in: [null, []] } : { $in: userIds }; + } + + if (assignedToMe) { + filter.assignedUserIds = { $in: [currentUserId] }; + } + + // if (segmentData) { + // const segment = JSON.parse(segmentData); + // const itemIds = await fetchSegment(subdomain, '', {}, segment); + // filter._id = { $in: itemIds }; + // } + + // if (segment) { + // const segmentObj = await sendTRPCMessage({ + // pluginName: 'core', + // method: 'query', + // module: 'segment', + // action: 'findOne', + // input: { _id: segment }, + // defaultValue: {}, + // }); + // const itemIds = await fetchSegment(subdomain, segmentObj); + + // filter._id = { $in: itemIds }; + // } + + if (hasStartAndCloseDate) { + filter.startDate = { $exists: true }; + filter.closeDate = { $exists: true }; + } + + if (number) { + filter.number = { $regex: `${number}`, $options: 'mui' }; + } + + // if (vendorCustomerIds?.length > 0) { + // const cards = await sendCommonMessage({ + // subdomain, + // serviceName: 'clientportal', + // action: 'clientPortalUserCards.find', + // data: { + // contentType: 'ticket', + // cpUserId: { $in: vendorCustomerIds }, + // }, + // isRPC: true, + // defaultValue: [], + // }); + // const cardIds = cards.map((d) => d.contentTypeId); + // if (filter._id) { + // const ids = filter._id.$in; + // const newIds = ids.filter((d) => cardIds.includes(d)); + // filter._id = { $in: newIds }; + // } else { + // filter._id = { $in: cardIds }; + // } + // } + + if ((stageId || stageCodes) && resolvedDayBetween) { + const [dayFrom, dayTo] = resolvedDayBetween; + filter.$expr = { + $and: [ + // Convert difference between stageChangedDate and createdAt to days + { + $gte: [ + { + $divide: [ + { $subtract: ['$stageChangedDate', '$createdAt'] }, + 1000 * 60 * 60 * 24, // Convert milliseconds to days + ], + }, + dayFrom, // Minimum day (0 days) + ], + }, + { + $lt: [ + { + $divide: [ + { $subtract: ['$stageChangedDate', '$createdAt'] }, + 1000 * 60 * 60 * 24, + ], + }, + dayTo, // Maximum day (3 days) + ], + }, + ], + }; + } + + if (extraParams) { + filter = await generateExtraFilters(filter, extraParams); + } + + if (productIds) { + filter['productsData.productId'] = { $in: productIds }; + } + + // Calendar monthly date + await calendarFilters(models, filter, args); + + return filter; +}; + +export const ticketQueries = { + /** + * Tickets list + */ + async tickets( + _root: undefined, + args: ITicketQueryParams, + { user, models }: IContext, + ) { + const filter = await generateFilter(models, user._id, args); + + return await getItemList(models, filter, args, user); + }, + + async ticketsTotalCount( + _root: undefined, + args: ITicketQueryParams, + { user, models }: IContext, + ) { + const filter = { + ...(await generateFilter(models, user._id, args)), + }; + + return models.Tickets.find(filter).countDocuments(); + }, + + /** + * Archived list + */ + async archivedTickets( + _root: undefined, + args: IArchiveArgs, + { models }: IContext, + ) { + const { pipelineId } = args; + + const stages = await models.Stages.find({ pipelineId }).lean(); + + if (stages.length > 0) { + const filter = generateArchivedItemsFilter(args, stages); + + const { list, pageInfo, totalCount } = + await cursorPaginate({ + model: models.Tickets, + params: args, + query: filter, + }); + + return { list, pageInfo, totalCount }; + } + + return {}; + }, + + async archivedTicketsCount( + _root: undefined, + args: IArchiveArgs, + { models }: IContext, + ) { + const { pipelineId } = args; + + const stages = await models.Stages.find({ pipelineId }); + + if (stages.length > 0) { + const filter = generateArchivedItemsFilter(args, stages); + + return models.Tickets.find(filter).countDocuments(); + } + + return 0; + }, + + /** + * Tickets detail + */ + async ticketDetail( + _root: undefined, + { _id }: { _id: string }, + { user, models }: IContext, + ) { + const ticket = await models.Tickets.getTicket(_id); + + return checkItemPermByUser(models, user, ticket); + }, +}; diff --git a/backend/plugins/frontline_api/src/modules/tickets/graphql/schemas/board.ts b/backend/plugins/frontline_api/src/modules/tickets/graphql/schemas/board.ts new file mode 100644 index 0000000000..bccd7e7188 --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/tickets/graphql/schemas/board.ts @@ -0,0 +1,33 @@ +export const types = ` + type TicketsBoard @key(fields: "_id") { + _id: String! + name: String! + order: Int + createdAt: Date + type: String + pipelines: [TicketsPipeline] + } + + type TicketsBoardCount { + _id: String + name: String + count: Int + } +`; + +export const queries = ` + ticketsBoards: [TicketsBoard] + ticketsBoardCounts: [TicketsBoardCount] + ticketsBoardGetLast: TicketsBoard + ticketsBoardDetail(_id: String!): TicketsBoard + ticketsBoardContentTypeDetail(contentType: String, contentId: String): JSON + ticketsBoardLogs(action: String, content:JSON, contentId: String): JSON +`; + +export const mutations = ` + ticketsBoardsAdd(name: String!): TicketsBoard + ticketsBoardsEdit(_id: String!, name: String!): TicketsBoard + ticketsBoardsRemove(_id: String!): JSON + ticketsBoardItemUpdateTimeTracking(_id: String!, status: String!, timeSpent: Int!, startDate: String): JSON + ticketsBoardItemsSaveForGanttTimeline(items: JSON, links: JSON): String +`; diff --git a/backend/plugins/frontline_api/src/modules/tickets/graphql/schemas/checklist.ts b/backend/plugins/frontline_api/src/modules/tickets/graphql/schemas/checklist.ts new file mode 100644 index 0000000000..4d2e5ce1f4 --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/tickets/graphql/schemas/checklist.ts @@ -0,0 +1,37 @@ +export const types = ` + type TicketsChecklistItem { + _id: String! + checklistId: String + isChecked: Boolean + content: String + order: Int + } + + type TicketsChecklist { + _id: String! + contentType: String + contentTypeId: String + title: String + createdUserId: String + createdDate: Date + items: [TicketsChecklistItem] + percent: Float + } + +`; + +export const queries = ` + ticketsChecklists(contentTypeId: String): [TicketsChecklist] + ticketsChecklistDetail(_id: String!): TicketsChecklist +`; + +export const mutations = ` + ticketsChecklistsAdd(contentTypeId: String, title: String): TicketsChecklist + ticketsChecklistsEdit(_id: String!, title: String, contentTypeId: String): TicketsChecklist + ticketsChecklistsRemove(_id: String!): TicketsChecklist + ticketsChecklistItemsOrder(_id: String!, destinationIndex: Int): TicketsChecklistItem + + ticketsChecklistItemsAdd(checklistId: String, content: String, isChecked: Boolean): TicketsChecklistItem + ticketsChecklistItemsEdit(_id: String!, checklistId: String, content: String, isChecked: Boolean): TicketsChecklistItem + ticketsChecklistItemsRemove(_id: String!): TicketsChecklistItem +`; diff --git a/backend/plugins/frontline_api/src/modules/tickets/graphql/schemas/common.ts b/backend/plugins/frontline_api/src/modules/tickets/graphql/schemas/common.ts new file mode 100644 index 0000000000..2e826d06d3 --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/tickets/graphql/schemas/common.ts @@ -0,0 +1,6 @@ +export const commonDeps = ` + input TicketsOrderItem { + _id: String! + order: Int! + } +`; diff --git a/backend/plugins/frontline_api/src/modules/tickets/graphql/schemas/index.ts b/backend/plugins/frontline_api/src/modules/tickets/graphql/schemas/index.ts new file mode 100644 index 0000000000..0f48b54210 --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/tickets/graphql/schemas/index.ts @@ -0,0 +1,65 @@ +import { + mutations as boardMutations, + queries as boardQueries, + types as boardTypes, +} from './board'; + +import { + mutations as pipelineMutations, + queries as pipelineQueries, + types as pipelineTypes, +} from './pipeline'; + +import { + mutations as checkListMutations, + queries as checkListQueries, + types as checkListTypes, +} from './checklist'; + +import { + mutations as ticketMutations, + queries as ticketQueries, + types as ticketTypes, +} from './ticket'; + +import { + mutations as pipelineLabelMutations, + queries as pipelineLabelQueries, + types as pipelineLabelTypes, +} from './label'; + +import { + mutations as stageMutations, + queries as stageQueries, + types as stageTypes, +} from './stage'; + +import { commonDeps } from '~/modules/tickets/graphql/schemas/common'; + +export const types = ` + ${commonDeps} + ${checkListTypes} + ${boardTypes} + ${pipelineTypes} + ${ticketTypes} + ${pipelineLabelTypes} + ${stageTypes} +`; + +export const queries = ` + ${checkListQueries} + ${boardQueries} + ${pipelineQueries} + ${ticketQueries} + ${pipelineLabelQueries} + ${stageQueries} +`; + +export const mutations = ` + ${checkListMutations} + ${boardMutations} + ${pipelineMutations} + ${ticketMutations} + ${pipelineLabelMutations} + ${stageMutations} +`; diff --git a/backend/plugins/frontline_api/src/modules/tickets/graphql/schemas/label.ts b/backend/plugins/frontline_api/src/modules/tickets/graphql/schemas/label.ts new file mode 100644 index 0000000000..1cbdab53df --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/tickets/graphql/schemas/label.ts @@ -0,0 +1,28 @@ +export const types = ` + type TicketsPipelineLabel @key(fields: "_id") { + _id: String! + name: String! + colorCode: String + pipelineId: String + createdBy: String + createdAt: Date + } +`; + +export const queries = ` + ticketsPipelineLabels(pipelineId: String, pipelineIds: [String]): [TicketsPipelineLabel] + ticketsPipelineLabelDetail(_id: String!): TicketsPipelineLabel +`; + +const mutationParams = ` + name: String! + colorCode: String! + pipelineId: String! +`; + +export const mutations = ` + ticketsPipelineLabelsAdd(${mutationParams}): TicketsPipelineLabel + ticketsPipelineLabelsEdit(_id: String!, ${mutationParams}): TicketsPipelineLabel + ticketsPipelineLabelsRemove(_id: String!): JSON + ticketsPipelineLabelsLabel(pipelineId: String!, targetId: String!, labelIds: [String!]!): String +`; diff --git a/backend/plugins/frontline_api/src/modules/tickets/graphql/schemas/pipeline.ts b/backend/plugins/frontline_api/src/modules/tickets/graphql/schemas/pipeline.ts new file mode 100644 index 0000000000..610f23d15a --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/tickets/graphql/schemas/pipeline.ts @@ -0,0 +1,79 @@ +export const types = ` + type TicketsPipeline @key(fields: "_id") { + _id: String! + name: String! + status: String + boardId: String! + tagId: String + tag: Tag + visibility: String! + memberIds: [String] + departmentIds: [String] + branchIds: [String] + members: [User] + bgColor: String + isWatched: Boolean + itemsTotalCount: Int + userId: String + createdUser: User + startDate: Date + endDate: Date + metric: String + hackScoringType: String + templateId: String + state: String + isCheckDate: Boolean + isCheckUser: Boolean + isCheckDepartment: Boolean + excludeCheckUserIds: [String] + numberConfig: String + numberSize: String + nameConfig: String + order: Int + createdAt: Date + type: String + } +`; + +export const queries = ` + ticketsPipelines(boardId: String, isAll: Boolean): [TicketsPipeline] + ticketsPipelineDetail(_id: String!): TicketsPipeline + ticketsPipelineAssignedUsers(_id: String!): [User] + ticketsPipelineStateCount(boardId: String): JSON +`; + +const mutationParams = ` + name: String!, + type: String! + + boardId: String!, + stages: JSON, + visibility: String!, + memberIds: [String], + tagId: String, + bgColor: String, + startDate: Date, + endDate: Date, + metric: String, + hackScoringType: String, + templateId: String, + isCheckDate: Boolean, + isCheckUser: Boolean, + isCheckDepartment: Boolean, + excludeCheckUserIds: [String], + numberConfig: String, + numberSize: String, + departmentIds: [String], + nameConfig: String, + branchIds: [String], +`; + +export const mutations = ` + ticketsPipelinesAdd(${mutationParams}): TicketsPipeline + ticketsPipelinesEdit(_id: String!, ${mutationParams}): TicketsPipeline + ticketsPipelinesUpdateOrder(orders: [TicketsOrderItem]): [TicketsPipeline] + ticketsPipelinesWatch(_id: String!, isAdd: Boolean, type: String!): TicketsPipeline + ticketsPipelinesRemove(_id: String!): JSON + ticketsPipelinesArchive(_id: String!): JSON + ticketsPipelinesCopied(_id: String!): JSON +`; diff --git a/backend/plugins/frontline_api/src/modules/tickets/graphql/schemas/stage.ts b/backend/plugins/frontline_api/src/modules/tickets/graphql/schemas/stage.ts new file mode 100644 index 0000000000..a015a4ea92 --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/tickets/graphql/schemas/stage.ts @@ -0,0 +1,67 @@ +export const types = ` + type TicketsStage @key(fields: "_id") { + _id: String! + name: String! + pipelineId: String! + visibility: String + code: String + memberIds: [String] + canMoveMemberIds: [String] + canEditMemberIds: [String] + members: [User] + departmentIds: [String] + probability: String + status: String + unUsedAmount: JSON + amount: JSON + itemsTotalCount: Int + compareNextStageTickets: JSON + stayedTicketsTotalCount: Int + initialTicketsTotalCount: Int + inProcessTicketsTotalCount: Int + formId: String + age: Int + defaultTick: Boolean + order: Int + createdAt: Date + type: String + } +`; + +const queryParams = ` + search: String, + companyIds: [String] + customerIds: [String] + assignedUserIds: [String] + labelIds: [String] + extraParams: JSON, + closeDateType: String, + assignedToMe: String, + age: Int, + branchIds: [String] + departmentIds: [String] + segment: String + segmentData:String + createdStartDate: Date + createdEndDate: Date + stateChangedStartDate: Date + stateChangedEndDate: Date + startDateStartDate: Date + startDateEndDate: Date + closeDateStartDate: Date + closeDateEndDate: Date +`; + +export const queries = ` + ticketsStages(isNotLost: Boolean, isAll: Boolean, pipelineId: String, pipelineIds: [String], ${queryParams}): [TicketsStage] + ticketsStageDetail(_id: String!, ${queryParams}): TicketsStage + ticketsArchivedStages(pipelineId: String!, search: String, page: Int, perPage: Int): [TicketsStage] + ticketsArchivedStagesCount(pipelineId: String!, search: String): Int +`; + +export const mutations = ` + ticketsStagesUpdateOrder(orders: [TicketsOrderItem]): [TicketsStage] + ticketsStagesRemove(_id: String!): JSON + ticketsStagesEdit(_id: String!, type: String, name: String, status: String): TicketsStage + ticketsStagesSortItems(stageId: String!, type: String, proccessId: String, sortType: String): String +`; diff --git a/backend/plugins/frontline_api/src/modules/tickets/graphql/schemas/ticket.ts b/backend/plugins/frontline_api/src/modules/tickets/graphql/schemas/ticket.ts new file mode 100644 index 0000000000..4b5150d440 --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/tickets/graphql/schemas/ticket.ts @@ -0,0 +1,183 @@ +import { GQL_CURSOR_PARAM_DEFS } from 'erxes-api-shared/utils'; + +const typeDeps = ` + type TicketsTimeTrack { + status: String, + timeSpent: Int, + startDate: String + } +`; + +const inputDeps = ` + input TicketsItemDate { + month: Int + year: Int + } +`; + +export const types = ` + ${typeDeps} + ${inputDeps} + + type Ticket @key(fields: "_id") { + _id: String! + source: String + companies: [Company] + customers: [Customer] + + name: String! + order: Float + createdAt: Date + hasNotified: Boolean + assignedUserIds: [String] + branchIds: [String] + departmentIds:[String] + labelIds: [String] + startDate: Date + closeDate: Date + description: String + modifiedAt: Date + modifiedBy: String + reminderMinute: Int, + isComplete: Boolean, + isWatched: Boolean, + stageId: String + boardId: String + priority: String + status: String + attachments: [Attachment] + userId: String + tagIds: [String] + + assignedUsers: [User] + stage: TicketsStage + labels: [TicketsPipelineLabel] + pipeline: TicketsPipeline + createdUser: User + customFieldsData: JSON + score: Float + timeTrack: TicketsTimeTrack + number: String + stageChangedDate: Date + + customProperties: JSON + type: String + + tags: [Tag] + + cursor: String + } + + type TicketsListResponse { + list: [Ticket], + pageInfo: PageInfo + totalCount: Int, + } +`; + +const queryParams = ` + _ids: [String] + pipelineId: String + pipelineIds: [String] + parentId:String + stageId: String + stage: [String] + customerIds: [String] + vendorCustomerIds: [String] + companyIds: [String] + date: TicketsItemDate + skip: Int + limit: Int + search: String + assignedUserIds: [String] + closeDateType: String + priority: [String] + source: [String] + labelIds: [String] + userIds: [String] + segment: String + segmentData: String + assignedToMe: String + startDate: String + endDate: String + hasStartAndCloseDate: Boolean + tagIds: [String] + noSkipArchive: Boolean + number: String + branchIds: [String] + departmentIds: [String] + boardIds: [String] + stageCodes: [String] + dateRangeFilters:JSON + customFieldsDataFilters:JSON + createdStartDate: Date, + createdEndDate: Date + stateChangedStartDate: Date + stateChangedEndDate: Date + startDateStartDate: Date + startDateEndDate: Date + closeDateStartDate: Date + closeDateEndDate: Date + resolvedDayBetween:[Int] + + ${GQL_CURSOR_PARAM_DEFS} +`; + +const archivedQueryParams = ` + pipelineId: String! + search: String + userIds: [String] + priorities: [String] + assignedUserIds: [String] + labelIds: [String] + companyIds: [String] + customerIds: [String] + startDate: String + endDate: String + sources: [String] +`; + +export const queries = ` + ticketDetail(_id: String!): Ticket + + tickets(${queryParams}): TicketsListResponse + ticketsTotalCount(${queryParams}): Int + + archivedTickets(${archivedQueryParams}): TicketsListResponse + archivedTicketsCount(${archivedQueryParams}): Int +`; + +const mutationParams = ` + source: String, + type: String, + parentId:String, + proccessId: String, + aboveItemId: String, + stageId: String, + assignedUserIds: [String], + attachments: [AttachmentInput], + startDate: Date, + closeDate: Date, + description: String, + order: Int, + reminderMinute: Int, + isComplete: Boolean, + priority: String, + status: String, + sourceConversationIds: [String], + customFieldsData: JSON, + tagIds: [String], + branchIds: [String], + departmentIds: [String], +`; + +export const mutations = ` + ticketsAdd(name: String!, companyIds: [String], customerIds: [String], labelIds: [String], ${mutationParams}): Ticket + ticketsEdit(_id: String!, name: String, ${mutationParams}): Ticket + ticketsChange(itemId: String!, aboveItemId: String, destinationStageId: String!, sourceStageId: String, proccessId: String): Ticket + ticketsRemove(_id: String!): Ticket + + ticketsWatch(_id: String, isAdd: Boolean): Ticket + ticketsCopy(_id: String!, proccessId: String): Ticket + ticketsArchive(stageId: String!, proccessId: String): String +`; diff --git a/backend/plugins/frontline_api/src/modules/tickets/resolver.ts b/backend/plugins/frontline_api/src/modules/tickets/resolver.ts new file mode 100644 index 0000000000..aad3515812 --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/tickets/resolver.ts @@ -0,0 +1,86 @@ +import mongoose from 'mongoose'; +import { IModels } from '~/connectionResolvers'; +import { IBoardDocument } from '~/modules/tickets/@types/board'; +import { + IChecklistDocument, + IChecklistItemDocument, +} from '~/modules/tickets/@types/checklist'; +import { ICommentDocument } from '~/modules/tickets/@types/comment'; +import { IPipelineLabelDocument } from '~/modules/tickets/@types/label'; +import { IPipelineDocument } from '~/modules/tickets/@types/pipeline'; +import { IStageDocument } from '~/modules/tickets/@types/stage'; +import { ITicketDocument } from '~/modules/tickets/@types/ticket'; +import { + IBoardModel, + loadBoardClass, +} from '~/modules/tickets/db/models/Boards'; +import { + IChecklistItemModel, + IChecklistModel, + loadChecklistClass, + loadChecklistItemClass, +} from '~/modules/tickets/db/models/Checklists'; +import { + ICommentModel, + loadCommentClass, +} from '~/modules/tickets/db/models/Comments'; +import { + IPipelineLabelModel, + loadPipelineLabelClass, +} from '~/modules/tickets/db/models/Labels'; +import { + IPipelineModel, + loadPipelineClass, +} from '~/modules/tickets/db/models/Pipelines'; +import { + IStageModel, + loadStageClass, +} from '~/modules/tickets/db/models/Stages'; +import { + ITicketModel, + loadTicketClass, +} from '~/modules/tickets/db/models/Tickets'; + +export const loadClasses = (models: IModels, db: mongoose.Connection) => { + models.Boards = db.model( + 'tickets_boards', + loadBoardClass(models), + ); + + models.Pipelines = db.model( + 'tickets_pipelines', + loadPipelineClass(models), + ); + + models.Stages = db.model( + 'tickets_stages', + loadStageClass(models), + ); + + models.Tickets = db.model( + 'tickets', + loadTicketClass(models), + ); + + models.CheckLists = db.model( + 'tickets_checklists', + loadChecklistClass(models), + ); + + models.CheckListItems = db.model( + 'tickets_checklist_items', + loadChecklistItemClass(models), + ); + + models.PipelineLabels = db.model( + 'tickets_pipeline_labels', + loadPipelineLabelClass(models), + ); + + models.Comments = db.model( + 'ticket_comments', + loadCommentClass(models), + ); + + return models; +}; diff --git a/backend/plugins/frontline_api/src/modules/tickets/utils.ts b/backend/plugins/frontline_api/src/modules/tickets/utils.ts new file mode 100644 index 0000000000..aae1f9cb92 --- /dev/null +++ b/backend/plugins/frontline_api/src/modules/tickets/utils.ts @@ -0,0 +1,1060 @@ +import { IUserDocument } from 'erxes-api-shared/core-types'; +import { + cursorPaginate, + getNextMonth, + getToday, + regexSearchText, + sendTRPCMessage, + USER_ROLES, + validSearchText, +} from 'erxes-api-shared/utils'; +import moment from 'moment-timezone'; +import { DeleteResult } from 'mongoose'; +import * as _ from 'underscore'; +import resolvers from '~/apollo/resolvers'; +import { IModels } from '~/connectionResolvers'; +import { IPipeline } from '~/modules/tickets/@types/pipeline'; +import { IStage, IStageDocument } from '~/modules/tickets/@types/stage'; +import { + IArchiveArgs, + ITicket, + ITicketDocument, + ITicketQueryParams, +} from '~/modules/tickets/@types/ticket'; +import { CLOSE_DATE_TYPES, TICKET_STATUSES } from '~/modules/tickets/constants'; +import { generateFilter } from '~/modules/tickets/graphql/resolvers/queries/ticket'; + +export const fillSearchTextItem = (doc: ITicket, item?: ITicket) => { + const document = item || { name: '', description: '' }; + Object.assign(document, doc); + + return validSearchText([document.name || '', document.description || '']); +}; + +export const configReplacer = (config) => { + const now = new Date(); + + // replace type of date + return config + .replace(/\{year}/g, now.getFullYear().toString()) + .replace(/\{month}/g, `0${(now.getMonth() + 1).toString()}`.slice(-2)) + .replace(/\{day}/g, `0${now.getDate().toString()}`.slice(-2)); +}; + +// board item number calculator +const numberCalculator = (size: number, num?: any, skip?: boolean) => { + if (num && !skip) { + num = parseInt(num, 10) + 1; + } + + if (skip) { + num = 0; + } + + num = num.toString(); + + while (num.length < size) { + num = '0' + num; + } + + return num; +}; + +export const boardNumberGenerator = async ( + models: IModels, + config: string, + size: string, + skip: boolean, + type?: string, +) => { + const replacedConfig = await configReplacer(config); + const re = replacedConfig + '[0-9]+$'; + + let number; + + if (!skip) { + const pipeline = await models.Pipelines.findOne({ + lastNum: new RegExp(re), + type, + }); + + if (pipeline?.lastNum) { + const { lastNum } = pipeline; + + const lastGeneratedNumber = lastNum.slice(replacedConfig.length); + + number = + replacedConfig + + (await numberCalculator(parseInt(size, 10), lastGeneratedNumber)); + + return number; + } + } + + number = + replacedConfig + (await numberCalculator(parseInt(size, 10), '', skip)); + + return number; +}; + +export const generateLastNum = async (models: IModels, doc: IPipeline) => { + const replacedConfig = await configReplacer(doc.numberConfig); + const re = replacedConfig + '[0-9]+$'; + + const pipeline = await models.Pipelines.findOne({ + lastNum: new RegExp(re), + type: doc.type, + }); + + if (pipeline) { + return pipeline.lastNum; + } + + const tickets = await models.Tickets.findOne({ + number: new RegExp(re), + }); + + if (tickets) { + return tickets.number; + } + + // generate new number by new numberConfig + return await boardNumberGenerator( + models, + doc.numberConfig || '', + doc.numberSize || '', + true, + ); +}; + +export const createOrUpdatePipelineStages = async ( + models: IModels, + stages: IStageDocument[], + pipelineId: string, +): Promise => { + let order = 0; + + const validStageIds: string[] = []; + const bulkOpsPrevEntry: Array<{ + updateOne: { + filter: { _id: string }; + update: { $set: IStage }; + }; + }> = []; + const prevItemIds = stages.map((stage) => stage._id); + + const prevEntries = await models.Stages.find({ _id: { $in: prevItemIds } }); + const prevEntriesIds = prevEntries.map((entry) => entry._id); + + const selector = { pipelineId, _id: { $nin: prevItemIds } }; + + const stageIds = await models.Stages.find(selector).distinct('_id'); + + await models.Tickets.deleteMany({ stageId: { $in: stageIds } }); + + await models.Stages.deleteMany(selector); + + for (const stage of stages) { + order++; + + const doc: any = { ...stage, order, pipelineId }; + + const prevEntry = prevEntriesIds.includes(doc._id); + + // edit + if (prevEntry) { + validStageIds.push(doc._id); + + bulkOpsPrevEntry.push({ + updateOne: { + filter: { + _id: doc._id, + }, + update: { + $set: doc, + }, + }, + }); + // create + } else { + doc._id = undefined; + const createdStage = await models.Stages.createStage(doc); + validStageIds.push(createdStage._id); + } + } + + if (bulkOpsPrevEntry.length > 0) { + await models.Stages.bulkWrite(bulkOpsPrevEntry); + } + + return models.Stages.deleteMany({ pipelineId, _id: { $nin: validStageIds } }); +}; + +export const bulkUpdateOrders = async ({ + collection, + stageId, + sort = { order: 1 }, + additionFilter = {}, + startOrder = 100, +}: { + collection: any; + stageId: string; + sort?: { [key: string]: any }; + additionFilter?: any; + startOrder?: number; +}) => { + const bulkOps: Array<{ + updateOne: { + filter: { _id: string }; + update: { order: number }; + }; + }> = []; + + let ord = startOrder; + + const allItems = await collection + .find( + { + stageId, + status: { $ne: TICKET_STATUSES.ARCHIVED }, + ...additionFilter, + }, + { _id: 1, order: 1 }, + ) + .sort(sort); + + for (const item of allItems) { + bulkOps.push({ + updateOne: { + filter: { _id: item._id }, + update: { order: ord }, + }, + }); + + ord += 10; + } + + if (!bulkOps.length) { + return ''; + } + + await collection.bulkWrite(bulkOps); + return 'ok'; +}; + +export const checkNumberConfig = async ( + numberConfig: string, + numberSize: string, +) => { + if (!numberConfig) { + throw new Error('Please input number configuration.'); + } + + if (!numberSize) { + throw new Error('Please input fractional part.'); + } + + const replaced = await configReplacer(numberConfig); + const re = /[0-9]$/; + + if (re.test(replaced)) { + throw new Error( + `Please make sure that the number configuration itself doesn't end with any number.`, + ); + } + + return; +}; + +export const getAmountsMap = async ( + models: IModels, + user: IUserDocument, + args: any, + stage: IStageDocument, + tickUsed = true, +) => { + const amountsMap = {}; + + const filter = await generateFilter( + models, + user._id, + { ...args, stageId: stage._id, pipelineId: stage.pipelineId }, + args.extraParams, + ); + + const amountList = await models.Tickets.aggregate([ + { + $match: filter, + }, + { + $unwind: '$productsData', + }, + { + $project: { + amount: '$productsData.amount', + currency: '$productsData.currency', + tickUsed: '$productsData.tickUsed', + }, + }, + { + $match: { tickUsed }, + }, + { + $group: { + _id: '$currency', + amount: { $sum: '$amount' }, + }, + }, + ]); + + amountList.forEach((item) => { + if (item._id) { + amountsMap[item._id] = item.amount; + } + }); + + return amountsMap; +}; + +export const getItemList = async ( + models: IModels, + filter: any, + args: ITicketQueryParams, + user: IUserDocument, + getExtraFields?: (item: any) => { [key: string]: any }, +) => { + const { list, pageInfo, totalCount } = await cursorPaginate({ + model: models.Tickets, + params: args, + query: filter, + }); + + // const ids = list.map((item) => item._id); + + // const conformities = await sendCoreMessage({ + // subdomain, + // action: 'conformities.getConformities', + // data: { + // mainType: type, + // mainTypeIds: ids, + // relTypes: ['company', 'customer'], + // }, + // isRPC: true, + // defaultValue: [], + // }); + + const companyIds: string[] = []; + const customerIds: string[] = []; + const companyIdsByItemId = {}; + const customerIdsByItemId = {}; + + // const perConformity = ( + // conformity, + // cocIdsByItemId, + // cocIds, + // typeId1, + // typeId2, + // ) => { + // cocIds.push(conformity[typeId1]); + + // if (!cocIdsByItemId[conformity[typeId2]]) { + // cocIdsByItemId[conformity[typeId2]] = []; + // } + + // cocIdsByItemId[conformity[typeId2]].push(conformity[typeId1]); + // }; + + // for (const conf of conformities) { + // if (conf.mainType === 'company') { + // perConformity( + // conf, + // companyIdsByItemId, + // companyIds, + // 'mainTypeId', + // 'relTypeId', + // ); + // continue; + // } + // if (conf.relType === 'company') { + // perConformity( + // conf, + // companyIdsByItemId, + // companyIds, + // 'relTypeId', + // 'mainTypeId', + // ); + // continue; + // } + // if (conf.mainType === 'customer') { + // perConformity( + // conf, + // customerIdsByItemId, + // customerIds, + // 'mainTypeId', + // 'relTypeId', + // ); + // continue; + // } + // if (conf.relType === 'customer') { + // perConformity( + // conf, + // customerIdsByItemId, + // customerIds, + // 'relTypeId', + // 'mainTypeId', + // ); + // continue; + // } + // } + + const companies = await sendTRPCMessage({ + pluginName: 'core', + method: 'query', + module: 'companies', + action: 'findActiveCompanies', + input: { + query: { + _id: { $in: [...new Set(companyIds)] }, + }, + fields: { + primaryName: 1, + primaryEmail: 1, + primaryPhone: 1, + emails: 1, + phones: 1, + }, + }, + defaultValue: [], + }); + + const customers = await sendTRPCMessage({ + pluginName: 'core', + method: 'query', + module: 'customers', + action: 'findActiveCustomers', + input: { + query: { + _id: { $in: [...new Set(customerIds)] }, + }, + fields: { + firstName: 1, + lastName: 1, + middleName: 1, + visitorContactInfo: 1, + primaryEmail: 1, + primaryPhone: 1, + emails: 1, + phones: 1, + }, + }, + defaultValue: [], + }); + + const getCocsByItemId = ( + itemId: string, + cocIdsByItemId: any, + cocs: any[], + ) => { + const cocIds = cocIdsByItemId[itemId] || []; + + return cocIds.flatMap((cocId: string) => { + const found = cocs.find((coc) => cocId === coc._id); + + return found || []; + }); + }; + + const updatedList: any[] = []; + + // const notifications = await sendNotificationsMessage({ + // subdomain, + // action: 'find', + // data: { + // selector: { + // contentTypeId: { $in: ids }, + // isRead: false, + // receiver: user._id + // }, + // fields: { contentTypeId: 1 } + // }, + // isRPC: true, + // defaultValue: [] + // }); + + const fields = await sendTRPCMessage({ + pluginName: 'core', + method: 'query', + module: 'fields', + action: 'find', + input: { + query: { + showInCard: true, + contentType: `sales:deal`, + }, + }, + defaultValue: [], + }); + + // add just incremented order to each item in list, not from db + let order = 0; + for (const item of list as any) { + if (item.customFieldsData?.length && fields?.length) { + item.customProperties = []; + + fields.forEach((field) => { + const fieldData = (item.customFieldsData || []).find( + (f) => f.field === field._id, + ); + + if (item.customProperties && fieldData) { + item.customProperties.push({ + name: `${field.text} - ${fieldData.value}`, + }); + } + }); + } + + // const notification = notifications.find(n => n.contentTypeId === item._id); + + updatedList.push({ + ...item, + order: order++, + isWatched: (item.watchedUserIds || []).includes(user._id), + // hasNotified: notification ? false : true, + customers: getCocsByItemId(item._id, customerIdsByItemId, customers), + companies: getCocsByItemId(item._id, companyIdsByItemId, companies), + ...(getExtraFields ? await getExtraFields(item) : {}), + }); + } + + return { list: updatedList, pageInfo, totalCount }; +}; + +export const checkItemPermByUser = async ( + models: IModels, + user: any, + item: ITicket, +) => { + const stage = await models.Stages.getStage(item.stageId); + + const { + visibility, + memberIds, + departmentIds = [], + branchIds = [], + } = await models.Pipelines.getPipeline(stage.pipelineId); + + const supervisorDepartments = await sendTRPCMessage({ + pluginName: 'core', + method: 'query', + module: 'departments', + action: 'findWithChild', + input: { + query: { + supervisorId: user?._id, + }, + fields: { + _id: 1, + }, + }, + defaultValue: [], + }); + + const supervisorDepartmentIds = + supervisorDepartments?.map((x) => x._id) || []; + const userDepartmentIds = user?.departmentIds || []; + const userBranchIds = user?.branchIds || []; + + // check permission on department + const hasUserInDepartment = compareDepartmentIds(departmentIds, [ + ...userDepartmentIds, + ...supervisorDepartmentIds, + ]); + const isUserInBranch = compareDepartmentIds(branchIds, userBranchIds); + + if ( + visibility === 'private' && + !(memberIds || []).includes(user._id) && + !hasUserInDepartment && + !isUserInBranch && + user?.role !== USER_ROLES.SYSTEM + ) { + throw new Error('You do not have permission to view.'); + } + + const isSuperVisorInDepartment = compareDepartmentIds( + departmentIds, + supervisorDepartmentIds, + ); + if (isSuperVisorInDepartment) { + return item; + } + + // pipeline is Show only the users assigned(created) cards checked + // and current user nothing dominant users + // current user hans't this carts assigned and created + // if ( + // isCheckUser && + // !(excludeCheckUserIds || []).includes(user?._id) && + // !( + // (item.assignedUserIds || []).includes(user?._id) || + // item.userId === user?._id + // ) + // ) { + // throw new Error('You do not have permission to view.'); + // } + + return item; +}; + +// comparing pipelines departmentIds and current user departmentIds +export const compareDepartmentIds = ( + pipelineDepartmentIds: string[], + userDepartmentIds: string[], +): boolean => { + if (!pipelineDepartmentIds?.length || !userDepartmentIds?.length) { + return false; + } + + for (const uDepartmentId of userDepartmentIds) { + if (pipelineDepartmentIds.includes(uDepartmentId)) { + return true; + } + } + + return false; +}; + +export const getCloseDateByType = (closeDateType: string) => { + if (closeDateType === CLOSE_DATE_TYPES.NEXT_DAY) { + const tomorrow = moment().add(1, 'days'); + + return { + $gte: new Date(tomorrow.startOf('day').toISOString()), + $lte: new Date(tomorrow.endOf('day').toISOString()), + }; + } + + if (closeDateType === CLOSE_DATE_TYPES.NEXT_WEEK) { + const monday = moment() + .day(1 + 7) + .format('YYYY-MM-DD'); + const nextSunday = moment() + .day(7 + 7) + .format('YYYY-MM-DD'); + + return { + $gte: new Date(monday), + $lte: new Date(nextSunday), + }; + } + + if (closeDateType === CLOSE_DATE_TYPES.NEXT_MONTH) { + const now = new Date(); + const { start, end } = getNextMonth(now); + + return { + $gte: new Date(start), + $lte: new Date(end), + }; + } + + if (closeDateType === CLOSE_DATE_TYPES.NO_CLOSE_DATE) { + return { $exists: false }; + } + + if (closeDateType === CLOSE_DATE_TYPES.OVERDUE) { + const now = new Date(); + const today = getToday(now); + + return { $lt: today }; + } +}; + +export const generateExtraFilters = async (filter, extraParams) => { + const { + source, + userIds, + priority, + startDate, + endDate, + createdStartDate, + createdEndDate, + stateChangedStartDate, + stateChangedEndDate, + startDateStartDate, + startDateEndDate, + closeDateStartDate, + closeDateEndDate, + } = extraParams; + + const isListEmpty = (value) => { + return value.length === 1 && value[0].length === 0; + }; + + if (source) { + filter.source = { $eq: source }; + } + + if (userIds) { + const isEmpty = isListEmpty(userIds); + + filter.userId = isEmpty ? { $in: [null, []] } : { $in: userIds }; + } + + if (priority) { + filter.priority = { $eq: priority }; + } + + if (startDate) { + filter.closeDate = { + $gte: new Date(startDate), + }; + } + + if (endDate) { + if (filter.closeDate) { + filter.closeDate.$lte = new Date(endDate); + } else { + filter.closeDate = { + $lte: new Date(endDate), + }; + } + } + + if (createdStartDate || createdEndDate) { + filter.createdAt = { + $gte: new Date(createdStartDate), + $lte: new Date(createdEndDate), + }; + } + + if (stateChangedStartDate || stateChangedEndDate) { + filter.stageChangedDate = { + $gte: new Date(stateChangedStartDate), + $lte: new Date(stateChangedEndDate), + }; + } + + if (startDateStartDate || startDateEndDate) { + filter.startDate = { + $gte: new Date(startDateStartDate), + $lte: new Date(startDateEndDate), + }; + } + + if (closeDateStartDate || closeDateEndDate) { + filter.closeDate = { + $gte: new Date(closeDateStartDate), + $lte: new Date(closeDateEndDate), + }; + } + + return filter; +}; + +export const calendarFilters = async (models: IModels, filter, args) => { + const { + date, + pipelineId, + createdStartDate, + createdEndDate, + stateChangedStartDate, + stateChangedEndDate, + startDateStartDate, + startDateEndDate, + closeDateStartDate, + closeDateEndDate, + } = args; + + if (date) { + const stageIds = await models.Stages.find({ pipelineId }).distinct('_id'); + + filter.closeDate = dateSelector(date); + filter.stageId = { $in: stageIds }; + } + + if (createdStartDate || createdEndDate) { + filter.createdAt = { + $gte: new Date(createdStartDate), + $lte: new Date(createdEndDate), + }; + } + if (stateChangedStartDate || stateChangedEndDate) { + filter.stageChangedDate = { + $gte: new Date(stateChangedStartDate), + $lte: new Date(stateChangedEndDate), + }; + } + if (startDateStartDate || startDateEndDate) { + filter.startDate = { + $gte: new Date(startDateStartDate), + $lte: new Date(startDateEndDate), + }; + } + if (closeDateStartDate || closeDateEndDate) { + filter.closeDate = { + $gte: new Date(closeDateStartDate), + $lte: new Date(closeDateEndDate), + }; + } + + return filter; +}; + +export const dateSelector = (date: { month: number; year: number }) => { + const { year, month } = date; + + const start = new Date(Date.UTC(year, month, 1, 0, 0, 0)); + const end = new Date(Date.UTC(year, month + 1, 1, 0, 0, 0)); + + return { + $gte: start, + $lte: end, + }; +}; + +export const generateArchivedItemsFilter = ( + params: IArchiveArgs, + stages: IStageDocument[], +) => { + const { + search, + userIds, + priorities, + assignedUserIds, + labelIds, + productIds, + startDate, + endDate, + sources, + hackStages, + } = params; + + const filter: any = { status: TICKET_STATUSES.ARCHIVED }; + + filter.stageId = { $in: stages.map((stage) => stage._id) }; + + if (search) { + Object.assign(filter, regexSearchText(search, 'name')); + } + + if (userIds && userIds.length) { + filter.userId = { $in: userIds }; + } + + if (priorities && priorities.length) { + filter.priority = { $in: priorities }; + } + + if (assignedUserIds && assignedUserIds.length) { + filter.assignedUserIds = { $in: assignedUserIds }; + } + + if (labelIds && labelIds.length) { + filter.labelIds = { $in: labelIds }; + } + + if (productIds && productIds.length) { + filter['productsData.productId'] = { $in: productIds }; + } + + if (startDate) { + filter.closeDate = { + $gte: new Date(startDate), + }; + } + + if (endDate) { + if (filter.closeDate) { + filter.closeDate.$lte = new Date(endDate); + } else { + filter.closeDate = { + $lte: new Date(endDate), + }; + } + } + + if (sources && sources.length) { + filter.source = { $in: sources }; + } + + if (hackStages && hackStages.length) { + filter.hackStages = { $in: hackStages }; + } + + return filter; +}; + +const randomBetween = (min: number, max: number) => { + return Math.random() * (max - min) + min; +}; + +const orderHelper = (aboveOrder, belowOrder) => { + // empty stage + if (!aboveOrder && !belowOrder) { + return 100; + } + + // end of stage + if (!belowOrder) { + return aboveOrder + 10; + } + + // begin of stage + if (!aboveOrder) { + return randomBetween(0, belowOrder); + } + + // between items on stage + return randomBetween(aboveOrder, belowOrder); +}; + +export const getNewOrder = async ({ + collection, + stageId, + aboveItemId, +}: { + collection: any; + stageId: string; + aboveItemId?: string; +}) => { + const aboveItem = await collection.findOne({ _id: aboveItemId }); + + const aboveOrder = aboveItem?.order || 0; + + const belowItems = await collection + .find({ + stageId, + order: { $gt: aboveOrder }, + status: { $ne: TICKET_STATUSES.ARCHIVED }, + }) + .sort({ order: 1 }) + .limit(1); + + const belowOrder = belowItems[0]?.order; + + const order = orderHelper(aboveOrder, belowOrder); + + // if duplicated order, then in stages items bulkUpdate 100, 110, 120, 130 + if ([aboveOrder, belowOrder].includes(order)) { + await bulkUpdateOrders({ collection, stageId }); + + return getNewOrder({ collection, stageId, aboveItemId }); + } + + return order; +}; + +/** + * Copies pipeline labels alongside ticket when they are moved between different pipelines. + */ +export const copyPipelineLabels = async ( + models: IModels, + params: { + item: ITicketDocument; + doc: any; + user: IUserDocument; + }, +) => { + const { item, doc, user } = params; + + const oldStage = await models.Stages.findOne({ _id: item.stageId }).lean(); + const newStage = await models.Stages.findOne({ _id: doc.stageId }).lean(); + + if (!(oldStage && newStage)) { + throw new Error('Stage not found'); + } + + if (oldStage.pipelineId === newStage.pipelineId) { + return; + } + + const oldLabels = await models.PipelineLabels.find({ + _id: { $in: item.labelIds }, + }).lean(); + + const updatedLabelIds: string[] = []; + + const existingLabels = await models.PipelineLabels.find({ + name: { $in: oldLabels.map((o) => o.name) }, + colorCode: { $in: oldLabels.map((o) => o.colorCode) }, + pipelineId: newStage.pipelineId, + }).lean(); + + // index using only name and colorCode, since all pipelineIds are same + const existingLabelsByUnique = _.indexBy( + existingLabels, + ({ name, colorCode }) => JSON.stringify({ name, colorCode }), + ); + + // Collect labels that don't exist on the new stage's pipeline here + const notExistingLabels: any[] = []; + + for (const label of oldLabels) { + const exists = + existingLabelsByUnique[ + JSON.stringify({ name: label.name, colorCode: label.colorCode }) + ]; + if (!exists) { + notExistingLabels.push({ + name: label.name, + colorCode: label.colorCode, + pipelineId: newStage.pipelineId, + createdAt: new Date(), + createdBy: user._id, + }); + } else { + updatedLabelIds.push(exists._id); + } + } // end label loop + + // Insert labels that don't already exist on the new stage's pipeline + const newLabels = await models.PipelineLabels.insertMany(notExistingLabels, { + ordered: false, + }); + + for (const newLabel of newLabels) { + updatedLabelIds.push(newLabel._id); + } + + await models.PipelineLabels.labelsLabel(item._id, updatedLabelIds); +}; + +export const checkMovePermission = ( + stage: IStageDocument, + user: IUserDocument, +) => { + if ( + stage.canMoveMemberIds && + stage.canMoveMemberIds.length > 0 && + !stage.canMoveMemberIds.includes(user._id) + ) { + throw new Error('Permission denied'); + } +}; + +export const ticketResolver = async ( + models: IModels, + subdomain: string, + user: any, + ticket: ITicket, +) => { + const additionInfo = {}; + const resolver = resolvers['Ticket'] || {}; + + for (const subResolver of Object.keys(resolver)) { + try { + additionInfo[subResolver] = await resolver[subResolver]( + ticket, + {}, + { models, subdomain, user }, + { isSubscription: true }, + ); + } catch (unused) { + continue; + } + } + + return additionInfo; +}; diff --git a/backend/plugins/sales_api/src/modules/sales/graphql/resolvers/mutations/checklists.ts b/backend/plugins/sales_api/src/modules/sales/graphql/resolvers/mutations/checklists.ts index ec238967eb..d168e6d678 100644 --- a/backend/plugins/sales_api/src/modules/sales/graphql/resolvers/mutations/checklists.ts +++ b/backend/plugins/sales_api/src/modules/sales/graphql/resolvers/mutations/checklists.ts @@ -1,4 +1,5 @@ import { moduleRequireLogin } from 'erxes-api-shared/core-modules'; +import { graphqlPubsub } from 'erxes-api-shared/utils'; import { IContext } from '~/connectionResolvers'; import { IChecklist, @@ -8,24 +9,24 @@ import { } from '~/modules/sales/@types'; const checklistsChanged = (checklist: IChecklistDocument) => { - // graphqlPubsub.publish( - // `salesChecklistsChanged:${checklist.contentType}:${checklist.contentTypeId}`, - // { - // salesChecklistsChanged: { - // _id: checklist._id, - // contentType: checklist.contentType, - // contentTypeId: checklist.contentTypeId, - // }, - // }, - // ); + graphqlPubsub.publish( + `salesChecklistsChanged:${checklist.contentType}:${checklist.contentTypeId}`, + { + salesChecklistsChanged: { + _id: checklist._id, + contentType: checklist.contentType, + contentTypeId: checklist.contentTypeId, + }, + }, + ); }; const checklistDetailChanged = (_id: string) => { - // graphqlPubsub.publish(`salesChecklistDetailChanged:${_id}`, { - // salesChecklistDetailChanged: { - // _id, - // }, - // }); + graphqlPubsub.publish(`salesChecklistDetailChanged:${_id}`, { + salesChecklistDetailChanged: { + _id, + }, + }); }; export const checklistMutations = { diff --git a/backend/plugins/sales_api/src/modules/sales/graphql/resolvers/mutations/deals.ts b/backend/plugins/sales_api/src/modules/sales/graphql/resolvers/mutations/deals.ts index b6f1c8ba7b..89a6c0707e 100644 --- a/backend/plugins/sales_api/src/modules/sales/graphql/resolvers/mutations/deals.ts +++ b/backend/plugins/sales_api/src/modules/sales/graphql/resolvers/mutations/deals.ts @@ -1,4 +1,8 @@ -import { checkUserIds, sendTRPCMessage } from 'erxes-api-shared/utils'; +import { + checkUserIds, + graphqlPubsub, + sendTRPCMessage, +} from 'erxes-api-shared/utils'; import { IContext } from '~/connectionResolvers'; import { IDeal, IDealDocument, IProductData } from '~/modules/sales/@types'; import { SALES_STATUSES } from '~/modules/sales/constants'; @@ -7,7 +11,7 @@ import { copyChecklists, destroyBoardItemRelations, getNewOrder, - itemMover, + itemResolver, itemsEdit, } from '~/modules/sales/utils'; @@ -48,13 +52,13 @@ export const dealMutations = { }); } - return await models.Deals.createDeal(extendedDoc); + const ticket = await models.Deals.createDeal(extendedDoc); - // const stage = await models.Stages.getStage(item.stageId); + const stage = await models.Stages.getStage(ticket.stageId); // await createConformity(subdomain, { // mainType: type, - // mainTypeId: item._id, + // mainTypeId: ticket._id, // companyIds: doc.companyIds, // customerIds: doc.customerIds, // }); @@ -83,18 +87,20 @@ export const dealMutations = { // ); // } - // graphqlPubsub.publish(`salesPipelinesChanged:${stage.pipelineId}`, { - // salesPipelinesChanged: { - // _id: stage.pipelineId, - // proccessId: doc.proccessId, - // action: "itemAdd", - // data: { - // item, - // aboveItemId: doc.aboveItemId, - // destinationStageId: stage._id, - // }, - // }, - // }); + graphqlPubsub.publish(`salesPipelinesChanged:${stage.pipelineId}`, { + salesPipelinesChanged: { + _id: stage.pipelineId, + proccessId: doc.proccessId, + action: 'itemAdd', + data: { + item: ticket, + aboveItemId: doc.aboveItemId, + destinationStageId: stage._id, + }, + }, + }); + + return ticket; }, /** @@ -193,13 +199,9 @@ export const dealMutations = { sourceStageId, } = doc; - const item = await models.Deals.findOne({ _id: itemId }); - - if (!item) { - throw new Error('Deal not found'); - } + const deal = await models.Deals.getDeal(itemId); - const stage = await models.Stages.getStage(item.stageId); + const stage = await models.Stages.getStage(deal.stageId); const extendedDoc: IDeal = { modifiedBy: user._id, @@ -211,7 +213,7 @@ export const dealMutations = { }), }; - if (item.stageId !== destinationStageId) { + if (deal.stageId !== destinationStageId) { checkMovePermission(stage, user); const destinationStage = await models.Stages.getStage(destinationStageId); @@ -228,12 +230,12 @@ export const dealMutations = { const updatedItem = await models.Deals.updateDeal(itemId, extendedDoc); - const { content, action } = await itemMover( - models, - user._id, - item, - destinationStageId, - ); + // const { content, action } = await itemMover( + // models, + // user._id, + // deal, + // destinationStageId, + // ); // await sendNotifications(models, subdomain, { // item, @@ -275,31 +277,31 @@ export const dealMutations = { // ); // order notification - // const labels = await models.PipelineLabels.find({ - // _id: { - // $in: item.labelIds, - // }, - // }); + const labels = await models.PipelineLabels.find({ + _id: { + $in: deal.labelIds, + }, + }); - // graphqlPubsub.publish(`salesPipelinesChanged:${stage.pipelineId}`, { - // salesPipelinesChanged: { - // _id: stage.pipelineId, - // proccessId, - // action: 'orderUpdated', - // data: { - // item: { - // ...item._doc, - // ...(await itemResolver(models, subdomain, user, type, item)), - // labels, - // }, - // aboveItemId, - // destinationStageId, - // oldStageId: sourceStageId, - // }, - // }, - // }); + graphqlPubsub.publish(`salesPipelinesChanged:${stage.pipelineId}`, { + salesPipelinesChanged: { + _id: stage.pipelineId, + proccessId, + action: 'orderUpdated', + data: { + item: { + ...deal, + ...(await itemResolver(models, user, deal)), + labels, + }, + aboveItemId, + destinationStageId, + oldStageId: sourceStageId, + }, + }, + }); - return item; + return deal; }, /** @@ -427,23 +429,23 @@ export const dealMutations = { }); // order notification - // const stage = await models.Stages.getStage(clone.stageId); - - // graphqlPubsub.publish(`salesPipelinesChanged:${stage.pipelineId}`, { - // salesPipelinesChanged: { - // _id: stage.pipelineId, - // proccessId, - // action: 'itemAdd', - // data: { - // item: { - // ...clone._doc, - // ...(await itemResolver(models, subdomain, user, type, clone)), - // }, - // aboveItemId: _id, - // destinationStageId: stage._id, - // }, - // }, - // }); + const stage = await models.Stages.getStage(clone.stageId); + + graphqlPubsub.publish(`salesPipelinesChanged:${stage.pipelineId}`, { + salesPipelinesChanged: { + _id: stage.pipelineId, + proccessId, + action: 'itemAdd', + data: { + item: { + ...clone, + ...(await itemResolver(models, user, clone)), + }, + aboveItemId: _id, + destinationStageId: stage._id, + }, + }, + }); // await publishHelperItemsConformities(clone, stage); @@ -455,10 +457,10 @@ export const dealMutations = { { stageId, proccessId }: { stageId: string; proccessId: string }, { user, models }: IContext, ) { - // const items = await models.Deals.find({ - // stageId, - // status: { $ne: SALES_STATUSES.ARCHIVED }, - // }).lean(); + const deals = await models.Deals.find({ + stageId, + status: { $ne: SALES_STATUSES.ARCHIVED }, + }).lean(); await models.Deals.updateMany( { stageId }, @@ -466,34 +468,34 @@ export const dealMutations = { ); // order notification - // const stage = await models.Stages.getStage(stageId); - - // for (const item of items) { - // await putActivityLog(subdomain, { - // action: 'createArchiveLog', - // data: { - // item, - // contentType: type, - // action: 'archive', - // userId: user._id, - // createdBy: user._id, - // contentId: item._id, - // content: 'archived', - // }, - // }); - - // graphqlPubsub.publish(`salesPipelinesChanged:${stage.pipelineId}`, { - // salesPipelinesChanged: { - // _id: stage.pipelineId, - // proccessId, - // action: 'itemsRemove', - // data: { - // item, - // destinationStageId: stage._id, - // }, - // }, - // }); - // } + const stage = await models.Stages.getStage(stageId); + + for (const deal of deals) { + // await putActivityLog(subdomain, { + // action: 'createArchiveLog', + // data: { + // item, + // contentType: type, + // action: 'archive', + // userId: user._id, + // createdBy: user._id, + // contentId: item._id, + // content: 'archived', + // }, + // }); + + graphqlPubsub.publish(`salesPipelinesChanged:${stage.pipelineId}`, { + salesPipelinesChanged: { + _id: stage.pipelineId, + proccessId, + action: 'itemsRemove', + data: { + item: deal, + destinationStageId: stage._id, + }, + }, + }); + } return 'ok'; }, @@ -541,42 +543,36 @@ export const dealMutations = { const updatedItem = (await models.Deals.findOne({ _id: dealId })) || ({} as any); - // graphqlPubsub.publish(`salesPipelinesChanged:${stage.pipelineId}`, { - // salesPipelinesChanged: { - // _id: stage.pipelineId, - // proccessId, - // action: 'itemUpdate', - // data: { - // item: { - // ...updatedItem, - // ...(await itemResolver( - // models, - // subdomain, - // user, - // 'deal', - // updatedItem, - // )), - // }, - // }, - // }, - // }); + graphqlPubsub.publish(`salesPipelinesChanged:${stage.pipelineId}`, { + salesPipelinesChanged: { + _id: stage.pipelineId, + proccessId, + action: 'itemUpdate', + data: { + item: { + ...updatedItem, + ...(await itemResolver(models, user, updatedItem)), + }, + }, + }, + }); const dataIds = (updatedItem.productsData || []) .filter((pd) => !oldDataIds.includes(pd._id)) .map((pd) => pd._id); - // graphqlPubsub.publish(`salesProductsDataChanged:${dealId}`, { - // salesProductsDataChanged: { - // _id: dealId, - // proccessId, - // action: 'create', - // data: { - // dataIds, - // docs, - // productsData, - // }, - // }, - // }); + graphqlPubsub.publish(`salesProductsDataChanged:${dealId}`, { + salesProductsDataChanged: { + _id: dealId, + proccessId, + action: 'create', + data: { + dataIds, + docs, + productsData, + }, + }, + }); return { dataIds, @@ -614,42 +610,36 @@ export const dealMutations = { await models.Deals.updateOne({ _id: dealId }, { $set: { productsData } }); - // const stage = await models.Stages.getStage(deal.stageId); - // const updatedItem = - // (await models.Deals.findOne({ _id: dealId })) || ({} as any); + const stage = await models.Stages.getStage(deal.stageId); + const updatedItem = + (await models.Deals.findOne({ _id: dealId })) || ({} as any); - // graphqlPubsub.publish(`salesPipelinesChanged:${stage.pipelineId}`, { - // salesPipelinesChanged: { - // _id: stage.pipelineId, - // proccessId, - // action: 'itemUpdate', - // data: { - // item: { - // ...updatedItem, - // ...(await itemResolver( - // models, - // subdomain, - // user, - // 'deal', - // updatedItem, - // )), - // }, - // }, - // }, - // }); + graphqlPubsub.publish(`salesPipelinesChanged:${stage.pipelineId}`, { + salesPipelinesChanged: { + _id: stage.pipelineId, + proccessId, + action: 'itemUpdate', + data: { + item: { + ...updatedItem, + ...(await itemResolver(models, user, updatedItem)), + }, + }, + }, + }); - // graphqlPubsub.publish(`salesProductsDataChanged:${dealId}`, { - // salesProductsDataChanged: { - // _id: dealId, - // proccessId, - // action: 'edit', - // data: { - // dataId, - // doc, - // productsData, - // }, - // }, - // }); + graphqlPubsub.publish(`salesProductsDataChanged:${dealId}`, { + salesProductsDataChanged: { + _id: dealId, + proccessId, + action: 'edit', + data: { + dataId, + doc, + productsData, + }, + }, + }); return { dataId, @@ -686,41 +676,35 @@ export const dealMutations = { await models.Deals.updateOne({ _id: dealId }, { $set: { productsData } }); - // const stage = await models.Stages.getStage(deal.stageId); - // const updatedItem = - // (await models.Deals.findOne({ _id: dealId })) || ({} as any); + const stage = await models.Stages.getStage(deal.stageId); + const updatedItem = + (await models.Deals.findOne({ _id: dealId })) || ({} as any); - // graphqlPubsub.publish(`salesPipelinesChanged:${stage.pipelineId}`, { - // salesPipelinesChanged: { - // _id: stage.pipelineId, - // proccessId, - // action: 'itemUpdate', - // data: { - // item: { - // ...updatedItem, - // ...(await itemResolver( - // models, - // subdomain, - // user, - // 'deal', - // updatedItem, - // )), - // }, - // }, - // }, - // }); + graphqlPubsub.publish(`salesPipelinesChanged:${stage.pipelineId}`, { + salesPipelinesChanged: { + _id: stage.pipelineId, + proccessId, + action: 'itemUpdate', + data: { + item: { + ...updatedItem, + ...(await itemResolver(models, user, updatedItem)), + }, + }, + }, + }); - // graphqlPubsub.publish(`salesProductsDataChanged:${dealId}`, { - // salesProductsDataChanged: { - // _id: dealId, - // proccessId, - // action: 'delete', - // data: { - // dataId, - // productsData, - // }, - // }, - // }); + graphqlPubsub.publish(`salesProductsDataChanged:${dealId}`, { + salesProductsDataChanged: { + _id: dealId, + proccessId, + action: 'delete', + data: { + dataId, + productsData, + }, + }, + }); return { dataId, diff --git a/backend/plugins/sales_api/src/modules/sales/graphql/resolvers/mutations/stages.ts b/backend/plugins/sales_api/src/modules/sales/graphql/resolvers/mutations/stages.ts index ea4184079e..fbb89d44f9 100644 --- a/backend/plugins/sales_api/src/modules/sales/graphql/resolvers/mutations/stages.ts +++ b/backend/plugins/sales_api/src/modules/sales/graphql/resolvers/mutations/stages.ts @@ -1,4 +1,5 @@ import { IOrderInput } from 'erxes-api-shared/core-types'; +import { graphqlPubsub } from 'erxes-api-shared/utils'; import { IContext } from '~/connectionResolvers'; import { IStageDocument } from '~/modules/sales/@types'; import { bulkUpdateOrders } from '~/modules/sales/utils'; @@ -96,18 +97,18 @@ export const stageMutations = { } } - // const stage = await models.Stages.getStage(stageId); + const stage = await models.Stages.getStage(stageId); - // graphqlPubsub.publish(`salesPipelinesChanged:${stage.pipelineId}`, { - // salesPipelinesChanged: { - // _id: stage.pipelineId, - // proccessId, - // action: "reOrdered", - // data: { - // destinationStageId: stageId - // } - // } - // }); + graphqlPubsub.publish(`salesPipelinesChanged:${stage.pipelineId}`, { + salesPipelinesChanged: { + _id: stage.pipelineId, + proccessId, + action: 'reOrdered', + data: { + destinationStageId: stageId, + }, + }, + }); return 'ok'; }, diff --git a/backend/plugins/sales_api/src/modules/sales/utils.ts b/backend/plugins/sales_api/src/modules/sales/utils.ts index 71de3ddf61..9c6054d9cd 100644 --- a/backend/plugins/sales_api/src/modules/sales/utils.ts +++ b/backend/plugins/sales_api/src/modules/sales/utils.ts @@ -5,6 +5,7 @@ import { cursorPaginate, getNextMonth, getToday, + graphqlPubsub, regexSearchText, sendTRPCMessage, USER_ROLES, @@ -13,6 +14,7 @@ import { import moment from 'moment'; import { DeleteResult } from 'mongoose'; import * as _ from 'underscore'; +import resolvers from '~/apollo/resolvers'; import { IModels } from '~/connectionResolvers'; import { IArchiveArgs, @@ -26,6 +28,26 @@ import { import { CLOSE_DATE_TYPES, SALES_STATUSES } from './constants'; import { generateFilter } from './graphql/resolvers/queries/deals'; +export const itemResolver = async (models: IModels, user: any, item: IDeal) => { + const additionalInfo = {}; + const resolver = resolvers['Deal'] || {}; + + for (const subResolver of Object.keys(resolver)) { + try { + additionalInfo[subResolver] = await resolver[subResolver]( + item, + {}, + { models, user }, + { isSubscription: true }, + ); + } catch (unused) { + continue; + } + } + + return additionalInfo; +}; + export const configReplacer = (config) => { const now = new Date(); @@ -1159,58 +1181,58 @@ export const itemsEdit = async ( // user // ); - // const updatedStage = await models.Stages.getStage(updatedItem.stageId); + const updatedStage = await models.Stages.getStage(updatedItem.stageId); - // if (doc.tagIds || doc.startDate || doc.closeDate || doc.name) { - // graphqlPubsub.publish(`salesPipelinesChanged:${stage.pipelineId}`, { - // salesPipelinesChanged: { - // _id: stage.pipelineId, - // }, - // }); - // } + if (doc.tagIds || doc.startDate || doc.closeDate || doc.name) { + graphqlPubsub.publish(`salesPipelinesChanged:${stage.pipelineId}`, { + salesPipelinesChanged: { + _id: stage.pipelineId, + }, + }); + } - // if (updatedStage.pipelineId !== stage.pipelineId) { - // graphqlPubsub.publish(`salesPipelinesChanged:${stage.pipelineId}`, { - // salesPipelinesChanged: { - // _id: stage.pipelineId, - // proccessId, - // action: "itemRemove", - // data: { - // item: oldItem, - // oldStageId: stage._id, - // }, - // }, - // }); - // graphqlPubsub.publish(`salesPipelinesChanged:${stage.pipelineId}`, { - // salesPipelinesChanged: { - // _id: updatedStage.pipelineId, - // proccessId, - // action: "itemAdd", - // data: { - // item: { - // ...updatedItem._doc, - // ...(await itemResolver(models, subdomain, user, type, updatedItem)), - // }, - // aboveItemId: "", - // destinationStageId: updatedStage._id, - // }, - // }, - // }); - // } else { - // graphqlPubsub.publish(`salesPipelinesChanged:${stage.pipelineId}`, { - // salesPipelinesChanged: { - // _id: stage.pipelineId, - // proccessId, - // action: "itemUpdate", - // data: { - // item: { - // ...updatedItem._doc, - // ...(await itemResolver(models, subdomain, user, type, updatedItem)), - // }, - // }, - // }, - // }); - // } + if (updatedStage.pipelineId !== stage.pipelineId) { + graphqlPubsub.publish(`salesPipelinesChanged:${stage.pipelineId}`, { + salesPipelinesChanged: { + _id: stage.pipelineId, + proccessId, + action: 'itemRemove', + data: { + item: oldItem, + oldStageId: stage._id, + }, + }, + }); + graphqlPubsub.publish(`salesPipelinesChanged:${stage.pipelineId}`, { + salesPipelinesChanged: { + _id: updatedStage.pipelineId, + proccessId, + action: 'itemAdd', + data: { + item: { + ...updatedItem._doc, + ...(await itemResolver(models, user, updatedItem)), + }, + aboveItemId: '', + destinationStageId: updatedStage._id, + }, + }, + }); + } else { + graphqlPubsub.publish(`salesPipelinesChanged:${stage.pipelineId}`, { + salesPipelinesChanged: { + _id: stage.pipelineId, + proccessId, + action: 'itemUpdate', + data: { + item: { + ...updatedItem._doc, + ...(await itemResolver(models, user, updatedItem)), + }, + }, + }, + }); + } // await doScoreCampaign(subdomain, models, _id, updatedItem); @@ -1315,17 +1337,17 @@ export const changeItemStatus = async ( }, ) => { if (status === 'archived') { - // graphqlPubsub.publish(`salesPipelinesChanged:${stage.pipelineId}`, { - // salesPipelinesChanged: { - // _id: stage.pipelineId, - // proccessId, - // action: "itemRemove", - // data: { - // item, - // oldStageId: item.stageId, - // }, - // }, - // }); + graphqlPubsub.publish(`salesPipelinesChanged:${stage.pipelineId}`, { + salesPipelinesChanged: { + _id: stage.pipelineId, + proccessId, + action: 'itemRemove', + data: { + item, + oldStageId: item.stageId, + }, + }, + }); return; } @@ -1354,21 +1376,21 @@ export const changeItemStatus = async ( }, ); - // graphqlPubsub.publish(`salesPipelinesChanged:${stage.pipelineId}`, { - // salesPipelinesChanged: { - // _id: stage.pipelineId, - // proccessId, - // action: "itemAdd", - // data: { - // item: { - // ...item._doc, - // ...(await itemResolver(models, subdomain, user, type, item)), - // }, - // aboveItemId, - // destinationStageId: item.stageId, - // }, - // }, - // }); + graphqlPubsub.publish(`salesPipelinesChanged:${stage.pipelineId}`, { + salesPipelinesChanged: { + _id: stage.pipelineId, + proccessId, + action: 'itemAdd', + data: { + item: { + ...item._doc, + ...(await itemResolver(models, user, item)), + }, + aboveItemId, + destinationStageId: item.stageId, + }, + }, + }); }; /** diff --git a/scripts/create-backend-plugin.js b/scripts/create-backend-plugin.js index 403838080e..afdb6f4995 100644 --- a/scripts/create-backend-plugin.js +++ b/scripts/create-backend-plugin.js @@ -248,7 +248,7 @@ import { I${capitalizedModuleName}Document } from '@/${moduleName}/@types/${modu import mongoose from 'mongoose'; -import { load${capitalizedModuleName}Class, I${capitalizedModuleName}Model } from '@/${moduleName}/db/models/${moduleName}'; +import { load${capitalizedModuleName}Class, I${capitalizedModuleName}Model } from '@/${moduleName}/db/models/${capitalizedModuleName}'; export interface IModels { ${capitalizedModuleName}: I${capitalizedModuleName}Model;