├── .eslintrc ├── .gitignore ├── .nvmrc ├── .travis.yml ├── Dockerfile ├── LICENSE ├── README.md ├── app.json ├── basic-auth-validate.js ├── config.js ├── constants ├── commandTypes.js ├── commands.js └── statuses.js ├── controllers └── employee.js ├── index.js ├── models ├── base.js ├── db.js └── employee.js ├── package.json ├── routes.js ├── test └── default_employee_status_spec.js └── util ├── commandMapper.js ├── commandParser.js ├── logEvent.js └── slack.js /.eslintrc: -------------------------------------------------------------------------------- 1 | env: 2 | node: true 3 | mocha: true 4 | 5 | # enable ECMAScript features 6 | ecmaFeatures: 7 | arrowFunctions: true 8 | binaryLiterals: true 9 | blockBindings: true 10 | classes: true 11 | forOf: true 12 | generators: true 13 | objectLiteralShorthandMethods: true 14 | objectLiteralShorthandProperties: true 15 | octalLiterals: true 16 | templateStrings: true 17 | 18 | rules: 19 | # Possible Errors 20 | # list: https://github.com/eslint/eslint/tree/master/docs/rules#possible-errors 21 | ## check debugger sentence 22 | no-debugger: 2 23 | ## check duplicate arguments 24 | no-dupe-args: 2 25 | ## check duplicate object keys 26 | no-dupe-keys: 2 27 | ## check duplicate switch-case 28 | no-duplicate-case: 2 29 | ## disallow assignment of exceptional params 30 | no-ex-assign: 2 31 | ## disallow unreachable code 32 | no-unreachable: 2 33 | ## require valid typeof compared string like typeof foo === 'strnig' 34 | valid-typeof: 2 35 | 36 | # Best Practices 37 | # list: https://github.com/eslint/eslint/tree/master/docs/rules#best-practices 38 | ## require falls through comment on switch-case 39 | no-fallthrough: 2 40 | 41 | # Stylistic Issues 42 | # list: https://github.com/eslint/eslint/tree/master/docs/rules#stylistic-issues 43 | ## use single quote, we can use double quote when escape chars 44 | quotes: [2, "single", "avoid-escape"] 45 | ## 2 space indentation 46 | indent: [2, 2] 47 | ## add space after comma 48 | comma-spacing: 2 49 | ## put semi-colon 50 | semi: 2 51 | ## require spaces operator like var sum = 1 + 1; 52 | space-infix-ops: 2 53 | ## require spaces return, throw, case 54 | space-return-throw-case: 2 55 | ## no space before function, eg. 'function()' 56 | space-before-function-paren: [2, "never"] 57 | ## require space before blocks, eg 'function() {' 58 | space-before-blocks: [2, "always"] 59 | ## require parens for Constructor 60 | new-parens: 2 61 | ## max 2 consecutive empty lines 62 | no-multiple-empty-lines: [2, {max: 2}] 63 | ## require newline at end of files 64 | eol-last: 2 65 | ## no trailing spaces 66 | no-trailing-spaces: 2 67 | # require space after keywords, eg 'for (..)' 68 | space-after-keywords: 2 69 | 70 | # Strict Mode 71 | # list: https://github.com/eslint/eslint/tree/master/docs/rules#strict-mode 72 | ## 'use strict' on top 73 | strict: [2, "global"] 74 | 75 | # Variables 76 | # list: https://github.com/eslint/eslint/tree/master/docs/rules#variables 77 | ## disallow use of undefined variables (globals) 78 | no-undef: 2 79 | 80 | # Global scoped method and vars 81 | globals: 82 | DTRACE_HTTP_CLIENT_REQUEST : false 83 | LTTNG_HTTP_CLIENT_REQUEST : false 84 | COUNTER_HTTP_CLIENT_REQUEST : false 85 | DTRACE_HTTP_CLIENT_RESPONSE : false 86 | LTTNG_HTTP_CLIENT_RESPONSE : false 87 | COUNTER_HTTP_CLIENT_RESPONSE : false 88 | DTRACE_HTTP_SERVER_REQUEST : false 89 | LTTNG_HTTP_SERVER_REQUEST : false 90 | COUNTER_HTTP_SERVER_REQUEST : false 91 | DTRACE_HTTP_SERVER_RESPONSE : false 92 | LTTNG_HTTP_SERVER_RESPONSE : false 93 | COUNTER_HTTP_SERVER_RESPONSE : false 94 | DTRACE_NET_STREAM_END : false 95 | LTTNG_NET_STREAM_END : false 96 | COUNTER_NET_SERVER_CONNECTION_CLOSE : false 97 | DTRACE_NET_SERVER_CONNECTION : false 98 | LTTNG_NET_SERVER_CONNECTION : false 99 | COUNTER_NET_SERVER_CONNECTION : false 100 | escape : false 101 | unescape : false 102 | emit : true 103 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 27 | node_modules 28 | 29 | dev-config.js -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 4 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | env: 3 | - CXX=g++-4.8 4 | language: node_js 5 | node_js: 6 | - '4.0' 7 | - '4.1' 8 | - '4.2' 9 | deploy: 10 | provider: heroku 11 | on: 12 | branch: master 13 | api_key: 14 | secure: QgdI23f0DLlOUAbJ8Q3y5JZF/SEU/DLGLNmDKqeKJ+8UbPd5q1+sIPWChSiSoKfrvApZz4oUI5fMSSCz/IL+yM/gwZfQ/9gvNDZRxDQW0uNRhP/fPiyqK90us8wMSdHsBuW7bGpldFmdlMtjwqFy8WC5JB0mjuLLBL8Fm15zGMnTNQXYpJvGlQmhuJoBFPrsld/VgMVnXNdofrbE68h+lsPJfNDrZWEUBF+zGMXpotuO2J6piz7M4uVO6zGML3zE+C9Lwt6weLhjCzkimTqOkhCS5VObn+C8JfUI91d1HaV9Fj1ZsNPEqY2VWtqt2Op/KpHQkC/uLxBv9TAh6k2RyirjtZsiBco41O3AWndItFO6V/hNOS8cFER8rf/zKgkTy1cjK6VxlqmkZiS07Rsj/fFDz//u51V3BumT+ldi2f28DIws4iFI7FvxDzsuTdh3c1izOtqfmEc0H/jB3juWcE4jT9zIV0v3UqyyJpFyai1nRt36fXNYjwLM44AfG2g3AL57FHEz+b4O/Vpcckb93sE05UFnZY9xugNCBnPBASmktQ1FwbNUjFXNd3TYh1VmLFbX1+zUTXxYyUL5+gpZJIxVhXYwmp38mZkHB1DhycrBKPGtBm9jmpx7t1QbRgh+wjw3wAUfXj6xzF8NTtGk+SK/r8XTBescLoe4NUDIKBY= 15 | app: 16 | master: pebblecode-wfh 17 | addons: 18 | apt: 19 | sources: 20 | - ubuntu-toolchain-r-test 21 | packages: 22 | - gcc-4.8 23 | - g++-4.8 -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:4.2.1-wheezy 2 | 3 | COPY . /src 4 | RUN cd /src; npm i 5 | 6 | ENV COUCHDB_USERNAME 7 | ENV COUCHDB_PASSWORD 8 | ENV COUCHDB_URL https://pebblecode.cloudant.com 9 | ENV COUCHDB_PORT 443 10 | ENV COUCHDB_NAME pebblecode-wfh-dev 11 | 12 | EXPOSE 3000 13 | CMD ["node", "/src/index.js"] 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2015 pebble{code} (Pebble Code Limited) http://pebblecode.com 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WFH API 2 | ### What is this? 3 | At [pebble {code}][1] we have a remote working policy, so to help find out who is working where we created a system to show the list of workers and their status. For more details see our [blogpost](http://pebblecode.com/blog/november-wfh-slack/) Do you also work in a remote workplace? If you're using Slack you can easily deploy your own, and even start building things with the api. We have two services connecting to our api, one to send a summary email every weekday at 10am and another that pulls sickness and holiday information from our HR system. 4 | 5 | This repo contains a node implementation of the pebble {code}the wfh api, more details see blogpost: http://pebblecode.com/blog/november-wfh-slack/ 6 | 7 | 8 | ###functionality 9 | This adds the following slash commands in slack: 10 | `/wfo` 11 | `/wfh` 12 | 13 | ### Slack slash command parameters 14 | message: `/wfh message:I'll be at home in my pants` or `/wfo message:I'll be at the stand up desk today` 15 | default: `/wfh default:wfo` this allows your default location to be either `wfh,wfo` 16 | 17 | The first time a user does /wfh, /wfo opts the user into the system. After that they will be shown in the api response /workers. 18 | 19 | 20 | #Have your own instance of WFH. [![Deploy your own!](https://www.herokucdn.com/deploy/button.svg)](https://dashboard.heroku.com/new?template=https%3A%2F%2Fgithub.com%2Fpebblecode%2Fwfh3_backend) 21 | You will need to configure Slack slash [commands](https://slack.com/services#service_16). 22 | use `/wfh` and `/wfo` to point to: `/webhooks/slack`, use a post request and add the tokens to the apps environment variables 23 | 24 | #Deployment 25 | - Up to you. we currently use heroku. 26 | - Deploy your own. 27 | [![Deploy your own!](https://www.herokucdn.com/deploy/button.svg)](https://dashboard.heroku.com/new?template=https%3A%2F%2Fgithub.com%2Fpebblecode%2Fwfh3_backend) 28 | - DB is [couchdb](http://couchdb.apache.org/) and easily hosted on [Cloudant](https://cloudant.com/) create a database called: `wfh` 29 | - #####ENV variables Set the following to get your app up and running 30 | - COUCHDB_USERNAME 31 | - COUCHDB_PASSWORD 32 | - COUCHDB_URL 33 | - COUCHDB_PORT 34 | - COUCHDB_NAME 35 | 36 | - SEGMENT_IO_WRITE_KEY (optional) used for tracking and graphs to work out averages for sickness, holiday, out of office etc. 37 | - SLACK_TOKEN used to get a users profile see slack [docs](https://api.slack.com/). 38 | 39 | - ADMIN_PASSWORD for admin user, basic auth password 40 | 41 | #Development && installation 42 | [Install CouchDB](http://couchdb.apache.org/#download) and run. 43 | For a nicer couch db ui install: `npm i -g fauxton` then in a new shell run `fauxton` 44 | 45 | `npm i && npm i -g nodemon` 46 | start server locally 47 | `npm run dev` 48 | 49 | When NODE_ENV === 'development' connection will be made with localhost couchdb. 50 | 51 | ###Configuration for local development. 52 | Copy `config.template.js > config.js` and complete the default fields `getEnv('ENV_VAR','DEFAULT_VALUE')`, 53 | to get a slack token visit: https://api.slack.com/web 54 | 55 | 56 | #Endpoints 57 | 58 | | path | method | payload | details | 59 | |------------|---------|---------|---------| 60 | | /workers | GET | | get all workers, used by email cron and tv display | 61 | | /workers | POST | `{"name":"Jon Snow", "email":"jon.snow@pebblecode.com", status:"Sick" }` | create a new worker, `"message": "your message"` is optional | 62 | | /workers | PUT | `{"email":"jon.snow@pebblecode.com", "status":"Holiday"}` | Update status for worker| 63 | | /workers/id | DELETE | | Delete a worker by uuid| 64 | 65 | #AUTH 66 | is BASIC auth and password is a hashed bcrypt stored in the `ADMIN_PASSWORD` env variable. 67 | 68 | 69 | ###Integrations 70 | The api has full CRUD functionality, see the docs for more details. So this is open for your own integrations. We've used this to integrate with [Tribe HR](https://github.com/pebblecode/tribehr-holiday-fetcher), [send Emails at 10am](https://github.com/pebblecode/wfh-email-cron) or [display on a TV in the office](https://github.com/pebblecode/wfh-frontend). 71 | 72 | ###Architecture 73 | ![image of architecture](http://pebblecode.com/img/posts/2015-11-03-wfh-slack/diagram.png) 74 | 75 | #Helpful Links 76 | - Slack [api](https://api.slack.com/). 77 | - [Segment](https://segment.io). 78 | 79 | #Contributing 80 | Awesome! Get in touch first, make an issue to discuss your change and we can go from there. Will be followed by a PR. 81 | 82 | # TODO: 83 | - Add functionality to delete workers (use slack as source of truth. slack api) 84 | - Websocket support for connecting devices 85 | - Docker deployment - fix bcrypt build errors: https://github.com/ncb000gt/node.bcrypt.js/issues/368 86 | 87 | ###We'd love to hear from you 88 | Let us know what you do with WFH bot. 89 | 90 | [1]: http://pebblecode.com 91 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pebble-code-wfh-server", 3 | "description": "A Hapi, CouchDb rest api for handling /wfh /wfo slash commands from slack", 4 | "repository": "https://github.com/pebblecode/wfh3_backend", 5 | "logo": "https://media.glassdoor.com/sqll/998927/pebble-code-squarelogo-1432313973043.png", 6 | "keywords": ["node", "hapi", "pebble{code}", "working from home"], 7 | "env": { 8 | "COUCHDB_USERNAME": { 9 | "description": "CouchDb username" 10 | }, 11 | "COUCHDB_PASSWORD": { 12 | "description": "CouchDb password" 13 | }, 14 | "COUCHDB_URL":{ 15 | "description":"couch db url, eg: https://.cloudant.com" 16 | }, 17 | "COUCHDB_PORT":{ 18 | "description":"CouchDb port", 19 | "value":"443" 20 | }, 21 | "COUCHDB_NAME":{ 22 | "description":"", 23 | "value":"wfh" 24 | }, 25 | "SLACK_TOKEN":{ 26 | "description": "to get slack profiles" 27 | }, 28 | "SLACK_WEBHOOK_TOKENS":{ 29 | "description":"comma seperated for each webhook, /wfo, /wfh" 30 | }, 31 | "ADMIN_PASSWORD":{ 32 | "description": "admin user password, enter hash value from: https://www.dailycred.com/article/bcrypt-calculator use 10 rounds" 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /basic-auth-validate.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Bcrypt = require('bcrypt'); 4 | const config = require('./config'); 5 | 6 | const users = { 7 | admin:{ 8 | username: 'admin', 9 | password: config.auth.admin.password, 10 | name: 'Admin', 11 | id: 1 12 | } 13 | }; 14 | 15 | module.exports = function(request, username, password, callback) { 16 | var user = users[username]; 17 | 18 | if (!user) { 19 | return callback(null, false); 20 | } 21 | 22 | Bcrypt.compare(password, user.password, (err, isValid) => { 23 | callback(err, isValid, {id: user.id, name: user.name}); 24 | }); 25 | }; 26 | -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var getEnv = require('getenv'); 3 | var path = require('path'); 4 | 5 | module.exports = { 6 | port: getEnv.int('PORT', 3000), 7 | couchDb:{ 8 | username: getEnv('COUCHDB_USERNAME', ''), 9 | password: getEnv('COUCHDB_PASSWORD', ''), 10 | url: getEnv('COUCHDB_URL', 'http://127.0.0.1'), 11 | port: getEnv.int('COUCHDB_PORT', 5984), 12 | dbName: getEnv('COUCHDB_NAME', 'wfh') 13 | }, 14 | segment:{ 15 | writeKey: getEnv('SEGMENT_IO_WRITE_KEY', ''), 16 | }, 17 | slack:{ 18 | token: getEnv('SLACK_TOKEN', ''), 19 | webhooks:{ 20 | requestTokens: getEnv('SLACK_WEBHOOK_TOKENS', '').split(',') 21 | } 22 | }, 23 | auth:{ 24 | admin:{ 25 | password: getEnv('ADMIN_PASSWORD', '$2a$10$oIeQ626Z5yIU7IsvC.1t2.JaegXE1Jn9FaLxF1SfA/jXgQNFah/Wu') 26 | } 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /constants/commandTypes.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const keyMirror = require('keymirror'); 3 | 4 | module.exports = keyMirror({ 5 | default:null, 6 | message:null 7 | }); 8 | -------------------------------------------------------------------------------- /constants/commands.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const keyMirror = require('keymirror'); 3 | 4 | module.exports = keyMirror({ 5 | wfh: null, 6 | wfo: null, 7 | wfhtest: null, 8 | wfotest: null 9 | }); 10 | -------------------------------------------------------------------------------- /constants/statuses.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const keyMirror = require('keymirror'); 3 | 4 | //uppercase to match usage on client and email sender. 5 | module.exports = keyMirror({ 6 | InOffice: null, 7 | OutOfOffice: null, 8 | Sick: null, 9 | Holiday: null 10 | }); 11 | -------------------------------------------------------------------------------- /controllers/employee.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const Boom = require('boom'); 3 | 4 | const config = require('../config'); 5 | const Employee = require('../models/employee'); 6 | const logEvent = require('../util/logEvent'); 7 | const Slack = require('../util/slack'); 8 | const commandMapper = require('../util/commandMapper'); 9 | const commandParser = require('../util/commandParser'); 10 | const commands = require('../constants/commands'); 11 | 12 | const slack = new Slack({ 13 | token: config.slack.token 14 | }); 15 | 16 | module.exports.getAll = function(request, reply) { 17 | 18 | Employee.getAll() 19 | .then((workers) => { 20 | reply(workers); 21 | }) 22 | .catch((err) => { 23 | console.log(err); 24 | reply(Boom.badImplementation()); 25 | }); 26 | 27 | }; 28 | 29 | module.exports.addNew = function(request, reply) { 30 | 31 | var payload = request.payload; 32 | 33 | Employee.getByEmail(payload.email) 34 | .then((employee) => { 35 | if (employee) { 36 | return reply(Boom.conflict()); 37 | } 38 | 39 | if (!Employee.isValidStatus(payload.status)) { 40 | return reply(Boom.badRequest(`Not a valid status type Email: ${payload.email}`)); 41 | } 42 | 43 | var employee = new Employee(payload) 44 | .save(payload) 45 | .then((worker) => { 46 | 47 | reply(); 48 | 49 | if (worker) { 50 | logEvent(employee); 51 | } 52 | }); 53 | 54 | }) 55 | .catch((err) => { 56 | console.log(err); 57 | reply(Boom.badImplementation()); 58 | }); 59 | 60 | }; 61 | 62 | module.exports.updateStatus = function(request, reply) { 63 | var payload = request.payload; 64 | 65 | if (!Employee.isValidStatus(payload.status)) { 66 | return reply(Boom.badRequest('Not a valid status type')); 67 | } 68 | 69 | Employee.updateStatus(payload.email, payload.status) 70 | .then((employee) => { 71 | if (!employee) { 72 | return reply(Boom.notFound('Email Address Not Found')); 73 | } 74 | 75 | reply(employee); 76 | 77 | logEvent(employee); 78 | 79 | }) 80 | .catch((err) => { 81 | console.log(err); 82 | reply(Boom.badImplementation()); 83 | }); 84 | }; 85 | 86 | module.exports.delete = function(request, reply) { 87 | Employee.delete(request.params.id) 88 | .then((employee) => { 89 | if (!employee) { 90 | return reply(Boom.notFound('Worker not found')); 91 | } 92 | 93 | reply(); 94 | }) 95 | .catch((err) => { 96 | console.log(err); 97 | reply(Boom.badImplementation()); 98 | }); 99 | }; 100 | 101 | function slackTokenMatch(token) { 102 | const tokens = config.slack.webhooks.requestTokens; 103 | const match = tokens.filter((t) => t === token); 104 | 105 | return match.length > 0; 106 | } 107 | 108 | module.exports.slackHook = function(request, reply) { 109 | console.log(request.payload); 110 | const payload = request.payload; 111 | 112 | if (!slackTokenMatch(payload.token)) { 113 | return reply(Boom.badRequest('Bad Request Token')); 114 | } 115 | 116 | const slashCommand = payload.command.substr('1'); 117 | const status = commandMapper(slashCommand); 118 | const command = commandParser(payload.text); 119 | 120 | slack.getUserInfo(payload.user_id) 121 | .then((result) => { 122 | const profile = result.user.profile; 123 | 124 | return Employee.getByEmail(profile.email) 125 | .then((employee) => { 126 | 127 | if (!employee) { 128 | 129 | return new Employee({ 130 | name: profile.realName, 131 | email: profile.email, 132 | status, 133 | command 134 | }) 135 | .save(); 136 | 137 | } else { 138 | 139 | return Employee.updateStatus(employee.email, status, command); 140 | } 141 | }) 142 | .then((employee) => { 143 | reply(`Updated status to ${employee.status}, your default status is: ${employee.defaultStatus}, to change your default status use \`/${slashCommand} default:(wfo|wfh) \``); 144 | logEvent(employee); 145 | }); 146 | 147 | }) 148 | .catch((err) => { 149 | console.log(err); 150 | reply(Boom.badImplementation()); 151 | }); 152 | }; 153 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Hapi = require('hapi'); 4 | const config = require('./config'); 5 | const validate = require('./basic-auth-validate'); 6 | const server = new Hapi.Server(); 7 | 8 | server.connection({ 9 | port: config.port, 10 | routes: { 11 | cors: true 12 | } 13 | }); 14 | 15 | 16 | server.register(require('hapi-auth-basic'), () => { 17 | 18 | server.auth.strategy('simple', 'basic', {validateFunc: validate}); 19 | 20 | server.route(require('./routes')); 21 | 22 | server.start(() => { 23 | console.log('Server running at:', server.info.uri); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /models/base.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var config = require('../config'); 4 | 5 | var _ = require('lodash'); 6 | var Uuid = require('node-uuid'); 7 | var db = require('./db'); 8 | 9 | var internals = {}; 10 | 11 | module.exports = internals.Base = function(attr) { 12 | this.attr = attr; 13 | this.id = attr.id || Uuid.v4(); 14 | }; 15 | 16 | internals.Base.prototype.save = function() { 17 | var _this = this; 18 | 19 | var promise = new Promise(function(resolve, reject) { 20 | 21 | db.save(_this.id, _this.toJSON(), function(err, res) { 22 | if (err) { 23 | return internals.errorHandler(reject, err); 24 | } 25 | 26 | resolve(_.extend(res, _this.toJSON())); 27 | }); 28 | 29 | }); 30 | 31 | return promise; 32 | 33 | }; 34 | 35 | internals.Base.update = function(model, attr) { 36 | 37 | const id = model.id || model._id; 38 | delete model.id; 39 | 40 | return new Promise(function(resolve, reject) { 41 | 42 | db.merge(id, attr, function(err, res) { 43 | if (err) { 44 | return internals.errorHandler(reject, err); 45 | } 46 | 47 | resolve(_.extend(model, attr)); 48 | }); 49 | 50 | }); 51 | }; 52 | 53 | internals.Base.delete = function(id) { 54 | return new Promise(function(resolve, reject) { 55 | db.remove(id, function(err, res) { 56 | if (err && err.error != 'not_found') { 57 | return reject(err); 58 | } 59 | resolve(res); 60 | }); 61 | }); 62 | }; 63 | 64 | internals.errorHandler = function(reject, err) { 65 | if (err.error === 'not_found') { 66 | reject({ 67 | notFound: true 68 | }); 69 | } else { 70 | reject(err); 71 | } 72 | }; 73 | 74 | internals.Base.get = function(id) { 75 | var Model = this; 76 | return new Promise(function(resolve, reject) { 77 | 78 | db.get(id, { 79 | cacheEnabled: false 80 | }, function(err, res) { 81 | if (err) { 82 | return internals.errorHandler(reject, err); 83 | } 84 | 85 | resolve(new Model(res)); 86 | }); 87 | 88 | }); 89 | 90 | }; 91 | 92 | internals.Base.view = function(view, key) { 93 | var options; 94 | 95 | if (key) { 96 | options = {key}; 97 | } 98 | 99 | return new Promise(function(resolve, reject) { 100 | db.view(view, options, function(err, doc) { 101 | if (err) { 102 | return reject(err); 103 | } 104 | 105 | doc = doc.length ? doc : null; 106 | 107 | resolve(doc); 108 | }); 109 | }); 110 | }; 111 | -------------------------------------------------------------------------------- /models/db.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var Cradle = require('cradle'); 3 | 4 | var config = require('../config'); 5 | var internals = {}; 6 | 7 | if (process.env.NODE_ENV === 'development') { 8 | 9 | internals.db = new Cradle.Connection({ cache: false }).database(config.couchDb.dbName); 10 | 11 | } else { 12 | 13 | internals.db = new Cradle.Connection( 14 | config.couchDb.url, 15 | config.couchDb.port, { 16 | auth: { 17 | username: config.couchDb.username, 18 | password: config.couchDb.password 19 | }, 20 | cache: false 21 | }) 22 | .database(config.couchDb.dbName); 23 | } 24 | 25 | module.exports = internals.db; 26 | -------------------------------------------------------------------------------- /models/employee.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const _ = require('lodash'); 3 | const db = require('./db'); 4 | const Base = require('./base'); 5 | const moment = require('moment'); 6 | const statuses = require('../constants/statuses'); 7 | const commandTypes = require('../constants/commandTypes'); 8 | const logEvent = require('../util/logEvent'); 9 | var internals = {}; 10 | 11 | const TYPE = 'Employee'; 12 | 13 | 14 | module.exports = internals.Employee = function(options) { 15 | options = options || {}; 16 | 17 | this.name = options.name; 18 | this.email = options.email; 19 | this.status = options.status; 20 | this.defaultStatus = options.defaultStatus || this.status; 21 | this.command = options.command; 22 | this.dateModified = options.dateModified; 23 | 24 | Base.call(this, options); 25 | }; 26 | 27 | _.extend(internals.Employee, Base); 28 | _.extend(internals.Employee.prototype, Base.prototype); 29 | 30 | internals.Employee.prototype.toJSON = function() { 31 | return { 32 | id: this.id, 33 | name: this.name, 34 | email: this.email, 35 | status: this.status, 36 | defaultStatus: this.defaultStatus, 37 | dateModified: new Date(), 38 | type: TYPE 39 | }; 40 | }; 41 | 42 | 43 | // When get all is requested we check for expired statuses, this way we enusre response is correct, 44 | // and the data stored in db and analytics is correct. I.e. some employees may not update their status 45 | // until they work other than their usual work location. 46 | internals.Employee.getAll = function() { 47 | 48 | return Base.view(`${TYPE}/all`) 49 | .then((employees) => { 50 | if (!employees) { 51 | return []; 52 | } 53 | 54 | let batchUpdates = []; 55 | 56 | employees = employees.map((employee) => { 57 | let status = employee.status; 58 | 59 | internals.Employee.setDefaultStatusBasedOnTime(employee); 60 | 61 | //update database and send events to analytics for users that have not logged their status today. 62 | if (employee.statusExpired) { 63 | batchUpdates.push(employee); 64 | } 65 | 66 | return { 67 | id: employee._id, 68 | name: employee.name, 69 | email: employee.email, 70 | status: { 71 | statusType: employee.status, // to be determined at run time. 72 | defaultStatus: employee.defaultStatus, 73 | isDefault: employee.status === employee.defaultStatus 74 | }, 75 | message: employee.message 76 | }; 77 | }); 78 | 79 | internals.Employee.batchUpdate(batchUpdates); 80 | 81 | return employees; 82 | }); 83 | 84 | }; 85 | 86 | internals.Employee.batchUpdate = function(employees) { 87 | 88 | employees.forEach(employee => { 89 | logEvent(employee); 90 | internals.Employee.updateStatus(employee.email, employee.status) 91 | .then(() => { 92 | console.log(`Updated ${employee.name} status:${employee.status} in background`); 93 | }) 94 | .catch(err => { 95 | console.log(`Error updating ${employee.name} status in background`); 96 | }); 97 | }); 98 | 99 | }; 100 | 101 | internals.Employee.prototype.save = function() { 102 | var employee = this; 103 | 104 | return Base.prototype.save.call(this) 105 | .then(() => { 106 | return internals.Employee.updateStatus(this.employee, this.status, this.command); 107 | }); 108 | 109 | }; 110 | 111 | internals.Employee.getByEmail = function(email) { 112 | return Base.view(`${TYPE}/byEmail`, email) 113 | .then((employee) => { 114 | if (employee) { 115 | return _.first(employee).value; 116 | } else { 117 | console.log(`Employee Does not Exist ${email}`); 118 | return null; 119 | } 120 | }); 121 | }; 122 | 123 | internals.Employee.isValidStatus = function(status) { 124 | return !!statuses[status]; 125 | }; 126 | 127 | internals.Employee.updateStatus = function(email, status, command) { 128 | return internals.Employee.getByEmail(email) 129 | .then((employee) => { 130 | 131 | if (employee) { 132 | 133 | var attr = { 134 | status: status, 135 | dateModified: new Date(), 136 | message: '' 137 | }; 138 | 139 | if (command && !!commandTypes[command.commandType]) { 140 | 141 | if (command.commandType === commandTypes.default && internals.Employee.isValidStatus(command.value)) { 142 | 143 | attr.defaultStatus = command.value; 144 | } 145 | 146 | if (command.commandType === commandTypes.message) { 147 | attr.message = command.value; 148 | } 149 | } 150 | 151 | return internals.Employee.update(employee, attr) 152 | .then(internals.Employee.appendStatus); 153 | } 154 | 155 | return null; 156 | }); 157 | 158 | }; 159 | 160 | internals.Employee.setDefaultStatusBasedOnTime = function(employee, overrideCurrent) { 161 | const current = overrideCurrent || moment(); 162 | 163 | const currentHours = current.hours(); 164 | 165 | const dateModified = moment(employee.dateModified); 166 | const hours = dateModified.hours(); 167 | 168 | if (hours >= 20 && current.clone().subtract(1, 'days').isSame(dateModified, 'd')) { 169 | //any statuses set yesterday at 8pm onwards 170 | return employee; 171 | 172 | } else if (hours < 20 && current.isSame(dateModified, 'd')) { 173 | //any statuses set today 174 | return employee; 175 | 176 | } else { 177 | employee.statusExpired = true; 178 | //any expired statuses set default. 179 | employee.status = employee.defaultStatus; 180 | return employee; 181 | } 182 | 183 | }; 184 | 185 | internals.Employee.appendStatus = function(employee) { 186 | return new Promise((resolve, reject) => { 187 | 188 | db.save({ 189 | TYPE: 'Employee-Log', 190 | id: employee.email + '/' + new Date(), 191 | status: employee.status, 192 | defaultStatus: employee.defaultStatus, 193 | dateModified: new Date(), 194 | email: employee.email, 195 | name: employee.name, 196 | message: employee.message 197 | }, function(err, res) { 198 | if (err) { 199 | return reject(err); 200 | } 201 | resolve(employee); 202 | }); 203 | 204 | }); 205 | }; 206 | 207 | db.save('_design/' + TYPE, { 208 | all: { 209 | map: function(doc) { 210 | if (doc.type === 'Employee') { 211 | emit(doc.id, doc); 212 | } 213 | } 214 | }, 215 | byEmail: { 216 | map: function(doc) { 217 | if (doc.type === 'Employee') { 218 | emit(doc.email, doc); 219 | } 220 | } 221 | }, 222 | byStatus: { 223 | map: function(doc) { 224 | if (doc.type === 'Employee') { 225 | emit(doc.status, doc); 226 | } 227 | } 228 | }, 229 | byName: { 230 | map: function(doc) { 231 | if (doc.type === 'Employee') { 232 | emit(doc.name, doc); 233 | } 234 | } 235 | } 236 | }); 237 | 238 | db.save('_design/Employee-Log', { 239 | all: { 240 | map: function(doc) { 241 | if (doc.type === 'Employee-Log') { 242 | emit(doc.id, doc); 243 | } 244 | } 245 | }, 246 | byEmail: { 247 | map: function(doc) { 248 | if (doc.type === 'Employee-Log') { 249 | emit(doc.email, doc); 250 | } 251 | } 252 | }, 253 | byDate: { 254 | map: function(doc) { 255 | if (doc.type === 'Employee-Log') { 256 | emit(doc.dateModified, doc); 257 | } 258 | } 259 | } 260 | }); 261 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wfh-api", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "node index.js", 8 | "dev": "nodemon --debug index.js", 9 | "lint": "eslint --ext .js --ext .jsx ./ && echo No linting errors.", 10 | "test": "mocha", 11 | "test:watch": "npm run test -- --watch" 12 | }, 13 | "repository": "git@github.com:pebblecode/wfh3_backend.git", 14 | "keywords": [], 15 | "author": "Mike James ", 16 | "license": "ISC", 17 | "dependencies": { 18 | "analytics-node": "^2.0.0", 19 | "bcrypt": "^0.8.5", 20 | "boom": "^2.9.0", 21 | "camelcase-keys-recursive": "^0.2.0", 22 | "cradle": "^0.6.9", 23 | "getenv": "^0.5.0", 24 | "hapi": "^11.0.1", 25 | "hapi-auth-basic": "^3.0.0", 26 | "joi": "^6.9.1", 27 | "keymirror": "^0.1.1", 28 | "lodash": "^3.10.1", 29 | "moment": "^2.10.6", 30 | "node-uuid": "^1.4.3", 31 | "request": "^2.65.0" 32 | }, 33 | "devDependencies": { 34 | "chai": "^3.4.0", 35 | "mocha": "^2.3.3" 36 | }, 37 | "engines": { 38 | "node": "4.x" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /routes.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const Joi = require('joi'); 3 | const employee = require('./controllers/employee'); 4 | 5 | module.exports = [ 6 | { 7 | path:'/', 8 | method:'GET', 9 | handler: function(request, reply) { 10 | reply('OK'); 11 | }, 12 | }, 13 | 14 | { 15 | path: '/workers', 16 | method: 'GET', 17 | handler: employee.getAll 18 | }, 19 | 20 | { 21 | path: '/workers', 22 | method: 'POST', 23 | handler: employee.addNew, 24 | config:{ 25 | validate:{ 26 | payload:{ 27 | name: Joi.string().min(1), 28 | email: Joi.string().email(), 29 | status: Joi.string().min(4) 30 | } 31 | }, 32 | auth: 'simple' 33 | } 34 | }, 35 | 36 | { 37 | path: '/workers/{id}', 38 | method: 'DELETE', 39 | handler: employee.delete, 40 | config:{ 41 | validate:{ 42 | params:{ 43 | id: Joi.string().guid() 44 | } 45 | }, 46 | auth: 'simple' 47 | } 48 | }, 49 | 50 | { 51 | path: '/workers', 52 | method: 'PUT', 53 | handler: employee.updateStatus, 54 | config: { 55 | validate:{ 56 | payload:{ 57 | email: Joi.string().email(), 58 | status: Joi.string().min(4) 59 | } 60 | } 61 | } 62 | }, 63 | 64 | { 65 | path:'/webhooks/slack', 66 | method:'POST', 67 | handler: employee.slackHook, 68 | } 69 | ]; 70 | -------------------------------------------------------------------------------- /test/default_employee_status_spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const expect = require('chai').expect; 4 | const moment = require('moment'); 5 | 6 | const Employee = require('../models/employee'); 7 | const statuses = require('../constants/statuses'); 8 | 9 | describe('Employee', function() { 10 | 11 | describe('Default status', function() { 12 | 13 | it('should equal default (InOffice) status when date modified is for previous day', function() { 14 | 15 | const employee = Employee.setDefaultStatusBasedOnTime({ 16 | status: statuses.OutOfOffice, 17 | dateModified: moment().hours(14).subtract(1, 'days'), 18 | defaultStatus: statuses.InOffice 19 | }, moment().hours(13)); 20 | 21 | expect(employee.status).to.equal(statuses.InOffice); 22 | }); 23 | 24 | it('should equal status (OutOfOffice) when last updated is on or after 8pm', function() { 25 | 26 | const employee = Employee.setDefaultStatusBasedOnTime({ 27 | status: statuses.OutOfOffice, 28 | dateModified: moment() 29 | .hours(20) 30 | .minutes(0) 31 | .subtract(1, 'days'), 32 | defaultStatus: statuses.InOffice 33 | }, moment().hours(13)); 34 | 35 | expect(employee.status).to.equal(statuses.OutOfOffice); 36 | }); 37 | 38 | it('should equal status (OutOfOffice) set when last updated is after 8pm and current time 9am the next day', function() { 39 | 40 | const employee = Employee.setDefaultStatusBasedOnTime({ 41 | status: statuses.OutOfOffice, 42 | dateModified: moment() 43 | .hours(9), 44 | defaultStatus: statuses.InOffice 45 | }, moment().hours(13)); 46 | 47 | expect(employee.status).to.equal(statuses.OutOfOffice); 48 | }); 49 | 50 | it('should equal default (InOffice) status set when last updated before 8pm', function() { 51 | 52 | const employee = Employee.setDefaultStatusBasedOnTime({ 53 | status: statuses.OutOfOffice, 54 | dateModified: moment().set({ 55 | hours:19, 56 | minutes:30 57 | }).subtract(1, 'days'), 58 | defaultStatus: statuses.InOffice 59 | }, moment().hours(13)); 60 | 61 | expect(employee.status).to.equal(statuses.InOffice); 62 | }); 63 | 64 | it('should equal default (InOffice) status when last updated is on or after 8pm more than 2 days ago', function() { 65 | 66 | const employee = Employee.setDefaultStatusBasedOnTime({ 67 | status: statuses.OutOfOffice, 68 | dateModified: moment().set({ 69 | hours:19, 70 | minutes:30 71 | }).subtract(2, 'days'), 72 | defaultStatus: statuses.InOffice 73 | }, moment().hours(13)); 74 | 75 | expect(employee.status).to.equal(statuses.InOffice); 76 | }); 77 | 78 | it('should equal default (InOffice) status when last updated is on or after 8pm more than 2 days ago, even if sick', function() { 79 | 80 | const employee = Employee.setDefaultStatusBasedOnTime({ 81 | status: statuses.Sick, 82 | dateModified: moment().set({ 83 | hours:19, 84 | minutes:30 85 | }).subtract(2, 'days'), 86 | defaultStatus: statuses.InOffice 87 | }, moment().hours(13)); 88 | 89 | expect(employee.status).to.equal(statuses.InOffice); 90 | }); 91 | 92 | it('should equal default (InOffice) status when last updated is on or after 8pm more than 2 days ago, even if on Holiday', function() { 93 | 94 | const employee = Employee.setDefaultStatusBasedOnTime({ 95 | status: statuses.Holiday, 96 | dateModified: moment().set({ 97 | hours:19, 98 | minutes:30 99 | }).subtract(2, 'days'), 100 | defaultStatus: statuses.InOffice 101 | }, moment().hours(13)); 102 | 103 | expect(employee.status).to.equal(statuses.InOffice); 104 | }); 105 | 106 | }); 107 | 108 | 109 | }); 110 | -------------------------------------------------------------------------------- /util/commandMapper.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const commands = require('../constants/commands'); 4 | const statuses = require('../constants/statuses'); 5 | 6 | var internals = {}; 7 | 8 | module.exports = internals.commands = function(command) { 9 | 10 | switch (command) { 11 | case commands.wfh: 12 | return statuses.OutOfOffice; 13 | case commands.wfo: 14 | return statuses.InOffice; 15 | case commands.wfotest: 16 | return statuses.InOffice; 17 | case commands.wfhtest: 18 | return statuses.OutOfOffice; 19 | default: 20 | return statuses.InOffice; 21 | } 22 | 23 | }; 24 | 25 | -------------------------------------------------------------------------------- /util/commandParser.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const commands = require('../constants/commands'); 3 | const commandTypes = require('../constants/commandTypes'); 4 | const commandMapper = require('./commandMapper'); 5 | 6 | function getDefaultStatus(command) { 7 | let c = commandParser(commandTypes.default, command); 8 | 9 | if (c) { 10 | c.value = commandMapper(c.value); 11 | } 12 | 13 | return c; 14 | } 15 | 16 | function commandParser(commandType, command) { 17 | let paramSplit = command.split(`${commandType}:`); 18 | 19 | if (paramSplit.length === 2) { 20 | return {commandType: commandType, value: paramSplit[1]}; 21 | } else { 22 | return null; 23 | } 24 | } 25 | 26 | function getMessage(command) { 27 | return commandParser(commandTypes.message, command); 28 | } 29 | 30 | module.exports = function(command) { 31 | let c = getDefaultStatus(command); 32 | 33 | if (c) { 34 | return c; 35 | } 36 | 37 | return getMessage(command); 38 | 39 | }; 40 | -------------------------------------------------------------------------------- /util/logEvent.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const Analytics = require('analytics-node'); 3 | const config = require('../config'); 4 | 5 | module.exports = function(employee) { 6 | 7 | if (!config.segment || !config.segment.writeKey || !config.segment.writeKey.length) { 8 | return; 9 | } 10 | 11 | const analytics = new Analytics(config.segment.writeKey); 12 | analytics.identify({ 13 | userId: employee.email, 14 | traits: { 15 | name: employee.name 16 | } 17 | }); 18 | 19 | analytics.track({ 20 | userId: employee.email, 21 | event: 'status', 22 | properties: { 23 | status: employee.status 24 | } 25 | }); 26 | 27 | }; 28 | -------------------------------------------------------------------------------- /util/slack.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const request = require('request'); 4 | const keyMirror = require('keymirror'); 5 | const camelCase = require('camelcase-keys-recursive'); 6 | 7 | const commands = keyMirror({wfh:null, wfo:null, wfhtest:null}); 8 | var internals = {}; 9 | 10 | module.exports = internals.Slack = function(options) { 11 | this.token = options.token; 12 | }; 13 | 14 | internals.Slack.prototype.getUserInfo = function(userId) { 15 | const url = `https://slack.com/api/users.info?token=${this.token}&user=${userId}`; 16 | return internals.getRequest(url); 17 | }; 18 | 19 | internals.Slack.command = function(command) { 20 | command = command.substr(1); 21 | return commands(command); 22 | }; 23 | 24 | internals.getRequest = function(url) { 25 | 26 | return new Promise((resolve, reject) => { 27 | 28 | request(url, function(err, httpResponse, body) { 29 | if (err) { 30 | return reject(err); 31 | } 32 | 33 | if (httpResponse.statusCode === 200) { 34 | return resolve(camelCase(JSON.parse(body))); 35 | } 36 | 37 | reject('Not OK Response'); 38 | 39 | }); 40 | 41 | }); 42 | 43 | }; 44 | --------------------------------------------------------------------------------