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