oaeproject/Hilary

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

Summary

Maintainability
F
3 days
Test Coverage
A
98%
/*
 * 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 { has, pluck } 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 ContentUtil from 'oae-content/lib/internal/util.js';
import * as MessageBoxAPI from 'oae-messagebox';
import * as MessageBoxUtil from 'oae-messagebox/lib/util.js';
import PreviewConstants from 'oae-preview-processor/lib/constants.js';
import * as PrincipalsUtil from 'oae-principals/lib/util.js';
import * as TenantsUtil from 'oae-tenants/lib/util.js';
import * as FoldersAPI from 'oae-folders';
import * as FoldersDAO from 'oae-folders/lib/internal/dao.js';

import { AuthzConstants } from 'oae-authz/lib/constants.js';
import { ActivityConstants } from 'oae-activity/lib/constants.js';
import { ContentConstants } from 'oae-content/lib/constants.js';
import { FoldersConstants } from 'oae-folders/lib/constants.js';

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

ActivityAPI.registerActivityType(FoldersConstants.activity.ACTIVITY_FOLDER_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 folder-create activity when a user creates a folder
 */
FoldersAPI.emitter.on(FoldersConstants.events.CREATED_FOLDER, (ctx, folder, memberChangeInfo) => {
  const millis = Date.now();
  const actorResource = new ActivityModel.ActivitySeedResource('user', ctx.user().id, {
    user: ctx.user()
  });
  const objectResource = new ActivityModel.ActivitySeedResource('folder', folder.id, { folder });
  let targetResource = null;

  // Get the extra members
  const extraMembers = _.chain(memberChangeInfo.members.added).pluck('id').without(ctx.user().id).value();

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

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

/// ///////////////////////
// FOLDER-ADD-TO-FOLDER //
/// ///////////////////////

ActivityAPI.registerActivityType(FoldersConstants.activity.ACTIVITY_FOLDER_ADD_TO_FOLDER, {
  // Simon added Content A and B to folder X
  // Simon added Content A, B and C to folder Y
  groupBy: [{ target: true }],
  streams: {
    activity: {
      router: {
        actor: ['self', 'followers'],
        object: ['managers'],
        target: ['members']
      }
    },
    notification: {
      router: {
        target: ['members']
      }
    },
    email: {
      router: {
        target: ['members']
      }
    }
  }
});

/*!
 * Post a folder-add-to-folder activity when a user adds content items to a folder
 */
FoldersAPI.emitter.on(FoldersConstants.events.ADDED_CONTENT_ITEMS, (ctx, actionContext, folder, contentItems) => {
  // Ignore activities triggered by content-create
  if (actionContext !== 'content-create') {
    const millis = Date.now();
    const actorResource = new ActivityModel.ActivitySeedResource('user', ctx.user().id, {
      user: ctx.user()
    });
    const targetResource = new ActivityModel.ActivitySeedResource('folder', folder.id, {
      folder
    });
    _.each(contentItems, (content) => {
      const objectResource = new ActivityModel.ActivitySeedResource('content', content.id, {
        content
      });
      const activitySeed = new ActivityModel.ActivitySeed(
        FoldersConstants.activity.ACTIVITY_FOLDER_ADD_TO_FOLDER,
        millis,
        ActivityConstants.verbs.ADD,
        actorResource,
        objectResource,
        targetResource
      );
      ActivityAPI.postActivity(ctx, activitySeed);
    });
  }
});

/// ////////////////////////////////////////////
// FOLDER-UPDATE and FOLDER-UPDATE-VISIBILITY//
/// ////////////////////////////////////////////

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

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

/*!
 * Post a folder-update activity when a user updates a folder
 */
FoldersAPI.emitter.on(FoldersConstants.events.UPDATED_FOLDER, (ctx, oldFolder, updatedFolder) => {
  const millis = Date.now();
  const actorResource = new ActivityModel.ActivitySeedResource('user', ctx.user().id, {
    user: ctx.user()
  });
  const objectResource = new ActivityModel.ActivitySeedResource('folder', updatedFolder.id, {
    folder: updatedFolder
  });

  let activityType = null;
  activityType =
    updatedFolder.visibility === oldFolder.visibility
      ? FoldersConstants.activity.ACTIVITY_FOLDER_UPDATE
      : FoldersConstants.activity.ACTIVITY_FOLDER_UPDATE_VISIBILITY;

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

/// /////////////////
// FOLDER-COMMENT //
/// /////////////////

ActivityAPI.registerActivityType(FoldersConstants.activity.ACTIVITY_FOLDER_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 folder
        target: ['self']
      }
    }
  }
});

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

