[KnackSteem] - More moderation tools and stats endpoints

in #utopian-io6 years ago

Repository & Pull Request

https://github.com/knacksteem/knacksteem-api/
https://github.com/knacksteem/knacksteem-api/pull/9

What is KnackSteem?

"Do you have any talent? If yes! then KnackSteem is for you."
"Rewards people with talents, it can be any talent, anything you know how to do best is highly welcome on the platform. "
Source: Discord Channel :D

Screen Shot 2018-06-29 at 3.18.29 PM.png

Changes Made

Reservations to moderate a post

To avoid the conflict of two moderators moderating the same post at the same time, a reservation system was added. How does it work? Easy. A moderator selects a post to reserve from the list of posts pending for moderation. When this reservation is made, this post is hidden from the list of posts pending for moderation for 1 hour. If the moderator does not moderate the post in 1 hour after its reservation, the reservation status will be void and another moderator will be able to moderate this post. What if a moderator somehow tries to moderate the reserved post? This case was addressed as well. Even if we are not showing this post in the list, a moderator doing a request directly to the server will be able to do this change. To avoid this scenario, I added a middleware to check if the selected post is reserved and to check if the reservation time is expired. If the reservation time is expired, the reserved status is voided.

Related code (Reserve post controller):

/**
 * Method to reserve a post for moderation.
 * @public
 * @author Jayser Mendez
 */
exports.reservePost = async (req, res, next) => {
  try {
    // Get the moderator username from the last middleware.
    const moderator = res.locals.username;

    // Grab the post from the locals
    const { post } = res.locals;

    // Set a temp date with the current date
    const d1 = new Date();
    // Initialize another new date
    const reservedUntil = new Date(d1);
    // Add one hour to the second date using the first date.
    reservedUntil.setHours(d1.getHours() + 1);

    // Update the post with the reservation data
    await post.update({
      'moderation.reserved': true, // Can be voided if the reservedUntil is expired
      'moderation.reservedBy': moderator,
      'moderation.reservedUntil': reservedUntil, // Only 1 hour
    });

    // If the post is returned, it means that it was edited correctly. Let the client know it.
    if (post) {
      return res.status(httpStatus.OK).send({
        status: httpStatus.OK,
        message: 'Post reserved correctly.',
      });
    }

    // Otherwise, the post is not found, let the client know.
    return res.status(httpStatus.NOT_FOUND).send({
      status: httpStatus.NOT_FOUND,
      message: 'This posts has not been found.',
    });

  // Catch any error
  } catch (err) {
    // Catch errors here.
    return next({
      status: httpStatus.INTERNAL_SERVER_ERROR,
      message: 'Opps! Something is wrong in our server. Please report it to the administrator.',
      error: err,
    });
  }
};

Related code (Middleware):

const httpStatus = require('http-status');
const Post = require('../models/post.model');

/**
 * Check if a post is already reserved
 * @author Jayser Mendez.
 * @public
 */
const isPostReserved = async (req, res, next) => {
  try {
    // Grab the permlink from the post request
    const { permlink } = req.body;

    // Ask database if the post exist
    const post = await Post.findOne({ permlink });

    // If this post is already reserved, prevent the moderator to reserve it again.
    // If the reservation is expired, the reserved field is void.
    if (post.moderation.reserved === true && Date.now() < post.moderation.reservedUntil) {
      return next({
        status: httpStatus.UNAUTHORIZED,
        message: `This post is already reserved by ${post.moderation.reservedBy}. You cannot moderate it by now.`,
      });
    }

    // Since the post is found and it is not reserved, pass it to the next middleware.
    res.locals.post = post;

    // If the post is not reserved, move to the next middleware.
    return next();

  // Catch any possible error.
  } catch (err) {
    // Catch errors here.
    return next({
      status: httpStatus.INTERNAL_SERVER_ERROR,
      message: 'Opps! Something is wrong in our server. Please report it to the administrator.',
      error: err,
    });
  }
};

