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/AdminCampaignStats.jsx b/src/containers/AdminCampaignStats.jsx
index abbdadbc3..20a1f60e2 100644
--- a/src/containers/AdminCampaignStats.jsx
+++ b/src/containers/AdminCampaignStats.jsx
@@ -350,9 +350,11 @@ class AdminCampaignStats extends React.Component {
campaign.exportResults.campaignExportUrl.startsWith("http") ? (
;
+ }
+ 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/schema.js b/src/server/api/schema.js
index a7d61fb36..c0abaa43b 100644
--- a/src/server/api/schema.js
+++ b/src/server/api/schema.js
@@ -193,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 =
@@ -259,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) {
@@ -1425,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);