packages/oae-content/lib/activity.js
/*!
* 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])
);