Source: questions.js

/*
Copyright (C) 2016
Developed at University of Toronto

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program.  If not, see <http://www.gnu.org/licenses/>.
*/

const db = require('./db.js');
const logger = require('./log.js');
const common = require('./common.js');
const questionValidator = require('./questionValidator.js');
const settings = require('./settings.js');
const vfs = require('./virtualFileSystem.js');

/**
 * Preparing data on update/edit of a question
 *
 * @param {object} question
 */
var questionUpdateParser = function(question) {
    var updatedQuestion = question;

    if ('visible' in question) {
        updatedQuestion.visible = (question.visible === 'true');
    }

    if ('minpoints' in question) {
        updatedQuestion.minpoints = parseInt(question.minpoints);
    }

    if ('maxpoints' in question) {
        updatedQuestion.maxpoints = parseInt(question.maxpoints);
    }

    return updatedQuestion;
}

/**
 * Prepare question data on first pass to DB
 *
 * @param {object} question
 * @param {function} callback
 */
var prepareQuestionData = function(question, callback) {
    // prepare regular data
    var currentDate = common.getDateByFormat('YYYY-MM-DD');
    var questionToAdd = {};

    questionToAdd._id = common.getUUID();
    questionToAdd.topic = question.topic;
    questionToAdd.title = question.title;
    questionToAdd.text = question.text;
    questionToAdd.hint = question.hint;
    questionToAdd.minpoints = parseInt(question.minpoints);
    questionToAdd.maxpoints = parseInt(question.maxpoints);
    questionToAdd.visible = (question.visible === 'true');
    questionToAdd.correctAttempts = [];
    questionToAdd.wrongAttempts = [];
    questionToAdd.totalAttempts = [];
    questionToAdd.correctAttemptsCount = 0;
    questionToAdd.wrongAttemptsCount = 0;
    questionToAdd.totalAttemptsCount = 0;
    questionToAdd.ctime = currentDate;
    questionToAdd.mtime = currentDate;
    questionToAdd.ratings = [];
    questionToAdd.comments = [];
    questionToAdd.userSubmissionTime = [];
    questionToAdd.deleted = false;
    //Add specific attributes by Type
    switch (question.type) {
        case common.questionTypes.REGULAR.value:
            questionToAdd.type = common.questionTypes.REGULAR.value;
            questionToAdd.answer = question.answer;
            break;

        case common.questionTypes.MULTIPLECHOICE.value:
            questionToAdd.type = common.questionTypes.MULTIPLECHOICE.value;
            questionToAdd.choices = question.choices;
            questionToAdd.answer = question.answer;
            break;

        case common.questionTypes.TRUEFALSE.value:
            questionToAdd.type = common.questionTypes.TRUEFALSE.value;
            questionToAdd.answer = question.answer;
            break;

        case common.questionTypes.MATCHING.value:
            questionToAdd.type = common.questionTypes.MATCHING.value;
            questionToAdd.leftSide = question.leftSide;
            questionToAdd.rightSide = question.rightSide;
            break;

        case common.questionTypes.CHOOSEALL.value:
            questionToAdd.type = common.questionTypes.CHOOSEALL.value;
            questionToAdd.choices = question.choices;
            questionToAdd.answer = question.answer;
            break;

        case common.questionTypes.ORDERING.value:
            questionToAdd.type = common.questionTypes.ORDERING.value;
            questionToAdd.answer = question.answer;
            break;

        default:
            return callback(common.getError(3001), null)
    }

    return callback(null, questionToAdd);
}

/**
 * Insert a new question into the database.
 * The question object passed to the function should have
 * the text, topic, type, answer, minpoints, maxpoints and hint set.
 *
 * @param {object} question
 * @param {function} callback
 */
exports.addQuestion = function(question, callback) {
    prepareQuestionData(question, function(err, questionToAdd) {
        if (err) {
            return callback(err, null)
        }

        // validate constant question attributes
        result = questionValidator.questionCreationValidation(questionToAdd);
        if (result.success) {
            db.addQuestion(questionToAdd, function (err, questionId) {
                if (err) {
                    return callback(common.getError(3018), null);
                }

                vfs.mkdir(common.vfsTree.QUESTIONS, questionToAdd._id, common.vfsPermission.OWNER, function (err, result) {
                    logger.log(common.formatString('Creating question directory: {0} {1}', [questionToAdd._id, err ? err : 'ok']));
                });

                return callback(null, questionId);
            });
        } else{
            return callback(result.err, null)
        }
    })
}

/**
 * Replace a question in the database with the provided question object.
 *
 * @param {string} questionId
 * @param {object} info
 * @param {function} callback
 */
