Source: users.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 bcrypt = require('bcryptjs');
const db = require('./db.js');
const logger = require('./log.js');
const common = require('./common.js');
const settings = require('./settings.js');
const vfs = require('./virtualFileSystem.js');

/**
 * Create a student USER, if the USER object is valid
 *
 * @param {object} user
 * @param {function} callback
 */
exports.addAdmin = function (user, callback) {
    if (!user.fname || !user.lname || !user.username || !user.password) {
        logger.error('Failed to create a new admin, missing requirements');
        return callback(common.getError(2005), null);
    }

    bcrypt.hash(user.password, 11, function (err, hash) {
        if (err) {
            logger.error(JSON.stringify(err));
            return callback(common.getError(1009), null);
        }

        var currentDate = new Date().toString();
        var userToAdd = {};

        userToAdd._id = common.getUUID();
        userToAdd.username = user.username.toLowerCase();
        userToAdd.fname = user.fname;
        userToAdd.lname = user.lname;
        userToAdd.ctime = currentDate;
        userToAdd.atime = currentDate;
        userToAdd.mtime = currentDate;
        userToAdd.email = user.email ? user.email : '';
        userToAdd.type = common.userTypes.ADMIN;
        userToAdd.password = hash;
        userToAdd.active = true;
        userToAdd.picture = null;
        userToAdd.ratings = [];

        db.addAdmin(userToAdd, function (err, userObj) {
            if(err) {
                if (err.code === 2014) {
                    logger.error(common.formatString('Failed to create admin {0}, database issue', [userToAdd.username]));
                } else if (err.code === 2019) {
                    logger.error(common.formatString('Admin {0} already exists', [userToAdd.username]));
                }
                return callback(common.getError(2005), null);
            }

            vfs.mkdir(common.vfsTree.USERS, userToAdd._id, common.vfsPermission.OWNER, function (err, result) {
                logger.log(common.formatString('Creating user {0} directory: {1} {2}', [userToAdd.username, userToAdd._id, err ? err : 'ok']));

                logger.log(common.formatString('Admin {0} created', [userToAdd.username]));
                return callback(null, userObj);
            });
        });
    });
}

/**
 * Create a student USER, if the USER object is valid
 *
 * @param {object} user
 * @param {function} callback
 */
exports.addStudent = function (user, callback) {
    if (!user.fname || !user.lname || !user.username || !user.password) {
        logger.error('Failed to create a new student, missing requirements');
        return callback(common.getError(2007), null);
    }

    bcrypt.hash(user.password, 11, function (err, hash) {
        if (err) {
            logger.error(JSON.stringify(err));
            return callback(common.getError(1009), null);
        }

        var currentDate = new Date().toString();
        var userToAdd = {};

        userToAdd._id = common.getUUID();
        userToAdd.username = user.username.toLowerCase();
        userToAdd.fname = user.fname;
        userToAdd.lname = user.lname;
        userToAdd.ctime = currentDate;
        userToAdd.atime = currentDate;
        userToAdd.mtime = currentDate;
        userToAdd.email = user.email ? user.email : '';
        userToAdd.type = common.userTypes.STUDENT;
        userToAdd.password = hash;
        userToAdd.active = true;
        userToAdd.picture = null;
        userToAdd.ratings = [];

        userToAdd.points = 0.0;
        userToAdd.correctAttempts = [];
        userToAdd.wrongAttempts = [];
        userToAdd.totalAttempts = [];
        userToAdd.correctAttemptsCount = 0;
        userToAdd.wrongAttemptsCount = 0;
        userToAdd.totalAttemptsCount = 0;

        db.addStudent(userToAdd, function (err, userObj) {
            if (err) {
                if (err.code === 2014) {
                    logger.error(common.formatString('Failed to create student {0}, database issue', [userToAdd.username]));
                } else if (err.code === 2019) {
                    logger.error(common.formatString('Student {0} already exists', [userToAdd.username]));
                }

                return callback(err, null);
            }

            vfs.mkdir(common.vfsTree.USERS, userToAdd._id, common.vfsPermission.OWNER, function (err, result) {
                logger.log(common.formatString('Creating user {0} directory: {1} {2}', [userToAdd.username, userToAdd._id, err ? err : 'ok']));

                logger.log(common.formatString('Student {0} created', [userToAdd.username]));
                return callback(null, userObj);
            });
        });
    });
}

