diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..b91d5d5 --- /dev/null +++ b/.env.sample @@ -0,0 +1,16 @@ +COOKIE_SECRET=insert-random-string +TWILIO_ACCOUNT_SID=add-twilio-sid-here +TWILIO_AUTH_TOKEN=add-auth-token-here +TWILIO_PHONE_NUMBER=+15555555555 +PHONE_ENCRYPTION_KEY=insert-random-string +DATABASE_URL=postgres://courtbot:courtbot@localhost:5432/courtbotdb +DATABASE_TEST_URL=postgres://courtbot:courtbot@localhost:5432/courtbotdb_test +DATA_URL=http://courtrecords.alaska.gov/MAJIC/sandbox/acs_mo_event.csv +QUEUE_TTL_DAYS=10 +COURT_PUBLIC_URL=http://courts.alaska.gov +COURT_NAME=Alaska State Court System +TZ=America/Anchorage +ADMIN_LOGIN=some_login +ADMIN_PASSWORD=some_password +JWT_SECRET=hash_secret_for_web_token +TEST_CASE_NUMBER=TESTCASE \ No newline at end of file diff --git a/.gitignore b/.gitignore index 6c13171..319c7a2 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ utils/tmp/* !utils/tmp/.gitkeep TODO .idea +.vscode/ diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..1db0f91 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,14 @@ +language: node_js +node_js: + - "8.6.0" +services: + - postgresql +env: +- DATABASE_TEST_URL=postgres://localhost:5432/courtbotdb_test +before_script: + - psql -c 'create database courtbotdb_test;' -U postgres + - cp .env.sample .env + - npm run dbsetup + - export TZ=America/Anchorage +addons: + postgresql: "9.6" \ No newline at end of file diff --git a/README.md b/README.md index d605367..ad25a1b 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,79 @@ +[![Build Status](https://travis-ci.org/codeforanchorage/courtbot.svg?branch=master)](https://travis-ci.org/codeforanchorage/courtbot) ## Courtbot - Courtbot is a simple web service for handling court case data. It offers a basic HTTP endpoint for integration with websites, and a set of advanced twilio workflows to handle text-based lookup. Specifically, the twilio features include: -- **Payable Prompt.** If a case can be paid immediately, the app offers a phone number and link to begin payment. -- **Reminders.** If a case requires a court appearance, the app allows users to sign up for reminders, served 24 hours in advance of the case. -- **Queued Cases.** If a case isn't in the system (usually because it takes two weeks for paper citations to be put into the computer), the app allows users to get information when it becomes available. The app continues checking each day for up to 16 days and sends the case information when found (or an apology if not). +- **Requests.** If a case requires a court appearance, the app allows users to sign up for reminders, served 24 hours in advance of the case. +- **Unmatched Cases.** If a case isn't in the system (usually because it takes two weeks for paper citations to be put into the computer), the app allows users to get information when it becomes available. The app continues checking each day for a number of days (set by config QUEUE_TTL_DAYS) and sends the case information when found (or an apology if not). + +## Datamodel +The main features of the app use three tables in a PostgreSQL database: +1. hearings | This table has the data about upcoming cases. It is recreated each time *runners/load.js* is exectued from the csv files found at urls set in config variable *DATA_URL*. It is ephemeral — it is recreated from scratch every day so the app must be prepared for cases that are there one day and not there the next. It is possible for the CSV to have duplicate rows. The load script enforces unique *case_ids*. +2. requests | This table stores the requests for notifications. Each row requires a phone number, which is encrypted using config *PHONE_ENCRYPTION_KEY*, and a *case_id*. The table also has columns *known_case* and *active*. *known_case* allows the app to distinguish between cases that we have seen *at some point* in the hearings table. Requests for cases where *known_case* is false will expire after *QUEUE_TTL_DAYS* and *active* will be set to false. If the case appears at anytime before that *known_case* will be set to true and the request will not expire unless a user manually turns it off by texting DELETE after sending the case. The requests table uses the column *updated_at* to determine if an unmatched case has expired rather than *created_at*. These will generally be the same, but it allows for the future possibility of allowing unmatched cases to be extended. +3. notifications | Rows in the notifications table are added whenever the app sends the user a notification. These can include notifications the day before a hearing or notifications that an unmatched case was not found within QUEUE_TTL_DAYS. The table has columns for *case_id* and *phone_number* which link the case to the person recieving the notification. It also has the following columns: + * *created_at* timestamp, which should correspond to the time the notification is sent + * *event_date* the date of the hearing at the time the notification was sent. This may or may not be the date in current versions of the csv as this changes frequently. + * *type* enumeration to distinguish between hearing notifications [reminder], matched [matched] cases, and expired cases that were not found within QUEUE_TTL_DAYS [expired] + * *error* an error string if sending a notification failed (perhaps due to a twilio error or bad phone number). + +See *sendReminders.js* and *sendUnmatched.js* for examples of SQL using these tables. + +The database also has tables *log_hits* and *log_runners*. These log activity of the app. ## Running Locally -First, install [node](https://github.com/codeforamerica/howto/blob/master/Node.js.md), [postgres](https://github.com/codeforamerica/howto/blob/master/PostgreSQL.md), and [foreman](https://github.com/ddollar/foreman). +First, install [node](https://github.com/codeforamerica/howto/blob/master/Node.js.md) (atleast version 7.6), and [postgres](https://github.com/codeforamerica/howto/blob/master/PostgreSQL.md) (at least version 9.5). + +Then clone the repository into a folder called courtbot: + +```console +git clone git@github.com:codeforanchorage/courtbot.git courtbot +cd courtbot +``` + +Since the app uses twilio to send text messages, it requires a bit of configuration. Get a [twilio account](http://www.twilio.com/), create a .env file by running `cp .env.sample .env`, and add your twilio authentication information. While you're there, add a cookie secret and an encryption key (long random strings). + +Install node dependencies + +```console +npm install +``` + +Define a new PostgreSQL user account, give it a password. You might have to create a postgres account for yourself first with superuser permissions if you don't have one already, or use sudo -u postgres before these commands. + +``` +createuser courtbot --pwprompt +``` + +Create a new PostgreSQL database and a database to run tests. + +``` +createdb courtbotdb -O courtbot +createdb courtbotdb_test -O courtbot +``` + +Set up your environment variables. This may require some customization-- especially the DATABASE_TEST_URL. + +``` +cp .env.sample .env +``` Then, to create the tables and load in initial data: ```console -node utils/createQueuedTable.js -node utils/createRemindersTable.js +node utils/createTables.js node runners/load.js ``` -Since the app uses twilio to send text messages, it requires a bit of configuration. Get a [twilio account](http://www.twilio.com/), create a .env file by running `mv .env.sample .env`, and add your twilio authentication information. While you're there, add a cookie secret and an encryption key (long random strings). - To start the web service: ```console -foreman start +npm start ``` +Now you can interact with a mock of the service at http://localhost:5000. + ## Deploying to Heroku First, get a twilio account and auth token as described above. Then: @@ -36,23 +82,52 @@ First, get a twilio account and auth token as described above. Then: heroku create heroku addons:add heroku-postgresql heroku addons:add scheduler +heroku addons:create rollbar:free (only add if you do NOT have another rollbar account you want to use) heroku config:set COOKIE_SECRET= -heroku config:set TWILIO_ACCOUNT= +heroku config:set ROLLBAR_ACCESS_TOKEN = (only needed if you did NOT use the heroku addon for rollbar) +heroku config:set ROLLBAR_ENDPOINT = (only needed if you did NOT use the heroku addon for rollbar) +heroku config:set TWILIO_ACCOUNT_SID= heroku config:set TWILIO_AUTH_TOKEN= heroku config:set TWILIO_PHONE_NUMBER= heroku config:set PHONE_ENCRYPTION_KEY= +heroku config:set DATA_URL= +heroku config:set COURT_PUBLIC_URL= +heroku config:set COURT_NAME= +heroku config:set QUEUE_TTL_DAYS=<# days to keep a citation on the search queue> +heroku config:set TZ= +heroku config:set TEST_TOMORROW_DATES=<1 if you want all court dates to be tomorrow to test reminders> +heroku config:set ADMIN_LOGIN= +heroku config:set ADMIN_PASSWORD= +heroku config:set JWT_SECRET= +heroku config:set TESTCASE= git push heroku master -heroku run node utils/createQueuedTable.js -heroku run node utils/createRemindersTable.js +heroku run node utils/createRequestsTable.js +heroku run node utils/createNotificationsTable.js heroku run node runners/load.js heroku open ``` -Finally, you'll want to setup scheduler to run the various tasks each day. Here's the recommended config: +The dotenv module will try to load the *.env* file to get the environment variables as an alternative to the above "heroku config" commands. +If you don't have this file, dotenv will throw an ENOENT error, but things will still work. To get rid of this error, do this: +``` +heroku run bash --app +touch .env +exit +``` + +### Setting up the Scheduler + +Finally, you'll want to set up the [scheduler](https://elements.heroku.com/addons/scheduler) addon to run the various tasks each day. Here's the recommended configuration: -![scheduler settings](https://cloud.githubusercontent.com/assets/1435836/4785655/2893dd9a-5d83-11e4-9618-d743bee27d2f.png) +| Task | Dyno Size | Frequency | At | +| --- | :---: | :--: | :--: | +| `node runners/load.js` | 1X | Daily | 8am | +| `node runners/sendReminders.js` | 1X | Daily | 5pm | +| `node runners/sendUnmatched.js` | 1X | Daily |5:30pm | -## Scheduler Changes -* node runners/load.js -* node runners/sendQueued.js -* node runners/sendReminders.js + +## Running Tests + +``` +npm test +``` diff --git a/db.js b/db.js index c89b7ef..e0ae36b 100644 --- a/db.js +++ b/db.js @@ -1,70 +1,149 @@ -var crypto = require('crypto'); -var Knex = require('knex'); -var knex = Knex.initialize({ - client: 'pg', - connection: process.env.DATABASE_URL -}); +require('dotenv').config(); +const crypto = require('crypto'); +const manager = require('./utils/db/manager'); +const knex = manager.knex; +const log = require('./utils/logger') -exports.findCitation = function(citation, callback) { - // Postgres JSON search based on prebuilt index - citation = escapeSQL(citation.toUpperCase()); - var citationSearch = knex.raw("'{\"" + citation + "\"}'::text[] <@ (json_val_arr(citations, 'id'))"); - knex('cases').where(citationSearch).select().exec(callback); -}; +/** + * encrypts the phone number + * + * param {string} phone number to encrypt + * returns {string} encrypted phone number + */ +function encryptPhone(phone) { + // Be careful when refactoring this function, the decipher object needs to be created + // each time a reminder is sent because the decipher.final() method destroys the object + // Reference: https://nodejs.org/api/crypto.html#crypto_decipher_final_output_encoding + const cipher = crypto.createCipher('aes256', process.env.PHONE_ENCRYPTION_KEY); + return cipher.update(phone, 'utf8', 'hex') + cipher.final('hex'); +} -exports.fuzzySearch = function(str, callback) { - var parts = str.toUpperCase().split(" "); +/** + * decrypts the phone number + * + * param {string} phone number to decrypt + * returns {string} decrypted phone number + */ +function decryptPhone(phone) { + // Be careful when refactoring this function, the decipher object needs to be created + // each time a reminder is sent because the decipher.final() method destroys the object + // Reference: https://nodejs.org/api/crypto.html#crypto_decipher_final_output_encoding + const decipher = crypto.createDecipher('aes256', process.env.PHONE_ENCRYPTION_KEY); + return decipher.update(phone, 'hex', 'utf8') + decipher.final('utf8'); +} - // Search for Names - var query = knex('cases').where('defendant', 'like', '%' + parts[0] + '%'); - if (parts.length > 1) query = query.andWhere('defendant', 'like', '%' + parts[1] + '%'); +/** + * Given a case id return the hearing(s) + * @param {string} case_id + * return + */ +function findCitation(case_id) { + return knex('hearings').where('case_id', case_id ) + .select('*', knex.raw(` + CURRENT_DATE = date_trunc('day', date) as today, + date < CURRENT_TIMESTAMP as has_past + `)) +} - // Search for Citations - var citation = escapeSQL(parts[0]); - var citationSearch = knex.raw("'{\"" + citation + "\"}'::text[] <@ (json_val_arr(citations, 'id'))"); - query = query.orWhere(citationSearch); +/** + * + * @param {*} case_id + * @param {*} phone + */ +function findRequest(case_id, phone) { + return knex('requests').where('case_id', case_id ) + .andWhere('phone', encryptPhone(phone) ) + .andWhere('active', true) + .select('*') +} - // Limit to ten results - query = query.limit(10); - query.exec(callback); -}; +/** + * Find request's case_ids based on phone + * @param {string} phone + * @returns {Promise} resolves to an array of case_ids + */ +function requestsFor(phone) { + return knex('requests') + .where('phone', encryptPhone(phone)) + .select('case_id') +} -exports.addReminder = function(data, callback) { - var cipher = crypto.createCipher('aes256', process.env.PHONE_ENCRYPTION_KEY); - var encryptedPhone = cipher.update(data.phone, 'utf8', 'hex') + cipher.final('hex'); +/** + * Deletes requests associated with phone number + * @param {string} phone + * @returns {Promise} resolves deleted case ids + */ +function deactivateRequestsFor(phone){ + return knex('requests') + .where('phone', encryptPhone(phone)) + .update('active', false) + .returning('case_id') +} - knex('reminders').insert({ - case_id: data.caseId, - sent: false, - phone: encryptedPhone, - created_at: new Date(), - original_case: data.originalCase, - }).exec(callback); -}; +/** + * Adds the given request. Requests have a unique constraint on (case_id, phone) + * adding a duplicate will renew the updated_at date, which in the case of unmatched + * requests will start the clock on them again + * @param {*} data + * @returns {Promise} no resolve value + */ +function addRequest(data) { + data.phone = encryptPhone(data.phone) + return knex.raw(` + INSERT INTO requests + (case_id, phone, known_case) + VALUES(:case_id ,:phone, :known_case) + ON CONFLICT (case_id, phone) DO UPDATE SET updated_at = NOW(), active = true`, + { + case_id: data.case_id, + phone: data.phone, + known_case: data.known_case + } + ) +} -exports.addQueued = function(data, callback) { - var cipher = crypto.createCipher('aes256', process.env.PHONE_ENCRYPTION_KEY); - var encryptedPhone = cipher.update(data.phone, 'utf8', 'hex') + cipher.final('hex'); +/** + * Marks requests associated with the case_id and phone number as inactive + * @param {string} case_id + * @param {string} unencrypted phone number + */ +function deactivateRequest(case_id, phone) { + const enc_phone = encryptPhone(phone) + return knex('requests') + .where('phone', enc_phone) + .andWhere('case_id', case_id) + .update('active', false) +} - knex('queued').insert({ - citation_id: data.citationId, - sent: false, - phone: encryptedPhone, - created_at: new Date(), - }).exec(callback); -}; +/** + * Find hearings based on case_id or partial name search + * @param {string} str + * @returns {Promise} array of rows from hearings table + */ +function fuzzySearch(str) { + const parts = str.trim().toUpperCase().split(' '); + + // Search for Names + let query = knex('hearings').where('defendant', 'ilike', `%${parts[0]}%`); + if (parts.length > 1) query = query.andWhere('defendant', 'ilike', `%${parts[1]}%`); -var escapeSQL = function(val) { - val.replace(/[\0\n\r\b\t\\\'\"\x1a]/g, function(s) { - switch(s) { - case "\0": return "\\0"; - case "\n": return "\\n"; - case "\r": return "\\r"; - case "\b": return "\\b"; - case "\t": return "\\t"; - case "\x1a": return "\\Z"; - default: return "\\"+s; - } - }); - return val; -}; \ No newline at end of file + // Search for Citations + query = query.orWhere('case_id',parts[0]); + + // Limit to ten results + query = query.limit(10); + return query; +} + + +module.exports = { + addRequest, + decryptPhone, + encryptPhone, + findCitation, + fuzzySearch, + deactivateRequestsFor, + deactivateRequest, + requestsFor, + findRequest, +}; diff --git a/docs/Art/smartphone.ai b/docs/Art/smartphone.ai new file mode 100644 index 0000000..042badd --- /dev/null +++ b/docs/Art/smartphone.ai @@ -0,0 +1,458 @@ +%PDF-1.5 % +1 0 obj <>/OCGs[6 0 R 26 0 R 47 0 R 48 0 R 49 0 R 74 0 R 75 0 R 76 0 R 101 0 R 102 0 R 103 0 R 128 0 R 129 0 R 130 0 R 155 0 R 156 0 R 157 0 R]>>/Pages 3 0 R/Type/Catalog>> endobj 2 0 obj <>stream + + + + + Adobe Illustrator CC 22.1 (Macintosh) + 2018-08-07T10:45:22-08:00 + 2018-08-07T15:41:17-08:00 + 2018-08-07T15:41:17-08:00 + + + + 216 + 256 + JPEG + /9j/4AAQSkZJRgABAgEASABIAAD/7QAsUGhvdG9zaG9wIDMuMAA4QklNA+0AAAAAABAASAAAAAEA AQBIAAAAAQAB/+4ADkFkb2JlAGTAAAAAAf/bAIQABgQEBAUEBgUFBgkGBQYJCwgGBggLDAoKCwoK DBAMDAwMDAwQDA4PEA8ODBMTFBQTExwbGxscHx8fHx8fHx8fHwEHBwcNDA0YEBAYGhURFRofHx8f Hx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8f/8AAEQgBAADYAwER AAIRAQMRAf/EAaIAAAAHAQEBAQEAAAAAAAAAAAQFAwIGAQAHCAkKCwEAAgIDAQEBAQEAAAAAAAAA AQACAwQFBgcICQoLEAACAQMDAgQCBgcDBAIGAnMBAgMRBAAFIRIxQVEGE2EicYEUMpGhBxWxQiPB UtHhMxZi8CRygvElQzRTkqKyY3PCNUQnk6OzNhdUZHTD0uIIJoMJChgZhJRFRqS0VtNVKBry4/PE 1OT0ZXWFlaW1xdXl9WZ2hpamtsbW5vY3R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo+Ck5SVlpeYmZ qbnJ2en5KjpKWmp6ipqqusra6voRAAICAQIDBQUEBQYECAMDbQEAAhEDBCESMUEFURNhIgZxgZEy obHwFMHR4SNCFVJicvEzJDRDghaSUyWiY7LCB3PSNeJEgxdUkwgJChgZJjZFGidkdFU38qOzwygp 0+PzhJSktMTU5PRldYWVpbXF1eX1RlZmdoaWprbG1ub2R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo +DlJWWl5iZmpucnZ6fkqOkpaanqKmqq6ytrq+v/aAAwDAQACEQMRAD8A7l+Yn5iJ5cQWNiFm1aZe XxbpCh6Mw7sf2V+k+6rxTVdf1rVpTLqN7LcsTXi7HiO/wp9lR8hiqAxV2KuxV2KuxV2KuxV2KuxV 2KuxV2Ko7S9d1jSpll068ltnXeiMQp70ZfssPYjFXtX5dfmKnmJTYX6rFq0S8gV2SZR1ZR2Ydx9I 9lWc4q7FXYq7FXYq7FXYq7FXYq7FXYq+Xde1SXVdZvdRlJLXMrOta7KT8CivZVoBiqAxV2KuxV2K uxVXtbiGEsZLWK55UoJTKOPy9N4/xxVEfpKy/wCrVa/8Fdf9V8Vd+krL/q1Wv/BXX/VfFVC6uYZ+ PpWkVrxrX0jKeVadfVeTp7Yqh8VdirsVdirsVR2iapPpWr2mowEiS1lWTbuoPxKfZlqDir6jxV2K uxV2KuxV2KuxV2KuxV2KuxV8y+XPK2r+YZ5oNMRHkgQPIHYIOJNO+Kp//wAqf87f74h/5HLirv8A lT/nb/fEP/I5cVd/yp/zt/viH/kcuKu/5U/52/3xD/yOXFXf8qf87f74h/5HLirv+VP+dv8AfEP/ ACOXFXf8qf8AO3++If8AkcuKu/5U/wCdv98Q/wDI5cVd/wAqf87f74h/5HLirv8AlT/nb/fEP/I5 cVd/yp/zt/viH/kcuKu/5U/52/3xD/yOXFXf8qf87f74h/5HLirv+VP+dv8AfEP/ACOXFUg8x+Vt X8vTwwamiJJOheMIwccQadsVfTWKuxV2KuxV2KuxV2KuxV2KuxV2KvGvyM/47Opf8w6/8TGKvSr+ /u47uREkKqpFBQeGSAayTah+k77/AH6fuH9MaRxF36Tvv9+n7h/TGl4i79J33+/T9w/pjS8Rd+k7 7/fp+4f0xpeIu/Sd9/v0/cP6Y0vEXfpO+/36fuH9MaXiLv0nff79P3D+mNLxF36Tvv8Afp+4f0xp eIu/Sd9/v0/cP6Y0vEXfpO+/36fuH9MaXiLv0nff79P3D+mNLxF36Tvv9+n7h/TGl4i79J33+/T9 w/pjS8Reefnn/wAdnTf+Ydv+JnItr2XFXYq7FXYq7FXYq7FXYq7FXYq7FXjX5Gf8dnUv+Ydf+JjF Xoep/wC90vzH6hkg1S5oXCh2KuxV2KuxV2KuxV2KuxV2KuxV2KuxVg355/8AHZ03/mHb/iZyDc9l xV2KuxV2KuxV2KuxV2KuxV2KuxV41+Rn/HZ1L/mHX/iYxV6Hqf8AvdL8x+oZINUuaFwodirsVdir sVdirsVdirsVdirsVdirsVYN+ef/AB2dN/5h2/4mcg3PZcVdirsVdirsVdirsVdirsVdirsVeNfk Z/x2dS/5h1/4mMVek38tsLyQPByYEVbmRXYdskGs80P61p/yzf8ADtiiw71rT/lm/wCHbFbDvWtP +Wb/AIdsVsO9a0/5Zv8Ah2xWw71rT/lm/wCHbFbDvWtP+Wb/AIdsVsO9a0/5Zv8Ah2xWw71rT/lm /wCHbFbDvWtP+Wb/AIdsVsO9a0/5Zv8Ah2xWw71rT/lm/wCHbFbDvWtP+Wb/AIdsVsO9a0/5Zv8A h2xWw8+/PP8A47Om/wDMO3/EzkW17LirsVdirsVdirsVdirsVdirsVdirxr8jP8Ajs6l/wAw6/8A Exir0PU/97pfmP1DJBqlzQuFDsVdirsVdirsVdirsVdirsVdirsVdirBvzz/AOOzpv8AzDt/xM5B uey4q7FXYq7FXYq7FXYq7FXYq7FXYq8a/Iz/AI7Opf8AMOv/ABMYq9D1P/e6X5j9QyQapc0LhQ7F XYq7FXYq7FXYq7FXYq7FXYq7FXYqwb88/wDjs6b/AMw7f8TOQbnsuKuxV2KuxV2KuxV2KuxV2Kux V2KvGvyM/wCOzqX/ADDr/wATGKvSb+O1N5IXmKtUVUJWmw71yQazVof0rL/lob/kX/zdiig70rL/ AJaG/wCRf/N2K0HelZf8tDf8i/8Am7FaDvSsv+Whv+Rf/N2K0HelZf8ALQ3/ACL/AObsVoO9Ky/5 aG/5F/8AN2K0HelZf8tDf8i/+bsVoO9Ky/5aG/5F/wDN2K0HelZf8tDf8i/+bsVoO9Ky/wCWhv8A kX/zditB3pWX/LQ3/Iv/AJuxWg70rL/lob/kX/zditB3pWX/AC0N/wAi/wDm7FaDz788/wDjs6b/ AMw7f8TORbXsuKuxV2KuxV2KuxV2KuxV2KuxV2KvGvyM/wCOzqX/ADDr/wATGKvQ9T/3ul+Y/UMk GqXNC4UOxV2KuxV2KuxV2KuxV2KuxV2KuxV2KsG/PP8A47Om/wDMO3/EzkG57LirsVdirsVdirsV dirsVdirsVdirxr8jP8Ajs6l/wAw6/8AExir0PU/97pfmP1DJBqlzQuFDsVdirsVdirsVdirsVdi rsVdirsVdirBvzz/AOOzpv8AzDt/xM5Buey4q7FXYq7FXYq7FXYq7FXYq7FXYq8a/Iz/AI7Opf8A MOv/ABMYq9Jv7SV7yRgUAJHV1B6DsTkg1kbof6jN/NH/AMGv9cbRTvqM380f/Br/AFxtad9Rm/mj /wCDX+uNrTvqM380f/Br/XG1p31Gb+aP/g1/rja076jN/NH/AMGv9cbWnfUZv5o/+DX+uNrTvqM3 80f/AAa/1xtad9Rm/mj/AODX+uNrTvqM380f/Br/AFxtad9Rm/mj/wCDX+uNrTvqM380f/Br/XG1 p31Gb+aP/g1/rja08+/PP/js6b/zDt/xM5Ftey4q7FXYq7FXYq7FXYq7FXYq7FXYq8a/Iz/js6l/ zDr/AMTGKvQ9T/3ul+Y/UMkGqXNC4UOxV2KuxV2KuxV2KuxV2KuxV2KuxV2KsG/PP/js6b/zDt/x M5Buey4q7FXYq7FXYq7FXYq7FXYq7FXYq8a/Iz/js6l/zDr/AMTGKvQ9T/3ul+Y/UMkGqXNC4UOx V2KuxV2KuxV2KuxVJT5ptBb3116bfVLNhGk1R+9k6FUHsab5g/no1KVemO3vLX4g3Pcm1tLJLbxy yRmF3UM0RNSpIrQn2zLhImIJFMw83843d1H5ju0SZ0UenRVYgf3S9gc5vtCchmlR7vuDiZSeIpfc R6/bQR3Fwl3Dby/3U0gkVGqK/CxoDtmLLxALPFXxYniHO0N9evf+WiX/AINv65DxZd5Y8RWyy3N0 wMrvOygkciXIA3PWu2EZJ95TxFU/Sepf8tc3/Ixv64PEl3leM97v0nqX/LXN/wAjG/rj4ku8rxnv RQHmU2f14C9Nlv8A6V+99LY8T8f2eu3XJ/vOHi9XD37pudXuhf0nqX/LXN/yMb+uQ8SXeUcZ718N 7rE8qxQz3EsrmiRo7szE9gAanCJzJoEqJSPes/Sepf8ALXN/yMb+uDxJd5XjPeva91hYkmae4EUh ZY5C7hWK05AGtCRyFcPHOrsrxS7ys/Sepf8ALXN/yMb+uDxJd5XjPeyT8ur6+l85afHLcSuh9aqM 7EGkDnoTmZ2fORzRs9/3FtwSJmN3o/m7zrbeW5LVJbaS4a55GqkKFVaA7nqd+mbnVawYasXbl5Mo gh9D/MXTNX106VDbyx8+X1ed6fGUUsar1XYEjIYe0I5MnAAiGcSlTBvyM/47Opf8w6/8TGZ7c9F1 KOQ3spCkio3APgMkGqXNDelL/I33HCinelL/ACN9xxWnelL/ACN9xxWnelL/ACN9xxWnelL/ACN9 xxWnelL/ACN9xxWnelL/ACN9xxWmmglZSOLCopUA1GArTBR5D8yC1ltvrI9FHElvFV+JYV+Lp8Bo e2aL+TcvCY8W3T8dHG8GVUy7R7HU7fTYYb52nukB9SXc13JG53NBtXNvp4SjACZuTfEEDdgXmGWC 288ma7TlbxTWzzxkdUVIywp7jNBrSBqSTyuP3Bxpmp7q0Vsg1W+vLu8s7x7hZW06WaeGSNpywKNL GzHh8HKnqqBypXICPrJJib5bjn5/tTW5JITaGDy3O+jmRtOW5t7kPrdHgSJgyKBwFQjp8O4Sqhq9 jmQI4zw3w2D6uVft+DYBHbl5oLS9UsLb6jbW31ON5tJuFlmdIKm6f1QiTSSCg+yuzGlDvscqxZIi gOHeB7ue/O2MZAUBXJUX/DscGmtHHbXch9NipazgCv6D/WBK7yMW/e0ZBJGE2AFQckPDAjyP+lHT e9+/lYpfTty+xT1FdGj0y/u+MM8tq7WtpNHDFHHM10itUiMemzW4EnxLtXjTtkcggIk7GthsN792 3p3+xZcNE/j8BbZajpa6BaW/rBL/APRt7EHaRfTUPPIxjeOgPN468Pi602xhkj4YF+rgl95299ck RkOGutFFXjeVre9D24tZkitr17VmS39JlEINsjoskjNIHrvKqsxPTbJz8IS2rlKuXdt15++iyPCD 06r7a48tS6qvqixjgt7nT5IWRYowwmhZrnkRTkqyUqDsvTbDGWMy34aBj3dRv9vySDG+nRDQTeV5 RHb3i2q2sNrpsjPGsaymUtEtyC6Dmx4M3Na+/XIA4jtKqAh+i/2sQY9a6Irnpk1rZw6hcWCXkIvm t0thayQK7vB6ZZOccG8Ybj6jDpv8QpllxIAkY2OLlw10+HLvZbULrr3ILVpNBMcNvY29mfWmumuR K0MbKUgjK0lieTghk5FFV6H7PiMqynHQERHme7uHUefLfyYyMeQpBflt/wAprp3/AD2/5MSZX2d/ fR+P3FrwfWHqnnDQr3W7KDT4Gjjt5Jla8lfd1iU8v3ex+IkZvNXglliIjle/7HOywMhSjYeWJU80 yavOIkt7aFbbTIYuoTjRnkNB8VKjIw0xGXjNUBQQIeq3n35Gf8dnUv8AmHX/AImMzW16PqFzcJeS qsrqoIoAxA6DJBrkd0P9cu/9/wAn/BH+uLGy765d/wC/5P8Agj/XFbLvrl3/AL/k/wCCP9cVsu+u Xf8Av+T/AII/1xWy765d/wC/5P8Agj/XFbLvrl3/AL/k/wCCP9cVsu+uXf8Av+T/AII/1xWy765d /wC/5P8Agj/XFbLvrl3/AL/k/wCCP9cVsu+uXf8Av+T/AII/1xWy8p87u7+Z71nYsx9KrE1P90mc v2j/AH8vh9wcLN9Ram8p6nWL6qouElggmDEpGS88Ql9JA7VkcDstSfDKjpZdN9h9ouvNTiPRZc+W L+PT7S+hpPDcQrM4BQNGXlaIDhy5kVC/FSlTTBLTSERIbgj9NKcRoFRvPL+qWNxDDexiIzSGEMrp IA6kBlJjZqFeQqDkZ4JRIEkHGQd1XVfLGo6fqJtG4OjTSwwXBkiRH9E7liXKxmlCVc1Fcll00oSr zPd0+5MsRBpSvNI1q3tXN0pS3s39Pi8qEBnCsfSXl8dQ6sSlRQg5GeKYG/Ief3fsQYSA36KLaRfC wN8FRrZeJcpLG7oGNFLxqxdQTt8S5HwpcPF0+COA1aqnl7VnW3KwryuqGCIyRiQqylg5jLc1TipP MjjTvkhgma25/j8FPhlc/lrWlaJTbgieWKCB1kjZHefl6YR1YqwbgfiBptucTp593MgfPkvhyRGn eUNXvDCSIreKdZmiklliXl6AfmApbls0ZB226nbJ49LOVchd9R0THESk0iFHZGoSpKkqQwqNtmUk H5jMcimtbgVk35bf8prp3/Pb/kxJmb2d/fR+P3FtwfWHuWdO7J2KvGvyM/47Opf8w6/8TGKvQ9T/ AN7pfmP1DJBqlzQuFDsVdirsVdirsVdirsVdirsVeY+bjCPNdyZlZ4gYvURGCMV9JKgMQ9D70Ocv 2hXjyvy+4OFl+pEDzmnqQF7HlHYSRy6Ynq09NoYkiX1SE/ej90pNOO/ttkfzfLb6eW/cK37+Xky8 by5ckPF5rkQITbhnWxFiW50rS5+sepTj/safT7ZAany/hr7bQMv3Up3/AJkN3ypb8OWoz6l9vl/f cP3fQfZ4de/hgyaji6fxGXzQcl/O0UvnBPrLyvZBlkvbu+p6g5KbuMIAjFCA0dOQbid+2T/N73X8 Uj8/1Mhl8utrdV82pfxX6tavzvfSp6swlSMwxxp6gUxq3qH0j8XIbGhBwZdVxg7c6691eXPZEst3 tzWzea0fQZNKS1eMSwQwPxl/choZFf1RCEHxvx+Ilj1+jE6n93wV0A57bdaU5fTS1fMtv9Yhu3s2 a8EAtLtvWpHLB6BtzROHJHKU+INSo6YPzAsGt6o78xVL4m90ibXzjaQC1g/R7NZWEkE1nF69HElu 8j8pJPTo/IzNUBR2ycdWBQ4do1W/dfl5pGUd3JDQ+aeF1YTNa8lso7mJ1D0LrdNKWoeJ48RNQbHp kBqaMTXK/tv9aBl3G3K/tSJypYlAQlTxBNSB2qaCv3ZilqaxVk35bf8AKa6d/wA9v+TEmZvZ399H 4/cW3B9Ye5Z07snYq8a/Iz/js6l/zDr/AMTGKvQ9T/3ul+Y/UMkGqXNC4UOxV2KuxV2KuxV2KuxV 2KuxV5p5nRX85SowqrSQBge4KJnMa8f4Qfh9wcPJ9acXvlPSry+1G7tg8Nva3N7HcWzSwwqWgePi IpGVY4krNT4q0Ayc9LCUpEbAGW1gcq5dBzbDiBJI80H/AIR08w6i6zl0hjmlsZ0mR1f0IFneMrGj hivPizc1FelemV/lY1LfvrfuF/jdj4Q3Rx8qaH9YvbFDcCC1vDC8jGEyn0rS4mJV/T2BMajj9PhS 38rCzHehLyv6ZHu8mfhR5ef6Esk8u6SlqNRpcyWkkFvKlojoZg1xJJFvJ6fEqphP7ArUDbKDp4Ac W9UNuu5I7vLuYeGKvfosuvL2m2d9rXq+vPa6XMkMcMbIsr+qWAZnKOoC8d/g6kYJYIxlO7Iifx+K QcYBPkjrnybpMJjt/VuGuroXTWzkoqRi3tkuAsqcSS3x8Gow3y2WkgNt7N/YL3+5mcIGyj5d0S11 Ty3Lbemi6hcXZFpckDkBCsZkQt148JGbr2yOnwieOv4idvhX60Y4CUfO0Vq2j6Fc3X1+GCRbB1s4 YLaz4I/7/moleqMP91eHxMeoyeXFjJ4gPT6dh53v9jKcIk302Qtx5U0qGBofVme9FvfTidWT0f8A QZpEpw48iHWL+fY+OQlpYgVZ4qkfL0k/fTA4hXz+xiWYDQ7FWTflt/ymunf89v8AkxJmb2d/fR+P 3FtwfWHuWdO7J2KvGvyM/wCOzqX/ADDr/wATGKvSb+54Xki+lG1CN2Wp6DJBrJ3Q/wBc/wCKIv8A gf7caRbvrn/FEX/A/wBuNLbvrn/FEX/A/wBuNLbvrn/FEX/A/wBuNLbvrn/FEX/A/wBuNLbvrn/F EX/A/wBuNLbvrn/FEX/A/wBuNLbvrn/FEX/A/wBuNLbvrn/FEX/A/wBuNLbvrn/FEX/A/wBuNLby vziJrjzfcrAn76RoVijjG5YxIFCgdyc5jtAE5yBz2+4OFl3mg7xte02dXlvGSdpJJQ8N0srCVqCR mMTvxc7cq7nMeZyQO539/wCosTxR6of9M6vxmX69ccbkk3C+q9JCy8WL7/FVdjXtkPGnvud/NHGe 9u4utZtp5EnnninlpLMGdwzGWM0Zt9y0cp69ifHGUpg7k3+P0FSZBV0vX73T5fUT98Vj9GMSSTKE Tly4r6UkZ41J+E7e2SxZ5QN8/n+ghMchDct7rsc36bE8kEl/JLS5hf02Z1KtIPgIIA5j2xM5g8d1 xXv96ky+rvQn6Qv6g/WZagyEHm2xmHGQ9f2xs3j3yvjl3n+3mx4iiNNh1qeOX6g8nCzSW4k4ScAi lKSsKsu7ItDTcgZPGJn6eln9aYiR5dFGHVNTgYNDdzRMsfohkkdSI614bH7PtkRlkORKBI96ql1q mnXNrJIZAYkV4YpGbgYZfj40BH7uQMeQHWpyQlKBH42/UU2YkIS4nluLiW4lPKWZ2kkbxZjUn7zl cpEmyxJs2p4FZN+W3/Ka6d/z2/5MSZm9nf30fj9xbcH1h7lnTuydirxr8jP+OzqX/MOv/Exir0PU /wDe6X5j9QyQapc0LhQ7FXYq7FXYq7FXYq7FXYq7FXmnmeRo/OUsizfVmSSBluKE+mRGhD0UE/D1 2zmNea1B3rl9wcPJ9ad2ut+W4o3ZrlItZlAMuo2QltYiqtXj/cSsGb9vjEAcsjmxjr6+8WB/uT8d m0Tj8V+oaxYppdtMZ1hsLm31DjpCCRllaW5mEJU8FUBHPIFuJFNhhyZY8IN1EiXp+Jr5LKYry32d F5q0mS9mu7m9eWSSGzMSSPMqhYo+NzbSH0Z9pX+Jgo4t/MDiNTAyJJ6R7/iOR5/b3qMou77kD/iy KHRFtbO7eCaGxiW3jUMOFyLtmko1Pteg32vDb2yr80BCga9P28X6mPi+mh3JhJ5m0JZgbe/9Kxjv bua4sRFKBcwTBeKAcePxEHZqU69hlp1EL2Pp4pGt9wWfiR79t3N5q8vxXEUltMfRT1GtUdpHa1Bt JYljSP6uiKrOyggSMKgHxOE6nGDYO29eXpI5cP6Svixv8d3uYpoupRxXl9cXspL3Npdx+o1WLSzR MFqRU/Ex65gYcgEiZdYy+ZDjwlub7imXkfVNI06eWS/uTCHeNXib1jFJAQwkBWEHm24or/D165do skIG5Gvny68v07NmGQHNBeZ9W/STWEou2ufTtIY5I3MhaOVEVZa8xT42FaqTXvlepy8dG79I/awy Sut+iSZjNbsVZN+W3/Ka6d/z2/5MSZm9nf30fj9xbcH1h7lnTuydirxr8jP+OzqX/MOv/Exir0PU /wDe6X5j9QyQapc0LhQ7FXYq7FXYq7FXYq7FXYq7FXmPm70/8V3PqBjHyh5hKcuPppXjXvnLdof3 8r8vuDhZfrb1ny8lk9tKsFxBC8CXF3aTun1iJWmMVB8MfItQEDhUV3yvNg4aNEbWQeY3r8bJljpG XXlrSbSBr27S7ggjjhM9gzp9ZjknkkVeTmMCnCLnTgDuBt1yyWnhEcRsDbbrvfl5XyZHGALNoe48 kXsdzJAl1b8zcT2tpE7MJJngpsoCsoLBhSrDfbIy0cgascyB50g4TbSeSNQdggvLT1awK0ReTkr3 Sc4EP7ulX6DenicA0cu8dPt5dF8E94Y86sjFGFGUkMD2IzEaWsVdirsVdirsVdirJvy2/wCU107/ AJ7f8mJMzezv76Px+4tuD6w9yzp3ZOxV41+Rn/HZ1L/mHX/iYxV6RqH1X65LzEnKorQinQeIyQaz Voetl4Sfev8ATFGzq2XhJ96/0xXZ1bLwk+9f6Yrs6tl4Sfev9MV2dWy8JPvX+mK7OrZeEn3r/TFd nVsvCT71/piuzq2XhJ96/wBMV2dWy8JPvX+mK7OrZeEn3r/TFdnlXnRo181XbIvJAYjxfvSJNjSm cv2j/fy+H3Bwcv1KE3mIywxW7afaC1hqYYKTFVLOruQzSl/j4AH4unSmUnPYrhFD3/rU5OlBUHmu 7BEZtbZrNI0iSyYSGJRG5kQ1MnqEhnbq/enTD+ZPcK7t/f339q+KUQPOEq2VuRCkuqR3FzctdyqT we44UeLiwHKoNQykdMn+bPCNvVZN+/uZeNt5oZPNupJctcCOEu0tnMQVanKwXhF+10b9r8KZAaqV 3t/D/seTHxTd+77EnlkaWV5W+07Fmp4k1zHJs21k2twK7FXYq7FXYq7FWTflt/ymunf89v8AkxJm b2d/fR+P3FtwfWHuWdO7J2KvGvyM/wCOzqX/ADDr/wATGKvQ9T/3ul+Y/UMkGqXNC4UOxV2KuxV2 KuxV2KuxV2KuxV5d50/5SW8/55/8mkzlu0v7+Xw+4OFl+opJmC1uxV2KuxV2KuxV2KuxV2KuxV2K sm/Lb/lNdO/57f8AJiTM3s7++j8fuLbg+sPcs6d2TsVeNfkZ/wAdnUv+Ydf+JjFXoep/73S/MfqG SDVLmhcKHYq7FXYq7FXYq7FXYq7FXYqx7VfJdhqV/LeyzypJLxqq8aDioXuD4Zrs/ZsMkzIk7tUs QJtiXmvQbLR5LeK3lkkeUMzh6bAEBaUA675qNdpY4SACTbj5YCKQ5gNbsQLV6L5e/KZ7myFxrE0l rLJvHbxceSr/AJZYHc+HbNxp+y+KNzNeTlw01jdNf+VO6J/y23P/ACT/AOacv/kmHeWf5WPe7/lT uif8ttz/AMk/+acf5Jh3lfyse93/ACp3RP8Altuf+Sf/ADTj/JMO8r+Vj3u/5U7on/Lbc/8AJP8A 5px/kmHeV/Kx73f8qd0T/ltuf+Sf/NOP8kw7yv5WPe7/AJU7on/Lbc/8k/8AmnH+SYd5X8rHvd/y p3RP+W25/wCSf/NOP8kw7yv5WPemGg/ltpWjatBqUF1PJLBz4o/DieaFDWig9Gy3B2dDHMSBOzKG ARNsuzYN7sVeNfkZ/wAdnUv+Ydf+JjFXpN/bK15IxnjWpHwsTUbDwGSDWRuh/qi/8tEX3t/zTiin fVF/5aIvvb/mnFad9UX/AJaIvvb/AJpxWnfVF/5aIvvb/mnFad9UX/loi+9v+acVp31Rf+WiL72/ 5pxWnfVF/wCWiL72/wCacVp31Rf+WiL72/5pxWnfVF/5aIvvb/mnFad9UX/loi+9v+acVp31Rf8A loi+9v8AmnFaeW+fpxJ5iliDh1tkSMMtaGo5nrTu9M5ntOfFmI7tnCzn1JFa2txdTpBbxmSaQ0VB mDCBkaAstQFo3zz5auPLv1C2mlD3FzE0s4T7KnlQKD3pnRaPQjFvLeX3OwxYBHc830Nmwb3Yq7FX Yq7FXYq7FXYq7FXYq7FXjX5Gf8dnUv8AmHX/AImMVeh6n/vdL8x+oZINUuaFwodirsVdirsVdirs VdirsVdirsVeUfU73Xdbufqi8zLK7lzsqoW2LHwpnJ+HLPlPD1Lg0ZS2ehaD5dstIgpGPUuWH724 I+I+w8F9s6HS6SOEbfV3uVCAixv88/8Ajs6b/wAw7f8AEzmQ5T2XFXYq7FXYq7FXYq7FXYq7FXYq 7FXjX5Gf8dnUv+Ydf+JjFXoep/73S/MfqGSDVLmhcKHYq7FXYq7FXYq7FXYq7FXYqsmjMkMkYbgX UqGHaopXIyFghCH0zS7LTbUW9qnFBuzftMfFj3OV4cEcceGKIxAGyLy5kwb88/8Ajs6b/wAw7f8A EzkG57LirsVdirsVdirsVdirsVdirsVdirxr8jP+OzqX/MOv/Exir0jULS5e8lZImZSRQgbdBkg1 yG6H+o3n++X+442ii76jef75f7jja0XfUbz/AHy/3HG1ou+o3n++X+442tF31G8/3y/3HG1ou+o3 n++X+442tF31G8/3y/3HG1ou+o3n++X+442tF31G8/3y/wBxxtaLvqN5/vl/uONrRd9RvP8AfL/c cbWi76jef75f7jja0XfUbz/fL/ccbWi8/wDzz/47Om/8w7f8TORbXsuKuxV2KuxV2KuxV2KuxV2K uxV2KvmXy55p1fy9PNPpjokk6BJC6hxxBr3xVP8A/lcHnb/f8P8AyJXFXf8AK4PO3+/4f+RK4q7/ AJXB52/3/D/yJXFXf8rg87f7/h/5Erirv+Vwedv9/wAP/IlcVd/yuDzt/v8Ah/5Erirv+Vwedv8A f8P/ACJXFXf8rg87f7/h/wCRK4q7/lcHnb/f8P8AyJXFXf8AK4PO3+/4f+RK4q7/AJXB52/3/D/y JXFXf8rg87f7/h/5Erirv+Vwedv9/wAP/IlcVd/yuDzt/v8Ah/5EriqQeY/NOr+YZ4Z9TdHkgQpG UUIOJNe2KvprFXYq7FXYq7FXYq7FXYq7FXYq7FXy7r2ly6VrN7p0oIa2lZFrXdQfgYV7MtCMVQGK uxV2KuxV2KuxVNY7h9P0yCa1+C6unkL3QALosZACRt1Q/tMRvQjt1Vc9zJqGmXEt0PUubVo2S7I+ NlclSkjftnoyk77H6FUqxV2KuxV2KuxV2Ko3RNLn1XV7TToFLSXMqpt2Un4m+SrUnFX1JirsVdir sVdirsVdirsVdirsVdirCPzE/LtPMaC+sSsOrQrx+LZJkHRWPZh+y30H2VeKapoOs6VKYtRspbZg acnUhTvSqv8AZYe4OKoDFXYq7FXYq7FUZa6k0Nu1rLDHc2rNzEUnIcHpQsjKVZSQKHsdqjYYq1da iZoFtoYUtrYNzMUfI83AIDOzliSATTsOw3OKoTFXYq7FXYq7FUdpehaxqsyxadZy3LsaVRSVHarN 9lR7k4q9q/Lr8ul8uqb+/Ky6tKvEBd0hQ9VU92Pc/QPdVnOKuxV2KuxV2KuxV2KuxV2KuxV2Kv8A /9k= + + + + 1 + False + False + + 792.000000 + 612.000000 + Points + + + + + MyriadPro-Regular + Myriad Pro + Regular + Open Type + Version 2.106;PS 2.000;hotconv 1.0.70;makeotf.lib2.5.58329 + False + MyriadPro-Regular.otf + + + AmericanTypewriter-Semibold + American Typewriter + Semibold + TrueType + 13.0d1e4 + False + AmericanTypewriter.ttc + + + + + + Cyan + Magenta + Yellow + Black + + + + + + Default Swatch Group + 0 + + + + Document + application/pdf + proof:pdf + uuid:d9b475d2-572e-7e46-8e6f-3e3b22acc834 + uuid:f2b87d81-b02a-1b43-9c39-1274d5b3af61 + + + + + + + + + + + + + + + + + + + + + + + + + endstream endobj 3 0 obj <> endobj 8 0 obj <>/Resources<>/Font<>/ProcSet[/PDF/Text]/Properties<>>>/Thumb 162 0 R/TrimBox[0.0 0.0 792.0 612.0]/Type/Page>> endobj 159 0 obj <>stream +HTM9W1h6GfJj8BM @]!=n!ⴧUKgׇk./ =W%쵈g.Ń`hkkFul`v{樽P _Q[zc -7E7Z!醴k-تzI{e`,d㾬+>՟׆>ܗ@4aH^[꽺BG l)$K]s\FLh5oj ʱSyk:vIy x XJ:ff2 źA;I-PUAḟO02;jj M@fkd7!ר n=.jTk?@d +D &vD!0_XƐÍ )j6*ۓ9|3Up:UE11/^bQ(Bm-"sUZ@ÒBo^fDaRضoErzr^؝'ށ ]?.JftPڝ uh~eƑ<ƞ=C1$iP&[8iҦp0m?WtM& Λ|5yvةW%3-n[veOwz젮wKxL733j3͞67tCc6>stream +8;Z]!5n8Q%#Xr[RL:GU"FQ7)Ho.XE^W7B9&P8HrP:ABKU2hgEJ.Zqp9EkP]he?H"S +]\f67[9X*0J]"VnFPO3gFsR2`OePNB$Assi'PU-);q/[M6=G\6.>?me]oM(^0CCes +4r5U-F)b)A(FQ%U$ok!&M@td_]VCEHMLkkUVW`#dZsKq7PV-pi.;"S8_Q,-rAM2Dp +:R)8G/f2mJaIR5`oj/V6<8du23hah&P50qIF0Rj$+[<.hc30 +$#9n0%:qa?\`\\plAiEZ5>cq*^"D,=f^q]X^YEnfAee8PAn_,b25:+@#si6#!C+H( +"9;)>qVJeoaH6Q!*lQ_n,~> endstream endobj 163 0 obj [/Indexed/DeviceRGB 255 164 0 R] endobj 164 0 obj <>stream +8;X]O>EqN@%''O_@%e@?J;%+8(9e>X=MR6S?i^YgA3=].HDXF.R$lIL@"pJ+EP(%0 +b]6ajmNZn*!='OQZeQ^Y*,=]?C.B+\Ulg9dhD*"iC[;*=3`oP1[!S^)?1)IZ4dup` +E1r!/,*0[*9.aFIR2&b-C#soRZ7Dl%MLY\.?d>Mn +6%Q2oYfNRF$$+ON<+]RUJmC0InDZ4OTs0S!saG>GGKUlQ*Q?45:CI&4J'_2j$XKrcYp0n+Xl_nU*O( +l[$6Nn+Z_Nq0]s7hs]`XX1nZ8&94a\~> endstream endobj 155 0 obj <> endobj 156 0 obj <> endobj 157 0 obj <> endobj 169 0 obj [/View/Design] endobj 170 0 obj <>>> endobj 167 0 obj [/View/Design] endobj 168 0 obj <>>> endobj 165 0 obj [/View/Design] endobj 166 0 obj <>>> endobj 153 0 obj <> endobj 154 0 obj <> endobj 172 0 obj <> endobj 173 0 obj <>stream +H| xW}_W/uEU3Ӫ5vNHP:SocͨiJ- b_;V(I$̣Lf<̜yw;_)aL0}5o oJ>22$։m9:b~12oi[txXHhӡ_o ~ 歗"& AWGY)12dZef!as>mGMm Aw?dRaZ-o<i"6 i'K,Po-D8 SgЗxUzFS)JJ+ᣤeQQQ QmT@MBmQGW uQhWM Z%^G+Fx;[x[;耎肮B7tGD/FŻx?Ox111C1 Pa41c1x%ɈL_f FO,<,",_K ˱+ +MY|O> k){%bMe|؄؂8S[?a;v ;]؍=؋}؏8C8#8dNN Rqppi;\Wkq7[Fn#yW$fƽ͔cm%~^o03|`b|,7Wfbv昹any&ա K4&tZ@Ktn=\&rCnm}x/%8NdNs|IH'e,ղN6V%k)/wb_c_k_8:\@kvNe ݎNv8.ggcgKgss3EҮʮ.+5:U? } +>Kqjm,xy9=7FwdMe4e+֬7Ǥ+)))W^Kstҕئ*r5Up՞;zxU^x%oxީq@FX$ѲDVȧ+]*I-yR +oO_߶98{xvpl} +t6R^pessZ˧򪬼ZwU^bYϬB*5kDh2/ Fu|KuZ`|5ӚaM3~\wc &zS+8.PtNQz,T^l[qEi[u뚾/]o.V +2u+m^WZ :5SKr94z{4kqZҲg'doKseΪ4S:r٪'3zdL(ȸ-u֚돫gW/("lCKM(P˸4=ڕ-Gt2).a ӍߥP"$Eiު}i/t!ӝ`8BG)RMxD +ݦ\ʣ|*;\kH`_\$mTnL% x<?cF3=.Uˏ^)e y#oA2Xf}x Mv!s#:$.Ży}2>ȇ#|=Zw\ >ɧT3RҘI]N|/eNo[~"|EjI9=*aWgp,ev\\I?@5J^U?SԓzQoùwHh0 q a4Bhb(hpCciTJA+q̽ڋmb_ˮ6@4< + ^`RjIyȟJ(*i'B4UUJTE--QSZ>Oh +vvٹ{RR[>>yLKRW>)O&>Ʈ_d]g)Q%E ]L  k9[Й",-0*:am6ѷ##ec"_\/sZ^Gv,90#P,/~;'e;75 =/nF67Moe- [ۖ榲RO.}k Oh|;8󡺆PmAUA_^*7V*_Hr2d PY%3 +5s += ++:Y$,y;_D- "K6se3GfMFW|OYYbOǎfW.?!0-+) %ޱ͑Pa_Tqgs-!|?voTyꠧvYtFX+d잎TZi7?fmwW*;DP5*foPDE-@ڿJB!o_@=`M`&0_<k((/C_|en,UYE'My\Fu|r?@-y\7GHY԰UFPoz k!-1n˔D, "EUS,*k)hE߈zsѢFcTJX[-J9w2*rbPE9˱旁OɣV3;g:,CѢEv X wkng[P ^8θ mӭKϣEJA7v+ +,C{sw{<t YV^}{WXSuBz(̈́b}=Q0w +;u8?1oB‘OR#i ^/sep]6Qsx;_jT +9h +f +~65 I!L=c@a^4psؗŦi>zeZtu]cdiN۴NR}؄2ޤN]t0>z7~JfRcX\曩Nqed17ɐa3_3Tc#3)N%'mM;Rp)%ytqcɺbr~výWϲX&K]auzB{ķq2c~8'۷cGIc?DWZva'iy~{ HSqLؽ^ȷ8?quƜt箥\ڇrΆV!,Q|C9ާs9\q:y,BE Ml2IC%@ +w8nʀXM^:B9ȟ wRǍ>'ow!OR7uyZws.Cp̱3*pQy9w8J:9Is޹L/v#F諐6A'[l')ϫ,qF;:}M 8cQ33Q4Op; :{q6;;R<8׍m}5S)v#\oc?ʣb抽 +"C> endobj 174 0 obj <>stream +H|TyPSi^ٕ7Asˈ + +(*Œ:#hA\q$r_IQ 3#bDFTP +ˌ' "A6հ"A(WZ +Vus$Ǣh 3{A b1dzDKX +|`Սⴎk=l~"GHZ  õ÷͠l!ayMDwា=6&!5}}iGU~ju$SO:[_@.gՑ^,6(+b%" 7Y&C +L`Rr=AGwF%2]MxFrg%Ah=,UWv՚*ߋF*ȟphlg6_`2Cq@ K<|dsb_A/ ^5,)Cwwq}F?F]I" H~*]u1vfyQڦ} k*{̇#0M3'=/i!LD.{~kZ6uču`i#G XzɮSn'o<"zJ9)kla{эC҆+KntnϩeUyJ}71؜ *bN~&<:{:"kY%wWʯ?k$0pjMڲ )ሊ5[6*R2C(lq}[uF +~#y0Fj~ste.DYh[UֆKL6Il gLަ*M| :پc*f9Pu9D6_q# X΂S֢I<"(5Mϱ^bq12I^v;~;ſg:$ykSwƣG6k/ 8>A7FZ!4{Մ ,!f7Ŝ[Ȋ JjRu 1 ++Bʗh3Z"0vŌ4Nj"3J3.K^XdOfZFCRҮ_?v7_ c YzT\]Rp;@'֔/R' F\s^ KRD#uWxw~S)dŽH +3hn"^XA*]h-27If5TXYS~[䟊fܢWk +Z@ 2!sTi*gޘ_SD^kwbIګ;oiP + +DֱC5&䜟JP-oǪuQ1D&AG-2uRYRnW]sXxvKhJ g%(F | ;1TO~t16:}Lˢ 4gsr+/er7%!,sNKc):}HԅT۶&$Uw1Kfe F1ae[o/XFKz˷j#IYw>y`h&4>@ ' ~#s` +=5^ZR: +w~`7Ln{p`Z#LK=Q[lstacmkK_]q&@HR`qs~ztZzՀbvxw|rx3{{~d{bbP;o3>aιx<xw[xGRw^misxqun}uAǿ:.+hfn!x.fؗb_ + cq 'l endstream endobj 161 0 obj <> endobj 160 0 obj <> endobj 175 0 obj <> endobj 176 0 obj <>stream +%!PS-Adobe-3.0 %%Creator: Adobe Illustrator(R) 17.0 %%AI8_CreatorVersion: 22.1.0 %%For: (Mark Meyer) () %%Title: (smartphone.ai) %%CreationDate: 8/7/18 3:41 PM %%Canvassize: 16383 %%BoundingBox: 289 115 628 517 %%HiResBoundingBox: 289.720376884423 115.030000000001 627.9892578125 516.969999999999 %%DocumentProcessColors: Cyan Magenta Yellow Black %AI5_FileFormat 13.0 %AI12_BuildNumber: 312 %AI3_ColorUsage: Color %AI7_ImageSettings: 0 %%CMYKProcessColor: 1 1 1 1 ([Registration]) %AI3_Cropmarks: 0 0 792 612 %AI3_TemplateBox: 395.5 306.5 395.5 306.5 %AI3_TileBox: 18 18 752 594 %AI3_DocumentPreview: None %AI5_ArtSize: 14400 14400 %AI5_RulerUnits: 2 %AI9_ColorModel: 2 %AI5_ArtFlags: 0 0 0 1 0 0 1 0 0 %AI5_TargetResolution: 800 %AI5_NumLayers: 3 %AI17_Begin_Content_if_version_gt:17 1 %AI9_OpenToView: -322 844 1 1908 1102 18 1 0 6 43 0 0 0 0 1 0 1 1 0 1 %AI17_Alternate_Content %AI9_OpenToView: -322 844 1 1908 1102 18 1 0 6 43 0 0 0 0 1 0 1 1 0 1 %AI17_End_Versioned_Content %AI5_OpenViewLayers: 777 %%PageOrigin:0 0 %AI7_GridSettings: 72 8 72 8 1 0 0.800000011920929 0.800000011920929 0.800000011920929 0.899999976158142 0.899999976158142 0.899999976158142 %AI9_Flatten: 1 %AI12_CMSettings: 00.MS %%EndComments endstream endobj 177 0 obj <>stream +%%BoundingBox: 289 115 628 517 %%HiResBoundingBox: 289.720376884423 115.030000000001 627.9892578125 516.969999999999 %AI7_Thumbnail: 108 128 8 %%BeginData: 21142 Hex Bytes %0000330000660000990000CC0033000033330033660033990033CC0033FF %0066000066330066660066990066CC0066FF009900009933009966009999 %0099CC0099FF00CC0000CC3300CC6600CC9900CCCC00CCFF00FF3300FF66 %00FF9900FFCC3300003300333300663300993300CC3300FF333300333333 %3333663333993333CC3333FF3366003366333366663366993366CC3366FF %3399003399333399663399993399CC3399FF33CC0033CC3333CC6633CC99 %33CCCC33CCFF33FF0033FF3333FF6633FF9933FFCC33FFFF660000660033 %6600666600996600CC6600FF6633006633336633666633996633CC6633FF %6666006666336666666666996666CC6666FF669900669933669966669999 %6699CC6699FF66CC0066CC3366CC6666CC9966CCCC66CCFF66FF0066FF33 %66FF6666FF9966FFCC66FFFF9900009900339900669900999900CC9900FF %9933009933339933669933999933CC9933FF996600996633996666996699 %9966CC9966FF9999009999339999669999999999CC9999FF99CC0099CC33 %99CC6699CC9999CCCC99CCFF99FF0099FF3399FF6699FF9999FFCC99FFFF %CC0000CC0033CC0066CC0099CC00CCCC00FFCC3300CC3333CC3366CC3399 %CC33CCCC33FFCC6600CC6633CC6666CC6699CC66CCCC66FFCC9900CC9933 %CC9966CC9999CC99CCCC99FFCCCC00CCCC33CCCC66CCCC99CCCCCCCCCCFF %CCFF00CCFF33CCFF66CCFF99CCFFCCCCFFFFFF0033FF0066FF0099FF00CC %FF3300FF3333FF3366FF3399FF33CCFF33FFFF6600FF6633FF6666FF6699 %FF66CCFF66FFFF9900FF9933FF9966FF9999FF99CCFF99FFFFCC00FFCC33 %FFCC66FFCC99FFCCCCFFCCFFFFFF33FFFF66FFFF99FFFFCC110000001100 %000011111111220000002200000022222222440000004400000044444444 %550000005500000055555555770000007700000077777777880000008800 %000088888888AA000000AA000000AAAAAAAABB000000BB000000BBBBBBBB %DD000000DD000000DDDDDDDDEE000000EE000000EEEEEEEE0000000000FF %00FF0000FFFFFF0000FF00FFFFFF00FFFFFF %524C45FD05FF7D7D527D527D527D527D527D527D527D527D527D527D527D %527D527D527D527D527D527D527D527D527D527D527D527D527D527D7DA8 %A8FD33FF7DFD05527D5252527D5252527D5252527D5252527D5252527D52 %52527D5252527D5252527D5252527D5252527D5252527D5252527D52527D %FD31FF7D527D527D527D527D527D527D527D527D527D527D527D527D527D %527D527D527D527D527D527D527D527D527D527D527D527D527D527D527D %52527DFD2FFF7D527D5252527D5252527D5252527D5252527D5252527D52 %52527D5252527D5252527D5252527D5252527D5252527D5252527D525252 %7D5252527D52527DFD2EFF527D527D527D527D527D527D527D527D527D52 %7D527D527D527D7D7D527D7D7D527D7D7D527D527D527D527D527D527D52 %7D527D527D527D527D527D527DA8FD2CFF7D52527D5252527D5252527D52 %52527D5252527D5252527D5252FD0C7D527D5252527D5252527D5252527D %5252527D5252527D5252527DFD2CFF7D527D527D527D527D527D527D527D %527D527D527D527D527D527D527D527D527D527D527D527D527D527D527D %527D527D527D527D527D527D527D527D527D52FD2CFF527D5252527D5252 %527D5252527D5252527D5252527D5252527D5252527D5252527D5252527D %5252527D5252527D5252527D5252527D5252527D5252527D527DA8FD2BFF %7D527D527D527D527D527D527D527D527D527D527D527D527D527D527D52 %7D527D527D527D527D527D527D527D527D527D527D527D527D527D527D52 %7D527D52FD2CFF527D5252527D5252527D5252527D5252527D5252527D52 %52527D5252527D5252527D5252527D5252527D5252527D5252527D525252 %7D5252527D5252527D527DA8FD2BFF7D52FD3D7D52FD2CFF5252A8FD3AFF %A8527DA8FD2BFF7D52FD3BFFA87D52FD2CFF5252A8FFFFA9A8AFA8A9A8AF %A8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9 %A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFFFA8527DFD2CFF7D52A8FFFF %A8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AF %A8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA87D52FD %2CFF527DA8FFA8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8 %A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AF %A8A9FFA8527DA8FD2BFF7D52CAFFFFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8 %FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8 %FFA8FFA8FFA8FFA8FFA8FFA87D52FD2CFF527DA8FFAFAFA8A9A8AFA8A9A8 %AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8 %A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8AFFFA8527DA8FD2BFF7D52CFFFFF %A8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FF %A8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8FFA87D52FD %2CFF5252A8FFFFA8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AF %A8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8 %A8AFFFA8527DA8FD2BFF7D52FFFFFFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8 %FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8 %FFA8FFA8FFA8FFA8FFA8FFA87D52FD2CFF5252A8FFFFA9A8AFA8A9A8AFA8 %A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8 %AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFFFA8527DFD2CFF7D52A8FFFFA8 %AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8 %FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA87D52FD2C %FF527DA8FFA8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8 %AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8 %A8FFA8527DA8FD2BFF7D52CAFFFFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FF %A8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FF %A8FFA8FFA8FFA8FFA8FFA87D52FD2CFF527DA8FFFFFFAFFFA8FFAFFFA8FF %AFFFA8FFAFFFA8FFAFFFA8FFAFFFA8FFAFFFA8FFAFFFA8FFAFFFA8FFAFFF %A8FFAFFFA8FFAFFFA8FFAFFFA8FFAFFFFFA8527DA8FD2BFF7D52CFFFFFA8 %FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8 %FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA87D52FD2C %FF5252A8FFFFA8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8 %A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8 %AFFFA8527DA8FD2BFF7D52FFFFFFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FF %A8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FF %A8FFA8FFA8FFA8FFA8FFA87D52FD2CFF5252A8FFFFA9A8AFA8A9A8AFA8A9 %A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AF %A8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFFFA8527DFD2CFF7D52A8FFFFA8AF %A8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FF %A8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA87D52FD2CFF %527DA8FFA8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AF %A8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A9 %FFA8527DA8FD2BFF7D52CAFFFFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8 %FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8 %FFA8FFA8FFA8FFA8FFA87D52FD2CFF527DA8FFAFAFA8A9A8AFA8A9A8AFA8 %A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8 %AFA8A9A8AFA8A9A8AFA8A9A8AFA8AFFFA8527DA8FD2BFF7D52CFFFFFA8FF %A8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AF %A8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8FFA87D52FD2CFF %5252A8FFFFA8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8 %A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AF %FFA8527DA8FD2BFF7D52FFFFFFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8 %FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8 %FFA8FFA8FFA8FFA8FFA87D52FD2CFF5252A8FFFFAFA8FFA8AFA8FFA8AFA8 %FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8 %AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFFFA8527DFD2CFF7D52A8FD04FFAFFF %FFFFAFFFFFFFAFFFFFFFAFFFFFFFAFFFFFFFAFFFFFFFAFFFFFFFAFFFFFFF %AFFFFFFFAFFFFFFFAFFFFFFFAFFFFFFFAFFD05FFA87D52FD2CFF527DA8FF %A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AF %A8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A9FFA8527D %A8FD2BFF7D52CAFFFFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8 %FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8 %FFA8FFA8FFA87D52FD2CFF527DA8FFAFAFA8A9A8AFA8A9A8AFA8A9A8AFA8 %A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8 %AFA8A9A8AFA8A9A8AFA8AFFFA8527DA8FD2BFF7D52CFFFFFA8FFA8AFA8FF %A8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AF %A8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8FFA87D52FD2CFF5252A8FF %FFA8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8 %A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFFFA8527D %A8FD2BFF7D52FFFFFFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8 %FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8 %FFA8FFA8FFA87D52FD2CFF5252A8FFFFA9A8AFA8A9A8AFA8A9A8AFA8A9A8 %AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8 %A9A8AFA8A9A8AFA8A9A8AFFFA8527DFD2CFF7D52A8FFFFA8AFA8FFA8AFA8 %FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8 %AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA87D52FD2CFF527DA8FFA8 %AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8 %A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A9FFA8527DA8 %FD2BFF7D52CAFFFFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FF %A8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FF %A8FFA8FFA87D52FD2CFF527DA8FFA9AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8 %A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AF %A8A8A8AFA8A8A8AFA8A9FFA8527DA8FD2BFF7D52CFFFFFA8FFA8FFA8FFA8 %FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8 %FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA9FFA87D52FD2CFF5252A8FFFF %FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8 %FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFFFA8527DA8 %FD2BFF7D52FFFFFFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FF %A8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FF %A8FFA8FFA87D52FD2CFF5252A8FFFFA9A8AFA8A9A8AFA8A9A8AFA8A9A8AF %A8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9 %A8AFA8A9A8AFA8A9A8AFFFA8527DFD2CFF7D52A8FFFFA8AFA8FFA8AFA8FF %A8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AF %A8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA87D52FD2CFF527DA8FFA8AF %A8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8 %A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A9A8AFA8A9A8AFA8AFFFA85277A8FD %2BFF7D52CAFFFFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8 %FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FF82827C8282827C8282 %827CA7827C57838282828382828283828282838282828382828283828282 %8382828283828282838283FD09FF527DA8FFAFAFA8A9A8AFA8A9A8AFA8A9 %A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8AFA8AF %A8AFA85D5757575D5757575D5757575DFD2557FD09FF7D52CFFFFFA8FFA8 %AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8 %FFA9A858AF8383A8FFA8AF5782575D575E575D5782575D5782575D578257 %5D5782575D575E575D5782575D5782575D5782575D5782575D5782575D57 %82FFFFCF8282A757ADFF5252A8FFFFA8A8AFA8A8A8AFA8A8A8AFA8A8A8AF %A8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AF5783845DA7AFA8AFA857 %57577CA7FD095782FD0957A78282FD075782FD04573382FD045733FD0457 %FFFFCFFD0557FF7D52FFFFFFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FF %A8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA85E83A757A8A8FFA8AF575E82AE %7C8283A7FD0582AEA8FD0482A7FD0482A7ADA7828282A7A7838282A783A7 %57A7A882838282A757825782FD04FF57CF8382FF5252A8FFFFA9A8AFA8A9 %A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8A8 %FD0557A8A8AFA85733A77C3382A782FF7CA783A882A782A883AE82A7A7A7 %57AEA7A7A7A883AEA7A783A7A7A783AE82A783A7A7CF8283575757FFFFFF %8282CF8283FF7D52A8FFFFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8 %FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8A782A882A7A8AFA8AF575D82AE57 %A782A7FFA7A7A7838283ADAEA782CF82FF5782A8A7A7CFA7A783AE82AEA7 %CF82A8A8A7A7AEA8AEA8827C825782FFFFFFA7A8FF83FFFF527DA8FFA8FF %A8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FF %A8FFA8FFA8FFA8FFA8FFA8FFA85D57577CA757837C828283578257835783 %57837CA78357578257827C82578257827C827C827C8282A7578357825782 %575757FD09FF7D52CAFD04FFAFFFFFFFAFFFFFFFAFFFFFFFAFFFFFFFAFFF %FFFFAFFFFFFFAFFFFFFFAFFFFFFFAFFFFFFFAFFD05FF575E575E575E575E %575E57825782575E575E575E82825782575E575E575E575E575E575E575E %575E575E575E575E57825782FD09FF527DA8FFA9AFA8A8A8AFA8A8A8AFA8 %A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8 %AFA8A9A8825757575D5757575D5757575D5757575DFD07575D5757575D57 %57575D5757575D5757575D5757575D5757575D57FD09FF7D52CFFFFFA8FF %A8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AF %A8FFA8AFA8FFA8AFA8FFA8AF578257A77C825782A7825782575E82A7575E %5782578257825782575D578257825782575D5782A7825782575D5782575D %5782FD09FF5252A8FFFFA8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8 %A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA857575783A783 %A77CAE8383A7A75782A8A757A7A7A7835757A7A7577CA87CA783A77C8382 %A883FFFD0B57FD09FF7D52FFFFFFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FF %A8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8AF5782 %57A7CF8283AE82A7A8A783A782A783AD83CF82AE578283FF57AE5782A782 %CFA783A7A782A75E57825782578257825782FD09FF5252A8FFFFA9A8AFA8 %A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8 %AFA8A9A8AFA8A9A8AFA8575782A782A7A782837CA882837C82828382AEA7 %82A78282A8A78282A87CA7A78383AE82A757AE5757575D5757575D575757 %FD09FF7D52A8FFFFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AF %A8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8AF575D578257825782 %57815782575D5782575D5782578257825782575E7B82578257825782575D %5782575D5782575D57825782FD09FF527DA8FFA8AFA8A8A8AFA8A8A8AFA8 %A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8 %AFA8A9A85D5757335DFD1A5733FD1257FD09FF7D52CAFFFFA8FFA8FFA8FF %A8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FF %A8FFA8FFA8FFA8FF575E7B82575E5782575E575E5782575E575E575E575E %5782575E5782828357A7825E5782575E578257825782578257825782FD09 %FF527DA8FFAFAFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8 %AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8AFA8825782A88257A757FD04 %82A7828382827CA77C577CA75782838233A7A857AE827CA757A78382575D %5757575D5757575D57FD09FF7D52CFFFFFA8FFA8AFA8FFA8AFA8FFA8AFA8 %FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8 %AF575E82AE83AEA7AE7CCFA7AE82AEA7A7A7AD82AE57AE82AEA8ADA75882 %CFA7AE83A783AEA7ADA7585782575D5782575D5782FD09FF5252A8FFFFA8 %A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AF %A8A8A8AFA8A8A8AFA8A8A8AFA85733838282A8AE83A7A8AE8382A7A77CA8 %8382A757A782A7AE57AE33A783CF82A783A7A7A77CA7FD0B57FD09FF7D52 %FFFFFFAFFFFFFFAFFFFFFFAFFFFFFFAFFFFFFFAFFFFFFFAFFFFFFFAFFFFF %FFAFFFFFFFAFFFFFFFAFFFFFFFAFFFFFFF578282827C8282827CAD828257 %A782827CA7AEAE5782A7FD04825EFD04827C8383828282A7A75782578257 %8257825782FD09FF5252A8FFFFFFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FF %A8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA857575D %FD065733FD04575D57577CA7FD1357825757575D5757575D575757FD09FF %7D52A8FFFFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8 %AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8AF575D57827B5D5782575D57 %5E575D5782575D575E575D5782575D578257825782575D575E575D575E57 %5D5782575D57825782FD09FF527DA8FFA8AFA8A8A8AFA8A8A8AFA8A8A8AF %A8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A9 %A85D57828383578257825757828257575782578157A77C575782FD0457A7 %A7335757577C82FD0D57FD09FF7D52CAFFFFA8FFA8FFA8FFA8FFA8FFA8FF %A8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FF %A8FF575E82AE57A7A7A7A8AE82FF83A78383A8AEA7A7A7AEFD04A7A88257 %A7A75E57A7A7CFA75E578257825782578257825782FD09FF527DA8FFAFAF %A8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9 %A8AFA8A9A8AFA8A9A8AFA8AFA882575757FFA8A7A7A757A88382A7A783A7 %8382A7AE57AEA8A7A7A7575782A7338283A8A7825757575D5757575D5757 %575D57FD09FF7D52CFFFFFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8 %AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AF575E82A7A7 %AEA783A8AE82A783AE838283A782A7A7AEA7AEA7A7A75D5783A75857CFA7 %A7835E575D5782575D5782575D5782FD09FF5252A8FFFFA8A8AFA8A8A8AF %A8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8 %A8AFA8A8A8AFA85757825757578257A7FD0557825757575DFD055782575D %5757575D575757825781FD0D57FD09FF7D52FFFFFFA8FFA8FFA8FFA8FFA8 %FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8 %FFA8FFA8AF5782575E5782575E575E575E57825782578257825782578257 %82578257825782578257825782578257825782578257825782FD09FF5252 %A8FFFFA9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AF %A8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA857578283823382838257A75757 %335DFD07575D5757575D5757575D5757575D5757575D5757575D5757575D %575757FD09FF7D52A8FFFFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8 %FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8AF575D57A7A7 %5D57A7A7A783A782A77C82A7A78282575D5782575D5782575D5782575D57 %82575D5782575D5782575D57825782FD09FF527DA8FFA8AFA8A8A8AFA8A8 %A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AF %A8A8A8AFA8A9A85D575783A77C5783A7835783AE7CA7A8A7A7A858FD1E57 %FD09FF7D52CAFFFFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AF %A8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FF575E57A7A8837BAE %A7AD83ADA7A7A7CF82ADA7A7578257825782578257825782578257825782 %578257825782578257825782FD09FF527DA8FFFFFFA8FFA8FFA8FFA8FFA8 %FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8 %FFA8FFA88257FD07827CA757AEA7827C827C82575D5757575D5757575D57 %57575D5757575D5757575D5757575D5757575D57FD09FF7D52CFFFFFA8FF %AFFFA8FFAFFFA8FFAFFFA8FFAFFFA8FFAFFFA8FFAFFFA8FFAFFFA8FFAFFF %A8FFAFFFA8FFAFFFA8FFAFFF57825757575E5757575E575D8282575D575E %575D5782575D5782575D5782575D5782575D5782575D5782575D5782575D %5782FD09FF5252A8FFFFA8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8 %A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8FD3257FD09FF %7D52FFFFFFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8 %FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8AF5782578257825782578257 %825782578257825782578257825782578257825782578257825782578257 %825782578257825782FD09FF5252A8FFFFA9A8AFA8A9A8AFA8A9A8AFA8A9 %A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AF %A857575D5757575D5757575D5757575D5757575D5757575D5757575D5757 %575D5757575D5757575D5757575D5757575D575757FD09FF7D52A8FFFFA8 %AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8 %FFA8AFA8FFA8AFA8FFA8AFA8AF575D5782575D5782575D5782575D578257 %5D5782575D5782575D5782575D5782575D5782575D5782575D5782575D57 %825782FD09FF527DA8FFA8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8 %A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A9A85DFD3157FD %09FF7D52CAFFFFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8 %FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FF575E575E5782578257 %82578257825782575E575E575E575E575E575E575E575E575E575E575E57 %5E575E575E575E575E575EFD09FF527DA8FFAFAFA8A9A8AFA8A9A8AFA8A9 %A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AF %A8AFA8837C827C835757575D5757575D5757575D828382A7828382A78283 %82A7828382A7828382A7828382A7828382A7828382A782FD09FF7D52CFFF %FFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8 %FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA9FFA882575D5782575D578257 %81A8FD2AFF5252A8FFFFA8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8 %A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A7FD %095758A8FD2BFF7D52FFFFFFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FF %A8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FF %835E578257825782578258FD2CFF5252A8FFFFA8A8AFA8A8A8AFA8A8A8AF %A8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8 %A8AFA8A8A8AFA882575D5757575D5782527DFD2CFF7D52A8FD04FFA9FFFF %FFA9FFFFFFA9FFFFFFA9FFFFFFA9FFFFFFA9FFFFFFA9FFFFFFA9FFFFFFA9 %FFFFFFA9FFFFFFA9FFFFFFAFFF575D5782575D57ADA87D52FD2CFF527DA8 %FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8 %FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA85DFD0557A7FFA8527DA8 %FD2BFF7D52CAFFFFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AF %A8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA9A85782575E %57AEA9FFA87D52FD2CFF527DA8FFAFAFA8A9A8AFA8A9A8AFA8A9A8AFA8A9 %A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AF %A8AF7CFD0457AEA8AFFFA8527DA8FD2BFF7D52CFFFFFA8FFA8AFA8FFA8AF %A8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FF %A8AFA8FFA8AFA8FFA882575E82AFA8FFA8FFA87D52FD2CFF5252A8FFFFA8 %A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AF %A8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AE57577CAFA8A8A8AFFFA8527DA8FD %2BFF7D52FFFFFFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8 %FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA85E83FFA8FFA8 %FFA8FFA87D52FD2CFF5252A8FFFFA9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8 %A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8 %A883AFA8AFA8A9A8AFFFA8527DFD2CFF7D52A8FFFFA8AFA8FFA8AFA8FFA8 %AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8 %FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA87D52FD2CFF527DA8FFA8AFA8 %A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8 %AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A8A8AFA8A9FFA8527DA8FD2B %FF7D52CAFFFFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FF %A8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FF %A8FFA87D52FD2CFF527DA8FFA9AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AF %A8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9A8AFA8A9 %A8AFA8A9A8AFA8AFFFA8527DA8FD2BFF7D52CFFFFFA8FFA8AFA8FFA8AFA8 %FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8 %AFA8FFA8AFA8FFA8AFA8FFA8AFA8FFA8FFA87D52FD2CFF5252A8FFFFFFA8 %FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8 %FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFFFA8527DA8FD2B %FF7D52FD3BFFA87D52FD2CFF525252FD3B7D527DFD2CFF7D527D527D527D %527D527D527D527D527D527D527D527D527D527D527D527D527D527D527D %527D527D527D527D527D527D527D527D527D527D527D527D527D52FD2CFF %527D5252527D5252527D5252527D5252527D5252527D5252527D5252527D %527DFD05527D5252527D5252527D5252527D5252527D5252527D5252527D %527DA8FD2BFF7D527D527D527D527D527D527D527D527D527D527D527D52 %7D527D527D52A17DA87D7D527D527D527D527D527D527D527D527D527D52 %7D527D527D527D527D52FD2CFF527D5252527D5252527D5252527D525252 %7D5252527D5252527D5252527D7D52527D7D52527D5252527D5252527D52 %52527D5252527D5252527D5252527D527DFD2CFFA8527D527D527D527D52 %7D527D527D527D527D527D527D527D527D527D7D7D527D52A8527D527D52 %7D527D527D527D527D527D527D527D527D527D527D527D7DFD2CFFA85252 %7D5252527D5252527D5252527D5252527D5252527D5252527D527D525952 %527D7D5252527D5252527D5252527D5252527D5252527D5252527D525252 %A8FD2DFF7D7D527D527D527D527D527D527D527D527D527D527D527D527D %527D7DA8527D7D7D527D527D527D527D527D527D527D527D527D527D527D %527D527D52A8FD2FFF7DFD04527D5252527D5252527D5252527D5252527D %5252527D5252FD047D527D5252527D5252527D5252527D5252527D525252 %7D5252527D527DFD31FF7D7D527D527D527D527D527D527D527D527D527D %527D527D527D527D527D527D527D527D527D527D527D527D527D527D527D %527D527D525252A8FD33FFA87D5252527D5252527D5252527D5252527D52 %52527D5252527D5252527D5252527D5252527D5252527D5252527D525252 %7D5252527D7DA8FD2FFFFF %%EndData endstream endobj 178 0 obj <>stream +%AI12_CompressedDataxܽ~( ٦k @B1%ذz?ε܍e߳,H4i46KM#fV~K%Wsm:{ +DԏFZl6n@OeaH-r.so_R|!Å@{_Yne!*f2`9c# >7h6Hԋ*b"pW쏱x nfG#D8FF~a&z`D``؅r OV;ӻ)~=IX+`3ji&1,7YxBc9 +< +crXXi8>I˕Z\3Ŀ1d 4l8^P`!LW?Phü?DpiHUR*ɿ`/6 Qf-v˴݊n[+izv2H +$6h+8 +åvC +R`7f2Z*QHhl91 &$FO)+a-z5&Ј 4CnAL,6$ +~ ,IANo0jFwM/oMZl1~E@O7@q-vpn8&39Jfkbd@bD߄ߙ'Ax4?M!3E=E+7 e< d4: i  XĄD,Z}1Q;WbHSc~ٵ71ZL7i$<&zpLA~ 5т`4M z5<:by=Sr~УcQHx MyaihZ!֫XFޘuܣ$|p GEF) +ʨ[D`%i0\/l#pvQ X~11 jN1052@-=|,lh60d @Gۋx ++`r3#́tL 0_ɄYм *CSsgBA{u*m[q!@gf870|Mȡ``]\|}Grbn~2w>8`?P+Er +-ZE;X>j|`u`"!1s;z:_G^ſ: ;feSznm+Og+l߁^ش6\ @ 0;w[pBvbF9Oi#Z0_p3v30~SYmLn,F`ve=k@vqsw>ܑhs-<0=EVUq %l`K/f1ˌ5k5aFnnp$x"x4zK/{O<?tP`z28i2<*w'ԝ@@;Q"#ЫXtN$_0ԍP bf+뮇ՇX#Z6EcS;#`nGc@ Y,DXuKh^nD["9ulȤQ4Ј0<(50;4vn5TDQvHM1Ddu8" !Wm Y`^Gp/3Sv`u'ArZ  xݼs4ܓ@{x=c+nf11ÃLH8 )cf,T2 ( $n w +l1upcG*n[,!6'z?{4-VQs@ϋHp3; vͷ5""5j1ۉe`xh˼}6֒S%eҁ@+JF#zsGna}faG 'Y^"W3-/H).sphh +s0d"`t !h1P's]AˇՊ{= *OwDPFBIe?A7 "rW!{Ee?{I8Ð"# ΀ S0Gi5JZ'[I~b2+R{Bo aڡԁT"xKM1Ov/y,1dF5uV]żC%$/I1R{2*-X2jEV~źc#Ɯ3ņĀ[Aœَ~=Qn~27K? rNLІUQ69)Mï{ؓ۝h+ߘ(;GZ7^YjӉ%clr*\Z4ޯE&(2D7 ->#P4ГxCk,hfX]6+JX#?׆05"XFC6/vHz+Q ڀyAn\n0f9t&ŗmXיpVw5|q Zթ$uW_]XEr +,y\A= a)k6fkDeғՊɷ4˂qQ/ȓdaQӭiŞ%xH%%YxWHBHjG ?: +ڙM6l(,Uu (Bമ5Ï"G̹!!#!:jL I/fj "ndzPpb 7aNBj|s%"4ܱ^m6#ͷtMbt:_Cq [FULy6B1ccJWq1v/, @=0yv쎱b9ĕ bJnG 4;$KGs\;3 9JJ`ՊfkXqsmnMB씡/q=`hS>ýN#zcn3јӊJI}l}. "aL`c?MRSrwɀ*M oB0(t!ԚtőyćVAEۖV _BM5BVg*ޏlMdb6 Ɔ4&Yw3=ρF/Rÿÿ`.rGVa1v׆&gl"T̒ +n̉"1G""b'rB I(Pe>hoY'f}u: +Fm"TI?2Ƈ!Kr)-ş̋[/;,]`&[6ٰP\}~V}ldS![XܥE1;B՘|bٖUş|ͭ ~?#r1d}&[qߏYi}uvljY&hSege25߃֡8|WKb !!Ƿ8&T] _-d-&5K溾 Ӈ`qi~tA2|Oypx<\O Oiژ3 F;WGy>F &A-fsc{Hb"i0Gkg x=d:s$lxsd~m6lB-u\4{6-l<=~" (q)yk?σ'%k۬\ @w4ڙl|ﺞk5r53>̀ґ0orxh2ʊM e@ui&[mbS"^ S"דƿs%xb`8'3J`,b:7*=8&%iݟSb:"%GЉe}*#eN u %L4jmCufM:[Y>/}S)Ҕėd`z0x1x]*F`єxz8}jb-IW}&0.0\*GOT^}d_m\ob 6e/Lڧ=4挴tjv;g`RD8@P Qh{ii;{{_~Vh"X{<_:wrB8EitHXPr> FpsݣoOFO߆a~اw6U-A> {x; rC/$TUD;ә}=vy@T +,+N,d& +(,HxO9[KѰ v%$ïE^6j +p,D|#޴QPGL64LfSvK"`BN"r+bF$s4@Juf|IE䱤dSf_:Q +S{DȦ¼%F@tJ+ +}*%?%L'"Ta C-`?*UN>&3Vr8r c[JDTw dD4IHAd)!EU8Y-ɬE!%r +K% Iv#Z_dǔ 4OcCfJ5HKN$FZC~8 yMjs~}S:ˆ6@WD-㴽 +,2|4+,xkgTȜ<#wbRu82׮>'l[^͋Φv`W5!AC)wy+ݸށߕx>HG".U +ZJ@ +D؈)mvʘgG^B5{|`%ΉZJ:'\9Ƹk)x(sd[}PoyȑG8(}wm{dCQUAlOt( xp,sjjr:`w::T0LG"n~TY&`wl+K c +5w_݌qO2Ԕm8))#a**o~q' +d,qSI@eH-o Ktx0fk?|G= $2pg̎'&^#GTC>ݷVe_ZXtܸ?@[lGcۇ6'xkǣè7vUn 3*edYS(Ax& >j"ح]8}-9xœu/r+y}vyC@\=;).4M6G=1t@Q+Soǎ9RJ3-+fl"9;Ҁ0~BQ{:W/p‚^D`TKD^hnnR_& "f6ĄƻzsR^7OBQF%gL. $Kj˯na1O:?d_N^cyR7bJkPJ#m!a~G;3`)Ev]fZFSYj6IB3u#r{1>xMez_Gw{<*PbSQ(wXpCc*u>ֳ[8ʯbVor"_Ȓٖ'7ɩ;W@JC#WR,^tX8I͕U$-}jwGdK$I~(hΒwF&rp޻'aa2K,32`w/(;0;`"yw&Q?XH[MW`uk)Nj6V@kmǻ#rDMwb˛Yʙ|\s,NXeP_2 o.BA25"A%/s^. qP>@#3m\}ʅS\[攛?~6,{Qo$&Y6(.y8tn4Ws#e<)Te*/oW~@Phcv:r(;_6Ys)o + \s{ͯ߱7Gj2$&Gu(VhK)x7nx ZJI \LV(ӵhlgۯ}q + Zs{;/4@ +U,@x9/V6hlMseKL}AW@n'vba* OE,V93|`"*-~ +ra_ + + j:UuM)w)ˑv`^۹mnT(_p %h֊\ v#37oCU_v2^1Fx!YAn5$-ܬsmc_~'<^D>'cʥX*76~rӵ` )X@bh "\@/VXu+\+a6xԪr`]uoY#IoqA8i t|tB-n)֠+,dddJXío_y5$5s%Ɯ]#+̘JC.6D* ܷaz[<9=\nEL1d.rv{M7^D<y{$z`^f bp3|hFdц̇IُE3ja>M6*~XC{)|fgуc ߖ@Nw*,×,z)o;%ڷߢac,IyH]^*Yn/81u*v]6zSDg@G> ddKv~#z }^1M_C0-7md6Nf6L3h^I ~HXHu|x2<=E{O|, YB*?jD1QD#+8}s1o ³/XZ "5sZx%`LǹV +KgIor ߓ^ҏ\o8Wߑ+aUe>-#pZD7GGַ56ˇ>/\H#aN\)>QbyW\ފzsj yv+Vgeg=~nE#dX@3\JaA2J0Aá:Ak.BG1#4( M.;?p1|-S\{𕬵HiBY+-}`q>4t(xJ8Xu/XC=4NW'*[g#Ua\{揈JϽE:bTzZQJaXpJ[%YacX9Hv*^-||~2OP + +w/ &(Ok*D=֣ٛp8c-ti9/6ܭ+*ɧ2PKQhD^]v! o= ºaѭS@ NM[S)oLJK'PiJCaH]u aU$6}/W%6q/,ʌQLS^h2as`N{/wꛝsړ$Ro]Oyx}Otٔ| . 3GE]9g9fMe^NZ6x+ +*{X"1 L)L}f T;aMPZ +c5Zd%.n1p΀F=Y@c1]⠰2H{=X*F5b0"}ȜXfԹhv>'B\ i̋lyo^l4nMChAShw?LF0\0[rzs ̰$~N+4[ܘ!oRT\ yHßT'EX$+g{0czd.7.60GLkW7pJ(W?FXp1ft }UhsmSDyLn;/ha|eۘZ<[;] =;RZ=230={fB!E17̓G=1σϓS zL' 4^YVo;wCEMujHViiюR+u&l.gS )^*H#nJ +HIf/V;)IwV*RAβ-Z0޵hVL4kyH;9F4"}FeAxoY^ ++"cD )0nj!!. +wM2Һ>!{v*G>/1%nl4) H*Xew,B +yL6Fz)"}ΌܪHY ~VϥLfkRL~Hm!5َQ)ezE_D )P@Xxf_. ux~AEӺM@zGɑ~;Ve-kxna4H;R~Bc[>n#e4K Hy$ +?qfg+#%Wn9Ľ'OzwbǂE*'A.E"M<#>mB<5޾{" Cf~#LF'-R~9^OCK +`}x*ibHo9({-*&-[ ԣ*GLuSPg5t鷨)Gݒm=߮-6ŧiҀXNV~&}Jr,bӔ_H=gUVj=)֨zHC\t)?mv?Qy:Hc߭oIƔ~#OO;J9y%pw㍖/EQl?ŷmϏ(=c0t§㧁Vk=8CTn-w鶥,*O\{K\]3+\kfqד[<[;'|w6t _}ley1}]t{m]}9 giGz-F.`ݔ٧nuw[6L+ei!Cjޔ|Hr>RA + M09PA$T,au +# JƪZRV1҉~+ $WOMa`B~ hc}UGzSH օw!j UXa,'e)Wk5LP7X;D6Zv;`[2[w`)pznr8\_ -+kFBގx5@8: <ņL. 1ٌuj~6YaͪΟt :tfPa(^Y0`'k89K{q e + )&h'cZ,/>rp`ӊr^宄:U&B0%hUV٫rV |uM;nu2:>@6֧n'֑"Bmi=uJwT;N>&RR#P#kuEo9!tJf+fD3OKyIX'.q bOЕĒ.'~vJ\^5c $VmѨS&`@?Bb ${/Նf'ӈ?޸GjYWY$|Iqdsqd Z㿡kf@d1ň Cx(`# ,5qm\0᧺ܔC 6%x{)J;R9S.U8S(֧:Ҁᓤ#SIӃ'[!,b`Wk;)Yx{L0BXCD Pǘ-rz't/Yw!,|jG$mf_YA`[e2_ʫ@LU4k/z:f_Hr'*NEi/ |'=jD.TFnoDָ04Սɸ߅z飸Qf%ܬ@,OWhh5o ճ̎>vU%d_w<2 -GsPfc_ QG!KP +EEpWI.Ez~LKzt᳠ 965 D =%_>1XFސ˵% + /bFk-|}!7a +J[tEi3%J4hf(߶%7tj[A'ّlNrE|-8\b`I O-oPs|ӻ($f ֋L)S2ư^hOmI+= _VէJ$>&;yQwg0~bHọ WuІ lAY:"r`0|Y+Q ͋!S2:;zNUL>ͷ/ke+ 40ɤrzڎ2F3^ y`VZ{G%gU:oJKu/a ;/NǮw Up:'TwT|·팰q·b3⮝D.H0gR̈SjY=|8]_yR:ܹ||1g"." khQ8:%qVTC'! ѽB‘h8sT +F\߬|$Sճ@ D= f @B`P|@uffEh΍-GQVF)Uдeؗ-³qïr0s2A»;8]7 ca/HLzŌmSws6pn84btJ茢S9BK95Sq̙~@ft>ru7D!?on1ߞTiv -/I)2{EB[3#Œ%_mЁ>6,(%|*RTT[5.r j%if2 ;XV[),=Srik])MCyd7X s̰)Hr=VAo f]QGZy}SAZz]Gz_iTΩ~>upa-@;۝INB$:_CrCQP^#c\%!*ߩrv͝m&qDe$T+^qRJIO陫kTS.L*Ig4ᚷ뻠< +ǃ v靛ht\.1Q IV0Cj[w=5N9{l:85^ڵ8ɦSe)iWMwrl.ݥQl:%CWȦ3t +l:-|l:%Y)j}Y6R.zl:=WʦS;sl:{M$ZUwEΦ;>s/=;zlдp({q6XͦSexp6( $gMw1 eөStJ(V6(tJk[F6R.r~%tJ%̥tJtJ5/˦Sʥ˳r4nQ%i+eɻggrĝ1)ǥj]tJȯ^ 0WW%U4J'ުZq[yKt.w-~RTo9Ӡn٣.)J)REED;7RP8d$2XP畹S;<|h0^|i,ceLF +]Yó=E/sKK ËҔUSl)pS';^`s{:3ˮ~6s7Pg{s~+,UQlS{<:$_+|JD3@Q†ʽLV~JT6W yPcuxll+i蒬nÙlt1$ ϓāfi6[ʲUKe14 o"˽ +ȭ$KLdh +":l #^捤rniPOkŊs&FuVu@)^z";YgoRR+ E )|XRPhNdѣO, -av[i⸥ʹdd#`tr |XU4J,UwFh7m??ߋzGa#$?+rF=Jj^P彨}J5'AS`^WxV^NGX̻3xNKw|GOjš5:wRh`uSiitIVK:tʿ=Wޝv͔ۗ8{ ╴_CW +<`pǾJJqc`O߲>:7~rI (Vc4h,_@;>e<>"MC]f8\Gn6]<3P;yPHNSanJT-g'e=+-(%F%jMwI&E gP*H-e\ {Ɓp4W !Y}PTbB+sEO*@,Yl&Tb0Ǥӵ*KEtpW*ʇpjY W++FW6ԋ)$^(q>Q]j(P|.5B. 2d(5X5үg0JwC)W;n(~g=R~P~R괺~t~2C' >(6 +Ӯzy&6M\̸Q]?mT{j]?;맽p+/.!IN|P&\GiYiH@uf]?~W8-njӆrPO[T +T"vU?uiyZ]?9t9uSLW]O4/S8p-'rQ]??wfVQ]? $aPOsB]?mz|=(D.'pyN-έqN]? Pԫi/'NV^]?ms^1tA]~\l;?ROfN]J7;KUξQGV׏OS4wF4-.ΩB@ff ujC6g,Һ~<7WOb=ˢ˪EH~Zw˗V]?(Q]?~b-vI]#RJ)se<牫*+ӾIFiW+UQ]KNm +;V6 XROHSJ= i0ry]?Lvu)%*%UO#ҩzsu$9!rhp^<ӻƗ,+<>x/M[1,t۹` 4.*& +z0|w,>6VI\:gqϨGgK؃\85l{:d,μ[n,˒+wׯHosp/mIc]~7u7?.OMqz`r5Z=SAQAfKn1n']oq<8ӿu6E$gȷV8xR{.msZd|avosna3-ӿ%]ĂR?:-HEq /M[9(B H#Q&֊uO_*4-ͺGi0rz[vKKvrBqC'i( ` O)|jP--J +h^%UhNּ04A1yQG¾ J~ۻxhKjpoeOfnzL9nL +U4@Sb6-]yvjFbpr67c/K_`PxqlcbVx" +n fnh X8B_Ų[5A_bY&SКJ}l뽎Ola@z7-~P^'b? hv7w|ڜ{t͐#j\>ɕ]bR7q&F~ 0G_6i2G/pU:̣i2Ȃ}ѵĸڂ`Yw)8Z`ejVz +_hJ} 0f@ ޲e+ƣ [@ԛ/U8d,ϖ0Ixmq'ܹx(k¹ |. +r9j$BxcV=B ړaSqd9yoZg:Gi>C횂R㢣~)1QpW0Ed/T8@/&[vr(>+gkD24ZQp/^IWV U-#@#4pj[jƱ[ܴYqY ɫ}D5KI^~?}Kc/~ق J+~}W&d!(Mݱ]Ngv<]$,+_wxIW39Vs +HY4گ9Prv-d<̎dA/K[x_+˭ܢTE`)$~؃=Yx4]dz  +QLk?vd~T AkQP `Nձ +j9cJ0Z@o_I|Zc  JT8SMl oc {+MlHОjvoCK!%$:z er+YHvX"hpvqyp2Sf/'u,|(ugdH|ה4&7,? -@|u UHyç]/L?jqQ|;ꋤ*P"wX.}a(/ѳwibپ%{>=8oL_Iblnz. hUe;fUv"!y JUs {&jg f ahx&?惢MwID(KQ]:)1ʀ1A{- Ȭ`,rht/R 9,W40_<C:{*P5F7?bzgb'}[dC?s&IC?XN3ɴ]l,86 HŨqxw (FOzY|]ו֝Z|1ߗŵXѺy`d2g@覜Y_3}2ۯʃ}f;Қ^gK_,=\s;0?vY;:,r]֢ր~`]k@\* pt-~gՍZEFhf0OPIV3UXYZrL^ӗ+L|A& &[!k3Y.f+~˦IFTG(Ǖ}vKQ7g}%b 8ܼ%[(p D +V 77:H.buKL+*9v>`M"U&v<Dכ:d؎#A6$&7G&g)-!A&p$p_LJO)[ M*$s$^R~DLg;$~o~8G|E1A2T,AK%}L+aA`Ujcu0PnL6 O Lb|eq>|;խ/_8(e2 {&%.q<>tR<TS|_11AOIc%keE(d3||7juS_QJŶ+"P-nٸq֨*ONԑQT Ƥ&M+EN[]B 4<5\%pY,qSQE+~kjdM5Β: +Nd"›[y՘23 :EP߲yYᬏO@V!\ EE˔Lrv.Q_'Nsc9C<ʕf1㜼1{j~S7x+f}Aɻ~l]Ú'>a>d|k00;Bt9¿Sox!b|nPqG' frq6GE3bK9[;I%P`Yb1}8Q3MwP +4'&۵yYQ8}1._=Wt30|x=`%cW'[R AFcS)IT +lfNhHhAGF7*O6G5-a&5@Q^*_P %x*Z=άd&6 sy4+b3X{ N% aphV|o+ g+rI2_(p&O|%sUQپoS@&{CItZE(*raP/^EE`eaj0w}+YBU.. +5*-"Ν|J%Dq{g@-N=QF!eRϡ %kӈ̱"CSU;5e 0BUeө D<)H4:;Ю n+90-vlz첚D*A ,0ι<Ȝg +Bpi#=OQAȝ-| Y! A>]ֿ6 scx<*;00S8.mFqGX󐥩|% Qk/58qIvEʇ1*!4` BTfM 746Ѽ}99JY9Ľ46Bj gӾM4M5tg߭N4׎]);*rAv$!2˯UfzY#i&LSg:LGlIg䫳9l\k\Jmeql=H,n,պl9 r1H'2*Vl=&(=aɉF1$^>p!Ԕ`2 K9v%!^ej;V!'J7g=LqɣGx@- +e|O1R(4..p%7tC'<ɴƯ~ҀO3y @k plkpJ3 ;)tG4d7Vt4 F\RNZ Z*-1DM Ya`u;#ÚMuz ፸o<Cq_^{KTgI˧M"3$rquZTdS6F&WVц͋zzwo`1O.푩ٺC1 6E">l +#ײc|d*A\1zw?)|syDY?#7 +ܯ =71^DB.A^Z[6CBpQZpQ'6;_:s:̲@==V|k|?K ?rqQɳ<8ƚ ɥy(ln4pDShאp9\?#Zk\/ɛm^X<sߗN1_0=+Q_p"aB'`v\ئl|+=1&kk^rGl.5t+-)&XCq[dLS.vFL4&#c"^+\nV.Hry\,rEM1JKm z ݼMapzqIm,Ap*j*ZL;̡Ɍ+X?R΋hB9U\0K[ݍcv:߅g̊=es>A=*ʃʍ sL{"?}nE%J%Nƕ=i?ZfY +,)W0〙SxOU*D_ّ4]Zghlel;V\n״lʅ՟<{C^'ED#]nQen?#z|$xeD$XJ GFHѶsdd +:Y9J H<-+㇬]ƒJ|!Žkk^Vnn+K=6zzWqYɷQK˒[ %}6iHBTx`}" ;iŽYD|a.OxK;=/B ,wA2_o].p ^} I +fA DwRc/a'_ع8'_zQ^۰bu +O›B^UpĂ?W1*xuUn6?}I5R?;5ntMK޻kwKOۿ( b +$7Gc~Zb%Up7 .Cߟz'^[oNSXܝ;5xuw5HjͧϡN=:A964ݽ=Am)轄u%α_cQqXV|%|U15wrw$Vl~ +uXX_魞x w/Ds3|uB%#7HQIu*Wm~lՖ٤S51Nw8y╻wܣG0.ovs8w9hF'x@:О}%2܂f_vJnĢ'Y6GQ|奔 D(KYZ^]sK:\ܜ7i!Ax2[y0S }CΥM~0k锍{$0٥EȲݏէiZv});fIۻV+{wf\ـ?by6q>/wNgu}Qj-McڎŋݫΥx Up5 +ӫEmV|76˥$|:Լ cl(Χʪ{ݏx,|:3^vIg%o)Bryq+w?as"]2$t_:ZhK G IN -+W]p ϰ[ظ;a(\ĽsuH w o;#a9½sY-K^z-q263/uTҮ۷^w{q_eM5܌%Xָ{P`ʮ ak NRڈv+(O@䆤$^Q==l7Dc0,W1_W>>ňQY EUʆ'J5K!EͰ ~1e2L"SDuCd]7}w _~__qJ; oaciHlXT Եj* /A `FMI@U NU7=wvl';l_n;bW5M'&C@h8ZUdjQxzAu kYLPg1PWu)Z 4x>BNk/aH耍P>Wص®)Bugw8n'Ĕ:l|^F-ߒEk؎)0 +%41Gb%XB9vvJc~5q>K*{m]-g!fR` Dx#F{>f \XXb7+VLmSJDin!y4I 7Љ +XbI㠻6*S*0%+<+cXhY4CW-!]R*ױkj thC;u}˯vqjwڅ^KXَ0?> f?T nP96qabD5)D -!J12t1DBy4g2F@jxq/'KDo?&H:B PwEUg) F2xH4G5 ݇D&k8 '?aHna-2,GAQY(NQIZ @E/YQ!ƢC1"! nNps nʦ"p32 -^`2 Z'9 n>*@/vBBzT5 ,N`q,X$QxE"addd4= YB,*TxUXXiahi"KE͈P +#xUXX-şâhڢF%8UM`qXm`#Cgeʢb *NPq*Rb*V)29W.F*tcDbA 躮f)4nǑ#T'DRa2%*Qt˒`L> endobj 26 0 obj <> endobj 47 0 obj <> endobj 48 0 obj <> endobj 49 0 obj <> endobj 74 0 obj <> endobj 75 0 obj <> endobj 76 0 obj <> endobj 101 0 obj <> endobj 102 0 obj <> endobj 103 0 obj <> endobj 128 0 obj <> endobj 129 0 obj <> endobj 130 0 obj <> endobj 142 0 obj [/View/Design] endobj 143 0 obj <>>> endobj 140 0 obj [/View/Design] endobj 141 0 obj <>>> endobj 138 0 obj [/View/Design] endobj 139 0 obj <>>> endobj 115 0 obj [/View/Design] endobj 116 0 obj <>>> endobj 113 0 obj [/View/Design] endobj 114 0 obj <>>> endobj 111 0 obj [/View/Design] endobj 112 0 obj <>>> endobj 88 0 obj [/View/Design] endobj 89 0 obj <>>> endobj 86 0 obj [/View/Design] endobj 87 0 obj <>>> endobj 84 0 obj [/View/Design] endobj 85 0 obj <>>> endobj 61 0 obj [/View/Design] endobj 62 0 obj <>>> endobj 59 0 obj [/View/Design] endobj 60 0 obj <>>> endobj 57 0 obj [/View/Design] endobj 58 0 obj <>>> endobj 34 0 obj [/View/Design] endobj 35 0 obj <>>> endobj 15 0 obj [/View/Design] endobj 16 0 obj <>>> endobj 158 0 obj [157 0 R 156 0 R 155 0 R] endobj 179 0 obj <> endobj xref 0 180 0000000004 65535 f +0000000016 00000 n +0000000388 00000 n +0000020576 00000 n +0000000005 00000 f +0000000007 00000 f +0000084908 00000 n +0000000009 00000 f +0000020627 00000 n +0000000010 00000 f +0000000011 00000 f +0000000012 00000 f +0000000013 00000 f +0000000014 00000 f +0000000017 00000 f +0000087439 00000 n +0000087470 00000 n +0000000018 00000 f +0000000019 00000 f +0000000020 00000 f +0000000021 00000 f +0000000022 00000 f +0000000023 00000 f +0000000024 00000 f +0000000025 00000 f +0000000027 00000 f +0000084978 00000 n +0000000028 00000 f +0000000029 00000 f +0000000030 00000 f +0000000031 00000 f +0000000032 00000 f +0000000033 00000 f +0000000036 00000 f +0000087323 00000 n +0000087354 00000 n +0000000037 00000 f +0000000038 00000 f +0000000039 00000 f +0000000040 00000 f +0000000041 00000 f +0000000042 00000 f +0000000043 00000 f +0000000044 00000 f +0000000045 00000 f +0000000046 00000 f +0000000050 00000 f +0000085049 00000 n +0000085120 00000 n +0000085191 00000 n +0000000051 00000 f +0000000052 00000 f +0000000053 00000 f +0000000054 00000 f +0000000055 00000 f +0000000056 00000 f +0000000063 00000 f +0000087207 00000 n +0000087238 00000 n +0000087091 00000 n +0000087122 00000 n +0000086975 00000 n +0000087006 00000 n +0000000064 00000 f +0000000065 00000 f +0000000066 00000 f +0000000067 00000 f +0000000068 00000 f +0000000069 00000 f +0000000070 00000 f +0000000071 00000 f +0000000072 00000 f +0000000073 00000 f +0000000077 00000 f +0000085262 00000 n +0000085333 00000 n +0000085404 00000 n +0000000078 00000 f +0000000079 00000 f +0000000080 00000 f +0000000081 00000 f +0000000082 00000 f +0000000083 00000 f +0000000090 00000 f +0000086859 00000 n +0000086890 00000 n +0000086743 00000 n +0000086774 00000 n +0000086627 00000 n +0000086658 00000 n +0000000091 00000 f +0000000092 00000 f +0000000093 00000 f +0000000094 00000 f +0000000095 00000 f +0000000096 00000 f +0000000097 00000 f +0000000098 00000 f +0000000099 00000 f +0000000100 00000 f +0000000104 00000 f +0000085475 00000 n +0000085549 00000 n +0000085623 00000 n +0000000105 00000 f +0000000106 00000 f +0000000107 00000 f +0000000108 00000 f +0000000109 00000 f +0000000110 00000 f +0000000117 00000 f +0000086509 00000 n +0000086541 00000 n +0000086391 00000 n +0000086423 00000 n +0000086273 00000 n +0000086305 00000 n +0000000118 00000 f +0000000119 00000 f +0000000120 00000 f +0000000121 00000 f +0000000122 00000 f +0000000123 00000 f +0000000124 00000 f +0000000125 00000 f +0000000000 00000 f +0000000000 00000 f +0000000000 00000 f +0000085697 00000 n +0000085771 00000 n +0000085845 00000 n +0000000000 00000 f +0000000000 00000 f +0000000000 00000 f +0000000000 00000 f +0000000000 00000 f +0000000000 00000 f +0000000000 00000 f +0000086155 00000 n +0000086187 00000 n +0000086037 00000 n +0000086069 00000 n +0000085919 00000 n +0000085951 00000 n +0000000000 00000 f +0000000000 00000 f +0000000000 00000 f +0000000000 00000 f +0000000000 00000 f +0000000000 00000 f +0000000000 00000 f +0000000000 00000 f +0000000000 00000 f +0000023561 00000 n +0000023959 00000 n +0000022985 00000 n +0000023059 00000 n +0000023133 00000 n +0000087555 00000 n +0000021070 00000 n +0000032348 00000 n +0000032234 00000 n +0000021904 00000 n +0000022421 00000 n +0000022471 00000 n +0000023443 00000 n +0000023475 00000 n +0000023325 00000 n +0000023357 00000 n +0000023207 00000 n +0000023239 00000 n +0000029173 00000 n +0000024143 00000 n +0000024395 00000 n +0000029522 00000 n +0000032424 00000 n +0000032602 00000 n +0000033872 00000 n +0000055249 00000 n +0000087598 00000 n +trailer <]>> startxref 87740 %%EOF \ No newline at end of file diff --git a/docs/CNAME b/docs/CNAME new file mode 100644 index 0000000..d633a76 --- /dev/null +++ b/docs/CNAME @@ -0,0 +1 @@ +courtbot.codeforanchorage.org \ No newline at end of file diff --git a/docs/img/phone.png b/docs/img/phone.png new file mode 100644 index 0000000..8ff8352 Binary files /dev/null and b/docs/img/phone.png differ diff --git a/docs/img/phone_339x406.png b/docs/img/phone_339x406.png new file mode 100644 index 0000000..3888a58 Binary files /dev/null and b/docs/img/phone_339x406.png differ diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..c20e899 --- /dev/null +++ b/docs/index.html @@ -0,0 +1,79 @@ + + + + + + + + + Alaska CourtBot + + + + + + + + + + + + + + + + + +
+
+ +
+
+

Alaska CourtBot

+

A free service that will send you a text message reminder the day before your court hearing.

+

How it works:

+
+

+ Just text your case or ticket number to: +
+ 907-312-2700
+ We will attempt to send you a reminder the evening before your court hearing. Case numbers are 14 characters long like: 1KE-19-01234MO. Ticket numbers can be 8 to 17 characters long, for example: KETEEP000123456. +

+
+
Curious how it works? Try it out in demo mode:
+

Text: testcase
To: 907-312-2700

+
+
+ +

Frequently asked questions

+

+ How do I turn off all notifications?
+ Reply to the message with "stop" and we will stop sending you notifications. +

+

+ How do I turn off notifications for an individual case or ticket?
+ Text in the case or ticket number you are currently following to 1-907-312-2700 and the service will reply with the option to reply "DELETE" to stop notifications for that case or ticket. +

+

+ Do I still need to verify my court date?
+ Yes! Court dates change frequently, so you should always verify the date and time of your hearing by visiting the Alaska State Court System. +

+

+ Who maintains Alaska CourtBot
+ Alaska CourtBot is maintained by volunteers at Code for Anchorage, a brigade of Code for America. If you would like to be involved with this or similar projects, please visit: Code for Anchorage. +

+
+
+ + + + diff --git a/docs/js/fittext.js b/docs/js/fittext.js new file mode 100644 index 0000000..75928c3 --- /dev/null +++ b/docs/js/fittext.js @@ -0,0 +1,60 @@ +/*! +* FitText.js 1.0 jQuery free version +* +* Copyright 2011, Dave Rupert http://daverupert.com +* Released under the WTFPL license +* http://sam.zoy.org/wtfpl/ +* Modified by Slawomir Kolodziej http://slawekk.info +* +* Date: Tue Aug 09 2011 10:45:54 GMT+0200 (CEST) +*/ +(function(){ + + var addEvent = function (el, type, fn) { + if (el.addEventListener) + el.addEventListener(type, fn, false); + else + el.attachEvent('on'+type, fn); + }; + + var extend = function(obj,ext){ + for(var key in ext) + if(ext.hasOwnProperty(key)) + obj[key] = ext[key]; + return obj; + }; + + window.fitText = function (el, kompressor, options) { + + var settings = extend({ + 'minFontSize' : -1/0, + 'maxFontSize' : 1/0 + },options); + + var fit = function (el) { + var compressor = kompressor || 1; + + var resizer = function () { + el.style.fontSize = Math.max(Math.min(el.clientWidth / (compressor*10), parseFloat(settings.maxFontSize)), parseFloat(settings.minFontSize)) + 'px'; + }; + + // Call once to set. + resizer(); + + // Bind events + // If you have any js library which support Events, replace this part + // and remove addEvent function (or use original jQuery version) + addEvent(window, 'resize', resizer); + addEvent(window, 'orientationchange', resizer); + }; + + if (el.length) + for(var i=0; i= 1.3.1 < 2" + } + }, + "setprototypeof": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.0.3.tgz", + "integrity": "sha1-ZlZ+NwQ+608E2RvWWMDL77VbjgQ=" + } + } + }, + "statuses": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.4.0.tgz", + "integrity": "sha512-zhSCtt8v2NDrRlPQpCNtw/heZLtfUDqxBM1udqikb/Hbk52LK4nQSwr10u77iopCW5LsyHpuXS0GnEc48mLeew==" + } + } + }, + "extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, + "extend-shallow": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", + "integrity": "sha1-Jqcarwc7OfshJxcnRhMcJwQCjbg=", + "requires": { + "assign-symbols": "^1.0.0", + "is-extendable": "^1.0.1" + }, + "dependencies": { + "is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "requires": { + "is-plain-object": "^2.0.4" + } + } + } + }, + "extglob": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/extglob/-/extglob-2.0.4.tgz", + "integrity": "sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==", + "requires": { + "array-unique": "^0.3.2", + "define-property": "^1.0.0", + "expand-brackets": "^2.1.4", + "extend-shallow": "^2.0.1", + "fragment-cache": "^0.2.1", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + }, + "dependencies": { + "define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", + "requires": { + "is-descriptor": "^1.0.0" + } + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "requires": { + "is-extendable": "^0.1.0" + } + }, + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + } + } + }, + "extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=" + }, + "fast-deep-equal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-1.1.0.tgz", + "integrity": "sha1-wFNHeBfIa1HaqFPIHgWbcz0CNhQ=" + }, + "fast-json-stable-stringify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz", + "integrity": "sha1-1RQsDK7msRifh9OnYREGT4bIu/I=" + }, + "fast-safe-stringify": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.0.5.tgz", + "integrity": "sha512-QHbbCj2PmRSMNL9P7EuNBCeNXO06/E3t3XyQgb32AZul8wLmRa1Wbt2cm7GeUsX9OZGyXTQxMYcPOEBqARyhNw==" + }, + "fecha": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-2.3.3.tgz", + "integrity": "sha512-lUGBnIamTAwk4znq5BcqsDaxSmZ9nDVJaij6NvRt/Tg4R69gERA+otPKbS86ROw9nxVMw2/mp1fnaiWqbs6Sdg==" + }, + "fill-range": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", + "integrity": "sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc=", + "requires": { + "extend-shallow": "^2.0.1", + "is-number": "^3.0.0", + "repeat-string": "^1.6.1", + "to-regex-range": "^2.1.0" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "finalhandler": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.1.tgz", + "integrity": "sha512-Y1GUDo39ez4aHAw7MysnUD5JzYX+WaIj8I57kO3aEPT1fFRL4sr7mjei97FgnwhAyyzRYmQZaTHb2+9uZ1dPtg==", + "requires": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.2", + "statuses": "~1.4.0", + "unpipe": "~1.0.0" + }, + "dependencies": { + "statuses": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.4.0.tgz", + "integrity": "sha512-zhSCtt8v2NDrRlPQpCNtw/heZLtfUDqxBM1udqikb/Hbk52LK4nQSwr10u77iopCW5LsyHpuXS0GnEc48mLeew==" + } + } + }, + "find": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/find/-/find-0.2.9.tgz", + "integrity": "sha1-S3Px/55WrZG3bnFkB/5f/mVUu4w=", + "optional": true, + "requires": { + "traverse-chain": "~0.1.0" + } + }, + "findup-sync": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-3.0.0.tgz", + "integrity": "sha512-YbffarhcicEhOrm4CtrwdKBdCuz576RLdhJDsIfvNtxUuhdRet1qZcsMjqbePtAseKdAnDyM/IyXbu7PRPRLYg==", + "requires": { + "detect-file": "^1.0.0", + "is-glob": "^4.0.0", + "micromatch": "^3.0.4", + "resolve-dir": "^1.0.1" + } + }, + "fined": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fined/-/fined-1.2.0.tgz", + "integrity": "sha512-ZYDqPLGxDkDhDZBjZBb+oD1+j0rA4E0pXY50eplAAOPg2N/gUBSSk5IM1/QhPfyVo19lJ+CvXpqfvk+b2p/8Ng==", + "requires": { + "expand-tilde": "^2.0.2", + "is-plain-object": "^2.0.3", + "object.defaults": "^1.1.0", + "object.pick": "^1.2.0", + "parse-filepath": "^1.0.1" + } + }, + "flagged-respawn": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/flagged-respawn/-/flagged-respawn-1.0.1.tgz", + "integrity": "sha512-lNaHNVymajmk0OJMBn8fVUAU1BtDeKIqKoVhk4xAALB57aALg6b4W0MfJ/cUE0g9YBXy5XhSlPIpYIJ7HaY/3Q==" + }, + "for-in": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", + "integrity": "sha1-gQaNKVqBQuwKxybG4iAMMPttXoA=" + }, + "for-own": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/for-own/-/for-own-1.0.0.tgz", + "integrity": "sha1-xjMy9BXO3EsE2/5wz4NklMU8tEs=", + "requires": { + "for-in": "^1.0.1" + } + }, + "forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=" + }, + "form-data": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.1.tgz", + "integrity": "sha1-b7lPvXGIUwbXPRXMSX/kzE7NRL8=", + "dev": true, + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.5", + "mime-types": "^2.1.12" + } + }, + "formidable": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.1.1.tgz", + "integrity": "sha1-lriIb3w8NQi5Mta9cMTTqI818ak=", + "dev": true + }, + "forwarded": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz", + "integrity": "sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ=" + }, + "fragment-cache": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/fragment-cache/-/fragment-cache-0.2.1.tgz", + "integrity": "sha1-QpD60n8T6Jvn8zeZxrxaCr//DRk=", + "requires": { + "map-cache": "^0.2.2" + } + }, + "fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=" + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "dev": true + }, + "get-func-name": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz", + "integrity": "sha1-6td0q+5y4gQJQzoGY2YCPdaIekE=", + "dev": true + }, + "get-value": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz", + "integrity": "sha1-3BXKHGcjh8p2vTesCjlbogQqLCg=" + }, + "getopts": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/getopts/-/getopts-2.2.5.tgz", + "integrity": "sha512-9jb7AW5p3in+IiJWhQiZmmwkpLaR/ccTWdWQCtZM66HJcHHLegowh4q4tSD7gouUyeNvFWRavfK9GXosQHDpFA==" + }, + "getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", + "requires": { + "assert-plus": "^1.0.0" + } + }, + "glob": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", + "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "global-modules": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-1.0.0.tgz", + "integrity": "sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==", + "requires": { + "global-prefix": "^1.0.1", + "is-windows": "^1.0.1", + "resolve-dir": "^1.0.0" + } + }, + "global-prefix": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-1.0.2.tgz", + "integrity": "sha1-2/dDxsFJklk8ZVVoy2btMsASLr4=", + "requires": { + "expand-tilde": "^2.0.2", + "homedir-polyfill": "^1.0.1", + "ini": "^1.3.4", + "is-windows": "^1.0.1", + "which": "^1.2.14" + } + }, + "growl": { + "version": "1.10.5", + "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz", + "integrity": "sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==", + "dev": true + }, + "har-schema": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", + "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=" + }, + "har-validator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.0.tgz", + "integrity": "sha512-+qnmNjI4OfH2ipQ9VQOw23bBd/ibtfbVdK2fYbY4acTDqKTW/YDp9McimZdDbG8iV9fZizUqQMD5xvriB146TA==", + "requires": { + "ajv": "^5.3.0", + "har-schema": "^2.0.0" + } + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "has-value": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-value/-/has-value-1.0.0.tgz", + "integrity": "sha1-GLKB2lhbHFxR3vJMkw7SmgvmsXc=", + "requires": { + "get-value": "^2.0.6", + "has-values": "^1.0.0", + "isobject": "^3.0.0" + } + }, + "has-values": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-values/-/has-values-1.0.0.tgz", + "integrity": "sha1-lbC2P+whRmGab+V/51Yo1aOe/k8=", + "requires": { + "is-number": "^3.0.0", + "kind-of": "^4.0.0" + }, + "dependencies": { + "kind-of": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-4.0.0.tgz", + "integrity": "sha1-IIE989cSkosgc3hpGkUGb65y3Vc=", + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "he": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/he/-/he-1.1.1.tgz", + "integrity": "sha1-k0EP0hsAlzUVH4howvJx80J+I/0=", + "dev": true + }, + "homedir-polyfill": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz", + "integrity": "sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==", + "requires": { + "parse-passwd": "^1.0.0" + } + }, + "http-errors": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", + "integrity": "sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0=", + "requires": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.0", + "statuses": ">= 1.4.0 < 2" + }, + "dependencies": { + "depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=" + } + } + }, + "http-signature": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", + "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=", + "requires": { + "assert-plus": "^1.0.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + } + }, + "iconv-lite": { + "version": "0.4.23", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.23.tgz", + "integrity": "sha512-neyTUVFtahjf0mB3dZT77u+8O0QB89jFdnBkd5P1JgYPbPaia3gXXOVL2fq8VyU2gMMD7SaN7QukTB/pmXYvDA==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dev": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" + }, + "ini": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz", + "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==" + }, + "interpret": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz", + "integrity": "sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==" + }, + "ipaddr.js": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.8.0.tgz", + "integrity": "sha1-6qM9bd16zo9/b+DJygRA5wZzix4=" + }, + "is-absolute": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-absolute/-/is-absolute-1.0.0.tgz", + "integrity": "sha512-dOWoqflvcydARa360Gvv18DZ/gRuHKi2NU/wU5X1ZFzdYfH29nkiNZsF3mp4OJ3H4yo9Mx8A/uAGNzpzPN3yBA==", + "requires": { + "is-relative": "^1.0.0", + "is-windows": "^1.0.1" + } + }, + "is-accessor-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", + "integrity": "sha1-qeEss66Nh2cn7u84Q/igiXtcmNY=", + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==" + }, + "is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" + }, + "is-data-descriptor": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz", + "integrity": "sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y=", + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "is-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz", + "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==", + "requires": { + "is-accessor-descriptor": "^0.1.6", + "is-data-descriptor": "^0.1.4", + "kind-of": "^5.0.0" + }, + "dependencies": { + "kind-of": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", + "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==" + } + } + }, + "is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik=" + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=" + }, + "is-glob": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz", + "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==", + "requires": { + "is-extglob": "^2.1.1" + } + }, + "is-number": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", + "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=", + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "requires": { + "isobject": "^3.0.1" + } + }, + "is-relative": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-relative/-/is-relative-1.0.0.tgz", + "integrity": "sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA==", + "requires": { + "is-unc-path": "^1.0.0" + } + }, + "is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=" + }, + "is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" + }, + "is-unc-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-unc-path/-/is-unc-path-1.0.0.tgz", + "integrity": "sha512-mrGpVd0fs7WWLfVsStvgF6iEJnbjDFZh9/emhRDcGWTduTfNHd9CHeUwH3gYIjdbwo4On6hunkztwOaAw0yllQ==", + "requires": { + "unc-path-regex": "^0.1.2" + } + }, + "is-windows": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", + "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==" + }, + "is_js": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/is_js/-/is_js-0.9.0.tgz", + "integrity": "sha1-CrlFQFArp6+iTIVqqYVWFmnpxS0=" + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" + }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=" + }, + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=" + }, + "isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=" + }, + "jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=", + "optional": true + }, + "json-schema": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", + "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=" + }, + "json-schema-traverse": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz", + "integrity": "sha1-NJptRMU6Ud6JtAgFxdXlm0F9M0A=" + }, + "json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=" + }, + "jsonwebtoken": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-8.3.0.tgz", + "integrity": "sha512-oge/hvlmeJCH+iIz1DwcO7vKPkNGJHhgkspk8OH3VKlw+mbi42WtD4ig1+VXRln765vxptAv+xT26Fd3cteqag==", + "requires": { + "jws": "^3.1.5", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1" + }, + "dependencies": { + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==" + } + } + }, + "jsprim": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", + "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=", + "requires": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.2.3", + "verror": "1.10.0" + } + }, + "just-extend": { + "version": "1.1.27", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-1.1.27.tgz", + "integrity": "sha512-mJVp13Ix6gFo3SBAy9U/kL+oeZqzlYYYLQBwXVBlVzIsZwBqGREnOro24oC/8s8aox+rJhtZ2DiQof++IrkA+g==", + "dev": true + }, + "jwa": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.1.6.tgz", + "integrity": "sha512-tBO/cf++BUsJkYql/kBbJroKOgHWEigTKBAjjBEmrMGYd1QMBC74Hr4Wo2zCZw6ZrVhlJPvoMrkcOnlWR/DJfw==", + "requires": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.10", + "safe-buffer": "^5.0.1" + } + }, + "jws": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.1.5.tgz", + "integrity": "sha512-GsCSexFADNQUr8T5HPJvayTjvPIfoyJPtLQBwn5a4WZQchcrPMPMAWcC1AzJVRDKyD6ZPROPAxgv6rfHViO4uQ==", + "requires": { + "jwa": "^1.1.5", + "safe-buffer": "^5.0.1" + } + }, + "keygrip": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.0.2.tgz", + "integrity": "sha1-rTKXxVcGneqLz+ek+kkbdcXd65E=" + }, + "kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==" + }, + "knex": { + "version": "0.19.5", + "resolved": "https://registry.npmjs.org/knex/-/knex-0.19.5.tgz", + "integrity": "sha512-Hy258avCVircQq+oj3WBqPzl8jDIte438Qlq+8pt1i/TyLYVA4zPh2uKc7Bx0t+qOpa6D42HJ2jjtl2vagzilw==", + "requires": { + "bluebird": "^3.7.0", + "colorette": "1.1.0", + "commander": "^3.0.2", + "debug": "4.1.1", + "getopts": "2.2.5", + "inherits": "~2.0.4", + "interpret": "^1.2.0", + "liftoff": "3.1.0", + "lodash": "^4.17.15", + "mkdirp": "^0.5.1", + "pg-connection-string": "2.1.0", + "tarn": "^2.0.0", + "tildify": "2.0.0", + "uuid": "^3.3.3", + "v8flags": "^3.1.3" + }, + "dependencies": { + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "requires": { + "ms": "^2.1.1" + } + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "pg-connection-string": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.1.0.tgz", + "integrity": "sha512-bhlV7Eq09JrRIvo1eKngpwuqKtJnNhZdpdOlvrPrA4dxqXPjxSrbNrfnIDmTpwMyRszrcV4kU5ZA4mMsQUrjdg==" + } + } + }, + "kuler": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-1.0.0.tgz", + "integrity": "sha512-oyy6pu/yWRjiVfCoJebNUKFL061sNtrs9ejKTbirIwY3oiHmENVCSkHhxDV85Dkm7JYR/czMCBeoM87WilTdSg==", + "requires": { + "colornames": "^1.1.1" + } + }, + "liftoff": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/liftoff/-/liftoff-3.1.0.tgz", + "integrity": "sha512-DlIPlJUkCV0Ips2zf2pJP0unEoT1kwYhiiPUGF3s/jtxTCjziNLoiVVh+jqWOWeFi6mmwQ5fNxvAUyPad4Dfog==", + "requires": { + "extend": "^3.0.0", + "findup-sync": "^3.0.0", + "fined": "^1.0.1", + "flagged-respawn": "^1.0.0", + "is-plain-object": "^2.0.4", + "object.map": "^1.0.0", + "rechoir": "^0.6.2", + "resolve": "^1.1.7" + } + }, + "lodash": { + "version": "4.17.15", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", + "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==" + }, + "lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=", + "dev": true + }, + "lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8=" + }, + "lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY=" + }, + "lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha1-YZwK89A/iwTDH1iChAt3sRzWg0M=" + }, + "lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha1-POdoEMWSjQM1IwGsKHMX8RwLH/w=" + }, + "lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=" + }, + "lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=" + }, + "lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=" + }, + "logfmt": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/logfmt/-/logfmt-1.2.1.tgz", + "integrity": "sha512-QlZuQi8AlGbrXfW7LrxH/lhyFjI6Xr2DNSrIzhtIJAicAgl21P2gHpqABR3Sh0Kd4dvwTAej6jDVdh0o/HwfcA==", + "requires": { + "lodash": "4.x", + "split": "0.2.x", + "through": "2.3.x" + } + }, + "logform": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/logform/-/logform-1.9.1.tgz", + "integrity": "sha512-ZHrZE8VSf7K3xKxJiQ1aoTBp2yK+cEbFcgarsjzI3nt3nE/3O0heNSppoOQMUJVMZo/xiVwCxiXIabaZApsKNQ==", + "requires": { + "colors": "^1.2.1", + "fast-safe-stringify": "^2.0.4", + "fecha": "^2.3.3", + "ms": "^2.1.1", + "triple-beam": "^1.2.0" + }, + "dependencies": { + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==" + } + } + }, + "lolex": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/lolex/-/lolex-2.7.1.tgz", + "integrity": "sha512-Oo2Si3RMKV3+lV5MsSWplDQFoTClz/24S0MMHYcgGWWmFXr6TMlqcqk/l1GtH+d5wLBwNRiqGnwDRMirtFalJw==", + "dev": true + }, + "lru-cache": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-2.2.4.tgz", + "integrity": "sha1-bGWGGb7PFAMdDQtZSxYELOTcBj0=" + }, + "make-iterator": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/make-iterator/-/make-iterator-1.0.1.tgz", + "integrity": "sha512-pxiuXh0iVEq7VM7KMIhs5gxsfxCux2URptUQaXo4iZZJxBAzTPOLE2BumO5dbfVYq/hBJFBR/a1mFDmOx5AGmw==", + "requires": { + "kind-of": "^6.0.2" + } + }, + "map-cache": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz", + "integrity": "sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8=" + }, + "map-visit": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/map-visit/-/map-visit-1.0.0.tgz", + "integrity": "sha1-7Nyo8TFE5mDxtb1B8S80edmN+48=", + "requires": { + "object-visit": "^1.0.0" + } + }, + "media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=" + }, + "merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=" + }, + "methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=" + }, + "micromatch": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz", + "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==", + "requires": { + "arr-diff": "^4.0.0", + "array-unique": "^0.3.2", + "braces": "^2.3.1", + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "extglob": "^2.0.4", + "fragment-cache": "^0.2.1", + "kind-of": "^6.0.2", + "nanomatch": "^1.2.9", + "object.pick": "^1.3.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.2" + } + }, + "mime": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.4.1.tgz", + "integrity": "sha512-KI1+qOZu5DcW6wayYHSzR/tXKCDC5Om4s1z2QJjDULzLcmf3DvzS7oluY4HCTrc+9FiKmWUgeNLg7W3uIQvxtQ==" + }, + "mime-db": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.30.0.tgz", + "integrity": "sha1-dMZD2i3Z1qRTmZY0ZbJtXKfXHwE=", + "dev": true + }, + "mime-types": { + "version": "2.1.17", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.17.tgz", + "integrity": "sha1-Cdejk/A+mVp5+K+Fe3Cp4KsWVXo=", + "dev": true, + "requires": { + "mime-db": "~1.30.0" + } + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "mixin-deep": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.2.tgz", + "integrity": "sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==", + "requires": { + "for-in": "^1.0.2", + "is-extendable": "^1.0.1" + }, + "dependencies": { + "is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "requires": { + "is-plain-object": "^2.0.4" + } + } + } + }, + "mixme": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/mixme/-/mixme-0.3.5.tgz", + "integrity": "sha512-SyV9uPETRig5ZmYev0ANfiGeB+g6N2EnqqEfBbCGmmJ6MgZ3E4qv5aPbnHVdZ60KAHHXV+T3sXopdrnIXQdmjQ==" + }, + "mkdirp": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "requires": { + "minimist": "0.0.8" + }, + "dependencies": { + "minimist": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=" + } + } + }, + "mocha": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-5.2.0.tgz", + "integrity": "sha512-2IUgKDhc3J7Uug+FxMXuqIyYzH7gJjXECKe/w43IGgQHTSj3InJi+yAA7T24L9bQMRKiUEHxEX37G5JpVUGLcQ==", + "dev": true, + "requires": { + "browser-stdout": "1.3.1", + "commander": "2.15.1", + "debug": "3.1.0", + "diff": "3.5.0", + "escape-string-regexp": "1.0.5", + "glob": "7.1.2", + "growl": "1.10.5", + "he": "1.1.1", + "minimatch": "3.0.4", + "mkdirp": "0.5.1", + "supports-color": "5.4.0" + }, + "dependencies": { + "commander": { + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.15.1.tgz", + "integrity": "sha512-VlfT9F3V0v+jr4yxPc5gg9s62/fIVWsd2Bk2iD435um1NlGMYdVCq+MjcXnhYq2icNOizHr1kK+5TI6H0Hy0ag==", + "dev": true + }, + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "supports-color": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.4.0.tgz", + "integrity": "sha512-zjaXglF5nnWpsq470jSv6P9DwPvgLkuapYmfDm3JWOm0vkNTVF2tI4UrN2r6jH1qM/uc/WtxYY1hYoA2dOKj5w==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "moment": { + "version": "2.22.2", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.22.2.tgz", + "integrity": "sha1-PCV/mDn8DpP/UxSWMiOeuQeD/2Y=" + }, + "moment-timezone": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.21.tgz", + "integrity": "sha512-j96bAh4otsgj3lKydm3K7kdtA3iKf2m6MY2iSYCzCm5a1zmHo1g+aK3068dDEeocLZQIS9kU8bsdQHLqEvgW0A==", + "requires": { + "moment": ">= 2.9.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + }, + "nanomatch": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz", + "integrity": "sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA==", + "requires": { + "arr-diff": "^4.0.0", + "array-unique": "^0.3.2", + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "fragment-cache": "^0.2.1", + "is-windows": "^1.0.2", + "kind-of": "^6.0.2", + "object.pick": "^1.3.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + } + }, + "negotiator": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.1.tgz", + "integrity": "sha1-KzJxhOiZIQEXeyhWP7XnECrNDKk=" + }, + "nise": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/nise/-/nise-1.4.3.tgz", + "integrity": "sha512-cg44dkGHutAY+VmftgB1gHvLWxFl2vwYdF8WpbceYicQwylESRJiAAKgCRJntdoEbMiUzywkZEUzjoDWH0JwKA==", + "dev": true, + "requires": { + "@sinonjs/formatio": "^2.0.0", + "just-extend": "^1.1.27", + "lolex": "^2.3.2", + "path-to-regexp": "^1.7.0", + "text-encoding": "^0.6.4" + }, + "dependencies": { + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", + "dev": true + }, + "path-to-regexp": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.7.0.tgz", + "integrity": "sha1-Wf3g9DW62suhA6hOnTvGTpa5k30=", + "dev": true, + "requires": { + "isarray": "0.0.1" + } + } + } + }, + "nock": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/nock/-/nock-9.6.1.tgz", + "integrity": "sha512-EDgl/WgNQ0C1BZZlASOQkQdE6tAWXJi8QQlugqzN64JJkvZ7ILijZuG24r4vCC7yOfnm6HKpne5AGExLGCeBWg==", + "dev": true, + "requires": { + "chai": "^4.1.2", + "debug": "^3.1.0", + "deep-equal": "^1.0.0", + "json-stringify-safe": "^5.0.1", + "lodash": "^4.17.5", + "mkdirp": "^0.5.0", + "propagate": "^1.0.0", + "qs": "^6.5.1", + "semver": "^5.5.0" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + } + } + }, + "oauth-sign": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==" + }, + "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 + }, + "object-copy": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/object-copy/-/object-copy-0.1.0.tgz", + "integrity": "sha1-fn2Fi3gb18mRpBupde04EnVOmYw=", + "requires": { + "copy-descriptor": "^0.1.0", + "define-property": "^0.2.5", + "kind-of": "^3.0.3" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "requires": { + "is-descriptor": "^0.1.0" + } + }, + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "object-visit": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/object-visit/-/object-visit-1.0.1.tgz", + "integrity": "sha1-95xEk68MU3e1n+OdOV5BBC3QRbs=", + "requires": { + "isobject": "^3.0.0" + } + }, + "object.defaults": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/object.defaults/-/object.defaults-1.1.0.tgz", + "integrity": "sha1-On+GgzS0B96gbaFtiNXNKeQ1/s8=", + "requires": { + "array-each": "^1.0.1", + "array-slice": "^1.0.0", + "for-own": "^1.0.0", + "isobject": "^3.0.0" + } + }, + "object.map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/object.map/-/object.map-1.0.1.tgz", + "integrity": "sha1-z4Plncj8wK1fQlDh94s7gb2AHTc=", + "requires": { + "for-own": "^1.0.0", + "make-iterator": "^1.0.0" + } + }, + "object.pick": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz", + "integrity": "sha1-h6EKxMFpS9Lhy/U1kaZhQftd10c=", + "requires": { + "isobject": "^3.0.1" + } + }, + "on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=", + "requires": { + "ee-first": "1.1.1" + } + }, + "on-headers": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.1.tgz", + "integrity": "sha1-ko9dD0cNSTQmUepnlLCFfBAGk/c=" + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dev": true, + "requires": { + "wrappy": "1" + } + }, + "one-time": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-0.0.4.tgz", + "integrity": "sha1-+M33eISCb+Tf+T46nMN7HkSAdC4=" + }, + "packet-reader": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/packet-reader/-/packet-reader-0.3.1.tgz", + "integrity": "sha1-zWLmCvjX/qinBexP+ZCHHEaHHyc=" + }, + "parse-filepath": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/parse-filepath/-/parse-filepath-1.0.2.tgz", + "integrity": "sha1-pjISf1Oq89FYdvWHLz/6x2PWyJE=", + "requires": { + "is-absolute": "^1.0.0", + "map-cache": "^0.2.0", + "path-root": "^0.1.1" + } + }, + "parse-passwd": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/parse-passwd/-/parse-passwd-1.0.0.tgz", + "integrity": "sha1-bVuTSkVpk7I9N/QKOC1vFmao5cY=" + }, + "parseurl": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.2.tgz", + "integrity": "sha1-/CidTtiZMRlGDBViUyYs3I3mW/M=" + }, + "pascalcase": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/pascalcase/-/pascalcase-0.1.1.tgz", + "integrity": "sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ=" + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true + }, + "path-parse": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", + "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==" + }, + "path-root": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/path-root/-/path-root-0.1.1.tgz", + "integrity": "sha1-mkpoFMrBwM1zNgqV8yCDyOpHRbc=", + "requires": { + "path-root-regex": "^0.1.0" + } + }, + "path-root-regex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/path-root-regex/-/path-root-regex-0.1.2.tgz", + "integrity": "sha1-v8zcjfWxLcUsi0PsONGNcsBLqW0=" + }, + "path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=" + }, + "pathval": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.0.tgz", + "integrity": "sha1-uULm1L3mUwBe9rcTYd74cn0GReA=", + "dev": true + }, + "performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=" + }, + "pg": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/pg/-/pg-7.4.3.tgz", + "integrity": "sha1-97b5P1NA7MJZavu5ShPj1rYJg0s=", + "requires": { + "buffer-writer": "1.0.1", + "packet-reader": "0.3.1", + "pg-connection-string": "0.1.3", + "pg-pool": "~2.0.3", + "pg-types": "~1.12.1", + "pgpass": "1.x", + "semver": "4.3.2" + }, + "dependencies": { + "semver": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-4.3.2.tgz", + "integrity": "sha1-x6BxWKgL7dBSNVt3DYLWZA+AO+c=" + } + } + }, + "pg-connection-string": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-0.1.3.tgz", + "integrity": "sha1-2hhHsglA5C7hSSvq9l1J2RskXfc=" + }, + "pg-copy-streams": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pg-copy-streams/-/pg-copy-streams-1.2.0.tgz", + "integrity": "sha1-ez+d7gtsX8IGj1nED6IY4MHXQkk=" + }, + "pg-pool": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-2.0.3.tgz", + "integrity": "sha1-wCIDLIlJ8xKk+R+2QJzgQHa+Mlc=" + }, + "pg-types": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-1.12.1.tgz", + "integrity": "sha1-1kCH45A7WP+q0nnnWVxSIIoUw9I=", + "requires": { + "postgres-array": "~1.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.0", + "postgres-interval": "^1.1.0" + } + }, + "pgpass": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.2.tgz", + "integrity": "sha1-Knu0G2BltnkH6R2hsHwYR8h3swY=", + "requires": { + "split": "^1.0.0" + }, + "dependencies": { + "split": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/split/-/split-1.0.1.tgz", + "integrity": "sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==", + "requires": { + "through": "2" + } + } + } + }, + "pop-iterate": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pop-iterate/-/pop-iterate-1.0.1.tgz", + "integrity": "sha1-zqz9q0q/NT16DyqqLB/Hs/lBO6M=" + }, + "posix-character-classes": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz", + "integrity": "sha1-AerA/jta9xoqbAL+q7jB/vfgDqs=" + }, + "postgres-array": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-1.0.2.tgz", + "integrity": "sha1-jgsy6wO/d6XAp4UeBEHBaaJWojg=" + }, + "postgres-bytea": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", + "integrity": "sha1-AntTPAqokOJtFy1Hz5zOzFIazTU=" + }, + "postgres-date": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.3.tgz", + "integrity": "sha1-4tiXAu/bJY/52c7g/pG9BpdSV6g=" + }, + "postgres-interval": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.1.2.tgz", + "integrity": "sha512-fC3xNHeTskCxL1dC8KOtxXt7YeFmlbTYtn7ul8MkVERuTmf7pI4DrkAxcw3kh1fQ9uz4wQmd03a1mRiXUZChfQ==", + "requires": { + "xtend": "^4.0.0" + } + }, + "process-nextick-args": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz", + "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==", + "dev": true + }, + "propagate": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/propagate/-/propagate-1.0.0.tgz", + "integrity": "sha1-AMLa7t2iDofjeCs0Stuhzd1q1wk=", + "dev": true + }, + "proxy-addr": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.4.tgz", + "integrity": "sha512-5erio2h9jp5CHGwcybmxmVqHmnCBZeewlfJ0pex+UW7Qny7OOZXTtH56TGNyBizkgiOwhJtMKrVzDTeKcySZwA==", + "requires": { + "forwarded": "~0.1.2", + "ipaddr.js": "1.8.0" + } + }, + "psl": { + "version": "1.1.29", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.1.29.tgz", + "integrity": "sha512-AeUmQ0oLN02flVHXWh9sSJF7mcdFq0ppid/JkErufc3hGIV/AMa8Fo9VgDo/cT2jFdOWoFvHp90qqBH54W+gjQ==" + }, + "punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=" + }, + "q": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/q/-/q-2.0.3.tgz", + "integrity": "sha1-dbjbAlWhpa+C9Yw/Oqoe/sfQ0TQ=", + "requires": { + "asap": "^2.0.0", + "pop-iterate": "^1.0.1", + "weak-map": "^1.0.5" + } + }, + "qs": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.1.tgz", + "integrity": "sha512-eRzhrN1WSINYCDCbrz796z37LOe3m5tmW7RQf6oBntukAG1nmovJvhnwHHRMAfeoItc1m2Hk02WER2aQ/iqs+A==" + }, + "range-parser": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz", + "integrity": "sha1-9JvmtIeJTdxA3MlKMi9hEJLgDV4=" + }, + "raw-body": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.3.3.tgz", + "integrity": "sha512-9esiElv1BrZoI3rCDuOuKCBRbuApGGaDPQfjSflGxdy4oyzqghxu6klEkkVIvBje+FF0BX9coEv8KqW6X/7njw==", + "requires": { + "bytes": "3.0.0", + "http-errors": "1.6.3", + "iconv-lite": "0.4.23", + "unpipe": "1.0.0" + } + }, + "readable-stream": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "rechoir": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", + "integrity": "sha1-hSBLVNuoLVdC4oyWdW70OvUOM4Q=", + "requires": { + "resolve": "^1.1.6" + } + }, + "regex-not": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz", + "integrity": "sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A==", + "requires": { + "extend-shallow": "^3.0.2", + "safe-regex": "^1.1.0" + } + }, + "repeat-element": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.3.tgz", + "integrity": "sha512-ahGq0ZnV5m5XtZLMb+vP76kcAM5nkLqk0lpqAuojSKGgQtn4eRi4ZZGm2olo2zKFH+sMsWaqOCW1dqAnOru72g==" + }, + "repeat-string": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc=" + }, + "request": { + "version": "2.88.0", + "resolved": "https://registry.npmjs.org/request/-/request-2.88.0.tgz", + "integrity": "sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg==", + "requires": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~2.3.2", + "har-validator": "~5.1.0", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "oauth-sign": "~0.9.0", + "performance-now": "^2.1.0", + "qs": "~6.5.2", + "safe-buffer": "^5.1.2", + "tough-cookie": "~2.4.3", + "tunnel-agent": "^0.6.0", + "uuid": "^3.3.2" + }, + "dependencies": { + "combined-stream": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.6.tgz", + "integrity": "sha1-cj599ugBrFYTETp+RFqbactjKBg=", + "requires": { + "delayed-stream": "~1.0.0" + } + }, + "form-data": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.2.tgz", + "integrity": "sha1-SXBJi+YEwgwAXU9cI67NIda0kJk=", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "1.0.6", + "mime-types": "^2.1.12" + } + }, + "mime-db": { + "version": "1.35.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.35.0.tgz", + "integrity": "sha512-JWT/IcCTsB0Io3AhWUMjRqucrHSPsSf2xKLaRldJVULioggvkJvggZ3VXNNSRkCddE6D+BUI4HEIZIA2OjwIvg==" + }, + "mime-types": { + "version": "2.1.19", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.19.tgz", + "integrity": "sha512-P1tKYHVSZ6uFo26mtnve4HQFE3koh1UWVkp8YUC+ESBHe945xWSoXuHHiGarDqcEZ+whpCDnlNw5LON0kLo+sw==", + "requires": { + "mime-db": "~1.35.0" + } + }, + "qs": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", + "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==" + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "uuid": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", + "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==" + } + } + }, + "request-ip": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/request-ip/-/request-ip-2.0.2.tgz", + "integrity": "sha1-3urm1K8hdoSX24zQX6NxQ/jxJX4=", + "requires": { + "is_js": "^0.9.0" + } + }, + "resolve": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz", + "integrity": "sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w==", + "requires": { + "path-parse": "^1.0.6" + } + }, + "resolve-dir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/resolve-dir/-/resolve-dir-1.0.1.tgz", + "integrity": "sha1-eaQGRMNivoLybv/nOcm7U4IEb0M=", + "requires": { + "expand-tilde": "^2.0.0", + "global-modules": "^1.0.0" + } + }, + "resolve-url": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz", + "integrity": "sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo=" + }, + "ret": { + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", + "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==" + }, + "rollbar": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/rollbar/-/rollbar-2.4.4.tgz", + "integrity": "sha512-22DOE8sAkMmmjYOZNcqHJGhmJzq5Jp6zgMfu6x2IH9xnuYveEDyHba46TtCKwYPuY92A+pRCL/d0RKPmazk5Fg==", + "requires": { + "async": "~1.2.1", + "console-polyfill": "0.3.0", + "debug": "2.6.9", + "decache": "^3.0.5", + "error-stack-parser": "1.3.3", + "json-stringify-safe": "~5.0.0", + "lru-cache": "~2.2.1", + "request-ip": "~2.0.1", + "uuid": "3.0.x" + }, + "dependencies": { + "uuid": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.0.1.tgz", + "integrity": "sha1-ZUS7ot/ajBzxfmKaOjBeK7H+5sE=" + } + } + }, + "rootpath": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/rootpath/-/rootpath-0.1.2.tgz", + "integrity": "sha1-Wzeah9ypBum5HWkKWZQ5vvJn6ms=" + }, + "safe-buffer": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz", + "integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==" + }, + "safe-regex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", + "integrity": "sha1-QKNmnzsHfR6UPURinhV91IAjvy4=", + "requires": { + "ret": "~0.1.10" + } + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "samsam": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/samsam/-/samsam-1.3.0.tgz", + "integrity": "sha512-1HwIYD/8UlOtFS3QO3w7ey+SdSDFE4HRNLZoZRYVQefrOY3l17epswImeB1ijgJFQJodIaHcwkp3r/myBjFVbg==", + "dev": true + }, + "scmp": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/scmp/-/scmp-0.0.3.tgz", + "integrity": "sha1-NkjfLXKUZB5/eGc//CloHZutkHM=" + }, + "semver": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.5.0.tgz", + "integrity": "sha512-4SJ3dm0WAwWy/NVeioZh5AntkdJoWKxHxcmyP622fOkgHa4z3R0TdBJICINyaSDE6uNwVc8gZr+ZinwZAH4xIA==", + "dev": true + }, + "send": { + "version": "0.16.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.16.2.tgz", + "integrity": "sha512-E64YFPUssFHEFBvpbbjr44NCLtI1AohxQ8ZSiJjQLskAdKuriYEP6VyGEsRDH8ScozGpkaX1BGvhanqCwkcEZw==", + "requires": { + "debug": "2.6.9", + "depd": "~1.1.2", + "destroy": "~1.0.4", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "~1.6.2", + "mime": "1.4.1", + "ms": "2.0.0", + "on-finished": "~2.3.0", + "range-parser": "~1.2.0", + "statuses": "~1.4.0" + }, + "dependencies": { + "depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=" + }, + "statuses": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.4.0.tgz", + "integrity": "sha512-zhSCtt8v2NDrRlPQpCNtw/heZLtfUDqxBM1udqikb/Hbk52LK4nQSwr10u77iopCW5LsyHpuXS0GnEc48mLeew==" + } + } + }, + "serve-static": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.13.2.tgz", + "integrity": "sha512-p/tdJrO4U387R9oMjb1oj7qSMaMfmOyd4j9hOFoxZe2baQszgHcSWjuya/CiT5kgZZKRudHNOA0pYXOl8rQ5nw==", + "requires": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.2", + "send": "0.16.2" + } + }, + "set-value": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", + "integrity": "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==", + "requires": { + "extend-shallow": "^2.0.1", + "is-extendable": "^0.1.1", + "is-plain-object": "^2.0.3", + "split-string": "^3.0.1" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "setprototypeof": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", + "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==" + }, + "sha1": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/sha1/-/sha1-1.1.1.tgz", + "integrity": "sha1-rdqnqTFo85PxnrKxUJFhjicA+Eg=", + "requires": { + "charenc": ">= 0.0.1", + "crypt": ">= 0.0.1" + } + }, + "simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo=", + "requires": { + "is-arrayish": "^0.3.1" + } + }, + "sinon": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-4.5.0.tgz", + "integrity": "sha512-trdx+mB0VBBgoYucy6a9L7/jfQOmvGeaKZT4OOJ+lPAtI8623xyGr8wLiE4eojzBS8G9yXbhx42GHUOVLr4X2w==", + "dev": true, + "requires": { + "@sinonjs/formatio": "^2.0.0", + "diff": "^3.1.0", + "lodash.get": "^4.4.2", + "lolex": "^2.2.0", + "nise": "^1.2.0", + "supports-color": "^5.1.0", + "type-detect": "^4.0.5" + }, + "dependencies": { + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "supports-color": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.4.0.tgz", + "integrity": "sha512-zjaXglF5nnWpsq470jSv6P9DwPvgLkuapYmfDm3JWOm0vkNTVF2tI4UrN2r6jH1qM/uc/WtxYY1hYoA2dOKj5w==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + }, + "type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true + } + } + }, + "snapdragon": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/snapdragon/-/snapdragon-0.8.2.tgz", + "integrity": "sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg==", + "requires": { + "base": "^0.11.1", + "debug": "^2.2.0", + "define-property": "^0.2.5", + "extend-shallow": "^2.0.1", + "map-cache": "^0.2.2", + "source-map": "^0.5.6", + "source-map-resolve": "^0.5.0", + "use": "^3.1.0" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "requires": { + "is-descriptor": "^0.1.0" + } + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "snapdragon-node": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/snapdragon-node/-/snapdragon-node-2.1.1.tgz", + "integrity": "sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==", + "requires": { + "define-property": "^1.0.0", + "isobject": "^3.0.0", + "snapdragon-util": "^3.0.1" + }, + "dependencies": { + "define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", + "requires": { + "is-descriptor": "^1.0.0" + } + }, + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + } + } + }, + "snapdragon-util": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/snapdragon-util/-/snapdragon-util-3.0.1.tgz", + "integrity": "sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ==", + "requires": { + "kind-of": "^3.2.0" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=" + }, + "source-map-resolve": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.5.3.tgz", + "integrity": "sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw==", + "requires": { + "atob": "^2.1.2", + "decode-uri-component": "^0.2.0", + "resolve-url": "^0.2.1", + "source-map-url": "^0.4.0", + "urix": "^0.1.0" + } + }, + "source-map-url": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/source-map-url/-/source-map-url-0.4.0.tgz", + "integrity": "sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM=" + }, + "split": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/split/-/split-0.2.10.tgz", + "integrity": "sha1-Zwl8YB1pfOE2j0GPBs0gHPBSGlc=", + "requires": { + "through": "2" + } + }, + "split-string": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz", + "integrity": "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==", + "requires": { + "extend-shallow": "^3.0.0" + } + }, + "sshpk": { + "version": "1.14.2", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.14.2.tgz", + "integrity": "sha1-xvxhZIo9nE52T9P8306hBeSSupg=", + "requires": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + } + }, + "stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA=" + }, + "stackframe": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-0.3.1.tgz", + "integrity": "sha1-M6qE8Rd6VUjIk1Uzy/6zQgl19aQ=" + }, + "static-extend": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz", + "integrity": "sha1-YICcOcv/VTNyJv1eC1IPNB8ftcY=", + "requires": { + "define-property": "^0.2.5", + "object-copy": "^0.1.0" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "requires": { + "is-descriptor": "^0.1.0" + } + } + } + }, + "statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=" + }, + "stream-transform": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/stream-transform/-/stream-transform-2.0.2.tgz", + "integrity": "sha512-J+D5jWPF/1oX+r9ZaZvEXFbu7znjxSkbNAHJ9L44bt/tCVuOEWZlDqU9qJk7N2xBU1S+K2DPpSKeR/MucmCA1Q==", + "requires": { + "mixme": "^0.3.1" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.0" + } + }, + "superagent": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-3.8.3.tgz", + "integrity": "sha512-GLQtLMCoEIK4eDv6OGtkOoSMt3D+oq0y3dsxMuYuDvaNUvuT8eFBuLmfR0iYYzHC1e8hpzC6ZsxbuP6DIalMFA==", + "dev": true, + "requires": { + "component-emitter": "^1.2.0", + "cookiejar": "^2.1.0", + "debug": "^3.1.0", + "extend": "^3.0.0", + "form-data": "^2.3.1", + "formidable": "^1.2.0", + "methods": "^1.1.1", + "mime": "^1.4.1", + "qs": "^6.5.1", + "readable-stream": "^2.3.5" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha1-W7WgZyYotkFJVmuhaBnmFRjGcmE=", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "formidable": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.1.tgz", + "integrity": "sha512-Fs9VRguL0gqGHkXS5GQiMCr1VhZBxz0JnJs4JmMp/2jL18Fmbzvv7vOFRU+U8TBkHEE/CX1qDXzJplVULgsLeg==", + "dev": true + } + } + }, + "supertest": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-3.0.0.tgz", + "integrity": "sha1-jUu2j9GDDuBwM7HFpamkAhyWUpY=", + "dev": true, + "requires": { + "methods": "~1.1.2", + "superagent": "^3.0.0" + } + }, + "supertest-session": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/supertest-session/-/supertest-session-3.3.0.tgz", + "integrity": "sha512-i6vqLtPkR5SUHqhhWo9Q+RkQNWn0Zs9DNlFcxUg32NC8Nh8d3hiEQ4Mp/ef0oPkkxqCjV5V4FjQYk4Q+9YC7jA==", + "dev": true, + "requires": { + "cookiejar": "^2.0.1", + "methods": "^1.1.1", + "object-assign": "^4.0.1", + "supertest": "^3.1.0" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "process-nextick-args": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz", + "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==", + "dev": true + }, + "readable-stream": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.0" + } + }, + "superagent": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-3.8.2.tgz", + "integrity": "sha512-gVH4QfYHcY3P0f/BZzavLreHW3T1v7hG9B+hpMQotGQqurOvhv87GcMCd6LWySmBuf+BDR44TQd0aISjVHLeNQ==", + "dev": true, + "requires": { + "component-emitter": "^1.2.0", + "cookiejar": "^2.1.0", + "debug": "^3.1.0", + "extend": "^3.0.0", + "form-data": "^2.3.1", + "formidable": "^1.1.1", + "methods": "^1.1.1", + "mime": "^1.4.1", + "qs": "^6.5.1", + "readable-stream": "^2.0.5" + } + }, + "supertest": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-3.1.0.tgz", + "integrity": "sha512-O44AMnmJqx294uJQjfUmEyYOg7d9mylNFsMw/Wkz4evKd1njyPrtCN+U6ZIC7sKtfEVQhfTqFFijlXx8KP/Czw==", + "dev": true, + "requires": { + "methods": "~1.1.2", + "superagent": "3.8.2" + } + } + } + }, + "tarn": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tarn/-/tarn-2.0.0.tgz", + "integrity": "sha512-7rNMCZd3s9bhQh47ksAQd92ADFcJUjjbyOvyFjNLwTPpGieFHMC84S+LOzw0fx1uh6hnDz/19r8CPMnIjJlMMA==" + }, + "text-encoding": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/text-encoding/-/text-encoding-0.6.4.tgz", + "integrity": "sha1-45mpgiV6J22uQou5KEXLcb3CbRk=", + "dev": true + }, + "text-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==" + }, + "through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=" + }, + "tildify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tildify/-/tildify-2.0.0.tgz", + "integrity": "sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw==" + }, + "to-object-path": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/to-object-path/-/to-object-path-0.3.0.tgz", + "integrity": "sha1-KXWIt7Dn4KwI4E5nL4XB9JmeF68=", + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "to-regex": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/to-regex/-/to-regex-3.0.2.tgz", + "integrity": "sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw==", + "requires": { + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "regex-not": "^1.0.2", + "safe-regex": "^1.1.0" + } + }, + "to-regex-range": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz", + "integrity": "sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg=", + "requires": { + "is-number": "^3.0.0", + "repeat-string": "^1.6.1" + } + }, + "tough-cookie": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.4.3.tgz", + "integrity": "sha512-Q5srk/4vDM54WJsJio3XNn6K2sCG+CQ8G5Wz6bZhRZoAe/+TxjWB/GlFAnYEbkYVlON9FMk/fE3h2RLpPXo4lQ==", + "requires": { + "psl": "^1.1.24", + "punycode": "^1.4.1" + } + }, + "traverse-chain": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/traverse-chain/-/traverse-chain-0.1.0.tgz", + "integrity": "sha1-YdvC1Ttp/2CRoSoWj9fUMxB+QPE=", + "optional": true + }, + "triple-beam": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.3.0.tgz", + "integrity": "sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw==" + }, + "tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", + "requires": { + "safe-buffer": "^5.0.1" + } + }, + "tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=", + "optional": true + }, + "twilio": { + "version": "3.19.1", + "resolved": "https://registry.npmjs.org/twilio/-/twilio-3.19.1.tgz", + "integrity": "sha512-aGE8Y3VHG+Mp1xQWxE8cK3QzawEkwHlIPz/9uukHpjhNviB398LsUzkzpZlVMdfs/u7qmOcSCDJG0VNdRPbr/g==", + "requires": { + "deprecate": "1.0.0", + "jsonwebtoken": "^8.1.0", + "lodash": "^4.17.10", + "moment": "2.19.3", + "q": "2.0.x", + "request": "^2.87.0", + "rootpath": "0.1.2", + "scmp": "0.0.3", + "xmlbuilder": "9.0.1" + }, + "dependencies": { + "moment": { + "version": "2.19.3", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.19.3.tgz", + "integrity": "sha1-vbmdJw1tf9p4zA+6zoVeJ/59pp8=" + } + } + }, + "type-detect": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.3.tgz", + "integrity": "sha1-Dj8mcLRAmbC0bChNE2p+9Jx0wuo=", + "dev": true + }, + "type-is": { + "version": "1.6.16", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.16.tgz", + "integrity": "sha512-HRkVv/5qY2G6I8iab9cI7v1bOIdhm94dVjQCPFElW9W+3GeDOSHmy2EBYe4VTApuzolPcmgFTN3ftVJRKR2J9Q==", + "requires": { + "media-typer": "0.3.0", + "mime-types": "~2.1.18" + }, + "dependencies": { + "mime-db": { + "version": "1.35.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.35.0.tgz", + "integrity": "sha512-JWT/IcCTsB0Io3AhWUMjRqucrHSPsSf2xKLaRldJVULioggvkJvggZ3VXNNSRkCddE6D+BUI4HEIZIA2OjwIvg==" + }, + "mime-types": { + "version": "2.1.19", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.19.tgz", + "integrity": "sha512-P1tKYHVSZ6uFo26mtnve4HQFE3koh1UWVkp8YUC+ESBHe945xWSoXuHHiGarDqcEZ+whpCDnlNw5LON0kLo+sw==", + "requires": { + "mime-db": "~1.35.0" + } + } + } + }, + "unc-path-regex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz", + "integrity": "sha1-5z3T17DXxe2G+6xrCufYxqadUPo=" + }, + "union-value": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz", + "integrity": "sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==", + "requires": { + "arr-union": "^3.1.0", + "get-value": "^2.0.6", + "is-extendable": "^0.1.1", + "set-value": "^2.0.1" + } + }, + "unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=" + }, + "unset-value": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unset-value/-/unset-value-1.0.0.tgz", + "integrity": "sha1-g3aHP30jNRef+x5vw6jtDfyKtVk=", + "requires": { + "has-value": "^0.3.1", + "isobject": "^3.0.0" + }, + "dependencies": { + "has-value": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/has-value/-/has-value-0.3.1.tgz", + "integrity": "sha1-ex9YutpiyoJ+wKIHgCVlSEWZXh8=", + "requires": { + "get-value": "^2.0.3", + "has-values": "^0.1.4", + "isobject": "^2.0.0" + }, + "dependencies": { + "isobject": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz", + "integrity": "sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk=", + "requires": { + "isarray": "1.0.0" + } + } + } + }, + "has-values": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/has-values/-/has-values-0.1.4.tgz", + "integrity": "sha1-bWHeldkd/Km5oCCJrThL/49it3E=" + } + } + }, + "urix": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/urix/-/urix-0.1.0.tgz", + "integrity": "sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=" + }, + "use": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", + "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==" + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" + }, + "utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=" + }, + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" + }, + "v8flags": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/v8flags/-/v8flags-3.2.0.tgz", + "integrity": "sha512-mH8etigqMfiGWdeXpaaqGfs6BndypxusHHcv2qSHyZkGEznCd/qAXCWWRzeowtL54147cktFOC4P5y+kl8d8Jg==", + "requires": { + "homedir-polyfill": "^1.0.1" + } + }, + "vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=" + }, + "verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", + "requires": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "weak-map": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/weak-map/-/weak-map-1.0.5.tgz", + "integrity": "sha1-eWkVhNmGB/UHC9O3CkDmuyLkAes=" + }, + "which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "requires": { + "isexe": "^2.0.0" + } + }, + "winston": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.0.0.tgz", + "integrity": "sha512-7QyfOo1PM5zGL6qma6NIeQQMh71FBg/8fhkSAePqtf5YEi6t+UrPDcUuHhuuUasgso49ccvMEsmqr0GBG2qaMQ==", + "requires": { + "async": "^2.6.0", + "diagnostics": "^1.0.1", + "is-stream": "^1.1.0", + "logform": "^1.9.0", + "one-time": "0.0.4", + "readable-stream": "^2.3.6", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.2.0" + }, + "dependencies": { + "async": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.1.tgz", + "integrity": "sha512-fNEiL2+AZt6AlAw/29Cr0UDe4sRAHCpEHh54WMz+Bb7QfNcFw4h3loofyJpLeQs4Yx7yuqu/2dLgM5hKOs6HlQ==", + "requires": { + "lodash": "^4.17.10" + } + }, + "process-nextick-args": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz", + "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==" + }, + "readable-stream": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "requires": { + "safe-buffer": "~5.1.0" + } + } + } + }, + "winston-transport": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.2.0.tgz", + "integrity": "sha512-0R1bvFqxSlK/ZKTH86nymOuKv/cT1PQBMuDdA7k7f0S9fM44dNH6bXnuxwXPrN8lefJgtZq08BKdyZ0DZIy/rg==", + "requires": { + "readable-stream": "^2.3.6", + "triple-beam": "^1.2.0" + }, + "dependencies": { + "process-nextick-args": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz", + "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==" + }, + "readable-stream": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "requires": { + "safe-buffer": "~5.1.0" + } + } + } + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "dev": true + }, + "xmlbuilder": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-9.0.1.tgz", + "integrity": "sha1-kc1wiXdVNj66V8Et3uq0o0GmH2U=" + }, + "xtend": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz", + "integrity": "sha1-pcbVMr5lbiPbgg77lDofBJmNY68=" + } + } +} diff --git a/package.json b/package.json index 1026a6e..71664ad 100644 --- a/package.json +++ b/package.json @@ -2,40 +2,51 @@ "name": "courtbot", "version": "0.0.1", "description": "Deliver simple court data via JSON or SMS.", + "repository": { + "type": "git", + "url": "git://github.com/codeforanchorage/courtbot.git" + }, "main": "web.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "NODE_ENV=test mocha --exit test", + "start": "node web.js", + "dbsetup": "node utils/createTables.js", + "loaddata": "node runners/load.js" }, "author": "Sam Hashemi", "license": "MIT", "engines": { - "node": "0.10.x" + "node": "8.9.1" }, "dependencies": { - "async": "~0.2.10", - "bluebird": "~1.1.1", - "chai": "^2.1.2", - "csv-parse": "0.0.1", - "dotenv": "^1.1.0", - "express": "~3.4.8", - "knex": "~0.5.8", - "logfmt": "~0.23.0", - "mocha": "^2.2.4", - "moment": "~2.5.1", - "nock": "^1.2.1", - "pg": "~2.11.1", - "request": "~2.34.0", + "body-parser": "^1.18.3", + "cookie-session": "^2.0.0-beta.3", + "csv": "^5.3.2", + "dotenv": "~4.0.0", + "emoji-strip": "^1.0.1", + "express": "^4.16.3", + "jsonwebtoken": "^8.3.0", + "knex": "^0.19.5", + "logfmt": "^1.2.1", + "moment": "^2.22.2", + "moment-timezone": "^0.5.21", + "on-headers": "^1.0.1", + "pg": "^7.4.3", + "pg-copy-streams": "^1.2.0", + "request": "^2.88.0", + "rollbar": "^2.4.4", "sha1": "~1.1.0", - "sleep": "^2.0.0", - "timekeeper": "0.0.5", - "twilio": "~1.6.0", - "underscore": "~1.6.0" + "twilio": "^3.19.1", + "winston": "^3.0.0" }, "devDependencies": { + "chai": "~4.1.2", "cookie-parser": "^1.3.5", - "sinon": "^1.15.4", - "sinon-chai": "^2.8.0", - "supertest": "^0.15.0", - "supertest-session": "^0.0.7" + "keygrip": "^1.0.2", + "mocha": "^5.2.0", + "nock": "^9.6.1", + "sinon": "^4.5.0", + "supertest": "~3.0.0", + "supertest-session": "^3.3.0" } } diff --git a/psql.md b/psql.md index 50d5b74..fb320af 100644 --- a/psql.md +++ b/psql.md @@ -1 +1 @@ -postgres://localhost:5432/courtbot_test \ No newline at end of file +postgres://localhost:5432/courtbotdb \ No newline at end of file diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..cf711f2 --- /dev/null +++ b/public/index.html @@ -0,0 +1,103 @@ + + + Courtbot + + + + + + + + + + + + + + +

Impersonate Twilio

+ +
+ Phone Number:
+ +
+ +
+
+ +
+ +
+ + + diff --git a/runners/load.js b/runners/load.js index 5be22cf..1fef074 100644 --- a/runners/load.js +++ b/runners/load.js @@ -1,9 +1,31 @@ -var loadScript = require("../utils/loaddata"); +require('dotenv').config(); // needed for local dev when not using Heroku to pull in env vars +const runnerScript = require('../utils/loaddata.js'); +const manager = require('../utils/db/manager') +const runner_log = require('../utils/logger/runner_log') +const log = require('../utils/logger') +const {HTTPError} = require('../utils/errors') +const {addTestCase} = require('../utils/testCase') -loadScript().then(function(success) { - console.log(success); - process.exit(0); -}, function(err) { - console.log(err); - process.exit(1); -}); +let count = 0 +const max_tries = 6 +const time_between_retries = 5 * 60 * 1000 + +function load(){ + count++ + runnerScript() + .then((r) => runner_log.loaded(r)) + .then(() => addTestCase()) + .then(() => manager.knex.destroy()) + .catch((err) => { + if (count < max_tries && err instanceof HTTPError){ + console.log(err.message) + log.debug("load failed retrying", err) + setTimeout(load, time_between_retries) + } else { + manager.knex.destroy() + log.error(err) + } + }); +} + +load() \ No newline at end of file diff --git a/runners/sendQueued.js b/runners/sendQueued.js deleted file mode 100644 index eb83103..0000000 --- a/runners/sendQueued.js +++ /dev/null @@ -1,9 +0,0 @@ -var runnerScript = require("../sendQueued.js"); - -runnerScript().then(function(success) { - console.log(success); - process.exit(0); -}, function(err) { - console.log(err); - process.exit(1); -}); diff --git a/runners/sendReminders.js b/runners/sendReminders.js index b587634..fa8c34c 100644 --- a/runners/sendReminders.js +++ b/runners/sendReminders.js @@ -1,9 +1,18 @@ -var runnerScript = require("../sendReminders.js"); +/* eslint "no-console": "off" */ -runnerScript().then(function(success) { - console.log(success); - process.exit(0); -}, function(err) { - console.log(err); - process.exit(1); +require('dotenv').config(); // needed for local dev when not using Heroku to pull in env vars +const runnerScript = require('../sendReminders.js').sendReminders; +const manager = require('../utils/db/manager') +const runner_log = require('../utils/logger/runner_log') +const log = require('../utils/logger') +const {deleteTestRequests, incrementTestCaseDate} = require('../utils/testCase') + +runnerScript() +.then(reminders => runner_log.sent({action: 'send_reminder', data: reminders})) +.then(() => deleteTestRequests()) +.then(() => incrementTestCaseDate()) +.then(() => manager.knex.destroy()) +.catch((err) => { + manager.knex.destroy() + log.error(err) }); diff --git a/runners/sendUnmatched.js b/runners/sendUnmatched.js new file mode 100644 index 0000000..7b65a19 --- /dev/null +++ b/runners/sendUnmatched.js @@ -0,0 +1,20 @@ +/* eslint "no-console": "off" */ + +require('dotenv').config(); // needed for local dev when not using Heroku to pull in env vars +const runnerScript = require('../sendUnmatched.js').sendUnmatched; +const manager = require('../utils/db/manager') +const runner_log = require('../utils/logger/runner_log') +const log = require('../utils/logger') + +runnerScript() +.then((expired_and_matched) => { + return Promise.all([ + runner_log.sent({action:'send_expired', data: expired_and_matched.expired}), + runner_log.sent({action: 'send_matched', data: expired_and_matched.matched}) + ]) +}) +.then(() => manager.knex.destroy()) +.catch((err) => { + manager.knex.destroy() + log.error(err) +}); diff --git a/sendQueued.js b/sendQueued.js deleted file mode 100644 index 0bf4d18..0000000 --- a/sendQueued.js +++ /dev/null @@ -1,124 +0,0 @@ -var crypto = require('crypto'); -var Knex = require('knex'); -var twilio = require('twilio'); -var client = twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN); -var db = require('./db.js'); -var Promise = require('bluebird'); -var knex = Knex.initialize({ - client: 'pg', - connection: process.env.DATABASE_URL -}); -var moment = require('moment'); - - -// Finds reminders for cases happening tomorrow -var findQueued = function() { - return knex('queued') - .where('sent', false) - .select(); -}; - -function sendQueuedMessage(queued) { - return new Promise(function (resolve, reject) { - if (queued.length === 0) { - console.log('No queued messages to send today.'); - resolve(); - } - - var count = 0; - queued.forEach(function(queuedCitation) { - db.findCitation(queuedCitation.citation_id, function(err, results) { - var decipher = crypto.createDecipher('aes256', process.env.PHONE_ENCRYPTION_KEY); - var phone = decipher.update(queuedCitation.phone, 'hex', 'utf8') + decipher.final('utf8'); - - if (results && results.length > 0) { - var match = results[0]; - var name = cleanupName(match.defendant); - var date = moment(match.date).format('dddd, MMM Do'); - var body = 'Your Atlanta Municipal Court information was found: a court case for ' + name + ' on ' + date + ' at ' + match.time + ', in courtroom ' + match.room + '. Call us at (404) 954-7914 with any questions.'; - - client.sendMessage({ - to: phone, - from: process.env.TWILIO_PHONE_NUMBER, - body: body - }, function(err, result) { - if (err) { - return console.log("client.sendMessage", err); - } - - console.log('Queued message sent to ' + phone); - - knex('queued') - .where('queued_id', '=', queuedCitation.queued_id) - .update({'sent': true}) - .exec(function(err, results) { - if (err) { - console.log(err); - } - - count++; - - if (count === queued.length) { - resolve(); - } - }); - }); - } else { - var daysSinceCreation = moment().diff(moment(queuedCitation.created_at), 'days'); - console.log('Queued message created ' + daysSinceCreation + ' days ago.'); - - var ALLOWABLE_QUEUED_DAYS = 16; - if (daysSinceCreation > ALLOWABLE_QUEUED_DAYS) { - knex('queued') - .where('queued_id', '=', queuedCitation.queued_id) - .update({'sent': true}) - .exec(function(err, results) { - if (err) { - console.log(err); - } - }); - - client.sendMessage({ - to: phone, - from: process.env.TWILIO_PHONE_NUMBER, - body: 'We haven\'t been able to find your court case. Please call us at (404) 954-7914. -Atlanta Municipal Court', - }, function(err, result) { - if (err) { - return console.log(err); - } - count++; - if (count === queued.length) { - resolve(); - } - }); - } else { - count++; - if (count === queued.length) { - resolve(); - } - } - } - }); - }); - }); -} - -var cleanupName = function(name) { - // Switch LAST, FIRST to FIRST LAST - var bits = name.split(','); - name = bits[1] + ' ' + bits[0]; - name = name.trim(); - - // Change FIRST LAST to First Last - name = name.replace(/\w\S*/g, function(txt) { return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase(); }); - - return name; -}; - -module.exports = function() { - return new Promise(function(resolve, reject) { - findQueued().then(function(resp) { - sendQueuedMessage(resp).then(resolve, reject); - }).catch(reject); - }); -}; diff --git a/sendReminders.js b/sendReminders.js index 734a343..7309150 100644 --- a/sendReminders.js +++ b/sendReminders.js @@ -1,74 +1,77 @@ -var crypto = require('crypto'); -var Knex = require('knex'); -var twilio = require('twilio'); -var client = twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN); +/* eslint "no-console": "off" */ -var knex = Knex.initialize({ - client: 'pg', - connection: process.env.DATABASE_URL -}); +const db = require('./db.js'); +const manager = require('./utils/db/manager'); +const messages = require('./utils/messages'); +const knex = manager.knex; +const logger = require('./utils/logger'); -// Finds reminders for cases happening tomorrow -var findReminders = function() { - return knex('reminders') - .where('sent', false) - .join('cases', 'reminders.case_id', '=', 'cases.id') - .where('cases.date', 'tomorrow') - .select(); -}; - -function sendReminderMessages(reminders) { - return new Promise(function(resolve, reject) { - if (reminders.length === 0) { - console.log('No reminders to send out today.'); - resolve(); - } - - var count = 0; +/** + * Find all reminders with a case date of tomorrow for which a reminder has not been sent for that date/time + * + * @return {array} Promise to return results + */ +function findReminders() { + return knex.raw(` + SELECT DISTINCT case_id, phone, defendant, date, room FROM requests + INNER JOIN hearings USING (case_id) + WHERE tstzrange(TIMESTAMP 'tomorrow', TIMESTAMP 'tomorrow' + interval '1 day') @> hearings.date + AND requests.active = true + AND hearings.case_id NOT IN + (SELECT case_id FROM notifications WHERE notifications.type = 'reminder' AND notifications.event_date = hearings.date ) + `) + .then(result => result.rows) +} - // Send SMS reminder - reminders.forEach(function(reminder) { - var decipher = crypto.createDecipher('aes256', process.env.PHONE_ENCRYPTION_KEY); - var phone = decipher.update(reminder.phone, 'hex', 'utf8') + decipher.final('utf8'); +/** + * Update statuses of reminders that we send messages for. + * + * @param {Object} reminder reminder record that needs to be updated in db. + * @return {Promise} Promise that resolves to the reminder. + */ +function sendReminder(reminder) { + const phone = db.decryptPhone(reminder.phone); + return messages.send(phone, process.env.TWILIO_PHONE_NUMBER, messages.reminder(reminder)) + .then(() => { + return knex('notifications') + .insert({ + case_id: reminder.case_id, + phone:reminder.phone, + event_date: reminder.date, + type: 'reminder' + }) + .then(() => reminder) // knex needsa then() to fire the request + }) + .catch(err => { + return knex('notifications') + .insert({ + case_id: reminder.case_id, + phone:reminder.phone, + event_date: reminder.date, + type: 'reminder', + error: err.message + }) + .then(() =>{ + logger.error(err) + reminder.error = err + return reminder + }) + }) +} - client.sendMessage({ - to: phone, - from: process.env.TWILIO_PHONE_NUMBER, - body: 'Reminder: You\'ve got a court case tomorrow at ' + reminder.time + - ' in court room ' + reminder.room + - '. Call us at (404) 954-7914 with any questions. -Atlanta Municipal Court' - - }, function(err, result) { - if (err) { - console.log(err); - } - console.log('Reminder sent to ' + reminder.phone); - // Update table - knex('reminders') - .where('reminder_id', '=', reminder.reminder_id) - .update({'sent': true}) - .exec(function(err, results) { - if (err) { - console.log(err); - } - }).then(function(err, data) { - if (err) { - console.log(err); - } - count++; - if (count === reminders.length) { - resolve(); - } - }); - }); - }); - }); -}; +/** + * Main function for executing: + * 1.) The retrieval of court date reminders + * 2.) Add notification and Send Status + * + * @return {Promise} Promise to send messages and update statuses. + */ +function sendReminders() { + return findReminders() + .then(resultArray => Promise.all(resultArray.map(r => sendReminder(r)))) +} -module.exports = function() { - return new Promise(function(resolve, reject) { - findReminders().then(function(resp) { - sendReminderMessages(resp).then(resolve, reject); - }).catch(reject); - }); +module.exports = { + findReminders, + sendReminders, }; diff --git a/sendUnmatched.js b/sendUnmatched.js new file mode 100644 index 0000000..a60d214 --- /dev/null +++ b/sendUnmatched.js @@ -0,0 +1,144 @@ +/* eslint "no-console": "off" */ + +const db = require('./db.js'); +const messages = require('./utils/messages'); +const manager = require('./utils/db/manager'); +const logger = require('./utils/logger'); +const knex = manager.knex; + +/** + * Retrieve array of requests that have sat too long. + * + * @return {Promise} Promise that resolves to an array of objects: + * [{phone: 'encrypted-phone', case_id: [id1, id2, ...]}, ...] + */ +function getExpiredRequests() { + /* We dont delete these all at once even though that's easier, becuase we only want to + delete if there's not a tillio (or other) error. */ + return knex('requests') + .where('known_case', false) + .andWhere('active', true) + .and.whereRaw(`updated_at < CURRENT_DATE - interval '${process.env.QUEUE_TTL_DAYS} day'`) + .whereNotExists(function() { // should only be neccessary if there's an error in discoverNewCitations + this.select('*').from('hearings').whereRaw('hearings.case_id = requests.case_id'); + }) + .select('*') +} + +/** + * Deletes given case_ids and sends unable-to-find message + * Perform both actions inside transaction so if we only update DB if twilio succeeds + * and don't send to Twilio if delete fails. + * + * @param {*} groupedRequest is an object with a phone and an array of case_ids. + */ +function notifyExpired(expiredRequest) { + const phone = db.decryptPhone(expiredRequest.phone); + return knex('requests') + .where('phone', expiredRequest.phone) + .and.where('case_id', expiredRequest.case_id ) + .update('active', false) + .then(() => messages.send(phone, process.env.TWILIO_PHONE_NUMBER, messages.unableToFindCitationForTooLong(expiredRequest))) + .then(() => knex('notifications') + .insert({ + case_id: expiredRequest.case_id, + phone:expiredRequest.phone, + type:'expired' + }) + .then(() => expiredRequest ) + ) + .catch(err => { + /* The most likely error should be Twilio's error for users who have stopped service, + but other errors like bad numbers are possible. This adds an error to the notification + so end admin user can see if there were send errors on notifications */ + return knex('notifications') + .insert({ + case_id: expiredRequest.case_id, + phone:expiredRequest.phone, + type:'expired', + error: err.message + }) + .then(() => { + logger.error(err) + expiredRequest.error = err + return expiredRequest; + }) + }) +} + +/** + * Finds requests that have matched a real citation for the first time. + * These are identified with the 'know_case = false' flag + * @return {Promise} that resolves to case and request information + */ +function discoverNewCitations() { + return knex.select('*', knex.raw(` + CURRENT_DATE = date_trunc('day', date) as today, + date < CURRENT_TIMESTAMP as has_past`)) + .from('requests') + .innerJoin('hearings', {'requests.case_id': 'hearings.case_id'}) + .where('requests.known_case', false) +} + +/** + * Inform subscriber that we found this case and will send reminders before future hearings. + * Perform both actions inside transaction so if we only update DB if twilio suceeds + * @param {*} request_case object from join of request and case table + */ +async function updateAndNotify(request_case) { + const phone = db.decryptPhone(request_case.phone); + return knex.update({ + 'known_case': true, + 'updated_at': knex.fn.now() + }) + .into('requests') + .where('phone', request_case.phone) + .andWhere('case_id', request_case.case_id ) + .then(() => messages.send(phone, process.env.TWILIO_PHONE_NUMBER, messages.foundItWillRemind(true, request_case))) + .then(() => knex('notifications') + .insert({ + case_id: request_case.case_id, + phone:request_case.phone, + type:'matched' + }) + .then(() => request_case ) + ) + .catch(err => { + /* add error to notification so end admin user can see if there were send errors */ + return knex('notifications') + .insert({ + case_id: request_case.case_id, + phone:request_case.phone, + type:'matched', + error:err.message + }) + .then(() => { + logger.error(err) + request_case.error = err + return request_case + }) + }) +} + +/** + * Hook for processing all requests which have not yet been matched to a real case_id. + * + * @return {Promise} Promise to process all queued messages. + */ + +async function sendUnmatched() { + const matched = await discoverNewCitations() + const matched_sent = await Promise.all(matched.map(r => updateAndNotify(r))) + + const expired = await getExpiredRequests() + const expired_sent = await Promise.all(expired.map((r => notifyExpired(r)))) + + // returning these results to make it easier to log in one place + return {expired: expired_sent, matched: matched_sent } +} + +module.exports = { + sendUnmatched, + getExpiredRequests + +}; diff --git a/test/date_and_time_tests.js b/test/date_and_time_tests.js new file mode 100644 index 0000000..018231e --- /dev/null +++ b/test/date_and_time_tests.js @@ -0,0 +1,110 @@ +'use strict'; +require('dotenv').config(); +const findReminders = require("../sendReminders.js").findReminders; +const expect = require("chai").expect; +const manager = require("../utils/db/manager"); +const db = require('../db'); +const knex = manager.knex; +const moment = require("moment-timezone"); +const TEST_CASE_ID = "1MM-17-00029CR", + TEST_HOURS = [-10,-9,-8,-7,-6,-5,-4,-3,-2,-1,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,23.75,24,24.15,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49], + TEST_UTC_DATE = moment("2015-03-27T08:00:00").tz('America/Anchorage').format(); + +describe("With local dates without timezone", function() { + beforeEach(function() { + return manager.ensureTablesExist() + .then(() => knex("hearings").del()) + }); + + it("Database can read csv date format and gets correct time without timezone", function(){ + const test_date = moment('2014-09-08T10:00:00').tz(process.env.TZ) + const date_string = "09/08/2014 10:00AM" + return knex("hearings").insert([turnerData("", date_string)]) + .then(() => knex.select("*").from("hearings")) + .then(row => expect(moment(row[0].date).toISOString()).to.equal(test_date.toISOString())) + }) + it("Database assumes correct time zone when none is given during DST", function(){ + const test_date = moment('2014-11-08T10:00:00').tz(process.env.TZ) + const date_string = "11/08/2014 10:00AM" + return knex("hearings").insert([turnerData("", date_string)]) + .then(() => knex.select("*").from("hearings")) + .then(row => { + expect(moment(row[0].date).toISOString()).to.equal(test_date.toISOString()) + }) + }) +}) + +describe("For a given date", function() { + beforeEach(function() { + return manager.ensureTablesExist() + .then(() => knex("hearings").del()) + .then(() => knex("requests").del()) + .then(() => knex("hearings").insert([turnerData()])) + .then(() => addTestRequest()) + }); + + it("datetime in table matches datetime on the client", function() { + return knex.select("*").from("hearings").where("date", TEST_UTC_DATE) + .then(results => expect(results.length).to.equal(1)); + }); + + it("datetime matches for all hours in a day", function() { + this.timeout(5000); // This may take a while + const test = function(hr) { + console.log("hr: ", hr) + const testDateTime = moment().add(1, "days").hour(0).minute(0).add(hr, "hours"); + console.log("Now: ", moment().format()); + + return updateHearingDate(TEST_CASE_ID, testDateTime) + .then(findReminders) + .then(function(results) { + if (results[0]) console.log(moment(results[0].date).format(), testDateTime.format()); + if ((hr >= 0) && (hr < 24)) { // Should only find reminders for the next day + console.log("Reminder found for hour ", hr) + expect(results.length).to.equal(1); + expect(moment(results[0].date).format()).to.equal(testDateTime.format()); + } else { + console.log("NO reminder found for hour ", hr) + expect(results.length).to.equal(0); + } + }); + }; + + // test() overwrites DB data with each iteration so it's important that the tests are done sequentially + return TEST_HOURS.reduce((p, hr) => p.then(r => test(hr)), Promise.resolve()) + }); +}); + +function updateHearingDate(caseId, newDate) { + console.log("Updating date to: " + newDate.format()); + return knex("hearings").where("case_id", "=", caseId) + .update({ + "date": newDate.format(), + }) + .then(() => knex('hearings').where("case_id", "=", caseId).select()) + .then(function(results) { + console.log("Stored: ", results[0].date) + return results + }); +} + + +function addTestRequest() { + //console.log("Adding Test Reminder"); + return db.addRequest({ + case_id: TEST_CASE_ID, + phone: "+12223334444", + known_case: true + }); +}; + + +function turnerData(v, d) { + return { + //date: '27-MAR-15', + date: d || TEST_UTC_DATE, + defendant: 'TURNER, FREDERICK T', + room: 'CNVCRT', + case_id: TEST_CASE_ID + (v||"") + }; +}; diff --git a/test/fixtures/acs_cr_event.csv b/test/fixtures/acs_cr_event.csv new file mode 100644 index 0000000..43242c6 --- /dev/null +++ b/test/fixtures/acs_cr_event.csv @@ -0,0 +1,20 @@ +09/27/2017,"Zuboff","Alan","Angoon Courthouse",10:00 am,"1AG-17-00006CR","Calendar Call" +09/27/2017,"Oleman","Jeffery","Courtroom D, Juneau Courthouse", 2:00 pm,"1AG-13-00014CR","Motion Hearing" +12/06/2017,"Petersen","Jan","Courtroom 1, Prince of Wales Courthouse", 2:00 pm,"1CR-15-00034CR","Calendar Call" +12/12/2017,"Loucks","Steven","Courtroom 1, Prince of Wales Courthouse",10:30 am,"1CR-15-00170CR","Change of Plea" +09/19/2017,"Bezemer","Derek","Courtroom 1, Prince of Wales Courthouse", 2:00 pm,"1CR-15-00162CR","Adjudication Hearing" +09/19/2017,"Kasinger","Ronnie","Courtroom 1, Prince of Wales Courthouse", 9:30 am,"1CR-15-00195CR","Change of Plea" +09/28/2017,"Knutson","Morgan","Courtroom A, Haines Courthouse",11:00 am,"1HA-17-00022CR","Calendar Call" +09/19/2017,"Sellards","Donald","Courtroom 1, Prince of Wales Courthouse", 2:00 pm,"1CR-15-00157CR","Adjudication Hearing" +09/19/2017,"Jones","William","Courtroom 1, Prince of Wales Courthouse", 9:00 am,"1CR-15-00243CR","Sentencing Hearing" +10/31/2017,"Anderson","Curtis","Courtroom A, Haines Courthouse", 3:15 pm,"1HA-17-00025CR","Calendar Call" +09/26/2017,"Totland","Tyler","Courtroom A, Haines Courthouse", 3:15 pm,"1HA-17-00029CR","Calendar Call" +09/26/2017,"Totland","Tyler","Courtroom A, Haines Courthouse", 3:30 pm,"1HA-17-00029CR","Further Proceedings" +12/06/2017,"McGraw","Jonathan","Courtroom 1, Prince of Wales Courthouse", 2:00 pm,"1CR-15-00204CR","Calendar Call" +09/19/2017,"Kasinger","Ronnie","Courtroom 1, Prince of Wales Courthouse", 9:30 am,"1CR-15-00187CR","Change of Plea" +12/06/2017,"Locher","Tyler","Courtroom 1, Prince of Wales Courthouse", 2:00 pm,"1CR-15-00141CR","Calendar Call" +09/20/2017,"Alamillo","Gabriel","Courtroom C, Juneau Courthouse", 3:00 pm,"1HA-15-00015CR","Adjudication Hearing: Superior Court" +12/19/2017,"Brower","Stephen","Courtroom C, Juneau Courthouse", 2:30 pm,"1HA-16-00067CR","Sentencing: Superior Court" +10/24/2017,"Whittington","William","Courtroom A, Haines Courthouse",11:00 am,"1HA-16-00052CR","Calendar Call" +10/24/2017,"Whittington","William","Courtroom A, Haines Courthouse",11:00 am,"1HA-16-00052CR","Calendar Call" +10/24/2017,"Whittington","William","Courtroom A, Haines Courthouse",11:00 am,"1HA-14-00004CR","Further Proceedings" diff --git a/test/fixtures/acs_mo_event.csv b/test/fixtures/acs_mo_event.csv new file mode 100644 index 0000000..afb0bed --- /dev/null +++ b/test/fixtures/acs_mo_event.csv @@ -0,0 +1,38 @@ +10/04/2016,Nanok,Gabriel,,"Courtroom 2, Bethel Courthouse", 9:30 am,BETZP00411628,,AS11.56.757(a): Violate Condition Of Release, +09/20/2016,Dunlap,Christopher,,Petersburg Courthouse, 3:30 pm,PEFEP00391416,,AS28.35.140(a): Obstruct Or Blocking Traffic, +08/22/2016,Rios,Joseph,,"Courtroom A, Juneau Courthouse", 1:00 pm,JUNZE000002850050,,CBJ72.02.125: Fail to Yield when Turning Left, +09/19/2016,Knight,Lawton,,"Courtroom A, Juneau Courthouse", 3:45 pm,JUNZE000009140038,,CBJ72.04.230(b)(3): Studded Tires (4/15-9/30), +08/22/2016,Petratrovich,Leroy,,"Courtroom B, Juneau Courthouse", 3:30 pm,JUNZP00409275,,CBJ72.10.020: Negligent Driving, +09/15/2016,Karmun,Wilbur,,Kotzebue Courthouse, 3:00 pm,KOTZP00393959,,KBMC09.76.050(A)(1): Curfew Violation By Minor/Parent/Guardian-1st Offense, +09/22/2016,Clauson,Brenda,,"Courtroom 1, Craig Courthouse",10:00 am,CRFEP00363431,,5AAC92.050(a)(8): Failure To Submit Drawing Permit Hunt/Tier Permit Hunt, +08/18/2016,Lam,Yuen Sun,,"Courtroom 1, Nenana Courthouse", 3:00 pm,CANEE000038810093,,13AAC02.275(b): Speeding (10-19 MPH Over), +09/08/2016,Bracht,Jackie,,Tok Courthouse, 1:30 pm,NOTEE000007410290,,AS28.10.471: Operating Vehicle w/ Expired Registration, +10/04/2016,Abruska,Aaron,,"Courtroom 2, Bethel Courthouse", 9:30 am,BETZP00411962,,AS11.56.757(a): Violate Condition Of Release, +09/12/2016,Sadeghi,Anies,,"Courtroom A, Juneau Courthouse", 3:45 pm,JUFEP00400948,,5AAC75.076(C): Failure To Complete Log Book As Required (Sport Fishing Guide Statewide), +08/11/2016,Patterson,Drew,,"Courtroom B, Juneau Courthouse",11:00 am,JUFEP00415426,,5AAC33.310(a)(10): Comm Fishing-Seasons/ periods for net gear-established by emergncy order-3rd+ Off, +08/25/2016,Hotch,Donald,,"Courtroom A, Haines Courthouse",10:00 am,JUNEE000004700233,,AS28.35.410: Negligent Driving Not CMV, +08/10/2016,Ruben,John,,"Courtroom 1, Craig Courthouse", 1:30 pm,CRFEP00400730,,5AAC75.035(1): ID Requirements For Shellfish Sport Fishing Gear, +08/22/2016,Paniptchuk,Corwen,,Unalakleet Courthouse,11:00 am,WOFEP00421284,,5AAC85.045(a)(20): Hunting Seasons And Bag Limits For Moose - Game Unit 22, +08/24/2016,Malone,Daniel,,"Courtroom 406, Ketchikan Courthouse", 1:00 pm,KETZP00410608,,KMC9.54.050(b): Off-Premises Commercial Solicitation-Obstruct/Interfere w/ Pedestrian-1st Offense, +09/19/2016,Knight,Lawton,,"Courtroom B, Juneau Courthouse", 3:45 pm,JUNZE000009140038,,CBJ72.02.010(a)(3)(A): Stop for a Steady Red Light, +08/24/2016,Hatzipetros,Nicholas,,"Courtroom B, Juneau Courthouse", 9:00 am,JUNZP00330758,,CBJ36.30.230(a): Abandoned/Junked Vehicles on Public Property, +08/15/2016,Borgerding,Hannah,,"Courtroom 1, Nenana Courthouse", 1:30 pm,CANEE000021830145,,13AAC02.275(b): Speeding (4-9 MPH Over), +11/07/2016,Bourdon,Eugene,,"Courtroom A, Juneau Courthouse", 3:45 pm,JUFEP00395578,,AS16.05.330(a)(1): Sport Fish w/o Lic In Possession, +09/28/2016,Mccourt,Brock,,"Courtroom B, Juneau Courthouse", 8:30 am,JUNZE000005590034,,CBJ72.02.055: Improper Overtaking on Right, +08/16/2016,Guthrie,Michael,,Ketchikan Courthouse,10:00 am,KETEE000003760307,,5AAC92.230(a)(1): Feeding Game, +09/20/2016,Krantz,Ashleigh,,Ketchikan Courthouse, 9:00 am,KETZP00410587,,AS28.15.291(a)(2): Drive w/ License Cancelled/Suspended/Revoked, +08/18/2016,Farnell,Teresa Marie,,"Courtroom 1, Nenana Courthouse", 3:00 pm,CANEE000039310093,,13AAC02.275(b): Speeding (20+ MPH Over), +09/20/2016,Haynes,Bradley,,Ketchikan Courthouse, 9:30 am,JUFEP00415232,,5AAC33.310(a): Commercial Fish Seasons/Periods for Net Gear-Established by Emergency Order-1st Off, +08/10/2016,Sidney,Misty,,"Courtroom A, Juneau Courthouse", 9:00 am,JUNZP00421963,,CBJ08.40.010: Dog at Large-3rd+ Offense, +08/10/2016,Nook,Randy,,Aniak Courtroom, 9:10 am,ANIEP00377130,,AS11.51.110(a)(2): Endanger Welfare Child 2-Impaired By Intoxicant, +08/23/2016,White,Brett,,Ketchikan Courthouse,10:00 am,KETEE000003770311,,AS28.22.019: Proof Of Insurance To Be Carried And Exhibited On Demand (Correctable), +09/26/2016,Smith,Charlie,,"Courtroom 2, Bethel Courthouse", 8:30 am,BETEE000001810076,,AS28.15.011(b): Driving With License Expired Less Than One Year, +08/10/2016,Garcia,Michael,,"Courtroom A, Juneau Courthouse", 9:00 am,JUNZE000006560047,,CBJ72.02.275(b): Speeding in Excess of Limit Set in Ordinance (21+ MPH Over), +08/30/2016,Day,Chance,,Petersburg Courthouse,10:00 am,PEFEP00391415,,5AAC92.044(b)(10): Failure To Clean Bear Bait Station, +11/21/2016,Baranovic,Lucas,,"Courtroom A, Juneau Courthouse", 3:45 pm,JUNZE000002620050,,CBJ72.02.130(a): Fail to Stop at a Stop Sign/Yield to Vehicle In Intersection, +08/18/2016,Phelps,Dylan,,"Courtroom 1, Nenana Courthouse", 3:00 pm,CANEE000038930093,,13AAC02.130(b): Failure to Stop for Stop Sign, +09/06/2016,Lewis,Jacob,,Ketchikan Courthouse, 9:00 am,KETZP00410583,,KMC9.54.050(b): Off-Premises Commercial Solicitation-Obstruct/Interfere w/ Pedestrian-1st Offense, +09/06/2016,Lewis,Jacob,,Ketchikan Courthouse, 11:00 am,KETZP00410583,,KMC9.54.050(b): Off-Premises Commercial Solicitation-Obstruct/Interfere w/ Pedestrian-1st Offense, +08/24/2016,Montero,George,,"Courtroom B, Juneau Courthouse", 8:30 am,JUNZE000005440042,,CBJ72.02.275(b): Speeding in Excess of Limit Set in Ordinance (10-19 MPH Over), +08/30/2016,Ben Simon,Edan,,Ketchikan Courthouse, 9:00 am,KETZP00356921,,KMC9.54.050(b): Off-Premises Commercial Solicitation-Obstruct/Interfere w/ Pedestrian-1st Offense, +08/16/2016,Guthrie,Michael,,Ketchikan Courthouse,10:00 am,KETEE000003760307,,5AAC92.230(a)(1): Baiting, diff --git a/test/fixtures/codeamerica.03012015.csv b/test/fixtures/codeamerica.03012015.csv deleted file mode 100644 index dd0b1af..0000000 --- a/test/fixtures/codeamerica.03012015.csv +++ /dev/null @@ -1,41 +0,0 @@ -20-MAR-15|BARBER, DIANA S.|MITCHELL STREET/ ELLIOTT STREET|CNVCRT|03:00:00 PM|4736480|40-6-49|FOLLOWING TOO CLOSELY|0 -02-APR-15|HEMPHILL, DAVID S|CLEVELAND AV|6D|10:00:00 AM|4882976|40-5-29|FAILURE TO CARRY/EXHIBIT LIC|0 -12-MAR-15|THOMAS, FREDERICK V|27 EAST LAKE DR|3B|08:00:00 AM|2241746|154-70(D)|INTERFERING W/ CITY WATER SYSTEM USING WATER W/O PERMISSION|0 -18-MAR-15|DOYLE, LUCRETIA|I75/85 SB N OF GA HWY 166|JRYASM|11:00:00 AM|E01674112|40-6-181(E)|SPEEDING 19 to 23 MPH OVER|1 -17-MAR-15|TELL, DARYL ORLANDO|PTREE RD|CNVCRT|03:00:00 PM|4961187|40-6-181(C)|SPEEDING 11 to 14 MPH OVER|1 -02-MAR-15|MORRIS, RODDRICK L|75 SB|6A|08:00:00 AM|4878515|40-6-181(F).1|SPEEDING 24 to 30 MPH OVER|1 -01-APR-15|GOOLSBY, TRAVAR D|FORRIL PATH RD|JRYASM|11:00:00 AM|4884010|40-6-72.B|FAILURE TO STOP FOR STOP SIGN|1 -17-MAR-15|HICKMAN, ISAAC|PRYOR/UNIVERSITY|CNVCRT|08:00:00 AM|4842420|40-8-76.1|SAFETY BELT VIOLATION|1 -09-MAR-15|OWENS, VICTOR JAREEM|GA 400NB/ LENOX RD|6C|08:00:00 AM|4962992|40-2-8|NO TAG/ NO DECAL|0 -02-MAR-15|WILLIAMS, ROLAND HAMILTON|I-75 NB HOWELL MILL|6A|08:00:00 AM|E01768397|40-2-8|NO TAG/ NO DECAL|0 -20-MAR-15|BODDIE, PATRICIA A|METROPOLITAN PKWY|6C|10:00:00 AM|4883344|40-6-20|FAIL TO OBEY TRAF CTRL DEVICE|1 -08-APR-15|TERRELL, SHERIDAN MAURICE|--|JRYASM|11:00:00 AM|4942355|40-8-31|FAILURE TO DIM HEADLIGHTS|1 -05-MAR-15|HAZELL, CLYDE|RDA.JLOW|6B|01:00:00 PM|4835163|40-8-76.1|SAFETY BELT VIOLATION|1 -18-MAR-15|ABERNATHY, ANDREW|3434 ROSWELL RD|5A|08:00:00 AM|4873467|40-6-391.A1|D.U.I./ALCOHOL (40-6-391.A1)|0 -18-MAR-15|ABERNATHY, ANDREW|3434 ROSWELL RD|5A|08:00:00 AM|4873472|40-6-391(A)5|D.U.I. PERSON CONCENTRATION (40-6-391(A)5)|0 -17-MAR-15|REDWINE, CHARLES WILLIAM|EDGEWOOD/ DANIEL|CNVCRT|03:00:00 PM|4928278|40-8-76.1|SAFETY BELT VIOLATION|1 -10-MAR-15|THOMAS, CRYSTAL C|STARMIST DR|6B|10:00:00 AM|4912114|40-2-5|TAG USE TO CONCEAL VEHICLE ID|0 -02-MAR-15|GROOMES, DONALD J|I-75N/ S OF MOORES MILL RD|6A|08:00:00 AM|E01761243|40-6-181(F).1|SPEEDING 24 to 30 MPH OVER|1 -26-MAR-15|ALBIGESE, JOHN ANTHONY|372 MORELAND|CNVCRT|08:00:00 AM|4904148|40-6-72(B)|FAILURE TO STOP FOR STOP SIGN|1 -19-MAR-15|SHIGUTE, ADDISU K|14TH ST|3B|08:00:00 AM|4947935|40-6-202|S/S OR PARKING OUTSIDE BUS/RES-BLOCKING ST.|0 -11-MAR-15|CARINI, SUSAN M|MORLEAND AVENEU /GLENWOOD AVEE|6A|08:00:00 AM|4906301|40-6-49|FOLLOWING TOO CLOSELY|0 -04-MAR-15|KILBY, JULIET D|13TH|6D|08:00:00 AM|308666293|150-86|GENERAL PARKING VIOLATION/IMPROPER PARKING|1 -18-MAR-15|GRAHAM, GREG WARREN|I75N/NORTHSIDE DR|CNVCRT|01:00:00 PM|E01793115|40-6-181(F).1|SPEEDING 24 to 30 MPH OVER|1 -30-MAR-15|WEAVER, LESLIE C|METROPOLITAN/LAKEWOOD|CNVCRT|03:00:00 PM|4944633|40-8-26|NO BRAKE LGTS OR WORK/TURN SIG|1 -31-MAR-15|HARDY JR., ROOSEVELT|CUSTER/BOULEVARD|JRYASM|11:00:00 AM|4905302|40-2-8|NO TAG/ NO DECAL|0 -23-JUN-15|RICHARDSON, RAYNARD R|CAMPBELLTON RD /KIMBERLY RD|6D|03:00:00 PM|4738033|40-6-49|FOLLOWING TOO CLOSELY|0 -03-APR-15|JONES, WARREN DEREK|THURMOND ST|1B|03:00:00 PM|4848904|40-6-181(D)|SPEEDING 15 to 18 MPH OVER|1 -04-MAR-15|EDWARDS, GLORIA J|1849 D L HOLLOWELL PKWY|6A|08:00:00 AM|4840202|40-6-181(C)|SPEEDING 11 to 14 MPH OVER|1 -04-MAR-15|WALLS, GAYLE D|1079 ISA DR|3B|08:00:00 AM|2293241|E.25-A|OWNER/TENANT RESPON FOR CLEAN|0 -24-JUL-15|BOVELL, DIRK D|75/85 nb @ 14th st|3B|10:00:00 AM|5001370|40-6-181(G)|SPEEDING 34 MPH AND OVER|0 -24-JUL-15|BOVELL, DIRK D|75/85 nb @ 14th st|3B|10:00:00 AM|0700137|40-2-8|NO TAG/ NO DECAL|0 -02-MAR-15|WARD, CHRISTOPHER L|I75/85 NB/ANDREW YOUNG INTERNATIONAL|6B|03:00:00 PM|4736837|40-6-49|FOLLOWING TOO CLOSELY|0 -31-JUL-15|LAWRENCE, CHRISTIE L|marietta/wright|3B|10:00:00 AM|2355274|40-5-20|NO DRIVERS LICENSE|0 -05-MAR-15|WILLIAMS, DERWIN T|I 20 W B /NORELAND AVE EXIT|6D|08:00:00 AM|4939041|40-6-49|FOLLOWING TOO CLOSELY|0 -16-MAR-15|RUCKER, SEAN D|UNIVERSITY AVE/ MCDONOUGH BLVD|JRYASM|11:00:00 AM|4849358|40-2-8|NO TAG/ NO DECAL|0 -16-MAR-15|RUCKER, SEAN D|UNIVERSITY AVE/ MCDONOUGH BLVD|JRYASM|11:00:00 AM|4849359|40-2-8.1|OPERATING VEHICLE WITHOUT REVALIDATION DECAL ON LICENSE PLATE|0 -20-MAR-15|HESTER, MICHAEL F|CLEVELAND AV|CNVCRT|03:00:00 PM|4943925|40-6-49|FOLLOWING TOO CLOSELY|0 -02-APR-15|MOSS, LATISHA R|2185 MLK JR DR|JRYASM|11:00:00 AM|4897724|40-6-15|OPER OF VEH WHILE REGISTRATION SUSPENDED|0 -27-MAR-15|TURNER, FREDERICK T|27 DECAATUR ST|CNVCRT|01:00:00 PM|4928456|40-8-76.1|SAFETY BELT VIOLATION|1 -17-MAR-15|HUNTER, STEPHEN E|1355 R D A|CNVCRT|03:00:00 PM|4681874|40-6-10.|FAILURE TO MAINTAIN INSURANCE|0 -07-APR-15|ROMANT JR, JOSEPH J|RDA|CNVCRT|03:00:00 PM|4931427|40-8-76.1|SAFETY BELT VIOLATION|1 diff --git a/test/load_data_test.js b/test/load_data_test.js index a8f6f1a..0fefcfe 100644 --- a/test/load_data_test.js +++ b/test/load_data_test.js @@ -1,79 +1,127 @@ -var expect = require("chai").expect; -var assert = require("chai").assert; -var nock = require('nock'); -var tk = require('timekeeper'); -var fs = require('fs'); -var Promise = require('bluebird'); -var moment = require("moment"); - -var Knex = require('knex'); -var knex = Knex.initialize({ - client: 'pg', - connection: process.env.DATABASE_URL -}); +'use strict'; +/* eslint "no-console": "off" */ -describe("Loading of Data", function() { - beforeEach(function() { - var time = new Date(1425297600000); // Freeze to March 2, 2015. Yesterday is March 1 - tk.freeze(time); - }); +// see https://mochajs.org/#arrow-functions +/* eslint-env mocha */ +/* eslint arrow-body-style: ["warn", "as-needed"] */ +/* eslint func-names: "off" */ +/* eslint prefer-arrow-callback: "off" */ - describe("With a 404 on the CSV", function() { - nock('http://courtview.atlantaga.gov') - .get('/courtcalendars/court_online_calendar/codeamerica.03012015.csv') - .reply(404); +require('dotenv').config(); +const expect = require('chai').expect; +const assert = require('chai').assert; +const nock = require('nock'); +const fs = require('fs'); +const url = require('url'); +const manager = require('../utils/db/manager'); +const loadData = require('../utils/loaddata'); - it("hits the error callback with a 404 message", function() { - return require("../utils/loaddata")().then(assert.failed, function(err) { - expect(err).to.include("404 page not found"); - }); - }); - }); +const knex = manager.knex; +const MOCKED_DATA_URL = 'http://courtrecords.alaska.gov/MAJIC/sandbox/acs_mo_event.csv|civil_cases,http://courtrecords.alaska.gov/MAJIC/sandbox/acs_cr_event.csv|criminal_cases'; +const dataUrls = MOCKED_DATA_URL.split(','); + +function dataHostname(dataUrl) { + return `http://${url.parse(dataUrl.split('|')[0]).hostname}`; +} - describe("With a 200 on the CSV", function() { +function dataPath(dataUrl) { + return url.parse(dataUrl.split('|')[0]).pathname; +} + +dataUrls.forEach((dataUrl) => { + console.log('Host: ', dataHostname(dataUrl), 'Path: ', dataPath(dataUrl)); +}); + +describe('Loading of Data', function () { beforeEach(function() { - nock('http://courtview.atlantaga.gov') - .get('/courtcalendars/court_online_calendar/codeamerica.03012015.csv') - .reply(200, function() { - return fs.createReadStream('test/fixtures/codeamerica.03012015.csv'); + return manager.ensureTablesExist() + .then(() => knex("hearings").del()) + }); + + describe('With a 404 on the CSV', function () { + beforeEach(function(){ + dataUrls.forEach((dataUrl) => { + nock(dataHostname(dataUrl)) + .get(dataPath(dataUrl)) + .reply(404); + }); + }) + afterEach(nock.cleanAll) + + it('hits the error callback with a 404 message', function () { + return loadData(MOCKED_DATA_URL).then(assert.failed, (err) => { + console.log("error: ", err.message) + expect(err.message).to.include('HTTP Status: 404'); + }); }); + + it('leaves current hearings table intact', function(){ + return knex('hearings').insert({date: '2017-01-01', room: 'test room', case_id: '112233', defendant:'Jane Doe'}) + .then(() => loadData(MOCKED_DATA_URL)) + .then(assert.failed, () => knex('hearings').select('*')) + .then(rows => { + expect(rows.length).to.equal(1) + }) + }) + }); - it("hits the success callback correctly", function() { - return require("../utils/loaddata")().then(function(resp) { - expect(resp).to.equal(true); - }, assert.failed); + describe('With a 200 on the CSV', function () { + beforeEach(() => { + dataUrls.forEach((dataUrl) => { + const path = dataPath(dataUrl); + nock(dataHostname(dataUrl)) + .get(path) + .reply(200, () => fs.createReadStream(`test/fixtures/${path.split('/').reverse()[0]}`)); + }); + }); + + it('hits the success callback correctly', function () { + return loadData(MOCKED_DATA_URL) + .then(resp => expect(resp).to.deep.equal({files: 2, records: 55})); + }); + + it('creates 55 cases', function () { + // 38 lines, two sets of duplicates in first file + // 20 lines, one set of duplicates in second file + return loadData(MOCKED_DATA_URL) + .then(() => knex('hearings').count('* as count')) + .then(rows => expect(rows[0].count).to.equal('55')); }); - it("creates 38 cases", function() { // there are 41 rows but 3 are repeats - return require("../utils/loaddata")().then(function(resp) { - return knex("cases").count('* as count').then(function(rows) { - expect(rows[0].count).to.equal('38'); - }, assert.failed); - }, assert.failed); + it('properly manages a single defendant', function () { + return loadData(MOCKED_DATA_URL) + .then(() => knex('hearings').where({ defendant: 'Christopher Dunlap' })) + .then((rows) => { + expect(rows[0].defendant).to.equal('Christopher Dunlap'); + expect(rows[0].room).to.equal('Petersburg Courthouse'); + expect(rows[0].case_id).to.equal('PEFEP00391416'); + }); }); - it("properly manages a single defendant", function() { - return require("../utils/loaddata")().then(function(resp) { - return knex("cases").where({ defendant: "BARBER, DIANA S."}).then(function(rows) { - expect(rows[0].defendant).to.equal('BARBER, DIANA S.'); - expect(rows[0].room).to.equal('CNVCRT'); - expect(rows[0].citations.length).to.equal(1); - expect(rows[0].citations[0].id).to.equal('4736480'); - }, assert.failed); - }, assert.failed); + it('properly manages a multiple hearings on same day for same case ID', function () { + return loadData(MOCKED_DATA_URL) + .then(() => knex('hearings').where({ case_id: 'KETZP00410583' })) + .then((rows) => { + expect(rows.length).to.equal(2) + expect(rows[0].defendant).to.equal('Jacob Lewis'); + expect(rows[1].defendant).to.equal('Jacob Lewis'); + expect(rows[0].room).to.equal('Ketchikan Courthouse'); + }); }); - it("properly manages a duplicate defendant", function() { - return require("../utils/loaddata")().then(function(resp) { - return knex("cases").where({ defendant: "RUCKER, SEAN D"}).then(function(rows) { - expect(rows[0].defendant).to.equal('RUCKER, SEAN D'); - expect(rows[0].room).to.equal('JRYASM'); - expect(rows[0].citations.length).to.equal(2); - expect(rows[0].citations[0].id).to.equal('4849358'); - expect(rows[0].citations[1].id).to.equal('4849359'); - }, assert.failed); - }, assert.failed); + it('properly manages a criminal case', function () { + return loadData(MOCKED_DATA_URL) + .then(() => knex('hearings').where({ defendant: 'Tyler Totland' })) + .then((rows) => { + for (let i = 0; i < 2; i++) { + expect(rows[i].defendant).to.equal('Tyler Totland'); + expect(rows[i].room).to.equal('Courtroom A, Haines Courthouse'); + expect(rows[i].case_id).to.equal('1HA-17-00029CR'); + } + // hearing types differ + expect(rows[0].type).to.not.equal(rows[1].type); + }); }); }); }); diff --git a/test/log_test.js b/test/log_test.js new file mode 100644 index 0000000..0fc3b29 --- /dev/null +++ b/test/log_test.js @@ -0,0 +1,78 @@ +'use strict'; +require('dotenv').config(); +const sendUnmatched = require("../sendUnmatched.js").sendUnmatched; +const expect = require("chai").expect; +const assert = require("chai").assert; +const moment = require("moment-timezone") +const manager = require("../utils/db/manager"); +const db = require('../db'); +const knex = manager.knex; +const sinon = require('sinon') +const messages = require('../utils/messages') +const app = require('../web'); +const session = require('supertest-session'); +const Rollbar = require('rollbar'); +const log = require('../utils/logger') + + +describe("Endpoint requests", function(){ + let sess; + const params = { Body: "A4928456", From: "+12223334444" }; + + beforeEach(function() { + sess = session(app); + return knex('log_hits').del() + }) + afterEach(function(){ + sess.destroy(); + }) + it("should create row in log_hits table", function(done){ + sess.post('/sms').send(params) + .expect(200) + .end(function (err, res) { + if (err) return done(err); + setTimeout(() =>{ // fix this once new winston event emitters are working + knex('log_hits').select('*') + .then(rows => { + expect(rows.length).to.equal(1) + expect(rows[0].phone).to.equal(db.encryptPhone(params.From)) + expect(rows[0].body).to.equal(params.Body) + expect(rows[0].action).to.equal('unmatched_case') + expect(rows[0].path).to.equal('/sms') + }) + .then(done) + .catch(done) + }, 200) + + }); + }) + it("should create a row with error status for non-200 responses", function(done){ + const bad_path = '/sms0jhkjhkjhdfsf090mfl' + sess.get(bad_path) + .expect(404) + .end(function (err, res) { + if (err) return done(err); + setTimeout(() =>{ // fix this once new winston event emitters are working + knex('log_hits').select('*') + .then(rows => { + expect(rows.length).to.equal(1) + expect(rows[0].status_code).to.equal('404') + expect(rows[0].path).to.equal(bad_path) + expect(rows[0].action).to.be.null + }) + .then(done) + .catch(done) + }, 200) + }); + }) +}) + +describe("Error level logs", function(){ + it('should be sent to Rollbar', function(){ + const rollbarStub = sinon.stub(Rollbar.prototype, "error") + const err = new Error("whoops") + log.error(err) + sinon.assert.calledOnce(rollbarStub) + sinon.assert.calledWith(rollbarStub, err) + } ) +}) \ No newline at end of file diff --git a/test/send_queued_test.js b/test/send_queued_test.js deleted file mode 100644 index 6ff8149..0000000 --- a/test/send_queued_test.js +++ /dev/null @@ -1,125 +0,0 @@ -process.env.COOKIE_SECRET="test"; -process.env.PHONE_ENCRYPTION_KEY = "phone_encryption_key"; -process.env.TWILIO_ACCOUNT_SID = "test"; -process.env.TWILIO_AUTH_TOKEN = "token"; -process.env.TWILIO_PHONE_NUMBER = "+test"; - -var sendQueued = require("../sendQueued.js"); -var expect = require("chai").expect; -var assert = require("chai").assert; -var nock = require('nock'); -var moment = require("moment"); - -var db = require('../db'); -var Knex = require('knex'); -var knex = Knex.initialize({ - client: 'pg', - connection: process.env.DATABASE_URL -}); - -nock.disableNetConnect(); -//nock('https://api.twilio.com').log(console.log); - -describe("with 2 valid queued cases (same citation)", function() { - beforeEach(function(done) { - knex('cases').del().then(function() { - knex('cases').insert([turnerData()]).then(function() { - knex("queued").del().then(function() { - db.addQueued({ - citationId: "4928456", - phone: "+12223334444" - }, function(err, data) { - db.addQueued({ - citationId: "4928456", - phone: "+12223334444" - }, function(err, data) { - done(err); - }); - }); - }); - }); - }); - }); - - it("sends the correct info to Twilio and updates the queued to sent", function(done) { - var number = "+12223334444"; - var message = "Your Atlanta Municipal Court information was found: a court case for " + - "Frederick T Turner on Thursday, Mar 26th at 01:00:00 PM, in courtroom CNVCRT. " + - "Call us at (404) 954-7914 with any questions."; - - nock('https://api.twilio.com:443') - .post('/2010-04-01/Accounts/test/Messages.json', "To=" + encodeURIComponent(number) + "&From=%2Btest&Body=" + encodeURIComponent(message)) - .reply(200, {"status":200}, { 'access-control-allow-credentials': 'true'}); - - nock('https://api.twilio.com:443') - .post('/2010-04-01/Accounts/test/Messages.json', "To=" + encodeURIComponent(number) + "&From=%2Btest&Body=" + encodeURIComponent(message)) - .reply(200, {"status":200}, { 'access-control-allow-credentials': 'true'}); - - sendQueued().then(function(res) { - knex("queued").select("*").then(function(rows) { - expect(rows[0].sent).to.equal(true); - expect(rows[1].sent).to.equal(true); - done(); - }).catch(done); - }, done); - }); -}); - -describe("with a queued non-existent case", function() { - beforeEach(function(done) { - knex('cases').del().then(function() { - knex('cases').insert([turnerData()]).then(function() { - knex("queued").del().then(function() { - db.addQueued({ - citationId: "123", - phone: "+12223334444" - }, function(err, data) { - done(err); - }); - }); - }); - }); - }); - - it("doesn't do anything < 16 days", function(done) { - sendQueued().then(function(res) { - knex("queued").select("*").then(function(rows) { - expect(rows[0].sent).to.equal(false); - done(); - }).catch(done); - }, done); - }); - - it("sends a failure sms after 16 days", function(done) { - var number = "+12223334444"; - var message = "We haven't been able to find your court case. Please call us at (404) 954-7914. -Atlanta Municipal Court"; - - nock('https://api.twilio.com:443') - .post('/2010-04-01/Accounts/test/Messages.json', "To=" + encodeURIComponent(number) + "&From=%2Btest&Body=" + encodeURIComponent(message)) - .reply(200, {"status":200}, { 'access-control-allow-credentials': 'true'}); - - - knex("queued").update({created_at: moment().subtract(18, 'days')}).then(function() { - sendQueued().then(function(res) { - knex("queued").select("*").then(function(rows) { - expect(rows[0].sent).to.equal(true); - done(); - }).catch(done); - }, done); - }); - }); -}); - -function turnerData(v, payable) { - if (payable === undefined) { - payable = true; - } - - return { date: '27-MAR-15', - defendant: 'TURNER, FREDERICK T', - room: 'CNVCRT', - time: '01:00:00 PM', - citations: '[{"id":"4928456","violation":"40-8-76.1","description":"SAFETY BELT VIOLATION","location":"27 DECAATUR ST","payable":"' + (payable ? 1 : 0) + '"}]', - id: '677167760f89d6f6ddf7ed19ccb63c15486a0eab' + (v||"") - }; -} diff --git a/test/send_reminders_test.js b/test/send_reminders_test.js index 17e06b1..a20ff73 100644 --- a/test/send_reminders_test.js +++ b/test/send_reminders_test.js @@ -1,75 +1,237 @@ -process.env.COOKIE_SECRET="test"; -process.env.PHONE_ENCRYPTION_KEY = "phone_encryption_key"; -process.env.TWILIO_ACCOUNT_SID = "test"; -process.env.TWILIO_AUTH_TOKEN = "token"; -process.env.TWILIO_PHONE_NUMBER = "+test"; - -var sendReminders = require("../sendReminders.js"); -var expect = require("chai").expect; -var assert = require("chai").assert; -var nock = require('nock'); -var moment = require("moment"); - -var db = require('../db'); -var Knex = require('knex'); -var knex = Knex.initialize({ - client: 'pg', - connection: process.env.DATABASE_URL +'use strict'; +require('dotenv').config(); +const sr = require("../sendReminders.js"); +const sendReminders = sr.sendReminders; +const findReminders = sr.findReminders; +const expect = require("chai").expect; +const sinon = require('sinon') +const manager = require("../utils/db/manager"); +const db = require('../db'); +const knex = manager.knex; +const messages = require('../utils/messages') +const moment = require('moment-timezone') +const TEST_CASE_ID = "677167760f89d6f6ddf7ed19ccb63c15486a0eab", + TOMORROW_DATE = moment(14, 'HH').tz(process.env.TZ).add(1, 'days'), // 2:00pm tomorrow + TEST_UTC_DATE = moment("2015-03-27T08:00:00").tz(process.env.TZ).format(); +// todo test that reminders are not sent when notification indicates its already sent + +describe("with one reminder that hasn't been sent", function() { + let messageStub + + beforeEach(function () { + messageStub = sinon.stub(messages, 'send') + messageStub.resolves(true) + + return manager.ensureTablesExist() + .then(clearTable("hearings")) + .then(clearTable("requests")) + .then(clearTable("notifications")) + .then(loadHearings([case1])) + .then(addTestRequests([request1])) + }); + + afterEach(function() { + messageStub.restore() + }); + + it("sends the correct info to Twilio and adds a notification", function() { + var message = `Courtesy reminder: Frederick T Turner has a court hearing on ${TOMORROW_DATE.format('ddd, MMM Do')} at 2:00 PM, at CNVCRT for case/ticket number: ${case1.case_id}. You should verify your hearing date and time by going to ${process.env.COURT_PUBLIC_URL}. - ${process.env.COURT_NAME}`; + return sendReminders() + .then(rows => { + sinon.assert.calledWith(messageStub, request1.phone, process.env.TWILIO_PHONE_NUMBER, message) + }); + }); + + it("sending reminder adds a notification with the correct case, phone, and time", function(){ + return sendReminders() + .then(() => knex("notifications").where({ case_id: case1.case_id }).select("*")) + .then(function (rows) { + expect(rows.length).to.equal(1); + expect(rows[0].phone).to.equal(db.encryptPhone(request1.phone)) + expect(moment(rows[0].event_date).tz(process.env.TZ).toISOString()).to.equal(moment(14, 'HH').tz(process.env.TZ).add(1, 'days').toISOString()) + }) + }) }); -nock.disableNetConnect(); -//nock('https://api.twilio.com').log(console.log); - -describe("with a reminder that hasn't been sent", function() { - beforeEach(function(done) { - knex('cases').del() - .then(function() { - return knex('cases').insert([turnerData()]) - }) - .then(function() { - return knex('reminders').del() - }) - .then(function() { - return db.addReminder({ - caseId: "677167760f89d6f6ddf7ed19ccb63c15486a0eab", - phone: "+12223334444", - originalCase: turnerData() - }, function(err, data) { - done(err); +describe("when there is an error sending the message", function(){ + let messageStub + const errorString = "an error occured" + beforeEach(function () { + messageStub = sinon.stub(messages, 'send') + + messageStub.rejects(new Error(errorString)) + + return manager.ensureTablesExist() + .then(clearTable("hearings")) + .then(clearTable("requests")) + .then(clearTable("notifications")) + .then(loadHearings([case1])) + .then(addTestRequests([request1])) + }); + + afterEach(function() { + messageStub.restore() + }); + + it("records the error in the notification", function(){ + var message = `Courtesy reminder: Frederick T Turner has a court hearing on ${TOMORROW_DATE.format('ddd, MMM Do')} at 2:00 PM, at CNVCRT for case/ticket number: ${case1.case_id}. You should verfify your hearing date and time by going to ${process.env.COURT_PUBLIC_URL}. - ${process.env.COURT_NAME}`; + return sendReminders() + .then(res => knex("notifications").whereIn('case_id', [case1['case_id'], case2['case_id']]).select("*")) + .then(rows => { + expect(rows[0].error).to.equal(errorString) }); - }); - }); - - it("sends the correct info to Twilio and updates the reminder to sent", function(done) { - var number = "+12223334444"; - var message = "Reminder: You've got a court case tomorrow at 01:00:00 PM in court room CNVCRT." + - " Call us at (404) 954-7914 with any questions. -Atlanta Municipal Court"; - - nock('https://api.twilio.com:443') - .post('/2010-04-01/Accounts/test/Messages.json', "To=" + encodeURIComponent(number) + "&From=%2Btest&Body=" + encodeURIComponent(message)) - .reply(200, {"status":200}, { 'access-control-allow-credentials': 'true'}); - - knex("cases").update({date: moment().add(1, 'days')}).then(function() { - sendReminders().then(function(res) { - knex("reminders").select("*").then(function(rows) { - expect(rows[0].sent).to.equal(true); - done(); - }).catch(done); - }); - }, done); - }); + }) + +}) + +describe("with three reminders (including one duplicate) that haven't been sent", function () { + let messageMock + + beforeEach(function () { + messageMock = sinon.mock(messages) + + return manager.ensureTablesExist() + .then(clearTable("hearings")) + .then(clearTable("requests")) + .then(clearTable("notifications")) + .then(loadHearings([case1, case2])) + .then(addTestRequests([request1, request2, request2_dup])) + }); + + afterEach(function() { + messageMock.restore() + }); + + it("sends the correct info to Twilio, adds notification, and skips duplicate request", function () { + var message1 = `Courtesy reminder: Frederick T Turner has a court hearing on ${TOMORROW_DATE.format('ddd, MMM Do')} at 2:00 PM, at CNVCRT for case/ticket number: ${case1.case_id}. You should verify your hearing date and time by going to ${process.env.COURT_PUBLIC_URL}. - ${process.env.COURT_NAME}`; + var message2 = `Courtesy reminder: Bob J Smith has a court hearing on ${TOMORROW_DATE.format('ddd, MMM Do')} at 2:00 PM, at CNVJAIL for case/ticket number: ${case2.case_id}. You should verify your hearing date and time by going to ${process.env.COURT_PUBLIC_URL}. - ${process.env.COURT_NAME}`; + + messageMock.expects('send').resolves(true).once().withExactArgs(request1.phone, process.env.TWILIO_PHONE_NUMBER, message1) + messageMock.expects('send').resolves(true).once().withExactArgs(request2.phone, process.env.TWILIO_PHONE_NUMBER, message2) + + return sendReminders() + .then(res => knex("notifications").whereIn('case_id', [case1['case_id'], case2['case_id']]).select("*")) + .then(rows => { + messageMock.verify() + expect(rows.length).to.equal(2); + }); + }); }); -function turnerData(v, payable) { - if (payable === undefined) { - payable = true; - } +describe("with notification already sent for hearing", function () { + let messageMock + + beforeEach(function () { + messageMock = sinon.mock(messages) + + return manager.ensureTablesExist() + .then(clearTable("hearings")) + .then(clearTable("requests")) + .then(clearTable("notifications")) + .then(loadHearings([case1, case2])) + .then(addTestRequests([request1, request2])) + .then(addTestNotification(notification1)) + }); + + afterEach(function() { + messageMock.restore() + }); - return { date: '27-MAR-15', - defendant: 'TURNER, FREDERICK T', + it("Should only send reminders to requests without existing notifications for same case_id/event time/number", function(){ + var message = `Courtesy reminder: Bob J Smith has a court hearing on ${TOMORROW_DATE.format('ddd, MMM Do')} at 2:00 PM, at CNVJAIL for case/ticket number: ${case2.case_id}. You should verify your hearing date and time by going to ${process.env.COURT_PUBLIC_URL}. - ${process.env.COURT_NAME}`; + messageMock.expects('send').resolves(true).once().withExactArgs(request2.phone, process.env.TWILIO_PHONE_NUMBER, message) + + return knex("notifications").update({ event_date: TOMORROW_DATE}) + .then(() => sendReminders()) + .then(() => knex("notifications").whereIn('case_id', [case1['case_id'], case2['case_id']]).select("*")) + .then(rows => { + messageMock.verify() + expect(rows.length).to.equal(2) + }) + }) + + it("should send reminder when notification exists for same phone/case_id but at a different date/time", function(){ + var message1 = `Courtesy reminder: Frederick T Turner has a court hearing on ${TOMORROW_DATE.format('ddd, MMM Do')} at 2:00 PM, at CNVCRT for case/ticket number: ${case1.case_id}. You should verify your hearing date and time by going to ${process.env.COURT_PUBLIC_URL}. - ${process.env.COURT_NAME}`; + var message2 = `Courtesy reminder: Bob J Smith has a court hearing on ${TOMORROW_DATE.format('ddd, MMM Do')} at 2:00 PM, at CNVJAIL for case/ticket number: ${case2.case_id}. You should verify your hearing date and time by going to ${process.env.COURT_PUBLIC_URL}. - ${process.env.COURT_NAME}`; + + messageMock.expects('send').resolves(true).once().withExactArgs(request1.phone, process.env.TWILIO_PHONE_NUMBER, message1) + messageMock.expects('send').resolves(true).once().withExactArgs(request2.phone, process.env.TWILIO_PHONE_NUMBER, message2) + + return sendReminders() + .then(() => messageMock.verify()) + }) +}) + +function loadHearings(hearing) { + return function() { + return knex("hearings").insert(hearing); + } +} + +function addTestRequests(requests) { + return function () { + return Promise.all(requests.map(function (request) { + return addTestRequest(request); + })); + } +} + +function addTestRequest(request) { + return db.addRequest({ + case_id: request.case_id, + phone: request.phone, + known_case: request.known_case + }); +} +function addTestNotification(notification){ + return function(){ + return knex("notifications").insert(notification) + } +} +function clearTable(table) { + return function() { + return knex(table).del() + }; +} + +const case1 = { + //date: '27-MAR-15', + date: TOMORROW_DATE, + defendant: 'FREDERICK T TURNER', room: 'CNVCRT', - time: '01:00:00 PM', - citations: '[{"id":"4928456","violation":"40-8-76.1","description":"SAFETY BELT VIOLATION","location":"27 DECAATUR ST","payable":"' + (payable ? 1 : 0) + '"}]', - id: '677167760f89d6f6ddf7ed19ccb63c15486a0eab' + (v||"") - }; + case_id: "4928456" } + +const case2 = { + //date: '27-MAR-15', + date: TOMORROW_DATE, + defendant: ' Bob J SMITH', + room: 'CNVJAIL', + case_id: "4928457" +} + +const request1 = { + phone: "+12223334444", + case_id: case1.case_id, + known_case: true +} + +const request2 = { + case_id: case2.case_id, + phone: "+12223334445", + known_case: true +} + +const request2_dup = { + case_id: case2.case_id, + phone: "+12223334445", + known_case: true +} + +const notification1 = { + case_id: case1.case_id, + phone: db.encryptPhone(request1.phone), + event_date: TEST_UTC_DATE, + type:'reminder' +} + diff --git a/test/test_case_test.js b/test/test_case_test.js new file mode 100644 index 0000000..0e5579f --- /dev/null +++ b/test/test_case_test.js @@ -0,0 +1,87 @@ +'use strict'; +const expect = require("chai").expect; +const manager = require("../utils/db/manager"); +const knex = manager.knex; +const db = require('../db'); +const moment = require('moment-timezone') +const {deleteTestRequests,incrementTestCaseDate,addTestCase} = require('../utils/testCase.js') + +const test_case_date = moment(11, 'HH').tz(process.env.TZ).add(1, 'days') + +describe("A test case", function() { + let a_case, a_request, test_request + beforeEach(function () { + a_case = { + //date: '27-MAR-15', + date: moment(14, 'HH').tz(process.env.TZ).add(1, 'days'), // 2:00pm tomorrow, + defendant: 'FREDERICK T TURNER', + room: 'CNVCRT', + case_id: "4928456" + } + a_request = { + phone: "+12223334444", + case_id: a_case.case_id, + known_case: true + } + test_request = { + phone: "+12223335555", + case_id: process.env['TEST_CASE_NUMBER'], + known_case: true + } + + return manager.ensureTablesExist() + .then(() => knex("hearings").del()) + .then(() => knex("requests").del()) + .then(() => knex("notifications").del()) + .then(() => knex("hearings").insert(a_case)) + .then(() => db.addRequest(a_request)) + .then(() => db.addRequest(test_request)) + }) + + it("should be added to the hearings table", function(){ + return addTestCase() + .then(() => knex('hearings').where({ case_id: process.env['TEST_CASE_NUMBER'] })) + .then((rows) => { + expect(rows.length).to.equal(1) + }) + }) + it("should not effect other hearings", function(){ + return addTestCase() + .then(() => knex('hearings').where({ case_id: a_case.case_id })) + .then((rows) => { + expect(rows.length).to.equal(1) + expect(rows[0].defendant).to.equal(a_case.defendant) + }) + }) + it("should set a hearing for tomorrow at 11 am", function(){ + return addTestCase() + .then(() => knex('hearings').where({ case_id: process.env['TEST_CASE_NUMBER'] })) + .then((rows) => { + expect(rows[0].date).to.equal(test_case_date.format()) + }) + }) + it("incrementTestCaseDate should add one day to the existing test case ", function(){ + return addTestCase() + .then(incrementTestCaseDate) + .then(() => knex('hearings').where({ case_id: process.env['TEST_CASE_NUMBER'] })) + .then((rows) => { + expect(rows[0].date).to.equal(test_case_date.add(1, 'days').format()) + }) + }) + it("deleteTestRequests should remove test requests", function(){ + return addTestCase() + .then(deleteTestRequests) + .then(() => knex('requests').where({case_id: process.env['TEST_CASE_NUMBER'] })) + .then((rows) => { + expect(rows.length).to.equal(0) + }) + }) + it("deleteTestRequests should ONLY remove test requests", function(){ + return addTestCase() + .then(deleteTestRequests) + .then(() => knex('requests').where({case_id: a_case.case_id })) + .then((rows) => { + expect(rows.length).to.equal(1) + }) + }) +}) \ No newline at end of file diff --git a/test/unmatched_requests_test.js b/test/unmatched_requests_test.js new file mode 100644 index 0000000..0dab224 --- /dev/null +++ b/test/unmatched_requests_test.js @@ -0,0 +1,115 @@ +'use strict'; +require('dotenv').config(); +const sendUnmatched = require("../sendUnmatched.js").sendUnmatched; +const expect = require("chai").expect; +const assert = require("chai").assert; +const moment = require("moment-timezone") +const manager = require("../utils/db/manager"); +const db = require('../db'); +const knex = manager.knex; +const sinon = require('sinon') +const messages = require('../utils/messages') + +describe("with 2 unmatched requests that now have matching hearings (same case_id / different phone number)", function() { + let messageMock + let phone1 = '+12223334444' + let phone2 = '+12223334445' + beforeEach(function() { + messageMock = sinon.mock(messages) + + return manager.ensureTablesExist() + .then(() => knex('hearings').del()) + .then(() => knex('hearings').insert([turnerData()])) + .then(() => knex("requests").del()) + .then(() => knex("notifications").del()) + .then(() => db.addRequest({ case_id: "4928456", phone: phone1, known_case: false})) + .then(() => db.addRequest({ case_id: "4928456", phone: phone2, known_case: false})) + }); + + afterEach(function(){ + messageMock.restore() + }); + + it("sends the correct info to Twilio and updates the known_case to true", function() { + const number = "+12223334444"; + const message = `Hello from the ${process.env.COURT_NAME}. We found a case for Frederick Turner scheduled on Fri, Mar 27th at 1:00 PM, at CNVCRT. We will send you courtesy reminders the day before future hearings.`; + messageMock.expects('send').resolves(true).once().withExactArgs(phone1, process.env.TWILIO_PHONE_NUMBER, message) + messageMock.expects('send').resolves(true).once().withExactArgs(phone2, process.env.TWILIO_PHONE_NUMBER, message) + + return sendUnmatched() + .then(res => knex("requests").select("*")) + .then(rows => { + expect(rows.length).to.equal(2) + expect(rows[0].known_case).to.be.true; + expect(rows[1].known_case).to.be.true; + messageMock.verify() + }); + }); +}); + +describe("with an unmatched request", function() { + let messageStub + const number = "+12223334444"; + const case_id = "123" + beforeEach(function() { + messageStub = sinon.stub(messages, 'send') + messageStub.resolves(true) + + return knex('hearings').del() + .then(() => knex('hearings').insert([turnerData()])) + .then(() => knex("requests").del()) + .then(() => knex("notifications").del()) + .then(() => db.addRequest({case_id: case_id, phone: number, known_case: false, active: true})) + }); + + afterEach(function(){ + messageStub.restore() + }); + + it("doesn't do anything < QUEUE_TTL days", function() { + return sendUnmatched() + .then(res => knex("requests").select("*")) + .then(rows => { + sinon.assert.notCalled(messageStub) + expect(rows[0].known_case).to.equal(false) + }) + }); + + it("sends a failure sms after QUEUE_TTL days and sets request to inactive", function() { + const message = `We haven't been able to find your court case ${case_id}. You can go to ${process.env.COURT_PUBLIC_URL} for more information. - ${process.env.COURT_NAME}`; + const mockCreatedDate = moment().tz(process.env.TZ).subtract(parseInt(process.env.QUEUE_TTL_DAYS, 10) + 2, 'days'); + + return knex("requests").update({updated_at: mockCreatedDate}) + .then(() => sendUnmatched()) + .then(res => knex("requests").select("*")) + .then(rows => { + sinon.assert.calledOnce(messageStub) + sinon.assert.alwaysCalledWithExactly(messageStub, number, process.env.TWILIO_PHONE_NUMBER, message ) + expect(rows.length).to.equal(1) + expect(rows[0].active).to.equal(false) + }); + }); + + it("creates a 'expired' notification corresponding to the request", function(){ + const mockCreatedDate = moment().tz(process.env.TZ).subtract(parseInt(process.env.QUEUE_TTL_DAYS, 10) + 2, 'days'); + return knex("requests").update({updated_at: mockCreatedDate}) + .then(() => sendUnmatched()) + then(res => knex("notifications").select("*")) + .then(rows => { + expect(rows.length).to.equal(1) + expect(rows[0].case_id).to.equal(case_id) + expect(rows[0].phone).to.equal(db.encryptPhone(number)) + expect(rows[0].type).to.equal(expired) + }); + }) +}); + +function turnerData(v) { + return { + //date: '27-MAR-15', + date: '2015-03-27T21:00:00.000Z', + defendant: 'Frederick Turner', + room: 'CNVCRT', + case_id: '4928456' + } +} diff --git a/test/web_test.js b/test/web_test.js index c2b4696..51a84b3 100644 --- a/test/web_test.js +++ b/test/web_test.js @@ -1,456 +1,483 @@ +'use strict'; // setup ENV dependencies -process.env.COOKIE_SECRET="test"; -process.env.PHONE_ENCRYPTION_KEY = "phone_encryption_key"; - -var expect = require("chai").expect; -var assert = require("chai").assert; -var nock = require('nock'); -var tk = require('timekeeper'); -var fs = require('fs'); -var Promise = require('bluebird'); -var moment = require("moment"); -var _ = require("underscore"); -var cookieParser = require("cookie-parser"); -var crypto = require('crypto'); -var Session = require('supertest-session')({ - app: require('../web') -}); - -var sess; - -beforeEach(function () { - sess = new Session(); -}); - -afterEach(function () { - sess.destroy(); -}); - -var Knex = require('knex'); -var knex = Knex.initialize({ - client: 'pg', - connection: process.env.DATABASE_URL -}); - -nock.enableNetConnect('127.0.0.1'); - +require('dotenv').config(); +const fs = require('fs'); +const expect = require('chai').expect; +const cookieParser = require('cookie-parser'); +const Keygrip = require('keygrip'); +const db = require('../db.js'); +const manager = require('../utils/db/manager'); +const moment = require('moment-timezone'); +const knex = manager.knex; +const app = require('../web'); +const session = require('supertest-session'); + +const TEST_UTC_DATE = moment("2015-03-27T13:00:00").tz(process.env.TZ).format(); +const keys = Keygrip([process.env.COOKIE_SECRET]) + +/** + * Altered this to do a local read of the expected content to get expected content length because + * on windows machine the content length was 354 and not the hard-coded 341 (maybe windows character encoding?) + * + * It is partly a guess that it is okay to make this change because I am assuming the unit tests + * only should run where app.settings.env == 'development' (web.js) -- this is what causes public/index.html + * to be served, rather than "hello I am courtbot..." + */ describe("GET /", function() { - it("responds with a simple message", function(done) { - sess.get('/'). - expect('Content-Length', '79'). - expect(200). - end(function(err, res) { - if (err) return done(err); - expect(res.text).to.contain("Hello, I am Courtbot."); - done(); - }); - }); -}); - -describe("GET /cases", function() { - it("400s when there is no ?q=", function(done) { - sess.get('/cases'). - expect(400, done); - }); - - it("200s + empty array when there is ?q=", function(done) { - sess.get('/cases?q=test'). - expect(200). - end(function(err, res) { - if (err) return done(err); - expect(res.text).to.equal("[]"); - done(); - }); - }); - - it("finds partial matches of name", function(done) { - knex('cases').del().then(function() { - knex('cases').insert([turnerData(1), turnerData(2)]).then(function() { - sess.get('/cases?q=turner'). - expect(200). - end(function(err, res) { + let sess; + beforeEach(function() { + sess = session(app); + }) + afterEach(function(){ + sess.destroy(); + }) + it("responds with web form test input", function(done) { + var expectedContent = fs.readFileSync("public/index.html", "utf8"); + sess.get('/') + .expect('Content-Length', expectedContent.length.toString()) + .expect(200) + .end(function(err, res) { if (err) return done(err); - expect(JSON.parse(res.text)).to.deep.equal([turnerDataAsObject(1), turnerDataAsObject(2)]); + expect(res.text).to.contain("Impersonate Twilio"); done(); - }); - }); + }); }); - }); - - it("finds exact matches of id", function(done) { - knex('cases').del().then(function() { - knex('cases').insert([turnerData()]).then(function() { - sess.get('/cases?q=4928456'). - expect(200). - end(function(err, res) { - if (err) return done(err); - expect(JSON.parse(res.text)).to.deep.equal([turnerDataAsObject()]); - done(); - }); - }); +}); + +describe("GET /cases", function() { + let sess; + beforeEach(function() { + sess = session(app); + }) + afterEach(function(){ + sess.destroy(); + }) + it("400s when there is no ?q=", function(done) { + sess.get('/cases') + .expect(400, done); }); - }); - - it("doesnt find partial matches of id", function(done) { - knex('cases').del().then(function() { - knex('cases').insert([turnerData()]).then(function() { - sess.get('/cases?q=492845'). - expect(200). - end(function(err, res) { + + it("200s + empty array when there is ?q=", function(done) { + sess.get('/cases?q=test') + .expect(200) + .end(function(err, res) { if (err) return done(err); - expect(JSON.parse(res.text)).to.deep.equal([]); + expect(res.text).to.equal("[]"); done(); - }); - }); + }); }); - }); -}); -describe("POST /sms", function() { - beforeEach(function(done) { - knex('cases').del().then(function() { - knex('reminders').del().then(function() { - knex('queued').del().then(function() { - knex('cases').insert([turnerData()]).then(function() { - done(); - }); + it("finds partial matches of name", function(done) { + knex('hearings').del().then(function() { + knex('hearings').insert([turnerData(1), turnerData(2)]).then(function() { + sess.get('/cases?q=turner') + .expect(200) + .end(function(err, res) { + if (err) return done(err); + expect([sortObject(JSON.parse(res.text)[0]),sortObject(JSON.parse(res.text)[1])]).to.deep.equal([turnerDataAsObject(1), turnerDataAsObject(2)]); + done(); + }); + }); }); - }); }); - }); - context("without session set", function() { - context("with 1 matching court case", function() { - var params = { Body: "4928456" }; - - context("it can pay online", function() { - beforeEach(function(done) { - knex('cases').del().then(function() { - knex('cases').insert([turnerData("", true)]).then(function() { - done(); + it("finds exact matches of id", function(done) { + knex('hearings').del() + .then(() => knex('hearings').insert([turnerData()])) + .then(() => { + sess.get('/cases?q=A4928456') + .expect(200) + .end(function(err, res) { + if (err) return done(err); + expect(sortObject(JSON.parse(res.text))["0"]).to.deep.equal(turnerDataAsObject()); + done(); }); - }); }); + }); - it("responds that we can pay now and skip court", function(done) { - sess. - post('/sms'). - send(params). - expect(200). - end(function(err, res) { - if (err) { return done(err); } - expect(res.text).to.equal('You can pay now and skip court. Just call (404) 658-6940 or visit court.atlantaga.gov. \n\nOtherwise, your court date is Thursday, Mar 26th at 01:00:00 PM, in courtroom CNVCRT.'); - done(); + it("finds find id with leading and trailing spaces", function(done) { + knex('hearings').del() + .then(() => knex('hearings').insert([turnerData()])) + .then(() => { + sess.get('/cases?q=%20A4928456%20') + .expect(200) + .end(function(err, res) { + if (err) return done(err); + expect(sortObject(JSON.parse(res.text))["0"]).to.deep.equal(turnerDataAsObject()); + done(); }); }); + }); - it("doesn't set anything on session", function(done) { - sess. - post('/sms'). - send(params). - expect(200). - end(function(err, res) { - if (err) { return done(err); } - expect(getConnectCookie().askedQueued).to.equal(undefined); - expect(getConnectCookie().askedReminder).to.equal(undefined); - expect(getConnectCookie().citationId).to.equal(undefined); - done(); + it("doesnt find partial matches of id", function(done) { + knex('hearings').del() + .then(() => knex('hearings').insert([turnerData()])) + .then(() => { + sess.get('/cases?q=492845') + .expect(200) + .end(function(err, res) { + if (err) return done(err); + expect(JSON.parse(res.text)).to.deep.equal([]); + done(); }); }); - }); + }); +}); + +describe("POST /sms", function() { + let sess; + const new_date = moment().add(5, 'days'); + + beforeEach(function() { + sess = session(app); + return knex('hearings').del() + .then(() => knex('notifications').del()) + .then(() => knex('requests').del()) + .then(() => knex('hearings').insert([turnerData('', new_date)])) + }) + afterEach(function () { + sess.destroy(); + }); - context("it can not be paid online", function() { - beforeEach(function(done) { - knex('cases').del().then(function() { - knex('cases').insert([turnerData("", false)]).then(function() { - done(); + context("without session set", function() { + context("with 1 matching court case", function() { + const params = { Body: " A4928456 ", From: "+12223334444"}; + + beforeEach(function() { + return knex('hearings').del() + .then(() => knex('hearings').insert([turnerData("", new_date)])) }); - }); - }); - it("says there is a court case and prompts for reminder", function(done) { - sess. - post('/sms'). - send(params). - expect(200). - end(function(err, res) { - if (err) { return done(err); } - expect(res.text).to.equal('Found a court case for Frederick T Turner on Thursday, Mar 26th at 01:00:00 PM, in courtroom CNVCRT. Would you like a reminder the day before? (reply YES or NO)'); - done(); + it("says there is a court case and prompts for reminder", function(done) { + sess.post('/sms') + .send(params) + .expect(200) + .end(function(err, res) { + if (err) return done(err); + expect(res.text).to.equal(`We found a case for Frederick Turner scheduled on ${new_date.format('ddd, MMM Do')} at ${new_date.format('h:mm A')}, at CNVCRT. Would you like a courtesy reminder the day before? (reply YES or NO)`); + done(); + }); }); - }); - it("sets match and askedReminder on session", function(done) { - sess. - post('/sms'). - send(params). - expect(200). - end(function(err, res) { - if (err) { return done(err); } - expect(getConnectCookie().askedQueued).to.equal(undefined); - expect(getConnectCookie().askedReminder).to.equal(true); - expect(getConnectCookie().match).to.deep.equal(rawTurnerDataAsObject("", false)); - done(); + it("strips emojis from a text", function (done) { + sess.post('/sms') + .send({ + Body: 'A4928456 😁', + From: "+12223334444" + }) + .expect(200) + .end(function(err, res) { + if(err) return done(err); + expect(res.text).to.equal(`We found a case for Frederick Turner scheduled on ${new_date.format('ddd, MMM Do')} at ${new_date.format('h:mm A')}, at CNVCRT. Would you like a courtesy reminder the day before? (reply YES or NO)`); + done(); + }); }); - }); - }); - }); - context("with 0 matching court cases", function() { - context("with a citation length between 6-9 inclusive", function() { - var params = { Body: "123456" }; - - it("says we couldn't find their case and prompt for reminder", function(done) { - sess. - post('/sms'). - send(params). - expect(200). - end(function(err, res) { - if (err) { return done(err); } - expect(res.text).to.equal('Couldn't find your case. It takes 14 days for new citations to appear in the sytem. Would you like a text when we find your information? (Reply YES or NO)'); - done(); + it("strips everything after newlines and carriage returns from id", function (done) { + sess.post('/sms') + .send({ + Body: 'A4928456\r\n-Simon', + From: "+12223334444" + }) + .expect(200) + .end(function(err, res) { + if(err) return done(err); + expect(res.text).to.equal(`We found a case for Frederick Turner scheduled on ${new_date.format('ddd, MMM Do')} at ${new_date.format('h:mm A')}, at CNVCRT. Would you like a courtesy reminder the day before? (reply YES or NO)`); + done(); + }); + }); + + it("strips everything after newlines and carriage returns from id", function (done) { + sess.post('/sms') + .send({ + Body: 'A4928456\n-Simon', + From: "+12223334444" + }) + .expect(200) + .end(function(err, res) { + if(err) return done(err); + expect(res.text).to.equal(`We found a case for Frederick Turner scheduled on ${new_date.format('ddd, MMM Do')} at ${new_date.format('h:mm A')}, at CNVCRT. Would you like a courtesy reminder the day before? (reply YES or NO)`); + done(); + }); + }); + + it("sets case_id and known_case on session", function(done) { + sess.post('/sms') + .send(params) + .expect(200) + .end(function(err, res) { + if (err) return done(err); + expect(getConnectCookie(sess).case_id).to.equal(params.Body.trim()); + expect(getConnectCookie(sess).known_case).to.be.true; + done(); + }); }); }); - it("sets the askedQueued and citationId cookies", function(done) { - sess. - post('/sms'). - send(params). - expect(200). - end(function(err, res) { - if (err) { return done(err); } - expect(getConnectCookie().askedQueued).to.equal(true); - expect(getConnectCookie().askedReminder).to.equal(undefined); - expect(getConnectCookie().citationId).to.equal("123456"); - done(); + context("with 0 matching court cases", function() { + context("with a citation length between 6-25 inclusive", function() { + const params = { Body: "B1234567", From: "+12223334444" }; + + it("says we couldn't find their case and prompt for reminder", function(done) { + sess.post('/sms') + .send(params) + .expect(200) + .end(function(err, res) { + if (err) return done(err); + expect(res.text).to.equal('We could not find that number. It can take several days for a citation number to appear in our system. Would you like us to keep checking for the next ' + process.env.QUEUE_TTL_DAYS + ' days and text you if we find it? (reply YES or NO)'); + done(); + }); + }); + + it("sets the case_id and known_case cookies", function(done) { + sess.post('/sms') + .send(params) + .expect(200) + .end(function(err, res) { + if (err) return done(err); + expect(getConnectCookie(sess).case_id).to.equal(params.Body.trim()); + expect(getConnectCookie(sess).known_case).to.be.false; + done(); + }); + }); }); + + context("the citation length is too short", function() { + const params = { Body: "12345", From: "+12223334444" }; + + it("says that case id is wrong", function(done) { + sess.post('/sms') + .send(params) + .expect(200) + .end(function(err, res) { + if (err) return done(err); + expect(res.text).to.equal('Reply with a case or ticket number to sign up for a reminder. Case number length should be 14, example: 1KE-18-01234MO. Ticket number can be 8 to 17 letters and/or numbers in length, example: KETEEP00000123456.'); + expect(getConnectCookie(sess).askedQueued).to.equal(undefined); + expect(getConnectCookie(sess).askedReminder).to.equal(undefined); + expect(getConnectCookie(sess).citationId).to.equal(undefined); + done(); + }); + }); + }); }); - }); - - context("the citation length is too long", function() { - var params = { Body: "123456789123456" }; - - it("says that you need to call", function(done) { - sess. - post('/sms'). - send(params). - expect(200). - end(function(err, res) { - if (err) { return done(err); } - expect(res.text).to.equal('Sorry, we couldn't find that court case. Please call us at (404) 954-7914.'); - expect(getConnectCookie().askedQueued).to.equal(undefined); - expect(getConnectCookie().askedReminder).to.equal(undefined); - expect(getConnectCookie().citationId).to.equal(undefined); - done(); - }); + + context("Same day court case or or case already happened", function() { + const params = { Body: "A4928456", From: "+12223334444" }; + + it("says case is same day", function(done) { + const caseDate = moment().add(1, "hours") + knex('hearings').del() + .then(() => knex('hearings').insert([turnerData("", caseDate)])) + .then(() => { + sess.post('/sms').send(params) + .expect(200) + .end(function (err, res) { + if (err) return done(err); + expect(res.text).to.equal(`We found a case for Frederick Turner scheduled today at ${caseDate.format('h:mm A')}, at CNVCRT. Would you like a courtesy reminder the day before a future hearing? (reply YES or NO)`); + expect(getConnectCookie(sess).case_id).to.equal(params.Body); + expect(getConnectCookie(sess).known_case).to.be.true; + done(); + }); + }); + }); + + it("says case is already happening (time is now)", function (done) { + const caseDate = moment() + knex('hearings').del() + .then(() => knex('hearings').insert([turnerData("", caseDate)])) + .then(() => { + sess.post('/sms').send(params) + .expect(200) + .end(function (err, res) { + if (err) return done(err); + expect(res.text).to.equal(`We found a case for Frederick Turner scheduled today at ${caseDate.format('h:mm A')}, at CNVCRT. Would you like a courtesy reminder the day before a future hearing? (reply YES or NO)`); + expect(getConnectCookie(sess).case_id).to.equal(params.Body); + expect(getConnectCookie(sess).known_case).to.be.true; + done(); + }); + }); + }); + + it("says case is already happening (time in the past)", function (done) { + const caseDate = moment().subtract(2, "hours") + knex('hearings').del() + .then(() => knex('hearings').insert([turnerData("", caseDate)])) + .then(() => { + sess.post('/sms').send(params) + .expect(200) + .end(function (err, res) { + if (err) return done(err); + expect(res.text).to.equal(`We found a case for Frederick Turner scheduled today at ${caseDate.format('h:mm A')}, at CNVCRT. Would you like a courtesy reminder the day before a future hearing? (reply YES or NO)`); + expect(getConnectCookie(sess).case_id).to.equal(params.Body); + expect(getConnectCookie(sess).known_case).to.be.true; + done(); + }); + }); + }); }); - }); - }); - }); - - context("with session.askedReminder", function() { - // This cookie comes from "sets match and askedReminder on session" in order to avoid finicky node session management / encryption - // TODO: Have this be a hash that is set and encrypted instead of hardcoded like this - var cookieArr = ['connect.sess=s%3Aj%3A%7B%22match%22%3A%7B%22id%22%3A%22677167760f89d6f6ddf7ed19ccb63c15486a0eab%22%2C%22defendant%22%3A%22TURNER%2C%20FREDERICK%20T%22%2C%22date%22%3A%222015-03-27T00%3A00%3A00.000Z%22%2C%22time%22%3A%2201%3A00%3A00%20PM%22%2C%22room%22%3A%22CNVCRT%22%2C%22citations%22%3A%5B%7B%22id%22%3A%224928456%22%2C%22violation%22%3A%2240-8-76.1%22%2C%22description%22%3A%22SAFETY%20BELT%20VIOLATION%22%2C%22location%22%3A%2227%20DECAATUR%20ST%22%2C%22payable%22%3A%220%22%7D%5D%7D%2C%22askedReminder%22%3Atrue%7D.LJMfW%2B9Dz6BLG2mkRlMdVVnIm3V2faxF3ke7oQjYnls; Path=/; HttpOnly']; - - describe("the user texts YES", function() { - var params = { Body: "yEs", From: "+12223334444" }; - - it("creates a reminder", function(done) { - sess. - post('/sms'). - set('Cookie', cookieArr). - send(params). - expect(200). - end(function(err, res) { - if (err) { return done(err); } - setTimeout(function() { // This is a hack because the DB operation happens ASYNC - knex("reminders").select("*").groupBy("reminders.reminder_id").count('* as count').then(function(rows) { - var record = rows[0]; - expect(record.count).to.equal('1'); - expect(record.phone).to.equal(cypher("+12223334444")); - expect(record.case_id).to.equal('677167760f89d6f6ddf7ed19ccb63c15486a0eab'); - expect(record.sent).to.equal(false); - expect(JSON.parse(record.original_case)).to.deep.equal(rawTurnerDataAsObject("", false)); - done(); - }, done); - }, 200); - }); - }); - - it("responds to the user about the reminder being created", function(done) { - sess. - post('/sms'). - set('Cookie', cookieArr). - send(params). - expect(200). - end(function(err, res) { - expect(res.text).to.equal('Sounds good. We'll text you a day before your case. Call us at (404) 954-7914 with any other questions.'); - expect(getConnectCookie().askedReminder).to.equal(false); - done(); - }); - }); }); - describe("the user texts NO", function() { - var params = { Body: "No", From: "+12223334444" }; - - it("doesn't create a reminder", function(done) { - sess. - post('/sms'). - set('Cookie', cookieArr). - send(params). - expect(200). - end(function(err, res) { - if (err) { return done(err); } - knex("reminders").count('* as count').then(function(rows) { - expect(rows[0].count).to.equal('0'); - done(); - }, done); - }); - }); - - it("responds to the user with our number", function(done) { - sess. - post('/sms'). - set('Cookie', cookieArr). - send(params). - expect(200). - end(function(err, res) { - expect(res.text).to.equal('Alright, no problem. See you on your court date. Call us at (404) 954-7914 with any other questions.'); - expect(getConnectCookie().askedReminder).to.equal(false); - done(); - }); - }); - }); - }); - - context("with session.askedQueued", function() { - // This cookie comes from "sets the askedQueued and citationId cookies" in order to avoid finicky node session management / encryption - // TODO: Have this be a hash that is set and encrypted instead of hardcoded like this - var cookieArr = ['connect.sess=s%3Aj%3A%7B%22askedQueued%22%3Atrue%2C%22citationId%22%3A%22123456%22%7D.%2FuRCxqdZogql42ti2bU0yMSOU0CFKA0kbL81MQb5o24; Path=/; HttpOnly']; - - describe("the user texts YES", function() { - var params = { Body: "Y", From: "+12223334444" }; - - it("creates a queued", function(done) { - sess. - post('/sms'). - set('Cookie', cookieArr). - send(params). - expect(200). - end(function(err, res) { - if (err) { return done(err); } - setTimeout(function() { // This is a hack because the DB operation happens ASYNC - knex("queued").select("*").groupBy("queued.queued_id").count('* as count').then(function(rows) { - var record = rows[0]; - expect(record.count).to.equal('1'); - expect(record.phone).to.equal(cypher("+12223334444")); - expect(record.citation_id).to.equal('123456'); - expect(record.sent).to.equal(false); - done(); - }, done); - }, 200); - }); - }); - - it("tells the user we'll text them", function(done) { - sess. - post('/sms'). - set('Cookie', cookieArr). - send(params). - expect(200). - end(function(err, res) { - expect(res.text).to.equal('Sounds good. We'll text you in the next 14 days. Call us at (404) 954-7914 with any other questions.'); - expect(getConnectCookie().askedQueued).to.equal(false); - done(); - }); - }); - }); + context("with session.case_id", function() { + const new_date = moment().add(5, 'days'); + // Build json object, serialize, sign, encode [TODO: can we get session-cookie to do this for us?] + var cookieObj = {case_id: turnerData().case_id, known_case: true}; + var cookieb64 = new Buffer(JSON.stringify(cookieObj)).toString('base64'); + var sig = keys.sign('session='+cookieb64); + var cookieArr = ['session='+cookieb64 + '; session.sig=' + sig + '; Path=/;']; + + describe("User responding askedReminder session", function() { + it("YES - creates a request and responds appropriately", function (done) { + const params = { Body: " yEs ", From: "+12223334444" }; + sess.post('/sms').set('Cookie', cookieArr[0]).send(params) + .expect(200) + .end(function (err, res) { + if (err) return done(err); + + expect(res.text).to.equal('OK. We will text you a courtesy reminder the day before your hearing date. Note that court schedules frequently change. You should always verify your hearing date and time by going to http://courts.alaska.gov.'); + expect(getConnectCookie(sess).case_id).to.be.undefined; + expect(getConnectCookie(sess).known_case).to.be.undefined; + + knex("requests").select("*") + .then((rows) => { + expect(rows.length).to.equal(1) + const record = rows[0]; + expect(record.phone).to.equal(db.encryptPhone('+12223334444')); + expect(record.case_id).to.equal(turnerData().case_id); + expect(record.known_case).to.be.true; + }) + .then(done, done) + }); + }); - describe("the user texts NO", function() { - var params = { Body: "No", From: "+12223334444" }; - - it("doesn't create a queued", function(done) { - sess. - post('/sms'). - set('Cookie', cookieArr). - send(params). - expect(200). - end(function(err, res) { - if (err) { return done(err); } - setTimeout(function() { // This is a hack because the DB operation happens ASYNC - knex("queued").count('* as count').then(function(rows) { - expect(rows[0].count).to.equal('0'); - done(); - }, done); - }, 200); - }); - }); - - it("tells the user we'll text them", function(done) { - sess. - post('/sms'). - set('Cookie', cookieArr). - send(params). - expect(200). - end(function(err, res) { - expect(res.text).to.equal('No problem. Call us at (404) 954-7914 with any other questions.'); - expect(getConnectCookie().askedQueued).to.equal(false); - done(); - }); - }); + it("NO - doesn't create a reminder and responds appropriately", function (done) { + const params = { Body: " nO ", From: "+12223334444" }; + sess.post('/sms').set('Cookie', cookieArr).send(params) + .expect(200) + .end(function (err, res) { + if (err) return done(err); + expect(res.text).to.equal('You said “No” so we won’t text you a reminder. You can always go to ' + process.env.COURT_PUBLIC_URL + ' for more information about your case and contact information.'); + expect(getConnectCookie(sess).case_id).to.be.undefined; + expect(getConnectCookie(sess).known_case).to.be.undefined; + knex("requests").count('* as count') + .then((rows) => { + expect(rows[0].count).to.equal('0'); + }) + .then(done, done) + }); + }); + }); }); - }); -}); -function turnerData(v, payable) { - if (payable === undefined) { - payable = true; - } + describe("Deleting requests", function() { + const number = '+12223334444' + const case_id = turnerData().case_id + const request = { + case_id: case_id, + phone: db.encryptPhone(number), + known_case: true + } + beforeEach(function(){ + return knex('hearings').del() + .then(() => knex('requests').del()) + .then(() => knex('hearings').insert([turnerData()])) + .then(() => knex('requests').insert([request])) + }) + + describe("Without delete_case_id set on session", function(){ + it("tells them they are subscribed and gives instuction on deleting", function (done){ + const params = { Body: case_id, From: number }; + sess.post('/sms').send(params) + .expect(200) + .end(function(err, res){ + if (err) return done(err) + expect(res.text).to.equal(`You are currently scheduled to receive reminders for this case. We will attempt to text you a courtesy reminder the day before your hearing date. To stop receiving reminders for this case text 'DELETE'. You can go to ${process.env.COURT_PUBLIC_URL} for more information.`); + expect(getConnectCookie(sess).delete_case_id).to.equal('A4928456') + done() + }) + }) + }) + + describe("send delete with 'delete_case_id' on session set", function(){ + var cookieObj = {delete_case_id: case_id}; + var cookieb64 = new Buffer(JSON.stringify(cookieObj)).toString('base64'); + var sig = keys.sign('session='+cookieb64); + var cookieArr = ['session='+cookieb64 + '; session.sig=' + sig + '; Path=/;']; + + it("marks user's request inactive", function(done){ + const params = { Body: " Delete ", From: number }; + sess.post('/sms').set('Cookie', cookieArr).send(params) + .expect(200) + .end(function(err, res){ + if (err) return done(err) + expect(res.text).to.equal(`OK. We will stop sending reminders for case: ${case_id}. If you want to resume reminders you can text this ID to us again. You can go to ${process.env.COURT_PUBLIC_URL} for more information.`); + + knex('requests').select('*') + .then(rows => { + expect(rows.length).to.equal(1) + expect(rows[0].active).to.be.false + }) + .then(done, done) + }) + }) + }) + + }) +}); - return { date: '27-MAR-15', - defendant: 'TURNER, FREDERICK T', +function turnerData(v,d) { + return { + //date: '27-MAR-15', + date: d||TEST_UTC_DATE, + defendant: 'Frederick Turner', room: 'CNVCRT', - time: '01:00:00 PM', - citations: '[{"id":"4928456","violation":"40-8-76.1","description":"SAFETY BELT VIOLATION","location":"27 DECAATUR ST","payable":"' + (payable ? 1 : 0) + '"}]', - id: '677167760f89d6f6ddf7ed19ccb63c15486a0eab' + (v||"") + case_id: 'A4928456' + (v||""), + type: null }; } -function turnerDataAsObject(v, payable) { - if (payable === undefined) { - payable = true; - } +function turnerDataAsObject(v,d) { + const data = turnerData(v,d); + data.date = d||TEST_UTC_DATE; + data.readableDate = moment.utc(d||TEST_UTC_DATE).format("dddd, MMM Do"); + return data; +} - var data = turnerData(v); - data.date = "2015-03-27T00:00:00.000Z"; - data.citations = JSON.parse(data.citations); - data.payable = payable; - data.readableDate = "Thursday, Mar 26th"; - return data; +function rawTurnerDataAsObject(v,d) { + const data = turnerData(v,d); + data.date = moment(d ||TEST_UTC_DATE).tz(process.env.TZ).format(); + data.today = moment(d).isSame(moment(), 'day') + data.has_past = moment(d).isBefore(moment()) + return data; } -function rawTurnerDataAsObject(v, payable) { - if (payable === undefined) { - payable = true; +function turnerRequest(){ + return { + case_id: 'A4928456', + phone: '+12223334444', + known_case: true + } +} +function getConnectCookie(sess) { + if (!sess.cookies) return {} + const sessionCookie = sess.cookies.find(cookie => cookie.name === 'session'); + const cookie = sessionCookie && JSON.parse(Buffer.from(sessionCookie['value'], 'base64')); + return cookie || {} } - var data = turnerData(v, payable); - data.date = "2015-03-27T00:00:00.000Z"; - data.citations = JSON.parse(data.citations); - return data; -} +function sortObject(o) { + let sorted = {}, + a = []; -function getConnectCookie() { - var sessionCookie = _.find(sess.cookies, function(cookie) { - return _.has(cookie, 'connect.sess'); - }); - var cookie = sessionCookie['connect.sess']; - return cookieParser.JSONCookie(cookieParser.signedCookie(cookie, process.env.COOKIE_SECRET)); -} + for (let key in o) { + if (o.hasOwnProperty(key)) { + a.push(key); + } + } + + a.sort(); -function cypher(phone) { - var cipher = crypto.createCipher('aes256', process.env.PHONE_ENCRYPTION_KEY); - return cipher.update(phone, 'utf8', 'hex') + cipher.final('hex'); + for (let key = 0; key < a.length; key++) { + sorted[a[key]] = o[a[key]]; + } + return sorted; } diff --git a/test_utils/reset.js b/test_utils/reset.js deleted file mode 100644 index 12a8300..0000000 --- a/test_utils/reset.js +++ /dev/null @@ -1,15 +0,0 @@ -var exec = require('child_process').exec; - -console.log("Reseting courtbot_test"); - -exec("dropdb 'courtbot_test'", function(err, std) { - exec("createdb 'courtbot_test'", function(err, std) { - exec("DATABASE_URL=postgres://localhost:5432/courtbot_test node utils/createQueuedTable.js", function(err, std) { - exec("DATABASE_URL=postgres://localhost:5432/courtbot_test node utils/createRemindersTable.js", function(err, std) { - if (!err) { - console.log("Finished"); - } - }); - }); - }); -}); diff --git a/utils/createQueuedTable.js b/utils/createQueuedTable.js deleted file mode 100644 index 202dadd..0000000 --- a/utils/createQueuedTable.js +++ /dev/null @@ -1,27 +0,0 @@ -// Creates the reminders table. -var Knex = require('knex'); - -var knex = Knex.initialize({ - client: 'pg', - connection: process.env.DATABASE_URL -}); - -var createTable = function() { - return knex.schema.createTable('queued', function(table) { - table.increments('queued_id').primary(); - table.dateTime('created_at'); - table.string('citation_id', 100); - table.string('phone', 100); - table.boolean('sent', 100); - }); -}; - -var close = function() { - return knex.client.pool.destroy(); -}; - -createTable() - .then(close) - .then(function() { - console.log('Queued table created.'); - }); diff --git a/utils/createRemindersTable.js b/utils/createRemindersTable.js deleted file mode 100644 index f5d9500..0000000 --- a/utils/createRemindersTable.js +++ /dev/null @@ -1,28 +0,0 @@ -// Creates the reminders table. -var Knex = require('knex'); - -var knex = Knex.initialize({ - client: 'pg', - connection: process.env.DATABASE_URL -}); - -var createTable = function() { - return knex.schema.createTable('reminders', function(table) { - table.increments('reminder_id').primary(); - table.dateTime('created_at'); - table.string('case_id', 100); - table.string('phone', 100); - table.boolean('sent', 100); - table.json('original_case'); - }); -}; - -var close = function() { - return knex.client.pool.destroy(); -}; - -createTable() - .then(close) - .then(function() { - console.log('Reminders table created.'); - }); diff --git a/utils/createTables.js b/utils/createTables.js new file mode 100644 index 0000000..407b2a6 --- /dev/null +++ b/utils/createTables.js @@ -0,0 +1,4 @@ +var manager = require("./db/manager"); + +manager.ensureTablesExist() + .then(() => manager.closeConnection()) diff --git a/utils/db/db_connections.js b/utils/db/db_connections.js new file mode 100644 index 0000000..a53c44d --- /dev/null +++ b/utils/db/db_connections.js @@ -0,0 +1,45 @@ +/** + * It's important to set the correct time zone for the database here as a tz string. + * i.e. America/Anchorage (not -08 or PST) + * This allows us to simply insert local date/times and let the database perform the conversion + * which will store utc in the DB. + */ + +module.exports = { + production: { + client: "pg", + connection: { + connectionString: process.env.DATABASE_URL, + ssl: true + }, + pool: { + afterCreate: function(connection, callback) { + connection.query(`SET TIME ZONE '${process.env.TZ}';`, function(err) { + callback(err, connection); + }); + } + } + }, + development: { + client: "pg", + connection: process.env.DATABASE_URL, + pool: { + afterCreate: function(connection, callback) { + connection.query(`SET TIME ZONE '${process.env.TZ}';`, function(err) { + callback(err, connection); + }); + } + } + }, + test: { + client: "pg", + connection: process.env.DATABASE_TEST_URL, + pool: { + afterCreate: function(connection, callback) { + connection.query(`SET TIME ZONE '${process.env.TZ}';`, function(err) { + callback(err, connection); + }); + } + } + } +} \ No newline at end of file diff --git a/utils/db/manager.js b/utils/db/manager.js new file mode 100644 index 0000000..e3f4832 --- /dev/null +++ b/utils/db/manager.js @@ -0,0 +1,199 @@ +/* eslint no-console: "off" */ + +require('dotenv').config(); +const db_connections = require('./db_connections'); /* eslint camelcase: "off" */ +const knex = require('knex')(db_connections[process.env.NODE_ENV || 'development']); +const moment = require('moment-timezone') +const logger = require('../logger') + +/** + * Postgres returns the absolute date string with local offset detemined by its timezone setting. + * Knex by default creates a javascript Date object from this string. + * This function overrides knex's default to instead returns an ISO 8601 string with local offset. + * For more info: https://github.com/brianc/node-pg-types + */ +const TIMESTAMPTZ_OID = 1184; +require('pg').types.setTypeParser(TIMESTAMPTZ_OID, date => moment(date).tz(process.env.TZ).format()); + +/** + * Set of instructions for creating tables needed by the courtbot application. + * + * @type {Object} + */ +const createTableInstructions = { + hearings() { + return knex.schema.hasTable('hearings') + .then((exists) => { + if (!exists) { + return knex.schema.createTable('hearings', (table) => { + table.string('defendant', 100); + table.timestamp('date'); + table.string('room', 100); + table.string('case_id', 100); + table.string('type', 100); + table.primary(['case_id', 'date']); + table.index('case_id'); + }) + } + }) + }, + requests() { + return knex.schema.hasTable('requests') + .then((exists) => { + if (!exists) { + return knex.schema.createTable('requests', (table) => { + table.timestamps(true, true); + table.string('case_id', 100); + table.string('phone', 100); + table.boolean('known_case').defaultTo(false); + table.boolean('active').defaultTo(true); + table.primary(['case_id', 'phone']); + }); + } + }) + }, + notifications() { + return knex.schema.hasTable('notifications') + .then((exists) => { + if (!exists) { + return knex.schema.createTable('notifications', (table) => { + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.string('case_id'); + table.string('phone'); + table.timestamp('event_date'); + table.enu('type', ['reminder', 'matched', 'expired']); + table.string('error'); + table.foreign(['case_id', 'phone']).onDelete('CASCADE').references(['case_id', 'phone' ]).inTable('requests') + }) + } + }) + }, + log_runners() { + return knex.schema.hasTable('log_runners') + .then((exists) => { + if (!exists) { + return knex.schema.createTable('log_runners', function (table) { + table.increments() + table.enu('runner', ['send_reminder', 'send_expired', 'send_matched','load']) + table.integer('count') + table.integer('error_count') + table.timestamp('date').defaultTo(knex.fn.now()) + }) + } + }) + }, + log_hits() { + return knex.schema.hasTable('log_hits') + .then((exists) => { + if (!exists) { + return knex.schema.createTable('log_hits', function (table) { + table.timestamp('time').defaultTo(knex.fn.now()), + table.string('path'), + table.string('method'), + table.string('status_code'), + table.string('phone'), + table.string('body'), + table.string('action') + }) + } + }) + } +}; + +/** + * Insert chunk of data to table + * + * @param {String} table Table to insert data to. + * @param {Array} rows Array of rows to insert into the table. + * @param {number} size number of rows to insert into the table at one time. + * @return {Promise} + */ +function batchInsert(table, rows, size) { + logger.debug('batch inserting', rows.length, 'rows'); + + // had to explicitly use transaction for record counts in test cases to work + return knex.transaction(trx => trx.batchInsert(table, rows, size) + .then(trx.commit) + .catch(trx.rollback)); +} + +function acquireSingleConnection() { + return knex.client.acquireConnection() +} + +/** + * Manually close one or all idle database connections. + * + * @return {void} + */ +function closeConnection(conn) { + if (conn == null) { + return knex.client.pool.destroy() + } else { + return knex.client.releaseConnection(conn) + } +} + +/** + * Create specified table if it does not already exist. + * + * @param {String} table [description] + * @param {function} table (optional) function to be performed after table is created. + * @return {Promise} Promise to create table if it does not exist. + */ +function createTable(table) { + if (!createTableInstructions[table]) { + logger.error(`No Table Creation Instructions found for table "${table}".`); + return false; + } + + return knex.schema.hasTable(table) + .then((exists) => { + if (exists) { + return logger.debug(`Table "${table}" already exists. Will not create.`); + } + + return createTableInstructions[table]() + .then(() => { + return logger.debug(`Table created: "${table}"`); + }); + }); +} + +/** + * Drop specified table + * + * @param {String} table name of the table to be dropped. + * @return {Promise} Promise to drop the specified table. + */ +function dropTable(table) { + return knex.schema.dropTableIfExists(table) + .then(() => logger.debug(`Dropped existing table "${table}"`)); +} + +/** + * Ensure all necessary tables exist. + * + * Note: create logic only creates if a table does not exists, so it is enough to just + * call createTable() for each table. Becuase of foreign key constraint, requests table must + * exist before creating notifications table. The order is important because of constraints. + * + * @return {Promise} Promise to ensure all courtbot tables exist. + */ +function ensureTablesExist() { + const tables = ['requests', 'hearings', 'notifications', 'log_runners', 'log_hits'] + return tables.reduce((p, v) => p.then(() => { + return createTable(v) + .catch(err => logger.error(err)) + }), Promise.resolve()) +} + +module.exports = { + ensureTablesExist, + closeConnection, + createTable, + dropTable, + batchInsert, + knex, + acquireSingleConnection +}; diff --git a/utils/errors.js b/utils/errors.js new file mode 100644 index 0000000..5fac244 --- /dev/null +++ b/utils/errors.js @@ -0,0 +1,8 @@ +/* Allows distingushing different kinds of errors */ + +class HTTPError extends Error{ +} + +module.exports = { + HTTPError: HTTPError +} \ No newline at end of file diff --git a/utils/loaddata.js b/utils/loaddata.js index 2edd5fa..703abb4 100644 --- a/utils/loaddata.js +++ b/utils/loaddata.js @@ -1,195 +1,132 @@ +/* eslint "no-console": "off" */ + // Downloads the latest courtdate CSV file and // rebuilds the database. For best results, load nightly. -var http = require('http'); -var moment = require('moment'); -var request = require('request'); -var parse = require('csv-parse'); -var Promise = require('bluebird'); -var sha1 = require('sha1'); - -var Knex = require('knex'); -var knex = Knex.initialize({ - client: 'pg', - connection: process.env.DATABASE_URL -}); - -var loadData = function () { - var yesterday = moment().subtract('days', 1).format('MMDDYYYY'); - var url = 'http://courtview.atlantaga.gov/courtcalendars/' + - 'court_online_calendar/codeamerica.' + yesterday + '.csv'; - - console.log('Downloading latest CSV file...'); - - return new Promise(function (resolve, reject) { - request.get(url, function(req, res) { - console.log('Parsing CSV File...'); - - if (res.statusCode == 404) { - console.log("404 page not found: ", url); - reject("404 page not found"); - } else { - parse(res.body, {delimiter: '|', quote: false, escape: false}, function(err, rows) { - if (err) { - console.log('Unable to parse file: ', url); - console.log(err); - reject(err); - } - - console.log('Extracting court case information...'); - var cases = extractCourtData(rows); - recreateDB(cases, function() { - console.log('Database recreated! All systems are go.'); - resolve(true); - }); - }); - } - }); - }); -}; - - -// Citation data provided in CSV has a few tricky parsing problems. The -// main of which is that citation numbers can appear multiple times. -// There's actually a couple reasons why: -// -// 1. Duplicates produced by the SQL query that generates the file -// 2. Date updates -- each date is included. Need to go with latest. -// 3. Cases that use identical citatiation numbers. Typos when put into the system. -var extractCourtData = function(rows) { - var cases = []; - var casesMap = {}; - var citationsMap = {}; - - var latest = function(date1, date2) { - if (moment(date1).isAfter(date2)) { - return date1; - } else { - return date2; - } - }; - - rows.forEach(function(c) { - var newCitation = { - id: c[5], - violation: c[6], - description: c[7], - location: c[2], - payable: c[8], - }; - - var newCase = { - date: c[0], - defendant: c[1], - room: c[3], - time: c[4], - citations: [], - }; - - // Since no values here are actually unique, we create some lookups - var citationLookup = newCitation.id + newCitation.violation; - var caseLookup = newCase.id = sha1(newCase.defendant + newCitation.location.slice(0, 6)); - - // The checks below handle the multiple citations in the dataset issue. - // See above for a more detailed explanation. - var prevCitation = citationsMap[citationLookup]; - var prevCase = casesMap[caseLookup]; - - // If we've seen this citation and case, this is just a date update. - // If we've seen this case, this is an additional citation on it - // Otherwise, both the case and the citation are new. - if (prevCitation && prevCase) { - prevCase.date = latest(prevCase.date, newCase.date); - } else if (prevCase) { - prevCase.date = latest(prevCase.date, newCase.date); - prevCase.citations.push(newCitation); - citationsMap[citationLookup] = newCitation; - } else { - cases.push(newCase); - casesMap[caseLookup] = newCase; - - newCase.citations.push(newCitation); - citationsMap[citationLookup] = newCitation; +const request = require('request'); +const csv = require('csv'); +const copyFrom = require('pg-copy-streams').from; +const manager = require('./db/manager'); +const {HTTPError} = require('./errors') +const CSV_DELIMITER = ','; + +const csv_headers = { + criminal_cases: ['date', 'last', 'first', 'room', 'time', 'id', 'type'], + civil_cases: ['date', 'last', 'first', false, 'room', 'time', 'id', false, 'violation', false] +} + +/** + * Main function that performs the entire load process. + * + * @param {String} dataUrls - list of data urls to load along with an optional + * header object key to use on each file. Format is url|csv_type,... The default + * csv_type is civil_cases. If this parameter is missing, then the + * environment variable DATA_URL is used instead. + * @return {Promise} - resolves to object with file and record count: { files: 2, records: 12171 } + */ +async function loadData(dataUrls) { + // determine what urls to load and how to extract them + // example DATA_URL=http://courtrecords.alaska.gov/MAJIC/sandbox/acs_mo_event.csv + // example DATA_URL=http://courtrecords.../acs_mo_event.csv|civil_cases,http://courtrecords.../acs_cr_event.csv|criminal_cases + + const files = (dataUrls || process.env.DATA_URL).split(','); + + // A single connection is needed for pg-copy-streams and the temp table + const stream_client = await manager.acquireSingleConnection() + stream_client.on('end', () => manager.closeConnection(stream_client)) + + // Postgres temp tables only last as long as the connection + // so we need to use one connection for the whole life of the table + await createTempHearingsTable(stream_client) + + for (let i = 0; i < files.length; i++) { + const [url, csv_type] = files[i].split('|'); + if (url.trim() == '') continue + try { + await loadCSV(stream_client, url, csv_type) + } catch(err) { + stream_client.end() + throw(err) + } } - }); - - return cases; -}; - -var recreateDB = function(cases, callback) { - // inserts cases, 1000 at a time. - var insertCases = function() { - // Make violations a JSON blob, to keep things simple - cases.forEach(function(c) { c.citations = JSON.stringify(c.citations); }); - - var chunks = chunk(cases, 1000); - return Promise.all(chunks.map(function(chunk) { - return knex('cases').insert(chunk); - })); - }; - - knex.schema - .dropTableIfExists('cases') - .then(createCasesTable) - .then(insertCases) - .then(createIndexingFunction) - .then(dropIndex) - .then(createIndex) - .then(close) - .then(function() { - callback(); - }); -}; - -var createCasesTable = function() { - return knex.schema.createTable('cases', function(table) { - table.string('id', 100).primary(); - table.string('defendant', 100); - table.date('date'); - table.string('time', 100); - table.string('room', 100); - table.json('citations'); - }); -}; - -// Creating an index for citation ids, stored in a JSON array -// Using this strategy: http://stackoverflow.com/a/18405706 -var createIndexingFunction = function () { - var text = ['CREATE OR REPLACE FUNCTION json_val_arr(_j json, _key text)', - ' RETURNS text[] AS', - '$$', - 'SELECT array_agg(elem->>_key)', - 'FROM json_array_elements(_j) AS x(elem)', - '$$', - ' LANGUAGE sql IMMUTABLE;'].join('\n'); - return knex.raw(text); -}; - -var dropIndex = function() { - var text = "DROP INDEX IF EXISTS citation_ids_gin_idx"; - return knex.raw(text); -}; - -var createIndex = function() { - var text = "CREATE INDEX citation_ids_gin_idx ON cases USING GIN (json_val_arr(citations, 'id'))"; - return knex.raw(text); -}; - -var close = function() { - return knex.client.pool.destroy(); -}; - -var chunk = function(arr, len) { - var chunks = []; - var i = 0; - var n = arr.length; - - while (i < n) { - chunks.push(arr.slice(i, i += len)); - } - - return chunks; -}; - -// Do the thing! + var count = await copyTemp(stream_client) + stream_client.end() + return {files: files.length, records: count} +} + +/** + * Transforms and loads a streamed csv file into the Postgres table . + * + * @param {Client} client - single pg client to use to create temp table and stream into DB + * @param {string} url - CSV url + * @param {string} csv_type - key for the csv_headers + */ +function loadCSV(client, url, csv_type){ + /* Define transform from delivered csv to unified format suitable for DB */ + const transformToTable = csv.transform(row => [`${row.date} ${row.time}`, `${row.first} ${row.last}`, row.room, row.id, row.type]) + + /* Use the csv header array to determine which headers describe the csv. + Default to the original citation headers */ + const parser = csv.parse({ + delimiter: CSV_DELIMITER, + columns: csv_headers[csv_type === 'criminal_cases' ? 'criminal_cases' : 'civil_cases'], + trim: true + }) + + return new Promise(async (resolve, reject) => { + /* Since we've transformed csv into [date, defendant, room, id] form, we can just pipe it to postgres */ + const copy_stream = client.query(copyFrom('COPY hearings_temp ("date", "defendant", "room", "case_id", "type") FROM STDIN CSV')); + copy_stream.on('error', reject) + copy_stream.on('end', resolve) + + request.get(url) + .on('response', function (res) { + if (res.statusCode !== 200) { + this.emit('error', new HTTPError("Error loading CSV. Return HTTP Status: "+res.statusCode)) + } + }) + .on('error', reject) + .pipe(parser) + .on('error', reject) + .pipe(transformToTable) + .pipe(csv.stringify()) + .pipe(copy_stream) + }) +} + +/** + * Copy temp table to real table. Enforce unique constraints by ignoring dupes. + * @param {*} client + */ +async function copyTemp(client){ + await manager.dropTable('hearings') + await manager.createTable('hearings') + let resp = await client.query( + `INSERT INTO hearings (date, defendant, room, case_id, type) + SELECT date, defendant, room, case_id, type from hearings_temp + ON CONFLICT DO NOTHING;` + ) + const count = resp.rowCount + return count +} + +/** + * Temp table to pipe into. This is necessary because Postgres can't configure + * alternate constraint handling when consuming streams. Duplicates would kill the insert. + * @param {*} client + */ +async function createTempHearingsTable(client){ + // Need to use the client rather than pooled knex connection + // becuase pg temp tables are tied to the life of the client. + await client.query( + `CREATE TEMP TABLE hearings_temp ( + date timestamptz, + defendant varchar(100), + room varchar(100), + case_id varchar(100), + type varchar(100) + )` + ) + return +} module.exports = loadData; diff --git a/utils/logger/hit_log.js b/utils/logger/hit_log.js new file mode 100644 index 0000000..a5ffdb1 --- /dev/null +++ b/utils/logger/hit_log.js @@ -0,0 +1,78 @@ +const { createLogger, format, transports } = require('winston'); +const { combine, timestamp, printf, colorize } = format; +const Transport = require('winston-transport'); +const crypto = require('crypto'); +const action_symbol = Symbol.for('action'); +const Rollbar = require('rollbar'); +const winston = require('winston'); +const {knex} = require("../db/manager"); + +const rollbar = new Rollbar({ + accessToken: process.env.ROLLBAR_ACCESS_TOKEN, + captureUncaught: false, + captureUnhandledRejections: false +}); + +/* Log transport to write logs to database table */ +class hit_table extends Transport { + constructor(opts) { + super(opts); + } + log(info, callback) { + setImmediate(() => { + this.emit('logged', "hit"); + }); + + const cipher = crypto.createCipher('aes256', process.env.PHONE_ENCRYPTION_KEY); + const phone = info.req.body && info.req.body.From ? cipher.update(info.req.body.From, 'utf8', 'hex') + cipher.final('hex') : undefined + return knex('log_hits').insert({ + path: info.req.url, + method: info.req.method, + status_code: info.statusCode, + phone: phone, + body: info.req.body && info.req.body.Body, + action: info[action_symbol] + }) + .then((res) => callback()) + .catch((err) => rollbar.error(err)) + } + }; + +const config = { + levels: { hit: 0 }, + colors: {hit: 'green'} +}; + +winston.addColors(config); + +const myFormat = printf(info => `${info.level}: ${info.timestamp} ${info.message}`.replace(/undefined/g, '')); + +const logger = createLogger({ + levels: config.levels, + level: 'hit', + format: combine( + timestamp(), + myFormat, + colorize(), + ), + transports: [ + new transports.Console({ + format: combine(myFormat) + }), + new hit_table() + ] +}) + +logger.on('error', function (err) {rollbar.error(err)}); + +/** + * Basic log for incoming sms and web requests + * This function is called by 'on-headers' module in web.js, which + * sets the value of 'this' to the Express response object + */ +function log() { + logger.hit(`${this.req.url} ${this.statusCode} ${this.req.method} ${this.req.body.From} ${this.req.body.Body} ${this[action_symbol]}`, this) +} + +module.exports = log + diff --git a/utils/logger/index.js b/utils/logger/index.js new file mode 100644 index 0000000..ba39f57 --- /dev/null +++ b/utils/logger/index.js @@ -0,0 +1,44 @@ +const { createLogger, format, transports } = require('winston'); +const { combine, timestamp, printf, colorize } = format; +const Transport = require('winston-transport'); +const Rollbar = require('rollbar'); + +/* + The primary purpose of this is to allow simple error logs to be sent to Rollbar + Simply calling `logger.error(err)` will send the error to rollbar. + Calling `logger.debug('message')` will write to the console. +*/ + +const rollbar = new Rollbar({ + accessToken: process.env.ROLLBAR_ACCESS_TOKEN, + captureUncaught: false, + captureUnhandledRejections: false +}); + +/* Logging with level of error sends notification to Rollbar */ +class rollbarTransport extends Transport { + constructor(opts) { + super(opts) + } + log(error, callback) { + setImmediate(() => this.emit('logged', "rollbar")); + rollbar.error(error, function(err2) { + if (err2) console.log("error reporting to rollbar: ", err2) + callback() + }) + } +} + +const logger = createLogger({ + format: combine( + colorize(), + timestamp(), + printf(info => `${info.level}: ${info.timestamp} ${info.message}`) + ), + transports: [ + new transports.Console({level: 'debug'}), + new rollbarTransport({level: 'error'}) + ] +}) + +module.exports = logger diff --git a/utils/logger/runner_log.js b/utils/logger/runner_log.js new file mode 100644 index 0000000..4de8e8f --- /dev/null +++ b/utils/logger/runner_log.js @@ -0,0 +1,41 @@ +const {knex} = require("../db/manager"); +const logger = require("./index") + +/** + * Creates an entry in the log_runners table and an entries for each request in the log_request_events table + * @param {Object} loginfo + * @param {string} loginfo.action - one of the enumerated actions available in log tables + * @param {Object[]} loginfo.data - array of requests that have been notified, matched, or expired + * @returns {Promise} resolves when DB is finished saving + */ +function logSendRunner({action, data}) { + if (!action || !data) throw new Error("Cannot log without action and data") + const {err, sent} = data.reduce((a, c) => (c.error ? a.err += 1 : a.sent += 1, a), {err: 0, sent: 0}) + return knex('log_runners').insert({ runner: action, count: sent, error_count: err }) + .then(() => ({action, err, sent})) +} + +/** + * Adds an entry to log_runners. Should be called when new csv files are loaded + * @param {Object} param + * @param {number} param.files - the number of files processed + * @param {number} param.records - the number of hearings added + */ +function logLoadRunner({files, records}) { + return knex('log_runners').insert({ runner: 'load', count: records }) + .then(() => ({files, records})) +} + +const runnerLog = { + sent({action, data}){ + return logSendRunner({action, data}) + .then((r) => logger.info(`Runner: ${r.action} | sent: ${r.sent} errors: ${r.err} `)) + .catch(logger.error) + }, + loaded({files, records}) { + return logLoadRunner({files, records}) + .then((r) => logger.info(`Runner: load | files: ${r.files} records: ${r.records} `)) + .catch(logger.error) + } +} +module.exports = runnerLog \ No newline at end of file diff --git a/utils/messages.js b/utils/messages.js new file mode 100644 index 0000000..6abfa06 --- /dev/null +++ b/utils/messages.js @@ -0,0 +1,258 @@ +const twilio = require('twilio'); +const moment = require('moment-timezone'); + +/** + * reduces whitespace to a single space + * + * Note: This is useful for reducing the character length of a string + * when es6 string templates are used. + * + * @param {String} msg the message to normalize + * @return {String} the msg with whitespace condensed to a single space + */ +function normalizeSpaces(msg) { + return msg.replace(/\s\s+/g, ' '); +} + +/** + * Change FIRST LAST to First Last + * + * @param {String} name name to manipulate + * @return {String} propercased name + */ +function cleanupName(name) { + return name.trim() + .replace(/\w\S*/g, txt => txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase()); +} + +/** + * message to go to the site for more information + * + * @return {String} message. + */ +function forMoreInfo() { + return normalizeSpaces(`OK. You can always go to ${process.env.COURT_PUBLIC_URL} + for more information about your case and contact information.`); +} +/** + * message when user replies 'No' when offered to reciece reminders + * + * @return {String} message. + */ +function repliedNo(){ + return normalizeSpaces(`You said “No” so we won’t text you a reminder. + You can always go to ${process.env.COURT_PUBLIC_URL} + for more information about your case and contact information.`); +} +/** + * message when user replies 'No' when offered to reciece reminders + * + * @return {String} message. + */ +function repliedNoToKeepChecking(){ + return normalizeSpaces(`You said “No” so we won’t keep checking. + You can always go to ${process.env.COURT_PUBLIC_URL} + for more information about your case and contact information.`); +} +/** + * tell them of the court date, and ask them if they would like a reminder + * + * @param {Boolean} includeSalutation true if we should greet them + * @param {string} name Name of cited person/defendant. + * @param {moment} datetime moment object containing date and time of court appearance. + * @param {string} room room of court appearance. + * @return {String} message. + */ +function foundItAskForReminder(match) { + const caseInfo = `We found a case for ${cleanupName(match.defendant)} scheduled + ${(match.today ? 'today' : `on ${moment(match.date).format('ddd, MMM Do')}`)} + at ${moment(match.date).format('h:mm A')}, at ${match.room}.`; + + let futureHearing = ''; + if (match.has_past) { + futureHearing = ' a future hearing'; + } else if (match.today) { // Hearing today + futureHearing = ' a future hearing'; + } + + return normalizeSpaces(`${caseInfo} + Would you like a courtesy reminder the day before${futureHearing}? (reply YES or NO)`); +} + +/** + * tell them of the court date, and ask them if they would like a reminder + * + * @param {Boolean} includeSalutation true if we should greet them + * @param {string} name Name of cited person/defendant. + * @param {moment} datetime moment object containing date and time of court appearance. + * @param {string} room room of court appearance. + * @return {String} message. + */ +function foundItWillRemind(includeSalutation, match) { + const salutation = `Hello from the ${process.env.COURT_NAME}. `; + const caseInfo = `We found a case for ${cleanupName(match.defendant)} scheduled + ${(match.today ? 'today' : `on ${moment(match.date).format('ddd, MMM Do')}`)} + at ${moment(match.date).format('h:mm A')}, at ${match.room}.`; + + let futureHearing = ''; + if (match.has_past) { + futureHearing = ' future hearings'; + } else if (match.today) { // Hearing today + futureHearing = ' future hearings'; + } + + return normalizeSpaces(`${(includeSalutation ? salutation : '')}${caseInfo} + We will send you courtesy reminders the day before${futureHearing}.`); +} + +/** + * greeting, who i am message + * + * @return {String} message. + */ +function iAmCourtBot() { + return 'Hello, I am Courtbot. I have a heart of justice and a knowledge of court cases.'; +} + +/** + * tell them their case number input was invalid + * + * @return {String} message. + */ +function invalidCaseNumber() { + return normalizeSpaces(`Reply with a case or ticket number to sign up for a reminder. + Case number length should be 14, example: 1KE-18-01234MO. + Ticket number can be 8 to 17 letters and/or numbers in length, example: KETEEP00000123456.`); +} + +/** + * tell them we could not find it and ask if they want us to keep looking + * + * @return {String} message. + */ +function notFoundAskToKeepLooking() { + return normalizeSpaces(`We could not find that number. It can take several days for a citation + number to appear in our system. Would you like us to keep + checking for the next ${process.env.QUEUE_TTL_DAYS} days and text you if + we find it? (reply YES or NO)`); +} + +/** + * Reminder message body + * + * @param {Object} occurrence reminder record. + * @return {string} message + */ +function reminder(occurrence) { + return normalizeSpaces(`Courtesy reminder: ${cleanupName(occurrence.defendant)} has a court hearing on + ${moment(occurrence.date).format('ddd, MMM Do')} at ${moment(occurrence.date).format('h:mm A')}, at ${occurrence.room} + for case/ticket number: ${occurrence.case_id}. + You should verify your hearing date and time by going to + ${process.env.COURT_PUBLIC_URL}. + - ${process.env.COURT_NAME}`); +} + +/** + * Message to send when we we cannot find a person's court case for too long. + * + * @return {string} Not Found Message + */ +function unableToFindCitationForTooLong(request) { + return normalizeSpaces(`We haven't been able to find your court case ${request.case_id}. + You can go to ${process.env.COURT_PUBLIC_URL} for more information. + - ${process.env.COURT_NAME}`); +} + +/** + * tell them we will keep looking for the case they inquired about + * @param {Array} cases + * @return {string} message + */ +function weWillKeepLooking() { + return normalizeSpaces(`OK. We will keep checking for up to ${process.env.QUEUE_TTL_DAYS} days. + You can always go to ${process.env.COURT_PUBLIC_URL} for more information about + your case and contact information.`); +} + +/** + * tell them we will try to remind them as requested + * + * @return {String} message. + */ +function weWillRemindYou() { + return normalizeSpaces(`OK. We will text you a courtesy reminder + the day before your hearing date. Note that court schedules frequently change. + You should always verify your hearing date and time by going + to ${process.env.COURT_PUBLIC_URL}.`); +} + +/** + * alerts user they are currently getting reminders for this case and gives option to stop + * @param {Array} cases + * @return {string} message + */ +function alreadySubscribed(case_id){ + return normalizeSpaces(`You are currently scheduled to receive reminders for this case. + We will attempt to text you a courtesy reminder the day before your hearing date. To stop receiving reminders for this case text 'DELETE'. + You can go to ${process.env.COURT_PUBLIC_URL} for more information.`); +} + +/** + * tell them we will stop sending reminders about cases + * @param {Array} cases + * @return {string} message + */ +function weWillStopSending(case_id) { + return normalizeSpaces(`OK. We will stop sending reminders for case: ${case_id}. + If you want to resume reminders you can text this ID to us again. + You can go to ${process.env.COURT_PUBLIC_URL} for more information.`); +} + +/** + * tell them we don't have any requests in the system for them + * + * @return {String} message. + */ +function youAreNotFollowingAnything(){ + return normalizeSpaces(`You are not currently subscribed for any reminders. If you want to be reminded + about an upcoming hearing, send us the case/citation number. You can go to ${process.env.COURT_PUBLIC_URL} for more information. + - ${process.env.COURT_NAME}`) +} + +/** + * Send a twilio message + * + * @param {string} to phone number message will be sent to + * @param {string} from who the message is being sent from + * @param {string} body message to be sent + * @param {function} function for resolving callback + * @return {Promise} Promise to send message. + */ +function send(to, from, body) { + const client = new twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN); + + return client.messages.create({ + body: body, + to: to, + from: from + }) +} + +module.exports = { + forMoreInfo, + foundItAskForReminder, + foundItWillRemind, + iAmCourtBot, + invalidCaseNumber, + notFoundAskToKeepLooking, + weWillKeepLooking, + weWillRemindYou, + reminder, + send, + unableToFindCitationForTooLong, + weWillStopSending, + youAreNotFollowingAnything, + alreadySubscribed, + repliedNo, + repliedNoToKeepChecking +}; diff --git a/utils/testCase.js b/utils/testCase.js new file mode 100644 index 0000000..957a7cc --- /dev/null +++ b/utils/testCase.js @@ -0,0 +1,64 @@ +const manager = require('./db/manager'); +const knex = manager.knex; + +/** + * These functions manage a fake test case to allow to subscribe to a test case + * Becuase the hearings table is deleted and refreshed every day some care is + * required to ensure this case always has a hearing set for the next round of reminders. + * + * If someone subscribes to the test case on Monday before reminders have been sent they should get a reminder Monday evening + * for a case on Tuesday. If they sign up on Monday after the reminders have been sent or Tuesday before the reminders + * the should get a reminder on Tuesday night for a case on Wednesday. + * + * This means we have to add the test hearing when loading hearings + * AND need to adjust the date of the test hearing after reminders have been sent. + * + */ + +/** + * Deletes requests for the test case number. + * The test case has a hearing every day, but each request should just + * get one notification. This should run after notification have been sent + */ +function deleteTestRequests(){ + if (process.env.TEST_CASE_NUMBER) { + return knex('requests').where('case_id', process.env.TEST_CASE_NUMBER) + .del() + } +} + +/** + * Sets the date of the test case hearing forward 1 day. + * This should run right after sending reminders so future subsribers get the corrent date. + */ +function incrementTestCaseDate(){ + if (process.env.TEST_CASE_NUMBER) { + return knex.raw(` + UPDATE hearings + SET date = (date + interval '1 day') + WHERE case_id = '${process.env.TEST_CASE_NUMBER}' + `) + } +} + +/** + * Adds a row to the hearing table for tommorrow for a test case + * This nees to run after loaddata has refreshed the hearings table so + * that day's subsribers get the request, + */ +async function addTestCase(){ + /* This needs to add a test case for tomorrow's date + So when the reminders are sent subscribers get the reminder */ + if (process.env.TEST_CASE_NUMBER){ + return knex.raw( + `INSERT INTO hearings (date, defendant, room, case_id) + VALUES (CURRENT_DATE + interval '35 hours', 'John Doe', 'Courtroom B, Juneau Courthouse', '${process.env.TEST_CASE_NUMBER}')` + ) + } +} + +module.exports = { + deleteTestRequests, + incrementTestCaseDate, + addTestCase +}; diff --git a/web.js b/web.js index 3aa3e6c..98b5a98 100644 --- a/web.js +++ b/web.js @@ -1,146 +1,301 @@ -var twilio = require('twilio'); -var express = require('express'); -var logfmt = require('logfmt'); -var moment = require('moment'); - -var app = express(); - -// Express Middleware -app.use(logfmt.requestLogger()); -app.use(express.json()); -app.use(express.urlencoded()); -app.use(express.cookieParser(process.env.COOKIE_SECRET)); -app.use(express.cookieSession()); - -// Allows CORS -app.all('*', function(req, res, next) { - res.header("Access-Control-Allow-Origin", "*"); - res.header("Access-Control-Allow-Headers", "X-Requested-With"); - next(); +/* 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', function(req, res) { - res.send('\n' + ''); +/* Enable CORS support for IE8. */ +app.get('/proxy.html', (req, res) => { + res.send('\n'); }); -app.get('/', function(req, res) { - res.status(200).send('Hello, I am Courtbot. I have a heart of justice and a knowledge of court cases.'); +app.get('/', (req, res) => { + res.status(200).send(messages.iAmCourtBot()); }); -// Fuzzy search that returns cases with a partial name match or -// an exact citation match -app.get('/cases', function(req, res) { - if (!req.query || !req.query.q) return res.send(400); +/* Add routes for api access */ +app.use('/api', web_api); - db.fuzzySearch(req.query.q, function(err, data) { - // Add readable dates, to avoid browser side date issues - data.forEach(function(d) { - d.readableDate = moment(d.date).format('dddd, MMM Do'); - d.payable = canPayOnline(d); - }); +/* 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); + } - res.send(data); - }); + 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)); }); -// Respond to text messages that come in from Twilio -app.post('/sms', function(req, res) { - var twiml = new twilio.TwimlResponse(); - var text = req.body.Body.toUpperCase(); - - if (req.session.askedReminder) { - if (text === 'YES' || text === 'YEA' || text === 'YUP' || text === 'Y') { - var match = req.session.match; - db.addReminder({ - caseId: match.id, - phone: req.body.From, - originalCase: JSON.stringify(match) - }, function(err, data) {}); - - twiml.sms('Sounds good. We\'ll text you a day before your case. Call us at (404) 954-7914 with any other questions.'); - req.session.askedReminder = false; - res.send(twiml.toString()); - } else if (text === 'NO' || text ==='N') { - twiml.sms('Alright, no problem. See you on your court date. Call us at (404) 954-7914 with any other questions.'); - req.session.askedReminder = false; - res.send(twiml.toString()); - } - } - - if (req.session.askedQueued) { - if (text === 'YES' || text === 'YEA' || text === 'YUP' || text === 'Y') { - db.addQueued({ - citationId: req.session.citationId, - phone: req.body.From - }, function(err, data) {}); - - twiml.sms('Sounds good. We\'ll text you in the next 14 days. Call us at (404) 954-7914 with any other questions.'); - req.session.askedQueued = false; - res.send(twiml.toString()); - } else if (text === 'NO' || text ==='N') { - twiml.sms('No problem. Call us at (404) 954-7914 with any other questions.'); - req.session.askedQueued = false; - res.send(twiml.toString()); - } - } - - db.findCitation(text, function(err, results) { - // If we can't find the case, or find more than one case with the citation - // number, give an error and recommend they call in. - if (!results || results.length === 0 || results.length > 1) { - var correctLengthCitation = 6 <= text.length && text.length <= 9; - if (correctLengthCitation) { - twiml.sms('Couldn\'t find your case. It takes 14 days for new citations to appear in the sytem. Would you like a text when we find your information? (Reply YES or NO)'); - - req.session.askedQueued = true; - req.session.citationId = text; - } else { - twiml.sms('Sorry, we couldn\'t find that court case. Please call us at (404) 954-7914.'); - } - } else { - var match = results[0]; - var name = cleanupName(match.defendant); - var date = moment(match.date).format('dddd, MMM Do'); - - if (canPayOnline(match)){ - twiml.sms('You can pay now and skip court. Just call (404) 658-6940 or visit court.atlantaga.gov. \n\nOtherwise, your court date is ' + date + ' at ' + match.time +', in courtroom ' + match.room + '.'); - } else { - twiml.sms('Found a court case for ' + name + ' on ' + date + ' at ' + match.time +', in courtroom ' + match.room +'. Would you like a reminder the day before? (reply YES or NO)'); - - req.session.match = match; - req.session.askedReminder = true; - } +/** + * 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()); - }); -}); +} -// You can pay online if ALL your individual citations can be paid online -var canPayOnline = function(courtCase) { - var eligible = true; - courtCase.citations.forEach(function(citation) { - if (citation.payable !== '1') eligible = false; - }); - return eligible; -}; +/* 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'); +} -var cleanupName = function(name) { - // Switch LAST, FIRST to FIRST LAST - var bits = name.split(','); - name = bits[1] + ' ' + bits[0]; - name = name.trim(); - // Change FIRST LAST to First Last - name = name.replace(/\w\S*/g, function(txt) { return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase(); }); +/* 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'); + } +}); - return name; +/* Send all uncaught exceptions to Rollbar??? */ +const options = { + exitOnUncaughtException: true, }; -var port = Number(process.env.PORT || 5000); -app.listen(port, function() { - console.log("Listening on " + port); +const port = Number(process.env.PORT || 5000); +app.listen(port, () => { + log.info(`Listening on port ${port}`); }); module.exports = app; diff --git a/web_api/db.js b/web_api/db.js new file mode 100644 index 0000000..44aa078 --- /dev/null +++ b/web_api/db.js @@ -0,0 +1,269 @@ +require('dotenv').config(); + +const crypto = require('crypto'); +const manager = require("../utils/db/manager"); +const knex = manager.knex; + +/** + * Details about specific case. + * @param {*} case_id + */ +function findHearing(case_id) { + case_id = case_id.toUpperCase().trim(); + return knex.raw(` + SELECT h.case_id, h.date, h.room, h.type, h.defendant + FROM hearings h + WHERE case_id = :case_id + `, {case_id: case_id}) + .then(r => r.rows) + } + +/** + * Number of current hearings and the last run of the load script + */ +function hearingCount() { + return knex.raw(` + SELECT lr.*, ( + SELECT COUNT(*) FROM hearings + ) + FROM log_runners lr + WHERE runner = 'load' + ORDER BY lr.date DESC LIMIT 1 + `) + .then(r => r.rows) +} + +/** + * Gets request stats + * returns a simple object with counts: { scheduled: '3', sent: '10', all: '3' } + */ +function requestCounts() { + return knex('requests') + .select(knex.raw('COUNT(DISTINCT phone) AS phone_count, COUNT(DISTINCT case_id) AS case_count')) + .first() + } + +/** + * Counts of SMS hits within last daysback days by action type + * @param {*} daysback + */ +function actionCounts(daysback = 7){ + return knex.raw(` + SELECT action as type, COUNT(*) + FROM log_hits + WHERE time > CURRENT_DATE - '1 DAYS'::INTERVAL * :days AND action IS NOT NULL + GROUP BY action + ORDER BY action; + `, {days: daysback}) + .then(r => r.rows) + } + +/** + * Counts of notifications within last daysback days by type + * @param {Number} daysback + */ + function notificationCounts(daysback = 7){ + return knex.raw(` + SELECT type, COUNT(*) + FROM notifications + WHERE created_at > CURRENT_DATE - '1 DAYS'::INTERVAL * :days + GROUP BY type + ORDER BY type; + `, {days: daysback}) + .then(r => r.rows) +} +/** + * Erros sending notifications within last daysback days + * @param {Number} daysback + */ +function notificationErrors(daysback = 7){ + return knex.raw(` + SELECT * + FROM notifications + WHERE created_at > CURRENT_DATE - '1 DAYS'::INTERVAL * :days AND error is NOT NULL + ORDER BY created_at; + `, {days: daysback}) + .then(r => r.rows) +} +/** + * Get requests and associated notifactions from a case_id + * @param {String} case_id + */ +function findRequestNotifications(case_id) { + case_id = case_id.toUpperCase().trim(); + return knex.raw(` + SELECT r.case_id, r.phone, r.created_at, r.active, json_agg(n) as notifications + FROM requests r + LEFT JOIN notifications n ON (r.case_id = n.case_id AND r.phone = n.phone) + WHERE r.case_id = :case_id + GROUP BY (r.case_id, r.phone) + `, {case_id: case_id}) + .then(r => r.rows) + } + +/** + * Get all requests associated with a phone number + * @param {String} encypted phone number + */ + function findRequestsFromPhone(phone) { + // expects encypted phone + return knex.raw(` + SELECT r.case_id, r.phone, r.created_at, r.active, json_agg(n) as notifications + FROM requests r + LEFT JOIN notifications n ON (r.case_id = n.case_id AND r.phone = n.phone) + WHERE r.phone = :phone + GROUP BY (r.case_id, r.phone) + `, {phone: phone}) + .then(r => r.rows) + } + + /** + * A logs from a particular phone + * @param {String} encypted phone number + */ + function phoneLog(phone){ + return knex.raw(` + SELECT * + FROM log_hits + WHERE phone = :phone + ORDER BY time DESC; + `, {phone: phone}) + .then(r => r.rows) + } + +/** + * The last run dates from runner scripts + */ + function notificationRunnerLog(){ + return knex.raw(` + SELECT runner, + MAX(date) as date + FROM log_runners GROUP BY runner; + `) + .then(r => r.rows) + } + + /** + * The notfications associated with a specific case_id + * @param {string} case_id + */ + function notificationsFromCitation(case_id) { + return knex('notifications') + .where('case_id', case_id) + } + + +/** + * Returns logged action counts grouped by day and action + * @param {Number} daysback (ooptional) + */ +function actionsByDay(daysback = 14){ + return knex.raw(` + WITH actions as( + SELECT DISTINCT action from log_hits WHERE action is NOT NULL + ), + date_list as ( + SELECT generate_series as day FROM generate_series( + CURRENT_DATE - '1 DAYS'::INTERVAL * :days, + current_date, + '1 day' + ) + ) + SELECT day, json_agg(jsonb_build_object('type', action, 'count', count)) as actions FROM + ( + SELECT date_list.day, actions.action, count(log_hits) FROM date_list + CROSS JOIN actions + LEFT JOIN log_hits on log_hits.time::date = day AND log_hits.action = actions.action + GROUP by date_list.day, actions.action + ) as ag + GROUP BY day + ORDER BY day; + `, {days: daysback}) + .then(r => r.rows) +} + +/** + * All notifications since daysback [default 30 days] + * @param {Number} daysback (optional) + */ +function recentNotifications(daysback = 30){ + return knex.raw(` + SELECT type, json_agg(jsonb_build_object( + 'case_id', case_id, + 'created_at', created_at, + 'phone', phone, + 'event_date', event_date) + ORDER BY created_at DESC + ) AS notices + FROM notifications + WHERE created_at > CURRENT_DATE - '1 DAYS'::INTERVAL * :days + GROUP BY type + ORDER BY type DESC; + `, {days: daysback}) + .then(r => r.rows) +} + +/** + * All notifications since daysback [default 30 days] grouped by day + * @param {Number} daysback (optional) + */ +function recentNotificationsByDay(daysback = 30){ + return knex.raw(` + SELECT created_at::date, json_agg(jsonb_build_object( + 'case_id', case_id, + 'created_at', created_at, + 'phone', phone, + 'event_date', event_date, + 'error', error, + 'type', type) + ORDER BY created_at DESC + ) AS notices + FROM notifications + WHERE created_at > CURRENT_DATE - '1 DAYS'::INTERVAL * :days + GROUP BY created_at::date + ORDER BY created_at::date DESC; + `, {days: daysback}) + .then(r => r.rows) +} + +/** + * Histogram of user inputs that the app didn't understand + * @param {*} daysback + */ +function unusableInput(daysback = 30){ + return knex.raw(` + SELECT body, count(*) from log_hits + WHERE action='unusable_input' AND time > CURRENT_DATE - '1 DAYS'::INTERVAL * :days + GROUP BY body + ORDER BY count DESC; + `, {days: daysback}) + .then(r => r.rows) +} + +function notificationErrors(daysback = 30){ + return knex.raw(` + select * from notifications + WHERE error is NOT NULL AND created_at > CURRENT_DATE - '1 DAYS'::INTERVAL * :days + `, {days: daysback}) + .then(r => r.rows) +} + + + module.exports = { + findHearing, + hearingCount, + requestCounts, + findRequestNotifications, + findRequestsFromPhone, + phoneLog, + actionCounts, + actionsByDay, + notificationCounts, + notificationErrors, + notificationRunnerLog, + recentNotifications, + recentNotificationsByDay, + unusableInput, + notificationErrors + } + diff --git a/web_api/routes.js b/web_api/routes.js new file mode 100644 index 0000000..7d3226e --- /dev/null +++ b/web_api/routes.js @@ -0,0 +1,245 @@ +require('dotenv').config(); +const express = require('express') +const router = express.Router() +const db = require('./db') +const moment = require('moment-timezone') +const jwt = require("jsonwebtoken"); + +/** + * Test whether user/password is valid + * @param {String} user + * @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 } +} + +/** + * Middleware that checks JWT in auth header is valid. If it is it will call next() to allow request to proceed + * otherwise sends 401. + * @param {*} req + * @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"}) + } +} + +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) + .then(data => { + res.send(data); + }) + .catch(err => next(err)) +}) + +/** + * Returns requests associated with case_id. If + * there are notifications associated with request the will be + * included in a notifcations list + * @param {String} case_id + * @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) + .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); + }) + .catch(err => next(err)) +}) + +/** + * Returns requests associated with phone. If + * there are notifications associated with request the will be + * included in a notifcations list + * @param {String} encrypted phone + * @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) + .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); + }) + .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) + .then(data => res.send(data)) + .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) + .then(data => res.send(data)) + .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) + .then(data => res.send(data)) + .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) + .then(data => res.send(data)) + .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) + .then(data => res.send(data)) + .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() + .then(data => res.send(data)) + .catch(err => next(err)) +}) + +/** + * The current number of distinct cases for which there are requests + * and the number of distinct phone numbers watching cases + * @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() + .then(data => res.send(data)) + .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) + .then(data => res.send(data)) + .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) + .then(data => res.send(data)) + .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() + .then(data => res.send(data)) + .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) + .then(data => res.send(data)) + .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) + .then(data => res.send(data)) + .catch(err => next(err)) +}) + +module.exports = router; \ No newline at end of file