api/src/data/resolvers/mutations/widgets.ts

Summary

Maintainability
F
1 wk
Test Coverage
import * as strip from 'strip';
import {
  Companies,
  Conformities,
  ConversationMessages,
  Conversations,
  Customers,
  Fields,
  Forms,
  FormSubmissions,
  Integrations,
  KnowledgeBaseArticles,
  Products,
  Users
} from '../../../db/models';
import Messages from '../../../db/models/ConversationMessages';
import {
  IBrowserInfo,
  IVisitorContactInfoParams
} from '../../../db/models/Customers';
import {
  CONVERSATION_OPERATOR_STATUS,
  CONVERSATION_STATUSES,
  KIND_CHOICES,
  MESSAGE_TYPES
} from '../../../db/models/definitions/constants';
import { ISubmission } from '../../../db/models/definitions/fields';
import {
  IAttachment,
  IIntegrationDocument,
  IMessengerDataMessagesItem
} from '../../../db/models/definitions/integrations';
import { IKnowledgebaseCredentials } from '../../../db/models/definitions/messengerApps';
import { debugError } from '../../../debuggers';
import { trackViewPageEvent } from '../../../events';
import { get, set } from '../../../inmemoryStorage';
import { graphqlPubsub } from '../../../pubsub';
import { sendToLog } from '../../logUtils';
import { AUTO_BOT_MESSAGES, BOT_MESSAGE_TYPES } from '../../constants';
import { IContext } from '../../types';
import {
  findCompany,
  registerOnboardHistory,
  sendEmail,
  sendMobileNotification,
  sendRequest,
  sendToWebhook
} from '../../utils';
import { solveSubmissions } from '../../widgetUtils';
import { getDocument, getMessengerApps } from './cacheUtils';
import { conversationNotifReceivers } from './conversations';
import { IFormDocument } from '../../../db/models/definitions/forms';
import EditorAttributeUtil from '../../editorAttributeUtils';

interface IWidgetEmailParams {
  toEmails: string[];
  fromEmail: string;
  title: string;
  content: string;
  customerId?: string;
  formId?: string;
  attachments?: IAttachment[];
}

export const getMessengerData = async (integration: IIntegrationDocument) => {
  let messagesByLanguage: IMessengerDataMessagesItem | null = null;
  let messengerData = integration.messengerData;

  if (messengerData) {
    if (messengerData.toJSON) {
      messengerData = messengerData.toJSON();
    }

    const languageCode = integration.languageCode || 'en';
    const messages = (messengerData || {}).messages;

    if (messages) {
      messagesByLanguage = messages[languageCode];
    }
  }

  // knowledgebase app =======
  const kbApp = await getMessengerApps('knowledgebase', integration._id);

  const topicId =
    kbApp && kbApp.credentials
      ? (kbApp.credentials as IKnowledgebaseCredentials).topicId
      : null;

  // lead app ==========
  const leadApps = await getMessengerApps('lead', integration._id, false);

  const formCodes = [] as string[];

  for (const app of leadApps) {
    if (app && app.credentials) {
      formCodes.push(app.credentials.formCode);
    }
  }

  // website app ============
  const websiteApps = await getMessengerApps('website', integration._id, false);

  return {
    ...(messengerData || {}),
    messages: messagesByLanguage,
    knowledgeBaseTopicId: topicId,
    websiteApps,
    formCodes
  };
};

const createVisitor = async (visitorId: string) => {
  const customer = await Customers.createCustomer({
    state: 'visitor',
    visitorId
  });

  sendToLog('visitor:convertRequest', { visitorId });

  return customer;
};

