oaeproject/Hilary

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

Summary

Maintainability
D
2 days
Test Coverage
A
91%
/*!
 * 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 { format } from 'node:util';
import _ from 'underscore';
import { logger } from 'oae-logger';
import { setUpConfig } from 'oae-config';

import * as AuthzAPI from 'oae-authz';
import * as AuthzInvitations from 'oae-authz/lib/invitations/index.js';
import * as AuthzPermissions from 'oae-authz/lib/permissions.js';
import * as ContentAPI from 'oae-content';
import * as ContentDAO from 'oae-content/lib/internal/dao.js';
import * as ContentUtil from 'oae-content/lib/internal/util.js';
import * as EmitterAPI from 'oae-emitter';
import * as LibraryAPI from 'oae-library';

import * as MessageBoxAPI from 'oae-messagebox';
import * as OaeUtil from 'oae-util/lib/util.js';
import * as GroupAPI from 'oae-principals/lib/api.group.js';
import * as PrincipalsDAO from 'oae-principals/lib/internal/dao.js';
import * as PrincipalsUtil from 'oae-principals/lib/util.js';
import * as ResourceActions from 'oae-resource/lib/actions.js';
import * as SearchAPI from 'oae-search';
import * as Signature from 'oae-util/lib/signature.js';
import { MessageBoxConstants } from 'oae-messagebox/lib/constants.js';
import { AuthzConstants } from 'oae-authz/lib/constants.js';
import { Validator as validator } from 'oae-util/lib/validator.js';

import isIn from 'validator/lib/isIn.js';
import isInt from 'validator/lib/isInt.js';
import { forEachObjIndexed } from 'ramda';
import * as FoldersFoldersLibrary from './internal/folders-library.js';
import * as FoldersAuthz from './authz.js';
import * as FoldersContentLibrary from './internal/content-library.js';
import * as FoldersDAO from './internal/dao.js';

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

const {
  isArray,
  isValidRoleChange,
  unless,
  validateInCase: bothCheck,
  isANumber,
  isLoggedInUser,
  isPrincipalId,
  isNotEmpty,
  isObject,
  isResourceId,
  isShortString,
  isMediumString,
  isArrayNotEmpty,
  isLongString
} = validator;

const log = logger('oae-folders-api');

const FoldersConfig = setUpConfig('oae-folders');

const DISPLAY_NAME = 'displayName';
const DESCRIPTION = 'description';
const VISIBILITY = 'visibility';
/*!
 * ### Events
 *
 * * `getFolderProfile(ctx, folder)`: A folder profile was retrieved
 * * `createdFolder(ctx, folder, members)`: A new folder was created
 * * `updatedFolder(ctx, oldFolder, newFolder)`: A folder was updated
 * * `deletedFolder(ctx, folder, memberIds)`: A folder was deleted
 * * `updatedFolderMembers(ctx, folder, memberUpdates, addedMemberIds, updatedMemberIds, removedMemberIds)`: The members of a folder have been updated
 * * `updatedFolderVisibility(ctx, folder, visibility, affectedContentItems, failedContentItems)`: The content items in a folder their visibility have been updated
 * * `addedContentItems(ctx, actionContext, folder, contentItems)`: One or more content items were added to a folder
 * * `removedContentItems(ctx, folder, contentIds)`: One or more content items were removed from a folder
 * * `createdComment(ctx, folder, message)`: A comment was placed on a folder
 */
const FoldersAPI = new EmitterAPI.EventEmitter();

/**
 * Create a folder
 *
 * @param  {Context}        ctx                     Current execution context
 * @param  {String}         displayName             The display name of the folder
 * @param  {String}         [description]           The description of the folder. By default, a folder will have no description
 * @param  {String}         [visibility]            The visibility of the folder. One of `AuthzConstants.visibility`. This will default to a value configured for the tenant
 * @param  {Object}         [roles]                 An object whose keys are principal ids and values are the role they should have on the folder. By default only the creator of the folder will be a manager
 * @param  {Function}       callback                Standard callback function
 * @param  {Object}         callback.err            An error that occurred, if any
 * @param  {Folder}         callback.folder         The folder that was created
 */