/**
 * Update the account with ID userid in the student database.
 * The user argument holds the complete new object to insert.
 * Fail if the ID has changed and the new ID already belongs
 * to a user.
 *
 * @param {string} userId
 * @param {object} info
 * @param {function} callback
 */
exports.updateUserById = function (userId, info, callback) {
    db.updateUserById(userId, info, callback);
}

/**
 * update student by id
 *
 * @param {string} userId
 * @param {object} info
 * @param {function} callback
 */
exports.updateStudentById = function (userId, info, callback) {
    db.updateStudentById(userId, info, callback);
}

/**
 * update admin by id
 *
 * @param {string} userId
 * @param {object} info
 * @param {function} callback
 */
exports.updateAdminById = function (userId, info, callback) {
    db.updateAdminById(userId, info, callback);
}

/**
 * Return an array of users in the database.
 *
 * @param {function} callback
 */
var getAdminsList = function (callback) {
    db.getAdminsList(callback);
}
exports.getAdminsList = getAdminsList;

/**
 * get students list
 *
 * @param {function} callback
 */
var getStudentsList = function (callback) {
    db.getStudentsList(callback);
}
exports.getStudentsList = getStudentsList;

/**
 * get students list with status
 *
 * @param {boolean} active
 * @param {function} callback
 */
var getStudentsListWithStatus = function (active, callback) {
    db.getStudentsListWithStatus(active, callback);
}
exports.getStudentsListWithStatus = getStudentsListWithStatus;

/**
 * get users list
 *
 * @param {function} callback
 */
var getUsersList = function (callback) {
    db.getUsersList(callback);
}
exports.getUsersList = getUsersList;

/**
 * Return an array of users in the database, sorted by rank.
 *
 * @param {int} lim
 * @param {function} callback
 */
var getStudentsListSorted = function (lim, callback) {
    db.getStudentsListSorted(lim, callback);
}
exports.getStudentsListSorted = getStudentsListSorted;

/**
 * Check if the account given by user and pass is valid.
 * Return account object if it is or null otherwise.
 *
 * @param {string} username
 * @param {string} pass
 * @param {function} callback
 */
exports.checkLogin = function (username, pass, callback) {
    db.checkLogin(username, pass, callback);
}

/**
 * Fetch the user object with ID iserId in the users database.
 *
 * @param {string} userId
 * @param {function} callback
 */
exports.getUserById = function (userId, callback) {
    db.getUserById(userId, callback);
}

/**
 * get the admin object by Id if exists
 *
 * @param {string} studentId
 * @param {function} callback
 */
exports.getStudentById = function (studentId, callback) {
    db.getStudentById(studentId, callback);
}

/**
 * get the admin object by Id if exists
 *
 * @param {string} adminId
 * @param {function} callback
 */
exports.getAdminById = function (adminId, callback) {
    db.getStudentById(adminId, callback);
}

/**
 * get the user object by username if exists
 *
 * @param {string} adminId
 * @param {function} callback
 */
exports.getUserByUsername = function (username, callback) {
    db.getUserObject({username: username}, callback);
}

/**
 * submit user's answer on a question by updating the collections
 *
 * @param {string} userId
 * @param {string} questionId
 * @param {boolean} correct
 * @param {int} points
 * @param {string} answer
 * @param {function} callback
 */
