api/src/db/models/Fields.ts

Summary

Maintainability
F
1 wk
Test Coverage
/*
 * Extra fields for form, customer, company
 */
import { Model, model } from 'mongoose';
import * as validator from 'validator';
import { Customers, Forms } from '.';
import {
  COMPANY_INFO,
  CONVERSATION_INFO,
  CUSTOMER_BASIC_INFO,
  DEVICE_PROPERTIES_INFO,
  FIELD_CONTENT_TYPES,
  PRODUCT_INFO,
  PROPERTY_GROUPS,
  USER_PROPERTIES_INFO
} from '../../data/constants';
import { updateOrder } from './boardUtils';
import { ICoordinates } from './definitions/common';
import { FIELDS_GROUPS_CONTENT_TYPES } from './definitions/constants';
import {
  fieldGroupSchema,
  fieldSchema,
  IField,
  IFieldDocument,
  IFieldGroup,
  IFieldGroupDocument
} from './definitions/fields';

export interface IOrderInput {
  _id: string;
  order: number;
}

export interface ITypedListItem {
  field: string;
  value: any;
  stringValue?: string;
  numberValue?: number;
  dateValue?: Date;
  locationValue?: ICoordinates;
}

export const isValidDate = value => {
  if (
    (value && validator.isISO8601(value.toString())) ||
    /\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+([+-][0-2]\d:[0-5]\d|Z)/.test(
      value.toString()
    ) ||
    value instanceof Date
  ) {
    return true;
  }

  return false;
};

export interface IFieldModel extends Model<IFieldDocument> {
  checkCodeDuplication(code: string): string;
  checkIsDefinedByErxes(_id: string): never;
  createField(doc: IField): Promise<IFieldDocument>;
  updateField(_id: string, doc: IField): Promise<IFieldDocument>;
  removeField(_id: string): void;
  updateOrder(orders: IOrderInput[]): Promise<IFieldDocument[]>;
  clean(_id: string, _value: string | Date | number): string | Date | number;
  cleanMulti(data: { [key: string]: any }): any;
  generateTypedListFromMap(data: { [key: string]: any }): ITypedListItem[];
  generateTypedItem(
    field: string,
    value: string,
    type: string,
    validation?: string
  ): ITypedListItem;
  prepareCustomFieldsData(
    customFieldsData?: Array<{ field: string; value: any }>
  ): Promise<ITypedListItem[]>;
  updateFieldsVisible(
    _id: string,
    lastUpdatedUserId: string,
    isVisible?: boolean,
    isVisibleInDetail?: boolean
  ): Promise<IFieldDocument>;
  createSystemFields(
    groupId: string,
    contentType: string
  ): Promise<IFieldDocument[]>;
  generateCustomFieldsData(
    data: {
      [key: string]: any;
    },
    contentType: string
  ): Promise<any>;
}

