├── .keep ├── .nvmrc ├── docs ├── .nojekyll ├── images │ ├── spoke_options.png │ ├── action-handler.png │ ├── action-handler-hint.png │ ├── select-action-handler.png │ └── twilio_number_buying_guide │ │ ├── sms_menu.png │ │ ├── numbers_menu.png │ │ ├── spoke_app_name.png │ │ ├── buy_number_button.png │ │ ├── messaging_services.png │ │ ├── programmable_sms_menu.png │ │ └── blurred_number_purchased.png ├── HOWTO_RUN_THE_SERVER_IN_A_DEBUGGER.md ├── index.html ├── HOWTO_MINIMALIST_DEPLOY.md ├── PROD_RUNNING_JOBS.md ├── EXPLANATION-labels.md ├── REFERENCE_ROLES_DESCRIPTION.md ├── README.md ├── HOWTO_USE_POSTGRESQL.md ├── EXPLANATION_DECIDING_ON_SPOKE.md └── HOWTO_INTEGRATE_SLACK_AUTH.md ├── .eslintignore ├── src ├── workers │ ├── job-handler.js │ ├── incoming-message-handler.js │ ├── message-sender-01.js │ ├── message-sender-56.js │ ├── message-sender-234.js │ ├── message-sender-789.js │ ├── parse_csv.js │ ├── process-message-csv.js │ └── lib.js ├── containers │ ├── -- SQLite.sql │ ├── context │ │ └── ThemeContext.js │ ├── hoc │ │ ├── withMuiTheme.jsx │ │ └── withSetTheme.jsx │ └── DashboardLoader.jsx ├── lib │ ├── is-client.js │ ├── attributes.js │ ├── gzip.js │ ├── tz-helpers.js │ ├── search-helpers.js │ ├── cookie.js │ ├── permissions.js │ ├── faqs.js │ ├── index.js │ ├── log.js │ └── phone-format.js ├── api │ ├── invite.js │ ├── opt-out.js │ ├── question-response.js │ ├── message.js │ ├── tag.js │ ├── interaction-step.js │ ├── question.js │ ├── conversations.js │ ├── canned-response.js │ ├── service.js │ ├── user.js │ ├── assignment.js │ └── campaign-contact.js ├── styles │ ├── media-queries.js │ └── mui-theme.js ├── server │ ├── api │ │ ├── invite.js │ │ ├── opt-out.js │ │ ├── question-response.js │ │ ├── canned-response.js │ │ ├── tag.js │ │ ├── mutations │ │ │ ├── getOptOutMessage.js │ │ │ ├── index.js │ │ │ ├── startCampaign.js │ │ │ ├── clearCachedOrgAndExtensionCaches.js │ │ │ └── updateContactCustomFields.js │ │ ├── message.js │ │ ├── phone.js │ │ └── interaction-step.js │ ├── wrap.js │ └── models │ │ ├── invite.js │ │ ├── tag-canned-response.js │ │ ├── log.js │ │ ├── user-cell.js │ │ ├── assignment.js │ │ ├── cacheable_queries │ │ ├── lib.js │ │ ├── index.js │ │ ├── assignment.js │ │ └── opt-out-message.js │ │ ├── canned-response.js │ │ ├── assignment-feedback.js │ │ ├── tag-campaign-contact.js │ │ ├── tag.js │ │ ├── opt-out.js │ │ ├── custom-types.js │ │ ├── owned_phone_number.js │ │ ├── question-response.js │ │ ├── zip-code.js │ │ ├── datawarehouse.js │ │ ├── user.js │ │ ├── campaign-admin.js │ │ ├── user-organization.js │ │ ├── organization.js │ │ ├── job-request.js │ │ ├── pending-message-part.js │ │ └── interaction-step.js ├── client │ ├── error-catcher.js │ └── index.jsx ├── heroku │ ├── print-base-url.js │ └── heroku-dispatch.js ├── extensions │ ├── job-runners │ │ ├── experimental-bull │ │ │ ├── queues.js │ │ │ ├── README.md │ │ │ ├── index.js │ │ │ └── processor.js │ │ ├── index.js │ │ ├── helpers.js │ │ ├── lambda-async │ │ │ └── index.js │ │ └── legacy.js │ ├── service-vendors │ │ ├── components.js │ │ └── message-sending.js │ ├── service-managers │ │ ├── components.js │ │ └── civicrm-donotsms │ │ │ └── index.js │ ├── secret-manager │ │ ├── default-encrypt │ │ │ └── index.js │ │ └── index.js │ ├── contact-loaders │ │ ├── components.js │ │ ├── civicrm │ │ │ └── getcachelength.js │ │ └── ngpvan │ │ │ └── util.js │ ├── message-handlers │ │ ├── index.js │ │ └── to-ascii │ │ │ └── index.js │ ├── dynamicassignment-batches │ │ ├── all-texters │ │ │ └── index.js │ │ └── index.js │ └── texter-sideboxes │ │ ├── per-campaign-bulk-send │ │ └── react-component.js │ │ └── hide-media │ │ └── react-component.js ├── components │ ├── forms │ │ ├── index.js │ │ ├── GSFormField.jsx │ │ ├── GSDateField.jsx │ │ ├── GSSubmitButton.jsx │ │ ├── GSSelectField.jsx │ │ └── GSAutoComplete.jsx │ ├── LoadingIndicator.jsx │ ├── DisplayLink.jsx │ ├── CampaignFormSectionHeading.jsx │ ├── OrganizationReassignLink.jsx │ ├── PasswordResetLink.jsx │ ├── Chart.jsx │ ├── PeopleList │ │ ├── SimpleRolesDropdown.jsx │ │ ├── UserEditDialog.jsx │ │ ├── RolesDropdown.jsx │ │ └── ResetPasswordDialog.jsx │ ├── OrganizationJoinLink.jsx │ ├── Slider.jsx │ ├── TagChips.jsx │ ├── utils.jsx │ ├── SendButton.jsx │ ├── TexterDashboard.jsx │ ├── SelectedCampaigns.jsx │ ├── Search.jsx │ ├── OrganizationWrapper.jsx │ ├── Chip.jsx │ ├── Empty.jsx │ ├── Downtime.jsx │ ├── IncomingMessageList │ │ └── TagList.jsx │ └── TopNav.jsx └── network │ └── errors.js ├── .prettierignore ├── .dockerignore ├── __mocks__ ├── styleMock.js └── fileMock.js ├── heroku.yml ├── __test__ ├── sum.js ├── sum.test.js ├── cypress │ ├── fixtures │ │ ├── three-contacts.csv │ │ ├── two-contacts.csv │ │ ├── two-contacts-broken.csv │ │ └── csv-headers-test.csv │ ├── support │ │ ├── e2e.js │ │ └── commands.js │ └── integration │ │ ├── phone-inventory.test.js │ │ ├── user-edit.test.js │ │ └── local-auth.test.js ├── lib │ ├── tz-helpers.test.js │ └── search-helpers.test.js ├── setup.js ├── extensions │ ├── action-handlers │ │ ├── test-action.test.js │ │ ├── actionkit-rsvp.test.js │ │ ├── revere-signup.test.js │ │ ├── mobilecommons-signup.test.js │ │ └── complex-test-action.test.js │ ├── secret-manager │ │ └── crypto.test.js │ └── service-vendors │ │ └── index.test.js ├── components │ ├── TopNav.test.js │ ├── TexterFrequentlyAskedQuestions.test.js │ ├── ScriptEditor.test.js │ ├── OrganizationJoinLink.test.js │ └── TagChips.test.js ├── server │ ├── render-index.test.js │ └── db │ │ ├── utils.js │ │ └── export.js ├── containers │ ├── Tags.test.js │ └── UserEdit.test.js ├── workers │ └── parse_csv.test.js └── TopNav.test.js ├── dev-tools ├── README.md ├── babel-run ├── db-startup.js ├── babel-run-with-env.js ├── babel-run-debug ├── jest.transform.js ├── Procfile.test ├── redis-cli ├── symmetric-decrypt.js ├── symmetric-encrypt.js ├── psql ├── create-test-database ├── Procfile.dev ├── Procfile-debug-server.dev ├── generate-contacts.js ├── .env.test └── export-query.js ├── deploy ├── lambda-scheduled-event.json └── lambda-migrate-database.js ├── nodemon.json ├── .husky └── pre-commit ├── jest.config.sqlite.js ├── migrations ├── 20190916145342_add_user_alias.js ├── 20200623101608_profile_fields.js ├── 20201001164842_message_media.js ├── 20200810202726_response-window.js ├── 20200422002041_add_messaging_sid_to_campaign.js ├── 20200328004744_add_answer_actions_data.js ├── 20200613153306_add_owned_phone_number_index.js ├── 20200505220742_campaign_dynamic_texteroptions.js ├── 20210208115400_add_canned_response_actions.js ├── 20221130154133_cascade_delete_interaction_step.js ├── 20200218220127_del_contact_xtraindexes.js ├── 20200826173603_optout_assignmentid_null.js ├── 20200701185642_addTagCannedResponseTable.js ├── helpers │ └── index.js ├── 20201010173732_add_assignment_feedback.js ├── 20240116233906_opt_out_message.js ├── 20200220094840_campaign_admin_table.js ├── 20191217125726_add_message_campaign_contact_id.js ├── 20240503180901_campaigncontactsupdatedat.js ├── 20200512143258_add_user_roles.js ├── 20191217130355_change_message_indexes.js └── 20191108164924_message_errors.js ├── .eslintrc ├── .gitattributes ├── knexfile.env.js ├── Procfile ├── .github ├── dependabot.yml └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── .babelrc ├── .gitignore ├── .prettierrc ├── cypress.config.js ├── docker-compose.yml ├── babel.config.js ├── pull_request_template.md ├── Dockerfile └── webpack └── server.js /.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20.11.1 2 | -------------------------------------------------------------------------------- /docs/.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | build 2 | -------------------------------------------------------------------------------- /src/workers/job-handler.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | build 3 | -------------------------------------------------------------------------------- /__mocks__/styleMock.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /__mocks__/fileMock.js: -------------------------------------------------------------------------------- 1 | module.exports = "test-file-stub"; 2 | -------------------------------------------------------------------------------- /heroku.yml: -------------------------------------------------------------------------------- 1 | build: 2 | docker: 3 | web: Dockerfile 4 | -------------------------------------------------------------------------------- /__test__/sum.js: -------------------------------------------------------------------------------- 1 | function sum(a, b) { 2 | return a + b; 3 | } 4 | module.exports = sum; 5 | -------------------------------------------------------------------------------- /src/containers/-- SQLite.sql: -------------------------------------------------------------------------------- 1 | -- SQLite 2 | select * from user; 3 | select * from campaign_admin -------------------------------------------------------------------------------- /src/lib/is-client.js: -------------------------------------------------------------------------------- 1 | export function isClient() { 2 | return typeof window !== "undefined"; 3 | } 4 | -------------------------------------------------------------------------------- /docs/images/spoke_options.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ProgressiveCoders/Spoke/HEAD/docs/images/spoke_options.png -------------------------------------------------------------------------------- /dev-tools/README.md: -------------------------------------------------------------------------------- 1 | This folder needs to retain Unix style linefeeds or `nf` wont be able to find the Procfile 2 | 3 | -------------------------------------------------------------------------------- /docs/images/action-handler.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ProgressiveCoders/Spoke/HEAD/docs/images/action-handler.png -------------------------------------------------------------------------------- /docs/images/action-handler-hint.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ProgressiveCoders/Spoke/HEAD/docs/images/action-handler-hint.png -------------------------------------------------------------------------------- /docs/images/select-action-handler.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ProgressiveCoders/Spoke/HEAD/docs/images/select-action-handler.png -------------------------------------------------------------------------------- /src/workers/incoming-message-handler.js: -------------------------------------------------------------------------------- 1 | import { handleIncomingMessages } from "./job-processes"; 2 | 3 | handleIncomingMessages(); 4 | -------------------------------------------------------------------------------- /__test__/sum.test.js: -------------------------------------------------------------------------------- 1 | const sum = require("./sum"); 2 | 3 | test("adds 1+2 to equal 3", () => { 4 | expect(sum(1, 2)).toBe(3); 5 | }); 6 | -------------------------------------------------------------------------------- /dev-tools/babel-run: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require('@babel/register') 3 | require('babel-polyfill') 4 | require('../' + process.argv[2]) 5 | -------------------------------------------------------------------------------- /src/api/invite.js: -------------------------------------------------------------------------------- 1 | export const schema = ` 2 | type Invite { 3 | id: ID 4 | isValid: Boolean 5 | hash: String 6 | } 7 | `; 8 | -------------------------------------------------------------------------------- /dev-tools/db-startup.js: -------------------------------------------------------------------------------- 1 | import "../src/server/models"; // This forces Thinky to autocreate the database, tables, and indexes if they don't exist 2 | -------------------------------------------------------------------------------- /src/containers/context/ThemeContext.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const context = React.createContext(); 4 | 5 | export default context; 6 | -------------------------------------------------------------------------------- /__test__/cypress/fixtures/three-contacts.csv: -------------------------------------------------------------------------------- 1 | firstName,lastName,cell 2 | Hannah,Clarke,7208293847 3 | James,Potter,9709204873 4 | Hermione,Granger,8143853820 -------------------------------------------------------------------------------- /deploy/lambda-scheduled-event.json: -------------------------------------------------------------------------------- 1 | { 2 | "command": "dispatchProcesses", 3 | "maxCount": "2", 4 | "description": "see docs/DEPLOYING_AWS_LAMBDA.md" 5 | } 6 | -------------------------------------------------------------------------------- /docs/images/twilio_number_buying_guide/sms_menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ProgressiveCoders/Spoke/HEAD/docs/images/twilio_number_buying_guide/sms_menu.png -------------------------------------------------------------------------------- /src/workers/message-sender-01.js: -------------------------------------------------------------------------------- 1 | import { messageSender01 } from "./job-processes"; 2 | 3 | messageSender01().catch(err => { 4 | console.log(err); 5 | }); 6 | -------------------------------------------------------------------------------- /src/workers/message-sender-56.js: -------------------------------------------------------------------------------- 1 | import { messageSender56 } from "./job-processes"; 2 | 3 | messageSender56().catch(err => { 4 | console.log(err); 5 | }); 6 | -------------------------------------------------------------------------------- /src/workers/message-sender-234.js: -------------------------------------------------------------------------------- 1 | import { messageSender234 } from "./job-processes"; 2 | 3 | messageSender234().catch(err => { 4 | console.log(err); 5 | }); 6 | -------------------------------------------------------------------------------- /src/workers/message-sender-789.js: -------------------------------------------------------------------------------- 1 | import { messageSender789 } from "./job-processes"; 2 | 3 | messageSender789().catch(err => { 4 | console.log(err); 5 | }); 6 | -------------------------------------------------------------------------------- /docs/images/twilio_number_buying_guide/numbers_menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ProgressiveCoders/Spoke/HEAD/docs/images/twilio_number_buying_guide/numbers_menu.png -------------------------------------------------------------------------------- /docs/images/twilio_number_buying_guide/spoke_app_name.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ProgressiveCoders/Spoke/HEAD/docs/images/twilio_number_buying_guide/spoke_app_name.png -------------------------------------------------------------------------------- /src/api/opt-out.js: -------------------------------------------------------------------------------- 1 | export const schema = ` 2 | type OptOut { 3 | id: ID 4 | cell: String 5 | assignment: Assignment 6 | createdAt: Date 7 | } 8 | `; 9 | -------------------------------------------------------------------------------- /src/api/question-response.js: -------------------------------------------------------------------------------- 1 | export const schema = ` 2 | type QuestionResponse { 3 | id: String 4 | value: String 5 | question: Question 6 | } 7 | `; 8 | -------------------------------------------------------------------------------- /__test__/cypress/fixtures/two-contacts.csv: -------------------------------------------------------------------------------- 1 | firstName,last_name,cell,favorite_color 2 | ContactFirst1,ContactLast1,12025550175,green 3 | ContactFirst2,ContactLast2,12025550176,orange -------------------------------------------------------------------------------- /docs/images/twilio_number_buying_guide/buy_number_button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ProgressiveCoders/Spoke/HEAD/docs/images/twilio_number_buying_guide/buy_number_button.png -------------------------------------------------------------------------------- /docs/images/twilio_number_buying_guide/messaging_services.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ProgressiveCoders/Spoke/HEAD/docs/images/twilio_number_buying_guide/messaging_services.png -------------------------------------------------------------------------------- /dev-tools/babel-run-with-env.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require("dotenv").config(); 3 | require("@babel/register"); 4 | require("babel-polyfill"); 5 | require("../" + process.argv[2]); 6 | -------------------------------------------------------------------------------- /docs/images/twilio_number_buying_guide/programmable_sms_menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ProgressiveCoders/Spoke/HEAD/docs/images/twilio_number_buying_guide/programmable_sms_menu.png -------------------------------------------------------------------------------- /docs/images/twilio_number_buying_guide/blurred_number_purchased.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ProgressiveCoders/Spoke/HEAD/docs/images/twilio_number_buying_guide/blurred_number_purchased.png -------------------------------------------------------------------------------- /__test__/cypress/fixtures/two-contacts-broken.csv: -------------------------------------------------------------------------------- 1 | funnyName,lastName,cell,zipWrongPostal,custom_foo,custom_xxx 2 | Dolores,Huerta,2095550100,95201,bar,yyy 3 | Jill,Wonka,9384274839,72845,foo,xxx 4 | 5 | -------------------------------------------------------------------------------- /dev-tools/babel-run-debug: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | ":" //# comment; exec /usr/bin/env node --inspect "$0" "$@" 3 | require('@babel/register') 4 | require('babel-polyfill') 5 | require('../' + process.argv[2]) 6 | -------------------------------------------------------------------------------- /src/styles/media-queries.js: -------------------------------------------------------------------------------- 1 | export const onMobile = "@media (max-width: 480px)"; 2 | export const onTablet = "@media (max-width: 992px)"; 3 | export const onDesktop = "@media (min-width: 992px)"; 4 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "verbose": true, 3 | "ignore": [ 4 | "src/client", 5 | "src/components/", 6 | "src/containers/", 7 | "src/store", 8 | "src/styles", 9 | "src/routes.jsx" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | PATH="/usr/local/bin:$PATH" 3 | if [ -f $HOME/.nvm/nvm.sh ] 4 | then 5 | . $HOME/.nvm/nvm.sh 6 | PATH="$HOME/.nvm/versions/node/v16.18.0/bin:$PATH" 7 | fi 8 | 9 | yarn lint-staged 10 | -------------------------------------------------------------------------------- /src/server/api/invite.js: -------------------------------------------------------------------------------- 1 | import { mapFieldsToModel } from "./lib/utils"; 2 | import { Invite } from "../models"; 3 | 4 | export const resolvers = { 5 | Invite: { 6 | ...mapFieldsToModel(["id", "isValid", "hash"], Invite) 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /deploy/lambda-migrate-database.js: -------------------------------------------------------------------------------- 1 | { 2 | "command": "runDatabaseMigrations", 3 | "env": { 4 | "RETHINK_KNEX_FORCE_INDEXCREATION": "1", 5 | "RETHINK_KNEX_DEBUG": "1" 6 | }, 7 | "description": "see docs/DEPLOYING_AWS_LAMBDA.md" 8 | } 9 | -------------------------------------------------------------------------------- /src/client/error-catcher.js: -------------------------------------------------------------------------------- 1 | import { log } from "../lib"; 2 | 3 | export default error => { 4 | if (!error) { 5 | log.error("Uncaught exception with null error object"); 6 | return; 7 | } 8 | 9 | log.error(error); 10 | }; 11 | -------------------------------------------------------------------------------- /dev-tools/jest.transform.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | var config = require("dotenv").config(); 3 | require("@babel/register"); 4 | require("babel-polyfill"); 5 | require("../" + process.argv[2]); 6 | model.exports = require("babel-jest").createTransformer(config); 7 | -------------------------------------------------------------------------------- /jest.config.sqlite.js: -------------------------------------------------------------------------------- 1 | module.exports = require("./jest.config"); 2 | module.exports.globals.DB_JSON = JSON.stringify({ 3 | client: "sqlite3", 4 | connection: { filename: "./test.sqlite" }, 5 | defaultsUnsupported: true, 6 | useNullAsDefault: true 7 | }); 8 | -------------------------------------------------------------------------------- /__test__/cypress/fixtures/csv-headers-test.csv: -------------------------------------------------------------------------------- 1 | first,last_name,company_name,address,city,county,state,ZipOrPost,Cell_Phone,phone,email 2 | James,Baggins,"Benton, John B Jr",6649 N Blue Gum St,New Orleans,Orleans,LA,70116,504-621-8927,504-845-1427,jbaggs@gmail.com 3 | 4 | -------------------------------------------------------------------------------- /dev-tools/Procfile.test: -------------------------------------------------------------------------------- 1 | # Starts a Spoke server against which integration tests will run 2 | # Enables babel and webpack hot reloading so you can do TDD :) 3 | # Usage: yarn start:test 4 | server: npm run start:test-babel 5 | webpack: ./dev-tools/babel-run ./webpack/server.js -------------------------------------------------------------------------------- /__test__/cypress/support/e2e.js: -------------------------------------------------------------------------------- 1 | // support/index.js is processed and loaded automatically before your test files. 2 | // this runs in the browser is a good place to define common cypress operations 3 | import "./commands"; 4 | 5 | beforeEach(() => { 6 | cy.task("resetDB"); 7 | }); 8 | -------------------------------------------------------------------------------- /__test__/lib/tz-helpers.test.js: -------------------------------------------------------------------------------- 1 | import { getProcessEnvDstReferenceTimezone } from "../../src/lib/tz-helpers"; 2 | 3 | describe("test getProcessEnvDstReferenceTimezone", () => { 4 | it("works", () => { 5 | expect(getProcessEnvDstReferenceTimezone()).toEqual("US/Eastern"); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /migrations/20190916145342_add_user_alias.js: -------------------------------------------------------------------------------- 1 | exports.up = knex => { 2 | return knex.schema.table("user", table => { 3 | table.string("alias").defaultTo(null); 4 | }); 5 | }; 6 | 7 | exports.down = knex => { 8 | return knex.schema.table("user", table => { 9 | table.dropColumn("alias"); 10 | }); 11 | }; 12 | -------------------------------------------------------------------------------- /migrations/20200623101608_profile_fields.js: -------------------------------------------------------------------------------- 1 | exports.up = knex => { 2 | return knex.schema.table("user", table => { 3 | table.jsonb("extra").defaultTo(null); 4 | }); 5 | }; 6 | 7 | exports.down = knex => { 8 | return knex.schema.table("user", table => { 9 | table.dropColumn("extra"); 10 | }); 11 | }; 12 | -------------------------------------------------------------------------------- /migrations/20201001164842_message_media.js: -------------------------------------------------------------------------------- 1 | exports.up = knex => { 2 | return knex.schema.table("message", table => { 3 | table.jsonb("media").defaultTo(null); 4 | }); 5 | }; 6 | 7 | exports.down = knex => { 8 | return knex.schema.table("message", table => { 9 | table.dropColumn("media"); 10 | }); 11 | }; 12 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["airbnb", "prettier"], 3 | "parser": "@babel/eslint-parser", 4 | "env": { "jest": true, "node": true, "browser": true, "jasmine": true }, 5 | "rules": { 6 | "no-console": [ 7 | "warn", 8 | { "allow": ["warn", "error", "info", "time", "timeEnd"] } 9 | ] 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set the default behavior, in case people don't have core.autocrlf set. 2 | * text=auto 3 | 4 | # devtools need to use lf linefeeds or nf will not be able to find the Procfile 5 | dev-tools/* text eol=lf 6 | 7 | # Denote all files that are truly binary and should not be modified. 8 | *.png binary 9 | *.jpg binary 10 | -------------------------------------------------------------------------------- /knexfile.env.js: -------------------------------------------------------------------------------- 1 | require("dotenv").config(); 2 | 3 | // environment variables will be populated from above, and influence the knex-connect import 4 | var config = require("./src/server/knex-connect"); 5 | 6 | module.exports = { 7 | development: config, 8 | test: config, 9 | staging: config, 10 | production: config 11 | }; 12 | -------------------------------------------------------------------------------- /src/server/wrap.js: -------------------------------------------------------------------------------- 1 | /* This is a function wrapper to correctly 2 | catch and handle uncaught exceptions in 3 | asynchronous code. */ 4 | import { log } from "../lib"; 5 | export default fn => (...args) => 6 | fn(...args).catch(ex => { 7 | log.error(ex); 8 | process.nextTick(() => { 9 | throw ex; 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: npm run start:heroku 2 | jobhandler: npm run prod-job-handler 3 | messagesender01: npm run prod-message-sender-01 4 | messagesender234: npm run prod-message-sender-234 5 | messagesender56: npm run prod-message-sender-56 6 | messagesender789: npm run prod-message-sender-789 7 | incomingmessagehandler: npm run prod-incoming-message-handler 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | target-branch: "stage-main-14.2" 6 | schedule: 7 | interval: "weekly" 8 | day: "monday" 9 | time: "12:00" 10 | rebase-strategy: auto 11 | open-pull-requests-limit: 10 12 | labels: 13 | - "A-dependencies" 14 | -------------------------------------------------------------------------------- /src/heroku/print-base-url.js: -------------------------------------------------------------------------------- 1 | if (process.env.BASE_URL) { 2 | console.log(process.env.BASE_URL); 3 | } else if (process.env.HEROKU_APP_NAME) { 4 | console.log(`https://${process.env.HEROKU_APP_NAME}.herokuapp.com`); 5 | } else { 6 | throw new Error( 7 | "Neither BASE_URL nor HEROKU_APP_NAME environment variables are present." 8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /src/server/api/opt-out.js: -------------------------------------------------------------------------------- 1 | import { mapFieldsToModel } from "./lib/utils"; 2 | import { OptOut } from "../models"; 3 | 4 | export const resolvers = { 5 | OptOut: { 6 | ...mapFieldsToModel(["id", "cell", "createdAt"], OptOut), 7 | assignment: async (optOut, _, { loaders }) => 8 | loaders.assignment.load(optOut.assignment_id) 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /migrations/20200810202726_response-window.js: -------------------------------------------------------------------------------- 1 | exports.up = function(knex) { 2 | return knex.schema.table("campaign", table => { 3 | table.float("response_window").defaultTo(null); 4 | }); 5 | }; 6 | 7 | exports.down = function(knex) { 8 | return knex.schema.table("campaign", table => { 9 | table.dropColumn("response_window"); 10 | }); 11 | }; 12 | -------------------------------------------------------------------------------- /src/api/message.js: -------------------------------------------------------------------------------- 1 | export const schema = ` 2 | type MediaItem { 3 | type: String 4 | url: String 5 | } 6 | 7 | type Message { 8 | id: ID 9 | text: String 10 | media: [MediaItem] 11 | userNumber: String 12 | contactNumber: String 13 | createdAt: Date 14 | isFromContact: Boolean 15 | userId: String 16 | } 17 | `; 18 | -------------------------------------------------------------------------------- /dev-tools/redis-cli: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | # Connect to the docker redis instance with the redis-cli command line tool 5 | # without installing it locally. 6 | # 7 | # Sample usage: 8 | # * To open a shell: ./dev-tools/redis-cli 9 | # * To clear the cache: ./dev-tools/redis-cli FLUSHDB 10 | 11 | docker compose exec redis redis-cli "$@" 12 | -------------------------------------------------------------------------------- /src/server/api/question-response.js: -------------------------------------------------------------------------------- 1 | import { mapFieldsToModel } from "./lib/utils"; 2 | import { QuestionResponse } from "../models"; 3 | 4 | export const resolvers = { 5 | QuestionResponse: { 6 | ...mapFieldsToModel(["id", "value"], QuestionResponse), 7 | question: async (question, _, { loaders }) => 8 | loaders.question.load(question.id) 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /src/api/tag.js: -------------------------------------------------------------------------------- 1 | import { gql } from "@apollo/client"; 2 | 3 | export const schema = gql` 4 | type Tag { 5 | id: ID 6 | name: String 7 | group: String 8 | description: String 9 | isDeleted: Boolean 10 | organizationId: String 11 | } 12 | 13 | type ContactTag { 14 | campaignContactId: String 15 | id: ID 16 | value: String 17 | } 18 | `; 19 | -------------------------------------------------------------------------------- /dev-tools/symmetric-decrypt.js: -------------------------------------------------------------------------------- 1 | // Run this script from the top level directory to decrypt a value: 2 | // 3 | // node ./dev-tools/symmetric-decrypt.js ValueToBeDecrypted 4 | 5 | require("dotenv").config(); 6 | const { symmetricDecrypt } = require("../src/server/api/lib/crypto"); 7 | 8 | const result = symmetricDecrypt(process.argv[2]); 9 | console.log(result); 10 | process.exit(0); 11 | -------------------------------------------------------------------------------- /dev-tools/symmetric-encrypt.js: -------------------------------------------------------------------------------- 1 | // Run this script from the top level directory to encrypt a value: 2 | // 3 | // node ./dev-tools/symmetric-encrypt.js ValueToBeEncrypted 4 | 5 | require("dotenv").config(); 6 | const { symmetricEncrypt } = require("../src/server/api/lib/crypto"); 7 | 8 | const result = symmetricEncrypt(process.argv[2]); 9 | console.log(result); 10 | process.exit(0); 11 | -------------------------------------------------------------------------------- /__test__/setup.js: -------------------------------------------------------------------------------- 1 | import { configure } from "enzyme"; 2 | import Adapter from "enzyme-adapter-react-16"; 3 | 4 | import { flushRedis } from "./test_helpers"; 5 | 6 | configure({ adapter: new Adapter() }); 7 | 8 | // server/api/campaign.test.js has some long tests so we increase from 5sec default 9 | jest.setTimeout(15000); 10 | 11 | beforeEach(async () => { 12 | await flushRedis(); 13 | }); 14 | -------------------------------------------------------------------------------- /__test__/cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // See https://docs.cypress.io/api/cypress-api/custom-commands.html#Syntax 2 | import "cypress-file-upload"; 3 | import "cypress-wait-until"; 4 | 5 | Cypress.Commands.add("login", userData => { 6 | cy.request("POST", "/login-callback", { 7 | nextUrl: "/", 8 | authType: "login", 9 | password: userData.password, 10 | email: userData.email 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /__test__/extensions/action-handlers/test-action.test.js: -------------------------------------------------------------------------------- 1 | import { validateActionHandler } from "../../../src/extensions/action-handlers"; 2 | 3 | import * as HandlerToTest from "../../../src/extensions/action-handlers/test-action"; 4 | 5 | describe("test-action", () => { 6 | it("passes validation", async () => { 7 | expect(() => validateActionHandler(HandlerToTest)).not.toThrowError(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /__test__/extensions/action-handlers/actionkit-rsvp.test.js: -------------------------------------------------------------------------------- 1 | import { validateActionHandler } from "../../../src/extensions/action-handlers"; 2 | 3 | import * as HandlerToTest from "../../../src/extensions/action-handlers/actionkit-rsvp"; 4 | 5 | describe("test-action", () => { 6 | it("passes validation", async () => { 7 | expect(() => validateActionHandler(HandlerToTest)).not.toThrowError(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /__test__/extensions/action-handlers/revere-signup.test.js: -------------------------------------------------------------------------------- 1 | import { validateActionHandler } from "../../../src/extensions/action-handlers"; 2 | 3 | import * as HandlerToTest from "../../../src/extensions/action-handlers/revere-signup"; 4 | 5 | describe("test-action", () => { 6 | it("passes validation", async () => { 7 | expect(() => validateActionHandler(HandlerToTest)).not.toThrowError(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /dev-tools/psql: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Connect to the docker postgres database with the psql command line tool 4 | # without installing it locally 5 | # 6 | # Sample usage: 7 | # * To open a shell: ./dev-tools/psql 8 | # * Run a query: ./dev-tools/psql -c "SELECT COUNT(*) FROM campaign_contact" 9 | 10 | set -euo pipefail 11 | 12 | docker compose exec postgres psql -h localhost -p 5432 -U spoke spokedev "$@" 13 | -------------------------------------------------------------------------------- /src/api/interaction-step.js: -------------------------------------------------------------------------------- 1 | export const schema = ` 2 | type InteractionStep { 3 | id: ID! 4 | question: Question 5 | questionText: String 6 | script: String 7 | answerOption: String 8 | parentInteractionId: String 9 | isDeleted: Boolean 10 | answerActions: String 11 | answerActionsData: String 12 | questionResponse(campaignContactId: String): QuestionResponse 13 | } 14 | `; 15 | -------------------------------------------------------------------------------- /__test__/extensions/action-handlers/mobilecommons-signup.test.js: -------------------------------------------------------------------------------- 1 | import { validateActionHandler } from "../../../src/extensions/action-handlers"; 2 | 3 | import * as HandlerToTest from "../../../src/extensions/action-handlers/mobilecommons-signup"; 4 | 5 | describe("test-action", () => { 6 | it("passes validation", async () => { 7 | expect(() => validateActionHandler(HandlerToTest)).not.toThrowError(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/extensions/job-runners/experimental-bull/queues.js: -------------------------------------------------------------------------------- 1 | import Queue from "bull"; 2 | 3 | // Simple job and task queues. Note that bull gives us the option to 4 | // create separate queues for each job and task type, which would allow 5 | // finer control over concurrent processing, locking, and retries. 6 | export const jobQueue = new Queue("jobs", process.env.REDIS_URL); 7 | export const taskQueue = new Queue("tasks", process.env.REDIS_URL); 8 | -------------------------------------------------------------------------------- /src/api/question.js: -------------------------------------------------------------------------------- 1 | export const schema = ` 2 | type Question { 3 | text: String 4 | answerOptions: [AnswerOption] 5 | interactionStep: InteractionStep 6 | } 7 | 8 | type AnswerOption { 9 | interactionStepId: Int 10 | value: String 11 | action: String 12 | nextInteractionStep: InteractionStep 13 | responders: [CampaignContact] 14 | responderCount: Int 15 | question: Question 16 | } 17 | `; 18 | -------------------------------------------------------------------------------- /src/components/forms/index.js: -------------------------------------------------------------------------------- 1 | export GCDateField from "./GSDateField"; 2 | export GSAutoComplete from "./GSAutoComplete"; 3 | export GSForm from "./GSForm"; 4 | export GSFormField from "./GSFormField"; 5 | export GSPasswordField from "./GSPasswordField"; 6 | export GSScriptField from "./GSScriptField"; 7 | export GSSelectField from "./GSSelectField"; 8 | export GSSubmitButton from "./GSSubmitButton"; 9 | export GSTextField from "./GSTextField"; 10 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-react", "@babel/preset-env", ["@babel/preset-typescript", { "allExtensions": true, "isTSX": true }]], 3 | "only": ["./**/*.js", "./**/*.jsx"], 4 | "plugins": [ 5 | "@babel/plugin-proposal-export-default-from", 6 | ["@babel/plugin-transform-runtime", { 7 | "regenerator": true 8 | }] 9 | ], 10 | "env": { 11 | "dev": { 12 | "plugins":["react-hot-loader/babel"] 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /build 3 | npm-debug.log 4 | yarn-error.log 5 | .DS_Store 6 | rethinkdb_data 7 | .env 8 | __test__/test_data/* 9 | production-env.json 10 | claudia.json 11 | stage.json 12 | beta.json 13 | stage-env.json 14 | beta-env.json 15 | test.sqlite 16 | *~ 17 | *# 18 | coverage/ 19 | .idea/ 20 | *sqlite 21 | *.vscode 22 | *.rdb 23 | package-lock.json 24 | CONFIG_FILE.json 25 | scratch/ 26 | cypress/screenshots 27 | cypress/videos 28 | spoke-pm2.config.js 29 | -------------------------------------------------------------------------------- /src/heroku/heroku-dispatch.js: -------------------------------------------------------------------------------- 1 | import { dispatchProcesses } from "../workers/job-processes"; 2 | 3 | const runDispatch = async () => { 4 | const event = { 5 | maxCount: 2 // maxCount is 2 so erroredMessageSender runs once 6 | }; 7 | dispatchProcesses(event) 8 | .catch(err => { 9 | console.log(err); 10 | }) 11 | .then(results => { 12 | console.log("dispatch results", results); 13 | process.exit(0); 14 | }); 15 | }; 16 | runDispatch(); 17 | -------------------------------------------------------------------------------- /src/lib/attributes.js: -------------------------------------------------------------------------------- 1 | // Used to generate data-test attributes on non-production environments and used by integration tests 2 | export const dataTest = (value, disable) => { 3 | return !disable ? { "data-test": value } : {}; 4 | }; 5 | 6 | export const camelCase = str => { 7 | return str 8 | .replace(/(?:^\w|[A-Z]|\b\w)/g, (letter, index) => { 9 | return index == 0 ? letter.toLowerCase() : letter.toUpperCase(); 10 | }) 11 | .replace(/\s+/g, ""); 12 | }; 13 | -------------------------------------------------------------------------------- /dev-tools/create-test-database: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | docker compose exec -T postgres psql -h localhost -p 5432 -U spoke spokedev < ( 15 |
16 | 17 |
18 | ); 19 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "bracketSpacing": true, 4 | "htmlWhitespaceSensitivity": "css", 5 | "insertPragma": false, 6 | "jsxBracketSameLine": false, 7 | "jsxSingleQuote": false, 8 | "printWidth": 80, 9 | "proseWrap": "preserve", 10 | "quoteProps": "as-needed", 11 | "requirePragma": false, 12 | "semi": true, 13 | "singleQuote": false, 14 | "tabWidth": 2, 15 | "trailingComma": "none", 16 | "useTabs": false, 17 | "vueIndentScriptAndStyle": false 18 | } 19 | -------------------------------------------------------------------------------- /migrations/20200422002041_add_messaging_sid_to_campaign.js: -------------------------------------------------------------------------------- 1 | exports.up = function(knex) { 2 | return knex.schema.table("campaign", table => { 3 | table.string("messageservice_sid").defaultTo(null); 4 | table.boolean("use_own_messaging_service").defaultTo(false); 5 | }); 6 | }; 7 | 8 | exports.down = function(knex) { 9 | return knex.schema.table("campaign", table => { 10 | table.dropColumn("messageservice_sid"); 11 | table.dropColumn("use_own_messaging_service"); 12 | }); 13 | }; 14 | -------------------------------------------------------------------------------- /src/api/conversations.js: -------------------------------------------------------------------------------- 1 | export const schema = ` 2 | input ConversationFilter { 3 | assignmentsFilter: AssignmentsFilter 4 | campaignsFilter: CampaignsFilter 5 | contactsFilter: ContactsFilter 6 | messageTextFilter: String 7 | } 8 | 9 | type Conversation { 10 | texter: User! 11 | contact: CampaignContact! 12 | campaign: Campaign! 13 | } 14 | 15 | type PaginatedConversations { 16 | conversations: [Conversation]! 17 | pageInfo: PageInfo 18 | } 19 | `; 20 | -------------------------------------------------------------------------------- /src/extensions/service-vendors/components.js: -------------------------------------------------------------------------------- 1 | /* eslint no-console: 0 */ 2 | export const getServiceVendorComponent = serviceName => { 3 | try { 4 | // eslint-disable-next-line global-require 5 | const component = require(`./${serviceName}/react-component.js`); 6 | return component; 7 | } catch (caught) { 8 | console.log("caught", caught); 9 | console.error( 10 | `SERVICE_VENDOR failed to load react component for ${serviceName}` 11 | ); 12 | return null; 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /migrations/20200328004744_add_answer_actions_data.js: -------------------------------------------------------------------------------- 1 | // Add answer_actions_data column to interaction_step 2 | exports.up = function(knex) { 3 | return knex.schema.alterTable("interaction_step", table => { 4 | table.text("answer_actions_data").nullable(); 5 | }); 6 | }; 7 | 8 | // Drop answer_actions_data column from interaction_step 9 | exports.down = function(knex) { 10 | return knex.schema.alterTable("interaction_step", table => { 11 | table.dropColumn("answer_actions_data"); 12 | }); 13 | }; 14 | -------------------------------------------------------------------------------- /src/containers/hoc/withMuiTheme.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import ThemeContext from "../context/ThemeContext"; 4 | 5 | const withSetTheme = WrappedComponent => { 6 | class WithSetTheme extends React.Component { 7 | render() { 8 | const { muiTheme } = this.context; 9 | return ; 10 | } 11 | } 12 | WithSetTheme.contextType = ThemeContext; 13 | 14 | return WithSetTheme; 15 | }; 16 | 17 | export default withSetTheme; 18 | -------------------------------------------------------------------------------- /src/api/canned-response.js: -------------------------------------------------------------------------------- 1 | export const schema = ` 2 | input CannedResponseInput { 3 | id: String 4 | title: String 5 | text: String 6 | campaignId: String 7 | userId: String 8 | tagIds: [Int] 9 | answerActions: String 10 | answerActionsData: String 11 | } 12 | 13 | type CannedResponse { 14 | id: ID 15 | title: String 16 | text: String 17 | isUserCreated: Boolean 18 | tagIds: [ID] 19 | answerActions: String 20 | answerActionsData: String 21 | } 22 | `; 23 | -------------------------------------------------------------------------------- /dev-tools/Procfile-debug-server.dev: -------------------------------------------------------------------------------- 1 | server: npm run dev-start-debug 2 | webpack: ./dev-tools/babel-run ./webpack/server.js 3 | ## These are only useful if you don't have JOBS_SAME_PROCESS environment variable set 4 | #messagesender01: npm run dev-message-sender-01 5 | #messagesender234: npm run dev-message-sender-234 6 | #messagesender56: npm run dev-message-sender-56 7 | #messagesender789: npm run dev-message-sender-789 8 | #jobhandler: npm run dev-job-handler 9 | incomingmessagehandler: npm run dev-incoming-message-handler 10 | -------------------------------------------------------------------------------- /src/server/api/canned-response.js: -------------------------------------------------------------------------------- 1 | import { mapFieldsToModel } from "./lib/utils"; 2 | import { CannedResponse } from "../models"; 3 | 4 | export const resolvers = { 5 | CannedResponse: { 6 | ...mapFieldsToModel( 7 | ["id", "title", "text", "answerActions", "answerActionsData"], 8 | CannedResponse 9 | ), 10 | isUserCreated: cannedResponse => cannedResponse.user_id !== "", 11 | tagIds: cannedResponse => cannedResponse.tagIds || [] 12 | } 13 | }; 14 | 15 | CannedResponse.ensureIndex("campaign_id"); 16 | -------------------------------------------------------------------------------- /migrations/20200613153306_add_owned_phone_number_index.js: -------------------------------------------------------------------------------- 1 | exports.up = async knex => { 2 | await knex.schema.alterTable("owned_phone_number", table => { 3 | table.index( 4 | ["allocated_to", "allocated_to_id"], 5 | "allocation_type_and_id_idx" 6 | ); 7 | }); 8 | }; 9 | 10 | exports.down = async knex => { 11 | await knex.schema.alterTable("owned_phone_number", table => { 12 | table.dropIndex( 13 | ["allocated_to", "allocated_to_id"], 14 | "allocation_type_and_id_idx" 15 | ); 16 | }); 17 | }; 18 | -------------------------------------------------------------------------------- /src/lib/gzip.js: -------------------------------------------------------------------------------- 1 | import zlib from "zlib"; 2 | 3 | export const gzip = str => 4 | new Promise((resolve, reject) => { 5 | zlib.gzip(str, (err, res) => { 6 | if (err) { 7 | reject(err); 8 | } else { 9 | resolve(res); 10 | } 11 | }); 12 | }); 13 | 14 | export const gunzip = buf => 15 | new Promise((resolve, reject) => { 16 | zlib.gunzip(buf, (err, res) => { 17 | if (err) { 18 | reject(err); 19 | } else { 20 | resolve(res); 21 | } 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /migrations/20200505220742_campaign_dynamic_texteroptions.js: -------------------------------------------------------------------------------- 1 | exports.up = function(knex) { 2 | return knex.schema.table("campaign", table => { 3 | table.text("join_token").defaultTo(null); 4 | table.integer("batch_size").defaultTo(null); 5 | table.json("features").defaultTo(null); 6 | }); 7 | }; 8 | 9 | exports.down = function(knex) { 10 | return knex.schema.table("campaign", table => { 11 | table.dropColumn("join_token"); 12 | table.dropColumn("batch_size"); 13 | table.dropColumn("features"); 14 | }); 15 | }; 16 | -------------------------------------------------------------------------------- /src/extensions/service-managers/components.js: -------------------------------------------------------------------------------- 1 | /* eslint no-console: 0 */ 2 | export const getServiceManagerComponent = (serviceName, componentName) => { 3 | try { 4 | // eslint-disable-next-line global-require 5 | const component = require(`./${serviceName}/react-component.js`); 6 | return component && component[componentName]; 7 | } catch (caught) { 8 | console.log("caught", caught); 9 | console.error( 10 | `SERVICE_VENDOR failed to load react component for ${serviceName}` 11 | ); 12 | return null; 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /src/extensions/secret-manager/default-encrypt/index.js: -------------------------------------------------------------------------------- 1 | import { symmetricDecrypt, symmetricEncrypt } from "../crypto"; 2 | 3 | export async function getSecret(name, token, organization) { 4 | try { 5 | return symmetricDecrypt(token); 6 | } catch (e) { 7 | // Can't decrypt, return value as-is. 8 | return token; 9 | } 10 | } 11 | 12 | export async function convertSecret(name, organization, secretValue) { 13 | // returns token, which the caller is still responsible for saving somewhere 14 | return symmetricEncrypt(secretValue); 15 | } 16 | -------------------------------------------------------------------------------- /src/workers/parse_csv.js: -------------------------------------------------------------------------------- 1 | import util from "util"; 2 | import { parseCSV } from "../lib/parse_csv"; 3 | 4 | parseCSV[util.promisify.custom] = (file, options) => { 5 | return new Promise((resolve, reject) => { 6 | parseCSV( 7 | file, 8 | result => { 9 | if (result.error) { 10 | reject(result.error); 11 | } else { 12 | resolve(result); 13 | } 14 | }, 15 | options 16 | ); 17 | }); 18 | }; 19 | 20 | export const parseCSVAsync = util.promisify(parseCSV); 21 | export default parseCSVAsync; 22 | -------------------------------------------------------------------------------- /src/extensions/job-runners/index.js: -------------------------------------------------------------------------------- 1 | function getJobRunner() { 2 | const name = process.env.JOB_RUNNER || "legacy"; 3 | let runner; 4 | try { 5 | // eslint-disable-next-line global-require 6 | runner = require(`./${name}`); 7 | } catch (e) { 8 | throw new Error(`Job runner ${name} not found`); 9 | } 10 | console.log(`Successfully loaded ${name} job runner`); 11 | if (!runner.fullyConfigured()) { 12 | throw new Error(`Job runner ${name} is not fully configured`); 13 | } 14 | return runner; 15 | } 16 | 17 | export const jobRunner = getJobRunner(); 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for Spoke 4 | title: "Feature Request: " 5 | labels: C-enhancement 6 | assignees: "" 7 | --- 8 | 9 | **Problem** 10 | Is your feature request related to a problem? Please describe the problem with a clear and concise description. E.g. I'm always confused when[...] 11 | 12 | **Solution** 13 | Describe the solution with a clear and concise description of what you want to happen. 14 | 15 | **Context** 16 | Add any other context or screenshots about the feature request here. 17 | -------------------------------------------------------------------------------- /migrations/20210208115400_add_canned_response_actions.js: -------------------------------------------------------------------------------- 1 | // Add actions columns to canned_response 2 | exports.up = function(knex) { 3 | return knex.schema.alterTable("canned_response", table => { 4 | table.text("answer_actions").nullable(); 5 | table.text("answer_actions_data").nullable(); 6 | }); 7 | }; 8 | 9 | // Drop actions columns from canned_response 10 | exports.down = function(knex) { 11 | return knex.schema.alterTable("canned_response", table => { 12 | table.dropColumn("answer_actions"); 13 | table.dropColumn("answer_actions_data"); 14 | }); 15 | }; 16 | -------------------------------------------------------------------------------- /src/extensions/job-runners/helpers.js: -------------------------------------------------------------------------------- 1 | import { r } from "../../server/models"; 2 | 3 | export const saveJob = async (jobData, trx) => { 4 | const builder = trx || r.knex; 5 | 6 | let unsavedJob; 7 | if (typeof jobData.payload === "string") { 8 | unsavedJob = jobData; 9 | } else { 10 | unsavedJob = { ...jobData, payload: JSON.stringify(jobData.payload || {}) }; 11 | } 12 | 13 | const [res] = await builder("job_request").insert(unsavedJob, "id"); 14 | 15 | return builder("job_request") 16 | .select("*") 17 | .where("id", res.id) 18 | .first(); 19 | }; 20 | -------------------------------------------------------------------------------- /src/workers/process-message-csv.js: -------------------------------------------------------------------------------- 1 | import { loadMessages } from "./jobs"; 2 | import fs from "fs"; 3 | 4 | const csvFilename = process.argv.filter(f => /\.csv/.test(f))[0]; 5 | 6 | new Promise((resolve, reject) => { 7 | fs.readFile(csvFilename, "utf8", function(err, contents) { 8 | loadMessages(contents) 9 | .then(msgs => { 10 | resolve(msgs); 11 | process.exit(); 12 | }) 13 | .catch(err => { 14 | console.log(err); 15 | reject(err); 16 | console.log("Error", err); 17 | process.exit(); 18 | }); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /__test__/extensions/action-handlers/complex-test-action.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | validateActionHandler, 3 | validateActionHandlerWithClientChoices 4 | } from "../../../src/extensions/action-handlers"; 5 | 6 | import * as HandlerToTest from "../../../src/extensions/action-handlers/complex-test-action"; 7 | 8 | describe("test-action", () => { 9 | it("passes validation", async () => { 10 | expect(() => validateActionHandler(HandlerToTest)).not.toThrowError(); 11 | expect(() => 12 | validateActionHandlerWithClientChoices(HandlerToTest) 13 | ).not.toThrowError(); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /cypress.config.js: -------------------------------------------------------------------------------- 1 | require("@babel/register"); 2 | require("babel-polyfill"); 3 | const { defineConfig } = require('cypress') 4 | 5 | module.exports = defineConfig({ 6 | e2e: { 7 | baseUrl: "http://localhost:3001", 8 | specPattern: "__test__/cypress/integration/*", 9 | fixturesFolder: "__test__/cypress/fixtures", 10 | supportFile: "__test__/cypress/support/e2e.js", 11 | video: true, 12 | setupNodeEvents(on, config) { 13 | require("./__test__/cypress/plugins/tasks").defineTasks(on, config); 14 | // bind to the event we care about 15 | 16 | }, 17 | }, 18 | }) -------------------------------------------------------------------------------- /src/server/models/invite.js: -------------------------------------------------------------------------------- 1 | import thinky from "./thinky"; 2 | const type = thinky.type; 3 | import { timestamp } from "./custom-types"; 4 | 5 | const Invite = thinky.createModel( 6 | "invite", 7 | type 8 | .object() 9 | .schema({ 10 | id: type.string(), 11 | is_valid: type 12 | .boolean() 13 | .required() 14 | .allowNull(false), 15 | hash: type.string(), 16 | created_at: timestamp() 17 | }) 18 | .allowExtra(false), 19 | { noAutoCreation: true } 20 | ); 21 | 22 | Invite.ensureIndex("is_valid"); 23 | 24 | export default Invite; 25 | -------------------------------------------------------------------------------- /src/api/service.js: -------------------------------------------------------------------------------- 1 | import { gql } from "@apollo/client"; 2 | 3 | export const schema = gql` 4 | type ServiceVendor { 5 | id: String! 6 | name: String! 7 | config: JSON 8 | supportsOrgConfig: Boolean! 9 | } 10 | 11 | type ServiceManager { 12 | id: String! 13 | name: String! 14 | displayName: String! 15 | data: JSON 16 | supportsOrgConfig: Boolean! 17 | supportsCampaignConfig: Boolean! 18 | fullyConfigured: Boolean 19 | startPolling: Boolean 20 | unArchiveable: Boolean 21 | organization: Organization 22 | campaign: Campaign 23 | } 24 | `; 25 | -------------------------------------------------------------------------------- /migrations/20221130154133_cascade_delete_interaction_step.js: -------------------------------------------------------------------------------- 1 | exports.up = function(knex) { 2 | return knex.schema.alterTable("interaction_step", (table) => { 3 | table.dropForeign("parent_interaction_id"); 4 | table.foreign("parent_interaction_id").references("interaction_step.id").onDelete("CASCADE"); 5 | }); 6 | }; 7 | 8 | exports.down = function(knex) { 9 | return knex.schema.alterTable("interaction_step", (table) => { 10 | table.dropForeign("parent_interaction_id"); 11 | table.foreign("parent_interaction_id").references("interaction_step.id").onDelete("NO ACTION"); 12 | }); 13 | }; 14 | -------------------------------------------------------------------------------- /src/containers/hoc/withSetTheme.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import ThemeContext from "../context/ThemeContext"; 4 | 5 | const withSetTheme = WrappedComponent => { 6 | class WithSetTheme extends React.Component { 7 | render() { 8 | const { muiTheme, setTheme } = this.context; 9 | return ( 10 | 15 | ); 16 | } 17 | } 18 | WithSetTheme.contextType = ThemeContext; 19 | 20 | return WithSetTheme; 21 | }; 22 | 23 | export default withSetTheme; 24 | -------------------------------------------------------------------------------- /src/lib/tz-helpers.js: -------------------------------------------------------------------------------- 1 | import { isClient } from "./is-client"; 2 | 3 | export function getProcessEnvTz(defaultTimezone) { 4 | // TZ is a reserved env var in Lambda and always returns :UTC 5 | return ( 6 | defaultTimezone || 7 | process.env.DEFAULT_TZ || 8 | (process.env.TZ === ":UTC" ? undefined : process.env.TZ) 9 | ); 10 | } 11 | 12 | export function getProcessEnvDstReferenceTimezone() { 13 | if (isClient()) { 14 | return window.DST_REFERENCE_TIMEZONE; 15 | } 16 | 17 | return ( 18 | process.env.DST_REFERENCE_TIMEZONE || 19 | global.DST_REFERENCE_TIMEZONE || 20 | "US/Eastern" 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/styles/mui-theme.js: -------------------------------------------------------------------------------- 1 | import { createTheme } from "@material-ui/core/styles"; 2 | 3 | /** 4 | * This is the default theme to be used in the app 5 | * when there is no organization theme to show or use 6 | */ 7 | const defaultTheme = { 8 | palette: { 9 | type: "light", 10 | primary: { 11 | main: "#209556" 12 | }, 13 | secondary: { 14 | main: "#555555" 15 | }, 16 | warning: { 17 | main: "#fabe28" 18 | }, 19 | info: { 20 | main: "#3f80b2" 21 | } 22 | } 23 | }; 24 | 25 | let theme = createTheme(defaultTheme); 26 | 27 | export { defaultTheme }; 28 | 29 | export default theme; 30 | -------------------------------------------------------------------------------- /src/extensions/job-runners/experimental-bull/README.md: -------------------------------------------------------------------------------- 1 | # EXPERIMENTAL: bull job runner 2 | 3 | [Bull](https://github.com/OptimalBits/bull) is a mature, feature-rich job 4 | queueing library for nodejs. 5 | 6 | This job runner is experimental and should not be used in production yet 7 | 8 | ## Running locally 9 | 10 | From the Spoke root directory: 11 | 12 | 1. Install bull: `yarn add bull` 13 | 2. Run redis locally 14 | 3. Add `JOB_RUNNER=experimental-bull` and `REDIS_URL` to your .env 15 | 4. Run the worker process: `dev-tools/babel-run-with-env.js src/extensions/job-runners/experimental-bull/processor.js` 16 | 5. Run the application: `yarn run dev` -------------------------------------------------------------------------------- /src/lib/search-helpers.js: -------------------------------------------------------------------------------- 1 | // pathsToSearch can search nested fields with ["field.subfield"]. 2 | export function searchFor(query, objectsToSearch, pathsToSearch) { 3 | return objectsToSearch.filter(o => { 4 | for (let j = 0; j < pathsToSearch.length; j++) { 5 | const keys = pathsToSearch[j].split("."); 6 | let value = o; 7 | while (keys.length > 0) { 8 | value = value[keys.shift()]; 9 | } 10 | 11 | if ( 12 | value && 13 | value.toLowerCase().indexOf(query.trim().toLowerCase()) !== -1 14 | ) { 15 | return true; 16 | } 17 | } 18 | return false; 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /migrations/20200218220127_del_contact_xtraindexes.js: -------------------------------------------------------------------------------- 1 | exports.up = function up(knex) { 2 | const isSqlite = /sqlite/.test(knex.client.config.client); 3 | return knex.schema.alterTable("campaign_contact", table => { 4 | if (!isSqlite) { 5 | table.dropIndex("assignment_id"); 6 | table.dropIndex("campaign_id"); 7 | } 8 | }); 9 | }; 10 | 11 | exports.down = function down(knex) { 12 | const isSqlite = /sqlite/.test(knex.client.config.client); 13 | return knex.schema.alterTable("campaign_contact", table => { 14 | if (!isSqlite) { 15 | table.index("assignment_id"); 16 | table.index("campaign_id"); 17 | } 18 | }); 19 | }; 20 | -------------------------------------------------------------------------------- /src/server/models/tag-canned-response.js: -------------------------------------------------------------------------------- 1 | import thinky from "./thinky"; 2 | import { requiredString, timestamp } from "./custom-types"; 3 | const type = thinky.type; 4 | import CannedResponse from "./canned-response"; 5 | import Tag from "./tag"; 6 | 7 | const TagCannedResponse = thinky.createModel( 8 | "tag_canned_response", 9 | type 10 | .object() 11 | .schema({ 12 | id: type.string(), 13 | tag_id: requiredString(), 14 | canned_response_id: requiredString(), 15 | created_at: timestamp() 16 | }) 17 | .allowExtra(false), 18 | { 19 | noAutoCreation: true, 20 | dependencies: [Tag, CannedResponse] 21 | } 22 | ); 23 | -------------------------------------------------------------------------------- /docs/HOWTO_RUN_THE_SERVER_IN_A_DEBUGGER.md: -------------------------------------------------------------------------------- 1 | # How to run the server in a debugger 2 | 3 | This doc explains how to start Spoke's node app server so that you can debug it in a v8-inspector compliant debugging tool, such as [Chrome DevTools](https://medium.com/@paul_irish/debugging-node-js-nightlies-with-chrome-devtools-7c4a1b95ae27) or [Jetbrains WebStorm](https://www.jetbrains.com/help/webstorm/running-and-debugging-node-js.html) 4 | 5 | 1. Run `npm run debug-server`. This will start Spoke ready to accept debugging connections on the standard port (9229). 6 | 7 | 2. Attach the tool of your choice, set breakpoints (or whatever else you may want to do), and be productive! 8 | 9 | -------------------------------------------------------------------------------- /dev-tools/generate-contacts.js: -------------------------------------------------------------------------------- 1 | import faker from "faker"; 2 | import json2csv from "json2csv"; 3 | 4 | const fields = ["firstName", "lastName", "cell", "companyName", "city", "zip"]; 5 | const numContacts = 10000; 6 | 7 | const data = []; 8 | for (let index = 0; index < numContacts; index++) { 9 | data.push({ 10 | firstName: faker.name.firstName(), 11 | lastName: faker.name.lastName(), 12 | cell: `1-${faker.phone.phoneNumberFormat()}`, 13 | companyName: faker.company.companyName(), 14 | city: faker.address.city(), 15 | zip: faker.address.zipCode() 16 | }); 17 | } 18 | 19 | const csvFile = json2csv({ data, fields }); 20 | console.log(csvFile); 21 | -------------------------------------------------------------------------------- /src/components/DisplayLink.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | import React from "react"; 3 | import TextField from "@material-ui/core/TextField"; 4 | import { dataTest } from "../lib/attributes"; 5 | 6 | const DisplayLink = ({ url, textContent }) => ( 7 |
8 |
{textContent}
9 | event.target.select()} 15 | fullWidth 16 | /> 17 |
18 | ); 19 | 20 | DisplayLink.propTypes = { 21 | url: PropTypes.string, 22 | textContent: PropTypes.string 23 | }; 24 | 25 | export default DisplayLink; 26 | -------------------------------------------------------------------------------- /src/server/models/log.js: -------------------------------------------------------------------------------- 1 | import thinky from "./thinky"; 2 | import { requiredString, timestamp } from "./custom-types"; 3 | 4 | const type = thinky.type; 5 | 6 | const Log = thinky.createModel( 7 | "log", 8 | type 9 | .object() 10 | .schema({ 11 | id: type.string(), 12 | message_sid: requiredString(), 13 | body: type.string(), 14 | from_num: type.string(), 15 | to_num: type.string(), 16 | error_code: type 17 | .integer() 18 | .allowNull() 19 | .default(null), 20 | created_at: timestamp() 21 | }) 22 | .allowExtra(false), 23 | { noAutoCreation: true } 24 | ); 25 | 26 | export default Log; 27 | -------------------------------------------------------------------------------- /migrations/20200826173603_optout_assignmentid_null.js: -------------------------------------------------------------------------------- 1 | exports.up = async knex => { 2 | const isSqlite = /sqlite/.test(knex.client.config.client); 3 | if (!isSqlite) { 4 | await knex.schema.alterTable("opt_out", table => { 5 | table 6 | .integer("assignment_id") 7 | .alter() 8 | .nullable(); 9 | }); 10 | } 11 | }; 12 | 13 | exports.down = async knex => { 14 | const isSqlite = /sqlite/.test(knex.client.config.client); 15 | if (!isSqlite) { 16 | await knex.schema.alterTable("opt_out", table => { 17 | table 18 | .integer("assignment_id") 19 | .alter() 20 | .notNullable(); 21 | }); 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /__test__/components/TopNav.test.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { shallow } from "enzyme"; 3 | import { StyleSheetTestUtils } from "aphrodite"; 4 | import { TopNavBase as TopNav } from "../../src/components/TopNav"; 5 | import UserMenu from "../../src/containers/UserMenu"; 6 | 7 | describe("TopNav component", () => { 8 | StyleSheetTestUtils.suppressStyleInjection(); 9 | 10 | const orgId = "1"; 11 | const title = "foo"; 12 | const wrapper = shallow(); 13 | 14 | test("Renders UserMenu with orgId", () => { 15 | const menu = wrapper.find(UserMenu); 16 | 17 | // then 18 | expect(menu.prop("orgId")).toBe("1"); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | postgres: 4 | image: postgres:alpine 5 | command: postgres -c 'max_connections=250' 6 | restart: always 7 | environment: 8 | POSTGRES_DB: ${DB_NAME:-spokedev} 9 | POSTGRES_PASSWORD: ${DB_PASSWORD:-spoke} 10 | POSTGRES_USER: ${DB_USER:-spoke} 11 | volumes: 12 | - postgres:/var/lib/postgresql/data 13 | ports: 14 | - ${DB_PORT:-5432}:5432 15 | redis: 16 | image: redis:alpine 17 | restart: always 18 | volumes: 19 | - redis:/data 20 | ports: 21 | - ${REDIS_PORT:-6379}:6379 22 | volumes: 23 | postgres: 24 | external: false 25 | redis: 26 | external: false 27 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | // The .babelrc config file takes priority for app compilation. 2 | // For running Jest tests, this file takes precedence over the settings in .babelrc 3 | 4 | module.exports = { 5 | presets: [ 6 | "@babel/preset-react", 7 | "@babel/preset-env", 8 | ["@babel/preset-typescript", { allExtensions: true, isTSX: true }] 9 | ], 10 | only: ["./**/*.js", "./**/*.jsx"], 11 | plugins: [ 12 | "@babel/plugin-proposal-export-default-from", 13 | [ 14 | "@babel/plugin-transform-runtime", 15 | { 16 | regenerator: true 17 | } 18 | ] 19 | ], 20 | env: { 21 | dev: { 22 | plugins: ["react-hot-loader/babel"] 23 | } 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /migrations/20200701185642_addTagCannedResponseTable.js: -------------------------------------------------------------------------------- 1 | exports.up = async knex => { 2 | await knex.schema.createTable("tag_canned_response", table => { 3 | table.increments(); 4 | table 5 | .integer("canned_response_id") 6 | .notNullable() 7 | .references("id") 8 | .inTable("canned_response") 9 | .index(); 10 | table 11 | .integer("tag_id") 12 | .notNullable() 13 | .references("id") 14 | .inTable("tag"); 15 | 16 | table.timestamp("created_at").defaultTo(knex.fn.now()); 17 | table.unique(["canned_response_id", "tag_id"]); 18 | }); 19 | }; 20 | 21 | exports.down = async knex => { 22 | await knex.schema.dropTable("tag_canned_response"); 23 | }; 24 | -------------------------------------------------------------------------------- /src/server/models/user-cell.js: -------------------------------------------------------------------------------- 1 | import thinky from "./thinky"; 2 | const type = thinky.type; 3 | import { requiredString } from "./custom-types"; 4 | 5 | import User from "./user"; 6 | 7 | const UserCell = thinky.createModel( 8 | "user_cell", 9 | type 10 | .object() 11 | .schema({ 12 | id: type.string(), 13 | cell: requiredString(), 14 | user_id: requiredString(), 15 | service: type 16 | .string() 17 | .required() 18 | .enum("nexmo", "twilio"), 19 | is_primary: type.boolean().required() 20 | }) 21 | .allowExtra(false), 22 | { noAutoCreation: true, dependencies: [User] } 23 | ); 24 | 25 | UserCell.ensureIndex("user_id"); 26 | 27 | export default UserCell; 28 | -------------------------------------------------------------------------------- /src/extensions/contact-loaders/components.js: -------------------------------------------------------------------------------- 1 | import { getConfig } from "../../server/api/lib/config"; 2 | 3 | function getComponents() { 4 | const enabledComponents = ( 5 | global.CONTACT_LOADERS || "csv-upload,test-fakedata,datawarehouse" 6 | ).split(","); 7 | const components = {}; 8 | enabledComponents.forEach(componentName => { 9 | try { 10 | const c = require(`./${componentName}/react-component.js`); 11 | components[componentName] = c.CampaignContactsForm; 12 | } catch (err) { 13 | console.error("CONTACT_LOADERS failed to load component", componentName); 14 | } 15 | }); 16 | return components; 17 | } 18 | 19 | const componentList = getComponents(); 20 | 21 | export default componentList; 22 | -------------------------------------------------------------------------------- /src/server/api/tag.js: -------------------------------------------------------------------------------- 1 | import { mapFieldsToModel } from "./lib/utils"; 2 | import { Tag, r } from "../models"; 3 | import { getConfig } from "../api/lib/config"; 4 | 5 | export async function getTags(organization, group) { 6 | // TODO: need to cache this on organization object 7 | let query = r.knex 8 | .select("*") 9 | .from("tag") 10 | .where("tag.organization_id", organization.id) 11 | .where("tag.is_deleted", false); 12 | if (group) { 13 | query = query.where("group", group); 14 | } 15 | return query; 16 | } 17 | 18 | export const resolvers = { 19 | Tag: { 20 | ...mapFieldsToModel( 21 | ["id", "name", "group", "description", "isDeleted", "organizationId"], 22 | Tag 23 | ) 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /src/server/models/assignment.js: -------------------------------------------------------------------------------- 1 | import thinky from "./thinky"; 2 | import { requiredString, timestamp } from "./custom-types"; 3 | 4 | import Campaign from "./campaign"; 5 | import User from "./user"; 6 | 7 | const type = thinky.type; 8 | 9 | const Assignment = thinky.createModel( 10 | "assignment", 11 | type 12 | .object() 13 | .schema({ 14 | id: type.string(), 15 | user_id: requiredString(), 16 | campaign_id: requiredString(), 17 | created_at: timestamp(), 18 | max_contacts: type.integer() 19 | }) 20 | .allowExtra(false), 21 | { noAutoCreation: true } 22 | ); 23 | 24 | Assignment.ensureIndex("user_id"); 25 | Assignment.ensureIndex("campaign_id"); 26 | 27 | export default Assignment; 28 | -------------------------------------------------------------------------------- /migrations/helpers/index.js: -------------------------------------------------------------------------------- 1 | exports.isSqlite = knex => { 2 | return /sqlite/.test(knex.client.config.client); 3 | }; 4 | 5 | // Recreate table with new schema, to work around lacking ALTER TABLE functionality 6 | // If needed we could add functionality to migrate data to the new table 7 | exports.redefineSqliteTable = async (knex, tableName, newTableFn) => { 8 | if (!exports.isSqlite(knex)) { 9 | throw Error("Must be connected to Sqlite"); 10 | } 11 | await knex.schema.dropTable(tableName); 12 | await knex.schema.createTable(tableName, newTableFn); 13 | }; 14 | 15 | 16 | exports.onUpdateTrigger = table => ` 17 | CREATE TRIGGER ${table}_updated_at 18 | BEFORE UPDATE ON ${table} 19 | FOR EACH ROW 20 | EXECUTE PROCEDURE on_update_timestamp(); 21 | ` -------------------------------------------------------------------------------- /__test__/server/render-index.test.js: -------------------------------------------------------------------------------- 1 | import renderIndex from "../../src/server/middleware/render-index"; 2 | 3 | const fakeArguments = { 4 | html: "", 5 | css: { 6 | content: "", 7 | renderedClassNames: "" 8 | }, 9 | assetMap: { 10 | "bundle.js": "" 11 | }, 12 | store: { 13 | getState: () => "" 14 | } 15 | }; 16 | 17 | describe("renderIndex", () => { 18 | it("returns html markup that contains a tag link for the favicon", () => { 19 | const { html, css, assetMap, store } = fakeArguments; 20 | const htmlMarkup = renderIndex(html, css, assetMap, store); 21 | expect(htmlMarkup).toContain( 22 | '' 23 | ); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/server/api/mutations/getOptOutMessage.js: -------------------------------------------------------------------------------- 1 | import { getConfig } from "../lib/config"; 2 | import optOutMessageCache from "../../models/cacheable_queries/opt-out-message"; 3 | import zipStateCache from "../../models/cacheable_queries/zip"; 4 | 5 | export const getOptOutMessage = async ( 6 | _, 7 | { organizationId, zip, defaultMessage } 8 | ) => { 9 | if (!getConfig("OPT_OUT_PER_STATE")) { 10 | return defaultMessage; 11 | } 12 | try { 13 | const queryResult = await optOutMessageCache.query({ 14 | organizationId: organizationId, 15 | state: await zipStateCache.query({ zip: zip }) 16 | }); 17 | 18 | return queryResult || defaultMessage; 19 | } catch (e) { 20 | console.error(e); 21 | return defaultMessage; 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /src/server/models/cacheable_queries/lib.js: -------------------------------------------------------------------------------- 1 | export const modelWithExtraProps = (obj, Model, props) => { 2 | // This accepts a Model type and adds extra properties to it 3 | // while preserving its Model prototype 4 | // This is useful so we can return Model types so .save() and other 5 | // methods are available, but we decorate it with additional 6 | // properties which are distilled from separate tables 7 | // e.g. campaign.interactionSteps 8 | const newObj = { ...obj }; 9 | const extraProps = {}; 10 | props.forEach(prop => { 11 | extraProps[prop] = newObj[prop]; 12 | delete newObj[prop]; 13 | }); 14 | const newModel = new Model(newObj); 15 | props.forEach(prop => { 16 | newModel[prop] = extraProps[prop]; 17 | }); 18 | return newModel; 19 | }; 20 | -------------------------------------------------------------------------------- /src/extensions/job-runners/experimental-bull/index.js: -------------------------------------------------------------------------------- 1 | import { saveJob } from "../helpers"; 2 | import { taskQueue, jobQueue } from "./queues"; 3 | 4 | export const fullyConfigured = () => !!process.env.REDIS_URL; 5 | 6 | export const dispatchJob = async ( 7 | { queue_name, job_type, organization_id, campaign_id, payload }, 8 | opts = {} 9 | ) => { 10 | const job = await saveJob( 11 | { 12 | locks_queue: false, 13 | assigned: true, 14 | organization_id, 15 | campaign_id, 16 | queue_name, 17 | payload, 18 | job_type 19 | }, 20 | opts.trx 21 | ); 22 | await jobQueue.add({ jobId: job.id }); 23 | return job; 24 | }; 25 | 26 | export const dispatchTask = async (taskName, payload) => { 27 | await taskQueue.add({ taskName, payload }); 28 | }; 29 | -------------------------------------------------------------------------------- /src/components/CampaignFormSectionHeading.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | import React from "react"; 3 | import { StyleSheet, css } from "aphrodite"; 4 | import theme from "../styles/theme"; 5 | 6 | const styles = StyleSheet.create({ 7 | header: theme.text.header, 8 | secondaryHeader: theme.text.body, 9 | container: { 10 | marginBottom: 20 11 | } 12 | }); 13 | 14 | const CampaignFormSectionHeading = ({ title, subtitle }) => ( 15 |
16 |
{title}
17 |
{subtitle}
18 |
19 | ); 20 | 21 | CampaignFormSectionHeading.propTypes = { 22 | title: PropTypes.string, 23 | subtitle: PropTypes.any 24 | }; 25 | 26 | export default CampaignFormSectionHeading; 27 | -------------------------------------------------------------------------------- /src/server/api/message.js: -------------------------------------------------------------------------------- 1 | import { mapFieldsToModel } from "./lib/utils"; 2 | import { Message } from "../models"; 3 | 4 | export const resolvers = { 5 | Message: { 6 | ...mapFieldsToModel( 7 | ["text", "userNumber", "contactNumber", "isFromContact"], 8 | Message 9 | ), 10 | createdAt: msg => ( 11 | msg.created_at instanceof Date || !msg.created_at 12 | ? msg.created_at || null 13 | : new Date(msg.created_at) 14 | ), 15 | media: msg => 16 | // Sometimes it's array, sometimes string. Maybe db vs. cache? 17 | typeof msg.media === "string" ? JSON.parse(msg.media) : msg.media || [], 18 | // cached messages don't have message.id -- why bother 19 | id: msg => msg.id || `fake${Math.random()}`, 20 | userId: msg => msg.user_id || null 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /src/server/models/canned-response.js: -------------------------------------------------------------------------------- 1 | import thinky from "./thinky"; 2 | const type = thinky.type; 3 | import { requiredString, timestamp, optionalString } from "./custom-types"; 4 | 5 | const CannedResponse = thinky.createModel( 6 | "canned_response", 7 | type 8 | .object() 9 | .schema({ 10 | id: type.string(), 11 | campaign_id: requiredString(), 12 | text: requiredString(), 13 | title: requiredString(), 14 | user_id: optionalString(), 15 | created_at: timestamp(), 16 | answer_actions: optionalString(), 17 | answer_actions_data: optionalString() 18 | }) 19 | .allowExtra(false), 20 | { noAutoCreation: true } 21 | ); 22 | 23 | CannedResponse.ensureIndex("campaign_id"); 24 | CannedResponse.ensureIndex("user_id"); 25 | 26 | export default CannedResponse; 27 | -------------------------------------------------------------------------------- /dev-tools/.env.test: -------------------------------------------------------------------------------- 1 | # Configuration for local integration test server 2 | NODE_ENV=test 3 | SUPPRESS_SELF_INVITE= 4 | JOBS_SAME_PROCESS=1 5 | DEV_APP_PORT=8091 6 | OUTPUT_DIR=./build 7 | ASSETS_DIR=./build/client/assets 8 | ASSETS_MAP_FILE=assets.json 9 | DB_HOST=127.0.0.1 10 | DB_PORT=5432 11 | DB_NAME=spoke_test 12 | DB_USER=spoke_test 13 | DB_PASSWORD=spoke_test 14 | DB_TYPE=pg 15 | DB_MIN_POOL=2 16 | DB_MAX_POOL=10 17 | DB_USE_SSL=false 18 | DB_DEBUG=false 19 | WEBPACK_HOT_RELOAD=1 20 | WEBPACK_HOST=localhost 21 | WEBPACK_PORT=3001 22 | BASE_URL=http://localhost:3001 23 | SESSION_SECRET=set_this_in_production 24 | DEFAULT_SERVICE=fakeservice 25 | PHONE_NUMBER_COUNTRY=US 26 | PHONE_INVENTORY=1 27 | ALLOW_SEND_ALL=false 28 | DST_REFERENCE_TIMEZONE='America/New_York' 29 | PASSPORT_STRATEGY=local 30 | ASSIGNMENT_LOAD_LIMIT=1 31 | -------------------------------------------------------------------------------- /dev-tools/export-query.js: -------------------------------------------------------------------------------- 1 | import { r } from "../src/server/models"; 2 | import Papa from "papaparse"; 3 | 4 | (async function() { 5 | try { 6 | const res = await r 7 | .table("message") 8 | .eqJoin("assignment_id", r.table("assignment")) 9 | .zip() 10 | .filter({ campaign_id: process.env.CAMPAIGN_ID }); 11 | const finalResults = res.map(row => ({ 12 | assignment_id: row.assignment_id, 13 | campaign_id: row.campaign_id, 14 | contact_number: row.contact_number, 15 | user_number: row.user_number, 16 | is_from_contact: row.is_from_contact, 17 | send_status: row.send_status, 18 | text: row.text 19 | })); 20 | const csvResults = Papa.unparse(finalResults); 21 | console.log(csvResults); 22 | } catch (ex) { 23 | console.log(ex); 24 | } 25 | })(); 26 | -------------------------------------------------------------------------------- /src/lib/cookie.js: -------------------------------------------------------------------------------- 1 | export function setCookie(name, value, days) { 2 | var expires = ""; 3 | if (days) { 4 | var date = new Date(); 5 | date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000); 6 | expires = "; expires=" + date.toUTCString(); 7 | } 8 | document.cookie = name + "=" + value + expires + "; path=/"; 9 | } 10 | 11 | export function getCookie(name) { 12 | var nameEQ = name + "="; 13 | var ca = document.cookie.split(";"); 14 | for (var i = 0; i < ca.length; i++) { 15 | var c = ca[i]; 16 | while (c.charAt(0) == " ") c = c.substring(1, c.length); 17 | if (c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length, c.length); 18 | } 19 | return null; 20 | } 21 | 22 | export function eraseCookie(name) { 23 | document.cookie = name + "=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;"; 24 | } 25 | -------------------------------------------------------------------------------- /src/server/models/assignment-feedback.js: -------------------------------------------------------------------------------- 1 | import thinky from "./thinky"; 2 | import { requiredString, optionalString, timestamp } from "./custom-types"; 3 | 4 | const type = thinky.type; 5 | 6 | const AssignmentFeedback = thinky.createModel( 7 | "assignment_feedback", 8 | type 9 | .object() 10 | .schema({ 11 | id: type.string(), 12 | creator_id: requiredString(), 13 | assignment_id: requiredString(), 14 | created_at: timestamp(), 15 | feedback: optionalString(), 16 | is_acknowledged: type.bool().default(false), 17 | complete: type.bool().default(false) 18 | }) 19 | .allowExtra(false), 20 | { noAutoCreation: true } 21 | ); 22 | 23 | AssignmentFeedback.ensureIndex("assignment_id"); 24 | AssignmentFeedback.ensureIndex("creator_id"); 25 | 26 | export default AssignmentFeedback; 27 | -------------------------------------------------------------------------------- /src/components/OrganizationReassignLink.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | import React from "react"; 3 | import DisplayLink from "./DisplayLink"; 4 | 5 | const OrganizationReassignLink = ({ joinToken, campaignId }) => { 6 | let baseUrl = "https://base"; 7 | if (typeof window !== "undefined") { 8 | baseUrl = window.location.origin; 9 | } 10 | 11 | const replyUrl = `${baseUrl}/${joinToken}/replies/${campaignId}`; 12 | const textContent = `Send your texting volunteers this link! Once they sign up, they\'ll be automatically assigned replies for this campaign.`; 13 | 14 | return ; 15 | }; 16 | 17 | OrganizationReassignLink.propTypes = { 18 | joinToken: PropTypes.string, 19 | campaignId: PropTypes.string 20 | }; 21 | 22 | export default OrganizationReassignLink; 23 | -------------------------------------------------------------------------------- /src/lib/permissions.js: -------------------------------------------------------------------------------- 1 | export const ROLE_HIERARCHY = [ 2 | "SUSPENDED", 3 | "TEXTER", 4 | "VETTED_TEXTER", 5 | "SUPERVOLUNTEER", 6 | "ADMIN", 7 | "OWNER" 8 | ]; 9 | 10 | export const isRoleGreater = (role1, role2) => 11 | ROLE_HIERARCHY.indexOf(role1) > ROLE_HIERARCHY.indexOf(role2); 12 | 13 | export const hasRoleAtLeast = (hasRole, wantsRole) => 14 | ROLE_HIERARCHY.indexOf(hasRole) >= ROLE_HIERARCHY.indexOf(wantsRole); 15 | 16 | export const getHighestRole = roles => [...roles].sort(isRoleGreater).pop(); 17 | 18 | export const hasRole = (role, roles) => 19 | hasRoleAtLeast(getHighestRole(roles), role); 20 | 21 | export const rolesEqualOrGreater = role => 22 | ROLE_HIERARCHY.slice(ROLE_HIERARCHY.indexOf(role)); 23 | 24 | export const rolesEqualOrLess = role => 25 | ROLE_HIERARCHY.slice(0, ROLE_HIERARCHY.indexOf(role) + 1); 26 | -------------------------------------------------------------------------------- /src/server/models/tag-campaign-contact.js: -------------------------------------------------------------------------------- 1 | import thinky from "./thinky"; 2 | import { requiredString, timestamp } from "./custom-types"; 3 | const type = thinky.type; 4 | import CampaignContact from "./campaign-contact"; 5 | import Tag from "./tag"; 6 | 7 | const TagCampaignContact = thinky.createModel( 8 | "tag_campaign_contact", 9 | type 10 | .object() 11 | .schema({ 12 | id: type.string(), 13 | value: type.string(), 14 | tag_id: requiredString(), 15 | campaign_contact_id: requiredString(), 16 | created_at: timestamp(), 17 | updated_at: timestamp() 18 | }) 19 | .allowExtra(false), 20 | { 21 | noAutoCreation: true, 22 | dependencies: [Tag, CampaignContact] 23 | } 24 | ); 25 | 26 | TagCampaignContact.ensureIndex("campaign_contact_id"); 27 | 28 | export default TagCampaignContact; 29 | -------------------------------------------------------------------------------- /src/workers/lib.js: -------------------------------------------------------------------------------- 1 | import { r, JobRequest } from "../server/models"; 2 | 3 | export const sleep = (ms = 0) => new Promise(fn => setTimeout(fn, ms)); 4 | 5 | export async function updateJob(job, percentComplete) { 6 | if (job.id) { 7 | await JobRequest.get(job.id).update({ 8 | status: percentComplete, 9 | updated_at: new Date() 10 | }); 11 | } 12 | } 13 | 14 | export async function getNextJob() { 15 | let nextJob = await r 16 | .table("job_request") 17 | .filter({ assigned: false }) 18 | .orderBy("created_at") 19 | .limit(1)(0); 20 | if (nextJob) { 21 | const updateResults = await r 22 | .table("job_request") 23 | .get(nextJob.id) 24 | .update({ assigned: true }); 25 | if (updateResults.replaced !== 1) { 26 | nextJob = null; 27 | } 28 | } 29 | return nextJob; 30 | } 31 | -------------------------------------------------------------------------------- /src/components/PasswordResetLink.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | import React from "react"; 3 | import DisplayLink from "./DisplayLink"; 4 | 5 | const PasswordResetLink = ({ passwordResetHash }) => { 6 | let baseUrl = "http://base"; 7 | if (typeof window !== "undefined") { 8 | baseUrl = window.location.origin; 9 | } 10 | 11 | const url = `${baseUrl}/reset/${passwordResetHash}`; 12 | const textContent = 13 | "Send your texting volunteer this link! Once they try to log in, they'll be asked to create a new password. Please note that the link expires in 15 minutes, after which a new link will need to be generated."; 14 | 15 | return ; 16 | }; 17 | 18 | PasswordResetLink.propTypes = { 19 | passwordResetHash: PropTypes.string 20 | }; 21 | 22 | export default PasswordResetLink; 23 | -------------------------------------------------------------------------------- /src/extensions/message-handlers/index.js: -------------------------------------------------------------------------------- 1 | import { getConfig } from "../../server/api/lib/config"; 2 | 3 | export function getMessageHandlers(organization) { 4 | const handlerKey = "MESSAGE_HANDLERS"; 5 | const configuredHandlers = getConfig(handlerKey, organization); 6 | const enabledHandlers = 7 | (configuredHandlers && configuredHandlers.split(",")) || []; 8 | 9 | if (typeof configuredHandlers === "undefined") { 10 | enabledHandlers.push("auto-optout", "outbound-unassign"); 11 | } 12 | 13 | const handlers = []; 14 | enabledHandlers.forEach(name => { 15 | try { 16 | const c = require(`./${name}/index.js`); 17 | handlers.push(c); 18 | } catch (err) { 19 | console.error( 20 | `${handlerKey} failed to load message handler ${name} -- ${err}` 21 | ); 22 | } 23 | }); 24 | return handlers; 25 | } 26 | -------------------------------------------------------------------------------- /src/server/models/tag.js: -------------------------------------------------------------------------------- 1 | import thinky from "./thinky"; 2 | const type = thinky.type; 3 | import { requiredString, timestamp } from "./custom-types"; 4 | import Organization from "./organization"; 5 | 6 | const Tag = thinky.createModel( 7 | "tag", 8 | type 9 | .object() 10 | .schema({ 11 | id: type.string(), 12 | name: requiredString(), 13 | group: type.string(), 14 | description: requiredString(), 15 | is_deleted: type.boolean().default(false), 16 | created_at: timestamp(), 17 | updated_at: timestamp(), 18 | organization_id: type.string() 19 | }) 20 | .allowExtra(false), 21 | { noAutoCreation: true, depenencies: [Organization] } 22 | ); 23 | 24 | Tag.ensureIndex("organization_is_deleted", doc => [ 25 | doc("organization_id"), 26 | doc("is_deleted") 27 | ]); 28 | 29 | export default Tag; 30 | -------------------------------------------------------------------------------- /__test__/components/TexterFrequentlyAskedQuestions.test.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { shallow } from "enzyme"; 3 | import CardHeader from "@material-ui/core/CardHeader"; 4 | import CardContent from "@material-ui/core/CardContent"; 5 | import TexterFaqs from "../../src/components/TexterFrequentlyAskedQuestions"; 6 | 7 | describe("FAQs component", () => { 8 | // given 9 | const faq = [ 10 | { 11 | question: "q1", 12 | answer: "a2" 13 | } 14 | ]; 15 | const wrapper = shallow(); 16 | 17 | // when 18 | test("Renders question and answer", () => { 19 | const question = wrapper.find(CardHeader); 20 | const answer = wrapper.find(CardContent).find("p"); 21 | 22 | // then 23 | expect(question.at(0).prop("title")).toBe("1. q1"); 24 | expect(answer.at(0).text()).toBe("a2"); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/components/Chart.jsx: -------------------------------------------------------------------------------- 1 | import type from "prop-types"; 2 | import React from "react"; 3 | import { Pie } from "react-chartjs-2"; 4 | 5 | const Chart = ({ data }) => { 6 | const chartColors = ["#F7464A", "#46BFBD", "#FDB45C", "#949FB1", "#4D5360"]; 7 | 8 | const pieData = { 9 | labels: data.map(([label]) => label), 10 | datasets: [ 11 | { 12 | data: data.map(([, value]) => value), 13 | backgroundColor: data.map( 14 | (value, index) => chartColors[index % chartColors.length] 15 | ) 16 | } 17 | ] 18 | }; 19 | const options = { 20 | legend: { 21 | position: "bottom" 22 | } 23 | }; 24 | 25 | return ( 26 |
27 | 28 |
29 | ); 30 | }; 31 | 32 | Chart.propTypes = { 33 | data: type.array 34 | }; 35 | 36 | export default Chart; 37 | -------------------------------------------------------------------------------- /src/server/models/opt-out.js: -------------------------------------------------------------------------------- 1 | import thinky from "./thinky"; 2 | const type = thinky.type; 3 | import { optionalString, requiredString, timestamp } from "./custom-types"; 4 | 5 | import Organization from "./organization"; 6 | import Assignment from "./assignment"; 7 | 8 | const OptOut = thinky.createModel( 9 | "opt_out", 10 | type 11 | .object() 12 | .schema({ 13 | id: type.string(), 14 | cell: requiredString(), 15 | assignment_id: optionalString(), 16 | organization_id: requiredString(), 17 | reason_code: optionalString(), 18 | created_at: timestamp() 19 | }) 20 | .allowExtra(false), 21 | { noAutoCreation: true, dependencies: [Organization, Assignment] } 22 | ); 23 | 24 | OptOut.ensureIndex("cell"); 25 | OptOut.ensureIndex("assignment_id"); 26 | OptOut.ensureIndex("organization_id"); 27 | 28 | export default OptOut; 29 | -------------------------------------------------------------------------------- /migrations/20201010173732_add_assignment_feedback.js: -------------------------------------------------------------------------------- 1 | exports.up = async knex => { 2 | await knex.schema.createTable("assignment_feedback", table => { 3 | table.increments(); 4 | table 5 | .integer("assignment_id") 6 | .notNullable() 7 | .references("id") 8 | .inTable("assignment"); 9 | table 10 | .integer("creator_id") 11 | .nullable() 12 | .references("id") 13 | .inTable("user"); 14 | table.timestamp("created_at").defaultTo(knex.fn.now()); 15 | table 16 | .jsonb("feedback") 17 | .nullable() 18 | .defaultTo(null); 19 | table.boolean("is_acknowledged").defaultTo(false); 20 | table.boolean("complete").defaultTo(false); 21 | 22 | table.unique("assignment_id"); 23 | }); 24 | }; 25 | 26 | exports.down = async knex => { 27 | await knex.schema.dropTable("assignment_feedback"); 28 | }; 29 | -------------------------------------------------------------------------------- /src/server/models/custom-types.js: -------------------------------------------------------------------------------- 1 | import thinky from "./thinky"; 2 | const type = thinky.type; 3 | const r = thinky.r; 4 | 5 | // In order to not end up with optional 6 | // strings that are half null and half 7 | // empty strings, we standardize on empty 8 | // strings for optional strings and don't 9 | // allow null strings 10 | 11 | export function requiredString() { 12 | return type 13 | .string() 14 | .required() 15 | .allowNull(false) 16 | .min(1); 17 | } 18 | 19 | export function optionalString() { 20 | return type 21 | .string() 22 | .required() 23 | .allowNull(false) 24 | .default(""); 25 | } 26 | 27 | export function timestamp() { 28 | return type 29 | .date() 30 | .required() 31 | .allowNull(false) 32 | .default(r.now()); 33 | } 34 | 35 | export function optionalTimestamp() { 36 | return type.date(); 37 | } 38 | -------------------------------------------------------------------------------- /src/server/models/owned_phone_number.js: -------------------------------------------------------------------------------- 1 | import thinky from "./thinky"; 2 | const type = thinky.type; 3 | import { 4 | optionalString, 5 | optionalTimestamp, 6 | requiredString, 7 | timestamp 8 | } from "./custom-types"; 9 | 10 | // For documentation purposes only. Use knex queries instead of this model. 11 | const OwnedPhoneNumber = thinky.createModel( 12 | "owned_phone_number", 13 | type 14 | .object() 15 | .schema({ 16 | id: requiredString(), 17 | service: requiredString(), 18 | service_id: requiredString().stopReference(), 19 | organization_id: requiredString(), 20 | phone_number: requiredString(), 21 | allocated_to: optionalString(), 22 | allocated_to_id: optionalString().stopReference(), 23 | allocated_at: optionalTimestamp(), 24 | created_at: timestamp() 25 | }) 26 | .allowExtra(false) 27 | ); 28 | -------------------------------------------------------------------------------- /src/api/user.js: -------------------------------------------------------------------------------- 1 | export const schema = ` 2 | type User { 3 | id: ID 4 | firstName: String 5 | lastName: String 6 | alias: String 7 | displayName: String 8 | email: String 9 | cell: String 10 | is_superadmin: Boolean 11 | extra: String 12 | organizations(role: String): [Organization] 13 | todos(organizationId: String): [Assignment] 14 | roles(organizationId: String!): [String] 15 | assignedCell: Phone 16 | assignment(campaignId: String): Assignment 17 | terms: Boolean 18 | profileComplete(organizationId: String): Boolean 19 | notifications(organizationId: String): [Assignment] 20 | notifiable: Boolean 21 | } 22 | 23 | type UsersList { 24 | users: [User] 25 | } 26 | 27 | type PaginatedUsers { 28 | users: [User] 29 | pageInfo: PageInfo 30 | } 31 | 32 | union UsersReturn = PaginatedUsers | UsersList 33 | `; 34 | -------------------------------------------------------------------------------- /src/server/models/cacheable_queries/index.js: -------------------------------------------------------------------------------- 1 | import assignment from "./assignment"; 2 | import campaign from "./campaign"; 3 | import campaignContact from "./campaign-contact"; 4 | import cannedResponse from "./canned-response"; 5 | import organizationContact from "./organization-contact"; 6 | import message from "./message"; 7 | import optOut from "./opt-out"; 8 | import organization from "./organization"; 9 | import questionResponse from "./question-response"; 10 | import { tagCampaignContactCache as tagCampaignContact } from "./tag-campaign-contact"; 11 | import user from "./user"; 12 | 13 | const cacheableData = { 14 | assignment, 15 | campaign, 16 | campaignContact, 17 | cannedResponse, 18 | organizationContact, 19 | message, 20 | optOut, 21 | organization, 22 | questionResponse, 23 | tagCampaignContact, 24 | user 25 | }; 26 | 27 | export default cacheableData; 28 | -------------------------------------------------------------------------------- /src/server/models/question-response.js: -------------------------------------------------------------------------------- 1 | import thinky from "./thinky"; 2 | const type = thinky.type; 3 | import { requiredString, timestamp } from "./custom-types"; 4 | 5 | import CampaignContact from "./campaign-contact"; 6 | import InteractionStep from "./interaction-step"; 7 | 8 | const QuestionResponse = thinky.createModel( 9 | "question_response", 10 | type 11 | .object() 12 | .schema({ 13 | id: type.string(), 14 | campaign_contact_id: requiredString(), 15 | interaction_step_id: requiredString(), 16 | value: requiredString(), 17 | created_at: timestamp() 18 | }) 19 | .allowExtra(false), 20 | { noAutoCreation: true, dependencies: [CampaignContact, InteractionStep] } 21 | ); 22 | 23 | QuestionResponse.ensureIndex("campaign_contact_id"); 24 | QuestionResponse.ensureIndex("interaction_step_id"); 25 | 26 | export default QuestionResponse; 27 | -------------------------------------------------------------------------------- /src/extensions/contact-loaders/civicrm/getcachelength.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | import { hasConfig, getConfig } from "../../../server/api/lib/config"; 3 | import { CIVICRM_CACHE_SECONDS } from "./const"; 4 | import { getCustomFields } from "./util"; 5 | 6 | // To make mocking easier, we place getCacheLength in its own file. 7 | 8 | export function getCacheLength(nameActionOrLoader) { 9 | if (!hasConfig("CIVICRM_CACHE_LENGTHS")) { 10 | return CIVICRM_CACHE_SECONDS; 11 | } 12 | const cacheLengths = getCustomFields(getConfig("CIVICRM_CACHE_LENGTHS")); 13 | if (nameActionOrLoader in cacheLengths) { 14 | const nameActionOrLoaderParsed = Number(cacheLengths[nameActionOrLoader]); 15 | if (isNaN(nameActionOrLoaderParsed)) { 16 | return CIVICRM_CACHE_SECONDS; 17 | } 18 | return nameActionOrLoaderParsed; 19 | } 20 | return CIVICRM_CACHE_SECONDS; 21 | } 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help Spoke improve 4 | title: 'Bug: ' 5 | labels: C-bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Platform (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Desktop or Mobile? 30 | - Spoke Version: [e.g. 14.0.1] 31 | 32 | **Additional context** 33 | Add any other context about the problem here. 34 | -------------------------------------------------------------------------------- /src/server/models/zip-code.js: -------------------------------------------------------------------------------- 1 | import thinky from "./thinky"; 2 | const type = thinky.type; 3 | import { requiredString } from "./custom-types"; 4 | 5 | const ZipCode = thinky.createModel( 6 | "zip_code", 7 | type 8 | .object() 9 | .schema({ 10 | zip: requiredString(), 11 | city: requiredString(), 12 | state: requiredString(), 13 | latitude: type 14 | .number() 15 | .required() 16 | .allowNull(false), 17 | longitude: type 18 | .number() 19 | .required() 20 | .allowNull(false), 21 | timezone_offset: type 22 | .number() 23 | .required() 24 | .allowNull(false), 25 | has_dst: type 26 | .boolean() 27 | .required() 28 | .allowNull(false) 29 | }) 30 | .allowExtra(false), 31 | { pk: "zip", noAutoCreation: true } 32 | ); 33 | 34 | export default ZipCode; 35 | -------------------------------------------------------------------------------- /src/server/api/phone.js: -------------------------------------------------------------------------------- 1 | import { GraphQLScalarType } from "graphql"; 2 | import { GraphQLError } from "graphql"; 3 | import { Kind } from "graphql/language"; 4 | 5 | const identity = value => value; 6 | 7 | // Regex taken from http://stackoverflow.com/questions/6478875/regular-expression-matching-e-164-formatted-phone-numbers 8 | const pattern = /^\+[1-9]\d{1,14}$/; 9 | 10 | export const GraphQLPhone = new GraphQLScalarType({ 11 | name: "Phone", 12 | description: "Phone number", 13 | parseValue: identity, 14 | serialize: identity, 15 | parseLiteral(ast) { 16 | if (ast.kind !== Kind.STRING) { 17 | throw new GraphQLError( 18 | `Query error: Can only parse strings got a: ${ast.kind}` 19 | ); 20 | } 21 | 22 | if (!pattern.test(ast.value)) { 23 | throw new GraphQLError("Query error: Not a valid Phone"); 24 | } 25 | 26 | return ast.value; 27 | } 28 | }); 29 | -------------------------------------------------------------------------------- /src/server/models/datawarehouse.js: -------------------------------------------------------------------------------- 1 | import knex from "knex"; 2 | 3 | const { 4 | WAREHOUSE_DB_TYPE, 5 | WAREHOUSE_DB_HOST, 6 | WAREHOUSE_DB_PORT, 7 | WAREHOUSE_DB_NAME, 8 | WAREHOUSE_DB_PASSWORD, 9 | WAREHOUSE_DB_USER, 10 | WAREHOUSE_DB_SCHEMA = "", 11 | WAREHOUSE_USE_SSL = "false" 12 | } = process.env; 13 | 14 | const useSSL = 15 | WAREHOUSE_USE_SSL === "1" || WAREHOUSE_USE_SSL.toLowerCase() === "true"; 16 | 17 | let config; 18 | 19 | if (WAREHOUSE_DB_TYPE) { 20 | config = { 21 | client: WAREHOUSE_DB_TYPE, 22 | connection: { 23 | host: WAREHOUSE_DB_HOST, 24 | port: WAREHOUSE_DB_PORT, 25 | database: WAREHOUSE_DB_NAME, 26 | password: WAREHOUSE_DB_PASSWORD, 27 | user: WAREHOUSE_DB_USER, 28 | searchPath: WAREHOUSE_DB_SCHEMA, 29 | ssl: useSSL 30 | } 31 | }; 32 | } 33 | 34 | export default WAREHOUSE_DB_TYPE ? () => knex(config) : null; 35 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Document 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/api/assignment.js: -------------------------------------------------------------------------------- 1 | export const schema = ` 2 | input AssignmentsFilter { 3 | texterId: Int 4 | stats: Boolean 5 | sender: Boolean 6 | } 7 | 8 | type FeedbackCreatedBy { 9 | id: Int 10 | name: String 11 | } 12 | 13 | type AssignmentFeedback { 14 | message: String! 15 | issueCounts: JSON! 16 | skillCounts: JSON! 17 | isAcknowledged: Boolean! 18 | createdBy: FeedbackCreatedBy! 19 | sweepComplete: Boolean! 20 | } 21 | 22 | type Assignment { 23 | id: ID 24 | texter: User 25 | campaign: Campaign 26 | contacts(contactsFilter: ContactsFilter): [CampaignContact] 27 | contactsCount(contactsFilter: ContactsFilter, hasAny: Boolean): Int 28 | hasUnassignedContactsForTexter: Int 29 | userCannedResponses: [CannedResponse] 30 | campaignCannedResponses: [CannedResponse] 31 | maxContacts: Int 32 | feedback: AssignmentFeedback 33 | } 34 | `; 35 | -------------------------------------------------------------------------------- /src/extensions/job-runners/experimental-bull/processor.js: -------------------------------------------------------------------------------- 1 | import { invokeJobFunction } from "../../../workers/job-processes"; 2 | import { invokeTaskFunction } from "../../../workers/tasks"; 3 | import { r } from "../../../server/models"; 4 | 5 | import { jobQueue, taskQueue } from "./queues"; 6 | 7 | taskQueue.process(async bullJob => { 8 | console.debug("Processing bull job:", { ...bullJob, queue: null }); 9 | const { taskName, payload } = bullJob.data; 10 | await invokeTaskFunction(taskName, payload); 11 | }); 12 | 13 | jobQueue.process(async bullJob => { 14 | console.debug("Processing bull job:", { ...bullJob, queue: null }); 15 | const { jobId } = bullJob.data; 16 | const job = await r 17 | .knex("job_request") 18 | .select("*") 19 | .where("id", jobId) 20 | .first(); 21 | if (!job) { 22 | throw new Error(`Job ${jobId} not found`); 23 | } 24 | await invokeJobFunction(job); 25 | }); 26 | -------------------------------------------------------------------------------- /src/server/models/user.js: -------------------------------------------------------------------------------- 1 | import thinky from "./thinky"; 2 | const type = thinky.type; 3 | import { requiredString, timestamp } from "./custom-types"; 4 | 5 | const User = thinky.createModel( 6 | "user", 7 | type 8 | .object() 9 | .schema({ 10 | id: type.string(), 11 | // LEGACY name, but contains the auth0_id OR the auth secret/token 12 | auth0_id: requiredString().stopReference(), 13 | first_name: requiredString(), 14 | last_name: requiredString(), 15 | alias: type.string().default(null), 16 | cell: requiredString(), 17 | email: requiredString(), 18 | created_at: timestamp(), 19 | assigned_cell: type.string(), 20 | is_superadmin: type.boolean(), 21 | terms: type.boolean().default(false), 22 | extra: type.string().default(null) 23 | }) 24 | .allowExtra(false), 25 | { noAutoCreation: true } 26 | ); 27 | 28 | export default User; 29 | -------------------------------------------------------------------------------- /src/extensions/service-managers/civicrm-donotsms/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-empty-function */ 2 | /* eslint-disable no-unused-vars */ 3 | // This is the civicrm-donotsms service manager. 4 | 5 | import { optoutContactToGroup } from "../../contact-loaders/civicrm/util"; 6 | // import { log } from "../../../lib/log"; 7 | 8 | export const name = "civicrm-donotsms"; 9 | 10 | export const metadata = () => ({ 11 | displayName: "The 'Do Not SMS' Opt out service manager", 12 | description: "Used to opt folk out of receiving SMSes", 13 | canSpendMoney: false, 14 | moneySpendingOperations: [], 15 | supportsOrgConfig: true, 16 | supportsCampaignConfig: true 17 | }); 18 | 19 | export async function onOptOut({ 20 | organization, 21 | contact, 22 | campaign, 23 | user, 24 | noReply, 25 | reason, 26 | assignmentId 27 | }) { 28 | const returnVal = await optoutContactToGroup(contact.external_id); 29 | return returnVal; 30 | } 31 | -------------------------------------------------------------------------------- /src/server/models/campaign-admin.js: -------------------------------------------------------------------------------- 1 | import thinky from "./thinky"; 2 | const type = thinky.type; 3 | import { optionalString, requiredString, timestamp } from "./custom-types"; 4 | 5 | const CampaignAdmin = thinky.createModel( 6 | "campaign_admin", 7 | type 8 | .object() 9 | .schema({ 10 | id: type.string(), 11 | campaign_id: requiredString(), 12 | ingest_method: optionalString(), 13 | ingest_data_reference: optionalString(), 14 | ingest_result: optionalString(), 15 | ingest_success: type.boolean(), 16 | contacts_count: type.number().integer(), 17 | deleted_optouts_count: type.number().integer(), 18 | duplicate_contacts_count: type.number().integer(), 19 | updated_at: timestamp(), 20 | created_at: timestamp() 21 | }) 22 | .allowExtra(false), 23 | { noAutoCreation: true } 24 | ); 25 | 26 | CampaignAdmin.ensureIndex("campaign_id"); 27 | 28 | export default CampaignAdmin; 29 | -------------------------------------------------------------------------------- /docs/HOWTO_MINIMALIST_DEPLOY.md: -------------------------------------------------------------------------------- 1 | ## Deploying Minimally 2 | 3 | There are several ways to deploy. This is the 'most minimal' approach: 4 | 5 | 1. Run `OUTPUT_DIR=./build yarn run prod-build-server` 6 | This will generate something you can deploy to production in ./build and run nodejs server/server/index.js 7 | 2. Run `yarn run prod-build-client` 8 | 3. Make a copy of `deploy/spoke-pm2.config.js.template`, e.g. `spoke-pm2.config.js`, add missing environment variables, and run it with [pm2](https://www.npmjs.com/package/pm2), e.g. `pm2 start spoke-pm2.config.js --env production` 9 | 4. [Install PostgreSQL](https://wiki.postgresql.org/wiki/Detailed_installation_guides) 10 | 5. Start PostgreSQL (e.g. `sudo /etc/init.d/postgresql start`), connect (e.g. `sudo -u postgres psql`), create a user and database (e.g. `create user spoke password 'spoke'; create database spoke owner spoke;`), disconnect (e.g. `\q`) and add credentials to `DB_` variables in spoke-pm2.config.js 11 | -------------------------------------------------------------------------------- /pull_request_template.md: -------------------------------------------------------------------------------- 1 | # Fixes # (issue) 2 | 3 | ## Description 4 | 5 | _Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change, and any blockers that make your change a WIP_ 6 | 7 | # Checklist: 8 | 9 | - [ ] I have manually tested my changes on desktop and mobile 10 | - [ ] The test suite passes locally with my changes 11 | - [ ] If my change is a UI change, I have attached a screenshot to the description section of this pull request 12 | - [ ] [My change is 300 lines of code or less](https://github.com/StateVoicesNational/Spoke/blob/main/CONTRIBUTING.md#submitting-your-pull-request), or has a documented reason in the description why it’s longer 13 | - [ ] I have made any necessary changes to the documentation 14 | - [ ] I have added tests that prove my fix is effective or that my feature works 15 | - [ ] My PR is labeled [WIP] if it is in progress 16 | -------------------------------------------------------------------------------- /migrations/20240116233906_opt_out_message.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @param { import("knex").Knex } knex 3 | * @returns { Promise } 4 | */ 5 | exports.up = async function(knex) { 6 | await knex.schema.createTable("opt_out_message", table => { 7 | table.increments(); 8 | table.timestamp("created_at").defaultTo(knex.fn.now()); 9 | table.text("message").notNullable(); 10 | table 11 | .integer("organization_id") 12 | .references("id") 13 | .inTable("organization") 14 | .notNullable(); 15 | table.string("state", 2).notNullable(); 16 | 17 | table.index( 18 | ["organization_id", "state"], 19 | "opt_out_message_organization_id_state" 20 | ); 21 | table.unique(["organization_id", "state"]); 22 | }); 23 | }; 24 | 25 | /** 26 | * @param { import("knex").Knex } knex 27 | * @returns { Promise } 28 | */ 29 | exports.down = async function(knex) { 30 | await knex.schema.dropTableIfExists("opt_out_message"); 31 | }; 32 | -------------------------------------------------------------------------------- /src/components/PeopleList/SimpleRolesDropdown.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import type from "prop-types"; 3 | 4 | import Select from "@material-ui/core/Select"; 5 | import MenuItem from "@material-ui/core/MenuItem"; 6 | 7 | import { ROLE_HIERARCHY } from "../../lib"; 8 | 9 | export const ALL_ROLES = "ALL ROLES"; 10 | 11 | const SimpleRolesDropdown = props => ( 12 | 26 | ); 27 | 28 | SimpleRolesDropdown.propTypes = { 29 | selectedRole: type.string, 30 | onChange: type.func 31 | }; 32 | 33 | export default SimpleRolesDropdown; 34 | -------------------------------------------------------------------------------- /src/components/OrganizationJoinLink.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | import React from "react"; 3 | import DisplayLink from "./DisplayLink"; 4 | 5 | const OrganizationJoinLink = ({ organizationUuid, campaignId }) => { 6 | let baseUrl = "http://base"; 7 | if (typeof window !== "undefined") { 8 | baseUrl = window.location.origin; 9 | } 10 | 11 | const joinUrl = campaignId 12 | ? `${baseUrl}/${organizationUuid}/join/${campaignId}` 13 | : `${baseUrl}/${organizationUuid}/join`; 14 | const entityInvite = campaignId ? "campaign" : "organization"; 15 | const textContent = `Send your texting volunteers this link! Once they sign up, they\'ll be automatically assigned to this ${entityInvite}.`; 16 | 17 | return ; 18 | }; 19 | 20 | OrganizationJoinLink.propTypes = { 21 | organizationUuid: PropTypes.string, 22 | campaignId: PropTypes.string 23 | }; 24 | 25 | export default OrganizationJoinLink; 26 | -------------------------------------------------------------------------------- /src/client/index.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import { Router, browserHistory } from "react-router"; 4 | import { StyleSheet } from "aphrodite"; 5 | import errorCatcher from "./error-catcher"; 6 | import makeRoutes from "../routes"; 7 | import { ApolloProvider } from "@apollo/client"; 8 | import ApolloClientSingleton from "../network/apollo-client-singleton"; 9 | import { login, logout } from "./auth-service"; 10 | 11 | window.onerror = (msg, file, line, col, error) => { 12 | errorCatcher(error); 13 | }; 14 | window.addEventListener("unhandledrejection", event => { 15 | errorCatcher(event.reason); 16 | }); 17 | window.AuthService = { 18 | login, 19 | logout 20 | }; 21 | 22 | StyleSheet.rehydrate(window.RENDERED_CLASS_NAMES); 23 | 24 | ReactDOM.render( 25 | 26 | 27 | , 28 | document.getElementById("mount") 29 | ); 30 | -------------------------------------------------------------------------------- /src/extensions/service-vendors/message-sending.js: -------------------------------------------------------------------------------- 1 | import { cacheableData } from "../../server/models"; 2 | 3 | export async function getLastMessage({ 4 | contactNumber, 5 | service, 6 | messageServiceSid, 7 | userNumber 8 | }) { 9 | const lookup = await cacheableData.campaignContact.lookupByCell( 10 | contactNumber, 11 | service, 12 | messageServiceSid, 13 | userNumber 14 | ); 15 | return lookup; 16 | } 17 | 18 | export async function saveNewIncomingMessage(messageInstance, contact) { 19 | await cacheableData.message.save({ messageInstance, contact }); 20 | } 21 | 22 | const mediaExtractor = new RegExp(/\[\s*(http[^\]\s]*)\s*\]/); 23 | 24 | export function parseMessageText(message) { 25 | const text = message.text || ""; 26 | const params = { 27 | body: text.replace(mediaExtractor, "") 28 | }; 29 | // Image extraction 30 | const results = text.match(mediaExtractor); 31 | if (results) { 32 | params.mediaUrl = results[1]; 33 | } 34 | return params; 35 | } 36 | -------------------------------------------------------------------------------- /__test__/server/db/utils.js: -------------------------------------------------------------------------------- 1 | export const tables = [ 2 | "log", 3 | "message", 4 | "user_cell", 5 | "job_request", 6 | "pending_message_part", 7 | "zip_code", 8 | "invite", 9 | "user", 10 | "user_organization", 11 | "campaign", 12 | "interaction_step", 13 | "assignment", 14 | "organization", 15 | "canned_response", 16 | "opt_out", 17 | "question_response", 18 | "campaign_contact", 19 | "tag", 20 | "tag_canned_response" 21 | ]; 22 | 23 | // Adapted from https://dba.stackexchange.com/a/37068 24 | export const indexQuery = `SELECT conrelid::regclass AS table_from 25 | , conname 26 | , pg_get_constraintdef(c.oid) 27 | FROM pg_constraint c 28 | JOIN pg_namespace n ON n.oid = c.connamespace 29 | WHERE contype IN ('f', 'p ') 30 | AND conrelid::regclass::text <> 'migrations' 31 | AND conrelid::regclass::text <> 'knex_migrations' 32 | AND conrelid::regclass::text <> 'knex_migrations_lock' 33 | AND n.nspname = 'public' 34 | ORDER BY conrelid::regclass::text, contype DESC;`; 35 | -------------------------------------------------------------------------------- /docs/PROD_RUNNING_JOBS.md: -------------------------------------------------------------------------------- 1 | # Running Jobs in Production 2 | 3 | There are two modes possible in the code base driven by an environment variable 4 | (and what processes your setup/run) called `JOBS_SAME_PROCESS`. If that variable 5 | is set then dispatched jobs like processing uploaded contacts, and sending text 6 | messages out are run on the same process as the actual web application. 7 | 8 | There are advantages and disadvantages to each model: 9 | 10 | ## JOBS_SAME_PROCESS 11 | 12 | * Simpler dispatch, application runs in a single 'node' process loop 13 | * Can be run on 'serverless' platforms like AWS Lambda (with one lambda) 14 | * When in development mode the log loop of the job processor processes doesn't 15 | 16 | ## Separate Processes 17 | 18 | * If you are using a texting backend that rate-limits API calls (Twilio does not) 19 | then it's easier to make sure the rate-limit is observed with the separate process 20 | * On a big server-deployment job segfaults (should be impossible, but...) will more 21 | isolated 22 | 23 | -------------------------------------------------------------------------------- /__test__/lib/search-helpers.test.js: -------------------------------------------------------------------------------- 1 | import { searchFor } from "../../src/lib/search-helpers"; 2 | 3 | const objects = [ 4 | { 5 | field1: "red", 6 | field2: { inner: "purple" } 7 | }, 8 | { 9 | field1: "blue", 10 | field2: { inner: "yellow" } 11 | }, 12 | { 13 | field1: "green", 14 | field2: { inner: " blue " } 15 | } 16 | ]; 17 | 18 | describe("searchFor", () => { 19 | test("returns correct object based on one property", () => { 20 | const result = searchFor("blue", objects, ["field1"]); 21 | expect(result).toEqual([{ field1: "blue", field2: { inner: "yellow" } }]); 22 | }); 23 | 24 | test("returns correct objects based on different (nested) properties", () => { 25 | const result = searchFor("blue", objects, ["field1", "field2.inner"]); 26 | expect(result).toEqual([ 27 | { 28 | field1: "blue", 29 | field2: { inner: "yellow" } 30 | }, 31 | { 32 | field1: "green", 33 | field2: { inner: " blue " } 34 | } 35 | ]); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /migrations/20200220094840_campaign_admin_table.js: -------------------------------------------------------------------------------- 1 | exports.up = async function up(knex) { 2 | if (!(await knex.schema.hasTable("campaign_admin"))) { 3 | return knex.schema.createTable("campaign_admin", t => { 4 | t.increments("id"); 5 | t.integer("campaign_id").notNullable(); 6 | t.text("ingest_method"); 7 | t.text("ingest_data_reference"); 8 | t.text("ingest_result"); 9 | t.boolean("ingest_success"); 10 | t.integer("contacts_count"); 11 | t.integer("deleted_optouts_count"); 12 | t.integer("duplicate_contacts_count"); 13 | t.timestamp("updated_at") 14 | .notNullable() 15 | .defaultTo(knex.fn.now()); 16 | t.timestamp("created_at") 17 | .notNullable() 18 | .defaultTo(knex.fn.now()); 19 | 20 | t.index("campaign_id"); 21 | t.foreign("campaign_id").references("campaign.id"); 22 | }); 23 | } 24 | return Promise.resolve(); 25 | }; 26 | 27 | exports.down = function down(knex) { 28 | return knex.schema.dropTableIfExists("campaign_admin"); 29 | }; 30 | -------------------------------------------------------------------------------- /src/components/PeopleList/UserEditDialog.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | import Dialog from "@material-ui/core/Dialog"; 5 | import DialogTitle from "@material-ui/core/DialogTitle"; 6 | 7 | import UserEdit from "../../containers/UserEdit"; 8 | import { dataTest } from "../../lib/attributes"; 9 | 10 | const UserEditDialog = props => ( 11 | 17 | Edit user ({props.userId}) 18 | 24 | 25 | ); 26 | 27 | UserEditDialog.propTypes = { 28 | open: PropTypes.bool, 29 | organizationId: PropTypes.string, 30 | userId: PropTypes.string, 31 | updateUser: PropTypes.func, 32 | requestClose: PropTypes.func 33 | }; 34 | 35 | export default UserEditDialog; 36 | -------------------------------------------------------------------------------- /src/lib/faqs.js: -------------------------------------------------------------------------------- 1 | const FAQs = [ 2 | { 3 | question: "Can I edit my name and email?", 4 | answer: 5 | "Yes - you can edit your name by clicking on the letter in the right hand corner. This will pop up the " + 6 | "user menu in the corner. You can click on your name and edit your information." 7 | }, 8 | { 9 | question: "How do I reset my password?", 10 | answer: 11 | "Please contact your account administrator or Text Team Manager to reset your password." 12 | }, 13 | { 14 | question: "Does Spoke use my personal phone number to text people?", 15 | answer: 16 | "No - We purchase phone numbers and connect them to the application using a service called Twilio. The " + 17 | "texts you send use those purchased phone numbers." 18 | }, 19 | { 20 | question: "Is Spoke available as an Android/iPhone app?", 21 | answer: 22 | "Spoke is a web-based program you can access from any web browser on your computer, tablet or mobile " + 23 | "device. No app needed!" 24 | } 25 | ]; 26 | 27 | export default FAQs; 28 | -------------------------------------------------------------------------------- /docs/EXPLANATION-labels.md: -------------------------------------------------------------------------------- 1 | # Labels and how we use them 2 | 3 | We've loosely based our labeling system off of [Rust's conventions for prefixing labels](https://github.com/rust-lang/rust/labels). Labels are a big part of the issue intake process to help us figure out how the submission fits into the project. We have a few different prefixes that we use to help distinguish the categories of labels: 4 | 5 | ### Area (`A-`) 6 | For marking different sections of the codebase or different pages in the product. They are helpful for sorting issues by your familiarity with a part of Spoke. 7 | 8 | ### Category (`C-`) 9 | For marking the "type" of issue this is. For example, this is used to differentiate between bugs and features. 10 | 11 | ### Status (`S-`) 12 | For marking the status of pull requests. These are used by core contributors and admin during the review process to communicate to each other what state a PR is in. 13 | 14 | ### Organization (`O-`) 15 | For marking the priorities of specific orgs that are part of the community. Filter by these labels to engage more directly with orgs. -------------------------------------------------------------------------------- /src/components/Slider.jsx: -------------------------------------------------------------------------------- 1 | import type from "prop-types"; 2 | import React from "react"; 3 | import { flowRight as compose } from "lodash"; 4 | import withMuiTheme from "./../containers/hoc/withMuiTheme"; 5 | 6 | const Slider = ({ maxValue, value, color, direction, muiTheme }) => { 7 | const valuePercent = Math.round((value / maxValue) * 100); 8 | return ( 9 |
17 |
26 |
27 | ); 28 | }; 29 | 30 | Slider.propTypes = { 31 | color: type.string, 32 | maxValue: type.number, 33 | value: type.number, 34 | direction: type.number 35 | }; 36 | 37 | export default compose(withMuiTheme)(Slider); 38 | -------------------------------------------------------------------------------- /src/server/models/user-organization.js: -------------------------------------------------------------------------------- 1 | import thinky from "./thinky"; 2 | const type = thinky.type; 3 | import { requiredString } from "./custom-types"; 4 | 5 | import User from "./user"; 6 | import Organization from "./organization"; 7 | 8 | const UserOrganization = thinky.createModel( 9 | "user_organization", 10 | type 11 | .object() 12 | .schema({ 13 | id: type.string(), 14 | user_id: requiredString(), 15 | organization_id: requiredString(), 16 | role: requiredString().enum( 17 | "OWNER", 18 | "ADMIN", 19 | "SUPERVOLUNTEER", 20 | "TEXTER", 21 | "VETTED_TEXTER", 22 | "ORG_SUPERADMIN", 23 | "SUSPENDED" 24 | ) 25 | }) 26 | .allowExtra(false), 27 | { noAutoCreation: true, dependencies: [User, Organization] } 28 | ); 29 | 30 | UserOrganization.ensureIndex("user_id"); 31 | UserOrganization.ensureIndex("organization_id"); 32 | UserOrganization.ensureIndex("organization_user", doc => [ 33 | doc("organization_id"), 34 | doc("user_id") 35 | ]); 36 | 37 | export default UserOrganization; 38 | -------------------------------------------------------------------------------- /src/api/campaign-contact.js: -------------------------------------------------------------------------------- 1 | export const schema = ` 2 | input ContactsFilter { 3 | messageStatus: String 4 | isOptedOut: Boolean 5 | validTimezone: Boolean 6 | includePastDue: Boolean 7 | tags: [String] 8 | suppressedTags: [String] 9 | contactId: String 10 | errorCode: [Int] 11 | } 12 | 13 | type Timezone { 14 | offset: Int 15 | hasDST: Boolean 16 | } 17 | 18 | type Location { 19 | timezone: Timezone 20 | city: String 21 | state: String 22 | } 23 | 24 | type CampaignContact { 25 | id: ID 26 | firstName: String 27 | lastName: String 28 | cell: Phone 29 | zip: String 30 | external_id: String 31 | updated_at: Date 32 | customFields: JSON 33 | messages: [Message] 34 | tags(tagId: String): [ContactTag] 35 | location: Location 36 | optOut: OptOut 37 | campaign: Campaign 38 | questionResponseValues: [AnswerOption] 39 | interactionSteps: [InteractionStep] 40 | messageStatus: String 41 | assignmentId: String 42 | errorCode: Int 43 | updatedAt: Date 44 | } 45 | `; 46 | -------------------------------------------------------------------------------- /src/server/models/organization.js: -------------------------------------------------------------------------------- 1 | import thinky from "./thinky"; 2 | const type = thinky.type; 3 | import { requiredString, timestamp } from "./custom-types"; 4 | 5 | const Organization = thinky.createModel( 6 | "organization", 7 | type 8 | .object() 9 | .schema({ 10 | id: type.string(), 11 | uuid: type.string(), 12 | name: requiredString(), 13 | created_at: timestamp(), 14 | features: type 15 | .string() 16 | .required() 17 | .default(""), // should be JSON 18 | texting_hours_enforced: type 19 | .boolean() 20 | .required() 21 | .default(false), 22 | texting_hours_start: type 23 | .number() 24 | .integer() 25 | .required() 26 | .min(0) 27 | .max(24) 28 | .default(9), 29 | texting_hours_end: type 30 | .number() 31 | .integer() 32 | .required() 33 | .min(0) 34 | .max(24) 35 | .default(21) 36 | }) 37 | .allowExtra(false), 38 | { noAutoCreation: true } 39 | ); 40 | 41 | export default Organization; 42 | -------------------------------------------------------------------------------- /__test__/extensions/secret-manager/crypto.test.js: -------------------------------------------------------------------------------- 1 | import crypto from "../../../src/extensions/secret-manager/crypto"; 2 | 3 | beforeEach(() => { 4 | jest.resetModules(); 5 | }); 6 | 7 | it("encrypted value should be different", () => { 8 | const plaintext = "test_auth_token"; 9 | const encrypted = crypto.symmetricEncrypt(plaintext); 10 | expect(encrypted).not.toEqual(plaintext); 11 | }); 12 | 13 | it("decrypted value should match original", () => { 14 | const plaintext = "another_test_auth_token"; 15 | const encrypted = crypto.symmetricEncrypt(plaintext); 16 | const decrypted = crypto.symmetricDecrypt(encrypted); 17 | expect(decrypted).toEqual(plaintext); 18 | }); 19 | 20 | it("session secret must exist", () => { 21 | function encrypt() { 22 | delete global.SESSION_SECRET; 23 | const crypto2 = require("../../../src/extensions/secret-manager/crypto"); 24 | const plaintext = "foo"; 25 | const encrypted = crypto2.symmetricEncrypt(plaintext); 26 | } 27 | expect(encrypt).toThrowError( 28 | "The SESSION_SECRET environment variable must be set to use crypto functions!" 29 | ); 30 | }); 31 | -------------------------------------------------------------------------------- /migrations/20191217125726_add_message_campaign_contact_id.js: -------------------------------------------------------------------------------- 1 | // Add campaign_contact_id column to message 2 | exports.up = function(knex) { 3 | return knex.schema.alterTable("message", table => { 4 | table.integer("campaign_contact_id").unsigned(); 5 | table.foreign("campaign_contact_id").references("campaign_contact.id"); 6 | table.text("messageservice_sid"); 7 | if (!/sqlite/.test(knex.client.config.client)) { 8 | table 9 | .integer("assignment_id") 10 | .nullable() 11 | .alter(); 12 | } 13 | //table.index("campaign_contact_id"); // wait to do this in a second migration 14 | }); 15 | }; 16 | 17 | // Drop campaign_contact_id column from message 18 | exports.down = function(knex) { 19 | return knex.schema.alterTable("message", table => { 20 | table.dropForeign("campaign_contact_id"); 21 | table.dropColumn("campaign_contact_id"); 22 | table.dropColumn("messageservice_sid"); 23 | if (!/sqlite/.test(knex.client.config.client)) { 24 | table 25 | .integer("assignment_id") 26 | .notNullable() 27 | .alter(); 28 | } 29 | }); 30 | }; 31 | -------------------------------------------------------------------------------- /migrations/20240503180901_campaigncontactsupdatedat.js: -------------------------------------------------------------------------------- 1 | 2 | const { onUpdateTrigger } = require('./helpers/index') 3 | const ON_UPDATE_TIMESTAMP_FUNCTION = ` 4 | CREATE OR REPLACE FUNCTION on_update_timestamp() 5 | RETURNS trigger AS $$ 6 | BEGIN 7 | NEW.updated_at = now(); 8 | RETURN NEW; 9 | END; 10 | $$ language 'plpgsql'; 11 | ` 12 | 13 | const DROP_ON_UPDATE_TIMESTAMP_FUNCTION = `DROP FUNCTION on_update_timestamp` 14 | 15 | /** 16 | * @param { import("knex").Knex } knex 17 | */ 18 | exports.up = async function(knex) { 19 | const isSqlite = /sqlite/.test(knex.client.config.client); 20 | if (!isSqlite) { 21 | await knex.raw(ON_UPDATE_TIMESTAMP_FUNCTION); 22 | await knex.raw(onUpdateTrigger('campaign_contact')); 23 | } 24 | }; 25 | 26 | /** 27 | * @param { import("knex").Knex } knex 28 | */ 29 | exports.down = async function(knex) { 30 | const isSqlite = /sqlite/.test(knex.client.config.client); 31 | if (!isSqlite) { 32 | await knex.raw("DROP TRIGGER campaign_contact_updated_at on campaign_contact"); 33 | await knex.raw(DROP_ON_UPDATE_TIMESTAMP_FUNCTION); 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /docs/REFERENCE_ROLES_DESCRIPTION.md: -------------------------------------------------------------------------------- 1 | Previously roles on Spoke included 'Admin', 'Texter', and 'Owner'. We currently want to shift application permissions to reflect the following user stories. As of August 2018, the roles work as following: 2 | 3 | 1) Organization Administrator - This user can view all campaigns and have access to upload contacts, create campaigns, organizations and download reports 4 | 2) Campaign Admin - This user (super texter/trusted volunteer) has the ability to create a campaign and be over individual campaigns assigned to them (by org admin) 5 | 3) Super Volunteer - This user, designated by a campaign administrator, has the ability to edit campaigns in the following ways: 6 | * Edit/update campaign basics (ie title, due date). This user does not have access to campaign contact data (ex: uploading capabilities, exporting capabilities) and cannot create or copy campaigns. 7 | * Assign texters to a campaigns. 8 | * Edit the interaction script. 9 | * Create canned responses for a campaign. 10 | 4) Texter - This user can only see their todos for their assigned campaigns 11 | 5) Suspended - This user should have no access 12 | -------------------------------------------------------------------------------- /src/server/models/cacheable_queries/assignment.js: -------------------------------------------------------------------------------- 1 | import { r } from "../../models"; 2 | 3 | const cacheKey = id => `${process.env.CACHE_PREFIX || ""}a-${id}`; 4 | 5 | const load = async id => { 6 | if (r.redis) { 7 | const cachedData = await r.redis.get(cacheKey(id)); 8 | if (cachedData) { 9 | return JSON.parse(cachedData); 10 | } 11 | } 12 | const dbResult = await r 13 | .knex("assignment") 14 | .where("id", id) 15 | .first(); 16 | if (r.redis) { 17 | await r.redis 18 | .MULTI() 19 | .SET(cacheKey(id), JSON.stringify(dbResult)) 20 | .EXPIRE(cacheKey(id), 14400) // 4 hours 21 | .exec(); 22 | } 23 | return dbResult; 24 | }; 25 | 26 | const assignmentCache = { 27 | clear: async id => { 28 | if (r.redis) { 29 | await r.redis.DEL(cacheKey(id)); 30 | } 31 | }, 32 | hasAssignment: async (userId, assignmentId) => { 33 | const assignment = await load(assignmentId); 34 | // assignment is also last so it's returned 35 | return assignment && assignment.user_id === Number(userId) && assignment; 36 | }, 37 | load 38 | }; 39 | 40 | export default assignmentCache; 41 | -------------------------------------------------------------------------------- /src/components/TagChips.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import TagChip from "./TagChip"; 3 | import type from "prop-types"; 4 | import { StyleSheet, css } from "aphrodite"; 5 | 6 | const styles = StyleSheet.create({ 7 | tagChips: { 8 | display: "flex", 9 | flexWrap: "wrap" 10 | } 11 | }); 12 | 13 | const TagChips = ({ tags, tagIds, onRequestDelete, extraProps }) => ( 14 |
15 | {tagIds.map((id, i) => { 16 | const listedTag = tags.find(t => t.id == id); 17 | return ( 18 | listedTag && ( 19 | onRequestDelete(listedTag))} 23 | deleteIconStyle={{ 24 | marginBottom: "4px" 25 | }} 26 | {...(extraProps ? extraProps(listedTag, i) : {})} 27 | /> 28 | ) 29 | ); 30 | })} 31 |
32 | ); 33 | 34 | TagChips.propTypes = { 35 | tags: type.array, 36 | tagIds: type.array, 37 | extraProps: type.func, 38 | onRequestDelete: type.func 39 | }; 40 | 41 | export default TagChips; 42 | -------------------------------------------------------------------------------- /src/server/models/job-request.js: -------------------------------------------------------------------------------- 1 | import thinky from "./thinky"; 2 | const type = thinky.type; 3 | import { optionalString, requiredString, timestamp } from "./custom-types"; 4 | 5 | import Campaign from "./campaign"; 6 | 7 | const JobRequest = thinky.createModel( 8 | "job_request", 9 | type 10 | .object() 11 | .schema({ 12 | id: type.string(), 13 | organization_id: type.string(), 14 | campaign_id: type.string(), 15 | payload: requiredString(), 16 | queue_name: requiredString(), 17 | job_type: requiredString(), 18 | result_message: type.string().default(""), 19 | locks_queue: type 20 | .boolean() 21 | .required() 22 | .default(false), 23 | assigned: type 24 | .boolean() 25 | .required() 26 | .default(false), 27 | status: type 28 | .number() 29 | .integer() 30 | .required() 31 | .default(0), 32 | updated_at: timestamp(), 33 | created_at: timestamp() 34 | }) 35 | .allowExtra(false), 36 | { noAutoCreation: true } 37 | ); 38 | 39 | JobRequest.ensureIndex("queue_name"); 40 | 41 | export default JobRequest; 42 | -------------------------------------------------------------------------------- /src/extensions/message-handlers/to-ascii/index.js: -------------------------------------------------------------------------------- 1 | import { r } from "../../../server/models"; 2 | 3 | export const serverAdministratorInstructions = () => { 4 | return { 5 | description: ` 6 | If initial texts deviate from the original script then automatically 7 | unassign the rest of the contacts and set max_contacts=0 for that 8 | campaign. 9 | 10 | VETTED_TEXTERs and above will not be blocked in the same way. 11 | `, 12 | setupInstructions: `Just enable it -- only works when caching is on`, 13 | environmentVariables: [] 14 | }; 15 | }; 16 | 17 | // note this is NOT async 18 | export const available = organization => { 19 | // without caching 20 | return Boolean(r.redis); 21 | }; 22 | 23 | // export const preMessageSave = async () => {}; 24 | 25 | export const preMessageSave = async ({ messageToSave }) => { 26 | if (!messageToSave.is_from_contact && messageToSave.text) { 27 | messageToSave.text = messageToSave.text 28 | .replace(/—/g, "--") 29 | .replace(/‘|’/g, "'") 30 | .replace(/“|”|„/g, '"'); 31 | return { 32 | messageToSave, 33 | context: { messageAltered: true } 34 | }; 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /src/server/api/mutations/index.js: -------------------------------------------------------------------------------- 1 | export { bulkSendMessages } from "./bulkSendMessages"; 2 | export { bulkUpdateScript } from "./bulkUpdateScript"; 3 | export { buyPhoneNumbers, deletePhoneNumbers } from "./buyPhoneNumbers"; 4 | export { editOrganization } from "./editOrganization"; 5 | export { findNewCampaignContact } from "./findNewCampaignContact"; 6 | export { getOptOutMessage } from "./getOptOutMessage"; 7 | export { joinOrganization } from "./joinOrganization"; 8 | export { releaseContacts } from "./releaseContacts"; 9 | export { sendMessage } from "./sendMessage"; 10 | export { startCampaign } from "./startCampaign"; 11 | export { updateContactTags } from "./updateContactTags"; 12 | export { updateContactCustomFields } from "./updateContactCustomFields"; 13 | export { updateQuestionResponses } from "./updateQuestionResponses"; 14 | export { releaseCampaignNumbers } from "./releaseCampaignNumbers"; 15 | export { clearCachedOrgAndExtensionCaches } from "./clearCachedOrgAndExtensionCaches"; 16 | export { updateFeedback } from "./updateFeedback"; 17 | export { updateServiceManager } from "./updateServiceManager"; 18 | export { updateServiceVendorConfig } from "./updateServiceVendorConfig"; 19 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG BUILDER_IMAGE=node:20.11.1 2 | ARG RUNTIME_IMAGE=node:20.11.1-alpine 3 | ARG PHONE_NUMBER_COUNTRY=US 4 | 5 | FROM ${BUILDER_IMAGE} as builder 6 | 7 | ENV NODE_ENV=production \ 8 | OUTPUT_DIR=./build \ 9 | ASSETS_DIR=./build/client/assets \ 10 | ASSETS_MAP_FILE=assets.json \ 11 | PHONE_NUMBER_COUNTRY=${PHONE_NUMBER_COUNTRY} 12 | 13 | COPY . /spoke 14 | WORKDIR /spoke 15 | RUN yarn install --ignore-scripts --non-interactive --frozen-lockfile && \ 16 | yarn run prod-build && \ 17 | rm -rf node_modules && \ 18 | yarn install --production --ignore-scripts 19 | 20 | # Spoke Runtime 21 | FROM ${RUNTIME_IMAGE} 22 | WORKDIR /spoke 23 | COPY --from=builder /spoke/build build 24 | COPY --from=builder /spoke/node_modules node_modules 25 | COPY --from=builder /spoke/package.json /spoke/yarn.lock ./ 26 | ENV NODE_ENV=production \ 27 | PORT=3000 \ 28 | ASSETS_DIR=./build/client/assets \ 29 | ASSETS_MAP_FILE=assets.json \ 30 | JOBS_SAME_PROCESS=1 31 | 32 | # Switch to non-root user https://github.com/nodejs/docker-node/blob/d4d52ac41b1f922242d3053665b00336a50a50b3/docs/BestPractices.md#non-root-user 33 | USER node 34 | EXPOSE 3000 35 | CMD ["npm", "start"] 36 | -------------------------------------------------------------------------------- /__test__/server/db/export.js: -------------------------------------------------------------------------------- 1 | import { r } from "../../../src/server/models"; 2 | import { tables, indexQuery } from "./utils"; 3 | import fs from "fs"; 4 | 5 | function getSchema(s) { 6 | return r 7 | .k(s) 8 | .columnInfo() 9 | .then(schema => { 10 | console.log("exported schema for", s); 11 | fs.writeFileSync( 12 | `init_schemas/${s}.json`, 13 | JSON.stringify(schema, null, 2) 14 | ); 15 | }); 16 | } 17 | function getIndexes() { 18 | return r.k.raw(indexQuery).then(indexes => { 19 | fs.writeFileSync( 20 | "init_schemas/indexes.json", 21 | JSON.stringify(indexes, null, 2) 22 | ); 23 | console.log("exported indices"); 24 | }); 25 | } 26 | 27 | const tablePromises = tables.map(getSchema); 28 | const indexesPromises = getIndexes(); 29 | 30 | Promise.all(tablePromises.concat([indexesPromises])) 31 | .then(() => { 32 | console.log("completed"); 33 | process.exit(0); 34 | }) 35 | .catch(error => { 36 | console.error(error); 37 | process.exit(1); 38 | }); 39 | 40 | // Run this file _from this directory_ (e.g. with npx babel-node export.js) to get nice JSON representations of each table's schema, for testing. 41 | -------------------------------------------------------------------------------- /src/containers/DashboardLoader.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | import React from "react"; 3 | import { gql } from "@apollo/client"; 4 | import { withRouter } from "react-router"; 5 | import loadData from "./hoc/load-data"; 6 | 7 | class DashboardLoader extends React.Component { 8 | componentWillMount() { 9 | if (this.props.data.currentUser.organizations.length > 0) { 10 | this.props.router.push( 11 | `${this.props.path}/${this.props.data.currentUser.organizations[0].id}` 12 | ); 13 | } else { 14 | this.props.router.push("/"); 15 | } 16 | } 17 | 18 | render() { 19 | return
; 20 | } 21 | } 22 | 23 | DashboardLoader.propTypes = { 24 | data: PropTypes.object, 25 | router: PropTypes.object, 26 | path: PropTypes.string 27 | }; 28 | 29 | const queries = { 30 | data: { 31 | query: gql` 32 | query getCurrentUserForLoader { 33 | currentUser { 34 | id 35 | organizations { 36 | id 37 | } 38 | } 39 | } 40 | `, 41 | options: { 42 | fetchPolicy: "network-only" 43 | } 44 | } 45 | }; 46 | 47 | export default loadData({ queries })(withRouter(DashboardLoader)); 48 | -------------------------------------------------------------------------------- /src/server/api/mutations/startCampaign.js: -------------------------------------------------------------------------------- 1 | import { cacheableData } from "../../models"; 2 | import { accessRequired } from "../errors"; 3 | import { jobRunner } from "../../../extensions/job-runners"; 4 | import { Jobs } from "../../../workers/job-processes"; 5 | 6 | export const startCampaign = async (_, { id }, { user }) => { 7 | const campaign = await cacheableData.campaign.load(id); 8 | await accessRequired(user, campaign.organization_id, "ADMIN"); 9 | 10 | // onCampaignStart service managers get to do stuff, 11 | // before we update campaign.is_started (see workers/jobs.js::startCampaign) 12 | const userLookupField = user.lookupField || "id"; 13 | const job = await jobRunner.dispatchJob({ 14 | queue_name: `${id}:start_campaign`, 15 | job_type: Jobs.START_CAMPAIGN, 16 | locks_queue: true, 17 | campaign_id: id, 18 | payload: { 19 | userLookupField, 20 | userLookupValue: user[userLookupField], 21 | organizationId: campaign.organization_id 22 | } 23 | }); 24 | 25 | const updatedCampaign = await cacheableData.campaign.load(id); 26 | if (!updatedCampaign.is_started) { 27 | updatedCampaign.isStarting = true; 28 | } 29 | return updatedCampaign; 30 | }; 31 | -------------------------------------------------------------------------------- /src/extensions/secret-manager/index.js: -------------------------------------------------------------------------------- 1 | import { symmetricDecrypt } from "./crypto"; 2 | 3 | const SECRET_MANAGER_NAME = 4 | process.env.SECRET_MANAGER || global.SECRET_MANAGER || "default-encrypt"; 5 | const SECRET_MANAGER_COMPONENT = getSetup(SECRET_MANAGER_NAME); 6 | 7 | function getSetup(name) { 8 | try { 9 | const c = require(`./${name}/index.js`); 10 | return c; 11 | } catch (err) { 12 | console.error("SECRET_MANAGER failed to load", name, err); 13 | } 14 | } 15 | 16 | export async function getSecret(name, token, organization) { 17 | if (token.startsWith(SECRET_MANAGER_NAME)) { 18 | return await SECRET_MANAGER_COMPONENT.getSecret( 19 | name, 20 | token.slice(SECRET_MANAGER_NAME.length + 1), 21 | organization 22 | ); 23 | } else { 24 | // legacy fallback 25 | return symmetricDecrypt(token); 26 | } 27 | } 28 | 29 | export async function convertSecret(name, organization, secretValue) { 30 | // returns token, which the caller is still responsible for saving somewhere 31 | const token = await SECRET_MANAGER_COMPONENT.convertSecret( 32 | name, 33 | organization, 34 | secretValue 35 | ); 36 | return `${SECRET_MANAGER_NAME}|${token}`; 37 | } 38 | -------------------------------------------------------------------------------- /src/components/utils.jsx: -------------------------------------------------------------------------------- 1 | import pick from "lodash/pick"; 2 | 3 | export function dataSourceItem(name, key) { 4 | return { 5 | text: name, 6 | rawValue: key 7 | }; 8 | } 9 | 10 | export function getButtonProps(props) { 11 | const validProps = [ 12 | "children", 13 | "classes", 14 | "color", 15 | "component", 16 | "disabled", 17 | "disableElevation", 18 | "disableFocusRipple", 19 | "disableRipple", 20 | "endIcon", 21 | "fullWidth", 22 | "href", 23 | "size", 24 | "startIcon", 25 | "variant", 26 | "data-test" 27 | ]; 28 | return pick(props, validProps); 29 | } 30 | 31 | // Create a deep copy of an object so nested properties are also mutable 32 | export function deepCopy(obj) { 33 | if (Array.isArray(obj)) { 34 | return obj.map(item => deepCopy(item)); 35 | } else if (typeof obj === "object" && obj !== null) { 36 | return Object.fromEntries( 37 | Object.entries(obj).map(([key, value]) => [key, deepCopy(value)]) 38 | ); 39 | } else { 40 | return obj; 41 | } 42 | } 43 | 44 | // Convert an array of strings to an array of integers 45 | export function convertToInt(array) { 46 | return array.map(str => parseInt(str)); 47 | } 48 | -------------------------------------------------------------------------------- /src/network/errors.js: -------------------------------------------------------------------------------- 1 | export function GraphQLRequestError(err) { 2 | this.name = this.constructor.name; 3 | this.message = err.message; 4 | this.status = err.status; 5 | this.stack = new Error().stack; 6 | } 7 | GraphQLRequestError.prototype = Object.create(Error.prototype); 8 | GraphQLRequestError.prototype.constructor = GraphQLRequestError; 9 | 10 | export function graphQLErrorParser(response) { 11 | if (response.errors && response.errors.length > 0) { 12 | const error = response.errors[0]; 13 | let parsedError = null; 14 | try { 15 | parsedError = JSON.parse(error.message); 16 | } catch (ex) { 17 | // Even if we can't parse an error messge into JSON, still render it as a string 18 | // so that we still display some error message instead of no error message at all. 19 | parsedError = { status: 500, message: error.message }; 20 | } 21 | if (parsedError) { 22 | return { 23 | status: parsedError.status, 24 | message: parsedError.message 25 | }; 26 | } 27 | return { 28 | status: 500, 29 | message: 30 | "There was an error with your request. Try again in a little bit!" 31 | }; 32 | } 33 | return null; 34 | } 35 | -------------------------------------------------------------------------------- /src/server/models/pending-message-part.js: -------------------------------------------------------------------------------- 1 | import thinky from "./thinky"; 2 | const type = thinky.type; 3 | import { requiredString, optionalString, timestamp } from "./custom-types"; 4 | 5 | // this mostly exists because of: 6 | // https://help.nexmo.com/hc/en-us/articles/205704158-Inbound-SMS-concatenation 7 | // Recommended handling is here: 8 | // https://docs.nexmo.com/messaging/sms-api 9 | // Twilio auto-assembles it for us (thank you!), so this isn't an issue for twilio 10 | 11 | const PendingMessagePart = thinky.createModel( 12 | "pending_message_part", 13 | type 14 | .object() 15 | .schema({ 16 | id: type.string(), 17 | service: requiredString(), 18 | service_id: requiredString().stopReference(), 19 | parent_id: optionalString() 20 | .allowNull(true) 21 | .stopReference(), 22 | service_message: requiredString(), // JSON 23 | user_number: optionalString(), 24 | contact_number: requiredString(), 25 | created_at: timestamp() 26 | }) 27 | .allowExtra(false), 28 | { noAutoCreation: true } 29 | ); 30 | 31 | PendingMessagePart.ensureIndex("parent_id"); 32 | PendingMessagePart.ensureIndex("service"); 33 | 34 | export default PendingMessagePart; 35 | -------------------------------------------------------------------------------- /src/components/forms/GSDateField.jsx: -------------------------------------------------------------------------------- 1 | import "date-fns"; 2 | import React from "react"; 3 | import moment from "moment"; 4 | import GSFormField from "./GSFormField"; 5 | import { dataTest } from "../../lib/attributes"; 6 | import DateFnsUtils from "@date-io/date-fns"; 7 | import { 8 | MuiPickersUtilsProvider, 9 | KeyboardDatePicker 10 | } from "@material-ui/pickers"; 11 | import theme from "../../styles/mui-theme"; 12 | 13 | export default class GSDateField extends GSFormField { 14 | state = { open: false }; 15 | 16 | render() { 17 | return ( 18 | 19 | { 22 | this.setState({ open: true }); 23 | evt.preventDefault(); 24 | }} 25 | onClose={() => this.setState({ open: false })} 26 | onOpen={() => this.setState({ open: true })} 27 | autoOk 28 | format="MM/dd/yyyy" 29 | label={this.floatingLabelText()} 30 | style={{ 31 | marginBottom: theme.spacing(2) 32 | }} 33 | open={this.state.open} 34 | {...this.props} 35 | /> 36 | 37 | ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/components/SendButton.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | import React, { Component } from "react"; 3 | import Button from "@material-ui/core/Button"; 4 | import { StyleSheet, css } from "aphrodite"; 5 | import { dataTest } from "../lib/attributes"; 6 | 7 | // This is because the Toolbar from material-ui seems to only apply the correct margins if the 8 | // immediate child is a Button or other type it recognizes. Can get rid of this if we remove material-ui 9 | const styles = StyleSheet.create({ 10 | container: { 11 | display: "inline-block", 12 | marginLeft: 24 13 | } 14 | }); 15 | class SendButton extends Component { 16 | render() { 17 | return ( 18 |
19 | 28 |
29 | ); 30 | } 31 | } 32 | 33 | SendButton.propTypes = { 34 | onClick: PropTypes.func, 35 | disabled: PropTypes.bool, 36 | doneFirstClick: PropTypes.bool 37 | }; 38 | 39 | export default SendButton; 40 | -------------------------------------------------------------------------------- /src/components/TexterDashboard.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | import React from "react"; 3 | import { StyleSheet, css } from "aphrodite"; 4 | import theme from "../styles/theme"; 5 | import { withRouter } from "react-router"; 6 | 7 | const styles = StyleSheet.create({ 8 | container: { 9 | ...theme.layouts.multiColumn.container 10 | }, 11 | content: { 12 | ...theme.layouts.multiColumn.flexColumn, 13 | paddingLeft: "2rem", 14 | paddingRight: "2rem", 15 | margin: "24px auto" 16 | } 17 | }); 18 | 19 | class TexterDashboard extends React.Component { 20 | render() { 21 | const { main, topNav, fullScreen } = this.props; 22 | return ( 23 | fullScreen || ( 24 |
25 | {topNav} 26 |
27 |
{main}
28 |
29 |
30 | ) 31 | ); 32 | } 33 | } 34 | 35 | TexterDashboard.propTypes = { 36 | router: PropTypes.object, 37 | params: PropTypes.object, 38 | children: PropTypes.object, 39 | location: PropTypes.object, 40 | main: PropTypes.element, 41 | topNav: PropTypes.element, 42 | fullScreen: PropTypes.object 43 | }; 44 | 45 | export default withRouter(TexterDashboard); 46 | -------------------------------------------------------------------------------- /src/server/models/cacheable_queries/opt-out-message.js: -------------------------------------------------------------------------------- 1 | import { r } from "../../models"; 2 | 3 | const cacheKey = (orgId, state) => 4 | `${process.env.CACHE_PREFIX || ""}optoutmessages-${orgId}-${state}`; 5 | 6 | const optOutMessageCache = { 7 | clearQuery: async ({ organizationId, state }) => { 8 | if (r.redis) { 9 | await r.redis.del(cacheKey(organizationId, state)); 10 | } 11 | }, 12 | query: async ({ organizationId, state }) => { 13 | async function getMessage() { 14 | const res = await r 15 | .knex("opt_out_message") 16 | .select("message") 17 | .where({ state: state }) 18 | .limit(1); 19 | 20 | return res.length ? res[0].message : ""; 21 | } 22 | if (r.redis) { 23 | const key = cacheKey(organizationId, state); 24 | let message = await r.redis.get(key); 25 | 26 | if (message !== null) { 27 | return message; 28 | } 29 | 30 | message = await getMessage(); 31 | 32 | await r.redis 33 | .multi() 34 | .set(key, message) 35 | .expire(key, 15780000) // 6 months 36 | .execAsync(); 37 | 38 | return message; 39 | } 40 | 41 | return await getMessage(); 42 | } 43 | }; 44 | 45 | export default optOutMessageCache; 46 | -------------------------------------------------------------------------------- /migrations/20200512143258_add_user_roles.js: -------------------------------------------------------------------------------- 1 | // Add VETTED_TEXTER, ORG_SUPERADMIN, and SUSPENDED as a values in the `role` enumeration 2 | exports.up = knex => { 3 | const isSqlite = /sqlite/.test(knex.client.config.client); 4 | if (isSqlite) { 5 | return Promise.resolve(); 6 | } 7 | return knex.schema.raw(` 8 | ALTER TABLE "user_organization" DROP CONSTRAINT IF EXISTS "user_organization_role_check"; 9 | ALTER TABLE "user_organization" ADD CONSTRAINT "user_organization_role_check" CHECK (role IN ( 10 | 'OWNER'::text, 11 | 'ADMIN'::text, 12 | 'SUPERVOLUNTEER'::text, 13 | 'TEXTER'::text, 14 | 'VETTED_TEXTER'::text, 15 | 'ORG_SUPERADMIN'::text, 16 | 'SUSPENDED'::text 17 | )) 18 | `); 19 | }; 20 | 21 | exports.down = knex => { 22 | const isSqlite = /sqlite/.test(knex.client.config.client); 23 | if (isSqlite) { 24 | return Promise.resolve(); 25 | } 26 | return knex.schema.raw(` 27 | ALTER TABLE "user_organization" DROP CONSTRAINT IF EXISTS "user_organization_role_check"; 28 | ALTER TABLE "user_organization" ADD CONSTRAINT "user_organization_role_check" CHECK (role IN ( 29 | 'OWNER'::text, 30 | 'ADMIN'::text, 31 | 'SUPERVOLUNTEER'::text, 32 | 'TEXTER'::text 33 | )) 34 | `); 35 | }; 36 | -------------------------------------------------------------------------------- /src/components/forms/GSSubmitButton.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | import React from "react"; 3 | import Button from "@material-ui/core/Button"; 4 | import CircularProgress from "@material-ui/core/CircularProgress"; 5 | import { getButtonProps } from "../utils"; 6 | 7 | const styles = { 8 | button: { 9 | marginTop: 15, 10 | display: "inline-block" 11 | } 12 | }; 13 | 14 | const GSSubmitButton = props => { 15 | let icon = ""; 16 | const extraProps = {}; 17 | if (props.isSubmitting) { 18 | extraProps.disabled = true; 19 | icon = ( 20 | 28 | ); 29 | } 30 | 31 | return ( 32 |
33 | 43 | {icon} 44 |
45 | ); 46 | }; 47 | 48 | GSSubmitButton.propTypes = { 49 | isSubmitting: PropTypes.bool 50 | }; 51 | 52 | export default GSSubmitButton; 53 | -------------------------------------------------------------------------------- /src/extensions/dynamicassignment-batches/all-texters/index.js: -------------------------------------------------------------------------------- 1 | import { cacheableData } from "../../../server/models"; 2 | 3 | export const name = "all-texters"; 4 | 5 | export const displayName = () => "Everyone can request a new batch"; 6 | 7 | export const requestNewBatchCount = async ({ 8 | organization, 9 | campaign, 10 | assignment, 11 | texter, 12 | r, 13 | cacheableData, 14 | loaders, 15 | hasAny 16 | }) => { 17 | // START WITH SOME BASE-LINE THINGS EVERY POLICY SHOULD HAVE 18 | if (!campaign.use_dynamic_assignment || campaign.is_archived) { 19 | return 0; 20 | } 21 | if (assignment.max_contacts === 0 || !campaign.batch_size) { 22 | return 0; 23 | } 24 | 25 | const countQuery = r 26 | .knex("campaign_contact") 27 | .where({ 28 | campaign_id: campaign.id, 29 | is_opted_out: false, 30 | message_status: "needsMessage" 31 | }) 32 | .whereNull("assignment_id"); 33 | 34 | let availableCount = 0; 35 | if (hasAny) { 36 | availableCount = assignment.hasOwnProperty("hasUnassigned") 37 | ? assignment.hasUnassigned 38 | : (await countQuery.select("id").first()) 39 | ? 1 40 | : 0; 41 | } else { 42 | availableCount = await r.getCount(countQuery); 43 | } 44 | return availableCount; 45 | }; 46 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Spoke Documentation Hub 2 | 3 | Welcome to the Spoke documentation hub! This is the documentation micro-site for [Spoke's docs folder](https://github.com/StateVoicesNational/Spoke/tree/main/docs) to make the docs more readable and user-friendly. 4 | 5 | Spoke is an open source text-distribution tool for organizations to mobilize supporters and members into action. Spoke allows you to upload phone numbers, customize scripts and assign volunteers to communicate with supporters while allowing organizations to manage the process. 6 | 7 | ## How to contribute to the Spoke docs 8 | [Any issues tagged with the C-documentation tag](https://github.com/StateVoicesNational/Spoke/issues?q=is%3Aopen+is%3Aissue+label%3AC-documentation) are feature requests for docs that we'd love for you to add! We also have a sidebar section that reads "Outlines of docs we wish we had - contributors welcome!" that we'd love for folks to flesh out. 9 | 10 | You can add a doc by making a pull request to [Spoke's repo](https://github.com/StateVoicesNational/Spoke) with changes to the doc. If you're adding a new doc, please add it to the [sidebar.md file](_sidebar.md) to make sure it shows up in the doc table of contents. 11 | 12 | We also accept PRs that want to reorder docs in the table of contents to help with readability! -------------------------------------------------------------------------------- /src/server/api/interaction-step.js: -------------------------------------------------------------------------------- 1 | import { mapFieldsToModel } from "./lib/utils"; 2 | import { InteractionStep, r } from "../models"; 3 | import { accessRequired } from "./errors"; 4 | 5 | export const resolvers = { 6 | InteractionStep: { 7 | ...mapFieldsToModel( 8 | [ 9 | "id", 10 | "script", 11 | "answerOption", 12 | "answerActions", 13 | "parentInteractionId", 14 | "isDeleted" 15 | ], 16 | InteractionStep 17 | ), 18 | questionText: async interactionStep => { 19 | return interactionStep.question; 20 | }, 21 | question: async interactionStep => interactionStep, 22 | questionResponse: async (interactionStep, { campaignContactId }) => { 23 | return r 24 | .table("question_response") 25 | .getAll(campaignContactId, { index: "campaign_contact_id" }) 26 | .filter({ 27 | interaction_step_id: interactionStep.id 28 | }) 29 | .limit(1)(0) 30 | .default(null); 31 | }, 32 | answerActionsData: async (interactionStep, _, { user, loaders }) => { 33 | const campaign = await loaders.campaign.load(interactionStep.campaign_id); 34 | await accessRequired(user, campaign.organization_id, "SUPERVOLUNTEER"); 35 | return interactionStep.answer_actions_data; 36 | } 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /__test__/components/ScriptEditor.test.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { shallow } from "enzyme"; 3 | import { Editor } from "draft-js"; 4 | 5 | import ScriptEditor from "../../src/components/ScriptEditor"; 6 | import { sleep } from "../test_helpers"; 7 | 8 | describe("ScriptEditor component", () => { 9 | test("re-encodes unicode easy-wins for GSM", () => { 10 | const wrapper = shallow( 11 | {}} 15 | name={"Canned Response"} 16 | /> 17 | ); 18 | const editor = wrapper.find(Editor); 19 | expect(editor.prop("name")).toBe("Canned Response"); 20 | expect(wrapper.instance().getValue()).toBe('f*xx and "foo" - dash'); 21 | }); 22 | 23 | test("readyToAdd is delayed to avoid mouse race-conditions", async () => { 24 | const wrapper = shallow( 25 | {}} 29 | name={"Canned Response"} 30 | /> 31 | ); 32 | expect(wrapper.state().readyToAdd).toEqual(false); 33 | await sleep(300); // wait until it's ready: see readyToAdd in component 34 | expect(wrapper.state().readyToAdd).toEqual(true); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /src/components/SelectedCampaigns.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | import React from "react"; 3 | import { StyleSheet, css } from "aphrodite"; 4 | import Chip from "@material-ui/core/Chip"; 5 | import CloseIcon from "@material-ui/icons/Close"; 6 | 7 | const ssStyles = StyleSheet.create({ 8 | container: { 9 | display: "flex", 10 | flexWrap: "wrap" 11 | } 12 | }); 13 | 14 | const styles = { 15 | chip: { 16 | margin: 6 17 | } 18 | }; 19 | 20 | const SelectedCampaigns = props => ( 21 |
22 | {props.campaigns.length > 0 && ( 23 | 29 | )} 30 | {props.campaigns.map((campaign, index) => ( 31 | props.onDeleteRequested(campaign.key)} 35 | deleteIcon={} 36 | label={campaign.text} 37 | /> 38 | ))} 39 |
40 | ); 41 | 42 | SelectedCampaigns.propTypes = { 43 | campaigns: PropTypes.array.isRequired, 44 | onDeleteRequested: PropTypes.func.isRequired, 45 | onClear: PropTypes.func.isRequired 46 | }; 47 | 48 | export default SelectedCampaigns; 49 | -------------------------------------------------------------------------------- /src/extensions/job-runners/lambda-async/index.js: -------------------------------------------------------------------------------- 1 | import { Lambda } from "@aws-sdk/client-lambda"; 2 | import { saveJob } from "../helpers"; 3 | 4 | const functionName = 5 | process.env.WORKER_LAMBDA_FUNCTION_NAME || 6 | process.env.AWS_LAMBDA_FUNCTION_NAME; 7 | 8 | const client = new Lambda(); 9 | 10 | export const fullyConfigured = () => !!functionName; 11 | 12 | export const dispatchJob = async ( 13 | { queue_name, job_type, organization_id, campaign_id, payload }, 14 | opts = {} 15 | ) => { 16 | const job = await saveJob( 17 | { 18 | // locking the queue is not supported 19 | // TODO: think about how that functionality should be handled 20 | locks_queue: false, 21 | assigned: true, 22 | organization_id, 23 | campaign_id, 24 | queue_name, 25 | payload, 26 | job_type 27 | }, 28 | opts.trx 29 | ); 30 | 31 | await client 32 | .invoke({ 33 | FunctionName: functionName, 34 | InvocationType: "Event", 35 | Payload: JSON.stringify({ type: "JOB", jobId: job.id }) 36 | }); 37 | return job; 38 | }; 39 | 40 | export const dispatchTask = async (taskName, payload) => { 41 | await client 42 | .invoke({ 43 | FunctionName: functionName, 44 | InvocationType: "Event", 45 | Payload: JSON.stringify({ type: "TASK", taskName, payload }) 46 | }); 47 | }; 48 | -------------------------------------------------------------------------------- /docs/HOWTO_USE_POSTGRESQL.md: -------------------------------------------------------------------------------- 1 | # How to set up Spoke with Postgresql 2 | 3 | To use Postgresql, follow these steps: 4 | 5 | 1. Either install docker (recommended) or postgresql on your machine: 6 | * If you installed docker run the database using: `docker-compose up` 7 | * If you installed postgres locally (or if you already have a local installation of postgres), create the spoke dev database: `psql -c "create database spokedev;"` 8 | * Then create a spoke user to connect to the database with `psql -d spokedev -c "create user spoke with password 'spoke';"` (to match the credentials in the .env.example file) 9 | * Grant permissions to the new Spoke user: 10 | * `psql -d spokedev -c "GRANT ALL PRIVILEGES ON DATABASE spokedev TO spoke;"` 11 | * `psql -d spokedev -c "GRANT ALL PRIVILEGES ON schema public TO spoke;"` 12 | * Create a test database by running the following commands: 13 | ``` 14 | psql -d spokedev 15 | CREATE DATABASE spoke_test; 16 | CREATE USER spoke_test WITH PASSWORD 'spoke_test'; 17 | GRANT ALL PRIVILEGES ON DATABASE spoke_test TO spoke_test; 18 | ``` 19 | 1. In `.env` set `DB_TYPE=pg`. (Otherwise, you will use sqlite.) 20 | 2. Set `DB_PORT=5432`, which is the default port for Postgres. 21 | 22 | That's all you need to do initially. The tables will be created the first time the app runs. 23 | -------------------------------------------------------------------------------- /__test__/components/OrganizationJoinLink.test.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { shallow } from "enzyme"; 4 | import OrganizationJoinLink from "../../src/components/OrganizationJoinLink"; 5 | //Tests A and B check to see if the invite text 6 | //statement changes depending on wether a campaignID prop is passed 7 | describe("OrganizationJoinLink tests A", () => { 8 | test("finds proper text for invite when campaignId is present", () => { 9 | const wrapper = shallow( 10 | 11 | ); 12 | const inviteText = wrapper.find("DisplayLink"); 13 | // then 14 | expect(inviteText.prop("textContent")).toBe( 15 | "Send your texting volunteers this link! Once they sign up, they'll be automatically assigned to this campaign." 16 | ); 17 | }); 18 | }); 19 | 20 | describe("OrganizationJoinLink tests B", () => { 21 | test("finds proper text for invite when capaignId is null", () => { 22 | const wrapper = shallow( 23 | 24 | ); 25 | const inviteText = wrapper.find("DisplayLink"); 26 | // then 27 | expect(inviteText.prop("textContent")).toBe( 28 | "Send your texting volunteers this link! Once they sign up, they'll be automatically assigned to this organization." 29 | ); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /src/extensions/dynamicassignment-batches/index.js: -------------------------------------------------------------------------------- 1 | import { getConfig } from "../../server/api/lib/config"; 2 | 3 | // Checks the gloabl var DYNAMICASSIGNMENT_BATCHES and 4 | // whether the handler loads, similar to how texter-sideboxes works 5 | 6 | // https://github.com/StateVoicesNational/Spoke/blob/main/docs/HOWTO-use-dynamicassignment-batches.md 7 | 8 | export const getDynamicAssignmentBatchPolicies = ({ 9 | organization, 10 | campaign 11 | }) => { 12 | const handlerKey = "DYNAMICASSIGNMENT_BATCHES"; 13 | const campaignEnabled = getConfig(handlerKey, campaign, { onlyLocal: true }); 14 | const configuredHandlers = 15 | campaignEnabled || 16 | getConfig(handlerKey, organization) || 17 | "finished-replies-tz,vetted-texters,finished-replies"; 18 | const enabledHandlers = 19 | (configuredHandlers && configuredHandlers.split(",")) || []; 20 | if (!campaignEnabled) { 21 | // remove everything except the first one for non-campaign enabled choices 22 | enabledHandlers.splice(1); 23 | } 24 | 25 | const handlers = []; 26 | enabledHandlers.forEach(name => { 27 | try { 28 | const c = require(`./${name}/index.js`); 29 | handlers.push(c); 30 | } catch (err) { 31 | console.error( 32 | `${handlerKey} failed to load dynamicassignment-batches handler ${name} -- ${err}` 33 | ); 34 | } 35 | }); 36 | return handlers; 37 | }; 38 | -------------------------------------------------------------------------------- /__test__/components/TagChips.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | import React from "react"; 5 | import { mount } from "enzyme"; 6 | import TagChips from "../../src/components/TagChips"; 7 | import { StyleSheetTestUtils } from "aphrodite"; 8 | import ThemeContext from "../../src/containers/context/ThemeContext"; 9 | import { muiTheme } from "../test_helpers"; 10 | 11 | describe("TagChips component", () => { 12 | // given 13 | 14 | const tags = [ 15 | { 16 | id: 1, 17 | name: "Tag1" 18 | }, 19 | { 20 | id: 2, 21 | name: "Tag2" 22 | } 23 | ]; 24 | 25 | const tagIds = [1, 2, 3]; 26 | 27 | StyleSheetTestUtils.suppressStyleInjection(); 28 | const wrapper = mount( 29 | 30 | 31 | 32 | ); 33 | 34 | // when 35 | 36 | test("Renders TagChip components with correct text if a tagId is present", () => { 37 | expect( 38 | wrapper 39 | .find("TagChip") 40 | .at(0) 41 | .prop("text") 42 | ).toBe("Tag1"); 43 | expect( 44 | wrapper 45 | .find("TagChip") 46 | .at(1) 47 | .prop("text") 48 | ).toBe("Tag2"); 49 | expect( 50 | wrapper 51 | .find("TagChip") 52 | .at(2) 53 | .exists() 54 | ).toBeFalsy(); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /docs/EXPLANATION_DECIDING_ON_SPOKE.md: -------------------------------------------------------------------------------- 1 | # Deciding to use Spoke 2 | 3 | You should treat all tech infrastructure choices as 'build vs buy' decisions, and make the right choice for your organization. 4 | 5 | ## Reasons to run Spoke: 6 | 1. You want to run a peer-to-peer textbanking system at scale and pay only infrastructure costs 7 | 1. You have the tech capacity to do the technical deployment work to set up Spoke and maintain your instance over time. 8 | 1. You want to be able to control costs and system scaling on your own terms. Perhaps you have experienced scaling problems from a textbanking vendor at an inopportune time. You can choose to control this risk by spending developer time and money on hardware running your own system. 9 | 1. You care about the open source community, want to give back to the progressive movement, and are interested in contributing back fixes and features. 10 | 1. You don't want to sign a contract with a vendor, and may have a more short term need for peer to peer texting. 11 | 12 | ## Reasons to not run Spoke: 13 | 1. You have straightforward and non-bursty scaling needs that are well documented, that a vendor can meet for you. (We recommend contractually binding vendors to SLAs that ensure you get the service you have been promised) 14 | 1. You have more money than developer time to spend. 15 | 1. You don't have an organizational need to control costs, system scaling, or vendor risk. -------------------------------------------------------------------------------- /src/components/PeopleList/RolesDropdown.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import type from "prop-types"; 3 | 4 | import Select from "@material-ui/core/Select"; 5 | import MenuItem from "@material-ui/core/MenuItem"; 6 | 7 | import { getHighestRole, ROLE_HIERARCHY } from "../../lib"; 8 | 9 | const RolesDropdown = props => ( 10 | 38 | ); 39 | 40 | RolesDropdown.propTypes = { 41 | roles: type.array, 42 | texterId: type.string, 43 | currentUser: type.object, 44 | onChange: type.func 45 | }; 46 | 47 | export default RolesDropdown; 48 | -------------------------------------------------------------------------------- /src/components/Search.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | import React from "react"; 3 | import SearchBar from "material-ui-search-bar"; 4 | 5 | class Search extends React.Component { 6 | constructor(props) { 7 | super(props); 8 | this.state = { 9 | searchString: props.searchString 10 | }; 11 | } 12 | 13 | handleSearchStringChanged = searchString => { 14 | const trimmedSearchString = searchString.trim(); 15 | if (!!this.state.searchString && !trimmedSearchString) { 16 | this.props.onSearchRequested(undefined); 17 | } 18 | this.setState({ searchString }); 19 | }; 20 | 21 | handleSearchRequested = () => { 22 | this.props.onSearchRequested(this.state.searchString); 23 | }; 24 | 25 | onCancelSearch = () => { 26 | this.handleSearchStringChanged(""); 27 | this.props.onSearchRequested(""); 28 | }; 29 | 30 | render = () => ( 31 | 38 | ); 39 | } 40 | 41 | Search.propTypes = { 42 | searchString: PropTypes.string, 43 | onSearchRequested: PropTypes.func.isRequired, 44 | hintText: PropTypes.string, 45 | placeholder: PropTypes.string 46 | }; 47 | 48 | export default Search; 49 | -------------------------------------------------------------------------------- /src/components/PeopleList/ResetPasswordDialog.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | import DialogTitle from "@material-ui/core/DialogTitle"; 5 | import Dialog from "@material-ui/core/Dialog"; 6 | import DialogActions from "@material-ui/core/DialogActions"; 7 | import DialogContent from "@material-ui/core/DialogContent"; 8 | 9 | import PasswordResetLink from "../../components/PasswordResetLink"; 10 | import Button from "@material-ui/core/Button"; 11 | import { dataTest } from "../../lib/attributes"; 12 | 13 | const ResetPasswordDialog = props => ( 14 | 15 | Reset user password 16 | 17 | 18 | {props.isAuth0 ? ( 19 |
The user has been sent an Auth0 password reset link!
20 | ) : ( 21 | 22 | )} 23 |
24 | 25 | 32 | 33 |
34 | ); 35 | 36 | ResetPasswordDialog.propTypes = { 37 | open: PropTypes.bool, 38 | requestClose: PropTypes.func, 39 | passwordResetHash: PropTypes.string, 40 | isAuth0: PropTypes.bool 41 | }; 42 | 43 | export default ResetPasswordDialog; 44 | -------------------------------------------------------------------------------- /src/server/api/mutations/clearCachedOrgAndExtensionCaches.js: -------------------------------------------------------------------------------- 1 | import { accessRequired } from "../errors"; 2 | import { clearCacheForOrganization as clearContactLoaderCaches } from "../../../extensions/contact-loaders"; 3 | import { clearCacheForOrganization as clearActionHandlerCaches } from "../../../extensions/action-handlers"; 4 | import cacheable from "../../../server/models/cacheable_queries"; 5 | import { r } from "../../../server/models"; 6 | 7 | export const clearCachedOrgAndExtensionCaches = async ( 8 | _, 9 | { organizationId }, 10 | { user } 11 | ) => { 12 | await accessRequired(user, organizationId, "OWNER"); 13 | 14 | if (!r.redis) { 15 | return "Redis not configured. No need to clear organization caches"; 16 | } 17 | 18 | try { 19 | await cacheable.organization.clear(organizationId); 20 | await cacheable.organization.load(organizationId); 21 | } catch (caught) { 22 | // eslint-disable-next-line no-console 23 | console.error(`Error while clearing organization cache. ${caught}`); 24 | } 25 | 26 | const promises = [ 27 | clearActionHandlerCaches(organizationId), 28 | clearContactLoaderCaches(organizationId) 29 | ]; 30 | 31 | try { 32 | await Promise.all(promises); 33 | } catch (caught) { 34 | // eslint-disable-next-line no-console 35 | console.error(`Error while clearing extension caches. ${caught}`); 36 | } 37 | 38 | return "Cleared organization and extension caches"; 39 | }; 40 | -------------------------------------------------------------------------------- /src/components/OrganizationWrapper.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { withRouter } from "react-router"; 3 | import { gql } from "@apollo/client"; 4 | 5 | import loadData from "../containers/hoc/load-data"; 6 | import withSetTheme from "../containers/hoc/withSetTheme"; 7 | 8 | const OrganizationWrapper = ({ children, ...props }) => { 9 | return {children}; 10 | }; 11 | 12 | const queries = { 13 | organization: { 14 | query: gql` 15 | query getOrganization($id: String!) { 16 | organization(id: $id) { 17 | id 18 | theme 19 | tags { 20 | id 21 | name 22 | } 23 | } 24 | } 25 | `, 26 | options: ownProps => { 27 | return { 28 | variables: { 29 | id: ownProps.params.organizationId 30 | }, 31 | fetchPolicy: "network-only" 32 | }; 33 | } 34 | } 35 | }; 36 | 37 | export const operations = { queries }; 38 | 39 | class EnhancedOrganizationWrapper extends React.Component { 40 | componentDidMount() { 41 | this.props.setTheme(this.props.organization.organization.theme); 42 | } 43 | 44 | componentWillUnmount() { 45 | this.props.setTheme(undefined); 46 | } 47 | 48 | render() { 49 | return ; 50 | } 51 | } 52 | 53 | export default loadData(operations)( 54 | withSetTheme(withRouter(EnhancedOrganizationWrapper)) 55 | ); 56 | -------------------------------------------------------------------------------- /__test__/containers/Tags.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | import React from "react"; 5 | import { mount } from "enzyme"; 6 | import { Tags } from "../../src/containers/Tags"; 7 | import { StyleSheetTestUtils } from "aphrodite"; 8 | import Card from "@material-ui/core/Card"; 9 | 10 | describe("Tags list with several tags", () => { 11 | // given 12 | 13 | const data = { 14 | organization: { 15 | tags: [ 16 | { 17 | id: "1", 18 | name: "Tag1", 19 | description: "Tag1desc", 20 | isDeleted: false, 21 | organizationId: "1", 22 | group: null 23 | }, 24 | { 25 | id: "2", 26 | name: "Tag2", 27 | description: "Tag2desc", 28 | isDeleted: false, 29 | organizationId: "1", 30 | group: null 31 | } 32 | ] 33 | } 34 | }; 35 | 36 | // when 37 | test("Renders cards with tag name and description", () => { 38 | StyleSheetTestUtils.suppressStyleInjection(); 39 | const wrapper = mount(); 40 | const card1 = wrapper.find({ id: "1" }).find(Card); 41 | expect(card1.text().includes("Tag1")).toBeTruthy(); 42 | expect(card1.text().includes("Tag1desc")).toBeTruthy(); 43 | const card2 = wrapper.find({ id: "2" }).find(Card); 44 | expect(card2.text().includes("Tag2")).toBeTruthy(); 45 | expect(card2.text().includes("Tag2desc")).toBeTruthy(); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /__test__/containers/UserEdit.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | import React from "react"; 5 | import { mount } from "enzyme"; 6 | import { UserEditBase as UserEdit } from "../../src/containers/UserEdit"; 7 | import { StyleSheetTestUtils } from "aphrodite"; 8 | import { muiTheme } from "../test_helpers"; 9 | import ThemeContext from "../../src/containers/context/ThemeContext"; 10 | 11 | describe("User edit page", () => { 12 | StyleSheetTestUtils.suppressStyleInjection(); 13 | const wrapper = mount( 14 | 15 | 16 | 17 | ); 18 | 19 | test("Render edit page with standard fields", () => { 20 | expect(wrapper.exists({ name: "email" })).toBeTruthy(); 21 | expect(wrapper.exists({ name: "firstName" })).toBeTruthy(); 22 | expect(wrapper.exists({ name: "extra.foo" })).toBeFalsy(); 23 | }); 24 | 25 | test("Render edit page with extra profile fields", () => { 26 | const org = { 27 | organization: { 28 | profileFields: [ 29 | { 30 | name: "foo", 31 | label: "Foo" 32 | } 33 | ] 34 | } 35 | }; 36 | wrapper.find(UserEdit).setState({ currentOrg: org }); 37 | 38 | expect(wrapper.exists({ name: "email" })).toBeTruthy(); 39 | expect(wrapper.exists({ name: "firstName" })).toBeTruthy(); 40 | expect(wrapper.exists({ name: "extra.foo" })).toBeTruthy(); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/components/Chip.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | import React from "react"; 3 | import _ from "lodash"; 4 | 5 | // Credit to materialize CSS 6 | // Material UI coming out with Chip 7 | const styles = { 8 | chip: { 9 | display: "inline-block", 10 | height: "32px", 11 | fontSize: "13px", 12 | fontWeight: "500", 13 | color: "rgba(0,0,0,0.6)", 14 | lineHeight: "32px", 15 | padding: "0 12px", 16 | borderRadius: "16px", 17 | backgroundColor: "#e4e4e4", 18 | margin: 5 19 | }, 20 | icon: { 21 | cursor: "pointer", 22 | float: "right", 23 | fontSize: "16px", 24 | lineHeight: "32px", 25 | height: "30px", 26 | width: "16px", 27 | paddingLeft: "8px", 28 | color: "rgba(0,0,0,0.6)" 29 | } 30 | }; 31 | 32 | function Chip({ 33 | text, 34 | iconRightClass, 35 | onIconRightTouchTap, 36 | onClick, 37 | style = {} 38 | }) { 39 | return ( 40 |
41 | {text} 42 | {iconRightClass 43 | ? React.createElement(iconRightClass, { 44 | style: styles.icon, 45 | onClick: onIconRightTouchTap 46 | }) 47 | : ""} 48 |
49 | ); 50 | } 51 | 52 | Chip.propTypes = { 53 | text: PropTypes.string, 54 | iconRightClass: PropTypes.string, 55 | onIconRightTouchTap: PropTypes.func, 56 | onClick: PropTypes.func, 57 | style: PropTypes.object 58 | }; 59 | 60 | export default Chip; 61 | -------------------------------------------------------------------------------- /__test__/workers/parse_csv.test.js: -------------------------------------------------------------------------------- 1 | import { parseCSVAsync } from "../../src/workers/parse_csv"; 2 | 3 | describe("#parseCSVAsync", () => { 4 | let csv; 5 | 6 | beforeEach(async () => { 7 | csv = "firstName,lastName,cell,zip\r\nJerome,Garcia,14155551212,94970"; 8 | }); 9 | 10 | it("returns a promise", async () => { 11 | const parsed = await parseCSVAsync(csv); 12 | 13 | expect(parsed.contacts).toHaveLength(1); 14 | expect(parsed.contacts[0]).toEqual({ 15 | cell: "+14155551212", 16 | first_name: "Jerome", 17 | last_name: "Garcia", 18 | zip: "94970", 19 | custom_fields: "{}", 20 | external_id: "" 21 | }); 22 | expect(parsed.customFields).toEqual([]); 23 | expect(parsed.validationStats).toEqual({ 24 | dupeCount: 0, 25 | invalidCellCount: 0, 26 | missingCellCount: 0, 27 | zipCount: 1 28 | }); 29 | }); 30 | 31 | describe("when there is an error", () => { 32 | beforeEach(async () => { 33 | csv = 34 | "givenName,surName,mobile,postal\r\nJerome,Garcia,14155551212,94970"; 35 | }); 36 | 37 | it("throws an exception", done => { 38 | parseCSVAsync(csv) 39 | .then(() => { 40 | return done("didn't raise an error"); // this will cause the test to fail 41 | }) 42 | .catch(error => { 43 | expect(error).toEqual("Missing fields: firstName, lastName, cell"); 44 | return done(); 45 | }); 46 | }); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /__test__/cypress/integration/phone-inventory.test.js: -------------------------------------------------------------------------------- 1 | describe("Phone number management screen in the Admin interface", () => { 2 | const testAreaCode = "212"; 3 | const adminInfo = { email: "admin@example.com", password: "Admin1!" }; 4 | let admin = null; 5 | 6 | beforeEach(() => { 7 | cy.task("createOrganization").then(org => { 8 | cy.task("createUser", { 9 | userInfo: adminInfo, 10 | org, 11 | role: "OWNER" 12 | }).then(user => (admin = user)); 13 | }); 14 | }); 15 | 16 | it("shows numbers by area code and allows OWNERs to buy more", () => { 17 | cy.login(admin); 18 | cy.visit("/admin/1/phone-numbers"); 19 | 20 | cy.get("th").contains("Area Code"); 21 | cy.get("th").contains("Allocated"); 22 | cy.get("th").contains("Available"); 23 | cy.get("tr") 24 | .contains(testAreaCode) 25 | .should("not.exist"); 26 | 27 | // Fill out the form to buy a new number 28 | cy.get("button[data-test=buyPhoneNumbers]").click(); 29 | cy.get("[data-test=areaCode] input").type(testAreaCode); 30 | cy.get("[data-test=limit] input").type("1"); 31 | cy.get("[data-test=buyNumbersForm]").submit(); 32 | 33 | // "Available" column of the row containing the test areaCode 34 | // Waits until job run completes 35 | cy.waitUntil( 36 | () => 37 | cy 38 | .get(`tr:contains(${testAreaCode}) td:nth-child(4) div`) 39 | .contains("1"), 40 | { timeout: 1000 } 41 | ); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/server/models/interaction-step.js: -------------------------------------------------------------------------------- 1 | import thinky from "./thinky"; 2 | const type = thinky.type; 3 | import { requiredString, optionalString, timestamp } from "./custom-types"; 4 | 5 | import Campaign from "./campaign"; 6 | 7 | const InteractionStep = thinky.createModel( 8 | "interaction_step", 9 | type 10 | .object() 11 | .schema({ 12 | id: type.string(), 13 | campaign_id: requiredString(), 14 | // PROMPTS: 15 | question: optionalString(), 16 | script: optionalString(), 17 | created_at: timestamp(), 18 | 19 | // Previously there were answer options, and no such thing as 20 | // parents/ancestors. This was pretty cool, in-theory 21 | // since you could have many paths that led into a unified 22 | // path. However, the UI didn't allow it, so we are going 23 | // to squash that dream, at least until after the db migration 24 | 25 | // FIELDS FOR SUB-INTERACTIONS (only): 26 | parent_interaction_id: optionalString().foreign("interaction_step"), 27 | answer_option: optionalString(), // (was 'value') 28 | answer_actions: optionalString(), 29 | answer_actions_data: optionalString(), 30 | is_deleted: type 31 | .boolean() 32 | .default(false) 33 | .allowNull(false) 34 | }) 35 | .allowExtra(false), 36 | { noAutoCreation: true } 37 | ); 38 | 39 | InteractionStep.ensureIndex("campaign_id"); 40 | InteractionStep.ensureIndex("parent_interaction_id"); 41 | 42 | export default InteractionStep; 43 | -------------------------------------------------------------------------------- /src/extensions/contact-loaders/ngpvan/util.js: -------------------------------------------------------------------------------- 1 | import { 2 | getConfig, 3 | getConfigDecrypt, 4 | hasConfig 5 | } from "../../../server/api/lib/config"; 6 | 7 | export const DEFAULT_NGP_VAN_API_BASE_URL = "https://api.securevan.com"; 8 | export const DEFAULT_NGP_VAN_DATABASE_MODE = 0; 9 | export const DEFAULT_NGPVAN_TIMEOUT = 32000; 10 | 11 | export default class Van { 12 | static getAuth = async organization => { 13 | const appName = getConfig("NGP_VAN_APP_NAME", organization); 14 | const apiKey = hasConfig("NGP_VAN_API_KEY_ENCRYPTED", organization) 15 | ? await getConfigDecrypt("NGP_VAN_API_KEY_ENCRYPTED", organization) 16 | : getConfig("NGP_VAN_API_KEY", organization); 17 | const databaseMode = getConfig("NGP_VAN_DATABASE_MODE", organization); 18 | 19 | if (!appName || !apiKey) { 20 | throw new Error( 21 | "Environment missing NGP_VAN_APP_NAME or NGP_VAN_API_KEY" 22 | ); 23 | } 24 | 25 | const buffer = Buffer.from( 26 | `${appName}:${apiKey}|${databaseMode || DEFAULT_NGP_VAN_DATABASE_MODE}` 27 | ); 28 | return `Basic ${buffer.toString("base64")}`; 29 | }; 30 | 31 | static makeUrl = (pathAndQuery, organization) => { 32 | const baseUrl = 33 | getConfig("NGP_VAN_API_BASE_URL", organization) || 34 | DEFAULT_NGP_VAN_API_BASE_URL; 35 | return `${baseUrl}/${pathAndQuery}`; 36 | }; 37 | 38 | static getNgpVanTimeout = organization => { 39 | return getConfig("NGP_VAN_TIMEOUT", organization) || DEFAULT_NGPVAN_TIMEOUT; 40 | }; 41 | } 42 | -------------------------------------------------------------------------------- /__test__/extensions/service-vendors/index.test.js: -------------------------------------------------------------------------------- 1 | import * as serviceMap from "../../../src/extensions/service-vendors/service_map"; 2 | import * as messagingServices from "../../../src/extensions/service-vendors/index"; 3 | 4 | describe("extensions/service-vendors index", () => { 5 | describe("fullyConfigured", () => { 6 | let fullyConfiguredFunction; 7 | let organization; 8 | beforeEach(async () => { 9 | organization = { 10 | feature: { 11 | service: "fake_fake_service" 12 | } 13 | }; 14 | fullyConfiguredFunction = jest.fn().mockReturnValue(false); 15 | jest 16 | .spyOn(serviceMap, "tryGetFunctionFromService") 17 | .mockReturnValue(fullyConfiguredFunction); 18 | }); 19 | it("calls functions and returns something", async () => { 20 | expect(await messagingServices.fullyConfigured(organization)).toEqual( 21 | false 22 | ); 23 | expect(messagingServices.tryGetFunctionFromService.mock.calls).toEqual([ 24 | ["fake_fake_service", "fullyConfigured"] 25 | ]); 26 | }); 27 | describe("when the services doesn't have fullyConfigured", () => { 28 | beforeEach(async () => { 29 | jest 30 | .spyOn(serviceMap, "tryGetFunctionFromService") 31 | .mockReturnValue(null); 32 | }); 33 | it("returns true", async () => { 34 | expect(await messagingServices.fullyConfigured(organization)).toEqual( 35 | true 36 | ); 37 | }); 38 | }); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /__test__/TopNav.test.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { shallow } from "enzyme"; 3 | import { StyleSheetTestUtils } from "aphrodite"; 4 | import IconButton from "@material-ui/core/IconButton"; 5 | import { TopNavBase as TopNav } from "../src/components/TopNav"; 6 | import UserMenu from "../src/containers/UserMenu"; 7 | import { muiTheme } from "./test_helpers"; 8 | 9 | describe("TopNav", () => { 10 | it("can render only title", () => { 11 | const nav = shallow( 12 | 13 | ); 14 | expect(nav.text()).toContain("Welcome to my website"); 15 | expect(nav.find("Link").length).toBe(0); 16 | }); 17 | 18 | it("can render Link to go back", () => { 19 | const link = shallow( 20 | 25 | ).find("Link"); 26 | expect(link.length).toBe(1); 27 | expect(link.prop("to")).toBe("/admin/1/campaigns"); 28 | expect(link.find(IconButton).length).toBe(1); 29 | }); 30 | 31 | it("renders UserMenu", () => { 32 | const nav = shallow( 33 | 34 | ); 35 | expect(nav.find(UserMenu).length).toBe(1); 36 | }); 37 | }); 38 | 39 | // https://github.com/Khan/aphrodite/issues/62#issuecomment-267026726 40 | beforeEach(() => { 41 | StyleSheetTestUtils.suppressStyleInjection(); 42 | }); 43 | afterEach(() => { 44 | StyleSheetTestUtils.clearBufferAndResumeStyleInjection(); 45 | }); 46 | -------------------------------------------------------------------------------- /src/components/forms/GSSelectField.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import Select from "@material-ui/core/Select"; 4 | import FormControl from "@material-ui/core/FormControl"; 5 | import InputLabel from "@material-ui/core/InputLabel"; 6 | import MenuItem from "@material-ui/core/MenuItem"; 7 | import theme from "../../styles/mui-theme"; 8 | 9 | import GSFormField from "./GSFormField"; 10 | 11 | export default class GSSelectField extends GSFormField { 12 | createMenuItems() { 13 | return this.props.choices.map(({ value, label }) => ( 14 | 15 | {label} 16 | 17 | )); 18 | } 19 | 20 | render() { 21 | const { name, label, onChange, style, disabled } = this.props; 22 | let { value } = this.props; 23 | const dataTest = { "data-test": this.props["data-test"] }; 24 | 25 | // can't be undefined or react throw uncontroled component error 26 | if (!value) { 27 | value = ""; 28 | } 29 | 30 | return ( 31 | 32 | {label} 33 | 48 | 49 | ); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/server/api/mutations/updateContactCustomFields.js: -------------------------------------------------------------------------------- 1 | import { assignmentRequiredOrAdminRole } from "../errors"; 2 | import { cacheableData } from "../../models"; 3 | 4 | export const updateContactCustomFields = async ( 5 | _, 6 | { campaignContactId, customFields }, 7 | { user, loaders } 8 | ) => { 9 | let contact; 10 | try { 11 | contact = await cacheableData.campaignContact.load(campaignContactId); 12 | const campaign = await loaders.campaign.load(contact.campaign_id); 13 | const organization = await loaders.organization.load( 14 | campaign.organization_id 15 | ); 16 | 17 | await assignmentRequiredOrAdminRole( 18 | user, 19 | campaign.organization_id, 20 | contact.assignment_id, 21 | contact 22 | ); 23 | 24 | await cacheableData.campaignContact.updateCustomFields( 25 | contact, 26 | customFields, 27 | campaign, 28 | organization 29 | ); 30 | } catch (err) { 31 | // eslint-disable-next-line no-console 32 | console.error( 33 | `Error updating contact campaignContactID: ${campaignContactId}` + 34 | ` customFields: ${customFields} error: ${err}` 35 | ); 36 | throw err; 37 | } 38 | 39 | if (typeof customFields === "string") { 40 | try { 41 | // eslint-disable-next-line no-param-reassign 42 | customFields = JSON.parse(customFields || "{}"); 43 | } catch (err) { 44 | // eslint-disable-next-line no-console 45 | console.log(err); 46 | } 47 | } 48 | 49 | return { 50 | id: contact.id, 51 | customFields 52 | }; 53 | }; 54 | -------------------------------------------------------------------------------- /migrations/20191217130355_change_message_indexes.js: -------------------------------------------------------------------------------- 1 | exports.up = function(knex) { 2 | return knex.schema.alterTable("message", table => { 3 | const isSqlite = /sqlite/.test(knex.client.config.client); 4 | // NOTE: this could be an expensive migration which locks tables for some time, if you have millions of message rows 5 | // In that case, we recommend performing this migration manually during planned downtime 6 | if (!isSqlite) { 7 | // sqlite is not good at dropping indexes 8 | table.dropIndex("contact_number"); // Will be recreated with messageservice_sid 9 | table.dropIndex("assignment_id"); // SHOULD NO LONGER BE REQUIRED 10 | } 11 | // For Postgres, consider concurrent creation with manual command: 12 | // CREATE INDEX CONCURRENTLY cell_messageservice_sid_idx ON message (contact_number, messageservice_sid) 13 | table.index( 14 | ["contact_number", "messageservice_sid"], 15 | "cell_messageservice_sid_idx" 16 | ); 17 | // For Postgres, consider concurrent creation with manual command: 18 | // CREATE INDEX CONCURRENTLY message_campaign_contact_id_index ON message (campaign_contact_id) 19 | table.index("campaign_contact_id"); 20 | }); 21 | }; 22 | 23 | exports.down = function(knex) { 24 | return knex.schema.alterTable("message", table => { 25 | table.dropIndex( 26 | ["contact_number", "messageservice_sid"], 27 | "cell_messageservice_sid_idx" 28 | ); 29 | table.dropIndex("campaign_contact_id"); 30 | table.index("contact_number"); 31 | table.index("assignment_id"); 32 | }); 33 | }; 34 | -------------------------------------------------------------------------------- /src/extensions/texter-sideboxes/per-campaign-bulk-send/react-component.js: -------------------------------------------------------------------------------- 1 | import type from "prop-types"; 2 | import React from "react"; 3 | import { withRouter } from "react-router"; 4 | import loadData from "../../../containers/hoc/load-data"; 5 | 6 | export const displayName = () => "Allow bulk send for this campaign"; 7 | 8 | export const showSidebox = () => { 9 | return true; 10 | }; 11 | 12 | export class TexterSideboxClass extends React.Component { 13 | gotoInitials = () => { 14 | const { campaign, assignment } = this.props; 15 | this.props.router.push( 16 | `/app/${campaign.organization.id}/todos/${assignment.id}/text` 17 | ); 18 | }; 19 | 20 | gotoReplies = () => { 21 | const { campaign, assignment } = this.props; 22 | this.props.router.push( 23 | `/app/${campaign.organization.id}/todos/${assignment.id}/reply` 24 | ); 25 | }; 26 | 27 | gotoTodos = () => { 28 | const { campaign } = this.props; 29 | this.props.router.push(`/app/${campaign.organization.id}/todos`); 30 | }; 31 | 32 | render() { 33 | return null; 34 | } 35 | } 36 | 37 | TexterSideboxClass.propTypes = { 38 | router: type.object, 39 | mutations: type.object, 40 | 41 | // data 42 | contact: type.object, 43 | campaign: type.object, 44 | assignment: type.object, 45 | texter: type.object, 46 | 47 | // parent state 48 | navigationToolbarChildren: type.object, 49 | messageStatusFilter: type.string 50 | }; 51 | 52 | export const mutations = {}; 53 | 54 | export const TexterSidebox = loadData({ mutations })( 55 | withRouter(TexterSideboxClass) 56 | ); 57 | -------------------------------------------------------------------------------- /src/components/Empty.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | import React from "react"; 3 | import { StyleSheet, css } from "aphrodite"; 4 | import theme from "../styles/theme"; 5 | import { dataTest } from "../lib/attributes"; 6 | 7 | const inlineStyles = { 8 | icon: { 9 | width: 180, 10 | height: 180, 11 | opacity: 0.2 12 | } 13 | }; 14 | 15 | // removing pencil image for mobile widths smaller than 450px 16 | 17 | const styles = StyleSheet.create({ 18 | container: { 19 | marginTop: "10px", 20 | width: 180, 21 | marginLeft: "auto", 22 | marginRight: "auto" 23 | }, 24 | hideMobile: { 25 | marginTop: "10px", 26 | width: 180, 27 | marginLeft: "auto", 28 | marginRight: "auto", 29 | "@media(max-width: 450px)": { 30 | display: "none" 31 | } 32 | }, 33 | title: { 34 | ...theme.text.header, 35 | textAlign: "center" 36 | }, 37 | content: { 38 | marginTop: "15px", 39 | textAlign: "center" 40 | } 41 | }); 42 | 43 | const Empty = ({ title, icon, content, hideMobile }) => ( 44 |
48 | {React.cloneElement(icon, { style: inlineStyles.icon })} 49 |
{title}
50 | {content ?
{content}
: ""} 51 |
52 | ); 53 | 54 | Empty.propTypes = { 55 | title: PropTypes.string, 56 | icon: PropTypes.object, 57 | content: PropTypes.object, 58 | hideMobile: PropTypes.bool 59 | }; 60 | 61 | export default Empty; 62 | -------------------------------------------------------------------------------- /src/extensions/texter-sideboxes/hide-media/react-component.js: -------------------------------------------------------------------------------- 1 | import type from "prop-types"; 2 | import React from "react"; 3 | import * as yup from "yup"; 4 | import Form from "react-formal"; 5 | 6 | export const displayName = () => 7 | "Hide media including images and videos from texters"; 8 | 9 | export const showSidebox = () => true; 10 | 11 | export class TexterSidebox extends React.Component { 12 | constructor(props) { 13 | super(props); 14 | const { parent } = props; 15 | parent.setState({ 16 | hideMedia: true 17 | }); 18 | } 19 | 20 | render() { 21 | return null; 22 | } 23 | } 24 | 25 | TexterSidebox.propTypes = { 26 | // data 27 | contact: type.object, 28 | campaign: type.object, 29 | assignment: type.object, 30 | texter: type.object, 31 | 32 | // parent state 33 | disabled: type.bool, 34 | navigationToolbarChildren: type.object 35 | }; 36 | 37 | export const adminSchema = () => ({}); 38 | 39 | export class AdminConfig extends React.Component { 40 | render() { 41 | return ( 42 |
43 |

44 | When contacts reply with images/media Spoke will have a prompt for the 45 | contact to view the image/media. However, this is often a vehicle for 46 | offensive and graphic responses. We recommend enabling hiding media 47 | for most outreach campaigns, and to enable this for contacts that are 48 | more likely allies. 49 |

50 |
51 | ); 52 | } 53 | } 54 | 55 | AdminConfig.propTypes = { 56 | settingsData: type.object, 57 | onToggle: type.func 58 | }; 59 | -------------------------------------------------------------------------------- /src/extensions/job-runners/legacy.js: -------------------------------------------------------------------------------- 1 | import { saveJob } from "./helpers"; 2 | import { invokeJobFunction } from "../../workers/job-processes"; 3 | import { invokeTaskFunction } from "../../workers/tasks"; 4 | 5 | const JOBS_SAME_PROCESS = !!( 6 | process.env.JOBS_SAME_PROCESS || global.JOBS_SAME_PROCESS 7 | ); 8 | const JOBS_SYNC = !!(process.env.JOBS_SYNC || global.JOBS_SYNC); 9 | const TASKS_SYNC = !!(process.env.TASKS_SYNC || global.TASKS_SYNC); 10 | 11 | export const fullyConfigured = () => true; 12 | 13 | export const dispatchJob = async ( 14 | { queue_name, locks_queue, job_type, organization_id, campaign_id, payload }, 15 | opts = {} 16 | ) => { 17 | const job = await saveJob( 18 | { 19 | assigned: JOBS_SAME_PROCESS, 20 | locks_queue, 21 | job_type, 22 | organization_id, 23 | campaign_id, 24 | queue_name, 25 | payload 26 | }, 27 | opts.trx 28 | ); 29 | 30 | if (JOBS_SAME_PROCESS) { 31 | if (JOBS_SYNC) { 32 | await invokeJobFunction(job); 33 | } else { 34 | // Intentionally un-awaited promise 35 | invokeJobFunction(job).catch(err => console.log("Job failed", job, err)); 36 | } 37 | } 38 | return job; 39 | // default: just save the job 40 | }; 41 | 42 | // Tasks always run in the same process 43 | export const dispatchTask = async (taskName, payload) => { 44 | if (TASKS_SYNC) { 45 | await invokeTaskFunction(taskName, payload); 46 | } else { 47 | // fire and forget 48 | invokeTaskFunction(taskName, payload).catch(err => 49 | console.log(`Task ${taskName} failed`, err) 50 | ); 51 | } 52 | }; 53 | -------------------------------------------------------------------------------- /src/lib/index.js: -------------------------------------------------------------------------------- 1 | export { getFormattedPhoneNumber, getDisplayPhoneNumber } from "./phone-format"; 2 | export { 3 | getFormattedZip, 4 | zipToTimeZone, 5 | findZipRanges, 6 | getCommonZipRanges 7 | } from "./zip-format"; 8 | export { 9 | convertOffsetsToStrings, 10 | getLocalTime, 11 | isBetweenTextingHours, 12 | defaultTimezoneIsBetweenTextingHours, 13 | getOffsets, 14 | getContactTimezone, 15 | getUtcFromTimezoneAndHour, 16 | getUtcFromOffsetAndHour, 17 | getSendBeforeTimeUtc 18 | } from "./timezones"; 19 | export { 20 | getProcessEnvTz, 21 | getProcessEnvDstReferenceTimezone 22 | } from "./tz-helpers"; 23 | export { DstHelper } from "./dst-helper"; 24 | export { isClient } from "./is-client"; 25 | import { log } from "./log"; 26 | export { log }; 27 | export { 28 | findParent, 29 | getInteractionPath, 30 | getInteractionTree, 31 | sortInteractionSteps, 32 | interactionStepForId, 33 | getAvailableInteractionSteps, 34 | getTopMostParent, 35 | getChildren, 36 | makeTree 37 | } from "./interaction-step-helpers"; 38 | 39 | export { 40 | getCampaignsFilterForCampaignArchiveStatus, 41 | getContactsFilterForConversationOptOutStatus, 42 | getConversationFiltersFromQuery, 43 | tagsFilterStateFromTagsFilter 44 | } from "./conversations"; 45 | 46 | export { 47 | ROLE_HIERARCHY, 48 | getHighestRole, 49 | hasRole, 50 | isRoleGreater 51 | } from "./permissions"; 52 | 53 | export { gzip, gunzip } from "./gzip"; 54 | export { 55 | parseCSV, 56 | organizationCustomFields, 57 | requiredUploadFields, 58 | topLevelUploadFields, 59 | parseCannedResponseCsv 60 | } from "./parse_csv.js"; 61 | -------------------------------------------------------------------------------- /src/lib/log.js: -------------------------------------------------------------------------------- 1 | import minilog from "minilog"; 2 | import { isClient } from "./is-client"; 3 | const rollbar = require("rollbar"); 4 | let logInstance = null; 5 | 6 | if (isClient()) { 7 | minilog.enable(); 8 | logInstance = minilog("client"); 9 | const existingErrorLogger = logInstance.error; 10 | logInstance.error = (...err) => { 11 | const errObj = err; 12 | if (window.Rollbar) { 13 | window.Rollbar.error(...errObj); 14 | } 15 | existingErrorLogger.call(...errObj); 16 | }; 17 | } else { 18 | let enableRollbar = false; 19 | if ( 20 | process.env.NODE_ENV === "production" && 21 | process.env.ROLLBAR_ACCESS_TOKEN 22 | ) { 23 | enableRollbar = true; 24 | rollbar.init(process.env.ROLLBAR_ACCESS_TOKEN); 25 | } 26 | 27 | minilog.suggest.deny( 28 | /.*/, 29 | process.env.NODE_ENV === "development" ? "debug" : "debug" 30 | ); 31 | 32 | minilog 33 | .enable() 34 | .pipe(minilog.backends.console.formatWithStack) 35 | .pipe(minilog.backends.console); 36 | 37 | logInstance = minilog("backend"); 38 | const existingErrorLogger = logInstance.error; 39 | logInstance.error = err => { 40 | if (enableRollbar) { 41 | if (typeof err === "object") { 42 | rollbar.handleError(err); 43 | } else if (typeof err === "string") { 44 | rollbar.reportMessage(err); 45 | } else { 46 | rollbar.reportMessage("Got backend error with no error message"); 47 | } 48 | } 49 | 50 | existingErrorLogger(err && err.stack ? err.stack : err); 51 | }; 52 | } 53 | 54 | const log = process.env.LAMBDA_DEBUG_LOG ? console : logInstance; 55 | 56 | export { log }; 57 | -------------------------------------------------------------------------------- /src/components/forms/GSAutoComplete.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Autocomplete from "@material-ui/lab/Autocomplete"; 3 | import TextField from "@material-ui/core/TextField"; 4 | 5 | import GSFormField from "./GSFormField"; 6 | import theme from "../../styles/mui-theme"; 7 | 8 | export default class GSAutoComplete extends GSFormField { 9 | constructor(props) { 10 | super(props); 11 | } 12 | 13 | render = () => { 14 | const { 15 | label, 16 | fullWidth, 17 | placeholder, 18 | onChange, 19 | getOptionLabel, 20 | options, 21 | getOptionSelected 22 | } = this.props; 23 | let { value } = this.props; 24 | const dataTest = { "data-test": this.props["data-test"] }; 25 | 26 | // can't be undefined or react throw uncontroled component error 27 | if (!value) { 28 | value = ""; 29 | } 30 | 31 | return ( 32 | option.label || "")} 35 | value={value} 36 | options={options} 37 | getOptionSelected={getOptionSelected || (opt => opt.value || "")} 38 | renderInput={params => { 39 | return ( 40 | 49 | ); 50 | }} 51 | onChange={(event, value) => { 52 | onChange(value); 53 | }} 54 | /> 55 | ); 56 | }; 57 | } 58 | -------------------------------------------------------------------------------- /migrations/20191108164924_message_errors.js: -------------------------------------------------------------------------------- 1 | exports.up = function(knex) { 2 | return Promise.all([ 3 | knex.schema.alterTable("message", table => { 4 | table 5 | .integer("error_code") 6 | .nullable() 7 | .default(null); 8 | table.dropColumn("service_response"); 9 | if (!/sqlite/.test(knex.client.config.client)) { 10 | // sqlite doesn't find it 11 | table.dropIndex("user_number"); 12 | } 13 | }), 14 | knex.schema.alterTable("campaign_contact", table => { 15 | table 16 | .integer("error_code") 17 | .nullable() 18 | .default(null); 19 | }), 20 | knex.schema.alterTable("log", table => { 21 | table 22 | .integer("error_code") 23 | .nullable() 24 | .default(null); 25 | table 26 | .string("from_num", 15) 27 | .nullable() 28 | .default(null); 29 | table 30 | .string("to_num", 15) 31 | .nullable() 32 | .default(null); 33 | }) 34 | ]); 35 | }; 36 | 37 | exports.down = function(knex) { 38 | return Promise.all([ 39 | knex.schema.alterTable("message", table => { 40 | table.dropColumn("error_code"); 41 | table 42 | .text("service_response") 43 | .notNullable() 44 | .defaultTo(""); 45 | table.index("user_number"); 46 | }), 47 | knex.schema.alterTable("campaign_contact", table => { 48 | table.dropColumn("error_code"); 49 | }), 50 | knex.schema.alterTable("log", table => { 51 | table.dropColumn("from_num"); 52 | table.dropColumn("to_num"); 53 | table.dropColumn("error_code"); 54 | }) 55 | ]); 56 | }; 57 | -------------------------------------------------------------------------------- /src/components/Downtime.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | import React from "react"; 3 | import theme from "../styles/theme"; 4 | import { css } from "aphrodite"; 5 | import { styles } from "../containers/Home"; 6 | 7 | class Downtime extends React.Component { 8 | render() { 9 | return ( 10 |
11 |
12 | 16 |
17 |
18 | {window.DOWNTIME || window.DOWNTIME_TEXTER ? ( 19 |
20 | Spoke is not currently available. 21 | {window.DOWNTIME && 22 | window.DOWNTIME != "1" && 23 | window.DOWNTIME != "true" ? ( 24 |
{window.DOWNTIME}
25 | ) : ( 26 | "Please talk to your campaign manager or system administrator." 27 | )} 28 | {window.DOWNTIME_TEXTER && 29 | window.DOWNTIME_TEXTER != "1" && 30 | window.DOWNTIME_TEXTER != "true" ? ( 31 |
{window.DOWNTIME_TEXTER}
32 | ) : null} 33 |
34 | ) : ( 35 |
36 | This page is where Spoke users are brought to when the system is 37 | set to DOWNTIME=true for maintenance, etc. 38 |
39 | )} 40 |
41 |
42 | ); 43 | } 44 | } 45 | 46 | export default Downtime; 47 | -------------------------------------------------------------------------------- /src/components/IncomingMessageList/TagList.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { StyleSheet, css } from "aphrodite"; 3 | import PropTypes from "prop-types"; 4 | 5 | import Avatar from "@material-ui/core/Avatar"; 6 | import FlagIcon from "@material-ui/icons/Flag"; 7 | 8 | import withMuiTheme from "../../containers/hoc/withMuiTheme"; 9 | 10 | const styles = StyleSheet.create({ 11 | conversationRow: { 12 | color: "white", 13 | padding: "10px", 14 | borderRadius: "5px", 15 | fontWeight: "normal" 16 | } 17 | }); 18 | 19 | const TagList = props => ( 20 |
21 | {props.tags.map((tag, index) => { 22 | const tagStyle = { 23 | marginRight: "60px", 24 | backgroundColor: this.props.muiTheme.palette.error.main, 25 | display: "flex", 26 | maxHeight: "25px", 27 | alignItems: "center" 28 | }; 29 | 30 | const textStyle = { 31 | marginLeft: "10px", 32 | display: "flex", 33 | flexDirection: "column" 34 | }; 35 | 36 | return ( 37 |

38 | 41 | 42 | 43 |

{props.organizationTags[tag.id]}

44 |

45 | ); 46 | })} 47 |
48 | ); 49 | 50 | TagList.propTypes = { 51 | tags: PropTypes.arrayOf(PropTypes.object), 52 | organizationTags: PropTypes.object 53 | }; 54 | 55 | export default withMuiTheme(TagList); 56 | -------------------------------------------------------------------------------- /docs/HOWTO_INTEGRATE_SLACK_AUTH.md: -------------------------------------------------------------------------------- 1 | # How to configure Slack Authentication 2 | 3 | This integration ties a Spoke instance to a single Slack workspace. All 4 | users in the Slack workspace will be able to log in to Spoke without any 5 | additional set up. Note that users will still need to join organizations 6 | using organization and/or campaign join links. 7 | 8 | ## Set up 9 | 10 | #### Configure a Slack App 11 | 12 | Create a private (non-distributed) Slack app [here](https://api.slack.com/apps) 13 | using your workspace as the development workspace. 14 | 15 | 1. On the landing page, under "Add features and functionality", select "Permissions" 16 | 2. Go to "OAuth & Permissions" in the sidebar 17 | 1. Under "Redirect URLs" add `https:///login-callback` 18 | 2. Under "Scopes > User Token Scopes" add the following OAuth Scopes: 19 | * identity.basic 20 | * identity.email 21 | * identity.team 22 | 3. Install the app into your workspace 23 | 24 | 25 | #### Configure Spoke env vars 26 | 27 | Set the following env vars: 28 | * PASSPORT_STRATEGY: Set this to `slack` 29 | * SLACK_CLIENT_ID: Copy this from your Slack app's "Basic Information" page 30 | * SLACK_CLIENT_SECRET: Copy this from your Slack app's "Basic Information" page 31 | * SLACK_TEAM_NAME: The subdomain of your slack workspace https://.slack.com 32 | * SLACK_TEAM_ID: The ID of your team. This can be tricky to find, see [this SO thread.](https://stackoverflow.com/questions/40940327/what-is-the-simplest-way-to-find-a-slack-team-id-and-a-channel-id) 33 | * BASE_URL: `https://` - Not specific to Slack Auth but needs to be set for the handshake to work properly 34 | -------------------------------------------------------------------------------- /webpack/server.js: -------------------------------------------------------------------------------- 1 | import WebpackDevServer from "webpack-dev-server"; 2 | import webpack from "webpack"; 3 | import config from "./config"; 4 | import { log } from "../src/lib"; 5 | 6 | const webpackPort = process.env.WEBPACK_PORT || 3000; 7 | const appPort = process.env.DEV_APP_PORT; 8 | const webpackHost = process.env.WEBPACK_HOST || "127.0.0.1"; 9 | 10 | Object.keys(config.entry).forEach(key => { 11 | config.entry[key].unshift( 12 | `webpack-dev-server/client?http://${webpackHost}:${webpackPort}/` 13 | ); 14 | config.entry[key].unshift("webpack/hot/only-dev-server"); 15 | }); 16 | 17 | const compiler = webpack(config); 18 | const connstring = `http://127.0.0.1:${appPort}`; 19 | 20 | log.info(`Proxying requests to: ${connstring}`); 21 | 22 | const app = new WebpackDevServer(compiler, { 23 | contentBase: "/assets/", 24 | publicPath: "/assets/", 25 | hot: true, 26 | // this should be temporary until we get the real hostname plugged in everywhere 27 | disableHostCheck: true, 28 | headers: { "Access-Control-Allow-Origin": "*" }, 29 | proxy: { 30 | "*": `http://127.0.0.1:${appPort}` 31 | }, 32 | clientLogLevel: "debug", 33 | stats: { 34 | colors: true, 35 | hash: false, 36 | version: false, 37 | timings: false, 38 | assets: false, 39 | chunks: false, 40 | modules: false, 41 | reasons: false, 42 | children: false, 43 | source: false, 44 | errors: true, 45 | errorDetails: true, 46 | warnings: true, 47 | publicPath: false 48 | } 49 | }); 50 | 51 | app.listen(webpackPort || process.env.PORT, () => { 52 | log.info( 53 | `Webpack dev server is now running on http://${webpackHost}:${webpackPort}` 54 | ); 55 | }); 56 | -------------------------------------------------------------------------------- /src/components/TopNav.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | import React from "react"; 3 | 4 | import IconButton from "@material-ui/core/IconButton"; 5 | import ArrowBackIcon from "@material-ui/icons/ArrowBack"; 6 | import AppBar from "@material-ui/core/AppBar"; 7 | import Toolbar from "@material-ui/core/Toolbar"; 8 | import Typography from "@material-ui/core/Typography"; 9 | import { makeStyles } from "@material-ui/core/styles"; 10 | 11 | import { Link as RouterLink } from "react-router"; 12 | import UserMenu from "../containers/UserMenu"; 13 | import withMuiTheme from "./../containers/hoc/withMuiTheme"; 14 | 15 | const useStyles = makeStyles(() => ({ 16 | toolBar: { 17 | flexGrow: 1 18 | }, 19 | title: { 20 | flexGrow: 1 21 | } 22 | })); 23 | 24 | export function TopNavBase(props) { 25 | const classes = useStyles(); 26 | const { backToURL, orgId, title, muiTheme } = props; 27 | return ( 28 | 29 | 30 | {backToURL && ( 31 | 32 | 33 | 36 | 37 | 38 | )} 39 | 40 | {title} 41 | 42 | 43 | 44 | 45 | ); 46 | } 47 | 48 | TopNavBase.propTypes = { 49 | backToURL: PropTypes.string, 50 | title: PropTypes.string.isRequired, 51 | orgId: PropTypes.string 52 | }; 53 | 54 | export default withMuiTheme(TopNavBase); 55 | -------------------------------------------------------------------------------- /__test__/cypress/integration/user-edit.test.js: -------------------------------------------------------------------------------- 1 | describe("The user edit screen", () => { 2 | const adminInfo = { email: "admin@example.com", password: "Admin1!" }; 3 | let admin = null; 4 | 5 | beforeEach(() => { 6 | cy.task("createOrganization").then(org => { 7 | cy.task("createUser", { 8 | userInfo: adminInfo, 9 | org, 10 | role: "OWNER" 11 | }).then(user => (admin = user)); 12 | }); 13 | }); 14 | 15 | it("displays the current user's and allows them to edit it", () => { 16 | cy.login(admin); 17 | cy.visit("/"); 18 | 19 | cy.get("[data-test=userMenuButton]").click(); 20 | cy.get("[data-test=userMenuDisplayName]").click(); 21 | cy.get("[data-test=email] input").should("have.value", admin.email); 22 | cy.get("[data-test=firstName] input").should( 23 | "have.value", 24 | admin.first_name 25 | ); 26 | cy.get("[data-test=lastName] input").should("have.value", admin.last_name); 27 | cy.get("[data-test=alias] input").should("have.value", ""); 28 | cy.get("[data-test=cell] input").should("have.value", admin.cell); 29 | 30 | cy.get("[data-test=firstName] input").type("NewAdminFirstName"); 31 | cy.get("[data-test=lastName] input").type("NewAdminLastName"); 32 | cy.get("[data-test=alias] input").type("NewAlias"); 33 | cy.get("[data-test=userEditForm]").submit(); 34 | 35 | cy.reload(); 36 | cy.get("[data-test=firstName] input").should( 37 | "have.value", 38 | "NewAdminFirstName" 39 | ); 40 | cy.get("[data-test=lastName] input").should( 41 | "have.value", 42 | "NewAdminLastName" 43 | ); 44 | cy.get("[data-test=alias] input").should("have.value", "NewAlias"); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /src/lib/phone-format.js: -------------------------------------------------------------------------------- 1 | import { PhoneNumberUtil, PhoneNumberFormat } from "google-libphonenumber"; 2 | import { log } from "./log"; 3 | 4 | export const getFormattedPhoneNumber = (cell, country = "US") => { 5 | const phoneUtil = PhoneNumberUtil.getInstance(); 6 | // we return an empty string vs null when the phone number is inValid 7 | // because when the cell is null, batch inserts into campaign contacts fail 8 | // then when contacts have cell.length < 12 (+1), it's deleted before assignments are created 9 | try { 10 | const inputNumber = phoneUtil.parse(cell, country); 11 | const isValid = phoneUtil.isValidNumber(inputNumber); 12 | if (isValid) { 13 | return phoneUtil.format(inputNumber, PhoneNumberFormat.E164); 14 | } 15 | return ""; 16 | } catch (e) { 17 | log.error(e); 18 | return ""; 19 | } 20 | }; 21 | 22 | const parsePhoneNumber = (e164Number, country = "US") => { 23 | const phoneUtil = PhoneNumberUtil.getInstance(); 24 | const parsed = phoneUtil.parse(e164Number, country); 25 | return parsed; 26 | }; 27 | 28 | export const getDisplayPhoneNumber = (e164Number, country = "US") => { 29 | const parsed = parsePhoneNumber(e164Number, country); 30 | return phoneUtil.format(parsed, PhoneNumberFormat.NATIONAL); 31 | }; 32 | 33 | export const getDashedPhoneNumberDisplay = (e164Number, country = "US") => { 34 | const parsed = parsePhoneNumber(e164Number, country); 35 | return parsed 36 | .getNationalNumber() 37 | .toString() 38 | .replace(/(\d{3})(\d{3})(\d{4})/, "$1-$2-$3"); 39 | }; 40 | 41 | export const getCountryCode = (e164Number, country = "US") => { 42 | const parsed = parsePhoneNumber(e164Number, country); 43 | return parsed.getCountryCode(); 44 | }; 45 | -------------------------------------------------------------------------------- /__test__/cypress/integration/local-auth.test.js: -------------------------------------------------------------------------------- 1 | describe("Authentication with the local passport strategy", () => { 2 | // TODO: test with SUPPRESS_SELF_INVITE=true by creating an invite 3 | describe("With SUPPRESS_SELF_INVITE=false", () => { 4 | it("Signs a user up", () => { 5 | cy.visit("/"); 6 | 7 | cy.get("#login").click(); 8 | cy.get("button[name='signup']").click(); 9 | cy.get("input[name='email']").type(`SignupTestUser@example.com`); 10 | cy.get("input[name='firstName']").type("SignupTestUserFirst"); 11 | cy.get("input[name='lastName']").type("SignupTestUserLast"); 12 | cy.get("input[name='cell']").type("5555551234"); 13 | cy.get("input[name='password']").type("SignupTestUser1!", { delay: 10 }); 14 | cy.get("input[name='passwordConfirm']").type("SignupTestUser1!", { 15 | delay: 10 16 | }); 17 | cy.get("[data-test=userEditForm]").submit(); 18 | // The next page is different depending on whether SUPPRESS_SELF_INVITE is 19 | // set, so we just assert that we are not still on the login page 20 | cy.waitUntil(() => cy.url().then(url => !url.match(/login/))); 21 | }); 22 | 23 | it("Logs a user in", () => { 24 | let email = "user@example.com"; 25 | let password = "User1!"; 26 | cy.task("createUser", { 27 | userInfo: { email, password } 28 | }); 29 | 30 | cy.visit("/"); 31 | cy.get("#login").click(); 32 | 33 | cy.get("input[name='email']").type(email); 34 | cy.get("input[name='password']").type(password, { delay: 10 }); 35 | cy.get("[data-test=userEditForm]").submit(); 36 | cy.waitUntil(() => cy.url().then(url => !url.match(/login/))); 37 | }); 38 | }); 39 | }); 40 | --------------------------------------------------------------------------------