From 5e31328ac229331243d27caedcff1109c797c1dd Mon Sep 17 00:00:00 2001 From: sjwmoveon Date: Fri, 3 May 2024 13:31:51 -0400 Subject: [PATCH 01/11] Dynamic replies initial implementation Generates a special link used to reassign replies. Known bugs: replies can be reassigned indefinitely because the `updated_at` field in `campaign_contact` does not actually update on reassignment. --- src/api/campaign.js | 2 + src/api/schema.js | 6 ++ .../CampaignDynamicAssignmentForm.jsx | 53 ++++++++++- src/components/OrganizationReassignLink.jsx | 22 +++++ src/containers/AdminCampaignEdit.jsx | 6 +- src/containers/AssignReplies.jsx | 88 +++++++++++++++++++ src/routes.jsx | 6 ++ src/server/api/campaign.js | 9 ++ src/server/api/conversations.js | 11 +++ src/server/api/schema.js | 75 +++++++++++++++- 10 files changed, 273 insertions(+), 5 deletions(-) create mode 100644 src/components/OrganizationReassignLink.jsx create mode 100644 src/containers/AssignReplies.jsx 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..7e312c0a1 100644 --- a/src/api/schema.js +++ b/src/api/schema.js @@ -99,6 +99,8 @@ const rootSchema = gql` texterUIConfig: TexterUIConfigInput timezone: String inventoryPhoneNumberCounts: [CampaignPhoneNumberInput!] + useDynamicReplies: Boolean + replyBatchSize: Int } input OrganizationInput { @@ -395,6 +397,10 @@ const rootSchema = gql` messageTextFilter: String newTexterUserId: String! ): [CampaignIdAssignmentId] + dynamicReassign( + organizationUuid: 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..e12f8c56a 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..1a3fd388b --- /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 = ({ organizationUuid, campaignId }) => { + let baseUrl = "http://base"; + if (typeof window !== "undefined") { + baseUrl = window.location.origin; + } + + const replyUrl = `${baseUrl}/${organizationUuid}/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 = { + organizationUuid: 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..aca51dad8 --- /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.organizationUuid, + 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( + $organizationUuid: String! + $campaignId: String! + ) { + dynamicReassign( + organizationUuid: $organizationUuid + campaignId: $campaignId + ) + } +`; + +const mutations = { + dynamicReassign: ownProps => ( + organizationUuid, + campaignId + ) => ({ + mutation: dynamicReassignMutation, + variables: { + organizationUuid, + campaignId + } + }) +}; + +export default loadData({ mutations })(withRouter(AssignReplies)); diff --git a/src/routes.jsx b/src/routes.jsx index 8b82f290c..47685bfa1 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..d0674b731 100644 --- a/src/server/api/schema.js +++ b/src/server/api/schema.js @@ -2,7 +2,7 @@ import GraphQLDate from "graphql-date"; import GraphQLJSON from "graphql-type-json"; import { GraphQLError } from "graphql/error"; import isUrl from "is-url"; -import _ from "lodash"; +import _, { orderBy } from "lodash"; import { gzip, makeTree, getHighestRole } from "../../lib"; import { capitalizeWord, groupCannedResponses } from "./lib/utils"; import httpRequest from "../lib/http-request"; @@ -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,64 @@ const rootMutations = { newTexterUserId ); }, + dynamicReassign: async ( + _, + { + organizationUuid, + campaignId + }, + { user } + ) => { + // verify permissions + const campaign = await r + .knex("campaign") + .where({ + id: campaignId, + join_token: organizationUuid, + }) + .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); + 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); From ddcd4448111b31d20db1a4ab3689f9923896d375 Mon Sep 17 00:00:00 2001 From: sjwmoveon Date: Fri, 3 May 2024 14:23:29 -0400 Subject: [PATCH 02/11] Update campaign_contact.updated_at on change --- ...0240503180901_campaigncontactsupdatedat.js | 32 +++++++++++++++++++ migrations/helpers/index.js | 8 +++++ 2 files changed, 40 insertions(+) create mode 100644 migrations/20240503180901_campaigncontactsupdatedat.js diff --git a/migrations/20240503180901_campaigncontactsupdatedat.js b/migrations/20240503180901_campaigncontactsupdatedat.js new file mode 100644 index 000000000..1ef891573 --- /dev/null +++ b/migrations/20240503180901_campaigncontactsupdatedat.js @@ -0,0 +1,32 @@ + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ + +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` + +exports.up = async function(knex) { + await knex.raw(ON_UPDATE_TIMESTAMP_FUNCTION); + await knex.raw(onUpdateTrigger('campaign_contact')); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = async function(knex) { + await knex.raw("DROP TRIGGER campaign_contact_updated_at on campaign_contact"); + await knex.raw(DROP_ON_UPDATE_TIMESTAMP_FUNCTION); +}; \ No newline at end of file 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 From 226f4e069dc8a22a7b41da7c41a978b988da8ccd Mon Sep 17 00:00:00 2001 From: sjwmoveon Date: Fri, 3 May 2024 14:32:57 -0400 Subject: [PATCH 03/11] Only run migration on postgres, not sqlite --- .../20240503180901_campaigncontactsupdatedat.js | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/migrations/20240503180901_campaigncontactsupdatedat.js b/migrations/20240503180901_campaigncontactsupdatedat.js index 1ef891573..eb483c4ae 100644 --- a/migrations/20240503180901_campaigncontactsupdatedat.js +++ b/migrations/20240503180901_campaigncontactsupdatedat.js @@ -18,8 +18,11 @@ $$ language 'plpgsql'; const DROP_ON_UPDATE_TIMESTAMP_FUNCTION = `DROP FUNCTION on_update_timestamp` exports.up = async function(knex) { - await knex.raw(ON_UPDATE_TIMESTAMP_FUNCTION); - await knex.raw(onUpdateTrigger('campaign_contact')); + const isSqlite = /sqlite/.test(knex.client.config.client); + if (!isSqlite) { + await knex.raw(ON_UPDATE_TIMESTAMP_FUNCTION); + await knex.raw(onUpdateTrigger('campaign_contact')); + } }; /** @@ -27,6 +30,9 @@ exports.up = async function(knex) { * @returns { Promise } */ exports.down = async function(knex) { - await knex.raw("DROP TRIGGER campaign_contact_updated_at on campaign_contact"); - await knex.raw(DROP_ON_UPDATE_TIMESTAMP_FUNCTION); + 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); + } }; \ No newline at end of file From 454e1dbb2ff7df75b310b2958524066a4c1bf13c Mon Sep 17 00:00:00 2001 From: Sophie Waldman <62553142+sjwmoveon@users.noreply.github.com> Date: Fri, 3 May 2024 14:41:50 -0400 Subject: [PATCH 04/11] Fix comments --- .../20240503180901_campaigncontactsupdatedat.js | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/migrations/20240503180901_campaigncontactsupdatedat.js b/migrations/20240503180901_campaigncontactsupdatedat.js index eb483c4ae..39f79d864 100644 --- a/migrations/20240503180901_campaigncontactsupdatedat.js +++ b/migrations/20240503180901_campaigncontactsupdatedat.js @@ -1,9 +1,4 @@ -/** - * @param { import("knex").Knex } knex - * @returns { Promise } - */ - const { onUpdateTrigger } = require('./helpers/index') const ON_UPDATE_TIMESTAMP_FUNCTION = ` CREATE OR REPLACE FUNCTION on_update_timestamp() @@ -17,6 +12,9 @@ $$ 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) { @@ -27,7 +25,6 @@ exports.up = async function(knex) { /** * @param { import("knex").Knex } knex - * @returns { Promise } */ exports.down = async function(knex) { const isSqlite = /sqlite/.test(knex.client.config.client); @@ -35,4 +32,4 @@ exports.down = async function(knex) { await knex.raw("DROP TRIGGER campaign_contact_updated_at on campaign_contact"); await knex.raw(DROP_ON_UPDATE_TIMESTAMP_FUNCTION); } -}; \ No newline at end of file +}; From 4be13a6037abb6630fa435cdd423da79fe651677 Mon Sep 17 00:00:00 2001 From: sjwmoveon Date: Fri, 3 May 2024 16:55:54 -0400 Subject: [PATCH 05/11] Add test for dynamicReassign method --- __test__/server/api/campaign/campaign.test.js | 102 ++++++++++++++++++ __test__/test_helpers.js | 1 + src/api/schema.js | 1 + src/server/api/schema.js | 3 +- 4 files changed, 105 insertions(+), 2 deletions(-) diff --git a/__test__/server/api/campaign/campaign.test.js b/__test__/server/api/campaign/campaign.test.js index 2589c5b19..da3b0eac3 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; @@ -828,6 +829,8 @@ describe("Reassignments", () => { }, 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 ); @@ -840,6 +843,105 @@ describe("Reassignments", () => { expect(texterCampaignDataResults2.data.assignment.allContactsCount).toEqual( 29 ); + await runGql( + dynamicReassignMutation, + { + organizationUuid: testCampaign.joinToken, + campaignId: testCampaign.id, + }, + testTexterUser2 + ); + // TEXTER 1 (60 needsMessage, 4 messaged) + // TEXTER 2 (25 needsMessage, 2 needsResponse, 3 convo, 1 messaged) + 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 + ); + 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 + ); + jest.useFakeTimers() + jest.advanceTimersByTime(4000000) + await runGql( + dynamicReassignMutation, + { + organizationUuid: 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 + ); + 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/src/api/schema.js b/src/api/schema.js index 7e312c0a1..a87157cbb 100644 --- a/src/api/schema.js +++ b/src/api/schema.js @@ -101,6 +101,7 @@ const rootSchema = gql` inventoryPhoneNumberCounts: [CampaignPhoneNumberInput!] useDynamicReplies: Boolean replyBatchSize: Int + joinToken: String } input OrganizationInput { diff --git a/src/server/api/schema.js b/src/server/api/schema.js index d0674b731..14f7d3127 100644 --- a/src/server/api/schema.js +++ b/src/server/api/schema.js @@ -1468,8 +1468,7 @@ const rootMutations = { if (!organization) { throw INVALID_REASSIGN(); } - - const maxContacts = getConfig("MAX_REPLIES_PER_TEXTER", organization); + 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} From 0dfcea59a092b1d136bf3f26b5410f1c1b94313e Mon Sep 17 00:00:00 2001 From: sjwmoveon Date: Fri, 3 May 2024 18:12:37 -0400 Subject: [PATCH 06/11] Use 'joinToken' in place of 'organizationUuid' since we are actually using the joinToken value Also remove an unnecessary import and fix a comment --- __test__/server/api/campaign/campaign.test.js | 8 ++++---- src/api/schema.js | 2 +- src/components/CampaignDynamicAssignmentForm.jsx | 2 +- src/components/OrganizationReassignLink.jsx | 6 +++--- src/containers/AssignReplies.jsx | 10 +++++----- src/routes.jsx | 2 +- src/server/api/schema.js | 6 +++--- 7 files changed, 18 insertions(+), 18 deletions(-) diff --git a/__test__/server/api/campaign/campaign.test.js b/__test__/server/api/campaign/campaign.test.js index da3b0eac3..086a32c72 100644 --- a/__test__/server/api/campaign/campaign.test.js +++ b/__test__/server/api/campaign/campaign.test.js @@ -846,13 +846,11 @@ describe("Reassignments", () => { await runGql( dynamicReassignMutation, { - organizationUuid: testCampaign.joinToken, + joinToken: testCampaign.joinToken, campaignId: testCampaign.id, }, testTexterUser2 ); - // TEXTER 1 (60 needsMessage, 4 messaged) - // TEXTER 2 (25 needsMessage, 2 needsResponse, 3 convo, 1 messaged) texterCampaignDataResults = await runGql( TexterTodoQuery, { @@ -897,7 +895,7 @@ describe("Reassignments", () => { await runGql( dynamicReassignMutation, { - organizationUuid: testCampaign.joinToken, + joinToken: testCampaign.joinToken, campaignId: testCampaign.id, }, testTexterUser2 @@ -930,6 +928,8 @@ describe("Reassignments", () => { }, 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 ); diff --git a/src/api/schema.js b/src/api/schema.js index a87157cbb..fee8d89a5 100644 --- a/src/api/schema.js +++ b/src/api/schema.js @@ -399,7 +399,7 @@ const rootSchema = gql` newTexterUserId: String! ): [CampaignIdAssignmentId] dynamicReassign( - organizationUuid: String! + joinToken: String! campaignId: String! ): String importCampaignScript(campaignId: String!, url: String!): Int diff --git a/src/components/CampaignDynamicAssignmentForm.jsx b/src/components/CampaignDynamicAssignmentForm.jsx index e12f8c56a..ae25dbab5 100644 --- a/src/components/CampaignDynamicAssignmentForm.jsx +++ b/src/components/CampaignDynamicAssignmentForm.jsx @@ -156,7 +156,7 @@ class CampaignDynamicAssignmentForm extends React.Component {
  • {joinToken ? ( ) : ( diff --git a/src/components/OrganizationReassignLink.jsx b/src/components/OrganizationReassignLink.jsx index 1a3fd388b..bdcc1a315 100644 --- a/src/components/OrganizationReassignLink.jsx +++ b/src/components/OrganizationReassignLink.jsx @@ -2,20 +2,20 @@ import PropTypes from "prop-types"; import React from "react"; import DisplayLink from "./DisplayLink"; -const OrganizationReassignLink = ({ organizationUuid, campaignId }) => { +const OrganizationReassignLink = ({ joinToken, campaignId }) => { let baseUrl = "http://base"; if (typeof window !== "undefined") { baseUrl = window.location.origin; } - const replyUrl = `${baseUrl}/${organizationUuid}/replies/${campaignId}`; + 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 = { - organizationUuid: PropTypes.string, + joinToken: PropTypes.string, campaignId: PropTypes.string }; diff --git a/src/containers/AssignReplies.jsx b/src/containers/AssignReplies.jsx index aca51dad8..3e50c5418 100644 --- a/src/containers/AssignReplies.jsx +++ b/src/containers/AssignReplies.jsx @@ -22,7 +22,7 @@ class AssignReplies extends React.Component { try { const organizationId = (await this.props.mutations.dynamicReassign( - this.props.params.organizationUuid, + this.props.params.joinToken, this.props.params.campaignId )).data.dynamicReassign; console.log("ID:", organizationId); @@ -62,11 +62,11 @@ AssignReplies.propTypes = { export const dynamicReassignMutation = gql` mutation dynamicReassign( - $organizationUuid: String! + $joinToken: String! $campaignId: String! ) { dynamicReassign( - organizationUuid: $organizationUuid + joinToken: $joinToken campaignId: $campaignId ) } @@ -74,12 +74,12 @@ export const dynamicReassignMutation = gql` const mutations = { dynamicReassign: ownProps => ( - organizationUuid, + joinToken, campaignId ) => ({ mutation: dynamicReassignMutation, variables: { - organizationUuid, + joinToken, campaignId } }) diff --git a/src/routes.jsx b/src/routes.jsx index 47685bfa1..7fce13cfc 100644 --- a/src/routes.jsx +++ b/src/routes.jsx @@ -277,7 +277,7 @@ export default function makeRoutes(requireAuth = () => {}) { onEnter={requireAuth} /> diff --git a/src/server/api/schema.js b/src/server/api/schema.js index 14f7d3127..c0abaa43b 100644 --- a/src/server/api/schema.js +++ b/src/server/api/schema.js @@ -2,7 +2,7 @@ import GraphQLDate from "graphql-date"; import GraphQLJSON from "graphql-type-json"; import { GraphQLError } from "graphql/error"; import isUrl from "is-url"; -import _, { orderBy } from "lodash"; +import _ from "lodash"; import { gzip, makeTree, getHighestRole } from "../../lib"; import { capitalizeWord, groupCannedResponses } from "./lib/utils"; import httpRequest from "../lib/http-request"; @@ -1441,7 +1441,7 @@ const rootMutations = { dynamicReassign: async ( _, { - organizationUuid, + joinToken, campaignId }, { user } @@ -1451,7 +1451,7 @@ const rootMutations = { .knex("campaign") .where({ id: campaignId, - join_token: organizationUuid, + join_token: joinToken, }) .first(); const INVALID_REASSIGN = () => { From 716a1542d69837b24c0f980eec4e1e8ee7b42ba9 Mon Sep 17 00:00:00 2001 From: Sophie Waldman <62553142+sjwmoveon@users.noreply.github.com> Date: Wed, 15 May 2024 15:15:08 -0400 Subject: [PATCH 07/11] Fix SonarCloud alert SonarCloud doesn't like raw http links, preferring https. In this case the link should never be used so it makes no difference to switch to https. --- src/components/OrganizationReassignLink.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/OrganizationReassignLink.jsx b/src/components/OrganizationReassignLink.jsx index bdcc1a315..f610b452c 100644 --- a/src/components/OrganizationReassignLink.jsx +++ b/src/components/OrganizationReassignLink.jsx @@ -3,7 +3,7 @@ import React from "react"; import DisplayLink from "./DisplayLink"; const OrganizationReassignLink = ({ joinToken, campaignId }) => { - let baseUrl = "http://base"; + let baseUrl = "https://base"; if (typeof window !== "undefined") { baseUrl = window.location.origin; } From 14a56734d88cb29b44ddd0a5bf5645edfc62feba Mon Sep 17 00:00:00 2001 From: sjwmoveon Date: Wed, 15 May 2024 15:54:41 -0400 Subject: [PATCH 08/11] retrigger checks From f01f203c775eeaf973fe4129a6b72cfa1a60bc64 Mon Sep 17 00:00:00 2001 From: sjwmoveon Date: Wed, 15 May 2024 16:00:36 -0400 Subject: [PATCH 09/11] retrigger checks x2 From 22977c900ccdfb6c8a46f8a0941637bd338408c8 Mon Sep 17 00:00:00 2001 From: Alex French Date: Wed, 15 May 2024 14:09:29 -0700 Subject: [PATCH 10/11] retrigger checks x3 From 6840d4665b3e209fdd8f8cd5bbd3a27c1a9286fb Mon Sep 17 00:00:00 2001 From: sjwmoveon Date: Tue, 21 May 2024 16:32:35 -0400 Subject: [PATCH 11/11] Add line breaks to export links --- src/containers/AdminCampaignStats.jsx | 2 ++ 1 file changed, 2 insertions(+) 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 +