exports.updateQuestionById = function(questionId, info, callback) {
    updateQuestionById(questionId, info, callback);
}

/**
 * Replace a question in the database with the provided question object.
 *
 * @param {string} questionId
 * @param {object} info
 * @param {function} callback
 */
var updateQuestionById = function(qId, infoToUpdate, callback) {
    // Get Type of question and validate it
    lookupQuestionById(qId, function(err, question) {
        if (err) {
            return callback(common.getError(3019), null);
        }
        infoToUpdate = questionUpdateParser(infoToUpdate);

        // validate each field that will be updated
        const result = questionValidator.validateAttributeFields(infoToUpdate, question.type);
        if (result.success) {
            db.updateQuestionById(qId, infoToUpdate, callback);
        } else {
            return callback(result.err, null)
        }
    });
}

/**
 * Deactivate the question with ID qid from the database.
 *
 * @param {string} questionId
 * @param {function} callback
 */
exports.deleteQuestion = function(questionId, callback) {
    lookupQuestionById(questionId, function(err, questionObj){
        if (err) {
            logger.error(JSON.stringify(err));
            return callback(common.getError(3023), null);
        }

        var query = {_id:questionId};
        var update = {};
        update.$set = {'deleted':true};

        db.updateQuestionByQuery(query, update, function(err, res){
            if (err) {
                logger.error(JSON.stringify(err));
                return callback(common.getError(3023), null);
            }
            logger.log(common.formatString('Question {0} deleted from database.', [questionId]));
            return callback(null, res);
        });
    });
}

/**
 * Extract a question object from the database using its ID.
 *
 * @param {string} questionId
 * @param {function} callback
 */
exports.lookupQuestionById = function(questionId, callback) {
    lookupQuestionById(questionId, callback);
}

/**
 * lookup question in database
 *
 * @param {string} questionId
 * @param {function} callback
 */
var lookupQuestionById = function(questionId, callback) {
    db.lookupQuestion({_id: questionId}, callback);
}

/**
 * adding rating to question collection
 *
 * @param {string} questionId
 * @param {string} userId
 * @param {number} rating
 * @param {function} callback
 */
exports.submitRating = function (questionId, userId, rating, callback) {
    var currentDate = common.getDate();
    var query = {_id: questionId};
    var update = {};

    update.$push = {};
    update.$push.ratings = {
        userId: userId,
        date: currentDate,
        rating: rating
    }

    db.updateQuestionByQuery(query, update, function (err,result) {
        return callback(err, result);
    });
}

/**
 * get all questions list
 *
 * @param {function} callback
 */
exports.getAllQuestionsList = function(callback) {
    db.getQuestionsList({}, {number: 1}, callback);
}

/**
 * get all questions list by Query
 *
 * @param {object} findQuery
 * @param {object} sortQuery
 * @param {function} callback
 */
exports.getAllQuestionsByQuery = function(findQuery, sortQuery, callback) {
    db.getQuestionsList(findQuery, sortQuery, callback);
}

/**
 * submit answer
 *
 * @param {string} questionId
 * @param {string} userId
 * @param {boolean} correct
 * @param {number} points
 * @param {*} answer
 * @param {function} callback
 */
exports.submitAnswer = function(questionId, userId, correct, points, answer, callback) {
    var currentDate = common.getDate();
    var query = {_id: questionId};
    var update = {};

    update.$push = {};
    update.$inc = {};

    query['correctAttempts.userId'] = { $ne : userId };
    if (correct) {
        update.$inc.correctAttemptsCount = 1;
        update.$push.correctAttempts = {
            userId : userId,
            points: points,
            answer: answer,
            date : currentDate
        };
    } else {
        update.$inc.wrongAttemptsCount = 1;
        update.$push.wrongAttempts = {
            userId : userId,
            attemp: answer,
            date : currentDate
        };
    }
    update.$inc.totalAttemptsCount = 1;
    update.$push.totalAttempts = {
        userId : userId,
        attemp: answer,
        date : currentDate
    };

    db.updateQuestionByQuery(query, update, function (err,result) {
        return callback(err, result);
    });
}

/**
 * verify answer based on type
 *
 * @param {object} question
 * @param {*} answer
 */
exports.verifyAnswer = function(question, answer) {
    if (answer) {
        switch (question.type) {
            case common.questionTypes.MATCHING.value:
                return verifyMatchingQuestionAnswer(question,answer);
            case common.questionTypes.CHOOSEALL.value:
                return verifyChooseAllQuestionAnswer(question,answer);
            case common.questionTypes.ORDERING.value:
                return verifyOrderingQuestionAnswer(question,answer);
            default:
                return (answer === question.answer);
        }
    }
    return false;
}

