├── src ├── static │ └── version.json ├── data │ ├── resolvers │ │ ├── field.ts │ │ ├── queries │ │ │ ├── form.ts │ │ │ ├── index.ts │ │ │ ├── knowledgebase.ts │ │ │ └── messenger.ts │ │ ├── conversationMessage.ts │ │ ├── engage.ts │ │ ├── mutations │ │ │ ├── index.ts │ │ │ ├── knowledgebase.ts │ │ │ ├── lead.ts │ │ │ └── messenger.ts │ │ ├── form.ts │ │ ├── conversation.ts │ │ ├── index.ts │ │ ├── utils │ │ │ ├── email.ts │ │ │ ├── messenger.ts │ │ │ └── engage.ts │ │ ├── knowledgeBase.ts │ │ └── customScalars.ts │ └── schema.ts ├── debuggers.ts ├── db │ ├── models │ │ ├── definitions │ │ │ ├── tasks.ts │ │ │ ├── configs.ts │ │ │ ├── tickets.ts │ │ │ ├── emailTemplates.ts │ │ │ ├── utils.ts │ │ │ ├── responseTemplates.ts │ │ │ ├── pipelineLabels.ts │ │ │ ├── common.ts │ │ │ ├── tags.ts │ │ │ ├── scripts.ts │ │ │ ├── internalNotes.ts │ │ │ ├── channels.ts │ │ │ ├── importHistory.ts │ │ │ ├── growthHacks.ts │ │ │ ├── brands.ts │ │ │ ├── robot.ts │ │ │ ├── pipelineTemplates.ts │ │ │ ├── permissions.ts │ │ │ ├── conformities.ts │ │ │ ├── messengerApps.ts │ │ │ ├── checklists.ts │ │ │ ├── emailDeliveries.ts │ │ │ ├── segments.ts │ │ │ ├── notifications.ts │ │ │ ├── conversations.ts │ │ │ ├── fields.ts │ │ │ ├── conversationMessages.ts │ │ │ ├── knowledgebase.ts │ │ │ ├── forms.ts │ │ │ ├── deals.ts │ │ │ ├── users.ts │ │ │ ├── engages.ts │ │ │ ├── activityLogs.ts │ │ │ ├── companies.ts │ │ │ ├── boards.ts │ │ │ ├── customers.ts │ │ │ └── integrations.ts │ │ ├── Users.ts │ │ ├── Brands.ts │ │ ├── Fields.ts │ │ ├── MessengerApps.ts │ │ ├── Conformities.ts │ │ ├── Engages.ts │ │ ├── Forms.ts │ │ ├── Companies.ts │ │ ├── index.ts │ │ ├── KnowledgeBase.ts │ │ ├── Messages.ts │ │ ├── Conversations.ts │ │ └── Integrations.ts │ ├── connection.ts │ └── factories.ts ├── commands │ └── generateVersion.ts ├── setupTests.ts ├── messageQueue.ts ├── __tests__ │ ├── formDb.test.ts │ ├── companyDb.test.ts │ ├── integrationDb.test.ts │ ├── conversationDb.test.ts │ ├── messengerStatus.test.ts │ ├── engageRules.test.ts │ └── engage.test.ts └── index.ts ├── .prettierrc ├── .github ├── FUNDING.yml └── ISSUE_TEMPLATE │ ├── feature_request.md │ ├── enhancement-request.md │ └── bug_report.md ├── tsconfig.prod.json ├── .dockerignore ├── Dockerfile.dev ├── .gitignore ├── .env.sample ├── Dockerfile ├── tslint.json ├── tsconfig.json ├── scripts └── install.sh ├── jest.config.js ├── .release-it.json ├── .snyk ├── package.json ├── CHANGELOG.md ├── .drone.yml ├── README.md └── wait-for.sh /src/static/version.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "singleQuote": true, 4 | "trailingComma": "all" 5 | } -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | open_collective: erxes 4 | patreon: erxes 5 | -------------------------------------------------------------------------------- /tsconfig.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "dist", "src/__tests__"] 4 | } -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | *.md 2 | *.yml 3 | .env* 4 | .git* 5 | .snyk 6 | Dockerfile* 7 | scripts 8 | scripts 9 | src 10 | *.tar.gz 11 | -------------------------------------------------------------------------------- /Dockerfile.dev: -------------------------------------------------------------------------------- 1 | FROM erxes/runner 2 | WORKDIR /erxes-widgets-api 3 | COPY yarn.lock package.json ./ 4 | RUN yarn install 5 | CMD ["yarn", "dev"] 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | settings.js 3 | server/settings.js 4 | dist 5 | .DS_Store 6 | dump.rdb 7 | npm-debug.log* 8 | .env 9 | coverage 10 | *.un~ 11 | *.swp 12 | -------------------------------------------------------------------------------- /src/data/resolvers/field.ts: -------------------------------------------------------------------------------- 1 | import { IFieldDocument } from '../../db/models'; 2 | 3 | export default { 4 | name(field: IFieldDocument) { 5 | return `erxes-form-field-${field._id}`; 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /src/data/resolvers/queries/form.ts: -------------------------------------------------------------------------------- 1 | import { Forms } from '../../../db/models'; 2 | 3 | export default { 4 | form(_root: any, { formId }: { formId: string }) { 5 | return Forms.findOne({ _id: formId }); 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /src/data/resolvers/conversationMessage.ts: -------------------------------------------------------------------------------- 1 | import { IMessageDocument, Users } from '../../db/models'; 2 | 3 | export default { 4 | user(message: IMessageDocument) { 5 | return Users.findOne({ _id: message.userId }); 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /src/debuggers.ts: -------------------------------------------------------------------------------- 1 | import * as debug from 'debug'; 2 | 3 | export const debugInit = debug('erxes-widgets-api:init'); 4 | export const debugDb = debug('erxes-widgets-api:db'); 5 | export const debugBase = debug('erxes-widgets-api:base'); 6 | -------------------------------------------------------------------------------- /src/data/resolvers/engage.ts: -------------------------------------------------------------------------------- 1 | import { IMessageEngageData, Users } from '../../db/models'; 2 | 3 | export default { 4 | fromUser(engageData: IMessageEngageData) { 5 | return Users.findOne({ _id: engageData.fromUserId }); 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | # general 2 | PORT=3100 3 | NODE_ENV=development 4 | WIDGET_URL=http://localhost:3200 5 | 6 | # MongoDB 7 | MONGO_URL=mongodb://localhost/erxes 8 | TEST_MONGO_URL=mongodb://localhost/erxesApiTest 9 | 10 | RABBITMQ_HOST=amqp://localhost -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:10.16.0-slim 2 | WORKDIR /erxes-widgets-api/ 3 | RUN chown -R node:node /erxes-widgets-api 4 | COPY --chown=node:node . /erxes-widgets-api 5 | USER node 6 | EXPOSE 3100 7 | ENTRYPOINT ["node", "--max_old_space_size=8192", "dist"] 8 | -------------------------------------------------------------------------------- /src/data/resolvers/queries/index.ts: -------------------------------------------------------------------------------- 1 | import form from './form'; 2 | import knowledgeBase from './knowledgebase'; 3 | import messenger from './messenger'; 4 | 5 | export default { 6 | ...form, 7 | ...messenger, 8 | ...knowledgeBase, 9 | }; 10 | -------------------------------------------------------------------------------- /src/data/resolvers/mutations/index.ts: -------------------------------------------------------------------------------- 1 | import knowledgebase from './knowledgebase'; 2 | import lead from './lead'; 3 | import messenger from './messenger'; 4 | 5 | export default { 6 | ...lead, 7 | ...messenger, 8 | ...knowledgebase, 9 | }; 10 | -------------------------------------------------------------------------------- /src/data/resolvers/form.ts: -------------------------------------------------------------------------------- 1 | import { Fields, IFormDocument } from '../../db/models'; 2 | 3 | export default { 4 | fields(form: IFormDocument) { 5 | return Fields.find({ contentType: 'form', contentTypeId: form._id }).sort({ 6 | order: 1, 7 | }); 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /src/data/resolvers/mutations/knowledgebase.ts: -------------------------------------------------------------------------------- 1 | import { KnowledgeBaseArticles } from '../../../db/models'; 2 | 3 | export default { 4 | knowledgebaseIncReactionCount(_root, { articleId, reactionChoice }: { articleId: string; reactionChoice: string }) { 5 | return KnowledgeBaseArticles.incReactionCount(articleId, reactionChoice); 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "tslint:latest", 4 | "tslint-config-prettier" 5 | ], 6 | "rules": { 7 | "object-literal-sort-keys": false, 8 | "no-console": false, 9 | "no-implicit-dependencies": [true, "dev"], 10 | "variable-name": [true, "ban-keywords", "check-format", "allow-leading-underscore"], 11 | "max-classes-per-file": [true, 5] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/db/models/definitions/tasks.ts: -------------------------------------------------------------------------------- 1 | import { Document, Schema } from 'mongoose'; 2 | import { commonItemFieldsSchema, IItemCommonFields } from './boards'; 3 | 4 | export interface ITaskDocument extends IItemCommonFields, Document { 5 | _id: string; 6 | } 7 | 8 | // Mongoose schemas ======================= 9 | export const taskSchema = new Schema({ 10 | ...commonItemFieldsSchema, 11 | }); 12 | -------------------------------------------------------------------------------- /src/db/models/Users.ts: -------------------------------------------------------------------------------- 1 | import { Model, model } from 'mongoose'; 2 | import { IUserDocument, userSchema } from './definitions/users'; 3 | 4 | export interface IUserModel extends Model {} 5 | 6 | export const loadClass = () => { 7 | return userSchema; 8 | }; 9 | 10 | // tslint:disable-next-line 11 | const Users = model('users', userSchema); 12 | 13 | export default Users; 14 | -------------------------------------------------------------------------------- /src/data/resolvers/conversation.ts: -------------------------------------------------------------------------------- 1 | import { Conversations, IConversationDocument, Users } from '../../db/models'; 2 | 3 | export default { 4 | participatedUsers(conversation: IConversationDocument) { 5 | return Users.find({ _id: { $in: conversation.participatedUserIds } }); 6 | }, 7 | 8 | messages(conversation: IConversationDocument) { 9 | return Conversations.getMessages(conversation._id); 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /src/db/models/Brands.ts: -------------------------------------------------------------------------------- 1 | import { Model, model } from 'mongoose'; 2 | import { brandSchema, IBrandDocument } from './definitions/brands'; 3 | 4 | interface IBrandModel extends Model {} 5 | 6 | export const loadClass = () => { 7 | return brandSchema; 8 | }; 9 | 10 | // tslint:disable-next-line 11 | const Brands = model('brands', brandSchema); 12 | 13 | export default Brands; 14 | -------------------------------------------------------------------------------- /src/db/models/Fields.ts: -------------------------------------------------------------------------------- 1 | import { model, Model } from 'mongoose'; 2 | import { fieldSchema, IFieldDocument } from './definitions/fields'; 3 | 4 | interface IFieldModel extends Model {} 5 | 6 | export const loadClass = () => { 7 | return fieldSchema; 8 | }; 9 | 10 | // tslint:disable-next-line 11 | const Fields = model('fields', fieldSchema); 12 | 13 | export default Fields; 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist", 4 | "allowJs": true, 5 | "target": "es5", 6 | "moduleResolution": "node", 7 | "module": "commonjs", 8 | "lib": ["es2015", "es6", "es7"], 9 | "sourceMap": true, 10 | "noUnusedLocals": true, 11 | "noUnusedParameters": true, 12 | "skipLibCheck": true 13 | }, 14 | "include": ["./src/**/*"], 15 | "exclude": ["node_modules", "dist"] 16 | } 17 | -------------------------------------------------------------------------------- /scripts/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo 'Clone erxes-widgets-api repository and install its dependencies:' 4 | git clone https://github.com/erxes/erxes-widgets-api.git 5 | cd erxes-widgets-api 6 | git checkout develop 7 | yarn install 8 | 9 | echo 'Create `.env.sample` from default settings file and configure it on your own:' 10 | cp .env.sample .env 11 | 12 | CURRENT_FOLDER=${PWD##*/} 13 | if [ $CURRENT_FOLDER = 'erxes-widgets-api' ]; then 14 | cd .. 15 | fi -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['/src'], 3 | transform: { 4 | '^.+\\.ts$': 'ts-jest', 5 | }, 6 | testRegex: '/__tests__/.*\\.(ts|js)$', 7 | testEnvironment: 'node', 8 | moduleFileExtensions: ['ts', 'js', 'json', 'node'], 9 | globals: { 10 | 'ts-jest': { 11 | tsConfigFile: 'tsconfig.json', 12 | enableTsDiagnostics: true 13 | }, 14 | }, 15 | setupTestFrameworkScriptFile: './src/setupTests.ts' 16 | }; 17 | -------------------------------------------------------------------------------- /src/db/models/definitions/configs.ts: -------------------------------------------------------------------------------- 1 | import { Document, Schema } from 'mongoose'; 2 | import { field } from './utils'; 3 | 4 | export interface IConfig { 5 | code: string; 6 | value: string[]; 7 | } 8 | 9 | export interface IConfigDocument extends IConfig, Document { 10 | _id: string; 11 | } 12 | 13 | // Mongoose schemas =========== 14 | 15 | export const configSchema = new Schema({ 16 | _id: field({ pkey: true }), 17 | code: field({ type: String }), 18 | value: field({ type: [String] }), 19 | }); 20 | -------------------------------------------------------------------------------- /src/db/models/MessengerApps.ts: -------------------------------------------------------------------------------- 1 | import { Model, model } from 'mongoose'; 2 | import { IMessengerAppDocument, messengerAppSchema } from './definitions/messengerApps'; 3 | 4 | export interface IMessengerAppModel extends Model {} 5 | 6 | export const loadClass = () => { 7 | return messengerAppSchema; 8 | }; 9 | 10 | // tslint:disable-next-line 11 | const MessengerApps = model('messenger_apps', messengerAppSchema); 12 | 13 | export default MessengerApps; 14 | -------------------------------------------------------------------------------- /.release-it.json: -------------------------------------------------------------------------------- 1 | { 2 | "git": { 3 | "tagName": "%s", 4 | "tagAnnotation": "Release ${version}", 5 | "commitMessage": "Release ${version}", 6 | "requireCleanWorkingDir": false 7 | }, 8 | "github": { 9 | "release": true, 10 | "releaseName": "Release ${version}", 11 | "draft": false 12 | }, 13 | "npm": { 14 | "publish": false 15 | }, 16 | "plugins": { 17 | "@release-it/conventional-changelog": { 18 | "preset": "angular", 19 | "infile": "CHANGELOG.md" 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/commands/generateVersion.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as gitRepoInfo from 'git-repo-info'; 3 | import * as path from 'path'; 4 | 5 | const start = () => { 6 | const projectPath = process.cwd(); 7 | const packageVersion = require(path.join(projectPath, 'package.json')).version; 8 | const info = gitRepoInfo(); 9 | const versionInfo = { packageVersion, ...info }; 10 | 11 | fs.writeFile('./dist/static/version.json', JSON.stringify(versionInfo), () => { 12 | console.log('saved'); 13 | }); 14 | }; 15 | 16 | start(); 17 | -------------------------------------------------------------------------------- /src/db/models/Conformities.ts: -------------------------------------------------------------------------------- 1 | import { Model, model } from 'mongoose'; 2 | import { conformitySchema, IConformityDocument } from './definitions/conformities'; 3 | 4 | interface IConformityModel extends Model {} 5 | 6 | export const loadClass = () => { 7 | class Conformity {} 8 | 9 | conformitySchema.loadClass(Conformity); 10 | 11 | return conformitySchema; 12 | }; 13 | 14 | loadClass(); 15 | 16 | // tslint:disable-next-line 17 | export const Conformities = model('conformities', conformitySchema); 18 | -------------------------------------------------------------------------------- /src/db/models/definitions/tickets.ts: -------------------------------------------------------------------------------- 1 | import { Document, Schema } from 'mongoose'; 2 | import { commonItemFieldsSchema, IItemCommonFields } from './boards'; 3 | import { field } from './utils'; 4 | 5 | export interface ITicket extends IItemCommonFields { 6 | source?: string; 7 | } 8 | 9 | export interface ITicketDocument extends ITicket, Document { 10 | _id: string; 11 | } 12 | 13 | // Mongoose schemas ======================= 14 | export const ticketSchema = new Schema({ 15 | ...commonItemFieldsSchema, 16 | 17 | source: field({ type: String }), 18 | }); 19 | -------------------------------------------------------------------------------- /src/db/models/definitions/emailTemplates.ts: -------------------------------------------------------------------------------- 1 | import { Document, Schema } from 'mongoose'; 2 | import { field, schemaWrapper } from './utils'; 3 | 4 | export interface IEmailTemplate { 5 | name: string; 6 | content: string; 7 | } 8 | 9 | export interface IEmailTemplateDocument extends IEmailTemplate, Document { 10 | _id: string; 11 | } 12 | 13 | export const emailTemplateSchema = schemaWrapper( 14 | new Schema({ 15 | _id: field({ pkey: true }), 16 | name: field({ type: String }), 17 | content: field({ type: String, optional: true }), 18 | }), 19 | ); 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: 'type: feature' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/enhancement-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Enhancement request 3 | about: Suggest an enhancement to the erxes project 4 | title: '' 5 | labels: 'type: enhancement' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your enhancement request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the enhancement request here. 18 | -------------------------------------------------------------------------------- /src/db/models/Engages.ts: -------------------------------------------------------------------------------- 1 | import { Model, model } from 'mongoose'; 2 | import { engageMessageSchema, IEngageMessageDocument } from './definitions/engages'; 3 | 4 | interface IEngageMessageModel extends Model {} 5 | 6 | export const loadClass = () => { 7 | class EngageMessage {} 8 | 9 | engageMessageSchema.loadClass(EngageMessage); 10 | 11 | return engageMessageSchema; 12 | }; 13 | 14 | loadClass(); 15 | 16 | // tslint:disable-next-line 17 | export const EngageMessages = model( 18 | 'engage_messages', 19 | engageMessageSchema, 20 | ); 21 | -------------------------------------------------------------------------------- /src/db/models/definitions/utils.ts: -------------------------------------------------------------------------------- 1 | import * as Random from 'meteor-random'; 2 | 3 | /* 4 | * Mongoose field options wrapper 5 | */ 6 | export const field = options => { 7 | const { pkey, type, optional } = options; 8 | 9 | if (type === String && !pkey && !optional) { 10 | options.validate = /\S+/; 11 | } 12 | 13 | // TODO: remove 14 | if (pkey) { 15 | options.type = String; 16 | options.default = () => Random.id(); 17 | } 18 | 19 | return options; 20 | }; 21 | 22 | export const schemaWrapper = schema => { 23 | schema.add({ scopeBrandIds: [String] }); 24 | 25 | return schema; 26 | }; 27 | -------------------------------------------------------------------------------- /src/db/models/definitions/responseTemplates.ts: -------------------------------------------------------------------------------- 1 | import { Document, Schema } from 'mongoose'; 2 | import { field, schemaWrapper } from './utils'; 3 | 4 | export interface IResponseTemplate { 5 | name?: string; 6 | content?: string; 7 | brandId?: string; 8 | files?: string[]; 9 | } 10 | 11 | export interface IResponseTemplateDocument extends IResponseTemplate, Document { 12 | _id: string; 13 | } 14 | 15 | export const responseTemplateSchema = schemaWrapper( 16 | new Schema({ 17 | _id: field({ pkey: true }), 18 | name: field({ type: String }), 19 | content: field({ type: String }), 20 | brandId: field({ type: String }), 21 | files: field({ type: Array }), 22 | }), 23 | ); 24 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv'; 2 | import mongoose = require('mongoose'); 3 | 4 | mongoose.Promise = global.Promise; 5 | 6 | // prevent deprecated warning related findAndModify 7 | // https://github.com/Automattic/mongoose/issues/6880 8 | mongoose.set('useFindAndModify', false); 9 | 10 | // load environment variables 11 | dotenv.config(); 12 | 13 | beforeAll(() => { 14 | jest.setTimeout(30000); 15 | 16 | const { TEST_MONGO_URL } = process.env; 17 | 18 | return mongoose.connect( 19 | TEST_MONGO_URL || 'mongodb://localhost/test', 20 | { useNewUrlParser: true, useCreateIndex: true }, 21 | ); 22 | }); 23 | 24 | afterAll(() => { 25 | return mongoose.connection.db.dropDatabase(); 26 | }); 27 | -------------------------------------------------------------------------------- /src/db/models/definitions/pipelineLabels.ts: -------------------------------------------------------------------------------- 1 | import { Document, Schema } from 'mongoose'; 2 | import { field } from './utils'; 3 | 4 | export interface IPipelineLabel { 5 | name: string; 6 | colorCode: string; 7 | pipelineId: string; 8 | createdBy?: string; 9 | createdDate?: Date; 10 | } 11 | 12 | export interface IPipelineLabelDocument extends IPipelineLabel, Document { 13 | _id: string; 14 | } 15 | 16 | export const pipelineLabelSchema = new Schema({ 17 | _id: field({ pkey: true }), 18 | 19 | name: field({ type: String }), 20 | colorCode: field({ type: String }), 21 | pipelineId: field({ type: String }), 22 | createdBy: field({ type: String }), 23 | createdAt: field({ 24 | type: Date, 25 | default: new Date(), 26 | }), 27 | }); 28 | -------------------------------------------------------------------------------- /src/db/models/definitions/common.ts: -------------------------------------------------------------------------------- 1 | import { Document, Schema } from 'mongoose'; 2 | import { field } from './utils'; 3 | export interface IRule extends Document { 4 | _id: string; 5 | kind: string; 6 | text: string; 7 | condition: string; 8 | value: string; 9 | } 10 | 11 | // schema for form's rules 12 | const ruleSchema = new Schema( 13 | { 14 | _id: field({ type: String }), 15 | 16 | // browserLanguage, currentUrl, etc ... 17 | kind: field({ type: String }), 18 | 19 | // Browser language, Current url etc ... 20 | text: field({ type: String }), 21 | 22 | // is, isNot, startsWith 23 | condition: field({ type: String }), 24 | 25 | value: field({ type: String }), 26 | }, 27 | { _id: false }, 28 | ); 29 | 30 | export { ruleSchema }; 31 | -------------------------------------------------------------------------------- /src/db/models/definitions/tags.ts: -------------------------------------------------------------------------------- 1 | import { Document, Schema } from 'mongoose'; 2 | import { TAG_TYPES } from './constants'; 3 | import { field, schemaWrapper } from './utils'; 4 | 5 | export interface ITag { 6 | name: string; 7 | type: string; 8 | colorCode?: string; 9 | objectCount?: number; 10 | } 11 | 12 | export interface ITagDocument extends ITag, Document { 13 | _id: string; 14 | createdAt: Date; 15 | } 16 | 17 | export const tagSchema = schemaWrapper( 18 | new Schema({ 19 | _id: field({ pkey: true }), 20 | name: field({ type: String }), 21 | type: field({ 22 | type: String, 23 | enum: TAG_TYPES.ALL, 24 | }), 25 | colorCode: field({ type: String }), 26 | createdAt: field({ type: Date }), 27 | objectCount: field({ type: Number }), 28 | }), 29 | ); 30 | -------------------------------------------------------------------------------- /src/data/resolvers/index.ts: -------------------------------------------------------------------------------- 1 | import Conversation from './conversation'; 2 | import ConversationMessage from './conversationMessage'; 3 | import customScalars from './customScalars'; 4 | import Engage from './engage'; 5 | import Field from './field'; 6 | import Form from './form'; 7 | import { knowledgeBaseArticle, knowledgeBaseCategory, knowledgeBaseTopic } from './knowledgeBase'; 8 | import Mutation from './mutations'; 9 | import Query from './queries'; 10 | 11 | export default { 12 | ...customScalars, 13 | 14 | Conversation, 15 | ConversationMessage, 16 | Form, 17 | Field, 18 | 19 | EngageData: { 20 | ...Engage, 21 | }, 22 | 23 | Query, 24 | Mutation, 25 | 26 | KnowledgeBaseArticle: knowledgeBaseArticle, 27 | KnowledgeBaseCategory: knowledgeBaseCategory, 28 | KnowledgeBaseTopic: knowledgeBaseTopic, 29 | }; 30 | -------------------------------------------------------------------------------- /src/messageQueue.ts: -------------------------------------------------------------------------------- 1 | import * as amqplib from 'amqplib'; 2 | import { debugBase } from './debuggers'; 3 | 4 | const { NODE_ENV, RABBITMQ_HOST = 'amqp://localhost' } = process.env; 5 | 6 | let connection; 7 | let channel; 8 | 9 | const initBroker = async () => { 10 | try { 11 | connection = await amqplib.connect(RABBITMQ_HOST); 12 | channel = await connection.createChannel(); 13 | } catch (e) { 14 | debugBase(e.message); 15 | } 16 | }; 17 | 18 | export const sendMessage = async (action: string, data) => { 19 | if (NODE_ENV === 'test') { 20 | return; 21 | } 22 | 23 | try { 24 | await channel.assertQueue('widgetNotification'); 25 | await channel.sendToQueue('widgetNotification', Buffer.from(JSON.stringify({ action, data }))); 26 | } catch (e) { 27 | debugBase(e.message); 28 | } 29 | }; 30 | 31 | initBroker(); 32 | -------------------------------------------------------------------------------- /src/db/models/definitions/scripts.ts: -------------------------------------------------------------------------------- 1 | import { Document, Schema } from 'mongoose'; 2 | import { field, schemaWrapper } from './utils'; 3 | 4 | export interface IScript { 5 | name: string; 6 | messengerId?: string; 7 | messengerBrandCode?: string; 8 | leadIds?: string[]; 9 | leadMaps?: Array<{ formCode: string; brandCode: string }>; 10 | kbTopicId?: string; 11 | } 12 | 13 | export interface IScriptDocument extends IScript, Document { 14 | _id: string; 15 | } 16 | 17 | export const scriptSchema = schemaWrapper( 18 | new Schema({ 19 | _id: field({ pkey: true }), 20 | name: field({ type: String }), 21 | messengerId: field({ type: String }), 22 | messengerBrandCode: field({ type: String }), 23 | kbTopicId: field({ type: String }), 24 | leadIds: field({ type: [String] }), 25 | leadMaps: field({ type: [Object] }), 26 | }), 27 | ); 28 | -------------------------------------------------------------------------------- /src/db/connection.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv'; 2 | import mongoose = require('mongoose'); 3 | import { debugDb } from '../debuggers'; 4 | 5 | dotenv.config(); 6 | 7 | const { MONGO_URL = '' } = process.env; 8 | 9 | mongoose.Promise = global.Promise; 10 | 11 | mongoose.set('useFindAndModify', false); 12 | 13 | mongoose.connection 14 | .on('connected', () => { 15 | debugDb(`Connected to the database: ${MONGO_URL}`); 16 | }) 17 | .on('disconnected', () => { 18 | debugDb(`Disconnected from the database: ${MONGO_URL}`); 19 | }) 20 | .on('error', error => { 21 | debugDb(`Database connection error: ${MONGO_URL}`, error); 22 | }); 23 | 24 | export function connect() { 25 | return mongoose.connect( 26 | MONGO_URL, 27 | { useNewUrlParser: true, useCreateIndex: true }, 28 | ); 29 | } 30 | 31 | export function disconnect() { 32 | return mongoose.connection.close(); 33 | } 34 | -------------------------------------------------------------------------------- /src/data/resolvers/utils/email.ts: -------------------------------------------------------------------------------- 1 | import * as nodemailer from 'nodemailer'; 2 | 3 | export interface IEmail { 4 | toEmails: string[]; 5 | fromEmail: string; 6 | title: string; 7 | content: string; 8 | } 9 | 10 | export const sendEmail = (args: IEmail): void => { 11 | const { toEmails, fromEmail, title, content } = args; 12 | const { MAIL_SERVICE, MAIL_USER, MAIL_PASS } = process.env; 13 | 14 | const transporter = nodemailer.createTransport({ 15 | service: MAIL_SERVICE, 16 | auth: { 17 | user: MAIL_USER, 18 | pass: MAIL_PASS, 19 | }, 20 | }); 21 | 22 | toEmails.forEach(toEmail => { 23 | const mailOptions = { 24 | from: fromEmail, 25 | to: toEmail, 26 | subject: title, 27 | text: content, 28 | }; 29 | 30 | transporter.sendMail(mailOptions, (error: Error, info: any) => { 31 | console.log(error); // eslint-disable-line 32 | console.log(info); // eslint-disable-line 33 | }); 34 | }); 35 | }; 36 | -------------------------------------------------------------------------------- /src/db/models/definitions/internalNotes.ts: -------------------------------------------------------------------------------- 1 | import { Document, Schema } from 'mongoose'; 2 | import { ACTIVITY_CONTENT_TYPES } from './constants'; 3 | import { field } from './utils'; 4 | 5 | export interface IInternalNote { 6 | contentType: string; 7 | contentTypeId: string; 8 | content: string; 9 | mentionedUserIds?: string[]; 10 | } 11 | 12 | export interface IInternalNoteDocument extends IInternalNote, Document { 13 | _id: string; 14 | createdUserId: string; 15 | createdDate: Date; 16 | } 17 | 18 | // Mongoose schemas ======================= 19 | 20 | export const internalNoteSchema = new Schema({ 21 | _id: field({ pkey: true }), 22 | contentType: field({ 23 | type: String, 24 | enum: ACTIVITY_CONTENT_TYPES.ALL, 25 | }), 26 | contentTypeId: field({ type: String }), 27 | content: field({ 28 | type: String, 29 | }), 30 | createdUserId: field({ 31 | type: String, 32 | }), 33 | createdDate: field({ 34 | type: Date, 35 | }), 36 | }); 37 | -------------------------------------------------------------------------------- /src/__tests__/formDb.test.ts: -------------------------------------------------------------------------------- 1 | import { customerFactory, formFactory } from '../db/factories'; 2 | import { Forms, FormSubmissions } from '../db/models'; 3 | import { IFormDocument } from '../db/models/definitions/forms'; 4 | 5 | /** 6 | * Form related tests 7 | */ 8 | describe('Forms', () => { 9 | let _form: IFormDocument; 10 | 11 | beforeEach(async () => { 12 | // Creating test form 13 | _form = await formFactory({}); 14 | }); 15 | 16 | afterEach(async () => { 17 | // Clearing test forms 18 | await Forms.deleteMany({}); 19 | await FormSubmissions.deleteMany({}); 20 | }); 21 | 22 | test('form submission', async () => { 23 | const customer = await customerFactory({}); 24 | 25 | const doc = { 26 | formId: _form._id, 27 | customerId: customer._id, 28 | }; 29 | 30 | const updated = await FormSubmissions.createFormSubmission(doc); 31 | 32 | expect(updated.formId).toBe(_form._id); 33 | expect(updated.customerId).toBe(customer._id); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /src/db/models/definitions/channels.ts: -------------------------------------------------------------------------------- 1 | import { Document, Schema } from 'mongoose'; 2 | import { field } from './utils'; 3 | 4 | export interface IChannel { 5 | name?: string; 6 | description?: string; 7 | integrationIds?: string[]; 8 | memberIds?: string[]; 9 | userId?: string; 10 | conversationCount?: number; 11 | openConversationCount?: number; 12 | } 13 | 14 | export interface IChannelDocument extends IChannel, Document { 15 | _id: string; 16 | createdAt: Date; 17 | } 18 | 19 | export const channelSchema = new Schema({ 20 | _id: field({ pkey: true }), 21 | createdAt: field({ type: Date }), 22 | name: field({ type: String }), 23 | description: field({ 24 | type: String, 25 | optional: true, 26 | }), 27 | integrationIds: field({ type: [String] }), 28 | memberIds: field({ type: [String] }), 29 | userId: field({ type: String }), 30 | conversationCount: field({ 31 | type: Number, 32 | default: 0, 33 | }), 34 | openConversationCount: field({ 35 | type: Number, 36 | default: 0, 37 | }), 38 | }); 39 | -------------------------------------------------------------------------------- /src/db/models/definitions/importHistory.ts: -------------------------------------------------------------------------------- 1 | import { Document, Schema } from 'mongoose'; 2 | import { field } from './utils'; 3 | 4 | export interface IImportHistory { 5 | success: number; 6 | failed: number; 7 | total: number; 8 | ids: string[]; 9 | contentType: string; 10 | errorMsgs?: string[]; 11 | status?: string; 12 | percentage?: number; 13 | } 14 | 15 | export interface IImportHistoryDocument extends IImportHistory, Document { 16 | _id: string; 17 | userId: string; 18 | date: Date; 19 | } 20 | 21 | export const importHistorySchema = new Schema({ 22 | _id: field({ pkey: true }), 23 | success: field({ type: Number, default: 0 }), 24 | failed: field({ type: Number, default: 0 }), 25 | total: field({ type: Number }), 26 | ids: field({ type: [String], default: [] }), 27 | contentType: field({ type: String }), 28 | userId: field({ type: String }), 29 | date: field({ type: Date }), 30 | errorMsgs: field({ type: [String], default: [] }), 31 | status: field({ type: String, default: 'In Progress' }), 32 | percentage: field({ type: Number, default: 0 }), 33 | }); 34 | -------------------------------------------------------------------------------- /src/db/models/definitions/growthHacks.ts: -------------------------------------------------------------------------------- 1 | import { Document, Schema } from 'mongoose'; 2 | import { commonItemFieldsSchema, IItemCommonFields } from './boards'; 3 | import { field } from './utils'; 4 | 5 | export interface IGrowthHack extends IItemCommonFields { 6 | voteCount?: number; 7 | votedUserIds?: string[]; 8 | 9 | hackStages?: string; 10 | reach?: number; 11 | impact?: number; 12 | confidence?: number; 13 | ease?: number; 14 | } 15 | 16 | export interface IGrowthHackDocument extends IGrowthHack, Document { 17 | _id: string; 18 | } 19 | 20 | export const growthHackSchema = new Schema({ 21 | ...commonItemFieldsSchema, 22 | 23 | voteCount: field({ type: Number, default: 0, optional: true }), 24 | votedUserIds: field({ type: [String], optional: true }), 25 | 26 | hackStages: field({ type: [String], optional: true }), 27 | reach: field({ type: Number, default: 0, optional: true }), 28 | impact: field({ type: Number, default: 0, optional: true }), 29 | confidence: field({ type: Number, default: 0, optional: true }), 30 | ease: field({ type: Number, default: 0, optional: true }), 31 | }); 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: 'type: bug' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 14 | 15 | **Describe the bug** 16 | A clear and concise description of what the bug is. 17 | 18 | **To Reproduce** 19 | Steps to reproduce the behavior: 20 | 1. Go to '...' 21 | 2. Click on '....' 22 | 3. Scroll down to '....' 23 | 4. See error 24 | 25 | **Expected behavior** 26 | A clear and concise description of what you expected to happen. 27 | 28 | **Screenshots** 29 | If applicable, add screenshots to help explain your problem. 30 | 31 | **Context (please include the related information if applicable):** 32 | - OS: [e.g. Ubuntu 16.04] 33 | - Browser version [e.g. chrome 75.0.3770.142 safari 12.1.1] 34 | - erxes version [e.g. 0.9.17, 0.9.16] 35 | - installation type [e.g docker, manual] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /src/db/models/definitions/brands.ts: -------------------------------------------------------------------------------- 1 | import { Document, Schema } from 'mongoose'; 2 | import { field } from './utils'; 3 | 4 | export interface IBrandEmailConfig { 5 | type?: string; 6 | template?: string; 7 | } 8 | 9 | interface IBrandEmailConfigDocument extends IBrandEmailConfig, Document {} 10 | 11 | export interface IBrand { 12 | code?: string; 13 | name?: string; 14 | description?: string; 15 | userId?: string; 16 | emailConfig?: IBrandEmailConfig; 17 | } 18 | 19 | export interface IBrandDocument extends IBrand, Document { 20 | _id: string; 21 | emailConfig?: IBrandEmailConfigDocument; 22 | createdAt: Date; 23 | } 24 | 25 | // Mongoose schemas =========== 26 | const brandEmailConfigSchema = new Schema({ 27 | type: field({ 28 | type: String, 29 | enum: ['simple', 'custom'], 30 | }), 31 | template: field({ type: String }), 32 | }); 33 | 34 | export const brandSchema = new Schema({ 35 | _id: field({ pkey: true }), 36 | code: field({ type: String }), 37 | name: field({ type: String }), 38 | description: field({ type: String, optional: true }), 39 | userId: field({ type: String }), 40 | createdAt: field({ type: Date }), 41 | emailConfig: field({ type: brandEmailConfigSchema }), 42 | }); 43 | -------------------------------------------------------------------------------- /src/db/models/definitions/robot.ts: -------------------------------------------------------------------------------- 1 | import { Document, Schema } from 'mongoose'; 2 | import { field } from './utils'; 3 | 4 | // entry ==================== 5 | export interface IRobotEntry { 6 | parentId?: string; 7 | isNotified: boolean; 8 | action: string; 9 | data: object; 10 | } 11 | 12 | export interface IRobotEntryDocument extends IRobotEntry, Document { 13 | _id: string; 14 | } 15 | 16 | export const robotEntrySchema = new Schema({ 17 | _id: field({ pkey: true }), 18 | parentId: field({ type: String, optional: true }), 19 | isNotified: field({ type: Boolean, default: false }), 20 | action: field({ type: String }), 21 | data: field({ type: Object }), 22 | }); 23 | 24 | // onboarding history ==================== 25 | export interface IOnboardingHistory { 26 | userId: string; 27 | totalPoint: number; 28 | isCompleted: boolean; 29 | completedSteps: string[]; 30 | } 31 | 32 | export interface IOnboardingHistoryDocument extends IOnboardingHistory, Document { 33 | _id: string; 34 | } 35 | 36 | export const onboardingHistorySchema = new Schema({ 37 | _id: field({ pkey: true }), 38 | userId: field({ type: String }), 39 | totalPoint: field({ type: Number }), 40 | isCompleted: field({ type: Boolean }), 41 | completedSteps: field({ type: [String] }), 42 | }); 43 | -------------------------------------------------------------------------------- /src/db/models/definitions/pipelineTemplates.ts: -------------------------------------------------------------------------------- 1 | import { Document, Schema } from 'mongoose'; 2 | import { field } from './utils'; 3 | 4 | export interface IPipelineTemplateStage { 5 | _id: string; 6 | name: string; 7 | formId: string; 8 | } 9 | 10 | export interface IPipelineTemplate { 11 | name: string; 12 | description?: string; 13 | type: string; 14 | isDefinedByErxes: boolean; 15 | stages: IPipelineTemplateStage[]; 16 | createdBy: string; 17 | createdDate: Date; 18 | } 19 | 20 | export interface IPipelineTemplateDocument extends IPipelineTemplate, Document { 21 | _id: string; 22 | } 23 | 24 | const stageSchema = new Schema( 25 | { 26 | _id: field({ type: String }), 27 | name: field({ type: String }), 28 | formId: field({ type: String, optional: true }), 29 | order: field({ type: Number }), 30 | }, 31 | { _id: false }, 32 | ); 33 | 34 | export const pipelineTemplateSchema = new Schema({ 35 | _id: field({ pkey: true }), 36 | 37 | name: field({ type: String }), 38 | type: field({ type: String }), 39 | description: field({ type: String, optional: true }), 40 | stages: field({ type: [stageSchema], default: [] }), 41 | isDefinedByErxes: field({ type: Boolean, default: false }), 42 | createdBy: field({ type: String }), 43 | createdAt: field({ 44 | type: Date, 45 | default: new Date(), 46 | }), 47 | }); 48 | -------------------------------------------------------------------------------- /src/__tests__/companyDb.test.ts: -------------------------------------------------------------------------------- 1 | import { companyFactory } from '../db/factories'; 2 | import { Companies } from '../db/models'; 3 | 4 | /** 5 | * Company related tests 6 | */ 7 | describe('Companies', () => { 8 | afterEach(() => { 9 | // Clearing test companies 10 | return Companies.deleteMany({}); 11 | }); 12 | 13 | test('createCompany', async () => { 14 | const company = await Companies.createCompany({ name: 'test' }); 15 | 16 | expect(company).toBeDefined(); 17 | expect(company.createdAt).toBeDefined(); 18 | expect(company.modifiedAt).toBeDefined(); 19 | expect(company.primaryName).toBe('test'); 20 | expect(company.names).toContain('test'); 21 | }); 22 | 23 | test('getOrCreate()', async () => { 24 | // check names 25 | let company = await companyFactory({ 26 | names: ['911111'], 27 | }); 28 | 29 | company = await Companies.getOrCreate({ 30 | name: '911111', 31 | }); 32 | 33 | expect(company._id).toBeDefined(); 34 | expect(company.names).toContain('911111'); 35 | 36 | // check primaryName 37 | company = await companyFactory({ 38 | primaryName: '24244242', 39 | }); 40 | 41 | company = await Companies.getOrCreate({ 42 | name: '24244242', 43 | }); 44 | 45 | expect(company._id).toBeDefined(); 46 | expect(company.primaryName).toBe('24244242'); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /src/db/models/Forms.ts: -------------------------------------------------------------------------------- 1 | import { Model, model } from 'mongoose'; 2 | import { 3 | formSchema, 4 | formSubmissionSchema, 5 | IFormDocument, 6 | IFormSubmission, 7 | IFormSubmissionDocument, 8 | } from './definitions/forms'; 9 | 10 | export interface IFormModel extends Model {} 11 | 12 | export const loadFormClass = () => { 13 | class Form {} 14 | 15 | formSchema.loadClass(Form); 16 | 17 | return formSchema; 18 | }; 19 | 20 | export interface IFormSubmissionModel extends Model { 21 | createFormSubmission(doc: IFormSubmission): Promise; 22 | } 23 | 24 | export const loadFormSubmissionClass = () => { 25 | class FormSubmission { 26 | /** 27 | * Creates a form submission 28 | */ 29 | public static async createFormSubmission(doc: IFormSubmission) { 30 | return FormSubmissions.create(doc); 31 | } 32 | } 33 | 34 | formSubmissionSchema.loadClass(FormSubmission); 35 | 36 | return formSubmissionSchema; 37 | }; 38 | 39 | loadFormClass(); 40 | loadFormSubmissionClass(); 41 | 42 | // tslint:disable-next-line 43 | const Forms = model('forms', formSchema); 44 | 45 | // tslint:disable-next-line 46 | const FormSubmissions = model('form_submissions', formSubmissionSchema); 47 | 48 | export { Forms, FormSubmissions }; 49 | -------------------------------------------------------------------------------- /src/db/models/definitions/permissions.ts: -------------------------------------------------------------------------------- 1 | import { Document, Schema } from 'mongoose'; 2 | import { field } from './utils'; 3 | 4 | export interface IPermission { 5 | module?: string; 6 | action: string; 7 | userId?: string; 8 | groupId?: string; 9 | requiredActions: string[]; 10 | allowed: boolean; 11 | } 12 | 13 | export interface IPermissionParams { 14 | module?: string; 15 | actions?: string[]; 16 | userIds?: string[]; 17 | groupIds?: string[]; 18 | requiredActions?: string[]; 19 | allowed?: boolean; 20 | } 21 | 22 | export interface IPermissionDocument extends IPermission, Document { 23 | _id: string; 24 | } 25 | 26 | export const permissionSchema = new Schema({ 27 | _id: field({ pkey: true }), 28 | module: field({ type: String }), 29 | action: field({ type: String }), 30 | userId: field({ type: String }), 31 | groupId: field({ type: String }), 32 | requiredActions: field({ type: [String], default: [] }), 33 | allowed: field({ type: Boolean, default: false }), 34 | }); 35 | 36 | export interface IUserGroup { 37 | name?: string; 38 | description?: string; 39 | } 40 | 41 | export interface IUserGroupDocument extends IUserGroup, Document { 42 | _id: string; 43 | } 44 | 45 | export const userGroupSchema = new Schema({ 46 | _id: field({ pkey: true }), 47 | name: field({ type: String, unique: true }), 48 | description: field({ type: String }), 49 | }); 50 | -------------------------------------------------------------------------------- /src/db/models/definitions/conformities.ts: -------------------------------------------------------------------------------- 1 | import { Document, Schema } from 'mongoose'; 2 | import { field } from './utils'; 3 | 4 | export interface IConformity { 5 | mainType: string; 6 | mainTypeId: string; 7 | relType: string; 8 | relTypeId: string; 9 | } 10 | 11 | export interface IConformityAdd { 12 | mainType: string; 13 | mainTypeId: string; 14 | relType: string; 15 | relTypeId: string; 16 | } 17 | 18 | export interface IConformityEdit { 19 | mainType: string; 20 | mainTypeId: string; 21 | relType: string; 22 | relTypeIds: string[]; 23 | } 24 | 25 | export interface IConformitySaved { 26 | mainType: string; 27 | mainTypeId: string; 28 | relType: string; 29 | } 30 | 31 | export interface IConformityChange { 32 | type: string; 33 | newTypeId: string; 34 | oldTypeIds: string[]; 35 | } 36 | 37 | export interface IConformityFilter { 38 | mainType: string; 39 | mainTypeIds: string[]; 40 | relType: string; 41 | } 42 | 43 | export interface IConformityRemove { 44 | mainType: string; 45 | mainTypeId: string; 46 | } 47 | 48 | export interface IConformityDocument extends IConformity, Document { 49 | _id: string; 50 | } 51 | 52 | export const conformitySchema = new Schema({ 53 | _id: field({ pkey: true }), 54 | mainType: field({ type: String, index: true }), 55 | mainTypeId: field({ type: String }), 56 | relType: field({ type: String, index: true }), 57 | relTypeId: field({ type: String }), 58 | }); 59 | -------------------------------------------------------------------------------- /src/db/models/definitions/messengerApps.ts: -------------------------------------------------------------------------------- 1 | import { Document, Schema } from 'mongoose'; 2 | import { field, schemaWrapper } from './utils'; 3 | 4 | export interface IGoogleCredentials { 5 | access_token: string; 6 | scope: string; 7 | token_type: string; 8 | expiry_date: number; 9 | } 10 | 11 | export interface IKnowledgebaseCredentials { 12 | integrationId: string; 13 | topicId: string; 14 | } 15 | 16 | export interface ILeadCredentials { 17 | integrationId: string; 18 | formCode: string; 19 | } 20 | 21 | export type IMessengerAppCrendentials = IGoogleCredentials | IKnowledgebaseCredentials | ILeadCredentials; 22 | 23 | export interface IMessengerApp { 24 | kind: 'googleMeet' | 'knowledgebase' | 'lead'; 25 | name: string; 26 | accountId?: string; 27 | showInInbox?: boolean; 28 | credentials?: IMessengerAppCrendentials; 29 | } 30 | 31 | export interface IMessengerAppDocument extends IMessengerApp, Document { 32 | _id: string; 33 | } 34 | 35 | // Messenger apps =============== 36 | export const messengerAppSchema = schemaWrapper( 37 | new Schema({ 38 | _id: field({ pkey: true }), 39 | 40 | kind: field({ 41 | type: String, 42 | enum: ['googleMeet', 'knowledgebase', 'lead'], 43 | }), 44 | 45 | name: field({ type: String }), 46 | accountId: field({ type: String, optional: true }), 47 | showInInbox: field({ type: Boolean, default: false }), 48 | credentials: field({ type: Object }), 49 | }), 50 | ); 51 | -------------------------------------------------------------------------------- /src/db/models/definitions/checklists.ts: -------------------------------------------------------------------------------- 1 | import { Document, Schema } from 'mongoose'; 2 | import { ACTIVITY_CONTENT_TYPES } from './constants'; 3 | import { field } from './utils'; 4 | 5 | export interface IChecklist { 6 | contentType: string; 7 | contentTypeId: string; 8 | title: string; 9 | } 10 | 11 | export interface IChecklistDocument extends IChecklist, Document { 12 | _id: string; 13 | createdUserId: string; 14 | createdDate: Date; 15 | } 16 | 17 | export interface IChecklistItem { 18 | checklistId: string; 19 | content: string; 20 | isChecked: boolean; 21 | } 22 | 23 | export interface IChecklistItemDocument extends IChecklistItem, Document { 24 | _id: string; 25 | createdUserId: string; 26 | createdDate: Date; 27 | } 28 | 29 | // Mongoose schemas ======================= 30 | 31 | export const checklistSchema = new Schema({ 32 | _id: field({ pkey: true }), 33 | contentType: field({ 34 | type: String, 35 | enum: ACTIVITY_CONTENT_TYPES.ALL, 36 | }), 37 | contentTypeId: field({ type: String }), 38 | title: field({ type: String }), 39 | createdUserId: field({ type: String }), 40 | createdDate: field({ type: Date }), 41 | }); 42 | 43 | export const checklistItemSchema = new Schema({ 44 | _id: field({ pkey: true }), 45 | checklistId: field({ type: String }), 46 | content: field({ type: String }), 47 | isChecked: field({ type: Boolean }), 48 | createdUserId: field({ type: String }), 49 | createdDate: field({ type: Date }), 50 | }); 51 | -------------------------------------------------------------------------------- /src/data/resolvers/knowledgeBase.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IKbArticleDocument, 3 | IKbCategoryDocument, 4 | IKbTopicDocument, 5 | KnowledgeBaseArticles as KnowledgeBaseArticlesModel, 6 | KnowledgeBaseCategories as KnowledgeBaseCategoriesModel, 7 | Users, 8 | } from '../../db/models'; 9 | 10 | export const knowledgeBaseArticle = { 11 | author(article: IKbArticleDocument) { 12 | return Users.findOne({ _id: article.createdBy }); 13 | }, 14 | }; 15 | 16 | export const knowledgeBaseTopic = { 17 | categories(topic: IKbTopicDocument) { 18 | return KnowledgeBaseCategoriesModel.find({ 19 | _id: { $in: topic.categoryIds }, 20 | }); 21 | }, 22 | }; 23 | 24 | export const knowledgeBaseCategory = { 25 | articles(category: IKbCategoryDocument) { 26 | return KnowledgeBaseArticlesModel.find({ 27 | _id: { $in: category.articleIds }, 28 | status: 'publish', 29 | }); 30 | }, 31 | 32 | async authors(category: IKbCategoryDocument) { 33 | const articles = await KnowledgeBaseArticlesModel.find( 34 | { 35 | _id: { $in: category.articleIds }, 36 | status: 'publish', 37 | }, 38 | { createdBy: 1 }, 39 | ); 40 | 41 | const authorIds = articles.map(article => article.createdBy); 42 | 43 | return Users.find({ 44 | _id: { $in: authorIds }, 45 | }); 46 | }, 47 | 48 | numOfArticles(category: IKbCategoryDocument) { 49 | return KnowledgeBaseArticlesModel.find({ 50 | _id: { $in: category.articleIds }, 51 | status: 'publish', 52 | }).countDocuments(); 53 | }, 54 | }; 55 | -------------------------------------------------------------------------------- /src/db/models/definitions/emailDeliveries.ts: -------------------------------------------------------------------------------- 1 | import { Document, Schema } from 'mongoose'; 2 | import { field } from './utils'; 3 | 4 | interface IAttachmentParams { 5 | data: string; 6 | filename: string; 7 | size: number; 8 | mimeType: string; 9 | } 10 | 11 | export interface IEmailDeliveries { 12 | cocType: string; 13 | cocId?: string; 14 | subject: string; 15 | body: string; 16 | toEmails: string; 17 | cc?: string; 18 | bcc?: string; 19 | attachments?: IAttachmentParams[]; 20 | fromEmail?: string; 21 | type?: string; 22 | userId: string; 23 | } 24 | 25 | export interface IEmailDeliveriesDocument extends IEmailDeliveries, Document { 26 | id: string; 27 | } 28 | 29 | // Mongoose schemas =========== 30 | 31 | const attachmentSchema = new Schema( 32 | { 33 | data: field({ type: String }), 34 | filename: field({ type: String }), 35 | size: field({ type: Number }), 36 | mimeType: field({ type: String }), 37 | }, 38 | { _id: false }, 39 | ); 40 | 41 | export const emailDeliverySchema = new Schema({ 42 | _id: field({ pkey: true }), 43 | cocType: field({ type: String }), 44 | cocId: field({ type: String }), 45 | subject: field({ type: String, optional: true }), 46 | body: field({ type: String }), 47 | toEmails: field({ type: String }), 48 | cc: field({ type: String, optional: true }), 49 | bcc: field({ type: String, optional: true }), 50 | attachments: field({ type: [attachmentSchema] }), 51 | fromEmail: field({ type: String }), 52 | userId: field({ type: String }), 53 | 54 | type: { type: String }, 55 | 56 | createdAt: field({ type: Date, default: Date.now }), 57 | }); 58 | -------------------------------------------------------------------------------- /src/data/resolvers/customScalars.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLScalarType } from 'graphql'; 2 | import { Kind } from 'graphql/language'; // tslint:disable-line 3 | 4 | function jSONidentity(value: any) { 5 | return value; 6 | } 7 | 8 | function jSONparseLiteral(ast: any) { 9 | switch (ast.kind) { 10 | case Kind.STRING: 11 | case Kind.BOOLEAN: 12 | return ast.value; 13 | case Kind.INT: 14 | case Kind.FLOAT: 15 | return parseFloat(ast.value); 16 | case Kind.OBJECT: { 17 | const value = Object.create(null); 18 | 19 | ast.fields.forEach((field: any) => { 20 | value[field.name.value] = jSONparseLiteral(field.value); 21 | }); 22 | 23 | return value; 24 | } 25 | case Kind.LIST: 26 | return ast.values.map(jSONparseLiteral); 27 | default: 28 | return null; 29 | } 30 | } 31 | 32 | export default { 33 | Date: new GraphQLScalarType({ 34 | name: 'Date', 35 | description: 'Date custom scalar type', 36 | parseValue(value) { 37 | return new Date(value); // value from the client 38 | }, 39 | serialize(value) { 40 | return value.getTime(); // value sent to the client 41 | }, 42 | parseLiteral(ast) { 43 | if (ast.kind === Kind.INT) { 44 | return parseInt(ast.value, 10); // ast value is always in string format 45 | } 46 | return null; 47 | }, 48 | }), 49 | 50 | JSON: new GraphQLScalarType({ 51 | name: 'JSON', 52 | description: 53 | 'The `jSON` scalar type represents jSON values as specified by ' + 54 | '[ECMA-404](http://www.ecma-international.org/' + 55 | 'publications/files/ECMA-ST/ECMA-404.pdf).', 56 | serialize: jSONidentity, 57 | parseValue: jSONidentity, 58 | parseLiteral: jSONparseLiteral, 59 | }), 60 | }; 61 | -------------------------------------------------------------------------------- /src/__tests__/integrationDb.test.ts: -------------------------------------------------------------------------------- 1 | import { brandFactory, integrationFactory } from '../db/factories'; 2 | import { Customers, IBrandDocument, IIntegrationDocument, Integrations } from '../db/models'; 3 | 4 | describe('Integrations', () => { 5 | let _brand: IBrandDocument; 6 | let _integration: IIntegrationDocument; 7 | 8 | beforeEach(async () => { 9 | // Creating test brand and integration 10 | _brand = await brandFactory(); 11 | _integration = await integrationFactory({ 12 | brandId: _brand._id, 13 | kind: 'messenger', 14 | leadData: { 15 | viewCount: 0, 16 | contactsGathered: 0, 17 | }, 18 | }); 19 | }); 20 | 21 | afterEach(async () => { 22 | // Clearing test data 23 | await Customers.deleteMany({}); 24 | await Integrations.deleteMany({}); 25 | }); 26 | 27 | test('getIntegration() must return an integration', async () => { 28 | const integration = await Integrations.getIntegration(_brand.code, _integration.kind); 29 | expect(integration).toBeDefined(); 30 | expect(integration.kind).toBe(_integration.kind); 31 | }); 32 | 33 | test('Increase view count of lead', async () => { 34 | let updated = await Integrations.increaseViewCount(_integration.formId); 35 | expect(updated.leadData.viewCount).toBe(1); 36 | 37 | updated = await Integrations.increaseViewCount(_integration.formId); 38 | expect(updated.leadData.viewCount).toBe(2); 39 | }); 40 | 41 | test('Increase contacts gathered', async () => { 42 | let updated = await Integrations.increaseContactsGathered(_integration.formId); 43 | 44 | expect(updated.leadData.contactsGathered).toBe(1); 45 | 46 | updated = await Integrations.increaseContactsGathered(_integration.formId); 47 | expect(updated.leadData.contactsGathered).toBe(2); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /src/db/models/definitions/segments.ts: -------------------------------------------------------------------------------- 1 | import { Document, Schema } from 'mongoose'; 2 | import { ACTIVITY_CONTENT_TYPES } from './constants'; 3 | import { field, schemaWrapper } from './utils'; 4 | 5 | export interface ICondition { 6 | field: string; 7 | operator: string; 8 | type: string; 9 | value?: string; 10 | brandId?: string; 11 | dateUnit?: string; 12 | } 13 | 14 | export interface IConditionDocument extends ICondition, Document {} 15 | 16 | export interface ISegment { 17 | contentType: string; 18 | name: string; 19 | description?: string; 20 | subOf: string; 21 | color: string; 22 | connector: string; 23 | conditions: ICondition[]; 24 | } 25 | 26 | export interface ISegmentDocument extends ISegment, Document { 27 | _id: string; 28 | } 29 | 30 | // Mongoose schemas ======================= 31 | 32 | const conditionSchema = new Schema( 33 | { 34 | field: field({ type: String }), 35 | operator: field({ type: String }), 36 | type: field({ type: String }), 37 | 38 | value: field({ 39 | type: String, 40 | optional: true, 41 | }), 42 | 43 | dateUnit: field({ 44 | type: String, 45 | optional: true, 46 | }), 47 | 48 | brandId: field({ 49 | type: String, 50 | optional: true, 51 | }), 52 | }, 53 | { _id: false }, 54 | ); 55 | 56 | export const segmentSchema = schemaWrapper( 57 | new Schema({ 58 | _id: field({ pkey: true }), 59 | contentType: field({ 60 | type: String, 61 | enum: ACTIVITY_CONTENT_TYPES.ALL, 62 | }), 63 | name: field({ type: String }), 64 | description: field({ type: String, optional: true }), 65 | subOf: field({ type: String, optional: true }), 66 | color: field({ type: String }), 67 | connector: field({ type: String }), 68 | conditions: field({ type: [conditionSchema] }), 69 | }), 70 | ); 71 | -------------------------------------------------------------------------------- /src/db/models/Companies.ts: -------------------------------------------------------------------------------- 1 | import { Model, model } from 'mongoose'; 2 | import { sendMessage } from '../../messageQueue'; 3 | import { companySchema, ICompanyDocument } from './definitions/companies'; 4 | 5 | interface ICompanyDoc { 6 | id?: string; 7 | name: string; 8 | plan?: string; 9 | } 10 | 11 | interface ICompanyModel extends Model { 12 | getOrCreate(doc: ICompanyDoc): ICompanyDocument; 13 | createCompany(doc: ICompanyDoc): ICompanyDocument; 14 | } 15 | 16 | export const loadClass = () => { 17 | class Company { 18 | /** 19 | * Create a company 20 | */ 21 | public static async createCompany(doc: ICompanyDoc) { 22 | const { name, ...restDoc } = doc; 23 | 24 | const company = await Companies.create({ 25 | createdAt: new Date(), 26 | modifiedAt: new Date(), 27 | primaryName: name, 28 | names: [name], 29 | searchText: [doc.name, doc.plan || ''].join(' '), 30 | ...restDoc, 31 | }); 32 | 33 | // notify main api 34 | sendMessage('activityLog', { 35 | type: 'create-company', 36 | payload: company, 37 | }); 38 | 39 | return company; 40 | } 41 | 42 | /** 43 | * Get or create company 44 | */ 45 | public static async getOrCreate(doc: ICompanyDoc) { 46 | const company = await Companies.findOne({ 47 | $or: [{ names: { $in: [doc.name] } }, { primaryName: doc.name }], 48 | }); 49 | 50 | if (company) { 51 | return company; 52 | } 53 | 54 | return this.createCompany(doc); 55 | } 56 | } 57 | 58 | companySchema.loadClass(Company); 59 | 60 | return companySchema; 61 | }; 62 | 63 | loadClass(); 64 | 65 | // tslint:disable-next-line 66 | const Companies = model('companies', companySchema); 67 | 68 | export default Companies; 69 | -------------------------------------------------------------------------------- /src/db/models/definitions/notifications.ts: -------------------------------------------------------------------------------- 1 | import { Document, Schema } from 'mongoose'; 2 | import { NOTIFICATION_TYPES } from './constants'; 3 | import { field } from './utils'; 4 | 5 | export interface INotification { 6 | notifType?: string; 7 | title?: string; 8 | content?: string; 9 | link?: string; 10 | contentType?: string; 11 | contentTypeId?: string; 12 | receiver?: string; 13 | action?: string; 14 | } 15 | 16 | export interface INotificationDocument extends INotification, Document { 17 | _id: string; 18 | createdUser?: string; 19 | receiver: string; 20 | date: Date; 21 | isRead: boolean; 22 | } 23 | 24 | export const notificationSchema = new Schema({ 25 | _id: field({ pkey: true }), 26 | notifType: field({ 27 | type: String, 28 | enum: NOTIFICATION_TYPES.ALL, 29 | }), 30 | action: field({ 31 | type: String, 32 | optional: true, 33 | }), 34 | title: field({ type: String }), 35 | link: field({ type: String }), 36 | content: field({ type: String }), 37 | createdUser: field({ type: String }), 38 | receiver: field({ type: String }), 39 | contentType: field({ type: String }), 40 | contentTypeId: field({ type: String }), 41 | date: field({ 42 | type: Date, 43 | default: Date.now, 44 | }), 45 | isRead: field({ 46 | type: Boolean, 47 | default: false, 48 | }), 49 | }); 50 | 51 | export interface IConfig { 52 | user?: string; 53 | notifType?: string; 54 | isAllowed?: boolean; 55 | } 56 | 57 | export interface IConfigDocument extends IConfig, Document { 58 | _id: string; 59 | } 60 | 61 | export const configSchema = new Schema({ 62 | _id: field({ pkey: true }), 63 | // to whom this config is related 64 | user: field({ type: String }), 65 | notifType: field({ 66 | type: String, 67 | enum: NOTIFICATION_TYPES.ALL, 68 | }), 69 | isAllowed: field({ type: Boolean }), 70 | }); 71 | -------------------------------------------------------------------------------- /src/db/models/index.ts: -------------------------------------------------------------------------------- 1 | import Brands from './Brands'; 2 | import Companies from './Companies'; 3 | import Conversations from './Conversations'; 4 | import Customers from './Customers'; 5 | import { EngageMessages } from './Engages'; 6 | import Fields from './Fields'; 7 | import { Forms, FormSubmissions } from './Forms'; 8 | import Integrations from './Integrations'; 9 | import { KnowledgeBaseArticles, KnowledgeBaseCategories, KnowledgeBaseTopics } from './KnowledgeBase'; 10 | import Messages from './Messages'; 11 | import MessengerApps from './MessengerApps'; 12 | import Users from './Users'; 13 | 14 | import { IBrandDocument } from './definitions/brands'; 15 | import { IConversationDocument } from './definitions/conversations'; 16 | import { ICustomerDocument } from './definitions/customers'; 17 | import { IIntegrationDocument } from './definitions/integrations'; 18 | import { IUserDocument } from './definitions/users'; 19 | 20 | import { IEngageData as IMessageEngageData, IMessageDocument } from './definitions/conversationMessages'; 21 | 22 | import { IFieldDocument } from './definitions/fields'; 23 | import { IFormDocument } from './definitions/forms'; 24 | 25 | import { 26 | IArticleDocument as IKbArticleDocument, 27 | ICategoryDocument as IKbCategoryDocument, 28 | ITopicDocument as IKbTopicDocument, 29 | } from './definitions/knowledgebase'; 30 | 31 | export { 32 | Companies, 33 | Brands, 34 | IBrandDocument, 35 | Conversations, 36 | IConversationDocument, 37 | Customers, 38 | ICustomerDocument, 39 | Fields, 40 | IFieldDocument, 41 | Forms, 42 | FormSubmissions, 43 | IFormDocument, 44 | Integrations, 45 | IIntegrationDocument, 46 | Messages, 47 | IMessageDocument, 48 | IMessageEngageData, 49 | Users, 50 | IUserDocument, 51 | EngageMessages, 52 | KnowledgeBaseTopics, 53 | KnowledgeBaseCategories, 54 | KnowledgeBaseArticles, 55 | IKbTopicDocument, 56 | IKbCategoryDocument, 57 | IKbArticleDocument, 58 | MessengerApps, 59 | }; 60 | -------------------------------------------------------------------------------- /src/db/models/KnowledgeBase.ts: -------------------------------------------------------------------------------- 1 | import { Model, model } from 'mongoose'; 2 | import { 3 | articleSchema, 4 | categorySchema, 5 | IArticleDocument, 6 | ICategoryDocument, 7 | ITopicDocument, 8 | topicSchema, 9 | } from './definitions/knowledgebase'; 10 | 11 | export interface IArticleModel extends Model { 12 | incReactionCount(articleId: string, reactionChoice): void; 13 | } 14 | 15 | export interface ICategoryModel extends Model {} 16 | export interface ITopicModel extends Model {} 17 | 18 | export const loadArticleClass = () => { 19 | class Article { 20 | /* 21 | * Increase form view count 22 | */ 23 | public static async incReactionCount(articleId: string, reactionChoice: string) { 24 | const article = await KnowledgeBaseArticles.findOne({ _id: articleId }); 25 | 26 | if (!article) { 27 | throw new Error('Article not found'); 28 | } 29 | 30 | const reactionCounts = article.reactionCounts || {}; 31 | 32 | reactionCounts[reactionChoice] = (reactionCounts[reactionChoice] || 0) + 1; 33 | 34 | await KnowledgeBaseArticles.updateOne({ _id: articleId }, { $set: { reactionCounts } }); 35 | } 36 | } 37 | 38 | articleSchema.loadClass(Article); 39 | 40 | return articleSchema; 41 | }; 42 | 43 | export const loadCategoryClass = () => { 44 | return categorySchema; 45 | }; 46 | 47 | export const loadTopicClass = () => { 48 | return topicSchema; 49 | }; 50 | 51 | loadArticleClass(); 52 | 53 | // tslint:disable-next-line 54 | export const KnowledgeBaseArticles = model('knowledgebase_articles', articleSchema); 55 | 56 | // tslint:disable-next-line 57 | export const KnowledgeBaseCategories = model( 58 | 'knowledgebase_categories', 59 | categorySchema, 60 | ); 61 | 62 | // tslint:disable-next-line 63 | export const KnowledgeBaseTopics = model('knowledgebase_topics', topicSchema); 64 | -------------------------------------------------------------------------------- /src/db/models/definitions/conversations.ts: -------------------------------------------------------------------------------- 1 | import { Document, Schema } from 'mongoose'; 2 | import { CONVERSATION_STATUSES } from './constants'; 3 | import { field } from './utils'; 4 | 5 | export interface IConversation { 6 | content?: string; 7 | integrationId: string; 8 | customerId?: string; 9 | userId?: string; 10 | assignedUserId?: string; 11 | participatedUserIds?: string[]; 12 | readUserIds?: string[]; 13 | 14 | closedAt?: Date; 15 | closedUserId?: string; 16 | 17 | status?: string; 18 | messageCount?: number; 19 | tagIds?: string[]; 20 | 21 | // number of total conversations 22 | number?: number; 23 | 24 | firstRespondedUserId?: string; 25 | firstRespondedDate?: Date; 26 | } 27 | 28 | // Conversation schema 29 | export interface IConversationDocument extends IConversation, Document { 30 | _id: string; 31 | createdAt: Date; 32 | updatedAt: Date; 33 | } 34 | 35 | // Conversation schema 36 | export const conversationSchema = new Schema({ 37 | _id: field({ pkey: true }), 38 | content: field({ type: String }), 39 | integrationId: field({ type: String, index: true }), 40 | customerId: field({ type: String }), 41 | userId: field({ type: String }), 42 | assignedUserId: field({ type: String }), 43 | participatedUserIds: field({ type: [String] }), 44 | readUserIds: field({ type: [String] }), 45 | createdAt: field({ type: Date, index: true }), 46 | updatedAt: field({ type: Date }), 47 | 48 | closedAt: field({ 49 | type: Date, 50 | optional: true, 51 | }), 52 | 53 | closedUserId: field({ 54 | type: String, 55 | optional: true, 56 | }), 57 | 58 | status: field({ 59 | type: String, 60 | enum: CONVERSATION_STATUSES.ALL, 61 | index: true, 62 | }), 63 | messageCount: field({ type: Number }), 64 | tagIds: field({ type: [String] }), 65 | 66 | // number of total conversations 67 | number: field({ type: Number }), 68 | 69 | firstRespondedUserId: field({ type: String }), 70 | firstRespondedDate: field({ type: Date }), 71 | }); 72 | -------------------------------------------------------------------------------- /.snyk: -------------------------------------------------------------------------------- 1 | # Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities. 2 | version: v1.13.5 3 | # ignores vulnerabilities until expiry date; change duration by modifying expiry date 4 | ignore: 5 | 'npm:chownr:20180731': 6 | - chownr: 7 | reason: Development only 8 | expires: '2018-12-13T02:48:47.308Z' 9 | - node-pre-gyp > tar > chownr: 10 | reason: Development only 11 | expires: '2018-12-13T02:48:47.308Z' 12 | SNYK-JS-LODASH-450202: 13 | - apollo-server-express > apollo-server-core > lodash: 14 | reason: None given 15 | expires: '2019-08-08T03:59:06.513Z' 16 | - mongoose > async > lodash: 17 | reason: None given 18 | expires: '2019-08-08T03:59:06.513Z' 19 | - apollo-server-express > apollo-server-core > apollo-engine-reporting > lodash: 20 | reason: None given 21 | expires: '2019-08-08T03:59:06.513Z' 22 | SNYK-JS-LODASHMERGE-173732: 23 | - '@google-cloud/pubsub > lodash.merge': 24 | reason: None given 25 | expires: '2019-08-08T03:59:06.514Z' 26 | - '@axelspringer/graphql-google-pubsub > @google-cloud/pubsub > lodash.merge': 27 | reason: None given 28 | expires: '2019-08-08T03:59:06.514Z' 29 | SNYK-JS-LODASHMERGE-173733: 30 | - '@google-cloud/pubsub > lodash.merge': 31 | reason: None given 32 | expires: '2019-08-08T03:59:06.514Z' 33 | - '@axelspringer/graphql-google-pubsub > @google-cloud/pubsub > lodash.merge': 34 | reason: None given 35 | expires: '2019-08-08T03:59:06.514Z' 36 | # patches apply the minimum changes required to fix a vulnerability 37 | patch: 38 | SNYK-JS-LODASH-450202: 39 | - apollo-server-express > apollo-server-core > lodash: 40 | patched: '2019-10-29T23:58:53.237Z' 41 | snyk > snyk-nuget-plugin > dotnet-deps-parser > lodash: 42 | patched: '2019-10-30T00:01:03.776Z' 43 | - apollo-server-express > apollo-server-core > apollo-engine-reporting > lodash: 44 | patched: '2019-10-29T23:58:53.237Z' 45 | - release-it > deprecated-obj > lodash: 46 | patched: '2019-10-29T23:58:53.237Z' 47 | -------------------------------------------------------------------------------- /src/data/resolvers/queries/knowledgebase.ts: -------------------------------------------------------------------------------- 1 | import { 2 | KnowledgeBaseArticles as KnowledgeBaseArticlesModel, 3 | KnowledgeBaseCategories as KnowledgeBaseCategoriesModel, 4 | KnowledgeBaseTopics as KnowledgeBaseTopicsModel, 5 | } from '../../../db/models'; 6 | 7 | export default { 8 | /** 9 | * Find Topic detail data along with its categories 10 | * @return {Promise} topic detail 11 | */ 12 | knowledgeBaseTopicsDetail(_root: any, { topicId }: { topicId: string }) { 13 | return KnowledgeBaseTopicsModel.findOne({ _id: topicId }); 14 | }, 15 | 16 | /** 17 | * Find Category detail data along with its articles 18 | * @return {Promise} category detail 19 | */ 20 | knowledgeBaseCategoriesDetail(_root: any, { categoryId }: { categoryId: string }) { 21 | return KnowledgeBaseCategoriesModel.findOne({ _id: categoryId }); 22 | }, 23 | 24 | /* 25 | * Search published articles that contain searchString (case insensitive) 26 | * in a topic found by topicId 27 | * @return {Promise} searched articles 28 | */ 29 | async knowledgeBaseArticles(_root: any, args: { topicId: string; searchString: string }) { 30 | const { topicId, searchString = '' } = args; 31 | 32 | let articleIds: string[] = []; 33 | 34 | const topic = await KnowledgeBaseTopicsModel.findOne({ _id: topicId }); 35 | 36 | if (!topic) { 37 | return []; 38 | } 39 | 40 | const categories = await KnowledgeBaseCategoriesModel.find({ 41 | _id: topic.categoryIds, 42 | }); 43 | 44 | categories.forEach(category => { 45 | articleIds = [...articleIds, ...category.articleIds]; 46 | }); 47 | 48 | return KnowledgeBaseArticlesModel.find({ 49 | _id: { $in: articleIds }, 50 | content: { $regex: `.*${searchString.trim()}.*`, $options: 'i' }, 51 | status: 'publish', 52 | }); 53 | }, 54 | 55 | /** 56 | * return a KnowledgeBaseLoader object with only `loadType` field in it 57 | * @return {Promise} KnowledgeBaseLoader 58 | */ 59 | knowledgeBaseLoader(_root: any, { topicId }: { topicId: string }) { 60 | return KnowledgeBaseTopicsModel.findOne({ _id: topicId }, { loadType: 1 }); 61 | }, 62 | }; 63 | -------------------------------------------------------------------------------- /src/db/models/Messages.ts: -------------------------------------------------------------------------------- 1 | import { Model, model } from 'mongoose'; 2 | 3 | import Conversations from './Conversations'; 4 | import { IMessageDocument, messageSchema } from './definitions/conversationMessages'; 5 | 6 | interface IMessageParams { 7 | conversationId: string; 8 | content: string; 9 | customerId?: string; 10 | userId?: string; 11 | attachments?: any; 12 | engageData?: any; 13 | formWidgetData?: any; 14 | } 15 | 16 | interface IMessageModel extends Model { 17 | createMessage(doc: IMessageParams): Promise; 18 | forceReadCustomerPreviousEngageMessages(customerId: string): Promise; 19 | } 20 | 21 | export const loadClass = () => { 22 | class Message { 23 | /* 24 | * Create new message 25 | */ 26 | public static async createMessage(doc: IMessageParams) { 27 | const conversation = await Conversations.findOne({ 28 | _id: doc.conversationId, 29 | }); 30 | 31 | if (!conversation) { 32 | throw new Error('Conversation not found'); 33 | } 34 | 35 | // increment messageCount 36 | await Conversations.findByIdAndUpdate( 37 | conversation._id, 38 | { 39 | messageCount: conversation.messageCount + 1, 40 | updatedAt: new Date(), 41 | }, 42 | { new: true }, 43 | ); 44 | 45 | // create message 46 | return Messages.create({ 47 | createdAt: new Date(), 48 | internal: false, 49 | ...doc, 50 | }); 51 | } 52 | 53 | // force read previous unread engage messages ============ 54 | public static forceReadCustomerPreviousEngageMessages(customerId: string) { 55 | return Messages.updateMany( 56 | { 57 | customerId, 58 | engageData: { $exists: true }, 59 | isCustomerRead: { $ne: true }, 60 | }, 61 | { $set: { isCustomerRead: true } }, 62 | { multi: true }, 63 | ); 64 | } 65 | } 66 | 67 | messageSchema.loadClass(Message); 68 | 69 | return messageSchema; 70 | }; 71 | 72 | loadClass(); 73 | 74 | // tslint:disable-next-line 75 | const Messages = model('conversation_messages', messageSchema); 76 | 77 | export default Messages; 78 | -------------------------------------------------------------------------------- /src/db/models/definitions/fields.ts: -------------------------------------------------------------------------------- 1 | import { Document, Schema } from 'mongoose'; 2 | import { FIELDS_GROUPS_CONTENT_TYPES } from './constants'; 3 | import { field, schemaWrapper } from './utils'; 4 | 5 | export interface IField { 6 | contentType?: string; 7 | contentTypeId?: string; 8 | type?: string; 9 | validation?: string; 10 | text: string; 11 | description?: string; 12 | options?: string[]; 13 | isRequired?: boolean; 14 | isDefinedByErxes?: boolean; 15 | order?: number; 16 | groupId?: string; 17 | isVisible?: boolean; 18 | lastUpdatedUserId?: string; 19 | } 20 | 21 | export interface IFieldDocument extends IField, Document { 22 | _id: string; 23 | } 24 | 25 | export interface IFieldGroup { 26 | name?: string; 27 | contentType?: string; 28 | order?: number; 29 | isDefinedByErxes?: boolean; 30 | description?: string; 31 | lastUpdatedUserId?: string; 32 | isVisible?: boolean; 33 | } 34 | 35 | export interface IFieldGroupDocument extends IFieldGroup, Document { 36 | _id: string; 37 | } 38 | 39 | // Mongoose schemas ============= 40 | export const fieldSchema = new Schema({ 41 | _id: field({ pkey: true }), 42 | 43 | // form, customer, company 44 | contentType: field({ type: String }), 45 | 46 | // formId when contentType is form 47 | contentTypeId: field({ type: String }), 48 | 49 | type: field({ type: String }), 50 | validation: field({ 51 | type: String, 52 | optional: true, 53 | }), 54 | text: field({ type: String }), 55 | description: field({ 56 | type: String, 57 | optional: true, 58 | }), 59 | options: field({ 60 | type: [String], 61 | optional: true, 62 | }), 63 | isRequired: field({ type: Boolean }), 64 | isDefinedByErxes: field({ type: Boolean }), 65 | order: field({ type: Number }), 66 | groupId: field({ type: String }), 67 | isVisible: field({ type: Boolean, default: true }), 68 | lastUpdatedUserId: field({ type: String }), 69 | }); 70 | 71 | export const fieldGroupSchema = schemaWrapper( 72 | new Schema({ 73 | _id: field({ pkey: true }), 74 | name: field({ type: String }), 75 | // customer, company 76 | contentType: field({ type: String, enum: FIELDS_GROUPS_CONTENT_TYPES.ALL }), 77 | order: field({ type: Number }), 78 | isDefinedByErxes: field({ type: Boolean, default: false }), 79 | description: field({ 80 | type: String, 81 | }), 82 | // Id of user who updated the group 83 | lastUpdatedUserId: field({ type: String }), 84 | isVisible: field({ type: Boolean, default: true }), 85 | }), 86 | ); 87 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "erxes-widgets-api", 3 | "version": "0.11.2", 4 | "description": "GraphQL API for erxes widgets", 5 | "homepage": "https://erxes.io", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/erxes/erxes-widgets-api" 9 | }, 10 | "bugs": "https://github.com/erxes/erxes-widgets-api/issues", 11 | "keywords": [ 12 | "node", 13 | "express", 14 | "graphql", 15 | "apollo" 16 | ], 17 | "license": "MIT", 18 | "private": true, 19 | "scripts": { 20 | "start": "node dist", 21 | "dev": "DEBUG=erxes-widgets-api:* NODE_ENV=development node_modules/.bin/ts-node-dev --respawn src", 22 | "test": "NODE_ENV=test jest --runInBand --forceExit", 23 | "build": "tsc -p tsconfig.prod.json && cp -rf src/static dist/ && yarn generateVersion", 24 | "lint": "tslint 'src/**/*.ts'", 25 | "format": "prettier --write 'src/**/*.ts'", 26 | "precommit": "lint-staged", 27 | "generateVersion": "ts-node ./src/commands/generateVersion.ts", 28 | "release": "release-it", 29 | "snyk-protect": "snyk protect", 30 | "prepare": "yarn run snyk-protect" 31 | }, 32 | "lint-staged": { 33 | "*.ts": [ 34 | "prettier --write", 35 | "git add" 36 | ] 37 | }, 38 | "dependencies": { 39 | "amqplib": "0.5.3", 40 | "apollo-server-express": "^2.3.1", 41 | "body-parser": "^1.17.1", 42 | "cors": "^2.8.1", 43 | "debug": "^4.1.1", 44 | "dotenv": "^4.0.0", 45 | "express": "^4.15.2", 46 | "git-repo-info": "^2.1.0", 47 | "graphql": "^14.0.2", 48 | "graphql-tools": "^4.0.3", 49 | "meteor-random": "0.0.3", 50 | "mongoose": "5.7.5", 51 | "nodemailer": "^4.0.1", 52 | "q": "^1.5.1", 53 | "snyk": "^1.239.5", 54 | "strip": "^3.0.0", 55 | "underscore": "^1.8.3", 56 | "validator": "^10.9.0" 57 | }, 58 | "peerOptionalDependencies": { 59 | "kerberos": "^1.0.0" 60 | }, 61 | "devDependencies": { 62 | "@release-it/conventional-changelog": "^1.1.0", 63 | "@types/body-parser": "^1.17.0", 64 | "@types/cors": "^2.8.4", 65 | "@types/dotenv": "^4.0.3", 66 | "@types/express": "^4.16.0", 67 | "@types/jest": "^23.3.0", 68 | "@types/mongodb": "^3.1.2", 69 | "@types/mongoose": "^5.2.1", 70 | "@types/q": "^1.5.0", 71 | "faker": "^4.1.0", 72 | "husky": "^0.13.4", 73 | "jest": "22.0.4", 74 | "lint-staged": "^3.6.0", 75 | "prettier": "^1.13.7", 76 | "release-it": "^12.4.3", 77 | "ts-jest": "22.0.4", 78 | "ts-node": "^7.0.0", 79 | "ts-node-dev": "^1.0.0-pre.32", 80 | "tslint": "^5.8.0", 81 | "tslint-config-prettier": "^1.1.0", 82 | "tslint-config-standard": "^7.0.0", 83 | "typescript": "^2.9.2" 84 | }, 85 | "snyk": true 86 | } 87 | -------------------------------------------------------------------------------- /src/data/resolvers/utils/messenger.ts: -------------------------------------------------------------------------------- 1 | import { IConversationDocument, IIntegrationDocument } from '../../../db/models'; 2 | 3 | const daysAsString = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']; 4 | 5 | export const isTimeInBetween = (date: Date, startTime: string, closeTime: string): boolean => { 6 | // concatnating time ranges with today's date 7 | const dateString = date.toLocaleDateString(); 8 | const startDate = new Date(`${dateString} ${startTime}`); 9 | const closeDate = new Date(`${dateString} ${closeTime}`); 10 | 11 | return startDate <= date && date <= closeDate; 12 | }; 13 | 14 | const isWeekday = (day: string): boolean => { 15 | return ['monday', 'tuesday', 'wednesday', 'thursday', 'friday'].includes(day); 16 | }; 17 | 18 | const isWeekend = (day: string): boolean => { 19 | return ['saturday', 'sunday'].includes(day); 20 | }; 21 | 22 | export const isOnline = (integration: IIntegrationDocument, now = new Date()): boolean => { 23 | if (!integration.messengerData) { 24 | return false; 25 | } 26 | 27 | const { messengerData } = integration; 28 | const { availabilityMethod, onlineHours } = messengerData; 29 | 30 | /* 31 | * Manual: We can determine state from isOnline field value when method is manual 32 | */ 33 | if (availabilityMethod === 'manual') { 34 | return messengerData.isOnline; 35 | } 36 | 37 | /* 38 | * Auto 39 | */ 40 | const day = daysAsString[now.getDay()]; 41 | 42 | if (!onlineHours) { 43 | return false; 44 | } 45 | 46 | // check by everyday config 47 | const everydayConf = onlineHours.find(c => c.day === 'everyday'); 48 | 49 | if (everydayConf) { 50 | return isTimeInBetween(now, everydayConf.from, everydayConf.to); 51 | } 52 | 53 | // check by weekdays config 54 | const weekdaysConf = onlineHours.find(c => c.day === 'weekdays'); 55 | 56 | if (weekdaysConf && isWeekday(day)) { 57 | return isTimeInBetween(now, weekdaysConf.from, weekdaysConf.to); 58 | } 59 | 60 | // check by weekends config 61 | const weekendsConf = onlineHours.find(c => c.day === 'weekends'); 62 | 63 | if (weekendsConf && isWeekend(day)) { 64 | return isTimeInBetween(now, weekendsConf.from, weekendsConf.to); 65 | } 66 | 67 | // check by regular day config 68 | const dayConf = onlineHours.find(c => c.day === day); 69 | 70 | if (dayConf) { 71 | return isTimeInBetween(now, dayConf.from, dayConf.to); 72 | } 73 | 74 | return false; 75 | }; 76 | 77 | export const unreadMessagesSelector = { 78 | userId: { $exists: true }, 79 | internal: false, 80 | isCustomerRead: { $ne: true }, 81 | }; 82 | 83 | export const unreadMessagesQuery = (conversations: IConversationDocument[]) => { 84 | const conversationIds = conversations.map(c => c._id); 85 | 86 | return { 87 | conversationId: { $in: conversationIds }, 88 | ...unreadMessagesSelector, 89 | }; 90 | }; 91 | -------------------------------------------------------------------------------- /src/db/models/definitions/conversationMessages.ts: -------------------------------------------------------------------------------- 1 | import { Document, Schema } from 'mongoose'; 2 | import { field } from './utils'; 3 | 4 | interface IEngageDataRules { 5 | kind: string; 6 | text: string; 7 | condition: string; 8 | value?: string; 9 | } 10 | 11 | interface IEngageDataRulesDocument extends IEngageDataRules, Document {} 12 | 13 | export interface IEngageData { 14 | messageId: string; 15 | brandId: string; 16 | content: string; 17 | fromUserId: string; 18 | kind: string; 19 | sentAs: string; 20 | rules?: IEngageDataRules[]; 21 | } 22 | 23 | interface IEngageDataDocument extends IEngageData, Document { 24 | rules?: IEngageDataRulesDocument[]; 25 | } 26 | 27 | export interface IMessage { 28 | content?: string; 29 | attachments?: any; 30 | mentionedUserIds?: string[]; 31 | conversationId: string; 32 | internal?: boolean; 33 | customerId?: string; 34 | userId?: string; 35 | fromBot?: boolean; 36 | isCustomerRead?: boolean; 37 | formWidgetData?: any; 38 | messengerAppData?: any; 39 | engageData?: IEngageData; 40 | } 41 | 42 | export interface IMessageDocument extends IMessage, Document { 43 | _id: string; 44 | engageData?: IEngageDataDocument; 45 | createdAt: Date; 46 | } 47 | 48 | const attachmentSchema = new Schema( 49 | { 50 | url: field({ type: String }), 51 | name: field({ type: String }), 52 | size: field({ type: Number }), 53 | type: field({ type: String }), 54 | }, 55 | { _id: false }, 56 | ); 57 | 58 | const engageDataRuleSchema = new Schema( 59 | { 60 | kind: field({ type: String }), 61 | text: field({ type: String }), 62 | condition: field({ type: String }), 63 | value: field({ type: String, optional: true }), 64 | }, 65 | { _id: false }, 66 | ); 67 | 68 | const engageDataSchema = new Schema( 69 | { 70 | messageId: field({ type: String }), 71 | brandId: field({ type: String }), 72 | content: field({ type: String }), 73 | fromUserId: field({ type: String }), 74 | kind: field({ type: String }), 75 | sentAs: field({ type: String }), 76 | rules: field({ type: [engageDataRuleSchema], optional: true }), 77 | }, 78 | { _id: false }, 79 | ); 80 | 81 | export const messageSchema = new Schema({ 82 | _id: field({ pkey: true }), 83 | content: field({ type: String }), 84 | attachments: [attachmentSchema], 85 | mentionedUserIds: field({ type: [String] }), 86 | conversationId: field({ type: String, index: true }), 87 | internal: field({ type: Boolean, index: true }), 88 | customerId: field({ type: String, index: true }), 89 | fromBot: field({ type: Boolean }), 90 | userId: field({ type: String, index: true }), 91 | createdAt: field({ type: Date, index: true }), 92 | isCustomerRead: field({ type: Boolean }), 93 | formWidgetData: field({ type: Object }), 94 | messengerAppData: field({ type: Object }), 95 | engageData: field({ type: engageDataSchema }), 96 | }); 97 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [0.11.2](https://github.com/erxes/erxes-widgets-api/compare/0.11.1...0.11.2) (2019-12-15) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * **deal:** fixed search ([8d8863f](https://github.com/erxes/erxes-widgets-api/commit/8d8863f)), closes [#1251](https://github.com/erxes/erxes-widgets-api/issues/1251) 7 | 8 | 9 | ### Features 10 | 11 | * **customers:** added code field ([40e52d8](https://github.com/erxes/erxes-widgets-api/commit/40e52d8)), closes [#631](https://github.com/erxes/erxes-widgets-api/issues/631) 12 | * **integration:** archive ([149c367](https://github.com/erxes/erxes-widgets-api/commit/149c367)), closes [#624](https://github.com/erxes/erxes-widgets-api/issues/624) 13 | 14 | ## [0.11.1](https://github.com/erxes/erxes-widgets-api/compare/0.11.0...0.11.1) (2019-11-01) 15 | 16 | # [0.11.0](https://github.com/erxes/erxes-widgets-api/compare/0.10.1...0.11.0) (2019-11-01) 17 | 18 | 19 | ### Features 20 | 21 | * **permission:** restrict user permissions by brand ([9ffc714](https://github.com/erxes/erxes-widgets-api/commit/9ffc714)), closes [#517](https://github.com/erxes/erxes-widgets-api/issues/517) 22 | 23 | 24 | ### Performance Improvements 25 | 26 | * **deal/ticket/task:** add attachment field ([677e440](https://github.com/erxes/erxes-widgets-api/commit/677e440)), closes [erxes/erxes#1029](https://github.com/erxes/erxes/issues/1029) 27 | * **engage:** extract engage email sender logic ([1280e42](https://github.com/erxes/erxes-widgets-api/commit/1280e42)), closes [#510](https://github.com/erxes/erxes-widgets-api/issues/510) 28 | 29 | 30 | ### BREAKING CHANGES 31 | 32 | * **engage:** https://github.com/erxes/erxes-engages-email-sender is required in order to run engage properly 33 | 34 | ## [0.10.1](https://github.com/erxes/erxes-widgets-api/compare/0.10.0...0.10.1) (2019-08-31) 35 | 36 | 37 | ### Bug Fixes 38 | 39 | * **kb:** articles query searchString default value ([c65217f](https://github.com/erxes/erxes-widgets-api/commit/c65217f)), closes [#82](https://github.com/erxes/erxes-widgets-api/issues/82) 40 | 41 | # [0.10.0](https://github.com/erxes/erxes-widgets-api/compare/0.9.17...0.10.0) (2019-08-15) 42 | 43 | 44 | ### Bug Fixes 45 | 46 | * **docker:** fix dockerfile permission error ([3815953](https://github.com/erxes/erxes-widgets-api/commit/3815953)) 47 | 48 | ## [0.9.17](https://github.com/erxes/erxes-widgets-api/compare/0.9.16...0.9.17) (2019-07-09) 49 | 50 | 51 | ### Features 52 | 53 | * **deal/ticket/task:** Add watch option for deal, ticket, task and pipeline ([1956ec1](https://github.com/erxes/erxes-widgets-api/commit/1956ec1)), closes [erxes/erxes#1013](https://github.com/erxes/erxes/issues/1013) 54 | 55 | ## [0.9.16](https://github.com/erxes/erxes-widgets-api/compare/0.9.15...0.9.16) (2019-07-03) 56 | 57 | 58 | ### Bug Fixes 59 | 60 | * **drone:** workaround for wrong version information showing on version.json ([c99e3c1](https://github.com/erxes/erxes-widgets-api/commit/c99e3c1)) 61 | -------------------------------------------------------------------------------- /.drone.yml: -------------------------------------------------------------------------------- 1 | pipeline: 2 | ping: 3 | image: mongo:3.4.4 4 | commands: 5 | - sleep 15 6 | - mongo --host mongo 7 | 8 | test: 9 | image: erxes/runner:latest 10 | environment: 11 | - TEST_MONGO_URL=mongodb://mongo/test 12 | commands: 13 | - node -v 14 | - npm -v 15 | - yarn --version 16 | - yarn install 17 | - yarn lint 18 | - yarn test 19 | 20 | build: 21 | image: erxes/runner:latest 22 | commands: 23 | - apt-get update && apt-get install -y git 24 | - git checkout $DRONE_COMMIT_BRANCH 25 | - yarn build 26 | when: 27 | branch: 28 | - master 29 | - develop 30 | - staging 31 | event: 32 | - push 33 | 34 | build_tag: 35 | image: erxes/runner:latest 36 | commands: 37 | - yarn build 38 | - tar -zcf ${DRONE_REPO_NAME}_${DRONE_TAG}.tar.gz dist .env.sample package.json yarn.lock 39 | when: 40 | event: 41 | - tag 42 | 43 | github_prerelease: 44 | image: plugins/github-release 45 | secrets: [ github_token ] 46 | prerelease: true 47 | files: 48 | - ${DRONE_REPO_NAME}_${DRONE_TAG}.tar.gz 49 | checksum: 50 | - sha256 51 | when: 52 | event: 53 | - tag 54 | ref: 55 | include: 56 | - "refs/tags/*rc*" 57 | - "refs/tags/*alpha*" 58 | - "refs/tags/*beta*" 59 | 60 | github_release: 61 | image: plugins/github-release 62 | secrets: [ github_token ] 63 | files: 64 | - ${DRONE_REPO_NAME}_${DRONE_TAG}.tar.gz 65 | checksum: 66 | - sha256 67 | when: 68 | event: 69 | - tag 70 | ref: 71 | include: 72 | - "refs/tags/*" 73 | exclude: 74 | - "refs/tags/*rc*" 75 | - "refs/tags/*alpha*" 76 | - "refs/tags/*beta*" 77 | 78 | docker_publish: 79 | image: plugins/docker 80 | repo: ${DRONE_REPO_OWNER}/${DRONE_REPO_NAME} 81 | dockerfile: Dockerfile 82 | secrets: 83 | - source: docker_hub_username 84 | target: docker_username 85 | - source: docker_hub_password 86 | target: docker_password 87 | tags: 88 | - ${DRONE_BRANCH} 89 | when: 90 | branch: 91 | - master 92 | - develop 93 | - staging 94 | event: 95 | - push 96 | 97 | docker_publish_tag: 98 | image: plugins/docker 99 | repo: ${DRONE_REPO_OWNER}/${DRONE_REPO_NAME} 100 | dockerfile: Dockerfile 101 | secrets: 102 | - source: docker_hub_username 103 | target: docker_username 104 | - source: docker_hub_password 105 | target: docker_password 106 | tags: 107 | - ${DRONE_TAG} 108 | when: 109 | event: 110 | - tag 111 | 112 | services: 113 | mongo: 114 | image: mongo:3.4.4 115 | command: [--smallfiles] 116 | -------------------------------------------------------------------------------- /src/db/models/definitions/knowledgebase.ts: -------------------------------------------------------------------------------- 1 | import { Document, Schema } from 'mongoose'; 2 | import { PUBLISH_STATUSES } from './constants'; 3 | import { field, schemaWrapper } from './utils'; 4 | 5 | interface ICommonFields { 6 | createdBy: string; 7 | createdDate: Date; 8 | modifiedBy: string; 9 | modifiedDate: Date; 10 | } 11 | 12 | export interface IArticle { 13 | title?: string; 14 | summary?: string; 15 | content?: string; 16 | status?: string; 17 | reactionChoices?: string[]; 18 | reactionCounts?: { [key: string]: number }; 19 | } 20 | 21 | export interface IArticleDocument extends ICommonFields, IArticle, Document { 22 | _id: string; 23 | } 24 | 25 | export interface ICategory { 26 | title?: string; 27 | description?: string; 28 | articleIds?: string[]; 29 | icon?: string; 30 | } 31 | 32 | export interface ICategoryDocument extends ICommonFields, ICategory, Document { 33 | _id: string; 34 | } 35 | 36 | export interface ITopic { 37 | title?: string; 38 | description?: string; 39 | brandId?: string; 40 | categoryIds?: string[]; 41 | color?: string; 42 | backgroundImage?: string; 43 | languageCode?: string; 44 | } 45 | 46 | export interface ITopicDocument extends ICommonFields, ITopic, Document { 47 | _id: string; 48 | } 49 | 50 | // Mongoose schemas ================== 51 | 52 | // Schema for common fields 53 | const commonFields = { 54 | createdBy: field({ type: String }), 55 | createdDate: field({ 56 | type: Date, 57 | }), 58 | modifiedBy: field({ type: String }), 59 | modifiedDate: field({ 60 | type: Date, 61 | }), 62 | }; 63 | 64 | export const articleSchema = new Schema({ 65 | _id: field({ pkey: true }), 66 | title: field({ type: String }), 67 | summary: field({ type: String, optional: true }), 68 | content: field({ type: String }), 69 | status: field({ 70 | type: String, 71 | enum: PUBLISH_STATUSES.ALL, 72 | default: PUBLISH_STATUSES.DRAFT, 73 | }), 74 | reactionChoices: field({ type: [String], default: [] }), 75 | reactionCounts: field({ type: Object }), 76 | ...commonFields, 77 | }); 78 | 79 | export const categorySchema = new Schema({ 80 | _id: field({ pkey: true }), 81 | title: field({ type: String }), 82 | description: field({ type: String, optional: true }), 83 | articleIds: field({ type: [String] }), 84 | icon: field({ type: String, optional: true }), 85 | ...commonFields, 86 | }); 87 | 88 | export const topicSchema = schemaWrapper( 89 | new Schema({ 90 | _id: field({ pkey: true }), 91 | title: field({ type: String }), 92 | description: field({ type: String, optional: true }), 93 | brandId: field({ type: String, optional: true }), 94 | 95 | categoryIds: field({ 96 | type: [String], 97 | required: false, 98 | }), 99 | 100 | color: field({ type: String, optional: true }), 101 | backgroundImage: field({ type: String, optional: true }), 102 | 103 | languageCode: field({ 104 | type: String, 105 | optional: true, 106 | }), 107 | 108 | ...commonFields, 109 | }), 110 | ); 111 | -------------------------------------------------------------------------------- /src/db/models/definitions/forms.ts: -------------------------------------------------------------------------------- 1 | import { Document, Schema } from 'mongoose'; 2 | import { IRule, ruleSchema } from './common'; 3 | import { FORM_TYPES } from './constants'; 4 | import { calloutSchema, ICallout, ISubmission, submissionSchema } from './integrations'; 5 | import { field, schemaWrapper } from './utils'; 6 | 7 | export interface IForm { 8 | title: string; 9 | code?: string; 10 | type: string; 11 | description?: string; 12 | buttonText?: string; 13 | } 14 | 15 | export interface IFormDocument extends IForm, Document { 16 | _id: string; 17 | createdUserId: string; 18 | createdDate: Date; 19 | // TODO: remove 20 | contactsGathered?: number; 21 | // TODO: remove 22 | viewCount?: number; 23 | // TODO: remove 24 | submissions?: ISubmission[]; 25 | // TODO: remove 26 | themeColor?: string; 27 | // TODO: remove 28 | callout?: ICallout; 29 | // TODO: remove 30 | rules?: IRule; 31 | } 32 | 33 | // schema for form document 34 | export const formSchema = schemaWrapper( 35 | new Schema({ 36 | _id: field({ pkey: true }), 37 | title: field({ type: String, optional: true }), 38 | type: field({ type: String, enum: FORM_TYPES.ALL, required: true }), 39 | description: field({ 40 | type: String, 41 | optional: true, 42 | }), 43 | buttonText: field({ type: String, optional: true }), 44 | code: field({ type: String }), 45 | createdUserId: field({ type: String }), 46 | createdDate: field({ 47 | type: Date, 48 | default: Date.now, 49 | }), 50 | 51 | // TODO: remove 52 | themeColor: field({ 53 | type: String, 54 | optional: true, 55 | }), 56 | // TODO: remove 57 | callout: field({ 58 | type: calloutSchema, 59 | optional: true, 60 | }), 61 | // TODO: remove 62 | viewCount: field({ 63 | type: Number, 64 | optional: true, 65 | }), 66 | // TODO: remove 67 | contactsGathered: field({ 68 | type: Number, 69 | optional: true, 70 | }), 71 | // TODO: remove 72 | submissions: field({ 73 | type: [submissionSchema], 74 | optional: true, 75 | }), 76 | // TODO: remove 77 | rules: field({ 78 | type: [ruleSchema], 79 | optional: true, 80 | }), 81 | }), 82 | ); 83 | 84 | export interface IFormSubmission { 85 | customerId?: string; 86 | contentType?: string; 87 | contentTypeId?: string; 88 | formId?: string; 89 | formFieldId?: string; 90 | value?: JSON; 91 | submittedAt?: Date; 92 | } 93 | 94 | export interface IFormSubmissionDocument extends IFormSubmission, Document { 95 | _id: string; 96 | } 97 | 98 | // schema for form submission document 99 | export const formSubmissionSchema = schemaWrapper( 100 | new Schema({ 101 | _id: field({ pkey: true }), 102 | customerId: field({ type: String, optional: true }), 103 | contentType: field({ type: String, optional: true }), 104 | contentTypeId: field({ type: String, optional: true }), 105 | value: field({ type: Object, optional: true }), 106 | submittedAt: field({ type: Date, default: Date.now }), 107 | formId: field({ type: String, optional: true }), 108 | formFieldId: field({ type: String, optional: true }), 109 | }), 110 | ); 111 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { ApolloServer, PlaygroundConfig } from 'apollo-server-express'; 2 | import * as bodyParser from 'body-parser'; 3 | import * as cors from 'cors'; 4 | import * as dotenv from 'dotenv'; 5 | import * as express from 'express'; 6 | import * as path from 'path'; 7 | import resolvers from './data/resolvers'; 8 | import typeDefs from './data/schema'; 9 | import { connect } from './db/connection'; 10 | import { debugInit } from './debuggers'; 11 | 12 | // load environment variables 13 | dotenv.config(); 14 | 15 | // connect to mongo database 16 | const connectionPromise = connect(); 17 | 18 | const app = express(); 19 | 20 | app.disable('x-powered-by'); 21 | app.use('/static', express.static(path.join(__dirname, 'static'))); 22 | 23 | app.use(bodyParser.urlencoded({ extended: true })); 24 | app.use(bodyParser.json()); 25 | 26 | app.use(cors()); 27 | 28 | const { NODE_ENV, PORT } = process.env; 29 | 30 | let playground: PlaygroundConfig = false; 31 | 32 | if (NODE_ENV !== 'production') { 33 | playground = { 34 | settings: { 35 | 'general.betaUpdates': false, 36 | 'editor.theme': 'dark', 37 | 'editor.reuseHeaders': true, 38 | 'tracing.hideTracingResponse': true, 39 | 'editor.fontSize': 14, 40 | 'editor.fontFamily': `'Source Code Pro', 'Consolas', 'Inconsolata', 'Droid Sans Mono', 'Monaco', monospace`, 41 | 'request.credentials': 'include', 42 | }, 43 | }; 44 | } 45 | 46 | const apolloServer = new ApolloServer({ 47 | typeDefs, 48 | resolvers, 49 | playground, 50 | }); 51 | 52 | // for health check 53 | app.get('/status', async (_, res) => { 54 | res.end('ok'); 55 | }); 56 | 57 | app.get('/script-manager', async (req, res) => { 58 | const { WIDGET_URL } = process.env; 59 | 60 | const instance = await connectionPromise; 61 | 62 | const script = await instance.connection.db.collection('scripts').findOne({ _id: req.query.id }); 63 | 64 | if (!script) { 65 | res.end('Not found'); 66 | } 67 | 68 | const generateScript = type => { 69 | return ` 70 | (function() { 71 | var script = document.createElement('script'); 72 | script.src = "${WIDGET_URL}/build/${type}Widget.bundle.js"; 73 | script.async = true; 74 | 75 | var entry = document.getElementsByTagName('script')[0]; 76 | entry.parentNode.insertBefore(script, entry); 77 | })(); 78 | `; 79 | }; 80 | 81 | let erxesSettings = '{'; 82 | let includeScripts = ''; 83 | 84 | if (script.messengerBrandCode) { 85 | erxesSettings += `messenger: { brand_id: "${script.messengerBrandCode}" },`; 86 | includeScripts += generateScript('messenger'); 87 | } 88 | 89 | if (script.kbTopicId) { 90 | erxesSettings += `knowledgeBase: { topic_id: "${script.kbTopicId}" },`; 91 | includeScripts += generateScript('knowledgebase'); 92 | } 93 | 94 | if (script.leadMaps) { 95 | erxesSettings += 'forms: ['; 96 | 97 | script.leadMaps.forEach(map => { 98 | erxesSettings += `{ brand_id: "${map.brandCode}", form_id: "${map.formCode}" },`; 99 | includeScripts += generateScript('form'); 100 | }); 101 | 102 | erxesSettings += '],'; 103 | } 104 | 105 | erxesSettings = `${erxesSettings}}`; 106 | 107 | res.end(`window.erxesSettings=${erxesSettings};${includeScripts}`); 108 | }); 109 | 110 | apolloServer.applyMiddleware({ app, path: '/graphql' }); 111 | 112 | app.listen(PORT, () => { 113 | debugInit(`Websocket server is running on port ${PORT}`); 114 | }); 115 | -------------------------------------------------------------------------------- /src/data/resolvers/queries/messenger.ts: -------------------------------------------------------------------------------- 1 | import { Conversations, Integrations, Messages, Users } from '../../../db/models'; 2 | import { isOnline as isStaffsOnline, unreadMessagesQuery, unreadMessagesSelector } from '../utils/messenger'; 3 | 4 | const isMessengerOnline = async (integrationId: string) => { 5 | const integration = await Integrations.findOne({ _id: integrationId }); 6 | 7 | if (!integration) { 8 | return false; 9 | } 10 | 11 | if (!integration.messengerData) { 12 | return false; 13 | } 14 | 15 | const { availabilityMethod, isOnline, onlineHours } = integration.messengerData; 16 | 17 | const modifiedIntegration = { 18 | ...integration.toJSON(), 19 | messengerData: { 20 | availabilityMethod, 21 | isOnline, 22 | onlineHours, 23 | }, 24 | }; 25 | 26 | return isStaffsOnline(modifiedIntegration); 27 | }; 28 | 29 | const messengerSupporters = async (integrationId: string) => { 30 | const integration = await Integrations.findOne({ _id: integrationId }); 31 | 32 | if (!integration) { 33 | return []; 34 | } 35 | 36 | const messengerData = integration.messengerData || { supporterIds: [] }; 37 | 38 | return Users.find({ _id: { $in: messengerData.supporterIds } }); 39 | }; 40 | 41 | export default { 42 | getMessengerIntegration(_root, args: { brandCode: string }) { 43 | return Integrations.getIntegration(args.brandCode, 'messenger'); 44 | }, 45 | 46 | conversations(_root, args: { integrationId: string; customerId: string }) { 47 | const { integrationId, customerId } = args; 48 | 49 | return Conversations.find({ 50 | integrationId, 51 | customerId, 52 | }).sort({ createdAt: -1 }); 53 | }, 54 | 55 | async conversationDetail(_root, args: { _id: string; integrationId: string }) { 56 | const { _id, integrationId } = args; 57 | const conversation = await Conversations.findOne({ _id }); 58 | 59 | if (!conversation) { 60 | return null; 61 | } 62 | 63 | return { 64 | _id, 65 | messages: await Conversations.getMessages(conversation._id), 66 | isOnline: await isMessengerOnline(integrationId), 67 | participatedUsers: await Users.find({ 68 | _id: { $in: conversation.participatedUserIds }, 69 | }), 70 | supporters: await messengerSupporters(integrationId), 71 | }; 72 | }, 73 | 74 | messages(_root, args: { conversationId: string }) { 75 | const { conversationId } = args; 76 | 77 | return Conversations.getMessages(conversationId); 78 | }, 79 | 80 | unreadCount(_root, args: { conversationId: string }) { 81 | const { conversationId } = args; 82 | 83 | return Messages.countDocuments({ 84 | conversationId, 85 | ...unreadMessagesSelector, 86 | }); 87 | }, 88 | 89 | async totalUnreadCount(_root, args: { integrationId: string; customerId: string }) { 90 | const { integrationId, customerId } = args; 91 | 92 | // find conversations 93 | const convs = await Conversations.find({ integrationId, customerId }); 94 | 95 | // find read messages count 96 | return Messages.countDocuments(unreadMessagesQuery(convs)); 97 | }, 98 | 99 | async messengerSupporters(_root, { integrationId }: { integrationId: string }) { 100 | const integration = await Integrations.findOne({ _id: integrationId }); 101 | const messengerData = integration.messengerData || { supporterIds: [] }; 102 | 103 | return Users.find({ _id: { $in: messengerData.supporterIds || [] } }); 104 | }, 105 | }; 106 | -------------------------------------------------------------------------------- /src/db/models/definitions/deals.ts: -------------------------------------------------------------------------------- 1 | import { Document, Schema } from 'mongoose'; 2 | import { commonItemFieldsSchema, IItemCommonFields } from './boards'; 3 | import { PRODUCT_TYPES } from './constants'; 4 | import { field, schemaWrapper } from './utils'; 5 | 6 | export interface IProduct { 7 | name: string; 8 | categoryId?: string; 9 | categoryCode?: string; 10 | type?: string; 11 | description?: string; 12 | sku?: string; 13 | unitPrice?: number; 14 | code: string; 15 | customFieldsData?: any; 16 | productId?: string; 17 | tagIds?: string[]; 18 | } 19 | 20 | export interface IProductDocument extends IProduct, Document { 21 | _id: string; 22 | createdAt: Date; 23 | } 24 | 25 | export interface IProductCategory { 26 | name: string; 27 | code: string; 28 | order: string; 29 | description?: string; 30 | parentId?: string; 31 | } 32 | 33 | export interface IProductCategoryDocument extends IProductCategory, Document { 34 | _id: string; 35 | createdAt: Date; 36 | } 37 | 38 | interface IProductData extends Document { 39 | productId: string; 40 | uom: string; 41 | currency: string; 42 | quantity: number; 43 | unitPrice: number; 44 | taxPercent?: number; 45 | tax?: number; 46 | discountPercent?: number; 47 | discount?: number; 48 | amount?: number; 49 | } 50 | 51 | export interface IDeal extends IItemCommonFields { 52 | productsData?: IProductData[]; 53 | } 54 | 55 | export interface IDealDocument extends IDeal, Document { 56 | _id: string; 57 | } 58 | 59 | // Mongoose schemas ======================= 60 | 61 | export const productSchema = schemaWrapper( 62 | new Schema({ 63 | _id: field({ pkey: true }), 64 | name: field({ type: String }), 65 | code: field({ type: String, unique: true }), 66 | categoryId: field({ type: String }), 67 | type: field({ 68 | type: String, 69 | enum: PRODUCT_TYPES.ALL, 70 | default: PRODUCT_TYPES.PRODUCT, 71 | }), 72 | tagIds: field({ type: [String], optional: true }), 73 | description: field({ type: String, optional: true }), 74 | sku: field({ type: String, optional: true }), // Stock Keeping Unit 75 | unitPrice: field({ type: Number, optional: true }), 76 | customFieldsData: field({ 77 | type: Object, 78 | }), 79 | createdAt: field({ 80 | type: Date, 81 | default: new Date(), 82 | }), 83 | }), 84 | ); 85 | 86 | export const productCategorySchema = schemaWrapper( 87 | new Schema({ 88 | _id: field({ pkey: true }), 89 | name: field({ type: String }), 90 | code: field({ type: String, unique: true }), 91 | order: field({ type: String }), 92 | parentId: field({ type: String, optional: true }), 93 | description: field({ type: String, optional: true }), 94 | createdAt: field({ 95 | type: Date, 96 | default: new Date(), 97 | }), 98 | }), 99 | ); 100 | 101 | const productDataSchema = new Schema( 102 | { 103 | _id: field({ type: String }), 104 | productId: field({ type: String }), 105 | uom: field({ type: String }), // Units of measurement 106 | currency: field({ type: String }), 107 | quantity: field({ type: Number }), 108 | unitPrice: field({ type: Number }), 109 | taxPercent: field({ type: Number }), 110 | tax: field({ type: Number }), 111 | discountPercent: field({ type: Number }), 112 | discount: field({ type: Number }), 113 | amount: field({ type: Number }), 114 | }, 115 | { _id: false }, 116 | ); 117 | 118 | export const dealSchema = new Schema({ 119 | ...commonItemFieldsSchema, 120 | 121 | productsData: field({ type: [productDataSchema] }), 122 | }); 123 | -------------------------------------------------------------------------------- /src/db/models/Conversations.ts: -------------------------------------------------------------------------------- 1 | import { Model, model } from 'mongoose'; 2 | import { CONVERSATION_STATUSES } from './definitions/constants'; 3 | import { IMessageDocument } from './definitions/conversationMessages'; 4 | import { conversationSchema, IConversationDocument } from './definitions/conversations'; 5 | 6 | import { sendMessage } from '../../messageQueue'; 7 | import { Messages } from './'; 8 | 9 | interface ISTATUSES { 10 | NEW: 'new'; 11 | OPEN: 'open'; 12 | CLOSED: 'closed'; 13 | ALL_LIST: ['new', 'open', 'closed']; 14 | } 15 | 16 | interface IConversationParams { 17 | conversationId?: string; 18 | userId?: string; 19 | integrationId: string; 20 | customerId: string; 21 | content: string; 22 | } 23 | 24 | interface IConversationModel extends Model { 25 | getMessages(conversationId: string): Promise; 26 | getConversationStatuses(): ISTATUSES; 27 | createConversation(doc: IConversationParams): Promise; 28 | getOrCreateConversation(doc: IConversationParams): Promise; 29 | } 30 | 31 | export const loadClass = () => { 32 | class Conversation { 33 | public static getConversationStatuses() { 34 | return CONVERSATION_STATUSES; 35 | } 36 | 37 | public static getMessages(conversationId: string) { 38 | return Messages.find({ 39 | conversationId, 40 | internal: false, 41 | fromBot: { $exists: false }, 42 | }).sort({ 43 | createdAt: 1, 44 | }); 45 | } 46 | 47 | /** 48 | * Create new conversation 49 | */ 50 | public static async createConversation(doc: IConversationParams) { 51 | const { integrationId, userId, customerId, content } = doc; 52 | 53 | const count = await Conversations.find({ 54 | customerId, 55 | integrationId, 56 | }).countDocuments(); 57 | 58 | const conversation = await Conversations.create({ 59 | customerId, 60 | userId, 61 | integrationId, 62 | content, 63 | status: this.getConversationStatuses().NEW, 64 | createdAt: new Date(), 65 | messageCount: 0, 66 | 67 | // Number is used for denormalization of posts count 68 | number: count + 1, 69 | }); 70 | 71 | sendMessage('activityLog', { 72 | type: 'create-conversation', 73 | payload: conversation, 74 | }); 75 | 76 | return conversation; 77 | } 78 | 79 | /** 80 | * Get or create conversation 81 | */ 82 | public static getOrCreateConversation(doc: IConversationParams) { 83 | const { conversationId, integrationId, customerId, content } = doc; 84 | 85 | // customer can write a message 86 | // to the closed conversation even if it's closed 87 | if (conversationId) { 88 | return Conversations.findByIdAndUpdate( 89 | conversationId, 90 | { 91 | // mark this conversation as unread 92 | readUserIds: [], 93 | 94 | // reopen this conversation if it's closed 95 | status: this.getConversationStatuses().OPEN, 96 | }, 97 | { new: true }, 98 | ); 99 | } 100 | 101 | // create conversation 102 | return this.createConversation({ 103 | customerId, 104 | integrationId, 105 | content, 106 | }); 107 | } 108 | } 109 | 110 | conversationSchema.loadClass(Conversation); 111 | 112 | return conversationSchema; 113 | }; 114 | 115 | loadClass(); 116 | 117 | // tslint:disable-next-line 118 | const Conversations = model('conversations', conversationSchema); 119 | 120 | export default Conversations; 121 | -------------------------------------------------------------------------------- /src/db/models/definitions/users.ts: -------------------------------------------------------------------------------- 1 | import { Document, Schema } from 'mongoose'; 2 | import { field } from './utils'; 3 | 4 | export interface IEmailSignature { 5 | brandId?: string; 6 | signature?: string; 7 | } 8 | 9 | export interface IEmailSignatureDocument extends IEmailSignature, Document {} 10 | 11 | export interface IDetail { 12 | avatar?: string; 13 | fullName?: string; 14 | shortName?: string; 15 | position?: string; 16 | location?: string; 17 | description?: string; 18 | } 19 | 20 | export interface IDetailDocument extends IDetail, Document {} 21 | 22 | export interface ILink { 23 | linkedIn?: string; 24 | twitter?: string; 25 | facebook?: string; 26 | github?: string; 27 | youtube?: string; 28 | website?: string; 29 | } 30 | 31 | interface ILinkDocument extends ILink, Document {} 32 | 33 | export interface IUser { 34 | username?: string; 35 | password: string; 36 | resetPasswordToken?: string; 37 | resetPasswordExpires?: Date; 38 | registrationToken?: string; 39 | registrationTokenExpires?: Date; 40 | isOwner?: boolean; 41 | email?: string; 42 | getNotificationByEmail?: boolean; 43 | emailSignatures?: IEmailSignature[]; 44 | starredConversationIds?: string[]; 45 | details?: IDetail; 46 | links?: ILink; 47 | isActive?: boolean; 48 | brandIds?: string[]; 49 | groupIds?: string[]; 50 | deviceTokens?: string[]; 51 | } 52 | 53 | export interface IUserDocument extends IUser, Document { 54 | _id: string; 55 | emailSignatures?: IEmailSignatureDocument[]; 56 | details?: IDetailDocument; 57 | links?: ILinkDocument; 58 | } 59 | 60 | // Mongoose schemas =============================== 61 | const emailSignatureSchema = new Schema( 62 | { 63 | brandId: field({ type: String }), 64 | signature: field({ type: String }), 65 | }, 66 | { _id: false }, 67 | ); 68 | 69 | // Detail schema 70 | const detailSchema = new Schema( 71 | { 72 | avatar: field({ type: String }), 73 | shortName: field({ type: String, optional: true }), 74 | fullName: field({ type: String }), 75 | position: field({ type: String }), 76 | location: field({ type: String, optional: true }), 77 | description: field({ type: String, optional: true }), 78 | }, 79 | { _id: false }, 80 | ); 81 | 82 | const linkSchema = new Schema( 83 | { 84 | linkedIn: field({ type: String, optional: true }), 85 | twitter: field({ type: String, optional: true }), 86 | facebook: field({ type: String, optional: true }), 87 | github: field({ type: String, optional: true }), 88 | youtube: field({ type: String, optional: true }), 89 | website: field({ type: String, optional: true }), 90 | }, 91 | { _id: false }, 92 | ); 93 | 94 | // User schema 95 | export const userSchema = new Schema({ 96 | _id: field({ pkey: true }), 97 | username: field({ type: String }), 98 | password: field({ type: String }), 99 | resetPasswordToken: field({ type: String }), 100 | registrationToken: field({ type: String }), 101 | registrationTokenExpires: field({ type: Date }), 102 | resetPasswordExpires: field({ type: Date }), 103 | isOwner: field({ type: Boolean }), 104 | email: field({ 105 | type: String, 106 | unique: true, 107 | match: [/^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*(\.\w{2,8})+$/, 'Please fill a valid email address'], 108 | }), 109 | getNotificationByEmail: field({ type: Boolean }), 110 | emailSignatures: field({ type: [emailSignatureSchema] }), 111 | starredConversationIds: field({ type: [String] }), 112 | details: field({ type: detailSchema, default: {} }), 113 | links: field({ type: linkSchema, default: {} }), 114 | isActive: field({ type: Boolean, default: true }), 115 | brandIds: field({ type: [String] }), 116 | groupIds: field({ type: [String] }), 117 | deviceTokens: field({ type: [String], default: [] }), 118 | }); 119 | -------------------------------------------------------------------------------- /src/db/models/definitions/engages.ts: -------------------------------------------------------------------------------- 1 | import { Document, Schema } from 'mongoose'; 2 | import { IRule, ruleSchema } from './common'; 3 | import { MESSENGER_KINDS, METHODS, SENT_AS_CHOICES } from './constants'; 4 | import { field, schemaWrapper } from './utils'; 5 | 6 | export interface IScheduleDate { 7 | type?: string; 8 | month?: string | number; 9 | day?: string | number; 10 | time?: string; 11 | } 12 | 13 | interface IScheduleDateDocument extends IScheduleDate, Document {} 14 | 15 | export interface IEmail { 16 | attachments?: any; 17 | subject?: string; 18 | content?: string; 19 | templateId?: string; 20 | } 21 | 22 | export interface IEmailDocument extends IEmail, Document {} 23 | 24 | export interface IMessenger { 25 | brandId?: string; 26 | kind?: string; 27 | sentAs?: string; 28 | content?: string; 29 | rules?: IRule[]; 30 | } 31 | 32 | interface IMessengerDocument extends IMessenger, Document {} 33 | 34 | export interface IEngageMessage { 35 | kind?: string; 36 | segmentIds?: string[]; 37 | brandIds?: string[]; 38 | tagIds?: string[]; 39 | customerIds?: string[]; 40 | title?: string; 41 | fromUserId?: string; 42 | method?: string; 43 | isDraft?: boolean; 44 | isLive?: boolean; 45 | stopDate?: Date; 46 | messengerReceivedCustomerIds?: string[]; 47 | email?: IEmail; 48 | scheduleDate?: IScheduleDate; 49 | messenger?: IMessenger; 50 | } 51 | 52 | export interface IEngageMessageDocument extends IEngageMessage, Document { 53 | scheduleDate?: IScheduleDateDocument; 54 | 55 | email?: IEmailDocument; 56 | messenger?: IMessengerDocument; 57 | 58 | _id: string; 59 | } 60 | 61 | // Mongoose schemas ======================= 62 | const scheduleDateSchema = new Schema( 63 | { 64 | type: field({ type: String, optional: true }), 65 | month: field({ type: String, optional: true }), 66 | day: field({ type: String, optional: true }), 67 | time: field({ type: Date, optional: true }), 68 | }, 69 | { _id: false }, 70 | ); 71 | 72 | const emailSchema = new Schema( 73 | { 74 | attachments: field({ type: Object, optional: true }), 75 | subject: field({ type: String }), 76 | content: field({ type: String }), 77 | templateId: field({ type: String, optional: true }), 78 | }, 79 | { _id: false }, 80 | ); 81 | 82 | const messengerSchema = new Schema( 83 | { 84 | brandId: field({ type: String }), 85 | kind: field({ 86 | type: String, 87 | enum: MESSENGER_KINDS.ALL, 88 | }), 89 | sentAs: field({ 90 | type: String, 91 | enum: SENT_AS_CHOICES.ALL, 92 | }), 93 | content: field({ type: String }), 94 | rules: field({ type: [ruleSchema] }), 95 | }, 96 | { _id: false }, 97 | ); 98 | 99 | export const engageMessageSchema = schemaWrapper( 100 | new Schema({ 101 | _id: field({ pkey: true }), 102 | kind: field({ type: String }), 103 | segmentId: field({ type: String, optional: true }), // TODO Remove 104 | segmentIds: field({ 105 | type: [String], 106 | optional: true, 107 | }), 108 | brandIds: field({ 109 | type: [String], 110 | optional: true, 111 | }), 112 | customerIds: field({ type: [String] }), 113 | title: field({ type: String }), 114 | fromUserId: field({ type: String }), 115 | method: field({ 116 | type: String, 117 | enum: METHODS.ALL, 118 | }), 119 | isDraft: field({ type: Boolean }), 120 | isLive: field({ type: Boolean }), 121 | stopDate: field({ type: Date }), 122 | createdDate: field({ type: Date }), 123 | tagIds: field({ type: [String], optional: true }), 124 | messengerReceivedCustomerIds: field({ type: [String] }), 125 | 126 | email: field({ type: emailSchema }), 127 | scheduleDate: field({ type: scheduleDateSchema }), 128 | messenger: field({ type: messengerSchema }), 129 | }), 130 | ); 131 | -------------------------------------------------------------------------------- /src/db/models/definitions/activityLogs.ts: -------------------------------------------------------------------------------- 1 | import { Document, Schema } from 'mongoose'; 2 | import { ACTIVITY_ACTIONS, ACTIVITY_CONTENT_TYPES, ACTIVITY_PERFORMER_TYPES, ACTIVITY_TYPES } from './constants'; 3 | import { field } from './utils'; 4 | 5 | export interface IActionPerformer { 6 | type: string; 7 | id: string; 8 | } 9 | 10 | interface IActionPerformerDocument extends IActionPerformer, Document { 11 | id: string; 12 | } 13 | 14 | export interface IActivity { 15 | type: string; 16 | action: string; 17 | content?: string; 18 | id?: string; 19 | } 20 | 21 | interface IActivityDocument extends IActivity, Document { 22 | id?: string; 23 | } 24 | 25 | export interface IContentType { 26 | id: string; 27 | type: string; 28 | } 29 | 30 | interface IContentTypeDocument extends IContentType, Document { 31 | id: string; 32 | } 33 | 34 | export interface IActivityLogDocument extends Document { 35 | _id: string; 36 | activity: IActivityDocument; 37 | performedBy?: IActionPerformerDocument; 38 | contentType: IContentTypeDocument; 39 | createdAt: Date; 40 | } 41 | 42 | export interface IActivityLog { 43 | contentType: string; 44 | contentId: string; 45 | activityType: string; 46 | limit: number; 47 | } 48 | 49 | // Mongoose schemas =========== 50 | 51 | /* Performer of the action: 52 | *system* cron job, user 53 | ex: Sales manager that has registered a new customer 54 | Sales manager is the action performer */ 55 | const actionPerformerSchema = new Schema( 56 | { 57 | type: field({ 58 | type: String, 59 | enum: ACTIVITY_PERFORMER_TYPES.ALL, 60 | default: ACTIVITY_PERFORMER_TYPES.SYSTEM, 61 | required: true, 62 | }), 63 | id: field({ 64 | type: String, 65 | }), 66 | }, 67 | { _id: false }, 68 | ); 69 | 70 | /* 71 | The action that is being performed 72 | ex1: A user writes an internal note 73 | in this case: type is InternalNote 74 | action is create (write) 75 | id is the InternalNote id 76 | ex2: Sales manager registers a new customer 77 | in this case: type is customer 78 | action is create (register) 79 | id is Customer id 80 | customer and activity contentTypes are the same in this case 81 | ex3: Cronjob runs and a customer is found to be suitable for a particular segment 82 | action is create: a new segment user 83 | type is segment 84 | id is Segment id 85 | ex4: An internalNote concerning a customer was updated 86 | action is update 87 | type is InternalNote 88 | id is InternalNote id 89 | */ 90 | const activitySchema = new Schema( 91 | { 92 | type: field({ 93 | type: String, 94 | required: true, 95 | enum: ACTIVITY_TYPES.ALL, 96 | }), 97 | action: field({ 98 | type: String, 99 | required: true, 100 | enum: ACTIVITY_ACTIONS.ALL, 101 | }), 102 | content: field({ 103 | type: String, 104 | optional: true, 105 | }), 106 | id: field({ 107 | type: String, 108 | }), 109 | }, 110 | { _id: false }, 111 | ); 112 | 113 | /* the customer that is related to a given ActivityLog 114 | can be both Company or Customer documents */ 115 | const contentTypeSchema = new Schema( 116 | { 117 | id: field({ 118 | type: String, 119 | required: true, 120 | }), 121 | type: field({ 122 | type: String, 123 | enum: ACTIVITY_CONTENT_TYPES.ALL, 124 | required: true, 125 | }), 126 | }, 127 | { _id: false }, 128 | ); 129 | 130 | export const activityLogSchema = new Schema({ 131 | _id: field({ pkey: true }), 132 | activity: { type: activitySchema }, 133 | performedBy: { type: actionPerformerSchema, optional: true }, 134 | contentType: { type: contentTypeSchema }, 135 | // TODO: remove 136 | coc: { type: contentTypeSchema, optional: true }, 137 | 138 | createdAt: field({ 139 | type: Date, 140 | required: true, 141 | default: Date.now, 142 | }), 143 | }); 144 | -------------------------------------------------------------------------------- /src/db/models/Integrations.ts: -------------------------------------------------------------------------------- 1 | import { Model, model } from 'mongoose'; 2 | import { MessengerApps } from '.'; 3 | import Brands from './Brands'; 4 | import { IIntegrationDocument, IMessengerDataMessagesItem, integrationSchema } from './definitions/integrations'; 5 | import { IKnowledgebaseCredentials, ILeadCredentials } from './definitions/messengerApps'; 6 | 7 | interface IIntegrationModel extends Model { 8 | getIntegration(brandCode: string, kind: string, brandObject?: boolean): IIntegrationDocument; 9 | 10 | getMessengerData(integration: IIntegrationDocument); 11 | 12 | increaseViewCount(formId: string): Promise; 13 | increaseContactsGathered(formId: string): Promise; 14 | } 15 | 16 | export const loadClass = () => { 17 | class Integration { 18 | /* 19 | * Get integration 20 | */ 21 | public static async getIntegration(brandCode: string, kind: string, brandObject = false) { 22 | const brand = await Brands.findOne({ code: brandCode }); 23 | 24 | if (!brand) { 25 | throw new Error('Brand not found'); 26 | } 27 | 28 | const integration = await Integrations.findOne({ 29 | brandId: brand._id, 30 | kind, 31 | }); 32 | 33 | if (brandObject) { 34 | return { 35 | integration, 36 | brand, 37 | }; 38 | } 39 | 40 | return integration; 41 | } 42 | 43 | public static async getMessengerData(integration: IIntegrationDocument) { 44 | let messagesByLanguage: IMessengerDataMessagesItem; 45 | let messengerData = integration.messengerData; 46 | if (messengerData) { 47 | messengerData = messengerData.toJSON(); 48 | 49 | const languageCode = integration.languageCode || 'en'; 50 | const messages = messengerData.messages; 51 | 52 | if (messages) { 53 | messagesByLanguage = messages[languageCode]; 54 | } 55 | } 56 | 57 | // knowledgebase app ======= 58 | const kbApp = await MessengerApps.findOne({ 59 | kind: 'knowledgebase', 60 | 'credentials.integrationId': integration._id, 61 | }); 62 | 63 | const topicId = kbApp && kbApp.credentials ? (kbApp.credentials as IKnowledgebaseCredentials).topicId : null; 64 | 65 | // lead app ========== 66 | const leadApp = await MessengerApps.findOne({ 67 | kind: 'lead', 68 | 'credentials.integrationId': integration._id, 69 | }); 70 | 71 | const formCode = leadApp && leadApp.credentials ? (leadApp.credentials as ILeadCredentials).formCode : null; 72 | 73 | return { 74 | ...(messengerData || {}), 75 | messages: messagesByLanguage, 76 | knowledgeBaseTopicId: topicId, 77 | formCode, 78 | }; 79 | } 80 | 81 | public static async increaseViewCount(formId: string) { 82 | const integration = await Integrations.findOne({ formId }); 83 | 84 | if (!integration) { 85 | throw new Error('Integration not found'); 86 | } 87 | 88 | const leadData = integration.leadData; 89 | 90 | let viewCount = 0; 91 | 92 | if (leadData && leadData.viewCount) { 93 | viewCount = leadData.viewCount; 94 | } 95 | 96 | viewCount++; 97 | 98 | leadData.viewCount = viewCount; 99 | 100 | await Integrations.updateOne({ formId }, { leadData }); 101 | 102 | return Integrations.findOne({ formId }); 103 | } 104 | 105 | /* 106 | * Increase form submitted count 107 | */ 108 | public static async increaseContactsGathered(formId: string) { 109 | const integration = await Integrations.findOne({ formId }); 110 | 111 | if (!integration) { 112 | throw new Error('Integration not found'); 113 | } 114 | 115 | const leadData = integration.leadData; 116 | 117 | let contactsGathered = 0; 118 | 119 | if (leadData && leadData.contactsGathered) { 120 | contactsGathered = leadData.contactsGathered; 121 | } 122 | 123 | contactsGathered++; 124 | 125 | leadData.contactsGathered = contactsGathered; 126 | 127 | await Integrations.updateOne({ formId }, { leadData }); 128 | 129 | return Integrations.findOne({ formId }); 130 | } 131 | } 132 | 133 | integrationSchema.loadClass(Integration); 134 | 135 | return integrationSchema; 136 | }; 137 | 138 | loadClass(); 139 | 140 | // tslint:disable-next-line 141 | const Integrations = model('integrations', integrationSchema); 142 | 143 | export default Integrations; 144 | -------------------------------------------------------------------------------- /src/db/models/definitions/companies.ts: -------------------------------------------------------------------------------- 1 | import { Document, Schema } from 'mongoose'; 2 | 3 | import { 4 | COMPANY_BUSINESS_TYPES, 5 | COMPANY_INDUSTRY_TYPES, 6 | COMPANY_LEAD_STATUS_TYPES, 7 | COMPANY_LIFECYCLE_STATE_TYPES, 8 | STATUSES, 9 | } from './constants'; 10 | 11 | import { field, schemaWrapper } from './utils'; 12 | 13 | export interface ILink { 14 | linkedIn?: string; 15 | twitter?: string; 16 | facebook?: string; 17 | github?: string; 18 | youtube?: string; 19 | website?: string; 20 | } 21 | 22 | interface ILinkDocument extends ILink, Document {} 23 | 24 | export interface ICompany { 25 | scopeBrandIds?: string[]; 26 | primaryName?: string; 27 | avatar?: string; 28 | names?: string[]; 29 | size?: number; 30 | industry?: string; 31 | plan?: string; 32 | parentCompanyId?: string; 33 | 34 | primaryEmail?: string; 35 | emails?: string[]; 36 | 37 | ownerId?: string; 38 | 39 | primaryPhone?: string; 40 | phones?: string[]; 41 | 42 | mergedIds?: string[]; 43 | leadStatus?: string; 44 | status?: string; 45 | lifecycleState?: string; 46 | businessType?: string; 47 | description?: string; 48 | employees?: number; 49 | doNotDisturb?: string; 50 | links?: ILink; 51 | tagIds?: string[]; 52 | customFieldsData?: any; 53 | website?: string; 54 | } 55 | 56 | export interface ICompanyDocument extends ICompany, Document { 57 | _id: string; 58 | links?: ILinkDocument; 59 | status?: string; 60 | createdAt: Date; 61 | modifiedAt: Date; 62 | searchText: string; 63 | } 64 | 65 | const linkSchema = new Schema( 66 | { 67 | linkedIn: field({ type: String, optional: true, label: 'LinkedIn' }), 68 | twitter: field({ type: String, optional: true, label: 'Twitter' }), 69 | facebook: field({ type: String, optional: true, label: 'Facebook' }), 70 | github: field({ type: String, optional: true, label: 'Github' }), 71 | youtube: field({ type: String, optional: true, label: 'Youtube' }), 72 | website: field({ type: String, optional: true, label: 'Website' }), 73 | }, 74 | { _id: false }, 75 | ); 76 | 77 | export const companySchema = schemaWrapper( 78 | new Schema({ 79 | _id: field({ pkey: true }), 80 | 81 | createdAt: field({ type: Date, label: 'Created at' }), 82 | modifiedAt: field({ type: Date, label: 'Modified at' }), 83 | 84 | primaryName: field({ 85 | type: String, 86 | label: 'Name', 87 | }), 88 | 89 | names: field({ 90 | type: [String], 91 | optional: true, 92 | }), 93 | 94 | avatar: field({ 95 | type: String, 96 | optional: true, 97 | }), 98 | 99 | size: field({ 100 | type: Number, 101 | label: 'Size', 102 | optional: true, 103 | }), 104 | 105 | industry: field({ 106 | type: String, 107 | enum: COMPANY_INDUSTRY_TYPES, 108 | label: 'Industry', 109 | optional: true, 110 | }), 111 | 112 | website: field({ 113 | type: String, 114 | label: 'Website', 115 | optional: true, 116 | }), 117 | 118 | plan: field({ 119 | type: String, 120 | label: 'Plan', 121 | optional: true, 122 | }), 123 | 124 | parentCompanyId: field({ 125 | type: String, 126 | optional: true, 127 | label: 'Parent Company', 128 | }), 129 | 130 | primaryEmail: field({ type: String, optional: true, label: 'Email' }), 131 | emails: field({ type: [String], optional: true }), 132 | 133 | primaryPhone: field({ type: String, optional: true, label: 'Phone' }), 134 | phones: field({ type: [String], optional: true }), 135 | 136 | ownerId: field({ type: String, optional: true, label: 'Owner' }), 137 | 138 | leadStatus: field({ 139 | type: String, 140 | enum: COMPANY_LEAD_STATUS_TYPES, 141 | optional: true, 142 | label: 'Lead Status', 143 | }), 144 | 145 | status: field({ 146 | type: String, 147 | enum: STATUSES.ALL, 148 | default: STATUSES.ACTIVE, 149 | optional: true, 150 | label: 'Status', 151 | }), 152 | 153 | lifecycleState: field({ 154 | type: String, 155 | enum: COMPANY_LIFECYCLE_STATE_TYPES, 156 | optional: true, 157 | label: 'Lifecycle State', 158 | }), 159 | 160 | businessType: field({ 161 | type: String, 162 | enum: COMPANY_BUSINESS_TYPES, 163 | optional: true, 164 | label: 'Business Type', 165 | }), 166 | 167 | description: field({ type: String, optional: true }), 168 | employees: field({ type: Number, optional: true, label: 'Employees' }), 169 | doNotDisturb: field({ 170 | type: String, 171 | optional: true, 172 | label: 'Do not disturb', 173 | }), 174 | links: field({ type: linkSchema, default: {} }), 175 | 176 | tagIds: field({ 177 | type: [String], 178 | optional: true, 179 | }), 180 | 181 | // Merged company ids 182 | mergedIds: field({ type: [String], optional: true }), 183 | 184 | customFieldsData: field({ 185 | type: Object, 186 | }), 187 | searchText: field({ type: String, optional: true, index: true }), 188 | }), 189 | ); 190 | -------------------------------------------------------------------------------- /src/__tests__/conversationDb.test.ts: -------------------------------------------------------------------------------- 1 | import * as faker from 'faker'; 2 | import * as Random from 'meteor-random'; 3 | 4 | import { conversationFactory, engageDataFactory, messageFactory } from '../db/factories'; 5 | 6 | import { Conversations, IConversationDocument, Messages } from '../db/models'; 7 | 8 | /** 9 | * Conversations related tests 10 | */ 11 | describe('Conversations', () => { 12 | let _conversation: IConversationDocument; 13 | 14 | beforeEach(async () => { 15 | // Creating test conversation 16 | _conversation = await conversationFactory(); 17 | }); 18 | 19 | afterEach(async () => { 20 | // Clearing test data 21 | await Conversations.deleteMany({}); 22 | await Messages.deleteMany({}); 23 | }); 24 | 25 | test('createConversation() must return a new conversation', async () => { 26 | const now = new Date(); 27 | 28 | const conversation = await Conversations.createConversation({ 29 | integrationId: _conversation.integrationId, 30 | customerId: _conversation.customerId, 31 | content: _conversation.content, 32 | }); 33 | 34 | expect(conversation).toBeDefined(); 35 | expect(conversation.integrationId).toBe(_conversation.integrationId); 36 | expect(conversation.customerId).toBe(_conversation.customerId); 37 | expect(conversation.content).toBe(_conversation.content); 38 | expect(conversation.createdAt >= now).toBe(true); 39 | expect(conversation.messageCount).toBe(0); 40 | expect(conversation.status).toBe(Conversations.getConversationStatuses().NEW); 41 | expect(conversation.number).toBe(2); 42 | }); 43 | 44 | test('getOrCreateConversation() must return an existing conversation', async () => { 45 | const now = new Date(); 46 | 47 | const conversation = await Conversations.getOrCreateConversation({ 48 | conversationId: _conversation._id, 49 | integrationId: _conversation.integrationId, 50 | customerId: _conversation.customerId, 51 | content: _conversation.content, 52 | }); 53 | 54 | expect(conversation).toBeDefined(); 55 | expect(conversation._id).toBe(_conversation._id); 56 | expect(conversation.createdAt < now).toBe(true); 57 | expect(conversation.status).toBe(Conversations.getConversationStatuses().OPEN); 58 | expect(conversation.readUserIds.length).toBe(0); 59 | }); 60 | 61 | test('getOrCreateConversation() must return a new conversation', async () => { 62 | const now = new Date(); 63 | 64 | const conversation = await Conversations.getOrCreateConversation({ 65 | integrationId: _conversation.integrationId, 66 | customerId: _conversation.customerId, 67 | content: _conversation.content, 68 | }); 69 | 70 | expect(conversation).toBeDefined(); 71 | expect(conversation._id).not.toBe(_conversation._id); 72 | expect(conversation.createdAt >= now).toBe(true); 73 | expect(conversation.status).toBe(Conversations.getConversationStatuses().NEW); 74 | expect(conversation.messageCount).toBe(0); 75 | expect(conversation.content).toBe(_conversation.content); 76 | expect(conversation.readUserIds.length).toBe(0); 77 | expect(conversation.number).toBe(2); 78 | }); 79 | 80 | test('createMessage() must return a new message', async () => { 81 | const now = new Date(); 82 | 83 | const _message = { 84 | conversationId: _conversation._id, 85 | customerId: Random.id(), 86 | content: faker.lorem.sentence(), 87 | }; 88 | 89 | const message = await Messages.createMessage(_message); 90 | 91 | const updatedConversation = await Conversations.findOne({ 92 | _id: _message.conversationId, 93 | }); 94 | 95 | if (!updatedConversation) { 96 | throw new Error('conversation not found'); 97 | } 98 | 99 | expect(updatedConversation.updatedAt).toEqual(expect.any(Date)); 100 | expect(message).toBeDefined(); 101 | expect(message._id).toBeDefined(); 102 | expect(message.createdAt >= now).toBeTruthy(); 103 | expect(message.userId).toBeUndefined(); 104 | expect(message.isCustomerRead).toBeUndefined(); 105 | expect(message.internal).toBeFalsy(); 106 | }); 107 | 108 | test('forceReadCustomerPreviousEngageMessages', async () => { 109 | const customerId = '_id'; 110 | 111 | // isCustomRead is defined =============== 112 | await messageFactory({ 113 | customerId, 114 | engageData: engageDataFactory({ messageId: '_id' }), 115 | isCustomerRead: false, 116 | }); 117 | 118 | await Messages.forceReadCustomerPreviousEngageMessages(customerId); 119 | 120 | let messages = await Messages.find({ 121 | customerId, 122 | engageData: { $exists: true }, 123 | isCustomerRead: true, 124 | }); 125 | 126 | expect(messages.length).toBe(1); 127 | 128 | // isCustomRead is undefined =============== 129 | await Messages.deleteMany({}); 130 | 131 | await messageFactory({ 132 | customerId, 133 | engageData: engageDataFactory({ messageId: '_id' }), 134 | }); 135 | 136 | await Messages.forceReadCustomerPreviousEngageMessages(customerId); 137 | 138 | messages = await Messages.find({ 139 | customerId, 140 | engageData: { $exists: true }, 141 | isCustomerRead: true, 142 | }); 143 | 144 | expect(messages.length).toBe(1); 145 | }); 146 | }); 147 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # erxes Inc - erxes Widgets API 2 | 3 | erxes is an open source growth marketing platform. Marketing, sales, and customer service platform designed to help your business attract more engaged customers. Replace Hubspot with the mission and community-driven ecosystem. 4 | 5 | View demo | Download ZIP | Join us on Slack 6 | 7 | ## Status
8 | 9 | ![Build Status](https://drone.erxes.io/api/badges/erxes/erxes-widgets-api/status.svg?branch=master) 10 | [![Coverage Status](https://coveralls.io/repos/github/erxes/erxes-widgets-api/badge.svg?branch=master)](https://coveralls.io/github/erxes/erxes-widgets-api?branch=master) 11 | [![Known Vulnerabilities](https://snyk.io/test/github/erxes/erxes-widgets-api/badge.svg)](https://snyk.io/test/github/erxes/erxes-widgets-api) 12 | 13 | ## Running the server 14 | 15 | #### 1. Node (version >= 4) and NPM need to be installed. 16 | #### 2. Clone and install dependencies. 17 | 18 | ```Shell 19 | git clone https://github.com/erxes/erxes-widgets-api.git 20 | cd erxes-widgets-api 21 | yarn install 22 | ``` 23 | 24 | #### 3. Create configuration from sample file. We use [dotenv](https://github.com/motdotla/dotenv) for this. 25 | 26 | ```Shell 27 | cp .env.sample .env 28 | ``` 29 | 30 | .env file description 31 | 32 | ```env 33 | NODE_ENV=development (Node environment: development | production) 34 | PORT=3100 (Server port) 35 | MAIN_API_URL=http://localhost:3300/graphql (erxes-api project url) 36 | MONGO_URL=mongodb://localhost/erxes (MongoDB url) 37 | ``` 38 | 39 | #### 4. Start the server. 40 | 41 | For development: 42 | 43 | ```Shell 44 | yarn dev 45 | ``` 46 | 47 | For production: 48 | 49 | ```Shell 50 | yarn build 51 | yarn start 52 | ``` 53 | 54 | #### 5. Running servers: 55 | 56 | - GraphQL server: [http://localhost:3100/graphql](http://localhost:3100/graphql) 57 | - GraphiQL: [http://localhost:3100/graphiql](http://localhost:3100/graphiql) 58 | - Subscription server (websocket): [http://localhost:3100/subscriptions](http://localhost:3100/subscriptions) 59 | 60 | ## Contributors 61 | 62 | This project exists thanks to all the people who contribute. [[Contribute]](CONTRIBUTING.md). 63 | 64 | 65 | 66 | ## Backers 67 | 68 | Thank you to all our backers! 🙏 [[Become a backer](https://opencollective.com/erxes#backer)] 69 | 70 | 71 | 72 | 73 | ## Sponsors 74 | 75 | Support this project by becoming a sponsor. Your logo will show up here with a link to your website. [[Become a sponsor](https://opencollective.com/erxes#sponsor)] 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | ## In-kind sponsors 89 | 90 |       91 |       92 |       93 |       94 | 95 | 96 | ## Copyright & License 97 | Copyright (c) 2018 erxes Inc - Released under the [MIT license.](https://github.com/erxes/erxes/blob/develop/LICENSE.md) 98 | -------------------------------------------------------------------------------- /src/db/models/definitions/boards.ts: -------------------------------------------------------------------------------- 1 | import { Document, Schema } from 'mongoose'; 2 | import { BOARD_TYPES, HACK_SCORING_TYPES, PIPELINE_VISIBLITIES, PROBABILITY } from './constants'; 3 | import { field, schemaWrapper } from './utils'; 4 | 5 | interface ICommonFields { 6 | userId?: string; 7 | createdAt?: Date; 8 | order?: number; 9 | type: string; 10 | } 11 | 12 | export interface IItemCommonFields { 13 | name?: string; 14 | // TODO migrate after remove 2row 15 | companyIds?: string[]; 16 | customerIds?: string[]; 17 | closeDate?: Date; 18 | description?: string; 19 | assignedUserIds?: string[]; 20 | watchedUserIds?: string[]; 21 | notifiedUserIds?: string[]; 22 | labelIds?: string[]; 23 | attachments?: any[]; 24 | stageId?: string; 25 | initialStageId?: string; 26 | modifiedAt?: Date; 27 | modifiedBy?: string; 28 | userId?: string; 29 | createdAt?: Date; 30 | order?: number; 31 | searchText?: string; 32 | priority?: string; 33 | } 34 | 35 | export interface IBoard extends ICommonFields { 36 | name?: string; 37 | } 38 | 39 | export interface IBoardDocument extends IBoard, Document { 40 | _id: string; 41 | } 42 | 43 | export interface IPipeline extends ICommonFields { 44 | name?: string; 45 | boardId?: string; 46 | visibility?: string; 47 | memberIds?: string[]; 48 | bgColor?: string; 49 | watchedUserIds?: string[]; 50 | startDate?: Date; 51 | endDate?: Date; 52 | metric?: string; 53 | hackScoringType?: string; 54 | templateId?: string; 55 | isCheckUser?: boolean; 56 | excludeCheckUserIds?: string[]; 57 | } 58 | 59 | export interface IPipelineDocument extends IPipeline, Document { 60 | _id: string; 61 | } 62 | 63 | export interface IStage extends ICommonFields { 64 | name?: string; 65 | probability?: string; 66 | pipelineId: string; 67 | formId?: string; 68 | } 69 | 70 | export interface IStageDocument extends IStage, Document { 71 | _id: string; 72 | } 73 | 74 | // Not mongoose document, just stage shaped plain object 75 | export type IPipelineStage = IStage & { _id: string }; 76 | 77 | export interface IOrderInput { 78 | _id: string; 79 | order: number; 80 | } 81 | 82 | const attachmentSchema = new Schema( 83 | { 84 | name: field({ type: String }), 85 | url: field({ type: String }), 86 | type: field({ type: String }), 87 | size: field({ type: Number, optional: true }), 88 | }, 89 | { _id: false }, 90 | ); 91 | 92 | // Mongoose schemas ======================= 93 | const commonFieldsSchema = { 94 | userId: field({ type: String }), 95 | createdAt: field({ 96 | type: Date, 97 | default: new Date(), 98 | }), 99 | order: field({ type: Number }), 100 | type: field({ 101 | type: String, 102 | enum: BOARD_TYPES.ALL, 103 | required: true, 104 | }), 105 | }; 106 | 107 | export const commonItemFieldsSchema = { 108 | _id: field({ pkey: true }), 109 | userId: field({ type: String }), 110 | createdAt: field({ 111 | type: Date, 112 | default: new Date(), 113 | }), 114 | order: field({ type: Number }), 115 | name: field({ type: String }), 116 | closeDate: field({ type: Date }), 117 | reminderMinute: field({ type: Number }), 118 | isComplete: field({ type: Boolean, default: false }), 119 | description: field({ type: String, optional: true }), 120 | assignedUserIds: field({ type: [String] }), 121 | watchedUserIds: field({ type: [String] }), 122 | labelIds: field({ type: [String] }), 123 | attachments: field({ type: [attachmentSchema] }), 124 | stageId: field({ type: String }), 125 | initialStageId: field({ type: String, optional: true }), 126 | modifiedAt: field({ 127 | type: Date, 128 | default: new Date(), 129 | }), 130 | modifiedBy: field({ type: String }), 131 | searchText: field({ type: String, optional: true, index: true }), 132 | priority: field({ type: String, optional: true }), 133 | }; 134 | 135 | export const boardSchema = schemaWrapper( 136 | new Schema({ 137 | _id: field({ pkey: true }), 138 | name: field({ type: String }), 139 | ...commonFieldsSchema, 140 | }), 141 | ); 142 | 143 | export const pipelineSchema = new Schema({ 144 | _id: field({ pkey: true }), 145 | name: field({ type: String }), 146 | boardId: field({ type: String }), 147 | visibility: field({ 148 | type: String, 149 | enum: PIPELINE_VISIBLITIES.ALL, 150 | default: PIPELINE_VISIBLITIES.PUBLIC, 151 | }), 152 | watchedUserIds: field({ type: [String] }), 153 | memberIds: field({ type: [String] }), 154 | bgColor: field({ type: String }), 155 | // Growth hack 156 | startDate: field({ type: Date, optional: true }), 157 | endDate: field({ type: Date, optional: true }), 158 | metric: field({ type: String, optional: true }), 159 | hackScoringType: field({ 160 | type: String, 161 | enum: HACK_SCORING_TYPES.ALL, 162 | }), 163 | templateId: field({ type: String, optional: true }), 164 | isCheckUser: field({ type: Boolean, optional: true }), 165 | excludeCheckUserIds: field({ type: [String], optional: true }), 166 | ...commonFieldsSchema, 167 | }); 168 | 169 | export const stageSchema = new Schema({ 170 | _id: field({ pkey: true }), 171 | name: field({ type: String }), 172 | probability: field({ 173 | type: String, 174 | enum: PROBABILITY.ALL, 175 | }), // Win probability 176 | pipelineId: field({ type: String }), 177 | formId: field({ type: String }), 178 | ...commonFieldsSchema, 179 | }); 180 | -------------------------------------------------------------------------------- /wait-for.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Use this script to test if a given TCP host/port are available 3 | 4 | WAITFORIT_cmdname=${0##*/} 5 | 6 | echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } 7 | 8 | usage() 9 | { 10 | cat << USAGE >&2 11 | Usage: 12 | $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args] 13 | -h HOST | --host=HOST Host or IP under test 14 | -p PORT | --port=PORT TCP port under test 15 | Alternatively, you specify the host and port as host:port 16 | -s | --strict Only execute subcommand if the test succeeds 17 | -q | --quiet Don't output any status messages 18 | -t TIMEOUT | --timeout=TIMEOUT 19 | Timeout in seconds, zero for no timeout 20 | -- COMMAND ARGS Execute command with args after the test finishes 21 | USAGE 22 | exit 1 23 | } 24 | 25 | wait_for() 26 | { 27 | if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then 28 | echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" 29 | else 30 | echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout" 31 | fi 32 | WAITFORIT_start_ts=$(date +%s) 33 | while : 34 | do 35 | if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then 36 | nc -z $WAITFORIT_HOST $WAITFORIT_PORT 37 | WAITFORIT_result=$? 38 | else 39 | (echo > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1 40 | WAITFORIT_result=$? 41 | fi 42 | if [[ $WAITFORIT_result -eq 0 ]]; then 43 | WAITFORIT_end_ts=$(date +%s) 44 | echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds" 45 | break 46 | fi 47 | sleep 1 48 | done 49 | return $WAITFORIT_result 50 | } 51 | 52 | wait_for_wrapper() 53 | { 54 | # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692 55 | if [[ $WAITFORIT_QUIET -eq 1 ]]; then 56 | timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & 57 | else 58 | timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & 59 | fi 60 | WAITFORIT_PID=$! 61 | trap "kill -INT -$WAITFORIT_PID" INT 62 | wait $WAITFORIT_PID 63 | WAITFORIT_RESULT=$? 64 | if [[ $WAITFORIT_RESULT -ne 0 ]]; then 65 | echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" 66 | fi 67 | return $WAITFORIT_RESULT 68 | } 69 | 70 | # process arguments 71 | while [[ $# -gt 0 ]] 72 | do 73 | case "$1" in 74 | *:* ) 75 | WAITFORIT_hostport=(${1//:/ }) 76 | WAITFORIT_HOST=${WAITFORIT_hostport[0]} 77 | WAITFORIT_PORT=${WAITFORIT_hostport[1]} 78 | shift 1 79 | ;; 80 | --child) 81 | WAITFORIT_CHILD=1 82 | shift 1 83 | ;; 84 | -q | --quiet) 85 | WAITFORIT_QUIET=1 86 | shift 1 87 | ;; 88 | -s | --strict) 89 | WAITFORIT_STRICT=1 90 | shift 1 91 | ;; 92 | -h) 93 | WAITFORIT_HOST="$2" 94 | if [[ $WAITFORIT_HOST == "" ]]; then break; fi 95 | shift 2 96 | ;; 97 | --host=*) 98 | WAITFORIT_HOST="${1#*=}" 99 | shift 1 100 | ;; 101 | -p) 102 | WAITFORIT_PORT="$2" 103 | if [[ $WAITFORIT_PORT == "" ]]; then break; fi 104 | shift 2 105 | ;; 106 | --port=*) 107 | WAITFORIT_PORT="${1#*=}" 108 | shift 1 109 | ;; 110 | -t) 111 | WAITFORIT_TIMEOUT="$2" 112 | if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi 113 | shift 2 114 | ;; 115 | --timeout=*) 116 | WAITFORIT_TIMEOUT="${1#*=}" 117 | shift 1 118 | ;; 119 | --) 120 | shift 121 | WAITFORIT_CLI=("$@") 122 | break 123 | ;; 124 | --help) 125 | usage 126 | ;; 127 | *) 128 | echoerr "Unknown argument: $1" 129 | usage 130 | ;; 131 | esac 132 | done 133 | 134 | if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then 135 | echoerr "Error: you need to provide a host and port to test." 136 | usage 137 | fi 138 | 139 | WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15} 140 | WAITFORIT_STRICT=${WAITFORIT_STRICT:-0} 141 | WAITFORIT_CHILD=${WAITFORIT_CHILD:-0} 142 | WAITFORIT_QUIET=${WAITFORIT_QUIET:-0} 143 | 144 | # check to see if timeout is from busybox? 145 | WAITFORIT_TIMEOUT_PATH=$(type -p timeout) 146 | WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH) 147 | if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then 148 | WAITFORIT_ISBUSY=1 149 | WAITFORIT_BUSYTIMEFLAG="-t" 150 | 151 | else 152 | WAITFORIT_ISBUSY=0 153 | WAITFORIT_BUSYTIMEFLAG="" 154 | fi 155 | 156 | if [[ $WAITFORIT_CHILD -gt 0 ]]; then 157 | wait_for 158 | WAITFORIT_RESULT=$? 159 | exit $WAITFORIT_RESULT 160 | else 161 | if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then 162 | wait_for_wrapper 163 | WAITFORIT_RESULT=$? 164 | else 165 | wait_for 166 | WAITFORIT_RESULT=$? 167 | fi 168 | fi 169 | 170 | if [[ $WAITFORIT_CLI != "" ]]; then 171 | if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then 172 | echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess" 173 | exit $WAITFORIT_RESULT 174 | fi 175 | exec "${WAITFORIT_CLI[@]}" 176 | else 177 | exit $WAITFORIT_RESULT 178 | fi 179 | -------------------------------------------------------------------------------- /src/__tests__/messengerStatus.test.ts: -------------------------------------------------------------------------------- 1 | import { isOnline, isTimeInBetween } from '../data/resolvers/utils/messenger'; 2 | 3 | import { integrationFactory } from '../db/factories'; 4 | 5 | describe('Manual mode', () => { 6 | test('isOnline() must return status as it is', async () => { 7 | const integration = await integrationFactory({ 8 | messengerData: { 9 | availabilityMethod: 'manual', 10 | }, 11 | }); 12 | 13 | // online 14 | integration.messengerData.isOnline = true; 15 | expect(isOnline(integration)).toBeTruthy(); 16 | 17 | // offline 18 | integration.messengerData.isOnline = false; 19 | expect(isOnline(integration)).toBeFalsy(); 20 | }); 21 | }); 22 | 23 | describe('Auto mode', () => { 24 | test('isTimeInBetween()', () => { 25 | const time1 = '09:00 AM'; 26 | const time2 = '6:00 PM'; 27 | 28 | expect(isTimeInBetween(new Date('2017/05/08 11:10 AM'), time1, time2)).toBeTruthy(); 29 | expect(isTimeInBetween(new Date('2017/05/08 7:00 PM'), time1, time2)).toBeFalsy(); 30 | }); 31 | 32 | test('isOnline() must return false if there is no config for current day', async () => { 33 | const integration = await integrationFactory({ 34 | messengerData: { 35 | availabilityMethod: 'auto', 36 | onlineHours: [ 37 | { 38 | day: 'tuesday', 39 | from: '09:00 AM', 40 | to: '05:00 PM', 41 | }, 42 | ], 43 | }, 44 | }); 45 | 46 | // 2017-05-08, monday 47 | expect(isOnline(integration, new Date('2017/05/08 11:10 AM'))).toBeFalsy(); 48 | }); 49 | 50 | test('isOnline() for specific day', async () => { 51 | const integration = await integrationFactory({ 52 | messengerData: { 53 | availabilityMethod: 'auto', 54 | onlineHours: [ 55 | { 56 | day: 'tuesday', 57 | from: '09:00 AM', 58 | to: '05:00 PM', 59 | }, 60 | ], 61 | }, 62 | }); 63 | 64 | // 2017-05-09, tuesday 65 | expect(isOnline(integration, new Date('2017/05/09 06:10 PM'))).toBeFalsy(); 66 | expect(isOnline(integration, new Date('2017/05/09 09:01 AM'))).toBeTruthy(); 67 | }); 68 | 69 | test('isOnline() for everyday', async () => { 70 | const integration = await integrationFactory({ 71 | messengerData: { 72 | availabilityMethod: 'auto', 73 | onlineHours: [ 74 | { 75 | day: 'everyday', 76 | from: '09:00 AM', 77 | to: '05:00 PM', 78 | }, 79 | ], 80 | }, 81 | }); 82 | 83 | // monday -> sunday 84 | expect(isOnline(integration, new Date('2017/05/08 10:00 AM'))).toBeTruthy(); 85 | expect(isOnline(integration, new Date('2017/05/09 11:00 AM'))).toBeTruthy(); 86 | expect(isOnline(integration, new Date('2017/05/10 12:00 PM'))).toBeTruthy(); 87 | expect(isOnline(integration, new Date('2017/05/11 1:00 PM'))).toBeTruthy(); 88 | expect(isOnline(integration, new Date('2017/05/12 2:00 PM'))).toBeTruthy(); 89 | expect(isOnline(integration, new Date('2017/05/13 3:00 PM'))).toBeTruthy(); 90 | expect(isOnline(integration, new Date('2017/05/14 4:00 PM'))).toBeTruthy(); 91 | 92 | // monday -> sunday 93 | expect(isOnline(integration, new Date('2017/05/08 3:00 AM'))).toBeFalsy(); 94 | expect(isOnline(integration, new Date('2017/05/09 4:00 AM'))).toBeFalsy(); 95 | expect(isOnline(integration, new Date('2017/05/10 5:00 AM'))).toBeFalsy(); 96 | expect(isOnline(integration, new Date('2017/05/11 6:00 AM'))).toBeFalsy(); 97 | expect(isOnline(integration, new Date('2017/05/12 6:00 PM'))).toBeFalsy(); 98 | expect(isOnline(integration, new Date('2017/05/13 7:00 PM'))).toBeFalsy(); 99 | expect(isOnline(integration, new Date('2017/05/14 8:00 PM'))).toBeFalsy(); 100 | }); 101 | 102 | test('isOnline() for weekdays', async () => { 103 | const integration = await integrationFactory({ 104 | messengerData: { 105 | availabilityMethod: 'auto', 106 | onlineHours: [ 107 | { 108 | day: 'weekdays', 109 | from: '09:00 AM', 110 | to: '05:00 PM', 111 | }, 112 | ], 113 | }, 114 | }); 115 | 116 | // weekdays 117 | expect(isOnline(integration, new Date('2017/05/08 10:00 AM'))).toBeTruthy(); 118 | expect(isOnline(integration, new Date('2017/05/09 11:00 AM'))).toBeTruthy(); 119 | expect(isOnline(integration, new Date('2017/05/10 12:00 PM'))).toBeTruthy(); 120 | expect(isOnline(integration, new Date('2017/05/11 1:00 PM'))).toBeTruthy(); 121 | expect(isOnline(integration, new Date('2017/05/12 2:00 PM'))).toBeTruthy(); 122 | expect(isOnline(integration, new Date('2017/05/11 11:00 PM'))).toBeFalsy(); 123 | expect(isOnline(integration, new Date('2017/05/12 07:00 AM'))).toBeFalsy(); 124 | 125 | // weekend 126 | expect(isOnline(integration, new Date('2017/05/13 10:00 AM'))).toBeFalsy(); 127 | expect(isOnline(integration, new Date('2017/05/14 11:00 AM'))).toBeFalsy(); 128 | }); 129 | 130 | test('isOnline() for weekend', async () => { 131 | const integration = await integrationFactory({ 132 | messengerData: { 133 | availabilityMethod: 'auto', 134 | onlineHours: [ 135 | { 136 | day: 'weekends', 137 | from: '09:00 AM', 138 | to: '05:00 PM', 139 | }, 140 | ], 141 | }, 142 | }); 143 | 144 | // weekdays 145 | expect(isOnline(integration, new Date('2017/05/08 10:00 AM'))).toBeFalsy(); 146 | expect(isOnline(integration, new Date('2017/05/09 11:00 AM'))).toBeFalsy(); 147 | expect(isOnline(integration, new Date('2017/05/10 12:00 PM'))).toBeFalsy(); 148 | expect(isOnline(integration, new Date('2017/05/11 1:00 PM'))).toBeFalsy(); 149 | expect(isOnline(integration, new Date('2017/05/12 2:00 PM'))).toBeFalsy(); 150 | 151 | // weekend 152 | expect(isOnline(integration, new Date('2017/05/13 10:00 AM'))).toBeTruthy(); 153 | expect(isOnline(integration, new Date('2017/05/14 11:00 AM'))).toBeTruthy(); 154 | expect(isOnline(integration, new Date('2017/05/13 07:00 AM'))).toBeFalsy(); 155 | expect(isOnline(integration, new Date('2017/05/14 11:00 PM'))).toBeFalsy(); 156 | }); 157 | }); 158 | -------------------------------------------------------------------------------- /src/__tests__/engageRules.test.ts: -------------------------------------------------------------------------------- 1 | import { checkRule, checkRules } from '../data/resolvers/utils/engage'; 2 | 3 | const browserLanguageRule = { 4 | kind: 'browserLanguage', 5 | condition: 'is', 6 | value: 'en', 7 | }; 8 | 9 | describe('checkRules', () => { 10 | test('browserLanguage: not matched', async () => { 11 | const response = await checkRules({ 12 | rules: [browserLanguageRule], 13 | browserInfo: { language: 'mn' }, 14 | }); 15 | 16 | expect(response).toBe(false); 17 | }); 18 | 19 | test('browserLanguage: not all rules matched', async () => { 20 | const response = await checkRules({ 21 | rules: [ 22 | browserLanguageRule, 23 | { 24 | kind: 'browserLanguage', 25 | condition: 'is', 26 | value: 'mn', 27 | }, 28 | ], 29 | 30 | browserInfo: { language: 'en' }, 31 | }); 32 | 33 | expect(response).toBe(false); 34 | }); 35 | 36 | test('browserLanguage: all rules matched', async () => { 37 | const response = await checkRules({ 38 | rules: [browserLanguageRule, browserLanguageRule], 39 | browserInfo: { language: 'en' }, 40 | }); 41 | 42 | expect(response).toBe(true); 43 | }); 44 | }); 45 | 46 | describe('checkIndividualRule', () => { 47 | // is 48 | test('is: not matching', () => { 49 | const response = checkRule({ 50 | rule: browserLanguageRule, 51 | browserInfo: { language: 'mn' }, 52 | }); 53 | 54 | expect(response).toBe(false); 55 | }); 56 | 57 | test('is: matching', () => { 58 | const response = checkRule({ 59 | rule: browserLanguageRule, 60 | browserInfo: { language: 'en' }, 61 | }); 62 | 63 | expect(response).toBe(true); 64 | }); 65 | 66 | // isNot 67 | const isNotRule = { 68 | kind: 'currentPageUrl', 69 | condition: 'isNot', 70 | value: '/page', 71 | }; 72 | 73 | test('isNot: not matching', () => { 74 | const response = checkRule({ 75 | rule: isNotRule, 76 | browserInfo: { url: '/page' }, 77 | }); 78 | 79 | expect(response).toBe(false); 80 | }); 81 | 82 | test('isNot: matching', () => { 83 | const response = checkRule({ 84 | rule: isNotRule, 85 | browserInfo: { url: '/category' }, 86 | }); 87 | 88 | expect(response).toBe(true); 89 | }); 90 | 91 | // isUnknown 92 | const isUnknownRule = { 93 | kind: 'city', 94 | condition: 'isUnknown', 95 | }; 96 | 97 | test('isUnknown: not matching', () => { 98 | const response = checkRule({ 99 | rule: isUnknownRule, 100 | browserInfo: { city: 'Ulaanbaatar' }, 101 | }); 102 | 103 | expect(response).toBe(false); 104 | }); 105 | 106 | test('isUnknown: matching', () => { 107 | const response = checkRule({ 108 | rule: isUnknownRule, 109 | browserInfo: {}, 110 | }); 111 | 112 | expect(response).toBe(true); 113 | }); 114 | 115 | // hasAnyValue 116 | const hasAnyValueRule = { 117 | kind: 'country', 118 | condition: 'hasAnyValue', 119 | }; 120 | 121 | test('hasAnyValue: not matching', () => { 122 | const response = checkRule({ 123 | rule: hasAnyValueRule, 124 | browserInfo: {}, 125 | }); 126 | 127 | expect(response).toBe(false); 128 | }); 129 | 130 | test('hasAnyValue: matching', () => { 131 | const response = checkRule({ 132 | rule: hasAnyValueRule, 133 | browserInfo: { country: 'MN' }, 134 | }); 135 | 136 | expect(response).toBe(true); 137 | }); 138 | 139 | // startsWith 140 | const startsWithRule = { 141 | kind: 'browserLanguage', 142 | condition: 'startsWith', 143 | value: 'en', 144 | }; 145 | 146 | test('startsWith: not matching', () => { 147 | const response = checkRule({ 148 | rule: startsWithRule, 149 | browserInfo: { language: 'mongolian' }, 150 | }); 151 | 152 | expect(response).toBe(false); 153 | }); 154 | 155 | test('startsWith: matching', () => { 156 | const response = checkRule({ 157 | rule: startsWithRule, 158 | browserInfo: { language: 'english' }, 159 | }); 160 | 161 | expect(response).toBe(true); 162 | }); 163 | 164 | // endsWith 165 | const endsWithRule = { 166 | kind: 'browserLanguage', 167 | condition: 'endsWith', 168 | value: 'ian', 169 | }; 170 | 171 | test('endsWith: not matching', () => { 172 | const response = checkRule({ 173 | rule: endsWithRule, 174 | browserInfo: { language: 'english' }, 175 | }); 176 | 177 | expect(response).toBe(false); 178 | }); 179 | 180 | test('endsWith: matching', () => { 181 | const response = checkRule({ 182 | rule: endsWithRule, 183 | browserInfo: { language: 'mongolian' }, 184 | }); 185 | 186 | expect(response).toBe(true); 187 | }); 188 | 189 | // greaterThan 190 | const greaterThanRule = { 191 | kind: 'numberOfVisits', 192 | condition: 'greaterThan', 193 | value: '1', 194 | }; 195 | 196 | test('greaterThan: not matching', () => { 197 | const response = checkRule({ 198 | rule: greaterThanRule, 199 | browserInfo: {}, 200 | numberOfVisits: 0, 201 | }); 202 | 203 | expect(response).toBe(false); 204 | }); 205 | 206 | test('greaterThan: matching', () => { 207 | const response = checkRule({ 208 | rule: greaterThanRule, 209 | browserInfo: {}, 210 | numberOfVisits: 2, 211 | }); 212 | 213 | expect(response).toBe(true); 214 | }); 215 | 216 | // lessThan 217 | const lessThanRule = { 218 | kind: 'numberOfVisits', 219 | condition: 'lessThan', 220 | value: '1', 221 | }; 222 | 223 | test('lessThan: not matching', () => { 224 | const response = checkRule({ 225 | rule: lessThanRule, 226 | browserInfo: {}, 227 | numberOfVisits: 2, 228 | }); 229 | 230 | expect(response).toBe(false); 231 | }); 232 | 233 | test('lessThan: matching', () => { 234 | const response = checkRule({ 235 | rule: lessThanRule, 236 | browserInfo: {}, 237 | numberOfVisits: 0, 238 | }); 239 | 240 | expect(response).toBe(true); 241 | }); 242 | 243 | // contains ====== 244 | const containsRule = { 245 | kind: 'currentPageUrl', 246 | condition: 'contains', 247 | value: 'page', 248 | }; 249 | 250 | test('contains: not matching', () => { 251 | const response = checkRule({ 252 | rule: containsRule, 253 | browserInfo: { url: '/test' }, 254 | }); 255 | 256 | expect(response).toBe(false); 257 | }); 258 | 259 | test('contains: matching', () => { 260 | const response = checkRule({ 261 | rule: containsRule, 262 | browserInfo: { url: '/page' }, 263 | }); 264 | 265 | expect(response).toBe(true); 266 | }); 267 | }); 268 | -------------------------------------------------------------------------------- /src/db/factories.ts: -------------------------------------------------------------------------------- 1 | import * as faker from 'faker'; 2 | import * as Random from 'meteor-random'; 3 | 4 | import { 5 | Brands, 6 | Companies, 7 | Conversations, 8 | Customers, 9 | Fields, 10 | Forms, 11 | IMessageEngageData, 12 | Integrations, 13 | Messages, 14 | MessengerApps, 15 | Users, 16 | } from './models'; 17 | import { FORM_TYPES } from './models/definitions/constants'; 18 | import { IMessengerAppCrendentials } from './models/definitions/messengerApps'; 19 | 20 | interface IUserParams { 21 | fullName?: string; 22 | email?: string; 23 | } 24 | export const userFactory = (params: IUserParams = {}) => { 25 | const user = new Users({ 26 | details: { 27 | fullName: params.fullName || faker.random.word(), 28 | }, 29 | email: params.email || faker.internet.email(), 30 | }); 31 | 32 | return user.save(); 33 | }; 34 | 35 | interface IBrandParams { 36 | name?: string; 37 | code?: string; 38 | } 39 | export const brandFactory = (params: IBrandParams = {}) => { 40 | const brand = new Brands({ 41 | name: params.name || faker.random.word(), 42 | code: params.code || faker.random.word(), 43 | userId: Random.id(), 44 | }); 45 | 46 | return brand.save(); 47 | }; 48 | 49 | interface IIntegrationParams { 50 | kind?: string; 51 | brandId?: string; 52 | formId?: string; 53 | messengerData?: any; 54 | leadData?: any; 55 | } 56 | export const integrationFactory = (params: IIntegrationParams = {}) => { 57 | const integration = new Integrations({ 58 | name: faker.random.word(), 59 | kind: params.kind || 'messenger', 60 | brandId: params.brandId || Random.id(), 61 | formId: params.formId || Random.id(), 62 | messengerData: params.messengerData, 63 | leadData: params.leadData || {}, 64 | }); 65 | 66 | return integration.save(); 67 | }; 68 | 69 | interface IFormParams { 70 | title?: string; 71 | code?: string; 72 | type?: string; 73 | } 74 | export const formFactory = (params: IFormParams = {}) => { 75 | const form = new Forms({ 76 | title: params.title || faker.random.word(), 77 | code: params.code || Random.id(), 78 | type: params.type || FORM_TYPES.LEAD, 79 | }); 80 | 81 | return form.save(); 82 | }; 83 | 84 | interface IFormFieldParams { 85 | contentTypeId?: string; 86 | type?: string; 87 | validation?: string; 88 | isRequired?: boolean; 89 | } 90 | export const formFieldFactory = (params: IFormFieldParams = {}) => { 91 | const field = new Fields({ 92 | contentType: 'form', 93 | contentTypeId: params.contentTypeId || Random.id(), 94 | type: params.type || faker.random.word(), 95 | name: faker.random.word(), 96 | validation: params.validation || faker.random.word(), 97 | text: faker.random.word(), 98 | description: faker.random.word(), 99 | isRequired: params.isRequired || false, 100 | number: faker.random.word(), 101 | }); 102 | 103 | return field.save(); 104 | }; 105 | 106 | interface ICustomerParams { 107 | integrationId?: string; 108 | firstName?: string; 109 | lastName?: string; 110 | primaryEmail?: string; 111 | emails?: string[]; 112 | phones?: string[]; 113 | primaryPhone?: string; 114 | isActive?: boolean; 115 | urlVisits?: object; 116 | deviceToken?: string; 117 | } 118 | export function customerFactory(params: ICustomerParams = {}) { 119 | const createdAt = faker.date.past(); 120 | const email = faker.internet.email(); 121 | 122 | const customer = new Customers({ 123 | integrationId: params.integrationId || Random.id(), 124 | createdAt, 125 | 126 | firstName: params.firstName, 127 | lastName: params.lastName, 128 | 129 | primaryEmail: params.primaryEmail || email, 130 | emails: params.emails || [email], 131 | 132 | primaryPhone: params.primaryPhone || '244244', 133 | phones: params.phones || ['244244'], 134 | 135 | isUser: faker.random.boolean(), 136 | name: faker.name.findName(), 137 | messengerData: { 138 | lastSeenAt: faker.date.between(createdAt, new Date()), 139 | isActive: params.isActive || false, 140 | sessionCount: faker.random.number(), 141 | }, 142 | urlVisits: params.urlVisits, 143 | deviceTokens: params.deviceToken || [], 144 | code: faker.random.word(), 145 | }); 146 | 147 | return customer.save(); 148 | } 149 | 150 | export function conversationFactory() { 151 | const conversation = new Conversations({ 152 | createdAt: faker.date.past(), 153 | content: faker.lorem.sentence, 154 | customerId: Random.id(), 155 | integrationId: Random.id(), 156 | number: 1, 157 | messageCount: 0, 158 | status: Conversations.getConversationStatuses().NEW, 159 | }); 160 | 161 | return conversation.save(); 162 | } 163 | 164 | interface IConversationMessageParams { 165 | customerId?: string; 166 | conversationId?: string; 167 | engageData?: IMessageEngageData; 168 | isCustomerRead?: boolean; 169 | } 170 | export function messageFactory(params: IConversationMessageParams = {}) { 171 | const message = new Messages({ 172 | userId: Random.id(), 173 | conversationId: Random.id(), 174 | customerId: Random.id(), 175 | content: faker.lorem.sentence, 176 | createdAt: faker.date.past(), 177 | isCustomerRead: params.isCustomerRead, 178 | engageData: params.engageData, 179 | ...params, 180 | }); 181 | 182 | return message.save(); 183 | } 184 | 185 | interface ICompanyParams { 186 | primaryName?: string; 187 | names?: string[]; 188 | } 189 | export function companyFactory(params: ICompanyParams = {}) { 190 | const company = new Companies({ 191 | primaryName: params.primaryName || faker.lorem.sentence, 192 | names: params.names || [faker.lorem.sentence], 193 | lastSeenAt: faker.date.past(), 194 | sessionCount: faker.random.number(), 195 | }); 196 | 197 | return company.save(); 198 | } 199 | 200 | interface IMessageEngageDataParams { 201 | messageId?: string; 202 | brandId?: string; 203 | content?: string; 204 | fromUserId?: string; 205 | kind?: string; 206 | sentAs?: string; 207 | } 208 | export function engageDataFactory(params: IMessageEngageDataParams) { 209 | return { 210 | messageId: params.messageId || Random.id(), 211 | brandId: params.brandId || Random.id(), 212 | content: params.content || faker.lorem.sentence(), 213 | fromUserId: params.fromUserId || Random.id(), 214 | kind: params.kind || 'popup', 215 | sentAs: params.sentAs || 'post', 216 | }; 217 | } 218 | interface IMessengerApp { 219 | name?: string; 220 | kind?: string; 221 | credentials: IMessengerAppCrendentials; 222 | } 223 | 224 | export function messengerAppFactory(params: IMessengerApp) { 225 | return MessengerApps.create({ 226 | name: params.name || faker.random.word(), 227 | kind: params.kind || 'knowledgebase', 228 | credentials: params.credentials, 229 | }); 230 | } 231 | -------------------------------------------------------------------------------- /src/data/resolvers/mutations/lead.ts: -------------------------------------------------------------------------------- 1 | import * as validator from 'validator'; 2 | 3 | import { 4 | Brands, 5 | Conversations, 6 | Customers, 7 | Fields, 8 | Forms, 9 | FormSubmissions, 10 | IMessageDocument, 11 | Integrations, 12 | Messages, 13 | } from '../../../db/models'; 14 | 15 | import { IBrowserInfo } from '../../../db/models/Customers'; 16 | import { sendMessage } from '../../../messageQueue'; 17 | import { IEmail, sendEmail } from '../utils/email'; 18 | 19 | interface ISubmission { 20 | _id: string; 21 | value: any; 22 | type?: string; 23 | validation?: string; 24 | } 25 | 26 | interface IError { 27 | fieldId: string; 28 | code: string; 29 | text: string; 30 | } 31 | 32 | export const validate = async (formId: string, submissions: ISubmission[]): Promise => { 33 | const fields = await Fields.find({ contentTypeId: formId }); 34 | const errors = []; 35 | 36 | for (const field of fields) { 37 | // find submission object by _id 38 | const submission = submissions.find(sub => sub._id === field._id); 39 | 40 | if (!submission) { 41 | continue; 42 | } 43 | 44 | const value = submission.value || ''; 45 | 46 | const type = field.type; 47 | const validation = field.validation; 48 | 49 | // required 50 | if (field.isRequired && !value) { 51 | errors.push({ 52 | fieldId: field._id, 53 | code: 'required', 54 | text: 'Required', 55 | }); 56 | } 57 | 58 | if (value) { 59 | // email 60 | if ((type === 'email' || validation === 'email') && !validator.isEmail(value)) { 61 | errors.push({ 62 | fieldId: field._id, 63 | code: 'invalidEmail', 64 | text: 'Invalid email', 65 | }); 66 | } 67 | 68 | // phone 69 | if ((type === 'phone' || validation === 'phone') && !/^\d{8,}$/.test(value.replace(/[\s()+\-\.]|ext/gi, ''))) { 70 | errors.push({ 71 | fieldId: field._id, 72 | code: 'invalidPhone', 73 | text: 'Invalid phone', 74 | }); 75 | } 76 | 77 | // number 78 | if (validation === 'number' && !validator.isNumeric(value.toString())) { 79 | errors.push({ 80 | fieldId: field._id, 81 | code: 'invalidNumber', 82 | text: 'Invalid number', 83 | }); 84 | } 85 | 86 | // date 87 | if (validation === 'date' && !validator.isISO8601(value)) { 88 | errors.push({ 89 | fieldId: field._id, 90 | code: 'invalidDate', 91 | text: 'Invalid Date', 92 | }); 93 | } 94 | } 95 | } 96 | 97 | return errors; 98 | }; 99 | 100 | export const saveValues = async (args: { 101 | integrationId: string; 102 | submissions: ISubmission[]; 103 | formId: string; 104 | browserInfo: IBrowserInfo; 105 | }): Promise => { 106 | const { integrationId, submissions, formId, browserInfo } = args; 107 | 108 | const form = await Forms.findOne({ _id: formId }); 109 | 110 | if (!form) { 111 | return null; 112 | } 113 | 114 | const content = form.title; 115 | 116 | let email; 117 | let phone; 118 | let firstName = ''; 119 | let lastName = ''; 120 | 121 | submissions.forEach(submission => { 122 | if (submission.type === 'email') { 123 | email = submission.value; 124 | } 125 | 126 | if (submission.type === 'phone') { 127 | phone = submission.value; 128 | } 129 | 130 | if (submission.type === 'firstName') { 131 | firstName = submission.value; 132 | } 133 | 134 | if (submission.type === 'lastName') { 135 | lastName = submission.value; 136 | } 137 | }); 138 | 139 | // get or create customer 140 | const customer = await Customers.getOrCreateCustomer({ email }, { integrationId, email, firstName, lastName, phone }); 141 | 142 | await Customers.updateLocation(customer._id, browserInfo); 143 | 144 | // Inserting customer id into submitted customer ids 145 | const doc = { 146 | formId, 147 | customerId: customer._id, 148 | submittedAt: new Date(), 149 | }; 150 | 151 | FormSubmissions.createFormSubmission(doc); 152 | 153 | // create conversation 154 | const conversation = await Conversations.createConversation({ 155 | integrationId, 156 | customerId: customer._id, 157 | content, 158 | }); 159 | 160 | // create message 161 | return Messages.createMessage({ 162 | conversationId: conversation._id, 163 | customerId: customer._id, 164 | content, 165 | formWidgetData: submissions, 166 | }); 167 | }; 168 | 169 | export default { 170 | // Find integrationId by brandCode 171 | async leadConnect(_root, args: { brandCode: string; formCode: string }) { 172 | const brand = await Brands.findOne({ code: args.brandCode }); 173 | const form = await Forms.findOne({ code: args.formCode }); 174 | 175 | if (!brand || !form) { 176 | throw new Error('Invalid configuration'); 177 | } 178 | 179 | // find integration by brandId & formId 180 | const integ = await Integrations.findOne({ 181 | brandId: brand._id, 182 | formId: form._id, 183 | }); 184 | 185 | if (!integ) { 186 | throw new Error('Integration not found'); 187 | } 188 | 189 | if (integ.leadData && integ.leadData.loadType === 'embedded') { 190 | await Integrations.increaseViewCount(form._id); 191 | } 192 | 193 | // notify main api 194 | sendMessage('leadInstalled', { 195 | payload: { 196 | integrationId: integ._id, 197 | }, 198 | }); 199 | 200 | // return integration details 201 | return { 202 | integration: integ, 203 | form, 204 | }; 205 | }, 206 | 207 | // create new conversation using form data 208 | async saveLead( 209 | _root, 210 | args: { 211 | integrationId: string; 212 | formId: string; 213 | submissions: ISubmission[]; 214 | browserInfo: any; 215 | }, 216 | ) { 217 | const { formId, submissions } = args; 218 | 219 | const errors = await validate(formId, submissions); 220 | 221 | if (errors.length > 0) { 222 | return { status: 'error', errors }; 223 | } 224 | 225 | const message = await saveValues(args); 226 | 227 | if (!message) { 228 | return { status: 'error', errors: ['Invalid form'] }; 229 | } 230 | 231 | // increasing form submitted count 232 | await Integrations.increaseContactsGathered(formId); 233 | 234 | // notify main api 235 | sendMessage('callPublish', { 236 | trigger: 'conversationClientMessageInserted', 237 | payload: message, 238 | }); 239 | 240 | sendMessage('callPublish', { 241 | trigger: 'conversationMessageInserted', 242 | payload: message, 243 | }); 244 | 245 | return { status: 'ok', messageId: message._id }; 246 | }, 247 | 248 | // send email 249 | sendEmail(_root, args: IEmail) { 250 | sendEmail(args); 251 | }, 252 | 253 | leadIncreaseViewCount(_root, { formId }: { formId: string }) { 254 | return Integrations.increaseViewCount(formId); 255 | }, 256 | }; 257 | -------------------------------------------------------------------------------- /src/db/models/definitions/customers.ts: -------------------------------------------------------------------------------- 1 | import { Document, Schema } from 'mongoose'; 2 | 3 | import { CUSTOMER_LEAD_STATUS_TYPES, CUSTOMER_LIFECYCLE_STATE_TYPES, STATUSES } from './constants'; 4 | 5 | import { field, schemaWrapper } from './utils'; 6 | 7 | export interface ILocation { 8 | remoteAddress: string; 9 | country: string; 10 | city: string; 11 | region: string; 12 | hostname: string; 13 | language: string; 14 | userAgent: string; 15 | } 16 | 17 | export interface ILocationDocument extends ILocation, Document {} 18 | 19 | export interface IVisitorContact { 20 | email?: string; 21 | phone?: string; 22 | } 23 | 24 | export interface IVisitorContactDocument extends IVisitorContact, Document {} 25 | 26 | export interface IMessengerData { 27 | lastSeenAt?: number; 28 | sessionCount?: number; 29 | isActive?: boolean; 30 | customData?: any; 31 | } 32 | 33 | export interface IMessengerDataDocument extends IMessengerData, Document {} 34 | 35 | export interface ILink { 36 | linkedIn?: string; 37 | twitter?: string; 38 | facebook?: string; 39 | github?: string; 40 | youtube?: string; 41 | website?: string; 42 | } 43 | 44 | interface ILinkDocument extends ILink, Document {} 45 | 46 | export interface ICustomer { 47 | scopeBrandIds?: string[]; 48 | firstName?: string; 49 | lastName?: string; 50 | primaryEmail?: string; 51 | emails?: string[]; 52 | avatar?: string; 53 | primaryPhone?: string; 54 | phones?: string[]; 55 | 56 | ownerId?: string; 57 | position?: string; 58 | department?: string; 59 | leadStatus?: string; 60 | lifecycleState?: string; 61 | hasAuthority?: string; 62 | description?: string; 63 | doNotDisturb?: string; 64 | hasValidEmail?: boolean; 65 | links?: ILink; 66 | isUser?: boolean; 67 | integrationId?: string; 68 | tagIds?: string[]; 69 | // TODO migrate after remove 1row 70 | companyIds?: string[]; 71 | mergedIds?: string[]; 72 | status?: string; 73 | customFieldsData?: any; 74 | messengerData?: IMessengerData; 75 | location?: ILocation; 76 | visitorContactInfo?: IVisitorContact; 77 | urlVisits?: any; 78 | deviceTokens?: string[]; 79 | code?: string; 80 | } 81 | 82 | export interface ICustomerDocument extends ICustomer, Document { 83 | _id: string; 84 | messengerData?: IMessengerDataDocument; 85 | location?: ILocationDocument; 86 | links?: ILinkDocument; 87 | visitorContactInfo?: IVisitorContactDocument; 88 | profileScore?: number; 89 | status?: string; 90 | createdAt: Date; 91 | modifiedAt: Date; 92 | deviceTokens?: string[]; 93 | searchText?: string; 94 | } 95 | 96 | /* location schema */ 97 | const locationSchema = new Schema( 98 | { 99 | remoteAddress: String, 100 | country: String, 101 | city: String, 102 | region: String, 103 | hostname: String, 104 | language: String, 105 | userAgent: String, 106 | }, 107 | { _id: false }, 108 | ); 109 | 110 | const visitorContactSchema = new Schema( 111 | { 112 | email: String, 113 | phone: String, 114 | }, 115 | { _id: false }, 116 | ); 117 | 118 | /* 119 | * messenger schema 120 | */ 121 | const messengerSchema = new Schema( 122 | { 123 | lastSeenAt: field({ 124 | type: Date, 125 | label: 'Last seen at', 126 | }), 127 | sessionCount: field({ 128 | type: Number, 129 | label: 'Session count', 130 | }), 131 | isActive: field({ 132 | type: Boolean, 133 | label: 'Is online', 134 | }), 135 | customData: field({ 136 | type: Object, 137 | optional: true, 138 | }), 139 | }, 140 | { _id: false }, 141 | ); 142 | 143 | const linkSchema = new Schema( 144 | { 145 | linkedIn: field({ type: String, optional: true, label: 'LinkedIn' }), 146 | twitter: field({ type: String, optional: true, label: 'Twitter' }), 147 | facebook: field({ type: String, optional: true, label: 'Facebook' }), 148 | github: field({ type: String, optional: true, label: 'Github' }), 149 | youtube: field({ type: String, optional: true, label: 'Youtube' }), 150 | website: field({ type: String, optional: true, label: 'Website' }), 151 | }, 152 | { _id: false }, 153 | ); 154 | 155 | export const customerSchema = schemaWrapper( 156 | new Schema({ 157 | _id: field({ pkey: true }), 158 | 159 | createdAt: field({ type: Date, label: 'Created at' }), 160 | modifiedAt: field({ type: Date, label: 'Modified at' }), 161 | avatar: field({ type: String, optional: true }), 162 | 163 | firstName: field({ type: String, label: 'First name', optional: true }), 164 | lastName: field({ type: String, label: 'Last name', optional: true }), 165 | 166 | primaryEmail: field({ type: String, label: 'Primary Email', optional: true }), 167 | emails: field({ type: [String], optional: true }), 168 | hasValidEmail: field({ type: Boolean, optional: true }), 169 | 170 | primaryPhone: field({ type: String, label: 'Primary Phone', optional: true }), 171 | phones: field({ type: [String], optional: true }), 172 | profileScore: field({ type: Number, index: true, optional: true }), 173 | 174 | ownerId: field({ type: String, optional: true }), 175 | position: field({ type: String, optional: true, label: 'Position' }), 176 | department: field({ type: String, optional: true, label: 'Department' }), 177 | 178 | leadStatus: field({ 179 | type: String, 180 | enum: CUSTOMER_LEAD_STATUS_TYPES, 181 | optional: true, 182 | label: 'Lead Status', 183 | }), 184 | 185 | status: field({ 186 | type: String, 187 | enum: STATUSES.ALL, 188 | default: STATUSES.ACTIVE, 189 | optional: true, 190 | label: 'Status', 191 | index: true, 192 | }), 193 | 194 | lifecycleState: field({ 195 | type: String, 196 | enum: CUSTOMER_LIFECYCLE_STATE_TYPES, 197 | optional: true, 198 | label: 'Lifecycle State', 199 | }), 200 | 201 | hasAuthority: field({ type: String, optional: true, label: 'Has authority' }), 202 | description: field({ type: String, optional: true, label: 'Description' }), 203 | doNotDisturb: field({ 204 | type: String, 205 | optional: true, 206 | label: 'Do not disturb', 207 | }), 208 | links: field({ type: linkSchema, default: {} }), 209 | 210 | isUser: field({ type: Boolean, label: 'Is user', optional: true }), 211 | 212 | integrationId: field({ type: String, optional: true }), 213 | tagIds: field({ type: [String], optional: true, index: true }), 214 | 215 | // Merged customer ids 216 | mergedIds: field({ type: [String], optional: true }), 217 | 218 | customFieldsData: field({ type: Object, optional: true }), 219 | messengerData: field({ type: messengerSchema, optional: true }), 220 | 221 | location: field({ type: locationSchema, optional: true }), 222 | 223 | // if customer is not a user then we will contact with this visitor using 224 | // this information 225 | visitorContactInfo: field({ 226 | type: visitorContactSchema, 227 | optional: true, 228 | label: 'Visitor contact info', 229 | }), 230 | urlVisits: Object, 231 | 232 | deviceTokens: field({ type: [String], default: [] }), 233 | searchText: field({ type: String, optional: true, index: true }), 234 | code: field({ type: String, label: 'Code', optional: true }), 235 | }), 236 | ); 237 | -------------------------------------------------------------------------------- /src/data/resolvers/mutations/messenger.ts: -------------------------------------------------------------------------------- 1 | import * as strip from 'strip'; 2 | 3 | import { Brands, Companies, Conversations, Customers, Integrations, Messages } from '../../../db/models'; 4 | 5 | import { IBrowserInfo, IVisitorContactInfoParams } from '../../../db/models/Customers'; 6 | import { sendMessage } from '../../../messageQueue'; 7 | import { createEngageVisitorMessages } from '../utils/engage'; 8 | import { unreadMessagesQuery } from '../utils/messenger'; 9 | 10 | export default { 11 | /* 12 | * Create a new customer or update existing customer info 13 | * when connection established 14 | */ 15 | async messengerConnect( 16 | _root, 17 | args: { 18 | brandCode: string; 19 | email?: string; 20 | phone?: string; 21 | code?: string; 22 | isUser?: boolean; 23 | companyData?: any; 24 | data?: any; 25 | cachedCustomerId?: string; 26 | deviceToken?: string; 27 | }, 28 | ) { 29 | const { brandCode, email, phone, code, isUser, companyData, data, cachedCustomerId, deviceToken } = args; 30 | 31 | const customData = data; 32 | 33 | // find brand 34 | const brand = await Brands.findOne({ code: brandCode }); 35 | 36 | // find integration 37 | const integration = await Integrations.getIntegration(brandCode, 'messenger'); 38 | 39 | if (!integration) { 40 | throw new Error('Integration not found'); 41 | } 42 | 43 | let customer = await Customers.getCustomer({ 44 | cachedCustomerId, 45 | email, 46 | phone, 47 | code, 48 | }); 49 | 50 | if (customer) { 51 | // update prev customer 52 | customer = await Customers.updateMessengerCustomer({ 53 | _id: customer._id, 54 | doc: { 55 | email, 56 | phone, 57 | code, 58 | isUser, 59 | deviceToken, 60 | }, 61 | customData, 62 | }); 63 | 64 | // create new customer 65 | } else { 66 | customer = await Customers.createMessengerCustomer( 67 | { 68 | integrationId: integration._id, 69 | email, 70 | phone, 71 | code, 72 | isUser, 73 | deviceToken, 74 | }, 75 | customData, 76 | ); 77 | } 78 | 79 | // get or create company 80 | if (companyData) { 81 | const company = await Companies.getOrCreate({ 82 | ...companyData, 83 | scopeBrandIds: [brand._id], 84 | }); 85 | 86 | // add company to customer's companyIds list 87 | await Customers.addCompany(customer._id, company._id); 88 | } 89 | 90 | const messengerData = await Integrations.getMessengerData(integration); 91 | 92 | return { 93 | integrationId: integration._id, 94 | uiOptions: integration.uiOptions, 95 | languageCode: integration.languageCode, 96 | messengerData, 97 | customerId: customer._id, 98 | brand, 99 | }; 100 | }, 101 | 102 | /* 103 | * Create a new message 104 | */ 105 | async insertMessage( 106 | _root, 107 | args: { 108 | integrationId: string; 109 | customerId: string; 110 | conversationId?: string; 111 | message: string; 112 | attachments?: any[]; 113 | }, 114 | ) { 115 | const { integrationId, customerId, conversationId, message, attachments } = args; 116 | 117 | const conversationContent = strip(message || '').substring(0, 100); 118 | 119 | // get or create conversation 120 | const conversation = await Conversations.getOrCreateConversation({ 121 | conversationId, 122 | integrationId, 123 | customerId, 124 | content: conversationContent, 125 | }); 126 | 127 | // create message 128 | const msg = await Messages.createMessage({ 129 | conversationId: conversation._id, 130 | customerId, 131 | content: message, 132 | attachments, 133 | }); 134 | 135 | await Conversations.updateOne( 136 | { _id: msg.conversationId }, 137 | { 138 | $set: { 139 | // Reopen its conversation if it's closed 140 | status: Conversations.getConversationStatuses().OPEN, 141 | 142 | // setting conversation's content to last message 143 | content: conversationContent, 144 | 145 | // Mark as unread 146 | readUserIds: [], 147 | }, 148 | }, 149 | ); 150 | 151 | // mark customer as active 152 | await Customers.markCustomerAsActive(conversation.customerId); 153 | 154 | // notify main api 155 | sendMessage('callPublish', { 156 | trigger: 'conversationClientMessageInserted', 157 | payload: msg, 158 | }); 159 | 160 | sendMessage('callPublish', { 161 | trigger: 'conversationMessageInserted', 162 | payload: msg, 163 | }); 164 | 165 | sendMessage('callPublish', { 166 | trigger: 'conversationClientTypingStatusChanged', 167 | payload: { 168 | conversationId, 169 | text: '', 170 | }, 171 | }); 172 | 173 | return msg; 174 | }, 175 | 176 | /* 177 | * Mark given conversation's messages as read 178 | */ 179 | async readConversationMessages(_root, args: { conversationId: string }) { 180 | const response = await Messages.updateMany( 181 | { 182 | conversationId: args.conversationId, 183 | userId: { $exists: true }, 184 | isCustomerRead: { $ne: true }, 185 | }, 186 | { isCustomerRead: true }, 187 | { multi: true }, 188 | ); 189 | 190 | return response; 191 | }, 192 | 193 | saveCustomerGetNotified(_root, args: IVisitorContactInfoParams) { 194 | return Customers.saveVisitorContactInfo(args); 195 | }, 196 | 197 | /* 198 | * Update customer location field 199 | */ 200 | async saveBrowserInfo(_root, { customerId, browserInfo }: { customerId: string; browserInfo: IBrowserInfo }) { 201 | // update location 202 | await Customers.updateLocation(customerId, browserInfo); 203 | 204 | // update messenger session data 205 | const customer = await Customers.updateMessengerSession(customerId, browserInfo.url || ''); 206 | 207 | // Preventing from displaying non messenger integrations like form's messages 208 | // as last unread message 209 | const integration = await Integrations.findOne({ 210 | _id: customer.integrationId, 211 | kind: 'messenger', 212 | }); 213 | 214 | if (!integration) { 215 | return null; 216 | } 217 | 218 | const brand = await Brands.findOne({ _id: integration.brandId }); 219 | 220 | if (!brand) { 221 | return null; 222 | } 223 | 224 | // try to create engage chat auto messages 225 | if (!customer.primaryEmail) { 226 | await createEngageVisitorMessages({ 227 | brand, 228 | integration, 229 | customer, 230 | browserInfo, 231 | }); 232 | } 233 | 234 | // find conversations 235 | const convs = await Conversations.find({ 236 | integrationId: integration._id, 237 | customerId: customer._id, 238 | }); 239 | 240 | return Messages.findOne(unreadMessagesQuery(convs)); 241 | }, 242 | 243 | sendTypingInfo(_root, args: { conversationId: string; text?: string }) { 244 | sendMessage('callPublish', { 245 | trigger: 'conversationClientTypingStatusChanged', 246 | payload: args, 247 | }); 248 | 249 | return 'ok'; 250 | }, 251 | }; 252 | -------------------------------------------------------------------------------- /src/data/schema.ts: -------------------------------------------------------------------------------- 1 | export const types = ` 2 | scalar Date 3 | scalar JSON 4 | 5 | type Company { 6 | _id: String! 7 | name: String 8 | size: Int 9 | website: String 10 | industry: String 11 | plan: String 12 | lastSeenAt: Date 13 | sessionCount: Int 14 | tagIds: [String], 15 | } 16 | 17 | type UserDetails { 18 | avatar: String 19 | shortName: String 20 | position: String 21 | location: String 22 | description: String 23 | fullName: String 24 | } 25 | 26 | type UserLinks { 27 | facebook: String 28 | twitter: String 29 | youtube: String 30 | linkedIn: String 31 | github: String 32 | website: String 33 | } 34 | 35 | type User { 36 | _id: String! 37 | details: UserDetails 38 | links: UserLinks 39 | } 40 | 41 | type Customer { 42 | _id: String! 43 | location: JSON 44 | } 45 | 46 | type EngageData { 47 | messageId: String 48 | brandId: String 49 | content: String 50 | fromUserId: String 51 | fromUser: User 52 | kind: String 53 | sentAs: String 54 | } 55 | 56 | type Integration { 57 | _id: String! 58 | name: String 59 | languageCode: String 60 | uiOptions: JSON 61 | messengerData: JSON 62 | leadData: JSON 63 | } 64 | 65 | type Brand { 66 | name: String! 67 | code: String! 68 | description: String 69 | } 70 | 71 | type Attachment { 72 | url: String! 73 | name: String! 74 | type: String! 75 | size: Float 76 | } 77 | 78 | type Conversation { 79 | _id: String! 80 | customerId: String! 81 | integrationId: String! 82 | status: String! 83 | content: String 84 | createdAt: Date 85 | participatedUsers: [User] 86 | readUserIds: [String] 87 | 88 | messages: [ConversationMessage] 89 | } 90 | 91 | type ConversationMessage { 92 | _id: String! 93 | conversationId: String! 94 | customerId: String 95 | user: User 96 | content: String 97 | createdAt: Date 98 | attachments: [Attachment] 99 | internal: Boolean 100 | fromBot: Boolean 101 | engageData: EngageData 102 | messengerAppData: JSON 103 | } 104 | 105 | type Field { 106 | _id: String 107 | formId: String 108 | type: String 109 | check: String 110 | text: String 111 | description: String 112 | options: [String] 113 | isRequired: Boolean 114 | name: String 115 | validation: String 116 | order: Int 117 | } 118 | 119 | type Rule { 120 | _id : String! 121 | kind: String! 122 | text: String! 123 | condition: String! 124 | value: String 125 | } 126 | 127 | type Form { 128 | _id: String 129 | title: String 130 | description: String 131 | buttonText: String 132 | fields: [Field] 133 | } 134 | 135 | type MessengerConnectResponse { 136 | integrationId: String 137 | uiOptions: JSON 138 | languageCode: String 139 | messengerData: JSON 140 | customerId: String 141 | brand: Brand 142 | } 143 | 144 | type ConversationDetailResponse { 145 | _id: String 146 | messages: [ConversationMessage] 147 | participatedUsers: [User] 148 | isOnline: Boolean 149 | supporters: [User] 150 | } 151 | 152 | type FormConnectResponse { 153 | integration: Integration 154 | form: Form 155 | } 156 | 157 | type SaveFormResponse { 158 | status: String! 159 | errors: [Error] 160 | messageId: String 161 | } 162 | 163 | type Error { 164 | fieldId: String 165 | code: String 166 | text: String 167 | } 168 | 169 | type KnowledgeBaseArticle { 170 | _id: String 171 | title: String 172 | summary: String 173 | content: String 174 | reactionChoices: [String] 175 | createdBy: String 176 | createdDate: Date 177 | modifiedBy: String 178 | modifiedDate: Date 179 | author: User 180 | } 181 | 182 | type KnowledgeBaseCategory { 183 | _id: String 184 | title: String 185 | description: String 186 | articles: [KnowledgeBaseArticle] 187 | numOfArticles: Int 188 | authors: [User] 189 | icon: String 190 | } 191 | 192 | type KnowledgeBaseTopic { 193 | _id: String 194 | title: String 195 | description: String 196 | categories: [KnowledgeBaseCategory] 197 | color: String 198 | backgroundImage: String 199 | languageCode: String 200 | } 201 | 202 | type KnowledgeBaseLoader { 203 | loadType: String 204 | } 205 | 206 | input AttachmentInput { 207 | url: String! 208 | name: String! 209 | type: String! 210 | size: Float 211 | } 212 | 213 | input FieldValueInput { 214 | _id: String! 215 | type: String 216 | validation: String 217 | text: String 218 | value: String 219 | } 220 | `; 221 | 222 | export const queries = ` 223 | type Query { 224 | conversations(integrationId: String!, customerId: String!): [Conversation] 225 | conversationDetail(_id: String, integrationId: String!): ConversationDetailResponse 226 | getMessengerIntegration(brandCode: String!): Integration 227 | messages(conversationId: String): [ConversationMessage] 228 | unreadCount(conversationId: String): Int 229 | totalUnreadCount(integrationId: String!, customerId: String!): Int 230 | messengerSupporters(integrationId: String!): [User] 231 | form(formId: String): Form 232 | knowledgeBaseTopicsDetail(topicId: String!) : KnowledgeBaseTopic 233 | knowledgeBaseCategoriesDetail(categoryId: String!) : KnowledgeBaseCategory 234 | knowledgeBaseArticles(topicId: String!, searchString: String) : [KnowledgeBaseArticle] 235 | knowledgeBaseLoader(topicId: String!) : KnowledgeBaseLoader 236 | } 237 | `; 238 | 239 | export const mutations = ` 240 | type Mutation { 241 | messengerConnect( 242 | brandCode: String! 243 | email: String 244 | phone: String 245 | code: String 246 | isUser: Boolean 247 | 248 | companyData: JSON 249 | data: JSON 250 | 251 | cachedCustomerId: String 252 | deviceToken: String 253 | ): MessengerConnectResponse 254 | 255 | saveBrowserInfo( 256 | customerId: String! 257 | browserInfo: JSON! 258 | ): ConversationMessage 259 | 260 | insertMessage( 261 | integrationId: String! 262 | customerId: String! 263 | conversationId: String 264 | message: String, 265 | attachments: [AttachmentInput] 266 | ): ConversationMessage 267 | 268 | readConversationMessages(conversationId: String): JSON 269 | saveCustomerGetNotified(customerId: String!, type: String!, value: String!): JSON 270 | 271 | leadConnect( 272 | brandCode: String!, 273 | formCode: String! 274 | ): FormConnectResponse 275 | 276 | saveLead( 277 | integrationId: String! 278 | formId: String! 279 | submissions: [FieldValueInput] 280 | browserInfo: JSON! 281 | ): SaveFormResponse 282 | 283 | sendEmail( 284 | toEmails: [String] 285 | fromEmail: String 286 | title: String 287 | content: String 288 | ): String 289 | 290 | knowledgebaseIncReactionCount(articleId: String!, reactionChoice: String!): String 291 | leadIncreaseViewCount(formId: String!): JSON 292 | sendTypingInfo(conversationId: String!, text: String): String 293 | } 294 | `; 295 | 296 | const typeDefs = [types, queries, mutations]; 297 | 298 | export default typeDefs; 299 | -------------------------------------------------------------------------------- /src/db/models/definitions/integrations.ts: -------------------------------------------------------------------------------- 1 | import { Document, Schema } from 'mongoose'; 2 | import { IRule, ruleSchema } from './common'; 3 | import { KIND_CHOICES, LEAD_LOAD_TYPES, LEAD_SUCCESS_ACTIONS, MESSENGER_DATA_AVAILABILITY } from './constants'; 4 | import { field } from './utils'; 5 | 6 | export interface ISubmission extends Document { 7 | customerId: string; 8 | submittedAt: Date; 9 | } 10 | 11 | export interface ILink { 12 | twitter?: string; 13 | facebook?: string; 14 | youtube?: string; 15 | } 16 | 17 | export interface IMessengerOnlineHours { 18 | day?: string; 19 | from?: string; 20 | to?: string; 21 | } 22 | 23 | export interface IMessengerOnlineHoursDocument extends IMessengerOnlineHours, Document {} 24 | 25 | export interface IMessengerDataMessagesItem { 26 | greetings?: { title?: string; message?: string }; 27 | away?: string; 28 | thank?: string; 29 | welcome?: string; 30 | } 31 | 32 | export interface IMessageDataMessages { 33 | [key: string]: IMessengerDataMessagesItem; 34 | } 35 | 36 | export interface IMessengerData { 37 | supporterIds?: string[]; 38 | notifyCustomer?: boolean; 39 | availabilityMethod?: string; 40 | isOnline?: boolean; 41 | onlineHours?: IMessengerOnlineHours[]; 42 | timezone?: string; 43 | messages?: IMessageDataMessages; 44 | links?: ILink; 45 | showChat?: boolean; 46 | showLauncher?: boolean; 47 | requireAuth?: boolean; 48 | forceLogoutWhenResolve?: boolean; 49 | } 50 | 51 | export interface IMessengerDataDocument extends IMessengerData, Document {} 52 | 53 | export interface ICallout extends Document { 54 | title?: string; 55 | body?: string; 56 | buttonText?: string; 57 | featuredImage?: string; 58 | skip?: boolean; 59 | } 60 | 61 | export interface ILeadData { 62 | loadType?: string; 63 | successAction?: string; 64 | fromEmail?: string; 65 | userEmailTitle?: string; 66 | userEmailContent?: string; 67 | adminEmails?: string; 68 | adminEmailTitle?: string; 69 | adminEmailContent?: string; 70 | thankContent?: string; 71 | redirectUrl?: string; 72 | themeColor?: string; 73 | callout?: ICallout; 74 | rules?: IRule; 75 | } 76 | 77 | export interface ILeadDataDocument extends ILeadData, Document { 78 | viewCount?: number; 79 | contactsGathered?: number; 80 | } 81 | 82 | export interface IUiOptions { 83 | color?: string; 84 | wallpaper?: string; 85 | logo?: string; 86 | } 87 | 88 | // subdocument schema for messenger UiOptions 89 | export interface IUiOptionsDocument extends IUiOptions, Document {} 90 | 91 | export interface IIntegration { 92 | kind?: string; 93 | name?: string; 94 | brandId?: string; 95 | languageCode?: string; 96 | tagIds?: string[]; 97 | formId?: string; 98 | leadData?: ILeadData; 99 | messengerData?: IMessengerData; 100 | uiOptions?: IUiOptions; 101 | isActive?: boolean; 102 | } 103 | 104 | export interface IIntegrationDocument extends IIntegration, Document { 105 | _id: string; 106 | createdUserId: string; 107 | // TODO remove 108 | formData?: ILeadData; 109 | leadData?: ILeadDataDocument; 110 | messengerData?: IMessengerDataDocument; 111 | uiOptions?: IUiOptionsDocument; 112 | } 113 | 114 | // subdocument schema for MessengerOnlineHours 115 | const messengerOnlineHoursSchema = new Schema( 116 | { 117 | day: field({ type: String }), 118 | from: field({ type: String }), 119 | to: field({ type: String }), 120 | }, 121 | { _id: false }, 122 | ); 123 | 124 | // subdocument schema for MessengerData 125 | const messengerDataSchema = new Schema( 126 | { 127 | supporterIds: field({ type: [String] }), 128 | notifyCustomer: field({ type: Boolean }), 129 | availabilityMethod: field({ 130 | type: String, 131 | enum: MESSENGER_DATA_AVAILABILITY.ALL, 132 | }), 133 | isOnline: field({ 134 | type: Boolean, 135 | }), 136 | onlineHours: field({ type: [messengerOnlineHoursSchema] }), 137 | timezone: field({ 138 | type: String, 139 | optional: true, 140 | }), 141 | messages: field({ type: Object, optional: true }), 142 | links: { 143 | facebook: String, 144 | twitter: String, 145 | youtube: String, 146 | }, 147 | requireAuth: field({ type: Boolean, default: true }), 148 | showChat: field({ type: Boolean, default: true }), 149 | showLauncher: field({ type: Boolean, default: true }), 150 | forceLogoutWhenResolve: field({ type: Boolean, default: false }), 151 | }, 152 | { _id: false }, 153 | ); 154 | 155 | // schema for lead's callout component 156 | export const calloutSchema = new Schema( 157 | { 158 | title: field({ type: String, optional: true }), 159 | body: field({ type: String, optional: true }), 160 | buttonText: field({ type: String, optional: true }), 161 | featuredImage: field({ type: String, optional: true }), 162 | skip: field({ type: Boolean, optional: true }), 163 | }, 164 | { _id: false }, 165 | ); 166 | 167 | // TODO: remove 168 | // schema for lead submission details 169 | export const submissionSchema = new Schema( 170 | { 171 | customerId: field({ type: String }), 172 | submittedAt: field({ type: Date }), 173 | }, 174 | { _id: false }, 175 | ); 176 | 177 | // subdocument schema for LeadData 178 | const leadDataSchema = new Schema( 179 | { 180 | loadType: field({ 181 | type: String, 182 | enum: LEAD_LOAD_TYPES.ALL, 183 | }), 184 | successAction: field({ 185 | type: String, 186 | enum: LEAD_SUCCESS_ACTIONS.ALL, 187 | optional: true, 188 | }), 189 | fromEmail: field({ 190 | type: String, 191 | optional: true, 192 | }), 193 | userEmailTitle: field({ 194 | type: String, 195 | optional: true, 196 | }), 197 | userEmailContent: field({ 198 | type: String, 199 | optional: true, 200 | }), 201 | adminEmails: field({ 202 | type: [String], 203 | optional: true, 204 | }), 205 | adminEmailTitle: field({ 206 | type: String, 207 | optional: true, 208 | }), 209 | adminEmailContent: field({ 210 | type: String, 211 | optional: true, 212 | }), 213 | thankContent: field({ 214 | type: String, 215 | optional: true, 216 | }), 217 | redirectUrl: field({ 218 | type: String, 219 | optional: true, 220 | }), 221 | themeColor: field({ 222 | type: String, 223 | optional: true, 224 | }), 225 | callout: field({ 226 | type: calloutSchema, 227 | optional: true, 228 | }), 229 | viewCount: field({ 230 | type: Number, 231 | optional: true, 232 | }), 233 | contactsGathered: field({ 234 | type: Number, 235 | optional: true, 236 | }), 237 | rules: field({ 238 | type: [ruleSchema], 239 | optional: true, 240 | }), 241 | }, 242 | { _id: false }, 243 | ); 244 | 245 | // subdocument schema for messenger UiOptions 246 | const uiOptionsSchema = new Schema( 247 | { 248 | color: field({ type: String }), 249 | wallpaper: field({ type: String }), 250 | logo: field({ type: String }), 251 | }, 252 | { _id: false }, 253 | ); 254 | 255 | // schema for integration document 256 | export const integrationSchema = new Schema({ 257 | _id: field({ pkey: true }), 258 | createdUserId: field({ type: String }), 259 | 260 | kind: field({ 261 | type: String, 262 | enum: KIND_CHOICES.ALL, 263 | }), 264 | 265 | name: field({ type: String }), 266 | brandId: field({ type: String }), 267 | 268 | languageCode: field({ 269 | type: String, 270 | optional: true, 271 | }), 272 | tagIds: field({ type: [String], optional: true }), 273 | formId: field({ type: String }), 274 | leadData: field({ type: leadDataSchema }), 275 | // TODO: remove 276 | formData: field({ type: leadDataSchema }), 277 | messengerData: field({ type: messengerDataSchema }), 278 | uiOptions: field({ type: uiOptionsSchema }), 279 | isActive: field({ type: Boolean, optional: true, default: true }), 280 | }); 281 | -------------------------------------------------------------------------------- /src/data/resolvers/utils/engage.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Conversations, 3 | EngageMessages, 4 | IBrandDocument, 5 | ICustomerDocument, 6 | IIntegrationDocument, 7 | IMessageDocument, 8 | IMessageEngageData, 9 | IUserDocument, 10 | Messages, 11 | Users, 12 | } from '../../../db/models'; 13 | 14 | import { IBrowserInfo } from '../../../db/models/Customers'; 15 | 16 | /* 17 | * Replaces customer & user infos in given content 18 | */ 19 | export const replaceKeys = (params: { content: string; customer: ICustomerDocument; user: IUserDocument }): string => { 20 | const { content, customer, user } = params; 21 | 22 | let result = content; 23 | 24 | // replace customer fields 25 | result = result.replace(/{{\s?customer.name\s?}}/gi, `${customer.firstName} ${customer.lastName}`); 26 | result = result.replace(/{{\s?customer.email\s?}}/gi, customer.primaryEmail || ''); 27 | 28 | // replace user fields 29 | if (user.details) { 30 | result = result.replace(/{{\s?user.fullName\s?}}/gi, user.details.fullName); 31 | result = result.replace(/{{\s?user.position\s?}}/gi, user.details.position); 32 | result = result.replace(/{{\s?user.email\s?}}/gi, user.email); 33 | } 34 | 35 | return result; 36 | }; 37 | 38 | /* 39 | * Checks individual rule 40 | */ 41 | interface IRule { 42 | value?: string; 43 | kind: string; 44 | condition: string; 45 | } 46 | 47 | interface ICheckRuleParams { 48 | rule: IRule; 49 | browserInfo: IBrowserInfo; 50 | numberOfVisits?: number; 51 | } 52 | 53 | export const checkRule = (params: ICheckRuleParams): boolean => { 54 | const { rule, browserInfo, numberOfVisits } = params; 55 | const { language, url, city, country } = browserInfo; 56 | const { value, kind, condition } = rule; 57 | const ruleValue: any = value; 58 | 59 | let valueToTest: any; 60 | 61 | if (kind === 'browserLanguage') { 62 | valueToTest = language; 63 | } 64 | 65 | if (kind === 'currentPageUrl') { 66 | valueToTest = url; 67 | } 68 | 69 | if (kind === 'city') { 70 | valueToTest = city; 71 | } 72 | 73 | if (kind === 'country') { 74 | valueToTest = country; 75 | } 76 | 77 | if (kind === 'numberOfVisits') { 78 | valueToTest = numberOfVisits; 79 | } 80 | 81 | // is 82 | if (condition === 'is' && valueToTest !== ruleValue) { 83 | return false; 84 | } 85 | 86 | // isNot 87 | if (condition === 'isNot' && valueToTest === ruleValue) { 88 | return false; 89 | } 90 | 91 | // isUnknown 92 | if (condition === 'isUnknown' && valueToTest) { 93 | return false; 94 | } 95 | 96 | // hasAnyValue 97 | if (condition === 'hasAnyValue' && !valueToTest) { 98 | return false; 99 | } 100 | 101 | // startsWith 102 | if (condition === 'startsWith' && valueToTest && !valueToTest.startsWith(ruleValue)) { 103 | return false; 104 | } 105 | 106 | // endsWith 107 | if (condition === 'endsWith' && valueToTest && !valueToTest.endsWith(ruleValue)) { 108 | return false; 109 | } 110 | 111 | // contains 112 | if (condition === 'contains' && valueToTest && !valueToTest.includes(ruleValue)) { 113 | return false; 114 | } 115 | 116 | // greaterThan 117 | if (condition === 'greaterThan' && valueToTest < parseInt(ruleValue, 10)) { 118 | return false; 119 | } 120 | 121 | if (condition === 'lessThan' && valueToTest > parseInt(ruleValue, 10)) { 122 | return false; 123 | } 124 | 125 | if (condition === 'doesNotContain' && valueToTest.includes(ruleValue)) { 126 | return false; 127 | } 128 | 129 | return true; 130 | }; 131 | 132 | /* 133 | * This function determines whether or not current visitor's information 134 | * satisfying given engage message's rules 135 | */ 136 | interface ICheckRulesParams { 137 | rules: IRule[]; 138 | browserInfo: IBrowserInfo; 139 | numberOfVisits?: number; 140 | } 141 | export const checkRules = async (params: ICheckRulesParams): Promise => { 142 | const { rules, browserInfo, numberOfVisits } = params; 143 | 144 | let passedAllRules = true; 145 | 146 | rules.forEach(rule => { 147 | // check individual rule 148 | if (!checkRule({ rule, browserInfo, numberOfVisits })) { 149 | passedAllRules = false; 150 | return; 151 | } 152 | }); 153 | 154 | return passedAllRules; 155 | }; 156 | 157 | /* 158 | * Creates or update conversation & message object using given info 159 | */ 160 | export const createOrUpdateConversationAndMessages = async (args: { 161 | customer: ICustomerDocument; 162 | integration: IIntegrationDocument; 163 | user: IUserDocument; 164 | engageData: IMessageEngageData; 165 | }): Promise => { 166 | const { customer, integration, user, engageData } = args; 167 | 168 | const prevMessage = await Messages.findOne({ 169 | customerId: customer._id, 170 | 'engageData.messageId': engageData.messageId, 171 | }); 172 | 173 | // if previously created conversation for this customer 174 | if (prevMessage) { 175 | const messages = await Messages.find({ 176 | conversationId: prevMessage.conversationId, 177 | }); 178 | 179 | // leave conversations with responses alone 180 | if (messages.length > 1) { 181 | return null; 182 | } 183 | 184 | // mark as unread again && reset engageData 185 | await Messages.updateOne({ _id: prevMessage._id }, { $set: { engageData, isCustomerRead: false } }); 186 | 187 | return null; 188 | } 189 | 190 | // replace keys in content 191 | const replacedContent = replaceKeys({ 192 | content: engageData.content, 193 | customer, 194 | user, 195 | }); 196 | 197 | // create conversation 198 | const conversation = await Conversations.createConversation({ 199 | userId: user._id, 200 | customerId: customer._id, 201 | integrationId: integration._id, 202 | content: replacedContent, 203 | }); 204 | 205 | // create message 206 | return Messages.createMessage({ 207 | engageData, 208 | conversationId: conversation._id, 209 | userId: user._id, 210 | customerId: customer._id, 211 | content: replacedContent, 212 | }); 213 | }; 214 | 215 | /* 216 | * This function will be used in messagerConnect and it will create conversations 217 | * when visitor messenger connect 218 | */ 219 | export const createEngageVisitorMessages = async (params: { 220 | brand: IBrandDocument; 221 | integration: IIntegrationDocument; 222 | customer: ICustomerDocument; 223 | browserInfo: any; 224 | }): Promise => { 225 | const { brand, integration, customer, browserInfo } = params; 226 | 227 | // force read previous unread engage messages ============ 228 | await Messages.forceReadCustomerPreviousEngageMessages(customer._id); 229 | 230 | const messages = await EngageMessages.find({ 231 | 'messenger.brandId': brand._id, 232 | kind: 'visitorAuto', 233 | method: 'messenger', 234 | isLive: true, 235 | }); 236 | 237 | const conversationMessages = []; 238 | 239 | for (const message of messages) { 240 | const messenger = message.messenger ? message.messenger.toJSON() : {}; 241 | 242 | const user = await Users.findOne({ _id: message.fromUserId }); 243 | 244 | if (!user) { 245 | continue; 246 | } 247 | 248 | // check for rules === 249 | const urlVisits = customer.urlVisits || {}; 250 | 251 | const isPassedAllRules = await checkRules({ 252 | rules: messenger.rules, 253 | browserInfo, 254 | numberOfVisits: urlVisits[browserInfo.url] || 0, 255 | }); 256 | 257 | // if given visitor is matched with given condition then create 258 | // conversations 259 | if (isPassedAllRules) { 260 | const conversationMessage = await createOrUpdateConversationAndMessages({ 261 | customer, 262 | integration, 263 | user, 264 | engageData: { 265 | ...messenger, 266 | messageId: message._id, 267 | fromUserId: message.fromUserId, 268 | }, 269 | }); 270 | 271 | if (conversationMessage) { 272 | // collect created messages 273 | conversationMessages.push(conversationMessage); 274 | 275 | // add given customer to customerIds list 276 | await EngageMessages.updateOne({ _id: message._id }, { $push: { customerIds: customer._id } }); 277 | } 278 | } 279 | } 280 | 281 | // newly created conversation messages 282 | return conversationMessages; 283 | }; 284 | -------------------------------------------------------------------------------- /src/__tests__/engage.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createEngageVisitorMessages, 3 | createOrUpdateConversationAndMessages, 4 | replaceKeys, 5 | } from '../data/resolvers/utils/engage'; 6 | 7 | import { 8 | brandFactory, 9 | customerFactory, 10 | engageDataFactory, 11 | integrationFactory, 12 | messageFactory, 13 | userFactory, 14 | } from '../db/factories'; 15 | 16 | import { 17 | Brands, 18 | Conversations, 19 | Customers, 20 | EngageMessages, 21 | IBrandDocument, 22 | ICustomerDocument, 23 | IIntegrationDocument, 24 | Integrations, 25 | IUserDocument, 26 | Messages, 27 | } from '../db/models'; 28 | 29 | describe('replace keys', () => { 30 | test('must replace customer, user placeholders', async () => { 31 | const customer = await customerFactory({ 32 | firstName: 'firstName', 33 | lastName: 'lastName', 34 | }); 35 | const user = await userFactory({ fullName: 'fullName' }); 36 | 37 | const response = replaceKeys({ 38 | content: 'hi {{ customer.name }} - {{ user.fullName }}', 39 | customer, 40 | user, 41 | }); 42 | 43 | expect(response).toBe('hi firstName lastName - fullName'); 44 | }); 45 | }); 46 | 47 | describe('createConversation', () => { 48 | let _customer: ICustomerDocument; 49 | let _integration: IIntegrationDocument; 50 | 51 | beforeEach(async () => { 52 | // Creating test data 53 | _customer = await customerFactory(); 54 | _integration = await integrationFactory({}); 55 | }); 56 | 57 | afterEach(async () => { 58 | // Clearing test data 59 | await Customers.deleteMany({}); 60 | await Integrations.deleteMany({}); 61 | await Conversations.deleteMany({}); 62 | await Messages.deleteMany({}); 63 | }); 64 | 65 | test('createOrUpdateConversationAndMessages', async () => { 66 | const user = await userFactory({ fullName: 'Full name' }); 67 | 68 | const kwargs = { 69 | customer: _customer, 70 | integration: _integration, 71 | user, 72 | engageData: engageDataFactory({ 73 | content: 'hi {{ customer.name }} {{ user.fullName }}', 74 | messageId: '_id', 75 | }), 76 | }; 77 | 78 | // create ========================== 79 | const message = await createOrUpdateConversationAndMessages(kwargs); 80 | 81 | if (!message) { 82 | throw new Error('message is null'); 83 | } 84 | 85 | const conversation = await Conversations.findOne({ 86 | _id: message.conversationId, 87 | }); 88 | 89 | if (!conversation) { 90 | throw new Error('conversation not found'); 91 | } 92 | 93 | expect(await Conversations.find().countDocuments()).toBe(1); 94 | expect(await Messages.find().countDocuments()).toBe(1); 95 | 96 | const customerName = `${_customer.firstName} ${_customer.lastName}`; 97 | 98 | // check message fields 99 | expect(message._id).toBeDefined(); 100 | expect(message.content).toBe(`hi ${customerName} Full name`); 101 | expect(message.userId).toBe(user._id); 102 | expect(message.customerId).toBe(_customer._id); 103 | 104 | // check conversation fields 105 | expect(conversation._id).toBeDefined(); 106 | expect(conversation.content).toBe(`hi ${customerName} Full name`); 107 | expect(conversation.integrationId).toBe(_integration._id); 108 | 109 | // second time ========================== 110 | // must not create new conversation & messages update 111 | await Messages.updateMany({ conversationId: conversation._id }, { $set: { isCustomerRead: true } }); 112 | 113 | let response = await createOrUpdateConversationAndMessages(kwargs); 114 | 115 | expect(response).toBe(null); 116 | 117 | expect(await Conversations.find().countDocuments()).toBe(1); 118 | expect(await Messages.find().countDocuments()).toBe(1); 119 | 120 | const updatedMessage = await Messages.findOne({ 121 | conversationId: conversation._id, 122 | }); 123 | 124 | if (!updatedMessage) { 125 | throw new Error('message not found'); 126 | } 127 | 128 | expect(updatedMessage.isCustomerRead).toBe(false); 129 | 130 | // do not mark as unread for conversations that 131 | // have more than one messages ===================== 132 | await Messages.updateMany({ conversationId: conversation._id }, { $set: { isCustomerRead: true } }); 133 | 134 | await messageFactory({ 135 | conversationId: conversation._id, 136 | isCustomerRead: true, 137 | }); 138 | 139 | response = await createOrUpdateConversationAndMessages(kwargs); 140 | 141 | expect(response).toBe(null); 142 | 143 | expect(await Conversations.find().countDocuments()).toBe(1); 144 | expect(await Messages.find().countDocuments()).toBe(2); 145 | 146 | const [message1, message2] = await Messages.find({ 147 | conversationId: conversation._id, 148 | }); 149 | 150 | expect(message1.isCustomerRead).toBe(true); 151 | expect(message2.isCustomerRead).toBe(true); 152 | }); 153 | }); 154 | 155 | describe('createEngageVisitorMessages', () => { 156 | let _user: IUserDocument; 157 | let _brand: IBrandDocument; 158 | let _customer: ICustomerDocument; 159 | let _integration: IIntegrationDocument; 160 | 161 | beforeEach(async () => { 162 | // Creating test data 163 | _customer = await customerFactory({ 164 | urlVisits: { '/page': 11 }, 165 | }); 166 | 167 | _brand = await brandFactory({}); 168 | _integration = await integrationFactory({ brandId: _brand._id }); 169 | _user = await userFactory({}); 170 | 171 | const message = new EngageMessages({ 172 | title: 'Visitor', 173 | fromUserId: _user._id, 174 | kind: 'visitorAuto', 175 | method: 'messenger', 176 | isLive: true, 177 | messenger: { 178 | brandId: _brand._id, 179 | rules: [ 180 | { 181 | kind: 'currentPageUrl', 182 | condition: 'is', 183 | value: '/page', 184 | }, 185 | { 186 | kind: 'numberOfVisits', 187 | condition: 'greaterThan', 188 | value: 10, 189 | }, 190 | ], 191 | content: 'hi {{ customer.name }}', 192 | }, 193 | }); 194 | 195 | return message.save(); 196 | }); 197 | 198 | afterEach(async () => { 199 | // Clearing test data 200 | await Customers.deleteMany({}); 201 | await Integrations.deleteMany({}); 202 | await Conversations.deleteMany({}); 203 | await Messages.deleteMany({}); 204 | await Brands.deleteMany({}); 205 | }); 206 | 207 | test('must create conversation & message object', async () => { 208 | // previous unread conversation messages created by engage 209 | await messageFactory({ 210 | customerId: _customer._id, 211 | isCustomerRead: false, 212 | engageData: engageDataFactory({ 213 | messageId: '_id2', 214 | }), 215 | }); 216 | 217 | await messageFactory({ 218 | customerId: _customer._id, 219 | isCustomerRead: false, 220 | engageData: engageDataFactory({ 221 | messageId: '_id2', 222 | }), 223 | }); 224 | 225 | // main call 226 | await createEngageVisitorMessages({ 227 | brand: _brand, 228 | customer: _customer, 229 | integration: _integration, 230 | browserInfo: { 231 | url: '/page', 232 | }, 233 | }); 234 | 235 | const conversation = await Conversations.findOne({}); 236 | 237 | if (!conversation) { 238 | throw new Error('conversation not found'); 239 | } 240 | 241 | const content = `hi ${_customer.firstName} ${_customer.lastName}`; 242 | 243 | expect(conversation._id).toBeDefined(); 244 | expect(conversation.content).toBe(content); 245 | expect(conversation.customerId).toBe(_customer._id); 246 | expect(conversation.integrationId).toBe(_integration._id); 247 | 248 | const message = await Messages.findOne({ 249 | conversationId: conversation._id, 250 | }); 251 | 252 | if (!message) { 253 | throw new Error('message not found'); 254 | } 255 | 256 | expect(message._id).toBeDefined(); 257 | expect(message.content).toBe(content); 258 | 259 | // count of unread conversation messages created by engage must be zero 260 | const convEngageMessages = await Messages.find({ 261 | customerId: _customer._id, 262 | isCustomerRead: false, 263 | engageData: { $exists: true }, 264 | }); 265 | 266 | expect(convEngageMessages.length).toBe(0); 267 | }); 268 | }); 269 | --------------------------------------------------------------------------------