Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merge dynamic replies feature into staging branch #18

Merged
merged 14 commits into from
May 15, 2024
Merged
102 changes: 102 additions & 0 deletions __test__/server/api/campaign/campaign.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
sleep,
startCampaign
} from "../../../test_helpers";
import { dynamicReassignMutation } from "../../../../src/containers/AssignReplies";

let testAdminUser;
let testInvite;
Expand Down Expand Up @@ -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,
{
Expand All @@ -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
});

Expand Down
1 change: 1 addition & 0 deletions __test__/test_helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,7 @@ export async function createCampaign(
const campaignQuery = `mutation createCampaign($input: CampaignInput!) {
createCampaign(campaign: $input) {
id
joinToken
}
}`;
const variables = {
Expand Down
35 changes: 35 additions & 0 deletions migrations/20240503180901_campaigncontactsupdatedat.js
Original file line number Diff line number Diff line change
@@ -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);
}
};
8 changes: 8 additions & 0 deletions migrations/helpers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
`
2 changes: 2 additions & 0 deletions src/api/campaign.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,8 @@ export const schema = gql`
messageServiceLink: String
phoneNumbers: [String]
inventoryPhoneNumberCounts: [CampaignPhoneNumberCount]
useDynamicReplies: Boolean
replyBatchSize: Int
}

type CampaignsList {
Expand Down
7 changes: 7 additions & 0 deletions src/api/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,9 @@ const rootSchema = gql`
texterUIConfig: TexterUIConfigInput
timezone: String
inventoryPhoneNumberCounts: [CampaignPhoneNumberInput!]
useDynamicReplies: Boolean
replyBatchSize: Int
joinToken: String
}

input OrganizationInput {
Expand Down Expand Up @@ -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
Expand Down
53 changes: 51 additions & 2 deletions src/components/CampaignDynamicAssignmentForm.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 }));
Expand All @@ -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"
/>
<br/>
<GSForm
schema={this.formSchema}
value={this.state}
Expand Down Expand Up @@ -133,6 +135,52 @@ class CampaignDynamicAssignmentForm extends React.Component {
message status filter in Message Review. You might set this to 48
hours for slower campaigns or 2 hours or less for GOTV campaigns.
</p>
<FormControlLabel
control={
<Switch
color="primary"
checked={useDynamicReplies || false}
onChange={(toggler, val) => {
console.log(toggler, val);
this.toggleChange("useDynamicReplies", val);
}}
/>
}
label="Allow texters with a link to dynamically get assigned replies?"
labelPlacement="start"
/>

{!useDynamicReplies ? null : (
<div>
<ul>
<li>
{joinToken ? (
<OrganizationReassignLink
joinToken={joinToken}
campaignId={campaignId}
/>
) : (
"Please save the campaign and reload the page to get the reply link to share with texters."
)}
</li>
<li>
You can turn off dynamic assignment after starting a campaign
to disallow more new texters to receive replies.
</li>
</ul>

<Form.Field
as={GSTextField}
fullWidth
name="replyBatchSize"
type="number"
label="How large should a batch of replies be?"
initialValue={200}
/>
</div>
)

}
{organization.batchPolicies.length > 1 ? (
<div>
<h3>Batch Strategy</h3>
Expand Down Expand Up @@ -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);
22 changes: 22 additions & 0 deletions src/components/OrganizationReassignLink.jsx
Original file line number Diff line number Diff line change
@@ -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 <DisplayLink url={replyUrl} textContent={textContent} />;
};

OrganizationReassignLink.propTypes = {
joinToken: PropTypes.string,
campaignId: PropTypes.string
};

export default OrganizationReassignLink;
6 changes: 5 additions & 1 deletion src/containers/AdminCampaignEdit.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@ const campaignInfoFragment = `
state
count
}
useDynamicReplies
replyBatchSize
`;

export const campaignDataQuery = gql`query getCampaign($campaignId: String!) {
Expand Down Expand Up @@ -514,7 +516,9 @@ export class AdminCampaignEditBase extends React.Component {
"batchSize",
"useDynamicAssignment",
"responseWindow",
"batchPolicies"
"batchPolicies",
"useDynamicReplies",
"replyBatchSize"
],
checkCompleted: () => true,
blocksStarting: false,
Expand Down
Loading
Loading