diff --git a/__test__/server/api/campaign/campaign.test.js b/__test__/server/api/campaign/campaign.test.js index 2589c5b19..086a32c72 100644 --- a/__test__/server/api/campaign/campaign.test.js +++ b/__test__/server/api/campaign/campaign.test.js @@ -37,6 +37,7 @@ import { sleep, startCampaign } from "../../../test_helpers"; +import { dynamicReassignMutation } from "../../../../src/containers/AssignReplies"; let testAdminUser; let testInvite; @@ -815,6 +816,55 @@ describe("Reassignments", () => { testTexterUser ); + texterCampaignDataResults2 = await runGql( + TexterTodoQuery, + { + contactsFilter: { + messageStatus: "needsResponse", + isOptedOut: false, + validTimezone: true + }, + assignmentId: assignmentId2, + organizationId + }, + testTexterUser2 + ); + // TEXTER 1 (60 needsMessage, 2 needsResponse, 4 messaged) + // TEXTER 2 (25 needsMessage, 3 convo, 1 messaged) + expect(texterCampaignDataResults.data.assignment.contacts.length).toEqual( + 2 + ); + expect(texterCampaignDataResults.data.assignment.allContactsCount).toEqual( + 66 + ); + expect(texterCampaignDataResults2.data.assignment.contacts.length).toEqual( + 0 + ); + expect(texterCampaignDataResults2.data.assignment.allContactsCount).toEqual( + 29 + ); + await runGql( + dynamicReassignMutation, + { + joinToken: testCampaign.joinToken, + campaignId: testCampaign.id, + }, + testTexterUser2 + ); + texterCampaignDataResults = await runGql( + TexterTodoQuery, + { + contactsFilter: { + messageStatus: "needsResponse", + isOptedOut: false, + validTimezone: true + }, + assignmentId, + organizationId + }, + testTexterUser + ); + texterCampaignDataResults2 = await runGql( TexterTodoQuery, { @@ -840,6 +890,58 @@ describe("Reassignments", () => { expect(texterCampaignDataResults2.data.assignment.allContactsCount).toEqual( 29 ); + jest.useFakeTimers() + jest.advanceTimersByTime(4000000) + await runGql( + dynamicReassignMutation, + { + joinToken: testCampaign.joinToken, + campaignId: testCampaign.id, + }, + testTexterUser2 + ); + jest.useRealTimers() + texterCampaignDataResults = await runGql( + TexterTodoQuery, + { + contactsFilter: { + messageStatus: "needsResponse", + isOptedOut: false, + validTimezone: true + }, + assignmentId, + organizationId + }, + testTexterUser + ); + + texterCampaignDataResults2 = await runGql( + TexterTodoQuery, + { + contactsFilter: { + messageStatus: "needsResponse", + isOptedOut: false, + validTimezone: true + }, + assignmentId: assignmentId2, + organizationId + }, + testTexterUser2 + ); + // TEXTER 1 (60 needsMessage, 4 messaged) + // TEXTER 2 (25 needsMessage, 2 needsResponse, 3 convo, 1 messaged) + expect(texterCampaignDataResults.data.assignment.contacts.length).toEqual( + 0 + ); + expect(texterCampaignDataResults.data.assignment.allContactsCount).toEqual( + 64 + ); + expect(texterCampaignDataResults2.data.assignment.contacts.length).toEqual( + 2 + ); + expect(texterCampaignDataResults2.data.assignment.allContactsCount).toEqual( + 31 + ); }, 10000); // long test can exceed default 5seconds }); diff --git a/__test__/test_helpers.js b/__test__/test_helpers.js index 6c81b11bf..12215882f 100644 --- a/__test__/test_helpers.js +++ b/__test__/test_helpers.js @@ -359,6 +359,7 @@ export async function createCampaign( const campaignQuery = `mutation createCampaign($input: CampaignInput!) { createCampaign(campaign: $input) { id + joinToken } }`; const variables = { diff --git a/migrations/20240503180901_campaigncontactsupdatedat.js b/migrations/20240503180901_campaigncontactsupdatedat.js new file mode 100644 index 000000000..39f79d864 --- /dev/null +++ b/migrations/20240503180901_campaigncontactsupdatedat.js @@ -0,0 +1,35 @@ + +const { onUpdateTrigger } = require('./helpers/index') +const ON_UPDATE_TIMESTAMP_FUNCTION = ` + CREATE OR REPLACE FUNCTION on_update_timestamp() + RETURNS trigger AS $$ + BEGIN + NEW.updated_at = now(); + RETURN NEW; + END; +$$ language 'plpgsql'; +` + +const DROP_ON_UPDATE_TIMESTAMP_FUNCTION = `DROP FUNCTION on_update_timestamp` + +/** + * @param { import("knex").Knex } knex + */ +exports.up = async function(knex) { + const isSqlite = /sqlite/.test(knex.client.config.client); + if (!isSqlite) { + await knex.raw(ON_UPDATE_TIMESTAMP_FUNCTION); + await knex.raw(onUpdateTrigger('campaign_contact')); + } +}; + +/** + * @param { import("knex").Knex } knex + */ +exports.down = async function(knex) { + const isSqlite = /sqlite/.test(knex.client.config.client); + if (!isSqlite) { + await knex.raw("DROP TRIGGER campaign_contact_updated_at on campaign_contact"); + await knex.raw(DROP_ON_UPDATE_TIMESTAMP_FUNCTION); + } +}; diff --git a/migrations/helpers/index.js b/migrations/helpers/index.js index 5570a8aea..6eca405f0 100644 --- a/migrations/helpers/index.js +++ b/migrations/helpers/index.js @@ -11,3 +11,11 @@ exports.redefineSqliteTable = async (knex, tableName, newTableFn) => { await knex.schema.dropTable(tableName); await knex.schema.createTable(tableName, newTableFn); }; + + +exports.onUpdateTrigger = table => ` +CREATE TRIGGER ${table}_updated_at +BEFORE UPDATE ON ${table} +FOR EACH ROW +EXECUTE PROCEDURE on_update_timestamp(); +` \ No newline at end of file diff --git a/src/api/campaign.js b/src/api/campaign.js index 4a70ea5d1..e6427127b 100644 --- a/src/api/campaign.js +++ b/src/api/campaign.js @@ -137,6 +137,8 @@ export const schema = gql` messageServiceLink: String phoneNumbers: [String] inventoryPhoneNumberCounts: [CampaignPhoneNumberCount] + useDynamicReplies: Boolean + replyBatchSize: Int } type CampaignsList { diff --git a/src/api/schema.js b/src/api/schema.js index ad2370bee..fee8d89a5 100644 --- a/src/api/schema.js +++ b/src/api/schema.js @@ -99,6 +99,9 @@ const rootSchema = gql` texterUIConfig: TexterUIConfigInput timezone: String inventoryPhoneNumberCounts: [CampaignPhoneNumberInput!] + useDynamicReplies: Boolean + replyBatchSize: Int + joinToken: String } input OrganizationInput { @@ -395,6 +398,10 @@ const rootSchema = gql` messageTextFilter: String newTexterUserId: String! ): [CampaignIdAssignmentId] + dynamicReassign( + joinToken: String! + campaignId: String! + ): String importCampaignScript(campaignId: String!, url: String!): Int createTag(organizationId: String!, tagData: TagInput!): Tag editTag(organizationId: String!, id: String!, tagData: TagInput!): Tag diff --git a/src/components/CampaignDynamicAssignmentForm.jsx b/src/components/CampaignDynamicAssignmentForm.jsx index eadb2f92d..ae25dbab5 100644 --- a/src/components/CampaignDynamicAssignmentForm.jsx +++ b/src/components/CampaignDynamicAssignmentForm.jsx @@ -7,6 +7,7 @@ import GSTextField from "../components/forms/GSTextField"; import * as yup from "yup"; import Form from "react-formal"; import OrganizationJoinLink from "./OrganizationJoinLink"; +import OrganizationReassignLink from "./OrganizationReassignLink"; import { dataTest } from "../lib/attributes"; import cloneDeep from "lodash/cloneDeep"; import TagChips from "./TagChips"; @@ -53,7 +54,7 @@ class CampaignDynamicAssignmentForm extends React.Component { render() { const { joinToken, campaignId, organization } = this.props; - const { useDynamicAssignment, batchPolicies } = this.state; + const { useDynamicAssignment, batchPolicies, useDynamicReplies } = this.state; const unselectedPolicies = organization.batchPolicies .filter(p => !batchPolicies.find(cur => cur === p)) .map(p => ({ id: p, name: p })); @@ -73,6 +74,7 @@ class CampaignDynamicAssignmentForm extends React.Component { label="Allow texters with a link to join and start texting when the campaign is started?" labelPlacement="start" /> +
+ { + console.log(toggler, val); + this.toggleChange("useDynamicReplies", val); + }} + /> + } + label="Allow texters with a link to dynamically get assigned replies?" + labelPlacement="start" + /> + + {!useDynamicReplies ? null : ( +
+
    +
  • + {joinToken ? ( + + ) : ( + "Please save the campaign and reload the page to get the reply link to share with texters." + )} +
  • +
  • + You can turn off dynamic assignment after starting a campaign + to disallow more new texters to receive replies. +
  • +
+ + +
+ ) + + } {organization.batchPolicies.length > 1 ? (

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") ? (
Most recent export: +
Contacts Export CSV +
{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/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);