├── log └── .gitkeep ├── .DS_Store ├── .travis.yml ├── app ├── event_listeners │ └── index.js ├── api │ ├── codes.js │ ├── controllers │ │ ├── actions │ │ │ ├── reminder_all.js │ │ │ ├── reminder_user.js │ │ │ ├── notify_all.js │ │ │ ├── notify_user.js │ │ │ ├── timer.js │ │ │ └── notify_management.js │ │ └── index.js │ └── index.js └── services │ ├── cronjobs │ ├── index.js │ └── lib │ │ └── jobs.js │ ├── auth │ ├── index.js │ └── lib │ │ ├── error.js │ │ ├── handlers.js │ │ └── auth.js │ ├── interactive_session │ └── lib │ │ ├── step_tools.js │ │ ├── resolver.js │ │ ├── time_parser.js │ │ └── user_session.js │ ├── notifier │ └── index.js │ ├── slack │ ├── index.js │ └── notifier │ │ ├── remind.js │ │ └── index.js │ ├── reminder │ └── index.js │ ├── report │ ├── lib │ │ └── view_builder.js │ └── index.js │ ├── logger.js │ ├── tools.js │ ├── timer.js │ └── harvest │ └── index.js ├── .gitignore ├── Berksfile ├── consts.json ├── config └── index.js ├── test ├── config.json ├── unit │ └── services │ │ ├── auth │ │ └── lib │ │ │ ├── error.js │ │ │ ├── auth.js │ │ │ └── handlers.js │ │ ├── slack │ │ └── index.js │ │ ├── interactive_session │ │ └── lib │ │ │ ├── time_parser.js │ │ │ └── user_session.js │ │ ├── reminder │ │ └── index.js │ │ ├── timer.js │ │ ├── tools.js │ │ └── harvest │ │ └── index.js ├── functional │ ├── notifications.js │ └── app.js └── mock │ └── harvest.js ├── LICENSE ├── package.json ├── app.js ├── config.dist.json ├── Vagrantfile └── README.md /log/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Neverbland/slack-harvest/HEAD/.DS_Store -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.12" 4 | - "0.11" 5 | - "0.10" 6 | -------------------------------------------------------------------------------- /app/event_listeners/index.js: -------------------------------------------------------------------------------- 1 | /*jshint node: true*/ 2 | 'use strict'; 3 | 4 | /** 5 | * Event listeners module 6 | * 7 | * @author Maciej Garycki 8 | * @param {Object} config The application config 9 | */ 10 | module.exports = function (app) { 11 | 12 | }; -------------------------------------------------------------------------------- /app/api/codes.js: -------------------------------------------------------------------------------- 1 | /*jshint node: true*/ 2 | 'use strict'; 3 | 4 | /** 5 | * HTTP Response codes consts 6 | * 7 | * @author Maciej Garycki 8 | */ 9 | module.exports = { 10 | OK : 200, 11 | UNAUTHORIZED : 401, 12 | BAD_REQUEST : 400, 13 | NOT_FOUND : 404 14 | }; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .vagrant 3 | Berksfile.lock 4 | nbproject 5 | config.json 6 | !test/config.json 7 | .idea 8 | docs/ 9 | 10 | # sessions 11 | .sessions 12 | 13 | .cookbooks 14 | build/chef/nodes 15 | 16 | # common ignores 17 | node_modules 18 | log/* 19 | !log/.gitkeep 20 | npm-debug.log 21 | 22 | # generated test coverage 23 | coverage.html 24 | -------------------------------------------------------------------------------- /Berksfile: -------------------------------------------------------------------------------- 1 | source 'https://api.berkshelf.com' 2 | 3 | cookbook 'build-essential', '~> 2.0.4' 4 | cookbook 'apt', '~> 2.4.0' 5 | cookbook 'git', '~> 4.0.2' 6 | cookbook 'nodejs', '~> 2.0.0' 7 | cookbook 'nginx', '~> 2.7.4' 8 | cookbook 'mysql', '= 5.5.3' 9 | cookbook 'user', '~> 0.3.0' 10 | cookbook 'openssh', '~> 1.3.4' 11 | cookbook 'slack-harvest-middleman', path: './build/chef/cookbooks/slack-harvest-middleman' -------------------------------------------------------------------------------- /consts.json: -------------------------------------------------------------------------------- 1 | { 2 | "preload" : { 3 | "CRON_TIME" : "00 00 7-20 * * 1-5" 4 | }, 5 | "report" : { 6 | "CRON_TIME" : "00 00 20 * * 5", 7 | "DEFAULT_REPORT_TITLE" : "Weekly activity report", 8 | "DATE_FROM_TEXT" : "last monday", 9 | "DATE_TO_TEXT" : "+ 0 hours" 10 | }, 11 | "remind" : { 12 | "CRON_TIME" : "00 00 12 * * 1-5" 13 | }, 14 | "userSession" : { 15 | "sessionTime" : 120000 16 | } 17 | } -------------------------------------------------------------------------------- /app/services/cronjobs/index.js: -------------------------------------------------------------------------------- 1 | /*jshint node: true*/ 2 | 'use strict'; 3 | 4 | var cron = require('cron'), 5 | notifier = require('./../notifier'), 6 | harvest = require('./../harvest')('default'), 7 | _ = require('lodash'), 8 | tools = require('./../tools.js'), 9 | logger = require('./../logger.js')('default'); 10 | 11 | 12 | 13 | module.exports = function (app, config) { 14 | 15 | var cronJobs = require('./lib/jobs.js')(config); 16 | cronJobs.run(); 17 | }; -------------------------------------------------------------------------------- /config/index.js: -------------------------------------------------------------------------------- 1 | /*jshint node: true*/ 2 | 'use strict'; 3 | 4 | var ConfigResolver = new function () 5 | { 6 | var env = process.env.env || 'live'; 7 | 8 | this.getConfig = function () 9 | { 10 | var config; 11 | if (env === 'test') { 12 | config = require('./../test/config.json'); 13 | } else { 14 | try { 15 | config = require('./../config.json'); 16 | } catch (err) { 17 | config = require('./../config.dist.json'); 18 | } 19 | } 20 | 21 | return config; 22 | }; 23 | }; 24 | 25 | module.exports = ConfigResolver.getConfig(); 26 | -------------------------------------------------------------------------------- /test/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": { 3 | "hostname" : "localhost", 4 | "ip" : "127.0.0.1", 5 | "port" : "3333" 6 | }, 7 | "api": { 8 | "auth" : { 9 | "token" : "thisissomeauthtoken" 10 | }, 11 | "controllers" : { 12 | "timer" : { 13 | "sessionTime" : 120000, 14 | "command" : "/t" 15 | } 16 | } 17 | }, 18 | "users": { 19 | "2345" : "some_user" 20 | }, 21 | "harvest" : { 22 | "subdomain" : "test", 23 | "email" : "XXXXX@test.com", 24 | "password" : "XXXXXXXXXXXXXXX" 25 | }, 26 | "slack" : { 27 | 28 | }, 29 | "cron" : { 30 | 31 | }, 32 | "logger" : { 33 | "console": false, 34 | "syslog": false, 35 | "file" : false, 36 | "processName" : "slack_harvest_middleman", 37 | "appHostname" : "slack-harvest-middleman.dev" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2014-2015 Neverbland 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining 7 | a copy of this software and associated documentation files (the 8 | "Software"), to deal in the Software without restriction, including 9 | without limitation the rights to use, copy, modify, merge, publish, 10 | distribute, sublicense, and/or sell copies of the Software, and to 11 | permit persons to whom the Software is furnished to do so, subject to 12 | the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be 15 | included in all copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 18 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 19 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 20 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 21 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 22 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 23 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /test/unit/services/auth/lib/error.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var expect = require('chai').expect, 4 | path = './../../../../../app/services/auth/lib/', 5 | authErrorFactory = require(path + 'error.js'); 6 | 7 | describe('auth/lib/error', function () { 8 | 9 | describe('error.create', function () { 10 | 11 | var message = "Some message", 12 | errorReasons = [ 13 | "Some error reason 1", 14 | "Some error reason 2" 15 | ], 16 | error = authErrorFactory.create(message, errorReasons); 17 | 18 | it ("Should create a new AuthError", function () { 19 | expect(error.name).to.be.equal('AuthError'); 20 | }); 21 | 22 | it ("Should return proper error message", function () { 23 | expect(error.getMessage()).to.be.equal(message); 24 | }); 25 | 26 | it ("Should return proper error reasons", function () { 27 | var reasons = error.getErrors(); 28 | expect(reasons).to.be.a('array'); 29 | expect(reasons).to.include.members(errorReasons); 30 | }); 31 | }); 32 | 33 | }); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sh-middleman", 3 | "private": true, 4 | "scripts": { 5 | "test": "env env=test mocha --recursive" 6 | }, 7 | "dependencies": { 8 | "express": "4.7.2", 9 | "knex": "0.7.3", 10 | "q": "1.0.1", 11 | "body-parser": "1.9.2", 12 | "mysql": "2.5.2", 13 | "cron": "1.0.9", 14 | "lodash": "2.4.1", 15 | "request": "2.55.0", 16 | "express-validator": "2.7.0", 17 | "express-session": "1.9.3", 18 | "express-session-json": "0.0.7", 19 | "syslogh": "1.1.2", 20 | "harvest": "gacek85/node-harvest#master", 21 | "node-uuid": "1.4.2", 22 | "moment": "2.9.0", 23 | "winston": "0.8.1", 24 | "q" : "1.2.0", 25 | "date-util" : "1.2.1", 26 | "humps" : "0.5.1", 27 | "walk" : "2.3.9" 28 | }, 29 | "devDependencies": { 30 | "blanket": "1.1.6", 31 | "chai": "2.1.2", 32 | "gulp": "^3.8.5", 33 | "gulp-concat": "^2.4.3", 34 | "gulp-nodemon": "1.0.4", 35 | "gulp-sass": "1.0.0", 36 | "gulp-sourcemaps": "^1.3.0", 37 | "gulp-uglify": "^1.0.2", 38 | "mocha": "2.0.1", 39 | "nodemon": "1.2.1", 40 | "rewire": "2.1.3", 41 | "sinon": "1.11.1", 42 | "sinon-chai" : "2.7.0", 43 | "zombie" : "3.1.*" 44 | }, 45 | "config": { 46 | "blanket": { 47 | "pattern": "app", 48 | "data-cover-never": "node_modules" 49 | } 50 | } 51 | } -------------------------------------------------------------------------------- /app/services/auth/index.js: -------------------------------------------------------------------------------- 1 | /*jshint node: true*/ 2 | 'use strict'; 3 | 4 | var authErrorFactory = require('./lib/error.js'), 5 | auth = require('./lib/auth.js'), 6 | applyHandlers = require('./lib/handlers.js'); 7 | 8 | /** 9 | * Auth module handles authentication. Takes the app and the auth config as params 10 | * and a mandatory error handler when the request is not granted with access to the 11 | * app. 12 | * 13 | * @param {express} app 14 | * @param {Object} config 15 | * @param {Function} errorCallback 16 | * @returns {undefined} 17 | */ 18 | module.exports = function (app, config, errorCallback) 19 | { 20 | 21 | // Register handlers in the auth object 22 | applyHandlers(auth, config); 23 | 24 | // Assign action name to the request 25 | app.use(function (req, res, next) 26 | { 27 | var url = req.originalUrl; 28 | var noSlash = url.substr(1); 29 | var parts = noSlash.split('/'); 30 | req.body.action = parts[1]; 31 | 32 | next(); 33 | }); 34 | 35 | app.use(function (req, res, next) 36 | { 37 | if (auth.hasAccess(req)) { 38 | next(); 39 | } else { 40 | errorCallback( 41 | authErrorFactory.create("Access denied!", auth.getErrors(req)), 42 | res 43 | ); 44 | } 45 | }); 46 | }; -------------------------------------------------------------------------------- /app/services/interactive_session/lib/step_tools.js: -------------------------------------------------------------------------------- 1 | /*jshint node: true*/ 2 | 'use strict'; 3 | 4 | var interactiveSession = require('./user_session.js'), 5 | stepTools = function () {}; 6 | 7 | stepTools.prototype = { 8 | 9 | /** 10 | * Checks if given option value is negative 11 | * 12 | * @param {Object} option 13 | * @param {String} value 14 | * @returns {Boolean} 15 | */ 16 | isRejectResponse : function (option, value) 17 | { 18 | return (option.type === 'system' && value === 'no'); 19 | }, 20 | 21 | /** 22 | * Executes the reject response providing reject data to the 23 | * callback 24 | * 25 | * @param {String} userId 26 | * @param {Function} callback 27 | * @returns {undefined} 28 | */ 29 | executeRejectResponse : function (userId, callback) 30 | { 31 | interactiveSession.getDefault().clear(userId); 32 | callback( 33 | null, 34 | 'Cool, try again later!', 35 | null 36 | ); 37 | }, 38 | 39 | validate : function (stepNumber, step) 40 | { 41 | if (step === null) { 42 | return false; 43 | } 44 | 45 | return (step.getParam('stepNumber') === stepNumber); 46 | } 47 | }; 48 | 49 | stepTools.prototype.constructor = stepTools; 50 | 51 | module.exports = stepTools; -------------------------------------------------------------------------------- /app/services/auth/lib/error.js: -------------------------------------------------------------------------------- 1 | /*jshint node: true*/ 2 | 'use strict'; 3 | 4 | /** 5 | * Auth error used to gather error messages for authorization 6 | * 7 | * @author Maciej Garycki 8 | * @param {String} message The reason of the error 9 | * @param {Array} errors The error strings 10 | * @constructor 11 | */ 12 | function AuthError (message, errors) 13 | { 14 | this.name = 'AuthError'; 15 | this.message = message; 16 | this.errors = errors; 17 | } 18 | 19 | AuthError.prototype = new Error(); 20 | AuthError.prototype.constructor = AuthError; 21 | 22 | 23 | /** 24 | * Returns the error objects provided by the error thrown 25 | * 26 | * @return {Array} An array of Objects 27 | */ 28 | AuthError.prototype.getErrors = function () 29 | { 30 | return this.errors; 31 | }; 32 | 33 | 34 | /** 35 | * Returns the message 36 | * 37 | * @return {String} 38 | */ 39 | AuthError.prototype.getMessage = function () 40 | { 41 | return this.message; 42 | }; 43 | 44 | 45 | // Exports the factory 46 | module.exports = { 47 | 48 | /** 49 | * Creates new error instance 50 | * 51 | * @param {String} message 52 | * @param {Array} errors 53 | * @returns {AuthError} 54 | */ 55 | create : function (message, errors) { 56 | return new AuthError(message, errors); 57 | } 58 | }; -------------------------------------------------------------------------------- /test/unit/services/slack/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var expect = require('chai').expect, 4 | rewire = require('rewire'), 5 | config = { 6 | "username" : "Harvest", 7 | "icon_url": "https://avatars0.githubusercontent.com/u/43635?v=3&s=200", 8 | "endpoint" : "XXXXXXXXXXXXXX" 9 | }, 10 | slackModule = rewire('./../../../../app/services/slack'), 11 | requestMock = { 12 | post : function () { 13 | 14 | expect(arguments.length).to.satisfy(function (num) { 15 | return num === 2; 16 | }); 17 | 18 | 19 | var params = arguments[0], 20 | callback = arguments[1]; 21 | 22 | expect(params).to.be.a('object'); 23 | expect(callback).to.be.a('function'); 24 | expect(params.url).to.be.equal(config.endpoint); 25 | expect(params.form.payload).to.be.a('string'); 26 | var payloadJSON = JSON.parse(params.form.payload); 27 | expect(payloadJSON).to.be.a('object'); 28 | } 29 | }; 30 | 31 | slackModule.__set__('request', requestMock); 32 | var slack = slackModule('default', config); 33 | 34 | 35 | describe('slack', function () { 36 | describe('slack.sendMessage', function () { 37 | it('Should send a request for given url with given post data', function () { 38 | slack.sendMessage("Test", {}, function () {}); 39 | }); 40 | }); 41 | }); -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | /*jshint node: true*/ 2 | 'use strict'; 3 | 4 | /* 5 | * Main application body and dispatch point for 6 | * Slack - Harvest integration miniserver 7 | * 8 | * @author Maciej Garycki 9 | */ 10 | 11 | 12 | 13 | var 14 | express = require('express'), 15 | app = express(), 16 | config = require('./config/index.js'), 17 | logger = require('./app/services/logger.js')('default', config.logger), 18 | harvest = require('./app/services/harvest')('default', config.harvest), 19 | slack = require('./app/services/slack')('default', config.slack), 20 | notifier = require('./app/services/notifier'), 21 | reportNotifier = require('./app/services/report')(slack, harvest), 22 | slackNotifier = require('./app/services/slack/notifier')(slack, harvest), 23 | slackReminder = require('./app/services/slack/notifier/remind')(slack, harvest), 24 | server 25 | ; 26 | 27 | harvest.setUsers(config.users); 28 | slack.setUsers(config.users); 29 | 30 | // Defining two notification channels 31 | notifier.addNotifier('users', slackNotifier); 32 | notifier.addNotifier('reminder', slackReminder); 33 | notifier.addNotifier('management', reportNotifier); 34 | 35 | require('./app/event_listeners')(app); 36 | require('./app/api')(app, config.api); 37 | require('./app/services/cronjobs')(app, config.cron); 38 | 39 | module.exports = app; 40 | 41 | if (!module.parent) { 42 | server = app.listen(config.app.port, function () { 43 | logger.info('Started web server with config : ', config, {}); 44 | }); 45 | } -------------------------------------------------------------------------------- /app/api/controllers/actions/reminder_all.js: -------------------------------------------------------------------------------- 1 | /*jshint node: true*/ 2 | 'use strict'; 3 | 4 | var harvest = require('./../../../services/harvest')('default'), 5 | notifier = require('./../../../services/notifier'), 6 | _ = require('lodash'), 7 | logger = require('./../../../services/logger.js')('default'), 8 | reminder = require('./../../../services/reminder/index') 9 | ; 10 | 11 | 12 | /** 13 | * Notifies the users on Slack 14 | * 15 | * @param {Object} harvestResponse The harvest API response 16 | * @param {Number} userId The harvest user Id 17 | * @returns {undefined} 18 | */ 19 | function doNotify (harvestResponse, userId) 20 | { 21 | notifier.notify('reminder', { 22 | harvestUserId : userId, 23 | harvestResponse : harvestResponse 24 | }); 25 | } 26 | 27 | 28 | /** 29 | * Notifies all mapped slack users to activate their Harvest if that's not done. 30 | * 31 | * @param {Object} req The request object 32 | * @param {Object} res The response object 33 | * @param {Function} next The next callback to apply 34 | * @returns {undefined} 35 | */ 36 | function remindAllController (req, res, next) 37 | { 38 | reminder.remind(harvest.users, function () { 39 | res.success = true; 40 | next(); 41 | }); 42 | } 43 | 44 | module.exports = function (app, config) 45 | { 46 | app.use('/api/remind-all', remindAllController); 47 | }; -------------------------------------------------------------------------------- /app/api/index.js: -------------------------------------------------------------------------------- 1 | /*jshint node: true*/ 2 | 'use strict'; 3 | 4 | var httpCodes = require('./codes.js'), 5 | controllers = require('./controllers'), 6 | logger = require('./../services/logger.js')('default'); 7 | 8 | /** 9 | * API module 10 | * 11 | * @author Maciej Garycki 12 | * @param {express} app The application 13 | * @param {Object} config The application config 14 | */ 15 | module.exports = function (app, config) 16 | { 17 | var bodyParser = require('body-parser'); 18 | app.use(bodyParser.urlencoded({ // to support URL-encoded bodies 19 | extended: true 20 | })); 21 | 22 | app.use(function (req, res, next) 23 | { 24 | res.set('Content-Type', 'application/json'); // JSON responses for all calls 25 | return next(); 26 | }); 27 | 28 | // The callback takes an error of AuthError type and the response as 29 | // parameters. 30 | require('./../services/auth')(app, config.auth, function (err, res) 31 | { 32 | var errorMsgs = err.getErrors(); 33 | logger.warn('API request blocked. Errors: ', errorMsgs, {}); 34 | res.writeHead(httpCodes.UNAUTHORIZED); // Unauthorized 35 | res.write(JSON.stringify({ 36 | success : false, 37 | code: httpCodes.UNAUTHORIZED, 38 | errors : errorMsgs 39 | })); 40 | res.send(); 41 | }); 42 | 43 | // Apply controllers 44 | controllers(app, config.controllers); 45 | } -------------------------------------------------------------------------------- /config.dist.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": { 3 | "hostname" : "slack-harvest-middleman.dev", 4 | "ip" : "localhost", 5 | "port" : "3333" 6 | }, 7 | "api": { 8 | "auth" : { 9 | "secret" : "thisissomeauthsecret" 10 | }, 11 | "controllers" : { 12 | "timer" : { 13 | "sessionTime" : 120000, 14 | "command" : "/t" 15 | } 16 | } 17 | }, 18 | "users": { 19 | 20 | }, 21 | "harvest" : { 22 | "subdomain" : "neverbland", 23 | "email" : "XXXXX@neverbland.com", 24 | "password" : "XXXXXXXXXXXXXXX" 25 | }, 26 | "slack" : { 27 | "username" : "Harvest", 28 | "icon_url": "https://avatars0.githubusercontent.com/u/43635?v=3&s=200", 29 | "endpoint" : "XXXXXXXXXXXXXX" 30 | }, 31 | "cron" : { 32 | "notify" : { 33 | "cronTime" : false, 34 | "hour" : "16", 35 | "minutes" : "30" 36 | }, 37 | "preload" : { 38 | "cronTime" : "00 00 7-20 * * 1-5" 39 | }, 40 | "report" : { 41 | "reportTitle" : "Weekly activity report", 42 | "channel" : "#channel_name" 43 | }, 44 | "remind" : { 45 | "cronTime" : "00 00 12 * * 1-5" 46 | } 47 | }, 48 | "logger" : { 49 | "console": false, 50 | "syslog": false, 51 | "file" : false, 52 | "processName" : "slack_harvest_middleman", 53 | "appHostname" : "slack-harvest-middleman.dev" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /app/services/notifier/index.js: -------------------------------------------------------------------------------- 1 | /*jshint node: true*/ 2 | 'use strict'; 3 | 4 | var _ = require('lodash'); 5 | 6 | 7 | /** 8 | * Notifier module provides an aggregator for notifiers that 9 | * take a context object and send notifications 10 | * 11 | * @author Maciej Garycki 12 | * @constructor 13 | */ 14 | var Notifier = function () 15 | { 16 | this.notifiers = {}; 17 | } 18 | 19 | 20 | Notifier.prototype = { 21 | 22 | /** 23 | * Registers a notifier 24 | * 25 | * @param {String} channel The notification channel name 26 | * @param {Object} notifier 27 | * @returns {Notifier} This instance 28 | */ 29 | addNotifier : function (channel, notifier) 30 | { 31 | this.notifiers[channel] = this.notifiers[channel] || []; 32 | this.notifiers[channel].push(notifier); 33 | 34 | return this; 35 | }, 36 | 37 | 38 | /** 39 | * Runs all aggregated notifiers 40 | * 41 | * @param {Object} context 42 | * @param {String} channel The notification channel name 43 | * @returns {Notifier} This instance 44 | */ 45 | notify : function (channel, context) 46 | { 47 | this.notifiers[channel] = this.notifiers[channel] || []; 48 | _.each(this.notifiers[channel], function (notifier) { 49 | notifier.notify(context); 50 | }); 51 | 52 | return this; 53 | } 54 | }; 55 | 56 | Notifier.prototype.constructor = Notifier; 57 | 58 | 59 | module.exports = new Notifier(); -------------------------------------------------------------------------------- /app/api/controllers/actions/reminder_user.js: -------------------------------------------------------------------------------- 1 | /*jshint node: true*/ 2 | 'use strict'; 3 | 4 | var harvest = require('./../../../services/harvest')('default'), 5 | notifier = require('./../../../services/notifier'), 6 | _ = require('lodash'), 7 | logger = require('./../../../services/logger.js')('default'), 8 | reminder = require('./../../../services/reminder/index'), 9 | tools = require('./../../../services/tools.js') 10 | ; 11 | 12 | 13 | /** 14 | * Notifies the users on Slack 15 | * 16 | * @param {Object} harvestResponse The harvest API response 17 | * @param {Number} userId The harvest user Id 18 | * @returns {undefined} 19 | */ 20 | function doNotify (harvestResponse, userId) 21 | { 22 | notifier.notify('reminder', { 23 | harvestUserId : userId, 24 | harvestResponse : harvestResponse 25 | }); 26 | } 27 | 28 | 29 | /** 30 | * Notifies all mapped slack users to activate their Harvest if that's not done. 31 | * 32 | * @param {Object} req The request object 33 | * @param {Object} res The response object 34 | * @param {Function} next The next callback to apply 35 | * @returns {undefined} 36 | */ 37 | function remindUserController (req, res, next) 38 | { 39 | var users; 40 | try { 41 | users = tools.validateGetUser(harvest.users, req.params.user); 42 | } catch (err) { 43 | res.success = false; 44 | res.errors = [ 45 | err.message 46 | ]; 47 | next(); 48 | return; 49 | } 50 | reminder.remind(users, function () { 51 | res.success = true; 52 | next(); 53 | }); 54 | } 55 | 56 | module.exports = function (app, config) 57 | { 58 | app.use('/api/remind-user/:user', remindUserController); 59 | }; -------------------------------------------------------------------------------- /app/api/controllers/index.js: -------------------------------------------------------------------------------- 1 | /*jshint node: true*/ 2 | 'use strict'; 3 | 4 | var httpCodes = require('./../codes.js'), 5 | walk = require('walk') 6 | ; 7 | 8 | 9 | /** 10 | * API controllers 11 | * 12 | * @author Maciej Garycki 13 | * @param {express} app The application 14 | * @param {Object} config The application config 15 | */ 16 | module.exports = function (app, config) 17 | { 18 | var walker; 19 | 20 | /** 21 | * Sets json response 22 | * 23 | * @param {Object} req 24 | * @param {Object} res 25 | * @param {Function} next 26 | * @returns {undefined} 27 | */ 28 | function setResponse (req, res, next) 29 | { 30 | var httpCode; 31 | 32 | if (typeof res.success === 'undefined') { 33 | res.statusCode = httpCodes.NOT_FOUND; 34 | } 35 | 36 | if (!!res.success === false) { 37 | httpCode = (res.statusCode === httpCodes.NOT_FOUND) ? res.statusCode : httpCodes.BAD_REQUEST; // Unauthorized 38 | } else { 39 | httpCode = httpCodes.OK; 40 | } 41 | res.statusCode = httpCode; 42 | var responseJson = { 43 | success : Boolean(res.success), 44 | code: httpCode 45 | }; 46 | if (!!res.errors) { 47 | responseJson.errors = res.errors; 48 | } 49 | res.write(JSON.stringify(responseJson)); 50 | res.send(); 51 | } 52 | 53 | walker = walk.walk(__dirname + '/actions', { 54 | followLinks : false 55 | }); 56 | 57 | // Load all actions 58 | 59 | walker.on('file', function (root, stat, next) { 60 | var file = __dirname + '/actions/' + stat.name, 61 | baseName = stat.name.substr(0, stat.name.length - 3), 62 | conf = config[baseName] || {}; 63 | require(file)(app, conf); 64 | next(); 65 | }); 66 | 67 | 68 | walker.on('end', function () { 69 | // Default response should be 404 70 | app.use(setResponse); 71 | }); 72 | }; -------------------------------------------------------------------------------- /test/unit/services/auth/lib/auth.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var expect = require('chai').expect, 4 | path = './../../../../../app/services/auth/lib/', 5 | authErrorFactory = require(path + 'error.js'), 6 | auth = require(path + 'auth.js'); 7 | 8 | 9 | 10 | 11 | describe('auth/lib/auth', function () { 12 | 13 | describe("Auth.hasAccess", function () { 14 | var requestMock = { 15 | body : {} 16 | }; 17 | it("Should be able to register handlers and call them when asked if access is granted.", function () { 18 | auth.resetHandlers().addHandler({ 19 | validate : function (req) { 20 | expect(req).to.equal(requestMock); 21 | } 22 | }); 23 | expect(auth.handlers.length).to.equal(1); 24 | auth.hasAccess(requestMock); 25 | }); 26 | it ("Should return false if registered handler throws an auth error.", function () { 27 | auth.resetHandlers().addHandler({ 28 | validate : function (req) { 29 | throw authErrorFactory.create("Some Error", ["Some Error Reason"]); 30 | } 31 | }); 32 | expect(auth.handlers.length).to.equal(1); 33 | expect(auth.hasAccess(requestMock)).to.equal(false); 34 | }); 35 | it ("Should return true if registered handler doesn't throw an auth error.", function () { 36 | auth.resetHandlers().addHandler({ 37 | validate : function (req) { 38 | // Do nothing here 39 | } 40 | }); 41 | expect(auth.handlers.length).to.equal(1); 42 | expect(auth.hasAccess(requestMock)).to.equal(true); 43 | }); 44 | it ("Should throw error if the handler throws any other error than auth error.", function () { 45 | auth.resetHandlers().addHandler({ 46 | validate : function (req) { 47 | throw new Error('Some error'); 48 | } 49 | }); 50 | expect(auth.handlers.length).to.equal(1); 51 | expect(auth.hasAccess).to.throw(Error); 52 | }); 53 | }); 54 | }); -------------------------------------------------------------------------------- /app/api/controllers/actions/notify_all.js: -------------------------------------------------------------------------------- 1 | /*jshint node: true*/ 2 | 'use strict'; 3 | 4 | var harvest = require('./../../../services/harvest')('default'), 5 | notifier = require('./../../../services/notifier'), 6 | _ = require('lodash'), 7 | logger = require('./../../../services/logger.js')('default') 8 | ; 9 | 10 | 11 | /** 12 | * Notifies the users on Slack 13 | * 14 | * @param {Object} harvestResponse The harvest API response 15 | * @param {Number} userId The harvest user Id 16 | * @returns {undefined} 17 | */ 18 | function doNotify (harvestResponse, userId) 19 | { 20 | notifier.notify('users', { 21 | harvestUserId : userId, 22 | harvestResponse : harvestResponse 23 | }); 24 | } 25 | 26 | 27 | /** 28 | * Notifies all mapped slack users about their Harvest 29 | * activities 30 | * 31 | * @param {Object} req The request object 32 | * @param {Object} res The response object 33 | * @param {Function} next The next callback to apply 34 | * @returns {undefined} 35 | */ 36 | function notifyAllController (req, res, next) 37 | { 38 | var errors = []; 39 | var anySuccess = false; 40 | var ids = harvest.fromUserMap(harvest.users); 41 | _.each(harvest.fromUserMap(harvest.users), function (userId) { 42 | harvest.getUserTimeTrack(userId, new Date(), new Date(), function (err, harvestResponse) { 43 | ids.shift(); 44 | if (err === null) { 45 | anySuccess = true; 46 | doNotify(harvestResponse, userId); 47 | } else { 48 | logger.error("Failed fetching user timeline from Harvest API for user " + userId, err, {}); 49 | errors.push(err); 50 | } 51 | 52 | if (!ids.length) { 53 | if (anySuccess) { 54 | res.success = true; 55 | } else { 56 | res.success = false; 57 | } 58 | if (errors.length) { 59 | res.errors = errors; 60 | } 61 | 62 | next(); 63 | } 64 | }); 65 | }); 66 | } 67 | 68 | module.exports = function (app, config) 69 | { 70 | app.use('/api/notify-all', notifyAllController); 71 | }; -------------------------------------------------------------------------------- /test/functional/notifications.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var app = require('./../../app.js'), 4 | expect = require('chai').expect, 5 | http = require('http'), 6 | codes = require('./../../app/api/codes.js'), 7 | request = require('request'), 8 | auth = require('./../../app/services/auth/lib/auth.js'), 9 | applyHandlers = require('./../../app/services/auth/lib/handlers.js'), 10 | sinon = require("sinon"), 11 | sinonChai = require("sinon-chai"), 12 | rewire = require('rewire'), 13 | harvestMock = require('./../mock/harvest'), 14 | notifyController = rewire('./../../app/api/controllers/actions/notify_user.js'), 15 | dayEntries = [], 16 | slackNotifier = require('./../../app/services/slack/notifier'), 17 | notifier = require('./../../app/services/notifier') 18 | ; 19 | 20 | 21 | notifyController.__set__('harvest', harvestMock); 22 | 23 | describe('Functional: Notifications', function () { 24 | 25 | var server; 26 | 27 | applyHandlers(auth, { 28 | token : 'thisissomeauthtoken' 29 | }); 30 | 31 | 32 | before(function () { 33 | server = http.createServer(app).listen(3333); 34 | }); 35 | 36 | describe('Notify user', function () { 37 | it('Should send a notification to the user after calling proper api url.', function (done) { 38 | 39 | var userId = 2345; 40 | 41 | harvestMock.setUsers({ 42 | '23456' : 'some_test_user' 43 | }); 44 | 45 | 46 | request.post({ 47 | url : 'http://localhost:3333/api/notify-user/' + userId, 48 | form : { 49 | token : 'thisissomeauthtoken' 50 | } 51 | }, function (err, res, body) { 52 | 53 | var json = JSON.parse(body); 54 | // expect(res.statusCode).to.be.equal(codes.OK); 55 | // expect(json.code).to.be.equal(codes.OK); 56 | // expect(json.success).to.be.equal(true); 57 | done(); 58 | }); 59 | }); 60 | }); 61 | 62 | 63 | after(function (done) { 64 | server.close(done); 65 | }); 66 | }); -------------------------------------------------------------------------------- /app/api/controllers/actions/notify_user.js: -------------------------------------------------------------------------------- 1 | /*jshint node: true*/ 2 | 'use strict'; 3 | 4 | var harvest = require('./../../../services/harvest')('default'), 5 | notifier = require('./../../../services/notifier'), 6 | _ = require('lodash'), 7 | logger = require('./../../../services/logger.js')('default') 8 | ; 9 | 10 | 11 | /** 12 | * Notifies the users on Slack 13 | * 14 | * @param {Object} harvestResponse The harvest API response 15 | * @param {Number} userId The harvest user Id 16 | * @returns {undefined} 17 | */ 18 | function doNotify (harvestResponse, userId) 19 | { 20 | notifier.notify('users', { 21 | harvestUserId : userId, 22 | harvestResponse : harvestResponse 23 | }); 24 | } 25 | 26 | 27 | function getHarvestUserId (users, userId) 28 | { 29 | var harvestUserId = null; 30 | _.each(users, function (slackName, harvestId) { 31 | if ((String(harvestId) === String(userId)) || (String(slackName) === String(userId))) { 32 | harvestUserId = harvestId; 33 | } 34 | }); 35 | 36 | return harvestUserId; 37 | } 38 | 39 | 40 | /** 41 | * Notifies a single user given either by slack name or harvest id 42 | * 43 | * @param {Object} req The request object 44 | * @param {Object} res The response object 45 | * @param {Function} next The next callback to apply 46 | * @returns {undefined} 47 | */ 48 | function notifyUserController (req, res, next) 49 | { 50 | var userId = getHarvestUserId(harvest.users, req.params.user); 51 | if (userId) { 52 | res.success = true; 53 | harvest.getUserTimeTrack(userId, new Date(), new Date(), function (err, harvestResponse) { 54 | if (err === null) { 55 | doNotify(harvestResponse, userId); 56 | } else { 57 | logger.error("Failed fetching user timeline from Harvest API for user " + userId, err, {}); 58 | res.success = false; 59 | res.errors = [ 60 | err 61 | ]; 62 | } 63 | next(); 64 | }); 65 | } else { 66 | res.success = false; 67 | res.errors = [ 68 | 'Invalid user id' 69 | ]; 70 | next(); 71 | } 72 | } 73 | 74 | 75 | module.exports = function (app, config) 76 | { 77 | app.use('/api/notify-user/:user', notifyUserController); 78 | }; -------------------------------------------------------------------------------- /test/mock/harvest.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var HarvestMock = { 4 | 5 | hasError : false, 6 | 7 | users : {}, 8 | 9 | timeTrack : [{ 10 | day_entry: 11 | { 12 | id: 311036476, 13 | notes: '', 14 | spent_at: '2015-03-16', 15 | hours: 1.6, 16 | user_id: 449849, 17 | project_id: 2, 18 | task_id: 1815946, 19 | created_at: '2015-03-16T15:00:02Z', 20 | updated_at: '2015-03-17T08:28:27Z', 21 | adjustment_record: false, 22 | timer_started_at: null, 23 | is_closed: false, 24 | is_billed: false 25 | } 26 | }], 27 | 28 | projects : [{ 29 | project : { 30 | id : 2, 31 | name : 'Test project', 32 | client_id : 3 33 | } 34 | }], 35 | 36 | clients : [{ 37 | client : { 38 | id : 3, 39 | name : 'Test client' 40 | } 41 | }], 42 | 43 | 44 | /** 45 | * Sets the users 46 | * 47 | * @param {Object} users 48 | * @returns {HarvestMock} This instance 49 | */ 50 | setUsers : function (users) 51 | { 52 | this.users = users; 53 | return this; 54 | }, 55 | 56 | 57 | /** 58 | * If has error, the callbacks will have an error passed 59 | * 60 | * @param {Boolean} hasError 61 | * @returns {HarvestMock} 62 | */ 63 | setHasError : function (hasError) 64 | { 65 | this.hasError = Boolean(hasError); 66 | return this; 67 | }, 68 | 69 | getUserTimeTrack : function (user_id, fromDate, toDate, callback) 70 | { 71 | 72 | if (this.hasError) { 73 | callback('Error', []); 74 | } else { 75 | callback(null, this.timeTrack); 76 | } 77 | }, 78 | 79 | 80 | getProjectsByIds : function (ids, callback) 81 | { 82 | if (this.hasError) { 83 | callback('Error', []); 84 | } else { 85 | callback(null, this.projects); 86 | } 87 | }, 88 | 89 | 90 | getClientsByIds : function (ids, callback) 91 | { 92 | if (this.hasError) { 93 | callback('Error', []); 94 | } else { 95 | callback(null, this.clients); 96 | } 97 | } 98 | 99 | }; 100 | 101 | 102 | 103 | module.exports = HarvestMock; -------------------------------------------------------------------------------- /app/api/controllers/actions/timer.js: -------------------------------------------------------------------------------- 1 | /*jshint node: true*/ 2 | 'use strict'; 3 | 4 | var httpCodes = require('./../../codes.js'), 5 | harvest = require('./../../../services/harvest')('default'), 6 | _ = require('lodash'), 7 | logger = require('./../../../services/logger.js')('default'), 8 | tools = require('./../../../services/tools.js'), 9 | timerCommandParser = require('./../../../services/timer.js'), 10 | consts = require('../../../../consts.json'), 11 | commandSessionResolver = require('./../../../services/interactive_session'); 12 | 13 | 14 | function getHarvestUserId (users, userId) 15 | { 16 | var harvestUserId = null; 17 | _.each(users, function (slackName, harvestId) { 18 | if ((String(harvestId) === String(userId)) || (String(slackName) === String(userId))) { 19 | harvestUserId = harvestId; 20 | } 21 | }); 22 | 23 | return harvestUserId; 24 | } 25 | 26 | 27 | /** 28 | * Notifies management about stats of given user(s) work 29 | * 30 | * @param {Object} req The request object 31 | * @param {Object} res The response object 32 | * @param {Function} next The next callback to apply 33 | * @returns {undefined} 34 | */ 35 | function manageTimerController(req, res, next) 36 | { 37 | var text = req.body.text || '', 38 | config, 39 | userName = (function () { 40 | try { 41 | return tools.validateGet(req.body, 'user_name', "Invalid username provided!"); 42 | } catch (err) { 43 | res.success = false; 44 | res.errors = [ 45 | err.message 46 | ]; 47 | 48 | } 49 | })(); 50 | if (!userName) { 51 | next(); 52 | return; 53 | } 54 | 55 | try { 56 | config = timerCommandParser.parseTimerConfig(text); 57 | config.userId = getHarvestUserId(harvest.users, userName); 58 | } catch (err) { 59 | res.success = false; 60 | res.errors = [ 61 | err.message 62 | ]; 63 | next(); 64 | return; 65 | } 66 | 67 | commandSessionResolver.runStep(config, function (err, view) { 68 | res.set('Content-Type', 'text/html'); 69 | if (err === null) { 70 | res.writeHead(httpCodes.OK); 71 | res.write(view); 72 | res.send(); 73 | } else { 74 | res.writeHead(httpCodes.BAD_REQUEST); 75 | res.write(view); 76 | res.send(); 77 | } 78 | }); 79 | } 80 | 81 | 82 | module.exports = function (app, config) 83 | { 84 | var sessionTime = config.sessionTime || consts.userSession.sessionTime; 85 | commandSessionResolver.setSesstionTime(sessionTime); 86 | app.use('/api/timer', manageTimerController); 87 | }; -------------------------------------------------------------------------------- /app/services/slack/index.js: -------------------------------------------------------------------------------- 1 | /*jshint node: true*/ 2 | 'use strict'; 3 | 4 | var _ = require('lodash'), 5 | request = require('request'); 6 | 7 | 8 | function _Slack (config) 9 | { 10 | this.endpoint = config.endpoint; 11 | this.config = config; 12 | if (!config.username) { 13 | this.config.username = _Slack.prototype.USER_AGENT; 14 | } 15 | } 16 | 17 | 18 | _Slack.prototype = { 19 | USER_AGENT : "Neverbland Slack - Harvest Integration Middleman", 20 | 21 | 22 | /** 23 | * Returns all slack user ids 24 | * 25 | * @param {Object} userMap A map of harvest id -> slack id 26 | * @returns {undefined} 27 | */ 28 | fromUserMap : function (userMap) 29 | { 30 | var results = []; 31 | for (var hId in userMap) { 32 | if (userMap.hasOwnProperty(hId)) { 33 | results.push(userMap[hId]); 34 | } 35 | } 36 | 37 | return results; 38 | }, 39 | 40 | 41 | 42 | /** 43 | * Sets the available user ids for this instance of the service 44 | * 45 | * @param {Array} users 46 | * @returns {undefined} 47 | */ 48 | setUsers : function (users) 49 | { 50 | this.users = users; 51 | }, 52 | 53 | 54 | /** 55 | * Sends slack message 56 | * 57 | * @param {String} text 58 | * @param {Object} config 59 | * @param {Function} callback A callback taking err, 60 | * httpResponse, body params 61 | * @returns {undefined} 62 | */ 63 | sendMessage : function (text, config, callback) 64 | { 65 | config = config || {}; 66 | config = _.assign({ 67 | text : text 68 | }, this.config, config); 69 | 70 | 71 | request.post( 72 | { 73 | url : this.endpoint, 74 | form : { 75 | payload : JSON.stringify(config) 76 | } 77 | }, 78 | (typeof callback === 'function') ? callback : function () {} 79 | ); 80 | 81 | } 82 | }; 83 | _Slack.prototype.constructor = _Slack; 84 | 85 | 86 | /** 87 | * 88 | * @type {Object} An object containing _Slack instances 89 | */ 90 | var instances = {}; 91 | 92 | /** 93 | * Creates a new instance if such instance does not exist. If exists, returns 94 | * the existing one. 95 | * 96 | * @param {String} key 97 | * @param {Object} config 98 | * @returns {_Slack} 99 | */ 100 | module.exports = function (key, config) 101 | { 102 | if (!!instances[key]) { 103 | return instances[key]; 104 | } else { 105 | instances[key] = new _Slack(config); 106 | return instances[key]; 107 | } 108 | } -------------------------------------------------------------------------------- /Vagrantfile: -------------------------------------------------------------------------------- 1 | # -*- mode: ruby -*- 2 | # vi: set ft=ruby : 3 | 4 | VAGRANTFILE_API_VERSION = "2" 5 | VAGRANT_HOST="192.168.10.12" 6 | 7 | Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| 8 | config.berkshelf.enabled = true 9 | 10 | # install debian 11 | config.vm.box = "debian7.2-slack-harvest-middleman" 12 | config.vm.box_url = "http://nbcdn.io/debian-7.2.0-amd64.box" 13 | 14 | # configure network 15 | config.vm.hostname = "slack-harvest-middleman.neverbland.dev" 16 | config.vm.network "private_network", ip: VAGRANT_HOST, network: "255.255.0.0." 17 | 18 | config.vm.synced_folder ".", "/var/www/sh-middleman", :nfs => true 19 | 20 | # VirtualBox specific config 21 | config.vm.provider :virtualbox do |vb, override| 22 | override.vm.synced_folder ".", "/var/www/sh-middleman", :nfs => true 23 | vb.customize ["modifyvm", :id, "--rtcuseutc", "on"] 24 | vb.customize ["modifyvm", :id, "--memory", 2048] 25 | vb.customize ["modifyvm", :id, "--cpus", 2] 26 | end 27 | 28 | # manage /etc/hosts file 29 | config.hostmanager.enabled = true 30 | config.hostmanager.manage_host = true 31 | config.hostmanager.ignore_private_ip = false 32 | config.hostmanager.include_offline = true 33 | config.hostmanager.aliases = [ 34 | "slack-harvest-middleman.dev", "www.slack-harvest-middleman.dev", "sh.dev" 35 | ] 36 | 37 | # fixed chef version to be sure that recipes are working 38 | config.omnibus.chef_version = "11.10.0" 39 | 40 | # enable caching in host machine 41 | config.cache.auto_detect = true 42 | config.cache.enable :apt 43 | config.cache.enable :chef 44 | config.cache.scope = :machine 45 | 46 | # chef recipes 47 | config.vm.provision "chef_solo" do |chef| 48 | chef.cookbooks_path = ["./build/chef/cookbooks"] 49 | chef.roles_path = "./build/chef/roles" 50 | chef.add_role "slack-harvest-middleman" 51 | chef.json = { 52 | "nodejs" => { 53 | "install_method" => "source", 54 | "version" => "0.10.26" 55 | }, 56 | "nginx" => { 57 | "server_names_hash_bucket_size" => 128, 58 | "version" => "1.2.1" 59 | }, 60 | "mysql" => { 61 | "server_root_password" => "root", 62 | "server_repl_password" => "root", 63 | "server_debian_password" => "root" 64 | }, 65 | "application" => { 66 | "root_dir" => "/var/www/sh-middleman", 67 | "app" => { 68 | "hostname" => "sh.dev", 69 | "ip" => VAGRANT_HOST, 70 | "port" => 3000 71 | }, 72 | "mysql" => { 73 | "database" => "sh_middleman", 74 | "username" => "root", 75 | "plainPassword" => "root" 76 | } 77 | } 78 | } 79 | end 80 | 81 | # also run hostmanager after all provisioning has happened 82 | config.vm.provision :hostmanager 83 | 84 | end -------------------------------------------------------------------------------- /app/services/slack/notifier/remind.js: -------------------------------------------------------------------------------- 1 | /*jshint node: true*/ 2 | 'use strict'; 3 | 4 | var _ = require('lodash'), 5 | events = require("events"), 6 | logger = require('./../../logger.js')('default'), 7 | tools = require('./../../tools.js'); 8 | 9 | 10 | /** 11 | * Fetches the user name 12 | * 13 | * @param {Object} users A map of harvest id -> slack id 14 | * @param {Number} harvestUserId 15 | * @returns {String} 16 | */ 17 | function getUserName (users, harvestUserId) 18 | { 19 | var response; 20 | for (var harvestId in users) { 21 | if (String(harvestId) === String(harvestUserId)) { 22 | response = users[harvestId]; 23 | } 24 | } 25 | 26 | return response; 27 | } 28 | 29 | /** 30 | * Sends notifications via slack 31 | * 32 | * @author Maciej Garycki 33 | * 34 | * @param {Object} slack The slack object 35 | * @param {Object} harvest The harvest object 36 | * @constructor 37 | */ 38 | function SlackReminder (slack, harvest) 39 | { 40 | this.slack = slack; 41 | this.harvest = harvest; 42 | } 43 | 44 | function SlackReminderPrototype () 45 | { 46 | this.LINK = "https://neverbland.harvestapp.com/time"; 47 | 48 | 49 | /** 50 | * Sends notification to slack 51 | * 52 | * @param {Object} slackContext 53 | * @returns {undefined} 54 | */ 55 | this.notify = function (slackContext) 56 | { 57 | var userName = getUserName(this.slack.users, slackContext.harvestUserId); 58 | this.prepareText(userName); 59 | 60 | }; 61 | 62 | 63 | 64 | /** 65 | * prepares the text and triggers propper event when ready 66 | * 67 | * @param {String} userName 68 | * @returns {undefined} 69 | */ 70 | this.prepareText = function (userName) 71 | { 72 | var view = [ 73 | 'You have no tasks running on *Harvest*!', 74 | 'Click here <' + SlackReminder.prototype.LINK + '> to add them or use the timer command on Slack' 75 | ].join('\n') 76 | ; 77 | this.slack.sendMessage(view, { 78 | channel : '@' + userName 79 | }, function (err, httpResponse, body) { 80 | if (err === null) { 81 | logger.info('Successfully sent a reminder message to user ' + userName, {}); 82 | } else { 83 | logger.info('Reminder for user ' + userName + ' not sent. Error: ', err, {}); 84 | } 85 | }); 86 | }; 87 | }; 88 | 89 | SlackReminderPrototype.prototype = new events.EventEmitter(); 90 | 91 | 92 | SlackReminder.prototype = new SlackReminderPrototype(); 93 | SlackReminder.prototype.constructor = SlackReminder; 94 | 95 | var instance = null; 96 | 97 | module.exports = function (slack, harvest) { 98 | instance = new SlackReminder(slack, harvest); 99 | module.exports.instance = instance; 100 | return instance; 101 | }; 102 | -------------------------------------------------------------------------------- /app/services/auth/lib/handlers.js: -------------------------------------------------------------------------------- 1 | /*jshint node: true*/ 2 | 'use strict'; 3 | 4 | var crypto = require('crypto'), 5 | authErrorFactory = require('./error.js'), 6 | _ = require('lodash'); 7 | 8 | /** 9 | * Generates/recreates hash for given secret, seed and action 10 | * 11 | * @param {String} secret 12 | * @param {String} seed 13 | * @param {String} action 14 | * @returns {String} 15 | */ 16 | function generateHash (secret, seed, action) 17 | { 18 | var hashBase = [ 19 | secret, 20 | seed, 21 | action 22 | ].join('|'); 23 | 24 | var shasum = crypto.createHash('sha1'); 25 | shasum.update(hashBase, 'utf8'); 26 | 27 | return shasum.digest('hex'); 28 | } 29 | 30 | /** 31 | * Returns handlers for auth checking 32 | * 33 | * @author Maciej Garycki 34 | */ 35 | var handlers = { 36 | 37 | /** 38 | * Secret based handler 39 | * 40 | * @constructor Creates a secret handler 41 | */ 42 | secret : function (secret) 43 | { 44 | this.secret = secret; 45 | }, 46 | 47 | /** 48 | * Token based handler 49 | * 50 | * @constructor Creates a secret handler 51 | */ 52 | token : function (token) 53 | { 54 | this.token = token; 55 | } 56 | } 57 | 58 | 59 | 60 | handlers.secret.prototype = { 61 | 62 | /** 63 | * Validates the request 64 | * 65 | * @param {Object} req 66 | * @returns {undefined} 67 | */ 68 | validate : function (req) 69 | { 70 | var requestToken = req.body.token; 71 | var action = req.body.action; 72 | var seed = req.body.seed; 73 | var hash = generateHash(this.secret, seed, action); 74 | 75 | if (hash !== requestToken) { 76 | throw new authErrorFactory.create("Invalid token", [ 77 | "Provided token is invalid" 78 | ]); 79 | } 80 | } 81 | }; 82 | handlers.secret.prototype.constructor = handlers.secret; 83 | 84 | 85 | handlers.token.prototype = { 86 | 87 | /** 88 | * Validates the request 89 | * 90 | * @param {Object} req 91 | * @returns {undefined} 92 | */ 93 | validate : function (req) 94 | { 95 | var requestToken = req.body.token; 96 | 97 | if (this.token !== requestToken) { 98 | throw new authErrorFactory.create("Invalid token", [ 99 | "Provided token is invalid" 100 | ]); 101 | } 102 | } 103 | }; 104 | handlers.token.prototype.constructor = handlers.token; 105 | 106 | 107 | 108 | module.exports = function (auth, config) 109 | { 110 | auth.resetHandlers(); 111 | _.each(config, function (param, handlerName) { 112 | if (!handlers[handlerName]) { 113 | return; 114 | } 115 | var handler = new handlers[handlerName](param); 116 | auth.addHandler(handler); 117 | }); 118 | }; -------------------------------------------------------------------------------- /app/services/reminder/index.js: -------------------------------------------------------------------------------- 1 | /*jshint node: true*/ 2 | 'use strict'; 3 | 4 | var harvest = require('./../harvest')('default'), 5 | notifier = require('./../notifier'), 6 | _ = require('lodash'), 7 | logger = require('./../logger.js')('default'), 8 | Q = require('q') 9 | ; 10 | 11 | 12 | /** 13 | * Notifies the user given by harvest ID 14 | * 15 | * @param {Number} harvestId 16 | * @returns {undefined} 17 | */ 18 | function doNotify (harvestId) 19 | { 20 | notifier.notify('reminder', { 21 | harvestUserId : harvestId 22 | }); 23 | } 24 | 25 | 26 | /** 27 | * Returns true if notification needs to be sent 28 | * 29 | * @param {Array} dayEntries An array of day entries provided 30 | * by Harvest API 31 | * @returns {Boolean} 32 | */ 33 | function needsNotification (dayEntries) 34 | { 35 | return !dayEntries.length; 36 | } 37 | 38 | 39 | var Reminder = { 40 | 41 | /** 42 | * Reminds users if the users have no harvest day entries assigned 43 | * 44 | * @param {Object} users harvestID -> slackName pairs 45 | * @param {Function} initCallback Called after the user timetrack was initiated 46 | * @param {Function} callback Takes an object containing: success (list of succeeded users) 47 | * notified (list of the users that needed to be notified) 48 | * errors (list of failed users) 49 | */ 50 | remind : function (users, initCallback, callback) 51 | { 52 | var errors = {}, 53 | successes = {}, 54 | notified = {}, 55 | promises = [] 56 | ; 57 | _.each(users, function (slackName, harvestId) { 58 | var def = Q.defer(); 59 | harvest.getUserTimeTrack(harvestId, new Date(), new Date(), function (err, harvestResponse) { 60 | if (err === null) { 61 | successes[harvestId] = slackName; 62 | if (needsNotification(harvestResponse)) { 63 | doNotify(harvestId); 64 | notified[harvestId] = slackName; 65 | } 66 | } else { 67 | logger.error("Failed fetching user timeline from Harvest API for user " + harvestId, err, {}); 68 | errors[harvestId] = slackName; 69 | } 70 | def.resolve(harvestId); 71 | }); 72 | 73 | promises.push(def.promise); 74 | }); 75 | 76 | Q.all(promises).then(function (items) { 77 | if (typeof callback === 'function') { 78 | callback({ 79 | successes : successes, 80 | errors : errors, 81 | notified : notified 82 | }); 83 | } 84 | }); 85 | 86 | if (typeof initCallback === 'function') { 87 | initCallback(); 88 | } 89 | } 90 | }; 91 | 92 | 93 | module.exports = Reminder; -------------------------------------------------------------------------------- /app/api/controllers/actions/notify_management.js: -------------------------------------------------------------------------------- 1 | /*jshint node: true*/ 2 | 'use strict'; 3 | 4 | var notifier = require('./../../../services/notifier'), 5 | _ = require('lodash'), 6 | logger = require('./../../../services/logger.js')('default'), 7 | consts = require('./../../../../consts.json'), 8 | tools = require('./../../../services/tools.js') 9 | ; 10 | 11 | 12 | /** 13 | * Validates the date string and throws a TypeError if invalid. If valid, creates 14 | * the date 15 | * 16 | * @param {String} dateString 17 | * @returns {Date} 18 | * @throws {TypeError} If invalid input string provided 19 | */ 20 | function validateCreateDate (dateString) 21 | { 22 | var date = new Date(dateString); 23 | if (date.toString() === 'Invalid Date') { 24 | throw new TypeError('Provided date ' + dateString + ' is invalid!'); 25 | } 26 | 27 | return date; 28 | } 29 | 30 | 31 | /** 32 | * Notifies management about stats of given user(s) work 33 | * 34 | * @param {Object} req The request object 35 | * @param {Object} res The response object 36 | * @param {Function} next The next callback to apply 37 | * @returns {undefined} 38 | */ 39 | function notifyManagementController (req, res, next) 40 | { 41 | var from = req.body.from || null, 42 | to = req.body.to || null, 43 | channel = req.body.channel, 44 | reportTitle = req.body.reportTitle || consts.report.DEFAULT_REPORT_TITLE, 45 | dateFromObject = from ? (function (date) { 46 | try { 47 | return validateCreateDate(date); 48 | } catch (err) { 49 | if (err instanceof TypeError) { 50 | res.success = false; 51 | res.errors = res.errors || []; 52 | res.errors.push(err.message); 53 | next(); 54 | return; 55 | } 56 | } 57 | })(from) : tools.dateFromString(consts.report.DATE_FROM_TEXT), 58 | dateToObject = to ? (function (date) { 59 | try { 60 | return validateCreateDate(date); 61 | } catch (err) { 62 | if (err instanceof TypeError) { 63 | res.success = false; 64 | res.errors = res.errors || []; 65 | res.errors.push(err.message); 66 | next(); 67 | return; 68 | } 69 | } 70 | })(to) : tools.dateFromString(consts.report.DATE_TO_TEXT) 71 | ; 72 | 73 | if (!channel) { 74 | res.success = false; 75 | res.errors = [ 76 | 'A channel must be provided in \'channel\' post field.' 77 | ]; 78 | next(); 79 | return; 80 | } 81 | 82 | res.success = true; 83 | logger.info('Preparing management report from: ' + dateFromObject + ' to ' + dateToObject, {}); 84 | notifier.notify('management', { 85 | reportTitle: reportTitle, 86 | channel: channel, 87 | fromDate: dateFromObject, 88 | toDate: dateToObject 89 | }); 90 | 91 | next(); 92 | } 93 | 94 | 95 | 96 | module.exports = function (app, config) 97 | { 98 | app.use('/api/notify-management', notifyManagementController); 99 | }; -------------------------------------------------------------------------------- /test/functional/app.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var app = require('./../../app.js'), 4 | expect = require('chai').expect, 5 | http = require('http'), 6 | codes = require('./../../app/api/codes.js'), 7 | request = require('request'), 8 | auth = require('./../../app/services/auth/lib/auth.js'), 9 | applyHandlers = require('./../../app/services/auth/lib/handlers.js') 10 | ; 11 | 12 | 13 | describe('Functional: Server response', function () { 14 | 15 | var server; 16 | 17 | before(function () { 18 | server = http.createServer(app).listen(3333); 19 | }); 20 | 21 | 22 | describe('Server UNAUTHORIZED error response', function () { 23 | 24 | it('Should show an \'access dennied\' code/message/success result, error reasons for non-authorized request/valid endpoint.', function (done) { 25 | request.post({ 26 | url : 'http://localhost:3333/api/timer' 27 | }, function (err, res, body) { 28 | 29 | var json = JSON.parse(body); 30 | expect(res.statusCode).to.be.equal(codes.UNAUTHORIZED); 31 | expect(json.code).to.be.equal(codes.UNAUTHORIZED); 32 | expect(json.success).to.be.equal(false); 33 | expect(json.errors).to.be.a('array'); 34 | expect(json.errors).to.include.members([ 35 | 'Provided token is invalid' 36 | ]); 37 | 38 | done(); 39 | }); 40 | }); 41 | 42 | it('Should show an \'access dennied\' code/message/success result, error reasons for non-authorized request/invalid endpoint.', function (done) { 43 | request.post({ 44 | url : 'http://localhost:3333/api/non-existing-endpoint' 45 | }, function (err, res, body) { 46 | 47 | var json = JSON.parse(body); 48 | expect(res.statusCode).to.be.equal(codes.UNAUTHORIZED); 49 | expect(json.code).to.be.equal(codes.UNAUTHORIZED); 50 | expect(json.success).to.be.equal(false); 51 | expect(json.errors).to.be.a('array'); 52 | expect(json.errors).to.include.members([ 53 | 'Provided token is invalid' 54 | ]); 55 | 56 | done(); 57 | }); 58 | }); 59 | }); 60 | 61 | 62 | describe('Server 404 error response', function () { 63 | 64 | it('Should show a 404 code/message if accessed url does not exist but user is authenticated', function (done) { 65 | applyHandlers(auth, { 66 | token : 'thisissomeauthtoken' 67 | }); 68 | 69 | request.post({ 70 | url : 'http://localhost:3333/api/non-existing-url', 71 | form : { 72 | token : 'thisissomeauthtoken' 73 | } 74 | }, function (err, res, body) { 75 | 76 | var json = JSON.parse(body); 77 | expect(json.success).to.be.equal(false); 78 | expect(json.code).to.be.equal(codes.NOT_FOUND); 79 | expect(res.statusCode).to.be.equal(codes.NOT_FOUND); 80 | done(); 81 | }); 82 | }); 83 | }); 84 | 85 | 86 | after(function (done) { 87 | server.close(done); 88 | }); 89 | }); -------------------------------------------------------------------------------- /app/services/auth/lib/auth.js: -------------------------------------------------------------------------------- 1 | /* 2 | * To change this license header, choose License Headers in Project Properties. 3 | * To change this template file, choose Tools | Templates 4 | * and open the template in the editor. 5 | */ 6 | 7 | var Auth = function () { 8 | this.handlers = []; 9 | }; 10 | 11 | 12 | /** 13 | * Validates registered handler 14 | * 15 | * @param {Object} handler 16 | * @returns {Object} The same given handler object 17 | * @throws {TypeError} If the handler does not implement 'validate' method 18 | */ 19 | function validateHandler (handler) 20 | { 21 | if (typeof handler.validate !== 'function') { 22 | throw new TypeError('The validator must implement method validate!'); 23 | } 24 | 25 | return handler; 26 | } 27 | 28 | 29 | /** 30 | * Combines arrays 31 | * 32 | * @param {Array} errors1 33 | * @param {Array} errors2 34 | * @returns {Array} Two arrays combined 35 | */ 36 | function mergeErrors (errors1, errors2) 37 | { 38 | if ((typeof errors1.length === undefined) || (typeof errors2.length === undefined)) { 39 | throw new TypeError('Both given params should be array-like objects or arrays!'); 40 | } 41 | for (var i = 0; i < errors2.length; i++) { 42 | var value = errors2[i]; 43 | errors1.push(value); 44 | } 45 | 46 | return errors1; 47 | } 48 | 49 | 50 | Auth.prototype = { 51 | 52 | /** 53 | * Registers auth handler 54 | * 55 | * @param {Object} A handler for checking if given request is valid 56 | */ 57 | addHandler : function (handler) 58 | { 59 | this.handlers.push(validateHandler(handler)); 60 | return this; 61 | }, 62 | 63 | 64 | /** 65 | * Clears all the handlers 66 | * 67 | * @return {Auth} This instance 68 | */ 69 | resetHandlers : function () 70 | { 71 | this.handlers = []; 72 | return this; 73 | }, 74 | 75 | 76 | /** 77 | * Validates if given request can be processed 78 | * 79 | * @param {Object} req 80 | * @returns {Boolean} 81 | */ 82 | hasAccess : function (req) 83 | { 84 | var requestErrors = []; 85 | var hasAccess = true; 86 | for (var i = 0; i < this.handlers.length; i++) { 87 | var handler = this.handlers[i]; 88 | try { 89 | handler.validate(req); 90 | } catch (err) { 91 | hasAccess = false; 92 | if (err.name === 'AuthError') { 93 | var errors = err.getErrors(); 94 | requestErrors = mergeErrors(requestErrors, errors); 95 | } else { 96 | throw err; 97 | } 98 | } 99 | } 100 | 101 | return hasAccess ? hasAccess : (function (requestErrors) { 102 | req.errors = requestErrors; 103 | return false; 104 | })(requestErrors); 105 | }, 106 | 107 | 108 | /** 109 | * Returns an array of reasons why the request was denied taken from the 110 | * request 111 | * 112 | * @param {Object} The request object 113 | * @returns {Array} 114 | */ 115 | getErrors : function (req) 116 | { 117 | return req.errors || []; 118 | } 119 | }; 120 | 121 | 122 | module.exports = new Auth(); -------------------------------------------------------------------------------- /test/unit/services/interactive_session/lib/time_parser.js: -------------------------------------------------------------------------------- 1 | /*jshint node: true*/ 2 | 'use strict'; 3 | 4 | var expect = require('chai').expect, 5 | timeParser = require('./../../../../../app/services/interactive_session/lib/time_parser.js'), 6 | _ = require('lodash') 7 | ; 8 | 9 | 10 | describe('time_parser', function () { 11 | 12 | describe('time_parser.getDefaultInstance', function () { 13 | it('Should return same instance of Parser', function () { 14 | expect(timeParser.getDefault()).to.equal(timeParser.getDefault()); 15 | expect((timeParser.getDefault()).constructor.name).to.be.equal('Parser'); 16 | }); 17 | }); 18 | 19 | describe('time_parser.createDefault', function () { 20 | it('Should not return same instance of Parser', function () { 21 | expect(timeParser.createDefault()).to.not.equal(timeParser.createDefault()); 22 | expect((timeParser.createDefault()).constructor.name).to.be.equal('Parser'); 23 | }); 24 | }); 25 | 26 | 27 | describe('time_parser.error', function () { 28 | it('Should reference the constructor of TranslatorNotFoundError', function () { 29 | expect((new timeParser.error()).constructor.name).to.be.equal('TranslatorNotFoundError'); 30 | }); 31 | }); 32 | 33 | 34 | describe('Parser.getTranslators', function () { 35 | it('Should return all registered translators', function () { 36 | expect(timeParser.getDefault().getTranslators().length).to.be.above(0); 37 | }); 38 | }); 39 | 40 | 41 | describe('Parser.parse', function () { 42 | it('Should be able to parse the default time format: HH:mm', function () { 43 | 44 | var times = [ 45 | {given : '1:00', expected : 1.0}, 46 | {given : '01:00', expected : 1.0}, 47 | {given : '01:30', expected : 1.5}, 48 | {given : '02:15', expected : 2.25} 49 | ]; 50 | 51 | _.each(times, function (timeObject) { 52 | var given = timeObject.given, 53 | expected = timeObject.expected 54 | ; 55 | 56 | expect(timeParser.getDefault().parse(given)).to.be.equal(expected); 57 | }); 58 | }); 59 | 60 | it ('Should throw TranslatorNotFoundError instance if given time string cannot be parsed', function () { 61 | var invalidTime = 'this is an invalid time string'; 62 | expect(function () { 63 | timeParser.getDefault().parse(invalidTime); 64 | }).to.throw(timeParser.error); 65 | }); 66 | }); 67 | 68 | describe('Parser.parse', function () { 69 | it('Should be able to parse the default time format: X number of seconds', function () { 70 | 71 | var times = [ 72 | {given : '1800', expected : 0.5}, 73 | {given : '3600', expected : 1.0}, 74 | {given : '5400', expected : 1.5}, 75 | {given : '8100', expected : 2.25} 76 | ]; 77 | 78 | _.each(times, function (timeObject) { 79 | var given = timeObject.given, 80 | expected = timeObject.expected 81 | ; 82 | 83 | expect(timeParser.getDefault().parse(given)).to.be.equal(expected); 84 | }); 85 | }); 86 | 87 | it ('Should throw TranslatorNotFoundError instance if given time string cannot be parsed', function () { 88 | var invalidTime = 'this is an invalid time string'; 89 | expect(function () { 90 | timeParser.getDefault().parse(invalidTime); 91 | }).to.throw(timeParser.error); 92 | }); 93 | }); 94 | 95 | }); -------------------------------------------------------------------------------- /app/services/interactive_session/lib/resolver.js: -------------------------------------------------------------------------------- 1 | /*jshint node: true*/ 2 | 'use strict'; 3 | 4 | var _ = require('lodash'), 5 | userSession = require('./user_session.js'), 6 | logger = require('./../../../services/logger.js')('default') 7 | ; 8 | 9 | function Resolver (userSession, sessionTime) 10 | { 11 | this.sessionTime = sessionTime; 12 | this.userSession = userSession; 13 | this.stepProviders = []; 14 | this.timeouts = {}; 15 | } 16 | 17 | Resolver.prototype = { 18 | 19 | 20 | /** 21 | * Sets the session time 22 | * 23 | * @param {Number} sessionTime 24 | * @returns {Resolver} This instance 25 | */ 26 | setSesstionTime : function (sessionTime) 27 | { 28 | this.sessionTime = parseInt(sessionTime); 29 | return this; 30 | }, 31 | 32 | /** 33 | * Registers a step provider 34 | * 35 | * @param {Object} stepProvider 36 | * @returns {Resolver} This instance 37 | */ 38 | addStepProvider : function (stepProvider) 39 | { 40 | this.stepProviders.push(stepProvider); 41 | return this; 42 | }, 43 | 44 | 45 | /** 46 | * Runs slack -> server dialogue step 47 | * 48 | * @param {Object} params The timer config 49 | * @param {Function} viewCallback The callback that processes the view 50 | * @returns {undefined} 51 | */ 52 | runStep : function (params, viewCallback) 53 | { 54 | var userId = params.userId, 55 | previousStep = this.userSession.hasSession(userId) ? this.userSession.getStep(userId) : null, 56 | that = this, 57 | stepProvider = null 58 | ; 59 | 60 | _.each(this.stepProviders, function (provider) { 61 | if (stepProvider !== null) { 62 | return; 63 | } 64 | 65 | if (provider.validate(params, previousStep)) { 66 | stepProvider = provider; 67 | } 68 | }); 69 | 70 | if (stepProvider !== null) { 71 | stepProvider.execute(params, previousStep, function (err, view, newStep) { 72 | if (err === null) { 73 | if (newStep !== null) { 74 | that.userSession.addStep(userId, newStep); 75 | that.addTimeout(userId); 76 | } 77 | viewCallback(null, view); 78 | } else { 79 | viewCallback(err, view); 80 | } 81 | }); 82 | } 83 | }, 84 | 85 | 86 | /** 87 | * Adds a timeout to clear user session for given userId 88 | * 89 | * @param {Number} userId 90 | * @returns {undefined} 91 | */ 92 | addTimeout : function (userId) 93 | { 94 | this.clearTimeout(userId); 95 | this.timeouts[userId] = setTimeout(function () { 96 | userSession 97 | .getDefault() 98 | .clear(userId) 99 | ; 100 | logger.info('Cleared user session for userId ' + userId, {}); 101 | }, this.sessionTime); 102 | logger.info('Added user session for userId ' + userId, {}); 103 | }, 104 | 105 | 106 | /** 107 | * Clears the timeout for given userId 108 | * 109 | * @param {Number} userId 110 | * @returns {undefined} 111 | */ 112 | clearTimeout : function (userId) 113 | { 114 | if (!!this.timeouts[userId]) { 115 | clearTimeout(this.timeouts[userId]); 116 | delete this.timeouts[userId]; 117 | } 118 | } 119 | }; 120 | 121 | Resolver.prototype.constructor = Resolver; 122 | 123 | 124 | module.exports = Resolver; // The constructor -------------------------------------------------------------------------------- /app/services/report/lib/view_builder.js: -------------------------------------------------------------------------------- 1 | /*jshint node: true*/ 2 | 'use strict'; 3 | 4 | var _ = require('lodash'), 5 | tools = require('./../../tools.js'); 6 | 7 | 8 | 9 | /** 10 | * Formats the summary of the projects to message string 11 | * 12 | * @param {Object} summary 13 | * @returns {String} 14 | */ 15 | function formatSummary (summary) 16 | { 17 | var records = []; 18 | var totalTime = 0; 19 | _.each(summary, function (project) { 20 | var responsePart = [ 21 | project.clientName, 22 | project.projectName, 23 | tools.formatTime(project.time) 24 | ].join(' - '); 25 | totalTime += project.time; 26 | records.push(responsePart); 27 | }); 28 | 29 | records.push('Total: ' + tools.formatTime(totalTime)); 30 | 31 | return records.join('\n'); 32 | } 33 | 34 | 35 | /** 36 | * 37 | * @param {Object} dayEntries 38 | * @param {Object} clientsById 39 | * @param {Object} projectsById 40 | * 41 | * @returns {String} 42 | */ 43 | function projectsSummary(dayEntries, clientsById, projectsById) 44 | { 45 | var summary = {}; 46 | _.each(dayEntries, function (dayEntryObject) { 47 | var dayEntry = dayEntryObject.day_entry, 48 | projectId = dayEntry.project_id, 49 | project = projectsById[projectId] || null, 50 | client = (project && clientsById[project.client_id]) ? clientsById[project.client_id] : null; 51 | 52 | if (!summary[projectId]) { 53 | summary[projectId] = { 54 | projectName : project ? project.name : dayEntry.project_id, 55 | clientName : client ? client.name : "Unknown client", 56 | time : tools.getHours(dayEntry) 57 | }; 58 | } else { 59 | summary[projectId].time = summary[projectId].time + tools.getHours(dayEntry); 60 | } 61 | }); 62 | 63 | return formatSummary(summary); 64 | 65 | } 66 | 67 | 68 | /** 69 | * Provides a full report message for given user 70 | * (message part of overall report) 71 | * 72 | * @param {Object} userEntriesObject 73 | * @param {Object} clientsById 74 | * @param {Object} projectsById 75 | * 76 | * @returns {String} 77 | */ 78 | function userReport (userEntriesObject, clientsById, projectsById) 79 | { 80 | var resultsRow = { 81 | title : userEntriesObject.slackName, 82 | text : projectsSummary(userEntriesObject.dayEntries, clientsById, projectsById), 83 | mrkdwn_in : ["text", "title"], 84 | color: "FFA200" 85 | }; 86 | 87 | return resultsRow; 88 | } 89 | 90 | /** 91 | * Builds the view for report messages that will be sent to slack management 92 | * channels 93 | * 94 | * @author Maciej Garycki 95 | */ 96 | module.exports = { 97 | 98 | /** 99 | * Returns a complete message for given data 100 | * 101 | * @param {Object} data 102 | * @return {Array} The array of attachments 103 | */ 104 | prepareView : function (data) 105 | { 106 | 107 | var clientsById = data.clientsById, 108 | projectsById = data.projectsById, 109 | dayEntries = data.dayEntries, 110 | results = [] 111 | ; 112 | 113 | _.each(dayEntries, function (dayEntriesObject) { 114 | results.push(userReport(dayEntriesObject, clientsById, projectsById)); 115 | }); 116 | 117 | return results; 118 | }, 119 | 120 | /** 121 | * Returns the title of the report 122 | * 123 | * @param {Object} data 124 | * @returns {String} 125 | */ 126 | prepareTitle : function (data) 127 | { 128 | return '*' + data.title + '*\n'; 129 | } 130 | }; -------------------------------------------------------------------------------- /app/services/logger.js: -------------------------------------------------------------------------------- 1 | /*jshint node: true*/ 2 | 'use strict'; 3 | 4 | var Syslogh = require('syslogh'), 5 | _ = require('lodash'), 6 | uuid = require('node-uuid'), 7 | util = require('util'), 8 | moment = require('moment'), 9 | winston = require('winston'); 10 | 11 | function Logger(config) { 12 | this.logger = null; 13 | this.config = config; 14 | 15 | this._init(); 16 | } 17 | 18 | /** 19 | * Setup Logger transports and configuration 20 | * 21 | * @private 22 | */ 23 | Logger.prototype._init = function () { 24 | // prepare logger transports 25 | var transports = []; 26 | 27 | if (this.config.file) { 28 | transports.push(new winston.transports.File({ 29 | filename: this.config.file, 30 | json: true, 31 | stringify: function (obj) { 32 | return JSON.stringify(obj); 33 | } 34 | })); 35 | } 36 | 37 | // add console transport if defined 38 | if (this.config.console) { 39 | transports.push(new (winston.transports.Console)({ 40 | json: true, 41 | stringify: function (obj) { 42 | return JSON.stringify(obj, null, '\t'); 43 | } 44 | })); 45 | } 46 | 47 | this.logger = new (winston.Logger)({ 48 | transports: transports 49 | }); 50 | 51 | // enable syslog if defined 52 | if (this.config.syslog) { 53 | Syslogh.openlog(this.config.processName, Syslogh.PID, Syslogh.LOCAL0); 54 | } 55 | }; 56 | 57 | /** 58 | * Basic logging metadata 59 | */ 60 | Logger.prototype._getLogBasicInfo = function () { 61 | return { 62 | created_at: moment().format(this.config.date_format), 63 | app: this.config.appHostname, 64 | process_name: this.config.processName, 65 | pid: process.pid 66 | }; 67 | }; 68 | 69 | /** 70 | * @param {Object} args 71 | * @param {Object} extendData 72 | * @returns {Object} 73 | */ 74 | Logger.prototype._extendLastArgument = function (args, extendData) { 75 | var lastArgument = args[args.length - 1]; 76 | if (_.isObject(lastArgument)) { 77 | args[args.length - 1] = _.extend({}, extendData, lastArgument); 78 | } 79 | 80 | return args; 81 | }; 82 | 83 | /** 84 | * @returns {String} 85 | */ 86 | Logger.prototype.generateLogId = function () { 87 | return uuid.v4(); 88 | }; 89 | 90 | /** 91 | * Log info 92 | */ 93 | Logger.prototype.info = function () { 94 | var modifiedArgs = this._extendLastArgument(arguments, this._getLogBasicInfo()); 95 | this.logger.info.apply(this.logger, modifiedArgs); 96 | }; 97 | 98 | /** 99 | * Log warn 100 | */ 101 | Logger.prototype.warn = function () { 102 | var modifiedArgs = this._extendLastArgument(arguments, this._getLogBasicInfo()); 103 | this.logger.warn.apply(this.logger, modifiedArgs); 104 | }; 105 | 106 | /** 107 | * Log error 108 | */ 109 | Logger.prototype.error = function () { 110 | if (this.config.syslog) 111 | Syslogh.syslog(Syslogh.ERR, util.format.apply(util, arguments)); 112 | 113 | var modifiedArgs = this._extendLastArgument(arguments, this._getLogBasicInfo()); 114 | this.logger.error.apply(this.logger, modifiedArgs); 115 | }; 116 | 117 | /** 118 | * Log log 119 | */ 120 | Logger.prototype.log = function () { 121 | var modifiedArgs = this._extendLastArgument(arguments, this._getLogBasicInfo()); 122 | this.logger.log.apply(this.logger, modifiedArgs); 123 | }; 124 | 125 | 126 | /** 127 | * 128 | * @type {Object} The instances of the logger 129 | */ 130 | var instances = {}; 131 | 132 | 133 | /** 134 | * Exports the logger factory 135 | * 136 | * @param {String} key 137 | * @param {Object} config 138 | * @returns {Logger} 139 | */ 140 | module.exports = function (key, config) { 141 | if (!!instances[key]) { 142 | return instances[key]; 143 | } else { 144 | instances[key] = new Logger(config); 145 | return instances[key]; 146 | } 147 | }; 148 | -------------------------------------------------------------------------------- /test/unit/services/reminder/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var expect = require('chai').expect, 4 | slack = require('./../../../../app/services/slack')('default'), 5 | harvest = require('./../../../../app/services/harvest')('default'), 6 | requestMock, 7 | dayEntriesMock, 8 | reminder = require('./../../../../app/services/reminder/index.js') 9 | ; 10 | 11 | 12 | dayEntriesMock = { 13 | some_user1 : [], 14 | some_user2 : [ 15 | { 16 | day_entry: { 17 | id: 311036476, 18 | notes: '', 19 | spent_at: '2015-03-16', 20 | hours: 1.6, 21 | user_id: 449849, 22 | project_id: 2, 23 | task_id: 1815946, 24 | created_at: '2015-03-16T15:00:02Z', 25 | updated_at: '2015-03-17T08:28:27Z', 26 | adjustment_record: false, 27 | timer_started_at: null, 28 | is_closed: false, 29 | is_billed: false 30 | } 31 | }, 32 | { 33 | day_entry: { 34 | id: 310859756, 35 | notes: '', 36 | spent_at: '2015-03-16', 37 | hours: 6.55, 38 | user_id: 449849, 39 | project_id: 2, 40 | task_id: 1815946, 41 | created_at: '2015-03-16T08:27:20Z', 42 | updated_at: '2015-03-16T15:00:02Z', 43 | adjustment_record: false, 44 | timer_started_at: null, 45 | is_closed: false, 46 | is_billed: false 47 | } 48 | } 49 | ], 50 | some_user3 : [] 51 | }; 52 | 53 | 54 | describe('reminder', function () { 55 | 56 | describe('remind', function () { 57 | 58 | it('Should send reminder messages to all people whose harvest day entries reports are empty.', function (done) { 59 | var users = { 60 | '12345' : 'some_user1', 61 | '23456' : 'some_user2', 62 | '34567' : 'some_user3' 63 | }, 64 | counter = 0, 65 | sendMessage = slack.sendMessage 66 | ; 67 | 68 | harvest.users = users; 69 | 70 | slack.sendMessage = function (text, config, callback) 71 | { 72 | expect(text).to.be.equal([ 73 | 'You have no tasks running on *Harvest*!', 74 | 'Click here to add them or use the timer command on Slack' 75 | ].join('\n')); 76 | callback(null, true, ''); 77 | }; 78 | 79 | harvest.harvest.client.get = function (url, data, cb) { 80 | counter++; 81 | var userId = (function (url) { 82 | var split = url.split('/entries'); 83 | return split[0].split('/people/')[1]; 84 | })(url), 85 | slackName = users[userId], 86 | entries = dayEntriesMock[slackName] 87 | ; 88 | cb(null, entries); 89 | }; 90 | 91 | reminder.remind(users, null, function (results) { 92 | 93 | slack.sendMessage = sendMessage; 94 | expect(counter).to.be.equal(3); 95 | expect(results).to.be.a('object'); 96 | expect(results.successes).to.be.a('object'); 97 | expect(results.errors).to.be.a('object'); 98 | expect(results.notified).to.be.a('object'); 99 | 100 | 101 | expect(results.successes).to.be.deep.equal({ 102 | '12345' : 'some_user1', 103 | '23456' : 'some_user2', 104 | '34567' : 'some_user3' 105 | }); 106 | 107 | expect(results.notified).to.be.deep.equal({ 108 | '12345' : 'some_user1', 109 | '34567' : 'some_user3' 110 | }); 111 | 112 | expect(results.errors).to.be.deep.equal({}); 113 | 114 | done(); 115 | }); 116 | }); 117 | }); 118 | }); 119 | 120 | -------------------------------------------------------------------------------- /app/services/slack/notifier/index.js: -------------------------------------------------------------------------------- 1 | /*jshint node: true*/ 2 | 'use strict'; 3 | 4 | var _ = require('lodash'), 5 | events = require("events"), 6 | logger = require('./../../logger.js')('default'), 7 | tools = require('./../../tools.js'); 8 | 9 | 10 | /** 11 | * Fetches the user name 12 | * 13 | * @param {Object} users A map of harvest id -> slack id 14 | * @param {Number} harvestUserId 15 | * @returns {String} 16 | */ 17 | function getUserName (users, harvestUserId) 18 | { 19 | var response; 20 | for (var harvestId in users) { 21 | if (String(harvestId) === String(harvestUserId)) { 22 | response = users[harvestId]; 23 | } 24 | } 25 | 26 | return response; 27 | } 28 | 29 | /** 30 | * Sends notifications via slack 31 | * 32 | * @author Maciej Garycki 33 | * 34 | * @param {Object} slack The slack object 35 | * @param {Object} harvest The harvest object 36 | * @constructor 37 | */ 38 | function SlackNotifier (slack, harvest) 39 | { 40 | this.slack = slack; 41 | this.harvest = harvest; 42 | } 43 | 44 | 45 | function formatResponse (dayEntries, projects, clients) 46 | { 47 | var response = [ 48 | "*Your time tracked today*:" 49 | ]; 50 | 51 | 52 | var clientsById = tools.byId(clients || {}, 'client'); 53 | var projectsById = tools.byId(projects || {}, 'project'); 54 | 55 | _.each(dayEntries, function (resourceObject) { 56 | 57 | var resource = resourceObject.day_entry; 58 | var project = projectsById[resource.project_id] || null; 59 | var client = (project && !!clientsById[project.client_id]) ? clientsById[project.client_id] : null; 60 | var responsePart = [ 61 | (client ? client.name : "Unknown client"), 62 | (project ? project.name : resource.project_id), 63 | tools.formatTime(tools.getHours(resource)) 64 | ].join(' - '); 65 | 66 | response.push(responsePart); 67 | }); 68 | 69 | response.push('\n'); 70 | response.push('If anything is missing, add it here <' + SlackNotifier.prototype.LINK + '>' ) 71 | 72 | return response.join("\n"); 73 | } 74 | 75 | 76 | var SlackNotifierPrototype = function () 77 | { 78 | this.LINK = "https://neverbland.harvestapp.com/time"; 79 | 80 | 81 | /** 82 | * Sends notification to slack 83 | * 84 | * @param {Object} slackContext 85 | * @returns {undefined} 86 | */ 87 | this.notify = function (slackContext) 88 | { 89 | var userName = getUserName(this.slack.users, slackContext.harvestUserId); 90 | this.prepareText(userName, slackContext.harvestResponse); 91 | 92 | }; 93 | 94 | 95 | 96 | /** 97 | * prepares the text and triggers propper event when ready 98 | * 99 | * @param {String} userName 100 | * @param {Array} An array of day entries 101 | * @returns {undefined} 102 | */ 103 | this.prepareText = function (userName, dayEntries) 104 | { 105 | var that = this; 106 | var projectsIds = tools.getIds(dayEntries, 'day_entry', 'project_id'); 107 | this.harvest.getProjectsByIds(projectsIds, function (err, projects) { 108 | if (err === null) { 109 | var clientsIds = tools.getIds(projects, 'project', 'client_id'); 110 | that.harvest.getClientsByIds(clientsIds, function (err, clients) { 111 | if (err === null) { 112 | that.emit('responseReady', { 113 | userName : userName, 114 | text : formatResponse(dayEntries, projects, clients) 115 | }); 116 | } else{ 117 | logger.error('Failed fetching clients for given clients ids', clientsIds, {}); 118 | } 119 | }); 120 | } else { 121 | logger.error('Failed fetching projects for given projects ids', projectsIds, {}); 122 | } 123 | }); 124 | }; 125 | 126 | 127 | this.responseReadyHandler = function (data) 128 | { 129 | var that = this; 130 | that.slack.sendMessage(data.text, { 131 | channel : '@' + data.userName 132 | }, function (err, httpResponse, body) { 133 | if (err === null) { 134 | logger.info('Successfully sent a reminder message to user ' + data.userName, {}); 135 | } else { 136 | logger.info('Reminder for user ' + data.userName + ' not sent. Error: ', err, {}); 137 | } 138 | }); 139 | }; 140 | 141 | 142 | // Send the message when all content populated and the text is prepared 143 | this.on('responseReady', this.responseReadyHandler); 144 | }; 145 | 146 | SlackNotifierPrototype.prototype = new events.EventEmitter(); 147 | 148 | 149 | SlackNotifier.prototype = new SlackNotifierPrototype(); 150 | SlackNotifier.prototype.constructor = SlackNotifier; 151 | 152 | var instance = null; 153 | 154 | module.exports = function (slack, harvest) { 155 | instance = new SlackNotifier(slack, harvest); 156 | module.exports.instance = instance; 157 | return instance; 158 | }; 159 | -------------------------------------------------------------------------------- /test/unit/services/interactive_session/lib/user_session.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var expect = require('chai').expect, 4 | user_session = require('./../../../../../app/services/interactive_session/lib/user_session.js'), 5 | _ = require('lodash') 6 | ; 7 | 8 | 9 | describe('user_session', function () { 10 | 11 | describe('user_session.create', function () { 12 | it('Should create a new instance of UserSession', function () { 13 | expect(user_session.create()).to.not.equal(user_session.create()); 14 | }); 15 | }); 16 | 17 | describe('user_session.getDefault', function () { 18 | it('Should return same instance of UserSession', function () { 19 | expect(user_session.getDefault()).to.equal(user_session.getDefault()); 20 | }); 21 | }); 22 | 23 | describe('user_session.error', function () { 24 | it('Should reference the constructor of InvalidOptionError', function () { 25 | expect((new user_session.error()).constructor.name).to.be.equal('InvalidOptionError'); 26 | }); 27 | }); 28 | 29 | 30 | describe('user_session.UserSession.createStep', function () { 31 | it('Should create a new UserSessionStep instance', function () { 32 | var action = 'action_name', 33 | userId = 12345, 34 | options = { 35 | some : 'option' 36 | }, 37 | step = user_session.getDefault().createStep(userId, options, action); 38 | ; 39 | expect(step.constructor.name).to.be.equal('UserSessionStep'); 40 | }); 41 | }); 42 | 43 | 44 | describe('user_session.UserSession.addStep', function () { 45 | it('Should add an UserSessionStep instance to user session', function () { 46 | var action = 'action_name', 47 | userSession = user_session.getDefault(), 48 | session, 49 | userId = 12345, 50 | options = { 51 | some : 'option' 52 | }, 53 | step = userSession.createStep(userId, options, action); 54 | ; 55 | 56 | session = userSession.addStep(userId, step); 57 | expect(session.countSteps(userId)).to.equal(1); 58 | expect(session).to.equal(userSession); 59 | expect(session.hasSession(userId)).to.equal(true); 60 | }); 61 | }); 62 | 63 | 64 | describe('user_session.UserSession.getStep', function () { 65 | it('Should return an UserSessionStep instance', function () { 66 | var userSession = user_session.getDefault(), 67 | userId = 12345, 68 | step = userSession.getStep(userId) 69 | ; 70 | expect(step).to.equal(userSession.getStep(userId, 1)); 71 | expect(step.constructor.name).to.be.equal('UserSessionStep'); 72 | }); 73 | }); 74 | 75 | 76 | describe('user_session.UserSession.clear', function () { 77 | it('Should clear all steps for the session and return the same instance of user session object', function () { 78 | var userSession = user_session.getDefault(), 79 | session, 80 | userId = 12345 81 | ; 82 | 83 | session = userSession.clear(userId); 84 | expect(session).to.equal(userSession); 85 | }); 86 | }); 87 | 88 | 89 | describe('user_session.UserSessionStep', function () { 90 | it('Should return a valid option, param etc.', function () { 91 | var action = 'action_name', 92 | userSession = user_session.getDefault(), 93 | userId = 12345, 94 | sameStep, 95 | options = { 96 | some : 'option' 97 | }, 98 | step1, step2 99 | ; 100 | 101 | step1 = userSession.createStep(userId, options, action); 102 | userSession.addStep(userId, step1); 103 | step2 = userSession.createStep(userId, options, action); 104 | userSession.addStep(userId, step2); 105 | 106 | sameStep = step1.addParam('foo', 'bar'); 107 | expect(sameStep).to.equal(step1); 108 | 109 | expect(step1.getParam('foo')).to.equal('bar'); 110 | step1.clearParam('foo'); 111 | expect(step1.getParam('foo')).to.equal(undefined); 112 | expect(step1.getParam('stepNumber')).to.equal(1); 113 | expect(step1.getParam('previousStep')).to.equal(null); 114 | expect(step1.getParam('userId')).to.equal(userId); 115 | expect(step1.getOption('some')).to.equal('option'); 116 | expect(step1.getOptions()).to.equal(options); 117 | expect(step1.getAction()).to.equal(action); 118 | 119 | expect(step2.getParam('stepNumber')).to.equal(2); 120 | expect(step2.getParam('previousStep')).to.equal(step1); 121 | expect(step2.getParam('userId')).to.equal(userId); 122 | expect(step2.getOption('some')).to.equal('option'); 123 | expect(step2.getOptions()).to.equal(options); 124 | expect(step2.getAction()).to.equal(action); 125 | }); 126 | }); 127 | }); -------------------------------------------------------------------------------- /app/services/interactive_session/lib/time_parser.js: -------------------------------------------------------------------------------- 1 | /*jshint node: true*/ 2 | 'use strict'; 3 | 4 | var instance = null, 5 | _ = require('lodash'), 6 | defaultTranslators 7 | ; 8 | 9 | 10 | defaultTranslators = [ 11 | { 12 | /** 13 | * Retuns bool info if this translator can translate the given time 14 | * 15 | * @param {String} time 16 | * @returns {Boolean} 17 | */ 18 | canTranslate : function (time) 19 | { 20 | var regexp = /^([01]?\d|2[0-3])(:[0-5]\d){1,2}$/; 21 | return regexp.test(time); 22 | }, 23 | 24 | /** 25 | * Translates the input time string to number of hours 26 | * 27 | * @param {String} time 28 | * @returns {Number} Float value for the number of hours 29 | */ 30 | translate : function (time) 31 | { 32 | var split = time.split(':'), 33 | hours = split[0], 34 | mins = split[1], 35 | hoursInt = parseInt(hours), 36 | minsInt = parseInt(mins) 37 | ; 38 | 39 | return hoursInt + (minsInt / 60); // Number of hours + part of hour 40 | } 41 | }, 42 | { 43 | /** 44 | * Retuns bool info if this translator can translate the given time 45 | * 46 | * @param {String} time 47 | * @returns {Boolean} 48 | */ 49 | canTranslate : function (time) 50 | { 51 | var n = ~~Number(time); 52 | return String(n) === time && n >= 0; 53 | }, 54 | 55 | /** 56 | * Translates the input time string to number of hours 57 | * 58 | * @param {String} time 59 | * @returns {Number} Float value for the number of hours 60 | */ 61 | translate : function (time) 62 | { 63 | 64 | var 65 | secs = Number(time), 66 | hours = Math.floor(secs / (60 * 60)), 67 | divisorForMinutes = secs % (60 * 60), 68 | mins = Math.floor(divisorForMinutes / 60), 69 | divisorForSeconds = divisorForMinutes % 60, 70 | seconds = Math.ceil(divisorForSeconds), 71 | 72 | hoursInt = parseInt(hours), 73 | minsInt = parseInt(mins), 74 | secondsInt = parseInt(seconds) 75 | ; 76 | 77 | return hoursInt + (minsInt / 60) + (secondsInt / (60 * 60)); // Number of hours + part of hour 78 | } 79 | } 80 | ]; 81 | 82 | 83 | /** 84 | * Populates the parser instance with default time translators 85 | * 86 | * @param {Parser} parser 87 | * @returns {undefined} 88 | */ 89 | function populate (parser) 90 | { 91 | _.each(defaultTranslators, function (translator) { 92 | parser.addTranslator(translator); 93 | }); 94 | } 95 | 96 | 97 | function TranslatorNotFoundError () 98 | {} 99 | TranslatorNotFoundError.prototype = new Error(); 100 | TranslatorNotFoundError.prototype.constructor = TranslatorNotFoundError; 101 | 102 | 103 | function Parser () 104 | { 105 | 106 | var translators = []; 107 | 108 | /** 109 | * Registers a translator object 110 | * 111 | * @param {Object} translator 112 | * @returns {Parser} This instance 113 | */ 114 | this.addTranslator = function (translator) 115 | { 116 | translators.push(translator); 117 | return this; 118 | }; 119 | 120 | 121 | /** 122 | * Parses the input string to get the output 123 | * number of hours 124 | * 125 | * @param {String} time 126 | * @returns {Number} 127 | * @throws {type} description 128 | */ 129 | this.parse = function (time) 130 | { 131 | var hours = null, 132 | isDone = false 133 | ; 134 | _.each (translators, function (translator) { 135 | if (isDone || !translator.canTranslate(time)) { 136 | return; 137 | } 138 | 139 | hours = translator.translate(time); 140 | }); 141 | 142 | if (hours === null) { 143 | throw new TranslatorNotFoundError('Could not translate ' + time); 144 | } else { 145 | return hours; 146 | } 147 | }; 148 | 149 | /** 150 | * Provides a collection of all registered translators 151 | * 152 | * @returns {Array} An array of translators 153 | */ 154 | this.getTranslators = function () 155 | { 156 | return translators; 157 | }; 158 | } 159 | 160 | Parser.prototype = {}; 161 | Parser.prototype.constructor = Parser; 162 | 163 | 164 | 165 | module.exports = { 166 | 167 | /** 168 | * Returns singleton instance of parser 169 | * 170 | * @returns {Parser} 171 | */ 172 | getDefault : function () 173 | { 174 | if (instance === null) { 175 | instance = this.createDefault(); 176 | } 177 | 178 | return instance; 179 | }, 180 | 181 | 182 | /** 183 | * Creates a new instance of Parser 184 | * 185 | * @returns {Parser} 186 | */ 187 | createDefault : function () 188 | { 189 | var instance = new Parser(); 190 | populate(instance); 191 | 192 | return instance; 193 | }, 194 | 195 | error : TranslatorNotFoundError // The constructor for the error 196 | }; -------------------------------------------------------------------------------- /app/services/report/index.js: -------------------------------------------------------------------------------- 1 | /*jshint node: true*/ 2 | 'use strict'; 3 | 4 | var Q = require('q'), 5 | events = require('events'), 6 | logger = require('./../logger.js')('default'), 7 | _ = require('lodash'), 8 | tools = require('./../tools.js'), 9 | viewBuilder = require('./lib/view_builder.js'); 10 | 11 | 12 | 13 | function validate (config, field) 14 | { 15 | if (!!config.field) { 16 | throw new Error('The field ' + field + ' is not present in the config!'); 17 | } 18 | 19 | return config[field]; 20 | } 21 | 22 | 23 | /** 24 | * Reports provides a way to generate a full report of activity for users and send 25 | * it on given channel on Slack 26 | * 27 | * @param {Object} slack The slack object 28 | * @param {Object} harvest The harvest object 29 | * @param {Object} viewBuilder Provides the report string 30 | * @constructor 31 | * @returns {undefined} 32 | */ 33 | function Report (slack, harvest, viewBuilder) 34 | { 35 | this.slack = slack; 36 | this.harvest = harvest; 37 | this.viewBuilder = viewBuilder; 38 | } 39 | 40 | 41 | /** 42 | * Provides ids from combined collection of day entries 43 | * 44 | * @param {Array} entries An array of entry objects 45 | * 46 | * @param {String} mainKey The property under which the object is 47 | * stored in single object resource. 48 | * Fot clients client, for day resource 49 | * - day_resource, etc. 50 | * 51 | * @param {String} indexKey The index of the id to be returned 52 | * 53 | * @returns {Array} An array of integer numbers 54 | */ 55 | function getIdsFromCombined (entries, mainKey, indexKey) 56 | { 57 | var ids = []; 58 | _.each(entries, function (userObject) { 59 | if (!userObject.error) { 60 | _.each(userObject.dayEntries, function (entryObject) { 61 | var entry = entryObject[mainKey]; 62 | ids.push(entry[indexKey]); 63 | }); 64 | } 65 | }); 66 | 67 | return ids; 68 | } 69 | 70 | 71 | function ReportPrototype () 72 | { 73 | this.notify = function (slackContext) 74 | { 75 | var users = slackContext.users || this.harvest.users; 76 | 77 | var promises = []; 78 | var that = this; 79 | _.each(users, function (slackName, harvestId) { 80 | var def = Q.defer(); 81 | that.harvest.getUserTimeTrack(harvestId, slackContext.fromDate, slackContext.toDate, function (err, dayEntries) { 82 | if (err !== null) { 83 | logger.error("Failed fetching user timeline from Harvest API for user " + harvestId, err, {}); 84 | def.resolve({ 85 | dayEntries : dayEntries, 86 | slackName : slackName, 87 | harvestId : harvestId, 88 | error : err 89 | }); 90 | } else { 91 | def.resolve({ 92 | dayEntries : dayEntries, 93 | slackName : slackName, 94 | harvestId : harvestId, 95 | error : false 96 | }); 97 | } 98 | }); 99 | promises.push(def.promise); 100 | }); 101 | 102 | Q.all(promises).then(function (dayEntries) { 103 | var projectsIds = getIdsFromCombined(dayEntries, 'day_entry', 'project_id'); 104 | that.harvest.getProjectsByIds(projectsIds, function (err, projects) { 105 | if (err === null) { 106 | var clientsIds = tools.getIds(projects, 'project', 'client_id'); 107 | that.harvest.getClientsByIds(clientsIds, function (err, clients) { 108 | if (err === null) { 109 | that.emit('responseReady', { 110 | dayEntries : dayEntries, 111 | clientsById : tools.byId(clients, 'client'), 112 | projectsById : tools.byId(projects, 'project'), 113 | users : users, 114 | channel : slackContext.channel, 115 | title : slackContext.reportTitle 116 | }); 117 | } else{ 118 | logger.error('Failed fetching clients for given clients ids', clientsIds, {}); 119 | } 120 | }); 121 | } else { 122 | logger.error('Failed fetching projects for given projects ids', projectsIds, {}); 123 | } 124 | }); 125 | }); 126 | }; 127 | 128 | 129 | this.responseReadyHandler = function (data) 130 | { 131 | var attachments = this.viewBuilder.prepareView(data), 132 | title = this.viewBuilder.prepareTitle(data); 133 | 134 | this.slack.sendMessage(title, { 135 | channel : data.channel, 136 | attachments : attachments 137 | }, function (err, httpResponse, body) { 138 | if (err === null) { 139 | logger.info('Successfully sent a report message to channel ' + data.channel, {}); 140 | } else { 141 | logger.info('Report for channel ' + data.channel + ' not sent. Error: ', err, {}); 142 | } 143 | }); 144 | }; 145 | 146 | 147 | // Send the message when all content populated and the text is prepared 148 | this.on('responseReady', this.responseReadyHandler); 149 | }; 150 | 151 | 152 | ReportPrototype.prototype = new events.EventEmitter(); 153 | Report.prototype = new ReportPrototype(); 154 | 155 | 156 | module.exports = function (slack, harvest, builder) { 157 | return new Report(slack, harvest, (builder || viewBuilder)); 158 | } 159 | -------------------------------------------------------------------------------- /app/services/tools.js: -------------------------------------------------------------------------------- 1 | /*jshint node: true*/ 2 | 'use strict'; 3 | 4 | var _ = require('lodash'); 5 | require('date-util'); 6 | 7 | if (typeof Object.size === 'undefined') { 8 | Object.size = function(obj) { 9 | var size = 0, key; 10 | for (key in obj) { 11 | if (obj.hasOwnProperty(key)) size++; 12 | } 13 | return size; 14 | }; 15 | } 16 | 17 | module.exports = { 18 | 19 | /** 20 | * Formats the time spent on project 21 | * 22 | * @param {Number} timeFloatValue Float value of hours spent 23 | * @returns {String} 24 | */ 25 | formatTime: function (timeFloatValue) 26 | { 27 | return [ 28 | (function (totalSec) { 29 | var hours = parseInt( totalSec / 3600 ); 30 | var minutes = parseInt( totalSec / 60 ) % 60; 31 | var result = (hours < 10 ? "0" + hours : hours) + ":" + (minutes < 10 ? "0" + minutes : minutes); 32 | 33 | return result; 34 | })(timeFloatValue * 3600) // Multiply by number of seconds per hour 35 | ].join(' '); 36 | }, 37 | 38 | 39 | /** 40 | * 41 | * @param {Array} entries An array of entry objects 42 | * 43 | * @param {String} mainKey The property under which the object is 44 | * stored in single object resource. 45 | * For clients client, for day resource 46 | * - day_resource, etc. 47 | * 48 | * @param {String} indexKey The index of the id to be returned 49 | * 50 | * @returns {Array} An array of integer numbers 51 | */ 52 | getIds: function (entries, mainKey, indexKey) 53 | { 54 | var ids = []; 55 | _.each(entries, function (entryObject) { 56 | var entry = entryObject[mainKey], 57 | id = entry[indexKey]; 58 | if (_.indexOf(ids, id) === -1) { 59 | ids.push(id); 60 | } 61 | }); 62 | 63 | return _.sortBy(ids); 64 | }, 65 | 66 | 67 | 68 | /** 69 | * Orders given input collection array by object id simplifying it's 70 | * structure by removing the leading mainKey 71 | * 72 | * @param {Array} entries An array of entry objects 73 | * @param {String} mainKey The property under which the object is 74 | * stored in single object resource. 75 | * For clients client, for day resource 76 | * - day_resource, etc. 77 | * 78 | * @returns {Object} Given entries by their id property 79 | */ 80 | byId : function (entries, mainKey) 81 | { 82 | var results = {}; 83 | _.each(entries, function (entryObject) { 84 | var entry = entryObject[mainKey]; 85 | var id = entry.id; 86 | results[id] = entry; 87 | }); 88 | 89 | return results; 90 | }, 91 | 92 | 93 | /** 94 | * Returns the hours time for day entry resource 95 | * 96 | * @param {Object} resource The day entry resource 97 | * @returns {Number} 98 | */ 99 | getHours : function (resource) 100 | { 101 | var regularTime = resource.hours; 102 | var timeWithTimer = !!resource.hours_with_timer ? resource.hours_with_timer : 0; 103 | 104 | return Math.max(regularTime, timeWithTimer); 105 | }, 106 | 107 | 108 | /** 109 | * 110 | * @param {type} timeString 111 | * @returns {Date}Creates date object from string 112 | * 113 | * @param {String} 114 | * @return {Date} 115 | */ 116 | dateFromString : function (timeString) 117 | { 118 | var date = new Date().strtotime(timeString); 119 | if (date instanceof Date) { 120 | return date; 121 | } 122 | if (parseInt(date) !== NaN) { 123 | return new Date(date); 124 | } else { 125 | return date; 126 | } 127 | }, 128 | 129 | 130 | /** 131 | * Validates if the param exists within the object and returns 132 | * the value. If it doesn't exist, throws an error. 133 | * 134 | * @param {Object} obj 135 | * @param {String} param 136 | * @param {String} errorMessage 137 | * @returns {Object} 138 | * @throws {Error} If param does not exist within the object 139 | */ 140 | validateGet : function (obj, param, errorMessage) 141 | { 142 | if (typeof obj[param] === 'undefined') { 143 | errorMessage = errorMessage || 'Param ' + param + ' does not exist.'; 144 | throw new Error(errorMessage); 145 | } 146 | 147 | return obj[param]; 148 | }, 149 | 150 | 151 | /** 152 | * Validates if correct user has been sent 153 | * 154 | * @param {Object} users A map of harvest id -> slack name of all 155 | * configured users 156 | * @param {String} userId Either harvest id or slack name 157 | * @returns {Object} A hashmap of harvestId -> slackName 158 | * @throws {Error} If invalid user provided 159 | */ 160 | validateGetUser : function (users, userId) 161 | { 162 | var userMap = {}, 163 | found = false 164 | ; 165 | 166 | _.each(users, function (slackName, harvestId) { 167 | if ((String(harvestId) === String(userId)) || (String(slackName) === String(userId))) { 168 | userMap[harvestId] = slackName; 169 | found = true; 170 | } 171 | }); 172 | 173 | if (!found) { 174 | throw new Error('Invalid user provided.'); 175 | } else { 176 | return userMap; 177 | } 178 | } 179 | 180 | }; -------------------------------------------------------------------------------- /app/services/timer.js: -------------------------------------------------------------------------------- 1 | /*jshint node: true*/ 2 | 'use strict'; 3 | 4 | var _ = require('lodash'), 5 | timer 6 | ; 7 | 8 | 9 | /** 10 | * Finds if project name or client name for given entry match the 11 | * given name 12 | * 13 | * @param {String} name 14 | * @param {Object} entry 15 | * @param {Array} fields An array of strings containing field names to check 16 | * @returns {Boolean} 17 | */ 18 | function entryPropertiesMatches (name, entry, fields) 19 | { 20 | var lowerName = String(name).toLowerCase(), 21 | regexp = new RegExp(lowerName, 'ig'), 22 | anyMatching = false 23 | ; 24 | _.each(fields, function (field) { 25 | var value = entry[field]; 26 | if (regexp.test(String(value).toLowerCase())) { 27 | anyMatching = true; 28 | } 29 | }); 30 | return anyMatching; 31 | } 32 | 33 | 34 | timer = { 35 | 36 | validActions : [], 37 | 38 | /** 39 | * Sets valid actions 40 | * 41 | * @param {Array} Array of strings 42 | * @return {timer} This instance 43 | */ 44 | setAvailableActions : function (actions) 45 | { 46 | this.validActions = actions; 47 | return this; 48 | }, 49 | 50 | 51 | validateAction : function (action) 52 | { 53 | if (this.validActions.indexOf(action) === -1) { 54 | throw new Error('Invalid action ' + action + ' provided!'); 55 | } 56 | 57 | return action; 58 | }, 59 | 60 | 61 | /** 62 | * Takes the timer command text and changes it tnto a bunch of parameters 63 | * for the projects 64 | * 65 | * @param {String} postText 66 | * @returns {Object} An object containing all required data 67 | * to start/stop a timer 68 | * @throws {Error} If not all parts of the command are provided 69 | */ 70 | parseTimerConfig : (function () { 71 | 72 | var clear = function (inputArray) 73 | { 74 | var results = []; 75 | _.each(inputArray, function (value) { 76 | var clean = value.trim(); 77 | if (clean.length) { 78 | results.push(clean); 79 | } 80 | }); 81 | 82 | return results; 83 | }; 84 | 85 | return function (postText) 86 | { 87 | var config = {}, 88 | parts = postText.split(' '), 89 | partsBase = postText.split(' '), 90 | cleanParts = clear(parts); 91 | 92 | if (cleanParts.length < 1) { 93 | throw new Error("Invalid number of paramerers provided! Required at least one parameter!"); 94 | } 95 | try { 96 | config.action = this.validateAction(parts.shift()); 97 | config.name = clear(parts).join(' '); 98 | } catch (err) { 99 | config.action = null; 100 | config.value = clear(partsBase).join(' '); 101 | } 102 | 103 | return config; 104 | }; 105 | })(), 106 | 107 | /** 108 | * Finds day entries which project/client/task matches name 109 | * 110 | * @param {String} name 111 | * @param {Array} dailyEntries 112 | * @returns {Array} 113 | */ 114 | findMatchingEntries : function (name, dailyEntries) 115 | { 116 | var matching = []; 117 | _.each(dailyEntries, function (entry) { 118 | if (entryPropertiesMatches(name, entry, ['client', 'project', 'task'])) { 119 | matching.push(entry); 120 | } 121 | }); 122 | 123 | return matching; 124 | }, 125 | 126 | 127 | /** 128 | * Finds projects entries matching name 129 | * 130 | * @param {String} name 131 | * @param {Array} dailyEntries 132 | * @returns {Array} 133 | */ 134 | findMatchingClientsOrProjects : function (name, projects) 135 | { 136 | var matching = []; 137 | _.each(projects, function (project) { 138 | if (entryPropertiesMatches(name, project, ['client', 'name'])) { 139 | matching.push({ 140 | client : project.client, 141 | project : project.name, 142 | clientId : project.client_id, 143 | projectId : project.id 144 | }); 145 | } 146 | }); 147 | 148 | return matching; 149 | }, 150 | 151 | 152 | /** 153 | * Returns tasks for matching project id or an empty object 154 | * if no project matches the project id. 155 | * 156 | * @param {Number} projectId 157 | * @param {Object} projects 158 | * @returns {Object} 159 | */ 160 | getProjectTasks : function (projectId, projects) 161 | { 162 | var tasks = null; 163 | _.each(projects, function (project) { 164 | if (parseInt(project.id) === parseInt(projectId)) { 165 | tasks = project.tasks; 166 | } 167 | }); 168 | 169 | return tasks ? tasks : {}; 170 | }, 171 | 172 | 173 | /** 174 | * Provides daily entry id for given task id from given day entries 175 | * 176 | * @param {Number} taskId 177 | * @param {Number} projectId 178 | * @param {Array} dailyEntries 179 | * @returns {Object|null} 180 | */ 181 | getDailyEntry : function (taskId, projectId, dailyEntries) 182 | { 183 | var entry = null; 184 | _.each(dailyEntries, function (dailyEntry) { 185 | if (parseInt(dailyEntry.task_id) === parseInt(taskId) && parseInt(dailyEntry.project_id) === parseInt(projectId)) { 186 | entry = dailyEntry; 187 | } 188 | }); 189 | 190 | return entry; 191 | }, 192 | 193 | 194 | /** 195 | * Checks if given taskId is in the currently running daily entries 196 | * 197 | * @param {Number} taskId 198 | * @param {Number} projectId 199 | * @param {Array} dailyEntries 200 | * @returns {Boolean} 201 | */ 202 | isRunningTask : function (taskId, projectId, dailyEntries) 203 | { 204 | var isRunning = false; 205 | _.each(dailyEntries, function (dailyEntry) { 206 | if ((parseInt(dailyEntry.task_id) === parseInt(taskId)) && (parseInt(projectId) === parseInt(dailyEntry.project_id)) && !!dailyEntry.timer_started_at) { 207 | isRunning = true; 208 | } 209 | }); 210 | 211 | return isRunning; 212 | }, 213 | 214 | 215 | /** 216 | * Finds current entry 217 | * 218 | * @param {Array} dailyEntries 219 | * @returns {Object|null} 220 | */ 221 | filterCurrentEntry : function (dailyEntries) 222 | { 223 | var entry = null; 224 | _.each(dailyEntries, function (dailyEntry) { 225 | if (!!dailyEntry.timer_started_at) { 226 | entry = dailyEntry; 227 | } 228 | }); 229 | 230 | return entry; 231 | } 232 | }; 233 | 234 | module.exports = timer; -------------------------------------------------------------------------------- /test/unit/services/auth/lib/handlers.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var expect = require('chai').expect, 4 | path = './../../../../../app/services/auth/lib/', 5 | authErrorFactory = require(path + 'error.js'), 6 | auth = require(path + 'auth.js'), 7 | handlersApplier = require(path + 'handlers.js'), 8 | crypto = require('crypto'); 9 | 10 | 11 | 12 | /** 13 | * Generates random string of given length 14 | * 15 | * @param {Number} len Integer number of generated string length 16 | * @returns {String} The random string 17 | */ 18 | function generateRandom (len) 19 | { 20 | var text = ""; 21 | var possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; 22 | for (var i=0; i < len; i++) { 23 | text += possible.charAt(Math.floor(Math.random() * possible.length)); 24 | } 25 | 26 | return text; 27 | } 28 | 29 | describe('auth/lib/handlers', function () { 30 | 31 | describe("handlers.secret.validate", function () { 32 | 33 | it ("Should allow valid requests containing correctly calculated tokens.", function () { 34 | var action = generateRandom(10), 35 | seed = generateRandom(10), 36 | secret = generateRandom(20); 37 | 38 | handlersApplier(auth.resetHandlers(), { 39 | secret : secret 40 | }); 41 | 42 | var requestMock = { 43 | body : { 44 | token : (function (input) { 45 | var shasum = crypto.createHash('sha1'); 46 | shasum.update(input, 'utf8'); 47 | return shasum.digest('hex'); 48 | })([ 49 | secret, 50 | seed, 51 | action 52 | ].join('|')), 53 | action : action, 54 | seed : seed 55 | } 56 | }; 57 | 58 | expect(auth.handlers.length).to.equal(1); 59 | expect(auth.hasAccess(requestMock)).to.equal(true); 60 | }); 61 | 62 | it ("Should ban invalid requests not containing seeds.", function () { 63 | var action = generateRandom(10), 64 | seed = generateRandom(10), 65 | secret = generateRandom(20); 66 | 67 | handlersApplier(auth.resetHandlers(), { 68 | secret : secret 69 | }); 70 | 71 | var requestMock = { 72 | body : { 73 | token : (function (input) { 74 | var shasum = crypto.createHash('sha1'); 75 | shasum.update(input, 'utf8'); 76 | return shasum.digest('hex'); 77 | })([ 78 | secret, 79 | seed, 80 | action 81 | ].join('|')), 82 | action : action 83 | } 84 | }; 85 | 86 | expect(auth.handlers.length).to.equal(1); 87 | expect(auth.hasAccess(requestMock)).to.equal(false); 88 | }); 89 | 90 | it ("Should ban invalid requests not containing actions.", function () { 91 | var action = generateRandom(10), 92 | seed = generateRandom(10), 93 | secret = generateRandom(20); 94 | 95 | handlersApplier(auth.resetHandlers(), { 96 | secret : secret 97 | }); 98 | 99 | var requestMock = { 100 | body : { 101 | token : (function (input) { 102 | var shasum = crypto.createHash('sha1'); 103 | shasum.update(input, 'utf8'); 104 | return shasum.digest('hex'); 105 | })([ 106 | secret, 107 | seed, 108 | action 109 | ].join('|')), 110 | seed : seed 111 | } 112 | }; 113 | 114 | expect(auth.handlers.length).to.equal(1); 115 | expect(auth.hasAccess(requestMock)).to.equal(false); 116 | }); 117 | 118 | 119 | it ("Should ban invalid requests containing incorrectly calculated tokens.", function () { 120 | var action = generateRandom(10), 121 | seed = generateRandom(10), 122 | secret = generateRandom(20); 123 | 124 | handlersApplier(auth.resetHandlers(), { 125 | secret : secret 126 | }); 127 | 128 | var requestMock = { 129 | body : { 130 | token : "InvalidToken", 131 | action : action, 132 | seed : seed 133 | } 134 | }; 135 | 136 | expect(auth.handlers.length).to.equal(1); 137 | expect(auth.hasAccess(requestMock)).to.equal(false); 138 | }); 139 | 140 | 141 | it ("Should ban invalid requests not containing tokens.", function () { 142 | var action = generateRandom(10), 143 | seed = generateRandom(10), 144 | secret = generateRandom(20); 145 | 146 | handlersApplier(auth.resetHandlers(), { 147 | secret : secret 148 | }); 149 | 150 | var requestMock = { 151 | body : { 152 | action : action, 153 | seed : seed 154 | } 155 | }; 156 | 157 | expect(auth.handlers.length).to.equal(1); 158 | expect(auth.hasAccess(requestMock)).to.equal(false); 159 | }); 160 | }); 161 | 162 | 163 | 164 | describe("handlers.token.validate", function () { 165 | 166 | it ("Should allow valid requests containing correct, matching tokens.", function () { 167 | 168 | handlersApplier(auth.resetHandlers(), { 169 | token : 'thisisatoken' 170 | }); 171 | 172 | var requestMock = { 173 | body : { 174 | token : 'thisisatoken' 175 | } 176 | }; 177 | 178 | expect(auth.handlers.length).to.equal(1); 179 | expect(auth.hasAccess(requestMock)).to.equal(true); 180 | }); 181 | 182 | it ("Should disallow invalid requests containing incorrect, non-matching tokens.", function () { 183 | handlersApplier(auth.resetHandlers(), { 184 | token : 'thisisatoken' 185 | }); 186 | 187 | var requestMock = { 188 | body : { 189 | token : 'thisisanon-matchingtoken' 190 | } 191 | }; 192 | 193 | expect(auth.handlers.length).to.equal(1); 194 | expect(auth.hasAccess(requestMock)).to.equal(false); 195 | }); 196 | }); 197 | }); -------------------------------------------------------------------------------- /app/services/interactive_session/lib/user_session.js: -------------------------------------------------------------------------------- 1 | /*jshint node: true*/ 2 | 'use strict'; 3 | 4 | function InvalidOptionError () {} 5 | InvalidOptionError.prototype = new Error(); 6 | InvalidOptionError.prototype.constructor = InvalidOptionError; 7 | 8 | 9 | function UserSessionStep (options, action) 10 | { 11 | this.options = options; 12 | this.action = action; 13 | this.params = {}; 14 | } 15 | 16 | 17 | UserSessionStep.prototype = (function () { 18 | 19 | 20 | /** 21 | * Returns an option for given key. Throws an error if such option does 22 | * not exist 23 | * 24 | * @param {Object} options 25 | * @param {String} key 26 | * @returns {Object} 27 | * @throws {InvalidOptionError} If such option does not exist 28 | */ 29 | function validateOption (options, key) 30 | { 31 | if (typeof options[key] === 'undefined') { 32 | throw new InvalidOptionError('Option ' + key + ' does not exist.'); 33 | } 34 | 35 | return options[key]; 36 | } 37 | 38 | return { 39 | 40 | 41 | /** 42 | * Returns an option for given key. Throws an error if such option does 43 | * not exist 44 | * 45 | * @param {String} key 46 | * @returns {Object} 47 | * @throws {InvalidOptionError} If such option does not exist 48 | */ 49 | getOption : function (key) 50 | { 51 | return validateOption(this.options, key); 52 | }, 53 | 54 | 55 | /** 56 | * Returns all available options 57 | * 58 | * @returns {Object} 59 | */ 60 | getOptions : function () 61 | { 62 | return this.options; 63 | }, 64 | 65 | 66 | /** 67 | * Returns the string action name 68 | * 69 | * @returns {String} 70 | */ 71 | getAction : function () 72 | { 73 | return this.action; 74 | }, 75 | 76 | /** 77 | * Sets the session step param. If one exists already, gets overriden. 78 | * 79 | * @param {String} paramName 80 | * @param {Object} paramValue 81 | * @returns {UserSessionStep} This instance of session step 82 | */ 83 | addParam : function (paramName, paramValue) 84 | { 85 | this.params[paramName] = paramValue; 86 | return this; 87 | }, 88 | 89 | 90 | /** 91 | * Returns stored value for given param or undefined if such 92 | * param does not exist. 93 | * 94 | * @param {String} paramName 95 | * @returns {Object|undefined} 96 | */ 97 | getParam : function (paramName) 98 | { 99 | return this.params[paramName]; 100 | }, 101 | 102 | 103 | /** 104 | * Returns all params stored in the step 105 | * 106 | * @returns {Object} 107 | */ 108 | getParams : function () 109 | { 110 | return this.params; 111 | }, 112 | 113 | 114 | /** 115 | * Bulk sets step params 116 | * 117 | * @param {Object} params 118 | * @returns {UserSessionStep} This instance of session step 119 | */ 120 | addParams : function (params) 121 | { 122 | for (var i in params) { 123 | if (params.hasOwnProperty(i)) { 124 | this.addParam(i, params[i]); 125 | } 126 | } 127 | 128 | return this; 129 | }, 130 | 131 | 132 | /** 133 | * Unsets the param given by name. Returns 134 | * this param's value 135 | * 136 | * @param {String} paramName 137 | * @returns {Object} 138 | */ 139 | clearParam : function (paramName) 140 | { 141 | var value = this.getParam(paramName); 142 | delete this.params[paramName]; 143 | 144 | return value; 145 | } 146 | 147 | }; 148 | 149 | })(); 150 | UserSessionStep.prototype.constructor = UserSessionStep; 151 | 152 | 153 | /** 154 | * Constructs the user session object 155 | * 156 | * @constructor 157 | * @returns {undefined} 158 | */ 159 | function UserSession () 160 | { 161 | this.users = {}; 162 | } 163 | 164 | 165 | UserSession.prototype = (function () { 166 | 167 | 168 | function validateStep (step) 169 | { 170 | if (!(step instanceof UserSessionStep)) { 171 | throw new Error('The step needs to be an instance of UserSessionStep!'); 172 | } 173 | 174 | return step; 175 | } 176 | 177 | 178 | return { 179 | 180 | /** 181 | * Registers an user session step 182 | * 183 | * @param {String} userId Unique user identifier 184 | * @param {UserSessionStep} step 185 | * @returns {UserSession} This instance 186 | */ 187 | addStep : function (userId, step) 188 | { 189 | this.users[userId] = this.users[userId] || []; 190 | this.users[userId].push(validateStep(step)); 191 | 192 | return this; 193 | }, 194 | 195 | 196 | /** 197 | * Clears the session for userId 198 | * 199 | * @param {String} userId 200 | * @returns {UserSession} This instance 201 | */ 202 | clear : function (userId) 203 | { 204 | this.users[userId] = []; 205 | return this; 206 | }, 207 | 208 | 209 | /** 210 | * Returns true if user given by id has any steps stored 211 | * 212 | * @param {String} userId 213 | * @returns {Boolean} 214 | */ 215 | hasSession : function (userId) 216 | { 217 | return Boolean(!!this.users[userId] && this.users[userId].length); 218 | }, 219 | 220 | 221 | /** 222 | * Returns stored step for given user and number 223 | * 224 | * @param {String} userId The user id 225 | * @param {Number} step The step number 226 | * @returns {Object} 227 | * @throws {Error} If the use has no steps stored or 228 | * given step dows not exist 229 | */ 230 | getStep : function (userId, step) 231 | { 232 | 233 | var steps = this.users[userId] || [], 234 | step = step || steps.length, 235 | index = step - 1, 236 | value; 237 | 238 | if (!steps.length) { 239 | throw new Error('Given user has no steps stored!'); 240 | } 241 | 242 | value = steps[index]; 243 | 244 | if (typeof value === 'undefined') { 245 | throw new Error('Step ' + step + ' does not exist!'); 246 | } 247 | 248 | return value; 249 | }, 250 | 251 | 252 | /** 253 | * Returns the number of steps stored for the user 254 | * 255 | * @param {Number} userId 256 | * @returns {Number} 257 | */ 258 | countSteps : function (userId) 259 | { 260 | var steps = this.users[userId] || []; 261 | return steps.length; 262 | }, 263 | 264 | 265 | /** 266 | * Creates new session step 267 | * 268 | * @param {Number} userId 269 | * @param {Object} options 270 | * @param {String} action 271 | * @returns {UserSessionStep} 272 | */ 273 | createStep: function (userId, options, action) 274 | { 275 | var step = new UserSessionStep(options, action), 276 | stepNo = (this.countSteps(userId) + 1), 277 | previousStep 278 | ; 279 | 280 | step.addParam('userId', userId); 281 | step.addParam('stepNumber', stepNo); 282 | try { 283 | previousStep = this.getStep(userId); 284 | } catch (err) { 285 | previousStep = null; 286 | } 287 | step.addParam('previousStep', previousStep); 288 | 289 | return step; 290 | } 291 | }; 292 | 293 | })(); 294 | 295 | UserSession.prototype.constructor = UserSession; 296 | 297 | var instance = null; 298 | 299 | module.exports = { 300 | 301 | /** 302 | * The error constructor 303 | * 304 | * @returns {InvalidOptionError} 305 | */ 306 | error : InvalidOptionError, 307 | 308 | /** 309 | * Creates a new instance of UserSession class 310 | * 311 | * @returns {UserSession} 312 | */ 313 | create : function () 314 | { 315 | return new UserSession(); 316 | }, 317 | 318 | /** 319 | * Returns default, single instance of UserSession 320 | * 321 | * @returns {UserSession} 322 | */ 323 | getDefault : function () 324 | { 325 | if (instance === null) { 326 | instance = this.create(); 327 | } 328 | 329 | return instance; 330 | }, 331 | }; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Travis CI](https://travis-ci.org/Neverbland/slack-harvest.svg) 2 | 3 | ### :warning: We are looking for maintainers! 4 | This plugin is not currently being maintained, so if you're interested, send us an email at hello@neverbland.com. 5 | 6 | # Slack - Harvest integration 7 | 8 | The project aims to create a standalone application for automated integration between Harvest time tracking tool and Slack messaging system that will notify all configured team users about the amount of time they've spent on each project according to their Harvest timesheet. 9 | 10 | ## Setup 11 | 12 | To download the application, check it out with `git`. Next thing to do is installing all dependencies with the `npm` node packages manager tool. 13 | 14 | ```bash 15 | $ cd /path/to/project 16 | $ npm install 17 | ``` 18 | 19 | Next thing to do is preparing the **config file**. The file must be located in the repository root and named `config.json` A template file is stored in `config.dist.json`. The file is divided into sections containing particular configuration options for the application middlewares. To get the Slack <-> Harvest part communicate, the most important thing is to set up the credentials for both services properly. 20 | 21 | ## Architecture 22 | 23 | The application is written 100% in `Node.JS` and at this point consists of three blocks: 24 | - the **cron-like time schedule** that sends notification messages to slack users, 25 | - a simple **HTTP API** to trigger notifications (single user and all users notifications, management report), 26 | - a **Slack Command API endpoint** that manages Harvest timer setup. 27 | 28 | 29 | ###HARVEST configuration 30 | 31 | For the moment, harvest communication may be only set up using an account. To have a complete list of projects in all user notifications, an account with access to prefferably all available projects, users and clients should be used. Mandatory parameters in the configuration object are `subdomain`, `email` and `password`. 32 | 33 | ``` 34 | "harvest": { 35 | "subdomain": "example_harvest_domain", 36 | "email": "XXXXX@neverbland.com", 37 | "password": "XXXXXXXXXXXXXXX" 38 | } 39 | ``` 40 | 41 | ###SLACK configuration 42 | 43 | The Slack part uses a simple **incoming WebHook** that needs to be created within the Slack application itself (See [https://api.slack.com/incoming-webhooks](https://api.slack.com/incoming-webhooks)). The only mandatory parameter that need to be set is `endpoint` which is the webhook endpoint. The configuration below contains additional params which are overriding the default settings for the webhook. All [params available for the webhook](https://api.slack.com/incoming-webhooks) can be used except `channel`, which will always be overridden by **slack username** of the user that receives the notifications. 44 | 45 | ``` 46 | "slack": { 47 | "username": "Harvest", 48 | "icon_url": "https://avatars0.githubusercontent.com/u/43635?v=3&s=200", 49 | "endpoint": "XXXXXXXXXXXXXX" 50 | } 51 | ``` 52 | 53 | ### Users configuration 54 | 55 | The users section contains the mapping of all available users **Harvest ID -> Slack username** map. Only these users will be available for notification. 56 | 57 | ``` 58 | "users": { 59 | "123456": "slack_name" 60 | } 61 | ``` 62 | 63 | 64 | ##CRON 65 | 66 | For the moment the application is able to: 67 | 68 | - notify defined users at the configured time every work day (monday - friday). The time is defined in the `cron.notify` section of the config file and uses **the same timezone as the machine the app is running on**. For the configuration below, the app will automatically send notifications every working day at `16:30`. If a `cron.notify.cronTime` value is provided in the config, **this value will be used instead of the hour and minutes settings** 69 | 70 | - refresh (preload) the information about timesheet related entries (clients and projects) according to cron time provided in `cron.preload.cronTime` section 71 | 72 | - send periodical report notifications on Slack management channel defined in `cron.report.channel` setting according to cron time provided in `cron.report.cronTime` section 73 | 74 | - send reminder messages via Slack to people who have no timers running according to `cron.remind.cronTime` setting. 75 | 76 | ``` 77 | "cron": { 78 | "notify": { 79 | "hour": "16", 80 | "munutes": "30", 81 | "cronTime": "00 30 16 * * 1-5" // Optional, instead of hour/minutes 82 | }, 83 | "preload": { 84 | "cronTime": "00 00 7-20 * * 1-5" 85 | }, 86 | "report": { 87 | "reportTitle": "Weekly activity report", 88 | "channel": "#channel_name", 89 | "cronTime": "00 00 20 * * 5" 90 | } 91 | } 92 | ``` 93 | 94 | If any of the section for `cron` settings are not provided, the cron job will not be set up. 95 | 96 | 97 | ##API 98 | 99 | The API provides given endpoints: 100 | 101 | - `http|https://your.domain.you/notify-all` Notifies all users present in the users config providing information about their started tasks. 102 | 103 | - `http|https://your.domain.you/notify-user/user_id` Notifies user given by `user_id` which represents **either Harvest user ID or Slack username** about her/his started tasks. 104 | 105 | - `http|https://your.domain.you/notify-management` Notifies management channel provided in the `channel` property of the `POST` request. 106 | 107 | - `http|https://your.domain.you/remind-all` Triggers notifications to users wo have no tasks started on Harvest. 108 | 109 | The `notify-all` and `notify-user` are **actions names**; this will be useful when creating authorization token. 110 | 111 | 112 | ###API configuration 113 | 114 | The `api.auth` section of the config file contains settings for the authorization parameters. There are two built in methods of authorization: 115 | 116 | - **Static token method** - will be used if an `api.auth.token` setting is present. The incoming requests will be checked if they contain a `POST` value with the name `token` and if the value matches the `api.auth.token` value. 117 | 118 | - **Dynamic token method** - will be used if an `api.auth.secret` setting is present. To validate the request and grant access, a `POST` payload must contain a **token** and a **seed**. The token is generated with an `SHA1` hash of the same **secret as provided in the application config file**, **seed** and the **action name** from the URL (see above) joined by **|**. 119 | 120 | Example PHP implementation of the token generation: 121 | 122 | ```php 123 | 21 | */ 22 | function JobsHolder () 23 | { 24 | this.jobs = []; 25 | } 26 | 27 | 28 | JobsHolder.prototype = { 29 | 30 | /** 31 | * Adds a job 32 | * 33 | * @param {Object} job 34 | * @param {Object} jobConfig This particular job config 35 | * @return {JobsHolder} This instance 36 | */ 37 | addJob : function (job, jobConfig) 38 | { 39 | this.jobs.push({ 40 | job : job, 41 | config : jobConfig 42 | }); 43 | return this; 44 | }, 45 | 46 | /** 47 | * Runs the jobs 48 | * 49 | * @return {JobsHolder} This instance 50 | */ 51 | run : function () 52 | { 53 | _.each(this.jobs, function (jobObject) { 54 | var job = jobObject.job, 55 | config = jobObject.config, 56 | cronTime = job.getCronTime(config), 57 | handler = job.getJob(config), 58 | description = job.getDescription(), 59 | autoRun = job.shouldRunNow() 60 | ; 61 | 62 | logger.info('Setting up cron job for: "' + description + '" with cron time: ', cronTime, {}); 63 | 64 | var job = new CronJob(cronTime, handler); 65 | job.start(); 66 | if (autoRun) { 67 | logger.info('Immediately executing cron job for: "' + description + '".', {}); 68 | handler(); 69 | } 70 | }); 71 | 72 | return this; 73 | } 74 | 75 | }; 76 | 77 | JobsHolder.prototype.constructor = JobsHolder; 78 | 79 | var defaultJobs = { 80 | notify : { 81 | 82 | /** 83 | * Returns the job function 84 | * 85 | * @param {Object} config 86 | * @returns {Function} 87 | */ 88 | getJob : function (config) 89 | { 90 | return function () 91 | { 92 | _.each(harvest.fromUserMap(harvest.users), function (userId) { 93 | logger.info('Trying to send notifications to user: ' + userId, {}); 94 | harvest.getUserTimeTrack(userId, new Date(), new Date(), function (err, harvestResponse) { 95 | if (err === null) { 96 | notifier.notify('users', { 97 | harvestUserId : userId, 98 | harvestResponse : harvestResponse 99 | }); 100 | } else { 101 | logger.error("Failed fetching user timeline from Harvest API for user " + userId, err, {}); 102 | } 103 | }); 104 | }); 105 | }; 106 | }, 107 | 108 | /** 109 | * Formats the cron time according to given config 110 | * 111 | * @param {Object} config 112 | * @returns {String} The cron time format string 113 | */ 114 | getCronTime : function (config) 115 | { 116 | /* 117 | * Every work day at XX:XX send slack notification to all users 118 | */ 119 | var cronTime; 120 | if (!config.cronTime) { 121 | cronTime = '00 ' + config.minutes + ' ' + config.hour + ' * * 1-5'; 122 | } else { 123 | cronTime = config.cronTime; 124 | } 125 | 126 | return cronTime; 127 | }, 128 | 129 | /** 130 | * Defines if given job should be ran independently from setting it up 131 | * with cron 132 | * 133 | * @returns {Boolean} 134 | */ 135 | shouldRunNow : function () 136 | { 137 | return false; 138 | }, 139 | 140 | 141 | /** 142 | * Returns description of the task 143 | * 144 | * @returns {String} 145 | */ 146 | getDescription : function () 147 | { 148 | return 'Automatic notifications of harvest users on their Slack channels'; 149 | } 150 | }, 151 | 152 | preload : { 153 | /** 154 | * Returns the job function 155 | * 156 | * @param {Object} config 157 | * @returns {Function} 158 | */ 159 | getJob : function (config) 160 | { 161 | return function () 162 | { 163 | logger.info('Loading projects from Harvest API...', {}); 164 | harvest.doGetProjects(); 165 | logger.info('Loading clients from Harvest API...', {}); 166 | harvest.doGetClients(); 167 | }; 168 | }, 169 | 170 | /** 171 | * Formats the cron time according to given config 172 | * 173 | * @param {Object} config 174 | * @returns {String} The cron time format string 175 | */ 176 | getCronTime : function (config) 177 | { 178 | 179 | return !!config.cronTime 180 | ? config.cronTime 181 | : consts.preload.CRON_TIME; 182 | }, 183 | 184 | /** 185 | * Defines if given job should be ran independently from setting it up 186 | * with cron 187 | * 188 | * @returns {Boolean} 189 | */ 190 | shouldRunNow : function () 191 | { 192 | return true; 193 | }, 194 | 195 | 196 | /** 197 | * Returns description of the task 198 | * 199 | * @returns {String} 200 | */ 201 | getDescription : function () 202 | { 203 | return 'Automatic periodical fetching clients and projects from Harvest API'; 204 | } 205 | }, 206 | 207 | report : { 208 | /** 209 | * Returns the job function 210 | * 211 | * @param {Object} config 212 | * @returns {Function} 213 | */ 214 | getJob : function (config) 215 | { 216 | var dateFrom = config.dateFromText || consts.report.DATE_FROM_TEXT, 217 | dateTo = config.dateToText || consts.report.DATE_TO_TEXT; 218 | 219 | return function () 220 | { 221 | var dateFromObject = tools.dateFromString(dateFrom), 222 | dateToObject = tools.dateFromString(dateTo), 223 | reportTitle = config.reportTitle || consts.report.DEFAULT_REPORT_TITLE; 224 | 225 | logger.info('Preparing management report from: ' + dateFromObject + ' to ' + dateToObject, {}); 226 | notifier.notify('management', { 227 | reportTitle : reportTitle, 228 | channel : config.channel, 229 | fromDate : dateFromObject, 230 | toDate : dateToObject 231 | }); 232 | }; 233 | }, 234 | 235 | /** 236 | * Formats the cron time according to given config 237 | * 238 | * @param {Object} config 239 | * @returns {String} The cron time format string 240 | */ 241 | getCronTime : function (config) 242 | { 243 | 244 | return !!config.cronTime 245 | ? config.cronTime 246 | : consts.report.CRON_TIME; // by default every hour, every week day, from 7am to 8pm; 247 | }, 248 | 249 | /** 250 | * Defines if given job should be ran independently from setting it up 251 | * with cron 252 | * 253 | * @returns {Boolean} 254 | */ 255 | shouldRunNow : function () 256 | { 257 | return false; 258 | }, 259 | 260 | 261 | /** 262 | * Returns description of the task 263 | * 264 | * @returns {String} 265 | */ 266 | getDescription : function () 267 | { 268 | return 'Automatic management channel notifications'; 269 | } 270 | }, 271 | 272 | 273 | remind : { 274 | 275 | /** 276 | * Returns the job function 277 | * 278 | * @param {Object} config 279 | * @returns {Function} 280 | */ 281 | getJob : function (config) 282 | { 283 | return function () 284 | { 285 | reminder.remind(harvest.users, null, function (results) { 286 | _.each(results.notified, function (slackName) { 287 | logger.info('Successfully notified user ' + slackName + ' about empty time tracker.', {}); 288 | }); 289 | }); 290 | }; 291 | }, 292 | 293 | /** 294 | * Formats the cron time according to given config 295 | * 296 | * @param {Object} config 297 | * @returns {String} The cron time format string 298 | */ 299 | getCronTime : function (config) 300 | { 301 | return !!config.cronTime 302 | ? config.cronTime 303 | : consts.remind.CRON_TIME; // by default every midday of working day; 304 | }, 305 | 306 | /** 307 | * Defines if given job should be ran independently from setting it up 308 | * with cron 309 | * 310 | * @returns {Boolean} 311 | */ 312 | shouldRunNow : function () 313 | { 314 | return false; 315 | }, 316 | 317 | 318 | /** 319 | * Returns description of the task 320 | * 321 | * @returns {String} 322 | */ 323 | getDescription : function () 324 | { 325 | return 'User notifications about empty tracker.'; 326 | } 327 | } 328 | }; 329 | 330 | 331 | module.exports = function (config, additionalJobs) 332 | { 333 | var jobsHolder = new JobsHolder(); 334 | var jobs = _.assign(defaultJobs, additionalJobs); 335 | _.each(config, function (configValues, jobName) { 336 | var job = jobs[jobName]; 337 | if (!!job) { 338 | jobsHolder.addJob(job, configValues); 339 | } 340 | }); 341 | 342 | return jobsHolder; 343 | }; -------------------------------------------------------------------------------- /test/unit/services/timer.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var chai = require('chai'), 4 | _ = require('lodash'), 5 | expect = require('chai').expect, 6 | timer = require('./../../../app/services/timer.js'); 7 | 8 | 9 | describe('api.controllers.timer', function () { 10 | 11 | describe('timer.parseTimerConfig', function () { 12 | 13 | it('Should prepare proper harvest request config object for a correctly defined input command nontaining action name', function () { 14 | var validCommand = "start project_name"; 15 | var config = timer.parseTimerConfig(validCommand); 16 | 17 | expect(config.action).to.be.equal('start'); 18 | expect(config.name).to.be.a('string'); 19 | expect(config.name).to.be.equal('project_name'); 20 | 21 | }); 22 | 23 | 24 | it('Should prepare proper harvest request config object for a correctly defined input command not containing action name', function () { 25 | var validCommand = "1"; 26 | var config = timer.parseTimerConfig(validCommand); 27 | 28 | expect(config.action).to.be.equal(null); 29 | expect(config.name).to.be.a('undefined'); 30 | expect(config.value).to.be.equal("1"); 31 | 32 | }); 33 | }); 34 | 35 | 36 | describe('timer.findMatchingClientsOrProjects', function () { 37 | var projects = [{name: 'Website Maintenance', 38 | code: '', 39 | id: 3906589, 40 | billable: false, 41 | tasks: [Object], 42 | client: 'Sample Client 58', 43 | client_id: 1456809, 44 | client_currency: 'British Pound - GBP', 45 | client_currency_symbol: '£'}, 46 | {name: 'New Phase', 47 | code: '', 48 | id: 7492773, 49 | billable: true, 50 | tasks: [Object], 51 | client: 'Sample Client 58', 52 | client_id: 1560768, 53 | client_currency: 'British Pound - GBP', 54 | client_currency_symbol: '£'}, 55 | {name: 'Website Maintenance', 56 | code: '', 57 | id: 6277432, 58 | billable: false, 59 | tasks: [Object], 60 | client: 'Sample Client 58', 61 | client_id: 1560768, 62 | client_currency: 'British Pound - GBP', 63 | client_currency_symbol: '£'}, 64 | {name: 'App', 65 | code: '', 66 | id: 3097821, 67 | billable: false, 68 | tasks: [Object], 69 | client: 'Conjure', 70 | client_id: 1561330, 71 | client_currency: 'British Pound - GBP', 72 | client_currency_symbol: '£'}, 73 | {name: 'Test Site', 74 | code: '', 75 | id: 3322251, 76 | billable: false, 77 | tasks: [Object], 78 | client: 'Sample Client 55', 79 | client_id: 1561330, 80 | client_currency: 'British Pound - GBP', 81 | client_currency_symbol: '£'}, 82 | {name: 'Holiday', 83 | code: '', 84 | id: 4445437, 85 | billable: false, 86 | tasks: [Object], 87 | client: 'NEVERBLAND', 88 | client_id: 1441113, 89 | client_currency: 'British Pound - GBP', 90 | client_currency_symbol: '£'}, 91 | {name: 'Sample Task 8', 92 | code: '', 93 | id: 4847113, 94 | billable: false, 95 | tasks: [Object], 96 | client: 'NEVERBLAND', 97 | client_id: 1441113, 98 | client_currency: 'British Pound - GBP', 99 | client_currency_symbol: '£'}, 100 | {name: 'Internal', 101 | code: '', 102 | id: 3058542, 103 | billable: false, 104 | tasks: [Object], 105 | client: 'NEVERBLAND', 106 | client_id: 1441113, 107 | client_currency: 'British Pound - GBP', 108 | client_currency_symbol: '£'}, 109 | {name: 'Website Maintenance', 110 | code: '', 111 | id: 6258456, 112 | billable: false, 113 | tasks: [Object], 114 | client: 'Sample Client 2', 115 | client_id: 1795160, 116 | client_currency: 'British Pound - GBP', 117 | client_currency_symbol: '£'}, 118 | {name: 'Backend', 119 | code: '', 120 | id: 7074009, 121 | billable: true, 122 | tasks: [Object], 123 | client: 'Sample Client 2', 124 | client_id: 3094246, 125 | client_currency: 'British Pound - GBP', 126 | client_currency_symbol: '£'}, 127 | {name: 'Awesome task', 128 | code: '', 129 | id: 7479901, 130 | billable: false, 131 | tasks: [Object], 132 | client: 'Sample client 2', 133 | client_id: 1575859, 134 | client_currency: 'British Pound - GBP', 135 | client_currency_symbol: '£'}, 136 | {name: 'Sample project 4', 137 | code: '', 138 | id: 4047500, 139 | billable: true, 140 | tasks: [Object], 141 | client: 'Sample client 3', 142 | client_id: 1867831, 143 | client_currency: 'British Pound - GBP', 144 | client_currency_symbol: '£'}, 145 | {name: 'Test project', 146 | code: '', 147 | id: 5897618, 148 | billable: false, 149 | tasks: [Object], 150 | client: 'S+O', 151 | client_id: 1448780, 152 | client_currency: 'British Pound - GBP', 153 | client_currency_symbol: '£'}, 154 | {name: 'Sample project 2', 155 | code: '', 156 | id: 7420124, 157 | billable: false, 158 | tasks: [Object], 159 | client: 'Slate', 160 | client_id: 1549998, 161 | client_currency: 'British Pound - GBP', 162 | client_currency_symbol: '£'}, 163 | {name: 'Sample project 2', 164 | code: '', 165 | id: 3294106, 166 | billable: false, 167 | tasks: [Object], 168 | client: 'Slate', 169 | client_id: 1549998, 170 | client_currency: 'British Pound - GBP', 171 | client_currency_symbol: '£'}, 172 | {name: 'Sample project 1', 173 | code: '', 174 | id: 3717796, 175 | billable: false, 176 | tasks: [Object], 177 | client: 'Slate', 178 | client_id: 1549998, 179 | client_currency: 'British Pound - GBP', 180 | client_currency_symbol: '£'}, 181 | {name: 'Old Stuff maintenance', 182 | code: '', 183 | id: 7492008, 184 | billable: false, 185 | tasks: [Object], 186 | client: 'Slate', 187 | client_id: 1549998, 188 | client_currency: 'British Pound - GBP', 189 | client_currency_symbol: '£'}, 190 | {name: 'Sample project 1', 191 | code: '', 192 | id: 7549919, 193 | billable: false, 194 | tasks: [Object], 195 | client: 'Slate', 196 | client_id: 1549998, 197 | client_currency: 'British Pound - GBP', 198 | client_currency_symbol: '£'}, 199 | {name: 'Sample project 2', 200 | code: '', 201 | id: 4611867, 202 | billable: false, 203 | tasks: [Object], 204 | client: 'Slate', 205 | client_id: 1549998, 206 | client_currency: 'British Pound - GBP', 207 | client_currency_symbol: '£'}, 208 | {name: 'Sample project 3', 209 | code: '', 210 | id: 6031218, 211 | billable: false, 212 | tasks: [Object], 213 | client: 'Sample client 3', 214 | client_id: 1447817, 215 | client_currency: 'British Pound - GBP', 216 | client_currency_symbol: '£'}]; 217 | 218 | 219 | var projectDataSamples = [ 220 | { 221 | name : 'NEVERBLAND', 222 | expected : [ 223 | { 224 | client : 'NEVERBLAND', 225 | project : 'Holiday', 226 | clientId : 1441113, 227 | projectId : 4445437 228 | }, 229 | { 230 | client : 'NEVERBLAND', 231 | project : 'Sample Task 8', 232 | clientId : 1441113, 233 | projectId : 4847113 234 | }, 235 | { 236 | client : 'NEVERBLAND', 237 | project : 'Internal', 238 | clientId : 1441113, 239 | projectId : 3058542 240 | } 241 | ] 242 | } 243 | ]; 244 | 245 | it('Should find best matches for given array of projects and name', function () { 246 | _.each(projectDataSamples, function (sample) { 247 | var expected = sample.expected, 248 | name = sample.name, 249 | matching = timer.findMatchingClientsOrProjects(name, projects); 250 | 251 | expect(matching).to.be.a('array'); 252 | expect(matching).to.deep.equal(expected); 253 | }); 254 | }); 255 | }); 256 | 257 | 258 | describe('timer.findMatchingEntries', function () { 259 | var entries = [ 260 | { 261 | project: 'Test Project1', 262 | client: 'Test Client1', 263 | task: 'Test Task1' 264 | }, 265 | { 266 | project: 'Test Project2', 267 | client: 'Test Client2', 268 | task: 'Test Task2' 269 | } 270 | ]; 271 | 272 | 273 | var projectDataSamples = [ 274 | { 275 | name : 'Project1', 276 | expected : [ 277 | { 278 | project: 'Test Project1', 279 | client: 'Test Client1', 280 | task: 'Test Task1' 281 | } 282 | 283 | ] 284 | }, 285 | { 286 | name : 'Project2', 287 | expected : [ 288 | { 289 | project: 'Test Project2', 290 | client: 'Test Client2', 291 | task: 'Test Task2' 292 | } 293 | 294 | ] 295 | }, 296 | { 297 | name : 'Test', 298 | expected : [ 299 | { 300 | project: 'Test Project1', 301 | client: 'Test Client1', 302 | task: 'Test Task1' 303 | }, 304 | { 305 | project: 'Test Project2', 306 | client: 'Test Client2', 307 | task: 'Test Task2' 308 | } 309 | ] 310 | }, 311 | { 312 | name : 'Nonmatching', 313 | expected : [] 314 | } 315 | ]; 316 | 317 | it('Should find matches for given array of day entries', function () { 318 | _.each(projectDataSamples, function (sample) { 319 | var expected = sample.expected, 320 | name = sample.name, 321 | matching = timer.findMatchingEntries(name, entries); 322 | 323 | expect(matching).to.be.a('array'); 324 | expect(matching).to.deep.equal(expected); 325 | }); 326 | }); 327 | }); 328 | }); 329 | -------------------------------------------------------------------------------- /test/unit/services/tools.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var expect = require('chai').expect, 4 | tools = require('./../../../app/services/tools'), 5 | _ = require('lodash'), 6 | exampleEntries = [ 7 | {day_entry: 8 | {id: 311036476, 9 | notes: '', 10 | spent_at: '2015-03-16', 11 | hours: 1.6, 12 | user_id: 449849, 13 | project_id: 2, 14 | task_id: 1815946, 15 | created_at: '2015-03-16T15:00:02Z', 16 | updated_at: '2015-03-17T08:28:27Z', 17 | adjustment_record: false, 18 | timer_started_at: null, 19 | is_closed: false, 20 | is_billed: false}}, 21 | {day_entry: 22 | {id: 310859756, 23 | notes: '', 24 | spent_at: '2015-03-16', 25 | hours: 6.55, 26 | user_id: 449849, 27 | project_id: 2, 28 | task_id: 1815946, 29 | created_at: '2015-03-16T08:27:20Z', 30 | updated_at: '2015-03-16T15:00:02Z', 31 | adjustment_record: false, 32 | timer_started_at: null, 33 | is_closed: false, 34 | is_billed: false}}, 35 | {day_entry: 36 | {id: 311351742, 37 | notes: 'Harvest - Slack integration', 38 | spent_at: '2015-03-17', 39 | hours: 3.08, 40 | user_id: 449849, 41 | project_id: 3, 42 | task_id: 1815946, 43 | created_at: '2015-03-17T08:28:51Z', 44 | updated_at: '2015-03-17T14:44:31Z', 45 | adjustment_record: false, 46 | timer_started_at: null, 47 | is_closed: false, 48 | is_billed: false}}, 49 | {day_entry: 50 | {id: 311471459, 51 | notes: 'PL Office devs meeting', 52 | spent_at: '2015-03-17', 53 | hours: 0.99, 54 | user_id: 449849, 55 | project_id: 1, 56 | task_id: 1815946, 57 | created_at: '2015-03-17T14:44:31Z', 58 | updated_at: '2015-03-17T15:43:58Z', 59 | adjustment_record: false, 60 | timer_started_at: null, 61 | is_closed: false, 62 | is_billed: false}}, 63 | {day_entry: 64 | {id: 311371886, 65 | notes: '', 66 | spent_at: '2015-03-17', 67 | hours: 2.72, 68 | user_id: 449849, 69 | project_id: 3, 70 | task_id: 1815946, 71 | created_at: '2015-03-17T09:55:59Z', 72 | updated_at: '2015-03-17T13:46:44Z', 73 | adjustment_record: false, 74 | timer_started_at: null, 75 | is_closed: false, 76 | is_billed: false}}, 77 | {day_entry: 78 | {id: 311388706, 79 | notes: 'RockHound', 80 | spent_at: '2015-03-17', 81 | hours: 1.33, 82 | user_id: 449849, 83 | project_id: 2, 84 | task_id: 1815946, 85 | created_at: '2015-03-17T11:12:32Z', 86 | updated_at: '2015-03-17T16:35:54Z', 87 | adjustment_record: false, 88 | timer_started_at: null, 89 | is_closed: false, 90 | is_billed: false}}, 91 | {day_entry: 92 | {id: 311774684, 93 | notes: 'Slack - Harvest integration', 94 | spent_at: '2015-03-18', 95 | hours: 4.18, 96 | user_id: 449849, 97 | project_id: 3, 98 | task_id: 1815946, 99 | created_at: '2015-03-18T09:07:21Z', 100 | updated_at: '2015-03-18T15:56:44Z', 101 | adjustment_record: false, 102 | timer_started_at: null, 103 | is_closed: false, 104 | is_billed: false}}, 105 | {day_entry: 106 | {id: 311767624, 107 | notes: '', 108 | spent_at: '2015-03-18', 109 | hours: 3.67, 110 | user_id: 449849, 111 | project_id: 1, 112 | task_id: 1815946, 113 | created_at: '2015-03-18T08:34:59Z', 114 | updated_at: '2015-03-18T16:25:27Z', 115 | adjustment_record: false, 116 | timer_started_at: null, 117 | is_closed: false, 118 | is_billed: false}} 119 | 120 | ]; 121 | 122 | 123 | describe ('Prototypes and object modifications', function () { 124 | 125 | describe('Object.size', function () { 126 | 127 | it ('Should add a method that would result with similar output to Array.prototype.length property but on hash table-ish objects.', function () { 128 | var input = [ 129 | { 130 | given : {}, 131 | expected : 0 132 | }, 133 | { 134 | given : { 135 | property1: "value 1", 136 | property2: "value 2", 137 | 'property 3': "value 3" 138 | }, 139 | expected : 3 140 | } 141 | ]; 142 | 143 | _.each(input, function (givenObject) { 144 | var given = givenObject.given, 145 | expected = givenObject.expected 146 | ; 147 | 148 | expect(Object.size(given)).to.be.equal(expected); 149 | }); 150 | }); 151 | }); 152 | }); 153 | 154 | 155 | describe('tools', function () { 156 | describe('tools.formatTime', function () { 157 | it ('Should return time in HH:MM format for given float number of hours.', function () { 158 | var input = [ 159 | { given : 1, expected : "01:00"}, 160 | { given : 1.5, expected : "01:30"}, 161 | { given : 2.29, expected : "02:17"}, 162 | { given : 2.31, expected : "02:18"}, 163 | { given : 34.20, expected : "34:12"}, 164 | ]; 165 | 166 | 167 | _.each(input, function (value) { 168 | var expected = value.expected, 169 | given = value.given, 170 | returned = tools.formatTime(given); 171 | 172 | expect(returned).to.equal(expected); 173 | }); 174 | }); 175 | }); 176 | 177 | describe('tools.getIds', function () { 178 | it ('Should return an array of unique ids for given array of entries with defined parameters', function () { 179 | var given = exampleEntries, 180 | expected = [1,2,3], 181 | returned = tools.getIds(given, 'day_entry', 'project_id'), 182 | emptyReturned = tools.getIds([], 'day_entry', 'project_id'); 183 | 184 | expect(returned).to.be.a('array'); 185 | expect(returned).to.include.members(expected); 186 | 187 | expect(emptyReturned).to.be.a('array'); 188 | expect(emptyReturned).to.be.empty; 189 | }); 190 | }); 191 | 192 | 193 | describe('tools.byId', function () { 194 | it ('Should return an object of objects from given array but each stored under key representing it\'s id and simplified.', function () { 195 | var given = exampleEntries, 196 | expected = { 197 | '311036476' : given[0]['day_entry'], 198 | '310859756' : given[1]['day_entry'], 199 | '311351742' : given[2]['day_entry'], 200 | '311471459' : given[3]['day_entry'], 201 | '311371886' : given[4]['day_entry'], 202 | '311388706' : given[5]['day_entry'], 203 | '311774684' : given[6]['day_entry'], 204 | '311767624' : given[7]['day_entry'], 205 | 206 | }, 207 | returned = tools.byId(given, 'day_entry'), 208 | emptyReturned = tools.byId([], 'day_entry'); 209 | 210 | expect(returned).to.be.a('object'); 211 | expect(returned).to.be.deep.equal(expected); 212 | 213 | expect(emptyReturned).to.be.a('object'); 214 | expect(emptyReturned).to.be.empty; 215 | }); 216 | }); 217 | 218 | 219 | describe('tools.getHours', function () { 220 | it ('Should return a total number of hours for given resource.', function () { 221 | var input = [ 222 | {given : {hours : 2.5}, expected : 2.5}, 223 | {given : {hours : 0.1}, expected : 0.1}, 224 | {given : {hours : 0.1, hours_with_timer : 5}, expected : 5}, 225 | {given : {hours : 3, hours_with_timer : 1}, expected : 3}, 226 | {given : {hours : 0, hours_with_timer : 0}, expected : 0}, 227 | ]; 228 | 229 | 230 | _.each(input, function (value) { 231 | var expected = value.expected, 232 | given = value.given, 233 | returned = tools.getHours(given); 234 | 235 | expect(returned).to.equal(expected); 236 | }); 237 | }); 238 | }); 239 | 240 | 241 | 242 | describe('tools.validateGet', function () { 243 | it ('Should return a value of given object for given key', function () { 244 | var input = [ 245 | {given : {someKey : 'Some Value'}, expected : "Some Value"}, 246 | {given : {someKey : 1}, expected : 1}, 247 | {given : {someKey : true}, expected : true}, 248 | {given : {someKey : false}, expected : false} 249 | ]; 250 | 251 | 252 | _.each(input, function (value) { 253 | var expected = value.expected, 254 | given = value.given, 255 | returned = tools.validateGet(given, 'someKey'); 256 | 257 | expect(returned).to.equal(expected); 258 | }); 259 | }); 260 | 261 | it ('Should throw an error with expected default message if value is not present', function () { 262 | var given = { 263 | someKey : 'Some value' 264 | }; 265 | expect(function () { 266 | tools.validateGet(given, 'param'); 267 | }).to.throw(Error, 'Param param does not exist.') 268 | }); 269 | 270 | it ('Should throw an error with expected defined message if value is not present', function () { 271 | var given = { 272 | someKey : 'Some value' 273 | }, 274 | errorMessage = 'Some error message'; 275 | expect(function () { 276 | tools.validateGet(given, 'param', errorMessage); 277 | }).to.throw(Error, errorMessage) 278 | }); 279 | }); 280 | 281 | describe('tools.validateGetUser', function () { 282 | var users = { 283 | 12345 : 'some_user1', 284 | 23456 : 'some_user2', 285 | 34567 : 'some_user3' 286 | }; 287 | 288 | var given = [ 289 | { 290 | given : 'some_user1', 291 | expected : { 292 | 12345 : 'some_user1' 293 | } 294 | }, 295 | { 296 | given : 'some_user2', 297 | expected : { 298 | 23456 : 'some_user2' 299 | } 300 | }, 301 | { 302 | given : 'some_user3', 303 | expected : { 304 | 34567 : 'some_user3' 305 | } 306 | }, 307 | { 308 | given : 12345, 309 | expected : { 310 | 12345 : 'some_user1' 311 | } 312 | }, 313 | { 314 | given : 23456, 315 | expected : { 316 | 23456 : 'some_user2' 317 | } 318 | }, 319 | { 320 | given : 34567, 321 | expected : { 322 | 34567 : 'some_user3' 323 | } 324 | } 325 | ]; 326 | 327 | it('Should return proper harvest id -> slack name pair object for given user id.', function () { 328 | _.each(given, function (givenObject) { 329 | expect(tools.validateGetUser(users, givenObject.given)).to.be.deep.equal(givenObject.expected); 330 | }); 331 | }); 332 | }); 333 | }); -------------------------------------------------------------------------------- /app/services/harvest/index.js: -------------------------------------------------------------------------------- 1 | /*jshint node: true*/ 2 | 'use strict'; 3 | 4 | var harvest = require('harvest'), 5 | _ = require('lodash'), 6 | tools = require('./../tools.js'), 7 | logger = require('./../logger.js')('default'), 8 | humps = require('humps'), 9 | Q = require('q'), 10 | instances = {} 11 | ; 12 | 13 | /** 14 | * Takes the Date object and formats it to YYYYMMDD 15 | * 16 | * @param {Date} date 17 | * @returns {String} 18 | */ 19 | function formatDate (date) 20 | { 21 | var yyyy = date.getFullYear().toString(); 22 | var mm = (date.getMonth() + 1).toString(); // getMonth() is zero-based 23 | var dd = date.getDate().toString(); 24 | 25 | return yyyy + (mm[1] ? mm : "0" + mm[0]) + (dd[1] ? dd : "0" + dd[0]); // padding 26 | } 27 | 28 | function _Harvest (config) 29 | { 30 | this.harvest = new harvest({ 31 | subdomain : config.subdomain, 32 | email : config.email, 33 | password : config.password, 34 | identifier : config.identifier, 35 | secret : config.secret, 36 | user_agent : this.USER_AGENT 37 | }); 38 | this.users = []; 39 | this.clients = {}; 40 | this.projects = {}; 41 | } 42 | 43 | 44 | /** 45 | * Explodes the resources array by id 46 | * 47 | * @param {Array} resources 48 | * @param {String} mainKey 49 | * @return {Object} 50 | */ 51 | function byId (resources, mainKey) 52 | { 53 | var results = {}; 54 | _.each(resources, function (resourceObject) { 55 | var resource = resourceObject[mainKey]; 56 | var id = resource.id; 57 | results[id] = resourceObject; 58 | }); 59 | 60 | return results; 61 | } 62 | 63 | 64 | _Harvest.prototype = { 65 | USER_AGENT : "Neverbland Slack - Harvest Integration Middleman", 66 | 67 | /** 68 | * Provides harvest entries for given user 69 | * 70 | * @param {Number} user_id 71 | * @param {Date} fromDate 72 | * @param {Date} toDate 73 | * @param {Function} callback The callback that takes 74 | * the error and returned data 75 | */ 76 | getUserTimeTrack : function (user_id, fromDate, toDate, callback) 77 | { 78 | var reports = this.load('Reports'); 79 | reports.timeEntriesByUser({ 80 | user_id : user_id, 81 | from : formatDate(fromDate), 82 | to : formatDate(toDate) 83 | }, callback); 84 | }, 85 | 86 | load : function (component) 87 | { 88 | var Component = require('../../../node_modules/harvest/lib/' + this.decamelize(component) + '.js'); 89 | return new Component(this.harvest); 90 | }, 91 | 92 | 93 | /** 94 | * Turns a camel case string into a dash separated string 95 | * 96 | * @param {String} inputString 97 | * @returns {String} 98 | */ 99 | decamelize : function (inputString) 100 | { 101 | return humps.decamelize(inputString, '-'); 102 | }, 103 | 104 | 105 | 106 | /** 107 | * Passes loaded projects to callback 108 | * 109 | * @param {Function} callback 110 | * @param {Boolean} force 111 | * @return {Object} 112 | */ 113 | getProjects : function (callback, force) 114 | { 115 | force = force || false; 116 | if (force || (this.projects === {})) { 117 | this.doGetProjects(callback); 118 | } else { 119 | callback(null, this.projects); 120 | } 121 | }, 122 | 123 | 124 | doGetProjects : function (callback) 125 | { 126 | var that = this; 127 | callback = callback || function() {}; 128 | var projects = this.load('Projects'); 129 | projects.list({}, function (err, results) { 130 | if (err === null) { 131 | that.projects = _.assign(that.projects, byId(results, 'project')); 132 | } else { 133 | logger.log('Not able to load all projects.', err, {}); 134 | } 135 | callback(err, results); 136 | }); 137 | }, 138 | 139 | 140 | /** 141 | * Fetches all projects by given ids one by one. If some already exists and is 142 | * stored in the app, fetches it from the app storage. 143 | * 144 | * @param {Array} ids An array of ids 145 | * @param {Function} callback The callback that takes the error 146 | * and projects 147 | */ 148 | getProjectsByIds : function (ids, callback) 149 | { 150 | this.populate('getProject', 'projects', ids, callback); 151 | }, 152 | 153 | 154 | populate : function (methodName, cacheName, ids, callback) 155 | { 156 | var that = this; 157 | var promises = []; 158 | if (!ids.length) { 159 | callback(null, ids); 160 | return; // No need to do anything for an empty request 161 | } 162 | _.each(ids, function (id) { 163 | var def = Q.defer(); 164 | if (!!that[cacheName][id]) { 165 | def.resolve(that[cacheName][id]); 166 | } else { 167 | that[methodName].call(that, id, function (err, resource) { 168 | if (err !== null) { 169 | logger.log('Not able to load resource for method ' + methodName + ', id: ' + id, err, {}); 170 | def.resolve(null); 171 | } else { 172 | that[cacheName][id] = resource; 173 | def.resolve(resource); 174 | } 175 | }); 176 | } 177 | promises.push(def.promise); 178 | }); 179 | 180 | Q.all(promises).then(function (items) { 181 | 182 | var validItems = []; 183 | _.each(items, function (item) { 184 | if (item !== null) { 185 | validItems.push(item); 186 | } 187 | }); 188 | callback(null, validItems); 189 | }); 190 | }, 191 | 192 | 193 | /** 194 | * Fetches all clients by given ids one by one. If some already exists and is 195 | * stored in the app, fetches it from the app storage. 196 | * 197 | * @param {Array} ids An array of ids 198 | * @param {Function} callback The callback that takes the error 199 | * and clients 200 | */ 201 | getClientsByIds : function (ids, callback) 202 | { 203 | this.populate('getClient', 'clients', ids, callback); 204 | }, 205 | 206 | 207 | /** 208 | * Runs callback on loaded clients 209 | * 210 | * @param {Function} callback 211 | * @param {Boolean} force 212 | * @return {Object} 213 | */ 214 | getClients : function (callback, force) 215 | { 216 | if (force || (this.clients === {})) { 217 | this.doGetClients(callback); 218 | } else { 219 | callback(null, this.clients); 220 | } 221 | }, 222 | 223 | 224 | /** 225 | * Fetches client for given client id and applies callback on it 226 | * 227 | * @param {Number} clientId 228 | * @param {Function} callback Callback takes err and resource 229 | * (client) as params 230 | */ 231 | getClient : function (clientId, callback) 232 | { 233 | var that = this; 234 | this.clients = this.clients || {}; 235 | var clients = this.load('Clients'); 236 | clients.get({ 237 | id : clientId 238 | }, function (err, results) { 239 | if (err === null) { 240 | that.clients[clientId] = results; 241 | } 242 | callback(err, results); 243 | }); 244 | }, 245 | 246 | 247 | /** 248 | * Fetches project for given project id and applies callback on it 249 | * 250 | * @param {Number} projectId 251 | * @param {Function} callback Callback takes err and resource 252 | * (project) as params 253 | */ 254 | getProject : function (projectId, callback) 255 | { 256 | var that = this; 257 | this.projects = this.projects || {}; 258 | var projects = this.load('Projects'); 259 | projects.get({ 260 | id : projectId 261 | }, function (err, results) { 262 | if (err === null) { 263 | that.projects[projectId] = results; 264 | } 265 | 266 | callback(err, results); 267 | }); 268 | }, 269 | 270 | 271 | doGetClients : function (callback) 272 | { 273 | var that = this; 274 | callback = callback || function (){}; 275 | var clients = this.load('Clients'); 276 | clients.list({}, function (err, results) { 277 | if (err === null) { 278 | that.clients = _.assign(that.clients, byId(results, 'client')); 279 | } else { 280 | logger.log('Not able to load all clients.', err, {}); 281 | } 282 | callback(err, results); 283 | }); 284 | }, 285 | 286 | 287 | /** 288 | * Loads all user daily tasks and performs a callback on the results 289 | * 290 | * @param {Number} userId The integer value of the user id 291 | * @param {Function} callback 292 | * @returns {undefined} 293 | */ 294 | getTasks : function (userId, callback) 295 | { 296 | var timeTrack = this.load('TimeTracking'); 297 | timeTrack.daily({ 298 | of_user : userId 299 | }, function (err, results) { 300 | if (err !== null) { 301 | logger.log('Not able to load tasks for user ' + userId, err, {}); 302 | } 303 | callback(err, results); 304 | }); 305 | }, 306 | 307 | 308 | /** 309 | * Stops a running task 310 | * 311 | * @param {Number} userId 312 | * @param {Number} dayEntryId 313 | * @param {Function} the callback 314 | */ 315 | toggle : function (userId, dayEntryId, callback) 316 | { 317 | var timeTrack = this.load('TimeTracking'); 318 | timeTrack.toggleTimer({ 319 | of_user : userId, 320 | id : dayEntryId 321 | }, function (err, results) { 322 | 323 | if (err !== null) { 324 | logger.log('Not able to toggle task day entry for user ' + userId + ' and id ' + dayEntryId, err, {}); 325 | } 326 | callback(err, results); 327 | }); 328 | }, 329 | 330 | 331 | /** 332 | * Updates given entry id for given user with given values 333 | * 334 | * @param {Number} userId 335 | * @param {Number} dayEntryId 336 | * @param {Object} values 337 | * @param {Function} callback 338 | * @returns {undefined} 339 | */ 340 | update : function (userId, dayEntryId, values, callback) 341 | { 342 | var timeTrack = this.load('TimeTracking'); 343 | values.of_user = userId; 344 | values.id = dayEntryId; 345 | timeTrack.update(values, function (err, results) { 346 | 347 | if (err !== null) { 348 | logger.log('Not able to update task day entry for user ' + userId + ' and id ' + dayEntryId, err, {}); 349 | } 350 | callback(err, results); 351 | }); 352 | }, 353 | 354 | 355 | /** 356 | * Creates the day entry 357 | * 358 | * @param {Number} userId 359 | * @param {Number} projectId 360 | * @param {Number} taskId 361 | * @param {Function} callback 362 | */ 363 | createEntry : function (userId, projectId, taskId, callback) 364 | { 365 | var timeTrack = this.load('TimeTracking'); 366 | timeTrack.create({ 367 | of_user : userId, 368 | task_id : taskId, 369 | project_id : projectId, 370 | hours : '' 371 | }, function (err, results) { 372 | if (err !== null) { 373 | logger.log('Not able to create an entry for user ' + userId + ' and taskId ' + taskId, err, {}); 374 | } 375 | callback(err, results); 376 | }); 377 | }, 378 | 379 | 380 | /** 381 | * Returns all harvest user ids 382 | * 383 | * @param {Object} userMap A map of harvest id -> slack id 384 | * @returns {undefined} 385 | */ 386 | fromUserMap : function (userMap) 387 | { 388 | var results = []; 389 | for (var hId in userMap) { 390 | if (userMap.hasOwnProperty(hId)) { 391 | results.push(hId); 392 | } 393 | } 394 | 395 | return results; 396 | }, 397 | 398 | 399 | 400 | /** 401 | * Sets the available user ids for this instance of the service 402 | * 403 | * @param {Array} users 404 | * @returns {undefined} 405 | */ 406 | setUsers : function (users) 407 | { 408 | this.users = users; 409 | } 410 | }; 411 | _Harvest.prototype.constructor = _Harvest; 412 | 413 | /** 414 | * Creates a new instance if such instance does not exist. If exists, returns 415 | * the existing one. 416 | * 417 | * @param {String} key 418 | * @param {Object} config 419 | * @returns {_Harvest} 420 | */ 421 | module.exports = function (key, config) 422 | { 423 | if (!!instances[key]) { 424 | return instances[key]; 425 | } else { 426 | instances[key] = new _Harvest(config); 427 | return instances[key]; 428 | } 429 | } -------------------------------------------------------------------------------- /test/unit/services/harvest/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var chai = require("chai"), 4 | expect = require('chai').expect, 5 | sinon = require("sinon"), 6 | sinonChai = require("sinon-chai"), 7 | harvest = require('./../../../../app/services/harvest')('default', { 8 | subdomain : "test", 9 | email : "test@test.com", 10 | password : "password" 11 | }), 12 | harvestModule = harvest.harvest; 13 | chai.use(sinonChai); 14 | 15 | describe('harvest', function () { 16 | describe('harvest.getUserTimeTrack', function () { 17 | it('Should send a request to Harvest API with valid user data', function () { 18 | 19 | var userId = 123456, 20 | dateForm = { 21 | date : new Date(0), 22 | string : '19700101' 23 | }, 24 | dateTo = { 25 | date : new Date(0), 26 | string : '19700101' 27 | }, 28 | expectedUrl = '/people/' + userId + '/entries?from=' + dateForm.string + '&to=' + dateTo.string, 29 | cb = function (err, result) {}; 30 | 31 | 32 | harvestModule.client.get = function (url, data, cb) { 33 | expect(url).to.be.equal(expectedUrl); 34 | expect(cb).to.be.equal(cb); 35 | }; 36 | 37 | 38 | harvest.getUserTimeTrack(userId, dateForm.date, dateTo.date, cb); 39 | }); 40 | }); 41 | 42 | 43 | describe('harvest.getProjects', function () { 44 | it('Should send a request to Harvest API for given projects url if projects not present and load not foeced.', function () { 45 | 46 | var expectedUrl = '/projects', 47 | cb = function (err, result) {}; 48 | 49 | 50 | harvestModule.client.get = function (url, data, cb) { 51 | expect(url).to.be.equal(expectedUrl); 52 | expect(cb).to.be.equal(cb); 53 | }; 54 | 55 | 56 | harvest.getProjects(cb); 57 | }); 58 | 59 | it('Should not send a request to Harvest API for given projects url if projects are present and load not foeced.', function () { 60 | 61 | var cb = sinon.spy(); 62 | harvest.projects = {id : {}}; // Some not null stuff 63 | harvestModule.client.get = function (url, data, cb) { 64 | throw new Error("Random error to ensure this method is not executed."); 65 | }; 66 | harvest.getProjects(cb); 67 | expect(cb).to.have.been.calledWith(null, harvest.projects); 68 | harvest.projects = {}; 69 | }); 70 | 71 | it('Should send a request to Harvest API for given projects url even if projects are present if load is foeced.', function () { 72 | 73 | var expectedUrl = '/projects', 74 | cb = sinon.spy(); 75 | 76 | 77 | 78 | harvest.projects = {id : {}}; // Some not null stuff 79 | 80 | harvestModule.client.get = function (url, data, cb) { 81 | expect(url).to.be.equal(expectedUrl); 82 | expect(cb).to.be.equal(cb); 83 | }; 84 | 85 | 86 | harvest.getProjects(cb, true); 87 | harvest.projects = {}; 88 | }); 89 | }); 90 | 91 | 92 | 93 | describe('harvest.getClients', function () { 94 | it('Should send a request to Harvest API for given clients url if clients not present and load not foeced.', function () { 95 | 96 | var expectedUrl = '/clients', 97 | cb = function (err, result) {}; 98 | 99 | 100 | harvestModule.client.get = function (url, data, cb) { 101 | expect(url).to.be.equal(expectedUrl); 102 | expect(cb).to.be.equal(cb); 103 | }; 104 | 105 | 106 | harvest.getClients(cb); 107 | }); 108 | 109 | it('Should not send a request to Harvest API for given clients url if clients are present and load not foeced.', function () { 110 | 111 | var cb = sinon.spy(); 112 | harvest.clients = {id : {}}; // Some not null stuff 113 | harvestModule.client.get = function (url, data, cb) { 114 | throw new Error("Random error to ensure this method is not executed."); 115 | }; 116 | harvest.getClients(cb); 117 | expect(cb).to.have.been.calledWith(null, harvest.clients); 118 | harvest.clients = {}; 119 | }); 120 | 121 | it('Should send a request to Harvest API for given clients url even if clients are present if load is foeced.', function () { 122 | 123 | var expectedUrl = '/clients', 124 | cb = sinon.spy(); 125 | 126 | 127 | 128 | harvest.clients = {id : {}}; // Some not null stuff 129 | 130 | harvestModule.client.get = function (url, data, cb) { 131 | expect(url).to.be.equal(expectedUrl); 132 | expect(cb).to.be.equal(cb); 133 | }; 134 | 135 | 136 | harvest.getClients(cb, true); 137 | harvest.clients = {}; 138 | }); 139 | }); 140 | 141 | 142 | describe('harvest.getProject', function () { 143 | it('Should send a request to Harvest API for given project url.', function () { 144 | 145 | var projectId = 12345, 146 | expectedUrl = '/projects/' + projectId, 147 | cb = function (err, result) {}; 148 | 149 | harvestModule.client.get = function (url, data, cb) { 150 | expect(url).to.be.equal(expectedUrl); 151 | expect(cb).to.be.equal(cb); 152 | }; 153 | 154 | harvest.getProject(projectId, cb); 155 | }); 156 | }); 157 | 158 | 159 | describe('harvest.getClient', function () { 160 | it('Should send a request to Harvest API for given client url.', function () { 161 | 162 | var clientId = 12345, 163 | expectedUrl = '/clients/' + clientId, 164 | cb = function (err, result) {}; 165 | 166 | harvestModule.client.get = function (url, data, cb) { 167 | expect(url).to.be.equal(expectedUrl); 168 | expect(cb).to.be.equal(cb); 169 | }; 170 | 171 | harvest.getClient(clientId, cb); 172 | }); 173 | }); 174 | 175 | 176 | describe('harvest.getProjectsByIds', function () { 177 | it('Should call a callback with empty array if given ids array is empty.', function () { 178 | 179 | var cb = sinon.spy(), 180 | emptyIds = []; 181 | 182 | harvest.getProjectsByIds(emptyIds, cb); 183 | expect(cb).to.have.been.calledWith(null, emptyIds); 184 | }); 185 | 186 | it('Should call a callback with an array of projects without calling an API if the projects are preloaded.', function () { 187 | var projectsById = { 188 | 1 : "Dummy Item 1", 189 | 2 : "Dummy Item 2", 190 | 3 : "Dummy Item 3" 191 | }, 192 | cb = function (err, results) { 193 | expect(err).to.be.equal(null); 194 | expect(results).to.include.members([ 195 | projectsById[1], 196 | projectsById[2], 197 | projectsById[3] 198 | ]); 199 | }, 200 | ids = [1,2,3]; 201 | 202 | harvest.projects = projectsById; 203 | harvestModule.client.get = function (url, data, cb) { 204 | throw new Error("Random error to ensure this method is not executed."); 205 | }; 206 | 207 | harvest.getProjectsByIds(ids, cb); 208 | harvest.projects = {}; 209 | }); 210 | 211 | 212 | it('Should call a callback with an array of projects with calling an API if the projects are not preloaded.', function () { 213 | var projectsById = { 214 | 1 : "Dummy Item 1", 215 | 2 : "Dummy Item 2", 216 | 3 : "Dummy Item 3" 217 | }, 218 | cb = function (err, results) { 219 | expect(err).to.be.equal(null); 220 | expect(results).to.include.members([ 221 | "Dummy Item 2", 222 | "Dummy Item 3", 223 | "Dummy Item 4" 224 | ]); 225 | }, 226 | ids = [2,3,4]; 227 | 228 | harvest.projects = projectsById; 229 | harvestModule.client.get = function (url, data, cb) { 230 | expect(url).satisfy(function (givenUrl) { 231 | return (['/projects/3', '/projects/4'].indexOf(givenUrl) === -1) ? false : true; 232 | }); 233 | 234 | var splitUrl = url.substr(1).split('/'); 235 | cb(null, "Dummy Item " + splitUrl[1]); 236 | }; 237 | 238 | harvest.getProjectsByIds(ids, cb); 239 | harvest.projects = {}; 240 | }); 241 | }); 242 | 243 | 244 | describe('harvest.getClientsByIds', function () { 245 | it('Should call a callback with empty array if given ids array is empty.', function () { 246 | 247 | var cb = sinon.spy(), 248 | emptyIds = []; 249 | 250 | harvest.getClientsByIds(emptyIds, cb); 251 | expect(cb).to.have.been.calledWith(null, emptyIds); 252 | }); 253 | 254 | it('Should call a callback with an array of clients without calling an API if the clients are preloaded.', function () { 255 | var clientsById = { 256 | 1 : "Dummy Item 1", 257 | 2 : "Dummy Item 2", 258 | 3 : "Dummy Item 3" 259 | }, 260 | cb = function (err, results) { 261 | expect(err).to.be.equal(null); 262 | expect(results).to.include.members([ 263 | clientsById[1], 264 | clientsById[2], 265 | clientsById[3] 266 | ]); 267 | }, 268 | ids = [1,2,3]; 269 | 270 | harvest.clients = clientsById; 271 | harvestModule.client.get = function (url, data, cb) { 272 | throw new Error("Random error to ensure this method is not executed."); 273 | }; 274 | 275 | harvest.getClientsByIds(ids, cb); 276 | harvest.clients = {}; 277 | }); 278 | 279 | 280 | it('Should call a callback with an array of clients with calling an API if the clients are not preloaded.', function () { 281 | var clientsById = { 282 | 1 : "Dummy Item 1", 283 | 2 : "Dummy Item 2", 284 | 3 : "Dummy Item 3" 285 | }, 286 | cb = function (err, results) { 287 | expect(err).to.be.equal(null); 288 | expect(results).to.include.members([ 289 | "Dummy Item 2", 290 | "Dummy Item 3", 291 | "Dummy Item 4" 292 | ]); 293 | }, 294 | ids = [2,3,4]; 295 | 296 | harvest.clients = clientsById; 297 | harvestModule.client.get = function (url, data, cb) { 298 | expect(url).satisfy(function (givenUrl) { 299 | return (['/clients/3', '/clients/4'].indexOf(givenUrl) === -1) ? false : true; 300 | }); 301 | 302 | var splitUrl = url.substr(1).split('/'); 303 | cb(null, "Dummy Item " + splitUrl[1]); 304 | }; 305 | 306 | harvest.getClientsByIds(ids, cb); 307 | harvest.clients = {}; 308 | }); 309 | }); 310 | 311 | 312 | describe('harvest.toggle', function () { 313 | it('Should send a request to Harvest API for given day entry id url.', function () { 314 | 315 | var dayEntryId = 12345, 316 | userId = 23456, 317 | expectedUrl = '/daily/timer/' + dayEntryId + '?of_user=' + userId, 318 | cb = function (err, result) {}; 319 | 320 | harvestModule.client.get = function (url, data, cb) { 321 | expect(url).to.be.equal(expectedUrl); 322 | expect(cb).to.be.equal(cb); 323 | }; 324 | 325 | harvest.toggle(userId, dayEntryId, cb); 326 | }); 327 | }); 328 | 329 | 330 | describe('harvest.createEntry', function () { 331 | it('Should send a request to Harvest API making a POST call with proper params.', function () { 332 | 333 | var dayEntryId = 12345, 334 | projectId = 23456, 335 | userId = 9876, 336 | taskId = 6789, 337 | expectedUrl = '/daily/add?of_user=' + userId, 338 | cb = function (err, result) {}; 339 | 340 | harvestModule.client.post = function (url, data, cb) { 341 | expect(url).to.be.equal(expectedUrl); 342 | expect(cb).to.be.equal(cb); 343 | expect(data).to.be.deep.equal({ 344 | task_id : taskId, 345 | project_id : projectId, 346 | hours: '' 347 | }); 348 | }; 349 | 350 | harvest.createEntry(userId, projectId, taskId, cb); 351 | }); 352 | }); 353 | }); --------------------------------------------------------------------------------