const createFolder = function (ctx, displayName, description, visibility, roles, callback) {
  visibility = visibility || FoldersConfig.getValue(ctx.tenant().alias, 'visibility', 'folder');
  roles = roles || {};

  const allVisibilities = _.values(AuthzConstants.visibility);

  // Verify basic properties
  try {
    unless(isLoggedInUser, {
      code: 401,
      msg: 'Anonymous users cannot create a folder'
    })(ctx);

    unless(isNotEmpty, {
      code: 400,
      msg: 'Must provide a display name for the folder'
    })(displayName);

    unless(isShortString, {
      code: 400,
      msg: 'A display name can be at most 1000 characters long'
    })(displayName);

    const descriptionIsThere = Boolean(description);
    unless(bothCheck(descriptionIsThere, isMediumString), {
      code: 400,
      msg: 'A description can be at most 10000 characters long'
    })(description);

    unless(isIn, {
      code: 400,
      msg: 'An invalid folder visibility option has been provided. Must be one of: ' + allVisibilities.join(', ')
    })(visibility, allVisibilities);

    // Verify each role is valid
    forEachObjIndexed((role) => {
      unless(isIn, {
        code: 400,
        msg: format('The role "%s" is not a valid member role for a folder', role)
      })(role, FoldersConstants.role.ALL_PRIORITY);
    }, roles);
  } catch (error) {
    return callback(error);
  }

  // Check if the current user can manage any of the specified managers
  const managerIds = _.chain(roles)
    .keys()
    .filter((principalId) => roles[principalId] === AuthzConstants.role.MANAGER)
    .value();
  GroupAPI.canManageAny(ctx, managerIds, (error, canManageAny) => {
    if (error && error.code !== 404) {
      return callback(error);
    }

    if (error) {
      return callback({ code: 400, msg: 'One or more target principals could not be found' });
    }

    if (!canManageAny) {
      // We only make the current user a manager of the folder if they cannot
      // manage any of the specified managers
      roles[ctx.user().id] = AuthzConstants.role.MANAGER;
    }

    const createFn = _.partial(FoldersDAO.createFolder, ctx.user().id, displayName, description, visibility);
    ResourceActions.create(ctx, roles, createFn, (error, folder, memberChangeInfo) => {
      if (error) {
        return callback(error);
      }

      FoldersAPI.emit(FoldersConstants.events.CREATED_FOLDER, ctx, folder, memberChangeInfo, (errs) => {
        if (errs) {
          return callback(_.first(errs));
        }

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

/**
 * Update a folder's metadata
 *
 * @param  {Context}        ctx                             Current execution context
 * @param  {String}         folderId                        The id of the folder to update
 * @param  {Object}         updates                         The updates that should be persisted on the folder
 * @param  {String}         [updates.displayName]           The new display name for the folder
 * @param  {String}         [updates.description]           The new description for the folder
 * @param  {String}         [updates.visibility]            The new visibility for the folder
 * @param  {Function}       callback                        Standard callback function
 * @param  {Object}         callback.err                    An error that occurred, if any
 * @param  {Folder}         callback.folder                 The updated folder
 */
const updateFolder = function (ctx, folderId, updates, callback) {
  const allVisibilities = _.values(AuthzConstants.visibility);

  try {
    unless(isLoggedInUser, {
      code: 401,
      msg: 'Anonymous users cannot create a folder'
    })(ctx);

    unless(isResourceId, {
      code: 400,
      msg: format('The folder id "%s" is not a valid resource id', folderId)
    })(folderId);

    unless(isObject, {
      code: 400,
      msg: 'Missing update information'
    })(updates, updates);

    // Ensure that at least one valid update field was provided
    const updateFields = _.keys(updates);
    const legalUpdateFields = [DISPLAY_NAME, DESCRIPTION, VISIBILITY];

    unless(isArrayNotEmpty, {
      code: 400,
      msg: 'One of ' + legalUpdateFields.join(', ') + ' must be provided'
    })(_.intersection(updateFields, legalUpdateFields));

    forEachObjIndexed((value, key) => {
      unless(isIn, {
        code: 400,
        msg: 'Unknown update field provided'
      })(key, legalUpdateFields);
    }, updates);

    const isThereDisplayName = Boolean(updates.displayName);
    unless(bothCheck(isThereDisplayName, isShortString), {
      code: 400,
      msg: 'A display name can be at most 1000 characters long'
    })(updates.displayName);

    const isThereDescription = Boolean(updates.description);
    unless(bothCheck(isThereDescription, isMediumString), {
      code: 400,
      msg: 'A description can be at most 10000 characters long'
    })(updates.description);

    const isThereVisibility = Boolean(updates.visibility);
    unless(bothCheck(isThereVisibility, isIn), {
      code: 400,
      msg: 'An invalid folder visibility option has been provided. Must be one of: ' + allVisibilities.join(', ')
    })(updates.visibility, allVisibilities);
  } catch (error) {
    return callback(error);
  }

  // Get the folder from storage to use for permission checks
  FoldersDAO.getFolder(folderId, (error, folder) => {
    if (error) {
      return callback(error);
    }

    // Ensure the current user can manage the folder
    AuthzPermissions.canManage(ctx, folder, (error_) => {
      if (error_) {
        return callback(error_);
      }

      // Update the folder's metadata
      FoldersDAO.updateFolder(folder, updates, (error, updatedFolder) => {
        if (error) {
          return callback(error);
        }

        FoldersAPI.emit(FoldersConstants.events.UPDATED_FOLDER, ctx, updatedFolder, folder);

        // Get the full folder profile for the updated folder
        return _getFullFolderProfile(ctx, updatedFolder, callback);
      });
    });
  });
};

/**
 * Update the content items in a folder
 *
 * @param  {Context}        ctx                             Current execution context
 * @param  {String}         folderId                        The id of the folder for which to update the visibility of the content items
 * @param  {String}         visibility                      The new visibility for the content items in the folder
 * @param  {Function}       callback                        Standard callback function
 * @param  {Object}         callback.err                    An error that occurred, if any
 * @param  {Content[]}      callback.failedContent          The content items that could not be updated
 */
const updateFolderContentVisibility = function (ctx, folderId, visibility, callback) {
  const allVisibilities = _.values(AuthzConstants.visibility);

  try {
    unless(isLoggedInUser, {
      code: 401,
      msg: 'Anonymous users cannot update the visibility of items in a folder'
    })(ctx);

    unless(isResourceId, {
      code: 400,
      msg: format('The folder id "%s" is not a valid resource id', folderId)
    })(folderId);

    unless(isNotEmpty, {
      code: 400,
      msg: 'Missing visibility value'
    })(visibility);

    unless(isIn, {
      code: 400,
      msg: 'An invalid folder visibility option has been provided. Must be one of: ' + allVisibilities.join(', ')
    })(visibility, allVisibilities);
  } catch (error) {
    return callback(error);
  }

  // Get the folder from storage to use for permission checks
  FoldersDAO.getFolder(folderId, (error, folder) => {
    if (error) {
      return callback(error);
    }

    // Ensure the current user can manage the folder
    AuthzPermissions.canManage(ctx, folder, (error_) => {
      if (error_) {
        return callback(error_);
      }

      // Apply the visibility on all the content items in the folder
      _updateFolderContentVisibility(ctx, folder, visibility, callback);
    });
  });
};

/**
 * Set the `newVisibility` visibility on all the content items in the folder. This function
 * assumes that the current user has manager rights on the given folder.
 *
 * Keep in mind that this is *NOT* a lightweight operation. The following actions will take place:
 *   -  The private folder library needs to be listed (to retrieve the content ids)
 *   -  All those content items need to be retrieved
 *   -  All those content items need to be updated
 *       -  Because each content item can have it own set of permissions, we need to check
 *          each content item at a time
 *       -  This means an authz check happens PER content item
 *   -  Each update triggers a search reindex of the content item
 *   -  Purges the folder content library
 *
 * @param  {Context}        ctx                         Current execution context
 * @param  {Folder}         folder                      The folder for which to update the visibility of the content items
 * @param  {String}         visibility                  The new visibility for the content items
 * @param  {Function}       callback                    Standard callback function
 * @param  {Object}         callback.err                An error that occurred, if any
 * @param  {Content[]}      callback.failedContent      The content items that could not be updated
 * @api private
 */
const _updateFolderContentVisibility = function (ctx, folder, visibility, callback) {
  // Get all the content items in this folder
  FoldersAuthz.getContentInFolder(folder, (error, contentIds) => {
    if (error) {
      log().error(
        { err: error, folderId: folder.id },
        'Got an error when updating the visibility of content in a folder'
      );
      return callback(error);
    }

    // Get the content objects
    ContentDAO.Content.getMultipleContentItems(contentIds, null, (error, contentItems) => {
      if (error) {
        log().error(
          { err: error, folderId: folder.id },
          'Got an error when updating the visibility of content in a folder'
        );
        return callback(error);
      }

      contentItems = _.chain(contentItems)
        // Remove null content items. This can happen if libraries are in an inconsistent
        // state. For example, if an item was deleted from the system but hasn't been removed
        // from the libraries, a `null` value would be returned by `getMultipleContentItems`
        .compact()

        // Grab those content items that don't have the desired visibility
        .filter((content) => content.visibility !== visibility)
        .value();

      const failedContent = [];

      /*!
       * Executed once all the content items have been updated
       */
      const done = function () {
        FoldersContentLibrary.purge(folder, (error_) => {
          if (error_) {
            return callback(error_);
          }

          // Sign the previews for each content item
          _.each(failedContent, (content) => {
            ContentUtil.augmentContent(ctx, content);
          });

          FoldersAPI.emit(
            FoldersConstants.events.UPDATED_FOLDER_VISIBILITY,
            ctx,
            folder,
            visibility,
            contentItems,
            failedContent
          );
          return callback(null, failedContent);
        });
      };

      /*!
       * Update a batch of content items
       */
      const updateBatch = function () {
        // If there are no items to update, we can move on
        if (_.isEmpty(contentItems)) {
          return done();
        }

        // Get the next batch of content items that should be updated
        const contentItemsToUpdate = contentItems.splice(0, 20);

        // We move on to the next batch once all content items in the current batch have been updated
        const contentUpdated = _.after(contentItemsToUpdate.length, updateBatch);

        // Try and update each content item
        _.each(contentItemsToUpdate, (content) => {
          _updateContentVisibility(ctx, content, visibility, (error_) => {
            if (error_) {
              failedContent.push(content);
            }

            contentUpdated();
          });
        });
      };

      // Update the first batch of content items
      updateBatch();
    });
  });
};

/**
 * Update the visibility of a given content item. This function will update
 * the visibility of the content item if, and only if, the current user has
 * manager rights on that item. It will *NOT* trigger any content-update activities
 *
 * @param  {Context}        ctx                         Current execution context
 * @param  {Folder}         content                     The content item for which to update the visibility
 * @param  {String}         visibility                  The new visibility of the content item
 * @param  {Function}       callback                    Standard callback function
 * @param  {Object}         callback.err                An error that occurred, if any
 * @api private
 */
const _updateContentVisibility = function (ctx, content, visibility, callback) {
  AuthzPermissions.canManage(ctx, content, (error) => {
    if (error) {
      return callback(error);
    }

    ContentDAO.Content.updateContent(content, { visibility }, true, (error) => {
      if (error) {
        return callback(error);
      }

      // Because we updated the visibility with the DAO, we'll need to
      // manually trigger a search reindexing event
      SearchAPI.postIndexTask('content', [{ id: content.id }], {
        resource: true
      });

      // Return to the caller
      return callback();
    });
  });
};

/**
 * Get a folder by its id
 *
 * @param  {Context}        ctx                 Current execution context
 * @param  {String}         folderId            The id of the folder to get
 * @param  {Function}       callback            Standard callback function
 * @param  {Object}         callback.err        An error that occurred, if any
 * @param  {Folder}         callback.folder     The folder identified by the given id
 */
const getFolder = function (ctx, folderId, callback) {
  try {
    unless(isResourceId, {
      code: 400,
      msg: format('The folder id "%s" is not a valid resource id', folderId)
    })(folderId);
  } catch (error) {
    return callback(error);
  }

  // Get the folder from storage to use for permission checks
  FoldersDAO.getFolder(folderId, (error, folder) => {
    if (error) {
      return callback(error);
    }

    // Ensure the current user can view the folder
    AuthzPermissions.canView(ctx, folder, (error_) => {
      if (error_) {
        return callback(error_);
      }

      // Sign the folder previews (if any)
      folder = _augmentFolder(ctx, folder);

      // Return the folder to the user
      return callback(null, folder);
    });
  });
};

/**
 * Get the full folder profile, which includes additional information about the relation of the
 * current user to the folder
 *
 * @param  {Context}        ctx                             Current execution context
 * @param  {String}         folderId                        The id of the folder whose full profile to get
 * @param  {Function}       callback                        Standard callback function
 * @param  {Object}         callback.err                    An error that occurred, if any
 * @param  {Folder}         callback.folder                 The basic profile of the folder, with some additional information provided
 * @param  {Boolean}        callback.folder.canManage       Whether or not the current user can manage the folder
 * @param  {Boolean}        callback.folder.canShare        Whether or not the current user can share the folder
 * @param  {Boolean}        callback.folder.canAddItem      Whether or not the current user can add a content item to the folder
 * @param  {User}           callback.folder.createdBy       The basic profile of the user who created the folder
 */
const getFullFolderProfile = function (ctx, folderId, callback) {
  try {
    unless(isResourceId, {
      code: 400,
      msg: format('The folder id "%s" is not a valid resource id', folderId)
    })(folderId);
  } catch (error) {
    return callback(error);
  }

  // Get the folder from storage to use for permissions checks
  FoldersDAO.getFolder(folderId, (error, folder) => {
    if (error) {
      return callback(error);
    }

    _getFullFolderProfile(ctx, folder, callback);
  });
};

/**
 * Get a full folder profile. Next to the basic folder profile, this will include the profile of the user who originally
 * created the profile, a set of properties that determine whether the folder can be managed, shared or content can be
 * added to it by the current user and finally a signature that allows the user to sign up for push notifications relating
 * to the folder
 *
 * @param  {Context}        ctx                             Current execution context
 * @param  {Folder}         folder                          The folder whose full profile to get
 * @param  {Function}       callback                        Standard callback function
 * @param  {Object}         callback.err                    An error that occurred, if any
 * @param  {Folder}         callback.folder                 The full folder profile
 * @param  {Boolean}        callback.folder.canManage       Whether or not the current user can manage the folder
 * @param  {Boolean}        callback.folder.canShare        Whether or not the current user can share the folder
 * @param  {Boolean}        callback.folder.canAddItem      Whether or not the current user can add a content item to the folder
 * @param  {User}           callback.folder.createdBy       The basic profile of the user who created the folder
 * @api private
 */
const _getFullFolderProfile = function (ctx, folder, callback) {
  AuthzPermissions.resolveEffectivePermissions(ctx, folder, (error, permissions) => {
    if (error) {
      return callback(error);
    }

    if (!permissions.canView) {
      return callback({ code: 401, msg: 'You are not authorized to view this folder' });
    }

    // Sign the folder previews (if any)
    folder = _augmentFolder(ctx, folder);

    folder.canManage = permissions.canManage;
    folder.canShare = permissions.canShare;
    folder.canAddItem = permissions.canManage;

    if (ctx.user()) {
      // Add a signature that can be used to subscribe to push notifications
      folder.signature = Signature.createExpiringResourceSignature(ctx, folder.id);
    }

    // Populate the creator of the folder
    PrincipalsUtil.getPrincipal(ctx, folder.createdBy, (error, creator) => {
      if (error) {
        log(ctx).warn(
          {
            err: error,
            userId: folder.createdBy,
            folderId: folder.id
          },
          'An error occurred getting the creator of a folder. Proceeding with empty user for full profile'
        );
      }

      if (creator) {
        folder.createdBy = creator;
      }

      FoldersAPI.emit(FoldersConstants.events.GET_FOLDER_PROFILE, ctx, folder);
      return callback(null, folder);
    });
  });
};

/**
 * Delete a folder
 *
 * @param  {Context}        ctx                         Current execution context
 * @param  {String}         folderId                    The id of the folder to delete
 * @param  {Boolean}        deleteContent               Whether or not to delete the content that's in the folder
 * @param  {Function}       callback                    Standard callback function
 * @param  {Object}         callback.err                An error that occurred, if any
 * @param  {Content[]}      callback.failedContent      The content items that could not be deleted
 */
const deleteFolder = function (ctx, folderId, deleteContent, callback) {
  try {
    unless(isResourceId, { code: 400, msg: 'a folder id must be provided' })(folderId);

    unless(isLoggedInUser, {
      code: 401,
      msg: 'You must be authenticated to delete a folder'
    })(ctx);
  } catch (error) {
    return callback(error);
  }

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

    AuthzPermissions.canManage(ctx, folder, (error_) => {
      if (error_) {
        return callback(error_);
      }

      _deleteFolder(folder, (error, memberIds) => {
        if (error) {
          return callback(error);
        }

        // eslint-disable-next-line no-unused-vars
        FoldersAPI.emit(FoldersConstants.events.DELETED_FOLDER, ctx, folder, memberIds, (errs) => {
          // Get all the content items that were in this folder so we can either
          // remove the content items or remove the authz link
          FoldersAuthz.getContentInFolder(folder, (error, contentIds) => {
            if (error) {
              return callback(error);
            }

            // Delete the content if we were instructed to do so
            if (deleteContent) {
              _deleteContent(ctx, contentIds, (failedContent) => {
                // Get the content objects that we couldn't delete
                ContentDAO.Content.getMultipleContentItems(failedContent, null, (error, contentItems) => {
                  if (error) {
                    return callback(error);
                  }

                  _.chain(contentItems)
                    // Remove null content items. This can happen if libraries are in an inconsistent
                    // state. For example, if an item was deleted from the system but hasn't been removed
                    // from the libraries, a `null` value would be returned by `getMultipleContentItems`
                    .compact()

                    // Sign the content items, note that we don't have to do any permission
                    // checks here, as the user had access to these content items by virtue
                    // of being a member of the folder
                    .each((contentItem) => {
                      ContentUtil.augmentContent(ctx, contentItem);
                    });

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

              // Otherwise remove the folder as an authz member of
              // all the content items
            } else {
              return _removeAuthzFolderFromContentItems(folder, contentIds, callback);
            }
          });
        });
      });
    });
  });
};

/**
 * Delete a folder. This function will not perform any access checks.
 *
 * @param  {Folder}         folder                  The folder that should be removed
 * @param  {Function}       callback                Standard callback function
 * @param  {Object}         callback.err            An error object, if any
 * @param  {String[]}       callback.memberIds      The ids of the principals who were members of this folder
 * @api private
 */
const _deleteFolder = function (folder, callback) {
  // Get all the principal ids who are a member of this folder
  AuthzAPI.getAllAuthzMembers(folder.groupId, (error, memberRoles) => {
    if (error) {
      return callback(error);
    }

    // Remove each principal from this folder
    const memberIds = _.pluck(memberRoles, 'id');
    const roleChanges = {};
    _.each(memberIds, (memberId) => {
      roleChanges[memberId] = false;
    });

    // Update the authz associations
    AuthzAPI.updateRoles(folder.groupId, roleChanges, (error_) => {
      if (error_) {
        return callback(error_);
      }

      // Remove the actual folder
      FoldersDAO.deleteFolder(folder.id, (error_) => {
        if (error_) {
          return callback(error_);
        }

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

/**
 * Delete a set of content items
 *
 * @param  {Context}        ctx                         Current execution context
 * @param  {String[]}       contentIds                  The ids of the content items to remove
 * @param  {Function}       callback                    Standard callback function
 * @param  {Content[]}      callback.failedContent      The content items that could not be deleted
 * @api private
 */
const _deleteContent = function (ctx, contentIds, callback, _failedContent) {
  _failedContent = _failedContent || [];

  // If there are no items to delete, we can return to the caller
  if (contentIds.length === 0) {
    return callback(_failedContent);
  }

  // In order to not overload the database with a massive amount of queries
  // we delete the content items in batches
  const contentIdsToDelete = contentIds.splice(0, 20);

  // Only proceed to the next batch if all content from this batch has been removed
  const done = _.after(contentIdsToDelete.length, () => {
    _deleteContent(ctx, contentIds, callback, _failedContent);
  });

  // Delete each content item
  _.each(contentIdsToDelete, (contentId) => {
    ContentAPI.deleteContent(ctx, contentId, (error) => {
      // Keep track of the content items that could not be deleted
      if (error) {
        _failedContent.push(contentId);
      }

      done();
    });
  });
};

/**
 * Remove the authz membership between a folder and a set of content items
 *
 * @param  {Folder}     folder          The folder for which to remove the authz membership
 * @param  {String[]}   contentIds      The content ids for which to remove the authz membership
 * @param  {Function}   callback        Standard callback function
 * @param  {Object}     callback.err    An error object, if any
 * @api private
 */
const _removeAuthzFolderFromContentItems = function (folder, contentIds, callback) {
  if (_.isEmpty(contentIds)) {
    return callback();
  }

  // In order to not overload the database with a massive amount of queries
  // we remove the authz link in batches
  const contentIdsToDelete = contentIds.splice(0, 20);

  // Only proceed to the next batch if all links in this batch have been removed
  const done = _.after(contentIdsToDelete.length, () => {
    _removeAuthzFolderFromContentItems(folder, contentIds, callback);
  });

  // Remove the link between the content items and the folder
  _.each(contentIdsToDelete, (contentId) => {
    // Remove the folder as an authz member
    const roleChange = {};
    roleChange[folder.groupId] = false;
    AuthzAPI.updateRoles(contentId, roleChange, (error) => {
      if (error) {
        log().error(
          {
            err: error,
            folderId: folder.id,
            folderGroupId: folder.groupId,
            contentId
          },
          'Unable to remove the folder from a group'
        );
      }

      done();
    });
  });
};

/**
 * List the members of a folder
 *
 * @param  {Context}        ctx                             Current execution context
 * @param  {String}         folderId                        The id of the folder whose members to get
 * @param  {String}         [start]                         A token that indicates where in the list to start returning members. Use the `nextToken` result from this method to determine where to start the next page of members
 * @param  {Number}         [limit]                         The maximum number of members to return
 * @param  {Function}       callback                        Standard callback function
 * @param  {Object}         callback.err                    An error that occurred, if any
 * @param  {Object[]}       callback.results                An array of objects indicating the members of the folder and their roles
 * @param  {User|Group}     callback.results[i].profile     The basic profile of the user or group who is a member of the folder
 * @param  {String}         callback.results[i].role        The role of the user or group on the folder
 * @param  {String}         callback.nextToken              The token to use for the next `start` value in order to get the next page of members. If this value is `null`, it indicates that there are no more members to page
 */
const getFolderMembers = function (ctx, folderId, start, limit, callback) {
  limit = OaeUtil.getNumberParam(limit, 10, 1);

  try {
    unless(isResourceId, {
      code: 400,
      msg: 'A folder id must be provided'
    })(folderId);
  } catch (error) {
    return callback(error);
  }

  getFolder(ctx, folderId, (error, folder) => {
    if (error) {
      return callback(error);
    }

    // Get the folder members
    AuthzAPI.getAuthzMembers(folder.groupId, start, limit, (error, memberRoles, nextToken) => {
      if (error) {
        return callback(error);
      }

      // Get the basic profiles for all of these principals
      PrincipalsUtil.getPrincipals(ctx, _.pluck(memberRoles, 'id'), (error, memberProfiles) => {
        if (error) {
          return callback(error);
        }

        // Merge the member profiles and roles into a single object
        const memberList = _.map(memberRoles, (memberRole) => ({
          profile: memberProfiles[memberRole.id],
          role: memberRole.role
        }));

        return callback(null, memberList, nextToken);
      });
    });
  });
};

/**
 * Get the invitations for the specified folder
 *
 * @param  {Context}        ctx                     Current execution context
 * @param  {String}         folderId                The id of the folder to get the invitations for
 * @param  {Function}       callback                Standard callback function
 * @param  {Object}         callback.err            An error that occurred, if any
 * @param  {Invitation[]}   callback.invitations    The invitations
 */
const getFolderInvitations = function (ctx, folderId, callback) {
  try {
    unless(isResourceId, {
      code: 400,
      msg: 'A valid resource id must be specified'
    })(folderId);
  } catch (error) {
    return callback(error);
  }

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

    return AuthzInvitations.getAllInvitations(ctx, folder, callback);
  });
};

/**
 * Resend an invitation email for the specified email and folder
 *
 * @param  {Context}        ctx             Current execution context
 * @param  {String}         folderId        The id of the folder to which the email was invited
 * @param  {String}         email           The email that was previously invited
 * @param  {Function}       callback        Standard callback function
 * @param  {Object}         callback.err    An error that occurred, if any
 */
const resendFolderInvitation = function (ctx, folderId, email, callback) {
  try {
    unless(isResourceId, {
      code: 400,
      msg: 'A valid resource id must be specified'
    })(folderId);
  } catch (error) {
    return callback(error);
  }

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

    return ResourceActions.resendInvitation(ctx, folder, email, callback);
  });
};

/**
 * Share a folder with a set of users and groups. All users and groups who are shared the
 * folder will be given the `member` role. However, if they already have a different role, the
 * existing role will not be changed
 *
 * @param  {Context}    ctx             Current execution context
 * @param  {String}     folderId        The id of the folder to share
 * @param  {String[]}   principalIds    The ids of the users and groups with whom to share the folder
 * @param  {Function}   callback        Standard callback function
 * @param  {Object}     callback.err    An error that occurred, if any
 */
const shareFolder = function (ctx, folderId, principalIds, callback) {
  try {
    unless(isLoggedInUser, {
      code: 401,
      msg: 'You have to be logged in to be able to share a folder'
    })(ctx);

    unless(isResourceId, {
      code: 400,
      msg: 'A valid folder id must be provided'
    })(folderId);
  } catch (error) {
    return callback(error);
  }

  // Ensure the folder exists
  FoldersDAO.getFolder(folderId, (error, folder) => {
    if (error) {
      return callback(error);
    }

    // Perform the share operation
    ResourceActions.share(ctx, folder, principalIds, AuthzConstants.role.VIEWER, (error, memberChangeInfo) => {
      if (error) {
        return callback(error);
      }

      if (_.isEmpty(memberChangeInfo.changes)) {
        // If no new members were actually added, we don't have to do anything more
        return callback();
      }

      FoldersAPI.emit(FoldersConstants.events.UPDATED_FOLDER_MEMBERS, ctx, folder, memberChangeInfo, {}, (errs) => {
        if (errs) {
          return callback(_.first(errs));
        }

        return callback();
      });
    });
  });
};

/**
 * Set permissions to the folder. This is similar to sharing a folder, however rather than
 * only giving users and groups the `member` role, other roles can be applied and also users and
 * groups can be removed from the folder membership
 *
 * @param  {Context}    ctx             Current execution context
 * @param  {String}     folderId        The id of the folder whose permissions to set
 * @param  {Object}     changes         An object whose key is the user or group id to set on the folder, and the value is the role you wish them to have. If the role of a user is set to `false`, then it indicates to remove the user from the folder
 * @param  {Function}   callback        Standard callback function
 * @param  {Object}     callback.err    An error that occurred, if any
 */
const setFolderPermissions = function (ctx, folderId, changes, callback) {
  try {
    unless(isLoggedInUser, {
      code: 401,
      msg: 'You have to be logged in to be able to change folder permissions'
    })(ctx);

    unless(isResourceId, {
      code: 400,
      msg: 'A valid folder id must be provided'
    })(folderId);

    forEachObjIndexed((role /* , principalId */) => {
      unless(isValidRoleChange, {
        code: 400,
        msg: 'The role change: ' + role + ' is not a valid value. Must either be a string, or false'
      })(role);

      const thereIsRole = Boolean(role);
      unless(bothCheck(thereIsRole, isIn), {
        code: 400,
        msg:
          'The role: "' +
          role +
          '" is not a valid value. Must be one of: ' +
          FoldersConstants.role.ALL_PRIORITY.join(', ') +
          '; or false'
      })(role, FoldersConstants.role.ALL_PRIORITY);
    }, changes);
  } catch (error) {
    return callback(error);
  }

  // Get the folder object, throwing an error if it doesn't exist, but not applying permissions checks
  FoldersDAO.getFolder(folderId, (error, folder) => {
    if (error) {
      return callback(error);
    }

    // Set the folder roles
    ResourceActions.setRoles(ctx, folder, changes, (error, memberChangeInfo) => {
      if (error) {
        return callback(error);
      }

      if (_.isEmpty(memberChangeInfo.changes)) {
        return callback();
      }

      FoldersAPI.emit(FoldersConstants.events.UPDATED_FOLDER_MEMBERS, ctx, folder, memberChangeInfo, {}, (errs) => {
        if (errs) {
          return callback(_.first(errs));
        }

        return callback();
      });
    });
  });
};

/**
 * Add a set of content items to a folder
 *
 * @param  {Context}    ctx             Current execution context
 * @param  {String}     folderId        The id of the folder to which to add the content items
 * @param  {String[]}   contentIds      The ids of the content items to add to the folder
 * @param  {Function}   callback        Standard callback function
 * @param  {Object}     callback.err    An error that occurred, if any
 */
const addContentItemsToFolder = function (ctx, folderId, contentIds, callback) {
  try {
    unless(isLoggedInUser, {
      code: 401,
      msg: 'You have to be authenticated to be able to add an item to a folder'
    })(ctx);

    unless(isResourceId, {
      code: 400,
      msg: 'A valid folder id must be provided'
    })(folderId);

    unless(isArray, {
      code: 400,
      msg: 'Must specify at least one content item to add'
    })(contentIds);

    unless(isArrayNotEmpty, {
      code: 400,
      msg: 'You must specify at least one content item to add'
    })(_.values(contentIds));

    // Ensure each content id is valid
    forEachObjIndexed((contentId) => {
      unless(isResourceId, {
        code: 400,
        msg: format('The id "%s" is not a valid content id', contentId)
      })(contentId);
    }, contentIds);
  } catch (error) {
    return callback(error);
  }

  // Get the folder to which we're trying to add the content items
  FoldersDAO.getFolder(folderId, (error, folder) => {
    if (error) {
      return callback(error);
    }

    // Get the content profiles of all items being added for permission checks
    ContentDAO.Content.getMultipleContentItems(contentIds, null, (error, contentItems) => {
      if (error) {
        return callback(error);
      }

      // Return an error if one or more content items could not be found
      contentItems = _.compact(contentItems);
      if (contentItems.length !== contentIds.length) {
        return callback({
          code: 404,
          msg: 'One or more of the specified content items do not exist'
        });
      }

      // Determine if the content items can be added to the folder
      FoldersAuthz.canAddItemsToFolder(ctx, folder, contentItems, (error_) => {
        if (error_ && error_.code !== 401) {
          return callback(error_);
        }

        if (error_ && !_.isEmpty(error_.invalidContentIds)) {
          return callback({
            code: 401,
            msg: format(
              'You are not authorized to add the following items to the folder: %s',
              error_.invalidContentIds.join(', ')
            )
          });
        }

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

        // Add all the items to the folder
        return _addContentItemsToFolderLibrary(ctx, 'add-to-folder', folder, [...contentItems], callback);
      });
    });
  });
};

/**
 * Add content items to a folder. Note that this method does *NOT* perform any
 * permission or validation checks.
 *
 * @param  {String}         actionContext       One of `content-create` or `add-to-folder`
 * @param  {Folder}         folder              The folder to which to add the content items
 * @param  {Content[]}      contentItems        The content items to add
 * @param  {Function}       callback            Standard callback function
 * @param  {Object}         callback.err        An error object, if any
 * @api private
 */
const _addContentItemsToFolderLibrary = function (ctx, actionContext, folder, contentItems, callback) {
  // First, make the folder a member of all the content items
  _addContentItemsToAuthzFolder(folder, [...contentItems], (error) => {
    if (error) {
      return callback(error);
    }

    // Second, add the content items in the folder's library buckets
    FoldersContentLibrary.insert(folder, contentItems, (error) => {
      if (error) {
        log(ctx).warn(
          {
            err: error,
            folderId: folder.id,
            contentIds: _.pluck(contentItems, 'id')
          },
          'An error occurred while inserting content items into a folder library'
        );
      }

      FoldersAPI.emit(FoldersConstants.events.ADDED_CONTENT_ITEMS, ctx, actionContext, folder, contentItems);

      return callback(error);
    });
  });
};

/**
 * Remove a set of content items from a folder
 *
 * @param  {Context}    ctx             Current execution context
 * @param  {String}     folderId        The id of the folder from which to remove the content items
 * @param  {String[]}   contentIds      The ids of the content items to remove from the folder
 * @param  {Function}   callback        Standard callback function
 * @param  {Object}     callback.err    An error that occurred, if any
 */
const removeContentItemsFromFolder = function (ctx, folderId, contentIds, callback) {
  try {
    unless(isLoggedInUser, {
      code: 401,
      msg: 'You have to be authenticated to be able to remove an item from a folder'
    })(ctx);

    unless(isResourceId, {
      code: 400,
      msg: 'A valid folder id must be provided'
    })(folderId);

    unless(isArray, {
      code: 400,
      msg: 'You must specify at least one content item to remove'
    })(contentIds);

    unless(isArrayNotEmpty, {
      code: 400,
      msg: 'You must specify at least one content item to remove'
    })(_.values(contentIds));

    // Ensure each content id is valid
    forEachObjIndexed((contentId) => {
      unless(isResourceId, {
        code: 400,
        msg: format('The id "%s" is not a valid content id', contentId)
      })(contentId);
    }, contentIds);
  } catch (error) {
    return callback(error);
  }

  // Get the folder from which we're trying to remove the content items
  FoldersDAO.getFolder(folderId, (error, folder) => {
    if (error) {
      return callback(error);
    }

    // Ensure that the user is allowed to remove items from this folder
    AuthzPermissions.canManage(ctx, folder, (error_) => {
      if (error_) {
        return callback(error_);
      }

      // Get the content profiles of all items being removed
      ContentDAO.Content.getMultipleContentItems(contentIds, null, (error, contentItems) => {
        if (error) {
          return callback(error);
        }

        // Return an error if one or more content items could not be found
        contentItems = _.compact(contentItems);
        if (contentItems.length !== contentIds.length) {
          return callback({
            code: 404,
            msg: 'One or more of the specified content items do not exist'
          });
        }

        // Remove all the items from the folder
        _removeContentItemsFromFolder(folder, [...contentIds], (error_) => {
          if (error_) {
            return callback(error_);
          }

          FoldersContentLibrary.remove(folder, contentItems, (error_) => {
            if (error_) {
              log(ctx).warn(
                {
                  err: error_,
                  folderId: folder.id,
                  contentIds
                },
                'An error occurred while removing content items from a folder library'
              );
            }

            FoldersAPI.emit(FoldersConstants.events.REMOVED_CONTENT_ITEMS, ctx, folder, contentItems);
            return callback();
          });
        });
      });
    });
  });
};

/**
 * List a user or group library of folders
 *
 * @param  {Context}        ctx                     Current execution context
 * @param  {String}         principalId             The id of the user or group whose library of folders to list
 * @param  {String}         [start]                 A token that indicates where in the list to start returning folders. Use the `nextToken` result from this method to determine where to start the next page of folders
 * @param  {Number}         [limit]                 The maximum number of folders to return
 * @param  {Function}       callback                Standard callback function
 * @param  {Object}         callback.err            An error that occurred, if any
 * @param  {Folder[]}       callback.folders        The list of folders
 * @param  {String}         callback.nextToken      The token to use for the next `start` value in order to get the next page of folders. If this value is `null`, it indicates that there are no more folders to page
 */
const getFoldersLibrary = function (ctx, principalId, start, limit, callback) {
  limit = OaeUtil.getNumberParam(limit, 10, 1);

  try {
    unless(isPrincipalId, {
      code: 400,
      msg: 'A user or group id must be provided'
    })(principalId);
  } catch (error) {
    return callback(error);
  }

  // Get the principal
  PrincipalsDAO.getPrincipal(principalId, (error, principal) => {
    if (error) {
      return callback(error);
    }

    // Determine which library visibility the current user should receive
    LibraryAPI.Authz.resolveTargetLibraryAccess(ctx, principal.id, principal, (error, hasAccess, visibility) => {
      if (error) {
        return callback(error);
      }

      if (!hasAccess) {
        return callback({ code: 401, msg: 'You do not have have access to this library' });
      }

      // Get the folder ids from the library index
      FoldersFoldersLibrary.list(principal, visibility, { start, limit }, (error, folderIds, nextToken) => {
        if (error) {
          return callback(error);
        }

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

          folders = _.map(folders, (folder) => _augmentFolder(ctx, folder));

          // Emit an event indicating that the folder library has been retrieved
          FoldersAPI.emit(
            FoldersConstants.events.GET_FOLDERS_LIBRARY,
            ctx,
            principalId,
            visibility,
            start,
            limit,
            folders
          );

          return callback(null, folders, nextToken);
        });
      });
    });
  });
};

/**
 * Get the folders that are managed by the current user
 *
 * @param  {Context}        ctx                     Current execution context
 * @param  {Function}       callback                Standard callback function
 * @param  {Object}         callback.err            An error that occurred, if any
 * @param  {Folder[]}       callback.folders        The folders which the current user can manage
 */
const getManagedFolders = function (ctx, callback) {
  if (!ctx.user()) {
    return callback({ code: 401, msg: 'Anonymous users cannot manage folders' });
  }

  // Get all the groups this user is a member of
  AuthzAPI.getRolesForPrincipalAndResourceType(ctx.user().id, 'g', null, 1000, (error, roles) => {
    if (error) {
      return callback(error);
    }

    // Get all the groups the user manages
    const managedGroupIds = _.chain(roles)
      .filter((role) => role.role === AuthzConstants.role.MANAGER)
      .map((role) => role.id)
      .value();

    // Get all the folders that match these groups
    FoldersDAO.getFoldersByGroupIds(managedGroupIds, (error, folders) => {
      if (error) {
        return callback(error);
      }

      folders = _.chain(folders)
        // Because we retrieved all the folders that this user manages
        // we sort them, so they can be displayed immediately
        .sort((a, b) => a.displayName.localeCompare(b.displayName))

        // Augment the folder with the signed preview urls
        .map((folder) => _augmentFolder(ctx, folder))
        .value();

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

/**
 * Remove a folder from a principal's library
 *
 * @param  {Context}        ctx                 Current execution context
 * @param  {String}         principalId         The principal id of the library from which to remove this folder
 * @param  {String}         folderId            The id of the folder that should be removed
 * @param  {Function}       callback            Standard callback function
 * @param  {Object}         callback.err        An error that occurred, if any
 */
const removeFolderFromLibrary = function (ctx, principalId, folderId, callback) {
  try {
    unless(isLoggedInUser, {
      code: 401,
      msg: 'You must be authenticated to remove a folder from a library'
    })(ctx);

    unless(isPrincipalId, {
      code: 400,
      msg: 'A user or group id must be provided'
    })(principalId);

    unless(isResourceId, {
      code: 400,
      msg: 'A valid folder id must be provided'
    })(folderId);
  } catch (error) {
    return callback(error);
  }

  // Make sure the folder exists
  FoldersDAO.getFolder(folderId, (error, folder) => {
    if (error) {
      return callback(error);
    }

    // Make sure the target user exists
    PrincipalsDAO.getPrincipal(principalId, (error, principal) => {
      if (error) {
        return callback(error);
      }

      // Verify the current user has access to remove folders from the target library
      AuthzPermissions.canRemoveRole(ctx, principal, folder, (error, memberChangeInfo) => {
        if (error) {
          return callback(error);
        }

        // All validation checks have passed, finally persist the role change and update the library
        AuthzAPI.updateRoles(folder.groupId, memberChangeInfo.changes, (error_) => {
          if (error_) {
            return callback(error_);
          }

          FoldersAPI.emit(FoldersConstants.events.UPDATED_FOLDER_MEMBERS, ctx, folder, memberChangeInfo, {}, (errs) => {
            if (errs) {
              return callback(_.first(errs));
            }

            return callback();
          });
        });
      });
    });
  });
};

/**
 * List the library of content items that have been added to a folder
 *
 * @param  {Context}        ctx                     Current execution context
 * @param  {String}         folderId                The id of the folder whose content library to list
 * @param  {String}         [start]                 A token that indicates where in the list to start returning content items. Use the `nextToken` result from this method to determine where to start the next page of content items
 * @param  {Number}         [limit]                 The maximum number of content items to return
 * @param  {Function}       callback                Standard callback function
 * @param  {Object}         callback.err            An error that occurred, if any
 * @param  {Folder[]}       callback.contentItems   The list of content items in the folder library
 * @param  {String}         callback.nextToken      The token to use for the next `start` value in order to get the next page of content items. If this value is `null`, it indicates that there are no more content items to page
 */
const getFolderContentLibrary = function (ctx, folderId, start, limit, callback) {
  limit = OaeUtil.getNumberParam(limit, 10, 1);

  try {
    unless(isResourceId, {
      code: 400,
      msg: 'A folder id must be provided'
    })(folderId);
  } catch (error) {
    return callback(error);
  }

  // Get the folder
  FoldersDAO.getFolder(folderId, (error, folder) => {
    if (error) {
      return callback(error);
    }

    // Determine which library visibility the current user should receive
    LibraryAPI.Authz.resolveTargetLibraryAccess(ctx, folder.groupId, folder, (error, hasAccess, visibility) => {
      if (error) {
        return callback(error);
      }

      if (!hasAccess) {
        return callback({ code: 401, msg: 'You do not have access to this folder' });
      }

      FoldersContentLibrary.list(folder, visibility, { start, limit }, (error, contentIds, nextToken) => {
        if (error) {
          return callback(error);
        }

        ContentDAO.Content.getMultipleContentItems(contentIds, null, (error, contentItems) => {
          if (error) {
            return callback(error);
          }

          contentItems = _.chain(contentItems)
            // Remove null content items. This can happen if libraries are in an inconsistent
            // state. For example, if an item was deleted from the system but hasn't been removed
            // from the libraries, a `null` value would be returned by `getMultipleContentItems`
            .compact()

            // Augment each content item with its signed preview urls
            .each((contentItem) => {
              ContentUtil.augmentContent(ctx, contentItem);
            })
            .value();

          return callback(null, contentItems, nextToken);
        });
      });
    });
  });
};

/// ///////////
// Comments //
/// ///////////

/**
 * Create a new message in a folder. If `replyToCreatedTimestamp` is specified, the message will be
 * a reply to the message in the folder identified by that timestamp.
 *
 * @param  {Context}            ctx                             Current execution context
 * @param  {String}             folderId                        The id of the folder to which to post the message
 * @param  {String}             body                            The body of the message
 * @param  {String|Number}      [replyToCreatedTimestamp]       The timestamp of the message to which this message is a reply. Not specifying this will create a top level message
 * @param  {Function}           callback                        Standard callback function
 * @param  {Object}             callback.err                    An error that occurred, if any
 * @param  {Message}            callback.message                The created message
 */
const createMessage = function (ctx, folderId, body, replyToCreatedTimestamp, callback) {
  try {
    unless(isLoggedInUser, {
      code: 401,
      msg: 'Only authenticated users can post to folders'
    })(ctx);

    unless(isResourceId, {
      code: 400,
      msg: 'Invalid folder id provided'
    })(folderId);

    unless(isNotEmpty, {
      code: 400,
      msg: 'A message body must be provided'
    })(body);

    unless(isLongString, {
      code: 400,
      msg: 'A message body can only be 100000 characters long'
    })(body);

    const timestampIsDefined = Boolean(replyToCreatedTimestamp);
    unless(bothCheck(timestampIsDefined, isInt), {
      code: 400,
      msg: 'Invalid reply-to timestamp provided'
    })(replyToCreatedTimestamp);
  } catch (error) {
    return callback(error);
  }

  // Get the folder from storage to use for permission checks
  FoldersDAO.getFolder(folderId, (error, folder) => {
    if (error) {
      return callback(error);
    }

    // Ensure the current user can view the folder
    AuthzPermissions.canInteract(ctx, folder, (error_) => {
      if (error_) {
        return callback(error_);
      }

      // Create the message
      MessageBoxAPI.createMessage(
        folderId,
        ctx.user().id,
        body,
        { replyToCreated: replyToCreatedTimestamp },
        (error, message) => {
          if (error) {
            return callback(error);
          }

          // Get a UI-appropriate representation of the current user
          PrincipalsUtil.getPrincipal(ctx, ctx.user().id, (error, createdBy) => {
            if (error) {
              return callback(error);
            }

            message.createdBy = createdBy;

            // The message has been created in the database so we can emit the `createdComment` event
            FoldersAPI.emit(FoldersConstants.events.CREATED_COMMENT, ctx, message, folder, (errs) => {
              if (errs) {
                return callback(_.first(errs));
              }

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

/**
 * Delete a message in a folder. Managers of the folder can delete all messages while people that have access
 * to the folder can only delete their own messages. Therefore, anonymous users will never be able to delete messages.
 *
 * @param  {Context}        ctx                         Current execution context
 * @param  {String}         folderId                    The id of the folder from which to delete the message
 * @param  {Number}         messageCreatedDate          The timestamp of the message that should be deleted
 * @param  {Function}       callback                    Standard callback function
 * @param  {Object}         callback.err                An error that occurred, if any
 * @param  {Comment}        [callback.softDeleted]      When the message has been soft deleted (because it has replies), a stripped down message object representing the deleted message will be returned, with the `deleted` parameter set to `false`. If the message has been deleted from the index, no message object will be returned
 */
const deleteMessage = function (ctx, folderId, messageCreatedDate, callback) {
  try {
    unless(isLoggedInUser, {
      code: 401,
      msg: 'Only authenticated users can delete messages'
    })(ctx);

    unless(isResourceId, {
      code: 400,
      msg: 'A folder id must be provided'
    })(folderId);

    unless(isInt, {
      code: 400,
      msg: 'A valid integer message created timestamp must be specified'
    })(messageCreatedDate);
  } catch (error) {
    return callback(error);
  }

  // Get the folder from storage to use for permission checks
  FoldersDAO.getFolder(folderId, (error, folder) => {
    if (error) {
      return callback(error);
    }

    // Ensure that the message exists. We also need it so we can make sure we have access to delete it
    MessageBoxAPI.getMessages(folderId, [messageCreatedDate], { scrubDeleted: false }, (error, messages) => {
      if (error) {
        return callback(error);
      }

      if (!messages[0]) {
        return callback({ code: 404, msg: 'The specified message does not exist' });
      }

      const message = messages[0];

      // Determine if we have access to delete the folder message
      AuthzPermissions.canManageMessage(ctx, folder, message, (error_) => {
        if (error_) {
          return callback(error_);
        }

        // Delete the message using the "leaf" method, which will SOFT delete if the message has replies, or HARD delete if it does not
        MessageBoxAPI.deleteMessage(
          folderId,
          messageCreatedDate,
          { deleteType: MessageBoxConstants.deleteTypes.LEAF },
          (error, deleteType, deletedMessage) => {
            if (error) {
              return callback(error);
            }

            FoldersAPI.emit(FoldersConstants.events.DELETED_COMMENT, ctx, message, folder, deleteType);

            // If a soft-delete occurred, we want to inform the consumer of the soft-delete message model
            if (deleteType === MessageBoxConstants.deleteTypes.SOFT) {
              return callback(null, deletedMessage);
            }

            return callback();
          }
        );
      });
    });
  });
};

/**
 * Get the messages in a folder
 *
 * @param  {Context}        ctx                     Current execution context
 * @param  {String}         folderId                The id of the folder for which to get the messages
 * @param  {String}         [start]                 The `threadKey` of the message from which to start retrieving messages (exclusively). By default, will start fetching from the most recent message
 * @param  {Number}         [limit]                 The maximum number of results to return. Default: 10
 * @param  {Function}       callback                Standard callback function
 * @param  {Object}         callback.err            An error that occurred, if any
 * @param  {Message[]}      callback.messages       The messages in the folder. Of the type `MessageBoxModel#Message`
 * @param  {String}         callback.nextToken      The value to provide in the `start` parameter to get the next set of results
 */
const getMessages = function (ctx, folderId, start, limit, callback) {
  limit = OaeUtil.getNumberParam(limit, 10, 1);

  try {
    unless(isResourceId, {
      code: 400,
      msg: 'Must provide a valid folder id'
    })(folderId);

    unless(isANumber, {
      code: 400,
      msg: 'Must provide a valid limit'
    })(limit);
  } catch (error) {
    return callback(error);
  }

  // Get the folder from storage to use for permission checks
  FoldersDAO.getFolder(folderId, (error, folder) => {
    if (error) {
      return callback(error);
    }

    // Ensure the current user can view the folder
    AuthzPermissions.canView(ctx, folder, (error_) => {
      if (error_) {
        return callback(error_);
      }

      // Fetch the messages from the message box
      MessageBoxAPI.getMessagesFromMessageBox(folderId, start, limit, null, (error, messages, nextToken) => {
        if (error) {
          return callback(error);
        }

        // Get the unique user ids from the messages so we can retrieve their full user objects
        const userIds = _.chain(messages)
          .map((message) => message.createdBy)
          .uniq()
          .compact()
          .value();

        // Get the basic principal profiles of the messagers
        PrincipalsUtil.getPrincipals(ctx, userIds, (error, users) => {
          if (error) {
            return callback(error);
          }

          // Attach the user profiles to the message objects
          _.each(messages, (message) => {
            if (users[message.createdBy]) {
              message.createdBy = users[message.createdBy];
            }
          });

          return callback(error, messages, nextToken);
        });
      });
    });
  });
};

/**
 * Recursively add the given list of content items to the given folder. This method is
 * destructive to the `contentItems` parameter as it iterates
 *
 * @param  {Folder}         folder          The folder to which to add the content items
 * @param  {Content[]}      contentItems    The content items to add to the folder
 * @param  {Function}       callback        Standard callback function
 * @param  {Object}         callback.err    An error that occurred, if any
 * @api private
 */
const _addContentItemsToAuthzFolder = function (folder, contentItems, callback) {
  if (_.isEmpty(contentItems)) {
    return callback();
  }

  const roleChange = {};
  roleChange[folder.groupId] = AuthzConstants.role.VIEWER;

  const contentItem = contentItems.pop();
  AuthzAPI.updateRoles(contentItem.id, roleChange, (error) => {
    if (error) {
      return callback(error);
    }

    return _addContentItemsToAuthzFolder(folder, contentItems, callback);
  });
};

/**
 * Recursively remove the given list of content items from the given folder. This method is
 * destructive to the `contentIds` parameter as it iterates
 *
 * @param  {Folder}         folder          The folder from which to remove the content items
 * @param  {String[]}       contentIds      The ids of the content items to remove from the folder
 * @param  {Function}       callback        Standard callback function
 * @param  {Object}         callback.err    An error that occurred, if any
 * @api private
 */
const _removeContentItemsFromFolder = function (folder, contentIds, callback) {
  if (_.isEmpty(contentIds)) {
    return callback();
  }

  const roleChange = {};
  roleChange[folder.groupId] = false;

  const contentId = contentIds.pop();
  AuthzAPI.updateRoles(contentId, roleChange, (error) => {
    if (error) {
      return callback(error);
    }

    return _removeContentItemsFromFolder(folder, contentIds, callback);
  });
};

/**
 * Augment the folder object by signing the preview uris
 *
 * @param  {Context}    ctx         Current execution context
 * @param  {Folder}     folder      The folder object to augment
 * @return {Folder}                 The augmented folder holding the signed urls
 * @api private
 */
const _augmentFolder = function (ctx, folder) {
  if (folder.previews && folder.previews.thumbnailUri) {
    folder.previews.thumbnailUrl = ContentUtil.getSignedDownloadUrl(ctx, folder.previews.thumbnailUri);
  }

  if (folder.previews && folder.previews.wideUri) {
    folder.previews.wideUrl = ContentUtil.getSignedDownloadUrl(ctx, folder.previews.wideUri);
  }

  return folder;
};

export {
  createFolder,
  updateFolder,
  updateFolderContentVisibility,
  getFolder,
  getFullFolderProfile,
  deleteFolder,
  getFolderMembers,
  getFolderInvitations,
  resendFolderInvitation,
  shareFolder,
  setFolderPermissions,
  addContentItemsToFolder,
  _addContentItemsToFolderLibrary,
  removeContentItemsFromFolder,
  getFoldersLibrary,
  getManagedFolders,
  removeFolderFromLibrary,
  getFolderContentLibrary,
  createMessage,
  deleteMessage,
  getMessages,
  FoldersAPI as emitter
};