Curriculum Manager
Manager of student data and online student portal for private students.
HTML5 CSS3 Javascript
JQuery AJAX Youtube
Node.JS MongoDB Express Fuse.js
JSON Web Tokens Moment.js Mocha.js Chai.js
As a teacher for many years, I have always wanted an app that allows me to see all of my student's information as a glance. I noticed that there are similiar services for classroom application, but not really much for private instruction. This app supports such functionality and allows that information to be instantly searchable.
A common issue with private lessons is that assignments and instructions for students can be inconsistent. There are various methods I have tried over the years, including requiring students to have a notebook (they would misplace it), and using my tablet (it takes more time than I would like out of the lesson). I realized that the ideal would be a centralized place where these notes could kept. It was important to give students to log in with their own accounts to access notes and assignments that they were working on.
A clean user interface that could serve to allow student content to be displayed, edited, and searched was both necessary from an aesthetic and functional point of view.
Creating two classes of users using Node.js posed an interesting problem of how to make sure that each user in the teacher class can only access their students. The solution was to have teacher information written into every student document, and when the client connects to the server, it searches for students with that specific teacher (or author) key:
const studentAddressSchema = new mongoose.Schema({
street_address: {
type: String,
required: true,
trim: true
},
apartment_number: mongoose.Schema.Types.Mixed,
city: {
type: String,
required: true,
trim: true
},
state: {
type: String,
required: true,
minlength: 2,
maxlength: 2
},
zipcode: {
type: String,
required: true,
match: zipcodeValid
},
_id: false
});
const studentCurriculumSchema = new mongoose.Schema({
project_date: {
type: Date,
default: Date.now(),
required: true
},
project_name: {
type: String,
required: true,
trim: true
},
project_description: {
type: String,
required: true,
trim: true
},
// array of comments TODO
teacher_project_comments: {
type: String,
trim: true
},
_id: false
});
const studentLessonTimeSchema = new mongoose.Schema({
startDate: {
type: Date,
required: true,
default: Date()
},
weekday: {
type: String,
required: true,
trim: true
},
startTime: {
type: Date,
required: true
},
endTime: {
type: Date,
required: true
},
_id: false
});
const studentSchema = mongoose.Schema({
first_name: {
type: String,
required: true,
maxlength: maxlength,
trim: true
},
last_name: {
type: String,
required: true,
maxlength: maxlength,
trim: true
},
email: {
type: String,
required: true,
trim: true,
match: emailValid
},
parent_first_name: {
type: String,
maxlength: maxlength,
trim: true
},
parent_last_name: {
type: String,
maxlength: maxlength,
trim: true
},
address: {
type: studentAddressSchema,
required: true
},
student_curriculum: {
type: [studentCurriculumSchema]
},
student_lesson_time: {
type: studentLessonTimeSchema,
required: true
},
teacher_comments: {
type: String
}, // private to students (only teachers can see)
author: {
id: {
type: mongoose.Schema.Types.ObjectId
},
first_name: {
type: String
},
last_name: {
type: String
}
}
});
curriculum_router.get('/', (req, res) => {
if (req.user._user.role === 'student') {
Curriculum
.findOne({
'first_name': req.user._user.first_name,
'last_name': req.user._user.last_name
})
.then((student_record) => {
return res.status(200).json({
student_record: student_record.studentView()
});
})
.catch((err) => {
return res.status(500).json({
error: err
});
});
} else if (req.user._user.role === 'teacher') {
if (Object.keys(req.query).length === 0) {
Curriculum
.find({
'author.id': req.user._user._id
})
.then((student_records) => {
return res.status(200).json({
student_records: student_records.map((record) => {
return record.apiView();
})
});
})
.catch((err) => {
return res.status(500).json({
error: err
});
});
} else if (Object.keys(req.query).length > 0) {
const filtersToSearch = {};
const queryableFields = ['first_name', 'last_name', 'email', 'parent_first_name', 'parent_last_name', 'student_lesson_time'];
queryableFields.forEach(field => {
// ?first_name=
if (req.query[field]) {
filtersToSearch[field] = req.query[field];
}
});
Curriculum
.find(filtersToSearch)
.then(student_records => {
res.status(200).json({
student_records: student_records.map((record) => {
return record.apiView();
})
});
})
.catch((err) => {
res.status(500).json({
message: 'Something went wrong with the server'
});
});
}
}
});
Authentication was handled using JSON web tokens:
/* Strategy:
Check if fields are missing.
If there are, let the user know which ones.
If there are no missing fields,
Check if a user with that username exists,
If the user exists, return an error.
If not, hash the password then create the user.
To add: redirection
*/
auth_router.post('/register', (req, res) => {
const userFields = ['username', 'password', 'first_name', 'last_name', 'role'];
const missingFields = [];
userFields.forEach((field) => {
if (!(field in req.body) || (field === "")) {
missingFields.push(field);
}
});
if (missingFields.length !== 0) {
res.status(400).json({
missingFields
});
} else {
let password = req.body.password;
User
.find({
'username': req.body.username
})
.count()
.then((count) => {
if (count > 0) {
return res.status(422).json({
message: 'username already taken'
});
} else {
return User.hashPassword(password)
.then((hash) => {
return User
.create({
username: req.body.username,
password: hash,
role: req.body.role,
first_name: req.body.first_name,
last_name: req.body.last_name
})
.then((user) => {
return res.status(201).json(user.showUser());
})
.catch((err) => {
return res.status(500).json(err);
});
});
}
});
}
});
/* Strategy:
Accept a username and password to login
Search the database for the username
If the user is not found return an bad request error
If the user is found valid the password in the db by hashing the provided one and checking the hashes
If passwords do not match, return an unauthorized error
If passwords match, sign a json web token and send it to the user
To add: redirection
*/
auth_router.post('/login', (req, res) => {
const {
username,
password
} = req.body;
let _user;
User
.findOne({
username
})
.then((user) => {
_user = user;
if (user) {
return user.validatePassword(password);
} else { // user not found
return res.status(400).json({
error: 'Username or Password not found.'
});
}
})
.then((passwordValid) => {
if (passwordValid) {
const token = jwt.sign({
_user
}, SECRET);
return res.status(200).json({
token:token,
username:req.body.username,
url: `/welcome/dashboard/${req.body.username}`
});
} else { // password doesn't match
return res.status(401).json({
error: 'Username or Password not Found.'
});
}
})
.catch((err) => {
res.status(500).json({
error: err
});
});
});
A challenge on the front end was to validate student data. A solution here was to write custom validation instead of using built in HTML5 validation to prevent page refreshes with AJAX calls to the server.
// Validate main Student data both for add and edit student versions
function validateNewStudent(studentObj) {
// remove previous errors
$('.error, .errors').remove();
// return borders to normal
$('input').css('border', 'none');
var errors = {};
var formData = {};
formData.address = {};
formData.student_lesson_time = {};
$('.error').remove();
Object.keys(studentObj).forEach(function (field) {
if (studentObj[field] === "" || studentObj[field] === null) {
// If the following fields are empty, do nothing (they are optional)
switch (field) {
case 'parent_first_name':
case 'parent_last_name':
case 'apartment_number':
case 'teacher_comments':
break;
default:
errors[field] = `The ${field.replace('_', ' ')} field is empty.`;
}
// If the fields have values, then create custom error messages if they exist or add the fields to the formdata object as a success
} else {
switch (field) {
case "first_name":
case "last_name":
case "parent_first_name":
case "parent_last_name":
case 'existing_first_name':
case 'existing_last_name':
if (studentObj[field].length > 35) {
errors[field] = `The ${field.replace('_', ' ')} field must contain more than 35 characters.`;
} else {
formData[field] = studentObj[field];
}
break;
case "email":
case "existing_email":
var emailValid = new RegExp(/^.+@{1}.+\.[a-zA-Z]{2,4}$/);
if (!(emailValid.test(studentObj[field]))) {
errors[field] = `The ${field} field is not valid.`;
} else {
formData[field] = studentObj[field];
}
break;
case "city":
var cityValid = new RegExp(/^([ \u00c0-\u01ffa-zA-Z'\-]{2,20})+$/);
if (!(cityValid.test(studentObj[field]))) {
errors[field] = `The ${field} field must contain only UTF-8 letters and be between 2 and 20 characters.`;
} else {
formData.address[field] = studentObj[field];
}
break;
case "zipcode":
var zipcodeValid = new RegExp(/^\d{5}(?:[-\s]\d{4})?$/);
if (!(zipcodeValid.test(studentObj[field]))) {
errors[field] = `The ${field} field must either be in the format XXXXX or XXXXX-XXXX.`;
} else {
formData.address[field] = studentObj[field];
}
break;
case "apartment_number":
case "state":
case "street_address":
formData.address[field] = studentObj[field];
break;
case "teacher_comments":
formData[field] = studentObj[field];
break;
case "startDate":
case "weekday":
case "startTime":
formData.student_lesson_time[field] = studentObj[field];
break;
case "endTime":
var startTime = moment(studentObj.startTime).format("HH:mm");
var endTime = moment(studentObj.endTime).format("HH:mm");
var startTimeN = parseInt(startTime.replace(":", ""));
var endTimeN = parseInt(endTime.replace(":", ""));
if (endTimeN < startTimeN) {
errors[field] = `The ${field} cannot be before the start time.`;
} else {
formData.student_lesson_time[field] = studentObj[field];
}
}
}
});
// If the error object has keys, render the error html.
if (Object.keys(errors).length > 0) {
var errorHtml = Object.keys(errors).map(function (elem, index) {
var currentError = errors[elem];
var errorMsg = ` ${currentError}
`;
$(`input[name="${elem}"]`).css('border', '0.3vw solid red');
if (index === 0) {
var errorDiv = ` ${currentError}
`;
return (errorDiv);
} else if (index === Object.keys(errors).length-1) {
var closeErrDiv = ` ${currentError}
`;
return (closeErrDiv);
} else {
return errorMsg;
}
}).join("").replace(',', "");
$(`.mode_select`).after(errorHtml);
// Scroll to start of form
$(document).scrollTop(138.5);
return errors;
// If there are no errors, return the form data with a success message
} else {
formData.message = "No errors found.";
return formData;
}
}
Sorting the data using a real time search feature was important as well. One extremely useful sub feature is displaying students in order according to the current day.
// Listener for typing in the input search box for the student list
function studentSearchListener() {
$('input#student_search').keydown(
$.debounce(500, sortResults)
);
}
// sort results based on the value of the Select tag option on the student list using fuse.js library
function sortResults(event) {
var input = $('input#student_search').val();
var options = {
shouldSort: true,
tokenize: true,
matchAllTokens: true,
threshold: 0.3,
location: 0,
distance: 20,
maxPatternLength: 32,
minMatchCharLength: 4,
keys: [
"first_name",
"last_name",
"parent_first_name",
"parent_last_name",
"email",
"address.city",
"address.state",
"address.street_address",
"address.zipcode",
"student_lesson_time.weekday",
]
};
var fuse = new Fuse(state.student_records, options);
var result = fuse.search(input);
state.current_sort = result;
var arrayToSort = null;
if (input === "") {
arrayToSort = state.student_records;
} else {
arrayToSort = state.current_sort;
}
var arrayToDisplay = null;
var selected = +($('select#sort').val());
if (selected === 5) {
arrayToDisplay = daySort(arrayToSort);
} else if (selected) {
arrayToDisplay = sortFields(arrayToSort,
sortOptions[selected][0],
sortOptions[selected][1]);
}else {
arrayToDisplay = arrayToSort;
}
$('.card_container').empty();
renderStudentCard(arrayToDisplay);
}
// wrapper for .sort function to select various fields for sort
function sortFields (arr, field, descending = true ) {
var sorted = arr.sort(function(a, b) {
if (a[field] < b[field]) {
if (descending === true) {
return 1;
} else {
return -1;
}
}
if (a[field] > b[field]) {
if (descending === true) {
return -1;
} else {
return 1;
}
}
return 0;
});
return sorted;
}
// Sorts on changes to the select tag
function studentListSortListener() {
$('select#sort').change(sortResults);
}
// Sorts students according to the upcoming schedule when the Select tag is set to 'upcoming lessons'
function daySort(arr) {
var daysArray = arr.map(function(element) {
var dayElement = {
weekday: element.student_lesson_time.weekday,
id: element.id,
startTime: moment(element.student_lesson_time.startTime).format('HH:mm')
};
return dayElement;
});
var weekdays = [
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday'
];
var currentDay = weekdays.indexOf(moment().format('dddd'));
var days = {};
weekdays.forEach(function(day, i) {
days[day] = (i + 7 - currentDay) % 7;
});
var currentDay = moment().format('dddd');
daysArray.sort(function(a, b){
return days[a.weekday] - days[b.weekday]
|| parseInt(a.startTime.replace(':', '')) - parseInt(b.startTime.replace(':', ''));
});
var daySortedArray = daysArray.map(function(record) {
var currentID = record.id;
var foundRecord = state.student_records.find(function(student_record) {
if (currentID === student_record.id) {
return student_record;
}
});
return foundRecord;
});
return daySortedArray;
}