How to create a simple website with Node.js and the default MongoDB database

How to create a simple website with Node.js and the default MongoDB database

In this article we're going to see how to build a website using Node.js and the default MongoDB database.

In this article we're going to see how to build a website using Node.js and the default MongoDB database.

Project goals

The default MongoDB database, named test, contains the restaurants collection with more than 25,000 records.

Our site will show the relevant restaurants data as follows:

  1. The homepage will show six restaurants
  2. Visitors will be able to search for a specific restaurant using the site's search engine.
  3. Each restaurant will have its own page with its data, a featured image, a Google map, a form that will allow visitors to vote the current restaurant and six related restaurants shown as markers on another Google map. Finally, visitors will be able to book a table by using a specific form which will send an e-mail with the relevant booking data.
  4. Our site will feature a Top Ten with the best rated restaurants.

Setting up the database

The default MongoDB's collection lacks of the featured image among the fields of each document.

For that reason, we have to slightly modify this structure by adding the image field on the MongoDB shell:

const rand = items => {
  return items[Math.floor(Math.random()*items.length)];
}

const images = ['1.jpg', '2.jpg', '3.jpg'/*...*/];

db.restaurants.find().forEach(r => {
    let img = rand(images);
    r.image = img;
    db.restaurants.save(r);
});

Now our document structure looks like this:

{
 address: Object,
 borough: String,
 cuisine: String,
 grades: Array,
 name: String,
 restaurant_id: String,
 image: String
 }

An example:

{
  "address" : {
    "building" : "469",
    "coord" : [
        -73.961704,
        40.662942
    ],
    "street" : "Flatbush Avenue",
    "zipcode" : "11225"
},
"borough" : "Brooklyn",
"cuisine" : "Hamburgers",
"grades" : [
    {
        "date" : ISODate("2014-12-30T00:00:00Z"),
        "grade" : "A",
        "score" : 8
    },
    {
        "date" : ISODate("2014-07-01T00:00:00Z"),
        "grade" : "B",
        "score" : 23
    },
    {
        "date" : ISODate("2013-04-30T00:00:00Z"),
        "grade" : "A",
        "score" : 12
    },
    {
        "date" : ISODate("2012-05-08T00:00:00Z"),
        "grade" : "A",
        "score" : 12
    }
],
"name" : "Wendy'S",
"restaurant_id" : "30112340",
"image" : "pexels-photo-26981.jpg"
}

The address field contains the geolocalization data in the coord field (here longitude comes first in the array).

grades is an array of objects which in turn contain sthe relevant data related to the current restaurant's rating, including the date.

restaurant_id will be used in permalinks. If you think that this is not so SEO-friendly, you can create an additional slug field in the Mongo shell by using the name field like so:

db.restaurants.find().forEach(r => {
   let name = r.name.toLowerCase();
   let slug = name.replace(/[^a-z0-9]+/g, '');
   r.slug = slug;
   db.restaurants.save(r);
});

Now we get:

{
  //...
"name" : "Wendy'S",
  "restaurant_id" : "30112340",
"image" : "pexels-photo-26981.jpg",
"slug": "wendys"
}

Better. Then we can start querying our database in order to implement three of the features mentioned earlier, namely the related restaurants, our Top Ten and the site's search engine.

Related restaurants are simply a set of restaurants with a common field excluding the current restaurant. The common field can be either the borough or the cuisine field. We need to filter out the current restaurant by passing its Mongo ID to our query:

db.restaurants.find({cuisine: 'Hamburgers', borough: 'Brooklyn', _id: { $not: { $eq: '5953d2b67eddda6789601f93' }}}).limit(6);

Here we're getting six restaurants with the same cuisine and borough of Wendy's restaurant except the Wendy's one.

Our Top Ten, instead, needs Mongo's aggregation. We have to select the most rated restaurants using the maximum score value in the grades array:

db.restaurants.aggregate([
        { $unwind : '$grades' },
        { $group : {
            _id : { restaurant_id: "$restaurant_id", name: "$name" },
            'scores' : { $sum : '$grades.score' }
        } },
        { $sort : { 'scores': -1 } },
        { $limit : 10 }
    ]);

The returned values are the restaurant ID along with its name and total score. scores contains the sum of all the score field of the objects contained within the grades array.

And we get:

{ "_id" : { "restaurant_id" : "41602559", "name" : "Red Chopstick" }, "scores" : 254 }
{ "_id" : { "restaurant_id" : "41164678", "name" : "Nios Restaurant" }, "scores" : 227 }
{ "_id" : { "restaurant_id" : "40366157", "name" : "Nanni Restaurant" }, "scores" : 225 }
{ "_id" : { "restaurant_id" : "41459809", "name" : "Amici 36" }, "scores" : 215 }
{ "_id" : { "restaurant_id" : "41660581", "name" : "Cheikh Umar Futiyu Restaurant" }, "scores" : 212 }
{ "_id" : { "restaurant_id" : "41239374", "name" : "East Market Restaurant" }, "scores" : 209 }
{ "_id" : { "restaurant_id" : "41434866", "name" : "Bella Vita" }, "scores" : 205 }
{ "_id" : { "restaurant_id" : "41233430", "name" : "Korean Bbq Restaurant" }, "scores" : 204 }
{ "_id" : { "restaurant_id" : "40372466", "name" : "Murals On 54/Randolphs'S" }, "scores" : 202 }
{ "_id" : { "restaurant_id" : "40704853", "name" : "B.B. Kings" }, "scores" : 199 }

