Build a Leave Management System with Node, MongoDB, and Express
A step-by-step build of a working leave management system. Staff request leave, managers approve or reject, and SendGrid delivers the news, with authentication, role checks, and a seeded admin. From 2020.
From the archive
An early tutorial from my first years of writing in public, preserved as part of the record. The engineer who wrote it was still learning out loud. Some practices here are not how I would build today, and that's rather the point.
Node.js is eating the world, many of the largest companies build more and more of their backends on it. This tutorial takes you step by step through building a fully functional leave management system. Along the way you'll work with Express, user authentication, role-based route protection, email notifications, and CRUD operations against MongoDB.1
What we want to build
A leave management system where a registered staff member of an organization can request leave, and an admin (manager or HR) can approve or reject the request, with an email notification sent to the staff member either way.
The build breaks into four modules.
- Project setup and server
- Authentication
- Authorization
- Leave request and approval
Main dependencies. Node.js, MongoDB, Babel, and SendGrid (for email).
Module 1, the server
Create a server.js file in the root folder.
const express = require('express');
const app = express();
const port = process.env.PORT || 3000;
const morgan = require('morgan');
const mongoose = require('mongoose');
app.use(express.json());
app.use(morgan('dev'));
const database = 'mongodb://localhost:27017/leavemanagementdb';
mongoose
.connect(database, {
useUnifiedTopology: true,
useFindAndModify: false,
useNewUrlParser: true,
useCreateIndex: true,
})
.then(() => console.log('connected to database', database))
.catch((err) => console.log('error mongodb', err));
app.listen(port, () => console.log('server running on port:', port));Run it with node server.js, if all goes well, your terminal reports the port and a successful database connection.
Module 2, authentication
Users need to register and log in. Create an authentication folder with four files. auth.model.js, auth.validation.js, auth.controller.js, and auth.route.js.
The user model, note the manager reference (staff pick their manager at registration) and the role enum that powers authorization later.
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const userSchema = new Schema(
{
fullname: { type: String },
manager: { type: Schema.Types.ObjectId, ref: 'user' }, // [!code highlight]
email: { type: String },
password: { type: String },
role: { type: String, enum: ['manager', 'staff'], default: 'staff' }, // [!code highlight]
deleted: { type: Boolean, default: false },
},
{ timestamps: true }
);
const Usermodel = mongoose.model('user', userSchema, 'Users');
module.exports = Usermodel;Input validation uses validatorjs.
'use strict';
const Validator = require('validatorjs');
export const UserValidator = {
validateAccount(obj) {
const rules = {
email: 'required|string',
password: 'required|string',
fullname: 'required|string',
manager: 'required|string',
};
const validator = new Validator(obj, rules);
return {
errors: validator.errors.all(),
passed: validator.passes(),
};
},
};The controller handles account creation (validate, reject duplicate emails, hash the password), login (compare the hash, sign a JWT), and a manager listing so staff can select their manager during registration.
const { UserValidator } = require('./auth.validation');
const User = require('./auth.model');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
// CREATE ACCOUNT
exports.CreateAccount = async (req, res, next) => {
try {
const obj = req.body;
const validateUserInput = UserValidator.validateAccount(obj);
if (!validateUserInput.passed) {
return res.status(400).json({
status: false,
message: "There's an error in your inputs",
errors: validateUserInput.errors,
});
}
const checkUserEmail = await User.findOne({ email: req.body.email });
if (checkUserEmail) {
return res.status(409).json({ status: false, message: 'email already exists' });
}
obj.password = await bcrypt.hash(obj.password, 10);
const account = new User(obj);
const saved = await account.save();
saved.password = null;
return res.status(201).json({ status: true, message: 'account created', data: saved });
} catch (e) {
return next(e);
}
};
// LOGIN
exports.login = async (req, res, next) => {
try {
const user = await User.findOne({ email: req.body.email });
if (!user) {
return res.status(400).json({ status: false, message: 'email not found' });
}
const isMatch = await bcrypt.compare(req.body.password, user.password);
if (!isMatch) {
return res.status(400).json({ status: false, message: 'incorrect password' });
}
const token = jwt.sign({ id: user._id }, process.env.JWT_SECRET);
user.password = null;
return res
.status(200)
.json({ status: true, message: 'login successful', data: { user, token } });
} catch (e) {
return next(e);
}
};
// list managers so staff can select theirs at registration
exports.listManagers = async (req, res, next) => {
try {
const managers = await User.find({ role: 'manager' });
return res.json(managers);
} catch (e) {
return next(e);
}
};And here are the routes.
'use strict';
const { Router } = require('express');
const UserController = require('./auth.controller');
const router = Router();
router.post('/create/account', UserController.CreateAccount);
router.post('/account/login', UserController.login);
router.get('/managers/list', UserController.listManagers);
module.exports = router;The mailer
For notifications we use SendGrid, create an account and grab an API key. Create a util/mailer.js file.
'use strict';
const Helper = require('sendgrid').mail;
const sg = require('sendgrid')(process.env.SENDGRID_KEY);
function sendMail(from, to, subject, content) {
const fromEmail = new Helper.Email(from);
const toEmail = new Helper.Email(to);
const emailContent = new Helper.Content('text/html', content);
const mail = new Helper.Mail(fromEmail, subject, toEmail, emailContent);
const request = sg.emptyRequest({
method: 'POST',
path: '/v3/mail/send',
body: mail.toJSON(),
});
sg.API(request, function (err, response) {
if (err) {
console.log('err in sendgrid:', err);
return;
}
console.log('sendgrid status:', response.statusCode);
});
}
module.exports.sendMail = sendMail;Module 4, leave requests and approval
Create a leave folder with three files. The model tracks who requested, why, and where the request stands.
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const leaveSchema = new Schema(
{
reason: { type: String },
staff: { type: Schema.Types.ObjectId, ref: 'user' },
leave_status: {
type: String,
enum: ['pending', 'approved', 'rejected'], // [!code highlight]
default: 'pending',
},
},
{ timestamps: true }
);
const leaveModel = mongoose.model('leave', leaveSchema, 'Leave');
module.exports = leaveModel;The controller wires the whole flow together, a request notifies the staff member's manager by email; a decision notifies the staff member.
const { sendMail } = require('../util/mailer');
const LeaveModel = require('./leave.model');
const User = require('../authentication/auth.model');
// request leave
exports.requestLeave = async (req, res, next) => {
try {
const staff = await User.findById(req.body.staff);
if (!staff) {
return res.status(400).json({ status: false, message: 'Invalid user details' });
}
const leaveRequest = new LeaveModel({ staff: staff._id, reason: req.body.reason });
await leaveRequest.save();
// notify the staff member's manager
const manager = await User.findOne({ _id: staff.manager });
await sendMail(
'noreply@leavemanagement',
manager.email,
'Leave Request',
`${staff.fullname} is requesting leave`
);
return res.status(200).json({ status: true, message: 'leave request sent' });
} catch (e) {
return next(e);
}
};
// approve or reject leave
exports.approveLeaveOrRejectLeave = async (req, res, next) => {
try {
const leave = await LeaveModel.findById(req.query.leave_id);
if (!leave) {
return res.status(404).json({ status: false, message: 'Leave request not found' });
}
const staff = await User.findById(leave.staff);
const decision = req.body.approvalstatus === 'approved' ? 'approved' : 'rejected';
await sendMail(
'noreply@leavemanagement',
staff.email,
'Leave Approval',
`Hello ${staff.fullname}, your leave request has been ${decision}`
);
leave.leave_status = req.body.approvalstatus;
await leave.save();
return res
.status(200)
.json({ status: true, message: 'leave request status updated successfully' });
} catch (e) {
return next(e);
}
};Module 3, authorization
Only managers should approve or reject leave. A small middleware decodes the JWT, loads the user, and checks the role. This is the authorization module in action.
const User = require('../authentication/auth.model');
const jwt = require('jsonwebtoken');
exports.ManagerChecker = async (req, res, next) => {
const token = req.headers.authorization;
if (!token) {
return res.status(400).json('please login to continue');
}
const decoded = jwt.decode(token);
if (decoded && decoded.id) {
const user = await User.findById(decoded.id);
if (user && user.role !== 'manager') { // [!code highlight]
return res.status(403).json({
status: false,
message: 'You are not authorized to do this action',
});
}
}
return next();
};The leave routes apply it to the approval endpoint only, anyone can request leave; only managers decide.
'use strict';
const { ManagerChecker } = require('../util/RoleChecker');
const { Router } = require('express');
const LeaveController = require('./leave.controller');
const router = Router();
router.post('/request/leave', LeaveController.requestLeave);
router.patch('/update/leave/status', ManagerChecker, LeaveController.approveLeaveOrRejectLeave);
module.exports = router;Seed a manager and test
Lastly, let's seed a manager account so we can test all our hard work, if you've made it this far, you're doing well.
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');
const db = 'mongodb://localhost:27017/leavemanagementdb';
mongoose.connect(db);
const User = require('../authentication/auth.model');
async function seedUser() {
const hash = await bcrypt.hash('password123', 10);
User.create({
email: 'youremail@gmail.com',
password: hash,
fullname: 'staff manager',
role: 'manager',
})
.then((user) => console.log(`${user} user created`))
.catch((err) => console.log(err))
.finally(() => mongoose.connection.close());
}
seedUser();Add the script to package.json.
{
"scripts": {
"seed": "node util/seeder.js"
}
}Run npm run seed, start the server, and walk the flow. Register a staff member (selecting the seeded manager), log in, request leave, then log in as the manager and approve it. Two emails later, you have a working leave management system.
The full source is on GitHub. You can extend this simple REST API with other features, leave balances, date ranges, an approval history, and that's exactly what I'd encourage you to try.
Thank you!
Footnotes
-
Originally published on my dev.to blog in November 2020. Revised for this site. Tidied code, secrets moved to environment variables, and one structural note preserved with affection, the modules are numbered 1, 2, 4, 3, because authorization only makes sense once you've seen what it protects. ↩