diff --git a/__test__/containers/UserMenu.test.js b/__test__/containers/UserMenu.test.js
index ca8a1f786..2e8e72b00 100644
--- a/__test__/containers/UserMenu.test.js
+++ b/__test__/containers/UserMenu.test.js
@@ -8,35 +8,43 @@ import { StyleSheetTestUtils } from "aphrodite";
import { UserMenu } from "../../src/containers/UserMenu";
import MuiThemeProvider from "material-ui/styles/MuiThemeProvider";
+function getData(isSuperAdmin = false) {
+ return {
+ currentUser: {
+ id: 1,
+ displayName: "TestName",
+ email: "test@test.com",
+ is_superadmin: isSuperAdmin,
+ superVolOrganizations: [
+ {
+ id: 2,
+ name: "testOrg"
+ }
+ ],
+ texterOrganizations: [
+ {
+ id: 2,
+ name: "testOrg"
+ }
+ ]
+ }
+ };
+}
+
+function getWrapper(data) {
+ return mount(
+
+
+
+ ).find(UserMenu);
+}
+
describe("UserMenu", () => {
it("renders the correct user Avatar icon", async () => {
StyleSheetTestUtils.suppressStyleInjection();
- const data = {
- currentUser: {
- id: 1,
- displayName: "TestName",
- email: "test@test.com",
- superVolOrganizations: [
- {
- id: 2,
- name: "testOrg"
- }
- ],
- texterOrganizations: [
- {
- id: 2,
- name: "testOrg"
- }
- ]
- }
- };
-
- const wrapper = mount(
-
-
-
- ).find(UserMenu);
+ const data = getData();
+ const wrapper = getWrapper(data);
const avatar = wrapper.find("Avatar");
expect(avatar.props().children).toBe(
@@ -47,26 +55,8 @@ describe("UserMenu", () => {
it("renders the full popover menu", async () => {
StyleSheetTestUtils.suppressStyleInjection();
- const data = {
- currentUser: {
- id: 1,
- displayName: "TestName",
- email: "test@test.com",
- superVolOrganizations: [],
- texterOrganizations: [
- {
- id: 2,
- name: "testOrg"
- }
- ]
- }
- };
-
- const wrapper = mount(
-
-
-
- ).find(UserMenu);
+ const data = getData();
+ const wrapper = getWrapper(data);
// Make sure the menu loads
const menuPopover = wrapper.find("Popover");
@@ -83,4 +73,77 @@ describe("UserMenu", () => {
expect(menuItems[2].props["data-test"]).toBe("FAQs");
expect(menuItems[3].props["data-test"]).toBe("userMenuLogOut");
});
+
+ it("renders admin tools if user is superadmin and MULTI_TENTANT", async () => {
+ StyleSheetTestUtils.suppressStyleInjection();
+
+ const data = getData("true");
+ const wrapper = getWrapper(data);
+ window.MULTI_TENTANT = true;
+
+ // Make sure the menu loads
+ const menuPopover = wrapper.find("Popover");
+ expect(menuPopover.length).toBeGreaterThan(0);
+
+ const menuContentArray = menuPopover.props().children.props.children;
+ const menuItems = menuContentArray.filter(
+ item =>
+ item.type && (item.type.muiName === "MenuItem" || item.type === "div")
+ );
+
+ // Check for each thing we always expect to see in the menu
+ expect(menuItems[0].props["data-test"]).toBe("userMenuDisplayName");
+ expect(menuItems[1].key).toBe(null); // div containing rendered AdminTools
+ expect(menuItems[2].props["data-test"]).toBe("home");
+ expect(menuItems[3].props["data-test"]).toBe("FAQs");
+ expect(menuItems[4].props["data-test"]).toBe("userMenuLogOut");
+ });
+
+ it("DOESN'T render admin tools if user is NOT superadmin and MULTI_TENTANT", async () => {
+ StyleSheetTestUtils.suppressStyleInjection();
+
+ const data = getData();
+ const wrapper = getWrapper(data);
+ window.MULTI_TENTANT = true;
+
+ // Make sure the menu loads
+ const menuPopover = wrapper.find("Popover");
+ expect(menuPopover.length).toBeGreaterThan(0);
+
+ const menuContentArray = menuPopover.props().children.props.children;
+ const menuItems = menuContentArray.filter(
+ item =>
+ item.type && (item.type.muiName === "MenuItem" || item.type === "div")
+ );
+
+ // Check for each thing we always expect to see in the menu
+ expect(menuItems[0].props["data-test"]).toBe("userMenuDisplayName");
+ expect(menuItems[2].props["data-test"]).toBe("home");
+ expect(menuItems[3].props["data-test"]).toBe("FAQs");
+ expect(menuItems[4].props["data-test"]).toBe("userMenuLogOut");
+ });
+
+ it("DOESN'T render admin tools if user is NOT superadmin and NOT MULTI_TENTANT", async () => {
+ StyleSheetTestUtils.suppressStyleInjection();
+
+ const data = getData();
+ const wrapper = getWrapper(data);
+ window.MULTI_TENTANT = false;
+
+ // Make sure the menu loads
+ const menuPopover = wrapper.find("Popover");
+ expect(menuPopover.length).toBeGreaterThan(0);
+
+ const menuContentArray = menuPopover.props().children.props.children;
+ const menuItems = menuContentArray.filter(
+ item =>
+ item.type && (item.type.muiName === "MenuItem" || item.type === "div")
+ );
+
+ // Check for each thing we always expect to see in the menu
+ expect(menuItems[0].props["data-test"]).toBe("userMenuDisplayName");
+ expect(menuItems[2].props["data-test"]).toBe("home");
+ expect(menuItems[3].props["data-test"]).toBe("FAQs");
+ expect(menuItems[4].props["data-test"]).toBe("userMenuLogOut");
+ });
});
diff --git a/__test__/extensions/contact-loaders/csv-upload.test.js b/__test__/extensions/contact-loaders/csv-upload.test.js
index 865b796e1..3452ed3a4 100644
--- a/__test__/extensions/contact-loaders/csv-upload.test.js
+++ b/__test__/extensions/contact-loaders/csv-upload.test.js
@@ -32,7 +32,7 @@ import React from "react";
import { shallow, mount } from "enzyme";
import MuiThemeProvider from "material-ui/styles/MuiThemeProvider";
import { StyleSheetTestUtils } from "aphrodite";
-import CampaignContactsChoiceForm from "../../../src/components/CampaignContactsChoiceForm";
+import { CampaignContactsChoiceForm } from "../../../src/components/CampaignContactsChoiceForm";
import { icons } from "../../../src/components/CampaignContactsChoiceForm";
const contacts = [
@@ -53,6 +53,13 @@ const dupeContacts = [
zip: "10025",
custom_fields: '{"custom1": "abc"}'
},
+ {
+ first_name: "second",
+ last_name: "thirdlast",
+ cell: "+12125550100",
+ zip: "10025",
+ custom_fields: '{"custom1": "xyz"}'
+ },
{
first_name: "fdsa",
last_name: "yyyy",
@@ -111,7 +118,7 @@ describe("ingest-contact-loader method: csv-upload backend", async () => {
expect(dbContacts[0].last_name).toBe("xxxx");
expect(dbContacts[0].custom_fields).toBe('{"custom1": "abc"}');
});
- it("csv-upload:processContactLoad dedupe", async () => {
+ it("csv-upload:processContactLoad dedupe last wins", async () => {
const job = {
payload: await gzip(JSON.stringify({ contacts: dupeContacts })),
campaign_id: testCampaign.id,
@@ -127,7 +134,7 @@ describe("ingest-contact-loader method: csv-upload backend", async () => {
.where("campaign_id", testCampaign.id)
.first();
expect(dbContacts.length).toBe(1);
- expect(adminResult.duplicate_contacts_count).toBe(1);
+ expect(adminResult.duplicate_contacts_count).toBe(2);
expect(adminResult.contacts_count).toBe(1);
expect(dbContacts[0].first_name).toBe("fdsa");
expect(dbContacts[0].last_name).toBe("yyyy");
diff --git a/__test__/integrations/action-handlers/action-network.test.js b/__test__/integrations/action-handlers/action-network.test.js
new file mode 100644
index 000000000..a4e5e0400
--- /dev/null
+++ b/__test__/integrations/action-handlers/action-network.test.js
@@ -0,0 +1,815 @@
+import nock from "nock";
+import moment from "moment";
+const ActionNetwork = require("../../../src/integrations/action-handlers/action-network");
+
+expect.extend({
+ stringifiedObjectEqualObject(receivedString, expectedObject) {
+ let pass = true;
+ let message = "";
+ try {
+ expect(JSON.parse(receivedString)).toEqual(expectedObject);
+ } catch (caught) {
+ pass = false;
+ message = `Expected ${receivedString} to equal ${JSON.stringify(
+ expectedObject
+ )}`;
+ }
+ return {
+ pass,
+ message: () => message
+ };
+ }
+});
+
+describe("action-network", () => {
+ let veryFakeOrganization;
+
+ afterEach(async () => {
+ jest.restoreAllMocks();
+ });
+
+ beforeEach(async () => {
+ process.env[ActionNetwork.envVars.API_KEY] = "fake_api_key";
+
+ veryFakeOrganization = {
+ id: 3
+ };
+ });
+
+ describe("#getClientChoiceData", async () => {
+ let makeGetEventsNock;
+ let makeGetTagsNock;
+ let getEventsNock;
+ let getTagsNock;
+ let getEventsResponse;
+ let getTagsResponse;
+ let getClientChoiceDataResponse;
+
+ const allNocksDone = () => {
+ getEventsNock.done();
+ getTagsNock.done();
+ };
+
+ const makeAllNocks = ({
+ getEventsStatusCode,
+ eventsResponse,
+ getTagsStatusCode,
+ tagsResponse
+ }) => {
+ getEventsNock = makeGetEventsNock({
+ page: 1,
+ getEventsStatusCode,
+ eventsResponse
+ });
+
+ getTagsNock = makeGetTagsNock({
+ page: 1,
+ getTagsStatusCode,
+ tagsResponse
+ });
+ };
+
+ beforeEach(async () => {
+ makeGetEventsNock = ({ page, getEventsStatusCode, eventsResponse }) =>
+ nock("https://actionnetwork.org:443", {
+ encodedQueryParams: true
+ })
+ .get(`/api/v2/events?page=${page}`)
+ .reply(getEventsStatusCode, eventsResponse);
+
+ makeGetTagsNock = ({ page, getTagsStatusCode, tagsResponse }) =>
+ nock("https://actionnetwork.org:443", {
+ encodedQueryParams: true
+ })
+ .get(`/api/v2/tags?page=${page}`)
+ .reply(getTagsStatusCode, tagsResponse);
+ });
+
+ beforeEach(async () => {
+ getEventsResponse = {
+ total_pages: 1,
+ per_page: 25,
+ page: 1,
+ total_records: 1,
+ _embedded: {
+ "osdi:events": [
+ {
+ identifiers: [
+ "action_network:3e50f1a2-5470-4ae9-8719-1cb4f5de4d43"
+ ],
+ title: "Randi Weingarten's Birthday Party",
+ name: "RWBP",
+ start_date: moment()
+ .add(1, "days")
+ .utc()
+ .format()
+ }
+ ]
+ }
+ };
+
+ getTagsResponse = {
+ total_pages: 1,
+ per_page: 25,
+ page: 1,
+ total_records: 1,
+ _embedded: {
+ "osdi:tags": [
+ {
+ name: "release",
+ identifiers: [
+ "action_network:c1f68579-b36b-4d43-8312-204894af8731"
+ ]
+ },
+ {
+ name: "2019 Survey - Trainings",
+ identifiers: [
+ "action_network:eb24f4ec-2a3b-4ba4-b400-bc9997ca9ca5"
+ ]
+ }
+ ]
+ }
+ };
+
+ getClientChoiceDataResponse = [
+ {
+ name: "RSVP RWBP",
+ details: expect.stringifiedObjectEqualObject({
+ identifier: "3e50f1a2-5470-4ae9-8719-1cb4f5de4d43",
+ type: "event"
+ })
+ },
+ {
+ name: "TAG release",
+ details: expect.stringifiedObjectEqualObject({
+ tag: "release",
+ type: "tag"
+ })
+ },
+ {
+ name: "TAG 2019 Survey - Trainings",
+ details: expect.stringifiedObjectEqualObject({
+ tag: "2019 Survey - Trainings",
+ type: "tag"
+ })
+ }
+ ];
+
+ jest.spyOn(ActionNetwork, "setTimeoutPromise");
+ });
+
+ it("returns what we expect", async () => {
+ makeAllNocks({
+ getEventsStatusCode: 200,
+ eventsResponse: getEventsResponse,
+ getTagsStatusCode: 200,
+ tagsResponse: getTagsResponse
+ });
+
+ const clientChoiceData = await ActionNetwork.getClientChoiceData(
+ veryFakeOrganization
+ );
+
+ expect(ActionNetwork.setTimeoutPromise).not.toHaveBeenCalled();
+
+ const receivedEvents = JSON.parse(clientChoiceData.data).items;
+ expect(receivedEvents).toEqual(getClientChoiceDataResponse);
+
+ allNocksDone();
+ });
+
+ describe("when there is more than one page", () => {
+ let secondPageEventsResponse;
+ let secondPageTagsResponse;
+ beforeEach(async () => {
+ getEventsResponse = {
+ total_pages: 2,
+ per_page: 2,
+ page: 1,
+ total_records: 3,
+ _embedded: {
+ "osdi:events": [
+ {
+ identifiers: [
+ "action_network:3e50f1a2-5470-4ae9-8719-1cb4f5de4d43"
+ ],
+ title: "Randi Weingarten's Birthday Party",
+ name: "RWBP",
+ start_date: moment()
+ .add(1, "days")
+ .utc()
+ .format()
+ },
+ {
+ identifiers: [
+ "action_network:c3d9aed7-46b2-4a5c-9f85-3df314271081"
+ ],
+ title: "Bonnie Castillo's Birthday Party",
+ name: "BCBP",
+ start_date: moment()
+ .add(2, "days")
+ .utc()
+ .format()
+ }
+ ]
+ }
+ };
+
+ getTagsResponse = {
+ total_pages: 2,
+ per_page: 2,
+ page: 1,
+ total_records: 3,
+ _embedded: {
+ "osdi:tags": [
+ {
+ name: "release",
+ identifiers: [
+ "action_network:c1f68579-b36b-4d43-8312-204894af8731"
+ ]
+ },
+ {
+ name: "2019 Survey - Trainings",
+ identifiers: [
+ "action_network:eb24f4ec-2a3b-4ba4-b400-bc9997ca9ca5"
+ ]
+ }
+ ]
+ }
+ };
+
+ secondPageEventsResponse = {
+ total_pages: 2,
+ per_page: 2,
+ page: 2,
+ total_records: 3,
+ _embedded: {
+ "osdi:events": [
+ {
+ identifiers: [
+ "action_network:40f96e48-1910-47c1-933e-feaeac754e8d"
+ ],
+ title: "Richard Trompka's Birthday Party",
+ name: "RTBP",
+ start_date: moment()
+ .add(3, "days")
+ .utc()
+ .format()
+ }
+ ]
+ }
+ };
+
+ secondPageTagsResponse = {
+ total_pages: 2,
+ per_page: 2,
+ page: 2,
+ total_records: 3,
+ _embedded: {
+ "osdi:tags": [
+ {
+ name: "2019 Survey - Federal/State Legislation",
+ identifiers: [
+ "action_network:c1f68579-b36b-4d43-8312-204894afaaaa"
+ ]
+ }
+ ]
+ }
+ };
+
+ getClientChoiceDataResponse = [
+ {
+ name: "RSVP RWBP",
+ details: expect.stringifiedObjectEqualObject({
+ identifier: "3e50f1a2-5470-4ae9-8719-1cb4f5de4d43",
+ type: "event"
+ })
+ },
+ {
+ name: "RSVP BCBP",
+ details: expect.stringifiedObjectEqualObject({
+ identifier: "c3d9aed7-46b2-4a5c-9f85-3df314271081",
+ type: "event"
+ })
+ },
+ {
+ name: "RSVP RTBP",
+ details: expect.stringifiedObjectEqualObject({
+ identifier: "40f96e48-1910-47c1-933e-feaeac754e8d",
+ type: "event"
+ })
+ },
+ {
+ name: "TAG release",
+ details: expect.stringifiedObjectEqualObject({
+ tag: "release",
+ type: "tag"
+ })
+ },
+ {
+ name: "TAG 2019 Survey - Trainings",
+ details: expect.stringifiedObjectEqualObject({
+ tag: "2019 Survey - Trainings",
+ type: "tag"
+ })
+ },
+ {
+ name: "TAG 2019 Survey - Federal/State Legislation",
+ details: expect.stringifiedObjectEqualObject({
+ tag: "2019 Survey - Federal/State Legislation",
+ type: "tag"
+ })
+ }
+ ];
+ });
+
+ it("return the jawns from all the pages", async () => {
+ makeAllNocks({
+ getEventsStatusCode: 200,
+ eventsResponse: getEventsResponse,
+ getTagsStatusCode: 200,
+ tagsResponse: getTagsResponse
+ });
+
+ const eventsSecondPageNock = makeGetEventsNock({
+ page: 2,
+ statusCode: 200,
+ eventsResponse: secondPageEventsResponse
+ });
+
+ const tagsSecondPageNock = makeGetTagsNock({
+ page: 2,
+ statusCode: 200,
+ tagsResponse: secondPageTagsResponse
+ });
+
+ const clientChoiceData = await ActionNetwork.getClientChoiceData(
+ veryFakeOrganization
+ );
+
+ expect(ActionNetwork.setTimeoutPromise).not.toHaveBeenCalled();
+
+ const receivedEvents = JSON.parse(clientChoiceData.data).items;
+ expect(receivedEvents).toEqual(getClientChoiceDataResponse);
+
+ eventsSecondPageNock.done();
+ tagsSecondPageNock.done();
+ allNocksDone();
+ });
+
+ describe("when there is an error retrieving the first page of events", () => {
+ it("returns an error and doesn't make the call for the second page", async () => {
+ makeAllNocks({
+ getEventsStatusCode: 500,
+ eventsResponse: {},
+ getTagsStatusCode: 200,
+ tagsResponse: getTagsResponse
+ });
+
+ const eventsSecondPageNock = makeGetEventsNock({
+ page: 2,
+ statusCode: 200,
+ eventsResponse: secondPageEventsResponse
+ });
+
+ const response = await ActionNetwork.getClientChoiceData(
+ veryFakeOrganization
+ );
+
+ expect(ActionNetwork.setTimeoutPromise).not.toHaveBeenCalled();
+
+ expect(response).toEqual({
+ data: expect.stringifiedObjectEqualObject({
+ error: "Failed to load choices from ActionNetwork"
+ })
+ });
+
+ expect(eventsSecondPageNock.isDone()).toEqual(false);
+ allNocksDone();
+ nock.cleanAll();
+ });
+ });
+
+ describe("when there is an error retrieving the first page of tags", () => {
+ it("returns an error and doesn't make the call for the second page", async () => {
+ makeAllNocks({
+ getEventsStatusCode: 200,
+ eventsResponse: getEventsResponse,
+ getTagsStatusCode: 500,
+ tagsResponse: {}
+ });
+
+ const eventsSecondPageNock = makeGetEventsNock({
+ page: 2,
+ statusCode: 200,
+ eventsResponse: secondPageEventsResponse
+ });
+
+ const tagsSecondPageNock = makeGetTagsNock({
+ page: 2,
+ statusCode: 200,
+ eventsResponse: secondPageEventsResponse
+ });
+
+ const response = await ActionNetwork.getClientChoiceData(
+ veryFakeOrganization
+ );
+
+ expect(ActionNetwork.setTimeoutPromise).not.toHaveBeenCalled();
+
+ expect(response).toEqual({
+ data: expect.stringifiedObjectEqualObject({
+ error: "Failed to load choices from ActionNetwork"
+ })
+ });
+
+ expect(tagsSecondPageNock.isDone()).toEqual(false);
+ expect(eventsSecondPageNock.isDone()).toEqual(false);
+ allNocksDone();
+ nock.cleanAll();
+ });
+ });
+
+ describe("when there is an error retrieving the second page of events", () => {
+ it("returns an error", async () => {
+ makeAllNocks({
+ getEventsStatusCode: 200,
+ eventsResponse: getEventsResponse,
+ getTagsStatusCode: 200,
+ tagsResponse: getTagsResponse
+ });
+
+ const eventsSecondPageNock = makeGetEventsNock({
+ page: 2,
+ getEventsStatusCode: 500,
+ eventsResponse: {}
+ });
+
+ const tagsSecondPageNock = makeGetTagsNock({
+ page: 2,
+ statusCode: 200,
+ eventsResponse: secondPageEventsResponse
+ });
+
+ const response = await ActionNetwork.getClientChoiceData(
+ veryFakeOrganization
+ );
+
+ expect(ActionNetwork.setTimeoutPromise).not.toHaveBeenCalled();
+
+ expect(response).toEqual({
+ data: expect.stringifiedObjectEqualObject({
+ error: "Failed to load choices from ActionNetwork"
+ })
+ });
+
+ eventsSecondPageNock.done();
+ tagsSecondPageNock.done();
+ allNocksDone();
+ });
+ });
+
+ describe("when there is an error retrieving the second page of tags", () => {
+ it("returns an error", async () => {
+ makeAllNocks({
+ getEventsStatusCode: 200,
+ eventsResponse: getEventsResponse,
+ getTagsStatusCode: 200,
+ tagsResponse: getTagsResponse
+ });
+
+ const eventsSecondPageNock = makeGetEventsNock({
+ page: 2,
+ statusCode: 200,
+ eventsResponse: secondPageEventsResponse
+ });
+
+ const tagsSecondPageNock = makeGetTagsNock({
+ page: 2,
+ getTagsStatusCode: 500,
+ tagsResponse: {}
+ });
+
+ const response = await ActionNetwork.getClientChoiceData(
+ veryFakeOrganization
+ );
+
+ expect(ActionNetwork.setTimeoutPromise).not.toHaveBeenCalled();
+
+ expect(response).toEqual({
+ data: expect.stringifiedObjectEqualObject({
+ error: "Failed to load choices from ActionNetwork"
+ })
+ });
+
+ tagsSecondPageNock.done();
+ eventsSecondPageNock.done();
+ allNocksDone();
+ });
+ });
+
+ describe("when there are more than 4 total pages", () => {
+ let additionalTagsResponses = [];
+ beforeEach(async () => {
+ getTagsResponse.total_pages = 3;
+ getTagsResponse.perPage = 2;
+ getTagsResponse.total_records = 5;
+
+ // eslint-disable-next-line no-underscore-dangle
+ secondPageTagsResponse._embedded["osdi:tags"].push({
+ name: "2019 Survey - Worker Stories",
+ identifiers: ["action_network:c1f68579-b36b-4d43-8312-204894afbbbb"]
+ });
+
+ additionalTagsResponses = [
+ {
+ total_pages: 3,
+ per_page: 2,
+ page: 3,
+ total_records: 9,
+ _embedded: {
+ "osdi:tags": [
+ {
+ name: "page 3 tag 1"
+ }
+ ]
+ }
+ }
+ ];
+
+ getClientChoiceDataResponse.push(
+ ...[
+ {
+ details: '{"type":"tag","tag":"2019 Survey - Worker Stories"}',
+ name: "TAG 2019 Survey - Worker Stories"
+ },
+ {
+ details: '{"type":"tag","tag":"page 3 tag 1"}',
+ name: "TAG page 3 tag 1"
+ }
+ ]
+ );
+ });
+
+ it("return the jawns from all the pages", async () => {
+ makeAllNocks({
+ getEventsStatusCode: 200,
+ eventsResponse: getEventsResponse,
+ getTagsStatusCode: 200,
+ tagsResponse: getTagsResponse
+ });
+
+ const eventsSecondPageNock = makeGetEventsNock({
+ page: 2,
+ statusCode: 200,
+ eventsResponse: secondPageEventsResponse
+ });
+
+ const tagsSecondPageNock = makeGetTagsNock({
+ page: 2,
+ statusCode: 200,
+ tagsResponse: secondPageTagsResponse
+ });
+
+ const additionalPagesNocks = additionalTagsResponses.map(
+ (response, index) =>
+ makeGetTagsNock({
+ page: index + 3,
+ statusCode: 200,
+ tagsResponse: response
+ })
+ );
+ const clientChoiceData = await ActionNetwork.getClientChoiceData(
+ veryFakeOrganization
+ );
+
+ const receivedEvents = JSON.parse(clientChoiceData.data).items;
+
+ expect(ActionNetwork.setTimeoutPromise.mock.calls).toEqual([[1100]]);
+
+ expect(receivedEvents).toEqual(getClientChoiceDataResponse);
+
+ additionalPagesNocks.forEach(pageNock => {
+ pageNock.done();
+ });
+ eventsSecondPageNock.done();
+ tagsSecondPageNock.done();
+ allNocksDone();
+ });
+ });
+ describe("when there are more than 6 total pages", () => {
+ let additionalTagsResponses = [];
+ beforeEach(async () => {
+ getTagsResponse.total_pages = 5;
+ getTagsResponse.perPage = 2;
+ getTagsResponse.total_records = 9;
+
+ // eslint-disable-next-line no-underscore-dangle
+ secondPageTagsResponse._embedded["osdi:tags"].push({
+ name: "2019 Survey - Worker Stories",
+ identifiers: ["action_network:c1f68579-b36b-4d43-8312-204894afbbbb"]
+ });
+
+ additionalTagsResponses = [
+ {
+ total_pages: 4,
+ per_page: 2,
+ page: 3,
+ total_records: 9,
+ _embedded: {
+ "osdi:tags": [
+ {
+ name: "page 3 tag 1"
+ },
+ {
+ name: "page 3 tag 2"
+ }
+ ]
+ }
+ },
+ {
+ total_pages: 4,
+ per_page: 2,
+ page: 4,
+ total_records: 9,
+ _embedded: {
+ "osdi:tags": [
+ {
+ name: "page 4 tag 1"
+ },
+ {
+ name: "page 4 tag 2"
+ }
+ ]
+ }
+ },
+ {
+ total_pages: 4,
+ per_page: 2,
+ page: 5,
+ total_records: 9,
+ _embedded: {
+ "osdi:tags": [
+ {
+ name: "page 5 tag 1"
+ },
+ {
+ name: "page 5 tag 2"
+ }
+ ]
+ }
+ }
+ ];
+
+ getClientChoiceDataResponse.push(
+ ...[
+ {
+ details: '{"type":"tag","tag":"2019 Survey - Worker Stories"}',
+ name: "TAG 2019 Survey - Worker Stories"
+ },
+ {
+ details: '{"type":"tag","tag":"page 3 tag 1"}',
+ name: "TAG page 3 tag 1"
+ },
+ {
+ details: '{"type":"tag","tag":"page 3 tag 2"}',
+ name: "TAG page 3 tag 2"
+ },
+ {
+ details: '{"type":"tag","tag":"page 4 tag 1"}',
+ name: "TAG page 4 tag 1"
+ },
+ {
+ details: '{"type":"tag","tag":"page 4 tag 2"}',
+ name: "TAG page 4 tag 2"
+ },
+ {
+ details: '{"type":"tag","tag":"page 5 tag 1"}',
+ name: "TAG page 5 tag 1"
+ },
+ {
+ details: '{"type":"tag","tag":"page 5 tag 2"}',
+ name: "TAG page 5 tag 2"
+ }
+ ]
+ );
+ });
+
+ it("return the jawns from all the pages", async () => {
+ makeAllNocks({
+ getEventsStatusCode: 200,
+ eventsResponse: getEventsResponse,
+ getTagsStatusCode: 200,
+ tagsResponse: getTagsResponse
+ });
+
+ const eventsSecondPageNock = makeGetEventsNock({
+ page: 2,
+ statusCode: 200,
+ eventsResponse: secondPageEventsResponse
+ });
+
+ const tagsSecondPageNock = makeGetTagsNock({
+ page: 2,
+ statusCode: 200,
+ tagsResponse: secondPageTagsResponse
+ });
+
+ const additionalPagesNocks = additionalTagsResponses.map(
+ (response, index) =>
+ makeGetTagsNock({
+ page: index + 3,
+ statusCode: 200,
+ tagsResponse: response
+ })
+ );
+ const clientChoiceData = await ActionNetwork.getClientChoiceData(
+ veryFakeOrganization
+ );
+
+ const receivedEvents = JSON.parse(clientChoiceData.data).items;
+
+ expect(ActionNetwork.setTimeoutPromise.mock.calls).toEqual([
+ [1100],
+ [1100]
+ ]);
+
+ expect(receivedEvents).toEqual(getClientChoiceDataResponse);
+
+ additionalPagesNocks.forEach(pageNock => {
+ pageNock.done();
+ });
+ eventsSecondPageNock.done();
+ tagsSecondPageNock.done();
+ allNocksDone();
+ });
+ });
+ });
+
+ describe("when an event doesn't have a short name", () => {
+ beforeEach(async () => {
+ // eslint-disable-next-line no-underscore-dangle
+ getEventsResponse._embedded["osdi:events"][0].name = undefined;
+ getClientChoiceDataResponse[0].name =
+ "RSVP Randi Weingarten's Birthday Party";
+ });
+
+ it("uses title and returns what we expect", async () => {
+ makeAllNocks({
+ getEventsStatusCode: 200,
+ eventsResponse: getEventsResponse,
+ getTagsStatusCode: 200,
+ tagsResponse: getTagsResponse
+ });
+
+ const clientChoiceData = await ActionNetwork.getClientChoiceData(
+ veryFakeOrganization
+ );
+
+ expect(ActionNetwork.setTimeoutPromise).not.toHaveBeenCalled();
+
+ const receivedEvents = JSON.parse(clientChoiceData.data).items;
+ expect(receivedEvents).toEqual(getClientChoiceDataResponse);
+
+ allNocksDone();
+ });
+ });
+
+ describe("when there is an event in the past", () => {
+ beforeEach(async () => {
+ const pastEvent = {
+ identifiers: ["action_network:40f96e48-1910-47c1-933e-feaeac754e8d"],
+ title: "Richard Trompka's Birthday Party",
+ name: "RTBP",
+ start_date: moment()
+ .subtract(1, "days")
+ .utc()
+ .format()
+ };
+
+ // eslint-disable-next-line no-underscore-dangle
+ getEventsResponse._embedded["osdi:events"].push(pastEvent);
+ });
+
+ it("returns only future events", async () => {
+ makeAllNocks({
+ getEventsStatusCode: 200,
+ eventsResponse: getEventsResponse,
+ getTagsStatusCode: 200,
+ tagsResponse: getTagsResponse
+ });
+
+ const clientChoiceData = await ActionNetwork.getClientChoiceData(
+ veryFakeOrganization
+ );
+
+ expect(ActionNetwork.setTimeoutPromise).not.toHaveBeenCalled();
+
+ const receivedEvents = JSON.parse(clientChoiceData.data).items;
+ expect(receivedEvents).toEqual(getClientChoiceDataResponse);
+
+ allNocksDone();
+ });
+ });
+ });
+});
diff --git a/docs/HOWTO_IMPORT_GOOGLE_DOCS_SCRIPTS_TO_IMPORT.md b/docs/HOWTO_IMPORT_GOOGLE_DOCS_SCRIPTS_TO_IMPORT.md
index ce4afc408..a983aab32 100644
--- a/docs/HOWTO_IMPORT_GOOGLE_DOCS_SCRIPTS_TO_IMPORT.md
+++ b/docs/HOWTO_IMPORT_GOOGLE_DOCS_SCRIPTS_TO_IMPORT.md
@@ -70,7 +70,7 @@ It's a horrible idea to publish live secrets to Github. You should never do that
## Create script documents
-1. Create a script from a Google Doc Spoke script template - see for example https://docs.google.com/document/d/1zKRbDU9vwetJlfWYgSGd1taBEVj_QrI7bhu4snvHlmg/edit
+1. Create a script from a Google Doc Spoke script template - see for example https://docs.google.com/document/d/1gFji2Vh_0svb4j7VUtmJZkqNhRWt3htp_bdGoWh6S6w/edit
2. Copy the template, create a new draft doc with that and save it, then edit the new script doc, not the template :)
3. Share the script document with your API user.
- Go to the document in Google Docs.
diff --git a/docs/REFERENCE-environment_variables.md b/docs/REFERENCE-environment_variables.md
index 8e5b5aa26..4239bee49 100644
--- a/docs/REFERENCE-environment_variables.md
+++ b/docs/REFERENCE-environment_variables.md
@@ -31,6 +31,9 @@
| DEFAULT_ORG | Set only with FIX_ORGLESS. Set to integer organization.id corresponding to the organization you want orgless users to be assigned to. |
| DEFAULT_RESPONSEWINDOW | Default number of hours after when a campaign's contacts that need a response (after they reply) -- this is changeable per-campaign, but this sets the default. |
| DEV_APP_PORT | Port for development Webpack server. Required for development. |
+| DOWNTIME | When enabled it will redirect users to a /downtime page. If set to a string, it will show the string as a message to users on the downtime page. Use this to take the system down for maintenance. It will NOT stop graphql requests and will NOT stop users that are already in the app. |
+| DOWNTIME_NO_DB | On AWS Lambda this blocks the site from loading the app at all and swaps out a system that redirects users to /downtime. This is useful for DB maintenance. For non-Lambda environments, just run the src/server/downtime app instead of src/server/index default app |
+| DOWNTIME_TEXTER | Setting DOWNTIME_TEXTER to a text message (without quotes, please) will give the message as a text to texters when they arrive on the site, but the admin pages will still be accessible. This could be useful if you want to stop new texters from landing on the site and texting, while you debug things. |
| DST_REFERENCE_TIMEZONE | Timezone to use to determine whether DST is in effect. If it's DST in this timezone, we assume it's DST everywhere. _Default_: "America/New_York". (The default will work for any campaign in the US. For example, if the campaign is in Australia, use "Australia/Sydney" or some other timezone in Australia. Note that DST is opposite in the northern and souther hemispheres.) |
| EMAIL_FROM | Email from address. _Required to send email from either Mailgun **or** a custom SMTP server_. |
| EMAIL_HOST | Email server host. _Required for custom SMTP server usage_. |
@@ -53,7 +56,8 @@
| MAX_CONTACTS | If set each campaign can only have a maximum of the value (an integer). This is good for staging/QA/evaluation instances. _Default_: false (i.e. there is no maximum) |
| MAX_CONTACTS_PER_TEXTER | Maximum contacts that a texter can send to, per campaign. This is particularly useful for dynamic assignment. This must not be blank/empty and must be a number greater than 0. |
| MAX_MESSAGE_LENGTH | The maximum size for a message that a texter can send. When you send a SMS message over 160 characters the message will be split, so you might want to set this as 160 or less if you have a high SMS-only target demographic. _Default_: 99999 |
-| MAX_TEXTERS_PER_CAMPAIGN | Maximum texters that can join a campaign before joining with a dynamic assignment campaign link will block the texter from joining with a message that the campaign is full. |
+| MAX_TEXTERS_PER_CAMPAIGN | Maximum texters that can join a campaign before joining with a dynamic assignment campaign link will block the texter from joining with a message that the campaign is full. |
+| MULTI_TENANT | Set to true if instance can host more than one organization. |
| NEXMO_API_KEY | Nexmo API key. Required if using Nexmo. |
| NEXMO_API_SECRET | Nexmo API secret. Required if using Nexmo. |
| NGP_VAN_API_KEY | API key. Request an API key on the API Integrations section of VAN. Select `Hustle` API key in VAN. _Required_ for VAN integration_. |
diff --git a/lambda.js b/lambda.js
index e0b60e76d..77cc22f58 100644
--- a/lambda.js
+++ b/lambda.js
@@ -6,18 +6,24 @@ let app, server, jobs, dispatcher;
let invocationContext = {};
let invocationEvent = {};
-try {
- app = require("./build/server/server/index");
+if (process.env.DOWNTIME_NO_DB) {
+ app = require("./build/server/server/downtime");
server = awsServerlessExpress.createServer(app.default);
- jobs = require("./build/server/workers/job-processes");
- dispatcher = require("./build/server/extensions/job-runners/lambda-async/handler");
+ jobs = {};
+} else {
+ try {
+ app = require("./build/server/server/index");
+ server = awsServerlessExpress.createServer(app.default);
+ jobs = require("./build/server/workers/job-processes");
+ dispatcher = require("./build/server/extensions/job-runners/lambda-async/handler");
- app.default.set("awsContextGetter", function(req, res) {
- return [invocationEvent, invocationContext];
- });
-} catch (err) {
- if (!global.TEST_ENVIRONMENT) {
- console.error(`Unable to load built server: ${err}`);
+ app.default.set("awsContextGetter", function(req, res) {
+ return [invocationEvent, invocationContext];
+ });
+ } catch (err) {
+ if (!global.TEST_ENVIRONMENT) {
+ console.error(`Unable to load built server: ${err}`);
+ }
}
/*
app = require("./src/server/index");
@@ -101,27 +107,7 @@ exports.handler = async (event, context) => {
const job = jobs[event.command];
// behavior and arguments documented here:
// https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/Lambda.html#invoke-property
- const result = await 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) {
- if (err) {
- console.error("Failed to invoke Lambda job: ", err);
- }
- if (callback) {
- callback(err, dataReceived);
- }
- }
- );
- });
+ const result = await job(event, context);
return result;
} else {
console.error("Unfound command sent as a Lambda event: " + event.command);
diff --git a/package.json b/package.json
index 6c2f99af9..4fafc6bc7 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "spoke",
- "version": "9.0.0",
+ "version": "9.1.0",
"description": "Spoke",
"main": "src/server",
"engines": {
diff --git a/src/api/assignment.js b/src/api/assignment.js
index 6e5b3cf94..7176a0fbd 100644
--- a/src/api/assignment.js
+++ b/src/api/assignment.js
@@ -2,6 +2,7 @@ export const schema = `
input AssignmentsFilter {
texterId: Int
stats: Boolean
+ sender: Boolean
}
type Assignment {
id: ID
diff --git a/src/api/organization.js b/src/api/organization.js
index a1ce4d4e1..daea28c1e 100644
--- a/src/api/organization.js
+++ b/src/api/organization.js
@@ -57,6 +57,8 @@ export const schema = gql`
campaignsFilter: CampaignsFilter
sortBy: SortCampaignsBy
): CampaignsReturn
+ campaignsCount: Int
+ numTextsInLastDay: Int
people(role: String, campaignId: String, sortBy: SortPeopleBy): [User]
profileFields: [ProfileField]
optOuts: [OptOut]
diff --git a/src/api/user.js b/src/api/user.js
index 2cc0f7fa0..53ffa514f 100644
--- a/src/api/user.js
+++ b/src/api/user.js
@@ -7,6 +7,7 @@ export const schema = `
displayName: String
email: String
cell: String
+ is_superadmin: Boolean
extra: String
organizations(role: String): [Organization]
todos(organizationId: String): [Assignment]
diff --git a/src/components/AssignmentTexter/Demo.jsx b/src/components/AssignmentTexter/Demo.jsx
index 954cdfd27..418d6d937 100644
--- a/src/components/AssignmentTexter/Demo.jsx
+++ b/src/components/AssignmentTexter/Demo.jsx
@@ -570,7 +570,7 @@ export function generateDemoTexterContact(testName) {
enabledSideboxes={props.enabledSideboxes}
messageStatusFilter={test.messageStatusFilter}
disabled={test.disabled}
- onMessageFormSubmit={data => {
+ onMessageFormSubmit={() => async data => {
console.log("logging data onMessageFormSubmit", data);
props.onFinishContact(1);
diff --git a/src/components/CampaignContactsChoiceForm.jsx b/src/components/CampaignContactsChoiceForm.jsx
index 5d51a6564..0008b657e 100644
--- a/src/components/CampaignContactsChoiceForm.jsx
+++ b/src/components/CampaignContactsChoiceForm.jsx
@@ -16,6 +16,7 @@ import InfoIcon from "material-ui/svg-icons/action/info";
import theme from "../styles/theme";
import components from "../extensions/contact-loaders/components";
import yup from "yup";
+import { withRouter } from "react-router";
const check = ;
const warning = ;
@@ -39,7 +40,7 @@ const innerStyles = {
}
};
-export default class CampaignContactsChoiceForm extends React.Component {
+export class CampaignContactsChoiceForm extends React.Component {
state = {
uploading: false,
validationStats: null,
@@ -47,18 +48,21 @@ export default class CampaignContactsChoiceForm extends React.Component {
};
getCurrentMethod() {
- const { ingestMethodChoices, pastIngestMethod } = this.props;
+ const { ingestMethodChoices, pastIngestMethod, location } = this.props;
if (typeof this.state.ingestMethodIndex === "number") {
return ingestMethodChoices[this.state.ingestMethodIndex];
- } else if (pastIngestMethod && pastIngestMethod.name) {
- const index = ingestMethodChoices.findIndex(
- m => m.name === pastIngestMethod.name
- );
+ }
+ const name =
+ (pastIngestMethod && pastIngestMethod.name) ||
+ (location && location.query.contactLoader);
+ if (name) {
+ const index = ingestMethodChoices.findIndex(m => m.name === name);
if (index) {
// make sure it's available
return ingestMethodChoices[index];
}
}
+
return ingestMethodChoices[0];
}
@@ -163,6 +167,7 @@ CampaignContactsChoiceForm.propTypes = {
formValues: type.object,
ensureComplete: type.bool,
onSubmit: type.func,
+ location: type.object,
saveDisabled: type.bool,
saveLabel: type.string,
jobResultMessage: type.string,
@@ -170,3 +175,5 @@ CampaignContactsChoiceForm.propTypes = {
pastIngestMethod: type.object,
contactsCount: type.number
};
+
+export default withRouter(CampaignContactsChoiceForm);
diff --git a/src/components/Downtime.jsx b/src/components/Downtime.jsx
new file mode 100644
index 000000000..0cb755850
--- /dev/null
+++ b/src/components/Downtime.jsx
@@ -0,0 +1,46 @@
+import PropTypes from "prop-types";
+import React from "react";
+import theme from "../styles/theme";
+import { css } from "aphrodite";
+import { styles } from "../containers/Home";
+
+class Downtime extends React.Component {
+ render() {
+ return (
+
+
+
+
+
+ {window.DOWNTIME || window.DOWNTIME_TEXTER ? (
+
+ Spoke is not currently available.
+ {window.DOWNTIME &&
+ window.DOWNTIME != "1" &&
+ window.DOWNTIME != "true" ? (
+
{window.DOWNTIME}
+ ) : (
+ "Please talk to your campaign manager or system administrator."
+ )}
+ {window.DOWNTIME_TEXTER &&
+ window.DOWNTIME_TEXTER != "1" &&
+ window.DOWNTIME_TEXTER != "true" ? (
+
{window.DOWNTIME_TEXTER}
+ ) : null}
+
+ ) : (
+
+ This page is where Spoke users are brought to when the system is
+ set to DOWNTIME=true for maintenance, etc.
+
+ )}
+
+
+ );
+ }
+}
+
+export default Downtime;
diff --git a/src/components/IncomingMessageActions.jsx b/src/components/IncomingMessageActions.jsx
index 586aacdd2..e7c93d6e5 100644
--- a/src/components/IncomingMessageActions.jsx
+++ b/src/components/IncomingMessageActions.jsx
@@ -86,6 +86,11 @@ class IncomingMessageActions extends Component {
}
render() {
+ const showCreateCampaign =
+ this.props.conversationCount &&
+ document.location.search &&
+ /=/.test(document.location.search);
+
const texterNodes = !this.props.people
? []
: this.props.people.map(user => {
@@ -109,7 +114,6 @@ class IncomingMessageActions extends Component {
onClick={this.handleConfirmDialogReassign}
/>
];
-
return (
+
+
+ search senders (instead of assignments)
+
@@ -414,7 +426,8 @@ IncomingMessageFilter.propTypes = {
texters: type.array.isRequired,
onMessageFilterChanged: type.func.isRequired,
assignmentsFilter: type.shape({
- texterId: type.number
+ texterId: type.number,
+ sender: type.bool
}).isRequired,
onTagsFilterChanged: type.func.isRequired,
tags: type.arrayOf(type.object).isRequired,
diff --git a/src/components/IncomingMessageList/index.jsx b/src/components/IncomingMessageList/index.jsx
index 667863acc..1cf460b1a 100644
--- a/src/components/IncomingMessageList/index.jsx
+++ b/src/components/IncomingMessageList/index.jsx
@@ -18,10 +18,12 @@ export const prepareDataTableData = conversations =>
conversations.map(conversation => ({
campaignTitle: conversation.campaign.title,
texter:
- conversation.texter.displayName +
- (getHighestRole(conversation.texter.roles) === "SUSPENDED"
- ? " (Suspended)"
- : ""),
+ conversation.texter.id !== null
+ ? conversation.texter.displayName +
+ (getHighestRole(conversation.texter.roles) === "SUSPENDED"
+ ? " (Suspended)"
+ : "")
+ : "unassigned",
to:
conversation.contact.firstName +
" " +
diff --git a/src/containers/-- SQLite.sql b/src/containers/-- SQLite.sql
new file mode 100644
index 000000000..2da1953d4
--- /dev/null
+++ b/src/containers/-- SQLite.sql
@@ -0,0 +1,3 @@
+-- SQLite
+select * from user;
+select * from campaign_admin
\ No newline at end of file
diff --git a/src/containers/AdminCampaignEdit.jsx b/src/containers/AdminCampaignEdit.jsx
index a9d2ed9f9..8c43decc0 100644
--- a/src/containers/AdminCampaignEdit.jsx
+++ b/src/containers/AdminCampaignEdit.jsx
@@ -24,7 +24,8 @@ import CampaignTexterUIForm from "../components/CampaignTexterUIForm";
import CampaignPhoneNumbersForm from "../components/CampaignPhoneNumbersForm";
import { dataTest, camelCase } from "../lib/attributes";
import CampaignTextingHoursForm from "../components/CampaignTextingHoursForm";
-
+import { css } from "aphrodite";
+import { styles } from "./AdminCampaignStats";
import AdminScriptImport from "../containers/AdminScriptImport";
import { makeTree } from "../lib";
@@ -650,6 +651,8 @@ export class AdminCampaignEdit extends React.Component {
job => job.jobType === "start_campaign_with_phone_numbers"
)[0];
const isStarting = startJob || this.state.startingCampaign;
+ const organizationId = this.props.params.organizationId;
+ const campaign = this.props.campaignData.campaign;
const notStarting = this.props.campaignData.campaign.isStarted ? (
- {this.props.campaignData.campaign.title && (
-
{this.props.campaignData.campaign.title}
- )}
-
- {isStarting ? (
-
-
+ {campaign.title && (
+
+ {campaign.title}
+ {isStarting ? (
+
- Starting your campaign...
+ >
+
+
+ Starting your campaign...
+
+
+ ) : (
+ notStarting
+ )}
+
+ )}
+ {campaign.isStarted ? (
+
+
+
+
+
+ this.props.router.push(
+ `/admin/${organizationId}/campaigns/${campaign.id}`
+ )
+ }
+ label="Stats"
+ />
+
+ this.props.router.push(
+ `/admin/${organizationId}/incoming?campaigns=${campaign.id}`
+ )
+ }
+ label="Convos"
+ />
+
+
- ) : (
- notStarting
- )}
-
+
+ ) : null}
);
}
diff --git a/src/containers/AdminCampaignStats.jsx b/src/containers/AdminCampaignStats.jsx
index 5fad1fd83..967275573 100644
--- a/src/containers/AdminCampaignStats.jsx
+++ b/src/containers/AdminCampaignStats.jsx
@@ -33,7 +33,7 @@ const inlineStyles = {
}
};
-const styles = StyleSheet.create({
+export const styles = StyleSheet.create({
container: {
...theme.layouts.multiColumn.container,
marginBottom: 40,
diff --git a/src/containers/AdminIncomingMessageList.jsx b/src/containers/AdminIncomingMessageList.jsx
index c29fa2e98..4d7e2a8a2 100644
--- a/src/containers/AdminIncomingMessageList.jsx
+++ b/src/containers/AdminIncomingMessageList.jsx
@@ -13,125 +13,34 @@ import loadData from "./hoc/load-data";
import { withRouter } from "react-router";
import PaginatedUsersRetriever from "./PaginatedUsersRetriever";
import * as queryString from "query-string";
-
-function getCampaignsFilterForCampaignArchiveStatus(
- includeActiveCampaigns,
- includeArchivedCampaigns
-) {
- let isArchived = undefined;
- if (!includeActiveCampaigns && includeArchivedCampaigns) {
- isArchived = true;
- } else if (
- (includeActiveCampaigns && !includeArchivedCampaigns) ||
- (!includeActiveCampaigns && !includeArchivedCampaigns)
- ) {
- isArchived = false;
- }
-
- if (isArchived !== undefined) {
- return { isArchived };
- }
-
- return {};
-}
-
-function getContactsFilterForConversationOptOutStatus(
- includeNotOptedOutConversations,
- includeOptedOutConversations
-) {
- let isOptedOut = undefined;
- if (!includeNotOptedOutConversations && includeOptedOutConversations) {
- isOptedOut = true;
- } else if (
- (includeNotOptedOutConversations && !includeOptedOutConversations) ||
- (!includeNotOptedOutConversations && !includeOptedOutConversations)
- ) {
- isOptedOut = false;
- }
-
- if (isOptedOut !== undefined) {
- return { isOptedOut };
- }
-
- return {};
-}
+import {
+ getConversationFiltersFromQuery,
+ tagsFilterStateFromTagsFilter,
+ getCampaignsFilterForCampaignArchiveStatus,
+ getContactsFilterForConversationOptOutStatus
+} from "../lib";
export class AdminIncomingMessageList extends Component {
- static tagsFilterStateFromTagsFilter = tagsFilter => {
- let newTagsFilter = null;
- if (tagsFilter.anyTag) {
- newTagsFilter = ["*"];
- } else if (tagsFilter.noTag) {
- newTagsFilter = [];
- } else if (!tagsFilter.ignoreTags) {
- newTagsFilter = Object.values(tagsFilter.selectedTags).map(
- tagFilter => tagFilter.id
- );
- }
- return newTagsFilter;
- };
-
constructor(props) {
super(props);
const query = props.location.query;
- console.log("constructor");
+ const filters = getConversationFiltersFromQuery(
+ props.location.query,
+ props.organization.organization.tags
+ );
this.state = {
page: 0,
pageSize: 10,
- campaignsFilter: { isArchived: false },
- contactsFilter: { isOptedOut: false },
- messageTextFilter: query.messageText ? query.messageText : "",
- assignmentsFilter: query.texterId
- ? { texterId: Number(query.texterId) }
- : {},
needsRender: false,
utc: Date.now().toString(),
campaigns: [],
reassignmentTexters: [],
campaignTexters: [],
- includeArchivedCampaigns: query.archived
- ? Boolean(parseInt(query.archived))
- : false,
conversationCount: 0,
- includeActiveCampaigns: query.active
- ? Boolean(parseInt(query.active))
- : true,
- includeNotOptedOutConversations: query.notOptedOut
- ? Boolean(parseInt(query.notOptedOut))
- : true,
- includeOptedOutConversations: query.optedOut
- ? Boolean(parseInt(query.optedOut))
- : false,
clearSelectedMessages: false,
- tagsFilter: { ignoreTags: true }
+ ...filters
};
- if (query.campaigns) {
- this.state.campaignsFilter.campaignIds = query.campaigns.split(",");
- }
- if (query.messageStatus) {
- this.state.contactsFilter.messageStatus = query.messageStatus;
- }
- if (query.errorCode) {
- this.state.contactsFilter.errorCode = query.errorCode.split(",");
- }
- if (query.tags) {
- if (/^[a-z]/.test(query.tags)) {
- this.state.tagsFilter = { [query.tags]: true };
- } else {
- const selectedTags = {};
- query.tags.split(",").forEach(t => {
- selectedTags[t] = props.organization.organization.tags.find(
- ot => ot.id === t
- );
- });
- this.state.tagsFilter = { selectedTags };
- }
- }
- const newTagsFilter = AdminIncomingMessageList.tagsFilterStateFromTagsFilter(
- this.state.tagsFilter
- );
- this.state.contactsFilter.tags = newTagsFilter;
}
shouldComponentUpdate = (dummy, nextState) => {
@@ -165,7 +74,11 @@ export class AdminIncomingMessageList extends Component {
}
if (nextState.assignmentsFilter.texterId) {
query.texterId = nextState.assignmentsFilter.texterId;
+ if (nextState.assignmentsFilter.sender) {
+ query.sender = "1";
+ }
}
+
if (
nextState.campaignsFilter.campaignIds &&
nextState.campaignsFilter.campaignIds.length
@@ -229,10 +142,16 @@ export class AdminIncomingMessageList extends Component {
});
};
- handleTexterChanged = async texterId => {
- const assignmentsFilter = {};
- if (texterId >= 0) {
- assignmentsFilter.texterId = texterId;
+ handleTexterChanged = async (texterId, sender) => {
+ const assignmentsFilter = { ...this.state.assignmentsFilter };
+ if (sender !== undefined) {
+ assignmentsFilter.sender = sender;
+ } else {
+ if (texterId >= 0 || texterId === -2) {
+ assignmentsFilter.texterId = texterId;
+ } else {
+ delete assignmentsFilter.texterId;
+ }
}
await this.setState({
assignmentsFilter,
@@ -444,9 +363,7 @@ export class AdminIncomingMessageList extends Component {
};
handleTagsFilterChanged = tagsFilter => {
- const newTagsFilter = AdminIncomingMessageList.tagsFilterStateFromTagsFilter(
- tagsFilter
- );
+ const newTagsFilter = tagsFilterStateFromTagsFilter(tagsFilter);
const contactsFilter = {
...this.state.contactsFilter,
diff --git a/src/containers/AdminOrganizationsDashboard.jsx b/src/containers/AdminOrganizationsDashboard.jsx
new file mode 100644
index 000000000..bb8a44891
--- /dev/null
+++ b/src/containers/AdminOrganizationsDashboard.jsx
@@ -0,0 +1,219 @@
+import PropTypes from "prop-types";
+import React from "react";
+import TopNav from "../components/TopNav";
+import gql from "graphql-tag";
+import loadData from "./hoc/load-data";
+import { withRouter, Link } from "react-router";
+import ContentAdd from "material-ui/svg-icons/content/add";
+import DataTables from "material-ui-datatables";
+import FloatingActionButton from "material-ui/FloatingActionButton";
+
+import { StyleSheet, css } from "aphrodite";
+import theme from "../styles/theme";
+
+const styles = StyleSheet.create({
+ fieldContainer: {
+ background: theme.colors.white,
+ padding: "15px",
+ width: "256px"
+ },
+ loginPage: {
+ display: "flex",
+ "justify-content": "center",
+ "align-items": "flex-start",
+ height: "100vh",
+ "padding-top": "10vh",
+ background: theme.colors.veryLightGray
+ },
+ button: {
+ border: "none",
+ background: theme.colors.lightGray,
+ color: theme.colors.darkGreen,
+ padding: "16px 16px",
+ "font-size": "14px",
+ "text-transform": "uppercase",
+ cursor: "pointer",
+ width: "50%",
+ transition: "all 0.3s",
+ ":disabled": {
+ background: theme.colors.white,
+ cursor: "default",
+ color: theme.colors.green
+ }
+ },
+ header: {
+ ...theme.text.header,
+ color: theme.colors.coreBackgroundColor,
+ "text-align": "center",
+ "margin-bottom": 0
+ }
+});
+
+class AdminOrganizationsDashboard extends React.Component {
+ componentDidMount = () => {
+ const {
+ location: {
+ query: { nextUrl }
+ }
+ } = this.props;
+ };
+
+ handleCreateOrgClick = async e => {
+ e.preventDefault();
+ const newInvite = await this.props.mutations.createInvite({
+ is_valid: true
+ });
+ if (newInvite.errors) {
+ alert("There was an error creating your invite");
+ throw new Error(newInvite.errors);
+ } else {
+ this.props.router.push(
+ `/addOrganization/${newInvite.data.createInvite.hash}`
+ );
+ }
+ };
+
+ sortFunc(key) {
+ const sorts = {
+ id: (a, b) => b.id - a.id,
+ name: (a, b) => (b.name > a.name ? 1 : -1),
+ campaignsCount: (a, b) => b.id - a.id,
+ numTextsInLastDay: (a, b) => b.id - a.id
+ };
+ return sorts[key];
+ }
+
+ renderActionButton() {
+ return (
+
+
+
+ );
+ }
+
+ render() {
+ // Note: when adding new columns, make sure to update the sortFunc to include that column
+ var columns = [
+ {
+ key: "id",
+ label: "id",
+ sortable: true,
+ style: {
+ width: "5em"
+ }
+ },
+ {
+ key: "name",
+ label: "Name",
+ sortable: true,
+ style: {
+ width: "5em"
+ },
+ render: (columnKey, organizations) => {
+ return (
+
+
+ {columnKey}
+
+
+ );
+ }
+ },
+ // note that 'active' is defined as 'not archived'.
+ // campaigns that have not yet started are included here.
+ // is this what we want?
+ {
+ key: "campaignsCount",
+ label: "Number of Active Campaigns",
+ sortable: true,
+ style: {
+ width: "5em"
+ }
+ }
+ ];
+
+ if (!this.props.userData.currentUser.is_superadmin) {
+ return (
+
You do not have access to the Manage Organizations page.
+ );
+ }
+
+ return (
+
+
+
+ {
+ this.props.data.organizations.sort(this.sortFunc(key));
+ if (direction === "desc") {
+ this.props.data.organizations.reverse();
+ }
+ }}
+ />
+
+ {this.renderActionButton()}
+
+ );
+ }
+}
+
+const mutations = {
+ createInvite: ownProps => invite => ({
+ mutation: gql`
+ mutation createInvite($invite: InviteInput!) {
+ createInvite(invite: $invite) {
+ hash
+ }
+ }
+ `,
+ variables: { invite }
+ })
+};
+
+AdminOrganizationsDashboard.propTypes = {
+ location: PropTypes.object,
+ data: PropTypes.object,
+ router: PropTypes.object,
+ mutations: PropTypes.object,
+ userData: PropTypes.object
+};
+
+const queries = {
+ data: {
+ query: gql`
+ query getOrganizations {
+ organizations {
+ id
+ name
+ campaignsCount
+ }
+ }
+ `,
+ options: ownProps => ({
+ fetchPolicy: "network-only"
+ })
+ },
+ userData: {
+ query: gql`
+ query getCurrentUser {
+ currentUser {
+ id
+ is_superadmin
+ }
+ }
+ `,
+ options: () => ({
+ fetchPolicy: "network-only"
+ })
+ }
+};
+
+export default loadData({ queries, mutations })(
+ withRouter(AdminOrganizationsDashboard)
+);
diff --git a/src/containers/CreateAdditionalOrganization.jsx b/src/containers/CreateAdditionalOrganization.jsx
new file mode 100644
index 000000000..c9f971665
--- /dev/null
+++ b/src/containers/CreateAdditionalOrganization.jsx
@@ -0,0 +1,173 @@
+import PropTypes from "prop-types";
+import React from "react";
+import loadData from "./hoc/load-data";
+import gql from "graphql-tag";
+import Form from "react-formal";
+import yup from "yup";
+import { StyleSheet, css } from "aphrodite";
+import theme from "../styles/theme";
+import TopNav from "../components/TopNav";
+import Paper from "material-ui/Paper";
+import { withRouter } from "react-router";
+import GSForm from "../components/forms/GSForm";
+import { dataTest } from "../lib/attributes";
+
+const styles = StyleSheet.create({
+ container: {
+ textAlign: "center",
+ color: theme.colors.white
+ },
+ formContainer: {
+ ...theme.layouts.greenBox
+ },
+ header: {
+ ...theme.text.header,
+ marginRight: "auto",
+ marginLeft: "auto",
+ maxWidth: "80%"
+ },
+ form: {
+ marginTop: 40,
+ maxWidth: "80%",
+ marginRight: "auto",
+ marginLeft: "auto"
+ }
+});
+
+class CreateAdditionalOrganization extends React.Component {
+ formSchema = yup.object({
+ name: yup.string().required()
+ });
+ renderInvalid() {
+ return (
+
+ That invite is no longer valid. This probably means it already got used!
+
+ );
+ }
+
+ renderForm() {
+ return (
+
+
+ Create an additional organization below!
+
+
+
+ {
+ await this.props.mutations.createOrganization(
+ formValues.name,
+ this.props.userData.currentUser.id,
+ this.props.inviteData.inviteByHash[0].id
+ );
+ this.props.router.push(`/organizations`);
+ }}
+ >
+
+
+
+
+
+
+ );
+ }
+
+ render() {
+ if (!this.props.userData.currentUser.is_superadmin) {
+ return (
+
+ You must be a super admin to create an additional organization.
+
+ );
+ }
+ return (
+
+
+
+
+ {this.props.inviteData.inviteByHash &&
+ this.props.inviteData.inviteByHash[0].isValid
+ ? this.renderForm()
+ : this.renderInvalid()}
+
+
+
+ );
+ }
+}
+
+CreateAdditionalOrganization.propTypes = {
+ mutations: PropTypes.object,
+ router: PropTypes.object,
+ userData: PropTypes.object,
+ inviteData: PropTypes.object
+};
+
+const queries = {
+ inviteData: {
+ query: gql`
+ query getInvite($inviteId: String!) {
+ inviteByHash(hash: $inviteId) {
+ id
+ isValid
+ }
+ }
+ `,
+ options: ownProps => ({
+ variables: {
+ inviteId: ownProps.params.inviteId
+ },
+ fetchPolicy: "network-only"
+ })
+ },
+ userData: {
+ query: gql`
+ query getCurrentUser {
+ currentUser {
+ id
+ is_superadmin
+ }
+ }
+ `,
+ options: () => ({
+ fetchPolicy: "network-only"
+ })
+ }
+};
+
+const mutations = {
+ createOrganization: () => (name, userId, inviteId) => ({
+ mutation: gql`
+ mutation createOrganization(
+ $name: String!
+ $userId: String!
+ $inviteId: String!
+ ) {
+ createOrganization(name: $name, userId: $userId, inviteId: $inviteId) {
+ id
+ }
+ }
+ `,
+ variables: { name, userId, inviteId }
+ })
+};
+
+export default loadData({ queries, mutations })(
+ withRouter(CreateAdditionalOrganization)
+);
diff --git a/src/containers/Home.jsx b/src/containers/Home.jsx
index b0dca4667..f18ff7170 100644
--- a/src/containers/Home.jsx
+++ b/src/containers/Home.jsx
@@ -6,7 +6,7 @@ import { StyleSheet, css } from "aphrodite";
import theme from "../styles/theme";
import { withRouter } from "react-router";
-const styles = StyleSheet.create({
+export const styles = StyleSheet.create({
container: {
marginTop: "5vh",
textAlign: "center",
@@ -44,8 +44,8 @@ class Home extends React.Component {
componentWillMount() {
const user = this.props.data.currentUser;
if (user) {
- if (user.adminOrganizations.length > 0) {
- this.props.router.push(`/admin/${user.adminOrganizations[0].id}`);
+ if (user.organizations.length > 0) {
+ this.props.router.push(`/admin/${user.organizations[0].id}`);
} else if (user.ownerOrganizations.length > 0) {
this.props.router.push(`/admin/${user.ownerOrganizations[0].id}`);
} else if (user.texterOrganizations.length > 0) {
@@ -148,7 +148,7 @@ const queries = {
query getCurrentUser {
currentUser {
id
- adminOrganizations: organizations(role: "ADMIN") {
+ organizations: organizations(role: "ADMIN") {
id
}
superVolOrganizations: organizations(role: "SUPERVOLUNTEER") {
diff --git a/src/containers/Settings.jsx b/src/containers/Settings.jsx
index d4fba6abb..69560c890 100644
--- a/src/containers/Settings.jsx
+++ b/src/containers/Settings.jsx
@@ -339,6 +339,7 @@ class Settings extends React.Component {
{this.renderTextingHoursForm()}
{window.TWILIO_MULTI_ORG && this.renderTwilioAuthForm()}
{this.props.data.organization &&
+ this.props.data.organization.texterUIConfig &&
this.props.data.organization.texterUIConfig.sideboxChoices.length ? (
+ {userId ? User Id: {userId}
: null}
{
+ e.preventDefault();
+ this.props.router.push(`/organizations`);
+ };
+
renderAvatar(user, size) {
// Material-UI seems to not be handling this correctly when doing serverside rendering
const inlineStyles = {
@@ -87,13 +92,27 @@ export class UserMenu extends Component {
);
}
+ renderAdminTools() {
+ return (
+
+ );
+ }
+
render() {
const { currentUser } = this.props.data;
if (!currentUser) {
return
;
}
const organizations = currentUser.texterOrganizations;
-
+ const isSuperAdmin = currentUser.is_superadmin;
return (
+ {isSuperAdmin ? this.renderAdminTools() :
}
Teams
{organizations.map(organization => (
{
console.log("datawarehouse: finished running", job.id, job.campaign_id);
})
@@ -137,6 +138,7 @@ export async function processContactLoad(job, maxContacts, organization) {
}
export async function loadContactsFromDataWarehouseFragment(job, jobEvent) {
+ /// run to load a portion of the total
console.log(
"starting loadContactsFromDataWarehouseFragment",
jobEvent.campaignId,
@@ -144,6 +146,7 @@ export async function loadContactsFromDataWarehouseFragment(job, jobEvent) {
jobEvent.offset,
jobEvent
);
+ const jobMessages = [];
const insertOptions = {
batchSize: 1000
};
@@ -181,7 +184,15 @@ export async function loadContactsFromDataWarehouseFragment(job, jobEvent) {
// query failed
console.error("Data warehouse query failed: ", err);
jobMessages.push(`Data warehouse count query failed with ${err}`);
- // TODO: send feedback about job
+ await failedContactLoad(
+ job,
+ null,
+ { contactSql: sqlQuery },
+ {
+ errors: jobMessages
+ }
+ );
+ return;
}
const fields = {};
const customFields = {};
@@ -207,6 +218,14 @@ export async function loadContactsFromDataWarehouseFragment(job, jobEvent) {
jobMessages.push(
`SQL statement does not return first_name, last_name and cell => ${sqlQuery} => with fields ${fields}`
);
+ await failedContactLoad(
+ job,
+ null,
+ { contactSql: sqlQuery },
+ {
+ errors: jobMessages
+ }
+ );
return;
}
@@ -285,7 +304,14 @@ export async function loadContactsFromDataWarehouseFragment(job, jobEvent) {
validationStats.invalidCellCount = result;
});
}
- completeContactLoad(job);
+ await completeContactLoad(
+ job,
+ null,
+ { contactSql: jobEvent.query },
+ {
+ validationStats
+ }
+ );
return { completed: 1, validationStats };
} else if (jobEvent.part < jobEvent.totalParts - 1) {
const newPart = jobEvent.part + 1;
@@ -304,25 +330,36 @@ export async function loadContactsFromDataWarehouseFragment(job, jobEvent) {
await sendJobToAWSLambda(newJob);
return { invokedAgain: 1 };
} else {
- return loadContactsFromDataWarehouseFragment(job, newJob);
+ return await loadContactsFromDataWarehouseFragment(job, newJob);
}
}
}
export async function loadContactsFromDataWarehouse(job) {
- console.log("STARTING loadContactsFromDataWarehouse", job.payload);
+ console.log("STARTING loadContactsFromDataWarehouse", job.id, job.payload);
const jobMessages = [];
const sqlQuery = JSON.parse(job.payload).contactSql;
if (!sqlQuery.startsWith("SELECT") || sqlQuery.indexOf(";") >= 0) {
- console.error(
- "Malformed SQL statement. Must begin with SELECT and not have any semicolons: ",
- sqlQuery
+ jobMessages.push(
+ "Malformed SQL statement. Must begin with SELECT and not have any semicolons"
);
- return;
+ console.error(jobMessages.slice(-1)[0], sqlQuery);
}
if (!datawarehouse) {
- console.error("No data warehouse connection, so cannot load contacts", job);
+ jobMessages.push("No data warehouse connection, so cannot load contacts");
+ console.error(jobMessages.slice(-1)[0], job);
+ }
+
+ if (jobMessages.length) {
+ await failedContactLoad(
+ job,
+ null,
+ { contactSql: sqlQuery },
+ {
+ errors: jobMessages
+ }
+ );
return;
}
@@ -336,12 +373,30 @@ export async function loadContactsFromDataWarehouse(job) {
} catch (err) {
console.error("Data warehouse count query failed: ", err);
jobMessages.push(`Data warehouse count query failed with ${err}`);
+ await failedContactLoad(
+ job,
+ null,
+ { contactSql: sqlQuery },
+ {
+ errors: jobMessages
+ }
+ );
+ return;
}
if (knexCountRes) {
knexCount = knexCountRes.rows[0].count;
if (!knexCount || knexCount == 0) {
jobMessages.push("Error: Data warehouse query returned zero results");
+ await failedContactLoad(
+ job,
+ null,
+ { contactSql: sqlQuery },
+ {
+ errors: jobMessages
+ }
+ );
+ return;
}
}
@@ -360,14 +415,15 @@ export async function loadContactsFromDataWarehouse(job) {
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;
+ await failedContactLoad(
+ job,
+ null,
+ { contactSql: sqlQuery },
+ {
+ errors: jobMessages
+ }
+ );
+ return;
}
await r
@@ -376,6 +432,9 @@ export async function loadContactsFromDataWarehouse(job) {
.delete();
await loadContactsFromDataWarehouseFragment(job, {
+ id: job.id,
+ campaign_id: job.campaign_id,
+ job_type: "ingest.datawarehouse",
jobId: job.id,
query: sqlQuery,
campaignId: job.campaign_id,
diff --git a/src/extensions/contact-loaders/datawarehouse/react-component.js b/src/extensions/contact-loaders/datawarehouse/react-component.js
index 30393e45a..968b5f429 100644
--- a/src/extensions/contact-loaders/datawarehouse/react-component.js
+++ b/src/extensions/contact-loaders/datawarehouse/react-component.js
@@ -38,9 +38,18 @@ const styles = StyleSheet.create({
});
export class CampaignContactsForm extends React.Component {
- state = {
- formValues: null
- };
+ constructor(props) {
+ super(props);
+ const { lastResult } = props;
+ let cur = {};
+ if (lastResult && lastResult.reference) {
+ cur = JSON.parse(lastResult.reference);
+ }
+ console.log("datawarehouse", lastResult, props);
+ this.state = {
+ contactSql: cur.contactSql || ""
+ };
+ }
validateSql = () => {
const errors = [];
@@ -56,6 +65,11 @@ export class CampaignContactsForm extends React.Component {
"Spoke currently does not support LIMIT statements of higher than 10000 (no limit is fine, though)"
);
}
+ if (!/ORDER BY/i.test(sql)) {
+ errors.push(
+ "An ORDER BY statement is required to ensure loading all the contacts."
+ );
+ }
const requiredFields = ["first_name", "last_name", "cell"];
requiredFields.forEach(f => {
if (sql.indexOf(f) === -1) {
@@ -82,20 +96,34 @@ export class CampaignContactsForm extends React.Component {
render() {
const { contactSqlError } = this.state;
+ const { lastResult } = this.props;
+ let results = {};
+ if (lastResult && lastResult.result) {
+ results = JSON.parse(lastResult.result);
+ }
return (
- {!this.props.jobResultMessage ? (
- ""
- ) : (
+ {results.errors ? (
-
-
{this.props.jobResultMessage}
+
Previous Errors
+
+ {results.errors.map(e => (
+
+ ))}
+
+ ) : (
+ ""
)}
{
// sets values locally
this.setState({ ...formValues });
@@ -110,6 +138,10 @@ export class CampaignContactsForm extends React.Component {
in contacts. The SQL requires some constraints:
Start the query with "SELECT"
+
+ Finish with a required "ORDER BY" -- if there is not a
+ reliable ordering then not all contacts may load.
+
Do not include a trailing (or any) semicolon
Three columns are necessary:
@@ -130,6 +162,10 @@ export class CampaignContactsForm extends React.Component {
sometimes.
Other columns will be added to the customFields
+
+ During processing %’s are not percentage complete, but
+ every 10K contacts
+
1) {
+ function intersection(o1, o2) {
+ const res = o1.filter(a => o2.indexOf(a) !== -1);
+ console.log("intersection", o1, o2, res);
+ return res;
+ }
+ const firstFields = Object.keys(
+ JSON.parse(extraFields[0].custom_fields_example)
+ );
+ minExtraFields = extraFields
+ .map(o => Object.keys(JSON.parse(o.custom_fields_example)))
+ .reduce(intersection, firstFields);
+ console.log("extraFieldsScrubbing", extraFields, "min", minExtraFields);
+ if (minExtraFields.length !== firstFields.length) {
+ extraFieldsNeedsScrubbing = true;
+ }
+ }
+ query.whereIn(
+ "campaign_contact.id",
+ ccIdQuery.query.clearSelect().select("campaign_contact.id")
+ );
+ }
+
+ if (contactData.questionResponseAnswer) {
+ query
+ .join(
+ "question_response",
+ "question_response.campaign_contact_id",
+ "campaign_contact.id"
+ )
+ .where({
+ value: contactData.questionResponseAnswer
+ });
+ }
+
+ await r
+ .knex("campaign_contact")
+ .where("campaign_id", targetCampaignId)
+ .delete();
+
+ const copyColumns = [
+ "external_id",
+ "first_name",
+ "last_name",
+ "cell",
+ "zip",
+ "custom_fields",
+ "timezone_offset"
+ ];
+
+ // Based on https://github.com/knex/knex/issues/1323#issuecomment-331274931
+ query = query.select(
+ r.knex.raw("? AS ??", [targetCampaignId, "campaign_id"]),
+ r.knex.raw("? AS ??", ["needsMessage", "message_status"]),
+ ...copyColumns
+ );
+ const result = await r
+ .knex(
+ r.knex.raw("?? (??, ??, ??, ??, ??, ??, ??, ??, ??)", [
+ "campaign_contact",
+ "campaign_id",
+ "message_status",
+ ...copyColumns
+ ])
+ )
+ .insert(query);
+
+ await completeContactLoad(
+ job,
+ null,
+ JSON.stringify(contactData),
+ String(result.rowCount)
+ );
+
+ // This needs to be AFTER completeContactLoad
+ // because we need the first record AFTER, not before, records get scrubbed
+ if (extraFieldsNeedsScrubbing) {
+ const firstContact = await r
+ .knex("campaign_contact")
+ .select("id", "campaign_id", "custom_fields")
+ .where("campaign_id", targetCampaignId)
+ .orderBy("id")
+ .first();
+ if (firstContact) {
+ const firstContactFields = JSON.parse(firstContact.custom_fields);
+ const finalFields = {};
+ minExtraFields.forEach(f => {
+ finalFields[f] = firstContactFields[f];
+ });
+ await r
+ .knex("campaign_contact")
+ .where("id", firstContact.id)
+ .update({ custom_fields: JSON.stringify(finalFields) });
+ }
+ }
+}
diff --git a/src/extensions/contact-loaders/past-contacts/react-component.js b/src/extensions/contact-loaders/past-contacts/react-component.js
new file mode 100644
index 000000000..75bb2b884
--- /dev/null
+++ b/src/extensions/contact-loaders/past-contacts/react-component.js
@@ -0,0 +1,117 @@
+import type from "prop-types";
+import React from "react";
+import RaisedButton from "material-ui/RaisedButton";
+import GSForm from "../../../components/forms/GSForm";
+import Form from "react-formal";
+import Subheader from "material-ui/Subheader";
+import Divider from "material-ui/Divider";
+import { ListItem, List } from "material-ui/List";
+import CampaignFormSectionHeading from "../../../components/CampaignFormSectionHeading";
+import CheckIcon from "material-ui/svg-icons/action/check-circle";
+import WarningIcon from "material-ui/svg-icons/alert/warning";
+import ErrorIcon from "material-ui/svg-icons/alert/error";
+import { StyleSheet, css } from "aphrodite";
+import yup from "yup";
+import { withRouter } from "react-router";
+
+export class CampaignContactsFormInner extends React.Component {
+ constructor(props) {
+ super(props);
+ const { lastResult } = props;
+ let cur = {};
+ if (lastResult && lastResult.reference) {
+ cur = JSON.parse(lastResult.reference);
+ }
+ console.log("pastcontacts", lastResult, props);
+ this.state = {
+ pastContactsQuery:
+ props.location.query.pastContactsQuery || cur.pastContactsQuery || "",
+ questionResponseAnswer:
+ props.location.query.questionResponseAnswer ||
+ cur.questionResponseAnswer ||
+ ""
+ };
+ }
+
+ render() {
+ const { clientChoiceData, lastResult } = this.props;
+ let resultMessage = "";
+ return (
+ {
+ this.setState({ ...formValues });
+ this.props.onChange(JSON.stringify(formValues));
+ }}
+ onSubmit={formValues => {
+ // sets values locally
+ this.setState({ ...formValues });
+ // triggers the parent to update values
+ this.props.onChange(JSON.stringify(formValues));
+ // and now do whatever happens when clicking 'Next'
+ this.props.onSubmit();
+ }}
+ >
+
+ Copy the url or the query string from a Message Review filter. Note
+ that if you load contacts across campaigns, the custom fields will be
+ reduced to those that all contacts have in common.
+
+
+
+ You can narrow the result further with the exact text of the{" "}
+ Answer for a question response
+
+
+
+
+
+ {resultMessage ? (
+
+ ) : null}
+
+
+
+
+ );
+ }
+}
+
+CampaignContactsFormInner.propTypes = {
+ onChange: type.func,
+ onSubmit: type.func,
+ campaignIsStarted: type.bool,
+
+ icons: type.object,
+
+ saveDisabled: type.bool,
+ saveLabel: type.string,
+
+ clientChoiceData: type.string,
+ jobResultMessage: type.string,
+ lastResult: type.object,
+ location: type.object
+};
+
+export const CampaignContactsForm = withRouter(CampaignContactsFormInner);
diff --git a/src/extensions/contact-loaders/test-fakedata/index.js b/src/extensions/contact-loaders/test-fakedata/index.js
index 7b67973f7..0cf733823 100644
--- a/src/extensions/contact-loaders/test-fakedata/index.js
+++ b/src/extensions/contact-loaders/test-fakedata/index.js
@@ -118,6 +118,12 @@ export async function processContactLoad(job, maxContacts, organization) {
maxContacts ? maxContacts : areaCodes.length * 100,
areaCodes.length * 100
);
+ function genCustomFields(i, campaignId) {
+ return JSON.stringify({
+ campaignIndex: String(i),
+ [`custom${campaignId}`]: String(Math.random()).slice(3, 8)
+ });
+ }
const newContacts = [];
for (let i = 0; i < contactCount; i++) {
const ac = areaCodes[parseInt(i / 100, 10)];
@@ -129,7 +135,7 @@ export async function processContactLoad(job, maxContacts, organization) {
// https://www.businessinsider.com/555-phone-number-tv-movies-telephone-exchange-names-ghostbusters-2018-3
cell: `+1${ac}555${suffix}`,
zip: "10011",
- custom_fields: "{}",
+ custom_fields: genCustomFields(i, campaignId),
message_status: "needsMessage",
campaign_id: campaignId
});
diff --git a/src/integrations/action-handlers/action-network.js b/src/integrations/action-handlers/action-network.js
new file mode 100644
index 000000000..af505afdc
--- /dev/null
+++ b/src/integrations/action-handlers/action-network.js
@@ -0,0 +1,310 @@
+/* eslint no-console: 0 */
+/* eslint no-underscore-dangle: 0 */
+import moment from "moment";
+import util from "util";
+import { getConfig } from "../../server/api/lib/config";
+
+import httpRequest from "../../server/lib/http-request.js";
+
+export const setTimeoutPromise = util.promisify(setTimeout);
+
+export const name = "action-network";
+
+// What the user sees as the option
+export const displayName = () => "Action Network";
+
+// The Help text for the user after selecting the action
+export const instructions = () =>
+ `
+ This action is for reporting the results of interactions with contacts to Action Network
+ `;
+
+export const envVars = Object.freeze({
+ API_KEY: "ACTION_NETWORK_API_KEY",
+ DOMAIN: "ACTION_NETWORK_API_DOMAIN",
+ BASE_URL: "ACTION_NETWORK_API_BASE_URL",
+ CACHE_TTL: "ACTION_NETWORK_ACTION_HANDLER_CACHE_TTL"
+});
+
+export const defaults = Object.freeze({
+ DOMAIN: "https://actionnetwork.org",
+ BASE_URL: "/api/v2",
+ CACHE_TTL: 1800
+});
+
+export const makeUrl = url =>
+ `${getConfig(envVars.DOMAIN) || defaults.DOMAIN}${getConfig(
+ envVars.BASE_URL
+ ) || defaults.BASE_URL}/${url}`;
+
+export const makeAuthHeader = organization => ({
+ "OSDI-API-Token": getConfig(envVars.API_KEY, organization)
+});
+
+export function serverAdministratorInstructions() {
+ return {
+ description:
+ "This action is for reporting the results of interactions with contacts to Action Network",
+ setupInstructions:
+ "Get an API key for your Action Network account. Add it to your config. In most cases the defaults for the other environment variables will work",
+ environmentVariables: envVars.values
+ };
+}
+
+export function clientChoiceDataCacheKey(organization) {
+ return `${organization.id}`;
+}
+
+const handlers = {
+ event: (email, actionData) => {
+ const identifier = actionData.identifier;
+ if (!identifier) {
+ throw new Error("Missing identifier for event");
+ }
+
+ return {
+ path: `events/${identifier}/attendances`,
+ body: {
+ person: { email_addresses: [{ address: `${email}` }] },
+ triggers: {
+ autoresponse: {
+ enabled: 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(
+ unusedQuestionResponse,
+ interactionStep,
+ unusedCampaignContactId,
+ contact,
+ unusedCampaign,
+ organization
+) {
+ try {
+ const email = JSON.parse(contact.custom_fields || "{}").email;
+ if (!email) {
+ throw new Error("Missing contact email");
+ }
+
+ const actionData = JSON.parse(
+ JSON.parse(interactionStep.answer_actions_data || "{}").value || "{}"
+ );
+ if (!actionData) {
+ throw new Error("Missing action data");
+ }
+
+ const actionType = actionData.type;
+ if (!actionType) {
+ throw new Error("Missing action data");
+ }
+
+ const postDetails = handlers[actionType](email, actionData);
+ if (!postDetails) {
+ throw new Error(`No handler for action type ${actionType}`);
+ }
+
+ const { path, body } = postDetails;
+
+ if (!path || !body) {
+ throw new Error(
+ `Handler for action type ${actionType} missing path or body ${postDetails}`
+ );
+ }
+
+ console.info(
+ "Sending updagte to Action Network",
+ JSON.stringify(postDetails)
+ );
+
+ const url = makeUrl(path);
+ await httpRequest(url, {
+ method: "POST",
+ timeout: 30000,
+ headers: {
+ ...makeAuthHeader(organization),
+ "Content-Type": "application/json"
+ },
+ body: JSON.stringify(body)
+ });
+ } catch (caught) {
+ console.error(
+ "Encountered exception in action-network.processAction",
+ caught
+ );
+ throw caught;
+ }
+}
+
+const getPage = async (item, page, organization) => {
+ const url = makeUrl(`${item}?page=${page}`);
+ try {
+ const pageResponse = await httpRequest(url, {
+ method: "GET",
+ headers: {
+ ...makeAuthHeader(organization)
+ }
+ })
+ .then(async response => await response.json())
+ .catch(error => {
+ const message = `Error retrieving ${item} from ActionNetwork ${error}`;
+ console.error(message);
+ throw new Error(message);
+ });
+
+ return {
+ item,
+ page,
+ pageResponse
+ };
+ } catch (caughtError) {
+ console.error(
+ `Error loading ${item} page ${page} from ActionNetwork ${caughtError}`
+ );
+ throw caughtError;
+ }
+};
+
+const extractReceived = (item, responses) => {
+ const toReturn = [];
+ responses[item].forEach(response => {
+ toReturn.push(...((response._embedded || [])[`osdi:${item}`] || []));
+ });
+ return toReturn;
+};
+
+export async function getClientChoiceData(organization) {
+ const responses = {
+ tags: [],
+ events: []
+ };
+
+ try {
+ const firstPagePromises = [
+ getPage("events", 1, organization),
+ getPage("tags", 1, organization)
+ ];
+
+ const [firstEventsResponse, firstTagsResponse] = await Promise.all(
+ firstPagePromises
+ );
+
+ responses.events.push(firstEventsResponse.pageResponse);
+ responses.tags.push(firstTagsResponse.pageResponse);
+
+ const pagesNeeded = {
+ events: firstEventsResponse.pageResponse.total_pages,
+ tags: firstTagsResponse.pageResponse.total_pages
+ };
+
+ const pageToDo = [];
+
+ Object.entries(pagesNeeded).forEach(([item, pageCount]) => {
+ for (let i = 2; i <= pageCount; ++i) {
+ pageToDo.push([item, i, organization]);
+ }
+ });
+
+ const REQUESTS_PER_SECOND = 4;
+ const WAIT_MILLIS = 1100;
+ let pageToDoStart = 0;
+
+ while (pageToDoStart < pageToDo.length) {
+ if (pageToDo.length > REQUESTS_PER_SECOND - firstPagePromises.length) {
+ await exports.setTimeoutPromise(WAIT_MILLIS);
+ }
+
+ const pageToDoEnd = pageToDoStart + REQUESTS_PER_SECOND;
+ const thisTranche = pageToDo.slice(pageToDoStart, pageToDoEnd);
+
+ const pagePromises = thisTranche.map(thisPageToDo => {
+ return getPage(...thisPageToDo);
+ });
+
+ const pageResponses = await Promise.all(pagePromises);
+
+ pageResponses.forEach(pageResponse => {
+ responses[pageResponse.item].push(pageResponse.pageResponse);
+ });
+ pageToDoStart = pageToDoEnd;
+ }
+ } catch (caughtError) {
+ console.error(`Error loading choices from ActionNetwork ${caughtError}`);
+ return {
+ data: `${JSON.stringify({
+ error: "Failed to load choices from ActionNetwork"
+ })}`
+ };
+ }
+
+ const receivedEvents = [...extractReceived("events", responses)];
+ const receivedTags = [...extractReceived("tags", responses)];
+
+ const identifierRegex = /action_network:(.*)/;
+ const toReturn = [];
+
+ receivedEvents.forEach(event => {
+ let identifier;
+
+ if (moment(event.start_date) < moment()) {
+ return;
+ }
+
+ (event.identifiers || []).some(identifierCandidate => {
+ const regexMatch = identifierRegex.exec(identifierCandidate);
+ if (regexMatch) {
+ identifier = regexMatch[1];
+ return true;
+ }
+ return false;
+ });
+
+ if (!identifier) {
+ return;
+ }
+
+ toReturn.push({
+ name: `RSVP ${event.name || event.title}`,
+ details: JSON.stringify({
+ type: "event",
+ identifier
+ })
+ });
+ });
+
+ receivedTags.forEach(tag => {
+ toReturn.push({
+ name: `TAG ${tag.name}`,
+ details: JSON.stringify({
+ type: "tag",
+ tag: `${tag.name}`
+ })
+ });
+ });
+
+ return {
+ data: `${JSON.stringify({ items: toReturn })}`,
+ expiresSeconds:
+ Number(getConfig(envVars.CACHE_TTL, organization)) || defaults.CACHE_TTL
+ };
+}
+
+export async function available(organization) {
+ const result = !!getConfig(envVars.API_KEY, organization);
+
+ if (!result) {
+ console.info(
+ "action-network action unavailable. Missing one or more required environment variables"
+ );
+ }
+
+ return {
+ result,
+ expiresSeconds: 86400
+ };
+}
diff --git a/src/lib/conversations.js b/src/lib/conversations.js
new file mode 100644
index 000000000..ac31fa641
--- /dev/null
+++ b/src/lib/conversations.js
@@ -0,0 +1,113 @@
+export const tagsFilterStateFromTagsFilter = tagsFilter => {
+ let newTagsFilter = null;
+ if (tagsFilter.anyTag) {
+ newTagsFilter = ["*"];
+ } else if (tagsFilter.noTag) {
+ newTagsFilter = [];
+ } else if (!tagsFilter.ignoreTags) {
+ newTagsFilter = Object.values(tagsFilter.selectedTags).map(
+ tagFilter => tagFilter.id
+ );
+ }
+ return newTagsFilter;
+};
+
+export function getCampaignsFilterForCampaignArchiveStatus(
+ includeActiveCampaigns,
+ includeArchivedCampaigns
+) {
+ let isArchived = undefined;
+ if (!includeActiveCampaigns && includeArchivedCampaigns) {
+ isArchived = true;
+ } else if (
+ (includeActiveCampaigns && !includeArchivedCampaigns) ||
+ (!includeActiveCampaigns && !includeArchivedCampaigns)
+ ) {
+ isArchived = false;
+ }
+
+ if (isArchived !== undefined) {
+ return { isArchived };
+ }
+
+ return {};
+}
+
+export function getContactsFilterForConversationOptOutStatus(
+ includeNotOptedOutConversations,
+ includeOptedOutConversations
+) {
+ let isOptedOut = undefined;
+ if (!includeNotOptedOutConversations && includeOptedOutConversations) {
+ isOptedOut = true;
+ } else if (
+ (includeNotOptedOutConversations && !includeOptedOutConversations) ||
+ (!includeNotOptedOutConversations && !includeOptedOutConversations)
+ ) {
+ isOptedOut = false;
+ }
+
+ if (isOptedOut !== undefined) {
+ return { isOptedOut };
+ }
+
+ return {};
+}
+
+export const getConversationFiltersFromQuery = (query, organizationTags) => {
+ const includeArchivedCampaigns = query.archived
+ ? Boolean(parseInt(query.archived))
+ : false;
+ const includeActiveCampaigns = query.active
+ ? Boolean(parseInt(query.active))
+ : true;
+ const includeNotOptedOutConversations = query.notOptedOut
+ ? Boolean(parseInt(query.notOptedOut))
+ : true;
+ const includeOptedOutConversations = query.optedOut
+ ? Boolean(parseInt(query.optedOut))
+ : false;
+ const filters = {
+ campaignsFilter: getCampaignsFilterForCampaignArchiveStatus(
+ includeActiveCampaigns,
+ includeArchivedCampaigns
+ ),
+ contactsFilter: getContactsFilterForConversationOptOutStatus(
+ includeNotOptedOutConversations,
+ includeOptedOutConversations
+ ),
+ messageTextFilter: query.messageText ? query.messageText : "",
+ assignmentsFilter: query.texterId
+ ? { texterId: Number(query.texterId), sender: query.sender === "1" }
+ : {},
+ texterSearchText: query.texterId == "-2" ? " Unassigned" : undefined,
+ includeArchivedCampaigns,
+ includeActiveCampaigns,
+ includeNotOptedOutConversations,
+ includeOptedOutConversations,
+ tagsFilter: { ignoreTags: true }
+ };
+ if (query.campaigns) {
+ filters.campaignsFilter.campaignIds = query.campaigns.split(",");
+ }
+ if (query.messageStatus) {
+ filters.contactsFilter.messageStatus = query.messageStatus;
+ }
+ if (query.errorCode) {
+ filters.contactsFilter.errorCode = query.errorCode.split(",");
+ }
+ if (query.tags) {
+ if (/^[a-z]/.test(query.tags)) {
+ filters.tagsFilter = { [query.tags]: true };
+ } else {
+ const selectedTags = {};
+ query.tags.split(",").forEach(t => {
+ selectedTags[t] = organizationTags.find(ot => ot.id === t);
+ });
+ filters.tagsFilter = { selectedTags };
+ }
+ }
+ const newTagsFilter = tagsFilterStateFromTagsFilter(filters.tagsFilter);
+ filters.contactsFilter.tags = newTagsFilter;
+ return filters;
+};
diff --git a/src/lib/index.js b/src/lib/index.js
index 20d59bab5..7d2abb923 100644
--- a/src/lib/index.js
+++ b/src/lib/index.js
@@ -33,6 +33,13 @@ export {
makeTree
} from "./interaction-step-helpers";
+export {
+ getCampaignsFilterForCampaignArchiveStatus,
+ getContactsFilterForConversationOptOutStatus,
+ getConversationFiltersFromQuery,
+ tagsFilterStateFromTagsFilter
+} from "./conversations";
+
export {
ROLE_HIERARCHY,
getHighestRole,
diff --git a/src/lib/interaction-step-helpers.js b/src/lib/interaction-step-helpers.js
index 258234cbd..9e2cf09d2 100644
--- a/src/lib/interaction-step-helpers.js
+++ b/src/lib/interaction-step-helpers.js
@@ -106,6 +106,17 @@ export function makeTree(interactionSteps, id = null) {
};
}
+export function getUsedScriptFields(allInteractionSteps, key) {
+ // problem: also need all the canned response fields
+ const usedFields = {};
+ allInteractionSteps.forEach(is => {
+ is[key].replace(/\{([^}]+)\}/g, (m, scriptField) => {
+ usedFields[scriptField] = 1;
+ });
+ });
+ return usedFields;
+}
+
export function assembleAnswerOptions(allInteractionSteps) {
// creates recursive array required for the graphQL query with 'answerOptions' key
const interactionStepsCopy = allInteractionSteps.map(is => ({
diff --git a/src/lib/scripts.js b/src/lib/scripts.js
index e54021fdc..8de4619e0 100644
--- a/src/lib/scripts.js
+++ b/src/lib/scripts.js
@@ -29,14 +29,38 @@ const CAPITALIZE_FIELDS = [
"texterAliasOrFirstName"
];
+const SYSTEM_FIELDS = ["contactId", "contactIdBase62"];
+
+export const coreFields = {
+ firstName: 1,
+ lastName: 1,
+ texterFirstName: 1,
+ texterLastName: 1,
+ texterAliasOrFirstName: 1,
+ cell: 1,
+ zip: 1,
+ external_id: 1,
+ contactId: 1,
+ contactIdBase62: 1
+};
+
// TODO: This will include zipCode even if you ddin't upload it
export const allScriptFields = (customFields, includeDeprecated) =>
TOP_LEVEL_UPLOAD_FIELDS.concat(TEXTER_SCRIPT_FIELDS)
.concat(customFields)
+ .concat(SYSTEM_FIELDS)
.concat(includeDeprecated ? DEPRECATED_SCRIPT_FIELDS : []);
const capitalize = str => {
const strTrimmed = str.trim();
+ if (
+ strTrimmed.charAt(0).toUpperCase() == strTrimmed.charAt(0) &&
+ /[a-z]/.test(strTrimmed)
+ ) {
+ // first letter is upper-cased and some lowercase
+ // so then let's return as-is.
+ return strTrimmed;
+ }
return strTrimmed.charAt(0).toUpperCase() + strTrimmed.slice(1).toLowerCase();
};
@@ -48,6 +72,20 @@ const getScriptFieldValue = (contact, texter, fieldName) => {
result = texter.firstName;
} else if (fieldName === "texterLastName") {
result = texter.lastName;
+ } else if (fieldName === "contactId") {
+ result = String(contact.id);
+ } else if (fieldName === "contactIdBase62") {
+ let n = Number(contact.id);
+ if (n === 0) {
+ return "0";
+ }
+ const digits =
+ "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
+ result = "";
+ while (n > 0) {
+ result = digits[n % digits.length] + result;
+ n = parseInt(n / digits.length, 10);
+ }
} else if (TOP_LEVEL_UPLOAD_FIELDS.indexOf(fieldName) !== -1) {
result = contact[fieldName];
} else {
diff --git a/src/routes.jsx b/src/routes.jsx
index f3052fe4e..55eb4133b 100644
--- a/src/routes.jsx
+++ b/src/routes.jsx
@@ -16,8 +16,11 @@ import TexterTodoList from "./containers/TexterTodoList";
import TexterTodo from "./containers/TexterTodo";
import Login from "./components/Login";
import Terms from "./containers/Terms";
+import Downtime from "./components/Downtime";
import React from "react";
import CreateOrganization from "./containers/CreateOrganization";
+import CreateAdditionalOrganization from "./containers/CreateAdditionalOrganization";
+import AdminOrganizationsDashboard from "./containers/AdminOrganizationsDashboard";
import JoinTeam from "./containers/JoinTeam";
import Home from "./containers/Home";
import Settings from "./containers/Settings";
@@ -33,10 +36,29 @@ import {
} from "./components/AssignmentTexter/Demo";
import AdminPhoneNumberInventory from "./containers/AdminPhoneNumberInventory";
+const checkDowntime = (nextState, replace) => {
+ if (global.DOWNTIME && nextState.location.pathname !== "/downtime") {
+ replace({
+ pathname: "/downtime"
+ });
+ }
+};
+
+const checkTexterDowntime = requireAuth => (nextState, replace) => {
+ if (global.DOWNTIME_TEXTER && nextState.location.pathname !== "/downtime") {
+ replace({
+ pathname: "/downtime"
+ });
+ } else {
+ return requireAuth(nextState, replace);
+ }
+};
+
export default function makeRoutes(requireAuth = () => {}) {
return (
-
+
+
} />
@@ -61,7 +83,11 @@ export default function makeRoutes(requireAuth = () => {}) {
-
+
,
@@ -188,6 +214,7 @@ export default function makeRoutes(requireAuth = () => {}) {
+
{}) {
component={CreateOrganization}
onEnter={requireAuth}
/>
+
{
@@ -54,6 +46,31 @@ export const resolvers = {
);
return campaignContact.error_code;
},
+ cell: campaignContact =>
+ campaignContact.usedFields && !campaignContact.usedFields.cell
+ ? ""
+ : campaignContact.cell,
+ external_id: campaignContact =>
+ campaignContact.usedFields && !campaignContact.usedFields.external_id
+ ? ""
+ : campaignContact.external_id,
+ zip: campaignContact =>
+ campaignContact.usedFields && !campaignContact.usedFields.zip
+ ? ""
+ : campaignContact.zip,
+ customFields: async (campaignContact, _, { loaders }) => {
+ if (campaignContact.usedFields) {
+ const fullCustom = JSON.parse(campaignContact.custom_fields);
+ const filteredCustom = {};
+ Object.keys(campaignContact.usedFields).forEach(f => {
+ if (!coreFields[f]) {
+ filteredCustom[f] = fullCustom[f];
+ }
+ });
+ return JSON.stringify(filteredCustom);
+ }
+ return campaignContact.custom_fields;
+ },
campaign: async (campaignContact, _, { loaders }) =>
loaders.campaign.load(campaignContact.campaign_id),
// To get that result to look like what the original code returned
diff --git a/src/server/api/conversations.js b/src/server/api/conversations.js
index bca6d8301..348d4b6d5 100644
--- a/src/server/api/conversations.js
+++ b/src/server/api/conversations.js
@@ -17,10 +17,24 @@ function getConversationsJoinsAndWhereClause(
query = addCampaignsFilterToQuery(query, campaignsFilter, organizationId);
- if (assignmentsFilter && assignmentsFilter.texterId) {
- query = query.where({ "assignment.user_id": assignmentsFilter.texterId });
+ if (
+ assignmentsFilter &&
+ assignmentsFilter.texterId &&
+ !assignmentsFilter.sender
+ ) {
+ if (assignmentsFilter.texterId === -2) {
+ // unassigned
+ query = query.whereNull("campaign_contact.assignment_id");
+ } else {
+ query = query.where({ "assignment.user_id": assignmentsFilter.texterId });
+ }
}
- if (forData || (assignmentsFilter && assignmentsFilter.texterId)) {
+ if (
+ forData ||
+ (assignmentsFilter &&
+ assignmentsFilter.texterId &&
+ !assignmentsFilter.sender)
+ ) {
query = query
.leftJoin("assignment", "campaign_contact.assignment_id", "assignment.id")
.leftJoin("user", "assignment.user_id", "user.id")
@@ -33,15 +47,26 @@ function getConversationsJoinsAndWhereClause(
});
}
- if (messageTextFilter && !forData) {
+ if (
+ !forData &&
+ (messageTextFilter || (assignmentsFilter && assignmentsFilter.sender))
+ ) {
// NOT forData -- just for filter -- and then we need ALL the messages
- query = query
- .join(
- "message AS msgfilter",
- "msgfilter.campaign_contact_id",
- "campaign_contact.id"
- )
- .where("msgfilter.text", "LIKE", `%${messageTextFilter}%`);
+ query.join(
+ "message AS msgfilter",
+ "msgfilter.campaign_contact_id",
+ "campaign_contact.id"
+ );
+ if (messageTextFilter) {
+ query.where("msgfilter.text", "LIKE", `%${messageTextFilter}%`);
+ }
+ if (
+ assignmentsFilter &&
+ assignmentsFilter.sender &&
+ assignmentsFilter.texterId
+ ) {
+ query.where("msgfilter.user_id", assignmentsFilter.texterId);
+ }
}
query = addWhereClauseForContactsFilterMessageStatusIrrespectiveOfPastDue(
@@ -112,7 +137,8 @@ export async function getConversations(
{ campaignsFilter, assignmentsFilter, contactsFilter, messageTextFilter },
utc,
includeTags,
- awsContext
+ awsContext,
+ options
) {
/* Query #1 == get campaign_contact.id for all the conversations matching
* the criteria with offset and limit. */
@@ -129,9 +155,17 @@ export async function getConversations(
messageTextFilter
}
);
+ if (options && options.justIdQuery) {
+ return { query: offsetLimitQuery };
+ }
offsetLimitQuery = offsetLimitQuery.orderBy("cc_id", "desc");
- offsetLimitQuery = offsetLimitQuery.limit(cursor.limit).offset(cursor.offset);
+
+ if (cursor.limit || cursor.offset) {
+ offsetLimitQuery = offsetLimitQuery
+ .limit(cursor.limit)
+ .offset(cursor.offset);
+ }
console.log(
"getConversations sql",
awsContext && awsContext.awsRequestId,
@@ -151,7 +185,6 @@ export async function getConversations(
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 */
let query = r.knex.select(
diff --git a/src/server/api/lib/import-script.js b/src/server/api/lib/import-script.js
index e4d873a47..92dece2d0 100644
--- a/src/server/api/lib/import-script.js
+++ b/src/server/api/lib/import-script.js
@@ -320,7 +320,8 @@ const makeCannedResponsesList = cannedResponsesParagraphs => {
const cannedResponses = [];
while (cannedResponsesParagraphs[0]) {
const cannedResponse = {
- text: []
+ text: [],
+ tagIds: []
};
const paragraph = cannedResponsesParagraphs.shift();
@@ -336,7 +337,17 @@ const makeCannedResponsesList = cannedResponsesParagraphs => {
!cannedResponsesParagraphs[0].isParagraphBold
) {
const textParagraph = cannedResponsesParagraphs.shift();
- cannedResponse.text.push(textParagraph.text);
+ if (textParagraph.isParagraphItalic) {
+ // Italic = tag.
+ const tagId = textParagraph.text.match(/^\d*\b/);
+ if (tagId && !!tagId[0]) {
+ cannedResponse.tagIds.push(tagId[0])
+ }
+ }
+ else {
+ // Regular text, add to response.
+ cannedResponse.text.push(textParagraph.text);
+ }
}
if (!cannedResponse.text[0]) {
@@ -355,32 +366,62 @@ const replaceCannedResponsesInDatabase = async (
campaignId,
cannedResponses
) => {
+ const convertedResponses = [];
+ for (let index = 0; index < cannedResponses.length; index++) {
+ const response = cannedResponses[index];
+ convertedResponses.push({
+ ...response,
+ text: response.text.join("\n"),
+ campaign_id: campaignId,
+ id: undefined
+ });
+ }
+
+ // delete canned response / tag relations from tag_canned_response
await r.knex.transaction(async trx => {
- try {
- await r
+ await trx("tag_canned_response")
+ .whereIn(
+ "canned_response_id",
+ r
.knex("canned_response")
- .transacting(trx)
+ .select("id")
.where({
campaign_id: campaignId
})
- .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);
- }
- } catch (exception) {
- console.log(exception);
- throw exception;
- }
+ )
+ .delete();
+ // delete canned responses
+ await trx("canned_response")
+ .where({
+ campaign_id: campaignId
+ })
+ .whereNull("user_id")
+ .delete();
+
+ // save new canned responses and add their ids with related tag ids to tag_canned_response
+ const saveCannedResponse = async cannedResponse => {
+ const [res] = await trx("canned_response").insert(
+ cannedResponse, [
+ "id"
+ ]);
+ return res.id;
+ };
+ const tagCannedResponses = await Promise.all(
+ convertedResponses.map(async response => {
+ const {
+ tagIds,
+ ...filteredResponse
+ } = response;
+ const responseId = await saveCannedResponse(
+ filteredResponse);
+ return (tagIds || []).map(t => ({
+ tag_id: t,
+ canned_response_id: responseId
+ }));
+ })
+ );
+ await trx("tag_canned_response").insert(_.flatten(
+ tagCannedResponses));
});
};
diff --git a/src/server/api/mutations/clearCachedOrgAndExtensionCaches.js b/src/server/api/mutations/clearCachedOrgAndExtensionCaches.js
index 4c885f704..fd23a2a53 100644
--- a/src/server/api/mutations/clearCachedOrgAndExtensionCaches.js
+++ b/src/server/api/mutations/clearCachedOrgAndExtensionCaches.js
@@ -9,7 +9,7 @@ export const clearCachedOrgAndExtensionCaches = async (
{ organizationId },
{ user }
) => {
- await accessRequired(user, organizationId, "ADMIN", true);
+ await accessRequired(user, organizationId, "OWNER");
if (!r.redis) {
return "Redis not configured. No need to clear organization caches";
diff --git a/src/server/api/mutations/sendMessage.js b/src/server/api/mutations/sendMessage.js
index 0268f3d43..76a140188 100644
--- a/src/server/api/mutations/sendMessage.js
+++ b/src/server/api/mutations/sendMessage.js
@@ -65,7 +65,7 @@ export const sendMessage = async (
// })
// }
- const { contactNumber, text } = message;
+ const { text } = message;
if (text.length > (process.env.MAX_MESSAGE_LENGTH || 99999)) {
throw newError("Message was longer than the limit", "SENDERR_MAXLEN");
@@ -114,7 +114,7 @@ export const sendMessage = async (
const finalText = replaceCurlyApostrophes(text);
const messageInstance = new Message({
text: finalText,
- contact_number: contactNumber,
+ contact_number: contact.cell,
user_number: "",
user_id: user.id,
campaign_contact_id: contact.id,
diff --git a/src/server/api/organization.js b/src/server/api/organization.js
index 782088619..48ea55656 100644
--- a/src/server/api/organization.js
+++ b/src/server/api/organization.js
@@ -3,7 +3,7 @@ import { getConfig, getFeatures } from "./lib/config";
import { r, Organization, cacheableData } from "../models";
import { getTags } from "./tag";
import { accessRequired } from "./errors";
-import { getCampaigns } from "./campaign";
+import { getCampaigns, getCampaignsCount } from "./campaign";
import { buildUsersQuery } from "./user";
import {
getAvailableActionHandlers,
@@ -83,16 +83,24 @@ export const resolvers = {
{ cursor, campaignsFilter, sortBy },
{ user }
) => {
- await accessRequired(user, organization.id, "SUPERVOLUNTEER");
+ await accessRequired(user, organization.id, "SUPERVOLUNTEER", true);
return getCampaigns(organization.id, cursor, campaignsFilter, sortBy);
},
+ campaignsCount: async (organization, _, { user }) => {
+ await accessRequired(user, organization.id, "OWNER", true);
+ return r.getCount(
+ r
+ .knex("campaign")
+ .where({ organization_id: organization.id, is_archived: false })
+ );
+ },
+ numTextsInLastDay: async (organization, _, { user }) => {
+ await accessRequired(user, organization.id, "OWNER", true);
+ return getNumTextsInLastDay(organization.id);
+ },
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;
+ return organization.uuid;
},
optOuts: async (organization, _, { user }) => {
await accessRequired(user, organization.id, "ADMIN");
@@ -206,7 +214,12 @@ export const resolvers = {
textingHoursStart: organization => organization.texting_hours_start,
textingHoursEnd: organization => organization.texting_hours_end,
texterUIConfig: async (organization, _, { user }) => {
- await accessRequired(user, organization.id, "OWNER");
+ try {
+ await accessRequired(user, organization.id, "OWNER");
+ } catch (caught) {
+ return null;
+ }
+
const options = getConfig("TEXTER_UI_SETTINGS", organization) || null;
// note this is global, since we need the set that's globally enabled/allowed to choose from
const sideboxChoices = getSideboxChoices();
@@ -278,7 +291,7 @@ export const resolvers = {
return true;
},
phoneInventoryEnabled: async (organization, _, { user }) => {
- await accessRequired(user, organization.id, "SUPERVOLUNTEER");
+ await accessRequired(user, organization.id, "SUPERVOLUNTEER", true);
return (
getConfig("EXPERIMENTAL_PHONE_INVENTORY", organization, {
truthy: true
@@ -376,3 +389,21 @@ export const resolvers = {
}
}
};
+
+export async function getNumTextsInLastDay(organizationId) {
+ var yesterday = new Date();
+ yesterday.setDate(yesterday.getDate() - 1);
+
+ const textsInLastDay = r.knex
+ .from("message")
+ .join(
+ "campaign_contact",
+ "message.campaign_contact_id",
+ "campaign_contact.id"
+ )
+ .join("campaign", "campaign.id", "campaign_contact.campaign_id")
+ .where({ "campaign.organization_id": organizationId })
+ .where("message.sent_at", ">=", yesterday);
+ const numTexts = await r.getCount(textsInLastDay);
+ return numTexts;
+}
diff --git a/src/server/api/schema.js b/src/server/api/schema.js
index 222833895..58e99ac7d 100644
--- a/src/server/api/schema.js
+++ b/src/server/api/schema.js
@@ -1097,7 +1097,7 @@ const rootMutations = {
getAssignmentContacts: async (
_,
{ assignmentId, contactIds, findNew },
- { user }
+ { user, loaders }
) => {
if (contactIds.length === 0) {
return [];
@@ -1147,7 +1147,29 @@ const rootMutations = {
});
}
console.log("getAssignedContacts", contacts.length, updatedContacts);
- return contacts.map(c => c && (updatedContacts[c.id] || c)).map(hasAssn);
+ const finalContacts = contacts
+ .map(c => c && (updatedContacts[c.id] || c))
+ .map(hasAssn);
+ if (finalContacts.length && r.redis) {
+ // find out used fields so we can only send back those
+ const campaign = await loaders.campaign.load(firstContact.campaign_id);
+ const cannedResponses = await cacheableData.cannedResponse.query({
+ campaignId: firstContact.campaign_id
+ });
+ if (
+ campaign.usedFields &&
+ (!cannedResponses.length || cannedResponses[0].usedFields)
+ ) {
+ const usedFields = campaign.usedFields;
+ if (cannedResponses.length && cannedResponses[0].usedFields) {
+ Object.keys(cannedResponses[0].usedFields).forEach(f => {
+ usedFields[f] = 1;
+ });
+ }
+ return finalContacts.map(c => (c && { ...c, usedFields }) || c);
+ }
+ }
+ return finalContacts;
},
createOptOut: async (
_,
@@ -1176,9 +1198,9 @@ const rootMutations = {
campaignContactId,
contact.campaign_id
);
- const { assignmentId, cell, reason } = optOut;
+ const { assignmentId, reason } = optOut;
await cacheableData.optOut.save({
- cell,
+ cell: contact.cell,
campaignContactId,
reason,
assignmentId,
@@ -1371,6 +1393,9 @@ const rootResolvers = {
);
assignmentId = campaignContact.assignment_id;
}
+ if (!assignmentId) {
+ return null;
+ }
const assignment = await loaders.assignment.load(assignmentId);
if (!assignment) {
return null;
@@ -1394,7 +1419,7 @@ const rootResolvers = {
return assignment;
},
organization: async (_, { id }, { user, loaders }) => {
- await accessRequired(user, id, "TEXTER");
+ await accessRequired(user, id, "TEXTER", true);
return await loaders.organization.load(id);
},
inviteByHash: async (_, { hash }, { loaders, user }) => {
diff --git a/src/server/api/user.js b/src/server/api/user.js
index 0acdeb5c9..74532659d 100644
--- a/src/server/api/user.js
+++ b/src/server/api/user.js
@@ -291,7 +291,9 @@ export const resolvers = {
...fields,
"campaign_contact.timezone_offset",
"campaign_contact.message_status",
- r.knex.raw("COUNT(*) as tz_status_count")
+ r.knex.raw(
+ "SUM(CASE WHEN campaign_contact.id IS NOT NULL THEN 1 ELSE 0 END) as tz_status_count"
+ )
);
const result = await query;
const assignments = {};
diff --git a/src/server/auth-passport.js b/src/server/auth-passport.js
index 9614d7e99..caad3df42 100644
--- a/src/server/auth-passport.js
+++ b/src/server/auth-passport.js
@@ -65,7 +65,13 @@ export function setupAuth0Passport() {
email: req.user._json.email,
is_superadmin: false
};
- await User.save(userData);
+ const finalUser = await User.save(userData);
+ if (finalUser && finalUser.id === 1) {
+ await r
+ .knex("user")
+ .where("id", 1)
+ .update({ is_superadmin: true });
+ }
res.redirect(req.query.state || "terms");
return;
}
@@ -238,8 +244,13 @@ export function setupSlackPassport(app) {
email: slackUser.email,
is_superadmin: false
};
- await User.save(userData);
-
+ const finalUser = await User.save(userData);
+ if (finalUser && finalUser.id === 1) {
+ await r
+ .knex("user")
+ .where("id", 1)
+ .update({ is_superadmin: true });
+ }
res.redirect(req.query.state || "/"); // TODO: terms?
})
]
diff --git a/src/server/downtime.js b/src/server/downtime.js
new file mode 100644
index 000000000..0049479a2
--- /dev/null
+++ b/src/server/downtime.js
@@ -0,0 +1,78 @@
+import "babel-polyfill";
+import bodyParser from "body-parser";
+import express from "express";
+import { log } from "../lib";
+import renderIndex from "./middleware/render-index";
+import fs from "fs";
+import { existsSync } from "fs";
+import path from "path";
+
+// This server is for when it is in downtime mode and we just statically
+// serve the client app
+
+const DEBUG = process.env.NODE_ENV === "development";
+
+const app = express();
+const port = process.env.DEV_APP_PORT || process.env.PORT;
+// Don't rate limit heroku
+app.enable("trust proxy");
+
+app.use(bodyParser.json({ limit: "50mb" }));
+app.use(bodyParser.urlencoded({ extended: true }));
+
+// Serve static assets
+if (existsSync(process.env.ASSETS_DIR)) {
+ app.use(
+ "/assets",
+ express.static(process.env.ASSETS_DIR, {
+ maxAge: "180 days"
+ })
+ );
+}
+
+let assetMap = {
+ "bundle.js": "/assets/bundle.js"
+};
+if (process.env.NODE_ENV === "production") {
+ const assetMapData = JSON.parse(
+ fs.readFileSync(
+ // this is a bit overly complicated for the use case
+ // of it being run from the build directory BY claudia.js
+ // we need to make it REALLY relative, but not by the
+ // starting process or the 'local' directory (which are both wrong then)
+ (process.env.ASSETS_DIR || "").startsWith(".")
+ ? path.join(
+ __dirname,
+ "../../../",
+ process.env.ASSETS_DIR,
+ process.env.ASSETS_MAP_FILE
+ )
+ : path.join(process.env.ASSETS_DIR, process.env.ASSETS_MAP_FILE)
+ )
+ );
+ const staticBase = process.env.STATIC_BASE_URL || "/assets/";
+ for (var a in assetMapData) {
+ assetMap[a] = staticBase + assetMapData[a];
+ }
+}
+
+app.use((req, res, next) => {
+ if (req.path !== "/downtime") {
+ if (/twilio|nexmo/.test(req.path) && process.env.DOWNTIME_NO_DB) {
+ res.status(500).send("Server is down");
+ } else {
+ res.redirect(302, "/downtime");
+ }
+ } else {
+ res.send(renderIndex("", "", assetMap));
+ }
+ next();
+});
+
+if (port) {
+ app.listen(port, () => {
+ log.info(`Node app is running on port ${port}`);
+ });
+}
+
+export default app;
diff --git a/src/server/local-auth-helpers.js b/src/server/local-auth-helpers.js
index 08e417bfa..928b5cdf7 100644
--- a/src/server/local-auth-helpers.js
+++ b/src/server/local-auth-helpers.js
@@ -100,6 +100,13 @@ const signup = async ({
cell: reqBody.cell,
is_superadmin: false
});
+ if (user && user.id === 1) {
+ await r
+ .knex("user")
+ .where("id", 1)
+ .update({ is_superadmin: true });
+ user.is_superadmin = true;
+ }
resolve(user);
});
});
diff --git a/src/server/middleware/render-index.js b/src/server/middleware/render-index.js
index a8eb51b4b..a593ea524 100644
--- a/src/server/middleware/render-index.js
+++ b/src/server/middleware/render-index.js
@@ -96,6 +96,8 @@ export default function renderIndex(html, css, assetMap) {
.CONVERSATION_LIST_ROW_SIZES || ""}"
window.CORE_BACKGROUND_COLOR="${process.env.CORE_BACKGROUND_COLOR || ""}"
window.CAN_GOOGLE_IMPORT=${canGoogleImport}
+ window.DOWNTIME="${process.env.DOWNTIME || ""}"
+ window.DOWNTIME_TEXTER="${process.env.DOWNTIME_TEXTER || ""}"
window.EXPERIMENTAL_TWILIO_PER_CAMPAIGN_MESSAGING_SERVICE=${process.env
.EXPERIMENTAL_TWILIO_PER_CAMPAIGN_MESSAGING_SERVICE || false}
window.TWILIO_MULTI_ORG=${process.env.TWILIO_MULTI_ORG || false}
diff --git a/src/server/models/cacheable_queries/campaign.js b/src/server/models/cacheable_queries/campaign.js
index 6da2dae28..0c3a7f7d5 100644
--- a/src/server/models/cacheable_queries/campaign.js
+++ b/src/server/models/cacheable_queries/campaign.js
@@ -1,6 +1,9 @@
import { r, Campaign } from "../../models";
import { modelWithExtraProps } from "./lib";
-import { assembleAnswerOptions } from "../../../lib/interaction-step-helpers";
+import {
+ assembleAnswerOptions,
+ getUsedScriptFields
+} from "../../../lib/interaction-step-helpers";
import { getFeatures } from "../../api/lib/config";
import organizationCache from "./organization";
@@ -29,12 +32,23 @@ const CONTACT_CACHE_ENABLED =
process.env.REDIS_CONTACT_CACHE || global.REDIS_CONTACT_CACHE;
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));
+ // This rather Byzantine query just to get the first record
+ // is due to postgres query planner (for 11.8 anyway) being particularly aggregious
+ // This forces the use of the campaign_id index to get the minimum contact.id
+ const firstContact = await r
+ .knex("campaign_contact")
+ .select("custom_fields")
+ .whereIn(
+ "campaign_contact.id",
+ r
+ .knex("campaign")
+ .join("campaign_contact", "campaign_contact.campaign_id", "campaign.id")
+ .select(r.knex.raw("min(campaign_contact.id) as id"))
+ .where("campaign.id", id)
+ )
+ .first();
+ if (firstContact) {
+ return Object.keys(JSON.parse(firstContact.custom_fields));
}
return [];
};
@@ -67,7 +81,6 @@ const clear = async (id, campaign) => {
};
const loadDeep = async id => {
- // console.log('load campaign deep', id)
if (r.redis) {
const campaign = await Campaign.get(id);
if (Array.isArray(campaign) && campaign.length === 0) {
@@ -79,9 +92,12 @@ const loadDeep = async id => {
// do not cache archived campaigns
return campaign;
}
- // console.log('campaign loaddeep', campaign)
campaign.customFields = await dbCustomFields(id);
campaign.interactionSteps = await dbInteractionSteps(id);
+ campaign.usedFields = getUsedScriptFields(
+ campaign.interactionSteps,
+ "script"
+ );
campaign.contactTimezones = await dbContactTimezones(id);
campaign.contactsCount = await r.getCount(
r.knex("campaign_contact").where("campaign_id", id)
diff --git a/src/server/models/cacheable_queries/canned-response.js b/src/server/models/cacheable_queries/canned-response.js
index 6379248ee..a05ce0d38 100644
--- a/src/server/models/cacheable_queries/canned-response.js
+++ b/src/server/models/cacheable_queries/canned-response.js
@@ -1,6 +1,6 @@
import { r } from "../../models";
import { groupCannedResponses } from "../../api/lib/utils";
-
+import { getUsedScriptFields } from "../../../lib/interaction-step-helpers";
// Datastructure:
// * regular GET/SET with JSON ordered list of the objects {id,title,text}
// * keyed by campaignId-userId pairs -- userId is '' for global campaign records
@@ -50,6 +50,9 @@ const cannedResponseCache = {
user_id: cannedRes.user_id,
tagIds: cannedRes.tagIds
}));
+ if (cacheData.length) {
+ cacheData[0].usedFields = getUsedScriptFields(cacheData, "text");
+ }
await r.redis
.multi()
.set(cacheKey(campaignId, userId), JSON.stringify(cacheData))
diff --git a/src/server/models/cacheable_queries/opt-out.js b/src/server/models/cacheable_queries/opt-out.js
index 22f2b57c0..6bf741053 100644
--- a/src/server/models/cacheable_queries/opt-out.js
+++ b/src/server/models/cacheable_queries/opt-out.js
@@ -48,6 +48,25 @@ const loadMany = async organizationId => {
}
};
+const updateIsOptedOuts = async queryModifier => {
+ // update all organization/instance's active campaigns as well
+ const optOutContactQuery = r
+ .knex("campaign_contact")
+ .join("campaign", "campaign_contact.campaign_id", "campaign.id")
+ .where("campaign.is_archived", false)
+ .select("campaign_contact.id");
+
+ await r
+ .knex("campaign_contact")
+ .whereIn(
+ "id",
+ queryModifier ? queryModifier(optOutContactQuery) : optOutContactQuery
+ )
+ .update({
+ is_opted_out: true
+ });
+};
+
const optOutCache = {
clearQuery: async ({ cell, organizationId }) => {
// remove cache by organization
@@ -142,33 +161,19 @@ const optOutCache = {
return;
}
- // 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 };
- await r
- .knex("campaign_contact")
- .where(
- "id",
- "in",
- r
- .knex("campaign_contact")
- .leftJoin("campaign", "campaign_contact.campaign_id", "campaign.id")
- .where(updateOrgOrInstanceOptOuts)
- .select("campaign_contact.id")
- )
- .update({
- is_opted_out: true
- });
+ await updateIsOptedOuts(query => {
+ if (!sharingOptOuts) {
+ query.where("campaign.organization_id", organizationId);
+ }
+ return query.where("campaign_contact.cell", cell);
+ });
+
if (noReply) {
await campaignCache.incrCount(campaign.id, "needsResponseCount", -1);
}
},
- loadMany
+ loadMany,
+ updateIsOptedOuts
};
export default optOutCache;
diff --git a/src/server/models/cacheable_queries/user.js b/src/server/models/cacheable_queries/user.js
index 76ba6a313..2e86cd64a 100644
--- a/src/server/models/cacheable_queries/user.js
+++ b/src/server/models/cacheable_queries/user.js
@@ -9,13 +9,13 @@ KEY: texterauth-${authId}
- 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)
+ email: requiredString(),
+ created_at: timestamp(),
+ assigned_cell: type.string(),
+ is_superadmin: type.boolean(),
+ terms: type.boolean().default(false)
-HASH texterroles-
+ASH texterroles-
key = orgId
value = highest_role:org_name
diff --git a/src/workers/job-processes.js b/src/workers/job-processes.js
index 6592bc8a4..fd0a6411c 100644
--- a/src/workers/job-processes.js
+++ b/src/workers/job-processes.js
@@ -2,7 +2,7 @@
// that are tracked in the database via the JobRequest table.
// See src/extensions/job-runners/README.md for more details
-import { r } from "../server/models";
+import { r, cacheableData } from "../server/models";
import { sleep, getNextJob } from "./lib";
import { log } from "../lib";
import {
@@ -19,6 +19,7 @@ import {
startCampaignWithPhoneNumbers
} from "./jobs";
import { setupUserNotificationObservers } from "../server/notifications";
+import { loadContactsFromDataWarehouseFragment } from "../extensions/contact-loaders/datawarehouse";
export { seedZipCodes } from "../server/seeds/seed-zip-codes";
@@ -60,6 +61,7 @@ export const invokeJobFunction = async job => {
};
export async function processJobs() {
+ // DEPRECATED -- switch to job dispatchers. See src/extensions/job-runners/README.md
setupUserNotificationObservers();
console.log("Running processJobs");
// eslint-disable-next-line no-constant-condition
@@ -77,31 +79,66 @@ export async function processJobs() {
const twoMinutesAgo = new Date(new Date() - 1000 * 60 * 2);
// clear out stuck jobs
- await clearOldJobs(twoMinutesAgo);
+ await clearOldJobs({ oldJobPast: twoMinutesAgo });
} catch (ex) {
log.error(ex);
}
}
}
-export async function checkMessageQueue() {
- if (!process.env.TWILIO_SQS_QUEUE_URL) {
+export async function checkMessageQueue(event, contextVars) {
+ console.log("checkMessageQueue", process.env.TWILIO_SQS_QUEUE_URL, event);
+ const twilioSqsQueue =
+ (event && event.TWILIO_SQS_QUEUE_URL) || process.env.TWILIO_SQS_QUEUE_URL;
+ if (!twilioSqsQueue) {
return;
}
console.log("checking if messages are in message queue");
while (true) {
try {
- await sleep(10000);
- processSqsMessages();
+ if (event && event.delay) {
+ await sleep(event.delay);
+ }
+ await processSqsMessages(twilioSqsQueue);
+ if (
+ contextVars &&
+ typeof contextVars.remainingMilliseconds === "function"
+ ) {
+ if (contextVars.remainingMilliseconds() < 5000) {
+ // rather than get caught half-way through a message batch, let's bail
+ return;
+ }
+ }
} catch (ex) {
log.error(ex);
}
}
}
+export async function loadContactsFromDataWarehouseFragmentJob(
+ event,
+ contextVars,
+ eventCallback
+) {
+ try {
+ const rv = await loadContactsFromDataWarehouseFragment(
+ event, // double up argument
+ event
+ );
+ if (eventCallback) {
+ eventCallback(null, rv);
+ }
+ } catch (err) {
+ if (eventCallback) {
+ eventCallback(err, null);
+ }
+ }
+ return "completed";
+}
+
const messageSenderCreator = (subQuery, defaultStatus) => {
- return async event => {
+ return async (event, contextVars) => {
console.log("Running a message sender");
let sentCount = 0;
setupUserNotificationObservers();
@@ -121,6 +158,14 @@ const messageSenderCreator = (subQuery, defaultStatus) => {
} catch (ex) {
log.error(ex);
}
+ if (
+ contextVars &&
+ typeof contextVars.remainingMilliseconds === "function"
+ ) {
+ if (contextVars.remainingMilliseconds() < 5000) {
+ return sentCount;
+ }
+ }
}
return sentCount;
};
@@ -184,6 +229,7 @@ export const erroredMessageSender = messageSenderCreator(function(mQuery) {
// This is OK to run in a scheduled event because we are specifically narrowing on the error_code
// It's important though that runs are never in parallel
const twentyMinutesAgo = new Date(new Date() - 1000 * 60 * 20);
+ console.log("erroredMessageSender", twentyMinutesAgo);
return mQuery
.where("message.created_at", ">", twentyMinutesAgo)
.where("message.error_code", "<", 0);
@@ -232,7 +278,30 @@ export async function handleIncomingMessages() {
}
}
-export async function runDatabaseMigrations(event, dispatcher, eventCallback) {
+export async function updateOptOuts(event, context, eventCallback) {
+ // Especially for auto-optouts, campaign_contact.is_opted_out is not
+ // always updated and depends on this batch job to run
+ // We avoid it in-process to avoid db-write thrashing on optouts
+ // so they don't appear in queries
+ await cacheableData.optOut.updateIsOptedOuts(query =>
+ query
+ .join("opt_out", {
+ "opt_out.cell": "campaign_contact.cell",
+ ...(!process.env.OPTOUTS_SHARE_ALL_ORGS
+ ? { "opt_out.organization_id": "campaign.organization_id" }
+ : {})
+ })
+ .where(
+ "opt_out.created_at",
+ ">",
+ new Date(
+ new Date() - ((event && event.milliseconds_past) || 1000 * 60 * 60) // default 1 hour back
+ )
+ )
+ );
+}
+
+export async function runDatabaseMigrations(event, context, eventCallback) {
console.log("inside runDatabaseMigrations1");
console.log("inside runDatabaseMigrations2", event);
await r.k.migrate.latest();
@@ -243,11 +312,7 @@ export async function runDatabaseMigrations(event, dispatcher, eventCallback) {
return "completed migrations runDatabaseMigrations";
}
-export async function databaseMigrationChange(
- event,
- dispatcher,
- eventCallback
-) {
+export async function databaseMigrationChange(event, context, eventCallback) {
console.log("inside databaseMigrationChange", event);
if (event.up) {
await r.k.migrate.up();
@@ -279,29 +344,25 @@ const syncProcessMap = {
handleIncomingMessages,
checkMessageQueue,
fixOrgless,
- clearOldJobs
+ clearOldJobs,
+ updateOptOuts
};
-export async function dispatchProcesses(event, dispatcher, eventCallback) {
+export async function dispatchProcesses(event, context, eventCallback) {
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()
- .catch(err => {
- console.error("Process Error", p, err);
- });
- }
- }
+ await Promise.all(
+ Object.keys(toDispatch).map(p => {
+ const prom = toDispatch[p](event, context).catch(err => {
+ console.error("Process Error", p, err);
+ });
+ return prom;
+ })
+ );
return "completed";
}
-export async function ping(event, dispatcher) {
+export async function ping(event, context) {
return "pong";
}
@@ -312,6 +373,7 @@ export default {
ping,
processJobs,
checkMessageQueue,
+ loadContactsFromDataWarehouseFragmentJob,
messageSender01,
messageSender234,
messageSender56,
diff --git a/src/workers/jobs.js b/src/workers/jobs.js
index eba884916..9da957d38 100644
--- a/src/workers/jobs.js
+++ b/src/workers/jobs.js
@@ -9,7 +9,7 @@ import {
} from "../server/models";
import telemetry from "../server/telemetry";
import { log, gunzip, zipToTimeZone, convertOffsetsToStrings } from "../lib";
-import { updateJob } from "./lib";
+import { sleep, updateJob } from "./lib";
import serviceMap from "../server/api/lib/services";
import twilio from "../server/api/lib/twilio";
import {
@@ -148,20 +148,20 @@ export async function sendJobToAWSLambda(job) {
return p;
}
-export async function processSqsMessages() {
+export async function processSqsMessages(TWILIO_SQS_QUEUE_URL) {
// hit endpoint on SQS
// ask for a list of messages from SQS (with quantity tied to it)
// if SQS has messages, process messages into pending_message_part and dequeue messages (mark them as handled)
// if SQS doesnt have messages, exit
- if (!process.env.TWILIO_SQS_QUEUE_URL) {
+ if (!TWILIO_SQS_QUEUE_URL) {
return Promise.reject("TWILIO_SQS_QUEUE_URL not set");
}
const sqs = new AWS.SQS();
const params = {
- QueueUrl: process.env.TWILIO_SQS_QUEUE_URL,
+ QueueUrl: TWILIO_SQS_QUEUE_URL,
AttributeNames: ["All"],
MessageAttributeNames: ["string"],
MaxNumberOfMessages: 10,
@@ -173,33 +173,36 @@ export async function processSqsMessages() {
const p = new Promise((resolve, reject) => {
sqs.receiveMessage(params, async (err, data) => {
if (err) {
- console.log(err, err.stack);
+ console.log("processSqsMessages Error", 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
- },
- (delMessageErr, delMessageData) => {
- if (delMessageErr) {
- console.log(delMessageErr, delMessageErr.stack); // an error occurred
- } else {
- console.log(delMessageData); // successful response
- }
+ } else {
+ if (!data.Messages || !data.Messages.length) {
+ // Since we are likely in a while(true) loop let's avoid racing
+ await sleep(10000);
+ resolve();
+ } else {
+ console.log("processSqsMessages", data.Messages.length);
+ for (let i = 0; i < data.Messages.length; i++) {
+ const message = data.Messages[i];
+ const body = message.Body;
+ if (process.env.DEBUG) {
+ console.log("processSqsMessages message body", body);
}
- );
+ const twilioMessage = JSON.parse(body);
+ await serviceMap.twilio.handleIncomingMessage(twilioMessage);
+ const delMessageData = await sqs
+ .deleteMessage({
+ QueueUrl: TWILIO_SQS_QUEUE_URL,
+ ReceiptHandle: message.ReceiptHandle
+ })
+ .promise()
+ .catch(reject);
+ if (process.env.DEBUG) {
+ console.log("processSqsMessages deleteresult", delMessageData);
+ }
+ }
+ resolve();
}
- resolve();
}
});
});
@@ -291,28 +294,25 @@ export async function completeContactLoad(
console.log("Error deleting opt-outs:", campaignId, err);
});
- // delete duplicate cells
+ // delete duplicate cells (last wins)
await r
.knex("campaign_contact")
- .whereIn(
+ .whereNotIn(
"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", campaignId)
- .whereNotNull("c2.id")
+ .select(r.knex.raw("max(id) as id"))
+ .where("campaign_id", campaignId)
+ .groupBy("cell")
)
+ .where("campaign_contact.campaign_id", campaignId)
.delete()
.then(result => {
deleteDuplicateCells = result;
console.log("Deduplication result", campaignId, result);
})
.catch(err => {
+ deleteDuplicateCells = -1;
console.error("Failed deduplication", campaignId, err);
});
@@ -1152,10 +1152,10 @@ export async function fixOrgless() {
} // if
} // function
-export async function clearOldJobs(delay) {
+export async function clearOldJobs(event) {
// to clear out old stuck jobs
const twoHoursAgo = new Date(new Date() - 1000 * 60 * 60 * 2);
- delay = delay || twoHoursAgo;
+ const delay = (event && event.oldJobPast) || twoHoursAgo;
return await r
.knex("job_request")
.where({ assigned: true })