export const loadFieldClass = () => {
  class Field {
    static async checkCodeDuplication(code: string) {
      const group = await Fields.findOne({
        code
      });

      if (group) {
        throw new Error('Code must be unique');
      }
    }
    /*
     * Check if Group is defined by erxes by default
     */
    public static async checkIsDefinedByErxes(_id: string) {
      const fieldObj = await Fields.findOne({ _id });

      // Checking if the field is defined by the erxes
      if (fieldObj && fieldObj.isDefinedByErxes) {
        throw new Error('Cant update this field');
      }
    }

    public static async checkCanToggleVisible(_id: string) {
      const fieldObj = await Fields.findOne({ _id });

      // Checking if the field is defined by the erxes
      if (fieldObj && !fieldObj.canHide) {
        throw new Error('Cant update this field');
      }
    }

    /*
     * Create new field
     */
    public static async createField({
      contentType,
      contentTypeId,
      groupId,
      groupName,
      ...fields
    }: IField) {
      if (fields.code) {
        await this.checkCodeDuplication(fields.code || '');
      }

      const query: { [key: string]: any } = { contentType };

      if (groupId) {
        query.groupId = groupId;
      }

      if (contentTypeId) {
        query.contentTypeId = contentTypeId;
      }

      // form checks
      if (contentType === FIELD_CONTENT_TYPES.FORM) {
        if (!contentTypeId) {
          throw new Error('Content type id is required');
        }

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

        if (!form) {
          throw new Error(`Form not found with _id of ${contentTypeId}`);
        }

        if (groupName) {
          let group = await FieldsGroups.findOne({ name: groupName });

          if (!group) {
            group = await FieldsGroups.createGroup({
              name: groupName,
              contentType: 'form',
              isDefinedByErxes: false
            });
          }
          groupId = group._id;
        }
      }

      // Generate order
      // if there is no field then start with 0
      let order = 0;

      const lastField = await Fields.findOne(query).sort({ order: -1 });

      if (lastField) {
        order = (lastField.order || 0) + 1;
      }

      return Fields.create({
        contentType,
        contentTypeId,
        order,
        groupId,
        isDefinedByErxes: false,
        ...fields
      });
    }

    /*
     * Update field
     */
    public static async updateField(_id: string, doc: IField) {
      await this.checkIsDefinedByErxes(_id);
      const { groupName } = doc;

      if (groupName) {
        let group = await FieldsGroups.findOne({ name: groupName });

        if (!group) {
          group = await FieldsGroups.createGroup({
            name: groupName,
            contentType: 'form',
            isDefinedByErxes: false
          });
        }

        doc.groupId = group._id;
      }

      const field = await Fields.findOne({ _id });

      if (doc.code && field && field.code !== doc.code) {
        await this.checkCodeDuplication(doc.code);
      }

      await Fields.updateOne({ _id }, { $set: doc });

      return Fields.findOne({ _id });
    }

    /*
     * Remove field
     */
    public static async removeField(_id: string) {
      const fieldObj = await Fields.findOne({ _id });

      if (!fieldObj) {
        throw new Error(`Field not found with id ${_id}`);
      }

      await this.checkIsDefinedByErxes(_id);

      // Removing field value from customer
      await Customers.updateMany(
        { 'customFieldsData.field': _id },
        { $pull: { customFieldsData: { field: _id } } }
      );

      // Removing form associated field
      await Fields.updateMany(
        { associatedFieldId: _id },
        { $unset: { associatedFieldId: '' } }
      );

      return fieldObj.remove();
    }

    /*
     * Update given fields orders
     */
    public static async updateOrder(orders: IOrderInput[]) {
      return updateOrder(Fields, orders);
    }

    /*
     * Validate per field according to it's validation and type
     * fixes values if necessary
     */
    public static async clean(_id: string, _value: string | Date | number) {
      const field = await Fields.findOne({ _id });

      let value = _value;

      if (!field) {
        throw new Error(`Field not found with the _id of ${_id}`);
      }

      const { type, validation } = field;

      // throw error helper
      const throwError = message => {
        throw new Error(`${field.text}: ${message}`);
      };

      // required
      if (field.isRequired && (!value || !value.toString().trim())) {
        throwError('required');
      }

      if (value) {
        // email
        if (
          (type === 'email' || validation === 'email') &&
          !validator.isEmail(value)
        ) {
          throwError('Invalid email');
        }

        // number
        if (
          !['check', 'radio', 'select'].includes(type || '') &&
          validation === 'number' &&
          !validator.isFloat(value.toString())
        ) {
          throwError('Invalid number');
        }

        // date
        if (validation === 'date') {
          if (!isValidDate(value)) {
            throwError('Invalid date');
          }

          value = new Date(value);
        }
      }

      return value;
    }

    /*
     * Validates multiple fields, fixes values if necessary
     */
    public static async cleanMulti(data: { [key: string]: any }) {
      const ids = Object.keys(data);
      const fixedValues = {};

      // validate individual fields
      for (const _id of ids) {
        fixedValues[_id] = await this.clean(_id, data[_id]);
      }

      return fixedValues;
    }

    public static generateTypedItem(
      field: string,
      value: string | number | string[] | number[] | ICoordinates,
      type: string,
      validation?: string
    ): ITypedListItem {
      let stringValue;
      let numberValue;
      let dateValue;
      let locationValue;

      if (value) {
        stringValue = value.toString();

        // string
        if (type === 'input' && !validation) {
          numberValue = null;
          value = stringValue;
          return { field, value, stringValue, numberValue, dateValue };
        }

        // number
        if (type !== 'check' && validator.isFloat(value.toString())) {
          numberValue = value;
          stringValue = null;
          value = Number(value);
        }

        if (isValidDate(value)) {
          dateValue = value;
          stringValue = null;
        }

        if (type === 'map') {
          const { lat, lng } = value as ICoordinates;

          stringValue = `${lng},${lat}`;
          locationValue = { type: 'Point', coordinates: [lng, lat] };
          return { field, value, stringValue, locationValue };
        }
      }
      return {
        field,
        value,
        stringValue,
        numberValue,
        dateValue,
        locationValue
      };
    }

    public static generateTypedListFromMap(data: {
      [key: string]: any;
    }): ITypedListItem[] {
      const ids = Object.keys(data || {});
      return ids.map(_id => this.generateTypedItem(_id, data[_id], ''));
    }

    public static async prepareCustomFieldsData(
      customFieldsData?: Array<{ field: string; value: any }>
    ): Promise<ITypedListItem[]> {
      const result: ITypedListItem[] = [];

      for (const customFieldData of customFieldsData || []) {
        const field = await Fields.findById(customFieldData.field);

        if (!field) {
          continue;
        }

        try {
          await Fields.clean(customFieldData.field, customFieldData.value);
        } catch (e) {
          throw new Error(e.message);
        }
        result.push(
          Fields.generateTypedItem(
            customFieldData.field,
            customFieldData.value,
            field ? field.type || '' : '',
            field?.validation
          )
        );
      }

      return result;
    }

    /**
     * Update single field's visible
     */
    public static async updateFieldsVisible(
      _id: string,
      lastUpdatedUserId: string,
      isVisible?: boolean,
      isVisibleInDetail?: boolean
    ) {
      await this.checkCanToggleVisible(_id);

      // Updating visible
      const set =
        isVisible !== undefined
          ? { isVisible, lastUpdatedUserId }
          : { isVisibleInDetail, lastUpdatedUserId };

      await Fields.updateOne({ _id }, { $set: set });

      return Fields.findOne({ _id });
    }

    public static async createSystemFields(
      groupId: string,
      contentType: string
    ) {
      switch (contentType) {
        case FIELDS_GROUPS_CONTENT_TYPES.CUSTOMER:
          const customerFields = CUSTOMER_BASIC_INFO.ALL.map(e => ({
            text: e.label,
            type: e.field,
            canHide: e.canHide,
            validation: e.validation,
            groupId,
            contentType,
            isDefinedByErxes: true
          }));
          await Fields.insertMany(customerFields);
          break;
        case FIELDS_GROUPS_CONTENT_TYPES.COMPANY:
          const companyFields = COMPANY_INFO.ALL.map(e => ({
            text: e.label,
            type: e.field,
            canHide: e.canHide,
            validation: e.validation,
            groupId,
            contentType,
            isDefinedByErxes: true
          }));
          await Fields.insertMany(companyFields);
          break;
        case FIELDS_GROUPS_CONTENT_TYPES.PRODUCT:
          const productFields = PRODUCT_INFO.ALL.map(e => ({
            text: e.label,
            type: e.field,
            groupId,
            contentType,
            canHide: false,
            isDefinedByErxes: true
          }));
          await Fields.insertMany(productFields);
          break;
        case FIELDS_GROUPS_CONTENT_TYPES.CONVERSATION:
          const conversationFields = CONVERSATION_INFO.ALL.map(e => ({
            text: e.label,
            type: e.field,
            groupId,
            contentType,
            isDefinedByErxes: true
          }));
          await Fields.insertMany(conversationFields);
          break;
        case FIELDS_GROUPS_CONTENT_TYPES.DEVICE:
          const deviceFields = DEVICE_PROPERTIES_INFO.ALL.map(e => ({
            text: e.label,
            type: e.field,
            groupId,
            contentType,
            isDefinedByErxes: true
          }));
          await Fields.insertMany(deviceFields);
          break;
        case FIELDS_GROUPS_CONTENT_TYPES.USER:
          const userFields = USER_PROPERTIES_INFO.ALL.map(e => ({
            text: e.label,
            type: e.field,
            groupId,
            contentType,
            isDefinedByErxes: true
          }));
          await Fields.insertMany(userFields);
          break;
      }
    }

    public static async generateCustomFieldsData(
      data: { [key: string]: any },
      contentType: string
    ) {
      const keys = Object.keys(data || {});

      let customFieldsData: any = [];

      for (const key of keys) {
        const customField = await Fields.findOne({
          contentType,
          text: key
        });

        let value = data[key];

        if (customField) {
          if (customField.validation === 'date') {
            value = new Date(data[key]);
          }

          customFieldsData.push({
            field: customField._id,
            value
          });

          delete data[key];
        }
      }

      const trackedData = await this.generateTypedListFromMap(data);

      customFieldsData = await this.prepareCustomFieldsData(customFieldsData);

      return { customFieldsData, trackedData };
    }
  }

  fieldSchema.loadClass(Field);

  return fieldSchema;
};