Finally, our search engine will make use of the $regex operator to search for restaurants by their name:

db.restaurants.find({
    name: { "$regex": 'query', "$options": "i" }
}).limit(6);

This kind of operator is here used with the case-insensitive flag in order to get more results. If you prefer the other approach with the $text operator, you need to add an index to the collection on the name field.

Setting up the Node.js environment

In order to get our website work, we need the following relevant packages:

  • ExpressJS
  • Mongoose
  • Body Parser
  • Express Handlebars for using Handlebars as our template engine
  • Nodemailer for sending e-mails
  • Validator for validating incoming forms

Here's our package.json file:

{
"name": "Restaurants",
"version": "1.0.0",
"private": true,
"description": "Sample app",
"author": "Name <user@site.com>",
"dependencies": {
"body-parser": "^1.13.2",
"express": "^4.13.1",
"express-handlebars": "^3.0.0",
"express-url-breadcrumb": "0.0.8",
"mongoose": "4.9.9",
"nodemailer": "^4.0.1",
"serve-favicon": "^2.4.2",
"validator": "^8.0.0"
},
"license": "MIT"
}

Then:

npm install

Directory structure

Our website directory structure will be as follows:

  • /lib will contain the Handlebars helpers, our config file and the relevant app classes.
  • /models will contain the Mongoose schemas.
  • /public will contain the frontend assets.
  • /views will contain the Handlebars templates
    • /layouts will contain the main Handlebars layout
    • /partials will contain the template components shared by the views.

The config.js file

This file contains the basic settings of several features used in our application:

'use strict';
module.exports = {
apiKey: 'Google Maps API key',
imagesPath: '/public/images/',
adminEmail: 'your email',
mail: {
    service: 'Gmail',
    auth: {
        user: 'user@gmail.com',
        pass: 'password'
    }
}
};

