From 655b94b49c31be86df1917249bae2b732056344b Mon Sep 17 00:00:00 2001 From: Maciej Garycki Date: Tue, 26 Apr 2016 13:44:36 +0200 Subject: [PATCH] forecast notification provider #14 --- app.js | 6 +- app/services/cronjobs/index.js | 35 ++++- app/services/cronjobs/jobs/forecast.js | 83 ++++++++++ app/services/cronjobs/lib/jobs.js | 8 +- app/services/forecast/index.js | 180 ++++++++++++++++++++++ app/services/slack/notifier/forecast.js | 192 ++++++++++++++++++++++++ consts.json | 3 + package.json | 3 +- 8 files changed, 498 insertions(+), 12 deletions(-) create mode 100644 app/services/cronjobs/jobs/forecast.js create mode 100644 app/services/forecast/index.js create mode 100644 app/services/slack/notifier/forecast.js diff --git a/app.js b/app.js index a4029a9..07d65eb 100644 --- a/app.js +++ b/app.js @@ -20,11 +20,14 @@ var notifier = require('./app/services/notifier'), reportNotifier = require('./app/services/report')(slack, harvest), slackNotifier = require('./app/services/slack/notifier')(slack, harvest), + slackForecastNotifier = require('./app/services/slack/notifier/forecast')(slack, harvest), slackReminder = require('./app/services/slack/notifier/remind')(slack, harvest), i18n = require('i18n'), + forecast = require('./app/services/forecast')('default', config.forecast), server ; + i18n.configure({ locales : ['en'], directory: __dirname + '/locales', @@ -41,8 +44,9 @@ i18n.configure({ harvest.setUsers(config.users); slack.setUsers(config.users); -// Defining two notification channels +// Defining three notification channels notifier.addNotifier('users', slackNotifier); +notifier.addNotifier('forecast', slackForecastNotifier); notifier.addNotifier('reminder', slackReminder); notifier.addNotifier('management', reportNotifier); diff --git a/app/services/cronjobs/index.js b/app/services/cronjobs/index.js index 4d82b06..a0b4046 100644 --- a/app/services/cronjobs/index.js +++ b/app/services/cronjobs/index.js @@ -1,17 +1,36 @@ /*jshint node: true*/ 'use strict'; -var cron = require('cron'), - notifier = require('./../notifier'), - harvest = require('./../harvest')('default'), - _ = require('lodash'), - tools = require('./../tools.js'), - logger = require('./../logger.js')('default'); +var + jobs = require('./lib/jobs.js'), + walk = require('walk') +; module.exports = function (app, config) { - var cronJobs = require('./lib/jobs.js')(config); - cronJobs.run(); + var walker, + cronJobs = jobs(config) + ; + + walker = walk.walk(__dirname + '/jobs', { + followLinks : false + }); + + walker.on('file', function (root, stat, next) { + var file = __dirname + '/jobs/' + stat.name, + baseName = stat.name.substr(0, stat.name.length - 3), + conf = config[baseName] || {}, + job = require(file) + ; + + cronJobs.addJob(job, conf); + + next(); + }); + + walker.on('end', function () { + cronJobs.run(); + }); }; \ No newline at end of file diff --git a/app/services/cronjobs/jobs/forecast.js b/app/services/cronjobs/jobs/forecast.js new file mode 100644 index 0000000..5a98075 --- /dev/null +++ b/app/services/cronjobs/jobs/forecast.js @@ -0,0 +1,83 @@ +/*jshint node: true*/ +'use strict'; + +var + i18n = require('i18n'), + notifier = require('./../../notifier/index.js') + consts = require('./../../../../consts.json'), + forecast = require('./../../forecast')('default'), + logger = require('./../../logger.js')('default'), + moment = require('moment'), + job = { + + /** + * Returns the job function + * + * @param {Object} config + * @returns {Function} + */ + getJob : function (config) + { + return function () + { + var options = { + startDate : moment().startOf('day'), + endDate : moment().endOf('day') + }; + + forecast.assignments(options, function (error, assignments) { + if (error) { + logger.error(i18n.__('Failed loading forecast schedule.', {})); + } else { + notifier.notify('forecast', { + assignments : assignments + }); + } + }); + }; + }, + + /** + * Formats the cron time according to given config + * + * @param {Object} config + * @returns {String} The cron time format string + */ + getCronTime : function (config) + { + var cronTime; + if (!!config.cronTime) { + cronTime = config.cronTime; + } else if (!!config.minutes && !!config.hour) { + cronTime = '00 ' + config.minutes + ' ' + config.hour + ' * * 1-5'; + } + + return !!cronTime ? cronTime : consts.forecast.CRON_TIME; // by default every midday of working day; + }, + + /** + * Defines if given job should be ran independently from setting it up + * with cron + * + * @returns {Boolean} + */ + shouldRunNow : function (config) + { + return true; + }, + + + /** + * Returns description of the task + * + * @returns {String} + */ + getDescription : function () + { + return i18n.__('User notifications for their forecast schedule.'); + } + } +; + + +module.exports = job; \ No newline at end of file diff --git a/app/services/cronjobs/lib/jobs.js b/app/services/cronjobs/lib/jobs.js index 8cc4630..baa8dae 100644 --- a/app/services/cronjobs/lib/jobs.js +++ b/app/services/cronjobs/lib/jobs.js @@ -3,6 +3,7 @@ var notifier = require('./../../notifier/index.js'), harvest = require('./../../harvest')('default'), + forecast = require('./../../forecast')('default'), _ = require('lodash'), tools = require('./../../tools.js'), logger = require('./../../logger.js')('default'), @@ -167,6 +168,8 @@ var defaultJobs = { harvest.doGetProjects(); logger.info(i18n.__('Loading clients from Harvest API...'), {}); harvest.doGetClients(); + logger.info(i18n.__('Loading forecast clients/projects/people from Forecast API...')); + forecast.preload(true); }; }, @@ -331,8 +334,9 @@ var defaultJobs = { module.exports = function (config, additionalJobs) { - var jobsHolder = new JobsHolder(); - var jobs = _.assign(defaultJobs, additionalJobs); + var jobsHolder = new JobsHolder(), + jobs = _.assign(defaultJobs, additionalJobs) + ; _.each(config, function (configValues, jobName) { var job = jobs[jobName]; if (!!job) { diff --git a/app/services/forecast/index.js b/app/services/forecast/index.js new file mode 100644 index 0000000..dccbb44 --- /dev/null +++ b/app/services/forecast/index.js @@ -0,0 +1,180 @@ +/*jshint node: true*/ +'use strict'; + +var Forecast = require('forecast-api'), + _ = require('lodash'), + Q = require('q'), + logger = require('./../logger.js')('default'), + i18n = require('i18n'), + instances = {} +; + + +/** + * Explodes the resources array by id + * + * @param {Array} resources + * @return {Object} + */ +function byId (resources) +{ + var results = {}; + _.each(resources, function (resourceObject) { + var id = resourceObject.id; + results[id] = resourceObject; + }); + + return results; +} + + +function ForecastWrapper (config) +{ + this.config = config; + this.forecast = new Forecast(config); +} + + +ForecastWrapper.prototype = { + + caches : { + projects : null, + clients : null, + people: null + }, + + /** + * Wrapper function for assignments API call + * + * @param {Object} options + * @param {Function} callback + * @returns {undefined} + */ + assignments : function (options, callback) { + var that = this; + this.forecast.assignments(options, function (err, assignments) { + if (err) { + callback(err, assignments); + } else { + that.mergeAssignments(assignments, callback); + } + }); + }, + + /** + * + */ + mergeAssignments : function (assignments, callback) { + var that = this; + this.preload(false, function () { + that.doMerge(assignments, callback); + }); + }, + + + /** + * Preloading clients, projects and people + * + * @param {Boolean} force + * @param {Function} callback + * @returns {undefined} + */ + preload : function (force, callback) { + var promises = [], + that = this + ; + _.each(this.caches, function (values, key) { + var def = Q.defer(); + + if ((values === null) || force) { + that.doPreload(key, function (valuesFromApi) { + that.caches[key] = valuesFromApi; + def.resolve(valuesFromApi); + }); + } else { + def.resolve(values); + + } + promises.push(def.promise); + }); + + Q.all(promises).then(function (items) { + if (callback) { + callback(); + } + }, function (err) { + console.log(err); + }).catch(function () { + console.log(Array.prototype.slice.call(arguments)); + }); + }, + + + doPreload : function (key, callback) { + var that = this; + this.forecast[key].call(that.forecast, function (err, items) { + if (err) { + logger.log(i18n.__('Not able to load forecast resource for method {{methodName}}', { + methodName : key + }), err, {}); + callback(null); + } else { + callback(items); + } + }); + }, + + + doMerge : function (assignments, callback) { + var projects = this.caches.projects, + clientsById = byId(this.caches.clients), + projectsById, + peopleById = byId(this.caches.people) + ; + + _.each(projects, function (project) { + var clientId = project.client_id, + client = clientsById[clientId] || null + ; + project.client = client; + }); + + projectsById = byId(projects); + + + _.each(assignments, function (assignmentObject) { + var projectId = assignmentObject.project_id, + personId = assignmentObject.person_id + ; + assignmentObject.project = projectsById[projectId] || null; + assignmentObject.person = peopleById[personId] || null; + }); + + callback(null, assignments); + } +}; + + +ForecastWrapper.prototype.constructor = ForecastWrapper; + +/** + * Creates a new instance if such instance does not exist. If exists, returns + * the existing one. + * + * @param {String} key + * @param {Object} config + * @returns {Harvest} + */ +module.exports = function (key, config) +{ + if (!!instances[key]) { + return instances[key]; + } else { + if (!!config && !!config.accountId && config.authorization) { + instances[key] = new ForecastWrapper(config); + return instances[key]; + } + + return null; + } +}; \ No newline at end of file diff --git a/app/services/slack/notifier/forecast.js b/app/services/slack/notifier/forecast.js new file mode 100644 index 0000000..cc596e6 --- /dev/null +++ b/app/services/slack/notifier/forecast.js @@ -0,0 +1,192 @@ +/*jshint node: true*/ +'use strict'; + +var _ = require('lodash'), + events = require("events"), + logger = require('./../../logger.js')('default'), + tools = require('./../../tools.js'), + i18n = require('i18n'), + instance = null +; + + +/** + * Fetches the user name + * + * @param {Object} users A map of harvest id -> slack id + * @param {Number} harvestUserId + * @returns {String} + */ +function getUserName (users, harvestUserId) +{ + var response; + for (var harvestId in users) { + if (String(harvestId) === String(harvestUserId)) { + response = users[harvestId]; + } + } + + return response; +} + + +/** + * Aggregates assignments by user + * + * @param {Object} assignments + * @returns {Object} + */ +function aggregateByUser (assignments) +{ + var results = {}; + _.each(assignments, function (assignment) { + var person = assignment.person, + harvestUserId = person ? person.harvest_user_id : null, + project = assignment.project + ; + + if (harvestUserId) { + results[harvestUserId] = results[harvestUserId] || { + person : person, + projects : [] + }; + + results[harvestUserId].projects.push(project); + } + }); + + return results; +} + + +function format (title, text) +{ + return [title, text].join("\n"); +} + + +/** + * Sends notifications via slack + * + * @author Maciej Garycki + * + * @param {Object} slack The slack object + * @param {Object} harvest The harvest object + * @constructor + */ +function SlackNotifier (slack, harvest) +{ + this.slack = slack; + this.harvest = harvest; +} + + +var SlackNotifierPrototype = function () +{ + + /** + * Sends notification to slack + * + * @param {Object} slackContext + * @returns {undefined} + */ + this.notify = function (slackContext) + { + var that = this, + assignments = slackContext.assignments, + assignmentsByUser = aggregateByUser(assignments) + ; + _.each(assignmentsByUser, function (userAssignments, harvestId) { + if (!userAssignments.assignments.length) { + return; + } + var text = that.prepareText(userAssignments), + title = that.prepareTitle(userAssignments), + slackId = getUserName(that.slack.users, harvestId), + fullText = format(title, text) + ; + + if (!slackId) { + return; + } + + that.slack.sendMessage(fullText, { + channel : '@' + slackId + }, function (err, httpResponse, body) { + if (err === null) { + logger.info(i18n.__('Successfully sent a forecast schedule message to user %s', slackId), {}); + } else { + logger.info(i18n.__('Forecast schedule message for user %s not sent.', slackId), err, {}); + } + }); + }); + }; + + + + /** + * + * @param {Object} userAssignments + * @returns {undefined} + */ + this.prepareTitle = function (userAssignments) + { + + var name = userAssignments.person.first_name + ' ' + userAssignments.person.last_name; + return i18n.__('Projects assignments schedule for {{name}}:', { + name : name + }); + + } + + + /** + * prepares the text and triggers propper event when ready + * + * @param {Array} userAssignments + * @returns {undefined} + */ + this.prepareText = function (userAssignments) + { + var results = []; + _.each(userAssignments, function (assignment) { + var text = [], + project = assignment.project, + client = project ? assignment.project.client : null, + timeSeconds = assignment.project.allocation, + timeText = tools.formatTime(timeSeconds) + ; + + text.push(timeSeconds); + if (client) { + text.push(client.name); + } + if (project) { + text.push(project.name); + } + + if (!project && !client) { + text.push(i18n.__('N/A')); + } + + text.push(timeText); + + results.push(text.join(' - ')); + }); + + return results.join("\n"); + }; +}; + +SlackNotifierPrototype.prototype = new events.EventEmitter(); + + +SlackNotifier.prototype = new SlackNotifierPrototype(); +SlackNotifier.prototype.constructor = SlackNotifier; + +module.exports = function (slack, harvest) { + instance = new SlackNotifier(slack, harvest); + module.exports.instance = instance; + + return instance; +}; diff --git a/consts.json b/consts.json index e56ab20..fc66fd5 100644 --- a/consts.json +++ b/consts.json @@ -13,5 +13,8 @@ }, "userSession" : { "sessionTime" : 120000 + }, + "forecast" : { + "CRON_TIME" : "00 00 10 * * 1-5" } } \ No newline at end of file diff --git a/package.json b/package.json index 4cc985b..932143f 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "date-util" : "1.2.1", "humps" : "0.5.1", "walk" : "2.3.9", - "i18n" : "0.6.*" + "i18n" : "0.6.*", + "forecast-api" : "*" }, "devDependencies": { "blanket": "1.1.6",