diff --git a/.github/workflows/build-image.yaml b/.github/workflows/build-image.yaml index 372a645bf..0a2dfebf3 100644 --- a/.github/workflows/build-image.yaml +++ b/.github/workflows/build-image.yaml @@ -27,7 +27,7 @@ jobs: uses: docker/metadata-action@v3 with: images: | - ghcr.io/statevoicesnational/spoke + ghcr.io/moveonorg/moveon-spoke tags: | type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} diff --git a/__test__/components/CampaignInteractionStepsForm.test.js b/__test__/components/CampaignInteractionStepsForm.test.js index 43464da66..eef6d9e68 100644 --- a/__test__/components/CampaignInteractionStepsForm.test.js +++ b/__test__/components/CampaignInteractionStepsForm.test.js @@ -18,9 +18,13 @@ import { operations as adminCampaignEditOps } from "../../src/containers/AdminCampaignEdit"; import { + mockInteractionSteps, setupTest, cleanupTest, - createStartedCampaign, + createCampaign, + createInvite, + createOrganization, + createUser, makeRunnableMutations, runComponentQueries, muiTheme @@ -98,59 +102,233 @@ describe("CampaignInteractionStepsForm", () => { }); describe("action handlers", () => { + const pinkInteractionStep = { + id: 4, + questionText: "", + script: "Deep Pink is an awesome color, {firstName}!", + answerOption: "Deep Pink", + answerActions: "", + answerActionsData: null, + parentInteractionId: 1, + isDeleted: false + }; + let wrappedComponent; let interactionSteps; + function cmpAnswerOptions(step) { + return function(mStep) { + /** + * @returns True if the answer options are equal. False otherwise. + */ + return step.answer_option === mStep.answerOption; + }; + } + + function cmpProp(prop, val) { + return function(node) { + /** + * @returns True if the node prop and val are equal. False otherwise. + */ + return node.props()[prop] === val; + }; + } + + function dummyFunction() { + /** + * Empty function that does nothing + * + * @returns Empty object + */ + return {}; + } + + function saveInteractionSteps( + campaign, + done, + interactionSteps, + queryResults, + wrappedComponent + ) { + const newInteractionSteps = []; + let instance, interactionStepsAfter; + + async function callback1() { + const campaignInteractionStepsForm = wrappedComponent.find( + CampaignInteractionStepsForm + ); + + expect(campaignInteractionStepsForm.exists()).toEqual(true); + + instance = campaignInteractionStepsForm.instance(); + + await instance.onSave(); + + interactionStepsAfter = await r + .knex("interaction_step") + .where({ campaign_id: campaign.id }); + + interactionStepsAfter.map(normalizeIsDeleted); + + expect(interactionStepsAfter).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + answer_actions: "", + answer_actions_data: null, + answer_option: "", + campaign_id: Number(campaign.id), + id: expect.any(Number), + is_deleted: false, + parent_interaction_id: null, + question: "What's your favorite color?", + script: "Hi {firstName}! Let's talk about colors." + }), + expect.objectContaining({ + answer_actions: "complex-test-action", + answer_actions_data: + '{"value":"{\\"hex\\":\\"#B22222\\",\\"rgb\\":{\\"r\\":178,\\"g\\":34,\\"b\\":34}}","label":"firebrick"}', + answer_option: "Red", + id: expect.any(Number), + campaign_id: Number(campaign.id), + is_deleted: false, + parent_interaction_id: expect.any(Number), + question: "What's your favorite shade of red?", + script: "Red is a great color, {firstName}!" + }), + expect.objectContaining({ + answer_actions: "", + answer_actions_data: "", + answer_option: "Crimson", + campaign_id: Number(campaign.id), + id: expect.any(Number), + is_deleted: false, + parent_interaction_id: expect.any(Number), + question: "", + script: "Crimson is a great shade of red, {firstName}!" + }), + expect.objectContaining({ + answer_actions: "", + answer_actions_data: "", + answer_option: "Cherry", + campaign_id: Number(campaign.id), + id: expect.any(Number), + is_deleted: false, + parent_interaction_id: expect.any(Number), + question: "", + script: "Cherry is a great shade of red, {firstName}!" + }), + expect.objectContaining({ + answer_actions: "complex-test-action", + answer_actions_data: + '{"value":"{\\"hex\\":\\"#4B0082\\",\\"rgb\\":{\\"r\\":75,\\"g\\":0,\\"b\\":130}}","label":"indigo"}', + answer_option: "Purple", + campaign_id: Number(campaign.id), + id: expect.any(Number), + is_deleted: false, + parent_interaction_id: expect.any(Number), + question: "", + script: "Purple is a great color, {firstName}!" + }) + ]) + ); + + // Delete "Red" interaction step + wrappedComponent.setState( + { + expandedSection: 3 + }, + callback2 + ); + } + + async function callback2() { + interactionStepsAfter.forEach(deleteRedInteractionSteps); + + instance.state.interactionSteps = newInteractionSteps; + await instance.onSave(); + + const interactionStepsAfterDelete = await r + .knex("interaction_step") + .where({ campaign_id: campaign.id }); + + // Test that the "Red" interaction step and its children are deleted + interactionStepsAfterDelete.map(normalizeIsDeleted); + expect(interactionStepsAfterDelete).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + answer_actions: "", + answer_actions_data: null, + answer_option: "", + campaign_id: Number(campaign.id), + id: expect.any(Number), + is_deleted: false, + parent_interaction_id: null, + question: "What's your favorite color?", + script: "Hi {firstName}! Let's talk about colors." + }), + expect.objectContaining({ + answer_actions: "complex-test-action", + answer_actions_data: + '{"value":"{\\"hex\\":\\"#4B0082\\",\\"rgb\\":{\\"r\\":75,\\"g\\":0,\\"b\\":130}}","label":"indigo"}', + answer_option: "Purple", + campaign_id: Number(campaign.id), + id: expect.any(Number), + is_deleted: false, + parent_interaction_id: expect.any(Number), + question: "", + script: "Purple is a great color, {firstName}!" + }) + ]) + ); + + done(); + } + + function deleteRedInteractionSteps(step) { + const newStep = JSON.parse( + JSON.stringify( + instance.state.interactionSteps.find(cmpAnswerOptions(step)) + ) + ); + + newStep.id = step.id; + newStep.parentInteractionId = step.parent_interaction_id; + + if (step.answer_option === "Red") { + newStep.isDeleted = true; + } + + newInteractionSteps.push(newStep); + } + + /** + * Normalize is_deleted field due to various possible truthy values in different databases types + * @param {array} is Interaction steps + */ + function normalizeIsDeleted(step) { + // eslint-disable-next-line no-param-reassign + step.is_deleted = !!step.is_deleted; + } + + return function(interactionStepsBefore) { + expect(interactionStepsBefore).toHaveLength(0); + + return wrappedComponent.setState( + { + expandedSection: 3, + campaignFormValues: { + ...queryResults.campaignData.campaign, + interactionSteps + } + }, + callback1 + ); + }; + } + describe("when there are no action handlers", () => { beforeEach(async () => { - interactionSteps = [ - { - id: "new_1", - questionText: "What is your favorite color", - script: "Hello {firstName}. Let's talk about your favorite color.", - answerOption: "", - answerActions: "", - answerActionsData: "", - parentInteractionId: null, - isDeleted: false, - interactionSteps: [ - { - id: "new_2", - questionText: "What is your favorite shade of red?", - script: "Red is an awesome color, {firstName}!", - answerOption: "Red", - answerActions: "", - answerActionsData: "", - parentInteractionId: "new_1", - isDeleted: false, - interactionSteps: [ - { - id: "new_21", - questionText: "", - script: "Crimson is a rad shade of red, {firstName}", - answerOption: "Crimson", - answerActions: "", - answerActionsData: "", - parentInteractionId: "new_2", - isDeleted: false, - interactionSteps: [] - } - ] - }, - { - id: "new_3", - questionText: "", - script: "Purple is an awesome color, {firstName}!", - answerOption: "Purple", - answerActions: "", - answerActionsData: "", - parentInteractionId: "new_1", - isDeleted: false, - interactionSteps: [] - } - ] - } - ]; + interactionSteps = [mockInteractionSteps]; StyleSheetTestUtils.suppressStyleInjection(); wrappedComponent = mount( @@ -160,8 +338,8 @@ describe("CampaignInteractionStepsForm", () => { formValues={{ interactionSteps }} - onChange={() => {}} - onSubmit={() => {}} + onChange={dummyFunction} + onSubmit={dummyFunction} ensureComplete customFields={[]} saveLabel="save" @@ -174,7 +352,7 @@ describe("CampaignInteractionStepsForm", () => { it("doesn't render the answer actions", async () => { const answerActionsComponents = wrappedComponent.findWhere( - node => node.props()["data-test"] === "actionSelect" + cmpProp("data-test", "actionSelect") ); expect(answerActionsComponents.exists()).toEqual(false); }); @@ -213,16 +391,7 @@ describe("CampaignInteractionStepsForm", () => { parentInteractionId: 1, isDeleted: false }, - { - id: 4, - questionText: "", - script: "Deep Pink is an awesome color, {firstName}!", - answerOption: "Deep Pink", - answerActions: "", - answerActionsData: null, - parentInteractionId: 1, - isDeleted: false - } + { ...pinkInteractionStep } ]; StyleSheetTestUtils.suppressStyleInjection(); @@ -233,8 +402,8 @@ describe("CampaignInteractionStepsForm", () => { formValues={{ interactionSteps }} - onChange={() => {}} - onSubmit={() => {}} + onChange={dummyFunction} + onSubmit={dummyFunction} ensureComplete customFields={[]} saveLabel="save" @@ -264,7 +433,7 @@ describe("CampaignInteractionStepsForm", () => { const step1 = cards.at(1); const selectField1 = step1.find(GSSelectField); const step1AnswerActionNodes = step1.findWhere( - node => node.props()["data-test"] === "actionSelect" + cmpProp("data-test", "actionSelect") ); expect(step1AnswerActionNodes.first().props().value).toEqual( "red-handler" @@ -286,7 +455,7 @@ describe("CampaignInteractionStepsForm", () => { ]); const step1ClientChoiceNodes = step1.findWhere( - node => node.props()["data-test"] === "actionDataAutoComplete" + cmpProp("data-test", "actionDataAutoComplete") ); expect(step1ClientChoiceNodes.exists()).toEqual(false); @@ -295,7 +464,7 @@ describe("CampaignInteractionStepsForm", () => { const step2 = cards.at(2); const selectField2 = step2.find(GSSelectField); const step2AnswerActionNodes = step2.findWhere( - node => node.props()["data-test"] === "actionSelect" + cmpProp("data-test", "actionSelect") ); expect(step2AnswerActionNodes.first().props().value).toEqual( @@ -318,7 +487,7 @@ describe("CampaignInteractionStepsForm", () => { ]); const step2ClientChoiceNodes = step2.findWhere( - node => node.props()["data-test"] === "actionDataAutoComplete" + cmpProp("data-test", "actionDataAutoComplete") ); expect(step2ClientChoiceNodes.exists()).toEqual(false); @@ -327,7 +496,7 @@ describe("CampaignInteractionStepsForm", () => { const step3 = cards.at(3); const selectField3 = step3.find(GSSelectField); const step3AnswerActionNodes = step3.findWhere( - node => node.props()["data-test"] === "actionSelect" + cmpProp("data-test", "actionSelect") ); expect(step3AnswerActionNodes.first().props().value).toEqual(""); @@ -348,7 +517,7 @@ describe("CampaignInteractionStepsForm", () => { ]); const step3ClientChoiceNodes = step3.findWhere( - node => node.props()["data-test"] === "actionDataAutoComplete" + cmpProp("data-test", "actionDataAutoComplete") ); expect(step3ClientChoiceNodes.exists()).toEqual(false); @@ -394,16 +563,7 @@ describe("CampaignInteractionStepsForm", () => { parentInteractionId: 1, isDeleted: false }, - { - id: 4, - questionText: "", - script: "Deep Pink is an awesome color, {firstName}!", - answerOption: "Deep Pink", - answerActions: "pink-handler", - answerActionsData: null, - parentInteractionId: 1, - isDeleted: false - }, + { ...pinkInteractionStep, answerActions: "pink-handler" }, { id: 5, questionText: "", @@ -424,8 +584,8 @@ describe("CampaignInteractionStepsForm", () => { formValues={{ interactionSteps }} - onChange={() => {}} - onSubmit={() => {}} + onChange={dummyFunction} + onSubmit={dummyFunction} ensureComplete customFields={[]} saveLabel="save" @@ -469,7 +629,7 @@ describe("CampaignInteractionStepsForm", () => { const step1 = cards.at(1); const selectField1 = step1.find(GSSelectField); const step1AnswerActionNodes = step1.findWhere( - node => node.props()["data-test"] === "actionSelect" + cmpProp("data-test", "actionSelect") ); expect(step1AnswerActionNodes.first().props().value).toEqual( @@ -492,7 +652,7 @@ describe("CampaignInteractionStepsForm", () => { ]); const step1ClientChoiceNodes = step1.findWhere( - node => node.props()["data-test"] === "actionDataAutoComplete" + cmpProp("data-test", "actionDataAutoComplete") ); expect(step1ClientChoiceNodes.at(2).props().options).toEqual([ @@ -514,7 +674,7 @@ describe("CampaignInteractionStepsForm", () => { const step2 = cards.at(2); const selectField2 = step2.find(GSSelectField); const step2AnswerActionNodes = step2.findWhere( - node => node.props()["data-test"] === "actionSelect" + cmpProp("data-test", "actionSelect") ); expect(step2AnswerActionNodes.first().props().value).toEqual( @@ -537,7 +697,7 @@ describe("CampaignInteractionStepsForm", () => { ]); const step2ClientChoiceNodes = step2.findWhere( - node => node.props()["data-test"] === "actionDataAutoComplete" + cmpProp("data-test", "actionDataAutoComplete") ); expect(step2ClientChoiceNodes.first().props().value).toEqual({ @@ -564,7 +724,7 @@ describe("CampaignInteractionStepsForm", () => { const step3 = cards.at(3); const selectField3 = step3.find(GSSelectField); const step3AnswerActionNodes = step3.findWhere( - node => node.props()["data-test"] === "actionSelect" + cmpProp("data-test", "actionSelect") ); expect(step3AnswerActionNodes.first().props().value).toEqual( @@ -587,7 +747,7 @@ describe("CampaignInteractionStepsForm", () => { ]); const step3ClientChoiceNodes = step3.findWhere( - node => node.props()["data-test"] === "actionDataAutoComplete" + cmpProp("data-test", "actionDataAutoComplete") ); expect(step3ClientChoiceNodes.exists()).toEqual(false); @@ -596,7 +756,7 @@ describe("CampaignInteractionStepsForm", () => { const step4 = cards.at(4); const selectField4 = step4.find(GSSelectField); const step4AnswerActionNodes = step4.findWhere( - node => node.props()["data-test"] === "actionSelect" + cmpProp("data-test", "actionSelect") ); expect(step4AnswerActionNodes.first().props().value).toEqual(""); @@ -617,7 +777,7 @@ describe("CampaignInteractionStepsForm", () => { ]); const step4ClientChoiceNodes = step4.findWhere( - node => node.props()["data-test"] === "actionDataAutoComplete" + cmpProp("data-test", "actionDataAutoComplete") ); expect(step4ClientChoiceNodes.exists()).toEqual(false); @@ -635,14 +795,13 @@ describe("CampaignInteractionStepsForm", () => { beforeEach(async () => { await setupTest(); - const startedCampaign = await createStartedCampaign(); - ({ - testOrganization: { - data: { createOrganization: organization } - }, - testAdminUser: adminUser, - testCampaign: campaign - } = startedCampaign); + adminUser = await createUser(); + const testOrganization = await createOrganization( + adminUser, + await createInvite() + ); + campaign = await createCampaign(adminUser, testOrganization); + organization = testOrganization.data.createOrganization; }, global.DATABASE_SETUP_TEARDOWN_TIMEOUT); afterEach(async () => { @@ -749,103 +908,15 @@ describe("CampaignInteractionStepsForm", () => { expect(wrappedComponent.exists()).toEqual(true); r.knex("interaction_step") .where({ campaign_id: campaign.id }) - .then(interactionStepsBefore => { - expect(interactionStepsBefore).toHaveLength(0); - - return wrappedComponent.setState( - { - expandedSection: 3, - campaignFormValues: { - ...queryResults.campaignData.campaign, - interactionSteps - } - }, - async () => { - const campaignInteractionStepsForm = wrappedComponent.find( - CampaignInteractionStepsForm - ); - - expect(campaignInteractionStepsForm.exists()).toEqual(true); - - const instance = campaignInteractionStepsForm.instance(); - - await instance.onSave(); - - const interactionStepsAfter = await r - .knex("interaction_step") - .where({ campaign_id: campaign.id }); - - interactionStepsAfter.forEach(step => { - // eslint-disable-next-line no-param-reassign - step.is_deleted = !!step.is_deleted; - }); - - expect(interactionStepsAfter).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - answer_actions: "", - answer_actions_data: null, - answer_option: "", - campaign_id: Number(campaign.id), - id: expect.any(Number), - is_deleted: false, - parent_interaction_id: null, - question: "What's your favorite color?", - script: "Hi {firstName}! Let's talk about colors." - }), - expect.objectContaining({ - answer_actions: "complex-test-action", - answer_actions_data: - '{"value":"{\\"hex\\":\\"#B22222\\",\\"rgb\\":{\\"r\\":178,\\"g\\":34,\\"b\\":34}}","label":"firebrick"}', - answer_option: "Red", - id: expect.any(Number), - campaign_id: Number(campaign.id), - is_deleted: false, - parent_interaction_id: expect.any(Number), - question: "What's your favorite shade of red?", - script: "Red is a great color, {firstName}!" - }), - expect.objectContaining({ - answer_actions: "", - answer_actions_data: "", - answer_option: "Crimson", - campaign_id: Number(campaign.id), - id: expect.any(Number), - is_deleted: false, - parent_interaction_id: expect.any(Number), - question: "", - script: "Crimson is a great shade of red, {firstName}!" - }), - expect.objectContaining({ - answer_actions: "", - answer_actions_data: "", - answer_option: "Cherry", - campaign_id: Number(campaign.id), - id: expect.any(Number), - is_deleted: false, - parent_interaction_id: expect.any(Number), - question: "", - script: "Cherry is a great shade of red, {firstName}!" - }), - expect.objectContaining({ - answer_actions: "complex-test-action", - answer_actions_data: - '{"value":"{\\"hex\\":\\"#4B0082\\",\\"rgb\\":{\\"r\\":75,\\"g\\":0,\\"b\\":130}}","label":"indigo"}', - answer_option: "Purple", - campaign_id: Number(campaign.id), - id: expect.any(Number), - is_deleted: false, - parent_interaction_id: expect.any(Number), - question: "", - script: "Purple is a great color, {firstName}!" - }) - ]) - ); - - done(); - } - ); - }); + .then( + saveInteractionSteps( + campaign, + done, + interactionSteps, + queryResults, + wrappedComponent + ) + ); }); }); }); diff --git a/__test__/server/api/assignment.test.js b/__test__/server/api/assignment.test.js index 67508111c..be1831457 100644 --- a/__test__/server/api/assignment.test.js +++ b/__test__/server/api/assignment.test.js @@ -219,4 +219,29 @@ describe("test getContacts timezone stuff only", () => { /^select \* from .campaign_contact. where .assignment_id. = 1.*/ ); }); // it + + it("returns the correct query -- assignment load limit not set", () => { + let query = getContacts( + assignment, + { validTimezone: null }, + organization, + campaign + ); + expect(query.toString()).not.toMatch( + /^select \* from .campaign_contact. where .assignment_id. = 1.* limit 1/ + ); + }); // it + + it("returns the correct query -- assignment load limit set", () => { + global["ASSIGNMENT_LOAD_LIMIT"] = 1; + let query = getContacts( + assignment, + { validTimezone: null }, + organization, + campaign + ); + expect(query.toString()).toMatch( + /^select \* from .campaign_contact. where .assignment_id. = 1.* limit 1/ + ); + }); // it }); // describe diff --git a/__test__/server/api/campaign/campaign.test.js b/__test__/server/api/campaign/campaign.test.js index 2589c5b19..086a32c72 100644 --- a/__test__/server/api/campaign/campaign.test.js +++ b/__test__/server/api/campaign/campaign.test.js @@ -37,6 +37,7 @@ import { sleep, startCampaign } from "../../../test_helpers"; +import { dynamicReassignMutation } from "../../../../src/containers/AssignReplies"; let testAdminUser; let testInvite; @@ -815,6 +816,55 @@ describe("Reassignments", () => { testTexterUser ); + texterCampaignDataResults2 = await runGql( + TexterTodoQuery, + { + contactsFilter: { + messageStatus: "needsResponse", + isOptedOut: false, + validTimezone: true + }, + assignmentId: assignmentId2, + organizationId + }, + testTexterUser2 + ); + // TEXTER 1 (60 needsMessage, 2 needsResponse, 4 messaged) + // TEXTER 2 (25 needsMessage, 3 convo, 1 messaged) + expect(texterCampaignDataResults.data.assignment.contacts.length).toEqual( + 2 + ); + expect(texterCampaignDataResults.data.assignment.allContactsCount).toEqual( + 66 + ); + expect(texterCampaignDataResults2.data.assignment.contacts.length).toEqual( + 0 + ); + expect(texterCampaignDataResults2.data.assignment.allContactsCount).toEqual( + 29 + ); + await runGql( + dynamicReassignMutation, + { + joinToken: testCampaign.joinToken, + campaignId: testCampaign.id, + }, + testTexterUser2 + ); + texterCampaignDataResults = await runGql( + TexterTodoQuery, + { + contactsFilter: { + messageStatus: "needsResponse", + isOptedOut: false, + validTimezone: true + }, + assignmentId, + organizationId + }, + testTexterUser + ); + texterCampaignDataResults2 = await runGql( TexterTodoQuery, { @@ -840,6 +890,58 @@ describe("Reassignments", () => { expect(texterCampaignDataResults2.data.assignment.allContactsCount).toEqual( 29 ); + jest.useFakeTimers() + jest.advanceTimersByTime(4000000) + await runGql( + dynamicReassignMutation, + { + joinToken: testCampaign.joinToken, + campaignId: testCampaign.id, + }, + testTexterUser2 + ); + jest.useRealTimers() + texterCampaignDataResults = await runGql( + TexterTodoQuery, + { + contactsFilter: { + messageStatus: "needsResponse", + isOptedOut: false, + validTimezone: true + }, + assignmentId, + organizationId + }, + testTexterUser + ); + + texterCampaignDataResults2 = await runGql( + TexterTodoQuery, + { + contactsFilter: { + messageStatus: "needsResponse", + isOptedOut: false, + validTimezone: true + }, + assignmentId: assignmentId2, + organizationId + }, + testTexterUser2 + ); + // TEXTER 1 (60 needsMessage, 4 messaged) + // TEXTER 2 (25 needsMessage, 2 needsResponse, 3 convo, 1 messaged) + expect(texterCampaignDataResults.data.assignment.contacts.length).toEqual( + 0 + ); + expect(texterCampaignDataResults.data.assignment.allContactsCount).toEqual( + 64 + ); + expect(texterCampaignDataResults2.data.assignment.contacts.length).toEqual( + 2 + ); + expect(texterCampaignDataResults2.data.assignment.allContactsCount).toEqual( + 31 + ); }, 10000); // long test can exceed default 5seconds }); diff --git a/__test__/server/api/campaign/updateQuestionResponses.test.js b/__test__/server/api/campaign/updateQuestionResponses.test.js index 7073188a5..381f6f328 100644 --- a/__test__/server/api/campaign/updateQuestionResponses.test.js +++ b/__test__/server/api/campaign/updateQuestionResponses.test.js @@ -5,6 +5,8 @@ import { cleanupTest, createScript, createStartedCampaign, + mockInteractionSteps, + muiTheme, runGql, sendMessage, setupTest, @@ -33,7 +35,6 @@ import { contactDataFragment } from "../../../../src/containers/TexterTodo"; -import { muiTheme } from "../../../test_helpers"; import ThemeContext from "../../../../src/containers/context/ThemeContext"; describe("mutations.updateQuestionResponses", () => { @@ -166,65 +167,7 @@ describe("mutations.updateQuestionResponses", () => { describe("when called through the mutation", () => { beforeEach(async () => { - const inputInteractionSteps = [ - { - id: "new_1", - questionText: "What is your favorite color", - script: "Hello {firstName}. Let's talk about your favorite color.", - answerOption: "", - answerActions: "", - answerActionsData: "", - parentInteractionId: null, - isDeleted: false, - interactionSteps: [ - { - id: "new_2", - questionText: "What is your favorite shade of red?", - script: "Red is an awesome color, {firstName}!", - answerOption: "Red", - answerActions: "", - answerActionsData: "", - parentInteractionId: "new_1", - isDeleted: false, - interactionSteps: [ - { - id: "new_21", - questionText: "", - script: "Crimson is a rad shade of red, {firstName}", - answerOption: "Crimson", - answerActions: "", - answerActionsData: "", - parentInteractionId: "new_2", - isDeleted: false, - interactionSteps: [] - }, - { - id: "new_22", - questionText: "", - script: "Firebrick is a rad shade of red, {firstName}", - answerOption: "Firebrick", - answerActions: "", - answerActionsData: "", - parentInteractionId: "new_2", - isDeleted: false, - interactionSteps: [] - } - ] - }, - { - id: "new_3", - questionText: "", - script: "Purple is an awesome color, {firstName}!", - answerOption: "Purple", - answerActions: "", - answerActionsData: "", - parentInteractionId: "new_1", - isDeleted: false, - interactionSteps: [] - } - ] - } - ]; + const inputInteractionSteps = [mockInteractionSteps]; ({ interactionSteps, @@ -310,7 +253,7 @@ describe("mutations.updateQuestionResponses", () => { campaign_id: Number(campaign.id), question: "What is your favorite color", script: "Hello {firstName}. Let's talk about your favorite color.", - answer_actions: "", + answer_actions: "complex-test-action", value: "Red" }, { @@ -320,7 +263,7 @@ describe("mutations.updateQuestionResponses", () => { campaign_id: Number(campaign.id), question: "What is your favorite shade of red?", script: "Red is an awesome color, {firstName}!", - answer_actions: "", + answer_actions: "complex-test-action", value: "Crimson" } ]); @@ -430,7 +373,7 @@ describe("mutations.updateQuestionResponses", () => { expect(databaseQueryResults.rows || databaseQueryResults).toEqual([ { - answer_actions: "", + answer_actions: "complex-test-action", answer_option: "Red", campaign_id: 1, child_id: 2, @@ -449,136 +392,8 @@ describe("mutations.updateQuestionResponses", () => { let inputInteractionStepsWithoutActionHandlers; beforeEach(async () => { - inputInteractionStepsWithoutActionHandlers = [ - { - id: "new_1", - questionText: "What is your favorite color", - script: "Hello {firstName}. Let's talk about your favorite color.", - answerOption: "", - answerActions: "", - answerActionsData: "", - parentInteractionId: null, - isDeleted: false, - interactionSteps: [ - { - id: "new_2", - questionText: "What is your favorite shade of red?", - script: "Red is an awesome color, {firstName}!", - answerOption: "Red", - answerActions: "", - answerActionsData: "", - parentInteractionId: "new_1", - isDeleted: false, - interactionSteps: [ - { - id: "new_21", - questionText: "", - script: "Crimson is a rad shade of red, {firstName}", - answerOption: "Crimson", - answerActions: "", - answerActionsData: "", - parentInteractionId: "new_2", - isDeleted: false, - interactionSteps: [] - }, - { - id: "new_22", - questionText: "", - script: "Firebrick is a rad shade of red, {firstName}", - answerOption: "Firebrick", - answerActions: "", - answerActionsData: "", - parentInteractionId: "new_2", - isDeleted: false, - interactionSteps: [] - } - ] - }, - { - id: "new_3", - questionText: "", - script: "Purple is an awesome color, {firstName}!", - answerOption: "Purple", - answerActions: "", - answerActionsData: "", - parentInteractionId: "new_1", - isDeleted: false, - interactionSteps: [] - } - ] - } - ]; - - inputInteractionStepsWithActionHandlers = [ - { - id: "new_1", - questionText: "What is your favorite color", - script: "Hello {firstName}. Let's talk about your favorite color.", - answerOption: "", - answerActions: "", - answerActionsData: "", - parentInteractionId: null, - isDeleted: false, - interactionSteps: [ - { - id: "new_2", - questionText: "What is your favorite shade of red?", - script: "Red is an awesome color, {firstName}!", - answerOption: "Red", - answerActions: "complex-test-action", - answerActionsData: "red answer actions data", - parentInteractionId: "new_1", - isDeleted: false, - interactionSteps: [ - { - id: "new_21", - questionText: "", - script: "Crimson is a rad shade of red, {firstName}", - answerOption: "Crimson", - answerActions: "complex-test-action", - answerActionsData: "crimson answer actions data", - parentInteractionId: "new_2", - isDeleted: false, - interactionSteps: [] - }, - { - id: "new_22", - questionText: "", - script: "Firebrick is a rad shade of red, {firstName}", - answerOption: "Firebrick", - answerActions: "", - answerActionsData: "", - parentInteractionId: "new_2", - isDeleted: false, - interactionSteps: [] - } - ] - }, - { - id: "new_3", - questionText: "", - script: "Purple is an awesome color, {firstName}!", - answerOption: "Purple", - answerActions: "", - answerActionsData: "", - parentInteractionId: "new_1", - isDeleted: false, - interactionSteps: [] - }, - { - id: "new_4", - questionText: "", - script: "Blue is an awesome color, {firstName}!", - answerOption: "Blue", - answerActions: "complex-test-action", - answerActionsData: "blue answer actions data", - parentInteractionId: "new_1", - isDeleted: false, - interactionSteps: [] - } - ] - } - ]; + inputInteractionStepsWithoutActionHandlers = [mockInteractionSteps]; + inputInteractionStepsWithActionHandlers = [mockInteractionSteps]; }); describe("happy path", () => { @@ -642,24 +457,21 @@ describe("mutations.updateQuestionResponses", () => { }); describe("when some of the steps have an action handler", () => { + function getMessagePass(received, expectedObject) { + let pass = false; + if (received?.id && expectedObject?.id) { + pass = Number(received.id) === Number(expectedObject.id); + } + const message = pass ? "ok" : "fail"; + return { + message, + pass + }; + } + beforeEach(async () => { expect.extend({ - objectWithId: (received, expectedObject) => { - let pass = false; - if ( - received && - received.id && - expectedObject && - expectedObject.id - ) { - pass = Number(received.id) === Number(expectedObject.id); - } - const message = pass ? "ok" : "fail"; - return { - message, - pass - }; - } + objectWithId: getMessagePass }); ({ @@ -748,8 +560,8 @@ describe("mutations.updateQuestionResponses", () => { ); }); - describe("when a response is added", () => { - beforeEach(async () => { + const responseAdded = { + beforeEach: async () => { questionResponses = [ { campaignContactId: contacts[0].id, @@ -777,9 +589,8 @@ describe("mutations.updateQuestionResponses", () => { ComplexTestActionHandler, "processDeletedQuestionResponse" ); - }); - - it("calls the action handler for the new response", async () => { + }, + it1: async () => { await Mutations.updateQuestionResponses( undefined, { questionResponses, campaignContactId: contacts[0].id }, @@ -819,11 +630,115 @@ describe("mutations.updateQuestionResponses", () => { ComplexTestActionHandler.processAction.mock.calls[0][0] .previousValue ).toBeNull(); - }); + } + } + + describe("when a response is added", () => { + beforeEach(responseAdded.beforeEach); + + it("calls the action handler for the new response", responseAdded.it1); }); - describe("when responses are added, resubmitted with no change, updated, and deleted", () => { - beforeEach(async () => { + async function deletedResponse () { + await Mutations.updateQuestionResponses( + undefined, + { + questionResponses: [questionResponses[0]], + campaignContactId: contacts[0].id + }, + { loaders, user: texterUser } + ); + + expect( + ComplexTestActionHandler.processAction + ).not.toHaveBeenCalled(); + + expect( + ComplexTestActionHandler.processDeletedQuestionResponse + ).toHaveBeenCalled(); + + expect( + ComplexTestActionHandler.processDeletedQuestionResponse.mock + .calls[0][0].questionResponse + ).toEqual(questionResponses[1]); + + expect( + ComplexTestActionHandler.processDeletedQuestionResponse.mock.calls[0][0].interactionStep.id.toString() + ).toEqual(shadesOfRedInteractionSteps[0].id); + + expect( + ComplexTestActionHandler.processDeletedQuestionResponse.mock + .calls[0][0].campaignContactId + ).toEqual(contacts[0].id); + + expect( + ComplexTestActionHandler.processDeletedQuestionResponse.mock + .calls[0][0].contact.id + ).toEqual(contacts[0].id); + + expect( + ComplexTestActionHandler.processDeletedQuestionResponse.mock.calls[0][0].campaign.id.toString() + ).toEqual(campaign.id); + + expect( + ComplexTestActionHandler.processDeletedQuestionResponse.mock.calls[0][0].organization.id.toString() + ).toEqual(organization.id); + + expect( + ComplexTestActionHandler.processDeletedQuestionResponse.mock + .calls[0][0].previousValue + ).toEqual("Crimson"); + } + + async function newResponse() { + await Mutations.updateQuestionResponses( + undefined, + { questionResponses, campaignContactId: contacts[0].id }, + { loaders, user: texterUser } + ); + + await sleep(100); + + expect( + ComplexTestActionHandler.processAction + ).not.toHaveBeenCalled(); + expect( + ComplexTestActionHandler.processDeletedQuestionResponse + ).not.toHaveBeenCalled(); + } + + async function setQuestionResponseValue() { + questionResponses[0].value = "Blue"; + } + + async function updatedResponse() { + await Mutations.updateQuestionResponses( + undefined, + { questionResponses, campaignContactId: contacts[0].id }, + { loaders, user: texterUser } + ); + + expect( + ComplexTestActionHandler.processDeletedQuestionResponse + ).not.toHaveBeenCalled(); + expect(ComplexTestActionHandler.processAction.mock.calls).toEqual( + expect.arrayContaining([ + [ + expect.objectContaining({ + actionObject: expect.objectWithId(colorInteractionSteps[2]), + campaignContactId: Number(contacts[0].id), + contact: expect.objectWithId(contacts[0]), + campaign: expect.objectWithId(campaign), + organization: expect.objectWithId(organization), + previousValue: "Red" + }) + ] + ]) + ); + } + + const responseResubmitted = { + beforeEach: async () => { questionResponses = [ { campaignContactId: contacts[0].id, @@ -851,115 +766,32 @@ describe("mutations.updateQuestionResponses", () => { ComplexTestActionHandler, "processDeletedQuestionResponse" ); - }); + }, + saved: () => { + it("calls processAction for the new question response", newResponse); + }, + updated: () => { + beforeEach(setQuestionResponseValue); - describe("when one of the question responses has already been saved with the same value", () => { - it("calls processAction for the new question response", async () => { - await Mutations.updateQuestionResponses( - undefined, - { questionResponses, campaignContactId: contacts[0].id }, - { loaders, user: texterUser } - ); - - await sleep(100); - - expect( - ComplexTestActionHandler.processAction - ).not.toHaveBeenCalled(); - expect( - ComplexTestActionHandler.processDeletedQuestionResponse - ).not.toHaveBeenCalled(); - }); - }); + it("calls processAction for for the updated response, and it passes in previousValue", updatedResponse); + }, + deleted: () => { + it("calls processDeletedQuestionResponse", deletedResponse); + } + } - describe("when one of the question responses was updated", () => { - beforeEach(async () => { - questionResponses[0].value = "Blue"; - }); - - it("calls processAction for for the updated response, and it passes in previousValue", async () => { - await Mutations.updateQuestionResponses( - undefined, - { questionResponses, campaignContactId: contacts[0].id }, - { loaders, user: texterUser } - ); - - expect( - ComplexTestActionHandler.processDeletedQuestionResponse - ).not.toHaveBeenCalled(); - expect(ComplexTestActionHandler.processAction.mock.calls).toEqual( - expect.arrayContaining([ - [ - expect.objectContaining({ - actionObject: expect.objectWithId(colorInteractionSteps[2]), - campaignContactId: Number(contacts[0].id), - contact: expect.objectWithId(contacts[0]), - campaign: expect.objectWithId(campaign), - organization: expect.objectWithId(organization), - previousValue: "Red" - }) - ] - ]) - ); - }); - }); + describe("when responses are added, resubmitted with no change, updated, and deleted", () => { + beforeEach(responseResubmitted.beforeEach); - describe("when one of the question responses is deleted", () => { - it("calls processDeletedQuestionResponse", async () => { - await Mutations.updateQuestionResponses( - undefined, - { - questionResponses: [questionResponses[0]], - campaignContactId: contacts[0].id - }, - { loaders, user: texterUser } - ); - - expect( - ComplexTestActionHandler.processAction - ).not.toHaveBeenCalled(); - - expect( - ComplexTestActionHandler.processDeletedQuestionResponse - ).toHaveBeenCalled(); - - expect( - ComplexTestActionHandler.processDeletedQuestionResponse.mock - .calls[0][0].questionResponse - ).toEqual(questionResponses[1]); - - expect( - ComplexTestActionHandler.processDeletedQuestionResponse.mock.calls[0][0].interactionStep.id.toString() - ).toEqual(shadesOfRedInteractionSteps[0].id); - - expect( - ComplexTestActionHandler.processDeletedQuestionResponse.mock - .calls[0][0].campaignContactId - ).toEqual(contacts[0].id); - - expect( - ComplexTestActionHandler.processDeletedQuestionResponse.mock - .calls[0][0].contact.id - ).toEqual(contacts[0].id); - - expect( - ComplexTestActionHandler.processDeletedQuestionResponse.mock.calls[0][0].campaign.id.toString() - ).toEqual(campaign.id); - - expect( - ComplexTestActionHandler.processDeletedQuestionResponse.mock.calls[0][0].organization.id.toString() - ).toEqual(organization.id); - - expect( - ComplexTestActionHandler.processDeletedQuestionResponse.mock - .calls[0][0].previousValue - ).toEqual("Crimson"); - }); - }); + describe("when one of the question responses has already been saved with the same value", responseResubmitted.saved); + + describe("when one of the question responses was updated", responseResubmitted.updated); + + describe("when one of the question responses is deleted", responseResubmitted.deleted); }); - describe("when no action handlers are configured", () => { - beforeEach(async () => { + const noActionHandlersConfigured = { + beforeEach: async () => { ({ interactionSteps, redInteractionStep, @@ -969,9 +801,8 @@ describe("mutations.updateQuestionResponses", () => { inputInteractionStepsWithActionHandlers, 2 )); - }); - - it("exits early and logs an error", async () => { + }, + earlyExit: async () => { jest .spyOn(ActionHandlers, "rawAllActionHandlers") .mockReturnValue({}); @@ -984,11 +815,21 @@ describe("mutations.updateQuestionResponses", () => { ); expect(cacheableData.organization.load).not.toHaveBeenCalled(); - }); + } + } + + describe("when no action handlers are configured", () => { + beforeEach(noActionHandlersConfigured.beforeEach); + + it("exits early and logs an error", noActionHandlersConfigured.earlyExit); }); - describe("when task dispatch fails", () => { - beforeEach(async () => { + function throwError() { + throw new Error("foo"); + } + + const taskDispatchFails = { + beforeEach: async () => { ({ interactionSteps, redInteractionStep, @@ -998,13 +839,10 @@ describe("mutations.updateQuestionResponses", () => { inputInteractionStepsWithActionHandlers, 2 )); - }); - - it("dispatches other actions", async () => { + }, + dispatchOtherActions: async () => { jest.spyOn(ComplexTestActionHandler, "processAction"); - jest.spyOn(jobRunner, "dispatchTask").mockImplementationOnce(() => { - throw new Error("foo"); - }); + jest.spyOn(jobRunner, "dispatchTask").mockImplementationOnce(throwError); await Mutations.updateQuestionResponses( {}, { questionResponses, campaignContactId: contacts[0].id }, @@ -1030,11 +868,17 @@ describe("mutations.updateQuestionResponses", () => { } ] ]); - }); + } + } + + describe("when task dispatch fails", () => { + beforeEach(taskDispatchFails.beforeEach); + + it("dispatches other actions", taskDispatchFails.dispatchOtherActions); }); - describe("when the action handler throws an exception", () => { - beforeEach(async () => { + const actionHandlerThrowsException = { + beforeEach: async () => { ({ interactionSteps, redInteractionStep, @@ -1044,9 +888,8 @@ describe("mutations.updateQuestionResponses", () => { inputInteractionStepsWithActionHandlers, 2 )); - }); - - it("processes the other actions", async () => { + }, + processOtherActions: async () => { jest .spyOn(ComplexTestActionHandler, "processAction") .mockRejectedValueOnce(new Error("oh no")); @@ -1090,7 +933,13 @@ describe("mutations.updateQuestionResponses", () => { ] ]) ); - }); + } + } + + describe("when the action handler throws an exception", () => { + beforeEach(actionHandlerThrowsException.beforeEach); + + it("processes the other actions", actionHandlerThrowsException.processOtherActions); }); }); }); diff --git a/__test__/test_helpers.js b/__test__/test_helpers.js index 377a92a8b..12215882f 100644 --- a/__test__/test_helpers.js +++ b/__test__/test_helpers.js @@ -117,6 +117,75 @@ export async function runGql(operation, vars, user) { return result; } +export const mockInteractionSteps = { + id: "new_1", + questionText: "What is your favorite color", + script: "Hello {firstName}. Let's talk about your favorite color.", + answerOption: "", + answerActions: "", + answerActionsData: "", + parentInteractionId: null, + isDeleted: false, + interactionSteps: [ + { + id: "new_2", + questionText: "What is your favorite shade of red?", + script: "Red is an awesome color, {firstName}!", + answerOption: "Red", + answerActions: "complex-test-action", + answerActionsData: "red answer actions data", + parentInteractionId: "new_1", + isDeleted: false, + interactionSteps: [ + { + id: "new_21", + questionText: "", + script: "Crimson is a rad shade of red, {firstName}", + answerOption: "Crimson", + answerActions: "complex-test-action", + answerActionsData: "crimson answer actions data", + parentInteractionId: "new_2", + isDeleted: false, + interactionSteps: [] + }, + { + id: "new_22", + questionText: "", + script: "Firebrick is a rad shade of red, {firstName}", + answerOption: "Firebrick", + answerActions: "", + answerActionsData: "", + parentInteractionId: "new_2", + isDeleted: false, + interactionSteps: [] + } + ] + }, + { + id: "new_3", + questionText: "", + script: "Purple is an awesome color, {firstName}!", + answerOption: "Purple", + answerActions: "", + answerActionsData: "", + parentInteractionId: "new_1", + isDeleted: false, + interactionSteps: [] + }, + { + id: "new_4", + questionText: "", + script: "Blue is an awesome color, {firstName}!", + answerOption: "Blue", + answerActions: "complex-test-action", + answerActionsData: "blue answer actions data", + parentInteractionId: "new_1", + isDeleted: false, + interactionSteps: [] + } + ] +}; + export const updateUserRoles = async ( adminUser, organizationId, @@ -290,6 +359,7 @@ export async function createCampaign( const campaignQuery = `mutation createCampaign($input: CampaignInput!) { createCampaign(campaign: $input) { id + joinToken } }`; const variables = { diff --git a/dev-tools/.env.test b/dev-tools/.env.test index 341b8b3d4..bb9e37c82 100644 --- a/dev-tools/.env.test +++ b/dev-tools/.env.test @@ -26,3 +26,4 @@ PHONE_INVENTORY=1 ALLOW_SEND_ALL=false DST_REFERENCE_TIMEZONE='America/New_York' PASSPORT_STRATEGY=local +ASSIGNMENT_LOAD_LIMIT=1 diff --git a/docs/REFERENCE-environment_variables.md b/docs/REFERENCE-environment_variables.md index 7ca67278a..3a7bed028 100644 --- a/docs/REFERENCE-environment_variables.md +++ b/docs/REFERENCE-environment_variables.md @@ -6,6 +6,7 @@ | ASSETS_DIR | Directory path where front-end packaged JavaScript is saved and loaded. _Required_. | | ASSETS_MAP_FILE | File name of map file, within ASSETS_DIR, containing map of general file names to unique build-specific file names. | | ASSIGNMENT_CONTACTS_SIDEBAR | Show a sidebar with a list of contacts to the texter. Allows texter to freely navigate between conversations, regardless of status. | +| ASSIGNMENT_LOAD_LIMIT | Limit of contacts to load at one time for an assignment. Used when Spoke is deployed on a service with time and bandwidth limitations, such as AWS Lambda. Type: integer | | AUTH0_DOMAIN | Domain name on Auth0 account, should end in `.auth0.com`, e.g. `example.auth0.com`. _Required_. | | AUTH0_CLIENT_ID | Client ID from Auth0 app. _Required_. | | AUTH0_CLIENT_SECRET | Client secret from Auth0 app. _Required_. | diff --git a/migrations/20221130154133_cascade_delete_interaction_step.js b/migrations/20221130154133_cascade_delete_interaction_step.js new file mode 100644 index 000000000..f27747d6f --- /dev/null +++ b/migrations/20221130154133_cascade_delete_interaction_step.js @@ -0,0 +1,13 @@ +exports.up = function(knex) { + return knex.schema.alterTable("interaction_step", (table) => { + table.dropForeign("parent_interaction_id"); + table.foreign("parent_interaction_id").references("interaction_step.id").onDelete("CASCADE"); + }); +}; + +exports.down = function(knex) { + return knex.schema.alterTable("interaction_step", (table) => { + table.dropForeign("parent_interaction_id"); + table.foreign("parent_interaction_id").references("interaction_step.id").onDelete("NO ACTION"); + }); +}; diff --git a/migrations/20240503180901_campaigncontactsupdatedat.js b/migrations/20240503180901_campaigncontactsupdatedat.js new file mode 100644 index 000000000..39f79d864 --- /dev/null +++ b/migrations/20240503180901_campaigncontactsupdatedat.js @@ -0,0 +1,35 @@ + +const { onUpdateTrigger } = require('./helpers/index') +const ON_UPDATE_TIMESTAMP_FUNCTION = ` + CREATE OR REPLACE FUNCTION on_update_timestamp() + RETURNS trigger AS $$ + BEGIN + NEW.updated_at = now(); + RETURN NEW; + END; +$$ language 'plpgsql'; +` + +const DROP_ON_UPDATE_TIMESTAMP_FUNCTION = `DROP FUNCTION on_update_timestamp` + +/** + * @param { import("knex").Knex } knex + */ +exports.up = async function(knex) { + const isSqlite = /sqlite/.test(knex.client.config.client); + if (!isSqlite) { + await knex.raw(ON_UPDATE_TIMESTAMP_FUNCTION); + await knex.raw(onUpdateTrigger('campaign_contact')); + } +}; + +/** + * @param { import("knex").Knex } knex + */ +exports.down = async function(knex) { + const isSqlite = /sqlite/.test(knex.client.config.client); + if (!isSqlite) { + await knex.raw("DROP TRIGGER campaign_contact_updated_at on campaign_contact"); + await knex.raw(DROP_ON_UPDATE_TIMESTAMP_FUNCTION); + } +}; diff --git a/migrations/helpers/index.js b/migrations/helpers/index.js index 5570a8aea..6eca405f0 100644 --- a/migrations/helpers/index.js +++ b/migrations/helpers/index.js @@ -11,3 +11,11 @@ exports.redefineSqliteTable = async (knex, tableName, newTableFn) => { await knex.schema.dropTable(tableName); await knex.schema.createTable(tableName, newTableFn); }; + + +exports.onUpdateTrigger = table => ` +CREATE TRIGGER ${table}_updated_at +BEFORE UPDATE ON ${table} +FOR EACH ROW +EXECUTE PROCEDURE on_update_timestamp(); +` \ No newline at end of file diff --git a/src/api/campaign.js b/src/api/campaign.js index 4a70ea5d1..e6427127b 100644 --- a/src/api/campaign.js +++ b/src/api/campaign.js @@ -137,6 +137,8 @@ export const schema = gql` messageServiceLink: String phoneNumbers: [String] inventoryPhoneNumberCounts: [CampaignPhoneNumberCount] + useDynamicReplies: Boolean + replyBatchSize: Int } type CampaignsList { diff --git a/src/api/schema.js b/src/api/schema.js index ad2370bee..fee8d89a5 100644 --- a/src/api/schema.js +++ b/src/api/schema.js @@ -99,6 +99,9 @@ const rootSchema = gql` texterUIConfig: TexterUIConfigInput timezone: String inventoryPhoneNumberCounts: [CampaignPhoneNumberInput!] + useDynamicReplies: Boolean + replyBatchSize: Int + joinToken: String } input OrganizationInput { @@ -395,6 +398,10 @@ const rootSchema = gql` messageTextFilter: String newTexterUserId: String! ): [CampaignIdAssignmentId] + dynamicReassign( + joinToken: String! + campaignId: String! + ): String importCampaignScript(campaignId: String!, url: String!): Int createTag(organizationId: String!, tagData: TagInput!): Tag editTag(organizationId: String!, id: String!, tagData: TagInput!): Tag diff --git a/src/components/CampaignDynamicAssignmentForm.jsx b/src/components/CampaignDynamicAssignmentForm.jsx index eadb2f92d..ae25dbab5 100644 --- a/src/components/CampaignDynamicAssignmentForm.jsx +++ b/src/components/CampaignDynamicAssignmentForm.jsx @@ -7,6 +7,7 @@ import GSTextField from "../components/forms/GSTextField"; import * as yup from "yup"; import Form from "react-formal"; import OrganizationJoinLink from "./OrganizationJoinLink"; +import OrganizationReassignLink from "./OrganizationReassignLink"; import { dataTest } from "../lib/attributes"; import cloneDeep from "lodash/cloneDeep"; import TagChips from "./TagChips"; @@ -53,7 +54,7 @@ class CampaignDynamicAssignmentForm extends React.Component { render() { const { joinToken, campaignId, organization } = this.props; - const { useDynamicAssignment, batchPolicies } = this.state; + const { useDynamicAssignment, batchPolicies, useDynamicReplies } = this.state; const unselectedPolicies = organization.batchPolicies .filter(p => !batchPolicies.find(cur => cur === p)) .map(p => ({ id: p, name: p })); @@ -73,6 +74,7 @@ class CampaignDynamicAssignmentForm extends React.Component { label="Allow texters with a link to join and start texting when the campaign is started?" labelPlacement="start" /> +
+ { + console.log(toggler, val); + this.toggleChange("useDynamicReplies", val); + }} + /> + } + label="Allow texters with a link to dynamically get assigned replies?" + labelPlacement="start" + /> + + {!useDynamicReplies ? null : ( +
+
    +
  • + {joinToken ? ( + + ) : ( + "Please save the campaign and reload the page to get the reply link to share with texters." + )} +
  • +
  • + You can turn off dynamic assignment after starting a campaign + to disallow more new texters to receive replies. +
  • +
+ + +
+ ) + + } {organization.batchPolicies.length > 1 ? (

Batch Strategy

@@ -211,7 +259,8 @@ CampaignDynamicAssignmentForm.propTypes = { saveDisabled: type.bool, joinToken: type.string, responseWindow: type.number, - batchSize: type.string + batchSize: type.string, + replyBatchSize: type.string }; export default compose(withMuiTheme)(CampaignDynamicAssignmentForm); diff --git a/src/components/OrganizationReassignLink.jsx b/src/components/OrganizationReassignLink.jsx new file mode 100644 index 000000000..f610b452c --- /dev/null +++ b/src/components/OrganizationReassignLink.jsx @@ -0,0 +1,22 @@ +import PropTypes from "prop-types"; +import React from "react"; +import DisplayLink from "./DisplayLink"; + +const OrganizationReassignLink = ({ joinToken, campaignId }) => { + let baseUrl = "https://base"; + if (typeof window !== "undefined") { + baseUrl = window.location.origin; + } + + const replyUrl = `${baseUrl}/${joinToken}/replies/${campaignId}`; + const textContent = `Send your texting volunteers this link! Once they sign up, they\'ll be automatically assigned replies for this campaign.`; + + return ; +}; + +OrganizationReassignLink.propTypes = { + joinToken: PropTypes.string, + campaignId: PropTypes.string +}; + +export default OrganizationReassignLink; diff --git a/src/containers/AdminCampaignEdit.jsx b/src/containers/AdminCampaignEdit.jsx index 5931189b3..2c1746284 100644 --- a/src/containers/AdminCampaignEdit.jsx +++ b/src/containers/AdminCampaignEdit.jsx @@ -139,6 +139,8 @@ const campaignInfoFragment = ` state count } + useDynamicReplies + replyBatchSize `; export const campaignDataQuery = gql`query getCampaign($campaignId: String!) { @@ -514,7 +516,9 @@ export class AdminCampaignEditBase extends React.Component { "batchSize", "useDynamicAssignment", "responseWindow", - "batchPolicies" + "batchPolicies", + "useDynamicReplies", + "replyBatchSize" ], checkCompleted: () => true, blocksStarting: false, diff --git a/src/containers/AssignReplies.jsx b/src/containers/AssignReplies.jsx new file mode 100644 index 000000000..3e50c5418 --- /dev/null +++ b/src/containers/AssignReplies.jsx @@ -0,0 +1,88 @@ +import PropTypes from "prop-types"; +import React from "react"; +import loadData from "./hoc/load-data"; +import gql from "graphql-tag"; +import { withRouter } from "react-router"; +import { StyleSheet, css } from "aphrodite"; +import theme from "../styles/theme"; + +const styles = StyleSheet.create({ + greenBox: { + ...theme.layouts.greenBox + } +}); + +class AssignReplies extends React.Component { + state = { + errors: null + }; + + async componentWillMount() { + console.log("Props",this.props); + try { + + const organizationId = (await this.props.mutations.dynamicReassign( + this.props.params.joinToken, + this.props.params.campaignId + )).data.dynamicReassign; + console.log("ID:", organizationId); + + this.props.router.push(`/app/${organizationId}`); + } catch (err) { + console.log("error assigning replies", err); + const texterMessage = (err && + err.message && + err.message.match(/(Sorry,.+)$/)) || [ + 0, + "Something went wrong trying to assign replies. Please contact your administrator." + ]; + this.setState({ + errors: texterMessage[1] + }); + } + } + renderErrors() { + if (this.state.errors) { + return
{this.state.errors}
; + } + return
; + } + + render() { + return
{this.renderErrors()}
; + } +} + +AssignReplies.propTypes = { + mutations: PropTypes.object, + router: PropTypes.object, + params: PropTypes.object, + campaign: PropTypes.object +}; + +export const dynamicReassignMutation = gql` + mutation dynamicReassign( + $joinToken: String! + $campaignId: String! + ) { + dynamicReassign( + joinToken: $joinToken + campaignId: $campaignId + ) + } +`; + +const mutations = { + dynamicReassign: ownProps => ( + joinToken, + campaignId + ) => ({ + mutation: dynamicReassignMutation, + variables: { + joinToken, + campaignId + } + }) +}; + +export default loadData({ mutations })(withRouter(AssignReplies)); diff --git a/src/routes.jsx b/src/routes.jsx index 8b82f290c..7fce13cfc 100644 --- a/src/routes.jsx +++ b/src/routes.jsx @@ -23,6 +23,7 @@ import CreateOrganization from "./containers/CreateOrganization"; import CreateAdditionalOrganization from "./containers/CreateAdditionalOrganization"; import AdminOrganizationsDashboard from "./containers/AdminOrganizationsDashboard"; import JoinTeam from "./containers/JoinTeam"; +import AssignReplies from "./containers/AssignReplies"; import Home from "./containers/Home"; import Settings from "./containers/Settings"; import Tags from "./containers/Tags"; @@ -275,6 +276,11 @@ export default function makeRoutes(requireAuth = () => {}) { component={CreateAdditionalOrganization} onEnter={requireAuth} /> + { + const features = getFeatures(campaign); + return features.REPLY_BATCH_SIZE || 200; + }, + useDynamicReplies: campaign => { + const features = getFeatures(campaign); + return features.USE_DYNAMIC_REPLIES ? features.USE_DYNAMIC_REPLIES : false; + }, responseWindow: campaign => campaign.response_window || 48, organization: async (campaign, _, { loaders }) => campaign.organization || diff --git a/src/server/api/conversations.js b/src/server/api/conversations.js index 969769fd3..202019768 100644 --- a/src/server/api/conversations.js +++ b/src/server/api/conversations.js @@ -74,6 +74,13 @@ function getConversationsJoinsAndWhereClause( contactsFilter && contactsFilter.messageStatus ); + if (contactsFilter.updatedAtGt) { + query = query.andWhere(function() {this.where('updated_at', '>', contactsFilter.updatedAtGt)}) + } + if (contactsFilter.updatedAtLt) { + query = query.andWhere(function() {this.where('updated_at', '<', contactsFilter.updatedAtLt)}) + } + if (contactsFilter) { if ("isOptedOut" in contactsFilter) { query.where("is_opted_out", contactsFilter.isOptedOut); @@ -126,6 +133,10 @@ function getConversationsJoinsAndWhereClause( ); } } + + if (contactsFilter.orderByRaw) { + query = query.orderByRaw(contactsFilter.orderByRaw); + } } return query; diff --git a/src/server/api/mutations/getOptOutMessage.js b/src/server/api/mutations/getOptOutMessage.js index 3b37e2505..541ee18c0 100644 --- a/src/server/api/mutations/getOptOutMessage.js +++ b/src/server/api/mutations/getOptOutMessage.js @@ -1,40 +1,17 @@ -import { getConfig } from "../lib/config"; -import SmartyStreetsSDK from "smartystreets-javascript-sdk"; import optOutMessageCache from "../../models/cacheable_queries/opt-out-message"; - -const SmartyStreetsCore = SmartyStreetsSDK.core; -const Lookup = SmartyStreetsSDK.usZipcode.Lookup; - -const clientBuilder = new SmartyStreetsCore.ClientBuilder( - new SmartyStreetsCore.StaticCredentials( - getConfig("SMARTY_AUTH_ID"), - getConfig("SMARTY_AUTH_TOKEN") - ) -); -const client = clientBuilder.buildUsZipcodeClient(); +import zipStateCache from "../../models/cacheable_queries/zip"; export const getOptOutMessage = async ( _, { organizationId, zip, defaultMessage } ) => { - const lookup = new Lookup(); - - lookup.zipCode = zip; - try { - const res = await client.send(lookup); - const lookupRes = res.lookups[0].result[0]; + const queryResult = await optOutMessageCache.query({ + organizationId: organizationId, + state: await zipStateCache.query({ zip: zip }) + }); - if (lookupRes.valid) { - const queryResult = await optOutMessageCache.query({ - organizationId: organizationId, - state: lookupRes.zipcodes[0].stateAbbreviation - }); - - return queryResult || defaultMessage; - } - - return defaultMessage; + return queryResult || defaultMessage; } catch (e) { console.error(e); return defaultMessage; diff --git a/src/server/api/schema.js b/src/server/api/schema.js index e8b668917..c0abaa43b 100644 --- a/src/server/api/schema.js +++ b/src/server/api/schema.js @@ -18,6 +18,7 @@ import { Organization, Tag, UserOrganization, + isSqlite, r, cacheableData } from "../models"; @@ -192,7 +193,9 @@ async function editCampaign(id, campaign, loaders, user, origCampaignRecord) { textingHoursStart, textingHoursEnd, timezone, - serviceManagers + serviceManagers, + useDynamicReplies, + replyBatchSize } = campaign; // some changes require ADMIN and we recheck below const organizationId = @@ -258,6 +261,17 @@ async function editCampaign(id, campaign, loaders, user, origCampaignRecord) { }); campaignUpdates.features = JSON.stringify(features); } + if (useDynamicReplies) { + Object.assign(features, { + "USE_DYNAMIC_REPLIES": true, + "REPLY_BATCH_SIZE": replyBatchSize + }) + } else { + Object.assign(features, { + "USE_DYNAMIC_REPLIES": false + }) + } + campaignUpdates.features = JSON.stringify(features); let changed = Boolean(Object.keys(campaignUpdates).length); if (changed) { @@ -416,6 +430,11 @@ async function updateInteractionSteps( origCampaignRecord, idMap = {} ) { + // Allows cascade delete for SQLite + if (isSqlite) { + await r.knex.raw("PRAGMA foreign_keys = ON"); + } + for (let i = 0; i < interactionSteps.length; i++) { const is = interactionSteps[i]; // map the interaction step ids for new ones @@ -1419,6 +1438,63 @@ const rootMutations = { newTexterUserId ); }, + dynamicReassign: async ( + _, + { + joinToken, + campaignId + }, + { user } + ) => { + // verify permissions + const campaign = await r + .knex("campaign") + .where({ + id: campaignId, + join_token: joinToken, + }) + .first(); + const INVALID_REASSIGN = () => { + const error = new GraphQLError("Invalid reassign request - organization not found"); + error.code = "INVALID_REASSIGN"; + return error; + }; + if (!campaign) { + throw INVALID_REASSIGN(); + } + const organization = await cacheableData.organization.load( + campaign.organization_id + ); + if (!organization) { + throw INVALID_REASSIGN(); + } + const maxContacts = getConfig("MAX_REPLIES_PER_TEXTER", organization) ?? 200; + let d = new Date(); + d.setHours(d.getHours() - 1); + const contactsFilter = { messageStatus: 'needsResponse', isOptedOut: false, listSize: maxContacts, orderByRaw: "updated_at DESC", updatedAtLt: d} + const campaignsFilter = { + campaignId: campaignId + }; + + await accessRequired( + user, + organization.id, + "TEXTER", + /* superadmin*/ true + ); + const { campaignIdContactIdsMap } = await getCampaignIdContactIdsMaps( + organization.id, + { + campaignsFilter, + contactsFilter, + } + ); + await reassignConversations( + campaignIdContactIdsMap, + user.id + ); + return organization.id; + }, importCampaignScript: async (_, { campaignId, url }, { user }) => { const campaign = await cacheableData.campaign.load(campaignId); await accessRequired(user, campaign.organization_id, "ADMIN", true); diff --git a/src/server/models/cacheable_queries/README.md b/src/server/models/cacheable_queries/README.md index 584008fc4..faa7226c1 100644 --- a/src/server/models/cacheable_queries/README.md +++ b/src/server/models/cacheable_queries/README.md @@ -109,7 +109,7 @@ manually referencing a key inline. All root keys are prefixed by the environmen * SET `optouts${-orgId|}` * if OPTOUTS_SHARE_ALL_ORGS is set, then orgId='' * optOutMessage - * SET `optoutmessages-${orgId}` + * KEY `optoutmessages-${orgId}` * campaign-contact (only when `REDIS_CONTACT_CACHE=1`) * KEY `contact-${contactId}` * Besides contact data, also includes `organization_id`, `messageservice_sid`, `zip.city`, `zip.state` @@ -130,3 +130,5 @@ manually referencing a key inline. All root keys are prefixed by the environmen * message (only when `REDIS_CONTACT_CACHE=1`) * LIST `messages-${contactId}` * Includes all message data +* zip + * KEY `state-of-${zip}` diff --git a/src/server/models/cacheable_queries/zip.js b/src/server/models/cacheable_queries/zip.js new file mode 100644 index 000000000..9b47498fa --- /dev/null +++ b/src/server/models/cacheable_queries/zip.js @@ -0,0 +1,65 @@ +import { getConfig } from "../../api/lib/config"; +import { r } from ".."; +import SmartyStreetsSDK from "smartystreets-javascript-sdk"; + +// SmartyStreets +const SmartyStreetsCore = SmartyStreetsSDK.core; +const Lookup = SmartyStreetsSDK.usZipcode.Lookup; + +const clientBuilder = new SmartyStreetsCore.ClientBuilder( + new SmartyStreetsCore.StaticCredentials( + getConfig("SMARTY_AUTH_ID"), + getConfig("SMARTY_AUTH_TOKEN") + ) +); +const client = clientBuilder.buildUsZipcodeClient(); + +// Cache +const cacheKey = zip => `${process.env.CACHE_PREFIX || ""}state-of-${zip}`; + +const zipStateCache = { + clearQuery: async ({ zip }) => { + if (r.redis) { + await r.redis.delAsync(cacheKey(zip)); + } + }, + query: async ({ zip }) => { + async function getState() { + const lookup = new Lookup(); + + lookup.zipCode = zip; + + const res = await client.send(lookup); + const lookupRes = res.lookups[0].result[0]; + + if (lookupRes.valid) { + return lookupRes.zipcodes[0].stateAbbreviation; + } else { + throw new Error(`State not found for zip code ${zip}`); + } + } + + if (r.redis) { + const key = cacheKey(zip); + let state = await r.redis.getAsync(key); + + if (state !== null) { + return state; + } + + state = await getState(); + + await r.redis + .multi() + .set(key, state) + .expire(key, 15780000) // 6 months + .execAsync(); + + return state; + } + + return await getState(); + } +}; + +export default zipStateCache; diff --git a/webpack/config.js b/webpack/config.js index 02a775292..68c4f8bd1 100644 --- a/webpack/config.js +++ b/webpack/config.js @@ -1,6 +1,6 @@ const path = require("path"); const webpack = require("webpack"); -const { WebpackManifestPlugin } = require('webpack-manifest-plugin'); +const { WebpackManifestPlugin } = require("webpack-manifest-plugin"); const TerserPlugin = require("terser-webpack-plugin"); const DEBUG = @@ -8,7 +8,7 @@ const DEBUG = const plugins = [ new webpack.ProvidePlugin({ - process: 'process/browser' + process: "process/browser" }), new webpack.DefinePlugin({ "process.env.NODE_ENV": `"${process.env.NODE_ENV}"`, @@ -50,7 +50,9 @@ if (!DEBUG) { } const config = { - mode: ["development", "production"].includes(process.env.NODE_ENV) ? process.env.NODE_ENV : "none", + mode: ["development", "production"].includes(process.env.NODE_ENV) + ? process.env.NODE_ENV + : "none", entry: { bundle: ["babel-polyfill", "./src/client/index.jsx"] }, @@ -68,7 +70,10 @@ const config = { ] }, resolve: { - fallback: { stream: require.resolve("stream-browserify"), zlib: require.resolve("browserify-zlib") }, + fallback: { + stream: require.resolve("stream-browserify"), + zlib: require.resolve("browserify-zlib") + }, mainFields: ["browser", "main", "module"], extensions: [".js", ".jsx", ".json"] },