oaeproject/Hilary

View on GitHub
packages/oae-discussions/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 * 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 TenantsUtil from 'oae-tenants/lib/util.js';
import { AuthzConstants } from 'oae-authz/lib/constants.js';
import { ActivityConstants } from 'oae-activity/lib/constants.js';
import * as DiscussionsDAO from './internal/dao.js';
import DiscussionsAPI from './api.js';

import { DiscussionsConstants } from './constants.js';

/**
 * Discussion create
 */

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

/*!
 * Post a discussion-create activity when a user creates a discussion.
 */
DiscussionsAPI.on(DiscussionsConstants.events.CREATED_DISCUSSION, (ctx, discussion) => {
  const millis = Date.now();
  const actorResource = new ActivityModel.ActivitySeedResource('user', ctx.user().id, {
    user: ctx.user()
  });
  const objectResource = new ActivityModel.ActivitySeedResource('discussion', discussion.id, {
    discussion
  });
  const activitySeed = new ActivityModel.ActivitySeed(
    DiscussionsConstants.activity.ACTIVITY_DISCUSSION_CREATE,
    millis,
    ActivityConstants.verbs.CREATE,
    actorResource,
    objectResource
  );
  ActivityAPI.postActivity(ctx, activitySeed);
});

/// /////////////////////////////////////////////////////
// DISCUSSION-UPDATE and DISCUSSION-UPDATE-VISIBILITY //
/// /////////////////////////////////////////////////////

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

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

/*!
 * Post either a discussion-update or discussion-update-visibility activity when a user updates a discussion's metadata.
 */
DiscussionsAPI.on(DiscussionsConstants.events.UPDATED_DISCUSSION, (ctx, newDiscussion, oldDiscussion) => {
  const millis = Date.now();
  const actorResource = new ActivityModel.ActivitySeedResource('user', ctx.user().id, {
    user: ctx.user()
  });
  const objectResource = new ActivityModel.ActivitySeedResource('discussion', newDiscussion.id, {
    discussion: newDiscussion
  });

  // 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 =
    newDiscussion.visibility === oldDiscussion.visibility
      ? DiscussionsConstants.activity.ACTIVITY_DISCUSSION_UPDATE
      : DiscussionsConstants.activity.ACTIVITY_DISCUSSION_UPDATE_VISIBILITY;

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

/// /////////////////////
// DISCUSSION-MESSAGE //
/// /////////////////////

ActivityAPI.registerActivityType(DiscussionsConstants.activity.ACTIVITY_DISCUSSION_MESSAGE, {
  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 discussion
        target: ['self']
      }
    }
  }
});

/*!
 * Post a discussion-message activity when a user comments on a discussion
 */
DiscussionsAPI.on(DiscussionsConstants.events.CREATED_DISCUSSION_MESSAGE, (ctx, message, discussion) => {
  const millis = Date.now();
  const actorResource = new ActivityModel.ActivitySeedResource('user', ctx.user().id, {
    user: ctx.user()
  });
  const objectResource = new ActivityModel.ActivitySeedResource('discussion-message', message.id, {
    discussionId: discussion.id,
    message
  });
  const targetResource = new ActivityModel.ActivitySeedResource('discussion', discussion.id, {
    discussion
  });
  const activitySeed = new ActivityModel.ActivitySeed(
    DiscussionsConstants.activity.ACTIVITY_DISCUSSION_MESSAGE,
    millis,
    ActivityConstants.verbs.POST,
    actorResource,
    objectResource,
    targetResource
  );
  ActivityAPI.postActivity(ctx, activitySeed);
});

/// ////////////////////////////////////////////////////////////////////////////////
// DISCUSSION-SHARE, DISCUSSION-ADD-TO-LIBRARY and DISCUSSION-UPDATE-MEMBER-ROLE //
/// ////////////////////////////////////////////////////////////////////////////////s

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