/// /////////////////////////////////////////////
// FOLDER-SHARE and FOLDER-UPDATE-MEMBER-ROLE //
/// /////////////////////////////////////////////

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

ActivityAPI.registerActivityType(FoldersConstants.activity.ACTIVITY_FOLDER_SHARE, {
  groupBy: [
    // "Branden Visser shared Folder with 5 users and groups"
    { actor: true, object: true },

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

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

FoldersAPI.emitter.on(FoldersConstants.events.UPDATED_FOLDER_MEMBERS, (ctx, folder, 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 folderResource = new ActivityModel.ActivitySeedResource('folder', folder.id, { folder });

  // When a user is added, it is considered either a folder-share or a folder-add-to-library
  // activity, depending on whether 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(
          FoldersConstants.activity.ACTIVITY_FOLDER_ADD_TO_LIBRARY,
          millis,
          ActivityConstants.verbs.ADD,
          actorResource,
          folderResource
        )
      );
    } else {
      // A user shared a folder with some other user, fire the folder share activity
      const principalResourceType = PrincipalsUtil.isGroup(memberId) ? 'group' : 'user';
      const principalResource = new ActivityModel.ActivitySeedResource(principalResourceType, memberId);
      ActivityAPI.postActivity(
        ctx,
        new ActivityModel.ActivitySeed(
          FoldersConstants.activity.ACTIVITY_FOLDER_SHARE,
          millis,
          ActivityConstants.verbs.SHARE,
          actorResource,
          folderResource,
          principalResource
        )
      );
    }
  });

  // When a user's role is updated, we fire a "folder-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(
        FoldersConstants.activity.ACTIVITY_FOLDER_UPDATE_MEMBER_ROLE,
        millis,
        ActivityConstants.verbs.UPDATE,
        actorResource,
        principalResource,
        folderResource
      )
    );
  });
});

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

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

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

  FoldersDAO.getFolder(resource.resourceId, (error, folder) => {
    if (error) {
      return callback(error);
    }

    return callback(null, _createPersistentFolderActivityEntity(folder));
  });
};

/*!
 * Transform the folder persistent activity entities into UI-friendly ones
 * @see ActivityAPI#registerActivityEntityType
 */
