diff --git a/.gitignore b/.gitignore index e43b0f9..9432a77 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ .DS_Store +node_modules +db diff --git a/README.md b/README.md index 51dcbfe..4ba6ed9 100644 --- a/README.md +++ b/README.md @@ -43,47 +43,47 @@ The API you build should have the following capabilities. The schema of your dat - There is not an authentication requirement for this project; assume all users interacting with the API are video store employees. ### Customers -- Retrive a list of all customers -- Retrive a subset of customers - - Given a sort column, return _n_ customer records, offset by _p_ records (this will be used to create "pages" of customers) - - Sort columns are +DONE- Retrive a list of all customers +DONE- Retrive a subset of customers + DONE- Given a sort column, return _n_ customer records, offset by _p_ records (this will be used to create "pages" of customers) + DONE- Sort columns are - `name` - `registered_at` - `postal_code` - Given a customer's `id`... - - List the movies they _currently_ have checked out + DONE- List the movies they _currently_ have checked out - List the movies a customer has checked out in the past - ordered by check out date - includes return date ### Movies -- Retrieve a list of all movies -- Retrieve a subset of movies - - Given a sort column, return _n_ movie records, offset by _p_ records (this will be used to create "pages" of movies) - - Sort columns are +DONE- Retrieve a list of all movies +DONE- Retrieve a subset of movies + DONE- Given a sort column, return _n_ movie records, offset by _p_ records (this will be used to create "pages" of movies) + DONE- Sort columns are - `title` - `release_date` - Given a movie's `title`... - - Get a list of customers that have _currently_ checked out a copy of the film - - Get a list of customers that have checked out a copy _in the past_ + DONE- Get a list of customers that have _currently_ checked out a copy of the film + DONE- Get a list of customers that have checked out a copy _in the past_ - ordered by customer `id` - ordered by customer `name` - ordered by check out date ### Rental -- Look a movie up by title to see +- DONE Look a movie up by title to see - it's synopsis - release date - and inventory total -- Know if a movie has any inventory available to rent -- See a list of customers that have _currently_ checked out any of the movie's inventory -- Given a customer's `id` and a movie's `title` ... +- DONE Know if a movie has any inventory available to rent +- DONE See a list of customers that have _currently_ checked out any of the movie's inventory +- DONE Given a customer's `id` and a movie's `title` ... - "check out" one of the movie's inventory to the customer - Establish a return date - Charge the customer's account (cost up to you) - - "check in" one of customer's rentals + - DONE "check in" one of customer's rentals - return the movie to its inventory -- See a list of customers with overdue movies +- DONE See a list of customers with overdue movies ### Interface - This part of the project is purely an API; all interactions should happen over HTTP requests. There is no front-end, user-facing interface. @@ -93,3 +93,47 @@ The API you build should have the following capabilities. The schema of your dat - We will use [Mocha](https://mochajs.org/) for tests. - There isn't a coverage requirement for this project, beyond demonstrating that every endpoint is covered by some manner of tests. +### Things to fix +- accomadate white space in 'given a movie's title' endpoints +- account for negative customer balances +- add late fees? +- account for negative availability +- possibly refactor database.js + +# Endpoints + +## Movies + +- Show all movies: +http://localhost:3000/movies +- Look up past rentals for a movie: http://localhost:3000/movies/alien/past_rentals/id +- Look up current rentals for a movie +http://localhost:3000/movies/psycho/current_rentals +- Pagination for title +http://localhost:3000/movies/title/page1 +______ + +## Customers +-Show all customers: +http://localhost:3000/customers +- Loop at subset of customers sorted by name, registered at, postal_code +http://localhost:3000/customers/name/page1 +- Show customers current rentals +http://localhost:3000/customers/1/current_rentals +- Customers past rentals +http://localhost:3000/customers/2/current_rentals + + +## Rentals +- Check out a new title +curl -X POST http://localhost:3000/rentals/new_rental/4/the_exorcist +- Check in a title +curl -X PUT http://localhost:3000/rentals/check_in/4/the_exorcist +- Check overdue rentals +http://localhost:3000/rentals/overdue +- Look at the movie's details +http://localhost:3000/rentals/the_exorcist +- Find a movie's current customer rentals +http://localhost:3000/rentals/the_exorcist/customers + + diff --git a/app.js b/app.js new file mode 100644 index 0000000..b7ea318 --- /dev/null +++ b/app.js @@ -0,0 +1,64 @@ +var express = require('express'); +var path = require('path'); +var favicon = require('serve-favicon'); +var logger = require('morgan'); +var cookieParser = require('cookie-parser'); +var bodyParser = require('body-parser'); + +var app = express(); + +// view engine setup +app.set('views', path.join(__dirname, 'views')); +app.set('view engine', 'jade'); + +// uncomment after placing your favicon in /public +//app.use(favicon(path.join(__dirname, 'public', 'favicon.ico'))); +app.use(logger('dev')); +app.use(bodyParser.json()); +app.use(bodyParser.urlencoded({ extended: false })); +app.use(cookieParser()); +app.use(express.static(path.join(__dirname, 'public'))); + + +var routes = require('./routes/movies'); +app.use('/movies', routes); + +var customers = require('./routes/customers'); +app.use('/customers', customers); + +var rentals = require('./routes/rentals'); +app.use('/rentals', rentals); + +// catch 404 and forward to error handler +app.use(function(req, res, next) { + var err = new Error('Not Found'); + err.status = 404; + next(err); +}); + +// error handlers + +// development error handler +// will print stacktrace +if (app.get('env') === 'development') { + app.use(function(err, req, res, next) { + res.status(err.status || 500); + res.render('error', { + message: err.message, + error: err + }); + }); +} + +// production error handler +// no stacktraces leaked to user +app.use(function(err, req, res, next) { + res.status(err.status || 500); + res.render('error', { + message: err.message, + error: {} + }); +}); + + +module.exports = app; diff --git a/bin/www b/bin/www new file mode 100755 index 0000000..cc43085 --- /dev/null +++ b/bin/www @@ -0,0 +1,90 @@ +#!/usr/bin/env node + +/** + * Module dependencies. + */ + +var app = require('../app'); +var debug = require('debug')('C3Projects--VideoStoreAPI:server'); +var http = require('http'); + +/** + * Get port from environment and store in Express. + */ + +var port = normalizePort(process.env.PORT || '3000'); +app.set('port', port); + +/** + * Create HTTP server. + */ + +var server = http.createServer(app); + +/** + * Listen on provided port, on all network interfaces. + */ + +server.listen(port); +server.on('error', onError); +server.on('listening', onListening); + +/** + * Normalize a port into a number, string, or false. + */ + +function normalizePort(val) { + var port = parseInt(val, 10); + + if (isNaN(port)) { + // named pipe + return val; + } + + if (port >= 0) { + // port number + return port; + } + + return false; +} + +/** + * Event listener for HTTP server "error" event. + */ + +function onError(error) { + if (error.syscall !== 'listen') { + throw error; + } + + var bind = typeof port === 'string' + ? 'Pipe ' + port + : 'Port ' + port; + + // handle specific listen errors with friendly messages + switch (error.code) { + case 'EACCES': + console.error(bind + ' requires elevated privileges'); + process.exit(1); + break; + case 'EADDRINUSE': + console.error(bind + ' is already in use'); + process.exit(1); + break; + default: + throw error; + } +} + +/** + * Event listener for HTTP server "listening" event. + */ + +function onListening() { + var addr = server.address(); + var bind = typeof addr === 'string' + ? 'pipe ' + addr + : 'port ' + addr.port; + debug('Listening on ' + bind); +} diff --git a/controllers/customers.js b/controllers/customers.js new file mode 100644 index 0000000..cd4ed47 --- /dev/null +++ b/controllers/customers.js @@ -0,0 +1,43 @@ +"use strict"; + +var Customer = require('../models/customer'); + +exports.customersController = { + index: function index(req, res) { + + var customer = new Customer(); + + customer.find_all(function(err, record) { + res.status(200).json({ all_customers: record }); + }) + }, + + subset: function subset(req, res, column, pageNumber) { + + var customer = new Customer(); + //pagination begins at 0 + var offset = pageNumber * 50; + + customer.find_subset(column, 50, offset, function(err, record) { + res.status(200).json({ customer_subset: record }); + }) + }, + + current_rentals: function current_rentals(req, res, customer_id) { + + var customer = new Customer(); + + customer.customer_rentals(customer_id, function(err, record){ + res.status(200).json({ current_rentals: record }); + }) + }, + + rental_history: function rental_history(req, res, customer_id) { + + var customer = new Customer(); + + customer.customer_history(customer_id, function(err, record){ + res.status(200).json({ rental_history: record }); + }) + } +} diff --git a/controllers/movies.js b/controllers/movies.js new file mode 100644 index 0000000..6e17686 --- /dev/null +++ b/controllers/movies.js @@ -0,0 +1,53 @@ +"use strict"; + +var Movie = require('../models/movie'); + +function titleSpaces(title) { + return title.replace("_", " "); +} + +exports.moviesController = { + + index: function index(req, res) { + + var movie = new Movie(); + + movie.find_all(function(err, record) { + res.status(200).json({ all_movies: record }); + }) + }, + + subset: function subset(req, res, column, pageNumber) { + + var movie = new Movie(); + + //pagination begins at 0 + var offset = pageNumber * 50; + + movie.find_subset(column, 50, offset, function(err, record) { + res.status(200).json({ movie_subset: record }); + }) + }, + + current_rentals: function current_rentals(req, res, movie_title) { + + movie_title = titleSpaces(movie_title); + + var movie = new Movie(); + + movie.movie_current_customers(movie_title, function(err, record) { + res.status(200).json({ movie_current_customers: record }); + }) + }, + + past_rentals: function past_rentals(req, res, movie_title, column) { + + movie_title = titleSpaces(movie_title); + + var movie = new Movie(); + + movie.movie_past_customers(movie_title, column, function(err, record) { + res.status(200).json({ movie_past_customers: record }); + }) + } +} diff --git a/controllers/rentals.js b/controllers/rentals.js new file mode 100644 index 0000000..2f397ba --- /dev/null +++ b/controllers/rentals.js @@ -0,0 +1,92 @@ +"use strict"; + +var Rental = require('../models/rental'); + +function titleSpaces(title) { + return title.replace("_", " "); +} + +exports.rentalsController = { + find_movie: function find_movie(req, res, movie_title) { + + movie_title = titleSpaces(movie_title); + var rental = new Rental(); + + + rental.movie_details(movie_title, function(err, record) { + res.status(200).json({ movie_details: record }); + }) + }, + + find_customers: function find_customers(req, res, movie_title) { + + movie_title = titleSpaces(movie_title); + var rental = new Rental(); + + rental.customers(movie_title, function(err, record) { + res.status(200).json({ rental_customers: record }); + }) + }, + + overdue_customers: function overdue_customers(req, res) { + + var rental = new Rental(); + + rental.overdue(function(err, record) { + res.status(200).json({ overdue_customers: record }); + }) + }, + + new_rental: function new_rental(req, res, customer_id, movie_title) { + + movie_title = titleSpaces(movie_title); + var rental = new Rental(); + + // check availablity in movies table first + rental.check_movie_availability(movie_title, function(err, record) { + if (record[0].available > 0) { + // console.log("we're getting somewhere"); + + movie_title = titleSpaces(movie_title); + //nested function inception :( + + // change available in movies table + rental.update_availabile_movies(movie_title, function(err, record) { + // insert new rental into rental table + rental.create_rental(movie_title, customer_id, function(err, record) + // charge customer and update customer table + {rental.charge_customer(customer_id, function(err, record) { + res.status(200).json({ new_rental: "You've successfully charged the customer." }); + }); + }); + }); + + } else { + // fail everything. die. + res.status(200).json({ new_rental: "Sorry, something went wrong." }); + } + }) + + }, + + check_in: function check_in(req, res, customer_id, movie_title) { + + movie_title = titleSpaces(movie_title); + + var rental = new Rental(); + + + // update the movie's available column + rental.check_movie_in(movie_title, function(err, record) { + // update today's date to return Date + movie_title = titleSpaces(movie_title); + + res.status(200).json({ new_rental: "You've successfully updated the availability." }); + + rental.update_return_date(customer_id, movie_title, function(err, record) { + }); + + }) + + } +} diff --git a/database.js b/database.js new file mode 100644 index 0000000..dc2e14a --- /dev/null +++ b/database.js @@ -0,0 +1,195 @@ +"use strict"; + +var sqlite3 = require('sqlite3').verbose(), + db_env = process.env.DB || 'development'; + +module.exports = { + find_all: function(callback) { + var db = new sqlite3.Database('db/' + db_env + '.db'); + var statement = "SELECT * FROM " + this.table_name + ";"; + + db.all(statement, function(err, record) { + if (callback) callback(err, record); + db.close(); + }); + }, + + find_subset: function(column, limit, offset, callback) { + var db = new sqlite3.Database('db/' + db_env + '.db'); + var statement = "SELECT * FROM " + this.table_name + " ORDER BY " + column + " LIMIT " + limit + " OFFSET " + offset + ";"; + + db.all(statement, function(err, record) { + if (callback) callback(err, record); + db.close(); + }); + }, + + find_by: function(column, value, callback) { + var db = new sqlite3.Database('db/' + db_env + '.db'); + var statement = "SELECT * FROM " + this.table_name + " WHERE " + column + "= ?;"; + + db.all(statement, value, function(err, res) { + if (callback) callback(err, res); + db.close(); + }); + }, + + customer_rentals: function(value, callback) { + var db = new sqlite3.Database('db/' + db_env + '.db'); + + var statement = "SELECT 'movies'.* FROM 'movies' INNER JOIN 'rentals' ON 'movies'.'id' = 'rentals'.'movie_id' WHERE 'rentals'.'customer_id' = ? AND 'rentals'.'returned_date' = '';"; + + db.all(statement, value, function(err, res) { + if (callback) callback(err, res); + db.close(); + }); + }, + + customer_history: function(value, callback) { + var db = new sqlite3.Database('db/' + db_env + '.db'); + + var statement = "SELECT movies.title, rentals.returned_date, rentals.checked_out FROM movies JOIN rentals ON movies.id = rentals.movie_id WHERE rentals.customer_id = ? AND rentals.returned_date != '' ORDER BY rentals.checked_out;" + + + db.all(statement, value, function(err, res) { + if (callback) callback(err, res); + db.close(); + }); + }, + + movie_current_customers: function(value, callback) { + var db = new sqlite3.Database('db/' + db_env + '.db'); + + var statement = "SELECT 'customers'.* FROM 'customers' INNER JOIN 'rentals' ON 'customers'.'id' = 'rentals'.'customer_id' WHERE 'rentals'.'movie_id' = (SELECT 'movies'.'id' FROM 'movies' WHERE 'movies'.'title' = ? COLLATE NOCASE LIMIT 1) AND 'rentals'.'returned_date' = '';"; + + db.all(statement, value, function(err, res) { + if (callback) callback(err, res); + db.close(); + }); + }, + + movie_past_customers: function(value, column, callback) { + var db = new sqlite3.Database('db/' + db_env + '.db'); + var statement = "SELECT 'customers'.* FROM 'customers' INNER JOIN 'rentals' ON 'customers'.'id' = 'rentals'.'customer_id' WHERE 'rentals'.'movie_id' = (SELECT 'movies'.'id' FROM 'movies' WHERE 'movies'.'title' = ? COLLATE NOCASE LIMIT 1) AND 'rentals'.'returned_date' != '' ORDER BY '" + column + "';"; + + db.all(statement, value, function(err, res) { + if (callback) callback(err, res); + db.close(); + }); + }, + + movie_details: function(value, callback) { + var db = new sqlite3.Database('db/' + db_env + '.db'); + var statement = "SELECT * FROM movies WHERE title = ? COLLATE NOCASE;"; + + db.all(statement, value, function(err, res) { + if (callback) callback(err, res); + db.close(); + }); + }, + + customers: function(value, callback) { + var db = new sqlite3.Database('db/' + db_env + '.db'); + var statement = "SELECT 'customers'.* FROM 'customers' INNER JOIN 'rentals' ON 'customers'.'id' = 'rentals'.'customer_id' WHERE 'rentals'.'movie_id' = (SELECT 'movies'.'id' FROM 'movies' WHERE 'movies'.'title' = ? COLLATE NOCASE LIMIT 1) AND 'rentals'.'returned_date' = '';"; + + db.all(statement, value, function(err, res) { + if (callback) callback(err, res); + db.close(); + }); + }, + + overdue: function(callback) { + var db = new sqlite3.Database('db/' + db_env + '.db'); + + //var today = new Date().toISOString().slice(0, 10); //gets date in SQL format for comparison + var today = new Date(); // sets today to today + + var date_conversion = today.setDate(today.getDate() - 10); // + + var overdue_date = new Date(date_conversion).toISOString().slice(0, 10); + console.log(overdue_date); + + var statement = 'SELECT customers.* FROM customers INNER JOIN rentals ON customers.id = rentals.customer_id WHERE rentals.returned_date = "" AND rentals.checked_out < ? ;'; + + db.all(statement, overdue_date, function(err, result) { + if (callback) callback(err, result); + db.close(); + }); + }, + + check_movie_availability: function(value, callback) { + var db = new sqlite3.Database('db/' + db_env + '.db'); + + var statement = 'SELECT movies.available FROM movies where title = ? COLLATE NOCASE;'; + + db.all(statement, value, function(err, result) { + if (callback) callback(err, result); + db.close(); + }); + }, + + create_rental: function(movie_title, customer_id, callback) { + var db = new sqlite3.Database('db/' + db_env + '.db'); + + var ugly_today = new Date(); + + var today = new Date().toISOString().slice(0, 10); + + var date_conversion = ugly_today.setDate(ugly_today.getDate() + 10); + + var due_date = new Date(date_conversion).toISOString().slice(0, 10); + + var statement = "INSERT INTO rentals(movie_id, customer_id, returned_date, due_date, checked_out) VALUES((select id from movies where title=? COLLATE NOCASE), ?, '', ?, ?);"; + + db.all(statement, movie_title, customer_id, due_date, today, function(err, result) { + if (callback) callback(err, result); + db.close(); + }); + }, + + charge_customer: function(customer_id, callback) { + var db = new sqlite3.Database('db/' + db_env + '.db'); + + // We're charging a dollar + var statement = "UPDATE customers SET account_credit = account_credit - 1.0 WHERE id = ?"; + + db.all(statement, customer_id, function(err, result) { + if (callback) callback(err, result); + db.close(); + }); + }, + + + update_availabile_movies: function(movie_title, callback) { + var db = new sqlite3.Database('db/' + db_env + '.db'); + + var statement = "UPDATE movies SET available = available - 1 WHERE title = ? COLLATE NOCASE"; + + db.all(statement, movie_title, function(err, result) { + if (callback) callback(err, result); + db.close(); + }); + }, + + update_return_date: function(customer_id, movie_title, callback) { + var db = new sqlite3.Database('db/' + db_env + '.db'); + + var statement = "UPDATE rentals SET returned_date = date('now') WHERE customer_id = ? AND movie_id = (SELECT id FROM movies WHERE title = ? COLLATE NOCASE) AND returned_date ='' ;"; + + db.all(statement, customer_id, movie_title, function(err, result) { + if (callback) callback(err, result); + db.close(); + }); + }, + + check_movie_in: function(movie_title, callback) { + var db = new sqlite3.Database('db/' + db_env + '.db'); + + var statement = "UPDATE movies SET available = available + 1 WHERE title = ? COLLATE NOCASE"; + + db.all(statement, movie_title, function(err, result) { + if (callback) callback(err, result); + db.close(); + }); + }, +} diff --git a/models/customer.js b/models/customer.js new file mode 100644 index 0000000..d1b1dd4 --- /dev/null +++ b/models/customer.js @@ -0,0 +1,9 @@ +"use strict"; + +function Customer() { + this.table_name = "customers"; +} + +Customer.prototype = require('../database'); + +module.exports = Customer diff --git a/models/movie.js b/models/movie.js new file mode 100644 index 0000000..83043d5 --- /dev/null +++ b/models/movie.js @@ -0,0 +1,9 @@ +"use strict"; + +function Movie() { + this.table_name = "movies"; +} + +Movie.prototype = require('../database'); + +module.exports = Movie diff --git a/models/rental.js b/models/rental.js new file mode 100644 index 0000000..da6f00a --- /dev/null +++ b/models/rental.js @@ -0,0 +1,9 @@ +"use strict"; + +function Rental() { + this.table_name = "rentals"; +} + +Rental.prototype = require('../database'); + +module.exports = Rental diff --git a/package.json b/package.json new file mode 100644 index 0000000..16a84ee --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "C3Projects--VideoStoreAPI", + "version": "0.0.0", + "private": true, + "scripts": { + "start": "nodemon ./bin/www", + "test": "clear; DB=test mocha --recursive", + "db:schema": "node ./utils/schema", + "db:seed": "node ./utils/seed", + "db:setup": "npm run db:schema; npm run db:seed", + "db:test_setup": "DB=test npm run db:schema" + }, + "dependencies": { + "body-parser": "~1.13.2", + "cookie-parser": "~1.3.5", + "debug": "~2.2.0", + "express": "~4.13.1", + "jade": "~1.11.0", + "mocha": "^2.3.3", + "morgan": "~1.6.1", + "serve-favicon": "~2.3.0", + "sqlite3": "^3.1.0", + "supertest": "^1.1.0" + } +} diff --git a/public/stylesheets/style.css b/public/stylesheets/style.css new file mode 100644 index 0000000..9453385 --- /dev/null +++ b/public/stylesheets/style.css @@ -0,0 +1,8 @@ +body { + padding: 50px; + font: 14px "Lucida Grande", Helvetica, Arial, sans-serif; +} + +a { + color: #00B7FF; +} diff --git a/rentals.json b/rentals.json new file mode 100644 index 0000000..2a3ec05 --- /dev/null +++ b/rentals.json @@ -0,0 +1,51 @@ +[ + { + "movie_id": 1, + "customer_id": 1, + "returned_date": "", + "due_date": "2015-09-11", + "checked_out": "2015-09-01" + }, + { + "movie_id": 1, + "customer_id": 7, + "returned_date": "2015-06-19", + "due_date": "2015-06-21", + "checked_out": "2015-06-11" + }, + { + "movie_id": 7, + "customer_id": 7, + "returned_date": "2015-07-18", + "due_date": "2015-06-21", + "checked_out": "2015-06-11" + }, + { + "movie_id": 3, + "customer_id": 1, + "returned_date": "", + "due_date": "2015-09-30", + "checked_out": "2015-09-20" + }, + { + "movie_id": 1, + "customer_id": 1, + "returned_date": "2015-07-17", + "due_date": "2015-07-20", + "checked_out": "2015-07-10" + }, + { + "movie_id": 5, + "customer_id": 3, + "returned_date": "", + "due_date": "2015-08-24", + "checked_out": "2015-08-14" + }, + { + "movie_id": 1, + "customer_id": 2, + "returned_date": "", + "due_date": "2015-09-30", + "checked_out": "2015-09-20" + } +] diff --git a/routes/customers.js b/routes/customers.js new file mode 100644 index 0000000..b7eba63 --- /dev/null +++ b/routes/customers.js @@ -0,0 +1,28 @@ +var express = require('express'); +var router = express.Router(); + +var customer_exports = require('../controllers/customers'); + +/* GET users listing. */ +router.get('/', function(req, res, next) { + return customer_exports.customersController.index(req, res); +}); + +// customers/:id/current_rentals +router.get('/:id/current_rentals', function(req, res, next) { + return customer_exports.customersController.current_rentals(req, res, req.params.id); +}); + +// customers/:id/rental_history +router.get('/:id/rental_history', function(req, res, next) { + return customer_exports.customersController.rental_history(req, res, req.params.id); +}); + +// customers/name/page1 +// customers/registered_at/page1 +// customers/postal_code/page1 +router.get('/:column/page:number', function(req, res, next) { + return customer_exports.customersController.subset(req, res, req.params.column, req.params.number); +}); + +module.exports = router; diff --git a/routes/movies.js b/routes/movies.js new file mode 100644 index 0000000..0f98f54 --- /dev/null +++ b/routes/movies.js @@ -0,0 +1,33 @@ +var express = require('express'); +var router = express.Router(); + +var movie_exports = require('../controllers/movies'); + +/* GET home page. */ +router.get('/', function(req, res, next) { + //return list of all movies + return movie_exports.moviesController.index(req, res); +}); + +//movies/:title/past_rentals/:column +router.get('/:movie_title/past_rentals/:column', function(req, res, next) { + return movie_exports.moviesController.past_rentals(req, res, req.params.movie_title, req.params.column); +}); + + +//movies/:title/current_rentals +router.get('/:movie_title/current_rentals', function(req, res, next) { + return movie_exports.moviesController.current_rentals(req, res, req.params.movie_title); +}); + + +// movies/name/1 +router.get('/:column/page:number', function(req, res, next) { + return movie_exports.moviesController.subset(req, res, req.params.column, req.params.number); +}); + +// one movies/title/customers will return customer info +// another movie/title will return movie info + + +module.exports = router; diff --git a/routes/rentals.js b/routes/rentals.js new file mode 100644 index 0000000..99a9d05 --- /dev/null +++ b/routes/rentals.js @@ -0,0 +1,38 @@ +var express = require('express'); +var router = express.Router(); + +var rental_exports = require('../controllers/rentals'); + +//rentals/new_rental/:id/:title +router.post('/new_rental/:customer_id/:movie_title', function(req, res, next) { + //return list of all movies + return rental_exports.rentalsController.new_rental(req, res, req.params.customer_id, req.params.movie_title); +}); + +//rentals/check_in/:id/:title +router.put('/check_in/:customer_id/:movie_title', function(req, res, next) { + //return list of all movies + return rental_exports.rentalsController.check_in(req, res, req.params.customer_id, req.params.movie_title); +}); + +//rentals/overdue +router.get('/overdue', function(req, res, next) { + //return list of all movies + return rental_exports.rentalsController.overdue_customers(req, res); +}); + + +router.get('/:movie_title', function(req, res, next) { + //return list of all movies + return rental_exports.rentalsController.find_movie(req, res, req.params.movie_title); +}); + +//rentals/:movie_title/customers +router.get('/:movie_title/customers', function(req, res, next) { + //return list of a movie's current rental customers + return rental_exports.rentalsController.find_customers(req, res, req.params.movie_title); +}); + + + +module.exports = router; diff --git a/test/controllers/customer.js b/test/controllers/customer.js new file mode 100644 index 0000000..b3da29e --- /dev/null +++ b/test/controllers/customer.js @@ -0,0 +1,77 @@ +var request = require('supertest'), + assert = require('assert'), + app = require('../../app'), + sqlite3 = require('sqlite3').verbose(), + agent = request.agent(app); + +describe("customers controller", function(){ + + describe("GET /", function(){ + it("knows about the route", function(done) { + agent.get("/customers").set('Accept', 'application/json') + .expect('Content-Type', /application\/json/) + .expect(200, function(error, result){ + assert.equal(error, undefined); + done(); + }) + }) + + it("can find a single customer's current rentals", function(done) { + agent.get("/customers/1/current_rentals").set('Accept', 'application/json') + .expect('Content-Type', /application\/json/) + .expect(200, function(error, result){ + assert.equal(error, undefined); + assert.equal(result.body.current_rentals.length, 1); + var keys = ["id", "title", "overview", "release_date", "inventory", "available"]; + assert.deepEqual(Object.keys(result.body.current_rentals[0]), keys); + done(); + }) + }) + + it("can find a single customer's rental history", function(done) { + agent.get("/customers/2/rental_history").set('Accept', 'application/json') + .expect('Content-Type', /application\/json/) + .expect(200, function(error, result){ + assert.equal(error, undefined); + assert.equal(result.body.rental_history.length, 1); + var keys = ["title", "returned_date", "checked_out"]; + assert.deepEqual(Object.keys(result.body.rental_history[0]), keys); + done(); + }) + }) + + + it("finds customer subset pages sorted by name", function(done) { + agent.get("/customers/name/page0").set('Accept', 'application/json') + .expect('Content-Type', /application\/json/) + .expect(200, function(error, result){ + assert.equal(error, undefined); + assert.equal(result.body.customer_subset[0].name, "Mulder"); + assert.equal(result.body.customer_subset[1].name, "Scully"); + done(); + }) + }) + + it("finds customer subset pages sorted by registered_at", function(done) { + agent.get("/customers/registered_at/page0").set('Accept', 'application/json') + .expect('Content-Type', /application\/json/) + .expect(200, function(error, result){ + assert.equal(error, undefined); + assert.equal(result.body.customer_subset[0].registered_at, "2013-12-23"); + assert.equal(result.body.customer_subset[1].registered_at, "2015-09-16"); + done(); + }) + }) + + it("finds customer subset pages sorted by postal_code", function(done) { + agent.get("/customers/postal_code/page0").set('Accept', 'application/json') + .expect('Content-Type', /application\/json/) + .expect(200, function(error, result){ + assert.equal(error, undefined); + assert.equal(result.body.customer_subset[0].postal_code, "2342"); + assert.equal(result.body.customer_subset[1].postal_code, "834885"); + done(); + }) + }) + }) +}) diff --git a/test/controllers/movies.js b/test/controllers/movies.js new file mode 100644 index 0000000..a4fe8d8 --- /dev/null +++ b/test/controllers/movies.js @@ -0,0 +1,70 @@ +var request = require('supertest'), + assert = require('assert'), + app = require('../../app'), + sqlite3 = require('sqlite3').verbose(), + agent = request.agent(app); + +describe("movies controller", function(){ + describe("GET /", function(){ + it("knows about the route", function(done) { + agent.get("/movies").set('Accept', 'application/json') + .expect('Content-Type', /application\/json/) + .expect(200, function(error, result){ + assert.equal(error, undefined); + done(); + }); + }); + }); + + it("returns customers who have rented movie in the past", function(done) { + agent.get("/movies/The%20Lone%20Gunmen/past_rentals/title").set('Accept', 'application/json') + .expect('Content-Type', /application\/json/) + .expect(200, function(error, result){ + assert.equal(error, undefined); + assert.equal(result.body.movie_past_customers.length, 1); + var keys = ["id", "name", "registered_at", "address", "city", "state", "postal_code", "phone", "account_credit"]; + assert.deepEqual(Object.keys(result.body.movie_past_customers[0]), keys); + assert.equal(result.body.movie_past_customers[0].name, "Scully"); + done(); + }); + }); + + it("returns customers who currently have movie rented", function(done) { + agent.get("/movies/X-files:%20I%20want%20to%20believe/current_rentals").set('Accept', 'application/json') + .expect('Content-Type', /application\/json/) + .expect(200, function(error, result){ + assert.equal(error, undefined); + assert.equal(result.body.movie_current_customers.length, 1); + var keys = ["id", "name", "registered_at", "address", "city", "state", "postal_code", "phone", "account_credit"]; + assert.deepEqual(Object.keys(result.body.movie_current_customers[0]), keys); + assert.equal(result.body.movie_current_customers[0].name, "Mulder"); + done(); + }); + }); + + it("returns subset of movies sorted by title", function(done) { + agent.get("/movies/title/page0").set('Accept', 'application/json') + .expect('Content-Type', /application\/json/) + .expect(200, function(error, result){ + assert.equal(error, undefined); + assert.equal(result.body.movie_subset.length, 3); + assert.equal(result.body.movie_subset[0].title, "Fight the Future") + assert.equal(result.body.movie_subset[1].title, "The Lone Gunmen") + assert.equal(result.body.movie_subset[2].title, "X-files: I want to believe") + done(); + }); + }); + + it("returns subset of movies sorted by release_date", function(done) { + agent.get("/movies/release_date/page0").set('Accept', 'application/json') + .expect('Content-Type', /application\/json/) + .expect(200, function(error, result){ + assert.equal(error, undefined); + assert.equal(result.body.movie_subset.length, 3); + assert.equal(result.body.movie_subset[0].release_date, "1998") + assert.equal(result.body.movie_subset[1].release_date, "2001") + assert.equal(result.body.movie_subset[2].release_date, "2007") + done(); + }); + }); +}); diff --git a/test/controllers/rentals.js b/test/controllers/rentals.js new file mode 100644 index 0000000..bc77578 --- /dev/null +++ b/test/controllers/rentals.js @@ -0,0 +1,143 @@ +var request = require('supertest'), + assert = require('assert'), + app = require('../../app'), + sqlite3 = require('sqlite3').verbose(), + agent = request.agent(app); + +// describe('Rental', function() { +// var rental, +// db_cleaner; + +// afterEach(function(done) { +// rental = new Rental(); + +// db_cleaner = new sqlite3.Database('db/test.db'); +// db_cleaner.serialize(function() { + +// db_cleaner.exec("BEGIN; DELETE FROM rentals; COMMIT;", function(err) { + +// db_cleaner.close(); +// done(); +// } +// ); +// }); +// }); +// }); + + +describe("rentals controller", function(){ + it("returns customers with overdue rentals", function(done) { + agent.get("/rentals/overdue").set('Accept', 'application/json') + .expect('Content-Type', /application\/json/) + .expect(200, function(error, result){ + assert.equal(error, undefined); + assert.equal(result.body.overdue_customers.length, 1); + var keys = ["id", "name", "registered_at", "address", "city", "state", "postal_code", "phone", "account_credit"]; + assert.deepEqual(Object.keys(result.body.overdue_customers[0]), keys); + done(); + }); + }); + + it("returns movie details", function(done) { + agent.get("/rentals/The%20Lone%20Gunmen").set('Accept', 'application/json') + .expect('Content-Type', /application\/json/) + .expect(200, function(error, result){ + assert.equal(error, undefined); + assert.equal(result.body.movie_details.length, 1); + var keys = ["id", "title", "overview", "release_date", "inventory", "available"]; + assert.deepEqual(Object.keys(result.body.movie_details[0]), keys); + assert.equal(result.body.movie_details[0].inventory, 5) + assert.equal(result.body.movie_details[0].overview,'misadventures of the best nerds') + + done(); + }); + }); + + it("returns current customers who are renting movie", function(done) { + agent.get("/rentals/X-files:%20I%20want%20to%20believe/customers").set('Accept', 'application/json') + .expect('Content-Type', /application\/json/) + .expect(200, function(error, result){ + assert.equal(error, undefined); + assert.equal(result.body.rental_customers.length, 1); + var keys = ["id", "name", "registered_at", "address", "city", "state", "postal_code", "phone", "account_credit"]; + assert.deepEqual(Object.keys(result.body.rental_customers[0]), keys); + assert.equal(result.body.rental_customers[0].name, "Mulder") + done(); + }); + }); + + describe("rentals check-in", function(){ + it("updates a movie's availability", function(done) { + agent.post("/rentals/new_rental/1/Fight%20The%20Future").set('Accept', 'application/json') + .expect('Content-Type', /application\/json/) + .expect(200, function(error, result){ + assert.equal(error, undefined); + + var db = new sqlite3.Database('db/test.db'); + + db.serialize (function () { + db.all("SELECT available FROM movies WHERE title= 'Fight the Future';", function(err, record) { + new_value = (record[0].available); + assert.equal(new_value, 6) + done(); + }); + }); + }); + }); + }); + + it("inserts a new rental into the rental database", function(done) { + agent.post("/rentals/new_rental/1/The%20Lone%20Gunmen").set('Accept', 'application/json') + .expect('Content-Type', /application\/json/) + .expect(200, function(error, result){ + assert.equal(error, undefined); + + var db = new sqlite3.Database('db/test.db'); + + db.serialize (function () { + db.all("SELECT * FROM rentals;", function(err, record) { + assert.equal(record.length, 3); + done(); + }); + }); + }); + }); + + it("charges the customer", function(done) { + agent.post("/rentals/new_rental/2/X-files:%20I%20want%20to%20believe").set('Accept', 'application/json') + .expect('Content-Type', /application\/json/) + .expect(200, function(error, result){ + assert.equal(error, undefined); + assert.equal(result.body.new_rental, "You've successfully charged the customer."); + + var db = new sqlite3.Database('db/test.db'); + + db.serialize (function () { + db.all("SELECT account_credit FROM customers WHERE id=2;", function(err, record) { + assert.equal(record[0].account_credit, 6.0); + done(); + }); + }); + }); + }); + + + it("checks in a movie and updates movie availability", function(done) { + agent.put("/rentals/check_in/1/X-files:%20I%20want%20to%20believe").set('Accept', 'application/json') + .expect('Content-Type', /application\/json/) + .expect(200, function(error, result){ + assert.equal(error, undefined); + + var db = new sqlite3.Database('db/test.db'); + + db.serialize (function () { + db.all("SELECT * FROM rentals WHERE customer_id= 1 AND movie_id =(SELECT id FROM movies WHERE title='X-files: I want to believe');", function(err, record) { + var today = new Date().toISOString().slice(0, 10); + assert.equal(record[0].returned_date, today); + done(); + }); + }); + }); + }); + }); + diff --git a/test/models/customer.js b/test/models/customer.js new file mode 100644 index 0000000..d9e93b9 --- /dev/null +++ b/test/models/customer.js @@ -0,0 +1,78 @@ +var assert = require('assert'), + Customer = require('../../models/customer'), + sqlite3 = require('sqlite3').verbose(); + +describe('Customer', function() { + var customer, + db_cleaner; + + beforeEach(function(done) { + customer = new Customer(); + + db_cleaner = new sqlite3.Database('db/test.db'); + db_cleaner.serialize(function() { + + db_cleaner.exec("BEGIN; DELETE FROM customers; DELETE FROM rentals; INSERT INTO customers(name, registered_at, address, city, state, postal_code, phone, account_credit) VALUES('Mulder', '2013-12-23', '123', 'DC', 'DC', '834885', '49583', 5.0), ('Scully', '2015-09-16', '12 blah','DC', 'DC', '2342', '534', 7.0); INSERT INTO rentals(movie_id, customer_id, returned_date, due_date, checked_out) VALUES(1, 1, '', '2015-09-10', '2015-09-01'), (2, 2, '2015-09-30', '2015-10-01', '2015-09-15'); COMMIT;", function(err) { + + db_cleaner.close(); + done(); + } + ); + }); + }); + + it("can be instantiated", function(){ + assert(customer instanceof Customer); + }); + + describe("instance methods", function() { + it("can find all customers", function(done) { + customer.find_all(function(err, res) { + assert.equal(err, undefined); + assert(res instanceof Array); + assert.equal(res.length, 2); + + assert.equal(res[0].name, 'Mulder'); + assert.equal(res[1].name, 'Scully'); + + done(); + }); + }); + + it("can find a subset of customers", function(done) { + customer.find_subset('name', 1, 1, function(err, res) { + assert.equal(err, undefined); + assert(res instanceof Array); + assert.equal(res.length, 1); + + assert.equal(res[0].name, 'Scully'); + + done(); + }); + }); + + it("can find a customer's current rentals", function(done) { + customer.customer_rentals(1, function(err, res) { + assert.equal(err, undefined); + assert(res instanceof Array); + assert.equal(res.length, 1); + + assert.equal(res[0].title, 'X-files: I want to believe'); + + done(); + }); + }); + + it("can find a customer's past rentals", function(done) { + customer.customer_history(2, function(err, res) { + assert.equal(err, undefined); + assert(res instanceof Array); + assert.equal(res.length, 1); + + assert.equal(res[0].title, 'The Lone Gunmen'); + + done(); + }); + }); + }); +}); diff --git a/test/models/movie.js b/test/models/movie.js new file mode 100644 index 0000000..5a1db37 --- /dev/null +++ b/test/models/movie.js @@ -0,0 +1,76 @@ + var assert = require('assert'), + Movie = require('../../models/movie'), + sqlite3 = require('sqlite3').verbose(); + +describe("Movie", function() { + var movie, db_cleaner + + beforeEach(function(done) { + movie = new Movie(); + + db_cleaner = new sqlite3.Database('db/test.db'); + db_cleaner.serialize(function() { + db_cleaner.exec("BEGIN; DELETE FROM movies; DELETE FROM rentals; INSERT INTO movies(title, overview, release_date, inventory, available) VALUES('X-files: I want to believe', 'Mulder and Scully rock it', '2007', 4, 3), ('The Lone Gunmen', 'misadventures of the best nerds', '2001', 5, 5), ('Fight the Future', 'a bee ruins everything', '1998', 7, 7); INSERT INTO rentals(movie_id, customer_id, returned_date, due_date, checked_out) VALUES(1, 1, '', '2015-09-10', '2015-09-01'), (2, 2, '2015-09-30', '2015-10-01', '2015-09-15'); COMMIT;" + , function(err) { + db_cleaner.close(); + done(); + } + ); + }); + }) + + it("can be instantiated", function() { + assert(movie instanceof Movie); + }); + + describe("instance methods", function() { + it("can find all movies", function(done) { + movie.find_all(function(err, res) { + assert.equal(err, undefined); + assert(res instanceof Array); + assert.equal(res.length, 3); + + assert.equal(res[0].title, 'X-files: I want to believe'); + assert.equal(res[1].title, 'The Lone Gunmen'); + + done(); + }); + }); + + it("can find a subset of movies", function(done) { + movie.find_subset('release_date', 50, 0, function(err, res) { + assert.equal(err, undefined); + assert(res instanceof Array); + assert.equal(res.length, 3); + assert.equal(res[0].title, 'Fight the Future'); + + done(); + }); + }); + + it("can find customers who currently have a rental for that movie", function(done) { + movie.movie_current_customers('X-files: I want to believe', function(err, res) { + assert.equal(err, undefined); + assert(res instanceof Array); + assert.equal(res.length, 1); + + assert.equal(res[0].name, 'Mulder'); + + done(); + }); + }); + + it("can find customers who have a past rental for that movie", function(done) { + movie.movie_past_customers('The Lone Gunmen', 'name', function(err, res) { + assert.equal(err, undefined); + assert(res instanceof Array); + assert.equal(res.length, 1); + + assert.equal(res[0].name, 'Scully'); + + done(); + }); + }); + }); +}); + diff --git a/test/models/rentals.js b/test/models/rentals.js new file mode 100644 index 0000000..cfe8d01 --- /dev/null +++ b/test/models/rentals.js @@ -0,0 +1,72 @@ +var assert = require('assert'), + Rental = require('../../models/rental'), + sqlite3 = require('sqlite3').verbose(); + +describe('Rental', function() { + var rental, + db_cleaner; + + beforeEach(function(done) { + rental = new Rental(); + + db_cleaner = new sqlite3.Database('db/test.db'); + db_cleaner.serialize(function() { + db_cleaner.exec("BEGIN; DELETE FROM customers; DELETE FROM rentals; INSERT INTO customers(name, registered_at, address, city, state, postal_code, phone, account_credit) VALUES('Mulder', '2013-12-23', '123', 'DC', 'DC', '834885', '49583', 5), ('Scully', '2015-09-16', '12 blah','DC', 'DC', '2342', '534', 7); INSERT INTO rentals(movie_id, customer_id, returned_date, due_date, checked_out) VALUES(1, 1, '', '2015-09-10', '2015-09-01'), (2, 2, '2015-10-01', '2015-09-29', '2015-09-19'); COMMIT;", function(err) { + db_cleaner.close(); + done(); + } + ); + }); + }); + + it("can be instantiated", function() { + assert(rental instanceof Rental); + }); + + describe("instance methods", function() { + it("can find details of a movie", function(done) { + rental.movie_details("X-files: I want to believe", function(err, res) { + assert.equal(err, undefined); + assert(res instanceof Array); + assert.equal(res.length, 1); + + assert.equal(res[0].overview, 'Mulder and Scully rock it'); + done(); + }); + }); + + it("can find current customers of a rental", function(done) { + rental.customers("X-files: I want to believe", function(err, res) { + assert.equal(err, undefined); + assert(res instanceof Array); + assert.equal(res.length, 1); + + assert.equal(res[0].name, 'Mulder'); + done(); + }); + }); + + it("can find customers with overdue accounts", function(done) { + rental.overdue("The Lone Gunmen", function(err, res) { + assert.equal(err, undefined); + assert(res instanceof Array); + assert.equal(res.length, 1); + + assert.equal(res[0].name, 'Scully'); + done(); + }); + }); + + it("checks movie availability", function(done) { + rental.check_movie_availability("The Lone Gunmen", function(err, res) { + console.log(res); + assert.equal(err, undefined); + assert(res instanceof Array); + assert.equal(res.length, 1); + assert.equal(Object.keys(res[0]), "available"); + assert.equal(res[0].available, 5) + done(); + }); + }); + }); +}); diff --git a/utils/schema.js b/utils/schema.js new file mode 100644 index 0000000..2b3034b --- /dev/null +++ b/utils/schema.js @@ -0,0 +1,54 @@ +"use strict"; + +var sqlite3 = require('sqlite3').verbose(), + db_env = process.env.DB || 'development', + db = new sqlite3.Database('db/' + db_env + '.db'); + +var movie_fields = [ + ['title', 'text'], + ['overview', 'text'], + ['release_date', 'text'], + ['inventory', 'integer'], + ['available', 'integer'] +] + +var customer_fields = [ + ['name', 'text'], + ['registered_at', 'text'], + ['address', 'text'], + ['city', 'text'], + ['state', 'text'], + ['postal_code', 'text'], + ['phone', 'text'], + ['account_credit', 'real'] +] + +var rental_fields = [ + ['movie_id', 'integer'], + ['customer_id', 'integer'], + ['returned_date', 'text'], + ['due_date', 'text'], + ['checked_out', 'text'] +] + +function set_tables(table, table_fields) { + db.serialize(function() { + // drop existing tables + db.run("DROP TABLE IF EXISTS " + table + ";"); + // create fresh versions of tables + db.run("CREATE TABLE " + table + " (id INTEGER PRIMARY KEY);"); + // add columns we need to tables + for (var i = 0; i < table_fields.length; i++) { + var name = table_fields[i][0], + type = table_fields[i][1]; + + db.run("ALTER TABLE " + table + " ADD COLUMN " + name + " " + type + ";"); + } + }); +} + +set_tables(" movies ", movie_fields); +set_tables(" customers ", customer_fields); +set_tables(" rentals ", rental_fields); + +db.close(); diff --git a/utils/seed.js b/utils/seed.js new file mode 100644 index 0000000..6f2692c --- /dev/null +++ b/utils/seed.js @@ -0,0 +1,84 @@ +"use strict"; + +var sqlite3 = require('sqlite3').verbose(), + db_env = process.env.DB || 'development', + db = new sqlite3.Database('db/' + db_env + '.db'); + +var movies = require('../movies'); +var movie_statement = db.prepare( + "INSERT INTO movies(title, overview, release_date, inventory, available) \ + VALUES (?, ?, ?, ?, ?);" +); + +db.serialize(function() { + for(var i = 0; i < movies.length; i++) { + var movie = movies[i]; + + movie_statement.run( + movie.title, + movie.overview, + movie.release_date, + movie.inventory, + movie.inventory + ); + } + movie_statement.finalize(); +}); + + +var customers = require('../customers'); +var customer_statement = db.prepare( + "INSERT INTO customers(name, registered_at, address, city, state, postal_code, phone, account_credit) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?);" +); + +db.serialize(function() { + for(var i = 0; i < customers.length; i++) { + var customer = customers[i]; + + customer_statement.run( + customer.name, + customer.registered_at, + customer.address, + customer.city, + customer.state, + customer.postal_code, + customer.phone, + customer.account_credit + ); + } + customer_statement.finalize(); +}); + +var rentals = require('../rentals'); +var rental_statement = db.prepare( + "INSERT INTO rentals(movie_id, customer_id, returned_date, checked_out) \ + VALUES (?, ?, ?, ?);" +); + +db.serialize(function() { + for(var i = 0; i < rentals.length; i++) { + var rental = rentals[i]; + + rental_statement.run( + rental.movie_id, + rental.customer_id, + rental.returned_date, + rental.checked_out + ); + } + rental_statement.finalize(); +}); + + +// INSERT INTO rentals(customer_id, movie_id, returned_date, checked_out) VALUES (2, 3, 'tomorrow', 1); + +// SELECT "movies".* FROM "movies" INNER JOIN "rentals" ON "movies"."id" = "rentals"."movie_id" WHERE "rentals"."movie_id" = 3; + +//finds rental based on customer id -> SELECT "rentals".* FROM "rentals" INNER JOIN "customers" ON "customers"."id" = "rentals"."customer_id" WHERE "rentals"."customer_id" = 1; + +// returns movies checkout out by customer 1 -> SELECT "movies".* FROM "movies" INNER JOIN "rentals" ON "movies"."id" = "rentals"."movie_id" WHERE "rentals"."customer_id" = 1 AND "rentals"."returned_date" = ""; + +// + +db.close(); diff --git a/views/error.jade b/views/error.jade new file mode 100644 index 0000000..51ec12c --- /dev/null +++ b/views/error.jade @@ -0,0 +1,6 @@ +extends layout + +block content + h1= message + h2= error.status + pre #{error.stack} diff --git a/views/index.jade b/views/index.jade new file mode 100644 index 0000000..3d63b9a --- /dev/null +++ b/views/index.jade @@ -0,0 +1,5 @@ +extends layout + +block content + h1= title + p Welcome to #{title} diff --git a/views/layout.jade b/views/layout.jade new file mode 100644 index 0000000..15af079 --- /dev/null +++ b/views/layout.jade @@ -0,0 +1,7 @@ +doctype html +html + head + title= title + link(rel='stylesheet', href='/stylesheets/style.css') + body + block content