/*
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('mongodb').Db;
const Server = require('mongodb').Server;
const logger = require('./log.js');
const common = require('./common.js');
const bcrypt = require('bcryptjs');
const DB_HOST = process.env.DB_HOST || 'localhost';
const DB_PORT = process.env.DB_PORT || 27017;
const DB_NAME = process.env.DB_NAME || 'quizzard';
const db = new Db(DB_NAME, new Server(DB_HOST, DB_PORT));
var usersCollection;
var questionsCollection;
var analyticsCollection;
var feedbackCollection;
var settingsCollection;
var vfsCollection;
var nextQuestionNumber = 0;
/**
* Open a connection to the database
*
* @param {function} callback
*/
exports.initialize = function (callback) {
db.open(function (err, db) {
if (err) {
logger.error(common.getError(1004).message);
return callback(err, null);
}
logger.log('Connection to Quizzard database successful.');
usersCollection = db.collection('users');
questionsCollection = db.collection('questions');
analyticsCollection = db.collection('analytics');
feedbackCollection = db.collection('feedback');
settingsCollection = db.collection('settings');
vfsCollection = db.collection('virtualFileSystem');
getNextQuestionNumber(function () {
logger.log(common.formatString('next question number: {0}', [nextQuestionNumber]));
return callback(err, null);
});
});
}
/**
* add a student
*
* @param {object} student
* @param {function} callback
*/
exports.addStudent = function (student, callback) {
addUser(student, callback);
}
/**
* add a user
*
* @param {object} admin
* @param {function} callback
*/
exports.addAdmin = function (admin, callback) {
addUser(admin, callback);
}
/**
* add a user
*
* @param {object} user
* @param {function} callback
*/
var addUser = function (user, callback) {
usersCollection.findOne({$or:[{_id: user._id}, {username: user.username}]}, function (err, obj) {
if (err) {
logger.error(JSON.stringify(err));
return callback(common.getError(2014), null);
}
if (obj) {
return callback(common.getError(2019), null);
}
usersCollection.insert(user, function (err, res) {
return callback(err, user);
});
});
}
/**
* get admin list
*
* @param {function} callback
*/
exports.getAdminsList = function (callback) {
getUsersList({type: common.userTypes.ADMIN}, {username: 1}, callback);
}
/**
* get student list
*
* @param {function} callback
*/
exports.getStudentsList = function (callback) {
getUsersList({type: common.userTypes.STUDENT}, {username: 1}, callback);
}
/**
* get users list
*
* @param {function} callback
*/
exports.getUsersList = function (callback) {
getUsersList({}, {username: 1}, callback);
}
/**
* get students list with status
*
* @param {string} status
* @param {function} callback
*/
exports.getStudentsListWithStatus = function (status, callback) {
getUsersList({type: common.userTypes.STUDENT, active: status}, {username: 1}, callback);
}
/**
* Return an array of users in the database, sorted by rank
*
* @param {object} findQuery
* @param {object} sortQuery
* @param {function} callback
*/
var getUsersList = function (findQuery, sortQuery, callback) {
usersCollection.find(findQuery).sort(sortQuery).toArray(function (err, docs) {
if (err) {
return callback(common.getError(2013), []);
}
return callback(null, docs);
});
}
/**
* get students list sorted
*
* @param {int} lim
* @param {function} callback
*/
exports.getStudentsListSorted = function (lim, callback) {
usersCollection.find({type: common.userTypes.STUDENT, active:{$ne: false}})
.sort({points: -1})
.limit(lim)
.toArray(function (err, docs) {
if (err) {
return callback(common.getError(2015), null);
}
return callback(null, docs);
});
}
/**
* get user by id
*
* @param {string} userId
* @param {function} callback
*/
exports.getUserById = function (userId, callback) {
getUserById(userId, callback);
}
/**
* Check if the account given by user and pass is valid
* user type of null
*
* @param {string} userId
* @param {string} pass
* @param {function} callback
*/
exports.checkLogin = function (userId, pass, callback) {
usersCollection.findOne({username : userId}, function (err, obj) {
if (err) {
logger.error(JSON.stringify(err));
return callback(common.getError(2014), null);
}
if (!obj) {
return callback(common.getError(2021), null);
}
if (!obj.active) {
return callback(common.getError(2022), null);
}
validatePassword(obj, pass, function (err, valid) {
if (err) {
return callback(common.getError(1005), null);
}
if (valid) {
delete obj.password;
return callback(null, obj);
}
return callback(common.getError(1006), null);
});
});
}
/**
* Check the hash of pass against the password stored in userobj
*
* @param {object} userobj
* @param {string} pass
* @param {function} callback
*/
var validatePassword = function (userobj, pass, callback) {
bcrypt.compare(pass, userobj.password, function (err, obj) {
callback(err, obj);
});
}
/**
* cleanup the users collection
*
* @param {function} callback
*/
exports.removeAllUsers = function (callback) {
usersCollection.remove({}, function (err, obj) {
if (err) {
logger.error(JSON.stringify(err));
return callback(common.getError(1008), null);
}
logger.log('All users have been removed');
return callback(null, obj);
});
}
/**
* get student by id
*
* @param {string} studentId
* @param {function} callback
*/
exports.getStudentById = function (studentId, callback) {
getUserById(studentId, callback);
}
/**
* get admin by id
*
* @param {string} adminId
* @param {function} callback
*/
exports.getAdminById = function (adminId, callback) {
getUserById(adminId, callback);
}
/**
* get user by id
*
* @param {string} userId
* @param {function} callback
*/
var getUserById = function (userId, callback) {
getUserObject({_id : userId}, callback);
}
/**
* get user by search query
*
* @param {object} findQuery
* @param {function} callback
*/
var getUserObject = function (findQuery, callback) {
usersCollection.findOne(findQuery, function (err, obj) {
if (err) {
logger.error(JSON.stringify(err));
return callback(common.getError(2017), null);
}
return callback(null, obj);
});
}
exports.getUserObject = getUserObject;
/**
* update user by id
*
* @param {string} userId
* @param {object} info
* @param {function} callback
*/
exports.updateUserById = function (userId, info, callback) {
updateUserById(userId, info, callback);
}
/**
* update student by id
*
* @param {string} userId
* @param {object} info
* @param {function} callback
*/
exports.updateStudentById = function (userId, info, callback) {
updateUserById(userId, info, callback);
}
/**
* update admin by id
*
* @param {string} userId
* @param {object} info
* @param {function} callback
*/
exports.updateAdminById = function (userId, info, callback) {
updateUserById(userId, info, callback);
}
/**
* update user by id
*
* @param {string} userId
* @param {object} info
* @param {function} callback
*/
var updateUserById = function (userId, info, callback) {
var currentDate = new Date().toString();
var query = { _id : userId };
var update = {};
update.$addToSet = {};
update.$inc = {};
update.$pull = {};
update.$set = { mtime : currentDate };
update.$push = {};
if ('username' in info) {
update.$set.username = info.username;
}
if ('fname' in info) {
update.$set.fname = info.fname;
}
if ('lname' in info) {
update.$set.lname = info.lname;
}
if ('email' in info) {
update.$set.email = info.email;
}
if ('points' in info && parseInt(info.points)) {
update.$set.points = parseInt(info.points);
}
if ('rating' in info && parseInt(info.rating)) {
update.$push.ratings = {
question: info.questionId,
date: currentDate,
rating: info.rating
}
}
if (typeof info.active !== 'undefined') {
update.$set.active = info.active;
}
if (common.isEmptyObject(update.$addToSet)) {
delete update.$addToSet;
}
if (common.isEmptyObject(update.$inc)) {
delete update.$inc;
}
if (common.isEmptyObject(update.$set)) {
delete update.$set;
}
if (common.isEmptyObject(update.$pull)) {
delete update.$pull;
}
if (common.isEmptyObject(update.$push)) {
delete update.$push;
}
if (typeof info.newPassword === 'undefined') {
usersCollection.update(query, update, function (err, obj) {
if (err) {
logger.error(JSON.stringify(err));
return callback(common.getError(2018), null);
}
return callback(null, 'success');
});
} else {
update.$set.password = info.newPassword;
updateUserPassword(query, update, info.newPassword, callback);
}
}
/**
* update user password
*
* @param {object} query
* @param {object} update
* @param {string} password
* @param {function} callback
*/
exports.updateUserPassword = function (query, update, password, callback) {
updateUserPassword(query, update, password, callback);
}
/**
* update user password
*
* @param {object} query
* @param {object} update
* @param {string} password
* @param {function} callback
*/
var updateUserPassword = function (query, update, password, callback) {
bcrypt.hash(password, 11, function (err, hash) {
if (err) {
logger.error(JSON.stringify(err));
return callback(common.getError(1009), null);
}
if (update.$set && !common.isEmptyObject(update.$set)) {
update.$set.password = hash;
} else {
update.$set = {password: hash};
}
usersCollection.update(query, update, function (err, obj) {
if (err) {
logger.error(JSON.stringify(err));
return callback(common.getError(2018), null);
}
return callback(null, 'success');
});
});
}
/**
* update users collection directly by a query
*
* @param {object} query
* @param {object} update
* @param {function} callback
*/
exports.updateUserByQuery = function (query, update, callback) {
usersCollection.update(query, update, function (err, obj) {
return callback(err, obj);
});
}
/**
* Questions functions
* Add QUESTION to questionsCollection in the database
*
* @param {object} question
* @param {function} callback
*/
exports.addQuestion = function (question, callback) {
question.number = ++nextQuestionNumber;
questionsCollection.insert(question, function (err, res) {
if (err) {
logger.error(JSON.stringify(err));
return callback(common.getError(3018), null);
}
return callback(null, question.number);
});
}
/**
* cleanup the users collection
*
* @param {function} callback
*/
exports.removeAllQuestions = function (callback) {
questionsCollection.remove({}, function (err, res) {
if (err) {
logger.error(JSON.stringify(err));
return callback(common.getError(1008), null);
}
nextQuestionNumber = 0;
logger.log('All questions have been removed');
logger.log(common.formatString('next question number: {0}', [nextQuestionNumber]));
return callback(null, res);
});
}
/**
* get next question number
*
* @param {function} callback
*/
var getNextQuestionNumber = function (callback) {
questionsCollection.find().sort({number: -1}).limit(1).toArray(function (err, docs) {
if (err) {
logger.error(JSON.stringify(err));
process.exit(1);
}
nextQuestionNumber = docs[0] ? docs[0].number : 0;
return callback(nextQuestionNumber);
});
}
/**
* get the list of Active questions sorted
*
* @param {object} findQuery
* @param {object} sortQuery
* @param {function} callback
*/
exports.getQuestionsList = function (findQuery, sortQuery, callback) {
var query = findQuery;
query['deleted'] = {$ne: true};
questionsCollection.find(query).sort(sortQuery).toArray(function (err, docs) {
if (err) {
return callback(common.getError(3017), null);
}
for (q in docs) {
docs[q].firstAnswer = docs[q].correctAttempts[0] ? docs[q].correctAttempts[0].userId : 'No One';
}
return callback(null, docs);
});
}
/**
* get the list of questions sorted
*
* @param {object} findQuery
* @param {object} sortQuery
* @param {function} callback
*/
exports.getQuestionsListforAdmin = function (findQuery, sortQuery, callback) {
questionsCollection.find(findQuery).sort(sortQuery).toArray(function (err, docs) {
if (err) {
return callback(common.getError(3017), null);
}
for (q in docs) {
docs[q].firstAnswer = docs[q].correctAttempts[0] ? docs[q].correctAttempts[0].userId : 'No One';
}
return callback(null, docs);
});
}
/**
* Extract a question object from the database using its ID
*
* @param {object} findQuery
* @param {function} callback
*/
exports.lookupQuestion = function (findQuery, callback) {
var query = findQuery;
query['deleted'] = {$ne: true};
questionsCollection.findOne(query, function (err, question) {
if (err) {
return callback(common.getError(3019), null);
}
if (!question) {
return callback(common.getError(3003), null);
}
/* necessary for later database update */
question.firstAnswer = question.correctAttempts[0] ? question.correctAttempts[0].userId : 'No One';
return callback(null, question);
});
}
/**
* update a question record based on its id
*
* @param {string} questionId
* @param {object} request
* @param {function} callback
*/
exports.updateQuestionById = function (questionId, request, callback) {
var currentDate = new Date().toString();
var query = {_id: questionId};
var update = {};
update.$addToSet = {};
update.$push = {};
update.$pull = {};
update.$set = {};
update.$inc = {};
if ('topic' in request) {
update.$set.topic = request.topic;
}
if ('title' in request) {
update.$set.title = request.title;
}
if ('text' in request) {
update.$set.text = request.text;
}
if ('answer' in request) {
update.$set.answer = request.answer;
}
if ('hint' in request) {
update.$set.hint = request.hint;
}
if ('minpoints' in request) {
update.$set.minpoints = request.minpoints;
}
if ('maxpoints' in request) {
update.$set.maxpoints = request.maxpoints;
}
if ('choices' in request) {
update.$set.choices = request.choices;
}
if ('leftSide' in request) {
update.$set.leftSide = request.leftSide;
}
if ('rightSide' in request) {
update.$set.rightSide = request.rightSide;
}
if ('visible' in request) {
update.$set.visible = request.visible;
}
if (common.isEmptyObject(update.$addToSet)) {
delete update.$addToSet;
}
if (common.isEmptyObject(update.$push)) {
delete update.$push;
}
if (common.isEmptyObject(update.$set)) {
delete update.$set;
}
if (common.isEmptyObject(update.$pull)) {
delete update.$pull;
}
if (common.isEmptyObject(update.$inc)) {
delete update.$inc;
}
questionsCollection.update(query, update, function (err, info) {
if (err) {
return callback(common.getError(3020), null);
}
return callback(null, 'success');
});
}
/**
* update users collection directly by a query
*
* @param {object} query
* @param {object} update
* @param {function} callback
*/
exports.updateQuestionByQuery = function (query, update, callback) {
questionsCollection.update(query, update, function (err, obj) {
return callback(err, obj);
});
}
// add feedback to feedback collections directly using a query
exports.addFeedback = function (feedback, callback) {
feedbackCollection.insert(feedback, function (err, res) {
if (err) {
logger.error('Failed to add feedback');
return callback(common.getError(8000), null);
}
return callback(null, 'success');
});
}
exports.getFeedback = function (callback) {
feedbackCollection.find().sort({time: -1}).toArray(function (err, res) {
if (err) {
logger.error('Failed to get feedback');
return callback(common.getError(8001), res);
}
return callback(null, res);
});
}
exports.removeAllFeedback = function (callback) {
feedbackCollection.remove({}, function (err, result) {
if (err) {
return callback(common.getError(8002), null);
}
return callback(null, 'ok');
});
}
/**
* reset all settings to default
*
* @param {function} callback
*/
exports.resetAllSettings = function (callback) {
resetAllSettings(callback);
}
/**
* reset all settings to default
*
* @param {function} callback
*/
var resetAllSettings = function (callback) {
settingsCollection.remove({}, function (err, result) {
if (err) {
return callback(common.getError(7000), null);
}
var defaultSettings = {};
defaultSettings._id = common.getUUID();
defaultSettings['general'] = {};
defaultSettings['student'] = {};
defaultSettings['question'] = {};
defaultSettings['discussionboard'] = {};
defaultSettings.general['active'] = true;
defaultSettings.general['leaderboardLimited'] = false;
defaultSettings.general['leaderboardLimit'] = 3;
defaultSettings.student['editNames'] = false;
defaultSettings.student['editEmail'] = false;
defaultSettings.student['editPassword'] = true;
defaultSettings.question['defaultTopic'] = null;
defaultSettings.question['defaultMinPoints'] = 10;
defaultSettings.question['defaultMaxPoints'] = 100;
defaultSettings.question['timeoutEnabled'] = true;
defaultSettings.question['timeoutPeriod'] = 30*60000;
defaultSettings.discussionboard['visibility'] = common.discussionboardVisibility.ALL;
defaultSettings.discussionboard['dislikesEnabled'] = true;
settingsCollection.insert(defaultSettings, function (err, obj) {
if (err) {
return callback(common.getError(7002), null);
}
return callback(null, defaultSettings);
});
});
}
/**
* get all settings objects from the collection
*
* @param {function} callback
*/
exports.getAllSettings = function (callback) {
getAllSettings(callback);
}
/**
* get all settings objects from the collection
*
* @param {function} callback
*/
var getAllSettings = function (callback) {
settingsCollection.findOne({}, function (err, obj) {
if (err) {
return callback (common.getError(7003), null);
}
else if (!obj) {
resetAllSettings(function (err, result) {
if (err) {
return callback(err, null)
}
return callback(null, result);
});
} else {
return callback (null, obj);
}
});
}
/**
* remove all previous analytics
*
* @param {function} callback
*/
exports.removeAnalytics = function (callback) {
analyticsCollection.remove({}, function (err, obj) {
if (err) {
logger.error(JSON.stringify(err));
return callback(common.getError(1008), null);
}
logger.log('All analytics have been removed');
return callback(null, obj);
});
}
/**
* add student analytics
* if there are no records of the student, create a new record
* if there are recards of the student, get the last recard and compute the deltas
*
* @param {string} studentId
* @param {string} date
* @param {object} info
* @param {function} callback
*/
exports.addStudentAnalyticsWithDate = function (studentId, date, info, callback) {
var query = {_id: studentId};
var update = {};
analyticsCollection.findOne(query, function (err, student) {
if (err) {
return callback(err, null);
}
if (!student) {
info.correctAttemptsDelta = 0;
info.wrongAttemptsDelta = 0;
info.totalAttemptsDelta = 0;
info.pointsDelta = 0;
info.accuracyDelta = 0;
update._id = studentId;
update.dates = [{date: date, info: info}];
analyticsCollection.insert(update, function (err, obj) {
if (err) {
return callback(err, null);
}
return callback(null, obj);
});
}
if (student) {
info.correctAttemptsDelta = info.correctAttemptsCount - student.dates[student.dates.length-1].info.correctAttemptsCount;
info.wrongAttemptsDelta = info.wrongAttemptsCount - student.dates[student.dates.length-1].info.wrongAttemptsCount;
info.totalAttemptsDelta = info.totalAttemptsCount - student.dates[student.dates.length-1].info.totalAttemptsCount;
info.pointsDelta = info.points - student.dates[student.dates.length-1].info.points;
info.accuracyDelta = (info.accuracy - student.dates[student.dates.length-1].info.accuracy).toFixed(2);
update.$push = {dates: {date: date, info: info}};
analyticsCollection.update(query, update, function (err, info) {
if (err) {
return callback(err, null);
}
return callback(null, info);
});
}
});
}
exports.getTimeBasedAnalytics = function (findQuery, callback) {
analyticsCollection.findOne(findQuery, function (err, data) {
if (err) {
return callback(err, null);
}
if (!data) {
return callback('invalid search query', null);
}
return callback(null, data);
});
}
/**
* update settings object
*
* @param {object} findQuery
* @param {object} updateQuery
* @param {function} callback
*/
exports.updateSettings = function (findQuery, updateQuery, callback) {
settingsCollection.update(findQuery, updateQuery, callback);
}
/**
* clean up the virtual file system
*
* @param {function} callback
*/
exports.removeVirtualFileSystem = function (callback) {
vfsCollection.remove({}, function (err, result) {
if (err) {
logger.error(JSON.stringify(err));
return callback(common.getError(9000), null);
}
return callback(null, 'ok');
});
}
/**
* add item to the virtual file system
*
* @param {function} callback
*/
exports.addToVirtualFileSystem = function (object, callback) {
vfsCollection.insert(object, function (err, obj) {
if (err) {
return callback(common.getError(9001), null);
}
return callback(null, 'ok');
});
}
/**
* find in the virtual file system
*
* @param {function} callback
*/
exports.findInVirtualFileSystem = function (findQuery, callback) {
vfsCollection.findOne(findQuery, function (err, obj) {
if (err) {
return callback(common.getError(9002), null);
}
if (!obj) {
return callback(common.getError(9003), null);
}
return callback(null, obj);
});
}