From efef6c49ff4a1d576dbf3c68263b23d99b12c378 Mon Sep 17 00:00:00 2001 From: alexisjcarr Date: Wed, 24 Jul 2019 20:03:09 -0500 Subject: [PATCH 1/2] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Adds=20index.js=20and?= =?UTF-8?q?=20server.js=20for=20separation=20of=20concerns.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1 --- index.js | 7 + package-lock.json | 12 +- package.json | 1 + server.js | 318 ++++++++++++++++++++++++++++++++++++++++++++++ web.js | 301 ------------------------------------------- 5 files changed, 336 insertions(+), 303 deletions(-) create mode 100644 index.js create mode 100644 server.js delete mode 100644 web.js diff --git a/index.js b/index.js new file mode 100644 index 0000000..b3bbeae --- /dev/null +++ b/index.js @@ -0,0 +1,7 @@ +const server = require("./server"); + +const PORT = process.env.PORT || 5000; + +server.listen(PORT, () => { + console.log(`\n::: Listening on port ${PORT} :::\n`); +}); diff --git a/package-lock.json b/package-lock.json index d69aca6..07ff24d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -597,6 +597,15 @@ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" }, + "cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "requires": { + "object-assign": "^4", + "vary": "^1" + } + }, "crypt": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", @@ -2121,8 +2130,7 @@ "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", - "dev": true + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" }, "object-copy": { "version": "0.1.0", diff --git a/package.json b/package.json index 9791f27..05e55fa 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "dependencies": { "body-parser": "^1.18.3", "cookie-session": "^2.0.0-beta.3", + "cors": "^2.8.5", "csv": "^1.2.1", "dotenv": "~4.0.0", "emoji-strip": "^1.0.1", diff --git a/server.js b/server.js new file mode 100644 index 0000000..d088766 --- /dev/null +++ b/server.js @@ -0,0 +1,318 @@ +/* eslint "no-console": "off" */ +require("dotenv").config(); +const MessagingResponse = require("twilio").twiml.MessagingResponse; +const express = require("express"); +const cookieSession = require("cookie-session"); +const bodyParser = require("body-parser"); +const emojiStrip = require("emoji-strip"); +const moment = require("moment-timezone"); +const onHeaders = require("on-headers"); +const action_symbol = Symbol.for("action"); + +const db = require("./db"); +const messages = require("./utils/messages.js"); +const log = require("./utils/logger"); +const web_log = require("./utils/logger/hit_log"); +const web_api = require("./web_api/routes"); + +const server = express(); + +/* Express Middleware */ + +server.use(bodyParser.urlencoded({ extended: false })); +server.use(bodyParser.json()); + +server.use( + cookieSession({ + name: "session", + secret: process.env.COOKIE_SECRET, + signed: false // causing problems with twilio -- investigating + }) +); + +/* makes json print nicer for /cases */ +server.set("json spaces", 2); + +/* Serve testing page on which you can impersonate Twilio (but not in production) */ +if (server.settings.env === "development" || server.settings.env === "test") { + server.use(express.static("public")); +} + +/* Allows CORS */ +server.use(cors()); + +/* Enable CORS support for IE8. */ +// server.get("/proxy.html", (req, res) => { +// res.send( +// '\n' +// ); +// }); + +server.get("/", (req, res) => { + res.status(200).send(messages.iAmCourtBot()); +}); + +/* Add routes for api access */ +server.use("/api", web_api); + +/* Fuzzy search that returns cases with a partial name match or + an exact citation match +*/ +server.get("/cases", (req, res, next) => { + if (!req.query || !req.query.q) { + return res.sendStatus(400); + } + + return db + .fuzzySearch(req.query.q) + .then(data => { + if (data) { + data.forEach(d => { + d.readableDate = moment(d.date).format( + "dddd, MMM Do" + ); /* eslint "no-param-reassign": "off" */ + }); + } + return res.json(data); + }) + .catch(err => next(err)); +}); + +/** + * Twilio Hook for incoming text messages + */ +server.post( + "/sms", + cleanupTextMiddelWare, + stopMiddleware, + deleteMiddleware, + yesNoMiddleware, + currentRequestMiddleware, + caseIdMiddleware, + unservicableRequest +); + +/* Middleware functions */ + +/** + * Strips line feeds, returns, and emojis from string and trims it + * + * @param {String} text incoming message to evaluate + * @return {String} cleaned up string + */ +function cleanupTextMiddelWare(req, res, next) { + let text = req.body.Body.replace(/[\r\n|\n].*/g, ""); + req.body.Body = emojiStrip(text) + .trim() + .toUpperCase(); + next(); +} + +/** + * Checks for 'STOP' text. We will recieve this if the user requests that twilio stop sending texts + * All further attempts to send a text (inlcuding responing to this text) will fail until the user restores this. + * This will delete any requests the user currently has (alternatively we could mark them inactive and reactiveate if they restart) + */ +function stopMiddleware(req, res, next) { + const stop_words = [ + "STOP", + "STOPALL", + "UNSUBSCRIBE", + "CANCEL", + "END", + "QUIT" + ]; + const text = req.body.Body; + if (!stop_words.includes(text)) return next(); + + db.deactivateRequestsFor(req.body.From) + .then(case_ids => { + res[action_symbol] = "stop"; + return res.sendStatus(200); // once stopped replies don't make it to the user + }) + .catch(err => next(err)); +} + +/** + * Handles cases when user has send a yes or no text. + */ +function yesNoMiddleware(req, res, next) { + // Yes or No resonses are only meaningful if we also know the citation ID. + if (!req.session.case_id) return next(); + + const twiml = new MessagingResponse(); + if (isResponseYes(req.body.Body)) { + db.addRequest({ + case_id: req.session.case_id, + phone: req.body.From, + known_case: req.session.known_case + }) + .then(() => { + twiml.message( + req.session.known_case + ? messages.weWillRemindYou() + : messages.weWillKeepLooking() + ); + res[action_symbol] = req.session.known_case + ? "schedule_reminder" + : "schedule_unmatched"; + req.session = null; + req.session = null; + res.send(twiml.toString()); + }) + .catch(err => next(err)); + } else if (isResponseNo(req.body.Body)) { + res[action_symbol] = "decline_reminder"; + twiml.message( + req.session.known_case + ? messages.repliedNo() + : messages.repliedNoToKeepChecking() + ); + req.session = null; + res.send(twiml.toString()); + } else { + next(); + } +} + +/** + * Handles cases where user has entered a case they are already subscribed to + * and then type Delete + */ +function deleteMiddleware(req, res, next) { + // Delete response is only meaningful if we have a delete_case_id. + const case_id = req.session.delete_case_id; + const phone = req.body.From; + if (!case_id || req.body.Body !== "DELETE") return next(); + res[action_symbol] = "delete_request"; + const twiml = new MessagingResponse(); + db.deactivateRequest(case_id, phone) + .then(() => { + req.session = null; + twiml.message(messages.weWillStopSending(case_id)); + res.send(twiml.toString()); + }) + .catch(err => next(err)); +} + +/** + * Responds if the sending phone number is alreay subscribed to this case_id= + */ +function currentRequestMiddleware(req, res, next) { + const text = req.body.Body; + const phone = req.body.From; + if (!possibleCaseID(text)) return next(); + db.findRequest(text, phone) + .then(results => { + if (!results || results.length === 0) return next(); + + const twiml = new MessagingResponse(); + // looks like they're already subscribed + res[action_symbol] = "already_subscribed"; + req.session.delete_case_id = text; + twiml.message(messages.alreadySubscribed(text)); + res.send(twiml.toString()); + }) + .catch(err => next(err)); +} + +/** + * If input looks like a case number handle it + */ +function caseIdMiddleware(req, res, next) { + const text = req.body.Body; + if (!possibleCaseID(text)) return next(); + const twiml = new MessagingResponse(); + + db.findCitation(req.body.Body) + .then(results => { + if (!results || results.length === 0) { + // Looks like it could be a citation that we don't know about yet + res[action_symbol] = "unmatched_case"; + twiml.message(messages.notFoundAskToKeepLooking()); + req.session.known_case = false; + req.session.case_id = text; + } else { + // They sent a known citation! + res[action_symbol] = "found_case"; + twiml.message(messages.foundItAskForReminder(results[0])); + req.session.case_id = text; + req.session.known_case = true; + } + res.send(twiml.toString()); + }) + .catch(err => next(err)); +} + +/** + * None of our middleware could figure out what to do with the input + * [TODO: create a better message to help users use the service] + */ +function unservicableRequest(req, res, next) { + // this would be a good place for some instructions to the user + res[action_symbol] = "unusable_input"; + const twiml = new MessagingResponse(); + twiml.message(messages.invalidCaseNumber()); + res.send(twiml.toString()); +} + +/* Utility helper functions */ + +/** + * Test message to see if it looks like a case id. + * Currently alphan-numeric plus '-' between 6 and 25 characters + * @param {String} text + */ +function possibleCaseID(text) { + /* From AK Court System: + - A citation must start with an alpha letter (A-Z) and followed + by only alpha (A-Z) and numeric (0-9) letters with a length of 8-17. + - Case number must start with a number (1-4) + and have a length of 14 exactly with dashes. + */ + + const citation_rx = /^[A-Za-z][A-Za-z0-9]{7,16}$/; + const case_rx = /^[1-4][A-Za-z0-9-]{13}$/; + return case_rx.test(text) || citation_rx.test(text); +} + +/** + * Checks for an affirmative response + * + * @param {String} text incoming message to evaluate + * @return {Boolean} true if the message is an affirmative response + */ +function isResponseYes(text) { + return text === "YES" || text === "YEA" || text === "YUP" || text === "Y"; +} + +/** + * Checks for negative or declined response + * + * @param {String} text incoming message to evaluate + * @return {Boolean} true if the message is a negative response + */ +function isResponseNo(text) { + return text === "NO" || text === "N"; +} + +/* Error handling Middleware */ +server.use((err, req, res, next) => { + if (!res.headersSent) { + log.error(err); + + // during development, return the trace to the client for helpfulness + if (server.settings.env !== "production") { + res.status(500).send(err.stack); + return; + } + res.status(500).send("Sorry, internal server error"); + } +}); + +/* Send all uncaught exceptions to Rollbar??? */ +const options = { + exitOnUncaughtException: true +}; + +module.exports = server; diff --git a/web.js b/web.js deleted file mode 100644 index a65b7d7..0000000 --- a/web.js +++ /dev/null @@ -1,301 +0,0 @@ -/* eslint "no-console": "off" */ -require('dotenv').config(); -const MessagingResponse = require('twilio').twiml.MessagingResponse; -const express = require('express'); -const cookieSession = require('cookie-session') -const bodyParser = require('body-parser') -const db = require('./db'); -const emojiStrip = require('emoji-strip'); -const messages = require('./utils/messages.js'); -const moment = require("moment-timezone"); -const onHeaders = require('on-headers'); -const log = require('./utils/logger') -const web_log = require('./utils/logger/hit_log') -const web_api = require('./web_api/routes'); -const action_symbol = Symbol.for('action'); - -const app = express(); - -/* Express Middleware */ - -app.use(bodyParser.urlencoded({ extended: false })) -app.use(bodyParser.json()) -app.use(cookieSession({ - name: 'session', - secret: process.env.COOKIE_SECRET, - signed: false, // causing problems with twilio -- investigating -})); - -/* makes json print nicer for /cases */ -app.set('json spaces', 2); - -/* Serve testing page on which you can impersonate Twilio (but not in production) */ -if (app.settings.env === 'development' || app.settings.env === 'test') { - app.use(express.static('public')); -} - -/* Allows CORS */ -app.all('*', (req, res, next) => { - res.header('Access-Control-Allow-Origin', '*'); - res.header('Access-Control-Allow-Headers', 'X-Requested-With, Authorization, Content-Type'); - res.header('Access-Control-Allow-Methods', 'GET,PUT,POST,OPTIONS'); - onHeaders(res, web_log) - next(); -}); - -/* Enable CORS support for IE8. */ -app.get('/proxy.html', (req, res) => { - res.send('\n'); -}); - -app.get('/', (req, res) => { - res.status(200).send(messages.iAmCourtBot()); -}); - -/* Add routes for api access */ -app.use('/api', web_api); - -/* Fuzzy search that returns cases with a partial name match or - an exact citation match -*/ -app.get('/cases', (req, res, next) => { - if (!req.query || !req.query.q) { - return res.sendStatus(400); - } - - return db.fuzzySearch(req.query.q) - .then((data) => { - if (data) { - data.forEach((d) => { - d.readableDate = moment(d.date).format('dddd, MMM Do'); /* eslint "no-param-reassign": "off" */ - }); - } - return res.json(data); - }) - .catch(err => next(err)); -}); - -/** - * Twilio Hook for incoming text messages - */ -app.post('/sms', - cleanupTextMiddelWare, - stopMiddleware, - deleteMiddleware, - yesNoMiddleware, - currentRequestMiddleware, - caseIdMiddleware, - unservicableRequest -); - - /* Middleware functions */ - -/** - * Strips line feeds, returns, and emojis from string and trims it - * - * @param {String} text incoming message to evaluate - * @return {String} cleaned up string - */ -function cleanupTextMiddelWare(req,res, next) { - let text = req.body.Body.replace(/[\r\n|\n].*/g, ''); - req.body.Body = emojiStrip(text).trim().toUpperCase(); - next() -} - -/** - * Checks for 'STOP' text. We will recieve this if the user requests that twilio stop sending texts - * All further attempts to send a text (inlcuding responing to this text) will fail until the user restores this. - * This will delete any requests the user currently has (alternatively we could mark them inactive and reactiveate if they restart) - */ -function stopMiddleware(req, res, next){ - const stop_words = ['STOP', 'STOPALL', 'UNSUBSCRIBE', 'CANCEL', 'END','QUIT'] - const text = req.body.Body - if (!stop_words.includes(text)) return next() - - db.deactivateRequestsFor(req.body.From) - .then(case_ids => { - res[action_symbol] = "stop" - return res.sendStatus(200); // once stopped replies don't make it to the user - }) - .catch(err => next(err)); -} - -/** - * Handles cases when user has send a yes or no text. - */ -function yesNoMiddleware(req, res, next) { - // Yes or No resonses are only meaningful if we also know the citation ID. - if (!req.session.case_id) return next() - - const twiml = new MessagingResponse(); - if (isResponseYes(req.body.Body)) { - db.addRequest({ - case_id: req.session.case_id, - phone: req.body.From, - known_case: req.session.known_case - }) - .then(() => { - twiml.message(req.session.known_case ? messages.weWillRemindYou() : messages.weWillKeepLooking() ); - res[action_symbol] = req.session.known_case? "schedule_reminder" : "schedule_unmatched" - req.session = null; - req.session = null; - res.send(twiml.toString()); - }) - .catch(err => next(err)); - } else if (isResponseNo(req.body.Body)) { - res[action_symbol] = "decline_reminder" - twiml.message(req.session.known_case ? messages.repliedNo(): messages.repliedNoToKeepChecking()); - req.session = null; - res.send(twiml.toString()); - } else{ - next() - } -} - -/** - * Handles cases where user has entered a case they are already subscribed to - * and then type Delete - */ -function deleteMiddleware(req, res, next) { - // Delete response is only meaningful if we have a delete_case_id. - const case_id = req.session.delete_case_id - const phone = req.body.From - if (!case_id || req.body.Body !== "DELETE") return next() - res[action_symbol] = "delete_request" - const twiml = new MessagingResponse(); - db.deactivateRequest(case_id, phone) - .then(() => { - req.session = null; - twiml.message(messages.weWillStopSending(case_id)) - res.send(twiml.toString()); - }) - .catch(err => next(err)); -} - -/** - * Responds if the sending phone number is alreay subscribed to this case_id= - */ -function currentRequestMiddleware(req, res, next) { - const text = req.body.Body - const phone = req.body.From - if (!possibleCaseID(text)) return next() - db.findRequest(text, phone) - .then(results => { - if (!results || results.length === 0) return next() - - const twiml = new MessagingResponse(); - // looks like they're already subscribed - res[action_symbol] = "already_subscribed" - req.session.delete_case_id = text - twiml.message(messages.alreadySubscribed(text)) - res.send(twiml.toString()); - }) - .catch(err => next(err)); -} - -/** - * If input looks like a case number handle it - */ -function caseIdMiddleware(req, res, next){ - const text = req.body.Body - if (!possibleCaseID(text)) return next() - const twiml = new MessagingResponse(); - - db.findCitation(req.body.Body) - .then(results => { - if (!results || results.length === 0){ - // Looks like it could be a citation that we don't know about yet - res[action_symbol] = "unmatched_case" - twiml.message(messages.notFoundAskToKeepLooking()); - req.session.known_case = false; - req.session.case_id = text; - } else { - // They sent a known citation! - res[action_symbol] = "found_case" - twiml.message(messages.foundItAskForReminder(results[0])); - req.session.case_id = text; - req.session.known_case = true; - } - res.send(twiml.toString()); - }) - .catch(err => next(err)); -} - -/** - * None of our middleware could figure out what to do with the input - * [TODO: create a better message to help users use the service] - */ -function unservicableRequest(req, res, next){ - // this would be a good place for some instructions to the user - res[action_symbol] = "unusable_input" - const twiml = new MessagingResponse(); - twiml.message(messages.invalidCaseNumber()); - res.send(twiml.toString()); -} - -/* Utility helper functions */ - -/** - * Test message to see if it looks like a case id. - * Currently alphan-numeric plus '-' between 6 and 25 characters - * @param {String} text - */ -function possibleCaseID(text) { - /* From AK Court System: - - A citation must start with an alpha letter (A-Z) and followed - by only alpha (A-Z) and numeric (0-9) letters with a length of 8-17. - - Case number must start with a number (1-4) - and have a length of 14 exactly with dashes. - */ - - const citation_rx = /^[A-Za-z][A-Za-z0-9]{7,16}$/ - const case_rx = /^[1-4][A-Za-z0-9-]{13}$/ - return case_rx.test(text) || citation_rx.test(text); -} - -/** - * Checks for an affirmative response - * - * @param {String} text incoming message to evaluate - * @return {Boolean} true if the message is an affirmative response - */ -function isResponseYes(text) { - return (text === 'YES' || text === 'YEA' || text === 'YUP' || text === 'Y'); -} - -/** - * Checks for negative or declined response - * - * @param {String} text incoming message to evaluate - * @return {Boolean} true if the message is a negative response - */ -function isResponseNo(text) { - return (text === 'NO' || text === 'N'); -} - - -/* Error handling Middleware */ -app.use((err, req, res, next) => { - if (!res.headersSent) { - log.error(err); - - // during development, return the trace to the client for helpfulness - if (app.settings.env !== 'production') { - res.status(500).send(err.stack); - return; - } - res.status(500).send('Sorry, internal server error'); - } -}); - -/* Send all uncaught exceptions to Rollbar??? */ -const options = { - exitOnUncaughtException: true, -}; - -const port = Number(process.env.PORT || 5000); -app.listen(port, () => { - log.info(`Listening on port ${port}`); -}); - -module.exports = app; From 3d963c310be87f6ef0330018bd63793fbe52cc43 Mon Sep 17 00:00:00 2001 From: alexisjcarr Date: Wed, 24 Jul 2019 20:27:01 -0500 Subject: [PATCH 2/2] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Starts=20abstracting?= =?UTF-8?q?=20middleware=20away=20from=20server.js?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1 --- .gitignore | 63 ++++++++++++ middleware/index.js | 159 +++++++++++++++++++++++++++++ package.json | 4 +- server.js | 11 +- web_api/routes.js | 240 +++++++++++++++++++++++--------------------- 5 files changed, 351 insertions(+), 126 deletions(-) create mode 100644 middleware/index.js diff --git a/.gitignore b/.gitignore index 319c7a2..4cbb8d1 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,66 @@ utils/tmp/* TODO .idea .vscode/ + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript v1 declaration files +typings/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env + +# next.js build output +.next + diff --git a/middleware/index.js b/middleware/index.js new file mode 100644 index 0000000..941a369 --- /dev/null +++ b/middleware/index.js @@ -0,0 +1,159 @@ +/* Middleware functions */ +module.exports = { + cleanupTextMiddleWare, + stopMiddleware, + yesNoMiddleware, + deleteMiddleware, + currentRequestMiddleware, + caseIdMiddleware +}; + +/** + * Strips line feeds, returns, and emojis from string and trims it + * + * @param {String} text incoming message to evaluate + * @return {String} cleaned up string + */ +function cleanupTextMiddleWare(req, res, next) { + let text = req.body.Body.replace(/[\r\n|\n].*/g, ""); + req.body.Body = emojiStrip(text) + .trim() + .toUpperCase(); + next(); +} + +/** + * Checks for 'STOP' text. We will recieve this if the user requests that twilio stop sending texts + * All further attempts to send a text (inlcuding responing to this text) will fail until the user restores this. + * This will delete any requests the user currently has (alternatively we could mark them inactive and reactiveate if they restart) + */ +function stopMiddleware(req, res, next) { + const stop_words = [ + "STOP", + "STOPALL", + "UNSUBSCRIBE", + "CANCEL", + "END", + "QUIT" + ]; + const text = req.body.Body; + if (!stop_words.includes(text)) return next(); + + db.deactivateRequestsFor(req.body.From) + .then(case_ids => { + res[action_symbol] = "stop"; + return res.sendStatus(200); // once stopped replies don't make it to the user + }) + .catch(err => next(err)); +} + +/** + * Handles cases when user has send a yes or no text. + */ +function yesNoMiddleware(req, res, next) { + // Yes or No resonses are only meaningful if we also know the citation ID. + if (!req.session.case_id) return next(); + + const twiml = new MessagingResponse(); + if (isResponseYes(req.body.Body)) { + db.addRequest({ + case_id: req.session.case_id, + phone: req.body.From, + known_case: req.session.known_case + }) + .then(() => { + twiml.message( + req.session.known_case + ? messages.weWillRemindYou() + : messages.weWillKeepLooking() + ); + res[action_symbol] = req.session.known_case + ? "schedule_reminder" + : "schedule_unmatched"; + req.session = null; + req.session = null; + res.send(twiml.toString()); + }) + .catch(err => next(err)); + } else if (isResponseNo(req.body.Body)) { + res[action_symbol] = "decline_reminder"; + twiml.message( + req.session.known_case + ? messages.repliedNo() + : messages.repliedNoToKeepChecking() + ); + req.session = null; + res.send(twiml.toString()); + } else { + next(); + } +} + +/** + * Handles cases where user has entered a case they are already subscribed to + * and then type Delete + */ +function deleteMiddleware(req, res, next) { + // Delete response is only meaningful if we have a delete_case_id. + const case_id = req.session.delete_case_id; + const phone = req.body.From; + if (!case_id || req.body.Body !== "DELETE") return next(); + res[action_symbol] = "delete_request"; + const twiml = new MessagingResponse(); + db.deactivateRequest(case_id, phone) + .then(() => { + req.session = null; + twiml.message(messages.weWillStopSending(case_id)); + res.send(twiml.toString()); + }) + .catch(err => next(err)); +} + +/** + * Responds if the sending phone number is alreay subscribed to this case_id= + */ +function currentRequestMiddleware(req, res, next) { + const text = req.body.Body; + const phone = req.body.From; + if (!possibleCaseID(text)) return next(); + db.findRequest(text, phone) + .then(results => { + if (!results || results.length === 0) return next(); + + const twiml = new MessagingResponse(); + // looks like they're already subscribed + res[action_symbol] = "already_subscribed"; + req.session.delete_case_id = text; + twiml.message(messages.alreadySubscribed(text)); + res.send(twiml.toString()); + }) + .catch(err => next(err)); +} + +/** + * If input looks like a case number handle it + */ +function caseIdMiddleware(req, res, next) { + const text = req.body.Body; + if (!possibleCaseID(text)) return next(); + const twiml = new MessagingResponse(); + + db.findCitation(req.body.Body) + .then(results => { + if (!results || results.length === 0) { + // Looks like it could be a citation that we don't know about yet + res[action_symbol] = "unmatched_case"; + twiml.message(messages.notFoundAskToKeepLooking()); + req.session.known_case = false; + req.session.case_id = text; + } else { + // They sent a known citation! + res[action_symbol] = "found_case"; + twiml.message(messages.foundItAskForReminder(results[0])); + req.session.case_id = text; + req.session.known_case = true; + } + res.send(twiml.toString()); + }) + .catch(err => next(err)); +} diff --git a/package.json b/package.json index 05e55fa..55ad097 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "main": "web.js", "scripts": { "test": "NODE_ENV=test mocha --exit test", - "start": "node web.js", + "start": "node index.js", "dbsetup": "node utils/createTables.js", "loaddata": "node runners/load.js" }, @@ -50,4 +50,4 @@ "supertest": "~3.0.0", "supertest-session": "^3.3.0" } -} +} \ No newline at end of file diff --git a/server.js b/server.js index d088766..1827c60 100644 --- a/server.js +++ b/server.js @@ -41,13 +41,6 @@ if (server.settings.env === "development" || server.settings.env === "test") { /* Allows CORS */ server.use(cors()); -/* Enable CORS support for IE8. */ -// server.get("/proxy.html", (req, res) => { -// res.send( -// '\n' -// ); -// }); - server.get("/", (req, res) => { res.status(200).send(messages.iAmCourtBot()); }); @@ -83,7 +76,7 @@ server.get("/cases", (req, res, next) => { */ server.post( "/sms", - cleanupTextMiddelWare, + cleanupTextMiddleWare, stopMiddleware, deleteMiddleware, yesNoMiddleware, @@ -100,7 +93,7 @@ server.post( * @param {String} text incoming message to evaluate * @return {String} cleaned up string */ -function cleanupTextMiddelWare(req, res, next) { +function cleanupTextMiddleWare(req, res, next) { let text = req.body.Body.replace(/[\r\n|\n].*/g, ""); req.body.Body = emojiStrip(text) .trim() diff --git a/web_api/routes.js b/web_api/routes.js index 7d3226e..ecb17a7 100644 --- a/web_api/routes.js +++ b/web_api/routes.js @@ -1,8 +1,8 @@ -require('dotenv').config(); -const express = require('express') -const router = express.Router() -const db = require('./db') -const moment = require('moment-timezone') +require("dotenv").config(); +const express = require("express"); +const router = express.Router(); +const db = require("./db"); +const moment = require("moment-timezone"); const jwt = require("jsonwebtoken"); /** @@ -11,13 +11,17 @@ const jwt = require("jsonwebtoken"); * @param {String} password * @returns {Boolean} is user/password valid */ -function authorized(user, password){ - // just a stub for now using .env values - // TODO flesh out with better user management - if (user == process.env.ADMIN_LOGIN && password == process.env.ADMIN_PASSWORD) { - return true - } - else { return false } +function authorized(user, password) { + // just a stub for now using .env values + // TODO flesh out with better user management + if ( + user == process.env.ADMIN_LOGIN && + password == process.env.ADMIN_PASSWORD + ) { + return true; + } else { + return false; + } } /** @@ -27,43 +31,49 @@ function authorized(user, password){ * @param {*} res * @param {*} next */ -function requireAuth(req, res, next){ - if (req.headers && req.headers.authorization && req.headers.authorization.split(' ')[0] == 'JWT'){ - jwt.verify(req.headers.authorization.split(' ')[1], process.env.JWT_SECRET, function(err, decoded){ - if (err) { - res.status(401).json({message: "Authorization Required"}) - } - else { - next() - } - }) - } - else { - res.status(401).json({message: "Authorization Required"}) - } +function requireAuth(req, res, next) { + if ( + req.headers && + req.headers.authorization && + req.headers.authorization.split(" ")[0] == "JWT" + ) { + jwt.verify( + req.headers.authorization.split(" ")[1], + process.env.JWT_SECRET, + function(err, decoded) { + if (err) { + res.status(401).json({ message: "Authorization Required" }); + } else { + next(); + } + } + ); + } else { + res.status(401).json({ message: "Authorization Required" }); + } } -router.post('/admin_login', function(req, res, next){ - if(authorized(req.body.user, req.body.password)) { - res.json(({token: jwt.sign({user: req.body.user}, process.env.JWT_SECRET)})) - } - else{ - res.status(401).json({message: "Login Failed"}) - } -}) - +router.post("/admin_login", function(req, res, next) { + if (authorized(req.body.user, req.body.password)) { + res.json({ + token: jwt.sign({ user: req.body.user }, process.env.JWT_SECRET) + }); + } else { + res.status(401).json({ message: "Login Failed" }); + } +}); /** * Get info form case_id */ -router.get('/case', requireAuth, function(req, res, next){ - if (!req.query || !req.query.case_id) return res.sendStatus(400); - db.findHearing(req.query.case_id) +router.get("/case", requireAuth, function(req, res, next) { + if (!req.query || !req.query.case_id) return res.sendStatus(400); + db.findHearing(req.query.case_id) .then(data => { - res.send(data); + res.send(data); }) - .catch(err => next(err)) -}) + .catch(err => next(err)); +}); /** * Returns requests associated with case_id. If @@ -73,20 +83,20 @@ router.get('/case', requireAuth, function(req, res, next){ * @returns: * [{case_id:string, phone:string, created_at:date_string, active:boolean, noficiations:[]}] */ -router.get('/requests', requireAuth, function(req, res, next){ - if (!req.query || !req.query.case_id) return res.sendStatus(400); - db.findRequestNotifications(req.query.case_id) +router.get("/requests", requireAuth, function(req, res, next) { + if (!req.query || !req.query.case_id) return res.sendStatus(400); + db.findRequestNotifications(req.query.case_id) .then(data => { - if (data) { - data.forEach(function (d) { - // Replace postgres' [null] with [] is much nicer on the front end - d.notifications = d.notifications.filter(n => n) - }); - } - res.send(data); + if (data) { + data.forEach(function(d) { + // Replace postgres' [null] with [] is much nicer on the front end + d.notifications = d.notifications.filter(n => n); + }); + } + res.send(data); }) - .catch(err => next(err)) -}) + .catch(err => next(err)); +}); /** * Returns requests associated with phone. If @@ -96,86 +106,86 @@ router.get('/requests', requireAuth, function(req, res, next){ * @returns * [{case_id:string, phone:string, created_at:timestamp, active:boolean, noficiations:[]}] */ -router.get('/requests_by_phone', requireAuth, function(req, res, next){ - if (!req.query || !req.query.phone) return res.sendStatus(400); - db.findRequestsFromPhone(req.query.phone) +router.get("/requests_by_phone", requireAuth, function(req, res, next) { + if (!req.query || !req.query.phone) return res.sendStatus(400); + db.findRequestsFromPhone(req.query.phone) .then(data => { - if (data) { - data.forEach(function (d) { - // Replace postgres' [null] with [] is much nicer on the front end - d.notifications = d.notifications.filter(n => n) - }); - } - res.send(data); + if (data) { + data.forEach(function(d) { + // Replace postgres' [null] with [] is much nicer on the front end + d.notifications = d.notifications.filter(n => n); + }); + } + res.send(data); }) - .catch(err => next(err)) -}) + .catch(err => next(err)); +}); /** * Returns all logged activity for an (encrypted) phone number * @param {string} encrypted phon * @return [{time: timstamp, path:/sms, method:POST, status_code:200, phone:encryptedPhone, body:'user input', action:action}] */ -router.get('/phonelog', requireAuth, function(req, res, next){ - if (!req.query || !req.query.phone) return res.sendStatus(400); - db.phoneLog(req.query.phone) +router.get("/phonelog", requireAuth, function(req, res, next) { + if (!req.query || !req.query.phone) return res.sendStatus(400); + db.phoneLog(req.query.phone) .then(data => res.send(data)) - .catch(err => next(err)) -}) + .catch(err => next(err)); +}); /** * Histogram of actions within timeframe * @param {Number} daysback [default 7] * @returns [{type:action type, count: number}] */ -router.get('/action_counts', requireAuth, function(req, res, next){ - db.actionCounts(req.query.daysback) +router.get("/action_counts", requireAuth, function(req, res, next) { + db.actionCounts(req.query.daysback) .then(data => res.send(data)) - .catch(err => next(err)) -}) + .catch(err => next(err)); +}); /** * Histogram of notifications sent by type * @param {Number} daysback [default 7] * @returns [{type:notification type, count: number}] */ -router.get('/notification_counts', requireAuth, function(req, res, next){ - db.notificationCounts(req.query.daysback) +router.get("/notification_counts", requireAuth, function(req, res, next) { + db.notificationCounts(req.query.daysback) .then(data => res.send(data)) - .catch(err => next(err)) -}) + .catch(err => next(err)); +}); /** * Notifications with errors * @param {Number} daysback [default 7] * @returns [{type:notification type, count: number}] */ -router.get('/notification_errors', requireAuth, function(req, res, next){ - db.notificationErrors(req.query.daysback) +router.get("/notification_errors", requireAuth, function(req, res, next) { + db.notificationErrors(req.query.daysback) .then(data => res.send(data)) - .catch(err => next(err)) -}) + .catch(err => next(err)); +}); /** * Histogram of action counts grouped by type and then by date * @param {Number} daysback [default 30] * @returns [{day: timestamp, actions:[{type: action number, count:number}]}] */ -router.get('/actions_by_day', requireAuth, function(req, res, next){ - db.actionsByDay(req.query.daysback) +router.get("/actions_by_day", requireAuth, function(req, res, next) { + db.actionsByDay(req.query.daysback) .then(data => res.send(data)) - .catch(err => next(err)) -}) + .catch(err => next(err)); +}); /** * Dates of the last run of each Runner script * @returns [{runner: runner name, date: timestamp}] */ -router.get('/runner_last_run', requireAuth, function(req, res, next){ - db.notificationRunnerLog() +router.get("/runner_last_run", requireAuth, function(req, res, next) { + db.notificationRunnerLog() .then(data => res.send(data)) - .catch(err => next(err)) -}) + .catch(err => next(err)); +}); /** * The current number of distinct cases for which there are requests @@ -183,63 +193,63 @@ router.get('/runner_last_run', requireAuth, function(req, res, next){ * @returns[{phone_count: number, case_count: number}] */ /* returns a simple object with counts: { scheduled: '3', sent: '10', all: '3' } */ -router.get('/request_counts', requireAuth, function(req, res, next){ - db.requestCounts() +router.get("/request_counts", requireAuth, function(req, res, next) { + db.requestCounts() .then(data => res.send(data)) - .catch(err => next(err)) -}) + .catch(err => next(err)); +}); /** * All notifications sent within daysback days grouped by type * @param {Number} daysback * @returns [{type:notification type, notices:[{phone:encrypted phone, case_id: id, created_at: timestamp when sent, event_date: hearing date}]}] */ -router.get('/notifications', requireAuth, function(req, res, next){ - db.recentNotifications(req.query.daysback) +router.get("/notifications", requireAuth, function(req, res, next) { + db.recentNotifications(req.query.daysback) .then(data => res.send(data)) - .catch(err => next(err)) -}) + .catch(err => next(err)); +}); /** * All notifications sent within daysback days grouped by type * @param {Number} daysback * @returns [{type:notification type, notices:[{phone:encrypted phone, case_id: id, created_at: timestamp when sent, event_date: hearing date}]}] */ -router.get('/notifications_by_day', requireAuth, function(req, res, next){ - db.recentNotificationsByDay(req.query.daysback) +router.get("/notifications_by_day", requireAuth, function(req, res, next) { + db.recentNotificationsByDay(req.query.daysback) .then(data => res.send(data)) - .catch(err => next(err)) -}) + .catch(err => next(err)); +}); /** * The number of hearings in the DB and date of last load runner * @returns [{id: log id, runner: load, count: number, error_count: number, date: runner timestamp }] */ /* returns a simple object with counts: { count: '3' } */ -router.get('/hearing_counts', requireAuth, function(req, res, next){ - db.hearingCount() +router.get("/hearing_counts", requireAuth, function(req, res, next) { + db.hearingCount() .then(data => res.send(data)) - .catch(err => next(err)) -}) + .catch(err => next(err)); +}); /** * User input that we couldn't understand * @returns [{body:phrase, count: number }] */ -router.get('/unusable_input', requireAuth, function(req, res, next){ - db.unusableInput(req.query.daysback) +router.get("/unusable_input", requireAuth, function(req, res, next) { + db.unusableInput(req.query.daysback) .then(data => res.send(data)) - .catch(err => next(err)) -}) + .catch(err => next(err)); +}); /** * Notifications that recieved errors when sending * @returns [{body:phrase, count: number }] */ -router.get('/notification_errors', requireAuth, function(req, res, next){ - db.notificationErrors(req.query.daysback) +router.get("/notification_errors", requireAuth, function(req, res, next) { + db.notificationErrors(req.query.daysback) .then(data => res.send(data)) - .catch(err => next(err)) -}) + .catch(err => next(err)); +}); -module.exports = router; \ No newline at end of file +module.exports = router;