oaeproject/Hilary

View on GitHub
packages/oae-content/lib/activity.js

Summary

Maintainability
F
1 wk
Test Coverage
A
97%
/*!
 * Copyright 2014 Apereo Foundation (AF) Licensed under the
 * Educational Community License, Version 2.0 (the "License"); you may
 * not use this file except in compliance with the License. You may
 * obtain a copy of the License at
 *
 *     http://opensource.org/licenses/ECL-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an "AS IS"
 * BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
 * or implied. See the License for the specific language governing
 * permissions and limitations under the License.
 */

import _ from 'underscore';
import { pluck, has } from 'ramda';

import * as ActivityAPI from 'oae-activity/lib/api.js';
import * as ActivityModel from 'oae-activity/lib/model.js';
import * as ActivityUtil from 'oae-activity/lib/util.js';
import * as AuthzUtil from 'oae-authz/lib/util.js';
import * as MessageBoxAPI from 'oae-messagebox';
import * as MessageBoxUtil from 'oae-messagebox/lib/util.js';
import * as PrincipalsUtil from 'oae-principals/lib/util.js';
import * as ContentAPI from 'oae-content';
import { ActivityConstants } from 'oae-activity/lib/constants.js';
import { AuthzConstants } from 'oae-authz/lib/constants.js';
import { ContentConstants } from 'oae-content/lib/constants.js';
import * as Etherpad from './internal/etherpad.js';
import * as ContentUtil from './internal/util.js';
import * as ContentDAO from './internal/dao.js';

const ID = 'id';
const INVITATION = 'invitation';
const hasInvitation = has(INVITATION);

/**
 * Content create
 */
ActivityAPI.registerActivityType(ContentConstants.activity.ACTIVITY_CONTENT_CREATE, {
  groupBy: [{ actor: true, target: true }],
  streams: {
    activity: {
      router: {
        actor: ['self', 'followers'],
        object: ['self', 'members']
      }
    },
    notification: {
      router: {
        object: ['members']
      }
    },
    email: {
      router: {
        object: ['members']
      }
    }
  }
});

/*!
 * Post a content-create activity when a user creates a content item.
 */
ContentAPI.emitter.on(
  ContentConstants.events.CREATED_CONTENT,
  (ctx, content, revision, memberChangeInfo, folderIds) => {
    const millis = Date.now();
    const actorResource = new ActivityModel.ActivitySeedResource('user', ctx.user().id, {
      user: ctx.user()
    });
    const objectResource = new ActivityModel.ActivitySeedResource('content', content.id, {
      content
    });
    let targetResource = null;

    // Get the extra members
    const extraMembers = _.chain(memberChangeInfo.changes)
      .keys()
      .filter((member) => member !== ctx.user().id)
      .value();

    // If we only added 1 extra user or group, we set the target to that entity
    if (_.isEmpty(folderIds) && extraMembers.length === 1) {
      const targetResourceType = PrincipalsUtil.isGroup(extraMembers[0]) ? 'group' : 'user';
      targetResource = new ActivityModel.ActivitySeedResource(targetResourceType, extraMembers[0]);

      // If we added the file to just 1 folder, we set it as a target
    } else if (_.isEmpty(extraMembers) && folderIds.length === 1) {
      targetResource = new ActivityModel.ActivitySeedResource('folder', folderIds[0]);
    }

    // Generate the activity seed and post it to the queue
    const activitySeed = new ActivityModel.ActivitySeed(
      ContentConstants.activity.ACTIVITY_CONTENT_CREATE,
      millis,
      ActivityConstants.verbs.CREATE,
      actorResource,
      objectResource,
      targetResource
    );
    ActivityAPI.postActivity(ctx, activitySeed);
  }
);

/// ///////////////////////////////////////////////
// CONTENT-UPDATE and CONTENT-UPDATE-VISIBILITY //
/// ///////////////////////////////////////////////

ActivityAPI.registerActivityType(ContentConstants.activity.ACTIVITY_CONTENT_UPDATE, {
  groupBy: [{ object: true }],
  streams: {
    activity: {
      router: {
        actor: ['self'],
        object: ['self', 'members']
      }
    },
    notification: {
      router: {
        object: ['managers']
      }
    },
    email: {
      router: {
        object: ['managers']
      }
    }
  }
});

