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..32416c351 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. 13.0.0] **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 02e8f3273..82f773c6c 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 76f5df1ca..d1eb44003 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] @@ -41,7 +41,7 @@ jobs: run: yarn test test-rediscache-contactcache: runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 services: redis: image: redis @@ -74,7 +74,7 @@ jobs: run: yarn test-rediscache-contactcache test-rediscache: runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 services: redis: image: redis @@ -107,7 +107,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/README.md b/README.md index 8b1a48417..13cbd3346 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,7 @@ # StateVoices is the new community steward for Spoke! -On November 19th, the repo Spoke was transfered from MoveOn to StateVoices. - -[Join us for the Merge Party for more information.](https://www.mobilize.us/statevoices/event/592881/) +On November 19th, the repo Spoke was transfered from MoveOn to StateVoices. If you're looking for the most up-to-date Spoke to fork from, start [at the StateVoices repo](https://github.com/StateVoicesNational/Spoke) ## Spoke History @@ -12,7 +10,14 @@ Spoke is an open source text-distribution tool for organizations to mobilize sup Spoke was created by Saikat Chakrabarti and Sheena Pakanati, and is now maintained by MoveOn.org. -The latest version is [13.1.0](https://github.com/StateVoicesNational/Spoke/tree/13.1.0) (see [release notes](https://github.com/StateVoicesNational/Spoke/blob/main/docs/RELEASE_NOTES.md#v1310)) +The latest version can be found on the [State Voices repo page](https://github.com/StateVoicesNational/Spoke) (see [release notes](https://github.com/StateVoicesNational/Spoke/blob/main/docs/RELEASE_NOTES.md)) + +If you're a developer switching over from the MoveOn fork to the new StateVoices base, you can run: +``` +git remote set-url upstream https://github.com/StateVoicesNational/Spoke +git remote set-url origin https://github.com/StateVoicesNational/Spoke +``` +to reset where your repo is pointing and ensure you're pulling the correct code. ## Setting up Spoke @@ -29,7 +34,7 @@ Want to know more? ### Quick Start with Heroku This version of Spoke suitable for testing and, potentially, for small campaigns. This won't cost any money and will not support production(aka large-scale) usage. It's a great way to practice deploying Spoke or see it in action. - + Deploy 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 a1ca4199b..61f4c6758 100644 --- a/__test__/containers/AssignmentTexterContact.test.js +++ b/__test__/containers/AssignmentTexterContact.test.js @@ -120,6 +120,7 @@ describe("when contact is not within texting hours...", () => { let component = mount( { component = 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/updateQuestionResponses.test.js b/__test__/server/api/campaign/updateQuestionResponses.test.js index 7073188a5..381f6f328 100644 --- a/__test__/server/api/campaign/updateQuestionResponses.test.js +++ b/__test__/server/api/campaign/updateQuestionResponses.test.js @@ -5,6 +5,8 @@ import { cleanupTest, createScript, createStartedCampaign, + mockInteractionSteps, + muiTheme, runGql, sendMessage, setupTest, @@ -33,7 +35,6 @@ import { contactDataFragment } from "../../../../src/containers/TexterTodo"; -import { muiTheme } from "../../../test_helpers"; import ThemeContext from "../../../../src/containers/context/ThemeContext"; describe("mutations.updateQuestionResponses", () => { @@ -166,65 +167,7 @@ describe("mutations.updateQuestionResponses", () => { describe("when called through the mutation", () => { beforeEach(async () => { - const inputInteractionSteps = [ - { - id: "new_1", - questionText: "What is your favorite color", - script: "Hello {firstName}. Let's talk about your favorite color.", - answerOption: "", - answerActions: "", - answerActionsData: "", - parentInteractionId: null, - isDeleted: false, - interactionSteps: [ - { - id: "new_2", - questionText: "What is your favorite shade of red?", - script: "Red is an awesome color, {firstName}!", - answerOption: "Red", - answerActions: "", - answerActionsData: "", - parentInteractionId: "new_1", - isDeleted: false, - interactionSteps: [ - { - id: "new_21", - questionText: "", - script: "Crimson is a rad shade of red, {firstName}", - answerOption: "Crimson", - answerActions: "", - answerActionsData: "", - parentInteractionId: "new_2", - isDeleted: false, - interactionSteps: [] - }, - { - id: "new_22", - questionText: "", - script: "Firebrick is a rad shade of red, {firstName}", - answerOption: "Firebrick", - answerActions: "", - answerActionsData: "", - parentInteractionId: "new_2", - isDeleted: false, - interactionSteps: [] - } - ] - }, - { - id: "new_3", - questionText: "", - script: "Purple is an awesome color, {firstName}!", - answerOption: "Purple", - answerActions: "", - answerActionsData: "", - parentInteractionId: "new_1", - isDeleted: false, - interactionSteps: [] - } - ] - } - ]; + const inputInteractionSteps = [mockInteractionSteps]; ({ interactionSteps, @@ -310,7 +253,7 @@ describe("mutations.updateQuestionResponses", () => { campaign_id: Number(campaign.id), question: "What is your favorite color", script: "Hello {firstName}. Let's talk about your favorite color.", - answer_actions: "", + answer_actions: "complex-test-action", value: "Red" }, { @@ -320,7 +263,7 @@ describe("mutations.updateQuestionResponses", () => { campaign_id: Number(campaign.id), question: "What is your favorite shade of red?", script: "Red is an awesome color, {firstName}!", - answer_actions: "", + answer_actions: "complex-test-action", value: "Crimson" } ]); @@ -430,7 +373,7 @@ describe("mutations.updateQuestionResponses", () => { expect(databaseQueryResults.rows || databaseQueryResults).toEqual([ { - answer_actions: "", + answer_actions: "complex-test-action", answer_option: "Red", campaign_id: 1, child_id: 2, @@ -449,136 +392,8 @@ describe("mutations.updateQuestionResponses", () => { let inputInteractionStepsWithoutActionHandlers; beforeEach(async () => { - inputInteractionStepsWithoutActionHandlers = [ - { - id: "new_1", - questionText: "What is your favorite color", - script: "Hello {firstName}. Let's talk about your favorite color.", - answerOption: "", - answerActions: "", - answerActionsData: "", - parentInteractionId: null, - isDeleted: false, - interactionSteps: [ - { - id: "new_2", - questionText: "What is your favorite shade of red?", - script: "Red is an awesome color, {firstName}!", - answerOption: "Red", - answerActions: "", - answerActionsData: "", - parentInteractionId: "new_1", - isDeleted: false, - interactionSteps: [ - { - id: "new_21", - questionText: "", - script: "Crimson is a rad shade of red, {firstName}", - answerOption: "Crimson", - answerActions: "", - answerActionsData: "", - parentInteractionId: "new_2", - isDeleted: false, - interactionSteps: [] - }, - { - id: "new_22", - questionText: "", - script: "Firebrick is a rad shade of red, {firstName}", - answerOption: "Firebrick", - answerActions: "", - answerActionsData: "", - parentInteractionId: "new_2", - isDeleted: false, - interactionSteps: [] - } - ] - }, - { - id: "new_3", - questionText: "", - script: "Purple is an awesome color, {firstName}!", - answerOption: "Purple", - answerActions: "", - answerActionsData: "", - parentInteractionId: "new_1", - isDeleted: false, - interactionSteps: [] - } - ] - } - ]; - - inputInteractionStepsWithActionHandlers = [ - { - id: "new_1", - questionText: "What is your favorite color", - script: "Hello {firstName}. Let's talk about your favorite color.", - answerOption: "", - answerActions: "", - answerActionsData: "", - parentInteractionId: null, - isDeleted: false, - interactionSteps: [ - { - id: "new_2", - questionText: "What is your favorite shade of red?", - script: "Red is an awesome color, {firstName}!", - answerOption: "Red", - answerActions: "complex-test-action", - answerActionsData: "red answer actions data", - parentInteractionId: "new_1", - isDeleted: false, - interactionSteps: [ - { - id: "new_21", - questionText: "", - script: "Crimson is a rad shade of red, {firstName}", - answerOption: "Crimson", - answerActions: "complex-test-action", - answerActionsData: "crimson answer actions data", - parentInteractionId: "new_2", - isDeleted: false, - interactionSteps: [] - }, - { - id: "new_22", - questionText: "", - script: "Firebrick is a rad shade of red, {firstName}", - answerOption: "Firebrick", - answerActions: "", - answerActionsData: "", - parentInteractionId: "new_2", - isDeleted: false, - interactionSteps: [] - } - ] - }, - { - id: "new_3", - questionText: "", - script: "Purple is an awesome color, {firstName}!", - answerOption: "Purple", - answerActions: "", - answerActionsData: "", - parentInteractionId: "new_1", - isDeleted: false, - interactionSteps: [] - }, - { - id: "new_4", - questionText: "", - script: "Blue is an awesome color, {firstName}!", - answerOption: "Blue", - answerActions: "complex-test-action", - answerActionsData: "blue answer actions data", - parentInteractionId: "new_1", - isDeleted: false, - interactionSteps: [] - } - ] - } - ]; + inputInteractionStepsWithoutActionHandlers = [mockInteractionSteps]; + inputInteractionStepsWithActionHandlers = [mockInteractionSteps]; }); describe("happy path", () => { @@ -642,24 +457,21 @@ describe("mutations.updateQuestionResponses", () => { }); describe("when some of the steps have an action handler", () => { + function getMessagePass(received, expectedObject) { + let pass = false; + if (received?.id && expectedObject?.id) { + pass = Number(received.id) === Number(expectedObject.id); + } + const message = pass ? "ok" : "fail"; + return { + message, + pass + }; + } + beforeEach(async () => { expect.extend({ - objectWithId: (received, expectedObject) => { - let pass = false; - if ( - received && - received.id && - expectedObject && - expectedObject.id - ) { - pass = Number(received.id) === Number(expectedObject.id); - } - const message = pass ? "ok" : "fail"; - return { - message, - pass - }; - } + objectWithId: getMessagePass }); ({ @@ -748,8 +560,8 @@ describe("mutations.updateQuestionResponses", () => { ); }); - describe("when a response is added", () => { - beforeEach(async () => { + const responseAdded = { + beforeEach: async () => { questionResponses = [ { campaignContactId: contacts[0].id, @@ -777,9 +589,8 @@ describe("mutations.updateQuestionResponses", () => { ComplexTestActionHandler, "processDeletedQuestionResponse" ); - }); - - it("calls the action handler for the new response", async () => { + }, + it1: async () => { await Mutations.updateQuestionResponses( undefined, { questionResponses, campaignContactId: contacts[0].id }, @@ -819,11 +630,115 @@ describe("mutations.updateQuestionResponses", () => { ComplexTestActionHandler.processAction.mock.calls[0][0] .previousValue ).toBeNull(); - }); + } + } + + describe("when a response is added", () => { + beforeEach(responseAdded.beforeEach); + + it("calls the action handler for the new response", responseAdded.it1); }); - describe("when responses are added, resubmitted with no change, updated, and deleted", () => { - beforeEach(async () => { + async function deletedResponse () { + await Mutations.updateQuestionResponses( + undefined, + { + questionResponses: [questionResponses[0]], + campaignContactId: contacts[0].id + }, + { loaders, user: texterUser } + ); + + expect( + ComplexTestActionHandler.processAction + ).not.toHaveBeenCalled(); + + expect( + ComplexTestActionHandler.processDeletedQuestionResponse + ).toHaveBeenCalled(); + + expect( + ComplexTestActionHandler.processDeletedQuestionResponse.mock + .calls[0][0].questionResponse + ).toEqual(questionResponses[1]); + + expect( + ComplexTestActionHandler.processDeletedQuestionResponse.mock.calls[0][0].interactionStep.id.toString() + ).toEqual(shadesOfRedInteractionSteps[0].id); + + expect( + ComplexTestActionHandler.processDeletedQuestionResponse.mock + .calls[0][0].campaignContactId + ).toEqual(contacts[0].id); + + expect( + ComplexTestActionHandler.processDeletedQuestionResponse.mock + .calls[0][0].contact.id + ).toEqual(contacts[0].id); + + expect( + ComplexTestActionHandler.processDeletedQuestionResponse.mock.calls[0][0].campaign.id.toString() + ).toEqual(campaign.id); + + expect( + ComplexTestActionHandler.processDeletedQuestionResponse.mock.calls[0][0].organization.id.toString() + ).toEqual(organization.id); + + expect( + ComplexTestActionHandler.processDeletedQuestionResponse.mock + .calls[0][0].previousValue + ).toEqual("Crimson"); + } + + async function newResponse() { + await Mutations.updateQuestionResponses( + undefined, + { questionResponses, campaignContactId: contacts[0].id }, + { loaders, user: texterUser } + ); + + await sleep(100); + + expect( + ComplexTestActionHandler.processAction + ).not.toHaveBeenCalled(); + expect( + ComplexTestActionHandler.processDeletedQuestionResponse + ).not.toHaveBeenCalled(); + } + + async function setQuestionResponseValue() { + questionResponses[0].value = "Blue"; + } + + async function updatedResponse() { + await Mutations.updateQuestionResponses( + undefined, + { questionResponses, campaignContactId: contacts[0].id }, + { loaders, user: texterUser } + ); + + expect( + ComplexTestActionHandler.processDeletedQuestionResponse + ).not.toHaveBeenCalled(); + expect(ComplexTestActionHandler.processAction.mock.calls).toEqual( + expect.arrayContaining([ + [ + expect.objectContaining({ + actionObject: expect.objectWithId(colorInteractionSteps[2]), + campaignContactId: Number(contacts[0].id), + contact: expect.objectWithId(contacts[0]), + campaign: expect.objectWithId(campaign), + organization: expect.objectWithId(organization), + previousValue: "Red" + }) + ] + ]) + ); + } + + const responseResubmitted = { + beforeEach: async () => { questionResponses = [ { campaignContactId: contacts[0].id, @@ -851,115 +766,32 @@ describe("mutations.updateQuestionResponses", () => { ComplexTestActionHandler, "processDeletedQuestionResponse" ); - }); + }, + saved: () => { + it("calls processAction for the new question response", newResponse); + }, + updated: () => { + beforeEach(setQuestionResponseValue); - describe("when one of the question responses has already been saved with the same value", () => { - it("calls processAction for the new question response", async () => { - await Mutations.updateQuestionResponses( - undefined, - { questionResponses, campaignContactId: contacts[0].id }, - { loaders, user: texterUser } - ); - - await sleep(100); - - expect( - ComplexTestActionHandler.processAction - ).not.toHaveBeenCalled(); - expect( - ComplexTestActionHandler.processDeletedQuestionResponse - ).not.toHaveBeenCalled(); - }); - }); + it("calls processAction for for the updated response, and it passes in previousValue", updatedResponse); + }, + deleted: () => { + it("calls processDeletedQuestionResponse", deletedResponse); + } + } - describe("when one of the question responses was updated", () => { - beforeEach(async () => { - questionResponses[0].value = "Blue"; - }); - - it("calls processAction for for the updated response, and it passes in previousValue", async () => { - await Mutations.updateQuestionResponses( - undefined, - { questionResponses, campaignContactId: contacts[0].id }, - { loaders, user: texterUser } - ); - - expect( - ComplexTestActionHandler.processDeletedQuestionResponse - ).not.toHaveBeenCalled(); - expect(ComplexTestActionHandler.processAction.mock.calls).toEqual( - expect.arrayContaining([ - [ - expect.objectContaining({ - actionObject: expect.objectWithId(colorInteractionSteps[2]), - campaignContactId: Number(contacts[0].id), - contact: expect.objectWithId(contacts[0]), - campaign: expect.objectWithId(campaign), - organization: expect.objectWithId(organization), - previousValue: "Red" - }) - ] - ]) - ); - }); - }); + describe("when responses are added, resubmitted with no change, updated, and deleted", () => { + beforeEach(responseResubmitted.beforeEach); - describe("when one of the question responses is deleted", () => { - it("calls processDeletedQuestionResponse", async () => { - await Mutations.updateQuestionResponses( - undefined, - { - questionResponses: [questionResponses[0]], - campaignContactId: contacts[0].id - }, - { loaders, user: texterUser } - ); - - expect( - ComplexTestActionHandler.processAction - ).not.toHaveBeenCalled(); - - expect( - ComplexTestActionHandler.processDeletedQuestionResponse - ).toHaveBeenCalled(); - - expect( - ComplexTestActionHandler.processDeletedQuestionResponse.mock - .calls[0][0].questionResponse - ).toEqual(questionResponses[1]); - - expect( - ComplexTestActionHandler.processDeletedQuestionResponse.mock.calls[0][0].interactionStep.id.toString() - ).toEqual(shadesOfRedInteractionSteps[0].id); - - expect( - ComplexTestActionHandler.processDeletedQuestionResponse.mock - .calls[0][0].campaignContactId - ).toEqual(contacts[0].id); - - expect( - ComplexTestActionHandler.processDeletedQuestionResponse.mock - .calls[0][0].contact.id - ).toEqual(contacts[0].id); - - expect( - ComplexTestActionHandler.processDeletedQuestionResponse.mock.calls[0][0].campaign.id.toString() - ).toEqual(campaign.id); - - expect( - ComplexTestActionHandler.processDeletedQuestionResponse.mock.calls[0][0].organization.id.toString() - ).toEqual(organization.id); - - expect( - ComplexTestActionHandler.processDeletedQuestionResponse.mock - .calls[0][0].previousValue - ).toEqual("Crimson"); - }); - }); + describe("when one of the question responses has already been saved with the same value", responseResubmitted.saved); + + describe("when one of the question responses was updated", responseResubmitted.updated); + + describe("when one of the question responses is deleted", responseResubmitted.deleted); }); - describe("when no action handlers are configured", () => { - beforeEach(async () => { + const noActionHandlersConfigured = { + beforeEach: async () => { ({ interactionSteps, redInteractionStep, @@ -969,9 +801,8 @@ describe("mutations.updateQuestionResponses", () => { inputInteractionStepsWithActionHandlers, 2 )); - }); - - it("exits early and logs an error", async () => { + }, + earlyExit: async () => { jest .spyOn(ActionHandlers, "rawAllActionHandlers") .mockReturnValue({}); @@ -984,11 +815,21 @@ describe("mutations.updateQuestionResponses", () => { ); expect(cacheableData.organization.load).not.toHaveBeenCalled(); - }); + } + } + + describe("when no action handlers are configured", () => { + beforeEach(noActionHandlersConfigured.beforeEach); + + it("exits early and logs an error", noActionHandlersConfigured.earlyExit); }); - describe("when task dispatch fails", () => { - beforeEach(async () => { + function throwError() { + throw new Error("foo"); + } + + const taskDispatchFails = { + beforeEach: async () => { ({ interactionSteps, redInteractionStep, @@ -998,13 +839,10 @@ describe("mutations.updateQuestionResponses", () => { inputInteractionStepsWithActionHandlers, 2 )); - }); - - it("dispatches other actions", async () => { + }, + dispatchOtherActions: async () => { jest.spyOn(ComplexTestActionHandler, "processAction"); - jest.spyOn(jobRunner, "dispatchTask").mockImplementationOnce(() => { - throw new Error("foo"); - }); + jest.spyOn(jobRunner, "dispatchTask").mockImplementationOnce(throwError); await Mutations.updateQuestionResponses( {}, { questionResponses, campaignContactId: contacts[0].id }, @@ -1030,11 +868,17 @@ describe("mutations.updateQuestionResponses", () => { } ] ]); - }); + } + } + + describe("when task dispatch fails", () => { + beforeEach(taskDispatchFails.beforeEach); + + it("dispatches other actions", taskDispatchFails.dispatchOtherActions); }); - describe("when the action handler throws an exception", () => { - beforeEach(async () => { + const actionHandlerThrowsException = { + beforeEach: async () => { ({ interactionSteps, redInteractionStep, @@ -1044,9 +888,8 @@ describe("mutations.updateQuestionResponses", () => { inputInteractionStepsWithActionHandlers, 2 )); - }); - - it("processes the other actions", async () => { + }, + processOtherActions: async () => { jest .spyOn(ComplexTestActionHandler, "processAction") .mockRejectedValueOnce(new Error("oh no")); @@ -1090,7 +933,13 @@ describe("mutations.updateQuestionResponses", () => { ] ]) ); - }); + } + } + + describe("when the action handler throws an exception", () => { + beforeEach(actionHandlerThrowsException.beforeEach); + + it("processes the other actions", actionHandlerThrowsException.processOtherActions); }); }); }); diff --git a/__test__/test_helpers.js b/__test__/test_helpers.js index 377a92a8b..6c81b11bf 100644 --- a/__test__/test_helpers.js +++ b/__test__/test_helpers.js @@ -117,6 +117,75 @@ export async function runGql(operation, vars, user) { return result; } +export const mockInteractionSteps = { + id: "new_1", + questionText: "What is your favorite color", + script: "Hello {firstName}. Let's talk about your favorite color.", + answerOption: "", + answerActions: "", + answerActionsData: "", + parentInteractionId: null, + isDeleted: false, + interactionSteps: [ + { + id: "new_2", + questionText: "What is your favorite shade of red?", + script: "Red is an awesome color, {firstName}!", + answerOption: "Red", + answerActions: "complex-test-action", + answerActionsData: "red answer actions data", + parentInteractionId: "new_1", + isDeleted: false, + interactionSteps: [ + { + id: "new_21", + questionText: "", + script: "Crimson is a rad shade of red, {firstName}", + answerOption: "Crimson", + answerActions: "complex-test-action", + answerActionsData: "crimson answer actions data", + parentInteractionId: "new_2", + isDeleted: false, + interactionSteps: [] + }, + { + id: "new_22", + questionText: "", + script: "Firebrick is a rad shade of red, {firstName}", + answerOption: "Firebrick", + answerActions: "", + answerActionsData: "", + parentInteractionId: "new_2", + isDeleted: false, + interactionSteps: [] + } + ] + }, + { + id: "new_3", + questionText: "", + script: "Purple is an awesome color, {firstName}!", + answerOption: "Purple", + answerActions: "", + answerActionsData: "", + parentInteractionId: "new_1", + isDeleted: false, + interactionSteps: [] + }, + { + id: "new_4", + questionText: "", + script: "Blue is an awesome color, {firstName}!", + answerOption: "Blue", + answerActions: "complex-test-action", + answerActionsData: "blue answer actions data", + parentInteractionId: "new_1", + isDeleted: false, + interactionSteps: [] + } + ] +}; + export const updateUserRoles = async ( adminUser, organizationId, diff --git a/cypress.config.js b/cypress.config.js new file mode 100644 index 000000000..6087a638d --- /dev/null +++ b/cypress.config.js @@ -0,0 +1,14 @@ +const { defineConfig } = require('cypress') + +module.exports = defineConfig({ + fixturesFolder: '__test__/cypress/fixtures', + video: true, + e2e: { + setupNodeEvents(on, config) { + return require('./__test__/cypress/plugins/index.js')(on, config) + }, + baseUrl: 'http://localhost:3001', + specPattern: '__test__/cypress/integration/*.test.js', + supportFile: '__test__/cypress/support/index.js', + }, +}) diff --git a/cypress.json b/cypress.json deleted file mode 100644 index d0ccaa08d..000000000 --- a/cypress.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "baseUrl": "http://localhost:3001", - "integrationFolder": "__test__/cypress/integration", - "fixturesFolder": "__test__/cypress/fixtures", - "pluginsFile": "__test__/cypress/plugins/index.js", - "supportFile": "__test__/cypress/support/index.js", - "testFiles": "*.test.js", - "video": true -} diff --git a/dev-tools/.env.test b/dev-tools/.env.test index 341b8b3d4..bb9e37c82 100644 --- a/dev-tools/.env.test +++ b/dev-tools/.env.test @@ -26,3 +26,4 @@ PHONE_INVENTORY=1 ALLOW_SEND_ALL=false DST_REFERENCE_TIMEZONE='America/New_York' PASSPORT_STRATEGY=local +ASSIGNMENT_LOAD_LIMIT=1 diff --git a/dev-tools/create-test-database b/dev-tools/create-test-database index 693dd00db..a27b07a96 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 d127753a2..fe7ce62a3 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 994544d15..3a7bed028 100644 --- a/docs/REFERENCE-environment_variables.md +++ b/docs/REFERENCE-environment_variables.md @@ -6,6 +6,7 @@ | ASSETS_DIR | Directory path where front-end packaged JavaScript is saved and loaded. _Required_. | | ASSETS_MAP_FILE | File name of map file, within ASSETS_DIR, containing map of general file names to unique build-specific file names. | | ASSIGNMENT_CONTACTS_SIDEBAR | Show a sidebar with a list of contacts to the texter. Allows texter to freely navigate between conversations, regardless of status. | +| ASSIGNMENT_LOAD_LIMIT | Limit of contacts to load at one time for an assignment. Used when Spoke is deployed on a service with time and bandwidth limitations, such as AWS Lambda. Type: integer | | AUTH0_DOMAIN | Domain name on Auth0 account, should end in `.auth0.com`, e.g. `example.auth0.com`. _Required_. | | AUTH0_CLIENT_ID | Client ID from Auth0 app. _Required_. | | AUTH0_CLIENT_SECRET | Client secret from Auth0 app. _Required_. | @@ -82,6 +83,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. | @@ -104,6 +106,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/RELEASE_NOTES.md b/docs/RELEASE_NOTES.md index db23fc126..5cf197b57 100644 --- a/docs/RELEASE_NOTES.md +++ b/docs/RELEASE_NOTES.md @@ -1,5 +1,23 @@ # Release Notes +## v13.2.0 +_December 2023_ Version 13.2.0 + +13.2.0 is a minor release. + +### Bug Fixes +* Set contactTimezones for campaign when Redis is disabled + +### Improvements +* Additional error catching +* Update bug report template +* Indicate progress in UI for bulk send +* Support for PostgreSQL 15 +* Update Auth0 instructions + +### Appreciations +* [Arique Aguilar](https://github.com/Arique1104), [Cody Gordon](https://github.com/codygordon), [Kathy Nguyen](https://github.com/crayolakat), and Harold Travis for QA. + ## v13.1.0 _October 2023_ Version 13.1.0 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/jest.config.js b/jest.config.js index 00f4a91b5..09aab9c8a 100644 --- a/jest.config.js +++ b/jest.config.js @@ -37,7 +37,8 @@ module.exports = { moduleNameMapper: { "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/__mocks__/fileMock.js", - "\\.(css|less)$": "/__mocks__/styleMock.js" + "\\.(css|less)$": "/__mocks__/styleMock.js", + "^axios$": "axios/dist/node/axios.cjs" }, collectCoverageFrom: [ "**/*.{js,jsx}", 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/package.json b/package.json index 480488034..4fff68181 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "spoke", - "version": "13.1.0", + "version": "13.2.0", "description": "Spoke", "main": "src/server", "engines": { @@ -157,6 +157,7 @@ "request": "^2.81.0", "rethink-knex-adapter": "0.4.20", "rollbar": "^2.4.4", + "smartystreets-javascript-sdk": "^5.0.0", "terser-webpack-plugin": "4", "thinky": "^2.3.3", "timezonecomplete": "^5.5.0", @@ -173,7 +174,7 @@ "@babel/eslint-parser": "^7.19.1", "babel-jest": "^29.3.1", "babel-preset-es2017": "^6.24.1", - "cypress": "5.6.0", + "cypress": "13.6.4", "cypress-file-upload": "^4.0.6", "cypress-wait-until": "^1.7.1", "enzyme": "^3.11.0", diff --git a/src/api/schema.js b/src/api/schema.js index b68f58796..ad2370bee 100644 --- a/src/api/schema.js +++ b/src/api/schema.js @@ -291,6 +291,11 @@ const rootSchema = gql` organizationId: String! textingHoursEnforced: Boolean! ): Organization + getOptOutMessage( + organizationId: String + zip: String + defaultMessage: String + ): String updateOptOutMessage( organizationId: String! optOutMessage: String! 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 fe47ed1bd..2a4b463d2 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: {}, @@ -341,10 +342,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 @@ -662,19 +671,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" @@ -914,8 +922,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 }); }} @@ -1266,6 +1273,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/containers/AssignmentTexterContact.jsx b/src/containers/AssignmentTexterContact.jsx index 5f4867a04..12c1393ac 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 5fd74ac98..c78e29174 100644 --- a/src/extensions/contact-loaders/s3-pull/index.js +++ b/src/extensions/contact-loaders/s3-pull/index.js @@ -187,7 +187,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 64a900a5c..9f0af417f 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 = @@ -139,10 +140,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/server/api/assignment.js b/src/server/api/assignment.js index debc700b6..730bbf61d 100644 --- a/src/server/api/assignment.js +++ b/src/server/api/assignment.js @@ -114,6 +114,14 @@ export function getContacts( query = query.where({ assignment_id: assignment.id }); + + let assignmentLoadLimit = getConfig("ASSIGNMENT_LOAD_LIMIT"); + if (assignmentLoadLimit) { + assignmentLoadLimit = parseInt(assignmentLoadLimit); + if (!isNaN(assignmentLoadLimit)) { + query.limit(assignmentLoadLimit); + } + } } if (contactsFilter) { 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 94e5b2369..eb596c0ad 100644 --- a/src/server/api/mutations/index.js +++ b/src/server/api/mutations/index.js @@ -3,6 +3,7 @@ export { bulkUpdateScript } from "./bulkUpdateScript"; export { buyPhoneNumbers, deletePhoneNumbers } from "./buyPhoneNumbers"; 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 4d15b33cf..a7d61fb36 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"; @@ -60,6 +61,7 @@ import { buyPhoneNumbers, deletePhoneNumbers, findNewCampaignContact, + getOptOutMessage, joinOrganization, editOrganization, releaseContacts, @@ -393,11 +395,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", @@ -419,6 +417,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 @@ -758,6 +761,7 @@ const rootMutations = { return await cacheableData.organization.load(organizationId); }, + getOptOutMessage, updateOptOutMessage: async ( _, { organizationId, optOutMessage }, @@ -1261,6 +1265,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); } } 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 4a1623d5d..faae91f86 100644 --- a/src/server/models/cacheable_queries/campaign.js +++ b/src/server/models/cacheable_queries/campaign.js @@ -206,7 +206,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 02a775292..68c4f8bd1 100644 --- a/webpack/config.js +++ b/webpack/config.js @@ -1,6 +1,6 @@ const path = require("path"); const webpack = require("webpack"); -const { WebpackManifestPlugin } = require('webpack-manifest-plugin'); +const { WebpackManifestPlugin } = require("webpack-manifest-plugin"); const TerserPlugin = require("terser-webpack-plugin"); const DEBUG = @@ -8,7 +8,7 @@ const DEBUG = const plugins = [ new webpack.ProvidePlugin({ - process: 'process/browser' + process: "process/browser" }), new webpack.DefinePlugin({ "process.env.NODE_ENV": `"${process.env.NODE_ENV}"`, @@ -50,7 +50,9 @@ if (!DEBUG) { } const config = { - mode: ["development", "production"].includes(process.env.NODE_ENV) ? process.env.NODE_ENV : "none", + mode: ["development", "production"].includes(process.env.NODE_ENV) + ? process.env.NODE_ENV + : "none", entry: { bundle: ["babel-polyfill", "./src/client/index.jsx"] }, @@ -68,7 +70,10 @@ const config = { ] }, resolve: { - fallback: { stream: require.resolve("stream-browserify"), zlib: require.resolve("browserify-zlib") }, + fallback: { + stream: require.resolve("stream-browserify"), + zlib: require.resolve("browserify-zlib") + }, mainFields: ["browser", "main", "module"], extensions: [".js", ".jsx", ".json"] }, diff --git a/yarn.lock b/yarn.lock index 4bdae6ad2..c9982c3ff 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1282,20 +1282,10 @@ resolved "https://registry.yarnpkg.com/@csstools/selector-specificity/-/selector-specificity-2.0.2.tgz#1bfafe4b7ed0f3e4105837e056e0a89b108ebe36" integrity sha512-IkpVW/ehM1hWKln4fCA3NzJU8KwD+kIOvPZA4cqxoJHtE21CCzjyp+Kxbu0i5I4tBNOlXPL9mjwnWlL0VEG4Fg== -"@cypress/listr-verbose-renderer@^0.4.1": - version "0.4.1" - resolved "https://registry.yarnpkg.com/@cypress/listr-verbose-renderer/-/listr-verbose-renderer-0.4.1.tgz#a77492f4b11dcc7c446a34b3e28721afd33c642a" - integrity sha512-EDiBsVPWC27DDLEJCo+dpl9ODHhdrwU57ccr9tspwCdG2ni0QVkf6LF0FGbhfujcjPxnXLIwsaks4sOrwrA4Qw== - dependencies: - chalk "^1.1.3" - cli-cursor "^1.0.2" - date-fns "^1.27.2" - figures "^1.7.0" - -"@cypress/request@^2.88.5": - version "2.88.10" - resolved "https://registry.yarnpkg.com/@cypress/request/-/request-2.88.10.tgz#b66d76b07f860d3a4b8d7a0604d020c662752cce" - integrity sha512-Zp7F+R93N0yZyG34GutyTNr+okam7s/Fzc1+i3kcqOP8vk6OuajuE9qZJ6Rs+10/1JFtXFYMdyarnU1rZuJesg== +"@cypress/request@^3.0.0": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@cypress/request/-/request-3.0.1.tgz#72d7d5425236a2413bd3d8bb66d02d9dc3168960" + integrity sha512-TWivJlJi8ZDx2wGOw1dbLuHJKUYX7bWySw377nlnGOW3hP9/MUKIsEdXT/YngWxVdgNCHRBmFlBipE+5/2ZZlQ== dependencies: aws-sign2 "~0.7.0" aws4 "^1.8.0" @@ -1310,9 +1300,9 @@ json-stringify-safe "~5.0.1" mime-types "~2.1.19" performance-now "^2.1.0" - qs "~6.5.2" + qs "6.10.4" safe-buffer "^5.1.2" - tough-cookie "~2.5.0" + tough-cookie "^4.1.3" tunnel-agent "^0.6.0" uuid "^8.3.2" @@ -2390,10 +2380,10 @@ "@types/mime" "*" "@types/node" "*" -"@types/sinonjs__fake-timers@^6.0.1": - version "6.0.4" - resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-6.0.4.tgz#0ecc1b9259b76598ef01942f547904ce61a6a77d" - integrity sha512-IFQTJARgMUBF+xVd2b+hIgXWrZEjND3vJtRCvIelcFB5SIXfjV4bOHbHJ0eXKh+0COrBRc8MqteKAz/j88rE0A== +"@types/sinonjs__fake-timers@8.1.1": + version "8.1.1" + resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.1.tgz#b49c2c70150141a15e0fa7e79cf1f92a72934ce3" + integrity sha512-0kSuKjAS0TrGLJ0M/+8MaFkGsQhZpB6pxOmvS3K8FYI72K//YmdfoW9X2qPsAKh1mkwxGD5zib9s1FIFed6E8g== "@types/sizzle@^2.3.2": version "2.3.3" @@ -2450,6 +2440,13 @@ dependencies: "@types/yargs-parser" "*" +"@types/yauzl@^2.9.1": + version "2.10.3" + resolved "https://registry.yarnpkg.com/@types/yauzl/-/yauzl-2.10.3.tgz#e9b2808b4f109504a03cda958259876f61017999" + integrity sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q== + dependencies: + "@types/node" "*" + "@types/zen-observable@^0.8.0": version "0.8.3" resolved "https://registry.yarnpkg.com/@types/zen-observable/-/zen-observable-0.8.3.tgz#781d360c282436494b32fe7d9f7f8e64b3118aa3" @@ -2950,12 +2947,17 @@ ansi-colors@^3.0.0: resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-3.2.4.tgz#e3a3da4bfbae6c86a9c285625de124a234026fbf" integrity sha512-hHUXGagefjN2iRrID63xckIvotOXOojhQKWIPUZ4mNUZ9nLZW+7FMNoE1lOkEhNWYsx/7ysGIuJYCiMAA9FnrA== +ansi-colors@^4.1.1: + version "4.1.3" + resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.3.tgz#37611340eb2243e70cc604cad35d63270d48781b" + integrity sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw== + ansi-escapes@^3.0.0: version "3.2.0" resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-3.2.0.tgz#8780b98ff9dbf5638152d1f1fe5c1d7b4442976b" integrity sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ== -ansi-escapes@^4.2.1, ansi-escapes@^4.3.1: +ansi-escapes@^4.2.1, ansi-escapes@^4.3.0, ansi-escapes@^4.3.1: version "4.3.2" resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e" integrity sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ== @@ -3167,7 +3169,7 @@ apollo-utilities@1.3.4, apollo-utilities@^1.0.1, apollo-utilities@^1.3.0, apollo resolved "https://registry.yarnpkg.com/aproba/-/aproba-2.0.0.tgz#52520b8ae5b569215b354efc0caa3fe1e45a8adc" integrity sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ== -arch@^2.1.2: +arch@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/arch/-/arch-2.2.0.tgz#1bc47818f305764f23ab3306b0bfc086c5a29d11" integrity sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ== @@ -3409,6 +3411,11 @@ ast-types@0.x.x: dependencies: tslib "^2.0.1" +astral-regex@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31" + integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ== + async-each@^1.0.1: version "1.0.3" resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.3.tgz#b727dbf87d7651602f06f4d4ac387f47d91b0cbf" @@ -3516,6 +3523,13 @@ axe-core@^4.4.3: resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.5.2.tgz#823fdf491ff717ac3c58a52631d4206930c1d9f7" integrity sha512-u2MVsXfew5HBvjsczCv+xlwdNnB1oQR9HlAcsejZttNjKKSkeDNVwB1vMThIUIFI9GoT57Vtk8iQLwqOfAkboA== +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.21.1: version "0.21.4" resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.4.tgz#c67b90dc0568e5c1cf2b0b858c43ba28e2eda575" @@ -3530,6 +3544,15 @@ axios@^0.26.1: dependencies: follow-redirects "^1.14.8" +axios@^1.6.2: + version "1.6.5" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.5.tgz#2c090da14aeeab3770ad30c3a1461bc970fb0cd8" + integrity sha512-Ii012v05KEVuUoFWmMW/UQv9aRIc3ZwkWDcM+h5Il8izZCtRVpDUfwpoFf7eOtajT3QiGR4yDUx7lPqHJULgbg== + dependencies: + follow-redirects "^1.15.4" + form-data "^4.0.0" + proxy-from-env "^1.1.0" + axobject-query@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-2.2.0.tgz#943d47e10c0b704aa42275e20edf3722648989be" @@ -3846,7 +3869,7 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== -base64-js@^1.0.2, base64-js@^1.3.0, base64-js@^1.5.1: +base64-js@^1.0.2, base64-js@^1.3.0, base64-js@^1.3.1, base64-js@^1.5.1: version "1.5.1" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== @@ -3923,7 +3946,7 @@ bindings@^1.5.0: dependencies: file-uri-to-path "1.0.0" -blob-util@2.0.2: +blob-util@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/blob-util/-/blob-util-2.0.2.tgz#3b4e3c281111bb7f11128518006cdc60b403a1eb" integrity sha512-T7JQa+zsXXEa6/8ZhHcQEW1UFfVM49Ts65uBkFL6fz2QmrElqmbajIDJvuA0tEhRe5eIjpV9ZF+0RfZR9voJFQ== @@ -4231,6 +4254,14 @@ buffer@4.9.2: ieee754 "^1.1.4" isarray "^1.0.0" +buffer@^5.6.0: + version "5.7.1" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" + integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.1.13" + buffer@~5.2.1: version "5.2.1" resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.2.1.tgz#dd57fa0f109ac59c602479044dca7b8b3d0b71d6" @@ -4610,13 +4641,6 @@ clean-stack@^2.0.0: resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b" integrity sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A== -cli-cursor@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-1.0.2.tgz#64da3f7d56a54412e59794bd62dc35295e8f2987" - integrity sha512-25tABq090YNKkF6JH7lcwO0zFJTRke4Jcq9iX2nr/Sz0Cjjv4gckmwlW6Ty/aoyFd6z3ysR2hMGC2GFugmBo6A== - dependencies: - restore-cursor "^1.0.1" - cli-cursor@^2.0.0, cli-cursor@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-2.1.0.tgz#b35dac376479facc3e94747d41d0d0f5238ffcb5" @@ -4624,7 +4648,14 @@ cli-cursor@^2.0.0, cli-cursor@^2.1.0: dependencies: restore-cursor "^2.0.0" -cli-table3@~0.6.0: +cli-cursor@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-3.1.0.tgz#264305a7ae490d1d03bf0c9ba7c925d1753af307" + integrity sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw== + dependencies: + restore-cursor "^3.1.0" + +cli-table3@~0.6.1: version "0.6.3" resolved "https://registry.yarnpkg.com/cli-table3/-/cli-table3-0.6.3.tgz#61ab765aac156b52f222954ffc607a6f01dbeeb2" integrity sha512-w5Jac5SykAeZJKntOxJCrm63Eg5/4dhMWIcuTbo9rpE+brgaSZo0RuNJZeOyMgsUdhDeojvgyQLmjI+K50ZGyg== @@ -4648,6 +4679,14 @@ cli-truncate@^0.2.1: slice-ansi "0.0.4" string-width "^1.0.1" +cli-truncate@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/cli-truncate/-/cli-truncate-2.1.0.tgz#c39e28bf05edcde5be3b98992a22deed5a2b93c7" + integrity sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg== + dependencies: + slice-ansi "^3.0.0" + string-width "^4.2.0" + cli@^0.4.5: version "0.4.5" resolved "https://registry.yarnpkg.com/cli/-/cli-0.4.5.tgz#78f9485cd161b566e9a6c72d7170c4270e81db61" @@ -4776,6 +4815,11 @@ colorette@2.0.19, colorette@^2.0.10, colorette@^2.0.14: resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.19.tgz#cdf044f47ad41a0f4b56b3a0d5b4e6e1a2d5a798" integrity sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ== +colorette@^2.0.16: + version "2.0.20" + resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.20.tgz#9eb793e6833067f7235902fcd3b09917a000a95a" + integrity sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w== + colors@1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/colors/-/colors-1.0.3.tgz#0433f44d809680fdeb60ed260f1b0c262e82a40b" @@ -4808,10 +4852,10 @@ commander@^4.0.1: resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068" integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA== -commander@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/commander/-/commander-5.1.0.tgz#46abbd1652f8e059bddaef99bbdcb2ad9cf179ae" - integrity sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg== +commander@^6.2.1: + version "6.2.1" + resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c" + integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA== commander@^7.0.0, commander@^7.2.0: version "7.2.0" @@ -4878,7 +4922,7 @@ concat-map@0.0.1: resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== -concat-stream@^1.6.0, concat-stream@^1.6.1, concat-stream@^1.6.2, concat-stream@~1.6.0: +concat-stream@^1.6.0, concat-stream@^1.6.1, concat-stream@~1.6.0: version "1.6.2" resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34" integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw== @@ -5391,48 +5435,52 @@ cypress-wait-until@^1.7.1: resolved "https://registry.yarnpkg.com/cypress-wait-until/-/cypress-wait-until-1.7.2.tgz#7f534dd5a11c89b65359e7a0210f20d3dfc22107" integrity sha512-uZ+M8/MqRcpf+FII/UZrU7g1qYZ4aVlHcgyVopnladyoBrpoaMJ4PKZDrdOJ05H5RHbr7s9Tid635X3E+ZLU/Q== -cypress@5.6.0: - version "5.6.0" - resolved "https://registry.yarnpkg.com/cypress/-/cypress-5.6.0.tgz#6781755c3ddfd644ce3179fcd7389176c0c82280" - integrity sha512-cs5vG3E2JLldAc16+5yQxaVRLLqMVya5RlrfPWkC72S5xrlHFdw7ovxPb61s4wYweROKTyH01WQc2PFzwwVvyQ== +cypress@13.6.4: + version "13.6.4" + resolved "https://registry.yarnpkg.com/cypress/-/cypress-13.6.4.tgz#42c88d3ee0342f1681abfacabf9c1f082676bc53" + integrity sha512-pYJjCfDYB+hoOoZuhysbbYhEmNW7DEDsqn+ToCLwuVowxUXppIWRr7qk4TVRIU471ksfzyZcH+mkoF0CQUKnpw== dependencies: - "@cypress/listr-verbose-renderer" "^0.4.1" - "@cypress/request" "^2.88.5" + "@cypress/request" "^3.0.0" "@cypress/xvfb" "^1.2.4" - "@types/sinonjs__fake-timers" "^6.0.1" + "@types/sinonjs__fake-timers" "8.1.1" "@types/sizzle" "^2.3.2" - arch "^2.1.2" - blob-util "2.0.2" + arch "^2.2.0" + blob-util "^2.0.2" bluebird "^3.7.2" + buffer "^5.6.0" cachedir "^2.3.0" chalk "^4.1.0" check-more-types "^2.24.0" - cli-table3 "~0.6.0" - commander "^5.1.0" + cli-cursor "^3.1.0" + cli-table3 "~0.6.1" + commander "^6.2.1" common-tags "^1.8.0" - debug "^4.1.1" - eventemitter2 "^6.4.2" - execa "^4.0.2" + dayjs "^1.10.4" + debug "^4.3.4" + enquirer "^2.3.6" + eventemitter2 "6.4.7" + execa "4.1.0" executable "^4.1.1" - extract-zip "^1.7.0" - fs-extra "^9.0.1" + extract-zip "2.0.1" + figures "^3.2.0" + fs-extra "^9.1.0" getos "^3.2.1" - is-ci "^2.0.0" - is-installed-globally "^0.3.2" + is-ci "^3.0.0" + is-installed-globally "~0.4.0" lazy-ass "^1.6.0" - listr "^0.14.3" - lodash "^4.17.19" + listr2 "^3.8.3" + lodash "^4.17.21" log-symbols "^4.0.0" - minimist "^1.2.5" - moment "^2.27.0" + minimist "^1.2.8" ospath "^1.2.2" - pretty-bytes "^5.4.1" - ramda "~0.26.1" + pretty-bytes "^5.6.0" + process "^0.11.10" + proxy-from-env "1.0.0" request-progress "^3.0.0" - supports-color "^7.2.0" + semver "^7.5.3" + supports-color "^8.1.1" tmp "~0.2.1" untildify "^4.0.0" - url "^0.11.0" yauzl "^2.10.0" damerau-levenshtein@^1.0.8: @@ -5481,6 +5529,11 @@ date-fns@^2.19.0: resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.29.3.tgz#27402d2fc67eb442b511b70bbdf98e6411cd68a8" integrity sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA== +dayjs@^1.10.4: + version "1.11.10" + resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.10.tgz#68acea85317a6e164457d6d6947564029a6a16a0" + integrity sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ== + dayjs@^1.8.29: version "1.11.6" resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.6.tgz#2e79a226314ec3ec904e3ee1dd5a4f5e5b1c7afb" @@ -6114,6 +6167,14 @@ enhanced-resolve@^5.10.0: graceful-fs "^4.2.9" tapable "^2.2.0" +enquirer@^2.3.6: + version "2.4.1" + resolved "https://registry.yarnpkg.com/enquirer/-/enquirer-2.4.1.tgz#93334b3fbd74fc7097b224ab4a8fb7e40bf4ae56" + integrity sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ== + dependencies: + ansi-colors "^4.1.1" + strip-ansi "^6.0.1" + entities@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55" @@ -6645,10 +6706,10 @@ event-target-shim@^5.0.0: resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== -eventemitter2@^6.4.2: - version "6.4.9" - resolved "https://registry.yarnpkg.com/eventemitter2/-/eventemitter2-6.4.9.tgz#41f2750781b4230ed58827bc119d293471ecb125" - integrity sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg== +eventemitter2@6.4.7: + version "6.4.7" + resolved "https://registry.yarnpkg.com/eventemitter2/-/eventemitter2-6.4.7.tgz#a7f6c4d7abf28a14c1ef3442f21cb306a054271d" + integrity sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg== eventemitter3@^4.0.0: version "4.0.7" @@ -6678,6 +6739,21 @@ evp_bytestokey@^1.0.0, evp_bytestokey@^1.0.3: md5.js "^1.3.4" safe-buffer "^5.1.1" +execa@4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/execa/-/execa-4.1.0.tgz#4e5491ad1572f2f17a77d388c6c857135b22847a" + integrity sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA== + dependencies: + cross-spawn "^7.0.0" + get-stream "^5.0.0" + human-signals "^1.1.1" + is-stream "^2.0.0" + merge-stream "^2.0.0" + npm-run-path "^4.0.0" + onetime "^5.1.0" + signal-exit "^3.0.2" + strip-final-newline "^2.0.0" + execa@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/execa/-/execa-1.0.0.tgz#c6236a5bb4df6d6f15e88e7f017798216749ddd8" @@ -6706,21 +6782,6 @@ execa@^2.0.3: signal-exit "^3.0.2" strip-final-newline "^2.0.0" -execa@^4.0.2: - version "4.1.0" - resolved "https://registry.yarnpkg.com/execa/-/execa-4.1.0.tgz#4e5491ad1572f2f17a77d388c6c857135b22847a" - integrity sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA== - dependencies: - cross-spawn "^7.0.0" - get-stream "^5.0.0" - human-signals "^1.1.1" - is-stream "^2.0.0" - merge-stream "^2.0.0" - npm-run-path "^4.0.0" - onetime "^5.1.0" - signal-exit "^3.0.2" - strip-final-newline "^2.0.0" - execa@^5.0.0: version "5.1.1" resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd" @@ -6743,11 +6804,6 @@ executable@^4.1.1: dependencies: pify "^2.2.0" -exit-hook@^1.0.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/exit-hook/-/exit-hook-1.1.1.tgz#f05ca233b48c05d54fff07765df8507e95c02ff8" - integrity sha512-MsG3prOVw1WtLXAZbM3KiYtooKR1LvxHh3VHsVtIy0uiUu8usxgB/94DP2HxtD/661lLdB6yzQ09lGJSQr6nkg== - exit@^0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c" @@ -6854,15 +6910,16 @@ extglob@^2.0.4: snapdragon "^0.8.1" to-regex "^3.0.1" -extract-zip@^1.7.0: - version "1.7.0" - resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-1.7.0.tgz#556cc3ae9df7f452c493a0cfb51cc30277940927" - integrity sha512-xoh5G1W/PB0/27lXgMQyIhP5DSY/LhoCsOyZgb+6iMmRtCwVBo55uKaMoEYrDCKQhWvqEip5ZPKAc6eFNyf/MA== +extract-zip@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-2.0.1.tgz#663dca56fe46df890d5f131ef4a06d22bb8ba13a" + integrity sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg== dependencies: - concat-stream "^1.6.2" - debug "^2.6.9" - mkdirp "^0.5.4" + debug "^4.1.1" + get-stream "^5.1.0" yauzl "^2.10.0" + optionalDependencies: + "@types/yauzl" "^2.9.1" extsprintf@1.3.0: version "1.3.0" @@ -6997,6 +7054,13 @@ figures@^2.0.0: dependencies: escape-string-regexp "^1.0.5" +figures@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/figures/-/figures-3.2.0.tgz#625c18bd293c604dc4a8ddb2febf0c88341746af" + integrity sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg== + dependencies: + escape-string-regexp "^1.0.5" + file-entry-cache@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027" @@ -7173,6 +7237,11 @@ follow-redirects@^1.0.0, follow-redirects@^1.14.0, follow-redirects@^1.14.8: resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.2.tgz#b460864144ba63f2681096f274c4e57026da2c13" integrity sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA== +follow-redirects@^1.15.4: + version "1.15.5" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.5.tgz#54d4d6d062c0fa7d9d17feb008461550e3ba8020" + integrity sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw== + for-each@^0.3.3: version "0.3.3" resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e" @@ -7321,7 +7390,7 @@ fs-extra@^10.0.0: jsonfile "^6.0.1" universalify "^2.0.0" -fs-extra@^9.0.0, fs-extra@^9.0.1: +fs-extra@^9.0.0, fs-extra@^9.0.1, fs-extra@^9.1.0: version "9.1.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.1.0.tgz#5954460c764a8da2094ba3554bf839e6b9a7c86d" integrity sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ== @@ -7502,7 +7571,7 @@ get-stream@^4.0.0: dependencies: pump "^3.0.0" -get-stream@^5.0.0: +get-stream@^5.0.0, get-stream@^5.1.0: version "5.2.0" resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.2.0.tgz#4966a1795ee5ace65e706c4b7beb71257d6e22d3" integrity sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA== @@ -7613,12 +7682,12 @@ glob@^7.0.3, glob@^7.1.0, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, gl once "^1.3.0" path-is-absolute "^1.0.0" -global-dirs@^2.0.1: - version "2.1.0" - resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-2.1.0.tgz#e9046a49c806ff04d6c1825e196c8f0091e8df4d" - integrity sha512-MG6kdOUh/xBnyo9cJFeIKkLEc1AyFq42QTU4XiX51i2NEdxLxLWXIjEjmqKeSuKR7pAZjTqUVoT2b2huxVLgYQ== +global-dirs@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-3.0.1.tgz#0c488971f066baceda21447aecb1a8b911d22485" + integrity sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA== dependencies: - ini "1.3.7" + ini "2.0.0" global-modules@^1.0.0: version "1.0.0" @@ -8348,7 +8417,7 @@ ieee754@1.1.13: resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84" integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg== -ieee754@^1.1.4: +ieee754@^1.1.13, ieee754@^1.1.4: version "1.2.1" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== @@ -8470,10 +8539,10 @@ inherits@2.0.3: resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" integrity sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw== -ini@1.3.7: - version "1.3.7" - resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.7.tgz#a09363e1911972ea16d7a8851005d84cf09a9a84" - integrity sha512-iKpRpXP+CrP2jyrxvg1kMUpXDyRUFDWurxbnVT1vQPx+Wz9uCYsMIqYuSBLV+PAaZG/d7kRLKRFc9oDMsH+mFQ== +ini@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ini/-/ini-2.0.0.tgz#e5fd556ecdd5726be978fa1001862eacb0a94bc5" + integrity sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA== ini@^1.3.4, ini@^1.3.5: version "1.3.8" @@ -8658,12 +8727,12 @@ is-callable@^1.1.3, is-callable@^1.1.4, is-callable@^1.1.5, is-callable@^1.2.7: resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055" integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA== -is-ci@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c" - integrity sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w== +is-ci@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-3.0.1.tgz#db6ecbed1bd659c43dac0f45661e7674103d1867" + integrity sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ== dependencies: - ci-info "^2.0.0" + ci-info "^3.2.0" is-core-module@^2.8.1, is-core-module@^2.9.0: version "2.11.0" @@ -8791,13 +8860,13 @@ is-in-browser@^1.0.2, is-in-browser@^1.1.3: resolved "https://registry.yarnpkg.com/is-in-browser/-/is-in-browser-1.1.3.tgz#56ff4db683a078c6082eb95dad7dc62e1d04f835" integrity sha512-FeXIBgG/CPGd/WUxuEyvgGTEfwiG9Z4EKGxjNMRqviiIIfsmgrpnHLffEDdwUHqNva1VEW91o3xBT/m8Elgl9g== -is-installed-globally@^0.3.2: - version "0.3.2" - resolved "https://registry.yarnpkg.com/is-installed-globally/-/is-installed-globally-0.3.2.tgz#fd3efa79ee670d1187233182d5b0a1dd00313141" - integrity sha512-wZ8x1js7Ia0kecP/CHM/3ABkAmujX7WPvQk6uu3Fly/Mk44pySulQpnHG46OMjHGXApINnV4QhY3SWnECO2z5g== +is-installed-globally@~0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/is-installed-globally/-/is-installed-globally-0.4.0.tgz#9a0fd407949c30f86eb6959ef1b7994ed0b7b520" + integrity sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ== dependencies: - global-dirs "^2.0.1" - is-path-inside "^3.0.1" + global-dirs "^3.0.0" + is-path-inside "^3.0.2" is-lambda@^1.0.1: version "1.0.1" @@ -8864,7 +8933,7 @@ is-path-inside@^2.1.0: dependencies: path-is-inside "^1.0.2" -is-path-inside@^3.0.1, is-path-inside@^3.0.3: +is-path-inside@^3.0.1, is-path-inside@^3.0.2, is-path-inside@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== @@ -8911,6 +8980,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" @@ -10236,6 +10310,20 @@ listr-verbose-renderer@^0.5.0: date-fns "^1.27.2" figures "^2.0.0" +listr2@^3.8.3: + version "3.14.0" + resolved "https://registry.yarnpkg.com/listr2/-/listr2-3.14.0.tgz#23101cc62e1375fd5836b248276d1d2b51fdbe9e" + integrity sha512-TyWI8G99GX9GjE54cJ+RrNMcIFBfwMPxc3XTFiAYGN4s10hWROGtOg7+O6u6LE3mNkyld7RSLE6nrKBvTfcs3g== + dependencies: + cli-truncate "^2.1.0" + colorette "^2.0.16" + log-update "^4.0.0" + p-map "^4.0.0" + rfdc "^1.3.0" + rxjs "^7.5.1" + through "^2.3.8" + wrap-ansi "^7.0.0" + listr@^0.14.3: version "0.14.3" resolved "https://registry.yarnpkg.com/listr/-/listr-0.14.3.tgz#2fea909604e434be464c50bddba0d496928fa586" @@ -10493,6 +10581,16 @@ log-update@^2.3.0: cli-cursor "^2.0.0" wrap-ansi "^3.0.1" +log-update@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/log-update/-/log-update-4.0.0.tgz#589ecd352471f2a1c0c570287543a64dfd20e0a1" + integrity sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg== + dependencies: + ansi-escapes "^4.3.0" + cli-cursor "^3.1.0" + slice-ansi "^4.0.0" + wrap-ansi "^6.2.0" + loglevel@^1.6.8: version "1.8.1" resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.8.1.tgz#5c621f83d5b48c54ae93b6156353f555963377b4" @@ -10852,11 +10950,16 @@ minimatch@^5.0.1: dependencies: brace-expansion "^2.0.1" -minimist@^1.1.0, minimist@^1.1.3, minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6: +minimist@^1.1.0, minimist@^1.1.3, minimist@^1.2.0, minimist@^1.2.6: version "1.2.7" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.7.tgz#daa1c4d91f507390437c6a8bc01078e7000c4d18" integrity sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g== +minimist@^1.2.8: + version "1.2.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" + integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== + minipass-collect@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/minipass-collect/-/minipass-collect-1.0.2.tgz#22b813bf745dc6edba2576b940022ad6edc8c617" @@ -10924,7 +11027,7 @@ mkdirp-classic@^0.5.2: resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== -mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@^0.5.4, mkdirp@^0.5.6, mkdirp@~0.5.1: +mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@^0.5.6, mkdirp@~0.5.1: version "0.5.6" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6" integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw== @@ -10969,7 +11072,7 @@ moment-timezone@^0.5.14: dependencies: moment ">= 2.9.0" -moment@2.29.4, moment@2.x.x, "moment@>= 2.9.0", moment@^2.10.2, moment@^2.27.0: +moment@2.29.4, moment@2.x.x, "moment@>= 2.9.0", moment@^2.10.2: version "2.29.4" resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.4.tgz#3dbe052889fe7c1b2ed966fcb3a77328964ef108" integrity sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w== @@ -11508,11 +11611,6 @@ once@1.4.0, once@^1.3.0, once@^1.3.1, once@^1.4.0: dependencies: wrappy "1" -onetime@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/onetime/-/onetime-1.1.0.tgz#a1f7838f8314c516f05ecefcbc4ccfe04b4ed789" - integrity sha512-GZ+g4jayMqzCRMgB2sol7GiCLjKfS1PINkjmx8spcKce1LiVqcbQreXwqs2YAFXC6R03VIG28ZS31t8M866v6A== - onetime@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/onetime/-/onetime-2.0.1.tgz#067428230fd67443b2794b22bba528b6867962d4" @@ -12786,7 +12884,7 @@ prettier@^1.18.2: resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.19.1.tgz#f7d7f5ff8a9cd872a7be4ca142095956a60797cb" integrity sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew== -pretty-bytes@^5.3.0, pretty-bytes@^5.4.1: +pretty-bytes@^5.3.0, pretty-bytes@^5.4.1, pretty-bytes@^5.6.0: version "5.6.0" resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.6.0.tgz#356256f643804773c82f64723fe78c92c62beaeb" integrity sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg== @@ -12920,7 +13018,12 @@ proxy-agent@~3.0.0: proxy-from-env "^1.0.0" socks-proxy-agent "^4.0.1" -proxy-from-env@^1.0.0: +proxy-from-env@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.0.0.tgz#33c50398f70ea7eb96d21f7b817630a55791c7ee" + integrity sha512-F2JHgJQ1iqwnHDcQjVBsq3n/uoaFL+iPW/eAeL7kVxy/2RrWaN4WroKjjvbsoRtv0ftelNyC01bjRhn/bhcf4A== + +proxy-from-env@^1.0.0, proxy-from-env@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== @@ -12994,6 +13097,13 @@ q@^1.1.2: resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7" integrity sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw== +qs@6.10.4: + version "6.10.4" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.10.4.tgz#6a3003755add91c0ec9eacdc5f878b034e73f9e7" + integrity sha512-OQiU+C+Ds5qiH91qh/mg0w+8nwQuLjM4F4M/PbmhDOoYehPh+Fb0bDjtR1sOvy7YKxvj28Y/M0PhP5uVX0kB+g== + dependencies: + side-channel "^1.0.4" + qs@6.11.0, qs@^6.10.1, qs@^6.11.0, qs@^6.5.1, qs@^6.5.2, qs@^6.9.4: version "6.11.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a" @@ -13061,11 +13171,6 @@ railroad-diagrams@^1.0.0: resolved "https://registry.yarnpkg.com/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz#eb7e6267548ddedfb899c1b90e57374559cddb7e" integrity sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A== -ramda@~0.26.1: - version "0.26.1" - resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.26.1.tgz#8d41351eb8111c55353617fc3bbffad8e4d35d06" - integrity sha512-hLWjpy7EnsDBb0p+Z3B7rPi3GDeRG5ZtiI33kJhTt+ORCd38AbAIjB/9zRIUoeTbE/AVX5ZkU7m6bznsvrf8eQ== - randexp@0.4.6: version "0.4.6" resolved "https://registry.yarnpkg.com/randexp/-/randexp-0.4.6.tgz#e986ad5e5e31dae13ddd6f7b3019aa7c87f60ca3" @@ -13866,14 +13971,6 @@ resolve@^2.0.0-next.3: path-parse "^1.0.7" supports-preserve-symlinks-flag "^1.0.0" -restore-cursor@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-1.0.1.tgz#34661f46886327fed2991479152252df92daa541" - integrity sha512-reSjH4HuiFlxlaBaFCiS6O76ZGG2ygKoSlCsipKdaZuKSPx/+bt9mULkn4l0asVzbEfQQmXRg6Wp6gv6m0wElw== - dependencies: - exit-hook "^1.0.0" - onetime "^1.0.0" - restore-cursor@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-2.0.0.tgz#9f7ee287f82fd326d4fd162923d62129eee0dfaf" @@ -13882,6 +13979,14 @@ restore-cursor@^2.0.0: onetime "^2.0.0" signal-exit "^3.0.2" +restore-cursor@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-3.1.0.tgz#39f67c54b3a7a58cea5236d95cf0034239631f7e" + integrity sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA== + dependencies: + onetime "^5.1.0" + signal-exit "^3.0.2" + ret@~0.1.10: version "0.1.15" resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" @@ -13919,6 +14024,11 @@ reusify@^1.0.4: resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== +rfdc@^1.3.0: + version "1.3.1" + resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.3.1.tgz#2b6d4df52dffe8bb346992a10ea9451f24373a8f" + integrity sha512-r5a3l5HzYlIC68TpmYKlxWjmOP6wiPJ1vWv2HeLhNsRZMrCkxeqxiHlQ21oXmQ4F3SiryXBHhAD7JZqvOJjFmg== + rifm@^0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/rifm/-/rifm-0.7.0.tgz#debe951a9c83549ca6b33e5919f716044c2230be" @@ -14012,6 +14122,13 @@ rxjs@^6.3.3: dependencies: tslib "^1.9.0" +rxjs@^7.5.1: + version "7.8.1" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.1.tgz#6f6f3d99ea8044291efd92e7c7fcf562c4057543" + integrity sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg== + dependencies: + tslib "^2.1.0" + safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: version "5.1.2" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" @@ -14193,7 +14310,7 @@ semver@^7.3.2, semver@^7.3.5, semver@^7.3.7, semver@^7.3.8: dependencies: lru-cache "^6.0.0" -semver@^7.5.4: +semver@^7.5.3, semver@^7.5.4: version "7.5.4" resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e" integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA== @@ -14405,11 +14522,37 @@ slice-ansi@0.0.4: resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-0.0.4.tgz#edbf8903f66f7ce2f8eafd6ceed65e264c831b35" integrity sha512-up04hB2hR92PgjpyU3y/eg91yIBILyjVY26NvvciY3EVVPjybkMszMpXQ9QAkcS3I5rtJBDLoTxxg+qvW8c7rw== +slice-ansi@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-3.0.0.tgz#31ddc10930a1b7e0b67b08c96c2f49b77a789787" + integrity sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ== + dependencies: + ansi-styles "^4.0.0" + astral-regex "^2.0.0" + is-fullwidth-code-point "^3.0.0" + +slice-ansi@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-4.0.0.tgz#500e8dd0fd55b05815086255b3195adf2a45fe6b" + integrity sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ== + dependencies: + ansi-styles "^4.0.0" + astral-regex "^2.0.0" + is-fullwidth-code-point "^3.0.0" + smart-buffer@^4.1.0, smart-buffer@^4.2.0: version "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" @@ -15056,14 +15199,14 @@ supports-color@^6.1.0: dependencies: has-flag "^3.0.0" -supports-color@^7.0.0, supports-color@^7.1.0, supports-color@^7.2.0: +supports-color@^7.0.0, supports-color@^7.1.0: version "7.2.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== dependencies: has-flag "^4.0.0" -supports-color@^8.0.0: +supports-color@^8.0.0, supports-color@^8.1.1: version "8.1.1" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== @@ -15298,7 +15441,7 @@ through2@^2.0.0: readable-stream "~2.3.6" xtend "~4.0.1" -"through@>=2.2.7 <3": +"through@>=2.2.7 <3", through@^2.3.8: version "2.3.8" resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg== @@ -15454,6 +15597,16 @@ tough-cookie@^4.0.0: universalify "^0.2.0" url-parse "^1.5.3" +tough-cookie@^4.1.3: + version "4.1.3" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.1.3.tgz#97b9adb0728b42280aa3d814b6b999b2ff0318bf" + integrity sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw== + dependencies: + psl "^1.1.33" + punycode "^2.1.1" + universalify "^0.2.0" + url-parse "^1.5.3" + tough-cookie@~2.5.0: version "2.5.0" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2" @@ -16573,6 +16726,15 @@ wrap-ansi@^5.1.0: string-width "^3.0.0" strip-ansi "^5.0.0" +wrap-ansi@^6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53" + integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.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"