├── .github ├── dependabot.yml └── workflows │ └── node.js.yml ├── .gitignore ├── LICENSE ├── README.md ├── app.js ├── bin └── www ├── config └── config.json ├── free-zipcode-database.csv ├── migrations ├── 20170412145746-create-state.js ├── 20170412145747-create-zipcode.js └── 20170412145748-create-senator.js ├── models ├── index.js ├── senator.js ├── state.js └── zipcode.js ├── package.json ├── public ├── css │ └── main.css └── images │ └── jefferson-memorial.jpg ├── routes └── index.js ├── seeders ├── 20170412153806-zipcodes-seeder.js ├── 20170413125653-states-seed.js └── 20170413125708-senators-seed.js ├── senators.json ├── test └── test-app.js ├── utils └── parsers.js └── views ├── index.pug └── layout.pug /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | ignore: 9 | - dependency-name: sequelize 10 | versions: 11 | - 6.5.0 12 | - 6.5.1 13 | - 6.6.1 14 | - dependency-name: mocha 15 | versions: 16 | - 8.2.1 17 | - 8.3.0 18 | - 8.3.1 19 | - dependency-name: chai 20 | versions: 21 | - 4.2.0 22 | - 4.3.0 23 | - 4.3.1 24 | - 4.3.3 25 | - dependency-name: sqlite3 26 | versions: 27 | - 5.0.1 28 | - dependency-name: csv-parse 29 | versions: 30 | - 4.15.0 31 | - 4.15.1 32 | - 4.15.3 33 | - dependency-name: pug 34 | versions: 35 | - 3.0.0 36 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [master] 9 | paths-ignore: 10 | - "**.md" 11 | pull_request: 12 | branches: [master] 13 | paths-ignore: 14 | - "**.md" 15 | 16 | jobs: 17 | build: 18 | runs-on: ${{ matrix.os }} 19 | strategy: 20 | matrix: 21 | os: [ubuntu-latest, macos-latest, windows-latest] 22 | node-version: [14] 23 | 24 | steps: 25 | - uses: actions/checkout@v2 26 | - name: Use Node.js ${{ matrix.node-version }} 27 | uses: actions/setup-node@v2 28 | with: 29 | node-version: ${{ matrix.node-version }} 30 | 31 | - name: Install Dependencies 32 | run: yarn install 33 | - run: yarn migrate 34 | - run: yarn seed 35 | - run: yarn test 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | db.development.sqlite 3 | yarn.lock 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Twilio Inc. 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | Twilio 3 | 4 | 5 | # Advanced Call Forwarding with Node and Express 6 | 7 | [![Node.js CI](https://github.com/TwilioDevEd/call-forwarding-node/actions/workflows/node.js.yml/badge.svg)](https://github.com/TwilioDevEd/call-forwarding-node/actions/workflows/node.js.yml) 8 | 9 | Learn how to use [Twilio](https://www.twilio.com) to forward a series of phone 10 | calls to your state senators. 11 | 12 | ## Local Development 13 | This project is built using the [Express](https://expressjs.com) web framework, 14 | and runs on Node. 15 | 16 | We recommend to install Node through 17 | [nvm](https://github.com/creationix/nvm#install-script) (when possible) 18 | 19 | ```sh 20 | curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.33.2/install.sh | bash \ 21 | nvm install node --stable 22 | ``` 23 | 24 | And [yarn](https://yarnpkg.com/en/docs/install#alternatives-tab) through 25 | `npm`. 26 | 27 | ```sh 28 | npm install --global yarn 29 | ``` 30 | 31 | To run the app locally, follow these steps: 32 | 33 | 1. Clone this repository and `cd` into it. 34 | 35 | ```sh 36 | git clone https://github.com/TwilioDevEd/call-forwarding-node.git \ 37 | cd call-forwarding-node 38 | ``` 39 | 40 | 2. Install the dependencies with: 41 | 42 | ``` 43 | yarn install 44 | ``` 45 | 46 | 3. Run the migrations: 47 | 48 | ``` 49 | yarn run migrate 50 | ``` 51 | 52 | 4. Seed the database with data: 53 | 54 | ``` 55 | yarn run seed 56 | ``` 57 | 58 | This will load senators.json and US zip codes into your SQLite database. 59 | Please note: our senators dataset is likely outdated, and we've mapped 60 | senators to placeholder phone numbers that are set up with Twilio to read 61 | a message and hang up. 62 | 63 | 5. Expose your application to the internet using 64 | [ngrok](https://www.twilio.com/blog/2015/09/6-awesome-reasons-to-use-ngrok-when-testing-webhooks.html). 65 | In a separate terminal session, start ngrok with: 66 | 67 | ``` 68 | ngrok http 3000 69 | ``` 70 | 71 | Once you have started ngrok, update your TwiML application's voice URL 72 | setting to use your ngrok hostname. It will look something like this in 73 | your Twilio [console](https://www.twilio.com/console/phone-numbers/): 74 | 75 | ``` 76 | https://d06f533b.ngrok.io/callcongress/welcome 77 | ``` 78 | 79 | 6. Start your development server: 80 | 81 | ``` 82 | yarn start 83 | ``` 84 | 85 | Once ngrok is running, open up your browser and go to your ngrok URL. 86 | 87 | ## Run the Tests 88 | 89 | ``` 90 | yarn test 91 | ``` 92 | 93 | ## Meta 94 | * No warranty expressed or implied. Software is as is. Diggity. 95 | * [MIT License](https://opensource.org/licenses/mit-license.html) 96 | * Lovingly crafted by Twilio Developer Education. 97 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('dotenv').config(); 4 | 5 | const express = require('express'); 6 | const morgan = require('morgan'); 7 | const path = require('path'); 8 | const urlencoded = require('body-parser').urlencoded; 9 | 10 | const routes = require('./routes/index'); 11 | const app = express(); 12 | 13 | // View engine setup 14 | app.set('view engine', 'pug'); 15 | 16 | // Application setup 17 | app.use(urlencoded({ extended: true })); 18 | app.use(morgan('combined')); 19 | app.use(express.static(path.join(__dirname, 'public'))); 20 | 21 | app.use('/', routes); 22 | 23 | module.exports = app; -------------------------------------------------------------------------------- /bin/www: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Module dependencies. 5 | */ 6 | 7 | var app = require('../app'); 8 | var debug = require('debug')('express-sequelize'); 9 | var http = require('http'); 10 | var models = require('../models'); 11 | 12 | /** 13 | * Get port from environment and store in Express. 14 | */ 15 | 16 | var port = normalizePort(process.env.PORT || '3000'); 17 | app.set('port', port); 18 | /** 19 | * Create HTTP server. 20 | */ 21 | var server = http.createServer(app); 22 | 23 | models.sequelize.sync().then(function() { 24 | /** 25 | * Listen on provided port, on all network interfaces. 26 | */ 27 | server.listen(port, function() { 28 | debug('Express server listening on port ' + server.address().port); 29 | }); 30 | server.on('error', onError); 31 | server.on('listening', onListening); 32 | }); 33 | 34 | /** 35 | * Normalize a port into a number, string, or false. 36 | */ 37 | 38 | function normalizePort(val) { 39 | var port = parseInt(val, 10); 40 | 41 | if (isNaN(port)) { 42 | // named pipe 43 | return val; 44 | } 45 | 46 | if (port >= 0) { 47 | // port number 48 | return port; 49 | } 50 | 51 | return false; 52 | } 53 | 54 | /** 55 | * Event listener for HTTP server "error" event. 56 | */ 57 | 58 | function onError(error) { 59 | if (error.syscall !== 'listen') { 60 | throw error; 61 | } 62 | 63 | var bind = typeof port === 'string' 64 | ? 'Pipe ' + port 65 | : 'Port ' + port; 66 | 67 | // handle specific listen errors with friendly messages 68 | switch (error.code) { 69 | case 'EACCES': 70 | console.error(bind + ' requires elevated privileges'); 71 | process.exit(1); 72 | break; 73 | case 'EADDRINUSE': 74 | console.error(bind + ' is already in use'); 75 | process.exit(1); 76 | break; 77 | default: 78 | throw error; 79 | } 80 | } 81 | 82 | /** 83 | * Event listener for HTTP server "listening" event. 84 | */ 85 | 86 | function onListening() { 87 | var addr = server.address(); 88 | var bind = typeof addr === 'string' 89 | ? 'pipe ' + addr 90 | : 'port ' + addr.port; 91 | debug('Listening on ' + bind); 92 | } -------------------------------------------------------------------------------- /config/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "development": { 3 | "dialect": "sqlite", 4 | "storage": "./db.development.sqlite" 5 | }, 6 | "test": { 7 | "dialect": "sqlite", 8 | "storage": ":memory:" 9 | }, 10 | "production": { 11 | "username": "postgres", 12 | "password": null, 13 | "database": "call-forwarding", 14 | "host": "localhost", 15 | "dialect": "postgresql" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /migrations/20170412145746-create-state.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | module.exports = { 3 | up: function(queryInterface, Sequelize) { 4 | return queryInterface.createTable('States', { 5 | id: { 6 | allowNull: false, 7 | autoIncrement: true, 8 | primaryKey: true, 9 | type: Sequelize.INTEGER 10 | }, 11 | name: { 12 | type: Sequelize.STRING 13 | } 14 | }); 15 | }, 16 | down: function(queryInterface, Sequelize) { 17 | return queryInterface.dropTable('States'); 18 | } 19 | }; -------------------------------------------------------------------------------- /migrations/20170412145747-create-zipcode.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | module.exports = { 3 | up: function(queryInterface, Sequelize) { 4 | return queryInterface.createTable('ZipCodes', { 5 | id: { 6 | allowNull: false, 7 | autoIncrement: true, 8 | primaryKey: true, 9 | type: Sequelize.INTEGER 10 | }, 11 | zipcode: { 12 | type: Sequelize.STRING 13 | }, 14 | state: { 15 | type: Sequelize.STRING 16 | } 17 | }); 18 | }, 19 | down: function(queryInterface, Sequelize) { 20 | return queryInterface.dropTable('ZipCodes'); 21 | } 22 | }; -------------------------------------------------------------------------------- /migrations/20170412145748-create-senator.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | module.exports = { 3 | up: function(queryInterface, Sequelize) { 4 | return queryInterface.createTable('Senators', { 5 | id: { 6 | allowNull: false, 7 | autoIncrement: true, 8 | primaryKey: true, 9 | type: Sequelize.INTEGER 10 | }, 11 | name: { 12 | type: Sequelize.STRING 13 | }, 14 | phone: { 15 | type: Sequelize.STRING 16 | }, 17 | StateId: { 18 | type: Sequelize.INTEGER, 19 | onDelete: "CASCADE", 20 | allowNull: true, 21 | references: { 22 | model: 'States', 23 | key: 'id' 24 | } 25 | } 26 | }); 27 | }, 28 | down: function(queryInterface, Sequelize) { 29 | return queryInterface.dropTable('Senators'); 30 | } 31 | }; -------------------------------------------------------------------------------- /models/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var fs = require('fs'); 4 | var path = require('path'); 5 | var Sequelize = require('sequelize'); 6 | var basename = path.basename(module.filename); 7 | var env = process.env.NODE_ENV || 'development'; 8 | var config = require(__dirname + '/../config/config.json')[env]; 9 | var db = {}; 10 | 11 | if (config.use_env_variable) { 12 | var sequelize = new Sequelize(process.env[config.use_env_variable]); 13 | } else { 14 | var sequelize = new Sequelize(config.database, config.username, config.password, config); 15 | } 16 | 17 | fs 18 | .readdirSync(__dirname) 19 | .filter(function(file) { 20 | return (file.indexOf('.') !== 0) && (file !== basename) && (file.slice(-3) === '.js'); 21 | }) 22 | .forEach(function(file) { 23 | var model = sequelize['import'](path.join(__dirname, file)); 24 | db[model.name] = model; 25 | }); 26 | 27 | Object.keys(db).forEach(function(modelName) { 28 | if (db[modelName].associate) { 29 | db[modelName].associate(db); 30 | } 31 | }); 32 | 33 | db.sequelize = sequelize; 34 | db.Sequelize = Sequelize; 35 | 36 | module.exports = db; 37 | -------------------------------------------------------------------------------- /models/senator.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | module.exports = function(sequelize, DataTypes) { 3 | var Senator = sequelize.define('Senator', { 4 | name: DataTypes.STRING, 5 | phone: DataTypes.STRING 6 | }, { 7 | timestamps: false, 8 | classMethods: { 9 | associate: function(models) { 10 | Senator.belongsTo(models.State, { 11 | onDelete: "CASCADE", 12 | foreignKey: { 13 | allowNull: true 14 | } 15 | }); 16 | } 17 | } 18 | }, {name: { 19 | singular: 'senator', 20 | plural: 'senators' 21 | }}); 22 | return Senator; 23 | }; -------------------------------------------------------------------------------- /models/state.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | module.exports = function(sequelize, DataTypes) { 3 | var State = sequelize.define('State', { 4 | name: DataTypes.STRING 5 | }, { 6 | timestamps: false, 7 | classMethods: { 8 | associate: function(models) { 9 | State.hasMany(models.Senator); 10 | } 11 | } 12 | }); 13 | return State; 14 | }; -------------------------------------------------------------------------------- /models/zipcode.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | module.exports = function(sequelize, DataTypes) { 3 | var ZipCode = sequelize.define('ZipCode', { 4 | zipcode: DataTypes.STRING, 5 | state: DataTypes.STRING 6 | }, { 7 | timestamps: false, 8 | classMethods: { 9 | } 10 | }); 11 | return ZipCode; 12 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "call-forwarding-node", 3 | "version": "1.0.0", 4 | "description": "A sample implementation of advanced call forwarding using Twilio and Express", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "node ./bin/www", 8 | "migrate": "./node_modules/.bin/sequelize db:migrate", 9 | "seed": "./node_modules/.bin/sequelize db:seed:all", 10 | "test": "./node_modules/.bin/mocha" 11 | }, 12 | "keywords": [ 13 | "twilio", 14 | "node", 15 | "express" 16 | ], 17 | "author": "Samuel Mendes", 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/TwilioDevEd/call-forwarding-node" 21 | }, 22 | "license": "MIT", 23 | "dependencies": { 24 | "bluebird": "^3.5.0", 25 | "body-parser": "^1.17.1", 26 | "csv-parse": "^1.2.0", 27 | "dotenv": "^4.0.0", 28 | "express": "^4.15.2", 29 | "morgan": "^1.8.1", 30 | "pug": "^2.0.0-beta11", 31 | "sequelize": "^3.30.4", 32 | "sequelize-cli": "^6.2.0", 33 | "sqlite3": "^4.0.9", 34 | "twilio": "^3.0.0-rc.16" 35 | }, 36 | "devDependencies": { 37 | "mocha": "^3.2.0", 38 | "chai": "^3.5.0", 39 | "chai-http": "^3.0.0", 40 | "chai-xml": "^0.3.1" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /public/css/main.css: -------------------------------------------------------------------------------- 1 | @import url(http://fonts.googleapis.com/css?family=Raleway:400,600,300); 2 | 3 | html { 4 | background: url(/static/images/jefferson-memorial.jpg) no-repeat center center fixed; 5 | -webkit-background-size: cover; 6 | -moz-background-size: cover; 7 | -o-background-size: cover; 8 | background-size: cover; 9 | } 10 | 11 | body { 12 | font-family: 'Raleway', sans-serif; 13 | } 14 | 15 | footer { 16 | font-size: 12px; 17 | color: white; 18 | position: absolute; 19 | text-align: center; 20 | margin: auto; 21 | bottom: 0; 22 | height: 50px; 23 | width: 100%; 24 | } 25 | 26 | footer i { 27 | color:#ff0000; 28 | } 29 | 30 | .hero-text { 31 | display: flex; 32 | align-items: center; 33 | justify-content: center; 34 | flex-direction: column; 35 | text-align: center; 36 | color: black; 37 | text-shadow: 38 | -.5px -.5px 0 white, 39 | .5px -.5px 0 white, 40 | -.5px .5px 0 white, 41 | .5px .5px 0 white; 42 | 43 | } 44 | 45 | .hero-text.full-page { 46 | height: 100%; 47 | position: absolute; 48 | width: 100%; 49 | } 50 | 51 | .hero-text h1 { 52 | font-size: 60px; 53 | text-transform: uppercase; 54 | font-weight: bold; 55 | color: #273769; 56 | text-shadow: 2px 2px 2px white; 57 | } 58 | 59 | .hero-text h2 { 60 | font-size: 40px; 61 | } 62 | 63 | .hero-text p { 64 | font-style: italic; 65 | font-size: 30px; 66 | } 67 | -------------------------------------------------------------------------------- /public/images/jefferson-memorial.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwilioDevEd/call-forwarding-node/abcdbd8cdce3195737c8fb0f0d22ef816eaa9d02/public/images/jefferson-memorial.jpg -------------------------------------------------------------------------------- /routes/index.js: -------------------------------------------------------------------------------- 1 | const models = require('../models'); 2 | const express = require('express'); 3 | const router = express.Router(); 4 | const twilio = require('twilio'); 5 | 6 | // Very basic route to landing page. 7 | router.get('/', function (req, res) { 8 | res.render('index'); 9 | }); 10 | 11 | 12 | // Verify or collect State information. 13 | router.post('/callcongress/welcome', (req, res) => { 14 | const response = new twilio.twiml.VoiceResponse(); 15 | const fromState = req.body.FromState; 16 | 17 | if (fromState) { 18 | const gather = response.gather({ 19 | numDigits: 1, 20 | action: '/callcongress/set-state', 21 | method: 'POST' 22 | }); 23 | gather.say("Thank you for calling congress! It looks like " + 24 | "you're calling from " + fromState + "." + 25 | "If this is correct, please press 1. Press 2 if " + 26 | "this is not your current state of residence."); 27 | } else { 28 | const gather = response.gather({ 29 | numDigits: 5, 30 | action: '/callcongress/state-lookup', 31 | method: 'POST' 32 | }); 33 | gather.say('Thank you for calling Call Congress! If you wish to' + 34 | 'call your senators, please enter your 5-digit zip code.'); 35 | } 36 | res.set('Content-Type', 'text/xml'); 37 | res.send(response.toString()); 38 | }); 39 | 40 | 41 | // Look up state from given zipcode. 42 | // 43 | // Once state is found, redirect to call_senators for forwarding. 44 | router.post('/callcongress/state-lookup', (req, res) => { 45 | zipDigits = req.body.Digits; 46 | // NB: We don't do any error handling for a missing/erroneous zip code 47 | // in this sample application. You, gentle reader, should to handle that 48 | // edge case before deploying this code. 49 | models.ZipCode.findOne({where: { zipcode: zipDigits}}).get('state').then( 50 | (state) => { 51 | return models.State.findOne({where: {name: state}}).get('id').then( 52 | (stateId) => { 53 | return res.redirect('/callcongress/call-senators/' + stateId); 54 | } 55 | ); 56 | } 57 | ); 58 | }); 59 | 60 | 61 | // If our state guess is wrong, prompt user for zip code. 62 | router.get('/callcongress/collect-zip', (req, res) => { 63 | const response = new twilio.twiml.VoiceResponse(); 64 | const gather = response.gather({ 65 | numDigits: 5, 66 | action: '/callcongress/state-lookup', 67 | method: 'POST' 68 | }); 69 | gather.say('If you wish to call your senators, please ' + 70 | 'enter your 5-digit zip code.'); 71 | res.set('Content-Type', 'text/xml'); 72 | res.send(response.toString()); 73 | }); 74 | 75 | 76 | // Set state for senator call list. 77 | // 78 | // Set user's state from confirmation or user-provided Zip. 79 | // Redirect to call_senators route. 80 | router.post('/callcongress/set-state', (req, res) => { 81 | // Get the digit pressed by the user 82 | const digitsProvided = req.body.Digits; 83 | 84 | if (digitsProvided === '1') { 85 | const state = req.body.CallerState; 86 | models.State.findOne({where: {name: state}}).get('id').then( 87 | (stateId) => { 88 | return res.redirect('/callcongress/call-senators/' + stateId); 89 | } 90 | ); 91 | } else { 92 | res.redirect('/callcongress/collect-zip') 93 | } 94 | }); 95 | 96 | 97 | function callSenator(req, res) { 98 | models.State.findOne({ 99 | where: { 100 | id: req.params.state_id 101 | } 102 | }).then( 103 | (state) => { 104 | return state.getSenators().then( 105 | (senators) => { 106 | const response = new twilio.twiml.VoiceResponse(); 107 | response.say("Connecting you to " + senators[0].name + ". " + 108 | "After the senator's office ends the call, you will " + 109 | "be re-directed to " + senators[1].name + "."); 110 | response.dial(senators[0].phone, { 111 | action: '/callcongress/call-second-senator/' + senators[1].id 112 | }); 113 | res.set('Content-Type', 'text/xml'); 114 | return res.send(response.toString()); 115 | } 116 | ); 117 | } 118 | ); 119 | } 120 | 121 | // Route for connecting caller to both of their senators. 122 | router.get('/callcongress/call-senators/:state_id', callSenator); 123 | router.post('/callcongress/call-senators/:state_id', callSenator); 124 | 125 | 126 | function callSecondSenator(req, res) { 127 | models.Senator.findOne({ 128 | where: { 129 | id: req.params.senator_id 130 | } 131 | }).then( 132 | (senator) => { 133 | const response = new twilio.twiml.VoiceResponse(); 134 | response.say("Connecting you to " + senator.name + ". "); 135 | response.dial(senator.phone, { 136 | action: '/callcongress/goodbye/' 137 | }); 138 | res.set('Content-Type', 'text/xml'); 139 | return res.send(response.toString()); 140 | } 141 | ); 142 | } 143 | 144 | // Forward the caller to their second senator. 145 | router.get('/callcongress/call-second-senator/:senator_id', callSecondSenator); 146 | router.post('/callcongress/call-second-senator/:senator_id', callSecondSenator); 147 | 148 | 149 | // Thank user & hang up. 150 | router.post('/callcongress/goodbye', (req, res) => { 151 | const response = new twilio.twiml.VoiceResponse(); 152 | response.say("Thank you for using Call Congress! " + 153 | "Your voice makes a difference. Goodbye."); 154 | response.hangup(); 155 | res.set('Content-Type', 'text/xml'); 156 | res.send(response.toString()); 157 | }); 158 | 159 | module.exports = router; -------------------------------------------------------------------------------- /seeders/20170412153806-zipcodes-seeder.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const parsers = require('../utils/parsers'); 3 | 4 | module.exports = { 5 | up: function (queryInterface, Sequelize) { 6 | return parsers.zipsFromCSV().then( 7 | (zipcodes) => { 8 | return queryInterface.bulkInsert('ZipCodes', zipcodes, {}); 9 | } 10 | ); 11 | }, 12 | 13 | down: function (queryInterface, Sequelize) { 14 | return queryInterface.bulkDelete('ZipCodes', null, {}); 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /seeders/20170413125653-states-seed.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const parsers = require('../utils/parsers'); 4 | 5 | module.exports = { 6 | up: function (queryInterface, Sequelize) { 7 | return queryInterface.bulkInsert('States', 8 | parsers.statesFromJSON().map( 9 | (state) => { return {name: state}; } 10 | ) 11 | ); 12 | }, 13 | 14 | down: function (queryInterface, Sequelize) { 15 | return queryInterface.bulkDelete('States', null, {}); 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /seeders/20170413125708-senators-seed.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const parsers = require('../utils/parsers'); 4 | 5 | module.exports = { 6 | up: function (queryInterface, Sequelize) { 7 | return parsers.senatorsFromJSON().then( 8 | (senators) => { 9 | return queryInterface.bulkInsert('Senators', senators); 10 | } 11 | ); 12 | }, 13 | 14 | down: function (queryInterface, Sequelize) { 15 | return queryInterface.bulkDelete('Senators', null, {}); 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /senators.json: -------------------------------------------------------------------------------- 1 | { 2 | "states": [ 3 | "AL","AK","AZ","AR","CA","CO","CT","DE","FL","GA","HI","ID","IL", 4 | "IN","IA","KS","KY","LA","ME","MD","MA","MI","MN","MS","MO","MT", 5 | "NE","NV","NH","NJ","NM","NY","NC","ND","OH","OK","OR","PA","RI", 6 | "SC","SD","TN","TX","UT","VT","VA","WA","WV","WI","WY","AS","DC", 7 | "FM","GU","MH","MP","PW","PR","VI","AE","AA","AE","AE","AE","AP" 8 | ], 9 | "AK": [ 10 | {"name": "Mark Begich", "phone": "+14157671351", "state": "AK"}, 11 | {"name": "Lisa Murkowski", "phone": "+12174412652", "state": "AK"}], 12 | "AL": [ 13 | {"name": "Jeff Sessions", "phone": "+14157671351", "state": "AL"}, 14 | {"name": "Richard C. Shelby", "phone": "+12174412652", "state": "AL"}], 15 | "AR": [ 16 | {"name": "John Boozman", "phone": "+14157671351", "state": "AR"}, 17 | {"name": "Mark L. Pryor", "phone": "+12174412652", "state": "AR"}], 18 | "AZ": [ 19 | {"name": "Jon Kyl", "phone": "+14157671351", "state": "AZ"}, 20 | {"name": "John McCain", "phone": "+12174412652", "state": "AZ"}], 21 | "CA": [ 22 | {"name": "Barbara Boxer", "phone": "+14157671351", "state": "CA"}, 23 | {"name": "Dianne Feinstein", "phone": "+12174412652", "state": "CA"}], 24 | "CO": [ 25 | {"name": "Michael F. Bennet", "phone": "+14157671351", "state": "CO"}, 26 | {"name": "Mark Udall", "phone": "+12174412652", "state": "CO"}], 27 | "CT": [ 28 | {"name": "Richard Blumenthal", "phone": "+14157671351", "state": "CT"}, 29 | {"name": "Joseph I. Lieberman", "phone": "+12174412652", "state": "CT"}], 30 | "DE": [ 31 | {"name": "Thomas R. Carper", "phone": "+14157671351", "state": "DE"}, 32 | {"name": "Christopher A. Coons", "phone": "+12174412652", "state": "DE"}], 33 | "FL": [ 34 | {"name": "Bill Nelson", "phone": "+14157671351", "state": "FL"}, 35 | {"name": "Marco Rubio", "phone": "+12174412652", "state": "FL"}], 36 | "GA": [ 37 | {"name": "Saxby Chambliss", "phone": "+14157671351", "state": "GA"}, 38 | {"name": "Johnny Isakson", "phone": "+12174412652", "state": "GA"}], 39 | "HI": [ 40 | {"name": "Daniel K. Akaka", "phone": "+14157671351", "state": "HI"}, 41 | {"name": "Daniel K. Inouye", "phone": "+12174412652", "state": "HI"}], 42 | "IA": [ 43 | {"name": "Chuck Grassley", "phone": "+14157671351", "state": "IA"}, 44 | {"name": "Tom Harkin", "phone": "+12174412652", "state": "IA"}], 45 | "ID": [ 46 | {"name": "Mike Crapo", "phone": "+14157671351", "state": "ID"}, 47 | {"name": "James E. Risch", "phone": "+12174412652", "state": "ID"}], 48 | "IL": [ 49 | {"name": "Richard J. Durbin", "phone": "+14157671351", "state": "IL"}, 50 | {"name": "Tammy Duckworth", "phone": "+12174412652", "state": "IL"}], 51 | "IN": [ 52 | {"name": "Daniel Coats", "phone": "+14157671351", "state": "IN"}, 53 | {"name": "Richard G. Lugar", "phone": "+12174412652", "state": "IN"}], 54 | "KS": [ 55 | {"name": "Jerry Moran", "phone": "+14157671351", "state": "KS"}, 56 | {"name": "Pat Roberts", "phone": "+12174412652", "state": "KS"}], 57 | "KY": [ 58 | {"name": "Mitch McConnell", "phone": "+14157671351", "state": "KY"}, 59 | {"name": "Rand Paul", "phone": "+12174412652", "state": "KY"}], 60 | "LA": [ 61 | {"name": "Mary L. Landrieu", "phone": "+14157671351", "state": "LA"}, 62 | {"name": "David Vitter", "phone": "+12174412652", "state": "LA"}], 63 | "MA": [ 64 | {"name": "Scott P. Brown", "phone": "+14157671351", "state": "MA"}, 65 | {"name": "John F. Kerry", "phone": "+12174412652", "state": "MA"}], 66 | "MD": [ 67 | {"name": "Benjamin L. Cardin", "phone": "+14157671351", "state": "MD"}, 68 | {"name": "Barbara A. Mikulski", "phone": "+12174412652", "state": "MD"}], 69 | "ME": [ 70 | {"name": "Susan M. Collins", "phone": "+14157671351", "state": "ME"}, 71 | {"name": "Olympia J. Snowe", "phone": "+12174412652", "state": "ME"}], 72 | "MI": [ 73 | {"name": "Carl Levin", "phone": "+14157671351", "state": "MI"}, 74 | {"name": "Debbie Stabenow", "phone": "+12174412652", "state": "MI"}], 75 | "MN": [ 76 | {"name": "Al Franken", "phone": "+14157671351", "state": "MN"}, 77 | {"name": "Amy Klobuchar", "phone": "+12174412652", "state": "MN"}], 78 | "MO": [ 79 | {"name": "Roy Blunt", "phone": "+14157671351", "state": "MO"}, 80 | {"name": "Claire McCaskill", "phone": "+12174412652", "state": "MO"}], 81 | "MS": [ 82 | {"name": "Thad Cochran", "phone": "+14157671351", "state": "MS"}, 83 | {"name": "Roger F. Wicker", "phone": "+12174412652", "state": "MS"}], 84 | "MT": [ 85 | {"name": "Max Baucus", "phone": "+14157671351", "state": "MT"}, 86 | {"name": "Jon Tester", "phone": "+12174412652", "state": "MT"}], 87 | "NC": [ 88 | {"name": "Richard Burr", "phone": "+14157671351", "state": "NC"}, 89 | {"name": "Kay R. Hagan", "phone": "+12174412652", "state": "NC"}], 90 | "ND": [ 91 | {"name": "Kent Conrad", "phone": "+14157671351", "state": "ND"}, 92 | {"name": "John Hoeven", "phone": "+12174412652", "state": "ND"}], 93 | "NE": [ 94 | {"name": "Mike Johanns", "phone": "+14157671351", "state": "NE"}, 95 | {"name": "Ben Nelson", "phone": "+12174412652", "state": "NE"}], 96 | "NH": [ 97 | {"name": "Kelly Ayotte", "phone": "+14157671351", "state": "NH"}, 98 | {"name": "Jeanne Shaheen", "phone": "+12174412652", "state": "NH"}], 99 | "NJ": [ 100 | {"name": "Frank R. Lautenberg", "phone": "+14157671351", "state": "NJ"}, 101 | {"name": "Robert Menendez", "phone": "+12174412652", "state": "NJ"}], 102 | "MN": [ 103 | {"name": "Jeff Bingaman", "phone": "+14157671351", "state": "NM"}, 104 | {"name": "Tom Udall", "phone": "+12174412652", "state": "NM"}], 105 | "NV": [ 106 | {"name": "John Ensign", "phone": "+14157671351", "state": "NV"}, 107 | {"name": "Harry Reid", "phone": "+12174412652", "state": "NV"}], 108 | "NY": [ 109 | {"name": "Kirsten E. Gillibrand", "phone": "+14157671351", "state": "NY"}, 110 | {"name": "Charles E. Schumer", "phone": "+12174412652", "state": "NY"}], 111 | "OH": [ 112 | {"name": "Sherrod Brown", "phone": "+14157671351", "state": "OH"}, 113 | {"name": "Rob Portman", "phone": "+12174412652", "state": "OH"}], 114 | "OK": [ 115 | {"name": "Tom Coburn", "phone": "+14157671351", "state": "OK"}, 116 | {"name": "James M. Inhofe", "phone": "+12174412652", "state": "OK"}], 117 | "OR": [ 118 | {"name": "Jeff Merkley", "phone": "+14157671351", "state": "OR"}, 119 | {"name": "Ron Wyden", "phone": "+12174412652", "state": "OR"}], 120 | "PA": [ 121 | {"name": "Robert P., Jr. Casey", "phone": "+14157671351", "state": "PA"}, 122 | {"name": "Patrick J. Toomey", "phone": "+12174412652", "state": "PA"}], 123 | "RI": [ 124 | {"name": "Jack Reed", "phone": "+14157671351", "state": "RI"}, 125 | {"name": "Sheldon Whitehouse", "phone": "+12174412652", "state": "RI"}], 126 | "SC": [ 127 | {"name": "Jim DeMint", "phone": "+14157671351", "state": "SC"}, 128 | {"name": "Lindsey Graham", "phone": "+12174412652", "state": "SC"}], 129 | "SD": [ 130 | {"name": "Tim Johnson", "phone": "+14157671351", "state": "SD"}, 131 | {"name": "John Thune", "phone": "+12174412652", "state": "SD"}], 132 | "TN": [ 133 | {"name": "Lamar Alexander", "phone": "+14157671351", "state": "TN"}, 134 | {"name": "Bob Corker", "phone": "+12174412652", "state": "TN"}], 135 | "TX": [ 136 | {"name": "John Cornyn", "phone": "+14157671351", "state": "TX"}, 137 | {"name": "Kay Bailey Hutchison", "phone": "+12174412652", "state": "TX"}], 138 | "UT": [ 139 | {"name": "Orrin G. Hatch", "phone": "+14157671351", "state": "UT"}, 140 | {"name": "Mike Lee", "phone": "+12174412652", "state": "UT"}], 141 | "VA": [ 142 | {"name": "Mark R. Warner", "phone": "+14157671351", "state": "VA"}, 143 | {"name": "Jim Webb", "phone": "+12174412652", "state": "VA"}], 144 | "VT": [ 145 | {"name": "Patrick J. Leahy", "phone": "+14157671351", "state": "VT"}, 146 | {"name": "Bernard Sanders", "phone": "+12174412652", "state": "VT"}], 147 | "WA": [ 148 | {"name": "Maria Cantwell", "phone": "+14157671351", "state": "WA"}, 149 | {"name": "Patty Murray", "phone": "+12174412652", "state": "WA"}], 150 | "WI": [ 151 | {"name": "Ron Johnson", "phone": "+14157671351", "state": "WI"}, 152 | {"name": "Herb Kohl", "phone": "+12174412652", "state": "WI"}], 153 | "WV": [ 154 | {"name": "Joe, III Manchin", "phone": "+14157671351", "state": "WV"}, 155 | {"name": "John D., IV Rockefeller", "phone": "+12174412652", "state": "WV"}], 156 | "WY": [ 157 | {"name": "John Barrasso", "phone": "+14157671351", "state": "WY"}, 158 | {"name": "Michael B. Enzi", "phone": "+12174412652", "state": "WY"}] 159 | } 160 | 161 | -------------------------------------------------------------------------------- /test/test-app.js: -------------------------------------------------------------------------------- 1 | const chai = require('chai'); 2 | const chaiHttp = require('chai-http'); 3 | const chaiXml = require('chai-xml'); 4 | const xml2js = require('xml2js'); 5 | const server = require('../app'); 6 | const models = require('../models'); 7 | const should = chai.should(); 8 | const expect = chai.expect; 9 | 10 | chai.use(chaiHttp); 11 | chai.use(chaiXml); 12 | 13 | 14 | describe('CallForwarding', function() { 15 | it('should render index on / GET', (done) => { 16 | chai.request(server) 17 | .get('/') 18 | .end( 19 | (err, res) => { 20 | res.should.have.status(200); 21 | expect(res).to.be.html; 22 | done(); 23 | } 24 | ); 25 | }); 26 | 27 | it('should get TwiML to gather state no on /callcongress/welcome POST', (done) => { 28 | chai.request(server) 29 | .post('/callcongress/welcome') 30 | .end( 31 | (err, res) => { 32 | res.should.have.status(200); 33 | expect(res.text).xml.to.be.valid; 34 | new xml2js.Parser().parseString(res.text, (err, result) => { 35 | expect(result).to.have.deep.property( 36 | 'Response.Gather[0].$.action', 37 | '/callcongress/state-lookup' 38 | ); 39 | }); 40 | done(); 41 | } 42 | ); 43 | }); 44 | 45 | it('should get TwiML to gather state validation on /callcongress/welcome POST', (done) => { 46 | chai.request(server) 47 | .post('/callcongress/welcome') 48 | .type('form') 49 | .send({FromState: 'IL'}) 50 | .end( 51 | (err, res) => { 52 | res.should.have.status(200); 53 | expect(res.text).xml.to.be.valid; 54 | new xml2js.Parser().parseString(res.text, (err, result) => { 55 | expect(result).to.have.deep.property( 56 | 'Response.Gather[0].$.action', 57 | '/callcongress/set-state' 58 | ); 59 | }); 60 | done(); 61 | } 62 | ); 63 | }); 64 | 65 | it('should be redirected to senator call when confirming state on /callcongress/set-state POST', (done) => { 66 | chai.request(server) 67 | .post('/callcongress/set-state') 68 | .type('form') 69 | .send({Digits: 1, CallerState: 'IL'}) 70 | .end( 71 | (err, res) => { 72 | res.should.have.status(200); 73 | expect(res.text).xml.to.be.valid; 74 | new xml2js.Parser().parseString(res.text, (err, result) => { 75 | expect(result).to.have.deep.property( 76 | 'Response.Dial[0]._', 77 | '+14157671351' 78 | ); 79 | }); 80 | done(); 81 | } 82 | ); 83 | }); 84 | 85 | it('should be redirected set state when invalidating state on /callcongress/set-state POST', (done) => { 86 | chai.request(server) 87 | .post('/callcongress/set-state') 88 | .type('form') 89 | .send({Digits: 2}) 90 | .end( 91 | (err, res) => { 92 | res.should.have.status(200); 93 | expect(res.text).xml.to.be.valid; 94 | new xml2js.Parser().parseString(res.text, (err, result) => { 95 | expect(result).to.have.deep.property( 96 | 'Response.Gather[0].$.numDigits', 97 | '5' 98 | ); 99 | expect(result).to.have.deep.property( 100 | 'Response.Gather[0].$.action', 101 | '/callcongress/state-lookup' 102 | ); 103 | }); 104 | done(); 105 | } 106 | ); 107 | }); 108 | 109 | it('should get TwiML for dialing senator when sending state no on /callcongress/state-lookup POST', (done) => { 110 | chai.request(server) 111 | .post('/callcongress/state-lookup') 112 | .type('form') 113 | .send({Digits: '60616'}) 114 | .end( 115 | (err, res) => { 116 | res.should.have.status(200); 117 | expect(res.text).xml.to.be.valid; 118 | new xml2js.Parser().parseString(res.text, (err, result) => { 119 | expect(result).to.have.deep.property( 120 | 'Response.Say[0]', 121 | 'Connecting you to Richard J. Durbin. After the senator\'s office ends the call, you will be re-directed to Tammy Duckworth.' 122 | ); 123 | expect(result).to.have.deep.property( 124 | 'Response.Dial[0].$.action', 125 | '/callcongress/call-second-senator/26' 126 | ); 127 | }); 128 | done(); 129 | } 130 | ); 131 | }); 132 | 133 | it('should get TwiML for dialing senator on /callcongress/call-senators/id POST', (done) => { 134 | chai.request(server) 135 | .post('/callcongress/call-senators/13') 136 | .end( 137 | (err, res) => { 138 | res.should.have.status(200); 139 | expect(res.text).xml.to.be.valid; 140 | new xml2js.Parser().parseString(res.text, (err, result) => { 141 | expect(result).to.have.deep.property( 142 | 'Response.Dial[0]._', 143 | '+14157671351' 144 | ); 145 | }); 146 | done(); 147 | } 148 | ); 149 | }); 150 | 151 | it('should get TwiML for redirecting to second senator on /callcongress/call-senators/id POST', (done) => { 152 | chai.request(server) 153 | .post('/callcongress/call-senators/13') 154 | .end( 155 | (err, res) => { 156 | res.should.have.status(200); 157 | expect(res.text).xml.to.be.valid; 158 | new xml2js.Parser().parseString(res.text, (err, result) => { 159 | expect(result).to.have.deep.property( 160 | 'Response.Dial[0].$.action', 161 | '/callcongress/call-second-senator/26' 162 | ); 163 | }); 164 | done(); 165 | } 166 | ); 167 | }); 168 | 169 | it('should get TwiML for dialing second senator on /callcongress/call-second-senator/id POST', (done) => { 170 | chai.request(server) 171 | .post('/callcongress/call-second-senator/26') 172 | .end( 173 | (err, res) => { 174 | res.should.have.status(200); 175 | expect(res.text).xml.to.be.valid; 176 | new xml2js.Parser().parseString(res.text, (err, result) => { 177 | expect(result).to.have.deep.property( 178 | 'Response.Dial[0]._', 179 | '+12174412652' 180 | ); 181 | }); 182 | done(); 183 | } 184 | ); 185 | }); 186 | 187 | it('should get TwiML for redirecting to goodbye on /callcongress/call-second-senator/id POST', (done) => { 188 | chai.request(server) 189 | .post('/callcongress/call-second-senator/26') 190 | .end( 191 | (err, res) => { 192 | res.should.have.status(200); 193 | expect(res.text).xml.to.be.valid; 194 | new xml2js.Parser().parseString(res.text, (err, result) => { 195 | expect(result).to.have.deep.property( 196 | 'Response.Dial[0].$.action', 197 | '/callcongress/goodbye/' 198 | ); 199 | }); 200 | done(); 201 | } 202 | ); 203 | }); 204 | 205 | it('should get TwiML for saying to goodbye on /callcongress/goodbye POST', (done) => { 206 | chai.request(server) 207 | .post('/callcongress/goodbye') 208 | .end( 209 | (err, res) => { 210 | res.should.have.status(200); 211 | expect(res.text).xml.to.be.valid; 212 | new xml2js.Parser().parseString(res.text, (err, result) => { 213 | expect(result).to.have.deep.property( 214 | 'Response.Say[0]', 215 | 'Thank you for using Call Congress! Your voice makes a difference. Goodbye.' 216 | ); 217 | }); 218 | done(); 219 | } 220 | ); 221 | }); 222 | }); -------------------------------------------------------------------------------- /utils/parsers.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const csv = require('csv-parse'); 6 | const Promise = require("bluebird"); 7 | 8 | const models = require("../models"); 9 | 10 | function parseJSON(filepath) { 11 | const jsonfilepath = filepath || path.join(__dirname, '../senators.json'); 12 | const content = fs.readFileSync(jsonfilepath); 13 | return JSON.parse(content); 14 | } 15 | 16 | function statesFromJSON(filepath) { 17 | const json = parseJSON(filepath); 18 | return json.states; 19 | } 20 | 21 | function senatorsFromJSON(filepath) { 22 | const json = parseJSON(filepath); 23 | const senators = json.states.reduce( 24 | (acc, state) => { 25 | if (Array.isArray(json[state])) { 26 | acc.push( 27 | models.State.findOne({ where: {name: state} }) 28 | .get('id') 29 | .then( 30 | (id) => { 31 | return json[state].map( 32 | (senator) => { 33 | senator.StateId = id; 34 | delete senator['state']; 35 | return senator; 36 | } 37 | ); 38 | } 39 | ) 40 | ); 41 | } 42 | return acc; 43 | }, 44 | [] 45 | ); 46 | 47 | return Promise.reduce( 48 | senators, 49 | (acc, value) => { 50 | return acc.concat(value); 51 | }, 52 | [] 53 | ); 54 | } 55 | 56 | function parseCSV(filepath) { 57 | return new Promise(function (resolve, reject) { 58 | var parser = csv({delimiter: ',', escape: '"', from: 2}, 59 | (err, data) => { 60 | if (err) { 61 | reject(err); 62 | } else { 63 | resolve(data); 64 | } 65 | parser.end(); 66 | }); 67 | fs.createReadStream(filepath).pipe(parser); 68 | }); 69 | } 70 | 71 | function zipsFromCSV(filepath) { 72 | const csvfilepath = filepath || path.join(__dirname, '../free-zipcode-database.csv'); 73 | return parseCSV(csvfilepath).then( 74 | (data) => { 75 | return new Promise((resolve) => { 76 | resolve(data.map((row) => { 77 | return {zipcode: row[0], state: row[3] } 78 | })); 79 | }); 80 | }, 81 | (reason) => { 82 | console.log('Error while parsing CSV: ' + reason); 83 | } 84 | ); 85 | } 86 | 87 | module.exports = { 88 | zipsFromCSV: zipsFromCSV, 89 | statesFromJSON: statesFromJSON, 90 | senatorsFromJSON: senatorsFromJSON 91 | } -------------------------------------------------------------------------------- /views/index.pug: -------------------------------------------------------------------------------- 1 | extends layout.pug 2 | 3 | block content 4 | div(class="hero-text full-page") 5 | h1 Welcome to
Call Forward! 6 | h2 Call 312-997-5372 7 | p Make your voice heard. -------------------------------------------------------------------------------- /views/layout.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html 3 | head 4 | title CallForward 5 | meta(charset="UTF-8") 6 | meta(http-equiv="X-UA-Compatible", content="IE=edge") 7 | meta(name="viewport",content="width=device-width, initial-scale=1") 8 | link( 9 | rel="stylesheet" 10 | href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css" 11 | integrity="sha256-7s5uDGW3AHqw6xtJmNNtr+OBRJUlgkNJEo78P4b0yRw= sha512-nNo+yCHEyn0smMxSswnf/OnX6/KwJuZTlNZBjauKhTK0c+zT+q5JOCx0UFhXQ6rJR9jg6Es8gPuD2uZcYDLqSw==" 12 | crossorigin="anonymous" 13 | ) 14 | link( 15 | rel="stylesheet" 16 | href="https://maxcdn.bootstrapcdn.com/font-awesome/4.4.0/css/font-awesome.min.css" 17 | integrity="sha256-k2/8zcNbxVIh5mnQ52A0r3a6jAgMGxFJFE2707UxGCk= sha512-ZV9KawG2Legkwp3nAlxLIVFudTauWuBpC10uEafMHYL0Sarrz5A7G79kXh5+5+woxQ5HM559XX2UZjMJ36Wplg==" 18 | crossorigin="anonymous" 19 | ) 20 | link(type="text/css" href="/static/css/main.css" rel="stylesheet") 21 | 22 | 23 | body 24 | section(id="main" class="push-nav") 25 | block content 26 | footer(class="container") Made with by your pals 27 | | 28 | | 29 | a(href="http://www.twilio.com") @twilio --------------------------------------------------------------------------------