ActivityAPI.registerActivityType(ContentConstants.activity.ACTIVITY_CONTENT_UPDATE_VISIBILITY, {
  streams: {
    activity: {
      router: {
        actor: ['self'],
        object: ['self', 'members']
      }
    },
    notification: {
      router: {
        object: ['managers']
      }
    }
  }
});

/*!
 * Post either a content-update or content-update-visibility activity when a user updates a content item's metadata.
 */
ContentAPI.emitter.on(ContentConstants.events.UPDATED_CONTENT, (ctx, newContent, oldContent, _) => {
  const millis = Date.now();
  const actorResource = new ActivityModel.ActivitySeedResource('user', ctx.user().id, {
    user: ctx.user()
  });
  const objectResource = new ActivityModel.ActivitySeedResource('content', newContent.id, {
    content: newContent
  });

  // We discriminate between general updates and visibility changes. If the visibility has changed, we fire a visibility changed activity *instead* of an update activity
  let activityType = null;
  activityType =
    newContent.visibility === oldContent.visibility
      ? ContentConstants.activity.ACTIVITY_CONTENT_UPDATE
      : ContentConstants.activity.ACTIVITY_CONTENT_UPDATE_VISIBILITY;

  const activitySeed = new ActivityModel.ActivitySeed(
    activityType,
    millis,
    ActivityConstants.verbs.UPDATE,
    actorResource,
    objectResource
  );
  ActivityAPI.postActivity(ctx, activitySeed);
});

/// ///////////////////
// CONTENT-REVISION //
/// ///////////////////

ActivityAPI.registerActivityType(ContentConstants.activity.ACTIVITY_CONTENT_REVISION, {
  groupBy: [{ object: true }],
  streams: {
    activity: {
      router: {
        actor: ['self'],
        object: ['self', 'members']
      }
    },
    notification: {
      router: {
        object: ['members', '^online-authors']
      }
    },
    email: {
      router: {
        object: ['members', '^online-authors']
      }
    }
  }
});

/*!
 * Post a content-revision activity when a user uploads a new file body
 */
ContentAPI.emitter.on(
  ContentConstants.events.UPDATED_CONTENT_BODY,
  // eslint-disable-next-line no-unused-vars
  (ctx, newContentObject, oldContentObject, revision) => {
    const millis = Date.now();
    const actorResource = new ActivityModel.ActivitySeedResource('user', ctx.user().id, {
      user: ctx.user()
    });
    const objectResource = new ActivityModel.ActivitySeedResource('content', newContentObject.id, {
      content: newContentObject
    });
    const activitySeed = new ActivityModel.ActivitySeed(
      ContentConstants.activity.ACTIVITY_CONTENT_REVISION,
      millis,
      ActivityConstants.verbs.UPDATE,
      actorResource,
      objectResource
    );
    ActivityAPI.postActivity(ctx, activitySeed);
  }
);

/*!
 * Post a content-revision activity when a user has made an edit to a collaborative document (even though there is technically no new revision)
 */
ContentAPI.emitter.on(ContentConstants.events.EDITED_COLLABDOC, (ctx, contentObject) => {
  const millis = Date.now();
  const actorResource = new ActivityModel.ActivitySeedResource('user', ctx.user().id, {
    user: ctx.user()
  });
  const objectResource = new ActivityModel.ActivitySeedResource('content', contentObject.id, {
    content: contentObject
  });
  const activitySeed = new ActivityModel.ActivitySeed(
    ContentConstants.activity.ACTIVITY_CONTENT_REVISION,
    millis,
    ActivityConstants.verbs.UPDATE,
    actorResource,
    objectResource
  );
  ActivityAPI.postActivity(ctx, activitySeed);
});

/**
 * Content-restored-revision
 */

ActivityAPI.registerActivityType(ContentConstants.activity.ACTIVITY_CONTENT_RESTORED_REVISION, {
  groupBy: [{ object: true }],
  streams: {
    activity: {
      router: {
        actor: ['self'],
        object: ['self', 'members']
      }
    },
    notification: {
      router: {
        object: ['managers']
      }
    },
    email: {
      router: {
        object: ['managers']
      }
    }
  }
});

