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;