const createFormConversation = async (
  args: {
    integrationId: string;
    formId: string;
    submissions: ISubmission[];
    browserInfo: any;
    cachedCustomerId?: string;
  },
  generateContent: (form: IFormDocument) => string,
  generateConvData: () => {
    conversation?: any;
    message: any;
  },
  type?: string
) => {
  const { integrationId, formId, submissions } = args;

  const form = await Forms.findOne({ _id: formId });

  if (!form) {
    throw new Error('Form not found');
  }

  const errors = await Forms.validate(formId, submissions);

  if (errors.length > 0) {
    return { status: 'error', errors };
  }

  const content = await generateContent(form);

  const cachedCustomer = await solveSubmissions(args);

  const conversationData = await generateConvData();

  // create conversation
  const conversation = await Conversations.createConversation({
    integrationId,
    customerId: cachedCustomer._id,
    content,
    ...conversationData.conversation
  });

  // create message
  const message = await Messages.createMessage({
    conversationId: conversation._id,
    customerId: cachedCustomer._id,
    content,
    ...conversationData.message
  });

  graphqlPubsub.publish('conversationClientMessageInserted', {
    conversationClientMessageInserted: message
  });

  graphqlPubsub.publish('conversationMessageInserted', {
    conversationMessageInserted: message
  });

  if (type === 'lead') {
    // increasing form submitted count
    await Integrations.increaseContactsGathered(formId);

    const formData = {
      formId: args.formId,
      submissions: args.submissions,
      customer: cachedCustomer,
      cachedCustomerId: cachedCustomer._id,
      conversationId: conversation._id
    };

    await sendToWebhook('create', 'popupSubmitted', formData);
  }

  for (const submission of submissions) {
    let value: any = submission.value || '';

    if (submission.validation === 'number') {
      value = Number(submission.value);
    }

    if (
      submission.validation &&
      ['datetime', 'date'].includes(submission.validation)
    ) {
      value = new Date(submission.value);
    }

    const doc = {
      contentTypeId: conversation._id,
      contentType: type,
      formFieldId: submission._id,
      formId,
      value,
      customerId: cachedCustomer._id
    };

    await FormSubmissions.createFormSubmission(doc);
  }

  return {
    status: 'ok',
    messageId: message._id,
    customerId: cachedCustomer._id
  };
};

