├── assets ├── banner.png └── dispatcher-bot.png ├── src ├── __mocks__ │ └── geo.js ├── slack │ ├── bot.js │ ├── sendError.js │ ├── sendDispatch.js │ ├── reminder.js │ └── message │ │ └── index.js ├── utils │ ├── date-utils.js │ ├── phone-number-utils.js │ └── airtable-utils.js ├── http.js ├── logger │ ├── index.js │ └── slack-error-transport.js ├── model │ ├── user-record.js │ └── request-record.js ├── languageFilter.js ├── geo.js ├── service │ ├── volunteer-service.js │ ├── requester-service.js │ └── request-service.js ├── config.js ├── task.js └── index.js ├── .editorconfig ├── entrypoints └── start.sh ├── Dockerfile ├── test ├── slack │ ├── sendDispatch.test.js │ └── message │ │ └── index.test.js ├── utils │ ├── phone-number-utils.test.js │ └── airtable-utils.test.js ├── languageFilter.test.js ├── service │ ├── volunteer-service.test.js │ ├── requester-service.test.js │ └── request-service.test.js └── task.test.js ├── Makefile ├── .eslintrc.json ├── scripts ├── blocks │ └── volunteer-name-transforms.js └── oneoff │ ├── backfillRequesters.js │ └── removeExtraWhitespace.js ├── .github └── workflows │ ├── test.yml │ └── linting.yml ├── docker-compose.yaml ├── LICENSE ├── package.json ├── README.md ├── .gitignore ├── docs ├── ENVIRONMENT_VARIABLES.md └── HOW_TO_GET_API_KEYS.md └── jest.config.js /assets/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/astoria-tech/volunteer-dispatch/HEAD/assets/banner.png -------------------------------------------------------------------------------- /assets/dispatcher-bot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/astoria-tech/volunteer-dispatch/HEAD/assets/dispatcher-bot.png -------------------------------------------------------------------------------- /src/__mocks__/geo.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | distanceBetweenCoords: jest.fn(), 3 | getCoords: jest.fn(), 4 | }; 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = false 8 | insert_final_newline = false 9 | -------------------------------------------------------------------------------- /entrypoints/start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | MAIN_SCRIPT=/srv/src/index.js 4 | 5 | if [ ${NODE_ENV} = 'production' ]; then 6 | node ${MAIN_SCRIPT} 7 | else 8 | npm start 9 | fi 10 | -------------------------------------------------------------------------------- /src/slack/bot.js: -------------------------------------------------------------------------------- 1 | require("dotenv").config(); 2 | 3 | const Slack = require("slack"); 4 | const config = require("../config"); 5 | 6 | const token = config.SLACK_TOKEN; 7 | const bot = new Slack({ token }); 8 | 9 | module.exports = { 10 | bot, 11 | token, 12 | }; 13 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:13.10-alpine3.11 2 | 3 | ARG NODE_ENV=production 4 | ENV NODE_ENV $NODE_ENV 5 | 6 | RUN mkdir -p /srv && chown node:node /srv 7 | WORKDIR /srv 8 | USER node 9 | COPY package.json package-lock.json* ./ 10 | 11 | RUN npm install 12 | 13 | COPY . ./ 14 | 15 | CMD ["/srv/entrypoints/start.sh"] 16 | -------------------------------------------------------------------------------- /src/utils/date-utils.js: -------------------------------------------------------------------------------- 1 | const moment = require("moment"); 2 | 3 | /** 4 | * Get the amount of time passed from a date until now. 5 | * 6 | * @param {string} date Date string as returned from Airtable. 7 | * @returns {string} A colloquial express of ellapsed time (e.g., 2 months ago) 8 | */ 9 | const getElapsedTime = (date) => { 10 | return moment(date).fromNow(); 11 | }; 12 | 13 | module.exports = { 14 | getElapsedTime, 15 | }; 16 | -------------------------------------------------------------------------------- /test/slack/sendDispatch.test.js: -------------------------------------------------------------------------------- 1 | jest.mock("../../src/config", () => { 2 | return { 3 | AIRTABLE_API_KEY: "mock-api-key", 4 | AIRTABLE_BASE_ID: "mock-base-id", 5 | }; 6 | }); 7 | 8 | const { sendDispatch } = require("../../src/slack/sendDispatch"); 9 | 10 | test("sendMessage() throws error when no requester record is passed", async () => { 11 | await expect(sendDispatch()).rejects.toThrow( 12 | "No record passed to sendMessage()." 13 | ); 14 | }); 15 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | develop: clean build run ## Alias for: clean build run. 2 | 3 | help: ## Prints help for targets with comments. 4 | @grep -E '^[a-zA-Z._-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}' 5 | 6 | clean: ## Remove stopped Docker services. 7 | docker-compose rm -vf 8 | 9 | build: ## Build Docker services. 10 | docker-compose build 11 | 12 | run: ## Start Docker services. 13 | docker-compose up 14 | 15 | pr: ## Prepare for pull request 16 | npm run lint 17 | npm run test 18 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "airbnb-base", 4 | "plugin:jest/recommended", 5 | "plugin:prettier/recommended", 6 | "plugin:jsdoc/recommended" 7 | ], 8 | "plugins": [ 9 | "jest", 10 | "jsdoc" 11 | ], 12 | "rules": { 13 | "arrow-parens": "off", 14 | "no-console": "off", 15 | "no-continue": "off", 16 | "no-await-in-loop": "off", 17 | "quote-props": "off", 18 | "no-restricted-syntax": "off", 19 | "max-len": [2, {"code": 100, "tabWidth": 2, "ignoreUrls": true, "ignoreStrings": true}] 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /scripts/blocks/volunteer-name-transforms.js: -------------------------------------------------------------------------------- 1 | // Parse the "Full Name" field and into "First" and "Last" name fields. 2 | // One off script to be run in Airtable script block. 3 | 4 | const table = base.getTable('Volunteers'); 5 | 6 | // Update all the records 7 | const result = await table.selectRecordsAsync(); 8 | for (let record of result.records) { 9 | 10 | const fullName = record.getCellValue('Full Name'); 11 | 12 | if (fullName === null) { continue; } 13 | if (record.getCellValue('First Name') !== null) { continue; } 14 | 15 | const namePieces = fullName.split(' '); 16 | const firstName = namePieces.shift(); 17 | const lastName = namePieces.join(' '); 18 | 19 | await table.updateRecordAsync(record, { 20 | 'First Name': firstName, 21 | 'Last Name': lastName 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /src/http.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const bodyParser = require("body-parser"); 3 | const { logger } = require("./logger"); 4 | const { handleButtonUpdate, slackConf } = require("./slack/reminder"); 5 | 6 | const app = express(); 7 | const port = 3000; 8 | 9 | app.use( 10 | bodyParser.urlencoded({ 11 | extended: false, 12 | }) 13 | ); 14 | 15 | app.get("/", (req, res) => res.send("ok")); 16 | 17 | // this route is handling slack update buttons 18 | app.post("/slack/actions/", (req, res) => { 19 | if (slackConf(req)) { 20 | const body = JSON.parse(req.body.payload); 21 | res.sendStatus(200); 22 | handleButtonUpdate(body); 23 | } else { 24 | res.status(400).send("Ignore this request."); 25 | } 26 | }); 27 | 28 | function run() { 29 | app.listen(port, () => 30 | logger.info(`Health check running: http://localhost:${port}`) 31 | ); 32 | } 33 | 34 | module.exports = { 35 | run, 36 | }; 37 | -------------------------------------------------------------------------------- /test/utils/phone-number-utils.test.js: -------------------------------------------------------------------------------- 1 | const { getDisplayNumber } = require("../../src/utils/phone-number-utils"); 2 | 3 | test("Return kebab-case number when passed number is formatted with parens", () => { 4 | expect(getDisplayNumber("(212) 222-2222")).toBe("212-222-2222"); 5 | }); 6 | 7 | test("Return parsed number when 10 unformatted digits are passed", () => { 8 | expect(getDisplayNumber("2122222222")).toBe("212-222-2222"); 9 | }); 10 | 11 | test("Return parsed number when 11 unformattted digits are passed", () => { 12 | expect(getDisplayNumber("12122222222")).toBe("212-222-2222"); 13 | }); 14 | 15 | test("Return a human readable string if no value is passed", () => { 16 | expect(getDisplayNumber()).toBe("None provided"); 17 | }); 18 | 19 | test("Return raw input (plus flag string) if unparseable value is passed", () => { 20 | const unparseableValue = "n/a"; 21 | expect(getDisplayNumber(unparseableValue)).toBe( 22 | `${unparseableValue} _[Bot note: unparseable number.]_` 23 | ); 24 | }); 25 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | 2 | name: Tests 3 | on: pull_request 4 | jobs: 5 | jest: 6 | runs-on: ubuntu-latest 7 | container: 8 | image: node:12 9 | 10 | steps: 11 | - uses: actions/checkout@v2 12 | - name: Cache node modules 13 | id: cache 14 | uses: actions/cache@v1 15 | with: 16 | path: node_modules # npm cache files are stored in `~/.npm` on Linux/macOS 17 | key: ${{ runner.os }}-node_modules-${{ hashFiles('**/package-lock.json') }} 18 | restore-keys: | 19 | ${{ runner.os }}-build-${{ env.cache-name }}- 20 | ${{ runner.os }}-build- 21 | ${{ runner.os }}- 22 | - name: Install dependencies 23 | if: steps.cache.outputs.cache-hit != 'true' 24 | run: npm ci 25 | - name: Jest 26 | run: npm run test 27 | - name: Coveralls GitHub Action 28 | uses: coverallsapp/github-action@v1.1.1 29 | with: 30 | github-token: ${{ secrets.GITHUB_TOKEN }} 31 | -------------------------------------------------------------------------------- /src/logger/index.js: -------------------------------------------------------------------------------- 1 | const { createLogger, format, transports } = require("winston"); 2 | const SlackErrorTransport = require("./slack-error-transport"); 3 | 4 | /** 5 | * Format log message. 6 | * 7 | * @param {Date} timestamp Current unix timestamp. 8 | * @param {string} level The log level. Can be 'error', 'warning', 'info', or 'debug'. 9 | * @param {string} message The message to log. 10 | * @param {object} stack The stack trace. 11 | * @returns {string} The formatted log entry. 12 | */ 13 | const myFormat = format.printf(({ timestamp, level, message, stack }) => { 14 | return `${timestamp} ${level}: ${message} ${stack ? `\n ${stack}` : ""}`; 15 | }); 16 | 17 | const logger = createLogger({ 18 | format: format.combine(format.timestamp(), format.colorize(), myFormat), 19 | transports: [ 20 | new transports.Console(), 21 | new SlackErrorTransport({ level: "error" }), 22 | ], 23 | exceptionHandlers: [new transports.Console(), new SlackErrorTransport()], 24 | }); 25 | 26 | exports.logger = logger; 27 | -------------------------------------------------------------------------------- /src/logger/slack-error-transport.js: -------------------------------------------------------------------------------- 1 | const Transport = require("winston-transport"); 2 | const { sendError } = require("../slack/sendError"); 3 | 4 | /** 5 | * Inherit from `winston-transport` so you can take advantage 6 | * of the base functionality and `.exceptions.handle()`. 7 | */ 8 | module.exports = class SlackErrorTransport extends Transport { 9 | // constructor(opts) { 10 | // super(opts); 11 | // // 12 | // // Consume any custom options here. e.g.: 13 | // // - Connection information for databases 14 | // // - Authentication information for APIs (e.g. loggly, papertrail, 15 | // // logentries, etc.). 16 | // // 17 | // } 18 | 19 | /** 20 | * Log info. 21 | * 22 | * @param {string} info The information to log. 23 | * @param {Function} callback The function to call after logging. 24 | * @returns {void} 25 | */ 26 | async log(info, callback) { 27 | setImmediate(() => { 28 | this.emit("logged", info); 29 | }); 30 | 31 | // Perform the writing to the remote service 32 | await sendError(info); 33 | 34 | callback(); 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /.github/workflows/linting.yml: -------------------------------------------------------------------------------- 1 | name: Linting 2 | on: pull_request 3 | jobs: 4 | eslint: 5 | runs-on: ubuntu-latest 6 | container: 7 | image: node:12 8 | 9 | steps: 10 | - uses: actions/checkout@v1 11 | - name: Cache node modules 12 | id: cache 13 | uses: actions/cache@v1 14 | with: 15 | path: node_modules # npm cache files are stored in `~/.npm` on Linux/macOS 16 | key: ${{ runner.os }}-node_modules-${{ hashFiles('**/package-lock.json') }} 17 | restore-keys: | 18 | ${{ runner.os }}-build-${{ env.cache-name }}- 19 | ${{ runner.os }}-build- 20 | ${{ runner.os }}- 21 | - name: Install Dependencies 22 | if: steps.cache.outputs.cache-hit != 'true' 23 | run: npm ci 24 | - name: ESlint 25 | run: npm run lint 26 | no-console: 27 | runs-on: ubuntu-latest 28 | container: 29 | image: node:12 30 | 31 | steps: 32 | - uses: actions/checkout@v1 33 | - name: Check for console usage 34 | run: | 35 | [ $(grep -R "console." src | wc -l) = 0 ] 36 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | 5 | bot: 6 | labels: 7 | shipyard.route: '/' 8 | build: 9 | context: . 10 | args: 11 | - NODE_ENV=${NODE_ENV:-development} 12 | environment: 13 | AIRTABLE_API_KEY: ${AIRTABLE_API_KEY} 14 | AIRTABLE_BASE_ID: ${AIRTABLE_BASE_ID} 15 | AIRTABLE_REQUESTS_VIEW_NAME: ${AIRTABLE_REQUESTS_VIEW_NAME} 16 | AIRTABLE_REQUESTS_VIEW_URL: ${AIRTABLE_REQUESTS_VIEW_URL} 17 | AIRTABLE_VOLUNTEERS_VIEW_NAME: ${AIRTABLE_VOLUNTEERS_VIEW_NAME} 18 | AIRTABLE_VOLUNTEERS_VIEW_URL: ${AIRTABLE_VOLUNTEERS_VIEW_URL} 19 | GOOGLE_API_KEY: ${GOOGLE_API_KEY} 20 | MAPQUEST_KEY: ${MAPQUEST_KEY} 21 | NODE_ENV: ${NODE_ENV:-development} 22 | SLACK_ALERT_CHANNEL_ID: ${SLACK_ALERT_CHANNEL_ID} 23 | SLACK_CHANNEL_ID: ${SLACK_CHANNEL_ID} 24 | SLACK_XOXB: ${SLACK_XOXB} 25 | VOLUNTEER_DISPATCH_PREVENT_PROCESSING: ${VOLUNTEER_DISPATCH_PREVENT_PROCESSING} 26 | VOLUNTEER_DISPATCH_STATE: ${VOLUNTEER_DISPATCH_STATE} 27 | volumes: 28 | - "./src:/srv/src" 29 | ports: 30 | - '3000:3000' 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Astoria Tech 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/model/user-record.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A User in the system 3 | */ 4 | class UserRecord { 5 | constructor(airtableRequest) { 6 | this.airtableRequest = airtableRequest; 7 | } 8 | 9 | /** 10 | * ID of the record 11 | * 12 | * @type {string} 13 | */ 14 | get id() { 15 | return this.airtableRequest.id; 16 | } 17 | 18 | /** 19 | * Emulates a getter you would normally see on an Airtable object. 20 | * 21 | * @param {*} field Field to get 22 | * @throws an error always, to let you know that this isn't the right way to access a property. 23 | */ 24 | // eslint-disable-next-line class-methods-use-this 25 | get(field) { 26 | throw Error( 27 | `Please try to access the property '${field}' using its getter instead of this method.` 28 | ); 29 | } 30 | 31 | /** 32 | * Full name of the user 33 | * 34 | * @type {string} 35 | */ 36 | get fullName() { 37 | return this.airtableRequest.get("Full Name"); 38 | } 39 | 40 | /** 41 | * Phone number of the user 42 | * 43 | * @type {string} 44 | */ 45 | get phoneNumber() { 46 | return this.airtableRequest.get("Phone Number"); 47 | } 48 | } 49 | 50 | module.exports = UserRecord; 51 | -------------------------------------------------------------------------------- /src/languageFilter.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @param {object} request The Airtable request object. 3 | * @param {Array} volunteerDistances List of all potential volunteer records with distance. 4 | * @returns {Array} List of potential volunteers that speak requester's language. 5 | */ 6 | function filterByLanguage(request, volunteerDistances) { 7 | // HACK: only use the first language until we introduce logic to search for multiple languagers 8 | let firstLanguage; 9 | const languages = request.get("Language"); 10 | if (languages && languages.length && languages.length > 0) { 11 | firstLanguage = languages.shift(); 12 | } else { 13 | firstLanguage = "English"; 14 | } 15 | 16 | // If the language is English, return everyone. Otherwise, filter by the language 17 | let result; 18 | if (firstLanguage === "English") { 19 | result = volunteerDistances; 20 | } else { 21 | result = volunteerDistances.filter((volunteerAndDistance) => { 22 | const volunteer = volunteerAndDistance[0]; 23 | const volLanguages = volunteer.get( 24 | "Please select any language you have verbal fluency with:" 25 | ); 26 | return (volLanguages || []).some( 27 | (language) => language === firstLanguage 28 | ); 29 | }); 30 | } 31 | 32 | return result; 33 | } 34 | 35 | module.exports = { filterByLanguage }; 36 | -------------------------------------------------------------------------------- /src/utils/phone-number-utils.js: -------------------------------------------------------------------------------- 1 | const PNF = require("google-libphonenumber").PhoneNumberFormat; 2 | const phoneUtil = require("google-libphonenumber").PhoneNumberUtil.getInstance(); 3 | 4 | /** 5 | * Parse phone numbers 6 | * 7 | * @param {string} rawInput - phone number to operate on. 8 | * @returns {string} - formatted phone number. 9 | */ 10 | const getTappablePhoneNumber = (rawInput) => { 11 | let parsedNumber; 12 | try { 13 | parsedNumber = phoneUtil.parseAndKeepRawInput(rawInput, "US"); 14 | } catch (error) { 15 | return false; // Not a phone number 16 | } 17 | 18 | if (!phoneUtil.isValidNumber(parsedNumber)) { 19 | return false; // Not a phone number 20 | } 21 | 22 | // Return +1 ###-###-#### without the country code 23 | return phoneUtil.format(parsedNumber, PNF.INTERNATIONAL).substring(3); 24 | }; 25 | 26 | /** 27 | * Format phone numbers 28 | * 29 | * @param {string} rawInput - phone number to operate on. 30 | * @returns {string} - formatted phone number. 31 | */ 32 | const getDisplayNumber = (rawInput) => { 33 | if (!rawInput) return "None provided"; 34 | 35 | const tappableNumber = getTappablePhoneNumber(rawInput); 36 | 37 | const displayNumber = 38 | tappableNumber || `${rawInput} _[Bot note: unparseable number.]_`; 39 | 40 | return displayNumber; 41 | }; 42 | 43 | module.exports = { 44 | getDisplayNumber, 45 | }; 46 | -------------------------------------------------------------------------------- /src/geo.js: -------------------------------------------------------------------------------- 1 | const NodeGeocoder = require("node-geocoder"); 2 | const geolib = require("geolib"); 3 | const { logger } = require("./logger"); 4 | const config = require("./config"); 5 | require("dotenv").config(); 6 | 7 | const METERS_TO_MILES = 0.000621371; 8 | 9 | // Geocoder 10 | const ngcOptions = { 11 | httpAdapter: "https", 12 | formatter: null, 13 | }; 14 | 15 | // Use Google Maps if API key provided, otherwise use MapQuest 16 | const useGoogleApi = 17 | typeof config.GOOGLE_API_KEY === "string" && config.GOOGLE_API_KEY.length > 0; 18 | ngcOptions.provider = useGoogleApi ? "google" : "mapquest"; 19 | ngcOptions.apiKey = useGoogleApi ? config.GOOGLE_API_KEY : config.MAPQUEST_KEY; 20 | const geocoder = NodeGeocoder(ngcOptions); 21 | 22 | logger.info(`Geocoder: ${ngcOptions.provider}`); 23 | 24 | /** 25 | * Get coordinates. 26 | * 27 | * @param {string} address The Airtable address to pull coordinates from. 28 | * @returns {Promise} Promise object containing properties latitude and longitude. 29 | */ 30 | function getCoords(address) { 31 | return new Promise((resolve, reject) => { 32 | geocoder.geocode(address, (err, res) => { 33 | if (err || res.length === 0) { 34 | reject(err); 35 | } else { 36 | resolve({ 37 | latitude: res[0].latitude, 38 | longitude: res[0].longitude, 39 | }); 40 | } 41 | }); 42 | }); 43 | } 44 | 45 | const distanceBetweenCoords = (volCoords, errandCoords) => 46 | METERS_TO_MILES * geolib.getDistance(volCoords, errandCoords); 47 | 48 | module.exports = { 49 | distanceBetweenCoords, 50 | getCoords, 51 | }; 52 | -------------------------------------------------------------------------------- /src/service/volunteer-service.js: -------------------------------------------------------------------------------- 1 | const preconditions = require("preconditions").singleton(); 2 | const { Random, nodeCrypto } = require("random-js"); 3 | 4 | const config = require("../config"); 5 | const Task = require("../task"); 6 | 7 | /** 8 | * APIs that interact with Volunteer 9 | */ 10 | class VolunteerService { 11 | constructor(base) { 12 | preconditions.shouldBeObject(base); 13 | this.base = base; 14 | this.random = new Random(nodeCrypto); 15 | } 16 | 17 | /** 18 | * Honestly, this is being exposed for testing. 19 | * 20 | * @param {Array} lonelinessVolunteers to append to. 21 | * @returns {function(...[*]=)} A function that can be provided to Airtable's `eachPage` function 22 | */ 23 | // eslint-disable-next-line class-methods-use-this 24 | appendVolunteersForLoneliness(lonelinessVolunteers) { 25 | return (volunteers, nextPage) => { 26 | volunteers 27 | .filter((v) => Task.LONELINESS.canBeFulfilledByVolunteer(v)) 28 | .forEach((v) => lonelinessVolunteers.push(v)); 29 | nextPage(); 30 | }; 31 | } 32 | 33 | /** 34 | * Fetches volunteers willing to to take on loneliness relates tasks 35 | * 36 | * @returns {Promise<[]>} Volunteers capable of handling loneliness tasks 37 | */ 38 | async findVolunteersForLoneliness() { 39 | const lonelinessVolunteers = []; 40 | await this.base 41 | .select({ 42 | view: config.AIRTABLE_VOLUNTEERS_VIEW_NAME, 43 | filterByFormula: "{Account Disabled} != TRUE()", 44 | }) 45 | .eachPage(this.appendVolunteersForLoneliness(lonelinessVolunteers)); 46 | const sampleSize = 47 | lonelinessVolunteers.length > 10 ? 10 : lonelinessVolunteers.length; 48 | return this.random.sample(lonelinessVolunteers, sampleSize); 49 | } 50 | } 51 | 52 | module.exports = VolunteerService; 53 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "slack-volunteer-seeker", 3 | "version": "1.0.0", 4 | "description": "Slackbot to coordinate errand volunteering", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "jest --coverage", 8 | "test:slack": "jest test/slack/*", 9 | "start": "nodemon src/index.js", 10 | "lint": "eslint src/**/*.js test/**/*.js", 11 | "lint:fix": "eslint src/**/*.js test/**/*.js --fix", 12 | "backfill-requesters": "node scripts/oneoff/backfillRequesters.js", 13 | "remove-extra-whitespace": "node scripts/oneoff/removeExtraWhitespace.js" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/astoria-tech/slack-volunteer-seeker.git" 18 | }, 19 | "author": "Astoria Tech", 20 | "license": "ISC", 21 | "bugs": { 22 | "url": "https://github.com/astoria-tech/slack-volunteer-seeker/issues" 23 | }, 24 | "homepage": "https://github.com/astoria-tech/slack-volunteer-seeker#readme", 25 | "dependencies": { 26 | "airtable": "^0.10.0", 27 | "axios": "^0.19.2", 28 | "body-parser": "^1.19.0", 29 | "condense-whitespace": "^2.0.0", 30 | "dotenv": "^8.2.0", 31 | "express": "^4.17.1", 32 | "geolib": "^3.2.1", 33 | "google-libphonenumber": "^3.2.8", 34 | "moment": "^2.26.0", 35 | "node-geocoder": "^3.25.1", 36 | "preconditions": "^2.2.3", 37 | "qs": "^6.9.3", 38 | "random-js": "^2.1.0", 39 | "slack": "^11.0.2", 40 | "winston": "^3.2.1", 41 | "winston-transport": "^4.3.0" 42 | }, 43 | "devDependencies": { 44 | "eslint": "^6.8.0", 45 | "eslint-config-airbnb-base": "^14.1.0", 46 | "eslint-config-prettier": "^6.10.1", 47 | "eslint-plugin-import": "^2.20.1", 48 | "eslint-plugin-jest": "^23.8.2", 49 | "eslint-plugin-jsdoc": "^24.0.0", 50 | "eslint-plugin-prettier": "^3.1.2", 51 | "jest": "^25.2.7", 52 | "jest-when": "^2.7.1", 53 | "nodemon": "^2.0.2", 54 | "prettier": "^2.0.2", 55 | "rewire": "^5.0.0" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | require("dotenv").config(); 2 | 3 | /** 4 | * Environment boolean. 5 | * 6 | * @param {string} name The name of the environment variable. 7 | * @param {boolean} defaultValue The default value if name is not available. 8 | * @returns {boolean} True if name environment variable exists, or defaultValue. 9 | */ 10 | function envBoolean(name, defaultValue) { 11 | if (process.env[name]) { 12 | return process.env[name] === "true"; 13 | } 14 | return defaultValue; 15 | } 16 | 17 | const config = { 18 | // General 19 | VOLUNTEER_DISPATCH_PREVENT_PROCESSING: envBoolean( 20 | "VOLUNTEER_DISPATCH_PREVENT_PROCESSING", 21 | false 22 | ), 23 | 24 | // Geocoder 25 | GOOGLE_API_KEY: process.env.GOOGLE_API_KEY, 26 | MAPQUEST_KEY: process.env.MAPQUEST_KEY, 27 | VOLUNTEER_DISPATCH_STATE: process.env.VOLUNTEER_DISPATCH_STATE || "NY", 28 | 29 | // Airtable 30 | AIRTABLE_API_KEY: process.env.AIRTABLE_API_KEY, 31 | AIRTABLE_BASE_ID: process.env.AIRTABLE_BASE_ID, 32 | 33 | AIRTABLE_REQUESTS_TABLE_NAME: 34 | process.env.AIRTABLE_REQUESTS_TABLE_NAME || "Requests", 35 | AIRTABLE_REQUESTS_VIEW_NAME: 36 | process.env.AIRTABLE_REQUESTS_VIEW_NAME || "Grid view", 37 | AIRTABLE_REQUESTS_VIEW_URL: process.env.AIRTABLE_REQUESTS_VIEW_URL, 38 | 39 | AIRTABLE_VOLUNTEERS_TABLE_NAME: 40 | process.env.AIRTABLE_VOLUNTEERS_TABLE_NAME || "Volunteers", 41 | AIRTABLE_VOLUNTEERS_VIEW_NAME: 42 | process.env.AIRTABLE_VOLUNTEERS_VIEW_NAME || "Grid view", 43 | AIRTABLE_VOLUNTEERS_VIEW_URL: process.env.AIRTABLE_VOLUNTEERS_VIEW_URL, 44 | 45 | AIRTABLE_REQUESTERS_TABLE_NAME: 46 | process.env.AIRTABLE_REQUESTERS_TABLE_NAME || "Requesters", 47 | AIRTABLE_USERS_VIEW_NAME: process.env.AIRTABLE_USERS_VIEW_NAME || "Grid view", 48 | 49 | // Slack 50 | SLACK_TOKEN: process.env.SLACK_XOXB, 51 | SLACK_SIGNING_SECRET: process.env.SLACK_SIGNING_SECRET, 52 | SLACK_CHANNEL_ID: process.env.SLACK_CHANNEL_ID, 53 | SLACK_ALERT_CHANNEL_ID: process.env.SLACK_ALERT_CHANNEL_ID, 54 | }; 55 | 56 | module.exports = Object.freeze(config); 57 | -------------------------------------------------------------------------------- /src/utils/airtable-utils.js: -------------------------------------------------------------------------------- 1 | const preconditions = require("preconditions").singleton(); 2 | const { logger } = require("../logger"); 3 | 4 | class AirtableUtils { 5 | constructor(base) { 6 | this.base = base; 7 | } 8 | 9 | /** 10 | * Logs errors to Airtable. 11 | * 12 | * @param {string} table - name of the table to log to. 13 | * @param {object} request - the request to check. 14 | * @param {object} error - the error to log. 15 | * @param {string} operation - the 16 | * @returns {void} 17 | */ 18 | logErrorToTable(table, request, error, operation) { 19 | let errorToInsertInAirtable = `${Date.now()} - ${JSON.stringify(error)}`; 20 | if (operation) { 21 | errorToInsertInAirtable += ` while performing ${operation}`; 22 | } 23 | const existingErrors = request.get("Error"); 24 | if (existingErrors) { 25 | errorToInsertInAirtable = `${existingErrors}, ${errorToInsertInAirtable}`; 26 | } 27 | this.base(table) 28 | .update(request.id, { Error: errorToInsertInAirtable }) 29 | .catch((reason) => { 30 | logger.error( 31 | `Error while trying to update Error field in table ${table} for request ${request.id}` 32 | ); 33 | logger.error(reason); 34 | }); 35 | } 36 | 37 | /** 38 | * Clones fields of the provided request record, while replacing the "Tasks" 39 | * field with the given task. 40 | * 41 | * @param {object} request Request whose fields you want to clone 42 | * @param {object} task Task to be set 43 | * @param {string} order String that indicates the order of given task 44 | * relative to total tasks in the request 45 | * @returns {{fields: {Tasks: string[], "Cloned from": string[], 46 | * "Task Order": string[]}}} along with properties from the original request 47 | */ 48 | static cloneRequestFieldsWithGivenTask(request, task, order) { 49 | preconditions.shouldBeObject(request); 50 | preconditions.shouldBeDefined(request.id); 51 | preconditions.shouldBeObject(request.rawFields); 52 | preconditions.shouldBeObject(task); 53 | preconditions.shouldBeString(order); 54 | const fields = { 55 | ...request.rawFields, 56 | "Original Tasks": request.rawFields.Tasks, 57 | Tasks: [task.rawTask], 58 | "Cloned from": [request.id], 59 | "Task Order": order, 60 | }; 61 | delete fields["Created time"]; 62 | delete fields["Record ID"]; 63 | delete fields.Error; 64 | return { fields }; 65 | } 66 | } 67 | 68 | module.exports = AirtableUtils; 69 | -------------------------------------------------------------------------------- /scripts/oneoff/backfillRequesters.js: -------------------------------------------------------------------------------- 1 | const Airtable = require("airtable"); 2 | 3 | const AirtableUtils = require("../../src/utils/airtable-utils"); 4 | const config = require("../../src/config"); 5 | const Request = require("../../src/model/request-record"); 6 | const RequesterService = require("../../src/service/requester-service"); 7 | const RequestService = require("../../src/service/request-service"); 8 | const { logger } = require("../../src/logger"); 9 | 10 | const base = new Airtable({ apiKey: config.AIRTABLE_API_KEY }).base( 11 | config.AIRTABLE_BASE_ID 12 | ); 13 | const customAirtable = new AirtableUtils(base); 14 | const requesterService = new RequesterService( 15 | base(config.AIRTABLE_REQUESTERS_TABLE_NAME) 16 | ); 17 | const requestService = new RequestService( 18 | base(config.AIRTABLE_REQUESTS_TABLE_NAME), 19 | customAirtable, 20 | requesterService 21 | ); 22 | /** 23 | * Checks `Requests` table for records without a `Requester`, and runs each of 24 | * of those records through linkUserWithRequest() to create a new record, or 25 | * update an existing one, in `Requesters` table. 26 | * 27 | * @returns {void} 28 | */ 29 | async function backfillRequesters() { 30 | const errors = []; 31 | let totalCount = 0; 32 | let successCount = 0; 33 | await base(config.AIRTABLE_REQUESTS_TABLE_NAME) 34 | .select({ 35 | view: config.AIRTABLE_REQUESTS_VIEW_NAME, 36 | filterByFormula: `{Requester} = ''`, 37 | }) 38 | .eachPage(async (records, nextPage) => { 39 | if (!records.length) return; 40 | const mappedRecords = records.map((r) => new Request(r)); 41 | for (const record of mappedRecords) { 42 | const requesterName = record.get("Name"); 43 | const phoneNumber = record.get("Phone number"); 44 | logger.info(`Now processing:`); 45 | logger.info(`id: ${record.id}`); 46 | try { 47 | await requestService.linkUserWithRequest(record); 48 | successCount++; 49 | logger.info( 50 | `successfully processed ${successCount} of ${totalCount + 1}` 51 | ); 52 | } catch (err) { 53 | errors.push({ 54 | id: record.id, 55 | name: requesterName, 56 | phone: phoneNumber, 57 | error: err, 58 | }); 59 | logger.error(err); 60 | } 61 | totalCount++; 62 | console.log(""); 63 | } 64 | 65 | nextPage(); 66 | }); 67 | logger.info(`successfully processed ${successCount} of ${totalCount}`); 68 | console.log("errors", errors); 69 | } 70 | 71 | backfillRequesters(); 72 | -------------------------------------------------------------------------------- /src/model/request-record.js: -------------------------------------------------------------------------------- 1 | const Task = require("../task"); 2 | const config = require("../config"); 3 | 4 | /** 5 | * Request for help. 6 | */ 7 | class RequestRecord { 8 | constructor(airtableRequest) { 9 | this.airtableRequest = airtableRequest; 10 | } 11 | 12 | /** 13 | * Get other field from airtable. 14 | * 15 | * @param {object} field to get from airtable record 16 | * @type {*} The Airtable field. 17 | */ 18 | get(field) { 19 | return this.airtableRequest.get(field); 20 | } 21 | 22 | /** 23 | * ID of the record 24 | * 25 | * @type {string} 26 | */ 27 | get id() { 28 | return this.airtableRequest.id; 29 | } 30 | 31 | /** 32 | * Tasks requester needs help with 33 | * 34 | * @type {Task[]} 35 | */ 36 | get tasks() { 37 | return (this.get("Tasks") || []).map(Task.mapFromRawTask); 38 | } 39 | 40 | /** 41 | * "fields" property from the underlying airtable record. 42 | * 43 | * @type {object} 44 | */ 45 | get rawFields() { 46 | return this.airtableRequest.fields; 47 | } 48 | 49 | /** 50 | * Address of the requester. 51 | * 52 | * @type {string} The requester address, including street, city and state. 53 | */ 54 | get fullAddress() { 55 | return `${this.get("Address")} ${this.get("City")}, ${ 56 | config.VOLUNTEER_DISPATCH_STATE 57 | }`; 58 | } 59 | 60 | /** 61 | * Co-ordinates if they are available. 62 | * 63 | * @type {object} Geo co-ordinates. 64 | */ 65 | get coordinates() { 66 | return JSON.parse(this.get("_coordinates")); 67 | } 68 | 69 | /** 70 | * Address used to resolve coordinates. 71 | * 72 | * @see coordinates 73 | * @type {string} Address co-ordinates. 74 | */ 75 | get coordinatesAddress() { 76 | return this.get("_coordinates_address"); 77 | } 78 | 79 | /** 80 | * ID to a record in the Users table. 81 | * 82 | * @type {string|null} 83 | */ 84 | get requesterId() { 85 | const requesters = this.get("Requester"); 86 | if (requesters) { 87 | return requesters[0]; 88 | } 89 | return null; 90 | } 91 | 92 | /** 93 | * Full name of the requester 94 | * 95 | * @type {string} 96 | */ 97 | get requesterName() { 98 | return this.get("Name"); 99 | } 100 | 101 | /** 102 | * Phone number of the requester 103 | * 104 | * @type {string} 105 | */ 106 | get phoneNumber() { 107 | return this.get("Phone number"); 108 | } 109 | } 110 | 111 | module.exports = RequestRecord; 112 | -------------------------------------------------------------------------------- /src/slack/sendError.js: -------------------------------------------------------------------------------- 1 | require("dotenv").config(); 2 | const config = require("../config"); 3 | const { bot, token } = require("./bot"); 4 | const { getSection } = require("./message"); 5 | 6 | const channel = config.SLACK_ALERT_CHANNEL_ID; 7 | 8 | let prevErrorMessage = ""; 9 | let prevStackTrace = ""; 10 | let threadTs = ""; 11 | 12 | /** 13 | * For use by SlackErrorTranport in winston logger. 14 | * 15 | * @param {object} error The error object. 16 | * @returns {void} 17 | */ 18 | const sendError = async (error) => { 19 | // Exit if no slack alert channel is provided 20 | if (!channel) return; 21 | 22 | // For new errors (i.e., not the same error as the previous one) 23 | if (error.message !== prevErrorMessage && error.stack !== prevStackTrace) { 24 | const errorMessage = getSection(`:fire: *${error.message}* :fire:\n\n`); 25 | const space = getSection(" "); 26 | 27 | const messageConfig = { 28 | token, 29 | channel, 30 | text: "Uh oh! Something's wrong.", 31 | blocks: [errorMessage, space], 32 | }; 33 | 34 | if (error.stack) { 35 | const stackTrace = getSection(error.stack); 36 | 37 | messageConfig.attachments = [ 38 | { 39 | blocks: [stackTrace], 40 | }, 41 | ]; 42 | } 43 | 44 | const res = await bot.chat.postMessage(messageConfig); 45 | 46 | // Set previous message info for comparison 47 | prevErrorMessage = error.message; 48 | prevStackTrace = error.stack; 49 | threadTs = res.ts; 50 | } else if (!!prevErrorMessage && !!prevStackTrace && !!threadTs) { 51 | // Handle repeats 52 | const repeatMessage = getSection(":repeat: :fire: Error repeated."); 53 | const space = getSection(" "); 54 | 55 | await bot.chat.postMessage({ 56 | thread_ts: threadTs, 57 | token, 58 | channel, 59 | text: "Uh oh! Something's wrong.", 60 | blocks: [repeatMessage, space], 61 | }); 62 | } else { 63 | // A block just in case an error isn't handled by the above 64 | const errorMessage = getSection(`:fire: *${error.message}* :fire:\n\n`); 65 | const space = getSection(" "); 66 | 67 | const messageConfig = { 68 | token, 69 | channel, 70 | text: "Uh oh! Something's wrong.", 71 | blocks: [errorMessage, space], 72 | }; 73 | 74 | if (error.stack) { 75 | const stackTrace = getSection(error.stack); 76 | 77 | messageConfig.attachments = [ 78 | { 79 | blocks: [stackTrace], 80 | }, 81 | ]; 82 | } 83 | 84 | await bot.chat.postMessage(messageConfig); 85 | } 86 | }; 87 | 88 | module.exports = { 89 | sendError, 90 | }; 91 | -------------------------------------------------------------------------------- /src/service/requester-service.js: -------------------------------------------------------------------------------- 1 | const preconditions = require("preconditions").singleton(); 2 | 3 | const config = require("../config"); 4 | const UserRecord = require("../model/user-record"); 5 | const phoneNumberUtil = require("../utils/phone-number-utils"); 6 | 7 | /** 8 | * Extracts a user record from a Airtable's select operation. 9 | * 10 | * @param {string} paramForSearching Parameter that was used to search for the record 11 | * @param {[]} records Records returned by an Airtable select operation 12 | * @returns {object} An extracted user record from extracted 13 | */ 14 | // eslint-disable-next-line no-unused-vars 15 | function extractUserRecordIfAvailable(paramForSearching, records) { 16 | if (records && records.length > 1) { 17 | throw new Error( 18 | `${paramForSearching} has more than one user linked to it!` 19 | ); 20 | } 21 | let userRecord = null; 22 | if (records && records.length === 1) { 23 | // eslint-disable-next-line no-param-reassign 24 | userRecord = new UserRecord(records[0]); 25 | } 26 | return userRecord; 27 | } 28 | 29 | /** 30 | * APIs that deal with Requesters 31 | */ 32 | class RequesterService { 33 | constructor(base) { 34 | preconditions.shouldBeObject(base); 35 | this.base = base; 36 | } 37 | 38 | /** 39 | * Looks for a user with the provided phone number 40 | * 41 | * @param {string} phoneNumber to search with 42 | * @returns {Promise} User, if one is found. Null otherwise. 43 | */ 44 | async findUserByPhoneNumber(phoneNumber) { 45 | preconditions.shouldBeString(phoneNumber); 46 | const records = await this.base 47 | .select({ 48 | view: config.AIRTABLE_USERS_VIEW_NAME, 49 | filterByFormula: `{Phone Number} = "${phoneNumber}"`, 50 | }) 51 | .firstPage(); 52 | return extractUserRecordIfAvailable(phoneNumber, records); 53 | } 54 | 55 | /** 56 | * Looks for a user with the provided name 57 | * 58 | * @param {string} fullName to search with 59 | * @returns {Promise} User, if one is found. Null otherwise. 60 | */ 61 | async findUserByFullName(fullName) { 62 | preconditions.shouldBeString(fullName); 63 | // eslint-disable-next-line prefer-const 64 | let searchName = fullName; 65 | 66 | // Escape double quotes in name 67 | if (fullName.includes('"')) { 68 | searchName = fullName.replace(/"/g, '\\"'); 69 | } 70 | 71 | const records = await this.base 72 | .select({ 73 | view: config.AIRTABLE_USERS_VIEW_NAME, 74 | filterByFormula: `{Full Name} = "${searchName}"`, 75 | }) 76 | .firstPage(); 77 | return extractUserRecordIfAvailable(fullName, records); 78 | } 79 | 80 | /** 81 | * Creates a new User 82 | * 83 | * @param {string} fullName Full name of the user 84 | * @param {string} phoneNumber Phone number of the user 85 | * @returns {Promise} The created record 86 | */ 87 | async createUser(fullName, phoneNumber) { 88 | const record = await this.base.create({ 89 | "Full Name": fullName, 90 | "Phone Number": phoneNumberUtil.getDisplayNumber(phoneNumber), 91 | }); 92 | return new UserRecord(record); 93 | } 94 | } 95 | 96 | module.exports = RequesterService; 97 | -------------------------------------------------------------------------------- /test/languageFilter.test.js: -------------------------------------------------------------------------------- 1 | const { filterByLanguage } = require("../src/languageFilter"); 2 | 3 | class MockRecord { 4 | constructor(fields) { 5 | this.fields = fields; 6 | } 7 | 8 | get(field) { 9 | return this.fields[field]; 10 | } 11 | 12 | set(field, value) { 13 | this.fields[field] = value; 14 | return this.fields; 15 | } 16 | } 17 | 18 | const request = new MockRecord({}); 19 | 20 | const mockVolunteerData = [ 21 | [ 22 | { 23 | "Full Name": "Hugh Hamer", 24 | "Please provide your contact phone number:": "202-555-0171", 25 | "Please select any language you have verbal fluency with:": [ 26 | "Greek", 27 | "Hebrew", 28 | ], 29 | }, 30 | 0.17, 31 | ], 32 | [ 33 | { 34 | "Full Name": "Siraj Harvey", 35 | "Please provide your contact phone number:": "202-555-0171", 36 | "Please select any language you have verbal fluency with:": [ 37 | "Arabic", 38 | "Spanish", 39 | ], 40 | }, 41 | 0.54, 42 | ], 43 | [ 44 | { 45 | "Full Name": "Lexie Pacheco", 46 | "Please provide your contact phone number:": "202-555-0171", 47 | "Please select any language you have verbal fluency with:": [ 48 | "Polish", 49 | "Spanish", 50 | ], 51 | }, 52 | 0.32, 53 | ], 54 | [ 55 | { 56 | "Full Name": "Whitney Elwood", 57 | "Please provide your contact phone number:": "202-555-0171", 58 | "Please select any language you have verbal fluency with:": [ 59 | "Arabic", 60 | "Spanish", 61 | ], 62 | }, 63 | 0.04, 64 | ], 65 | [ 66 | { 67 | "Full Name": "Jaydan Cook", 68 | "Please provide your contact phone number:": "202-555-0171", 69 | "Please select any language you have verbal fluency with:": [], 70 | }, 71 | 0.33, 72 | ], 73 | ]; 74 | 75 | const volunteerDistances = []; 76 | 77 | for (const data of mockVolunteerData) { 78 | const [fields, distance] = data; 79 | volunteerDistances.push([new MockRecord(fields), distance]); 80 | } 81 | 82 | describe("Volunteer list language filter", () => { 83 | it("should not filter volunteer list when requester's language isn't specified", () => { 84 | const filteredVolunteers = filterByLanguage(request, volunteerDistances); 85 | expect(filteredVolunteers.length).toBe(5); 86 | }); 87 | 88 | it("should not filter volunteer list when requester's language is 'English'", () => { 89 | request.set("Language", "English"); 90 | const filteredVolunteers = filterByLanguage(request, volunteerDistances); 91 | expect(filteredVolunteers.length).toBe(5); 92 | }); 93 | 94 | it("should filter out volunteers who don't speak requester's language", () => { 95 | request.set("Language", "Spanish"); 96 | const filteredVolunteers = filterByLanguage(request, volunteerDistances); 97 | expect(filteredVolunteers.length).toBe(3); 98 | }); 99 | 100 | it("should return an empty list if no volunteers speak requester's language", () => { 101 | request.set("Language", "Bengali"); 102 | const filteredVolunteers = filterByLanguage(request, volunteerDistances); 103 | expect(filteredVolunteers.length).toBe(0); 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Volunteer Dispatch 2 | 3 | A bot which locates the closest volunteers to check-in on & run errands for vulnerable members of the community. 4 | 5 | _Made by [Astoria Tech](https://github.com/astoria-tech) volunteers, for use by the [Astoria Mutual Aid Network](https://astoriamutualaid.com)._ 6 | 7 | ![](assets/banner.png) 8 | 9 | ## How it works 10 | 11 | Astoria Mutual Aid Network’s volunteer dispatch works as follows: 12 | 13 | - People fill out a form to request help (https://astoriamutualaid.com/help) which feeds into Airtable 14 | - The bot (a node.js container) watches the Airtable sheet for new entries (every 15 seconds) 15 | - When a new entry is found, the request address is cross-referenced against the volunteer list to 16 | find the 10 closest volunteers who can fulfill the need, and posts them to a private dispatch channel 17 | on Slack (where we have trained dispatch volunteers coordinating with the field volunteers). 18 | 19 | ## Software requirements 20 | 21 | - Make 22 | - Docker & Docker Compose 23 | 24 | ## Integration requirements 25 | 26 | Get the integration points setup: 27 | 28 | - an Airtable account - sign up for a free account, then fill out this form to get a year free as a relief group: https://airtable.com/shr2yzaeJmeuhbyrD 29 | - a free MapQuest dev account - https://developer.mapquest.com/plan_purchase/steps/business_edition/business_edition_free/register 30 | - a dedicated private Slack channel for the bot to post to 31 | 32 | And grab the API keys from each (and channel ID for Slack), and put them into the following environment variables: 33 | 34 | - `AIRTABLE_API_KEY` 35 | - `AIRTABLE_BASE_ID` - go to the [Airtable API page](https://airtable.com/api), click your Volunteer Dispatch base, and the ID is there 36 | - `AIRTABLE_REQUESTS_VIEW_URL` - go to the **Grid View** of the **Requests** table in your **Volunteer Dispatch** base, and copy the URL (e.g. `https://airtable.com/tblMSgCqcFR404rTo/viwgqR1sKrOdmB0dn`) 37 | - `AIRTABLE_VOLUNTEERS_VIEW_URL` - go to the **Grid View** of the **Volunteers** table in your **Volunteer Dispatch** base, and copy the URL (e.g. `https://airtable.com/tbl9xI8U5heH4EoGX/viwp51zSgXEicB3wB`) 38 | - `MAPQUEST_KEY` 39 | - `SLACK_XOXB` - Slack bot token. To setup: create an app, add the OAuth `chat:write` bot scope, install the app to a channel, and grab the bot token 40 | - `SLACK_SECRET` - Slack app signing secret. Found in the 'Basic Information' section of your app on api.slack.com/apps 41 | - `SLACK_CHANNEL_ID` - Slack channel ID (e.g. `C0107MVRF08`) 42 | 43 | ## How to run 44 | 45 | - Clone this repo and navigate to the project root in your terminal. 46 | - Set the environment variables documented above. 47 | - Run `make develop` and the bot will start running, processing records every 15 seconds. 48 | 49 | ## Setting up data backend 50 | 51 | We store our data on Airtable. You can see the data and make your own copy with a single click here: 52 | https://airtable.com/universe/expOp8DfPcmAPTSOz/volunteer-dispatch 53 | 54 | ## How we have it deployed 55 | 56 | We use a tool called [Shipyard](https://shipyard.build) to deploy the bot. In short, it compiles 57 | the Docker Compose file to Kubernetes manifests and deploys to a managed cluster. 58 | 59 | Shipyard will host the bot for free for any mutual aid or relief organizations. Send a message to 60 | [covid@shipyard.build](mailto:covid@shipyard.build) and they'll set you up with an account. 61 | -------------------------------------------------------------------------------- /test/service/volunteer-service.test.js: -------------------------------------------------------------------------------- 1 | const { when } = require("jest-when"); 2 | 3 | const { Random, nodeCrypto } = require("random-js"); 4 | const config = require("../../src/config"); 5 | const VolunteerService = require("../../src/service/volunteer-service"); 6 | 7 | const mockSample = jest.fn().mockImplementation((p) => p); 8 | jest.mock("random-js", () => { 9 | return { 10 | Random: jest.fn().mockImplementation(() => { 11 | return { 12 | sample: mockSample, 13 | }; 14 | }), 15 | }; 16 | }); 17 | 18 | describe("VolunteerService", () => { 19 | it("should construct a Random instance", () => { 20 | // eslint-disable-next-line no-new 21 | new VolunteerService(jest.fn()); 22 | expect(Random).toHaveBeenCalledTimes(1); 23 | expect(Random).toHaveBeenCalledWith(nodeCrypto); 24 | }); 25 | describe("findVolunteersForLoneliness", () => { 26 | let base; 27 | let service; 28 | let selectMock; 29 | let eachPageMock; 30 | beforeEach(() => { 31 | selectMock = jest.fn(); 32 | base = { create: jest.fn(), update: jest.fn(), select: selectMock }; 33 | eachPageMock = jest.fn(); 34 | when(selectMock) 35 | .calledWith({ 36 | view: config.AIRTABLE_VOLUNTEERS_VIEW_NAME, 37 | filterByFormula: "{Account Disabled} != TRUE()", 38 | }) 39 | .mockReturnValue({ eachPage: eachPageMock }); 40 | service = new VolunteerService(base); 41 | }); 42 | it("should call Airtable select and eachPage once and then sample results", async () => { 43 | expect.assertions(5); 44 | const volunteers = await service.findVolunteersForLoneliness(); 45 | expect(selectMock).toHaveBeenCalledTimes(1); 46 | expect(eachPageMock).toHaveBeenCalledTimes(1); 47 | expect(mockSample).toHaveBeenCalledTimes(1); 48 | expect(mockSample).toHaveBeenCalledWith([], 0); 49 | expect(volunteers.length).toBe(0); 50 | }); 51 | }); 52 | describe("appendVolunteersForLoneliness", () => { 53 | let service; 54 | beforeEach(() => { 55 | service = new VolunteerService(jest.fn()); 56 | }); 57 | it("should return a function", () => { 58 | expect(typeof service.appendVolunteersForLoneliness([])).toBe("function"); 59 | }); 60 | it("should call next page once", () => { 61 | const nextPage = jest.fn(); 62 | service.appendVolunteersForLoneliness([])([], nextPage); 63 | expect(nextPage.mock.calls.length).toBe(1); 64 | }); 65 | it("should append volunteers capable of fulfilling tasks for loneliness", () => { 66 | const uselessVolunteerGetMock = jest.fn(); 67 | when(uselessVolunteerGetMock) 68 | .calledWith("I can provide the following support (non-binding)") 69 | .mockReturnValue([]); 70 | const uselessVolunteer = { 71 | get: uselessVolunteerGetMock, 72 | }; 73 | const usefulVolunteerGetMock = jest.fn(); 74 | when(usefulVolunteerGetMock) 75 | .calledWith("I can provide the following support (non-binding)") 76 | .mockReturnValue(["Checking in on people"]); 77 | const usefulVolunteer = { 78 | get: usefulVolunteerGetMock, 79 | }; 80 | const volunteers = [uselessVolunteer, usefulVolunteer]; 81 | const lonelinessVolunteers = []; 82 | service.appendVolunteersForLoneliness(lonelinessVolunteers)( 83 | volunteers, 84 | jest.fn() 85 | ); 86 | expect(lonelinessVolunteers).toEqual( 87 | expect.arrayContaining([usefulVolunteer]) 88 | ); 89 | }); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /scripts/oneoff/removeExtraWhitespace.js: -------------------------------------------------------------------------------- 1 | const Airtable = require("airtable"); 2 | const condenseWhitespace = require("condense-whitespace"); 3 | 4 | const config = require("../../src/config"); 5 | const { logger } = require("../../src/logger"); 6 | 7 | const base = new Airtable({ apiKey: config.AIRTABLE_API_KEY }).base( 8 | config.AIRTABLE_BASE_ID 9 | ); 10 | 11 | /** 12 | * Checks table for records that contain extra whitespace and removes the extra 13 | * whitespace 14 | * 15 | * @returns {void} 16 | * @param {string} tableName - Name of table to check for extra whitespace 17 | * @param {string} fieldName - Name of field to check for extra whitespace 18 | */ 19 | async function removeExtraWhitespace(tableName, fieldName) { 20 | if (process.argv.length < 3) { 21 | logger.error("Incorrect number of arguments"); 22 | } 23 | 24 | const errors = []; 25 | let table; 26 | let view; 27 | let updatedCount = 0; 28 | let totalCount = 0; 29 | if (tableName.toLowerCase() === "requests") { 30 | table = config.AIRTABLE_REQUESTS_TABLE_NAME; 31 | view = config.AIRTABLE_REQUESTS_VIEW_NAME; 32 | } else if (tableName.toLowerCase() === "volunteers") { 33 | table = config.AIRTABLE_VOLUNTEERS_TABLE_NAME; 34 | view = config.AIRTABLE_VOLUNTEERS_VIEW_NAME; 35 | } else { 36 | throw new Error(`Invalid table name: ${tableName}`); 37 | } 38 | 39 | await base(table) 40 | .select({ 41 | view, 42 | filterByFormula: `OR( 43 | LEFT({${fieldName}}, 1) = " ", 44 | RIGHT({${fieldName}}, 1) = " ", 45 | FIND(" ", {${fieldName}}) > 0 46 | )`, 47 | }) 48 | .eachPage(async (records, nextPage) => { 49 | if (!records.length) { 50 | logger.info("No records require updating"); 51 | return; 52 | } 53 | for (const record of records) { 54 | logger.info(`Now processing:`); 55 | logger.info(`id: ${record.id}`); 56 | let currentValue; 57 | try { 58 | currentValue = await record.get(fieldName); 59 | if (!currentValue) { 60 | throw new Error("Invalid field name"); 61 | } 62 | } catch (err) { 63 | logger.error(err); 64 | return; 65 | } 66 | 67 | const condensedValue = condenseWhitespace(currentValue); 68 | const extraSpace = currentValue.length - condensedValue.length; 69 | 70 | try { 71 | await base(table).update( 72 | record.id, 73 | { [fieldName]: condensedValue }, 74 | function (err) { 75 | if (err) { 76 | logger.error(err); 77 | } 78 | } 79 | ); 80 | } catch (err) { 81 | errors.push({ 82 | id: record.id, 83 | name: currentValue, 84 | error: err, 85 | }); 86 | logger.error(err); 87 | } 88 | logger.info( 89 | `Removed ${extraSpace} extra space${extraSpace > 1 ? "s" : ""}` 90 | ); 91 | updatedCount += 1; 92 | totalCount += 1; 93 | console.log(""); 94 | } 95 | nextPage(); 96 | }) 97 | .catch((err) => { 98 | console.log("are we here?"); 99 | return logger.error(err); 100 | }); 101 | logger.info(`Successfully updated ${updatedCount} of ${totalCount} records`); 102 | if (errors.length > 0) { 103 | logger.info(`${errors.length} error${errors.length === 1 ? "" : "s"}`); 104 | console.log("errors", errors); 105 | } 106 | } 107 | 108 | removeExtraWhitespace(process.argv[2], process.argv[3]).catch((err) => 109 | logger.error(err) 110 | ); 111 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | ### JetBrains 107 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 108 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 109 | 110 | # User-specific stuff 111 | .idea/**/workspace.xml 112 | .idea/**/tasks.xml 113 | .idea/**/usage.statistics.xml 114 | .idea/**/dictionaries 115 | .idea/**/shelf 116 | 117 | # Generated files 118 | .idea/**/contentModel.xml 119 | 120 | # Sensitive or high-churn files 121 | .idea/**/dataSources/ 122 | .idea/**/dataSources.ids 123 | .idea/**/dataSources.local.xml 124 | .idea/**/sqlDataSources.xml 125 | .idea/**/dynamic.xml 126 | .idea/**/uiDesigner.xml 127 | .idea/**/dbnavigator.xml 128 | 129 | 130 | # Gradle and Maven with auto-import 131 | # When using Gradle or Maven with auto-import, you should exclude module files, 132 | # since they will be recreated, and may cause churn. Uncomment if using 133 | # auto-import. 134 | .idea/artifacts 135 | .idea/compiler.xml 136 | .idea/modules.xml 137 | .idea/*.iml 138 | .idea/modules 139 | *.iml 140 | *.ipr 141 | 142 | # CMake 143 | cmake-build-*/ 144 | 145 | # Mongo Explorer plugin 146 | .idea/**/mongoSettings.xml 147 | 148 | # File-based project format 149 | *.iws 150 | 151 | # IntelliJ 152 | out/ 153 | 154 | # Cursive Clojure plugin 155 | .idea/replstate.xml 156 | 157 | # Crashlytics plugin (for Android Studio and IntelliJ) 158 | com_crashlytics_export_strings.xml 159 | crashlytics.properties 160 | crashlytics-build.properties 161 | fabric.properties 162 | 163 | # Editor-based Rest Client 164 | .idea/httpRequests 165 | 166 | # macOS 167 | .DS_Store 168 | -------------------------------------------------------------------------------- /src/slack/sendDispatch.js: -------------------------------------------------------------------------------- 1 | require("dotenv").config(); 2 | const config = require("../config"); 3 | const { bot, token } = require("./bot"); 4 | const message = require("./message"); 5 | const { followUpButton } = require("./reminder"); 6 | 7 | const channel = config.SLACK_CHANNEL_ID; 8 | 9 | /** 10 | * Send the primary request info. 11 | * 12 | * @param {object} record The request record object. 13 | * @param {string} text The message to send to slack. 14 | * @param {boolean} reminder The reminder object. 15 | * @returns {object} Block to send to slack. 16 | */ 17 | const getPrimaryRequestInfoBlock = (record, text, reminder) => { 18 | const heading = message.getHeading({ reminder, text }); 19 | const taskOrder = message.getTaskOrder(record); 20 | const requester = message.getRequester(record); 21 | const tasks = message.getTasks(record); 22 | const requestedTimeframe = message.getTimeframe(record); 23 | return taskOrder 24 | ? [heading, taskOrder, requester, tasks, requestedTimeframe, followUpButton] 25 | : [heading, requester, tasks, requestedTimeframe, followUpButton]; 26 | }; 27 | 28 | /** 29 | * Send secondary request info. 30 | * 31 | * @param {object} record The request record object. 32 | * @returns {object} Block to send to slack. 33 | */ 34 | const getSecondaryRequestInfoBlock = (record) => { 35 | const subsidyRequested = message.getSubsidyRequest(record); 36 | const anythingElse = message.getAnythingElse(record); 37 | 38 | return [subsidyRequested, anythingElse]; 39 | }; 40 | 41 | /** 42 | * Send volunteer info. 43 | * 44 | * @param {Array} volunteers The list of identified volunteers. 45 | * @returns {object} Block to send to slack. 46 | */ 47 | const getVolunteerInfoBlock = (volunteers) => { 48 | const volunteerHeading = message.getVolunteerHeading(volunteers); 49 | const volunteerList = message.getVolunteers(volunteers); 50 | const volunteerClosing = message.getVolunteerClosing(volunteers); 51 | 52 | return [volunteerHeading, ...volunteerList, volunteerClosing]; 53 | }; 54 | 55 | /** 56 | * Send copy and paste numbers. 57 | * 58 | * @param {Array} volunteers The list of selected volunteers. 59 | * @param {string} threadTs The threaded message object returned from slack. 60 | * @returns {object} The slack chat message object sent. 61 | */ 62 | const sendCopyPasteNumbers = async (volunteers, threadTs) => { 63 | const copyPasteNumbers = message.getCopyPasteNumbers(volunteers); 64 | 65 | return bot.chat.postMessage({ 66 | thread_ts: threadTs, 67 | token, 68 | channel, 69 | text: copyPasteNumbers, 70 | }); 71 | }; 72 | 73 | /** 74 | * This function actually sends the message to the slack channel. 75 | * 76 | * @param {object} record The Airtable record to use. 77 | * @param {Array} volunteers The volunteer list selected. 78 | * @param {boolean} [reminder] Whether this is a reminder task. Defaults to false. 79 | * @throws {Error} If no record is provided. 80 | * @returns {void} 81 | */ 82 | const sendDispatch = async (record, volunteers, reminder = false) => { 83 | if (!record) throw new Error("No record passed to sendMessage()."); 84 | 85 | const text = message.getText({ reminder }); 86 | const primaryRequestInfo = getPrimaryRequestInfoBlock(record, text, reminder); 87 | const blocksList = [ 88 | getSecondaryRequestInfoBlock(record), 89 | getVolunteerInfoBlock(volunteers), 90 | ]; 91 | const res = await bot.chat.postMessage({ 92 | token, 93 | channel, 94 | text, 95 | blocks: primaryRequestInfo, 96 | }); 97 | const { ts } = res; 98 | for (const blocks of blocksList) { 99 | await bot.chat.postMessage({ 100 | thread_ts: ts, 101 | token, 102 | channel, 103 | text, 104 | blocks, 105 | }); 106 | } 107 | await sendCopyPasteNumbers(volunteers, ts); 108 | }; 109 | 110 | module.exports = { 111 | sendDispatch, 112 | }; 113 | -------------------------------------------------------------------------------- /test/task.test.js: -------------------------------------------------------------------------------- 1 | const { when } = require("jest-when"); 2 | const Task = require("../src/task"); 3 | 4 | describe("Task", () => { 5 | describe("mapFromRawTask", () => { 6 | it("raw string maps to correct task", () => { 7 | const rawtask = "Food Assistance"; 8 | const mappedtask = Task.mapFromRawTask(rawtask); 9 | expect(mappedtask.rawTask).toBe(rawtask); 10 | expect(mappedtask.supportRequirements).toEqual([ 11 | "Picking up groceries/medications", 12 | ]); 13 | }); 14 | }); 15 | describe("canBeFulfilledByVolunteer", () => { 16 | it("should throw error if volunteer does not have get function", () => { 17 | const task = Task.mapFromRawTask("Food Assistance"); 18 | const volunteer = {}; 19 | expect(() => task.canBeFulfilledByVolunteer(volunteer)).toThrowError(); 20 | }); 21 | it("should return false if volunteer does not have any supporting capabilities", () => { 22 | const getMock = jest.fn(); 23 | when(getMock) 24 | .calledWith("I can provide the following support (non-binding)") 25 | .mockReturnValue([]); 26 | const volunteer = { get: getMock }; 27 | const task = Task.mapFromRawTask("Food Assistance"); 28 | expect(task.canBeFulfilledByVolunteer(volunteer)).toBeFalsy(); 29 | }); 30 | it("should return false if volunteer does have capabilities but none supporting the task at hand", () => { 31 | const getMock = jest.fn(); 32 | when(getMock) 33 | .calledWith("I can provide the following support (non-binding)") 34 | .mockReturnValue([ 35 | "Check-in on folks throughout the day (in-person or phone call)", 36 | ]); 37 | const volunteer = { get: getMock }; 38 | const task = Task.mapFromRawTask("Food Assistance"); 39 | expect(task.canBeFulfilledByVolunteer(volunteer)).toBeFalsy(); 40 | }); 41 | it("should return true if volunteer does have supporting capabilities", () => { 42 | const getMock = jest.fn(); 43 | when(getMock) 44 | .calledWith("I can provide the following support (non-binding)") 45 | .mockReturnValue(["Picking up groceries/medications"]); 46 | const volunteer = { get: getMock }; 47 | const task = Task.mapFromRawTask("Food Assistance"); 48 | expect(task.canBeFulfilledByVolunteer(volunteer)).toBeTruthy(); 49 | }); 50 | it("should return false if task requires volunteer to have a car, but volunteer does not have one", () => { 51 | const getMock = jest.fn(); 52 | when(getMock) 53 | .calledWith("I can provide the following support (non-binding)") 54 | .mockReturnValue([]) 55 | .calledWith( 56 | "Do you have a private mode of transportation with valid license/insurance? " 57 | ) 58 | .mockReturnValue([]); 59 | const volunteer = { get: getMock }; 60 | const task = Task.mapFromRawTask( 61 | "Transportation to/from a medical appointment" 62 | ); 63 | expect(task.canBeFulfilledByVolunteer(volunteer)).toBeFalsy(); 64 | }); 65 | it("should return true if task requires volunteer to have a car and volunteer has one", () => { 66 | const getMock = jest.fn(); 67 | when(getMock) 68 | .calledWith("I can provide the following support (non-binding)") 69 | .mockReturnValue([]) 70 | .calledWith( 71 | "Do you have a private mode of transportation with valid license/insurance? " 72 | ) 73 | .mockReturnValue(["Yes, I have a car"]); 74 | const volunteer = { get: getMock }; 75 | const task = Task.mapFromRawTask( 76 | "Transportation to/from a medical appointment" 77 | ); 78 | expect(task.canBeFulfilledByVolunteer(volunteer)).toBeTruthy(); 79 | }); 80 | }); 81 | describe("equals", () => { 82 | it.each` 83 | task | raw 84 | ${Task.GROCERY_SHOPPING} | ${"Food Assistance"} 85 | ${Task.PRESCRIPTION_PICKUP} | ${"Picking up a prescription"} 86 | ${Task.MEDICAL_APPT_TRANSPORTATION} | ${"Transportation to/from a medical appointment"} 87 | ${Task.DOG_WALKING} | ${"Dog walking"} 88 | ${Task.LONELINESS} | ${"Loneliness"} 89 | ${Task.ACCESS_HEALTH_INFO} | ${"Accessing verified health information"} 90 | ${Task.OTHER} | ${"Other"} 91 | `("should return true if rawTasks match", ({ task, raw }) => { 92 | expect(task.equals(new Task(raw, [], []))).toBeTruthy(); 93 | }); 94 | }); 95 | }); 96 | -------------------------------------------------------------------------------- /src/task.js: -------------------------------------------------------------------------------- 1 | const preconditions = require("preconditions").singleton(); 2 | 3 | class Task { 4 | /** 5 | * Task that folks can request help for. 6 | * 7 | * @param {string} rawTask representing a possible value in the "Tasks" field 8 | * in "Requests" Airtable 9 | * @param {Array} supportRequirements Array of strings. Volunteers can specify how they 10 | * can support. This is stored in the "I can provide the following support (non-binding)" 11 | * field on the "Volunteers" Airtable 12 | * @param {Array} arbitraryRequirements Array of functions that return a boolean value if 13 | * this errand has other arbitrary requirements. 14 | */ 15 | constructor(rawTask, supportRequirements, arbitraryRequirements = []) { 16 | preconditions.shouldBeString(rawTask).shouldNotBeEmpty(rawTask); 17 | preconditions.shouldBeArray(supportRequirements); 18 | preconditions.shouldBeArray(arbitraryRequirements); 19 | arbitraryRequirements.forEach(preconditions.shouldBeFunction); 20 | this.rawTask = rawTask; 21 | this.supportRequirements = supportRequirements; 22 | this.otherFulfillmentRequirements = arbitraryRequirements; 23 | } 24 | 25 | /** 26 | * Check if a given volunteer can fulfill this task. 27 | * 28 | * This method is better housed in a volunteer matching utility or service class. 29 | * I am keeping it here for now because I did not want to introduce a new 30 | * service/util layer this early in the project. We might need it eventually, though. 31 | * 32 | * @param {object} volunteer Airtable record about volunteer 33 | * @returns {boolean} True if volunteer can fulfillt task. 34 | */ 35 | canBeFulfilledByVolunteer(volunteer) { 36 | preconditions.shouldBeObject(volunteer); 37 | preconditions.shouldBeFunction(volunteer.get); 38 | const capabilities = 39 | volunteer.get("I can provide the following support (non-binding)") || []; 40 | // If the beginning of any capability matches the requirement, 41 | // the volunteer can handle the task 42 | return ( 43 | (this.supportRequirements.length === 0 || 44 | this.supportRequirements.some((r) => 45 | capabilities.some((c) => c.startsWith(r)) 46 | )) && 47 | (this.otherFulfillmentRequirements.length === 0 || 48 | this.otherFulfillmentRequirements.some((requirement) => 49 | requirement(volunteer) 50 | )) 51 | ); 52 | } 53 | 54 | equals(task) { 55 | return this.rawTask === task.rawTask; 56 | } 57 | } 58 | 59 | const doesVolunteerHaveACar = (volunteer) => { 60 | const transportationModes = volunteer.get( 61 | "Do you have a private mode of transportation with valid license/insurance? " 62 | ); 63 | if (transportationModes) { 64 | return transportationModes.indexOf("Yes, I have a car") !== -1; 65 | } 66 | return false; 67 | }; 68 | 69 | Task.GROCERY_SHOPPING = new Task("Food Assistance", [ 70 | "Picking up groceries/medications", 71 | ]); 72 | Task.PRESCRIPTION_PICKUP = new Task("Picking up a prescription", [ 73 | "Picking up groceries/medications", 74 | ]); 75 | Task.MEDICAL_APPT_TRANSPORTATION = new Task( 76 | "Transportation to/from a medical appointment", 77 | [], 78 | [doesVolunteerHaveACar] 79 | ); 80 | Task.DOG_WALKING = new Task("Dog walking", ["Pet-sitting/walking/feeding"]); 81 | Task.LONELINESS = new Task("Loneliness", [ 82 | "Check-in on folks throughout the day (in-person or phone call)", 83 | "Checking in on people", 84 | ]); 85 | Task.ACCESS_HEALTH_INFO = new Task("Accessing verified health information", [ 86 | "Check-in on folks throughout the day (in-person or phone call)", 87 | "Checking in on people", 88 | "Navigating the health care/insurance websites", 89 | ]); 90 | // Match most requirements since we don't know the nature of an "Other" 91 | Task.OTHER = new Task("Other", [ 92 | "Meal delivery", 93 | "Picking up groceries/medications", 94 | "Pet-sitting/walking/feeding", 95 | "Checking in on people", 96 | "Donations of other kind", 97 | ]); 98 | Task.possibleTasks = [ 99 | Task.GROCERY_SHOPPING, 100 | Task.PRESCRIPTION_PICKUP, 101 | Task.MEDICAL_APPT_TRANSPORTATION, 102 | Task.DOG_WALKING, 103 | Task.LONELINESS, 104 | Task.ACCESS_HEALTH_INFO, 105 | Task.OTHER, 106 | ]; 107 | const cache = {}; 108 | Task.possibleTasks.forEach((errand) => { 109 | cache[errand.rawTask] = errand; 110 | }); 111 | Task.mapFromRawTask = (rawTask) => { 112 | return cache[rawTask] || new Task(rawTask, []); 113 | }; 114 | 115 | module.exports = Task; 116 | -------------------------------------------------------------------------------- /test/utils/airtable-utils.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-len */ 2 | /* To allow test case table to pass linting */ 3 | 4 | const AirtableUtils = require("../../src/utils/airtable-utils"); 5 | const RequestRecord = require("../../src/model/request-record"); 6 | const Task = require("../../src/task"); 7 | 8 | describe("AirtableUtils", () => { 9 | describe("cloneRequestFieldsWithGivenTask", () => { 10 | const taskOrder = "1 of 3"; 11 | it.each` 12 | request | task | order | expectedError 13 | ${undefined} | ${undefined} | ${taskOrder} | ${"Variable should be of type Object."} 14 | ${{ id: "kjhs8090" }} | ${undefined} | ${taskOrder} | ${"Variable should be of type Object."} 15 | ${{}} | ${undefined} | ${taskOrder} | ${"Variable should be defined."} 16 | ${new RequestRecord({ id: "kjhs8090", fields: {} })} | ${undefined} | ${taskOrder} | ${"Variable should be of type Object."} 17 | ${new RequestRecord({ id: "kjhs8090", fields: {} })} | ${undefined} | ${taskOrder} | ${"Variable should be of type Object."} 18 | ${new RequestRecord({ id: "kjhs8090", fields: {} })} | ${{}} | ${undefined} | ${"Variable should be a String."} 19 | `( 20 | "should throw in case of invalid arguments", 21 | ({ request, task, order, expectedError }) => { 22 | expect(() => 23 | AirtableUtils.cloneRequestFieldsWithGivenTask(request, task, order) 24 | ).toThrow(expectedError); 25 | } 26 | ); 27 | it.each([ 28 | new RequestRecord({ id: "kjhs8090", fields: {} }), 29 | new RequestRecord({ id: "kjhs8090", fields: { "Created time": "" } }), 30 | new RequestRecord({ 31 | id: "kjhs8090", 32 | fields: { "Created time": "2323434" }, 33 | }), 34 | new RequestRecord({ 35 | id: "kjhs8090", 36 | fields: { "Created time": "", Error: "" }, 37 | }), 38 | new RequestRecord({ 39 | id: "kjhs8090", 40 | fields: { "Created time": "2323434", Error: "" }, 41 | }), 42 | new RequestRecord({ 43 | id: "kjhs8090", 44 | fields: { "Created time": "2323434", Error: "some error" }, 45 | }), 46 | ])( 47 | "should return object without 'Created time' or 'Error fields", 48 | (request) => { 49 | const clonedRequest = AirtableUtils.cloneRequestFieldsWithGivenTask( 50 | request, 51 | Task.possibleTasks[0], 52 | taskOrder 53 | ); 54 | expect(clonedRequest.fields).not.toHaveProperty("Created time"); 55 | expect(clonedRequest.fields).not.toHaveProperty("Error"); 56 | } 57 | ); 58 | it.each([ 59 | new RequestRecord({ id: "kjhs8090", fields: {} }), 60 | new RequestRecord({ id: "kjhs8090", fields: { Tasks: undefined } }), 61 | new RequestRecord({ id: "kjhs8090", fields: { Tasks: "" } }), 62 | new RequestRecord({ id: "kjhs8090", fields: { Tasks: [] } }), 63 | new RequestRecord({ 64 | id: "kjhs8090", 65 | fields: { Tasks: [Task.possibleTasks[0]] }, 66 | }), 67 | new RequestRecord({ 68 | id: "kjhs8090", 69 | fields: { Tasks: [Task.possibleTasks[1]] }, 70 | }), 71 | new RequestRecord({ 72 | id: "kjhs8090", 73 | fields: { Tasks: Task.possibleTasks }, 74 | }), 75 | ])("should replace 'Tasks' field with given task", (request) => { 76 | const task = Task.possibleTasks[0]; 77 | const clonedRequest = AirtableUtils.cloneRequestFieldsWithGivenTask( 78 | request, 79 | task, 80 | taskOrder 81 | ); 82 | expect(clonedRequest.fields).toHaveProperty("Tasks"); 83 | expect(clonedRequest.fields.Tasks).toEqual([task.rawTask]); 84 | }); 85 | it.each([ 86 | new RequestRecord({ id: "kjhs8090", fields: {} }), 87 | new RequestRecord({ id: "kjhs8090", fields: { Name: "Severus Snape" } }), 88 | new RequestRecord({ 89 | id: "kjhs8090", 90 | fields: { Name: "Severus Snape", City: "Astoria" }, 91 | }), 92 | ])("should copy other fields from the original", (givenRequest) => { 93 | expect( 94 | AirtableUtils.cloneRequestFieldsWithGivenTask( 95 | givenRequest, 96 | Task.possibleTasks[0], 97 | taskOrder 98 | ).fields 99 | ).toEqual(expect.objectContaining(givenRequest.rawFields)); 100 | }); 101 | it("should set the 'Cloned from' field with the request's id", () => { 102 | const id = "jsdhf9329"; 103 | const request = new RequestRecord({ fields: {}, id }); 104 | expect( 105 | AirtableUtils.cloneRequestFieldsWithGivenTask( 106 | request, 107 | Task.possibleTasks[0], 108 | "1 of 3" 109 | ).fields["Cloned from"] 110 | ).toEqual([id]); 111 | }); 112 | it("should set the 'Task Order' field with the task order string", () => { 113 | const id = "jsdhf9329"; 114 | const request = new RequestRecord({ fields: {}, id }); 115 | expect( 116 | AirtableUtils.cloneRequestFieldsWithGivenTask( 117 | request, 118 | Task.possibleTasks[0], 119 | taskOrder 120 | ).fields["Task Order"] 121 | ).toEqual(taskOrder); 122 | }); 123 | }); 124 | }); 125 | -------------------------------------------------------------------------------- /test/service/requester-service.test.js: -------------------------------------------------------------------------------- 1 | const RequesterService = require("../../src/service/requester-service"); 2 | 3 | jest.mock("../../src/utils/phone-number-utils"); 4 | jest.mock("../../src/config", () => { 5 | return { 6 | AIRTABLE_USERS_VIEW_NAME: "Grid View", 7 | }; 8 | }); 9 | const mockUsersViewName = "Grid View"; 10 | 11 | const fullName = "Ezekiel Adams"; 12 | const phoneNumber = "(055) 956-1902"; 13 | const records = [ 14 | { 15 | get: (field) => { 16 | switch (field) { 17 | case "Full Name": 18 | return fullName; 19 | case "Phone Number": 20 | return phoneNumber; 21 | default: 22 | return ""; 23 | } 24 | }, 25 | }, 26 | ]; 27 | 28 | describe("RequesterService", () => { 29 | let service; 30 | let base; 31 | let mockSelect; 32 | let mockFirstPage; 33 | beforeEach(() => { 34 | mockFirstPage = jest.fn(); 35 | mockSelect = jest.fn(); 36 | mockSelect.mockReturnValue({ firstPage: mockFirstPage }); 37 | base = { 38 | select: mockSelect, 39 | }; 40 | service = new RequesterService(base); 41 | }); 42 | describe("findUserByPhoneNumber", () => { 43 | it("should check is supplied argument is a string", async () => { 44 | expect.assertions(2); 45 | // noinspection JSCheckFunctionSignatures 46 | await expect(service.findUserByPhoneNumber()).rejects.toThrow( 47 | "Variable should be a String." 48 | ); 49 | // noinspection JSCheckFunctionSignatures 50 | await expect(service.findUserByPhoneNumber(9)).rejects.toThrow( 51 | "Variable should be a String." 52 | ); 53 | }); 54 | const expectedFilterByFormula = `{Phone Number} = "${phoneNumber}"`; 55 | it("should throw error if more than one records are found for given number", async () => { 56 | expect.assertions(2); 57 | mockFirstPage.mockReturnValue([records[0], records[1]]); 58 | await expect(service.findUserByPhoneNumber(phoneNumber)).rejects.toThrow( 59 | `${phoneNumber} has more than one user linked to it!` 60 | ); 61 | expect(mockSelect).toHaveBeenCalledWith({ 62 | view: mockUsersViewName, 63 | filterByFormula: expectedFilterByFormula, 64 | }); 65 | }); 66 | it("should throw return null if no users found for given phone number", async () => { 67 | expect.assertions(2); 68 | mockFirstPage.mockReturnValue([]); 69 | const userRecord = await service.findUserByPhoneNumber(phoneNumber); 70 | expect(mockSelect).toHaveBeenCalledWith({ 71 | view: mockUsersViewName, 72 | filterByFormula: expectedFilterByFormula, 73 | }); 74 | expect(userRecord).toBe(null); 75 | }); 76 | it("should search for users with formatted phone number", async () => { 77 | expect.assertions(3); 78 | mockFirstPage.mockReturnValue(records); 79 | const userRecord = await service.findUserByPhoneNumber(phoneNumber); 80 | expect(mockSelect).toHaveBeenCalledWith({ 81 | view: mockUsersViewName, 82 | filterByFormula: expectedFilterByFormula, 83 | }); 84 | expect(userRecord.fullName).toBe(fullName); 85 | expect(userRecord.phoneNumber).toBe(phoneNumber); 86 | }); 87 | }); 88 | describe("findUserByFullName", () => { 89 | it("should check is supplied argument is a string", async () => { 90 | expect.assertions(2); 91 | // noinspection JSCheckFunctionSignatures 92 | await expect(service.findUserByFullName()).rejects.toThrow( 93 | "Variable should be a String." 94 | ); 95 | // noinspection JSCheckFunctionSignatures 96 | await expect(service.findUserByFullName(9)).rejects.toThrow( 97 | "Variable should be a String." 98 | ); 99 | }); 100 | const searchName = fullName.replace(/"/g, '\\"'); 101 | const expectedFilterByFormula = `{Full Name} = "${searchName}"`; 102 | it("should throw error if more than one records are found for given full name", async () => { 103 | expect.assertions(2); 104 | mockFirstPage.mockReturnValue([records[0], records[1]]); 105 | await expect(service.findUserByFullName(fullName)).rejects.toThrow( 106 | `${fullName} has more than one user linked to it!` 107 | ); 108 | expect(mockSelect).toHaveBeenCalledWith({ 109 | view: mockUsersViewName, 110 | filterByFormula: expectedFilterByFormula, 111 | }); 112 | }); 113 | it("should throw return null if no users found for given full name", async () => { 114 | expect.assertions(2); 115 | mockFirstPage.mockReturnValue([]); 116 | const userRecord = await service.findUserByFullName(fullName); 117 | expect(mockSelect).toHaveBeenCalledWith({ 118 | view: mockUsersViewName, 119 | filterByFormula: expectedFilterByFormula, 120 | }); 121 | expect(userRecord).toBe(null); 122 | }); 123 | it("should search for users with full name", async () => { 124 | expect.assertions(3); 125 | mockFirstPage.mockReturnValue(records); 126 | const userRecord = await service.findUserByFullName(fullName); 127 | expect(mockSelect).toHaveBeenCalledWith({ 128 | view: mockUsersViewName, 129 | filterByFormula: expectedFilterByFormula, 130 | }); 131 | expect(userRecord.fullName).toBe(fullName); 132 | expect(userRecord.phoneNumber).toBe(phoneNumber); 133 | }); 134 | }); 135 | }); 136 | -------------------------------------------------------------------------------- /docs/ENVIRONMENT_VARIABLES.md: -------------------------------------------------------------------------------- 1 | # Environment Variables 2 | 3 | ## Summary 4 | 5 | | Name | Required | Default value | 6 | | --------------------------------------- |:--------:| -------------:| 7 | | `AIRTABLE_API_KEY` | Yes | | 8 | | `AIRTABLE_BASE_ID` | Yes | | 9 | | `AIRTABLE_REQUESTS_TABLE_NAME` | No | Requests | 10 | | `AIRTABLE_REQUESTS_VIEW_URL` | Yes | | 11 | | `AIRTABLE_VOLUNTEERS_TABLE_NAME` | No | Volunteers | 12 | | `AIRTABLE_VOLUNTEERS_VIEW_URL` | Yes | | 13 | | `GOOGLE_API_KEY` | See desc | | 14 | | `MAPQUEST_KEY` | See desc | | 15 | | `SLACK_ALERT_CHANNEL_ID` | No | | 16 | | `SLACK_CHANNEL_ID` | Yes | | 17 | | `SLACK_XOXB` | Yes | | 18 | | `VOLUNTEER_DISPATCH_PREVENT_PROCESSING` | No | `false` | 19 | | `VOLUNTEER_DISPATCH_STATE` | No | NY | 20 | 21 | ## Storing environment variables 22 | 23 | This project supports two methods of storing environment variables. 24 | 25 | ### exports 26 | 27 | You can use standard unix shell environment variable exports, either in 28 | a config file or inline. 29 | 30 | Example in `.bashrc`: 31 | 32 | ```bash 33 | #!/bin/bash 34 | 35 | export AIRTABLE_API_KEY=my_key 36 | export AIRTABLE_BASE_ID=my_base_id 37 | export MAPQUEST_KEY=my_mapquest_id 38 | export SLACK_XOXB=my_slack_xoxb 39 | export SLACK_CHANNEL_ID=my_channel_id 40 | export AIRTABLE_REQUESTS_VIEW_URL=my_requests_url 41 | export AIRTABLE_VOLUNTEERS_VIEW_URL=my_volunteers_url 42 | ``` 43 | 44 | ### .env file 45 | 46 | You can also create a `.env` file in the project root, in the form of 47 | `NAME=VALUE`. 48 | 49 | Example: 50 | 51 | ```sh 52 | AIRTABLE_API_KEY=my_key 53 | AIRTABLE_BASE_ID=my_base_id 54 | MAPQUEST_KEY=my_mapquest_id 55 | SLACK_XOXB=my_slack_xoxb 56 | SLACK_CHANNEL_ID=my_channel_id 57 | AIRTABLE_REQUESTS_VIEW_URL=my_requests_url 58 | AIRTABLE_VOLUNTEERS_VIEW_URL=my_volunteers_url 59 | ``` 60 | 61 | This file is git ignored. 62 | 63 | ## Environment variable descriptions 64 | 65 | ### `AIRTABLE_API_KEY` 66 | 67 | The Airtable API key associated with your application. 68 | 69 | To retrieve, first make sure you have an Airtable account - sign up for 70 | a free account, then fill out this form to get a year free as a relief 71 | group: https://airtable.com/shr2yzaeJmeuhbyrD 72 | 73 | You can see the data or make your own copy for 74 | local development with a single click here: 75 | https://airtable.com/universe/expOp8DfPcmAPTSOz/volunteer-dispatch 76 | 77 | ### `AIRTABLE_BASE_ID` 78 | 79 | The unique identifier associated with the Airtable base you will use to 80 | store the volunteer data. 81 | 82 | To retrieve, go to the [Airtable API page](https://airtable.com/api), 83 | click your Volunteer Dispatch base, and the ID is there. 84 | 85 | ### `AIRTABLE_REQUESTS_TABLE_NAME` 86 | 87 | The URL for the Volunteer's tab of your Airtable base. 88 | 89 | To retrieve go to the **Grid View** tab of the **Volunteers** table 90 | in your **Volunteer Dispatch** base, and copy the URL (e.g. 91 | `https://airtable.com/tbl9xI8U5heH4EoGX/viwp51zSgXEicB3wB`). 92 | 93 | ### `AIRTABLE_REQUESTS_VIEW_URL` 94 | 95 | The URL for the Requests tab of your Airtable base. 96 | 97 | To retrieve, go to the **Grid View** tab of the **Requests** 98 | table in your **Volunteer Dispatch** base, and copy the URL (e.g. 99 | `https://airtable.com/tblMSgCqcFR404rTo/viwgqR1sKrOdmB0dn`) 100 | 101 | ### `AIRTABLE_VOLUNTEERS_TABLE_NAME` 102 | 103 | The name of the table inside your Airtable base for Volunteers. 104 | Optional. Defaults to `Volunteers`. 105 | 106 | ### `AIRTABLE_VOLUNTEERS_VIEW_URL` 107 | 108 | The URL for the Volunteers tab of your Airtable base. 109 | 110 | To retrieve, go to the **Grid View** tab of the **Volunteers** 111 | table in your **Volunteer Dispatch** base, and copy the URL (e.g. 112 | `https://airtable.com/tbl9xI8U5heH4EoGX/viwp51zSgXEicB3wB`) 113 | 114 | ### `GOOGLE_API_KEY` 115 | 116 | ℹ️ The bot will always use Google data if this key is provided, 117 | overriding `MAPQUEST_KEY`, if present. 118 | 119 | Your Google API key, used to get geo-locating data to match volunteers 120 | to people in need based on proximity. 121 | 122 | ### `MAPQUEST_KEY` 123 | 124 | ℹ️ MapQuest data will only be used if no `GOOGLE_API_KEY` is preset. 125 | 126 | Your MapQuest API key, used to get geo-locating data to match volunteers 127 | to people in need based on proximity. 128 | 129 | To retrieve, first make sure you have a free MapQuest dev account - 130 | https://developer.mapquest.com/plan_purchase/steps/business_edition/business_edition_free/register. 131 | Then, create or retrieve the API key generated for your application 132 | under My Keys. 133 | 134 | ### `SLACK_ALERT_CHANNEL_ID` 135 | 136 | A Slack channel ID to send bot errors. Optional. 137 | 138 | ### `SLACK_CHANNEL_ID` 139 | 140 | The Slack channel ID that messages will be posted to (e.g. `C0107MVRF08`). 141 | 142 | ### `SLACK_XOXB` 143 | 144 | The Slack bot token. To setup: create an app, add the OAuth `chat:write` bot 145 | scope, install the app to a channel, and grab the bot token. 146 | 147 | Note that the Slack token will be the same for local development and 148 | for production, so this token may already exist. Check with your system 149 | administrator if applicable. 150 | 151 | ### `VOLUNTEER_DISPATCH_PREVENT_PROCESSING` 152 | 153 | Prevent processing of records. Used to sequence bot pull requests. 154 | Optional. Defaults to `false`. 155 | 156 | ### `VOLUNTEER_DISPATCH_STATE` 157 | 158 | The two-letter state code where your volunteer effort is located. 159 | Optional. Defaults to NY. 160 | -------------------------------------------------------------------------------- /docs/HOW_TO_GET_API_KEYS.md: -------------------------------------------------------------------------------- 1 | # How to get the API keys for your volunteer dispatch Slackbot 2 | 3 | By the end of this tutorial, you will have set up API keys for: 4 | 5 | - `AIRTABLE_API_KEY` 6 | - `MAPQUEST_KEY` 7 | - `SLACK_XOXB` - Slack bot token. 8 | - `SLACK_CHANNEL_ID` - Slack channel ID 9 | 10 | ## 1. Get Airtable API-key 11 | 12 | In Airtable, click in your account in the top right hand corner: 13 | 14 | ![figure 1](https://github.com/MutualAidNYC/media/blob/master/Airtable%20API%20key%201.png) 15 | 16 | Halfway down your account overview page you can click on a grey button that says “Generate API key” 17 | 18 | ![figure 2](https://github.com/MutualAidNYC/media/blob/master/Airtable%20API%20key%202.png) 19 | 20 | The API key is generated but hidden behind a row of doubts in the light purple box. 21 | 22 | ![figure 3](https://github.com/MutualAidNYC/media/blob/master/Airtable%20API%20key%203.png) 23 | 24 | Hover your cursor over the dotted area to reveal the API key and copy it to a sticky or notes app. 25 | 26 | ![figure 4](https://github.com/MutualAidNYC/media/blob/master/Airtable%20API%20key%204.png) 27 | 28 | ## 2. Get Mapquest API key 29 | 30 | When you open Mapquest, your home dashboard is a page that says “Manage Keys”. Click on the blue button on the right that says “Create a new key”. 31 | 32 | ![figure 5](https://github.com/MutualAidNYC/media/blob/master/Mapquest%20API%20key%201.png) 33 | 34 | We will be using this API for the Slackbot volunteer app you will create so call the App Name something like “volunteer map”. You can leave the callback URL blank. 35 | 36 | ![figure 6](https://github.com/MutualAidNYC/media/blob/master/MapQuest%20API%20key%202.png) 37 | 38 | You will be returned to your home “Manage Keys’ page. Click the triangle/arrow next to the app name “Volunteer map” to reveal your keys. 39 | 40 | ![figure 7](https://github.com/MutualAidNYC/media/blob/master/Mapquest%20API%20key%203.png) 41 | 42 | Your consumer key is your API key. Copy and paste this key into the notes doc where you saved the Airtable API key. 43 | 44 | ![figure 8](https://github.com/MutualAidNYC/media/blob/master/MapQuest%20API%20key%204.png) 45 | 46 | ## 3. Create a Slackbot app token. 47 | 48 | Now we need to set up our Slack channel where the bot will live when it is doing the processing. In Slack, create a new channel by clicking the + sign next to Channels in the left-hand menu and choose “Create a channel”. 49 | 50 | ![figure 9](https://github.com/MutualAidNYC/media/blob/master/slack%201.png) 51 | 52 | Call the channel something like slackbot-vol. Make it private. 53 | 54 | ![figure 10](https://github.com/MutualAidNYC/media/blob/master/slack%202.png) 55 | 56 | Don’t worry about adding anyone to the channel at the moment. Click skip for now. 57 | 58 | ![figure 11](https://github.com/MutualAidNYC/media/blob/master/slack%203.png) 59 | 60 | Your channel has now been created. You will need to have a look at it in a desktop for this next step. Open the channel in a browser and not the slack app. Look for the last set of letters and numbers in the URL after the final backslash. Copy this to your notes document, noting that this is the Slack Channel ID. 61 | 62 | ![figure 12](https://github.com/MutualAidNYC/media/blob/master/slack%204.png) 63 | 64 | Now in the left-hand menu, click on your workspace name at the top and see the dropdown menu. Cursor over the “Settings & Administration” and click on “Manage apps”. This will open you in the browser again if you did that in the Slack app. On the top menu of that page, the button next to your workspace name says “Build”. Click on it. 65 | 66 | ![figure 13](https://github.com/MutualAidNYC/media/blob/master/slack%205.png) 67 | 68 | Click on the green button “Start Building”. 69 | 70 | ![figure 14](https://github.com/MutualAidNYC/media/blob/master/slack%206.png) 71 | 72 | Give your app a name (this will be your slackbot, so we called it Slackbot-vol here), make sure it is connected to the right Slack workspace, and click the green “Create app” button. 73 | 74 | ![figure 15](https://github.com/MutualAidNYC/media/blob/master/slack%207.png) 75 | 76 | Now select “OAuth & Permissions” from the left-hand menu: 77 | 78 | ![figure 16](https://github.com/MutualAidNYC/media/blob/master/slack%208.png) 79 | 80 | Scroll down to where it says “Scopes” and click the “Add an OAuth Scope” button. 81 | 82 | ![figure 17](https://github.com/MutualAidNYC/media/blob/master/slack%209.png) 83 | 84 | Add a new scope as shown in the image below. Choose chat:write from the dropdown list. 85 | 86 | ![figure 18](https://github.com/MutualAidNYC/media/blob/master/slack%2010.png) 87 | 88 | Now further down this page, you can copy the bot token: 89 | 90 | ![figure 19](https://github.com/MutualAidNYC/media/blob/master/slack%2014.png) 91 | 92 | Copy this and put it in your notes doc. 93 | Now scroll up to the top of the page and click the green “Install App to Workspace” button. 94 | 95 | ![figure 20](https://github.com/MutualAidNYC/media/blob/master/slack%2012.png) 96 | 97 | Choose the right workspace from the list and this screen will appear. Click the green “Allow” button. 98 | 99 | ![figure 21](https://github.com/MutualAidNYC/media/blob/master/slack%2013.png) 100 | 101 | Now go back to Slack and go to the channel you created earlier (in our case that was also called slackbot-vol). Click the blue menu option “Add an app”. 102 | 103 | ![figure 22](https://github.com/MutualAidNYC/media/blob/master/slack%2011.png) 104 | 105 | The Slackbot-vol appears in our workspace options. Click the “Add” button on the right. 106 | 107 | ![figure 23](https://github.com/MutualAidNYC/media/blob/master/slack%2015.png) 108 | 109 | The channel will now say that the app has been added to the channel. 110 | 111 | ## 4. Add these keys to your slackbot! 112 | 113 | The Docker Compose YAML is set up to accept your API keys from your environment. So you can load the API keys into the shell that is running your Docker Compose. When you deploy to production, your deployment tools will take the environment API keys to make sure your slackbot is integrated and able to start checking for new requests every 15 seconds. 114 | 115 | -------------------------------------------------------------------------------- /src/service/request-service.js: -------------------------------------------------------------------------------- 1 | const preconditions = require("preconditions").singleton(); 2 | 3 | const AirtableUtils = require("../utils/airtable-utils"); 4 | const { logger } = require("../logger"); 5 | const { getCoords } = require("../geo"); 6 | const config = require("../config"); 7 | const RequestRecord = require("../model/request-record"); 8 | const RequesterService = require("./requester-service"); 9 | 10 | /** 11 | * APIs that deals with Request 12 | */ 13 | class RequestService { 14 | /** 15 | * Constructs an instance of RequestService 16 | * 17 | * @param {object} base Airtable's "base" object for the Requests table 18 | * @param {AirtableUtils} airtableUtils instance of AirtableUtils 19 | * @param {RequesterService} requesterService instance of UserService 20 | */ 21 | constructor(base, airtableUtils, requesterService) { 22 | preconditions.shouldBeObject(base); 23 | this.base = base; 24 | this.airtableUtils = airtableUtils; 25 | this.requesterService = requesterService; 26 | } 27 | 28 | /** 29 | * Resolve and update coordinates for requester's address 30 | * 31 | * @param {object} request Request requiring coordinates 32 | * @returns {Promise} request records updated with coordinates 33 | * @throws error when unable to resolve coordinates or update them in airtable 34 | */ 35 | async resolveAndUpdateCoords(request) { 36 | preconditions.shouldBeObject(request); 37 | preconditions.shouldBeString(request.fullAddress); 38 | try { 39 | if ( 40 | request.coordinates && 41 | (typeof request.coordinatesAddress === "undefined" || 42 | request.coordinatesAddress.trim().length === 0 || 43 | request.coordinatesAddress === request.fullAddress) 44 | ) { 45 | return request; 46 | } 47 | } catch (e) { 48 | // error expected here 49 | } 50 | let errandCoords; 51 | try { 52 | errandCoords = await getCoords(request.fullAddress); 53 | } catch (e) { 54 | // catch error so we can log it with logger and in airtable 55 | logger.error( 56 | `Error getting coordinates for requester ${request.get( 57 | "Name" 58 | )} with error: ${JSON.stringify(e)}` 59 | ); 60 | this.airtableUtils.logErrorToTable( 61 | config.AIRTABLE_REQUESTS_TABLE_NAME, 62 | request, 63 | e, 64 | "getCoords" 65 | ); 66 | // re-throw error because there is no point in continuing or returning something else 67 | // and we should let caller know that something went wrong. 68 | throw e; 69 | } 70 | let updatedRecord; 71 | try { 72 | updatedRecord = await this.base.update(request.id, { 73 | _coordinates: JSON.stringify(errandCoords), 74 | _coordinates_address: request.fullAddress, 75 | }); 76 | } catch (e) { 77 | // catch error so we can log it with logger and in airtable 78 | logger.error( 79 | `Error getting coordinates for requester ${request.get( 80 | "Name" 81 | )} with error: ${JSON.stringify(e)}` 82 | ); 83 | this.airtableUtils.logErrorToTable( 84 | config.AIRTABLE_REQUESTS_TABLE_NAME, 85 | request, 86 | e, 87 | "update _coordinates" 88 | ); 89 | // re-throw error because there is no point in continuing or returning something else 90 | // and we should let caller know that something went wrong. 91 | throw e; 92 | } 93 | return new RequestRecord(updatedRecord); 94 | } 95 | 96 | /** 97 | * Splits a task with multiple requests into one request per task. 98 | * New records are created in Airtable. 99 | * 100 | * @param {object} request Original request record with multiple tasks 101 | * @returns {void} 102 | */ 103 | async splitMultiTaskRequest(request) { 104 | preconditions.shouldBeObject(request); 105 | preconditions.checkArgument(request.tasks.length > 1); 106 | // Update the original request to only contain the first Task, to prevent 107 | // duplicates from cloning records for each task. 108 | try { 109 | await this.base.update(request.id, { 110 | Tasks: [request.rawFields.Tasks[0]], 111 | "Original Tasks": request.rawFields.Tasks, 112 | "Task Order": `1 of ${request.tasks.length}`, 113 | }); 114 | } catch (e) { 115 | logger.error( 116 | `Error updating 'Tasks' column in request ${request.id}: `, 117 | e 118 | ); 119 | throw e; 120 | } 121 | const newRecordsPerTask = request.tasks.slice(1).map((task, idx) => { 122 | const order = `${idx + 2} of ${request.tasks.length}`; 123 | return AirtableUtils.cloneRequestFieldsWithGivenTask( 124 | request, 125 | task, 126 | order 127 | ); 128 | }); 129 | try { 130 | await this.base.create(newRecordsPerTask); 131 | } catch (e) { 132 | if (e) { 133 | logger.error( 134 | `Error cloning request with multiple tasks for request ${request.id}: `, 135 | e 136 | ); 137 | } 138 | } 139 | } 140 | 141 | /** 142 | * Links a user to the request. 143 | * We try to identify the User by the requester's phone number first, followed by name. 144 | * A new user is created if they don't already exist in the system. 145 | * 146 | * @param {object|RequestRecord} request that needs to be linked to a user 147 | * @returns {Promise} Does not return any value. 148 | */ 149 | async linkUserWithRequest(request) { 150 | preconditions.shouldBeObject(request); 151 | // noinspection JSCheckFunctionSignatures 152 | preconditions.checkArgument( 153 | request.phoneNumber || request.requesterName, 154 | "Either phone number or requester's name should be present" 155 | ); 156 | let userRecord = null; 157 | if (request.phoneNumber) { 158 | userRecord = await this.requesterService.findUserByPhoneNumber( 159 | request.phoneNumber 160 | ); 161 | } 162 | if (userRecord === null && request.requesterName) { 163 | userRecord = await this.requesterService.findUserByFullName( 164 | request.requesterName 165 | ); 166 | } 167 | if (userRecord === null) { 168 | userRecord = await this.requesterService.createUser( 169 | request.requesterName, 170 | request.phoneNumber 171 | ); 172 | } 173 | this.base.update(request.id, { Requester: [userRecord.id] }, (err) => { 174 | if (err) { 175 | logger.error( 176 | `Error updating 'Requester' column in request ${request.id}`, 177 | err 178 | ); 179 | } 180 | }); 181 | } 182 | } 183 | 184 | module.exports = RequestService; 185 | -------------------------------------------------------------------------------- /test/service/request-service.test.js: -------------------------------------------------------------------------------- 1 | const { when, resetAllWhenMocks } = require("jest-when"); 2 | 3 | jest.mock("../../src/geo"); 4 | jest.mock("../../src/utils/airtable-utils"); 5 | jest.mock("../../src/service/requester-service"); 6 | const AirtableUtils = require("../../src/utils/airtable-utils"); 7 | const RequestRecord = require("../../src/model/request-record"); 8 | const Task = require("../../src/task"); 9 | const RequestService = require("../../src/service/request-service"); 10 | const RequesterService = require("../../src/service/requester-service"); 11 | const UserRecord = require("../../src/model/user-record"); 12 | 13 | describe("RequestService", () => { 14 | let base; 15 | let service; 16 | let mockAirtableRequest; 17 | const mockAirtableGet = jest.fn(); 18 | let userService; 19 | beforeEach(() => { 20 | base = { create: jest.fn(), update: jest.fn() }; 21 | mockAirtableRequest = { 22 | id: "lkdjf8979", 23 | get: mockAirtableGet, 24 | fields: {}, 25 | }; 26 | const airtableUtils = new AirtableUtils(base); 27 | RequesterService.mockClear(); 28 | userService = new RequesterService(base); 29 | service = new RequestService(base, airtableUtils, userService); 30 | resetAllWhenMocks(); 31 | }); 32 | describe("splitMultiTaskRequest", () => { 33 | it("should throw error if there is only 1 task", async () => { 34 | expect.assertions(3); 35 | when(mockAirtableGet).calledWith("Tasks").mockReturnValue(undefined); 36 | let request = new RequestRecord(mockAirtableRequest); 37 | await expect(service.splitMultiTaskRequest(request)).rejects.toThrow( 38 | "Illegal Argument." 39 | ); 40 | when(mockAirtableGet).calledWith("Tasks").mockReturnValue([]); 41 | request = new RequestRecord(mockAirtableRequest); 42 | await expect(service.splitMultiTaskRequest(request)).rejects.toThrow( 43 | "Illegal Argument." 44 | ); 45 | when(mockAirtableGet) 46 | .calledWith("Tasks") 47 | .mockReturnValue([Task.possibleTasks[0].rawTask]); 48 | request = new RequestRecord(mockAirtableRequest); 49 | await expect(service.splitMultiTaskRequest(request)).rejects.toThrow( 50 | "Illegal Argument." 51 | ); 52 | }); 53 | it("should try to create correct records", async () => { 54 | expect.assertions(1); 55 | mockAirtableRequest.fields.Tasks = [ 56 | Task.possibleTasks[0], 57 | Task.possibleTasks[1], 58 | ]; 59 | when(mockAirtableGet) 60 | .calledWith("Tasks") 61 | .mockReturnValue([ 62 | Task.possibleTasks[0].rawTask, 63 | Task.possibleTasks[1].rawTask, 64 | ]); 65 | const request = new RequestRecord(mockAirtableRequest); 66 | const newRecords = [ 67 | { fields: { Tasks: Task.possibleTasks[0] } }, 68 | { fields: { Tasks: Task.possibleTasks[1] } }, 69 | ]; 70 | AirtableUtils.cloneRequestFieldsWithGivenTask.mockReturnValueOnce( 71 | newRecords[0] 72 | ); 73 | AirtableUtils.cloneRequestFieldsWithGivenTask.mockReturnValueOnce( 74 | newRecords[1] 75 | ); 76 | await service.splitMultiTaskRequest(request); 77 | expect(base.create).toHaveBeenCalledWith([newRecords[0]]); 78 | }); 79 | }); 80 | describe("linkUserWithRequest", () => { 81 | const request = new RequestRecord(); 82 | const userRecord = new UserRecord(); 83 | const userId = "oiesr1212"; 84 | let requestId; 85 | beforeEach(() => { 86 | requestId = mockAirtableRequest.id; 87 | jest.spyOn(request, "id", "get").mockReturnValue(requestId); 88 | jest.spyOn(userRecord, "id", "get").mockReturnValue(userId); 89 | }); 90 | it("should throw error if both phone number and requester name are not present", async () => { 91 | expect.assertions(1); 92 | jest.spyOn(request, "phoneNumber", "get").mockReturnValue(undefined); 93 | jest.spyOn(request, "requesterName", "get").mockReturnValue(undefined); 94 | await expect(service.linkUserWithRequest(request)).rejects.toThrow( 95 | "Either phone number or requester's name should be present" 96 | ); 97 | }); 98 | const phoneNumber = "055.956.1902"; 99 | const updatedField = { Requester: [userId] }; 100 | it("should try to find user by phone number if present", async () => { 101 | expect.assertions(3); 102 | jest.spyOn(request, "phoneNumber", "get").mockReturnValue(phoneNumber); 103 | jest.spyOn(request, "requesterName", "get").mockReturnValue(undefined); 104 | when(userService.findUserByPhoneNumber) 105 | .expectCalledWith(phoneNumber) 106 | .mockResolvedValue(userRecord); 107 | await service.linkUserWithRequest(request); 108 | expect(userService.findUserByFullName).not.toHaveBeenCalled(); 109 | expect(base.update).toHaveBeenCalledWith( 110 | requestId, 111 | updatedField, 112 | expect.any(Function) 113 | ); 114 | }); 115 | const fullName = "Ezekiel Adams"; 116 | it("should try to find user by fullname if present", async () => { 117 | expect.assertions(3); 118 | jest.spyOn(request, "phoneNumber", "get").mockReturnValue(undefined); 119 | jest.spyOn(request, "requesterName", "get").mockReturnValue(fullName); 120 | when(userService.findUserByFullName) 121 | .expectCalledWith(fullName) 122 | .mockResolvedValue(userRecord); 123 | await service.linkUserWithRequest(request); 124 | expect(userService.findUserByPhoneNumber).not.toHaveBeenCalled(); 125 | expect(base.update).toHaveBeenCalledWith( 126 | requestId, 127 | updatedField, 128 | expect.any(Function) 129 | ); 130 | }); 131 | it("should try to create a user if one is not found", async () => { 132 | expect.assertions(8); 133 | jest.spyOn(request, "phoneNumber", "get").mockReturnValue(phoneNumber); 134 | jest.spyOn(request, "requesterName", "get").mockReturnValue(fullName); 135 | when(userService.findUserByFullName) 136 | .expectCalledWith(fullName) 137 | .mockResolvedValue(null); 138 | when(userService.findUserByPhoneNumber) 139 | .expectCalledWith(phoneNumber) 140 | .mockResolvedValue(null); 141 | when(userService.createUser) 142 | .expectCalledWith(fullName, phoneNumber) 143 | .mockResolvedValue(userRecord); 144 | await service.linkUserWithRequest(request); 145 | expect(userService.findUserByPhoneNumber).toHaveBeenCalled(); 146 | expect(userService.findUserByFullName).toHaveBeenCalled(); 147 | expect(userService.createUser).toHaveBeenCalledWith( 148 | fullName, 149 | phoneNumber 150 | ); 151 | expect(base.update).toHaveBeenCalledWith( 152 | requestId, 153 | updatedField, 154 | expect.any(Function) 155 | ); 156 | }); 157 | }); 158 | }); 159 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-len */ 2 | // For a detailed explanation regarding each configuration property, visit: 3 | // https://jestjs.io/docs/en/configuration.html 4 | 5 | module.exports = { 6 | // All imported modules in your tests should be mocked automatically 7 | // automock: false, 8 | 9 | // Stop running tests after `n` failures 10 | // bail: 0, 11 | 12 | // Respect "browser" field in package.json when resolving modules 13 | // browser: false, 14 | 15 | // The directory where Jest should store its cached dependency information 16 | // cacheDirectory: "/private/var/folders/k4/w0pj9d050tlct0hbv6tmc1br0000gn/T/jest_dx", 17 | 18 | // Automatically clear mock calls and instances between every test 19 | clearMocks: true, 20 | 21 | // Indicates whether the coverage information should be collected while executing the test 22 | // collectCoverage: false, 23 | 24 | // An array of glob patterns indicating a set of files for which coverage information should be collected 25 | // collectCoverageFrom: undefined, 26 | 27 | // The directory where Jest should output its coverage files 28 | // coverageDirectory: undefined, 29 | 30 | // An array of regexp pattern strings used to skip coverage collection 31 | // coveragePathIgnorePatterns: [ 32 | // "/node_modules/" 33 | // ], 34 | 35 | // A list of reporter names that Jest uses when writing coverage reports 36 | // coverageReporters: [ 37 | // "json", 38 | // "text", 39 | // "lcov", 40 | // "clover" 41 | // ], 42 | 43 | // An object that configures minimum threshold enforcement for coverage results 44 | // coverageThreshold: undefined, 45 | 46 | // A path to a custom dependency extractor 47 | // dependencyExtractor: undefined, 48 | 49 | // Make calling deprecated APIs throw helpful error messages 50 | // errorOnDeprecated: false, 51 | 52 | // Force coverage collection from ignored files using an array of glob patterns 53 | // forceCoverageMatch: [], 54 | 55 | // A path to a module which exports an async function that is triggered once before all test suites 56 | // globalSetup: undefined, 57 | 58 | // A path to a module which exports an async function that is triggered once after all test suites 59 | // globalTeardown: undefined, 60 | 61 | // A set of global variables that need to be available in all test environments 62 | // globals: {}, 63 | 64 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. 65 | // maxWorkers: "50%", 66 | 67 | // An array of directory names to be searched recursively up from the requiring module's location 68 | // moduleDirectories: [ 69 | // "node_modules" 70 | // ], 71 | 72 | // An array of file extensions your modules use 73 | // moduleFileExtensions: [ 74 | // "js", 75 | // "json", 76 | // "jsx", 77 | // "ts", 78 | // "tsx", 79 | // "node" 80 | // ], 81 | 82 | // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module 83 | // moduleNameMapper: {}, 84 | 85 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 86 | // modulePathIgnorePatterns: [], 87 | 88 | // Activates notifications for test results 89 | // notify: false, 90 | 91 | // An enum that specifies notification mode. Requires { notify: true } 92 | // notifyMode: "failure-change", 93 | 94 | // A preset that is used as a base for Jest's configuration 95 | // preset: undefined, 96 | 97 | // Run tests from one or more projects 98 | // projects: undefined, 99 | 100 | // Use this configuration option to add custom reporters to Jest 101 | // reporters: undefined, 102 | 103 | // Automatically reset mock state between every test 104 | // resetMocks: false, 105 | 106 | // Reset the module registry before running each individual test 107 | // resetModules: false, 108 | 109 | // A path to a custom resolver 110 | // resolver: undefined, 111 | 112 | // Automatically restore mock state between every test 113 | // restoreMocks: false, 114 | 115 | // The root directory that Jest should scan for tests and modules within 116 | // rootDir: undefined, 117 | 118 | // A list of paths to directories that Jest should use to search for files in 119 | // roots: [ 120 | // "" 121 | // ], 122 | 123 | // Allows you to use a custom runner instead of Jest's default test runner 124 | // runner: "jest-runner", 125 | 126 | // The paths to modules that run some code to configure or set up the testing environment before each test 127 | // setupFiles: [], 128 | 129 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 130 | // setupFilesAfterEnv: [], 131 | 132 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 133 | // snapshotSerializers: [], 134 | 135 | // The test environment that will be used for testing 136 | testEnvironment: "node", 137 | 138 | // Options that will be passed to the testEnvironment 139 | // testEnvironmentOptions: {}, 140 | 141 | // Adds a location field to test results 142 | // testLocationInResults: false, 143 | 144 | // The glob patterns Jest uses to detect test files 145 | // testMatch: [ 146 | // "**/__tests__/**/*.[jt]s?(x)", 147 | // "**/?(*.)+(spec|test).[tj]s?(x)" 148 | // ], 149 | 150 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 151 | // testPathIgnorePatterns: [ 152 | // "/node_modules/" 153 | // ], 154 | 155 | // The regexp pattern or array of patterns that Jest uses to detect test files 156 | // testRegex: [], 157 | 158 | // This option allows the use of a custom results processor 159 | // testResultsProcessor: undefined, 160 | 161 | // This option allows use of a custom test runner 162 | // testRunner: "jasmine2", 163 | 164 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href 165 | // testURL: "http://localhost", 166 | 167 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" 168 | // timers: "real", 169 | 170 | // A map from regular expressions to paths to transformers 171 | // transform: undefined, 172 | 173 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 174 | // transformIgnorePatterns: [ 175 | // "/node_modules/" 176 | // ], 177 | 178 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 179 | // unmockedModulePathPatterns: undefined, 180 | 181 | // Indicates whether each individual test should be reported during the run 182 | // verbose: undefined, 183 | 184 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 185 | // watchPathIgnorePatterns: [], 186 | 187 | // Whether to use watchman for file crawling 188 | // watchman: true, 189 | }; 190 | -------------------------------------------------------------------------------- /src/slack/reminder.js: -------------------------------------------------------------------------------- 1 | const Airtable = require("airtable"); 2 | 3 | require("dotenv").config(); 4 | const axios = require("axios"); 5 | const crypto = require("crypto"); 6 | const qs = require("qs"); 7 | 8 | const config = require("../config"); 9 | 10 | const slackSecret = config.SLACK_SIGNING_SECRET; 11 | 12 | const base = new Airtable({ apiKey: config.AIRTABLE_API_KEY }).base( 13 | config.AIRTABLE_BASE_ID 14 | ); 15 | 16 | /** 17 | * Function that takes in date and time from slack buttons and returns date/time in ms 18 | * 19 | * @param {string} date Date and time from slack. 20 | * @returns {Date} The date and time in milliseconds. 21 | */ 22 | const convertDate = (date) => { 23 | const dateArr = date.split("-"); 24 | return Date.parse(`${dateArr[1]} ${dateArr[2]} ${dateArr[0]}`) + 28800000; 25 | }; 26 | 27 | /** 28 | * Convert specific time strings to unix timestamps. 29 | * 30 | * @param {string} time The time to process. Can be 8am, 12pm, 4pm, or 8pm. 31 | * @returns {Date} Time in miliseconds. 32 | */ 33 | const convertTime = (time) => { 34 | let timeInMils = 0; 35 | switch (time) { 36 | case "8am": 37 | timeInMils = 0; 38 | break; 39 | case "12pm": 40 | timeInMils = 14400000; 41 | break; 42 | case "4pm": 43 | timeInMils = 28800000; 44 | break; 45 | case "8pm": 46 | timeInMils = 43200000; 47 | break; 48 | default: 49 | break; 50 | } 51 | return timeInMils; 52 | }; 53 | 54 | /** 55 | * Function that confirms our slack button requests are actually from slack. 56 | * 57 | * @param {object} req The request object. 58 | * @returns {boolean} True if from slack, false otherwise. 59 | */ 60 | const slackConf = (req) => { 61 | const reqBody = qs.stringify(req.body, { format: "RFC1738" }); 62 | const timeStamp = req.headers["x-slack-request-timestamp"]; 63 | const slackSig = req.headers["x-slack-signature"]; 64 | if (Math.abs(Math.floor(Date.now() / 1000) - timeStamp) > 300) { 65 | return false; 66 | } 67 | const baseString = `v0:${timeStamp}:${reqBody}`; 68 | const mySecret = `v0=${crypto 69 | .createHmac("sha256", slackSecret) 70 | .update(baseString) 71 | .digest("hex")}`; 72 | 73 | if ( 74 | crypto.timingSafeEqual( 75 | Buffer.from(mySecret, "utf8"), 76 | Buffer.from(slackSig, "utf8") 77 | ) 78 | ) { 79 | return true; 80 | } 81 | return false; 82 | }; 83 | 84 | /** 85 | * Function that updates airtable 'Reminder Date/Time', 86 | * and 'Reminder Posted' fields when reminder is set 87 | * 88 | * @param {number} id The Airtable ID. 89 | * @param {string} dateTime The time to set to. 90 | * @returns {void} 91 | */ 92 | const updateReminderDateTime = async (id, dateTime) => { 93 | await base(config.AIRTABLE_REQUESTS_TABLE_NAME) 94 | .select({ 95 | view: "Grid view", 96 | filterByFormula: `({Record ID} = '${id}')`, 97 | }) 98 | .eachPage(async (record, nextPage) => { 99 | if (dateTime !== "reset") { 100 | const oldDateTime = record[0].get("Reminder Date/Time"); 101 | let newDateTime = 0; 102 | if (!oldDateTime) { 103 | newDateTime = dateTime; 104 | } else { 105 | newDateTime = parseInt(oldDateTime, 10) + dateTime; 106 | } 107 | record[0].patchUpdate({ 108 | "Reminder Date/Time": newDateTime.toString(), 109 | "Reminder Posted": "", 110 | }); 111 | } else { 112 | record[0].patchUpdate({ 113 | "Reminder Date/Time": "", 114 | "Reminder Posted": "", 115 | }); 116 | } 117 | nextPage(); 118 | }); 119 | }; 120 | 121 | /** 122 | * Function to get date and return in correct format for slack datepicker. 123 | * 124 | * @returns {string} formatted date for slack. 125 | */ 126 | function getDate() { 127 | const date = new Date(); 128 | return `${date.getUTCFullYear()}-${ 129 | date.getUTCMonth() + 1 130 | }-${date.getUTCDate()}`; 131 | } 132 | // array of slack reminder buttons 133 | const followUpButton = { 134 | type: "actions", 135 | block_id: "followup", 136 | elements: [ 137 | { 138 | type: "button", 139 | text: { 140 | type: "plain_text", 141 | text: "Flag for Follow-up?", 142 | }, 143 | style: "primary", 144 | value: "follow_up_requested", 145 | }, 146 | ], 147 | }; 148 | 149 | const datePickerButton = { 150 | type: "section", 151 | block_id: "calendar", 152 | text: { 153 | type: "mrkdwn", 154 | text: "Pick a date for the reminder.", 155 | }, 156 | accessory: { 157 | type: "datepicker", 158 | action_id: "datepicker123", 159 | initial_date: `${getDate()}`, 160 | }, 161 | }; 162 | 163 | const timeText = { 164 | type: "section", 165 | block_id: "timeText", 166 | text: { 167 | type: "mrkdwn", 168 | text: "What time would you like to be reminded?", 169 | }, 170 | }; 171 | 172 | const timePickerButton = { 173 | type: "actions", 174 | block_id: "time", 175 | 176 | elements: [ 177 | { 178 | type: "button", 179 | text: { 180 | type: "plain_text", 181 | text: "8AM", 182 | }, 183 | style: "primary", 184 | value: "8am", 185 | }, 186 | { 187 | type: "button", 188 | text: { 189 | type: "plain_text", 190 | text: "12PM", 191 | }, 192 | style: "primary", 193 | value: "12pm", 194 | }, 195 | { 196 | type: "button", 197 | text: { 198 | type: "plain_text", 199 | text: "4PM", 200 | }, 201 | style: "primary", 202 | value: "4pm", 203 | }, 204 | { 205 | type: "button", 206 | text: { 207 | type: "plain_text", 208 | text: "8PM", 209 | }, 210 | style: "primary", 211 | value: "8pm", 212 | }, 213 | ], 214 | }; 215 | const confText = { 216 | type: "section", 217 | text: { 218 | type: "mrkdwn", 219 | text: ":white_check_mark: Your follow-up reminder has been set!", 220 | }, 221 | }; 222 | 223 | /** 224 | * Function that swaps out slack reminder buttons after they've been pressed 225 | * 226 | * @param {object} body The contents of what's sent to slack. 227 | * @returns {void} 228 | */ 229 | function handleButtonUpdate(body) { 230 | const url = body.message.blocks[1].text.text; 231 | const id = url.substr(url.indexOf("rec"), 17); 232 | const responseUrl = body.response_url; 233 | const oldMessage = body.message; 234 | let updateObj = {}; 235 | const newBlocks = []; 236 | for (let i = 0; i < oldMessage.blocks.length; i += 1) { 237 | switch (oldMessage.blocks[i].block_id) { 238 | case "followup": 239 | updateReminderDateTime(id, "reset"); 240 | updateObj = datePickerButton; 241 | break; 242 | case "calendar": 243 | updateReminderDateTime(id, convertDate(body.actions[0].selected_date)); 244 | updateObj = timePickerButton; 245 | newBlocks.push(timeText); 246 | break; 247 | case "time": 248 | updateReminderDateTime(id, convertTime(body.actions[0].value)); 249 | updateObj = confText; 250 | newBlocks.pop(); 251 | break; 252 | default: 253 | updateObj = oldMessage.blocks[i]; 254 | break; 255 | } 256 | newBlocks.push(updateObj); 257 | } 258 | axios.post(responseUrl, { 259 | replace_original: true, 260 | text: oldMessage.text, 261 | type: "block_actions", 262 | blocks: newBlocks, 263 | }); 264 | } 265 | 266 | module.exports = { 267 | followUpButton, 268 | handleButtonUpdate, 269 | slackConf, 270 | }; 271 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const Airtable = require("airtable"); 2 | 3 | const Task = require("./task"); 4 | const config = require("./config"); 5 | const AirtableUtils = require("./utils/airtable-utils"); 6 | const { filterByLanguage } = require("./languageFilter"); 7 | const http = require("./http"); 8 | const { getCoords, distanceBetweenCoords } = require("./geo"); 9 | const { logger } = require("./logger"); 10 | const Request = require("./model/request-record"); 11 | const RequesterService = require("./service/requester-service"); 12 | const RequestService = require("./service/request-service"); 13 | const VolunteerService = require("./service/volunteer-service"); 14 | 15 | const { sendDispatch } = require("./slack/sendDispatch"); 16 | require("dotenv").config(); 17 | 18 | /* System notes: 19 | * - Certain tasks should probably have an unmatchable requirement (because the tasks requires 20 | * looking a shortlist of specialized volunteers) 21 | * - Airtable fields that start with '_' are system columns, not to be updated manually 22 | * - If the result seems weird, verify the addresses of the request/volunteers 23 | */ 24 | 25 | // Airtable 26 | const base = new Airtable({ apiKey: config.AIRTABLE_API_KEY }).base( 27 | config.AIRTABLE_BASE_ID 28 | ); 29 | const customAirtable = new AirtableUtils(base); 30 | const requesterService = new RequesterService( 31 | base(config.AIRTABLE_REQUESTERS_TABLE_NAME) 32 | ); 33 | const requestService = new RequestService( 34 | base(config.AIRTABLE_REQUESTS_TABLE_NAME), 35 | customAirtable, 36 | requesterService 37 | ); 38 | const volunteerService = new VolunteerService( 39 | base(config.AIRTABLE_VOLUNTEERS_TABLE_NAME) 40 | ); 41 | 42 | /** 43 | * Fetch volunteers and return custom fields. 44 | * 45 | * @param {Array} volunteerAndDistance An array with volunteer record on the 0th index and its 46 | * distance from requester on the 1st index 47 | * @param {object} request The Airtable request object. 48 | * @returns {{Number: *, record: *, Distance: *, Name: *, Language: *}} Custom volunteer fields. 49 | */ 50 | function volunteerWithCustomFields(volunteerAndDistance, request) { 51 | const [volunteer, distance] = volunteerAndDistance; 52 | let volLanguage = request.get("Language") 53 | ? request.get("Language") 54 | : volunteer.get("Please select any language you have verbal fluency with:"); 55 | 56 | if (Array.isArray(volLanguage)) { 57 | if (volLanguage.length > 1) { 58 | volLanguage = volLanguage.join(", "); 59 | } 60 | } 61 | 62 | return { 63 | Name: volunteer.get("Full Name"), 64 | Number: volunteer.get("Please provide your contact phone number:"), 65 | Distance: distance, 66 | record: volunteer, 67 | Id: volunteer.id, 68 | Language: volLanguage, 69 | }; 70 | } 71 | 72 | // Accepts errand address and checks volunteer spreadsheet for closest volunteers 73 | /** 74 | * Find volunteers. 75 | * 76 | * @param {object} request The Airtable request object. 77 | * @returns {Array} An array of objects of the closest volunteers to the request, 78 | * or an empty array if none are found. 79 | */ 80 | async function findVolunteers(request) { 81 | const { tasks } = request; 82 | if (tasks && tasks.length > 0 && tasks[0].equals(Task.LONELINESS)) { 83 | return (await volunteerService.findVolunteersForLoneliness()) 84 | .map((v) => [v, "N/A"]) 85 | .map((volunteerAndDistance) => 86 | volunteerWithCustomFields(volunteerAndDistance, request) 87 | ); 88 | } 89 | 90 | let errandCoords; 91 | try { 92 | errandCoords = request.coordinates; 93 | } catch (e) { 94 | logger.error( 95 | `Unable to parse coordinates for request ${request.id} from ${request.name}` 96 | ); 97 | return []; 98 | } 99 | logger.info(`Tasks: ${tasks.map((task) => task.rawTask).join(", ")}`); 100 | 101 | const volunteerDistances = []; 102 | // Figure out which volunteers can fulfill at least one of the tasks 103 | await base(config.AIRTABLE_VOLUNTEERS_TABLE_NAME) 104 | .select({ 105 | view: config.AIRTABLE_VOLUNTEERS_VIEW_NAME, 106 | filterByFormula: "{Account Disabled} != TRUE()", 107 | }) 108 | .eachPage(async (volunteers, nextPage) => { 109 | const suitableVolunteers = volunteers.filter((volunteer) => 110 | tasks.some((task) => task.canBeFulfilledByVolunteer(volunteer)) 111 | ); 112 | 113 | // Calculate the distance to each volunteer 114 | for (const volunteer of suitableVolunteers) { 115 | const volAddress = 116 | volunteer.get( 117 | "Full Street address (You can leave out your apartment/unit.)" 118 | ) || ""; 119 | 120 | // Check if we need to retrieve the addresses coordinates 121 | // NOTE: We do this to prevent using up our free tier queries on Mapquest (15k/month) 122 | if (volAddress !== volunteer.get("_coordinates_address")) { 123 | let newVolCoords; 124 | try { 125 | newVolCoords = await getCoords(volAddress); 126 | } catch (e) { 127 | logger.info( 128 | "Unable to retrieve volunteer coordinates:", 129 | volunteer.get("Full Name") 130 | ); 131 | customAirtable.logErrorToTable( 132 | config.AIRTABLE_VOLUNTEERS_TABLE_NAME, 133 | volunteer, 134 | e, 135 | "getCoords" 136 | ); 137 | continue; 138 | } 139 | 140 | volunteer.patchUpdate({ 141 | _coordinates: JSON.stringify(newVolCoords), 142 | _coordinates_address: volAddress, 143 | }); 144 | volunteer.fetch(); 145 | } 146 | 147 | // Try to get coordinates for this volunteer 148 | let volCoords; 149 | try { 150 | volCoords = JSON.parse(volunteer.get("_coordinates")); 151 | } catch (e) { 152 | logger.info( 153 | "Unable to parse volunteer coordinates:", 154 | volunteer.get("Full Name") 155 | ); 156 | continue; 157 | } 158 | 159 | // Calculate the distance 160 | const distance = distanceBetweenCoords(volCoords, errandCoords); 161 | volunteerDistances.push([volunteer, distance]); 162 | } 163 | 164 | nextPage(); 165 | }); 166 | 167 | // Filter the volunteers by language, then sort by distance and grab the closest 10 168 | const volFilteredByLanguage = filterByLanguage(request, volunteerDistances); 169 | 170 | const closestVolunteers = volFilteredByLanguage 171 | .sort((a, b) => a[1] - b[1]) 172 | .slice(0, 10) 173 | .map((volunteerAndDistance) => 174 | volunteerWithCustomFields(volunteerAndDistance, request) 175 | ); 176 | 177 | logger.info("Closest:"); 178 | closestVolunteers.forEach((v) => { 179 | logger.info(`${v.Name} ${v.Distance.toFixed(2)} Mi`); 180 | }); 181 | 182 | return closestVolunteers; 183 | } 184 | 185 | /** 186 | * Checks for updates on errand spreadsheet, finds closest volunteers from volunteer 187 | * spreadsheet and executes slack message if new row has been detected or if the row's reminder 188 | * date/time has passed 189 | * 190 | * @returns {void} 191 | */ 192 | async function checkForNewSubmissions() { 193 | base(config.AIRTABLE_REQUESTS_TABLE_NAME) 194 | .select({ 195 | view: config.AIRTABLE_REQUESTS_VIEW_NAME, 196 | filterByFormula: ` 197 | AND( 198 | {Name} != '', 199 | OR( 200 | {Posted to Slack?} != 'yes', 201 | AND( 202 | {Posted to Slack?} = 'yes', 203 | {Reminder Posted} != 'yes', 204 | AND( 205 | {Reminder Date/Time} != '', 206 | {Reminder Date/Time} < ${Date.now()} 207 | ) 208 | ) 209 | ) 210 | )`, 211 | }) 212 | .eachPage(async (records, nextPage) => { 213 | if (!records.length) return; 214 | 215 | const newSubmissions = records.map((r) => new Request(r)); 216 | 217 | // Look for records that have not been posted to slack yet 218 | for (const record of newSubmissions) { 219 | let requestWithCoords; 220 | try { 221 | requestWithCoords = await requestService.resolveAndUpdateCoords( 222 | record 223 | ); 224 | } catch (e) { 225 | logger.error( 226 | `Error resolving and updating coordinates of request ${record.id} of ${record.name}` 227 | ); 228 | continue; 229 | } 230 | if (requestWithCoords.tasks.length > 1) { 231 | // noinspection ES6MissingAwait 232 | requestService.splitMultiTaskRequest(requestWithCoords); 233 | continue; 234 | } 235 | 236 | logger.info(`New help request for: ${requestWithCoords.get("Name")}`); 237 | 238 | try { 239 | // Can be an async operation 240 | // noinspection ES6MissingAwait - no need to wait for a response. 241 | requestService.linkUserWithRequest(record); 242 | } catch (e) { 243 | logger.error("Unable to link user with request ", e); 244 | } 245 | 246 | let volunteers; 247 | try { 248 | // Find the closest volunteers 249 | volunteers = await findVolunteers(requestWithCoords); 250 | } catch (e) { 251 | logger.error("Unable to find volunteers for request ", e); 252 | } 253 | 254 | // Send the message to Slack 255 | let messageSent = false; 256 | let reminder = false; 257 | 258 | try { 259 | if ( 260 | Date.now() > record.get("Reminder Date/Time") && 261 | record.get("Posted to Slack?") === "yes" 262 | ) { 263 | await sendDispatch(requestWithCoords, volunteers, true); 264 | reminder = true; 265 | } else { 266 | await sendDispatch(requestWithCoords, volunteers); 267 | } 268 | 269 | messageSent = true; 270 | logger.info("Posted to Slack!"); 271 | } catch (error) { 272 | logger.error("Unable to post to Slack: ", error); 273 | } 274 | 275 | if (messageSent) { 276 | if (reminder) { 277 | await requestWithCoords.airtableRequest 278 | .patchUpdate({ 279 | "Reminder Posted": "yes", 280 | }) 281 | .then(logger.info("Updated Airtable record!")) 282 | .catch((error) => logger.error(error)); 283 | } else { 284 | await requestWithCoords.airtableRequest 285 | .patchUpdate({ 286 | "Posted to Slack?": "yes", 287 | Status: record.get("Status") || "Needs assigning", // don't overwrite the status 288 | }) 289 | .then(logger.info("Updated Airtable record!")) 290 | .catch((error) => logger.error(error)); 291 | } 292 | } 293 | } 294 | 295 | nextPage(); 296 | }); 297 | } 298 | 299 | /** 300 | * Start the chat bot service. 301 | * 302 | * @returns {void} 303 | */ 304 | async function start() { 305 | try { 306 | // Run once right away, and run again every 15 seconds 307 | if (config.VOLUNTEER_DISPATCH_PREVENT_PROCESSING) { 308 | logger.info( 309 | "Processing prevented by VOLUNTEER_DISPATCH_PREVENT_PROCESSING flag!" 310 | ); 311 | } else { 312 | logger.info("Volunteer Dispatch started!"); 313 | setTimeout(checkForNewSubmissions, 0); 314 | setInterval(checkForNewSubmissions, 15000); 315 | } 316 | 317 | // Run an HTTP server for health-check purposes 318 | http.run(); 319 | } catch (error) { 320 | logger.error(error); 321 | } 322 | } 323 | 324 | process.on("unhandledRejection", (reason) => { 325 | logger.error({ 326 | message: `Unhandled Rejection: ${reason.message}`, 327 | stack: reason.stack, 328 | }); 329 | // application specific logging, throwing an error, or other logic here 330 | }); 331 | 332 | start(); 333 | -------------------------------------------------------------------------------- /src/slack/message/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable consistent-return */ 2 | require("dotenv").config(); 3 | const config = require("../../config"); 4 | const { getDisplayNumber } = require("../../utils/phone-number-utils"); 5 | const { getElapsedTime } = require("../../utils/date-utils"); 6 | 7 | /** 8 | * Format section message for slack 9 | * 10 | * @param {string} text - message to print 11 | * @returns {object} - formatting object for slack 12 | */ 13 | const getSection = (text) => ({ 14 | type: "section", 15 | text: { 16 | type: "mrkdwn", 17 | text, 18 | }, 19 | }); 20 | 21 | /** 22 | * Get text for reminders or new errand 23 | * 24 | * @param {object} options The options containing the reminder. 25 | * @returns {string} Text based on the reminder. 26 | */ 27 | const getText = (options) => { 28 | return options.reminder 29 | ? "Reminder for a previous request" 30 | : "A new errand has been added"; 31 | }; 32 | 33 | /** 34 | * Format heading section for slack 35 | * 36 | * @param {object} options The configuration options. 37 | * @param {string} options.text The message to format. 38 | * @param {boolean} options.reminder Whether this message is a reminder or not. 39 | * @returns {object} The formatted heading. 40 | */ 41 | const getHeading = (options) => { 42 | if (options.reminder) { 43 | return getSection(`:alarm_clock: *${options.text}* :alarm_clock:`); 44 | } 45 | 46 | return getSection(`:exclamation: *${options.text}* :exclamation:`); 47 | }; 48 | 49 | /** 50 | * Format task order for split tasks. 51 | * 52 | * @param {object} record The split task to get order from. 53 | * @returns {object} The formatted task order to display after message header. 54 | */ 55 | const getTaskOrder = (record) => { 56 | if (record.get("Task Order")) { 57 | return getSection( 58 | `:bellhop_bell: This is *Task ${record.get("Task Order")}* of the Request` 59 | ); 60 | } 61 | }; 62 | 63 | /** 64 | * Format languages for slack display 65 | * 66 | * @param {object} record - the requested task to get the language from 67 | * @returns {string} - the formatted languages to display in slack 68 | */ 69 | const getLanguage = (record) => { 70 | const languages = [record.get("Language"), record.get("Language - other")]; 71 | const languageList = languages.filter((language) => language).join(", "); 72 | 73 | const formattedLanguageList = `${ 74 | languageList.length ? languageList : "None provided" 75 | }`; 76 | 77 | return formattedLanguageList; 78 | }; 79 | 80 | /** 81 | * Format the task requester for display in slack 82 | * 83 | * @param {object} record - the requested task to get the requester from 84 | * @returns {object} - formatted requester 85 | */ 86 | const getRequester = (record) => { 87 | const heading = "*Requester:*"; 88 | const recordURL = `${config.AIRTABLE_REQUESTS_VIEW_URL}/${record.id}`; 89 | const requesterName = record.get("Name"); 90 | const requesterNumber = record.get("Phone number"); 91 | const requesterAddress = record.get("Address"); 92 | 93 | const displayNameLink = `<${recordURL}|:heart: ${ 94 | requesterName || "No name provided" 95 | }>`; 96 | const displayNumber = `:phone: ${getDisplayNumber(requesterNumber)}`; 97 | const displayAddress = `:house: ${requesterAddress || "None provided"}`; 98 | const displayLanguage = `:speaking_head_in_silhouette: ${getLanguage( 99 | record 100 | )}`; 101 | 102 | const requesterInfo = [ 103 | heading, 104 | displayNameLink, 105 | displayNumber, 106 | displayAddress, 107 | displayLanguage, 108 | ]; 109 | 110 | const requesterSection = getSection(requesterInfo.join("\n")); 111 | 112 | return requesterSection; 113 | }; 114 | 115 | /** 116 | * Format a task to display in slack 117 | * 118 | * @param {object} record - the requested task to format 119 | * @returns {string} - the requested task formatted for slack 120 | */ 121 | const formatTasks = (record) => { 122 | const tasks = record.get("Tasks"); 123 | const otherTasks = record.get("Task - other"); 124 | 125 | if (!tasks && !otherTasks) return "None provided"; 126 | 127 | // Put each task on a new line 128 | if (tasks) { 129 | const formattedTasks = record.get("Tasks").reduce((taskList, task) => { 130 | if (task !== "Other") { 131 | const msg = `${taskList}\n:small_orange_diamond: ${task}`; 132 | 133 | return msg; 134 | } 135 | 136 | let msg = `${taskList}\n:warning: _"Other" request: `; 137 | msg += "volunteers might not be the best match_"; 138 | msg += `\n:small_orange_diamond: ${otherTasks}`; 139 | 140 | return msg; 141 | }, ""); 142 | 143 | return formattedTasks; 144 | } 145 | }; 146 | 147 | /** 148 | * Get tasks from records 149 | * 150 | * @param {object} record - the requested task to format tasks from 151 | * @returns {object} - Slack formatting object 152 | */ 153 | const getTasks = (record) => { 154 | const tasks = formatTasks(record); 155 | const tasksSection = getSection(`*Needs assistance with:* ${tasks}`); 156 | 157 | return tasksSection; 158 | }; 159 | 160 | /** 161 | * Get subsidy 162 | * 163 | * @param {object} record The record to process for subsidies. 164 | * @returns {object} The object with the subsidy request. 165 | */ 166 | const getSubsidyRequest = (record) => { 167 | const subsidy = record.get( 168 | "Please note, we are a volunteer-run organization, but may be able to help offset some of the cost of hard goods. Do you need a subsidy for your assistance?" 169 | ) 170 | ? ":white_check_mark:" 171 | : ":no_entry_sign:"; 172 | 173 | const subsidySection = getSection(`*Subsidy requested:* ${subsidy}`); 174 | 175 | return subsidySection; 176 | }; 177 | 178 | /** 179 | * Get timeframe of the request. 180 | * 181 | * @param {object} record The Airtable record to process. 182 | * @returns {object} The timeframe object. 183 | */ 184 | const getTimeframe = (record) => { 185 | const timeframe = record.get("Timeframe"); 186 | const timeframeSection = getSection( 187 | `*Requested timeframe:* ${timeframe || "None provided"}` 188 | ); 189 | 190 | return timeframeSection; 191 | }; 192 | 193 | /** 194 | * Truncate responses to 2000 characters. Hides the overflow in a collapsed field. 195 | * 196 | * @param {string} response The response to truncate. 197 | * @param {number} recordId The ID of the Airtable record. 198 | * @returns {string} The truncated response. 199 | */ 200 | const truncateLongResponses = (response, recordId) => { 201 | const charLimit = 2000; 202 | let truncatedResponse; 203 | 204 | if (response.length > 2000) { 205 | const recordURL = `${config.AIRTABLE_REQUESTS_VIEW_URL}/${recordId}`; 206 | 207 | truncatedResponse = response.substring(0, charLimit); 208 | truncatedResponse += `... <${recordURL}|See Airtable record for full response.>`; 209 | } 210 | 211 | return truncatedResponse || response; 212 | }; 213 | 214 | /** 215 | * Format other records. A catchall for items not covered in other functions. 216 | * 217 | * @param {object} record The Airtalbe record to process. 218 | * @returns {object} The requested record formatted for slack. 219 | */ 220 | const getAnythingElse = (record) => { 221 | const anythingElse = record.get("Anything else") || ""; 222 | const truncatedResponse = truncateLongResponses(anythingElse, record.id); 223 | 224 | const anythingElseSection = getSection( 225 | `*Other notes from requester:* \n${truncatedResponse || "None provided"}` 226 | ); 227 | 228 | return anythingElseSection; 229 | }; 230 | 231 | /** 232 | * Format volunteer heading for slack. 233 | * 234 | * @param {Array} volunteers The volunteers to format the heading for. 235 | * @returns {object} The formatted volunteer heading section object. 236 | */ 237 | const getVolunteerHeading = (volunteers) => { 238 | if (!volunteers || !volunteers.length) { 239 | // No volunteers found 240 | const noneFoundText = 241 | "*No volunteers match this request!*\n*Check the full Airtable record, there might be more info there.*"; 242 | 243 | return getSection(noneFoundText); 244 | } 245 | const volunteerHeading = `*Here are the ${volunteers.length} closest volunteers:*`; 246 | return getSection(volunteerHeading); 247 | }; 248 | 249 | /** 250 | * Format the volunteer's distance for display in Slack message. 251 | * 252 | * @param {number} distance Volunteer's distance from request. 253 | * @returns {string} Distance formatted for display in Slack message. 254 | */ 255 | const formatDistance = (distance) => { 256 | return typeof distance === "number" 257 | ? `${distance.toFixed(2)} Mi.` 258 | : "Distance N/A"; 259 | }; 260 | 261 | /** 262 | * Format volunteer stats for display in Slack message. 263 | * 264 | * @param {object} volunteer Volunteer's Airtable record. 265 | * @returns {object} Volunteer stats formatted for display in Slack message. 266 | */ 267 | const formatStats = (volunteer) => { 268 | let count; 269 | let lastDate; 270 | const { record } = volunteer; 271 | const requests = record.get("Requests count"); 272 | if (requests > 0) { 273 | count = `${requests} assigned`; 274 | lastDate = `(last ${getElapsedTime(record.get("Latest Request"))})`; 275 | } else { 276 | count = "0 assigned"; 277 | lastDate = ""; 278 | } 279 | 280 | return { count, lastDate }; 281 | }; 282 | 283 | /** 284 | * Format volunteer section for slack. 285 | * 286 | * @param {Array} volunteers The volunteers to format the heading for. 287 | * @returns {Array} A array of formatted volunteer section objects. 288 | */ 289 | const getVolunteers = (volunteers) => { 290 | if (!volunteers || !volunteers.length) { 291 | const noneFoundText = 292 | "*No volunteers match this request!*\n*Check the full Airtable record, there might be more info there.*"; 293 | 294 | return [getSection(noneFoundText)]; 295 | } 296 | 297 | const volunteerSections = volunteers.map((volunteer) => { 298 | const volunteerURL = `${config.AIRTABLE_VOLUNTEERS_VIEW_URL}/${volunteer.record.id}`; 299 | const volunteerLink = `<${volunteerURL}|${volunteer.Name}>`; 300 | const displayNumber = getDisplayNumber(volunteer.Number); 301 | const volunteerDistance = formatDistance(volunteer.Distance); 302 | const volunteerLanguage = volunteer.Language 303 | ? volunteer.Language 304 | : "English"; 305 | const displayStats = formatStats(volunteer); 306 | const hasCar = Array.from( 307 | volunteer.record.get( 308 | "Do you have a private mode of transportation with valid license/insurance? " 309 | ) || [] 310 | ).includes("Yes, I have a car") 311 | ? " :car:" 312 | : ""; 313 | 314 | const volunteerDetails = 315 | `:wave:${hasCar} ${volunteerLink}\n` + 316 | `:pushpin: ${displayNumber} - ${volunteerDistance}\n` + 317 | `:speaking_head_in_silhouette: ${volunteerLanguage}\n` + 318 | `:chart_with_upwards_trend: ${displayStats.count} ${displayStats.lastDate}\n`; 319 | 320 | const volunteerSection = getSection(volunteerDetails); 321 | 322 | return volunteerSection; 323 | }); 324 | 325 | return volunteerSections; 326 | }; 327 | 328 | /** 329 | * Format volunteer closing section for slack. 330 | * 331 | * @param {Array} volunteers The volunteers to format the heading for. 332 | * @returns {object} The formatted volunteer closing section object. 333 | */ 334 | const getVolunteerClosing = (volunteers) => { 335 | if (!volunteers || !volunteers.length) { 336 | const noneFoundText = 337 | "*No volunteers match this request!*\n*Check the full Airtable record, there might be more info there.*"; 338 | 339 | return getSection(noneFoundText); 340 | } 341 | 342 | const volunteerClosing = 343 | "_For easy copy/paste, see the reply to this message:_"; 344 | 345 | return getSection(volunteerClosing); 346 | }; 347 | 348 | /** 349 | * Format volunteer copy/paste phone numbers. 350 | * 351 | * @param {Array} volunteers The volunteers to format the heading for. 352 | * @returns {string} The formatted volunteer phone numbers. 353 | */ 354 | const getCopyPasteNumbers = (volunteers) => { 355 | if (!volunteers || !volunteers.length) return "No numbers to display"; 356 | 357 | const simplePhoneList = volunteers 358 | .map((volunteer) => getDisplayNumber(volunteer.Number)) 359 | .join("\n"); 360 | 361 | return simplePhoneList; 362 | }; 363 | 364 | module.exports = { 365 | getText, 366 | getHeading, 367 | getTaskOrder, 368 | getRequester, 369 | getTasks, 370 | getTimeframe, 371 | getSubsidyRequest, 372 | getAnythingElse, 373 | getVolunteerHeading, 374 | getVolunteers, 375 | getVolunteerClosing, 376 | getCopyPasteNumbers, 377 | getSection, 378 | }; 379 | -------------------------------------------------------------------------------- /test/slack/message/index.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-underscore-dangle */ 2 | /* Reason: rewire injects a .__get__ method that is necessary */ 3 | const rewire = require("rewire"); 4 | 5 | const message = rewire("../../../src/slack/message/"); 6 | 7 | class MockRequestRecord { 8 | constructor() { 9 | this.fields = { 10 | Timeframe: "Within 2 days", 11 | Name: "Jest test", 12 | "Phone number": "202-555-0106", 13 | Language: "English", 14 | City: "Astoria", 15 | Address: "25-82 36th Street", 16 | Status: "Needs assigning", 17 | Tasks: ["Dog walking"], 18 | "Task Order": "2 of 3", 19 | }; 20 | } 21 | 22 | get(field) { 23 | return this.fields[field]; 24 | } 25 | 26 | set(field, value) { 27 | this.fields[field] = value; 28 | return this.fields[field]; 29 | } 30 | } 31 | 32 | const mockVolunteers = [ 33 | { 34 | record: { id: 42, get: () => 0 }, 35 | Id: 24, 36 | Name: "Jan", 37 | Distance: 4.2, 38 | Number: "212-222-2222", 39 | Language: "Greek", 40 | }, 41 | { 42 | record: { id: 42, get: () => 0 }, 43 | Id: 25, 44 | Name: "Joe", 45 | Distance: 4.2, 46 | Number: "+1 212-222-2222", 47 | Language: "Greek", 48 | }, 49 | { 50 | record: { id: 42, get: () => 0 }, 51 | Id: 26, 52 | Name: "Mary", 53 | Distance: 4.2, 54 | Number: "(212) 222-2222", 55 | Language: "Spanish", 56 | }, 57 | { 58 | record: { id: 42, get: () => 0 }, 59 | Id: 27, 60 | Name: "Jill", 61 | Distance: 4.2, 62 | Number: "+1 (212) 222-2222", 63 | Language: "Spanish", 64 | }, 65 | { 66 | record: { id: 42, get: () => 0 }, 67 | Id: 28, 68 | Name: "Steven", 69 | Distance: 4.2, 70 | Number: "2122222222", 71 | Language: "Bengali", 72 | }, 73 | { 74 | record: { id: 42, get: () => 0 }, 75 | Id: 29, 76 | Name: "Nancy", 77 | Distance: 4.2, 78 | Number: "+12122222222", 79 | Language: "Urdu", 80 | }, 81 | { 82 | record: { id: 42, get: () => 0 }, 83 | Id: 30, 84 | Name: "Jane", 85 | Distance: 4.2, 86 | Number: "+121222222222", 87 | Language: "Italian", 88 | }, 89 | { 90 | record: { id: 42, get: () => 0 }, 91 | Id: 31, 92 | Name: "Anthony", 93 | Distance: 4.2, 94 | Number: "n/a", 95 | Language: "English", 96 | }, 97 | { 98 | record: { id: 42, get: () => 0 }, 99 | Id: 32, 100 | Name: "Jason", 101 | Distance: 4.2, 102 | Number: undefined, 103 | Language: "Cantonese", 104 | }, 105 | ]; 106 | 107 | const validateSection = (section) => { 108 | let result = true; 109 | 110 | if (!Object.prototype.hasOwnProperty.call(section, "type")) result = false; 111 | if (!Object.prototype.hasOwnProperty.call(section, "text")) result = false; 112 | if (!Object.prototype.hasOwnProperty.call(section.text, "type")) 113 | result = false; 114 | if (!Object.prototype.hasOwnProperty.call(section.text, "text")) 115 | result = false; 116 | 117 | if (section.type && !section.type === "section") result = false; 118 | if (section.text.type && !section.text.type === "mrkdwn") result = false; 119 | 120 | return result; 121 | }; 122 | 123 | test("Get a basic section", () => { 124 | const text = "Hello, World!"; 125 | 126 | const sectionObject = { 127 | type: "section", 128 | text: { 129 | type: "mrkdwn", 130 | text, 131 | }, 132 | }; 133 | 134 | expect(message.getSection(text)).toMatchObject(sectionObject); 135 | }); 136 | 137 | describe("The primary message", () => { 138 | test("The message heading should be a section", () => { 139 | const options = { reminder: false }; 140 | options.text = message.getText(options); 141 | const headingSection = message.getHeading(options); 142 | 143 | expect(validateSection(headingSection)).toBe(true); 144 | }); 145 | 146 | test("The message heading is different if the message is a reminder", () => { 147 | const options = { reminder: false }; 148 | options.text = message.getText(options); 149 | let headingSection = message.getHeading(options); 150 | 151 | expect(headingSection.text.text).toEqual( 152 | expect.stringContaining("A new errand has been added") 153 | ); 154 | 155 | options.reminder = true; 156 | options.text = message.getText(options); 157 | headingSection = message.getHeading(options); 158 | 159 | expect(headingSection.text.text).toEqual( 160 | expect.stringContaining("Reminder for a previous request") 161 | ); 162 | }); 163 | 164 | describe("Task order", () => { 165 | test("If task is split from a multi-task request, display task order", () => { 166 | const requester = new MockRequestRecord(); 167 | const taskOrderSection = message.getTaskOrder(requester); 168 | 169 | expect(taskOrderSection.text.text).toEqual( 170 | expect.stringContaining(requester.get("Task Order")) 171 | ); 172 | }); 173 | 174 | test("The task order info should be a section", () => { 175 | const requester = new MockRequestRecord(); 176 | const taskOrderSection = message.getTaskOrder(requester); 177 | 178 | expect(validateSection(taskOrderSection)).toBe(true); 179 | }); 180 | }); 181 | 182 | describe("Requester info", () => { 183 | test("If no requester name is specified, a human readable string is returned", () => { 184 | const requester = new MockRequestRecord(); 185 | requester.set("Name", undefined); 186 | 187 | const requesterSection = message.getRequester(requester); 188 | const expected = `:heart: No name provided`; 189 | expect(requesterSection.text.text).toEqual( 190 | expect.stringContaining(expected) 191 | ); 192 | }); 193 | 194 | test("If 1 requester language is specified, the language is returned as-is", () => { 195 | const requester = new MockRequestRecord(); 196 | 197 | const getLanguage = message.__get__("getLanguage"); 198 | 199 | expect(getLanguage(requester)).toBe("English"); 200 | }); 201 | 202 | test("If 2 or more requester languages are specified, a comma-separated list is returned", () => { 203 | const requester = new MockRequestRecord(); 204 | const getLanguage = message.__get__("getLanguage"); 205 | 206 | expect(getLanguage(requester)).toBe("English"); 207 | }); 208 | 209 | test("If no requester language is specified, a human readable string is returned", () => { 210 | const requester = new MockRequestRecord(); 211 | requester.set("Language", "English"); 212 | requester.set("Language - other", "Japanese"); 213 | 214 | const getLanguage = message.__get__("getLanguage"); 215 | 216 | expect(getLanguage(requester)).toBe("English, Japanese"); 217 | }); 218 | 219 | test("If no requester address is specified, a human readable string is returned", () => { 220 | const requester = new MockRequestRecord(); 221 | requester.set("Address", undefined); 222 | 223 | const requesterSection = message.getRequester(requester); 224 | const expected = `:house: None provided`; 225 | expect(requesterSection.text.text).toEqual( 226 | expect.stringContaining(expected) 227 | ); 228 | }); 229 | 230 | test("The requester info should be a section", () => { 231 | const requester = new MockRequestRecord(); 232 | const requesterSection = message.getRequester(requester); 233 | 234 | expect(validateSection(requesterSection)).toBe(true); 235 | }); 236 | }); 237 | 238 | describe("Task list", () => { 239 | // no data passed 240 | test("If no tasks are passed in, a human readable string is returned", () => { 241 | const requester = new MockRequestRecord(); 242 | requester.set("Tasks", undefined); 243 | requester.set("Task - other", undefined); 244 | 245 | const getFormattedTasks = message.__get__("formatTasks"); 246 | 247 | expect(getFormattedTasks(requester)).toBe("None provided"); 248 | }); 249 | 250 | // only regular tasks passed 251 | test("If standard tasks are passed in, a standard list is returned", () => { 252 | const requester = new MockRequestRecord(); 253 | const taskList = ["Dog walking", "Food Assistance"]; 254 | requester.set("Tasks", taskList); 255 | 256 | const getFormattedTasks = message.__get__("formatTasks"); 257 | const bullet = ":small_orange_diamond:"; 258 | 259 | expect(getFormattedTasks(requester)).toBe( 260 | `\n${bullet} ${taskList[0]}\n${bullet} ${taskList[1]}` 261 | ); 262 | }); 263 | 264 | // only other task passed 265 | test("If only an 'Other' task is passed in, the warning and task is returned", () => { 266 | const requester = new MockRequestRecord(); 267 | requester.set("Tasks", ["Other"]); 268 | requester.set("Task - other", "Moving house"); 269 | 270 | const getFormattedTasks = message.__get__("formatTasks"); 271 | const bullet = ":small_orange_diamond:"; 272 | const warning = 273 | ':warning: _"Other" request: volunteers might not be the best match_'; 274 | 275 | expect(getFormattedTasks(requester)).toBe( 276 | `\n${warning}\n${bullet} Moving house` 277 | ); 278 | }); 279 | 280 | // mix of regular and other task passed 281 | test("If an 'Other' task is passed along with regular tasks, the task list plus the warning is returned", () => { 282 | const requester = new MockRequestRecord(); 283 | const taskList = ["Food Assistance", "Other"]; 284 | requester.set("Tasks", taskList); 285 | requester.set("Task - other", "Moving house"); 286 | 287 | const getFormattedTasks = message.__get__("formatTasks"); 288 | const bullet = "\n:small_orange_diamond:"; 289 | const warning = 290 | '\n:warning: _"Other" request: volunteers might not be the best match_'; 291 | 292 | const formattedTasks = getFormattedTasks(requester); 293 | 294 | const bullet1 = `${bullet} ${taskList[0]}`; 295 | const bullet2 = `${bullet} Moving house`; 296 | 297 | expect(formattedTasks).toBe(`${bullet1}${warning}${bullet2}`); 298 | }); 299 | 300 | test("Tasks should be a section", () => { 301 | const requester = new MockRequestRecord(); 302 | const tasksSection = message.getTasks(requester); 303 | 304 | expect(validateSection(tasksSection)).toBe(true); 305 | }); 306 | }); 307 | 308 | describe("Timeframe section", () => { 309 | test("Requested timeframe should return as decorated string", () => { 310 | const requester = new MockRequestRecord(); 311 | 312 | expect(message.getTimeframe(requester).text.text).toBe( 313 | `*Requested timeframe:* Within 2 days` 314 | ); 315 | }); 316 | 317 | test("If no timeframe is requested, a human readable string is returned", () => { 318 | const requester = new MockRequestRecord(); 319 | requester.set("Timeframe", undefined); 320 | 321 | expect(message.getTimeframe(requester).text.text).toBe( 322 | "*Requested timeframe:* None provided" 323 | ); 324 | }); 325 | 326 | test("Timeframe should be a section", () => { 327 | const requester = new MockRequestRecord(); 328 | const timeframeSection = message.getTimeframe(requester); 329 | 330 | expect(validateSection(timeframeSection)).toBe(true); 331 | }); 332 | }); 333 | }); 334 | 335 | describe("The second request info message", () => { 336 | describe("The subsidy section", () => { 337 | test("Subsidy requests are represented by an emoji", () => { 338 | const requester = new MockRequestRecord(); 339 | const property = 340 | "Please note, we are a volunteer-run organization, but may be able to help offset some of the cost of hard goods. Do you need a subsidy for your assistance?"; 341 | requester.set(property, true); 342 | 343 | expect(message.getSubsidyRequest(requester).text.text).toBe( 344 | "*Subsidy requested:* :white_check_mark:" 345 | ); 346 | }); 347 | 348 | test("Absence of subsidy request is represented by an emoji", () => { 349 | const requester = new MockRequestRecord(); 350 | const property = 351 | "Please note, we are a volunteer-run organization, but may be able to help offset some of the cost of hard goods. Do you need a subsidy for your assistance?"; 352 | requester.set(property, undefined); 353 | 354 | expect(message.getSubsidyRequest(requester).text.text).toBe( 355 | "*Subsidy requested:* :no_entry_sign:" 356 | ); 357 | }); 358 | 359 | test("Subsidy request should be a section", () => { 360 | const requester = new MockRequestRecord(); 361 | const subsidySection = message.getSubsidyRequest(requester); 362 | 363 | expect(validateSection(subsidySection)).toBe(true); 364 | }); 365 | }); 366 | 367 | describe("The other notes/anything else section", () => { 368 | test("A long string should be truncated", () => { 369 | const response = "o".repeat(3000); 370 | const id = "fakeId"; 371 | 372 | const truncateLongResponses = message.__get__("truncateLongResponses"); 373 | const truncatedResponse = truncateLongResponses(response, id); 374 | 375 | expect(truncatedResponse).toEqual( 376 | expect.stringContaining("See Airtable record for full response.>") 377 | ); 378 | }); 379 | 380 | test("'Anything else' notes should return as decorated string", () => { 381 | const requester = new MockRequestRecord(); 382 | requester.set("Anything else", "Other errands"); 383 | 384 | expect(message.getAnythingElse(requester).text.text).toBe( 385 | `*Other notes from requester:* \nOther errands` 386 | ); 387 | }); 388 | 389 | test("If no 'Anything else' notes are provided, a human readable string is returned", () => { 390 | const requester = new MockRequestRecord(); 391 | requester.set("Anything else", undefined); 392 | 393 | expect(message.getAnythingElse(requester).text.text).toBe( 394 | "*Other notes from requester:* \nNone provided" 395 | ); 396 | }); 397 | 398 | test("'Anything else' notes should be a section", () => { 399 | const requester = new MockRequestRecord(); 400 | const anythingElseSection = message.getAnythingElse(requester); 401 | 402 | expect(validateSection(anythingElseSection)).toBe(true); 403 | }); 404 | }); 405 | }); 406 | 407 | describe("The volunteers message", () => { 408 | describe("The volunteers heading", () => { 409 | test("If N volunteers are passed, the heading displays the count", () => { 410 | const volunteerHeading = `*Here are the ${mockVolunteers.length} closest volunteers:*`; 411 | 412 | expect(message.getVolunteerHeading(mockVolunteers).text.text).toBe( 413 | volunteerHeading 414 | ); 415 | }); 416 | 417 | test("If no volunteers are passed, a human readable string is returned", () => { 418 | const volunteers = undefined; 419 | const noneFoundText = 420 | "*No volunteers match this request!*\n*Check the full Airtable record, there might be more info there.*"; 421 | 422 | expect(message.getVolunteerHeading(volunteers).text.text).toBe( 423 | noneFoundText 424 | ); 425 | }); 426 | 427 | test("Volunteer heading should be a section", () => { 428 | const volunteerHeadingSection = message.getVolunteerHeading( 429 | mockVolunteers 430 | ); 431 | 432 | expect(validateSection(volunteerHeadingSection)).toBe(true); 433 | }); 434 | }); 435 | 436 | describe("The volunteers list", () => { 437 | test("Volunteers list is an array", () => { 438 | const volunteerSections = message.getVolunteers(mockVolunteers); 439 | 440 | expect(Array.isArray(volunteerSections)).toBe(true); 441 | }); 442 | 443 | test("Volunteers list elements should all be sections", () => { 444 | const mockTaskCount = new Map([["24", 2]]); 445 | const volunteerSections = message.getVolunteers( 446 | mockVolunteers, 447 | mockTaskCount 448 | ); 449 | 450 | volunteerSections.map((section) => 451 | expect(validateSection(section)).toBe(true) 452 | ); 453 | }); 454 | 455 | test("Volunteers list elements should display volunteer language icon", () => { 456 | const mockTaskCount = new Map([["24", 2]]); 457 | 458 | const volunteerSections = message.getVolunteers( 459 | mockVolunteers, 460 | mockTaskCount 461 | ); 462 | 463 | volunteerSections.map((section) => 464 | expect(section.text.text).toEqual( 465 | expect.stringContaining(":speaking_head_in_silhouette:") 466 | ) 467 | ); 468 | }); 469 | 470 | test("Volunteers list elements should display volunteer language", () => { 471 | const mockTaskCount = new Map([["24", 2]]); 472 | 473 | const volunteerSections = message.getVolunteers( 474 | mockVolunteers, 475 | mockTaskCount 476 | ); 477 | 478 | volunteerSections.map((section) => 479 | expect(section.text.text).not.toEqual( 480 | expect.stringContaining(":speaking_head_in_silhouette: undefined") 481 | ) 482 | ); 483 | }); 484 | }); 485 | }); 486 | 487 | describe("The copy/paste numbers message", () => { 488 | test("Copy/paste numbers section should only contain expected values", () => { 489 | const expected = [ 490 | "212-222-2222", 491 | "212-222-2222", 492 | "212-222-2222", 493 | "212-222-2222", 494 | "212-222-2222", 495 | "212-222-2222", 496 | "212-222-2222", 497 | "n/a _[Bot note: unparseable number.]_", 498 | "None provided", 499 | ]; 500 | 501 | const copyPasteNumbers = message 502 | .getCopyPasteNumbers(mockVolunteers) 503 | .split("\n"); 504 | 505 | expect(copyPasteNumbers).toEqual(expect.arrayContaining(expected)); 506 | }); 507 | 508 | test("If no volunteers are available, a human readable string is returned", () => { 509 | const noneFoundText = "No numbers to display"; 510 | expect(message.getCopyPasteNumbers([])).toBe(noneFoundText); 511 | }); 512 | }); 513 | --------------------------------------------------------------------------------