├── .nvmrc ├── .eslintignore ├── .prettierignore ├── Procfile ├── __mocks__ ├── styleMock.js └── fileMock.js ├── heroku.yml ├── readmestaging20180902 ├── src ├── store │ ├── reducers │ │ ├── index.js │ │ └── count.js │ ├── actions │ │ └── index.js │ └── index.js ├── lib │ ├── is-client.js │ ├── __mocks__ │ │ ├── timezones.js │ │ ├── zip-format.js │ │ └── tz-helpers.js │ ├── normalize-message.js │ ├── tz-helpers.js │ ├── attributes.js │ ├── permissions.js │ ├── state-codes.js │ ├── campaign-statuses.js │ ├── index.js │ ├── phone-format.js │ ├── tags.js │ ├── search-helpers.js │ ├── scripts.js │ └── color-helpers.js ├── server │ ├── crypto.js │ ├── random-secret.js │ ├── lambda │ │ ├── twilio-webhook.js │ │ ├── send-reminders.js │ │ ├── worker.js │ │ └── codedeploy.js │ ├── api │ │ ├── mutations │ │ │ ├── label.js │ │ │ └── organization.js │ │ ├── label.js │ │ ├── opt-out.js │ │ ├── question-response.js │ │ ├── mocks.js │ │ ├── canned-response.js │ │ ├── lib │ │ │ ├── services.js │ │ │ ├── utils.js │ │ │ ├── fakeservice.js │ │ │ └── message-sending.js │ │ ├── phone.js │ │ ├── interaction-step.js │ │ └── question.js │ ├── wrap.js │ ├── s3.js │ ├── log.js │ ├── models │ │ ├── cacheable_queries │ │ │ ├── index.js │ │ │ ├── organization.js │ │ │ └── canned-response.js │ │ ├── datawarehouse.js │ │ ├── assignment.js │ │ ├── tag.js │ │ ├── opt-out.js │ │ ├── question-response.js │ │ ├── user-organization.js │ │ ├── custom-types.js │ │ ├── user.js │ │ ├── zip-code.js │ │ ├── canned-response.js │ │ ├── organization.js │ │ ├── index.js │ │ ├── interaction-step.js │ │ └── campaign-contact.js │ ├── db │ │ ├── user.js │ │ ├── index.js │ │ ├── background-job.js │ │ └── label.js │ ├── preconditions.js │ ├── middleware │ │ └── graphql-response-time.js │ ├── data-loaders.js │ ├── notifications.js │ └── action_handlers │ │ └── test-action.js ├── api │ ├── opt-out.js │ ├── question-response.js │ ├── message.js │ ├── interaction-step.js │ ├── label.js │ ├── question.js │ ├── conversations.js │ ├── background-job.js │ ├── assignment.js │ ├── twilio-phone-number.js │ ├── canned-response.js │ ├── organization.js │ ├── user.js │ └── campaign-contact.js ├── styles │ ├── media-queries.js │ └── mui-theme.js ├── client │ ├── webpack-config.js │ ├── backcompat.js │ ├── lib │ │ └── error-helpers.js │ ├── telemetry.js │ ├── index.jsx │ └── auth-service.js ├── components │ ├── IncomingMessageFilter │ │ ├── index.jsx │ │ └── Filters │ │ │ └── TexterFilter.jsx │ ├── utils.jsx │ ├── DualNavDataTables │ │ └── index.jsx │ ├── Paginated │ │ ├── PaginatedList.jsx │ │ └── withPagination.jsx │ ├── LoadingIndicator.jsx │ ├── forms │ │ ├── GSFormField.jsx │ │ ├── GSPasswordField.jsx │ │ ├── GSTextField.jsx │ │ ├── GSSelectField.jsx │ │ ├── GSSubmitButton.jsx │ │ └── GSDateField.jsx │ ├── Error404.jsx │ ├── DisplayLink.jsx │ ├── CampaignFormSectionHeading.jsx │ ├── PasswordResetLink.jsx │ ├── PeopleList │ │ ├── UserEditDialog.jsx │ │ ├── SimpleRolesDropdown.jsx │ │ ├── ResetPasswordDialog.jsx │ │ └── RolesDropdown.jsx │ ├── Slider.jsx │ ├── Chart.jsx │ ├── ConversationLink.jsx │ ├── App.jsx │ ├── IncomingMessageList │ │ ├── ConversationPreviewModal │ │ │ ├── Body.jsx │ │ │ └── MessageList.jsx │ │ └── ConfirmChangePageWithConvosSelectedDialog.jsx │ ├── TagChip.jsx │ ├── Search.jsx │ ├── ConfirmCampaignArchiveModal.jsx │ ├── LabelChips.jsx │ ├── CannedResponseForm.jsx │ ├── SelectedCampaigns.jsx │ ├── CampaignStatusSelect.jsx │ ├── TexterDashboard.jsx │ ├── ConversationLinkDialog.jsx │ ├── IncomingMessageActions │ │ └── index.jsx │ ├── Chip.jsx │ ├── AdminCampaignList │ │ └── SortBy.jsx │ ├── Empty.jsx │ ├── EmbeddedShifter.jsx │ ├── SendButtonArrow.jsx │ ├── SendButton.jsx │ └── ConfirmButton.jsx ├── heroku │ └── print-base-url.js ├── containers │ ├── ConversationTexter │ │ └── components │ │ │ └── index.js │ ├── __mocks__ │ │ └── RequestBatchButton.jsx │ ├── hoc │ │ └── wrap-mutations.jsx │ ├── DashboardLoader.jsx │ ├── SuspendedTexter.jsx │ ├── JobProgress.jsx │ ├── AdminNavigation.jsx │ └── CSVUploader │ │ └── FilePicker.jsx └── network │ ├── errors.js │ └── apollo-client-singleton.js ├── .dockerignore ├── dev-tools ├── Procfile.dev ├── redis-cli.sh ├── babel-run ├── db-startup.js ├── psql.sh ├── babel-run-with-env.js ├── babel-run-debug ├── create_test_database_ci.sh ├── jest.transform.js ├── migrate.js ├── sentry-release-finalize.sh ├── sentry-release-create.sh ├── create_test_database.sh ├── Procfile-debug-server.dev ├── list-numbers.js ├── create-minio-bucket.sh ├── export-query.js ├── generate-test-data.py └── export-broken-interaction-steps.js ├── docs ├── images │ └── spoke_options.png ├── HOWTO_RUN_THE_SERVER_IN_A_DEBUGGER.md ├── HOWTO_CONNECT_WITH_REDIS.md ├── MESSAGE REVIEW documentation ├── PROD_RUNNING_JOBS.md ├── ROLES_DESCRIPTION.md └── HOWTO-run_tests.md ├── __test__ ├── e2e │ ├── data │ │ └── people.csv │ ├── page-functions │ │ └── index.js │ ├── page-objects │ │ ├── scriptEditor.js │ │ ├── index.js │ │ ├── navigation.js │ │ ├── texter.js │ │ ├── main.js │ │ ├── people.js │ │ └── login.js │ ├── util │ │ ├── setup.js │ │ └── config.js │ ├── .env.e2e │ ├── create_edit_campaign.test.js │ ├── invite_texter.test.js │ └── create_copy_campaign.test.js ├── setup.js ├── lib │ ├── tz-helpers.test.js │ └── parse-csv.test.js └── TopNav.test.js ├── deploy ├── lambda-scheduled-event.json └── lambda-migrate-database.js ├── knex-migration-stub.js ├── nodemon.json ├── migrations ├── 20200208191036_drop_log_table.js ├── 20200211111144_drop_invite_table.js ├── 20200208195458_add_shifting_configuration.js ├── 20200210184907_drop_unused_tables.js ├── 20200212160412_add_attachments_to_message.js ├── 20200222123230_add-started-at-to-campaign.js ├── helpers │ └── onUpdateTrigger.js ├── 20200226200532_drop_canned_response_label_deleted_at.js ├── 20200209153016_drop_non_null_constraint_user_cell.js ├── 20200211122858_add_token_to_campaign.js ├── 20200208170807_init.js ├── 20200224110931_add_file_name_and_status_to_campaign.js ├── 20200226133859_add_user_subscribed_column.js ├── 20200223202457_remove_assignment_id_non_null_contraint.js ├── 20200226133857_add_message_created_at_index.js ├── 20200222200118_add_user_trigram_indices.js ├── 20200208193050_add_canned_response_deleted_and_order.js ├── 20200228171536_campaign_status_data_mig.js ├── 20200226133858_add_notifications.js ├── 20200209103103_add_twilio_phone_number_table.js └── 20200210160708_add_background_job.js ├── jsconfig.json ├── .gitignore ├── Makefile ├── knexfile.js ├── .babelrc.json ├── travis-run-e2e-tests ├── .travis.yml ├── babel.config.js ├── jest.config.e2e.js ├── docker-compose.yml ├── .github └── workflows │ ├── dev-deploy.yml │ ├── prod-deploy.yml │ └── test.yml ├── LICENSE ├── Dockerfile ├── terraform └── README.md ├── webpack └── server.js ├── jest.config.js └── .eslintrc /.nvmrc: -------------------------------------------------------------------------------- 1 | 12.16 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | build 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: npm run start:heroku 2 | -------------------------------------------------------------------------------- /__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 | -------------------------------------------------------------------------------- /readmestaging20180902: -------------------------------------------------------------------------------- 1 | Creating branch staging2010902. 2 | -------------------------------------------------------------------------------- /src/store/reducers/index.js: -------------------------------------------------------------------------------- 1 | export count from "./count"; 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | build 3 | .serverless 4 | layer 5 | -------------------------------------------------------------------------------- /src/lib/is-client.js: -------------------------------------------------------------------------------- 1 | export function isClient() { 2 | return typeof window !== "undefined"; 3 | } 4 | -------------------------------------------------------------------------------- /dev-tools/Procfile.dev: -------------------------------------------------------------------------------- 1 | server: npm run dev-start 2 | webpack: ./dev-tools/babel-run ./webpack/server.js 3 | -------------------------------------------------------------------------------- /dev-tools/redis-cli.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | 5 | docker-compose exec redis redis-cli "$@" 6 | -------------------------------------------------------------------------------- /docs/images/spoke_options.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Elizabeth-Warren/Spoke/HEAD/docs/images/spoke_options.png -------------------------------------------------------------------------------- /src/lib/__mocks__/timezones.js: -------------------------------------------------------------------------------- 1 | const timezones = jest.genMockFromModule("../timezones"); 2 | module.exports = timezones; 3 | -------------------------------------------------------------------------------- /src/lib/__mocks__/zip-format.js: -------------------------------------------------------------------------------- 1 | const zipFormat = jest.genMockFromModule("../zip-format"); 2 | module.exports = zipFormat; 3 | -------------------------------------------------------------------------------- /__test__/e2e/data/people.csv: -------------------------------------------------------------------------------- 1 | firstName,lastName,cell,zip 2 | Firstone,Lastone,5675550001,43001 3 | Firsttwo,Lasttwo,5675550002,43002 -------------------------------------------------------------------------------- /deploy/lambda-scheduled-event.json: -------------------------------------------------------------------------------- 1 | { 2 | "command": "dispatchProcesses", 3 | "description": "see docs/DEPLOYING_AWS_LAMBDA.md" 4 | } 5 | -------------------------------------------------------------------------------- /dev-tools/babel-run: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require("@babel/register"); 3 | require("@babel/polyfill"); 4 | require("../" + process.argv[2]); 5 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /dev-tools/psql.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | 5 | docker-compose exec postgres psql -h localhost -p 5432 -U spoke spokedev "$@" 6 | -------------------------------------------------------------------------------- /src/lib/normalize-message.js: -------------------------------------------------------------------------------- 1 | export default function normalizeMessage(rawText) { 2 | return rawText.trim().replace(/[\u2018\u2019]/g, "'"); 3 | } 4 | -------------------------------------------------------------------------------- /knex-migration-stub.js: -------------------------------------------------------------------------------- 1 | exports.up = async knex => {}; 2 | 3 | exports.down = async knex => { 4 | throw Error("Down migrations not supported"); 5 | }; 6 | -------------------------------------------------------------------------------- /src/server/crypto.js: -------------------------------------------------------------------------------- 1 | import crypto from "crypto"; 2 | 3 | export function secureRandomString(size) { 4 | return crypto.randomBytes(size).toString("hex"); 5 | } 6 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/server/random-secret.js: -------------------------------------------------------------------------------- 1 | import crypto from "crypto"; 2 | 3 | export default function randomSecret() { 4 | return crypto.randomBytes(32).toString("hex"); 5 | } 6 | -------------------------------------------------------------------------------- /__test__/e2e/page-functions/index.js: -------------------------------------------------------------------------------- 1 | export * from "./campaigns"; 2 | export * from "./login"; 3 | export * from "./main"; 4 | export * from "./people"; 5 | export * from "./texter"; 6 | -------------------------------------------------------------------------------- /__test__/setup.js: -------------------------------------------------------------------------------- 1 | import { configure } from "enzyme"; 2 | import Adapter from "enzyme-adapter-react-16"; 3 | import "src/client/backcompat"; 4 | 5 | configure({ adapter: new Adapter() }); 6 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/server/lambda/twilio-webhook.js: -------------------------------------------------------------------------------- 1 | import log from "src/server/log"; 2 | 3 | exports.handler = async (event, context) => { 4 | log.info({ msg: "SQS event", event, context }); 5 | }; 6 | -------------------------------------------------------------------------------- /src/store/actions/index.js: -------------------------------------------------------------------------------- 1 | export const ADD_COUNT = "ADD_COUNT"; 2 | 3 | export function addCount(amount) { 4 | return { 5 | type: ADD_COUNT, 6 | payload: amount 7 | }; 8 | } 9 | -------------------------------------------------------------------------------- /src/styles/media-queries.js: -------------------------------------------------------------------------------- 1 | export const onMobile = "@media (maxWidth: 480px)"; 2 | export const onTablet = "@media (maxWidth: 992px)"; 3 | export const onDesktop = "@media (minWidth: 992px)"; 4 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "verbose": true, 3 | "ignore": [ 4 | "src/client", 5 | "src/components/", 6 | "src/containers/", 7 | "src/store", 8 | "src/styles" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /src/client/webpack-config.js: -------------------------------------------------------------------------------- 1 | /* eslint no-native-reassign: 0 */ 2 | /* global __webpack_public_path__ */ 3 | 4 | if (window.ASSET_DOMAIN) { 5 | __webpack_public_path__ = window.ASSET_DOMAIN; 6 | } 7 | -------------------------------------------------------------------------------- /src/lib/__mocks__/tz-helpers.js: -------------------------------------------------------------------------------- 1 | const tzHelpers = jest.genMockFromModule("../tz-helpers"); 2 | 3 | tzHelpers.getProcessEnvDstReferenceTimezone = () => "America/New_York"; 4 | 5 | module.exports = tzHelpers; 6 | -------------------------------------------------------------------------------- /dev-tools/create_test_database_ci.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | PGPASSWORD=spoke_test psql -h localhost -p 5432 -U spoke_test spoke_test < { 2 | await knex.schema.dropTable("log"); 3 | }; 4 | 5 | exports.down = async knex => { 6 | throw Error("Down migrations not supported"); 7 | }; 8 | -------------------------------------------------------------------------------- /migrations/20200211111144_drop_invite_table.js: -------------------------------------------------------------------------------- 1 | exports.up = async knex => { 2 | await knex.schema.dropTable("invite"); 3 | }; 4 | 5 | exports.down = async knex => { 6 | throw Error("Down migrations not supported"); 7 | }; 8 | -------------------------------------------------------------------------------- /src/store/reducers/count.js: -------------------------------------------------------------------------------- 1 | import { ADD_COUNT } from "../actions"; 2 | 3 | export default function(state = 0, action) { 4 | if (action.type === ADD_COUNT) { 5 | return state + action.payload; 6 | } 7 | return state; 8 | } 9 | -------------------------------------------------------------------------------- /src/server/api/mutations/label.js: -------------------------------------------------------------------------------- 1 | import db from "src/server/db"; 2 | 3 | export const mutations = { 4 | createLabel: async (_, { label }, { user }) => { 5 | return db.Label.create({ ...label, createdBy: user.id }); 6 | } 7 | }; 8 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "src/*": ["src/*"] 6 | }, 7 | "checkJs": false 8 | }, 9 | "exclude": ["node_modules", "webpack", "build", ".serverless"] 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 | -------------------------------------------------------------------------------- /__test__/e2e/page-objects/scriptEditor.js: -------------------------------------------------------------------------------- 1 | import { By } from "selenium-webdriver"; 2 | 3 | export const scriptEditor = { 4 | editor: By.css(".public-DraftEditor-content"), 5 | done: By.css("[data-test=scriptDone]"), 6 | cancel: By.css("[data-test=scriptCancel]") 7 | }; 8 | -------------------------------------------------------------------------------- /__test__/e2e/util/setup.js: -------------------------------------------------------------------------------- 1 | // This script will execute before the entire end to end run 2 | jest.setTimeout(1 * 60 * 1000); // Set the test callback timeout to 1 minute 3 | global.e2e = {}; // Pass global information around using the global object as Jasmine context isn't available. 4 | -------------------------------------------------------------------------------- /migrations/20200208195458_add_shifting_configuration.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex, Promise) { 2 | await knex.schema.alterTable("campaign", table => { 3 | table.text("shifting_configuration").nullable(); 4 | }); 5 | }; 6 | 7 | exports.down = function(knex, Promise) {}; 8 | -------------------------------------------------------------------------------- /migrations/20200210184907_drop_unused_tables.js: -------------------------------------------------------------------------------- 1 | exports.up = async knex => { 2 | await knex.schema.dropTable("pending_message_part"); 3 | await knex.schema.dropTable("user_cell"); 4 | }; 5 | 6 | exports.down = async knex => { 7 | throw Error("Down migrations not supported"); 8 | }; 9 | -------------------------------------------------------------------------------- /src/client/backcompat.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import createReactClass from "create-react-class"; 4 | 5 | // For old Apollo version that doesn't support new imports 6 | React.PropTypes = PropTypes; 7 | React.createClass = createReactClass; 8 | -------------------------------------------------------------------------------- /src/components/IncomingMessageFilter/index.jsx: -------------------------------------------------------------------------------- 1 | import IncomingMessageFilter from "./IncomingMessageFilter"; 2 | import { MESSAGE_STATUSES } from "./Filters/MessageStatusFilter"; 3 | 4 | export { MESSAGE_STATUSES, IncomingMessageFilter }; 5 | 6 | export default IncomingMessageFilter; 7 | -------------------------------------------------------------------------------- /src/components/utils.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { MenuItem } from "material-ui/Menu"; 3 | 4 | export function dataSourceItem(name, key) { 5 | return { 6 | text: name, 7 | rawValue: key, 8 | value: 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /migrations/20200212160412_add_attachments_to_message.js: -------------------------------------------------------------------------------- 1 | exports.up = async knex => { 2 | await knex.schema.alterTable("message", table => { 3 | table.text("attachments").nullable(); 4 | }); 5 | }; 6 | 7 | exports.down = async knex => { 8 | throw Error("Down migrations not supported"); 9 | }; 10 | -------------------------------------------------------------------------------- /src/lib/tz-helpers.js: -------------------------------------------------------------------------------- 1 | export function getProcessEnvTz() { 2 | return process.env.TZ; 3 | } 4 | 5 | export function getProcessEnvDstReferenceTimezone() { 6 | return ( 7 | process.env.DST_REFERENCE_TIMEZONE || 8 | global.DST_REFERENCE_TIMEZONE || 9 | "America/New_York" 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /migrations/20200222123230_add-started-at-to-campaign.js: -------------------------------------------------------------------------------- 1 | exports.up = async knex => { 2 | await knex.schema.alterTable("campaign", table => { 3 | table.timestamp("started_at").nullable(); 4 | }); 5 | }; 6 | 7 | exports.down = async knex => { 8 | throw Error("Down migrations not supported"); 9 | }; 10 | -------------------------------------------------------------------------------- /migrations/helpers/onUpdateTrigger.js: -------------------------------------------------------------------------------- 1 | // from: https://stackoverflow.com/a/48028011 2 | module.exports = function onUpdateTrigger(table) { 3 | return ` 4 | CREATE TRIGGER ${table}_updated_at 5 | BEFORE UPDATE ON ${table} 6 | FOR EACH ROW 7 | EXECUTE PROCEDURE on_update_timestamp(); 8 | `; 9 | }; 10 | -------------------------------------------------------------------------------- /src/api/message.js: -------------------------------------------------------------------------------- 1 | export const schema = ` 2 | type Message { 3 | id: ID 4 | text: String 5 | userNumber: String 6 | contactNumber: String 7 | createdAt: Date 8 | isFromContact: Boolean 9 | assignment: Assignment 10 | campaignId: String 11 | attachments: String 12 | } 13 | `; 14 | -------------------------------------------------------------------------------- /migrations/20200226200532_drop_canned_response_label_deleted_at.js: -------------------------------------------------------------------------------- 1 | exports.up = async knex => { 2 | await knex.schema.alterTable("canned_response_label", table => { 3 | table.dropColumn("deleted_at"); 4 | }); 5 | }; 6 | 7 | exports.down = async knex => { 8 | throw Error("Down migrations not supported"); 9 | }; 10 | -------------------------------------------------------------------------------- /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 "src/server/log"; 5 | export default fn => (...args) => 6 | fn(...args).catch(ex => { 7 | log.error(ex); 8 | process.nextTick(() => { 9 | throw ex; 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /migrations/20200209153016_drop_non_null_constraint_user_cell.js: -------------------------------------------------------------------------------- 1 | exports.up = async knex => { 2 | await knex.schema.alterTable("user", table => { 3 | table 4 | .string("cell") 5 | .alter() 6 | .nullable(); 7 | }); 8 | }; 9 | 10 | exports.down = async knex => { 11 | throw Error("Down migrations not supported"); 12 | }; 13 | -------------------------------------------------------------------------------- /migrations/20200211122858_add_token_to_campaign.js: -------------------------------------------------------------------------------- 1 | exports.up = async knex => { 2 | await knex.schema.alterTable("campaign", table => { 3 | table 4 | .string("join_token") 5 | .nullable() 6 | .unique(); 7 | }); 8 | }; 9 | 10 | exports.down = async knex => { 11 | throw Error("Down migrations not supported"); 12 | }; 13 | -------------------------------------------------------------------------------- /__test__/lib/tz-helpers.test.js: -------------------------------------------------------------------------------- 1 | import { getProcessEnvDstReferenceTimezone } from "../../src/lib/tz-helpers"; 2 | 3 | jest.unmock("../../src/lib/tz-helpers"); 4 | 5 | describe("test getProcessEnvDstReferenceTimezone", () => { 6 | it("works", () => { 7 | expect(getProcessEnvDstReferenceTimezone()).toEqual("America/New_York"); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /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/label.js: -------------------------------------------------------------------------------- 1 | import { accessRequired } from "src/server/api/errors"; 2 | 3 | export const resolvers = { 4 | Label: { 5 | createdBy: async ({ organizationId, createdBy }, _, { loaders, user }) => { 6 | await accessRequired(user, organizationId, "ADMIN"); 7 | return loaders.user.load(createdBy); 8 | } 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /migrations/20200208170807_init.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | 3 | exports.up = async knex => { 4 | const initalSchema = fs.readFileSync( 5 | `${__dirname}/initial_db_dump.sql`, 6 | "utf8" 7 | ); 8 | await knex.raw(initalSchema); 9 | }; 10 | 11 | exports.down = async knex => { 12 | throw Error("Down migrations not supported"); 13 | }; 14 | -------------------------------------------------------------------------------- /src/containers/ConversationTexter/components/index.js: -------------------------------------------------------------------------------- 1 | export { default as OptOutDialog } from "./OptOutDialog"; 2 | export { default as SkipDialog } from "./SkipDialog"; 3 | export { default as ConversationsMenu } from "./ConversationsMenu"; 4 | export { default as ContactToolbar } from "./ContactToolbar"; 5 | export { default as MessageList } from "./MessageList"; 6 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/server/s3.js: -------------------------------------------------------------------------------- 1 | import AWS from "aws-sdk"; 2 | 3 | const params = { 4 | signatureVersion: "v4", 5 | params: { 6 | Bucket: process.env.AWS_S3_BUCKET_NAME 7 | } 8 | }; 9 | 10 | if (process.env.S3_ENDPOINT) { 11 | params.endpoint = process.env.S3_ENDPOINT; 12 | params.s3ForcePathStyle = true; 13 | } 14 | 15 | export default new AWS.S3(params); 16 | -------------------------------------------------------------------------------- /dev-tools/migrate.js: -------------------------------------------------------------------------------- 1 | import { r } from "../src/server/models"; 2 | 3 | async function migrate() { 4 | console.log("Beginning migrations..."); 5 | try { 6 | await r.knex.migrate.latest(); 7 | console.log("All done!"); 8 | } catch (e) { 9 | console.log("Error running migrations", e); 10 | } 11 | } 12 | 13 | migrate().then(() => process.exit()); 14 | -------------------------------------------------------------------------------- /migrations/20200224110931_add_file_name_and_status_to_campaign.js: -------------------------------------------------------------------------------- 1 | exports.up = async knex => { 2 | await knex.schema.alterTable("campaign", async table => { 3 | table.string("status").nullable(); 4 | table.string("contact_file_name").nullable(); 5 | }); 6 | }; 7 | 8 | exports.down = async knex => { 9 | throw Error("Down migrations not supported"); 10 | }; 11 | -------------------------------------------------------------------------------- /migrations/20200226133859_add_user_subscribed_column.js: -------------------------------------------------------------------------------- 1 | exports.up = async knex => { 2 | await knex.schema.alterTable("user", async table => { 3 | table 4 | .boolean("subscribed_to_reminders") 5 | .notNullable() 6 | .default(true); 7 | }); 8 | }; 9 | 10 | exports.down = async knex => { 11 | throw Error("Down migrations not supported"); 12 | }; 13 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /migrations/20200223202457_remove_assignment_id_non_null_contraint.js: -------------------------------------------------------------------------------- 1 | exports.up = async knex => { 2 | await knex.schema.alterTable("opt_out", async table => { 3 | await table 4 | .integer("assignment_id") 5 | .alter() 6 | .nullable(); 7 | }); 8 | }; 9 | 10 | exports.down = async knex => { 11 | throw Error("Down migrations not supported"); 12 | }; 13 | -------------------------------------------------------------------------------- /src/containers/__mocks__/RequestBatchButton.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | The actual RequestBatchButton is a connected component, which makes 3 | it not work in tests of non-connected components. So we provide this 4 | mock for use in Jest tests. 5 | */ 6 | 7 | import React from "react"; 8 | 9 | export default function RequestBatchButton() { 10 | return RequestBatchButton; 11 | } 12 | -------------------------------------------------------------------------------- /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 | questionResponse(campaignContactId: String): QuestionResponse 12 | } 13 | `; 14 | -------------------------------------------------------------------------------- /src/components/DualNavDataTables/index.jsx: -------------------------------------------------------------------------------- 1 | import DataTables from "material-ui-datatables"; 2 | import React from "react"; 3 | import withPagination from "../Paginated/withPagination"; 4 | 5 | const DualNavDataTables = props => ( 6 | 7 | ); 8 | 9 | export default withPagination(DualNavDataTables, true, true); 10 | -------------------------------------------------------------------------------- /src/server/log.js: -------------------------------------------------------------------------------- 1 | import pino from "pino"; 2 | import config from "../server/config"; 3 | 4 | const logConfig = { base: null, level: config.LOG_LEVEL }; 5 | 6 | if (process.env.NODE_ENV !== "production") { 7 | // Note: pino-pretty is installed as a dev package 8 | logConfig.prettyPrint = { 9 | ignore: "pid,hostname", 10 | colorize: true 11 | }; 12 | } 13 | 14 | export default pino(logConfig); 15 | -------------------------------------------------------------------------------- /dev-tools/sentry-release-finalize.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euxo pipefail 4 | 5 | curl -sL https://sentry.io/get-cli/ | bash || true 6 | 7 | export SENTRY_ORG=your-sentry-org 8 | 9 | export VERSION=$(echo $GITHUB_REF | grep -oh 'release-.*' | tr -d '[:space:]') 10 | 11 | if [ -z "$VERSION" ]; then 12 | echo "$GITHUB_REF is not a valid release tag" 13 | exit 1 14 | fi 15 | 16 | sentry-cli releases finalize $VERSION 17 | -------------------------------------------------------------------------------- /migrations/20200226133857_add_message_created_at_index.js: -------------------------------------------------------------------------------- 1 | exports.up = async knex => { 2 | await knex.schema.alterTable("message", table => { 3 | table 4 | .timestamp("created_at") 5 | .alter() 6 | .notNullable() 7 | .default(knex.raw("CURRENT_TIMESTAMP")) 8 | .index(); 9 | }); 10 | }; 11 | 12 | exports.down = async knex => { 13 | throw Error("Down migrations not supported"); 14 | }; 15 | -------------------------------------------------------------------------------- /src/api/label.js: -------------------------------------------------------------------------------- 1 | import gql from "graphql-tag"; 2 | 3 | export const schema = gql` 4 | input LabelInput { 5 | organizationId: ID! 6 | group: String 7 | displayValue: String! 8 | slug: String 9 | } 10 | 11 | type Label { 12 | id: ID! 13 | organizationId: ID! 14 | group: String 15 | displayValue: String! 16 | slug: String! 17 | createdBy: User 18 | createdAt: Date! 19 | } 20 | `; 21 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | schema.graphql 25 | .serverless/ 26 | webpack/bundle.js 27 | scratch/ 28 | sample*.csv -------------------------------------------------------------------------------- /dev-tools/sentry-release-create.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euxo pipefail 4 | 5 | curl -sL https://sentry.io/get-cli/ | bash || true 6 | 7 | export SENTRY_ORG=your-sentry-org 8 | 9 | export VERSION=$(echo $GITHUB_REF | grep -oh 'release-.*') 10 | 11 | if [ -z "$VERSION" ]; then 12 | echo "$GITHUB_REF is not a valid release tag" 13 | exit 1 14 | fi 15 | 16 | sentry-cli releases new -p spoke $VERSION 17 | sentry-cli releases set-commits --auto $VERSION 18 | -------------------------------------------------------------------------------- /src/client/lib/error-helpers.js: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | 3 | export function getGraphQLErrors(response) { 4 | return _.get(response, "errors.graphQLErrors", []); 5 | } 6 | 7 | /** 8 | * Checks for a given error code and returns the _first_ error with that code 9 | * in the response's graphql errors array. 10 | */ 11 | export function checkForErrorCode(response, code) { 12 | return _.find(getGraphQLErrors(response), err => err.code === code); 13 | } 14 | -------------------------------------------------------------------------------- /__test__/e2e/page-objects/index.js: -------------------------------------------------------------------------------- 1 | import { campaigns } from "./campaigns"; 2 | import { main } from "./main"; 3 | import { login } from "./login"; 4 | import { navigation } from "./navigation"; 5 | import { people } from "./people"; 6 | import { scriptEditor } from "./scriptEditor"; 7 | import { texter } from "./texter"; 8 | 9 | export default { 10 | campaigns, 11 | login, 12 | main, 13 | navigation, 14 | people, 15 | scriptEditor, 16 | texter 17 | }; 18 | -------------------------------------------------------------------------------- /src/api/conversations.js: -------------------------------------------------------------------------------- 1 | export const schema = ` 2 | input ConversationFilter { 3 | assignmentsFilter: AssignmentsFilter 4 | campaignsFilter: CampaignsFilter 5 | contactsFilter: ContactsFilter 6 | } 7 | 8 | type Conversation { 9 | texter: User! 10 | contact: CampaignContact! 11 | campaign: Campaign! 12 | } 13 | 14 | type PaginatedConversations { 15 | conversations: [Conversation]! 16 | pageInfo: PageInfo 17 | } 18 | `; 19 | -------------------------------------------------------------------------------- /src/api/background-job.js: -------------------------------------------------------------------------------- 1 | import gql from "graphql-tag"; 2 | 3 | export const schema = gql` 4 | enum BackgroundJobStatus { 5 | PENDING 6 | RUNNING 7 | FAILED 8 | DONE 9 | } 10 | 11 | type BackgroundJob { 12 | id: ID! 13 | campaignId: ID 14 | organizationId: ID 15 | userId: ID 16 | resultMessage: String 17 | progress: Float 18 | status: BackgroundJobStatus 19 | createdAt: Date 20 | updatedAt: Date 21 | } 22 | `; 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Makefile 2 | 3 | build-image: 4 | ./deploy-tools build-image 5 | .PHONY: build-image 6 | 7 | install-deps: 8 | ./deploy-tools install-deps 9 | 10 | build-app: 11 | ./deploy-tools build-app 12 | .PHONY: build-app 13 | 14 | artifacts: build-image 15 | ./deploy-tools extract-artifacts 16 | .PHONY: artifacts 17 | 18 | deploy-build: 19 | ./deploy-tools deploy 20 | .PHONY: deploy-build 21 | 22 | deploy: build-app 23 | ./deploy-tools deploy 24 | .PHONY: deploy 25 | -------------------------------------------------------------------------------- /__test__/e2e/page-objects/navigation.js: -------------------------------------------------------------------------------- 1 | import { By } from "selenium-webdriver"; 2 | 3 | export const navigation = { 4 | sections: { 5 | campaigns: By.css("[data-test=navCampaigns]"), 6 | people: By.css("[data-test=navPeople]"), 7 | optouts: By.css("[data-test=navOptouts]"), 8 | messageReview: By.css("[data-test=navIncoming]"), 9 | settings: By.css("[data-test=navSettings]"), 10 | switchToTexter: By.css("[data-test=navSwitchToTexter]") 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /src/components/Paginated/PaginatedList.jsx: -------------------------------------------------------------------------------- 1 | import type from "prop-types"; 2 | import React from "react"; 3 | import { List } from "material-ui/List"; 4 | import withPagination from "../Paginated/withPagination"; 5 | 6 | const PaginatedList = props => ( 7 | {props.children} 8 | ); 9 | 10 | PaginatedList.propTypes = { 11 | children: type.arrayOf(type.object) 12 | }; 13 | 14 | export default withPagination(PaginatedList, true, true); 15 | -------------------------------------------------------------------------------- /dev-tools/create_test_database.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env 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 | -------------------------------------------------------------------------------- /src/components/forms/GSFormField.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | import React from "react"; 3 | 4 | class GSFormField extends React.Component { 5 | floatingLabelText() { 6 | return this.props.floatingLabelText === false 7 | ? null 8 | : this.props.floatingLabelText || this.props.label; 9 | } 10 | } 11 | 12 | GSFormField.propTypes = { 13 | floatingLabelText: PropTypes.string, 14 | label: PropTypes.string 15 | }; 16 | 17 | export default GSFormField; 18 | -------------------------------------------------------------------------------- /src/server/api/mocks.js: -------------------------------------------------------------------------------- 1 | const randomString = () => 2 | Math.random() 3 | .toString(36) 4 | .replace(/[^a-z]+/g, "") 5 | .substr(0, 5); 6 | const mocks = { 7 | String: () => `STRING_MOCK_${randomString()}`, 8 | Date: () => new Date(), 9 | Int: () => 42, 10 | ID: () => `ID_MOCK_${randomString()}`, 11 | Phone: () => "+12223334444", 12 | Timezone: () => ({ offset: -9, hasDST: true }), 13 | JSON: () => '{"field1":"value1", "field2": "value2"}' 14 | }; 15 | 16 | export default mocks; 17 | -------------------------------------------------------------------------------- /.babelrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/env", 5 | { 6 | "targets": { 7 | "node": true 8 | }, 9 | "modules": "commonjs" 10 | } 11 | ] 12 | ], 13 | "plugins": [ 14 | "@babel/plugin-proposal-class-properties", 15 | "@babel/plugin-proposal-export-default-from", 16 | [ 17 | "babel-plugin-module-resolver", 18 | { 19 | "root": ["."] 20 | } 21 | ] 22 | ], 23 | "only": ["**/*.js", "**/*.jsx"] 24 | } 25 | -------------------------------------------------------------------------------- /src/api/assignment.js: -------------------------------------------------------------------------------- 1 | export const schema = ` 2 | input AssignmentsFilter { 3 | texterId: Int 4 | } 5 | type Assignment { 6 | id: ID 7 | texter: User 8 | campaign: Campaign 9 | contacts(contactsFilter: ContactsFilter): [CampaignContact] 10 | contactsCount(contactsFilter: ContactsFilter): Int 11 | userCannedResponses: [CannedResponse] 12 | campaignCannedResponses: [CannedResponse] 13 | maxContacts: Int 14 | contactCounts: [ContactCountByMessageStatus]! 15 | } 16 | `; 17 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /migrations/20200222200118_add_user_trigram_indices.js: -------------------------------------------------------------------------------- 1 | exports.up = async knex => { 2 | await knex.raw(` 3 | CREATE INDEX IF NOT EXISTS user_first_name_trgm_idx ON "user" USING GIN (first_name gin_trgm_ops); 4 | CREATE INDEX IF NOT EXISTS user_last_name_trgm_idx ON "user" USING GIN (last_name gin_trgm_ops); 5 | CREATE INDEX IF NOT EXISTS user_email_trgm_idx ON "user" USING GIN (email gin_trgm_ops); 6 | `); 7 | }; 8 | 9 | exports.down = async knex => { 10 | throw Error("Down migrations not supported"); 11 | }; 12 | -------------------------------------------------------------------------------- /src/api/twilio-phone-number.js: -------------------------------------------------------------------------------- 1 | import gql from "graphql-tag"; 2 | 3 | export const schema = gql` 4 | enum PhoneNumberStatus { 5 | AVAILABLE 6 | RESERVED 7 | ASSIGNED 8 | } 9 | 10 | type PhoneNumbersByAreaCode { 11 | areaCode: String! 12 | count: Int! 13 | reservedAt: DateTime 14 | campaignTitle: String 15 | 16 | } 17 | 18 | type PhoneNumberCountsByStatus { 19 | areaCode: String! 20 | availableCount: Int! 21 | reservedCount: Int! 22 | assignedCount: Int! 23 | } 24 | `; 25 | -------------------------------------------------------------------------------- /migrations/20200208193050_add_canned_response_deleted_and_order.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | await knex.schema.alterTable("canned_response", table => { 3 | table 4 | .boolean("deleted") 5 | .notNullable() 6 | .default(false); 7 | table 8 | .integer("order") 9 | .notNullable() 10 | .default(0); 11 | }); 12 | console.log("added canned_response deleted and order columns"); 13 | }; 14 | 15 | exports.down = function(knex) { 16 | throw Error("Down migrations not supported"); 17 | }; 18 | -------------------------------------------------------------------------------- /src/server/api/canned-response.js: -------------------------------------------------------------------------------- 1 | import db from "src/server/db"; 2 | import { mapFieldsToModel } from "./lib/utils"; 3 | import { CannedResponse } from "../models"; 4 | 5 | export const resolvers = { 6 | CannedResponse: { 7 | ...mapFieldsToModel( 8 | ["id", "title", "text", "surveyQuestion", "deleted"], 9 | CannedResponse 10 | ), 11 | isUserCreated: cannedResponse => cannedResponse.user_id !== "", 12 | labels: async cannedResponse => 13 | db.CannedResponse.listLabels(cannedResponse.id) 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /src/server/models/cacheable_queries/index.js: -------------------------------------------------------------------------------- 1 | import userCache from "./user"; 2 | import { organizationCache } from "./organization"; 3 | import { cannedResponseCache } from "./canned-response"; 4 | import { campaignCache } from "./campaign"; 5 | 6 | const cacheableData = { 7 | campaign: campaignCache, 8 | cannedResponse: cannedResponseCache, 9 | // Note: Not used in the Warren fork see db/opt-out.js: 10 | // optOut: optOutCache, 11 | organization: organizationCache, 12 | user: userCache 13 | }; 14 | 15 | export { cacheableData }; 16 | -------------------------------------------------------------------------------- /src/lib/attributes.js: -------------------------------------------------------------------------------- 1 | // Used to generate data-test attributes on non-production environments and used by end-to-end tests 2 | export const dataTest = (value, disable) => { 3 | const attribute = 4 | window.NODE_ENV !== "production" && !disable ? { "data-test": value } : {}; 5 | return attribute; 6 | }; 7 | 8 | export const camelCase = str => { 9 | return str 10 | .replace(/(?:^\w|[A-Z]|\b\w)/g, (letter, index) => { 11 | return index == 0 ? letter.toLowerCase() : letter.toUpperCase(); 12 | }) 13 | .replace(/\s+/g, ""); 14 | }; 15 | -------------------------------------------------------------------------------- /src/api/canned-response.js: -------------------------------------------------------------------------------- 1 | export const schema = ` 2 | input CannedResponseInput { 3 | id: String 4 | title: String 5 | text: String 6 | surveyQuestion: String 7 | campaignId: String 8 | userId: String 9 | deleted: Boolean 10 | isNew: Boolean 11 | isUpdated: Boolean 12 | labelIds: [ID] 13 | } 14 | 15 | type CannedResponse { 16 | id: ID 17 | title: String 18 | text: String 19 | surveyQuestion: String 20 | isUserCreated: Boolean 21 | deleted: Boolean 22 | labels: [Label] 23 | } 24 | `; 25 | -------------------------------------------------------------------------------- /src/server/models/datawarehouse.js: -------------------------------------------------------------------------------- 1 | import knex from "knex"; 2 | 3 | let config; 4 | 5 | if (process.env.WAREHOUSE_DB_TYPE) { 6 | config = { 7 | client: process.env.WAREHOUSE_DB_TYPE, 8 | connection: { 9 | host: process.env.WAREHOUSE_DB_HOST, 10 | port: process.env.WAREHOUSE_DB_PORT, 11 | database: process.env.WAREHOUSE_DB_NAME, 12 | password: process.env.WAREHOUSE_DB_PASSWORD, 13 | user: process.env.WAREHOUSE_DB_USER 14 | } 15 | }; 16 | } 17 | 18 | export default process.env.WAREHOUSE_DB_TYPE ? () => knex(config) : null; 19 | -------------------------------------------------------------------------------- /src/server/models/assignment.js: -------------------------------------------------------------------------------- 1 | import thinky from "./thinky"; 2 | import { requiredString, timestamp } from "./custom-types"; 3 | 4 | const type = thinky.type; 5 | 6 | const Assignment = thinky.createModel( 7 | "assignment", 8 | type 9 | .object() 10 | .schema({ 11 | id: type.string(), 12 | user_id: requiredString(), 13 | campaign_id: requiredString(), 14 | created_at: timestamp(), 15 | max_contacts: type.integer() 16 | }) 17 | .allowExtra(false), 18 | { noAutoCreation: true } 19 | ); 20 | 21 | export default Assignment; 22 | -------------------------------------------------------------------------------- /travis-run-e2e-tests: -------------------------------------------------------------------------------- 1 | # Turning off e2e tests on travis until jest configuration is fixed. 2 | #!/bin/bash 3 | # set -ev 4 | # Only run on non-pull requests 5 | # https://docs.travis-ci.com/user/pull-requests/#Pull-Requests-and-Security-Restrictions 6 | # if [ "${TRAVIS_PULL_REQUEST}" = "false" ]; then 7 | # # Start dev server 8 | # npm run dev-nowrap & 9 | # # Wait for server availability https://github.com/jeffbski/wait-on#readme 10 | # wait-on -d 30000 -i 1000 -w 2000 http-get://localhost:3000 11 | # # Run end to end tests 12 | # npm run test-e2e --saucelabs 13 | # fi 14 | -------------------------------------------------------------------------------- /__test__/e2e/page-objects/texter.js: -------------------------------------------------------------------------------- 1 | import { By } from "selenium-webdriver"; 2 | 3 | export const texter = { 4 | sendFirstTexts: By.css("[data-test=sendFirstTexts]"), 5 | sendReplies: By.css("[data-test=sendReplies]"), 6 | send: By.css("[data-test=send]:not([disabled])"), 7 | replyByText(text) { 8 | return By.xpath( 9 | `//*[@data-test='messageList']/descendant::*[contains(text(),'${text}')]` 10 | ); 11 | }, 12 | emptyTodo: By.css("[data-test=empty]"), 13 | optOut: { 14 | button: By.css("[data-test=optOut]"), 15 | send: By.css("[type=submit]") 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /migrations/20200228171536_campaign_status_data_mig.js: -------------------------------------------------------------------------------- 1 | exports.up = async knex => { 2 | await knex.raw(` 3 | UPDATE campaign 4 | SET status = CASE 5 | WHEN is_archived THEN 'ARCHIVED' 6 | WHEN is_started IS NOT TRUE THEN 'NOT_STARTED' 7 | WHEN is_started THEN 'ACTIVE' 8 | END 9 | WHERE status IS NULL; 10 | `); 11 | 12 | knex.schema.alterTable("campaign", table => { 13 | table 14 | .string("status") 15 | .alter() 16 | .notNullable(); 17 | }); 18 | }; 19 | 20 | exports.down = async knex => { 21 | throw Error("Down migrations not supported"); 22 | }; 23 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "10.3" 4 | cache: 5 | yarn: true 6 | services: 7 | - postgresql 8 | env: 9 | global: 10 | - secure: TODO 11 | before_install: 12 | - curl -o- -L https://yarnpkg.com/install.sh | bash -s -- --version 1.13.0 13 | - export PATH="$HOME/.yarn/bin:$PATH" 14 | install: 15 | - yarn install --frozen-lockfile 16 | before_script: 17 | - psql -c 'CREATE DATABASE spoke_test;' -U postgres 18 | - psql -c "CREATE USER spoke_test WITH PASSWORD 'spoke_test';" -U postgres 19 | - psql -c 'GRANT ALL PRIVILEGES ON DATABASE spoke_test TO spoke_test;' -U postgres 20 | script: 21 | - yarn test 22 | -------------------------------------------------------------------------------- /dev-tools/list-numbers.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const twilio = require("twilio"); 3 | 4 | async function main() { 5 | try { 6 | const client = twilio( 7 | process.env.TWILIO_ACCOUNT_SID, 8 | process.env.TWILIO_AUTH_TOKEN 9 | ); 10 | 11 | let page = await client.incomingPhoneNumbers.page({ pageSize: 100 }); 12 | 13 | while (page) { 14 | for (const i of page.instances) { 15 | console.log(JSON.stringify(i)); 16 | } 17 | page = await page.nextPage(); 18 | } 19 | } catch (e) { 20 | console.error(e); 21 | } 22 | } 23 | 24 | main().then(() => process.exit()); 25 | -------------------------------------------------------------------------------- /src/lib/permissions.js: -------------------------------------------------------------------------------- 1 | export const ROLE_HIERARCHY = [ 2 | "SUSPENDED", 3 | "TEXTER", 4 | "SUPERVOLUNTEER", 5 | "ADMIN", 6 | "OWNER" 7 | ]; 8 | 9 | export const isRoleGreater = (role1, role2) => 10 | ROLE_HIERARCHY.indexOf(role1) > ROLE_HIERARCHY.indexOf(role2); 11 | 12 | export const hasRoleAtLeast = (hasRole, wantsRole) => 13 | ROLE_HIERARCHY.indexOf(hasRole) >= ROLE_HIERARCHY.indexOf(wantsRole); 14 | 15 | export const getHighestRole = roles => 16 | roles.slice(0).sort(isRoleGreater)[roles.length - 1]; 17 | 18 | export const hasRole = (role, roles) => 19 | hasRoleAtLeast(getHighestRole(roles), role); 20 | -------------------------------------------------------------------------------- /src/components/Error404.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { RaisedButton } from "material-ui"; 3 | import { withRouter } from "react-router"; 4 | 5 | function Error404({ router }) { 6 | return ( 7 |
8 |

