TL;DR
August 18, 2014 Update: GitHub repository code has been updated. Here is a brief overview of the main changes:
- Removed method-override, cookie-parser and express-session modules
- JSON Web Token authentication replaced cookie-based approach
- Login with Facebook
- Login with Google
- Use ngAnnotate instead of ngMin for AngularJS dependencies annotations
- New alert notifications based on Google’s Material Design
- General UI tweaks and updates
- Page transitions via ng-animate
- Added unit tests along with a Karma configuration file
- Password strength directive on the Signup page similar to Stripe and Dropbox
- Email is already taken directive on the Signup page to provide live feedback
- Use promises instead of callbacks for $resource.save method
- Ionic fonts
- Updated AngularJS to Beta 17
- Lots of code refactoring and cleanup
Before proceeding further, I will assume you have already installed the following:
Step 1: New Express Project
June 8, 2014 Update: After installing express-generator we can
quickly generate a minimal Express application using the express
command.
Run express showtrackr
to create a new Express project, where showtrackr is
the name of our app that we are going to build today.
Navigate into the showtrackr directory then run npm install
command.
Remove views, routes and bin directories because you will not be needing them anymore. Also, rename app.js
to server.js
since we will have another app.js
file for bootstraping the AngularJS application.
Replace everything inside the server.js
with the following code:
var express = require('express');
var path = require('path');
var logger = require('morgan');
var cookieParser = require('cookie-parser');
var bodyParser = require('body-parser');
var app = express();
app.set('port', process.env.PORT || 3000);
app.use(logger('dev'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded());
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));
app.listen(app.get('port'), function() {
console.log('Express server listening on port ' + app.get('port'));
});
Step 2: Bootstrapping AngularJS Application
Download and extract the Boostrap Sass.
Copy all glyphicons from assets/fonts/bootstrap to public/fonts directory and everything inside assets/stylesheets directory to public/stylesheets/bootstrap directory.
December 4, 2014 Update: Updated Bootstrap Sass path locations.
Download this favicon and place it inside public directory. You don’t really need it but it’s a nice touch.
You will also need to download the following scripts and place them inside the public/vendor directory:
- angular.js
- angular-strap.js
- angular-strap.tpl.js
- angular-message.js
- angular-resource.js
- angular-route.js
- angular-cookies.js
- moment.min.js
Create index.html
in public directory with the following contents:
<!DOCTYPE html>
<html ng-app="MyApp">
<head>
<base href="/">
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ShowTrackr</title>
<link rel="icon" type="image/png" href="favicon.png"/>
<link href="stylesheets/style.css" rel="stylesheet">
</head>
<body>
<div ng-view></div>
<script src="vendor/angular.js"></script>
<script src="vendor/angular-strap.js"></script>
<script src="vendor/angular-strap.tpl.js"></script>
<script src="vendor/angular-messages.js"></script>
<script src="vendor/angular-resource.js"></script>
<script src="vendor/angular-route.js"></script>
<script src="vendor/angular-cookies.js"></script>
<script src="vendor/moment.min.js"></script>
</body>
</html>
On Line 2 the ng-app
tells Angular to consider this to be the root element of our application. On Line 4 the <base href="/">
tag is necessary to enable HTML5 History API in AngularJS. This will allow us to have clean URLs without the #
symbol. The ng-view
on Line 14 is a directive that includes the rendered template of the current route. Every time the current route changes, the included view changes with it according to the configuration of the $route service that we will implement shortly.
Note: This is similar to the outlet in Ember.js.
Create a new file app.js
and add it to the index.html
after the vendor scripts.
<script src="app.js"></script>
For now app.js
will only include the following code just to get things started:
angular.module('MyApp', ['ngCookies', 'ngResource', 'ngMessages', 'ngRoute', 'mgcrea.ngStrap'])
.config(function() {
});
Let’s add an AngularStrap Navbar. Place this code right after the opening <body>
tag:
<div class="navbar navbar-default navbar-static-top"
role="navigation" bs-navbar>
<div class="navbar-header">
<a class="navbar-brand" href="/">
<span class="glyphicon glyphicon-film"></span>
Show<strong>Trackr</strong></a>
</div>
<ul class="nav navbar-nav">
<li data-match-route="/$"><a href="/">Home</a></li>
<li data-match-route="/add"><a href="/add">Add</a></li>
</ul>
<ul class="nav navbar-nav pull-right" ng-if="!currentUser">
<li data-match-route="/login"><a href="/login">Login</a></li>
<li data-match-route="/signup"><a href="/signup">Sign up</a></li>
</ul>
<ul class="nav navbar-nav pull-right" ng-if="currentUser">
<li class="navbar-text" ng-bind="currentUser.email"></li>
<li><a href="javascript:void(0)" ng-click="logout()">Logout</a></li>
</ul>
</div>
There is only one reason we are using AngularStrap Navbar instead of Bootstrap Navbar - the active class is applied automatically to <li>
elements when you change routes. Plus you get many other awesome directives that integrate with AngualrJS such as Alert, Typeahead, Tooltip, Tab and many more.
You could try running the app to make sure there aren’t any errors but you won’t see a Navbar because we haven’t included Bootstrap stylesheets yet. We will be using gulp to compile Sass stylesheets.
Go ahead and install the gulp and gulp plugins:
// Step 1: Install gulp globally
sudo npm install -g gulp
// Step 2: Install gulp in your project
npm install --save-dev gulp gulp-sass gulp-plumber
June 8, 2014 Update: You can install global NPM modules (with the -g
flag) from any command line path
but if you are installing local NPM modules like in the Step 2 above, you
have to run npm install
from anywhere within the project directory or
any of its sub-directories, just as long as you are somewhere within the project
directory.
Passing the --save-dev
flag will install and add packages to devDependencies in package.json
.
Create a new file gulpfile.js
in the project folder:
var gulp = require('gulp');
var sass = require('gulp-sass');
var plumber = require('gulp-plumber');
gulp.task('sass', function() {
gulp.src('public/stylesheets/style.scss')
.pipe(plumber())
.pipe(sass())
.pipe(gulp.dest('public/stylesheets'));
});
gulp.task('watch', function() {
gulp.watch('public/stylesheets/*.scss', ['sass']);
});
gulp.task('default', ['sass', 'watch']);
The very last line specifies which gulp tasks to run when you execute gulp
command in the terminal. For now it just compiles Sass stylesheets and watches for file changes, recompiling stylesheets automatically. You may be wondering what is gulp-plumber? It will prevent pipe breaking caused by errors from gulp plugins. In other words when you make a syntax error in a Sass stylesheet, the gulp watcher will not crash and you won’t see this crap happening in the middle of your workflow:
Create a new file style.scss
in the public/stylesheets directory:
@import url(http://fonts.googleapis.com/css?family=Open+Sans:300italic,400italic,600italic,700italic,400,300,600,700);
$icon-font-path: '../fonts/';
$body-bg: #e4e7ec;
$font-family-base: 'Open Sans', sans-serif;
$headings-color: #111;
$headings-font-family: Avenir, sans-serif;
$headings-font-weight: bold;
$brand-success: #22ae5f;
$brand-primary: #1d7cf4;
$brand-danger: #b30015;
$brand-warning: #ffd66a;
$text-muted: #90939a;
$link-color: #000;
$navbar-default-link-active-bg: #f7f7f7;
$navbar-default-link-color: #848484;
$navbar-default-bg: #fff;
$navbar-default-border: #e3e9ec;
$navbar-default-brand-color: #333;
$navbar-default-brand-hover-color: #ffe939;
$navbar-default-brand-hover-bg: #333;
$btn-success-bg: $brand-success;
$btn-success-border: darken($btn-success-bg, 3%);
$btn-primary-bg: $brand-primary;
$btn-primary-border: darken($btn-primary-bg, 3%);
$jumbotron-padding: 16px;
$jumbotron-bg: #f4f6f8;
$alert-border-radius: 0;
$input-border-radius: 0;
$alert-success-text: #fff;
$alert-success-bg: #60c060;
$alert-success-border: darken($alert-success-bg, 3%);
$alert-danger-text: #fff;
$alert-danger-bg: $brand-danger;
$alert-danger-border: darken($alert-danger-bg, 3%);
$alert-info-bg: #e5f7fd;
$alert-info-border: #bcf8f3;
$alert-info-text: #25484e;
@import 'bootstrap/bootstrap';
body {
padding-bottom: 20px;
}
em {
font-style: normal;
text-decoration: underline;
}
.alphabet {
cursor: pointer;
font-size: 22px;
text-align: center;
li {
display: inline-block;
padding-left: 5px;
padding-right: 5px;
&:hover {
color: $brand-primary;
}
}
}
.genres {
cursor: pointer;
li {
margin-right: 5px;
@extend .label;
@extend .label-default;
&:active {
box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.250);
}
}
}
.jumbotron {
margin-top: -20px;
border-bottom: 1px solid #dae2e4;
}
.media-object {
max-width: 200px;
margin-bottom: 10px;
}
.episode {
border-left: 5px solid #111;
padding-left: 10px;
}
.alert {
box-shadow: 0 0px 5px rgba(0, 0, 0, 0.3);
}
.alert.top-right {
position: fixed;
top: 50px;
right: 0;
margin: 20px;
z-index: 1050;
outline: none;
.close {
padding-left: 10px
}
}
.btn {
border-radius: 2px;
}
.center-form {
width: 330px;
margin: 10% auto;
input {
border-radius: 0;
}
}
.search {
color: #4f4f4f;
font-weight: 300;
font-size: 1.5em;
padding: 7px;
margin-top: -10px;
border: 0;
background-color: transparent;
outline: none;
-webkit-appearance: none;
&:focus {
-webkit-transition: all .4s ease;
transition: all .4s ease;
}
}
.panel {
border-color: #cfd9D7;
border-radius: 2px;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.1);
-webkit-box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.1);
}
.panel-default > .panel-heading {
color: #444;
border-color: #cfd9db;
font-weight: bold;
font-size: 85%;
text-transform: uppercase;
background-color: #f6f6f6;
}
.label {
display: inline-block;
margin-bottom: 5px;
padding: 4px 8px;
border: 0;
border-radius: 3px;
font-size: 12px;
transition: 0.1s all;
-webkit-font-smoothing: antialiased;
}
.label-default {
background-color: #e4e7ec;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.7);
color: #90939a;
&:hover {
background-color: #90939a;
color: #f4f6f8;
text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.2);
}
}
.navbar {
box-shadow: 0 3px 2px -3px rgba(0, 0, 0, 0.1);
}
.navbar-header {
float: left;
padding-left: 15px;
}
.navbar-brand {
background-color: #ffe939;
transition: 0.25s all;
margin-left: -15px;
}
.navbar-nav {
float: left;
margin: 0;
> li {
float: left;
> a {
padding: 15px;
}
}
}
June 8, 2014 Update:
Run the gulp
command from the project directory and refresh the browser.
Note: I typically have node server.js
running in one terminal tab, mongod
in another
tab, gulp
in a third tab and the last tab is used for general purpose commands
such as git add
or git commit
.
Everything in the style.scss
should be very straightforward if you are not completely new to Bootstrap.
There are only a few custom classes, everything else simply overrides core Bootstrap classes to make it look prettier.
Step 3: AngularJS Routes and Templates
Go back to app.js
and add this line inside the config method to enable HTML5 pushState:
$locationProvider.html5Mode(true);
What is $locationProvider and where does it come from? It’s a built-in AngularJS
service for configuring application linking paths. Using this service you can
enable HTML5 pushState or change URL prefix from #
to something like #!
, which you will need to do if you are planning to use Disqus comments in your AngularJS
application. Simply by adding $locationProvider
parameter to the config’s callback
function is enough to tell AngularJS to inject that service and make it available.
angular.module('MyApp', ['ngCookies', 'ngResource', 'ngMessages', 'ngRoute', 'mgcrea.ngStrap'])
.config(function($locationProvider) {
$locationProvider.html5Mode(true);
});
But what happens when you try to minify this script with UglifyJS? The $locationProvider
parameter will
be changed to some obscure name and AngularJS won’t know what to inject anymore.
You can get around this problem by annotating the function with the names of the dependencies.
angular.module('MyApp', ['ngCookies', 'ngResource', 'ngMessages', 'ngRoute', 'mgcrea.ngStrap'])
.config(['$locationProvider', function($locationProvider) {
$locationProvider.html5Mode(true);
}]);
Each string in the array is the name of the service to inject for the corresponding parameter. From now on forward I will be using this notation. We are planning to minify and concatenate scripts after all.
Next, we will need routes for the following pages:
- Home - display a list of popular shows.
- Detail - information about one particular TV show.
- Login - user login form.
- Signup - user signup form.
- Add - add a new show form.
Inject $routeProvider into config then add these routes:
$routeProvider
.when('/', {
templateUrl: 'views/home.html',
controller: 'MainCtrl'
})
.when('/shows/:id', {
templateUrl: 'views/detail.html',
controller: 'DetailCtrl'
})
.when('/login', {
templateUrl: 'views/login.html',
controller: 'LoginCtrl'
})
.when('/signup', {
templateUrl: 'views/signup.html',
controller: 'SignupCtrl'
})
.when('/add', {
templateUrl: 'views/add.html',
controller: 'AddCtrl'
})
.otherwise({
redirectTo: '/'
});
For each route there is a template and a controller. If you have a page with mostly static content then you don’t even need to specify a controller. If you reload the page right now and open Browser’s Developer Tools you will see a 404 (Not Found) error since we haven’t created any templates yet.
Create a new file home.html in public/views directory. This will be a place for all AngularJS templates.
<div class="jumbotron">
<div class="container">
<ul class="alphabet">
<li ng-repeat="char in alphabet">
<span ng-click="filterByAlphabet(char)">{{char}}</span>
</li>
</ul>
<ul class="genres">
<li ng-repeat="genre in genres">
<span ng-click="filterByGenre(genre)">{{genre}}</span>
</li>
</ul>
</div>
</div>
<div class="container">
<div class="panel panel-default">
<div class="panel-heading">
{{headingTitle}}
<div class="pull-right">
<input class="search" type="text" ng-model="query.name" placeholder="Search...">
</div>
</div>
<div class="panel-body">
<div class="row show-list">
<div class="col-xs-4 col-md-3" ng-repeat="show in shows | filter:query | orderBy:'rating':true">
<a href="/shows/{{show._id}}">
<img class="img-rounded" ng-src="{{show.poster}}" width="100%"/>
</a>
<div class="text-center">
<a href="/shows/{{show._id}}">{{show.name}}</a>
<p class="text-muted">Episodes: {{show.episodes.length}}</p>
</div>
</div>
</div>
</div>
</div>
</div>
If you have used Bootstrap CSS framework before then everything should look
familiar to you. There are however some AngularJS directives here. The ng-repeat
will iterate over an array of items specified in the controller for this page.
Let’s take a look at this code snippet:
<li ng-repeat="char in alphabet">
<span ng-click="filterByAlphabet(char)">{{char}}</span>
</li>
It expects an array called alphabet
defined in the MainCtrl
controller.
The char
refers to each individual item in that array, an alphabet letter
in this case. When you click on that letter it will run the filterByAlphabet
function specified in the MainCtrl
controller as well. Here we are passing the
current letter in filterByAlphabet(char)
otherwise how would it know which letter
to filter by?
The other ng-repeat
displays a thumbnail and a name of each show:
<div class="col-xs-4 col-md-3" ng-repeat="show in shows | filter:query | orderBy:'rating':true">
<a href="/shows/{{show._id}}">
<img class="img-rounded" ng-src="{{show.poster}}" width="100%"/>
</a>
<div class="text-center">
<a href="/shows/{{show._id}}">{{show.name}}</a>
<p class="text-muted">Episodes: {{show.episodes.length}}</p>
</div>
</div>
In AngularJS you can also filter and sort your results. In this code above, thumbnails are sorted by the rating and filtered by the query you type into the Search box:
<input class="search" type="text" ng-model="query.name" placeholder="Search...">
The reason it’s query.name
and not just query
is because we want to filter only
by the TV show name, not by its summary, rating, network, air time, etc.
Next create a new file main.js
in public/controllers directory then add it to index.html
:
<script src="controllers/main.js"></script>
angular.module('MyApp')
.controller('MainCtrl', ['$scope', 'Show', function($scope, Show) {
$scope.alphabet = ['0-9', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J',
'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X',
'Y', 'Z'];
$scope.genres = ['Action', 'Adventure', 'Animation', 'Children', 'Comedy',
'Crime', 'Documentary', 'Drama', 'Family', 'Fantasy', 'Food',
'Home and Garden', 'Horror', 'Mini-Series', 'Mystery', 'News', 'Reality',
'Romance', 'Sci-Fi', 'Sport', 'Suspense', 'Talk Show', 'Thriller',
'Travel'];
$scope.headingTitle = 'Top 12 Shows';
$scope.shows = Show.query();
$scope.filterByGenre = function(genre) {
$scope.shows = Show.query({ genre: genre });
$scope.headingTitle = genre;
};
$scope.filterByAlphabet = function(char) {
$scope.shows = Show.query({ alphabet: char });
$scope.headingTitle = char;
};
}]);
Here are the alphabet
and genre
arrays that I just mentioned earlier when describing the ng-repeat
directive.
The Show
service is injected automatically by AngularJS. We haven’t created it yet, so if you trying
reloading the page you will get this error: Unknown provider: ShowProvider <- Show.
Go ahead and create the show.js
in public/services directory and once again
don’t forget to add it to index.html
:
<script src="services/show.js"></script>
angular.module('MyApp')
.factory('Show', ['$resource', function($resource) {
return $resource('/api/shows/:_id');
}]);
The simplest service you will ever see thanks to the angular-resource.js
module
for doing all the heavy lifting for us. The $resource
service is the perfect companion for a RESTful backend. This is all we need to
query all shows and an individual show by id. Refresh the page and if you
see the api/shows 404 (Not Found) error then everything is working as expected for
the time being.
Let us switch over back to the Express application to implement database schemas and API routes.
Step 4: Database Schemas
June 8, 2014 Update:
To install mongoose and bcryptjs run the following command from the project directory:
npm install --save mongoose bcryptjs
Then add these two lines at the beginning of server.js
:
var mongoose = require('mongoose');
var bcrypt = require('bcryptjs');
Right below that, add the Show mongoose schema:
var showSchema = new mongoose.Schema({
_id: Number,
name: String,
airsDayOfWeek: String,
airsTime: String,
firstAired: Date,
genre: [String],
network: String,
overview: String,
rating: Number,
ratingCount: Number,
status: String,
poster: String,
subscribers: [{
type: mongoose.Schema.Types.ObjectId, ref: 'User'
}],
episodes: [{
season: Number,
episodeNumber: Number,
episodeName: String,
firstAired: Date,
overview: String
}]
});
A schema is just a representation of your data in MongoDB. This is where you can enforce a certain field to be of particular type. A field can also be required, unique, contain only certain characters.
All the fields above are almost 1-to-1 match with the data response from the TheTVDB.com API. Two things to note here:
- The default
_id
field has been overwritten with the numerical ID from The TVDB. There is no point in having both_id
andshowId
fields. - The
subscribers
field is an array of User ObjectIDs. We haven’t created the User schema yet, but essentially it’s just an array of references to User documents.
Next, create the User schema:
var userSchema = new mongoose.Schema({
email: { type: String, unique: true },
password: String
});
userSchema.pre('save', function(next) {
var user = this;
if (!user.isModified('password')) return next();
bcrypt.genSalt(10, function(err, salt) {
if (err) return next(err);
bcrypt.hash(user.password, salt, function(err, hash) {
if (err) return next(err);
user.password = hash;
next();
});
});
});
userSchema.methods.comparePassword = function(candidatePassword, cb) {
bcrypt.compare(candidatePassword, this.password, function(err, isMatch) {
if (err) return cb(err);
cb(null, isMatch);
});
};
Here we are using pre-save mongoose middleware and comparePassword instance method for password validation. This code was taken directly from passport-local example.
Now that we have schemas in place, we just have to create mongoose models which we will use for querying MongoDB. Where a schema is just an abstract representation of the data, a model on the other hand is a concrete object with methods to query, remove, update and save data from/to MongoDB.
var User = mongoose.model('User', userSchema);
var Show = mongoose.model('Show', showSchema);
And finally in order to connect to the database:
mongoose.connect('localhost');
Launch mongod
- MongoDB server, then restart server.js
just to make sure
our application still works.
Step 5: Express API Routes
We are going to create two routes for now. One is for querying all shows and another one for querying a single show by ID.
If we were going to implement all REST routes for /api/shows
here is a table that outlines a route’s responsibility.
Route | POST | GET | PUT | DELETE |
---|---|---|---|---|
/api/shows | Add a new show | Get all shows | Update all shows | Remove all shows |
/api/shows/:id | N/A | Get a show | Update a show | Delete a show |
Add these routes after Express middlewares:
app.get('/api/shows', function(req, res, next) {
var query = Show.find();
if (req.query.genre) {
query.where({ genre: req.query.genre });
} else if (req.query.alphabet) {
query.where({ name: new RegExp('^' + '[' + req.query.alphabet + ']', 'i') });
} else {
query.limit(12);
}
query.exec(function(err, shows) {
if (err) return next(err);
res.send(shows);
});
});
Initially I had 3 different routes for finding the most popular shows on the home page, finding by genre and finding by letter. But they were essentially doing the same thing so I merged them into a single route and used Mongoose query builder to dynamically construct a database query.
app.get('/api/shows/:id', function(req, res, next) {
Show.findById(req.params.id, function(err, show) {
if (err) return next(err);
res.send(show);
});
});
You may have noticed the next
parameter. If there an error it will be passed
on to the error middleware and handled there as well. How you handle that error
is up to you. A typical approach is to print a stack trace to the console and return
only an error message to the user.
Add this error middleware at the end of your routes. When an error occurs a stack trace is output in the console and JSON response is returned with the error message.
app.use(function(err, req, res, next) {
console.error(err.stack);
res.send(500, { message: err.message });
});
If you go to Add, Login or Signup pages right now and hit Refresh you will get a 404 error:
Cannot GET /add
This is a common problem when you use HTML5 pushState on the client-side. To get around this problem we have to create a redirect route. Add this route before the error handler:
app.get('*', function(req, res) {
res.redirect('/#' + req.originalUrl);
});
It is very important that you add this route after all your other routes (excluding error handler)
because we are using the *
wild card that will match any route that you type.
If you try going to http://localhost:3000/asdf
this last route that we have just added will match it and you will be redirected to http://localhost:3000/#asdf
.
At that point AngularJS will try to match this URL with your routes defined in $routeProvider
. Since
we haven’t defined a route that matches /asdf
you will be redirected back to home page:
.otherwise({
redirectTo: '/'
});
Step 6: Query and Parse The TVDB API
To add a new TV show to the database we will create a separate route for it.
app.post('/api/shows', function(req, res, next) {
var apiKey = '9EF1D1E7D28FDA0B';
var parser = xml2js.Parser({
explicitArray: false,
normalizeTags: true
});
var seriesName = req.body.showName
.toLowerCase()
.replace(/ /g, '_')
.replace(/[^\w-]+/g, '');
async.waterfall([
function(callback) {
request.get('http://thetvdb.com/api/GetSeries.php?seriesname=' + seriesName, function(error, response, body) {
if (error) return next(error);
parser.parseString(body, function(err, result) {
if (!result.data.series) {
return res.send(404, { message: req.body.showName + ' was not found.' });
}
var seriesId = result.data.series.seriesid || result.data.series[0].seriesid;
callback(err, seriesId);
});
});
},
function(seriesId, callback) {
request.get('http://thetvdb.com/api/' + apiKey + '/series/' + seriesId + '/all/en.xml', function(error, response, body) {
if (error) return next(error);
parser.parseString(body, function(err, result) {
var series = result.data.series;
var episodes = result.data.episode;
var show = new Show({
_id: series.id,
name: series.seriesname,
airsDayOfWeek: series.airs_dayofweek,
airsTime: series.airs_time,
firstAired: series.firstaired,
genre: series.genre.split('|').filter(Boolean),
network: series.network,
overview: series.overview,
rating: series.rating,
ratingCount: series.ratingcount,
runtime: series.runtime,
status: series.status,
poster: series.poster,
episodes: []
});
_.each(episodes, function(episode) {
show.episodes.push({
season: episode.seasonnumber,
episodeNumber: episode.episodenumber,
episodeName: episode.episodename,
firstAired: episode.firstaired,
overview: episode.overview
});
});
callback(err, show);
});
});
},
function(show, callback) {
var url = 'http://thetvdb.com/banners/' + show.poster;
request({ url: url, encoding: null }, function(error, response, body) {
show.poster = 'data:' + response.headers['content-type'] + ';base64,' + body.toString('base64');
callback(error, show);
});
}
], function(err, show) {
if (err) return next(err);
show.save(function(err) {
if (err) {
if (err.code == 11000) {
return res.send(409, { message: show.name + ' already exists.' });
}
return next(err);
}
res.send(200);
});
});
});
June 8, 2014 Update: I have added an error handling code for duplicate Shows
in the show.save()
method . Error code 11000
refers to the duplicate key error. We cannot have duplicate _id
fields in MongoDB.
If you choose not to override _id
and instead use another field such as
showId
then you would need to explicity set unique property like we did
with the userSchema
to avoid duplicate entries.
Oh and there is nothing special about the code 409
. It’s just a common HTTP
status code to indicate some sort of conflict. For a full list of status codes
check out http://httpstatus.es.
This error message object will be processed and displayed in the next step when we create the AddCtrl controller for adding a new Show.
I have also added a validation check to see if the seriesid
exists. If it does
not exist that means the TVDB API has no information on that show, so a 404
response is sent back to our AngularJS app with a message saying that a show
was not found.
You must first obtain an API key from the TVDB. Or you could use my API key for the purposes of this tutorial. The xml2js parser is configured to normalize all tags to lowercase and disable conversion to arrays when there is only one child element.
The TV show name is slugified with underscores instead of dashes because that’s what the TVDB API expects. For example if you pass in Breaking Bad it will be converted to breaking_bad.
I am using async.waterfall to manage multiple asynchronous operations. Here is how it works:
- Get the Show ID given the Show Name and pass it on to the next function.
- Get the show information using the Show ID from previous step and pass the new
show
object on to the next function. - Convert the poster image to Base64, assign it to
show.poster
and pass theshow
object to the final callback function. - Save the
show
object to database.
You may be surprised why are we storing Base64 images in MongoDB? The answer is I don’t have an Amazon S3 account to store these images. And even if I did, it is not for free, so I wouldn’t expect everyone to have an AWS account just to follow this tutorial. As a side effect, each image is about 30% larger in the Base64 form, but don’t worry, it is well within the 500MB free tier limit provided by MongoLab and MongoHQ.
Before moving on, don’t forget to install and add these dependencies which are used in the route we have just created:
npm install --save async request xml2js lodash
var async = require('async');
var request = require('request');
var xml2js = require('xml2js');
var _ = require('lodash');
Step 7: Back to AngularJS
Create a new template add.html
in the views directory:
<div class="container">
<div class="panel panel-default">
<div class="panel-heading">Add TV Show</div>
<div class="panel-body">
<form class="form" method="post" ng-submit="addShow()" name="addForm">
<div class="form-group" ng-class="{ 'has-success' : addForm.showName.$valid && addForm.showName.$dirty, 'has-error' : addForm.showName.$invalid && addForm.showName.$dirty }">
<input class="form-control" type="text" name="showName" ng-model="showName" placeholder="Enter TV show name" required autofocus>
<div class="help-block text-danger" ng-if="addForm.showName.$dirty" ng-messages="addForm.showName.$error">
<div ng-message="required">TV show name is required.</div>
</div>
</div>
<button class="btn btn-primary" type="submit" ng-disabled="addForm.$invalid">Add</button>
</form>
</div>
</div>
</div>
June 8, 2014 Update: I have added form validation and error messages to be consistent with the form on the Signup page in Step 8.
In a nutshell, we are using ng-class
directive to dynamically add
Bootstrap classes has-success
and has-error depending
on the state of the form. The reason for checking if the form field is $dirty
, i.e.
user interacted with it, is to avoid flagging it as invalid before a user even
got a chance to enter any text.
The ng-disabled
is another useful directive provided by AngularJS that allows
us to disable a button until form passes all validation rules. In this case
it’s just a required
attribute on the showName
field.
When you hit the Add button, AngularJS will execute the addShow()
function
defined in the AddCtrl
controller because of this line:
<form method="post" ng-submit="addShow()" name="addForm" class="form-inline">
We also need to create a controller for this page:
<script src="controllers/add.js"></script>
angular.module('MyApp')
.controller('AddCtrl', ['$scope', '$alert', 'Show', function($scope, $alert, Show) {
$scope.addShow = function() {
Show.save({ showName: $scope.showName },
function() {
$scope.showName = '';
$scope.addForm.$setPristine();
$alert({
content: 'TV show has been added.',
placement: 'top-right',
type: 'success',
duration: 3
});
},
function(response) {
$scope.showName = '';
$scope.addForm.$setPristine();
$alert({
content: response.data.message,
placement: 'top-right',
type: 'danger',
duration: 3
});
});
};
}]);
June 7, 2014 Update: Instead of making a $http.post('/api/shows')
request directly from the controller,
I have injected the Show
service so we could use the save()
method provided
by $resource
module. The code is now slightly cleaner (URL is no longer hard coded
in the controller) and more consistent with the rest of the code. I should have done that in the first place since I am
advocating for keeping $http
out of the controllers and leave that job to
services.
June 8, 2014 Update: I have added a second callback function to the
Show.save()
method for handling errors. It’s a convention you will see being used
in AngularJS quite frequently. One such error could be if you type
a Show name that does not exist on the TVDB. Another potential error is when a
Show you are trying to add already exists in your database.
I have also added the $setPristine()
method to clear the form of any errors after
adding a Show. Previously I only cleared the showName
by setting it
to an empty string but earlier today, after adding input validation and error messages
to this form, we need to properly clear it by changing its state from $dirty
to
$pristine
.
This controller sends a POST request to /api/shows
with the TV show name - the route we have created in the previous step.
If the request has been successfull, the form is cleared and a successful notification is shown.
Note: The $alert is part of the AngularStrap library.
Now, create another template detail.html
:
<div class="container">
<div class="panel panel-default">
<div class="panel-body">
<div class="media">
<div class="pull-left">
<img class="media-object img-rounded" ng-src="{{show.poster}}">
<div class="text-center" ng-if="currentUser">
<div ng-show="!isSubscribed()">
<button ng-click="subscribe()" class="btn btn-block btn-success">
<span class="glyphicon glyphicon-plus"></span> Subscribe
</button>
</div>
<div ng-show="isSubscribed()">
<button ng-click="unsubscribe()" class="btn btn-block btn-danger">
<span class="glyphicon glyphicon-minus"></span> Unsubscribe
</button>
</div>
</div>
<div class="text-center" ng-show="!currentUser">
<a class="btn btn-block btn-primary" href="#/login">Login to Subscribe</a>
</div>
</div>
<div class="media-body">
<h2 class="media-heading">
{{show.name}}
<span class="pull-right text-danger">{{show.rating}}</span>
</h2>
<h4 ng-show="show.status === 'Continuing'">
<span class="glyphicon glyphicon-calendar text-danger"></span>
{{show.airsDayOfWeek}} <em>{{show.airsTime}}</em> on
{{show.network}}
</h4>
<h4 ng-show="show.status === 'Ended'">
Status: <span class="text-danger">Ended</span>
</h4>
<p>{{show.overview}}</p>
</div>
</div>
</div>
</div>
<div class="alert alert-info" ng-show="nextEpisode">
The next episode starts {{nextEpisode.firstAired | fromNow}}.
</div>
<div class="panel panel-default">
<div class="panel-heading">
<span class="glyphicon glyphicon-play"></span> Episodes
</div>
<div class="panel-body">
<div class="episode" ng-repeat="episode in show.episodes">
<h4>{{episode.episodeName}}
<small>Season {{episode.season}}, Episode {{episode.episodeNumber}}</small>
</h4>
<p>
<span class="glyphicon glyphicon-calendar"></span>
{{episode.firstAired | date: 'short'}}
</p>
<p>{{episode.overview}}</p>
</div>
</div>
</div>
</div>
This template is a little more complicated so let’s break it down.
<div class="text-center" ng-if="currentUser">
<div ng-show="!isSubscribed()">
<button ng-click="subscribe()" class="btn btn-block btn-success">
<span class="glyphicon glyphicon-plus"></span> Subscribe
</button>
</div>
<div ng-show="isSubscribed()">
<button ng-click="unsubscribe()" class="btn btn-block btn-danger">
<span class="glyphicon glyphicon-minus"></span> Unsubscribe
</button>
</div>
</div>
A subscribe/unsubscribe button is shown only if the user is logged in. The
isSubscribed
function defined in the DetailCtrl
that we haven’t created yet simply
checks if current user ID is in the subscribers
array of current TV show. It returns either
true or false. Depending on which value is returned, either green subscribe button
or red unbscribe button is shown.
If the user is not logged in then a different button is shown:
<div class="text-center" ng-show="!currentUser">
<a class="btn btn-block btn-primary" href="#/login">Login to Subscribe</a>
</div>
The main difference between ng-show and ng-if is that the former simply shows/hides a DOM element and the latter won’t even insert a DOM element if the expression is false. For more detailed comparisson refer to this StackOverflow post.
In this code block I am using a custom filter fromNow
that we are about to create shortly.
It uses moment.js library to output a friendly date like in 6 hours or in 5 days.
<div class="alert alert-info" ng-show="nextEpisode">
The next episode starts {{nextEpisode.firstAired | fromNow}}.
</div>
Create a new file fromNow.js
in the public/filters directory:
angular.module('MyApp').
filter('fromNow', function() {
return function(date) {
return moment(date).fromNow();
}
});
And as usual, do not forget to reference it in index.html
:
<script src="filters/fromNow.js"></script>
Next, we need to create the DetailCtrl
controller:
<script src="controllers/detail.js"></script>
June 15, 2014 Update: Both subscribe()
and unsubscribe()
methods
below no longer pass $rootScope.currentUser
to the Subscription service. It
is not too difficult to fake that object and subscribe as someone else. Instead of
passing the current user to the server, we can already use req.user
object on
server to get currently signed-in user.
angular.module('MyApp')
.controller('DetailCtrl', ['$scope', '$rootScope', '$routeParams', 'Show', 'Subscription',
function($scope, $rootScope, $routeParams, Show, Subscription) {
Show.get({ _id: $routeParams.id }, function(show) {
$scope.show = show;
$scope.isSubscribed = function() {
return $scope.show.subscribers.indexOf($rootScope.currentUser._id) !== -1;
};
$scope.subscribe = function() {
Subscription.subscribe(show).success(function() {
$scope.show.subscribers.push($rootScope.currentUser._id);
});
};
$scope.unsubscribe = function() {
Subscription.unsubscribe(show).success(function() {
var index = $scope.show.subscribers.indexOf($rootScope.currentUser._id);
$scope.show.subscribers.splice(index, 1);
});
};
$scope.nextEpisode = show.episodes.filter(function(episode) {
return new Date(episode.firstAired) > new Date();
})[0];
});
}]);
June 9, 2014 Update: The nextEpisode
property is an object of an upcoming
episode. If a Show is currently airing you will see an alert box with a date
when the next episode starts. This nextEpisode
property uses a built-in Javascript
filter()
method to find the next episode from today.
The filter()
method creates a new array with all elements that pass the test
implemented by the provided callback function. The show.episodes
is an Array
of all episodes for a Show, we know that. A filter()
method goes through each
and every episode and checks if it passes the following condition new Date(episode.firstAired) > new Date()
and
if it passes, that episode will be added to a new Array. At the end we will have
either an empty Array (no upcoming shows) or potentially multiple episodes in an
Array (multiple upcoming episodes). We are only interested in the first upcoming
episode. And so that explains [0]
at the end of the filter()
method. When all
is done the `nex
Note: You could also use a good old for
loop to get a next episode, I just think it looks a lot
cleaner and more elegant with filter()
. I couldn’t do a one-liner in this case
but in many other cases it is certainly possible.
Remember our one-line Show
service? By default it has the following methods:
{ 'get': {method:'GET'},
'save': {method:'POST'},
'query': {method:'GET', isArray:true},
'remove': {method:'DELETE'},
'delete': {method:'DELETE'} };
In other words, we use Show.get()
to get a single show and Show.query()
to get an array of shows.
When we get a response back, we add the show to $scope
in order to make it available to the
detail.html
template. We also define a few functions to handle subscribe and unsbuscribe actions.
Notice the separation of concerns. We are not handling any HTTP requests inside any of the controllers. Sure it would be less lines of code to do everything inside a controller but it will quickly turn into a big pile of mess. AngularJS services, providers, factories are there for this reason.
Here is how subscribe/unsubscribe action works:
- Current show and current user objects are passed to the
Subscription
service. - Subscription service sends a POST request to either
/api/subscribe
or/api/unsubscribe
with just the Show ID and User ID. - Server reponds with 200 OK after updating MongoDB documents.
- Current user is added or removed from the
subscribers
array of the current TV show to keep things in sync.
The last thing we will do in this step is create the Subscription
service.
Create a new file subscription.js
in services directory:
<script src="services/subscription.js"></script>
angular.module('MyApp')
.factory('Subscription', ['$http', function($http) {
return {
subscribe: function(show, user) {
return $http.post('/api/subscribe', { showId: show._id });
},
unsubscribe: function(show, user) {
return $http.post('/api/unsubscribe', { showId: show._id });
}
};
}]);
June 15, 2014 Update: As I have mentioned above we no longer need to pass
{ userId: user._id }
to /api/subscribe
and /api/unsubsctibe
, just the show
ID is enough since we already have access to a user on the server.
We will create Express routes /api/subscribe
and /api/unsubscribe
in Step 10, after we implement client-side
and server-side authentication.
Step 8: Client-side Authentication
Create a new template login.html
:
<div class="container">
<div class="row">
<div class="center-form panel">
<div class="panel-body">
<h2 class="text-center">Login</h2>
<form method="post" ng-submit="login()" name="loginForm">
<div class="form-group">
<input class="form-control input-lg" type="text" name="email"
ng-model="email" placeholder="Email" required autofocus>
</div>
<div class="form-group">
<input class="form-control input-lg" type="password" name="password"
ng-model="password" placeholder="Password" required>
</div>
<button type="submit" ng-disabled="loginForm.$invalid"
class="btn btn-lg btn-block btn-success">Sign In
</button>
</form>
</div>
</div>
</div>
</div>
Create another template signup.html
:
<div class="container">
<br/>
<div class="row">
<div class="center-form panel">
<form method="post" ng-submit="signup()" name="signupForm">
<div class="panel-body">
<h2 class="text-center">Sign up</h2>
<div class="form-group"
ng-class="{ 'has-success' : signupForm.email.$valid && signupForm.email.$dirty, 'has-error' : signupForm.email.$invalid && signupForm.email.$dirty }">
<input class="form-control input-lg" type="email" id="email"
name="email" ng-model="email" placeholder="Email" required
autofocus>
<div class="help-block text-danger" ng-if="signupForm.email.$dirty"
ng-messages="signupForm.email.$error">
<div ng-message="required">Your email address is required.</div>
<div ng-message="email">Your email address is invalid.</div>
</div>
</div>
<div class="form-group"
ng-class="{ 'has-success' : signupForm.password.$valid && signupForm.password.$dirty, 'has-error' : signupForm.password.$invalid && signupForm.password.$dirty }">
<input class="form-control input-lg" type="password" name="password"
ng-model="password" placeholder="Password" required>
<div class="help-block text-danger"
ng-if="signupForm.password.$dirty"
ng-messages="signupForm.password.$error">
<div ng-message="required">Password is required.</div>
</div>
</div>
<div class="form-group"
ng-class="{ 'has-success' : signupForm.confirmPassword.$valid && signupForm.confirmPassword.$dirty, 'has-error' : signupForm.confirmPassword.$invalid && signupForm.confirmPassword.$dirty }">
<input class="form-control input-lg" type="password"
name="confirmPassword" ng-model="confirmPassword"
repeat-password="password" placeholder="Confirm Password"
required>
<div class="help-block text-danger my-special-animation"
ng-if="signupForm.confirmPassword.$dirty"
ng-messages="signupForm.confirmPassword.$error">
<div ng-message="required">You must confirm password.</div>
<div ng-message="repeat">Passwords do not match.</div>
</div>
</div>
<button type="submit" ng-disabled="signupForm.$invalid"
class="btn btn-lg btn-block btn-primary">Create Account
</button>
</div>
</form>
</div>
</div>
</div>
This template is a bit trickier than login.html
. First, I am dynamically assigning has-success
and has-error
CSS classes depending on whether the form is valid or not.
These CSS classes are part of the Bootstrap framework. Second, AngularJS is smart enough to use
native HTML attributes such as type="email"
and required
for input validation.
The ngMessages is a new feature in the AngularJS 1.3 Beta 8. Check out How to use ngMessages in AngularJS for an in-depth overview of ngMessages.
The only other thing that is worth mentioning is this directive:
repeat-password="password"
It’s a custom directive for checking that Confirm Password matches Password and vice versa.
Create a new file repeatPassword.js
in the public/directives directory. Then add it to index.html
:
<script src="directives/repeatPassword.js"></script>
angular.module('MyApp')
.directive('repeatPassword', function() {
return {
require: 'ngModel',
link: function(scope, elem, attrs, ctrl) {
var otherInput = elem.inheritedData("$formController")[attrs.repeatPassword];
ctrl.$parsers.push(function(value) {
if (value === otherInput.$viewValue) {
ctrl.$setValidity('repeat', true);
return value;
}
ctrl.$setValidity('repeat', false);
});
otherInput.$parsers.push(function(value) {
ctrl.$setValidity('repeat', value === ctrl.$viewValue);
return value;
});
}
};
});
Let’s create controllers for login.html
and signup.html
templates:
Here is the Signup controller:
<script src="controllers/signup.js"></script>
angular.module('MyApp')
.controller('SignupCtrl', ['$scope', 'Auth', function($scope, Auth) {
$scope.signup = function() {
Auth.signup({
email: $scope.email,
password: $scope.password
});
};
}]);
And here is the Login controller:
<script src="controllers/login.js"></script>
angular.module('MyApp')
.controller('LoginCtrl', ['$scope', 'Auth', function($scope, Auth) {
$scope.login = function() {
Auth.login({
email: $scope.email,
password: $scope.password
});
};
}]);
Both Login and Signup controllers use Auth
service which we are about to create.
Create a new service auth.js
in the services directory:
<script src="services/auth.js"></script>
angular.module('MyApp')
.factory('Auth', ['$http', '$location', '$rootScope', '$cookieStore', '$alert',
function($http, $location, $rootScope, $cookieStore, $alert) {
$rootScope.currentUser = $cookieStore.get('user');
$cookieStore.remove('user');
return {
login: function(user) {
return $http.post('/api/login', user)
.success(function(data) {
$rootScope.currentUser = data;
$location.path('/');
$alert({
title: 'Cheers!',
content: 'You have successfully logged in.',
placement: 'top-right',
type: 'success',
duration: 3
});
})
.error(function() {
$alert({
title: 'Error!',
content: 'Invalid username or password.',
placement: 'top-right',
type: 'danger',
duration: 3
});
});
},
signup: function(user) {
return $http.post('/api/signup', user)
.success(function() {
$location.path('/login');
$alert({
title: 'Congratulations!',
content: 'Your account has been created.',
placement: 'top-right',
type: 'success',
duration: 3
});
})
.error(function(response) {
$alert({
title: 'Error!',
content: response.data,
placement: 'top-right',
type: 'danger',
duration: 3
});
});
},
logout: function() {
return $http.get('/api/logout').success(function() {
$rootScope.currentUser = null;
$cookieStore.remove('user');
$alert({
content: 'You have been logged out.',
placement: 'top-right',
type: 'info',
duration: 3
});
});
}
};
}]);
In the next section we will create an Express middleware that creates a User cookie on each request.
The $cookieStore
service grabs that cookie, saves it locally on $rootScope
and removes the cookie (we don’t want to be authenticated forever).
Unfortunately I haven’t found a cleaner and more straightforward authentication implementation in AngularJS yet. This will do for now. If you know of a better way, let me know.
Go back to index.html
and find this line:
<li><a href="javascript:void(0)" ng-click="logout()">Logout</a></li>
We are using javascript:void(0)
instead of #
, that you would typically see used to represent a dummy or null URLs,
because hashes are used for routes in AngularJS.
Also, we are using the logout()
function but we haven’t created a controller to handle it.
Since Navbar doesn’t fall under any particular route in $routeProvider
we have to assign the
controller inline:
<div ng-controller="NavbarCtrl" class="navbar navbar-default navbar-static-top" role="navigation" bs-navbar>
Then create a controller navbar.js
:
<script src="controllers/navbar.js"></script>
angular.module('MyApp')
.controller('NavbarCtrl', ['$scope', 'Auth', function($scope, Auth) {
$scope.logout = function() {
Auth.logout();
};
}]);
Of course we cannot login or create a new account because we haven’t implemented that yet on the server. Let’s do that next!
Step 9: Server-side Authentication
Install the following dependencies:
npm install --save express-session passport passport-local
Then add them to your module dependencies:
var session = require('express-session');
var passport = require('passport');
var LocalStrategy = require('passport-local').Strategy;
In order to setup Passport.js we have to configure four things:
- Passport serialize and deserialize methods
- Passport strategy
- Express session middleware
- Passport middleware
Serialize and deserialize methods are used to keep you signed-in. More details here.
passport.serializeUser(function(user, done) {
done(null, user.id);
});
passport.deserializeUser(function(id, done) {
User.findById(id, function(err, user) {
done(err, user);
});
});
Passport comes with hundreds of different strategies for just about every third-party service out there. We will not be signing in with Facebook, Google or Twitter. Instead we will use Passport’s LocalStrategy to sign in with username and password.
passport.use(new LocalStrategy({ usernameField: 'email' }, function(email, password, done) {
User.findOne({ email: email }, function(err, user) {
if (err) return done(err);
if (!user) return done(null, false);
user.comparePassword(password, function(err, isMatch) {
if (err) return done(err);
if (isMatch) return done(null, user);
return done(null, false);
});
});
}));
Note: This code snippet is almost identical to the one found on the Passport | Configure page. The main difference here is we override username field to be called email field.
Add Express Session and Passport middleware right after the cookieParser()
middleware:
app.use(session({ secret: 'keyboard cat' }));
app.use(passport.initialize());
app.use(passport.session());
Also, add this function somewhere in the server.js
that we will use shortly to protect our routes
from unauthenticated requests.
function ensureAuthenticated(req, res, next) {
if (req.isAuthenticated()) next();
else res.send(401);
}
Next, we will create /login
, /logout
and /signup
routes.
When a user tries to sign-in from our AngularJS application, a POST request is sent with the following data:
{
email: 'example@email.com',
password: '1234'
}
This data is passed to the Passport LocalStrategy. If email is found and password is valid then a new cookie is created with the user object, additionally the user object is sent back to the client.
app.post('/api/login', passport.authenticate('local'), function(req, res) {
res.cookie('user', JSON.stringify(req.user));
res.send(req.user);
});
Yes, I know it’s a bad idea to send user’s password over the network or to store it in a cookie, even if password is encryped. I have looked at so many different tutorials on AngularJS authentication and there is not a single approach that I like. It is either too complicated, too ugly or both. If I find a better solution I will update this tutorial but for now this will do.
The signup route should pretty straightforward. In fact I oversimplified it for the purposes of this tutorial. There is no input validation. If you need input validation then take a look at the express-validator. You can see it being used through the hackathon-starter project.
app.post('/api/signup', function(req, res, next) {
var user = new User({
email: req.body.email,
password: req.body.password
});
user.save(function(err) {
if (err) return next(err);
res.send(200);
});
});
Passport exposes a logout()
function on req
object that can be called from any route which
terminates a login session. Invoking logout()
will remove the req.user
property and clear the login session.
app.get('/api/logout', function(req, res, next) {
req.logout();
res.send(200);
});
Finally, add the following custom middleware after the Express static middleware. If user is authenticated, this will create a new cookie that will be consumed by our AngularJS authentication service to read user information.
app.use(function(req, res, next) {
if (req.user) {
res.cookie('user', JSON.stringify(req.user));
}
next();
});
Go ahead create a new account and try logging in. If you did everything correctly you should get a success notification and you will see your email address in the Navbar.
Step 10: Subscription
In this step we will implement two routes for subscribing and unsubscribing to/from a show.
app.post('/api/subscribe', ensureAuthenticated, function(req, res, next) {
Show.findById(req.body.showId, function(err, show) {
if (err) return next(err);
show.subscribers.push(req.user.id);
show.save(function(err) {
if (err) return next(err);
res.send(200);
});
});
});
app.post('/api/unsubscribe', ensureAuthenticated, function(req, res, next) {
Show.findById(req.body.showId, function(err, show) {
if (err) return next(err);
var index = show.subscribers.indexOf(req.user.id);
show.subscribers.splice(index, 1);
show.save(function(err) {
if (err) return next(err);
res.send(200);
});
});
});
June 15, 2014 Update: Using req.user.id
of a currently signed-in user
instead of a req.body.userId
user object that was sent by in by the AngularJS app.
As I have explained above, for security reasons we should not rely on a client
because it is not to difficult to fake the value of $routeScope.currentUser
.
We are using ensureAuthenticated
middleware here to prevent unauthenticated users from accessing
these route handlers.
When users subscribe to a show this is how its MongoDB document may look:
Again, we are not storing actual users inside subscribers
array, only ObjectId references to those users.
When we need to “expand” those user objects we are going to use populate method provided by Mongoose.
Step 11: Email Notifications
For sending email notifications we are going to need agenda, sugar.js and nodemailer.
npm install --save agenda sugar nodemailer
Then add them to the list of module dependencies:
var agenda = require('agenda')({ db: { address: 'localhost:27017/test' } });
var sugar = require('sugar');
var nodemailer = require('nodemailer');
Next, we are going to create a new agenda task:
agenda.define('send email alert', function(job, done) {
Show.findOne({ name: job.attrs.data }).populate('subscribers').exec(function(err, show) {
var emails = show.subscribers.map(function(user) {
return user.email;
});
var upcomingEpisode = show.episodes.filter(function(episode) {
return new Date(episode.firstAired) > new Date();
})[0];
var smtpTransport = nodemailer.createTransport('SMTP', {
service: 'SendGrid',
auth: { user: 'hslogin', pass: 'hspassword00' }
});
var mailOptions = {
from: 'Fred Foo ✔ <foo@blurdybloop.com>',
to: emails.join(','),
subject: show.name + ' is starting soon!',
text: show.name + ' starts in less than 2 hours on ' + show.network + '.\n\n' +
'Episode ' + upcomingEpisode.episodeNumber + ' Overview\n\n' + upcomingEpisode.overview
};
smtpTransport.sendMail(mailOptions, function(error, response) {
console.log('Message sent: ' + response.message);
smtpTransport.close();
done();
});
});
});
agenda.start();
agenda.on('start', function(job) {
console.log("Job %s starting", job.attrs.name);
});
agenda.on('complete', function(job) {
console.log("Job %s finished", job.attrs.name);
});
It may not be immediately obvious how Agenda works so I will try to explain it here. Agenda is a job scheduling library for Node.js similar to node-cron. We define an agenda job called send email alert. Here, we don’t concern ourselves with when it runs. We only care what it does, i.e. what should happen when send email alert job is dispatched.
When this job runs, name of the show will be passed in as an optional data
object.
Since we are not storing the entire user document in subscribers
array (only references), we have to
use Mongoose’s populate method. Once the show is found,
we need a list of emails of all subscribers that have to be notified.
We then find the upcoming episode so that we could include a brief summary of the next episode in the email message.
And then it’s just your standard Nodemailer boilerplate for sending emails. Here is how an email message might look like when send email alert job runs:
Go back to the app.post('/api/shows')
route and add this code inside the show.save()
callback, so that it can start the agenda task whenever a new show is added to the database:
var alertDate = Date.create('Next ' + show.airsDayOfWeek + ' at ' + show.airsTime).rewind({ hour: 2});
agenda.schedule(alertDate, 'send email alert', show.name).repeatEvery('1 week');
Now that we have defined an agenda task, we are going to schedule it as soon as a new show is added.
There is a minor problem - how do we know when to schedule it? Do we schedule n jobs for every episode of every shows or would it be better to schedule a recurring job for each show? I chose the latter approach of using a recurring job per show.
The TVDB API gives us two pieces of information for each show: air time and air day, e.g. 9:00 PM and Tuesday.
Next challenge - how the heck do we construct a Date
object from that?!
Sugar.js to the rescue. Sugar overrides built-in objects such as Date to provide
us with extra functionality. The code below creates a Date
object from something like Next Saturday at 8:00 PM then subtract two hours from that.
var alertDate = Date.create('Next ' + show.airsDayOfWeek + ' at ' + show.airsTime).rewind({ hour: 2});
When a new job is scheduled, Agenda will save that job to MongoDB for guaranteed persistence:
You can do so much more with Agenda so be sure to check out the README if you are interested in running cron jobs with Node.js.
Step 12: Optimization
Just because you have a fast internet connection you shouldn’t assume that others do as well. If you want to deliver the best possible user experience it is important that your application loads fast.
Let’s take a look at the Network tab in Google Chrome to see how many requests are we making and how many bytes are transferred when users visit our site.
Here is what we are going to do in this section:
- Concatenate and minify the scripts
- Minify the stylesheet
- Cache AngularJS templates
- Enable gzip compression
- Enable static assets caching
We will use gulp.js for the first three tasks. Install the following gulp plugins:
npm install --save-dev gulp-csso gulp-uglify gulp-concat gulp-angular-templatecache
Then add them at the top with the rest of module dependecies:
var csso = require('gulp-csso');
var uglify = require('gulp-uglify');
var concat = require('gulp-concat');
var templateCache = require('gulp-angular-templatecache');
To minify CSS simply add .pipe(csso())
after .pipe(sass())
.
Here is how your sass gulp task should look now:
gulp.task('sass', function() {
gulp.src('public/stylesheets/style.scss')
.pipe(plumber())
.pipe(sass())
.pipe(csso())
.pipe(gulp.dest('public/stylesheets'));
});
To concatenate and minify JavaScript files add the following task:
gulp.task('compress', function() {
gulp.src([
'public/vendor/angular.js',
'public/vendor/*.js',
'public/app.js',
'public/services/*.js',
'public/controllers/*.js',
'public/filters/*.js',
'public/directives/*.js'
])
.pipe(concat('app.min.js'))
.pipe(uglify())
.pipe(gulp.dest('public'));
});
The reason we are passing an array of strings in this particular order is because
we need to concatenate them in the right order. It doesn’t make sense to load app.js
before
angular.js
is even loaded. That is why we first load AngularJS, then vendor fiiles, then
main app.js
file, then everything else. When you run this task a new file
app.min.js
is created.
Add compress task to the default task:
gulp.task('default', ['sass', 'compress', 'watch']);
And finally add a new watcher for the JavaScript files:
gulp.task('watch', function() {
gulp.watch('public/stylesheets/*.scss', ['sass']);
gulp.watch('public/views/**/*.html', ['templates']);
gulp.watch(['public/**/*.js', '!public/app.min.js', '!public/templates.js', '!public/vendor'], ['compress']);
});
Gulp will watch for all JavaScript files in the public
directory except for app.min.js
or any files in the vendor
directory.
June 21, 2014 Update: Added gulp.watch
for templates in the
public/views directory. I have also added
the string !public/templates.js
in the watcher below, in order to avoid
running compress task right after re-compiling templates because
public/**/*.js
in the compress task will match any JavaScript file, yes
including templates.js
.
Next, we are going to add a task for caching AngularJS templates.
Why do we need to cache AngularJS templates? If you haven’t noticed yet, open the Network
tab in Google Chrome and navigate between different pages in our ShowTrackr app. You will
notice a separate HTTP request for template files: add.html
, login.html
, signup.html
, etc.
Your goal should always be to minimize the number of HTTP requests when building high-performance
applications. This principle is especially true on mobile devices.
Add the following task for caching AngularJS templates:
gulp.task('templates', function() {
gulp.src('public/views/**/*.html')
.pipe(templateCache({ root: 'views', module: 'MyApp' }))
.pipe(gulp.dest('public'));
});
This task will create a file templates.js
in the public directory
that you have to include in the index.html
in order for AngularJS to detect it. We will do that shortly.
Don’t forget to update the default task:
gulp.task('default', ['sass', 'compress', 'templates', 'watch']);
Here is what your gulpfile.js
should look like at this point:
var gulp = require('gulp');
var sass = require('gulp-sass');
var csso = require('gulp-csso');
var uglify = require('gulp-uglify');
var concat = require('gulp-concat');
var plumber = require('gulp-plumber');
var templateCache = require('gulp-angular-templatecache');
gulp.task('sass', function() {
gulp.src('public/stylesheets/style.scss')
.pipe(plumber())
.pipe(sass())
.pipe(csso())
.pipe(gulp.dest('public/stylesheets'));
});
gulp.task('compress', function() {
gulp.src([
'public/vendor/angular.js',
'public/vendor/*.js',
'public/app.js',
'public/services/*.js',
'public/controllers/*.js',
'public/filters/*.js',
'public/directives/*.js'
])
.pipe(concat('app.min.js'))
.pipe(uglify())
.pipe(gulp.dest('public'));
});
gulp.task('templates', function() {
gulp.src('public/views/**/*.html')
.pipe(templateCache({ root: 'views', module: 'MyApp' }))
.pipe(gulp.dest('public'));
});
gulp.task('watch', function() {
gulp.watch('public/stylesheets/*.scss', ['sass']);
gulp.watch(['public/**/*.js', '!public/app.min.js', '!public/vendor'], ['compress']);
});
gulp.task('default', ['sass', 'compress', 'templates', 'watch']);
3 out of 5 tasks are complete. Let’s move on to gzip compression. Install the following Express middleware:
npm install --save compression
Add it to the list of module dependencies:
var compress = require('compression')
And finally add the middleware. This middleware should be placed “high” within the stack to ensure all responses may be compressed.
app.set('port', process.env.PORT || 3000);
app.use(compress())
app.use(logger('dev'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded());
app.use(cookieParser());
app.use(session({ secret: 'keyboard cat' }));
app.use(passport.initialize());
app.use(passport.session());
app.use(express.static(path.join(__dirname, 'public')));
app.use(function(req, res, next) {
if (req.user) {
res.cookie('user', JSON.stringify(req.user));
}
next();
});
Enable static assets caching is pretty trivial. Update your static middleware with the following,
where maxAge
is the number in milliseconds:
app.use(express.static(path.join(__dirname, 'public'), { maxAge: 86400000 }));
Note: 86400000 milliseconds is equivalent to 1 day. You may want to create a separate variable
such as oneDay
, oneWeek
, oneMonth
instead of defining milliseconds directly in the middleware.
Run gulp
in the terminal and you should see two new files in the public directory:
Note: Our default gulp.js task will continue watching for file changes
after all tasks have been executed. If you don’t like this behavior feel free
to separate it out into two separate tasks gulp build
and gulp watch
.
In index.html
add these two scripts and comment/remove all other scripts:
<script src="app.min.js"></script>
<script src="templates.js"></script>
Now if you check the Network tab again you should see much smaller number of requests and a smaller payload size.
In terms of assets optimiation we did an excellent job but the biggest bottleneck in the system
is on the GET /api/shows
request.
There are many other ways to optimize our application. For example it is not necessary for us to retrieve information about every single episode of every show because we don’t see it until we view the detail page of that show.
Also keep in mind we are storing images as Base64 strings that are are fairly large in size and resolution (680 x 1000), not cached, not optimized.
You could further improve performance by putting Redis database in front of the MongoDB for caching. Also take a look at the Couchbase database which seems to combine the best of both worlds. Couchbase seems to replace Redis, MongoDB and Riak all togther.
Consider customizing the Bootstrap framework. If you are not using certain components such as well
or button-group, remove it from bootstrap.scss
. It is also worth taking a look at gulp-uncss for removing unused CSS.
June 8, 2014 Update:
The style.css went from 146kb to 11kb using gulp-uncss in the ShowTrackr app that I posted yesterday. #nodejs #gulpjs pic.twitter.com/UbDkTZ1EiR
— Sahat Yalkabov (@EvNowAndForever) June 8, 2014
Step 13: Deployment
Create a new file .gitignore
and add node_modules
to it, since we don’t
want to commit that directory to Git.
touch .gitignore
echo node_modules > .gitignore
Open package.json
and update the start
property to the following:
"scripts": {
"start": "node server.js"
},
Go to mongolab.com and a create a new account. Then create a new single-node sandbox database. It’s free.
Note: As an alternative, you may also use MongoHQ. Both MongoLab and MongoHQ offer a sandbox database with 500MB of storage.
If you don’t feel like creating a new account you can use my database that I have created just for this tutorial:
mongodb://sahat:foobar@ds041178.mongolab.com:41178/showtrackrdemo
- Username: sahat
- Password: foobar
- Port: 41178
- Database: showtrackrdemo
Update these two lines of code with the MongoDB URI above:
Agenda
var agenda = require('agenda')({ db: { address: 'mongodb://sahat:foobar@ds041178.mongolab.com:41178/showtrackrdemo' } });
Mongoose
mongoose.connect('mongodb://sahat:foobar@ds041178.mongolab.com:41178/showtrackrdemo');
Turn your project into a Git repository:
git init
git add .
git commit -m 'Initial commit'
Create a new Heroku application:
heroku create
Note: You must have installed the Heroku Toolbelt
Deploy!
git push -u heroku master
Step 14: Closing Remarks
Congratulations on reaching this far. I hope you enjoyed this tutorial. Turns out this is also one of the longest blog posts I have ever written. For some people it would have been enough to just post the source code while others might appreciate the detailed explanations each step of the way.
There is a lot more that you can do with this project that I haven’t done. If you are interested in extending this project for fun or profit, consider the following:
- User profile page with a list of subscribed shows
- Dynamically update page
<title>
on each route - Create a personalized calendar view with subscribed shows
- Create a calendar view that displays every show (time, date, network, episode overview)
- Display a show’s episodes in Bootstrap Tabs, grouped by seasons
- Text message notifications
- Customizable alert time (2 hours in advance, 1 day in advance, etc.)
- Add an admin role; only admins can add new TV shows
- Display Twitter feed for each TV show
- Create an AngularJS service for fetching and displaying latest news and gossip about a TV show
- Resize thumbnails via sharp and optimize via gulp-imagemin then upload to Amazon S3
- Add Redis database as a caching layer
- Explore token-based authentication
- Live validation of email availability during user signup
If, after reading this tutorial, some concepts are still not clear to you, don’t give up, keep pushing yourself, keep learning. I picked up AngularJS about 2 months ago and I learned JavaScript language through Node.js and Express web framework less than 2 years ago. I am where I am today only because of the countless number of hours of writing code. There is no magic pill that will make you a JavaScript expert overnight. So keep on coding, keep on building new things with JavaScript - that really is the best way to learn.
For questions and comments send me an email.