├── .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 |
3 |
4 |
5 | # Advanced Call Forwarding with Node and Express
6 |
7 | [](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
--------------------------------------------------------------------------------