ActivityAPI.registerActivityType(DiscussionsConstants.activity.ACTIVITY_DISCUSSION_SHARE, {
  groupBy: [
    // "Branden Visser shared a discussion with 5 users and groups"
    { actor: true, object: true },

    // "Branden Visser shared 8 discussions 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(DiscussionsConstants.activity.ACTIVITY_DISCUSSION_UPDATE_MEMBER_ROLE, {
  groupBy: [{ actor: true, target: true }],
  streams: {
    activity: {
      router: {
        actor: ['self'],
        object: ['self', 'members'],
        target: ['managers']
      }
    }
  }
});

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

    const addedPrincipalIds = _.pluck(memberChangeInfo.members.added, 'id');
    const updatedPrincipalIds = _.pluck(memberChangeInfo.members.updated, 'id');

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

    // For users that are newly added to the discussion, post either a share or "add to library" activity, depending on context
    _.each(addedPrincipalIds, (principalId) => {
      if (principalId === ctx.user().id) {
        // Users can't "share" with themselves, they actually "add it to their library"
        ActivityAPI.postActivity(
          ctx,
          new ActivityModel.ActivitySeed(
            DiscussionsConstants.activity.ACTIVITY_DISCUSSION_ADD_TO_LIBRARY,
            millis,
            ActivityConstants.verbs.ADD,
            actorResource,
            discussionResource
          )
        );
      } else {
        // A user shared discussion with some other user, fire the discussion share activity
        const principalResourceType = PrincipalsUtil.isGroup(principalId) ? 'group' : 'user';
        const principalResource = new ActivityModel.ActivitySeedResource(principalResourceType, principalId);
        ActivityAPI.postActivity(
          ctx,
          new ActivityModel.ActivitySeed(
            DiscussionsConstants.activity.ACTIVITY_DISCUSSION_SHARE,
            millis,
            ActivityConstants.verbs.SHARE,
            actorResource,
            discussionResource,
            principalResource
          )
        );
      }
    });

    // For users whose role changed, post the discussion-update-member-role activity
    _.each(updatedPrincipalIds, (principalId) => {
      const principalResourceType = PrincipalsUtil.isGroup(principalId) ? 'group' : 'user';
      const principalResource = new ActivityModel.ActivitySeedResource(principalResourceType, principalId);
      ActivityAPI.postActivity(
        ctx,
        new ActivityModel.ActivitySeed(
          DiscussionsConstants.activity.ACTIVITY_DISCUSSION_UPDATE_MEMBER_ROLE,
          millis,
          ActivityConstants.verbs.UPDATE,
          actorResource,
          principalResource,
          discussionResource
        )
      );
    });
  }
);

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

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

  // If the discussion item was fired with the resource, use it instead of fetching
  if (discussion) {
    return callback(null, _createPersistentDiscussionActivityEntity(discussion));
  }

  DiscussionsDAO.getDiscussion(resource.resourceId, (error, discussion) => {
    if (error) {
      return callback(error);
    }

    return callback(null, _createPersistentDiscussionActivityEntity(discussion));
  });
};

/**
 * Create the persistent discussion entity that can be transformed into an activity entity for the UI.
 *
 * @param  {Discussion}     discussion      The discussion that provides the data for the entity.
 * @return {Object}                         An object containing the entity data that can be transformed into a UI discussion activity entity
 * @api private
 */
const _createPersistentDiscussionActivityEntity = function (discussion) {
  return new ActivityModel.ActivityEntity('discussion', discussion.id, discussion.visibility, {
    discussion
  });
};

/*!
 * Produces an persistent activity entity that represents a message that was posted
 * @see ActivityAPI#registerActivityEntityType
 */
const _discussionMessageProducer = function (resource, callback) {
  const { message, discussionId } = resource.resourceData;
  DiscussionsDAO.getDiscussion(discussionId, (error, discussion) => {
    if (error) {
      return callback(error);
    }

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

      // Store the discussion id and visibility on the entity as these are required for routing the activities
      entity.objectType = 'discussion-message';
      entity.discussionId = discussion.id;
      entity.discussionVisibility = discussion.visibility;
      return callback(null, entity);
    });
  });
};

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

  _.each(activityEntities, (entities, activityId) => {
    transformedActivityEntities[activityId] = transformedActivityEntities[activityId] || {};
    _.each(entities, (entity, entityId) => {
      // Transform the persistent entity into an ActivityStrea.ms compliant format
      transformedActivityEntities[activityId][entityId] = _transformPersistentDiscussionActivityEntity(ctx, entity);
    });
  });
  return callback(null, transformedActivityEntities);
};

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

  _.each(activityEntities, (entities, activityId) => {
    transformedActivityEntities[activityId] = transformedActivityEntities[activityId] || {};
    _.each(entities, (entity, entityId) => {
      // Transform the persistent entity into the OAE model
      transformedActivityEntities[activityId][entityId] = entity.discussion;
    });
  });
  return callback(null, transformedActivityEntities);
};