/**
 * verify choose all question answer
 *
 * @param {object} question
 * @param {*} answer
 */
var verifyChooseAllQuestionAnswer = function(question,answer) {
    if (!questionValidator.validateAttributeType(answer,'answer','CHOOSEALL') ||
        !questionValidator.validateArrayObject(answer,'String')) {
        return false;
    }
    return question.answer.sort().join(',') === answer.sort().join(',');
}

/**
 * verify matching question answer
 *
 * @param {object} question
 * @param {*} answer
 */
var verifyMatchingQuestionAnswer = function(question, answer) {
    if (!questionValidator.validateAttributeType(answer,'Array','DATATYPES') ||
        !questionValidator.validateArrayObject(answer,'Array') ||
        !questionValidator.validateArrayObject(answer[0],'String') ||
        !questionValidator.validateArrayObject(answer[1],'String')) {
        return false;
    }

    var ansLeftSide = answer[0];
    var ansRightSide = answer[1];

    if (ansLeftSide.length === question.leftSide.length) {
        var checkIndexLeft;
        var checkIndexRight;

        for (i = 0; i < ansLeftSide.length; i++) {
            checkIndexLeft = question.leftSide.indexOf(ansLeftSide[i]);
            checkIndexRight = question.rightSide.indexOf(ansRightSide[i]);
            if (checkIndexLeft !== checkIndexRight) {
                return false;
            }
        }
        return true;
    }
    return false;
}

/**
 * Check if answer submitted is correct for Ordering question Type
 *
 * @param {object} question
 * @param {*} answer
 */
var verifyOrderingQuestionAnswer = function(question,answer) {
    if (!questionValidator.validateAttributeType(answer,'answer','ORDERING') ||
        !questionValidator.validateArrayObject(answer,'String')) {
        return false;
    }
    return question.answer.join(',') === answer.join(',');
}

/**
 * add comment to question by id with user and comment
 *
 * @param {string} questionId
 * @param {string} userId
 * @param {string} comment
 * @param {function} callback
 */
exports.addComment = function (questionId, userId, comment, callback) {
    var currentDate = common.getDate();
    var query = {_id: questionId};
    var update = {};

    update.$push = {};
    update.$push.comments = {
        _id: common.getUUID(),
        date: currentDate,
        userId: userId,
        likes: [],
        dislikes: [],
        likesCount: 0,
        dislikesCount: 0,
        replies: [],
        repliesCount: 0,
        comment: comment
    };

    db.updateQuestionByQuery(query, update, function (err, result) {
        return callback(err, result);
    });
}

/**
 * add reply to comment by id with user and reply
 *
 * @param {string} commentId
 * @param {string} userId
 * @param {string} reply
 * @param {function} callback
 */
exports.addReply = function (commentId, userId, reply, callback) {
    var currentDate = common.getDate();
    var query = {'comments._id': commentId};
    var update = {};

    update.$push = {};
    update.$inc = {};
    update.$inc['comments.$.repliesCount'] = 1;
    update.$push['comments.$.replies'] = {
        _id: common.getUUID(),
        date: currentDate,
        userId: userId,
        likes: [],
        dislikes: [],
        likesCount: 0,
        dislikesCount: 0,
        reply: reply
    };

    db.updateQuestionByQuery(query, update, function (err, result) {
        return callback(err, result);
    });
}

/**
 * vote on a comment
 *
 * The way votes work:
 * if the user already voted up, then if they vote up again the previous vote
 * will get cancelled. Same for vote down.
 * if the user already voted up, then vote down the previous vote gets removed
 * and the new vote gets added.
 * if they never voted before, just add the vote
 *
 * @param {string} commentId
 * @param {number} vote
 * @param {string} userId
 * @param {function} callback
 */