module.exports = isPostReserved;
Reduce the number of queries to the database

Before, I was not sharing the queried object from the middleware to the controller or another middleware. It was causing the server to do multiple requests against the same data (which can lead to a decrease in the performance). To address this issue, I decide to move data between middlewares using locals variable of this request. By saving the object in a local variable in this request, I am able to read this variable in any middleware after it is defined. For instance, if I check if a post is moderated in the isModerated middleware, I can just assign the object result into a local variable and update it in the next middleware without needing to do the same query again. This was done by adding a simple line of code in the middleware. For instance:

// Ask database if the post exists
const post = await Post.findOne({ permlink });
...
// Since the post is found and it is not reserved, pass it to the next middleware.
res.locals.post = post;

And hence, in the next middleware, I don't need to ask MongoDB for the post object again. I only need to refer to res.locals.post.

What I like about locals in the request is that each variable created is destroyed at the end of the request.

Function to add a new member to the team

This function will allow supervisors to create new team members of the team. For instance, supervisors can create only moderators and master supervisors can create new supervisors. If a supervisor tries to create another supervisor, the server will reject the request. Simple as that :)

Related code:

/**
 * Method to add a new member to the team
 * @public
 * @author Jayser Mendez
 */
exports.createMember = role => async (req, res, next) => {
  try {
    // Grab the user object from the locals
    const teamMember = res.locals.username;

    // Grab the username from the POST body
    const { username } = req.body;

    // If the supervisor wants to add a new moderator, insert the respectives roles.
    if (role === 'moderator') {
      // Find/create the user
      const user = await createUser(username);

      // Update user object with new roles
      await user.update({
        $set: { roles: ['contributor', 'moderator'] },
      });

      // Let the client know that the new moderator was added correctly
      return res.status(httpStatus.OK).send({
        status: httpStatus.OK,
        message: 'The moderator was added correctly to the team',
      });

      // If the supervisor wants to add a new supervisor, insert the respectives roles.
    } else if (role === 'supervisor') {
      // Since only the master user can add a new supervisor, check if the current user
      // is the master user. If so, allow to make a new supervisor
      if (teamMember === config.master_user) {
        // Find/create the user
        const user = await createUser(username);

        // Update user object with new roles
        await user.update({
          $set: { roles: ['contributor', 'moderator', 'supervisor'] },
        });

        // Let the client know that the new moderator was added correctly
        return res.send({
          status: httpStatus.OK,
          message: 'The supervisor was added correctly to the team',
        });
      }

      // Otherwise, reject the action
      return res.status(httpStatus.UNAUTHORIZED).send({
        status: httpStatus.UNAUTHORIZED,
        message: 'Only the master supervisor can add a new supervisor',
      });
    }

    return true;

  // Catch any error
  } catch (err) {
    return next({
      status: httpStatus.INTERNAL_SERVER_ERROR,
      message: 'Opps! Something is wrong in our server. Please report it to the administrator.',
      error: err,
    });
  }
};
Reset moderation data

As mentioned in posts before, a moderator can only moderate a post once. If they try to moderate it again, they will not be able to. In order to moderate it again, they should do a request to a supervisor with valid reasons for why they want to reset this data. If the supervisor approves it, the supervisor just need to call this function and the moderation data for the selected post will be resetted to the default one.

Related code:

/**
 * Method to reset moderation data of a post (supervisors)
 * @public
 * @author Jayser Mendez
 */
exports.resetStatus = async (req, res, next) => {
  try {
    // Grab post permlink from POST request
    const { permlink } = req.body;

    // Find the post and update it with a default moderation data
    const post = await Post.findOneAndUpdate(
      { permlink },
      {
        'moderation.moderated': false,
        'moderation.approved': false,
        'moderation.moderatedBy': null,
        'moderation.moderatedAt': null,
      },
    );

    // If the post is returned, it means that it was edited correctly. Let the client know it.
    if (post) {
      return res.send({
        status: httpStatus.OK,
        message: 'Moderation data was modified correctly.',
      });
    }

    // Otherwise, the post is not found, let the client know.
    return res.status(httpStatus.NOT_FOUND).send({
      status: httpStatus.NOT_FOUND,
      message: 'This posts has not been found.',
    });

  // Catch any error
  } catch (err) {
    return next({
      status: httpStatus.INTERNAL_SERVER_ERROR,
      message: 'Opps! Something is wrong in our server. Please report it to the administrator.',
      error: err,
    });
  }
};
Function to remove roles