/**
 * Transform a discussion object into an activity entity suitable to be displayed in an activity stream.
 *
 * For more details on the transformed entity model, @see ActivityAPI#registerActivityEntityTransformer
 *
 * @param  {Context}           ctx         Current execution context
 * @param  {Object}            entity      The persistent activity entity to transform
 * @return {ActivityEntity}                The activity entity that represents the given discussion item
 */
const _transformPersistentDiscussionActivityEntity = function (ctx, entity) {
  const { discussion } = entity;

  // Generate URLs for this activity
  const tenant = ctx.tenant();
  const baseUrl = TenantsUtil.getBaseUrl(tenant);
  const globalId = baseUrl + '/api/discussion/' + discussion.id;
  const resource = AuthzUtil.getResourceFromId(discussion.id);
  const profileUrl = baseUrl + '/discussion/' + resource.tenantAlias + '/' + resource.resourceId;

  const options = {};
  options.url = profileUrl;
  options.displayName = discussion.displayName;
  options.ext = {};
  options.ext[ActivityConstants.properties.OAE_ID] = discussion.id;
  options.ext[ActivityConstants.properties.OAE_VISIBILITY] = discussion.visibility;
  options.ext[ActivityConstants.properties.OAE_PROFILEPATH] = discussion.profilePath;
  return new ActivityModel.ActivityEntity('discussion', globalId, discussion.visibility, options);
};

/*!
 * Transform the persisted message activity entities into UI-friendly ones
 * @see ActivityAPI#registerActivityEntityType
 */
const _discussionMessageTransformer = 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 { discussionId } = entity;
      const resource = AuthzUtil.getResourceFromId(discussionId);
      const profilePath = '/discussion/' + resource.tenantAlias + '/' + resource.resourceId;
      const urlFormat = '/api/discussion/' + discussionId + '/messages/%s';
      transformedActivityEntities[activityId][entityId] = MessageBoxUtil.transformPersistentMessageActivityEntity(
        ctx,
        entity,
        profilePath,
        urlFormat
      );
    }
  }

  return callback(null, transformedActivityEntities);
};

/*!
 * Transform the persisted message activity entities into UI-friendly ones
 * @see ActivityAPI#registerActivityEntityType
 */
const _discussionMessageInternalTransformer = 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('discussion', {
  producer: _discussionProducer,
  transformer: {
    activitystreams: _discussionTransformer,
    internal: _discussionInternalTransformer
  },
  propagation(associationsCtx, entity, callback) {
    ActivityUtil.getStandardResourcePropagation(entity.discussion.visibility, AuthzConstants.joinable.NO, callback);
  }
});

ActivityAPI.registerActivityEntityType('discussion-message', {
  producer: _discussionMessageProducer,
  transformer: {
    activitystreams: _discussionMessageTransformer,
    internal: _discussionMessageInternalTransformer
  },
  propagation(associationsCtx, entity, callback) {
    return callback(null, [{ type: ActivityConstants.entityPropagation.ALL }]);
  }
});

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

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

/*!
 * Register an association that presents the members of a discussion categorized by role
 */
ActivityAPI.registerActivityEntityAssociation('discussion', '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 discussion
 */
ActivityAPI.registerActivityEntityAssociation('discussion', '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 discussion
 */
ActivityAPI.registerActivityEntityAssociation('discussion', '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 assocation that presents all the commenting contributors of a discussion
 */
ActivityAPI.registerActivityEntityAssociation(
  'discussion',
  'message-contributors',
  (associationsCtx, entity, callback) => {
    MessageBoxAPI.getRecentContributions(entity[ActivityConstants.properties.OAE_ID], null, 100, callback);
  }
);

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