exports.voteComment = function (commentId, vote, userId, callback) {
    var query = {'comments._id': commentId};
    var update = {};
    var voteValue = -2;

    db.lookupQuestion(query, function(err, question) {
        if (err) {
            return callback(common.getError(3019), null);
        }

        if (!question) {
            return callback(common.getError(3003), null);
        }

        var comments = question.comments;
        for (var i in comments) {
            if (comments[i]._id === commentId) {
                var userUpVoted = comments[i].likes.indexOf(userId) !== -1;
                var userDownVoted = comments[i].dislikes.indexOf(userId) !== -1;
                var updatedLikesCount = comments[i].likesCount;
                var updatedDisLikesCount = comments[i].dislikesCount;

                if (userUpVoted && userDownVoted) {
                    return callback('User liked and disliked a comment at the sametime', null);
                }

                if (userUpVoted) {
                    updatedLikesCount--;
                    voteValue = 0;
                    update.$pull = { 'comments.$.likes': userId };
                    update.$inc = { 'comments.$.likesCount': -1 };

                    if (vote === -1) {
                        updatedDisLikesCount++;
                        voteValue = -1;
                        update.$push = { 'comments.$.dislikes': userId };
                        update.$inc['comments.$.dislikesCount'] = 1;
                    }
                }

                if (userDownVoted) {
                    updatedDisLikesCount--;
                    voteValue = 0;
                    update.$pull = { 'comments.$.dislikes': userId };
                    update.$inc = { 'comments.$.dislikesCount': -1 };

                    if (vote === 1) {
                        updatedLikesCount++;
                        voteValue = 1;
                        update.$push = { 'comments.$.likes': userId };
                        update.$inc['comments.$.likesCount'] = 1;
                    }
                }

                if (!userUpVoted && !userDownVoted) {
                    if (vote === 1) {
                        updatedLikesCount++;
                        update.$push = { 'comments.$.likes': userId };
                        update.$inc = { 'comments.$.likesCount': 1 };
                    }

                    if (vote === -1) {
                        updatedDisLikesCount++;
                        update.$push = { 'comments.$.dislikes': userId };
                        update.$inc = { 'comments.$.dislikesCount': 1 };
                    }

                    voteValue = vote;
                }

                return db.updateQuestionByQuery(query, update, function (err, result) {
                    return callback(err, {
                        result: result,
                        voteValue: voteValue,
                        likesCount: updatedLikesCount,
                        dislikesCount: updatedDisLikesCount
                    });
                });
            }
        }

        return callback(common.getError(3015), null);
    });
}

/**
 * vote on a reply
 *
 * @param {string} replyId
 * @param {number} vote
 * @param {string} userId
 * @param {function} callback
 */
exports.voteReply = function (replyId, vote, userId, callback) {
    var query = {'comments.replies._id': replyId};
    var update = {};
    var voteValue = -2;

    db.lookupQuestion(query, function(err, question) {
        if (err) {
            return callback(common.getError(3019), null);
        }

        if (!question) {
            return callback(common.getError(3003), null);
        }

        // TODO: optimize this using mongodb projections
        var comments = question.comments;
        for (var commentIndex in comments) {
            var replies = comments[commentIndex].replies;
            for (var replyIndex in replies) {
                if (replies[replyIndex]._id === replyId) {
                    var userUpVoted = replies[replyIndex].likes.indexOf(userId) !== -1;
                    var userDownVoted = replies[replyIndex].dislikes.indexOf(userId) !== -1;
                    var updatedLikesCount = replies[replyIndex].likesCount;
                    var updatedDisLikesCount = replies[replyIndex].dislikesCount;
                    var commonPrefix = 'comments.' + commentIndex + '.replies.' + replyIndex;

                    if (userUpVoted && userDownVoted) {
                        return callback('User liked and disliked a comment at the sametime', null);
                    }

                    if (userUpVoted) {
                        updatedLikesCount--;
                        voteValue = 0;
                        var commentsreplieslikes = commonPrefix + '.likes';
                        var commentsreplieslikesCount = commonPrefix + '.likesCount';
                        update.$pull = {};
                        update.$inc = {};
                        update.$pull[commentsreplieslikes] = userId;
                        update.$inc[commentsreplieslikesCount] = -1;

                        if (vote === -1) {
                            updatedDisLikesCount++;
                            voteValue = -1;
                            var commentsrepliesdislikes = commonPrefix + '.dislikes';
                            var commentsrepliesdislikesCount = commonPrefix + '.dislikesCount';
                            update.$push = {};
                            update.$push[commentsrepliesdislikes] = userId;
                            update.$inc[commentsrepliesdislikesCount] = 1;
                        }
                    }

                    if (userDownVoted) {
                        updatedDisLikesCount--;
                        voteValue = 0;
                        var commentsrepliesdislikes = commonPrefix + '.dislikes';
                        var commentsrepliesdislikesCount = commonPrefix + '.dislikesCount';
                        update.$pull = {};
                        update.$inc = {};
                        update.$pull[commentsrepliesdislikes] = userId;
                        update.$inc[commentsrepliesdislikesCount] = -1;

                        if (vote === 1) {
                            updatedLikesCount++;
                            voteValue = 1;
                            var commentsreplieslikes = commonPrefix + '.likes';
                            var commentsreplieslikesCount = commonPrefix + '.likesCount';
                            update.$push = {};
                            update.$push[commentsreplieslikes] = userId;
                            update.$inc[commentsreplieslikesCount] = 1;
                        }
                    }

                    if (!userUpVoted && !userDownVoted) {
                        if (vote === 1) {
                            updatedLikesCount++;
                            var commentsreplieslikes = commonPrefix + '.likes';
                            var commentsreplieslikesCount = commonPrefix + '.likesCount';
                            update.$push = {};
                            update.$inc = {};
                            update.$push[commentsreplieslikes] = userId;
                            update.$inc[commentsreplieslikesCount] = 1;
                        }

                        if (vote === -1) {
                            updatedDisLikesCount++;
                            var commentsrepliesdislikes = commonPrefix + '.dislikes';
                            var commentsrepliesdislikesCount = commonPrefix + '.dislikesCount';
                            update.$push = {};
                            update.$inc = {};
                            update.$push[commentsrepliesdislikes] = userId;
                            update.$inc[commentsrepliesdislikesCount] = 1;
                        }

                        voteValue = vote;
                    }

                    return db.updateQuestionByQuery(query, update, function (err, result) {
                        return callback(err, {
                            result: result,
                            voteValue: voteValue,
                            likesCount: updatedLikesCount,
                            dislikesCount: updatedDisLikesCount
                        });
                    });
                }
            }
        }

        return callback(common.getError(3016), null);
    });
}