const _folderTransformer = function (ctx, activityEntities, callback) {
  // Collect all the folder ids so we can fetch their preview data
  let folderIds = [];
  // eslint-disable-next-line no-unused-vars
  _.each(activityEntities, (entities, activityId) => {
    // eslint-disable-next-line no-unused-vars
    _.each(entities, (entity, entityId) => {
      folderIds.push(entity.folder.id);
    });
  });
  folderIds = _.uniq(folderIds);

  // Grab the latest folder objects
  FoldersDAO.getFoldersByIds(folderIds, (error, folders) => {
    if (error) {
      return callback(error);
    }

    const foldersById = _.indexBy(folders, 'id');
    const transformedActivityEntities = {};
    _.each(activityEntities, (entitiesPerActivity, activityId) => {
      transformedActivityEntities[activityId] = transformedActivityEntities[activityId] || {};
      _.each(entitiesPerActivity, (entity, entityId) => {
        transformedActivityEntities[activityId][entityId] = _transformPersistentFolderActivityEntity(
          ctx,
          entity,
          foldersById
        );
      });
    });

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

/*!
 * Produces a persistent activity entity that represents a comment that was posted
 * @see ActivityAPI#registerActivityEntityType
 */
const _folderCommentProducer = function (resource, callback) {
  const { message, folderId } = resource.resourceData;
  FoldersDAO.getFolder(folderId, (error, folder) => {
    if (error) {
      return callback(error);
    }

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

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

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

  return callback(null, transformedActivityEntities);
};

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

ActivityAPI.registerActivityEntityType('folder-comment', {
  producer: _folderCommentProducer,
  transformer: {
    activitystreams: _folderCommentTransformer,
    internal: _folderCommentInternalTransformer
  },
  propagation(associationsCtx, entity, callback) {
    return callback(null, [{ type: ActivityConstants.entityPropagation.ALL }]);
  }
});

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

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

/*!
 * Register an association that presents the members of a folder categorized by role
 */
ActivityAPI.registerActivityEntityAssociation('folder', 'members-by-role', (associationsCtx, entity, callback) => {
  ActivityUtil.getAllAuthzMembersByRole(entity[FoldersConstants.activity.PROP_OAE_GROUP_ID], callback);
});

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

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

/**
 * Create the persistent folder entity that can be transformed into an activity entity for the UI.
 *
 * @param  {Folder}     folder      The folder that provides the data for the entity
 * @return {Object}                 An object containing the entity data that can be transformed into a UI folder activity entity
 * @api private
 */
const _createPersistentFolderActivityEntity = function (folder) {
  const options = { folder };
  options[FoldersConstants.activity.PROP_OAE_GROUP_ID] = folder.groupId;
  return new ActivityModel.ActivityEntity('folder', folder.id, folder.visibility, options);
};

/**
 * Transform a folder 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 persisted activity entity to transform
 * @param  {Object}             foldersById         A set of folders keyed against their folder id
 * @return {ActivityEntity}                         The activity entity that represents the given folder
 */
const _transformPersistentFolderActivityEntity = function (ctx, entity, foldersById) {
  // Grab the folder from the `foldersById` hash as it would contain the updated
  // previews object. If it can't be found (because it has been deleted) we fall
  // back to the folder that was provided in the activity
  const folderId = entity.folder.id;
  const folder = foldersById[folderId] || entity.folder;

  const tenant = ctx.tenant();
  const baseUrl = TenantsUtil.getBaseUrl(tenant);
  const globalId = baseUrl + '/api/folder/' + folder.id;
  const profileUrl = baseUrl + folder.profilePath;
  const options = {
    displayName: folder.displayName,
    url: profileUrl,
    ext: {}
  };
  options.ext[ActivityConstants.properties.OAE_ID] = folder.id;
  options.ext[ActivityConstants.properties.OAE_VISIBILITY] = folder.visibility;
  options.ext[ActivityConstants.properties.OAE_PROFILEPATH] = folder.profilePath;

  if (folder.previews && folder.previews.thumbnailUri) {
    const thumbnailUrl = ContentUtil.getSignedDownloadUrl(ctx, folder.previews.thumbnailUri, -1);
    options.image = new ActivityModel.ActivityMediaLink(
      thumbnailUrl,
      PreviewConstants.SIZES.IMAGE.THUMBNAIL,
      PreviewConstants.SIZES.IMAGE.THUMBNAIL
    );
  }

  if (folder.previews && folder.previews.wideUri) {
    const wideUrl = ContentUtil.getSignedDownloadUrl(ctx, folder.previews.wideUri, -1);
    options.ext[ContentConstants.activity.PROP_OAE_WIDE_IMAGE] = new ActivityModel.ActivityMediaLink(
      wideUrl,
      PreviewConstants.SIZES.IMAGE.WIDE_WIDTH,
      PreviewConstants.SIZES.IMAGE.WIDE_HEIGHT
    );
  }

  return new ActivityModel.ActivityEntity('folder', globalId, folder.visibility, options);
};