const widgetMutations = {
  // Find integrationId by brandCode
  async widgetsLeadConnect(
    _root,
    args: { brandCode: string; formCode: string; cachedCustomerId?: string }
  ) {
    const brand = await getDocument('brands', { code: args.brandCode });

    const form = await Forms.findOne({ code: args.formCode });

    if (!brand || !form) {
      throw new Error('Invalid configuration');
    }

    // find integration by brandId & formId
    const integ = await Integrations.getIntegration({
      brandId: brand._id,
      formId: form._id,
      isActive: true
    });

    if (integ.leadData && integ.leadData.loadType === 'embedded') {
      await Integrations.increaseViewCount(form._id);
    }

    if (integ.createdUserId) {
      const user = await Users.getUser(integ.createdUserId);

      registerOnboardHistory({ type: 'leadIntegrationInstalled', user });
    }

    if (integ.leadData?.isRequireOnce && args.cachedCustomerId) {
      const conversation = await Conversations.findOne({
        customerId: args.cachedCustomerId,
        integrationId: integ._id
      });
      if (conversation) {
        return null;
      }
    }

    // return integration details
    return {
      integration: integ,
      form
    };
  },

  // create new conversation using form data
  async widgetsSaveLead(
    _root,
    args: {
      integrationId: string;
      formId: string;
      submissions: ISubmission[];
      browserInfo: any;
      cachedCustomerId?: string;
      userId?: string;
    }
  ) {
    const { submissions } = args;

    return createFormConversation(
      args,
      form => {
        return form.title;
      },
      () => {
        return {
          message: {
            formWidgetData: submissions
          }
        };
      },
      'lead'
    );
  },

  widgetsLeadIncreaseViewCount(_root, { formId }: { formId: string }) {
    return Integrations.increaseViewCount(formId);
  },

  widgetsKnowledgebaseIncReactionCount(
    _root,
    { articleId, reactionChoice }: { articleId: string; reactionChoice: string }
  ) {
    return KnowledgeBaseArticles.modifyReactionCount(
      articleId,
      reactionChoice,
      'inc'
    );
  },

  widgetsKnowledgebaseDecReactionCount(
    _root,
    { articleId, reactionChoice }: { articleId: string; reactionChoice: string }
  ) {
    return KnowledgeBaseArticles.modifyReactionCount(
      articleId,
      reactionChoice,
      'dec'
    );
  },

  /*
   * Create a new customer or update existing customer info
   * when connection established
   */
  async widgetsMessengerConnect(
    _root,
    args: {
      brandCode: string;
      email?: string;
      phone?: string;
      code?: string;
      isUser?: boolean;
      companyData?: any;
      data?: any;
      cachedCustomerId?: string;
      deviceToken?: string;
      visitorId?: string;
    }
  ) {
    const {
      brandCode,
      email,
      phone,
      code,
      isUser,
      companyData,
      data,

      cachedCustomerId,
      deviceToken,
      visitorId
    } = args;

    const customData = data;

    // find brand
    const brand = await getDocument('brands', { code: brandCode });

    if (!brand) {
      throw new Error('Invalid configuration');
    }

    // find integration
    const integration = await getDocument('integrations', {
      brandId: brand._id,
      kind: KIND_CHOICES.MESSENGER
    });

    if (!integration) {
      throw new Error('Integration not found');
    }

    let customer;

    if (cachedCustomerId || email || phone || code) {
      customer = await Customers.getWidgetCustomer({
        integrationId: integration._id,
        cachedCustomerId,
        email,
        phone,
        code
      });

      const doc = {
        integrationId: integration._id,
        email,
        phone,
        code,
        isUser,
        deviceToken,
        scopeBrandIds: [brand._id]
      };

      customer = customer
        ? await Customers.updateMessengerCustomer({
            _id: customer._id,
            doc,
            customData
          })
        : await Customers.createMessengerCustomer({ doc, customData });
    }

    if (visitorId) {
      sendToLog('visitor:createOrUpdate', {
        visitorId,
        integrationId: integration._id,
        scopeBrandIds: [brand._id]
      });
    }

    // get or create company
    if (companyData && companyData.name) {
      let company = await findCompany(companyData);

      const {
        customFieldsData,
        trackedData
      } = await Fields.generateCustomFieldsData(companyData, 'company');

      companyData.customFieldsData = customFieldsData;
      companyData.trackedData = trackedData;

      if (!company) {
        companyData.primaryName = companyData.name;

        try {
          company = await Companies.createCompany({
            ...companyData,
            scopeBrandIds: [brand._id]
          });
        } catch (e) {
          debugError(e.message);
        }
      } else {
        company = await Companies.updateCompany(company._id, {
          ...companyData,
          scopeBrandIds: [brand._id]
        });
      }

      if (customer && company) {
        // add company to customer's companyIds list
        await Conformities.create({
          mainType: 'customer',
          mainTypeId: customer._id,
          relType: 'company',
          relTypeId: company._id
        });
      }
    }

    return {
      integrationId: integration._id,
      uiOptions: integration.uiOptions,
      languageCode: integration.languageCode,
      messengerData: await getMessengerData(integration),
      customerId: customer && customer._id,
      visitorId: customer ? null : visitorId,
      brand
    };
  },
  /*
   * Create a new message
   */
  async widgetsInsertMessage(
    _root,
    args: {
      integrationId: string;
      customerId?: string;
      visitorId?: string;
      conversationId?: string;
      message: string;
      skillId?: string;
      attachments?: any[];
      contentType: string;
    },
    { dataSources }: IContext
  ) {
    const {
      integrationId,
      visitorId,
      conversationId,
      message,
      skillId,
      attachments,
      contentType
    } = args;

    if (contentType === MESSAGE_TYPES.VIDEO_CALL_REQUEST) {
      const videoCallRequestMessage = await ConversationMessages.findOne(
        { conversationId, contentType },
        { createdAt: 1 }
      ).sort({ createdAt: -1 });

      if (videoCallRequestMessage) {
        const messageTime = new Date(
          videoCallRequestMessage.createdAt
        ).getTime();

        const nowTime = new Date().getTime();

        let integrationConfigs: Array<{ code: string; value?: string }> = [];

        try {
          integrationConfigs = await dataSources.IntegrationsAPI.fetchApi(
            '/configs'
          );
        } catch (e) {
          debugError(e);
        }

        const timeDelay = integrationConfigs.find(
          config => config.code === 'VIDEO_CALL_TIME_DELAY_BETWEEN_REQUESTS'
        ) || { value: '0' };

        const timeDelayIntValue = parseInt(timeDelay.value || '0', 10);

        const timeDelayValue = isNaN(timeDelayIntValue) ? 0 : timeDelayIntValue;

        if (messageTime + timeDelayValue * 1000 > nowTime) {
          const defaultValue = 'Video call request has already sent';

          const messageForDelay = integrationConfigs.find(
            config => config.code === 'VIDEO_CALL_MESSAGE_FOR_TIME_DELAY'
          ) || { value: defaultValue };

          throw new Error(messageForDelay.value || defaultValue);
        }
      }
    }

    const conversationContent = strip(message || '').substring(0, 100);

    let { customerId } = args;

    if (visitorId && !customerId) {
      const customer = await createVisitor(visitorId);
      customerId = customer._id;
    }

    // customer can write a message
    // to the closed conversation even if it's closed
    let conversation;

    const integration =
      (await getDocument('integrations', {
        _id: integrationId
      })) || {};

    const messengerData = integration.messengerData || {};

    const { botEndpointUrl, botShowInitialMessage } = messengerData;

    const HAS_BOTENDPOINT_URL = (botEndpointUrl || '').length > 0;

    if (conversationId) {
      conversation = await Conversations.findOne({
        _id: conversationId
      }).lean();

      conversation = await Conversations.findByIdAndUpdate(
        conversationId,
        {
          // mark this conversation as unread
          readUserIds: [],

          // reopen this conversation if it's closed
          status: CONVERSATION_STATUSES.OPEN
        },
        { new: true }
      );
      // create conversation
    } else {
      conversation = await Conversations.createConversation({
        customerId,
        integrationId,
        operatorStatus: HAS_BOTENDPOINT_URL
          ? CONVERSATION_OPERATOR_STATUS.BOT
          : CONVERSATION_OPERATOR_STATUS.OPERATOR,
        status: CONVERSATION_STATUSES.OPEN,
        content: conversationContent,
        ...(skillId ? { skillId } : {})
      });
    }

    // create message

    const msg = await Messages.createMessage({
      conversationId: conversation._id,
      customerId,
      attachments,
      contentType,
      content: message
    });

    await Conversations.updateOne(
      { _id: msg.conversationId },
      {
        $set: {
          // Reopen its conversation if it's closed
          status: CONVERSATION_STATUSES.OPEN,

          // setting conversation's content to last message
          content: conversationContent,

          // Mark as unread
          readUserIds: [],

          customerId,

          // clear visitorId
          visitorId: ''
        }
      }
    );

    // mark customer as active
    await Customers.markCustomerAsActive(conversation.customerId);

    graphqlPubsub.publish('conversationClientMessageInserted', {
      conversationClientMessageInserted: msg
    });

    graphqlPubsub.publish('conversationMessageInserted', {
      conversationMessageInserted: msg
    });

    // bot message ================
    if (
      HAS_BOTENDPOINT_URL &&
      !botShowInitialMessage &&
      conversation.operatorStatus === CONVERSATION_OPERATOR_STATUS.BOT
    ) {
      graphqlPubsub.publish('conversationBotTypingStatus', {
        conversationBotTypingStatus: {
          conversationId: msg.conversationId,
          typing: true
        }
      });

      try {
        const botRequest = await sendRequest({
          method: 'POST',
          url: `${botEndpointUrl}/${conversation._id}`,
          body: {
            type: 'text',
            text: message
          }
        });

        const { responses } = botRequest;

        const botData =
          responses.length !== 0
            ? responses
            : [
                {
                  type: 'text',
                  text: AUTO_BOT_MESSAGES.NO_RESPONSE
                }
              ];

        const botMessage = await Messages.createMessage({
          conversationId: conversation._id,
          customerId,
          contentType,
          botData
        });

        graphqlPubsub.publish('conversationBotTypingStatus', {
          conversationBotTypingStatus: {
            conversationId: msg.conversationId,
            typing: false
          }
        });

        graphqlPubsub.publish('conversationMessageInserted', {
          conversationMessageInserted: botMessage
        });
      } catch (e) {
        debugError(`Failed to connect to BOTPRESS: ${e.message}`);
      }
    }

    const customerLastStatus = await get(
      `customer_last_status_${customerId}`,
      'left'
    );

    if (customerLastStatus === 'left' && customerId) {
      set(`customer_last_status_${customerId}`, 'joined');

      // customer has joined + time
      const conversationMessages = await Conversations.changeCustomerStatus(
        'joined',
        customerId,
        conversation.integrationId
      );

      for (const mg of conversationMessages) {
        graphqlPubsub.publish('conversationMessageInserted', {
          conversationMessageInserted: mg
        });
      }

      // notify as connected
      graphqlPubsub.publish('customerConnectionChanged', {
        customerConnectionChanged: {
          _id: customerId,
          status: 'connected'
        }
      });
    }

    if (!HAS_BOTENDPOINT_URL && customerId) {
      try {
        sendMobileNotification({
          title: 'You have a new message',
          body: conversationContent,
          customerId,
          conversationId: conversation._id,
          receivers: conversationNotifReceivers(conversation, customerId)
        });
      } catch (e) {
        debugError(`Failed to send mobile notification: ${e.message}`);
      }
    }

    await sendToWebhook('create', 'customerMessages', msg);

    return msg;
  },

  /*
   * Mark given conversation's messages as read
   */
  async widgetsReadConversationMessages(
    _root,
    args: { conversationId: string }
  ) {
    await Messages.updateMany(
      {
        conversationId: args.conversationId,
        userId: { $exists: true },
        isCustomerRead: { $ne: true }
      },
      { isCustomerRead: true },
      { multi: true }
    );

    return args.conversationId;
  },

  async widgetsSaveCustomerGetNotified(_root, args: IVisitorContactInfoParams) {
    const { visitorId, customerId } = args;

    if (visitorId && !customerId) {
      const customer = await createVisitor(visitorId);
      args.customerId = customer._id;

      await Messages.updateVisitorEngageMessages(visitorId, customer._id);
      await Conversations.updateMany(
        {
          visitorId
        },
        { $set: { customerId: customer._id, visitorId: '' } }
      );
    }

    return Customers.saveVisitorContactInfo(args);
  },

  /*
   * Update customer location field
   */
  async widgetsSaveBrowserInfo(
    _root,
    {
      visitorId,
      customerId,
      browserInfo
    }: { visitorId?: string; customerId?: string; browserInfo: IBrowserInfo }
  ) {
    // update location

    if (customerId) {
      const customer = await Customers.updateLocation(customerId, browserInfo);
      await Customers.updateSession(customer._id);
    }

    if (visitorId) {
      sendToLog('visitor:updateEntry', { visitorId, location: browserInfo });
    }

    try {
      await trackViewPageEvent({
        visitorId,
        customerId,
        attributes: { url: browserInfo.url }
      });
    } catch (e) {
      /* istanbul ignore next */
      debugError(
        `Error occurred during widgets save browser info ${e.message}`
      );
    }

    return null;
  },

  widgetsSendTypingInfo(
    _root,
    args: { conversationId: string; text?: string }
  ) {
    graphqlPubsub.publish('conversationClientTypingStatusChanged', {
      conversationClientTypingStatusChanged: args
    });

    return 'ok';
  },

  async widgetsSendEmail(_root, args: IWidgetEmailParams) {
    const { toEmails, fromEmail, title, content, customerId, formId } = args;

    const attachments = args.attachments || [];

    // do not use Customers.getCustomer() because it throws error if not found
    const customer = await Customers.findOne({ _id: customerId });
    const form = await Forms.getForm(formId || '');

    let finalContent = content;

    if (customer && form) {
      const replacedContent = await new EditorAttributeUtil().replaceAttributes(
        {
          content,
          customer,
          user: await Users.getUser(form.createdUserId)
        }
      );

      finalContent = replacedContent || '';
    }

    let mailAttachment: any = [];

    if (attachments.length > 0) {
      mailAttachment = attachments.map(file => {
        return {
          filename: file.name || '',
          path: file.url || ''
        };
      });
    }

    await sendEmail({
      toEmails,
      fromEmail,
      title,
      template: { data: { content: finalContent } },
      attachments: mailAttachment
    });
  },

  async widgetBotRequest(
    _root,
    {
      integrationId,
      conversationId,
      customerId,
      visitorId,
      message,
      payload,
      type
    }: {
      conversationId?: string;
      customerId?: string;
      visitorId?: string;
      integrationId: string;
      message: string;
      payload: string;
      type: string;
    }
  ) {
    const integration =
      (await getDocument('integrations', {
        _id: integrationId
      })) || {};

    const { botEndpointUrl } = integration.messengerData;

    if (visitorId && !customerId) {
      const customer = await createVisitor(visitorId);
      customerId = customer._id;
    }

    let sessionId = conversationId;

    if (!conversationId) {
      sessionId = await get(`bot_initial_message_session_id_${integrationId}`);

      const conversation = await Conversations.createConversation({
        customerId,
        integrationId,
        operatorStatus: CONVERSATION_OPERATOR_STATUS.BOT,
        status: CONVERSATION_STATUSES.CLOSED
      });

      conversationId = conversation._id;

      const initialMessageBotData = await get(
        `bot_initial_message_${integrationId}`
      );

      await Messages.createMessage({
        conversationId: conversation._id,
        customerId,
        botData: JSON.parse(initialMessageBotData || '{}')
      });
    }

    // create customer message
    const msg = await Messages.createMessage({
      conversationId,
      customerId,
      content: message
    });

    graphqlPubsub.publish('conversationMessageInserted', {
      conversationMessageInserted: msg
    });

    let botMessage;
    let botData;

    if (type !== BOT_MESSAGE_TYPES.SAY_SOMETHING) {
      const botRequest = await sendRequest({
        method: 'POST',
        url: `${botEndpointUrl}/${sessionId}`,
        body: {
          type: 'text',
          text: payload
        }
      });

      const { responses } = botRequest;

      botData =
        responses.length !== 0
          ? responses
          : [
              {
                type: 'text',
                text: AUTO_BOT_MESSAGES.NO_RESPONSE
              }
            ];
    } else {
      botData = [
        {
          type: 'text',
          text: payload
        }
      ];
    }

    // create bot message
    botMessage = await Messages.createMessage({
      conversationId,
      customerId,
      botData
    });

    graphqlPubsub.publish('conversationMessageInserted', {
      conversationMessageInserted: botMessage
    });

    return botMessage;
  },

  async widgetGetBotInitialMessage(
    _root,
    { integrationId }: { integrationId: string }
  ) {
    const sessionId = `_${Math.random()
      .toString(36)
      .substr(2, 9)}`;

    await set(`bot_initial_message_session_id_${integrationId}`, sessionId);

    const integration =
      (await getDocument('integrations', {
        _id: integrationId
      })) || {};

    const { botEndpointUrl } = integration.messengerData;

    const botRequest = await sendRequest({
      method: 'POST',
      url: `${botEndpointUrl}/${sessionId}`,
      body: {
        type: 'text',
        text: 'getStarted'
      }
    });

    await set(
      `bot_initial_message_${integrationId}`,
      JSON.stringify(botRequest.responses)
    );

    return { botData: botRequest.responses };
  },
  // Find integration
  async widgetsBookingConnect(_root, { _id }: { _id: string }) {
    const integration = await Integrations.getIntegration({
      _id,
      isActive: true
    });

    await Integrations.increaseBookingViewCount(_id);

    return integration;
  },

  // create new booking conversation using form data
  async widgetsSaveBooking(
    _root,
    args: {
      integrationId: string;
      formId: string;
      submissions: ISubmission[];
      browserInfo: any;
      cachedCustomerId?: string;
      productId: string;
    }
  ) {
    const { submissions, productId } = args;

    const product = await Products.getProduct({ _id: productId });

    return createFormConversation(
      args,
      () => {
        return `<p>submitted a new booking for <strong><a href="/settings/product-service/details/${productId}">${product?.name}</a> ${product?.code}</strong></p>`;
      },
      () => {
        return {
          conversation: {
            bookingProductId: product._id
          },
          message: {
            bookingWidgetData: {
              formWidgetData: submissions,
              productId,
              content: product.name
            }
          }
        };
      },
      'booking'
    );
  }
};

export default widgetMutations;