/*!
 * Post a content-restored-revision activity when a user restores an old revision.
 */
ContentAPI.emitter.on(
  ContentConstants.events.RESTORED_REVISION,
  // eslint-disable-next-line no-unused-vars
  (ctx, newContentObject, oldContentObject, restoredRevision) => {
    const millis = Date.now();
    const actorResource = new ActivityModel.ActivitySeedResource('user', ctx.user().id, {
      user: ctx.user()
    });
    const objectResource = new ActivityModel.ActivitySeedResource('content', newContentObject.id, {
      content: newContentObject
    });
    const activitySeed = new ActivityModel.ActivitySeed(
      ContentConstants.activity.ACTIVITY_CONTENT_RESTORED_REVISION,
      millis,
      ActivityConstants.verbs.UPDATE,
      actorResource,
      objectResource
    );
    ActivityAPI.postActivity(ctx, activitySeed);
  }
);

/// //////////////////
// CONTENT-COMMENT //
/// //////////////////

ActivityAPI.registerActivityType(ContentConstants.activity.ACTIVITY_CONTENT_COMMENT, {
  groupBy: [{ target: true }],
  streams: {
    activity: {
      router: {
        actor: ['self'],
        target: ['message-contributors', 'members']
      }
    },
    notification: {
      router: {
        target: ['message-contributors', 'members']
      }
    },
    email: {
      router: {
        target: ['message-contributors', 'members']
      }
    },
    message: {
      transient: true,
      router: {
        // Route the activity to the piece of content
        target: ['self']
      }
    }
  }
});

/*!
 * Post a content-comment activity when a user comments on a content item
 */
ContentAPI.emitter.on(ContentConstants.events.CREATED_COMMENT, (ctx, message, content) => {
  const millis = Date.now();
  const actorResource = new ActivityModel.ActivitySeedResource('user', ctx.user().id, {
    user: ctx.user()
  });
  const objectResource = new ActivityModel.ActivitySeedResource('content-comment', message.id, {
    contentId: content.id,
    message
  });
  const targetResource = new ActivityModel.ActivitySeedResource('content', content.id, {
    content
  });
  const activitySeed = new ActivityModel.ActivitySeed(
    ContentConstants.activity.ACTIVITY_CONTENT_COMMENT,
    millis,
    ActivityConstants.verbs.POST,
    actorResource,
    objectResource,
    targetResource
  );
  ActivityAPI.postActivity(ctx, activitySeed);
});

/// ///////////////////////////////////////////////////////////////////////
// CONTENT-SHARE, CONTENT-ADD-TO-LIBRARY and CONTENT-UPDATE-MEMBER-ROLE //
/// ///////////////////////////////////////////////////////////////////////

ActivityAPI.registerActivityType(ContentConstants.activity.ACTIVITY_CONTENT_ADD_TO_LIBRARY, {
  // "Branden Visser added 5 items to his library"
  groupBy: [{ actor: true }],
  streams: {
    activity: {
      router: {
        actor: ['self', 'followers'],
        object: ['managers']
      }
    }
  }
});

ActivityAPI.registerActivityType(ContentConstants.activity.ACTIVITY_CONTENT_SHARE, {
  groupBy: [
    // "Branden Visser shared Content Item with 5 users and groups"
    { actor: true, object: true },

    // "Branden Visser shared 8 files with OAE Team"
    { actor: true, target: true }
  ],
  streams: {
    activity: {
      router: {
        actor: ['self'],
        object: ['managers'],
        target: ['self', 'members', 'followers']
      }
    },
    notification: {
      router: {
        target: ['self']
      }
    },
    email: {
      router: {
        target: ['self']
      }
    }
  }
});

ActivityAPI.registerActivityType(ContentConstants.activity.ACTIVITY_CONTENT_UPDATE_MEMBER_ROLE, {
  groupBy: [{ actor: true, target: true }],
  streams: {
    activity: {
      router: {
        actor: ['self'],
        object: ['self', 'members'],
        target: ['managers']
      }
    }
  }
});

