${faker.lorem.sentences()}
\n`, + }); + + const segment = await Segments.createSegment({ + name: 'Happy customers', + description: faker.lorem.sentence(), + contentType: 'customer', + color: faker.internet.color(), + subOf: '', + conditions: [], + }); + + const docAutoMessage = { + kind: 'visitorAuto', + title: 'Visitor auto message', + fromUserId: randomUser[0]._id, + segmentIds: [segment._id], + brandIds: [brand._id], + tagIds: [], + isLive: false, + isDraft: true, + }; + + const docAutoEmail = { + kind: 'auto', + title: 'Auto email every friday', + fromUserId: randomUser[0]._id, + segmentIds: [segment._id], + brandIds: [brand._id], + tagIds: [], + isLive: false, + isDraft: true, + email: { + subject: faker.lorem.sentence(), + sender: faker.internet.email(), + replyTo: faker.internet.email(), + content: faker.lorem.paragraphs(), + attachments: [], + templateId: template._id, + }, + scheduleDate: { + type: '5', + month: '', + day: '', + }, + method: 'email', + }; + + await EngageMessages.createEngageMessage(docAutoMessage); + await EngageMessages.createEngageMessage(docAutoEmail); + + console.log('Finished: Engages'); + + // Growth Hack + + console.log('Creating: Growth Hack'); + + const growthForm = await createForms('growthHack', admin); + const growthBoard = await Boards.createBoard({ name: faker.random.word(), type: 'growthHack', userId: admin._id }); + + const pipelineTemplate = await PipelineTemplates.createPipelineTemplate( + { + name: faker.random.word(), + description: faker.lorem.sentence(), + type: 'growthHack', + }, + [ + { + _id: faker.unique, + name: faker.random.word(), + formId: growthForm._id, + }, + ], + ); + + const growthHackDock = { + name: faker.random.word(), + startDate: faker.date.past(), + endDate: faker.date.future(), + visibility: 'public', + type: 'growthHack', + boardId: growthBoard._id, + memberIds: [], + bgColor: faker.internet.color(), + templateId: pipelineTemplate._id, + hackScoringType: 'rice', + metric: 'monthly-active-users', + }; + await Pipelines.createPipeline(growthHackDock); + + console.log('Finished: Growth Hack'); + + await disconnect(); + + console.log('admin email: admin@erxes.io'); + console.log('admin password: ', newPwd); + + process.exit(); +}; + +const createXlsStream = async (fileName: string): Promise<{ fieldNames: string[]; datas: any[] }> => { + return new Promise(async (resolve, reject) => { + let rowCount = 0; + + const usedSheets: any[] = []; + + const xlsxReader = XlsxStreamReader(); + + try { + const stream = fs.createReadStream(`./src/initialData/xls/${fileName}`); + + stream.pipe(xlsxReader); + + xlsxReader.on('worksheet', workSheetReader => { + if (workSheetReader > 1) { + return workSheetReader.skip(); + } + + workSheetReader.on('row', row => { + if (rowCount > 100000) { + return reject(new Error('You can only import 100000 rows one at a time')); + } + + if (row.values.length > 0) { + usedSheets.push(row.values); + rowCount++; + } + }); + + workSheetReader.process(); + }); + + xlsxReader.on('end', () => { + const compactedRows: any = []; + + for (const row of usedSheets) { + if (row.length > 0) { + row.shift(); + + compactedRows.push(row); + } + } + + const fieldNames = usedSheets[0]; + + // Removing column + compactedRows.shift(); + + return resolve({ fieldNames, datas: compactedRows }); + }); + + xlsxReader.on('error', error => { + return reject(error); + }); + } catch (e) { + reject(e); + } + }); +}; + +const readXlsFile = async (fileName: string, type: string, user: any) => { + try { + let fieldNames: string[] = []; + let datas: any[] = []; + let result: any = {}; + + result = await createXlsStream(fileName); + + fieldNames = result.fieldNames; + datas = result.datas; + + if (datas.length === 0) { + throw new Error('Please import at least one row of data'); + } + + const properties = await checkFieldNames(type, fieldNames); + + const importHistoryId = await ImportHistory.create({ + contentType: type, + total: datas.length, + userId: user._id, + date: Date.now(), + }); + + return { properties, result: datas, type, importHistoryId }; + } catch (e) { + debugWorkers(e.message); + throw e; + } +}; + +const insertToDB = async xlsData => { + const { user, scopeBrandIds, result, contentType, properties, importHistoryId } = xlsData; + let create: any = null; + let model: any = null; + + const isBoardItem = (): boolean => contentType === 'deal' || contentType === 'task' || contentType === 'ticket'; + + switch (contentType) { + case 'company': + create = Companies.createCompany; + model = Companies; + break; + case 'customer': + create = Customers.createCustomer; + model = Customers; + break; + case 'lead': + create = Customers.createCustomer; + model = Customers; + break; + case 'product': + create = Products.createProduct; + model = Products; + break; + case 'deal': + create = Deals.createDeal; + break; + case 'task': + create = Tasks.createTask; + break; + case 'ticket': + create = Tickets.createTicket; + break; + default: + break; + } + + for (const fieldValue of result) { + // Import history result statistics + const inc: { success: number; failed: number; percentage: number } = { + success: 0, + failed: 0, + percentage: 100, + }; + + // Collecting errors + const errorMsgs: string[] = []; + + const doc: any = { + scopeBrandIds, + customFieldsData: [], + }; + + let colIndex: number = 0; + let boardName: string = ''; + let pipelineName: string = ''; + let stageName: string = ''; + + // Iterating through detailed properties + for (const property of properties) { + const value = (fieldValue[colIndex] || '').toString(); + + switch (property.type) { + case 'customProperty': + { + doc.customFieldsData.push({ + field: property.id, + value: fieldValue[colIndex], + }); + } + break; + + case 'customData': + { + doc[property.name] = value; + } + break; + + case 'ownerEmail': + { + const userEmail = value; + + const owner = await Users.findOne({ email: userEmail }).lean(); + + doc[property.name] = owner ? owner._id : ''; + } + break; + + case 'pronoun': + { + doc.sex = generatePronoun(value); + } + break; + + case 'companiesPrimaryNames': + { + doc.companiesPrimaryNames = value.split(','); + } + break; + + case 'customersPrimaryEmails': + doc.customersPrimaryEmails = value.split(','); + break; + + case 'state': + doc.state = value; + break; + + case 'boardName': + boardName = value; + break; + + case 'pipelineName': + pipelineName = value; + break; + + case 'stageName': + stageName = value; + break; + + case 'tag': + { + const tagName = value; + + const tag = await Tags.findOne({ name: new RegExp(`.*${tagName}.*`, 'i') }).lean(); + + doc[property.name] = tag ? [tag._id] : []; + } + break; + + case 'basic': + { + doc[property.name] = value; + + if (property.name === 'primaryName' && value) { + doc.names = [value]; + } + + if (property.name === 'primaryEmail' && value) { + doc.emails = [value]; + } + + if (property.name === 'primaryPhone' && value) { + doc.phones = [value]; + } + + if (property.name === 'phones' && value) { + doc.phones = value.split(','); + } + + if (property.name === 'emails' && value) { + doc.emails = value.split(','); + } + + if (property.name === 'names' && value) { + doc.names = value.split(','); + } + + if (property.name === 'isComplete') { + doc.isComplete = Boolean(value); + } + } + break; + } // end property.type switch + + colIndex++; + } // end properties for loop + + if ((contentType === 'customer' || contentType === 'lead') && !doc.emailValidationStatus) { + doc.emailValidationStatus = 'unknown'; + } + + if ((contentType === 'customer' || contentType === 'lead') && !doc.phoneValidationStatus) { + doc.phoneValidationStatus = 'unknown'; + } + + // set board item created user + if (isBoardItem()) { + doc.userId = user._id; + + if (boardName && pipelineName && stageName) { + const board = await Boards.findOne({ name: boardName, type: contentType }); + const pipeline = await Pipelines.findOne({ boardId: board && board._id, name: pipelineName }); + const stage = await Stages.findOne({ pipelineId: pipeline && pipeline._id, name: stageName }); + + doc.stageId = stage && stage._id; + } + } + + await create(doc, user) + .then(async cocObj => { + if (doc.companiesPrimaryNames && doc.companiesPrimaryNames.length > 0 && contentType !== 'company') { + const companyIds: string[] = []; + + for (const primaryName of doc.companiesPrimaryNames) { + let company = await Companies.findOne({ primaryName }).lean(); + + if (company) { + companyIds.push(company._id); + } else { + company = await Companies.createCompany({ primaryName }); + companyIds.push(company._id); + } + } + + for (const _id of companyIds) { + await Conformities.addConformity({ + mainType: contentType === 'lead' ? 'customer' : contentType, + mainTypeId: cocObj._id, + relType: 'company', + relTypeId: _id, + }); + } + } + + if (doc.customersPrimaryEmails && doc.customersPrimaryEmails.length > 0 && contentType !== 'customer') { + const customers = await Customers.find({ primaryEmail: { $in: doc.customersPrimaryEmails } }, { _id: 1 }); + const customerIds = customers.map(customer => customer._id); + + for (const _id of customerIds) { + await Conformities.addConformity({ + mainType: contentType === 'lead' ? 'customer' : contentType, + mainTypeId: cocObj._id, + relType: 'customer', + relTypeId: _id, + }); + } + } + + await ImportHistory.updateOne({ _id: importHistoryId }, { $push: { ids: [cocObj._id] } }); + + // Increasing success count + inc.success++; + }) + .catch(async (e: Error) => { + const updatedDoc = clearEmptyValues(doc); + + // Increasing failed count and pushing into error message + + switch (e.message) { + case 'Duplicated email': + inc.success++; + await updateDuplicatedValue(model, 'primaryEmail', updatedDoc); + break; + case 'Duplicated phone': + inc.success++; + await updateDuplicatedValue(model, 'primaryPhone', updatedDoc); + break; + case 'Duplicated name': + inc.success++; + await updateDuplicatedValue(model, 'primaryName', updatedDoc); + break; + default: + inc.failed++; + errorMsgs.push(e.message); + break; + } + }); + + await ImportHistory.updateOne({ _id: importHistoryId }, { $inc: inc, $push: { errorMsgs } }); + + let importHistory = await ImportHistory.findOne({ _id: importHistoryId }); + + if (!importHistory) { + throw new Error('Could not find import history'); + } + + if (importHistory.failed + importHistory.success === importHistory.total) { + await ImportHistory.updateOne({ _id: importHistoryId }, { $set: { status: 'Done', percentage: 100 } }); + + importHistory = await ImportHistory.findOne({ _id: importHistoryId }); + } + + if (!importHistory) { + throw new Error('Could not find import history'); + } + } +}; + +const populateStages = async type => { + const stages: IPipelineStage[] = []; + + for (let i = 0; i < 5; i++) { + const stage: IPipelineStage = { _id: faker.unique, name: faker.random.word(), type, pipelineId: '' }; + stages.push(stage); + } + return stages; +}; + +const createForms = async (type: string, user: any) => { + const form = await Forms.createForm( + { + title: faker.random.word(), + description: faker.lorem.sentence(), + buttonText: faker.random.word(), + type, + }, + user._id, + ); + + const validations = ['datetime', 'date', 'email', 'number', 'phone']; + + let order = 0; + + for (const validation of validations) { + let text = faker.random.word(); + + if (validation === 'email') { + text = 'email'; + } else if (validation === 'phone') { + text = 'phone number'; + } + + await Fields.createField({ + contentTypeId: form._id, + contentType: 'form', + type: 'input', + validation, + text, + description: faker.random.word(), + order, + }); + order++; + } + + return form; +}; + +main(); diff --git a/src/commands/runEsCommand.ts b/src/commands/runEsCommand.ts new file mode 100644 index 000000000..056ed2591 --- /dev/null +++ b/src/commands/runEsCommand.ts @@ -0,0 +1,33 @@ +import { client, getMappings } from '../elasticsearch'; + +const argv = process.argv; + +/* + * yarn run runEsCommand deleteByQuery '{"index":"erxes_office__events","body":{"query":{"match":{"customerId":"CX2BFBGDEHFehNT8y"}}}}' + */ +const main = async () => { + if (argv.length === 4) { + const body = argv.pop() || '{}'; + const action = argv.pop(); + + try { + if (action === 'getMapping') { + const mappingResponse = await getMappings(JSON.parse(body).index); + return console.log(JSON.stringify(mappingResponse)); + } + + const response = await client[action](JSON.parse(body)); + console.log(JSON.stringify(response)); + } catch (e) { + console.log(e); + } + } +}; + +main() + .then(() => { + console.log('done ...'); + }) + .catch(e => { + console.log(e.message); + }); diff --git a/src/commands/trackTelemetry.ts b/src/commands/trackTelemetry.ts new file mode 100644 index 000000000..3265d8295 --- /dev/null +++ b/src/commands/trackTelemetry.ts @@ -0,0 +1,12 @@ +import * as telemetry from 'erxes-telemetry'; + +const command = async () => { + const argv = process.argv; + const message = argv.pop(); + + telemetry.trackCli('installation_status', { message }); + + process.exit(); +}; + +command(); diff --git a/src/cronJobs/activityLogs.ts b/src/cronJobs/activityLogs.ts index 87f98ae34..0c7576b50 100644 --- a/src/cronJobs/activityLogs.ts +++ b/src/cronJobs/activityLogs.ts @@ -1,20 +1,30 @@ +import * as dotenv from 'dotenv'; import * as schedule from 'node-schedule'; -import QueryBuilder from '../data/modules/segments/queryBuilder'; -import { ActivityLogs, Customers, Segments } from '../db/models'; +import { fetchBySegments } from '../data/modules/segments/queryBuilder'; +import { connect } from '../db/connection'; +import { ActivityLogs, Companies, Customers, Segments } from '../db/models'; /** * Send conversation messages to customer */ +dotenv.config(); + export const createActivityLogsFromSegments = async () => { + await connect(); const segments = await Segments.find({}); for (const segment of segments) { - const selector = await QueryBuilder.segments(segment); - const customers = await Customers.find(selector); + const ids = await fetchBySegments(segment); + + const customers = await Customers.find({ _id: { $in: ids } }, { _id: 1 }); + const customerIds = customers.map(c => c._id); + + const companies = await Companies.find({ _id: { $in: ids } }, { _id: 1 }); + const companyIds = companies.map(c => c._id); + + await ActivityLogs.createSegmentLog(segment, customerIds, 'customer'); - for (const customer of customers) { - await ActivityLogs.createSegmentLog(segment, customer); - } + await ActivityLogs.createSegmentLog(segment, companyIds, 'company'); } }; diff --git a/src/cronJobs/conversations.ts b/src/cronJobs/conversations.ts index 1fe446af2..887523ecb 100644 --- a/src/cronJobs/conversations.ts +++ b/src/cronJobs/conversations.ts @@ -1,9 +1,10 @@ import * as moment from 'moment'; import * as schedule from 'node-schedule'; import * as _ from 'underscore'; -import utils from '../data/utils'; +import utils, { IEmailParams } from '../data/utils'; import { Brands, ConversationMessages, Conversations, Customers, Integrations, Users } from '../db/models'; import { IMessageDocument } from '../db/models/definitions/conversationMessages'; +import { debugCrons } from '../debuggers'; /** * Send conversation messages to customer @@ -12,84 +13,116 @@ export const sendMessageEmail = async () => { // new or open conversations const conversations = await Conversations.newOrOpenConversation(); + debugCrons(`Found ${conversations.length} conversations`); + for (const conversation of conversations) { - const customer = await Customers.findOne({ _id: conversation.customerId }); + const customer = await Customers.findOne({ _id: conversation.customerId }).lean(); + const integration = await Integrations.findOne({ _id: conversation.integrationId, }); if (!integration) { - return; + continue; } - const brand = await Brands.findOne({ _id: integration.brandId }); - - if (!customer || !customer.primaryEmail) { - return; + if (!customer || !(customer.emails && customer.emails.length > 0)) { + continue; } + const brand = await Brands.findOne({ _id: integration.brandId }).lean(); + if (!brand) { - return; + continue; } // user's last non answered question - const question: IMessageDocument | any = (await ConversationMessages.getNonAsnweredMessage(conversation._id)) || {}; + const question: IMessageDocument = await ConversationMessages.getNonAsnweredMessage(conversation._id); + + const adminMessages = await ConversationMessages.getAdminMessages(conversation._id); + + if (adminMessages.length < 1) { + continue; + } // generate admin unread answers const answers: any = []; - const adminMessages = await ConversationMessages.getAdminMessages(conversation._id); - for (const message of adminMessages) { - const answer = message; + const answer = { + ...message.toJSON(), + createdAt: new Date(moment(message.createdAt).format('DD MMM YY, HH:mm')), + }; + + const usr = await Users.findOne({ _id: message.userId }).lean(); + + if (usr) { + answer.user = usr; + answer.user.avatar = usr.details.avatar; + answer.user.fullName = usr.details.fullName; + } + + if (message.attachments.length !== 0) { + for (const attachment of message.attachments) { + answer.content = answer.content.concat(``); + } + } // add user object to answer - answers.push({ - ...answer.toJSON(), - user: await Users.findOne({ _id: message.userId }), - createdAt: new Date(moment(answer.createdAt).format('DD MMM YY, HH:mm')), - }); + answers.push(answer); } - if (answers.length < 1) { - return; - } + customer.name = Customers.getCustomerName(customer); - // template data - const data: any = { + const data = { customer, - question: { - ...question.toJSON(), - createdAt: new Date(moment(question.createdAt).format('DD MMM YY, HH:mm')), - }, + question: {}, answers, brand, }; - // add user's signature - const user = await Users.findOne({ _id: answers[0].userId }); - - if (user && user.emailSignatures) { - const signature = await _.find(user.emailSignatures, s => brand._id === s.brandId); - - if (signature) { - data.signature = signature.signature; + if (question) { + const questionData = { + ...question.toJSON(), + createdAt: new Date(moment(question.createdAt).format('DD MMM YY, HH:mm')), + }; + + if (question.attachments.length !== 0) { + for (const attachment of question.attachments) { + questionData.content = questionData.content.concat( + ``, + ); + } } + + data.question = questionData; } - // send email - utils.sendEmail({ - toEmails: [customer.primaryEmail], + const email = customer.primaryEmail || customer.emails[0]; + + const emailOptions: IEmailParams = { + toEmails: [email], title: `Reply from "${brand.name}"`, - template: { + }; + + const emailConfig = brand.emailConfig; + + if (emailConfig && emailConfig.type === 'custom') { + emailOptions.customHtml = emailConfig.template; + emailOptions.customHtmlData = data; + } else { + emailOptions.template = { name: 'conversationCron', - isCustom: true, data, - }, - }); + }; + } + + // send email + + await utils.sendEmail(emailOptions); // mark sent messages as read - ConversationMessages.markSentAsReadMessages(conversation._id); + await ConversationMessages.markSentAsReadMessages(conversation._id); } }; @@ -109,6 +142,8 @@ export default { * └───────────────────────── second (0 - 59, OPTIONAL) */ // every 10 minutes -schedule.scheduleJob('*/10 * * * *', () => { - sendMessageEmail(); +schedule.scheduleJob('*/10 * * * *', async () => { + debugCrons('Ran conversation crons'); + + await sendMessageEmail(); }); diff --git a/src/cronJobs/deals.ts b/src/cronJobs/deals.ts index 9a9d33f00..fae46a15a 100644 --- a/src/cronJobs/deals.ts +++ b/src/cronJobs/deals.ts @@ -1,38 +1,61 @@ import * as moment from 'moment'; import * as schedule from 'node-schedule'; import utils from '../data/utils'; -import { Deals, Pipelines, Stages } from '../db/models'; -import { NOTIFICATION_TYPES } from '../db/models/definitions/constants'; +import { Deals, Pipelines, Stages, Tasks, Tickets, Users } from '../db/models'; /** - * Send notification Deals dueDate + * Send notification Deals, Tasks and Tickets dueDate */ export const sendNotifications = async () => { const now = new Date(); + const collections = { + deal: Deals, + task: Tasks, + ticket: Tickets, + all: ['deal', 'task', 'ticket'], + }; - const deals = await Deals.find({ - closeDate: { - $gte: now, - $lte: moment(now) - .add(24, 'hour') - .toDate(), - }, - }); - - for (const deal of deals) { - const stage = await Stages.getStage(deal.stageId || ''); - const pipeline = await Pipelines.getPipeline(stage.pipelineId || ''); - - const content = `Reminder: '${deal.name}' deal is due in upcoming`; - - utils.sendNotification({ - notifType: NOTIFICATION_TYPES.DEAL_DUE_DATE, - title: content, - content, - link: `/deal/board?id=${pipeline.boardId}&pipelineId=${pipeline._id}`, - // exclude current user - receivers: deal.assignedUserIds || [], + for (const type of collections.all) { + const objects = await collections[type].find({ + closeDate: { + $gte: now, + $lte: moment() + .add(2, 'days') + .toDate(), + }, }); + + for (const object of objects) { + const stage = await Stages.getStage(object.stageId || ''); + const pipeline = await Pipelines.getPipeline(stage.pipelineId || ''); + + const user = await Users.findOne({ _id: object.modifiedBy }); + + if (!user) { + return; + } + + const diffMinute = Math.floor((object.closeDate.getTime() - now.getTime()) / 60000); + + if (Math.abs(diffMinute - (object.reminderMinute || 0)) < 5) { + const content = `${object.name} ${type} is due in upcoming`; + + const url = type === 'ticket' ? `/inbox/${type}/board` : `${type}/board`; + + utils.sendNotification({ + notifType: `${type}DueDate`, + title: content, + content, + action: `Reminder:`, + link: `${url}?id=${pipeline.boardId}&pipelineId=${pipeline._id}&itemId=${object._id}`, + createdUser: user, + // exclude current user + contentType: type, + contentTypeId: object._id, + receivers: object.assignedUserIds || [], + }); + } + } } }; @@ -51,7 +74,8 @@ export default { * │ └──────────────────── minute (0 - 59) * └───────────────────────── second (0 - 59, OPTIONAL) */ -// every day in 23:45:00 -schedule.scheduleJob('0 45 23 * * *', () => { + +// every 5 minutes +schedule.scheduleJob('*/5 * * * *', () => { sendNotifications(); }); diff --git a/src/cronJobs/engages.ts b/src/cronJobs/engages.ts index a9dbcca4a..2cd3b9e13 100644 --- a/src/cronJobs/engages.ts +++ b/src/cronJobs/engages.ts @@ -1,118 +1,127 @@ -import * as moment from 'moment'; import * as schedule from 'node-schedule'; import { send } from '../data/resolvers/mutations/engageUtils'; import { EngageMessages } from '../db/models'; -import { IEngageMessageDocument, IScheduleDate } from '../db/models/definitions/engages'; - -interface IEngageSchedules { - id: string; - job: any; -} - -// Track runtime cron job instances -export const ENGAGE_SCHEDULES: IEngageSchedules[] = []; - -/** - * Update or Remove selected engage message - * @param _id - Engage id - * @param update - Action type - */ -export const updateOrRemoveSchedule = async ({ _id }: { _id: string }, update?: boolean) => { - const selectedIndex = ENGAGE_SCHEDULES.findIndex(engage => engage.id === _id); - - if (selectedIndex === -1) { - return; - } +import { debugCrons } from '../debuggers'; - // Remove selected job instance and update tracker - ENGAGE_SCHEDULES[selectedIndex].job.cancel(); - ENGAGE_SCHEDULES.splice(selectedIndex, 1); +const findMessages = (selector = {}) => { + return EngageMessages.find({ + kind: { $in: ['auto', 'visitorAuto'] }, + isLive: true, + ...selector, + }); +}; - if (!update) { - return; +const runJobs = async messages => { + for (const message of messages) { + await send(message); } +}; - const message = await EngageMessages.findOne({ _id }); +const checkEveryMinuteJobs = async () => { + const messages = await findMessages({ 'scheduleDate.type': 'minute' }); + await runJobs(messages); +}; - if (!message) { - return; - } +const checkHourMinuteJobs = async () => { + debugCrons('Checking every hour jobs ....'); - return createSchedule(message); + const messages = await findMessages({ 'scheduleDate.type': 'hour' }); + + debugCrons(`Found every hour messages ${messages.length}`); + + await runJobs(messages); }; -/** - * Create cron job for an engage message - */ -export const createSchedule = (message: IEngageMessageDocument) => { - const { scheduleDate } = message; +const checkDayJobs = async () => { + debugCrons('Checking every day jobs ....'); - if (scheduleDate) { - const rule = createScheduleRule(scheduleDate); + // every day messages =========== + const everyDayMessages = await findMessages({ 'scheduleDate.type': 'day' }); + await runJobs(everyDayMessages); - const job = schedule.scheduleJob(rule, () => { - send(message); - }); + debugCrons(`Found every day messages ${everyDayMessages.length}`); - // Collect cron job instances - ENGAGE_SCHEDULES.push({ id: message._id, job }); - } -}; + const now = new Date(); + const day = now.getDate(); + const month = now.getMonth() + 1; + const year = now.getFullYear(); -/** - * Create cron job schedule rule - */ -export const createScheduleRule = (scheduleDate: IScheduleDate) => { - if (!scheduleDate || (!scheduleDate.type && !scheduleDate.time)) { - return '0 45 23 * * *'; - } + // every nth day messages ======= + const everyNthDayMessages = await findMessages({ 'scheduleDate.type': day.toString() }); + await runJobs(everyNthDayMessages); - if (!scheduleDate.time) { - return '0 45 23 * * *'; - } + debugCrons(`Found every nth day messages ${everyNthDayMessages.length}`); - const time = moment(new Date(scheduleDate.time)); + // every month messages ======== + let everyMonthMessages = await findMessages({ 'scheduleDate.type': 'month' }); - const hour = time.hour() || '*'; - const minute = time.minute() || '0'; - const month = scheduleDate.month || '*'; + everyMonthMessages = everyMonthMessages.filter(message => { + const { lastRunAt, scheduleDate } = message; - let dayOfWeek = '*'; - let day: string | number = '*'; + if (!lastRunAt) { + return true; + } - // Schedule type day of week [0-6] - if (scheduleDate.type && scheduleDate.type.length === 1) { - dayOfWeek = scheduleDate.type || '*'; - } + // ignore if last run month is this month + if (lastRunAt.getMonth() === month) { + return false; + } - if (scheduleDate.type === 'month' || scheduleDate.type === 'year') { - day = scheduleDate.day || '*'; - } + return scheduleDate && scheduleDate.day === day.toString(); + }); - /* - * * * * * * - ┬ ┬ ┬ ┬ ┬ ┬ - │ │ │ │ │ │ - │ │ │ │ │ └ day of week (0 - 7) (0 or 7 is Sun) - │ │ │ │ └───── month (1 - 12) - │ │ │ └────────── day of month (1 - 31) - │ │ └─────────────── hour (0 - 23) - │ └──────────────────── minute (0 - 59) - └───────────────────────── second (0 - 59, OPTIONAL) - */ - - return `${minute} ${hour} ${day} ${month} ${dayOfWeek}`; -}; + debugCrons(`Found every month messages ${everyMonthMessages.length}`); -const initCronJob = async () => { - const messages = await EngageMessages.find({ - kind: { $in: ['auto', 'visitorAuto'] }, - isLive: true, + await runJobs(everyMonthMessages); + + await EngageMessages.updateMany( + { _id: { $in: everyMonthMessages.map(m => m._id) } }, + { $set: { lastRunAt: new Date() } }, + ); + + // every year messages ======== + let everyYearMessages = await findMessages({ 'scheduleDate.type': 'year' }); + + everyYearMessages = everyYearMessages.filter(message => { + const { lastRunAt, scheduleDate } = message; + + if (!lastRunAt) { + return true; + } + + // ignore if last run year is this year + if (lastRunAt.getFullYear() === year) { + return false; + } + + if (scheduleDate && scheduleDate.month !== month.toString()) { + return false; + } + + return scheduleDate && scheduleDate.day === day.toString(); }); - for (const message of messages) { - createSchedule(message); - } + debugCrons(`Found every year messages ${everyYearMessages.length}`); + + await runJobs(everyYearMessages); + + await EngageMessages.updateMany( + { _id: { $in: everyYearMessages.map(m => m._id) } }, + { $set: { lastRunAt: new Date() } }, + ); }; -initCronJob(); +// every minute at 1sec +schedule.scheduleJob('1 * * * * *', async () => { + await checkEveryMinuteJobs(); +}); + +// every hour at 10min:10sec +schedule.scheduleJob('10 10 * * * *', async () => { + await checkHourMinuteJobs(); +}); + +// every day at 11hour:20min:20sec +schedule.scheduleJob('20 20 11 * * *', async () => { + checkDayJobs(); +}); diff --git a/src/cronJobs/index.ts b/src/cronJobs/index.ts index a0d6cef89..a5ca0e895 100644 --- a/src/cronJobs/index.ts +++ b/src/cronJobs/index.ts @@ -1,72 +1,38 @@ -import * as bodyParser from 'body-parser'; import * as dotenv from 'dotenv'; import * as express from 'express'; import { connect } from '../db/connection'; -import { debugCrons, debugRequest, debugResponse } from '../debuggers'; +import { debugCrons } from '../debuggers'; +import { initMemoryStorage } from '../inmemoryStorage'; +import { initBroker } from '../messageBroker'; import './activityLogs'; import './conversations'; import './deals'; -import { createSchedule, updateOrRemoveSchedule } from './engages'; +import './engages'; +import './integrations'; +import './robot'; // load environment variables dotenv.config(); -// connect to mongo database -connect(); - const app = express(); // for health check -app.get('/status', async (_req, res) => { +app.get('/health', async (_req, res) => { res.end('ok'); }); -app.use(bodyParser.json()); -app.use(bodyParser.urlencoded({ extended: true })); - -app.post('/create-schedule', async (req, res, next) => { - debugRequest(debugCrons, req); - - const { message } = req.body; - - try { - await createSchedule(JSON.parse(message)); - } catch (e) { - debugCrons(`Error while proccessing createSchedule ${e.message}`); - return next(e); - } - - debugResponse(debugCrons, req); - - return res.json({ status: 'ok ' }); -}); - -app.post('/update-or-remove-schedule', async (req, res, next) => { - debugRequest(debugCrons, req); +const { PORT_CRONS = 3600 } = process.env; - const { _id, update } = req.body; - - try { - await updateOrRemoveSchedule(_id, update); - } catch (e) { - debugCrons(`Error while proccessing createSchedule ${e.message}`); - return next(e); - } - - debugResponse(debugCrons, req); - - return res.json({ status: 'ok ' }); -}); - -// Error handling middleware -app.use((error, _req, res, _next) => { - console.error(error.stack); - res.status(500).send(error.message); -}); +app.listen(PORT_CRONS, () => { + // connect to mongo database + connect().then(async () => { + initMemoryStorage(); -const { PORT_CRONS } = process.env; + initBroker(app).catch(e => { + debugCrons(`Error ocurred during broker init ${e.message}`); + }); + }); -app.listen(PORT_CRONS, () => { debugCrons(`Cron Server is now running on ${PORT_CRONS}`); }); diff --git a/src/cronJobs/integrations.ts b/src/cronJobs/integrations.ts new file mode 100644 index 000000000..a4834951c --- /dev/null +++ b/src/cronJobs/integrations.ts @@ -0,0 +1,17 @@ +import * as schedule from 'node-schedule'; +import messageBroker from '../messageBroker'; + +/** + * * * * * * * + * ┬ ┬ ┬ ┬ ┬ ┬ + * │ │ │ │ │ | + * │ │ │ │ │ └ day of week (0 - 7) (0 or 7 is Sun) + * │ │ │ │ └───── month (1 - 12) + * │ │ │ └────────── day of month (1 - 31) + * │ │ └─────────────── hour (0 - 23) + * │ └──────────────────── minute (0 - 59) + * └───────────────────────── second (0 - 59, OPTIONAL) + */ +schedule.scheduleJob('0 0 * * *', () => { + return messageBroker().sendMessage('erxes-api:integrations-notification', { type: 'cronjob' }); +}); diff --git a/src/cronJobs/robot.ts b/src/cronJobs/robot.ts new file mode 100644 index 000000000..c37aa6cd6 --- /dev/null +++ b/src/cronJobs/robot.ts @@ -0,0 +1,44 @@ +import * as schedule from 'node-schedule'; +import { Users } from '../db/models'; +import { OnboardingHistories } from '../db/models/Robot'; +import { debugCrons } from '../debuggers'; +import messageBroker from '../messageBroker'; + +const checkOnboarding = async () => { + const users = await Users.find({}).lean(); + + for (const user of users) { + const status = await OnboardingHistories.userStatus(user._id); + + if (status === 'completed') { + continue; + } + + messageBroker().sendMessage('callPublish', { + name: 'onboardingChanged', + data: { + onboardingChanged: { + userId: user._id, + type: status, + }, + }, + }); + } +}; + +/** + * * * * * * * + * ┬ ┬ ┬ ┬ ┬ ┬ + * │ │ │ │ │ | + * │ │ │ │ │ └ day of week (0 - 7) (0 or 7 is Sun) + * │ │ │ │ └───── month (1 - 12) + * │ │ │ └────────── day of month (1 - 31) + * │ │ └─────────────── hour (0 - 23) + * │ └──────────────────── minute (0 - 59) + * └───────────────────────── second (0 - 59, OPTIONAL) + */ +schedule.scheduleJob('0 45 23 * * *', () => { + debugCrons('Checked onboarding'); + + checkOnboarding(); +}); diff --git a/src/data/constants.ts b/src/data/constants.ts index 030330bc8..a95487817 100644 --- a/src/data/constants.ts +++ b/src/data/constants.ts @@ -1,13 +1,6 @@ export const EMAIL_CONTENT_CLASS = 'erxes-email-content'; export const EMAIL_CONTENT_PLACEHOLDER = ``; -export const INTEGRATION_KIND_CHOICES = { - MESSENGER: 'messenger', - FORM: 'form', - FACEBOOK: 'facebook', - ALL: ['messenger', 'form', 'facebook'], -}; - export const MESSAGE_KINDS = { AUTO: 'auto', VISITOR_AUTO: 'visitorAuto', @@ -32,8 +25,9 @@ export const FORM_FIELDS = { BLANK: '', NUMBER: 'number', DATE: 'date', + DATETIME: 'datetime', EMAIL: 'email', - ALL: ['', 'number', 'date', 'email'], + ALL: ['', 'number', 'date', 'datetime', 'email'], }, }; @@ -41,7 +35,17 @@ export const FIELD_CONTENT_TYPES = { FORM: 'form', CUSTOMER: 'customer', COMPANY: 'company', - ALL: ['form', 'customer', 'company'], + PRODUCT: 'product', + ALL: ['form', 'customer', 'company', 'product'], +}; + +export const EXTEND_FIELDS = { + CUSTOMER: [ + { name: 'tag', label: 'Tag' }, + { name: 'ownerEmail', label: 'Owner' }, + { name: 'companiesPrimaryNames', label: 'Companies' }, + ], + PRODUCT: [{ name: 'categoryCode', label: 'Category Code' }], }; export const COC_LEAD_STATUS_TYPES = [ @@ -71,39 +75,10 @@ export const COC_LIFECYCLE_STATE_TYPES = [ export const FIELDS_GROUPS_CONTENT_TYPES = { CUSTOMER: 'customer', COMPANY: 'company', - ALL: ['customer', 'company'], + PRODUCT: 'product', + ALL: ['customer', 'company', 'product'], }; -export const CUSTOMER_BASIC_INFOS = [ - 'firstName', - 'lastName', - 'primaryEmail', - 'primaryPhone', - 'ownerId', - 'position', - 'department', - 'leadStatus', - 'lifecycleState', - 'hasAuthority', - 'description', - 'doNotDisturb', -]; - -export const COMPANY_BASIC_INFOS = [ - 'primaryName', - 'size', - 'industry', - 'website', - 'plan', - 'primaryEmail', - 'primaryPhone', - 'leadStatus', - 'lifecycleState', - 'businessType', - 'description', - 'doNotDisturb', -]; - export const INSIGHT_BASIC_INFOS = { count: 'Customer count', messageCount: 'Conversation message count', @@ -253,4 +228,83 @@ export const NOTIFICATION_MODULES = [ }, ], }, + { + name: 'customers', + description: 'Customers', + types: [ + { + name: 'customerMention', + text: 'Mention on customer note', + }, + ], + }, + { + name: 'companies', + description: 'Companies', + types: [ + { + name: 'companyMention', + text: 'Mention on company note', + }, + ], + }, ]; + +export const MODULE_NAMES = { + BOARD: 'board', + BOARD_DEAL: 'dealBoards', + BOARD_TASK: 'taskBoards', + BOARD_TICKET: 'ticketBoards', + BOARD_GH: 'growthHackBoards', + PIPELINE_DEAL: 'dealPipelines', + PIPELINE_TASK: 'taskPipelines', + PIPELINE_TICKET: 'ticketPipelines', + PIPELINE_GH: 'growthHackPipelines', + CHECKLIST: 'checklist', + CHECKLIST_ITEM: 'checkListItem', + BRAND: 'brand', + CHANNEL: 'channel', + COMPANY: 'company', + CUSTOMER: 'customer', + DEAL: 'deal', + EMAIL_TEMPLATE: 'emailTemplate', + IMPORT_HISTORY: 'importHistory', + PRODUCT: 'product', + PRODUCT_CATEGORY: 'product-category', + RESPONSE_TEMPLATE: 'responseTemplate', + TAG: 'tag', + TASK: 'task', + TICKET: 'ticket', + PERMISSION: 'permission', + USER: 'user', + KB_TOPIC: 'knowledgeBaseTopic', + KB_CATEGORY: 'knowledgeBaseCategory', + KB_ARTICLE: 'knowledgeBaseArticle', + USER_GROUP: 'userGroup', + INTERNAL_NOTE: 'internalNote', + PIPELINE_LABEL: 'pipelineLabel', + PIPELINE_TEMPLATE: 'pipelineTemplate', + GROWTH_HACK: 'growthHack', + INTEGRATION: 'integration', + SEGMENT: 'segment', + ENGAGE: 'engage', + SCRIPT: 'script', + FIELD: 'field', + WEBHOOK: 'webhook', +}; + +export const RABBITMQ_QUEUES = { + PUT_LOG: 'putLog', + RPC_API_TO_INTEGRATIONS: 'rpc_queue:api_to_integrations', + RPC_API_TO_WORKERS: 'rpc_queue:api_to_workers', + WORKERS: 'workers', +}; + +export const AUTO_BOT_MESSAGES = { + NO_RESPONSE: 'No reply', + CHANGE_OPERATOR: 'The team will reply in message', +}; + +export const BOT_MESSAGE_TYPES = { + SAY_SOMETHING: 'say_something', +}; diff --git a/src/data/dataSources/engages.ts b/src/data/dataSources/engages.ts new file mode 100644 index 000000000..e426b7e59 --- /dev/null +++ b/src/data/dataSources/engages.ts @@ -0,0 +1,85 @@ +import { HTTPCache, RESTDataSource } from 'apollo-datasource-rest'; +import { debugBase } from '../../debuggers'; +import { getSubServiceDomain } from '../utils'; + +export default class EngagesAPI extends RESTDataSource { + constructor() { + super(); + + const ENGAGES_API_DOMAIN = getSubServiceDomain({ name: 'ENGAGES_API_DOMAIN' }); + + this.baseURL = ENGAGES_API_DOMAIN; + this.httpCache = new HTTPCache(); + } + + public didEncounterError(e) { + const error = e.extensions || {}; + const { response } = error; + const { body } = response || { body: e.message }; + + if (e.code === 'ECONNREFUSED' || e.code === 'ENOTFOUND') { + throw new Error('Engages api is not running'); + } + + throw new Error(body); + } + + public async engagesConfigDetail() { + return this.get(`/configs/detail`); + } + + public async engagesUpdateConfigs(configsMap) { + return this.post(`/configs/save`, configsMap); + } + + public async engagesSendTestEmail(params) { + return this.post(`/configs/send-test-email`, params); + } + + public engagesGetVerifiedEmails() { + return this.get(`/configs/get-verified-emails`); + } + + public engagesVerifyEmail(params) { + return this.post(`/configs/verify-email`, params); + } + + public engagesRemoveVerifiedEmail(params) { + return this.post(`/configs/remove-verified-email`, params); + } + + public async engagesStats(engageMessageId) { + try { + const response = await this.get(`/deliveryReports/statsList/${engageMessageId}`); + return response; + } catch (e) { + debugBase(e.message); + return {}; + } + } + + public async engageReportsList(params) { + return this.get(`/deliveryReports/reportsList`, params); + } + + public async engagesLogs(engageMessageId) { + try { + const response = await this.get(`/deliveryReports/logs/${engageMessageId}`); + return response; + } catch (e) { + debugBase(e.message); + return []; + } + } + + public async engagesSmsStats(engageMessageId) { + try { + const response = await this.get(`/deliveryReports/smsStats/${engageMessageId}`); + + return response; + } catch (e) { + debugBase(e.message); + return {}; + } + } +} // end class diff --git a/src/data/dataSources/index.ts b/src/data/dataSources/index.ts new file mode 100644 index 000000000..77f6b23fc --- /dev/null +++ b/src/data/dataSources/index.ts @@ -0,0 +1,4 @@ +import EngagesAPI from './engages'; +import IntegrationsAPI from './integrations'; + +export { EngagesAPI, IntegrationsAPI }; diff --git a/src/data/dataSources/integrations.ts b/src/data/dataSources/integrations.ts new file mode 100644 index 000000000..a421d1695 --- /dev/null +++ b/src/data/dataSources/integrations.ts @@ -0,0 +1,89 @@ +import { HTTPCache, RESTDataSource } from 'apollo-datasource-rest'; +import { getSubServiceDomain } from '../utils'; + +export default class IntegrationsAPI extends RESTDataSource { + constructor() { + super(); + + const INTEGRATIONS_API_DOMAIN = getSubServiceDomain({ name: 'INTEGRATIONS_API_DOMAIN' }); + + this.baseURL = INTEGRATIONS_API_DOMAIN; + this.httpCache = new HTTPCache(); + } + + public willSendRequest(request) { + const { user } = this.context || {}; + + if (user) { + request.headers.set('userId', user._id); + } + } + + public didEncounterError(e) { + const error = e.extensions || {}; + const { response } = error; + const { body } = response || { body: e.message }; + + if (e.code === 'ECONNREFUSED' || e.code === 'ENOTFOUND') { + throw new Error('Integrations api is not running'); + } + + throw new Error(body); + } + + public async createIntegration(kind, params) { + return this.post(`/${kind}/create-integration`, params); + } + + public async removeIntegration(params) { + return this.post('/integrations/remove', params); + } + + public async removeAccount(params) { + return this.post('/accounts/remove', params); + } + + public async replyChatfuel(params) { + return this.post('/chatfuel/reply', params); + } + + public async sendEmail(kind, params) { + return this.post(`/${kind}/send`, params); + } + + public async deleteDailyVideoChatRoom(name) { + return this.delete(`/daily/rooms/${name}`); + } + + public async createDailyVideoChatRoom(params) { + return this.post('/daily/room', params); + } + + public async fetchApi(path, params) { + return this.get(path, params); + } + + public async replyTwitterDm(params) { + return this.post('/twitter/reply', params); + } + + public async replySmooch(params) { + return this.post('/smooch/reply', params); + } + + public async replyWhatsApp(params) { + return this.post('/whatsapp/reply', params); + } + + public async updateConfigs(configsMap) { + return this.post('/update-configs', { configsMap }); + } + + public async createProductBoardNote(params) { + return this.post('/productBoard/create-note', params); + } + + public async sendSms(params) { + return this.post('/telnyx/send-sms', params); + } +} diff --git a/src/data/logUtils.ts b/src/data/logUtils.ts new file mode 100644 index 000000000..89fdffd46 --- /dev/null +++ b/src/data/logUtils.ts @@ -0,0 +1,1344 @@ +import * as _ from 'underscore'; +import { IPipelineDocument } from '../db/models/definitions/boards'; +import { IChannelDocument } from '../db/models/definitions/channels'; +import { ICompanyDocument } from '../db/models/definitions/companies'; +import { ACTIVITY_CONTENT_TYPES } from '../db/models/definitions/constants'; +import { ICustomerDocument } from '../db/models/definitions/customers'; +import { IDealDocument, IProductDocument } from '../db/models/definitions/deals'; +import { IEngageMessage, IEngageMessageDocument } from '../db/models/definitions/engages'; +import { IGrowthHackDocument } from '../db/models/definitions/growthHacks'; +import { IIntegrationDocument } from '../db/models/definitions/integrations'; +import { ICategoryDocument, ITopicDocument } from '../db/models/definitions/knowledgebase'; +import { IPipelineTemplateDocument } from '../db/models/definitions/pipelineTemplates'; +import { IScriptDocument } from '../db/models/definitions/scripts'; +import { ITaskDocument } from '../db/models/definitions/tasks'; +import { ITicketDocument } from '../db/models/definitions/tickets'; +import { IUserDocument } from '../db/models/definitions/users'; +import { + Boards, + Brands, + Checklists, + Companies, + Customers, + Deals, + Forms, + GrowthHacks, + Integrations, + KnowledgeBaseArticles, + KnowledgeBaseCategories, + KnowledgeBaseTopics, + PipelineLabels, + Pipelines, + ProductCategories, + Products, + Segments, + Stages, + Tags, + Tasks, + Tickets, + Users, + UsersGroups, +} from '../db/models/index'; +import messageBroker from '../messageBroker'; +import { MODULE_NAMES, RABBITMQ_QUEUES } from './constants'; +import { getSubServiceDomain, registerOnboardHistory, sendRequest, sendToWebhook } from './utils'; + +export type LogDesc = { + [key: string]: any; +} & { name: any }; + +interface ILogNameParams { + idFields: string[]; + foreignKey: string; + prevList?: LogDesc[]; +} + +interface ILogParams extends ILogNameParams { + collection: any; + nameFields: string[]; +} + +interface IContentTypeParams { + contentType: string; + contentTypeId: string; +} + +/** + * @param object - Previous state of the object + * @param newData - Requested update data + * @param updatedDocument - State after any updates to the object + */ +export interface ILogDataParams { + type: string; + description?: string; + object: any; + newData?: object; + extraDesc?: object[]; + updatedDocument?: any; +} + +interface IFinalLogParams extends ILogDataParams { + action: string; +} + +export interface ILogQueryParams { + start?: string; + end?: string; + userId?: string; + action?: string; + page?: number; + perPage?: number; + type?: string; +} + +interface IDescriptions { + description?: string; + extraDesc?: LogDesc[]; +} + +interface IDescriptionParams { + action: string; + type: string; + obj: any; + updatedDocument?: any; +} + +type BoardItemDocument = IDealDocument | ITaskDocument | ITicketDocument | IGrowthHackDocument; + +const LOG_ACTIONS = { + CREATE: 'create', + UPDATE: 'update', + DELETE: 'delete', +}; + +// used in internalNotes mutations +const findContentItemName = async (contentType: string, contentTypeId: string): Promise tag should be allowed
+ const contentValid = content.indexOf(' {
firstRespondedDate?: Date;
} = {};
- if (!doc.fromBot) {
+ if (!doc.fromBot && !doc.internal) {
modifier.content = doc.content;
}
@@ -92,7 +109,7 @@ export const loadClass = () => {
modifier.firstRespondedDate = new Date();
}
- await Conversations.updateOne({ _id: doc.conversationId }, { $set: modifier });
+ await Conversations.updateConversation(doc.conversationId, modifier);
return this.createMessage({ ...doc, userId });
}
@@ -114,13 +131,22 @@ export const loadClass = () => {
return Messages.find({
conversationId,
userId: { $exists: true },
- isCustomerRead: false,
+ isCustomerRead: { $ne: true },
// exclude internal notes
internal: false,
}).sort({ createdAt: 1 });
}
+ public static widgetsGetUnreadMessagesCount(conversationId: string) {
+ return Messages.countDocuments({
+ conversationId,
+ userId: { $exists: true },
+ internal: false,
+ isCustomerRead: { $ne: true },
+ });
+ }
+
/**
* Mark sent messages as read
*/
@@ -129,7 +155,23 @@ export const loadClass = () => {
{
conversationId,
userId: { $exists: true },
- isCustomerRead: { $exists: false },
+ isCustomerRead: { $ne: true },
+ },
+ { $set: { isCustomerRead: true } },
+ { multi: true },
+ );
+ }
+
+ /**
+ * Force read previous unread engage messages ============
+ */
+ public static forceReadCustomerPreviousEngageMessages(customerId: string) {
+ return Messages.updateMany(
+ {
+ customerId,
+ engageData: { $exists: true },
+ 'engageData.engageKind': { $ne: 'auto' },
+ isCustomerRead: { $ne: true },
},
{ $set: { isCustomerRead: true } },
{ multi: true },
diff --git a/src/db/models/Conversations.ts b/src/db/models/Conversations.ts
index 840ceb034..24ac37c5b 100644
--- a/src/db/models/Conversations.ts
+++ b/src/db/models/Conversations.ts
@@ -1,25 +1,20 @@
import { Model, model } from 'mongoose';
import { ConversationMessages, Users } from '.';
+import { cleanHtml, sendToWebhook } from '../../data/utils';
import { CONVERSATION_STATUSES } from './definitions/constants';
import { IMessageDocument } from './definitions/conversationMessages';
import { conversationSchema, IConversation, IConversationDocument } from './definitions/conversations';
-interface ISTATUSES {
- NEW: 'new';
- OPEN: 'open';
- CLOSED: 'closed';
- ALL_LIST: ['new', 'open', 'closed'];
-}
-
export interface IConversationModel extends Model