Just as the function to add new members, this is a simple function. Supervisors can remove moderators but supervisors cannot remove supervisors. Only the master supervisor can remove supervisors.

Related code:

/**
 * Method to remove role from a user
 * @param {String} role: Role to remove
 * @public
 * @author Jayser Mendez
 */
exports.removeRole = role => async (req, res, next) => {
  try {
    // Grab the user object from the locals
    const teamMember = res.locals.username;

    // Grab the username from the POST body
    const { username } = req.body;

    // First find the username and then check if this user is a supervisor
    const user = await User.findOne({ username });

    // If there is not user, stop the request and let the client know
    if (!user) {
      return res.status(httpStatus.NOT_FOUND).send({
        status: httpStatus.NOT_FOUND,
        message: 'This user cannot be found in our records',
      });
    }

    // Check if the team member wants to remove a supervisor
    if (role === 'supervisor') {
      // Check if the user provided is another supervisor and check if the team member
      // is not the master supervisor
      if (user.roles.indexOf('supervisor') > -1 && teamMember !== config.master_user) {
        // If so, tell the client that this user is not authorized to perform such action
        return next({
          status: httpStatus.UNAUTHORIZED,
          message: 'Only the master supervisor can remove another supervisor.',
        });

      // Check if the user provided is a supervisor and the team member is a master supervisor
      } else if (user.roles.indexOf('supervisor') > -1 && teamMember === config.master_user) {
        // Otherwise, the master supervisor is doing it, proceed.
        await user.update({
          $pull: { roles: 'supervisor' },
        });

        return res.status(httpStatus.OK).send({
          status: httpStatus.OK,
          message: 'Supervisor role correctly removed',
        });
      }

      // Otherwise, this user is not a supervisor, let the client know
      return res.status(httpStatus.NOT_FOUND).send({
        status: httpStatus.NOT_FOUND,
        message: 'The user provided is not a supervisor',
      });

    // Check if the team member wants to remove a moderator
    } else if (role === 'moderator') {
      // Check if the user is currently a moderator
      if (user.roles.indexOf('moderator') > -1) {
        // Pull the moderator role from this user
        await user.update({
          $pull: { roles: 'moderator' },
        });

        return res.status(httpStatus.OK).send({
          status: httpStatus.OK,
          message: 'Moderator role correctly removed',
        });
      }

      // Otherwise, this user is not a moderator, let the client know
      return res.status(httpStatus.NOT_FOUND).send({
        status: httpStatus.NOT_FOUND,
        message: 'The user provided is not a moderator',
      });
    }

    return true;

  // Catch any error
  } catch (err) {
    return next({
      status: httpStatus.INTERNAL_SERVER_ERROR,
      message: 'Opps! Something is wrong in our server. Please report it to the administrator.',
      error: err,
    });
  }
};
Improve API docs by adding parameters default values and specify if they are optional

As mentioned in the title, I improved the API docs by adding default values of some parameters and specify whether they are optional or not. It will help other developers to better understand how the API work.

Stats endpoints: pending, approved, moderated, not-approved, reserved posts

These endpoints are used to get data from the posts mentioned above. These endpoints will return an array of posts along with its count.

Available endpoints:

  • GET v1/stats/moderation/approved - List and count all the approved posts
  • GET v1/stats/moderation/moderated - List and count all the moderated posts
  • GET v1/stats/moderation/not-approved - List and count all not approved posts
  • GET v1/stats/moderation/pending - List and count all pending posts
  • GET v1/stats/moderation/reserved - List and count all reserved posts

