diff --git a/.eslintrc b/.eslintrc index a6796f1ec..2f45ceb40 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,5 +1,11 @@ { "extends": ["airbnb", "prettier"], "parser": "@babel/eslint-parser", - "env": { "jest": true, "node": true, "browser": true, "jasmine": true } + "env": { "jest": true, "node": true, "browser": true, "jasmine": true }, + "rules": { + "no-console": [ + "warn", + { "allow": ["warn", "error", "info", "time", "timeEnd"] } + ] + } } diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 653166a42..8408fa5dd 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -27,7 +27,7 @@ If applicable, add screenshots to help explain your problem. - OS: [e.g. iOS] - Browser [e.g. chrome, safari] - Desktop or Mobile? - - Version [e.g. 22] + - Spoke Version: [e.g. 14.0.1] **Additional context** Add any other context about the problem here. diff --git a/.github/workflows/cypress-tests.yaml b/.github/workflows/cypress-tests.yaml index b33a2a12f..0c0866f62 100644 --- a/.github/workflows/cypress-tests.yaml +++ b/.github/workflows/cypress-tests.yaml @@ -5,7 +5,7 @@ on: [push] jobs: test: runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 strategy: matrix: node-version: [16.x] diff --git a/.github/workflows/jest-tests.yaml b/.github/workflows/jest-tests.yaml index 16bcd831b..8aaf26613 100644 --- a/.github/workflows/jest-tests.yaml +++ b/.github/workflows/jest-tests.yaml @@ -5,7 +5,7 @@ on: [push, pull_request] jobs: test: runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 strategy: matrix: node-version: [14.x, 15.x, 16.x, 18.x, 20.x] @@ -44,7 +44,7 @@ jobs: run: yarn test test-rediscache-contactcache: runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 services: redis: image: redis @@ -85,7 +85,7 @@ jobs: run: yarn test-rediscache-contactcache test-rediscache: runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 services: redis: image: redis @@ -126,7 +126,7 @@ jobs: run: yarn test-rediscache test-sqlite: runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 services: redis: image: redis 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__/containers/AssignmentTexterContact.test.js b/__test__/containers/AssignmentTexterContact.test.js index a563c52ae..96e09e2ad 100644 --- a/__test__/containers/AssignmentTexterContact.test.js +++ b/__test__/containers/AssignmentTexterContact.test.js @@ -118,6 +118,7 @@ describe("when contact is not within texting hours...", () => { let component = mount( { mount( { /^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 ae9939dbf..e77cfc055 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; @@ -576,6 +577,7 @@ describe("Reassignments", () => { text: "test text autorespond", assignmentId: assignmentId2 }); + return messageRes; } // does this sleep fix the "sometimes 4 instead of 5" below? await sleep(5); @@ -824,6 +826,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, { @@ -849,6 +900,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 94037755f..ca8f7ab3f 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", () => { @@ -165,65 +166,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, @@ -309,7 +252,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" }, { @@ -319,7 +262,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" } ]); @@ -429,7 +372,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, @@ -448,136 +391,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", () => { @@ -641,24 +456,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 }); ({ @@ -747,8 +559,8 @@ describe("mutations.updateQuestionResponses", () => { ); }); - describe("when a response is added", () => { - beforeEach(async () => { + const responseAdded = { + beforeEach: async () => { questionResponses = [ { campaignContactId: contacts[0].id, @@ -776,9 +588,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 }, @@ -818,11 +629,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, @@ -850,115 +765,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, @@ -968,9 +800,8 @@ describe("mutations.updateQuestionResponses", () => { inputInteractionStepsWithActionHandlers, 2 )); - }); - - it("exits early and logs an error", async () => { + }, + earlyExit: async () => { jest .spyOn(ActionHandlers, "rawAllActionHandlers") .mockReturnValue({}); @@ -983,11 +814,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, @@ -997,13 +838,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 }, @@ -1029,11 +867,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, @@ -1043,9 +887,8 @@ describe("mutations.updateQuestionResponses", () => { inputInteractionStepsWithActionHandlers, 2 )); - }); - - it("processes the other actions", async () => { + }, + processOtherActions: async () => { jest .spyOn(ComplexTestActionHandler, "processAction") .mockRejectedValueOnce(new Error("oh no")); @@ -1089,7 +932,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 f111833ec..bc5db483e 100644 --- a/__test__/test_helpers.js +++ b/__test__/test_helpers.js @@ -126,6 +126,75 @@ export async function runGql(operation, variableValues, 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, @@ -311,6 +380,7 @@ export async function createCampaign( const campaignQuery = `mutation createCampaign($input: CampaignInput!) { createCampaign(campaign: $input) { id + joinToken } }`; const variableValues = { diff --git a/dev-tools/.env.test b/dev-tools/.env.test index 3c5c11994..033e06a5b 100644 --- a/dev-tools/.env.test +++ b/dev-tools/.env.test @@ -27,3 +27,4 @@ PHONE_INVENTORY=1 ALLOW_SEND_ALL=false DST_REFERENCE_TIMEZONE='America/New_York' PASSPORT_STRATEGY=local +ASSIGNMENT_LOAD_LIMIT=1 diff --git a/dev-tools/create-test-database b/dev-tools/create-test-database index 1aa4f61eb..080385d7a 100755 --- a/dev-tools/create-test-database +++ b/dev-tools/create-test-database @@ -6,3 +6,7 @@ docker compose exec -T postgres psql -h localhost -p 5432 -U spoke spokedev <` +9. Click "Create". +10. In the code block of the Actions Code Editor, update the `exports.onExecutePostLogin` code as follows: ```javascript -function (user, context, callback) { -context.idToken["https://spoke/user_metadata"] = user.user_metadata; -callback(null, user, context); -} +exports.onExecutePostLogin = async (event, api) => { + api.idToken.setCustomClaim("https://spoke/user_metadata", event.user.user_metadata); +}; ``` -7. Update the Auth0 [Universal Landing page](https://manage.auth0.com/#/login_page), click on the `Customize Login Page` toggle, and copy and paste following code in the drop down into the `Default Templates` space: +11. Click `Deploy`. +12. Navigate to [Actions Flows](https://manage.auth0.com/#/actions/flows). +13. Click "Login". +14. Add "Spoke Action" to the Login flow. +15. Click "Apply". +16. Update the Auth0 [Universal Landing page](https://manage.auth0.com/#/login_page), click on the `Customize Login Page` toggle, and copy and paste following code in the drop down into the `Default Templates` space:
Code to paste into Auth0 @@ -125,4 +136,4 @@ callback(null, user, context); ```
-8. Replace `YOUR_TOS_LINK_HERE` with the link to your Terms of Service and replace `YOUR_PRIVACY_POLICY_LINK_HERE` with the link to your Privacy Policy +17. Replace `YOUR_TOS_LINK_HERE` with the link to your Terms of Service and replace `YOUR_PRIVACY_POLICY_LINK_HERE` with the link to your Privacy Policy diff --git a/docs/HOWTO-use_bulk_sending.md b/docs/HOWTO-use_bulk_sending.md index 0e42c584c..e9b877df5 100644 --- a/docs/HOWTO-use_bulk_sending.md +++ b/docs/HOWTO-use_bulk_sending.md @@ -1,7 +1,5 @@ # How to configure Spoke for bulk sending outside the USA -#### We assume that bulk sending -- sending a text to more than one contact with a single click -- is not legal in the USA. Please consult with an attorney before you do that. - ## About bulk sending If Spoke is configured to do bulk sending, a `Send Bulk` button will appear on the texter's view for the first contact. When the texter clicks that button, the next chunk of contacts will receive the initial message. After Spoke queues up messages for those contacts, if there are any contacts left in the texter's assignment, the next chunk of contacts will receive messages the next time the texter clicks `Send Bulk`. diff --git a/docs/HOWTO_INTEGRATE_BANDWIDTH.md b/docs/HOWTO_INTEGRATE_BANDWIDTH.md index bdf64a257..15f11462d 100644 --- a/docs/HOWTO_INTEGRATE_BANDWIDTH.md +++ b/docs/HOWTO_INTEGRATE_BANDWIDTH.md @@ -1,6 +1,6 @@ # Bandwidth Integration -Bandwidth.com is a telephone service API company. To use Bandwidth, set `DEFAULT_SERVICE=bandwidth`. The `sticky-sender` and `num-picker` service managers are required for the Bandwidth extension to work. `sticky-sender` must come before `numpicker-basic` in the `SERVICE_MANAGERS` environment variable. +Bandwidth.com is a telephone service API company. To use Bandwidth, set `DEFAULT_SERVICE=bandwidth`. The `sticky-sender` and `numpicker-basic` service managers are required for the Bandwidth extension to work. `sticky-sender` must come before `numpicker-basic` in the `SERVICE_MANAGERS` environment variable. For setting up a development environment with Bandwidth, first read [this section](HOWTO_DEVELOPMENT_LOCAL_SETUP.md#ngrok). diff --git a/docs/REFERENCE-environment_variables.md b/docs/REFERENCE-environment_variables.md index 7609dc732..5d2594446 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_. | @@ -83,6 +84,7 @@ | 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. | +| OPT_OUT_PER_STATE | Have different opt-out messages per state and org. Defaults to the organization's default opt-out message for non-specified states or when the Smarty Zip Code API is down. Requires the `SMARTY_AUTH_ID` and `SMARTY_AUTH_TOKEN` environment variables. | | 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_. | | OWNER_CONFIGURABLE | If set to `ALL` then organization owners will be able to configure all available options from their Settings section (otherwise only superadmins will). You can also put a comma-separated list of environment variables to white-list specific settable variables here. This gives organization owners a lot of control of internal settings, so enable at your own risk. | @@ -105,6 +107,8 @@ | SESSION_SECRET | Unique key used to encrypt sessions. _Required_. | | SHOW_SERVER_ERROR | Best practice is to hide errors in production for security purposes which can reveal internal database/system state (even in an open-source project where the code paths are known) | | SLACK_NOTIFY_URL | If set, then on post-install (often from deploying) a message will be posted to a slack channel's `#spoke` channel | +| SMARTY_AUTH_ID | Smarty API authentication ID. Required when the `OPT_OUT_PER_STATE` environment variable is enabled. | +| SMARTY_AUTH_TOKEN | Smarty API authentication token. Required when the `OPT_OUT_PER_STATE` environment variable is enabled. | | SUPPRESS_SELF_INVITE | Boolean value to prevent self-invitations. Recommend setting before making sites available to public. _Default_: false. | | SUPPRESS_DATABASE_AUTOCREATE | Suppress database auto-creation on first start. Mostly just used for test context | | TERMS_REQUIRE | Require texters to accept the [Terms page](../src/containers/Terms.jsx#L85) before they can start texting. _Default_: false | diff --git a/docs/_sidebar.md b/docs/_sidebar.md index a915012d8..50dae8709 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -29,6 +29,7 @@ - [How to configure Auth0 for authentication](HOWTO-configure-auth0.md) - [Instructions for using Redis in Development and Production](HOWTO_CONNECT_WITH_REDIS.md) - [How to configure Slack Authentication](HOWTO_INTEGRATE_SLACK_AUTH.md) + - [How to integrate Bandwidth](HOWTO_INTEGRATE_BANDWIDTH.md) - [How to integrate Twilio](HOWTO_INTEGRATE_TWILIO.md) - [How to handle high volume using Twilio Functions & Amazon SQS](HOWTO_setup_twilio_amazon_SQS.md) - [How to Integrate with Action Kit](HOWTO_INTEGRATE_WITH_ACTIONKIT.md) 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/20240116233906_opt_out_message.js b/migrations/20240116233906_opt_out_message.js new file mode 100644 index 000000000..bc322759a --- /dev/null +++ b/migrations/20240116233906_opt_out_message.js @@ -0,0 +1,31 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = async function(knex) { + await knex.schema.createTable("opt_out_message", table => { + table.increments(); + table.timestamp("created_at").defaultTo(knex.fn.now()); + table.text("message").notNullable(); + table + .integer("organization_id") + .references("id") + .inTable("organization") + .notNullable(); + table.string("state", 2).notNullable(); + + table.index( + ["organization_id", "state"], + "opt_out_message_organization_id_state" + ); + table.unique(["organization_id", "state"]); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = async function(knex) { + await knex.schema.dropTableIfExists("opt_out_message"); +}; 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/package.json b/package.json index 63d102b4a..c4a2803af 100644 --- a/package.json +++ b/package.json @@ -166,6 +166,7 @@ "request": "^2.81.0", "rethink-knex-adapter": "0.4.20", "rollbar": "^2.26.4", + "smartystreets-javascript-sdk": "^5.0.0", "terser-webpack-plugin": "^5.3.10", "thinky": "^2.3.3", "timezonecomplete": "^5.13.1", diff --git a/src/api/campaign.js b/src/api/campaign.js index 58fcf97eb..a6197dfa3 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 5e615d4b5..d1b70d728 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 { @@ -291,6 +294,11 @@ const rootSchema = gql` organizationId: String! textingHoursEnforced: Boolean! ): Organization + getOptOutMessage( + organizationId: String + zip: String + defaultMessage: String + ): String updateOptOutMessage( organizationId: String! optOutMessage: String! @@ -390,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/AssignmentTexter/BulkSendButton.jsx b/src/components/AssignmentTexter/BulkSendButton.jsx index a73b22581..5ddca8660 100644 --- a/src/components/AssignmentTexter/BulkSendButton.jsx +++ b/src/components/AssignmentTexter/BulkSendButton.jsx @@ -1,73 +1,141 @@ import PropTypes from "prop-types"; -import React, { Component } from "react"; +import React, { useState, useEffect } from "react"; import { StyleSheet, css } from "aphrodite"; import Button from "@material-ui/core/Button"; - +import LinearProgress from "@material-ui/core/LinearProgress"; +import Typography from '@material-ui/core/Typography'; +import Snackbar from '@material-ui/core/Snackbar'; +import Alert from '@material-ui/lab/Alert'; + // This is because the Toolbar from material-ui seems to only apply the correct margins if the // immediate child is a Button or other type it recognizes. Can get rid of this if we remove material-ui const styles = StyleSheet.create({ container: { - display: "block", - width: "25ex", - marginLeft: "auto", - marginRight: "auto" + display: "flex", + flexFlow: "column", + alignItems: "center", + width: "30%", + maxWidth: 300, + height: "100%", + marginTop: 10, + marginRight: "auto", + marginLeft: "auto" + }, + progressContainer: { + width: "100%" + }, + progressText: { + position: "relative", + textAlign: "center", + color: "white" + }, + progressBarRoot: { + height: 10, + borderRadius: 5 } }); -export default class BulkSendButton extends Component { - state = { - isSending: false - }; +function BulkSendButton({ + assignment, setDisabled, bulkSendMessages, refreshData, onFinishContact +}) { + const totalChunkSize = window.BULK_SEND_CHUNK_SIZE; + const [isSending, setIsSending] = useState(false); + const [totalSentMessages, setTotalSentMessages] = useState(0); + const [progress, setProgress] = useState(0); + const [errorMessage, setErrorMessage] = useState(''); - sendMessages = async () => { - let sentMessages = 0; + const sendMessages = async () => { + try { + const { data } = await bulkSendMessages(assignment.id); + const sentMessages = data.bulkSendMessages.length; + const updatedTotalSent = totalSentMessages + sentMessages; - this.setState({ isSending: true }); - this.props.setDisabled(true); + if (!sentMessages) { + /* end sending if no messages were left to send */ + setProgress(100); + } else { + setTotalSentMessages(updatedTotalSent); + setProgress((updatedTotalSent / totalChunkSize) * 100); + } + } catch (err) { + console.error(err); + setErrorMessage(err.message); + } + } - console.log(`Start bulk sending messages ${new Date()}`); - while (sentMessages < window.BULK_SEND_CHUNK_SIZE) { - const res = await this.props.bulkSendMessages(this.props.assignment.id); + const handleEndSend = () => { + refreshData(); + setErrorMessage(''); + setIsSending(false); + setProgress(0); + setTotalSentMessages(0); + setDisabled(false); + onFinishContact(); + } - // Check if all messages have been sent - if (!res.data.bulkSendMessages.length) { - break; + useEffect(() => { + if (isSending) { + /* sendMessages will be called the first time when isSending is set to true + and only called again when the progress state is updated and not complete */ + if (progress < 100) { + sendMessages(); + } else { + /* display "sent all" message for half a sec */ + setTimeout(handleEndSend, 500); } - - // Print progress to console - sentMessages += res.data.bulkSendMessages.length; - console.log(`Bulk sent ${sentMessages} messages ${new Date()}`); } - this.props.refreshData(); - console.log(`Finish bulk sending messages ${new Date()}`); - - this.setState({ isSending: false }); - this.props.setDisabled(false); - this.props.onFinishContact(); - }; + }, [isSending, progress]); - render() { - return ( -
+ return ( +
+ {isSending ? ( +
+
+ + {progress === 100 + ? 'Sent all messages!' + : `Sent ${totalSentMessages} of ${totalChunkSize} messages...`} + +
+ +
+ ) : ( -
- ); - } + )} + + + {errorMessage} + + +
+ ); } BulkSendButton.propTypes = { - assignment: PropTypes.object, - onFinishContact: PropTypes.func, - bulkSendMessages: PropTypes.func, - refreshData: PropTypes.func, - setDisabled: PropTypes.func + assignment: PropTypes.shape({ id: PropTypes.number }).isRequired, + onFinishContact: PropTypes.func.isRequired, + bulkSendMessages: PropTypes.func.isRequired, + refreshData: PropTypes.func.isRequired, + setDisabled: PropTypes.func.isRequired }; + +export default BulkSendButton; diff --git a/src/components/AssignmentTexter/Controls.jsx b/src/components/AssignmentTexter/Controls.jsx index 614a38057..3374c3884 100644 --- a/src/components/AssignmentTexter/Controls.jsx +++ b/src/components/AssignmentTexter/Controls.jsx @@ -64,9 +64,10 @@ export class AssignmentTexterContactControls extends React.Component { } this.state = { + contactOptOutMessage: "", questionResponses, filteredCannedResponses: props.campaign.cannedResponses, - optOutMessageText: props.campaign.organization.optOutMessage, + optOutMessageText: "", responsePopoverOpen: false, answerPopoverOpen: false, sideboxCloses: {}, @@ -343,10 +344,18 @@ export class AssignmentTexterContactControls extends React.Component { } }; - handleOpenDialog = () => { + handleOpenDialog = async () => { // delay to avoid accidental tap pass-through with focusing on // the text field -- this is annoying on mobile where the keyboard // pops up, inadvertantly + const optOutMessage = ( + await this.props.getOptOutMessage( + this.props.organizationId, + this.props.contact.zip, + this.props.campaign.organization.optOutMessage + ) + ).data.getOptOutMessage; + this.setState({contactOptOutMessage: optOutMessage, optOutMessageText: optOutMessage}); const update = { optOutDialogOpen: true }; if (this.refs.answerButtons) { // store this, because on-close, we lose this @@ -663,19 +672,18 @@ export class AssignmentTexterContactControls extends React.Component { margin: "9px", color: this.state.optOutMessageText === - this.props.campaign.organization.optOutMessage + this.state.contactOptOutMessage ? "white" : "#494949", backgroundColor: this.state.optOutMessageText === - this.props.campaign.organization.optOutMessage + this.state.contactOptOutMessage ? "#727272" : "white" }} onClick={() => { this.setState({ - optOutMessageText: this.props.campaign.organization - .optOutMessage + optOutMessageText: this.state.contactOptOutMessage }); }} variant="contained" @@ -915,8 +923,7 @@ export class AssignmentTexterContactControls extends React.Component { : opt.answer.value, nextScript: (!isCurrentAnswer(opt) && - opt.answer.nextInteractionStep && - opt.answer.nextInteractionStep.script) || + opt.answer.nextInteractionStep?.script) || null }); }} @@ -1267,6 +1274,7 @@ AssignmentTexterContactControls.propTypes = { review: PropTypes.string, // parent config/callbacks + getOptOutMessage: PropTypes.func, startingMessage: PropTypes.string, onMessageFormSubmit: PropTypes.func, onOptOut: PropTypes.func, diff --git a/src/components/CampaignDynamicAssignmentForm.jsx b/src/components/CampaignDynamicAssignmentForm.jsx index de98d6b56..851194641 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

@@ -214,7 +262,8 @@ CampaignDynamicAssignmentForm.propTypes = { saveDisabled: type.bool, joinToken: type.string, responseWindow: type.number, - batchSize: type.string + batchSize: type.string, + replyBatchSize: type.string }; export default 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 ab8a9eeb3..acd7fed08 100644 --- a/src/containers/AdminCampaignEdit.jsx +++ b/src/containers/AdminCampaignEdit.jsx @@ -138,6 +138,8 @@ const campaignInfoFragment = ` state count } + useDynamicReplies + replyBatchSize `; export const campaignDataQuery = gql`query getCampaign($campaignId: String!) { @@ -513,7 +515,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..311b03f49 --- /dev/null +++ b/src/containers/AssignReplies.jsx @@ -0,0 +1,85 @@ +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() { + try { + + const organizationId = (await this.props.mutations.dynamicReassign( + this.props.params.joinToken, + this.props.params.campaignId + )).data.dynamicReassign; + + this.props.router.push(`/app/${organizationId}`); + } catch (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/containers/AssignmentTexterContact.jsx b/src/containers/AssignmentTexterContact.jsx index 2d3a9989b..26a388080 100644 --- a/src/containers/AssignmentTexterContact.jsx +++ b/src/containers/AssignmentTexterContact.jsx @@ -412,6 +412,7 @@ export class AssignmentTexterContact extends React.Component {
) : null} (organizationId, zip, defaultMessage) => ({ + mutation: gql` + mutation getOptOutMessage( + $organizationId: String + $zip: String + $defaultMessage: String + ) { + getOptOutMessage( + organizationId: $organizationId + zip: $zip + defaultMessage: $defaultMessage + ) + } + `, + variables: { + organizationId, + zip, + defaultMessage + } + }), updateContactTags: ownProps => (tags, campaignContactId) => ({ mutation: gql` mutation updateContactTags( diff --git a/src/extensions/contact-loaders/s3-pull/index.js b/src/extensions/contact-loaders/s3-pull/index.js index 2e11fec62..61dfec43e 100644 --- a/src/extensions/contact-loaders/s3-pull/index.js +++ b/src/extensions/contact-loaders/s3-pull/index.js @@ -189,7 +189,9 @@ export async function loadContactS3PullProcessFile(jobEvent, contextVars) { return { alreadyComplete: 1 }; } - await r.knex.batchInsert("campaign_contact", insertRows); + await r.knex.batchInsert("campaign_contact", insertRows).catch(e => { + console.error("Error with S3 pull batch insertion for campaign", campaign_id, e); + }); } if (fileIndex < manifestData.entries.length - 1) { diff --git a/src/extensions/message-handlers/auto-optout/index.js b/src/extensions/message-handlers/auto-optout/index.js index 78366f963..0b750fd64 100644 --- a/src/extensions/message-handlers/auto-optout/index.js +++ b/src/extensions/message-handlers/auto-optout/index.js @@ -1,5 +1,6 @@ import { getConfig, getFeatures } from "../../../server/api/lib/config"; import { cacheableData } from "../../../server/models"; +import { getOptOutMessage } from "../../../server/api/mutations"; import { sendRawMessage } from "../../../server/api/mutations/sendMessage"; const DEFAULT_AUTO_OPTOUT_REGEX_LIST_BASE64 = @@ -149,10 +150,14 @@ export const postMessageSave = async ({ contact || (await cacheableData.campaignContact.load(message.campaign_contact_id)); - const optOutMessage = - getFeatures(organization).opt_out_message || - getConfig("OPT_OUT_MESSAGE", organization) || - "I'm opting you out of texts immediately. Have a great day."; + const optOutMessage = await getOptOutMessage(null, { + organizationId: organization.id, + zip: contact.zip, + defaultMessage: + getFeatures(organization).opt_out_message || + getConfig("OPT_OUT_MESSAGE", organization) || + "I'm opting you out of texts immediately. Have a great day." + }); await sendRawMessage({ finalText: optOutMessage, 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 e105f1536..ab9072921 100644 --- a/src/server/api/conversations.js +++ b/src/server/api/conversations.js @@ -75,6 +75,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); @@ -127,6 +134,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 new file mode 100644 index 000000000..541ee18c0 --- /dev/null +++ b/src/server/api/mutations/getOptOutMessage.js @@ -0,0 +1,19 @@ +import optOutMessageCache from "../../models/cacheable_queries/opt-out-message"; +import zipStateCache from "../../models/cacheable_queries/zip"; + +export const getOptOutMessage = async ( + _, + { organizationId, zip, defaultMessage } +) => { + try { + const queryResult = await optOutMessageCache.query({ + organizationId: organizationId, + state: await zipStateCache.query({ zip: zip }) + }); + + return queryResult || defaultMessage; + } catch (e) { + console.error(e); + return defaultMessage; + } +}; diff --git a/src/server/api/mutations/index.js b/src/server/api/mutations/index.js index 331a2f741..26a666a46 100644 --- a/src/server/api/mutations/index.js +++ b/src/server/api/mutations/index.js @@ -4,6 +4,7 @@ export { buyPhoneNumbers, deletePhoneNumbers } from "./buyPhoneNumbers"; export { getShortCodes} from "./getShortCodes"; export { editOrganization } from "./editOrganization"; export { findNewCampaignContact } from "./findNewCampaignContact"; +export { getOptOutMessage } from "./getOptOutMessage"; export { joinOrganization } from "./joinOrganization"; export { releaseContacts } from "./releaseContacts"; export { sendMessage } from "./sendMessage"; diff --git a/src/server/api/schema.js b/src/server/api/schema.js index 13bc6c540..a5bca916c 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"; @@ -61,6 +62,7 @@ import { deletePhoneNumbers, getShortCodes, findNewCampaignContact, + getOptOutMessage, joinOrganization, editOrganization, releaseContacts, @@ -192,7 +194,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 +262,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) { @@ -394,11 +409,7 @@ async function editCampaign(id, campaign, loaders, user, origCampaignRecord) { }); // hacky easter egg to force reload campaign contacts - if ( - r.redis && - campaignUpdates.description && - campaignUpdates.description.endsWith("..") - ) { + if (r.redis && campaignUpdates.description?.endsWith("..")) { // some asynchronous cache-priming console.log( "force-loading loadCampaignCache", @@ -423,6 +434,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 @@ -764,6 +780,7 @@ const rootMutations = { return await cacheableData.organization.load(organizationId); }, + getOptOutMessage, updateOptOutMessage: async ( _, { organizationId, optOutMessage }, @@ -1264,6 +1281,15 @@ const rootMutations = { usedFields[f] = 1; }); } + + if ( + getConfig("OPT_OUT_PER_STATE") && + getConfig("SMARTY_AUTH_ID") && + getConfig("SMARTY_AUTH_TOKEN") + ) { + usedFields.zip = 1; + } + return finalContacts.map(c => (c && { ...c, usedFields }) || c); } } @@ -1415,6 +1441,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/middleware/render-index.js b/src/server/middleware/render-index.js index d53cdd473..d8ad777f0 100644 --- a/src/server/middleware/render-index.js +++ b/src/server/middleware/render-index.js @@ -137,6 +137,9 @@ export default function renderIndex(html, css, assetMap) { window.ASSIGNMENT_CONTACTS_SIDEBAR=${getConfig( "ASSIGNMENT_CONTACTS_SIDEBAR" )} + window.OPT_OUT_PER_STATE=${getConfig("OPT_OUT_PER_STATE", null, { + truthy: true + })} diff --git a/src/server/models/cacheable_queries/README.md b/src/server/models/cacheable_queries/README.md index 09c1d8e8e..faa7226c1 100644 --- a/src/server/models/cacheable_queries/README.md +++ b/src/server/models/cacheable_queries/README.md @@ -108,6 +108,8 @@ manually referencing a key inline. All root keys are prefixed by the environmen * optOut * SET `optouts${-orgId|}` * if OPTOUTS_SHARE_ALL_ORGS is set, then orgId='' +* optOutMessage + * 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` @@ -128,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/campaign.js b/src/server/models/cacheable_queries/campaign.js index 48d04c2bf..21384d05b 100644 --- a/src/server/models/cacheable_queries/campaign.js +++ b/src/server/models/cacheable_queries/campaign.js @@ -207,7 +207,9 @@ const load = async (id, opts) => { } } - return await Campaign.get(id); + const campaign = await Campaign.get(id) + campaign.contactTimezones = await dbContactTimezones(id); + return campaign; }; const campaignCache = { diff --git a/src/server/models/cacheable_queries/opt-out-message.js b/src/server/models/cacheable_queries/opt-out-message.js new file mode 100644 index 000000000..068edab57 --- /dev/null +++ b/src/server/models/cacheable_queries/opt-out-message.js @@ -0,0 +1,45 @@ +import { r } from "../../models"; + +const cacheKey = (orgId, state) => + `${process.env.CACHE_PREFIX || ""}optoutmessages-${orgId}-${state}`; + +const optOutMessageCache = { + clearQuery: async ({ organizationId, state }) => { + if (r.redis) { + await r.redis.delAsync(cacheKey(organizationId, state)); + } + }, + query: async ({ organizationId, state }) => { + async function getMessage() { + const res = await r + .knex("opt_out_message") + .select("message") + .where({ state: state }) + .limit(1); + + return res.length ? res[0].message : ""; + } + if (r.redis) { + const key = cacheKey(organizationId, state); + let message = await r.redis.getAsync(key); + + if (message !== null) { + return message; + } + + message = await getMessage(); + + await r.redis + .multi() + .set(key, message) + .expire(key, 15780000) // 6 months + .execAsync(); + + return message; + } + + return await getMessage(); + } +}; + +export default optOutMessageCache; 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/src/server/models/index.js b/src/server/models/index.js index 19cb3cf10..093416fa4 100644 --- a/src/server/models/index.js +++ b/src/server/models/index.js @@ -48,6 +48,7 @@ function createLoader(model, opts) { // This is in dependency order, so tables are after their dependencies const tableList = [ "organization", // good candidate? + "opt_out_message", "user", // good candidate "campaign", // good candidate "campaign_admin", diff --git a/webpack/config.js b/webpack/config.js index 7f7ebe641..647871110 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"] }, diff --git a/yarn.lock b/yarn.lock index 745821811..73f4ef0e0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6510,7 +6510,14 @@ axe-core@^4.9.1: resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.9.1.tgz#fcd0f4496dad09e0c899b44f6c4bb7848da912ae" integrity sha512-QbUdXJVTpvUTHU7871ppZkdOLBeGUKBQWHkHrvN2V9IQWGMt61zf3B45BtzjxEJzYuj0JBjBZP/hmYS/R9pmAw== -axios@0.28.0, axios@^1.6.0, axios@^1.6.7: +axios-retry@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/axios-retry/-/axios-retry-4.0.0.tgz#d5cb8ea1db18e05ce6f08aa5fe8b2663bba48e60" + integrity sha512-F6P4HVGITD/v4z9Lw2mIA24IabTajvpDZmKa6zq/gGwn57wN5j1P3uWrAV0+diqnW6kTM2fTqmWNfgYWGmMuiA== + dependencies: + is-retry-allowed "^2.2.0" + +axios@0.28.0, axios@^1.6.0, axios@^1.6.2, axios@^1.6.7: version "0.28.0" resolved "https://registry.yarnpkg.com/axios/-/axios-0.28.0.tgz#801a4d991d0404961bccef46800e1170f8278c89" integrity sha512-Tu7NYoGY4Yoc7I+Npf9HhUMtEEpV7ZiLH9yndTCoNhcpBH0kwcvFbzYN9/u5QKI5A6uefjsNNWaz5olJVYS62Q== @@ -10144,43 +10151,6 @@ express@^4.17.1, express@^4.17.3, express@^4.19.2: utils-merge "1.0.1" vary "~1.1.2" -express@^4.19.2: - version "4.19.2" - resolved "https://registry.yarnpkg.com/express/-/express-4.19.2.tgz#e25437827a3aa7f2a827bc8171bbbb664a356465" - integrity sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q== - dependencies: - accepts "~1.3.8" - array-flatten "1.1.1" - body-parser "1.20.2" - content-disposition "0.5.4" - content-type "~1.0.4" - cookie "0.6.0" - cookie-signature "1.0.6" - debug "2.6.9" - depd "2.0.0" - encodeurl "~1.0.2" - escape-html "~1.0.3" - etag "~1.8.1" - finalhandler "1.2.0" - fresh "0.5.2" - http-errors "2.0.0" - merge-descriptors "1.0.1" - methods "~1.1.2" - on-finished "2.4.1" - parseurl "~1.3.3" - path-to-regexp "0.1.7" - proxy-addr "~2.0.7" - qs "6.11.0" - range-parser "~1.2.1" - safe-buffer "5.2.1" - send "0.18.0" - serve-static "1.15.0" - setprototypeof "1.2.0" - statuses "2.0.1" - type-is "~1.6.18" - utils-merge "1.0.1" - vary "~1.1.2" - extend-shallow@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-2.0.1.tgz#51af7d614ad9a9f610ea1bafbb989d6b1c56890f" @@ -12314,6 +12284,11 @@ is-relative@^1.0.0: dependencies: is-unc-path "^1.0.0" +is-retry-allowed@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/is-retry-allowed/-/is-retry-allowed-2.2.0.tgz#88f34cbd236e043e71b6932d09b0c65fb7b4d71d" + integrity sha512-XVm7LOeLpTW4jV19QSH38vkswxoLud8sQ57YwJVTPWdiaI9I8keEhGFpBlslyVsgdQy4Opg8QOLb8YRgsyZiQg== + is-root@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/is-root/-/is-root-2.1.0.tgz#809e18129cf1129644302a4f8544035d51984a9c" @@ -18013,6 +17988,14 @@ smart-buffer@^4.1.0, smart-buffer@^4.2.0: resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae" integrity sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg== +smartystreets-javascript-sdk@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/smartystreets-javascript-sdk/-/smartystreets-javascript-sdk-5.0.0.tgz#e34e0baf4b4aa192e573fd13c39bfbe5447a67f6" + integrity sha512-MGGGVMUapLa6JeOow0afbc700aV+5uTloV6U7dbaBaPdF6Nw/6akxGK9UW8Eu4pyR7rpUigQaEoj4cjcMfagyw== + dependencies: + axios "^1.6.2" + axios-retry "4.0.0" + snapdragon-node@^2.0.1: version "2.1.1" resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b" @@ -18408,7 +18391,7 @@ string-natural-compare@^3.0.1: resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4" integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw== -"string-width-cjs@npm:string-width@^4.2.0": +"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -18426,15 +18409,6 @@ string-width@^1.0.1: is-fullwidth-code-point "^1.0.0" strip-ansi "^3.0.0" -"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - string-width@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" @@ -18551,14 +18525,7 @@ stringify-object@^3.3.0: is-obj "^1.0.1" is-regexp "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@6.0.1, strip-ansi@^3.0.0, strip-ansi@^3.0.1, strip-ansi@^4.0.0, strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0, strip-ansi@^6.0.0, strip-ansi@^6.0.1, strip-ansi@^7.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@6.0.1, strip-ansi@^3.0.0, strip-ansi@^3.0.1, strip-ansi@^4.0.0, strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0, strip-ansi@^6.0.0, strip-ansi@^6.0.1, strip-ansi@^7.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -20330,7 +20297,7 @@ workbox-window@6.6.1: "@types/trusted-types" "^2.0.2" workbox-core "6.6.1" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -20356,15 +20323,6 @@ wrap-ansi@^5.1.0: string-width "^3.0.0" strip-ansi "^5.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"