export interface IFieldGroupModel extends Model<IFieldGroupDocument> {
  checkCodeDuplication(code: string): string;
  checkIsDefinedByErxes(_id: string): never;
  createGroup(doc: IFieldGroup): Promise<IFieldGroupDocument>;
  updateGroup(_id: string, doc: IFieldGroup): Promise<IFieldGroupDocument>;
  removeGroup(_id: string): Promise<string>;
  updateOrder(orders: IOrderInput[]): Promise<IFieldGroupDocument[]>;
  updateGroupVisible(
    _id: string,
    lastUpdatedUserId: string,
    isVisible?: boolean,
    isVisibleInDetail?: boolean
  ): Promise<IFieldGroupDocument>;
  createSystemGroupsFields(): Promise<IFieldGroupDocument[]>;
}

export const loadGroupClass = () => {
  class FieldGroup {
    static async checkCodeDuplication(code: string) {
      const group = await FieldsGroups.findOne({
        code
      });

      if (group) {
        throw new Error('Code must be unique');
      }
    }
    /*
     * Check if Group is defined by erxes by default
     */
    public static async checkIsDefinedByErxes(_id: string) {
      const groupObj = await FieldsGroups.findOne({ _id });

      // Checking if the group is defined by the erxes
      if (groupObj && groupObj.isDefinedByErxes) {
        throw new Error('Cant update this group');
      }
    }

    /*
     * Create new field group
     */
    public static async createGroup(doc: IFieldGroup) {
      if (doc.code) {
        await this.checkCodeDuplication(doc.code || '');
      }

      // Newly created group must be visible
      const isVisible = true;

      const { contentType } = doc;

      // Automatically setting order of group to the bottom
      let order = 1;

      const lastGroup = await FieldsGroups.findOne({ contentType }).sort({
        order: -1
      });

      if (lastGroup) {
        order = (lastGroup.order || 0) + 1;
      }

      return FieldsGroups.create({
        ...doc,
        isVisible,
        order,
        isDefinedByErxes: false
      });
    }

    /*
     * Update field group
     */
    public static async updateGroup(_id: string, doc: IFieldGroup) {
      const group = await FieldsGroups.findOne({ _id });

      if (doc.code && group && group.code !== doc.code) {
        await this.checkCodeDuplication(doc.code);
      }
      // Can not edit group that is defined by erxes
      await this.checkIsDefinedByErxes(_id);

      await FieldsGroups.updateOne({ _id }, { $set: doc });

      return FieldsGroups.findOne({ _id });
    }

    /**
     * Remove field group
     */
    public static async removeGroup(_id: string) {
      const groupObj = await FieldsGroups.findOne({ _id });

      if (!groupObj) {
        throw new Error(`Group not found with id of ${_id}`);
      }

      // Can not delete group that is defined by erxes
      await this.checkIsDefinedByErxes(_id);

      // Deleting fields that are associated with this group
      const fields = await Fields.find({ groupId: _id });

      for (const field of fields) {
        await Fields.removeField(field._id);
      }

      await groupObj.remove();

      return _id;
    }

    /**
     * Update field group's visible
     */
    public static async updateGroupVisible(
      _id: string,
      lastUpdatedUserId: string,
      isVisible?: boolean,
      isVisibleInDetail?: boolean
    ) {
      // Can not update group that is defined by erxes
      await this.checkIsDefinedByErxes(_id);

      // Updating visible
      const set =
        isVisible !== undefined
          ? { isVisible, lastUpdatedUserId }
          : { isVisibleInDetail, lastUpdatedUserId };

      await FieldsGroups.updateOne({ _id }, { $set: set });

      return FieldsGroups.findOne({ _id });
    }

    /**
     * Create system fields & groups
     */
    public static async createSystemGroupsFields() {
      for (const group of PROPERTY_GROUPS) {
        if (['ticket', 'task', 'lead', 'visitor'].includes(group.value)) {
          continue;
        }

        for (const subType of group.types) {
          if (subType.value === 'deal') {
            continue;
          }

          const doc = {
            name: 'Basic information',
            contentType: subType.value,
            order: 0,
            isDefinedByErxes: true,
            description: `Basic information of a ${subType.value}`,
            isVisible: true
          };

          const existingGroup = await FieldsGroups.findOne({
            contentType: doc.contentType,
            isDefinedByErxes: true
          });

          if (existingGroup) {
            continue;
          }

          if (['ticket', 'task', 'lead', 'visitor'].includes(doc.contentType)) {
            continue;
          }

          const fieldGroup = await FieldsGroups.create(doc);

          await Fields.createSystemFields(fieldGroup._id, subType.value);
        }
      }
    }

    /*
     * Update given fieldsGroups orders
     */
    public static async updateOrder(orders: IOrderInput[]) {
      return updateOrder(FieldsGroups, orders);
    }
  }

  fieldGroupSchema.loadClass(FieldGroup);

  return fieldGroupSchema;
};

loadFieldClass();
loadGroupClass();

// tslint:disable-next-line
export const FieldsGroups = model<IFieldGroupDocument, IFieldGroupModel>(
  'fields_groups',
  fieldGroupSchema
);

// tslint:disable-next-line
export const Fields = model<IFieldDocument, IFieldModel>('fields', fieldSchema);