/*!
 * Post a content-share or content-add-to-library activity based on content sharing
 */
ContentAPI.emitter.on(ContentConstants.events.UPDATED_CONTENT_MEMBERS, (ctx, content, memberChangeInfo, options) => {
  /**
   * If this member update came from an invitation,
   * we bypass adding activity as there is a dedicated activity for that
   */
  if (hasInvitation(options)) return;

  const addedMemberIds = pluck(ID, memberChangeInfo.members.added);
  const updatedMemberIds = pluck(ID, memberChangeInfo.members.updated);

  const millis = Date.now();
  const actorResource = new ActivityModel.ActivitySeedResource('user', ctx.user().id, {
    user: ctx.user()
  });
  const contentResource = new ActivityModel.ActivitySeedResource('content', content.id, {
    content
  });

  // When a user is added, it is considered either a content-share or a content-add-to-library activity, depending on if the
  // added user is the current user in context
  _.each(addedMemberIds, (memberId) => {
    if (memberId === ctx.user().id) {
      // Users can't "share" with themselves, they actually "add it to their library"
      ActivityAPI.postActivity(
        ctx,
        new ActivityModel.ActivitySeed(
          ContentConstants.activity.ACTIVITY_CONTENT_ADD_TO_LIBRARY,
          millis,
          ActivityConstants.verbs.ADD,
          actorResource,
          contentResource
        )
      );
    } else {
      // A user shared content with some other user, fire the content share activity
      const principalResourceType = PrincipalsUtil.isGroup(memberId) ? 'group' : 'user';
      const principalResource = new ActivityModel.ActivitySeedResource(principalResourceType, memberId);
      ActivityAPI.postActivity(
        ctx,
        new ActivityModel.ActivitySeed(
          ContentConstants.activity.ACTIVITY_CONTENT_SHARE,
          millis,
          ActivityConstants.verbs.SHARE,
          actorResource,
          contentResource,
          principalResource
        )
      );
    }
  });

  // When a user's role is updated, we fire a "content-update-member-role" activity
  _.each(updatedMemberIds, (memberId) => {
    const principalResourceType = PrincipalsUtil.isGroup(memberId) ? 'group' : 'user';
    const principalResource = new ActivityModel.ActivitySeedResource(principalResourceType, memberId);
    ActivityAPI.postActivity(
      ctx,
      new ActivityModel.ActivitySeed(
        ContentConstants.activity.ACTIVITY_CONTENT_UPDATE_MEMBER_ROLE,
        millis,
        ActivityConstants.verbs.UPDATE,
        actorResource,
        principalResource,
        contentResource
      )
    );
  });
});

/// ////////////////////////
// ACTIVITY ENTITY TYPES //
/// ////////////////////////

/*!
 * Produces a persistent 'content' activity entity
 * @see ActivityAPI#registerActivityEntityType
 */
const _contentProducer = function (resource, callback) {
  const content = resource.resourceData && resource.resourceData.content ? resource.resourceData.content : null;

  // If the content item was fired with the resource, use it instead of fetching
  if (content) {
    return callback(null, ContentUtil.createPersistentContentActivityEntity(content));
  }

  ContentDAO.Content.getContent(resource.resourceId, (error, content) => {
    if (error) {
      return callback(error);
    }

    return callback(null, ContentUtil.createPersistentContentActivityEntity(content));
  });
};

/*!
 * Produces an persistent activity entity that represents a comment that was posted
 * @see ActivityAPI#registerActivityEntityType
 */
const _contentCommentProducer = function (resource, callback) {
  const { message, contentId } = resource.resourceData;
  ContentDAO.Content.getContent(contentId, (error, content) => {
    if (error) {
      return callback(error);
    }

    MessageBoxUtil.createPersistentMessageActivityEntity(message, (error, entity) => {
      if (error) {
        return callback(error);
      }

      // Store the content id and visibility on the entity as these are required for routing the activities.
      entity.objectType = 'content-comment';
      entity.contentId = content.id;
      entity.contentVisibility = content.visibility;
      return callback(null, entity);
    });
  });
};