apiKey will be used when we want to create the Google maps in the singular restaurant's page. imagesPath is the base absolute path to the directory containing the featured images of restaurants. adminEmail and mail will be used by Nodemailer when sending e-mails. If you don't want to use Gmail with an app specific password (you should enable Less secure apps in your Google's account), you can use an SMTP account instead:

mail: {
host: 'smtp.example.com',
port: 465,
secure: true,
/* secure:true for port 465,
secure:false for port 587
*/
auth: {
    user: 'username@example.com',
    pass: 'userpass'
}
};

As a rule of thumb in this case, you should always triple-check your hosting mail settings before changing these parameters. Just make sure to use the same settings of your email client and everything should work smoothly.

The helpers.js file

Handlebars is really handy because it allows developers to specify custom helper functions to be used as special template tags such as {{{helper arguments}}}.

Let's say that we want to insert the full URL of the restaurant's featured image. We can write something like this:

'use strict';

const config = require('./config');

module.exports = {
    the_image: restaurant => {
  return config.imagesPath + restaurant.image;
    }
};

Once defined, we need to add all of our helpers to Handlebars:

// app.js
'use strict';
const express = require('express');
const exphbs = require('express-handlebars');
const helpers = require('./lib/helpers');
const app = express();

app.engine('.hbs', exphbs({
    extname: '.hbs',
    defaultLayout: 'main',
    helpers: helpers
}));
app.set('view engine', '.hbs');

Then we can use the the_image helper in our templates:

<!-- views/restaurants.hbs -->
<ul id="restaurants">
{{#each restaurants}}
    <li style="background-image: url({{{the_image this}}});">
        <a href="/restaurants/{{this.restaurant_id}}">
            <h3>{{this.name}}</h3>
            <p>{{this.address.street}} {{this.address.building}}, {{this.borough}}</p>
        </a>
    </li>
{{/each}}
</ul>

You can notice the benefits of helpers: data processing and views are kept separate, whereas in EJS for example you have to manually build a string by adding the full images path to the Express locals object.

Another example are dates. With the aid of helpers you can do the following:

 module.exports = {
    date: d => {
      let dt = Date.parse(d);
    let output = '';
    if(!isNaN(dt)) {
        let ds = new Date(dt);
        output = ds.toLocaleDateString();
    }
    return output;
  }
};

In this case we're formatting dates into a locale date string. This is only a basic example, but if you know how the Ghost CMS works then you should also know what you can accomplish with template helpers.

The Mail class

In order to send e-mails we have create a simple wrapper class around the Nodemailer package:

'use strict';
const config = require('./config');
const nodemailer = require('nodemailer');
class Mail {
constructor(options) {
    this.mailer = nodemailer;
    this.settings = config.mail;
    this.options = options;
}

send() {
    if(this.mailer && this.options) {
        let self = this;
        let transporter = self.mailer.createTransport(self.settings);

        if(transporter !== null) {
            return new Promise((resolve, reject) =>{
                transporter.sendMail(self.options, (error, info) =>{
                    if(error) {
                        reject(Error('Failed'));
                    } else {
                        resolve('OK');
                    }
                });
            });
        }
    }
}
}
module.exports = Mail;

Our wrapper class makes use of Promises when sending an email. In case of failure we're simply returning an Error object but if you want to get more info, I'd recommend to return the info object instead.

As said above, the most common reasons for getting errors here are problems with your SMTP settings.

The Restaurant class

This class implements two of the global features of our app, namely related restaurants and our Top Ten. Each method accepts the Restaurants Mongoose model as one of its arguments.

'use strict';
class Restaurant {
static getRelated(collection, restaurant, relation) {
    let query = {};
    query = relation;
    query._id = { $not: { $eq: restaurant._id }};
    return collection.find(query).limit(6);
}

static getTop(collection) {
    let query = [
        { $unwind : '$grades' },
        { $group : {
            _id : { restaurant_id: "$restaurant_id", name: "$name" },
            'scores' : { $sum : '$grades.score' }
        } },
        { $sort : { 'scores': -1 } },
        { $limit : 10 }
    ];
    return collection.aggregate(query);
}
}
module.exports = Restaurant;

The relevant Mongo queries are exactly the same explained earlier. Our class acts as a singleton due to the static nature of its methods.

Validation

We're using the Validator package to validate user input. The first validation that occurs in our app is a simple check on the current restaurant ID passed to the single restaurant's route:

app.get('/restaurants/:id', breadcrumb(), (req, res) => {
    if(validator.isNumeric(req.params.id)) {
      // OK
    } else {
      res.sendStatus(404);
    }
});

The Validator package works with strings so you should always make sure that you're dealing with this kind of data type.

Another use case of this package is the validation of a single user vote:

app.post('/vote', (req, res) => {
    let id = req.body.id;
    let vote = req.body.vote;
    let grade = req.body.grade;
    let now = new Date();

    let valid = true;
    let grades = 'A,B,C,D,E,F'.split('');

    if(!validator.isMongoId(id)) {
       valid = false;
    }
  if(!validator.isNumeric(vote)) {
   valid = false;
  }
 if(!validator.isInt(vote, {min: 0, max: 100})) {
   valid = false;
 }
 if(grades.indexOf(grade) === -1) {
   valid = false;
 }
 if(!valid) {
   res.sendStatus(403);
 } else {
    // OK
 }
 });

The routine seen above is entirely based on a single boolean flag that indicates whether the inserted data match the validation criteria or not. But we can also return a more complex JSON response by using object arrays as shown below:

app.post('/book', (req, res) => {
    let first = req.body.firstname;
    let last = req.body.lastname;
    let email = req.body.email;
    let persons = req.body.persons;
    let date = req.body.datehour;

    let errors = [];

    if(validator.isEmpty(first)) {
      errors.push({
          attr: 'firstname',
          msg: 'Required field'
      });
    }
    if(validator.isEmpty(last)) {
      errors.push({
          attr: 'lastname',
          msg: 'Required field'
      });
    }
    if(!validator.isEmail(email)) {
      errors.push({
          attr: 'email',
          msg: 'Invalid e-mail address'
      });
    }
    if(!validator.isInt(persons, {min: 1, max: 10})) {
      errors.push({
          attr: 'persons',
          msg: 'Invalid number of persons'
      });
    }
    if(!/^\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}$/.test(date)) {
      errors.push({
          attr: 'datehour',
          msg: 'Invalid date and hour'
      });
    }

    if(errors.length > 0) {
        res.json({errors: errors});
    } else {
        // OK
    }
});

Here all the relevant errors are returned as objects. The attr property maps to the corresponding name attribute of the HTML input elements in the booking form. By doing so, the client-side JavaScript code can easily insert each error message near the related form element.

Now a question: we're returning validation errors with an HTTP status code of 200. Is this correct? Technically speaking, we should return errors with an error status code, e.g. 40x. However, this is fine when you're handling AJAX either with Vanilla JS or with a library that doesn't halt your code when it encounters an error in the AJAX response. We actually need to access the errors array in order to display errors in our form. For that reason and for avoiding problems with browsers, we're returning errors with a successful status code.

Conclusion

In this article we've described the simple structure of a sample website. This structure can be further extended by adding for example a backend section where we could perform CRUD operations on the restaurants collection.

This website could be even turned into an AngularJS or Angular 2 app by removing the current routes and implementing an underlying RESTful API.

Complete code

Node.js MongoDB sample app