├── .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 |
3 |
4 |
5 | # Appointment Reminders powered by Twilio
6 |
7 | [](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/) | [](https://heroku.com/deploy) |
123 | | [Glitch](https://glitch.com) | [](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/) | [](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 |
--------------------------------------------------------------------------------