/*!
 * Transform the content persistent activity entities into UI-friendly ones
 * @see ActivityAPI#registerActivityEntityType
 */
const _contentTransformer = function (ctx, activityEntities, callback) {
  const transformedActivityEntities = {};

  // Collect all the revision ids so we can fetch their preview data
  let allRevisionIds = [];
  // eslint-disable-next-line no-unused-vars
  _.each(activityEntities, (entities, activityId) => {
    // eslint-disable-next-line no-unused-vars
    _.each(entities, (entity, entityId) => {
      allRevisionIds.push(entity.content.latestRevisionId);
    });
  });

  // No need to retrieve the same revision twice
  allRevisionIds = _.uniq(allRevisionIds);

  // Fetch the previews and attach them to the transformed entities
  ContentDAO.Previews.getPreviewUris(allRevisionIds, (error, previews) => {
    if (error) {
      return callback(error);
    }

    _.each(activityEntities, (entities, activityId) => {
      transformedActivityEntities[activityId] = transformedActivityEntities[activityId] || {};
      _.each(entities, (entity, entityId) => {
        // Transform the persistent entity with its up-to-date preview status
        transformedActivityEntities[activityId][entityId] = ContentUtil.transformPersistentContentActivityEntity(
          ctx,
          entity,
          previews[entity.content.latestRevisionId]
        );
      });
    });

    return callback(null, transformedActivityEntities);
  });
};

/*!
 * Transform the content persistent activity entities into their OAE profiles
 * @see ActivityAPI#registerActivityEntityType
 */
const _contentInternalTransformer = function (ctx, activityEntities, callback) {
  const transformedActivityEntities = {};

  // Collect all the revision ids so we can fetch their preview data
  let allRevisionIds = [];
  // eslint-disable-next-line no-unused-vars
  _.each(activityEntities, (entities, activityId) => {
    // eslint-disable-next-line no-unused-vars
    _.each(entities, (entity, entityId) => {
      allRevisionIds.push(entity.content.latestRevisionId);
    });
  });

  // No need to retrieve the same revision twice
  allRevisionIds = _.uniq(allRevisionIds);

  // We need the full previews object for the internal content object
  ContentDAO.Revisions.getMultipleRevisions(
    allRevisionIds,
    { fields: ['revisionId', 'previews'] },
    (error, revisions) => {
      if (error) {
        return callback(error);
      }

      const previews = {};
      _.each(revisions, (revision) => {
        previews[revision.revisionId] = revision.previews;
      });

      _.each(activityEntities, (entities, activityId) => {
        transformedActivityEntities[activityId] = transformedActivityEntities[activityId] || {};
        _.each(entities, (entity, entityId) => {
          // Transform the persistent entity with its up-to-date preview status
          transformedActivityEntities[activityId][entityId] =
            ContentUtil.transformPersistentContentActivityEntityToInternal(
              ctx,
              entity,
              previews[entity.content.latestRevisionId]
            );
        });
      });

      return callback(null, transformedActivityEntities);
    }
  );
};

/*!
 * Transform the comment persistent activity entities into UI-friendly ones
 * @see ActivityAPI#registerActivityEntityType
 */
const _contentCommentTransformer = function (ctx, activityEntities, callback) {
  const transformedActivityEntities = {};
  for (const activityId of _.keys(activityEntities)) {
    transformedActivityEntities[activityId] = transformedActivityEntities[activityId] || {};
    for (const entityId of _.keys(activityEntities[activityId])) {
      const entity = activityEntities[activityId][entityId];
      const contentId = entity.message.messageBoxId;
      const contentResource = AuthzUtil.getResourceFromId(contentId);
      const profilePath = '/content/' + contentResource.tenantAlias + '/' + contentResource.resourceId;
      const urlFormat = '/api/content/' + contentId + '/messages/%s';
      transformedActivityEntities[activityId][entityId] = MessageBoxUtil.transformPersistentMessageActivityEntity(
        ctx,
        entity,
        profilePath,
        urlFormat
      );
    }
  }

  return callback(null, transformedActivityEntities);
};

/*!
 * Transform the comment persistent activity entities into OAE profiles
 * @see ActivityAPI#registerActivityEntityType
 */