There's No Page Here

9 |

10 | Sorry, this page doesn't exist. You'll have to look elsewhere for big, 11 | structural, change! 12 |

13 | router.push("/")} /> 14 |
15 | ); 16 | } 17 | 18 | export default withRouter(Error404); 19 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/server/models/tag.js: -------------------------------------------------------------------------------- 1 | import thinky from "./thinky"; 2 | import { requiredString, timestamp, optionalTimestamp } from "./custom-types"; 3 | 4 | const type = thinky.type; 5 | 6 | const Tag = thinky.createModel( 7 | "tag", 8 | type 9 | .object() 10 | .schema({ 11 | id: type.string(), 12 | campaign_contact_id: requiredString(), 13 | tag: requiredString(), 14 | created_at: timestamp(), 15 | created_by: type.integer().required(), 16 | resolved_at: optionalTimestamp(), 17 | resolved_by: type.integer() 18 | }) 19 | .allowExtra(true), 20 | { noAutoCreation: true } 21 | ); 22 | 23 | export default Tag; 24 | -------------------------------------------------------------------------------- /src/components/DisplayLink.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | import React from "react"; 3 | import TextField from "material-ui/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 | -------------------------------------------------------------------------------- /dev-tools/create-minio-bucket.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | 5 | MINIO_CONTAINER_ID="$(docker-compose ps -q minio)" 6 | MINIO_CONTAINER_IP="$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' $MINIO_CONTAINER_ID)" 7 | MINIO_CONTAINER_NETWORK="$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.NetworkID}}{{end}}' $MINIO_CONTAINER_ID)" 8 | 9 | docker run --rm --name minio-client -it \ 10 | --env MINIO_SERVER_HOST="$MINIO_CONTAINER_IP" \ 11 | --env MINIO_SERVER_ACCESS_KEY="DEVACCESSKEY" \ 12 | --env MINIO_SERVER_SECRET_KEY="DEVSECRETKEY" \ 13 | --network "$MINIO_CONTAINER_NETWORK" \ 14 | bitnami/minio-client \ 15 | mb minio/spoke-local --ignore-existing 16 | -------------------------------------------------------------------------------- /docs/HOWTO_CONNECT_WITH_REDIS.md: -------------------------------------------------------------------------------- 1 | # Instructions for using Redis in Development and Production 2 | 3 | - To add a caching layer to your development and production environments, follow the below instructions to get setup. Initial Redis setup is included in `src/server/models/thinky.js`. 4 | 5 | ## Development 6 | 7 | - Using Redis in development can be achieved by connecting a local Redis server. 8 | 9 | ### To use a Local Redis Server 10 | 11 | - Run `npm install` 12 | - docker-compose up -d 13 | - Add `REDIS_URL=//127.0.0.1:6379` to your `.env` file 14 | 15 | ## Production 16 | 17 | - Point REDIS_URL in production environment variables configuration to production Redis instance url. For example `redis://[url]:6379`. 18 | -------------------------------------------------------------------------------- /migrations/20200226133858_add_notifications.js: -------------------------------------------------------------------------------- 1 | exports.up = async knex => { 2 | await knex.schema.createTable("notification", table => { 3 | table.increments(); 4 | 5 | table 6 | .text("email") 7 | .notNullable() 8 | .index(); 9 | table 10 | .integer("user_id") 11 | .notNullable() 12 | .references("id") 13 | .inTable("user"); 14 | table.text("notification_type").notNullable(); 15 | table.jsonb("data"); 16 | 17 | table 18 | .timestamp("created_at") 19 | .notNullable() 20 | .default(knex.raw("CURRENT_TIMESTAMP")) 21 | .index(); 22 | }); 23 | }; 24 | 25 | exports.down = async knex => { 26 | throw Error("Down migrations not supported"); 27 | }; 28 | -------------------------------------------------------------------------------- /src/components/forms/GSPasswordField.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import TextField from "material-ui/TextField"; 3 | import GSFormField from "./GSFormField"; 4 | 5 | export default class GSPasswordField extends GSFormField { 6 | render() { 7 | let value = this.props.value; 8 | return ( 9 | event.target.select()} 15 | {...this.props} 16 | value={value} 17 | onChange={event => { 18 | this.props.onChange(event.target.value); 19 | }} 20 | type="password" 21 | /> 22 | ); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/lib/state-codes.js: -------------------------------------------------------------------------------- 1 | export default new Set([ 2 | // States 3 | "AL", 4 | "AK", 5 | "AZ", 6 | "AR", 7 | "CA", 8 | "CO", 9 | "CT", 10 | "DE", 11 | "FL", 12 | "GA", 13 | "HI", 14 | "ID", 15 | "IL", 16 | "IN", 17 | "IA", 18 | "KS", 19 | "KY", 20 | "LA", 21 | "ME", 22 | "MD", 23 | "MA", 24 | "MI", 25 | "MN", 26 | "MS", 27 | "MO", 28 | "MT", 29 | "NE", 30 | "NV", 31 | "NH", 32 | "NJ", 33 | "NM", 34 | "NY", 35 | "NC", 36 | "ND", 37 | "OH", 38 | "OK", 39 | "OR", 40 | "PA", 41 | "RI", 42 | "SC", 43 | "SD", 44 | "TN", 45 | "TX", 46 | "UT", 47 | "VT", 48 | "VA", 49 | "WA", 50 | "WV", 51 | "WI", 52 | "WY", 53 | 54 | // DC 55 | "DC" 56 | ]); 57 | -------------------------------------------------------------------------------- /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: type.string(), 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 | export default OptOut; 25 | -------------------------------------------------------------------------------- /src/server/api/lib/services.js: -------------------------------------------------------------------------------- 1 | import twilio from "./twilio"; 2 | import fakeservice from "./fakeservice"; 3 | 4 | // TODO: Remove all references to nexmo 5 | // import nexmo from "./nexmo"; 6 | 7 | // Each service needs the following api points: 8 | // async sendMessage(message, contact, trx) -> void 9 | // To receive messages from the outside, you will probably need to implement these, as well: 10 | // async handleIncomingMessage() -> saved (new) messagePart.id 11 | // async convertMessagePartsToMessage(messagePartsGroupedByMessage) -> new Message() 12 | 13 | const serviceMap = { 14 | // TODO: Remove all references to nexmo 15 | // nexmo, 16 | twilio, 17 | fakeservice 18 | }; 19 | 20 | export default serviceMap; 21 | -------------------------------------------------------------------------------- /__test__/e2e/page-objects/main.js: -------------------------------------------------------------------------------- 1 | import { By } from "selenium-webdriver"; 2 | 3 | export const main = { 4 | organization: { 5 | name: By.css("[data-test=organization]"), 6 | submit: By.css('button[name="submit"]') 7 | }, 8 | userMenuButton: By.css("[data-test=userMenuButton]"), 9 | userMenuDisplayName: By.css("[data-test=userMenuDisplayName]"), 10 | edit: { 11 | editButton: By.css("[data-test=editPerson]"), 12 | firstName: By.css("[data-test=firstName]"), 13 | lastName: By.css("[data-test=lastName]"), 14 | email: By.css("[data-test=email]"), 15 | cell: By.css("[data-test=cell]"), 16 | save: By.css("[type=submit]") 17 | }, 18 | home: By.css("[data-test=home]"), 19 | logOut: By.css("[data-test=userMenuLogOut]") 20 | }; 21 | -------------------------------------------------------------------------------- /__test__/e2e/util/config.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | baseUrl: global.BASE_URL, 3 | sauceLabs: { 4 | username: process.env.SAUCE_USERNAME, 5 | accessKey: process.env.SAUCE_ACCESS_KEY, 6 | capabilities: { 7 | name: "Spoke - Chrome E2E Tests", 8 | browserName: "chrome", 9 | idleTimeout: 240, // 4 minute idle 10 | "tunnel-identifier": process.env.TRAVIS_JOB_NUMBER, 11 | username: process.env.SAUCE_USERNAME, 12 | accessKey: process.env.SAUCE_ACCESS_KEY, 13 | build: process.env.TRAVIS_BUILD_NUMBER 14 | }, 15 | server: `http://${process.env.SAUCE_USERNAME}:${process.env.SAUCE_ACCESS_KEY}@ondemand.saucelabs.com:80/wd/hub`, 16 | host: "localhost", 17 | port: 4445 18 | } 19 | }; 20 | 21 | export default config; 22 | -------------------------------------------------------------------------------- /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 | export default QuestionResponse; 24 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | // We maintain a babelrc.json because some babel tools only accept static JSON. 2 | // But for Jest, we need to configure some more options to be able to load JSX. 3 | // Jest supports a dynamic babel.config.js so we have this script load the base 4 | // config and extend it. 5 | 6 | const path = require("path"); 7 | const fs = require("fs"); 8 | 9 | const baseConfigPath = path.join(__dirname, ".babelrc.json"); 10 | const baseConfigJSON = fs.readFileSync(baseConfigPath); 11 | 12 | module.exports = api => { 13 | const isTest = api.env("test"); 14 | 15 | const config = JSON.parse(baseConfigJSON); 16 | 17 | if (isTest) { 18 | config.presets.unshift(["@babel/react"]); 19 | config.only.push("**/*.jsx"); 20 | } 21 | 22 | return config; 23 | }; 24 | -------------------------------------------------------------------------------- /src/components/forms/GSTextField.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import TextField from "material-ui/TextField"; 3 | import GSFormField from "./GSFormField"; 4 | import _ from "lodash"; 5 | 6 | export default class GSTextField extends GSFormField { 7 | render() { 8 | let value = this.props.value; 9 | return ( 10 | event.target.select()} 16 | {..._.omit(this.props, "errors", "invalid")} 17 | value={value} 18 | onChange={event => { 19 | this.props.onChange(event.target.value); 20 | }} 21 | type="text" 22 | /> 23 | ); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /jest.config.e2e.js: -------------------------------------------------------------------------------- 1 | const _ = require("lodash"); 2 | const config = require("./jest.config"); 3 | 4 | const overrides = { 5 | setupTestFrameworkScriptFile: "/__test__/e2e/util/setup.js", 6 | testMatch: ["**/__test__/e2e/**/*.test.js"], 7 | testPathIgnorePatterns: [ 8 | "/node_modules/", 9 | "/__test__/e2e/util/", 10 | "/__test__/e2e/pom/" 11 | ], 12 | bail: true // To learn about errors sooner 13 | }; 14 | const merges = { 15 | // Merge in changes to deeper objects 16 | globals: { 17 | // This sets the BASE_URL for the target of the e2e tests (what the tests are testing) 18 | BASE_URL: "localhost:3000" 19 | } 20 | }; 21 | 22 | module.exports = _.chain(config) 23 | .assign(overrides) 24 | .merge(merges) 25 | .value(); 26 | -------------------------------------------------------------------------------- /src/components/forms/GSSelectField.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import SelectField from "material-ui/SelectField"; 3 | import { MenuItem } from "material-ui/Menu"; 4 | 5 | import GSFormField from "./GSFormField"; 6 | 7 | export default class GSSelectField extends GSFormField { 8 | createMenuItems() { 9 | return this.props.choices.map(({ value, label }) => ( 10 | 11 | )); 12 | } 13 | 14 | render() { 15 | return ( 16 | { 21 | this.props.onChange(value); 22 | }} 23 | /> 24 | ); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /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 | "SUSPENDED" 22 | ) 23 | }) 24 | .allowExtra(false), 25 | { noAutoCreation: true, dependencies: [User, Organization] } 26 | ); 27 | 28 | export default UserOrganization; 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/lambda/send-reminders.js: -------------------------------------------------------------------------------- 1 | import { sendReminderEmails } from "src/server/notifications"; 2 | import log from "src/server/log"; 3 | import telemetry from "src/server/telemetry"; 4 | 5 | async function handler(event) { 6 | log.debug({ msg: "Received event", event }); 7 | 8 | log.info("Sending reminder emails..."); 9 | 10 | try { 11 | await sendReminderEmails(); 12 | } catch (e) { 13 | log.error({ msg: "Caught exception while sending reminders", err: e }); 14 | await telemetry.reportError(e, { jobType: "sendReminders" }); 15 | throw e; 16 | } 17 | 18 | log.info("All done."); 19 | } 20 | 21 | if (process.env.LAMBDA_INVOKE_IMMEDIATELY === "1") { 22 | handler() 23 | .catch(e => console.error(e)) 24 | .then(() => process.exit()); 25 | } 26 | 27 | module.exports.handler = handler; 28 | -------------------------------------------------------------------------------- /src/api/organization.js: -------------------------------------------------------------------------------- 1 | import gql from "graphql-tag"; 2 | 3 | export const schema = gql` 4 | type Organization { 5 | id: ID 6 | uuid: String 7 | name: String 8 | campaigns( 9 | cursor: OffsetLimitCursor 10 | campaignsFilter: CampaignsFilter 11 | sortBy: SortCampaignsBy 12 | ): CampaignsReturn 13 | people(role: String, campaignId: String, sortBy: SortPeopleBy): [User] 14 | optOuts: [OptOut] 15 | threeClickEnabled: Boolean 16 | optOutMessage: String 17 | textingHoursEnforced: Boolean 18 | textingHoursStart: Int 19 | textingHoursEnd: Int 20 | campaignPhoneNumbersEnabled: Boolean! 21 | availablePhoneNumbers: [PhoneNumbersByAreaCode] 22 | phoneNumbersByStatus: [PhoneNumberCountsByStatus] 23 | maxContacts: Int 24 | labels: [Label]! 25 | } 26 | `; 27 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /migrations/20200209103103_add_twilio_phone_number_table.js: -------------------------------------------------------------------------------- 1 | exports.up = async knex => { 2 | await knex.schema.createTable("twilio_phone_number", table => { 3 | table.string("sid").primary(); 4 | table.string("phone_number").notNullable(); 5 | table 6 | .string("area_code") 7 | .notNullable() 8 | .index(); 9 | table 10 | .string("status") 11 | .notNullable() 12 | .index(); 13 | table 14 | .integer("campaign_id") 15 | .unsigned() 16 | .nullable() 17 | .references("id") 18 | .inTable("campaign") 19 | .index(); 20 | table.timestamp("reserved_at").nullable(); 21 | table.timestamp("created_at").default(knex.raw("CURRENT_TIMESTAMP")); 22 | }); 23 | }; 24 | 25 | exports.down = async knex => { 26 | throw Error("Down migrations not supported"); 27 | }; 28 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /__test__/e2e/page-objects/people.js: -------------------------------------------------------------------------------- 1 | import { By } from "selenium-webdriver"; 2 | 3 | export const people = { 4 | add: By.css("[data-test=addPerson]"), 5 | invite: { 6 | joinUrl: By.css("[data-test=joinUrl]"), 7 | ok: By.css("[data-test=inviteOk]") 8 | }, 9 | getRowByName(name) { 10 | return By.xpath(`//td[contains(text(),'${name}')]/ancestor::tr`); 11 | }, 12 | editButtonByName(name) { 13 | return By.xpath( 14 | `//td[contains(text(),'${name}')]/ancestor::tr/descendant::button[@data-test='editPerson']` 15 | ); 16 | }, 17 | edit: { 18 | editButton: By.css("[data-test=editPerson]"), 19 | firstName: By.css("[data-test=firstName]"), 20 | lastName: By.css("[data-test=lastName]"), 21 | email: By.css("[data-test=email]"), 22 | cell: By.css("[data-test=cell]"), 23 | save: By.css("[type=submit]") 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /src/api/user.js: -------------------------------------------------------------------------------- 1 | export const schema = ` 2 | type User { 3 | id: ID 4 | firstName: String 5 | lastName: String 6 | displayName: String 7 | email: String 8 | cell: String 9 | organizations(role: String): [Organization] 10 | assignmentSummaries(organizationId: String!): [AssignmentSummary] 11 | roles(organizationId: String!): [String] 12 | allRoles: [RolesForOrg] 13 | assignedCell: Phone 14 | assignment(campaignId: String): Assignment 15 | terms: Boolean 16 | subscribedToReminders: Boolean 17 | } 18 | 19 | type UsersList { 20 | users: [User] 21 | } 22 | 23 | type PaginatedUsers { 24 | users: [User] 25 | pageInfo: PageInfo 26 | } 27 | 28 | type AssignmentSummary { 29 | assignment: Assignment! 30 | contactCounts: [ContactCountByMessageStatus]! 31 | } 32 | 33 | union UsersReturn = PaginatedUsers | UsersList 34 | `; 35 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | postgres: 4 | image: postgres:alpine 5 | restart: always 6 | environment: 7 | POSTGRES_DB: spokedev 8 | POSTGRES_PASSWORD: spoke 9 | POSTGRES_USER: spoke 10 | volumes: 11 | - postgres:/var/lib/postgresql/data 12 | ports: 13 | - 5432:5432 14 | redis: 15 | image: redis:alpine 16 | restart: always 17 | volumes: 18 | - redis:/data 19 | ports: 20 | - 6379:6379 21 | minio: 22 | image: minio/minio 23 | restart: always 24 | ports: 25 | - 19000:9000 26 | volumes: 27 | - minio:/data 28 | command: server /data 29 | environment: 30 | MINIO_ACCESS_KEY: DEVACCESSKEY 31 | MINIO_SECRET_KEY: DEVSECRETKEY 32 | volumes: 33 | postgres: 34 | external: false 35 | redis: 36 | external: false 37 | minio: 38 | external: false 39 | -------------------------------------------------------------------------------- /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 | cell: requiredString(), 16 | email: requiredString(), 17 | created_at: timestamp(), 18 | assigned_cell: type.string(), 19 | is_superadmin: type.boolean(), 20 | terms: type.boolean().default(false), 21 | subscribed_to_reminders: type.boolean().default(true) 22 | }) 23 | .allowExtra(false), 24 | { noAutoCreation: true } 25 | ); 26 | 27 | export default User; 28 | -------------------------------------------------------------------------------- /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/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 | survey_question: optionalString(), 17 | deleted: type 18 | .boolean() 19 | .required() 20 | .default(false), 21 | order: type 22 | .number() 23 | .integer() 24 | .required() 25 | .min(0) 26 | .default(0) 27 | }) 28 | .allowExtra(false), 29 | { noAutoCreation: true } 30 | ); 31 | 32 | export default CannedResponse; 33 | -------------------------------------------------------------------------------- /src/server/api/phone.js: -------------------------------------------------------------------------------- 1 | import { GraphQLScalarType } from "graphql"; 2 | import { GraphQLError } from "graphql/error"; 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 | [ast] 20 | ); 21 | } 22 | 23 | if (!pattern.test(ast.value)) { 24 | throw new GraphQLError("Query error: Not a valid Phone", [ast]); 25 | } 26 | 27 | return ast.value; 28 | } 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/Dialog"; 5 | import UserEdit from "../../containers/UserEdit"; 6 | import { dataTest } from "../../lib/attributes"; 7 | 8 | const UserEditDialog = props => ( 9 | 16 | 22 | 23 | ); 24 | 25 | UserEditDialog.propTypes = { 26 | open: PropTypes.bool, 27 | organizationId: PropTypes.string, 28 | userId: PropTypes.number, 29 | updateUser: PropTypes.func, 30 | requestClose: PropTypes.func 31 | }; 32 | 33 | export default UserEditDialog; 34 | -------------------------------------------------------------------------------- /src/components/Slider.jsx: -------------------------------------------------------------------------------- 1 | import type from "prop-types"; 2 | import React from "react"; 3 | import theme from "../styles/theme"; 4 | 5 | const Slider = ({ maxValue, value, color, direction }) => { 6 | const valuePercent = Math.round((value / maxValue) * 100); 7 | return ( 8 |
16 |
25 |
26 | ); 27 | }; 28 | 29 | Slider.propTypes = { 30 | color: type.string, 31 | maxValue: type.number, 32 | value: type.number, 33 | direction: type.number 34 | }; 35 | 36 | export default Slider; 37 | -------------------------------------------------------------------------------- /src/components/PeopleList/SimpleRolesDropdown.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import type from "prop-types"; 3 | 4 | import DropDownMenu from "material-ui/DropDownMenu"; 5 | import MenuItem from "material-ui/MenuItem"; 6 | import { ROLE_HIERARCHY } from "../../lib"; 7 | 8 | export const ALL_ROLES = "ALL ROLES"; 9 | 10 | const SimpleRolesDropdown = props => ( 11 | props.onChange(value)} 14 | > 15 | {[ALL_ROLES].concat(ROLE_HIERARCHY).map(option => ( 16 | 23 | ))} 24 | 25 | ); 26 | 27 | SimpleRolesDropdown.propTypes = { 28 | selectedRole: type.object, 29 | onChange: type.func 30 | }; 31 | 32 | export default SimpleRolesDropdown; 33 | -------------------------------------------------------------------------------- /src/server/api/interaction-step.js: -------------------------------------------------------------------------------- 1 | import { mapFieldsToModel } from "./lib/utils"; 2 | import { InteractionStep, r } from "../models"; 3 | 4 | export const resolvers = { 5 | InteractionStep: { 6 | ...mapFieldsToModel( 7 | [ 8 | "id", 9 | "script", 10 | "answerOption", 11 | "answerActions", 12 | "parentInteractionId", 13 | "isDeleted" 14 | ], 15 | InteractionStep 16 | ), 17 | questionText: async interactionStep => { 18 | return interactionStep.question; 19 | }, 20 | question: async interactionStep => interactionStep, 21 | questionResponse: async (interactionStep, { campaignContactId }) => 22 | r 23 | .table("question_response") 24 | .getAll(campaignContactId, { index: "campaign_contact_id" }) 25 | .filter({ 26 | interaction_step_id: interactionStep.id 27 | }) 28 | .limit(1)(0) 29 | .default(null) 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /src/server/db/user.js: -------------------------------------------------------------------------------- 1 | import userCache from "src/server/models/cacheable_queries/user"; 2 | import { Table, getAny, queryBuilder } from "./common"; 3 | 4 | async function getByEmail(email, opts) { 5 | return getAny(Table.USER, "email", email, opts); 6 | } 7 | 8 | async function isMemberOfOrganization(userId, organizationId, opts) { 9 | const res = await queryBuilder(Table.USER_ORGANIZATION, opts) 10 | .select("id") 11 | .where({ user_id: userId, organization_id: organizationId }) 12 | .first(); 13 | 14 | return !!res; 15 | } 16 | 17 | async function addToOrganization({ userId, organizationId, role }, opts) { 18 | const added = await queryBuilder(Table.USER_ORGANIZATION, opts).insert({ 19 | user_id: userId, 20 | organization_id: organizationId, 21 | role 22 | }); 23 | await userCache.clearUser(userId); 24 | return added; 25 | } 26 | 27 | export default { 28 | getByEmail, 29 | isMemberOfOrganization, 30 | addToOrganization 31 | }; 32 | -------------------------------------------------------------------------------- /src/containers/hoc/wrap-mutations.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { GraphQLRequestError, graphQLErrorParser } from "../../network/errors"; 3 | 4 | const wrapMutations = Component => props => { 5 | const newProps = { ...props }; 6 | if (props.hasOwnProperty("mutations")) { 7 | const newMutations = {}; 8 | // eslint-disable-next-line react/prop-types 9 | Object.keys(props.mutations).forEach(key => { 10 | newMutations[key] = async (...args) => { 11 | const argCopy = [...args]; 12 | // eslint-disable-next-line react/prop-types 13 | const resp = await props.mutations[key](...argCopy); 14 | const parsedError = graphQLErrorParser(resp); 15 | if (parsedError) { 16 | throw new GraphQLRequestError(parsedError); 17 | } 18 | return resp; 19 | }; 20 | }); 21 | newProps.mutations = newMutations; 22 | } 23 | return ; 24 | }; 25 | 26 | export default wrapMutations; 27 | -------------------------------------------------------------------------------- /src/lib/campaign-statuses.js: -------------------------------------------------------------------------------- 1 | export const CampaignStatus = { 2 | ACTIVE: "ACTIVE", 3 | ARCHIVED: "ARCHIVED", 4 | CLOSED: "CLOSED", 5 | CLOSED_FOR_INITIAL_SENDS: "CLOSED_FOR_INITIAL_SENDS", 6 | NOT_STARTED: "NOT_STARTED" 7 | }; 8 | 9 | export const CampaignStatusValues = { 10 | ACTIVE: { 11 | display: "Active", 12 | value: CampaignStatus.ACTIVE 13 | }, 14 | NOT_STARTED: { 15 | display: "Not Started", 16 | value: CampaignStatus.NOT_STARTED 17 | }, 18 | CLOSED_FOR_INITIAL_SENDS: { 19 | display: "Closed for Initial Sends", 20 | value: CampaignStatus.CLOSED_FOR_INITIAL_SENDS 21 | }, 22 | CLOSED: { 23 | display: "Closed", 24 | value: CampaignStatus.CLOSED 25 | }, 26 | ARCHIVED: { 27 | display: "Archived", 28 | value: CampaignStatus.ARCHIVED 29 | } 30 | }; 31 | 32 | export const getStatusDisplayName = status => 33 | ( 34 | Object.values(CampaignStatusValues).find(({ value }) => value === status) || 35 | {} 36 | ).display; 37 | -------------------------------------------------------------------------------- /src/components/Chart.jsx: -------------------------------------------------------------------------------- 1 | import type from "prop-types"; 2 | import React from "react"; 3 | import { Pie } from "react-chartjs"; 4 | 5 | const Chart = ({ data }) => { 6 | const chartColors = ["#F7464A", "#46BFBD", "#FDB45C", "#949FB1", "#4D5360"]; 7 | 8 | const pieData = data.map(([label, value], index) => ({ 9 | label, 10 | value, 11 | color: chartColors[index % chartColors.length] 12 | })); 13 | 14 | return ( 15 |
16 | 17 |
18 | {pieData.map(({ label, color }) => ( 19 | 28 | {label} 29 | 30 | ))} 31 |
32 |
33 | ); 34 | }; 35 | 36 | Chart.propTypes = { 37 | data: type.array 38 | }; 39 | 40 | export default Chart; 41 | -------------------------------------------------------------------------------- /src/server/preconditions.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Check that a given value is truthy, returning it if it is. 3 | */ 4 | function check(value, errorMessage) { 5 | if (!value) { 6 | throw Error(errorMessage || `Precondition failed: ${value} is not truthy`); 7 | } 8 | return value; 9 | } 10 | 11 | /** 12 | * Check that all the values in an object are truthy, returning the object if they are 13 | */ 14 | function checkMany(obj, errorMessage) { 15 | for (const k of Object.keys(obj)) { 16 | check(obj[k], errorMessage || `Precondition failed: ${k} is not truthy`); 17 | } 18 | return obj; 19 | } 20 | 21 | /** 22 | * Check that a given value is in a list of values. 23 | */ 24 | function checkEnum(value, validValues) { 25 | if (validValues.indexOf(value) === -1) { 26 | throw Error( 27 | `Precondition failed: ${value} is not a member of ${validValues}` 28 | ); 29 | } 30 | return value; 31 | } 32 | 33 | export default { 34 | checkEnum, 35 | checkMany, 36 | check 37 | }; 38 | -------------------------------------------------------------------------------- /docs/MESSAGE REVIEW documentation: -------------------------------------------------------------------------------- 1 | MESSAGE REVIEW documentation *draft* 2 | 20190501 3 | 4 | What Is 'Message Review'? 5 | 6 | How Do I Use Message Review? 7 | 8 | Tips and Tricks 9 | 10 | 'Refresh Filters' for Message Review: In Message Review, say you are doing 'power reassigning' of all Needs Texter Response to yourself, or another user. Here's what I do. First, select the Campaign and Needs Texter Response, and Reassign to [user]. Select Enter. You'll see any Needs Texter Response, and can reassign those. Leave that window open. Go back in a few minutes, and in Contact message status, select *both* All and Needs Texter Response. With the drop down still open in Contact message status, *unselect* All. You'll see new Needs Texter Response since you last ran that. Select all the new Needs Texter Response using the checkbox at top and Reassign to [user]. 11 | 12 | For more information, contact X at Y @ Z or @ on GitHub, or join our Spoke channel and community on the ProgCode Slack - start here - http://progco.de/join . 13 | -------------------------------------------------------------------------------- /__test__/e2e/page-objects/login.js: -------------------------------------------------------------------------------- 1 | import { By } from "selenium-webdriver"; 2 | 3 | export const login = { 4 | auth0: { 5 | tabs: { 6 | logIn: By.css(".auth0-lock-tabs>li:nth-child(1)"), 7 | signIn: By.css(".auth0-lock-tabs>li:nth-child(2)") 8 | }, 9 | form: { 10 | email: By.css("div.auth0-lock-input-email > div > input"), 11 | password: By.css("div.auth0-lock-input-password > div > input"), 12 | given_name: By.css("div.auth0-lock-input-given_name > div > input"), 13 | family_name: By.css("div.auth0-lock-input-family_name > div > input"), 14 | cell: By.css("div.auth0-lock-input-cell > div > input"), 15 | agreement: By.css( 16 | "span.auth0-lock-sign-up-terms-agreement > label > input" 17 | ), // Checkbox 18 | submit: By.css("button.auth0-lock-submit"), 19 | error: By.css("div.auth0-global-message-error") 20 | }, 21 | authorize: { 22 | allow: By.css("#allow") 23 | } 24 | }, 25 | loginGetStarted: By.css("#login") 26 | }; 27 | -------------------------------------------------------------------------------- /src/components/ConversationLink.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | import React from "react"; 3 | import DisplayLink from "./DisplayLink"; 4 | 5 | const ConversationLink = ({ 6 | conversation, 7 | organizationId, 8 | text, 9 | campaignId, 10 | isOptedOut = false 11 | }) => { 12 | let baseUrl = "http://base"; 13 | if (typeof window !== "undefined") { 14 | baseUrl = window.location.origin; 15 | } 16 | const { campaignContactId, contactNumber } = conversation; 17 | 18 | const url = `${baseUrl}/admin/${organizationId}/campaigns/${campaignId}/review?contactId=${campaignContactId}&optedOut=${isOptedOut}${ 19 | contactNumber ? `&cell=${encodeURIComponent(contactNumber)}` : "" 20 | }`; 21 | 22 | return ; 23 | }; 24 | 25 | ConversationLink.propTypes = { 26 | campaignId: PropTypes.string, 27 | text: PropTypes.string, 28 | organizationId: PropTypes.string, 29 | conversation: PropTypes.object 30 | }; 31 | 32 | export default ConversationLink; 33 | -------------------------------------------------------------------------------- /src/server/db/index.js: -------------------------------------------------------------------------------- 1 | import log from "src/server/log"; 2 | import Assignment from "./assignment"; 3 | import BackgroundJob from "./background-job"; 4 | import Campaign from "./campaign"; 5 | import CannedResponse from "./canned-response"; 6 | import Label from "./label"; 7 | import OptOut from "./opt-out"; 8 | import TwilioPhoneNumber from "./twilio-phone-number"; 9 | import User from "./user"; 10 | import Notification from "./notification"; 11 | 12 | import { knex, Table, transaction } from "./common"; 13 | 14 | let tracingEnabled = false; 15 | function enableTracing() { 16 | if (!tracingEnabled) { 17 | knex.on("query", ({ sql, bindings }) => log.trace(sql, bindings)); 18 | } 19 | tracingEnabled = true; 20 | } 21 | 22 | export default { 23 | // Utils 24 | knex, 25 | enableTracing, 26 | transaction, 27 | Table, 28 | // Queries 29 | Assignment, 30 | BackgroundJob, 31 | Campaign, 32 | CannedResponse, 33 | Label, 34 | OptOut, 35 | TwilioPhoneNumber, 36 | User, 37 | Notification 38 | }; 39 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/components/PeopleList/ResetPasswordDialog.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | import Dialog from "material-ui/Dialog"; 5 | import PasswordResetLink from "../../components/PasswordResetLink"; 6 | import FlatButton from "material-ui/FlatButton"; 7 | import { dataTest } from "../../lib/attributes"; 8 | 9 | const ResetPasswordDialog = props => ( 10 | 19 | ]} 20 | modal={false} 21 | open={props.open} 22 | onRequestClose={props.requestClose} 23 | > 24 | 25 | 26 | ); 27 | 28 | ResetPasswordDialog.propTypes = { 29 | open: PropTypes.bool, 30 | requestClose: PropTypes.func, 31 | passwordResetHash: PropTypes.string 32 | }; 33 | 34 | export default ResetPasswordDialog; 35 | -------------------------------------------------------------------------------- /src/server/api/lib/utils.js: -------------------------------------------------------------------------------- 1 | import humps from "humps"; 2 | 3 | export function mapFieldsToModel(fields, model) { 4 | const resolvers = {}; 5 | 6 | fields.forEach(field => { 7 | const snakeKey = humps.decamelize(field, { separator: "_" }); 8 | // eslint-disable-next-line no-underscore-dangle 9 | if (model._schema._schema.hasOwnProperty(snakeKey)) { 10 | resolvers[field] = instance => instance[snakeKey]; 11 | } else { 12 | // eslint-disable-next-line no-underscore-dangle 13 | throw new Error( 14 | `Could not find key ${snakeKey} in model ${model._schema._model._name}` 15 | ); 16 | } 17 | }); 18 | return resolvers; 19 | } 20 | 21 | export const capitalizeWord = word => { 22 | if (word) { 23 | return word[0].toUpperCase() + word.slice(1); 24 | } 25 | return ""; 26 | }; 27 | 28 | export function campaignPhoneNumbersEnabled(organization) { 29 | return ( 30 | organization.features && 31 | !!JSON.parse(organization.features).campaignPhoneNumbersEnabled 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /docs/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 | 7 | - 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. 8 | - Assign texters to a campaigns. 9 | - Edit the interaction script. 10 | - Create canned responses for a campaign. 11 | 12 | 4. Texter - This user can only see their todos for their assigned campaigns 13 | -------------------------------------------------------------------------------- /src/components/App.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | import React from "react"; 3 | import theme from "../styles/theme"; 4 | import { StyleSheet, css } from "aphrodite"; 5 | import Form from "react-formal"; 6 | import GSTextField from "./forms/GSTextField"; 7 | import GSDateField from "./forms/GSDateField"; 8 | import GSScriptField from "./forms/GSScriptField"; 9 | import GSSelectField from "./forms/GSSelectField"; 10 | import GSPasswordField from "./forms/GSPasswordField"; 11 | 12 | Form.addInputTypes({ 13 | string: GSTextField, 14 | description: GSTextField, 15 | number: GSTextField, 16 | date: GSDateField, 17 | email: GSTextField, 18 | script: GSScriptField, 19 | select: GSSelectField, 20 | password: GSPasswordField 21 | }); 22 | 23 | const styles = StyleSheet.create({ 24 | root: { 25 | ...theme.text.body, 26 | height: "100%" 27 | } 28 | }); 29 | 30 | const App = ({ children }) => ( 31 |
{children}
32 | ); 33 | 34 | App.propTypes = { 35 | children: PropTypes.object 36 | }; 37 | 38 | export default App; 39 | -------------------------------------------------------------------------------- /src/components/forms/GSSubmitButton.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | import React from "react"; 3 | import RaisedButton from "material-ui/RaisedButton"; 4 | import CircularProgress from "material-ui/CircularProgress"; 5 | 6 | const styles = { 7 | button: { 8 | marginTop: 15 9 | } 10 | }; 11 | 12 | const GSSubmitButton = props => { 13 | let icon = ""; 14 | const extraProps = {}; 15 | if (props.isSubmitting) { 16 | extraProps.disabled = true; 17 | icon = ( 18 | 25 | ); 26 | } 27 | 28 | return ( 29 |
30 | 37 | {icon} 38 |
39 | ); 40 | }; 41 | 42 | GSSubmitButton.propTypes = { 43 | isSubmitting: PropTypes.bool 44 | }; 45 | 46 | export default GSSubmitButton; 47 | -------------------------------------------------------------------------------- /src/store/index.js: -------------------------------------------------------------------------------- 1 | import { createStore, combineReducers, compose, applyMiddleware } from "redux"; 2 | import { routerReducer, routerMiddleware } from "react-router-redux"; 3 | import ReduxThunk from "redux-thunk"; 4 | import ApolloClientSingleton from "../network/apollo-client-singleton"; 5 | import * as reducers from "./reducers"; 6 | 7 | export default class Store { 8 | constructor(history, initialState = {}) { 9 | const reducer = combineReducers({ 10 | ...reducers, 11 | apollo: ApolloClientSingleton.reducer(), 12 | routing: routerReducer 13 | }); 14 | 15 | this.data = createStore( 16 | reducer, 17 | initialState, 18 | compose( 19 | applyMiddleware( 20 | routerMiddleware(history), 21 | ApolloClientSingleton.middleware(), 22 | ReduxThunk.withExtraArgument(ApolloClientSingleton) 23 | ), 24 | typeof window === "object" && 25 | typeof window.devToolsExtension !== "undefined" 26 | ? window.devToolsExtension() 27 | : f => f 28 | ) 29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/components/IncomingMessageList/ConversationPreviewModal/Body.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import PropTypes from "prop-types"; 3 | import MessageResponse from "./MessageResponse"; 4 | import MessageList from "./MessageList"; 5 | 6 | export default class ConversationPreviewBody extends Component { 7 | constructor(props) { 8 | super(props); 9 | 10 | this.state = { 11 | messages: props.conversation.messages 12 | }; 13 | 14 | this.messagesChanged = this.messagesChanged.bind(this); 15 | } 16 | 17 | messagesChanged(messages) { 18 | this.setState({ messages }); 19 | } 20 | 21 | render() { 22 | return ( 23 |
24 | 28 | 32 |
33 | ); 34 | } 35 | } 36 | 37 | ConversationPreviewBody.propTypes = { 38 | conversation: PropTypes.object 39 | }; 40 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/lib/index.js: -------------------------------------------------------------------------------- 1 | export { getFormattedPhoneNumber, getDisplayPhoneNumber } from "./phone-format"; 2 | 3 | export { 4 | getFormattedZip, 5 | zipToTimeZone, 6 | findZipRanges, 7 | getCommonZipRanges 8 | } from "./zip-format"; 9 | 10 | export { 11 | convertOffsetsToStrings, 12 | getLocalTime, 13 | isBetweenTextingHours, 14 | campaignIsBetweenTextingHours, 15 | timeUntilTextEnd, 16 | defaultTimezoneIsBetweenTextingHours, 17 | getOffsets, 18 | getContactTimezone, 19 | getUtcFromTimezoneAndHour, 20 | getUtcFromOffsetAndHour, 21 | getSendBeforeTimeUtc 22 | } from "./timezones"; 23 | 24 | export { getProcessEnvTz } from "./tz-helpers"; 25 | 26 | export { DstHelper } from "./dst-helper"; 27 | 28 | export { isClient } from "./is-client"; 29 | 30 | export { 31 | findParent, 32 | getInteractionPath, 33 | getInteractionTree, 34 | sortInteractionSteps, 35 | interactionStepForId, 36 | getTopMostParent, 37 | getChildren, 38 | makeTree 39 | } from "./interaction-step-helpers"; 40 | 41 | export { 42 | ROLE_HIERARCHY, 43 | getHighestRole, 44 | hasRole, 45 | isRoleGreater 46 | } from "./permissions"; 47 | -------------------------------------------------------------------------------- /src/server/models/index.js: -------------------------------------------------------------------------------- 1 | // Import models in order that creates referenced tables before foreign keys 2 | import User from "./user"; 3 | import Organization from "./organization"; 4 | import Campaign from "./campaign"; 5 | import Assignment from "./assignment"; 6 | import CampaignContact from "./campaign-contact"; 7 | import InteractionStep from "./interaction-step"; 8 | import QuestionResponse from "./question-response"; 9 | import OptOut from "./opt-out"; 10 | import CannedResponse from "./canned-response"; 11 | import UserOrganization from "./user-organization"; 12 | import Message from "./message"; 13 | import ZipCode from "./zip-code"; 14 | import Tag from "./tag"; 15 | import thinky from "./thinky"; 16 | import datawarehouse from "./datawarehouse"; 17 | 18 | const r = thinky.r; 19 | 20 | export { 21 | r, 22 | datawarehouse, 23 | Assignment, 24 | Campaign, 25 | CampaignContact, 26 | InteractionStep, 27 | Message, 28 | OptOut, 29 | Organization, 30 | CannedResponse, 31 | QuestionResponse, 32 | Tag, 33 | UserOrganization, 34 | User, 35 | ZipCode 36 | // Note: not used in the Warren fork 37 | // Log 38 | }; 39 | -------------------------------------------------------------------------------- /.github/workflows/dev-deploy.yml: -------------------------------------------------------------------------------- 1 | name: '[DEV] Deploy' 2 | 3 | # ALWAYS USE SINGLE QUOTES HERE 4 | 5 | on: 6 | push: 7 | branches: 8 | - master 9 | 10 | jobs: 11 | deploy: 12 | name: Deploy Spoke 13 | runs-on: ubuntu-latest 14 | env: 15 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 16 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 17 | AWS_DEFAULT_REGION: us-east-1 18 | STAGE: dev 19 | INFRASTRUCTURE: dev 20 | OUTPUT_DIR: ./build 21 | ASSETS_DIR: ./build/client/assets 22 | ASSETS_MAP_FILE: assets.json 23 | PHONE_NUMBER_COUNTRY: US 24 | steps: 25 | - name: Checkout 26 | uses: actions/checkout@v1 27 | - name: Setup Node 28 | uses: actions/setup-node@v1 29 | with: 30 | node-version: 12.16.x 31 | - name: Install Serverless 32 | run: npm install -g serverless 33 | - name: Install Dependencies 34 | run: make install-deps 35 | - name: Build 36 | run: make build-app 37 | env: 38 | NODE_ENV: production 39 | - name: Deploy 40 | run: make deploy-build 41 | -------------------------------------------------------------------------------- /src/server/lambda/worker.js: -------------------------------------------------------------------------------- 1 | import db from "src/server/db"; 2 | import log from "src/server/log"; 3 | import telemetry from "src/server/telemetry"; 4 | import { WORKER_MAP } from "src/server/workers"; 5 | 6 | db.enableTracing(); 7 | 8 | exports.handler = async (event, context) => { 9 | log.debug({ msg: "Received event", event }); 10 | if (!event.jobId) { 11 | log.error({ msg: "Missing jobId in event", event }); 12 | } 13 | const job = await db.BackgroundJob.get(event.jobId); 14 | try { 15 | const workerFn = WORKER_MAP[job.type]; 16 | if (!workerFn) { 17 | log.error({ 18 | msg: "Could not find a worker for job", 19 | job, 20 | workers: Object.keys(WORKER_MAP) 21 | }); 22 | return; 23 | } 24 | 25 | await workerFn(job); 26 | } catch (e) { 27 | // For now suppress Lambda retries by not raising the exception. 28 | // In the future, we may want to mark jobs as retryable and let Lambda do 29 | // its thing with exceptions. 30 | log.error({ msg: "Caught exception while processing job", job, err: e }); 31 | await telemetry.reportError(e, { jobType: job.type }); 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /src/components/TagChip.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Chip from "material-ui/Chip"; 3 | import type from "prop-types"; 4 | import Avatar from "material-ui/Avatar"; 5 | import FlagIcon from "material-ui/svg-icons/content/flag"; 6 | import theme from "../styles/theme"; 7 | import { gray900 } from "material-ui/styles/colors"; 8 | 9 | const inlineStyles = { 10 | chip: { 11 | display: "inline-flex", 12 | height: "18px", 13 | justifyContent: "center", 14 | flexWrap: "nowrap", 15 | alignItems: "center", 16 | backgroundColor: theme.colors.EWlightLibertyGreen 17 | }, 18 | text: { 19 | fontSize: "10px", 20 | fontWeight: "900", 21 | color: gray900 22 | }, 23 | icon: { 24 | width: "16px", 25 | height: "16px", 26 | backgroundColor: theme.colors.EWlightLibertyGreen 27 | } 28 | }; 29 | 30 | export const TagChip = props => ( 31 | 32 | 33 | 34 | 35 |
{props.text}
36 |
37 | ); 38 | 39 | TagChip.propTypes = { 40 | text: type.string 41 | }; 42 | 43 | export default TagChip; 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016-2017 Saikat Chakrabarti and Sheena Pakanati 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/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 | render = () => ( 26 | 32 | ); 33 | } 34 | 35 | Search.propTypes = { 36 | searchString: PropTypes.string, 37 | onSearchRequested: PropTypes.func.isRequired, 38 | hintText: PropTypes.string 39 | }; 40 | 41 | export default Search; 42 | -------------------------------------------------------------------------------- /src/components/ConfirmCampaignArchiveModal.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import FlatButton from "material-ui/FlatButton"; 4 | import { Dialog } from "material-ui"; 5 | 6 | const styles = { 7 | dialog: { 8 | width: 400 9 | } 10 | }; 11 | function ConfirmCampaignArchiveModal(props) { 12 | const { open, onClose, onHandleArchive, confirmationText } = props; 13 | const modalText = confirmationText || "Click OK to archive this campaign."; 14 | 15 | return ( 16 | , 22 | 28 | ]} 29 | onRequestClose={onClose} 30 | > 31 | {modalText} 32 | 33 | ); 34 | } 35 | 36 | ConfirmCampaignArchiveModal.propTypes = { 37 | open: PropTypes.bool, 38 | onClose: PropTypes.func, 39 | onHandleArchive: PropTypes.func 40 | }; 41 | 42 | export default ConfirmCampaignArchiveModal; 43 | -------------------------------------------------------------------------------- /src/containers/DashboardLoader.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | import React from "react"; 3 | import gql from "graphql-tag"; 4 | import { withRouter } from "react-router"; 5 | import loadData from "./hoc/load-data"; 6 | 7 | class DashboardLoader extends React.Component { 8 | UNSAFE_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 mapQueriesToProps = () => ({ 30 | data: { 31 | query: gql` 32 | query getCurrentUserForLoader { 33 | currentUser { 34 | id 35 | organizations { 36 | id 37 | } 38 | } 39 | } 40 | `, 41 | fetchPolicy: "network-only" 42 | } 43 | }); 44 | 45 | export default loadData(withRouter(DashboardLoader), { mapQueriesToProps }); 46 | -------------------------------------------------------------------------------- /__test__/e2e/.env.e2e: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | SUPPRESS_SELF_INVITE= 3 | JOBS_SAME_PROCESS=1 4 | AWS_ACCESS_KEY_ID= 5 | AWS_SECRET_ACCESS_KEY= 6 | AWS_S3_BUCKET_NAME= 7 | APOLLO_OPTICS_KEY= 8 | DEV_APP_PORT=8090 9 | OUTPUT_DIR=./build 10 | ASSETS_DIR=./build/client/assets 11 | ASSETS_MAP_FILE=assets.json 12 | CAMPAIGN_ID='campaign-id-hash' 13 | DB_HOST=127.0.0.1 14 | DB_PORT=5432 15 | DB_NAME=spoke_test 16 | DB_TYPE=pg 17 | DB_MIN_POOL=2 18 | DB_MAX_POOL=10 19 | DB_USE_SSL=false 20 | WEBPACK_HOST=localhost 21 | WEBPACK_PORT=3000 22 | BASE_URL=http://localhost:3000 23 | SESSION_SECRET=set_this_in_production 24 | DEFAULT_SERVICE=twilio 25 | NEXMO_API_KEY= 26 | NEXMO_API_SECRET= 27 | TWILIO_ACCOUNT_SID= 28 | TWILIO_APPLICATION_SID= 29 | TWILIO_AUTH_TOKEN= 30 | TWILIO_MESSAGE_SERVICE_SID= 31 | TWILIO_STATUS_CALLBACK_URL= 32 | TWILIO_SQS_QUEUE_URL= 33 | PHONE_NUMBER_COUNTRY=US 34 | ROLLBAR_CLIENT_TOKEN= 35 | ROLLBAR_ACCESS_TOKEN= 36 | ROLLBAR_ENDPOINT=https://api.rollbar.com/api/1/item/ 37 | ALLOW_SEND_ALL=false 38 | EMAIL_HOST= 39 | EMAIL_HOST_PASSWORD= 40 | EMAIL_HOST_USER= 41 | EMAIL_HOST_PORT= 42 | EMAIL_FROM= 43 | TWILIO_MESSAGE_VALIDITY_PERIOD= 44 | DST_REFERENCE_TIMEZONE='America/New_York' 45 | CONTACTS_PER_PHONE_NUMBER=150 46 | -------------------------------------------------------------------------------- /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/components/LabelChips.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import _ from "lodash"; 3 | import { Chip } from "material-ui"; 4 | import theme from "src/styles/theme"; 5 | 6 | export default function LabelChips({ labels, labelIds, onRequestDelete, keyBy = "id" }) { 7 | const labelsById = _.keyBy(labels, keyBy); 8 | return ( 9 |
10 | {(labelIds || []).map(labelId => { 11 | const label = labelsById[labelId]; 12 | 13 | let onDelete; 14 | if (onRequestDelete) { 15 | onDelete = () => onRequestDelete(label); 16 | } 17 | 18 | return ( 19 | 28 | 33 | {label.displayValue} 34 | 35 | 36 | ); 37 | })} 38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/server/models/cacheable_queries/organization.js: -------------------------------------------------------------------------------- 1 | import { r } from "src/server/models"; 2 | import config from "src/server/config"; 3 | 4 | const cacheKey = orgId => `${config.CACHE_PREFIX}org-${orgId}`; 5 | 6 | export const organizationCache = { 7 | clear: async id => { 8 | if (r.redis) { 9 | await r.redis.delAsync(cacheKey(id)); 10 | } 11 | }, 12 | load: async id => { 13 | if (r.redis) { 14 | const orgData = await r.redis.getAsync(cacheKey(id)); 15 | if (orgData) { 16 | return JSON.parse(orgData); 17 | } 18 | } 19 | const [dbResult] = await r 20 | .knex("organization") 21 | .where("id", id) 22 | .select("*") 23 | .limit(1); 24 | if (dbResult) { 25 | if (dbResult.features) { 26 | dbResult.feature = JSON.parse(dbResult.features); 27 | } else { 28 | dbResult.feature = {}; 29 | } 30 | if (r.redis) { 31 | await r.redis 32 | .multi() 33 | .set(cacheKey(id), JSON.stringify(dbResult)) 34 | .expire(cacheKey(id), config.DEFAULT_CACHE_TTL) 35 | .execAsync(); 36 | } 37 | return dbResult; 38 | } 39 | } 40 | }; 41 | 42 | export default organizationCache; 43 | -------------------------------------------------------------------------------- /src/server/middleware/graphql-response-time.js: -------------------------------------------------------------------------------- 1 | import cloudwatchMetrics from "cloudwatch-metrics"; 2 | import config from "src/server/config"; 3 | import log from "src/server/log"; 4 | 5 | const metric = new cloudwatchMetrics.Metric( 6 | config.CLOUDWATCH_METRICS_NAMESPACE, 7 | "Milliseconds", 8 | [], 9 | { 10 | enabled: config.CLOUDWATCH_METRICS_ENABLED, 11 | sendInterval: 30 * 1000, 12 | sendCallback: (e, _) => { 13 | if (e) { 14 | log.warn({ msg: "Error posting metric summary to CW", e }); 15 | } 16 | } 17 | } 18 | ); 19 | 20 | const SLOW_REQUEST_LOG_THRESHOLD = process.env.SLOW_REQUEST_LOG_THRESHOLD 21 | ? parseInt(process.env.SLOW_REQUEST_LOG_THRESHOLD, 10) 22 | : 500; 23 | 24 | export default function middleware(req, res, next) { 25 | const start = new Date(); 26 | res.on("finish", () => { 27 | const end = new Date(); 28 | const duration = end - start; 29 | if (duration > SLOW_REQUEST_LOG_THRESHOLD) { 30 | log.info({ msg: "Slow Request", duration, body: req.body }); 31 | } 32 | 33 | metric.summaryPut(duration, "GraphQLResponseTimeSummary", [ 34 | { Name: "OperationName", Value: req.body.operationName || "NOT_SET" } 35 | ]); 36 | }); 37 | next(); 38 | } 39 | -------------------------------------------------------------------------------- /src/components/CannedResponseForm.jsx: -------------------------------------------------------------------------------- 1 | import type from "prop-types"; 2 | import React from "react"; 3 | import Form from "react-formal"; 4 | import yup from "yup"; 5 | import GSForm from "./forms/GSForm"; 6 | 7 | class CannedResponseForm extends React.Component { 8 | handleSave = formValues => { 9 | const { onSaveCannedResponse } = this.props; 10 | onSaveCannedResponse(formValues); 11 | }; 12 | 13 | render() { 14 | const modelSchema = yup.object({ 15 | title: yup.string().required(), 16 | text: yup.string().required() 17 | }); 18 | 19 | const { customFields } = this.props; 20 | return ( 21 |
22 | 23 | 24 | 32 | 33 |
34 | ); 35 | } 36 | } 37 | 38 | CannedResponseForm.propTypes = { 39 | onSaveCannedResponse: type.func, 40 | customFields: type.array 41 | }; 42 | 43 | export default CannedResponseForm; 44 | -------------------------------------------------------------------------------- /src/styles/mui-theme.js: -------------------------------------------------------------------------------- 1 | import getMuiTheme from "material-ui/styles/getMuiTheme"; 2 | import theme from "./theme"; 3 | import { grey400, grey500, darkBlack } from "material-ui/styles/colors"; 4 | import { fade } from "material-ui/utils/colorManipulator"; 5 | 6 | const muiTheme = getMuiTheme( 7 | { 8 | fontFamily: "Poppins", 9 | palette: { 10 | primary1Color: theme.colors.EWnavy, 11 | textColor: theme.text.body.color, 12 | primary2Color: theme.colors.EWred, 13 | primary3Color: grey400, 14 | accent1Color: theme.colors.EWred, 15 | accent2Color: theme.colors.lightGray, 16 | accent3Color: grey500, 17 | alternateTextColor: theme.colors.white, 18 | canvasColor: theme.colors.white, 19 | borderColor: theme.colors.lightGray, 20 | disabledColor: fade(darkBlack, 0.3) 21 | }, 22 | datePicker: { 23 | textColor: theme.colors.EWnavy, 24 | calendarTextColor: theme.colors.EWnavy, 25 | selectColor: theme.colors.EWnavy, 26 | selectTextColor: theme.colors.white, 27 | headerColor: theme.colors.EWlibertyGreen 28 | }, 29 | textField: { 30 | errorColor: theme.colors.EWred 31 | } 32 | }, 33 | { userAgent: "all" } 34 | ); 35 | 36 | export default muiTheme; 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/Chip"; 5 | 6 | const ssStyles = StyleSheet.create({ 7 | container: { 8 | display: "flex", 9 | flexWrap: "wrap" 10 | } 11 | }); 12 | 13 | const styles = { 14 | chip: { 15 | margin: 6 16 | } 17 | }; 18 | 19 | const SelectedCampaigns = props => ( 20 |
21 | {!!props.campaigns.length && ( 22 | 28 | Clear campaigns 29 | 30 | )} 31 | {props.campaigns.map(campaign => ( 32 | props.onDeleteRequested(campaign.key)} 36 | > 37 | {campaign.text} 38 | 39 | ))} 40 |
41 | ); 42 | 43 | SelectedCampaigns.propTypes = { 44 | campaigns: PropTypes.array.isRequired, 45 | onDeleteRequested: PropTypes.func.isRequired, 46 | onClear: PropTypes.func.isRequired 47 | }; 48 | 49 | export default SelectedCampaigns; 50 | -------------------------------------------------------------------------------- /src/components/CampaignStatusSelect.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | import React from "react"; 3 | import DropDownMenu from "material-ui/DropDownMenu"; 4 | import MenuItem from "material-ui/MenuItem"; 5 | import { CampaignStatusValues } from "../lib/campaign-statuses"; 6 | 7 | export const DEFAULT_STATUS_FILTER = CampaignStatusValues.ACTIVE.value; 8 | 9 | const CampaignStatusSelect = props => { 10 | const allKeys = Object.keys(CampaignStatusValues); 11 | const statusKeys = props.statusOptions 12 | ? allKeys.filter(key => props.statusOptions.includes(key)) 13 | : allKeys; 14 | 15 | return ( 16 | 21 | {statusKeys.map(key => { 22 | const status = CampaignStatusValues[key]; 23 | return ( 24 | 29 | ); 30 | })} 31 | 32 | ); 33 | }; 34 | 35 | CampaignStatusSelect.propTypes = { 36 | value: PropTypes.string, 37 | onChange: PropTypes.func 38 | }; 39 | 40 | export default CampaignStatusSelect; 41 | -------------------------------------------------------------------------------- /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.element 43 | }; 44 | 45 | export default withRouter(TexterDashboard); 46 | -------------------------------------------------------------------------------- /__test__/e2e/create_edit_campaign.test.js: -------------------------------------------------------------------------------- 1 | import { selenium } from "./util/helpers"; 2 | import STRINGS from "./data/strings"; 3 | import { campaigns, login, main } from "./page-functions/index"; 4 | 5 | jasmine.getEnv().addReporter(selenium.reporter); 6 | 7 | describe("Create and Edit Campaign", () => { 8 | // Instantiate browser(s) 9 | const driver = selenium.buildDriver({ 10 | name: "Spoke E2E Tests - Chrome - Create and Edit Campaign - Admin" 11 | }); 12 | const CAMPAIGN = STRINGS.campaigns.editCampaign; 13 | 14 | beforeAll(() => { 15 | global.e2e = {}; 16 | }); 17 | 18 | afterAll(async () => { 19 | await selenium.quitDriver(driver); 20 | }); 21 | 22 | describe("(As Admin) Open Landing Page", () => { 23 | login.landing(driver); 24 | }); 25 | 26 | describe("(As Admin) Log In an admin to Spoke", () => { 27 | login.tryLoginThenSignUp(driver, CAMPAIGN.admin); 28 | }); 29 | 30 | describe("(As Admin) Create a New Organization / Team", () => { 31 | main.createOrg(driver, STRINGS.org); 32 | }); 33 | 34 | describe("(As Admin) Create a New Campaign", () => { 35 | campaigns.startCampaign(driver, CAMPAIGN); 36 | }); 37 | 38 | describe("(As Admin) Edit Campaign", () => { 39 | campaigns.editCampaign(driver, CAMPAIGN); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/components/ConversationLinkDialog.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | import Dialog from "material-ui/Dialog"; 5 | import ConversationLink from "../components/ConversationLink"; 6 | import FlatButton from "material-ui/FlatButton"; 7 | import { dataTest } from "../lib/attributes"; 8 | 9 | const ConversationLinkDialog = props => ( 10 | 19 | ]} 20 | modal={false} 21 | open={props.open} 22 | onRequestClose={props.requestClose} 23 | > 24 | 31 | 32 | ); 33 | 34 | ConversationLinkDialog.propTypes = { 35 | open: PropTypes.bool, 36 | requestClose: PropTypes.func, 37 | conversation: PropTypes.object, 38 | campaignId: PropTypes.string, 39 | organizationId: PropTypes.string, 40 | text: PropTypes.string 41 | }; 42 | 43 | export default ConversationLinkDialog; 44 | -------------------------------------------------------------------------------- /src/client/telemetry.js: -------------------------------------------------------------------------------- 1 | import * as Sentry from "@sentry/browser"; 2 | import _ from "lodash"; 3 | 4 | let sentryDefaultTags = {}; 5 | 6 | function init() { 7 | if (!window.SENTRY_DSN) { 8 | return; 9 | } 10 | 11 | const sentryConfig = { dsn: window.SENTRY_DSN, environment: window.STAGE }; 12 | 13 | sentryDefaultTags = {}; 14 | if (window.GIT_COMMIT_SHORT) { 15 | sentryDefaultTags.gitCommit = window.GIT_COMMIT_SHORT; 16 | } 17 | 18 | if (window.GIT_TAGS) { 19 | const releaseTag = window.GIT_TAGS.split(",").find(tag => 20 | tag.startsWith("release-") 21 | ); 22 | if (releaseTag) { 23 | sentryConfig.release = releaseTag; 24 | } 25 | } 26 | 27 | Sentry.init(sentryConfig); 28 | } 29 | 30 | function reportError(err, details = {}, onSubmit = () => {}) { 31 | if (!window.SENTRY_DSN) { 32 | return; 33 | } 34 | 35 | Sentry.configureScope(scope => { 36 | if (window.USER_ID) { 37 | scope.setUser({ id: window.USER_ID }); 38 | } 39 | 40 | _.each(sentryDefaultTags, (val, key) => { 41 | scope.setTag(key, val); 42 | }); 43 | 44 | _.each(details, (val, key) => { 45 | scope.setExtra(key, val); 46 | }); 47 | 48 | onSubmit(Sentry.captureException(err)); 49 | }); 50 | } 51 | 52 | export default { init, reportError }; 53 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG BUILDER_IMAGE=node:10.15 2 | ARG RUNTIME_IMAGE=node:10.15-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 | ADD package.json /spoke/package.json 14 | ADD yarn.lock /spoke/yarn.lock 15 | ADD webpack /spoke/webpack 16 | 17 | WORKDIR /spoke 18 | 19 | RUN yarn install --ignore-scripts --non-interactive --frozen-lockfile 20 | 21 | ADD . /spoke 22 | 23 | RUN yarn run prod-build && \ 24 | rm -rf node_modules && \ 25 | yarn install --production --ignore-scripts 26 | 27 | # Spoke Runtime 28 | FROM ${RUNTIME_IMAGE} 29 | WORKDIR /spoke 30 | COPY --from=builder /spoke/build build 31 | COPY --from=builder /spoke/node_modules node_modules 32 | COPY --from=builder /spoke/package.json /spoke/yarn.lock ./ 33 | ENV NODE_ENV=production \ 34 | PORT=3000 \ 35 | ASSETS_DIR=./build/client/assets \ 36 | ASSETS_MAP_FILE=assets.json \ 37 | JOBS_SAME_PROCESS=1 38 | 39 | # Switch to non-root user https://github.com/nodejs/docker-node/blob/d4d52ac41b1f922242d3053665b00336a50a50b3/docs/BestPractices.md#non-root-user 40 | USER node 41 | EXPOSE 3000 42 | CMD ["npm", "start"] 43 | -------------------------------------------------------------------------------- /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 | const InteractionStep = thinky.createModel( 6 | "interaction_step", 7 | type 8 | .object() 9 | .schema({ 10 | id: type.string(), 11 | campaign_id: requiredString(), 12 | // PROMPTS: 13 | question: optionalString(), 14 | script: optionalString(), 15 | created_at: timestamp(), 16 | 17 | // Previously there were answer options, and no such thing as 18 | // parents/ancestors. This was pretty cool, in-theory 19 | // since you could have many paths that led into a unified 20 | // path. However, the UI didn't allow it, so we are going 21 | // to squash that dream, at least until after the db migration 22 | 23 | // FIELDS FOR SUB-INTERACTIONS (only): 24 | parent_interaction_id: optionalString().foreign("interaction_step"), 25 | answer_option: optionalString(), // (was 'value') 26 | answer_actions: optionalString(), 27 | is_deleted: type 28 | .boolean() 29 | .default(false) 30 | .allowNull(false) 31 | }) 32 | .allowExtra(false), 33 | { noAutoCreation: true } 34 | ); 35 | 36 | export default InteractionStep; 37 | -------------------------------------------------------------------------------- /src/components/PeopleList/RolesDropdown.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import type from "prop-types"; 3 | 4 | import DropDownMenu from "material-ui/DropDownMenu"; 5 | import MenuItem from "material-ui/MenuItem"; 6 | import { getHighestRole, ROLE_HIERARCHY } from "../../lib"; 7 | 8 | const RolesDropdown = props => ( 9 | props.onChange(props.texterId, value)} 17 | > 18 | {ROLE_HIERARCHY.map(option => ( 19 | 30 | ))} 31 | 32 | ); 33 | 34 | RolesDropdown.propTypes = { 35 | roles: type.array, 36 | texterId: type.string, 37 | currentUser: type.object, 38 | onChange: type.func 39 | }; 40 | 41 | export default RolesDropdown; 42 | -------------------------------------------------------------------------------- /dev-tools/generate-test-data.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | 5 | 6 | def first_name(i): 7 | return f"Spokey{i}" 8 | 9 | 10 | def last_name(i): 11 | return f"McSpoke{i}" 12 | 13 | 14 | def phone_number(i): 15 | return f"+1555{i:07}" 16 | 17 | 18 | def email(i): 19 | return f"spokeymcspokeface{i}@blackhole.example.com" 20 | 21 | 22 | def zip(i): 23 | return "02141" 24 | 25 | 26 | fav_colors = ["red", "blue", "liberty-green", "orange", "purple", "yellow"] 27 | 28 | 29 | def fav_color(i): 30 | return fav_colors[i % len(fav_colors)] 31 | 32 | 33 | statecodes = ["MA", "NY", "IA", "CA", "NH", "DC", "FL"] 34 | 35 | 36 | def statecode(i): 37 | return statecodes[i % len(statecodes)] 38 | 39 | 40 | def main(): 41 | if len(sys.argv) < 2 or sys.argv[1] == "-h" or sys.argv[1] == "--help": 42 | print("Usage: generate-test-data.py NUMBER_OF_CONTACTS > contacts.csv") 43 | return 44 | 45 | print( 46 | "first_name,last_name,phone_number,email,zip,fav_color,source_id_type,source_id,van_statecode" 47 | ) 48 | for i in range(int(sys.argv[1])): 49 | print( 50 | f"{first_name(i)},{last_name(i)},{phone_number(i)},{email(i)},{zip(i)},{fav_color(i)},custom,{i},{statecode(i)}" 51 | ) 52 | 53 | 54 | if __name__ == "__main__": 55 | main() 56 | -------------------------------------------------------------------------------- /src/components/IncomingMessageList/ConfirmChangePageWithConvosSelectedDialog.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | 3 | import type from "prop-types"; 4 | import Dialog from "material-ui/Dialog"; 5 | import FlatButton from "material-ui/FlatButton"; 6 | 7 | const nextOrPrevious = pageDelta => (pageDelta > 0 ? "next" : "previous"); 8 | 9 | const ConfirmChangePageWithConvosSelectedDialog = props => ( 10 | props.onRequestClose(true)} 17 | />, 18 | props.onRequestClose(false)} 22 | /> 23 | ]} 24 | modal 25 | open={props.open} 26 | > 27 | {`There are conversations selected on this page. Move to the ${nextOrPrevious( 28 | props.pageDelta 29 | )} page and clear all selections?`} 30 | 31 | ); 32 | 33 | ConfirmChangePageWithConvosSelectedDialog.propTypes = { 34 | open: type.bool.isRequired, 35 | pageDelta: type.number.isRequired, 36 | onRequestClose: type.func.isRequired 37 | }; 38 | 39 | export default ConfirmChangePageWithConvosSelectedDialog; 40 | -------------------------------------------------------------------------------- /__test__/TopNav.test.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { shallow } from "enzyme"; 3 | import { StyleSheetTestUtils } from "aphrodite"; 4 | import TopNav from "../src/components/TopNav"; 5 | 6 | describe("TopNav", () => { 7 | it.skip("can render only title", () => { 8 | const nav = shallow(); 9 | expect(nav.text()).toEqual( 10 | "Welcome to my website" 11 | ); 12 | expect(nav.find("Link").length).toBe(0); 13 | }); 14 | 15 | it.skip("can render Link to go back", () => { 16 | const link = shallow( 17 | 18 | ).find("Link"); 19 | expect(link.length).toBe(1); 20 | expect(link.prop("to")).toBe("/admin/1/campaigns"); 21 | expect(link.find("IconButton").length).toBe(1); 22 | }); 23 | 24 | it.skip("renders UserMenu", () => { 25 | const nav = shallow(); 26 | expect(nav.find("Connect(Apollo(withRouter(UserMenu)))").length).toBe(1); 27 | }); 28 | }); 29 | 30 | // https://github.com/Khan/aphrodite/issues/62#issuecomment-267026726 31 | beforeEach(() => { 32 | StyleSheetTestUtils.suppressStyleInjection(); 33 | }); 34 | afterEach(() => { 35 | StyleSheetTestUtils.clearBufferAndResumeStyleInjection(); 36 | }); 37 | -------------------------------------------------------------------------------- /src/lib/phone-format.js: -------------------------------------------------------------------------------- 1 | import { PhoneNumberUtil, PhoneNumberFormat } from "google-libphonenumber"; 2 | 3 | const skipValidation = 4 | global.SUPPRESS_PHONE_VALIDATION === "1" || 5 | process.env.SUPPRESS_PHONE_VALIDATION === "1"; 6 | export const getFormattedPhoneNumber = (cell, country = "US") => { 7 | const phoneUtil = PhoneNumberUtil.getInstance(); 8 | // we return an empty string vs null when the phone number is inValid 9 | // because when the cell is null, batch inserts into campaign contacts fail 10 | // then when contacts have cell.length < 12 (+1), it's deleted before assignments are created 11 | try { 12 | const inputNumber = phoneUtil.parse(cell, country); 13 | 14 | if (skipValidation && phoneUtil.isPossibleNumber(inputNumber)) { 15 | return phoneUtil.format(inputNumber, PhoneNumberFormat.E164); 16 | } 17 | 18 | const isValid = phoneUtil.isValidNumber(inputNumber); 19 | if (isValid) { 20 | return phoneUtil.format(inputNumber, PhoneNumberFormat.E164); 21 | } 22 | return ""; 23 | } catch (e) { 24 | console.error(e); 25 | return ""; 26 | } 27 | }; 28 | 29 | export const getDisplayPhoneNumber = (e164Number, country = "US") => { 30 | const phoneUtil = PhoneNumberUtil.getInstance(); 31 | const parsed = phoneUtil.parse(e164Number, country); 32 | return phoneUtil.format(parsed, PhoneNumberFormat.NATIONAL); 33 | }; 34 | -------------------------------------------------------------------------------- /terraform/README.md: -------------------------------------------------------------------------------- 1 | # Warren Fork Example Terraform 2 | 3 | This directory includes the terraform configuration we used to configure all the resources we needed. It's likely not useful as-is, but rather serves as a guide for the infrastructure you'll need to stand up to run Spoke on AWS. 4 | 5 | This terraform module sets up: 6 | 7 | - SSM parameters for various secrets used by Spoke 8 | - An S3 bucket for private user content like CSV uploads 9 | - An Aurora Serverless Postgres database 10 | - An SQS queue and API gateway endpoint that feeds into that queue for receiving messages from Twilio. We didn't end up using this directly -- we still configured Twilio to send messages directly to Spoke -- but we used the API Gateway endpoint as the Twilio fallback URL so if Spoke did experience and outage or transient failure while processing a Twilio webhook, Twilio would fall back to delivering the message to the SQS queue where we could manually replay it. 11 | 12 | 13 | Not included in this terraform module: 14 | 15 | - We had a separate public CDN setup with S3 and CloudFront that was used to serve static assets. For example, the `scripts/deploy-tools` script copies the built client-side javascript to this bucket, which was called `ew-spoke-public`. This was then served by CloudFront on the domain https://ew-spoke-public.elizabethwarren.codes/ which was configured in `serverless.yml` as our `ASSET_DOMAIN`. -------------------------------------------------------------------------------- /.github/workflows/prod-deploy.yml: -------------------------------------------------------------------------------- 1 | name: '[PROD] Deploy' 2 | 3 | # ALWAYS USE SINGLE QUOTES HERE 4 | 5 | on: 6 | push: 7 | tags: 8 | - 'release-*' 9 | 10 | jobs: 11 | deploy: 12 | name: Deploy Spoke 13 | runs-on: ubuntu-latest 14 | env: 15 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 16 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 17 | AWS_DEFAULT_REGION: us-east-1 18 | SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} 19 | STAGE: prod 20 | INFRASTRUCTURE: prod 21 | OUTPUT_DIR: ./build 22 | ASSETS_DIR: ./build/client/assets 23 | ASSETS_MAP_FILE: assets.json 24 | PHONE_NUMBER_COUNTRY: US 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v1 28 | - name: Setup Node 29 | uses: actions/setup-node@v1 30 | with: 31 | node-version: 12.16.x 32 | - name: Install Serverless 33 | run: npm install -g serverless 34 | - name: Install Dependencies 35 | run: make install-deps 36 | - name: Create Sentry Release 37 | run: ./dev-tools/sentry-release-create.sh 38 | - name: Build 39 | run: make build-app 40 | env: 41 | NODE_ENV: production 42 | - name: Deploy 43 | run: make deploy-build 44 | - name: Finalize Sentry Release 45 | run: ./dev-tools/sentry-release-finalize.sh 46 | -------------------------------------------------------------------------------- /src/api/campaign-contact.js: -------------------------------------------------------------------------------- 1 | import gql from "graphql-tag"; 2 | 3 | // TODO allow passing an array of contact ids to the filter? 4 | export const schema = gql` 5 | input ContactsFilter { 6 | contactId: ID 7 | cell: String 8 | messageStatus: String 9 | isOptedOut: Boolean 10 | validTimezone: Boolean 11 | includePastDue: Boolean 12 | tags: [String] 13 | includeResolvedTags: Boolean 14 | } 15 | 16 | type Timezone { 17 | offset: Int 18 | hasDST: Boolean 19 | } 20 | 21 | type Location { 22 | timezone: Timezone 23 | city: String 24 | state: String 25 | } 26 | 27 | type Tag { 28 | tag: String! 29 | comment: String 30 | createdBy: User! 31 | createdAt: Date 32 | resolvedBy: User 33 | resolvedAt: Date 34 | } 35 | 36 | type CampaignContact { 37 | id: ID 38 | firstName: String 39 | lastName: String 40 | cell: Phone 41 | external_id: String 42 | external_id_type: String 43 | state_code: String 44 | customFields: JSON 45 | messages: [Message] 46 | location: Location 47 | optOut: OptOut 48 | campaign: Campaign 49 | questionResponseValues: [AnswerOption] 50 | questionResponses: [AnswerOption] 51 | interactionSteps: [InteractionStep] 52 | messageStatus: String 53 | assignmentId: String 54 | tags: [Tag] 55 | hasUnresolvedTags: Boolean 56 | updatedAt: Date 57 | } 58 | `; 59 | -------------------------------------------------------------------------------- /src/components/IncomingMessageActions/index.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import type from "prop-types"; 3 | import { Card, CardHeader, CardText } from "material-ui/Card"; 4 | import Reassign from "./Reassign"; 5 | import ManageTags from "./ManageTags"; 6 | 7 | const IncomingMessageActions = props => ( 8 | 9 | 14 | 15 | 22 | 27 | 28 | 29 | ); 30 | 31 | IncomingMessageActions.propTypes = { 32 | people: type.array, 33 | onReassignRequested: type.func.isRequired, 34 | onReassignAllMatchingRequested: type.func.isRequired, 35 | onAssignTags: type.func.isRequired, 36 | onRemoveTags: type.func.isRequired, 37 | conversationCount: type.number, 38 | tagsFilter: type.object, 39 | organizationId: type.string 40 | }; 41 | 42 | export default IncomingMessageActions; 43 | -------------------------------------------------------------------------------- /src/lib/tags.js: -------------------------------------------------------------------------------- 1 | export const NO_TAG = { key: -1, value: -1, display: "NO TAG" }; 2 | export const ANY_TAG = { key: -2, value: -2, display: "ANY TAG" }; 3 | export const IGNORE_TAGS = { key: -3, value: -3, display: "IGNORE TAGS" }; 4 | 5 | // TODO: add another table for possible values? 6 | export const TAGS = { 7 | 1: { key: 1, value: "COME BACK LATER", display: "COME BACK LATER" }, 8 | 2: { key: 2, value: "SPANISH", display: "SPANISH" }, 9 | 3: { key: 3, value: "LANGUAGE BARRIER", display: "LANGUAGE BARRIER" } 10 | }; 11 | 12 | export const TAG_META_FILTERS = {}; 13 | TAG_META_FILTERS[IGNORE_TAGS.key] = IGNORE_TAGS; 14 | TAG_META_FILTERS[ANY_TAG.key] = ANY_TAG; 15 | TAG_META_FILTERS[NO_TAG.key] = NO_TAG; 16 | 17 | export const makeTagMetafilter = (ignoreTags, anyTag, noTag, tagItem) => { 18 | const filter = { 19 | ignoreTags, 20 | anyTag, 21 | noTag, 22 | selectedTags: {} 23 | }; 24 | 25 | if (tagItem) { 26 | filter.selectedTags[tagItem.key] = tagItem; 27 | } 28 | 29 | return filter; 30 | }; 31 | 32 | export const IGNORE_TAGS_FILTER = makeTagMetafilter( 33 | true, 34 | false, 35 | false, 36 | IGNORE_TAGS 37 | ); 38 | export const ANY_TAG_FILTER = makeTagMetafilter(false, true, false, ANY_TAG); 39 | export const NO_TAG_FILTER = makeTagMetafilter(false, false, true, NO_TAG); 40 | export const EMPTY_TAG_FILTER = makeTagMetafilter(false, false, false, null); 41 | 42 | export default TAGS; 43 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: 'Test' 2 | 3 | # ALWAYS USE SINGLE QUOTES HERE 4 | 5 | on: push 6 | 7 | jobs: 8 | deploy: 9 | name: Deploy Spoke 10 | runs-on: ubuntu-latest 11 | services: 12 | postgres: 13 | image: postgres:10 14 | env: 15 | POSTGRES_USER: spoke_test 16 | POSTGRES_PASSWORD: spoke_test 17 | POSTGRES_DB: spoke_test 18 | ports: 19 | - 5432:5432 20 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 21 | redis: 22 | image: redis:alpine 23 | ports: 24 | - 6379:6379 25 | env: 26 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 27 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 28 | AWS_DEFAULT_REGION: us-east-1 29 | STAGE: dev 30 | INFRASTRUCTURE: dev 31 | OUTPUT_DIR: ./build 32 | ASSETS_DIR: ./build/client/assets 33 | ASSETS_MAP_FILE: assets.json 34 | PHONE_NUMBER_COUNTRY: US 35 | steps: 36 | - name: Checkout 37 | uses: actions/checkout@v1 38 | - name: Setup Node 39 | uses: actions/setup-node@v1 40 | with: 41 | node-version: 12.16.x 42 | - name: Install Dependencies 43 | run: make install-deps 44 | - name: Install psql 45 | run: sudo apt-get install postgresql-client 46 | - name: Test 47 | run: yarn test-ci 48 | -------------------------------------------------------------------------------- /src/components/IncomingMessageList/ConversationPreviewModal/MessageList.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import PropTypes from "prop-types"; 3 | import Message from "./../../Message"; 4 | import Tag from "./Tag"; 5 | 6 | export default class MessageList extends Component { 7 | componentDidMount() { 8 | this.refs.messageWindow.scrollTo(0, this.refs.messageWindow.scrollHeight); 9 | } 10 | 11 | componentDidUpdate() { 12 | this.refs.messageWindow.scrollTo(0, this.refs.messageWindow.scrollHeight); 13 | } 14 | 15 | render() { 16 | const { messages, tags } = this.props; 17 | const items = [...messages, ...tags]; 18 | const sortedItems = items.sort( 19 | (left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt) 20 | ); 21 | return ( 22 |
31 | {sortedItems.map((item, index) => 32 | !item.tag ? ( 33 | 34 | ) : ( 35 | 36 | ) 37 | )} 38 |
39 | ); 40 | } 41 | } 42 | 43 | MessageList.propTypes = { 44 | messages: PropTypes.arrayOf(PropTypes.object), 45 | tags: PropTypes.arrayOf(PropTypes.object) 46 | }; 47 | -------------------------------------------------------------------------------- /src/server/models/campaign-contact.js: -------------------------------------------------------------------------------- 1 | import thinky from "./thinky"; 2 | 3 | const type = thinky.type; 4 | import { requiredString, optionalString, timestamp } from "./custom-types"; 5 | 6 | const CampaignContact = thinky.createModel( 7 | "campaign_contact", 8 | type 9 | .object() 10 | .schema({ 11 | id: type.string(), 12 | campaign_id: requiredString(), 13 | assignment_id: optionalString(), 14 | external_id: optionalString().stopReference(), 15 | external_id_type: optionalString(), 16 | first_name: optionalString(), 17 | last_name: optionalString(), 18 | cell: requiredString(), 19 | state_code: optionalString(), 20 | zip: optionalString(), 21 | custom_fields: requiredString().default("{}"), 22 | created_at: timestamp(), 23 | updated_at: timestamp(), 24 | message_status: requiredString() 25 | .enum([ 26 | "needsMessage", 27 | "needsResponse", 28 | "convo", 29 | "messaged", 30 | "closed", 31 | "UPDATING" 32 | ]) 33 | .default("needsMessage"), 34 | is_opted_out: type.boolean().default(false), 35 | timezone_offset: type 36 | .string() 37 | .default("") 38 | .required(), 39 | has_unresolved_tags: type.boolean().default(false) 40 | }) 41 | .allowExtra(false), 42 | { noAutoCreation: true } 43 | ); 44 | 45 | export default CampaignContact; 46 | -------------------------------------------------------------------------------- /src/lib/search-helpers.js: -------------------------------------------------------------------------------- 1 | //quick & dirty client-side search courtesy of: https://stackoverflow.com/questions/8517089/js-search-in-object-values 2 | function trimString(s) { 3 | var l = 0, 4 | r = s.length - 1; 5 | while (l < s.length && s[l] == " ") l++; 6 | while (r > l && s[r] == " ") r -= 1; 7 | return s.substring(l, r + 1).toLowerCase(); 8 | } 9 | 10 | function compareObjects(o1, o2) { 11 | var k = ""; 12 | for (k in o1) if (o1[k] != o2[k]) return false; 13 | for (k in o2) if (o1[k] != o2[k]) return false; 14 | return true; 15 | } 16 | 17 | function itemExists(haystack, needle) { 18 | for (var i = 0; i < haystack.length; i++) 19 | if (compareObjects(haystack[i], needle)) return true; 20 | return false; 21 | } 22 | 23 | export function searchFor(query, objectsToSearch, keysToSearch) { 24 | var results = []; 25 | const toSearch = trimString(query); 26 | for (var i = 0; i < objectsToSearch.length; i++) { 27 | for (var x = 0; x < keysToSearch.length; x++) { 28 | const key = keysToSearch[x]; 29 | 30 | let field; 31 | if (typeof key === "function") { 32 | field = key(objectsToSearch[i]); 33 | } else { 34 | field = objectsToSearch[i][key]; 35 | } 36 | 37 | if (field && field.toLowerCase().indexOf(toSearch) != -1) { 38 | if (!itemExists(results, objectsToSearch[i])) 39 | results.push(objectsToSearch[i]); 40 | } 41 | } 42 | } 43 | return results; 44 | } 45 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/components/AdminCampaignList/SortBy.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | import React from "react"; 3 | import DropDownMenu from "material-ui/DropDownMenu"; 4 | import MenuItem from "material-ui/MenuItem"; 5 | 6 | const DUE_DATE_ASC_SORT = { 7 | display: "Due Date - Earliest First", 8 | value: "DUE_DATE_ASC" 9 | }; 10 | 11 | const DUE_DATE_DESC_SORT = { 12 | display: "Due Date - Latest First", 13 | value: "DUE_DATE_DESC" 14 | }; 15 | 16 | export const ID_ASC_SORT = { 17 | display: "Created - Earliest First", 18 | value: "ID_ASC" 19 | }; 20 | 21 | export const ID_DESC_SORT = { 22 | display: "Created - Latest First", 23 | value: "ID_DESC" 24 | }; 25 | 26 | const TITLE_SORT = { 27 | display: "Title", 28 | value: "TITLE" 29 | }; 30 | 31 | const SORTS = [ 32 | DUE_DATE_ASC_SORT, 33 | DUE_DATE_DESC_SORT, 34 | ID_ASC_SORT, 35 | ID_DESC_SORT, 36 | TITLE_SORT 37 | ]; 38 | 39 | export const DEFAULT_SORT_BY_VALUE = ID_DESC_SORT.value; 40 | 41 | const SortBy = props => ( 42 | props.onChange(value)} 45 | > 46 | {SORTS.map(sort => ( 47 | 52 | ))} 53 | 54 | ); 55 | 56 | SortBy.propTypes = { 57 | sortBy: PropTypes.string, 58 | onChange: PropTypes.func 59 | }; 60 | 61 | export default SortBy; 62 | -------------------------------------------------------------------------------- /src/client/index.jsx: -------------------------------------------------------------------------------- 1 | import "./webpack-config"; 2 | import "./backcompat"; 3 | import React, { Suspense } from "react"; 4 | import ReactDOM from "react-dom"; 5 | import { Router, browserHistory } from "react-router"; 6 | import { syncHistoryWithStore } from "react-router-redux"; 7 | import { ApolloProvider } from "react-apollo"; 8 | import MuiThemeProvider from "material-ui/styles/MuiThemeProvider"; 9 | 10 | import muiTheme from "src/styles/mui-theme"; 11 | import makeRoutes from "./routes"; 12 | import Store from "src/store"; 13 | import ApolloClientSingleton from "src/network/apollo-client-singleton"; 14 | import LoadingIndicator from "src/components/LoadingIndicator"; 15 | import ErrorBoundary from "src/components/ErrorBoundary"; 16 | 17 | import { login, logout } from "./auth-service"; 18 | import Telemetry from "./telemetry"; 19 | 20 | Telemetry.init(); 21 | 22 | window.AuthService = { 23 | login, 24 | logout 25 | }; 26 | 27 | const store = new Store(browserHistory, window.INITIAL_STATE); 28 | const history = syncHistoryWithStore(browserHistory, store.data); 29 | 30 | ReactDOM.render( 31 | 32 | 33 | 34 | }> 35 | 36 | 37 | 38 | 39 | , 40 | document.getElementById("mount") 41 | ); 42 | -------------------------------------------------------------------------------- /src/containers/SuspendedTexter.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | import React from "react"; 3 | 4 | import { withRouter } from "react-router"; 5 | 6 | import ErrorOutline from "material-ui/svg-icons/alert/error-outline"; 7 | 8 | import Empty from "../components/Empty"; 9 | import loadData from "./hoc/load-data"; 10 | 11 | import gql from "graphql-tag"; 12 | 13 | const SuspendedTexter = props => { 14 | if (!props.currentUser.errors) { 15 | console.log(props.currentUser); 16 | props.router.push(`/app/${props.organizationId}/todos`); 17 | return null; 18 | } 19 | 20 | return ( 21 |
22 | } 25 | /> 26 |
27 | ); 28 | }; 29 | 30 | SuspendedTexter.propTypes = { 31 | organizationId: PropTypes.string.isRequired, 32 | currentUser: PropTypes.object, 33 | router: PropTypes.object 34 | }; 35 | 36 | const mapQueriesToProps = ({ ownProps }) => ({ 37 | currentUser: { 38 | query: gql` 39 | query Q($organizationId: String!, $role: String!) { 40 | currentUserWithAccess(organizationId: $organizationId, role: $role) { 41 | id 42 | } 43 | } 44 | `, 45 | variables: { 46 | organizationId: ownProps.organizationId, 47 | role: "TEXTER" 48 | }, 49 | fetchPolicy: "network-only" 50 | } 51 | }); 52 | 53 | export default loadData(withRouter(SuspendedTexter), { mapQueriesToProps }); 54 | -------------------------------------------------------------------------------- /src/server/db/background-job.js: -------------------------------------------------------------------------------- 1 | import { 2 | Table, 3 | getAny, 4 | camelize, 5 | insertAndReturn, 6 | updateAndReturn, 7 | queryBuilder 8 | } from "./common"; 9 | 10 | export const STATUS = { 11 | PENDING: "PENDING", 12 | RUNNING: "RUNNING", 13 | FAILED: "FAILED", 14 | DONE: "DONE" 15 | }; 16 | 17 | async function create( 18 | { campaignId, organizationId, userId, type, config }, 19 | opts 20 | ) { 21 | return await insertAndReturn( 22 | Table.BACKGROUND_JOB, 23 | { 24 | campaignId, 25 | organizationId, 26 | userId, 27 | type, 28 | config 29 | }, 30 | opts 31 | ); 32 | } 33 | 34 | async function updateStatus(jobId, { progress, status, resultMessage }, opts) { 35 | return await updateAndReturn( 36 | Table.BACKGROUND_JOB, 37 | jobId, 38 | { 39 | progress, 40 | status, 41 | resultMessage 42 | }, 43 | opts 44 | ); 45 | } 46 | 47 | async function get(jobId, opts) { 48 | return getAny(Table.BACKGROUND_JOB, "id", jobId, opts); 49 | } 50 | 51 | async function getByTypeAndCampaign(type, campaignId, opts) { 52 | return camelize( 53 | await queryBuilder(Table.BACKGROUND_JOB, opts) 54 | .where({ 55 | campaign_id: campaignId, 56 | type 57 | }) 58 | .orderBy("created_at", "desc") 59 | .first() 60 | ); 61 | } 62 | 63 | const BackgroundJob = { 64 | create, 65 | updateStatus, 66 | get, 67 | getByTypeAndCampaign, 68 | STATUS 69 | }; 70 | export default BackgroundJob; 71 | -------------------------------------------------------------------------------- /src/containers/JobProgress.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import gql from "graphql-tag"; 3 | import loadData from "./hoc/load-data"; 4 | import { CircularProgress } from "material-ui"; 5 | import types from "prop-types"; 6 | 7 | class JobProgress extends Component { 8 | static propTypes = { 9 | jobId: types.string, 10 | mode: types.string, 11 | text: types.string 12 | }; 13 | 14 | render() { 15 | const job = this.props.jobData.backgroundJob; 16 | return ( 17 |
18 |

{this.props.text}

19 | 27 | {this.props.mode === "determinate" ? ( 28 |

{(job.progress * 100).toFixed(1)}%

29 | ) : null} 30 |
31 | ); 32 | } 33 | } 34 | 35 | const mapQueriesToProps = ({ ownProps }) => ({ 36 | jobData: { 37 | query: gql` 38 | query getJobData($jobId: ID!) { 39 | backgroundJob(jobId: $jobId) { 40 | id 41 | resultMessage 42 | progress 43 | status 44 | } 45 | } 46 | `, 47 | variables: { 48 | jobId: ownProps.jobId 49 | }, 50 | pollInterval: 2500 51 | } 52 | }); 53 | 54 | export default loadData(JobProgress, { 55 | mapQueriesToProps 56 | }); 57 | -------------------------------------------------------------------------------- /src/server/api/lib/fakeservice.js: -------------------------------------------------------------------------------- 1 | import { Message, r } from "../../models"; 2 | import log from "src/server/log"; 3 | 4 | // This 'fakeservice' allows for fake-sending messages 5 | // that end up just in the db appropriately and then using sendReply() graphql 6 | // queries for the reception (rather than a real service) 7 | 8 | async function sendMessage(message, contact, trx) { 9 | const newMessage = new Message({ 10 | ...message, 11 | service: "fakeservice", 12 | send_status: "SENT", 13 | sent_at: new Date() 14 | }); 15 | 16 | if (message && message.id) { 17 | let request = r.knex("message"); 18 | if (trx) { 19 | request = request.transacting(trx); 20 | } 21 | // updating message! 22 | await request.where("id", message.id).update({ 23 | service: "fakeservice", 24 | send_status: "SENT", 25 | sent_at: new Date() 26 | }); 27 | } 28 | 29 | if (contact && /autorespond/.test(message.text)) { 30 | // We can auto-respond to the the user if they include the text 'autorespond' in their message 31 | await Message.save({ 32 | ...message, 33 | // just flip/renew the vars for the contact 34 | id: undefined, 35 | service_id: `mockedresponse${Math.random()}`, 36 | is_from_contact: true, 37 | text: `responding to ${message.text}`, 38 | send_status: "DELIVERED" 39 | }); 40 | contact.message_status = "needsResponse"; 41 | await contact.save(); 42 | } 43 | return newMessage; 44 | } 45 | 46 | export default { 47 | sendMessage 48 | }; 49 | -------------------------------------------------------------------------------- /src/server/api/lib/message-sending.js: -------------------------------------------------------------------------------- 1 | import { r } from "../../models"; 2 | import log from "src/server/log"; 3 | 4 | export async function getLastMessage({ 5 | contactNumber, 6 | messagingServiceSid, 7 | service 8 | }) { 9 | return await r 10 | .table("message") 11 | .getAll(contactNumber, { index: "contact_number" }) 12 | .filter({ 13 | is_from_contact: false, 14 | service, 15 | messaging_service_sid: messagingServiceSid 16 | }) 17 | .orderBy(r.desc("created_at")) 18 | .limit(1) 19 | .pluck("assignment_id")(0) 20 | .default(null); 21 | } 22 | 23 | export async function saveNewIncomingMessage(messageInstance) { 24 | // TODO[matteo]: this should probably all happen in a transaction but I haven't 25 | // investigated if it's possible to pass one to thinky. 26 | if (messageInstance.service_id) { 27 | const existingMessage = await r 28 | .knex("message") 29 | .where("service_id", messageInstance.service_id) 30 | .first(); 31 | if (existingMessage) { 32 | log.error( 33 | "Skipping duplicate message:", 34 | messageInstance, 35 | "found:", 36 | existingMessage 37 | ); 38 | return; 39 | } 40 | } 41 | 42 | await messageInstance.save(); 43 | await r 44 | .table("campaign_contact") 45 | .getAll(messageInstance.assignment_id, { index: "assignment_id" }) 46 | .filter({ cell: messageInstance.contact_number }) 47 | .limit(1) 48 | .update({ message_status: "needsResponse", updated_at: "now()" }); 49 | } 50 | -------------------------------------------------------------------------------- /src/lib/scripts.js: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | import { allFields, getFieldValue } from "./fields-helpers"; 3 | 4 | export const delimiters = { 5 | startDelimiter: "{", 6 | endDelimiter: "}" 7 | }; 8 | 9 | export function delimit(text) { 10 | const { startDelimiter, endDelimiter } = delimiters; 11 | return `${startDelimiter}${text}${endDelimiter}`; 12 | } 13 | 14 | export function applyScript({ script, contact, customFields, texter }) { 15 | const scriptFields = allFields(customFields); 16 | let appliedScript = script; 17 | 18 | scriptFields.forEach(field => { 19 | const re = new RegExp(`${delimit(field)}`, "g"); 20 | appliedScript = appliedScript.replace( 21 | re, 22 | getFieldValue(contact, texter, field) 23 | ); 24 | }); 25 | 26 | return appliedScript; 27 | } 28 | 29 | export function validateScript({ script, customFields }) { 30 | const regex = /{(.*?)}/; 31 | const arr = script.trim().split(" "); 32 | const allVars = arr.reduce((acc, string) => { 33 | const match = string.match(regex); 34 | if (match) { 35 | acc.push(match[1]); 36 | } 37 | return acc; 38 | }, []); 39 | 40 | const validCustomFields = allFields(customFields); 41 | const sortedFields = _.partition( 42 | allVars, 43 | item => validCustomFields.indexOf(item) >= 0 44 | ); 45 | 46 | const missing = sortedFields[1] || []; 47 | const isValid = missing.length === 0; 48 | 49 | if (isValid) { 50 | return { field: script }; 51 | } 52 | return { 53 | field: "", 54 | missingFields: missing 55 | }; 56 | } 57 | -------------------------------------------------------------------------------- /src/containers/AdminNavigation.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | import React from "react"; 3 | import Navigation from "../components/Navigation"; 4 | import { ListItem } from "material-ui/List"; 5 | import { withRouter } from "react-router"; 6 | import { dataTest } from "../lib/attributes"; 7 | 8 | class AdminNavigation extends React.Component { 9 | urlFromPath(path) { 10 | const { organizationId } = this.props; 11 | return `/admin/${organizationId}/${path}`; 12 | } 13 | 14 | render() { 15 | const { organizationId, sections } = this.props; 16 | return ( 17 | ({ 21 | ...section, 22 | url: this.urlFromPath(section.path) 23 | }))} 24 | switchListItem={ 25 | 29 | this.props.router.push(`/app/${organizationId}/todos`) 30 | } 31 | /> 32 | } 33 | /> 34 | ); 35 | } 36 | } 37 | 38 | AdminNavigation.defaultProps = { 39 | showMenu: true 40 | }; 41 | 42 | AdminNavigation.propTypes = { 43 | data: PropTypes.object, 44 | organizationId: PropTypes.string, 45 | router: PropTypes.object, 46 | sections: PropTypes.array, 47 | params: PropTypes.object, 48 | onToggleMenu: PropTypes.func.isRequired, 49 | showMenu: PropTypes.bool 50 | }; 51 | 52 | export default withRouter(AdminNavigation); 53 | -------------------------------------------------------------------------------- /src/lib/color-helpers.js: -------------------------------------------------------------------------------- 1 | import theme from "../styles/theme"; 2 | 3 | const blackRgbArray = [0, 0, 0]; 4 | const MIN_CONTRAST = 4.5; 5 | const WHITE = theme.colors.white; 6 | const whiteRgbArray = [255, 255, 255]; 7 | 8 | //from https://stackoverflow.com/questions/5623838/rgb-to-hex-and-hex-to-rgb 9 | function hexToRgb(hex) { 10 | const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); 11 | return result 12 | ? [ 13 | parseInt(result[1], 16), 14 | parseInt(result[2], 16), 15 | parseInt(result[3], 16) 16 | ] 17 | : null; 18 | } 19 | 20 | const rgbToArray = color => color.match(/[\.\d]+/g).map(item => +item); 21 | 22 | //from: https://stackoverflow.com/questions/9733288/how-to-programmatically-calculate-the-contrast-ratio-between-two-colors 23 | function luminanace(r, g, b) { 24 | const a = [r, g, b].map(v => { 25 | v /= 255; 26 | return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); 27 | }); 28 | return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722; 29 | } 30 | function contrast(rgb1, rgb2) { 31 | const lum1 = luminanace(rgb1[0], rgb1[1], rgb1[2]); 32 | const lum2 = luminanace(rgb2[0], rgb2[1], rgb2[2]); 33 | const brightest = Math.max(lum1, lum2); 34 | const darkest = Math.min(lum1, lum2); 35 | return (brightest + 0.05) / (darkest + 0.05); 36 | } 37 | 38 | export function getBlackOrWhiteTextForBg(bgColor) { 39 | const rgbArray = hexToRgb(bgColor) || rgbToArray(bgColor) || whiteRgbArray; 40 | const contrastOnBlack = contrast(rgbArray, blackRgbArray); 41 | return contrastOnBlack > MIN_CONTRAST ? "black" : WHITE; 42 | } 43 | -------------------------------------------------------------------------------- /src/components/IncomingMessageFilter/Filters/TexterFilter.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import type from "prop-types"; 3 | import { dataSourceItem } from "../../utils"; 4 | import AutoComplete from "material-ui/AutoComplete"; 5 | 6 | export const ALL_TEXTERS = -1; 7 | 8 | export const TEXTER_FILTERS = [[ALL_TEXTERS, "All Texters"]]; 9 | 10 | export const TexterFilter = props => { 11 | const texterNodes = TEXTER_FILTERS.map(texterFilter => 12 | dataSourceItem(texterFilter[1], texterFilter[0]) 13 | ).concat( 14 | !props.texters 15 | ? [] 16 | : props.texters.map(user => { 17 | const userId = parseInt(user.id, 10); 18 | return dataSourceItem(user.displayName, userId); 19 | }) 20 | ); 21 | 22 | texterNodes.sort((left, right) => 23 | left.text.localeCompare(right.text, "en", { sensitivity: "base" }) 24 | ); 25 | 26 | return ( 27 | 38 | ); 39 | }; 40 | 41 | TexterFilter.propTypes = { 42 | texters: type.array.isRequired, 43 | onFocus: type.func.isRequired, 44 | onSearchTextUpdated: type.func.isRequired, 45 | texterSearchText: type.string.isRequired, 46 | onTexterSelected: type.func.isRequired 47 | }; 48 | 49 | export default TexterFilter; 50 | -------------------------------------------------------------------------------- /webpack/server.js: -------------------------------------------------------------------------------- 1 | import WebpackDevServer from "webpack-dev-server"; 2 | import webpack from "webpack"; 3 | import config from "./config"; 4 | 5 | const webpackPort = process.env.WEBPACK_PORT || 3000; 6 | const appPort = process.env.DEV_APP_PORT; 7 | const webpackHost = process.env.WEBPACK_HOST || "127.0.0.1"; 8 | 9 | Object.keys(config.entry).forEach(key => { 10 | config.entry[key].unshift( 11 | `webpack-dev-server/client?http://${webpackHost}:${webpackPort}/` 12 | ); 13 | config.entry[key].unshift("webpack/hot/only-dev-server"); 14 | }); 15 | 16 | const compiler = webpack(config); 17 | const connstring = `http://127.0.0.1:${appPort}`; 18 | 19 | console.log(`Proxying requests to:${connstring}`); 20 | 21 | const app = new WebpackDevServer(compiler, { 22 | contentBase: "/assets/", 23 | publicPath: "/assets/", 24 | hot: true, 25 | // this should be temporary until we get the real hostname plugged in everywhere 26 | disableHostCheck: true, 27 | headers: { "Access-Control-Allow-Origin": "*" }, 28 | proxy: { 29 | "*": `http://127.0.0.1:${appPort}` 30 | }, 31 | stats: { 32 | colors: true, 33 | hash: false, 34 | version: false, 35 | timings: false, 36 | assets: false, 37 | chunks: false, 38 | modules: false, 39 | reasons: false, 40 | children: false, 41 | source: false, 42 | errors: false, 43 | errorDetails: false, 44 | warnings: true, 45 | publicPath: false 46 | } 47 | }); 48 | 49 | app.listen(webpackPort || process.env.PORT, () => { 50 | log.info( 51 | `Webpack dev server is now running on http://${webpackHost}:${webpackPort}` 52 | ); 53 | }); 54 | -------------------------------------------------------------------------------- /src/server/db/label.js: -------------------------------------------------------------------------------- 1 | import preconditions from "src/server/preconditions"; 2 | import { UserInputError } from "src/server/api/errors"; 3 | import { 4 | Table, 5 | getAny, 6 | insertAndReturn, 7 | genericGetMany, 8 | genericList, 9 | convertCase, 10 | queryBuilder 11 | } from "./common"; 12 | 13 | const SLUG_REGEX = /^[a-z0-9_]+$/; 14 | 15 | async function create( 16 | { organizationId, group, displayValue, slug, createdBy }, 17 | opts 18 | ) { 19 | preconditions.checkMany({ organizationId, displayValue, slug }); 20 | if (!SLUG_REGEX.test(slug)) { 21 | // TODO: maybe create a db.ValidationError and map it to UserInput error in resolvers? 22 | throw new UserInputError(`slug does not match regex: ${SLUG_REGEX}`); 23 | } 24 | return insertAndReturn( 25 | Table.LABEL, 26 | { organizationId, group, displayValue, slug, createdBy }, 27 | opts 28 | ); 29 | } 30 | 31 | async function get(id, opts) { 32 | return getAny(Table.LABEL, "id", id, opts); 33 | } 34 | 35 | async function getByOrgAndSlug(organizationId, slug, opts) { 36 | return convertCase( 37 | await queryBuilder(Table.LABEL, opts) 38 | .where({ organization_id: organizationId, slug }) 39 | .first(), 40 | opts 41 | ); 42 | } 43 | 44 | async function getMany(ids, opts) { 45 | return genericGetMany(Table.LABEL, "id", ids, opts); 46 | } 47 | 48 | async function listForOrganization(organizationId, opts) { 49 | return genericList(Table.LABEL, { organization_id: organizationId }, opts); 50 | } 51 | 52 | export default { 53 | create, 54 | get, 55 | getByOrgAndSlug, 56 | getMany, 57 | listForOrganization 58 | }; 59 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | verbose: true, 3 | testURL: "http://localhost/", 4 | testEnvironment: "node", 5 | globals: { 6 | SUPPRESS_DATABASE_AUTOCREATE: "1", 7 | SUPPRESS_PHONE_VALIDATION: "1", 8 | DB_JSON: JSON.stringify({ 9 | client: "pg", 10 | connection: { 11 | host: "127.0.0.1", 12 | port: "5432", 13 | database: "spoke_test", 14 | password: "spoke_test", 15 | user: "spoke_test" 16 | } 17 | }), 18 | JOB_EXECUTOR: "IN_PROCESS", 19 | REDIS_URL: "redis://localhost:6379/2", 20 | RETHINK_KNEX_NOREFS: "1", // avoids db race conditions 21 | DEFAULT_SERVICE: "fakeservice", 22 | DST_REFERENCE_TIMEZONE: "America/New_York", 23 | DATABASE_SETUP_TEARDOWN_TIMEOUT: 60000, 24 | PASSPORT_STRATEGY: "local", 25 | SESSION_SECRET: "it is JUST a test! -- it better be!", 26 | TEST_ENVIRONMENT: "1" 27 | }, 28 | moduleFileExtensions: ["js", "jsx"], 29 | transform: { 30 | ".*.js": "/node_modules/babel-jest" 31 | }, 32 | moduleDirectories: ["node_modules"], 33 | moduleNameMapper: { 34 | "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": 35 | "/__mocks__/fileMock.js", 36 | "\\.(css|less)$": "/__mocks__/styleMock.js" 37 | }, 38 | collectCoverageFrom: [ 39 | "**/*.{js,jsx}", 40 | "!**/node_modules/**", 41 | "!**/__test__/**", 42 | "!**/deploy/**", 43 | "!**/coverage/**" 44 | ], 45 | setupTestFrameworkScriptFile: "/__test__/setup.js", 46 | testPathIgnorePatterns: ["/node_modules/", "/__test__/e2e/"] 47 | }; 48 | -------------------------------------------------------------------------------- /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: 200, 10 | height: 200, 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: 200, 21 | marginLeft: "auto", 22 | marginRight: "auto" 23 | }, 24 | hideMobile: { 25 | marginTop: "10px", 26 | width: 200, 27 | marginLeft: "auto", 28 | marginRight: "auto", 29 | "@media(maxWidth: 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, children }) => ( 44 |
48 | {React.cloneElement(icon, { style: inlineStyles.icon })} 49 |
{title}
50 | {content ?
{content}
: ""} 51 | {children ? children : null} 52 |
53 | ); 54 | 55 | Empty.propTypes = { 56 | title: PropTypes.string, 57 | icon: PropTypes.object, 58 | content: PropTypes.object, 59 | hideMobile: PropTypes.bool 60 | }; 61 | 62 | export default Empty; 63 | -------------------------------------------------------------------------------- /src/server/data-loaders.js: -------------------------------------------------------------------------------- 1 | import DataLoader from "dataloader"; 2 | import { cacheableData } from "src/server/models/cacheable_queries"; 3 | import { 4 | Assignment, 5 | Campaign, 6 | CampaignContact, 7 | InteractionStep, 8 | Message, 9 | OptOut, 10 | Organization, 11 | CannedResponse, 12 | QuestionResponse, 13 | Tag, 14 | UserOrganization, 15 | User, 16 | ZipCode 17 | } from "src/server/models"; 18 | 19 | function createLoader(model, opts) { 20 | const idKey = (opts && opts.idKey) || "id"; 21 | const cacheObj = opts && opts.cacheObj; 22 | return new DataLoader(async keys => { 23 | if (cacheObj && cacheObj.load) { 24 | return keys.map(async key => await cacheObj.load(key)); 25 | } 26 | const docs = await model.getAll(...keys, { index: idKey }); 27 | return keys.map(key => 28 | docs.find(doc => doc[idKey].toString() === key.toString()) 29 | ); 30 | }); 31 | } 32 | 33 | export const createLoaders = () => ({ 34 | assignment: createLoader(Assignment), 35 | campaign: createLoader(Campaign, { cacheObj: cacheableData.campaign }), 36 | organization: createLoader(Organization, { 37 | cacheObj: cacheableData.organization 38 | }), 39 | user: createLoader(User), 40 | interactionStep: createLoader(InteractionStep), 41 | campaignContact: createLoader(CampaignContact), 42 | zipCode: createLoader(ZipCode, { idKey: "zip" }), 43 | cannedResponse: createLoader(CannedResponse), 44 | message: createLoader(Message), 45 | optOut: createLoader(OptOut), 46 | tag: createLoader(Tag), 47 | questionResponse: createLoader(QuestionResponse), 48 | userOrganization: createLoader(UserOrganization) 49 | }); 50 | -------------------------------------------------------------------------------- /src/components/EmbeddedShifter.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const wrapperStyles = { 4 | height: "calc(100% - 10px)", 5 | padding: "10px", 6 | overflowY: "hidden" 7 | }; 8 | 9 | const iframeStyles = { 10 | height: "100%", 11 | width: "100%", 12 | border: "none", 13 | paddingBottom: "50px" 14 | }; 15 | 16 | function cleanPhoneNumber(phone) { 17 | // take the last 10 digits 18 | return phone 19 | .replace(/[^\d]/g, "") 20 | .split("") 21 | .reverse() 22 | .slice(0, 10) 23 | .reverse() 24 | .join(""); 25 | } 26 | 27 | export default function EmbeddedShifter({ 28 | shiftingConfiguration, 29 | contact, 30 | assignment: { campaign } 31 | }) { 32 | let customFields = {}; 33 | if (contact.customFields) { 34 | customFields = JSON.parse(contact.customFields) || {}; 35 | } 36 | 37 | const urlParams = { 38 | event: customFields.event_id || shiftingConfiguration.eventId || "", 39 | shift: customFields.timeslot_id || shiftingConfiguration.timeslotId || "", 40 | first_name: contact.firstName || "", 41 | last_name: contact.lastName || "", 42 | phone: cleanPhoneNumber(contact.cell || ""), 43 | email: customFields.email || "", 44 | zip: customFields.zip || "", 45 | source: `spoke-${campaign.organization.id}-${campaign.id}` 46 | }; 47 | 48 | const urlParamString = _.map( 49 | urlParams, 50 | (val, key) => `${key}=${encodeURIComponent(val)}` 51 | ).join("&"); 52 | 53 | return ( 54 |
55 |