From d6408b16744c04edf4e93f87c2ec8d844ac522e2 Mon Sep 17 00:00:00 2001 From: Ilona Brand Date: Mon, 5 Aug 2019 16:37:28 -0400 Subject: [PATCH] run prettier on all files --- __mocks__/fileMock.js | 2 +- __mocks__/styleMock.js | 2 +- __test__/AssignmentSummary.test.js | 324 +-- __test__/TexterStats.test.js | 86 +- __test__/TopNav.test.js | 52 +- __test__/backend.test.js | 635 +++--- __test__/components/AssignmentTexter.test.js | 81 +- __test__/components/TexterFAQs.test.js | 32 +- .../AssignmentTexterContact.test.js | 338 +-- __test__/containers/CampaignList.test.js | 65 +- __test__/containers/TexterTodo.test.js | 143 +- __test__/e2e/basic_text_manager.test.js | 258 +-- __test__/e2e/create_copy_campaign.test.js | 90 +- __test__/e2e/create_edit_campaign.test.js | 56 +- __test__/e2e/data/strings.js | 182 +- __test__/e2e/invite_texter.test.js | 90 +- __test__/e2e/page-functions/campaigns.js | 535 +++-- __test__/e2e/page-functions/index.js | 10 +- __test__/e2e/page-functions/login.js | 119 +- __test__/e2e/page-functions/main.js | 138 +- __test__/e2e/page-functions/people.js | 121 +- __test__/e2e/page-functions/texter.js | 83 +- __test__/e2e/page-objects/campaigns.js | 134 +- __test__/e2e/page-objects/index.js | 16 +- __test__/e2e/page-objects/login.js | 30 +- __test__/e2e/page-objects/main.js | 26 +- __test__/e2e/page-objects/navigation.js | 16 +- __test__/e2e/page-objects/people.js | 32 +- __test__/e2e/page-objects/scriptEditor.js | 10 +- __test__/e2e/page-objects/texter.js | 22 +- __test__/e2e/util/config.js | 12 +- __test__/e2e/util/helpers.js | 123 +- __test__/e2e/util/setup.js | 4 +- __test__/lambda.test.js | 103 +- __test__/lib.test.js | 46 +- __test__/lib/dst-helper.test.js | 162 +- __test__/lib/parse-csv.test.js | 32 +- __test__/lib/timezones.test.js | 1854 ++++++++++------- __test__/lib/tz-helpers.test.js | 15 +- __test__/lib/zip-format.test.js | 106 +- __test__/server/api/assignment.test.js | 239 ++- __test__/server/api/campaign.test.js | 882 +++++--- __test__/server/db/export.js | 46 +- __test__/server/db/utils.js | 38 +- __test__/server/render-index.test.js | 30 +- __test__/server/texter.test.js | 219 +- __test__/setup.js | 8 +- __test__/sum.js | 6 +- __test__/sum.test.js | 8 +- __test__/test_client_helpers.js | 145 +- __test__/test_helpers.js | 359 ++-- __test__/workers/assign-texters.test.js | 190 +- __test__/workers/jobs.test.js | 110 +- dev-tools/babel-run-with-env.js | 8 +- dev-tools/db-startup.js | 4 +- dev-tools/export-broken-interaction-steps.js | 76 +- dev-tools/export-query.js | 41 +- dev-tools/generate-contacts.js | 16 +- dev-tools/jest.transform.js | 10 +- jest.config.e2e.js | 25 +- jest.config.js | 40 +- jest.config.sqlite.js | 6 +- knexfile.env.js | 4 +- lambda.js | 100 +- migrations/20190207220000_init_db.js | 546 +++-- src/api/assignment.js | 2 +- src/api/campaign-contact.js | 3 +- src/api/campaign.js | 2 +- src/api/canned-response.js | 3 +- src/api/conversations.js | 2 +- src/api/interaction-step.js | 3 +- src/api/invite.js | 3 +- src/api/message.js | 3 +- src/api/opt-out.js | 3 +- src/api/organization.js | 2 +- src/api/question-response.js | 3 +- src/api/question.js | 3 +- src/api/schema.js | 201 +- src/api/user.js | 2 +- src/client/auth-service.js | 19 +- src/client/error-catcher.js | 12 +- src/heroku/print-base-url.js | 8 +- src/lib/__mocks__/timezones.js | 4 +- src/lib/__mocks__/tz-helpers.js | 7 +- src/lib/__mocks__/zip-format.js | 4 +- src/lib/attributes.js | 17 +- src/lib/dst-helper.js | 61 +- src/lib/faqs.js | 30 +- src/lib/index.js | 176 +- src/lib/interaction-step-helpers.js | 127 +- src/lib/is-client.js | 2 +- src/lib/log.js | 67 +- src/lib/pendingJobsUtils.js | 13 +- src/lib/permissions.js | 14 +- src/lib/phone-format.js | 32 +- src/lib/timezones.js | 215 +- src/lib/tz-helpers.js | 12 +- src/lib/zip-format.js | 79 +- src/network/apollo-client-singleton.js | 43 +- src/network/errors.js | 29 +- .../response-middleware-network-interface.js | 56 +- src/server/action_handlers/actionkit-rsvp.js | 173 +- src/server/action_handlers/helper-ak-sync.js | 100 +- .../action_handlers/mobilecommons-signup.js | 88 +- src/server/action_handlers/revere-signup.js | 95 +- src/server/action_handlers/test-action.js | 35 +- src/server/api/assignment.js | 145 +- src/server/api/campaign-contact.js | 228 +- src/server/api/campaign.js | 361 ++-- src/server/api/canned-response.js | 16 +- src/server/api/conversations.js | 320 +-- src/server/api/errors.js | 54 +- src/server/api/interaction-step.js | 39 +- src/server/api/invite.js | 12 +- src/server/api/lib/fakeservice.js | 75 +- src/server/api/lib/import-script..js | 460 ++-- src/server/api/lib/message-sending.js | 34 +- src/server/api/lib/nexmo.js | 244 ++- src/server/api/lib/services.js | 10 +- src/server/api/lib/twilio.js | 321 +-- src/server/api/lib/utils.js | 24 +- src/server/api/message.js | 27 +- src/server/api/mocks.js | 13 +- src/server/api/opt-out.js | 15 +- src/server/api/organization.js | 64 +- src/server/api/phone.js | 25 +- src/server/api/question-response.js | 13 +- src/server/api/question.js | 64 +- src/server/api/schema.js | 1220 ++++++----- src/server/api/user.js | 241 ++- src/server/auth-passport.js | 173 +- src/server/index.js | 296 +-- src/server/knex-connect.js | 52 +- src/server/local-auth-helpers.js | 182 +- src/server/mail.js | 64 +- src/server/middleware/render-index.js | 33 +- src/server/models/assignment.js | 37 +- .../models/cacheable_queries/assignment.js | 12 +- .../cacheable_queries/campaign-contact.js | 113 +- .../models/cacheable_queries/campaign.js | 139 +- .../cacheable_queries/canned-response.js | 35 +- src/server/models/cacheable_queries/index.js | 14 +- src/server/models/cacheable_queries/lib.js | 23 +- .../models/cacheable_queries/opt-out.js | 113 +- .../models/cacheable_queries/organization.js | 42 +- src/server/models/cacheable_queries/user.js | 232 ++- src/server/models/campaign-contact.js | 94 +- src/server/models/campaign.js | 118 +- src/server/models/canned-response.js | 39 +- src/server/models/custom-types.js | 15 +- src/server/models/datawarehouse.js | 10 +- src/server/models/index.js | 129 +- src/server/models/interaction-step.js | 60 +- src/server/models/invite.js | 35 +- src/server/models/job-request.js | 65 +- src/server/models/log.js | 27 +- src/server/models/message.js | 101 +- src/server/models/opt-out.js | 43 +- src/server/models/organization.js | 66 +- src/server/models/pending-message-part.js | 41 +- src/server/models/question-response.js | 39 +- src/server/models/thinky.js | 34 +- src/server/models/user-cell.js | 41 +- src/server/models/user-organization.js | 42 +- src/server/models/user.js | 41 +- src/server/models/zip-code.js | 57 +- src/server/notifications.js | 134 +- src/server/seeds/seed-zip-codes.js | 42 +- src/server/wrap.js | 16 +- src/store/actions/index.js | 4 +- src/store/index.js | 20 +- src/store/reducers/count.js | 8 +- src/store/reducers/index.js | 2 +- src/styles/media-queries.js | 6 +- src/styles/mui-theme.js | 45 +- src/styles/theme.js | 109 +- src/workers/incoming-message-handler.js | 4 +- src/workers/job-handler.js | 4 +- src/workers/job-processes.js | 222 +- src/workers/jobs.js | 1378 +++++++----- src/workers/lib.js | 33 +- src/workers/message-sender-01.js | 6 +- src/workers/message-sender-234.js | 6 +- src/workers/message-sender-56.js | 6 +- src/workers/message-sender-789.js | 6 +- webpack/config.js | 73 +- webpack/server.js | 47 +- 187 files changed, 11319 insertions(+), 8628 deletions(-) diff --git a/__mocks__/fileMock.js b/__mocks__/fileMock.js index 84c1da6fd..0a445d060 100644 --- a/__mocks__/fileMock.js +++ b/__mocks__/fileMock.js @@ -1 +1 @@ -module.exports = 'test-file-stub'; \ No newline at end of file +module.exports = "test-file-stub"; diff --git a/__mocks__/styleMock.js b/__mocks__/styleMock.js index a09954537..f053ebf79 100644 --- a/__mocks__/styleMock.js +++ b/__mocks__/styleMock.js @@ -1 +1 @@ -module.exports = {}; \ No newline at end of file +module.exports = {}; diff --git a/__test__/AssignmentSummary.test.js b/__test__/AssignmentSummary.test.js index eff06773c..8197f9423 100644 --- a/__test__/AssignmentSummary.test.js +++ b/__test__/AssignmentSummary.test.js @@ -1,34 +1,34 @@ /** * @jest-environment jsdom */ -import React from 'react' -import { mount } from 'enzyme' -import { StyleSheetTestUtils } from 'aphrodite' -import injectTapEventPlugin from 'react-tap-event-plugin' -import each from 'jest-each' -import MuiThemeProvider from 'material-ui/styles/MuiThemeProvider' -import { CardActions, CardTitle } from 'material-ui/Card' -import { AssignmentSummary } from '../src/components/AssignmentSummary' -import Badge from 'material-ui/Badge/Badge' -import RaisedButton from 'material-ui/RaisedButton/RaisedButton' +import React from "react"; +import { mount } from "enzyme"; +import { StyleSheetTestUtils } from "aphrodite"; +import injectTapEventPlugin from "react-tap-event-plugin"; +import each from "jest-each"; +import MuiThemeProvider from "material-ui/styles/MuiThemeProvider"; +import { CardActions, CardTitle } from "material-ui/Card"; +import { AssignmentSummary } from "../src/components/AssignmentSummary"; +import Badge from "material-ui/Badge/Badge"; +import RaisedButton from "material-ui/RaisedButton/RaisedButton"; function getAssignment(isDynamic = false) { return { - id: '1', + id: "1", campaign: { - id: '1', - title: 'New Campaign', - description: 'asdf', + id: "1", + title: "New Campaign", + description: "asdf", useDynamicAssignment: isDynamic, hasUnassignedContacts: false, - introHtml: 'yoyo', - primaryColor: '#2052d8', - logoImageUrl: '' + introHtml: "yoyo", + primaryColor: "#2052d8", + logoImageUrl: "" } - } + }; } -describe('AssignmentSummary text', function t() { +describe("AssignmentSummary text", function t() { beforeEach(() => { this.summary = mount( @@ -41,33 +41,40 @@ describe('AssignmentSummary text', function t() { skippedMessagesCount={0} /> - ) - }) + ); + }); each([[0, false], [1, false], [0, true], [1, true]]).test( - 'renders title and html for notInUSA=%s and allowSendAll=%s', + "renders title and html for notInUSA=%s and allowSendAll=%s", (notInUSA, allowSendAll) => { - window.NOT_IN_USA = notInUSA - window.ALLOW_SEND_ALL = allowSendAll - const title = this.summary.find(CardTitle) - expect(title.prop('title')).toBe('New Campaign') + window.NOT_IN_USA = notInUSA; + window.ALLOW_SEND_ALL = allowSendAll; + const title = this.summary.find(CardTitle); + expect(title.prop("title")).toBe("New Campaign"); // expect(title.find(CardTitle).prop('subtitle')).toBe('asdf - Jan 31 2018') const htmlWrapper = this.summary.findWhere( - d => d.length && d.type() === 'div' && d.prop('dangerouslySetInnerHTML') - ) - expect(htmlWrapper.prop('dangerouslySetInnerHTML')).toEqual({ - __html: 'yoyo' - }) + d => d.length && d.type() === "div" && d.prop("dangerouslySetInnerHTML") + ); + expect(htmlWrapper.prop("dangerouslySetInnerHTML")).toEqual({ + __html: "yoyo" + }); } - ) -}) - -describe('AssignmentSummary actions inUSA and NOT AllowSendAll', () => { - injectTapEventPlugin() // prevents warning - function create(unmessaged, unreplied, badTimezone, past, skipped, isDynamic) { - window.NOT_IN_USA = 0 - window.ALLOW_SEND_ALL = false + ); +}); + +describe("AssignmentSummary actions inUSA and NOT AllowSendAll", () => { + injectTapEventPlugin(); // prevents warning + function create( + unmessaged, + unreplied, + badTimezone, + past, + skipped, + isDynamic + ) { + window.NOT_IN_USA = 0; + window.ALLOW_SEND_ALL = false; return mount( { skippedMessagesCount={skipped} /> - ).find(CardActions) + ).find(CardActions); } it('renders "send first texts (1)" with unmessaged (dynamic assignment)', () => { - const actions = create(5, 0, 0, 0, 0, true) - expect(actions.find(Badge).at(0).prop('badgeContent')).toBe(5) - expect(actions.find(RaisedButton).at(0).prop('label')).toBe('Send first texts') - }) + const actions = create(5, 0, 0, 0, 0, true); + expect( + actions + .find(Badge) + .at(0) + .prop("badgeContent") + ).toBe(5); + expect( + actions + .find(RaisedButton) + .at(0) + .prop("label") + ).toBe("Send first texts"); + }); it('renders "send first texts (1)" with unmessaged (non-dynamic)', () => { - const actions = create(1, 0, 0, 0, 0, false) - expect(actions.find(Badge).at(0).prop('badgeContent')).toBe(1) - expect(actions.find(RaisedButton).at(0).prop('label')).toBe('Send first texts') - }) + const actions = create(1, 0, 0, 0, 0, false); + expect( + actions + .find(Badge) + .at(0) + .prop("badgeContent") + ).toBe(1); + expect( + actions + .find(RaisedButton) + .at(0) + .prop("label") + ).toBe("Send first texts"); + }); it('renders "send first texts" with no unmessaged (dynamic assignment)', () => { - const actions = create(0, 0, 0, 0, 0, true) - expect(actions.find(RaisedButton).at(0).prop('label')).toBe('Send first texts') - }) + const actions = create(0, 0, 0, 0, 0, true); + expect( + actions + .find(RaisedButton) + .at(0) + .prop("label") + ).toBe("Send first texts"); + }); it('renders a "past messages" badge after messaged contacts', () => { - const actions = create(0, 0, 0, 1, 0, false) - expect(actions.find(RaisedButton).length).toBe(1) - }) + const actions = create(0, 0, 0, 1, 0, false); + expect(actions.find(RaisedButton).length).toBe(1); + }); - it('renders two buttons with unmessaged and unreplied', () => { - const actions = create(3, 9, 0, 0, 0, false) - expect(actions.find(RaisedButton).length).toBe(2) - }) + it("renders two buttons with unmessaged and unreplied", () => { + const actions = create(3, 9, 0, 0, 0, false); + expect(actions.find(RaisedButton).length).toBe(2); + }); it('renders "past messages (n)" with messaged', () => { - const actions = create(0, 0, 0, 9, 0, false) - expect(actions.find(Badge).at(0).prop('badgeContent')).toBe(9) - expect(actions.find(RaisedButton).at(0).prop('label')).toBe('Past Messages') - }) -}) - -describe('AssignmentSummary NOT inUSA and AllowSendAll', () => { - function create(unmessaged, unreplied, badTimezone, past, skipped, isDynamic) { - window.NOT_IN_USA = 1 - window.ALLOW_SEND_ALL = true + const actions = create(0, 0, 0, 9, 0, false); + expect( + actions + .find(Badge) + .at(0) + .prop("badgeContent") + ).toBe(9); + expect( + actions + .find(RaisedButton) + .at(0) + .prop("label") + ).toBe("Past Messages"); + }); +}); + +describe("AssignmentSummary NOT inUSA and AllowSendAll", () => { + function create( + unmessaged, + unreplied, + badTimezone, + past, + skipped, + isDynamic + ) { + window.NOT_IN_USA = 1; + window.ALLOW_SEND_ALL = true; return mount( { skippedMessagesCount={skipped} /> - ).find(CardActions) + ).find(CardActions); } it('renders "Send message" with unmessaged', () => { - const actions = create(1, 0, 0, 0, 0, false) - expect(actions.find(RaisedButton).at(0).prop('label')).toBe('Send messages') - }) + const actions = create(1, 0, 0, 0, 0, false); + expect( + actions + .find(RaisedButton) + .at(0) + .prop("label") + ).toBe("Send messages"); + }); it('renders "Send messages" with unreplied', () => { - const actions = create(0, 1, 0, 0, 0, false) - expect(actions.find(RaisedButton).at(0).prop('label')).toBe('Send messages') - }) -}) + const actions = create(0, 1, 0, 0, 0, false); + expect( + actions + .find(RaisedButton) + .at(0) + .prop("label") + ).toBe("Send messages"); + }); +}); it('renders "Send later" when there is a badTimezoneCount', () => { const actions = mount( @@ -156,23 +215,38 @@ it('renders "Send later" when there is a badTimezoneCount', () => { skippedMessagesCount={0} /> - ).find(CardActions) - expect(actions.find(Badge).at(1).prop('badgeContent')).toBe(4) - expect(actions.find(RaisedButton).at(0).prop('label')).toBe('Past Messages') - expect(actions.find(RaisedButton).at(1).prop('label')).toBe('Send messages') -}) + ).find(CardActions); + expect( + actions + .find(Badge) + .at(1) + .prop("badgeContent") + ).toBe(4); + expect( + actions + .find(RaisedButton) + .at(0) + .prop("label") + ).toBe("Past Messages"); + expect( + actions + .find(RaisedButton) + .at(1) + .prop("label") + ).toBe("Send messages"); +}); -describe('contacts filters', () => { +describe("contacts filters", () => { // These are an attempt to confirm that the buttons will work. // It would be better to simulate clicking them, but I can't // get it to work right now because of 'react-tap-event-plugin' // some hints are here https://github.com/mui-org/material-ui/issues/4200#issuecomment-217738345 - it('filters correctly in USA', () => { - window.NOT_IN_USA = 0 - window.ALLOW_SEND_ALL = false - const mockRender = jest.fn() - AssignmentSummary.prototype.renderBadgedButton = mockRender + it("filters correctly in USA", () => { + window.NOT_IN_USA = 0; + window.ALLOW_SEND_ALL = false; + const mockRender = jest.fn(); + AssignmentSummary.prototype.renderBadgedButton = mockRender; mount( { skippedMessagesCount={0} /> - ) - const sendFirstTexts = mockRender.mock.calls[0][0] - expect(sendFirstTexts.title).toBe('Send first texts') - expect(sendFirstTexts.contactsFilter).toBe('text') - - const sendReplies = mockRender.mock.calls[1][0] - expect(sendReplies.title).toBe('Send replies') - expect(sendReplies.contactsFilter).toBe('reply') - - const sendLater = mockRender.mock.calls[2][0] - expect(sendLater.title).toBe('Past Messages') - expect(sendLater.contactsFilter).toBe('stale') - - const skippedMessages = mockRender.mock.calls[3][0] - expect(skippedMessages.title).toBe('Skipped Messages') - expect(skippedMessages.contactsFilter).toBe('skipped') - }) - it('filters correctly out of USA', () => { - window.NOT_IN_USA = 1 - window.ALLOW_SEND_ALL = true - const mockRender = jest.fn() - AssignmentSummary.prototype.renderBadgedButton = mockRender + ); + const sendFirstTexts = mockRender.mock.calls[0][0]; + expect(sendFirstTexts.title).toBe("Send first texts"); + expect(sendFirstTexts.contactsFilter).toBe("text"); + + const sendReplies = mockRender.mock.calls[1][0]; + expect(sendReplies.title).toBe("Send replies"); + expect(sendReplies.contactsFilter).toBe("reply"); + + const sendLater = mockRender.mock.calls[2][0]; + expect(sendLater.title).toBe("Past Messages"); + expect(sendLater.contactsFilter).toBe("stale"); + + const skippedMessages = mockRender.mock.calls[3][0]; + expect(skippedMessages.title).toBe("Skipped Messages"); + expect(skippedMessages.contactsFilter).toBe("skipped"); + }); + it("filters correctly out of USA", () => { + window.NOT_IN_USA = 1; + window.ALLOW_SEND_ALL = true; + const mockRender = jest.fn(); + AssignmentSummary.prototype.renderBadgedButton = mockRender; mount( { skippedMessagesCount={0} /> - ) - const sendMessages = mockRender.mock.calls[0][0] - expect(sendMessages.title).toBe('Past Messages') - expect(sendMessages.contactsFilter).toBe('stale') + ); + const sendMessages = mockRender.mock.calls[0][0]; + expect(sendMessages.title).toBe("Past Messages"); + expect(sendMessages.contactsFilter).toBe("stale"); - const skippedMessages = mockRender.mock.calls[1][0] - expect(skippedMessages.title).toBe('Skipped Messages') - expect(skippedMessages.contactsFilter).toBe('skipped') + const skippedMessages = mockRender.mock.calls[1][0]; + expect(skippedMessages.title).toBe("Skipped Messages"); + expect(skippedMessages.contactsFilter).toBe("skipped"); - const sendFirstTexts = mockRender.mock.calls[2][0] - expect(sendFirstTexts.title).toBe('Send messages') - expect(sendFirstTexts.contactsFilter).toBe('all') - }) -}) + const sendFirstTexts = mockRender.mock.calls[2][0]; + expect(sendFirstTexts.title).toBe("Send messages"); + expect(sendFirstTexts.contactsFilter).toBe("all"); + }); +}); // https://github.com/Khan/aphrodite/issues/62#issuecomment-267026726 beforeEach(() => { - StyleSheetTestUtils.suppressStyleInjection() -}) + StyleSheetTestUtils.suppressStyleInjection(); +}); afterEach(() => { - StyleSheetTestUtils.clearBufferAndResumeStyleInjection() -}) + StyleSheetTestUtils.clearBufferAndResumeStyleInjection(); +}); diff --git a/__test__/TexterStats.test.js b/__test__/TexterStats.test.js index bc69f0fec..8f35a9b53 100644 --- a/__test__/TexterStats.test.js +++ b/__test__/TexterStats.test.js @@ -1,88 +1,86 @@ -import React from 'react' -import { shallow } from 'enzyme' -import TexterStats from '../src/components/TexterStats' +import React from "react"; +import { shallow } from "enzyme"; +import TexterStats from "../src/components/TexterStats"; const campaign = { useDynamicAssignment: false, assignments: [ { - id: '1', + id: "1", texter: { - id: '1', - firstName: 'Test', - lastName: 'Tester' + id: "1", + firstName: "Test", + lastName: "Tester" }, unmessagedCount: 193, contactsCount: 238 }, { - id: '1', + id: "1", texter: { - id: '1', - firstName: 'Someone', - lastName: 'Else', + id: "1", + firstName: "Someone", + lastName: "Else" }, unmessagedCount: 4, contactsCount: 545 } ] -} +}; const campaignDynamic = { useDynamicAssignment: true, assignments: [ { - id: '1', + id: "1", texter: { - id: '1', - firstName: 'Test', - lastName: 'Tester' + id: "1", + firstName: "Test", + lastName: "Tester" }, unmessagedCount: 193, contactsCount: 238 }, { - id: '1', + id: "1", texter: { - id: '1', - firstName: 'Someone', - lastName: 'Else', + id: "1", + firstName: "Someone", + lastName: "Else" }, unmessagedCount: 4, contactsCount: 545 } ] -} +}; - -describe('TexterStats (Non-dynamic campaign)', () => { - it('contains the right text', () => { - const stats = shallow() +describe("TexterStats (Non-dynamic campaign)", () => { + it("contains the right text", () => { + const stats = shallow(); expect(stats.text()).toEqual( - 'Test Tester19%Someone Else99%' - ) - }) + "Test Tester19%Someone Else99%" + ); + }); - it('creates linear progress correctly', () => { + it("creates linear progress correctly", () => { const linearProgress = shallow().find( - 'LinearProgress' - ) - expect(linearProgress.length).toBe(2) + "LinearProgress" + ); + expect(linearProgress.length).toBe(2); expect(linearProgress.first().props()).toEqual({ max: 100, min: 0, - mode: 'determinate', + mode: "determinate", value: 19 - }) - }) -}) - + }); + }); +}); -describe('TexterStats (Dynamic campaign)', () => { - it('contains the right text', () => { - const stats = shallow() +describe("TexterStats (Dynamic campaign)", () => { + it("contains the right text", () => { + const stats = shallow(); expect(stats.text()).toEqual( - 'Test45 initial messages sentSomeone541 initial messages sent' - ) - }) -}) + "Test45 initial messages sentSomeone541 initial messages sent" + ); + }); +}); diff --git a/__test__/TopNav.test.js b/__test__/TopNav.test.js index f3051d2bf..3e5f60de7 100644 --- a/__test__/TopNav.test.js +++ b/__test__/TopNav.test.js @@ -1,36 +1,36 @@ -import React from 'react' -import { shallow } from 'enzyme' -import { StyleSheetTestUtils } from 'aphrodite' -import TopNav from '../src/components/TopNav' +import React from "react"; +import { shallow } from "enzyme"; +import { StyleSheetTestUtils } from "aphrodite"; +import TopNav from "../src/components/TopNav"; -describe('TopNav', () => { - it('can render only title', () => { - const nav = shallow() +describe("TopNav", () => { + it("can render only title", () => { + const nav = shallow(); expect(nav.text()).toEqual( - 'Welcome to my website' - ) - expect(nav.find('Link').length).toBe(0) - }) + "Welcome to my website" + ); + expect(nav.find("Link").length).toBe(0); + }); - it('can render Link to go back', () => { + it("can render Link to go back", () => { const link = shallow( - ).find('Link') - expect(link.length).toBe(1) - expect(link.prop('to')).toBe('/admin/1/campaigns') - expect(link.find('IconButton').length).toBe(1) - }) + ).find("Link"); + expect(link.length).toBe(1); + expect(link.prop("to")).toBe("/admin/1/campaigns"); + expect(link.find("IconButton").length).toBe(1); + }); - it('renders UserMenu', () => { - const nav = shallow() - expect(nav.find('Connect(Apollo(withRouter(UserMenu)))').length).toBe(1) - }) -}) + it("renders UserMenu", () => { + const nav = shallow(); + expect(nav.find("Connect(Apollo(withRouter(UserMenu)))").length).toBe(1); + }); +}); // https://github.com/Khan/aphrodite/issues/62#issuecomment-267026726 beforeEach(() => { - StyleSheetTestUtils.suppressStyleInjection() -}) + StyleSheetTestUtils.suppressStyleInjection(); +}); afterEach(() => { - StyleSheetTestUtils.clearBufferAndResumeStyleInjection() -}) + StyleSheetTestUtils.clearBufferAndResumeStyleInjection(); +}); diff --git a/__test__/backend.test.js b/__test__/backend.test.js index 212fd8873..fefa518a8 100644 --- a/__test__/backend.test.js +++ b/__test__/backend.test.js @@ -1,53 +1,60 @@ -import { resolvers } from '../src/server/api/schema' -import { schema } from '../src/api/schema' +import { resolvers } from "../src/server/api/schema"; +import { schema } from "../src/api/schema"; import { accessRequired, assignmentRequired, authRequired, superAdminRequired -} from '../src/server/api/errors' -import { graphql } from 'graphql' -import { User, Organization, Campaign, CampaignContact, Assignment, r } from '../src/server/models/' -import { resolvers as campaignResolvers } from '../src/server/api/campaign' -import { getContext, - setupTest, - cleanupTest } from './test_helpers' -import { makeExecutableSchema } from 'graphql-tools' +} from "../src/server/api/errors"; +import { graphql } from "graphql"; +import { + User, + Organization, + Campaign, + CampaignContact, + Assignment, + r +} from "../src/server/models/"; +import { resolvers as campaignResolvers } from "../src/server/api/campaign"; +import { getContext, setupTest, cleanupTest } from "./test_helpers"; +import { makeExecutableSchema } from "graphql-tools"; const mySchema = makeExecutableSchema({ typeDefs: schema, resolvers: resolvers, - allowUndefinedInResolve: true, -}) + allowUndefinedInResolve: true +}); -const rootValue = {} +const rootValue = {}; // data items used across tests -let testAdminUser -let testInvite -let testOrganization -let testCampaign -let testTexterUser +let testAdminUser; +let testInvite; +let testOrganization; +let testCampaign; +let testTexterUser; // data creation functions -async function createUser(userInfo = { - auth0_id: 'test123', - first_name: 'TestUserFirst', - last_name: 'TestUserLast', - cell: '555-555-5555', - email: 'testuser@example.com', -}) { - const user = new User(userInfo) +async function createUser( + userInfo = { + auth0_id: "test123", + first_name: "TestUserFirst", + last_name: "TestUserLast", + cell: "555-555-5555", + email: "testuser@example.com" + } +) { + const user = new User(userInfo); try { - await user.save() - console.log("created user") - console.log(user) - return user - } catch(err) { - console.error('Error saving user') - return false + await user.save(); + console.log("created user"); + console.log(user); + return user; + } catch (err) { + console.error("Error saving user"); + return false; } } @@ -58,15 +65,15 @@ async function createContact(campaignId) { cell: "5555555555", zip: "12345", campaign_id: campaignId - }) + }); try { - await contact.save() - console.log("created contact") - console.log(contact) - return contact - } catch(err) { - console.error('Error saving contact: ', err) - return false + await contact.save(); + console.log("created contact"); + console.log(contact); + return contact; + } catch (err) { + console.error("Error saving contact: ", err); + return false; } } @@ -75,19 +82,19 @@ async function createInvite() { createInvite(invite: {is_valid: true}) { id } - }` - const context = getContext() + }`; + const context = getContext(); try { - const invite = await graphql(mySchema, inviteQuery, rootValue, context) - return invite - } catch(err) { - console.error('Error creating invite') - return false + const invite = await graphql(mySchema, inviteQuery, rootValue, context); + return invite; + } catch (err) { + console.error("Error creating invite"); + return false; } } async function createOrganization(user, name, userId, inviteId) { - const context = getContext({ user }) + const context = getContext({ user }); const orgQuery = `mutation createOrganization($name: String!, $userId: String!, $inviteId: String!) { createOrganization(name: $name, userId: $userId, inviteId: $inviteId) { @@ -99,25 +106,37 @@ async function createOrganization(user, name, userId, inviteId) { textingHoursStart textingHoursEnd } - }` + }`; const variables = { - "userId": userId, - "name": name, - "inviteId": inviteId - } + userId: userId, + name: name, + inviteId: inviteId + }; try { - const org = await graphql(mySchema, orgQuery, rootValue, context, variables) - return org - } catch(err) { - console.error('Error creating organization') - return false + const org = await graphql( + mySchema, + orgQuery, + rootValue, + context, + variables + ); + return org; + } catch (err) { + console.error("Error creating organization"); + return false; } } -async function createCampaign(user, title, description, organizationId, contacts = []) { - const context = getContext({user}) +async function createCampaign( + user, + title, + description, + organizationId, + contacts = [] +) { + const context = getContext({ user }); const campaignQuery = `mutation createCampaign($input: CampaignInput!) { createCampaign(campaign: $input) { @@ -128,116 +147,146 @@ async function createCampaign(user, title, description, organizationId, contacts lastName } } - }` + }`; const variables = { - "input": { - "title": title, - "description": description, - "organizationId": organizationId, - "contacts": contacts + input: { + title: title, + description: description, + organizationId: organizationId, + contacts: contacts } - } + }; try { - const campaign = await graphql(mySchema, campaignQuery, rootValue, context, variables) - return campaign - } catch(err) { - console.error('Error creating campaign') - return false + const campaign = await graphql( + mySchema, + campaignQuery, + rootValue, + context, + variables + ); + return campaign; + } catch (err) { + console.error("Error creating campaign"); + return false; } } // graphQL tests -beforeAll(async () => await setupTest(), global.DATABASE_SETUP_TEARDOWN_TIMEOUT) -afterAll(async () => await cleanupTest(), global.DATABASE_SETUP_TEARDOWN_TIMEOUT) +beforeAll( + async () => await setupTest(), + global.DATABASE_SETUP_TEARDOWN_TIMEOUT +); +afterAll( + async () => await cleanupTest(), + global.DATABASE_SETUP_TEARDOWN_TIMEOUT +); -it('should be undefined when user not logged in', async () => { +it("should be undefined when user not logged in", async () => { const query = `{ currentUser { id } - }` - const context = getContext() - const result = await graphql(mySchema, query, rootValue, context) - const data = result + }`; + const context = getContext(); + const result = await graphql(mySchema, query, rootValue, context); + const data = result; - expect(typeof data.currentUser).toEqual('undefined') -}) + expect(typeof data.currentUser).toEqual("undefined"); +}); -it('should return the current user when user is logged in', async () => { - testAdminUser = await createUser() +it("should return the current user when user is logged in", async () => { + testAdminUser = await createUser(); const query = `{ currentUser { email } - }` - const context = getContext({ user: testAdminUser }) - const result = await graphql(mySchema, query, rootValue, context) - const { data } = result + }`; + const context = getContext({ user: testAdminUser }); + const result = await graphql(mySchema, query, rootValue, context); + const { data } = result; - expect(data.currentUser.email).toBe('testuser@example.com') -}) + expect(data.currentUser.email).toBe("testuser@example.com"); +}); // TESTING CAMPAIGN CREATION FROM END TO END -it('should create an invite', async () => { - testInvite = await createInvite() +it("should create an invite", async () => { + testInvite = await createInvite(); - expect(testInvite.data.createInvite.id).toBeTruthy() -}) - -it('should convert an invitation and user into a valid organization instance', async () => { + expect(testInvite.data.createInvite.id).toBeTruthy(); +}); +it("should convert an invitation and user into a valid organization instance", async () => { if (testInvite && testAdminUser) { - console.log("user and invite for org") - console.log([testAdminUser,testInvite.data]) - - testOrganization = await createOrganization(testAdminUser, "Testy test organization", testInvite.data.createInvite.id, testInvite.data.createInvite.id) - - expect(testOrganization.data.createOrganization.name).toBe('Testy test organization') + console.log("user and invite for org"); + console.log([testAdminUser, testInvite.data]); + + testOrganization = await createOrganization( + testAdminUser, + "Testy test organization", + testInvite.data.createInvite.id, + testInvite.data.createInvite.id + ); + + expect(testOrganization.data.createOrganization.name).toBe( + "Testy test organization" + ); } else { - console.log("Failed to create invite and/or user for organization test") - return false + console.log("Failed to create invite and/or user for organization test"); + return false; } -}) - - -it('should create a test campaign', async () => { - const campaignTitle = "test campaign" - testCampaign = await createCampaign(testAdminUser, campaignTitle, "test description", testOrganization.data.createOrganization.id) - - expect(testCampaign.data.createCampaign.title).toBe(campaignTitle) -}) - -it('should create campaign contacts', async () => { - const contact = await createContact(testCampaign.data.createCampaign.id) - expect(contact.campaign_id).toBe(parseInt(testCampaign.data.createCampaign.id)) -}) - -it('should add texters to a organization', async () => { +}); + +it("should create a test campaign", async () => { + const campaignTitle = "test campaign"; + testCampaign = await createCampaign( + testAdminUser, + campaignTitle, + "test description", + testOrganization.data.createOrganization.id + ); + + expect(testCampaign.data.createCampaign.title).toBe(campaignTitle); +}); + +it("should create campaign contacts", async () => { + const contact = await createContact(testCampaign.data.createCampaign.id); + expect(contact.campaign_id).toBe( + parseInt(testCampaign.data.createCampaign.id) + ); +}); + +it("should add texters to a organization", async () => { testTexterUser = await createUser({ - auth0_id: 'test456', - first_name: 'TestTexterFirst', - last_name: 'TestTexterLast', - cell: '555-555-6666', - email: 'testtexter@example.com', - }) + auth0_id: "test456", + first_name: "TestTexterFirst", + last_name: "TestTexterLast", + cell: "555-555-6666", + email: "testtexter@example.com" + }); const joinQuery = ` mutation joinOrganization($organizationUuid: String!) { joinOrganization(organizationUuid: $organizationUuid) { id } - }` + }`; const variables = { organizationUuid: testOrganization.data.createOrganization.uuid - } - const context = getContext({user: testTexterUser}) - const result = await graphql(mySchema, joinQuery, rootValue, context, variables) - expect(result.data.joinOrganization.id).toBeTruthy() -}) - -it('should assign texters to campaign contacts', async () => { + }; + const context = getContext({ user: testTexterUser }); + const result = await graphql( + mySchema, + joinQuery, + rootValue, + context, + variables + ); + expect(result.data.joinOrganization.id).toBeTruthy(); +}); + +it("should assign texters to campaign contacts", async () => { const campaignEditQuery = ` mutation editCampaign($campaignId: String!, $campaign: CampaignInput!) { editCampaign(id: $campaignId, campaign: $campaign) { @@ -273,23 +322,31 @@ it('should assign texters to campaign contacts', async () => { text } } - }` - const context = getContext({user: testAdminUser}) - const updateCampaign = Object.assign({}, testCampaign.data.createCampaign) - const campaignId = updateCampaign.id - updateCampaign.texters = [{ - id: testTexterUser.id - }] - delete(updateCampaign.id) - delete(updateCampaign.contacts) + }`; + const context = getContext({ user: testAdminUser }); + const updateCampaign = Object.assign({}, testCampaign.data.createCampaign); + const campaignId = updateCampaign.id; + updateCampaign.texters = [ + { + id: testTexterUser.id + } + ]; + delete updateCampaign.id; + delete updateCampaign.contacts; const variables = { campaignId: campaignId, campaign: updateCampaign - } - const result = await graphql(mySchema, campaignEditQuery, rootValue, context, variables) - expect(result.data.editCampaign.texters.length).toBe(1) - expect(result.data.editCampaign.texters[0].assignment.contactsCount).toBe(1) -}) + }; + const result = await graphql( + mySchema, + campaignEditQuery, + rootValue, + context, + variables + ); + expect(result.data.editCampaign.texters.length).toBe(1); + expect(result.data.editCampaign.texters[0].assignment.contactsCount).toBe(1); +}); // it('should save a campaign script composed of interaction steps', async() => {}) @@ -301,147 +358,193 @@ it('should assign texters to campaign contacts', async () => { // it('should send an inital message to test contacts', async() => {}) -describe('Campaign', () => { - let organization - const adminUser = { is_superadmin: true, id: 1 } +describe("Campaign", () => { + let organization; + const adminUser = { is_superadmin: true, id: 1 }; beforeEach(async () => { - organization = await (new Organization({ - name: 'organization', + organization = await new Organization({ + name: "organization", texting_hours_start: 0, texting_hours_end: 0 - })).save() - }) + }).save(); + }); - describe('contacts', async () => { - let campaigns - let contacts + describe("contacts", async () => { + let campaigns; + let contacts; beforeEach(async () => { - campaigns = await Promise.all([ - new Campaign({ - organization_id: organization.id, - is_started: false, - is_archived: false, - due_by: new Date() - }), - new Campaign({ - organization_id: organization.id, - is_started: false, - is_archived: false, - due_by: new Date() - }) - ].map(async (each) => ( - each.save() - ))) - - contacts = await Promise.all([ - new CampaignContact({campaign_id: campaigns[0].id, cell: '', message_status: 'closed'}), - new CampaignContact({campaign_id: campaigns[1].id, cell: '', message_status: 'closed'}) - ].map(async (each) => ( - each.save() - ))) - }) - - test('resolves contacts', async () => { - const results = await campaignResolvers.Campaign.contacts(campaigns[0], null, { user: adminUser }) - expect(results).toHaveLength(1) - expect(results[0].campaign_id).toEqual(campaigns[0].id) - }) - - test('resolves contacts count', async () => { - const results = await campaignResolvers.Campaign.contactsCount(campaigns[0], null, { user: adminUser }) - expect(results).toEqual(1) - }) - - test('resolves contacts count when empty', async () => { - const campaign = await (new Campaign({ + campaigns = await Promise.all( + [ + new Campaign({ + organization_id: organization.id, + is_started: false, + is_archived: false, + due_by: new Date() + }), + new Campaign({ + organization_id: organization.id, + is_started: false, + is_archived: false, + due_by: new Date() + }) + ].map(async each => each.save()) + ); + + contacts = await Promise.all( + [ + new CampaignContact({ + campaign_id: campaigns[0].id, + cell: "", + message_status: "closed" + }), + new CampaignContact({ + campaign_id: campaigns[1].id, + cell: "", + message_status: "closed" + }) + ].map(async each => each.save()) + ); + }); + + test("resolves contacts", async () => { + const results = await campaignResolvers.Campaign.contacts( + campaigns[0], + null, + { user: adminUser } + ); + expect(results).toHaveLength(1); + expect(results[0].campaign_id).toEqual(campaigns[0].id); + }); + + test("resolves contacts count", async () => { + const results = await campaignResolvers.Campaign.contactsCount( + campaigns[0], + null, + { user: adminUser } + ); + expect(results).toEqual(1); + }); + + test("resolves contacts count when empty", async () => { + const campaign = await new Campaign({ organization_id: organization.id, is_started: false, is_archived: false, due_by: new Date() - })).save() - const results = await campaignResolvers.Campaign.contactsCount(campaign, null, { user: adminUser }) - expect(results).toEqual(0) - }) - }) - - describe('unassigned contacts', () => { - let campaign + }).save(); + const results = await campaignResolvers.Campaign.contactsCount( + campaign, + null, + { user: adminUser } + ); + expect(results).toEqual(0); + }); + }); + + describe("unassigned contacts", () => { + let campaign; beforeEach(async () => { - campaign = await (new Campaign({ + campaign = await new Campaign({ organization_id: organization.id, is_started: false, is_archived: false, use_dynamic_assignment: true, due_by: new Date() - })).save() - }) + }).save(); + }); - test('resolves unassigned contacts when true', async () => { - const contact = await (new CampaignContact({ + test("resolves unassigned contacts when true", async () => { + const contact = await new CampaignContact({ campaign_id: campaign.id, - message_status: 'needsMessage', - cell: '', - })).save() - - const results = await campaignResolvers.Campaign.hasUnassignedContacts(campaign, null, { user: adminUser }) - expect(results).toEqual(true) - const resultsForTexter = await campaignResolvers.Campaign.hasUnassignedContactsForTexter(campaign, null, { user: adminUser }) - expect(resultsForTexter).toEqual(true) - }) - - test('resolves unassigned contacts when false with assigned contacts', async () => { - const user = await (new User({ - auth0_id: 'test123', - first_name: 'TestUserFirst', - last_name: 'TestUserLast', - cell: '555-555-5555', - email: 'testuser@example.com', - })).save() - - const assignment = await (new Assignment({ + message_status: "needsMessage", + cell: "" + }).save(); + + const results = await campaignResolvers.Campaign.hasUnassignedContacts( + campaign, + null, + { user: adminUser } + ); + expect(results).toEqual(true); + const resultsForTexter = await campaignResolvers.Campaign.hasUnassignedContactsForTexter( + campaign, + null, + { user: adminUser } + ); + expect(resultsForTexter).toEqual(true); + }); + + test("resolves unassigned contacts when false with assigned contacts", async () => { + const user = await new User({ + auth0_id: "test123", + first_name: "TestUserFirst", + last_name: "TestUserLast", + cell: "555-555-5555", + email: "testuser@example.com" + }).save(); + + const assignment = await new Assignment({ user_id: user.id, - campaign_id: campaign.id, - })).save() + campaign_id: campaign.id + }).save(); - const contact = await (new CampaignContact({ + const contact = await new CampaignContact({ campaign_id: campaign.id, assignment_id: assignment.id, - message_status: 'closed', - cell: '', - })).save() - - const results = await campaignResolvers.Campaign.hasUnassignedContacts(campaign, null, { user: adminUser }) - expect(results).toEqual(false) - const resultsForTexter = await campaignResolvers.Campaign.hasUnassignedContactsForTexter(campaign, null, { user: adminUser }) - expect(resultsForTexter).toEqual(false) - }) - - test('resolves unassigned contacts when false with no contacts', async () => { - const results = await campaignResolvers.Campaign.hasUnassignedContacts(campaign, null, { user: adminUser }) - expect(results).toEqual(false) - }) - - test('test assignmentRequired access control', async () => { - const user = await createUser() - - const assignment = await (new Assignment({ + message_status: "closed", + cell: "" + }).save(); + + const results = await campaignResolvers.Campaign.hasUnassignedContacts( + campaign, + null, + { user: adminUser } + ); + expect(results).toEqual(false); + const resultsForTexter = await campaignResolvers.Campaign.hasUnassignedContactsForTexter( + campaign, + null, + { user: adminUser } + ); + expect(resultsForTexter).toEqual(false); + }); + + test("resolves unassigned contacts when false with no contacts", async () => { + const results = await campaignResolvers.Campaign.hasUnassignedContacts( + campaign, + null, + { user: adminUser } + ); + expect(results).toEqual(false); + }); + + test("test assignmentRequired access control", async () => { + const user = await createUser(); + + const assignment = await new Assignment({ user_id: user.id, - campaign_id: campaign.id, - })).save() - - const allowUser = await assignmentRequired(user, assignment.id, assignment) - expect(allowUser).toEqual(true) - const allowUserAssignmentId = await assignmentRequired(user, assignment.id) - expect(allowUserAssignmentId).toEqual(true) + campaign_id: campaign.id + }).save(); + + const allowUser = await assignmentRequired( + user, + assignment.id, + assignment + ); + expect(allowUser).toEqual(true); + const allowUserAssignmentId = await assignmentRequired( + user, + assignment.id + ); + expect(allowUserAssignmentId).toEqual(true); try { - const notAllowed = await assignmentRequired(user, -1) - throw new Exception('should throw BEFORE this exception') - } catch(err) { - expect(/not authorized/.test(String(err))).toEqual(true) + const notAllowed = await assignmentRequired(user, -1); + throw new Exception("should throw BEFORE this exception"); + } catch (err) { + expect(/not authorized/.test(String(err))).toEqual(true); } - }) - - }) -}) + }); + }); +}); diff --git a/__test__/components/AssignmentTexter.test.js b/__test__/components/AssignmentTexter.test.js index 5688a93da..168656e56 100644 --- a/__test__/components/AssignmentTexter.test.js +++ b/__test__/components/AssignmentTexter.test.js @@ -1,9 +1,9 @@ -import React from 'react' -import { shallow } from 'enzyme' -import { StyleSheetTestUtils } from 'aphrodite' +import React from "react"; +import { shallow } from "enzyme"; +import { StyleSheetTestUtils } from "aphrodite"; -import { genAssignment, contactGenerator } from '../test_client_helpers' -import { AssignmentTexter } from '../../src/components/AssignmentTexter' +import { genAssignment, contactGenerator } from "../test_client_helpers"; +import { AssignmentTexter } from "../../src/components/AssignmentTexter"; /* These tests try to ensure the 'texting loop' -- i.e. the loop between @@ -35,13 +35,12 @@ import { AssignmentTexter } from '../../src/components/AssignmentTexter' * clearcontactidolddata(contactid) */ - function sleep(ms) { - return new Promise(resolve => setTimeout(resolve, ms)) + return new Promise(resolve => setTimeout(resolve, ms)); } function genComponent(assignment, propertyOverrides = {}) { - StyleSheetTestUtils.suppressStyleInjection() + StyleSheetTestUtils.suppressStyleInjection(); const wrapper = shallow( {} }} refreshData={() => {}} - loadContacts={(getIds) => {}} + loadContacts={getIds => {}} getNewContacts={() => {}} - assignContactsIfNeeded={ - (checkServer, currentContactIndex) => Promise.resolve() - } - organizationId={'123'} + assignContactsIfNeeded={(checkServer, currentContactIndex) => + Promise.resolve() + } + organizationId={"123"} {...propertyOverrides} /> - ) - return wrapper + ); + return wrapper; } -describe('AssignmentTexter process flows', async () => { - it('Normal nondynamic assignment queue', async () => { - const assignment = genAssignment(false, true, /* contacts=*/ 6, 'needsMessage') - const createContact = contactGenerator(assignment.id, 'needsMessage') - let calledAssignmentIfNeeded = false - let component +describe("AssignmentTexter process flows", async () => { + it("Normal nondynamic assignment queue", async () => { + const assignment = genAssignment( + false, + true, + /* contacts=*/ 6, + "needsMessage" + ); + const createContact = contactGenerator(assignment.id, "needsMessage"); + let calledAssignmentIfNeeded = false; + let component; const wrapper = genComponent(assignment, { - loadContacts: (getIds) => { - return { data: { getAssignmentContacts: getIds.map(createContact) } } + loadContacts: getIds => { + return { data: { getAssignmentContacts: getIds.map(createContact) } }; }, assignContactsIfNeeded: (checkServer, curContactIndex) => { - calledAssignmentIfNeeded = true - return Promise.resolve() + calledAssignmentIfNeeded = true; + return Promise.resolve(); } - }) - component = wrapper.instance() - let contactsContacted = 0 + }); + component = wrapper.instance(); + let contactsContacted = 0; while (component.hasNext()) { - component.handleFinishContact(wrapper.state('currentContactIndex')) - contactsContacted += 1 - await sleep(1) // triggers updates + component.handleFinishContact(wrapper.state("currentContactIndex")); + contactsContacted += 1; + await sleep(1); // triggers updates } // last contact - component.handleFinishContact(wrapper.state('currentContactIndex')) - contactsContacted += 1 - await sleep(1) - expect(calledAssignmentIfNeeded).toBe(true) - expect(contactsContacted).toBe(6) - }) -}) + component.handleFinishContact(wrapper.state("currentContactIndex")); + contactsContacted += 1; + await sleep(1); + expect(calledAssignmentIfNeeded).toBe(true); + expect(contactsContacted).toBe(6); + }); +}); /* TESTS: @@ -120,4 +124,3 @@ TESTS: } * */ - diff --git a/__test__/components/TexterFAQs.test.js b/__test__/components/TexterFAQs.test.js index ebb095413..527b2da71 100644 --- a/__test__/components/TexterFAQs.test.js +++ b/__test__/components/TexterFAQs.test.js @@ -1,27 +1,25 @@ -import React from 'react' +import React from "react"; -import { shallow } from 'enzyme'; -import TexterFaqs from '../../src/components/TexterFaqs' +import { shallow } from "enzyme"; +import TexterFaqs from "../../src/components/TexterFaqs"; -describe('FAQs component', () => { +describe("FAQs component", () => { // given const faq = [ { - question: 'q1', - answer: 'a2' + question: "q1", + answer: "a2" } - ] - const wrapper = shallow( - - ) + ]; + const wrapper = shallow(); // when - test('Renders question and answer', () => { - const question = wrapper.find('CardTitle') - const answer = wrapper.find('CardText p') + test("Renders question and answer", () => { + const question = wrapper.find("CardTitle"); + const answer = wrapper.find("CardText p"); // then - expect(question.prop('title')).toBe('1. q1') - expect(answer.text()).toBe('a2') - }) -}) + expect(question.prop("title")).toBe("1. q1"); + expect(answer.text()).toBe("a2"); + }); +}); diff --git a/__test__/containers/AssignmentTexterContact.test.js b/__test__/containers/AssignmentTexterContact.test.js index 0f3be4b22..f0a667e56 100644 --- a/__test__/containers/AssignmentTexterContact.test.js +++ b/__test__/containers/AssignmentTexterContact.test.js @@ -1,20 +1,20 @@ /** * @jest-environment jsdom */ -import React from 'react' -import moment from 'moment-timezone' -import {mount} from "enzyme"; -import MuiThemeProvider from 'material-ui/styles/MuiThemeProvider' -import {StyleSheetTestUtils} from 'aphrodite' -import {AssignmentTexterContact} from "../../src/containers/AssignmentTexterContact"; +import React from "react"; +import moment from "moment-timezone"; +import { mount } from "enzyme"; +import MuiThemeProvider from "material-ui/styles/MuiThemeProvider"; +import { StyleSheetTestUtils } from "aphrodite"; +import { AssignmentTexterContact } from "../../src/containers/AssignmentTexterContact"; -var MockDate = require('mockdate'); +var MockDate = require("mockdate"); -jest.mock('../../src/lib/timezones') -jest.unmock('../../src/lib/tz-helpers') -jest.useFakeTimers() +jest.mock("../../src/lib/timezones"); +jest.unmock("../../src/lib/tz-helpers"); +jest.useFakeTimers(); -var timezones = require('../../src/lib/timezones') +var timezones = require("../../src/lib/timezones"); const campaign = { id: 9, @@ -36,8 +36,8 @@ const campaign = { answerOptions: [] } } - ] -} + ] +}; const propsWithEnforcedTextingHoursCampaign = { texter: { @@ -66,41 +66,45 @@ const propsWithEnforcedTextingHoursCampaign = { id: 20 } ], - allContactsCount: 2, + allContactsCount: 2 }, refreshData: jest.fn(), contact: { - id: 19, - assignmentId: 9, - firstName: "larry", - lastName: "person", - cell: "+19734779697", - zip: "10025", - customFields: "{}", - optOut: null, - currentInteractionStepScript: "{firstName}", - questionResponseValues: [], - location: { - city: "New York", - state: "NY", - timezone: { - offset: -5, - hasDST: true - } - }, - messageStatus: "needsMessage", - messages: [] + id: 19, + assignmentId: 9, + firstName: "larry", + lastName: "person", + cell: "+19734779697", + zip: "10025", + customFields: "{}", + optOut: null, + currentInteractionStepScript: "{firstName}", + questionResponseValues: [], + location: { + city: "New York", + state: "NY", + timezone: { + offset: -5, + hasDST: true + } + }, + messageStatus: "needsMessage", + messages: [] } -} +}; -describe('when contact is not within texting hours...', () => { +describe("when contact is not within texting hours...", () => { afterEach(() => { - propsWithEnforcedTextingHoursCampaign.refreshData.mockReset() - }) - - it('it refreshes data in componentDidMount', () => { - timezones.isBetweenTextingHours.mockReturnValue(false) - timezones.getLocalTime.mockReturnValue(moment().utc().utcOffset(-5)) + propsWithEnforcedTextingHoursCampaign.refreshData.mockReset(); + }); + + it("it refreshes data in componentDidMount", () => { + timezones.isBetweenTextingHours.mockReturnValue(false); + timezones.getLocalTime.mockReturnValue( + moment() + .utc() + .utcOffset(-5) + ); StyleSheetTestUtils.suppressStyleInjection(); let component = mount( @@ -112,18 +116,23 @@ describe('when contact is not within texting hours...', () => { contact={propsWithEnforcedTextingHoursCampaign.contact} /> - ) - jest.runOnlyPendingTimers() - expect(propsWithEnforcedTextingHoursCampaign.refreshData.mock.calls).toHaveLength(1) - }) -}) - - -describe('when contact is within texting hours...', () => { - var component + ); + jest.runOnlyPendingTimers(); + expect( + propsWithEnforcedTextingHoursCampaign.refreshData.mock.calls + ).toHaveLength(1); + }); +}); + +describe("when contact is within texting hours...", () => { + var component; beforeEach(() => { - timezones.isBetweenTextingHours.mockReturnValue(true) - timezones.getLocalTime.mockReturnValue(moment().utc().utcOffset(-5)) + timezones.isBetweenTextingHours.mockReturnValue(true); + timezones.getLocalTime.mockReturnValue( + moment() + .utc() + .utcOffset(-5) + ); StyleSheetTestUtils.suppressStyleInjection(); component = mount( @@ -135,108 +144,131 @@ describe('when contact is within texting hours...', () => { contact={propsWithEnforcedTextingHoursCampaign.contact} /> - ) - }) + ); + }); afterEach(() => { - propsWithEnforcedTextingHoursCampaign.refreshData.mockReset() - }) - it('it does NOT refresh data in componentDidMount', () => { - jest.runOnlyPendingTimers() - expect(propsWithEnforcedTextingHoursCampaign.refreshData.mock.calls).toHaveLength(0) - }) -}) - -describe('AssignmentTextContact has the proper enabled/disabled state when created', () => { - - it('is enabled if the contact is inside texting hours', () => { - timezones.isBetweenTextingHours.mockReturnValueOnce(true) - var assignmentTexterContact = new AssignmentTexterContact(propsWithEnforcedTextingHoursCampaign) - expect(assignmentTexterContact.state.disabled).toBeFalsy() - expect(assignmentTexterContact.state.disabledText).toEqual('Sending...') - }) - - it('is disabled if the contact is inside texting hours', () => { - timezones.isBetweenTextingHours.mockReturnValueOnce(false) - var assignmentTexterContact = new AssignmentTexterContact(propsWithEnforcedTextingHoursCampaign) - expect(assignmentTexterContact.state.disabled).toBeTruthy() - expect(assignmentTexterContact.state.disabledText).toEqual('Refreshing ...') - }) -}) - -describe('test isContactBetweenTextingHours', () => { - var assignmentTexterContact - - beforeAll(() => { - assignmentTexterContact = new AssignmentTexterContact(propsWithEnforcedTextingHoursCampaign) - timezones.isBetweenTextingHours.mockImplementation((o, c) => false) - MockDate.set('2018-02-01T15:00:00.000Z') - timezones.getLocalTime.mockReturnValue(moment().utc().utcOffset(-5)) - }) - - afterAll(() => { - MockDate.reset() - }) - - beforeEach(() => { - jest.resetAllMocks() - }) - - it('works when the contact has location data with empty timezone', () => { - - let contact = { - location: { - city: "New York", - state: "NY", - timezone: { - offset: null, - hasDST: null - } - } - } - - expect(assignmentTexterContact.isContactBetweenTextingHours(contact)).toBeFalsy() - expect(timezones.isBetweenTextingHours.mock.calls).toHaveLength(1) - - let theCall = timezones.isBetweenTextingHours.mock.calls[0] - expect(theCall[0]).toBeFalsy() - expect(theCall[1]).toEqual({textingHoursStart: 8, textingHoursEnd: 21, textingHoursEnforced: true}) - }) + propsWithEnforcedTextingHoursCampaign.refreshData.mockReset(); + }); + it("it does NOT refresh data in componentDidMount", () => { + jest.runOnlyPendingTimers(); + expect( + propsWithEnforcedTextingHoursCampaign.refreshData.mock.calls + ).toHaveLength(0); + }); +}); + +describe("AssignmentTextContact has the proper enabled/disabled state when created", () => { + it("is enabled if the contact is inside texting hours", () => { + timezones.isBetweenTextingHours.mockReturnValueOnce(true); + var assignmentTexterContact = new AssignmentTexterContact( + propsWithEnforcedTextingHoursCampaign + ); + expect(assignmentTexterContact.state.disabled).toBeFalsy(); + expect(assignmentTexterContact.state.disabledText).toEqual("Sending..."); + }); + + it("is disabled if the contact is inside texting hours", () => { + timezones.isBetweenTextingHours.mockReturnValueOnce(false); + var assignmentTexterContact = new AssignmentTexterContact( + propsWithEnforcedTextingHoursCampaign + ); + expect(assignmentTexterContact.state.disabled).toBeTruthy(); + expect(assignmentTexterContact.state.disabledText).toEqual( + "Refreshing ..." + ); + }); +}); + +describe("test isContactBetweenTextingHours", () => { + var assignmentTexterContact; + + beforeAll(() => { + assignmentTexterContact = new AssignmentTexterContact( + propsWithEnforcedTextingHoursCampaign + ); + timezones.isBetweenTextingHours.mockImplementation((o, c) => false); + MockDate.set("2018-02-01T15:00:00.000Z"); + timezones.getLocalTime.mockReturnValue( + moment() + .utc() + .utcOffset(-5) + ); + }); + + afterAll(() => { + MockDate.reset(); + }); - it('works when the contact has location data', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); - let contact = { - location: { - city: "New York", - state: "NY", - timezone: { - offset: -5, - hasDST: true - } + it("works when the contact has location data with empty timezone", () => { + let contact = { + location: { + city: "New York", + state: "NY", + timezone: { + offset: null, + hasDST: null } } - - expect(assignmentTexterContact.isContactBetweenTextingHours(contact)).toBeFalsy() - expect(timezones.isBetweenTextingHours.mock.calls).toHaveLength(1) - - let theCall = timezones.isBetweenTextingHours.mock.calls[0] - expect(theCall[0]).toEqual({hasDST: true, offset: -5}) - expect(theCall[1]).toEqual({textingHoursStart: 8, textingHoursEnd: 21, textingHoursEnforced: true}) - - - }) - - it('works when the contact does not have location data', () => { - - let contact = {} - - expect(assignmentTexterContact.isContactBetweenTextingHours(contact)).toBeFalsy() - expect(timezones.isBetweenTextingHours.mock.calls).toHaveLength(1) - - let theCall = timezones.isBetweenTextingHours.mock.calls[0] - expect(theCall[0]).toBeNull() - expect(theCall[1]).toEqual({textingHoursStart: 8, textingHoursEnd: 21, textingHoursEnforced: true}) - - - }) - } -) + }; + + expect( + assignmentTexterContact.isContactBetweenTextingHours(contact) + ).toBeFalsy(); + expect(timezones.isBetweenTextingHours.mock.calls).toHaveLength(1); + + let theCall = timezones.isBetweenTextingHours.mock.calls[0]; + expect(theCall[0]).toBeFalsy(); + expect(theCall[1]).toEqual({ + textingHoursStart: 8, + textingHoursEnd: 21, + textingHoursEnforced: true + }); + }); + + it("works when the contact has location data", () => { + let contact = { + location: { + city: "New York", + state: "NY", + timezone: { + offset: -5, + hasDST: true + } + } + }; + + expect( + assignmentTexterContact.isContactBetweenTextingHours(contact) + ).toBeFalsy(); + expect(timezones.isBetweenTextingHours.mock.calls).toHaveLength(1); + + let theCall = timezones.isBetweenTextingHours.mock.calls[0]; + expect(theCall[0]).toEqual({ hasDST: true, offset: -5 }); + expect(theCall[1]).toEqual({ + textingHoursStart: 8, + textingHoursEnd: 21, + textingHoursEnforced: true + }); + }); + + it("works when the contact does not have location data", () => { + let contact = {}; + + expect( + assignmentTexterContact.isContactBetweenTextingHours(contact) + ).toBeFalsy(); + expect(timezones.isBetweenTextingHours.mock.calls).toHaveLength(1); + + let theCall = timezones.isBetweenTextingHours.mock.calls[0]; + expect(theCall[0]).toBeNull(); + expect(theCall[1]).toEqual({ + textingHoursStart: 8, + textingHoursEnd: 21, + textingHoursEnforced: true + }); + }); +}); diff --git a/__test__/containers/CampaignList.test.js b/__test__/containers/CampaignList.test.js index 575cba663..aa6668133 100644 --- a/__test__/containers/CampaignList.test.js +++ b/__test__/containers/CampaignList.test.js @@ -1,63 +1,66 @@ /** * @jest-environment jsdom */ -import React from 'react' -import { mount } from 'enzyme' -import MuiThemeProvider from 'material-ui/styles/MuiThemeProvider' -import { CampaignList } from '../../src/containers/CampaignList' +import React from "react"; +import { mount } from "enzyme"; +import MuiThemeProvider from "material-ui/styles/MuiThemeProvider"; +import { CampaignList } from "../../src/containers/CampaignList"; -describe('Campaign list for campaign with null creator', () => { +describe("Campaign list for campaign with null creator", () => { // given const campaignWithoutCreator = { id: 1, - title: 'Yes on A', - creator: null, - } + title: "Yes on A", + creator: null + }; const data = { organization: { campaigns: { - campaigns: [ campaignWithoutCreator ], - }, - }, - } + campaigns: [campaignWithoutCreator] + } + } + }; // when - test('Renders for campaign with null creator, doesn\'t include created by', () => { + test("Renders for campaign with null creator, doesn't include created by", () => { const wrapper = mount( - ) - expect(wrapper.text().includes('Created by')).toBeFalsy() - }) -}) + ); + expect(wrapper.text().includes("Created by")).toBeFalsy(); + }); +}); -describe('Campaign list for campaign with creator', () => { +describe("Campaign list for campaign with creator", () => { // given const campaignWithCreator = { id: 1, creator: { - displayName: 'Lorem Ipsum' - }, - } + displayName: "Lorem Ipsum" + } + }; const data = { organization: { campaigns: { - campaigns: [ campaignWithCreator ], - }, - }, - } + campaigns: [campaignWithCreator] + } + } + }; // when - test('Renders for campaign with creator, includes created by', () => { + test("Renders for campaign with creator, includes created by", () => { const wrapper = mount( - ) - expect(wrapper.containsMatchingElement( — Created by Lorem Ipsum)).toBeTruthy() - }) -}) - + ); + expect( + wrapper.containsMatchingElement( + — Created by Lorem Ipsum + ) + ).toBeTruthy(); + }); +}); diff --git a/__test__/containers/TexterTodo.test.js b/__test__/containers/TexterTodo.test.js index a1784fee8..8cf339a29 100644 --- a/__test__/containers/TexterTodo.test.js +++ b/__test__/containers/TexterTodo.test.js @@ -1,83 +1,104 @@ /** * @jest-environment jsdom */ -import React from 'react' -import {mount} from "enzyme"; -import MuiThemeProvider from 'material-ui/styles/MuiThemeProvider' -import {StyleSheetTestUtils} from 'aphrodite' +import React from "react"; +import { mount } from "enzyme"; +import MuiThemeProvider from "material-ui/styles/MuiThemeProvider"; +import { StyleSheetTestUtils } from "aphrodite"; -import {genAssignment, contactGenerator} from '../test_client_helpers'; -import {TexterTodo} from "../../src/containers/TexterTodo"; +import { genAssignment, contactGenerator } from "../test_client_helpers"; +import { TexterTodo } from "../../src/containers/TexterTodo"; -function genComponent(isArchived, hasContacts, routerPushes, statusMessage, assignmentNull) { - const assignmentId = 8 - const contactMapper = contactGenerator(assignmentId, statusMessage) - let assignment = genAssignment(assignmentId, isArchived, hasContacts) +function genComponent( + isArchived, + hasContacts, + routerPushes, + statusMessage, + assignmentNull +) { + const assignmentId = 8; + const contactMapper = contactGenerator(assignmentId, statusMessage); + let assignment = genAssignment(assignmentId, isArchived, hasContacts); if (assignmentNull) { - assignment = null + assignment = null; } StyleSheetTestUtils.suppressStyleInjection(); return mount( - - - - ) - + + + + ); } -describe('TexterTodo tests...', () => { +describe("TexterTodo tests...", () => { //afterEach(() => { // propsWithEnforcedTextingHoursCampaign.refreshData.mockReset() //}) - it('redirect if the assignment is archived', () => { - const routerPushes = [] - const isArchived = true - const hasContacts = true - const component = genComponent(isArchived, hasContacts, routerPushes, 'needsMessage') - expect(routerPushes[0]).toBe('/app/123/todos') - }) + it("redirect if the assignment is archived", () => { + const routerPushes = []; + const isArchived = true; + const hasContacts = true; + const component = genComponent( + isArchived, + hasContacts, + routerPushes, + "needsMessage" + ); + expect(routerPushes[0]).toBe("/app/123/todos"); + }); - it('redirect if the assignment is null', () => { - const routerPushes = [] - const isArchived = false - const hasContacts = true - const assignmentNull = true - const component = genComponent(isArchived, hasContacts, routerPushes, 'needsMessage', assignmentNull) - expect(routerPushes[0]).toBe('/app/123/todos') - }) + it("redirect if the assignment is null", () => { + const routerPushes = []; + const isArchived = false; + const hasContacts = true; + const assignmentNull = true; + const component = genComponent( + isArchived, + hasContacts, + routerPushes, + "needsMessage", + assignmentNull + ); + expect(routerPushes[0]).toBe("/app/123/todos"); + }); - it('redirect if the assignment is normal no redirects', () => { - const routerPushes = [] - const isArchived = false - const hasContacts = true - const assignmentNull = true - const component = genComponent(isArchived, hasContacts, routerPushes, 'needsMessage') - expect(routerPushes).toEqual([]) - }) + it("redirect if the assignment is normal no redirects", () => { + const routerPushes = []; + const isArchived = false; + const hasContacts = true; + const assignmentNull = true; + const component = genComponent( + isArchived, + hasContacts, + routerPushes, + "needsMessage" + ); + expect(routerPushes).toEqual([]); + }); // 1. test assignContactsIfNeeded() // 2. test getNewContacts() // 3. test loadContacts() // 4. component render - -}) +}); diff --git a/__test__/e2e/basic_text_manager.test.js b/__test__/e2e/basic_text_manager.test.js index b26ea79e2..625903d13 100644 --- a/__test__/e2e/basic_text_manager.test.js +++ b/__test__/e2e/basic_text_manager.test.js @@ -1,17 +1,21 @@ -import { selenium } from './util/helpers' -import STRINGS from './data/strings' -import { campaigns, login, main, people, texter } from './page-functions/index' +import { selenium } from "./util/helpers"; +import STRINGS from "./data/strings"; +import { campaigns, login, main, people, texter } from "./page-functions/index"; -jasmine.getEnv().addReporter(selenium.reporter) +jasmine.getEnv().addReporter(selenium.reporter); -describe('Basic Text Manager Workflow', () => { +describe("Basic Text Manager Workflow", () => { // Instantiate browser(s) - const driverAdmin = selenium.buildDriver({ name: 'Spoke E2E Tests - Chrome - Basic Text Manager Workflow - Admin' }) - const driverTexter = selenium.buildDriver({ name: 'Spoke E2E Tests - Chrome - Basic Text Manager Workflow - Texter' }) + const driverAdmin = selenium.buildDriver({ + name: "Spoke E2E Tests - Chrome - Basic Text Manager Workflow - Admin" + }); + const driverTexter = selenium.buildDriver({ + name: "Spoke E2E Tests - Chrome - Basic Text Manager Workflow - Texter" + }); beforeAll(() => { - global.e2e = {} - }) + global.e2e = {}; + }); /** * Test Suite Sequence: @@ -23,121 +27,121 @@ describe('Basic Text Manager Workflow', () => { */ afterAll(async () => { - await selenium.quitDriver(driverAdmin) - await selenium.quitDriver(driverTexter) - }) - - describe('Setup Admin User', () => { - describe('(As Admin) Open Landing Page', () => { - login.landing(driverAdmin) - }) - - describe('(As Admin) Log In an admin to Spoke', () => { - login.tryLoginThenSignUp(driverAdmin, STRINGS.users.admin0) - }) - - describe('(As Admin) Create a New Organization / Team', () => { - main.createOrg(driverAdmin, STRINGS.org) - }) - }) - - describe('Create Campaign (No Existing Texter)', () => { - const CAMPAIGN = STRINGS.campaigns.noExistingTexter - - describe('(As Admin) Create a New Campaign', () => { - campaigns.startCampaign(driverAdmin, CAMPAIGN) - }) - - describe('(As Texter) Follow the Invite URL', () => { - texter.viewInvite(driverTexter) - login.tryLoginThenSignUp(driverTexter, CAMPAIGN.texter) - }) - - describe('(As Texter) Verify Todos', () => { - texter.viewSendFirstTexts(driverTexter) - }) - - describe('(As Texter) Log Out', () => { - main.logOutUser(driverTexter) - }) - }) - - describe('Create Campaign (Existing Texter)', () => { - const CAMPAIGN = STRINGS.campaigns.existingTexter - - describe('(As Admin) Invite a new Texter', () => { - people.invite(driverAdmin) - }) - - describe('(As Texter) Follow the Invite URL', () => { - texter.viewInvite(driverTexter) - login.tryLoginThenSignUp(driverTexter, CAMPAIGN.texter) - }) - - describe('(As Admin) Create a New Campaign', () => { - campaigns.startCampaign(driverAdmin, CAMPAIGN) - }) - - describe('(As Texter) Send Texts', () => { - texter.sendTexts(driverTexter, CAMPAIGN) - }) - - describe('(As Admin) Send Replies', () => { - campaigns.sendReplies(driverAdmin, CAMPAIGN) - }) - - describe('(As Texter) View Replies', () => { - texter.viewReplies(driverTexter, CAMPAIGN) - }) - - describe('(As Texter) Opt Out Contact', () => { - texter.optOutContact(driverTexter) - }) - - describe('(As Texter) Log Out', () => { - main.logOutUser(driverTexter) - }) - }) - - describe('Create Campaign (No Existing Texter with Opt-Out)', () => { - const CAMPAIGN = STRINGS.campaigns.noExistingTexterOptOut - - describe('(As Admin) Create a New Campaign', () => { - campaigns.startCampaign(driverAdmin, CAMPAIGN) - }) - - describe('(As Texter) Follow the Invite URL', () => { - texter.viewInvite(driverTexter) - login.tryLoginThenSignUp(driverTexter, CAMPAIGN.texter) - }) - - describe('(As Texter) Verify Todos', () => { - texter.viewSendFirstTexts(driverTexter) - }) - - describe('(As Texter) Log Out', () => { - main.logOutUser(driverTexter) - }) - }) - - describe('Create Campaign (Existing Texters with Opt-Out)', () => { - const CAMPAIGN = STRINGS.campaigns.existingTexterOptOut - - describe('(As Admin) Invite a new Texter', () => { - people.invite(driverAdmin) - }) - - describe('(As Texter) Follow the Invite URL', () => { - texter.viewInvite(driverTexter) - login.tryLoginThenSignUp(driverTexter, CAMPAIGN.texter) - }) - - describe('(As Admin) Create a New Campaign', () => { - campaigns.startCampaign(driverAdmin, CAMPAIGN) - }) - - describe('(As Texter) Verify Todos', () => { - texter.viewSendFirstTexts(driverTexter) - }) - }) -}) + await selenium.quitDriver(driverAdmin); + await selenium.quitDriver(driverTexter); + }); + + describe("Setup Admin User", () => { + describe("(As Admin) Open Landing Page", () => { + login.landing(driverAdmin); + }); + + describe("(As Admin) Log In an admin to Spoke", () => { + login.tryLoginThenSignUp(driverAdmin, STRINGS.users.admin0); + }); + + describe("(As Admin) Create a New Organization / Team", () => { + main.createOrg(driverAdmin, STRINGS.org); + }); + }); + + describe("Create Campaign (No Existing Texter)", () => { + const CAMPAIGN = STRINGS.campaigns.noExistingTexter; + + describe("(As Admin) Create a New Campaign", () => { + campaigns.startCampaign(driverAdmin, CAMPAIGN); + }); + + describe("(As Texter) Follow the Invite URL", () => { + texter.viewInvite(driverTexter); + login.tryLoginThenSignUp(driverTexter, CAMPAIGN.texter); + }); + + describe("(As Texter) Verify Todos", () => { + texter.viewSendFirstTexts(driverTexter); + }); + + describe("(As Texter) Log Out", () => { + main.logOutUser(driverTexter); + }); + }); + + describe("Create Campaign (Existing Texter)", () => { + const CAMPAIGN = STRINGS.campaigns.existingTexter; + + describe("(As Admin) Invite a new Texter", () => { + people.invite(driverAdmin); + }); + + describe("(As Texter) Follow the Invite URL", () => { + texter.viewInvite(driverTexter); + login.tryLoginThenSignUp(driverTexter, CAMPAIGN.texter); + }); + + describe("(As Admin) Create a New Campaign", () => { + campaigns.startCampaign(driverAdmin, CAMPAIGN); + }); + + describe("(As Texter) Send Texts", () => { + texter.sendTexts(driverTexter, CAMPAIGN); + }); + + describe("(As Admin) Send Replies", () => { + campaigns.sendReplies(driverAdmin, CAMPAIGN); + }); + + describe("(As Texter) View Replies", () => { + texter.viewReplies(driverTexter, CAMPAIGN); + }); + + describe("(As Texter) Opt Out Contact", () => { + texter.optOutContact(driverTexter); + }); + + describe("(As Texter) Log Out", () => { + main.logOutUser(driverTexter); + }); + }); + + describe("Create Campaign (No Existing Texter with Opt-Out)", () => { + const CAMPAIGN = STRINGS.campaigns.noExistingTexterOptOut; + + describe("(As Admin) Create a New Campaign", () => { + campaigns.startCampaign(driverAdmin, CAMPAIGN); + }); + + describe("(As Texter) Follow the Invite URL", () => { + texter.viewInvite(driverTexter); + login.tryLoginThenSignUp(driverTexter, CAMPAIGN.texter); + }); + + describe("(As Texter) Verify Todos", () => { + texter.viewSendFirstTexts(driverTexter); + }); + + describe("(As Texter) Log Out", () => { + main.logOutUser(driverTexter); + }); + }); + + describe("Create Campaign (Existing Texters with Opt-Out)", () => { + const CAMPAIGN = STRINGS.campaigns.existingTexterOptOut; + + describe("(As Admin) Invite a new Texter", () => { + people.invite(driverAdmin); + }); + + describe("(As Texter) Follow the Invite URL", () => { + texter.viewInvite(driverTexter); + login.tryLoginThenSignUp(driverTexter, CAMPAIGN.texter); + }); + + describe("(As Admin) Create a New Campaign", () => { + campaigns.startCampaign(driverAdmin, CAMPAIGN); + }); + + describe("(As Texter) Verify Todos", () => { + texter.viewSendFirstTexts(driverTexter); + }); + }); +}); diff --git a/__test__/e2e/create_copy_campaign.test.js b/__test__/e2e/create_copy_campaign.test.js index d5185e7ce..d2ae33ad3 100644 --- a/__test__/e2e/create_copy_campaign.test.js +++ b/__test__/e2e/create_copy_campaign.test.js @@ -1,50 +1,54 @@ -import { selenium } from './util/helpers' -import STRINGS from './data/strings' -import { campaigns, login, main, people, texter } from './page-functions/index' +import { selenium } from "./util/helpers"; +import STRINGS from "./data/strings"; +import { campaigns, login, main, people, texter } from "./page-functions/index"; -jasmine.getEnv().addReporter(selenium.reporter) +jasmine.getEnv().addReporter(selenium.reporter); -describe('Create and Copy Campaign', () => { +describe("Create and Copy Campaign", () => { // Instantiate browser(s) - const driverAdmin = selenium.buildDriver({ name: 'Spoke E2E Tests - Chrome - Create and Copy Campaign - Admin' }) - const driverTexter = selenium.buildDriver({ name: 'Spoke E2E Tests - Chrome - Create and Copy Campaign - Texter' }) - const CAMPAIGN = STRINGS.campaigns.copyCampaign + const driverAdmin = selenium.buildDriver({ + name: "Spoke E2E Tests - Chrome - Create and Copy Campaign - Admin" + }); + const driverTexter = selenium.buildDriver({ + name: "Spoke E2E Tests - Chrome - Create and Copy Campaign - Texter" + }); + const CAMPAIGN = STRINGS.campaigns.copyCampaign; beforeAll(() => { - global.e2e = {} - }) + global.e2e = {}; + }); afterAll(async () => { - await selenium.quitDriver(driverAdmin) - await selenium.quitDriver(driverTexter) - }) - - describe('(As Admin) Open Landing Page', () => { - login.landing(driverAdmin) - }) - - describe('(As Admin) Log In an admin to Spoke', () => { - login.tryLoginThenSignUp(driverAdmin, CAMPAIGN.admin) - }) - - describe('(As Admin) Create a New Organization / Team', () => { - main.createOrg(driverAdmin, STRINGS.org) - }) - - describe('(As Admin) Invite a new User', () => { - people.invite(driverAdmin) - }) - - describe('(As Texter) Follow the Invite URL', () => { - texter.viewInvite(driverTexter) - login.tryLoginThenSignUp(driverTexter, CAMPAIGN.texter) - }) - - describe('(As Admin) Create a New Campaign', () => { - campaigns.startCampaign(driverAdmin, CAMPAIGN) - }) - - describe('(As Admin) Copy Campaign', () => { - campaigns.copyCampaign(driverAdmin, CAMPAIGN) - }) -}) + await selenium.quitDriver(driverAdmin); + await selenium.quitDriver(driverTexter); + }); + + describe("(As Admin) Open Landing Page", () => { + login.landing(driverAdmin); + }); + + describe("(As Admin) Log In an admin to Spoke", () => { + login.tryLoginThenSignUp(driverAdmin, CAMPAIGN.admin); + }); + + describe("(As Admin) Create a New Organization / Team", () => { + main.createOrg(driverAdmin, STRINGS.org); + }); + + describe("(As Admin) Invite a new User", () => { + people.invite(driverAdmin); + }); + + describe("(As Texter) Follow the Invite URL", () => { + texter.viewInvite(driverTexter); + login.tryLoginThenSignUp(driverTexter, CAMPAIGN.texter); + }); + + describe("(As Admin) Create a New Campaign", () => { + campaigns.startCampaign(driverAdmin, CAMPAIGN); + }); + + describe("(As Admin) Copy Campaign", () => { + campaigns.copyCampaign(driverAdmin, CAMPAIGN); + }); +}); diff --git a/__test__/e2e/create_edit_campaign.test.js b/__test__/e2e/create_edit_campaign.test.js index 508e799bc..24a712c34 100644 --- a/__test__/e2e/create_edit_campaign.test.js +++ b/__test__/e2e/create_edit_campaign.test.js @@ -1,39 +1,41 @@ -import { selenium } from './util/helpers' -import STRINGS from './data/strings' -import { campaigns, login, main } from './page-functions/index' +import { selenium } from "./util/helpers"; +import STRINGS from "./data/strings"; +import { campaigns, login, main } from "./page-functions/index"; -jasmine.getEnv().addReporter(selenium.reporter) +jasmine.getEnv().addReporter(selenium.reporter); -describe('Create and Edit Campaign', () => { +describe("Create and Edit Campaign", () => { // Instantiate browser(s) - const driver = selenium.buildDriver({ name: 'Spoke E2E Tests - Chrome - Create and Edit Campaign - Admin' }) - const CAMPAIGN = STRINGS.campaigns.editCampaign + const driver = selenium.buildDriver({ + name: "Spoke E2E Tests - Chrome - Create and Edit Campaign - Admin" + }); + const CAMPAIGN = STRINGS.campaigns.editCampaign; beforeAll(() => { - global.e2e = {} - }) + global.e2e = {}; + }); afterAll(async () => { - await selenium.quitDriver(driver) - }) + await selenium.quitDriver(driver); + }); - describe('(As Admin) Open Landing Page', () => { - login.landing(driver) - }) + describe("(As Admin) Open Landing Page", () => { + login.landing(driver); + }); - describe('(As Admin) Log In an admin to Spoke', () => { - login.tryLoginThenSignUp(driver, CAMPAIGN.admin) - }) + describe("(As Admin) Log In an admin to Spoke", () => { + login.tryLoginThenSignUp(driver, CAMPAIGN.admin); + }); - describe('(As Admin) Create a New Organization / Team', () => { - main.createOrg(driver, STRINGS.org) - }) + describe("(As Admin) Create a New Organization / Team", () => { + main.createOrg(driver, STRINGS.org); + }); - describe('(As Admin) Create a New Campaign', () => { - campaigns.startCampaign(driver, CAMPAIGN) - }) + describe("(As Admin) Create a New Campaign", () => { + campaigns.startCampaign(driver, CAMPAIGN); + }); - describe('(As Admin) Edit Campaign', () => { - campaigns.editCampaign(driver, CAMPAIGN) - }) -}) + describe("(As Admin) Edit Campaign", () => { + campaigns.editCampaign(driver, CAMPAIGN); + }); +}); diff --git a/__test__/e2e/data/strings.js b/__test__/e2e/data/strings.js index 51d29accf..2452e66d3 100644 --- a/__test__/e2e/data/strings.js +++ b/__test__/e2e/data/strings.js @@ -1,116 +1,116 @@ -import path from 'path' -import _ from 'lodash' +import path from "path"; +import _ from "lodash"; // Common to all campaigns const contacts = { - csv: path.resolve(__dirname, './people.csv') -} + csv: path.resolve(__dirname, "./people.csv") +}; const texters = { contactLength: 2, contactLengthAfterOptOut: 1 -} +}; const interaction = { - script: 'Test First {firstName} Last {lastName}!', - question: 'Test Question?', + script: "Test First {firstName} Last {lastName}!", + question: "Test Question?", answers: [ { - answerOption: 'Test Answer 0', - script: 'Test Answer 0 {firstName}.', - questionText: 'Test Child Question 0?' + answerOption: "Test Answer 0", + script: "Test Answer 0 {firstName}.", + questionText: "Test Child Question 0?" }, { - answerOption: 'Test Answer 1', - script: 'Test Answer 1 {lastName}.', - questionText: 'Test Child Question 1?' + answerOption: "Test Answer 1", + script: "Test Answer 1 {lastName}.", + questionText: "Test Child Question 1?" } ] -} +}; const cannedResponses = [ { - title: 'Test CR0', - script: 'Test CR First {firstName} Last {lastName}.' + title: "Test CR0", + script: "Test CR First {firstName} Last {lastName}." } -] +]; -const standardReply = 'Test Reply' +const standardReply = "Test Reply"; -const org = 'SpokeTestOrg' +const org = "SpokeTestOrg"; const users = { /** * Note: Changing passwords for existing Auth0 users requires the user be removed from Auth0 */ admin0: { - name: 'admin0', - email: 'spokeadmin0@moveon.org', - password: 'SpokeAdmin0!', - given_name: 'Adminzerofirst', - family_name: 'Adminzerolast', - cell: '4145550000' + name: "admin0", + email: "spokeadmin0@moveon.org", + password: "SpokeAdmin0!", + given_name: "Adminzerofirst", + family_name: "Adminzerolast", + cell: "4145550000" }, admin1: { - name: 'admin1', - email: 'spokeadmin1@moveon.org', - email_changed: 'spokeadmin1b@moveon.org', - password: 'SpokeAdmin1!', - given_name: 'Adminonefirst', - given_name_changed: 'Adminonefirstb', - family_name: 'Adminonelast', - family_name_changed: 'Adminonelastb', - cell: '4145550001', - cell_changed: '6085550001' + name: "admin1", + email: "spokeadmin1@moveon.org", + email_changed: "spokeadmin1b@moveon.org", + password: "SpokeAdmin1!", + given_name: "Adminonefirst", + given_name_changed: "Adminonefirstb", + family_name: "Adminonelast", + family_name_changed: "Adminonelastb", + cell: "4145550001", + cell_changed: "6085550001" }, texter0: { - name: 'texter0', - email: 'spoketexter0@moveon.org', - password: 'SpokeTexter0!', - given_name: 'Texterzerofirst', - family_name: 'Texterzerolast', - cell: '4146660000' + name: "texter0", + email: "spoketexter0@moveon.org", + password: "SpokeTexter0!", + given_name: "Texterzerofirst", + family_name: "Texterzerolast", + cell: "4146660000" }, texter1: { - name: 'texter1', - email: 'spoketexter1@moveon.org', - email_changed: 'spoketexter1b@moveon.org', - password: 'SpokeTexter1!', - given_name: 'Texteronefirst', - given_name_changed: 'Texteronefirstb', - family_name: 'Texteronelast', - family_name_changed: 'Texteronelastb', - cell: '4146660001', - cell_changed: '6086660001' + name: "texter1", + email: "spoketexter1@moveon.org", + email_changed: "spoketexter1b@moveon.org", + password: "SpokeTexter1!", + given_name: "Texteronefirst", + given_name_changed: "Texteronefirstb", + family_name: "Texteronelast", + family_name_changed: "Texteronelastb", + cell: "4146660001", + cell_changed: "6086660001" }, texter2: { - name: 'texter2', - email: 'spoketexter2@moveon.org', - password: 'SpokeTexter2!', - given_name: 'Textertwofirst', - family_name: 'Textertwolast', - cell: '4146660002' + name: "texter2", + email: "spoketexter2@moveon.org", + password: "SpokeTexter2!", + given_name: "Textertwofirst", + family_name: "Textertwolast", + cell: "4146660002" }, texter3: { - name: 'texter3', - email: 'spoketexter3@moveon.org', - password: 'SpokeTexter3!', - given_name: 'Texterthreefirst', - family_name: 'Texterthreelast', - cell: '4146660003' + name: "texter3", + email: "spoketexter3@moveon.org", + password: "SpokeTexter3!", + given_name: "Texterthreefirst", + family_name: "Texterthreelast", + cell: "4146660003" } -} +}; const campaigns = { noExistingTexter: { - name: 'noExistingTexter', + name: "noExistingTexter", optOut: false, admin: users.admin0, texter: users.texter0, existingTexter: false, basics: { - title: 'Test NET Campaign Title', - description: 'Test NET Campaign Description' + title: "Test NET Campaign Title", + description: "Test NET Campaign Description" }, contacts, texters, @@ -119,14 +119,14 @@ const campaigns = { standardReply }, existingTexter: { - name: 'existingTexter', + name: "existingTexter", optOut: false, admin: users.admin0, texter: users.texter1, existingTexter: true, basics: { - title: 'Test ET Campaign Title', - description: 'Test ET Campaign Description' + title: "Test ET Campaign Title", + description: "Test ET Campaign Description" }, contacts, texters, @@ -135,47 +135,51 @@ const campaigns = { standardReply }, noExistingTexterOptOut: { - name: 'noExistingTexterOptOut', + name: "noExistingTexterOptOut", optOut: true, admin: users.admin0, texter: users.texter2, existingTexter: false, basics: { - title: 'Test NETOO Campaign Title', - description: 'Test NETOO Campaign Description' + title: "Test NETOO Campaign Title", + description: "Test NETOO Campaign Description" }, contacts, - texters: _.assign({}, texters, { contactLength: texters.contactLengthAfterOptOut }), + texters: _.assign({}, texters, { + contactLength: texters.contactLengthAfterOptOut + }), interaction, cannedResponses, standardReply }, existingTexterOptOut: { - name: 'existingTexterOptOut', + name: "existingTexterOptOut", optOut: true, admin: users.admin0, texter: users.texter3, existingTexter: true, basics: { - title: 'Test ETOO Campaign Title', - description: 'Test ETOO Campaign Description' + title: "Test ETOO Campaign Title", + description: "Test ETOO Campaign Description" }, contacts, - texters: _.assign({}, texters, { contactLength: texters.contactLengthAfterOptOut }), + texters: _.assign({}, texters, { + contactLength: texters.contactLengthAfterOptOut + }), interaction, cannedResponses, standardReply }, copyCampaign: { - name: 'copyCampaign', + name: "copyCampaign", admin: users.admin0, texter: users.texter0, existingTexter: true, dynamicAssignment: false, basics: { - title: 'Test C Campaign Title', - title_copied: 'COPY - Test C Campaign Title', - description: 'Test C Campaign Description' + title: "Test C Campaign Title", + title_copied: "COPY - Test C Campaign Title", + description: "Test C Campaign Description" }, contacts, texters, @@ -183,14 +187,14 @@ const campaigns = { cannedResponses }, editCampaign: { - name: 'editCampaign', + name: "editCampaign", admin: users.admin1, existingTexter: false, dynamicAssignment: true, basics: { - title: 'Test E Campaign Title', - title_changed: 'Test E Campaign Title Changed', - description: 'Test E Campaign Description' + title: "Test E Campaign Title", + title_changed: "Test E Campaign Title Changed", + description: "Test E Campaign Description" }, contacts, texters, @@ -198,14 +202,14 @@ const campaigns = { cannedResponses }, userManagement: { - name: 'userManagement', + name: "userManagement", admin: users.admin1, texter: users.texter1 } -} +}; export default { campaigns, org, users -} +}; diff --git a/__test__/e2e/invite_texter.test.js b/__test__/e2e/invite_texter.test.js index 3e53a9c8a..9504503bd 100644 --- a/__test__/e2e/invite_texter.test.js +++ b/__test__/e2e/invite_texter.test.js @@ -1,50 +1,54 @@ -import { selenium } from './util/helpers' -import STRINGS from './data/strings' -import { login, main, people, texter } from './page-functions/index' +import { selenium } from "./util/helpers"; +import STRINGS from "./data/strings"; +import { login, main, people, texter } from "./page-functions/index"; -jasmine.getEnv().addReporter(selenium.reporter) +jasmine.getEnv().addReporter(selenium.reporter); -describe('Invite Texter workflow', () => { +describe("Invite Texter workflow", () => { // Instantiate browser(s) - const driverAdmin = selenium.buildDriver({ name: 'Spoke E2E Tests - Chrome - Invite Texter workflow - Admin' }) - const driverTexter = selenium.buildDriver({ name: 'Spoke E2E Tests - Chrome - Invite Texter workflow - Texter' }) - const CAMPAIGN = STRINGS.campaigns.userManagement + const driverAdmin = selenium.buildDriver({ + name: "Spoke E2E Tests - Chrome - Invite Texter workflow - Admin" + }); + const driverTexter = selenium.buildDriver({ + name: "Spoke E2E Tests - Chrome - Invite Texter workflow - Texter" + }); + const CAMPAIGN = STRINGS.campaigns.userManagement; beforeAll(() => { - global.e2e = {} - }) + global.e2e = {}; + }); afterAll(async () => { - await selenium.quitDriver(driverAdmin) - await selenium.quitDriver(driverTexter) - }) - - describe('(As Admin) Open Landing Page', () => { - login.landing(driverAdmin) - }) - - describe('(As Admin) Log In an admin to Spoke', () => { - login.tryLoginThenSignUp(driverAdmin, CAMPAIGN.admin) - }) - - describe('(As Admin) Create a New Organization / Team', () => { - main.createOrg(driverAdmin, STRINGS.org) - }) - - describe('(As Admin) Invite a new User', () => { - people.invite(driverAdmin) - }) - - describe('(As Texter) Follow the Invite URL', () => { - texter.viewInvite(driverTexter) - login.tryLoginThenSignUp(driverTexter, CAMPAIGN.texter) - }) - - describe('(As Admin) Edit User', () => { - people.editUser(driverAdmin, CAMPAIGN.admin) - }) - - describe('(As Texter) Edit User', () => { - main.editUser(driverTexter, CAMPAIGN.texter) - }) -}) + await selenium.quitDriver(driverAdmin); + await selenium.quitDriver(driverTexter); + }); + + describe("(As Admin) Open Landing Page", () => { + login.landing(driverAdmin); + }); + + describe("(As Admin) Log In an admin to Spoke", () => { + login.tryLoginThenSignUp(driverAdmin, CAMPAIGN.admin); + }); + + describe("(As Admin) Create a New Organization / Team", () => { + main.createOrg(driverAdmin, STRINGS.org); + }); + + describe("(As Admin) Invite a new User", () => { + people.invite(driverAdmin); + }); + + describe("(As Texter) Follow the Invite URL", () => { + texter.viewInvite(driverTexter); + login.tryLoginThenSignUp(driverTexter, CAMPAIGN.texter); + }); + + describe("(As Admin) Edit User", () => { + people.editUser(driverAdmin, CAMPAIGN.admin); + }); + + describe("(As Texter) Edit User", () => { + main.editUser(driverTexter, CAMPAIGN.texter); + }); +}); diff --git a/__test__/e2e/page-functions/campaigns.js b/__test__/e2e/page-functions/campaigns.js index 3d89249c1..15700841e 100644 --- a/__test__/e2e/page-functions/campaigns.js +++ b/__test__/e2e/page-functions/campaigns.js @@ -5,254 +5,443 @@ * Similarly, a sleep is added because it's difficult to know when the picker dialog is gone. */ -import _ from 'lodash' -import { wait, urlBuilder } from '../util/helpers' -import pom from '../page-objects/index' +import _ from "lodash"; +import { wait, urlBuilder } from "../util/helpers"; +import pom from "../page-objects/index"; // For legibility -const form = pom.campaigns.form +const form = pom.campaigns.form; export const campaigns = { startCampaign(driver, campaign) { - it('opens the Campaigns tab', async () => { - await driver.get(urlBuilder.admin.root()) - await wait.andClick(driver, pom.navigation.sections.campaigns) - }) + it("opens the Campaigns tab", async () => { + await driver.get(urlBuilder.admin.root()); + await wait.andClick(driver, pom.navigation.sections.campaigns); + }); - it('clicks the + button to add a new campaign', async () => { - await wait.andClick(driver, pom.campaigns.add, { goesStale: true }) - }) + it("clicks the + button to add a new campaign", async () => { + await wait.andClick(driver, pom.campaigns.add, { goesStale: true }); + }); - it('completes the Basics section', async () => { + it("completes the Basics section", async () => { // Title - await wait.andType(driver, form.basics.title, campaign.basics.title) + await wait.andType(driver, form.basics.title, campaign.basics.title); // Description - await wait.andType(driver, form.basics.description, campaign.basics.description) + await wait.andType( + driver, + form.basics.description, + campaign.basics.description + ); // Select a Due Date using the Date Picker - await wait.andClick(driver, form.basics.dueBy) - await wait.andClick(driver, form.datePickerDialog.nextMonth, { waitAfterVisible: 2000 }) - await wait.andClick(driver, form.datePickerDialog.enabledDate, { waitAfterVisible: 2000, goesStale: true }) + await wait.andClick(driver, form.basics.dueBy); + await wait.andClick(driver, form.datePickerDialog.nextMonth, { + waitAfterVisible: 2000 + }); + await wait.andClick(driver, form.datePickerDialog.enabledDate, { + waitAfterVisible: 2000, + goesStale: true + }); // Save - await wait.andClick(driver, form.save, { waitAfterVisible: 2000 }) + await wait.andClick(driver, form.save, { waitAfterVisible: 2000 }); // This should switch to the Contacts section - expect(await wait.andGetEl(driver, form.contacts.uploadButton)).toBeDefined() - expect(await wait.andGetEl(driver, form.contacts.input, { elementIsVisible: false })).toBeDefined() - }) + expect( + await wait.andGetEl(driver, form.contacts.uploadButton) + ).toBeDefined(); + expect( + await wait.andGetEl(driver, form.contacts.input, { + elementIsVisible: false + }) + ).toBeDefined(); + }); - it('completes the Contacts section', async () => { - await wait.andType(driver, form.contacts.input, campaign.contacts.csv, { clear: false, click: false, elementIsVisible: false }) - expect(await wait.andGetEl(driver, form.contacts.uploadedContacts)).toBeDefined() + it("completes the Contacts section", async () => { + await wait.andType(driver, form.contacts.input, campaign.contacts.csv, { + clear: false, + click: false, + elementIsVisible: false + }); + expect( + await wait.andGetEl(driver, form.contacts.uploadedContacts) + ).toBeDefined(); // Save - await wait.andClick(driver, form.save, { waitAfterVisible: 2000 }) + await wait.andClick(driver, form.save, { waitAfterVisible: 2000 }); // Reload the Contacts section to validate Contacts - await wait.andClick(driver, form.contacts.section, { waitAfterVisible: 2000 }) - expect(await wait.andGetEl(driver, form.contacts.uploadedContacts)).toBeDefined() - expect(await wait.andGetEl(driver, form.contacts.uploadedContactsByQty(campaign.texters.contactLength))).toBeDefined() - await wait.andClick(driver, form.texters.section, { waitAfterVisible: 2000 }) + await wait.andClick(driver, form.contacts.section, { + waitAfterVisible: 2000 + }); + expect( + await wait.andGetEl(driver, form.contacts.uploadedContacts) + ).toBeDefined(); + expect( + await wait.andGetEl( + driver, + form.contacts.uploadedContactsByQty(campaign.texters.contactLength) + ) + ).toBeDefined(); + await wait.andClick(driver, form.texters.section, { + waitAfterVisible: 2000 + }); // This should switch to the Texters section - expect(await wait.andGetEl(driver, form.texters.addAll)).toBeDefined() - }) + expect(await wait.andGetEl(driver, form.texters.addAll)).toBeDefined(); + }); - it('completes the Texters section', async () => { + it("completes the Texters section", async () => { if (campaign.existingTexter) { // Add All - await wait.andClick(driver, form.texters.addAll) + await wait.andClick(driver, form.texters.addAll); // Assign (Split) - await wait.andClick(driver, form.texters.autoSplit, { elementIsVisible: false }) + await wait.andClick(driver, form.texters.autoSplit, { + elementIsVisible: false + }); // Validate Assignment - const assignedToFirstTexter = await wait.andGetValue(driver, form.texters.texterAssignmentByIndex(0)) - expect(Number(assignedToFirstTexter)).toBeGreaterThan(0) + const assignedToFirstTexter = await wait.andGetValue( + driver, + form.texters.texterAssignmentByIndex(0) + ); + expect(Number(assignedToFirstTexter)).toBeGreaterThan(0); // Assign (All to Texter) - await wait.andClick(driver, form.texters.autoSplit, { elementIsVisible: false }) - await wait.andType(driver, form.texters.texterAssignmentByText(campaign.admin.given_name), '0') - await driver.sleep(1000) - await wait.andType(driver, form.texters.texterAssignmentByText(campaign.texter.given_name), campaign.texters.contactLength) + await wait.andClick(driver, form.texters.autoSplit, { + elementIsVisible: false + }); + await wait.andType( + driver, + form.texters.texterAssignmentByText(campaign.admin.given_name), + "0" + ); + await driver.sleep(1000); + await wait.andType( + driver, + form.texters.texterAssignmentByText(campaign.texter.given_name), + campaign.texters.contactLength + ); // Validate Assignment - expect(await wait.andGetValue(driver, form.texters.texterAssignmentByText(campaign.admin.given_name))).toBe('0') + expect( + await wait.andGetValue( + driver, + form.texters.texterAssignmentByText(campaign.admin.given_name) + ) + ).toBe("0"); } else { // Dynamically Assign - await wait.andClick(driver, form.texters.useDynamicAssignment, { elementIsVisible: false, waitAfterVisible: 2000 }) + await wait.andClick(driver, form.texters.useDynamicAssignment, { + elementIsVisible: false, + waitAfterVisible: 2000 + }); // Store the invite (join) URL into a global for future use. - global.e2e.joinUrl = await wait.andGetValue(driver, form.texters.joinUrl) + global.e2e.joinUrl = await wait.andGetValue( + driver, + form.texters.joinUrl + ); } // Save - await wait.andClick(driver, form.save) + await wait.andClick(driver, form.save); // This should switch to the Interactions section - expect(await wait.andGetEl(driver, form.interactions.editorLaunch)).toBeDefined() - }) + expect( + await wait.andGetEl(driver, form.interactions.editorLaunch) + ).toBeDefined(); + }); - describe('completes the Interactions section', () => { - it('adds an initial question', async () => { + describe("completes the Interactions section", () => { + it("adds an initial question", async () => { // Script - await wait.andClick(driver, form.interactions.editorLaunch) - await wait.andType(driver, pom.scriptEditor.editor, campaign.interaction.script, { clear: false, click: false, waitAfterVisible: 2000 }) - await wait.andClick(driver, pom.scriptEditor.done, { goesStale: true }) + await wait.andClick(driver, form.interactions.editorLaunch); + await wait.andType( + driver, + pom.scriptEditor.editor, + campaign.interaction.script, + { clear: false, click: false, waitAfterVisible: 2000 } + ); + await wait.andClick(driver, pom.scriptEditor.done, { goesStale: true }); // Question - await wait.andType(driver, form.interactions.questionText, campaign.interaction.question, { waitAfterVisible: 2000 }) + await wait.andType( + driver, + form.interactions.questionText, + campaign.interaction.question, + { waitAfterVisible: 2000 } + ); // Save with No Answers Defined - await wait.andClick(driver, form.interactions.submit) - await wait.andClick(driver, form.interactions.section, { waitAfterVisible: 2000 }) - let allChildInteractions = await driver.findElements(form.interactions.childInteraction) - expect(allChildInteractions.length).toBe(0) + await wait.andClick(driver, form.interactions.submit); + await wait.andClick(driver, form.interactions.section, { + waitAfterVisible: 2000 + }); + let allChildInteractions = await driver.findElements( + form.interactions.childInteraction + ); + expect(allChildInteractions.length).toBe(0); // Save with Empty Answer - await wait.andClick(driver, form.interactions.addResponse) - await wait.andClick(driver, form.interactions.submit) - await wait.andClick(driver, form.interactions.section, { waitAfterVisible: 2000 }) - allChildInteractions = await driver.findElements(form.interactions.childInteraction) - expect(allChildInteractions.length).toBe(1) - }) + await wait.andClick(driver, form.interactions.addResponse); + await wait.andClick(driver, form.interactions.submit); + await wait.andClick(driver, form.interactions.section, { + waitAfterVisible: 2000 + }); + allChildInteractions = await driver.findElements( + form.interactions.childInteraction + ); + expect(allChildInteractions.length).toBe(1); + }); - describe('Add all Responses', () => { + describe("Add all Responses", () => { _.each(campaign.interaction.answers, (answer, index) => { it(`Adds Answer ${index}`, async () => { - if (index > 0) await wait.andClick(driver, form.interactions.addResponse) // The first (0th) response reuses the empty Answer created above + if (index > 0) + await wait.andClick(driver, form.interactions.addResponse); // The first (0th) response reuses the empty Answer created above // Answer - await wait.andType(driver, form.interactions.answerOptionChildByIndex(index), answer.answerOption, { clear: false, waitAfterVisible: 2000 }) + await wait.andType( + driver, + form.interactions.answerOptionChildByIndex(index), + answer.answerOption, + { clear: false, waitAfterVisible: 2000 } + ); // Answer Script - await wait.andClick(driver, form.interactions.editorLaunchChildByIndex(index)) - await wait.andType(driver, pom.scriptEditor.editor, answer.script, { clear: false, click: false, waitAfterVisible: 2000 }) - await wait.andClick(driver, pom.scriptEditor.done, { goesStale: true }) + await wait.andClick( + driver, + form.interactions.editorLaunchChildByIndex(index) + ); + await wait.andType(driver, pom.scriptEditor.editor, answer.script, { + clear: false, + click: false, + waitAfterVisible: 2000 + }); + await wait.andClick(driver, pom.scriptEditor.done, { + goesStale: true + }); // Answer - Next Question - await wait.andType(driver, form.interactions.questionTextChildByIndex(index), answer.questionText, { clear: false, waitAfterVisible: 2000 }) - }) - }) - it('validates that all responses were added', async () => { - const allChildInteractions = await driver.findElements(form.interactions.childInteraction) - expect(allChildInteractions.length).toBe(campaign.interaction.answers.length) - }) - }) + await wait.andType( + driver, + form.interactions.questionTextChildByIndex(index), + answer.questionText, + { clear: false, waitAfterVisible: 2000 } + ); + }); + }); + it("validates that all responses were added", async () => { + const allChildInteractions = await driver.findElements( + form.interactions.childInteraction + ); + expect(allChildInteractions.length).toBe( + campaign.interaction.answers.length + ); + }); + }); - it('saves for the last time', async () => { + it("saves for the last time", async () => { // Save - await wait.andClick(driver, form.interactions.submit) + await wait.andClick(driver, form.interactions.submit); // This should switch to the Canned Responses section - expect(await wait.andGetEl(driver, form.cannedResponse.addNew)).toBeDefined() - }) - }) + expect( + await wait.andGetEl(driver, form.cannedResponse.addNew) + ).toBeDefined(); + }); + }); - it('completes the Canned Responses section', async () => { + it("completes the Canned Responses section", async () => { // Add New - await wait.andClick(driver, form.cannedResponse.addNew) + await wait.andClick(driver, form.cannedResponse.addNew); // Title - await wait.andType(driver, form.cannedResponse.title, campaign.cannedResponses[0].title) + await wait.andType( + driver, + form.cannedResponse.title, + campaign.cannedResponses[0].title + ); // Script - await wait.andClick(driver, form.cannedResponse.editorLaunch) - await wait.andType(driver, pom.scriptEditor.editor, campaign.cannedResponses[0].script, { clear: false, click: false, waitAfterVisible: 2000 }) - await wait.andClick(driver, pom.scriptEditor.done, { goesStale: true }) + await wait.andClick(driver, form.cannedResponse.editorLaunch); + await wait.andType( + driver, + pom.scriptEditor.editor, + campaign.cannedResponses[0].script, + { clear: false, click: false, waitAfterVisible: 2000 } + ); + await wait.andClick(driver, pom.scriptEditor.done, { goesStale: true }); // Script - Relaunch and cancel (bug?) - await wait.andClick(driver, form.cannedResponse.editorLaunch, { waitAfterVisible: 2000 }) - await wait.andClick(driver, pom.scriptEditor.cancel, { waitAfterVisible: 2000, goesStale: true }) + await wait.andClick(driver, form.cannedResponse.editorLaunch, { + waitAfterVisible: 2000 + }); + await wait.andClick(driver, pom.scriptEditor.cancel, { + waitAfterVisible: 2000, + goesStale: true + }); // Submit Response - await wait.andClick(driver, form.cannedResponse.submit, { waitAfterVisible: 2000, goesStale: true }) + await wait.andClick(driver, form.cannedResponse.submit, { + waitAfterVisible: 2000, + goesStale: true + }); // Save - await wait.andClick(driver, form.save, { waitAfterVisible: 2000, goesStale: true }) + await wait.andClick(driver, form.save, { + waitAfterVisible: 2000, + goesStale: true + }); // Should be able to start campaign - expect(await wait.andIsEnabled(driver, pom.campaigns.start)).toBeTruthy() - }) + expect(await wait.andIsEnabled(driver, pom.campaigns.start)).toBeTruthy(); + }); - it('clicks Start Campaign', async () => { + it("clicks Start Campaign", async () => { // Store the new campaign URL into a global for future use. - global.e2e.newCampaignUrl = await driver.getCurrentUrl() - await wait.andClick(driver, pom.campaigns.start, { waitAfterVisible: 2000, goesStale: true }) + global.e2e.newCampaignUrl = await driver.getCurrentUrl(); + await wait.andClick(driver, pom.campaigns.start, { + waitAfterVisible: 2000, + goesStale: true + }); // Validate Started - expect(await wait.andGetEl(driver, pom.campaigns.isStarted)).toBeTruthy() - }) + expect(await wait.andGetEl(driver, pom.campaigns.isStarted)).toBeTruthy(); + }); }, copyCampaign(driver, campaign) { - it('opens the Campaigns tab', async () => { - await driver.get(urlBuilder.admin.root()) - await wait.andClick(driver, pom.navigation.sections.campaigns) - }) + it("opens the Campaigns tab", async () => { + await driver.get(urlBuilder.admin.root()); + await wait.andClick(driver, pom.navigation.sections.campaigns); + }); - it('clicks on an existing campaign', async () => { - await wait.andClick(driver, pom.campaigns.campaignRowByText(campaign.basics.title), { goesStale: true }) - }) + it("clicks on an existing campaign", async () => { + await wait.andClick( + driver, + pom.campaigns.campaignRowByText(campaign.basics.title), + { goesStale: true } + ); + }); - it('clicks Copy in Stats', async () => { - await wait.andClick(driver, pom.campaigns.stats.copy, { waitAfterVisible: 2000 }) - }) + it("clicks Copy in Stats", async () => { + await wait.andClick(driver, pom.campaigns.stats.copy, { + waitAfterVisible: 2000 + }); + }); - it('verifies copy in Campaigns list', async () => { - await wait.andClick(driver, pom.navigation.sections.campaigns) - expect(await wait.andGetEl(driver, pom.campaigns.campaignRowByText('COPY'))).toBeDefined() + it("verifies copy in Campaigns list", async () => { + await wait.andClick(driver, pom.navigation.sections.campaigns); + expect( + await wait.andGetEl(driver, pom.campaigns.campaignRowByText("COPY")) + ).toBeDefined(); // expect(await wait.andGetEl(driver, pom.campaigns.warningIcon)).toBeDefined() - await wait.andClick(driver, pom.campaigns.campaignRowByText('COPY')) - }) + await wait.andClick(driver, pom.campaigns.campaignRowByText("COPY")); + }); - describe('verifies Campaign sections', () => { - it('verifies Basics section', async () => { - await wait.andClick(driver, form.basics.section) - expect(await wait.andGetValue(driver, form.basics.title)).toBe(campaign.basics.title_copied) - expect(await wait.andGetValue(driver, form.basics.description)).toBe(campaign.basics.description) - expect(await wait.andGetValue(driver, form.basics.dueBy)).toBe('') - }) - it('verifies Contacts section', async () => { - await wait.andClick(driver, form.contacts.section) - const uploadedContacts = await driver.findElements(form.contacts.uploadedContacts) - expect(uploadedContacts.length > 0).toBeFalsy() - }) - it('verifies Texters section', async () => { - await wait.andClick(driver, form.texters.section) - const assignedContacts = await driver.findElements(form.texters.texterAssignmentByText(campaign.texter.given_name)) - expect(assignedContacts.length > 0).toBeFalsy() - }) - it('verifies Interactions section', async () => { - await wait.andClick(driver, form.interactions.section) - expect(await wait.andGetValue(driver, form.interactions.editorLaunch)).toBe(campaign.interaction.script) - expect(await wait.andGetValue(driver, form.interactions.questionText)).toBe(campaign.interaction.question) + describe("verifies Campaign sections", () => { + it("verifies Basics section", async () => { + await wait.andClick(driver, form.basics.section); + expect(await wait.andGetValue(driver, form.basics.title)).toBe( + campaign.basics.title_copied + ); + expect(await wait.andGetValue(driver, form.basics.description)).toBe( + campaign.basics.description + ); + expect(await wait.andGetValue(driver, form.basics.dueBy)).toBe(""); + }); + it("verifies Contacts section", async () => { + await wait.andClick(driver, form.contacts.section); + const uploadedContacts = await driver.findElements( + form.contacts.uploadedContacts + ); + expect(uploadedContacts.length > 0).toBeFalsy(); + }); + it("verifies Texters section", async () => { + await wait.andClick(driver, form.texters.section); + const assignedContacts = await driver.findElements( + form.texters.texterAssignmentByText(campaign.texter.given_name) + ); + expect(assignedContacts.length > 0).toBeFalsy(); + }); + it("verifies Interactions section", async () => { + await wait.andClick(driver, form.interactions.section); + expect( + await wait.andGetValue(driver, form.interactions.editorLaunch) + ).toBe(campaign.interaction.script); + expect( + await wait.andGetValue(driver, form.interactions.questionText) + ).toBe(campaign.interaction.question); // Verify Answers - const allChildInteractions = await driver.findElements(form.interactions.childInteraction) - expect(allChildInteractions.length).toBe(campaign.interaction.answers.length) - }) - it('verifies Canned Responses section', async () => { - await wait.andClick(driver, form.cannedResponse.section) - expect(await wait.andGetEl(driver, form.cannedResponse.createdResponseByText(campaign.cannedResponses[0].title))).toBeDefined() - expect(await wait.andGetEl(driver, form.cannedResponse.createdResponseByText(campaign.cannedResponses[0].script))).toBeDefined() - }) - }) + const allChildInteractions = await driver.findElements( + form.interactions.childInteraction + ); + expect(allChildInteractions.length).toBe( + campaign.interaction.answers.length + ); + }); + it("verifies Canned Responses section", async () => { + await wait.andClick(driver, form.cannedResponse.section); + expect( + await wait.andGetEl( + driver, + form.cannedResponse.createdResponseByText( + campaign.cannedResponses[0].title + ) + ) + ).toBeDefined(); + expect( + await wait.andGetEl( + driver, + form.cannedResponse.createdResponseByText( + campaign.cannedResponses[0].script + ) + ) + ).toBeDefined(); + }); + }); }, editCampaign(driver, campaign) { - it('opens the Campaigns tab', async () => { - await driver.get(urlBuilder.admin.root()) - await wait.andClick(driver, pom.navigation.sections.campaigns) - }) + it("opens the Campaigns tab", async () => { + await driver.get(urlBuilder.admin.root()); + await wait.andClick(driver, pom.navigation.sections.campaigns); + }); - it('clicks on an existing campaign', async () => { - await wait.andClick(driver, pom.campaigns.campaignRowByText(campaign.basics.title), { goesStale: true }) - }) + it("clicks on an existing campaign", async () => { + await wait.andClick( + driver, + pom.campaigns.campaignRowByText(campaign.basics.title), + { goesStale: true } + ); + }); - it('clicks edit in Stats', async () => { - await wait.andClick(driver, pom.campaigns.stats.edit, { waitAfterVisible: 2000, goesStale: true }) - }) + it("clicks edit in Stats", async () => { + await wait.andClick(driver, pom.campaigns.stats.edit, { + waitAfterVisible: 2000, + goesStale: true + }); + }); - it('changes the title in the Basics section', async () => { + it("changes the title in the Basics section", async () => { // Expand Basics section - await wait.andClick(driver, form.basics.section) + await wait.andClick(driver, form.basics.section); // Change Title - await wait.andType(driver, form.basics.title, campaign.basics.title_changed, { clear: false }) + await wait.andType( + driver, + form.basics.title, + campaign.basics.title_changed, + { clear: false } + ); // Save - await wait.andClick(driver, form.save) - }) + await wait.andClick(driver, form.save); + }); - it('reopens the Basics section to verify title', async () => { + it("reopens the Basics section to verify title", async () => { // Expand Basics section - await wait.andClick(driver, form.basics.section, { waitAfterVisible: 2000 }) + await wait.andClick(driver, form.basics.section, { + waitAfterVisible: 2000 + }); // Verify Title - expect(await wait.andGetValue(driver, form.basics.title)).toBe(campaign.basics.title_changed) - }) + expect(await wait.andGetValue(driver, form.basics.title)).toBe( + campaign.basics.title_changed + ); + }); }, sendReplies(driver, campaign) { - it('sends Replies', async () => { - const sendRepliesUrl = global.e2e.newCampaignUrl.substring(0, global.e2e.newCampaignUrl.indexOf('edit?new=true')) + 'send-replies' - await driver.get(sendRepliesUrl) - }) - describe('simulates the assigned contacts sending replies', () => { + it("sends Replies", async () => { + const sendRepliesUrl = + global.e2e.newCampaignUrl.substring( + 0, + global.e2e.newCampaignUrl.indexOf("edit?new=true") + ) + "send-replies"; + await driver.get(sendRepliesUrl); + }); + describe("simulates the assigned contacts sending replies", () => { _.times(campaign.texters.contactLength, n => { it(`sends reply ${n}`, async () => { - await wait.andType(driver, pom.campaigns.replyByIndex(n), campaign.standardReply) - await wait.andClick(driver, pom.campaigns.sendByIndex(n)) - }) - }) - }) + await wait.andType( + driver, + pom.campaigns.replyByIndex(n), + campaign.standardReply + ); + await wait.andClick(driver, pom.campaigns.sendByIndex(n)); + }); + }); + }); } -} +}; diff --git a/__test__/e2e/page-functions/index.js b/__test__/e2e/page-functions/index.js index 907884d59..37511f521 100644 --- a/__test__/e2e/page-functions/index.js +++ b/__test__/e2e/page-functions/index.js @@ -1,5 +1,5 @@ -export * from './campaigns' -export * from './login' -export * from './main' -export * from './people' -export * from './texter' +export * from "./campaigns"; +export * from "./login"; +export * from "./main"; +export * from "./people"; +export * from "./texter"; diff --git a/__test__/e2e/page-functions/login.js b/__test__/e2e/page-functions/login.js index 4875b759c..4880cfa7f 100644 --- a/__test__/e2e/page-functions/login.js +++ b/__test__/e2e/page-functions/login.js @@ -1,90 +1,95 @@ -import { until } from 'selenium-webdriver' -import config from '../util/config' -import { wait, urlBuilder } from '../util/helpers' -import pom from '../page-objects/index' +import { until } from "selenium-webdriver"; +import config from "../util/config"; +import { wait, urlBuilder } from "../util/helpers"; +import pom from "../page-objects/index"; // For legibility -const auth0 = pom.login.auth0 +const auth0 = pom.login.auth0; export const login = { landing(driver) { - it('gets the landing page', async () => { - await driver.get(config.baseUrl) - }) + it("gets the landing page", async () => { + await driver.get(config.baseUrl); + }); - it('clicks the login link', async () => { + it("clicks the login link", async () => { // Click on the login button - wait.andClick(driver, pom.login.loginGetStarted, { msWait: 50000, waitAfterVisible: 2000 }) + wait.andClick(driver, pom.login.loginGetStarted, { + msWait: 50000, + waitAfterVisible: 2000 + }); // Wait until the Auth0 login page loads - await driver.wait(until.urlContains(urlBuilder.login)) - }) + await driver.wait(until.urlContains(urlBuilder.login)); + }); }, signUpTab(driver, user) { - let skip = false // Assume that these tests will proceed - it('opens the Sign Up tab', async () => { - skip = !!global.e2e[user.name].loginSucceeded // Skip tests if the login succeeded + let skip = false; // Assume that these tests will proceed + it("opens the Sign Up tab", async () => { + skip = !!global.e2e[user.name].loginSucceeded; // Skip tests if the login succeeded if (!skip) { - wait.andClick(driver, auth0.tabs.signIn, { msWait: 20000 }) + wait.andClick(driver, auth0.tabs.signIn, { msWait: 20000 }); } - }) + }); - it('fills in the new user details', async () => { + it("fills in the new user details", async () => { if (!skip) { - await driver.sleep(3000) // Allow time for the client to populate the email - await wait.andType(driver, auth0.form.email, user.email) - await wait.andType(driver, auth0.form.password, user.password) - await wait.andType(driver, auth0.form.given_name, user.given_name) - await wait.andType(driver, auth0.form.family_name, user.family_name) - await wait.andType(driver, auth0.form.cell, user.cell) + await driver.sleep(3000); // Allow time for the client to populate the email + await wait.andType(driver, auth0.form.email, user.email); + await wait.andType(driver, auth0.form.password, user.password); + await wait.andType(driver, auth0.form.given_name, user.given_name); + await wait.andType(driver, auth0.form.family_name, user.family_name); + await wait.andType(driver, auth0.form.cell, user.cell); } - }) + }); - it('accepts the user agreement', async () => { - if (!skip) await wait.andClick(driver, auth0.form.agreement) - }) + it("accepts the user agreement", async () => { + if (!skip) await wait.andClick(driver, auth0.form.agreement); + }); - it('clicks the submit button', async () => { - if (!skip) await wait.andClick(driver, auth0.form.submit) - }) + it("clicks the submit button", async () => { + if (!skip) await wait.andClick(driver, auth0.form.submit); + }); - it('authorizes Auth0 to access tenant', async () => { - if (!skip) await wait.andClick(driver, auth0.authorize.allow) - }) + it("authorizes Auth0 to access tenant", async () => { + if (!skip) await wait.andClick(driver, auth0.authorize.allow); + }); }, signUp(driver, user) { - this.landing(driver, user) - this.signUpTab(driver, user) + this.landing(driver, user); + this.signUpTab(driver, user); }, logIn(driver, user) { - it('opens the Log In tab', async () => { - await wait.andClick(driver, auth0.tabs.logIn, { msWait: 50000 }) - }) + it("opens the Log In tab", async () => { + await wait.andClick(driver, auth0.tabs.logIn, { msWait: 50000 }); + }); - it('fills in the existing user details', async () => { - await wait.andType(driver, auth0.form.email, user.email) - await wait.andType(driver, auth0.form.password, user.password) - }) + it("fills in the existing user details", async () => { + await wait.andType(driver, auth0.form.email, user.email); + await wait.andType(driver, auth0.form.password, user.password); + }); - it('clicks the submit button', async () => { - await wait.andClick(driver, auth0.form.submit, { waitAfterVisible: 1000 }) - }) + it("clicks the submit button", async () => { + await wait.andClick(driver, auth0.form.submit, { + waitAfterVisible: 1000 + }); + }); }, tryLoginThenSignUp(driver, user) { - this.logIn(driver, user) - it('looks for an error', async () => { - global.e2e[user.name] = {} // Set a global object for the user - await driver.sleep(5000) // Wait for login attempt to return. Takes about 1 sec - const errors = await driver.findElements(auth0.form.error) - global.e2e[user.name].loginSucceeded = errors.length === 0 - }) - describe('Sign Up if Login Fails', () => { + this.logIn(driver, user); + it("looks for an error", async () => { + global.e2e[user.name] = {}; // Set a global object for the user + await driver.sleep(5000); // Wait for login attempt to return. Takes about 1 sec + const errors = await driver.findElements(auth0.form.error); + global.e2e[user.name].loginSucceeded = errors.length === 0; + }); + describe("Sign Up if Login Fails", () => { /** * Note * This always runs, as the test suite is defined before any tests run * However, all tests will skip if global.e2e[user.name].loginSucceeded */ - this.signUpTab(driver, user) - }) + this.signUpTab(driver, user); + }); } -} +}; diff --git a/__test__/e2e/page-functions/main.js b/__test__/e2e/page-functions/main.js index e0fdae627..1409e07bf 100644 --- a/__test__/e2e/page-functions/main.js +++ b/__test__/e2e/page-functions/main.js @@ -1,70 +1,104 @@ -import { until } from 'selenium-webdriver' -import { wait } from '../util/helpers' -import config from '../util/config' -import pom from '../page-objects/index' +import { until } from "selenium-webdriver"; +import { wait } from "../util/helpers"; +import config from "../util/config"; +import pom from "../page-objects/index"; export const main = { createOrg(driver, name) { - it('fills in the organization name', async () => { - await wait.andType(driver, pom.main.organization.name, name) - }) + it("fills in the organization name", async () => { + await wait.andType(driver, pom.main.organization.name, name); + }); - it('clicks the submit button', async () => { - await wait.andClick(driver, pom.main.organization.submit) - await driver.wait(until.urlContains('admin')) - const url = await driver.getCurrentUrl() - const re = /\/admin\/(\d+)\//g - global.e2e.organization = await re.exec(url)[1] - }) + it("clicks the submit button", async () => { + await wait.andClick(driver, pom.main.organization.submit); + await driver.wait(until.urlContains("admin")); + const url = await driver.getCurrentUrl(); + const re = /\/admin\/(\d+)\//g; + global.e2e.organization = await re.exec(url)[1]; + }); }, editUser(driver, user) { - it('opens the User menu', async () => { - await wait.andClick(driver, pom.main.userMenuButton) - }) + it("opens the User menu", async () => { + await wait.andClick(driver, pom.main.userMenuButton); + }); - it('click on the user name', async () => { - await wait.andClick(driver, pom.main.userMenuDisplayName) - }) + it("click on the user name", async () => { + await wait.andClick(driver, pom.main.userMenuDisplayName); + }); - it('changes user details', async () => { - await wait.andType(driver, pom.people.edit.firstName, user.given_name_changed, { clear: false }) - await wait.andType(driver, pom.people.edit.lastName, user.family_name_changed, { clear: false }) - await wait.andType(driver, pom.people.edit.email, user.email_changed, { clear: false }) - await wait.andType(driver, pom.people.edit.cell, user.cell_changed, { clear: false }) + it("changes user details", async () => { + await wait.andType( + driver, + pom.people.edit.firstName, + user.given_name_changed, + { clear: false } + ); + await wait.andType( + driver, + pom.people.edit.lastName, + user.family_name_changed, + { clear: false } + ); + await wait.andType(driver, pom.people.edit.email, user.email_changed, { + clear: false + }); + await wait.andType(driver, pom.people.edit.cell, user.cell_changed, { + clear: false + }); // Save - await wait.andClick(driver, pom.people.edit.save) + await wait.andClick(driver, pom.people.edit.save); // Verify edits - expect(await wait.andGetValue(driver, pom.people.edit.firstName)).toBe(user.given_name_changed) - expect(await wait.andGetValue(driver, pom.people.edit.lastName)).toBe(user.family_name_changed) - expect(await wait.andGetValue(driver, pom.people.edit.email)).toBe(user.email_changed) - }) + expect(await wait.andGetValue(driver, pom.people.edit.firstName)).toBe( + user.given_name_changed + ); + expect(await wait.andGetValue(driver, pom.people.edit.lastName)).toBe( + user.family_name_changed + ); + expect(await wait.andGetValue(driver, pom.people.edit.email)).toBe( + user.email_changed + ); + }); - it('reverts user details back to original settings', async () => { - await wait.andType(driver, pom.people.edit.firstName, user.given_name, { clear: false }) - await wait.andType(driver, pom.people.edit.lastName, user.family_name, { clear: false }) - await wait.andType(driver, pom.people.edit.email, user.email, { clear: false }) - await wait.andType(driver, pom.people.edit.cell, user.cell, { clear: false }) + it("reverts user details back to original settings", async () => { + await wait.andType(driver, pom.people.edit.firstName, user.given_name, { + clear: false + }); + await wait.andType(driver, pom.people.edit.lastName, user.family_name, { + clear: false + }); + await wait.andType(driver, pom.people.edit.email, user.email, { + clear: false + }); + await wait.andType(driver, pom.people.edit.cell, user.cell, { + clear: false + }); // Save - await wait.andClick(driver, pom.people.edit.save) + await wait.andClick(driver, pom.people.edit.save); // Verify edits - expect(await wait.andGetValue(driver, pom.people.edit.firstName)).toBe(user.given_name) - expect(await wait.andGetValue(driver, pom.people.edit.lastName)).toBe(user.family_name) - expect(await wait.andGetValue(driver, pom.people.edit.email)).toBe(user.email) - }) + expect(await wait.andGetValue(driver, pom.people.edit.firstName)).toBe( + user.given_name + ); + expect(await wait.andGetValue(driver, pom.people.edit.lastName)).toBe( + user.family_name + ); + expect(await wait.andGetValue(driver, pom.people.edit.email)).toBe( + user.email + ); + }); }, logOutUser(driver) { - it('gets the landing page', async () => { - await driver.get(config.baseUrl) - }) + it("gets the landing page", async () => { + await driver.get(config.baseUrl); + }); - it('opens the User menu', async () => { - await wait.andClick(driver, pom.main.userMenuButton) - }) + it("opens the User menu", async () => { + await wait.andClick(driver, pom.main.userMenuButton); + }); - it('clicks on log out', async () => { - await wait.andClick(driver, pom.main.logOut, { waitAfterVisible: 3000 }) - const re = /http[s]*:\/\/[^\/]+[\/]*$/g - await driver.wait(until.urlMatches(re)) - }) + it("clicks on log out", async () => { + await wait.andClick(driver, pom.main.logOut, { waitAfterVisible: 3000 }); + const re = /http[s]*:\/\/[^\/]+[\/]*$/g; + await driver.wait(until.urlMatches(re)); + }); } -} +}; diff --git a/__test__/e2e/page-functions/people.js b/__test__/e2e/page-functions/people.js index f58b93c1e..2deda6d59 100644 --- a/__test__/e2e/page-functions/people.js +++ b/__test__/e2e/page-functions/people.js @@ -1,58 +1,99 @@ -import { wait, urlBuilder } from '../util/helpers' -import pom from '../page-objects/index' +import { wait, urlBuilder } from "../util/helpers"; +import pom from "../page-objects/index"; export const people = { invite(driver) { - it('opens the People tab', async () => { - await driver.get(urlBuilder.admin.root()) - await wait.andClick(driver, pom.navigation.sections.people) - }) + it("opens the People tab", async () => { + await driver.get(urlBuilder.admin.root()); + await wait.andClick(driver, pom.navigation.sections.people); + }); - it('clicks on the + button to Invite a User', async () => { - await wait.andClick(driver, pom.people.add) - }) + it("clicks on the + button to Invite a User", async () => { + await wait.andClick(driver, pom.people.add); + }); - it('views the invitation link', async () => { + it("views the invitation link", async () => { // Store Invite - global.e2e.joinUrl = await wait.andGetValue(driver, pom.people.invite.joinUrl) + global.e2e.joinUrl = await wait.andGetValue( + driver, + pom.people.invite.joinUrl + ); // OK - await wait.andClick(driver, pom.people.invite.ok) - }) + await wait.andClick(driver, pom.people.invite.ok); + }); }, editUser(driver, user) { - it('opens the People tab', async () => { - await driver.get(urlBuilder.admin.root()) - await wait.andClick(driver, pom.navigation.sections.people) - }) + it("opens the People tab", async () => { + await driver.get(urlBuilder.admin.root()); + await wait.andClick(driver, pom.navigation.sections.people); + }); - it('clicks on the Edit button next to name', async () => { - await wait.andClick(driver, pom.people.editButtonByName(user.given_name), { waitAfterVisible: 2000 }) - }) + it("clicks on the Edit button next to name", async () => { + await wait.andClick( + driver, + pom.people.editButtonByName(user.given_name), + { waitAfterVisible: 2000 } + ); + }); - it('changes user details', async () => { - await wait.andType(driver, pom.people.edit.firstName, user.given_name_changed, { clear: false, waitAfterVisible: 2000 }) - await wait.andType(driver, pom.people.edit.lastName, user.family_name_changed, { clear: false }) - await wait.andType(driver, pom.people.edit.email, user.email_changed, { clear: false }) - await wait.andType(driver, pom.people.edit.cell, user.cell_changed, { clear: false }) + it("changes user details", async () => { + await wait.andType( + driver, + pom.people.edit.firstName, + user.given_name_changed, + { clear: false, waitAfterVisible: 2000 } + ); + await wait.andType( + driver, + pom.people.edit.lastName, + user.family_name_changed, + { clear: false } + ); + await wait.andType(driver, pom.people.edit.email, user.email_changed, { + clear: false + }); + await wait.andType(driver, pom.people.edit.cell, user.cell_changed, { + clear: false + }); // Save - await wait.andClick(driver, pom.people.edit.save) + await wait.andClick(driver, pom.people.edit.save); // Verify edits - expect(await wait.andGetEl(driver, pom.people.getRowByName(user.given_name_changed))).toBeDefined() - }) + expect( + await wait.andGetEl( + driver, + pom.people.getRowByName(user.given_name_changed) + ) + ).toBeDefined(); + }); - it('clicks on the Edit button next to name', async () => { - await wait.andClick(driver, pom.people.editButtonByName(user.given_name), { waitAfterVisible: 2000 }) - }) + it("clicks on the Edit button next to name", async () => { + await wait.andClick( + driver, + pom.people.editButtonByName(user.given_name), + { waitAfterVisible: 2000 } + ); + }); - it('reverts user details back to original settings', async () => { - await wait.andType(driver, pom.people.edit.firstName, user.given_name, { clear: false, waitAfterVisible: 2000 }) - await wait.andType(driver, pom.people.edit.lastName, user.family_name, { clear: false }) - await wait.andType(driver, pom.people.edit.email, user.email, { clear: false }) - await wait.andType(driver, pom.people.edit.cell, user.cell, { clear: false }) + it("reverts user details back to original settings", async () => { + await wait.andType(driver, pom.people.edit.firstName, user.given_name, { + clear: false, + waitAfterVisible: 2000 + }); + await wait.andType(driver, pom.people.edit.lastName, user.family_name, { + clear: false + }); + await wait.andType(driver, pom.people.edit.email, user.email, { + clear: false + }); + await wait.andType(driver, pom.people.edit.cell, user.cell, { + clear: false + }); // Save - await wait.andClick(driver, pom.people.edit.save) + await wait.andClick(driver, pom.people.edit.save); // Verify edits - expect(await wait.andGetEl(driver, pom.people.getRowByName(user.given_name))).toBeDefined() - }) + expect( + await wait.andGetEl(driver, pom.people.getRowByName(user.given_name)) + ).toBeDefined(); + }); } -} +}; diff --git a/__test__/e2e/page-functions/texter.js b/__test__/e2e/page-functions/texter.js index dd60c9f9e..bee441b62 100644 --- a/__test__/e2e/page-functions/texter.js +++ b/__test__/e2e/page-functions/texter.js @@ -1,52 +1,59 @@ -import _ from 'lodash' -import { wait, urlBuilder } from '../util/helpers' -import pom from '../page-objects/index' +import _ from "lodash"; +import { wait, urlBuilder } from "../util/helpers"; +import pom from "../page-objects/index"; export const texter = { sendTexts(driver, campaign) { - it('refreshes Dashboard', async () => { - await driver.get(urlBuilder.app.todos()) - await wait.andClick(driver, pom.texter.sendFirstTexts) - }) - describe('works though the list of assigned contacts', () => { + it("refreshes Dashboard", async () => { + await driver.get(urlBuilder.app.todos()); + await wait.andClick(driver, pom.texter.sendFirstTexts); + }); + describe("works though the list of assigned contacts", () => { _.times(campaign.texters.contactLength, n => { it(`sends text ${n}`, async () => { - await wait.andClick(driver, pom.texter.send) - }) - }) - it('should have an empty todo list', async () => { - await driver.get(urlBuilder.app.todos()) - expect(await wait.andGetEl(driver, pom.texter.emptyTodo)).toBeDefined() - }) - }) + await wait.andClick(driver, pom.texter.send); + }); + }); + it("should have an empty todo list", async () => { + await driver.get(urlBuilder.app.todos()); + expect(await wait.andGetEl(driver, pom.texter.emptyTodo)).toBeDefined(); + }); + }); }, optOutContact(driver) { - it('clicks the Opt Out button', async () => { - await wait.andClick(driver, pom.texter.optOut.button) - }) - it('clicks Send', async () => { - await wait.andClick(driver, pom.texter.optOut.send) - await driver.sleep(3000) - }) + it("clicks the Opt Out button", async () => { + await wait.andClick(driver, pom.texter.optOut.button); + }); + it("clicks Send", async () => { + await wait.andClick(driver, pom.texter.optOut.send); + await driver.sleep(3000); + }); }, viewInvite(driver) { - it('follows the link to the invite', async () => { - await driver.get(global.e2e.joinUrl) - }) + it("follows the link to the invite", async () => { + await driver.get(global.e2e.joinUrl); + }); }, viewReplies(driver, campaign) { - it('refreshes Dashboard', async () => { - await driver.get(urlBuilder.app.todos()) - await wait.andClick(driver, pom.texter.sendReplies) - }) - it('verifies reply', async () => { - expect(await wait.andGetEl(driver, pom.texter.replyByText(campaign.standardReply))).toBeDefined() - }) + it("refreshes Dashboard", async () => { + await driver.get(urlBuilder.app.todos()); + await wait.andClick(driver, pom.texter.sendReplies); + }); + it("verifies reply", async () => { + expect( + await wait.andGetEl( + driver, + pom.texter.replyByText(campaign.standardReply) + ) + ).toBeDefined(); + }); }, viewSendFirstTexts(driver) { - it('verifies that Send First Texts button is present', async () => { - await driver.get(urlBuilder.app.todos()) - expect(await wait.andGetEl(driver, pom.texter.sendFirstTexts)).toBeDefined() - }) + it("verifies that Send First Texts button is present", async () => { + await driver.get(urlBuilder.app.todos()); + expect( + await wait.andGetEl(driver, pom.texter.sendFirstTexts) + ).toBeDefined(); + }); } -} +}; diff --git a/__test__/e2e/page-objects/campaigns.js b/__test__/e2e/page-objects/campaigns.js index d06264500..2292a8d6b 100644 --- a/__test__/e2e/page-objects/campaigns.js +++ b/__test__/e2e/page-objects/campaigns.js @@ -1,64 +1,108 @@ -import { By } from 'selenium-webdriver' +import { By } from "selenium-webdriver"; export const campaigns = { - add: By.css('[data-test=addCampaign]'), - start: By.css('[data-test=startCampaign]:not([disabled])'), - campaignRowByText(text) { return By.xpath(`//*[contains(text(),'${text}')]/ancestor::*[@data-test="campaignRow"]`) }, - warningIcon: By.css('[data-test=warningIcon]'), - replyByIndex(index) { return By.xpath(`(//input[@data-test='reply'])[${index + 1}]`) }, - sendByIndex(index) { return By.xpath(`(//button[@data-test='send'])[${index + 1}]`) }, + add: By.css("[data-test=addCampaign]"), + start: By.css("[data-test=startCampaign]:not([disabled])"), + campaignRowByText(text) { + return By.xpath( + `//*[contains(text(),'${text}')]/ancestor::*[@data-test="campaignRow"]` + ); + }, + warningIcon: By.css("[data-test=warningIcon]"), + replyByIndex(index) { + return By.xpath(`(//input[@data-test='reply'])[${index + 1}]`); + }, + sendByIndex(index) { + return By.xpath(`(//button[@data-test='send'])[${index + 1}]`); + }, form: { basics: { - section: By.css('[data-test=basics]'), - title: By.css('[data-test=title]'), - description: By.css('[data-test=description]'), - dueBy: By.css('[data-test=dueBy]') + section: By.css("[data-test=basics]"), + title: By.css("[data-test=title]"), + description: By.css("[data-test=description]"), + dueBy: By.css("[data-test=dueBy]") }, datePickerDialog: { // This selector is fragile and alternate means of finding an enabled date should be investigated. - nextMonth: By.css('body > div:nth-child(5) > div > div:nth-child(1) > div > div > div > div > div:nth-child(2) > div:nth-child(1) > div:nth-child(1) > button:nth-child(3)'), - enabledDate: By.css('body > div:nth-child(5) > div > div:nth-child(1) > div > div > div > div > div:nth-child(2) > div:nth-child(1) > div:nth-child(3) > div > div button[tabindex="0"]') + nextMonth: By.css( + "body > div:nth-child(5) > div > div:nth-child(1) > div > div > div > div > div:nth-child(2) > div:nth-child(1) > div:nth-child(1) > button:nth-child(3)" + ), + enabledDate: By.css( + 'body > div:nth-child(5) > div > div:nth-child(1) > div > div > div > div > div:nth-child(2) > div:nth-child(1) > div:nth-child(3) > div > div button[tabindex="0"]' + ) }, contacts: { - section: By.css('[data-test=contacts]'), - uploadButton: By.css('[data-test=uploadButton]'), - input: By.css('#contact-upload'), - uploadedContacts: By.css('[data-test=uploadedContacts]'), - uploadedContactsByQty(n) { return By.xpath(`//*[@data-test='uploadedContacts']/descendant::*[contains(text(),'${n} contact')]`) } + section: By.css("[data-test=contacts]"), + uploadButton: By.css("[data-test=uploadButton]"), + input: By.css("#contact-upload"), + uploadedContacts: By.css("[data-test=uploadedContacts]"), + uploadedContactsByQty(n) { + return By.xpath( + `//*[@data-test='uploadedContacts']/descendant::*[contains(text(),'${n} contact')]` + ); + } }, texters: { - section: By.css('[data-test=texters]'), - useDynamicAssignment: By.css('[data-test=useDynamicAssignment]'), - joinUrl: By.css('[data-test=joinUrl]'), - addAll: By.css('[data-test=addAll]'), - autoSplit: By.css('[data-test=autoSplit]'), - texterAssignmentByText(text) { return By.xpath(`//*[@data-test='texterName' and contains(text(),'${text}')]/ancestor::*[@data-test='texterRow']/descendant::input[@data-test='texterAssignment']`) }, - texterAssignmentByIndex(index) { return By.xpath(`(//*[@data-test='texterRow'])[${index + 1}]/descendant::input[@data-test='texterAssignment']`) } + section: By.css("[data-test=texters]"), + useDynamicAssignment: By.css("[data-test=useDynamicAssignment]"), + joinUrl: By.css("[data-test=joinUrl]"), + addAll: By.css("[data-test=addAll]"), + autoSplit: By.css("[data-test=autoSplit]"), + texterAssignmentByText(text) { + return By.xpath( + `//*[@data-test='texterName' and contains(text(),'${text}')]/ancestor::*[@data-test='texterRow']/descendant::input[@data-test='texterAssignment']` + ); + }, + texterAssignmentByIndex(index) { + return By.xpath( + `(//*[@data-test='texterRow'])[${index + + 1}]/descendant::input[@data-test='texterAssignment']` + ); + } }, interactions: { - section: By.css('[data-test=interactions]'), - questionText: By.css('[data-test=questionText]'), - addResponse: By.css('[data-test=addResponse]:nth-child(1)'), - childInteraction: By.css('[data-test=childInteraction]'), - questionTextChildByIndex(index) { return By.xpath(`(//*[@data-test='childInteraction']/descendant::*[@data-test='questionText'])[${index + 1}]`) }, - editorLaunch: By.css('[data-test=editorInteraction]'), - editorLaunchChildByIndex(index) { return By.xpath(`(//*[@data-test='childInteraction']/descendant::*[@data-test='editorInteraction'])[${index + 1}]`) }, - answerOptionChildByIndex(index) { return By.xpath(`(//*[@data-test='childInteraction']/descendant::*[@data-test='answerOption'])[${index + 1}]`) }, - submit: By.css('[data-test=interactionSubmit]') + section: By.css("[data-test=interactions]"), + questionText: By.css("[data-test=questionText]"), + addResponse: By.css("[data-test=addResponse]:nth-child(1)"), + childInteraction: By.css("[data-test=childInteraction]"), + questionTextChildByIndex(index) { + return By.xpath( + `(//*[@data-test='childInteraction']/descendant::*[@data-test='questionText'])[${index + + 1}]` + ); + }, + editorLaunch: By.css("[data-test=editorInteraction]"), + editorLaunchChildByIndex(index) { + return By.xpath( + `(//*[@data-test='childInteraction']/descendant::*[@data-test='editorInteraction'])[${index + + 1}]` + ); + }, + answerOptionChildByIndex(index) { + return By.xpath( + `(//*[@data-test='childInteraction']/descendant::*[@data-test='answerOption'])[${index + + 1}]` + ); + }, + submit: By.css("[data-test=interactionSubmit]") }, cannedResponse: { - section: By.css('[data-test=cannedResponses]'), - addNew: By.css('[data-test=newCannedResponse]'), - title: By.css('[data-test=title]'), - editorLaunch: By.css('[data-test=editorResponse]'), - createdResponseByText(text) { return By.xpath(`//span[@data-test='cannedResponse']/descendant::*[contains(text(),'${text}')]`) }, - submit: By.css('[data-test=addResponse]') + section: By.css("[data-test=cannedResponses]"), + addNew: By.css("[data-test=newCannedResponse]"), + title: By.css("[data-test=title]"), + editorLaunch: By.css("[data-test=editorResponse]"), + createdResponseByText(text) { + return By.xpath( + `//span[@data-test='cannedResponse']/descendant::*[contains(text(),'${text}')]` + ); + }, + submit: By.css("[data-test=addResponse]") }, - save: By.css('[type=submit]:not([disabled])') + save: By.css("[type=submit]:not([disabled])") }, stats: { - copy: By.css('[data-test=copyCampaign]'), - edit: By.css('[data-test=editCampaign]') + copy: By.css("[data-test=copyCampaign]"), + edit: By.css("[data-test=editCampaign]") }, - isStarted: By.css('[data-test=campaignIsStarted]') -} + isStarted: By.css("[data-test=campaignIsStarted]") +}; diff --git a/__test__/e2e/page-objects/index.js b/__test__/e2e/page-objects/index.js index 5e1ea4b35..d920ce90f 100644 --- a/__test__/e2e/page-objects/index.js +++ b/__test__/e2e/page-objects/index.js @@ -1,10 +1,10 @@ -import { campaigns } from './campaigns' -import { main } from './main' -import { login } from './login' -import { navigation } from './navigation' -import { people } from './people' -import { scriptEditor } from './scriptEditor' -import { texter } from './texter' +import { campaigns } from "./campaigns"; +import { main } from "./main"; +import { login } from "./login"; +import { navigation } from "./navigation"; +import { people } from "./people"; +import { scriptEditor } from "./scriptEditor"; +import { texter } from "./texter"; export default { campaigns, @@ -14,4 +14,4 @@ export default { people, scriptEditor, texter -} +}; diff --git a/__test__/e2e/page-objects/login.js b/__test__/e2e/page-objects/login.js index d9daa94fc..c46e92913 100644 --- a/__test__/e2e/page-objects/login.js +++ b/__test__/e2e/page-objects/login.js @@ -1,24 +1,26 @@ -import { By } from 'selenium-webdriver' +import { By } from "selenium-webdriver"; export const login = { auth0: { tabs: { - logIn: By.css('.auth0-lock-tabs>li:nth-child(1)'), - signIn: By.css('.auth0-lock-tabs>li:nth-child(2)') + logIn: By.css(".auth0-lock-tabs>li:nth-child(1)"), + signIn: By.css(".auth0-lock-tabs>li:nth-child(2)") }, form: { - email: By.css('div.auth0-lock-input-email > div > input'), - password: By.css('div.auth0-lock-input-password > div > input'), - given_name: By.css('div.auth0-lock-input-given_name > div > input'), - family_name: By.css('div.auth0-lock-input-family_name > div > input'), - cell: By.css('div.auth0-lock-input-cell > div > input'), - agreement: By.css('span.auth0-lock-sign-up-terms-agreement > label > input'), // Checkbox - submit: By.css('button.auth0-lock-submit'), - error: By.css('div.auth0-global-message-error') + email: By.css("div.auth0-lock-input-email > div > input"), + password: By.css("div.auth0-lock-input-password > div > input"), + given_name: By.css("div.auth0-lock-input-given_name > div > input"), + family_name: By.css("div.auth0-lock-input-family_name > div > input"), + cell: By.css("div.auth0-lock-input-cell > div > input"), + agreement: By.css( + "span.auth0-lock-sign-up-terms-agreement > label > input" + ), // Checkbox + submit: By.css("button.auth0-lock-submit"), + error: By.css("div.auth0-global-message-error") }, authorize: { - allow: By.css('#allow') + allow: By.css("#allow") } }, - loginGetStarted: By.css('#login') -} + loginGetStarted: By.css("#login") +}; diff --git a/__test__/e2e/page-objects/main.js b/__test__/e2e/page-objects/main.js index a6072aaa8..3ce7e8763 100644 --- a/__test__/e2e/page-objects/main.js +++ b/__test__/e2e/page-objects/main.js @@ -1,20 +1,20 @@ -import { By } from 'selenium-webdriver' +import { By } from "selenium-webdriver"; export const main = { organization: { - name: By.css('[data-test=organization]'), + name: By.css("[data-test=organization]"), submit: By.css('button[name="submit"]') }, - userMenuButton: By.css('[data-test=userMenuButton]'), - userMenuDisplayName: By.css('[data-test=userMenuDisplayName]'), + userMenuButton: By.css("[data-test=userMenuButton]"), + userMenuDisplayName: By.css("[data-test=userMenuDisplayName]"), edit: { - editButton: By.css('[data-test=editPerson]'), - firstName: By.css('[data-test=firstName]'), - lastName: By.css('[data-test=lastName]'), - email: By.css('[data-test=email]'), - cell: By.css('[data-test=cell]'), - save: By.css('[type=submit]') + editButton: By.css("[data-test=editPerson]"), + firstName: By.css("[data-test=firstName]"), + lastName: By.css("[data-test=lastName]"), + email: By.css("[data-test=email]"), + cell: By.css("[data-test=cell]"), + save: By.css("[type=submit]") }, - home: By.css('[data-test=home]'), - logOut: By.css('[data-test=userMenuLogOut]') -} + home: By.css("[data-test=home]"), + logOut: By.css("[data-test=userMenuLogOut]") +}; diff --git a/__test__/e2e/page-objects/navigation.js b/__test__/e2e/page-objects/navigation.js index a543c9c95..20f109139 100644 --- a/__test__/e2e/page-objects/navigation.js +++ b/__test__/e2e/page-objects/navigation.js @@ -1,12 +1,12 @@ -import { By } from 'selenium-webdriver' +import { By } from "selenium-webdriver"; export const navigation = { sections: { - campaigns: By.css('[data-test=navCampaigns]'), - people: By.css('[data-test=navPeople]'), - optouts: By.css('[data-test=navOptouts]'), - messageReview: By.css('[data-test=navIncoming]'), - settings: By.css('[data-test=navSettings]'), - switchToTexter: By.css('[data-test=navSwitchToTexter]') + campaigns: By.css("[data-test=navCampaigns]"), + people: By.css("[data-test=navPeople]"), + optouts: By.css("[data-test=navOptouts]"), + messageReview: By.css("[data-test=navIncoming]"), + settings: By.css("[data-test=navSettings]"), + switchToTexter: By.css("[data-test=navSwitchToTexter]") } -} +}; diff --git a/__test__/e2e/page-objects/people.js b/__test__/e2e/page-objects/people.js index 818f3f74a..c3f7d4478 100644 --- a/__test__/e2e/page-objects/people.js +++ b/__test__/e2e/page-objects/people.js @@ -1,19 +1,25 @@ -import { By } from 'selenium-webdriver' +import { By } from "selenium-webdriver"; export const people = { - add: By.css('[data-test=addPerson]'), + add: By.css("[data-test=addPerson]"), invite: { - joinUrl: By.css('[data-test=joinUrl]'), - ok: By.css('[data-test=inviteOk]') + joinUrl: By.css("[data-test=joinUrl]"), + ok: By.css("[data-test=inviteOk]") + }, + getRowByName(name) { + return By.xpath(`//td[contains(text(),'${name}')]/ancestor::tr`); + }, + editButtonByName(name) { + return By.xpath( + `//td[contains(text(),'${name}')]/ancestor::tr/descendant::button[@data-test='editPerson']` + ); }, - getRowByName(name) { return By.xpath(`//td[contains(text(),'${name}')]/ancestor::tr`) }, - editButtonByName(name) { return By.xpath(`//td[contains(text(),'${name}')]/ancestor::tr/descendant::button[@data-test='editPerson']`) }, edit: { - editButton: By.css('[data-test=editPerson]'), - firstName: By.css('[data-test=firstName]'), - lastName: By.css('[data-test=lastName]'), - email: By.css('[data-test=email]'), - cell: By.css('[data-test=cell]'), - save: By.css('[type=submit]') + editButton: By.css("[data-test=editPerson]"), + firstName: By.css("[data-test=firstName]"), + lastName: By.css("[data-test=lastName]"), + email: By.css("[data-test=email]"), + cell: By.css("[data-test=cell]"), + save: By.css("[type=submit]") } -} +}; diff --git a/__test__/e2e/page-objects/scriptEditor.js b/__test__/e2e/page-objects/scriptEditor.js index 46268c378..52cecde48 100644 --- a/__test__/e2e/page-objects/scriptEditor.js +++ b/__test__/e2e/page-objects/scriptEditor.js @@ -1,7 +1,7 @@ -import { By } from 'selenium-webdriver' +import { By } from "selenium-webdriver"; export const scriptEditor = { - editor: By.css('.public-DraftEditor-content'), - done: By.css('[data-test=scriptDone]'), - cancel: By.css('[data-test=scriptCancel]') -} + editor: By.css(".public-DraftEditor-content"), + done: By.css("[data-test=scriptDone]"), + cancel: By.css("[data-test=scriptCancel]") +}; diff --git a/__test__/e2e/page-objects/texter.js b/__test__/e2e/page-objects/texter.js index af56059b6..9ebbcfc35 100644 --- a/__test__/e2e/page-objects/texter.js +++ b/__test__/e2e/page-objects/texter.js @@ -1,13 +1,17 @@ -import { By } from 'selenium-webdriver' +import { By } from "selenium-webdriver"; export const texter = { - sendFirstTexts: By.css('[data-test=sendFirstTexts]'), - sendReplies: By.css('[data-test=sendReplies]'), - send: By.css('[data-test=send]:not([disabled])'), - replyByText(text) { return By.xpath(`//*[@data-test='messageList']/descendant::*[contains(text(),'${text}')]`) }, - emptyTodo: By.css('[data-test=empty]'), + sendFirstTexts: By.css("[data-test=sendFirstTexts]"), + sendReplies: By.css("[data-test=sendReplies]"), + send: By.css("[data-test=send]:not([disabled])"), + replyByText(text) { + return By.xpath( + `//*[@data-test='messageList']/descendant::*[contains(text(),'${text}')]` + ); + }, + emptyTodo: By.css("[data-test=empty]"), optOut: { - button: By.css('[data-test=optOut]'), - send: By.css('[type=submit]') + button: By.css("[data-test=optOut]"), + send: By.css("[type=submit]") } -} +}; diff --git a/__test__/e2e/util/config.js b/__test__/e2e/util/config.js index 851978602..9b2fc124e 100644 --- a/__test__/e2e/util/config.js +++ b/__test__/e2e/util/config.js @@ -4,18 +4,18 @@ const config = { username: process.env.SAUCE_USERNAME, accessKey: process.env.SAUCE_ACCESS_KEY, capabilities: { - name: 'Spoke - Chrome E2E Tests', - browserName: 'chrome', + name: "Spoke - Chrome E2E Tests", + browserName: "chrome", idleTimeout: 240, // 4 minute idle - 'tunnel-identifier': process.env.TRAVIS_JOB_NUMBER, + "tunnel-identifier": process.env.TRAVIS_JOB_NUMBER, username: process.env.SAUCE_USERNAME, accessKey: process.env.SAUCE_ACCESS_KEY, build: process.env.TRAVIS_BUILD_NUMBER }, server: `http://${process.env.SAUCE_USERNAME}:${process.env.SAUCE_ACCESS_KEY}@ondemand.saucelabs.com:80/wd/hub`, - host: 'localhost', + host: "localhost", port: 4445 } -} +}; -export default config +export default config; diff --git a/__test__/e2e/util/helpers.js b/__test__/e2e/util/helpers.js index c84a3bf96..c915a419a 100644 --- a/__test__/e2e/util/helpers.js +++ b/__test__/e2e/util/helpers.js @@ -1,88 +1,109 @@ -import { Builder, until } from 'selenium-webdriver' -import remote from 'selenium-webdriver/remote' -import config from './config' -import _ from 'lodash' +import { Builder, until } from "selenium-webdriver"; +import remote from "selenium-webdriver/remote"; +import config from "./config"; +import _ from "lodash"; -import SauceLabs from 'saucelabs' +import SauceLabs from "saucelabs"; const saucelabs = new SauceLabs({ username: process.env.SAUCE_USERNAME, password: process.env.SAUCE_ACCESS_KEY -}) +}); -const defaultWait = 10000 +const defaultWait = 10000; export const selenium = { buildDriver(options) { - const capabilities = _.assign({}, config.sauceLabs.capabilities, options) - const driver = process.env.npm_config_saucelabs ? - new Builder() - .withCapabilities(capabilities) - .usingServer(config.sauceLabs.server) - .build() : - new Builder().forBrowser('chrome').build() - driver.setFileDetector(new remote.FileDetector()) - return driver + const capabilities = _.assign({}, config.sauceLabs.capabilities, options); + const driver = process.env.npm_config_saucelabs + ? new Builder() + .withCapabilities(capabilities) + .usingServer(config.sauceLabs.server) + .build() + : new Builder().forBrowser("chrome").build(); + driver.setFileDetector(new remote.FileDetector()); + return driver; }, async quitDriver(driver) { - await driver.getSession() - .then(async session => { - if (process.env.npm_config_saucelabs) { - const sessionId = session.getId() - process.env.SELENIUM_ID = sessionId - await saucelabs.updateJob(sessionId, { passed: global.e2e.failureCount === 0 }) - console.log(`SauceOnDemandSessionID=${sessionId} job-name=${process.env.TRAVIS_JOB_NUMBER || ''}`) - } - }) - await driver.quit() + await driver.getSession().then(async session => { + if (process.env.npm_config_saucelabs) { + const sessionId = session.getId(); + process.env.SELENIUM_ID = sessionId; + await saucelabs.updateJob(sessionId, { + passed: global.e2e.failureCount === 0 + }); + console.log( + `SauceOnDemandSessionID=${sessionId} job-name=${process.env + .TRAVIS_JOB_NUMBER || ""}` + ); + } + }); + await driver.quit(); }, reporter: { - specDone: async (result) => { global.e2e.failureCount = global.e2e.failureCount + result.failedExpectations.length || 0 }, - suiteDone: async (result) => { global.e2e.failureCount = global.e2e.failureCount + result.failedExpectations.length || 0 } + specDone: async result => { + global.e2e.failureCount = + global.e2e.failureCount + result.failedExpectations.length || 0; + }, + suiteDone: async result => { + global.e2e.failureCount = + global.e2e.failureCount + result.failedExpectations.length || 0; + } } -} +}; export const urlBuilder = { login: `${config.baseUrl}/login`, admin: { - root() { return `${config.baseUrl}/admin/${global.e2e.organization}` } + root() { + return `${config.baseUrl}/admin/${global.e2e.organization}`; + } }, app: { - todos() { return `${config.baseUrl}/app/${global.e2e.organization}/todos` } + todos() { + return `${config.baseUrl}/app/${global.e2e.organization}/todos`; + } } -} +}; const waitAnd = async (driver, locator, options) => { - const el = await driver.wait(until.elementLocated(locator, options.msWait || defaultWait)) - if (options.elementIsVisible !== false) await driver.wait(until.elementIsVisible(el)) - if (options.waitAfterVisible) await driver.sleep(options.waitAfterVisible) - if (options.click) await el.click() - if (options.keys) await driver.sleep(500) - if (options.clear) await el.clear() - if (options.keys) await el.sendKeys(options.keys) - if (options.goesStale) await driver.wait(until.stalenessOf(el)) - return el -} + const el = await driver.wait( + until.elementLocated(locator, options.msWait || defaultWait) + ); + if (options.elementIsVisible !== false) + await driver.wait(until.elementIsVisible(el)); + if (options.waitAfterVisible) await driver.sleep(options.waitAfterVisible); + if (options.click) await el.click(); + if (options.keys) await driver.sleep(500); + if (options.clear) await el.clear(); + if (options.keys) await el.sendKeys(options.keys); + if (options.goesStale) await driver.wait(until.stalenessOf(el)); + return el; +}; export const wait = { async untilLocated(driver, locator, options) { - return await waitAnd(driver, locator, _.assign({}, options)) + return await waitAnd(driver, locator, _.assign({}, options)); }, async andGetEl(driver, locator, options) { - return await waitAnd(driver, locator, _.assign({}, options)) + return await waitAnd(driver, locator, _.assign({}, options)); }, async andClick(driver, locator, options) { - return await waitAnd(driver, locator, _.assign({ click: true }, options)) + return await waitAnd(driver, locator, _.assign({ click: true }, options)); }, async andType(driver, locator, keys, options) { - return await waitAnd(driver, locator, _.assign({ keys, clear: true, click: true }, options)) + return await waitAnd( + driver, + locator, + _.assign({ keys, clear: true, click: true }, options) + ); }, async andGetValue(driver, locator, options) { - const el = await waitAnd(driver, locator, _.assign({}, options)) - return await el.getAttribute('value') + const el = await waitAnd(driver, locator, _.assign({}, options)); + return await el.getAttribute("value"); }, async andIsEnabled(driver, locator, options) { - const el = await waitAnd(driver, locator, _.assign({}, options)) - return await el.isEnabled() + const el = await waitAnd(driver, locator, _.assign({}, options)); + return await el.isEnabled(); } -} +}; diff --git a/__test__/e2e/util/setup.js b/__test__/e2e/util/setup.js index cd8ad403e..dbfaac3a8 100644 --- a/__test__/e2e/util/setup.js +++ b/__test__/e2e/util/setup.js @@ -1,3 +1,3 @@ // This script will execute before the entire end to end run -jest.setTimeout(1 * 60 * 1000) // Set the test callback timeout to 1 minute -global.e2e = {} // Pass global information around using the global object as Jasmine context isn't available. +jest.setTimeout(1 * 60 * 1000); // Set the test callback timeout to 1 minute +global.e2e = {}; // Pass global information around using the global object as Jasmine context isn't available. diff --git a/__test__/lambda.test.js b/__test__/lambda.test.js index f1945f341..0231f1a62 100644 --- a/__test__/lambda.test.js +++ b/__test__/lambda.test.js @@ -1,59 +1,72 @@ -import { handler } from '../lambda.js' -import { setupTest, cleanupTest } from './test_helpers' +import { handler } from "../lambda.js"; +import { setupTest, cleanupTest } from "./test_helpers"; -beforeAll(async () => await setupTest(), global.DATABASE_SETUP_TEARDOWN_TIMEOUT) -afterAll(async () => await cleanupTest(), global.DATABASE_SETUP_TEARDOWN_TIMEOUT) +beforeAll( + async () => await setupTest(), + global.DATABASE_SETUP_TEARDOWN_TIMEOUT +); +afterAll( + async () => await cleanupTest(), + global.DATABASE_SETUP_TEARDOWN_TIMEOUT +); -describe('AWS Lambda', async () => { - test('completes request to lambda', () => { +describe("AWS Lambda", async () => { + test("completes request to lambda", () => { const fakeEvent = { - resource: '/{proxy+}', - path: '/', - httpMethod: 'GET', - headers: - { Accept: '*/*', - 'CloudFront-Forwarded-Proto': 'https', - Host: 'spoke.example.com', - origin: 'https://spoke.example.com', - 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Safari/537.36', - 'X-Twilio-Body': '\u0019!@#!@K#J!@K#J!@#', - 'X-Forwarded-Port': '443', - 'X-Forwarded-Proto': 'https' }, - multiValueHeaders: - { Accept: ['*/*'], - 'CloudFront-Forwarded-Proto': ['https'], - Host: ['spoke.example.com'], - 'X-Twilio-Body': ['\u0019!@#!@K#J!@K#J!@#'], - 'X-Forwarded-Port': ['443'], - 'X-Forwarded-Proto': ['https'] }, + resource: "/{proxy+}", + path: "/", + httpMethod: "GET", + headers: { + Accept: "*/*", + "CloudFront-Forwarded-Proto": "https", + Host: "spoke.example.com", + origin: "https://spoke.example.com", + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Safari/537.36", + "X-Twilio-Body": "\u0019!@#!@K#J!@K#J!@#", + "X-Forwarded-Port": "443", + "X-Forwarded-Proto": "https" + }, + multiValueHeaders: { + Accept: ["*/*"], + "CloudFront-Forwarded-Proto": ["https"], + Host: ["spoke.example.com"], + "X-Twilio-Body": ["\u0019!@#!@K#J!@K#J!@#"], + "X-Forwarded-Port": ["443"], + "X-Forwarded-Proto": ["https"] + }, queryStringParameters: null, multiValueQueryStringParameters: null, - pathParameters: { proxy: '' }, - stageVariables: { lambdaVersion: 'latest' }, - requestContext: - { resourcePath: '/{proxy+}', - httpMethod: 'POST', - requestTime: '25/Oct/2018:00:08:03 +0000', - path: '/', - protocol: 'HTTP/1.1', - stage: 'latest', - domainPrefix: 'spoke', + pathParameters: { proxy: "" }, + stageVariables: { lambdaVersion: "latest" }, + requestContext: { + resourcePath: "/{proxy+}", + httpMethod: "POST", + requestTime: "25/Oct/2018:00:08:03 +0000", + path: "/", + protocol: "HTTP/1.1", + stage: "latest", + domainPrefix: "spoke", requestTimeEpoch: 1540426083986, - domainName: 'spoke.example.com' + domainName: "spoke.example.com" }, isBase64Encoded: false - } + }; handler( fakeEvent, - { succeed: (response) => { - expect(response.statusCode).toBe(200) - expect(response.headers['content-type']).toBe('text/html; charset=utf-8') + { + succeed: response => { + expect(response.statusCode).toBe(200); + expect(response.headers["content-type"]).toBe( + "text/html; charset=utf-8" + ); // console.log('context.succeed response', response) - } + } }, (err, res) => { - console.log('result returned through callback', err, res) - }) + console.log("result returned through callback", err, res); + } + ); // console.log('lambda server', result) - }) -}) + }); +}); diff --git a/__test__/lib.test.js b/__test__/lib.test.js index 4405b8167..07c553dd7 100644 --- a/__test__/lib.test.js +++ b/__test__/lib.test.js @@ -1,23 +1,37 @@ -import { resolvers } from '../src/server/api/schema' -import { schema } from '../src/api/schema' -import twilio from '../src/server/api/lib/twilio' -import { makeExecutableSchema } from 'graphql-tools' +import { resolvers } from "../src/server/api/schema"; +import { schema } from "../src/api/schema"; +import twilio from "../src/server/api/lib/twilio"; +import { makeExecutableSchema } from "graphql-tools"; const mySchema = makeExecutableSchema({ typeDefs: schema, resolvers: resolvers, - allowUndefinedInResolve: true, -}) + allowUndefinedInResolve: true +}); -it('should parse a message with a media url', () => { - expect(twilio.parseMessageText({ text: 'foo bar' }).body).toBe('foo bar') - expect(twilio.parseMessageText({ text: 'foo bar [http://example.com/foo.jpg]' }).body).toBe('foo bar ') - expect(twilio.parseMessageText({ text: 'foo bar [http://example.com/foo.jpg]' }).mediaUrl).toBe('http://example.com/foo.jpg') - expect(twilio.parseMessageText({ text: 'foo bar [ https://example.com/foo.jpg ]' }).mediaUrl).toBe('https://example.com/foo.jpg') +it("should parse a message with a media url", () => { + expect(twilio.parseMessageText({ text: "foo bar" }).body).toBe("foo bar"); + expect( + twilio.parseMessageText({ text: "foo bar [http://example.com/foo.jpg]" }) + .body + ).toBe("foo bar "); + expect( + twilio.parseMessageText({ text: "foo bar [http://example.com/foo.jpg]" }) + .mediaUrl + ).toBe("http://example.com/foo.jpg"); + expect( + twilio.parseMessageText({ text: "foo bar [ https://example.com/foo.jpg ]" }) + .mediaUrl + ).toBe("https://example.com/foo.jpg"); - const doubleShouldOnlyUseFirst = 'foo bar [ https://example.com/foo.jpg ] and this other image! [ https://example.com/bar.jpg ]' - expect(twilio.parseMessageText({ text: doubleShouldOnlyUseFirst }).mediaUrl).toBe('https://example.com/foo.jpg') - expect(twilio.parseMessageText({ text: doubleShouldOnlyUseFirst }).body).toBe('foo bar and this other image! [ https://example.com/bar.jpg ]') + const doubleShouldOnlyUseFirst = + "foo bar [ https://example.com/foo.jpg ] and this other image! [ https://example.com/bar.jpg ]"; + expect( + twilio.parseMessageText({ text: doubleShouldOnlyUseFirst }).mediaUrl + ).toBe("https://example.com/foo.jpg"); + expect(twilio.parseMessageText({ text: doubleShouldOnlyUseFirst }).body).toBe( + "foo bar and this other image! [ https://example.com/bar.jpg ]" + ); - expect(twilio.parseMessageText({ text: undefined }).body).toBe('') -}) + expect(twilio.parseMessageText({ text: undefined }).body).toBe(""); +}); diff --git a/__test__/lib/dst-helper.test.js b/__test__/lib/dst-helper.test.js index 7443b1259..f505d5b63 100644 --- a/__test__/lib/dst-helper.test.js +++ b/__test__/lib/dst-helper.test.js @@ -1,85 +1,101 @@ -import {DstHelper} from '../../src/lib/dst-helper' -import {DateTime, zone, DateFunctions} from 'timezonecomplete' +import { DstHelper } from "../../src/lib/dst-helper"; +import { DateTime, zone, DateFunctions } from "timezonecomplete"; -var MockDate = require('mockdate'); +var MockDate = require("mockdate"); -describe('test DstHelper', () => { +describe("test DstHelper", () => { afterEach(() => { - MockDate.reset() - }) + MockDate.reset(); + }); - it('helps us figure out if we\'re in DST in February in New York', () => { - MockDate.set('2018-02-01T15:00:00Z') - let d = new DateTime(new Date(), DateFunctions.Get, zone('America/New_York')) - expect(DstHelper.isOffsetDst(d.offset(), 'America/New_York')).toBeFalsy() - expect(DstHelper.isDateTimeDst(d, 'America/New_York')).toBeFalsy() - expect(DstHelper.isDateDst(new Date(), 'America/New_York')).toBeFalsy() - }) + it("helps us figure out if we're in DST in February in New York", () => { + MockDate.set("2018-02-01T15:00:00Z"); + let d = new DateTime( + new Date(), + DateFunctions.Get, + zone("America/New_York") + ); + expect(DstHelper.isOffsetDst(d.offset(), "America/New_York")).toBeFalsy(); + expect(DstHelper.isDateTimeDst(d, "America/New_York")).toBeFalsy(); + expect(DstHelper.isDateDst(new Date(), "America/New_York")).toBeFalsy(); + }); - it('helps us figure out if we\'re in DST in July in New York', () => { - MockDate.set('2018-07-21T15:00:00Z') - let d = new DateTime(new Date(), DateFunctions.Get, zone('America/New_York')) - expect(DstHelper.isOffsetDst(d.offset(), 'America/New_York')).toBeTruthy() - expect(DstHelper.isDateTimeDst(d, 'America/New_York')).toBeTruthy() - expect(DstHelper.isDateDst(new Date(), 'America/New_York')).toBeTruthy() - }) + it("helps us figure out if we're in DST in July in New York", () => { + MockDate.set("2018-07-21T15:00:00Z"); + let d = new DateTime( + new Date(), + DateFunctions.Get, + zone("America/New_York") + ); + expect(DstHelper.isOffsetDst(d.offset(), "America/New_York")).toBeTruthy(); + expect(DstHelper.isDateTimeDst(d, "America/New_York")).toBeTruthy(); + expect(DstHelper.isDateDst(new Date(), "America/New_York")).toBeTruthy(); + }); - it('helps us figure out if we\'re in DST in February in Sydney', () => { - MockDate.set('2018-02-01T15:00:00Z') - let d = new DateTime(new Date(), DateFunctions.Get, zone('Australia/Sydney')) - expect(DstHelper.isOffsetDst(d.offset(), 'Australia/Sydney')).toBeTruthy() - expect(DstHelper.isDateTimeDst(d, 'Australia/Sydney')).toBeTruthy() - expect(DstHelper.isDateDst(new Date(), 'Australia/Sydney')).toBeTruthy() - }) + it("helps us figure out if we're in DST in February in Sydney", () => { + MockDate.set("2018-02-01T15:00:00Z"); + let d = new DateTime( + new Date(), + DateFunctions.Get, + zone("Australia/Sydney") + ); + expect(DstHelper.isOffsetDst(d.offset(), "Australia/Sydney")).toBeTruthy(); + expect(DstHelper.isDateTimeDst(d, "Australia/Sydney")).toBeTruthy(); + expect(DstHelper.isDateDst(new Date(), "Australia/Sydney")).toBeTruthy(); + }); - it('helps us figure out if we\'re in DST in July in Sydney', () => { - MockDate.set('2018-07-01T15:00:00Z') - let d = new DateTime(new Date(), DateFunctions.Get, zone('Australia/Sydney')) - expect(DstHelper.isOffsetDst(d.offset(), 'Australia/Sydney')).toBeFalsy() - expect(DstHelper.isDateTimeDst(d, 'Australia/Sydney')).toBeFalsy() - expect(DstHelper.isDateDst(new Date(), 'Australia/Sydney')).toBeFalsy() - }) + it("helps us figure out if we're in DST in July in Sydney", () => { + MockDate.set("2018-07-01T15:00:00Z"); + let d = new DateTime( + new Date(), + DateFunctions.Get, + zone("Australia/Sydney") + ); + expect(DstHelper.isOffsetDst(d.offset(), "Australia/Sydney")).toBeFalsy(); + expect(DstHelper.isDateTimeDst(d, "Australia/Sydney")).toBeFalsy(); + expect(DstHelper.isDateDst(new Date(), "Australia/Sydney")).toBeFalsy(); + }); - it('helps us figure out if we\'re in DST in February in Kathmandu, which has no DST', () => { - MockDate.set('2018-02-01T15:00:00Z') - let d = new DateTime(new Date(), DateFunctions.Get, zone('Asia/Kathmandu')) - expect(DstHelper.isOffsetDst(d.offset(), 'Asia/Kathmandu')).toBeFalsy() - expect(DstHelper.isDateTimeDst(d, 'Asia/Kathmandu')).toBeFalsy() - expect(DstHelper.isDateDst(new Date(), 'Asia/Kathmandu')).toBeFalsy() - }) + it("helps us figure out if we're in DST in February in Kathmandu, which has no DST", () => { + MockDate.set("2018-02-01T15:00:00Z"); + let d = new DateTime(new Date(), DateFunctions.Get, zone("Asia/Kathmandu")); + expect(DstHelper.isOffsetDst(d.offset(), "Asia/Kathmandu")).toBeFalsy(); + expect(DstHelper.isDateTimeDst(d, "Asia/Kathmandu")).toBeFalsy(); + expect(DstHelper.isDateDst(new Date(), "Asia/Kathmandu")).toBeFalsy(); + }); - it('helps us figure out if we\'re in DST in July in Kathmandu, which has no DST', () => { - MockDate.set('2018-07-01T15:00:00Z') - let d = new DateTime(new Date(), DateFunctions.Get, zone('Asia/Kathmandu')) - expect(DstHelper.isOffsetDst(d.offset(), 'Asia/Kathmandu')).toBeFalsy() - expect(DstHelper.isDateTimeDst(d, 'Asia/Kathmandu')).toBeFalsy() - expect(DstHelper.isDateDst(new Date(), 'Asia/Kathmandu')).toBeFalsy() - }) + it("helps us figure out if we're in DST in July in Kathmandu, which has no DST", () => { + MockDate.set("2018-07-01T15:00:00Z"); + let d = new DateTime(new Date(), DateFunctions.Get, zone("Asia/Kathmandu")); + expect(DstHelper.isOffsetDst(d.offset(), "Asia/Kathmandu")).toBeFalsy(); + expect(DstHelper.isDateTimeDst(d, "Asia/Kathmandu")).toBeFalsy(); + expect(DstHelper.isDateDst(new Date(), "Asia/Kathmandu")).toBeFalsy(); + }); - it('helps us figure out if we\'re in DST in February in Arizona, which has no DST', () => { - MockDate.set('2018-02-01T15:00:00Z') - let d = new DateTime(new Date(), DateFunctions.Get, zone('US/Arizona')) - expect(DstHelper.isOffsetDst(d.offset(), 'US/Arizona')).toBeFalsy() - expect(DstHelper.isDateTimeDst(d, 'US/Arizona')).toBeFalsy() - expect(DstHelper.isDateDst(new Date(), 'US/Arizona')).toBeFalsy() - }) + it("helps us figure out if we're in DST in February in Arizona, which has no DST", () => { + MockDate.set("2018-02-01T15:00:00Z"); + let d = new DateTime(new Date(), DateFunctions.Get, zone("US/Arizona")); + expect(DstHelper.isOffsetDst(d.offset(), "US/Arizona")).toBeFalsy(); + expect(DstHelper.isDateTimeDst(d, "US/Arizona")).toBeFalsy(); + expect(DstHelper.isDateDst(new Date(), "US/Arizona")).toBeFalsy(); + }); - it('helps us figure out if we\'re in DST in July in Arizona, which has no DST', () => { - MockDate.set('2018-07-01T15:00:00Z') - let d = new DateTime(new Date(), DateFunctions.Get, zone('US/Arizona')) - expect(DstHelper.isOffsetDst(d.offset(), 'US/Arizona')).toBeFalsy() - expect(DstHelper.isDateTimeDst(d, 'US/Arizona')).toBeFalsy() - expect(DstHelper.isDateDst(new Date(), 'US/Arizona')).toBeFalsy() - }) + it("helps us figure out if we're in DST in July in Arizona, which has no DST", () => { + MockDate.set("2018-07-01T15:00:00Z"); + let d = new DateTime(new Date(), DateFunctions.Get, zone("US/Arizona")); + expect(DstHelper.isOffsetDst(d.offset(), "US/Arizona")).toBeFalsy(); + expect(DstHelper.isDateTimeDst(d, "US/Arizona")).toBeFalsy(); + expect(DstHelper.isDateDst(new Date(), "US/Arizona")).toBeFalsy(); + }); - it('correctly reports a timezone\'s offset and whether it has DST', () => { - expect(DstHelper.getTimezoneOffsetHours('America/New_York')).toEqual(-5) - expect(DstHelper.timezoneHasDst('America/New_York')).toBeTruthy() - expect(DstHelper.getTimezoneOffsetHours('US/Arizona')).toEqual(-7) - expect(DstHelper.timezoneHasDst('US/Arizona')).toBeFalsy() - expect(DstHelper.getTimezoneOffsetHours('Europe/Paris')).toEqual(1) - expect(DstHelper.timezoneHasDst('Europe/Paris')).toBeTruthy() - expect(DstHelper.getTimezoneOffsetHours('Europe/London')).toEqual(0) - expect(DstHelper.timezoneHasDst('Europe/London')).toBeTruthy() - }) -}) \ No newline at end of file + it("correctly reports a timezone's offset and whether it has DST", () => { + expect(DstHelper.getTimezoneOffsetHours("America/New_York")).toEqual(-5); + expect(DstHelper.timezoneHasDst("America/New_York")).toBeTruthy(); + expect(DstHelper.getTimezoneOffsetHours("US/Arizona")).toEqual(-7); + expect(DstHelper.timezoneHasDst("US/Arizona")).toBeFalsy(); + expect(DstHelper.getTimezoneOffsetHours("Europe/Paris")).toEqual(1); + expect(DstHelper.timezoneHasDst("Europe/Paris")).toBeTruthy(); + expect(DstHelper.getTimezoneOffsetHours("Europe/London")).toEqual(0); + expect(DstHelper.timezoneHasDst("Europe/London")).toBeTruthy(); + }); +}); diff --git a/__test__/lib/parse-csv.test.js b/__test__/lib/parse-csv.test.js index 56567be64..a8ad92604 100644 --- a/__test__/lib/parse-csv.test.js +++ b/__test__/lib/parse-csv.test.js @@ -1,16 +1,20 @@ -import {parseCSV} from '../../src/lib' +import { parseCSV } from "../../src/lib"; -describe('parseCSV', () => { - describe('with PHONE_NUMBER_COUNTRY set', () => { - beforeEach(() => process.env.PHONE_NUMBER_COUNTRY = 'AU') - afterEach(() => delete process.env.PHONE_NUMBER_COUNTRY) +describe("parseCSV", () => { + describe("with PHONE_NUMBER_COUNTRY set", () => { + beforeEach(() => (process.env.PHONE_NUMBER_COUNTRY = "AU")); + afterEach(() => delete process.env.PHONE_NUMBER_COUNTRY); - it('should consider phone numbers from that country as valid', () => { - const csv = "firstName,lastName,cell\ntest,test,61468511000" - parseCSV(csv, [], ({ contacts, customFields, validationStats, error }) => { - expect(error).toBeFalsy() - expect(contacts.length).toEqual(1) - }) - }) - }) -}) + it("should consider phone numbers from that country as valid", () => { + const csv = "firstName,lastName,cell\ntest,test,61468511000"; + parseCSV( + csv, + [], + ({ contacts, customFields, validationStats, error }) => { + expect(error).toBeFalsy(); + expect(contacts.length).toEqual(1); + } + ); + }); + }); +}); diff --git a/__test__/lib/timezones.test.js b/__test__/lib/timezones.test.js index 126ffc92f..895a2bab5 100644 --- a/__test__/lib/timezones.test.js +++ b/__test__/lib/timezones.test.js @@ -1,6 +1,6 @@ -import moment from 'moment-timezone' +import moment from "moment-timezone"; -const MockDate = require('mockdate'); +const MockDate = require("mockdate"); import { convertOffsetsToStrings, @@ -12,9 +12,9 @@ import { getUtcFromOffsetAndHour, getUtcFromTimezoneAndHour, getSendBeforeTimeUtc -} from '../../src/lib/index' +} from "../../src/lib/index"; -import { getProcessEnvDstReferenceTimezone } from '../../src/lib/tz-helpers' +import { getProcessEnvDstReferenceTimezone } from "../../src/lib/tz-helpers"; const makeCampignTextingHoursConfig = ( textingHoursEnforced, @@ -27,8 +27,8 @@ const makeCampignTextingHoursConfig = ( textingHoursStart, textingHoursEnd, timezone - } -} + }; +}; const makeCampaignOnlyWithTextingHoursConfigFields = ( overrideOrganizationTextingHours, @@ -42,10 +42,10 @@ const makeCampaignOnlyWithTextingHoursConfigFields = ( textingHoursStart, textingHoursEnd, timezone - ) - textingHoursConfigFields.overrideOrganizationTextingHours = overrideOrganizationTextingHours - return textingHoursConfigFields -} + ); + textingHoursConfigFields.overrideOrganizationTextingHours = overrideOrganizationTextingHours; + return textingHoursConfigFields; +}; const makeConfig = ( textingHoursStart, @@ -58,461 +58,605 @@ const makeConfig = ( textingHoursEnd, textingHoursEnforced, campaignTextingHours - } -} + }; +}; const makeLocationWithOnlyTimezoneData = (offset, hasDst) => { - return { timezone: { offset, hasDst } } -} + return { timezone: { offset, hasDst } }; +}; -const buildIsBetweenTextingHoursExpectForSpecifiedTimezone = (offsetData, start, end, timezone) => { - return expect(isBetweenTextingHours(offsetData, makeConfig(0, 0, false, makeCampignTextingHoursConfig(true, start, end, timezone )))) -} +const buildIsBetweenTextingHoursExpectForSpecifiedTimezone = ( + offsetData, + start, + end, + timezone +) => { + return expect( + isBetweenTextingHours( + offsetData, + makeConfig( + 0, + 0, + false, + makeCampignTextingHoursConfig(true, start, end, timezone) + ) + ) + ); +}; const buildIsBetweenTextingHoursExpect = (offsetData, start, end) => { - return buildIsBetweenTextingHoursExpectForSpecifiedTimezone(offsetData, start, end, 'America/Los_Angeles') -} + return buildIsBetweenTextingHoursExpectForSpecifiedTimezone( + offsetData, + start, + end, + "America/Los_Angeles" + ); +}; const buildIsBetweenTextingHoursExpectWithNoOffset = (start, end) => { - return expect(isBetweenTextingHours(null, makeConfig(0, 0, false, makeCampignTextingHoursConfig(true, start, end, 'America/New_York' )))) -} + return expect( + isBetweenTextingHours( + null, + makeConfig( + 0, + 0, + false, + makeCampignTextingHoursConfig(true, start, end, "America/New_York") + ) + ) + ); +}; -jest.unmock('../../src/lib/timezones') -jest.mock('../../src/lib/tz-helpers') +jest.unmock("../../src/lib/timezones"); +jest.mock("../../src/lib/tz-helpers"); -describe('test getLocalTime winter (standard time)', () => { +describe("test getLocalTime winter (standard time)", () => { beforeAll(() => { - MockDate.set('2018-02-01T15:00:00Z') - }) + MockDate.set("2018-02-01T15:00:00Z"); + }); afterAll(() => { - MockDate.reset() - }) - - it('returns correct local time UTC-5 standard time', () => { - let localTime = getLocalTime(-5, true, getProcessEnvDstReferenceTimezone()) - expect(localTime.hours()).toEqual(10) - expect(new Date(localTime)).toEqual(new Date('2018-02-01T10:00:00.000-05:00')) - }) -}) - -describe('test getLocalTime summer (DST)', () => { + MockDate.reset(); + }); + + it("returns correct local time UTC-5 standard time", () => { + let localTime = getLocalTime(-5, true, getProcessEnvDstReferenceTimezone()); + expect(localTime.hours()).toEqual(10); + expect(new Date(localTime)).toEqual( + new Date("2018-02-01T10:00:00.000-05:00") + ); + }); +}); + +describe("test getLocalTime summer (DST)", () => { beforeEach(() => { - MockDate.set('2018-07-21T15:00:00Z') - }) + MockDate.set("2018-07-21T15:00:00Z"); + }); afterEach(() => { - MockDate.reset() - }) - - it('returns correct local time UTC-5 DST', () => { - let localTime = getLocalTime(-5, true, getProcessEnvDstReferenceTimezone()) - expect(localTime.hours()).toEqual(11) - expect(new Date(localTime)).toEqual(new Date('2018-07-21T10:00:00.000-05:00')) - }) -}) - -describe('testing isBetweenTextingHours with env.TZ set', () => { - var tzHelpers = require('../../src/lib/tz-helpers') + MockDate.reset(); + }); + + it("returns correct local time UTC-5 DST", () => { + let localTime = getLocalTime(-5, true, getProcessEnvDstReferenceTimezone()); + expect(localTime.hours()).toEqual(11); + expect(new Date(localTime)).toEqual( + new Date("2018-07-21T10:00:00.000-05:00") + ); + }); +}); + +describe("testing isBetweenTextingHours with env.TZ set", () => { + var tzHelpers = require("../../src/lib/tz-helpers"); beforeAll(() => { - tzHelpers.getProcessEnvTz.mockImplementation(() => 'America/Los_Angeles') - MockDate.set('2018-02-01T15:00:00.000-05:00') - }) + tzHelpers.getProcessEnvTz.mockImplementation(() => "America/Los_Angeles"); + MockDate.set("2018-02-01T15:00:00.000-05:00"); + }); afterAll(() => { jest.restoreAllMocks(); - MockDate.reset() - }) + MockDate.reset(); + }); - it('returns true if texting hours are not enforced', () => { - expect(isBetweenTextingHours(null, makeConfig(1, 1, false))).toBeTruthy() - }) + it("returns true if texting hours are not enforced", () => { + expect(isBetweenTextingHours(null, makeConfig(1, 1, false))).toBeTruthy(); + }); - it('returns true if texting hours are not enforced and there are campaign texting hours with !textingHoursEnforced', () => { - expect(isBetweenTextingHours(null, makeConfig(1, 1, false, makeCampignTextingHoursConfig(false, 0, 0, 'not_used')))).toBeTruthy() - }) + it("returns true if texting hours are not enforced and there are campaign texting hours with !textingHoursEnforced", () => { + expect( + isBetweenTextingHours( + null, + makeConfig( + 1, + 1, + false, + makeCampignTextingHoursConfig(false, 0, 0, "not_used") + ) + ) + ).toBeTruthy(); + }); - it('returns false if texting hours are 05-07 and time is 12:00', () => { - expect(isBetweenTextingHours(null, makeConfig(5, 7, true))).toBeFalsy() - } - ) + it("returns false if texting hours are 05-07 and time is 12:00", () => { + expect(isBetweenTextingHours(null, makeConfig(5, 7, true))).toBeFalsy(); + }); - it('returns false if texting hours are 14-21 and time is 12:00', () => { - expect(isBetweenTextingHours(null, makeConfig(14, 21, true))).toBeFalsy() - } - ) + it("returns false if texting hours are 14-21 and time is 12:00", () => { + expect(isBetweenTextingHours(null, makeConfig(14, 21, true))).toBeFalsy(); + }); - it('returns true if texting hours are 10-21 and time is 12:00', () => { - expect(isBetweenTextingHours(null, makeConfig(10, 21, true))).toBeTruthy() - }) + it("returns true if texting hours are 10-21 and time is 12:00", () => { + expect(isBetweenTextingHours(null, makeConfig(10, 21, true))).toBeTruthy(); + }); - it('returns true if texting hours are 12-21 and time is 12:00', () => { - expect(isBetweenTextingHours(null, makeConfig(12, 21, true))).toBeTruthy() - }) + it("returns true if texting hours are 12-21 and time is 12:00", () => { + expect(isBetweenTextingHours(null, makeConfig(12, 21, true))).toBeTruthy(); + }); - it('returns true if texting hours are 10-12 and time is 12:00', () => { - expect(isBetweenTextingHours(null, makeConfig(10, 12, true))).toBeTruthy() - }) -}) + it("returns true if texting hours are 10-12 and time is 12:00", () => { + expect(isBetweenTextingHours(null, makeConfig(10, 12, true))).toBeTruthy(); + }); +}); -describe('isBetweenTextingHours with campaign overrides works with DST', () => { +describe("isBetweenTextingHours with campaign overrides works with DST", () => { afterEach(() => { - MockDate.reset() - }) - - const easternOffsetData = {offset: -5, hasDST: true} - const arizonaOffsetData = {offset: -7, hasDST: true} - - it('works for NYC in January', () => { - MockDate.set('2018-01-01T15:00:00.000-05:00') - buildIsBetweenTextingHoursExpectForSpecifiedTimezone(easternOffsetData, 14, 18, 'US/Eastern').toBeTruthy() - buildIsBetweenTextingHoursExpectForSpecifiedTimezone(easternOffsetData, 15, 18, 'US/Eastern').toBeTruthy() - buildIsBetweenTextingHoursExpectForSpecifiedTimezone(easternOffsetData, 16, 18, 'US/Eastern').toBeFalsy() - buildIsBetweenTextingHoursExpectForSpecifiedTimezone(easternOffsetData, 17, 18, 'US/Eastern').toBeFalsy() - }) - - it('works for NYC in July', () => { - MockDate.set('2018-06-01T15:00:00.000-05:00') - buildIsBetweenTextingHoursExpectForSpecifiedTimezone(easternOffsetData, 14, 18, 'US/Eastern').toBeTruthy() - buildIsBetweenTextingHoursExpectForSpecifiedTimezone(easternOffsetData, 15, 18, 'US/Eastern').toBeTruthy() - buildIsBetweenTextingHoursExpectForSpecifiedTimezone(easternOffsetData, 16, 18, 'US/Eastern').toBeTruthy() - buildIsBetweenTextingHoursExpectForSpecifiedTimezone(easternOffsetData, 17, 18, 'US/Eastern').toBeFalsy() - }) - - it('works for Arizona in January', () => { - MockDate.set('2018-01-01T15:00:00.000-07:00') - buildIsBetweenTextingHoursExpectForSpecifiedTimezone(arizonaOffsetData, 14, 18, 'US/Arizona').toBeTruthy() - buildIsBetweenTextingHoursExpectForSpecifiedTimezone(arizonaOffsetData, 15, 18, 'US/Arizona').toBeTruthy() - buildIsBetweenTextingHoursExpectForSpecifiedTimezone(arizonaOffsetData, 16, 18, 'US/Arizona').toBeFalsy() - buildIsBetweenTextingHoursExpectForSpecifiedTimezone(arizonaOffsetData, 17, 18, 'US/Arizona').toBeFalsy() - }) - - it('works for Arizona in July', () => { - MockDate.set('2018-06-01T15:00:00.000-07:00') - buildIsBetweenTextingHoursExpectForSpecifiedTimezone(arizonaOffsetData, 14, 18, 'US/Arizona').toBeTruthy() - buildIsBetweenTextingHoursExpectForSpecifiedTimezone(arizonaOffsetData, 15, 18, 'US/Arizona').toBeTruthy() - buildIsBetweenTextingHoursExpectForSpecifiedTimezone(arizonaOffsetData, 16, 18, 'US/Arizona').toBeFalsy() - buildIsBetweenTextingHoursExpectForSpecifiedTimezone(arizonaOffsetData, 17, 18, 'US/Arizona').toBeFalsy() - }) -}) - -describe('test isBetweenTextingHours with campaign overrides', () => { + MockDate.reset(); + }); + + const easternOffsetData = { offset: -5, hasDST: true }; + const arizonaOffsetData = { offset: -7, hasDST: true }; + + it("works for NYC in January", () => { + MockDate.set("2018-01-01T15:00:00.000-05:00"); + buildIsBetweenTextingHoursExpectForSpecifiedTimezone( + easternOffsetData, + 14, + 18, + "US/Eastern" + ).toBeTruthy(); + buildIsBetweenTextingHoursExpectForSpecifiedTimezone( + easternOffsetData, + 15, + 18, + "US/Eastern" + ).toBeTruthy(); + buildIsBetweenTextingHoursExpectForSpecifiedTimezone( + easternOffsetData, + 16, + 18, + "US/Eastern" + ).toBeFalsy(); + buildIsBetweenTextingHoursExpectForSpecifiedTimezone( + easternOffsetData, + 17, + 18, + "US/Eastern" + ).toBeFalsy(); + }); + + it("works for NYC in July", () => { + MockDate.set("2018-06-01T15:00:00.000-05:00"); + buildIsBetweenTextingHoursExpectForSpecifiedTimezone( + easternOffsetData, + 14, + 18, + "US/Eastern" + ).toBeTruthy(); + buildIsBetweenTextingHoursExpectForSpecifiedTimezone( + easternOffsetData, + 15, + 18, + "US/Eastern" + ).toBeTruthy(); + buildIsBetweenTextingHoursExpectForSpecifiedTimezone( + easternOffsetData, + 16, + 18, + "US/Eastern" + ).toBeTruthy(); + buildIsBetweenTextingHoursExpectForSpecifiedTimezone( + easternOffsetData, + 17, + 18, + "US/Eastern" + ).toBeFalsy(); + }); + + it("works for Arizona in January", () => { + MockDate.set("2018-01-01T15:00:00.000-07:00"); + buildIsBetweenTextingHoursExpectForSpecifiedTimezone( + arizonaOffsetData, + 14, + 18, + "US/Arizona" + ).toBeTruthy(); + buildIsBetweenTextingHoursExpectForSpecifiedTimezone( + arizonaOffsetData, + 15, + 18, + "US/Arizona" + ).toBeTruthy(); + buildIsBetweenTextingHoursExpectForSpecifiedTimezone( + arizonaOffsetData, + 16, + 18, + "US/Arizona" + ).toBeFalsy(); + buildIsBetweenTextingHoursExpectForSpecifiedTimezone( + arizonaOffsetData, + 17, + 18, + "US/Arizona" + ).toBeFalsy(); + }); + + it("works for Arizona in July", () => { + MockDate.set("2018-06-01T15:00:00.000-07:00"); + buildIsBetweenTextingHoursExpectForSpecifiedTimezone( + arizonaOffsetData, + 14, + 18, + "US/Arizona" + ).toBeTruthy(); + buildIsBetweenTextingHoursExpectForSpecifiedTimezone( + arizonaOffsetData, + 15, + 18, + "US/Arizona" + ).toBeTruthy(); + buildIsBetweenTextingHoursExpectForSpecifiedTimezone( + arizonaOffsetData, + 16, + 18, + "US/Arizona" + ).toBeFalsy(); + buildIsBetweenTextingHoursExpectForSpecifiedTimezone( + arizonaOffsetData, + 17, + 18, + "US/Arizona" + ).toBeFalsy(); + }); +}); + +describe("test isBetweenTextingHours with campaign overrides", () => { beforeAll(() => { - MockDate.set('2018-02-01T15:00:00.000-05:00') - }) + MockDate.set("2018-02-01T15:00:00.000-05:00"); + }); afterAll(() => { - MockDate.reset() - }) - - const offsetData = {offset: -8, hasDST: true} - - it('returns false if texting hours are 05-07 and time is 12:00', () => { - buildIsBetweenTextingHoursExpect(offsetData, 5, 7).toBeFalsy() - } - ) - - it('returns false if texting hours are 14-21 and time is 12:00', () => { - buildIsBetweenTextingHoursExpect(offsetData, 14, 21).toBeFalsy() - } - ) - - it('returns true if texting hours are 10-21 and time is 12:00', () => { - buildIsBetweenTextingHoursExpect(offsetData,10, 21).toBeTruthy() - }) - - it('returns true if texting hours are 12-21 and time is 12:00', () => { - buildIsBetweenTextingHoursExpect(offsetData,12, 21).toBeTruthy() - }) - - it('returns false if texting hours are 10-12 and time is 12:00', () => { - buildIsBetweenTextingHoursExpect(offsetData,10, 12).toBeFalsy() - }) - - it('returns false if texting hours are 16-21 and time is 3pm NY and offset data is not provided', () => { - buildIsBetweenTextingHoursExpectWithNoOffset(16, 21).toBeFalsy() - }) - - it('returns true if texting hours are 09-21 and time is 3pm NY and offset data is not provided', () => { - buildIsBetweenTextingHoursExpectWithNoOffset(9, 21).toBeTruthy() - }) -}) - -describe('test isBetweenTextingHours with offset data supplied', () => { - var offsetData = {offset: -8, hasDST: true} - var tzHelpers = require('../../src/lib/tz-helpers') - beforeAll(() => { - jest.doMock('../../src/lib/tz-helpers') - tzHelpers.getProcessEnvTz.mockImplementation(() => null) - MockDate.set('2018-02-01T12:00:00.000-08:00') - }) - - afterAll(() => { - jest.restoreAllMocks(); - MockDate.reset() - }) - - it('returns true if texting hours are not enforced', () => { - expect(isBetweenTextingHours(offsetData, makeConfig(null, null, false))).toBeTruthy() - }) - - it('returns false if texting hours are 05-07 and time is 12:00', () => { - expect(isBetweenTextingHours(offsetData, makeConfig(5, 7, true))).toBeFalsy() - } - ) + MockDate.reset(); + }); - it('returns false if texting hours are 14-21 and time is 12:00', () => { - expect(isBetweenTextingHours(offsetData, makeConfig(14, 21, true))).toBeFalsy() - } - ) + const offsetData = { offset: -8, hasDST: true }; - it('returns true if texting hours are 10-21 and time is 12:00', () => { - expect(isBetweenTextingHours(offsetData, makeConfig(10, 21, true))).toBeTruthy() - }) - - it('returns true if texting hours are 12-21 and time is 12:00', () => { - expect(isBetweenTextingHours(offsetData, makeConfig(12, 21, true))).toBeTruthy() - }) - - it('returns true if texting hours are 10-12 and time is 12:00', () => { - expect(isBetweenTextingHours(offsetData, makeConfig(10, 12, true))).toBeFalsy() - }) - - it('returns true if texting hours are 10-11 and time is 12:00', () => { - expect(isBetweenTextingHours(offsetData, makeConfig(10, 13, true))).toBeTruthy() - }) - } -) - -describe('test isBetweenTextingHours with offset data empty', () => { - var offsetData = {offset: null, hasDST: null} - var tzHelpers = require('../../src/lib/tz-helpers') - beforeAll(() => { - tzHelpers.getProcessEnvTz.mockImplementation(() => null) - }) - - afterEach(() => { - MockDate.reset() - }) - - afterAll(() => { - jest.restoreAllMocks(); - }) - - it('returns true if texting hours are not enforced', () => { - expect(isBetweenTextingHours(offsetData, makeConfig(null, null, false))).toBeTruthy() - }) - - it('returns false if texting hours are for MISSING TIME ZONE and time is 12:00 EST', () => { - MockDate.set('2018-02-01T12:00:00.000-05:00') - expect(isBetweenTextingHours(offsetData, makeConfig(null, null, true))).toBeTruthy() - } - ) + it("returns false if texting hours are 05-07 and time is 12:00", () => { + buildIsBetweenTextingHoursExpect(offsetData, 5, 7).toBeFalsy(); + }); - it('returns false if texting hours are for MISSING TIME ZONE and time is 11:00 EST', () => { - MockDate.set('2018-02-01T11:00:00.000-05:00') - expect(isBetweenTextingHours(offsetData, makeConfig(null, null, true))).toBeFalsy() - } - ) + it("returns false if texting hours are 14-21 and time is 12:00", () => { + buildIsBetweenTextingHoursExpect(offsetData, 14, 21).toBeFalsy(); + }); - it('returns false if texting hours are for MISSING TIME ZONE and time is 20:00 EST', () => { - MockDate.set('2018-02-01T20:00:00.000-05:00') - expect(isBetweenTextingHours(offsetData, makeConfig(null, null, true))).toBeTruthy() - } - ) + it("returns true if texting hours are 10-21 and time is 12:00", () => { + buildIsBetweenTextingHoursExpect(offsetData, 10, 21).toBeTruthy(); + }); - it('returns false if texting hours are for MISSING TIME ZONE and time is 21:00 EST', () => { - MockDate.set('2018-02-01T21:00:00.000-05:00') - expect(isBetweenTextingHours(offsetData, makeConfig(null, null, true))).toBeFalsy() - } - ) - } -) - -describe('test isBetweenTextingHours with offset data NOT supplied', () => { - var tzHelpers = require('../../src/lib/tz-helpers') - beforeAll(() => { - tzHelpers.getProcessEnvTz.mockImplementation(() => null) - }) - - afterEach(() => { - MockDate.reset() - }) - - afterAll(() => { - jest.restoreAllMocks(); - }) - - it('returns true if texting hours are not enforced', () => { - expect(isBetweenTextingHours(null, makeConfig(null, null, false))).toBeTruthy() - }) - - it('returns false if texting hours are for MISSING TIME ZONE and time is 12:00 EST', () => { - MockDate.set('2018-02-01T12:00:00.000-05:00') - expect(isBetweenTextingHours(null, makeConfig(null, null, true))).toBeTruthy() - } - ) + it("returns true if texting hours are 12-21 and time is 12:00", () => { + buildIsBetweenTextingHoursExpect(offsetData, 12, 21).toBeTruthy(); + }); - it('returns false if texting hours are for MISSING TIME ZONE and time is 11:00 EST', () => { - MockDate.set('2018-02-01T11:00:00.000-05:00') - expect(isBetweenTextingHours(null, makeConfig(null, null, true))).toBeFalsy() - } - ) + it("returns false if texting hours are 10-12 and time is 12:00", () => { + buildIsBetweenTextingHoursExpect(offsetData, 10, 12).toBeFalsy(); + }); - it('returns false if texting hours are for MISSING TIME ZONE and time is 20:00 EST', () => { - MockDate.set('2018-02-01T20:00:00.000-05:00') - expect(isBetweenTextingHours(null, makeConfig(null, null, true))).toBeTruthy() - } - ) + it("returns false if texting hours are 16-21 and time is 3pm NY and offset data is not provided", () => { + buildIsBetweenTextingHoursExpectWithNoOffset(16, 21).toBeFalsy(); + }); - it('returns false if texting hours are for MISSING TIME ZONE and time is 21:00 EST', () => { - MockDate.set('2018-02-01T21:00:00.000-05:00') - expect(isBetweenTextingHours(null, makeConfig(null, null, true))).toBeFalsy() - } - ) - } -) + it("returns true if texting hours are 09-21 and time is 3pm NY and offset data is not provided", () => { + buildIsBetweenTextingHoursExpectWithNoOffset(9, 21).toBeTruthy(); + }); +}); +describe("test isBetweenTextingHours with offset data supplied", () => { + var offsetData = { offset: -8, hasDST: true }; + var tzHelpers = require("../../src/lib/tz-helpers"); + beforeAll(() => { + jest.doMock("../../src/lib/tz-helpers"); + tzHelpers.getProcessEnvTz.mockImplementation(() => null); + MockDate.set("2018-02-01T12:00:00.000-08:00"); + }); -describe('test defaultTimezoneIsBetweenTextingHours', () => { - var tzHelpers = require('../../src/lib/tz-helpers') - beforeAll(() => { - tzHelpers.getProcessEnvTz.mockImplementation(() => null) - jest.doMock('../../src/lib/tz-helpers') - }) + afterAll(() => { + jest.restoreAllMocks(); + MockDate.reset(); + }); - afterEach(() => { - MockDate.reset() - }) + it("returns true if texting hours are not enforced", () => { + expect( + isBetweenTextingHours(offsetData, makeConfig(null, null, false)) + ).toBeTruthy(); + }); - afterAll(() => { - jest.restoreAllMocks(); - }) + it("returns false if texting hours are 05-07 and time is 12:00", () => { + expect( + isBetweenTextingHours(offsetData, makeConfig(5, 7, true)) + ).toBeFalsy(); + }); - it('returns true if texting hours are not enforced', () => { - expect(defaultTimezoneIsBetweenTextingHours(makeConfig(null, null, false))).toBeTruthy() - }) + it("returns false if texting hours are 14-21 and time is 12:00", () => { + expect( + isBetweenTextingHours(offsetData, makeConfig(14, 21, true)) + ).toBeFalsy(); + }); - it('returns false if time is 12:00 EST', () => { - MockDate.set('2018-02-01T12:00:00.000-05:00') - expect(defaultTimezoneIsBetweenTextingHours(makeConfig(null, null, true))).toBeTruthy() - } - ) + it("returns true if texting hours are 10-21 and time is 12:00", () => { + expect( + isBetweenTextingHours(offsetData, makeConfig(10, 21, true)) + ).toBeTruthy(); + }); - it('returns false if time is 11:00 EST', () => { - MockDate.set('2018-02-01T11:00:00.000-05:00') - expect(defaultTimezoneIsBetweenTextingHours(makeConfig(null, null, true))).toBeFalsy() - } - ) + it("returns true if texting hours are 12-21 and time is 12:00", () => { + expect( + isBetweenTextingHours(offsetData, makeConfig(12, 21, true)) + ).toBeTruthy(); + }); - it('returns false if time is 20:00 EST', () => { - MockDate.set('2018-02-01T20:00:00.000-05:00') - expect(defaultTimezoneIsBetweenTextingHours(makeConfig(null, null, true))).toBeTruthy() - } - ) + it("returns true if texting hours are 10-12 and time is 12:00", () => { + expect( + isBetweenTextingHours(offsetData, makeConfig(10, 12, true)) + ).toBeFalsy(); + }); + + it("returns true if texting hours are 10-11 and time is 12:00", () => { + expect( + isBetweenTextingHours(offsetData, makeConfig(10, 13, true)) + ).toBeTruthy(); + }); +}); + +describe("test isBetweenTextingHours with offset data empty", () => { + var offsetData = { offset: null, hasDST: null }; + var tzHelpers = require("../../src/lib/tz-helpers"); + beforeAll(() => { + tzHelpers.getProcessEnvTz.mockImplementation(() => null); + }); - it('returns false if time is 21:00 EST', () => { - MockDate.set('2018-02-01T21:00:00.000-05:00') - expect(defaultTimezoneIsBetweenTextingHours(makeConfig(null, null, true))).toBeFalsy() - } - ) - } -) - -describe('test convertOffsetsToStrings', () => { - it('works', () => { - let test_offsets = [[1, true], [2, false], [-1, true]] - let strings_returned = convertOffsetsToStrings(test_offsets) - expect(strings_returned).toHaveLength(3) - expect(strings_returned[0]).toBe('1_1') - expect(strings_returned[1]).toBe('2_0') - expect(strings_returned[2]).toBe('-1_1') - } - ) -}) - -describe('test getOffsets', () => { afterEach(() => { - MockDate.reset() - }) - - it('works during daylight-savings time', () => { - MockDate.set('2018-07-21T17:00:00.000Z') - let offsets_returned = getOffsets(makeConfig(10, 12, true)) - expect(offsets_returned).toHaveLength(2) - - let valid_offsets_returned = offsets_returned[0] - expect(valid_offsets_returned).toHaveLength(4) - expect(valid_offsets_returned[0]).toBe('-7_1') - expect(valid_offsets_returned[1]).toBe('-8_1') - expect(valid_offsets_returned[2]).toBe('-6_0') - expect(valid_offsets_returned[3]).toBe('-7_0') - - let invalid_offsets_returned = offsets_returned[1] - expect(invalid_offsets_returned).toHaveLength(14) - expect(invalid_offsets_returned[0]).toBe('-4_1') - expect(invalid_offsets_returned[1]).toBe('-5_1') - expect(invalid_offsets_returned[2]).toBe('-6_1') - expect(invalid_offsets_returned[3]).toBe('-9_1') - expect(invalid_offsets_returned[4]).toBe('-10_1') - expect(invalid_offsets_returned[5]).toBe('-11_1') - expect(invalid_offsets_returned[6]).toBe('10_1') - expect(invalid_offsets_returned[7]).toBe('-4_0') - expect(invalid_offsets_returned[8]).toBe('-5_0') - expect(invalid_offsets_returned[9]).toBe('-8_0') - expect(invalid_offsets_returned[10]).toBe('-9_0') - expect(invalid_offsets_returned[11]).toBe('-10_0') - expect(invalid_offsets_returned[12]).toBe('-11_0') - expect(invalid_offsets_returned[13]).toBe('10_0') - }) - - it('works during standard time', () => { - MockDate.set('2018-02-01T17:00:00.000Z') - let offsets_returned = getOffsets(makeConfig(10, 12, true)) - expect(offsets_returned).toHaveLength(2) - - let valid_offsets_returned = offsets_returned[0] - expect(valid_offsets_returned).toHaveLength(4) - expect(valid_offsets_returned[0]).toBe('-6_1') - expect(valid_offsets_returned[1]).toBe('-7_1') - expect(valid_offsets_returned[2]).toBe('-6_0') - expect(valid_offsets_returned[3]).toBe('-7_0') - - let invalid_offsets_returned = offsets_returned[1] - expect(invalid_offsets_returned).toHaveLength(14) - expect(invalid_offsets_returned[0]).toBe('-4_1') - expect(invalid_offsets_returned[1]).toBe('-5_1') - expect(invalid_offsets_returned[2]).toBe('-8_1') - expect(invalid_offsets_returned[3]).toBe('-9_1') - expect(invalid_offsets_returned[4]).toBe('-10_1') - expect(invalid_offsets_returned[5]).toBe('-11_1') - expect(invalid_offsets_returned[6]).toBe('10_1') - expect(invalid_offsets_returned[7]).toBe('-4_0') - expect(invalid_offsets_returned[8]).toBe('-5_0') - expect(invalid_offsets_returned[9]).toBe('-8_0') - expect(invalid_offsets_returned[10]).toBe('-9_0') - expect(invalid_offsets_returned[11]).toBe('-10_0') - expect(invalid_offsets_returned[12]).toBe('-11_0') - expect(invalid_offsets_returned[13]).toBe('10_0') - }) -}) - -describe('test getContactTimezone', () => { - var tzHelpers = require('../../src/lib/tz-helpers') + MockDate.reset(); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + it("returns true if texting hours are not enforced", () => { + expect( + isBetweenTextingHours(offsetData, makeConfig(null, null, false)) + ).toBeTruthy(); + }); + + it("returns false if texting hours are for MISSING TIME ZONE and time is 12:00 EST", () => { + MockDate.set("2018-02-01T12:00:00.000-05:00"); + expect( + isBetweenTextingHours(offsetData, makeConfig(null, null, true)) + ).toBeTruthy(); + }); + + it("returns false if texting hours are for MISSING TIME ZONE and time is 11:00 EST", () => { + MockDate.set("2018-02-01T11:00:00.000-05:00"); + expect( + isBetweenTextingHours(offsetData, makeConfig(null, null, true)) + ).toBeFalsy(); + }); + + it("returns false if texting hours are for MISSING TIME ZONE and time is 20:00 EST", () => { + MockDate.set("2018-02-01T20:00:00.000-05:00"); + expect( + isBetweenTextingHours(offsetData, makeConfig(null, null, true)) + ).toBeTruthy(); + }); + + it("returns false if texting hours are for MISSING TIME ZONE and time is 21:00 EST", () => { + MockDate.set("2018-02-01T21:00:00.000-05:00"); + expect( + isBetweenTextingHours(offsetData, makeConfig(null, null, true)) + ).toBeFalsy(); + }); +}); + +describe("test isBetweenTextingHours with offset data NOT supplied", () => { + var tzHelpers = require("../../src/lib/tz-helpers"); + beforeAll(() => { + tzHelpers.getProcessEnvTz.mockImplementation(() => null); + }); + + afterEach(() => { + MockDate.reset(); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + it("returns true if texting hours are not enforced", () => { + expect( + isBetweenTextingHours(null, makeConfig(null, null, false)) + ).toBeTruthy(); + }); + + it("returns false if texting hours are for MISSING TIME ZONE and time is 12:00 EST", () => { + MockDate.set("2018-02-01T12:00:00.000-05:00"); + expect( + isBetweenTextingHours(null, makeConfig(null, null, true)) + ).toBeTruthy(); + }); + + it("returns false if texting hours are for MISSING TIME ZONE and time is 11:00 EST", () => { + MockDate.set("2018-02-01T11:00:00.000-05:00"); + expect( + isBetweenTextingHours(null, makeConfig(null, null, true)) + ).toBeFalsy(); + }); + + it("returns false if texting hours are for MISSING TIME ZONE and time is 20:00 EST", () => { + MockDate.set("2018-02-01T20:00:00.000-05:00"); + expect( + isBetweenTextingHours(null, makeConfig(null, null, true)) + ).toBeTruthy(); + }); + + it("returns false if texting hours are for MISSING TIME ZONE and time is 21:00 EST", () => { + MockDate.set("2018-02-01T21:00:00.000-05:00"); + expect( + isBetweenTextingHours(null, makeConfig(null, null, true)) + ).toBeFalsy(); + }); +}); + +describe("test defaultTimezoneIsBetweenTextingHours", () => { + var tzHelpers = require("../../src/lib/tz-helpers"); + beforeAll(() => { + tzHelpers.getProcessEnvTz.mockImplementation(() => null); + jest.doMock("../../src/lib/tz-helpers"); + }); + + afterEach(() => { + MockDate.reset(); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + it("returns true if texting hours are not enforced", () => { + expect( + defaultTimezoneIsBetweenTextingHours(makeConfig(null, null, false)) + ).toBeTruthy(); + }); + + it("returns false if time is 12:00 EST", () => { + MockDate.set("2018-02-01T12:00:00.000-05:00"); + expect( + defaultTimezoneIsBetweenTextingHours(makeConfig(null, null, true)) + ).toBeTruthy(); + }); + + it("returns false if time is 11:00 EST", () => { + MockDate.set("2018-02-01T11:00:00.000-05:00"); + expect( + defaultTimezoneIsBetweenTextingHours(makeConfig(null, null, true)) + ).toBeFalsy(); + }); + + it("returns false if time is 20:00 EST", () => { + MockDate.set("2018-02-01T20:00:00.000-05:00"); + expect( + defaultTimezoneIsBetweenTextingHours(makeConfig(null, null, true)) + ).toBeTruthy(); + }); + + it("returns false if time is 21:00 EST", () => { + MockDate.set("2018-02-01T21:00:00.000-05:00"); + expect( + defaultTimezoneIsBetweenTextingHours(makeConfig(null, null, true)) + ).toBeFalsy(); + }); +}); + +describe("test convertOffsetsToStrings", () => { + it("works", () => { + let test_offsets = [[1, true], [2, false], [-1, true]]; + let strings_returned = convertOffsetsToStrings(test_offsets); + expect(strings_returned).toHaveLength(3); + expect(strings_returned[0]).toBe("1_1"); + expect(strings_returned[1]).toBe("2_0"); + expect(strings_returned[2]).toBe("-1_1"); + }); +}); + +describe("test getOffsets", () => { + afterEach(() => { + MockDate.reset(); + }); + + it("works during daylight-savings time", () => { + MockDate.set("2018-07-21T17:00:00.000Z"); + let offsets_returned = getOffsets(makeConfig(10, 12, true)); + expect(offsets_returned).toHaveLength(2); + + let valid_offsets_returned = offsets_returned[0]; + expect(valid_offsets_returned).toHaveLength(4); + expect(valid_offsets_returned[0]).toBe("-7_1"); + expect(valid_offsets_returned[1]).toBe("-8_1"); + expect(valid_offsets_returned[2]).toBe("-6_0"); + expect(valid_offsets_returned[3]).toBe("-7_0"); + + let invalid_offsets_returned = offsets_returned[1]; + expect(invalid_offsets_returned).toHaveLength(14); + expect(invalid_offsets_returned[0]).toBe("-4_1"); + expect(invalid_offsets_returned[1]).toBe("-5_1"); + expect(invalid_offsets_returned[2]).toBe("-6_1"); + expect(invalid_offsets_returned[3]).toBe("-9_1"); + expect(invalid_offsets_returned[4]).toBe("-10_1"); + expect(invalid_offsets_returned[5]).toBe("-11_1"); + expect(invalid_offsets_returned[6]).toBe("10_1"); + expect(invalid_offsets_returned[7]).toBe("-4_0"); + expect(invalid_offsets_returned[8]).toBe("-5_0"); + expect(invalid_offsets_returned[9]).toBe("-8_0"); + expect(invalid_offsets_returned[10]).toBe("-9_0"); + expect(invalid_offsets_returned[11]).toBe("-10_0"); + expect(invalid_offsets_returned[12]).toBe("-11_0"); + expect(invalid_offsets_returned[13]).toBe("10_0"); + }); + + it("works during standard time", () => { + MockDate.set("2018-02-01T17:00:00.000Z"); + let offsets_returned = getOffsets(makeConfig(10, 12, true)); + expect(offsets_returned).toHaveLength(2); + + let valid_offsets_returned = offsets_returned[0]; + expect(valid_offsets_returned).toHaveLength(4); + expect(valid_offsets_returned[0]).toBe("-6_1"); + expect(valid_offsets_returned[1]).toBe("-7_1"); + expect(valid_offsets_returned[2]).toBe("-6_0"); + expect(valid_offsets_returned[3]).toBe("-7_0"); + + let invalid_offsets_returned = offsets_returned[1]; + expect(invalid_offsets_returned).toHaveLength(14); + expect(invalid_offsets_returned[0]).toBe("-4_1"); + expect(invalid_offsets_returned[1]).toBe("-5_1"); + expect(invalid_offsets_returned[2]).toBe("-8_1"); + expect(invalid_offsets_returned[3]).toBe("-9_1"); + expect(invalid_offsets_returned[4]).toBe("-10_1"); + expect(invalid_offsets_returned[5]).toBe("-11_1"); + expect(invalid_offsets_returned[6]).toBe("10_1"); + expect(invalid_offsets_returned[7]).toBe("-4_0"); + expect(invalid_offsets_returned[8]).toBe("-5_0"); + expect(invalid_offsets_returned[9]).toBe("-8_0"); + expect(invalid_offsets_returned[10]).toBe("-9_0"); + expect(invalid_offsets_returned[11]).toBe("-10_0"); + expect(invalid_offsets_returned[12]).toBe("-11_0"); + expect(invalid_offsets_returned[13]).toBe("10_0"); + }); +}); + +describe("test getContactTimezone", () => { + var tzHelpers = require("../../src/lib/tz-helpers"); afterEach(() => { - jest.resetAllMocks() - }) + jest.resetAllMocks(); + }); - it('returns the location if one is supplied', () => { - let location = makeLocationWithOnlyTimezoneData(7, true) - expect(getContactTimezone({}, location)).toEqual(location) + it("returns the location if one is supplied", () => { + let location = makeLocationWithOnlyTimezoneData(7, true); + expect(getContactTimezone({}, location)).toEqual(location); - location = makeLocationWithOnlyTimezoneData(9, false) - expect(getContactTimezone({}, location)).toEqual(location) - }) + location = makeLocationWithOnlyTimezoneData(9, false); + expect(getContactTimezone({}, location)).toEqual(location); + }); - it('uses campaign.timezone if no location is supplied and the campaign overrides', () => { + it("uses campaign.timezone if no location is supplied and the campaign overrides", () => { expect( getContactTimezone( makeCampaignOnlyWithTextingHoursConfigFields( @@ -520,7 +664,7 @@ describe('test getContactTimezone', () => { true, 14, 16, - 'America/New_York' + "America/New_York" ), {} ) @@ -529,286 +673,309 @@ describe('test getContactTimezone', () => { offset: -5, hasDST: true } - }) - }) - -}) - -describe('test isBetweenTextingHours with offset data supplied', () => { - var offsetData = {offset: -8, hasDST: true} - var tzHelpers = require('../../src/lib/tz-helpers') - beforeAll(() => { - jest.doMock('../../src/lib/tz-helpers') - tzHelpers.getProcessEnvTz.mockImplementation(() => null) - MockDate.set('2018-02-01T12:00:00.000-08:00') - }) - - afterAll(() => { - jest.restoreAllMocks(); - MockDate.reset() - }) - - it('returns true if texting hours are not enforced', () => { - expect(isBetweenTextingHours(offsetData, makeConfig(null, null, false))).toBeTruthy() - }) - - it('returns false if texting hours are 05-07 and time is 12:00', () => { - expect(isBetweenTextingHours(offsetData, makeConfig(5, 7, true))).toBeFalsy() - } - ) + }); + }); +}); - it('returns false if texting hours are 14-21 and time is 12:00', () => { - expect(isBetweenTextingHours(offsetData, makeConfig(14, 21, true))).toBeFalsy() - } - ) +describe("test isBetweenTextingHours with offset data supplied", () => { + var offsetData = { offset: -8, hasDST: true }; + var tzHelpers = require("../../src/lib/tz-helpers"); + beforeAll(() => { + jest.doMock("../../src/lib/tz-helpers"); + tzHelpers.getProcessEnvTz.mockImplementation(() => null); + MockDate.set("2018-02-01T12:00:00.000-08:00"); + }); - it('returns true if texting hours are 10-21 and time is 12:00', () => { - expect(isBetweenTextingHours(offsetData, makeConfig(10, 21, true))).toBeTruthy() - }) - - it('returns true if texting hours are 12-21 and time is 12:00', () => { - expect(isBetweenTextingHours(offsetData, makeConfig(12, 21, true))).toBeTruthy() - }) - - it('returns true if texting hours are 10-12 and time is 12:00', () => { - expect(isBetweenTextingHours(offsetData, makeConfig(10, 12, true))).toBeFalsy() - }) - - it('returns true if texting hours are 10-11 and time is 12:00', () => { - expect(isBetweenTextingHours(offsetData, makeConfig(10, 13, true))).toBeTruthy() - }) - } -) - -describe('test isBetweenTextingHours with offset data empty', () => { - var offsetData = {offset: null, hasDST: null} - var tzHelpers = require('../../src/lib/tz-helpers') - beforeAll(() => { - tzHelpers.getProcessEnvTz.mockImplementation(() => null) - }) - - afterEach(() => { - MockDate.reset() - }) - - afterAll(() => { - jest.restoreAllMocks(); - }) - - it('returns true if texting hours are not enforced', () => { - expect(isBetweenTextingHours(offsetData, makeConfig(null, null, false))).toBeTruthy() - }) - - it('returns false if texting hours are for MISSING TIME ZONE and time is 12:00 EST', () => { - MockDate.set('2018-02-01T12:00:00.000-05:00') - expect(isBetweenTextingHours(offsetData, makeConfig(null, null, true))).toBeTruthy() - } - ) + afterAll(() => { + jest.restoreAllMocks(); + MockDate.reset(); + }); - it('returns false if texting hours are for MISSING TIME ZONE and time is 11:00 EST', () => { - MockDate.set('2018-02-01T11:00:00.000-05:00') - expect(isBetweenTextingHours(offsetData, makeConfig(null, null, true))).toBeFalsy() - } - ) + it("returns true if texting hours are not enforced", () => { + expect( + isBetweenTextingHours(offsetData, makeConfig(null, null, false)) + ).toBeTruthy(); + }); - it('returns false if texting hours are for MISSING TIME ZONE and time is 20:00 EST', () => { - MockDate.set('2018-02-01T20:00:00.000-05:00') - expect(isBetweenTextingHours(offsetData, makeConfig(null, null, true))).toBeTruthy() - } - ) + it("returns false if texting hours are 05-07 and time is 12:00", () => { + expect( + isBetweenTextingHours(offsetData, makeConfig(5, 7, true)) + ).toBeFalsy(); + }); - it('returns false if texting hours are for MISSING TIME ZONE and time is 21:00 EST', () => { - MockDate.set('2018-02-01T21:00:00.000-05:00') - expect(isBetweenTextingHours(offsetData, makeConfig(null, null, true))).toBeFalsy() - } - ) - } -) - -describe('test isBetweenTextingHours with offset data NOT supplied', () => { - var tzHelpers = require('../../src/lib/tz-helpers') - beforeAll(() => { - tzHelpers.getProcessEnvTz.mockImplementation(() => null) - }) - - afterEach(() => { - MockDate.reset() - }) - - afterAll(() => { - jest.restoreAllMocks(); - }) - - it('returns true if texting hours are not enforced', () => { - expect(isBetweenTextingHours(null, makeConfig(null, null, false))).toBeTruthy() - }) - - it('returns false if texting hours are for MISSING TIME ZONE and time is 12:00 EST', () => { - MockDate.set('2018-02-01T12:00:00.000-05:00') - expect(isBetweenTextingHours(null, makeConfig(null, null, true))).toBeTruthy() - } - ) + it("returns false if texting hours are 14-21 and time is 12:00", () => { + expect( + isBetweenTextingHours(offsetData, makeConfig(14, 21, true)) + ).toBeFalsy(); + }); - it('returns false if texting hours are for MISSING TIME ZONE and time is 11:00 EST', () => { - MockDate.set('2018-02-01T11:00:00.000-05:00') - expect(isBetweenTextingHours(null, makeConfig(null, null, true))).toBeFalsy() - } - ) + it("returns true if texting hours are 10-21 and time is 12:00", () => { + expect( + isBetweenTextingHours(offsetData, makeConfig(10, 21, true)) + ).toBeTruthy(); + }); - it('returns false if texting hours are for MISSING TIME ZONE and time is 20:00 EST', () => { - MockDate.set('2018-02-01T20:00:00.000-05:00') - expect(isBetweenTextingHours(null, makeConfig(null, null, true))).toBeTruthy() - } - ) + it("returns true if texting hours are 12-21 and time is 12:00", () => { + expect( + isBetweenTextingHours(offsetData, makeConfig(12, 21, true)) + ).toBeTruthy(); + }); - it('returns false if texting hours are for MISSING TIME ZONE and time is 21:00 EST', () => { - MockDate.set('2018-02-01T21:00:00.000-05:00') - expect(isBetweenTextingHours(null, makeConfig(null, null, true))).toBeFalsy() - } - ) - } -) + it("returns true if texting hours are 10-12 and time is 12:00", () => { + expect( + isBetweenTextingHours(offsetData, makeConfig(10, 12, true)) + ).toBeFalsy(); + }); + it("returns true if texting hours are 10-11 and time is 12:00", () => { + expect( + isBetweenTextingHours(offsetData, makeConfig(10, 13, true)) + ).toBeTruthy(); + }); +}); + +describe("test isBetweenTextingHours with offset data empty", () => { + var offsetData = { offset: null, hasDST: null }; + var tzHelpers = require("../../src/lib/tz-helpers"); + beforeAll(() => { + tzHelpers.getProcessEnvTz.mockImplementation(() => null); + }); -describe('test defaultTimezoneIsBetweenTextingHours', () => { - var tzHelpers = require('../../src/lib/tz-helpers') - beforeAll(() => { - tzHelpers.getProcessEnvTz.mockImplementation(() => null) - jest.doMock('../../src/lib/tz-helpers') - }) + afterEach(() => { + MockDate.reset(); + }); - afterEach(() => { - MockDate.reset() - }) + afterAll(() => { + jest.restoreAllMocks(); + }); - afterAll(() => { - jest.restoreAllMocks(); - }) + it("returns true if texting hours are not enforced", () => { + expect( + isBetweenTextingHours(offsetData, makeConfig(null, null, false)) + ).toBeTruthy(); + }); - it('returns true if texting hours are not enforced', () => { - expect(defaultTimezoneIsBetweenTextingHours(makeConfig(null, null, false))).toBeTruthy() - }) + it("returns false if texting hours are for MISSING TIME ZONE and time is 12:00 EST", () => { + MockDate.set("2018-02-01T12:00:00.000-05:00"); + expect( + isBetweenTextingHours(offsetData, makeConfig(null, null, true)) + ).toBeTruthy(); + }); - it('returns false if time is 12:00 EST', () => { - MockDate.set('2018-02-01T12:00:00.000-05:00') - expect(defaultTimezoneIsBetweenTextingHours(makeConfig(null, null, true))).toBeTruthy() - } - ) + it("returns false if texting hours are for MISSING TIME ZONE and time is 11:00 EST", () => { + MockDate.set("2018-02-01T11:00:00.000-05:00"); + expect( + isBetweenTextingHours(offsetData, makeConfig(null, null, true)) + ).toBeFalsy(); + }); - it('returns false if time is 11:00 EST', () => { - MockDate.set('2018-02-01T11:00:00.000-05:00') - expect(defaultTimezoneIsBetweenTextingHours(makeConfig(null, null, true))).toBeFalsy() - } - ) + it("returns false if texting hours are for MISSING TIME ZONE and time is 20:00 EST", () => { + MockDate.set("2018-02-01T20:00:00.000-05:00"); + expect( + isBetweenTextingHours(offsetData, makeConfig(null, null, true)) + ).toBeTruthy(); + }); - it('returns false if time is 20:00 EST', () => { - MockDate.set('2018-02-01T20:00:00.000-05:00') - expect(defaultTimezoneIsBetweenTextingHours(makeConfig(null, null, true))).toBeTruthy() - } - ) + it("returns false if texting hours are for MISSING TIME ZONE and time is 21:00 EST", () => { + MockDate.set("2018-02-01T21:00:00.000-05:00"); + expect( + isBetweenTextingHours(offsetData, makeConfig(null, null, true)) + ).toBeFalsy(); + }); +}); + +describe("test isBetweenTextingHours with offset data NOT supplied", () => { + var tzHelpers = require("../../src/lib/tz-helpers"); + beforeAll(() => { + tzHelpers.getProcessEnvTz.mockImplementation(() => null); + }); - it('returns false if time is 21:00 EST', () => { - MockDate.set('2018-02-01T21:00:00.000-05:00') - expect(defaultTimezoneIsBetweenTextingHours(makeConfig(null, null, true))).toBeFalsy() - } - ) - } -) - -describe('test convertOffsetsToStrings', () => { - it('works', () => { - let test_offsets = [[1, true], [2, false], [-1, true]] - let strings_returned = convertOffsetsToStrings(test_offsets) - expect(strings_returned).toHaveLength(3) - expect(strings_returned[0]).toBe('1_1') - expect(strings_returned[1]).toBe('2_0') - expect(strings_returned[2]).toBe('-1_1') - } - ) -}) - -describe('test getOffsets', () => { afterEach(() => { - MockDate.reset() - }) - - it('works during daylight-savings time', () => { - MockDate.set('2018-07-21T17:00:00.000Z') - let offsets_returned = getOffsets(makeConfig(10, 12, true)) - expect(offsets_returned).toHaveLength(2) - - let valid_offsets_returned = offsets_returned[0] - expect(valid_offsets_returned).toHaveLength(4) - expect(valid_offsets_returned[0]).toBe('-7_1') - expect(valid_offsets_returned[1]).toBe('-8_1') - expect(valid_offsets_returned[2]).toBe('-6_0') - expect(valid_offsets_returned[3]).toBe('-7_0') - - let invalid_offsets_returned = offsets_returned[1] - expect(invalid_offsets_returned).toHaveLength(14) - expect(invalid_offsets_returned[0]).toBe('-4_1') - expect(invalid_offsets_returned[1]).toBe('-5_1') - expect(invalid_offsets_returned[2]).toBe('-6_1') - expect(invalid_offsets_returned[3]).toBe('-9_1') - expect(invalid_offsets_returned[4]).toBe('-10_1') - expect(invalid_offsets_returned[5]).toBe('-11_1') - expect(invalid_offsets_returned[6]).toBe('10_1') - expect(invalid_offsets_returned[7]).toBe('-4_0') - expect(invalid_offsets_returned[8]).toBe('-5_0') - expect(invalid_offsets_returned[9]).toBe('-8_0') - expect(invalid_offsets_returned[10]).toBe('-9_0') - expect(invalid_offsets_returned[11]).toBe('-10_0') - expect(invalid_offsets_returned[12]).toBe('-11_0') - expect(invalid_offsets_returned[13]).toBe('10_0') - }) - - it('works during standard time', () => { - MockDate.set('2018-02-01T17:00:00.000Z') - let offsets_returned = getOffsets(makeConfig(10, 12, true)) - expect(offsets_returned).toHaveLength(2) - - let valid_offsets_returned = offsets_returned[0] - expect(valid_offsets_returned).toHaveLength(4) - expect(valid_offsets_returned[0]).toBe('-6_1') - expect(valid_offsets_returned[1]).toBe('-7_1') - expect(valid_offsets_returned[2]).toBe('-6_0') - expect(valid_offsets_returned[3]).toBe('-7_0') - - let invalid_offsets_returned = offsets_returned[1] - expect(invalid_offsets_returned).toHaveLength(14) - expect(invalid_offsets_returned[0]).toBe('-4_1') - expect(invalid_offsets_returned[1]).toBe('-5_1') - expect(invalid_offsets_returned[2]).toBe('-8_1') - expect(invalid_offsets_returned[3]).toBe('-9_1') - expect(invalid_offsets_returned[4]).toBe('-10_1') - expect(invalid_offsets_returned[5]).toBe('-11_1') - expect(invalid_offsets_returned[6]).toBe('10_1') - expect(invalid_offsets_returned[7]).toBe('-4_0') - expect(invalid_offsets_returned[8]).toBe('-5_0') - expect(invalid_offsets_returned[9]).toBe('-8_0') - expect(invalid_offsets_returned[10]).toBe('-9_0') - expect(invalid_offsets_returned[11]).toBe('-10_0') - expect(invalid_offsets_returned[12]).toBe('-11_0') - expect(invalid_offsets_returned[13]).toBe('10_0') - }) -}) - -describe('test getContactTimezone', () => { - var tzHelpers = require('../../src/lib/tz-helpers') + MockDate.reset(); + }); + afterAll(() => { + jest.restoreAllMocks(); + }); + + it("returns true if texting hours are not enforced", () => { + expect( + isBetweenTextingHours(null, makeConfig(null, null, false)) + ).toBeTruthy(); + }); + + it("returns false if texting hours are for MISSING TIME ZONE and time is 12:00 EST", () => { + MockDate.set("2018-02-01T12:00:00.000-05:00"); + expect( + isBetweenTextingHours(null, makeConfig(null, null, true)) + ).toBeTruthy(); + }); + + it("returns false if texting hours are for MISSING TIME ZONE and time is 11:00 EST", () => { + MockDate.set("2018-02-01T11:00:00.000-05:00"); + expect( + isBetweenTextingHours(null, makeConfig(null, null, true)) + ).toBeFalsy(); + }); + + it("returns false if texting hours are for MISSING TIME ZONE and time is 20:00 EST", () => { + MockDate.set("2018-02-01T20:00:00.000-05:00"); + expect( + isBetweenTextingHours(null, makeConfig(null, null, true)) + ).toBeTruthy(); + }); + + it("returns false if texting hours are for MISSING TIME ZONE and time is 21:00 EST", () => { + MockDate.set("2018-02-01T21:00:00.000-05:00"); + expect( + isBetweenTextingHours(null, makeConfig(null, null, true)) + ).toBeFalsy(); + }); +}); + +describe("test defaultTimezoneIsBetweenTextingHours", () => { + var tzHelpers = require("../../src/lib/tz-helpers"); + beforeAll(() => { + tzHelpers.getProcessEnvTz.mockImplementation(() => null); + jest.doMock("../../src/lib/tz-helpers"); + }); + + afterEach(() => { + MockDate.reset(); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + it("returns true if texting hours are not enforced", () => { + expect( + defaultTimezoneIsBetweenTextingHours(makeConfig(null, null, false)) + ).toBeTruthy(); + }); + + it("returns false if time is 12:00 EST", () => { + MockDate.set("2018-02-01T12:00:00.000-05:00"); + expect( + defaultTimezoneIsBetweenTextingHours(makeConfig(null, null, true)) + ).toBeTruthy(); + }); + + it("returns false if time is 11:00 EST", () => { + MockDate.set("2018-02-01T11:00:00.000-05:00"); + expect( + defaultTimezoneIsBetweenTextingHours(makeConfig(null, null, true)) + ).toBeFalsy(); + }); + + it("returns false if time is 20:00 EST", () => { + MockDate.set("2018-02-01T20:00:00.000-05:00"); + expect( + defaultTimezoneIsBetweenTextingHours(makeConfig(null, null, true)) + ).toBeTruthy(); + }); + + it("returns false if time is 21:00 EST", () => { + MockDate.set("2018-02-01T21:00:00.000-05:00"); + expect( + defaultTimezoneIsBetweenTextingHours(makeConfig(null, null, true)) + ).toBeFalsy(); + }); +}); + +describe("test convertOffsetsToStrings", () => { + it("works", () => { + let test_offsets = [[1, true], [2, false], [-1, true]]; + let strings_returned = convertOffsetsToStrings(test_offsets); + expect(strings_returned).toHaveLength(3); + expect(strings_returned[0]).toBe("1_1"); + expect(strings_returned[1]).toBe("2_0"); + expect(strings_returned[2]).toBe("-1_1"); + }); +}); + +describe("test getOffsets", () => { afterEach(() => { - jest.resetAllMocks() - }) + MockDate.reset(); + }); + + it("works during daylight-savings time", () => { + MockDate.set("2018-07-21T17:00:00.000Z"); + let offsets_returned = getOffsets(makeConfig(10, 12, true)); + expect(offsets_returned).toHaveLength(2); + + let valid_offsets_returned = offsets_returned[0]; + expect(valid_offsets_returned).toHaveLength(4); + expect(valid_offsets_returned[0]).toBe("-7_1"); + expect(valid_offsets_returned[1]).toBe("-8_1"); + expect(valid_offsets_returned[2]).toBe("-6_0"); + expect(valid_offsets_returned[3]).toBe("-7_0"); + + let invalid_offsets_returned = offsets_returned[1]; + expect(invalid_offsets_returned).toHaveLength(14); + expect(invalid_offsets_returned[0]).toBe("-4_1"); + expect(invalid_offsets_returned[1]).toBe("-5_1"); + expect(invalid_offsets_returned[2]).toBe("-6_1"); + expect(invalid_offsets_returned[3]).toBe("-9_1"); + expect(invalid_offsets_returned[4]).toBe("-10_1"); + expect(invalid_offsets_returned[5]).toBe("-11_1"); + expect(invalid_offsets_returned[6]).toBe("10_1"); + expect(invalid_offsets_returned[7]).toBe("-4_0"); + expect(invalid_offsets_returned[8]).toBe("-5_0"); + expect(invalid_offsets_returned[9]).toBe("-8_0"); + expect(invalid_offsets_returned[10]).toBe("-9_0"); + expect(invalid_offsets_returned[11]).toBe("-10_0"); + expect(invalid_offsets_returned[12]).toBe("-11_0"); + expect(invalid_offsets_returned[13]).toBe("10_0"); + }); + + it("works during standard time", () => { + MockDate.set("2018-02-01T17:00:00.000Z"); + let offsets_returned = getOffsets(makeConfig(10, 12, true)); + expect(offsets_returned).toHaveLength(2); + + let valid_offsets_returned = offsets_returned[0]; + expect(valid_offsets_returned).toHaveLength(4); + expect(valid_offsets_returned[0]).toBe("-6_1"); + expect(valid_offsets_returned[1]).toBe("-7_1"); + expect(valid_offsets_returned[2]).toBe("-6_0"); + expect(valid_offsets_returned[3]).toBe("-7_0"); + + let invalid_offsets_returned = offsets_returned[1]; + expect(invalid_offsets_returned).toHaveLength(14); + expect(invalid_offsets_returned[0]).toBe("-4_1"); + expect(invalid_offsets_returned[1]).toBe("-5_1"); + expect(invalid_offsets_returned[2]).toBe("-8_1"); + expect(invalid_offsets_returned[3]).toBe("-9_1"); + expect(invalid_offsets_returned[4]).toBe("-10_1"); + expect(invalid_offsets_returned[5]).toBe("-11_1"); + expect(invalid_offsets_returned[6]).toBe("10_1"); + expect(invalid_offsets_returned[7]).toBe("-4_0"); + expect(invalid_offsets_returned[8]).toBe("-5_0"); + expect(invalid_offsets_returned[9]).toBe("-8_0"); + expect(invalid_offsets_returned[10]).toBe("-9_0"); + expect(invalid_offsets_returned[11]).toBe("-10_0"); + expect(invalid_offsets_returned[12]).toBe("-11_0"); + expect(invalid_offsets_returned[13]).toBe("10_0"); + }); +}); + +describe("test getContactTimezone", () => { + var tzHelpers = require("../../src/lib/tz-helpers"); - it('returns the location if one is supplied', () => { - let location = makeLocationWithOnlyTimezoneData(7, true) - expect(getContactTimezone({}, location)).toEqual(location) + afterEach(() => { + jest.resetAllMocks(); + }); - location = makeLocationWithOnlyTimezoneData(9, false) - expect(getContactTimezone({}, location)).toEqual(location) - }) + it("returns the location if one is supplied", () => { + let location = makeLocationWithOnlyTimezoneData(7, true); + expect(getContactTimezone({}, location)).toEqual(location); - it('uses campaign.timezone if no location is supplied and the campaign overrides', () => { + location = makeLocationWithOnlyTimezoneData(9, false); + expect(getContactTimezone({}, location)).toEqual(location); + }); + + it("uses campaign.timezone if no location is supplied and the campaign overrides", () => { expect( getContactTimezone( makeCampaignOnlyWithTextingHoursConfigFields( @@ -816,7 +983,7 @@ describe('test getContactTimezone', () => { true, 14, 16, - 'America/New_York' + "America/New_York" ), {} ) @@ -825,11 +992,11 @@ describe('test getContactTimezone', () => { offset: -5, hasDST: true } - }) - }) + }); + }); it("uses TZ if no location is supplied, and the campaign doesn't override, and TZ exists in the environment", () => { - tzHelpers.getProcessEnvTz.mockImplementation(() => 'America/Boise') + tzHelpers.getProcessEnvTz.mockImplementation(() => "America/Boise"); expect( getContactTimezone( makeCampaignOnlyWithTextingHoursConfigFields( @@ -837,7 +1004,7 @@ describe('test getContactTimezone', () => { true, 14, 16, - 'America/New_York' + "America/New_York" ), {} ) @@ -846,8 +1013,8 @@ describe('test getContactTimezone', () => { offset: -7, hasDST: true } - }) - }) + }); + }); it("uses TIMEZONE_CONFIG.missingTimeZone if no location is supplied, and the campaign doesn't override, and TZ is not in the environment", () => { expect( @@ -857,7 +1024,7 @@ describe('test getContactTimezone', () => { true, 14, 16, - 'America/New_York' + "America/New_York" ), {} ) @@ -866,152 +1033,223 @@ describe('test getContactTimezone', () => { offset: -5, hasDST: true } - }) - }) -}) - + }); + }); +}); -describe('test getUtcFromOffsetAndHour', () => { +describe("test getUtcFromOffsetAndHour", () => { afterEach(() => { - MockDate.reset() - }) - - it('returns the correct UTC during northern hemisphere summer', () => { - MockDate.set('2018-07-01T11:00:00.000-05:00') - expect(getUtcFromOffsetAndHour(-5, true, 12, 'America/New_York').unix()).toEqual(moment('2018-07-01T16:00:00.000Z').unix()) - }) + MockDate.reset(); + }); - it('returns the correct UTC during northern hemisphere summer with result being next day', () => { - MockDate.set('2018-07-01T11:00:00.000-05:00') - expect(getUtcFromOffsetAndHour(-5, true, 23, 'America/New_York').unix()).toEqual(moment('2018-07-02T03:00:00.000Z').unix()) - }) + it("returns the correct UTC during northern hemisphere summer", () => { + MockDate.set("2018-07-01T11:00:00.000-05:00"); + expect( + getUtcFromOffsetAndHour(-5, true, 12, "America/New_York").unix() + ).toEqual(moment("2018-07-01T16:00:00.000Z").unix()); + }); - it('returns the correct UTC during northern hemisphere winter', () => { - MockDate.set('2018-02-01T11:00:00.000-05:00') - expect(getUtcFromOffsetAndHour(-5, true, 12, 'America/New_York').unix()).toEqual(moment('2018-02-01T17:00:00.000Z').unix()) + it("returns the correct UTC during northern hemisphere summer with result being next day", () => { + MockDate.set("2018-07-01T11:00:00.000-05:00"); + expect( + getUtcFromOffsetAndHour(-5, true, 23, "America/New_York").unix() + ).toEqual(moment("2018-07-02T03:00:00.000Z").unix()); + }); - }) + it("returns the correct UTC during northern hemisphere winter", () => { + MockDate.set("2018-02-01T11:00:00.000-05:00"); + expect( + getUtcFromOffsetAndHour(-5, true, 12, "America/New_York").unix() + ).toEqual(moment("2018-02-01T17:00:00.000Z").unix()); + }); - it('returns the correct UTC during northern hemisphere summer if offset doesn\'t have DST', () => { - MockDate.set('2018-07-01T11:00:00.000-05:00') - expect(getUtcFromOffsetAndHour(-5, false, 12, 'America/New_York').unix()).toEqual(moment('2018-07-01T17:00:00.000Z').unix()) - }) + it("returns the correct UTC during northern hemisphere summer if offset doesn't have DST", () => { + MockDate.set("2018-07-01T11:00:00.000-05:00"); + expect( + getUtcFromOffsetAndHour(-5, false, 12, "America/New_York").unix() + ).toEqual(moment("2018-07-01T17:00:00.000Z").unix()); + }); - it('returns the correct UTC during northern hemisphere winter if offset doesn\'t have DST', () => { - MockDate.set('2018-02-01T11:00:00.000-05:00') - expect(getUtcFromOffsetAndHour(-5, false, 12, 'America/New_York').unix()).toEqual(moment('2018-02-01T17:00:00.000Z').unix()) - }) -}) + it("returns the correct UTC during northern hemisphere winter if offset doesn't have DST", () => { + MockDate.set("2018-02-01T11:00:00.000-05:00"); + expect( + getUtcFromOffsetAndHour(-5, false, 12, "America/New_York").unix() + ).toEqual(moment("2018-02-01T17:00:00.000Z").unix()); + }); +}); -describe('test getUtcFromTimezoneAndHour', () => { +describe("test getUtcFromTimezoneAndHour", () => { afterEach(() => { - MockDate.reset() - }) + MockDate.reset(); + }); + + it("returns the correct UTC during northern hemisphere summer", () => { + MockDate.set("2018-07-01T11:00:00.000-05:00"); + expect(getUtcFromTimezoneAndHour("America/New_York", 12).unix()).toEqual( + moment("2018-07-01T16:00:00.000Z").unix() + ); + }); + + it("returns the correct UTC during northern hemisphere summer with result being next day", () => { + MockDate.set("2018-07-01T11:00:00.000-05:00"); + expect(getUtcFromTimezoneAndHour("America/New_York", 23).unix()).toEqual( + moment("2018-07-02T03:00:00.000Z").unix() + ); + }); + + it("returns the correct UTC during northern hemisphere winter", () => { + MockDate.set("2018-02-01T11:00:00.000-05:00"); + expect(getUtcFromTimezoneAndHour("America/New_York", 12).unix()).toEqual( + moment("2018-02-01T17:00:00.000Z").unix() + ); + }); + + it("returns the correct UTC during northern hemisphere summer if timezone doesn't have DST", () => { + MockDate.set("2018-07-01T11:00:00.000-05:00"); + expect(getUtcFromTimezoneAndHour("US/Arizona", 12).unix()).toEqual( + moment("2018-07-01T19:00:00.000Z").unix() + ); + }); + + it("returns the correct UTC during northern hemisphere winter if timezone doesn't have DST", () => { + MockDate.set("2018-02-01T11:00:00.000-05:00"); + expect(getUtcFromTimezoneAndHour("US/Arizona", 12).unix()).toEqual( + moment("2018-02-01T19:00:00.000Z").unix() + ); + }); +}); + +describe("test getSendBeforeTimewUtc", () => { + const tzHelpers = require("../../src/lib/tz-helpers"); - it('returns the correct UTC during northern hemisphere summer', () => { - MockDate.set('2018-07-01T11:00:00.000-05:00') - expect(getUtcFromTimezoneAndHour('America/New_York', 12).unix()).toEqual(moment('2018-07-01T16:00:00.000Z').unix()) - }) - - it('returns the correct UTC during northern hemisphere summer with result being next day', () => { - MockDate.set('2018-07-01T11:00:00.000-05:00') - expect(getUtcFromTimezoneAndHour('America/New_York', 23).unix()).toEqual(moment('2018-07-02T03:00:00.000Z').unix()) - }) + beforeAll(() => { + MockDate.set("2018-09-03T11:00:00.000-05:00"); + }); - it('returns the correct UTC during northern hemisphere winter', () => { - MockDate.set('2018-02-01T11:00:00.000-05:00') - expect(getUtcFromTimezoneAndHour('America/New_York', 12).unix()).toEqual(moment('2018-02-01T17:00:00.000Z').unix()) + afterEach(() => { + jest.restoreAllMocks(); + }); - }) + afterAll(() => { + MockDate.reset(); + }); - it('returns the correct UTC during northern hemisphere summer if timezone doesn\'t have DST', () => { - MockDate.set('2018-07-01T11:00:00.000-05:00') - expect(getUtcFromTimezoneAndHour('US/Arizona', 12).unix()).toEqual(moment('2018-07-01T19:00:00.000Z').unix()) - }) + it("returns undefined if campaign overrides and texting hours are not enforced", () => { + expect( + getSendBeforeTimeUtc( + {}, + {}, + { overrideOrganizationTextingHours: true, textingHoursEnforced: false } + ) + ).toBeNull(); + }); - it('returns the correct UTC during northern hemisphere winter if timezone doesn\'t have DST', () => { - MockDate.set('2018-02-01T11:00:00.000-05:00') - expect(getUtcFromTimezoneAndHour('US/Arizona', 12).unix()).toEqual(moment('2018-02-01T19:00:00.000Z').unix()) - }) -}) + it("returns undefined if campaign does not override and texting hours are not enforced", () => { + expect( + getSendBeforeTimeUtc( + {}, + { + textingHoursStart: 9, + textingHoursEnd: 21, + textingHoursEnforced: false + }, + {} + ) + ).toBeNull(); + }); -describe('test getSendBeforeTimewUtc', () => { - const tzHelpers = require('../../src/lib/tz-helpers') + it("returns correct time if campaign overrides and contact offset is supplied", () => { + expect( + getSendBeforeTimeUtc( + { offset: -5, hasDST: 1 }, + { + textingHoursStart: 9, + textingHoursEnd: 21, + textingHoursEnforced: true + }, + { + overrideOrganizationTextingHours: true, + textingHoursEnforced: true, + textingHoursEnd: 21, + timezone: "America/New_York" + } + ).unix() + ).toEqual(moment("2018-09-04T01:00:00.000Z").unix()); + }); + + it("returns correct time if campaign overrides and contact offset is not supplied", () => { + expect( + getSendBeforeTimeUtc( + {}, + { + textingHoursStart: 9, + textingHoursEnd: 21, + textingHoursEnforced: true + }, + { + overrideOrganizationTextingHours: true, + textingHoursEnforced: true, + textingHoursEnd: 21, + timezone: "America/New_York" + } + ).unix() + ).toEqual(moment("2018-09-04T01:00:00.000Z").unix()); + }); + + it("returns correct time if campaign does not override and TZ is set", () => { + tzHelpers.getProcessEnvTz.mockImplementation(() => "America/New_York"); + expect( + getSendBeforeTimeUtc( + {}, + { + textingHoursStart: 9, + textingHoursEnd: 21, + textingHoursEnforced: true + }, + {} + ).unix() + ).toEqual(moment("2018-09-04T01:00:00.000Z").unix()); + }); - beforeAll(() => { - MockDate.set('2018-09-03T11:00:00.000-05:00') - }) + it("returns correct time if campaign does not override and TZ is not set and contact offset is supplied", () => { + expect( + getSendBeforeTimeUtc( + { offset: -5, hasDST: 1 }, + { + textingHoursStart: 9, + textingHoursEnd: 21, + textingHoursEnforced: true + }, + {} + ).unix() + ).toEqual(moment("2018-09-04T01:00:00.000Z").unix()); + }); - afterEach(() => { - jest.restoreAllMocks(); - }) + it("returns correct time if campaign does not override and TZ is not set and contact offset is not supplied", () => { + expect( + getSendBeforeTimeUtc( + {}, + { + textingHoursStart: 9, + textingHoursEnd: 21, + textingHoursEnforced: true + }, + {} + ).unix() + ).toEqual(moment("2018-09-04T01:00:00.000Z").unix()); + }); - afterAll(() => { - MockDate.reset() - }) - - it('returns undefined if campaign overrides and texting hours are not enforced', () => { - expect(getSendBeforeTimeUtc( - {}, - {}, - { overrideOrganizationTextingHours: true, textingHoursEnforced: false} - )).toBeNull() - }) - - it('returns undefined if campaign does not override and texting hours are not enforced', () => { - expect(getSendBeforeTimeUtc( - {}, - { textingHoursStart: 9, textingHoursEnd: 21, textingHoursEnforced: false}, - {} - )).toBeNull() - }) - - it('returns correct time if campaign overrides and contact offset is supplied', () => { - expect(getSendBeforeTimeUtc( - { offset: -5, hasDST: 1 }, - { textingHoursStart: 9, textingHoursEnd: 21, textingHoursEnforced: true }, - { overrideOrganizationTextingHours: true, textingHoursEnforced: true, textingHoursEnd: 21, timezone: 'America/New_York'} - ).unix()).toEqual(moment('2018-09-04T01:00:00.000Z').unix()) - }) - - it('returns correct time if campaign overrides and contact offset is not supplied', () => { - expect(getSendBeforeTimeUtc( - {}, - { textingHoursStart: 9, textingHoursEnd: 21, textingHoursEnforced: true }, - { overrideOrganizationTextingHours: true, textingHoursEnforced: true, textingHoursEnd: 21, timezone: 'America/New_York'} - ).unix()).toEqual(moment('2018-09-04T01:00:00.000Z').unix()) - }) - - it('returns correct time if campaign does not override and TZ is set', () => { - tzHelpers.getProcessEnvTz.mockImplementation(() => 'America/New_York') - expect(getSendBeforeTimeUtc( - {}, - { textingHoursStart: 9, textingHoursEnd: 21, textingHoursEnforced: true }, - {} - ).unix()).toEqual(moment('2018-09-04T01:00:00.000Z').unix()) - }) - - it('returns correct time if campaign does not override and TZ is not set and contact offset is supplied', () => { - expect(getSendBeforeTimeUtc( - { offset: -5, hasDST: 1 }, - { textingHoursStart: 9, textingHoursEnd: 21, textingHoursEnforced: true }, - {} - ).unix()).toEqual(moment('2018-09-04T01:00:00.000Z').unix()) - }) - - it('returns correct time if campaign does not override and TZ is not set and contact offset is not supplied', () => { - expect(getSendBeforeTimeUtc( - {}, - { textingHoursStart: 9, textingHoursEnd: 21, textingHoursEnforced: true }, - {} - ).unix()).toEqual(moment('2018-09-04T01:00:00.000Z').unix()) - }) - - it('converts to Date as expected', () => { - expect(getSendBeforeTimeUtc( - {}, - { textingHoursStart: 9, textingHoursEnd: 21, textingHoursEnforced: true }, - {} - ).toDate()).toEqual(Date('2018-09-04T01:00:00.000Z')) - }) -}) + it("converts to Date as expected", () => { + expect( + getSendBeforeTimeUtc( + {}, + { + textingHoursStart: 9, + textingHoursEnd: 21, + textingHoursEnforced: true + }, + {} + ).toDate() + ).toEqual(Date("2018-09-04T01:00:00.000Z")); + }); +}); diff --git a/__test__/lib/tz-helpers.test.js b/__test__/lib/tz-helpers.test.js index 8f2c858e5..89b60a474 100644 --- a/__test__/lib/tz-helpers.test.js +++ b/__test__/lib/tz-helpers.test.js @@ -1,10 +1,9 @@ -import {getProcessEnvDstReferenceTimezone} from "../../src/lib/tz-helpers"; +import { getProcessEnvDstReferenceTimezone } from "../../src/lib/tz-helpers"; -jest.unmock('../../src/lib/tz-helpers') - -describe('test getProcessEnvDstReferenceTimezone', () => { - it('works', () => { - expect(getProcessEnvDstReferenceTimezone()).toEqual('America/New_York') - }) -}) +jest.unmock("../../src/lib/tz-helpers"); +describe("test getProcessEnvDstReferenceTimezone", () => { + it("works", () => { + expect(getProcessEnvDstReferenceTimezone()).toEqual("America/New_York"); + }); +}); diff --git a/__test__/lib/zip-format.test.js b/__test__/lib/zip-format.test.js index 45556dd17..6d0f5b638 100644 --- a/__test__/lib/zip-format.test.js +++ b/__test__/lib/zip-format.test.js @@ -1,64 +1,64 @@ -import {getFormattedZip, zipToTimeZone} from "../../src/lib"; +import { getFormattedZip, zipToTimeZone } from "../../src/lib"; -describe('test getFormattedZip', () => { - it('handles zip correctly', () => { - expect(getFormattedZip('12345')).toEqual('12345'); - }) +describe("test getFormattedZip", () => { + it("handles zip correctly", () => { + expect(getFormattedZip("12345")).toEqual("12345"); + }); - it('handles zip + 4 correctly', () => { - expect(getFormattedZip('12345-3456')).toEqual('12345'); - }) + it("handles zip + 4 correctly", () => { + expect(getFormattedZip("12345-3456")).toEqual("12345"); + }); - it('handles malformed zip correctly 1', () => { - expect(getFormattedZip('12345-abcd')).toEqual('12345'); - }) + it("handles malformed zip correctly 1", () => { + expect(getFormattedZip("12345-abcd")).toEqual("12345"); + }); - it('handles malformed zip correctly 2', () => { - expect(getFormattedZip('a2345-abcd')).toBeFalsy(); - }) + it("handles malformed zip correctly 2", () => { + expect(getFormattedZip("a2345-abcd")).toBeFalsy(); + }); - it('handles malformed zip correctly 3', () => { - expect(getFormattedZip('2345-abcd')).toBeFalsy(); - }) + it("handles malformed zip correctly 3", () => { + expect(getFormattedZip("2345-abcd")).toBeFalsy(); + }); function wrapper() { - getFormattedZip('11790', 'OZ'); + getFormattedZip("11790", "OZ"); } - it('handles not the USA correctly', () => { + it("handles not the USA correctly", () => { expect(wrapper).toThrow(/OZ/); - }) -}) + }); +}); -describe('test zipToTimeZone', () => { - it('handles string with 2 leading zeroes', () => { - var result = zipToTimeZone('00100') - expect(result[0]).toBe(-1) - expect(result[1]).toBe(210) - expect(result[2]).toBe(-4) - expect(result[3]).toBe(1) - }) - it('handles 3-digit integer', () => { - var result = zipToTimeZone(100) - expect(result[0]).toBe(-1) - expect(result[1]).toBe(210) - expect(result[2]).toBe(-4) - expect(result[3]).toBe(1) - }) - it('handles highest zip in the list', () => { - expect(zipToTimeZone('99501')).toBeFalsy() - }) - it('handles a zip at the lower boundary of a range', () => { - var result = zipToTimeZone('59000') - expect(result[2]).toBe(-7) - expect(result[3]).toBe(1) - }) - it('handles a zip one lower than the upper limit of a range', () => { - var result = zipToTimeZone('69020') - expect(result[2]).toBe(-6) - expect(result[3]).toBe(1) - }) - it('handles a zip at the upper limit of a range', () => { - expect(zipToTimeZone('69021')).toBeFalsy() - }) -}) +describe("test zipToTimeZone", () => { + it("handles string with 2 leading zeroes", () => { + var result = zipToTimeZone("00100"); + expect(result[0]).toBe(-1); + expect(result[1]).toBe(210); + expect(result[2]).toBe(-4); + expect(result[3]).toBe(1); + }); + it("handles 3-digit integer", () => { + var result = zipToTimeZone(100); + expect(result[0]).toBe(-1); + expect(result[1]).toBe(210); + expect(result[2]).toBe(-4); + expect(result[3]).toBe(1); + }); + it("handles highest zip in the list", () => { + expect(zipToTimeZone("99501")).toBeFalsy(); + }); + it("handles a zip at the lower boundary of a range", () => { + var result = zipToTimeZone("59000"); + expect(result[2]).toBe(-7); + expect(result[3]).toBe(1); + }); + it("handles a zip one lower than the upper limit of a range", () => { + var result = zipToTimeZone("69020"); + expect(result[2]).toBe(-6); + expect(result[3]).toBe(1); + }); + it("handles a zip at the upper limit of a range", () => { + expect(zipToTimeZone("69021")).toBeFalsy(); + }); +}); diff --git a/__test__/server/api/assignment.test.js b/__test__/server/api/assignment.test.js index bbba659f7..d7ed902f4 100644 --- a/__test__/server/api/assignment.test.js +++ b/__test__/server/api/assignment.test.js @@ -1,187 +1,222 @@ -import { getContacts } from '../../../src/server/api/assignment' -import { Organization, Assignment, Campaign } from '../../../src/server/models' +import { getContacts } from "../../../src/server/api/assignment"; +import { Organization, Assignment, Campaign } from "../../../src/server/models"; -jest.mock('../../../src/lib/timezones.js') -var timezones = require('../../../src/lib/timezones.js') +jest.mock("../../../src/lib/timezones.js"); +var timezones = require("../../../src/lib/timezones.js"); -describe('test getContacts builds queries correctly', () => { +describe("test getContacts builds queries correctly", () => { var organization = new Organization({ texting_hours_enforced: false, texting_hours_start: 9, texting_hours_end: 14 - }) + }); var campaign = new Campaign({ due_by: new Date() - }) + }); const past_due_campaign = new Campaign({ due_by: new Date().setFullYear(new Date().getFullYear() - 1) - }) + }); var assignment = new Assignment({ id: 1 - }) + }); beforeEach(() => { - timezones.getOffsets.mockReturnValueOnce([['-5_1'], ['-4_1']]) - }) + timezones.getOffsets.mockReturnValueOnce([["-5_1"], ["-4_1"]]); + }); afterAll(() => { - jest.restoreAllMocks() - }) + jest.restoreAllMocks(); + }); - it('works with: no contacts filter', () => { - const query = getContacts(assignment, undefined, organization, campaign) + it("works with: no contacts filter", () => { + const query = getContacts(assignment, undefined, organization, campaign); expect(query.toString()).toBe( - "select * from \"campaign_contact\" where \"assignment_id\" = 1 order by message_status DESC, updated_at" - ) - }) // it + 'select * from "campaign_contact" where "assignment_id" = 1 order by message_status DESC, updated_at' + ); + }); // it - it('works with: contacts filter, include past due, message status', () => { - const query = getContacts(assignment, { includePastDue: true }, organization, campaign) + it("works with: contacts filter, include past due, message status", () => { + const query = getContacts( + assignment, + { includePastDue: true }, + organization, + campaign + ); expect(query.toString()).toBe( - "select * from \"campaign_contact\" where \"assignment_id\" = 1 and \"message_status\" in ('needsResponse', 'needsMessage') order by message_status DESC, updated_at" - ) - }) // it + 'select * from "campaign_contact" where "assignment_id" = 1 and "message_status" in (\'needsResponse\', \'needsMessage\') order by message_status DESC, updated_at' + ); + }); // it - it('works with: contacts filter, exclude past due, message status needsMessageOrResponse', () => { + it("works with: contacts filter, exclude past due, message status needsMessageOrResponse", () => { const query = getContacts( assignment, - { messageStatus: 'needsMessageOrResponse' }, + { messageStatus: "needsMessageOrResponse" }, organization, campaign - ) + ); expect(query.toString()).toBe( - "select * from \"campaign_contact\" where \"assignment_id\" = 1 and \"message_status\" in ('needsResponse', 'needsMessage') order by message_status DESC, updated_at" - ) - }) // it + 'select * from "campaign_contact" where "assignment_id" = 1 and "message_status" in (\'needsResponse\', \'needsMessage\') order by message_status DESC, updated_at' + ); + }); // it - it('works with: contacts filter, exclude past due, campaign is past due, message status needsMessage', () => { + it("works with: contacts filter, exclude past due, campaign is past due, message status needsMessage", () => { const query = getContacts( assignment, - { messageStatus: 'needsMessage' }, + { messageStatus: "needsMessage" }, organization, past_due_campaign - ) + ); // this should be empty because the query is empty and thus we return [] - expect(query.toString()).toBe('') - }) // it + expect(query.toString()).toBe(""); + }); // it - it('works with: contacts filter, exclude past due, message status one other', () => { - const query = getContacts(assignment, { messageStatus: 'convo' }, organization, campaign) + it("works with: contacts filter, exclude past due, message status one other", () => { + const query = getContacts( + assignment, + { messageStatus: "convo" }, + organization, + campaign + ); expect(query.toString()).toBe( - "select * from \"campaign_contact\" where \"assignment_id\" = 1 and \"message_status\" in ('convo') order by message_status DESC, updated_at DESC" - ) - }) // it + 'select * from "campaign_contact" where "assignment_id" = 1 and "message_status" in (\'convo\') order by message_status DESC, updated_at DESC' + ); + }); // it - it('works with: contacts filter, exclude past due, message status multiple other', () => { + it("works with: contacts filter, exclude past due, message status multiple other", () => { const query = getContacts( assignment, - { messageStatus: 'convo,messageReceived' }, + { messageStatus: "convo,messageReceived" }, organization, campaign - ) + ); expect(query.toString()).toBe( - "select * from \"campaign_contact\" where \"assignment_id\" = 1 and \"message_status\" in ('convo', 'messageReceived') order by message_status DESC, updated_at" - ) - }) // it + 'select * from "campaign_contact" where "assignment_id" = 1 and "message_status" in (\'convo\', \'messageReceived\') order by message_status DESC, updated_at' + ); + }); // it - it('works with: contacts filter, exclude past due, no message status, campaign is past due', () => { - const query = getContacts(assignment, {}, organization, past_due_campaign) + it("works with: contacts filter, exclude past due, no message status, campaign is past due", () => { + const query = getContacts(assignment, {}, organization, past_due_campaign); expect(query.toString()).toBe( 'select * from "campaign_contact" where "assignment_id" = 1 and "message_status" in (\'needsResponse\') order by message_status DESC, updated_at' - ) - }) // it + ); + }); // it - it('works with: contacts filter, exclude past due, no message status, campaign not past due', () => { - const query = getContacts(assignment, {}, organization, campaign) + it("works with: contacts filter, exclude past due, no message status, campaign not past due", () => { + const query = getContacts(assignment, {}, organization, campaign); expect(query.toString()).toBe( 'select * from "campaign_contact" where "assignment_id" = 1 and "message_status" in (\'needsResponse\', \'needsMessage\') order by message_status DESC, updated_at' - ) - }) // it + ); + }); // it - it('works with: forCount, contacts filter, exclude past due, no message status, campaign not past due', () => { - const query = getContacts(assignment, {}, organization, campaign, true) + it("works with: forCount, contacts filter, exclude past due, no message status, campaign not past due", () => { + const query = getContacts(assignment, {}, organization, campaign, true); expect(query.toString()).toBe( 'select * from "campaign_contact" where "assignment_id" = 1 and "message_status" in (\'needsResponse\', \'needsMessage\')' - ) - }) // it -}) // describe + ); + }); // it +}); // describe -describe('test getContacts timezone stuff only', () => { +describe("test getContacts timezone stuff only", () => { var organization = new Organization({ texting_hours_enforced: true, texting_hours_start: 9, texting_hours_end: 14 - }) + }); var campaign = new Campaign({ due_by: new Date() - }) + }); var assignment = new Assignment({ id: 1 - }) + }); beforeEach(() => { - timezones.getOffsets.mockReturnValueOnce([['-5_1'], ['-4_1']]) - }) + timezones.getOffsets.mockReturnValueOnce([["-5_1"], ["-4_1"]]); + }); afterAll(() => { - jest.restoreAllMocks() - }) + jest.restoreAllMocks(); + }); - it('returns the correct query -- in default texting hours, with valid_timezone == true', () => { - timezones.defaultTimezoneIsBetweenTextingHours.mockReturnValueOnce(true) - var query = getContacts(assignment, { validTimezone: true }, organization, campaign) + it("returns the correct query -- in default texting hours, with valid_timezone == true", () => { + timezones.defaultTimezoneIsBetweenTextingHours.mockReturnValueOnce(true); + var query = getContacts( + assignment, + { validTimezone: true }, + organization, + campaign + ); expect(query.toString()).toMatch( - "select * from \"campaign_contact\" where \"assignment_id\" = 1 and \"timezone_offset\" in ('-5_1', '') and \"message_status\" in ('needsResponse', 'needsMessage') order by message_status DESC, updated_at" - ) - }) // it + "select * from \"campaign_contact\" where \"assignment_id\" = 1 and \"timezone_offset\" in ('-5_1', '') and \"message_status\" in ('needsResponse', 'needsMessage') order by message_status DESC, updated_at" + ); + }); // it - it('returns the correct query -- in default texting hours, with valid_timezone == false', () => { - timezones.defaultTimezoneIsBetweenTextingHours.mockReturnValueOnce(true) - var query = getContacts(assignment, { validTimezone: false }, organization, campaign) + it("returns the correct query -- in default texting hours, with valid_timezone == false", () => { + timezones.defaultTimezoneIsBetweenTextingHours.mockReturnValueOnce(true); + var query = getContacts( + assignment, + { validTimezone: false }, + organization, + campaign + ); expect(query.toString()).toMatch( - "select * from \"campaign_contact\" where \"assignment_id\" = 1 and \"timezone_offset\" in ('-4_1') and \"message_status\" in ('needsResponse', 'needsMessage') order by message_status DESC, updated_at" - ) - }) // it + 'select * from "campaign_contact" where "assignment_id" = 1 and "timezone_offset" in (\'-4_1\') and "message_status" in (\'needsResponse\', \'needsMessage\') order by message_status DESC, updated_at' + ); + }); // it - it('returns the correct query -- NOT in default texting hours, with valid_timezone == true', () => { - timezones.defaultTimezoneIsBetweenTextingHours.mockReturnValueOnce(false) - var query = getContacts(assignment, { validTimezone: true }, organization, campaign) + it("returns the correct query -- NOT in default texting hours, with valid_timezone == true", () => { + timezones.defaultTimezoneIsBetweenTextingHours.mockReturnValueOnce(false); + var query = getContacts( + assignment, + { validTimezone: true }, + organization, + campaign + ); expect(query.toString()).toMatch( - "select * from \"campaign_contact\" where \"assignment_id\" = 1 and \"timezone_offset\" in ('-5_1') and \"message_status\" in ('needsResponse', 'needsMessage') order by message_status DESC, updated_at" - ) - }) // it + 'select * from "campaign_contact" where "assignment_id" = 1 and "timezone_offset" in (\'-5_1\') and "message_status" in (\'needsResponse\', \'needsMessage\') order by message_status DESC, updated_at' + ); + }); // it - it('returns the correct query -- NOT in default texting hours, with valid_timezone == false', () => { - timezones.defaultTimezoneIsBetweenTextingHours.mockReturnValueOnce(false) - var query = getContacts(assignment, { validTimezone: false }, organization, campaign) + it("returns the correct query -- NOT in default texting hours, with valid_timezone == false", () => { + timezones.defaultTimezoneIsBetweenTextingHours.mockReturnValueOnce(false); + var query = getContacts( + assignment, + { validTimezone: false }, + organization, + campaign + ); expect(query.toString()).toMatch( "select * from \"campaign_contact\" where \"assignment_id\" = 1 and \"timezone_offset\" in ('-4_1', '') and \"message_status\" in ('needsResponse', 'needsMessage') order by message_status DESC, updated_at" - ) - }) // it + ); + }); // it - it('returns the correct query -- no contacts filter', () => { - var query = getContacts(assignment, null, organization, campaign) + it("returns the correct query -- no contacts filter", () => { + var query = getContacts(assignment, null, organization, campaign); expect(query.toString()).toMatch( /^select \* from \"campaign_contact\" where \"assignment_id\" = 1.*/ - ) - }) // it + ); + }); // it - it('returns the correct query -- no validTimezone property in contacts filter', () => { - var query = getContacts(assignment, {}, organization, campaign) + it("returns the correct query -- no validTimezone property in contacts filter", () => { + var query = getContacts(assignment, {}, organization, campaign); expect(query.toString()).toMatch( /^select \* from \"campaign_contact\" where \"assignment_id\" = 1.*/ - ) - }) // it + ); + }); // it - it('returns the correct query -- validTimezone property is null', () => { - var query = getContacts(assignment, { validTimezone: null }, organization, campaign) + it("returns the correct query -- validTimezone property is null", () => { + var query = getContacts( + assignment, + { validTimezone: null }, + organization, + campaign + ); expect(query.toString()).toMatch( /^select \* from \"campaign_contact\" where \"assignment_id\" = 1.*/ - ) - }) // it -}) // describe + ); + }); // it +}); // describe diff --git a/__test__/server/api/campaign.test.js b/__test__/server/api/campaign.test.js index 185cfe0c9..cd4c92df8 100644 --- a/__test__/server/api/campaign.test.js +++ b/__test__/server/api/campaign.test.js @@ -1,15 +1,15 @@ /* eslint-disable no-unused-expressions, consistent-return */ -import { r } from '../../../src/server/models/' -import { dataQuery as TexterTodoListQuery } from '../../../src/containers/TexterTodoList' -import { dataQuery as TexterTodoQuery } from '../../../src/containers/TexterTodo' -import { campaignDataQuery as AdminCampaignEditQuery } from '../../../src/containers/AdminCampaignEdit' +import { r } from "../../../src/server/models/"; +import { dataQuery as TexterTodoListQuery } from "../../../src/containers/TexterTodoList"; +import { dataQuery as TexterTodoQuery } from "../../../src/containers/TexterTodo"; +import { campaignDataQuery as AdminCampaignEditQuery } from "../../../src/containers/AdminCampaignEdit"; import { bulkReassignCampaignContactsMutation, reassignCampaignContactsMutation -} from '../../../src/containers/AdminIncomingMessageList' +} from "../../../src/containers/AdminIncomingMessageList"; -import { makeTree } from '../../../src/lib' +import { makeTree } from "../../../src/lib"; import { setupTest, @@ -29,503 +29,723 @@ import { startCampaign, getCampaignContact, sendMessage -} from '../../test_helpers' - -let testAdminUser -let testInvite -let testOrganization -let testCampaign -let testTexterUser -let testTexterUser2 -let testContacts -let organizationId -let assignmentId +} from "../../test_helpers"; + +let testAdminUser; +let testInvite; +let testOrganization; +let testCampaign; +let testTexterUser; +let testTexterUser2; +let testContacts; +let organizationId; +let assignmentId; beforeEach(async () => { // Set up an entire working campaign - await setupTest() - testAdminUser = await createUser() - testInvite = await createInvite() - testOrganization = await createOrganization(testAdminUser, testInvite) - organizationId = testOrganization.data.createOrganization.id - testCampaign = await createCampaign(testAdminUser, testOrganization) - testContacts = await createContacts(testCampaign, 100) - testTexterUser = await createTexter(testOrganization) - testTexterUser2 = await createTexter(testOrganization) - await assignTexter(testAdminUser, testTexterUser, testCampaign) - const dbCampaignContact = await getCampaignContact(testContacts[0].id) - assignmentId = dbCampaignContact.assignment_id + await setupTest(); + testAdminUser = await createUser(); + testInvite = await createInvite(); + testOrganization = await createOrganization(testAdminUser, testInvite); + organizationId = testOrganization.data.createOrganization.id; + testCampaign = await createCampaign(testAdminUser, testOrganization); + testContacts = await createContacts(testCampaign, 100); + testTexterUser = await createTexter(testOrganization); + testTexterUser2 = await createTexter(testOrganization); + await assignTexter(testAdminUser, testTexterUser, testCampaign); + const dbCampaignContact = await getCampaignContact(testContacts[0].id); + assignmentId = dbCampaignContact.assignment_id; // await createScript(testAdminUser, testCampaign) // await startCampaign(testAdminUser, testCampaign) -}, global.DATABASE_SETUP_TEARDOWN_TIMEOUT) +}, global.DATABASE_SETUP_TEARDOWN_TIMEOUT); afterEach(async () => { - await cleanupTest() - if (r.redis) r.redis.flushdb() -}, global.DATABASE_SETUP_TEARDOWN_TIMEOUT) + await cleanupTest(); + if (r.redis) r.redis.flushdb(); +}, global.DATABASE_SETUP_TEARDOWN_TIMEOUT); +it("save campaign data, edit it, make sure the last value", async () => { + let campaignDataResults = await runComponentGql( + AdminCampaignEditQuery, + { campaignId: testCampaign.id }, + testAdminUser + ); -it('save campaign data, edit it, make sure the last value', async () => { - let campaignDataResults = await runComponentGql(AdminCampaignEditQuery, - { campaignId: testCampaign.id }, - testAdminUser) - - expect(campaignDataResults.data.campaign.title).toEqual('test campaign') - expect(campaignDataResults.data.campaign.description).toEqual('test description') + expect(campaignDataResults.data.campaign.title).toEqual("test campaign"); + expect(campaignDataResults.data.campaign.description).toEqual( + "test description" + ); - let texterCampaignDataResults = await runComponentGql(TexterTodoListQuery, - { organizationId }, - testTexterUser) + let texterCampaignDataResults = await runComponentGql( + TexterTodoListQuery, + { organizationId }, + testTexterUser + ); // empty before we start the campaign - expect(texterCampaignDataResults.data.currentUser.todos).toEqual([]) + expect(texterCampaignDataResults.data.currentUser.todos).toEqual([]); // now we start and confirm that we can access it - await startCampaign(testAdminUser, testCampaign) - texterCampaignDataResults = await runComponentGql(TexterTodoListQuery, - { organizationId }, - testTexterUser) - expect(texterCampaignDataResults.data.currentUser.todos[0].campaign.title).toEqual('test campaign') - expect(texterCampaignDataResults.data.currentUser.todos[0].campaign.description).toEqual('test description') + await startCampaign(testAdminUser, testCampaign); + texterCampaignDataResults = await runComponentGql( + TexterTodoListQuery, + { organizationId }, + testTexterUser + ); + expect( + texterCampaignDataResults.data.currentUser.todos[0].campaign.title + ).toEqual("test campaign"); + expect( + texterCampaignDataResults.data.currentUser.todos[0].campaign.description + ).toEqual("test description"); // now we modify it, and confirm that it changes - const savedCampaign = await saveCampaign(testAdminUser, - { id: testCampaign.id, - organizationId }, - 'test campaign new title') - expect(savedCampaign.title).toEqual('test campaign new title') + const savedCampaign = await saveCampaign( + testAdminUser, + { id: testCampaign.id, organizationId }, + "test campaign new title" + ); + expect(savedCampaign.title).toEqual("test campaign new title"); - campaignDataResults = await runComponentGql(AdminCampaignEditQuery, - { campaignId: testCampaign.id }, - testAdminUser) - - texterCampaignDataResults = await runComponentGql(TexterTodoListQuery, - { organizationId }, - testTexterUser) + campaignDataResults = await runComponentGql( + AdminCampaignEditQuery, + { campaignId: testCampaign.id }, + testAdminUser + ); - expect(texterCampaignDataResults.data.currentUser.todos[0].campaign.title).toEqual('test campaign new title') -}) + texterCampaignDataResults = await runComponentGql( + TexterTodoListQuery, + { organizationId }, + testTexterUser + ); + expect( + texterCampaignDataResults.data.currentUser.todos[0].campaign.title + ).toEqual("test campaign new title"); +}); -it('save campaign interaction steps, edit it, make sure the last value is set', async () => { - await createScript(testAdminUser, testCampaign) - let campaignDataResults = await runComponentGql(AdminCampaignEditQuery, - { campaignId: testCampaign.id }, - testAdminUser) - expect(campaignDataResults.data.campaign.interactionSteps.length).toEqual(2) - expect(campaignDataResults.data.campaign.interactionSteps[0].questionText).toEqual('hmm0') - expect(campaignDataResults.data.campaign.interactionSteps[1].questionText).toEqual('hmm1') - expect(campaignDataResults.data.campaign.interactionSteps[0].script).toEqual('autorespond {zip}') - expect(campaignDataResults.data.campaign.interactionSteps[1].script).toEqual('{lastName}') +it("save campaign interaction steps, edit it, make sure the last value is set", async () => { + await createScript(testAdminUser, testCampaign); + let campaignDataResults = await runComponentGql( + AdminCampaignEditQuery, + { campaignId: testCampaign.id }, + testAdminUser + ); + expect(campaignDataResults.data.campaign.interactionSteps.length).toEqual(2); + expect( + campaignDataResults.data.campaign.interactionSteps[0].questionText + ).toEqual("hmm0"); + expect( + campaignDataResults.data.campaign.interactionSteps[1].questionText + ).toEqual("hmm1"); + expect(campaignDataResults.data.campaign.interactionSteps[0].script).toEqual( + "autorespond {zip}" + ); + expect(campaignDataResults.data.campaign.interactionSteps[1].script).toEqual( + "{lastName}" + ); // save an update with a new questionText script - const interactionStepsClone1 = makeTree(campaignDataResults.data.campaign.interactionSteps) - interactionStepsClone1.interactionSteps[0].script = 'second save before campaign start' - await createScript(testAdminUser, testCampaign, interactionStepsClone1) - - campaignDataResults = await runComponentGql(AdminCampaignEditQuery, - { campaignId: testCampaign.id }, - testAdminUser) - expect(campaignDataResults.data.campaign.interactionSteps[1].script).toEqual('second save before campaign start') + const interactionStepsClone1 = makeTree( + campaignDataResults.data.campaign.interactionSteps + ); + interactionStepsClone1.interactionSteps[0].script = + "second save before campaign start"; + await createScript(testAdminUser, testCampaign, interactionStepsClone1); + + campaignDataResults = await runComponentGql( + AdminCampaignEditQuery, + { campaignId: testCampaign.id }, + testAdminUser + ); + expect(campaignDataResults.data.campaign.interactionSteps[1].script).toEqual( + "second save before campaign start" + ); // save an update with a change to first text - const interactionStepsClone2 = makeTree(campaignDataResults.data.campaign.interactionSteps) - interactionStepsClone2.script = 'Hi {firstName}, please autorespond' - await createScript(testAdminUser, testCampaign, interactionStepsClone2) + const interactionStepsClone2 = makeTree( + campaignDataResults.data.campaign.interactionSteps + ); + interactionStepsClone2.script = "Hi {firstName}, please autorespond"; + await createScript(testAdminUser, testCampaign, interactionStepsClone2); - campaignDataResults = await runComponentGql(AdminCampaignEditQuery, - { campaignId: testCampaign.id }, - testAdminUser) - expect(campaignDataResults.data.campaign.interactionSteps[0].script).toEqual('Hi {firstName}, please autorespond') + campaignDataResults = await runComponentGql( + AdminCampaignEditQuery, + { campaignId: testCampaign.id }, + testAdminUser + ); + expect(campaignDataResults.data.campaign.interactionSteps[0].script).toEqual( + "Hi {firstName}, please autorespond" + ); // CAMPAIGN START - await startCampaign(testAdminUser, testCampaign) + await startCampaign(testAdminUser, testCampaign); // now we start and confirm that we can access the script as a texter let texterCampaignDataResults = await runComponentGql( TexterTodoQuery, { contactsFilter: { - messageStatus: 'needsMessage', + messageStatus: "needsMessage", isOptedOut: false, validTimezone: true }, assignmentId }, - testTexterUser) + testTexterUser + ); expect( - texterCampaignDataResults.data.assignment.campaign.interactionSteps[0].script) - .toEqual('Hi {firstName}, please autorespond') + texterCampaignDataResults.data.assignment.campaign.interactionSteps[0] + .script + ).toEqual("Hi {firstName}, please autorespond"); expect( - texterCampaignDataResults.data.assignment.campaign.interactionSteps[1].script) - .toEqual('second save before campaign start') + texterCampaignDataResults.data.assignment.campaign.interactionSteps[1] + .script + ).toEqual("second save before campaign start"); // after campaign start: update script of first and second text and question text // verify both admin and texter queries - const interactionStepsClone3 = makeTree(campaignDataResults.data.campaign.interactionSteps) - interactionStepsClone3.script = 'Hi {firstName}, please autorespond -- after campaign start' - interactionStepsClone3.interactionSteps[0].script = 'third save after campaign start' - interactionStepsClone3.interactionSteps[0].questionText = 'hmm1 after campaign start' - await createScript(testAdminUser, testCampaign, interactionStepsClone3) - - campaignDataResults = await runComponentGql(AdminCampaignEditQuery, - { campaignId: testCampaign.id }, - testAdminUser) - expect( - campaignDataResults.data.campaign.interactionSteps[0].script) - .toEqual('Hi {firstName}, please autorespond -- after campaign start') - expect( - campaignDataResults.data.campaign.interactionSteps[1].script) - .toEqual('third save after campaign start') + const interactionStepsClone3 = makeTree( + campaignDataResults.data.campaign.interactionSteps + ); + interactionStepsClone3.script = + "Hi {firstName}, please autorespond -- after campaign start"; + interactionStepsClone3.interactionSteps[0].script = + "third save after campaign start"; + interactionStepsClone3.interactionSteps[0].questionText = + "hmm1 after campaign start"; + await createScript(testAdminUser, testCampaign, interactionStepsClone3); + + campaignDataResults = await runComponentGql( + AdminCampaignEditQuery, + { campaignId: testCampaign.id }, + testAdminUser + ); + expect(campaignDataResults.data.campaign.interactionSteps[0].script).toEqual( + "Hi {firstName}, please autorespond -- after campaign start" + ); + expect(campaignDataResults.data.campaign.interactionSteps[1].script).toEqual( + "third save after campaign start" + ); expect( - campaignDataResults.data.campaign.interactionSteps[1].questionText) - .toEqual('hmm1 after campaign start') - texterCampaignDataResults = await runComponentGql(TexterTodoQuery, + campaignDataResults.data.campaign.interactionSteps[1].questionText + ).toEqual("hmm1 after campaign start"); + texterCampaignDataResults = await runComponentGql( + TexterTodoQuery, { contactsFilter: { - messageStatus: 'needsMessage', + messageStatus: "needsMessage", isOptedOut: false, validTimezone: true }, assignmentId }, - testTexterUser) + testTexterUser + ); expect( - texterCampaignDataResults.data.assignment.campaign.interactionSteps[0].script) - .toEqual('Hi {firstName}, please autorespond -- after campaign start') + texterCampaignDataResults.data.assignment.campaign.interactionSteps[0] + .script + ).toEqual("Hi {firstName}, please autorespond -- after campaign start"); expect( - texterCampaignDataResults.data.assignment.campaign.interactionSteps[1].script) - .toEqual('third save after campaign start') + texterCampaignDataResults.data.assignment.campaign.interactionSteps[1] + .script + ).toEqual("third save after campaign start"); expect( - texterCampaignDataResults.data.assignment.campaign.interactionSteps[1].question.text) - .toEqual('hmm1 after campaign start') + texterCampaignDataResults.data.assignment.campaign.interactionSteps[1] + .question.text + ).toEqual("hmm1 after campaign start"); // COPIED CAMPAIGN - const copiedCampaign1 = await copyCampaign(testCampaign.id, testAdminUser) + const copiedCampaign1 = await copyCampaign(testCampaign.id, testAdminUser); // 2nd campaign to test against https://github.com/MoveOnOrg/Spoke/issues/854 - const copiedCampaign2 = await copyCampaign(testCampaign.id, testAdminUser) - expect(copiedCampaign1.data.copyCampaign.id).not.toEqual(testCampaign.id) + const copiedCampaign2 = await copyCampaign(testCampaign.id, testAdminUser); + expect(copiedCampaign1.data.copyCampaign.id).not.toEqual(testCampaign.id); - const prevCampaignIsteps = campaignDataResults.data.campaign.interactionSteps + const prevCampaignIsteps = campaignDataResults.data.campaign.interactionSteps; const compareToLater = async (campaignId, prevCampaignIsteps) => { const campaignDataResults = await runComponentGql( - AdminCampaignEditQuery, { campaignId: campaignId }, testAdminUser) + AdminCampaignEditQuery, + { campaignId: campaignId }, + testAdminUser + ); expect( - campaignDataResults.data.campaign.interactionSteps[0].script) - .toEqual('Hi {firstName}, please autorespond -- after campaign start') + campaignDataResults.data.campaign.interactionSteps[0].script + ).toEqual("Hi {firstName}, please autorespond -- after campaign start"); expect( - campaignDataResults.data.campaign.interactionSteps[1].script) - .toEqual('third save after campaign start') + campaignDataResults.data.campaign.interactionSteps[1].script + ).toEqual("third save after campaign start"); expect( - campaignDataResults.data.campaign.interactionSteps[1].questionText) - .toEqual('hmm1 after campaign start') + campaignDataResults.data.campaign.interactionSteps[1].questionText + ).toEqual("hmm1 after campaign start"); // make sure the copied steps are new ones expect( - Number(campaignDataResults.data.campaign.interactionSteps[0].id)) - .toBeGreaterThan(Number(prevCampaignIsteps[1].id)) + Number(campaignDataResults.data.campaign.interactionSteps[0].id) + ).toBeGreaterThan(Number(prevCampaignIsteps[1].id)); expect( - Number(campaignDataResults.data.campaign.interactionSteps[1].id)) - .toBeGreaterThan(Number(prevCampaignIsteps[1].id)) - return campaignDataResults - } - const campaign1Results = await compareToLater(copiedCampaign1.data.copyCampaign.id, prevCampaignIsteps) - await compareToLater(copiedCampaign2.data.copyCampaign.id, prevCampaignIsteps) - await compareToLater(copiedCampaign2.data.copyCampaign.id, campaign1Results.data.campaign.interactionSteps) - - -}) - -it('should save campaign canned responses across copies and match saved data', async () => { - await createScript(testAdminUser, testCampaign) - await createCannedResponses(testAdminUser, testCampaign, - [{title: "canned 1", text: "can1 {firstName}"}, - {title: "canned 2", text: "can2 {firstName}"}, - {title: "canned 3", text: "can3 {firstName}"}, - {title: "canned 4", text: "can4 {firstName}"}, - {title: "canned 5", text: "can5 {firstName}"}, - {title: "canned 6", text: "can6 {firstName}"}, - ]) + Number(campaignDataResults.data.campaign.interactionSteps[1].id) + ).toBeGreaterThan(Number(prevCampaignIsteps[1].id)); + return campaignDataResults; + }; + const campaign1Results = await compareToLater( + copiedCampaign1.data.copyCampaign.id, + prevCampaignIsteps + ); + await compareToLater( + copiedCampaign2.data.copyCampaign.id, + prevCampaignIsteps + ); + await compareToLater( + copiedCampaign2.data.copyCampaign.id, + campaign1Results.data.campaign.interactionSteps + ); +}); + +it("should save campaign canned responses across copies and match saved data", async () => { + await createScript(testAdminUser, testCampaign); + await createCannedResponses(testAdminUser, testCampaign, [ + { title: "canned 1", text: "can1 {firstName}" }, + { title: "canned 2", text: "can2 {firstName}" }, + { title: "canned 3", text: "can3 {firstName}" }, + { title: "canned 4", text: "can4 {firstName}" }, + { title: "canned 5", text: "can5 {firstName}" }, + { title: "canned 6", text: "can6 {firstName}" } + ]); let campaignDataResults = await runComponentGql( - AdminCampaignEditQuery, { campaignId: testCampaign.id }, testAdminUser) - - expect(campaignDataResults.data.campaign.cannedResponses.length).toEqual(6) - for (let i=0; i<6; i++) { - expect(campaignDataResults.data.campaign.cannedResponses[i].title).toEqual(`canned ${i+1}`) - expect(campaignDataResults.data.campaign.cannedResponses[i].text).toEqual(`can${i+1} {firstName}`) + AdminCampaignEditQuery, + { campaignId: testCampaign.id }, + testAdminUser + ); + + expect(campaignDataResults.data.campaign.cannedResponses.length).toEqual(6); + for (let i = 0; i < 6; i++) { + expect(campaignDataResults.data.campaign.cannedResponses[i].title).toEqual( + `canned ${i + 1}` + ); + expect(campaignDataResults.data.campaign.cannedResponses[i].text).toEqual( + `can${i + 1} {firstName}` + ); } // COPY CAMPAIGN - const copiedCampaign1 = await copyCampaign(testCampaign.id, testAdminUser) - const copiedCampaign2 = await copyCampaign(testCampaign.id, testAdminUser) - - campaignDataResults = await runComponentGql( - AdminCampaignEditQuery, { campaignId: copiedCampaign2.data.copyCampaign.id }, testAdminUser) - expect(campaignDataResults.data.campaign.cannedResponses.length).toEqual(6) - for (let i=0; i<6; i++) { - expect(campaignDataResults.data.campaign.cannedResponses[i].title).toEqual(`canned ${i+1}`) - expect(campaignDataResults.data.campaign.cannedResponses[i].text).toEqual(`can${i+1} {firstName}`) + const copiedCampaign1 = await copyCampaign(testCampaign.id, testAdminUser); + const copiedCampaign2 = await copyCampaign(testCampaign.id, testAdminUser); + + campaignDataResults = await runComponentGql( + AdminCampaignEditQuery, + { campaignId: copiedCampaign2.data.copyCampaign.id }, + testAdminUser + ); + expect(campaignDataResults.data.campaign.cannedResponses.length).toEqual(6); + for (let i = 0; i < 6; i++) { + expect(campaignDataResults.data.campaign.cannedResponses[i].title).toEqual( + `canned ${i + 1}` + ); + expect(campaignDataResults.data.campaign.cannedResponses[i].text).toEqual( + `can${i + 1} {firstName}` + ); } campaignDataResults = await runComponentGql( - AdminCampaignEditQuery, { campaignId: copiedCampaign1.data.copyCampaign.id }, testAdminUser) - - expect(campaignDataResults.data.campaign.cannedResponses.length).toEqual(6) - for (let i=0; i<6; i++) { - expect(campaignDataResults.data.campaign.cannedResponses[i].title).toEqual(`canned ${i+1}`) - expect(campaignDataResults.data.campaign.cannedResponses[i].text).toEqual(`can${i+1} {firstName}`) + AdminCampaignEditQuery, + { campaignId: copiedCampaign1.data.copyCampaign.id }, + testAdminUser + ); + + expect(campaignDataResults.data.campaign.cannedResponses.length).toEqual(6); + for (let i = 0; i < 6; i++) { + expect(campaignDataResults.data.campaign.cannedResponses[i].title).toEqual( + `canned ${i + 1}` + ); + expect(campaignDataResults.data.campaign.cannedResponses[i].text).toEqual( + `can${i + 1} {firstName}` + ); } +}); - -}) - -describe('Reassignments', async () => { - it('should allow reassignments before campaign start', async() => { +describe("Reassignments", async () => { + it("should allow reassignments before campaign start", async () => { // - user gets assignment todos // - assignments are changed in different ways (with different mutations) // - and the current assignments are verified // - assign three texters 10 contacts each // - reassign 5 from one to another // - verify admin query texter counts are correct - expect(true).toEqual(true) - }) - + expect(true).toEqual(true); + }); - it('should allow reassignments after campaign start', async () => { - await createScript(testAdminUser, testCampaign) - await startCampaign(testAdminUser, testCampaign) + it("should allow reassignments after campaign start", async () => { + await createScript(testAdminUser, testCampaign); + await startCampaign(testAdminUser, testCampaign); let texterCampaignDataResults = await runComponentGql( TexterTodoQuery, - { contactsFilter: { messageStatus: 'needsMessage', - isOptedOut: false, - validTimezone: true }, + { + contactsFilter: { + messageStatus: "needsMessage", + isOptedOut: false, + validTimezone: true + }, assignmentId }, - testTexterUser) + testTexterUser + ); // TEXTER 1 (100 needsMessage) - expect(texterCampaignDataResults.data.assignment.contacts.length).toEqual(100) - expect(texterCampaignDataResults.data.assignment.allContactsCount).toEqual(100) + expect(texterCampaignDataResults.data.assignment.contacts.length).toEqual( + 100 + ); + expect(texterCampaignDataResults.data.assignment.allContactsCount).toEqual( + 100 + ); // send some texts - for (let i=0; i<5; i++) { - const messageResult = await sendMessage(testContacts[i].id, testTexterUser, - { userId: testTexterUser.id, - contactNumber: testContacts[i].cell, - text: 'test text', - assignmentId }) + for (let i = 0; i < 5; i++) { + const messageResult = await sendMessage( + testContacts[i].id, + testTexterUser, + { + userId: testTexterUser.id, + contactNumber: testContacts[i].cell, + text: "test text", + assignmentId + } + ); } // TEXTER 1 (95 needsMessage, 5 needsResponse) texterCampaignDataResults = await runComponentGql( TexterTodoQuery, - { contactsFilter: { messageStatus: 'needsMessage', - isOptedOut: false, - validTimezone: true }, + { + contactsFilter: { + messageStatus: "needsMessage", + isOptedOut: false, + validTimezone: true + }, assignmentId }, - testTexterUser) - - expect(texterCampaignDataResults.data.assignment.contacts.length).toEqual(95) - expect(texterCampaignDataResults.data.assignment.allContactsCount).toEqual(100) + testTexterUser + ); + + expect(texterCampaignDataResults.data.assignment.contacts.length).toEqual( + 95 + ); + expect(texterCampaignDataResults.data.assignment.allContactsCount).toEqual( + 100 + ); // - reassign 5 from one to another // using editCampaign - await assignTexter(testAdminUser, testTexterUser, testCampaign, - [{id: testTexterUser.id, needsMessageCount: 70, contactsCount: 100}, - {id: testTexterUser2.id, needsMessageCount: 20}]) + await assignTexter(testAdminUser, testTexterUser, testCampaign, [ + { id: testTexterUser.id, needsMessageCount: 70, contactsCount: 100 }, + { id: testTexterUser2.id, needsMessageCount: 20 } + ]); // TEXTER 1 (70 needsMessage, 5 messaged) // TEXTER 2 (20 needsMessage) texterCampaignDataResults = await runComponentGql( TexterTodoQuery, - { contactsFilter: { messageStatus: 'needsMessage', - isOptedOut: false, - validTimezone: true }, + { + contactsFilter: { + messageStatus: "needsMessage", + isOptedOut: false, + validTimezone: true + }, assignmentId }, - testTexterUser) - let texterCampaignDataResults2 = await runComponentGql(TexterTodoListQuery, - { organizationId }, - testTexterUser2) - expect(texterCampaignDataResults.data.assignment.contacts.length).toEqual(70) - expect(texterCampaignDataResults.data.assignment.allContactsCount).toEqual(75) - - const assignmentId2 = texterCampaignDataResults2.data.currentUser.todos[0].id + testTexterUser + ); + let texterCampaignDataResults2 = await runComponentGql( + TexterTodoListQuery, + { organizationId }, + testTexterUser2 + ); + expect(texterCampaignDataResults.data.assignment.contacts.length).toEqual( + 70 + ); + expect(texterCampaignDataResults.data.assignment.allContactsCount).toEqual( + 75 + ); + + const assignmentId2 = + texterCampaignDataResults2.data.currentUser.todos[0].id; texterCampaignDataResults = await runComponentGql( TexterTodoQuery, - { contactsFilter: { messageStatus: 'needsMessage', - isOptedOut: false, - validTimezone: true }, + { + contactsFilter: { + messageStatus: "needsMessage", + isOptedOut: false, + validTimezone: true + }, assignmentId: assignmentId2 }, - testTexterUser2) - expect(texterCampaignDataResults.data.assignment.contacts.length).toEqual(20) - expect(texterCampaignDataResults.data.assignment.allContactsCount).toEqual(20) - let assignmentContacts2 = texterCampaignDataResults.data.assignment.contacts - for (let i=0; i<5; i++) { - const contact = testContacts.filter(c => assignmentContacts2[i].id == c.id)[0] - const messageResult = await sendMessage(contact.id, testTexterUser2, - { userId: testTexterUser2.id, - contactNumber: contact.cell, - text: 'test text autorespond', - assignmentId: assignmentId2 }) + testTexterUser2 + ); + expect(texterCampaignDataResults.data.assignment.contacts.length).toEqual( + 20 + ); + expect(texterCampaignDataResults.data.assignment.allContactsCount).toEqual( + 20 + ); + let assignmentContacts2 = + texterCampaignDataResults.data.assignment.contacts; + for (let i = 0; i < 5; i++) { + const contact = testContacts.filter( + c => assignmentContacts2[i].id == c.id + )[0]; + const messageResult = await sendMessage(contact.id, testTexterUser2, { + userId: testTexterUser2.id, + contactNumber: contact.cell, + text: "test text autorespond", + assignmentId: assignmentId2 + }); } // TEXTER 1 (70 needsMessage, 5 messaged) // TEXTER 2 (15 needsMessage, 5 needsResponse) texterCampaignDataResults = await runComponentGql( TexterTodoQuery, - { contactsFilter: { messageStatus: 'needsMessage', - isOptedOut: false, - validTimezone: true }, + { + contactsFilter: { + messageStatus: "needsMessage", + isOptedOut: false, + validTimezone: true + }, assignmentId: assignmentId2 }, - testTexterUser2) - - expect(texterCampaignDataResults.data.assignment.contacts.length).toEqual(15) - expect(texterCampaignDataResults.data.assignment.allContactsCount).toEqual(20) + testTexterUser2 + ); + + expect(texterCampaignDataResults.data.assignment.contacts.length).toEqual( + 15 + ); + expect(texterCampaignDataResults.data.assignment.allContactsCount).toEqual( + 20 + ); texterCampaignDataResults = await runComponentGql( TexterTodoQuery, - { contactsFilter: { messageStatus: 'needsResponse', - isOptedOut: false, - validTimezone: true }, + { + contactsFilter: { + messageStatus: "needsResponse", + isOptedOut: false, + validTimezone: true + }, assignmentId: assignmentId2 }, - testTexterUser2) - expect(texterCampaignDataResults.data.assignment.contacts.length).toEqual(5) - expect(texterCampaignDataResults.data.assignment.allContactsCount).toEqual(20) - for (let i=0; i<3; i++) { - const contact = testContacts.filter(c => texterCampaignDataResults.data.assignment.contacts[i].id == c.id)[0] - const messageResult = await sendMessage(contact.id, testTexterUser2, - { userId: testTexterUser2.id, - contactNumber: contact.cell, - text: 'keep talking', - assignmentId: assignmentId2 }) + testTexterUser2 + ); + expect(texterCampaignDataResults.data.assignment.contacts.length).toEqual( + 5 + ); + expect(texterCampaignDataResults.data.assignment.allContactsCount).toEqual( + 20 + ); + for (let i = 0; i < 3; i++) { + const contact = testContacts.filter( + c => texterCampaignDataResults.data.assignment.contacts[i].id == c.id + )[0]; + const messageResult = await sendMessage(contact.id, testTexterUser2, { + userId: testTexterUser2.id, + contactNumber: contact.cell, + text: "keep talking", + assignmentId: assignmentId2 + }); } // TEXTER 1 (70 needsMessage, 5 messaged) // TEXTER 2 (15 needsMessage, 2 needsResponse, 3 convo) texterCampaignDataResults = await runComponentGql( TexterTodoQuery, - { contactsFilter: { messageStatus: 'needsResponse', - isOptedOut: false, - validTimezone: true }, + { + contactsFilter: { + messageStatus: "needsResponse", + isOptedOut: false, + validTimezone: true + }, assignmentId: assignmentId2 }, - testTexterUser2) - expect(texterCampaignDataResults.data.assignment.contacts.length).toEqual(2) - expect(texterCampaignDataResults.data.assignment.allContactsCount).toEqual(20) + testTexterUser2 + ); + expect(texterCampaignDataResults.data.assignment.contacts.length).toEqual( + 2 + ); + expect(texterCampaignDataResults.data.assignment.allContactsCount).toEqual( + 20 + ); texterCampaignDataResults = await runComponentGql( TexterTodoQuery, - { contactsFilter: { messageStatus: 'convo', - isOptedOut: false, - validTimezone: true }, + { + contactsFilter: { + messageStatus: "convo", + isOptedOut: false, + validTimezone: true + }, assignmentId: assignmentId2 }, - testTexterUser2) - expect(texterCampaignDataResults.data.assignment.contacts.length).toEqual(3) - expect(texterCampaignDataResults.data.assignment.allContactsCount).toEqual(20) - - await assignTexter(testAdminUser, testTexterUser, testCampaign, - [{id: testTexterUser.id, needsMessageCount: 60, contactsCount: 75}, - // contactsCount: 30 = 25 (desired needsMessage) + 5 (messaged) - {id: testTexterUser2.id, needsMessageCount: 25, contactsCount: 30}]) + testTexterUser2 + ); + expect(texterCampaignDataResults.data.assignment.contacts.length).toEqual( + 3 + ); + expect(texterCampaignDataResults.data.assignment.allContactsCount).toEqual( + 20 + ); + + await assignTexter(testAdminUser, testTexterUser, testCampaign, [ + { id: testTexterUser.id, needsMessageCount: 60, contactsCount: 75 }, + // contactsCount: 30 = 25 (desired needsMessage) + 5 (messaged) + { id: testTexterUser2.id, needsMessageCount: 25, contactsCount: 30 } + ]); // TEXTER 1 (60 needsMessage, 5 messaged) // TEXTER 2 (25 needsMessage, 2 needsResponse, 3 convo) texterCampaignDataResults = await runComponentGql( TexterTodoQuery, - { contactsFilter: { messageStatus: 'needsMessage', - isOptedOut: false, - validTimezone: true }, + { + contactsFilter: { + messageStatus: "needsMessage", + isOptedOut: false, + validTimezone: true + }, assignmentId }, - testTexterUser) + testTexterUser + ); texterCampaignDataResults2 = await runComponentGql( TexterTodoQuery, - { contactsFilter: { messageStatus: 'needsMessage', - isOptedOut: false, - validTimezone: true }, + { + contactsFilter: { + messageStatus: "needsMessage", + isOptedOut: false, + validTimezone: true + }, assignmentId: assignmentId2 }, - testTexterUser2) - - - expect(texterCampaignDataResults.data.assignment.contacts.length).toEqual(60) - expect(texterCampaignDataResults.data.assignment.allContactsCount).toEqual(65) - expect(texterCampaignDataResults2.data.assignment.contacts.length).toEqual(25) - expect(texterCampaignDataResults2.data.assignment.allContactsCount).toEqual(30) + testTexterUser2 + ); + + expect(texterCampaignDataResults.data.assignment.contacts.length).toEqual( + 60 + ); + expect(texterCampaignDataResults.data.assignment.allContactsCount).toEqual( + 65 + ); + expect(texterCampaignDataResults2.data.assignment.contacts.length).toEqual( + 25 + ); + expect(texterCampaignDataResults2.data.assignment.allContactsCount).toEqual( + 30 + ); // maybe test no intersections of texted people and non-texted, and/or needsReply // reassignCampaignContactsMutation await runComponentGql( - reassignCampaignContactsMutation, { organizationId, - newTexterUserId: testTexterUser2.id, - campaignIdsContactIds: [ - { campaignId: testCampaign.id, - // depending on testContacts[0] being - // first message sent at top of text - campaignContactId: testContacts[0].id, - messageIds: [1] - } - ] - }, testAdminUser) + reassignCampaignContactsMutation, + { + organizationId, + newTexterUserId: testTexterUser2.id, + campaignIdsContactIds: [ + { + campaignId: testCampaign.id, + // depending on testContacts[0] being + // first message sent at top of text + campaignContactId: testContacts[0].id, + messageIds: [1] + } + ] + }, + testAdminUser + ); // TEXTER 1 (60 needsMessage, 4 messaged) // TEXTER 2 (25 needsMessage, 2 needsResponse, 3 convo, 1 messaged) texterCampaignDataResults = await runComponentGql( TexterTodoQuery, - { contactsFilter: { messageStatus: 'messaged', - isOptedOut: false, - validTimezone: true }, + { + contactsFilter: { + messageStatus: "messaged", + isOptedOut: false, + validTimezone: true + }, assignmentId }, - testTexterUser) + testTexterUser + ); texterCampaignDataResults2 = await runComponentGql( TexterTodoQuery, - { contactsFilter: { messageStatus: 'messaged', - isOptedOut: false, - validTimezone: true }, + { + contactsFilter: { + messageStatus: "messaged", + isOptedOut: false, + validTimezone: true + }, assignmentId: assignmentId2 }, - testTexterUser2) - - - expect(texterCampaignDataResults.data.assignment.contacts.length).toEqual(4) - expect(texterCampaignDataResults.data.assignment.allContactsCount).toEqual(64) - expect(texterCampaignDataResults2.data.assignment.contacts.length).toEqual(1) - expect(texterCampaignDataResults2.data.assignment.allContactsCount).toEqual(31) + testTexterUser2 + ); + + expect(texterCampaignDataResults.data.assignment.contacts.length).toEqual( + 4 + ); + expect(texterCampaignDataResults.data.assignment.allContactsCount).toEqual( + 64 + ); + expect(texterCampaignDataResults2.data.assignment.contacts.length).toEqual( + 1 + ); + expect(texterCampaignDataResults2.data.assignment.allContactsCount).toEqual( + 31 + ); // bulkReassignCampaignContactsMutation await runComponentGql( - bulkReassignCampaignContactsMutation, { + bulkReassignCampaignContactsMutation, + { organizationId, - contactsFilter: { messageStatus: 'needsResponse', - isOptedOut: false, - validTimezone: true }, + contactsFilter: { + messageStatus: "needsResponse", + isOptedOut: false, + validTimezone: true + }, campaignsFilter: { campaignId: testCampaign.id }, assignmentsFilter: { texterId: testTexterUser2.id }, newTexterUserId: testTexterUser.id - }, testAdminUser) + }, + testAdminUser + ); // TEXTER 1 (60 needsMessage, 2 needsResponse, 4 messaged) // TEXTER 2 (25 needsMessage, 3 convo, 1 messaged) texterCampaignDataResults = await runComponentGql( TexterTodoQuery, - { contactsFilter: { messageStatus: 'needsResponse', - isOptedOut: false, - validTimezone: true }, + { + contactsFilter: { + messageStatus: "needsResponse", + isOptedOut: false, + validTimezone: true + }, assignmentId }, - testTexterUser) + testTexterUser + ); texterCampaignDataResults2 = await runComponentGql( TexterTodoQuery, - { contactsFilter: { messageStatus: 'needsResponse', - isOptedOut: false, - validTimezone: true }, + { + contactsFilter: { + messageStatus: "needsResponse", + isOptedOut: false, + validTimezone: true + }, assignmentId: assignmentId2 }, - testTexterUser2) - - expect(texterCampaignDataResults.data.assignment.contacts.length).toEqual(2) - expect(texterCampaignDataResults.data.assignment.allContactsCount).toEqual(66) - expect(texterCampaignDataResults2.data.assignment.contacts.length).toEqual(0) - expect(texterCampaignDataResults2.data.assignment.allContactsCount).toEqual(29) - }) -}) + testTexterUser2 + ); + + expect(texterCampaignDataResults.data.assignment.contacts.length).toEqual( + 2 + ); + expect(texterCampaignDataResults.data.assignment.allContactsCount).toEqual( + 66 + ); + expect(texterCampaignDataResults2.data.assignment.contacts.length).toEqual( + 0 + ); + expect(texterCampaignDataResults2.data.assignment.allContactsCount).toEqual( + 29 + ); + }); +}); diff --git a/__test__/server/db/export.js b/__test__/server/db/export.js index 7b1f7ae2c..4063310da 100644 --- a/__test__/server/db/export.js +++ b/__test__/server/db/export.js @@ -1,32 +1,40 @@ -import {r} from '../../../src/server/models' -import { tables, indexQuery } from './utils' -import fs from 'fs' +import { r } from "../../../src/server/models"; +import { tables, indexQuery } from "./utils"; +import fs from "fs"; function getSchema(s) { - return r.k(s).columnInfo().then(schema => { - console.log('exported schema for', s) - fs.writeFileSync(`init_schemas/${s}.json`, JSON.stringify(schema, null, 2)) - }) + return r + .k(s) + .columnInfo() + .then(schema => { + console.log("exported schema for", s); + fs.writeFileSync( + `init_schemas/${s}.json`, + JSON.stringify(schema, null, 2) + ); + }); } function getIndexes() { - return r.k.raw(indexQuery) - .then(indexes => { - fs.writeFileSync('init_schemas/indexes.json', JSON.stringify(indexes, null, 2)) - console.log('exported indices') - }) + return r.k.raw(indexQuery).then(indexes => { + fs.writeFileSync( + "init_schemas/indexes.json", + JSON.stringify(indexes, null, 2) + ); + console.log("exported indices"); + }); } -const tablePromises = tables.map(getSchema) -const indexesPromises = getIndexes() +const tablePromises = tables.map(getSchema); +const indexesPromises = getIndexes(); Promise.all(tablePromises.concat([indexesPromises])) .then(() => { - console.log('completed') - process.exit(0) + console.log("completed"); + process.exit(0); }) .catch(error => { - console.error(error) - process.exit(1) - }) + console.error(error); + process.exit(1); + }); // Run this file _from this directory_ (e.g. with npx babel-node export.js) to get nice JSON representations of each table's schema, for testing. diff --git a/__test__/server/db/utils.js b/__test__/server/db/utils.js index 8b03a145f..4f4aac431 100644 --- a/__test__/server/db/utils.js +++ b/__test__/server/db/utils.js @@ -1,22 +1,22 @@ export const tables = [ - 'log', - 'message', - 'user_cell', - 'job_request', - 'pending_message_part', - 'zip_code', - 'invite', - 'user', - 'user_organization', - 'campaign', - 'interaction_step', - 'assignment', - 'organization', - 'canned_response', - 'opt_out', - 'question_response', - 'campaign_contact' -] + "log", + "message", + "user_cell", + "job_request", + "pending_message_part", + "zip_code", + "invite", + "user", + "user_organization", + "campaign", + "interaction_step", + "assignment", + "organization", + "canned_response", + "opt_out", + "question_response", + "campaign_contact" +]; // Adapted from https://dba.stackexchange.com/a/37068 export const indexQuery = `SELECT conrelid::regclass AS table_from @@ -29,4 +29,4 @@ AND conrelid::regclass::text <> 'migrations' AND conrelid::regclass::text <> 'knex_migrations' AND conrelid::regclass::text <> 'knex_migrations_lock' AND n.nspname = 'public' -ORDER BY conrelid::regclass::text, contype DESC;` +ORDER BY conrelid::regclass::text, contype DESC;`; diff --git a/__test__/server/render-index.test.js b/__test__/server/render-index.test.js index c2c44f649..272a27d87 100644 --- a/__test__/server/render-index.test.js +++ b/__test__/server/render-index.test.js @@ -1,23 +1,25 @@ -import renderIndex from '../../src/server/middleware/render-index' +import renderIndex from "../../src/server/middleware/render-index"; const fakeArguments = { - html: '', + html: "", css: { - content: '', - renderedClassNames: '' + content: "", + renderedClassNames: "" }, assetMap: { - 'bundle.js': '' + "bundle.js": "" }, store: { - getState: () => '' + getState: () => "" } -} +}; -describe('renderIndex', () => { - it('returns html markup that contains a tag link for the favicon', () => { - const { html, css, assetMap, store } = fakeArguments - const htmlMarkup = renderIndex(html, css, assetMap, store) - expect(htmlMarkup).toContain('') - }) -}) +describe("renderIndex", () => { + it("returns html markup that contains a tag link for the favicon", () => { + const { html, css, assetMap, store } = fakeArguments; + const htmlMarkup = renderIndex(html, css, assetMap, store); + expect(htmlMarkup).toContain( + '' + ); + }); +}); diff --git a/__test__/server/texter.test.js b/__test__/server/texter.test.js index e5fa47c54..ffd46973a 100644 --- a/__test__/server/texter.test.js +++ b/__test__/server/texter.test.js @@ -1,5 +1,5 @@ /* eslint-disable no-unused-expressions, consistent-return */ -import { r } from '../../src/server/models/' +import { r } from "../../src/server/models/"; import { runGql, setupTest, @@ -15,188 +15,211 @@ import { createScript, startCampaign, getCampaignContact -} from '../test_helpers' -import waitForExpect from 'wait-for-expect' - -let testAdminUser -let testInvite -let testOrganization -let testCampaign -let testTexterUser -let testTexterUser2 -let testContacts -let testContact -let assignmentId +} from "../test_helpers"; +import waitForExpect from "wait-for-expect"; + +let testAdminUser; +let testInvite; +let testOrganization; +let testCampaign; +let testTexterUser; +let testTexterUser2; +let testContacts; +let testContact; +let assignmentId; beforeEach(async () => { // Set up an entire working campaign - await setupTest() - testAdminUser = await createUser() - testInvite = await createInvite() - testOrganization = await createOrganization(testAdminUser, testInvite) - testCampaign = await createCampaign(testAdminUser, testOrganization) - testContacts = await createContacts(testCampaign, 100) - testContact = testContacts[0] - testTexterUser = await createTexter(testOrganization) - testTexterUser2 = await createTexter(testOrganization) - - await assignTexter(testAdminUser, testTexterUser, testCampaign) - - const dbCampaignContact = await getCampaignContact(testContact.id) - assignmentId = dbCampaignContact.assignment_id - await createScript(testAdminUser, testCampaign) - await startCampaign(testAdminUser, testCampaign) -}, global.DATABASE_SETUP_TEARDOWN_TIMEOUT) + await setupTest(); + testAdminUser = await createUser(); + testInvite = await createInvite(); + testOrganization = await createOrganization(testAdminUser, testInvite); + testCampaign = await createCampaign(testAdminUser, testOrganization); + testContacts = await createContacts(testCampaign, 100); + testContact = testContacts[0]; + testTexterUser = await createTexter(testOrganization); + testTexterUser2 = await createTexter(testOrganization); + + await assignTexter(testAdminUser, testTexterUser, testCampaign); + + const dbCampaignContact = await getCampaignContact(testContact.id); + assignmentId = dbCampaignContact.assignment_id; + await createScript(testAdminUser, testCampaign); + await startCampaign(testAdminUser, testCampaign); +}, global.DATABASE_SETUP_TEARDOWN_TIMEOUT); afterEach(async () => { - await cleanupTest() - if (r.redis) r.redis.flushdb() -}, global.DATABASE_SETUP_TEARDOWN_TIMEOUT) + await cleanupTest(); + if (r.redis) r.redis.flushdb(); +}, global.DATABASE_SETUP_TEARDOWN_TIMEOUT); -it('should send an inital message to test contacts', async () => { +it("should send an inital message to test contacts", async () => { const { query: [getContacts, getContactsVars], mutations - } = getGql('../src/containers/TexterTodo', { - messageStatus: 'needsMessage', + } = getGql("../src/containers/TexterTodo", { + messageStatus: "needsMessage", params: { assignmentId } - }) + }); - const contactsResult = await runGql(getContacts, getContactsVars, testTexterUser) + const contactsResult = await runGql( + getContacts, + getContactsVars, + testTexterUser + ); const [getAssignmentContacts, assignVars] = mutations.getAssignmentContacts( contactsResult.data.assignment.contacts.map(e => e.id), false - ) + ); - const ret2 = await runGql(getAssignmentContacts, assignVars, testTexterUser) - const contact = ret2.data.getAssignmentContacts[0] + const ret2 = await runGql(getAssignmentContacts, assignVars, testTexterUser); + const contact = ret2.data.getAssignmentContacts[0]; const message = { contactNumber: contact.cell, userId: testTexterUser.id, - text: 'test text', + text: "test text", assignmentId - } + }; - const [messageMutation, messageVars] = mutations.sendMessage(message, contact.id) + const [messageMutation, messageVars] = mutations.sendMessage( + message, + contact.id + ); - const messageResult = await runGql(messageMutation, messageVars, testTexterUser) - const campaignContact = messageResult.data.sendMessage + const messageResult = await runGql( + messageMutation, + messageVars, + testTexterUser + ); + const campaignContact = messageResult.data.sendMessage; // These things are expected to be returned from the sendMessage mutation - expect(campaignContact.messageStatus).toBe('messaged') - expect(campaignContact.messages.length).toBe(1) - expect(campaignContact.messages[0].text).toBe(message.text) + expect(campaignContact.messageStatus).toBe("messaged"); + expect(campaignContact.messages.length).toBe(1); + expect(campaignContact.messages[0].text).toBe(message.text); const expectedDbMessage = { // user_id: testTexterUser.id, //FUTURE contact_number: testContact.cell, text: message.text, - assignment_id: assignmentId, + assignment_id: assignmentId // campaign_contact_id: testContact.id //FUTURE - } + }; // wait for fakeservice to mark the message as sent await waitForExpect(async () => { - const dbMessage = await r.knex('message') - expect(dbMessage.length).toEqual(1) + const dbMessage = await r.knex("message"); + expect(dbMessage.length).toEqual(1); expect(dbMessage[0]).toEqual( expect.objectContaining({ - send_status: 'SENT', + send_status: "SENT", ...expectedDbMessage }) - ) - const dbCampaignContact = await getCampaignContact(testContact.id) - expect(dbCampaignContact.message_status).toBe('messaged') - }) + ); + const dbCampaignContact = await getCampaignContact(testContact.id); + expect(dbCampaignContact.message_status).toBe("messaged"); + }); // Refetch the contacts via gql to check the caching - const ret3 = await runGql(getAssignmentContacts, assignVars, testTexterUser) - expect(ret3.data.getAssignmentContacts[0].messageStatus).toEqual('messaged') -}) + const ret3 = await runGql(getAssignmentContacts, assignVars, testTexterUser); + expect(ret3.data.getAssignmentContacts[0].messageStatus).toEqual("messaged"); +}); -it('should be able to receive a response and reply (using fakeService)', async () => { +it("should be able to receive a response and reply (using fakeService)", async () => { const { query: [getContacts, getContactsVars], mutations - } = getGql('../src/containers/TexterTodo', { - messageStatus: 'needsMessage', + } = getGql("../src/containers/TexterTodo", { + messageStatus: "needsMessage", params: { assignmentId } - }) + }); - const contactsResult = await runGql(getContacts, getContactsVars, testTexterUser) + const contactsResult = await runGql( + getContacts, + getContactsVars, + testTexterUser + ); const [getAssignmentContacts, assignVars] = mutations.getAssignmentContacts( contactsResult.data.assignment.contacts.map(e => e.id), false - ) + ); - const ret2 = await runGql(getAssignmentContacts, assignVars, testTexterUser) - const contact = ret2.data.getAssignmentContacts[0] + const ret2 = await runGql(getAssignmentContacts, assignVars, testTexterUser); + const contact = ret2.data.getAssignmentContacts[0]; const message = { contactNumber: contact.cell, userId: testTexterUser.id, - text: 'test text autorespond', + text: "test text autorespond", assignmentId - } + }; - const [messageMutation, messageVars] = mutations.sendMessage(message, contact.id) + const [messageMutation, messageVars] = mutations.sendMessage( + message, + contact.id + ); - await runGql(messageMutation, messageVars, testTexterUser) + await runGql(messageMutation, messageVars, testTexterUser); // wait for fakeservice to autorespond await waitForExpect(async () => { - const dbMessage = await r.knex('message') - expect(dbMessage.length).toEqual(2) + const dbMessage = await r.knex("message"); + expect(dbMessage.length).toEqual(2); expect(dbMessage[1]).toEqual( expect.objectContaining({ - send_status: 'DELIVERED', + send_status: "DELIVERED", text: `responding to ${message.text}`, // user_id: testTexterUser.id, //FUTURE contact_number: testContact.cell, - assignment_id: assignmentId, + assignment_id: assignmentId // campaign_contact_id: testContact.id //FUTURE }) - ) - }) + ); + }); await waitForExpect(async () => { - const dbCampaignContact = await getCampaignContact(testContact.id) - expect(dbCampaignContact.message_status).toBe('needsResponse') - }) + const dbCampaignContact = await getCampaignContact(testContact.id); + expect(dbCampaignContact.message_status).toBe("needsResponse"); + }); // Refetch the contacts via gql to check the caching - const ret3 = await runGql(getAssignmentContacts, assignVars, testTexterUser) - expect(ret3.data.getAssignmentContacts[0].messageStatus).toEqual('needsResponse') + const ret3 = await runGql(getAssignmentContacts, assignVars, testTexterUser); + expect(ret3.data.getAssignmentContacts[0].messageStatus).toEqual( + "needsResponse" + ); // Then we reply const message2 = { contactNumber: contact.cell, userId: testTexterUser.id, - text: 'reply', + text: "reply", assignmentId - } + }; - const [replyMutation, replyVars] = mutations.sendMessage(message2, contact.id) + const [replyMutation, replyVars] = mutations.sendMessage( + message2, + contact.id + ); - await runGql(replyMutation, replyVars, testTexterUser) + await runGql(replyMutation, replyVars, testTexterUser); // wait for fakeservice to mark the message as sent await waitForExpect(async () => { - const dbMessage = await r.knex('message') - expect(dbMessage.length).toEqual(3) + const dbMessage = await r.knex("message"); + expect(dbMessage.length).toEqual(3); expect(dbMessage[2]).toEqual( expect.objectContaining({ - send_status: 'SENT' + send_status: "SENT" }) - ) - const dbCampaignContact = await getCampaignContact(testContact.id) - expect(dbCampaignContact.message_status).toBe('convo') - }) + ); + const dbCampaignContact = await getCampaignContact(testContact.id); + expect(dbCampaignContact.message_status).toBe("convo"); + }); // Refetch the contacts via gql to check the caching - const ret4 = await runGql(getAssignmentContacts, assignVars, testTexterUser) - expect(ret4.data.getAssignmentContacts[0].messageStatus).toEqual('convo') -}) + const ret4 = await runGql(getAssignmentContacts, assignVars, testTexterUser); + expect(ret4.data.getAssignmentContacts[0].messageStatus).toEqual("convo"); +}); diff --git a/__test__/setup.js b/__test__/setup.js index 84aa76ff9..814fd5021 100644 --- a/__test__/setup.js +++ b/__test__/setup.js @@ -1,6 +1,4 @@ -import { configure } from 'enzyme' -import Adapter from 'enzyme-adapter-react-15' - - -configure({ adapter: new Adapter() }) +import { configure } from "enzyme"; +import Adapter from "enzyme-adapter-react-15"; +configure({ adapter: new Adapter() }); diff --git a/__test__/sum.js b/__test__/sum.js index b73a9313d..e335e1652 100644 --- a/__test__/sum.js +++ b/__test__/sum.js @@ -1,4 +1,4 @@ -function sum(a,b) { - return a+b; +function sum(a, b) { + return a + b; } -module.exports = sum; \ No newline at end of file +module.exports = sum; diff --git a/__test__/sum.test.js b/__test__/sum.test.js index f3c6a6037..2b6c378b0 100644 --- a/__test__/sum.test.js +++ b/__test__/sum.test.js @@ -1,5 +1,5 @@ -const sum = require('./sum'); +const sum = require("./sum"); -test('adds 1+2 to equal 3', () => { - expect(sum(1,2)).toBe(3); -}); \ No newline at end of file +test("adds 1+2 to equal 3", () => { + expect(sum(1, 2)).toBe(3); +}); diff --git a/__test__/test_client_helpers.js b/__test__/test_client_helpers.js index d5914a2d6..6e0dadea0 100644 --- a/__test__/test_client_helpers.js +++ b/__test__/test_client_helpers.js @@ -1,83 +1,100 @@ -import moment from 'moment-timezone' -import {mount} from "enzyme"; -import MuiThemeProvider from 'material-ui/styles/MuiThemeProvider' -import {StyleSheetTestUtils} from 'aphrodite' - +import moment from "moment-timezone"; +import { mount } from "enzyme"; +import MuiThemeProvider from "material-ui/styles/MuiThemeProvider"; +import { StyleSheetTestUtils } from "aphrodite"; export function genAssignment(assignmentId, isArchived, hasContacts) { - const contacts = [] + const contacts = []; if (hasContacts) { - if (typeof hasContacts !== 'number') { - contacts.push.apply(contacts, (new Array(hasContacts)).map((x,i) => ({ id: i }))) + if (typeof hasContacts !== "number") { + contacts.push.apply( + contacts, + new Array(hasContacts).map((x, i) => ({ id: i })) + ); } else { - contacts.push({ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }, { id: 6 }) + contacts.push( + { id: 1 }, + { id: 2 }, + { id: 3 }, + { id: 4 }, + { id: 5 }, + { id: 6 } + ); } } const assignmentTest = { - id: assignmentId, - userCannedResponses: [{ - id: 78, - title: 'user canned response', - text: 'This is a canned response, {firstName}', - isUserCreated: true - }], - campaignCannedResponses: [{ - id: 89, - title: 'campaign canned response', - text: 'This is a campaign canned response, {firstName}', - isUserCreated: true - }], - texter: { - id: 67, - firstName: 'Tixer', - lastName: 'Texterness' - }, - campaign: { - id: 56, - isArchived: isArchived, - useDynamicAssignment: false, - organization: { - id: 123, - textingHoursEnforced: false, - textingHoursStart: 9, - textingHoursEnd: 15, - threeClickEnabled: false, - optOutMessage: 'We will remove you from any further communication.' - }, - customFields: ['customField'], - interactionSteps: [{ - id: 34, - script: 'Will you remember to vote today? {firstName} at {customField}' - /*question: { + id: assignmentId, + userCannedResponses: [ + { + id: 78, + title: "user canned response", + text: "This is a canned response, {firstName}", + isUserCreated: true + } + ], + campaignCannedResponses: [ + { + id: 89, + title: "campaign canned response", + text: "This is a campaign canned response, {firstName}", + isUserCreated: true + } + ], + texter: { + id: 67, + firstName: "Tixer", + lastName: "Texterness" + }, + campaign: { + id: 56, + isArchived: isArchived, + useDynamicAssignment: false, + organization: { + id: 123, + textingHoursEnforced: false, + textingHoursStart: 9, + textingHoursEnd: 15, + threeClickEnabled: false, + optOutMessage: "We will remove you from any further communication." + }, + customFields: ["customField"], + interactionSteps: [ + { + id: 34, + script: + "Will you remember to vote today? {firstName} at {customField}" + /*question: { text answerOptions: [{ value }]assignmentTest }*/ - }] - }, - contacts: contacts, - allContactsCount: 5 - } - return assignmentTest + } + ] + }, + contacts: contacts, + allContactsCount: 5 + }; + return assignmentTest; } export function contactGenerator(assignmentId, messageStatus) { - const messages = [] - if (messageStatus !== 'needsMessage') { + const messages = []; + if (messageStatus !== "needsMessage") { messages.push( { id: 90, - createdAt: '2019-04-27T01:11:07.836Z', - text: 'Will you remember to vote today? Same at fakecustomvalue', + createdAt: "2019-04-27T01:11:07.836Z", + text: "Will you remember to vote today? Same at fakecustomvalue", isFromContact: false }, { id: 91, - createdAt: '2019-04-27T02:22:07.836Z', - text: 'Yes, I will vote for reals', + createdAt: "2019-04-27T02:22:07.836Z", + text: "Yes, I will vote for reals", isFromContact: true - }) + } + ); //if (messageStatus !== 'convo') {} } return function createContact(id) { @@ -85,15 +102,15 @@ export function contactGenerator(assignmentId, messageStatus) { id: id, assignmentId: assignmentId, firstName: `first${id}Name`, - lastName: 'Lastname', - cell: '+155555550990', + lastName: "Lastname", + cell: "+155555550990", zip: `0909${id}`, - customFields: { customField: 'customfieldvalue' }, + customFields: { customField: "customfieldvalue" }, optOut: null, questionResponseValues: [], location: { - city: 'City', - state: 'CA', + city: "City", + state: "CA", timezone: { offset: -9, hasDST: 1 @@ -101,6 +118,6 @@ export function contactGenerator(assignmentId, messageStatus) { }, messageStatus: messageStatus, messages: messages - } - } + }; + }; } diff --git a/__test__/test_helpers.js b/__test__/test_helpers.js index 1e408b3d8..200246975 100644 --- a/__test__/test_helpers.js +++ b/__test__/test_helpers.js @@ -1,13 +1,20 @@ -import _ from 'lodash' -import { createLoaders, createTables, dropTables, User, CampaignContact, r } from '../src/server/models/' -import { graphql } from 'graphql' +import _ from "lodash"; +import { + createLoaders, + createTables, + dropTables, + User, + CampaignContact, + r +} from "../src/server/models/"; +import { graphql } from "graphql"; export async function setupTest() { - await createTables() + await createTables(); } export async function cleanupTest() { - await dropTables() + await dropTables(); } export function getContext(context) { @@ -15,10 +22,10 @@ export function getContext(context) { ...context, req: {}, loaders: createLoaders() - } + }; } -import loadData from '../src/containers/hoc/load-data' -jest.mock('../src/containers/hoc/load-data') +import loadData from "../src/containers/hoc/load-data"; +jest.mock("../src/containers/hoc/load-data"); /* Used to get graphql queries from components. * Because of some limitations with the jest require cache that * I can't find a way of getting around, it should only be called once @@ -27,154 +34,162 @@ jest.mock('../src/containers/hoc/load-data') * The query it returns will be that of the requested component, but * the mutations will be merged from the component and its children. */ -export function getGql(componentPath, props, dataKey='data') { - require(componentPath) // eslint-disable-line - const { mapQueriesToProps } = _.last(loadData.mock.calls)[1] +export function getGql(componentPath, props, dataKey = "data") { + require(componentPath); // eslint-disable-line + const { mapQueriesToProps } = _.last(loadData.mock.calls)[1]; const mutations = loadData.mock.calls.reduce((acc, mapping) => { - if (!mapping[1].mapMutationsToProps) return acc + if (!mapping[1].mapMutationsToProps) return acc; return { ...acc, ..._.mapValues( mapping[1].mapMutationsToProps({ ownProps: props }), mutation => (...params) => { - const m = mutation(...params) - return [m.mutation.loc.source.body, m.variables] + const m = mutation(...params); + return [m.mutation.loc.source.body, m.variables]; } ) - } - }, {}) + }; + }, {}); - let query + let query; if (mapQueriesToProps) { - const data = mapQueriesToProps({ ownProps: props }) - query = [data[dataKey].query.loc.source.body, data[dataKey].variables] + const data = mapQueriesToProps({ ownProps: props }); + query = [data[dataKey].query.loc.source.body, data[dataKey].variables]; } - return { query, mutations } + return { query, mutations }; } export async function createUser( userInfo = { - auth0_id: 'test123', - first_name: 'TestUserFirst', - last_name: 'TestUserLast', - cell: '555-555-5555', - email: 'testuser@example.com' + auth0_id: "test123", + first_name: "TestUserFirst", + last_name: "TestUserLast", + cell: "555-555-5555", + email: "testuser@example.com" } ) { - const user = new User(userInfo) - await user.save() - return user + const user = new User(userInfo); + await user.save(); + return user; } -export async function createContacts(campaign, count=1) { - const campaignId = campaign.id - const contacts = [] - for (let i=0; i` - const rootValue = {} + const rootValue = {}; const campaignEditQuery = ` mutation editCampaign($campaignId: String!, $campaign: CampaignInput!) { editCampaign(id: $campaignId, campaign: $campaign) { id } - }` - const context = getContext({ user: admin }) - const updateCampaign = Object.assign({}, campaign) - const campaignId = updateCampaign.id + }`; + const context = getContext({ user: admin }); + const updateCampaign = Object.assign({}, campaign); + const campaignId = updateCampaign.id; updateCampaign.texters = assignments || [ { id: user.id } - ] - delete updateCampaign.id - delete updateCampaign.contacts + ]; + delete updateCampaign.id; + delete updateCampaign.contacts; const variables = { campaignId, campaign: updateCampaign - } - return await graphql(mySchema, campaignEditQuery, rootValue, context, variables) + }; + return await graphql( + mySchema, + campaignEditQuery, + rootValue, + context, + variables + ); } export async function sendMessage(campaignContactId, user, message) { - const rootValue = {} + const rootValue = {}; const query = ` mutation sendMessage($message: MessageInput!, $campaignContactId: String!) { sendMessage(message: $message, campaignContactId: $campaignContactId) { @@ -267,99 +293,126 @@ export async function sendMessage(campaignContactId, user, message) { isFromContact } } - }` - const context = getContext({ user: user }) + }`; + const context = getContext({ user: user }); const variables = { message, campaignContactId - } - return await graphql(mySchema, query, rootValue, context, variables) + }; + return await graphql(mySchema, query, rootValue, context, variables); } -export function buildScript(steps=2) { +export function buildScript(steps = 2) { const createSteps = (step, max) => { if (max <= step) { - return [] + return []; } - return [{ - id: 'new'+step, - questionText: 'hmm' + step, - script: (step === 1 ? '{lastName}' : ( - step === 0 ? 'autorespond {zip}' : ('Step Script ' + step))), - answerOption: 'hmm' + step, - answerActions: '', - parentInteractionId: (step > 0 ? ('new' + (step - 1)) : null), - isDeleted: false, - interactionSteps: createSteps(step+1, max) - }] - } - return createSteps(0, steps) + return [ + { + id: "new" + step, + questionText: "hmm" + step, + script: + step === 1 + ? "{lastName}" + : step === 0 + ? "autorespond {zip}" + : "Step Script " + step, + answerOption: "hmm" + step, + answerActions: "", + parentInteractionId: step > 0 ? "new" + (step - 1) : null, + isDeleted: false, + interactionSteps: createSteps(step + 1, max) + } + ]; + }; + return createSteps(0, steps); } -export async function createScript(admin, campaign, interactionSteps, steps=2) { - const rootValue = {} +export async function createScript( + admin, + campaign, + interactionSteps, + steps = 2 +) { + const rootValue = {}; const campaignEditQuery = ` mutation editCampaign($campaignId: String!, $campaign: CampaignInput!) { editCampaign(id: $campaignId, campaign: $campaign) { id } - }` + }`; // function to create a recursive set of steps of arbitrary depth - let builtInteractionSteps + let builtInteractionSteps; if (!interactionSteps) { - builtInteractionSteps = buildScript(steps) + builtInteractionSteps = buildScript(steps); } - const context = getContext({ user: admin }) - const campaignId = campaign.id + const context = getContext({ user: admin }); + const campaignId = campaign.id; const variables = { campaignId, campaign: { - interactionSteps: (interactionSteps || builtInteractionSteps[0]) + interactionSteps: interactionSteps || builtInteractionSteps[0] } - } - return await graphql(mySchema, campaignEditQuery, rootValue, context, variables) + }; + return await graphql( + mySchema, + campaignEditQuery, + rootValue, + context, + variables + ); } export async function createCannedResponses(admin, campaign, cannedResponses) { // cannedResponses: {title, text} - const rootValue = {} + const rootValue = {}; const campaignEditQuery = ` mutation editCampaign($campaignId: String!, $campaign: CampaignInput!) { editCampaign(id: $campaignId, campaign: $campaign) { id } - }` - const context = getContext({ user: admin }) - const campaignId = campaign.id + }`; + const context = getContext({ user: admin }); + const campaignId = campaign.id; const variables = { campaignId, campaign: { cannedResponses } - } - return await graphql(mySchema, campaignEditQuery, rootValue, context, variables) - + }; + return await graphql( + mySchema, + campaignEditQuery, + rootValue, + context, + variables + ); } - -jest.mock('../src/server/mail') +jest.mock("../src/server/mail"); export async function startCampaign(admin, campaign) { - const rootValue = {} + const rootValue = {}; const startCampaignQuery = `mutation startCampaign($campaignId: String!) { startCampaign(id: $campaignId) { id } - }` - const context = getContext({ user: admin }) - const variables = { campaignId: campaign.id } - return await graphql(mySchema, startCampaignQuery, rootValue, context, variables) + }`; + const context = getContext({ user: admin }); + const variables = { campaignId: campaign.id }; + return await graphql( + mySchema, + startCampaignQuery, + rootValue, + context, + variables + ); } export async function getCampaignContact(id) { return await r - .knex('campaign_contact') - .where({ id }) - .first() + .knex("campaign_contact") + .where({ id }) + .first(); } diff --git a/__test__/workers/assign-texters.test.js b/__test__/workers/assign-texters.test.js index dc80e5b6c..003a118d7 100644 --- a/__test__/workers/assign-texters.test.js +++ b/__test__/workers/assign-texters.test.js @@ -1,54 +1,73 @@ -import {assignTexters} from '../../src/workers/jobs' -import {r, Campaign, CampaignContact, JobRequest, Organization, User, ZipCode} from '../../src/server/models' -import {setupTest, cleanupTest} from "../test_helpers"; +import { assignTexters } from "../../src/workers/jobs"; +import { + r, + Campaign, + CampaignContact, + JobRequest, + Organization, + User, + ZipCode +} from "../../src/server/models"; +import { setupTest, cleanupTest } from "../test_helpers"; -describe('test texter assignment in dynamic mode', () => { - - beforeAll(async () => await setupTest(), global.DATABASE_SETUP_TEARDOWN_TIMEOUT) - afterAll(async () => await cleanupTest(), global.DATABASE_SETUP_TEARDOWN_TIMEOUT) +describe("test texter assignment in dynamic mode", () => { + beforeAll( + async () => await setupTest(), + global.DATABASE_SETUP_TEARDOWN_TIMEOUT + ); + afterAll( + async () => await cleanupTest(), + global.DATABASE_SETUP_TEARDOWN_TIMEOUT + ); const testOrg = new Organization({ - id: '7777777', + id: "7777777", texting_hours_enforced: false, texting_hours_start: 9, - texting_hours_end: 14, - name: 'Test Organization' - }) + texting_hours_end: 14, + name: "Test Organization" + }); const testCampaign = new Campaign({ organization_id: testOrg.id, - id: '7777777', + id: "7777777", use_dynamic_assignment: true - }) + }); const texterInfo = [ - { - id: '1', - auth0_id: 'aaa', - first_name: 'Ruth', - last_name: 'Bader', - cell: '9999999999', - email: 'rbg@example.com', - }, - { - id: '2', - auth0_id: 'bbb', - first_name: 'Elena', - last_name: 'Kagan', - cell: '8888888888', - email: 'ek@example.com' - } - ] + { + id: "1", + auth0_id: "aaa", + first_name: "Ruth", + last_name: "Bader", + cell: "9999999999", + email: "rbg@example.com" + }, + { + id: "2", + auth0_id: "bbb", + first_name: "Elena", + last_name: "Kagan", + cell: "8888888888", + email: "ek@example.com" + } + ]; - const contactInfo = ['1111111111','2222222222','3333333333','4444444444','5555555555'] + const contactInfo = [ + "1111111111", + "2222222222", + "3333333333", + "4444444444", + "5555555555" + ]; - it('assigns no contacts to texters in dynamic assignment mode', async() => { - const organization = await Organization.save(testOrg) - const campaign = await Campaign.save(testCampaign) - contactInfo.map((contact) => { - CampaignContact.save({cell: contact, campaign_id: campaign.id}) - }) - texterInfo.map(async(texter) => { + it("assigns no contacts to texters in dynamic assignment mode", async () => { + const organization = await Organization.save(testOrg); + const campaign = await Campaign.save(testCampaign); + contactInfo.map(contact => { + CampaignContact.save({ cell: contact, campaign_id: campaign.id }); + }); + texterInfo.map(async texter => { await User.save({ id: texter.id, auth0_id: texter.auth0_id, @@ -56,59 +75,64 @@ describe('test texter assignment in dynamic mode', () => { last_name: texter.last_name, cell: texter.cell, email: texter.email - }) - }) - const payload = '{"id": "3","texters":[{"id":"1","needsMessageCount":5,"maxContacts":"","contactsCount":0},{"id":"2","needsMessageCount":5,"maxContacts":"0","contactsCount":0}]}' + }); + }); + const payload = + '{"id": "3","texters":[{"id":"1","needsMessageCount":5,"maxContacts":"","contactsCount":0},{"id":"2","needsMessageCount":5,"maxContacts":"0","contactsCount":0}]}'; const job = new JobRequest({ campaign_id: testCampaign.id, payload: payload, queue_name: "3:edit_campaign", - job_type: 'assign_texters', - }) - await assignTexters(job) - const result = await r.knex('campaign_contact') - .where({campaign_id: campaign.id}) - .whereNotNull('assignment_id') - .count() - const assignedTextersCount = result[0]["count"] - expect(assignedTextersCount).toEqual("0") - }) + job_type: "assign_texters" + }); + await assignTexters(job); + const result = await r + .knex("campaign_contact") + .where({ campaign_id: campaign.id }) + .whereNotNull("assignment_id") + .count(); + const assignedTextersCount = result[0]["count"]; + expect(assignedTextersCount).toEqual("0"); + }); - it('supports saving null or zero maxContacts', async() => { - const zero = await r.knex('assignment') - .where({campaign_id: testCampaign.id, user_id: "2"}) - .select('max_contacts') - const blank = await r.knex('assignment') - .where({campaign_id: testCampaign.id, user_id: "1"}) - .select('max_contacts') - const maxContactsZero = zero[0]["max_contacts"] - const maxContactsBlank = blank[0]["max_contacts"] - expect(maxContactsZero).toEqual(0) - expect(maxContactsBlank).toEqual(null) + it("supports saving null or zero maxContacts", async () => { + const zero = await r + .knex("assignment") + .where({ campaign_id: testCampaign.id, user_id: "2" }) + .select("max_contacts"); + const blank = await r + .knex("assignment") + .where({ campaign_id: testCampaign.id, user_id: "1" }) + .select("max_contacts"); + const maxContactsZero = zero[0]["max_contacts"]; + const maxContactsBlank = blank[0]["max_contacts"]; + expect(maxContactsZero).toEqual(0); + expect(maxContactsBlank).toEqual(null); + }); - }) - - it('updates max contacts when nothing else changes', async() => { - const payload = '{"id": "3","texters":[{"id":"1","needsMessageCount":0,"maxContacts":"10","contactsCount":0},{"id":"2","needsMessageCount":5,"maxContacts":"15","contactsCount":0}]}' + it("updates max contacts when nothing else changes", async () => { + const payload = + '{"id": "3","texters":[{"id":"1","needsMessageCount":0,"maxContacts":"10","contactsCount":0},{"id":"2","needsMessageCount":5,"maxContacts":"15","contactsCount":0}]}'; const job = new JobRequest({ campaign_id: testCampaign.id, payload: payload, queue_name: "4:edit_campaign", - job_type: 'assign_texters', - }) - await assignTexters(job) - const ten = await r.knex('assignment') - .where({campaign_id: testCampaign.id, user_id: "1"}) - .select('max_contacts') - const fifteen = await r.knex('assignment') - .where({campaign_id: testCampaign.id, user_id: "2"}) - .select('max_contacts') - const maxContactsTen = ten[0]["max_contacts"] - const maxContactsFifteen = fifteen[0]["max_contacts"] - expect(maxContactsTen).toEqual(10) - expect(maxContactsFifteen).toEqual(15) - }) - -}) + job_type: "assign_texters" + }); + await assignTexters(job); + const ten = await r + .knex("assignment") + .where({ campaign_id: testCampaign.id, user_id: "1" }) + .select("max_contacts"); + const fifteen = await r + .knex("assignment") + .where({ campaign_id: testCampaign.id, user_id: "2" }) + .select("max_contacts"); + const maxContactsTen = ten[0]["max_contacts"]; + const maxContactsFifteen = fifteen[0]["max_contacts"]; + expect(maxContactsTen).toEqual(10); + expect(maxContactsFifteen).toEqual(15); + }); +}); -// TODO: test in standard assignment mode \ No newline at end of file +// TODO: test in standard assignment mode diff --git a/__test__/workers/jobs.test.js b/__test__/workers/jobs.test.js index 3f9d73efc..8a8ab0c0b 100644 --- a/__test__/workers/jobs.test.js +++ b/__test__/workers/jobs.test.js @@ -1,71 +1,79 @@ -import {getTimezoneByZip} from '../../src/workers/jobs' -import {r, ZipCode} from '../../src/server/models' -import {setupTest, cleanupTest} from "../test_helpers"; +import { getTimezoneByZip } from "../../src/workers/jobs"; +import { r, ZipCode } from "../../src/server/models"; +import { setupTest, cleanupTest } from "../test_helpers"; -jest.mock('../../src/lib/zip-format') -var zipFormat = require('../../src/lib/zip-format') +jest.mock("../../src/lib/zip-format"); +var zipFormat = require("../../src/lib/zip-format"); -describe('test getTimezoneByZip', () => { +describe("test getTimezoneByZip", () => { + beforeAll( + async () => await setupTest(), + global.DATABASE_SETUP_TEARDOWN_TIMEOUT + ); + afterAll( + async () => await cleanupTest(), + global.DATABASE_SETUP_TEARDOWN_TIMEOUT + ); - beforeAll(async () => await setupTest(), global.DATABASE_SETUP_TEARDOWN_TIMEOUT) - afterAll(async () => await cleanupTest(), global.DATABASE_SETUP_TEARDOWN_TIMEOUT) + it("returns timezone data from the common zipcode/timezone mappings", async () => { + zipFormat.zipToTimeZone.mockReturnValueOnce([0, 0, 3, 1]); - it('returns timezone data from the common zipcode/timezone mappings', async () => { - zipFormat.zipToTimeZone.mockReturnValueOnce([0, 0, 3, 1]) + var good_things_come_to_those_who_wait = await getTimezoneByZip("11790"); + expect(good_things_come_to_those_who_wait).toEqual("3_1"); + }); - var good_things_come_to_those_who_wait = await getTimezoneByZip('11790') - expect(good_things_come_to_those_who_wait).toEqual('3_1') - }) + it("does not memoize common zipcode/timezone mappings", async () => { + zipFormat.zipToTimeZone.mockReturnValueOnce([0, 0, 4, 1]); - it('does not memoize common zipcode/timezone mappings', async () => { - zipFormat.zipToTimeZone.mockReturnValueOnce([0, 0, 4, 1]) + var future = await getTimezoneByZip("11790"); + expect(future).toEqual("4_1"); + }); - var future = await getTimezoneByZip('11790') - expect(future).toEqual('4_1') - }) + it("does not find a zipcode in the database!", async () => { + zipFormat.zipToTimeZone.mockReturnValueOnce(undefined); - it('does not find a zipcode in the database!', async () => { - zipFormat.zipToTimeZone.mockReturnValueOnce(undefined) + var future = await getTimezoneByZip("11790"); + expect(future).toEqual(""); + }); - var future = await getTimezoneByZip('11790') - expect(future).toEqual('') - }) - - it('finds a zipcode in the database and memoizes it', async () => { - zipFormat.zipToTimeZone.mockReturnValueOnce(undefined) + it("finds a zipcode in the database and memoizes it", async () => { + zipFormat.zipToTimeZone.mockReturnValueOnce(undefined); try { var zipCode = new ZipCode({ - zip: '11790', - city: 'Stony Brook', - state: 'NY', + zip: "11790", + city: "Stony Brook", + state: "NY", timezone_offset: 7, has_dst: true, latitude: 0, longitude: 0 - }) - var future = await ZipCode.save(zipCode) - expect(future).resolves - - future = await getTimezoneByZip('11790') - expect(future).toEqual('7_1') - - future = await r.table('zip_code').getAll().delete() - expect(future).resolves - - future = await r.table('zip_code').get('11790') - expect(future).toEqual([]) - - future = await getTimezoneByZip('11790') - expect(future).toEqual('7_1') - } - finally { - return await r.table('zip_code').getAll().delete() + }); + var future = await ZipCode.save(zipCode); + expect(future).resolves; + + future = await getTimezoneByZip("11790"); + expect(future).toEqual("7_1"); + + future = await r + .table("zip_code") + .getAll() + .delete(); + expect(future).resolves; + + future = await r.table("zip_code").get("11790"); + expect(future).toEqual([]); + + future = await getTimezoneByZip("11790"); + expect(future).toEqual("7_1"); + } finally { + return await r + .table("zip_code") + .getAll() + .delete(); } - - - }) -}) + }); +}); // TODO // 1. loadContacts with upload diff --git a/dev-tools/babel-run-with-env.js b/dev-tools/babel-run-with-env.js index fab9eb5f3..2afb295ac 100755 --- a/dev-tools/babel-run-with-env.js +++ b/dev-tools/babel-run-with-env.js @@ -1,5 +1,5 @@ #!/usr/bin/env node -require('dotenv').config() -require('babel-register') -require('babel-polyfill') -require('../' + process.argv[2]) +require("dotenv").config(); +require("babel-register"); +require("babel-polyfill"); +require("../" + process.argv[2]); diff --git a/dev-tools/db-startup.js b/dev-tools/db-startup.js index cdcebd012..e1a92a67a 100644 --- a/dev-tools/db-startup.js +++ b/dev-tools/db-startup.js @@ -1,3 +1 @@ -import '../src/server/models' // This forces Thinky to autocreate the database, tables, and indexes if they don't exist - - +import "../src/server/models"; // This forces Thinky to autocreate the database, tables, and indexes if they don't exist diff --git a/dev-tools/export-broken-interaction-steps.js b/dev-tools/export-broken-interaction-steps.js index bd8ab88c9..832311a74 100644 --- a/dev-tools/export-broken-interaction-steps.js +++ b/dev-tools/export-broken-interaction-steps.js @@ -1,44 +1,52 @@ -import { r } from '../src/server/models' -import Papa from 'papaparse' +import { r } from "../src/server/models"; +import Papa from "papaparse"; -(async function () { +(async function() { try { - const res = await r.table('question_response') - .merge((row) => ({ - campaign_contact: r.table('campaign_contact') - .get(row('campaign_contact_id')) + const res = await r + .table("question_response") + .merge(row => ({ + campaign_contact: r + .table("campaign_contact") + .get(row("campaign_contact_id")) })) - .merge((row) => ({ - interaction_step: r.table('interaction_step') - .get(row('interaction_step_id')) + .merge(row => ({ + interaction_step: r + .table("interaction_step") + .get(row("interaction_step_id")) })) - .merge((row) => ({ - campaign: r.table('campaign') - .get(row('campaign_contact')('campaign_id')) + .merge(row => ({ + campaign: r + .table("campaign") + .get(row("campaign_contact")("campaign_id")) })) - .merge((row) => ({ - organization: r.table('organization') - .get(row('campaign')('organization_id')) + .merge(row => ({ + organization: r + .table("organization") + .get(row("campaign")("organization_id")) })) .filter({ interaction_step: null }) - .group(r.row('campaign')('id')) - const finalResults = res.map((doc) => ( - doc.reduction.map((row) => ({ - organization: row.organization.name, - 'campaign[title]': row.campaign.title, - 'contact[cell]': row.campaign_contact.cell, - 'contact[first_name]': row.campaign_contact.first_name, - 'contact[last_name]': row.campaign_contact.last_name, - interaction_step_id: row.interaction_step_id, - value: row.value - })) - .reduce((left, right) => left.concat(right), []) - )).reduce((left, right) => left.concat(right), []) + .group(r.row("campaign")("id")); + const finalResults = res + .map(doc => + doc.reduction + .map(row => ({ + organization: row.organization.name, + "campaign[title]": row.campaign.title, + "contact[cell]": row.campaign_contact.cell, + "contact[first_name]": row.campaign_contact.first_name, + "contact[last_name]": row.campaign_contact.last_name, + interaction_step_id: row.interaction_step_id, + value: row.value + })) + .reduce((left, right) => left.concat(right), []) + ) + .reduce((left, right) => left.concat(right), []); - console.log(finalResults[0]) - const csvResults = Papa.unparse(finalResults) - console.log(csvResults) + console.log(finalResults[0]); + const csvResults = Papa.unparse(finalResults); + console.log(csvResults); } catch (ex) { - console.log(ex) + console.log(ex); } -})() +})(); diff --git a/dev-tools/export-query.js b/dev-tools/export-query.js index 52b5e3e19..ac1d929ab 100644 --- a/dev-tools/export-query.js +++ b/dev-tools/export-query.js @@ -1,26 +1,25 @@ -import { r } from '../src/server/models' -import Papa from 'papaparse' +import { r } from "../src/server/models"; +import Papa from "papaparse"; -(async function () { +(async function() { try { - const res = await r.table('message') - .eqJoin('assignment_id', r.table('assignment')) + const res = await r + .table("message") + .eqJoin("assignment_id", r.table("assignment")) .zip() - .filter({ campaign_id: process.env.CAMPAIGN_ID }) - const finalResults = res.map((row) => ( - { - assignment_id: row.assignment_id, - campaign_id: row.campaign_id, - contact_number: row.contact_number, - user_number: row.user_number, - is_from_contact: row.is_from_contact, - send_status: row.send_status, - text: row.text - } - )) - const csvResults = Papa.unparse(finalResults) - console.log(csvResults) + .filter({ campaign_id: process.env.CAMPAIGN_ID }); + const finalResults = res.map(row => ({ + assignment_id: row.assignment_id, + campaign_id: row.campaign_id, + contact_number: row.contact_number, + user_number: row.user_number, + is_from_contact: row.is_from_contact, + send_status: row.send_status, + text: row.text + })); + const csvResults = Papa.unparse(finalResults); + console.log(csvResults); } catch (ex) { - console.log(ex) + console.log(ex); } -})() +})(); diff --git a/dev-tools/generate-contacts.js b/dev-tools/generate-contacts.js index 3ea915108..41ab0169d 100644 --- a/dev-tools/generate-contacts.js +++ b/dev-tools/generate-contacts.js @@ -1,10 +1,10 @@ -import faker from 'faker' -import json2csv from 'json2csv' +import faker from "faker"; +import json2csv from "json2csv"; -const fields = ['firstName', 'lastName', 'cell', 'companyName', 'city', 'zip'] -const numContacts = 10000 +const fields = ["firstName", "lastName", "cell", "companyName", "city", "zip"]; +const numContacts = 10000; -const data = [] +const data = []; for (let index = 0; index < numContacts; index++) { data.push({ firstName: faker.name.firstName(), @@ -13,8 +13,8 @@ for (let index = 0; index < numContacts; index++) { companyName: faker.company.companyName(), city: faker.address.city(), zip: faker.address.zipCode() - }) + }); } -const csvFile = json2csv({ data, fields }) -console.log(csvFile) +const csvFile = json2csv({ data, fields }); +console.log(csvFile); diff --git a/dev-tools/jest.transform.js b/dev-tools/jest.transform.js index e390c671f..b9a324bf2 100644 --- a/dev-tools/jest.transform.js +++ b/dev-tools/jest.transform.js @@ -1,6 +1,6 @@ #!/usr/bin/env node -var config = require('dotenv').config() -require('babel-register') -require('babel-polyfill') -require('../' + process.argv[2]) -model.exports = require('babel-jest').createTransformer(config) +var config = require("dotenv").config(); +require("babel-register"); +require("babel-polyfill"); +require("../" + process.argv[2]); +model.exports = require("babel-jest").createTransformer(config); diff --git a/jest.config.e2e.js b/jest.config.e2e.js index b7cf22f1b..edaae8e03 100644 --- a/jest.config.e2e.js +++ b/jest.config.e2e.js @@ -1,22 +1,25 @@ -const _ = require('lodash') -const config = require('./jest.config') +const _ = require("lodash"); +const config = require("./jest.config"); const overrides = { - setupTestFrameworkScriptFile: '/__test__/e2e/util/setup.js', - testMatch: ['**/__test__/e2e/**/*.test.js'], - testPathIgnorePatterns: ['/node_modules/', '/__test__/e2e/util/', '/__test__/e2e/pom/'], + setupTestFrameworkScriptFile: "/__test__/e2e/util/setup.js", + testMatch: ["**/__test__/e2e/**/*.test.js"], + testPathIgnorePatterns: [ + "/node_modules/", + "/__test__/e2e/util/", + "/__test__/e2e/pom/" + ], bail: true // To learn about errors sooner -} +}; const merges = { // Merge in changes to deeper objects globals: { // This sets the BASE_URL for the target of the e2e tests (what the tests are testing) - BASE_URL: 'localhost:3000' + BASE_URL: "localhost:3000" } -} +}; -module.exports = _ - .chain(config) +module.exports = _.chain(config) .assign(overrides) .merge(merges) - .value() + .value(); diff --git a/jest.config.js b/jest.config.js index 89937ad18..08b4e3d03 100644 --- a/jest.config.js +++ b/jest.config.js @@ -8,37 +8,39 @@ module.exports = { client: "pg", connection: { host: "127.0.0.1", - "port": "5432", - "database": "spoke_test", - "password": "spoke_test", - "user": "spoke_test" - }, + port: "5432", + database: "spoke_test", + password: "spoke_test", + user: "spoke_test" + } }), JOBS_SYNC: "1", JOBS_SAME_PROCESS: "1", RETHINK_KNEX_NOREFS: "1", // avoids db race conditions - DEFAULT_SERVICE: 'fakeservice', - DST_REFERENCE_TIMEZONE: 'America/New_York', + DEFAULT_SERVICE: "fakeservice", + DST_REFERENCE_TIMEZONE: "America/New_York", DATABASE_SETUP_TEARDOWN_TIMEOUT: 60000, - PASSPORT_STRATEGY: 'local', - SESSION_SECRET: 'it is JUST a test! -- it better be!', - TEST_ENVIRONMENT: '1' + PASSPORT_STRATEGY: "local", + SESSION_SECRET: "it is JUST a test! -- it better be!", + TEST_ENVIRONMENT: "1" }, - moduleFileExtensions: [ - "js", - "jsx" - ], + moduleFileExtensions: ["js", "jsx"], transform: { ".*.js": "/node_modules/babel-jest" }, - moduleDirectories: [ - "node_modules" - ], + moduleDirectories: ["node_modules"], moduleNameMapper: { - "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/__mocks__/fileMock.js", + "\\.(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" }, - collectCoverageFrom: ["**/*.{js,jsx}", "!**/node_modules/**", "!**/__test__/**", "!**/deploy/**", "!**/coverage/**"], + collectCoverageFrom: [ + "**/*.{js,jsx}", + "!**/node_modules/**", + "!**/__test__/**", + "!**/deploy/**", + "!**/coverage/**" + ], setupTestFrameworkScriptFile: "/__test__/setup.js", testPathIgnorePatterns: ["/node_modules/", "/__test__/e2e/"] }; diff --git a/jest.config.sqlite.js b/jest.config.sqlite.js index 872856528..d80fd209f 100644 --- a/jest.config.sqlite.js +++ b/jest.config.sqlite.js @@ -1,6 +1,6 @@ -module.exports = require('./jest.config') +module.exports = require("./jest.config"); module.exports.globals.DB_JSON = JSON.stringify({ client: "sqlite3", - connection: {filename:"./test.sqlite"}, + connection: { filename: "./test.sqlite" }, defaultsUnsupported: true -}) +}); diff --git a/knexfile.env.js b/knexfile.env.js index a7588e9e0..8d69eb435 100644 --- a/knexfile.env.js +++ b/knexfile.env.js @@ -1,7 +1,7 @@ -require('dotenv').config() +require("dotenv").config(); // environment variables will be populated from above, and influence the knex-connect import -var config = require('./src/server/knex-connect') +var config = require("./src/server/knex-connect"); module.exports = { development: config, diff --git a/lambda.js b/lambda.js index 74d5ab1ec..8b7cafdbc 100644 --- a/lambda.js +++ b/lambda.js @@ -1,18 +1,18 @@ -'use strict' -const AWS = require('aws-sdk') -const awsServerlessExpress = require('aws-serverless-express') -let app, server, jobs +"use strict"; +const AWS = require("aws-sdk"); +const awsServerlessExpress = require("aws-serverless-express"); +let app, server, jobs; try { - app = require('./build/server/server/index') - server = awsServerlessExpress.createServer(app.default) - jobs = require('./build/server/workers/job-processes') -} catch(err) { + app = require("./build/server/server/index"); + server = awsServerlessExpress.createServer(app.default); + jobs = require("./build/server/workers/job-processes"); +} catch (err) { if (!global.TEST_ENVIRONMENT) { - console.error(`Unable to load built server: ${err}`) + console.error(`Unable to load built server: ${err}`); } - app = require('./src/server/index') - server = awsServerlessExpress.createServer(app.default) - jobs = require('./src/workers/job-processes') + app = require("./src/server/index"); + server = awsServerlessExpress.createServer(app.default); + jobs = require("./src/workers/job-processes"); } // NOTE: the downside of loading above is environment variables are initially loaded immediately, @@ -21,20 +21,20 @@ try { // See: http://docs.aws.amazon.com/lambda/latest/dg/best-practices.html#function-code // "Separate the Lambda handler (entry point) from your core logic" -let invocationContext = {} -let invocationEvent = {} -app.default.set('awsContextGetter', function(req, res) { - return [invocationEvent, invocationContext] -}) +let invocationContext = {}; +let invocationEvent = {}; +app.default.set("awsContextGetter", function(req, res) { + return [invocationEvent, invocationContext]; +}); function cleanHeaders(event) { // X-Twilio-Body can contain unicode and disallowed chars by aws-serverless-express like "'" // We don't need it anyway if (event.headers) { - delete event.headers['X-Twilio-Body'] + delete event.headers["X-Twilio-Body"]; } if (event.multiValueHeaders) { - delete event.multiValueHeaders['X-Twilio-Body'] + delete event.multiValueHeaders["X-Twilio-Body"]; } } @@ -43,55 +43,65 @@ exports.handler = (event, context, handleCallback) => { // or Lambda will re-run/re-try the invocation twice: // https://docs.aws.amazon.com/lambda/latest/dg/retries-on-errors.html if (process.env.LAMBDA_DEBUG_LOG) { - console.log('LAMBDA EVENT', event) + console.log("LAMBDA EVENT", event); } if (!event.command) { // default web server stuff - const startTime = (context.getRemainingTimeInMillis ? context.getRemainingTimeInMillis() : 0) - invocationEvent = event - invocationContext = context - cleanHeaders(event) - const webResponse = awsServerlessExpress.proxy(server, event, context) + const startTime = context.getRemainingTimeInMillis + ? context.getRemainingTimeInMillis() + : 0; + invocationEvent = event; + invocationContext = context; + cleanHeaders(event); + const webResponse = awsServerlessExpress.proxy(server, event, context); if (process.env.DEBUG_SCALING) { - const endTime = (context.getRemainingTimeInMillis ? context.getRemainingTimeInMillis() : 0) - if ((endTime - startTime) > 3000) { //3 seconds - console.log('SLOW_RESPONSE milliseconds:', endTime-startTime, event) + const endTime = context.getRemainingTimeInMillis + ? context.getRemainingTimeInMillis() + : 0; + if (endTime - startTime > 3000) { + //3 seconds + console.log("SLOW_RESPONSE milliseconds:", endTime - startTime, event); } } - return webResponse + return webResponse; } else { // handle a custom command sent as an event - const functionName = context.functionName + const functionName = context.functionName; if (event.env) { for (var a in event.env) { - process.env[a] = event.env[a] + process.env[a] = event.env[a]; } } - console.log('Running ' + event.command) + console.log("Running " + event.command); if (event.command in jobs) { - const job = jobs[event.command] + const job = jobs[event.command]; // behavior and arguments documented here: // https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/Lambda.html#invoke-property - job(event, - function dispatcher(dataToSend, callback) { - const lambda = new AWS.Lambda() - return lambda.invoke({ + job( + event, + function dispatcher(dataToSend, callback) { + const lambda = new AWS.Lambda(); + return lambda.invoke( + { FunctionName: functionName, InvocationType: "Event", //asynchronous Payload: JSON.stringify(dataToSend) - }, function(err, dataReceived) { + }, + function(err, dataReceived) { if (err) { - console.error('Failed to invoke Lambda job: ', err) + console.error("Failed to invoke Lambda job: ", err); } if (callback) { - callback(err, dataReceived) + callback(err, dataReceived); } - }) - }, - handleCallback) + } + ); + }, + handleCallback + ); } else { - console.error('Unfound command sent as a Lambda event: ' + event.command) + console.error("Unfound command sent as a Lambda event: " + event.command); } } -} +}; diff --git a/migrations/20190207220000_init_db.js b/migrations/20190207220000_init_db.js index 485d840da..2e5d7e25d 100644 --- a/migrations/20190207220000_init_db.js +++ b/migrations/20190207220000_init_db.js @@ -2,331 +2,431 @@ const initialize = async (knex, Promise) => { // This object's keys are table names and each key's value is a function that defines that table's schema. const buildTableSchema = [ { - tableName: 'user', + tableName: "user", create: t => { - t.increments('id').primary() - t.text('auth0_id').notNullable() - t.text('first_name').notNullable() - t.text('last_name').notNullable() - t.text('cell').notNullable() - t.text('email').notNullable() - t.timestamp('created_at').notNullable().defaultTo(knex.fn.now()) - t.text('assigned_cell') - t.boolean('is_superadmin') - t.boolean('terms').defaultTo(false) + t.increments("id").primary(); + t.text("auth0_id").notNullable(); + t.text("first_name").notNullable(); + t.text("last_name").notNullable(); + t.text("cell").notNullable(); + t.text("email").notNullable(); + t.timestamp("created_at") + .notNullable() + .defaultTo(knex.fn.now()); + t.text("assigned_cell"); + t.boolean("is_superadmin"); + t.boolean("terms").defaultTo(false); } }, { - tableName: 'pending_message_part', + tableName: "pending_message_part", create: t => { - t.increments('id').primary() - t.text('service').notNullable() - t.text('service_id').notNullable() - t.text('parent_id').defaultTo('') - t.text('service_message').notNullable() - t.text('user_number').notNullable().defaultTo('') - t.text('contact_number').notNullable() - t.timestamp('created_at').defaultTo(knex.fn.now()).notNullable() + t.increments("id").primary(); + t.text("service").notNullable(); + t.text("service_id").notNullable(); + t.text("parent_id").defaultTo(""); + t.text("service_message").notNullable(); + t.text("user_number") + .notNullable() + .defaultTo(""); + t.text("contact_number").notNullable(); + t.timestamp("created_at") + .defaultTo(knex.fn.now()) + .notNullable(); - t.index('parent_id') - t.index('service') + t.index("parent_id"); + t.index("service"); } }, { - tableName: 'organization', + tableName: "organization", create: t => { - t.increments('id') - t.text('uuid') - t.text('name').notNullable() - t.timestamp('created_at').defaultTo(knex.fn.now()).notNullable() - t.text('features').defaultTo('') - t.boolean('texting_hours_enforced').defaultTo(false) - t.integer('texting_hours_start').defaultTo(9) - t.integer('texting_hours_end').defaultTo(21) + t.increments("id"); + t.text("uuid"); + t.text("name").notNullable(); + t.timestamp("created_at") + .defaultTo(knex.fn.now()) + .notNullable(); + t.text("features").defaultTo(""); + t.boolean("texting_hours_enforced").defaultTo(false); + t.integer("texting_hours_start").defaultTo(9); + t.integer("texting_hours_end").defaultTo(21); } }, { - tableName: 'campaign', + tableName: "campaign", create: t => { - t.increments('id') - t.integer('organization_id').notNullable() - t.text('title').notNullable().defaultTo('') - t.text('description').notNullable().defaultTo('') - t.boolean('is_started') - t.timestamp('due_by').defaultTo(null) - t.timestamp('created_at').notNullable().defaultTo(knex.fn.now()) - t.boolean('is_archived') - t.boolean('use_dynamic_assignment') - t.text('logo_image_url') - t.text('intro_html') - t.text('primary_color') - t.boolean('override_organization_texting_hours').defaultTo(false) - t.boolean('texting_hours_enforced').defaultTo(true) - t.integer('texting_hours_start').defaultTo(9) - t.integer('texting_hours_end').defaultTo(21) - t.text('timezone').defaultTo('US/Eastern') + t.increments("id"); + t.integer("organization_id").notNullable(); + t.text("title") + .notNullable() + .defaultTo(""); + t.text("description") + .notNullable() + .defaultTo(""); + t.boolean("is_started"); + t.timestamp("due_by").defaultTo(null); + t.timestamp("created_at") + .notNullable() + .defaultTo(knex.fn.now()); + t.boolean("is_archived"); + t.boolean("use_dynamic_assignment"); + t.text("logo_image_url"); + t.text("intro_html"); + t.text("primary_color"); + t.boolean("override_organization_texting_hours").defaultTo(false); + t.boolean("texting_hours_enforced").defaultTo(true); + t.integer("texting_hours_start").defaultTo(9); + t.integer("texting_hours_end").defaultTo(21); + t.text("timezone").defaultTo("US/Eastern"); - t.index('organization_id') - t.foreign('organization_id').references('organization.id') + t.index("organization_id"); + t.foreign("organization_id").references("organization.id"); t.integer("creator_id") .unsigned() .nullable() .default(null) .index() - .references("user.id") + .references("user.id"); } }, { - tableName: 'assignment', + tableName: "assignment", create: t => { - t.increments('id') - t.integer('user_id').notNullable() - t.integer('campaign_id').notNullable() - t.timestamp('created_at').defaultTo(knex.fn.now()).notNullable() - t.integer('max_contacts') + t.increments("id"); + t.integer("user_id").notNullable(); + t.integer("campaign_id").notNullable(); + t.timestamp("created_at") + .defaultTo(knex.fn.now()) + .notNullable(); + t.integer("max_contacts"); - t.index('user_id') - t.foreign('user_id').references('user.id') - t.index('campaign_id') - t.foreign('campaign_id').references('campaign.id') + t.index("user_id"); + t.foreign("user_id").references("user.id"); + t.index("campaign_id"); + t.foreign("campaign_id").references("campaign.id"); } }, { - tableName: 'campaign_contact', + tableName: "campaign_contact", create: t => { - t.increments('id') - t.integer('campaign_id').notNullable() - t.integer('assignment_id') - t.text('external_id').notNullable().defaultTo('') - t.text('first_name').notNullable().defaultTo('') - t.text('last_name').notNullable().defaultTo('') - t.text('cell').notNullable() - t.text('zip').defaultTo('').notNullable() - t.text('custom_fields').notNullable().defaultTo('{}') - t.timestamp('created_at').defaultTo(knex.fn.now()).notNullable() - t.timestamp('updated_at').defaultTo(knex.fn.now()).notNullable() - t.enu('message_status', [ - 'needsMessage', - 'needsResponse', - 'convo', - 'messaged', - 'closed', - 'UPDATING' + t.increments("id"); + t.integer("campaign_id").notNullable(); + t.integer("assignment_id"); + t.text("external_id") + .notNullable() + .defaultTo(""); + t.text("first_name") + .notNullable() + .defaultTo(""); + t.text("last_name") + .notNullable() + .defaultTo(""); + t.text("cell").notNullable(); + t.text("zip") + .defaultTo("") + .notNullable(); + t.text("custom_fields") + .notNullable() + .defaultTo("{}"); + t.timestamp("created_at") + .defaultTo(knex.fn.now()) + .notNullable(); + t.timestamp("updated_at") + .defaultTo(knex.fn.now()) + .notNullable(); + t.enu("message_status", [ + "needsMessage", + "needsResponse", + "convo", + "messaged", + "closed", + "UPDATING" ]) - .defaultTo('needsMessage') - .notNullable() - t.boolean('is_opted_out').defaultTo(false) - t.text('timezone_offset').defaultTo('') + .defaultTo("needsMessage") + .notNullable(); + t.boolean("is_opted_out").defaultTo(false); + t.text("timezone_offset").defaultTo(""); - t.index('assignment_id') - t.foreign('assignment_id').references('assignment.id') - t.index('campaign_id') - t.foreign('campaign_id').references('campaign.id') - t.index('cell') - t.index(['campaign_id', 'assignment_id'], 'campaign_contact_campaign_id_assignment_id_index') - t.index(['assignment_id', 'timezone_offset'], 'campaign_contact_assignment_id_timezone_offset_index') // See footnote ¹ for clarification on naming. + t.index("assignment_id"); + t.foreign("assignment_id").references("assignment.id"); + t.index("campaign_id"); + t.foreign("campaign_id").references("campaign.id"); + t.index("cell"); + t.index( + ["campaign_id", "assignment_id"], + "campaign_contact_campaign_id_assignment_id_index" + ); + t.index( + ["assignment_id", "timezone_offset"], + "campaign_contact_assignment_id_timezone_offset_index" + ); // See footnote ¹ for clarification on naming. } }, { - tableName: 'interaction_step', + tableName: "interaction_step", create: t => { - t.increments('id') - t.integer('campaign_id').notNullable() - t.text('question').notNullable().defaultTo('') - t.text('script').notNullable().defaultTo('') - t.timestamp('created_at').notNullable().defaultTo(knex.fn.now()) + t.increments("id"); + t.integer("campaign_id").notNullable(); + t.text("question") + .notNullable() + .defaultTo(""); + t.text("script") + .notNullable() + .defaultTo(""); + t.timestamp("created_at") + .notNullable() + .defaultTo(knex.fn.now()); // FIELDS FOR SUB-INTERACTIONS (only): - t.integer('parent_interaction_id') - t.text('answer_option').notNullable().defaultTo('') - t.text('answer_actions').notNullable().defaultTo('') - t.boolean('is_deleted').defaultTo(false).notNullable() + t.integer("parent_interaction_id"); + t.text("answer_option") + .notNullable() + .defaultTo(""); + t.text("answer_actions") + .notNullable() + .defaultTo(""); + t.boolean("is_deleted") + .defaultTo(false) + .notNullable(); - t.index('parent_interaction_id') - t.foreign('parent_interaction_id').references('interaction_step.id') - t.index('campaign_id') - t.foreign('campaign_id').references('campaign.id') + t.index("parent_interaction_id"); + t.foreign("parent_interaction_id").references("interaction_step.id"); + t.index("campaign_id"); + t.foreign("campaign_id").references("campaign.id"); } }, { - tableName: 'question_response', + tableName: "question_response", create: t => { - t.increments('id') - t.integer('campaign_contact_id').notNullable() - t.integer('interaction_step_id').notNullable() - t.text('value').notNullable() - t.timestamp('created_at').notNullable().defaultTo(knex.fn.now()) + t.increments("id"); + t.integer("campaign_contact_id").notNullable(); + t.integer("interaction_step_id").notNullable(); + t.text("value").notNullable(); + t.timestamp("created_at") + .notNullable() + .defaultTo(knex.fn.now()); - t.index('campaign_contact_id') - t.foreign('campaign_contact_id').references('campaign_contact.id') - t.index('interaction_step_id') - t.foreign('interaction_step_id').references('interaction_step.id') + t.index("campaign_contact_id"); + t.foreign("campaign_contact_id").references("campaign_contact.id"); + t.index("interaction_step_id"); + t.foreign("interaction_step_id").references("interaction_step.id"); } }, { - tableName: 'opt_out', + tableName: "opt_out", create: t => { - t.increments('id') - t.text('cell').notNullable() - t.integer('assignment_id').notNullable() - t.integer('organization_id').notNullable() - t.text('reason_code').notNullable().defaultTo('') - t.timestamp('created_at').notNullable().defaultTo(knex.fn.now()) + t.increments("id"); + t.text("cell").notNullable(); + t.integer("assignment_id").notNullable(); + t.integer("organization_id").notNullable(); + t.text("reason_code") + .notNullable() + .defaultTo(""); + t.timestamp("created_at") + .notNullable() + .defaultTo(knex.fn.now()); - t.index('cell') - t.index('assignment_id') - t.foreign('assignment_id').references('assignment.id') - t.index('organization_id') - t.foreign('organization_id').references('organization.id') + t.index("cell"); + t.index("assignment_id"); + t.foreign("assignment_id").references("assignment.id"); + t.index("organization_id"); + t.foreign("organization_id").references("organization.id"); } }, // The migrations table appears at this position in the list, but Knex manages that table itself, so it's ommitted from the schema builder { - tableName: 'job_request', + tableName: "job_request", create: t => { - t.increments('id') - t.integer('campaign_id').notNullable() - t.text('payload').notNullable() - t.text('queue_name').notNullable() - t.text('job_type').notNullable() - t.text('result_message').defaultTo('') - t.boolean('locks_queue').defaultTo(false) - t.boolean('assigned').defaultTo(false) - t.integer('status').defaultTo(0) - t.timestamp('updated_at').notNullable().defaultTo(knex.fn.now()) - t.timestamp('created_at').notNullable().defaultTo(knex.fn.now()) + t.increments("id"); + t.integer("campaign_id").notNullable(); + t.text("payload").notNullable(); + t.text("queue_name").notNullable(); + t.text("job_type").notNullable(); + t.text("result_message").defaultTo(""); + t.boolean("locks_queue").defaultTo(false); + t.boolean("assigned").defaultTo(false); + t.integer("status").defaultTo(0); + t.timestamp("updated_at") + .notNullable() + .defaultTo(knex.fn.now()); + t.timestamp("created_at") + .notNullable() + .defaultTo(knex.fn.now()); - t.index('queue_name') - t.foreign('campaign_id').references('campaign.id') + t.index("queue_name"); + t.foreign("campaign_id").references("campaign.id"); } }, { - tableName: 'invite', + tableName: "invite", create: t => { - t.increments('id') - t.boolean('is_valid').notNullable() - t.text('hash') - t.timestamp('created_at').notNullable().defaultTo(knex.fn.now()) + t.increments("id"); + t.boolean("is_valid").notNullable(); + t.text("hash"); + t.timestamp("created_at") + .notNullable() + .defaultTo(knex.fn.now()); - t.index('is_valid') + t.index("is_valid"); } }, { - tableName: 'canned_response', + tableName: "canned_response", create: t => { - t.increments('id') - t.integer('campaign_id').notNullable() - t.text('text').notNullable() - t.text('title').notNullable() - t.integer('user_id') - t.timestamp('created_at').notNullable().defaultTo(knex.fn.now()) + t.increments("id"); + t.integer("campaign_id").notNullable(); + t.text("text").notNullable(); + t.text("title").notNullable(); + t.integer("user_id"); + t.timestamp("created_at") + .notNullable() + .defaultTo(knex.fn.now()); - t.index('campaign_id') - t.foreign('campaign_id').references('campaign.id') - t.index('user_id') - t.foreign('user_id').references('user.id') + t.index("campaign_id"); + t.foreign("campaign_id").references("campaign.id"); + t.index("user_id"); + t.foreign("user_id").references("user.id"); } }, { - tableName: 'user_organization', + tableName: "user_organization", create: t => { - t.increments('id') - t.integer('user_id').notNullable() - t.integer('organization_id').notNullable() - t.enu('role', ['OWNER', 'ADMIN', 'SUPERVOLUNTEER', 'TEXTER']).notNullable() + t.increments("id"); + t.integer("user_id").notNullable(); + t.integer("organization_id").notNullable(); + t.enu("role", [ + "OWNER", + "ADMIN", + "SUPERVOLUNTEER", + "TEXTER" + ]).notNullable(); - t.index('user_id') - t.foreign('user_id').references('user.id') - t.index('organization_id') - t.foreign('organization_id').references('organization.id') - t.index(['organization_id', 'user_id'], 'user_organization_organization_id_user_id_index') + t.index("user_id"); + t.foreign("user_id").references("user.id"); + t.index("organization_id"); + t.foreign("organization_id").references("organization.id"); + t.index( + ["organization_id", "user_id"], + "user_organization_organization_id_user_id_index" + ); // rethink-knex-adapter doesn't properly preserve index names when making multicolumn indexes, so the name here ('user_organization_organization_id_user_id_index') is different from the corresponding Thinky model ('organization_user'). However, the underlying PG index that is created has the same name, so the tests pass.¹ } }, { - tableName: 'user_cell', + tableName: "user_cell", create: t => { - t.increments('id').primary() - t.text('cell').notNullable() - t.integer('user_id').notNullable() - t.enu('service', ['nexmo', 'twilio']) - t.boolean('is_primary') + t.increments("id").primary(); + t.text("cell").notNullable(); + t.integer("user_id").notNullable(); + t.enu("service", ["nexmo", "twilio"]); + t.boolean("is_primary"); - t.foreign('user_id').references('user.id') + t.foreign("user_id").references("user.id"); } }, { - tableName: 'message', + tableName: "message", create: t => { - t.increments('id').primary() - t.text('user_number').notNullable().defaultTo('') - t.integer('user_id') - t.text('contact_number').notNullable() - t.boolean('is_from_contact').notNullable() - t.text('text').notNullable().defaultTo('') - t.text('service_response').notNullable().defaultTo('') - t.integer('assignment_id').notNullable() - t.text('service').notNullable().defaultTo('') - t.text('service_id').notNullable().defaultTo('') - t.enu('send_status', ['QUEUED', 'SENDING', 'SENT', 'DELIVERED', 'ERROR', 'PAUSED', 'NOT_ATTEMPTED']).notNullable() - t.timestamp('created_at').defaultTo(knex.fn.now()).notNullable() - t.timestamp('queued_at').defaultTo(knex.fn.now()).notNullable() - t.timestamp('sent_at').defaultTo(knex.fn.now()).notNullable() - t.timestamp('service_response_at').defaultTo(knex.fn.now()).notNullable() - t.timestamp('send_before') + t.increments("id").primary(); + t.text("user_number") + .notNullable() + .defaultTo(""); + t.integer("user_id"); + t.text("contact_number").notNullable(); + t.boolean("is_from_contact").notNullable(); + t.text("text") + .notNullable() + .defaultTo(""); + t.text("service_response") + .notNullable() + .defaultTo(""); + t.integer("assignment_id").notNullable(); + t.text("service") + .notNullable() + .defaultTo(""); + t.text("service_id") + .notNullable() + .defaultTo(""); + t.enu("send_status", [ + "QUEUED", + "SENDING", + "SENT", + "DELIVERED", + "ERROR", + "PAUSED", + "NOT_ATTEMPTED" + ]).notNullable(); + t.timestamp("created_at") + .defaultTo(knex.fn.now()) + .notNullable(); + t.timestamp("queued_at") + .defaultTo(knex.fn.now()) + .notNullable(); + t.timestamp("sent_at") + .defaultTo(knex.fn.now()) + .notNullable(); + t.timestamp("service_response_at") + .defaultTo(knex.fn.now()) + .notNullable(); + t.timestamp("send_before"); - t.index('assignment_id') - t.foreign('assignment_id').references('assignment.id') - t.foreign('user_id').references('user.id') - t.index('send_status') - t.index('user_number') - t.index('contact_number') - t.index('service_id') + t.index("assignment_id"); + t.foreign("assignment_id").references("assignment.id"); + t.foreign("user_id").references("user.id"); + t.index("send_status"); + t.index("user_number"); + t.index("contact_number"); + t.index("service_id"); } }, { - tableName: 'zip_code', + tableName: "zip_code", create: t => { - t.text('zip').notNullable().primary() - t.text('city').notNullable() - t.text('state').notNullable() - t.float('latitude').notNullable() - t.float('longitude').notNullable() - t.float('timezone_offset').notNullable() - t.boolean('has_dst').notNullable() + t.text("zip") + .notNullable() + .primary(); + t.text("city").notNullable(); + t.text("state").notNullable(); + t.float("latitude").notNullable(); + t.float("longitude").notNullable(); + t.float("timezone_offset").notNullable(); + t.boolean("has_dst").notNullable(); } }, { - tableName: 'log', + tableName: "log", create: t => { - t.increments('id').primary() - t.text('message_sid').notNullable() - t.text('body') - t.timestamp('created_at').notNullable().defaultTo(knex.fn.now()) + t.increments("id").primary(); + t.text("message_sid").notNullable(); + t.text("body"); + t.timestamp("created_at") + .notNullable() + .defaultTo(knex.fn.now()); } } - ] + ]; // For each table in the schema array, check if it exists and create it if necessary. Do these in order, to avoid race conditions surrounding foreign keys. - const tablePromises = [] + const tablePromises = []; for (let i = 0; i < buildTableSchema.length; i++) { - const { tableName, create } = buildTableSchema[i] - if (!await knex.schema.hasTable(tableName)) { + const { tableName, create } = buildTableSchema[i]; + if (!(await knex.schema.hasTable(tableName))) { // create is the function that defines the table's schema. knex.schema.createTable calls it with one argument, the table instance (t). - const result = await knex.schema.createTable(tableName, create) - tablePromises.push(result) + const result = await knex.schema.createTable(tableName, create); + tablePromises.push(result); } } - return Promise.all(tablePromises) -} + return Promise.all(tablePromises); +}; module.exports = { up: initialize, down: (knex, Promise) => { // consider a rollback here that would simply drop all the tables - Promise.resolve() + Promise.resolve(); } -} +}; /* This table ordering is taken from __test__/test_helpers.js. Go from the bottom up. - log diff --git a/src/api/assignment.js b/src/api/assignment.js index 1f16a90f2..f99880e1d 100644 --- a/src/api/assignment.js +++ b/src/api/assignment.js @@ -12,4 +12,4 @@ export const schema = ` campaignCannedResponses: [CannedResponse] maxContacts: Int } -` +`; diff --git a/src/api/campaign-contact.js b/src/api/campaign-contact.js index 1a964cb4c..e32f38e4d 100644 --- a/src/api/campaign-contact.js +++ b/src/api/campaign-contact.js @@ -35,5 +35,4 @@ export const schema = ` messageStatus: String assignmentId: String } -` - +`; diff --git a/src/api/campaign.js b/src/api/campaign.js index 22ce9ee2b..8e83ceb9f 100644 --- a/src/api/campaign.js +++ b/src/api/campaign.js @@ -65,4 +65,4 @@ export const schema = ` campaigns: [Campaign] pageInfo: PageInfo } -` +`; diff --git a/src/api/canned-response.js b/src/api/canned-response.js index 32d834d23..dcffe5599 100644 --- a/src/api/canned-response.js +++ b/src/api/canned-response.js @@ -13,5 +13,4 @@ export const schema = ` text: String isUserCreated: Boolean } -` - +`; diff --git a/src/api/conversations.js b/src/api/conversations.js index dc381041a..ab6016dc9 100644 --- a/src/api/conversations.js +++ b/src/api/conversations.js @@ -15,4 +15,4 @@ export const schema = ` conversations: [Conversation]! pageInfo: PageInfo } -` +`; diff --git a/src/api/interaction-step.js b/src/api/interaction-step.js index 44995091b..f385fda80 100644 --- a/src/api/interaction-step.js +++ b/src/api/interaction-step.js @@ -10,5 +10,4 @@ export const schema = ` answerActions: String questionResponse(campaignContactId: String): QuestionResponse } -` - +`; diff --git a/src/api/invite.js b/src/api/invite.js index 2030c9ab3..085e6f4a9 100644 --- a/src/api/invite.js +++ b/src/api/invite.js @@ -4,5 +4,4 @@ export const schema = ` isValid: Boolean hash: String } -` - +`; diff --git a/src/api/message.js b/src/api/message.js index 8640b8442..ded70b92f 100644 --- a/src/api/message.js +++ b/src/api/message.js @@ -9,5 +9,4 @@ export const schema = ` assignment: Assignment campaignId: String } -` - +`; diff --git a/src/api/opt-out.js b/src/api/opt-out.js index 3796c6faf..539d89419 100644 --- a/src/api/opt-out.js +++ b/src/api/opt-out.js @@ -5,5 +5,4 @@ export const schema = ` assignment: Assignment createdAt: Date } -` - +`; diff --git a/src/api/organization.js b/src/api/organization.js index ed3de0909..fb41c0783 100644 --- a/src/api/organization.js +++ b/src/api/organization.js @@ -12,4 +12,4 @@ export const schema = ` textingHoursStart: Int textingHoursEnd: Int } -` \ No newline at end of file +`; diff --git a/src/api/question-response.js b/src/api/question-response.js index 7c72bfe18..9d43d3183 100644 --- a/src/api/question-response.js +++ b/src/api/question-response.js @@ -4,5 +4,4 @@ export const schema = ` value: String question: Question } -` - +`; diff --git a/src/api/question.js b/src/api/question.js index bcaafff40..7366a11ea 100644 --- a/src/api/question.js +++ b/src/api/question.js @@ -14,5 +14,4 @@ export const schema = ` responderCount: Int question: Question } -` - +`; diff --git a/src/api/schema.js b/src/api/schema.js index f5eca34ca..a70e967fd 100644 --- a/src/api/schema.js +++ b/src/api/schema.js @@ -1,38 +1,56 @@ -import gql from 'graphql-tag' +import gql from "graphql-tag"; -import { schema as userSchema, resolvers as userResolvers, buildUserOrganizationQuery } from './user' +import { + schema as userSchema, + resolvers as userResolvers, + buildUserOrganizationQuery +} from "./user"; import { schema as conversationSchema, getConversations, resolvers as conversationsResolver -} from './conversations' -import { schema as organizationSchema, resolvers as organizationResolvers } from './organization' -import { schema as campaignSchema, resolvers as campaignResolvers } from './campaign' +} from "./conversations"; +import { + schema as organizationSchema, + resolvers as organizationResolvers +} from "./organization"; +import { + schema as campaignSchema, + resolvers as campaignResolvers +} from "./campaign"; import { schema as assignmentSchema, resolvers as assignmentResolvers -} from './assignment' +} from "./assignment"; import { schema as interactionStepSchema, resolvers as interactionStepResolvers -} from './interaction-step' -import { schema as questionSchema, resolvers as questionResolvers } from './question' +} from "./interaction-step"; +import { + schema as questionSchema, + resolvers as questionResolvers +} from "./question"; import { schema as questionResponseSchema, resolvers as questionResponseResolvers -} from './question-response' -import { schema as optOutSchema, resolvers as optOutResolvers } from './opt-out' -import { schema as messageSchema, resolvers as messageResolvers } from './message' +} from "./question-response"; +import { + schema as optOutSchema, + resolvers as optOutResolvers +} from "./opt-out"; +import { + schema as messageSchema, + resolvers as messageResolvers +} from "./message"; import { schema as campaignContactSchema, resolvers as campaignContactResolvers -} from './campaign-contact' +} from "./campaign-contact"; import { schema as cannedResponseSchema, resolvers as cannedResponseResolvers -} from './canned-response' -import { schema as inviteSchema, resolvers as inviteResolvers } from './invite' - +} from "./canned-response"; +import { schema as inviteSchema, resolvers as inviteResolvers } from "./invite"; const rootSchema = gql` input CampaignContactInput { @@ -180,71 +198,142 @@ const rootSchema = gql` LAST_NAME NEWEST OLDEST - } + } type RootQuery { currentUser: User - organization(id:String!, utc:String): Organization - campaign(id:String!): Campaign - inviteByHash(hash:String!): [Invite] - assignment(id:String!): Assignment + organization(id: String!, utc: String): Organization + campaign(id: String!): Campaign + inviteByHash(hash: String!): [Invite] + assignment(id: String!): Assignment organizations: [Organization] - availableActions(organizationId:String!): [Action] - conversations(cursor:OffsetLimitCursor!, organizationId:String!, campaignsFilter:CampaignsFilter, assignmentsFilter:AssignmentsFilter, contactsFilter:ContactsFilter, utc:String): PaginatedConversations - campaigns(organizationId:String!, cursor:OffsetLimitCursor, campaignsFilter: CampaignsFilter): CampaignsReturn - people(organizationId:String!, cursor:OffsetLimitCursor, campaignsFilter:CampaignsFilter, role: String, sortBy: SortPeopleBy): UsersReturn + availableActions(organizationId: String!): [Action] + conversations( + cursor: OffsetLimitCursor! + organizationId: String! + campaignsFilter: CampaignsFilter + assignmentsFilter: AssignmentsFilter + contactsFilter: ContactsFilter + utc: String + ): PaginatedConversations + campaigns( + organizationId: String! + cursor: OffsetLimitCursor + campaignsFilter: CampaignsFilter + ): CampaignsReturn + people( + organizationId: String! + cursor: OffsetLimitCursor + campaignsFilter: CampaignsFilter + role: String + sortBy: SortPeopleBy + ): UsersReturn } type RootMutation { - createInvite(invite:InviteInput!): Invite - createCampaign(campaign:CampaignInput!): Campaign - editCampaign(id:String!, campaign:CampaignInput!): Campaign - deleteJob(campaignId:String!, id:String!): JobRequest + createInvite(invite: InviteInput!): Invite + createCampaign(campaign: CampaignInput!): Campaign + editCampaign(id: String!, campaign: CampaignInput!): Campaign + deleteJob(campaignId: String!, id: String!): JobRequest copyCampaign(id: String!): Campaign - exportCampaign(id:String!): JobRequest - createCannedResponse(cannedResponse:CannedResponseInput!): CannedResponse - createOrganization(name: String!, userId: String!, inviteId: String!): Organization + exportCampaign(id: String!): JobRequest + createCannedResponse(cannedResponse: CannedResponseInput!): CannedResponse + createOrganization( + name: String! + userId: String! + inviteId: String! + ): Organization joinOrganization(organizationUuid: String!): Organization - editOrganizationRoles(organizationId: String!, userId: String!, campaignId: String, roles: [String]): Organization - editUser(organizationId: String!, userId: Int!, userData:UserInput): User + editOrganizationRoles( + organizationId: String! + userId: String! + campaignId: String + roles: [String] + ): Organization + editUser(organizationId: String!, userId: Int!, userData: UserInput): User resetUserPassword(organizationId: String!, userId: Int!): String! changeUserPassword(userId: Int!, formData: UserPasswordChange): User - updateTextingHours( organizationId: String!, textingHoursStart: Int!, textingHoursEnd: Int!): Organization - updateTextingHoursEnforcement( organizationId: String!, textingHoursEnforced: Boolean!): Organization - updateOptOutMessage( organizationId: String!, optOutMessage: String!): Organization + updateTextingHours( + organizationId: String! + textingHoursStart: Int! + textingHoursEnd: Int! + ): Organization + updateTextingHoursEnforcement( + organizationId: String! + textingHoursEnforced: Boolean! + ): Organization + updateOptOutMessage( + organizationId: String! + optOutMessage: String! + ): Organization bulkSendMessages(assignmentId: Int!): [CampaignContact] - sendMessage(message:MessageInput!, campaignContactId:String!): CampaignContact, - createOptOut(optOut:OptOutInput!, campaignContactId:String!):CampaignContact, - editCampaignContactMessageStatus(messageStatus: String!, campaignContactId:String!): CampaignContact, - deleteQuestionResponses(interactionStepIds:[String], campaignContactId:String!): CampaignContact, - updateQuestionResponses(questionResponses:[QuestionResponseInput], campaignContactId:String!): CampaignContact, - startCampaign(id:String!): Campaign, - archiveCampaign(id:String!): Campaign, - archiveCampaigns(ids: [String!]): [Campaign], - unarchiveCampaign(id:String!): Campaign, + sendMessage( + message: MessageInput! + campaignContactId: String! + ): CampaignContact + createOptOut( + optOut: OptOutInput! + campaignContactId: String! + ): CampaignContact + editCampaignContactMessageStatus( + messageStatus: String! + campaignContactId: String! + ): CampaignContact + deleteQuestionResponses( + interactionStepIds: [String] + campaignContactId: String! + ): CampaignContact + updateQuestionResponses( + questionResponses: [QuestionResponseInput] + campaignContactId: String! + ): CampaignContact + startCampaign(id: String!): Campaign + archiveCampaign(id: String!): Campaign + archiveCampaigns(ids: [String!]): [Campaign] + unarchiveCampaign(id: String!): Campaign sendReply(id: String!, message: String!): CampaignContact - getAssignmentContacts(assignmentId: String!, contactIds: [String], findNew: Boolean): [CampaignContact], - findNewCampaignContact(assignmentId: String!, numberContacts: Int!): FoundContact, - assignUserToCampaign(organizationUuid: String!, campaignId: String!): Campaign + getAssignmentContacts( + assignmentId: String! + contactIds: [String] + findNew: Boolean + ): [CampaignContact] + findNewCampaignContact( + assignmentId: String! + numberContacts: Int! + ): FoundContact + assignUserToCampaign( + organizationUuid: String! + campaignId: String! + ): Campaign userAgreeTerms(userId: String!): User - reassignCampaignContacts(organizationId:String!, campaignIdsContactIds:[CampaignIdContactId]!, newTexterUserId:String!):[CampaignIdAssignmentId], - bulkReassignCampaignContacts(organizationId:String!, campaignsFilter:CampaignsFilter, assignmentsFilter:AssignmentsFilter, contactsFilter:ContactsFilter, newTexterUserId:String!):[CampaignIdAssignmentId], - importCampaignScript(campaignId:String!, url:String!): Int + reassignCampaignContacts( + organizationId: String! + campaignIdsContactIds: [CampaignIdContactId]! + newTexterUserId: String! + ): [CampaignIdAssignmentId] + bulkReassignCampaignContacts( + organizationId: String! + campaignsFilter: CampaignsFilter + assignmentsFilter: AssignmentsFilter + contactsFilter: ContactsFilter + newTexterUserId: String! + ): [CampaignIdAssignmentId] + importCampaignScript(campaignId: String!, url: String!): Int } schema { query: RootQuery mutation: RootMutation } -` +`; export const schema = [ rootSchema, userSchema, organizationSchema, - 'scalar Date', - 'scalar JSON', - 'scalar Phone', + "scalar Date", + "scalar JSON", + "scalar Phone", campaignSchema, assignmentSchema, interactionStepSchema, @@ -256,4 +345,4 @@ export const schema = [ questionSchema, inviteSchema, conversationSchema -] +]; diff --git a/src/api/user.js b/src/api/user.js index 139849995..3439edfcc 100644 --- a/src/api/user.js +++ b/src/api/user.js @@ -25,4 +25,4 @@ type PaginatedUsers { } union UsersReturn = PaginatedUsers | UsersList -` +`; diff --git a/src/client/auth-service.js b/src/client/auth-service.js index 15281fc81..2e05c74bc 100644 --- a/src/client/auth-service.js +++ b/src/client/auth-service.js @@ -1,17 +1,18 @@ -import auth0 from 'auth0-js' +import auth0 from "auth0-js"; -const baseURL = window.BASE_URL || `${window.location.protocol}//${window.location.host}` +const baseURL = + window.BASE_URL || `${window.location.protocol}//${window.location.host}`; export function logout() { const webAuth = new auth0.WebAuth({ domain: window.AUTH0_DOMAIN, clientID: window.AUTH0_CLIENT_ID - }) + }); webAuth.logout({ returnTo: `${baseURL}/logout-callback`, client_id: window.AUTH0_CLIENT_ID - }) + }); } export function login(nextUrl) { @@ -19,10 +20,10 @@ export function login(nextUrl) { domain: window.AUTH0_DOMAIN, clientID: window.AUTH0_CLIENT_ID, redirectUri: `${baseURL}/login-callback`, - responseType: 'code', - state: nextUrl || '/', - scope: 'openid profile email' - }) + responseType: "code", + state: nextUrl || "/", + scope: "openid profile email" + }); - webAuth.authorize() + webAuth.authorize(); } diff --git a/src/client/error-catcher.js b/src/client/error-catcher.js index 617df0448..febc59621 100644 --- a/src/client/error-catcher.js +++ b/src/client/error-catcher.js @@ -1,10 +1,10 @@ -import { log } from '../lib' +import { log } from "../lib"; -export default (error) => { +export default error => { if (!error) { - log.error('Uncaught exception with null error object') - return + log.error("Uncaught exception with null error object"); + return; } - log.error(error) -} + log.error(error); +}; diff --git a/src/heroku/print-base-url.js b/src/heroku/print-base-url.js index 5b1f9329d..f61e8cce4 100644 --- a/src/heroku/print-base-url.js +++ b/src/heroku/print-base-url.js @@ -1,7 +1,9 @@ if (process.env.BASE_URL) { - console.log(process.env.BASE_URL) + console.log(process.env.BASE_URL); } else if (process.env.HEROKU_APP_NAME) { - console.log(`https://${process.env.HEROKU_APP_NAME}.herokuapp.com`) + console.log(`https://${process.env.HEROKU_APP_NAME}.herokuapp.com`); } else { - throw new Error('Neither BASE_URL nor HEROKU_APP_NAME environment variables are present.') + throw new Error( + "Neither BASE_URL nor HEROKU_APP_NAME environment variables are present." + ); } diff --git a/src/lib/__mocks__/timezones.js b/src/lib/__mocks__/timezones.js index 5478eb69d..d3963efca 100644 --- a/src/lib/__mocks__/timezones.js +++ b/src/lib/__mocks__/timezones.js @@ -1,2 +1,2 @@ -const timezones = jest.genMockFromModule('../timezones') -module.exports = timezones +const timezones = jest.genMockFromModule("../timezones"); +module.exports = timezones; diff --git a/src/lib/__mocks__/tz-helpers.js b/src/lib/__mocks__/tz-helpers.js index 86330283d..e3f934cbe 100644 --- a/src/lib/__mocks__/tz-helpers.js +++ b/src/lib/__mocks__/tz-helpers.js @@ -1,6 +1,5 @@ -const tzHelpers = jest.genMockFromModule('../tz-helpers') +const tzHelpers = jest.genMockFromModule("../tz-helpers"); +tzHelpers.getProcessEnvDstReferenceTimezone = () => "America/New_York"; -tzHelpers.getProcessEnvDstReferenceTimezone = () => 'America/New_York' - -module.exports = tzHelpers +module.exports = tzHelpers; diff --git a/src/lib/__mocks__/zip-format.js b/src/lib/__mocks__/zip-format.js index 17d4c5c73..d8e5a3fca 100644 --- a/src/lib/__mocks__/zip-format.js +++ b/src/lib/__mocks__/zip-format.js @@ -1,2 +1,2 @@ -const zipFormat = jest.genMockFromModule('../zip-format') -module.exports = zipFormat +const zipFormat = jest.genMockFromModule("../zip-format"); +module.exports = zipFormat; diff --git a/src/lib/attributes.js b/src/lib/attributes.js index 614f0930a..ce5c5845a 100644 --- a/src/lib/attributes.js +++ b/src/lib/attributes.js @@ -1,11 +1,14 @@ // Used to generate data-test attributes on non-production environments and used by end-to-end tests export const dataTest = (value, disable) => { - const attribute = (window.NODE_ENV !== 'production' && !disable) ? { 'data-test': value } : {} - return attribute -} + const attribute = + window.NODE_ENV !== "production" && !disable ? { "data-test": value } : {}; + return attribute; +}; export const camelCase = str => { - return str.replace(/(?:^\w|[A-Z]|\b\w)/g, (letter, index) => { - return index == 0 ? letter.toLowerCase() : letter.toUpperCase() - }).replace(/\s+/g, '') -} + return str + .replace(/(?:^\w|[A-Z]|\b\w)/g, (letter, index) => { + return index == 0 ? letter.toLowerCase() : letter.toUpperCase(); + }) + .replace(/\s+/g, ""); +}; diff --git a/src/lib/dst-helper.js b/src/lib/dst-helper.js index 6aec76741..08fc785bf 100644 --- a/src/lib/dst-helper.js +++ b/src/lib/dst-helper.js @@ -1,61 +1,78 @@ -import { DateTime, zone, DateFunctions } from 'timezonecomplete' - +import { DateTime, zone, DateFunctions } from "timezonecomplete"; class TimezoneOffsetAndDst { constructor(tzOffsetMinutes: number, hasDst: boolean) { - this.tzOffsetMinutes = tzOffsetMinutes - this.hasDst = hasDst + this.tzOffsetMinutes = tzOffsetMinutes; + this.hasDst = hasDst; } } -const _timezoneOffsetAndDst = {} - +const _timezoneOffsetAndDst = {}; // a class to help us know if a date is DST in a given timezone export class DstHelper { - static ensureTimezoneDstCalculated(timezone) { if (!(timezone in _timezoneOffsetAndDst)) { // If a location has DST, the offset from GMT at January 1 and June 1 will certainly // be different. The greater of the two is the DST offset. For our check, we // don't care when DST is (March-October in the northern hemisphere, October-March // in the southern hemisphere). We only care about the offset during DST. - const januaryDate = new DateTime(new Date().getFullYear(), 1, 1, 0, 0, 0, 0, zone(timezone)) - const julyDate = new DateTime(new Date().getFullYear(), 6, 1, 0, 0, 0, 0, zone(timezone)) + const januaryDate = new DateTime( + new Date().getFullYear(), + 1, + 1, + 0, + 0, + 0, + 0, + zone(timezone) + ); + const julyDate = new DateTime( + new Date().getFullYear(), + 6, + 1, + 0, + 0, + 0, + 0, + zone(timezone) + ); _timezoneOffsetAndDst[timezone] = new TimezoneOffsetAndDst( Math.min(januaryDate.offset(), julyDate.offset()), januaryDate.offset() !== julyDate.offset() - ) + ); } } - static getTimezoneOffsetHours(timezone:string): number { - DstHelper.ensureTimezoneDstCalculated(timezone) - return _timezoneOffsetAndDst[timezone].tzOffsetMinutes / 60 + static getTimezoneOffsetHours(timezone: string): number { + DstHelper.ensureTimezoneDstCalculated(timezone); + return _timezoneOffsetAndDst[timezone].tzOffsetMinutes / 60; } static timezoneHasDst(timezone: string): boolean { - DstHelper.ensureTimezoneDstCalculated(timezone) - return _timezoneOffsetAndDst[timezone].hasDst + DstHelper.ensureTimezoneDstCalculated(timezone); + return _timezoneOffsetAndDst[timezone].hasDst; } static isOffsetDst(offset: number, timezone: string): boolean { - DstHelper.ensureTimezoneDstCalculated(timezone) + DstHelper.ensureTimezoneDstCalculated(timezone); // if this timezone has DST (meaning, january and july offsets were different) // and the offset from GMT passed into this function is the same as the timezone's // offset from GMT during DST, we return true. - const timezoneOffsetAndDst = _timezoneOffsetAndDst[timezone] - return timezoneOffsetAndDst.hasDst && (timezoneOffsetAndDst.tzOffsetMinutes + 60) === offset + const timezoneOffsetAndDst = _timezoneOffsetAndDst[timezone]; + return ( + timezoneOffsetAndDst.hasDst && + timezoneOffsetAndDst.tzOffsetMinutes + 60 === offset + ); } static isDateDst(date: Date, timezone: string): boolean { - let d = new DateTime(date, DateFunctions.Get, zone(timezone)) - return DstHelper.isOffsetDst(d.offset(), timezone) + let d = new DateTime(date, DateFunctions.Get, zone(timezone)); + return DstHelper.isOffsetDst(d.offset(), timezone); } static isDateTimeDst(date: DateTime, timezone: string): boolean { - return DstHelper.isOffsetDst(date.offset(), timezone) + return DstHelper.isOffsetDst(date.offset(), timezone); } } - diff --git a/src/lib/faqs.js b/src/lib/faqs.js index a7527c2a9..ed1b17765 100644 --- a/src/lib/faqs.js +++ b/src/lib/faqs.js @@ -1,23 +1,27 @@ const FAQs = [ { - question: 'Can I edit my name and email?', - answer: 'Yes - you can edit your name by clicking on the letter in the right hand corner. This will pop up the ' + - 'user menu in the corner. You can click on your name and edit your information.' + question: "Can I edit my name and email?", + answer: + "Yes - you can edit your name by clicking on the letter in the right hand corner. This will pop up the " + + "user menu in the corner. You can click on your name and edit your information." }, { - question: 'How do I reset my password?', - answer: 'Please contact your account administrator or Text Team Manager to reset your password.' + question: "How do I reset my password?", + answer: + "Please contact your account administrator or Text Team Manager to reset your password." }, { - question: 'Does Spoke use my personal phone number to text people?', - answer: 'No - We purchase phone numbers and connect them to the application using a service called Twilio. The ' + - 'texts you send use those purchased phone numbers.' + question: "Does Spoke use my personal phone number to text people?", + answer: + "No - We purchase phone numbers and connect them to the application using a service called Twilio. The " + + "texts you send use those purchased phone numbers." }, { - question: 'Is Spoke available as an Android/iPhone app?', - answer: 'Spoke is a web-based program you can access from any web browser on your computer, tablet or mobile ' + - 'device. No app needed!' + question: "Is Spoke available as an Android/iPhone app?", + answer: + "Spoke is a web-based program you can access from any web browser on your computer, tablet or mobile " + + "device. No app needed!" } -] +]; -export default FAQs +export default FAQs; diff --git a/src/lib/index.js b/src/lib/index.js index 7f1c4d059..dd009edcf 100644 --- a/src/lib/index.js +++ b/src/lib/index.js @@ -1,6 +1,11 @@ -import zlib from 'zlib' -export { getFormattedPhoneNumber, getDisplayPhoneNumber } from './phone-format' -export { getFormattedZip, zipToTimeZone, findZipRanges, getCommonZipRanges } from './zip-format' +import zlib from "zlib"; +export { getFormattedPhoneNumber, getDisplayPhoneNumber } from "./phone-format"; +export { + getFormattedZip, + zipToTimeZone, + findZipRanges, + getCommonZipRanges +} from "./zip-format"; export { convertOffsetsToStrings, getLocalTime, @@ -11,21 +16,15 @@ export { getUtcFromTimezoneAndHour, getUtcFromOffsetAndHour, getSendBeforeTimeUtc -} from './timezones' -export { - getProcessEnvTz -} from './tz-helpers' -export { - DstHelper -} from './dst-helper' -export { - isClient -} from './is-client' -import { log } from './log' -export { log } -import Papa from 'papaparse' -import _ from 'lodash' -import { getFormattedPhoneNumber, getFormattedZip } from '../lib' +} from "./timezones"; +export { getProcessEnvTz } from "./tz-helpers"; +export { DstHelper } from "./dst-helper"; +export { isClient } from "./is-client"; +import { log } from "./log"; +export { log }; +import Papa from "papaparse"; +import _ from "lodash"; +import { getFormattedPhoneNumber, getFormattedZip } from "../lib"; export { findParent, getInteractionPath, @@ -35,39 +34,61 @@ export { getTopMostParent, getChildren, makeTree -} from './interaction-step-helpers' -const requiredUploadFields = ['firstName', 'lastName', 'cell'] -const topLevelUploadFields = ['firstName', 'lastName', 'cell', 'zip', 'external_id'] +} from "./interaction-step-helpers"; +const requiredUploadFields = ["firstName", "lastName", "cell"]; +const topLevelUploadFields = [ + "firstName", + "lastName", + "cell", + "zip", + "external_id" +]; -export { ROLE_HIERARCHY, getHighestRole, hasRole, isRoleGreater } from './permissions' +export { + ROLE_HIERARCHY, + getHighestRole, + hasRole, + isRoleGreater +} from "./permissions"; const getValidatedData = (data, optOuts) => { - const optOutCells = optOuts.map((optOut) => optOut.cell) - let validatedData - let result + const optOutCells = optOuts.map(optOut => optOut.cell); + let validatedData; + let result; // For some reason destructuring is not working here - result = _.partition(data, (row) => !!row.cell) - validatedData = result[0] - const missingCellRows = result[1] - - validatedData = _.map(validatedData, (row) => _.extend(row, { - cell: getFormattedPhoneNumber(row.cell, process.env.PHONE_NUMBER_COUNTRY || 'US') })) - result = _.partition(validatedData, (row) => !!row.cell) - validatedData = result[0] - const invalidCellRows = result[1] - - const count = validatedData.length - validatedData = _.uniqBy(validatedData, (row) => row.cell) - const dupeCount = (count - validatedData.length) + result = _.partition(data, row => !!row.cell); + validatedData = result[0]; + const missingCellRows = result[1]; + + validatedData = _.map(validatedData, row => + _.extend(row, { + cell: getFormattedPhoneNumber( + row.cell, + process.env.PHONE_NUMBER_COUNTRY || "US" + ) + }) + ); + result = _.partition(validatedData, row => !!row.cell); + validatedData = result[0]; + const invalidCellRows = result[1]; - result = _.partition(validatedData, (row) => optOutCells.indexOf(row.cell) === -1) - validatedData = result[0] - const optOutRows = result[1] + const count = validatedData.length; + validatedData = _.uniqBy(validatedData, row => row.cell); + const dupeCount = count - validatedData.length; - validatedData = _.map(validatedData, (row) => _.extend(row, { - zip: row.zip ? getFormattedZip(row.zip) : null - })) - const zipCount = validatedData.filter((row) => !!row.zip).length + result = _.partition( + validatedData, + row => optOutCells.indexOf(row.cell) === -1 + ); + validatedData = result[0]; + const optOutRows = result[1]; + + validatedData = _.map(validatedData, row => + _.extend(row, { + zip: row.zip ? getFormattedZip(row.zip) : null + }) + ); + const zipCount = validatedData.filter(row => !!row.zip).length; return { validatedData, @@ -78,75 +99,78 @@ const getValidatedData = (data, optOuts) => { missingCellCount: missingCellRows.length, zipCount } - } -} + }; +}; -export const gzip = (str) => ( +export const gzip = str => new Promise((resolve, reject) => { zlib.gzip(str, (err, res) => { if (err) { - reject(err) + reject(err); } else { - resolve(res) + resolve(res); } - }) - }) -) + }); + }); -export const gunzip = (buf) => ( +export const gunzip = buf => new Promise((resolve, reject) => { zlib.gunzip(buf, (err, res) => { if (err) { - reject(err) + reject(err); } else { - resolve(res) + resolve(res); } - }) - }) -) + }); + }); export const parseCSV = (file, optOuts, callback) => { Papa.parse(file, { header: true, // eslint-disable-next-line no-shadow, no-unused-vars complete: ({ data, meta, errors }, file) => { - const fields = meta.fields + const fields = meta.fields; - const missingFields = [] + const missingFields = []; for (const field of requiredUploadFields) { if (fields.indexOf(field) === -1) { - missingFields.push(field) + missingFields.push(field); } } if (missingFields.length > 0) { - const error = `Missing fields: ${missingFields.join(', ')}` - callback({ error }) + const error = `Missing fields: ${missingFields.join(", ")}`; + callback({ error }); } else { - const { validationStats, validatedData } = getValidatedData(data, optOuts) + const { validationStats, validatedData } = getValidatedData( + data, + optOuts + ); - const customFields = fields.filter((field) => topLevelUploadFields.indexOf(field) === -1) + const customFields = fields.filter( + field => topLevelUploadFields.indexOf(field) === -1 + ); callback({ customFields, validationStats, contacts: validatedData - }) + }); } } - }) -} + }); +}; -export const convertRowToContact = (row) => { - const customFields = row - const contact = {} +export const convertRowToContact = row => { + const customFields = row; + const contact = {}; for (const field of topLevelUploadFields) { if (_.has(row, field)) { - contact[field] = row[field] + contact[field] = row[field]; } } - contact.customFields = customFields - return contact -} + contact.customFields = customFields; + return contact; +}; diff --git a/src/lib/interaction-step-helpers.js b/src/lib/interaction-step-helpers.js index 369e0b418..b5ea69eb1 100644 --- a/src/lib/interaction-step-helpers.js +++ b/src/lib/interaction-step-helpers.js @@ -1,105 +1,122 @@ export function findParent(interactionStep, allInteractionSteps, isModel) { - let parent = null - allInteractionSteps.forEach((step) => { + let parent = null; + allInteractionSteps.forEach(step => { if (isModel) { if (step.id == interactionStep.parent_interaction_id) { parent = { ...step, answerLink: interactionStep.answer_option - } + }; } } else { - if (isModel || step.question && step.question.answerOptions) { - step.question.answerOptions.forEach((answer) => { - if (answer.nextInteractionStep && answer.nextInteractionStep.id === interactionStep.id) { + if (isModel || (step.question && step.question.answerOptions)) { + step.question.answerOptions.forEach(answer => { + if ( + answer.nextInteractionStep && + answer.nextInteractionStep.id === interactionStep.id + ) { parent = { ...step, answerLink: answer.value - } + }; } - }) + }); } } - }) - return parent + }); + return parent; } -export function getInteractionPath(interactionStep, allInteractionSteps, isModel) { - const path = [] - let parent = findParent(interactionStep, allInteractionSteps, isModel) +export function getInteractionPath( + interactionStep, + allInteractionSteps, + isModel +) { + const path = []; + let parent = findParent(interactionStep, allInteractionSteps, isModel); while (parent !== null) { - path.unshift(parent) - parent = findParent(parent, allInteractionSteps, isModel) + path.unshift(parent); + parent = findParent(parent, allInteractionSteps, isModel); } - return path + return path; } export function interactionStepForId(id, interactionSteps) { - let interactionStep = null - interactionSteps.forEach((step) => { + let interactionStep = null; + interactionSteps.forEach(step => { if (step.id === id) { - interactionStep = step + interactionStep = step; } - }) - return interactionStep + }); + return interactionStep; } export function getChildren(interactionStep, allInteractionSteps, isModel) { - const children = [] - allInteractionSteps.forEach((step) => { - const path = getInteractionPath(step, allInteractionSteps, isModel) - path.forEach((pathElement) => { + const children = []; + allInteractionSteps.forEach(step => { + const path = getInteractionPath(step, allInteractionSteps, isModel); + path.forEach(pathElement => { if (pathElement.id === interactionStep.id) { - children.push(step) + children.push(step); } - }) - }) - return children + }); + }); + return children; } export function getInteractionTree(allInteractionSteps, isModel) { - const pathLengthHash = {} - allInteractionSteps.forEach((step) => { - const path = getInteractionPath(step, allInteractionSteps, isModel) - pathLengthHash[path.length] = pathLengthHash[path.length] || [] - pathLengthHash[path.length].push({ interactionStep: step, path }) - }) - return pathLengthHash + const pathLengthHash = {}; + allInteractionSteps.forEach(step => { + const path = getInteractionPath(step, allInteractionSteps, isModel); + pathLengthHash[path.length] = pathLengthHash[path.length] || []; + pathLengthHash[path.length].push({ interactionStep: step, path }); + }); + return pathLengthHash; } export function sortInteractionSteps(interactionSteps) { - const pathTree = getInteractionTree(interactionSteps) - const orderedSteps = [] - Object.keys(pathTree).forEach((key) => { - const orderedBranch = pathTree[key].sort((a, b) => JSON.stringify(a.interactionStep) < JSON.stringify(b.interactionStep)) - orderedBranch.forEach((ele) => orderedSteps.push(ele.interactionStep)) - }) - return orderedSteps + const pathTree = getInteractionTree(interactionSteps); + const orderedSteps = []; + Object.keys(pathTree).forEach(key => { + const orderedBranch = pathTree[key].sort( + (a, b) => + JSON.stringify(a.interactionStep) < JSON.stringify(b.interactionStep) + ); + orderedBranch.forEach(ele => orderedSteps.push(ele.interactionStep)); + }); + return orderedSteps; } export function getTopMostParent(interactionSteps, isModel) { - return getInteractionTree(interactionSteps, isModel)[0][0].interactionStep + return getInteractionTree(interactionSteps, isModel)[0][0].interactionStep; } export function makeTree(interactionSteps, id = null) { - const root = interactionSteps.filter((is) => id ? is.id === id : is.parentInteractionId === null)[0] - const children = interactionSteps.filter((is) => is.parentInteractionId === root.id) + const root = interactionSteps.filter(is => + id ? is.id === id : is.parentInteractionId === null + )[0]; + const children = interactionSteps.filter( + is => is.parentInteractionId === root.id + ); return { ...root, - interactionSteps: children.map((c) => { - return makeTree(interactionSteps, c.id) + interactionSteps: children.map(c => { + return makeTree(interactionSteps, c.id); }) - } + }; } export function assembleAnswerOptions(allInteractionSteps) { // creates recursive array required for the graphQL query with 'answerOptions' key - const interactionStepsCopy = allInteractionSteps.map( - is => ({...is, answerOptions: []})) + const interactionStepsCopy = allInteractionSteps.map(is => ({ + ...is, + answerOptions: [] + })); allInteractionSteps.forEach(interactionStep => { if (interactionStep.parent_interaction_id) { const [parentStep] = interactionStepsCopy.filter( - parent => (parent.id === interactionStep.parent_interaction_id)) + parent => parent.id === interactionStep.parent_interaction_id + ); if (parentStep) { parentStep.answerOptions.push({ nextInteractionStep: interactionStep, @@ -107,9 +124,9 @@ export function assembleAnswerOptions(allInteractionSteps) { action: interactionStep.answer_actions, interaction_step_id: interactionStep.id, parent_interaction_step: interactionStep.parent_interaction_id - }) + }); } } - }) - return interactionStepsCopy + }); + return interactionStepsCopy; } diff --git a/src/lib/is-client.js b/src/lib/is-client.js index be6647c79..88bee7cd7 100644 --- a/src/lib/is-client.js +++ b/src/lib/is-client.js @@ -1,3 +1,3 @@ export function isClient() { - return typeof window !== 'undefined' + return typeof window !== "undefined"; } diff --git a/src/lib/log.js b/src/lib/log.js index f5734921c..baf85be44 100644 --- a/src/lib/log.js +++ b/src/lib/log.js @@ -1,49 +1,56 @@ -import minilog from 'minilog' -import { isClient } from './is-client' -const rollbar = require('rollbar') -let logInstance = null +import minilog from "minilog"; +import { isClient } from "./is-client"; +const rollbar = require("rollbar"); +let logInstance = null; if (isClient()) { - minilog.enable() - logInstance = minilog('client') - const existingErrorLogger = logInstance.error + minilog.enable(); + logInstance = minilog("client"); + const existingErrorLogger = logInstance.error; logInstance.error = (...err) => { - const errObj = err + const errObj = err; if (window.Rollbar) { - window.Rollbar.error(...errObj) + window.Rollbar.error(...errObj); } - existingErrorLogger.call(...errObj) - } + existingErrorLogger.call(...errObj); + }; } else { - let enableRollbar = false - if (process.env.NODE_ENV === 'production' && process.env.ROLLBAR_ACCESS_TOKEN) { - enableRollbar = true - rollbar.init(process.env.ROLLBAR_ACCESS_TOKEN) + let enableRollbar = false; + if ( + process.env.NODE_ENV === "production" && + process.env.ROLLBAR_ACCESS_TOKEN + ) { + enableRollbar = true; + rollbar.init(process.env.ROLLBAR_ACCESS_TOKEN); } - minilog.suggest.deny(/.*/, process.env.NODE_ENV === 'development' ? 'debug' : 'debug') + minilog.suggest.deny( + /.*/, + process.env.NODE_ENV === "development" ? "debug" : "debug" + ); - minilog.enable() + minilog + .enable() .pipe(minilog.backends.console.formatWithStack) - .pipe(minilog.backends.console) + .pipe(minilog.backends.console); - logInstance = minilog('backend') - const existingErrorLogger = logInstance.error - logInstance.error = (err) => { + logInstance = minilog("backend"); + const existingErrorLogger = logInstance.error; + logInstance.error = err => { if (enableRollbar) { - if (typeof err === 'object') { - rollbar.handleError(err) - } else if (typeof err === 'string') { - rollbar.reportMessage(err) + if (typeof err === "object") { + rollbar.handleError(err); + } else if (typeof err === "string") { + rollbar.reportMessage(err); } else { - rollbar.reportMessage('Got backend error with no error message') + rollbar.reportMessage("Got backend error with no error message"); } } - existingErrorLogger(err && err.stack ? err.stack : err) - } + existingErrorLogger(err && err.stack ? err.stack : err); + }; } -const log = (process.env.LAMBDA_DEBUG_LOG ? console : logInstance) +const log = process.env.LAMBDA_DEBUG_LOG ? console : logInstance; -export { log } +export { log }; diff --git a/src/lib/pendingJobsUtils.js b/src/lib/pendingJobsUtils.js index 2d5fe544f..88058fb32 100644 --- a/src/lib/pendingJobsUtils.js +++ b/src/lib/pendingJobsUtils.js @@ -1,7 +1,8 @@ -import gql from 'graphql-tag' +import gql from "graphql-tag"; -export const pendingJobsGql = (campaignId) => ({ - query: gql `query getCampaignJobs($campaignId: String!) { +export const pendingJobsGql = campaignId => ({ + query: gql` + query getCampaignJobs($campaignId: String!) { campaign(id: $campaignId) { id pendingJobs { @@ -12,10 +13,10 @@ export const pendingJobsGql = (campaignId) => ({ resultMessage } } - }`, + } + `, variables: { campaignId }, pollInterval: 60000 -}) - +}); diff --git a/src/lib/permissions.js b/src/lib/permissions.js index 236eca108..975201bf0 100644 --- a/src/lib/permissions.js +++ b/src/lib/permissions.js @@ -1,9 +1,13 @@ -export const ROLE_HIERARCHY = ['TEXTER', 'SUPERVOLUNTEER', 'ADMIN', 'OWNER'] +export const ROLE_HIERARCHY = ["TEXTER", "SUPERVOLUNTEER", "ADMIN", "OWNER"]; -export const isRoleGreater = (role1, role2) => (ROLE_HIERARCHY.indexOf(role1) > ROLE_HIERARCHY.indexOf(role2)) +export const isRoleGreater = (role1, role2) => + ROLE_HIERARCHY.indexOf(role1) > ROLE_HIERARCHY.indexOf(role2); -export const hasRoleAtLeast = (hasRole, wantsRole) => (ROLE_HIERARCHY.indexOf(hasRole) >= ROLE_HIERARCHY.indexOf(wantsRole)) +export const hasRoleAtLeast = (hasRole, wantsRole) => + ROLE_HIERARCHY.indexOf(hasRole) >= ROLE_HIERARCHY.indexOf(wantsRole); -export const getHighestRole = (roles) => roles.sort(isRoleGreater)[roles.length - 1] +export const getHighestRole = roles => + roles.sort(isRoleGreater)[roles.length - 1]; -export const hasRole = (role, roles) => hasRoleAtLeast(getHighestRole(roles), role) +export const hasRole = (role, roles) => + hasRoleAtLeast(getHighestRole(roles), role); diff --git a/src/lib/phone-format.js b/src/lib/phone-format.js index f5abe2ae6..b54606de7 100644 --- a/src/lib/phone-format.js +++ b/src/lib/phone-format.js @@ -1,26 +1,26 @@ -import { PhoneNumberUtil, PhoneNumberFormat } from 'google-libphonenumber' -import { log } from './log' +import { PhoneNumberUtil, PhoneNumberFormat } from "google-libphonenumber"; +import { log } from "./log"; -export const getFormattedPhoneNumber = (cell, country = 'US') => { - const phoneUtil = PhoneNumberUtil.getInstance() +export const getFormattedPhoneNumber = (cell, country = "US") => { + const phoneUtil = PhoneNumberUtil.getInstance(); // we return an empty string vs null when the phone number is inValid // because when the cell is null, batch inserts into campaign contacts fail // then when contacts have cell.length < 12 (+1), it's deleted before assignments are created try { - const inputNumber = phoneUtil.parse(cell, country) - const isValid = phoneUtil.isValidNumber(inputNumber) + const inputNumber = phoneUtil.parse(cell, country); + const isValid = phoneUtil.isValidNumber(inputNumber); if (isValid) { - return phoneUtil.format(inputNumber, PhoneNumberFormat.E164) + return phoneUtil.format(inputNumber, PhoneNumberFormat.E164); } - return '' + return ""; } catch (e) { - log.error(e) - return '' + log.error(e); + return ""; } -} +}; -export const getDisplayPhoneNumber = (e164Number, country = 'US') => { - const phoneUtil = PhoneNumberUtil.getInstance() - const parsed = phoneUtil.parse(e164Number, country) - return phoneUtil.format(parsed, PhoneNumberFormat.NATIONAL) -} +export const getDisplayPhoneNumber = (e164Number, country = "US") => { + const phoneUtil = PhoneNumberUtil.getInstance(); + const parsed = phoneUtil.parse(e164Number, country); + return phoneUtil.format(parsed, PhoneNumberFormat.NATIONAL); +}; diff --git a/src/lib/timezones.js b/src/lib/timezones.js index 77e8a2781..2d0287c46 100644 --- a/src/lib/timezones.js +++ b/src/lib/timezones.js @@ -1,7 +1,10 @@ -import moment from 'moment-timezone' +import moment from "moment-timezone"; -import { getProcessEnvTz, getProcessEnvDstReferenceTimezone } from '../lib/tz-helpers' -import { DstHelper } from './dst-helper' +import { + getProcessEnvTz, + getProcessEnvDstReferenceTimezone +} from "../lib/tz-helpers"; +import { DstHelper } from "./dst-helper"; const TIMEZONE_CONFIG = { missingTimeZone: { @@ -10,44 +13,63 @@ const TIMEZONE_CONFIG = { allowedStart: 12, // 12pm EST/9am PST allowedEnd: 21 // 9pm EST/6pm PST } -} +}; export const getContactTimezone = (campaign, location) => { - const returnLocation = Object.assign({}, location) + const returnLocation = Object.assign({}, location); if (location.timezone == null || location.timezone.offset == null) { - let timezoneData = null + let timezoneData = null; if (campaign.overrideOrganizationTextingHours) { - const offset = DstHelper.getTimezoneOffsetHours(campaign.timezone) - const hasDST = DstHelper.timezoneHasDst(campaign.timezone) - timezoneData = { offset, hasDST } + const offset = DstHelper.getTimezoneOffsetHours(campaign.timezone); + const hasDST = DstHelper.timezoneHasDst(campaign.timezone); + timezoneData = { offset, hasDST }; } else if (getProcessEnvTz()) { - const offset = DstHelper.getTimezoneOffsetHours(getProcessEnvTz()) - const hasDST = DstHelper.timezoneHasDst(getProcessEnvTz()) - timezoneData = { offset, hasDST } + const offset = DstHelper.getTimezoneOffsetHours(getProcessEnvTz()); + const hasDST = DstHelper.timezoneHasDst(getProcessEnvTz()); + timezoneData = { offset, hasDST }; } else { - const offset = TIMEZONE_CONFIG.missingTimeZone.offset - const hasDST = TIMEZONE_CONFIG.missingTimeZone.hasDST - timezoneData = { offset, hasDST } + const offset = TIMEZONE_CONFIG.missingTimeZone.offset; + const hasDST = TIMEZONE_CONFIG.missingTimeZone.hasDST; + timezoneData = { offset, hasDST }; } - returnLocation.timezone = timezoneData + returnLocation.timezone = timezoneData; } - return returnLocation -} - -export const getUtcFromOffsetAndHour = (offset, hasDst, hour, dstReferenceTimezone) => { - const isDst = moment().tz(dstReferenceTimezone).isDST() - return moment().utcOffset(offset + ((hasDst && isDst) ? 1 : 0)).hour(hour).startOf('hour').utc() -} + return returnLocation; +}; + +export const getUtcFromOffsetAndHour = ( + offset, + hasDst, + hour, + dstReferenceTimezone +) => { + const isDst = moment() + .tz(dstReferenceTimezone) + .isDST(); + return moment() + .utcOffset(offset + (hasDst && isDst ? 1 : 0)) + .hour(hour) + .startOf("hour") + .utc(); +}; export const getUtcFromTimezoneAndHour = (timezone, hour) => { - return moment().tz(timezone).hour(hour).startOf('hour').utc() -} - -export const getSendBeforeTimeUtc = (contactTimezone, organization, campaign) => { + return moment() + .tz(timezone) + .hour(hour) + .startOf("hour") + .utc(); +}; + +export const getSendBeforeTimeUtc = ( + contactTimezone, + organization, + campaign +) => { if (campaign.overrideOrganizationTextingHours) { if (!campaign.textingHoursEnforced) { - return null + return null; } if (contactTimezone && contactTimezone.offset) { @@ -56,24 +78,24 @@ export const getSendBeforeTimeUtc = (contactTimezone, organization, campaign) => contactTimezone.hasDST, campaign.textingHoursEnd, campaign.timezone - ) + ); } else { return getUtcFromTimezoneAndHour( campaign.timezone, campaign.textingHoursEnd - ) + ); } } if (!organization.textingHoursEnforced) { - return null + return null; } if (getProcessEnvTz()) { return getUtcFromTimezoneAndHour( getProcessEnvTz(), organization.textingHoursEnd - ) + ); } if (contactTimezone && contactTimezone.offset) { @@ -82,73 +104,90 @@ export const getSendBeforeTimeUtc = (contactTimezone, organization, campaign) => contactTimezone.hasDST, organization.textingHoursEnd, getProcessEnvDstReferenceTimezone() - ) + ); } else { return getUtcFromOffsetAndHour( TIMEZONE_CONFIG.missingTimeZone.offset, TIMEZONE_CONFIG.missingTimeZone.hasDST, organization.textingHoursEnd, getProcessEnvDstReferenceTimezone() - ) + ); } -} +}; export const getLocalTime = (offset, hasDST, dstReferenceTimezone) => { - return moment().utc().utcOffset(DstHelper.isDateDst(new Date(), dstReferenceTimezone) && hasDST ? offset + 1 : offset) -} - -const isOffsetBetweenTextingHours = (offsetData, textingHoursStart, textingHoursEnd, missingTimezoneConfig, dstReferenceTimezone) => { - let offset - let hasDST - let allowedStart - let allowedEnd + return moment() + .utc() + .utcOffset( + DstHelper.isDateDst(new Date(), dstReferenceTimezone) && hasDST + ? offset + 1 + : offset + ); +}; + +const isOffsetBetweenTextingHours = ( + offsetData, + textingHoursStart, + textingHoursEnd, + missingTimezoneConfig, + dstReferenceTimezone +) => { + let offset; + let hasDST; + let allowedStart; + let allowedEnd; if (offsetData && offsetData.offset) { - allowedStart = textingHoursStart - allowedEnd = textingHoursEnd - offset = offsetData.offset - hasDST = offsetData.hasDST + allowedStart = textingHoursStart; + allowedEnd = textingHoursEnd; + offset = offsetData.offset; + hasDST = offsetData.hasDST; } else { - allowedStart = missingTimezoneConfig.allowedStart - allowedEnd = missingTimezoneConfig.allowedEnd - offset = missingTimezoneConfig.offset - hasDST = missingTimezoneConfig.hasDST + allowedStart = missingTimezoneConfig.allowedStart; + allowedEnd = missingTimezoneConfig.allowedEnd; + offset = missingTimezoneConfig.offset; + hasDST = missingTimezoneConfig.hasDST; } - const localTime = getLocalTime(offset, hasDST, dstReferenceTimezone) - return (localTime.hours() >= allowedStart && localTime.hours() < allowedEnd) -} + const localTime = getLocalTime(offset, hasDST, dstReferenceTimezone); + return localTime.hours() >= allowedStart && localTime.hours() < allowedEnd; +}; export const isBetweenTextingHours = (offsetData, config) => { if (config.campaignTextingHours) { if (!config.campaignTextingHours.textingHoursEnforced) { - return true + return true; } } else if (!config.textingHoursEnforced) { - return true + return true; } if (config.campaignTextingHours) { - const { campaignTextingHours } = config + const { campaignTextingHours } = config; const missingTimezoneConfig = { allowedStart: campaignTextingHours.textingHoursStart, allowedEnd: campaignTextingHours.textingHoursEnd, offset: DstHelper.getTimezoneOffsetHours(campaignTextingHours.timezone), hasDST: DstHelper.timezoneHasDst(campaignTextingHours.timezone) - } + }; return isOffsetBetweenTextingHours( offsetData, campaignTextingHours.textingHoursStart, campaignTextingHours.textingHoursEnd, missingTimezoneConfig, - campaignTextingHours.timezone) + campaignTextingHours.timezone + ); } if (getProcessEnvTz()) { - const today = moment.tz(getProcessEnvTz()).format('YYYY-MM-DD') - const start = moment.tz(`${today}`, getProcessEnvTz()).add(config.textingHoursStart, 'hours') - const stop = moment.tz(`${today}`, getProcessEnvTz()).add(config.textingHoursEnd, 'hours') - return moment.tz(getProcessEnvTz()).isBetween(start, stop, null, '[]') + const today = moment.tz(getProcessEnvTz()).format("YYYY-MM-DD"); + const start = moment + .tz(`${today}`, getProcessEnvTz()) + .add(config.textingHoursStart, "hours"); + const stop = moment + .tz(`${today}`, getProcessEnvTz()) + .add(config.textingHoursEnd, "hours"); + return moment.tz(getProcessEnvTz()).isBetween(start, stop, null, "[]"); } return isOffsetBetweenTextingHours( @@ -156,42 +195,42 @@ export const isBetweenTextingHours = (offsetData, config) => { config.textingHoursStart, config.textingHoursEnd, TIMEZONE_CONFIG.missingTimeZone, - getProcessEnvDstReferenceTimezone()) -} - + getProcessEnvDstReferenceTimezone() + ); +}; // Currently USA (-4 through -11) and Australia (10) -const ALL_OFFSETS = [-4, -5, -6, -7, -8, -9, -10, -11, 10] +const ALL_OFFSETS = [-4, -5, -6, -7, -8, -9, -10, -11, 10]; -export const defaultTimezoneIsBetweenTextingHours = (config) => isBetweenTextingHours(null, config) +export const defaultTimezoneIsBetweenTextingHours = config => + isBetweenTextingHours(null, config); export function convertOffsetsToStrings(offsetArray) { - const result = [] - offsetArray.forEach((offset) => { - result.push((offset[0].toString() + '_' + (offset[1] === true ? '1' : '0'))) - }) - return result + const result = []; + offsetArray.forEach(offset => { + result.push(offset[0].toString() + "_" + (offset[1] === true ? "1" : "0")); + }); + return result; } -export const getOffsets = (config) => { - const offsets = ALL_OFFSETS.slice(0) +export const getOffsets = config => { + const offsets = ALL_OFFSETS.slice(0); - const valid = [] - const invalid = [] + const valid = []; + const invalid = []; - const dst = [true, false] - dst.forEach((hasDST) => ( - offsets.forEach((offset) => { + const dst = [true, false]; + dst.forEach(hasDST => + offsets.forEach(offset => { if (isBetweenTextingHours({ offset, hasDST }, config)) { - valid.push([offset, hasDST]) + valid.push([offset, hasDST]); } else { - invalid.push([offset, hasDST]) + invalid.push([offset, hasDST]); } }) + ); - )) - - const convertedValid = convertOffsetsToStrings(valid) - const convertedInvalid = convertOffsetsToStrings(invalid) - return [convertedValid, convertedInvalid] -} + const convertedValid = convertOffsetsToStrings(valid); + const convertedInvalid = convertOffsetsToStrings(invalid); + return [convertedValid, convertedInvalid]; +}; diff --git a/src/lib/tz-helpers.js b/src/lib/tz-helpers.js index 322907c3a..3974b3aaf 100644 --- a/src/lib/tz-helpers.js +++ b/src/lib/tz-helpers.js @@ -1,5 +1,11 @@ -export function getProcessEnvTz() { return process.env.TZ } +export function getProcessEnvTz() { + return process.env.TZ; +} export function getProcessEnvDstReferenceTimezone() { - return process.env.DST_REFERENCE_TIMEZONE || global.DST_REFERENCE_TIMEZONE || 'America/New_York' } - + return ( + process.env.DST_REFERENCE_TIMEZONE || + global.DST_REFERENCE_TIMEZONE || + "America/New_York" + ); +} diff --git a/src/lib/zip-format.js b/src/lib/zip-format.js index 816c84a8f..41c21f340 100644 --- a/src/lib/zip-format.js +++ b/src/lib/zip-format.js @@ -1,13 +1,13 @@ -export const getFormattedZip = (zip, country = 'US') => { - if (country === 'US') { - const regex = /(\d{5})([ \-]\d{4})?/ - const [, first5] = zip.match(regex) || [] +export const getFormattedZip = (zip, country = "US") => { + if (country === "US") { + const regex = /(\d{5})([ \-]\d{4})?/; + const [, first5] = zip.match(regex) || []; - return first5 + return first5; } else { - throw new Error(`Do not know how to format zip for country: ${country}`) + throw new Error(`Do not know how to format zip for country: ${country}`); } -} +}; var commonZipRanges = [ // list of zip ranges. [, , , , ] @@ -53,43 +53,52 @@ var commonZipRanges = [ [42501, 42602, -5, 1, 101], [37401, 37501, -5, 1, 100], [37501, 37601, -6, 1, 100] -] +]; -commonZipRanges.sort((a, b) => (a[0] - b[0])) +commonZipRanges.sort((a, b) => a[0] - b[0]); export function getCommonZipRanges() { - return commonZipRanges + return commonZipRanges; } -export const zipToTimeZone = function (zip) { +export const zipToTimeZone = function(zip) { // will search common zip ranges -- won't necessarily find something // so fallback on looking it up in db - if (typeof zip == 'number' || zip.length >= 5) { - zip = parseInt(zip) - return getCommonZipRanges().find((g) => (zip >= g[0] && zip < g[1])) + if (typeof zip == "number" || zip.length >= 5) { + zip = parseInt(zip); + return getCommonZipRanges().find(g => zip >= g[0] && zip < g[1]); } -} +}; // lperson 2018.02.10 this is dead code -export const findZipRanges = function (r) { - var zipchanges = [] - return r.knex('zip_code').select('zip', 'timezone_offset', 'has_dst') - .orderBy('zip').then(function (zips) { - var front = -1 - var curTz = -4 - var curHasDst = -1 - zips.forEach((zipRec) => { +export const findZipRanges = function(r) { + var zipchanges = []; + return r + .knex("zip_code") + .select("zip", "timezone_offset", "has_dst") + .orderBy("zip") + .then(function(zips) { + var front = -1; + var curTz = -4; + var curHasDst = -1; + zips.forEach(zipRec => { if (zipRec.timezone_offset != curTz || zipRec.has_dst != curHasDst) { - zipchanges.push([front, parseInt(zipRec.zip), curTz, curHasDst, parseInt(zipRec.zip) - front]) - curTz = zipRec.timezone_offset - curHasDst = zipRec.has_dst - front = parseInt(zipRec.zip) + zipchanges.push([ + front, + parseInt(zipRec.zip), + curTz, + curHasDst, + parseInt(zipRec.zip) - front + ]); + curTz = zipRec.timezone_offset; + curHasDst = zipRec.has_dst; + front = parseInt(zipRec.zip); } - }) - zipchanges.sort(function (a, b) { - return b[4] - a[4] - }) - console.log(zipchanges) - }) - return zipchanges -} + }); + zipchanges.sort(function(a, b) { + return b[4] - a[4]; + }); + console.log(zipchanges); + }); + return zipchanges; +}; diff --git a/src/network/apollo-client-singleton.js b/src/network/apollo-client-singleton.js index c2e0ee3e6..f94fcce60 100644 --- a/src/network/apollo-client-singleton.js +++ b/src/network/apollo-client-singleton.js @@ -1,37 +1,42 @@ -import ApolloClient, { addQueryMerging } from 'apollo-client' -import ResponseMiddlewareNetworkInterface from './response-middleware-network-interface' -import { log } from '../lib' -import fetch from 'isomorphic-fetch' -import { graphQLErrorParser } from './errors' +import ApolloClient, { addQueryMerging } from "apollo-client"; +import ResponseMiddlewareNetworkInterface from "./response-middleware-network-interface"; +import { log } from "../lib"; +import fetch from "isomorphic-fetch"; +import { graphQLErrorParser } from "./errors"; const responseMiddlewareNetworkInterface = new ResponseMiddlewareNetworkInterface( - process.env.GRAPHQL_URL || '/graphql', { credentials: 'same-origin' } -) + process.env.GRAPHQL_URL || "/graphql", + { credentials: "same-origin" } +); responseMiddlewareNetworkInterface.use({ applyResponseMiddleware: (response, next) => { - const parsedError = graphQLErrorParser(response) + const parsedError = graphQLErrorParser(response); if (parsedError) { - log.debug(parsedError) + log.debug(parsedError); if (parsedError.status === 401) { - window.location = `/login?nextUrl=${window.location.pathname}` + window.location = `/login?nextUrl=${window.location.pathname}`; } else if (parsedError.status === 403) { - window.location = '/' + window.location = "/"; } else if (parsedError.status === 404) { - window.location = '/404' + window.location = "/404"; } else { - log.error(`GraphQL request resulted in error:\nRequest:${JSON.stringify(response.data)}\nError:${JSON.stringify(response.errors)}`) + log.error( + `GraphQL request resulted in error:\nRequest:${JSON.stringify( + response.data + )}\nError:${JSON.stringify(response.errors)}` + ); } } - next() + next(); } -}) +}); -const networkInterface = addQueryMerging(responseMiddlewareNetworkInterface) +const networkInterface = addQueryMerging(responseMiddlewareNetworkInterface); const ApolloClientSingleton = new ApolloClient({ networkInterface, shouldBatch: true, - dataIdFromObject: (result) => result.id -}) -export default ApolloClientSingleton + dataIdFromObject: result => result.id +}); +export default ApolloClientSingleton; diff --git a/src/network/errors.js b/src/network/errors.js index b4ee6d902..e09ae6032 100644 --- a/src/network/errors.js +++ b/src/network/errors.js @@ -1,33 +1,34 @@ export function GraphQLRequestError(err) { - this.name = this.constructor.name - this.message = err.message - this.status = err.status - this.stack = (new Error()).stack + this.name = this.constructor.name; + this.message = err.message; + this.status = err.status; + this.stack = new Error().stack; } -GraphQLRequestError.prototype = Object.create(Error.prototype) -GraphQLRequestError.prototype.constructor = GraphQLRequestError +GraphQLRequestError.prototype = Object.create(Error.prototype); +GraphQLRequestError.prototype.constructor = GraphQLRequestError; export function graphQLErrorParser(response) { if (response.errors && response.errors.length > 0) { - const error = response.errors[0] - let parsedError = null + const error = response.errors[0]; + let parsedError = null; try { - parsedError = JSON.parse(error.message) + parsedError = JSON.parse(error.message); } catch (ex) { // Even if we can't parse an error messge into JSON, still render it as a string // so that we still display some error message instead of no error message at all. - parsedError = { status: 500, message: error.message } + parsedError = { status: 500, message: error.message }; } if (parsedError) { return { status: parsedError.status, message: parsedError.message - } + }; } return { status: 500, - message: 'There was an error with your request. Try again in a little bit!' - } + message: + "There was an error with your request. Try again in a little bit!" + }; } - return null + return null; } diff --git a/src/network/response-middleware-network-interface.js b/src/network/response-middleware-network-interface.js index f1aa4553d..6778f6bc1 100644 --- a/src/network/response-middleware-network-interface.js +++ b/src/network/response-middleware-network-interface.js @@ -1,52 +1,54 @@ -import { createNetworkInterface } from 'apollo-client' -import fetch from 'isomorphic-fetch' +import { createNetworkInterface } from "apollo-client"; +import fetch from "isomorphic-fetch"; class ResponseMiddlewareNetworkInterface { - constructor(endpoint = '/graphql', options = {}) { - this.defaultNetworkInterface = createNetworkInterface(endpoint, options) - this.responseMiddlewares = [] + constructor(endpoint = "/graphql", options = {}) { + this.defaultNetworkInterface = createNetworkInterface(endpoint, options); + this.responseMiddlewares = []; } use(responseMiddleware) { - let responseMiddlewares = responseMiddleware + let responseMiddlewares = responseMiddleware; if (!Array.isArray(responseMiddlewares)) { - responseMiddlewares = [responseMiddlewares] + responseMiddlewares = [responseMiddlewares]; } - responseMiddlewares.forEach((middleware) => { - if (typeof middleware.applyMiddleware === 'function') { - this.defaultNetworkInterface.use([middleware]) - } else if (typeof middleware.applyResponseMiddleware === 'function') { - this.responseMiddlewares.push(middleware) + responseMiddlewares.forEach(middleware => { + if (typeof middleware.applyMiddleware === "function") { + this.defaultNetworkInterface.use([middleware]); + } else if (typeof middleware.applyResponseMiddleware === "function") { + this.responseMiddlewares.push(middleware); } else { - throw new Error('Middleware must implement the applyMiddleware or applyResponseMiddleware functions') + throw new Error( + "Middleware must implement the applyMiddleware or applyResponseMiddleware functions" + ); } - }) + }); } async applyResponseMiddlewares(response) { // eslint-disable-next-line no-unused-vars return new Promise((resolve, reject) => { - const queue = async (funcs) => { + const queue = async funcs => { const next = async () => { if (funcs.length > 0) { - const f = funcs.shift() - f.applyResponseMiddleware(response, next) + const f = funcs.shift(); + f.applyResponseMiddleware(response, next); } else { - resolve(response) + resolve(response); } - } - next() - } + }; + next(); + }; - queue([...this.responseMiddlewares]) - }) + queue([...this.responseMiddlewares]); + }); } async query(request) { - let response = await this.defaultNetworkInterface.query(request) - response = await this.applyResponseMiddlewares(response) - return response + let response = await this.defaultNetworkInterface.query(request); + response = await this.applyResponseMiddlewares(response); + return response; } } -export default ResponseMiddlewareNetworkInterface +export default ResponseMiddlewareNetworkInterface; diff --git a/src/server/action_handlers/actionkit-rsvp.js b/src/server/action_handlers/actionkit-rsvp.js index 4814c39cd..94c08bb41 100644 --- a/src/server/action_handlers/actionkit-rsvp.js +++ b/src/server/action_handlers/actionkit-rsvp.js @@ -1,105 +1,142 @@ -import request from 'request' -import { r } from '../models' -import crypto from 'crypto' +import request from "request"; +import { r } from "../models"; +import crypto from "crypto"; -export const displayName = () => 'ActionKit Event RSVP' +export const displayName = () => "ActionKit Event RSVP"; -export const instructions = () => ( +export const instructions = () => ` Campaign contacts MUST be uploaded with "event_id" and "event_page" fields along with external_id=. Optional fields include "event_source" (defaults to 'spoke') and "event_field_*" fields and "event_action_*" which will be added as post data where '*' can be any word which will map to an action/event field. - `) + `; export async function available(organizationId) { if (process.env.AK_BASEURL && process.env.AK_SECRET) { - return true + return true; } - const org = await r.knex('organization').where('id', organizationId).select('features') - const features = JSON.parse(org.features || '{}') - let needed = [] + const org = await r + .knex("organization") + .where("id", organizationId) + .select("features"); + const features = JSON.parse(org.features || "{}"); + let needed = []; if (!process.env.AK_BASEURL && !features.AK_BASEURL) { - needed.push('AK_BASEURL') + needed.push("AK_BASEURL"); } if (!process.env.AK_SECRET && !features.AK_SECRET) { - needed.push('AK_SECRET') + needed.push("AK_SECRET"); } if (needed.length) { - console.error('actionkit-rsvp unavailable because ' - + needed.join(', ') - + ' must be set (either in environment variables or json value for organization)') + console.error( + "actionkit-rsvp unavailable because " + + needed.join(", ") + + " must be set (either in environment variables or json value for organization)" + ); } - return !!(needed.length) + return !!needed.length; } -export const akidGenerate = function (ak_secret, cleartext) { - const shaHash = crypto.createHash('sha256') - shaHash.write(`${ak_secret}.${cleartext}`) - const shortHash = shaHash.digest('base64').slice(0, 6) - return `${cleartext}.${shortHash}` -} +export const akidGenerate = function(ak_secret, cleartext) { + const shaHash = crypto.createHash("sha256"); + shaHash.write(`${ak_secret}.${cleartext}`); + const shortHash = shaHash.digest("base64").slice(0, 6); + return `${cleartext}.${shortHash}`; +}; -export async function processAction(questionResponse, interactionStep, campaignContactId) { - const contactRes = await r.knex('campaign_contact') - .where('campaign_contact.id', campaignContactId) - .leftJoin('campaign', 'campaign_contact.campaign_id', 'campaign.id') - .leftJoin('organization', 'campaign.organization_id', 'organization.id') - .select('campaign_contact.custom_fields as custom_fields', - 'campaign_contact.external_id as external_id', - 'organization.features as features', - 'organization.id as organization_id') - const contact = (contactRes.length ? contactRes[0] : {}) +export async function processAction( + questionResponse, + interactionStep, + campaignContactId +) { + const contactRes = await r + .knex("campaign_contact") + .where("campaign_contact.id", campaignContactId) + .leftJoin("campaign", "campaign_contact.campaign_id", "campaign.id") + .leftJoin("organization", "campaign.organization_id", "organization.id") + .select( + "campaign_contact.custom_fields as custom_fields", + "campaign_contact.external_id as external_id", + "organization.features as features", + "organization.id as organization_id" + ); + const contact = contactRes.length ? contactRes[0] : {}; - if (contact.external_id && contact.custom_fields != '{}') { + if (contact.external_id && contact.custom_fields != "{}") { try { - const customFields = JSON.parse(contact.custom_fields || '{}') - const features = JSON.parse(contact.features || '{}') - const actionkitBaseUrl = process.env.AK_BASEURL || features.AK_BASEURL - const akSecret = process.env.AK_SECRET || features.AK_SECRET + const customFields = JSON.parse(contact.custom_fields || "{}"); + const features = JSON.parse(contact.features || "{}"); + const actionkitBaseUrl = process.env.AK_BASEURL || features.AK_BASEURL; + const akSecret = process.env.AK_SECRET || features.AK_SECRET; - if (actionkitBaseUrl && customFields.event_id && customFields.event_page) { + if ( + actionkitBaseUrl && + customFields.event_id && + customFields.event_page + ) { const userData = { event_id: customFields.event_id, page: customFields.event_page, - role: 'attendee', - status: 'active', - akid: akidGenerate(akSecret, '.' + contact.external_id), - event_signup_ground_rules: '1', - source: customFields.event_source || 'spoke', - suppress_subscribe: customFields.suppress_subscribe || '1' - } + role: "attendee", + status: "active", + akid: akidGenerate(akSecret, "." + contact.external_id), + event_signup_ground_rules: "1", + source: customFields.event_source || "spoke", + suppress_subscribe: customFields.suppress_subscribe || "1" + }; for (let field in customFields) { - if (field.startsWith('event_field_')) { - userData['event_' + field.slice('event_field_'.length)] = customFields[field] - } else if (field.startsWith('event_action_')) { - userData[field.slice('event_'.length)] = customFields[field] + if (field.startsWith("event_field_")) { + userData["event_" + field.slice("event_field_".length)] = + customFields[field]; + } else if (field.startsWith("event_action_")) { + userData[field.slice("event_".length)] = customFields[field]; } } - request.post({ - 'url': `${actionkitBaseUrl}/act/`, - 'form': userData - }, async function (err, httpResponse, body) { - // TODO: should we save the action id somewhere? - if (err || (body && body.error)) { - console.error('error: actionkit event sign up failed', err, userData, body) - } else { - if (httpResponse.headers && httpResponse.headers.location) { - const actionId = httpResponse.headers.location.match(/action_id=([^&]+)/) - if (actionId) { - // save the action id of the rsvp back to the contact record - customFields['processed_event_action'] = actionId[1] - await r.knex('campaign_contact') - .where('campaign_contact.id', campaignContactId) - .update('custom_fields', JSON.stringify(customFields)) + request.post( + { + url: `${actionkitBaseUrl}/act/`, + form: userData + }, + async function(err, httpResponse, body) { + // TODO: should we save the action id somewhere? + if (err || (body && body.error)) { + console.error( + "error: actionkit event sign up failed", + err, + userData, + body + ); + } else { + if (httpResponse.headers && httpResponse.headers.location) { + const actionId = httpResponse.headers.location.match( + /action_id=([^&]+)/ + ); + if (actionId) { + // save the action id of the rsvp back to the contact record + customFields["processed_event_action"] = actionId[1]; + await r + .knex("campaign_contact") + .where("campaign_contact.id", campaignContactId) + .update("custom_fields", JSON.stringify(customFields)); + } } + console.info( + "actionkit event sign up SUCCESS!", + userData, + httpResponse, + body + ); } - console.info('actionkit event sign up SUCCESS!', userData, httpResponse, body) } - }) + ); } } catch (err) { - console.error('Processing Actionkit RSVP action failed on custom field parsing', campaignContactId, err) + console.error( + "Processing Actionkit RSVP action failed on custom field parsing", + campaignContactId, + err + ); } } } diff --git a/src/server/action_handlers/helper-ak-sync.js b/src/server/action_handlers/helper-ak-sync.js index 3c2ac590c..453d08f36 100644 --- a/src/server/action_handlers/helper-ak-sync.js +++ b/src/server/action_handlers/helper-ak-sync.js @@ -1,60 +1,66 @@ -import request from 'request' +import request from "request"; -const akAddUserUrl = process.env.AK_ADD_USER_URL -const akAddPhoneUrl = process.env.AK_ADD_PHONE_URL +const akAddUserUrl = process.env.AK_ADD_USER_URL; +const akAddPhoneUrl = process.env.AK_ADD_PHONE_URL; -export const actionKitSignup = (contact) => { - const cell = contact.cell.substring(1) - // We add the user to ActionKit to make sure we keep have a record of their phone number & attach it to a fake email. +export const actionKitSignup = contact => { + const cell = contact.cell.substring(1); + // We add the user to ActionKit to make sure we keep have a record of their phone number & attach it to a fake email. if (akAddUserUrl && akAddPhoneUrl) { const userData = { - email: cell + '-smssubscriber@example.com', + email: cell + "-smssubscriber@example.com", first_name: contact.first_name, last_name: contact.last_name, - user_sms_subscribed: 'sms_subscribed', - user_sms_termsandconditions: 'sms_termsandconditions', - user_robodial_termsandconditions: 'yes', + user_sms_subscribed: "sms_subscribed", + user_sms_termsandconditions: "sms_termsandconditions", + user_robodial_termsandconditions: "yes", suppress_subscribe: true, phone: [cell], - phone_type: 'mobile', - source: 'spoke-signup' - } + phone_type: "mobile", + source: "spoke-signup" + }; - request.post({ - url: akAddUserUrl, - headers: { - accept: 'application/json', - 'content-type': 'application/json' + request.post( + { + url: akAddUserUrl, + headers: { + accept: "application/json", + "content-type": "application/json" + }, + form: userData }, - form: userData - }, (errorResponse, httpResponse) => { - if (errorResponse) throw new Error(errorResponse) - if (httpResponse.statusCode === 201) { - request.post({ - url: akAddPhoneUrl, - headers: { - accept: 'application/json', - 'content-type': 'application/json' - }, - form: { - user: httpResponse.headers.location, - phone: cell, - type: 'mobile', - user_sms_subscribed: 'sms_subscribed', - action_mobilesubscribe: '1', - action_sms_termsandconditions: 'sms_termsandconditions', - user_sms_termsandconditions: 'sms_termsandconditions', - user_robodial_termsandconditions: 'yes' - } - }, (phoneError, phoneResponse) => { - if (phoneError) throw new Error(phoneError) - if (phoneResponse.statusCode === 201) { - return - } - }) + (errorResponse, httpResponse) => { + if (errorResponse) throw new Error(errorResponse); + if (httpResponse.statusCode === 201) { + request.post( + { + url: akAddPhoneUrl, + headers: { + accept: "application/json", + "content-type": "application/json" + }, + form: { + user: httpResponse.headers.location, + phone: cell, + type: "mobile", + user_sms_subscribed: "sms_subscribed", + action_mobilesubscribe: "1", + action_sms_termsandconditions: "sms_termsandconditions", + user_sms_termsandconditions: "sms_termsandconditions", + user_robodial_termsandconditions: "yes" + } + }, + (phoneError, phoneResponse) => { + if (phoneError) throw new Error(phoneError); + if (phoneResponse.statusCode === 201) { + return; + } + } + ); + } } - }) + ); } else { - console.log('No AK Post URLs Configured') + console.log("No AK Post URLs Configured"); } -} +}; diff --git a/src/server/action_handlers/mobilecommons-signup.js b/src/server/action_handlers/mobilecommons-signup.js index d52e46f5d..0d939541e 100644 --- a/src/server/action_handlers/mobilecommons-signup.js +++ b/src/server/action_handlers/mobilecommons-signup.js @@ -1,62 +1,76 @@ -import request from 'request' -import aws from 'aws-sdk' -import { r } from '../models' -import { actionKitSignup } from './helper-ak-sync.js' +import request from "request"; +import aws from "aws-sdk"; +import { r } from "../models"; +import { actionKitSignup } from "./helper-ak-sync.js"; // What the user sees as the option -export const displayName = () => 'Mobile Commons Signup' +export const displayName = () => "Mobile Commons Signup"; -const akAddUserUrl = process.env.AK_ADD_USER_URL -const akAddPhoneUrl = process.env.AK_ADD_PHONE_URL -const createProfileUrl = process.env.UMC_PROFILE_URL -const defaultProfileOptInId = process.env.UMC_OPT_IN_PATH -const umcAuth = 'Basic ' + Buffer.from(process.env.UMC_USER + ':' + process.env.UMC_PW).toString('base64') -const umcConfigured = (defaultProfileOptInId && createProfileUrl) +const akAddUserUrl = process.env.AK_ADD_USER_URL; +const akAddPhoneUrl = process.env.AK_ADD_PHONE_URL; +const createProfileUrl = process.env.UMC_PROFILE_URL; +const defaultProfileOptInId = process.env.UMC_OPT_IN_PATH; +const umcAuth = + "Basic " + + Buffer.from(process.env.UMC_USER + ":" + process.env.UMC_PW).toString( + "base64" + ); +const umcConfigured = defaultProfileOptInId && createProfileUrl; // The Help text for the user after selecting the action -export const instructions = () => ( - 'This option triggers a new user request to Upland Mobile Commons when selected.' -) +export const instructions = () => + "This option triggers a new user request to Upland Mobile Commons when selected."; export async function available(organizationId) { - if (organizationId && umcConfigured) { - return true + if (organizationId && umcConfigured) { + return true; } - return false + return false; } -export async function processAction(questionResponse, interactionStep, campaignContactId) { - const contactRes = await r.knex('campaign_contact') - .where('campaign_contact.id', campaignContactId) - .leftJoin('campaign', 'campaign_contact.campaign_id', 'campaign.id') - .leftJoin('organization', 'campaign.organization_id', 'organization.id') - .select('campaign_contact.cell', 'campaign_contact.first_name', 'campaign_contact.last_name', 'campaign_contact.custom_fields') +export async function processAction( + questionResponse, + interactionStep, + campaignContactId +) { + const contactRes = await r + .knex("campaign_contact") + .where("campaign_contact.id", campaignContactId) + .leftJoin("campaign", "campaign_contact.campaign_id", "campaign.id") + .leftJoin("organization", "campaign.organization_id", "organization.id") + .select( + "campaign_contact.cell", + "campaign_contact.first_name", + "campaign_contact.last_name", + "campaign_contact.custom_fields" + ); - const contact = (contactRes.length ? contactRes[0] : {}) - const customFields = JSON.parse(contact.custom_fields) - const optInPathId = (customFields.umc_opt_in_path ? customFields.umc_opt_in_path : defaultProfileOptInId) - const cell = contact.cell.substring(1) + const contact = contactRes.length ? contactRes[0] : {}; + const customFields = JSON.parse(contact.custom_fields); + const optInPathId = customFields.umc_opt_in_path + ? customFields.umc_opt_in_path + : defaultProfileOptInId; + const cell = contact.cell.substring(1); - actionKitSignup(contact) + actionKitSignup(contact); const options = { - method: 'POST', + method: "POST", url: createProfileUrl, headers: { - accept: 'application/json', - 'content-type': 'application/json', + accept: "application/json", + "content-type": "application/json", Authorization: umcAuth }, body: { phone_number: cell, - first_name: contact.first_name || '', - last_name: contact.last_name || '', + first_name: contact.first_name || "", + last_name: contact.last_name || "", opt_in_path_id: optInPathId }, json: true - } + }; return request(options, (error, response) => { - if (error) throw new Error(error) - }) - + if (error) throw new Error(error); + }); } diff --git a/src/server/action_handlers/revere-signup.js b/src/server/action_handlers/revere-signup.js index 324969928..905273fce 100644 --- a/src/server/action_handlers/revere-signup.js +++ b/src/server/action_handlers/revere-signup.js @@ -1,71 +1,82 @@ -import request from 'request' -import aws from 'aws-sdk' -import { r } from '../models' -import { actionKitSignup } from './helper-ak-sync.js' +import request from "request"; +import aws from "aws-sdk"; +import { r } from "../models"; +import { actionKitSignup } from "./helper-ak-sync.js"; -const sqs = new aws.SQS() +const sqs = new aws.SQS(); // What the user sees as the option -export const displayName = () => 'Revere Signup' +export const displayName = () => "Revere Signup"; -const listId = process.env.REVERE_LIST_ID -const defaultMobileFlowId = process.env.REVERE_NEW_SUBSCRIBER_MOBILE_FLOW -const mobileApiKey = process.env.REVERE_MOBILE_API_KEY -const sendContentUrl = process.env.REVERE_API_URL -const akAddUserUrl = process.env.AK_ADD_USER_URL -const akAddPhoneUrl = process.env.AK_ADD_PHONE_URL -const sqsUrl = process.env.REVERE_SQS_URL +const listId = process.env.REVERE_LIST_ID; +const defaultMobileFlowId = process.env.REVERE_NEW_SUBSCRIBER_MOBILE_FLOW; +const mobileApiKey = process.env.REVERE_MOBILE_API_KEY; +const sendContentUrl = process.env.REVERE_API_URL; +const akAddUserUrl = process.env.AK_ADD_USER_URL; +const akAddPhoneUrl = process.env.AK_ADD_PHONE_URL; +const sqsUrl = process.env.REVERE_SQS_URL; // The Help text for the user after selecting the action -export const instructions = () => ( - 'This option triggers a new user request to Revere when selected.' -) +export const instructions = () => + "This option triggers a new user request to Revere when selected."; export async function available(organizationId) { - if ((organizationId && listId) && mobileApiKey) { - return true + if (organizationId && listId && mobileApiKey) { + return true; } - return false + return false; } -export async function processAction(questionResponse, interactionStep, campaignContactId) { - const contactRes = await r.knex('campaign_contact') - .where('campaign_contact.id', campaignContactId) - .leftJoin('campaign', 'campaign_contact.campaign_id', 'campaign.id') - .leftJoin('organization', 'campaign.organization_id', 'organization.id') - .select('campaign_contact.cell', 'campaign_contact.first_name', 'campaign_contact.last_name', 'campaign_contact.custom_fields') +export async function processAction( + questionResponse, + interactionStep, + campaignContactId +) { + const contactRes = await r + .knex("campaign_contact") + .where("campaign_contact.id", campaignContactId) + .leftJoin("campaign", "campaign_contact.campaign_id", "campaign.id") + .leftJoin("organization", "campaign.organization_id", "organization.id") + .select( + "campaign_contact.cell", + "campaign_contact.first_name", + "campaign_contact.last_name", + "campaign_contact.custom_fields" + ); - const contact = (contactRes.length ? contactRes[0] : {}) - const customFields = JSON.parse(contact.custom_fields) - const mobileFlowId = (customFields.revere_signup_flow ? customFields.revere_signup_flow : defaultMobileFlowId) - const contactCell = contact.cell.substring(1) + const contact = contactRes.length ? contactRes[0] : {}; + const customFields = JSON.parse(contact.custom_fields); + const mobileFlowId = customFields.revere_signup_flow + ? customFields.revere_signup_flow + : defaultMobileFlowId; + const contactCell = contact.cell.substring(1); if (sqsUrl) { const msg = { payload: { cell: `${contactCell}`, mobile_flow_id: `${mobileFlowId}`, - source: 'spoke' + source: "spoke" } - } + }; const sqsParams = { MessageBody: JSON.stringify(msg), QueueUrl: sqsUrl - } + }; sqs.sendMessage(sqsParams, (err, data) => { if (err) { - console.log('Error sending message to queue', err) + console.log("Error sending message to queue", err); } - console.log('Sent message to queue with data:', data) - }) + console.log("Sent message to queue with data:", data); + }); } else { const options = { - method: 'POST', + method: "POST", url: sendContentUrl, headers: { - accept: 'application/json', - 'content-type': 'application/json', + accept: "application/json", + "content-type": "application/json", Authorization: mobileApiKey }, body: { @@ -73,12 +84,12 @@ export async function processAction(questionResponse, interactionStep, campaignC mobileFlow: `${mobileFlowId}` }, json: true - } + }; return request(options, (error, response) => { - if (error) throw new Error(error) - }) + if (error) throw new Error(error); + }); } - if (akAddUserUrl && akAddPhoneUrl) actionKitSignup(contact) + if (akAddUserUrl && akAddPhoneUrl) actionKitSignup(contact); } diff --git a/src/server/action_handlers/test-action.js b/src/server/action_handlers/test-action.js index 748d53c76..4ed9e6aca 100644 --- a/src/server/action_handlers/test-action.js +++ b/src/server/action_handlers/test-action.js @@ -1,15 +1,14 @@ -import request from 'request' -import { r } from '../models' +import request from "request"; +import { r } from "../models"; // What the user sees as the option -export const displayName = () => 'Test Action' +export const displayName = () => "Test Action"; // The Help text for the user after selecting the action -export const instructions = () => ( +export const instructions = () => ` This action is for testing and as a code-template for new actions. - ` -) + `; // return true, if the action is usable and available for the organizationId // Sometimes this means certain variables/credentials must be setup @@ -17,23 +16,29 @@ export const instructions = () => ( // Besides this returning true, "test-action" will also need to be added to // process.env.ACTION_HANDLERS export async function available(organizationId) { - return true + return true; } // What happens when a texter saves the answer that triggers the action // This is presumably the meat of the action -export async function processAction(questionResponse, interactionStep, campaignContactId) { +export async function processAction( + questionResponse, + interactionStep, + campaignContactId +) { // This is a meta action that updates a variable in the contact record itself. // Generally, you want to send action data to the outside world, so you // might want the request library loaded above - const contact = await r.knex('campaign_contact') - .where('campaign_contact_id', campaignContactId) - const customFields = JSON.parse(contact.custom_fields || '{}') + const contact = await r + .knex("campaign_contact") + .where("campaign_contact_id", campaignContactId); + const customFields = JSON.parse(contact.custom_fields || "{}"); if (customFields) { - customFields['processed_test_action'] = 'completed' + customFields["processed_test_action"] = "completed"; } - await r.knex('campaign_contact') - .where('campaign_contact.id', campaignContactId) - .update('custom_fields', JSON.stringify(customFields)) + await r + .knex("campaign_contact") + .where("campaign_contact.id", campaignContactId) + .update("custom_fields", JSON.stringify(customFields)); } diff --git a/src/server/api/assignment.js b/src/server/api/assignment.js index 2a5d4ef0a..248849ec7 100644 --- a/src/server/api/assignment.js +++ b/src/server/api/assignment.js @@ -1,123 +1,146 @@ -import { mapFieldsToModel } from './lib/utils' -import { Assignment, r, cacheableData } from '../models' -import { getOffsets, defaultTimezoneIsBetweenTextingHours } from '../../lib' +import { mapFieldsToModel } from "./lib/utils"; +import { Assignment, r, cacheableData } from "../models"; +import { getOffsets, defaultTimezoneIsBetweenTextingHours } from "../../lib"; export function addWhereClauseForContactsFilterMessageStatusIrrespectiveOfPastDue( queryParameter, messageStatusFilter ) { if (!messageStatusFilter) { - return queryParameter + return queryParameter; } - let query = queryParameter - if (messageStatusFilter === 'needsMessageOrResponse') { - query.whereIn('message_status', ['needsResponse', 'needsMessage']) + let query = queryParameter; + if (messageStatusFilter === "needsMessageOrResponse") { + query.whereIn("message_status", ["needsResponse", "needsMessage"]); } else { - query = query.whereIn('message_status', messageStatusFilter.split(',')) + query = query.whereIn("message_status", messageStatusFilter.split(",")); } - return query + return query; } -export function getContacts(assignment, contactsFilter, organization, campaign, forCount = false) { +export function getContacts( + assignment, + contactsFilter, + organization, + campaign, + forCount = false +) { // / returns list of contacts eligible for contacting _now_ by a particular user - const textingHoursEnforced = organization.texting_hours_enforced - const textingHoursStart = organization.texting_hours_start - const textingHoursEnd = organization.texting_hours_end + const textingHoursEnforced = organization.texting_hours_enforced; + const textingHoursStart = organization.texting_hours_start; + const textingHoursEnd = organization.texting_hours_end; // 24-hours past due - why is this 24 hours offset? - const includePastDue = (contactsFilter && contactsFilter.includePastDue) - const pastDue = (campaign.due_by - && Number(campaign.due_by) + 24 * 60 * 60 * 1000 < Number(new Date())) - const config = { textingHoursStart, textingHoursEnd, textingHoursEnforced } + const includePastDue = contactsFilter && contactsFilter.includePastDue; + const pastDue = + campaign.due_by && + Number(campaign.due_by) + 24 * 60 * 60 * 1000 < Number(new Date()); + const config = { textingHoursStart, textingHoursEnd, textingHoursEnforced }; if (campaign.override_organization_texting_hours) { - const textingHoursStart = campaign.texting_hours_start - const textingHoursEnd = campaign.texting_hours_end - const textingHoursEnforced = campaign.texting_hours_enforced - const timezone = campaign.timezone - - config.campaignTextingHours = { textingHoursStart, textingHoursEnd, textingHoursEnforced, timezone } + const textingHoursStart = campaign.texting_hours_start; + const textingHoursEnd = campaign.texting_hours_end; + const textingHoursEnforced = campaign.texting_hours_enforced; + const timezone = campaign.timezone; + + config.campaignTextingHours = { + textingHoursStart, + textingHoursEnd, + textingHoursEnforced, + timezone + }; } - const [validOffsets, invalidOffsets] = getOffsets(config) - if (!includePastDue && pastDue && contactsFilter && contactsFilter.messageStatus === 'needsMessage') { - return [] + const [validOffsets, invalidOffsets] = getOffsets(config); + if ( + !includePastDue && + pastDue && + contactsFilter && + contactsFilter.messageStatus === "needsMessage" + ) { + return []; } - let query = r.knex('campaign_contact').where({ + let query = r.knex("campaign_contact").where({ assignment_id: assignment.id - }) + }); if (contactsFilter) { - const validTimezone = contactsFilter.validTimezone + const validTimezone = contactsFilter.validTimezone; if (validTimezone !== null) { if (validTimezone === true) { if (defaultTimezoneIsBetweenTextingHours(config)) { // missing timezone ok - validOffsets.push('') + validOffsets.push(""); } - query = query.whereIn('timezone_offset', validOffsets) + query = query.whereIn("timezone_offset", validOffsets); } else if (validTimezone === false) { if (!defaultTimezoneIsBetweenTextingHours(config)) { // missing timezones are not ok to text - invalidOffsets.push('') + invalidOffsets.push(""); } - query = query.whereIn('timezone_offset', invalidOffsets) + query = query.whereIn("timezone_offset", invalidOffsets); } } query = addWhereClauseForContactsFilterMessageStatusIrrespectiveOfPastDue( query, - ((contactsFilter && contactsFilter.messageStatus) || - (pastDue - // by default if asking for 'send later' contacts we include only those that need replies - ? 'needsResponse' - // we do not want to return closed/messaged - : 'needsMessageOrResponse')) - ) - - if (Object.prototype.hasOwnProperty.call(contactsFilter, 'isOptedOut')) { - query = query.where('is_opted_out', contactsFilter.isOptedOut) + (contactsFilter && contactsFilter.messageStatus) || + (pastDue + ? // by default if asking for 'send later' contacts we include only those that need replies + "needsResponse" + : // we do not want to return closed/messaged + "needsMessageOrResponse") + ); + + if (Object.prototype.hasOwnProperty.call(contactsFilter, "isOptedOut")) { + query = query.where("is_opted_out", contactsFilter.isOptedOut); } } if (!forCount) { - if (contactsFilter && contactsFilter.messageStatus === 'convo') { - query = query.orderByRaw('message_status DESC, updated_at DESC') + if (contactsFilter && contactsFilter.messageStatus === "convo") { + query = query.orderByRaw("message_status DESC, updated_at DESC"); } else { - query = query.orderByRaw('message_status DESC, updated_at') + query = query.orderByRaw("message_status DESC, updated_at"); } } - return query + return query; } export const resolvers = { Assignment: { - ...mapFieldsToModel(['id', 'maxContacts'], Assignment), - texter: async (assignment, _, { loaders }) => ( + ...mapFieldsToModel(["id", "maxContacts"], Assignment), + texter: async (assignment, _, { loaders }) => assignment.texter - ? assignment.texter - : loaders.user.load(assignment.user_id) - ), - campaign: async (assignment, _, { loaders }) => loaders.campaign.load(assignment.campaign_id), + ? assignment.texter + : loaders.user.load(assignment.user_id), + campaign: async (assignment, _, { loaders }) => + loaders.campaign.load(assignment.campaign_id), contactsCount: async (assignment, { contactsFilter }) => { - const campaign = await r.table('campaign').get(assignment.campaign_id) + const campaign = await r.table("campaign").get(assignment.campaign_id); - const organization = await r.table('organization').get(campaign.organization_id) + const organization = await r + .table("organization") + .get(campaign.organization_id); - return await r.getCount(getContacts(assignment, contactsFilter, organization, campaign, true)) + return await r.getCount( + getContacts(assignment, contactsFilter, organization, campaign, true) + ); }, contacts: async (assignment, { contactsFilter }) => { - const campaign = await r.table('campaign').get(assignment.campaign_id) + const campaign = await r.table("campaign").get(assignment.campaign_id); - const organization = await r.table('organization').get(campaign.organization_id) - return getContacts(assignment, contactsFilter, organization, campaign) + const organization = await r + .table("organization") + .get(campaign.organization_id); + return getContacts(assignment, contactsFilter, organization, campaign); }, campaignCannedResponses: async assignment => await cacheableData.cannedResponse.query({ - userId: '', + userId: "", campaignId: assignment.campaign_id }), userCannedResponses: async assignment => @@ -126,4 +149,4 @@ export const resolvers = { campaignId: assignment.campaign_id }) } -} +}; diff --git a/src/server/api/campaign-contact.js b/src/server/api/campaign-contact.js index c523f6f7f..1ffcca56b 100644 --- a/src/server/api/campaign-contact.js +++ b/src/server/api/campaign-contact.js @@ -1,178 +1,196 @@ -import { CampaignContact, r, cacheableData } from '../models' -import { mapFieldsToModel } from './lib/utils' -import { log, getTopMostParent, zipToTimeZone } from '../../lib' +import { CampaignContact, r, cacheableData } from "../models"; +import { mapFieldsToModel } from "./lib/utils"; +import { log, getTopMostParent, zipToTimeZone } from "../../lib"; export const resolvers = { Location: { - timezone: (zipCode) => zipCode || {}, - city: (zipCode) => zipCode.city || '', - state: (zipCode) => zipCode.state || '' + timezone: zipCode => zipCode || {}, + city: zipCode => zipCode.city || "", + state: zipCode => zipCode.state || "" }, Timezone: { - offset: (zipCode) => zipCode.timezone_offset || null, - hasDST: (zipCode) => zipCode.has_dst || null + offset: zipCode => zipCode.timezone_offset || null, + hasDST: zipCode => zipCode.has_dst || null }, CampaignContact: { - ...mapFieldsToModel([ - 'id', - 'firstName', - 'lastName', - 'cell', - 'zip', - 'customFields', - 'messageStatus', - 'assignmentId', - 'external_id' - ], CampaignContact), + ...mapFieldsToModel( + [ + "id", + "firstName", + "lastName", + "cell", + "zip", + "customFields", + "messageStatus", + "assignmentId", + "external_id" + ], + CampaignContact + ), messageStatus: async (campaignContact, _, { loaders }) => { if (campaignContact.message_status) { - return campaignContact.message_status + return campaignContact.message_status; } // TODO: look it up via cacheing }, - campaign: async (campaignContact, _, { loaders }) => ( - loaders.campaign.load(campaignContact.campaign_id) - ), + campaign: async (campaignContact, _, { loaders }) => + loaders.campaign.load(campaignContact.campaign_id), // To get that result to look like what the original code returned // without using the outgoing answer_options array field, try this: // questionResponseValues: async (campaignContact, _, { loaders }) => { - if (campaignContact.message_status === 'needsMessage') { - return [] // it's the beginning, so there won't be any + if (campaignContact.message_status === "needsMessage") { + return []; // it's the beginning, so there won't be any } - return await r.knex('question_response') - .where('question_response.campaign_contact_id', campaignContact.id) - .select('value', 'interaction_step_id') + return await r + .knex("question_response") + .where("question_response.campaign_contact_id", campaignContact.id) + .select("value", "interaction_step_id"); }, questionResponses: async (campaignContact, _, { loaders }) => { - const results = await r.knex('question_response as qres') - .where('qres.campaign_contact_id', campaignContact.id) - .join('interaction_step', 'qres.interaction_step_id', 'interaction_step.id') - .join('interaction_step as child', - 'qres.interaction_step_id', - 'child.parent_interaction_id') - .select('child.answer_option', - 'child.id', - 'child.parent_interaction_id', - 'child.created_at', - 'interaction_step.interaction_step_id', - 'interaction_step.campaign_id', - 'interaction_step.question', - 'interaction_step.script', - 'qres.id', - 'qres.value', - 'qres.created_at', - 'qres.interaction_step_id') - .catch(log.error) + const results = await r + .knex("question_response as qres") + .where("qres.campaign_contact_id", campaignContact.id) + .join( + "interaction_step", + "qres.interaction_step_id", + "interaction_step.id" + ) + .join( + "interaction_step as child", + "qres.interaction_step_id", + "child.parent_interaction_id" + ) + .select( + "child.answer_option", + "child.id", + "child.parent_interaction_id", + "child.created_at", + "interaction_step.interaction_step_id", + "interaction_step.campaign_id", + "interaction_step.question", + "interaction_step.script", + "qres.id", + "qres.value", + "qres.created_at", + "qres.interaction_step_id" + ) + .catch(log.error); - let formatted = {} + let formatted = {}; for (let i = 0; i < results.length; i++) { - const res = results[i] + const res = results[i]; - const responseId = res['qres.id'] - const responseValue = res['qres.value'] - const answerValue = res['child.answer_option'] - const interactionStepId = res['child.id'] + const responseId = res["qres.id"]; + const responseValue = res["qres.value"]; + const answerValue = res["child.answer_option"]; + const interactionStepId = res["child.id"]; if (responseId in formatted) { - formatted[responseId]['parent_interaction_step']['answer_options'].push({ - 'value': answerValue, - 'interaction_step_id': interactionStepId - }) + formatted[responseId]["parent_interaction_step"][ + "answer_options" + ].push({ + value: answerValue, + interaction_step_id: interactionStepId + }); if (responseValue === answerValue) { - formatted[responseId]['interaction_step_id'] = interactionStepId + formatted[responseId]["interaction_step_id"] = interactionStepId; } } else { formatted[responseId] = { - 'contact_response_value': responseValue, - 'interaction_step_id': interactionStepId, - 'parent_interaction_step': { - 'answer_option': '', - 'answer_options': [{ 'value': answerValue, - 'interaction_step_id': interactionStepId - }], - 'campaign_id': res['interaction_step.campaign_id'], - 'created_at': res['child.created_at'], - 'id': responseId, - 'parent_interaction_id': res['interaction_step.parent_interaction_id'], - 'question': res['interaction_step.question'], - 'script': res['interaction_step.script'] + contact_response_value: responseValue, + interaction_step_id: interactionStepId, + parent_interaction_step: { + answer_option: "", + answer_options: [ + { value: answerValue, interaction_step_id: interactionStepId } + ], + campaign_id: res["interaction_step.campaign_id"], + created_at: res["child.created_at"], + id: responseId, + parent_interaction_id: + res["interaction_step.parent_interaction_id"], + question: res["interaction_step.question"], + script: res["interaction_step.script"] }, - 'value': responseValue - } + value: responseValue + }; } } - return Object.values(formatted) + return Object.values(formatted); }, location: async (campaignContact, _, { loaders }) => { if (campaignContact.timezone_offset) { // couldn't look up the timezone by zip record, so we load it // from the campaign_contact directly if it's there - const [offset, hasDst] = campaignContact.timezone_offset.split('_') + const [offset, hasDst] = campaignContact.timezone_offset.split("_"); const loc = { timezone_offset: parseInt(offset, 10), - has_dst: (hasDst === '1') - } + has_dst: hasDst === "1" + }; // From cache if (campaignContact.city) { - loc.city = campaignContact.city - loc.state = campaignContact.state || undefined + loc.city = campaignContact.city; + loc.state = campaignContact.state || undefined; } - return loc + return loc; } - const mainZip = campaignContact.zip.split('-')[0] - const calculated = zipToTimeZone(mainZip) + const mainZip = campaignContact.zip.split("-")[0]; + const calculated = zipToTimeZone(mainZip); if (calculated) { return { timezone_offset: calculated[2], - has_dst: (calculated[3] === 1) - } + has_dst: calculated[3] === 1 + }; } - return await loaders.zipCode.load(mainZip) + return await loaders.zipCode.load(mainZip); }, - messages: async (campaignContact) => { - if (campaignContact.message_status === 'needsMessage') { - return [] // it's the beginning, so there won't be any + messages: async campaignContact => { + if (campaignContact.message_status === "needsMessage") { + return []; // it's the beginning, so there won't be any } - if ('messages' in campaignContact) { - return campaignContact.messages + if ("messages" in campaignContact) { + return campaignContact.messages; } - const messages = await r.table('message') - .getAll(campaignContact.assignment_id, { index: 'assignment_id' }) + const messages = await r + .table("message") + .getAll(campaignContact.assignment_id, { index: "assignment_id" }) .filter({ contact_number: campaignContact.cell }) - .orderBy('created_at') + .orderBy("created_at"); - return messages + return messages; }, optOut: async (campaignContact, _, { loaders }) => { - if ('opt_out_cell' in campaignContact) { + if ("opt_out_cell" in campaignContact) { return { cell: campaignContact.opt_out_cell - } + }; } else { - let isOptedOut = null - if (typeof campaignContact.is_opted_out !== 'undefined') { - isOptedOut = campaignContact.is_opted_out + let isOptedOut = null; + if (typeof campaignContact.is_opted_out !== "undefined") { + isOptedOut = campaignContact.is_opted_out; } else { - let organizationId = campaignContact.organization_id + let organizationId = campaignContact.organization_id; if (!organizationId) { - const campaign = await loaders.campaign.load(campaignContact.campaign_id) - organizationId = campaign.organization_id + const campaign = await loaders.campaign.load( + campaignContact.campaign_id + ); + organizationId = campaign.organization_id; } const isOptedOut = await cacheableData.optOut.query({ cell: campaignContact.cell, organizationId - }) + }); } // fake ID so we don't need to look up existance - return (isOptedOut ? { id: 'optout' } : null) + return isOptedOut ? { id: "optout" } : null; } } } -} +}; diff --git a/src/server/api/campaign.js b/src/server/api/campaign.js index 5e5519f3d..4bf8aa72d 100644 --- a/src/server/api/campaign.js +++ b/src/server/api/campaign.js @@ -1,267 +1,334 @@ -import { accessRequired } from './errors' -import { mapFieldsToModel } from './lib/utils' -import { Campaign, JobRequest, r, cacheableData } from '../models' -import { currentEditors } from '../models/cacheable_queries' -import { getUsers } from './user'; - +import { accessRequired } from "./errors"; +import { mapFieldsToModel } from "./lib/utils"; +import { Campaign, JobRequest, r, cacheableData } from "../models"; +import { currentEditors } from "../models/cacheable_queries"; +import { getUsers } from "./user"; export function addCampaignsFilterToQuery(queryParam, campaignsFilter) { - let query = queryParam + let query = queryParam; if (campaignsFilter) { - const resultSize = (campaignsFilter.listSize ? campaignsFilter.listSize : 0) - const pageSize = (campaignsFilter.pageSize ? campaignsFilter.pageSize : 0) + const resultSize = campaignsFilter.listSize ? campaignsFilter.listSize : 0; + const pageSize = campaignsFilter.pageSize ? campaignsFilter.pageSize : 0; - if ('isArchived' in campaignsFilter) { - query = query.where('campaign.is_archived', campaignsFilter.isArchived ) + if ("isArchived" in campaignsFilter) { + query = query.where("campaign.is_archived", campaignsFilter.isArchived); } - if ('campaignId' in campaignsFilter) { - query = query.where('campaign.id', parseInt(campaignsFilter.campaignId, 10)) + if ("campaignId" in campaignsFilter) { + query = query.where( + "campaign.id", + parseInt(campaignsFilter.campaignId, 10) + ); } if (resultSize && !pageSize) { - query = query.limit(resultSize) + query = query.limit(resultSize); } if (resultSize && pageSize) { - query = query.limit(resultSize).offSet(pageSize) + query = query.limit(resultSize).offSet(pageSize); } } - return query + return query; } -export function buildCampaignQuery(queryParam, organizationId, campaignsFilter, addFromClause = true) { - let query = queryParam +export function buildCampaignQuery( + queryParam, + organizationId, + campaignsFilter, + addFromClause = true +) { + let query = queryParam; if (addFromClause) { - query = query.from('campaign') + query = query.from("campaign"); } - query = query.where('campaign.organization_id', organizationId) - query = addCampaignsFilterToQuery(query, campaignsFilter) + query = query.where("campaign.organization_id", organizationId); + query = addCampaignsFilterToQuery(query, campaignsFilter); - return query + return query; } export async function getCampaigns(organizationId, cursor, campaignsFilter) { - let campaignsQuery = buildCampaignQuery( - r.knex.select('*'), + r.knex.select("*"), organizationId, campaignsFilter - ) - campaignsQuery = campaignsQuery.orderBy('due_by', 'desc').orderBy('id') + ); + campaignsQuery = campaignsQuery.orderBy("due_by", "desc").orderBy("id"); if (cursor) { - campaignsQuery = campaignsQuery.limit(cursor.limit).offset(cursor.offset) - const campaigns = await campaignsQuery + campaignsQuery = campaignsQuery.limit(cursor.limit).offset(cursor.offset); + const campaigns = await campaignsQuery; const campaignsCountQuery = buildCampaignQuery( - r.knex.count('*'), + r.knex.count("*"), organizationId, - campaignsFilter) + campaignsFilter + ); - const campaignsCountArray = await campaignsCountQuery + const campaignsCountArray = await campaignsCountQuery; const pageInfo = { limit: cursor.limit, offset: cursor.offset, total: campaignsCountArray[0].count - } + }; return { campaigns, pageInfo - } + }; } else { - return campaignsQuery + return campaignsQuery; } } export const resolvers = { JobRequest: { - ...mapFieldsToModel([ - 'id', - 'assigned', - 'status', - 'jobType', - 'resultMessage' - ], JobRequest) + ...mapFieldsToModel( + ["id", "assigned", "status", "jobType", "resultMessage"], + JobRequest + ) }, CampaignStats: { sentMessagesCount: async (campaign, _, { user }) => { - await accessRequired(user, campaign.organization_id, 'SUPERVOLUNTEER', true) - return r.table('assignment') - .getAll(campaign.id, { index: 'campaign_id' }) - .eqJoin('id', r.table('message'), { index: 'assignment_id' }) + await accessRequired( + user, + campaign.organization_id, + "SUPERVOLUNTEER", + true + ); + return r + .table("assignment") + .getAll(campaign.id, { index: "campaign_id" }) + .eqJoin("id", r.table("message"), { index: "assignment_id" }) .filter({ is_from_contact: false }) - .count() + .count(); }, receivedMessagesCount: async (campaign, _, { user }) => { - await accessRequired(user, campaign.organization_id, 'SUPERVOLUNTEER', true) - return r.table('assignment') - .getAll(campaign.id, { index: 'campaign_id' }) - // TODO: NEEDSTESTING -- see above setMessagesCount() - .eqJoin('id', r.table('message'), { index: 'assignment_id' }) - .filter({ is_from_contact: true }) - .count() + await accessRequired( + user, + campaign.organization_id, + "SUPERVOLUNTEER", + true + ); + return ( + r + .table("assignment") + .getAll(campaign.id, { index: "campaign_id" }) + // TODO: NEEDSTESTING -- see above setMessagesCount() + .eqJoin("id", r.table("message"), { index: "assignment_id" }) + .filter({ is_from_contact: true }) + .count() + ); }, optOutsCount: async (campaign, _, { user }) => { - await accessRequired(user, campaign.organization_id, 'SUPERVOLUNTEER', true) + await accessRequired( + user, + campaign.organization_id, + "SUPERVOLUNTEER", + true + ); return await r.getCount( - r.knex('campaign_contact') + r + .knex("campaign_contact") .where({ is_opted_out: true, campaign_id: campaign.id }) - ) + ); } }, CampaignsReturn: { __resolveType(obj, context, _) { if (Array.isArray(obj)) { - return 'CampaignsList' - } else if ('campaigns' in obj && 'pageInfo' in obj) { - return 'PaginatedCampaigns' + return "CampaignsList"; + } else if ("campaigns" in obj && "pageInfo" in obj) { + return "PaginatedCampaigns"; } - return null + return null; } }, CampaignsList: { campaigns: campaigns => { - return campaigns + return campaigns; } }, PaginatedCampaigns: { campaigns: queryResult => { - return queryResult.campaigns + return queryResult.campaigns; }, pageInfo: queryResult => { - if ('pageInfo' in queryResult) { - return queryResult.pageInfo + if ("pageInfo" in queryResult) { + return queryResult.pageInfo; } - return null + return null; } }, Campaign: { - ...mapFieldsToModel([ - 'id', - 'title', - 'description', - 'isStarted', - 'isArchived', - 'useDynamicAssignment', - 'introHtml', - 'primaryColor', - 'logoImageUrl', - 'overrideOrganizationTextingHours', - 'textingHoursEnforced', - 'textingHoursStart', - 'textingHoursEnd', - 'timezone' - ], Campaign), - dueBy: (campaign) => ( - (campaign.due_by instanceof Date || !campaign.due_by) - ? campaign.due_by || null - : new Date(campaign.due_by) - ), - organization: async (campaign, _, { loaders }) => ( - campaign.organization - || loaders.organization.load(campaign.organization_id) - ), - datawarehouseAvailable: (campaign, _, { user }) => ( - user.is_superadmin && !!process.env.WAREHOUSE_DB_HOST + ...mapFieldsToModel( + [ + "id", + "title", + "description", + "isStarted", + "isArchived", + "useDynamicAssignment", + "introHtml", + "primaryColor", + "logoImageUrl", + "overrideOrganizationTextingHours", + "textingHoursEnforced", + "textingHoursStart", + "textingHoursEnd", + "timezone" + ], + Campaign ), + dueBy: campaign => + campaign.due_by instanceof Date || !campaign.due_by + ? campaign.due_by || null + : new Date(campaign.due_by), + organization: async (campaign, _, { loaders }) => + campaign.organization || + loaders.organization.load(campaign.organization_id), + datawarehouseAvailable: (campaign, _, { user }) => + user.is_superadmin && !!process.env.WAREHOUSE_DB_HOST, pendingJobs: async (campaign, _, { user }) => { - await accessRequired(user, campaign.organization_id, 'SUPERVOLUNTEER', true) - return r.table('job_request') - .filter({ campaign_id: campaign.id }).orderBy('updated_at', 'desc') + await accessRequired( + user, + campaign.organization_id, + "SUPERVOLUNTEER", + true + ); + return r + .table("job_request") + .filter({ campaign_id: campaign.id }) + .orderBy("updated_at", "desc"); }, texters: async (campaign, _, { user }) => { - await accessRequired(user, campaign.organization_id, 'SUPERVOLUNTEER', true) - return getUsers(campaign.organization_id, null, {campaignId: campaign.id }) + await accessRequired( + user, + campaign.organization_id, + "SUPERVOLUNTEER", + true + ); + return getUsers(campaign.organization_id, null, { + campaignId: campaign.id + }); }, assignments: async (campaign, { assignmentsFilter }, { user }) => { - await accessRequired(user, campaign.organization_id, 'SUPERVOLUNTEER', true) - let query = r.table('assignment') - .getAll(campaign.id, { index: 'campaign_id' }) + await accessRequired( + user, + campaign.organization_id, + "SUPERVOLUNTEER", + true + ); + let query = r + .table("assignment") + .getAll(campaign.id, { index: "campaign_id" }); - if (assignmentsFilter && assignmentsFilter.hasOwnProperty('texterId') && assignmentsFilter.textId !== null) { - query = query.filter({ user_id: assignmentsFilter.texterId }) + if ( + assignmentsFilter && + assignmentsFilter.hasOwnProperty("texterId") && + assignmentsFilter.textId !== null + ) { + query = query.filter({ user_id: assignmentsFilter.texterId }); } - return query + return query; }, interactionSteps: async (campaign, _, { user }) => { - await accessRequired(user, campaign.organization_id, 'TEXTER', true) - return campaign.interactionSteps - || cacheableData.campaign.dbInteractionSteps(campaign.id) + await accessRequired(user, campaign.organization_id, "TEXTER", true); + return ( + campaign.interactionSteps || + cacheableData.campaign.dbInteractionSteps(campaign.id) + ); }, cannedResponses: async (campaign, { userId }, { user }) => { - await accessRequired(user, campaign.organization_id, 'TEXTER', true) + await accessRequired(user, campaign.organization_id, "TEXTER", true); return await cacheableData.cannedResponse.query({ - userId: userId || '', + userId: userId || "", campaignId: campaign.id - }) + }); }, contacts: async (campaign, _, { user }) => { - await accessRequired(user, campaign.organization_id, 'ADMIN', true) + await accessRequired(user, campaign.organization_id, "ADMIN", true); // TODO: should we include a limit() since this is only for send-replies - return r.knex('campaign_contact') - .where({ campaign_id: campaign.id }) + return r.knex("campaign_contact").where({ campaign_id: campaign.id }); }, contactsCount: async (campaign, _, { user }) => { - await accessRequired(user, campaign.organization_id, 'SUPERVOLUNTEER', true) + await accessRequired( + user, + campaign.organization_id, + "SUPERVOLUNTEER", + true + ); return await r.getCount( - r.knex('campaign_contact') - .where({ campaign_id: campaign.id }) - ) + r.knex("campaign_contact").where({ campaign_id: campaign.id }) + ); }, hasUnassignedContactsForTexter: async (campaign, _, { user }) => { // This is the same as hasUnassignedContacts, but the access control // is different because for TEXTERs it's just for dynamic campaigns // but hasUnassignedContacts for admins is for the campaigns list - await accessRequired(user, campaign.organization_id, 'TEXTER', true) + await accessRequired(user, campaign.organization_id, "TEXTER", true); if (!campaign.use_dynamic_assignment || campaign.is_archived) { - return false + return false; } - const contacts = await r.knex('campaign_contact') - .select('id') + const contacts = await r + .knex("campaign_contact") + .select("id") .where({ campaign_id: campaign.id, assignment_id: null }) - .limit(1) - return contacts.length > 0 + .limit(1); + return contacts.length > 0; }, hasUnassignedContacts: async (campaign, _, { user }) => { - await accessRequired(user, campaign.organization_id, 'SUPERVOLUNTEER', true) - const contacts = await r.knex('campaign_contact') - .select('id') + await accessRequired( + user, + campaign.organization_id, + "SUPERVOLUNTEER", + true + ); + const contacts = await r + .knex("campaign_contact") + .select("id") .where({ campaign_id: campaign.id, assignment_id: null }) - .limit(1) - return contacts.length > 0 + .limit(1); + return contacts.length > 0; }, hasUnsentInitialMessages: async (campaign, _, { user }) => { - await accessRequired(user, campaign.organization_id, 'SUPERVOLUNTEER', true) - const contacts = await r.knex('campaign_contact') - .select('id') + await accessRequired( + user, + campaign.organization_id, + "SUPERVOLUNTEER", + true + ); + const contacts = await r + .knex("campaign_contact") + .select("id") .where({ campaign_id: campaign.id, - message_status: 'needsMessage', + message_status: "needsMessage", is_opted_out: false }) - .limit(1) - return contacts.length > 0 + .limit(1); + return contacts.length > 0; }, - customFields: async (campaign) => ( - campaign.customFields - || cacheableData.campaign.dbCustomFields(campaign.id) - ), - stats: async (campaign) => campaign, + customFields: async campaign => + campaign.customFields || + cacheableData.campaign.dbCustomFields(campaign.id), + stats: async campaign => campaign, cacheable: (campaign, _, { user }) => Boolean(r.redis), editors: async (campaign, _, { user }) => { - await accessRequired(user, campaign.organization_id, 'SUPERVOLUNTEER', true) + await accessRequired( + user, + campaign.organization_id, + "SUPERVOLUNTEER", + true + ); if (r.redis) { - return cacheableData.campaign.currentEditors(campaign, user) + return cacheableData.campaign.currentEditors(campaign, user); } - return '' + return ""; }, - creator: async (campaign, _, { loaders }) => ( - campaign.creator_id - ? loaders.user.load(campaign.creator_id) - : null - ) + creator: async (campaign, _, { loaders }) => + campaign.creator_id ? loaders.user.load(campaign.creator_id) : null } -} +}; diff --git a/src/server/api/canned-response.js b/src/server/api/canned-response.js index ac5c70434..55e659a30 100644 --- a/src/server/api/canned-response.js +++ b/src/server/api/canned-response.js @@ -1,15 +1,11 @@ -import { mapFieldsToModel } from './lib/utils' -import { CannedResponse } from '../models' +import { mapFieldsToModel } from "./lib/utils"; +import { CannedResponse } from "../models"; export const resolvers = { CannedResponse: { - ...mapFieldsToModel([ - 'id', - 'title', - 'text' - ], CannedResponse), - isUserCreated: (cannedResponse) => cannedResponse.user_id !== '' + ...mapFieldsToModel(["id", "title", "text"], CannedResponse), + isUserCreated: cannedResponse => cannedResponse.user_id !== "" } -} +}; -CannedResponse.ensureIndex('campaign_id') +CannedResponse.ensureIndex("campaign_id"); diff --git a/src/server/api/conversations.js b/src/server/api/conversations.js index 12cfdb8b2..bb13d0deb 100644 --- a/src/server/api/conversations.js +++ b/src/server/api/conversations.js @@ -1,8 +1,8 @@ -import _ from 'lodash' -import { Assignment, r } from '../models' -import { addWhereClauseForContactsFilterMessageStatusIrrespectiveOfPastDue } from './assignment' -import { buildCampaignQuery } from './campaign' -import { log } from '../../lib' +import _ from "lodash"; +import { Assignment, r } from "../models"; +import { addWhereClauseForContactsFilterMessageStatusIrrespectiveOfPastDue } from "./assignment"; +import { buildCampaignQuery } from "./campaign"; +import { log } from "../../lib"; function getConversationsJoinsAndWhereClause( queryParam, @@ -12,34 +12,36 @@ function getConversationsJoinsAndWhereClause( contactsFilter ) { let query = queryParam - .leftJoin('campaign_contact', 'campaign.id', 'campaign_contact.campaign_id') - .leftJoin('assignment', 'campaign_contact.assignment_id', 'assignment.id') - .leftJoin('user', 'assignment.user_id', 'user.id') - .where({ 'campaign.organization_id': organizationId }) + .leftJoin("campaign_contact", "campaign.id", "campaign_contact.campaign_id") + .leftJoin("assignment", "campaign_contact.assignment_id", "assignment.id") + .leftJoin("user", "assignment.user_id", "user.id") + .where({ "campaign.organization_id": organizationId }); - query = buildCampaignQuery(query, organizationId, campaignsFilter) + query = buildCampaignQuery(query, organizationId, campaignsFilter); if (assignmentsFilter) { - if ('texterId' in assignmentsFilter && assignmentsFilter.texterId !== null) - query = query.where({ 'assignment.user_id': assignmentsFilter.texterId }) + if ("texterId" in assignmentsFilter && assignmentsFilter.texterId !== null) + query = query.where({ "assignment.user_id": assignmentsFilter.texterId }); } query = addWhereClauseForContactsFilterMessageStatusIrrespectiveOfPastDue( query, - contactsFilter && contactsFilter.messageStatus) - - if (contactsFilter && 'isOptedOut' in contactsFilter) { - const subQuery = (r.knex.select('cell') - .from('opt_out') - .whereRaw('opt_out.cell=campaign_contact.cell')) + contactsFilter && contactsFilter.messageStatus + ); + + if (contactsFilter && "isOptedOut" in contactsFilter) { + const subQuery = r.knex + .select("cell") + .from("opt_out") + .whereRaw("opt_out.cell=campaign_contact.cell"); if (contactsFilter.isOptedOut) { - query = query.whereExists(subQuery) + query = query.whereExists(subQuery); } else { - query = query.whereNotExists(subQuery) + query = query.whereNotExists(subQuery); } } - return query + return query; } /* @@ -51,12 +53,12 @@ results can be consumed by downstream resolvers. */ function mapQueryFieldsToResolverFields(queryResult, fieldsMap) { return _.mapKeys(queryResult, (value, key) => { - const newKey = fieldsMap[key] + const newKey = fieldsMap[key]; if (newKey) { - return newKey + return newKey; } - return key - }) + return key; + }); } export async function getConversations( @@ -68,8 +70,8 @@ export async function getConversations( utc ) { /* Query #1 == get campaign_contact.id for all the conversations matching - * the criteria with offset and limit. */ - let offsetLimitQuery = r.knex.select('campaign_contact.id as cc_id') + * the criteria with offset and limit. */ + let offsetLimitQuery = r.knex.select("campaign_contact.id as cc_id"); offsetLimitQuery = getConversationsJoinsAndWhereClause( offsetLimitQuery, @@ -77,44 +79,44 @@ export async function getConversations( campaignsFilter, assignmentsFilter, contactsFilter - ) + ); offsetLimitQuery = offsetLimitQuery - .orderBy('campaign_contact.updated_at') - .orderBy('cc_id') - offsetLimitQuery = offsetLimitQuery.limit(cursor.limit).offset(cursor.offset) + .orderBy("campaign_contact.updated_at") + .orderBy("cc_id"); + offsetLimitQuery = offsetLimitQuery.limit(cursor.limit).offset(cursor.offset); - const ccIdRows = await offsetLimitQuery - const ccIds = ccIdRows.map((ccIdRow) => { - return ccIdRow.cc_id - }) + const ccIdRows = await offsetLimitQuery; + const ccIds = ccIdRows.map(ccIdRow => { + return ccIdRow.cc_id; + }); /* Query #2 -- get all the columns we need, including messages, using the - * cc_ids from Query #1 to scope the results to limit, offset */ + * cc_ids from Query #1 to scope the results to limit, offset */ let query = r.knex.select( - 'campaign_contact.id as cc_id', - 'campaign_contact.first_name as cc_first_name', - 'campaign_contact.last_name as cc_last_name', - 'campaign_contact.cell', - 'campaign_contact.message_status', - 'campaign_contact.is_opted_out', - 'campaign_contact.updated_at', - 'campaign_contact.assignment_id', - 'opt_out.cell as opt_out_cell', - 'user.id as u_id', - 'user.first_name as u_first_name', - 'user.last_name as u_last_name', - 'campaign.id as cmp_id', - 'campaign.title', - 'campaign.due_by', - 'assignment.id as ass_id', - 'message.id as mess_id', - 'message.text', - 'message.user_number', - 'message.contact_number', - 'message.created_at', - 'message.is_from_contact' - ) + "campaign_contact.id as cc_id", + "campaign_contact.first_name as cc_first_name", + "campaign_contact.last_name as cc_last_name", + "campaign_contact.cell", + "campaign_contact.message_status", + "campaign_contact.is_opted_out", + "campaign_contact.updated_at", + "campaign_contact.assignment_id", + "opt_out.cell as opt_out_cell", + "user.id as u_id", + "user.first_name as u_first_name", + "user.last_name as u_last_name", + "campaign.id as cmp_id", + "campaign.title", + "campaign.due_by", + "assignment.id as ass_id", + "message.id as mess_id", + "message.text", + "message.user_number", + "message.contact_number", + "message.created_at", + "message.is_from_contact" + ); query = getConversationsJoinsAndWhereClause( query, @@ -122,88 +124,90 @@ export async function getConversations( campaignsFilter, assignmentsFilter, contactsFilter - ) + ); - query = query.whereIn('campaign_contact.id', ccIds) + query = query.whereIn("campaign_contact.id", ccIds); - query = query.leftJoin('message', table => { + query = query.leftJoin("message", table => { table - .on('message.assignment_id', '=', 'assignment.id') - .andOn('message.contact_number', '=', 'campaign_contact.cell') - }) + .on("message.assignment_id", "=", "assignment.id") + .andOn("message.contact_number", "=", "campaign_contact.cell"); + }); query = query - .leftJoin('opt_out', table => { + .leftJoin("opt_out", table => { table - .on('opt_out.organization_id', '=', 'campaign.organization_id') - .andOn('campaign_contact.cell', 'opt_out.cell') + .on("opt_out.organization_id", "=", "campaign.organization_id") + .andOn("campaign_contact.cell", "opt_out.cell"); }) - .orderBy('campaign_contact.updated_at') - .orderBy('cc_id') - .orderBy('message.created_at') + .orderBy("campaign_contact.updated_at") + .orderBy("cc_id") + .orderBy("message.created_at"); - const conversationRows = await query + const conversationRows = await query; /* collapse the rows to produce an array of objects, with each object - * containing the fields for one conversation, each having an array of - * message objects */ + * containing the fields for one conversation, each having an array of + * message objects */ const messageFields = [ - 'mess_id', - 'text', - 'user_number', - 'contact_number', - 'created_at', - 'is_from_contact' - ] - - let ccId = undefined - let conversation = undefined - const conversations = [] + "mess_id", + "text", + "user_number", + "contact_number", + "created_at", + "is_from_contact" + ]; + + let ccId = undefined; + let conversation = undefined; + const conversations = []; for (const conversationRow of conversationRows) { if (ccId !== conversationRow.cc_id) { - ccId = conversationRow.cc_id - conversation = _.omit(conversationRow, messageFields) - conversation.messages = [] - conversations.push(conversation) + ccId = conversationRow.cc_id; + conversation = _.omit(conversationRow, messageFields); + conversation.messages = []; + conversations.push(conversation); } conversation.messages.push( - mapQueryFieldsToResolverFields(_.pick(conversationRow, messageFields), { mess_id: 'id' }) - ) + mapQueryFieldsToResolverFields(_.pick(conversationRow, messageFields), { + mess_id: "id" + }) + ); } /* Query #3 -- get the count of all conversations matching the criteria. - * We need this to show total number of conversations to support paging */ - const countQuery = r.knex.count('*') + * We need this to show total number of conversations to support paging */ + const countQuery = r.knex.count("*"); const conversationsCountArray = await getConversationsJoinsAndWhereClause( countQuery, organizationId, campaignsFilter, assignmentsFilter, contactsFilter - ) + ); const pageInfo = { limit: cursor.limit, offset: cursor.offset, total: conversationsCountArray[0].count - } + }; return { conversations, pageInfo - } + }; } export async function getCampaignIdMessageIdsAndCampaignIdContactIdsMaps( organizationId, campaignsFilter, assignmentsFilter, - contactsFilter, + contactsFilter ) { let query = r.knex.select( - 'campaign_contact.id as cc_id', - 'campaign.id as cmp_id', - 'message.id as mess_id', - ) + "campaign_contact.id as cc_id", + "campaign.id as cmp_id", + "message.id as mess_id" + ); query = getConversationsJoinsAndWhereClause( query, @@ -211,145 +215,149 @@ export async function getCampaignIdMessageIdsAndCampaignIdContactIdsMaps( campaignsFilter, assignmentsFilter, contactsFilter - ) + ); - query = query.leftJoin('message', table => { + query = query.leftJoin("message", table => { table - .on('message.assignment_id', '=', 'assignment.id') - .andOn('message.contact_number', '=', 'campaign_contact.cell') - }) + .on("message.assignment_id", "=", "assignment.id") + .andOn("message.contact_number", "=", "campaign_contact.cell"); + }); - query = query - .orderBy('cc_id') + query = query.orderBy("cc_id"); - const conversationRows = await query + const conversationRows = await query; - const campaignIdContactIdsMap = new Map() - const campaignIdMessagesIdsMap = new Map() + const campaignIdContactIdsMap = new Map(); + const campaignIdMessagesIdsMap = new Map(); - let ccId = undefined + let ccId = undefined; for (const conversationRow of conversationRows) { if (ccId !== conversationRow.cc_id) { - const ccId = conversationRow.cc_id - campaignIdContactIdsMap[conversationRow.cmp_id] = ccId + const ccId = conversationRow.cc_id; + campaignIdContactIdsMap[conversationRow.cmp_id] = ccId; if (!campaignIdContactIdsMap.has(conversationRow.cmp_id)) { - campaignIdContactIdsMap.set(conversationRow.cmp_id, []) + campaignIdContactIdsMap.set(conversationRow.cmp_id, []); } - campaignIdContactIdsMap.get(conversationRow.cmp_id).push(ccId) + campaignIdContactIdsMap.get(conversationRow.cmp_id).push(ccId); if (!campaignIdMessagesIdsMap.has(conversationRow.cmp_id)) { - campaignIdMessagesIdsMap.set(conversationRow.cmp_id, []) + campaignIdMessagesIdsMap.set(conversationRow.cmp_id, []); } } if (conversationRow.mess_id) { - campaignIdMessagesIdsMap.get(conversationRow.cmp_id).push(conversationRow.mess_id) - + campaignIdMessagesIdsMap + .get(conversationRow.cmp_id) + .push(conversationRow.mess_id); } } return { campaignIdContactIdsMap, campaignIdMessagesIdsMap - } + }; } -export async function reassignConversations(campaignIdContactIdsMap, campaignIdMessagesIdsMap, newTexterUserId) { +export async function reassignConversations( + campaignIdContactIdsMap, + campaignIdMessagesIdsMap, + newTexterUserId +) { // ensure existence of assignments - const campaignIdAssignmentIdMap = new Map() + const campaignIdAssignmentIdMap = new Map(); for (const [campaignId, _] of campaignIdContactIdsMap) { let assignment = await r - .table('assignment') - .getAll(newTexterUserId, { index: 'user_id' }) + .table("assignment") + .getAll(newTexterUserId, { index: "user_id" }) .filter({ campaign_id: campaignId }) .limit(1)(0) - .default(null) + .default(null); if (!assignment) { assignment = await Assignment.save({ user_id: newTexterUserId, campaign_id: campaignId, max_contacts: parseInt(process.env.MAX_CONTACTS_PER_TEXTER || 0, 10) - }) + }); } - campaignIdAssignmentIdMap.set(campaignId, assignment.id) + campaignIdAssignmentIdMap.set(campaignId, assignment.id); } // do the reassignment - const returnCampaignIdAssignmentIds = [] + const returnCampaignIdAssignmentIds = []; // TODO(larry) do this in a transaction! try { for (const [campaignId, campaignContactIds] of campaignIdContactIdsMap) { - const assignmentId = campaignIdAssignmentIdMap.get(campaignId) + const assignmentId = campaignIdAssignmentIdMap.get(campaignId); await r - .knex('campaign_contact') - .where('campaign_id', campaignId) - .whereIn('id', campaignContactIds) + .knex("campaign_contact") + .where("campaign_id", campaignId) + .whereIn("id", campaignContactIds) .update({ assignment_id: assignmentId - }) + }); returnCampaignIdAssignmentIds.push({ campaignId, assignmentId: assignmentId.toString() - }) + }); } for (const [campaignId, messageIds] of campaignIdMessagesIdsMap) { - const assignmentId = campaignIdAssignmentIdMap.get(campaignId) + const assignmentId = campaignIdAssignmentIdMap.get(campaignId); await r - .knex('message') + .knex("message") .whereIn( - 'id', + "id", messageIds.map(messageId => { - return messageId + return messageId; }) ) .update({ assignment_id: assignmentId - }) + }); } } catch (error) { - log.error(error) + log.error(error); } - return returnCampaignIdAssignmentIds + return returnCampaignIdAssignmentIds; } export const resolvers = { PaginatedConversations: { conversations: queryResult => { - return queryResult.conversations + return queryResult.conversations; }, pageInfo: queryResult => { - if ('pageInfo' in queryResult) { - return queryResult.pageInfo + if ("pageInfo" in queryResult) { + return queryResult.pageInfo; } else { - return null + return null; } } }, Conversation: { texter: queryResult => { return mapQueryFieldsToResolverFields(queryResult, { - u_id: 'id', - u_first_name: 'first_name', - u_last_name: 'last_name' - }) + u_id: "id", + u_first_name: "first_name", + u_last_name: "last_name" + }); }, contact: queryResult => { return mapQueryFieldsToResolverFields(queryResult, { - cc_id: 'id', - cc_first_name: 'first_name', - cc_last_name: 'last_name', - opt_out_cell: 'opt_out_cell' - }) + cc_id: "id", + cc_first_name: "first_name", + cc_last_name: "last_name", + opt_out_cell: "opt_out_cell" + }); }, campaign: queryResult => { - return mapQueryFieldsToResolverFields(queryResult, { cmp_id: 'id' }) + return mapQueryFieldsToResolverFields(queryResult, { cmp_id: "id" }); } } -} +}; diff --git a/src/server/api/errors.js b/src/server/api/errors.js index e01728525..56affbd81 100644 --- a/src/server/api/errors.js +++ b/src/server/api/errors.js @@ -1,57 +1,65 @@ -import { GraphQLError } from 'graphql/error' -import { r, cacheableData } from '../models' +import { GraphQLError } from "graphql/error"; +import { r, cacheableData } from "../models"; export function authRequired(user) { if (!user) { throw new GraphQLError({ status: 401, - message: 'You must login to access that resource.' - }) + message: "You must login to access that resource." + }); } } -export async function accessRequired(user, orgId, role, allowSuperadmin = false) { - authRequired(user) +export async function accessRequired( + user, + orgId, + role, + allowSuperadmin = false +) { + authRequired(user); if (!orgId) { - throw new Error('orgId not passed correctly to accessRequired') + throw new Error("orgId not passed correctly to accessRequired"); } if (allowSuperadmin && user.is_superadmin) { - return + return; } // require a permission at-or-higher than the permission requested - const hasRole = await cacheableData.user.userHasRole(user, orgId, role) + const hasRole = await cacheableData.user.userHasRole(user, orgId, role); if (!hasRole) { - throw new GraphQLError('You are not authorized to access that resource.') + throw new GraphQLError("You are not authorized to access that resource."); } } export async function assignmentRequired(user, assignmentId, assignment) { - authRequired(user) + authRequired(user); if (user.is_superadmin) { - return true + return true; } if (assignment && assignment.user_id === user.id) { // if we are passed the full assignment object, we can test directly - return true + return true; } - const [userHasAssignment] = await r.knex('assignment') - .where({ - user_id: user.id, - id: assignmentId - }).limit(1) + const [userHasAssignment] = await r + .knex("assignment") + .where({ + user_id: user.id, + id: assignmentId + }) + .limit(1); - if (!userHasAssignment) { // undefined or null - throw new GraphQLError('You are not authorized to access that resource.') + if (!userHasAssignment) { + // undefined or null + throw new GraphQLError("You are not authorized to access that resource."); } - return true + return true; } export function superAdminRequired(user) { - authRequired(user) + authRequired(user); if (!user.is_superadmin) { - throw new GraphQLError('You are not authorized to access that resource.') + throw new GraphQLError("You are not authorized to access that resource."); } } diff --git a/src/server/api/interaction-step.js b/src/server/api/interaction-step.js index 3f5dc473c..d3d73409f 100644 --- a/src/server/api/interaction-step.js +++ b/src/server/api/interaction-step.js @@ -1,28 +1,31 @@ -import { mapFieldsToModel } from './lib/utils' -import { InteractionStep, r } from '../models' +import { mapFieldsToModel } from "./lib/utils"; +import { InteractionStep, r } from "../models"; export const resolvers = { InteractionStep: { - ...mapFieldsToModel([ - 'id', - 'script', - 'answerOption', - 'answerActions', - 'parentInteractionId', - 'isDeleted' - ], InteractionStep), - questionText: async(interactionStep) => { - return interactionStep.question + ...mapFieldsToModel( + [ + "id", + "script", + "answerOption", + "answerActions", + "parentInteractionId", + "isDeleted" + ], + InteractionStep + ), + questionText: async interactionStep => { + return interactionStep.question; }, - question: async (interactionStep) => interactionStep, - questionResponse: async (interactionStep, { campaignContactId }) => ( - r.table('question_response') - .getAll(campaignContactId, { index: 'campaign_contact_id' }) + question: async interactionStep => interactionStep, + questionResponse: async (interactionStep, { campaignContactId }) => + r + .table("question_response") + .getAll(campaignContactId, { index: "campaign_contact_id" }) .filter({ interaction_step_id: interactionStep.id }) .limit(1)(0) .default(null) - ) } -} +}; diff --git a/src/server/api/invite.js b/src/server/api/invite.js index bc53461d4..4965266d5 100644 --- a/src/server/api/invite.js +++ b/src/server/api/invite.js @@ -1,12 +1,8 @@ -import { mapFieldsToModel } from './lib/utils' -import { Invite } from '../models' +import { mapFieldsToModel } from "./lib/utils"; +import { Invite } from "../models"; export const resolvers = { Invite: { - ...mapFieldsToModel([ - 'id', - 'isValid', - 'hash' - ], Invite) + ...mapFieldsToModel(["id", "isValid", "hash"], Invite) } -} +}; diff --git a/src/server/api/lib/fakeservice.js b/src/server/api/lib/fakeservice.js index 547d2e08d..ef54b96e2 100644 --- a/src/server/api/lib/fakeservice.js +++ b/src/server/api/lib/fakeservice.js @@ -1,6 +1,6 @@ -import { getLastMessage } from './message-sending' -import { Message, PendingMessagePart, r } from '../../models' -import { log } from '../../../lib' +import { getLastMessage } from "./message-sending"; +import { Message, PendingMessagePart, r } from "../../models"; +import { log } from "../../../lib"; // This 'fakeservice' allows for fake-sending messages // that end up just in the db appropriately and then using sendReply() graphql @@ -9,24 +9,22 @@ import { log } from '../../../lib' async function sendMessage(message, contact, trx) { const newMessage = new Message({ ...message, - service: 'fakeservice', - send_status: 'SENT', - sent_at: new Date(), - }) + service: "fakeservice", + send_status: "SENT", + sent_at: new Date() + }); if (message && message.id) { - let request = r.knex('message') + let request = r.knex("message"); if (trx) { - request = request.transacting(trx) + request = request.transacting(trx); } // updating message! - await request - .where('id', message.id) - .update({ - service: 'fakeservice', - send_status: 'SENT', - sent_at: new Date() - }) + await request.where("id", message.id).update({ + service: "fakeservice", + send_status: "SENT", + sent_at: new Date() + }); } if (contact && /autorespond/.test(message.text)) { @@ -38,29 +36,32 @@ async function sendMessage(message, contact, trx) { service_id: `mockedresponse${Math.random()}`, is_from_contact: true, text: `responding to ${message.text}`, - send_status: 'DELIVERED' - }) - contact.message_status = 'needsResponse' - await contact.save() + send_status: "DELIVERED" + }); + contact.message_status = "needsResponse"; + await contact.save(); } - return newMessage + return newMessage; } // None of the rest of this is even used for fake-service // but *would* be used if it was actually an outside service. async function convertMessagePartsToMessage(messageParts) { - const firstPart = messageParts[0] - const userNumber = firstPart.user_number - const contactNumber = firstPart.contact_number - const text = firstPart.service_message + const firstPart = messageParts[0]; + const userNumber = firstPart.user_number; + const contactNumber = firstPart.contact_number; + const text = firstPart.service_message; const lastMessage = await getLastMessage({ contactNumber - }) + }); - const service_id = (firstPart.service_id - || `fakeservice_${Math.random().toString(36).replace(/[^a-zA-Z1-9]+/g, '')}`) + const service_id = + firstPart.service_id || + `fakeservice_${Math.random() + .toString(36) + .replace(/[^a-zA-Z1-9]+/g, "")}`; return new Message({ contact_number: contactNumber, user_number: userNumber, @@ -69,24 +70,24 @@ async function convertMessagePartsToMessage(messageParts) { service_response: JSON.stringify(messageParts), service_id, assignment_id: lastMessage.assignment_id, - service: 'fakeservice', - send_status: 'DELIVERED' - }) + service: "fakeservice", + send_status: "DELIVERED" + }); } async function handleIncomingMessage(message) { - const { contact_number, user_number, service_id, text } = message + const { contact_number, user_number, service_id, text } = message; const pendingMessagePart = new PendingMessagePart({ - service: 'fakeservice', + service: "fakeservice", service_id, parent_id: null, service_message: text, user_number, contact_number - }) + }); - const part = await pendingMessagePart.save() - return part.id + const part = await pendingMessagePart.save(); + return part.id; } export default { @@ -94,4 +95,4 @@ export default { // useless unused stubs convertMessagePartsToMessage, handleIncomingMessage -} +}; diff --git a/src/server/api/lib/import-script..js b/src/server/api/lib/import-script..js index 9c582b8c1..bc64ac9e2 100644 --- a/src/server/api/lib/import-script..js +++ b/src/server/api/lib/import-script..js @@ -1,296 +1,382 @@ -import { - google -} from 'googleapis' - -import _ from 'lodash' -import { - compose, - map, - reduce, - getOr, - find, - filter, - has -} from 'lodash/fp' - -import { - r -} from '../../models' - -const textRegex = RegExp('.*[A-Za-z0-9]+.*') - -const getDocument = async (documentId) => { - const auth = google.auth.fromJSON(JSON.parse(process.env.GOOGLE_SECRET)) - auth.scopes = ['https://www.googleapis.com/auth/documents'] +import { google } from "googleapis"; + +import _ from "lodash"; +import { compose, map, reduce, getOr, find, filter, has } from "lodash/fp"; + +import { r } from "../../models"; + +const textRegex = RegExp(".*[A-Za-z0-9]+.*"); + +const getDocument = async documentId => { + const auth = google.auth.fromJSON(JSON.parse(process.env.GOOGLE_SECRET)); + auth.scopes = ["https://www.googleapis.com/auth/documents"]; const docs = google.docs({ - version: 'v1', + version: "v1", auth - }) + }); - let result = null + let result = null; try { result = await docs.documents.get({ documentId - }) + }); } catch (err) { - console.log(err) - throw new Error(err.message) + console.log(err); + throw new Error(err.message); } - return result -} + return result; +}; -const getParagraphStyle = getOr('', 'paragraph.paragraphStyle.namedStyleType') -const getTextRun = getOr('', 'textRun.content') -const sanitizeTextRun = (textRun) => textRun.replace('\n', '') +const getParagraphStyle = getOr("", "paragraph.paragraphStyle.namedStyleType"); +const getTextRun = getOr("", "textRun.content"); +const sanitizeTextRun = textRun => textRun.replace("\n", ""); const getSanitizedTextRun = compose( sanitizeTextRun, - getTextRun) -const concat = (left, right) => left.concat(right) -const reduceStrings = reduce(concat, String()) + getTextRun +); +const concat = (left, right) => left.concat(right); +const reduceStrings = reduce(concat, String()); const getParagraphText = compose( reduceStrings, map(getSanitizedTextRun), - getOr([], 'paragraph.elements')) -const getParagraphIndent = getOr(0, 'paragraph.paragraphStyle.indentFirstLine.magnitude') + getOr([], "paragraph.elements") +); +const getParagraphIndent = getOr( + 0, + "paragraph.paragraphStyle.indentFirstLine.magnitude" +); const getParagraphBold = compose( - getOr(false, 'textRun.textStyle.bold'), + getOr(false, "textRun.textStyle.bold"), find(getTextRun), - getOr([], 'paragraph.elements')) -const getParagraph = (element) => ({ + getOr([], "paragraph.elements") +); +const getParagraph = element => ({ style: getParagraphStyle(element), indent: getParagraphIndent(element), isParagraphBold: getParagraphBold(element), text: getParagraphText(element) -}) -const hasParagraph = has('paragraph') -const hasText = (paragraph) => !!paragraph.text && textRegex.test(paragraph.text.trim()) -const pushAndReturnSection = (sections) => { +}); +const hasParagraph = has("paragraph"); +const hasText = paragraph => + !!paragraph.text && textRegex.test(paragraph.text.trim()); +const pushAndReturnSection = sections => { const newSection = { paragraphs: [] - } - sections.push(newSection) - return newSection -} + }; + sections.push(newSection); + return newSection; +}; -const getLastSection = (sections) => _.last(sections) || pushAndReturnSection(sections) +const getLastSection = sections => + _.last(sections) || pushAndReturnSection(sections); const addParagraph = (accumulatorInput, value) => { - const accumulator = accumulatorInput || [] - getLastSection(accumulator).paragraphs.push(value) - return accumulator -} + const accumulator = accumulatorInput || []; + getLastSection(accumulator).paragraphs.push(value); + return accumulator; +}; -const sanitizeHeaderText = (header) => header.replace(/[^A-Za-z0-9 ]/g, '') +const sanitizeHeaderText = header => header.replace(/[^A-Za-z0-9 ]/g, ""); const addHeader = (accumulatorInput, value) => { - const accumulator = accumulatorInput || [] - accumulator.push(_.assign(_.clone(value), { - text: sanitizeHeaderText(value.text), - paragraphs: [] - })) - return accumulator -} + const accumulator = accumulatorInput || []; + accumulator.push( + _.assign(_.clone(value), { + text: sanitizeHeaderText(value.text), + paragraphs: [] + }) + ); + return accumulator; +}; -const addSection = (accumulator, value) => (value.style === 'HEADING_2' ? addHeader(accumulator, value) : addParagraph(accumulator, value)) +const addSection = (accumulator, value) => + value.style === "HEADING_2" + ? addHeader(accumulator, value) + : addParagraph(accumulator, value); const getSections = compose( reduce(addSection, null), filter(hasText), map(getParagraph), filter(hasParagraph) -) - -const getSectionParagraphs = (sections, heading) => (sections.find((section) => section.text && section.text.toLowerCase() === heading.toLowerCase()) || {}).paragraphs - -const getInteractions = (sections) => getSectionParagraphs(sections, 'Interactions') -const getCannedResponses = (sections) => getSectionParagraphs(sections, 'Canned Responses') - -const isNextChunkAQuestion = (interactionParagraphs, currentIndent) => interactionParagraphs.length > 1 && +); + +const getSectionParagraphs = (sections, heading) => + ( + sections.find( + section => + section.text && section.text.toLowerCase() === heading.toLowerCase() + ) || {} + ).paragraphs; + +const getInteractions = sections => + getSectionParagraphs(sections, "Interactions"); +const getCannedResponses = sections => + getSectionParagraphs(sections, "Canned Responses"); + +const isNextChunkAQuestion = (interactionParagraphs, currentIndent) => + interactionParagraphs.length > 1 && interactionParagraphs[0].indent === currentIndent && interactionParagraphs[1].indent > currentIndent && - interactionParagraphs[1].isParagraphBold + interactionParagraphs[1].isParagraphBold; -const isNextChunkAnAnswer = (interactionParagraphs, currentIndent) => interactionParagraphs.length > 1 && +const isNextChunkAnAnswer = (interactionParagraphs, currentIndent) => + interactionParagraphs.length > 1 && interactionParagraphs[0].indent === currentIndent && interactionParagraphs[1].indent === currentIndent && - !interactionParagraphs[1].isParagraphBold + !interactionParagraphs[1].isParagraphBold; -const isError = (interactionParagraphs, currentIndent) => interactionParagraphs.length > 1 && +const isError = (interactionParagraphs, currentIndent) => + interactionParagraphs.length > 1 && interactionParagraphs[0].indent === currentIndent && - ((interactionParagraphs[1].indent === currentIndent && interactionParagraphs[1].isParagraphBold) || - (interactionParagraphs[1].indent > currentIndent && !interactionParagraphs[1].isParagraphBold)) - -const isThisALeaf = (interactionParagraphs, currentIndent) => interactionParagraphs.length < 2 || - interactionParagraphs[1].indent < currentIndent - -const saveCurrentNodeToParent = (parentHierarchyNode, currentHierarchyNode) => parentHierarchyNode && parentHierarchyNode.children.push(currentHierarchyNode) - -const throwInteractionsHierarchyError = (message, interactionsHierarchyNode, interactionParagraphs) => { + ((interactionParagraphs[1].indent === currentIndent && + interactionParagraphs[1].isParagraphBold) || + (interactionParagraphs[1].indent > currentIndent && + !interactionParagraphs[1].isParagraphBold)); + +const isThisALeaf = (interactionParagraphs, currentIndent) => + interactionParagraphs.length < 2 || + interactionParagraphs[1].indent < currentIndent; + +const saveCurrentNodeToParent = (parentHierarchyNode, currentHierarchyNode) => + parentHierarchyNode && + parentHierarchyNode.children.push(currentHierarchyNode); + +const throwInteractionsHierarchyError = ( + message, + interactionsHierarchyNode, + interactionParagraphs +) => { const lookFor = [ - ...(interactionsHierarchyNode.answer ? [interactionsHierarchyNode.answer] : []), - ...(interactionsHierarchyNode.script ? interactionsHierarchyNode.script : []), + ...(interactionsHierarchyNode.answer + ? [interactionsHierarchyNode.answer] + : []), + ...(interactionsHierarchyNode.script + ? interactionsHierarchyNode.script + : []), ...(interactionParagraphs[0] ? [interactionParagraphs[0].text] : []), ...(interactionParagraphs[1] ? [interactionParagraphs[1].text] : []) - ].join(' | ') - throw new Error(`${message} Look for ${lookFor}`) -} - -const makeInteractionHierarchy = (interactionParagraphs, parentHierarchyNode) => { - if (!interactionParagraphs.length || interactionParagraphs[0].indent < (parentHierarchyNode ? parentHierarchyNode.indent : 0)) { - return parentHierarchyNode + ].join(" | "); + throw new Error(`${message} Look for ${lookFor}`); +}; + +const makeInteractionHierarchy = ( + interactionParagraphs, + parentHierarchyNode +) => { + if ( + !interactionParagraphs.length || + interactionParagraphs[0].indent < + (parentHierarchyNode ? parentHierarchyNode.indent : 0) + ) { + return parentHierarchyNode; } - const currentIndent = interactionParagraphs[0].indent + const currentIndent = interactionParagraphs[0].indent; - let interactionsHierarchyNode = undefined + let interactionsHierarchyNode = undefined; - while (interactionParagraphs[0] && interactionParagraphs[0].indent === currentIndent) { + while ( + interactionParagraphs[0] && + interactionParagraphs[0].indent === currentIndent + ) { interactionsHierarchyNode = { children: [] - } + }; - interactionsHierarchyNode.answer = interactionParagraphs.shift().text - interactionsHierarchyNode.script = [] + interactionsHierarchyNode.answer = interactionParagraphs.shift().text; + interactionsHierarchyNode.script = []; - while (interactionParagraphs.length && !interactionParagraphs[0].isParagraphBold) { - const interactionParagraph = interactionParagraphs.shift() - interactionsHierarchyNode.script.push(interactionParagraph.text) + while ( + interactionParagraphs.length && + !interactionParagraphs[0].isParagraphBold + ) { + const interactionParagraph = interactionParagraphs.shift(); + interactionsHierarchyNode.script.push(interactionParagraph.text); } if (!interactionsHierarchyNode.script[0]) { - throwInteractionsHierarchyError('Interactions format error -- no script.', interactionsHierarchyNode, interactionParagraphs) + throwInteractionsHierarchyError( + "Interactions format error -- no script.", + interactionsHierarchyNode, + interactionParagraphs + ); } if (isNextChunkAQuestion(interactionParagraphs, currentIndent)) { - interactionsHierarchyNode.question = interactionParagraphs.shift().text - saveCurrentNodeToParent(parentHierarchyNode, interactionsHierarchyNode) - makeInteractionHierarchy(interactionParagraphs, interactionsHierarchyNode) + interactionsHierarchyNode.question = interactionParagraphs.shift().text; + saveCurrentNodeToParent(parentHierarchyNode, interactionsHierarchyNode); + makeInteractionHierarchy( + interactionParagraphs, + interactionsHierarchyNode + ); } else if (isNextChunkAnAnswer(interactionParagraphs, currentIndent)) { - saveCurrentNodeToParent(parentHierarchyNode, interactionsHierarchyNode) - makeInteractionHierarchy(interactionParagraphs, parentHierarchyNode) + saveCurrentNodeToParent(parentHierarchyNode, interactionsHierarchyNode); + makeInteractionHierarchy(interactionParagraphs, parentHierarchyNode); } else if (isThisALeaf(interactionParagraphs, currentIndent)) { - saveCurrentNodeToParent(parentHierarchyNode, interactionsHierarchyNode) + saveCurrentNodeToParent(parentHierarchyNode, interactionsHierarchyNode); } else if (isError(interactionParagraphs, currentIndent)) { - throwInteractionsHierarchyError('Interactions format error.', interactionsHierarchyNode, interactionParagraphs) + throwInteractionsHierarchyError( + "Interactions format error.", + interactionsHierarchyNode, + interactionParagraphs + ); } else { - throwInteractionsHierarchyError('Interactions unexpected format.', interactionsHierarchyNode, interactionParagraphs) + throwInteractionsHierarchyError( + "Interactions unexpected format.", + interactionsHierarchyNode, + interactionParagraphs + ); } } - return interactionsHierarchyNode -} - -const saveInteractionsHierarchyNode = async (trx, campaignId, interactionsHierarchyNode, parentHierarchyNodeId) => { - const nodeId = await r.knex.insert({ - parent_interaction_id: parentHierarchyNodeId, - question: interactionsHierarchyNode.question || '', - script: interactionsHierarchyNode.script.join('\n') || '', - answer_option: interactionsHierarchyNode.answer || '', - answer_actions: '', - campaign_id: campaignId, - is_deleted: false - }) - .into('interaction_step') + return interactionsHierarchyNode; +}; + +const saveInteractionsHierarchyNode = async ( + trx, + campaignId, + interactionsHierarchyNode, + parentHierarchyNodeId +) => { + const nodeId = await r.knex + .insert({ + parent_interaction_id: parentHierarchyNodeId, + question: interactionsHierarchyNode.question || "", + script: interactionsHierarchyNode.script.join("\n") || "", + answer_option: interactionsHierarchyNode.answer || "", + answer_actions: "", + campaign_id: campaignId, + is_deleted: false + }) + .into("interaction_step") .transacting(trx) - .returning('id') + .returning("id"); for (const child of interactionsHierarchyNode.children) { - await saveInteractionsHierarchyNode(trx, campaignId, child, nodeId[0]) + await saveInteractionsHierarchyNode(trx, campaignId, child, nodeId[0]); } -} +}; -const replaceInteractionsInDatabase = async (campaignId, interactionsHierarchy) => { +const replaceInteractionsInDatabase = async ( + campaignId, + interactionsHierarchy +) => { await r.knex.transaction(async trx => { try { - await r.knex('interaction_step') + await r + .knex("interaction_step") .transacting(trx) .where({ campaign_id: campaignId }) - .delete() - - await saveInteractionsHierarchyNode(trx, campaignId, interactionsHierarchy, null) + .delete(); + + await saveInteractionsHierarchyNode( + trx, + campaignId, + interactionsHierarchy, + null + ); } catch (exception) { - console.log(exception) - throw exception + console.log(exception); + throw exception; } - }) -} + }); +}; -const makeCannedResponsesList = (cannedResponsesParagraphs) => { - const cannedResponses = [] +const makeCannedResponsesList = cannedResponsesParagraphs => { + const cannedResponses = []; while (cannedResponsesParagraphs[0]) { const cannedResponse = { text: [] - } + }; - const paragraph = cannedResponsesParagraphs.shift() + const paragraph = cannedResponsesParagraphs.shift(); if (!paragraph.isParagraphBold) { - throw new Error(`Canned responses format error -- can't find a bold paragraph. Look for [${paragraph.text}]`) + throw new Error( + `Canned responses format error -- can't find a bold paragraph. Look for [${paragraph.text}]` + ); } - cannedResponse.title = paragraph.text - - while (cannedResponsesParagraphs[0] && !cannedResponsesParagraphs[0].isParagraphBold) { - const textParagraph = cannedResponsesParagraphs.shift() - cannedResponse.text.push(textParagraph.text) + cannedResponse.title = paragraph.text; + + while ( + cannedResponsesParagraphs[0] && + !cannedResponsesParagraphs[0].isParagraphBold + ) { + const textParagraph = cannedResponsesParagraphs.shift(); + cannedResponse.text.push(textParagraph.text); } if (!cannedResponse.text[0]) { - throw new Error(`Canned responses format error -- canned response has no text. Look for [${cannedResponse.title}]`) + throw new Error( + `Canned responses format error -- canned response has no text. Look for [${cannedResponse.title}]` + ); } - cannedResponses.push(cannedResponse) + cannedResponses.push(cannedResponse); } - return cannedResponses -} + return cannedResponses; +}; -const replaceCannedResponsesInDatabase = async (campaignId, cannedResponses) => { +const replaceCannedResponsesInDatabase = async ( + campaignId, + cannedResponses +) => { await r.knex.transaction(async trx => { try { - await r.knex('canned_response') + await r + .knex("canned_response") .transacting(trx) .where({ campaign_id: campaignId }) - .whereNull('user_id') - .delete() + .whereNull("user_id") + .delete(); for (const cannedResponse of cannedResponses) { - await r.knex.insert({ - campaign_id: campaignId, - user_id: null, - title: cannedResponse.title, - text: cannedResponse.text.join('\n') - }) - .into('canned_response') - .transacting(trx) + await r.knex + .insert({ + campaign_id: campaignId, + user_id: null, + title: cannedResponse.title, + text: cannedResponse.text.join("\n") + }) + .into("canned_response") + .transacting(trx); } } catch (exception) { - console.log(exception) - throw exception + console.log(exception); + throw exception; } - }) -} + }); +}; const importScriptFromDocument = async (campaignId, scriptUrl) => { - const match = scriptUrl.match(/document\/d\/(.*)\//) + const match = scriptUrl.match(/document\/d\/(.*)\//); if (!match || !match[1]) { - throw new Error(`Invalid URL. This doesn't seem like a Google Docs URL.`) + throw new Error(`Invalid URL. This doesn't seem like a Google Docs URL.`); } - const documentId = match[1] - const result = await getDocument(documentId) - - const document = result.data.body.content - const sections = getSections(document) - - const interactionParagraphs = getInteractions(sections) - const interactionsHierarchy = makeInteractionHierarchy(_.clone(interactionParagraphs), null, 0) - await replaceInteractionsInDatabase(campaignId, interactionsHierarchy) - - const cannedResponsesParagraphs = getCannedResponses(sections) - const cannedResponsesList = makeCannedResponsesList(_.clone(cannedResponsesParagraphs)) - await replaceCannedResponsesInDatabase(campaignId, cannedResponsesList) -} - -export default importScriptFromDocument + const documentId = match[1]; + const result = await getDocument(documentId); + + const document = result.data.body.content; + const sections = getSections(document); + + const interactionParagraphs = getInteractions(sections); + const interactionsHierarchy = makeInteractionHierarchy( + _.clone(interactionParagraphs), + null, + 0 + ); + await replaceInteractionsInDatabase(campaignId, interactionsHierarchy); + + const cannedResponsesParagraphs = getCannedResponses(sections); + const cannedResponsesList = makeCannedResponsesList( + _.clone(cannedResponsesParagraphs) + ); + await replaceCannedResponsesInDatabase(campaignId, cannedResponsesList); +}; + +export default importScriptFromDocument; diff --git a/src/server/api/lib/message-sending.js b/src/server/api/lib/message-sending.js index 673aa56d8..5b6efb403 100644 --- a/src/server/api/lib/message-sending.js +++ b/src/server/api/lib/message-sending.js @@ -1,32 +1,40 @@ -import { r } from '../../models' +import { r } from "../../models"; export async function getLastMessage({ contactNumber, service }) { - const lastMessage = await r.table('message') - .getAll(contactNumber, { index: 'contact_number' }) + const lastMessage = await r + .table("message") + .getAll(contactNumber, { index: "contact_number" }) .filter({ is_from_contact: false, service }) - .orderBy(r.desc('created_at')) + .orderBy(r.desc("created_at")) .limit(1) - .pluck('assignment_id')(0) - .default(null) + .pluck("assignment_id")(0) + .default(null); - return lastMessage + return lastMessage; } export async function saveNewIncomingMessage(messageInstance) { if (messageInstance.service_id) { - const countResult = await r.getCount(r.knex('message').where('service_id', messageInstance.service_id)) + const countResult = await r.getCount( + r.knex("message").where("service_id", messageInstance.service_id) + ); if (countResult) { - console.error('DUPLICATE MESSAGE SAVED', countResult.count, messageInstance) + console.error( + "DUPLICATE MESSAGE SAVED", + countResult.count, + messageInstance + ); } } - await messageInstance.save() + await messageInstance.save(); - await r.table('campaign_contact') - .getAll(messageInstance.assignment_id, { index: 'assignment_id' }) + await r + .table("campaign_contact") + .getAll(messageInstance.assignment_id, { index: "assignment_id" }) .filter({ cell: messageInstance.contact_number }) .limit(1) - .update({ message_status: 'needsResponse', updated_at: 'now()' }) + .update({ message_status: "needsResponse", updated_at: "now()" }); } diff --git a/src/server/api/lib/nexmo.js b/src/server/api/lib/nexmo.js index 7bf613813..64221db9a 100644 --- a/src/server/api/lib/nexmo.js +++ b/src/server/api/lib/nexmo.js @@ -1,32 +1,34 @@ -import Nexmo from 'nexmo' -import { getFormattedPhoneNumber } from '../../../lib/phone-format' -import { Message, PendingMessagePart } from '../../models' -import { getLastMessage } from './message-sending' -import { log } from '../../../lib' -import faker from 'faker' - -let nexmo = null -const MAX_SEND_ATTEMPTS = 5 +import Nexmo from "nexmo"; +import { getFormattedPhoneNumber } from "../../../lib/phone-format"; +import { Message, PendingMessagePart } from "../../models"; +import { getLastMessage } from "./message-sending"; +import { log } from "../../../lib"; +import faker from "faker"; + +let nexmo = null; +const MAX_SEND_ATTEMPTS = 5; if (process.env.NEXMO_API_KEY && process.env.NEXMO_API_SECRET) { nexmo = new Nexmo({ apiKey: process.env.NEXMO_API_KEY, apiSecret: process.env.NEXMO_API_SECRET - }) + }); } async function convertMessagePartsToMessage(messageParts) { - const firstPart = messageParts[0] - const userNumber = firstPart.user_number - const contactNumber = firstPart.contact_number - const serviceMessages = messageParts.map((part) => JSON.parse(part.service_message)) + const firstPart = messageParts[0]; + const userNumber = firstPart.user_number; + const contactNumber = firstPart.contact_number; + const serviceMessages = messageParts.map(part => + JSON.parse(part.service_message) + ); const text = serviceMessages - .map((serviceMessage) => serviceMessage.text) - .join('') + .map(serviceMessage => serviceMessage.text) + .join(""); const lastMessage = await getLastMessage({ contactNumber, - service: 'nexmo' - }) + service: "nexmo" + }); return new Message({ contact_number: contactNumber, @@ -36,165 +38,195 @@ async function convertMessagePartsToMessage(messageParts) { service_response: JSON.stringify(serviceMessages), service_id: serviceMessages[0].service_id, assignment_id: lastMessage.assignment_id, - service: 'nexmo', - send_status: 'DELIVERED' - }) + service: "nexmo", + send_status: "DELIVERED" + }); } async function findNewCell() { if (!nexmo) { - return { numbers: [{ msisdn: getFormattedPhoneNumber(faker.phone.phoneNumber()) }] } + return { + numbers: [{ msisdn: getFormattedPhoneNumber(faker.phone.phoneNumber()) }] + }; } return new Promise((resolve, reject) => { - nexmo.number.search('US', { features: 'VOICE,SMS', size: 1 }, (err, response) => { - if (err) { - reject(err) - } else { - resolve(response) + nexmo.number.search( + "US", + { features: "VOICE,SMS", size: 1 }, + (err, response) => { + if (err) { + reject(err); + } else { + resolve(response); + } } - }) - }) + ); + }); } async function rentNewCell() { if (!nexmo) { - return getFormattedPhoneNumber(faker.phone.phoneNumber()) + return getFormattedPhoneNumber(faker.phone.phoneNumber()); } - const newCell = await findNewCell() - - if (newCell && newCell.numbers && newCell.numbers[0] && newCell.numbers[0].msisdn) { + const newCell = await findNewCell(); + + if ( + newCell && + newCell.numbers && + newCell.numbers[0] && + newCell.numbers[0].msisdn + ) { return new Promise((resolve, reject) => { - nexmo.number.buy('US', newCell.numbers[0].msisdn, (err, response) => { + nexmo.number.buy("US", newCell.numbers[0].msisdn, (err, response) => { if (err) { - reject(err) + reject(err); } else { // It appears we need to check error-code in the response even if response is returned. // This library returns responses that look like { error-code: 401, error-label: 'not authenticated'} // or the bizarrely-named { error-code: 200 } even in the case of success - if (response['error-code'] !== '200') { - reject(new Error(response['error-code-label'])) + if (response["error-code"] !== "200") { + reject(new Error(response["error-code-label"])); } else { - resolve(newCell.numbers[0].msisdn) + resolve(newCell.numbers[0].msisdn); } } - }) - }) + }); + }); } - throw new Error('Did not find any cell') + throw new Error("Did not find any cell"); } async function sendMessage(message, contact, trx) { if (!nexmo) { - const options = trx ? { transaction: trx } : {} - await Message.get(message.id) - .update({ send_status: 'SENT' }, options) - return 'test_message_uuid' + const options = trx ? { transaction: trx } : {}; + await Message.get(message.id).update({ send_status: "SENT" }, options); + return "test_message_uuid"; } return new Promise((resolve, reject) => { // US numbers require that the + be removed when sending via nexmo - nexmo.message.sendSms(message.user_number.replace(/^\+/, ''), + nexmo.message.sendSms( + message.user_number.replace(/^\+/, ""), message.contact_number, - message.text, { - 'status-report-req': 1, - 'client-ref': message.id - }, (err, response) => { + message.text, + { + "status-report-req": 1, + "client-ref": message.id + }, + (err, response) => { const messageToSave = { ...message - } - let hasError = false + }; + let hasError = false; if (err) { - hasError = true + hasError = true; } if (response) { - response.messages.forEach((serviceMessages) => { - if (serviceMessages.status !== '0') { - hasError = true + response.messages.forEach(serviceMessages => { + if (serviceMessages.status !== "0") { + hasError = true; } - }) - messageToSave.service_response += JSON.stringify(response) + }); + messageToSave.service_response += JSON.stringify(response); } - messageToSave.service = 'nexmo' + messageToSave.service = "nexmo"; if (hasError) { if (messageToSave.service_messages.length >= MAX_SEND_ATTEMPTS) { - messageToSave.send_status = 'ERROR' + messageToSave.send_status = "ERROR"; } - let options = { conflict: 'update' } + let options = { conflict: "update" }; if (trx) { - options.transaction = trx + options.transaction = trx; } Message.save(messageToSave, options) - // eslint-disable-next-line no-unused-vars - .then((_, newMessage) => { - reject(err || (response ? new Error(JSON.stringify(response)) : new Error('Encountered unknown error'))) - }) + // eslint-disable-next-line no-unused-vars + .then((_, newMessage) => { + reject( + err || + (response + ? new Error(JSON.stringify(response)) + : new Error("Encountered unknown error")) + ); + }); } else { - let options = { conflict: 'update' } + let options = { conflict: "update" }; if (trx) { - options.transaction = trx + options.transaction = trx; } - Message.save({ - ...messageToSave, - send_status: 'SENT' - }, options) - .then((saveError, newMessage) => { - resolve(newMessage) - }) + Message.save( + { + ...messageToSave, + send_status: "SENT" + }, + options + ).then((saveError, newMessage) => { + resolve(newMessage); + }); } } - ) - }) + ); + }); } async function handleDeliveryReport(report) { - if (report.hasOwnProperty('client-ref')) { - const message = await Message.get(report['client-ref']) - message.service_response += JSON.stringify(report) - if (report.status === 'delivered' || report.status === 'accepted') { - message.send_status = 'DELIVERED' - } else if (report.status === 'expired' || - report.status === 'failed' || - report.status === 'rejected') { - message.send_status = 'ERROR' + if (report.hasOwnProperty("client-ref")) { + const message = await Message.get(report["client-ref"]); + message.service_response += JSON.stringify(report); + if (report.status === "delivered" || report.status === "accepted") { + message.send_status = "DELIVERED"; + } else if ( + report.status === "expired" || + report.status === "failed" || + report.status === "rejected" + ) { + message.send_status = "ERROR"; } - Message.save(message, { conflict: 'update' }) + Message.save(message, { conflict: "update" }); } } async function handleIncomingMessage(message) { - if (!message.hasOwnProperty('to') || - !message.hasOwnProperty('msisdn') || - !message.hasOwnProperty('text') || - !message.hasOwnProperty('messageId')) { - log.error(`This is not an incoming message: ${JSON.stringify(message)}`) + if ( + !message.hasOwnProperty("to") || + !message.hasOwnProperty("msisdn") || + !message.hasOwnProperty("text") || + !message.hasOwnProperty("messageId") + ) { + log.error(`This is not an incoming message: ${JSON.stringify(message)}`); } - const { to, msisdn, concat } = message - const isConcat = concat === 'true' - const contactNumber = getFormattedPhoneNumber(msisdn) - const userNumber = getFormattedPhoneNumber(to) + const { to, msisdn, concat } = message; + const isConcat = concat === "true"; + const contactNumber = getFormattedPhoneNumber(msisdn); + const userNumber = getFormattedPhoneNumber(to); - let parentId = '' + let parentId = ""; if (isConcat) { - log.info(`Incoming message part (${message['concat-part']} of ${message['concat-total']} for ref ${message['concat-ref']}) from ${contactNumber} to ${userNumber}`) - parentId = message['concat-ref'] + log.info( + `Incoming message part (${message["concat-part"]} of ${ + message["concat-total"] + } for ref ${ + message["concat-ref"] + }) from ${contactNumber} to ${userNumber}` + ); + parentId = message["concat-ref"]; } else { - log.info(`Incoming message part from ${contactNumber} to ${userNumber}`) + log.info(`Incoming message part from ${contactNumber} to ${userNumber}`); } const pendingMessagePart = new PendingMessagePart({ - service: 'nexmo', - service_id: message['concat-ref'] || message.messageId, + service: "nexmo", + service_id: message["concat-ref"] || message.messageId, parent_id: parentId, // do we need this anymore, now we have service_id? service_message: JSON.stringify(message), user_number: userNumber, contact_number: contactNumber - }) + }); - const part = await pendingMessagePart.save() - return part.id + const part = await pendingMessagePart.save(); + return part.id; } export default { @@ -204,4 +236,4 @@ export default { sendMessage, handleDeliveryReport, handleIncomingMessage -} +}; diff --git a/src/server/api/lib/services.js b/src/server/api/lib/services.js index 5320a4aba..6092f3423 100644 --- a/src/server/api/lib/services.js +++ b/src/server/api/lib/services.js @@ -1,6 +1,6 @@ -import nexmo from './nexmo' -import twilio from './twilio' -import fakeservice from './fakeservice' +import nexmo from "./nexmo"; +import twilio from "./twilio"; +import fakeservice from "./fakeservice"; // Each service needs the following api points: // async sendMessage(message, contact, trx) -> void @@ -12,6 +12,6 @@ const serviceMap = { nexmo, twilio, fakeservice -} +}; -export default serviceMap +export default serviceMap; diff --git a/src/server/api/lib/twilio.js b/src/server/api/lib/twilio.js index 6f6603497..158723aec 100644 --- a/src/server/api/lib/twilio.js +++ b/src/server/api/lib/twilio.js @@ -1,48 +1,54 @@ -import Twilio from 'twilio' -import { getFormattedPhoneNumber } from '../../../lib/phone-format' -import { Log, Message, PendingMessagePart, r } from '../../models' -import { log } from '../../../lib' -import { getLastMessage, saveNewIncomingMessage } from './message-sending' -import faker from 'faker' +import Twilio from "twilio"; +import { getFormattedPhoneNumber } from "../../../lib/phone-format"; +import { Log, Message, PendingMessagePart, r } from "../../models"; +import { log } from "../../../lib"; +import { getLastMessage, saveNewIncomingMessage } from "./message-sending"; +import faker from "faker"; -let twilio = null -const MAX_SEND_ATTEMPTS = 5 -const MESSAGE_VALIDITY_PADDING_SECONDS = 30 -const MAX_TWILIO_MESSAGE_VALIDITY = 14400 +let twilio = null; +const MAX_SEND_ATTEMPTS = 5; +const MESSAGE_VALIDITY_PADDING_SECONDS = 30; +const MAX_TWILIO_MESSAGE_VALIDITY = 14400; if (process.env.TWILIO_API_KEY && process.env.TWILIO_AUTH_TOKEN) { // eslint-disable-next-line new-cap - twilio = Twilio(process.env.TWILIO_API_KEY, process.env.TWILIO_AUTH_TOKEN) + twilio = Twilio(process.env.TWILIO_API_KEY, process.env.TWILIO_AUTH_TOKEN); } else { - log.warn('NO TWILIO CONNECTION') + log.warn("NO TWILIO CONNECTION"); } if (!process.env.TWILIO_MESSAGE_SERVICE_SID) { - log.warn('Twilio will not be able to send without TWILIO_MESSAGE_SERVICE_SID set') + log.warn( + "Twilio will not be able to send without TWILIO_MESSAGE_SERVICE_SID set" + ); } function webhook() { - log.warn('twilio webhook call') // sky: doesn't run this + log.warn("twilio webhook call"); // sky: doesn't run this if (twilio) { - return Twilio.webhook() + return Twilio.webhook(); } else { - log.warn('NO TWILIO WEB VALIDATION') - return function (req, res, next) { next() } + log.warn("NO TWILIO WEB VALIDATION"); + return function(req, res, next) { + next(); + }; } } async function convertMessagePartsToMessage(messageParts) { - const firstPart = messageParts[0] - const userNumber = firstPart.user_number - const contactNumber = firstPart.contact_number - const serviceMessages = messageParts.map((part) => JSON.parse(part.service_message)) + const firstPart = messageParts[0]; + const userNumber = firstPart.user_number; + const contactNumber = firstPart.contact_number; + const serviceMessages = messageParts.map(part => + JSON.parse(part.service_message) + ); const text = serviceMessages - .map((serviceMessage) => serviceMessage.Body) - .join('') + .map(serviceMessage => serviceMessage.Body) + .join(""); const lastMessage = await getLastMessage({ contactNumber - }) + }); return new Message({ contact_number: contactNumber, user_number: userNumber, @@ -51,97 +57,114 @@ async function convertMessagePartsToMessage(messageParts) { service_response: JSON.stringify(serviceMessages), service_id: serviceMessages[0].MessagingServiceSid, assignment_id: lastMessage.assignment_id, - service: 'twilio', - send_status: 'DELIVERED' - }) + service: "twilio", + send_status: "DELIVERED" + }); } async function findNewCell() { if (!twilio) { - return { availablePhoneNumbers: [{ phone_number: '+15005550006' }] } + return { availablePhoneNumbers: [{ phone_number: "+15005550006" }] }; } return new Promise((resolve, reject) => { - twilio.availablePhoneNumbers('US').local.list({}, (err, data) => { + twilio.availablePhoneNumbers("US").local.list({}, (err, data) => { if (err) { - reject(new Error(err)) + reject(new Error(err)); } else { - resolve(data) + resolve(data); } - }) - }) + }); + }); } async function rentNewCell() { if (!twilio) { - return getFormattedPhoneNumber(faker.phone.phoneNumber()) + return getFormattedPhoneNumber(faker.phone.phoneNumber()); } - const newCell = await findNewCell() + const newCell = await findNewCell(); - if (newCell && newCell.availablePhoneNumbers && newCell.availablePhoneNumbers[0] && newCell.availablePhoneNumbers[0].phone_number) { + if ( + newCell && + newCell.availablePhoneNumbers && + newCell.availablePhoneNumbers[0] && + newCell.availablePhoneNumbers[0].phone_number + ) { return new Promise((resolve, reject) => { - twilio.incomingPhoneNumbers.create({ - phoneNumber: newCell.availablePhoneNumbers[0].phone_number, - smsApplicationSid: process.env.TWILIO_APPLICATION_SID - }, (err, purchasedNumber) => { - if (err) { - reject(err) - } else { - resolve(purchasedNumber.phone_number) + twilio.incomingPhoneNumbers.create( + { + phoneNumber: newCell.availablePhoneNumbers[0].phone_number, + smsApplicationSid: process.env.TWILIO_APPLICATION_SID + }, + (err, purchasedNumber) => { + if (err) { + reject(err); + } else { + resolve(purchasedNumber.phone_number); + } } - }) - }) + ); + }); } - - throw new Error('Did not find any cell') + throw new Error("Did not find any cell"); } -const mediaExtractor = new RegExp(/\[\s*(http[^\]\s]*)\s*\]/) +const mediaExtractor = new RegExp(/\[\s*(http[^\]\s]*)\s*\]/); function parseMessageText(message) { - const text = message.text || '' + const text = message.text || ""; const params = { - body: text.replace(mediaExtractor, '') - } + body: text.replace(mediaExtractor, "") + }; // Image extraction - const results = text.match(mediaExtractor) + const results = text.match(mediaExtractor); if (results) { - params.mediaUrl = results[1] + params.mediaUrl = results[1]; } - return params + return params; } async function sendMessage(message, contact, trx) { if (!twilio) { - log.warn('cannot actually send SMS message -- twilio is not fully configured:', message.id) + log.warn( + "cannot actually send SMS message -- twilio is not fully configured:", + message.id + ); if (message.id) { - const options = trx ? { transaction: trx } : {} - await Message.get(message.id) - .update({ send_status: 'SENT', sent_at: new Date() }, options) + const options = trx ? { transaction: trx } : {}; + await Message.get(message.id).update( + { send_status: "SENT", sent_at: new Date() }, + options + ); } - return 'test_message_uuid' + return "test_message_uuid"; } return new Promise((resolve, reject) => { - if (message.service !== 'twilio') { - log.warn('Message not marked as a twilio message', message.id) + if (message.service !== "twilio") { + log.warn("Message not marked as a twilio message", message.id); } - const messageParams = Object.assign({ - to: message.contact_number, - body: message.text, - messagingServiceSid: process.env.TWILIO_MESSAGE_SERVICE_SID, - statusCallback: process.env.TWILIO_STATUS_CALLBACK_URL - }, parseMessageText(message)) + const messageParams = Object.assign( + { + to: message.contact_number, + body: message.text, + messagingServiceSid: process.env.TWILIO_MESSAGE_SERVICE_SID, + statusCallback: process.env.TWILIO_STATUS_CALLBACK_URL + }, + parseMessageText(message) + ); - let twilioValidityPeriod = process.env.TWILIO_MESSAGE_VALIDITY_PERIOD + let twilioValidityPeriod = process.env.TWILIO_MESSAGE_VALIDITY_PERIOD; if (message.send_before) { // the message is valid no longer than the time between now and // the send_before time, less 30 seconds // we subtract the MESSAGE_VALIDITY_PADDING_SECONDS seconds to allow time for the message to be sent by // a downstream service - const messageValidityPeriod = Math.ceil((message.send_before - Date.now())/1000) - MESSAGE_VALIDITY_PADDING_SECONDS + const messageValidityPeriod = + Math.ceil((message.send_before - Date.now()) / 1000) - + MESSAGE_VALIDITY_PADDING_SECONDS; if (messageValidityPeriod < 0) { // this is an edge case @@ -150,118 +173,146 @@ async function sendMessage(message, contact, trx) { } if (twilioValidityPeriod) { - twilioValidityPeriod = Math.min(twilioValidityPeriod, messageValidityPeriod, MAX_TWILIO_MESSAGE_VALIDITY) + twilioValidityPeriod = Math.min( + twilioValidityPeriod, + messageValidityPeriod, + MAX_TWILIO_MESSAGE_VALIDITY + ); } else { - twilioValidityPeriod = Math.min(messageValidityPeriod, MAX_TWILIO_MESSAGE_VALIDITY) + twilioValidityPeriod = Math.min( + messageValidityPeriod, + MAX_TWILIO_MESSAGE_VALIDITY + ); } } if (twilioValidityPeriod) { - messageParams.validityPeriod = twilioValidityPeriod + messageParams.validityPeriod = twilioValidityPeriod; } twilio.messages.create(messageParams, (err, response) => { const messageToSave = { ...message - } - log.info('messageToSave', messageToSave) - let hasError = false + }; + log.info("messageToSave", messageToSave); + let hasError = false; if (err) { - hasError = true - log.error('Error sending message', err) - console.log('Error sending message', err) - messageToSave.service_response += JSON.stringify(err) + hasError = true; + log.error("Error sending message", err); + console.log("Error sending message", err); + messageToSave.service_response += JSON.stringify(err); } if (response) { - messageToSave.service_id = response.sid - hasError = !!response.error_code - messageToSave.service_response += JSON.stringify(response) + messageToSave.service_id = response.sid; + hasError = !!response.error_code; + messageToSave.service_response += JSON.stringify(response); } if (hasError) { - const SENT_STRING = '"status"' // will appear in responses - if (messageToSave.service_response.split(SENT_STRING).length >= MAX_SEND_ATTEMPTS + 1) { - messageToSave.send_status = 'ERROR' + const SENT_STRING = '"status"'; // will appear in responses + if ( + messageToSave.service_response.split(SENT_STRING).length >= + MAX_SEND_ATTEMPTS + 1 + ) { + messageToSave.send_status = "ERROR"; } - let options = { conflict: 'update' } + let options = { conflict: "update" }; if (trx) { - options.transaction = trx + options.transaction = trx; } Message.save(messageToSave, options) - // eslint-disable-next-line no-unused-vars - .then((_, newMessage) => { - reject(err || (response ? new Error(JSON.stringify(response)) : new Error('Encountered unknown error'))) - }) + // eslint-disable-next-line no-unused-vars + .then((_, newMessage) => { + reject( + err || + (response + ? new Error(JSON.stringify(response)) + : new Error("Encountered unknown error")) + ); + }); } else { - let options = { conflict: 'update' } + let options = { conflict: "update" }; if (trx) { - options.transaction = trx + options.transaction = trx; } - Message.save({ - ...messageToSave, - send_status: 'SENT', - service: 'twilio', - sent_at: new Date() - }, options) - .then((saveError, newMessage) => { - resolve(newMessage) - }) + Message.save( + { + ...messageToSave, + send_status: "SENT", + service: "twilio", + sent_at: new Date() + }, + options + ).then((saveError, newMessage) => { + resolve(newMessage); + }); } - }) - }) + }); + }); } async function handleDeliveryReport(report) { - const messageSid = report.MessageSid + const messageSid = report.MessageSid; if (messageSid) { - await Log.save({ message_sid: report.MessageSid, body: JSON.stringify(report) }) - const messageStatus = report.MessageStatus - const message = await r.table('message') - .getAll(messageSid, { index: 'service_id' }) + await Log.save({ + message_sid: report.MessageSid, + body: JSON.stringify(report) + }); + const messageStatus = report.MessageStatus; + const message = await r + .table("message") + .getAll(messageSid, { index: "service_id" }) .limit(1)(0) - .default(null) + .default(null); if (message) { - message.service_response_at = new Date() - if (messageStatus === 'delivered') { - message.send_status = 'DELIVERED' - } else if (messageStatus === 'failed' || - messageStatus === 'undelivered') { - message.send_status = 'ERROR' + message.service_response_at = new Date(); + if (messageStatus === "delivered") { + message.send_status = "DELIVERED"; + } else if ( + messageStatus === "failed" || + messageStatus === "undelivered" + ) { + message.send_status = "ERROR"; } - Message.save(message, { conflict: 'update' }) + Message.save(message, { conflict: "update" }); } } } async function handleIncomingMessage(message) { - if (!message.hasOwnProperty('From') || - !message.hasOwnProperty('To') || - !message.hasOwnProperty('Body') || - !message.hasOwnProperty('MessageSid')) { - log.error(`This is not an incoming message: ${JSON.stringify(message)}`) + if ( + !message.hasOwnProperty("From") || + !message.hasOwnProperty("To") || + !message.hasOwnProperty("Body") || + !message.hasOwnProperty("MessageSid") + ) { + log.error(`This is not an incoming message: ${JSON.stringify(message)}`); } - const { From, To, MessageSid } = message - const contactNumber = getFormattedPhoneNumber(From) - const userNumber = (To ? getFormattedPhoneNumber(To) : '') + const { From, To, MessageSid } = message; + const contactNumber = getFormattedPhoneNumber(From); + const userNumber = To ? getFormattedPhoneNumber(To) : ""; const pendingMessagePart = new PendingMessagePart({ - service: 'twilio', + service: "twilio", service_id: MessageSid, parent_id: null, service_message: JSON.stringify(message), user_number: userNumber, contact_number: contactNumber - }) + }); - const part = await pendingMessagePart.save() - const partId = part.id + const part = await pendingMessagePart.save(); + const partId = part.id; if (process.env.JOBS_SAME_PROCESS) { - const finalMessage = await convertMessagePartsToMessage([part]) - await saveNewIncomingMessage(finalMessage) - await r.knex('pending_message_part').where('id', partId).delete() + const finalMessage = await convertMessagePartsToMessage([part]); + await saveNewIncomingMessage(finalMessage); + await r + .knex("pending_message_part") + .where("id", partId) + .delete(); } - return partId + return partId; } export default { @@ -274,4 +325,4 @@ export default { handleDeliveryReport, handleIncomingMessage, parseMessageText -} +}; diff --git a/src/server/api/lib/utils.js b/src/server/api/lib/utils.js index 9c3776f33..65c51416a 100644 --- a/src/server/api/lib/utils.js +++ b/src/server/api/lib/utils.js @@ -1,24 +1,26 @@ -import humps from 'humps' +import humps from "humps"; export function mapFieldsToModel(fields, model) { - const resolvers = {} + const resolvers = {}; - fields.forEach((field) => { - const snakeKey = humps.decamelize(field, { separator: '_' }) + fields.forEach(field => { + const snakeKey = humps.decamelize(field, { separator: "_" }); // eslint-disable-next-line no-underscore-dangle if (model._schema._schema.hasOwnProperty(snakeKey)) { - resolvers[field] = (instance) => instance[snakeKey] + resolvers[field] = instance => instance[snakeKey]; } else { // eslint-disable-next-line no-underscore-dangle - throw new Error(`Could not find key ${snakeKey} in model ${model._schema._model._name}`) + throw new Error( + `Could not find key ${snakeKey} in model ${model._schema._model._name}` + ); } - }) - return resolvers + }); + return resolvers; } export const capitalizeWord = word => { if (word) { - return word[0].toUpperCase() + word.slice(1) + return word[0].toUpperCase() + word.slice(1); } - return '' -} + return ""; +}; diff --git a/src/server/api/message.js b/src/server/api/message.js index 50b69ea24..6ffb2422d 100644 --- a/src/server/api/message.js +++ b/src/server/api/message.js @@ -1,16 +1,19 @@ -import { mapFieldsToModel } from './lib/utils' -import { Message } from '../models' +import { mapFieldsToModel } from "./lib/utils"; +import { Message } from "../models"; export const resolvers = { Message: { - ...mapFieldsToModel([ - 'id', - 'text', - 'userNumber', - 'contactNumber', - 'createdAt', - 'isFromContact' - ], Message), - 'campaignId': (instance) => instance['campaign_id'] + ...mapFieldsToModel( + [ + "id", + "text", + "userNumber", + "contactNumber", + "createdAt", + "isFromContact" + ], + Message + ), + campaignId: instance => instance["campaign_id"] } -} +}; diff --git a/src/server/api/mocks.js b/src/server/api/mocks.js index 82a3b582b..3d4d8fff0 100644 --- a/src/server/api/mocks.js +++ b/src/server/api/mocks.js @@ -1,17 +1,16 @@ -const randomString = () => ( +const randomString = () => Math.random() .toString(36) - .replace(/[^a-z]+/g, '') - .substr(0, 5) -) + .replace(/[^a-z]+/g, "") + .substr(0, 5); const mocks = { String: () => `STRING_MOCK_${randomString()}`, Date: () => new Date(), Int: () => 42, ID: () => `ID_MOCK_${randomString()}`, - Phone: () => '+12223334444', + Phone: () => "+12223334444", Timezone: () => ({ offset: -9, hasDST: true }), JSON: () => '{"field1":"value1", "field2": "value2"}' -} +}; -export default mocks +export default mocks; diff --git a/src/server/api/opt-out.js b/src/server/api/opt-out.js index 21e60d9d2..e14633076 100644 --- a/src/server/api/opt-out.js +++ b/src/server/api/opt-out.js @@ -1,13 +1,10 @@ -import { mapFieldsToModel } from './lib/utils' -import { OptOut } from '../models' +import { mapFieldsToModel } from "./lib/utils"; +import { OptOut } from "../models"; export const resolvers = { OptOut: { - ...mapFieldsToModel([ - 'id', - 'cell', - 'createdAt' - ], OptOut), - assignment: async (optOut, _, { loaders }) => loaders.assignment.load(optOut.assignment_id) + ...mapFieldsToModel(["id", "cell", "createdAt"], OptOut), + assignment: async (optOut, _, { loaders }) => + loaders.assignment.load(optOut.assignment_id) } -} +}; diff --git a/src/server/api/organization.js b/src/server/api/organization.js index 799656269..b4e5d26c3 100644 --- a/src/server/api/organization.js +++ b/src/server/api/organization.js @@ -1,39 +1,49 @@ -import { mapFieldsToModel } from './lib/utils' -import { r, Organization } from '../models' -import { accessRequired } from './errors' -import { getCampaigns } from './campaign' -import { buildSortedUserOrganizationQuery } from './user' +import { mapFieldsToModel } from "./lib/utils"; +import { r, Organization } from "../models"; +import { accessRequired } from "./errors"; +import { getCampaigns } from "./campaign"; +import { buildSortedUserOrganizationQuery } from "./user"; export const resolvers = { Organization: { - ...mapFieldsToModel([ - 'id', - 'name' - ], Organization), + ...mapFieldsToModel(["id", "name"], Organization), campaigns: async (organization, { cursor, campaignsFilter }, { user }) => { - await accessRequired(user, organization.id, 'SUPERVOLUNTEER') - return getCampaigns(organization.id, cursor, campaignsFilter) + await accessRequired(user, organization.id, "SUPERVOLUNTEER"); + return getCampaigns(organization.id, cursor, campaignsFilter); }, uuid: async (organization, _, { user }) => { - await accessRequired(user, organization.id, 'SUPERVOLUNTEER') - const result = await r.knex('organization') - .column('uuid') - .where('id', organization.id) - return result[0].uuid + await accessRequired(user, organization.id, "SUPERVOLUNTEER"); + const result = await r + .knex("organization") + .column("uuid") + .where("id", organization.id); + return result[0].uuid; }, optOuts: async (organization, _, { user }) => { - await accessRequired(user, organization.id, 'ADMIN') - return r.table('opt_out') - .getAll(organization.id, { index: 'organization_id' }) + await accessRequired(user, organization.id, "ADMIN"); + return r + .table("opt_out") + .getAll(organization.id, { index: "organization_id" }); }, people: async (organization, { role, campaignId, sortBy }, { user }) => { - await accessRequired(user, organization.id, 'SUPERVOLUNTEER') - return buildSortedUserOrganizationQuery(organization.id, role, campaignId, sortBy) + await accessRequired(user, organization.id, "SUPERVOLUNTEER"); + return buildSortedUserOrganizationQuery( + organization.id, + role, + campaignId, + sortBy + ); }, - threeClickEnabled: (organization) => organization.features.indexOf('threeClick') !== -1, - textingHoursEnforced: (organization) => organization.texting_hours_enforced, - optOutMessage: (organization) => (organization.features && organization.features.indexOf('opt_out_message') !== -1 ? JSON.parse(organization.features).opt_out_message : process.env.OPT_OUT_MESSAGE) || 'I\'m opting you out of texts immediately. Have a great day.', - textingHoursStart: (organization) => organization.texting_hours_start, - textingHoursEnd: (organization) => organization.texting_hours_end + threeClickEnabled: organization => + organization.features.indexOf("threeClick") !== -1, + textingHoursEnforced: organization => organization.texting_hours_enforced, + optOutMessage: organization => + (organization.features && + organization.features.indexOf("opt_out_message") !== -1 + ? JSON.parse(organization.features).opt_out_message + : process.env.OPT_OUT_MESSAGE) || + "I'm opting you out of texts immediately. Have a great day.", + textingHoursStart: organization => organization.texting_hours_start, + textingHoursEnd: organization => organization.texting_hours_end } -} +}; diff --git a/src/server/api/phone.js b/src/server/api/phone.js index e624e6791..c1b4203ad 100644 --- a/src/server/api/phone.js +++ b/src/server/api/phone.js @@ -1,26 +1,29 @@ -import { GraphQLScalarType } from 'graphql' -import { GraphQLError } from 'graphql/error' -import { Kind } from 'graphql/language' +import { GraphQLScalarType } from "graphql"; +import { GraphQLError } from "graphql/error"; +import { Kind } from "graphql/language"; -const identity = value => value +const identity = value => value; // Regex taken from http://stackoverflow.com/questions/6478875/regular-expression-matching-e-164-formatted-phone-numbers -const pattern = /^\+[1-9]\d{1,14}$/ +const pattern = /^\+[1-9]\d{1,14}$/; export const GraphQLPhone = new GraphQLScalarType({ - name: 'Phone', - description: 'Phone number', + name: "Phone", + description: "Phone number", parseValue: identity, serialize: identity, parseLiteral(ast) { if (ast.kind !== Kind.STRING) { - throw new GraphQLError(`Query error: Can only parse strings got a: ${ast.kind}`, [ast]) + throw new GraphQLError( + `Query error: Can only parse strings got a: ${ast.kind}`, + [ast] + ); } if (!pattern.test(ast.value)) { - throw new GraphQLError('Query error: Not a valid Phone', [ast]) + throw new GraphQLError("Query error: Not a valid Phone", [ast]); } - return ast.value + return ast.value; } -}) +}); diff --git a/src/server/api/question-response.js b/src/server/api/question-response.js index 2629e1400..8c168c908 100644 --- a/src/server/api/question-response.js +++ b/src/server/api/question-response.js @@ -1,13 +1,10 @@ -import { mapFieldsToModel } from './lib/utils' -import { QuestionResponse } from '../models' +import { mapFieldsToModel } from "./lib/utils"; +import { QuestionResponse } from "../models"; export const resolvers = { QuestionResponse: { - ...mapFieldsToModel([ - 'id', - 'value' - ], QuestionResponse), + ...mapFieldsToModel(["id", "value"], QuestionResponse), question: async (question, _, { loaders }) => - (loaders.question.load(question.id)) + loaders.question.load(question.id) } -} +}; diff --git a/src/server/api/question.js b/src/server/api/question.js index e7a9dd4d8..dacecb866 100644 --- a/src/server/api/question.js +++ b/src/server/api/question.js @@ -1,46 +1,50 @@ -import { r } from '../models' +import { r } from "../models"; export const resolvers = { Question: { - text: async (interactionStep) => interactionStep.question, - answerOptions: async (interactionStep) => ( + text: async interactionStep => interactionStep.question, + answerOptions: async interactionStep => // this should usually be pre-built from campaign's interactionSteps call - interactionStep.answerOptions - || r.table('interaction_step') + interactionStep.answerOptions || + r + .table("interaction_step") .filter({ parent_interaction_id: interactionStep.id }) .filter({ is_deleted: false }) - .orderBy('answer_option') + .orderBy("answer_option") .map({ - value: r.row('answer_option'), - action: r.row('answer_actions'), - interaction_step_id: r.row('id'), - parent_interaction_step: r.row('parent_interaction_id') - }) - ), - interactionStep: async (interactionStep) => interactionStep + value: r.row("answer_option"), + action: r.row("answer_actions"), + interaction_step_id: r.row("id"), + parent_interaction_step: r.row("parent_interaction_id") + }), + interactionStep: async interactionStep => interactionStep }, AnswerOption: { - value: (answer) => answer.value, - interactionStepId: (answer) => answer.interaction_step_id, - nextInteractionStep: async (answer) => ( - answer.nextInteractionStep - || r.table('interaction_step').get(answer.interaction_step_id)), - responders: async (answer) => ( - r.table('question_response') - .getAll(answer.parent_interaction_step, { index: 'interaction_step_id' }) + value: answer => answer.value, + interactionStepId: answer => answer.interaction_step_id, + nextInteractionStep: async answer => + answer.nextInteractionStep || + r.table("interaction_step").get(answer.interaction_step_id), + responders: async answer => + r + .table("question_response") + .getAll(answer.parent_interaction_step, { + index: "interaction_step_id" + }) .filter({ value: answer.value }) - .eqJoin('campaign_contact_id', r.table('campaign_contact'))('right') - ), - responderCount: async (answer) => ( - r.table('question_response') - .getAll(answer.parent_interaction_step, { index: 'interaction_step_id' }) + .eqJoin("campaign_contact_id", r.table("campaign_contact"))("right"), + responderCount: async answer => + r + .table("question_response") + .getAll(answer.parent_interaction_step, { + index: "interaction_step_id" + }) .filter({ value: answer.value }) - .count() - ), - question: async (answer) => answer.parent_interaction_step + .count(), + question: async answer => answer.parent_interaction_step } -} +}; diff --git a/src/server/api/schema.js b/src/server/api/schema.js index e5263b94d..d9a104244 100644 --- a/src/server/api/schema.js +++ b/src/server/api/schema.js @@ -1,14 +1,20 @@ -import camelCaseKeys from 'camelcase-keys' -import GraphQLDate from 'graphql-date' -import GraphQLJSON from 'graphql-type-json' -import { GraphQLError } from 'graphql/error' -import isUrl from 'is-url' -import { organizationCache } from '../models/cacheable_queries/organization' - -import { gzip, log, makeTree } from '../../lib' -import { applyScript } from '../../lib/scripts' -import { capitalizeWord } from './lib/utils' -import { assignTexters, exportCampaign, importScript, loadContactsFromDataWarehouse, uploadContacts } from '../../workers/jobs' +import camelCaseKeys from "camelcase-keys"; +import GraphQLDate from "graphql-date"; +import GraphQLJSON from "graphql-type-json"; +import { GraphQLError } from "graphql/error"; +import isUrl from "is-url"; +import { organizationCache } from "../models/cacheable_queries/organization"; + +import { gzip, log, makeTree } from "../../lib"; +import { applyScript } from "../../lib/scripts"; +import { capitalizeWord } from "./lib/utils"; +import { + assignTexters, + exportCampaign, + importScript, + loadContactsFromDataWarehouse, + uploadContacts +} from "../../workers/jobs"; import { Assignment, Campaign, @@ -24,43 +30,45 @@ import { UserOrganization, r, cacheableData -} from '../models' +} from "../models"; // import { isBetweenTextingHours } from '../../lib/timezones' -import { Notifications, sendUserNotification } from '../notifications' -import { resolvers as assignmentResolvers } from './assignment' -import { getCampaigns, resolvers as campaignResolvers } from './campaign' -import { resolvers as campaignContactResolvers } from './campaign-contact' -import { resolvers as cannedResponseResolvers } from './canned-response' +import { Notifications, sendUserNotification } from "../notifications"; +import { resolvers as assignmentResolvers } from "./assignment"; +import { getCampaigns, resolvers as campaignResolvers } from "./campaign"; +import { resolvers as campaignContactResolvers } from "./campaign-contact"; +import { resolvers as cannedResponseResolvers } from "./canned-response"; import { getConversations, getCampaignIdMessageIdsAndCampaignIdContactIdsMaps, reassignConversations, resolvers as conversationsResolver -} from './conversations' +} from "./conversations"; import { accessRequired, assignmentRequired, authRequired, superAdminRequired -} from './errors' -import { resolvers as interactionStepResolvers } from './interaction-step' -import { resolvers as inviteResolvers } from './invite' -import { saveNewIncomingMessage } from './lib/message-sending' -import serviceMap from './lib/services' -import { resolvers as messageResolvers } from './message' -import { resolvers as optOutResolvers } from './opt-out' -import { resolvers as organizationResolvers } from './organization' -import { GraphQLPhone } from './phone' -import { resolvers as questionResolvers } from './question' -import { resolvers as questionResponseResolvers } from './question-response' -import { getUsers, resolvers as userResolvers } from './user' -import { change } from '../local-auth-helpers' - -import { getSendBeforeTimeUtc } from '../../lib/timezones' - -const uuidv4 = require('uuid').v4 -const JOBS_SAME_PROCESS = !!(process.env.JOBS_SAME_PROCESS || global.JOBS_SAME_PROCESS) -const JOBS_SYNC = !!(process.env.JOBS_SYNC || global.JOBS_SYNC) +} from "./errors"; +import { resolvers as interactionStepResolvers } from "./interaction-step"; +import { resolvers as inviteResolvers } from "./invite"; +import { saveNewIncomingMessage } from "./lib/message-sending"; +import serviceMap from "./lib/services"; +import { resolvers as messageResolvers } from "./message"; +import { resolvers as optOutResolvers } from "./opt-out"; +import { resolvers as organizationResolvers } from "./organization"; +import { GraphQLPhone } from "./phone"; +import { resolvers as questionResolvers } from "./question"; +import { resolvers as questionResponseResolvers } from "./question-response"; +import { getUsers, resolvers as userResolvers } from "./user"; +import { change } from "../local-auth-helpers"; + +import { getSendBeforeTimeUtc } from "../../lib/timezones"; + +const uuidv4 = require("uuid").v4; +const JOBS_SAME_PROCESS = !!( + process.env.JOBS_SAME_PROCESS || global.JOBS_SAME_PROCESS +); +const JOBS_SYNC = !!(process.env.JOBS_SYNC || global.JOBS_SYNC); async function editCampaign(id, campaign, loaders, user, origCampaignRecord) { const { @@ -76,10 +84,16 @@ async function editCampaign(id, campaign, loaders, user, origCampaignRecord) { textingHoursStart, textingHoursEnd, timezone - } = campaign + } = campaign; // some changes require ADMIN and we recheck below - const organizationId = campaign.organizationId || origCampaignRecord.organization_id - await accessRequired(user, organizationId, 'SUPERVOLUNTEER', /* superadmin*/ true) + const organizationId = + campaign.organizationId || origCampaignRecord.organization_id; + await accessRequired( + user, + organizationId, + "SUPERVOLUNTEER", + /* superadmin*/ true + ); const campaignUpdates = { id, title, @@ -87,7 +101,7 @@ async function editCampaign(id, campaign, loaders, user, origCampaignRecord) { due_by: dueBy, organization_id: organizationId, use_dynamic_assignment: useDynamicAssignment, - logo_image_url: isUrl(logoImageUrl) ? logoImageUrl : '', + logo_image_url: isUrl(logoImageUrl) ? logoImageUrl : "", primary_color: primaryColor, intro_html: introHtml, override_organization_texting_hours: overrideOrganizationTextingHours, @@ -95,16 +109,16 @@ async function editCampaign(id, campaign, loaders, user, origCampaignRecord) { texting_hours_start: textingHoursStart, texting_hours_end: textingHoursEnd, timezone - } + }; Object.keys(campaignUpdates).forEach(key => { - if (typeof campaignUpdates[key] === 'undefined') { - delete campaignUpdates[key] + if (typeof campaignUpdates[key] === "undefined") { + delete campaignUpdates[key]; } - }) + }); - if (campaign.hasOwnProperty('contacts') && campaign.contacts) { - await accessRequired(user, organizationId, 'ADMIN', /* superadmin*/ true) + if (campaign.hasOwnProperty("contacts") && campaign.contacts) { + await accessRequired(user, organizationId, "ADMIN", /* superadmin*/ true); const contactsToSave = campaign.contacts.map(datum => { const modelData = { campaign_id: datum.campaignId, @@ -114,93 +128,106 @@ async function editCampaign(id, campaign, loaders, user, origCampaignRecord) { external_id: datum.external_id, custom_fields: datum.customFields, zip: datum.zip - } - modelData.campaign_id = id - return modelData - }) - const compressedString = await gzip(JSON.stringify(contactsToSave)) + }; + modelData.campaign_id = id; + return modelData; + }); + const compressedString = await gzip(JSON.stringify(contactsToSave)); let job = await JobRequest.save({ queue_name: `${id}:edit_campaign`, - job_type: 'upload_contacts', + job_type: "upload_contacts", locks_queue: true, assigned: JOBS_SAME_PROCESS, // can get called immediately, below campaign_id: id, // NOTE: stringifying because compressedString is a binary buffer - payload: compressedString.toString('base64') - }) + payload: compressedString.toString("base64") + }); if (JOBS_SAME_PROCESS) { - uploadContacts(job) + uploadContacts(job); } } - if (campaign.hasOwnProperty('contactSql') && datawarehouse && user.is_superadmin) { - await accessRequired(user, organizationId, 'ADMIN', /* superadmin*/ true) + if ( + campaign.hasOwnProperty("contactSql") && + datawarehouse && + user.is_superadmin + ) { + await accessRequired(user, organizationId, "ADMIN", /* superadmin*/ true); let job = await JobRequest.save({ queue_name: `${id}:edit_campaign`, - job_type: 'upload_contacts_sql', + job_type: "upload_contacts_sql", locks_queue: true, assigned: JOBS_SAME_PROCESS, // can get called immediately, below campaign_id: id, payload: campaign.contactSql - }) + }); if (JOBS_SAME_PROCESS) { - loadContactsFromDataWarehouse(job) + loadContactsFromDataWarehouse(job); } } - if (campaign.hasOwnProperty('texters')) { + if (campaign.hasOwnProperty("texters")) { let job = await JobRequest.save({ queue_name: `${id}:edit_campaign`, locks_queue: true, assigned: JOBS_SAME_PROCESS, // can get called immediately, below - job_type: 'assign_texters', + job_type: "assign_texters", campaign_id: id, payload: JSON.stringify({ id, texters: campaign.texters }) - }) + }); if (JOBS_SAME_PROCESS) { if (JOBS_SYNC) { - await assignTexters(job) + await assignTexters(job); } else { - assignTexters(job) + assignTexters(job); } } } - if (campaign.hasOwnProperty('interactionSteps')) { - await accessRequired(user, organizationId, 'SUPERVOLUNTEER', /* superadmin*/ true) - await updateInteractionSteps(id, [campaign.interactionSteps], origCampaignRecord) - await cacheableData.campaign.clear(id) + if (campaign.hasOwnProperty("interactionSteps")) { + await accessRequired( + user, + organizationId, + "SUPERVOLUNTEER", + /* superadmin*/ true + ); + await updateInteractionSteps( + id, + [campaign.interactionSteps], + origCampaignRecord + ); + await cacheableData.campaign.clear(id); } - if (campaign.hasOwnProperty('cannedResponses')) { - const cannedResponses = campaign.cannedResponses - const convertedResponses = [] + if (campaign.hasOwnProperty("cannedResponses")) { + const cannedResponses = campaign.cannedResponses; + const convertedResponses = []; for (let index = 0; index < cannedResponses.length; index++) { - const response = cannedResponses[index] + const response = cannedResponses[index]; convertedResponses.push({ ...response, campaign_id: id, id: undefined - }) + }); } await r - .table('canned_response') - .getAll(id, { index: 'campaign_id' }) - .filter({ user_id: '' }) - .delete() - await CannedResponse.save(convertedResponses) + .table("canned_response") + .getAll(id, { index: "campaign_id" }) + .filter({ user_id: "" }) + .delete(); + await CannedResponse.save(convertedResponses); await cacheableData.cannedResponse.clearQuery({ - userId: '', + userId: "", campaignId: id - }) + }); } - const newCampaign = await Campaign.get(id).update(campaignUpdates) - cacheableData.campaign.reload(id) - return newCampaign || loaders.campaign.load(id) + const newCampaign = await Campaign.get(id).update(campaignUpdates); + cacheableData.campaign.reload(id); + return newCampaign || loaders.campaign.load(id); } async function updateInteractionSteps( @@ -212,9 +239,9 @@ async function updateInteractionSteps( await interactionSteps.forEach(async is => { // map the interaction step ids for new ones if (idMap[is.parentInteractionId]) { - is.parentInteractionId = idMap[is.parentInteractionId] + is.parentInteractionId = idMap[is.parentInteractionId]; } - if (is.id.indexOf('new') !== -1) { + if (is.id.indexOf("new") !== -1) { const newIstep = await InteractionStep.save({ parent_interaction_id: is.parentInteractionId || null, question: is.questionText, @@ -223,17 +250,17 @@ async function updateInteractionSteps( answer_actions: is.answerActions, campaign_id: campaignId, is_deleted: false - }) - idMap[is.id] = newIstep.id + }); + idMap[is.id] = newIstep.id; } else { if (!origCampaignRecord.is_started && is.isDeleted) { await r - .knex('interaction_step') + .knex("interaction_step") .where({ id: is.id }) - .delete() + .delete(); } else { await r - .knex('interaction_step') + .knex("interaction_step") .where({ id: is.id }) .update({ question: is.questionText, @@ -241,54 +268,60 @@ async function updateInteractionSteps( answer_option: is.answerOption, answer_actions: is.answerActions, is_deleted: is.isDeleted - }) + }); } } - await updateInteractionSteps(campaignId, is.interactionSteps, origCampaignRecord, idMap) - }) + await updateInteractionSteps( + campaignId, + is.interactionSteps, + origCampaignRecord, + idMap + ); + }); } const rootMutations = { RootMutation: { userAgreeTerms: async (_, { userId }, { user, loaders }) => { if (user.id === Number(userId)) { - return (user.terms ? user : null) + return user.terms ? user : null; } const currentUser = await r - .table('user') + .table("user") .get(userId) .update({ terms: true - }) - await cacheableData.user.clearUser(user.id, user.auth0_id) - return currentUser + }); + await cacheableData.user.clearUser(user.id, user.auth0_id); + return currentUser; }, sendReply: async (_, { id, message }, { user, loaders }) => { - const contact = await loaders.campaignContact.load(id) - const campaign = await loaders.campaign.load(contact.campaign_id) + const contact = await loaders.campaignContact.load(id); + const campaign = await loaders.campaign.load(contact.campaign_id); - await accessRequired(user, campaign.organization_id, 'ADMIN') + await accessRequired(user, campaign.organization_id, "ADMIN"); const lastMessage = await r - .table('message') - .getAll(contact.assignment_id, { index: 'assignment_id' }) + .table("message") + .getAll(contact.assignment_id, { index: "assignment_id" }) .filter({ contact_number: contact.cell }) .limit(1)(0) - .default(null) + .default(null); if (!lastMessage) { throw new GraphQLError({ status: 400, - message: 'Cannot fake a reply to a contact that has no existing thread yet' - }) + message: + "Cannot fake a reply to a contact that has no existing thread yet" + }); } - const userNumber = lastMessage.user_number - const contactNumber = contact.cell + const userNumber = lastMessage.user_number; + const contactNumber = contact.cell; const mockId = `mocked_${Math.random() .toString(36) - .replace(/[^a-zA-Z1-9]+/g, '')}` + .replace(/[^a-zA-Z1-9]+/g, "")}`; await saveNewIncomingMessage( new Message({ contact_number: contactNumber, @@ -303,18 +336,18 @@ const rootMutations = { service_id: mockId, assignment_id: lastMessage.assignment_id, service: lastMessage.service, - send_status: 'DELIVERED' + send_status: "DELIVERED" }) - ) - return loaders.campaignContact.load(id) + ); + return loaders.campaignContact.load(id); }, exportCampaign: async (_, { id }, { user, loaders }) => { - const campaign = await loaders.campaign.load(id) - const organizationId = campaign.organization_id - await accessRequired(user, organizationId, 'ADMIN') + const campaign = await loaders.campaign.load(id); + const organizationId = campaign.organization_id; + await accessRequired(user, organizationId, "ADMIN"); const newJob = await JobRequest.save({ queue_name: `${id}:export`, - job_type: 'export', + job_type: "export", locks_queue: false, assigned: JOBS_SAME_PROCESS, // can get called immediately, below campaign_id: id, @@ -322,237 +355,272 @@ const rootMutations = { id, requester: user.id }) - }) + }); if (JOBS_SAME_PROCESS) { - exportCampaign(newJob) + exportCampaign(newJob); } - return newJob + return newJob; }, - editOrganizationRoles: async (_, { userId, organizationId, roles }, { user, loaders }) => { + editOrganizationRoles: async ( + _, + { userId, organizationId, roles }, + { user, loaders } + ) => { const currentRoles = (await r - .knex('user_organization') + .knex("user_organization") .where({ organization_id: organizationId, user_id: userId }) - .select('role')).map(res => res.role) - const oldRoleIsOwner = currentRoles.indexOf('OWNER') !== -1 - const newRoleIsOwner = roles.indexOf('OWNER') !== -1 - const roleRequired = oldRoleIsOwner || newRoleIsOwner ? 'OWNER' : 'ADMIN' - let newOrgRoles = [] + .select("role")).map(res => res.role); + const oldRoleIsOwner = currentRoles.indexOf("OWNER") !== -1; + const newRoleIsOwner = roles.indexOf("OWNER") !== -1; + const roleRequired = oldRoleIsOwner || newRoleIsOwner ? "OWNER" : "ADMIN"; + let newOrgRoles = []; - await accessRequired(user, organizationId, roleRequired) + await accessRequired(user, organizationId, roleRequired); currentRoles.forEach(async curRole => { if (roles.indexOf(curRole) === -1) { await r - .table('user_organization') - .getAll([organizationId, userId], { index: 'organization_user' }) + .table("user_organization") + .getAll([organizationId, userId], { index: "organization_user" }) .filter({ role: curRole }) - .delete() + .delete(); } - }) + }); - newOrgRoles = roles.filter(newRole => currentRoles.indexOf(newRole) === -1).map(newRole => ({ - organization_id: organizationId, - user_id: userId, - role: newRole - })) + newOrgRoles = roles + .filter(newRole => currentRoles.indexOf(newRole) === -1) + .map(newRole => ({ + organization_id: organizationId, + user_id: userId, + role: newRole + })); if (newOrgRoles.length) { - await UserOrganization.save(newOrgRoles, { conflict: 'update' }) + await UserOrganization.save(newOrgRoles, { conflict: "update" }); } - await cacheableData.user.clearUser(userId) - return loaders.organization.load(organizationId) + await cacheableData.user.clearUser(userId); + return loaders.organization.load(organizationId); }, editUser: async (_, { organizationId, userId, userData }, { user }) => { if (user.id !== userId) { // User can edit themselves - await accessRequired(user, organizationId, 'ADMIN', true) + await accessRequired(user, organizationId, "ADMIN", true); } const userRes = await r - .knex('user') - .join('user_organization', 'user.id', 'user_organization.user_id') + .knex("user") + .join("user_organization", "user.id", "user_organization.user_id") .where({ - 'user_organization.organization_id': organizationId, - 'user.id': userId + "user_organization.organization_id": organizationId, + "user.id": userId }) - .limit(1) + .limit(1); if (!userRes || !userRes.length) { - return null + return null; } else { - const member = userRes[0] + const member = userRes[0]; const newUserData = { first_name: capitalizeWord(userData.firstName), last_name: capitalizeWord(userData.lastName), email: userData.email, cell: userData.cell - } + }; if (userData) { const userRes = await r - .knex('user') - .where('id', userId) - .update(newUserData) - await cacheableData.user.clearUser(member.id, member.auth0_id) + .knex("user") + .where("id", userId) + .update(newUserData); + await cacheableData.user.clearUser(member.id, member.auth0_id); userData = { id: userId, ...newUserData - } + }; } else { - userData = member + userData = member; } - return userData + return userData; } }, resetUserPassword: async (_, { organizationId, userId }, { user }) => { if (user.id === userId) { - throw new Error('You can\'t reset your own password.') + throw new Error("You can't reset your own password."); } - await accessRequired(user, organizationId, 'ADMIN', true) + await accessRequired(user, organizationId, "ADMIN", true); // Add date at the end in case user record is modified after password is reset - const passwordResetHash = uuidv4() - const auth0_id = `reset|${passwordResetHash}|${Date.now()}` + const passwordResetHash = uuidv4(); + const auth0_id = `reset|${passwordResetHash}|${Date.now()}`; const userRes = await r - .knex('user') - .where('id', userId) + .knex("user") + .where("id", userId) .update({ auth0_id - }) - return passwordResetHash + }); + return passwordResetHash; }, changeUserPassword: async (_, { userId, formData }, { user }) => { if (user.id !== userId) { - throw new Error('You can only change your own password.') + throw new Error("You can only change your own password."); } - const { password, newPassword, passwordConfirm } = formData + const { password, newPassword, passwordConfirm } = formData; - const updatedUser = await change({ user, password, newPassword, passwordConfirm }) + const updatedUser = await change({ + user, + password, + newPassword, + passwordConfirm + }); - return updatedUser + return updatedUser; }, joinOrganization: async (_, { organizationUuid }, { user, loaders }) => { - let organization - ;[organization] = await r.knex('organization').where('uuid', organizationUuid) + let organization; + [organization] = await r + .knex("organization") + .where("uuid", organizationUuid); if (organization) { const userOrg = await r - .table('user_organization') - .getAll(user.id, { index: 'user_id' }) + .table("user_organization") + .getAll(user.id, { index: "user_id" }) .filter({ organization_id: organization.id }) .limit(1)(0) - .default(null) + .default(null); if (!userOrg) { await UserOrganization.save({ user_id: user.id, organization_id: organization.id, - role: 'TEXTER' - }).error(function (error) { + role: "TEXTER" + }).error(function(error) { // Unexpected errors - console.log("error on userOrganization save", error) + console.log("error on userOrganization save", error); }); - await cacheableData.user.clearUser(user.id) - } else { // userOrg exists - console.log('existing userOrg ' + userOrg.id + ' user ' + user.id + ' organizationUuid ' + organizationUuid) + await cacheableData.user.clearUser(user.id); + } else { + // userOrg exists + console.log( + "existing userOrg " + + userOrg.id + + " user " + + user.id + + " organizationUuid " + + organizationUuid + ); } - } else { // no organization - console.log('no organization with id ' + organizationUuid + ' for user ' + user.id) + } else { + // no organization + console.log( + "no organization with id " + organizationUuid + " for user " + user.id + ); } - return organization + return organization; }, - assignUserToCampaign: async (_, { organizationUuid, campaignId }, { user, loaders }) => { + assignUserToCampaign: async ( + _, + { organizationUuid, campaignId }, + { user, loaders } + ) => { const campaign = await r - .knex('campaign') - .leftJoin('organization', 'campaign.organization_id', 'organization.id') + .knex("campaign") + .leftJoin("organization", "campaign.organization_id", "organization.id") .where({ - 'campaign.id': campaignId, - 'campaign.use_dynamic_assignment': true, - 'organization.uuid': organizationUuid + "campaign.id": campaignId, + "campaign.use_dynamic_assignment": true, + "organization.uuid": organizationUuid }) - .select('campaign.*') - .first() + .select("campaign.*") + .first(); if (!campaign) { throw new GraphQLError({ status: 403, - message: 'Invalid join request' - }) + message: "Invalid join request" + }); } const assignment = await r - .table('assignment') - .getAll(user.id, { index: 'user_id' }) + .table("assignment") + .getAll(user.id, { index: "user_id" }) .filter({ campaign_id: campaign.id }) .limit(1)(0) - .default(null) + .default(null); if (!assignment) { await Assignment.save({ user_id: user.id, campaign_id: campaign.id, - max_contacts: (process.env.MAX_CONTACTS_PER_TEXTER ? parseInt(process.env.MAX_CONTACTS_PER_TEXTER, 10) : null) - }) + max_contacts: process.env.MAX_CONTACTS_PER_TEXTER + ? parseInt(process.env.MAX_CONTACTS_PER_TEXTER, 10) + : null + }); } - return campaign + return campaign; }, updateTextingHours: async ( _, { organizationId, textingHoursStart, textingHoursEnd }, { user } ) => { - await accessRequired(user, organizationId, 'OWNER') + await accessRequired(user, organizationId, "OWNER"); await Organization.get(organizationId).update({ texting_hours_start: textingHoursStart, texting_hours_end: textingHoursEnd - }) - cacheableData.organization.clear(organizationId) + }); + cacheableData.organization.clear(organizationId); - return await Organization.get(organizationId) + return await Organization.get(organizationId); }, updateTextingHoursEnforcement: async ( _, { organizationId, textingHoursEnforced }, { user, loaders } ) => { - await accessRequired(user, organizationId, 'SUPERVOLUNTEER') + await accessRequired(user, organizationId, "SUPERVOLUNTEER"); await Organization.get(organizationId).update({ texting_hours_enforced: textingHoursEnforced - }) - await cacheableData.organization.clear(organizationId) + }); + await cacheableData.organization.clear(organizationId); - return await loaders.organization.load(organizationId) + return await loaders.organization.load(organizationId); }, updateOptOutMessage: async ( _, { organizationId, optOutMessage }, { user } ) => { - await accessRequired(user, organizationId, 'OWNER') + await accessRequired(user, organizationId, "OWNER"); - const organization = await Organization.get(organizationId) - const featuresJSON = JSON.parse(organization.features || '{}') - featuresJSON.opt_out_message = optOutMessage - organization.features = JSON.stringify(featuresJSON) + const organization = await Organization.get(organizationId); + const featuresJSON = JSON.parse(organization.features || "{}"); + featuresJSON.opt_out_message = optOutMessage; + organization.features = JSON.stringify(featuresJSON); - await organization.save() - await organizationCache.clear(organizationId) + await organization.save(); + await organizationCache.clear(organizationId); - return await Organization.get(organizationId) + return await Organization.get(organizationId); }, createInvite: async (_, { user }) => { if ((user && user.is_superadmin) || !process.env.SUPPRESS_SELF_INVITE) { const inviteInstance = new Invite({ is_valid: true, hash: uuidv4() - }) - const newInvite = await inviteInstance.save() - return newInvite + }); + const newInvite = await inviteInstance.save(); + return newInvite; } }, createCampaign: async (_, { campaign }, { user, loaders }) => { - await accessRequired(user, campaign.organizationId, 'ADMIN', /* allowSuperadmin=*/ true) + await accessRequired( + user, + campaign.organizationId, + "ADMIN", + /* allowSuperadmin=*/ true + ); const campaignInstance = new Campaign({ organization_id: campaign.organizationId, creator_id: user.id, @@ -561,46 +629,48 @@ const rootMutations = { due_by: campaign.dueBy, is_started: false, is_archived: false - }) - const newCampaign = await campaignInstance.save() - return editCampaign(newCampaign.id, campaign, loaders, user) + }); + const newCampaign = await campaignInstance.save(); + return editCampaign(newCampaign.id, campaign, loaders, user); }, copyCampaign: async (_, { id }, { user, loaders }) => { - const campaign = await loaders.campaign.load(id) - await accessRequired(user, campaign.organization_id, 'ADMIN') + const campaign = await loaders.campaign.load(id); + await accessRequired(user, campaign.organization_id, "ADMIN"); const campaignInstance = new Campaign({ organization_id: campaign.organization_id, creator_id: user.id, - title: 'COPY - ' + campaign.title, + title: "COPY - " + campaign.title, description: campaign.description, due_by: campaign.dueBy, is_started: false, is_archived: false - }) - const newCampaign = await campaignInstance.save() - const newCampaignId = newCampaign.id - const oldCampaignId = campaign.id + }); + const newCampaign = await campaignInstance.save(); + const newCampaignId = newCampaign.id; + const oldCampaignId = campaign.id; - let interactions = await r.knex('interaction_step').where({ campaign_id: oldCampaignId, is_deleted: false }) + let interactions = await r + .knex("interaction_step") + .where({ campaign_id: oldCampaignId, is_deleted: false }); - const interactionsArr = [] + const interactionsArr = []; interactions.forEach((interaction, index) => { if (interaction.parent_interaction_id) { let is = { - id: 'new' + interaction.id, + id: "new" + interaction.id, questionText: interaction.question, script: interaction.script, answerOption: interaction.answer_option, answerActions: interaction.answer_actions, isDeleted: interaction.is_deleted, campaign_id: newCampaignId, - parentInteractionId: 'new' + interaction.parent_interaction_id - } - interactionsArr.push(is) + parentInteractionId: "new" + interaction.parent_interaction_id + }; + interactionsArr.push(is); } else if (!interaction.parent_interaction_id) { let is = { - id: 'new' + interaction.id, + id: "new" + interaction.id, questionText: interaction.question, script: interaction.script, answerOption: interaction.answer_option, @@ -608,254 +678,288 @@ const rootMutations = { isDeleted: interaction.is_deleted, campaign_id: newCampaignId, parentInteractionId: interaction.parent_interaction_id - } - interactionsArr.push(is) + }; + interactionsArr.push(is); } - }) + }); let createSteps = updateInteractionSteps( newCampaignId, [makeTree(interactionsArr, (id = null))], campaign, {} - ) + ); - await createSteps + await createSteps; let createCannedResponses = r - .knex('canned_response') + .knex("canned_response") .where({ campaign_id: oldCampaignId }) - .then(function (res) { + .then(function(res) { res.forEach((response, index) => { const copiedCannedResponse = new CannedResponse({ campaign_id: newCampaignId, title: response.title, text: response.text - }).save() - }) - }) + }).save(); + }); + }); - await createCannedResponses + await createCannedResponses; - return newCampaign + return newCampaign; }, unarchiveCampaign: async (_, { id }, { user, loaders }) => { - const campaign = await loaders.campaign.load(id) - await accessRequired(user, campaign.organization_id, 'ADMIN') - campaign.is_archived = false - await campaign.save() - await cacheableData.campaign.clear(id) - return campaign + const campaign = await loaders.campaign.load(id); + await accessRequired(user, campaign.organization_id, "ADMIN"); + campaign.is_archived = false; + await campaign.save(); + await cacheableData.campaign.clear(id); + return campaign; }, archiveCampaign: async (_, { id }, { user, loaders }) => { - const campaign = await loaders.campaign.load(id) - await accessRequired(user, campaign.organization_id, 'ADMIN') - campaign.is_archived = true - await campaign.save() - await cacheableData.campaign.clear(id) - return campaign + const campaign = await loaders.campaign.load(id); + await accessRequired(user, campaign.organization_id, "ADMIN"); + campaign.is_archived = true; + await campaign.save(); + await cacheableData.campaign.clear(id); + return campaign; }, archiveCampaigns: async (_, { ids }, { user, loaders }) => { // Take advantage of the cache instead of running a DB query - const campaigns = await Promise.all(ids.map(id => ( - loaders.campaign.load(id) - ))) - - await Promise.all(campaigns.map(campaign => ( - accessRequired(user, campaign.organization_id, 'ADMIN') - ))) - - campaigns.forEach(campaign => { campaign.is_archived = true }) - await Promise.all(campaigns.map(async (campaign) => { - await campaign.save() - await cacheableData.campaign.clear(campaign.id) - })) - return campaigns + const campaigns = await Promise.all( + ids.map(id => loaders.campaign.load(id)) + ); + + await Promise.all( + campaigns.map(campaign => + accessRequired(user, campaign.organization_id, "ADMIN") + ) + ); + + campaigns.forEach(campaign => { + campaign.is_archived = true; + }); + await Promise.all( + campaigns.map(async campaign => { + await campaign.save(); + await cacheableData.campaign.clear(campaign.id); + }) + ); + return campaigns; }, startCampaign: async (_, { id }, { user, loaders }) => { - const campaign = await loaders.campaign.load(id) - await accessRequired(user, campaign.organization_id, 'ADMIN') - campaign.is_started = true + const campaign = await loaders.campaign.load(id); + await accessRequired(user, campaign.organization_id, "ADMIN"); + campaign.is_started = true; - await campaign.save() - cacheableData.campaign.reload(id) + await campaign.save(); + cacheableData.campaign.reload(id); await sendUserNotification({ type: Notifications.CAMPAIGN_STARTED, campaignId: id - }) - return campaign + }); + return campaign; }, editCampaign: async (_, { id, campaign }, { user, loaders }) => { - const origCampaign = await Campaign.get(id) + const origCampaign = await Campaign.get(id); if (campaign.organizationId) { - await accessRequired(user, campaign.organizationId, 'ADMIN') + await accessRequired(user, campaign.organizationId, "ADMIN"); } else { - await accessRequired(user, origCampaign.organization_id, 'SUPERVOLUNTEER') + await accessRequired( + user, + origCampaign.organization_id, + "SUPERVOLUNTEER" + ); } - if (origCampaign.is_started && campaign.hasOwnProperty('contacts') && campaign.contacts) { + if ( + origCampaign.is_started && + campaign.hasOwnProperty("contacts") && + campaign.contacts + ) { throw new GraphQLError({ status: 400, - message: 'Not allowed to add contacts after the campaign starts' - }) + message: "Not allowed to add contacts after the campaign starts" + }); } - return editCampaign(id, campaign, loaders, user, origCampaign) + return editCampaign(id, campaign, loaders, user, origCampaign); }, deleteJob: async (_, { campaignId, id }, { user, loaders }) => { - const campaign = await Campaign.get(campaignId) - await accessRequired(user, campaign.organization_id, 'ADMIN') - const res = await r.knex('job_request') + const campaign = await Campaign.get(campaignId); + await accessRequired(user, campaign.organization_id, "ADMIN"); + const res = await r + .knex("job_request") .where({ id, campaign_id: campaignId }) - .delete() - return { id } + .delete(); + return { id }; }, createCannedResponse: async (_, { cannedResponse }, { user, loaders }) => { - authRequired(user) + authRequired(user); const cannedResponseInstance = new CannedResponse({ campaign_id: cannedResponse.campaignId, user_id: cannedResponse.userId, title: cannedResponse.title, text: cannedResponse.text - }).save() + }).save(); // deletes duplicate created canned_responses let query = r - .knex('canned_response') + .knex("canned_response") .where( - 'text', - 'in', + "text", + "in", r - .knex('canned_response') + .knex("canned_response") .where({ text: cannedResponse.text, campaign_id: cannedResponse.campaignId }) - .select('text') + .select("text") ) .andWhere({ user_id: cannedResponse.userId }) - .del() - await query + .del(); + await query; cacheableData.cannedResponse.clearQuery({ campaignId: cannedResponse.campaignId, userId: cannedResponse.userId - }) + }); }, - createOrganization: async (_, { name, userId, inviteId }, { loaders, user }) => { - authRequired(user) - const invite = await loaders.invite.load(inviteId) + createOrganization: async ( + _, + { name, userId, inviteId }, + { loaders, user } + ) => { + authRequired(user); + const invite = await loaders.invite.load(inviteId); if (!invite || !invite.is_valid) { throw new GraphQLError({ status: 400, - message: 'That invitation is no longer valid' - }) + message: "That invitation is no longer valid" + }); } const newOrganization = await Organization.save({ name, uuid: uuidv4() - }) + }); await UserOrganization.save({ - role: 'OWNER', + role: "OWNER", user_id: userId, organization_id: newOrganization.id - }) + }); await Invite.save( { id: inviteId, is_valid: false }, - { conflict: 'update' } - ) + { conflict: "update" } + ); - return newOrganization + return newOrganization; }, editCampaignContactMessageStatus: async ( _, { messageStatus, campaignContactId }, { loaders, user } ) => { - const contact = await loaders.campaignContact.load(campaignContactId) - await assignmentRequired(user, contact.assignment_id) - contact.message_status = messageStatus - return await contact.save() + const contact = await loaders.campaignContact.load(campaignContactId); + await assignmentRequired(user, contact.assignment_id); + contact.message_status = messageStatus; + return await contact.save(); }, - getAssignmentContacts: async (_, { assignmentId, contactIds, findNew }, { loaders, user }) => { - await assignmentRequired(user, assignmentId) - const contacts = contactIds.map(async (contactId) => { - const contact = await loaders.campaignContact.load(contactId) + getAssignmentContacts: async ( + _, + { assignmentId, contactIds, findNew }, + { loaders, user } + ) => { + await assignmentRequired(user, assignmentId); + const contacts = contactIds.map(async contactId => { + const contact = await loaders.campaignContact.load(contactId); if (contact && contact.assignment_id === Number(assignmentId)) { - return contact + return contact; } - return null - }) + return null; + }); if (findNew) { // maybe TODO: we could automatically add dynamic assignments in the same api call // findNewCampaignContact() } - return contacts + return contacts; }, - findNewCampaignContact: async (_, { assignmentId, numberContacts }, { loaders, user }) => { + findNewCampaignContact: async ( + _, + { assignmentId, numberContacts }, + { loaders, user } + ) => { /* This attempts to find a new contact for the assignment, in the case that useDynamicAssigment == true */ - const assignment = await Assignment.get(assignmentId) - await assignmentRequired(user, assignmentId, assignment) + const assignment = await Assignment.get(assignmentId); + await assignmentRequired(user, assignmentId, assignment); - const campaign = await Campaign.get(assignment.campaign_id) + const campaign = await Campaign.get(assignment.campaign_id); if (!campaign.use_dynamic_assignment || assignment.max_contacts === 0) { - return { found: false } + return { found: false }; } const contactsCount = await r.getCount( - r.knex('campaign_contact').where('assignment_id', assignmentId) - ) - - numberContacts = numberContacts || 1 - if (assignment.max_contacts && contactsCount + numberContacts > assignment.max_contacts) { - numberContacts = assignment.max_contacts - contactsCount + r.knex("campaign_contact").where("assignment_id", assignmentId) + ); + + numberContacts = numberContacts || 1; + if ( + assignment.max_contacts && + contactsCount + numberContacts > assignment.max_contacts + ) { + numberContacts = assignment.max_contacts - contactsCount; } // Don't add more if they already have that many const result = await r.getCount( - r.knex('campaign_contact').where({ + r.knex("campaign_contact").where({ assignment_id: assignmentId, - message_status: 'needsMessage', + message_status: "needsMessage", is_opted_out: false }) - ) + ); if (result >= numberContacts) { - return { found: false } + return { found: false }; } const updatedCount = await r - .knex('campaign_contact') + .knex("campaign_contact") .where( - 'id', - 'in', + "id", + "in", r - .knex('campaign_contact') + .knex("campaign_contact") .where({ assignment_id: null, campaign_id: campaign.id }) .limit(numberContacts) - .select('id') + .select("id") ) .update({ assignment_id: assignmentId }) - .catch(log.error) + .catch(log.error); if (updatedCount > 0) { - return { found: true } + return { found: true }; } else { - return { found: false } + return { found: false }; } }, - createOptOut: async (_, { optOut, campaignContactId }, { loaders, user }) => { - const contact = await loaders.campaignContact.load(campaignContactId) - await assignmentRequired(user, contact.assignment_id) + createOptOut: async ( + _, + { optOut, campaignContactId }, + { loaders, user } + ) => { + const contact = await loaders.campaignContact.load(campaignContactId); + await assignmentRequired(user, contact.assignment_id); - const { assignmentId, cell, reason } = optOut - const campaign = await loaders.campaign.load(contact.campaign_id) + const { assignmentId, cell, reason } = optOut; + const campaign = await loaders.campaign.load(contact.campaign_id); await cacheableData.optOut.save({ cell, @@ -863,91 +967,97 @@ const rootMutations = { reason, assignmentId, campaign - }) + }); - return loaders.campaignContact.load(campaignContactId) + return loaders.campaignContact.load(campaignContactId); }, bulkSendMessages: async (_, { assignmentId }, loaders) => { if (!process.env.ALLOW_SEND_ALL || !process.env.NOT_IN_USA) { - log.error('Not allowed to send all messages at once') + log.error("Not allowed to send all messages at once"); throw new GraphQLError({ status: 403, - message: 'Not allowed to send all messages at once' - }) + message: "Not allowed to send all messages at once" + }); } - const assignment = await Assignment.get(assignmentId) - const campaign = await Campaign.get(assignment.campaign_id) + const assignment = await Assignment.get(assignmentId); + const campaign = await Campaign.get(assignment.campaign_id); // Assign some contacts await rootMutations.RootMutation.findNewCampaignContact( _, - { assignmentId, numberContacts: Number(process.env.BULK_SEND_CHUNK_SIZE) - 1 }, + { + assignmentId, + numberContacts: Number(process.env.BULK_SEND_CHUNK_SIZE) - 1 + }, loaders - ) + ); const contacts = await r - .knex('campaign_contact') - .where({ message_status: 'needsMessage' }) + .knex("campaign_contact") + .where({ message_status: "needsMessage" }) .where({ assignment_id: assignmentId }) - .orderByRaw('updated_at') - .limit(process.env.BULK_SEND_CHUNK_SIZE) + .orderByRaw("updated_at") + .limit(process.env.BULK_SEND_CHUNK_SIZE); - const texter = camelCaseKeys(await User.get(assignment.user_id)) - const customFields = Object.keys(JSON.parse(contacts[0].custom_fields)) + const texter = camelCaseKeys(await User.get(assignment.user_id)); + const customFields = Object.keys(JSON.parse(contacts[0].custom_fields)); const contactMessages = await contacts.map(async contact => { const script = await campaignContactResolvers.CampaignContact.currentInteractionStepScript( contact - ) - contact.customFields = contact.custom_fields + ); + contact.customFields = contact.custom_fields; const text = applyScript({ contact: camelCaseKeys(contact), texter, script, customFields - }) + }); const contactMessage = { contactNumber: contact.cell, userId: assignment.user_id, text, assignmentId - } + }; await rootMutations.RootMutation.sendMessage( _, { message: contactMessage, campaignContactId: contact.id }, loaders - ) - }) + ); + }); - return [] + return []; }, sendMessage: async (_, { message, campaignContactId }, { loaders }) => { - const contact = await loaders.campaignContact.load(campaignContactId) - const campaign = await loaders.campaign.load(contact.campaign_id) - if (contact.assignment_id !== parseInt(message.assignmentId) || campaign.is_archived) { + const contact = await loaders.campaignContact.load(campaignContactId); + const campaign = await loaders.campaign.load(contact.campaign_id); + if ( + contact.assignment_id !== parseInt(message.assignmentId) || + campaign.is_archived + ) { throw new GraphQLError({ status: 400, - message: 'Your assignment has changed' - }) + message: "Your assignment has changed" + }); } const organization = await r - .table('campaign') + .table("campaign") .get(contact.campaign_id) - .eqJoin('organization_id', r.table('organization'))('right') + .eqJoin("organization_id", r.table("organization"))("right"); - const orgFeatures = JSON.parse(organization.features || '{}') + const orgFeatures = JSON.parse(organization.features || "{}"); const optOut = await r - .table('opt_out') - .getAll(contact.cell, { index: 'cell' }) + .table("opt_out") + .getAll(contact.cell, { index: "cell" }) .filter({ organization_id: organization.id }) .limit(1)(0) - .default(null) + .default(null); if (optOut) { throw new GraphQLError({ status: 400, - message: 'Skipped sending because this contact was already opted out' - }) + message: "Skipped sending because this contact was already opted out" + }); } // const zipData = await r.table('zip_code') @@ -967,106 +1077,121 @@ const rootMutations = { // }) // } - const { contactNumber, text } = message + const { contactNumber, text } = message; if (text.length > (process.env.MAX_MESSAGE_LENGTH || 99999)) { throw new GraphQLError({ status: 400, - message: 'Message was longer than the limit' - }) + message: "Message was longer than the limit" + }); } - const replaceCurlyApostrophes = rawText => rawText.replace(/[\u2018\u2019]/g, "'") + const replaceCurlyApostrophes = rawText => + rawText.replace(/[\u2018\u2019]/g, "'"); - let contactTimezone = {} + let contactTimezone = {}; if (contact.timezone_offset) { // couldn't look up the timezone by zip record, so we load it // from the campaign_contact directly if it's there - const [offset, hasDST] = contact.timezone_offset.split('_') - contactTimezone.offset = parseInt(offset, 10) - contactTimezone.hasDST = hasDST === '1' + const [offset, hasDST] = contact.timezone_offset.split("_"); + contactTimezone.offset = parseInt(offset, 10); + contactTimezone.hasDST = hasDST === "1"; } const sendBefore = getSendBeforeTimeUtc( contactTimezone, - { textingHoursEnd: organization.texting_hours_end, textingHoursEnforced: organization.texting_hours_enforced }, + { + textingHoursEnd: organization.texting_hours_end, + textingHoursEnforced: organization.texting_hours_enforced + }, { textingHoursEnd: campaign.texting_hours_end, - overrideOrganizationTextingHours: campaign.override_organization_texting_hours, + overrideOrganizationTextingHours: + campaign.override_organization_texting_hours, textingHoursEnforced: campaign.texting_hours_enforced, timezone: campaign.timezone } - ) + ); - const sendBeforeDate = sendBefore ? sendBefore.toDate() : null + const sendBeforeDate = sendBefore ? sendBefore.toDate() : null; if (sendBeforeDate && sendBeforeDate <= Date.now()) { throw new GraphQLError({ status: 400, - message: 'Outside permitted texting time for this recipient' - }) + message: "Outside permitted texting time for this recipient" + }); } const messageInstance = new Message({ text: replaceCurlyApostrophes(text), contact_number: contactNumber, - user_number: '', + user_number: "", assignment_id: message.assignmentId, - send_status: JOBS_SAME_PROCESS ? 'SENDING' : 'QUEUED', - service: orgFeatures.service || process.env.DEFAULT_SERVICE || '', + send_status: JOBS_SAME_PROCESS ? "SENDING" : "QUEUED", + service: orgFeatures.service || process.env.DEFAULT_SERVICE || "", is_from_contact: false, queued_at: new Date(), send_before: sendBeforeDate - }) - - await messageInstance.save() - const service = serviceMap[messageInstance.service || process.env.DEFAULT_SERVICE || global.DEFAULT_SERVICE] - - contact.updated_at = 'now()' - - if (contact.message_status === 'needsResponse' || contact.message_status === 'convo') { - contact.message_status = 'convo' + }); + + await messageInstance.save(); + const service = + serviceMap[ + messageInstance.service || + process.env.DEFAULT_SERVICE || + global.DEFAULT_SERVICE + ]; + + contact.updated_at = "now()"; + + if ( + contact.message_status === "needsResponse" || + contact.message_status === "convo" + ) { + contact.message_status = "convo"; } else { - contact.message_status = 'messaged' + contact.message_status = "messaged"; } - await contact.save() + await contact.save(); log.info( - `Sending (${service}): ${messageInstance.user_number} -> ${ - messageInstance.contact_number - }\nMessage: ${messageInstance.text}` - ) + `Sending (${service}): ${messageInstance.user_number} -> ${messageInstance.contact_number}\nMessage: ${messageInstance.text}` + ); - service.sendMessage(messageInstance, contact) - return contact + service.sendMessage(messageInstance, contact); + return contact; }, deleteQuestionResponses: async ( _, { interactionStepIds, campaignContactId }, { loaders, user } ) => { - const contact = await loaders.campaignContact.load(campaignContactId) - await assignmentRequired(user, contact.assignment_id) + const contact = await loaders.campaignContact.load(campaignContactId); + await assignmentRequired(user, contact.assignment_id); // TODO: maybe undo action_handler await r - .table('question_response') - .getAll(campaignContactId, { index: 'campaign_contact_id' }) - .getAll(...interactionStepIds, { index: 'interaction_step_id' }) - .delete() - return contact + .table("question_response") + .getAll(campaignContactId, { index: "campaign_contact_id" }) + .getAll(...interactionStepIds, { index: "interaction_step_id" }) + .delete(); + return contact; }, - updateQuestionResponses: async (_, { questionResponses, campaignContactId }, { loaders }) => { - const count = questionResponses.length + updateQuestionResponses: async ( + _, + { questionResponses, campaignContactId }, + { loaders } + ) => { + const count = questionResponses.length; for (let i = 0; i < count; i++) { - const questionResponse = questionResponses[i] - const { interactionStepId, value } = questionResponse + const questionResponse = questionResponses[i]; + const { interactionStepId, value } = questionResponse; await r - .table('question_response') - .getAll(campaignContactId, { index: 'campaign_contact_id' }) + .table("question_response") + .getAll(campaignContactId, { index: "campaign_contact_id" }) .filter({ interaction_step_id: interactionStepId }) - .delete() + .delete(); // TODO: maybe undo action_handler if updated answer @@ -1074,37 +1199,42 @@ const rootMutations = { campaign_contact_id: campaignContactId, interaction_step_id: interactionStepId, value - }).save() + }).save(); const interactionStepResult = await r - .knex('interaction_step') + .knex("interaction_step") // TODO: is this really parent_interaction_id or just interaction_id? .where({ parent_interaction_id: interactionStepId, answer_option: value }) - .whereNot('answer_actions', '') - .whereNotNull('answer_actions') + .whereNot("answer_actions", "") + .whereNotNull("answer_actions"); const interactionStepAction = - interactionStepResult.length && interactionStepResult[0].answer_actions + interactionStepResult.length && + interactionStepResult[0].answer_actions; if (interactionStepAction) { // run interaction step handler try { - const handler = require(`../action_handlers/${interactionStepAction}.js`) - handler.processAction(qr, interactionStepResult[0], campaignContactId) + const handler = require(`../action_handlers/${interactionStepAction}.js`); + handler.processAction( + qr, + interactionStepResult[0], + campaignContactId + ); } catch (err) { console.error( - 'Handler for InteractionStep', + "Handler for InteractionStep", interactionStepId, - 'Does Not Exist:', + "Does Not Exist:", interactionStepAction - ) + ); } } } - const contact = loaders.campaignContact.load(campaignContactId) - return contact + const contact = loaders.campaignContact.load(campaignContactId); + return contact; }, reassignCampaignContacts: async ( _, @@ -1112,82 +1242,101 @@ const rootMutations = { { user } ) => { // verify permissions - await accessRequired(user, organizationId, 'ADMIN', /* superadmin*/ true) + await accessRequired(user, organizationId, "ADMIN", /* superadmin*/ true); // group contactIds by campaign // group messages by campaign - const campaignIdContactIdsMap = new Map() - const campaignIdMessagesIdsMap = new Map() + const campaignIdContactIdsMap = new Map(); + const campaignIdMessagesIdsMap = new Map(); for (const campaignIdContactId of campaignIdsContactIds) { - const { campaignId, campaignContactId, messageIds } = campaignIdContactId + const { + campaignId, + campaignContactId, + messageIds + } = campaignIdContactId; if (!campaignIdContactIdsMap.has(campaignId)) { - campaignIdContactIdsMap.set(campaignId, []) + campaignIdContactIdsMap.set(campaignId, []); } - campaignIdContactIdsMap.get(campaignId).push(campaignContactId) + campaignIdContactIdsMap.get(campaignId).push(campaignContactId); if (!campaignIdMessagesIdsMap.has(campaignId)) { - campaignIdMessagesIdsMap.set(campaignId, []) + campaignIdMessagesIdsMap.set(campaignId, []); } - campaignIdMessagesIdsMap.get(campaignId).push(...messageIds) + campaignIdMessagesIdsMap.get(campaignId).push(...messageIds); } - return await reassignConversations(campaignIdContactIdsMap, campaignIdMessagesIdsMap, newTexterUserId) + return await reassignConversations( + campaignIdContactIdsMap, + campaignIdMessagesIdsMap, + newTexterUserId + ); }, bulkReassignCampaignContacts: async ( _, - { organizationId, campaignsFilter, assignmentsFilter, contactsFilter, newTexterUserId }, + { + organizationId, + campaignsFilter, + assignmentsFilter, + contactsFilter, + newTexterUserId + }, { user } ) => { // verify permissions - await accessRequired(user, organizationId, 'ADMIN', /* superadmin*/ true) - const { campaignIdContactIdsMap, campaignIdMessagesIdsMap } = - await getCampaignIdMessageIdsAndCampaignIdContactIdsMaps( - organizationId, - campaignsFilter, - assignmentsFilter, - contactsFilter - ) - - return await reassignConversations(campaignIdContactIdsMap, campaignIdMessagesIdsMap, newTexterUserId) + await accessRequired(user, organizationId, "ADMIN", /* superadmin*/ true); + const { + campaignIdContactIdsMap, + campaignIdMessagesIdsMap + } = await getCampaignIdMessageIdsAndCampaignIdContactIdsMaps( + organizationId, + campaignsFilter, + assignmentsFilter, + contactsFilter + ); + + return await reassignConversations( + campaignIdContactIdsMap, + campaignIdMessagesIdsMap, + newTexterUserId + ); }, - importCampaignScript: async (_, { - campaignId, - url - }, { - loaders - }) => { - const campaign = await loaders.campaign.load(campaignId) + importCampaignScript: async (_, { campaignId, url }, { loaders }) => { + const campaign = await loaders.campaign.load(campaignId); if (campaign.is_started || campaign.is_archived) { - throw new GraphQLError('Cannot import a campaign script for a campaign that is started or archived') + throw new GraphQLError( + "Cannot import a campaign script for a campaign that is started or archived" + ); } - const compressedString = await gzip(JSON.stringify({ - campaignId, - url - })) + const compressedString = await gzip( + JSON.stringify({ + campaignId, + url + }) + ); const job = await JobRequest.save({ queue_name: `${campaignId}:import_script`, - job_type: 'import_script', + job_type: "import_script", locks_queue: true, assigned: JOBS_SAME_PROCESS, // can get called immediately, below campaign_id: campaignId, // NOTE: stringifying because compressedString is a binary buffer - payload: compressedString.toString('base64') - }) + payload: compressedString.toString("base64") + }); - const jobId = job.id + const jobId = job.id; if (JOBS_SAME_PROCESS) { - importScript(job) + importScript(job); } - return jobId + return jobId; } } -} +}; const rootResolvers = { Action: { @@ -1200,79 +1349,90 @@ const rootResolvers = { }, RootQuery: { campaign: async (_, { id }, { loaders, user }) => { - const campaign = await loaders.campaign.load(id) - await accessRequired(user, campaign.organization_id, 'SUPERVOLUNTEER') - return campaign + const campaign = await loaders.campaign.load(id); + await accessRequired(user, campaign.organization_id, "SUPERVOLUNTEER"); + return campaign; }, assignment: async (_, { id }, { loaders, user }) => { - authRequired(user) - const assignment = await loaders.assignment.load(id) - const campaign = await loaders.campaign.load(assignment.campaign_id) + authRequired(user); + const assignment = await loaders.assignment.load(id); + const campaign = await loaders.campaign.load(assignment.campaign_id); if (assignment.user_id == user.id) { - await accessRequired(user, campaign.organization_id, 'TEXTER', /* allowSuperadmin=*/ true) + await accessRequired( + user, + campaign.organization_id, + "TEXTER", + /* allowSuperadmin=*/ true + ); } else { await accessRequired( user, campaign.organization_id, - 'SUPERVOLUNTEER', + "SUPERVOLUNTEER", /* allowSuperadmin=*/ true - ) + ); } - return assignment + return assignment; }, organization: async (_, { id }, { user, loaders }) => { - await accessRequired(user, id, 'TEXTER') - return await loaders.organization.load(id) + await accessRequired(user, id, "TEXTER"); + return await loaders.organization.load(id); }, inviteByHash: async (_, { hash }, { loaders, user }) => { - authRequired(user) - return r.table('invite').filter({ hash }) + authRequired(user); + return r.table("invite").filter({ hash }); }, currentUser: async (_, { id }, { user }) => { if (!user) { - return null - } - else { - return user + return null; + } else { + return user; } }, organizations: async (_, { id }, { user }) => { if (user.is_superadmin) { - return r.table('organization') + return r.table("organization"); } else { - return await cacheableData.user.userOrgs(user.id, 'TEXTER') + return await cacheableData.user.userOrgs(user.id, "TEXTER"); } }, availableActions: (_, { organizationId }, { user }) => { if (!process.env.ACTION_HANDLERS) { - return [] + return []; } - const allHandlers = process.env.ACTION_HANDLERS.split(',') + const allHandlers = process.env.ACTION_HANDLERS.split(","); const availableHandlers = allHandlers .map(handler => { return { name: handler, handler: require(`../action_handlers/${handler}.js`) - } + }; }) - .filter(async h => h && (await h.handler.available(organizationId))) + .filter(async h => h && (await h.handler.available(organizationId))); const availableHandlerObjects = availableHandlers.map(handler => { return { name: handler.name, display_name: handler.handler.displayName(), instructions: handler.handler.instructions() - } - }) - return availableHandlerObjects + }; + }); + return availableHandlerObjects; }, conversations: async ( _, - { cursor, organizationId, campaignsFilter, assignmentsFilter, contactsFilter, utc }, + { + cursor, + organizationId, + campaignsFilter, + assignmentsFilter, + contactsFilter, + utc + }, { user } ) => { - await accessRequired(user, organizationId, 'SUPERVOLUNTEER', true) + await accessRequired(user, organizationId, "SUPERVOLUNTEER", true); return getConversations( cursor, @@ -1281,18 +1441,26 @@ const rootResolvers = { assignmentsFilter, contactsFilter, utc - ) + ); }, - campaigns: async (_, { organizationId, cursor, campaignsFilter }, { user }) => { - await accessRequired(user, organizationId, 'SUPERVOLUNTEER') - return getCampaigns(organizationId, cursor, campaignsFilter) + campaigns: async ( + _, + { organizationId, cursor, campaignsFilter }, + { user } + ) => { + await accessRequired(user, organizationId, "SUPERVOLUNTEER"); + return getCampaigns(organizationId, cursor, campaignsFilter); }, - people: async (_, { organizationId, cursor, campaignsFilter, role, sortBy }, { user }) => { - await accessRequired(user, organizationId, 'SUPERVOLUNTEER') - return getUsers(organizationId, cursor, campaignsFilter, role, sortBy) + people: async ( + _, + { organizationId, cursor, campaignsFilter, role, sortBy }, + { user } + ) => { + await accessRequired(user, organizationId, "SUPERVOLUNTEER"); + return getUsers(organizationId, cursor, campaignsFilter, role, sortBy); } } -} +}; export const resolvers = { ...rootResolvers, @@ -1313,4 +1481,4 @@ export const resolvers = { ...questionResolvers, ...conversationsResolver, ...rootMutations -} +}; diff --git a/src/server/api/user.js b/src/server/api/user.js index 2f3bb4be6..b62f048ef 100644 --- a/src/server/api/user.js +++ b/src/server/api/user.js @@ -1,113 +1,151 @@ -import { mapFieldsToModel } from './lib/utils' -import { r, User, cacheableData } from '../models' -import { addCampaignsFilterToQuery } from './campaign' +import { mapFieldsToModel } from "./lib/utils"; +import { r, User, cacheableData } from "../models"; +import { addCampaignsFilterToQuery } from "./campaign"; -const firstName = '"user"."first_name"' -const lastName = '"user"."last_name"' -const created = '"user"."created_at"' -const oldest = created -const newest = '"user"."created_at" desc' +const firstName = '"user"."first_name"'; +const lastName = '"user"."last_name"'; +const created = '"user"."created_at"'; +const oldest = created; +const newest = '"user"."created_at" desc'; function buildSelect(sortBy) { - const userStar = '"user".*' + const userStar = '"user".*'; - let fragmentArray = undefined + let fragmentArray = undefined; switch (sortBy) { - case 'COUNT_ONLY': - return r.knex.countDistinct('user.id') - case 'LAST_NAME': - fragmentArray = [userStar] - break - case 'NEWEST': - fragmentArray = [userStar] - break - case 'OLDEST': - fragmentArray = [userStar] - break - case 'FIRST_NAME': + case "COUNT_ONLY": + return r.knex.countDistinct("user.id"); + case "LAST_NAME": + fragmentArray = [userStar]; + break; + case "NEWEST": + fragmentArray = [userStar]; + break; + case "OLDEST": + fragmentArray = [userStar]; + break; + case "FIRST_NAME": default: - fragmentArray = [userStar] - break + fragmentArray = [userStar]; + break; } - return r.knex.select(r.knex.raw(fragmentArray.join(', '))) + return r.knex.select(r.knex.raw(fragmentArray.join(", "))); } function buildOrderBy(query, sortBy) { - let fragmentArray = undefined + let fragmentArray = undefined; switch (sortBy) { - case 'COUNT_ONLY': - return query - case 'LAST_NAME': - fragmentArray = [lastName, firstName, newest] - break - case 'NEWEST': - fragmentArray = [newest] - break - case 'OLDEST': - fragmentArray = [oldest] - break - case 'FIRST_NAME': + case "COUNT_ONLY": + return query; + case "LAST_NAME": + fragmentArray = [lastName, firstName, newest]; + break; + case "NEWEST": + fragmentArray = [newest]; + break; + case "OLDEST": + fragmentArray = [oldest]; + break; + case "FIRST_NAME": default: - fragmentArray = [firstName, lastName, newest] - break + fragmentArray = [firstName, lastName, newest]; + break; } - return query.orderByRaw(fragmentArray.join(', ')) + return query.orderByRaw(fragmentArray.join(", ")); } -export function buildUserOrganizationQuery(queryParam, organizationId, role, campaignId, offset) { - const roleFilter = role ? { role } : {} +export function buildUserOrganizationQuery( + queryParam, + organizationId, + role, + campaignId, + offset +) { + const roleFilter = role ? { role } : {}; let query = queryParam - .from('user_organization') - .innerJoin('user', 'user_organization.user_id', 'user.id') + .from("user_organization") + .innerJoin("user", "user_organization.user_id", "user.id") .where(roleFilter) .whereRaw('"user_organization"."organization_id" = ?', organizationId) - .distinct() + .distinct(); if (campaignId) { - query = query.leftOuterJoin('assignment', 'assignment.user_id', 'user.id') - .where({ 'assignment.campaign_id': campaignId }) + query = query + .leftOuterJoin("assignment", "assignment.user_id", "user.id") + .where({ "assignment.campaign_id": campaignId }); } - return query + return query; } -export function buildSortedUserOrganizationQuery(organizationId, role, campaignId, sortBy) { - const query = buildUserOrganizationQuery(buildSelect(sortBy), organizationId, role, campaignId) - return buildOrderBy(query, sortBy) +export function buildSortedUserOrganizationQuery( + organizationId, + role, + campaignId, + sortBy +) { + const query = buildUserOrganizationQuery( + buildSelect(sortBy), + organizationId, + role, + campaignId + ); + return buildOrderBy(query, sortBy); } function buildUsersQuery(organizationId, campaignsFilter, role, sortBy) { - return buildSortedUserOrganizationQuery(organizationId, role, campaignsFilter && campaignsFilter.campaignId, sortBy) + return buildSortedUserOrganizationQuery( + organizationId, + role, + campaignsFilter && campaignsFilter.campaignId, + sortBy + ); } -export async function getUsers(organizationId, cursor, campaignsFilter, role, sortBy) { - let usersQuery = buildUsersQuery(organizationId, campaignsFilter, role, sortBy) +export async function getUsers( + organizationId, + cursor, + campaignsFilter, + role, + sortBy +) { + let usersQuery = buildUsersQuery( + organizationId, + campaignsFilter, + role, + sortBy + ); if (cursor) { - usersQuery = usersQuery.limit(cursor.limit).offset(cursor.offset) - const users = await usersQuery + usersQuery = usersQuery.limit(cursor.limit).offset(cursor.offset); + const users = await usersQuery; - const usersCountQuery = buildUsersQuery(organizationId, campaignsFilter, role, 'COUNT_ONLY') + const usersCountQuery = buildUsersQuery( + organizationId, + campaignsFilter, + role, + "COUNT_ONLY" + ); - const usersCountArray = await usersCountQuery + const usersCountArray = await usersCountQuery; const pageInfo = { limit: cursor.limit, offset: cursor.offset, total: usersCountArray[0].count - } + }; return { users, pageInfo - } + }; } else { - return usersQuery + return usersQuery; } } @@ -115,11 +153,11 @@ export const resolvers = { UsersReturn: { __resolveType(obj) { if (Array.isArray(obj)) { - return 'UsersList' - } else if ('users' in obj && 'pageInfo' in obj) { - return 'PaginatedUsers' + return "UsersList"; + } else if ("users" in obj && "pageInfo" in obj) { + return "PaginatedUsers"; } - return null + return null; } }, UsersList: { @@ -128,55 +166,56 @@ export const resolvers = { PaginatedUsers: { users: queryResult => queryResult.users, pageInfo: queryResult => { - if ('pageInfo' in queryResult) { - return queryResult.pageInfo + if ("pageInfo" in queryResult) { + return queryResult.pageInfo; } - return null + return null; } }, User: { - ...mapFieldsToModel([ - 'id', - 'firstName', - 'lastName', - 'email', - 'cell', - 'assignedCell', - 'terms' - ], User), - displayName: (user) => `${user.first_name} ${user.last_name}`, + ...mapFieldsToModel( + ["id", "firstName", "lastName", "email", "cell", "assignedCell", "terms"], + User + ), + displayName: user => `${user.first_name} ${user.last_name}`, assignment: async (user, { campaignId }) => { - if (user.assignment_id && user.assignment_campaign_id === Number(campaignId)) { + if ( + user.assignment_id && + user.assignment_campaign_id === Number(campaignId) + ) { // from context of campaign.texters.assignment - return { id: user.assignment_id, - campaign_id: user.assignment_campaign_id, - max_contacts: user.assignment_max_contacts } + return { + id: user.assignment_id, + campaign_id: user.assignment_campaign_id, + max_contacts: user.assignment_max_contacts + }; } - return r.table('assignment') - .getAll(user.id, { index: 'user_id' }) + return r + .table("assignment") + .getAll(user.id, { index: "user_id" }) .filter({ campaign_id: campaignId }) .limit(1)(0) - .default(null) + .default(null); }, organizations: async (user, { role }) => { if (!user || !user.id) { - return [] + return []; } // Note: this only returns {id, name}, but that is all apis need here - return await cacheableData.user.userOrgs(user.id, role) + return await cacheableData.user.userOrgs(user.id, role); }, - roles: async(user, { organizationId }) => ( - cacheableData.user.orgRoles(user.id, organizationId) - ), - todos: async (user, { organizationId }) => ( - r.table('assignment') - .getAll(user.id, { index: 'assignment.user_id' }) - .eqJoin('campaign_id', r.table('campaign')) - .filter({ 'is_started': true, - 'organization_id': organizationId, - 'is_archived': false } - )('left') - ), + roles: async (user, { organizationId }) => + cacheableData.user.orgRoles(user.id, organizationId), + todos: async (user, { organizationId }) => + r + .table("assignment") + .getAll(user.id, { index: "assignment.user_id" }) + .eqJoin("campaign_id", r.table("campaign")) + .filter({ + is_started: true, + organization_id: organizationId, + is_archived: false + })("left"), cacheable: () => false // FUTURE: Boolean(r.redis) when full assignment data is cached } -} +}; diff --git a/src/server/auth-passport.js b/src/server/auth-passport.js index 2be73f46e..21827b6e2 100644 --- a/src/server/auth-passport.js +++ b/src/server/auth-passport.js @@ -1,126 +1,139 @@ -import passport from 'passport' -import Auth0Strategy from 'passport-auth0' -import { Strategy as LocalStrategy } from 'passport-local' -import { User, cacheableData } from './models' -import localAuthHelpers from './local-auth-helpers' -import wrap from './wrap' -import { capitalizeWord } from './api/lib/utils' - +import passport from "passport"; +import Auth0Strategy from "passport-auth0"; +import { Strategy as LocalStrategy } from "passport-local"; +import { User, cacheableData } from "./models"; +import localAuthHelpers from "./local-auth-helpers"; +import wrap from "./wrap"; +import { capitalizeWord } from "./api/lib/utils"; export function setupAuth0Passport() { - const strategy = new Auth0Strategy({ - domain: process.env.AUTH0_DOMAIN, - clientID: process.env.AUTH0_CLIENT_ID, - clientSecret: process.env.AUTH0_CLIENT_SECRET, - callbackURL: `${process.env.BASE_URL}/login-callback` - }, (accessToken, refreshToken, extraParams, profile, done) => done(null, profile) - ) + const strategy = new Auth0Strategy( + { + domain: process.env.AUTH0_DOMAIN, + clientID: process.env.AUTH0_CLIENT_ID, + clientSecret: process.env.AUTH0_CLIENT_SECRET, + callbackURL: `${process.env.BASE_URL}/login-callback` + }, + (accessToken, refreshToken, extraParams, profile, done) => + done(null, profile) + ); - passport.use(strategy) + passport.use(strategy); passport.serializeUser((user, done) => { // This is the Auth0 user object, not the db one // eslint-disable-next-line no-underscore-dangle - const auth0Id = (user.id || user._json.sub) - done(null, auth0Id) - }) + const auth0Id = user.id || user._json.sub; + done(null, auth0Id); + }); - passport.deserializeUser(wrap(async (id, done) => { - // add new cacheable query - const user = await cacheableData.user.userLoggedIn('auth0_id', id) - done(null, user || false) - })) + passport.deserializeUser( + wrap(async (id, done) => { + // add new cacheable query + const user = await cacheableData.user.userLoggedIn("auth0_id", id); + done(null, user || false); + }) + ); return { loginCallback: [ - passport.authenticate('auth0', { failureRedirect: '/login' }), + passport.authenticate("auth0", { failureRedirect: "/login" }), wrap(async (req, res) => { // eslint-disable-next-line no-underscore-dangle - const auth0Id = (req.user && (req.user.id || req.user._json.sub)) + const auth0Id = req.user && (req.user.id || req.user._json.sub); if (!auth0Id) { - throw new Error('Null user in login callback') + throw new Error("Null user in login callback"); } - const existingUser = await User.filter({ auth0_id: auth0Id }) + const existingUser = await User.filter({ auth0_id: auth0Id }); if (existingUser.length === 0) { - const userMetadata = ( + const userMetadata = // eslint-disable-next-line no-underscore-dangle - req.user._json['https://spoke/user_metadata'] + req.user._json["https://spoke/user_metadata"] || // eslint-disable-next-line no-underscore-dangle - || req.user._json.user_metadata - || {}) + req.user._json.user_metadata || + {}; const userData = { auth0_id: auth0Id, // eslint-disable-next-line no-underscore-dangle - first_name: capitalizeWord(userMetadata.given_name) || '', + first_name: capitalizeWord(userMetadata.given_name) || "", // eslint-disable-next-line no-underscore-dangle - last_name: capitalizeWord(userMetadata.family_name) || '', - cell: userMetadata.cell || '', + last_name: capitalizeWord(userMetadata.family_name) || "", + cell: userMetadata.cell || "", // eslint-disable-next-line no-underscore-dangle email: req.user._json.email, is_superadmin: false - } - await User.save(userData) - res.redirect(req.query.state || 'terms') - return + }; + await User.save(userData); + res.redirect(req.query.state || "terms"); + return; } - res.redirect(req.query.state || '/') - return - })] - } + res.redirect(req.query.state || "/"); + return; + }) + ] + }; } export function setupLocalAuthPassport() { - const strategy = new LocalStrategy({ - usernameField: 'email', - passReqToCallback: true - }, wrap(async (req, username, password, done) => { - const lowerCaseEmail = username.toLowerCase() - const existingUser = await User.filter({ email: lowerCaseEmail }) - const nextUrl = req.body.nextUrl || '' - const uuidMatch = nextUrl.match(/\w{8}-(\w{4}\-){3}\w{12}/) + const strategy = new LocalStrategy( + { + usernameField: "email", + passReqToCallback: true + }, + wrap(async (req, username, password, done) => { + const lowerCaseEmail = username.toLowerCase(); + const existingUser = await User.filter({ email: lowerCaseEmail }); + const nextUrl = req.body.nextUrl || ""; + const uuidMatch = nextUrl.match(/\w{8}-(\w{4}\-){3}\w{12}/); - // Run login, signup, or reset functions based on request data - if (req.body.authType && !localAuthHelpers[req.body.authType]) { - return done(null, false) - } - try { - const user = await localAuthHelpers[req.body.authType]({ - lowerCaseEmail, - password, - existingUser, - nextUrl, - uuidMatch, - reqBody: req.body - }) - return done(null, user) - } catch (err) { - return done(null, false, err.message) - } - })) + // Run login, signup, or reset functions based on request data + if (req.body.authType && !localAuthHelpers[req.body.authType]) { + return done(null, false); + } + try { + const user = await localAuthHelpers[req.body.authType]({ + lowerCaseEmail, + password, + existingUser, + nextUrl, + uuidMatch, + reqBody: req.body + }); + return done(null, user); + } catch (err) { + return done(null, false, err.message); + } + }) + ); - passport.use(strategy) + passport.use(strategy); passport.serializeUser((user, done) => { - done(null, user.id) - }) + done(null, user.id); + }); - passport.deserializeUser(wrap(async (id, done) => { - const user = await cacheableData.user.userLoggedIn('id', parseInt(id, 10)) - done(null, user || false) - })) + passport.deserializeUser( + wrap(async (id, done) => { + const user = await cacheableData.user.userLoggedIn( + "id", + parseInt(id, 10) + ); + done(null, user || false); + }) + ); return { loginCallback: [ - passport.authenticate('local'), + passport.authenticate("local"), (req, res) => { - res.redirect(req.body.nextUrl || '/') + res.redirect(req.body.nextUrl || "/"); } ] - } + }; } export default { local: setupLocalAuthPassport, auth0: setupAuth0Passport -} +}; diff --git a/src/server/index.js b/src/server/index.js index 83e5bf96f..992f38e24 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -1,185 +1,209 @@ -import 'babel-polyfill' -import bodyParser from 'body-parser' -import express from 'express' -import appRenderer from './middleware/app-renderer' -import { graphqlExpress, graphiqlExpress } from 'apollo-server-express' -import { makeExecutableSchema, addMockFunctionsToSchema } from 'graphql-tools' +import "babel-polyfill"; +import bodyParser from "body-parser"; +import express from "express"; +import appRenderer from "./middleware/app-renderer"; +import { graphqlExpress, graphiqlExpress } from "apollo-server-express"; +import { makeExecutableSchema, addMockFunctionsToSchema } from "graphql-tools"; // ORDERING: ./models import must be imported above ./api to help circular imports -import { createLoaders, createTablesIfNecessary, r } from './models' -import { resolvers } from './api/schema' -import { schema } from '../api/schema' -import mocks from './api/mocks' -import passport from 'passport' -import cookieSession from 'cookie-session' -import passportSetup from './auth-passport' -import wrap from './wrap' -import { log } from '../lib' -import nexmo from './api/lib/nexmo' -import twilio from './api/lib/twilio' -import { seedZipCodes } from './seeds/seed-zip-codes' -import { setupUserNotificationObservers } from './notifications' -import { TwimlResponse } from 'twilio' -import { existsSync } from 'fs' - -process.on('uncaughtException', (ex) => { - log.error(ex) - process.exit(1) -}) -const DEBUG = process.env.NODE_ENV === 'development' - -const loginCallbacks = passportSetup[process.env.PASSPORT_STRATEGY || global.PASSPORT_STRATEGY || 'auth0']() +import { createLoaders, createTablesIfNecessary, r } from "./models"; +import { resolvers } from "./api/schema"; +import { schema } from "../api/schema"; +import mocks from "./api/mocks"; +import passport from "passport"; +import cookieSession from "cookie-session"; +import passportSetup from "./auth-passport"; +import wrap from "./wrap"; +import { log } from "../lib"; +import nexmo from "./api/lib/nexmo"; +import twilio from "./api/lib/twilio"; +import { seedZipCodes } from "./seeds/seed-zip-codes"; +import { setupUserNotificationObservers } from "./notifications"; +import { TwimlResponse } from "twilio"; +import { existsSync } from "fs"; + +process.on("uncaughtException", ex => { + log.error(ex); + process.exit(1); +}); +const DEBUG = process.env.NODE_ENV === "development"; + +const loginCallbacks = passportSetup[ + process.env.PASSPORT_STRATEGY || global.PASSPORT_STRATEGY || "auth0" +](); if (!process.env.SUPPRESS_SEED_CALLS) { - seedZipCodes() + seedZipCodes(); } if (!process.env.SUPPRESS_DATABASE_AUTOCREATE) { - createTablesIfNecessary().then((didCreate) => { + createTablesIfNecessary().then(didCreate => { // seed above won't have succeeded if we needed to create first if (didCreate && !process.env.SUPPRESS_SEED_CALLS) { - seedZipCodes() + seedZipCodes(); } if (!didCreate && !process.env.SUPPRESS_MIGRATIONS) { - r.k.migrate.latest() + r.k.migrate.latest(); } - }) + }); } else if (!process.env.SUPPRESS_MIGRATIONS) { - r.k.migrate.latest() + r.k.migrate.latest(); } -setupUserNotificationObservers() -const app = express() +setupUserNotificationObservers(); +const app = express(); // Heroku requires you to use process.env.PORT -const port = process.env.DEV_APP_PORT || process.env.PORT +const port = process.env.DEV_APP_PORT || process.env.PORT; // Don't rate limit heroku -app.enable('trust proxy') +app.enable("trust proxy"); // Serve static assets if (existsSync(process.env.ASSETS_DIR)) { - app.use('/assets', express.static(process.env.ASSETS_DIR, { - maxAge: '180 days' - })) + app.use( + "/assets", + express.static(process.env.ASSETS_DIR, { + maxAge: "180 days" + }) + ); } -app.use(bodyParser.json({ limit: '50mb' })) -app.use(bodyParser.urlencoded({ extended: true })) - -app.use(cookieSession({ - cookie: { - httpOnly: true, - secure: !DEBUG, - maxAge: null - }, - secret: process.env.SESSION_SECRET || global.SESSION_SECRET -})) -app.use(passport.initialize()) -app.use(passport.session()) +app.use(bodyParser.json({ limit: "50mb" })); +app.use(bodyParser.urlencoded({ extended: true })); + +app.use( + cookieSession({ + cookie: { + httpOnly: true, + secure: !DEBUG, + maxAge: null + }, + secret: process.env.SESSION_SECRET || global.SESSION_SECRET + }) +); +app.use(passport.initialize()); +app.use(passport.session()); app.use((req, res, next) => { - const getContext = app.get('awsContextGetter') - if (typeof getContext === 'function') { - const [event, context] = getContext(req, res) - req.awsEvent = event - req.awsContext = context - } - next() -}) - -app.post('/nexmo', wrap(async (req, res) => { - try { - const messageId = await nexmo.handleIncomingMessage(req.body) - res.send(messageId) - } catch (ex) { - log.error(ex) - res.send('done') - } -})) - -app.post('/twilio', twilio.webhook(), wrap(async (req, res) => { - try { - await twilio.handleIncomingMessage(req.body) - } catch (ex) { - log.error(ex) + const getContext = app.get("awsContextGetter"); + if (typeof getContext === "function") { + const [event, context] = getContext(req, res); + req.awsEvent = event; + req.awsContext = context; } + next(); +}); + +app.post( + "/nexmo", + wrap(async (req, res) => { + try { + const messageId = await nexmo.handleIncomingMessage(req.body); + res.send(messageId); + } catch (ex) { + log.error(ex); + res.send("done"); + } + }) +); + +app.post( + "/twilio", + twilio.webhook(), + wrap(async (req, res) => { + try { + await twilio.handleIncomingMessage(req.body); + } catch (ex) { + log.error(ex); + } - const resp = new TwimlResponse() - res.writeHead(200, { 'Content-Type': 'text/xml' }) - res.end(resp.toString()) -})) - -app.post('/nexmo-message-report', wrap(async (req, res) => { - try { - const body = req.body - await nexmo.handleDeliveryReport(body) - } catch (ex) { - log.error(ex) - } - res.send('done') -})) - -app.post('/twilio-message-report', wrap(async (req, res) => { - try { - const body = req.body - await twilio.handleDeliveryReport(body) - } catch (ex) { - log.error(ex) - } - const resp = new TwimlResponse() - res.writeHead(200, { 'Content-Type': 'text/xml' }) - res.end(resp.toString()) -})) + const resp = new TwimlResponse(); + res.writeHead(200, { "Content-Type": "text/xml" }); + res.end(resp.toString()); + }) +); + +app.post( + "/nexmo-message-report", + wrap(async (req, res) => { + try { + const body = req.body; + await nexmo.handleDeliveryReport(body); + } catch (ex) { + log.error(ex); + } + res.send("done"); + }) +); + +app.post( + "/twilio-message-report", + wrap(async (req, res) => { + try { + const body = req.body; + await twilio.handleDeliveryReport(body); + } catch (ex) { + log.error(ex); + } + const resp = new TwimlResponse(); + res.writeHead(200, { "Content-Type": "text/xml" }); + res.end(resp.toString()); + }) +); // const accountSid = process.env.TWILIO_API_KEY // const authToken = process.env.TWILIO_AUTH_TOKEN // const client = require('twilio')(accountSid, authToken) -app.get('/logout-callback', (req, res) => { - req.logOut() - res.redirect('/') -}) +app.get("/logout-callback", (req, res) => { + req.logOut(); + res.redirect("/"); +}); if (loginCallbacks) { - app.get('/login-callback', ...loginCallbacks.loginCallback) - app.post('/login-callback', ...loginCallbacks.loginCallback) + app.get("/login-callback", ...loginCallbacks.loginCallback); + app.post("/login-callback", ...loginCallbacks.loginCallback); } const executableSchema = makeExecutableSchema({ typeDefs: schema, resolvers, allowUndefinedInResolve: false -}) +}); addMockFunctionsToSchema({ schema: executableSchema, mocks, preserveResolvers: true -}) - -app.use('/graphql', graphqlExpress((request) => ({ - schema: executableSchema, - context: { - loaders: createLoaders(), - user: request.user, - awsContext: request.awsContext || null, - awsEvent: request.awsEvent || null, - remainingMilliseconds: () => ( - (request.awsContext && request.awsContext.getRemainingTimeInMillis) - ? request.awsContext.getRemainingTimeInMillis() - : 5 * 60 * 1000 // default saying 5 min, no matter what - ) - } -}))) -app.get('/graphiql', graphiqlExpress({ - endpointURL: '/graphql' -})) +}); + +app.use( + "/graphql", + graphqlExpress(request => ({ + schema: executableSchema, + context: { + loaders: createLoaders(), + user: request.user, + awsContext: request.awsContext || null, + awsEvent: request.awsEvent || null, + remainingMilliseconds: () => + request.awsContext && request.awsContext.getRemainingTimeInMillis + ? request.awsContext.getRemainingTimeInMillis() + : 5 * 60 * 1000 // default saying 5 min, no matter what + } + })) +); +app.get( + "/graphiql", + graphiqlExpress({ + endpointURL: "/graphql" + }) +); // This middleware should be last. Return the React app only if no other route is hit. -app.use(appRenderer) - +app.use(appRenderer); if (port) { app.listen(port, () => { - log.info(`Node app is running on port ${port}`) - }) + log.info(`Node app is running on port ${port}`); + }); } -export default app +export default app; diff --git a/src/server/knex-connect.js b/src/server/knex-connect.js index fdb192944..4f7e07702 100644 --- a/src/server/knex-connect.js +++ b/src/server/knex-connect.js @@ -3,10 +3,10 @@ // deprecated, a better pattern would be to instantiate knex here and export // that instance, for reference everywhere else in the codebase. const { - DB_USE_SSL = 'false', + DB_USE_SSL = "false", DB_JSON = global.DB_JSON, - DB_HOST = '127.0.0.1', - DB_PORT = '5432', + DB_HOST = "127.0.0.1", + DB_PORT = "5432", DB_MIN_POOL = 2, DB_MAX_POOL = 10, DB_TYPE, @@ -15,23 +15,23 @@ const { DB_USER, DATABASE_URL, NODE_ENV -} = process.env -const min = parseInt(DB_MIN_POOL, 10) -const max = parseInt(DB_MAX_POOL, 10) +} = process.env; +const min = parseInt(DB_MIN_POOL, 10); +const max = parseInt(DB_MAX_POOL, 10); -const pg = require('pg') +const pg = require("pg"); -const useSSL = DB_USE_SSL === '1' || DB_USE_SSL.toLowerCase() === 'true' -if (useSSL) pg.defaults.ssl = true +const useSSL = DB_USE_SSL === "1" || DB_USE_SSL.toLowerCase() === "true"; +if (useSSL) pg.defaults.ssl = true; // see https://github.com/tgriesser/knex/issues/852 -let config +let config; if (DB_JSON) { - config = JSON.parse(DB_JSON) + config = JSON.parse(DB_JSON); } else if (DB_TYPE) { config = { - client: 'pg', + client: "pg", connection: { host: DB_HOST, port: DB_PORT, @@ -41,33 +41,33 @@ if (DB_JSON) { ssl: useSSL }, pool: { min, max } - } + }; } else if (DATABASE_URL) { - const dbType = DATABASE_URL.match(/^\w+/)[0] + const dbType = DATABASE_URL.match(/^\w+/)[0]; config = { - client: (/postgres/.test(dbType) ? 'pg' : dbType), + client: /postgres/.test(dbType) ? "pg" : dbType, connection: DATABASE_URL, pool: { min, max }, ssl: useSSL - } -} else if (NODE_ENV === 'test') { + }; +} else if (NODE_ENV === "test") { config = { - client: 'pg', + client: "pg", connection: { host: DB_HOST, port: DB_PORT, - database: 'spoke_test', - password: 'spoke_test', - user: 'spoke_test', + database: "spoke_test", + password: "spoke_test", + user: "spoke_test", ssl: useSSL } - } + }; } else { config = { - client: 'sqlite3', - connection: { filename: './mydb.sqlite' }, + client: "sqlite3", + connection: { filename: "./mydb.sqlite" }, defaultsUnsupported: true - } + }; } -module.exports = config +module.exports = config; diff --git a/src/server/local-auth-helpers.js b/src/server/local-auth-helpers.js index 549539da4..5a8a35b84 100644 --- a/src/server/local-auth-helpers.js +++ b/src/server/local-auth-helpers.js @@ -1,57 +1,50 @@ -import AuthHasher from 'passport-local-authenticate' -import { User, Invite, Organization } from './models' -import { capitalizeWord } from './api/lib/utils' +import AuthHasher from "passport-local-authenticate"; +import { User, Invite, Organization } from "./models"; +import { capitalizeWord } from "./api/lib/utils"; const errorMessages = { - invalidInvite: 'Invalid invite code. Contact your administrator.', - invalidCredentials: 'Invalid username or password', - emailTaken: 'That email is already taken.', - passwordsDontMatch: 'Passwords don\'t match.', - invalidResetHash: 'Invalid username or password reset link. Contact your administrator.', - noSamePassword: 'Old and new password can\'t be the same' -} + invalidInvite: "Invalid invite code. Contact your administrator.", + invalidCredentials: "Invalid username or password", + emailTaken: "That email is already taken.", + passwordsDontMatch: "Passwords don't match.", + invalidResetHash: + "Invalid username or password reset link. Contact your administrator.", + noSamePassword: "Old and new password can't be the same" +}; const validUuid = async (nextUrl, uuidMatch) => { - if (!uuidMatch || !nextUrl) throw new Error(errorMessages.invalidInvite) + if (!uuidMatch || !nextUrl) throw new Error(errorMessages.invalidInvite); - let foundUUID - if (nextUrl.includes('join')) { - foundUUID = await Organization.filter({ uuid: uuidMatch[0] }) - } else if (nextUrl.includes('invite')) { - foundUUID = await Invite.filter({ hash: uuidMatch[0] }) + let foundUUID; + if (nextUrl.includes("join")) { + foundUUID = await Organization.filter({ uuid: uuidMatch[0] }); + } else if (nextUrl.includes("invite")) { + foundUUID = await Invite.filter({ hash: uuidMatch[0] }); } - if (foundUUID.length === 0) throw new Error(errorMessages.invalidInvite) -} + if (foundUUID.length === 0) throw new Error(errorMessages.invalidInvite); +}; -const login = async ({ - password, - existingUser, - nextUrl, - uuidMatch -}) => { +const login = async ({ password, existingUser, nextUrl, uuidMatch }) => { if (existingUser.length === 0) { - throw new Error(errorMessages.invalidCredentials) + throw new Error(errorMessages.invalidCredentials); } // Get salt and hash and verify user password - const pwFieldSplit = existingUser[0].auth0_id.split('|') + const pwFieldSplit = existingUser[0].auth0_id.split("|"); const hashed = { salt: pwFieldSplit[1], hash: pwFieldSplit[2] - } + }; return new Promise((resolve, reject) => { - AuthHasher.verify( - password, hashed, - (err, verified) => { - if (err) reject(err) - if (verified) { - resolve(existingUser[0]) - } - reject({ message: errorMessages.invalidCredentials }) + AuthHasher.verify(password, hashed, (err, verified) => { + if (err) reject(err); + if (verified) { + resolve(existingUser[0]); } - ) - }) -} + reject({ message: errorMessages.invalidCredentials }); + }); + }); +}; const signup = async ({ lowerCaseEmail, @@ -63,24 +56,24 @@ const signup = async ({ }) => { // Verify UUID validity // If there is an error, it will be caught on local strategy invocation - await validUuid(nextUrl, uuidMatch) + await validUuid(nextUrl, uuidMatch); // Verify user doesn't already exist if (existingUser.length > 0 && existingUser[0].email === lowerCaseEmail) { - throw new Error(errorMessages.emailTaken) + throw new Error(errorMessages.emailTaken); } // Verify password and password confirm fields match if (password !== reqBody.passwordConfirm) { - throw new Error(errorMessages.passwordsDontMatch) + throw new Error(errorMessages.passwordsDontMatch); } // create the user return new Promise((resolve, reject) => { - AuthHasher.hash(password, async function (err, hashed) { - if (err) reject(err) + AuthHasher.hash(password, async function(err, hashed) { + if (err) reject(err); // .salt and .hash - const passwordToSave = `localauth|${hashed.salt}|${hashed.hash}` + const passwordToSave = `localauth|${hashed.salt}|${hashed.hash}`; const user = await User.save({ email: lowerCaseEmail, auth0_id: passwordToSave, @@ -88,98 +81,83 @@ const signup = async ({ last_name: capitalizeWord(reqBody.lastName), cell: reqBody.cell, is_superadmin: false - }) - resolve(user) - }) - }) -} + }); + resolve(user); + }); + }); +}; -const reset = ({ - password, - existingUser, - reqBody, - uuidMatch -}) => { +const reset = ({ password, existingUser, reqBody, uuidMatch }) => { if (existingUser.length === 0) { - throw new Error(errorMessages.invalidResetHash) + throw new Error(errorMessages.invalidResetHash); } // Get user resetHash and date of hash creation - const pwFieldSplit = existingUser[0].auth0_id.split('|') - const [resetHash, datetime] = [pwFieldSplit[1], pwFieldSplit[2]] + const pwFieldSplit = existingUser[0].auth0_id.split("|"); + const [resetHash, datetime] = [pwFieldSplit[1], pwFieldSplit[2]]; // Verify hash was created in the last 15 mins - const isExpired = (Date.now() - datetime) / 1000 / 60 > 15 + const isExpired = (Date.now() - datetime) / 1000 / 60 > 15; if (isExpired) { - throw new Error(errorMessages.invalidResetHash) + throw new Error(errorMessages.invalidResetHash); } // Verify the UUID in request matches hash in DB if (uuidMatch[0] !== resetHash) { - throw new Error(errorMessages.invalidResetHash) + throw new Error(errorMessages.invalidResetHash); } // Verify passwords match if (password !== reqBody.passwordConfirm) { - throw new Error(errorMessages.passwordsDontMatch) + throw new Error(errorMessages.passwordsDontMatch); } // Save new user password to DB return new Promise((resolve, reject) => { - AuthHasher.hash(password, async function (err, hashed) { - if (err) reject(err) + AuthHasher.hash(password, async function(err, hashed) { + if (err) reject(err); // .salt and .hash - const passwordToSave = `localauth|${hashed.salt}|${hashed.hash}` - const updatedUser = await User - .get(existingUser[0].id) + const passwordToSave = `localauth|${hashed.salt}|${hashed.hash}`; + const updatedUser = await User.get(existingUser[0].id) .update({ auth0_id: passwordToSave }) - .run() - resolve(updatedUser) - }) - }) -} + .run(); + resolve(updatedUser); + }); + }); +}; // Only used in the changeUserPassword GraphQl mutation -export const change = ({ - user, - password, - newPassword, - passwordConfirm -}) => { - const pwFieldSplit = user.auth0_id.split('|') +export const change = ({ user, password, newPassword, passwordConfirm }) => { + const pwFieldSplit = user.auth0_id.split("|"); const hashedPassword = { salt: pwFieldSplit[1], hash: pwFieldSplit[2] - } + }; // Verify password and password confirm fields match if (newPassword !== passwordConfirm) { - throw new Error(errorMessages.passwordsDontMatch) + throw new Error(errorMessages.passwordsDontMatch); } // Verify old and new passwords are different if (password === newPassword) { - throw new Error(errorMessages.noSamePassword) + throw new Error(errorMessages.noSamePassword); } return new Promise((resolve, reject) => { - AuthHasher.verify( - password, hashedPassword, - (error, verified) => { - if (error) return reject(error) - if (!verified) return reject(errorMessages.invalidCredentials) - return AuthHasher.hash(newPassword, async function (err, hashed) { - if (err) reject(err) - // .salt and .hash - const passwordToSave = `localauth|${hashed.salt}|${hashed.hash}` - const updatedUser = await User - .get(user.id) - .update({ auth0_id: passwordToSave }) - .run() - resolve(updatedUser) - }) - } - ) - }) -} -export default { login, signup, reset } + AuthHasher.verify(password, hashedPassword, (error, verified) => { + if (error) return reject(error); + if (!verified) return reject(errorMessages.invalidCredentials); + return AuthHasher.hash(newPassword, async function(err, hashed) { + if (err) reject(err); + // .salt and .hash + const passwordToSave = `localauth|${hashed.salt}|${hashed.hash}`; + const updatedUser = await User.get(user.id) + .update({ auth0_id: passwordToSave }) + .run(); + resolve(updatedUser); + }); + }); + }); +}; +export default { login, signup, reset }; diff --git a/src/server/mail.js b/src/server/mail.js index 6671b9300..c7e38bc9c 100644 --- a/src/server/mail.js +++ b/src/server/mail.js @@ -1,44 +1,46 @@ -import { log } from '../lib' -import nodemailer from 'nodemailer' -import mailgunConstructor from 'mailgun-js' +import { log } from "../lib"; +import nodemailer from "nodemailer"; +import mailgunConstructor from "mailgun-js"; const mailgun = process.env.MAILGUN_API_KEY && process.env.MAILGUN_DOMAIN && - mailgunConstructor({ apiKey: process.env.MAILGUN_API_KEY, domain: process.env.MAILGUN_DOMAIN }) + mailgunConstructor({ + apiKey: process.env.MAILGUN_API_KEY, + domain: process.env.MAILGUN_DOMAIN + }); const sender = process.env.MAILGUN_API_KEY && process.env.MAILGUN_DOMAIN ? { - sendMail: ({ from, to, subject, replyTo, text }) => - mailgun.messages().send( - { - from, - 'h:Reply-To': replyTo, - to, - subject, - text - }) - } + sendMail: ({ from, to, subject, replyTo, text }) => + mailgun.messages().send({ + from, + "h:Reply-To": replyTo, + to, + subject, + text + }) + } : nodemailer.createTransport({ - host: process.env.EMAIL_HOST, - port: process.env.EMAIL_HOST_PORT, - secure: - typeof process.env.EMAIL_HOST_SECURE !== 'undefined' + host: process.env.EMAIL_HOST, + port: process.env.EMAIL_HOST_PORT, + secure: + typeof process.env.EMAIL_HOST_SECURE !== "undefined" ? process.env.EMAIL_HOST_SECURE : true, - auth: { - user: process.env.EMAIL_HOST_USER, - pass: process.env.EMAIL_HOST_PASSWORD - } - }) + auth: { + user: process.env.EMAIL_HOST_USER, + pass: process.env.EMAIL_HOST_PASSWORD + } + }); export const sendEmail = async ({ to, subject, text, replyTo }) => { - log.info(`Sending e-mail to ${to} with subject ${subject}.`) + log.info(`Sending e-mail to ${to} with subject ${subject}.`); - if (process.env.NODE_ENV === 'development') { - log.debug(`Would send e-mail with subject ${subject} and text ${text}.`) - return null + if (process.env.NODE_ENV === "development") { + log.debug(`Would send e-mail with subject ${subject} and text ${text}.`); + return null; } const params = { @@ -46,11 +48,11 @@ export const sendEmail = async ({ to, subject, text, replyTo }) => { to, subject, text - } + }; if (replyTo) { - params['replyTo'] = replyTo + params["replyTo"] = replyTo; } - return sender.sendMail(params) -} + return sender.sendMail(params); +}; diff --git a/src/server/middleware/render-index.js b/src/server/middleware/render-index.js index a4a914565..0824a56a4 100644 --- a/src/server/middleware/render-index.js +++ b/src/server/middleware/render-index.js @@ -1,9 +1,9 @@ -const rollbarScript = process.env.ROLLBAR_CLIENT_TOKEN ? - `` : '' + ` + : ""; // the site is not very useful without auth0, unless you have a session cookie already // good for doing dev offline -const externalLinks = (process.env.NO_EXTERNAL_LINKS ? '' : - '') +const externalLinks = process.env.NO_EXTERNAL_LINKS + ? "" + : ''; export default function renderIndex(html, css, assetMap, store) { return ` @@ -63,21 +65,22 @@ export default function renderIndex(html, css, assetMap, store) { window.RENDERED_CLASS_NAMES=${JSON.stringify(css.renderedClassNames)} window.AUTH0_CLIENT_ID="${process.env.AUTH0_CLIENT_ID}" window.AUTH0_DOMAIN="${process.env.AUTH0_DOMAIN}" - window.SUPPRESS_SELF_INVITE="${process.env.SUPPRESS_SELF_INVITE || ''}" + window.SUPPRESS_SELF_INVITE="${process.env.SUPPRESS_SELF_INVITE || ""}" window.NODE_ENV="${process.env.NODE_ENV}" - window.PRIVACY_URL="${process.env.PRIVACY_URL || ''}" - window.BASE_URL="${process.env.BASE_URL || ''}" + window.PRIVACY_URL="${process.env.PRIVACY_URL || ""}" + window.BASE_URL="${process.env.BASE_URL || ""}" window.NOT_IN_USA=${process.env.NOT_IN_USA || 0} window.ALLOW_SEND_ALL=${process.env.ALLOW_SEND_ALL || 0} window.BULK_SEND_CHUNK_SIZE=${process.env.BULK_SEND_CHUNK_SIZE || 0} window.MAX_MESSAGE_LENGTH=${process.env.MAX_MESSAGE_LENGTH || 99999} - window.TERMS_REQUIRE="${process.env.TERMS_REQUIRE || ''}" - window.TZ="${process.env.TZ || ''}" - window.DST_REFERENCE_TIMEZONE="${process.env.DST_REFERENCE_TIMEZONE || 'America/New_York'}" - window.PASSPORT_STRATEGY="${process.env.PASSPORT_STRATEGY || ''}" + window.TERMS_REQUIRE="${process.env.TERMS_REQUIRE || ""}" + window.TZ="${process.env.TZ || ""}" + window.DST_REFERENCE_TIMEZONE="${process.env.DST_REFERENCE_TIMEZONE || + "America/New_York"}" + window.PASSPORT_STRATEGY="${process.env.PASSPORT_STRATEGY || ""}" - + -` +`; } diff --git a/src/server/models/assignment.js b/src/server/models/assignment.js index 993520c24..b9d55f39f 100644 --- a/src/server/models/assignment.js +++ b/src/server/models/assignment.js @@ -1,20 +1,27 @@ -import thinky from './thinky' -import { requiredString, timestamp } from './custom-types' +import thinky from "./thinky"; +import { requiredString, timestamp } from "./custom-types"; -import Campaign from './campaign' -import User from './user' +import Campaign from "./campaign"; +import User from "./user"; -const type = thinky.type +const type = thinky.type; -const Assignment = thinky.createModel('assignment', type.object().schema({ - id: type.string(), - user_id: requiredString(), - campaign_id: requiredString(), - created_at: timestamp(), - max_contacts: type.integer() -}).allowExtra(false), { noAutoCreation: true }) +const Assignment = thinky.createModel( + "assignment", + type + .object() + .schema({ + id: type.string(), + user_id: requiredString(), + campaign_id: requiredString(), + created_at: timestamp(), + max_contacts: type.integer() + }) + .allowExtra(false), + { noAutoCreation: true } +); -Assignment.ensureIndex('user_id') -Assignment.ensureIndex('campaign_id') +Assignment.ensureIndex("user_id"); +Assignment.ensureIndex("campaign_id"); -export default Assignment +export default Assignment; diff --git a/src/server/models/cacheable_queries/assignment.js b/src/server/models/cacheable_queries/assignment.js index ebce32f7d..e4158795f 100644 --- a/src/server/models/cacheable_queries/assignment.js +++ b/src/server/models/cacheable_queries/assignment.js @@ -1,12 +1,10 @@ -import { r } from '../../models' +import { r } from "../../models"; -export async function hasAssignment(userId, assignmentId) { -} +export async function hasAssignment(userId, assignmentId) {} export const assignmentCache = { - clear: async (id) => { - }, - load: async (id) => { + clear: async id => {}, + load: async id => { // should load cache of campaign by id separately, so that can be updated on campaign-save // e.g. for script changes // should include: @@ -15,4 +13,4 @@ export const assignmentCache = { // organizationId // ?should contact ids be key'd off of campaign or assignment? } -} +}; diff --git a/src/server/models/cacheable_queries/campaign-contact.js b/src/server/models/cacheable_queries/campaign-contact.js index 03054cc98..67b54cd61 100644 --- a/src/server/models/cacheable_queries/campaign-contact.js +++ b/src/server/models/cacheable_queries/campaign-contact.js @@ -1,5 +1,5 @@ -import { r, CampaignContact } from '../../models' -import { optOutCache } from './opt-out' +import { r, CampaignContact } from "../../models"; +import { optOutCache } from "./opt-out"; // // - assignmentId @@ -27,36 +27,43 @@ import { optOutCache } from './opt-out' // - messageStatus // TODO: relocate this method elsewhere -const getMessageServiceSid = (organization) => { - let orgFeatures = {} +const getMessageServiceSid = organization => { + let orgFeatures = {}; if (organization.features) { - orgFeatures = JSON.parse(organization.features) + orgFeatures = JSON.parse(organization.features); } - const orgSid = orgFeatures.message_service_sid + const orgSid = orgFeatures.message_service_sid; if (!orgSid) { - const service = orgFeatures.service || process.env.DEFAULT_SERVICE || '' - if (service === 'twilio') { - return process.env.TWILIO_MESSAGE_SERVICE_SID + const service = orgFeatures.service || process.env.DEFAULT_SERVICE || ""; + if (service === "twilio") { + return process.env.TWILIO_MESSAGE_SERVICE_SID; } - return '' + return ""; } - return orgSid -} + return orgSid; +}; -const cacheKey = async (id) => `${process.env.CACHE_PREFIX | ''}contact-${id}` +const cacheKey = async id => `${process.env.CACHE_PREFIX | ""}contact-${id}`; const saveCacheRecord = async (dbRecord, organization, messageServiceSid) => { if (r.redis) { // basic contact record - const contactCacheObj = generateCacheRecord(dbRecord, organization.id, messageServiceSid) - await r.redis.setAsync(cacheKey(dbRecord.id), JSON.stringify(contactCacheObj)) + const contactCacheObj = generateCacheRecord( + dbRecord, + organization.id, + messageServiceSid + ); + await r.redis.setAsync( + cacheKey(dbRecord.id), + JSON.stringify(contactCacheObj) + ); // TODO: // messageStatus- } // NOT INCLUDED: // - messages // - questionResponseValues -} +}; const generateCacheRecord = (dbRecord, organizationId, messageServiceSid) => ({ // This should be contactinfo that @@ -78,66 +85,70 @@ const generateCacheRecord = (dbRecord, organizationId, messageServiceSid) => ({ timezone_offset: dbRecord.timezone_offset, city: dbRecord.city, state: dbRecord.state -}) +}); export const campaignContactCache = { - clear: async (id) => { + clear: async id => { if (r.redis) { - await r.redis.delAsync(cacheKey(id)) + await r.redis.delAsync(cacheKey(id)); } }, - load: async(id) => { + load: async id => { if (r.redis) { - const cacheRecord = await r.redis.getAsync(cacheKey(id)) + const cacheRecord = await r.redis.getAsync(cacheKey(id)); if (cacheRecord) { - const cacheData = JSON.parse(cacheRecord) + const cacheData = JSON.parse(cacheRecord); if (cacheData.cell && cacheData.organization_id) { cacheData.is_opted_out = await optOutCache.query({ cell: cacheData.cell, - organizationId: cacheData.organization_id }) + organizationId: cacheData.organization_id + }); } - console.log('fromCache', cacheData) - return cacheData + console.log("fromCache", cacheData); + return cacheData; } } - return await CampaignContact.get(id) + return await CampaignContact.get(id); }, loadMany: async (organization, { campaign, queryFunc }) => { // queryFunc(query) has query input of a knex query // queryFunc should return a query with added where clauses if (!r.redis) { - return + return; } // 1. load the data - let query = r.knex('campaign_contact') - .leftJoin('zip_code', 'zip_code.zip', 'campaign_contact.zip') - .leftJoin('assignment', 'assignment.id', 'campaign_contact.assignment_id') - .select('campaign_contact.id', - 'campaign_contact.assignment_id', - 'campaign_contact.campaign_id', - 'assignment.user_id', - 'campaign_contact.first_name', - 'campaign_contact.last_name', - 'campaign_contact.cell', - 'campaign_contact.custom_fields', - 'campaign_contact.zip', - 'campaign_contact.external_id', - 'campaign_contact.message_status', - 'campaign_contact.timezone_offset', - 'zip_code.city', - 'zip_code.state') + let query = r + .knex("campaign_contact") + .leftJoin("zip_code", "zip_code.zip", "campaign_contact.zip") + .leftJoin("assignment", "assignment.id", "campaign_contact.assignment_id") + .select( + "campaign_contact.id", + "campaign_contact.assignment_id", + "campaign_contact.campaign_id", + "assignment.user_id", + "campaign_contact.first_name", + "campaign_contact.last_name", + "campaign_contact.cell", + "campaign_contact.custom_fields", + "campaign_contact.zip", + "campaign_contact.external_id", + "campaign_contact.message_status", + "campaign_contact.timezone_offset", + "zip_code.city", + "zip_code.state" + ); if (campaign) { - query = query.where('campaign_contact.campaign_id', campaign.id) + query = query.where("campaign_contact.campaign_id", campaign.id); } if (queryFunc) { - query = queryFunc(query) + query = queryFunc(query); } - const dbResult = await query + const dbResult = await query; // 2. cache the data - const messageServiceSid = getMessageServiceSid(organization) + const messageServiceSid = getMessageServiceSid(organization); for (let i = 0, l = dbResult.length; i < l; i++) { - const dbRecord = dbResult[i] - await saveCacheRecord(dbRecord, organization, messageServiceSid) + const dbRecord = dbResult[i]; + await saveCacheRecord(dbRecord, organization, messageServiceSid); } } -} +}; diff --git a/src/server/models/cacheable_queries/campaign.js b/src/server/models/cacheable_queries/campaign.js index 2a9142cc3..a9340a0b5 100644 --- a/src/server/models/cacheable_queries/campaign.js +++ b/src/server/models/cacheable_queries/campaign.js @@ -1,6 +1,6 @@ -import { r, loaders, Campaign } from '../../models' -import { modelWithExtraProps } from './lib' -import { assembleAnswerOptions } from '../../../lib/interaction-step-helpers' +import { r, loaders, Campaign } from "../../models"; +import { modelWithExtraProps } from "./lib"; +import { assembleAnswerOptions } from "../../../lib/interaction-step-helpers"; // This should be cached data for a campaign that will not change // based on assignments or texter actions @@ -19,130 +19,139 @@ import { assembleAnswerOptions } from '../../../lib/interaction-step-helpers' // * organization metadata (saved in organization.js instead) // * campaignCannedResponses (saved in canned-responses.js instead) -const cacheKey = (id) => `${process.env.CACHE_PREFIX || ''}campaign-${id}` +const cacheKey = id => `${process.env.CACHE_PREFIX || ""}campaign-${id}`; -const dbCustomFields = async (id) => { - const campaignContacts = await r.table('campaign_contact') - .getAll(id, { index: 'campaign_id' }) - .limit(1) +const dbCustomFields = async id => { + const campaignContacts = await r + .table("campaign_contact") + .getAll(id, { index: "campaign_id" }) + .limit(1); if (campaignContacts.length > 0) { - return Object.keys(JSON.parse(campaignContacts[0].custom_fields)) + return Object.keys(JSON.parse(campaignContacts[0].custom_fields)); } - return [] -} + return []; +}; -const dbInteractionSteps = async (id) => { - const allSteps = await r.table('interaction_step') - .getAll(id, { index: 'campaign_id' }) +const dbInteractionSteps = async id => { + const allSteps = await r + .table("interaction_step") + .getAll(id, { index: "campaign_id" }) .filter({ is_deleted: false }) - .orderBy('id') - return assembleAnswerOptions(allSteps) -} + .orderBy("id"); + return assembleAnswerOptions(allSteps); +}; -const dbContactTimezones = async (id) => ( - (await r.knex('campaign_contact') - .where('campaign_id', id) - .distinct('timezone_offset') - .select()) - .map(contact => contact.timezone_offset) -) +const dbContactTimezones = async id => + (await r + .knex("campaign_contact") + .where("campaign_id", id) + .distinct("timezone_offset") + .select()).map(contact => contact.timezone_offset); const clear = async (id, campaign) => { if (r.redis) { // console.log('clearing campaign cache') - await r.redis.delAsync(cacheKey(id)) + await r.redis.delAsync(cacheKey(id)); } - loaders.campaign.clear(id) -} + loaders.campaign.clear(id); +}; -const loadDeep = async (id) => { +const loadDeep = async id => { // console.log('load campaign deep', id) if (r.redis) { - const campaign = await Campaign.get(id) + const campaign = await Campaign.get(id); if (Array.isArray(campaign) && campaign.length === 0) { - console.error('NO CAMPAIGN FOUND') - return {} + console.error("NO CAMPAIGN FOUND"); + return {}; } if (campaign.is_archived) { // console.log('campaign is_archived') // do not cache archived campaigns - loaders.campaign.clear(id) - return campaign + loaders.campaign.clear(id); + return campaign; } // console.log('campaign loaddeep', campaign) - campaign.customFields = await dbCustomFields(id) - campaign.interactionSteps = await dbInteractionSteps(id) - campaign.contactTimezones = await dbContactTimezones(id) + campaign.customFields = await dbCustomFields(id); + campaign.interactionSteps = await dbInteractionSteps(id); + campaign.contactTimezones = await dbContactTimezones(id); // cache userIds for all assignments // console.log('loaded deep campaign', JSON.stringify(campaign, null, 2)) // We should only cache organization data // if/when we can clear it on organization data changes // campaign.organization = await organizationCache.load(campaign.organization_id) // console.log('campaign loaddeep', campaign, JSON.stringify(campaign)) - await r.redis.multi() + await r.redis + .multi() .set(cacheKey(id), JSON.stringify(campaign)) .expire(cacheKey(id), 43200) - .execAsync() + .execAsync(); } // console.log('clearing campaign', id, typeof id, loaders.campaign) - loaders.campaign.clear(String(id)) - loaders.campaign.clear(Number(id)) - return null -} + loaders.campaign.clear(String(id)); + loaders.campaign.clear(Number(id)); + return null; +}; const currentEditors = async (campaign, user) => { // Add user ID in case of duplicate admin names - const displayName = `${user.id}~${user.first_name} ${user.last_name}` + const displayName = `${user.id}~${user.first_name} ${user.last_name}`; - await r.redis.hsetAsync(`campaign_editors_${campaign.id}`, displayName, new Date()) - await r.redis.expire(`campaign_editors_${campaign.id}`, 120) + await r.redis.hsetAsync( + `campaign_editors_${campaign.id}`, + displayName, + new Date() + ); + await r.redis.expire(`campaign_editors_${campaign.id}`, 120); - let editors = await r.redis.hgetallAsync(`campaign_editors_${campaign.id}`) + let editors = await r.redis.hgetallAsync(`campaign_editors_${campaign.id}`); // Only get editors that were active in the last 2 mins, and exclude the // current user editors = Object.entries(editors).filter(editor => { - const rightNow = new Date() - return rightNow - new Date(editor[1]) <= 120000 && editor[0] !== displayName - }) + const rightNow = new Date(); + return ( + rightNow - new Date(editor[1]) <= 120000 && editor[0] !== displayName + ); + }); // Return a list of comma-separated names - return editors.map(editor => editor[0].split('~')[1]).join(', ') -} + return editors.map(editor => editor[0].split("~")[1]).join(", "); +}; const campaignCache = { clear, - load: async (id) => { + load: async id => { // console.log('campaign cache load', id) if (r.redis) { - let campaignData = await r.redis.getAsync(cacheKey(id)) + let campaignData = await r.redis.getAsync(cacheKey(id)); // console.log('pre campaign cache', campaignData) if (!campaignData) { // console.log('no campaigndata', id) - const campaignNoCache = await loadDeep(id) + const campaignNoCache = await loadDeep(id); if (campaignNoCache) { // not found in db either - return campaignNoCache + return campaignNoCache; } - campaignData = await r.redis.getAsync(cacheKey(id)) + campaignData = await r.redis.getAsync(cacheKey(id)); // console.log('new campaign data', campaignData) } if (campaignData) { - const campaignObj = JSON.parse(campaignData) + const campaignObj = JSON.parse(campaignData); // console.log('campaign cache', cacheKey(id), campaignObj, campaignData) - const campaign = modelWithExtraProps( - campaignObj, - Campaign, - ['customFields', 'interactionSteps', 'contactTimezones']) - return campaign + const campaign = modelWithExtraProps(campaignObj, Campaign, [ + "customFields", + "interactionSteps", + "contactTimezones" + ]); + return campaign; } } - return await Campaign.get(id) + return await Campaign.get(id); }, reload: loadDeep, currentEditors, dbCustomFields, dbInteractionSteps -} +}; -export default campaignCache +export default campaignCache; diff --git a/src/server/models/cacheable_queries/canned-response.js b/src/server/models/cacheable_queries/canned-response.js index 84059cc93..f868aee39 100644 --- a/src/server/models/cacheable_queries/canned-response.js +++ b/src/server/models/cacheable_queries/canned-response.js @@ -1,4 +1,4 @@ -import { r } from '../../models' +import { r } from "../../models"; // Datastructure: // * regular GET/SET with JSON ordered list of the objects {id,title,text} @@ -7,39 +7,42 @@ import { r } from '../../models' // * needs an order // * needs to get by campaignId-userId pairs -const cacheKey = (campaignId, userId) => `${process.env.CACHE_PREFIX || ''}canned-${campaignId}-${userId || ''}` +const cacheKey = (campaignId, userId) => + `${process.env.CACHE_PREFIX || ""}canned-${campaignId}-${userId || ""}`; const cannedResponseCache = { clearQuery: async ({ campaignId, userId }) => { if (r.redis) { - await r.redis.delAsync(cacheKey(campaignId, userId)) + await r.redis.delAsync(cacheKey(campaignId, userId)); } }, query: async ({ campaignId, userId }) => { if (r.redis) { - const cannedData = await r.redis.getAsync(cacheKey(campaignId, userId)) + const cannedData = await r.redis.getAsync(cacheKey(campaignId, userId)); if (cannedData) { - return JSON.parse(cannedData) + return JSON.parse(cannedData); } } - const dbResult = await r.table('canned_response') - .getAll(campaignId, { index: 'campaign_id' }) - .filter({ user_id: userId || '' }) - .orderBy('title') + const dbResult = await r + .table("canned_response") + .getAll(campaignId, { index: "campaign_id" }) + .filter({ user_id: userId || "" }) + .orderBy("title"); if (r.redis) { - const cacheData = dbResult.map((cannedRes) => ({ + const cacheData = dbResult.map(cannedRes => ({ id: cannedRes.id, title: cannedRes.title, text: cannedRes.text, user_id: cannedRes.user_id - })) - await r.redis.multi() + })); + await r.redis + .multi() .set(cacheKey(campaignId, userId), JSON.stringify(cacheData)) .expire(cacheKey(campaignId, userId), 43200) // 12 hours - .execAsync() + .execAsync(); } - return dbResult + return dbResult; } -} +}; -export default cannedResponseCache +export default cannedResponseCache; diff --git a/src/server/models/cacheable_queries/index.js b/src/server/models/cacheable_queries/index.js index 5a30b59c4..d92d6e26a 100644 --- a/src/server/models/cacheable_queries/index.js +++ b/src/server/models/cacheable_queries/index.js @@ -1,8 +1,8 @@ -import campaignCache from './campaign' -import cannedResponseCache from './canned-response' -import optOutCache from './opt-out' -import organizationCache from './organization' -import userCache from './user' +import campaignCache from "./campaign"; +import cannedResponseCache from "./canned-response"; +import optOutCache from "./opt-out"; +import organizationCache from "./organization"; +import userCache from "./user"; const cacheableData = { campaign: campaignCache, @@ -10,6 +10,6 @@ const cacheableData = { optOut: optOutCache, organization: organizationCache, user: userCache -} +}; -export default cacheableData +export default cacheableData; diff --git a/src/server/models/cacheable_queries/lib.js b/src/server/models/cacheable_queries/lib.js index 022e05857..9a934ebd8 100644 --- a/src/server/models/cacheable_queries/lib.js +++ b/src/server/models/cacheable_queries/lib.js @@ -1,5 +1,3 @@ - - export const modelWithExtraProps = (obj, Model, props) => { // This accepts a Model type and adds extra properties to it // while preserving its Model prototype @@ -7,16 +5,15 @@ export const modelWithExtraProps = (obj, Model, props) => { // methods are available, but we decorate it with additional // properties which are distilled from separate tables // e.g. campaign.interactionSteps - const newObj = { ...obj } - const extraProps = {} + const newObj = { ...obj }; + const extraProps = {}; props.forEach(prop => { - extraProps[prop] = newObj[prop] - delete newObj[prop] - }) - const newModel = new Model(newObj) + extraProps[prop] = newObj[prop]; + delete newObj[prop]; + }); + const newModel = new Model(newObj); props.forEach(prop => { - newModel[prop] = extraProps[prop] - }) - return newModel -} - + newModel[prop] = extraProps[prop]; + }); + return newModel; +}; diff --git a/src/server/models/cacheable_queries/opt-out.js b/src/server/models/cacheable_queries/opt-out.js index eb94b4369..97d5d5d25 100644 --- a/src/server/models/cacheable_queries/opt-out.js +++ b/src/server/models/cacheable_queries/opt-out.js @@ -1,32 +1,39 @@ -import { r, OptOut } from '../../models' +import { r, OptOut } from "../../models"; // STRUCTURE // SET by organization, so optout- has a key // and membership can be tested -const orgCacheKey = (orgId) => ( -!!process.env.OPTOUTS_SHARE_ALL_ORGS - ? `${process.env.CACHE_PREFIX || ''}optouts` - : `${process.env.CACHE_PREFIX || ''}optouts-${orgId}`) +const orgCacheKey = orgId => + !!process.env.OPTOUTS_SHARE_ALL_ORGS + ? `${process.env.CACHE_PREFIX || ""}optouts` + : `${process.env.CACHE_PREFIX || ""}optouts-${orgId}`; -const sharingOptOuts = !!process.env.OPTOUTS_SHARE_ALL_ORGS +const sharingOptOuts = !!process.env.OPTOUTS_SHARE_ALL_ORGS; -const loadMany = async (organizationId) => { +const loadMany = async organizationId => { if (r.redis) { - let dbQuery = r.knex('opt_out').select('cell') + let dbQuery = r.knex("opt_out").select("cell"); if (!sharingOptOuts) { - dbQuery = dbQuery.where('organization_id', organizationId) + dbQuery = dbQuery.where("organization_id", organizationId); } - const dbResult = await dbQuery - const cellOptOuts = dbResult.map((rec) => rec.cell) - const hashKey = orgCacheKey(organizationId) + const dbResult = await dbQuery; + const cellOptOuts = dbResult.map(rec => rec.cell); + const hashKey = orgCacheKey(organizationId); // save 100 at a time - for (let i100 = 0, l100 = Math.ceil(cellOptOuts.length / 100); i100 < l100; i100++) { - await r.redis.saddAsync(hashKey, cellOptOuts.slice(100 * i100, 100 * i100 + 100)) + for ( + let i100 = 0, l100 = Math.ceil(cellOptOuts.length / 100); + i100 < l100; + i100++ + ) { + await r.redis.saddAsync( + hashKey, + cellOptOuts.slice(100 * i100, 100 * i100 + 100) + ); } - await r.redis.expire(hashKey, 43200) + await r.redis.expire(hashKey, 43200); } -} +}; const optOutCache = { clearQuery: async ({ cell, organizationId }) => { @@ -34,9 +41,9 @@ const optOutCache = { // (if no cell is present, then clear whole query of organization) if (r.redis) { if (cell) { - await r.redis.sdelAsync(orgCacheKey(organizationId), cell) + await r.redis.sdelAsync(orgCacheKey(organizationId), cell); } else { - await r.redis.delAsync(orgCacheKey(organizationId)) + await r.redis.delAsync(orgCacheKey(organizationId)); } } }, @@ -45,37 +52,38 @@ const optOutCache = { // for a particular organization, if the org Id is NOT cached // then cache the WHOLE set of opt-outs for organizationId at once // and expire them in a day. - const accountingForOrgSharing = (!sharingOptOuts ? - { cell, organization_id: organizationId } : - { cell } - ) + const accountingForOrgSharing = !sharingOptOuts + ? { cell, organization_id: organizationId } + : { cell }; if (r.redis) { - const hashKey = orgCacheKey(organizationId) - const [exists, isMember] = await r.redis.multi() + const hashKey = orgCacheKey(organizationId); + const [exists, isMember] = await r.redis + .multi() .exists(hashKey) .sismember(hashKey, cell) - .execAsync() + .execAsync(); if (exists) { - return isMember + return isMember; } // note NOT awaiting this -- it should run in background // ideally not blocking the rest of the request - loadMany(organizationId) + loadMany(organizationId); } - const dbResult = await r.knex('opt_out') - .select('cell') + const dbResult = await r + .knex("opt_out") + .select("cell") .where(accountingForOrgSharing) - .limit(1) - return (dbResult.length > 0) + .limit(1); + return dbResult.length > 0; }, save: async ({ cell, campaignContactId, campaign, assignmentId, reason }) => { - const organizationId = campaign.organization_id + const organizationId = campaign.organization_id; if (r.redis) { - const hashKey = orgCacheKey(organizationId) - const exists = await r.redis.existsAsync(hashKey) + const hashKey = orgCacheKey(organizationId); + const exists = await r.redis.existsAsync(hashKey); if (exists) { - await r.redis.saddAsync(hashKey, cell) + await r.redis.saddAsync(hashKey, cell); } } // database @@ -84,31 +92,32 @@ const optOutCache = { organization_id: organizationId, reason_code: reason, cell - }).save() + }).save(); // update all organization/instance's active campaigns as well - const updateOrgOrInstanceOptOuts = (!sharingOptOuts ? - { 'campaign_contact.cell': cell, - 'campaign.organization_id': organizationId, - 'campaign.is_archived': false } : - { 'campaign_contact.cell': cell, - 'campaign.is_archived': false - }) + const updateOrgOrInstanceOptOuts = !sharingOptOuts + ? { + "campaign_contact.cell": cell, + "campaign.organization_id": organizationId, + "campaign.is_archived": false + } + : { "campaign_contact.cell": cell, "campaign.is_archived": false }; await r - .knex('campaign_contact') + .knex("campaign_contact") .where( - 'id', - 'in', - r.knex('campaign_contact') - .leftJoin('campaign', 'campaign_contact.campaign_id', 'campaign.id') + "id", + "in", + r + .knex("campaign_contact") + .leftJoin("campaign", "campaign_contact.campaign_id", "campaign.id") .where(updateOrgOrInstanceOptOuts) - .select('campaign_contact.id') + .select("campaign_contact.id") ) .update({ is_opted_out: true - }) + }); }, loadMany -} +}; -export default optOutCache +export default optOutCache; diff --git a/src/server/models/cacheable_queries/organization.js b/src/server/models/cacheable_queries/organization.js index dc1b694f8..0f300e3ff 100644 --- a/src/server/models/cacheable_queries/organization.js +++ b/src/server/models/cacheable_queries/organization.js @@ -1,41 +1,43 @@ -import { r, loaders } from '../../models' +import { r, loaders } from "../../models"; -const cacheKey = (orgId) => `${process.env.CACHE_PREFIX || ''}org-${orgId}` +const cacheKey = orgId => `${process.env.CACHE_PREFIX || ""}org-${orgId}`; const organizationCache = { - clear: async (id) => { + clear: async id => { if (r.redis) { - await r.redis.delAsync(cacheKey(id)) + await r.redis.delAsync(cacheKey(id)); } - loaders.organization.clear(String(id)) - loaders.organization.clear(Number(id)) + loaders.organization.clear(String(id)); + loaders.organization.clear(Number(id)); }, - load: async (id) => { + load: async id => { if (r.redis) { - const orgData = await r.redis.getAsync(cacheKey(id)) + const orgData = await r.redis.getAsync(cacheKey(id)); if (orgData) { - return JSON.parse(orgData) + return JSON.parse(orgData); } } - const [dbResult] = await r.knex('organization') - .where('id', id) - .select('*') - .limit(1) + const [dbResult] = await r + .knex("organization") + .where("id", id) + .select("*") + .limit(1); if (dbResult) { if (dbResult.features) { - dbResult.feature = JSON.parse(dbResult.features) + dbResult.feature = JSON.parse(dbResult.features); } else { - dbResult.feature = {} + dbResult.feature = {}; } if (r.redis) { - await r.redis.multi() + await r.redis + .multi() .set(cacheKey(id), JSON.stringify(dbResult)) .expire(cacheKey(id), 43200) - .execAsync() + .execAsync(); } } - return dbResult + return dbResult; } -} +}; -export default organizationCache +export default organizationCache; diff --git a/src/server/models/cacheable_queries/user.js b/src/server/models/cacheable_queries/user.js index 18bc71baa..0511de56d 100644 --- a/src/server/models/cacheable_queries/user.js +++ b/src/server/models/cacheable_queries/user.js @@ -1,6 +1,6 @@ -import DataLoader from 'dataloader' -import { r } from '../../models' -import { isRoleGreater } from '../../../lib/permissions' +import DataLoader from "dataloader"; +import { r } from "../../models"; +import { isRoleGreater } from "../../../lib/permissions"; /* KEY: texterauth-${authId} @@ -26,179 +26,197 @@ currentEditors(campaign, user) -> string userOrgsWithRole(role, user.id) -> organization list */ -const userRoleKey = (userId) => `${process.env.CACHE_PREFIX || ''}texterroles-${userId}` -const userAuthKey = (authId) => `${process.env.CACHE_PREFIX || ''}texterauth-${authId}` +const userRoleKey = userId => + `${process.env.CACHE_PREFIX || ""}texterroles-${userId}`; +const userAuthKey = authId => + `${process.env.CACHE_PREFIX || ""}texterauth-${authId}`; -export const accessHierarchy = ['TEXTER', 'SUPERVOLUNTEER', 'ADMIN', 'OWNER'] +export const accessHierarchy = ["TEXTER", "SUPERVOLUNTEER", "ADMIN", "OWNER"]; -const getHighestRolesPerOrg = (userOrgs) => { - const highestRolesPerOrg = {} +const getHighestRolesPerOrg = userOrgs => { + const highestRolesPerOrg = {}; userOrgs.forEach(userOrg => { - const orgId = userOrg.organization_id - const orgRole = userOrg.role - const orgName = userOrg.name + const orgId = userOrg.organization_id; + const orgRole = userOrg.role; + const orgName = userOrg.name; if (highestRolesPerOrg[orgId]) { - if (isRoleGreater( - orgRole, highestRolesPerOrg[orgId].role - )) { - highestRolesPerOrg[orgId].role = orgRole + if (isRoleGreater(orgRole, highestRolesPerOrg[orgId].role)) { + highestRolesPerOrg[orgId].role = orgRole; } } else { - highestRolesPerOrg[orgId] = { id: orgId, role: orgRole, name: orgName } + highestRolesPerOrg[orgId] = { id: orgId, role: orgRole, name: orgName }; } - }) - return highestRolesPerOrg -} - -const dbLoadUserRoles = async (userId) => { - const userOrgs = await r.knex('user_organization') - .where('user_id', userId) - .join('organization', 'user_organization.organization_id', 'organization.id') - .select('user_organization.role', 'user_organization.organization_id', 'organization.name') - - const highestRolesPerOrg = getHighestRolesPerOrg(userOrgs) + }); + return highestRolesPerOrg; +}; + +const dbLoadUserRoles = async userId => { + const userOrgs = await r + .knex("user_organization") + .where("user_id", userId) + .join( + "organization", + "user_organization.organization_id", + "organization.id" + ) + .select( + "user_organization.role", + "user_organization.organization_id", + "organization.name" + ); + + const highestRolesPerOrg = getHighestRolesPerOrg(userOrgs); if (r.redis) { // delete keys first // pass all values to hset instead of looping - const key = userRoleKey(userId) - const mappedHighestRoles = Object.values(highestRolesPerOrg).reduce((acc, orgRole) => { - acc.push(orgRole.id, `${orgRole.role}:${orgRole.name}`) - return acc - }, []) + const key = userRoleKey(userId); + const mappedHighestRoles = Object.values(highestRolesPerOrg).reduce( + (acc, orgRole) => { + acc.push(orgRole.id, `${orgRole.role}:${orgRole.name}`); + return acc; + }, + [] + ); if (mappedHighestRoles.length) { - await r.redis.multi() + await r.redis + .multi() .del(key) .hmset(key, ...mappedHighestRoles) - .execAsync() + .execAsync(); } else { - await r.redis.delAsync(key) + await r.redis.delAsync(key); } } - return highestRolesPerOrg -} + return highestRolesPerOrg; +}; -const loadUserRoles = async (userId) => { +const loadUserRoles = async userId => { if (r.redis) { - const roles = await r.redis.hgetallAsync(userRoleKey(userId)) + const roles = await r.redis.hgetallAsync(userRoleKey(userId)); if (roles) { - const userRoles = {} + const userRoles = {}; Object.keys(roles).forEach(orgId => { - const [highestRole, orgName] = roles[orgId].split(':') - userRoles[orgId] = { id: orgId, name: orgName, role: highestRole } - }) - return userRoles + const [highestRole, orgName] = roles[orgId].split(":"); + userRoles[orgId] = { id: orgId, name: orgName, role: highestRole }; + }); + return userRoles; } } - return await dbLoadUserRoles(userId) -} + return await dbLoadUserRoles(userId); +}; const dbLoadUserAuth = async (field, val) => { - const userAuth = await r.knex('user') + const userAuth = await r + .knex("user") .where(field, val) - .select('*') - .first() + .select("*") + .first(); if (r.redis && userAuth) { - const authKey = userAuthKey(val) - await r.redis.multi() + const authKey = userAuthKey(val); + await r.redis + .multi() .set(authKey, JSON.stringify(userAuth)) .expire(authKey, 43200) - .execAsync() - await dbLoadUserRoles(userAuth.id) + .execAsync(); + await dbLoadUserRoles(userAuth.id); } - return userAuth -} + return userAuth; +}; const userOrgs = async (userId, role) => { - const acceptableRoles = (role - ? accessHierarchy.slice(accessHierarchy.indexOf(role)) - : [...accessHierarchy]) - const orgRoles = await loadUserRoles(userId) - const matchedOrgs = Object.keys(orgRoles).filter(orgId => ( - acceptableRoles.indexOf(orgRoles[orgId].role) !== -1 - )) - return matchedOrgs.map(orgId => orgRoles[orgId]) -} + const acceptableRoles = role + ? accessHierarchy.slice(accessHierarchy.indexOf(role)) + : [...accessHierarchy]; + const orgRoles = await loadUserRoles(userId); + const matchedOrgs = Object.keys(orgRoles).filter( + orgId => acceptableRoles.indexOf(orgRoles[orgId].role) !== -1 + ); + return matchedOrgs.map(orgId => orgRoles[orgId]); +}; const orgRoles = async (userId, orgId) => { - const orgRolesDict = await loadUserRoles(userId) + const orgRolesDict = await loadUserRoles(userId); if (orgId in orgRolesDict) { return accessHierarchy.slice( - 0, 1 + accessHierarchy.indexOf(orgRolesDict[orgId].role)) + 0, + 1 + accessHierarchy.indexOf(orgRolesDict[orgId].role) + ); } - return [] -} + return []; +}; const userOrgHighestRole = async (userId, orgId) => { - let highestRole = '' + let highestRole = ""; if (r.redis) { // cached approach - const userKey = userRoleKey(userId) - const cacheRoleResult = await r.redis.hgetAsync(userKey, orgId) + const userKey = userRoleKey(userId); + const cacheRoleResult = await r.redis.hgetAsync(userKey, orgId); if (cacheRoleResult) { - highestRole = cacheRoleResult.split(':')[0] + highestRole = cacheRoleResult.split(":")[0]; } else { // need to get it from db, and then cache it - const highestRoles = await dbLoadUserRoles(userId) - highestRole = highestRoles[orgId] && highestRoles[orgId].role + const highestRoles = await dbLoadUserRoles(userId); + highestRole = highestRoles[orgId] && highestRoles[orgId].role; } } if (!highestRole) { // regular DB approach - const roles = await r.knex('user_organization') - .select('role') - .where({ user_id: userId, - organization_id: orgId }) + const roles = await r + .knex("user_organization") + .select("role") + .where({ user_id: userId, organization_id: orgId }); if (roles.length) { highestRole = roles .map(ri => ri.role) - .sort((a, b) => accessHierarchy.indexOf(b) - accessHierarchy.indexOf(a))[0] + .sort( + (a, b) => accessHierarchy.indexOf(b) - accessHierarchy.indexOf(a) + )[0]; } } - return highestRole -} + return highestRole; +}; const userHasRole = async (user, orgId, role) => { - const acceptableRoles = accessHierarchy.slice(accessHierarchy.indexOf(role)) - let highestRole = '' + const acceptableRoles = accessHierarchy.slice(accessHierarchy.indexOf(role)); + let highestRole = ""; if (user.orgRoleCache) { - highestRole = await user.orgRoleCache.load(`${user.id}:${orgId}`) + highestRole = await user.orgRoleCache.load(`${user.id}:${orgId}`); } else { - highestRole = await userOrgHighestRole(user.id, orgId) + highestRole = await userOrgHighestRole(user.id, orgId); } - return Boolean(highestRole && acceptableRoles.indexOf(highestRole) >= 0) -} - + return Boolean(highestRole && acceptableRoles.indexOf(highestRole) >= 0); +}; const userLoggedIn = async (field, val) => { - if (field !== 'id' && field !== 'auth0_id') { - return null + if (field !== "id" && field !== "auth0_id") { + return null; } - const authKey = userAuthKey(val) - let user = null + const authKey = userAuthKey(val); + let user = null; if (r.redis) { - const cachedAuth = await r.redis.getAsync(authKey) + const cachedAuth = await r.redis.getAsync(authKey); if (cachedAuth) { - user = JSON.parse(cachedAuth) + user = JSON.parse(cachedAuth); } } if (!user) { - user = await dbLoadUserAuth(field, val) + user = await dbLoadUserAuth(field, val); } if (user) { // This will be per-request, and can cache through multiple tests - user.orgRoleCache = new DataLoader(async (keys) => ( - keys.map(async (key) => { - const [userId, orgId] = key.split(':') - return await userOrgHighestRole(userId, orgId) + user.orgRoleCache = new DataLoader(async keys => + keys.map(async key => { + const [userId, orgId] = key.split(":"); + return await userOrgHighestRole(userId, orgId); }) - )) + ); } - return user -} + return user; +}; const userCache = { userHasRole, @@ -207,13 +225,13 @@ const userCache = { orgRoles, clearUser: async (userId, authId) => { if (r.redis) { - await r.redis.delAsync(userRoleKey(userId)) - await r.redis.delAsync(userAuthKey(userId)) + await r.redis.delAsync(userRoleKey(userId)); + await r.redis.delAsync(userAuthKey(userId)); if (authId) { - await r.redis.delAsync(userAuthKey(authId)) + await r.redis.delAsync(userAuthKey(authId)); } } } -} +}; -export default userCache +export default userCache; diff --git a/src/server/models/campaign-contact.js b/src/server/models/campaign-contact.js index e963e6d6c..2179e6312 100644 --- a/src/server/models/campaign-contact.js +++ b/src/server/models/campaign-contact.js @@ -1,46 +1,56 @@ -import thinky from './thinky' -const type = thinky.type -import { requiredString, optionalString, timestamp } from './custom-types' +import thinky from "./thinky"; +const type = thinky.type; +import { requiredString, optionalString, timestamp } from "./custom-types"; -import Campaign from './campaign' -import Assignment from './assignment' +import Campaign from "./campaign"; +import Assignment from "./assignment"; -const CampaignContact = thinky.createModel('campaign_contact', type.object().schema({ - id: type.string(), - campaign_id: requiredString(), - assignment_id: optionalString(), - external_id: optionalString().stopReference(), - first_name: optionalString(), - last_name: optionalString(), - cell: requiredString(), - zip: optionalString(), - custom_fields: requiredString().default('{}'), - created_at: timestamp(), - updated_at: timestamp(), - message_status: requiredString() - .enum([ - 'needsMessage', - 'needsResponse', - 'convo', - 'messaged', - 'closed', - 'UPDATING' - ]) - .default('needsMessage'), - is_opted_out: type.boolean().default(false), - timezone_offset: type - .string() - .default('') - .required() -}).allowExtra(false), { noAutoCreation: true }) +const CampaignContact = thinky.createModel( + "campaign_contact", + type + .object() + .schema({ + id: type.string(), + campaign_id: requiredString(), + assignment_id: optionalString(), + external_id: optionalString().stopReference(), + first_name: optionalString(), + last_name: optionalString(), + cell: requiredString(), + zip: optionalString(), + custom_fields: requiredString().default("{}"), + created_at: timestamp(), + updated_at: timestamp(), + message_status: requiredString() + .enum([ + "needsMessage", + "needsResponse", + "convo", + "messaged", + "closed", + "UPDATING" + ]) + .default("needsMessage"), + is_opted_out: type.boolean().default(false), + timezone_offset: type + .string() + .default("") + .required() + }) + .allowExtra(false), + { noAutoCreation: true } +); -CampaignContact.ensureIndex('assignment_id') -CampaignContact.ensureIndex('campaign_id') -CampaignContact.ensureIndex('cell') -CampaignContact.ensureIndex('campaign_assignment', (doc) => [doc('campaign_id'), doc('assignment_id')]) -CampaignContact.ensureIndex('assignment_timezone_offset', (doc) => [ - doc('assignment_id'), - doc('timezone_offset') -]) +CampaignContact.ensureIndex("assignment_id"); +CampaignContact.ensureIndex("campaign_id"); +CampaignContact.ensureIndex("cell"); +CampaignContact.ensureIndex("campaign_assignment", doc => [ + doc("campaign_id"), + doc("assignment_id") +]); +CampaignContact.ensureIndex("assignment_timezone_offset", doc => [ + doc("assignment_id"), + doc("timezone_offset") +]); -export default CampaignContact +export default CampaignContact; diff --git a/src/server/models/campaign.js b/src/server/models/campaign.js index edff975e1..764859fe3 100644 --- a/src/server/models/campaign.js +++ b/src/server/models/campaign.js @@ -1,61 +1,65 @@ -import thinky from './thinky' -const type = thinky.type -import { requiredString, optionalString, timestamp } from './custom-types' +import thinky from "./thinky"; +const type = thinky.type; +import { requiredString, optionalString, timestamp } from "./custom-types"; -import Organization from './organization' +import Organization from "./organization"; -const Campaign = thinky.createModel('campaign', type.object().schema({ - id: type.string(), - organization_id: requiredString(), - creator_id: type.string().allowNull(true).foreign('user'), - title: optionalString(), - description: optionalString(), - is_started: type - .boolean() - .required(), - due_by: type - .date() - .required() - .default(null), - created_at: timestamp(), - is_archived: type - .boolean() - .required(), - use_dynamic_assignment: type - .boolean() - .required(), - logo_image_url: type.string(), - intro_html: type.string(), - primary_color: type.string(), - override_organization_texting_hours: type - .boolean() - .required() - .default(false), - texting_hours_enforced: type - .boolean() - .required() - .default(true), - texting_hours_start: type.number() - .integer() - .required() - .min(0) - .max(23) - .default(9), - texting_hours_end: type.number() - .integer() - .required() - .min(0) - .max(23) - .default(21), - timezone: type - .string() - .required() - .default('US/Eastern') +const Campaign = thinky.createModel( + "campaign", + type + .object() + .schema({ + id: type.string(), + organization_id: requiredString(), + creator_id: type + .string() + .allowNull(true) + .foreign("user"), + title: optionalString(), + description: optionalString(), + is_started: type.boolean().required(), + due_by: type + .date() + .required() + .default(null), + created_at: timestamp(), + is_archived: type.boolean().required(), + use_dynamic_assignment: type.boolean().required(), + logo_image_url: type.string(), + intro_html: type.string(), + primary_color: type.string(), + override_organization_texting_hours: type + .boolean() + .required() + .default(false), + texting_hours_enforced: type + .boolean() + .required() + .default(true), + texting_hours_start: type + .number() + .integer() + .required() + .min(0) + .max(23) + .default(9), + texting_hours_end: type + .number() + .integer() + .required() + .min(0) + .max(23) + .default(21), + timezone: type + .string() + .required() + .default("US/Eastern") + }) + .allowExtra(false), + { noAutoCreation: true } +); +Campaign.ensureIndex("organization_id"); +Campaign.ensureIndex("creator_id"); -}).allowExtra(false), { noAutoCreation: true }) - -Campaign.ensureIndex('organization_id') -Campaign.ensureIndex('creator_id') - -export default Campaign +export default Campaign; diff --git a/src/server/models/canned-response.js b/src/server/models/canned-response.js index 893a88b5b..3480d6ad5 100644 --- a/src/server/models/canned-response.js +++ b/src/server/models/canned-response.js @@ -1,20 +1,27 @@ -import thinky from './thinky' -const type = thinky.type -import { requiredString, timestamp, optionalString } from './custom-types' +import thinky from "./thinky"; +const type = thinky.type; +import { requiredString, timestamp, optionalString } from "./custom-types"; -import Campaign from './campaign' -import User from './user' +import Campaign from "./campaign"; +import User from "./user"; -const CannedResponse = thinky.createModel('canned_response', type.object().schema({ - id: type.string(), - campaign_id: requiredString(), - text: requiredString(), - title: requiredString(), - user_id: optionalString(), - created_at: timestamp() -}).allowExtra(false), { noAutoCreation: true }) +const CannedResponse = thinky.createModel( + "canned_response", + type + .object() + .schema({ + id: type.string(), + campaign_id: requiredString(), + text: requiredString(), + title: requiredString(), + user_id: optionalString(), + created_at: timestamp() + }) + .allowExtra(false), + { noAutoCreation: true } +); -CannedResponse.ensureIndex('campaign_id') -CannedResponse.ensureIndex('user_id') +CannedResponse.ensureIndex("campaign_id"); +CannedResponse.ensureIndex("user_id"); -export default CannedResponse +export default CannedResponse; diff --git a/src/server/models/custom-types.js b/src/server/models/custom-types.js index 94f25d1d0..f19b7b4b7 100644 --- a/src/server/models/custom-types.js +++ b/src/server/models/custom-types.js @@ -1,6 +1,6 @@ -import thinky from './thinky' -const type = thinky.type -const r = thinky.r +import thinky from "./thinky"; +const type = thinky.type; +const r = thinky.r; // In order to not end up with optional // strings that are half null and half @@ -13,7 +13,7 @@ export function requiredString() { .string() .required() .allowNull(false) - .min(1) + .min(1); } export function optionalString() { @@ -21,7 +21,7 @@ export function optionalString() { .string() .required() .allowNull(false) - .default('') + .default(""); } export function timestamp() { @@ -29,10 +29,9 @@ export function timestamp() { .date() .required() .allowNull(false) - .default(r.now()) + .default(r.now()); } export function optionalTimestamp() { - return type - .date() + return type.date(); } diff --git a/src/server/models/datawarehouse.js b/src/server/models/datawarehouse.js index 8dfd5e914..d3c5c5745 100644 --- a/src/server/models/datawarehouse.js +++ b/src/server/models/datawarehouse.js @@ -1,6 +1,6 @@ -import knex from 'knex' +import knex from "knex"; -let config +let config; if (process.env.WAREHOUSE_DB_TYPE) { config = { @@ -12,9 +12,7 @@ if (process.env.WAREHOUSE_DB_TYPE) { password: process.env.WAREHOUSE_DB_PASSWORD, user: process.env.WAREHOUSE_DB_USER } - } + }; } -export default (process.env.WAREHOUSE_DB_TYPE - ? () => knex(config) - : null) +export default process.env.WAREHOUSE_DB_TYPE ? () => knex(config) : null; diff --git a/src/server/models/index.js b/src/server/models/index.js index 24ea62315..f82724dc5 100644 --- a/src/server/models/index.js +++ b/src/server/models/index.js @@ -1,85 +1,84 @@ -import DataLoader from 'dataloader' +import DataLoader from "dataloader"; // Import models in order that creates referenced tables before foreign keys -import User from './user' -import PendingMessagePart from './pending-message-part' -import Organization from './organization' -import Campaign from './campaign' -import Assignment from './assignment' -import CampaignContact from './campaign-contact' -import InteractionStep from './interaction-step' -import QuestionResponse from './question-response' -import OptOut from './opt-out' -import JobRequest from './job-request' -import Invite from './invite' -import CannedResponse from './canned-response' -import UserOrganization from './user-organization' -import UserCell from './user-cell' -import Message from './message' -import ZipCode from './zip-code' -import Log from './log' +import User from "./user"; +import PendingMessagePart from "./pending-message-part"; +import Organization from "./organization"; +import Campaign from "./campaign"; +import Assignment from "./assignment"; +import CampaignContact from "./campaign-contact"; +import InteractionStep from "./interaction-step"; +import QuestionResponse from "./question-response"; +import OptOut from "./opt-out"; +import JobRequest from "./job-request"; +import Invite from "./invite"; +import CannedResponse from "./canned-response"; +import UserOrganization from "./user-organization"; +import UserCell from "./user-cell"; +import Message from "./message"; +import ZipCode from "./zip-code"; +import Log from "./log"; -import thinky from './thinky' -import datawarehouse from './datawarehouse' +import thinky from "./thinky"; +import datawarehouse from "./datawarehouse"; -import cacheableData from './cacheable_queries' +import cacheableData from "./cacheable_queries"; function createLoader(model, opts) { - const idKey = (opts && opts.idKey) || 'id' - const cacheObj = opts && opts.cacheObj - return new DataLoader(async (keys) => { + const idKey = (opts && opts.idKey) || "id"; + const cacheObj = opts && opts.cacheObj; + return new DataLoader(async keys => { if (cacheObj && cacheObj.load) { - return keys.map(async (key) => await cacheObj.load(key)) + return keys.map(async key => await cacheObj.load(key)); } - const docs = await model.getAll(...keys, { index: idKey }) - return keys.map((key) => ( - docs.find((doc) => doc[idKey].toString() === key.toString()) - )) - }) + const docs = await model.getAll(...keys, { index: idKey }); + return keys.map(key => + docs.find(doc => doc[idKey].toString() === key.toString()) + ); + }); } // This is in dependency order, so tables are after their dependencies const tableList = [ - 'organization', // good candidate? - 'user', // good candidate - 'campaign', // good candidate - 'assignment', + "organization", // good candidate? + "user", // good candidate + "campaign", // good candidate + "assignment", // the rest are alphabetical - 'campaign_contact', // ?good candidate (or by cell) - 'canned_response', // good candidate - 'interaction_step', - 'invite', - 'job_request', - 'log', - 'message', - 'opt_out', // good candidate - 'pending_message_part', - 'question_response', - 'user_cell', - 'user_organization', - 'zip_code' // good candidate (or by contact)? -] + "campaign_contact", // ?good candidate (or by cell) + "canned_response", // good candidate + "interaction_step", + "invite", + "job_request", + "log", + "message", + "opt_out", // good candidate + "pending_message_part", + "question_response", + "user_cell", + "user_organization", + "zip_code" // good candidate (or by contact)? +]; function createTablesIfNecessary() { // builds the database if we don't see the organization table - return thinky.k.schema.hasTable('organization').then( - (tableExists) => { - if (!tableExists) { - console.log('CREATING DATABASE SCHEMA') - return thinky.r.k.migrate.latest() - } - }) + return thinky.k.schema.hasTable("organization").then(tableExists => { + if (!tableExists) { + console.log("CREATING DATABASE SCHEMA"); + return thinky.r.k.migrate.latest(); + } + }); } function createTables() { - return thinky.r.knex.migrate.latest() + return thinky.r.knex.migrate.latest(); } function dropTables() { // thinky.r.knex.destroy() DOES NOT WORK return thinky.dropTables(tableList).then(function() { - return thinky.dropTables(['knex_migrations', 'knex_migrations_lock']) - }) + return thinky.dropTables(["knex_migrations", "knex_migrations_lock"]); + }); } const loaders = { @@ -88,11 +87,13 @@ const loaders = { assignment: createLoader(Assignment), campaign: createLoader(Campaign, { cacheObj: cacheableData.campaign }), invite: createLoader(Invite), - organization: createLoader(Organization, { cacheObj: cacheableData.organization }), + organization: createLoader(Organization, { + cacheObj: cacheableData.organization + }), user: createLoader(User), interactionStep: createLoader(InteractionStep), campaignContact: createLoader(CampaignContact), - zipCode: createLoader(ZipCode, { idKey: 'zip' }), + zipCode: createLoader(ZipCode, { idKey: "zip" }), log: createLoader(Log), cannedResponse: createLoader(CannedResponse), jobRequest: createLoader(JobRequest), @@ -102,11 +103,11 @@ const loaders = { questionResponse: createLoader(QuestionResponse), userCell: createLoader(UserCell), userOrganization: createLoader(UserOrganization) -} +}; -const createLoaders = () => loaders +const createLoaders = () => loaders; -const r = thinky.r +const r = thinky.r; export { loaders, @@ -134,4 +135,4 @@ export { User, ZipCode, Log -} +}; diff --git a/src/server/models/interaction-step.js b/src/server/models/interaction-step.js index 4bc878f92..1712580d2 100644 --- a/src/server/models/interaction-step.js +++ b/src/server/models/interaction-step.js @@ -1,31 +1,41 @@ -import thinky from './thinky' -const type = thinky.type -import { requiredString, optionalString, timestamp } from './custom-types' +import thinky from "./thinky"; +const type = thinky.type; +import { requiredString, optionalString, timestamp } from "./custom-types"; -import Campaign from './campaign' +import Campaign from "./campaign"; -const InteractionStep = thinky.createModel('interaction_step', type.object().schema({ - id: type.string(), - campaign_id: requiredString(), - // PROMPTS: - question: optionalString(), - script: optionalString(), - created_at: timestamp(), +const InteractionStep = thinky.createModel( + "interaction_step", + type + .object() + .schema({ + id: type.string(), + campaign_id: requiredString(), + // PROMPTS: + question: optionalString(), + script: optionalString(), + created_at: timestamp(), - // Previously there were answer options, and no such thing as - // parents/ancestors. This was pretty cool, in-theory - // since you could have many paths that led into a unified - // path. However, the UI didn't allow it, so we are going - // to squash that dream, at least until after the db migration + // Previously there were answer options, and no such thing as + // parents/ancestors. This was pretty cool, in-theory + // since you could have many paths that led into a unified + // path. However, the UI didn't allow it, so we are going + // to squash that dream, at least until after the db migration - // FIELDS FOR SUB-INTERACTIONS (only): - parent_interaction_id: optionalString().foreign('interaction_step'), - answer_option: optionalString(), // (was 'value') - answer_actions: optionalString(), - is_deleted: type.boolean().default(false).allowNull(false) -}).allowExtra(false), { noAutoCreation: true }) + // FIELDS FOR SUB-INTERACTIONS (only): + parent_interaction_id: optionalString().foreign("interaction_step"), + answer_option: optionalString(), // (was 'value') + answer_actions: optionalString(), + is_deleted: type + .boolean() + .default(false) + .allowNull(false) + }) + .allowExtra(false), + { noAutoCreation: true } +); -InteractionStep.ensureIndex('campaign_id') -InteractionStep.ensureIndex('parent_interaction_id') +InteractionStep.ensureIndex("campaign_id"); +InteractionStep.ensureIndex("parent_interaction_id"); -export default InteractionStep +export default InteractionStep; diff --git a/src/server/models/invite.js b/src/server/models/invite.js index e549d5d10..ead94c6ae 100644 --- a/src/server/models/invite.js +++ b/src/server/models/invite.js @@ -1,17 +1,24 @@ -import thinky from './thinky' -const type = thinky.type -import { timestamp } from './custom-types' +import thinky from "./thinky"; +const type = thinky.type; +import { timestamp } from "./custom-types"; -const Invite = thinky.createModel('invite', type.object().schema({ - id: type.string(), - is_valid: type - .boolean() - .required() - .allowNull(false), - hash: type.string(), - created_at: timestamp() -}).allowExtra(false), { noAutoCreation: true }) +const Invite = thinky.createModel( + "invite", + type + .object() + .schema({ + id: type.string(), + is_valid: type + .boolean() + .required() + .allowNull(false), + hash: type.string(), + created_at: timestamp() + }) + .allowExtra(false), + { noAutoCreation: true } +); -Invite.ensureIndex('is_valid') +Invite.ensureIndex("is_valid"); -export default Invite +export default Invite; diff --git a/src/server/models/job-request.js b/src/server/models/job-request.js index 6a0dfb575..594cb7af7 100644 --- a/src/server/models/job-request.js +++ b/src/server/models/job-request.js @@ -1,33 +1,40 @@ -import thinky from './thinky' -const type = thinky.type -import { optionalString, requiredString, timestamp } from './custom-types' +import thinky from "./thinky"; +const type = thinky.type; +import { optionalString, requiredString, timestamp } from "./custom-types"; -import Campaign from './campaign' +import Campaign from "./campaign"; -const JobRequest = thinky.createModel('job_request', type.object().schema({ - id: type.string(), - campaign_id: requiredString(), - payload: requiredString(), - queue_name: requiredString(), - job_type: requiredString(), - result_message: type.string().default(''), - locks_queue: type - .boolean() - .required() - .default(false), - assigned: type - .boolean() - .required() - .default(false), - status: type - .number() - .integer() - .required() - .default(0), - updated_at: timestamp(), - created_at: timestamp() -}).allowExtra(false), { noAutoCreation: true }) +const JobRequest = thinky.createModel( + "job_request", + type + .object() + .schema({ + id: type.string(), + campaign_id: requiredString(), + payload: requiredString(), + queue_name: requiredString(), + job_type: requiredString(), + result_message: type.string().default(""), + locks_queue: type + .boolean() + .required() + .default(false), + assigned: type + .boolean() + .required() + .default(false), + status: type + .number() + .integer() + .required() + .default(0), + updated_at: timestamp(), + created_at: timestamp() + }) + .allowExtra(false), + { noAutoCreation: true } +); -JobRequest.ensureIndex('queue_name') +JobRequest.ensureIndex("queue_name"); -export default JobRequest +export default JobRequest; diff --git a/src/server/models/log.js b/src/server/models/log.js index 82b1bfa32..b6c408bb1 100644 --- a/src/server/models/log.js +++ b/src/server/models/log.js @@ -1,13 +1,20 @@ -import thinky from './thinky' -import { requiredString, timestamp } from './custom-types' +import thinky from "./thinky"; +import { requiredString, timestamp } from "./custom-types"; -const type = thinky.type +const type = thinky.type; -const Log = thinky.createModel('log', type.object().schema({ - id: type.string(), - message_sid: requiredString(), - body: type.string(), - created_at: timestamp() -}).allowExtra(false), { noAutoCreation: true }) +const Log = thinky.createModel( + "log", + type + .object() + .schema({ + id: type.string(), + message_sid: requiredString(), + body: type.string(), + created_at: timestamp() + }) + .allowExtra(false), + { noAutoCreation: true } +); -export default Log +export default Log; diff --git a/src/server/models/message.js b/src/server/models/message.js index bd08f9e06..33ee0f13b 100644 --- a/src/server/models/message.js +++ b/src/server/models/message.js @@ -1,45 +1,64 @@ -import thinky from './thinky' -const type = thinky.type -import { requiredString, optionalString, timestamp, optionalTimestamp } from './custom-types' +import thinky from "./thinky"; +const type = thinky.type; +import { + requiredString, + optionalString, + timestamp, + optionalTimestamp +} from "./custom-types"; -import User from './user' -import Assignment from './assignment' +import User from "./user"; +import Assignment from "./assignment"; -const Message = thinky.createModel('message', type.object().schema({ - id: type.string(), - // Assignments may change, so attribute the message to the specific - // texter account that sent it - user_id: type.string().allowNull(true), - // theoretically the phone number - // userNumber should stay constant for a - // texter, but this is not guaranteed - user_number: optionalString(), - contact_number: requiredString(), - is_from_contact: type - .boolean() - .required() - .allowNull(false), - text: optionalString(), - // for errors,etc returned back by the service - // will be several json strings appended together, so JSON.parse will NOT work - service_response: optionalString(), - assignment_id: requiredString(), - service: optionalString(), - service_id: optionalString().stopReference(), - send_status: requiredString().enum('QUEUED', 'SENDING', 'SENT', 'DELIVERED', 'ERROR', 'PAUSED', 'NOT_ATTEMPTED'), - created_at: timestamp(), - queued_at: timestamp(), - sent_at: timestamp(), - service_response_at: timestamp(), - send_before: optionalTimestamp() -}).allowExtra(false), { noAutoCreation: true, - dependencies: [User, Assignment] }) +const Message = thinky.createModel( + "message", + type + .object() + .schema({ + id: type.string(), + // Assignments may change, so attribute the message to the specific + // texter account that sent it + user_id: type.string().allowNull(true), + // theoretically the phone number + // userNumber should stay constant for a + // texter, but this is not guaranteed + user_number: optionalString(), + contact_number: requiredString(), + is_from_contact: type + .boolean() + .required() + .allowNull(false), + text: optionalString(), + // for errors,etc returned back by the service + // will be several json strings appended together, so JSON.parse will NOT work + service_response: optionalString(), + assignment_id: requiredString(), + service: optionalString(), + service_id: optionalString().stopReference(), + send_status: requiredString().enum( + "QUEUED", + "SENDING", + "SENT", + "DELIVERED", + "ERROR", + "PAUSED", + "NOT_ATTEMPTED" + ), + created_at: timestamp(), + queued_at: timestamp(), + sent_at: timestamp(), + service_response_at: timestamp(), + send_before: optionalTimestamp() + }) + .allowExtra(false), + { noAutoCreation: true, dependencies: [User, Assignment] } +); -Message.ensureIndex('user_id') -Message.ensureIndex('assignment_id') -Message.ensureIndex('send_status') -Message.ensureIndex('user_number') -Message.ensureIndex('contact_number') -Message.ensureIndex('service_id') +Message.ensureIndex("user_id"); +Message.ensureIndex("assignment_id"); +Message.ensureIndex("send_status"); +Message.ensureIndex("user_number"); +Message.ensureIndex("contact_number"); +Message.ensureIndex("service_id"); -export default Message +export default Message; diff --git a/src/server/models/opt-out.js b/src/server/models/opt-out.js index 9c34d8136..46ed1ce1c 100644 --- a/src/server/models/opt-out.js +++ b/src/server/models/opt-out.js @@ -1,23 +1,28 @@ -import thinky from './thinky' -const type = thinky.type -import { optionalString, requiredString, timestamp } from './custom-types' +import thinky from "./thinky"; +const type = thinky.type; +import { optionalString, requiredString, timestamp } from "./custom-types"; -import Organization from './organization' -import Assignment from './assignment' +import Organization from "./organization"; +import Assignment from "./assignment"; -const OptOut = thinky.createModel('opt_out', type.object().schema({ - id: type.string(), - cell: requiredString(), - assignment_id: requiredString(), - organization_id: requiredString(), - reason_code: optionalString(), - created_at: timestamp() -}).allowExtra(false), { noAutoCreation: true, - dependencies: [Organization, Assignment] - }) +const OptOut = thinky.createModel( + "opt_out", + type + .object() + .schema({ + id: type.string(), + cell: requiredString(), + assignment_id: requiredString(), + organization_id: requiredString(), + reason_code: optionalString(), + created_at: timestamp() + }) + .allowExtra(false), + { noAutoCreation: true, dependencies: [Organization, Assignment] } +); -OptOut.ensureIndex('cell') -OptOut.ensureIndex('assignment_id') -OptOut.ensureIndex('organization_id') +OptOut.ensureIndex("cell"); +OptOut.ensureIndex("assignment_id"); +OptOut.ensureIndex("organization_id"); -export default OptOut +export default OptOut; diff --git a/src/server/models/organization.js b/src/server/models/organization.js index 4d8d4b4b4..0f60cd62f 100644 --- a/src/server/models/organization.js +++ b/src/server/models/organization.js @@ -1,29 +1,41 @@ -import thinky from './thinky' -const type = thinky.type -import { requiredString, timestamp } from './custom-types' +import thinky from "./thinky"; +const type = thinky.type; +import { requiredString, timestamp } from "./custom-types"; -const Organization = thinky.createModel('organization', type.object().schema({ - id: type.string(), - uuid: type.string(), - name: requiredString(), - created_at: timestamp(), - features: type.string().required().default(''), // should be JSON - texting_hours_enforced: type - .boolean() - .required() - .default(false), - texting_hours_start: type.number() - .integer() - .required() - .min(0) - .max(24) - .default(9), - texting_hours_end: type.number() - .integer() - .required() - .min(0) - .max(24) - .default(21) -}).allowExtra(false), { noAutoCreation: true }) +const Organization = thinky.createModel( + "organization", + type + .object() + .schema({ + id: type.string(), + uuid: type.string(), + name: requiredString(), + created_at: timestamp(), + features: type + .string() + .required() + .default(""), // should be JSON + texting_hours_enforced: type + .boolean() + .required() + .default(false), + texting_hours_start: type + .number() + .integer() + .required() + .min(0) + .max(24) + .default(9), + texting_hours_end: type + .number() + .integer() + .required() + .min(0) + .max(24) + .default(21) + }) + .allowExtra(false), + { noAutoCreation: true } +); -export default Organization +export default Organization; diff --git a/src/server/models/pending-message-part.js b/src/server/models/pending-message-part.js index 970ad4af9..6a844fdab 100644 --- a/src/server/models/pending-message-part.js +++ b/src/server/models/pending-message-part.js @@ -1,6 +1,6 @@ -import thinky from './thinky' -const type = thinky.type -import { requiredString, optionalString, timestamp } from './custom-types' +import thinky from "./thinky"; +const type = thinky.type; +import { requiredString, optionalString, timestamp } from "./custom-types"; // this mostly exists because of: // https://help.nexmo.com/hc/en-us/articles/205704158-Inbound-SMS-concatenation @@ -8,18 +8,27 @@ import { requiredString, optionalString, timestamp } from './custom-types' // https://docs.nexmo.com/messaging/sms-api // Twilio auto-assembles it for us (thank you!), so this isn't an issue for twilio -const PendingMessagePart = thinky.createModel('pending_message_part', type.object().schema({ - id: type.string(), - service: requiredString(), - service_id: requiredString().stopReference(), - parent_id: optionalString().allowNull(true).stopReference(), - service_message: requiredString(), // JSON - user_number: optionalString(), - contact_number: requiredString(), - created_at: timestamp() -}).allowExtra(false), { noAutoCreation: true }) +const PendingMessagePart = thinky.createModel( + "pending_message_part", + type + .object() + .schema({ + id: type.string(), + service: requiredString(), + service_id: requiredString().stopReference(), + parent_id: optionalString() + .allowNull(true) + .stopReference(), + service_message: requiredString(), // JSON + user_number: optionalString(), + contact_number: requiredString(), + created_at: timestamp() + }) + .allowExtra(false), + { noAutoCreation: true } +); -PendingMessagePart.ensureIndex('parent_id') -PendingMessagePart.ensureIndex('service') +PendingMessagePart.ensureIndex("parent_id"); +PendingMessagePart.ensureIndex("service"); -export default PendingMessagePart +export default PendingMessagePart; diff --git a/src/server/models/question-response.js b/src/server/models/question-response.js index 40f5ec605..0aa14edce 100644 --- a/src/server/models/question-response.js +++ b/src/server/models/question-response.js @@ -1,21 +1,26 @@ -import thinky from './thinky' -const type = thinky.type -import { requiredString, timestamp } from './custom-types' +import thinky from "./thinky"; +const type = thinky.type; +import { requiredString, timestamp } from "./custom-types"; -import CampaignContact from './campaign-contact' -import InteractionStep from './interaction-step' +import CampaignContact from "./campaign-contact"; +import InteractionStep from "./interaction-step"; -const QuestionResponse = thinky.createModel('question_response', type.object().schema({ - id: type.string(), - campaign_contact_id: requiredString(), - interaction_step_id: requiredString(), - value: requiredString(), - created_at: timestamp() -}).allowExtra(false), { noAutoCreation: true, - dependencies: [CampaignContact, InteractionStep] - }) +const QuestionResponse = thinky.createModel( + "question_response", + type + .object() + .schema({ + id: type.string(), + campaign_contact_id: requiredString(), + interaction_step_id: requiredString(), + value: requiredString(), + created_at: timestamp() + }) + .allowExtra(false), + { noAutoCreation: true, dependencies: [CampaignContact, InteractionStep] } +); -QuestionResponse.ensureIndex('campaign_contact_id') -QuestionResponse.ensureIndex('interaction_step_id') +QuestionResponse.ensureIndex("campaign_contact_id"); +QuestionResponse.ensureIndex("interaction_step_id"); -export default QuestionResponse +export default QuestionResponse; diff --git a/src/server/models/thinky.js b/src/server/models/thinky.js index 7fdff5e1b..77883c12a 100644 --- a/src/server/models/thinky.js +++ b/src/server/models/thinky.js @@ -1,34 +1,34 @@ -import dumbThinky from 'rethink-knex-adapter' -import redis from 'redis' -import bluebird from 'bluebird' -import config from '../knex-connect' +import dumbThinky from "rethink-knex-adapter"; +import redis from "redis"; +import bluebird from "bluebird"; +import config from "../knex-connect"; -bluebird.promisifyAll(redis.RedisClient.prototype) -bluebird.promisifyAll(redis.Multi.prototype) +bluebird.promisifyAll(redis.RedisClient.prototype); +bluebird.promisifyAll(redis.Multi.prototype); // Instantiate the rethink-knex-adapter using the config defined in // /src/server/knex.js. -const thinkyConn = dumbThinky(config) +const thinkyConn = dumbThinky(config); -thinkyConn.r.getCount = async (query) => { +thinkyConn.r.getCount = async query => { // helper method to get a count result // with fewer bugs. Using knex's .count() // results in a 'count' key on postgres, but a 'count(*)' key // on sqlite -- ridiculous. This smooths that out if (Array.isArray(query)) { - return query.length + return query.length; } - return Number((await query.count('* as count').first()).count) -} + return Number((await query.count("* as count").first()).count); +}; if (process.env.REDIS_URL) { - thinkyConn.r.redis = redis.createClient({ url: process.env.REDIS_URL }) + thinkyConn.r.redis = redis.createClient({ url: process.env.REDIS_URL }); } else if (process.env.REDIS_FAKE) { - const fakeredis = require('fakeredis') - bluebird.promisifyAll(fakeredis.RedisClient.prototype) - bluebird.promisifyAll(fakeredis.Multi.prototype) + const fakeredis = require("fakeredis"); + bluebird.promisifyAll(fakeredis.RedisClient.prototype); + bluebird.promisifyAll(fakeredis.Multi.prototype); - thinkyConn.r.redis = fakeredis.createClient() + thinkyConn.r.redis = fakeredis.createClient(); } -export default thinkyConn +export default thinkyConn; diff --git a/src/server/models/user-cell.js b/src/server/models/user-cell.js index 9968035cb..f1e77f5a5 100644 --- a/src/server/models/user-cell.js +++ b/src/server/models/user-cell.js @@ -1,22 +1,27 @@ -import thinky from './thinky' -const type = thinky.type -import { requiredString } from './custom-types' +import thinky from "./thinky"; +const type = thinky.type; +import { requiredString } from "./custom-types"; -import User from './user' +import User from "./user"; -const UserCell = thinky.createModel('user_cell', type.object().schema({ - id: type.string(), - cell: requiredString(), - user_id: requiredString(), - service: type.string() - .required() - .enum('nexmo', 'twilio'), - is_primary: type.boolean() - .required() -}).allowExtra(false), { noAutoCreation: true, - dependencies: [User] - }) +const UserCell = thinky.createModel( + "user_cell", + type + .object() + .schema({ + id: type.string(), + cell: requiredString(), + user_id: requiredString(), + service: type + .string() + .required() + .enum("nexmo", "twilio"), + is_primary: type.boolean().required() + }) + .allowExtra(false), + { noAutoCreation: true, dependencies: [User] } +); -UserCell.ensureIndex('user_id') +UserCell.ensureIndex("user_id"); -export default UserCell +export default UserCell; diff --git a/src/server/models/user-organization.js b/src/server/models/user-organization.js index 38925352b..7953866e1 100644 --- a/src/server/models/user-organization.js +++ b/src/server/models/user-organization.js @@ -1,21 +1,29 @@ -import thinky from './thinky' -const type = thinky.type -import { requiredString } from './custom-types' +import thinky from "./thinky"; +const type = thinky.type; +import { requiredString } from "./custom-types"; -import User from './user' -import Organization from './organization' +import User from "./user"; +import Organization from "./organization"; -const UserOrganization = thinky.createModel('user_organization', type.object().schema({ - id: type.string(), - user_id: requiredString(), - organization_id: requiredString(), - role: requiredString().enum('OWNER', 'ADMIN', 'SUPERVOLUNTEER', 'TEXTER') -}).allowExtra(false), { noAutoCreation: true, - dependencies: [User, Organization] - }) +const UserOrganization = thinky.createModel( + "user_organization", + type + .object() + .schema({ + id: type.string(), + user_id: requiredString(), + organization_id: requiredString(), + role: requiredString().enum("OWNER", "ADMIN", "SUPERVOLUNTEER", "TEXTER") + }) + .allowExtra(false), + { noAutoCreation: true, dependencies: [User, Organization] } +); -UserOrganization.ensureIndex('user_id') -UserOrganization.ensureIndex('organization_id') -UserOrganization.ensureIndex('organization_user', (doc) => [doc('organization_id'), doc('user_id')]) +UserOrganization.ensureIndex("user_id"); +UserOrganization.ensureIndex("organization_id"); +UserOrganization.ensureIndex("organization_user", doc => [ + doc("organization_id"), + doc("user_id") +]); -export default UserOrganization +export default UserOrganization; diff --git a/src/server/models/user.js b/src/server/models/user.js index d21e7c06f..0da6baae9 100644 --- a/src/server/models/user.js +++ b/src/server/models/user.js @@ -1,19 +1,26 @@ -import thinky from './thinky' -const type = thinky.type -import { requiredString, timestamp } from './custom-types' +import thinky from "./thinky"; +const type = thinky.type; +import { requiredString, timestamp } from "./custom-types"; -const User = thinky.createModel('user', type.object().schema({ - id: type.string(), - // LEGACY name, but contains the auth0_id OR the auth secret/token - auth0_id: requiredString().stopReference(), - first_name: requiredString(), - last_name: requiredString(), - cell: requiredString(), - email: requiredString(), - created_at: timestamp(), - assigned_cell: type.string(), - is_superadmin: type.boolean(), - terms: type.boolean().default(false) -}).allowExtra(false), { noAutoCreation: true }) +const User = thinky.createModel( + "user", + type + .object() + .schema({ + id: type.string(), + // LEGACY name, but contains the auth0_id OR the auth secret/token + auth0_id: requiredString().stopReference(), + first_name: requiredString(), + last_name: requiredString(), + cell: requiredString(), + email: requiredString(), + created_at: timestamp(), + assigned_cell: type.string(), + is_superadmin: type.boolean(), + terms: type.boolean().default(false) + }) + .allowExtra(false), + { noAutoCreation: true } +); -export default User +export default User; diff --git a/src/server/models/zip-code.js b/src/server/models/zip-code.js index 598deb643..9510194b1 100644 --- a/src/server/models/zip-code.js +++ b/src/server/models/zip-code.js @@ -1,27 +1,34 @@ -import thinky from './thinky' -const type = thinky.type -import { requiredString } from './custom-types' +import thinky from "./thinky"; +const type = thinky.type; +import { requiredString } from "./custom-types"; -const ZipCode = thinky.createModel('zip_code', type.object().schema({ - zip: requiredString(), - city: requiredString(), - state: requiredString(), - latitude: type - .number() - .required() - .allowNull(false), - longitude: type - .number() - .required() - .allowNull(false), - timezone_offset: type - .number() - .required() - .allowNull(false), - has_dst: type - .boolean() - .required() - .allowNull(false) -}).allowExtra(false), { pk: 'zip', noAutoCreation: true }) +const ZipCode = thinky.createModel( + "zip_code", + type + .object() + .schema({ + zip: requiredString(), + city: requiredString(), + state: requiredString(), + latitude: type + .number() + .required() + .allowNull(false), + longitude: type + .number() + .required() + .allowNull(false), + timezone_offset: type + .number() + .required() + .allowNull(false), + has_dst: type + .boolean() + .required() + .allowNull(false) + }) + .allowExtra(false), + { pk: "zip", noAutoCreation: true } +); -export default ZipCode +export default ZipCode; diff --git a/src/server/notifications.js b/src/server/notifications.js index 8788192f1..4c2d7bd0a 100644 --- a/src/server/notifications.js +++ b/src/server/notifications.js @@ -1,40 +1,41 @@ -import { r, Assignment, Campaign, User, Organization } from './models' -import { log } from '../lib' -import { sendEmail } from './mail' +import { r, Assignment, Campaign, User, Organization } from "./models"; +import { log } from "../lib"; +import { sendEmail } from "./mail"; export const Notifications = { - CAMPAIGN_STARTED: 'campaign.started', - ASSIGNMENT_MESSAGE_RECEIVED: 'assignment.message.received', - ASSIGNMENT_CREATED: 'assignment.created', - ASSIGNMENT_UPDATED: 'assignment.updated' -} + CAMPAIGN_STARTED: "campaign.started", + ASSIGNMENT_MESSAGE_RECEIVED: "assignment.message.received", + ASSIGNMENT_CREATED: "assignment.created", + ASSIGNMENT_UPDATED: "assignment.updated" +}; async function getOrganizationOwner(organizationId) { - return await r.table('user_organization') - .getAll(organizationId, { index: 'organization_id' }) - .filter({ role: 'OWNER' }) + return await r + .table("user_organization") + .getAll(organizationId, { index: "organization_id" }) + .filter({ role: "OWNER" }) .limit(1) - .eqJoin('user_id', r.table('user'))('right')(0) + .eqJoin("user_id", r.table("user"))("right")(0); } const sendAssignmentUserNotification = async (assignment, notification) => { - const campaign = await Campaign.get(assignment.campaign_id) + const campaign = await Campaign.get(assignment.campaign_id); if (!campaign.is_started) { - return + return; } - const organization = await Organization.get(campaign.organization_id) - const user = await User.get(assignment.user_id) - const orgOwner = await getOrganizationOwner(organization.id) + const organization = await Organization.get(campaign.organization_id); + const user = await User.get(assignment.user_id); + const orgOwner = await getOrganizationOwner(organization.id); - let subject - let text + let subject; + let text; if (notification === Notifications.ASSIGNMENT_UPDATED) { - subject = `[${organization.name}] Updated assignment: ${campaign.title}` - text = `Your assignment changed: \n\n${process.env.BASE_URL}/app/${campaign.organization_id}/todos` + subject = `[${organization.name}] Updated assignment: ${campaign.title}`; + text = `Your assignment changed: \n\n${process.env.BASE_URL}/app/${campaign.organization_id}/todos`; } else if (notification === Notifications.ASSIGNMENT_CREATED) { - subject = `[${organization.name}] New assignment: ${campaign.title}` - text = `You just got a new texting assignment from ${organization.name}. You can start sending texts right away: \n\n${process.env.BASE_URL}/app/${campaign.organization_id}/todos` + subject = `[${organization.name}] New assignment: ${campaign.title}`; + text = `You just got a new texting assignment from ${organization.name}. You can start sending texts right away: \n\n${process.env.BASE_URL}/app/${campaign.organization_id}/todos`; } try { @@ -43,37 +44,42 @@ const sendAssignmentUserNotification = async (assignment, notification) => { replyTo: orgOwner.email, subject, text - }) + }); } catch (e) { - log.error(e) + log.error(e); } -} +}; -export const sendUserNotification = async (notification) => { - const { type } = notification +export const sendUserNotification = async notification => { + const { type } = notification; if (type === Notifications.CAMPAIGN_STARTED) { - const assignments = await r.table('assignment') - .getAll(notification.campaignId, { index: 'campaign_id' }) - .pluck(['user_id', 'campaign_id']) + const assignments = await r + .table("assignment") + .getAll(notification.campaignId, { index: "campaign_id" }) + .pluck(["user_id", "campaign_id"]); - const count = assignments.length + const count = assignments.length; for (let i = 0; i < count; i++) { - const assignment = assignments[i] - await sendAssignmentUserNotification(assignment, Notifications.ASSIGNMENT_CREATED) + const assignment = assignments[i]; + await sendAssignmentUserNotification( + assignment, + Notifications.ASSIGNMENT_CREATED + ); } } else if (type === Notifications.ASSIGNMENT_MESSAGE_RECEIVED) { - const assignment = await Assignment.get(notification.assignmentId) - const campaign = await Campaign.get(assignment.campaign_id) - const campaignContact = await r.table('campaign_contact') - .getAll(notification.contactNumber, { index: 'cell' }) + const assignment = await Assignment.get(notification.assignmentId); + const campaign = await Campaign.get(assignment.campaign_id); + const campaignContact = await r + .table("campaign_contact") + .getAll(notification.contactNumber, { index: "cell" }) .filter({ campaign_id: campaign.id }) - .limit(1)(0) + .limit(1)(0); if (!campaignContact.is_opted_out && !campaign.is_archived) { - const user = await User.get(assignment.user_id) - const organization = await Organization.get(campaign.organization_id) - const orgOwner = await getOrganizationOwner(organization.id) + const user = await User.get(assignment.user_id); + const organization = await Organization.get(campaign.organization_id); + const orgOwner = await getOrganizationOwner(organization.id); try { await sendEmail({ @@ -81,50 +87,50 @@ export const sendUserNotification = async (notification) => { replyTo: orgOwner.email, subject: `[${organization.name}] [${campaign.title}] New reply`, text: `Someone responded to your message. See all your replies here: \n\n${process.env.BASE_URL}/app/${campaign.organization_id}/todos/${notification.assignmentId}/reply` - }) + }); } catch (e) { - log.error(e) + log.error(e); } } } else if (type === Notifications.ASSIGNMENT_CREATED) { - const { assignment } = notification - await sendAssignmentUserNotification(assignment, type) + const { assignment } = notification; + await sendAssignmentUserNotification(assignment, type); } -} +}; -const setupIncomingReplyNotification = () => ( - r.table('message') +const setupIncomingReplyNotification = () => + r + .table("message") .changes() - .then(function (message) { + .then(function(message) { if (!message.old_val && message.new_val.is_from_contact) { sendUserNotification({ type: Notifications.ASSIGNMENT_MESSAGE_RECEIVED, assignmentId: message.new_val.assignment_id, contactNumber: message.new_val.contact_number - }) + }); } - }) -) + }); -const setupNewAssignmentNotification = () => ( - r.table('assignment') +const setupNewAssignmentNotification = () => + r + .table("assignment") .changes() - .then(function (assignment) { + .then(function(assignment) { if (!assignment.old_val) { sendUserNotification({ type: Notifications.ASSIGNMENT_CREATED, assignment: assignment.new_val - }) + }); } - }) -) + }); -let notificationObserversSetup = false +let notificationObserversSetup = false; export const setupUserNotificationObservers = () => { if (!notificationObserversSetup) { - notificationObserversSetup = true - setupIncomingReplyNotification() - setupNewAssignmentNotification() + notificationObserversSetup = true; + setupIncomingReplyNotification(); + setupNewAssignmentNotification(); } -} +}; diff --git a/src/server/seeds/seed-zip-codes.js b/src/server/seeds/seed-zip-codes.js index dfa1bced4..5ed249fe9 100644 --- a/src/server/seeds/seed-zip-codes.js +++ b/src/server/seeds/seed-zip-codes.js @@ -1,26 +1,28 @@ -import { ZipCode, r } from '../models' -import Papa from 'papaparse' -import { log, zipToTimeZone } from '../../lib' -import fs from 'fs' +import { ZipCode, r } from "../models"; +import Papa from "papaparse"; +import { log, zipToTimeZone } from "../../lib"; +import fs from "fs"; export async function seedZipCodes() { - log.info('Checking if zip code is needed') - const hasZip = (await r.table('zip_code') - .limit(1) - .count()) > 0 + log.info("Checking if zip code is needed"); + const hasZip = + (await r + .table("zip_code") + .limit(1) + .count()) > 0; if (!hasZip) { - log.info('Starting to seed zip codes') - const absolutePath = `${__dirname}/data/zip-codes.csv` - const content = fs.readFileSync(absolutePath, { encoding: 'binary' }) - const { data, error } = Papa.parse(content, { header: true }) + log.info("Starting to seed zip codes"); + const absolutePath = `${__dirname}/data/zip-codes.csv`; + const content = fs.readFileSync(absolutePath, { encoding: "binary" }); + const { data, error } = Papa.parse(content, { header: true }); if (error) { - throw new Error('Failed to seed zip codes') + throw new Error("Failed to seed zip codes"); } else { - log.info('Parsed a CSV with ', data.length, ' zip codes') + log.info("Parsed a CSV with ", data.length, " zip codes"); const zipCodes = data - .filter((row) => (!zipToTimeZone(row.zip))) - .map((row) => ({ + .filter(row => !zipToTimeZone(row.zip)) + .map(row => ({ zip: row.zip, city: row.city, state: row.state, @@ -28,12 +30,12 @@ export async function seedZipCodes() { has_dst: Boolean(row.has_dst), latitude: Number(row.latitude), longitude: Number(row.longitude) - })) + })); - log.info(zipCodes.length, 'ZIP CODES') + log.info(zipCodes.length, "ZIP CODES"); ZipCode.save(zipCodes) - .then(() => log.info('Finished seeding')) - .error((err) => log.error('error', err)) + .then(() => log.info("Finished seeding")) + .error(err => log.error("error", err)); } } } diff --git a/src/server/wrap.js b/src/server/wrap.js index 04f41a106..047b74531 100644 --- a/src/server/wrap.js +++ b/src/server/wrap.js @@ -1,11 +1,11 @@ /* This is a function wrapper to correctly catch and handle uncaught exceptions in asynchronous code. */ -import { log } from '../lib' -export default (fn) => - (...args) => - fn(...args) - .catch((ex) => { - log.error(ex) - process.nextTick(() => { throw ex }) - }) +import { log } from "../lib"; +export default fn => (...args) => + fn(...args).catch(ex => { + log.error(ex); + process.nextTick(() => { + throw ex; + }); + }); diff --git a/src/store/actions/index.js b/src/store/actions/index.js index 40d700724..a27bbba9c 100644 --- a/src/store/actions/index.js +++ b/src/store/actions/index.js @@ -1,8 +1,8 @@ -export const ADD_COUNT = 'ADD_COUNT' +export const ADD_COUNT = "ADD_COUNT"; export function addCount(amount) { return { type: ADD_COUNT, payload: amount - } + }; } diff --git a/src/store/index.js b/src/store/index.js index 7da9941aa..822b6ee3d 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -1,8 +1,8 @@ -import { createStore, combineReducers, compose, applyMiddleware } from 'redux' -import { routerReducer, routerMiddleware } from 'react-router-redux' -import ReduxThunk from 'redux-thunk' -import ApolloClientSingleton from '../network/apollo-client-singleton' -import * as reducers from './reducers' +import { createStore, combineReducers, compose, applyMiddleware } from "redux"; +import { routerReducer, routerMiddleware } from "react-router-redux"; +import ReduxThunk from "redux-thunk"; +import ApolloClientSingleton from "../network/apollo-client-singleton"; +import * as reducers from "./reducers"; export default class Store { constructor(history, initialState = {}) { @@ -10,7 +10,7 @@ export default class Store { ...reducers, apollo: ApolloClientSingleton.reducer(), routing: routerReducer - }) + }); this.data = createStore( reducer, @@ -21,9 +21,11 @@ export default class Store { ApolloClientSingleton.middleware(), ReduxThunk.withExtraArgument(ApolloClientSingleton) ), - typeof window === 'object' && - typeof window.devToolsExtension !== 'undefined' ? window.devToolsExtension() : f => f + typeof window === "object" && + typeof window.devToolsExtension !== "undefined" + ? window.devToolsExtension() + : f => f ) - ) + ); } } diff --git a/src/store/reducers/count.js b/src/store/reducers/count.js index 941b5f98b..a6adb6ddd 100644 --- a/src/store/reducers/count.js +++ b/src/store/reducers/count.js @@ -1,8 +1,8 @@ -import { ADD_COUNT } from '../actions' +import { ADD_COUNT } from "../actions"; -export default function (state = 0, action) { +export default function(state = 0, action) { if (action.type === ADD_COUNT) { - return state + action.payload + return state + action.payload; } - return state + return state; } diff --git a/src/store/reducers/index.js b/src/store/reducers/index.js index 2907d1716..871d79294 100644 --- a/src/store/reducers/index.js +++ b/src/store/reducers/index.js @@ -1 +1 @@ -export count from './count' +export count from "./count"; diff --git a/src/styles/media-queries.js b/src/styles/media-queries.js index 810213ca5..5cfa9ff27 100644 --- a/src/styles/media-queries.js +++ b/src/styles/media-queries.js @@ -1,3 +1,3 @@ -export const onMobile = '@media (max-width: 480px)' -export const onTablet = '@media (max-width: 992px)' -export const onDesktop = '@media (min-width: 992px)' +export const onMobile = "@media (max-width: 480px)"; +export const onTablet = "@media (max-width: 992px)"; +export const onDesktop = "@media (min-width: 992px)"; diff --git a/src/styles/mui-theme.js b/src/styles/mui-theme.js index 4d62522ae..8cc0d5161 100644 --- a/src/styles/mui-theme.js +++ b/src/styles/mui-theme.js @@ -1,23 +1,26 @@ -import getMuiTheme from 'material-ui/styles/getMuiTheme' -import theme from './theme' -import { grey400, grey500, darkBlack } from 'material-ui/styles/colors' -import { fade } from 'material-ui/utils/colorManipulator' +import getMuiTheme from "material-ui/styles/getMuiTheme"; +import theme from "./theme"; +import { grey400, grey500, darkBlack } from "material-ui/styles/colors"; +import { fade } from "material-ui/utils/colorManipulator"; -const muiTheme = getMuiTheme({ - fontFamily: 'Poppins', - palette: { - primary1Color: theme.colors.green, - textColor: theme.text.body.color, - primary2Color: theme.colors.orange, - primary3Color: grey400, - accent1Color: theme.colors.orange, - accent2Color: theme.colors.lightGray, - accent3Color: grey500, - alternateTextColor: theme.colors.white, - canvasColor: theme.colors.white, - borderColor: theme.colors.lightGray, - disabledColor: fade(darkBlack, 0.3) - } -}, { userAgent: 'all' }) +const muiTheme = getMuiTheme( + { + fontFamily: "Poppins", + palette: { + primary1Color: theme.colors.green, + textColor: theme.text.body.color, + primary2Color: theme.colors.orange, + primary3Color: grey400, + accent1Color: theme.colors.orange, + accent2Color: theme.colors.lightGray, + accent3Color: grey500, + alternateTextColor: theme.colors.white, + canvasColor: theme.colors.white, + borderColor: theme.colors.lightGray, + disabledColor: fade(darkBlack, 0.3) + } + }, + { userAgent: "all" } +); -export default muiTheme +export default muiTheme; diff --git a/src/styles/theme.js b/src/styles/theme.js index 49cf6a362..d32f44b75 100644 --- a/src/styles/theme.js +++ b/src/styles/theme.js @@ -1,25 +1,25 @@ const colors = { - orange: 'rgb(255, 102, 0)', - lightGreen: 'rgb(245, 255, 247)', - blue: 'rgb(20, 127, 215)', - purple: '#5f2787', - lightBlue: 'rgb(196, 223, 245)', - darkBlue: 'rgb(13, 81, 139)', - red: 'rgb(245, 91, 91)', - lightRed: 'rgb(255, 141, 141)', - darkRed: 'rgb(237, 60, 57)', - green: 'rgb(83, 180, 119)', - darkGreen: 'rgb(24, 154, 52)', - darkGray: 'rgb(54, 67, 80)', - gray: 'rgb(153, 155, 158)', - veryLightGray: 'rgb(240, 242, 240)', - lightGray: 'rgb(225, 228, 224)', - white: 'rgb(255,255,255)', - yellow: 'rgb(250,190,40)', - lightYellow: 'rgb(252, 214, 120)' -} + orange: "rgb(255, 102, 0)", + lightGreen: "rgb(245, 255, 247)", + blue: "rgb(20, 127, 215)", + purple: "#5f2787", + lightBlue: "rgb(196, 223, 245)", + darkBlue: "rgb(13, 81, 139)", + red: "rgb(245, 91, 91)", + lightRed: "rgb(255, 141, 141)", + darkRed: "rgb(237, 60, 57)", + green: "rgb(83, 180, 119)", + darkGreen: "rgb(24, 154, 52)", + darkGray: "rgb(54, 67, 80)", + gray: "rgb(153, 155, 158)", + veryLightGray: "rgb(240, 242, 240)", + lightGray: "rgb(225, 228, 224)", + white: "rgb(255,255,255)", + yellow: "rgb(250,190,40)", + lightYellow: "rgb(252, 214, 120)" +}; -const defaultFont = 'Poppins' +const defaultFont = "Poppins"; const text = { body: { @@ -30,98 +30,97 @@ const text = { link_light_bg: { fontWeight: 400, color: colors.green, - textDecoration: 'none', + textDecoration: "none", borderBottom: `1px solid ${colors.green}`, - cursor: 'pointer', - ':hover': { + cursor: "pointer", + ":hover": { borderBottom: 0, color: colors.orange }, - 'a:visited': { + "a:visited": { fontWeight: 400, color: colors.darkGray, - textDecoration: 'none' + textDecoration: "none" }, fontFamily: defaultFont }, link_dark_bg: { fontWeight: 400, color: colors.white, - textDecoration: 'none', + textDecoration: "none", borderBottom: `1px solid ${colors.white}`, - cursor: 'pointer', - ':hover': { + cursor: "pointer", + ":hover": { borderBottom: 0, color: colors.orange }, - 'a:visited': { + "a:visited": { fontWeight: 400, color: colors.veryLightGray, - textDecoration: 'none' + textDecoration: "none" }, fontFamily: defaultFont }, header: { color: colors.darkGray, - fontSize: '1.5em', + fontSize: "1.5em", fontWeight: 600, fontFamily: defaultFont }, secondaryHeader: { color: colors.darkGray, - fontSize: '1.25em', + fontSize: "1.25em", fontFamily: defaultFont } -} +}; const layouts = { multiColumn: { container: { - display: 'flex', - flexDirection: 'row' + display: "flex", + flexDirection: "row" }, flexColumn: { - display: 'flex', + display: "flex", flex: 1, - flexDirection: 'column' + flexDirection: "column" } }, greenBox: { - marginTop: '5vh', - maxWidth: '80%', - paddingBottom: '7vh', + marginTop: "5vh", + maxWidth: "80%", + paddingBottom: "7vh", borderRadius: 8, - paddingTop: '7vh', - marginLeft: 'auto', - marginRight: 'auto', - textAlign: 'center', + paddingTop: "7vh", + marginLeft: "auto", + marginRight: "auto", + textAlign: "center", backgroundColor: colors.green, color: colors.white } -} +}; const components = { floatingButton: { margin: 0, - top: 'auto', + top: "auto", right: 20, bottom: 20, - left: 'auto', - position: 'fixed' + left: "auto", + position: "fixed" }, logoDiv: { - margin: '50 auto', - overflow: 'hidden' + margin: "50 auto", + overflow: "hidden" }, - 'logoImg': { - } -} + logoImg: {} +}; const theme = { colors, text, layouts, components -} +}; -export default theme +export default theme; diff --git a/src/workers/incoming-message-handler.js b/src/workers/incoming-message-handler.js index bc388d83f..ff110baa1 100644 --- a/src/workers/incoming-message-handler.js +++ b/src/workers/incoming-message-handler.js @@ -1,3 +1,3 @@ -import { handleIncomingMessages } from './job-processes' +import { handleIncomingMessages } from "./job-processes"; -handleIncomingMessages() +handleIncomingMessages(); diff --git a/src/workers/job-handler.js b/src/workers/job-handler.js index 811905c6d..bdf069e00 100644 --- a/src/workers/job-handler.js +++ b/src/workers/job-handler.js @@ -1,3 +1,3 @@ -import { processJobs } from './job-processes' +import { processJobs } from "./job-processes"; -processJobs() +processJobs(); diff --git a/src/workers/job-processes.js b/src/workers/job-processes.js index 872a068eb..2e367badf 100644 --- a/src/workers/job-processes.js +++ b/src/workers/job-processes.js @@ -1,19 +1,21 @@ -import { r } from '../server/models' -import { sleep, getNextJob, log } from './lib' -import { exportCampaign, - processSqsMessages, - uploadContacts, - loadContactsFromDataWarehouse, - loadContactsFromDataWarehouseFragment, - assignTexters, - sendMessages, - handleIncomingMessageParts, - fixOrgless, - clearOldJobs, - importScript } from './jobs' -import { setupUserNotificationObservers } from '../server/notifications' - -export { seedZipCodes } from '../server/seeds/seed-zip-codes' +import { r } from "../server/models"; +import { sleep, getNextJob, log } from "./lib"; +import { + exportCampaign, + processSqsMessages, + uploadContacts, + loadContactsFromDataWarehouse, + loadContactsFromDataWarehouseFragment, + assignTexters, + sendMessages, + handleIncomingMessageParts, + fixOrgless, + clearOldJobs, + importScript +} from "./jobs"; +import { setupUserNotificationObservers } from "../server/notifications"; + +export { seedZipCodes } from "../server/seeds/seed-zip-codes"; /* Two process models are supported in this file. The main in both cases is to process jobs and send/receive messages @@ -25,160 +27,181 @@ export { seedZipCodes } from '../server/seeds/seed-zip-codes' */ const jobMap = { - 'export': exportCampaign, - 'upload_contacts': uploadContacts, - 'upload_contacts_sql': loadContactsFromDataWarehouse, - 'assign_texters': assignTexters, - 'import_script': importScript -} + export: exportCampaign, + upload_contacts: uploadContacts, + upload_contacts_sql: loadContactsFromDataWarehouse, + assign_texters: assignTexters, + import_script: importScript +}; export async function processJobs() { - setupUserNotificationObservers() - console.log('Running processJobs') + setupUserNotificationObservers(); + console.log("Running processJobs"); // eslint-disable-next-line no-constant-condition while (true) { try { - await sleep(1000) - const job = await getNextJob() + await sleep(1000); + const job = await getNextJob(); if (job) { - await (jobMap[job.job_type])(job) + await jobMap[job.job_type](job); } - const twoMinutesAgo = new Date(new Date() - 1000 * 60 * 2) + const twoMinutesAgo = new Date(new Date() - 1000 * 60 * 2); // clear out stuck jobs - await clearOldJobs(twoMinutesAgo) + await clearOldJobs(twoMinutesAgo); } catch (ex) { - log.error(ex) + log.error(ex); } } } export async function checkMessageQueue() { if (!process.env.TWILIO_SQS_QUEUE_URL) { - return + return; } - console.log('checking if messages are in message queue') + console.log("checking if messages are in message queue"); while (true) { try { - await sleep(10000) - processSqsMessages() + await sleep(10000); + processSqsMessages(); } catch (ex) { - log.error(ex) + log.error(ex); } } } const messageSenderCreator = (subQuery, defaultStatus) => { - return async (event) => { - console.log('Running a message sender') - setupUserNotificationObservers() - let delay = 1100 + return async event => { + console.log("Running a message sender"); + setupUserNotificationObservers(); + let delay = 1100; if (event && event.delay) { - delay = parseInt(event.delay, 10) + delay = parseInt(event.delay, 10); } // eslint-disable-next-line no-constant-condition while (true) { try { - await sleep(delay) - await sendMessages(subQuery, defaultStatus) + await sleep(delay); + await sendMessages(subQuery, defaultStatus); } catch (ex) { - log.error(ex) + log.error(ex); } } - } -} - -export const messageSender01 = messageSenderCreator(function (mQuery) { - return mQuery.where(r.knex.raw("(contact_number LIKE '%0' OR contact_number LIKE '%1')")) -}) - -export const messageSender234 = messageSenderCreator(function (mQuery) { - return mQuery.where(r.knex.raw("(contact_number LIKE '%2' OR contact_number LIKE '%3' or contact_number LIKE '%4')")) -}) - -export const messageSender56 = messageSenderCreator(function (mQuery) { - return mQuery.where(r.knex.raw("(contact_number LIKE '%5' OR contact_number LIKE '%6')")) -}) - -export const messageSender789 = messageSenderCreator(function (mQuery) { - return mQuery.where(r.knex.raw("(contact_number LIKE '%7' OR contact_number LIKE '%8' or contact_number LIKE '%9')")) -}) - -export const failedMessageSender = messageSenderCreator(function (mQuery) { + }; +}; + +export const messageSender01 = messageSenderCreator(function(mQuery) { + return mQuery.where( + r.knex.raw("(contact_number LIKE '%0' OR contact_number LIKE '%1')") + ); +}); + +export const messageSender234 = messageSenderCreator(function(mQuery) { + return mQuery.where( + r.knex.raw( + "(contact_number LIKE '%2' OR contact_number LIKE '%3' or contact_number LIKE '%4')" + ) + ); +}); + +export const messageSender56 = messageSenderCreator(function(mQuery) { + return mQuery.where( + r.knex.raw("(contact_number LIKE '%5' OR contact_number LIKE '%6')") + ); +}); + +export const messageSender789 = messageSenderCreator(function(mQuery) { + return mQuery.where( + r.knex.raw( + "(contact_number LIKE '%7' OR contact_number LIKE '%8' or contact_number LIKE '%9')" + ) + ); +}); + +export const failedMessageSender = messageSenderCreator(function(mQuery) { // messages that were attempted to be sent five minutes ago in status=SENDING // when JOBS_SAME_PROCESS is enabled, the send attempt is done immediately. // However, if it's still marked SENDING, then it must have failed to go out. // This is dangerous to run in a scheduled event because if there is // any failure path that stops the status from updating, then users might keep getting // texts over and over - const fiveMinutesAgo = new Date(new Date() - 1000 * 60 * 5) - return mQuery.where('created_at', '>', fiveMinutesAgo) -}, 'SENDING') + const fiveMinutesAgo = new Date(new Date() - 1000 * 60 * 5); + return mQuery.where("created_at", ">", fiveMinutesAgo); +}, "SENDING"); -export const failedDayMessageSender = messageSenderCreator(function (mQuery) { +export const failedDayMessageSender = messageSenderCreator(function(mQuery) { // messages that were attempted to be sent five minutes ago in status=SENDING // when JOBS_SAME_PROCESS is enabled, the send attempt is done immediately. // However, if it's still marked SENDING, then it must have failed to go out. // This is dangerous to run in a scheduled event because if there is // any failure path that stops the status from updating, then users might keep getting // texts over and over - const oneDayAgo = new Date(new Date() - 1000 * 60 * 60 * 24) - return mQuery.where('created_at', '>', oneDayAgo) -}, 'SENDING') + const oneDayAgo = new Date(new Date() - 1000 * 60 * 60 * 24); + return mQuery.where("created_at", ">", oneDayAgo); +}, "SENDING"); export async function handleIncomingMessages() { - setupUserNotificationObservers() + setupUserNotificationObservers(); if (process.env.DEBUG_INCOMING_MESSAGES) { - console.log('Running handleIncomingMessages') + console.log("Running handleIncomingMessages"); } // eslint-disable-next-line no-constant-condition - let i = 0 + let i = 0; while (true) { try { if (process.env.DEBUG_SCALING) { - console.log('entering handleIncomingMessages. round: ', ++i) + console.log("entering handleIncomingMessages. round: ", ++i); } - const countPendingMessagePart = await r.knex('pending_message_part') - .count('id AS total').then(total => { - let totalCount = 0 - totalCount = total[0].total - return totalCount - }) + const countPendingMessagePart = await r + .knex("pending_message_part") + .count("id AS total") + .then(total => { + let totalCount = 0; + totalCount = total[0].total; + return totalCount; + }); if (process.env.DEBUG_SCALING) { - console.log('counting handleIncomingMessages. count: ', countPendingMessagePart) + console.log( + "counting handleIncomingMessages. count: ", + countPendingMessagePart + ); } - await sleep(500) + await sleep(500); if (countPendingMessagePart > 0) { if (process.env.DEBUG_SCALING) { - console.log('running handleIncomingMessages') + console.log("running handleIncomingMessages"); } - await handleIncomingMessageParts() + await handleIncomingMessageParts(); } } catch (ex) { - log.error('error at handleIncomingMessages', ex) + log.error("error at handleIncomingMessages", ex); } } } export async function runDatabaseMigrations(event, dispatcher, eventCallback) { - await r.knex.migrate.latest() + await r.knex.migrate.latest(); if (eventCallback) { - eventCallback(null, 'completed migrations') + eventCallback(null, "completed migrations"); } } -export async function loadContactsFromDataWarehouseFragmentJob(event, dispatcher, eventCallback) { - const eventAsJob = event - console.log('LAMBDA INVOCATION job-processes', event) +export async function loadContactsFromDataWarehouseFragmentJob( + event, + dispatcher, + eventCallback +) { + const eventAsJob = event; + console.log("LAMBDA INVOCATION job-processes", event); try { - const rv = await loadContactsFromDataWarehouseFragment(eventAsJob) + const rv = await loadContactsFromDataWarehouseFragment(eventAsJob); if (eventCallback) { - eventCallback(null, rv) + eventCallback(null, rv); } } catch (err) { if (eventCallback) { - eventCallback(err, null) + eventCallback(err, null); } } } @@ -191,7 +214,7 @@ const processMap = { messageSender789, handleIncomingMessages, fixOrgless -} +}; // if process.env.JOBS_SAME_PROCESS then we don't need to run // the others and messageSender should just pick up the stragglers @@ -201,19 +224,20 @@ const syncProcessMap = { checkMessageQueue, fixOrgless, clearOldJobs -} +}; -const JOBS_SAME_PROCESS = !!process.env.JOBS_SAME_PROCESS +const JOBS_SAME_PROCESS = !!process.env.JOBS_SAME_PROCESS; export async function dispatchProcesses(event, dispatcher, eventCallback) { - const toDispatch = event.processes || (JOBS_SAME_PROCESS ? syncProcessMap : processMap) + const toDispatch = + event.processes || (JOBS_SAME_PROCESS ? syncProcessMap : processMap); for (let p in toDispatch) { if (p in processMap) { // / not using dispatcher, but another interesting model would be // / to dispatch processes to other lambda invocations // dispatcher({'command': p}) - console.log('process', p) - toDispatch[p]().then() + console.log("process", p); + toDispatch[p]().then(); } } } diff --git a/src/workers/jobs.js b/src/workers/jobs.js index a5b5695c9..dceddf169 100644 --- a/src/workers/jobs.js +++ b/src/workers/jobs.js @@ -1,112 +1,145 @@ -import { r, datawarehouse, cacheableData, - Assignment, Campaign, CampaignContact, Organization, User, - UserOrganization } from '../server/models' -import { log, gunzip, zipToTimeZone, convertOffsetsToStrings } from '../lib' -import { updateJob } from './lib' -import { getFormattedPhoneNumber } from '../lib/phone-format.js' -import serviceMap from '../server/api/lib/services' -import { getLastMessage, saveNewIncomingMessage } from '../server/api/lib/message-sending' -import importScriptFromDocument from '../server/api/lib/import-script.' - -import AWS from 'aws-sdk' -import Papa from 'papaparse' -import moment from 'moment' -import { sendEmail } from '../server/mail' -import { Notifications, sendUserNotification } from '../server/notifications' -import { unzip } from 'zlib'; - -const defensivelyDeleteJob = async (job) => { +import { + r, + datawarehouse, + cacheableData, + Assignment, + Campaign, + CampaignContact, + Organization, + User, + UserOrganization +} from "../server/models"; +import { log, gunzip, zipToTimeZone, convertOffsetsToStrings } from "../lib"; +import { updateJob } from "./lib"; +import { getFormattedPhoneNumber } from "../lib/phone-format.js"; +import serviceMap from "../server/api/lib/services"; +import { + getLastMessage, + saveNewIncomingMessage +} from "../server/api/lib/message-sending"; +import importScriptFromDocument from "../server/api/lib/import-script."; + +import AWS from "aws-sdk"; +import Papa from "papaparse"; +import moment from "moment"; +import { sendEmail } from "../server/mail"; +import { Notifications, sendUserNotification } from "../server/notifications"; +import { unzip } from "zlib"; + +const defensivelyDeleteJob = async job => { if (job.id) { - let retries = 0 + let retries = 0; const deleteJob = async () => { try { - await r.table('job_request').get(job.id).delete() + await r + .table("job_request") + .get(job.id) + .delete(); } catch (err) { if (retries < 5) { - retries += 1 - await deleteJob() - } else log.error(`Could not delete job. Err: ${err.message}`) + retries += 1; + await deleteJob(); + } else log.error(`Could not delete job. Err: ${err.message}`); } - } + }; - await deleteJob() - } else log.debug(job) -} + await deleteJob(); + } else log.debug(job); +}; -const zipMemoization = {} -let warehouseConnection = null +const zipMemoization = {}; +let warehouseConnection = null; function optOutsByOrgId(orgId) { - return r.knex.select('cell').from('opt_out').where('organization_id', orgId) + return r.knex + .select("cell") + .from("opt_out") + .where("organization_id", orgId); } function optOutsByInstance() { - return r.knex.select('cell').from('opt_out') + return r.knex.select("cell").from("opt_out"); } function getOptOutSubQuery(orgId) { - return (!!process.env.OPTOUTS_SHARE_ALL_ORGS ? optOutsByInstance() : optOutsByOrgId(orgId)) + return !!process.env.OPTOUTS_SHARE_ALL_ORGS + ? optOutsByInstance() + : optOutsByOrgId(orgId); } function optOutsByOrgId(orgId) { - return r.knex.select('cell').from('opt_out').where('organization_id', orgId) + return r.knex + .select("cell") + .from("opt_out") + .where("organization_id", orgId); } function optOutsByInstance() { - return r.knex.select('cell').from('opt_out') + return r.knex.select("cell").from("opt_out"); } function getOptOutSubQuery(orgId) { - return (!!process.env.OPTOUTS_SHARE_ALL_ORGS ? optOutsByInstance() : optOutsByOrgId(orgId)) + return !!process.env.OPTOUTS_SHARE_ALL_ORGS + ? optOutsByInstance() + : optOutsByOrgId(orgId); } export async function getTimezoneByZip(zip) { if (zip in zipMemoization) { - return zipMemoization[zip] + return zipMemoization[zip]; } - const rangeZip = zipToTimeZone(zip) + const rangeZip = zipToTimeZone(zip); if (rangeZip) { - return `${rangeZip[2]}_${rangeZip[3]}` + return `${rangeZip[2]}_${rangeZip[3]}`; } - const zipDatum = await r.table('zip_code').get(zip) + const zipDatum = await r.table("zip_code").get(zip); if (zipDatum && zipDatum.timezone_offset && zipDatum.has_dst) { - zipMemoization[zip] = convertOffsetsToStrings([[zipDatum.timezone_offset, zipDatum.has_dst]])[0] - return zipMemoization[zip] + zipMemoization[zip] = convertOffsetsToStrings([ + [zipDatum.timezone_offset, zipDatum.has_dst] + ])[0]; + return zipMemoization[zip]; } - return '' + return ""; } export async function sendJobToAWSLambda(job) { // job needs to be json-serializable // requires a 'command' key which should map to a function in job-processes.js - console.log('LAMBDA INVOCATION STARTING', job, process.env.AWS_LAMBDA_FUNCTION_NAME) + console.log( + "LAMBDA INVOCATION STARTING", + job, + process.env.AWS_LAMBDA_FUNCTION_NAME + ); if (!job.command) { - console.log('LAMBDA INVOCATION FAILED: JOB NOT INVOKABLE', job) - return Promise.reject('Job type not available in job-processes') + console.log("LAMBDA INVOCATION FAILED: JOB NOT INVOKABLE", job); + return Promise.reject("Job type not available in job-processes"); } - const lambda = new AWS.Lambda() - const lambdaPayload = JSON.stringify(job) + const lambda = new AWS.Lambda(); + const lambdaPayload = JSON.stringify(job); if (lambdaPayload.length > 128000) { - console.log('LAMBDA INVOCATION FAILED PAYLOAD TOO LARGE') - return Promise.reject('Payload too large') + console.log("LAMBDA INVOCATION FAILED PAYLOAD TOO LARGE"); + return Promise.reject("Payload too large"); } const p = new Promise((resolve, reject) => { - const result = lambda.invoke({ - FunctionName: process.env.AWS_LAMBDA_FUNCTION_NAME, - InvocationType: 'Event', - Payload: lambdaPayload - }, (err, data) => { - if (err) { - console.log('LAMBDA INVOCATION FAILED', err, job) - reject(err) - } else { - resolve(data) + const result = lambda.invoke( + { + FunctionName: process.env.AWS_LAMBDA_FUNCTION_NAME, + InvocationType: "Event", + Payload: lambdaPayload + }, + (err, data) => { + if (err) { + console.log("LAMBDA INVOCATION FAILED", err, job); + reject(err); + } else { + resolve(data); + } } - }) - console.log('LAMBDA INVOCATION RESULT', result) - }) - return p + ); + console.log("LAMBDA INVOCATION RESULT", result); + }); + return p; } export async function processSqsMessages() { @@ -116,330 +149,426 @@ export async function processSqsMessages() { // if SQS doesnt have messages, exit if (!process.env.TWILIO_SQS_QUEUE_URL) { - return Promise.reject('TWILIO_SQS_QUEUE_URL not set') + return Promise.reject("TWILIO_SQS_QUEUE_URL not set"); } - const sqs = new AWS.SQS() + const sqs = new AWS.SQS(); const params = { QueueUrl: process.env.TWILIO_SQS_QUEUE_URL, - AttributeNames: ['All'], - MessageAttributeNames: ['string'], + AttributeNames: ["All"], + MessageAttributeNames: ["string"], MaxNumberOfMessages: 10, VisibilityTimeout: 60, WaitTimeSeconds: 10, - ReceiveRequestAttemptId: 'string' - } + ReceiveRequestAttemptId: "string" + }; const p = new Promise((resolve, reject) => { sqs.receiveMessage(params, async (err, data) => { if (err) { - console.log(err, err.stack) - reject(err) + console.log(err, err.stack); + reject(err); } else if (data.Messages) { - console.log(data) - for (let i = 0; i < data.Messages.length; i ++) { - const message = data.Messages[i] - const body = message.Body - console.log('processing sqs queue:', body) - const twilioMessage = JSON.parse(body) - - await serviceMap.twilio.handleIncomingMessage(twilioMessage) - - sqs.deleteMessage({ QueueUrl: process.env.TWILIO_SQS_QUEUE_URL, ReceiptHandle: message.ReceiptHandle }, + console.log(data); + for (let i = 0; i < data.Messages.length; i++) { + const message = data.Messages[i]; + const body = message.Body; + console.log("processing sqs queue:", body); + const twilioMessage = JSON.parse(body); + + await serviceMap.twilio.handleIncomingMessage(twilioMessage); + + sqs.deleteMessage( + { + QueueUrl: process.env.TWILIO_SQS_QUEUE_URL, + ReceiptHandle: message.ReceiptHandle + }, (delMessageErr, delMessageData) => { - if (delMessageErr) { - console.log(delMessageErr, delMessageErr.stack) // an error occurred - } else { - console.log(delMessageData) // successful response - } - }) + if (delMessageErr) { + console.log(delMessageErr, delMessageErr.stack); // an error occurred + } else { + console.log(delMessageData); // successful response + } + } + ); } - resolve() + resolve(); } - }) - }) - return p + }); + }); + return p; } -const unzipPayload = async (job) => JSON.parse(await gunzip(Buffer.from(job.payload, 'base64'))) +const unzipPayload = async job => + JSON.parse(await gunzip(Buffer.from(job.payload, "base64"))); export async function uploadContacts(job) { - const campaignId = job.campaign_id + const campaignId = job.campaign_id; // We do this deletion in schema.js but we do it again here just in case the the queue broke and we had a backlog of contact uploads for one campaign - const campaign = await Campaign.get(campaignId) - const organization = await Organization.get(campaign.organization_id) - const orgFeatures = JSON.parse(organization.features || '{}') - - const jobMessages = [] - - await r.table('campaign_contact') - .getAll(campaignId, { index: 'campaign_id' }) - .delete() - const maxPercentage = 100 - let contacts = await unzipPayload(job) - const chunkSize = 1000 - - const maxContacts = parseInt(orgFeatures.hasOwnProperty('maxContacts') - ? orgFeatures.maxContacts - : process.env.MAX_CONTACTS || 0, 10) - - if (maxContacts) { // note: maxContacts == 0 means no maximum - contacts = contacts.slice(0, maxContacts) + const campaign = await Campaign.get(campaignId); + const organization = await Organization.get(campaign.organization_id); + const orgFeatures = JSON.parse(organization.features || "{}"); + + const jobMessages = []; + + await r + .table("campaign_contact") + .getAll(campaignId, { index: "campaign_id" }) + .delete(); + const maxPercentage = 100; + let contacts = await unzipPayload(job); + const chunkSize = 1000; + + const maxContacts = parseInt( + orgFeatures.hasOwnProperty("maxContacts") + ? orgFeatures.maxContacts + : process.env.MAX_CONTACTS || 0, + 10 + ); + + if (maxContacts) { + // note: maxContacts == 0 means no maximum + contacts = contacts.slice(0, maxContacts); } - const numChunks = Math.ceil(contacts.length / chunkSize) + const numChunks = Math.ceil(contacts.length / chunkSize); for (let index = 0; index < contacts.length; index++) { - const datum = contacts[index] + const datum = contacts[index]; if (datum.zip) { // using memoization and large ranges of homogenous zips - datum.timezone_offset = await getTimezoneByZip(datum.zip) + datum.timezone_offset = await getTimezoneByZip(datum.zip); } } for (let index = 0; index < numChunks; index++) { - await updateJob(job, Math.round((maxPercentage / numChunks) * index)) - const savePortion = contacts.slice(index * chunkSize, (index + 1) * chunkSize) - await CampaignContact.save(savePortion) + await updateJob(job, Math.round((maxPercentage / numChunks) * index)); + const savePortion = contacts.slice( + index * chunkSize, + (index + 1) * chunkSize + ); + await CampaignContact.save(savePortion); } - const optOutCellCount = await r.knex('campaign_contact') - .whereIn('cell', function optouts() { - this.select('cell').from('opt_out').where('organization_id', campaign.organization_id) - }) - - const deleteOptOutCells = await r.knex('campaign_contact') - .whereIn('cell', getOptOutSubQuery(campaign.organization_id)) - .where('campaign_id', campaignId) + const optOutCellCount = await r + .knex("campaign_contact") + .whereIn("cell", function optouts() { + this.select("cell") + .from("opt_out") + .where("organization_id", campaign.organization_id); + }); + + const deleteOptOutCells = await r + .knex("campaign_contact") + .whereIn("cell", getOptOutSubQuery(campaign.organization_id)) + .where("campaign_id", campaignId) .delete() .then(result => { - console.log('deleted result: ' + result); - }) + console.log("deleted result: " + result); + }); if (deleteOptOutCells) { - jobMessages.push(`Number of contacts excluded due to their opt-out status: ${optOutCellCount}`) + jobMessages.push( + `Number of contacts excluded due to their opt-out status: ${optOutCellCount}` + ); } if (job.id) { if (jobMessages.length) { - await r.knex('job_request').where('id', job.id) - .update({ result_message: jobMessages.join('\n') }) + await r + .knex("job_request") + .where("id", job.id) + .update({ result_message: jobMessages.join("\n") }); } else { - await r.table('job_request').get(job.id).delete() + await r + .table("job_request") + .get(job.id) + .delete(); } } - await cacheableData.campaign.reload(campaignId) + await cacheableData.campaign.reload(campaignId); } export async function loadContactsFromDataWarehouseFragment(jobEvent) { - console.log('starting loadContactsFromDataWarehouseFragment', jobEvent.campaignId, jobEvent.limit, jobEvent.offset, jobEvent) + console.log( + "starting loadContactsFromDataWarehouseFragment", + jobEvent.campaignId, + jobEvent.limit, + jobEvent.offset, + jobEvent + ); const insertOptions = { batchSize: 1000 - } - const jobCompleted = (await r.knex('job_request') - .where('id', jobEvent.jobId) - .select('status') - .first()) + }; + const jobCompleted = await r + .knex("job_request") + .where("id", jobEvent.jobId) + .select("status") + .first(); if (!jobCompleted) { - console.log('loadContactsFromDataWarehouseFragment job no longer exists', jobEvent.campaignId, jobCompleted, jobEvent) - return { 'alreadyComplete': 1 } + console.log( + "loadContactsFromDataWarehouseFragment job no longer exists", + jobEvent.campaignId, + jobCompleted, + jobEvent + ); + return { alreadyComplete: 1 }; } - let sqlQuery = jobEvent.query + let sqlQuery = jobEvent.query; if (jobEvent.limit) { - sqlQuery += ' LIMIT ' + jobEvent.limit + sqlQuery += " LIMIT " + jobEvent.limit; } if (jobEvent.offset) { - sqlQuery += ' OFFSET ' + jobEvent.offset + sqlQuery += " OFFSET " + jobEvent.offset; } - let knexResult + let knexResult; try { - warehouseConnection = warehouseConnection || datawarehouse() - console.log('loadContactsFromDataWarehouseFragment RUNNING WAREHOUSE query', sqlQuery) - knexResult = await warehouseConnection.raw(sqlQuery) + warehouseConnection = warehouseConnection || datawarehouse(); + console.log( + "loadContactsFromDataWarehouseFragment RUNNING WAREHOUSE query", + sqlQuery + ); + knexResult = await warehouseConnection.raw(sqlQuery); } catch (err) { // query failed - log.error('Data warehouse query failed: ', err) - jobMessages.push(`Data warehouse count query failed with ${err}`) + log.error("Data warehouse query failed: ", err); + jobMessages.push(`Data warehouse count query failed with ${err}`); // TODO: send feedback about job } - const fields = {} - const customFields = {} + const fields = {}; + const customFields = {}; const contactFields = { first_name: 1, last_name: 1, cell: 1, zip: 1, external_id: 1 - } - knexResult.fields.forEach((f) => { - fields[f.name] = 1 - if (! (f.name in contactFields)) { - customFields[f.name] = 1 + }; + knexResult.fields.forEach(f => { + fields[f.name] = 1; + if (!(f.name in contactFields)) { + customFields[f.name] = 1; } - }) - if (! ('first_name' in fields && 'last_name' in fields && 'cell' in fields)) { - log.error('SQL statement does not return first_name, last_name, and cell: ', sqlQuery, fields) - jobMessages.push(`SQL statement does not return first_name, last_name and cell => ${sqlQuery} => with fields ${fields}`) - return + }); + if (!("first_name" in fields && "last_name" in fields && "cell" in fields)) { + log.error( + "SQL statement does not return first_name, last_name, and cell: ", + sqlQuery, + fields + ); + jobMessages.push( + `SQL statement does not return first_name, last_name and cell => ${sqlQuery} => with fields ${fields}` + ); + return; } - const savePortion = await Promise.all(knexResult.rows.map(async (row) => { - const formatCell = getFormattedPhoneNumber(row.cell, (process.env.PHONE_NUMBER_COUNTRY || 'US')) - const contact = { - campaign_id: jobEvent.campaignId, - first_name: row.first_name || '', - last_name: row.last_name || '', - cell: formatCell, - zip: row.zip || '', - external_id: (row.external_id ? String(row.external_id) : ''), - assignment_id: null, - message_status: 'needsMessage' - } - const contactCustomFields = {} - Object.keys(customFields).forEach((f) => { - contactCustomFields[f] = row[f] + const savePortion = await Promise.all( + knexResult.rows.map(async row => { + const formatCell = getFormattedPhoneNumber( + row.cell, + process.env.PHONE_NUMBER_COUNTRY || "US" + ); + const contact = { + campaign_id: jobEvent.campaignId, + first_name: row.first_name || "", + last_name: row.last_name || "", + cell: formatCell, + zip: row.zip || "", + external_id: row.external_id ? String(row.external_id) : "", + assignment_id: null, + message_status: "needsMessage" + }; + const contactCustomFields = {}; + Object.keys(customFields).forEach(f => { + contactCustomFields[f] = row[f]; + }); + contact.custom_fields = JSON.stringify(contactCustomFields); + if ( + contact.zip && + !contactCustomFields.hasOwnProperty("timezone_offset") + ) { + contact.timezone_offset = getTimezoneByZip(contact.zip); + } + if (contactCustomFields.hasOwnProperty("timezone_offset")) { + contact.timezone_offset = contactCustomFields["timezone_offset"]; + } + return contact; }) - contact.custom_fields = JSON.stringify(contactCustomFields) - if (contact.zip && !contactCustomFields.hasOwnProperty('timezone_offset')){ - contact.timezone_offset = getTimezoneByZip(contact.zip) - } - if (contactCustomFields.hasOwnProperty('timezone_offset')){ - contact.timezone_offset = contactCustomFields['timezone_offset'] - } - return contact - })) - - await CampaignContact.save(savePortion, insertOptions) - await r.knex('job_request').where('id', jobEvent.jobId).increment('status', 1) - const validationStats = {} - const completed = (await r.knex('job_request') - .where('id', jobEvent.jobId) - .select('status') - .first()) - console.log('loadContactsFromDataWarehouseFragment toward end', completed, jobEvent) + ); + + await CampaignContact.save(savePortion, insertOptions); + await r + .knex("job_request") + .where("id", jobEvent.jobId) + .increment("status", 1); + const validationStats = {}; + const completed = await r + .knex("job_request") + .where("id", jobEvent.jobId) + .select("status") + .first(); + console.log( + "loadContactsFromDataWarehouseFragment toward end", + completed, + jobEvent + ); if (!completed) { - console.log('loadContactsFromDataWarehouseFragment job has been deleted', completed, jobEvent.campaignId) + console.log( + "loadContactsFromDataWarehouseFragment job has been deleted", + completed, + jobEvent.campaignId + ); } else if (jobEvent.totalParts && completed.status >= jobEvent.totalParts) { if (jobEvent.organizationId) { // now that we've saved them all, we delete everyone that is opted out locally // doing this in one go so that we can get the DB to do the indexed cell matching // delete optout cells - await r.knex('campaign_contact') - .whereIn('cell', getOptOutSubQuery(jobEvent.organizationId)) - .where('campaign_id', jobEvent.campaignId) + await r + .knex("campaign_contact") + .whereIn("cell", getOptOutSubQuery(jobEvent.organizationId)) + .where("campaign_id", jobEvent.campaignId) .delete() .then(result => { - console.log(`loadContactsFromDataWarehouseFragment # of contacts opted out removed from DW query (${jobEvent.campaignId}): ${result}`) - validationStats.optOutCount = result - }) + console.log( + `loadContactsFromDataWarehouseFragment # of contacts opted out removed from DW query (${jobEvent.campaignId}): ${result}` + ); + validationStats.optOutCount = result; + }); // delete invalid cells - await r.knex('campaign_contact') - .whereRaw('length(cell) != 12') - .andWhere('campaign_id', jobEvent.campaignId) + await r + .knex("campaign_contact") + .whereRaw("length(cell) != 12") + .andWhere("campaign_id", jobEvent.campaignId) .delete() .then(result => { - console.log(`loadContactsFromDataWarehouseFragment # of contacts with invalid cells removed from DW query (${jobEvent.campaignId}): ${result}`) - validationStats.invalidCellCount = result - }) + console.log( + `loadContactsFromDataWarehouseFragment # of contacts with invalid cells removed from DW query (${jobEvent.campaignId}): ${result}` + ); + validationStats.invalidCellCount = result; + }); // delete duplicate cells - await r.knex('campaign_contact') - .whereIn('id', r.knex('campaign_contact') - .select('campaign_contact.id') - .leftJoin('campaign_contact AS c2', function joinSelf() { - this.on('c2.campaign_id', '=', 'campaign_contact.campaign_id') - .andOn('c2.cell', '=', 'campaign_contact.cell') - .andOn('c2.id', '>', 'campaign_contact.id') - }) - .where('campaign_contact.campaign_id', jobEvent.campaignId) - .whereNotNull('c2.id')) + await r + .knex("campaign_contact") + .whereIn( + "id", + r + .knex("campaign_contact") + .select("campaign_contact.id") + .leftJoin("campaign_contact AS c2", function joinSelf() { + this.on("c2.campaign_id", "=", "campaign_contact.campaign_id") + .andOn("c2.cell", "=", "campaign_contact.cell") + .andOn("c2.id", ">", "campaign_contact.id"); + }) + .where("campaign_contact.campaign_id", jobEvent.campaignId) + .whereNotNull("c2.id") + ) .delete() .then(result => { - console.log(`loadContactsFromDataWarehouseFragment # of contacts with duplicate cells removed from DW query (${jobEvent.campaignId}): ${result}`) - validationStats.duplicateCellCount = result - }) + console.log( + `loadContactsFromDataWarehouseFragment # of contacts with duplicate cells removed from DW query (${jobEvent.campaignId}): ${result}` + ); + validationStats.duplicateCellCount = result; + }); } - await r.table('job_request').get(jobEvent.jobId).delete() - await cacheableData.campaign.reload(jobEvent.campaignId) - return { 'completed': 1, validationStats } - } else if (jobEvent.part < (jobEvent.totalParts - 1)) { - const newPart = jobEvent.part + 1 + await r + .table("job_request") + .get(jobEvent.jobId) + .delete(); + await cacheableData.campaign.reload(jobEvent.campaignId); + return { completed: 1, validationStats }; + } else if (jobEvent.part < jobEvent.totalParts - 1) { + const newPart = jobEvent.part + 1; const newJob = { ...jobEvent, part: newPart, offset: newPart * jobEvent.step, limit: jobEvent.step, - command: 'loadContactsFromDataWarehouseFragmentJob' - } + command: "loadContactsFromDataWarehouseFragmentJob" + }; if (process.env.WAREHOUSE_DB_LAMBDA_ITERATION) { - console.log('SENDING TO LAMBDA loadContactsFromDataWarehouseFragment', newJob) - await sendJobToAWSLambda(newJob) - return { 'invokedAgain': 1 } + console.log( + "SENDING TO LAMBDA loadContactsFromDataWarehouseFragment", + newJob + ); + await sendJobToAWSLambda(newJob); + return { invokedAgain: 1 }; } else { - return loadContactsFromDataWarehouseFragment(newJob) + return loadContactsFromDataWarehouseFragment(newJob); } } } export async function loadContactsFromDataWarehouse(job) { - console.log('STARTING loadContactsFromDataWarehouse', job.payload) - const jobMessages = [] - const sqlQuery = job.payload - - if (!sqlQuery.startsWith('SELECT') || sqlQuery.indexOf(';') >= 0) { - log.error('Malformed SQL statement. Must begin with SELECT and not have any semicolons: ', sqlQuery) - return + console.log("STARTING loadContactsFromDataWarehouse", job.payload); + const jobMessages = []; + const sqlQuery = job.payload; + + if (!sqlQuery.startsWith("SELECT") || sqlQuery.indexOf(";") >= 0) { + log.error( + "Malformed SQL statement. Must begin with SELECT and not have any semicolons: ", + sqlQuery + ); + return; } if (!datawarehouse) { - log.error('No data warehouse connection, so cannot load contacts', job) - return + log.error("No data warehouse connection, so cannot load contacts", job); + return; } - let knexCountRes - let knexCount + let knexCountRes; + let knexCount; try { - warehouseConnection = warehouseConnection || datawarehouse() - knexCountRes = await warehouseConnection.raw(`SELECT COUNT(*) FROM ( ${sqlQuery} ) AS QUERYCOUNT`) + warehouseConnection = warehouseConnection || datawarehouse(); + knexCountRes = await warehouseConnection.raw( + `SELECT COUNT(*) FROM ( ${sqlQuery} ) AS QUERYCOUNT` + ); } catch (err) { - log.error('Data warehouse count query failed: ', err) - jobMessages.push(`Data warehouse count query failed with ${err}`) + log.error("Data warehouse count query failed: ", err); + jobMessages.push(`Data warehouse count query failed with ${err}`); } if (knexCountRes) { - knexCount = knexCountRes.rows[0].count + knexCount = knexCountRes.rows[0].count; if (!knexCount || knexCount == 0) { - jobMessages.push('Error: Data warehouse query returned zero results') + jobMessages.push("Error: Data warehouse query returned zero results"); } } - const STEP = ((r.kninky && r.kninky.defaultsUnsupported) - ? 10 // sqlite has a max of 100 variables and ~8 or so are used per insert - : 10000) // default - const campaign = await Campaign.get(job.campaign_id) - const totalParts = Math.ceil(knexCount / STEP) + const STEP = + r.kninky && r.kninky.defaultsUnsupported + ? 10 // sqlite has a max of 100 variables and ~8 or so are used per insert + : 10000; // default + const campaign = await Campaign.get(job.campaign_id); + const totalParts = Math.ceil(knexCount / STEP); if (totalParts > 1 && /LIMIT/.test(sqlQuery)) { // We do naive string concatenation when we divide queries up for parts // just appending " LIMIT " and " OFFSET " arguments. // If there is already a LIMIT in the query then we'll be unable to do that // so we error out. Note that if the total is < 10000, then LIMIT will be respected - jobMessages.push(`Error: LIMIT in query not supported for results larger than ${STEP}. Count was ${knexCount}`) + jobMessages.push( + `Error: LIMIT in query not supported for results larger than ${STEP}. Count was ${knexCount}` + ); } if (job.id && jobMessages.length) { - let resultMessages = await r.knex('job_request').where('id', job.id) - .update({ result_message: jobMessages.join('\n') }) - return resultMessages + let resultMessages = await r + .knex("job_request") + .where("id", job.id) + .update({ result_message: jobMessages.join("\n") }); + return resultMessages; } - await r.knex('campaign_contact') - .where('campaign_id', job.campaign_id) - .delete() + await r + .knex("campaign_contact") + .where("campaign_id", job.campaign_id) + .delete(); await loadContactsFromDataWarehouseFragment({ jobId: job.id, @@ -452,8 +581,8 @@ export async function loadContactsFromDataWarehouse(job) { totalCount: knexCount, step: STEP, part: 0, - limit: (totalParts > 1 ? STEP : 0) // 0 is unlimited - }) + limit: totalParts > 1 ? STEP : 0 // 0 is unlimited + }); } export async function assignTexters(job) { @@ -532,141 +661,180 @@ export async function assignTexters(job) { TODO: what happens when we switch modes? Do we allow it? */ - const payload = JSON.parse(job.payload) - const cid = job.campaign_id - const campaign = (await r.knex('campaign').where({ id: cid }))[0] - const texters = payload.texters - const currentAssignments = await r.knex('assignment') - .where('assignment.campaign_id', cid) - .joinRaw('left join campaign_contact allcontacts' - + ' ON (allcontacts.assignment_id = assignment.id)') - .groupBy('user_id', 'assignment.id') - .select('user_id', - 'assignment.id as id', - r.knex.raw("SUM(CASE WHEN allcontacts.message_status = 'needsMessage' THEN 1 ELSE 0 END) as needs_message_count"), - r.knex.raw('COUNT(allcontacts.id) as full_contact_count'), - 'max_contacts' - ) - .catch(log.error) - - const unchangedTexters = {} // max_contacts and needsMessageCount unchanged - const demotedTexters = {} // needsMessageCount reduced - const dynamic = campaign.use_dynamic_assignment + const payload = JSON.parse(job.payload); + const cid = job.campaign_id; + const campaign = (await r.knex("campaign").where({ id: cid }))[0]; + const texters = payload.texters; + const currentAssignments = await r + .knex("assignment") + .where("assignment.campaign_id", cid) + .joinRaw( + "left join campaign_contact allcontacts" + + " ON (allcontacts.assignment_id = assignment.id)" + ) + .groupBy("user_id", "assignment.id") + .select( + "user_id", + "assignment.id as id", + r.knex.raw( + "SUM(CASE WHEN allcontacts.message_status = 'needsMessage' THEN 1 ELSE 0 END) as needs_message_count" + ), + r.knex.raw("COUNT(allcontacts.id) as full_contact_count"), + "max_contacts" + ) + .catch(log.error); + + const unchangedTexters = {}; // max_contacts and needsMessageCount unchanged + const demotedTexters = {}; // needsMessageCount reduced + const dynamic = campaign.use_dynamic_assignment; // detect changed assignments - currentAssignments.map((assignment) => { - const texter = texters.filter((texter) => parseInt(texter.id, 10) === assignment.user_id)[0] - if (texter) { - const unchangedMaxContacts = - parseInt(texter.maxContacts, 10) === assignment.max_contacts || // integer = integer - texter.maxContacts === assignment.max_contacts // null = null - const unchangedNeedsMessageCount = - texter.needsMessageCount === parseInt(assignment.needs_message_count, 10) - if ((!dynamic && unchangedNeedsMessageCount) || (dynamic && unchangedMaxContacts)) { - unchangedTexters[assignment.user_id] = true - return null - } else if (!dynamic) { // standard assignment change - // If there is a delta between client and server, then accommodate delta (See #322) - const clientMessagedCount = texter.contactsCount - texter.needsMessageCount - const serverMessagedCount = assignment.full_contact_count - assignment.needs_message_count - - const numDifferent = ((texter.needsMessageCount || 0) - - assignment.needs_message_count - - Math.max(0, serverMessagedCount - clientMessagedCount)) - - if (numDifferent < 0) { // got less than before - demotedTexters[assignment.id] = -numDifferent - } else { // got more than before: assign the difference - texter.needsMessageCount = numDifferent + currentAssignments + .map(assignment => { + const texter = texters.filter( + texter => parseInt(texter.id, 10) === assignment.user_id + )[0]; + if (texter) { + const unchangedMaxContacts = + parseInt(texter.maxContacts, 10) === assignment.max_contacts || // integer = integer + texter.maxContacts === assignment.max_contacts; // null = null + const unchangedNeedsMessageCount = + texter.needsMessageCount === + parseInt(assignment.needs_message_count, 10); + if ( + (!dynamic && unchangedNeedsMessageCount) || + (dynamic && unchangedMaxContacts) + ) { + unchangedTexters[assignment.user_id] = true; + return null; + } else if (!dynamic) { + // standard assignment change + // If there is a delta between client and server, then accommodate delta (See #322) + const clientMessagedCount = + texter.contactsCount - texter.needsMessageCount; + const serverMessagedCount = + assignment.full_contact_count - assignment.needs_message_count; + + const numDifferent = + (texter.needsMessageCount || 0) - + assignment.needs_message_count - + Math.max(0, serverMessagedCount - clientMessagedCount); + + if (numDifferent < 0) { + // got less than before + demotedTexters[assignment.id] = -numDifferent; + } else { + // got more than before: assign the difference + texter.needsMessageCount = numDifferent; + } } + return assignment; + } else { + // deleted texter + demotedTexters[assignment.id] = assignment.needs_message_count; + return assignment; } - return assignment - } else { // deleted texter - demotedTexters[assignment.id] = assignment.needs_message_count - return assignment - } - }).filter((ele) => ele !== null) + }) + .filter(ele => ele !== null); for (const assignId in demotedTexters) { // Here we unassign ALL the demotedTexters contacts (not just the demotion count) // because they will get reapportioned below - await r.knex('campaign_contact') - .where('id', 'in', - r.knex('campaign_contact') - .where('assignment_id', assignId) - .where('message_status', 'needsMessage') - .select('id') - ) + await r + .knex("campaign_contact") + .where( + "id", + "in", + r + .knex("campaign_contact") + .where("assignment_id", assignId) + .where("message_status", "needsMessage") + .select("id") + ) .update({ assignment_id: null }) - .catch(log.error) + .catch(log.error); } - await updateJob(job, 20) + await updateJob(job, 20); - let availableContacts = await r.table('campaign_contact') - .getAll(null, { index: 'assignment_id' }) + let availableContacts = await r + .table("campaign_contact") + .getAll(null, { index: "assignment_id" }) .filter({ campaign_id: cid }) - .count() + .count(); // Go through all the submitted texters and create assignments - const texterCount = texters.length + const texterCount = texters.length; for (let index = 0; index < texterCount; index++) { - const texter = texters[index] - const texterId = parseInt(texter.id, 10) - let maxContacts = null // no limit + const texter = texters[index]; + const texterId = parseInt(texter.id, 10); + let maxContacts = null; // no limit if (texter.maxContacts || texter.maxContacts === 0) { - maxContacts = Math.min(parseInt(texter.maxContacts, 10), - parseInt(process.env.MAX_CONTACTS_PER_TEXTER || texter.maxContacts, 10)) + maxContacts = Math.min( + parseInt(texter.maxContacts, 10), + parseInt(process.env.MAX_CONTACTS_PER_TEXTER || texter.maxContacts, 10) + ); } else if (process.env.MAX_CONTACTS_PER_TEXTER) { - maxContacts = parseInt(process.env.MAX_CONTACTS_PER_TEXTER, 10) + maxContacts = parseInt(process.env.MAX_CONTACTS_PER_TEXTER, 10); } if (unchangedTexters[texterId]) { - continue + continue; } - const contactsToAssign = Math.min(availableContacts, texter.needsMessageCount) + const contactsToAssign = Math.min( + availableContacts, + texter.needsMessageCount + ); if (contactsToAssign === 0) { // avoid creating a new assignment when the texter should get 0 if (!campaign.use_dynamic_assignment) { - continue + continue; } } - availableContacts = availableContacts - contactsToAssign - const existingAssignment = currentAssignments.find((ele) => ele.user_id === texterId) - let assignment = null + availableContacts = availableContacts - contactsToAssign; + const existingAssignment = currentAssignments.find( + ele => ele.user_id === texterId + ); + let assignment = null; if (existingAssignment) { if (!dynamic) { - assignment = new Assignment({ id: existingAssignment.id, - user_id: existingAssignment.user_id, - campaign_id: cid }) // for notification + assignment = new Assignment({ + id: existingAssignment.id, + user_id: existingAssignment.user_id, + campaign_id: cid + }); // for notification } else { - await r.knex('assignment') - .where({ id: existingAssignment.id }) - .update({ max_contacts: maxContacts }) + await r + .knex("assignment") + .where({ id: existingAssignment.id }) + .update({ max_contacts: maxContacts }); } } else { assignment = await new Assignment({ user_id: texterId, campaign_id: cid, max_contacts: maxContacts - }).save() + }).save(); } if (!campaign.use_dynamic_assignment) { - await r.knex('campaign_contact') - .where('id', 'in', - r.knex('campaign_contact') - .where({ assignment_id: null, - campaign_id: cid - }) - .limit(contactsToAssign) - .select('id')) + await r + .knex("campaign_contact") + .where( + "id", + "in", + r + .knex("campaign_contact") + .where({ assignment_id: null, campaign_id: cid }) + .limit(contactsToAssign) + .select("id") + ) .update({ assignment_id: assignment.id }) - .catch(log.error) + .catch(log.error); if (existingAssignment) { // We can't rely on an observer because nothing @@ -674,85 +842,104 @@ export async function assignTexters(job) { await sendUserNotification({ type: Notifications.ASSIGNMENT_UPDATED, assignment - }) + }); } } - await updateJob(job, Math.floor((75 / texterCount) * (index + 1)) + 20) + await updateJob(job, Math.floor((75 / texterCount) * (index + 1)) + 20); } // endfor if (!campaign.use_dynamic_assignment) { // dynamic assignments, having zero initially is ok - const assignmentsToDelete = r.knex('assignment') - .where('assignment.campaign_id', cid) - .leftJoin('campaign_contact', 'assignment.id', 'campaign_contact.assignment_id') - .groupBy('assignment.id') - .havingRaw('COUNT(campaign_contact.id) = 0') - .select('assignment.id as id') - - await r.knex('assignment') - .where('id', 'in', assignmentsToDelete) + const assignmentsToDelete = r + .knex("assignment") + .where("assignment.campaign_id", cid) + .leftJoin( + "campaign_contact", + "assignment.id", + "campaign_contact.assignment_id" + ) + .groupBy("assignment.id") + .havingRaw("COUNT(campaign_contact.id) = 0") + .select("assignment.id as id"); + + await r + .knex("assignment") + .where("id", "in", assignmentsToDelete) .delete() - .catch(log.error) + .catch(log.error); } - if (job.id) { - await r.table('job_request').get(job.id).delete() + await r + .table("job_request") + .get(job.id) + .delete(); } } export async function exportCampaign(job) { - const payload = JSON.parse(job.payload) - const id = job.campaign_id - const campaign = await Campaign.get(id) - const requester = payload.requester - const user = await User.get(requester) - const allQuestions = {} - const questionCount = {} - const interactionSteps = await r.table('interaction_step') - .getAll(id, { index: 'campaign_id' }) - - interactionSteps.forEach((step) => { - if (!step.question || step.question.trim() === '') { - return + const payload = JSON.parse(job.payload); + const id = job.campaign_id; + const campaign = await Campaign.get(id); + const requester = payload.requester; + const user = await User.get(requester); + const allQuestions = {}; + const questionCount = {}; + const interactionSteps = await r + .table("interaction_step") + .getAll(id, { index: "campaign_id" }); + + interactionSteps.forEach(step => { + if (!step.question || step.question.trim() === "") { + return; } if (questionCount.hasOwnProperty(step.question)) { - questionCount[step.question] += 1 + questionCount[step.question] += 1; } else { - questionCount[step.question] = 0 + questionCount[step.question] = 0; } - const currentCount = questionCount[step.question] + const currentCount = questionCount[step.question]; if (currentCount > 0) { - allQuestions[step.id] = `${step.question}_${currentCount}` + allQuestions[step.id] = `${step.question}_${currentCount}`; } else { - allQuestions[step.id] = step.question + allQuestions[step.id] = step.question; } - }) - - let finalCampaignResults = [] - let finalCampaignMessages = [] - const assignments = await r.knex('assignment') - .where('campaign_id', id) - .join('user', 'user_id', 'user.id') - .select('assignment.id as id', - // user fields - 'first_name', 'last_name', 'email', 'cell', 'assigned_cell') - const assignmentCount = assignments.length + }); + + let finalCampaignResults = []; + let finalCampaignMessages = []; + const assignments = await r + .knex("assignment") + .where("campaign_id", id) + .join("user", "user_id", "user.id") + .select( + "assignment.id as id", + // user fields + "first_name", + "last_name", + "email", + "cell", + "assigned_cell" + ); + const assignmentCount = assignments.length; for (let index = 0; index < assignmentCount; index++) { - const assignment = assignments[index] - const optOuts = await r.table('opt_out') - .getAll(assignment.id, { index: 'assignment_id' }) - - const contacts = await r.knex('campaign_contact') - .leftJoin('zip_code', 'zip_code.zip', 'campaign_contact.zip') + const assignment = assignments[index]; + const optOuts = await r + .table("opt_out") + .getAll(assignment.id, { index: "assignment_id" }); + + const contacts = await r + .knex("campaign_contact") + .leftJoin("zip_code", "zip_code.zip", "campaign_contact.zip") .select() - .where('assignment_id', assignment.id) - const messages = await r.table('message') - .getAll(assignment.id, { index: 'assignment_id' }) - let convertedMessages = messages.map((message) => { + .where("assignment_id", assignment.id); + const messages = await r + .table("message") + .getAll(assignment.id, { index: "assignment_id" }); + let convertedMessages = messages.map(message => { const messageRow = { assignmentId: message.assignment_id, campaignId: campaign.id, @@ -762,268 +949,305 @@ export async function exportCampaign(job) { sendStatus: message.send_status, attemptedAt: moment(message.created_at).toISOString(), text: message.text - } - return messageRow - }) + }; + return messageRow; + }); - convertedMessages = await Promise.all(convertedMessages) - finalCampaignMessages = finalCampaignMessages.concat(convertedMessages) - let convertedContacts = contacts.map(async (contact) => { + convertedMessages = await Promise.all(convertedMessages); + finalCampaignMessages = finalCampaignMessages.concat(convertedMessages); + let convertedContacts = contacts.map(async contact => { const contactRow = { campaignId: campaign.id, campaign: campaign.title, assignmentId: assignment.id, - 'texter[firstName]': assignment.first_name, - 'texter[lastName]': assignment.last_name, - 'texter[email]': assignment.email, - 'texter[cell]': assignment.cell, - 'texter[assignedCell]': assignment.assigned_cell, - 'contact[firstName]': contact.first_name, - 'contact[lastName]': contact.last_name, - 'contact[cell]': contact.cell, - 'contact[zip]': contact.zip, - 'contact[city]': contact.city ? contact.city : null, - 'contact[state]': contact.state ? contact.state : null, - 'contact[optOut]': optOuts.find((ele) => ele.cell === contact.cell) ? 'true' : 'false', - 'contact[messageStatus]': contact.message_status, - 'contact[external_id]': contact.external_id - } - const customFields = JSON.parse(contact.custom_fields) - Object.keys(customFields).forEach((fieldName) => { - contactRow[`contact[${fieldName}]`] = customFields[fieldName] - }) + "texter[firstName]": assignment.first_name, + "texter[lastName]": assignment.last_name, + "texter[email]": assignment.email, + "texter[cell]": assignment.cell, + "texter[assignedCell]": assignment.assigned_cell, + "contact[firstName]": contact.first_name, + "contact[lastName]": contact.last_name, + "contact[cell]": contact.cell, + "contact[zip]": contact.zip, + "contact[city]": contact.city ? contact.city : null, + "contact[state]": contact.state ? contact.state : null, + "contact[optOut]": optOuts.find(ele => ele.cell === contact.cell) + ? "true" + : "false", + "contact[messageStatus]": contact.message_status, + "contact[external_id]": contact.external_id + }; + const customFields = JSON.parse(contact.custom_fields); + Object.keys(customFields).forEach(fieldName => { + contactRow[`contact[${fieldName}]`] = customFields[fieldName]; + }); - const questionResponses = await r.table('question_response') - .getAll(contact.id, { index: 'campaign_contact_id' }) + const questionResponses = await r + .table("question_response") + .getAll(contact.id, { index: "campaign_contact_id" }); - Object.keys(allQuestions).forEach((stepId) => { - let value = '' - questionResponses.forEach((response) => { + Object.keys(allQuestions).forEach(stepId => { + let value = ""; + questionResponses.forEach(response => { if (response.interaction_step_id === parseInt(stepId, 10)) { - value = response.value + value = response.value; } - }) + }); - contactRow[`question[${allQuestions[stepId]}]`] = value - }) + contactRow[`question[${allQuestions[stepId]}]`] = value; + }); - return contactRow - }) - convertedContacts = await Promise.all(convertedContacts) - finalCampaignResults = finalCampaignResults.concat(convertedContacts) - await updateJob(job, Math.round(index / assignmentCount * 100)) + return contactRow; + }); + convertedContacts = await Promise.all(convertedContacts); + finalCampaignResults = finalCampaignResults.concat(convertedContacts); + await updateJob(job, Math.round((index / assignmentCount) * 100)); } - const campaignCsv = Papa.unparse(finalCampaignResults) - const messageCsv = Papa.unparse(finalCampaignMessages) + const campaignCsv = Papa.unparse(finalCampaignResults); + const messageCsv = Papa.unparse(finalCampaignMessages); - if (process.env.AWS_ACCESS_AVAILABLE || (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY)) { + if ( + process.env.AWS_ACCESS_AVAILABLE || + (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) + ) { try { - const s3bucket = new AWS.S3({ params: { Bucket: process.env.AWS_S3_BUCKET_NAME } }) - const campaignTitle = campaign.title.replace(/ /g, '_').replace(/\//g, '_') - const key = `${campaignTitle}-${moment().format('YYYY-MM-DD-HH-mm-ss')}.csv` - const messageKey = `${key}-messages.csv` - let params = { Key: key, Body: campaignCsv } - await s3bucket.putObject(params).promise() - params = { Key: key, Expires: 86400 } - const campaignExportUrl = await s3bucket.getSignedUrl('getObject', params) - params = { Key: messageKey, Body: messageCsv } - await s3bucket.putObject(params).promise() - params = { Key: messageKey, Expires: 86400 } - const campaignMessagesExportUrl = await s3bucket.getSignedUrl('getObject', params) + const s3bucket = new AWS.S3({ + params: { Bucket: process.env.AWS_S3_BUCKET_NAME } + }); + const campaignTitle = campaign.title + .replace(/ /g, "_") + .replace(/\//g, "_"); + const key = `${campaignTitle}-${moment().format( + "YYYY-MM-DD-HH-mm-ss" + )}.csv`; + const messageKey = `${key}-messages.csv`; + let params = { Key: key, Body: campaignCsv }; + await s3bucket.putObject(params).promise(); + params = { Key: key, Expires: 86400 }; + const campaignExportUrl = await s3bucket.getSignedUrl( + "getObject", + params + ); + params = { Key: messageKey, Body: messageCsv }; + await s3bucket.putObject(params).promise(); + params = { Key: messageKey, Expires: 86400 }; + const campaignMessagesExportUrl = await s3bucket.getSignedUrl( + "getObject", + params + ); await sendEmail({ to: user.email, subject: `Export ready for ${campaign.title}`, text: `Your Spoke exports are ready! These URLs will be valid for 24 hours. Campaign export: ${campaignExportUrl} Message export: ${campaignMessagesExportUrl}` - }).catch((err) => { - log.error(err) - log.info(`Campaign Export URL - ${campaignExportUrl}`) - log.info(`Campaign Messages Export URL - ${campaignMessagesExportUrl}`) - }) - log.info(`Successfully exported ${id}`) + }).catch(err => { + log.error(err); + log.info(`Campaign Export URL - ${campaignExportUrl}`); + log.info(`Campaign Messages Export URL - ${campaignMessagesExportUrl}`); + }); + log.info(`Successfully exported ${id}`); } catch (err) { - log.error(err) + log.error(err); await sendEmail({ to: user.email, subject: `Export failed for ${campaign.title}`, text: `Your Spoke exports failed... please try again later. Error: ${err.message}` - }) + }); } } else { - log.debug('Would have saved the following to S3:') - log.debug(campaignCsv) - log.debug(messageCsv) + log.debug("Would have saved the following to S3:"); + log.debug(campaignCsv); + log.debug(messageCsv); } - await defensivelyDeleteJob(job) + await defensivelyDeleteJob(job); } export async function importScript(job) { - const payload = await unzipPayload(job) + const payload = await unzipPayload(job); try { - await importScriptFromDocument(payload.campaignId, payload.url) // TODO try/catch + await importScriptFromDocument(payload.campaignId, payload.url); // TODO try/catch } catch (exception) { - await r.knex('job_request').where('id', job.id) - .update({ result_message: exception.message }) - console.log(exception.message) - return + await r + .knex("job_request") + .where("id", job.id) + .update({ result_message: exception.message }); + console.log(exception.message); + return; } - defensivelyDeleteJob(job) + defensivelyDeleteJob(job); } // add an in-memory guard that the same messages are being sent again and again // not related to stale filter -let pastMessages = [] +let pastMessages = []; export async function sendMessages(queryFunc, defaultStatus) { try { await knex.transaction(async trx => { - let messages = [] + let messages = []; try { - let messageQuery = r.knex('message') + let messageQuery = r + .knex("message") .transacting(trx) .forUpdate() - .where({ send_status: defaultStatus || 'QUEUED' }) + .where({ send_status: defaultStatus || "QUEUED" }); if (queryFunc) { - messageQuery = queryFunc(messageQuery) + messageQuery = queryFunc(messageQuery); } - messages = await messageQuery.orderBy('created_at') + messages = await messageQuery.orderBy("created_at"); } catch (err) { // Unable to obtain lock on these rows meaning another process must be // sending them. We will exit gracefully in that case. - trx.rollback() - return + trx.rollback(); + return; } try { for (let index = 0; index < messages.length; index++) { - let message = messages[index] + let message = messages[index]; if (pastMessages.indexOf(message.id) !== -1) { - throw new Error('Encountered send message request of the same message.' - + ' This is scary! If ok, just restart process. Message ID: ' + message.id) + throw new Error( + "Encountered send message request of the same message." + + " This is scary! If ok, just restart process. Message ID: " + + message.id + ); } - message.service = message.service || process.env.DEFAULT_SERVICE - const service = serviceMap[message.service] - log.info(`Sending (${message.service}): ${message.user_number} -> ${message.contact_number}\nMessage: ${message.text}`) - await service.sendMessage(message, null, trx) - pastMessages.push(message.id) - pastMessages = pastMessages.slice(-100) // keep the last 100 + message.service = message.service || process.env.DEFAULT_SERVICE; + const service = serviceMap[message.service]; + log.info( + `Sending (${message.service}): ${message.user_number} -> ${message.contact_number}\nMessage: ${message.text}` + ); + await service.sendMessage(message, null, trx); + pastMessages.push(message.id); + pastMessages = pastMessages.slice(-100); // keep the last 100 } - trx.commit() + trx.commit(); } catch (err) { - console.log('error sending messages:') - console.error(err) - trx.rollback() + console.log("error sending messages:"); + console.error(err); + trx.rollback(); } - }) + }); } catch (err) { - console.log('sendMessages transaction errored:') - console.error(err) + console.log("sendMessages transaction errored:"); + console.error(err); } } export async function handleIncomingMessageParts() { - const messageParts = await r.table('pending_message_part').limit(100) - const messagePartsByService = {} - messageParts.forEach((m) => { + const messageParts = await r.table("pending_message_part").limit(100); + const messagePartsByService = {}; + messageParts.forEach(m => { if (m.service in serviceMap) { if (!(m.service in messagePartsByService)) { - messagePartsByService[m.service] = [] + messagePartsByService[m.service] = []; } - messagePartsByService[m.service].push(m) + messagePartsByService[m.service].push(m); } - }) + }); for (const serviceKey in messagePartsByService) { - let allParts = messagePartsByService[serviceKey] - const service = serviceMap[serviceKey] + let allParts = messagePartsByService[serviceKey]; + const service = serviceMap[serviceKey]; if (service.syncMessagePartProcessing) { // filter for anything older than ten minutes ago - const tenMinutesAgo = new Date(new Date() - 1000 * 60 * 10) - allParts = allParts.filter((part) => (part.created_at < tenMinutesAgo)) + const tenMinutesAgo = new Date(new Date() - 1000 * 60 * 10); + allParts = allParts.filter(part => part.created_at < tenMinutesAgo); } - const allPartsCount = allParts.length + const allPartsCount = allParts.length; if (allPartsCount === 0) { - continue + continue; } - const convertMessageParts = service.convertMessagePartsToMessage - const messagesToSave = [] - let messagePartsToDelete = [] - const concatMessageParts = {} + const convertMessageParts = service.convertMessagePartsToMessage; + const messagesToSave = []; + let messagePartsToDelete = []; + const concatMessageParts = {}; for (let i = 0; i < allPartsCount; i++) { - const part = allParts[i] - const serviceMessageId = part.service_id - const savedCount = await r.table('message') - .getAll(serviceMessageId, { index: 'service_id' }) - .count() + const part = allParts[i]; + const serviceMessageId = part.service_id; + const savedCount = await r + .table("message") + .getAll(serviceMessageId, { index: "service_id" }) + .count(); const lastMessage = await getLastMessage({ contactNumber: part.contact_number, service: serviceKey - }) - const duplicateMessageToSaveExists = !!messagesToSave.find((message) => message.service_id === serviceMessageId) + }); + const duplicateMessageToSaveExists = !!messagesToSave.find( + message => message.service_id === serviceMessageId + ); if (!lastMessage) { - log.info('Received message part with no thread to attach to', part) - messagePartsToDelete.push(part) + log.info("Received message part with no thread to attach to", part); + messagePartsToDelete.push(part); } else if (savedCount > 0) { - log.info(`Found already saved message matching part service message ID ${part.service_id}`) - messagePartsToDelete.push(part) + log.info( + `Found already saved message matching part service message ID ${part.service_id}` + ); + messagePartsToDelete.push(part); } else if (duplicateMessageToSaveExists) { - log.info(`Found duplicate message to be saved matching part service message ID ${part.service_id}`) - messagePartsToDelete.push(part) + log.info( + `Found duplicate message to be saved matching part service message ID ${part.service_id}` + ); + messagePartsToDelete.push(part); } else { - const parentId = part.parent_id + const parentId = part.parent_id; if (!parentId) { - messagesToSave.push(await convertMessageParts([part])) - messagePartsToDelete.push(part) + messagesToSave.push(await convertMessageParts([part])); + messagePartsToDelete.push(part); } else { - if (part.service !== 'nexmo') { - throw new Error('should not have a parent ID for twilio') + if (part.service !== "nexmo") { + throw new Error("should not have a parent ID for twilio"); } - const groupKey = [parentId, part.contact_number, part.user_number] - const serviceMessage = JSON.parse(part.service_message) + const groupKey = [parentId, part.contact_number, part.user_number]; + const serviceMessage = JSON.parse(part.service_message); if (!concatMessageParts.hasOwnProperty(groupKey)) { - const partCount = parseInt(serviceMessage['concat-total'], 10) - concatMessageParts[groupKey] = Array(partCount).fill(null) + const partCount = parseInt(serviceMessage["concat-total"], 10); + concatMessageParts[groupKey] = Array(partCount).fill(null); } - const partIndex = parseInt(serviceMessage['concat-part'], 10) - 1 + const partIndex = parseInt(serviceMessage["concat-part"], 10) - 1; if (concatMessageParts[groupKey][partIndex] !== null) { - messagePartsToDelete.push(part) + messagePartsToDelete.push(part); } else { - concatMessageParts[groupKey][partIndex] = part + concatMessageParts[groupKey][partIndex] = part; } } } } - const keys = Object.keys(concatMessageParts) - const keyCount = keys.length + const keys = Object.keys(concatMessageParts); + const keyCount = keys.length; for (let i = 0; i < keyCount; i++) { - const groupKey = keys[i] - const messageParts = concatMessageParts[groupKey] + const groupKey = keys[i]; + const messageParts = concatMessageParts[groupKey]; - if (messageParts.filter((part) => part === null).length === 0) { - messagePartsToDelete = messagePartsToDelete.concat(messageParts) - const message = await convertMessageParts(messageParts) - messagesToSave.push(message) + if (messageParts.filter(part => part === null).length === 0) { + messagePartsToDelete = messagePartsToDelete.concat(messageParts); + const message = await convertMessageParts(messageParts); + messagesToSave.push(message); } } - const messageCount = messagesToSave.length + const messageCount = messagesToSave.length; for (let i = 0; i < messageCount; i++) { - log.info('Saving message with service message ID', messagesToSave[i].service_id) - await saveNewIncomingMessage(messagesToSave[i]) + log.info( + "Saving message with service message ID", + messagesToSave[i].service_id + ); + await saveNewIncomingMessage(messagesToSave[i]); } - const messageIdsToDelete = messagePartsToDelete.map((m) => m.id) - log.info('Deleting message parts', messageIdsToDelete) - await r.table('pending_message_part') + const messageIdsToDelete = messagePartsToDelete.map(m => m.id); + log.info("Deleting message parts", messageIdsToDelete); + await r + .table("pending_message_part") .getAll(...messageIdsToDelete) - .delete() + .delete(); } } @@ -1033,30 +1257,36 @@ export async function handleIncomingMessageParts() { export async function fixOrgless() { if (process.env.FIX_ORGLESS) { const orgless = await r.knex - .select('user.id') - .from('user') - .leftJoin('user_organization', 'user.id', 'user_organization.user_id') - .whereNull('user_organization.id'); - orgless.forEach(async(orglessUser) => { + .select("user.id") + .from("user") + .leftJoin("user_organization", "user.id", "user_organization.user_id") + .whereNull("user_organization.id"); + orgless.forEach(async orglessUser => { await UserOrganization.save({ user_id: orglessUser.id.toString(), organization_id: process.env.DEFAULT_ORG || 1, - role: 'TEXTER' + role: "TEXTER" }).error(function(error) { // Unexpected errors - console.log("error on userOrganization save in orgless", error) + console.log("error on userOrganization save in orgless", error); }); - console.log("added orgless user " + user.id + " to organization " + process.env.DEFAULT_ORG ) - }) // forEach + console.log( + "added orgless user " + + user.id + + " to organization " + + process.env.DEFAULT_ORG + ); + }); // forEach } // if } // function export async function clearOldJobs(delay) { // to clear out old stuck jobs - const twoHoursAgo = new Date(new Date() - 1000 * 60 * 60 * 2) - delay = delay || twoHoursAgo - return await r.knex('job_request') + const twoHoursAgo = new Date(new Date() - 1000 * 60 * 60 * 2); + delay = delay || twoHoursAgo; + return await r + .knex("job_request") .where({ assigned: true }) - .where('updated_at', '<', delay) - .delete() + .where("updated_at", "<", delay) + .delete(); } diff --git a/src/workers/lib.js b/src/workers/lib.js index 572883b4b..ff8d69070 100644 --- a/src/workers/lib.js +++ b/src/workers/lib.js @@ -1,29 +1,30 @@ -import { r, JobRequest } from '../server/models' +import { r, JobRequest } from "../server/models"; -export const sleep = (ms = 0) => new Promise(fn => setTimeout(fn, ms)) +export const sleep = (ms = 0) => new Promise(fn => setTimeout(fn, ms)); export async function updateJob(job, percentComplete) { if (job.id) { - await JobRequest.get(job.id) - .update({ - status: percentComplete, - updated_at: new Date() - }) + await JobRequest.get(job.id).update({ + status: percentComplete, + updated_at: new Date() + }); } } export async function getNextJob() { - let nextJob = await r.table('job_request') - .filter({ assigned: false }) - .orderBy('created_at') - .limit(1)(0) + let nextJob = await r + .table("job_request") + .filter({ assigned: false }) + .orderBy("created_at") + .limit(1)(0); if (nextJob) { - const updateResults = await r.table('job_request') - .get(nextJob.id) - .update({ assigned: true }) + const updateResults = await r + .table("job_request") + .get(nextJob.id) + .update({ assigned: true }); if (updateResults.replaced !== 1) { - nextJob = null + nextJob = null; } } - return nextJob + return nextJob; } diff --git a/src/workers/message-sender-01.js b/src/workers/message-sender-01.js index cc50b1c01..aef24541b 100644 --- a/src/workers/message-sender-01.js +++ b/src/workers/message-sender-01.js @@ -1,3 +1,5 @@ -import { messageSender01 } from './job-processes' +import { messageSender01 } from "./job-processes"; -messageSender01().catch((err) => { console.log(err) }) +messageSender01().catch(err => { + console.log(err); +}); diff --git a/src/workers/message-sender-234.js b/src/workers/message-sender-234.js index fe2ab4c66..bfa4131e0 100644 --- a/src/workers/message-sender-234.js +++ b/src/workers/message-sender-234.js @@ -1,3 +1,5 @@ -import { messageSender234 } from './job-processes' +import { messageSender234 } from "./job-processes"; -messageSender234().catch((err) => { console.log(err) }) +messageSender234().catch(err => { + console.log(err); +}); diff --git a/src/workers/message-sender-56.js b/src/workers/message-sender-56.js index d0e777b8a..2730ef2e2 100644 --- a/src/workers/message-sender-56.js +++ b/src/workers/message-sender-56.js @@ -1,3 +1,5 @@ -import { messageSender56 } from './job-processes' +import { messageSender56 } from "./job-processes"; -messageSender56().catch((err) => { console.log(err) }) +messageSender56().catch(err => { + console.log(err); +}); diff --git a/src/workers/message-sender-789.js b/src/workers/message-sender-789.js index 083fac253..832197954 100644 --- a/src/workers/message-sender-789.js +++ b/src/workers/message-sender-789.js @@ -1,3 +1,5 @@ -import { messageSender789 } from './job-processes' +import { messageSender789 } from "./job-processes"; -messageSender789().catch((err) => { console.log(err) }) +messageSender789().catch(err => { + console.log(err); +}); diff --git a/webpack/config.js b/webpack/config.js index 37797be94..a21794653 100644 --- a/webpack/config.js +++ b/webpack/config.js @@ -1,55 +1,58 @@ -require('dotenv').config() -const path = require('path') -const webpack = require('webpack') -const ManifestPlugin = require('webpack-manifest-plugin') +require("dotenv").config(); +const path = require("path"); +const webpack = require("webpack"); +const ManifestPlugin = require("webpack-manifest-plugin"); -const DEBUG = process.env.NODE_ENV !== 'production' +const DEBUG = process.env.NODE_ENV !== "production"; const plugins = [ new webpack.DefinePlugin({ - 'process.env.NODE_ENV': `"${process.env.NODE_ENV}"`, - 'process.env.PHONE_NUMBER_COUNTRY': `"${process.env.PHONE_NUMBER_COUNTRY}"` + "process.env.NODE_ENV": `"${process.env.NODE_ENV}"`, + "process.env.PHONE_NUMBER_COUNTRY": `"${process.env.PHONE_NUMBER_COUNTRY}"` }), new webpack.ContextReplacementPlugin( /[\/\\]node_modules[\/\\]timezonecomplete[\/\\]/, path.resolve("tz-database-context"), { - "tzdata": "tzdata", + tzdata: "tzdata" } ) -] -const jsxLoaders = [{loader: 'babel-loader'}] -const assetsDir = process.env.ASSETS_DIR -const assetMapFile = process.env.ASSETS_MAP_FILE -const outputFile = DEBUG ? '[name].js' : '[name].[chunkhash].js' +]; +const jsxLoaders = [{ loader: "babel-loader" }]; +const assetsDir = process.env.ASSETS_DIR; +const assetMapFile = process.env.ASSETS_MAP_FILE; +const outputFile = DEBUG ? "[name].js" : "[name].[chunkhash].js"; if (!DEBUG) { - plugins.push(new ManifestPlugin({ - fileName: assetMapFile - })) - plugins.push(new webpack.optimize.UglifyJsPlugin({ - sourceMap: true - })) - plugins.push(new webpack.LoaderOptionsPlugin({ - minimize: true - })) + plugins.push( + new ManifestPlugin({ + fileName: assetMapFile + }) + ); + plugins.push( + new webpack.optimize.UglifyJsPlugin({ + sourceMap: true + }) + ); + plugins.push( + new webpack.LoaderOptionsPlugin({ + minimize: true + }) + ); } else { - plugins.push(new webpack.HotModuleReplacementPlugin()) - jsxLoaders.unshift({loader: 'react-hot-loader'}) + plugins.push(new webpack.HotModuleReplacementPlugin()); + jsxLoaders.unshift({ loader: "react-hot-loader" }); } const config = { entry: { - bundle: ['babel-polyfill', './src/client/index.jsx'] + bundle: ["babel-polyfill", "./src/client/index.jsx"] }, module: { rules: [ { test: /\.css$/, - use: [ - {loader: 'style-loader'}, - {loader: 'css-loader'} - ] + use: [{ loader: "style-loader" }, { loader: "css-loader" }] }, { test: /\.jsx?$/, @@ -59,19 +62,19 @@ const config = { ] }, resolve: { - extensions: ['.js', '.jsx'] + extensions: [".js", ".jsx"] }, plugins, output: { filename: outputFile, path: path.resolve(DEBUG ? __dirname : assetsDir), - publicPath: '/assets/' + publicPath: "/assets/" } -} +}; if (DEBUG) { - config.devtool = 'inline-source-map' - config.output.sourceMapFilename = `${outputFile}.map` + config.devtool = "inline-source-map"; + config.output.sourceMapFilename = `${outputFile}.map`; } -module.exports = config +module.exports = config; diff --git a/webpack/server.js b/webpack/server.js index 65329df07..36f710d30 100644 --- a/webpack/server.js +++ b/webpack/server.js @@ -1,32 +1,33 @@ -import WebpackDevServer from 'webpack-dev-server' -import webpack from 'webpack' -import config from './config' -import { log } from '../src/lib' +import WebpackDevServer from "webpack-dev-server"; +import webpack from "webpack"; +import config from "./config"; +import { log } from "../src/lib"; -const webpackPort = process.env.WEBPACK_PORT || 3000 -const appPort = process.env.DEV_APP_PORT -const webpackHost = process.env.WEBPACK_HOST || '127.0.0.1' +const webpackPort = process.env.WEBPACK_PORT || 3000; +const appPort = process.env.DEV_APP_PORT; +const webpackHost = process.env.WEBPACK_HOST || "127.0.0.1"; -Object.keys(config.entry) -.forEach((key) => { - config.entry[key].unshift(`webpack-dev-server/client?http://${webpackHost}:${webpackPort}/`) - config.entry[key].unshift('webpack/hot/only-dev-server') -}) +Object.keys(config.entry).forEach(key => { + config.entry[key].unshift( + `webpack-dev-server/client?http://${webpackHost}:${webpackPort}/` + ); + config.entry[key].unshift("webpack/hot/only-dev-server"); +}); -const compiler = webpack(config) -const connstring = `http://127.0.0.1:${appPort}` +const compiler = webpack(config); +const connstring = `http://127.0.0.1:${appPort}`; -log.info(`Proxying requests to:${connstring}`) +log.info(`Proxying requests to:${connstring}`); const app = new WebpackDevServer(compiler, { - contentBase: '/assets/', - publicPath: '/assets/', + contentBase: "/assets/", + publicPath: "/assets/", hot: true, // this should be temporary until we get the real hostname plugged in everywhere disableHostCheck: true, - headers: { 'Access-Control-Allow-Origin': '*' }, + headers: { "Access-Control-Allow-Origin": "*" }, proxy: { - '*': `http://127.0.0.1:${appPort}` + "*": `http://127.0.0.1:${appPort}` }, stats: { colors: true, @@ -44,8 +45,10 @@ const app = new WebpackDevServer(compiler, { warnings: true, publicPath: false } -}) +}); app.listen(webpackPort || process.env.PORT, () => { - log.info(`Webpack dev server is now running on http://${webpackHost}:${webpackPort}`) -}) + log.info( + `Webpack dev server is now running on http://${webpackHost}:${webpackPort}` + ); +});