const _contentCommentInternalTransformer = function (ctx, activityEntities, callback) {
  const transformedActivityEntities = {};
  for (const activityId of _.keys(activityEntities)) {
    transformedActivityEntities[activityId] = transformedActivityEntities[activityId] || {};
    for (const entityId of _.keys(activityEntities[activityId])) {
      const entity = activityEntities[activityId][entityId];
      transformedActivityEntities[activityId][entityId] =
        MessageBoxUtil.transformPersistentMessageActivityEntityToInternal(ctx, entity.message);
    }
  }

  return callback(null, transformedActivityEntities);
};

ActivityAPI.registerActivityEntityType('content', {
  producer: _contentProducer,
  transformer: {
    activitystreams: _contentTransformer,
    internal: _contentInternalTransformer
  },
  propagation(associationsCtx, entity, callback) {
    ActivityUtil.getStandardResourcePropagation(entity.content.visibility, AuthzConstants.joinable.NO, callback);
  }
});

ActivityAPI.registerActivityEntityType('content-comment', {
  producer: _contentCommentProducer,
  transformer: {
    activitystreams: _contentCommentTransformer,
    internal: _contentCommentInternalTransformer
  },
  propagation(associationsCtx, entity, callback) {
    return callback(null, [{ type: ActivityConstants.entityPropagation.ALL }]);
  }
});

/// ///////////////////////////////
// ACTIVITY ENTITY ASSOCIATIONS //
/// ///////////////////////////////

/*!
 * Register an association that presents the content item
 */
ActivityAPI.registerActivityEntityAssociation('content', 'self', (associationsCtx, entity, callback) =>
  callback(null, [entity[ActivityConstants.properties.OAE_ID]])
);

/*!
 * Register an association that presents the members of a content item categorized by role
 */
ActivityAPI.registerActivityEntityAssociation('content', 'members-by-role', (associationsCtx, entity, callback) => {
  ActivityUtil.getAllAuthzMembersByRole(entity[ActivityConstants.properties.OAE_ID], callback);
});

/*!
 * Register an association that presents all the indirect members of a content item
 */
ActivityAPI.registerActivityEntityAssociation('content', 'members', (associationsCtx, entity, callback) => {
  associationsCtx.get('members-by-role', (error, membersByRole) => {
    if (error) {
      return callback(error);
    }

    return callback(null, _.values(membersByRole).flat());
  });
});

/*!
 * Register an association that presents all the managers of a content item
 */
ActivityAPI.registerActivityEntityAssociation('content', 'managers', (associationsCtx, entity, callback) => {
  associationsCtx.get('members-by-role', (error, membersByRole) => {
    if (error) {
      return callback(error);
    }

    return callback(null, membersByRole[AuthzConstants.role.MANAGER]);
  });
});

/*!
 * Register an association that presents those users who are active on a collaborative document right now
 */
ActivityAPI.registerActivityEntityAssociation('content', 'online-authors', (associationsCtx, entity, callback) => {
  // Ignore content items that aren't collaborative documents
  if (entity.content.resourceSubType !== 'collabdoc') {
    return callback(null, []);
  }

  // Grab the authors who are currently in the collaborative document
  Etherpad.getOnlineAuthors(entity.content.id, entity.content.etherpadPadId, (error, onlineAuthorIds) => {
    if (error) {
      return callback(error);
    }

    ContentDAO.Etherpad.getUserIds(onlineAuthorIds, (error, userIds) => {
      if (error) {
        return callback(error);
      }

      return callback(null, _.values(userIds));
    });
  });
});

/*!
 * Register an assocation that presents all the commenting contributors of a content item
 */
ActivityAPI.registerActivityEntityAssociation(
  'content',
  'message-contributors',
  (associationsCtx, entity, callback) => {
    MessageBoxAPI.getRecentContributions(entity[ActivityConstants.properties.OAE_ID], null, 100, callback);
  }
);

/*!
 * Register an association that presents the content item for a content-comment entity
 */
ActivityAPI.registerActivityEntityAssociation('content-comment', 'self', (associationsCtx, entity, callback) =>
  callback(null, [entity.contentId])
);