/**
 * check if the user is locked from answering the question
 *
 * @param {string} userId
 * @param {object} question
 * @param {function} callback
 */
exports.isUserLocked = function(userId, question, callback){
    if (!settings.getQuestionTimeoutEnabled()){
        return callback(null,false,null);
    }
    var waiting_time = settings.getQuestionTimeoutPeriod();
    var lastSubmissionTime;
    var currentDate = common.getDateObject();

    // check if user already got the question correct
    for (var obj = 0; obj < question.correctAttempts.length; obj ++){
        if(question.correctAttempts[obj]['userId'] === userId){
            return callback(null,false,null);
        }
    }
    // find the lastSubmissionTime for this user
    for (var obj = 0; obj < question.userSubmissionTime.length; obj++){
        if(question.userSubmissionTime[obj]['userId'] === userId){
            lastSubmissionTime = question.userSubmissionTime[obj]['submissionTime'];
            break;
        }
    }

    if(lastSubmissionTime){
        const diff = Math.abs(currentDate - lastSubmissionTime);
        const timeLeftToWait = waiting_time - diff;
        if (diff < waiting_time){
            return callback(null,true,common.getTime(timeLeftToWait), timeLeftToWait);
        }
    }
    return callback(null,false,null);
}

/**
 * Updates the Users Submission time for a Question
 *
 * @param {string} userId
 * @param {object} question
 * @param {function} callback
 */
exports.updateUserSubmissionTime = function(userId, question, callback){
    var query = {_id:question._id};
    var update = {};
    var currentDate = common.getDateObject();
    var userInList = false;
    // find the user in the submission list
    for (var obj = 0; obj < question.userSubmissionTime.length; obj++){
        if(question.userSubmissionTime[obj]['userId'] === userId){
            userInList = true;
            break;
        }
    }

    if(userInList){
        query['userSubmissionTime.userId'] = userId;
        update.$set = {"userSubmissionTime.$.submissionTime":currentDate};
        db.updateQuestionByQuery(query, update, function (err, result){
            if(err){
                return callback(err,null);
            }
            return callback(null,'success');
        });

    } else {
        update.$push = {'userSubmissionTime': {'userId':userId, submissionTime: currentDate}};
        db.updateQuestionByQuery(query, update, function (err, result){
            if(err){
                return callback(err,null);
            }
            return callback(null,'success');
        });
    }
}

/**
 * Changes visibilty of all questions based on the changeValue
 *
 * @param {boolean} changeValue
 * @param {funciton} callback
 */
exports.changeAllVisibility = function(changeValue, callback) {
    // Gets the list of students
    db.getQuestionsList({}, {number: 1}, function(err, questionList){
        if (err) {
            return callback(common.getError(3017), null);
        }

        questionList.forEach(question => {
            // Only change visibility of a question if it is different from the current visibility value
            if (question.visible !== changeValue) {
                // Question with only visibility because we don't want to change the other attributes of the question
                var newQuestion = {};
                newQuestion['visible'] = changeValue.toString();

                // Updates question with new visibility value.
                updateQuestionById(question._id, newQuestion, function(err, result) {
                    if (err) {
                        return callback(common.getError(3020), result);
                    }
                });
            }
        });

        return callback(null, 'success');
    });
}