From 7e53d56a275f46d1e525ef26c6a5c0517e2a47c8 Mon Sep 17 00:00:00 2001 From: tprost Date: Thu, 22 Dec 2016 22:43:48 -0500 Subject: [PATCH 1/4] Create working implementation in Node.js. --- .bowerrc | 3 + .gitignore | 6 + .sequelizerc | 6 + app.js | 50 +++++ config/express.js | 22 ++ config/index.js | 28 +++ config/public.js | 36 ++++ controllers/expenses.js | 63 ++++++ gulpfile.js | 197 ++++++++++++++++++ karma.conf.js | 73 +++++++ middleware/parser.js | 33 +++ models/config/config.js | 1 + models/config/development.js | 11 + models/config/production.js | 10 + models/config/test.js | 11 + models/expense.js | 21 ++ models/index.js | 40 ++++ .../20161221213243-create-expense.js | 53 +++++ .../20161223005428-create-request.js | 27 +++ models/request.js | 17 ++ package.json | 53 +++++ public/css/stylesheet.css | 9 + .../js/ExpensesFormController.controller.js | 36 ++++ public/js/app.module.js | 22 ++ public/js/file-model.directive.js | 15 ++ public/templates/cats.html | 1 + public/templates/index.html | 2 + routes/index.js | 17 ++ tests/unit/parser.test.js | 26 +++ views/error.html | 13 ++ views/index.html | 72 +++++++ 31 files changed, 974 insertions(+) create mode 100644 .bowerrc create mode 100644 .gitignore create mode 100644 .sequelizerc create mode 100644 app.js create mode 100644 config/express.js create mode 100644 config/index.js create mode 100644 config/public.js create mode 100644 controllers/expenses.js create mode 100644 gulpfile.js create mode 100644 karma.conf.js create mode 100644 middleware/parser.js create mode 100644 models/config/config.js create mode 100644 models/config/development.js create mode 100644 models/config/production.js create mode 100644 models/config/test.js create mode 100644 models/expense.js create mode 100644 models/index.js create mode 100644 models/migrations/20161221213243-create-expense.js create mode 100644 models/migrations/20161223005428-create-request.js create mode 100644 models/request.js create mode 100644 package.json create mode 100644 public/css/stylesheet.css create mode 100644 public/js/ExpensesFormController.controller.js create mode 100644 public/js/app.module.js create mode 100644 public/js/file-model.directive.js create mode 100644 public/templates/cats.html create mode 100644 public/templates/index.html create mode 100644 routes/index.js create mode 100644 tests/unit/parser.test.js create mode 100644 views/error.html create mode 100644 views/index.html diff --git a/.bowerrc b/.bowerrc new file mode 100644 index 000000000..47ad66735 --- /dev/null +++ b/.bowerrc @@ -0,0 +1,3 @@ +{ + "directory": "public/lib" +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..4caf600f0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules +npm-debug.log +*.sqlite +public/lib +dist +uploads diff --git a/.sequelizerc b/.sequelizerc new file mode 100644 index 000000000..2c55f7f78 --- /dev/null +++ b/.sequelizerc @@ -0,0 +1,6 @@ +var path = require('path') + +module.exports = { + 'config': path.resolve('models', 'config', 'config.js'), + 'migrations-path': path.resolve('models', 'migrations') +} \ No newline at end of file diff --git a/app.js b/app.js new file mode 100644 index 000000000..986c7b5e4 --- /dev/null +++ b/app.js @@ -0,0 +1,50 @@ +var express = require('express'); +var path = require('path'); +var http = require('http'); +var expressNunjucks = require('express-nunjucks'); + +var app = express(); + +// some config +require('./config/express')(app); +var config = require('./config'); + +// views +app.locals.js = require('./config/public.js').js(); +const isDev = app.get('env') === 'development'; +app.set('views', __dirname + '/views'); +const njk = expressNunjucks(app, { + watch: isDev, + noCache: isDev +}); +app.set('views', path.join(__dirname, 'views')); + +// routes +var routes = require('./routes/index'); + +app.use('/', routes); + +// 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 handler +// no stacktraces leaked to user unless in development environment +app.use(function(err, req, res, next) { + res.status(err.status || 500); + res.render('error', { + message: err.message, + error: (app.get('env') === 'development') ? err : {} + }); +}); + +var server = http.createServer(app); + +server.listen(config.port, function(){ + console.log('Express server listening on %d, in %s mode', config.port, app.get('env')); +}); + +module.exports = app; diff --git a/config/express.js b/config/express.js new file mode 100644 index 000000000..3514bd363 --- /dev/null +++ b/config/express.js @@ -0,0 +1,22 @@ +'use strict'; + +var express = require('express'); +var bodyParser = require('body-parser'); +var path = require('path'); +var config = require('./index.js'); + +module.exports = function(app) { + app.use(bodyParser.json()); + + var env = app.get('env'); + + if ('production' === env || 'test' === env) { + app.use(express.static(path.join(config.root, 'dist'))); + } + + if ('development' === env) { + app.use(express.static(path.join(config.root, 'dist'))); + app.use('/public', express.static(path.join(config.root, 'public'))); + } + +}; diff --git a/config/index.js b/config/index.js new file mode 100644 index 000000000..72d1c4e37 --- /dev/null +++ b/config/index.js @@ -0,0 +1,28 @@ +'use strict'; + +var path = require('path'); +var _ = require('lodash'); + +function requiredProcessEnv(name) { + if(!process.env[name]) { + throw new Error('You must set the ' + name + ' environment variable'); + } + return process.env[name]; +} + +var all = { + env: process.env.NODE_ENV, + + root: path.normalize(__dirname + '/..'), + + port: process.env.PORT || 3000 + +}; + +// Export the config object based on the NODE_ENV +// ============================================== +// module.exports = _.merge( +// all, +// require('./' + process.env.NODE_ENV + '.js') || {}); + +module.exports = all; diff --git a/config/public.js b/config/public.js new file mode 100644 index 000000000..f4a790399 --- /dev/null +++ b/config/public.js @@ -0,0 +1,36 @@ +var config = require('./index.js'); +var _ = require('lodash'); +var glob = require('glob'); +var path = require('path'); + +var assets = { + lib: { + "angular": "lib/angular/angular.js", + "angular-route": "lib/angular-route/angular-route.js" + }, + app: { + "modules": "js/**/*.module.js", + "components": "js/**/!(*.spec|*.mock).js" + }, + tests: { + "mocking": "lib/angular-mocks/angular-mocks.js", + "tests": "js/**/*.spec.js" + } +}; + +module.exports = { + globs: function() { + return assets; + }, + js: function() { + var files = []; + _.each([assets.lib, assets.app], function(value, key) { + _.forIn(value, function(value, key) { + var globToGlob = path.join('public', value); + var filesToAdd = glob.sync(globToGlob); + files = files.concat(filesToAdd); + }); + }); + return _.uniq(files); + } +}; diff --git a/controllers/expenses.js b/controllers/expenses.js new file mode 100644 index 000000000..2f8f5a849 --- /dev/null +++ b/controllers/expenses.js @@ -0,0 +1,63 @@ +var models = require('../models'); +var Expense = models.Expense; +var Request = models.Request; + +var parser = require('../middleware/parser.js'); + +var Q = require('q'); +var fs = require('fs'); +var _ = require('lodash'); + +function fileToString(file) { + var deferred = Q.defer(); + fs.readFile(file.path, function(error, data) { + if (error) { + deferred.reject(new Error(error)); + } + deferred.resolve(data.toString()); + }); + return deferred.promise; +}; + +function addExpenses(expenses) { + return Request.create().then(function(request) { + _.each(expenses, function(expense) { + expense.request_id = request.dataValues.id; + }); + return Expense.bulkCreate(expenses).then(function() { + return Expense.findAll({ + where: { + request_id: request.dataValues.id + } + }); + }); + }); +}; + +module.exports = { + addExpenses: function(req, res) { + if (req.file) { + fileToString(req.file) + .then(function(str) { + return parser.parse(str); + }) + .then(function(expenses) { + return addExpenses(expenses); + }) + .then(function(expenses) { + res.json({ + expenses: expenses + }); + }) + .catch(function(error) { + res.status(400).json({ + error: error.toString() + }); + }); + } else { + res.status(400).json({ + error: "No CSV file was provided." + }); + } + } +}; diff --git a/gulpfile.js b/gulpfile.js new file mode 100644 index 000000000..220be910e --- /dev/null +++ b/gulpfile.js @@ -0,0 +1,197 @@ +'use strict'; + +var path = require('path'); +var gulp = require('gulp'); +var browserSync = require('browser-sync'); +var nodemon = require('gulp-nodemon'); +var browserSyncSpa = require('browser-sync-spa'); +var util = require('util'); +var server = require( 'gulp-develop-server' ); +var gutil = require('gulp-util'); + +var BROWSER_SYNC_RELOAD_DELAY = 1000; + +var conf = { + paths: { + src: 'public', + dist: 'dist', + tmp: '.tmp', + e2e: 'e2e' + }, + wiredep: { + exclude: [/jquery/, /bootstrap.js$/], + directory: 'public/bower_components' + }, + errorHandler: function(title) { + 'use strict'; + return function(err) { + gutil.log(gutil.colors.red('[' + title + ']'), err.toString()); + this.emit('end'); + }; + } +}; + +browserSync.use(browserSyncSpa({ + selector: '[ng-app]' +})); + +function isOnlyChange(event) { + return event.type === 'changed'; +} + +gulp.task('nodemon', function(cb) { + var called = false; + return nodemon({ + script: 'app.js', + ignore: [ + 'gulpfile.js', + 'node_modules/' + ], + env: { + 'NODE_ENV': 'development' + } + }).on('start', function onStart() { + if (!called) { + called = true; + cb(); + } + }).on('restart', function onRestart() { + setTimeout(function() { + browserSync.reload({ + stream: false + }); + }, BROWSER_SYNC_RELOAD_DELAY); + }); +}); + +gulp.task('browser-sync', ['nodemon'], function () { + browserSync({ + proxy: 'localhost:3000', + port: 4000, + browser: ['google-chrome'], + notify: true + }); +}); + +gulp.task('test-server', function() { + server.listen({ + path: './app.js', + env: { + NODE_ENV: 'test', + SEED: 'seed' + } + }); +}); + +gulp.task('prod-test-server', function() { + server.listen({ + path: './app.js', + env: { + NODE_ENV: 'production', + SEED: 'seed' + } + }); +}); + +gulp.task('serve', ['watch'], function () { + gulp.start('browser-sync'); +}); + +gulp.task('serve:dist', ['build'], function () { + gulp.start('browser-sync'); +}); + +gulp.task('serve:e2e', ['inject'], function () { + gulp.start('test-server'); +}); + +gulp.task('serve:e2e-dist', ['build'], function () { + gulp.start('prod-test-server'); +}); + +gulp.task('watch', ['inject'], function () { + + // gulp.watch([path.join(conf.paths.src, '/*.html'), 'bower.json'], ['inject']); + + gulp.watch(path.join(conf.paths.src, '/css/**/*.css'), function(event) { + + gulp.start('inject'); + if(isOnlyChange(event)) { + browserSync.reload(event.path); + } + + + }); + + gulp.watch(path.join(conf.paths.src, '**/*.js'), function(event) { + if(isOnlyChange(event)) { + // gulp.start('scripts'); + } else { + gulp.start('inject'); + } + }); + + gulp.watch(path.join(conf.paths.src, '/templates/**/*.html'), function(event) { + gulp.start('inject'); + browserSync.reload(event.path); + }); +}); + +gulp.task('default', function() { + +}); + +// gulp.task('inject', function(){}); +// // gulp.task('default', ['clean'], function () { +// // gulp.start('build'); +// // }); + +// +gulp.task('inject', [], function () { + + gulp.src(path.join(conf.paths.src, '/css/**/*.css')) + .pipe(gulp.dest(path.join(conf.paths.dist, '/css'))); + + gulp.src(path.join(conf.paths.src, '/templates/**/*.html')) + .pipe(gulp.dest(path.join(conf.paths.dist, '/templates'))); + + // gulp.src(path.join(conf.paths.src, '/js/**/*.js')) + // .pipe(gulp.dest(path.join(conf.paths.dist, '/js'))); + + // var injectStyles = gulp.src([ + // path.join(conf.paths.src, '/assets/css/*.css') + // ], { read: false }); + + // var injectScripts = gulp.src([ + // path.join(conf.paths.src, '/app/**/*.module.js'), + // path.join(conf.paths.src, '/app/**/*.js'), + // path.join('!' + conf.paths.src, '/app/**/*.spec.js'), + // path.join('!' + conf.paths.src, '/app/**/*.mock.js') + // ]) + // .pipe($.angularFilesort()).on('error', conf.errorHandler('AngularFilesort')); + + // var injectOptions = { + // ignorePath: [conf.paths.src, path.join(conf.paths.tmp, '/serve')], + // addRootSlash: false + // }; + + // return gulp.src(path.join(conf.paths.src, '/*.html')) + // .pipe($.inject(injectStyles, injectOptions)) + // .pipe($.inject(injectScripts, injectOptions)) + // .pipe(wiredep(_.extend({}, conf.wiredep))) + // .pipe(gulp.dest(path.join(conf.paths.tmp, '/serve'))); +}); + +gulp.task('tests:unit:backend', function() { + const mocha = require('gulp-mocha'); + gulp.src('tests/unit/**/*.js', {read: false}) + .pipe(mocha({reporter: 'nyan'})); +}); + +gulp.task('tests:unit:frontend', function(done) { + var Server = require('karma').Server; + new Server({ + configFile: __dirname + '/karma.conf.js', + singleRun: true + }, done).start(); +}); diff --git a/karma.conf.js b/karma.conf.js new file mode 100644 index 000000000..08d248a71 --- /dev/null +++ b/karma.conf.js @@ -0,0 +1,73 @@ +// Karma configuration +// Generated on Sat Dec 17 2016 22:18:11 GMT-0500 (EST) + +var globs = require('./config/public.js').globs(); +var _ = require('lodash'); +var files = _.union( + _.toArray(globs.lib), + _.toArray(globs.app), + _.toArray(globs.tests)); + +module.exports = function(config) { + config.set({ + + // base path that will be used to resolve all patterns (eg. files, exclude) + basePath: 'public', + + + // frameworks to use + // available frameworks: https://npmjs.org/browse/keyword/karma-adapter + frameworks: ['jasmine'], + + // list of files / patterns to load in the browser + files: files, + + + // list of files to exclude + exclude: [ + ], + + + // preprocess matching files before serving them to the browser + // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor + preprocessors: { + }, + + + // test results reporter to use + // possible values: 'dots', 'progress' + // available reporters: https://npmjs.org/browse/keyword/karma-reporter + reporters: ['progress'], + + + // web server port + port: 9876, + + + // enable / disable colors in the output (reporters and logs) + colors: true, + + + // level of logging + // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG + logLevel: config.LOG_INFO, + + + // enable / disable watching file and executing tests whenever any file changes + autoWatch: true, + + + // start these browsers + // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher + browsers: ['Chrome'], + + + // Continuous Integration mode + // if true, Karma captures browsers, runs the tests and exits + singleRun: false, + + // Concurrency level + // how many browser should be started simultaneous + concurrency: Infinity + }); +} diff --git a/middleware/parser.js b/middleware/parser.js new file mode 100644 index 000000000..d9fab99c9 --- /dev/null +++ b/middleware/parser.js @@ -0,0 +1,33 @@ +'use strict'; + +var csvParse = require('csv-parse'); +var Q = require('q'); + +// TODO more error checking +function parse(csv) { + var deferred = Q.defer(); + csvParse(csv, { + trim: true, + comment: '#', + columns: function() { + return ['date', + 'category', + 'employee_name', + 'employee_address', + 'expense_description', + 'pretax_amount', + 'tax_name', + 'tax_amount']; + } + }, function(error, output) { + if (error) { + deferred.reject(new Error(error)); + } + deferred.resolve(output); + }); + return deferred.promise; +}; + +module.exports = { + parse: parse +}; diff --git a/models/config/config.js b/models/config/config.js new file mode 100644 index 000000000..946400840 --- /dev/null +++ b/models/config/config.js @@ -0,0 +1 @@ +module.exports = require('./development.js'); diff --git a/models/config/development.js b/models/config/development.js new file mode 100644 index 000000000..111ac605a --- /dev/null +++ b/models/config/development.js @@ -0,0 +1,11 @@ +'use strict'; + +module.exports = { + database: process.env.DBNAME || 'sean-boilerplate-development', + storage: "./db.development.sqlite", + username : process.env.DBUSER, + password : process.env.DBPASSWORD, + host : process.env.DBHOST || 'localhost', + dialect: "sqlite", + port : 5433 +}; diff --git a/models/config/production.js b/models/config/production.js new file mode 100644 index 000000000..f6c3bd282 --- /dev/null +++ b/models/config/production.js @@ -0,0 +1,10 @@ +'use strict'; + +module.exports = { + database: process.env.DBNAME, + username : process.env.DBUSER, + password : process.env.DBPASSWORD, + host : process.env.DBHOST, + dialect: "postgres", + port : 5432 +}; diff --git a/models/config/test.js b/models/config/test.js new file mode 100644 index 000000000..a06f59938 --- /dev/null +++ b/models/config/test.js @@ -0,0 +1,11 @@ +'use strict'; + +module.exports = { + database: process.env.DBNAME || 'sean-boilerplate-test', + storage: "./db.test.sqlite", + username : process.env.DBUSER || 'tprost', + password : process.env.DBPASSWORD || 'qweqwe', + host : process.env.DBHOST || 'localhost', + dialect: "sqlite", + port : 5433 +}; diff --git a/models/expense.js b/models/expense.js new file mode 100644 index 000000000..642ce8cd1 --- /dev/null +++ b/models/expense.js @@ -0,0 +1,21 @@ +'use strict'; + +module.exports = function(sequelize, DataTypes) { + var Expense = sequelize.define('Expense', { + date: DataTypes.DATE, + category: DataTypes.STRING, + employee_name: DataTypes.STRING, + employee_address: DataTypes.STRING, + expense_description: DataTypes.STRING, + pretax_amount: DataTypes.DECIMAL, + tax_name: DataTypes.STRING, + tax_amount: DataTypes.DECIMAL + }, { + classMethods: { + associate: function(models) { + // associations can be defined here + } + } + }); + return Expense; +}; diff --git a/models/index.js b/models/index.js new file mode 100644 index 000000000..4bce0e113 --- /dev/null +++ b/models/index.js @@ -0,0 +1,40 @@ +"use strict"; + +var fs = require("fs"); +var path = require("path"); +var Sequelize = require("sequelize"); + +var config = require("./config/config.js"); + + +if (process.env.DATABASE_URL) { + console.log(process.env.DATABASE_URL); + var sequelize = new Sequelize(process.env.DATABASE_URL); +} else { + var sequelize = new Sequelize(config.database, config.username, config.password, config); +} + +var db = {}; + +fs.readdirSync(__dirname) + .filter(function(file) { + return (file.indexOf(".") !== 0) + && (file !== "index.js") + && (file !== "config") + && (file !== "migrations"); + }) + .forEach(function(file) { + var model = sequelize.import(path.join(__dirname, file)); + db[model.name] = model; + }); + +Object.keys(db).forEach(function(modelName) { + if ("associate" in db[modelName]) { + db[modelName].associate(db); + } +}); + +db.sequelize = sequelize; +db.Sequelize = Sequelize; + +module.exports = db; diff --git a/models/migrations/20161221213243-create-expense.js b/models/migrations/20161221213243-create-expense.js new file mode 100644 index 000000000..7c88d84f3 --- /dev/null +++ b/models/migrations/20161221213243-create-expense.js @@ -0,0 +1,53 @@ +'use strict'; + +module.exports = { + up: function(queryInterface, Sequelize) { + return queryInterface.createTable('Expenses', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER + }, + request_id: { + type: Sequelize.INTEGER + }, + date: { + allowNull: false, + type: Sequelize.DATE + }, + category: { + type: Sequelize.STRING + }, + employee_name: { + type: Sequelize.STRING + }, + employee_address: { + type: Sequelize.STRING + }, + expense_description: { + type: Sequelize.STRING + }, + pretax_amount: { + type: Sequelize.DECIMAL + }, + tax_name: { + type: Sequelize.STRING + }, + tax_amount: { + type: Sequelize.DECIMAL + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + }, + down: function(queryInterface, Sequelize) { + return queryInterface.dropTable('Expenses'); + } +}; diff --git a/models/migrations/20161223005428-create-request.js b/models/migrations/20161223005428-create-request.js new file mode 100644 index 000000000..c5bd436ad --- /dev/null +++ b/models/migrations/20161223005428-create-request.js @@ -0,0 +1,27 @@ +'use strict'; +module.exports = { + up: function(queryInterface, Sequelize) { + return queryInterface.createTable('Requests', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER + }, + date: { + type: Sequelize.DATE + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + }, + down: function(queryInterface, Sequelize) { + return queryInterface.dropTable('Requests'); + } +}; \ No newline at end of file diff --git a/models/request.js b/models/request.js new file mode 100644 index 000000000..618a11d1f --- /dev/null +++ b/models/request.js @@ -0,0 +1,17 @@ +'use strict'; +module.exports = function(sequelize, DataTypes) { + var Request = sequelize.define('Request', { + date: DataTypes.DATE + }, { + classMethods: { + associate: function(models) { + // associations can be defined here + models.Request.hasMany(models.Expense, { + foreignKey: 'request_id' + }); + } + } + }); + + return Request; +}; diff --git a/package.json b/package.json new file mode 100644 index 000000000..72a3ae15c --- /dev/null +++ b/package.json @@ -0,0 +1,53 @@ +{ + "name": "se-challenge-expenses", + "version": "0.0.0", + "private": true, + "scripts": { + "postinstall": "npm run migrate", + "start": "node app.js", + "test": "npm run test:unit", + "migrate": "sequelize db:migrate", + "test:unit": "mocha tests/unit/*.test.js", + "test:client": "karma start --single-run", + "test:client:watch": "karma start" + }, + "dependencies": { + "async": "^2.1.4", + "body-parser": "^1.15.2", + "bower": "^1.8.0", + "cookie-parser": "^1.4.3", + "csv-parse": "^1.1.7", + "debug": "~2.2.0", + "express": "^4.14.0", + "express-nunjucks": "^2.1.2", + "glob": "^7.1.1", + "morgan": "^1.7.0", + "multer": "^1.2.1", + "nightwatch": "^0.9.11", + "nunjucks": "^3.0.0", + "pg": "^6.1.0", + "promised-csv": "^1.0.1", + "q": "^1.4.1", + "sequelize": "^3.23.6", + "sequelize-cli": "^2.4.0", + "serve-favicon": "~2.3.0", + "sqlite3": "^3.1.8" + }, + "devDependencies": { + "browser-sync": "^2.18.5", + "browser-sync-spa": "^1.0.3", + "chai": "^3.5.0", + "gulp": "^3.9.1", + "gulp-develop-server": "^0.5.2", + "gulp-mocha": "^3.0.1", + "gulp-nodemon": "^2.2.1", + "jasmine": "^2.5.2", + "karma": "^1.3.0", + "karma-chrome-launcher": "^2.0.0", + "karma-jasmine": "^1.1.0", + "karma-phantomjs-launcher": "^1.0.2", + "lodash": "^4.17.2", + "mocha": "^3.2.0", + "util": "^0.10.3" + } +} diff --git a/public/css/stylesheet.css b/public/css/stylesheet.css new file mode 100644 index 000000000..c5e8f9650 --- /dev/null +++ b/public/css/stylesheet.css @@ -0,0 +1,9 @@ +body { + background: white; + overflow-y: scroll; + padding-top: 5rem; +} + +.ui.container { + // max-width: 35rem !important; +} diff --git a/public/js/ExpensesFormController.controller.js b/public/js/ExpensesFormController.controller.js new file mode 100644 index 000000000..4270c33ab --- /dev/null +++ b/public/js/ExpensesFormController.controller.js @@ -0,0 +1,36 @@ +angular.module('app').controller('ExpensesFormController', function($http, $scope) { + + $scope.data = {}; + + $scope.error = null; + $scope.submitted = false; + + this.submit = function() { + + var data = new FormData(); + data.append('csv_file', $scope.data.file); + return $http({ + method: 'POST', + url: '/api/expenses', + data: data, + transformRequest: angular.identity, + headers: { + 'Content-Type': undefined + } + }).then(function(res) { + $scope.expenses = res.data.expenses; + angular.forEach($scope.expenses, function(expense) { + expense.date = new Date(expense.date); + }); + return $scope.expenses; + }).then(function(expenses) { + $scope.submitted = true; + $scope.error = null; + }, function(error) { + $scope.error = error.data.error; + }).finally(function() { + + }); + }; + +}); diff --git a/public/js/app.module.js b/public/js/app.module.js new file mode 100644 index 000000000..9aa14a607 --- /dev/null +++ b/public/js/app.module.js @@ -0,0 +1,22 @@ +angular.module('app', ['ngRoute']); + + +angular.module('app').config(function($routeProvider, $locationProvider) { + + $routeProvider + .when('/', { + templateUrl: '/templates/index.html', + controller: 'TestController' + }) + .when('/cats', { + templateUrl: '/templates/cats.html', + controller: 'TestController' + }); + + // configure html5 to get links working on jsfiddle + $locationProvider.html5Mode({ + enabled: true, + requireBase: false + }); + +}); diff --git a/public/js/file-model.directive.js b/public/js/file-model.directive.js new file mode 100644 index 000000000..c922a6146 --- /dev/null +++ b/public/js/file-model.directive.js @@ -0,0 +1,15 @@ +angular.module('app').directive('fileModel', ['$parse', function ($parse) { + return { + restrict: 'A', + link: function(scope, element, attrs) { + var model = $parse(attrs.fileModel); + var modelSetter = model.assign; + + element.bind('change', function(){ + scope.$apply(function(){ + modelSetter(scope, element[0].files[0]); + }); + }); + } + }; +}]); diff --git a/public/templates/cats.html b/public/templates/cats.html new file mode 100644 index 000000000..01f251a79 --- /dev/null +++ b/public/templates/cats.html @@ -0,0 +1 @@ +

cats cats cats

diff --git a/public/templates/index.html b/public/templates/index.html new file mode 100644 index 000000000..653e7d22b --- /dev/null +++ b/public/templates/index.html @@ -0,0 +1,2 @@ +

index template

+Cats diff --git a/routes/index.js b/routes/index.js new file mode 100644 index 000000000..fc68f0377 --- /dev/null +++ b/routes/index.js @@ -0,0 +1,17 @@ +var express = require('express'); +var multer = require('multer'); +var upload = multer({ dest: 'uploads/' }); +var expensesController = require('../controllers/expenses.js'); + + +var router = express.Router(); + +router.get('/', function(req, res) { + res.render('index.html'); +}); + +router.post('/api/expenses', + upload.single('csv_file'), + expensesController.addExpenses); + +module.exports = router; diff --git a/tests/unit/parser.test.js b/tests/unit/parser.test.js new file mode 100644 index 000000000..b7d703832 --- /dev/null +++ b/tests/unit/parser.test.js @@ -0,0 +1,26 @@ +'use strict'; + +// var expect = require('expect.js'); +var expect = require('chai').expect; + +describe('middleware/parser', function() { + it('parses csv', function () { + var parser = require('../../middleware/parser.js'); + return parser + .parse('1,2,3,4,5,6,7,8\n12/1/2013,Travel,Don Draper,"783 Park Ave, New York, NY 10021",Taxi ride, 350.00 ,NY Sales tax, 31.06') + .then(function(value) { + + expect(value[0]).to.deep.equal({ + date: '12/1/2013', + category: 'Travel', + employee_name: 'Don Draper', + employee_address: '783 Park Ave, New York, NY 10021', + expense_description: 'Taxi ride', + pretax_amount: '350.00', + tax_name: 'NY Sales tax', + tax_amount: '31.06' + }); + + }); + }); +}); diff --git a/views/error.html b/views/error.html new file mode 100644 index 000000000..afecab6a6 --- /dev/null +++ b/views/error.html @@ -0,0 +1,13 @@ + + + + + sean-boilerplate + + + + + +

an error occurred

+ + diff --git a/views/index.html b/views/index.html new file mode 100644 index 000000000..e536a7c3b --- /dev/null +++ b/views/index.html @@ -0,0 +1,72 @@ + + + + + Wave Expenses Challenge + + + + + + +
+

se-challenge-expenses

+
+

Hello! Please provide your CSV file in the form below.

+
+ + +
+

Thanks! The data you submitted is in the table below.

+ + + + + + + + + + + + + + + + + + + + + + + +
+ Date + + Category + + Employee Name + + Employee Address + + Expense Description + + Pre-tax Amount + + Tax Name + + Tax Amount +
+
+

An error occurred.

+

+
+ +
+
+ {% for path in js %} + + {% endfor %} + + From fe51cd5576fd4fd8af051c91a7617759a3d51ad9 Mon Sep 17 00:00:00 2001 From: tprost Date: Thu, 22 Dec 2016 22:56:17 -0500 Subject: [PATCH 2/4] Update README.markdown --- README.markdown | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.markdown b/README.markdown index f93d526ae..ceabf6d6a 100644 --- a/README.markdown +++ b/README.markdown @@ -57,3 +57,16 @@ Evaluation of your submission will be based on the following criteria. 1. What design decisions did you make when designing your models/entities? Why (i.e. were they explained?) 1. Did you separate any concerns in your application? Why or why not? 1. Does your solution use appropriate datatypes for the problem as described? + +# Instructions on how to build/run this application + +1. Install Node.js +2. Install sqlite3 +3. Run `npm install` in the root of this project +4. Run `npm start` and visit `locahost:3000` + +# What I'm proud of + +I like the structure of the project because it is relatively organized and is broken down into individual working pieces, which could be unit tested. + +I also liked that I used client-side JavaScript to submit the CSV file, as opposed to a vanilla HTML5 form, because it keeps the API decoupled from the frontend. From 9f9f7f46566ae9580e511a0232b7020178988127 Mon Sep 17 00:00:00 2001 From: tprost Date: Thu, 22 Dec 2016 22:57:02 -0500 Subject: [PATCH 3/4] Add a few comments. --- controllers/expenses.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/controllers/expenses.js b/controllers/expenses.js index 2f8f5a849..4673eb586 100644 --- a/controllers/expenses.js +++ b/controllers/expenses.js @@ -19,6 +19,8 @@ function fileToString(file) { return deferred.promise; }; +// given an array of expenses, create a Request +// and then create and save Expense objects function addExpenses(expenses) { return Request.create().then(function(request) { _.each(expenses, function(expense) { @@ -35,21 +37,28 @@ function addExpenses(expenses) { }; module.exports = { + + addExpenses: function(req, res) { if (req.file) { + // convert the submitted file to a string fileToString(req.file) .then(function(str) { + // parse the string to array of expense objects return parser.parse(str); }) .then(function(expenses) { + // save the expense objects return addExpenses(expenses); }) .then(function(expenses) { + // return expenses on success res.json({ expenses: expenses }); }) .catch(function(error) { + // if there are any errors provide an error message res.status(400).json({ error: error.toString() }); From ec650534de19cb8a512e603312e8f96c2a76a7fd Mon Sep 17 00:00:00 2001 From: tprost Date: Fri, 23 Dec 2016 00:39:44 -0500 Subject: [PATCH 4/4] Make table display monthly totals. --- controllers/expenses.js | 13 +++-- middleware/monthifier.js | 47 +++++++++++++++++++ middleware/parser.js | 31 +++++++++++- models/expense.js | 4 +- .../20161221213243-create-expense.js | 4 +- .../js/ExpensesFormController.controller.js | 5 +- views/index.html | 34 ++------------ 7 files changed, 98 insertions(+), 40 deletions(-) create mode 100644 middleware/monthifier.js diff --git a/controllers/expenses.js b/controllers/expenses.js index 4673eb586..2c673fcc5 100644 --- a/controllers/expenses.js +++ b/controllers/expenses.js @@ -3,6 +3,7 @@ var Expense = models.Expense; var Request = models.Request; var parser = require('../middleware/parser.js'); +var monthify = require('../middleware/monthifier.js').monthify; var Q = require('q'); var fs = require('fs'); @@ -52,11 +53,17 @@ module.exports = { return addExpenses(expenses); }) .then(function(expenses) { - // return expenses on success - res.json({ - expenses: expenses + return monthify(expenses).then(function(months) { + return { + expenses: expenses, + months: months + }; }); }) + .then(function(data) { + // return expenses on success + res.json(data); + }) .catch(function(error) { // if there are any errors provide an error message res.status(400).json({ diff --git a/middleware/monthifier.js b/middleware/monthifier.js new file mode 100644 index 000000000..e48de20c1 --- /dev/null +++ b/middleware/monthifier.js @@ -0,0 +1,47 @@ + +var Q = require('q'); +var async = require('async'); +var _ = require('lodash'); + +// given a list of Expenses, return an array +// of objects that represent each month in which +// there is one or more given Expenses, and for +// each month, provide a total +function monthify(expenses) { + + var deferred = Q.defer(); + + var months = {}; + + async.eachOfSeries(expenses, function(expense, key, callback) { + var date = expense.dataValues.date; + var month = date.getYear() + '-' + date.getMonth(); + var amount = + Number(expense.dataValues.pretax_amount) + + Number(expense.dataValues.tax_amount); + if (months[month]) { + months[month].total += amount; + if (months[month].date.getTime() > date.getTime()) { + months[month].date = date; + } + } else { + months[month] = { + total: amount, + date: date + }; + } + callback(); + }, function(error) { + if (error) { + deferred.reject(new Error(error)); + } + deferred.resolve(_.values(months)); + }); + + return deferred.promise; + +}; + +module.exports = { + monthify: monthify +}; diff --git a/middleware/parser.js b/middleware/parser.js index d9fab99c9..377330c82 100644 --- a/middleware/parser.js +++ b/middleware/parser.js @@ -2,11 +2,12 @@ var csvParse = require('csv-parse'); var Q = require('q'); +var async = require('async'); -// TODO more error checking -function parse(csv) { +function objectify(csv) { var deferred = Q.defer(); csvParse(csv, { + auto_parse: true, trim: true, comment: '#', columns: function() { @@ -28,6 +29,32 @@ function parse(csv) { return deferred.promise; }; +function numberify(expenses) { + var deferred = Q.defer(); + async.eachOfSeries(expenses, function(expense, key, callback) { + expense.pretax_amount = Number.parseFloat(expense.pretax_amount); + expense.tax_amount = Number.parseFloat(expense.tax_amount); + if (Number.isNaN(expense.pretax_amount)) { + callback(new Error("pretax amount is not a number")); + } else if (Number.isNaN(expense.tax_amount)) { + callback(new Error("tax amount is not a number")); + } else { + callback(); + } + }, function(error) { + if (error) { + deferred.reject(new Error(error)); + } + deferred.resolve(expenses); + }); + return deferred.promise; +}; + +// TODO more error checking +function parse(csv) { + return objectify(csv).then(numberify); +}; + module.exports = { parse: parse }; diff --git a/models/expense.js b/models/expense.js index 642ce8cd1..158d80dc7 100644 --- a/models/expense.js +++ b/models/expense.js @@ -7,9 +7,9 @@ module.exports = function(sequelize, DataTypes) { employee_name: DataTypes.STRING, employee_address: DataTypes.STRING, expense_description: DataTypes.STRING, - pretax_amount: DataTypes.DECIMAL, + pretax_amount: DataTypes.DOUBLE, tax_name: DataTypes.STRING, - tax_amount: DataTypes.DECIMAL + tax_amount: DataTypes.DOUBLE }, { classMethods: { associate: function(models) { diff --git a/models/migrations/20161221213243-create-expense.js b/models/migrations/20161221213243-create-expense.js index 7c88d84f3..28a61a7b3 100644 --- a/models/migrations/20161221213243-create-expense.js +++ b/models/migrations/20161221213243-create-expense.js @@ -29,13 +29,13 @@ module.exports = { type: Sequelize.STRING }, pretax_amount: { - type: Sequelize.DECIMAL + type: Sequelize.DOUBLE }, tax_name: { type: Sequelize.STRING }, tax_amount: { - type: Sequelize.DECIMAL + type: Sequelize.DOUBLE }, createdAt: { allowNull: false, diff --git a/public/js/ExpensesFormController.controller.js b/public/js/ExpensesFormController.controller.js index 4270c33ab..7dd01a675 100644 --- a/public/js/ExpensesFormController.controller.js +++ b/public/js/ExpensesFormController.controller.js @@ -19,8 +19,9 @@ angular.module('app').controller('ExpensesFormController', function($http, $scop } }).then(function(res) { $scope.expenses = res.data.expenses; - angular.forEach($scope.expenses, function(expense) { - expense.date = new Date(expense.date); + $scope.months = res.data.months; + angular.forEach($scope.months, function(month) { + month.date = new Date(month.date); }); return $scope.expenses; }).then(function(expenses) { diff --git a/views/index.html b/views/index.html index e536a7c3b..445342dfb 100644 --- a/views/index.html +++ b/views/index.html @@ -22,40 +22,16 @@

se-challenge-expenses

- Date + Month - Category - - - Employee Name - - - Employee Address - - - Expense Description - - - Pre-tax Amount - - - Tax Name - - - Tax Amount + Total Expenses (incl. tax) - - - - - - - - - + + +