exports.submitAnswer = function (userId, questionId, correct, points, answer, callback) {
    var currentDate = new Date().toString();
    var query = { _id : userId };
    var update = {};

    update.$inc = {};
    update.$set = { mtime : currentDate };
    update.$push = {};

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

    if (common.isEmptyObject(update.$inc)) {
        delete update.$inc;
    }

    if (common.isEmptyObject(update.$set)) {
        delete update.$set;
    }

    if (common.isEmptyObject(update.$push)) {
        delete update.$push;
    }

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

/**
 * Fetch the question list of userId
 *
 * @param {object} request
 * @param {function} callback
 */
exports.getQuestionsListByUser = function (request, callback) {
    var questionsQuery = {};
    var sortQuery = {number: 1};
    var user = request.user;
    var questionsStatus = request.questionsStatus;

    if (!user) {
        return callback(common.getError(2009), null);
    }

    if (user.type === common.userTypes.ADMIN) {
        if (typeof request.active !== 'boolean') {
            return callback(common.getError(1000), null);
        }
        db.getQuestionsListforAdmin({'deleted':{$ne:request.active}}, sortQuery, function (err, docs) {
            return callback(err, docs);
        });
    }

    if (user.type === common.userTypes.STUDENT) {
        questionsQuery.visible = true;

        db.getQuestionsList(questionsQuery, sortQuery, function (err, docs) {
            if (err) {
                return callback(common.getError(3017), null);
            }

            var compareList = common.getIdsListFromJSONList(user.correctAttempts, 'questionId');
            var answeredList = [];
            var unansweredList = [];

            for (q in docs) {
                if (compareList.indexOf(docs[q]._id) === -1) {
                    unansweredList.push(docs[q]);
                } else {
                    answeredList.push(docs[q]);
                }
            }

            var returnList = (questionsStatus === 'answered') ? answeredList : unansweredList;
            return callback(null, returnList);
        });
    }
}

/**
 * set the status of the user to active or in-active
 *
 * @param {string} studentId
 * @param {boolean} newStatus
 * @param {function} callback
 */
exports.setUserStatus = function (studentId, newStatus, callback) {
    db.updateUserById(studentId,{active: newStatus}, callback);
}

/**
 * adding rating to question collection
 *
 * @param {string} userId
 * @param {string} questionId
 * @param {int} rating
 * @param {function} callback
 */
exports.submitRating = function (userId, questionId, rating, callback) {
    db.updateUserById(userId, {questionId: questionId, rating: rating}, callback);
}

/**
 * update user's profile based on the given infomation
 *
 * @param {string} userId
 * @param {object} request
 * @param {function} callback
 */
exports.updateProfile = function (userId, request, callback) {
    var query = {_id: userId};
    var update = {};

    update.$set = {};

    if (request.newfname) {
        update.$set.fname = request.newfname;
    }

    if (request.newlname) {
        update.$set.lname = request.newlname;
    }

    if (request.newemail) {
        update.$set.email = request.newemail;
    }

    if (request.newpassword) {
        update.$set.password = request.newpassword;
        return db.updateUserPassword(query, update, request.newpassword, function (err, result) {
            return callback(err, result);
        });
    }

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

/**
 * Fetch a list of students to display in the leaderboard.
 *
 * If smallBoard is true, return points leaderboard with top 3 entries.
 *
 * @param {string} userid
 * @param {boolean} smallBoard
 * @param {function} callback
 */
exports.getLeaderboard = function (userid, smallBoard, callback) {
    getStudentsListSorted(0, function (err, studentlist) {
        if (err) {
            logger.error('Leaderboard error: ' + err);
            return callback(common.getError(2020), []);
        }

        var leaderboardList = [];

        if (smallBoard) {
            var rank;
            var prevRank;

            for (var i = 0; i < studentlist.length; ++i) {
                var currentStudent = studentlist[i];

                // Students with the same number of points have the same rank
                if (i === 0) {
                    rank = 1;
                    prevRank = rank;
                } else {
                    if (studentlist[i - 1].points === currentStudent.points) {
                        rank = prevRank;
                    } else {
                        rank = i + 1;
                        prevRank = rank;
                    }
                }

                var student = {
                    displayName:`${currentStudent.fname} ${currentStudent.lname[0]}.`,
                    points:currentStudent.points,
                    userRank: rank
                }

                // Adds the current student to the mini leaderboard
                if (currentStudent._id === userid) {
                    // Row full of ... to show that student is not in the top 3
                    if (student.userRank !== 1 && student.userRank !== leaderboardList[leaderboardList.length - 1].userRank + 1) {
                        var emptyStudent = {
                            displayName: '...',
                            points:'...',
                            userRank: '...'
                        }
                        leaderboardList.push(emptyStudent);
                    }
                    leaderboardList.push(student);
                }

                // Push only students with the top 3 number of poitns to mini leaderboard
                if (i < 3 && currentStudent._id !== userid) {
                    leaderboardList.push(student);
                }
            }
        } else {
            for (var i = 0; i < studentlist.length; ++i) {
                var currentStudent = studentlist[i];

                var student = {
                    userRank: -1,
                    id: currentStudent._id,
                    displayName:`${currentStudent.fname} ${currentStudent.lname[0]}.`,
                    picture: currentStudent.picture,
                    points:currentStudent.points,
                    accuracy:(currentStudent.totalAttemptsCount === 0)
                        ? 0
                        : ((currentStudent.correctAttemptsCount / currentStudent.totalAttemptsCount) * 100).toFixed(2),
                    attempt:(currentStudent.totalAttemptsCount === 0)
                        ? 0
                        : (currentStudent.points / currentStudent.totalAttemptsCount).toFixed(2),
                    overall:(currentStudent.totalAttemptsCount === 0)
                        ? 0
                        : (currentStudent.points *
                        ((currentStudent.correctAttemptsCount/currentStudent.totalAttemptsCount) +
                        (currentStudent.points/currentStudent.totalAttemptsCount))).toFixed(2)
                }
                leaderboardList.push(student);
            }
        }
        return callback(err, leaderboardList);
    });
}

/**
 * Fetch a list of students to display in the leaderboard.
 *
 * @param {function} callback
 */
exports.getFullLeaderboard = function (callback) {
    getStudentsList(function (err, studentlist) {
        if (err) {
            logger.error('Leaderboard error: ' + err);
            return callback(common.getError(2020), []);
        }

        var leaderboardList = [];
        for (var i = 0; i < studentlist.length; i++) {
            var currentStudent = studentlist[i];
            var student = {
                _id:currentStudent._id,
                points:currentStudent.points,
                correctAttemptsCount:currentStudent.correctAttemptsCount,
                accuracy:(currentStudent.totalAttemptsCount === 0)
                    ? 0
                    : ((currentStudent.correctAttemptsCount / currentStudent.totalAttemptsCount) * 100).toFixed(2),
                attempt:(currentStudent.totalAttemptsCount === 0)
                    ? 0
                    : (currentStudent.points / currentStudent.totalAttemptsCount).toFixed(2),
                overall:(currentStudent.totalAttemptsCount === 0)
                    ? 0
                    : (currentStudent.points *
                    ((currentStudent.correctAttemptsCount/currentStudent.totalAttemptsCount) +
                    (currentStudent.points/currentStudent.totalAttemptsCount))).toFixed(2)
            }
            leaderboardList.push(student);
        }
        return callback(err, leaderboardList);
    });
}

/**
 * Adds the user's feedback into the database
 *
 * @param {string} uuid
 * @param {string} subject
 * @param {string} message
 * @param {funciton} callback
 */
exports.addFeedback = function (uuid, subject, message, callback) {

    if (!uuid || !subject || !message) {
        logger.error('Failed to add user feedback, missing requirements');
        return callback(common.getError(8000), null);
    }

    var feedback = {};

    feedback.uuid = uuid;
    feedback.subject = subject;
    feedback.message = message;
    feedback.time = common.getDate();

    db.addFeedback(feedback, function (err, result) {
        if (err) {
            return callback(common.getError(8000), null);
        }

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

/**
 * Get all the feedback stored in the collection
 *
 * @param {function} callback
 */
exports.getFeedback = function (callback) {
    db.getFeedback(callback);
}

exports.updateUserPicture = function (userId, pictureId, callback) {
    db.updateUserByQuery({_id: userId}, {$set: {picture: pictureId}}, callback);
}