All these endpoints can be queried only by a specific user as well.

Related code:

/**
 * Method to generate a MongoDB query based on a given criteria
 * @param {String} filter: criteria to determine the query
 * @private
 * @author Jayser Mendez
 */
const buildQuery = (filter, req) => {
  // List and count all the posts pending of moderation
  if (filter === 'moderation_pending') {
    return {
      'moderation.moderated': false,
      'moderation.reservedUntil': { $lt: Date.now() },
    };

  // List and count all the approved posts
  } else if (filter === 'moderation_approved') {
    let query = { 'moderation.approved': true };

    // If the request has params, it is the username, append it.
    if (Object.keys(req.query).length !== 0) {
      query = { ...query, 'moderation.moderatedBy': req.query.username };
    }

    return query;

  // List and count all the not approved posts
  } else if (filter === 'moderation_not_approved') {
    let query = { 'moderation.approved': false };

    // If the request has params, it is the username, append it.
    if (Object.keys(req.query).length !== 0) {
      query = { ...query, 'moderation.moderatedBy': req.query.username };
    }

    return query;

  // List and count all the reserved posts
  } else if (filter === 'reserved') {
    let query = { 'moderation.reserved': true };

    // If the request has params, it is the username, append it.
    if (Object.keys(req.query).length !== 0) {
      query = {
        ...query,
        'moderation.reservedBy': req.query.username,
        'moderation.reservedUntil': { $gt: Date.now() },
      };
    }

    return query;

  // List and count all moderated posts
  } else if (filter === 'moderated') {
    let query = { 'moderation.moderated': true };

    // If the request has params, it is the username, append it.
    if (Object.keys(req.query).length !== 0) {
      query = { ...query, 'moderation.moderatedBy': req.query.username };
    }

    return query;
  }

  return false;
};

/**
 * Send stats response with the provided criteria
 * @param {String} filter: criteria to determine the query
 * @public
 * @author Jayser Mendez
 */
exports.sendStats = filter => async (req, res, next) => {
  try {
    // Grab the params from the request
    let { limit, skip } = req.query;
    limit = parseInt(limit, 10);
    skip = parseInt(skip, 10);

    // Find the post in the database
    const posts = await Post.find(buildQuery(filter, req)).limit(limit || 25).skip(skip || 0);

    // Send the response to the client formatted.
    return res.status(httpStatus.OK).send({
      status: httpStatus.OK,
      results: posts,
      count: posts.length,
    });

  // Catch errors here.
  } catch (err) {
    return next({
      status: httpStatus.INTERNAL_SERVER_ERROR,
      message: 'Opps! Something is wrong in our server. Please report it to the administrator.',
      error: err,
    });
  }
};
Update package.json

I downgrade the API version back to 1.0.0 and replaced the author of the API with the new author.

Clean up unused code and libraries

All the auth methods and endpoints were not used. To avoid confusions, I removed them all and remove all the references to them in the code. In addition, I uninstalled unused libraries to make the API lighter.

What is next?

  • Integration test for endpoints to easily test them
  • Improve security by whitelisting domains using CORS
  • Add protection for parameter pollution
  • Add rate limitter for some endpoints to avoid DDos attacks.
  • Deny iframes to the API.
Sort:  

Thanks for the contribution, @jaysermendez! Cool features and great work once again! How come you commented everything out in auth.test.js instead of removing it like the other files?

Your contribution has been evaluated according to Utopian policies and guidelines, as well as a predefined set of questions pertaining to the category.

To view those questions and the relevant answers related to your post, click here.


Need help? Write a ticket on https://support.utopian.io/.
Chat with us on Discord.
[utopian-moderator]

It is my reference to just copy and paste code for unit test :P removed in next PR

Hope to see knacksteem up and running soonest

Hey @jaysermendez
Thanks for contributing on Utopian.
We’re already looking forward to your next contribution!

Want to chat? Join us on Discord https://discord.gg/h52nFrV.

Vote for Utopian Witness!