├── .env.example ├── .eslintrc.json ├── .github └── workflows │ └── nodejs.yml ├── .gitignore ├── .prettierrc ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── _data └── .gitkeep ├── app.js ├── app.json ├── package-lock.json ├── package.json ├── public ├── css │ └── style.css └── js │ └── datetime-picker.js ├── routes └── appointments.js ├── server.js ├── src ├── config.js ├── db.js ├── notifications.js └── scheduler.js ├── test └── appoinment.spec.js └── views ├── appointments ├── _form.pug ├── create.pug ├── edit.pug └── index.pug ├── error.pug ├── index.pug └── layout.pug /.env.example: -------------------------------------------------------------------------------- 1 | # Your Twilio Account SID. Get a free account at twilio.com/try-twilio 2 | TWILIO_ACCOUNT_SID=Your-Account-SID 3 | # Your Twilio Auth Token. You can get it at twilio.com/console 4 | TWILIO_AUTH_TOKEN=Your-Twilio-Auth-Token 5 | # The Twilio phone number you want to use to send SMS. Get one in the Twilio Console 6 | TWILIO_PHONE_NUMBER=Your-Twilio-Phone-Number 7 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["google", "plugin:prettier/recommended"], 3 | "parserOptions": { 4 | "ecmaVersion": 8 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | os: [macos-latest, windows-latest, ubuntu-latest] 11 | node-version: [8, 10, 12] 12 | 13 | steps: 14 | - uses: actions/checkout@v1 15 | - name: Use Node.js ${{ matrix.node-version }} 16 | uses: actions/setup-node@v1 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | - name: Cache node modules 20 | uses: actions/cache@v1 21 | with: 22 | path: node_modules 23 | key: ${{ runner.OS }}-build-${{ hashFiles('**/package-lock.json') }} 24 | restore-keys: | 25 | ${{ runner.OS }}-build-${{ env.cache-name }}- 26 | ${{ runner.OS }}-build- 27 | ${{ runner.OS }}- 28 | - name: npm install, build, and test 29 | run: | 30 | npm install 31 | npm run build --if-present 32 | npm test 33 | env: 34 | CI: true 35 | NODE_ENV: test 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # node-waf configuration 21 | .lock-wscript 22 | 23 | # Compiled binary addons (http://nodejs.org/api/addons.html) 24 | build/Release 25 | 26 | # Dependency directory 27 | node_modules 28 | 29 | # Optional npm cache directory 30 | .npm 31 | 32 | # Optional REPL history 33 | .node_repl_history 34 | 35 | #local env file 36 | .env.local 37 | .env 38 | 39 | _data/db.json 40 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "trailingComma": "es5", 4 | "semi": true, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio-labs/sample-appointment-reminders-node/a2a57b4a7291ea48533709913438d414acdbd05c/.vscode/settings.json -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 TwilioDevEd 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | Twilio 3 | 4 | 5 | # Appointment Reminders powered by Twilio 6 | 7 | [![Actions Status](https://github.com/twilio-labs/sample-appointment-reminders-node/workflows/Node%20CI/badge.svg)](https://github.com/twilio-labs/sample-appointment-reminders-node/actions) 8 | 9 | ## About 10 | 11 | Appointment reminders allow you to automate the process of reaching out to your customers in advance for an upcoming appointment. In this sample, you'll learn how to use Twilio to create automatic appointment reminders for your business users. Use appointment reminders to reduce no-shows and ensure customers have everything they need in advance of an appointment. Whether you're a dentist, doctor, cable company, or car repair shop, you can use automated appointment reminders to save time and money. 12 | 13 | This sample includes the code required to implement an appointment reminder web application and scheduling job. 14 | 15 | 23 | 24 | ### How it works 25 | 26 | This application shows how appointment reminders with [Twilio](https://www.twilio.com) can work. It will set up a barebones web application that allows you to create appointments that are being stored in a database. 27 | 28 | The application has a background scheduled function running every minute checking if it has to send out any notifications. If it has to send out a notification it will send out an SMS using [Twilio Programmable SMS](https://www.twilio.com/sms) to the phone number stored with the respective appointment. 29 | 30 | 38 | 39 | ## Features 40 | 41 | - Receive notifications using [Programmable SMS](<[https://www.twilio.com/sms](https://www.twilio.com/sms)>) . 42 | - User interface to create reminders. 43 | - Small JSON database using [lowdb](<[https://github.com/typicode/lowdb](https://github.com/typicode/lowdb)>). 44 | - Execute reminders on a schedule using [node-cron](https://github.com/kelektiv/node-cron). 45 | 46 | ## Set up 47 | 48 | ### Requirements 49 | 50 | - [Node.js](https://nodejs.org/) 51 | - A Twilio account - [sign up](https://www.twilio.com/try-twilio) 52 | 53 | ### Twilio Account Settings 54 | 55 | This application should give you a ready-made starting point for writing your 56 | own appointment reminder application. Before we begin, we need to collect 57 | all the config values we need to run the application: 58 | 59 | | Config Value | Description | 60 | | :---------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------- | 61 | | Account Sid | Your primary Twilio account identifier - find this [in the Console](https://www.twilio.com/console). | 62 | | Auth Token | Used to authenticate - [just like the above, you'll find this here](https://www.twilio.com/console). | 63 | | Phone number | A Twilio phone number in [E.164 format](https://en.wikipedia.org/wiki/E.164) - you can [get one here](https://www.twilio.com/console/phone-numbers/incoming) | 64 | 65 | ### Local development 66 | 67 | After the above requirements have been met: 68 | 69 | 1. Clone this repository and `cd` into it 70 | 71 | ```bash 72 | git clone git@github.com:twilio-labs/sample-appointment-reminders-node.git 73 | cd sample-appointment-reminders-node 74 | ``` 75 | 76 | 2. Install dependencies 77 | 78 | ```bash 79 | npm install 80 | ``` 81 | 82 | 3. Set your environment variables 83 | 84 | ```bash 85 | npm run setup 86 | ``` 87 | 88 | See [Twilio Account Settings](#twilio-account-settings) to locate the necessary environment variables. 89 | 90 | 4. Run the application 91 | 92 | ```bash 93 | npm start 94 | ``` 95 | 96 | Alternatively, you can use this command to start the server in development mode. It will reload whenever you change any files. 97 | 98 | ```bash 99 | npm run dev 100 | ``` 101 | 102 | 5. Navigate to [http://localhost:3000](http://localhost:3000) 103 | 104 | That's it! 105 | 106 | ### Tests 107 | 108 | You can run the tests locally by typing: 109 | 110 | ```bash 111 | npm test 112 | ``` 113 | 114 | ### Cloud deployment 115 | 116 | Additionally to trying out this application locally, you can deploy it to a variety of host services. Here is a small selection of them. 117 | 118 | Please be aware that some of these might charge you for the usage or might make the source code for this application visible to the public. When in doubt research the respective hosting service first. 119 | 120 | | Service | | 121 | | :-------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 122 | | [Heroku](https://www.heroku.com/) | [![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy) | 123 | | [Glitch](https://glitch.com) | [![Remix on Glitch](https://cdn.glitch.com/2703baf2-b643-4da7-ab91-7ee2a2d00b5b%2Fremix-button.svg)](https://glitch.com/edit/#!/remix/clone-from-repo?REPO_URL=https://github.com/twilio-labs/sample-appointment-reminders-node.git) | 124 | | [Zeit](https://zeit.co/) | [![Deploy with ZEIT Now](https://zeit.co/button)](https://zeit.co/new/project?template=https://github.com/twilio-labs/sample-appointment-reminders-node/tree/master) | 125 | 126 | ## Resources 127 | 128 | - [Appointment reminders tutorial](https://www.twilio.com/docs/tutorials/walkthrough/appointment-reminders/node/express) 129 | - [Build appointment reminders in Studio (Video)](https://www.youtube.com/watch?v=vl0FbbZBADQ) 130 | - [Appointment reminders glossary](https://www.twilio.com/docs/glossary/appointment-reminders) 131 | 132 | ## Contributing 133 | 134 | This template is open source and welcomes contributions. All contributions are subject to our [Code of Conduct](https://github.com/twilio-labs/.github/blob/master/CODE_OF_CONDUCT.md). 135 | 136 | [Visit the project on GitHub](https://github.com/twilio-labs/sample-appointment-reminders-node) 137 | 138 | ## License 139 | 140 | [MIT](http://www.opensource.org/licenses/mit-license.html) 141 | 142 | ## Disclaimer 143 | 144 | No warranty expressed or implied. Software is as is. 145 | -------------------------------------------------------------------------------- /_data/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio-labs/sample-appointment-reminders-node/a2a57b4a7291ea48533709913438d414acdbd05c/_data/.gitkeep -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | const scheduler = require('./src/scheduler'); 2 | const cfg = require('./src/config'); 3 | const server = require('./server'); 4 | 5 | scheduler.start(); 6 | server.listen(cfg.port, function() { 7 | console.log( 8 | `Starting sample-appointment-reminders at http://localhost:${cfg.port}` 9 | ); 10 | }); 11 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Appointment Reminders powered by Twilio", 3 | "description": "A template for getting started with Appointment Reminders using Twilio and Node.js.", 4 | "website": "https://twilio.com", 5 | "logo": "https://www.twilio.com/marketing/bundles/marketing/img/favicons/favicon_114.png", 6 | "success_url": "/", 7 | "addons": [], 8 | "keywords": ["nodejs", "twilio"], 9 | "env": { 10 | "TWILIO_ACCOUNT_SID": { 11 | "description": "Your Twilio Account SID. Get a free account at twilio.com/try-twilio", 12 | "value": "" 13 | }, 14 | "TWILIO_AUTH_TOKEN": { 15 | "description": "Your Twilio Auth Token. You can get it at twilio.com/console", 16 | "value": "" 17 | }, 18 | "TWILIO_PHONE_NUMBER": { 19 | "description": "The Twilio phone number you want to use to send SMS. Get one in the Twilio Console", 20 | "value": "+12345678910" 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sample-appointment-reminders-node", 3 | "version": "1.0.0", 4 | "private": true, 5 | "main": "./app.js", 6 | "scripts": { 7 | "start": "node .", 8 | "setup": "configure-env", 9 | "pretest": "cross-env NODE_ENV=test", 10 | "test": "eslint . && mocha test", 11 | "dev": "nodemon ." 12 | }, 13 | "description": "Node implementation of Appointment Reminders", 14 | "author": { 15 | "name": "Twilio", 16 | "email": "open-source@twilio.com" 17 | }, 18 | "license": "MIT", 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/twilio-labs/sample-appointment-reminders-node" 22 | }, 23 | "keywords": [ 24 | "node", 25 | "appointment reminders", 26 | "twilio", 27 | "express", 28 | "sms", 29 | "notification" 30 | ], 31 | "engines": { 32 | "node": ">=8.x" 33 | }, 34 | "dependencies": { 35 | "body-parser": "^1.19.0", 36 | "configure-env": "^1.0.0", 37 | "cookie-parser": "~1.4.3", 38 | "cron": "^1.1.0", 39 | "debug": ">=2.6.9", 40 | "dotenv-safe": "^4.0.4", 41 | "express": "^4.17.1", 42 | "lowdb": "^1.0.0", 43 | "moment": "^2.24.0", 44 | "moment-timezone": "^0.5.0", 45 | "morgan": "^1.9.1", 46 | "npm": "^6.13.4", 47 | "pug": "^2.0.0-beta.12", 48 | "serve-favicon": "^2.5.0", 49 | "twilio": "^3.37.1" 50 | }, 51 | "devDependencies": { 52 | "chai": "^3.5.0", 53 | "cheerio": "^0.22.0", 54 | "cross-env": "^6.0.3", 55 | "eslint": "^6.6.0", 56 | "eslint-config-google": "^0.14.0", 57 | "eslint-config-prettier": "^6.6.0", 58 | "eslint-plugin-prettier": "^3.1.1", 59 | "mocha": "^6.2.2", 60 | "nodemon": "^1.19.4", 61 | "prettier": "^1.19.1", 62 | "proxyquire": "^1.7.10", 63 | "supertest": "^2.0.1" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /public/css/style.css: -------------------------------------------------------------------------------- 1 | footer { 2 | bottom: 10px; 3 | text-align: center; 4 | } 5 | 6 | footer i { 7 | color:#ff0000; 8 | } 9 | -------------------------------------------------------------------------------- /public/js/datetime-picker.js: -------------------------------------------------------------------------------- 1 | $(document).ready(function() { 2 | $('#inputDate').datetimepicker({ 3 | sideBySide: true, 4 | format: 'YYYY-MM-DD hh:mma', 5 | }); 6 | $('#selectTimeZone').chosen(); 7 | }); 8 | -------------------------------------------------------------------------------- /routes/appointments.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const express = require('express'); 4 | const momentTimeZone = require('moment-timezone'); 5 | const moment = require('moment'); 6 | const { Appointment } = require('../src/db'); 7 | 8 | /* eslint-disable new-cap */ 9 | const router = express.Router(); 10 | 11 | const getTimeZones = function() { 12 | return momentTimeZone.tz.names(); 13 | }; 14 | 15 | // GET: /appointments 16 | router.get('/', function(req, res, next) { 17 | Appointment.find().then(function(appointments) { 18 | res.render('appointments/index', { appointments: appointments }); 19 | }); 20 | }); 21 | 22 | // GET: /appointments/create 23 | router.get('/create', function(req, res, next) { 24 | res.render('appointments/create', { 25 | timeZones: getTimeZones(), 26 | appointment: new Appointment({ 27 | name: '', 28 | phoneNumber: '', 29 | notification: '', 30 | timeZone: '', 31 | time: '', 32 | }), 33 | }); 34 | }); 35 | 36 | // POST: /appointments 37 | router.post('/', function(req, res, next) { 38 | const name = req.body.name; 39 | const phoneNumber = req.body.phoneNumber; 40 | const notification = req.body.notification; 41 | const timeZone = req.body.timeZone; 42 | const time = moment(req.body.time, 'YYYY-MM-DD hh:mma'); 43 | 44 | const appointment = new Appointment({ 45 | name: name, 46 | phoneNumber: phoneNumber, 47 | notification: Number(notification), 48 | timeZone: timeZone, 49 | time: time, 50 | }); 51 | appointment.save().then(function() { 52 | res.redirect('/'); 53 | }); 54 | }); 55 | 56 | // GET: /appointments/:id/edit 57 | router.get('/:id/edit', function(req, res, next) { 58 | const id = req.params.id; 59 | Appointment.findOne({ _id: id }).then(function(appointment) { 60 | res.render('appointments/edit', { 61 | timeZones: getTimeZones(), 62 | appointment: appointment, 63 | }); 64 | }); 65 | }); 66 | 67 | // POST: /appointments/:id/edit 68 | router.post('/:id/edit', function(req, res, next) { 69 | const id = req.params.id; 70 | const name = req.body.name; 71 | const phoneNumber = req.body.phoneNumber; 72 | const notification = req.body.notification; 73 | const timeZone = req.body.timeZone; 74 | const time = moment(req.body.time, 'YYYY-MM-DD hh:mma'); 75 | 76 | Appointment.findOne({ _id: id }).then(function(appointment) { 77 | appointment.name = name; 78 | appointment.phoneNumber = phoneNumber; 79 | appointment.notification = notification; 80 | appointment.timeZone = timeZone; 81 | appointment.time = time; 82 | 83 | appointment.save().then(function() { 84 | res.redirect('/'); 85 | }); 86 | }); 87 | }); 88 | 89 | // POST: /appointments/:id/delete 90 | router.post('/:id/delete', function(req, res, next) { 91 | const id = req.params.id; 92 | 93 | Appointment.remove({ _id: id }).then(function() { 94 | res.redirect('/'); 95 | }); 96 | }); 97 | 98 | module.exports = router; 99 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const express = require('express'); 4 | const path = require('path'); 5 | const logger = require('morgan'); 6 | const cookieParser = require('cookie-parser'); 7 | const bodyParser = require('body-parser'); 8 | 9 | const appointments = require('./routes/appointments'); 10 | 11 | const app = express(); 12 | 13 | // view engine setup 14 | app.set('views', path.join(__dirname, 'views')); 15 | app.set('view engine', 'pug'); 16 | 17 | app.use(logger('dev')); 18 | app.use(bodyParser.json()); 19 | app.use( 20 | bodyParser.urlencoded({ 21 | extended: false, 22 | }) 23 | ); 24 | app.use(cookieParser()); 25 | app.use(express.static(path.join(__dirname, 'public'))); 26 | app.locals.moment = require('moment'); 27 | 28 | app.use('/appointments', appointments); 29 | app.use('/', appointments); 30 | 31 | // catch 404 and forward to error handler 32 | app.use(function(req, res, next) { 33 | const err = new Error('Not Found'); 34 | err.status = 404; 35 | next(err); 36 | }); 37 | 38 | // error handler 39 | app.use(function(err, req, res, next) { 40 | if (err.status !== 404) { 41 | console.error(err); 42 | } 43 | 44 | res.status(err.status || 500); 45 | res.render('error', { 46 | message: err.message, 47 | error: {}, 48 | }); 49 | }); 50 | 51 | module.exports = app; 52 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | if (!process.env.CI) { 2 | require('dotenv-safe').load(); 3 | } 4 | 5 | const cfg = {}; 6 | 7 | // HTTP Port to run our web application 8 | cfg.port = process.env.PORT || 3000; 9 | 10 | // A random string that will help generate secure one-time passwords and 11 | // HTTP sessions 12 | cfg.secret = process.env.APP_SECRET || 'keyboard cat'; 13 | 14 | // Your Twilio account SID and auth token, both found at: 15 | // https://www.twilio.com/user/account 16 | // 17 | // A good practice is to store these string values as system environment 18 | // variables, and load them from there as we are doing below. Alternately, 19 | // you could hard code these values here as strings. 20 | cfg.twilioAccountSid = process.env.TWILIO_ACCOUNT_SID || 'ACxxxxxxxxxxxxx'; 21 | cfg.twilioAuthToken = process.env.TWILIO_AUTH_TOKEN || '1234567890abc'; 22 | 23 | // A Twilio number you control - choose one from: 24 | // Specify in E.164 format, e.g. "+16519998877" 25 | cfg.twilioPhoneNumber = process.env.TWILIO_PHONE_NUMBER; 26 | 27 | // Export configuration object 28 | module.exports = cfg; 29 | -------------------------------------------------------------------------------- /src/db.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file contains a sample database interface inspired by Mongoose for 3 | * MongoDB. Instead of using an actual database it will use a JSON file in 4 | * _data/db.json to store the data. 5 | * 6 | * For a production environment you should swap this with your own database. 7 | */ 8 | const low = require('lowdb'); 9 | const FileAsync = require('lowdb/adapters/FileAsync'); 10 | 11 | const adapter = new FileAsync('_data/db.json', { 12 | defaultValue: { 13 | appointments: [], 14 | }, 15 | }); 16 | 17 | let db; 18 | 19 | /** 20 | * Returns a cached database instance of lowdb 21 | * @return {Promise<*>} database instance 22 | */ 23 | async function getDb() { 24 | if (db) { 25 | return db; 26 | } 27 | 28 | db = await low(adapter); 29 | return db; 30 | } 31 | 32 | /** 33 | * Generate a random ID that can be used to store database entries 34 | * @return {string} random string 35 | */ 36 | function getRandomId() { 37 | return Math.random() 38 | .toString(36) 39 | .substr(2); 40 | } 41 | 42 | /** 43 | * Class representing an Appointment entity. It's responsible to interact with 44 | * the database 45 | */ 46 | class Appointment { 47 | /** 48 | * Creates an instance of Appointment. 49 | * @param {*} data 50 | * @memberof Appointment 51 | */ 52 | constructor(data) { 53 | if (!data._id) { 54 | data._id = getRandomId(); 55 | } 56 | this._id = data._id; 57 | this.name = data.name; 58 | this.phoneNumber = data.phoneNumber; 59 | this.notification = data.notification; 60 | this.timeZone = data.timeZone; 61 | this.time = data.time; 62 | } 63 | 64 | /** 65 | * Turns the properties of this class instance into a plain JSON 66 | * @return {*} JSON object with all appointment properties 67 | */ 68 | toJson() { 69 | return { 70 | _id: this._id, 71 | name: this.name, 72 | phoneNumber: this.phoneNumber, 73 | notification: this.notification, 74 | timeZone: this.timeZone, 75 | time: this.time, 76 | }; 77 | } 78 | 79 | /** 80 | * Aliasing _id to id 81 | * 82 | * @readonly 83 | * @return {string} ID string 84 | * @memberof Appointment 85 | */ 86 | get id() { 87 | return this._id; 88 | } 89 | 90 | /** 91 | * Saves an entry to the database or updates it if necessary 92 | * 93 | * @return {Promise} the current instance of this class 94 | * @memberof Appointment 95 | */ 96 | async save() { 97 | const db = await getDb(); 98 | 99 | const entry = db.get(Appointment.dbKey).find({ _id: this._id }); 100 | if (!entry.value()) { 101 | await db 102 | .get(Appointment.dbKey) 103 | .push(this.toJson()) 104 | .write(); 105 | return this; 106 | } 107 | 108 | await entry.assign(this.toJson()).write(); 109 | return this; 110 | } 111 | 112 | /** 113 | * Returns all appointments stored in the database 114 | * 115 | * @static 116 | * @return {Promise} Appointment instances 117 | * @memberof Appointment 118 | */ 119 | static async find() { 120 | const db = await getDb(); 121 | 122 | return db 123 | .get(Appointment.dbKey) 124 | .value() 125 | .map(entry => new Appointment(entry)); 126 | } 127 | 128 | /** 129 | * Searches the database and returns the first instance that matches the passed 130 | * properties 131 | * 132 | * @static 133 | * @param {*} searchCondition an object of properties to look for 134 | * @return {Promise} the instance of first match 135 | * @memberof Appointment 136 | */ 137 | static async findOne(searchCondition) { 138 | const db = await getDb(); 139 | 140 | const firstEntry = db 141 | .get(Appointment.dbKey) 142 | .find(searchCondition) 143 | .value(); 144 | 145 | if (!firstEntry) { 146 | return null; 147 | } 148 | return new Appointment(firstEntry); 149 | } 150 | 151 | /** 152 | * Removes the first instance that matches the search parameters 153 | * 154 | * @static 155 | * @param {*} searchCondition an object of properties to look for 156 | * @return {Promise} 157 | * @memberof Appointment 158 | */ 159 | static async remove(searchCondition) { 160 | const db = await getDb(); 161 | await db 162 | .get(Appointment.dbKey) 163 | .remove(searchCondition) 164 | .write(); 165 | 166 | return undefined; 167 | } 168 | } 169 | 170 | Appointment.dbKey = 'appointments'; 171 | 172 | module.exports = { Appointment }; 173 | -------------------------------------------------------------------------------- /src/notifications.js: -------------------------------------------------------------------------------- 1 | const moment = require('moment'); 2 | const twilio = require('twilio'); 3 | const { Appointment } = require('./db'); 4 | const cfg = require('./config'); 5 | 6 | const client = twilio(cfg.twilioAccountSid, cfg.twilioAuthToken); 7 | 8 | /** 9 | * Checks if the distance between the current time and the time of the 10 | * appointment is the same as the specified notification range 11 | * 12 | * @param {*} appointment an appointment instance 13 | * @param {*} currentTime the current time stamp 14 | * @return {boolean} true if the distance is the same, false otherwise 15 | */ 16 | function requiresNotification(appointment, currentTime) { 17 | return ( 18 | Math.round( 19 | moment 20 | .duration( 21 | moment(appointment.time) 22 | .tz(appointment.timeZone) 23 | .utc() 24 | .diff(moment(currentTime).utc()) 25 | ) 26 | .asMinutes() 27 | ) === appointment.notification 28 | ); 29 | } 30 | 31 | /** 32 | * Sends an SMS to the specified phone number in an appointment 33 | * 34 | * @param {*} appointment 35 | */ 36 | async function sendNotification(appointment) { 37 | // Create options to send the message 38 | const options = { 39 | to: `+ ${appointment.phoneNumber}`, 40 | from: cfg.twilioPhoneNumber, 41 | /* eslint-disable max-len */ 42 | body: `Hi ${appointment.name}. Just a reminder that you have an appointment coming up.`, 43 | /* eslint-enable max-len */ 44 | }; 45 | 46 | // Send the message! 47 | try { 48 | client.messages.create(options); 49 | // Log the last few digits of a phone number 50 | let masked = appointment.phoneNumber.substr( 51 | 0, 52 | appointment.phoneNumber.length - 5 53 | ); 54 | masked += '*****'; 55 | console.log(`Message sent to ${masked}`); 56 | } catch (err) { 57 | // Just log it for now 58 | console.error(err); 59 | } 60 | } 61 | 62 | /** 63 | * Searches for all notifications that are due in this minute and triggers 64 | * notifications for them 65 | * 66 | * @param {Date} currentTime the current time to base the notifications on 67 | */ 68 | async function checkAndSendNecessaryNotifications(currentTime) { 69 | const appointments = await Appointment.find(); 70 | 71 | const appointmentsRequiringNotification = appointments.filter(appointment => { 72 | return requiresNotification(appointment, currentTime); 73 | }); 74 | 75 | console.log( 76 | `Sending ${appointmentsRequiringNotification.length} notifications` 77 | ); 78 | 79 | // Sending all notifications. 80 | // We'll not wait for success/failure before finishing this function. 81 | // They are queued in the background. 82 | appointmentsRequiringNotification.forEach(sendNotification); 83 | } 84 | 85 | module.exports = { 86 | checkAndSendNecessaryNotifications, 87 | }; 88 | -------------------------------------------------------------------------------- /src/scheduler.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const CronJob = require('cron').CronJob; 4 | const notifications = require('./notifications'); 5 | const moment = require('moment'); 6 | 7 | /** 8 | * Starts a scheduled cron job to check every minute if notifications have to be 9 | * send and starts sending those. 10 | */ 11 | function start() { 12 | new CronJob( 13 | '00 * * * * *', // run every minute 14 | () => { 15 | const currentTime = new Date(); 16 | // which code to run 17 | console.log( 18 | `Running Send Notifications Worker for ${moment(currentTime).format()}` 19 | ); 20 | notifications.checkAndSendNecessaryNotifications(currentTime); 21 | }, 22 | null, // don't run anything after finishing the job 23 | true, // start the timer 24 | '' // use default timezone 25 | ); 26 | } 27 | 28 | module.exports = { 29 | start, 30 | }; 31 | -------------------------------------------------------------------------------- /test/appoinment.spec.js: -------------------------------------------------------------------------------- 1 | const expect = require('chai').expect; 2 | const supertest = require('supertest'); 3 | const app = require('../server.js'); 4 | const { Appointment } = require('../src/db'); 5 | const agent = supertest(app); 6 | 7 | describe('appointment', function() { 8 | let appointment = {}; 9 | 10 | beforeEach(function(done) { 11 | Appointment.remove({}).then(done); 12 | appointment = new Appointment({ 13 | name: 'Appointment', 14 | phoneNumber: '+5555555', 15 | time: '2016-02-17 12:00:00', 16 | notification: 15, 17 | timeZone: 'Africa/Algiers', 18 | }); 19 | }); 20 | 21 | describe('GET /appointments', function() { 22 | it('list all appointments', function(done) { 23 | const result = appointment.save(); 24 | result.then(function() { 25 | agent 26 | .get('/appointments') 27 | .expect(function(response) { 28 | expect(response.text).to.contain('Appointment'); 29 | expect(response.text).to.contain('+5555555'); 30 | expect(response.text).to.contain('2016-02-17 12:00pm'); 31 | expect(response.text).to.contain('2016-02-17 11:45am'); 32 | expect(response.text).to.contain('Africa/Algiers'); 33 | }) 34 | .expect(200, done); 35 | }); 36 | }); 37 | }); 38 | 39 | describe('GET /appointments/create', function() { 40 | it('shows create property form', function(done) { 41 | agent 42 | .get('/appointments/create') 43 | .expect(function(response) { 44 | expect(response.text).to.contain('Create'); 45 | }) 46 | .expect(200, done); 47 | }); 48 | }); 49 | 50 | describe('POST to /appointments', function() { 51 | it('creates a new appointment', function(done) { 52 | agent 53 | .post('/appointments') 54 | .type('form') 55 | .send({ 56 | name: 'Appointment', 57 | phoneNumber: '+5555555', 58 | time: '2016-02-17 12:00am', 59 | notification: 15, 60 | timeZone: 'Africa/Algiers', 61 | }) 62 | .expect(function(res) { 63 | Appointment.find({}).then(function(appointments) { 64 | expect(appointments.length).to.equal(1); 65 | }); 66 | }) 67 | .expect(302, done); 68 | }); 69 | }); 70 | 71 | describe('GET /appointments/:id/edit', function() { 72 | it('shows a single appointment', function(done) { 73 | const result = appointment.save(); 74 | result.then(function() { 75 | agent 76 | .get('/appointments/' + appointment.id + '/edit') 77 | .expect(function(response) { 78 | expect(response.text).to.contain('Appointment'); 79 | expect(response.text).to.contain('+5555555'); 80 | expect(response.text).to.contain('15'); 81 | expect(response.text).to.contain('Africa/Algiers'); 82 | }) 83 | .expect(200, done); 84 | }); 85 | }); 86 | }); 87 | 88 | describe('POST /appointments/:id/edit', function() { 89 | it('updates an appointment', function(done) { 90 | const result = appointment.save(); 91 | result.then(function() { 92 | agent 93 | .post('/appointments/' + appointment.id + '/edit') 94 | .type('form') 95 | .send({ 96 | name: 'Appointment2', 97 | phoneNumber: '+66666666', 98 | time: '2016-02-17 12:00am', 99 | notification: 15, 100 | timeZone: 'Africa/Algiers', 101 | }) 102 | .expect(function(response) { 103 | Appointment.find().then(function([appointment]) { 104 | expect(appointment.name).to.contain('Appointment2'); 105 | expect(appointment.phoneNumber).to.contain('+66666666'); 106 | }); 107 | }) 108 | .expect(302, done); 109 | }); 110 | }); 111 | }); 112 | }); 113 | -------------------------------------------------------------------------------- /views/appointments/_form.pug: -------------------------------------------------------------------------------- 1 | .form-group 2 | label.col-sm-4.control-label(for='inputName') Name * 3 | .col-sm-8 4 | input#inputName.form-control(type='text', name='name', placeholder='Name', required='', data-parsley-maxlength='20', data-parsley-maxlength-message="This field can't have more than 20 characters", value=appointment.name) 5 | em The person who receives the notification. 6 | .form-group 7 | label.col-sm-4.control-label(for='inputPhoneNumber') Phone Number * 8 | .col-sm-8 9 | input#inputPhoneNumber.form-control(type='tel', name='phoneNumber', placeholder='Phone Number', required='', value=appointment.phoneNumber) 10 | em The phone number of the person who receives the notification. 11 | .form-group 12 | label.col-sm-4.control-label(for='time') Appointment Date 13 | .col-sm-8 14 | input#inputDate.form-control(type='text', name='time', placeholder='Pick a Date', required='', value=moment(appointment.time).format('YYYY-MM-DD hh:mma')) 15 | em The date and time of the appointment. 16 | .form-group 17 | label.col-sm-4.control-label(for='selectNotification') Notification Time * 18 | .col-sm-8 19 | select#selectDelta.form-control(name='notification', required='', value=appointment.notification) 20 | option(selected=appointment.notification == '', value='') Send alert before 21 | option(selected=appointment.notification == '2', value='2') 2 minutes before 22 | option(selected=appointment.notification == '15', value='15') 15 minutes before 23 | option(selected=appointment.notification == '30', value='30') 30 minutes before 24 | option(selected=appointment.notification == '45', value='45') 45 minutes before 25 | option(selected=appointment.notification == '60', value='60') 60 minutes before 26 | em Send notification before appointment. 27 | .form-group 28 | label.col-sm-4.control-label(for='selectTimeZone') Time Zone 29 | .col-sm-8 30 | select#selectTimeZone.form-control(name='timeZone', required='', value=appointment.timeZone) 31 | each zone in timeZones 32 | option() 33 | option(selected=zone == appointment.timeZone, value= zone) !{zone} 34 | em The time zone of the person who receives the notification. 35 | -------------------------------------------------------------------------------- /views/appointments/create.pug: -------------------------------------------------------------------------------- 1 | extends ../layout 2 | 3 | block styles 4 | link(rel='stylesheet', type='text/css', href='https://cdnjs.cloudflare.com/ajax/libs/bootstrap-datetimepicker/4.14.30/css/bootstrap-datetimepicker.min.css') 5 | link(rel='stylesheet', type='text/css', href='https://cdnjs.cloudflare.com/ajax/libs/chosen/1.4.2/chosen.min.css') 6 | 7 | block content 8 | h2 Create New Appointment 9 | 10 | form#formAppointment.form-horizontal.col-lg-5(name='formAppointment', method='POST', action='/appointments', data-parsley-validate='') 11 | include _form 12 | 13 | .form-group 14 | .col-sm-offset-4.col-sm-8 15 | button.btn.btn-default(type='submit') Create 16 | 17 | 18 | block scripts 19 | script(src='https://cdnjs.cloudflare.com/ajax/libs/chosen/1.4.2/chosen.jquery.min.js') 20 | script(src='https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.10.3/moment.min.js') 21 | script(src='https://cdnjs.cloudflare.com/ajax/libs/bootstrap-datetimepicker/4.14.30/js/bootstrap-datetimepicker.min.js') 22 | script(src='/js/datetime-picker.js') 23 | -------------------------------------------------------------------------------- /views/appointments/edit.pug: -------------------------------------------------------------------------------- 1 | extends ../layout 2 | 3 | block styles 4 | link(rel='stylesheet', type='text/css', href='https://cdnjs.cloudflare.com/ajax/libs/bootstrap-datetimepicker/4.14.30/css/bootstrap-datetimepicker.min.css') 5 | link(rel='stylesheet', type='text/css', href='https://cdnjs.cloudflare.com/ajax/libs/chosen/1.4.2/chosen.min.css') 6 | 7 | block content 8 | h2 Create New Appointment 9 | 10 | form#formAppointment.form-horizontal.col-lg-5(name='formAppointment', method='POST', action='/appointments/' + appointment.id + '/edit', data-parsley-validate='') 11 | include _form 12 | 13 | .form-group 14 | .col-sm-offset-4.col-sm-8 15 | button.btn.btn-default(type='submit') Save 16 | 17 | 18 | block scripts 19 | script(src='https://cdnjs.cloudflare.com/ajax/libs/chosen/1.4.2/chosen.jquery.min.js') 20 | script(src='https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.10.3/moment.min.js') 21 | script(src='https://cdnjs.cloudflare.com/ajax/libs/bootstrap-datetimepicker/4.14.30/js/bootstrap-datetimepicker.min.js') 22 | script(src='/js/datetime-picker.js') 23 | -------------------------------------------------------------------------------- /views/appointments/index.pug: -------------------------------------------------------------------------------- 1 | extends ../layout 2 | 3 | block content 4 | table.table 5 | thead 6 | tr 7 | th Name 8 | th Phone Number 9 | th Appointment time (UTC) 10 | th Notification time (UTC) 11 | th Time Zone 12 | th Actions 13 | th 14 | tbody 15 | each appointment in appointments 16 | tr 17 | td !{appointment.name} 18 | td !{appointment.phoneNumber} 19 | td #{moment(appointment.time).format('YYYY-MM-DD hh:mma')} 20 | td #{moment(appointment.time).subtract(appointment.notification, 'minutes').format('YYYY-MM-DD hh:mma')} 21 | td !{appointment.timeZone} 22 | td 23 | a.btn.btn-default.btn-sm(href="/appointments/" + appointment.id + "/edit") Edit 24 | td 25 | form(action="/appointments/" + appointment.id + "/delete",method="POST") 26 | button.btn.btn-danger.btn-sm(type='submit') Delete 27 | -------------------------------------------------------------------------------- /views/error.pug: -------------------------------------------------------------------------------- 1 | extends layout 2 | 3 | block content 4 | h1= message 5 | h2= error.status 6 | pre #{error.stack} 7 | -------------------------------------------------------------------------------- /views/index.pug: -------------------------------------------------------------------------------- 1 | extends layout 2 | 3 | block content 4 | h1= title 5 | p Welcome to #{title} 6 | -------------------------------------------------------------------------------- /views/layout.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html 3 | head 4 | link(rel='icon', type='image/png', href='img/favicon.ico') 5 | script(src='https://ajax.googleapis.com/ajax/libs/jquery/2.1.3/jquery.min.js') 6 | // Latest compiled and minified CSS 7 | link(rel='stylesheet', href='https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css') 8 | // Optional theme 9 | link(rel='stylesheet', href='https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap-theme.min.css') 10 | link(rel='stylesheet', type='text/css', href='https://maxcdn.bootstrapcdn.com/font-awesome/4.4.0/css/font-awesome.min.css') 11 | block styles 12 | link(rel='stylesheet', href='/css/style.css') 13 | 14 | title Appointments 15 | body 16 | nav.navbar.navbar-default 17 | .container 18 | .navbar-header 19 | a.navbar-brand(href='/') Appointment Reminders 20 | div 21 | ul.nav.navbar-nav 22 | li 23 | a(href='/') 24 | | Home 25 | li 26 | a(href='/appointments/create') 27 | | New Appointment 28 | 29 | .container 30 | .row 31 | .col-lg-9 32 | block content 33 | .col-lg-3 34 | // /row 35 | hr 36 | footer 37 | | Made with 38 | i.fa.fa-heart 39 | | by your pals 40 | | 41 | a(href='http://www.twilio.com') @twilio 42 | // /container 43 | 44 | script(src='https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/js/bootstrap.min.js') 45 | script(src='https://cdnjs.cloudflare.com/ajax/libs/parsley.js/2.1.2/parsley.min.js') 46 | block scripts 47 | --------------------------------------------------------------------------------