linagora/openpaas-esn

View on GitHub
backend/core/collaboration/member/index.js

Summary

Maintainability
F
1 wk
Test Coverage
'use strict';

const _ = require('lodash');
const async = require('async');
const localpubsub = require('../../pubsub').local;
const globalpubsub = require('../../pubsub').global;
const tupleModule = require('../../tuple');
const memberResolver = require('./resolver');

const CONSTANTS = require('../constants');
const MEMBERSHIP_TYPE_REQUEST = CONSTANTS.MEMBERSHIP_TYPES.request;
const MEMBERSHIP_TYPE_INVITATION = CONSTANTS.MEMBERSHIP_TYPES.invitation;
const WORKFLOW_NOTIFICATIONS_TOPIC = CONSTANTS.WORKFLOW_NOTIFICATIONS_TOPIC;

module.exports = function(collaborationModule) {
  return {
    addMember,
    addMembers,
    addMembershipRequest,
    cancelMembershipInvitation,
    cancelMembershipRequest,
    cleanMembershipRequest,
    countMembers,
    declineMembershipInvitation,
    fetchMember,
    getManagers,
    getMembers,
    getMemberAndMembershipRequestIds,
    getMembershipRequest,
    getMembershipRequests,
    isIndirectMember,
    isManager,
    isMember,
    join,
    leave,
    refuseMembershipRequest,
    removeMembers,
    supportsMemberShipRequests,
    WORKFLOW_NOTIFICATIONS_TOPIC,
    MEMBERSHIP_TYPE_REQUEST,
    MEMBERSHIP_TYPE_INVITATION
  };

  function addMember(collaboration, member, callback) {
    addMembers(collaboration, [member], callback);
  }

  function addMembers(collaboration, members, callback) {
    if (!collaboration || !members) {
      return callback(new Error('Collaboration and members are required'));
    }

    if (!collaboration.save) {
      return callback(new Error('addMembers(): first argument (collaboration) must be a collaboration mongoose model'));
    }

    const verifiedMembers = _.uniqWith(members, _.isEqual)
      .filter(member => !isMember(member))
      .map(verifyMember);

    if (!verifiedMembers.length) {
      // return the collaboration with 0 as collaboration document remains not updated
      return callback(null, collaboration, 0);
    }

    const verificationError = verifiedMembers.find(member => member instanceof Error);

    if (verificationError) {
      return callback(verificationError);
    }

    _.each(verifiedMembers, member => {
      collaboration.members.push({
        member,
        status: CONSTANTS.STATUS.joined
      });
    });

    collaboration.save((err, updated) => {
      if (err) return callback(err);

      // Pass 1 as number of updated collaboration.
      // Mongoose 5 no longer supports numAffected argument.
      callback(null, updated, 1);
    });

    function verifyMember(member) {
      if (!member.id || !member.objectType) {
        return new Error('member must be a tuple{id, objectType}');
      }

      if (!tupleModule[member.objectType]) {
        return new Error(`${member.objectType} is not a supported tuple`);
      }

      try {
        member = tupleModule.get(member.objectType, member.id);
      } catch (error) {
        return new Error(`Invalid tuple id: ${error.message}`);
      }

      return member;
    }

    function isMember(member) {
      return collaboration.members.find(ele => ((
        ele.member.objectType === member.objectType &&
        ele.member.id.equals ? ele.member.id.equals(member.id) : ele.member.id === member.id)
      ));
    }
  }

  function addMembershipRequest(objectType, collaboration, userAuthor, userTarget, workflow, actor, callback) {
    if (!userAuthor) {
      return callback(new Error('Author user object is required'));
    }

    if (!userTarget) {
      return callback(new Error('Target user object is required'));
    }

    if (!collaboration) {
      return callback(new Error('Collaboration object is required'));
    }

    const userAuthorId = userAuthor._id || userAuthor;
    const userTargetId = userTarget._id || userTarget;
    const topic = WORKFLOW_NOTIFICATIONS_TOPIC[workflow];

    if (!workflow) {
      return callback(new Error('Workflow string is required'));
    }

    if (!topic) {
      return callback(new Error('Invalid workflow, must be in ' + _.keys(WORKFLOW_NOTIFICATIONS_TOPIC)));
    }

    if (workflow !== 'invitation' && !supportsMemberShipRequests(collaboration)) {
      return callback(new Error('Only Restricted and Private collaborations allow membership requests.'));
    }

    isMember(collaboration, {objectType: 'user', id: userTargetId}, (err, isMember) => {
      if (err) {
        return callback(err);
      }

      if (isMember) {
        return callback(new Error('User is already member of the collaboration.'));
      }

      const previousRequests = collaboration.membershipRequests.filter(request => {
        const requestUserId = request.user._id || request.user;

        return requestUserId.equals(userTargetId);
      });

      if (previousRequests.length > 0) {
        return callback(null, collaboration);
      }

      collaboration.membershipRequests.push({user: userTargetId, workflow: workflow});

      collaboration.save((err, collaborationSaved) => {
        if (err) {
          return callback(err);
        }

        localpubsub.topic(topic).forward(globalpubsub, {
          author: userAuthorId,
          target: userTargetId,
          collaboration: {objectType: objectType, id: collaboration._id},
          workflow: workflow,
          actor: actor || 'user'
        });

        callback(null, collaborationSaved);
      });
    });
  }

  function cancelMembershipInvitation(objectType, collaboration, membership, manager, callback) {
    cleanMembershipRequest(collaboration, membership.user, err => {
      if (err) {
        return callback(err);
      }

      localpubsub.topic('collaboration:membership:invitation:cancel').forward(globalpubsub, {
        author: manager._id,
        target: membership.user,
        membership: membership,
        collaboration: {objectType: objectType, id: collaboration._id}
      });

      callback(err, collaboration);
    });
  }

  function cancelMembershipRequest(objectType, collaboration, membership, user, callback) {
    cleanMembershipRequest(collaboration, membership.user, err => {
      if (err) {
        return callback(err);
      }

      localpubsub.topic('collaboration:membership:request:cancel').forward(globalpubsub, {
        author: user._id,
        target: collaboration._id,
        membership: membership,
        collaboration: {objectType: objectType, id: collaboration._id}
      });

      callback(err, collaboration);
    });
  }

  function cleanMembershipRequest(collaboration, user, callback) {
    if (!user) {
      return callback(new Error('User author object is required'));
    }

    if (!collaboration) {
      return callback(new Error('Collaboration object is required'));
    }

    const userId = user._id || user;
    const otherUserRequests = Array.isArray(collaboration.membershipRequests) && collaboration.membershipRequests.filter(request => {
      const requestUserId = request.user._id || request.user;

      return !requestUserId.equals(userId);
    });

    collaboration.membershipRequests = otherUserRequests;
    collaboration.save(callback);
  }

  function countMembers(objectType, id, callback) {
    const Model = collaborationModule.getModel(objectType);

    if (!Model) {
      return callback(new Error(`Collaboration model ${objectType} is unknown`));
    }

    return Model.aggregate([
      {$match: {_id: id}},
      {$unwind: '$members'},
      {$group: {_id: null, number: {$sum: 1 }}}
    ]).exec((err, result) => {
      if (err) {
        return callback(err);
      }

      callback(null, result && result.length ? result[0].number : 0);
    });
   }

  function declineMembershipInvitation(objectType, collaboration, membership, user, callback) {
    cleanMembershipRequest(collaboration, membership.user, err => {
      if (err) {
        return callback(err);
      }

      localpubsub.topic('collaboration:membership:invitation:decline').forward(globalpubsub, {
        author: user._id,
        target: collaboration._id,
        membership: membership,
        collaboration: {objectType: objectType, id: collaboration._id}
      });

      callback(err, collaboration);
    });
  }

  function fetchMember(tuple, callback) {
    if (!tuple) {
      return callback(new Error('Member tuple is required'));
    }

    memberResolver.resolve(tuple)
      .then(
        member => callback(null, member),
        err => callback(err)
      );
  }

  function getManagers(objectType, collaboration, callback) {
    const id = collaboration._id || collaboration;
    const Model = collaborationModule.getModel(objectType);

    if (!Model) {
      return callback(new Error('Collaboration model ' + objectType + ' is unknown'));
    }

    // TODO Right now creator is the only manager. It will change in the future.
    Model.findById(id).populate('creator').exec((err, collaboration) => {
      // there is no creator on the "general" channel in chat :-(
      const response = collaboration && collaboration.creator ? [collaboration.creator] : [];

      callback(err, response);
    });
  }

  function getMembers(collaboration, objectType, query = {}, callback) {
    const id = collaboration._id || collaboration;
    const Model = collaborationModule.getModel(objectType);

    if (!Model) {
      return callback(new Error(`Collaboration model ${collaboration.objectType} is unknown`));
    }

    Model.findById(id, (err, collaboration) => {
      if (err) {
        return callback(err);
      }

      // TODO: We should also handle the cases when the collaboration is not found or when there are no members in it.
      // But our code may have already adapted to this missing logic, so we need to check everywhere to make sure it's
      // OK and does not break anything when we add the logic below:
      // if (!collaboration || !Array.isArray(collaboration.members) || !collaboration.members.length) {
      //   return callback(null, []);
      // }

      const offset = Number.isInteger(query.offset) && query.offset >= 0 ? query.offset : CONSTANTS.DEFAULT_OFFSET;
      const limit = Number.isInteger(query.limit) && query.limit >= 0 ? query.limit : CONSTANTS.DEFAULT_LIMIT;
      let members = collaboration.members;

      // TODO: This whole part of logic is missing tests. We should write tests for them.
      if (query.objectTypeFilter) {
        let operation;

        if (query.objectTypeFilter[0] === '!') {
          const objectTypeFilter = query.objectTypeFilter.substr(1);

          operation = function(m) { return m.member.objectType !== objectTypeFilter; };
        } else {
          operation = function(m) { return m.member.objectType === query.objectTypeFilter; };
        }

        members = members.filter(operation);
      }

      if (query.idFilter) {
        members = members.filter(m => String(m.member.id) === String(query.idFilter));
      }

      const total_count = members.length;

      members = limit === 0 ? members.slice(offset) : members.slice(offset, offset + limit);

      async.map(members, function(member, callback) {
        return fetchMember(member.member, function(err, loaded) {
          // TODO: We should be able to check if there's an error while calling fetchMember and handle it properly.
          // But our code may have already adapted to this missing logic, so we need to check everywhere to make sure it's
          // OK and does not break anything to add the missing logic.

          member = member.toObject();
          member.objectType = member.member.objectType;
          member.id = member.member.id;

          if (loaded) {
            member.member = loaded;
          }

          return callback(null, member);
        });
      }, (err, members) => {
        members.total_count = total_count;
        callback(err, members);
      });
    });
  }

  function getMembershipRequest(collaboration, user) {
    if (!collaboration.membershipRequests) {
      return false;
    }

    const mr = collaboration.membershipRequests.filter(mr => mr.user.equals(user._id));

    return mr.pop();
  }

  function getMembershipRequests(objectType, objetId, query = {}, callback) {
    const Model = collaborationModule.getModel(objectType);

    if (!Model) {
      return callback(new Error(`Collaboration model ${objectType} is unknown`));
    }

    const q = Model.findById(objetId);

    q.slice('membershipRequests', [query.offset || CONSTANTS.DEFAULT_OFFSET, query.limit || CONSTANTS.DEFAULT_LIMIT]);
    q.populate('membershipRequests.user');
    q.exec(function(err, collaboration) {
      if (err) {
        return callback(err);
      }

      callback(null, collaboration ? collaboration.membershipRequests : []);
    });
  }

  function isIndirectMember(collaboration, tuple, callback) {
    if (!collaboration || !collaboration._id) {
      return callback(new Error('Collaboration object is required'));
    }

    function isInnerMember(members, tupleToFind, callback) {
      async.some(members, (tuple, callback) => {
        const member = tuple.member;

        if (!collaborationModule.isCollaboration(member)) {
          return callback(null, String(member.id) === String(tupleToFind.id) && member.objectType === tupleToFind.objectType);
        }

        collaborationModule.queryOne(member.objectType, {_id: member.id}, (err, collaboration) => {
          if (err) {
            return callback(null, false);
          }

          isInnerMember(collaboration.members, tupleToFind, callback);
        });
      }, callback);
    }

    return isInnerMember(collaboration.members, tuple, callback);
  }

  function isManager(objectType, collaboration, user, callback) {
    const id = collaboration._id || collaboration;
    const user_id = user._id || user;
    const Model = collaborationModule.getModel(objectType);

    if (!Model) {
      return callback(new Error(`Collaboration model ${objectType} is unknown`));
    }

    Model.findOne({_id: id, creator: user_id}, (err, result) => callback(err, !!result));
  }

  function isMember(collaboration, tuple, callback) {
    if (!collaboration || !collaboration._id) {
      return callback(new Error('Collaboration object is required'));
    }

    const isInMembersArray = collaboration.members.some(m => m.member.objectType === tuple.objectType && String(m.member.id) === String(tuple.id));

    callback(null, isInMembersArray);
  }

  function join(objectType, collaboration, userAuthor, userTarget, actor, callback) {
    const id = collaboration._id || collaboration;
    const userAuthor_id = userAuthor._id || userAuthor;
    const userTarget_id = userTarget._id || userTarget;
    const member = {
      objectType: 'user',
      id: userTarget_id
    };

    addMember(collaboration, member, (err, updated, numAffected) => {
      if (err) {
        return callback(err);
      }

      if (numAffected) {
        localpubsub.topic('collaboration:join').forward(globalpubsub, {
          author: userAuthor_id,
          target: userTarget_id,
          actor: actor || 'user',
          collaboration: {objectType: objectType, id: id}
        });
      }

      callback(null, updated);
    });
  }

  function leave(objectType, collaboration, userAuthor, userTarget, callback) {
    const userAuthor_id = String(userAuthor._id || userAuthor);
    const userTarget_id = String(userTarget._id || userTarget);
    const member = { objectType: 'user', id: userTarget_id };

    collaboration = {
      id: collaboration._id || collaboration,
      objectType
    };

    removeMembers(collaboration, [member], (err, updated) => {
      if (err) {
        return callback(err);
      }

      localpubsub.topic('collaboration:leave').forward(globalpubsub, {
        author: userAuthor_id,
        target: userTarget_id,
        collaboration
      });

      callback(null, updated);
    });
  }

  function refuseMembershipRequest(objectType, collaboration, membership, manager, callback) {
    cleanMembershipRequest(collaboration, membership.user, err => {
      if (err) {
        return callback(err);
      }

      localpubsub.topic('collaboration:membership:request:refuse').forward(globalpubsub, {
        author: manager._id,
        target: membership.user,
        membership: membership,
        collaboration: {objectType: objectType, id: collaboration._id}
      });

      callback(err, collaboration);
    });
  }

  function removeMembers(collaboration, members, callback) {
    if (!collaboration || !Array.isArray(members)) {
      return callback(new Error('Collaboration and members are required'));
    }

    if (members.length === 0) {
      return callback();
    }

    members = members.map(member => {
      try {
        return tupleModule.get(member.objectType, member.id);
      } catch (err) {
        return null;
      }
    });

    if (members.some(member => !member)) {
      return callback(new Error('Some members are invalid or unsupported tuples'));
    }

    const Model = collaborationModule.getModel(collaboration.objectType);
    const selections = members.map(member => ({
      'member.id': member.id, 'member.objectType': member.objectType
    }));

    Model.updateOne(
      { _id: collaboration.id },
      {
        $pull: {
          members: { $or: selections }
        }
      },
      callback
    );
  }

  function supportsMemberShipRequests(collaboration) {
    if (!collaboration || !collaboration.type) {
      return false;
    }

    return collaboration.type === CONSTANTS.COLLABORATION_TYPES.RESTRICTED || collaboration.type === CONSTANTS.COLLABORATION_TYPES.PRIVATE;
  }

/**
 * Get IDs of members and membership requests from a collaboration
 *
 * @param {object} collaboration detailed information of collaboration
 */

  function getMemberAndMembershipRequestIds(collaboration) {
    const results = [];

    collaboration.members.forEach(item => {
      results.push(item.member.id);
    });

    collaboration.membershipRequests.forEach(member => {
      results.push(member.user);
    });

    return results;
  }
};