From 01f7e2e9b869a79ce14fccee0cef62bb08da69fe Mon Sep 17 00:00:00 2001 From: Larry Person Date: Mon, 3 Dec 2018 11:47:45 -0500 Subject: [PATCH] Revert "Revert "Moveon main plus reassign all plus message review optout plus set message validity"" This reverts commit 74938ff61737d1d21cbbaf198140a1457f9eb731. --- .eslintrc | 2 +- .../AssignmentTexterContact.test.js | 20 +- __test__/e2e/basic_text_manager.test.js | 142 +++++++++++--- __test__/e2e/create_copy_campaign.test.js | 50 +++++ __test__/e2e/create_edit_campaign.test.js | 39 ++++ __test__/e2e/data/strings.js | 181 ++++++++++++++++-- __test__/e2e/invite_texter.test.js | 39 ++-- __test__/e2e/page-functions/campaigns.js | 154 +++++++++------ __test__/e2e/page-functions/index.js | 3 +- __test__/e2e/page-functions/invite.js | 14 -- __test__/e2e/page-functions/login.js | 31 +-- __test__/e2e/page-functions/main.js | 70 +++++++ __test__/e2e/page-functions/people.js | 39 +++- __test__/e2e/page-functions/texter.js | 52 +++++ __test__/e2e/page-objects/campaigns.js | 24 ++- __test__/e2e/page-objects/index.js | 8 +- __test__/e2e/page-objects/invite.js | 8 - __test__/e2e/page-objects/main.js | 20 ++ __test__/e2e/page-objects/people.js | 10 + __test__/e2e/page-objects/scriptEditor.js | 2 +- __test__/e2e/page-objects/texter.js | 13 ++ __test__/e2e/util/config.js | 1 + __test__/e2e/util/helpers.js | 48 +++-- __test__/server/api/assignment.test.js | 7 +- __test__/test_data/female_scientists.csv | 164 ++++++++-------- __test__/workers/assign-texters.test.js | 114 +++++++++++ docs/DEPLOYING_AWS_LAMBDA.md | 2 +- docs/EXPLANATION-end-to-end-tests.md | 36 ++-- docs/HOWTO-run_tests.md | 14 +- docs/REFERENCE-environment_variables.md | 5 +- jest.config.js | 2 +- package.json | 1 + src/api/campaign-contact.js | 2 - src/api/canned-response.js | 2 - src/api/organization.js | 1 + src/api/schema.js | 2 + src/components/AssignmentSummary.jsx | 11 +- src/components/AssignmentTexter.jsx | 181 ++++++++++++++---- .../CampaignCannedResponsesForm.jsx | 1 + .../CampaignInteractionStepsForm.jsx | 3 + src/components/CampaignTextersForm.jsx | 10 +- src/components/Empty.jsx | 3 +- src/components/Navigation.jsx | 2 +- src/components/ScriptEditor.jsx | 1 - src/components/SendButton.jsx | 2 + src/containers/AdminCampaignEdit.jsx | 3 +- src/containers/AdminCampaignList.jsx | 2 +- src/containers/AdminCampaignStats.jsx | 3 + src/containers/AdminIncomingMessageList.jsx | 2 + src/containers/AdminReplySender.jsx | 3 + src/containers/AssignmentTexterContact.jsx | 110 ++++------- src/containers/CampaignList.jsx | 45 ++--- src/containers/Settings.jsx | 4 +- src/containers/TexterTodo.jsx | 81 ++++++-- src/containers/TexterTodoList.jsx | 1 + src/containers/UserEdit.jsx | 9 +- src/containers/UserMenu.jsx | 5 + src/lib/attributes.js | 10 +- src/server/api/assignment.js | 71 +++---- src/server/api/campaign-contact.js | 52 ++--- src/server/api/campaign.js | 27 +-- src/server/api/canned-response.js | 4 +- src/server/api/conversations.js | 4 +- src/server/api/interaction-step.js | 6 +- src/server/api/organization.js | 1 + src/server/api/schema.js | 113 ++++++----- src/server/index.js | 3 + src/server/middleware/render-index.js | 1 - src/server/models/cacheable_queries/README.md | 84 ++++++++ .../models/cacheable_queries/assignment.js | 18 ++ src/server/models/cacheable_queries/user.js | 2 +- src/server/models/index.js | 10 +- src/server/models/thinky.js | 3 + src/workers/jobs.js | 40 ++-- 74 files changed, 1633 insertions(+), 620 deletions(-) create mode 100644 __test__/e2e/create_copy_campaign.test.js create mode 100644 __test__/e2e/create_edit_campaign.test.js delete mode 100644 __test__/e2e/page-functions/invite.js create mode 100644 __test__/e2e/page-functions/main.js create mode 100644 __test__/e2e/page-functions/texter.js delete mode 100644 __test__/e2e/page-objects/invite.js create mode 100644 __test__/e2e/page-objects/main.js create mode 100644 __test__/e2e/page-objects/texter.js create mode 100644 __test__/workers/assign-texters.test.js create mode 100644 src/server/models/cacheable_queries/README.md create mode 100644 src/server/models/cacheable_queries/assignment.js diff --git a/.eslintrc b/.eslintrc index e82e96e1a..b5772090d 100644 --- a/.eslintrc +++ b/.eslintrc @@ -11,5 +11,5 @@ "semi": ["error", "never"], "react/require-extension": "off", }, - "env": { "jest": true, "node": true, "browser": true } + "env": { "jest": true, "node": true, "browser": true, "jasmine": true } } diff --git a/__test__/containers/AssignmentTexterContact.test.js b/__test__/containers/AssignmentTexterContact.test.js index 4a8e43bb9..9de46442a 100644 --- a/__test__/containers/AssignmentTexterContact.test.js +++ b/__test__/containers/AssignmentTexterContact.test.js @@ -59,16 +59,6 @@ const propsWithEnforcedTextingHoursCampaign = { }, campaign: campaign, contacts: [ - { - id: 19, - customFields: "{}" - }, - { - id: 20, - customFields: "{}" - } - ], - allContacts: [ { id: 19 }, @@ -76,11 +66,10 @@ const propsWithEnforcedTextingHoursCampaign = { id: 20 } ], + allContactsCount: 2, }, refreshData: jest.fn(), - data: { - loading: false, - contact: { + contact: { id: 19, assignmentId: 9, firstName: "larry", @@ -101,7 +90,6 @@ const propsWithEnforcedTextingHoursCampaign = { }, messageStatus: "needsMessage", messages: [] - } } } @@ -121,7 +109,7 @@ describe('when contact is not within texting hours...', () => { campaign={campaign} assignment={propsWithEnforcedTextingHoursCampaign.assignment} refreshData={propsWithEnforcedTextingHoursCampaign.refreshData} - data={propsWithEnforcedTextingHoursCampaign.data} + contact={propsWithEnforcedTextingHoursCampaign.contact} /> ) @@ -144,7 +132,7 @@ describe('when contact is within texting hours...', () => { campaign={campaign} assignment={propsWithEnforcedTextingHoursCampaign.assignment} refreshData={propsWithEnforcedTextingHoursCampaign.refreshData} - data={propsWithEnforcedTextingHoursCampaign.data} + contact={propsWithEnforcedTextingHoursCampaign.contact} /> ) diff --git a/__test__/e2e/basic_text_manager.test.js b/__test__/e2e/basic_text_manager.test.js index bc859d96d..b26ea79e2 100644 --- a/__test__/e2e/basic_text_manager.test.js +++ b/__test__/e2e/basic_text_manager.test.js @@ -1,47 +1,143 @@ import { selenium } from './util/helpers' import STRINGS from './data/strings' -import { login, invite, campaigns, people } from './page-functions/index' +import { campaigns, login, main, people, texter } from './page-functions/index' -// Instantiate browser(s) -const driver = selenium.buildDriver() -const driverTexter = selenium.buildDriver() +jasmine.getEnv().addReporter(selenium.reporter) + +describe('Basic Text Manager Workflow', () => { + // Instantiate browser(s) + const driverAdmin = selenium.buildDriver({ name: 'Spoke E2E Tests - Chrome - Basic Text Manager Workflow - Admin' }) + const driverTexter = selenium.buildDriver({ name: 'Spoke E2E Tests - Chrome - Basic Text Manager Workflow - Texter' }) -describe('Basic text manager workflow', () => { - const CAMPAIGN = STRINGS.campaigns.existingTexter beforeAll(() => { global.e2e = {} }) + + /** + * Test Suite Sequence: + * Setup Admin and Texter Users + * Create Campaign (No Existing Texter) + * Create Campaign (Existing Texter) + * Create Campaign (No Existing Texter with Opt-Out) + * Create Campaign (Existing Texter with Opt-Out) + */ + afterAll(async () => { - await selenium.quitDriver(driver) + await selenium.quitDriver(driverAdmin) await selenium.quitDriver(driverTexter) }) - describe('(As Admin) Open Landing Page', () => { - login.landing(driver) - }) + describe('Setup Admin User', () => { + describe('(As Admin) Open Landing Page', () => { + login.landing(driverAdmin) + }) + + describe('(As Admin) Log In an admin to Spoke', () => { + login.tryLoginThenSignUp(driverAdmin, STRINGS.users.admin0) + }) - describe('(As Admin) Log In an admin to Spoke', () => { - login.tryLoginThenSignUp(driver, CAMPAIGN.admin) + describe('(As Admin) Create a New Organization / Team', () => { + main.createOrg(driverAdmin, STRINGS.org) + }) }) - describe('(As Admin) Create a New Organization / Team', () => { - invite.createOrg(driver, STRINGS.org) + describe('Create Campaign (No Existing Texter)', () => { + const CAMPAIGN = STRINGS.campaigns.noExistingTexter + + describe('(As Admin) Create a New Campaign', () => { + campaigns.startCampaign(driverAdmin, CAMPAIGN) + }) + + describe('(As Texter) Follow the Invite URL', () => { + texter.viewInvite(driverTexter) + login.tryLoginThenSignUp(driverTexter, CAMPAIGN.texter) + }) + + describe('(As Texter) Verify Todos', () => { + texter.viewSendFirstTexts(driverTexter) + }) + + describe('(As Texter) Log Out', () => { + main.logOutUser(driverTexter) + }) }) - describe('(As Admin) Invite a new User', () => { - people.invite(driver) + describe('Create Campaign (Existing Texter)', () => { + const CAMPAIGN = STRINGS.campaigns.existingTexter + + describe('(As Admin) Invite a new Texter', () => { + people.invite(driverAdmin) + }) + + describe('(As Texter) Follow the Invite URL', () => { + texter.viewInvite(driverTexter) + login.tryLoginThenSignUp(driverTexter, CAMPAIGN.texter) + }) + + describe('(As Admin) Create a New Campaign', () => { + campaigns.startCampaign(driverAdmin, CAMPAIGN) + }) + + describe('(As Texter) Send Texts', () => { + texter.sendTexts(driverTexter, CAMPAIGN) + }) + + describe('(As Admin) Send Replies', () => { + campaigns.sendReplies(driverAdmin, CAMPAIGN) + }) + + describe('(As Texter) View Replies', () => { + texter.viewReplies(driverTexter, CAMPAIGN) + }) + + describe('(As Texter) Opt Out Contact', () => { + texter.optOutContact(driverTexter) + }) + + describe('(As Texter) Log Out', () => { + main.logOutUser(driverTexter) + }) }) - describe('(As Texter) Follow the Invite URL', () => { - describe('should follow the link to the invite', async () => { - it('should follow the link to the invite', async () => { - await driverTexter.get(global.e2e.joinUrl) - }) + describe('Create Campaign (No Existing Texter with Opt-Out)', () => { + const CAMPAIGN = STRINGS.campaigns.noExistingTexterOptOut + + describe('(As Admin) Create a New Campaign', () => { + campaigns.startCampaign(driverAdmin, CAMPAIGN) + }) + + describe('(As Texter) Follow the Invite URL', () => { + texter.viewInvite(driverTexter) login.tryLoginThenSignUp(driverTexter, CAMPAIGN.texter) }) + + describe('(As Texter) Verify Todos', () => { + texter.viewSendFirstTexts(driverTexter) + }) + + describe('(As Texter) Log Out', () => { + main.logOutUser(driverTexter) + }) }) - describe('(As Admin) Create a New Campaign', () => { - campaigns.startCampaign(driver, CAMPAIGN) + describe('Create Campaign (Existing Texters with Opt-Out)', () => { + const CAMPAIGN = STRINGS.campaigns.existingTexterOptOut + + describe('(As Admin) Invite a new Texter', () => { + people.invite(driverAdmin) + }) + + describe('(As Texter) Follow the Invite URL', () => { + texter.viewInvite(driverTexter) + login.tryLoginThenSignUp(driverTexter, CAMPAIGN.texter) + }) + + describe('(As Admin) Create a New Campaign', () => { + campaigns.startCampaign(driverAdmin, CAMPAIGN) + }) + + describe('(As Texter) Verify Todos', () => { + texter.viewSendFirstTexts(driverTexter) + }) }) }) diff --git a/__test__/e2e/create_copy_campaign.test.js b/__test__/e2e/create_copy_campaign.test.js new file mode 100644 index 000000000..d5185e7ce --- /dev/null +++ b/__test__/e2e/create_copy_campaign.test.js @@ -0,0 +1,50 @@ +import { selenium } from './util/helpers' +import STRINGS from './data/strings' +import { campaigns, login, main, people, texter } from './page-functions/index' + +jasmine.getEnv().addReporter(selenium.reporter) + +describe('Create and Copy Campaign', () => { + // Instantiate browser(s) + const driverAdmin = selenium.buildDriver({ name: 'Spoke E2E Tests - Chrome - Create and Copy Campaign - Admin' }) + const driverTexter = selenium.buildDriver({ name: 'Spoke E2E Tests - Chrome - Create and Copy Campaign - Texter' }) + const CAMPAIGN = STRINGS.campaigns.copyCampaign + + beforeAll(() => { + global.e2e = {} + }) + + afterAll(async () => { + await selenium.quitDriver(driverAdmin) + await selenium.quitDriver(driverTexter) + }) + + describe('(As Admin) Open Landing Page', () => { + login.landing(driverAdmin) + }) + + describe('(As Admin) Log In an admin to Spoke', () => { + login.tryLoginThenSignUp(driverAdmin, CAMPAIGN.admin) + }) + + describe('(As Admin) Create a New Organization / Team', () => { + main.createOrg(driverAdmin, STRINGS.org) + }) + + describe('(As Admin) Invite a new User', () => { + people.invite(driverAdmin) + }) + + describe('(As Texter) Follow the Invite URL', () => { + texter.viewInvite(driverTexter) + login.tryLoginThenSignUp(driverTexter, CAMPAIGN.texter) + }) + + describe('(As Admin) Create a New Campaign', () => { + campaigns.startCampaign(driverAdmin, CAMPAIGN) + }) + + describe('(As Admin) Copy Campaign', () => { + campaigns.copyCampaign(driverAdmin, CAMPAIGN) + }) +}) diff --git a/__test__/e2e/create_edit_campaign.test.js b/__test__/e2e/create_edit_campaign.test.js new file mode 100644 index 000000000..508e799bc --- /dev/null +++ b/__test__/e2e/create_edit_campaign.test.js @@ -0,0 +1,39 @@ +import { selenium } from './util/helpers' +import STRINGS from './data/strings' +import { campaigns, login, main } from './page-functions/index' + +jasmine.getEnv().addReporter(selenium.reporter) + +describe('Create and Edit Campaign', () => { + // Instantiate browser(s) + const driver = selenium.buildDriver({ name: 'Spoke E2E Tests - Chrome - Create and Edit Campaign - Admin' }) + const CAMPAIGN = STRINGS.campaigns.editCampaign + + beforeAll(() => { + global.e2e = {} + }) + + afterAll(async () => { + await selenium.quitDriver(driver) + }) + + describe('(As Admin) Open Landing Page', () => { + login.landing(driver) + }) + + describe('(As Admin) Log In an admin to Spoke', () => { + login.tryLoginThenSignUp(driver, CAMPAIGN.admin) + }) + + describe('(As Admin) Create a New Organization / Team', () => { + main.createOrg(driver, STRINGS.org) + }) + + describe('(As Admin) Create a New Campaign', () => { + campaigns.startCampaign(driver, CAMPAIGN) + }) + + describe('(As Admin) Edit Campaign', () => { + campaigns.editCampaign(driver, CAMPAIGN) + }) +}) diff --git a/__test__/e2e/data/strings.js b/__test__/e2e/data/strings.js index 3dce95181..51d29accf 100644 --- a/__test__/e2e/data/strings.js +++ b/__test__/e2e/data/strings.js @@ -1,8 +1,48 @@ import path from 'path' +import _ from 'lodash' + +// Common to all campaigns +const contacts = { + csv: path.resolve(__dirname, './people.csv') +} + +const texters = { + contactLength: 2, + contactLengthAfterOptOut: 1 +} + +const interaction = { + script: 'Test First {firstName} Last {lastName}!', + question: 'Test Question?', + answers: [ + { + answerOption: 'Test Answer 0', + script: 'Test Answer 0 {firstName}.', + questionText: 'Test Child Question 0?' + }, + { + answerOption: 'Test Answer 1', + script: 'Test Answer 1 {lastName}.', + questionText: 'Test Child Question 1?' + } + ] +} + +const cannedResponses = [ + { + title: 'Test CR0', + script: 'Test CR First {firstName} Last {lastName}.' + } +] + +const standardReply = 'Test Reply' const org = 'SpokeTestOrg' const users = { + /** + * Note: Changing passwords for existing Auth0 users requires the user be removed from Auth0 + */ admin0: { name: 'admin0', email: 'spokeadmin0@moveon.org', @@ -14,10 +54,14 @@ const users = { admin1: { name: 'admin1', email: 'spokeadmin1@moveon.org', + email_changed: 'spokeadmin1b@moveon.org', password: 'SpokeAdmin1!', given_name: 'Adminonefirst', + given_name_changed: 'Adminonefirstb', family_name: 'Adminonelast', - cell: '4145550001' + family_name_changed: 'Adminonelastb', + cell: '4145550001', + cell_changed: '6085550001' }, texter0: { name: 'texter0', @@ -26,36 +70,137 @@ const users = { given_name: 'Texterzerofirst', family_name: 'Texterzerolast', cell: '4146660000' + }, + texter1: { + name: 'texter1', + email: 'spoketexter1@moveon.org', + email_changed: 'spoketexter1b@moveon.org', + password: 'SpokeTexter1!', + given_name: 'Texteronefirst', + given_name_changed: 'Texteronefirstb', + family_name: 'Texteronelast', + family_name_changed: 'Texteronelastb', + cell: '4146660001', + cell_changed: '6086660001' + }, + texter2: { + name: 'texter2', + email: 'spoketexter2@moveon.org', + password: 'SpokeTexter2!', + given_name: 'Textertwofirst', + family_name: 'Textertwolast', + cell: '4146660002' + }, + texter3: { + name: 'texter3', + email: 'spoketexter3@moveon.org', + password: 'SpokeTexter3!', + given_name: 'Texterthreefirst', + family_name: 'Texterthreelast', + cell: '4146660003' } } const campaigns = { + noExistingTexter: { + name: 'noExistingTexter', + optOut: false, + admin: users.admin0, + texter: users.texter0, + existingTexter: false, + basics: { + title: 'Test NET Campaign Title', + description: 'Test NET Campaign Description' + }, + contacts, + texters, + interaction, + cannedResponses, + standardReply + }, existingTexter: { name: 'existingTexter', + optOut: false, admin: users.admin0, - texter: users.texter0, + texter: users.texter1, existingTexter: true, - dynamicAssignment: false, basics: { - title: 'Test Campaign Title', - description: 'Test Campaign Description' + title: 'Test ET Campaign Title', + description: 'Test ET Campaign Description' + }, + contacts, + texters, + interaction, + cannedResponses, + standardReply + }, + noExistingTexterOptOut: { + name: 'noExistingTexterOptOut', + optOut: true, + admin: users.admin0, + texter: users.texter2, + existingTexter: false, + basics: { + title: 'Test NETOO Campaign Title', + description: 'Test NETOO Campaign Description' }, - contacts: { - csv: path.resolve(__dirname, './people.csv') + contacts, + texters: _.assign({}, texters, { contactLength: texters.contactLengthAfterOptOut }), + interaction, + cannedResponses, + standardReply + }, + existingTexterOptOut: { + name: 'existingTexterOptOut', + optOut: true, + admin: users.admin0, + texter: users.texter3, + existingTexter: true, + basics: { + title: 'Test ETOO Campaign Title', + description: 'Test ETOO Campaign Description' }, - texters: { - contactLength: 2 + contacts, + texters: _.assign({}, texters, { contactLength: texters.contactLengthAfterOptOut }), + interaction, + cannedResponses, + standardReply + }, + copyCampaign: { + name: 'copyCampaign', + admin: users.admin0, + texter: users.texter0, + existingTexter: true, + dynamicAssignment: false, + basics: { + title: 'Test C Campaign Title', + title_copied: 'COPY - Test C Campaign Title', + description: 'Test C Campaign Description' }, - interaction: { - script: 'Test First {firstName} Last {lastName}!', - question: 'Test Question?' + contacts, + texters, + interaction, + cannedResponses + }, + editCampaign: { + name: 'editCampaign', + admin: users.admin1, + existingTexter: false, + dynamicAssignment: true, + basics: { + title: 'Test E Campaign Title', + title_changed: 'Test E Campaign Title Changed', + description: 'Test E Campaign Description' }, - cannedResponses: [ - { - title: 'Test CR0', - script: 'Test CR First {firstName} Last {lastName}.' - } - ] + contacts, + texters, + interaction, + cannedResponses + }, + userManagement: { + name: 'userManagement', + admin: users.admin1, + texter: users.texter1 } } diff --git a/__test__/e2e/invite_texter.test.js b/__test__/e2e/invite_texter.test.js index 56ce638bd..3e53a9c8a 100644 --- a/__test__/e2e/invite_texter.test.js +++ b/__test__/e2e/invite_texter.test.js @@ -1,43 +1,50 @@ import { selenium } from './util/helpers' import STRINGS from './data/strings' -import { login, invite, people } from './page-functions/index' +import { login, main, people, texter } from './page-functions/index' -// Instantiate browser(s) -const driver = selenium.buildDriver() -const driverTexter = selenium.buildDriver() +jasmine.getEnv().addReporter(selenium.reporter) describe('Invite Texter workflow', () => { - const CAMPAIGN = STRINGS.campaigns.existingTexter + // Instantiate browser(s) + const driverAdmin = selenium.buildDriver({ name: 'Spoke E2E Tests - Chrome - Invite Texter workflow - Admin' }) + const driverTexter = selenium.buildDriver({ name: 'Spoke E2E Tests - Chrome - Invite Texter workflow - Texter' }) + const CAMPAIGN = STRINGS.campaigns.userManagement + beforeAll(() => { global.e2e = {} }) + afterAll(async () => { - await selenium.quitDriver(driver) + await selenium.quitDriver(driverAdmin) await selenium.quitDriver(driverTexter) }) describe('(As Admin) Open Landing Page', () => { - login.landing(driver) + login.landing(driverAdmin) }) describe('(As Admin) Log In an admin to Spoke', () => { - login.tryLoginThenSignUp(driver, CAMPAIGN.admin) + login.tryLoginThenSignUp(driverAdmin, CAMPAIGN.admin) }) describe('(As Admin) Create a New Organization / Team', () => { - invite.createOrg(driver, STRINGS.org) + main.createOrg(driverAdmin, STRINGS.org) }) describe('(As Admin) Invite a new User', () => { - people.invite(driver) + people.invite(driverAdmin) }) describe('(As Texter) Follow the Invite URL', () => { - describe('should follow the link to the invite', async () => { - it('should follow the link to the invite', async () => { - await driverTexter.get(global.e2e.joinUrl) - }) - login.tryLoginThenSignUp(driverTexter, CAMPAIGN.texter) - }) + texter.viewInvite(driverTexter) + login.tryLoginThenSignUp(driverTexter, CAMPAIGN.texter) + }) + + describe('(As Admin) Edit User', () => { + people.editUser(driverAdmin, CAMPAIGN.admin) + }) + + describe('(As Texter) Edit User', () => { + main.editUser(driverTexter, CAMPAIGN.texter) }) }) diff --git a/__test__/e2e/page-functions/campaigns.js b/__test__/e2e/page-functions/campaigns.js index a1976279f..3d89249c1 100644 --- a/__test__/e2e/page-functions/campaigns.js +++ b/__test__/e2e/page-functions/campaigns.js @@ -5,113 +5,155 @@ * Similarly, a sleep is added because it's difficult to know when the picker dialog is gone. */ -import { wait } from '../util/helpers' +import _ from 'lodash' +import { wait, urlBuilder } from '../util/helpers' import pom from '../page-objects/index' +// For legibility +const form = pom.campaigns.form + export const campaigns = { startCampaign(driver, campaign) { it('opens the Campaigns tab', async () => { + await driver.get(urlBuilder.admin.root()) await wait.andClick(driver, pom.navigation.sections.campaigns) }) it('clicks the + button to add a new campaign', async () => { - await wait.andClick(driver, pom.campaigns.add) + await wait.andClick(driver, pom.campaigns.add, { goesStale: true }) }) it('completes the Basics section', async () => { // Title - await wait.andClick(driver, pom.campaigns.form.basics.title) - await wait.andType(driver, pom.campaigns.form.basics.title, campaign.basics.title) + await wait.andType(driver, form.basics.title, campaign.basics.title) // Description - await wait.andType(driver, pom.campaigns.form.basics.description, campaign.basics.description) + await wait.andType(driver, form.basics.description, campaign.basics.description) // Select a Due Date using the Date Picker - await wait.andClick(driver, pom.campaigns.form.basics.dueBy) - await wait.andClick(driver, pom.campaigns.form.datePickerDialog.nextMonth) - await driver.sleep(1000) - await wait.andClick(driver, pom.campaigns.form.datePickerDialog.enabledDate) - await driver.sleep(3000) + await wait.andClick(driver, form.basics.dueBy) + await wait.andClick(driver, form.datePickerDialog.nextMonth, { waitAfterVisible: 2000 }) + await wait.andClick(driver, form.datePickerDialog.enabledDate, { waitAfterVisible: 2000, goesStale: true }) // Save - await wait.andClick(driver, pom.campaigns.form.save) + await wait.andClick(driver, form.save, { waitAfterVisible: 2000 }) // This should switch to the Contacts section - expect(await wait.andGetEl(driver, pom.campaigns.form.contacts.uploadButton)).toBeDefined() + expect(await wait.andGetEl(driver, form.contacts.uploadButton)).toBeDefined() + expect(await wait.andGetEl(driver, form.contacts.input, { elementIsVisible: false })).toBeDefined() }) it('completes the Contacts section', async () => { - await wait.andType(driver, pom.campaigns.form.contacts.input, campaign.contacts.csv, { clear: false, elementIsVisible: false }) - expect(await wait.andGetEl(driver, pom.campaigns.form.contacts.uploadedContacts)).toBeDefined() + await wait.andType(driver, form.contacts.input, campaign.contacts.csv, { clear: false, click: false, elementIsVisible: false }) + expect(await wait.andGetEl(driver, form.contacts.uploadedContacts)).toBeDefined() // Save - await wait.andClick(driver, pom.campaigns.form.save) + await wait.andClick(driver, form.save, { waitAfterVisible: 2000 }) + // Reload the Contacts section to validate Contacts + await wait.andClick(driver, form.contacts.section, { waitAfterVisible: 2000 }) + expect(await wait.andGetEl(driver, form.contacts.uploadedContacts)).toBeDefined() + expect(await wait.andGetEl(driver, form.contacts.uploadedContactsByQty(campaign.texters.contactLength))).toBeDefined() + await wait.andClick(driver, form.texters.section, { waitAfterVisible: 2000 }) // This should switch to the Texters section - expect(await wait.andGetEl(driver, pom.campaigns.form.texters.addAll)).toBeDefined() + expect(await wait.andGetEl(driver, form.texters.addAll)).toBeDefined() }) - it('completes the Texters section (Dynamic assignment)', async () => { + it('completes the Texters section', async () => { if (campaign.existingTexter) { // Add All - await wait.andClick(driver, pom.campaigns.form.texters.addAll) + await wait.andClick(driver, form.texters.addAll) // Assign (Split) - await wait.andClick(driver, pom.campaigns.form.texters.autoSplit, { elementIsVisible: false }) + await wait.andClick(driver, form.texters.autoSplit, { elementIsVisible: false }) // Validate Assignment - expect(await wait.andGetValue(driver, pom.campaigns.form.texters.texterAssignmentByIndex(0)) > 0).toBeTruthy() - expect(await wait.andGetValue(driver, pom.campaigns.form.texters.texterAssignmentByIndex(1)) > 0).toBeTruthy() + const assignedToFirstTexter = await wait.andGetValue(driver, form.texters.texterAssignmentByIndex(0)) + expect(Number(assignedToFirstTexter)).toBeGreaterThan(0) // Assign (All to Texter) - await wait.andClick(driver, pom.campaigns.form.texters.autoSplit, { elementIsVisible: false }) - await wait.andType(driver, pom.campaigns.form.texters.texterAssignmentByIndex(0), '0') - await wait.andType(driver, pom.campaigns.form.texters.texterAssignmentByIndex(1), campaign.texters.contactLength) + await wait.andClick(driver, form.texters.autoSplit, { elementIsVisible: false }) + await wait.andType(driver, form.texters.texterAssignmentByText(campaign.admin.given_name), '0') + await driver.sleep(1000) + await wait.andType(driver, form.texters.texterAssignmentByText(campaign.texter.given_name), campaign.texters.contactLength) // Validate Assignment - expect(await wait.andGetValue(driver, pom.campaigns.form.texters.texterAssignmentByIndex(0))).toBe('0') - } - if (campaign.dynamicAssignment) { + expect(await wait.andGetValue(driver, form.texters.texterAssignmentByText(campaign.admin.given_name))).toBe('0') + } else { // Dynamically Assign - await wait.andClick(driver, pom.campaigns.form.texters.useDynamicAssignment, { elementIsVisible: false }) + await wait.andClick(driver, form.texters.useDynamicAssignment, { elementIsVisible: false, waitAfterVisible: 2000 }) // Store the invite (join) URL into a global for future use. - global.e2e.joinUrl = await wait.andGetValue(driver, pom.campaigns.form.texters.joinUrl) + global.e2e.joinUrl = await wait.andGetValue(driver, form.texters.joinUrl) } // Save - await wait.andClick(driver, pom.campaigns.form.save) + await wait.andClick(driver, form.save) // This should switch to the Interactions section - expect(await wait.andGetEl(driver, pom.campaigns.form.interactions.editorLaunch)).toBeDefined() + expect(await wait.andGetEl(driver, form.interactions.editorLaunch)).toBeDefined() }) - it('completes the Interactions section', async () => { - // Script - await wait.andClick(driver, pom.campaigns.form.interactions.editorLaunch) - await wait.andClick(driver, pom.scriptEditor.editor) - await wait.andType(driver, pom.scriptEditor.editor, campaign.interaction.script, { clear: false }) - await wait.andClick(driver, pom.scriptEditor.done) - // Question - await wait.andType(driver, pom.campaigns.form.interactions.questionText, campaign.interaction.question) - // Save - await wait.andClick(driver, pom.campaigns.form.interactions.submit) - // This should switch to the Canned Responses section - expect(await wait.andGetEl(driver, pom.campaigns.form.cannedResponse.addNew)).toBeDefined() + describe('completes the Interactions section', () => { + it('adds an initial question', async () => { + // Script + await wait.andClick(driver, form.interactions.editorLaunch) + await wait.andType(driver, pom.scriptEditor.editor, campaign.interaction.script, { clear: false, click: false, waitAfterVisible: 2000 }) + await wait.andClick(driver, pom.scriptEditor.done, { goesStale: true }) + // Question + await wait.andType(driver, form.interactions.questionText, campaign.interaction.question, { waitAfterVisible: 2000 }) + // Save with No Answers Defined + await wait.andClick(driver, form.interactions.submit) + await wait.andClick(driver, form.interactions.section, { waitAfterVisible: 2000 }) + let allChildInteractions = await driver.findElements(form.interactions.childInteraction) + expect(allChildInteractions.length).toBe(0) + // Save with Empty Answer + await wait.andClick(driver, form.interactions.addResponse) + await wait.andClick(driver, form.interactions.submit) + await wait.andClick(driver, form.interactions.section, { waitAfterVisible: 2000 }) + allChildInteractions = await driver.findElements(form.interactions.childInteraction) + expect(allChildInteractions.length).toBe(1) + }) + + describe('Add all Responses', () => { + _.each(campaign.interaction.answers, (answer, index) => { + it(`Adds Answer ${index}`, async () => { + if (index > 0) await wait.andClick(driver, form.interactions.addResponse) // The first (0th) response reuses the empty Answer created above + // Answer + await wait.andType(driver, form.interactions.answerOptionChildByIndex(index), answer.answerOption, { clear: false, waitAfterVisible: 2000 }) + // Answer Script + await wait.andClick(driver, form.interactions.editorLaunchChildByIndex(index)) + await wait.andType(driver, pom.scriptEditor.editor, answer.script, { clear: false, click: false, waitAfterVisible: 2000 }) + await wait.andClick(driver, pom.scriptEditor.done, { goesStale: true }) + // Answer - Next Question + await wait.andType(driver, form.interactions.questionTextChildByIndex(index), answer.questionText, { clear: false, waitAfterVisible: 2000 }) + }) + }) + it('validates that all responses were added', async () => { + const allChildInteractions = await driver.findElements(form.interactions.childInteraction) + expect(allChildInteractions.length).toBe(campaign.interaction.answers.length) + }) + }) + + it('saves for the last time', async () => { + // Save + await wait.andClick(driver, form.interactions.submit) + // This should switch to the Canned Responses section + expect(await wait.andGetEl(driver, form.cannedResponse.addNew)).toBeDefined() + }) }) it('completes the Canned Responses section', async () => { // Add New - await wait.andClick(driver, pom.campaigns.form.cannedResponse.addNew) + await wait.andClick(driver, form.cannedResponse.addNew) // Title - await wait.andType(driver, pom.campaigns.form.cannedResponse.title, campaign.cannedResponses[0].title) + await wait.andType(driver, form.cannedResponse.title, campaign.cannedResponses[0].title) // Script - await wait.andClick(driver, pom.campaigns.form.cannedResponse.editorLaunch) - await wait.andClick(driver, pom.scriptEditor.editor) - await wait.andType(driver, pom.scriptEditor.editor, campaign.interaction.script, { clear: false }) - await wait.andClick(driver, pom.scriptEditor.done) + await wait.andClick(driver, form.cannedResponse.editorLaunch) + await wait.andType(driver, pom.scriptEditor.editor, campaign.cannedResponses[0].script, { clear: false, click: false, waitAfterVisible: 2000 }) + await wait.andClick(driver, pom.scriptEditor.done, { goesStale: true }) // Script - Relaunch and cancel (bug?) - await driver.sleep(3000) // Transition - await wait.andClick(driver, pom.campaigns.form.cannedResponse.editorLaunch) - await wait.andClick(driver, pom.scriptEditor.cancel) - await driver.sleep(3000) // Transition + await wait.andClick(driver, form.cannedResponse.editorLaunch, { waitAfterVisible: 2000 }) + await wait.andClick(driver, pom.scriptEditor.cancel, { waitAfterVisible: 2000, goesStale: true }) // Submit Response - await wait.andClick(driver, pom.campaigns.form.cannedResponse.submit) + await wait.andClick(driver, form.cannedResponse.submit, { waitAfterVisible: 2000, goesStale: true }) // Save - await wait.andClick(driver, pom.campaigns.form.save) + await wait.andClick(driver, form.save, { waitAfterVisible: 2000, goesStale: true }) // Should be able to start campaign expect(await wait.andIsEnabled(driver, pom.campaigns.start)).toBeTruthy() }) it('clicks Start Campaign', async () => { - await wait.andClick(driver, pom.campaigns.start) + // Store the new campaign URL into a global for future use. + global.e2e.newCampaignUrl = await driver.getCurrentUrl() + await wait.andClick(driver, pom.campaigns.start, { waitAfterVisible: 2000, goesStale: true }) // Validate Started expect(await wait.andGetEl(driver, pom.campaigns.isStarted)).toBeTruthy() }) diff --git a/__test__/e2e/page-functions/index.js b/__test__/e2e/page-functions/index.js index 51e82a4eb..907884d59 100644 --- a/__test__/e2e/page-functions/index.js +++ b/__test__/e2e/page-functions/index.js @@ -1,4 +1,5 @@ export * from './campaigns' -export * from './invite' export * from './login' +export * from './main' export * from './people' +export * from './texter' diff --git a/__test__/e2e/page-functions/invite.js b/__test__/e2e/page-functions/invite.js deleted file mode 100644 index ae8849b72..000000000 --- a/__test__/e2e/page-functions/invite.js +++ /dev/null @@ -1,14 +0,0 @@ -import { wait } from '../util/helpers' -import pom from '../page-objects/index' - -export const invite = { - createOrg(driver, name) { - it('fills in the organization name', async () => { - await wait.andType(driver, pom.invite.organization.name, name) - }) - - it('clicks the submit button', async () => { - await wait.andClick(driver, pom.invite.organization.submit) - }) - } -} diff --git a/__test__/e2e/page-functions/login.js b/__test__/e2e/page-functions/login.js index 767a0cfdf..4875b759c 100644 --- a/__test__/e2e/page-functions/login.js +++ b/__test__/e2e/page-functions/login.js @@ -1,6 +1,6 @@ import { until } from 'selenium-webdriver' import config from '../util/config' -import { wait } from '../util/helpers' +import { wait, urlBuilder } from '../util/helpers' import pom from '../page-objects/index' // For legibility @@ -14,15 +14,10 @@ export const login = { it('clicks the login link', async () => { // Click on the login button - wait.untilLocated(driver, pom.login.loginGetStarted, { msWait: 30000 }) - await driver.sleep(2000) // Transition - wait.andClick(driver, pom.login.loginGetStarted) + wait.andClick(driver, pom.login.loginGetStarted, { msWait: 50000, waitAfterVisible: 2000 }) // Wait until the Auth0 login page loads - const loginUrl = `${config.baseUrl}/login` - await driver.wait(until.urlContains(loginUrl)) - const url = await driver.getCurrentUrl() - expect(url).toContain(loginUrl) + await driver.wait(until.urlContains(urlBuilder.login)) }) }, signUpTab(driver, user) { @@ -46,25 +41,15 @@ export const login = { }) it('accepts the user agreement', async () => { - if (!skip) { - const el = await driver.findElement(auth0.form.agreement) - await el.click() - } + if (!skip) await wait.andClick(driver, auth0.form.agreement) }) it('clicks the submit button', async () => { - if (!skip) { - const el = await driver.findElement(auth0.form.submit) - await el.click() - } + if (!skip) await wait.andClick(driver, auth0.form.submit) }) it('authorizes Auth0 to access tenant', async () => { - if (!skip) { - const el = await driver.wait(until.elementLocated(auth0.authorize.allow)) - await driver.wait(until.elementIsVisible(el)) - await el.click() - } + if (!skip) await wait.andClick(driver, auth0.authorize.allow) }) }, signUp(driver, user) { @@ -73,7 +58,7 @@ export const login = { }, logIn(driver, user) { it('opens the Log In tab', async () => { - await wait.andClick(driver, auth0.tabs.logIn, { msWait: 20000 }) + await wait.andClick(driver, auth0.tabs.logIn, { msWait: 50000 }) }) it('fills in the existing user details', async () => { @@ -82,7 +67,7 @@ export const login = { }) it('clicks the submit button', async () => { - await wait.andClick(driver, auth0.form.submit) + await wait.andClick(driver, auth0.form.submit, { waitAfterVisible: 1000 }) }) }, tryLoginThenSignUp(driver, user) { diff --git a/__test__/e2e/page-functions/main.js b/__test__/e2e/page-functions/main.js new file mode 100644 index 000000000..e0fdae627 --- /dev/null +++ b/__test__/e2e/page-functions/main.js @@ -0,0 +1,70 @@ +import { until } from 'selenium-webdriver' +import { wait } from '../util/helpers' +import config from '../util/config' +import pom from '../page-objects/index' + +export const main = { + createOrg(driver, name) { + it('fills in the organization name', async () => { + await wait.andType(driver, pom.main.organization.name, name) + }) + + it('clicks the submit button', async () => { + await wait.andClick(driver, pom.main.organization.submit) + await driver.wait(until.urlContains('admin')) + const url = await driver.getCurrentUrl() + const re = /\/admin\/(\d+)\//g + global.e2e.organization = await re.exec(url)[1] + }) + }, + editUser(driver, user) { + it('opens the User menu', async () => { + await wait.andClick(driver, pom.main.userMenuButton) + }) + + it('click on the user name', async () => { + await wait.andClick(driver, pom.main.userMenuDisplayName) + }) + + it('changes user details', async () => { + await wait.andType(driver, pom.people.edit.firstName, user.given_name_changed, { clear: false }) + await wait.andType(driver, pom.people.edit.lastName, user.family_name_changed, { clear: false }) + await wait.andType(driver, pom.people.edit.email, user.email_changed, { clear: false }) + await wait.andType(driver, pom.people.edit.cell, user.cell_changed, { clear: false }) + // Save + await wait.andClick(driver, pom.people.edit.save) + // Verify edits + expect(await wait.andGetValue(driver, pom.people.edit.firstName)).toBe(user.given_name_changed) + expect(await wait.andGetValue(driver, pom.people.edit.lastName)).toBe(user.family_name_changed) + expect(await wait.andGetValue(driver, pom.people.edit.email)).toBe(user.email_changed) + }) + + it('reverts user details back to original settings', async () => { + await wait.andType(driver, pom.people.edit.firstName, user.given_name, { clear: false }) + await wait.andType(driver, pom.people.edit.lastName, user.family_name, { clear: false }) + await wait.andType(driver, pom.people.edit.email, user.email, { clear: false }) + await wait.andType(driver, pom.people.edit.cell, user.cell, { clear: false }) + // Save + await wait.andClick(driver, pom.people.edit.save) + // Verify edits + expect(await wait.andGetValue(driver, pom.people.edit.firstName)).toBe(user.given_name) + expect(await wait.andGetValue(driver, pom.people.edit.lastName)).toBe(user.family_name) + expect(await wait.andGetValue(driver, pom.people.edit.email)).toBe(user.email) + }) + }, + logOutUser(driver) { + it('gets the landing page', async () => { + await driver.get(config.baseUrl) + }) + + it('opens the User menu', async () => { + await wait.andClick(driver, pom.main.userMenuButton) + }) + + it('clicks on log out', async () => { + await wait.andClick(driver, pom.main.logOut, { waitAfterVisible: 3000 }) + const re = /http[s]*:\/\/[^\/]+[\/]*$/g + await driver.wait(until.urlMatches(re)) + }) + } +} diff --git a/__test__/e2e/page-functions/people.js b/__test__/e2e/page-functions/people.js index 4f991991e..f58b93c1e 100644 --- a/__test__/e2e/page-functions/people.js +++ b/__test__/e2e/page-functions/people.js @@ -1,9 +1,10 @@ -import { wait } from '../util/helpers' +import { wait, urlBuilder } from '../util/helpers' import pom from '../page-objects/index' export const people = { invite(driver) { it('opens the People tab', async () => { + await driver.get(urlBuilder.admin.root()) await wait.andClick(driver, pom.navigation.sections.people) }) @@ -17,5 +18,41 @@ export const people = { // OK await wait.andClick(driver, pom.people.invite.ok) }) + }, + editUser(driver, user) { + it('opens the People tab', async () => { + await driver.get(urlBuilder.admin.root()) + await wait.andClick(driver, pom.navigation.sections.people) + }) + + it('clicks on the Edit button next to name', async () => { + await wait.andClick(driver, pom.people.editButtonByName(user.given_name), { waitAfterVisible: 2000 }) + }) + + it('changes user details', async () => { + await wait.andType(driver, pom.people.edit.firstName, user.given_name_changed, { clear: false, waitAfterVisible: 2000 }) + await wait.andType(driver, pom.people.edit.lastName, user.family_name_changed, { clear: false }) + await wait.andType(driver, pom.people.edit.email, user.email_changed, { clear: false }) + await wait.andType(driver, pom.people.edit.cell, user.cell_changed, { clear: false }) + // Save + await wait.andClick(driver, pom.people.edit.save) + // Verify edits + expect(await wait.andGetEl(driver, pom.people.getRowByName(user.given_name_changed))).toBeDefined() + }) + + it('clicks on the Edit button next to name', async () => { + await wait.andClick(driver, pom.people.editButtonByName(user.given_name), { waitAfterVisible: 2000 }) + }) + + it('reverts user details back to original settings', async () => { + await wait.andType(driver, pom.people.edit.firstName, user.given_name, { clear: false, waitAfterVisible: 2000 }) + await wait.andType(driver, pom.people.edit.lastName, user.family_name, { clear: false }) + await wait.andType(driver, pom.people.edit.email, user.email, { clear: false }) + await wait.andType(driver, pom.people.edit.cell, user.cell, { clear: false }) + // Save + await wait.andClick(driver, pom.people.edit.save) + // Verify edits + expect(await wait.andGetEl(driver, pom.people.getRowByName(user.given_name))).toBeDefined() + }) } } diff --git a/__test__/e2e/page-functions/texter.js b/__test__/e2e/page-functions/texter.js new file mode 100644 index 000000000..dd60c9f9e --- /dev/null +++ b/__test__/e2e/page-functions/texter.js @@ -0,0 +1,52 @@ +import _ from 'lodash' +import { wait, urlBuilder } from '../util/helpers' +import pom from '../page-objects/index' + +export const texter = { + sendTexts(driver, campaign) { + it('refreshes Dashboard', async () => { + await driver.get(urlBuilder.app.todos()) + await wait.andClick(driver, pom.texter.sendFirstTexts) + }) + describe('works though the list of assigned contacts', () => { + _.times(campaign.texters.contactLength, n => { + it(`sends text ${n}`, async () => { + await wait.andClick(driver, pom.texter.send) + }) + }) + it('should have an empty todo list', async () => { + await driver.get(urlBuilder.app.todos()) + expect(await wait.andGetEl(driver, pom.texter.emptyTodo)).toBeDefined() + }) + }) + }, + optOutContact(driver) { + it('clicks the Opt Out button', async () => { + await wait.andClick(driver, pom.texter.optOut.button) + }) + it('clicks Send', async () => { + await wait.andClick(driver, pom.texter.optOut.send) + await driver.sleep(3000) + }) + }, + viewInvite(driver) { + it('follows the link to the invite', async () => { + await driver.get(global.e2e.joinUrl) + }) + }, + viewReplies(driver, campaign) { + it('refreshes Dashboard', async () => { + await driver.get(urlBuilder.app.todos()) + await wait.andClick(driver, pom.texter.sendReplies) + }) + it('verifies reply', async () => { + expect(await wait.andGetEl(driver, pom.texter.replyByText(campaign.standardReply))).toBeDefined() + }) + }, + viewSendFirstTexts(driver) { + it('verifies that Send First Texts button is present', async () => { + await driver.get(urlBuilder.app.todos()) + expect(await wait.andGetEl(driver, pom.texter.sendFirstTexts)).toBeDefined() + }) + } +} diff --git a/__test__/e2e/page-objects/campaigns.js b/__test__/e2e/page-objects/campaigns.js index 2a7ba2097..d06264500 100644 --- a/__test__/e2e/page-objects/campaigns.js +++ b/__test__/e2e/page-objects/campaigns.js @@ -9,6 +9,7 @@ export const campaigns = { sendByIndex(index) { return By.xpath(`(//button[@data-test='send'])[${index + 1}]`) }, form: { basics: { + section: By.css('[data-test=basics]'), title: By.css('[data-test=title]'), description: By.css('[data-test=description]'), dueBy: By.css('[data-test=dueBy]') @@ -19,30 +20,45 @@ export const campaigns = { enabledDate: By.css('body > div:nth-child(5) > div > div:nth-child(1) > div > div > div > div > div:nth-child(2) > div:nth-child(1) > div:nth-child(3) > div > div button[tabindex="0"]') }, contacts: { + section: By.css('[data-test=contacts]'), uploadButton: By.css('[data-test=uploadButton]'), input: By.css('#contact-upload'), - uploadedContacts: By.css('[data-test=uploadedContacts]') + uploadedContacts: By.css('[data-test=uploadedContacts]'), + uploadedContactsByQty(n) { return By.xpath(`//*[@data-test='uploadedContacts']/descendant::*[contains(text(),'${n} contact')]`) } }, texters: { + section: By.css('[data-test=texters]'), useDynamicAssignment: By.css('[data-test=useDynamicAssignment]'), joinUrl: By.css('[data-test=joinUrl]'), addAll: By.css('[data-test=addAll]'), autoSplit: By.css('[data-test=autoSplit]'), - texterAssignmentByIndex(index) { return By.css(`[data-test=texter${index}Assignment]`) }, - texterNameByIndex(index) { return By.css(`[data-test=texter${index}Name]`) } + texterAssignmentByText(text) { return By.xpath(`//*[@data-test='texterName' and contains(text(),'${text}')]/ancestor::*[@data-test='texterRow']/descendant::input[@data-test='texterAssignment']`) }, + texterAssignmentByIndex(index) { return By.xpath(`(//*[@data-test='texterRow'])[${index + 1}]/descendant::input[@data-test='texterAssignment']`) } }, interactions: { + section: By.css('[data-test=interactions]'), questionText: By.css('[data-test=questionText]'), + addResponse: By.css('[data-test=addResponse]:nth-child(1)'), + childInteraction: By.css('[data-test=childInteraction]'), + questionTextChildByIndex(index) { return By.xpath(`(//*[@data-test='childInteraction']/descendant::*[@data-test='questionText'])[${index + 1}]`) }, editorLaunch: By.css('[data-test=editorInteraction]'), + editorLaunchChildByIndex(index) { return By.xpath(`(//*[@data-test='childInteraction']/descendant::*[@data-test='editorInteraction'])[${index + 1}]`) }, + answerOptionChildByIndex(index) { return By.xpath(`(//*[@data-test='childInteraction']/descendant::*[@data-test='answerOption'])[${index + 1}]`) }, submit: By.css('[data-test=interactionSubmit]') }, cannedResponse: { + section: By.css('[data-test=cannedResponses]'), addNew: By.css('[data-test=newCannedResponse]'), title: By.css('[data-test=title]'), editorLaunch: By.css('[data-test=editorResponse]'), + createdResponseByText(text) { return By.xpath(`//span[@data-test='cannedResponse']/descendant::*[contains(text(),'${text}')]`) }, submit: By.css('[data-test=addResponse]') }, - save: By.css('[type=submit]') + save: By.css('[type=submit]:not([disabled])') + }, + stats: { + copy: By.css('[data-test=copyCampaign]'), + edit: By.css('[data-test=editCampaign]') }, isStarted: By.css('[data-test=campaignIsStarted]') } diff --git a/__test__/e2e/page-objects/index.js b/__test__/e2e/page-objects/index.js index 0b6fc0111..5e1ea4b35 100644 --- a/__test__/e2e/page-objects/index.js +++ b/__test__/e2e/page-objects/index.js @@ -1,15 +1,17 @@ import { campaigns } from './campaigns' -import { invite } from './invite' +import { main } from './main' import { login } from './login' import { navigation } from './navigation' import { people } from './people' import { scriptEditor } from './scriptEditor' +import { texter } from './texter' export default { campaigns, - invite, login, + main, navigation, people, - scriptEditor + scriptEditor, + texter } diff --git a/__test__/e2e/page-objects/invite.js b/__test__/e2e/page-objects/invite.js deleted file mode 100644 index 826bb6a33..000000000 --- a/__test__/e2e/page-objects/invite.js +++ /dev/null @@ -1,8 +0,0 @@ -import { By } from 'selenium-webdriver' - -export const invite = { - organization: { - name: By.css('[data-test=organization]'), - submit: By.css('button[name="submit"]') - } -} diff --git a/__test__/e2e/page-objects/main.js b/__test__/e2e/page-objects/main.js new file mode 100644 index 000000000..a6072aaa8 --- /dev/null +++ b/__test__/e2e/page-objects/main.js @@ -0,0 +1,20 @@ +import { By } from 'selenium-webdriver' + +export const main = { + organization: { + name: By.css('[data-test=organization]'), + submit: By.css('button[name="submit"]') + }, + userMenuButton: By.css('[data-test=userMenuButton]'), + userMenuDisplayName: By.css('[data-test=userMenuDisplayName]'), + edit: { + editButton: By.css('[data-test=editPerson]'), + firstName: By.css('[data-test=firstName]'), + lastName: By.css('[data-test=lastName]'), + email: By.css('[data-test=email]'), + cell: By.css('[data-test=cell]'), + save: By.css('[type=submit]') + }, + home: By.css('[data-test=home]'), + logOut: By.css('[data-test=userMenuLogOut]') +} diff --git a/__test__/e2e/page-objects/people.js b/__test__/e2e/page-objects/people.js index c73365468..818f3f74a 100644 --- a/__test__/e2e/page-objects/people.js +++ b/__test__/e2e/page-objects/people.js @@ -5,5 +5,15 @@ export const people = { invite: { joinUrl: By.css('[data-test=joinUrl]'), ok: By.css('[data-test=inviteOk]') + }, + getRowByName(name) { return By.xpath(`//td[contains(text(),'${name}')]/ancestor::tr`) }, + editButtonByName(name) { return By.xpath(`//td[contains(text(),'${name}')]/ancestor::tr/descendant::button[@data-test='editPerson']`) }, + edit: { + editButton: By.css('[data-test=editPerson]'), + firstName: By.css('[data-test=firstName]'), + lastName: By.css('[data-test=lastName]'), + email: By.css('[data-test=email]'), + cell: By.css('[data-test=cell]'), + save: By.css('[type=submit]') } } diff --git a/__test__/e2e/page-objects/scriptEditor.js b/__test__/e2e/page-objects/scriptEditor.js index ae7e322d3..46268c378 100644 --- a/__test__/e2e/page-objects/scriptEditor.js +++ b/__test__/e2e/page-objects/scriptEditor.js @@ -1,7 +1,7 @@ import { By } from 'selenium-webdriver' export const scriptEditor = { - editor: By.css('[data-testid=editorid]'), + editor: By.css('.public-DraftEditor-content'), done: By.css('[data-test=scriptDone]'), cancel: By.css('[data-test=scriptCancel]') } diff --git a/__test__/e2e/page-objects/texter.js b/__test__/e2e/page-objects/texter.js new file mode 100644 index 000000000..af56059b6 --- /dev/null +++ b/__test__/e2e/page-objects/texter.js @@ -0,0 +1,13 @@ +import { By } from 'selenium-webdriver' + +export const texter = { + sendFirstTexts: By.css('[data-test=sendFirstTexts]'), + sendReplies: By.css('[data-test=sendReplies]'), + send: By.css('[data-test=send]:not([disabled])'), + replyByText(text) { return By.xpath(`//*[@data-test='messageList']/descendant::*[contains(text(),'${text}')]`) }, + emptyTodo: By.css('[data-test=empty]'), + optOut: { + button: By.css('[data-test=optOut]'), + send: By.css('[type=submit]') + } +} diff --git a/__test__/e2e/util/config.js b/__test__/e2e/util/config.js index 559c2505b..851978602 100644 --- a/__test__/e2e/util/config.js +++ b/__test__/e2e/util/config.js @@ -6,6 +6,7 @@ const config = { capabilities: { name: 'Spoke - Chrome E2E Tests', browserName: 'chrome', + idleTimeout: 240, // 4 minute idle 'tunnel-identifier': process.env.TRAVIS_JOB_NUMBER, username: process.env.SAUCE_USERNAME, accessKey: process.env.SAUCE_ACCESS_KEY, diff --git a/__test__/e2e/util/helpers.js b/__test__/e2e/util/helpers.js index 565a6f03b..c84a3bf96 100644 --- a/__test__/e2e/util/helpers.js +++ b/__test__/e2e/util/helpers.js @@ -3,13 +3,21 @@ import remote from 'selenium-webdriver/remote' import config from './config' import _ from 'lodash' +import SauceLabs from 'saucelabs' + +const saucelabs = new SauceLabs({ + username: process.env.SAUCE_USERNAME, + password: process.env.SAUCE_ACCESS_KEY +}) + const defaultWait = 10000 -const selenium = { - buildDriver() { +export const selenium = { + buildDriver(options) { + const capabilities = _.assign({}, config.sauceLabs.capabilities, options) const driver = process.env.npm_config_saucelabs ? new Builder() - .withCapabilities(config.sauceLabs.capabilities) + .withCapabilities(capabilities) .usingServer(config.sauceLabs.server) .build() : new Builder().forBrowser('chrome').build() @@ -18,25 +26,45 @@ const selenium = { }, async quitDriver(driver) { await driver.getSession() - .then(session => { - const sessionId = session.getId() - process.env.SELENIUM_ID = sessionId - console.log(`SauceOnDemandSessionID=${sessionId} job-name=${process.env.TRAVIS_JOB_NUMBER}`) + .then(async session => { + if (process.env.npm_config_saucelabs) { + const sessionId = session.getId() + process.env.SELENIUM_ID = sessionId + await saucelabs.updateJob(sessionId, { passed: global.e2e.failureCount === 0 }) + console.log(`SauceOnDemandSessionID=${sessionId} job-name=${process.env.TRAVIS_JOB_NUMBER || ''}`) + } }) await driver.quit() + }, + reporter: { + specDone: async (result) => { global.e2e.failureCount = global.e2e.failureCount + result.failedExpectations.length || 0 }, + suiteDone: async (result) => { global.e2e.failureCount = global.e2e.failureCount + result.failedExpectations.length || 0 } + } +} + +export const urlBuilder = { + login: `${config.baseUrl}/login`, + admin: { + root() { return `${config.baseUrl}/admin/${global.e2e.organization}` } + }, + app: { + todos() { return `${config.baseUrl}/app/${global.e2e.organization}/todos` } } } const waitAnd = async (driver, locator, options) => { const el = await driver.wait(until.elementLocated(locator, options.msWait || defaultWait)) if (options.elementIsVisible !== false) await driver.wait(until.elementIsVisible(el)) + if (options.waitAfterVisible) await driver.sleep(options.waitAfterVisible) if (options.click) await el.click() + if (options.keys) await driver.sleep(500) if (options.clear) await el.clear() if (options.keys) await el.sendKeys(options.keys) + if (options.goesStale) await driver.wait(until.stalenessOf(el)) return el } -const wait = { +export const wait = { async untilLocated(driver, locator, options) { return await waitAnd(driver, locator, _.assign({}, options)) }, @@ -47,7 +75,7 @@ const wait = { return await waitAnd(driver, locator, _.assign({ click: true }, options)) }, async andType(driver, locator, keys, options) { - return await waitAnd(driver, locator, _.assign({ keys, clear: true }, options)) + return await waitAnd(driver, locator, _.assign({ keys, clear: true, click: true }, options)) }, async andGetValue(driver, locator, options) { const el = await waitAnd(driver, locator, _.assign({}, options)) @@ -58,5 +86,3 @@ const wait = { return await el.isEnabled() } } - -export { selenium, wait } diff --git a/__test__/server/api/assignment.test.js b/__test__/server/api/assignment.test.js index a31e17d52..bbba659f7 100644 --- a/__test__/server/api/assignment.test.js +++ b/__test__/server/api/assignment.test.js @@ -64,9 +64,8 @@ describe('test getContacts builds queries correctly', () => { organization, past_due_campaign ) - expect(query.toString()).toBe( - "select * from \"campaign_contact\" where \"assignment_id\" = 1 and \"message_status\" = '' order by message_status DESC, updated_at" - ) + // this should be empty because the query is empty and thus we return [] + expect(query.toString()).toBe('') }) // it it('works with: contacts filter, exclude past due, message status one other', () => { @@ -91,7 +90,7 @@ describe('test getContacts builds queries correctly', () => { it('works with: contacts filter, exclude past due, no message status, campaign is past due', () => { const query = getContacts(assignment, {}, organization, past_due_campaign) expect(query.toString()).toBe( - 'select * from "campaign_contact" where "assignment_id" = 1 and "message_status" = \'needsResponse\' order by message_status DESC, updated_at' + 'select * from "campaign_contact" where "assignment_id" = 1 and "message_status" in (\'needsResponse\') order by message_status DESC, updated_at' ) }) // it diff --git a/__test__/test_data/female_scientists.csv b/__test__/test_data/female_scientists.csv index c86b73aee..4d41d5c6b 100644 --- a/__test__/test_data/female_scientists.csv +++ b/__test__/test_data/female_scientists.csv @@ -2,8 +2,8 @@ firstName,lastName,cell,zip Katharine,Bartlett,6465550000,90000 Ruth,Benedict,6465550001,90001 Alicia,Dussán de Reichel,6465550002,90002 -Dina,Dahbany-Miraglia,6465550003,90003 -Zora,Neale Hurston,6465550004,90004 +Dina,Dahbany-Miraglia,6465550003,30003 +Zora,Neale Hurston,6465550003,30004 Marjorie,F. Lambert,6465550005,90005 Dorothea,Leighton,6465550006,90006 Katharine,Luomala,6465550007,90007 @@ -12,8 +12,8 @@ Grete,Mostny,6465550009,90009 Miriam,Tildesley,6465550010,90010 Mildred,Trotter,6465550011,90011 Camilla,Wedgwood,6465550012,90012 -Alba,Zaluar,6465550013,90013 -Sonia,Alconini,6465550014,90014 +Alba,Zaluar,6465550013,30013 +Sonia,Alconini,6465550013,30014 Jole,Bovio Marconi,6465550015,90015 Hester,A. Davis,6465550016,90016 Perla,Fuscaldo,6465550017,90017 @@ -22,8 +22,8 @@ Rosemary,Joyce,6465550019,90019 Elisabeth,Ruttkay,6465550020,90020 Hanna,Rydh,6465550021,90021 Claudia,Alexander,6465550022,90022 -Mary,Adela Blagg,6465550023,90023 -Margaret,Burbidge,6465550024,90024 +Mary,Adela Blagg,6465550023,30023 +Margaret,Burbidge,6465550023,30024 Jocelyn,Bell Burnell,6465550025,90025 Annie,Jump Cannon,6465550026,90026 Janine,Connes,6465550027,90027 @@ -32,8 +32,8 @@ Heather,Couper,6465550029,90029 Joy,Crisp,6465550030,90030 Sandra,Faber,6465550031,90031 Pamela,Gay,6465550032,90032 -Vera,Fedorovna Gaze,6465550033,90033 -Julie,Vinter Hansen,6465550034,90034 +Vera,Fedorovna Gaze,6465550033,30033 +Julie,Vinter Hansen,6465550033,30034 Martha,Haynes,6465550035,90035 Lisa,Kaltenegger,6465550036,90036 Dorothea,Klumpke,6465550037,90037 @@ -42,8 +42,8 @@ Evelyn,Leland,6465550039,90039 Priyamvada,Natarajan,6465550040,90040 Carolyn,Porco,6465550041,90041 Cecilia,Payne-Gaposchkin,6465550042,90042 -Ruby,Payne-Scott,6465550043,90043 -Vera,Rubin,6465550044,90044 +Ruby,Payne-Scott,6465550043,30043 +Vera,Rubin,6465550043,30044 Charlotte,Moore Sitterly,6465550045,90045 Jill,Tarter,6465550046,90046 Beatrice,Tinsley,6465550047,90047 @@ -52,8 +52,8 @@ Nora,Lilian Alcock,6465550049,90049 Alice,Alldredge,6465550050,90050 June,Almeida,6465550051,90051 E.,K. Janaki Ammal,6465550052,90052 -Yvonne,Barr,6465550053,90053 -Lela,Viola Barton,6465550054,90054 +Yvonne,Barr,6465550053,30053 +Lela,Viola Barton,6465550053,30054 Kathleen,Basford,6465550055,90055 Gillian,Bates,6465550056,90056 Val,Beral,6465550057,90057 @@ -62,8 +62,8 @@ Agathe,L. van Beverwijk,6465550059,90059 Gladys,Black,6465550060,90060 Idelisa,Bonnelly,6465550061,90061 Alice,Middleton Boring,6465550062,90062 -Annette,Frances Braun,6465550063,90063 -Linda,B. Buck,6465550064,90064 +Annette,Frances Braun,6465550063,30063 +Linda,B. Buck,6465550063,30064 Hildred,Mary Butler,6465550065,90065 Esther,Byrnes,6465550066,90066 Bertha,Cady,6465550067,90067 @@ -72,8 +72,8 @@ Eleanor,Carothers,6465550069,90069 Rachel,Carson,6465550070,90070 Edith,Katherine Cash,6465550071,90071 Ann,Chapman,6465550072,90072 -Martha,Chase,6465550073,90073 -Mary-Dell,Chilton,6465550074,90074 +Martha,Chase,6465550073,30073 +Mary-Dell,Chilton,6465550073,30074 Theresa,Clay,6465550075,90075 Edith,Clements,6465550076,90076 Elzada,Clover,6465550077,90077 @@ -82,8 +82,8 @@ Gerty,Theresa Cori,6465550079,90079 Suzanne,Cory,6465550080,90080 Janet,Darbyshire,6465550081,90081 Gertrude,Crotty Davenport,6465550082,90082 -Sophie,Charlotte Ducker,6465550083,90083 -Sophia,Eckerson,6465550084,90084 +Sophie,Charlotte Ducker,6465550083,30083 +Sophia,Eckerson,6465550083,30084 Sylvia,Edlund,6465550085,90085 Charlotte,Elliott,6465550086,90086 Charlotte,Cortlandt Ellis,6465550087,90087 @@ -92,8 +92,8 @@ Rhoda,Erdmann,6465550089,90089 Katherine,Esau,6465550090,90090 Edna,H. Fawcett,6465550091,90091 Catherine,Feuillet,6465550092,90092 -Dian,Fossey,6465550093,90093 -Birutė,Galdikas,6465550094,90094 +Dian,Fossey,6465550093,30093 +Birutė,Galdikas,6465550093,30094 Margaret,Sylvia Gilliland,6465550095,90095 Jane,Goodall,6465550096,90096 Isabella,Gordon,6465550097,90097 @@ -102,8 +102,8 @@ Charlotte,Elliott,6465550099,90099 Constance,Endicott Hartt,6465550100,90100 Eliza,Amy Hodgson,6465550101,90101 Lena,B. Smithers Hughes,6465550102,90102 -Eva,Jablonka,6465550103,90103 -Marian,Koshland,6465550104,90104 +Eva,Jablonka,6465550103,30103 +Marian,Koshland,6465550103,30104 Frances,Adams Le Sueur,6465550105,90105 Margaret,Reed Lewis,6465550106,90106 Maria,Carmelo Lico,6465550107,90107 @@ -112,8 +112,8 @@ Liliana,Lubinska,6465550109,90109 Misha,Mahowald,6465550110,90110 Lynn,Margulis,6465550111,90111 Deborah,Martin-Downs,6465550112,90112 -Sara,Branham Matthews,6465550113,90113 -Barbara,McClintock,6465550114,90114 +Sara,Branham Matthews,6465550113,30113 +Barbara,McClintock,6465550113,30114 Eileen,McCracken,6465550115,90115 Ruth,Colvin Starrett McGuire,6465550116,90116 Anne,McLaren,6465550117,90117 @@ -122,8 +122,8 @@ Eunice,Thomas Miner,6465550119,90119 Rita,Levi-Montalcini,6465550120,90120 Ann,Haven Morgan,6465550121,90121 Christiane,Nüsslein-Volhard,6465550122,90122 -Ida,Shepard Oldroyd,6465550123,90123 -Daphne,Osborne,6465550124,90124 +Ida,Shepard Oldroyd,6465550123,30123 +Daphne,Osborne,6465550123,30124 Mary,Parke,6465550125,90125 Jane,E. Parker,6465550126,90126 Eva,J. Pell,6465550127,90127 @@ -132,8 +132,8 @@ Joan,Beauchamp Procter,6465550129,90129 F.,Gwendolen Rees,6465550130,90130 Anita,Roberts,6465550131,90131 Gudrun,Ruud,6465550132,90132 -Hazel,Schmoll,6465550133,90133 -Idah,Sithole-Niang,6465550134,90134 +Hazel,Schmoll,6465550133,30133 +Idah,Sithole-Niang,6465550133,30134 Margaret,A. Stanley,6465550135,90135 Phyllis,Starkey,6465550136,90136 Magda,Staudinger,6465550137,90137 @@ -142,8 +142,8 @@ Ragnhild,Sundby,6465550139,90139 Maria,Telkes,6465550140,90140 Lois,H. Tiffany,6465550141,90141 Lydia,Villa-Komaroff,6465550142,90142 -Karen,Vousden,6465550143,90143 -Elisabeth,Vrba,6465550144,90144 +Karen,Vousden,6465550143,30143 +Elisabeth,Vrba,6465550143,30144 Marvalee,Wake,6465550145,90145 Jane,C. Wright,6465550146,90146 Kono,Yasui,6465550147,90147 @@ -152,8 +152,8 @@ Anna,Veiga,6465550149,90149 Maria,Abbracchio,6465550150,90150 Barbara,Askins,6465550151,90151 Alice,Ball,6465550152,90152 -Ulrike,Beisiegel,6465550153,90153 -Anne,Beloff-Chain,6465550154,90154 +Ulrike,Beisiegel,6465550153,30153 +Anne,Beloff-Chain,6465550153,30154 Jeannette,Brown,6465550155,90155 Astrid,Cleve,6465550156,90156 Seetha,Coleman-Kammula,6465550157,90157 @@ -162,8 +162,8 @@ Mary,Campbell Dawbarn,6465550159,90159 Moira,Lenore Dynon,6465550160,90160 Gertrude,B. Elion,6465550161,90161 Gwendolyn,Wilson Fowler,6465550162,90162 -Rosalind,Franklin,6465550163,90163 -Ellen,Gleditsch,6465550164,90164 +Rosalind,Franklin,6465550163,30163 +Ellen,Gleditsch,6465550163,30164 Jenny,Glusker,6465550165,90165 Emīlija,Gudriniece,6465550166,90166 Anna,J. Harrison,6465550167,90167 @@ -172,8 +172,8 @@ Clara,Immerwahr,6465550169,90169 Irène,Joliot-Curie,6465550170,90170 Chika,Kuroda,6465550171,90171 Stephanie,Kwolek,6465550172,90172 -Lidija,Liepiņa,6465550173,90173 -Kathleen,Lonsdale,6465550174,90174 +Lidija,Liepiņa,6465550173,30173 +Kathleen,Lonsdale,6465550173,30174 Grace,Medes,6465550175,90175 Maud,Menten,6465550176,90176 Muriel,Wheldale Onslow,6465550177,90177 @@ -182,8 +182,8 @@ Nellie,M. Payne,6465550179,90179 Eva,Philbin,6465550180,90180 Darshan,Ranganathan,6465550181,90181 Mildred,Rebstock,6465550182,90182 -Elizabeth,Rona,6465550183,90183 -Patsy,Sherman,6465550184,90184 +Elizabeth,Rona,6465550183,30183 +Patsy,Sherman,6465550183,30184 Marija,Šimanska,6465550185,90185 Ida,Noddack Tacke,6465550186,90186 Grace,Oladunni Taylor,6465550187,90187 @@ -192,8 +192,8 @@ Michiyo,Tsujimura,6465550189,90189 Joanna,Maria Vandenberg,6465550190,90190 Elizabeth,Williamson,6465550191,90191 Ada,Yonath,6465550192,90192 -Christina,Miller,6465550193,90193 -Zonia,Baber,6465550194,90194 +Christina,Miller,6465550193,30193 +Zonia,Baber,6465550193,30194 Inés,Cifuentes,6465550195,90195 Moira,Dunbar,6465550196,90196 Elizabeth,F. Fisher,6465550197,90197 @@ -202,8 +202,8 @@ Eileen,Hendriks,6465550199,90199 Dorothée,Le Maître,6465550200,90200 Karen,Cook McNally,6465550201,90201 Inge,Lehmann,6465550202,90202 -Marcia,McNutt,6465550203,90203 -Ellen,Louise Mertz,6465550204,90204 +Marcia,McNutt,6465550203,30203 +Ellen,Louise Mertz,6465550203,30204 Ruth,Schmidt,6465550205,90205 Ethel,Shakespear,6465550206,90206 Kathleen,Sherrard,6465550207,90207 @@ -212,8 +212,8 @@ Marjorie,Sweeting,6465550209,90209 Marie,Tharp,6465550210,90210 Elsa,G. Vilmundardóttir,6465550211,90211 Marguerite,Williams,6465550212,90212 -Alice,Wilson,6465550213,90213 -Elizabeth,A. Wood,6465550214,90214 +Alice,Wilson,6465550213,30213 +Elizabeth,A. Wood,6465550213,30214 Hertha,Marks Ayrton,6465550215,90215 Anita,Borg,6465550216,90216 Mary,L. Cartwright,6465550217,90217 @@ -222,8 +222,8 @@ Ingrid,Daubechies,6465550219,90219 Tatjana,Ehrenfest-Afanassjewa,6465550220,90220 Deborah,Estrin,6465550221,90221 Vera,Faddeeva,6465550222,90222 -Evelyn,Boyd Granville,6465550223,90223 -Marion,Cameron Gray,6465550224,90224 +Evelyn,Boyd Granville,6465550223,30223 +Marion,Cameron Gray,6465550223,30224 Frances,Hardcastle,6465550225,90225 Grace,Hopper,6465550226,90226 Margarete,Kahn,6465550227,90227 @@ -232,8 +232,8 @@ Marguerite,Lehr,6465550229,90229 Margaret,Anne LeMone,6465550230,90230 Barbara,Liskov,6465550231,90231 Margaret,Millington,6465550232,90232 -Mangala,Narlikar,6465550233,90233 -Rózsa,Péter,6465550234,90234 +Mangala,Narlikar,6465550233,30233 +Rózsa,Péter,6465550233,30234 Dorothy,Maud Wrinch,6465550235,90235 Jeannette,Wing,6465550236,90236 Kathleen,Jannette Anderson,6465550237,90237 @@ -242,8 +242,8 @@ Florence,Annie Yeldham,6465550239,90239 Kate,Gleason,6465550240,90240 Frances,Hugle,6465550241,90241 Maria,Tereza Jorge Pádua,6465550242,90242 -Mary,Olliden Weaver,6465550243,90243 -Phyllis,Margery Anderson,6465550244,90244 +Mary,Olliden Weaver,6465550243,30243 +Phyllis,Margery Anderson,6465550243,30244 Virginia,Apgar,6465550245,90245 Anna,Baetjer,6465550246,90246 Roberta,Bondar,6465550247,90247 @@ -252,8 +252,8 @@ Audrey,Cahn,6465550249,90249 Margaret,Chan,6465550250,90250 Evelyn,Stocking Crosslin,6465550251,90251 Eleanor,Davies-Colley,6465550252,90252 -Claire,Fagin,6465550253,90253 -Esther,Greisheimer,6465550254,90254 +Claire,Fagin,6465550253,30253 +Esther,Greisheimer,6465550253,30254 L.,Ruth Guy,6465550255,90255 Karen,C. Johnson,6465550256,90256 Mary,Jeanne Kreek,6465550257,90257 @@ -262,8 +262,8 @@ Elaine,Marjory Little,6465550259,90259 Anna,Suk-Fong Lok,6465550260,90260 Eleanor,Josephine Macdonald,6465550261,90261 Catharine,Macfarlane,6465550262,90262 -Charlotte,E. Maguire,6465550263,90263 -Louisa,Martindale,6465550264,90264 +Charlotte,E. Maguire,6465550263,30263 +Louisa,Martindale,6465550263,30264 Helen,Mayo,6465550265,90265 Frances,Gertrude McGill,6465550266,90266 Eleanor,Montague,6465550267,90267 @@ -272,8 +272,8 @@ Antonia,Novello,6465550269,90269 Dorothea,Orem,6465550270,90270 Ida,Ørskov,6465550271,90271 May,Owen,6465550272,90272 -Angeliki,Panajiotatou,6465550273,90273 -Kathleen,I. Pritchard,6465550274,90274 +Angeliki,Panajiotatou,6465550273,30273 +Kathleen,I. Pritchard,6465550273,30274 Frieda,Robscheit-Robbins,6465550275,90275 Ora,Mendelsohn Rosen,6465550276,90276 Una,Ryan,6465550277,90277 @@ -282,8 +282,8 @@ Velma,Scantlebury,6465550279,90279 Lise,Thiry,6465550280,90280 Helen,Rodríguez Trías,6465550281,90281 Marie,Stopes,6465550282,90282 -Elizabeth,M. Ward,6465550283,90283 -Elsie,Widdowson,6465550284,90284 +Elizabeth,M. Ward,6465550283,30283 +Elsie,Widdowson,6465550283,30284 Fiona,Wood,6465550285,90285 Mary,Leakey,6465550286,90286 Suzanne,LeClercq,6465550287,90287 @@ -292,8 +292,8 @@ Betsy,Ancker-Johnson,6465550289,90289 Milla,Baldo-Ceolin,6465550290,90290 Marietta,Blau,6465550291,90291 Lili,Bleeker,6465550292,90292 -Katharine,Blodgett,6465550293,90293 -Christiane,Bonnelle,6465550294,90294 +Katharine,Blodgett,6465550293,30293 +Christiane,Bonnelle,6465550293,30294 Sonja,Ashauer,6465550295,90295 Tatiana,Birshtein,6465550296,90296 Margrete,Heiberg Bose,6465550297,90297 @@ -302,8 +302,8 @@ Harriet,Brooks,6465550299,90299 A.,Catrina Bryce,6465550300,90300 Nina,Byers,6465550301,90301 Yvette,Cauchois,6465550302,90302 -Yvonne,Choquet-Bruhat,6465550303,90303 -Patricia,Cladis,6465550304,90304 +Yvonne,Choquet-Bruhat,6465550303,30303 +Patricia,Cladis,6465550303,30304 Esther,Conwell,6465550305,90305 Cécile,DeWitt-Morette,6465550306,90306 Louise,Dolan,6465550307,90307 @@ -312,8 +312,8 @@ Mildred,Dresselhaus,6465550309,90309 Helen,T. Edwards,6465550310,90310 Magda,Ericson,6465550311,90311 Edith,Farkas,6465550312,90312 -Ursula,Franklin,6465550313,90313 -Judy,Franz,6465550314,90314 +Ursula,Franklin,6465550313,30313 +Judy,Franz,6465550313,30314 Joan,Maie Freeman,6465550315,90315 Phyllis,S. Freier,6465550316,90316 Mary,K. Gaillard,6465550317,90317 @@ -322,8 +322,8 @@ Claire,F. Gmachl,6465550319,90319 Maria,Goeppert-Mayer,6465550320,90320 Gertrude,Scharff Goldhaber,6465550321,90321 Sulamith,Goldhaber,6465550322,90322 -Gail,Hanson,6465550323,90323 -Margrete,Heiberg Bose,6465550324,90324 +Gail,Hanson,6465550323,30323 +Margrete,Heiberg Bose,6465550323,30324 Evans,Hayward,6465550325,90325 Caroline,Herzenberg,6465550326,90326 Hanna,von Hoerner,6465550327,90327 @@ -332,8 +332,8 @@ Bertha,Swirles Jeffreys,6465550329,90329 Lorella,M. Jones,6465550330,90330 Carole,Jordan,6465550331,90331 Renata,Kallosh,6465550332,90332 -Berta,Karlik,6465550333,90333 -Bruria,Kaufman,6465550334,90334 +Berta,Karlik,6465550333,30333 +Bruria,Kaufman,6465550333,30334 Elizaveta,Karamihailova,6465550335,90335 Marcia,Keith,6465550336,90336 Ann,Kiessling,6465550337,90337 @@ -342,8 +342,8 @@ Noemie,Benczer Koller,6465550339,90339 Ninni,Kronberg,6465550340,90340 Doris,Kuhlmann-Wilsdorf,6465550341,90341 Elizabeth,Laird (physicist),6465550342,90342 -Juliet,Lee-Franzini,6465550343,90343 -Inge,Lehmann,6465550344,90344 +Juliet,Lee-Franzini,6465550343,30343 +Inge,Lehmann,6465550343,30344 Kathleen,Lonsdale,6465550345,90345 Margaret,Eliza Maltby,6465550346,90346 Helen,Megaw,6465550347,90347 @@ -352,8 +352,8 @@ Lise,Meitner,6465550349,90349 Kirstine,Meyer,6465550350,90350 Luise,Meyer-Schutzmeister,6465550351,90351 Anna,Nagurney,6465550352,90352 -Chiara,Nappi,6465550353,90353 -Ann,Nelson,6465550354,90354 +Chiara,Nappi,6465550353,30353 +Ann,Nelson,6465550353,30354 Marcia,Neugebauer,6465550355,90355 Gertrude,Neumark,6465550356,90356 Ida,Tacke Noddack,6465550357,90357 @@ -362,8 +362,8 @@ Marguerite,Perey,6465550359,90359 Melba,Phillips,6465550360,90360 Agnes,Pockels,6465550361,90361 Pelageya,Polubarinova-Kochina,6465550362,90362 -Edith,Quimby,6465550363,90363 -Helen,Quinn,6465550364,90364 +Edith,Quimby,6465550363,30363 +Helen,Quinn,6465550363,30364 Lisa,Randall,6465550365,90365 Myriam,Sarachik,6465550366,90366 Bice,Sechi-Zorn,6465550367,90367 @@ -372,8 +372,8 @@ Johanna,Levelt Sengers,6465550369,90369 Hertha,Sponer,6465550370,90370 Isabelle,Stone,6465550371,90371 Edith,Anne Stoney,6465550372,90372 -Nina,Vedeneyeva,6465550373,90373 -Katharine,Way,6465550374,90374 +Nina,Vedeneyeva,6465550373,30373 +Katharine,Way,6465550373,30374 Mariana,Weissmann,6465550375,90375 Lucy,Wilson,6465550376,90376 Leona,Woods,6465550377,90377 @@ -382,8 +382,8 @@ Sau,Lan Wu,6465550379,90379 Xide,Xie,6465550380,90380 Rosalyn,Sussman Yalow,6465550381,90381 Fumiko,Yonezawa,6465550382,90382 -Toshiko,Yuasa,6465550383,90383 -Mary,Ainsworth,6465550384,90384 +Toshiko,Yuasa,6465550383,30383 +Mary,Ainsworth,6465550383,30384 Martha,E. Bernal,6465550385,90385 Lera,Boroditsky,6465550386,90386 Mamie,Clark,6465550387,90387 @@ -392,8 +392,8 @@ Tsuruko,Haraguchi,6465550389,90389 Margaret,Kennard,6465550390,90390 Grace,Manson,6465550391,90391 Rosalie,Rayner,6465550392,90392 -Marianne,Simmel,6465550393,90393 -Davida,Teller,6465550394,90394 +Marianne,Simmel,6465550393,30393 +Davida,Teller,6465550393,30394 Nora,Volkow,6465550395,90395 Margo,Wilson,6465550396,90396 Catherine,G. Wolf,6465550397,90397 @@ -402,5 +402,5 @@ List,of female mathematicians,6465550399,90399 List,of female Nobel laureates,6465550400,90400 Women,in computing,6465550401,90401 Women,in engineering,6465550402,90402 -Women,in geology,6465550403,90403 -Women,in medicine,6465550404,90404 +Women,in geology,6465550403,30403 +Women,in medicine,6465550403,30404 diff --git a/__test__/workers/assign-texters.test.js b/__test__/workers/assign-texters.test.js new file mode 100644 index 000000000..dc80e5b6c --- /dev/null +++ b/__test__/workers/assign-texters.test.js @@ -0,0 +1,114 @@ +import {assignTexters} from '../../src/workers/jobs' +import {r, Campaign, CampaignContact, JobRequest, Organization, User, ZipCode} from '../../src/server/models' +import {setupTest, cleanupTest} from "../test_helpers"; + +describe('test texter assignment in dynamic mode', () => { + + beforeAll(async () => await setupTest(), global.DATABASE_SETUP_TEARDOWN_TIMEOUT) + afterAll(async () => await cleanupTest(), global.DATABASE_SETUP_TEARDOWN_TIMEOUT) + + const testOrg = new Organization({ + id: '7777777', + texting_hours_enforced: false, + texting_hours_start: 9, + texting_hours_end: 14, + name: 'Test Organization' + }) + + const testCampaign = new Campaign({ + organization_id: testOrg.id, + id: '7777777', + use_dynamic_assignment: true + }) + + const texterInfo = [ + { + id: '1', + auth0_id: 'aaa', + first_name: 'Ruth', + last_name: 'Bader', + cell: '9999999999', + email: 'rbg@example.com', + }, + { + id: '2', + auth0_id: 'bbb', + first_name: 'Elena', + last_name: 'Kagan', + cell: '8888888888', + email: 'ek@example.com' + } + ] + + const contactInfo = ['1111111111','2222222222','3333333333','4444444444','5555555555'] + + it('assigns no contacts to texters in dynamic assignment mode', async() => { + const organization = await Organization.save(testOrg) + const campaign = await Campaign.save(testCampaign) + contactInfo.map((contact) => { + CampaignContact.save({cell: contact, campaign_id: campaign.id}) + }) + texterInfo.map(async(texter) => { + await User.save({ + id: texter.id, + auth0_id: texter.auth0_id, + first_name: texter.first_name, + last_name: texter.last_name, + cell: texter.cell, + email: texter.email + }) + }) + const payload = '{"id": "3","texters":[{"id":"1","needsMessageCount":5,"maxContacts":"","contactsCount":0},{"id":"2","needsMessageCount":5,"maxContacts":"0","contactsCount":0}]}' + const job = new JobRequest({ + campaign_id: testCampaign.id, + payload: payload, + queue_name: "3:edit_campaign", + job_type: 'assign_texters', + }) + await assignTexters(job) + const result = await r.knex('campaign_contact') + .where({campaign_id: campaign.id}) + .whereNotNull('assignment_id') + .count() + const assignedTextersCount = result[0]["count"] + expect(assignedTextersCount).toEqual("0") + }) + + it('supports saving null or zero maxContacts', async() => { + const zero = await r.knex('assignment') + .where({campaign_id: testCampaign.id, user_id: "2"}) + .select('max_contacts') + const blank = await r.knex('assignment') + .where({campaign_id: testCampaign.id, user_id: "1"}) + .select('max_contacts') + const maxContactsZero = zero[0]["max_contacts"] + const maxContactsBlank = blank[0]["max_contacts"] + expect(maxContactsZero).toEqual(0) + expect(maxContactsBlank).toEqual(null) + + }) + + it('updates max contacts when nothing else changes', async() => { + const payload = '{"id": "3","texters":[{"id":"1","needsMessageCount":0,"maxContacts":"10","contactsCount":0},{"id":"2","needsMessageCount":5,"maxContacts":"15","contactsCount":0}]}' + const job = new JobRequest({ + campaign_id: testCampaign.id, + payload: payload, + queue_name: "4:edit_campaign", + job_type: 'assign_texters', + }) + await assignTexters(job) + const ten = await r.knex('assignment') + .where({campaign_id: testCampaign.id, user_id: "1"}) + .select('max_contacts') + const fifteen = await r.knex('assignment') + .where({campaign_id: testCampaign.id, user_id: "2"}) + .select('max_contacts') + const maxContactsTen = ten[0]["max_contacts"] + const maxContactsFifteen = fifteen[0]["max_contacts"] + expect(maxContactsTen).toEqual(10) + expect(maxContactsFifteen).toEqual(15) + }) + +}) + +// TODO: test in standard assignment mode \ No newline at end of file diff --git a/docs/DEPLOYING_AWS_LAMBDA.md b/docs/DEPLOYING_AWS_LAMBDA.md index b1180f276..de7251c57 100644 --- a/docs/DEPLOYING_AWS_LAMBDA.md +++ b/docs/DEPLOYING_AWS_LAMBDA.md @@ -12,7 +12,7 @@ 2. [Deploy](#deploy) 1. [Seed Database](#seed-database) 2. [Setting Up Scheduled Jobs](#setting-up-scheduled-jobs) - 3. [Migrating the Database](migrating-the-database) + 3. [Migrating the Database](#migrating-the-database) 4. [Add a Custom Domain](#add-a-custom-domain) 3. [Updating Code or Environment Variables](#updating-code-or-environment-variables) diff --git a/docs/EXPLANATION-end-to-end-tests.md b/docs/EXPLANATION-end-to-end-tests.md index 5ec09eaf2..f3894446e 100644 --- a/docs/EXPLANATION-end-to-end-tests.md +++ b/docs/EXPLANATION-end-to-end-tests.md @@ -2,32 +2,36 @@ ## Selenium -End to end tests use Selenium, which is a framework which drives the WebDriver API exposed by browsers. A test script can therefore be executed at the UI level which is as close to a manual test as possible. +End to end tests use **Selenium**, which is a framework which drives the WebDriver API exposed by browsers. A test script can therefore be executed at the UI level which is as close to a manual test as possible. -## SauceLabs +## Sauce Labs -SauceLabs is a cloud service which provides access to test clients on which automated tests are run. +**Sauce Labs** is a cloud service which provides access to test clients on which automated tests are run. -In order to run tests on SauceLabs, environment variables need to get set on your environment: +In order to run tests on Sauce Labs, environment variables need to get set on your environment: -The access keys `SAUCE_USERNAME` and `SAUCE_ACCESS_KEY` is set in the "Environment Variables" section of Travis-CI +The access keys `SAUCE_USERNAME` and `SAUCE_ACCESS_KEY` is set in the "Environment Variables" section of [Travis-CI](https://travis-ci.org/MoveOnOrg/Spoke/settings) -https://travis-ci.org/MoveOnOrg/Spoke/settings +To run tests against your localhost on Sauce Labs clients, you must first setup [Sauce Labs Connect](https://wiki.saucelabs.com/display/DOCS/Basic+Sauce+Connect+Proxy+Setup). + +Reporting is setup between Sauce Labs and Travis CI using a [Jasmine custom reporter](https://jasmine.github.io/api/edge/global.html#SuiteResult) and [console stdout](https://wiki.saucelabs.com/display/DOCS/Setting+Up+Reporting+between+Sauce+Labs+and+Jenkins#SettingUpReportingbetweenSauceLabsandJenkins-OutputtingtheJenkinsSessionIDtostdout) in the tests. # Test Writing ## Jest / Jasmine -Jest is the test runner which locates, runs and summarizes the tests. +**Jest** is the test runner which locates, runs and summarizes the tests. Reference: [Jest 22.4 docs](http://jestjs.io/docs/en/22.4/getting-started) -Jasmine is the test syntax used by many test harnesses including Jest. +**Jasmine** is the BDD test framework used by many test harnesses including Jest. -Example of a jasmine block: +Example of a Jasmine block: ``` -it('step description', async () => { - // Operations +describe('test description', () => { + it('step description', async () => { + // Operations + }) }) ``` A note on `this`: Arrow functions lexically bind the this keyword. This interferes with how the test runner wants to use the `this` keyword for context. More information is available online. @@ -51,4 +55,12 @@ import { dataTest } from '../lib/attributes' ... /> ``` -This adds a `data-test` attribute to the **non-production** rendered HTML and indicates to future developers that this control is used in automated tests. \ No newline at end of file +This adds a `data-test` attribute to the **non-production** rendered HTML and indicates to future developers that this control is used in automated tests. + +## Helpers + +In `./Spoke/__test__/e2e/util/helpers.js` you'll find a helper named `wait`. This is an important collection of methods for interactive commands like click. + +``` +await wait.andClick(driver, ) +``` \ No newline at end of file diff --git a/docs/HOWTO-run_tests.md b/docs/HOWTO-run_tests.md index 45f1039b3..4458e40fb 100644 --- a/docs/HOWTO-run_tests.md +++ b/docs/HOWTO-run_tests.md @@ -35,13 +35,15 @@ https://github.com/MoveOnOrg/Spoke/blob/main/README.md#getting-started) section. ``` npm run test-e2e ``` - * ... using Sauce Labs - ``` - export SAUCE_USERNAME= - export SAUCE_ACCESS_KEY= - npm run test-e2e --saucelabs - ``` * ... individually ``` npm run test-e2e + ``` + * ... using Sauce Labs browser with your local host + + **Note:** You must first setup [Sauce Labs](https://github.com/MoveOnOrg/Spoke/blob/main/docs/EXPLANATION-end-to-end-tests.md#saucelabs) + ``` + export SAUCE_USERNAME= + export SAUCE_ACCESS_KEY= + npm run test-e2e --saucelabs ``` \ No newline at end of file diff --git a/docs/REFERENCE-environment_variables.md b/docs/REFERENCE-environment_variables.md index 0af2d4f37..fa177f66d 100644 --- a/docs/REFERENCE-environment_variables.md +++ b/docs/REFERENCE-environment_variables.md @@ -11,6 +11,7 @@ AWS_ACCESS_KEY_ID | AWS access key ID with access to S3 bucket, AWS_SECRET_ACCESS_KEY | AWS access key secret with access to S3 bucket, required for campaign exports outside Amazon Lambda. AWS_S3_BUCKET_NAME | Name of S3 bucket for saving campaign exports. BASE_URL | The base URL of the website, without trailing slack, e.g. `https://example.org`, used to construct various URLs. +CACHE_PREFIX | If REDIS_URL is set, then this will prefix keys CACHE_PREFIX, which might be useful if multiple applications use the same redis server. _Default_: "". CAMPAIGN_ID | Campaign ID used by `dev-tools/export-query.js` to identify which campaign should be exported. DB_HOST | Domain or IP address of database host. DB_MAX_POOL | Database connection pool maximum size. _Default_: 10. @@ -38,7 +39,7 @@ MAILGUN_SMTP_PASSWORD | 'Default Password' in Mailgun. _Required for MAILGUN_SMTP_PORT | _Default_: 587. Do not modify. _Required for Mailgun usage._ MAILGUN_SMTP_SERVER | _Default_: smtp.mailgun.org. Do not modify. _Required for Mailgun usage._ MAX_CONTACTS | If set each campaign can only have a maximum of the value (an integer). This is good for staging/QA/evaluation instances. _Default_: false (i.e. there is no maximum) -MAX_CONTACTS_PER_TEXTER | Maximum contacts that a texter can receive. This is particularly useful for dynamic assignment. If it's zero, then there is no maximum. _Default_: 0 +MAX_CONTACTS_PER_TEXTER | Maximum contacts that a texter can receive. This is particularly useful for dynamic assignment. Leave it blank (which is the default value) for no maximum. MAX_MESSAGE_LENGTH | The maximum size for a message that a texter can send. When you send a SMS message over 160 characters the message will be split, so you might want to set this as 160 or less if you have a high SMS-only target demographic. _Default_: 99999 NEXMO_API_KEY | Nexmo API key. Required if using Nexmo. NEXMO_API_SECRET | Nexmo API secret. Required if using Nexmo. @@ -46,10 +47,12 @@ NO_EXTERNAL_LINKS | Removes google fonts and auth0 login script NODE_ENV | Node environment type. _Options_: development, production. NOT_IN_USA | A flag to affirmatively indicate the ability to use features that are discouraged or not legally usable in the United States. Consult with an attorney about the implications for doing so. _Default_: false (i.e. default assumes a USA legal context) OPT_OUT_MESSAGE | Spoke instance-wide default for opt out message. +OPTOUTS_SHARE_ALL_ORGS | Can be set to true if opt outs should be respected per instance and across organizations OUTPUT_DIR | Directory path for packaged files should be saved to. _Required_. PHONE_NUMBER_COUNTRY | Country code for phone number formatting. _Default_: US. PORT | Port for Heroku servers. PUBLIC_DIR | Directory path server should use to serve files. _Required_. +REDIS_URL | This enables caching using the [`url` option in redis library](https://github.com/NodeRedis/node_redis#options-object-properties). This is an area of active development. More can be seen at [server/models/cacheable-queries/README](../src/server/models/cacheable-queries/README.md) and the [project board](https://github.com/MoveOnOrg/Spoke/projects/4) REVERE_SQS_URL | SQS URL to process outgoing Revere SMS Messages. REVERE_LIST_ID | Revere List to add user to. REVERE_NEW_SUBSCRIBER_MOBILE_FLOW | Revere mobile flow to trigger upon recording action. diff --git a/jest.config.js b/jest.config.js index f349f4707..2b2429825 100644 --- a/jest.config.js +++ b/jest.config.js @@ -19,7 +19,7 @@ module.exports = { RETHINK_KNEX_NOREFS: "1", // avoids db race conditions DEFAULT_SERVICE: 'fakeservice', DST_REFERENCE_TIMEZONE: 'America/New_York', - DATABASE_SETUP_TEARDOWN_TIMEOUT: 20000, + DATABASE_SETUP_TEARDOWN_TIMEOUT: 60000, }, moduleFileExtensions: [ "js", diff --git a/package.json b/package.json index 98d3ee015..fe30fd1af 100644 --- a/package.json +++ b/package.json @@ -167,6 +167,7 @@ "react-scripts": "^1.1.0", "react-test-renderer": "15", "regenerator-runtime": "^0.10.5", + "saucelabs": "^1.5.0", "selenium-webdriver": "^3.6.0", "sqlite3": "^3.1.9", "wait-on": "^2.1.0", diff --git a/src/api/campaign-contact.js b/src/api/campaign-contact.js index bae3caa03..1a964cb4c 100644 --- a/src/api/campaign-contact.js +++ b/src/api/campaign-contact.js @@ -32,8 +32,6 @@ export const schema = ` questionResponseValues: [AnswerOption] questionResponses: [AnswerOption] interactionSteps: [InteractionStep] - currentInteractionStepScript: String - currentInteractionStepId: String messageStatus: String assignmentId: String } diff --git a/src/api/canned-response.js b/src/api/canned-response.js index f7feb02d2..32d834d23 100644 --- a/src/api/canned-response.js +++ b/src/api/canned-response.js @@ -12,8 +12,6 @@ export const schema = ` title: String text: String isUserCreated: Boolean - campaign: Campaign - user: User } ` diff --git a/src/api/organization.js b/src/api/organization.js index b67010daf..64c0575a7 100644 --- a/src/api/organization.js +++ b/src/api/organization.js @@ -7,6 +7,7 @@ export const schema = ` people(role: String, campaignId: String): [User] optOuts: [OptOut] threeClickEnabled: Boolean + optOutMessage: String textingHoursEnforced: Boolean textingHoursStart: Int textingHoursEnd: Int diff --git a/src/api/schema.js b/src/api/schema.js index 6d2a9b1c4..0a8b94b49 100644 --- a/src/api/schema.js +++ b/src/api/schema.js @@ -194,6 +194,7 @@ const rootSchema = ` editUser(organizationId: String!, userId: Int!, userData:UserInput): User updateTextingHours( organizationId: String!, textingHoursStart: Int!, textingHoursEnd: Int!): Organization updateTextingHoursEnforcement( organizationId: String!, textingHoursEnforced: Boolean!): Organization + updateOptOutMessage( organizationId: String!, optOutMessage: String!): Organization bulkSendMessages(assignmentId: Int!): [CampaignContact] sendMessage(message:MessageInput!, campaignContactId:String!): CampaignContact, createOptOut(optOut:OptOutInput!, campaignContactId:String!):CampaignContact, @@ -204,6 +205,7 @@ const rootSchema = ` archiveCampaign(id:String!): Campaign, unarchiveCampaign(id:String!): Campaign, sendReply(id: String!, message: String!): CampaignContact + getAssignmentContacts(assignmentId: String!, contactIds: [String], findNew: Boolean): [CampaignContact], findNewCampaignContact(assignmentId: String!, numberContacts: Int!): FoundContact, assignUserToCampaign(organizationUuid: String!, campaignId: String!): Campaign userAgreeTerms(userId: String!): User diff --git a/src/components/AssignmentSummary.jsx b/src/components/AssignmentSummary.jsx index 563753bf3..b6e25941e 100644 --- a/src/components/AssignmentSummary.jsx +++ b/src/components/AssignmentSummary.jsx @@ -9,6 +9,7 @@ import Badge from 'material-ui/Badge' import moment from 'moment' import Divider from 'material-ui/Divider' import { withRouter } from 'react-router' +import { dataTest } from '../lib/attributes' const inlineStyles = { badge: { @@ -59,11 +60,12 @@ export class AssignmentSummary extends Component { } } - renderBadgedButton({ assignment, title, count, primary, disabled, contactsFilter, hideIfZero, style }) { + renderBadgedButton({ dataTestText, assignment, title, count, primary, disabled, contactsFilter, hideIfZero, style }) { if (count === 0 && hideIfZero) { return '' } if (count === 0) { return ( this.goToTodos(contactsFilter, assignment.id)} @@ -91,7 +94,7 @@ export class AssignmentSummary extends Component { const { title, description, hasUnassignedContacts, dueBy, primaryColor, logoImageUrl, introHtml, useDynamicAssignment } = assignment.campaign - + const maxContacts = assignment.maxContacts return (
{(window.NOT_IN_USA && window.ALLOW_SEND_ALL) ? '' : this.renderBadgedButton({ + dataTestText: 'sendFirstTexts', assignment, title: 'Send first texts', count: unmessagedCount, primary: true, - disabled: (useDynamicAssignment && !hasUnassignedContacts && unmessagedCount == 0) ? true : false, + disabled: (useDynamicAssignment && !hasUnassignedContacts && unmessagedCount == 0) || (useDynamicAssignment && maxContacts === 0), contactsFilter: 'text', hideIfZero: !useDynamicAssignment })} {(window.NOT_IN_USA && window.ALLOW_SEND_ALL) ? '' : this.renderBadgedButton({ + dataTestText: 'sendReplies', assignment, title: 'Send replies', count: unrepliedCount, diff --git a/src/components/AssignmentTexter.jsx b/src/components/AssignmentTexter.jsx index 4206e8d20..074a3b19b 100644 --- a/src/components/AssignmentTexter.jsx +++ b/src/components/AssignmentTexter.jsx @@ -34,13 +34,125 @@ class AssignmentTexter extends React.Component { super(props) this.state = { - currentContactIndex: 0, + // currentContactIndex: 0, + contactCache: {}, + loading: false, direction: 'right' } } + componentWillMount() { + this.updateCurrentContactIndex(0) + } + + componentWillUpdate(nextProps, nextState) { + if (this.contactCount() === 0) { + setTimeout(() => window.location.reload(), 5000) + } + + // When we send a message that changes the contact status, + // then if parent.refreshData is called, then props.contacts + // will return a new list with the last contact removed and + // presumably our currentContactIndex will be off. + // In fact, without the code below, we will 'double-jump' each message + // we send or change the status in some way. + // Below, we update our index with the contact that matches our current index. + if (typeof nextState.currentContactIndex !== 'undefined' + && nextState.currentContactIndex === this.state.currentContactIndex + && nextProps.contacts.length !== this.props.contacts.length + && this.props.contacts[this.state.currentContactIndex]) { + const curId = this.props.contacts[this.state.currentContactIndex].id + const nextIndex = nextProps.contacts.findIndex((c) => c.id === curId) + if (nextIndex !== nextState.currentContactIndex) { + // eslint-disable-next-line no-param-reassign + nextState.currentContactIndex = nextIndex + } + } + } + /* + getContactData is a place where we've hit scaling issues in the past, and will be important + to think carefully about for scaling considerations in the future. + + As p2ptextbanking work scales up, texters will contact more people, and so the number of + contacts in a campaign and the frequency at which we need to get contact data will increase. + + Previously, when the texter clicked 'next' from the texting screen, we'd load a list of contact metadata + to text next, and then as this data was rendered into the AssignmentTexter and AssignmentTexterContact + containers, we'd load up each contact in the list separately, doing an API call and database query + for each contact. For each of the O(n) contacts to text, in aggregate this yielded O(n^2) API calls and + database queries. + + This round of changes is a mostly client-side structural optimization that will make it so that + O(n) contacts to text results in O(n) queries. There will also be later rounds of server-side optimization. + + You'll also see references to "contact cache" below-- + this is different than a redis cache, and it's a reference to this component storing contact data in its state, + which is a form of in-memory client side caching. A blended set of strategies -- server-side optimization, + getting data from the data store in batches, and storing batches in the component that + is rendering this data-- working in concert will be key to achieving our scaling goals. + + In addition to getting all the contact data needed to text contacts at once instead of in a nested loop, + these changes get a batch of contacts at a time with a moving batch window. BATCH_GET is teh number of contacts + to get at a time, and BATCH_FORWARD is how much before the end of the batch window to prefetch the next batch. + + Example with BATCH_GET = 10 and BATCH_FORWARD = 5 : + - starting out in the contact list, we get contacts 0-9, and then get their associated data in batch + via this.props.loadContacts(getIds) + - texter starts texting through this list, texting contact 0, 1, 2, 3. we do not need to make any more + API or database calls because we're using data we already got and stored in this.state.contactCache + - when the texter gets to contact 4, contacts[newIndex + BATCH_FORWARD] is now false, and thts tells us we + should get the next batch, so the next 10 contacts (10-19) are loaded up into this.state.contactCache + - when the texter gets to contact 10, contact 10 has already been loaded up into this.state.contactCache and + so the texter wont likely experience a data loading delay + + getContactData runs when the user clicks the next arrow button on the contact screen. + + */ + getContactData = async (newIndex, force = false) => { + const { contacts } = this.props + const BATCH_GET = 10 // how many to get at once + const BATCH_FORWARD = 5 // when to reach out and get more + let getIds = [] + // if we don't have current data, get that + if (contacts[newIndex] + && !this.state.contactCache[contacts[newIndex].id]) { + getIds = contacts + .slice(newIndex, newIndex + BATCH_GET) + .map((c) => c.id) + .filter((cId) => !force || !this.state.contactCache[cId]) + } + // if we DO have current data, but don't have data base BATCH_FORWARD... + if (!getIds.length + && contacts[newIndex + BATCH_FORWARD] + && !this.state.contactCache[contacts[newIndex + BATCH_FORWARD].id]) { + getIds = contacts + .slice(newIndex + BATCH_FORWARD, newIndex + BATCH_FORWARD + BATCH_GET) + .map((c) => c.id) + .filter((cId) => !force || !this.state.contactCache[cId]) + } + + if (getIds.length) { + this.setState({ loading: true }) + const contactData = await this.props.loadContacts(getIds) + const { data: { getAssignmentContacts } } = contactData + if (getAssignmentContacts) { + const newContactData = {} + getAssignmentContacts.forEach((c) => { + newContactData[c.id] = c + }) + this.setState({ + loading: false, + contactCache: { ...this.state.contactCache, + ...newContactData } }) + } + } + } + getContact(contacts, index) { - return (contacts.length > index) ? contacts[index] : null + if (contacts.length > index) { + return contacts[index] + } + return null } incrementCurrentContactIndex = (increment) => { @@ -53,12 +165,7 @@ class AssignmentTexter extends React.Component { this.setState({ currentContactIndex: newIndex }) - } - - componentWillUpdate(nextProps, nextState) { - if (this.contactCount() === 0) { - setTimeout(() => window.location.reload(), 5000) - } + this.getContactData(newIndex) } hasPrevious() { @@ -71,30 +178,19 @@ class AssignmentTexter extends React.Component { handleFinishContact = () => { if (this.hasNext()) { - this.handleNavigateNextforSend() + this.handleNavigateNext() } else { // Will look async and then redirect to todo page if not this.props.assignContactsIfNeeded(/* checkServer*/true) } } - // handleNavigateNext was previously handled in one function but is separated to protect against behavior found in issue: - // https://github.com/MoveOnOrg/Spoke/issues/452 - handleNavigateNextforSend = () => { + handleNavigateNext = () => { if (!this.hasNext()) { return } - this.props.refreshData() - this.setState({ direction: 'right' }, () => this.incrementCurrentContactIndex(0)) - } - - handleNavigateNextforSkip = () => { - if (!this.hasNext()) { - return - } - - this.props.refreshData() + // this.props.refreshData() this.setState({ direction: 'right' }, () => this.incrementCurrentContactIndex(1)) } @@ -127,17 +223,16 @@ class AssignmentTexter extends React.Component { const { contacts } = this.props // If the index has got out of sync with the contacts available, then rewind to the start - if (this.getContact(contacts, this.state.currentContactIndex)) { + if (typeof this.state.currentContactIndex !== 'undefined') { return this.getContact(contacts, this.state.currentContactIndex) - } else { - this.updateCurrentContactIndex(0) - return this.getContact(contacts, 0) } + + this.updateCurrentContactIndex(0) + return this.getContact(contacts, 0) } renderNavigationToolbarChildren() { - const { allContacts } = this.props - const allContactsCount = allContacts.length + const { allContactsCount } = this.props const remainingContacts = this.contactCount() const messagedContacts = allContactsCount - remainingContacts @@ -158,13 +253,13 @@ class AssignmentTexter extends React.Component { disabled={!this.hasPrevious()} > - , + , - + ] } @@ -173,11 +268,24 @@ class AssignmentTexter extends React.Component { const { campaign, texter } = assignment const contact = this.currentContact() const navigationToolbarChildren = this.renderNavigationToolbarChildren() + const contactData = this.state.contactCache[contact.id] + if (!contactData) { + const self = this + setTimeout(() => { + if (self.state.contactCache[contact.id]) { + self.forceUpdate() + } else if (!self.state.loading) { + self.updateCurrentContactIndex(self.state.currentContactIndex) + } + }, 200) + return null + } return ( - )} - > - - + />)} + />
) } @@ -218,9 +323,11 @@ AssignmentTexter.propTypes = { currentUser: PropTypes.object, assignment: PropTypes.object, // current assignment contacts: PropTypes.array, // contacts for current assignment - allContacts: PropTypes.array, + allContactsCount: PropTypes.number, router: PropTypes.object, refreshData: PropTypes.func, + loadContacts: PropTypes.func, + assignContactsIfNeeded: PropTypes.func, organizationId: PropTypes.string } diff --git a/src/components/CampaignCannedResponsesForm.jsx b/src/components/CampaignCannedResponsesForm.jsx index fe7df3bee..12739bac0 100644 --- a/src/components/CampaignCannedResponsesForm.jsx +++ b/src/components/CampaignCannedResponsesForm.jsx @@ -86,6 +86,7 @@ export default class CampaignCannedResponsesForm extends React.Component { listItems(cannedResponses) { const listItems = cannedResponses.map((response) => ( {interactionStep.parentInteractionId ? {interactionStep.questionText && interactionStep.script && (!interactionStep.parentInteractionId || interactionStep.answerOption) ?
{ const messagedCount = texter.assignment.contactsCount - texter.assignment.needsMessageCount return ( -
+
{this.getDisplayName(texter.id)}
( -
+
{React.cloneElement(icon, { style: inlineStyles.icon })}
{title} diff --git a/src/components/Navigation.jsx b/src/components/Navigation.jsx index 1cd4dc47a..b7acb528c 100644 --- a/src/components/Navigation.jsx +++ b/src/components/Navigation.jsx @@ -5,7 +5,7 @@ import { List, ListItem } from 'material-ui/List' import Divider from 'material-ui/Divider' import { withRouter } from 'react-router' import _ from 'lodash' -import { dataTest } from '../lib/attributes' +import { dataTest, camelCase } from '../lib/attributes' import { FlatButton } from 'material-ui' import { StyleSheet, css } from 'aphrodite' diff --git a/src/components/ScriptEditor.jsx b/src/components/ScriptEditor.jsx index ef5a33e8f..78b1ccf38 100644 --- a/src/components/ScriptEditor.jsx +++ b/src/components/ScriptEditor.jsx @@ -176,7 +176,6 @@ class ScriptEditor extends React.Component {
this.props.router.push(`/admin/${organizationId}/campaigns/${campaignId}/edit`)} label='Edit' /> @@ -212,6 +214,7 @@ class AdminCampaignStats extends React.Component { /> : null), ( // copy await this.props.mutations.copyCampaign(this.props.params.campaignId)} />) diff --git a/src/containers/AdminIncomingMessageList.jsx b/src/containers/AdminIncomingMessageList.jsx index 02d9a533e..f0b408bc0 100644 --- a/src/containers/AdminIncomingMessageList.jsx +++ b/src/containers/AdminIncomingMessageList.jsx @@ -70,6 +70,7 @@ export class AdminIncomingMessageList extends Component { reassignmentTexters: [], campaignTexters: [], includeArchivedCampaigns: false, + conversationCount: 0, includeActiveCampaigns: true, conversationCount: 0, includeNotOptedOutConversations: true, @@ -104,6 +105,7 @@ export class AdminIncomingMessageList extends Component { this.handleOptedOutConversationsToggled = this.handleOptedOutConversationsToggled.bind( this ) + this.conversationCountChanged = this.conversationCountChanged.bind(this) } shouldComponentUpdate(dummy, nextState) { diff --git a/src/containers/AdminReplySender.jsx b/src/containers/AdminReplySender.jsx index fbd45bf58..eaf5be3d3 100644 --- a/src/containers/AdminReplySender.jsx +++ b/src/containers/AdminReplySender.jsx @@ -8,6 +8,7 @@ import GSForm from '../components/forms/GSForm' import wrapMutations from './hoc/wrap-mutations' import Form from 'react-formal' import yup from 'yup' +import { dataTest } from '../lib/attributes' const styles = StyleSheet.create({ infoContainer: { @@ -75,12 +76,14 @@ class AdminReplySender extends React.Component { }} > 0 ? availableSteps[availableSteps.length - 1] : null } this.onEnter = this.onEnter.bind(this) + this.setDisabled = this.setDisabled.bind(this) } componentDidMount() { - const { contact } = this.props.data + const { contact } = this.props if (contact.optOut) { this.skipContact() } else if (!this.isContactBetweenTextingHours(contact)) { @@ -257,6 +259,10 @@ export class AssignmentTexterContact extends React.Component { } } + setDisabled = async (disabled = true) => { + this.setState({ disabled }) + } + getAvailableInteractionSteps(questionResponses) { const allInteractionSteps = this.props.campaign.interactionSteps const availableSteps = [] @@ -290,8 +296,7 @@ export class AssignmentTexterContact extends React.Component { return questionResponses } getMessageTextFromScript(script) { - const { data, campaign, texter } = this.props - const { contact } = data + const { campaign, contact, texter } = this.props return script ? applyScript({ contact, @@ -302,9 +307,9 @@ export class AssignmentTexterContact extends React.Component { } getStartingMessageText() { - const { contact } = this.props.data + const { contact, campaign } = this.props const { messages } = contact - return messages.length > 0 ? '' : this.getMessageTextFromScript(contact.currentInteractionStepScript) + return messages.length > 0 ? '' : this.getMessageTextFromScript(getTopMostParent(campaign.interactionSteps).script) } handleOpenPopover = (event) => { @@ -327,7 +332,7 @@ export class AssignmentTexterContact extends React.Component { createMessageToContact(text) { const { texter, assignment } = this.props - const { contact } = this.props.data + const { contact } = this.props return { contactNumber: contact.cell, @@ -370,13 +375,9 @@ export class AssignmentTexterContact extends React.Component { } } - setDisabled = async (disabled = true) => { - this.setState({ disabled }) - } - handleMessageFormSubmit = async ({ messageText }) => { try { - const { contact } = this.props.data + const { contact } = this.props const message = this.createMessageToContact(messageText) if (this.state.disabled) { return // stops from multi-send @@ -392,7 +393,7 @@ export class AssignmentTexterContact extends React.Component { } handleSubmitSurveys = async () => { - const { contact } = this.props.data + const { contact } = this.props const deletionIds = [] const questionResponseObjects = [] @@ -429,13 +430,13 @@ export class AssignmentTexterContact extends React.Component { } handleEditMessageStatus = async (messageStatus) => { - const { contact } = this.props.data + const { contact } = this.props await this.props.mutations.editCampaignContactMessageStatus(messageStatus, contact.id) } handleOptOut = async () => { const optOutMessageText = this.state.optOutMessageText - const { contact } = this.props.data + const { contact } = this.props const { assignment } = this.props const message = this.createMessageToContact(optOutMessageText) if (this.state.disabled) { @@ -497,7 +498,7 @@ export class AssignmentTexterContact extends React.Component { handleClickSendMessageButton = () => { this.refs.form.submit() - if (this.props.data.contact.messageStatus === 'needsMessage') { + if (this.props.contact.messageStatus === 'needsMessage') { this.setState({ justSentNew: true }) } } @@ -512,9 +513,9 @@ export class AssignmentTexterContact extends React.Component { timezoneData = { hasDST, offset } } else { - let location = getContactTimezone(this.props.campaign, contact.location) + const location = getContactTimezone(this.props.campaign, contact.location) if (location) { - let timezone = location.timezone + const timezone = location.timezone if (timezone) { timezoneData = timezone } @@ -555,7 +556,7 @@ export class AssignmentTexterContact extends React.Component { handleMessageFormChange = ({ messageText }) => this.setState({ messageText }) renderMiddleScrollingSection() { - const { contact } = this.props.data + const { contact } = this.props return ( } hideMobile - > ) : ( + />) : (
: ''}
) : '' } -
+
{this.renderTopFixedSection()}
@@ -893,57 +895,12 @@ AssignmentTexterContact.propTypes = { navigationToolbarChildren: PropTypes.array, onFinishContact: PropTypes.func, router: PropTypes.object, - data: PropTypes.object, mutations: PropTypes.object, + refreshData: PropTypes.func, onExitTexter: PropTypes.func, onRefreshAssignmentContacts: PropTypes.func } -const mapQueriesToProps = ({ ownProps }) => ({ - data: { - // These fields are needed for possibly filling in script values - query: gql`query getContact($campaignContactId: String!) { - contact(id: $campaignContactId) { - id - assignmentId - firstName - lastName - cell - zip - customFields - optOut { - id - createdAt - } - currentInteractionStepScript - questionResponseValues { - interactionStepId - value - } - location { - city - state - timezone { - offset - hasDST - } - } - messageStatus - messages { - id - createdAt - text - isFromContact - } - } - }`, - variables: { - campaignContactId: ownProps.campaignContactId - }, - forceFetch: true - } -}) - const mapMutationsToProps = () => ({ createOptOut: (optOut, campaignContactId) => ({ mutation: gql` @@ -1038,6 +995,5 @@ const mapMutationsToProps = () => ({ export default loadData(wrapMutations( withRouter(AssignmentTexterContact)), { - mapQueriesToProps, mapMutationsToProps }) diff --git a/src/containers/CampaignList.jsx b/src/containers/CampaignList.jsx index cc365fc40..a7a697e01 100644 --- a/src/containers/CampaignList.jsx +++ b/src/containers/CampaignList.jsx @@ -14,7 +14,7 @@ import Chip from '../components/Chip' import loadData from './hoc/load-data' import wrapMutations from './hoc/wrap-mutations' import Empty from '../components/Empty' - +import { dataTest } from '../lib/attributes' const campaignInfoFragment = ` id @@ -94,8 +94,8 @@ class CampaignList extends React.Component { {campaign.description}
{dueByMoment.isValid() ? - dueByMoment.format('MMM D, YYYY') : - 'No due date set'} + dueByMoment.format('MMM D, YYYY') : + 'No due date set'} ) @@ -104,6 +104,7 @@ class CampaignList extends React.Component { const campaignUrl = `/admin/${this.props.organizationId}/campaigns/${campaign.id}` return ( this.props.mutations.unarchiveCampaign(campaign.id)} - > - - - ) : ( - this.props.mutations.archiveCampaign(campaign.id)} - > - - - )) : null} + (campaign.isArchived ? ( + this.props.mutations.unarchiveCampaign(campaign.id)} + > + + + ) : ( + this.props.mutations.archiveCampaign(campaign.id)} + > + + + )) : null} /> ) } @@ -140,10 +141,10 @@ class CampaignList extends React.Component { icon={} /> ) : ( - - {campaigns.campaigns.map((campaign) => this.renderRow(campaign))} - - ) + + {campaigns.campaigns.map((campaign) => this.renderRow(campaign))} + + ) } } diff --git a/src/containers/Settings.jsx b/src/containers/Settings.jsx index 723e9aa8b..2a945c808 100644 --- a/src/containers/Settings.jsx +++ b/src/containers/Settings.jsx @@ -54,11 +54,9 @@ class Settings extends React.Component { handleCloseTextingHoursDialog = () => this.setState({ textingHoursDialogOpen: false }) - renderTextingHoursForm() { const { organization } = this.props.data const { textingHoursStart, textingHoursEnd } = organization - const formSchema = yup.object({ textingHoursStart: yup.number().required(), textingHoursEnd: yup.number().required() @@ -242,6 +240,7 @@ const mapMutationsToProps = ({ ownProps }) => ({ optOutMessage } }) + }) const mapQueriesToProps = ({ ownProps }) => ({ @@ -253,6 +252,7 @@ const mapQueriesToProps = ({ ownProps }) => ({ textingHoursEnforced textingHoursStart textingHoursEnd + optOutMessage } }`, variables: { diff --git a/src/containers/TexterTodo.jsx b/src/containers/TexterTodo.jsx index d16713158..755a9aa4b 100644 --- a/src/containers/TexterTodo.jsx +++ b/src/containers/TexterTodo.jsx @@ -5,7 +5,46 @@ import { withRouter } from 'react-router' import loadData from './hoc/load-data' import gql from 'graphql-tag' +const contactDataFragment = ` + id + assignmentId + firstName + lastName + cell + zip + customFields + optOut { + id + } + questionResponseValues { + interactionStepId + value + } + location { + city + state + timezone { + offset + hasDST + } + } + messageStatus + messages { + id + createdAt + text + isFromContact + } +` + class TexterTodo extends React.Component { + constructor() { + super() + this.assignContactsIfNeeded = this.assignContactsIfNeeded.bind(this) + this.refreshData = this.refreshData.bind(this) + this.loadContacts = this.loadContacts.bind(this) + } + componentWillMount() { const { assignment } = this.props.data this.assignContactsIfNeeded() @@ -18,7 +57,7 @@ class TexterTodo extends React.Component { assignContactsIfNeeded = async (checkServer = false) => { const { assignment } = this.props.data - if (assignment.contacts.length == 0 || checkServer) { + if (assignment.contacts.length === 0 || checkServer) { if (assignment.campaign.useDynamicAssignment) { const didAddContacts = (await this.props.mutations.findNewCampaignContact(assignment.id, 1)).data.findNewCampaignContact.found if (didAddContacts) { @@ -32,6 +71,10 @@ class TexterTodo extends React.Component { } } + loadContacts = async (contactIds) => ( + await this.props.mutations.getAssignmentContacts(contactIds) + ) + refreshData = () => { this.props.data.refetch() } @@ -39,14 +82,15 @@ class TexterTodo extends React.Component { render() { const { assignment } = this.props.data const contacts = assignment.contacts - const allContacts = assignment.allContacts + const allContactsCount = assignment.allContactsCount return ( @@ -59,6 +103,7 @@ TexterTodo.propTypes = { messageStatus: PropTypes.string, params: PropTypes.object, data: PropTypes.object, + mutations: PropTypes.object, router: PropTypes.object } @@ -95,10 +140,12 @@ const mapQueriesToProps = ({ ownProps }) => ({ textingHoursStart textingHoursEnd threeClickEnabled + optOutMessage } customFields interactionSteps { id + script question { text answerOptions { @@ -113,11 +160,8 @@ const mapQueriesToProps = ({ ownProps }) => ({ } contacts(contactsFilter: $contactsFilter) { id - customFields - } - allContacts: contacts { - id } + allContactsCount: contactsCount } }`, variables: { @@ -128,11 +172,12 @@ const mapQueriesToProps = ({ ownProps }) => ({ }, assignmentId: ownProps.params.assignmentId }, - forceFetch: true + forceFetch: true, + pollInterval: 20000 } }) -const mapMutationsToProps = () => ({ +const mapMutationsToProps = ({ ownProps }) => ({ findNewCampaignContact: (assignmentId, numberContacts = 1) => ({ mutation: gql` mutation findNewCampaignContact($assignmentId: String!, $numberContacts: Int!) { @@ -145,6 +190,20 @@ const mapMutationsToProps = () => ({ assignmentId, numberContacts } + }), + getAssignmentContacts: (contactIds, findNew) => ({ + mutation: gql` + mutation getAssignmentContacts($assignmentId: String!, $contactIds: [String]!, $findNew: Boolean) { + getAssignmentContacts(assignmentId: $assignmentId, contactIds: $contactIds, findNew: $findNew) { + ${contactDataFragment} + } + } + `, + variables: { + assignmentId: ownProps.params.assignmentId, + contactIds, + findNew: !!findNew + } }) }) diff --git a/src/containers/TexterTodoList.jsx b/src/containers/TexterTodoList.jsx index 427ae856b..7f6abd25b 100644 --- a/src/containers/TexterTodoList.jsx +++ b/src/containers/TexterTodoList.jsx @@ -95,6 +95,7 @@ const mapQueriesToProps = ({ ownProps }) => ({ primaryColor logoImageUrl } + maxContacts unmessagedCount: contactsCount(contactsFilter: $needsMessageFilter) unrepliedCount: contactsCount(contactsFilter: $needsResponseFilter) badTimezoneCount: contactsCount(contactsFilter: $badTimezoneFilter) diff --git a/src/containers/UserEdit.jsx b/src/containers/UserEdit.jsx index 08c4f4f19..9ac375d1c 100644 --- a/src/containers/UserEdit.jsx +++ b/src/containers/UserEdit.jsx @@ -11,6 +11,7 @@ import yup from 'yup' import FlatButton from 'material-ui/FlatButton' import RaisedButton from 'material-ui/RaisedButton' +import { dataTest } from '../lib/attributes' class UserEdit extends React.Component { @@ -49,10 +50,10 @@ class UserEdit extends React.Component { onSubmit={this.handleSave} defaultValue={user} > - - - - + + + + @@ -102,6 +104,7 @@ class UserMenu extends Component { > @@ -129,6 +133,7 @@ class UserMenu extends Component { /> diff --git a/src/lib/attributes.js b/src/lib/attributes.js index 8825cd2a6..614f0930a 100644 --- a/src/lib/attributes.js +++ b/src/lib/attributes.js @@ -1,5 +1,11 @@ // Used to generate data-test attributes on non-production environments and used by end-to-end tests -export const dataTest = (value) => { - const attribute = window.NODE_ENV !== 'production' ? { 'data-test': value } : {} +export const dataTest = (value, disable) => { + const attribute = (window.NODE_ENV !== 'production' && !disable) ? { 'data-test': value } : {} return attribute } + +export const camelCase = str => { + return str.replace(/(?:^\w|[A-Z]|\b\w)/g, (letter, index) => { + return index == 0 ? letter.toLowerCase() : letter.toUpperCase() + }).replace(/\s+/g, '') +} diff --git a/src/server/api/assignment.js b/src/server/api/assignment.js index 311010ed3..2a5d4ef0a 100644 --- a/src/server/api/assignment.js +++ b/src/server/api/assignment.js @@ -1,20 +1,20 @@ import { mapFieldsToModel } from './lib/utils' -import { Assignment, r } from '../models' +import { Assignment, r, cacheableData } from '../models' import { getOffsets, defaultTimezoneIsBetweenTextingHours } from '../../lib' export function addWhereClauseForContactsFilterMessageStatusIrrespectiveOfPastDue( queryParameter, - contactsFilter + messageStatusFilter ) { - if (!contactsFilter || !('messageStatus' in contactsFilter)) { + if (!messageStatusFilter) { return queryParameter } let query = queryParameter - if (contactsFilter.messageStatus === 'needsMessageOrResponse') { + if (messageStatusFilter === 'needsMessageOrResponse') { query.whereIn('message_status', ['needsResponse', 'needsMessage']) } else { - query = query.whereIn('message_status', contactsFilter.messageStatus.split(',')) + query = query.whereIn('message_status', messageStatusFilter.split(',')) } return query } @@ -26,6 +26,7 @@ export function getContacts(assignment, contactsFilter, organization, campaign, const textingHoursEnd = organization.texting_hours_end // 24-hours past due - why is this 24 hours offset? + const includePastDue = (contactsFilter && contactsFilter.includePastDue) const pastDue = (campaign.due_by && Number(campaign.due_by) + 24 * 60 * 60 * 1000 < Number(new Date())) const config = { textingHoursStart, textingHoursEnd, textingHoursEnforced } @@ -40,6 +41,9 @@ export function getContacts(assignment, contactsFilter, organization, campaign, } const [validOffsets, invalidOffsets] = getOffsets(config) + if (!includePastDue && pastDue && contactsFilter && contactsFilter.messageStatus === 'needsMessage') { + return [] + } let query = r.knex('campaign_contact').where({ assignment_id: assignment.id @@ -63,32 +67,15 @@ export function getContacts(assignment, contactsFilter, organization, campaign, } } - const includePastDue = contactsFilter.includePastDue - - if (includePastDue && contactsFilter.messageStatus) { - query = addWhereClauseForContactsFilterMessageStatusIrrespectiveOfPastDue( - query, - contactsFilter - ) - } else { - if (contactsFilter.messageStatus) { - if (pastDue && contactsFilter.messageStatus === 'needsMessage') { - query = query.where('message_status', '') - } else if (contactsFilter.messageStatus === 'needsMessageOrResponse') { - query.whereIn('message_status', ['needsResponse', 'needsMessage']) - } else { - query = query.whereIn('message_status', contactsFilter.messageStatus.split(',')) - } - } else { - if (pastDue) { - // by default if asking for 'send later' contacts we include only those that need replies - query = query.where('message_status', 'needsResponse') - } else { - // we do not want to return closed/messaged - query.whereIn('message_status', ['needsResponse', 'needsMessage']) - } - } - } + query = addWhereClauseForContactsFilterMessageStatusIrrespectiveOfPastDue( + query, + ((contactsFilter && contactsFilter.messageStatus) || + (pastDue + // by default if asking for 'send later' contacts we include only those that need replies + ? 'needsResponse' + // we do not want to return closed/messaged + : 'needsMessageOrResponse')) + ) if (Object.prototype.hasOwnProperty.call(contactsFilter, 'isOptedOut')) { query = query.where('is_opted_out', contactsFilter.isOptedOut) @@ -109,7 +96,11 @@ export function getContacts(assignment, contactsFilter, organization, campaign, export const resolvers = { Assignment: { ...mapFieldsToModel(['id', 'maxContacts'], Assignment), - texter: async (assignment, _, { loaders }) => loaders.user.load(assignment.user_id), + texter: async (assignment, _, { loaders }) => ( + assignment.texter + ? assignment.texter + : loaders.user.load(assignment.user_id) + ), campaign: async (assignment, _, { loaders }) => loaders.campaign.load(assignment.campaign_id), contactsCount: async (assignment, { contactsFilter }) => { const campaign = await r.table('campaign').get(assignment.campaign_id) @@ -125,14 +116,14 @@ export const resolvers = { return getContacts(assignment, contactsFilter, organization, campaign) }, campaignCannedResponses: async assignment => - await r - .table('canned_response') - .getAll(assignment.campaign_id, { index: 'campaign_id' }) - .filter({ user_id: '' }), + await cacheableData.cannedResponse.query({ + userId: '', + campaignId: assignment.campaign_id + }), userCannedResponses: async assignment => - await r - .table('canned_response') - .getAll(assignment.campaign_id, { index: 'campaign_id' }) - .filter({ user_id: assignment.user_id }) + await cacheableData.cannedResponse.query({ + userId: assignment.user_id, + campaignId: assignment.campaign_id + }) } } diff --git a/src/server/api/campaign-contact.js b/src/server/api/campaign-contact.js index b1147698b..c523f6f7f 100644 --- a/src/server/api/campaign-contact.js +++ b/src/server/api/campaign-contact.js @@ -1,4 +1,4 @@ -import { CampaignContact, r } from '../models' +import { CampaignContact, r, cacheableData } from '../models' import { mapFieldsToModel } from './lib/utils' import { log, getTopMostParent, zipToTimeZone } from '../../lib' @@ -24,7 +24,12 @@ export const resolvers = { 'assignmentId', 'external_id' ], CampaignContact), - + messageStatus: async (campaignContact, _, { loaders }) => { + if (campaignContact.message_status) { + return campaignContact.message_status + } + // TODO: look it up via cacheing + }, campaign: async (campaignContact, _, { loaders }) => ( loaders.campaign.load(campaignContact.campaign_id) ), @@ -105,10 +110,16 @@ export const resolvers = { // couldn't look up the timezone by zip record, so we load it // from the campaign_contact directly if it's there const [offset, hasDst] = campaignContact.timezone_offset.split('_') - return { + const loc = { timezone_offset: parseInt(offset, 10), has_dst: (hasDst === '1') } + // From cache + if (campaignContact.city) { + loc.city = campaignContact.city + loc.state = campaignContact.state || undefined + } + return loc } const mainZip = campaignContact.zip.split('-')[0] const calculated = zipToTimeZone(mainZip) @@ -139,32 +150,29 @@ export const resolvers = { return messages }, optOut: async (campaignContact, _, { loaders }) => { - if ('opt_out_cell' in campaignContact) { return { cell: campaignContact.opt_out_cell } } else { - const campaign = await loaders.campaign.load(campaignContact.campaign_id) + let isOptedOut = null + if (typeof campaignContact.is_opted_out !== 'undefined') { + isOptedOut = campaignContact.is_opted_out + } else { + let organizationId = campaignContact.organization_id + if (!organizationId) { + const campaign = await loaders.campaign.load(campaignContact.campaign_id) + organizationId = campaign.organization_id + } - return r.table('opt_out') - .getAll(campaignContact.cell, { index: 'cell' }) - .filter({ organization_id: campaign.organization_id }) - .limit(1)(0) - .default(null) + const isOptedOut = await cacheableData.optOut.query({ + cell: campaignContact.cell, + organizationId + }) + } + // fake ID so we don't need to look up existance + return (isOptedOut ? { id: 'optout' } : null) } - }, - currentInteractionStepId: async (campaignContact) => { - const steps = await r.table('interaction_step') - .getAll(campaignContact.campaign_id, { index: 'campaign_id' }) - .filter({ is_deleted: false }) - return getTopMostParent(steps, true).id - }, - currentInteractionStepScript: async (campaignContact) => { - const steps = await r.table('interaction_step') - .getAll(campaignContact.campaign_id, { index: 'campaign_id' }) - .filter({ is_deleted: false }) - return getTopMostParent(steps, true).script } } } diff --git a/src/server/api/campaign.js b/src/server/api/campaign.js index 7255ea6f4..4a0097c0e 100644 --- a/src/server/api/campaign.js +++ b/src/server/api/campaign.js @@ -1,13 +1,11 @@ import { mapFieldsToModel } from './lib/utils' -import { Campaign, JobRequest, r } from '../models' -import { getUsers } from './user'; +import { Campaign, JobRequest, r, cacheableData } from '../models' import { currentEditors } from '../models/cacheable_queries' +import { getUsers } from './user'; export function addCampaignsFilterToQuery(queryParam, campaignsFilter) { let query = queryParam - const resultSize = (campaignsFilter.listSize ? campaignsFilter.listSize : 0) - const pageSize = (campaignsFilter.pageSize ? campaignsFilter.pageSize : 0) if (campaignsFilter) { const resultSize = (campaignsFilter.listSize ? campaignsFilter.listSize : 0) @@ -151,7 +149,6 @@ export const resolvers = { 'id', 'title', 'description', - 'dueBy', 'isStarted', 'isArchived', 'useDynamicAssignment', @@ -164,8 +161,14 @@ export const resolvers = { 'textingHoursEnd', 'timezone' ], Campaign), + dueBy: (campaign) => ( + (campaign.due_by instanceof Date || !campaign.due_by) + ? campaign.due_by || null + : new Date(campaign.due_by) + ), organization: async (campaign, _, { loaders }) => ( - loaders.organization.load(campaign.organization_id) + campaign.organization + || loaders.organization.load(campaign.organization_id) ), datawarehouseAvailable: (campaign, _, { user }) => ( user.is_superadmin && !!process.env.WAREHOUSE_DB_HOST @@ -185,14 +188,14 @@ export const resolvers = { return query }, interactionSteps: async (campaign) => ( - r.table('interaction_step') - .getAll(campaign.id, { index: 'campaign_id' }) - .filter({ is_deleted: false }) + campaign.interactionSteps + || cacheableData.campaign.dbInteractionSteps(campaign.id) ), cannedResponses: async (campaign, { userId }) => ( - r.table('canned_response') - .getAll(campaign.id, { index: 'campaign_id' }) - .filter({ user_id: userId || '' }) + await cacheableData.cannedResponse.query({ + userId: userId || '', + campaignId: campaign.id + }) ), contacts: async (campaign) => ( r.knex('campaign_contact') diff --git a/src/server/api/canned-response.js b/src/server/api/canned-response.js index 4b8ebd0a8..ac5c70434 100644 --- a/src/server/api/canned-response.js +++ b/src/server/api/canned-response.js @@ -8,9 +8,7 @@ export const resolvers = { 'title', 'text' ], CannedResponse), - isUserCreated: (cannedResponse) => cannedResponse.user_id !== '', - campaign: (cannedResponse, _, { loaders }) => (loaders.campaign.load(cannedResponse.campaign_id)), - user: (cannedResponse, _, { loaders }) => (loaders.user.load(cannedResponse.user_id)) + isUserCreated: (cannedResponse) => cannedResponse.user_id !== '' } } diff --git a/src/server/api/conversations.js b/src/server/api/conversations.js index 8294684df..4c9326bc5 100644 --- a/src/server/api/conversations.js +++ b/src/server/api/conversations.js @@ -24,7 +24,9 @@ function getConversationsJoinsAndWhereClause( query = query.where({ 'assignment.user_id': assignmentsFilter.texterId }) } - query = addWhereClauseForContactsFilterMessageStatusIrrespectiveOfPastDue(query, contactsFilter) + query = addWhereClauseForContactsFilterMessageStatusIrrespectiveOfPastDue( + query, + contactsFilter && contactsFilter.messageStatus) if (contactsFilter && 'isOptedOut' in contactsFilter) { const subQuery = (r.knex.select('cell') diff --git a/src/server/api/interaction-step.js b/src/server/api/interaction-step.js index 4a406c84c..3f5dc473c 100644 --- a/src/server/api/interaction-step.js +++ b/src/server/api/interaction-step.js @@ -9,14 +9,10 @@ export const resolvers = { 'answerOption', 'answerActions', 'parentInteractionId', - 'question', 'isDeleted' ], InteractionStep), questionText: async(interactionStep) => { - const interaction = await r.table('interaction_step') - .get(interactionStep.id) - - return interaction.question + return interactionStep.question }, question: async (interactionStep) => interactionStep, questionResponse: async (interactionStep, { campaignContactId }) => ( diff --git a/src/server/api/organization.js b/src/server/api/organization.js index f95d304de..8ddfd5913 100644 --- a/src/server/api/organization.js +++ b/src/server/api/organization.js @@ -32,6 +32,7 @@ export const resolvers = { }, threeClickEnabled: (organization) => organization.features.indexOf('threeClick') !== -1, textingHoursEnforced: (organization) => organization.texting_hours_enforced, + optOutMessage: (organization) => (organization.features && organization.features.indexOf('opt_out_message') !== -1 ? JSON.parse(organization.features).opt_out_message : process.env.OPT_OUT_MESSAGE) || 'I\'m opting you out of texts immediately. Have a great day.', textingHoursStart: (organization) => organization.texting_hours_start, textingHoursEnd: (organization) => organization.texting_hours_end } diff --git a/src/server/api/schema.js b/src/server/api/schema.js index 081cf024e..ff8b1d65d 100644 --- a/src/server/api/schema.js +++ b/src/server/api/schema.js @@ -3,6 +3,7 @@ import GraphQLDate from 'graphql-date' import GraphQLJSON from 'graphql-type-json' import { GraphQLError } from 'graphql/error' import isUrl from 'is-url' +import { organizationCache } from '../models/cacheable_queries/organization' import { gzip, log, makeTree } from '../../lib' import { applyScript } from '../../lib/scripts' @@ -16,12 +17,12 @@ import { Invite, JobRequest, Message, - OptOut, Organization, QuestionResponse, - r, User, - UserOrganization + UserOrganization, + r, + cacheableData } from '../models' // import { isBetweenTextingHours } from '../../lib/timezones' import { Notifications, sendUserNotification } from '../notifications' @@ -53,6 +54,7 @@ import { resolvers as questionResolvers } from './question' import { resolvers as questionResponseResolvers } from './question-response' import { getUsers, resolvers as userResolvers } from './user' + import { getSendBeforeTimeUtc } from '../../lib/timezones' const uuidv4 = require('uuid').v4 @@ -163,18 +165,6 @@ async function editCampaign(id, campaign, loaders, user, origCampaignRecord) { assignTexters(job) } } - - // assign the maxContacts - campaign.texters.forEach(async texter => { - const dog = r - .knex('campaign') - .where({ id }) - .select('useDynamicAssignment') - await r - .knex('assignment') - .where({ user_id: texter.id, campaign_id: id }) - .update({ max_contacts: texter.maxContacts ? texter.maxContacts : null }) - }) } if (campaign.hasOwnProperty('interactionSteps')) { @@ -201,9 +191,14 @@ async function editCampaign(id, campaign, loaders, user, origCampaignRecord) { .filter({ user_id: '' }) .delete() await CannedResponse.save(convertedResponses) + await cacheableData.cannedResponse.clearQuery({ + userId: '', + campaignId: id + }) } const newCampaign = await Campaign.get(id).update(campaignUpdates) + cacheableData.campaign.reload(id) return newCampaign || loaders.campaign.load(id) } @@ -475,19 +470,38 @@ const rootMutations = { texting_hours_start: textingHoursStart, texting_hours_end: textingHoursEnd }) + cacheableData.organization.clear(organizationId) return await Organization.get(organizationId) }, updateTextingHoursEnforcement: async ( _, { organizationId, textingHoursEnforced }, - { user } + { user, loaders } ) => { await accessRequired(user, organizationId, 'SUPERVOLUNTEER') await Organization.get(organizationId).update({ texting_hours_enforced: textingHoursEnforced }) + await cacheableData.organization.clear(organizationId) + + return await loaders.organization.load(organizationId) + }, + updateOptOutMessage: async ( + _, + { organizationId, optOutMessage }, + { user } + ) => { + await accessRequired(user, organizationId, 'OWNER') + + const organization = await Organization.get(organizationId) + const featuresJSON = JSON.parse(organization.features || '{}') + featuresJSON.opt_out_message = optOutMessage + organization.features = JSON.stringify(featuresJSON) + + await organization.save() + await organizationCache.clear(organizationId) return await Organization.get(organizationId) }, @@ -592,6 +606,7 @@ const rootMutations = { await accessRequired(user, campaign.organization_id, 'ADMIN') campaign.is_archived = false await campaign.save() + cacheableData.campaign.reload(id) return campaign }, archiveCampaign: async (_, { id }, { user, loaders }) => { @@ -599,13 +614,16 @@ const rootMutations = { await accessRequired(user, campaign.organization_id, 'ADMIN') campaign.is_archived = true await campaign.save() + cacheableData.campaign.reload(id) return campaign }, startCampaign: async (_, { id }, { user, loaders }) => { const campaign = await loaders.campaign.load(id) await accessRequired(user, campaign.organization_id, 'ADMIN') campaign.is_started = true + await campaign.save() + cacheableData.campaign.reload(id) await sendUserNotification({ type: Notifications.CAMPAIGN_STARTED, campaignId: id @@ -664,6 +682,10 @@ const rootMutations = { .andWhere({ user_id: cannedResponse.userId }) .del() await query + cacheableData.cannedResponse.clearQuery({ + campaignId: cannedResponse.campaignId, + userId: cannedResponse.userId + }) }, createOrganization: async (_, { name, userId, inviteId }, { loaders, user }) => { authRequired(user) @@ -706,7 +728,21 @@ const rootMutations = { contact.message_status = messageStatus return await contact.save() }, - + getAssignmentContacts: async (_, { assignmentId, contactIds, findNew }, { loaders, user }) => { + await assignmentRequired(user, assignmentId) + const contacts = contactIds.map(async (contactId) => { + const contact = await loaders.campaignContact.load(contactId) + if (contact && contact.assignment_id === Number(assignmentId)) { + return contact + } + return null + }) + if (findNew) { + // maybe TODO: we could automatically add dynamic assignments in the same api call + // findNewCampaignContact() + } + return contacts + }, findNewCampaignContact: async (_, { assignmentId, numberContacts }, { loaders, user }) => { /* This attempts to find a new contact for the assignment, in the case that useDynamicAssigment == true */ const assignment = await Assignment.get(assignmentId) @@ -717,7 +753,7 @@ const rootMutations = { }) } const campaign = await Campaign.get(assignment.campaign_id) - if (!campaign.use_dynamic_assignment) { + if (!campaign.use_dynamic_assignment || assignment.max_contacts === 0) { return { found: false } } @@ -770,37 +806,18 @@ const rootMutations = { await assignmentRequired(user, contact.assignment_id) const { assignmentId, cell, reason } = optOut + let organizationId = contact.organization_id - const campaign = await r - .table('assignment') - .get(assignmentId) - .eqJoin('campaign_id', r.table('campaign'))('right') - await new OptOut({ - assignment_id: assignmentId, - organization_id: campaign.organization_id, - reason_code: reason, - cell - }).save() - - // update all organization's active campaigns as well - await r - .knex('campaign_contact') - .where( - 'id', - 'in', - r - .knex('campaign_contact') - .leftJoin('campaign', 'campaign_contact.campaign_id', 'campaign.id') - .where({ - 'campaign_contact.cell': cell, - 'campaign.organization_id': campaign.organization_id, - 'campaign.is_archived': false - }) - .select('campaign_contact.id') - ) - .update({ - is_opted_out: true - }) + if (!organizationId) { + const campaign = await loaders.campaign.load(contact.campaign_id) + organizationId = campaign.organization_id + } + await cacheableData.optOut.save({ + cell, + reason, + assignmentId, + organizationId + }) return loaders.campaignContact.load(campaignContactId) }, diff --git a/src/server/index.js b/src/server/index.js index a8f070c9a..55a7375b4 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -45,6 +45,9 @@ if (!process.env.SUPPRESS_DATABASE_AUTOCREATE) { if (didCreate && !process.env.SUPPRESS_SEED_CALLS) { seedZipCodes() } + if (!didCreate && !process.env.SUPPRESS_MIGRATIONS) { + runMigrations() + } }) } else if (!process.env.SUPPRESS_MIGRATIONS) { runMigrations() diff --git a/src/server/middleware/render-index.js b/src/server/middleware/render-index.js index 082abd652..2129c849f 100644 --- a/src/server/middleware/render-index.js +++ b/src/server/middleware/render-index.js @@ -69,7 +69,6 @@ export default function renderIndex(html, css, assetMap, store) { window.BASE_URL="${process.env.BASE_URL || ''}" window.NOT_IN_USA=${process.env.NOT_IN_USA || 0} window.ALLOW_SEND_ALL=${process.env.ALLOW_SEND_ALL || 0} - window.OPT_OUT_MESSAGE="${process.env.OPT_OUT_MESSAGE || 'I\'m opting you out of texts immediately. Have a great day.'}" window.BULK_SEND_CHUNK_SIZE=${process.env.BULK_SEND_CHUNK_SIZE || 0} window.MAX_MESSAGE_LENGTH=${process.env.MAX_MESSAGE_LENGTH || 99999} window.TERMS_REQUIRE="${process.env.TERMS_REQUIRE || ''}" diff --git a/src/server/models/cacheable_queries/README.md b/src/server/models/cacheable_queries/README.md new file mode 100644 index 000000000..eaeb99864 --- /dev/null +++ b/src/server/models/cacheable_queries/README.md @@ -0,0 +1,84 @@ +## Caching goals + +Caching can speed up responses and lookups (and possibly writes, too) by several orders of +magnitude. However, adding caching layers also adds complexity to an application. It can +make features more difficult to add, and simple changes can accidentally affect the optimizations +the cache was meant for. + +Scaling Spoke is an active project and this directory and its relationship with the codebase +may change. Nonetheless, the first round has the following goals: + +* Optimize the Texter experience +* Find as many gains from reading from the cache as possible + +## Places to be most careful about caching + +* Anywhere in server/api/*, especially assignment.js and campaign-contact.js +* When editing/updating a campaign, we need to be careful about clearing the caches when appropriate +* If we add server/api/ resolvers or add fields in containers/TexterTodo.jsx + we should think carefully about caching consequences. If we go ahead, then we'll need + to make sure the data is cached by the time the Texter accesses the data. + +## Cached Objects + +* organization +* campaign +* optOut +* cannedResponse +* user + +## Code Style + +Generally `import { cacheableData } from 'server/models/cacheable-queries'` provides a +per-object interface. Not all interfaces are the same, based on what can/should be cached. + +If a caccheableData object is available then server code should generally try to use it +for speeding up responses. Occasionally, for admin interfaces, e.g., it's worth +forcing a database query, but especially for object-lookup, this is generally just inefficient. + +If you are loading an object by id, then the best way to do that in server code is to +use the `loaders` object, e.g. `await loaders.campaign.load(campaignId)` will return +a campaign object however is best (cache or not). + +In api resolvers (i.e. the methods in /api/*.js files), occasionally the method may have a +cached version already populated on the object. +For example in `server/api/campaign.js` interactionSteps is cached *with* the campaign object, +so by the time you get to the `interactionSteps` resolver, the result is available already. +In these cases, we should name the cached result the same as the resolver method. This way, +we can test whether our result is already cached by testing for `campaign.interactionSteps`, otherwise, +we can make the db call -- often that db call will be available + +### cacheableData Object Method Definitions + +All are `async` methods. + +* `load(id)` -- will load from cache if, available and otherwise from the database. + If appropriate, it may save the result in the cache (some situations, + it's better not to, because loading should happen purposefully at specific times) + Note: This needs to send an adapted Model object, so e.g. it should return a + `new Campaign(campaignData)` object and not just `campaignData` +* `clear(id)` -- will clear from the cache for a specific id -- you should do this whenever + the object is updated in the database. +* `reload(id)` -- if this method exists, it often means loading the cache may be more + expensive than a single database call for the id's data. While using `clear(id)` clears + the cache and then waits for the next request to cache the data, `reload()` can be used + to load the cache at the right moment instead of burdening a future request with it. +* `save(objectValues)` -- this will create an object in the db and update the cache. + In the future, if we have a mode that writes to cache-only, and then syncs later, that + logic can/will be captured in the cacheableData save method. If there *is* a save method, + then ALL saving of that model should be done through it. +* `query(objectValues)` -- a query of multiple values other than id that can return an object. + A classic example is in `cacheableData.optOut.query({ cell, organizationId })` where opt outs + will be unique by cell-organization pairs. Most query methods will only take specific + items -- *not any* set of values. If you need to query the object by a different set of + values, that may have adverse affects with the cache and/or response speed, so think through + the consequences. +* `clearQuery(objectValues)` -- this is a complement to `query()` and clears the result. + Depending on the method, it + +Others: +* `loadMany()` -- this will often take arguments to load a dataset into the cache, e.g. all + the opt-outs for a particular organization. + +## Data-structures + diff --git a/src/server/models/cacheable_queries/assignment.js b/src/server/models/cacheable_queries/assignment.js new file mode 100644 index 000000000..ebce32f7d --- /dev/null +++ b/src/server/models/cacheable_queries/assignment.js @@ -0,0 +1,18 @@ +import { r } from '../../models' + +export async function hasAssignment(userId, assignmentId) { +} + +export const assignmentCache = { + clear: async (id) => { + }, + load: async (id) => { + // should load cache of campaign by id separately, so that can be updated on campaign-save + // e.g. for script changes + // should include: + // texter: id, firstName, lastName, assignedCell, ?userCannedResponses + // campaignId + // organizationId + // ?should contact ids be key'd off of campaign or assignment? + } +} diff --git a/src/server/models/cacheable_queries/user.js b/src/server/models/cacheable_queries/user.js index a7b672f19..f0336de80 100644 --- a/src/server/models/cacheable_queries/user.js +++ b/src/server/models/cacheable_queries/user.js @@ -51,7 +51,7 @@ export async function userLoggedIn(authId) { await r.redis.multi() .set(authKey, JSON.stringify(userAuth)) .expire(authKey, 86400) - .exec() + .execAsync() } return userAuth } diff --git a/src/server/models/index.js b/src/server/models/index.js index d0d901faf..4ade0450c 100644 --- a/src/server/models/index.js +++ b/src/server/models/index.js @@ -23,8 +23,15 @@ import Log from './log' import thinky from './thinky' import datawarehouse from './datawarehouse' -function createLoader(model, idKey = 'id') { +import { cacheableData } from './cacheable_queries' + +function createLoader(model, opts) { + const idKey = (opts && opts.idKey) || 'id' + const cacheObj = opts && opts.cacheObj return new DataLoader(async (keys) => { + if (cacheObj && cacheObj.load) { + return keys.map(async (key) => await cacheObj.load(key)) + } const docs = await model.getAll(...keys, { index: idKey }) return keys.map((key) => ( docs.find((doc) => doc[idKey].toString() === key.toString()) @@ -101,6 +108,7 @@ const r = thinky.r export { createLoaders, r, + cacheableData, createTables, createTablesIfNecessary, dropTables, diff --git a/src/server/models/thinky.js b/src/server/models/thinky.js index b694e6d18..d44682972 100644 --- a/src/server/models/thinky.js +++ b/src/server/models/thinky.js @@ -63,6 +63,9 @@ thinkyConn.r.getCount = async (query) => { // with fewer bugs. Using knex's .count() // results in a 'count' key on postgres, but a 'count(*)' key // on sqlite -- ridiculous. This smooths that out + if (Array.isArray(query)) { + return query.length + } return Number((await query.count('* as count').first()).count) } diff --git a/src/workers/jobs.js b/src/workers/jobs.js index ec9bc85ef..9ef146185 100644 --- a/src/workers/jobs.js +++ b/src/workers/jobs.js @@ -27,6 +27,18 @@ function getOptOutSubQuery(orgId) { return (!!process.env.OPTOUTS_SHARE_ALL_ORGS ? optOutsByInstance() : optOutsByOrgId(orgId)) } +function optOutsByOrgId(orgId) { + return r.knex.select('cell').from('opt_out').where('organization_id', orgId) +} + +function optOutsByInstance() { + return r.knex.select('cell').from('opt_out') +} + +function getOptOutSubQuery(orgId) { + return (!!process.env.OPTOUTS_SHARE_ALL_ORGS ? optOutsByInstance() : optOutsByOrgId(orgId)) +} + export async function getTimezoneByZip(zip) { if (zip in zipMemoization) { return zipMemoization[zip] @@ -196,6 +208,7 @@ export async function uploadContacts(job) { await r.table('job_request').get(job.id).delete() } } + await cacheableData.campaign.reload(campaignId) } export async function loadContactsFromDataWarehouseFragment(jobEvent) { @@ -293,9 +306,7 @@ export async function loadContactsFromDataWarehouseFragment(jobEvent) { // now that we've saved them all, we delete everyone that is opted out locally // doing this in one go so that we can get the DB to do the indexed cell matching const optOutCellCount = await r.knex('campaign_contact') - .whereIn('cell', function optouts() { - this.select('cell').from('opt_out').where('organization_id', jobEvent.organizationId) - }) + .whereIn('cell', getOptOutSubQuery(jobEvent.organizationId)) .where('campaign_id', jobEvent.campaignId) .delete() .then(result => { @@ -496,14 +507,15 @@ export async function assignTexters(job) { .select('user_id', 'assignment.id as id', r.knex.raw("SUM(CASE WHEN allcontacts.message_status = 'needsMessage' THEN 1 ELSE 0 END) as needs_message_count"), - r.knex.raw('COUNT(allcontacts.id) as full_contact_count') + r.knex.raw('COUNT(allcontacts.id) as full_contact_count'), + 'max_contacts' ) .catch(log.error) - const unchangedTexters = {} - const demotedTexters = {} - - // changedAssignments: + const unchangedTexters = {} // max_contacts and needsMessageCount unchanged + const demotedTexters = {} // needsMessageCount reduced + const dynamic = campaign.use_dynamic_assignment + // detect changed assignments currentAssignments.map((assignment) => { const texter = texters.filter((ele) => parseInt(ele.id, 10) === assignment.user_id)[0] const unchangedMaxContacts = @@ -537,7 +549,7 @@ export async function assignTexters(job) { }).filter((ele) => ele !== null) for (const assignId in demotedTexters) { - // Here we demote ALL the demotedTexters contacts (not just the demotion count) + // Here we unassign ALL the demotedTexters contacts (not just the demotion count) // because they will get reapportioned below await r.knex('campaign_contact') .where('id', 'in', @@ -572,9 +584,11 @@ export async function assignTexters(job) { } if (unchangedTexters[texterId]) { - continue + continue } + const contactsToAssign = Math.min(availableContacts, texter.needsMessageCount) + if (contactsToAssign === 0) { // avoid creating a new assignment when the texter should get 0 if (!campaign.use_dynamic_assignment) { @@ -598,7 +612,7 @@ export async function assignTexters(job) { assignment = await new Assignment({ user_id: texterId, campaign_id: cid, - max_contacts: parseInt(maxContacts || process.env.MAX_CONTACTS_PER_TEXTER || 0, 10) + max_contacts: maxContacts }).save() } @@ -625,10 +639,10 @@ export async function assignTexters(job) { } await updateJob(job, Math.floor((75 / texterCount) * (index + 1)) + 20) - } + } // endfor if (!campaign.use_dynamic_assignment) { - // dynamic assignments, having zero initially initially is ok + // dynamic assignments, having zero initially is ok const assignmentsToDelete = r.knex('assignment') .where('assignment.campaign_id', cid) .leftJoin('campaign_contact', 'assignment.id', 'campaign_contact.assignment_id')