diff --git a/__test__/testbed-preparation/core.ts b/__test__/testbed-preparation/core.ts index 7dff6c5bc..3b1e854a1 100644 --- a/__test__/testbed-preparation/core.ts +++ b/__test__/testbed-preparation/core.ts @@ -1,18 +1,21 @@ +import type { + Assignment, + Campaign, + CampaignContact, + InteractionStep, + Message, + Organization, + User +} from "@spoke/spoke-codegen"; import faker from "faker"; import AuthHasher from "passport-local-authenticate"; import type { PoolClient } from "pg"; -import type { Assignment } from "../../src/api/assignment"; -import type { Campaign } from "../../src/api/campaign"; -import type { CampaignContact } from "../../src/api/campaign-contact"; -import type { InteractionStep } from "../../src/api/interaction-step"; -import type { Message } from "../../src/api/message"; -import type { Organization } from "../../src/api/organization"; import { UserRoleType } from "../../src/api/organization-membership"; -import type { User } from "../../src/api/user"; import { DateTime } from "../../src/lib/datetime"; import type { AssignmentRecord, + AutoReplyTriggerRecord, CampaignContactRecord, CampaignRecord, InteractionStepRecord, @@ -388,7 +391,7 @@ export const createMessage = async ( .then(({ rows: [message] }) => message); export interface CreateCompleteCampaignOptions { - organization?: CreateOrganizationOptions; + organization?: CreateOrganizationOptions & { id: number }; campaign?: Omit; texters?: number | CreateTexterOptions[]; contacts?: number | Omit[]; @@ -398,10 +401,10 @@ export const createCompleteCampaign = async ( client: PoolClient, options: CreateCompleteCampaignOptions ) => { - const organization = await createOrganization( - client, - options.organization ?? {} - ); + const optOrg = options.organization; + const organization = optOrg?.id + ? { id: optOrg?.id } + : await createOrganization(client, options.organization ?? {}); const campaign = await createCampaign(client, { ...(options.campaign ?? {}), @@ -524,3 +527,44 @@ export const createQuestionResponse = async ( [options.value, options.campaignContactId, options.interactionStepId] ) .then(({ rows: [questionResponse] }) => questionResponse); + +export type CreateAutoReplyTriggerOptions = { + token: string; + interactionStepId: number; +}; + +export const createAutoReplyTrigger = async ( + client: PoolClient, + options: CreateAutoReplyTriggerOptions +) => + client + .query( + ` + insert into public.auto_reply_trigger (token, interaction_step_id) + values ($1, $2) + returning * + `, + [options.token, options.interactionStepId] + ) + .then(({ rows: [trigger] }) => trigger); + +export const assignContacts = async ( + client: PoolClient, + assignmentId: number, + campaignId: number, + count: number +) => { + await client.query( + ` + update campaign_contact + set assignment_id = $1 + where id in ( + select id from campaign_contact + where campaign_id = $2 + and assignment_id is null + limit $3 + ) + `, + [assignmentId, campaignId, count] + ); +}; diff --git a/libs/gql-schema/campaign-contact.ts b/libs/gql-schema/campaign-contact.ts index e62d1393a..24e3e8b8a 100644 --- a/libs/gql-schema/campaign-contact.ts +++ b/libs/gql-schema/campaign-contact.ts @@ -36,6 +36,8 @@ export const schema = ` messageStatus: String! assignmentId: String updatedAt: Date! + autoReplyEligible: Boolean! + tags: [CampaignContactTag!]! } diff --git a/libs/gql-schema/interaction-step.ts b/libs/gql-schema/interaction-step.ts index a985ba7f1..dee49749a 100644 --- a/libs/gql-schema/interaction-step.ts +++ b/libs/gql-schema/interaction-step.ts @@ -6,6 +6,7 @@ export const schema = ` scriptOptions: [String]! answerOption: String parentInteractionId: String + autoReplyTokens: [String] isDeleted: Boolean answerActions: String questionResponse(campaignContactId: String): QuestionResponse @@ -18,10 +19,26 @@ export const schema = ` scriptOptions: [String]! answerOption: String answerActions: String + autoReplyTokens: [String] parentInteractionId: String isDeleted: Boolean createdAt: Date interactionSteps: [InteractionStepInput] } + + type InteractionStepWithChildren { + id: ID! + question: Question + questionText: String + scriptOptions: [String]! + answerOption: String + parentInteractionId: String + autoReplyTokens: [String] + isDeleted: Boolean + answerActions: String + questionResponse(campaignContactId: String): QuestionResponse + createdAt: Date! + interactionSteps: [InteractionStep] + } `; export default schema; diff --git a/libs/gql-schema/schema.ts b/libs/gql-schema/schema.ts index a1526a757..ad486a3f2 100644 --- a/libs/gql-schema/schema.ts +++ b/libs/gql-schema/schema.ts @@ -371,6 +371,7 @@ const rootSchema = ` bulkOptOut(organizationId: String!, csvFile: Upload, numbersList: String): Int! bulkOptIn(organizationId: String!, csvFile: Upload, numbersList: String): Int! exportOptOuts(organizationId: String!, campaignIds: [String!]): Boolean! + markForManualReply(campaignContactId: String!): CampaignContact! } schema { diff --git a/libs/spoke-codegen/src/graphql/message-sending.graphql b/libs/spoke-codegen/src/graphql/message-sending.graphql index 0c44b900b..b2565cb5b 100644 --- a/libs/spoke-codegen/src/graphql/message-sending.graphql +++ b/libs/spoke-codegen/src/graphql/message-sending.graphql @@ -12,3 +12,10 @@ mutation SendMessage($message: MessageInput!, $campaignContactId: String!) { } } } + +mutation MarkForManualReply($campaignContactId: String!) { + markForManualReply(campaignContactId: $campaignContactId) { + id + autoReplyEligible + } +} diff --git a/migrations/20230806003928_support-auto-replies.js b/migrations/20230806003928_support-auto-replies.js new file mode 100644 index 000000000..c4fb1534d --- /dev/null +++ b/migrations/20230806003928_support-auto-replies.js @@ -0,0 +1,73 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function up(knex) { + return knex.schema + .createTable("auto_reply_trigger", (table) => { + table.increments("id"); + table.text("token").notNullable(); + table.integer("interaction_step_id").notNullable(); + table.foreign("interaction_step_id").references("interaction_step.id"); + table.timestamp("created_at").notNull().defaultTo(knex.fn.now()); + table.timestamp("updated_at").notNull().defaultTo(knex.fn.now()); + table.unique(["interaction_step_id", "token"]); + }) + .raw( + ` + create or replace function auto_reply_trigger_before_insert() returns trigger as $$ + begin + if exists( + select 1 from auto_reply_trigger + where token = NEW.token + and interaction_step_id in ( + select id from interaction_step child_steps + where parent_interaction_id = ( + select parent_interaction_id from interaction_step + where id = NEW.interaction_step_id + ) + ) + and interaction_step_id <> NEW.id + ) then + raise exception 'Each interaction step can only have 1 child step assigned to any particular auto reply token'; + end if; + + return NEW; + end; + $$ language plpgsql; + + create trigger _500_auto_reply_trigger_insert + before insert + on auto_reply_trigger + for each row + execute procedure auto_reply_trigger_before_insert(); + + create trigger _500_auto_reply_trigger_updated_at + before update + on public.auto_reply_trigger + for each row + execute procedure universal_updated_at(); + ` + ) + .alterTable("campaign_contact", (table) => { + table.boolean("auto_reply_eligible").notNullable().defaultTo(false); + }) + .alterTable("campaign_contact", (table) => { + table + .boolean("auto_reply_eligible") + .notNullable() + .defaultTo(true) + .alter(); + }); +}; +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function down(knex) { + return knex.schema + .dropTable("auto_reply_trigger") + .alterTable("campaign_contact", (table) => { + table.dropColumn("auto_reply_eligible"); + }); +}; diff --git a/migrations/20230811182109_auto-replies-autoassignment.js b/migrations/20230811182109_auto-replies-autoassignment.js new file mode 100644 index 000000000..f6a16689f --- /dev/null +++ b/migrations/20230811182109_auto-replies-autoassignment.js @@ -0,0 +1,234 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function up(knex) { + return knex.schema.raw(` + drop view assignable_campaign_contacts cascade; + + create or replace view assignable_campaign_contacts as ( + select + campaign_contact.id, campaign_contact.campaign_id, + campaign_contact.message_status, campaign.texting_hours_end, + campaign_contact.timezone::text as contact_timezone + from campaign_contact + join campaign on campaign_contact.campaign_id = campaign.id + where assignment_id is null + and auto_reply_eligible = false + and is_opted_out = false + and archived = false + and not exists ( + select 1 + from campaign_contact_tag + join tag on campaign_contact_tag.tag_id = tag.id + where tag.is_assignable = false + and campaign_contact_tag.campaign_contact_id = campaign_contact.id + ) + ); + + create or replace view assignable_needs_message as ( + select acc.id, acc.campaign_id, acc.message_status + from assignable_campaign_contacts as acc + join campaign on campaign.id = acc.campaign_id + where message_status = 'needsMessage' + and ( + ( + acc.contact_timezone is null + and extract(hour from CURRENT_TIMESTAMP at time zone campaign.timezone) < campaign.texting_hours_end + and extract(hour from CURRENT_TIMESTAMP at time zone campaign.timezone) >= campaign.texting_hours_start + ) + or + ( + campaign.texting_hours_end > extract(hour from (CURRENT_TIMESTAMP at time zone acc.contact_timezone) + interval '10 minutes') + and campaign.texting_hours_start <= extract(hour from (CURRENT_TIMESTAMP at time zone acc.contact_timezone)) + ) + ) + ); + + create or replace view assignable_needs_reply as ( + select acc.id, acc.campaign_id, acc.message_status + from assignable_campaign_contacts as acc + join campaign on campaign.id = acc.campaign_id + where message_status = 'needsResponse' + and ( + ( + acc.contact_timezone is null + and extract(hour from CURRENT_TIMESTAMP at time zone campaign.timezone) < campaign.texting_hours_end + and extract(hour from CURRENT_TIMESTAMP at time zone campaign.timezone) >= campaign.texting_hours_start + ) + or + ( + campaign.texting_hours_end > extract(hour from (CURRENT_TIMESTAMP at time zone acc.contact_timezone) + interval '10 minutes') + and campaign.texting_hours_start <= extract(hour from (CURRENT_TIMESTAMP at time zone acc.contact_timezone)) + ) + ) + ); + + create or replace view assignable_needs_reply_with_escalation_tags as ( + select acc.id, acc.campaign_id, acc.message_status, acc.applied_escalation_tags + from assignable_campaign_contacts_with_escalation_tags as acc + join campaign on campaign.id = acc.campaign_id + where message_status = 'needsResponse' + and ( + ( + acc.contact_timezone is null + and extract(hour from CURRENT_TIMESTAMP at time zone campaign.timezone) < campaign.texting_hours_end + and extract(hour from CURRENT_TIMESTAMP at time zone campaign.timezone) >= campaign.texting_hours_start + ) + or + ( + campaign.texting_hours_end > extract(hour from (CURRENT_TIMESTAMP at time zone acc.contact_timezone) + interval '10 minutes') + and campaign.texting_hours_start <= extract(hour from (CURRENT_TIMESTAMP at time zone acc.contact_timezone)) + ) + ) + ); + + create or replace view assignable_campaigns_with_needs_message as ( + select * + from assignable_campaigns + where + exists ( + select 1 + from assignable_needs_message + where campaign_id = assignable_campaigns.id + ) + and not exists ( + select 1 + from campaign + where campaign.id = assignable_campaigns.id + and now() > date_trunc('day', (due_by + interval '24 hours') at time zone campaign.timezone) + ) + and autosend_status <> 'sending' + ); + + create or replace view assignable_campaigns_with_needs_reply as ( + select * + from assignable_campaigns + where exists ( + select 1 + from assignable_needs_reply + where campaign_id = assignable_campaigns.id + ) + ); + + drop index todos_partial_idx; + create index todos_partial_idx on campaign_contact (campaign_id, assignment_id, message_status, is_opted_out, auto_reply_eligible) where (archived = false); + `); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function down(knex) { + return knex.schema.raw(` + drop view assignable_campaign_contacts cascade; + + create or replace view assignable_campaign_contacts as ( + select + campaign_contact.id, campaign_contact.campaign_id, + campaign_contact.message_status, campaign.texting_hours_end, + campaign_contact.timezone::text as contact_timezone + from campaign_contact + join campaign on campaign_contact.campaign_id = campaign.id + where assignment_id is null + and is_opted_out = false + and archived = false + and not exists ( + select 1 + from campaign_contact_tag + join tag on campaign_contact_tag.tag_id = tag.id + where tag.is_assignable = false + and campaign_contact_tag.campaign_contact_id = campaign_contact.id + ) + ); + + create or replace view assignable_needs_message as ( + select acc.id, acc.campaign_id, acc.message_status + from assignable_campaign_contacts as acc + join campaign on campaign.id = acc.campaign_id + where message_status = 'needsMessage' + and ( + ( + acc.contact_timezone is null + and extract(hour from CURRENT_TIMESTAMP at time zone campaign.timezone) < campaign.texting_hours_end + and extract(hour from CURRENT_TIMESTAMP at time zone campaign.timezone) >= campaign.texting_hours_start + ) + or + ( + campaign.texting_hours_end > extract(hour from (CURRENT_TIMESTAMP at time zone acc.contact_timezone) + interval '10 minutes') + and campaign.texting_hours_start <= extract(hour from (CURRENT_TIMESTAMP at time zone acc.contact_timezone)) + ) + ) + ); + + create or replace view assignable_needs_reply as ( + select acc.id, acc.campaign_id, acc.message_status + from assignable_campaign_contacts as acc + join campaign on campaign.id = acc.campaign_id + where message_status = 'needsResponse' + and ( + ( + acc.contact_timezone is null + and extract(hour from CURRENT_TIMESTAMP at time zone campaign.timezone) < campaign.texting_hours_end + and extract(hour from CURRENT_TIMESTAMP at time zone campaign.timezone) >= campaign.texting_hours_start + ) + or + ( + campaign.texting_hours_end > extract(hour from (CURRENT_TIMESTAMP at time zone acc.contact_timezone) + interval '10 minutes') + and campaign.texting_hours_start <= extract(hour from (CURRENT_TIMESTAMP at time zone acc.contact_timezone)) + ) + ) + ); + + create or replace view assignable_needs_reply_with_escalation_tags as ( + select acc.id, acc.campaign_id, acc.message_status, acc.applied_escalation_tags + from assignable_campaign_contacts_with_escalation_tags as acc + join campaign on campaign.id = acc.campaign_id + where message_status = 'needsResponse' + and ( + ( + acc.contact_timezone is null + and extract(hour from CURRENT_TIMESTAMP at time zone campaign.timezone) < campaign.texting_hours_end + and extract(hour from CURRENT_TIMESTAMP at time zone campaign.timezone) >= campaign.texting_hours_start + ) + or + ( + campaign.texting_hours_end > extract(hour from (CURRENT_TIMESTAMP at time zone acc.contact_timezone) + interval '10 minutes') + and campaign.texting_hours_start <= extract(hour from (CURRENT_TIMESTAMP at time zone acc.contact_timezone)) + ) + ) + ); + + create or replace view assignable_campaigns_with_needs_message as ( + select * + from assignable_campaigns + where + exists ( + select 1 + from assignable_needs_message + where campaign_id = assignable_campaigns.id + ) + and not exists ( + select 1 + from campaign + where campaign.id = assignable_campaigns.id + and now() > date_trunc('day', (due_by + interval '24 hours') at time zone campaign.timezone) + ) + and autosend_status <> 'sending' + ); + + create or replace view assignable_campaigns_with_needs_reply as ( + select * + from assignable_campaigns + where exists ( + select 1 + from assignable_needs_reply + where campaign_id = assignable_campaigns.id + ) + ); + + drop index todos_partial_idx; + create index todos_partial_idx on campaign_contact (campaign_id, assignment_id, message_status, is_opted_out) where (archived = false); + `); +}; diff --git a/src/components/forms/GSAutoReplyTokensField.tsx b/src/components/forms/GSAutoReplyTokensField.tsx new file mode 100644 index 000000000..1b927f7c8 --- /dev/null +++ b/src/components/forms/GSAutoReplyTokensField.tsx @@ -0,0 +1,109 @@ +import uniqBy from "lodash/uniqBy"; +import type { KeyboardEventHandler } from "react"; +import React, { useEffect, useRef, useState } from "react"; +import CreatableSelect from "react-select/creatable"; + +import { optOutTriggers } from "../../lib/opt-out-triggers"; +import type { GSFormFieldProps } from "./GSFormField"; + +interface GSAutoReplyTokensFieldProps extends GSFormFieldProps { + selectedOptions?: Option[]; + disabled: boolean; + onChange: (...args: any[]) => any; +} + +interface Option { + readonly label: string; + readonly value: string; +} + +const createOption = (label: string) => ({ + label, + value: label +}); + +const GSAutoReplyTokensField: React.FC = ({ + disabled, + onChange, + value: selectedOptions +}) => { + const initialOptionValue = selectedOptions.map((token: string) => + createOption(token) + ); + const [optionValue, setOptionValue] = useState( + initialOptionValue + ); + + const [inputValue, setInputValue] = useState(""); + const onChangeValue = optionValue.map((option) => option.value); + const initialRender = useRef(true); + + useEffect(() => { + if (initialRender.current) initialRender.current = false; + else onChange(onChangeValue); + }, [optionValue]); + + const handleInputChange = (newValue: string) => { + if (newValue.includes(",")) { + const splitTokens = newValue.split(","); + const tokenValues = splitTokens.map((token: string) => + createOption(token) + ); + + const newOptionsValue = [...optionValue, ...tokenValues]; + const uniqValue = uniqBy(newOptionsValue, "value"); + setOptionValue(uniqValue); + } else setInputValue(newValue); + }; + + const handleChange = (newValue: Option[]) => { + setOptionValue(newValue); + }; + + const handleKeyDown: KeyboardEventHandler = (event) => { + if (!inputValue) return; + const lowerInputValue = inputValue.toLowerCase().trim(); + + if (optOutTriggers.includes(lowerInputValue)) { + setInputValue(""); + return; + } + switch (event.key) { + /* eslint-disable no-case-declarations */ + case ",": + case "Enter": + case "Tab": + const newValue = [...optionValue, createOption(lowerInputValue)]; + const uniqValue = uniqBy(newValue, "value"); + + setInputValue(""); + setOptionValue(uniqValue); + event.preventDefault(); + /* eslint-enable no-case-declarations */ + // no default + } + }; + + return ( +
+
+ Auto Replies + +
+ ); +}; + +export default GSAutoReplyTokensField; diff --git a/src/components/forms/SpokeFormField.tsx b/src/components/forms/SpokeFormField.tsx index 8da090640..dd2621f19 100644 --- a/src/components/forms/SpokeFormField.tsx +++ b/src/components/forms/SpokeFormField.tsx @@ -1,6 +1,7 @@ import React from "react"; import BaseForm from "react-formal"; +import GSAutoReplyTokensField from "./GSAutoReplyTokensField"; import GSDateField from "./GSDateField"; import GSPasswordField from "./GSPasswordField"; import GSScriptField from "./GSScriptField"; @@ -51,6 +52,8 @@ const SpokeFormField = React.forwardRef(function Component( Input = GSSelectField; } else if (type === "password") { Input = GSPasswordField; + } else if (type === "autoreplytokens") { + Input = GSAutoReplyTokensField; } else { Input = type || GSTextField; } diff --git a/src/config.js b/src/config.js index b9cf1ec49..b86041a83 100644 --- a/src/config.js +++ b/src/config.js @@ -115,6 +115,11 @@ const validators = { default: false, isClient: true }), + ENABLE_AUTO_REPLIES: bool({ + desc: "Whether auto reply handling is enabled", + default: false, + isClient: true + }), DISABLE_ASSIGNMENT_CASCADE: bool({ desc: "Whether to just assign from 1 campaign rather than gathering from multiple to fulfill a request", diff --git a/src/containers/AdminCampaignEdit/sections/CampaignInteractionStepsForm/components/InteractionStepCard.tsx b/src/containers/AdminCampaignEdit/sections/CampaignInteractionStepsForm/components/InteractionStepCard.tsx index af01d3406..51c9e56cc 100644 --- a/src/containers/AdminCampaignEdit/sections/CampaignInteractionStepsForm/components/InteractionStepCard.tsx +++ b/src/containers/AdminCampaignEdit/sections/CampaignInteractionStepsForm/components/InteractionStepCard.tsx @@ -11,15 +11,14 @@ import DeleteIcon from "@material-ui/icons/Delete"; import ExpandLess from "@material-ui/icons/ExpandLess"; import ExpandMore from "@material-ui/icons/ExpandMore"; import HelpIconOutline from "@material-ui/icons/HelpOutline"; -import type { CampaignVariable } from "@spoke/spoke-codegen"; +import type { + CampaignVariable, + InteractionStepWithChildren +} from "@spoke/spoke-codegen"; import isNil from "lodash/isNil"; import React, { useCallback, useState } from "react"; import * as yup from "yup"; -import type { - InteractionStep, - InteractionStepWithChildren -} from "../../../../../api/interaction-step"; import { supportsClipboard } from "../../../../../client/lib"; import GSForm from "../../../../../components/forms/GSForm"; import SpokeFormField from "../../../../../components/forms/SpokeFormField"; @@ -47,7 +46,8 @@ const interactionStepSchema = yup.object({ scriptOptions: yup.array(yup.string()), questionText: yup.string(), answerOption: yup.string(), - answerActions: yup.string() + answerActions: yup.string(), + autoReplyTokens: yup.array(yup.string()) }); type BlockHandlerFactory = (stepId: string) => () => Promise | void; @@ -62,7 +62,7 @@ interface Props { title?: string; disabled?: boolean; onFormChange(e: any): void; - onCopyBlock(interactionStep: InteractionStep): void; + onCopyBlock(interactionStep: InteractionStepWithChildren): void; onRequestRootPaste(): void; deleteStepFactory: BlockHandlerFactory; addStepFactory: BlockHandlerFactory; @@ -111,7 +111,7 @@ export const InteractionStepCard: React.FC = (props) => { const stepCanHaveChildren = isRootStep || answerOption; const isAbleToAddResponse = stepHasQuestion && stepHasScript && stepCanHaveChildren; - const childStepsLength = childSteps?.length; + const childStepsLength = childSteps?.length ?? 0; const clipboardEnabled = supportsClipboard(); @@ -251,6 +251,13 @@ export const InteractionStepCard: React.FC = (props) => { multiLine disabled={disabled} /> + {window.ENABLE_AUTO_REPLIES && parentInteractionId && ( + + )} = (props) => { )} {expanded && (childSteps ?? []) - .filter((is) => !is.isDeleted) - .map((childStep) => ( - - ))} + .filter((is) => !is?.isDeleted) + .map((childStep) => { + if (childStep) { + const { __typename, ...childStepWithoutTypename } = childStep; + return ( + + ); + } + return null; + })} ); diff --git a/src/containers/AdminCampaignEdit/sections/CampaignInteractionStepsForm/index.tsx b/src/containers/AdminCampaignEdit/sections/CampaignInteractionStepsForm/index.tsx index c1925e06d..7238ff0a1 100644 --- a/src/containers/AdminCampaignEdit/sections/CampaignInteractionStepsForm/index.tsx +++ b/src/containers/AdminCampaignEdit/sections/CampaignInteractionStepsForm/index.tsx @@ -6,18 +6,18 @@ import Dialog from "@material-ui/core/Dialog"; import DialogActions from "@material-ui/core/DialogActions"; import DialogContent from "@material-ui/core/DialogContent"; import DialogContentText from "@material-ui/core/DialogContentText"; -import type { CampaignVariablePage } from "@spoke/spoke-codegen"; +import type { + Action, + Campaign, + CampaignVariablePage, + InteractionStep, + InteractionStepWithChildren +} from "@spoke/spoke-codegen"; import produce from "immer"; import isEqual from "lodash/isEqual"; import React, { useEffect, useState } from "react"; import { compose } from "recompose"; -import type { Campaign } from "../../../../api/campaign"; -import type { - InteractionStep, - InteractionStepWithChildren -} from "../../../../api/interaction-step"; -import type { Action } from "../../../../api/types"; import { readClipboardText, writeClipboardText } from "../../../../client/lib"; import ScriptPreviewButton from "../../../../components/ScriptPreviewButton"; import { dataTest } from "../../../../lib/attributes"; @@ -43,7 +43,7 @@ import { generateId, GET_CAMPAIGN_INTERACTIONS } from "./resolvers"; -import { isBlock } from "./utils"; +import { hasDuplicateTriggerError, isBlock } from "./utils"; const DEFAULT_EMPTY_STEP_ID = "DEFAULT_EMPTY_STEP_ID"; @@ -69,7 +69,11 @@ interface HocProps { data: { campaign: Pick< Campaign, - "id" | "isStarted" | "customFields" | "externalSystem" + | "id" + | "isStarted" + | "customFields" + | "externalSystem" + | "invalidScriptFields" > & { interactionSteps: InteractionStepWithLocalState[]; campaignVariables: CampaignVariablePage; @@ -104,7 +108,7 @@ const CampaignInteractionStepsForm: React.FC = (props) => { const hasEmptyScript = (step: InteractionStep) => { const hasNoOptions = step.scriptOptions.length === 0; const hasEmptyScriptOption = - step.scriptOptions.find((version) => version.trim() === "") !== + step.scriptOptions.find((version) => version?.trim() === "") !== undefined; return hasNoOptions || hasEmptyScriptOption; }; @@ -159,8 +163,14 @@ const CampaignInteractionStepsForm: React.FC = (props) => { const response = await props.mutations.editCampaign({ interactionSteps }); - if (response.errors) throw response.errors; - } catch (err) { + if (response.errors) { + if (hasDuplicateTriggerError(response.errors)) { + throw new Error( + "Please double check your auto reply tokens! Each interaction step can only have 1 child step assigned to any particular auto reply token!" + ); + } else throw response.errors; + } + } catch (err: any) { props.onError(err.message); } finally { setIsWorking(false); @@ -227,11 +237,17 @@ const CampaignInteractionStepsForm: React.FC = (props) => { id: generateId() }); } - const { answerOption, questionText, scriptOptions } = changedStep; + const { + answerOption, + questionText, + scriptOptions, + autoReplyTokens + } = changedStep; props.mutations.stageUpdateInteractionStep(changedStep.id, { answerOption, questionText, - scriptOptions + scriptOptions, + autoReplyTokens }); }; @@ -255,14 +271,15 @@ const CampaignInteractionStepsForm: React.FC = (props) => { while (interactionStepsAdded !== 0) { interactionStepsAdded = 0; - for (const is of interactionSteps) { + for (const step of interactionSteps) { if ( - !interactionStepsInBlock.has(is.id) && - is.parentInteractionId && - interactionStepsInBlock.has(is.parentInteractionId) + !interactionStepsInBlock.has(step.id) && + step.parentInteractionId && + interactionStepsInBlock.has(step.parentInteractionId) ) { - block.push(is); - interactionStepsInBlock.add(is.id); + const { __typename, ...stepWithoutTypename } = step; + block.push(stepWithoutTypename); + interactionStepsInBlock.add(step.id); interactionStepsAdded += 1; } } @@ -302,16 +319,20 @@ const CampaignInteractionStepsForm: React.FC = (props) => { stripLocals: true }); + const stringCustomFields = customFields as string[]; + const invalidCampaignVariables = interactionSteps.reduce>( (acc, step) => { let result = acc; for (const scriptOption of step.scriptOptions) { - const { invalidCampaignVariablesUsed } = scriptToTokens({ - script: scriptOption ?? "", - customFields, - campaignVariables - }); - result = result.concat(invalidCampaignVariablesUsed); + if (customFields) { + const { invalidCampaignVariablesUsed } = scriptToTokens({ + script: scriptOption ?? "", + customFields: stringCustomFields, + campaignVariables + }); + result = result.concat(invalidCampaignVariablesUsed); + } } return result; }, @@ -341,25 +362,27 @@ const CampaignInteractionStepsForm: React.FC = (props) => { const campaignId = props.data?.campaign?.id; const renderInvalidScriptFields = () => { - if (invalidScriptFields.length === 0) { - return null; + if (invalidScriptFields) { + if (invalidScriptFields.length === 0) { + return null; + } + const invalidFields = invalidCampaignVariables.concat( + invalidScriptFields.map((field: string) => `{${field}}`) + ); + return ( +
+

+ Warning: Variable values are not all present for this script. You + can continue working on your script but you cannot start this + campaign. The following variables do not have values and will not + populate in your script: +

+

+ {invalidFields.join(", ")} +

+
+ ); } - const invalidFields = invalidCampaignVariables.concat( - invalidScriptFields.map((field: string) => `{${field}}`) - ); - return ( -
-

- Warning: Variable values are not all present for this script. You can - continue working on your script but you cannot start this campaign. - The following variables do not have values and will not populate in - your script: -

-

- {invalidFields.join(", ")} -

-
- ); }; return ( @@ -395,7 +418,7 @@ const CampaignInteractionStepsForm: React.FC = (props) => { {renderInvalidScriptFields()} = { variables }); const data = produce(old, (draft: any) => { - draft.campaign.interactionSteps = editCampaign.interactionSteps.map( + draft.campaign.interactionSteps = editCampaign?.interactionSteps.map( (step: InteractionStepWithLocalState) => ({ ...step, isModified: false @@ -524,6 +547,7 @@ const mutations: MutationMap = { $questionText: String $scriptOptions: [String] $answerOption: String + $autoReplyTokens: [String] ) { stageAddInteractionStep( campaignId: $campaignId @@ -532,6 +556,7 @@ const mutations: MutationMap = { questionText: $questionText scriptOptions: $scriptOptions answerOption: $answerOption + autoReplyTokens: $autoReplyTokens ) @client } `, @@ -547,12 +572,14 @@ const mutations: MutationMap = { $questionText: String $scriptOptions: [String] $answerOption: String + $autoReplyTokens: [String] ) { stageUpdateInteractionStep( iStepId: $iStepId questionText: $questionText scriptOptions: $scriptOptions answerOption: $answerOption + autoReplyTokens: $autoReplyTokens ) @client } `, diff --git a/src/containers/AdminCampaignEdit/sections/CampaignInteractionStepsForm/resolvers.ts b/src/containers/AdminCampaignEdit/sections/CampaignInteractionStepsForm/resolvers.ts index 167d120b9..00c9e94a8 100644 --- a/src/containers/AdminCampaignEdit/sections/CampaignInteractionStepsForm/resolvers.ts +++ b/src/containers/AdminCampaignEdit/sections/CampaignInteractionStepsForm/resolvers.ts @@ -1,8 +1,8 @@ import type { Resolver, Resolvers } from "@apollo/client"; import { gql } from "@apollo/client"; +import type { InteractionStep } from "@spoke/spoke-codegen"; import produce from "immer"; -import type { InteractionStep } from "../../../../api/interaction-step"; import { DateTime } from "../../../../lib/datetime"; import type { LocalResolverContext } from "../../../../network/types"; @@ -20,6 +20,7 @@ export const EditInteractionStepFragment = gql` answerActions parentInteractionId isDeleted + autoReplyTokens isModified @client } `; @@ -108,6 +109,7 @@ export type AddInteractionStepPayload = Partial< | "answerActions" | "questionText" | "scriptOptions" + | "autoReplyTokens" > >; @@ -134,6 +136,7 @@ export const stageAddInteractionStep: Resolver = ( scriptOptions: payload.scriptOptions ?? [""], answerOption: payload.answerOption ?? "", answerActions: payload.answerActions ?? "", + autoReplyTokens: payload.autoReplyTokens ?? [], isDeleted: false, isModified: true, createdAt: DateTime.local().toISO() @@ -148,7 +151,10 @@ export const stageAddInteractionStep: Resolver = ( }; export type UpdateInteractionStepPayload = Partial< - Pick + Pick< + InteractionStep, + "answerOption" | "questionText" | "scriptOptions" | "autoReplyTokens" + > >; export interface StageUpdateInteractionStepVars @@ -161,6 +167,7 @@ const EditableIStepFragment = gql` questionText scriptOptions answerOption + autoReplyTokens isModified @client } `; diff --git a/src/containers/AdminCampaignEdit/sections/CampaignInteractionStepsForm/utils.ts b/src/containers/AdminCampaignEdit/sections/CampaignInteractionStepsForm/utils.ts index a023f3abd..a2c0e69bd 100644 --- a/src/containers/AdminCampaignEdit/sections/CampaignInteractionStepsForm/utils.ts +++ b/src/containers/AdminCampaignEdit/sections/CampaignInteractionStepsForm/utils.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +import type { GraphQLError } from "graphql"; export const isBlock = (text: string) => { try { @@ -8,3 +8,14 @@ export const isBlock = (text: string) => { return false; } }; + +export const hasDuplicateTriggerError = ( + errors: Error | readonly GraphQLError[] +) => { + return ( + Array.isArray(errors) && + errors[0].message.includes( + "Each interaction step can only have 1 child step assigned to any particular auto reply token" + ) + ); +}; diff --git a/src/containers/AdminIncomingMessageList/components/IncomingMessageList/MessageColumn/MessageResponse.tsx b/src/containers/AdminIncomingMessageList/components/IncomingMessageList/MessageColumn/MessageResponse.tsx index 314d9df4d..1d141460c 100644 --- a/src/containers/AdminIncomingMessageList/components/IncomingMessageList/MessageColumn/MessageResponse.tsx +++ b/src/containers/AdminIncomingMessageList/components/IncomingMessageList/MessageColumn/MessageResponse.tsx @@ -4,13 +4,14 @@ import DialogActions from "@material-ui/core/DialogActions"; import DialogContent from "@material-ui/core/DialogContent"; import DialogContentText from "@material-ui/core/DialogContentText"; import DialogTitle from "@material-ui/core/DialogTitle"; -import { useSendMessageMutation } from "@spoke/spoke-codegen"; +import type { Conversation, Message, MessageInput } from "@spoke/spoke-codegen"; +import { + useMarkForManualReplyMutation, + useSendMessageMutation +} from "@spoke/spoke-codegen"; import React, { useState } from "react"; import * as yup from "yup"; -import type { Conversation } from "../../../../../api/conversations"; -import type { Message } from "../../../../../api/message"; -import type { MessageInput } from "../../../../../api/types"; import GSForm from "../../../../../components/forms/GSForm"; import SpokeFormField from "../../../../../components/forms/SpokeFormField"; import MessageLengthInfo from "../../../../../components/MessageLengthInfo"; @@ -30,6 +31,8 @@ const messageSchema = yup.object({ .max(window.MAX_MESSAGE_LENGTH) }); +type MessageFormValue = { messageText: string }; + const MessageResponse: React.FC = ({ conversation, value, @@ -41,6 +44,7 @@ const MessageResponse: React.FC = ({ const [messageForm, setMessageForm] = useState(null); const [sendMessage] = useSendMessageMutation(); + const [markForManualReply] = useMarkForManualReplyMutation(); const createMessageToContact = (text: string) => { const { contact, texter } = conversation; @@ -65,8 +69,12 @@ const MessageResponse: React.FC = ({ setIsSending(true); try { + const campaignContactId = contact.id as string; const { data, errors } = await sendMessage({ - variables: { message, campaignContactId: contact.id as string } + variables: { message, campaignContactId } + }); + await markForManualReply({ + variables: { campaignContactId } }); const messages = data?.sendMessage?.messages; diff --git a/src/global.d.ts b/src/global.d.ts index 560f0a41a..f9451ead2 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -16,6 +16,7 @@ interface Window { BASE_URL: string; ENABLE_TROLLBOT: boolean; SHOW_10DLC_REGISTRATION_NOTICES: boolean; + ENABLE_AUTO_REPLIES: boolean; AuthService: any; } diff --git a/src/lib/opt-out-triggers.ts b/src/lib/opt-out-triggers.ts new file mode 100644 index 000000000..15b6a676c --- /dev/null +++ b/src/lib/opt-out-triggers.ts @@ -0,0 +1,18 @@ +export const optOutTriggers: string[] = [ + "stop", + "stop all", + "stopall", + "unsub", + "unsubscribe", + "cancel", + "end", + "quit", + "stop2quit", + "stop 2 quit", + "stop=quit", + "stop = quit", + "stop to quit", + "stoptoquit" +]; + +export default optOutTriggers; diff --git a/src/schema.graphql b/src/schema.graphql index 1976af0c9..fcf390231 100644 --- a/src/schema.graphql +++ b/src/schema.graphql @@ -337,6 +337,7 @@ type RootMutation { bulkOptOut(organizationId: String!, csvFile: Upload, numbersList: String): Int! bulkOptIn(organizationId: String!, csvFile: Upload, numbersList: String): Int! exportOptOuts(organizationId: String!, campaignIds: [String!]): Boolean! + markForManualReply(campaignContactId: String!): CampaignContact! } schema { @@ -784,6 +785,7 @@ type InteractionStep { scriptOptions: [String]! answerOption: String parentInteractionId: String + autoReplyTokens: [String] isDeleted: Boolean answerActions: String questionResponse(campaignContactId: String): QuestionResponse @@ -796,12 +798,28 @@ input InteractionStepInput { scriptOptions: [String]! answerOption: String answerActions: String + autoReplyTokens: [String] parentInteractionId: String isDeleted: Boolean createdAt: Date interactionSteps: [InteractionStepInput] } +type InteractionStepWithChildren { + id: ID! + question: Question + questionText: String + scriptOptions: [String]! + answerOption: String + parentInteractionId: String + autoReplyTokens: [String] + isDeleted: Boolean + answerActions: String + questionResponse(campaignContactId: String): QuestionResponse + createdAt: Date! + interactionSteps: [InteractionStep] +} + type OptOut { @@ -939,6 +957,8 @@ type CampaignContact { messageStatus: String! assignmentId: String updatedAt: Date! + autoReplyEligible: Boolean! + tags: [CampaignContactTag!]! } diff --git a/src/server/api/interaction-step.js b/src/server/api/interaction-step.js index 6a5ff68cb..749c00705 100644 --- a/src/server/api/interaction-step.js +++ b/src/server/api/interaction-step.js @@ -27,7 +27,14 @@ export const resolvers = { interaction_step_id: interactionStep.id }) .first() - .then((qr) => qr || null) + .then((qr) => qr || null), + autoReplyTokens: async (interactionStep) => + r + .reader("auto_reply_trigger") + .where({ + interaction_step_id: interactionStep.id + }) + .pluck("token") } }; diff --git a/src/server/api/lib/campaign.ts b/src/server/api/lib/campaign.ts index b43315f7c..77c045716 100644 --- a/src/server/api/lib/campaign.ts +++ b/src/server/api/lib/campaign.ts @@ -24,6 +24,7 @@ import { cacheableData, datawarehouse, r } from "../../models"; import { addAssignTexters } from "../../tasks/assign-texters"; import { accessRequired } from "../errors"; import type { + AutoReplyTriggerRecord, CampaignRecord, InteractionStepRecord, UserRecord @@ -267,27 +268,44 @@ export const copyCampaign = async (options: CopyCampaignOptions) => { } // Copy interactions - const interactions = await trx("interaction_step") - .where({ - campaign_id: campaignId, - is_deleted: false - }) - .then((interactionSteps) => - interactionSteps.map( - (interactionStep) => ({ - id: `new${interactionStep.id}`, - questionText: interactionStep.question, - scriptOptions: interactionStep.script_options, - answerOption: interactionStep.answer_option, - answerActions: interactionStep.answer_actions, - isDeleted: interactionStep.is_deleted, - campaign_id: newCampaign.id, - parentInteractionId: interactionStep.parent_interaction_id - ? `new${interactionStep.parent_interaction_id}` - : interactionStep.parent_interaction_id - }) - ) - ); + const campaignInteractionStepRecords = await trx( + "interaction_step" + ).where({ + campaign_id: campaignId, + is_deleted: false + }); + + const triggers: AutoReplyTriggerRecord[] = await r + .knex("auto_reply_trigger") + .join( + "interaction_step", + "auto_reply_trigger.interaction_step_id", + "interaction_step.id" + ) + .where({ campaign_id: campaignId }); + + const campaignInteractionSteps = campaignInteractionStepRecords.map( + (step) => { + const stepTokens = triggers + .filter((trigger) => trigger.interaction_step_id === step.id) + .map((trigger) => trigger.token); + return { ...step, autoReplyTokens: stepTokens }; + } + ); + + const interactions = campaignInteractionSteps.map((interactionStep) => ({ + id: `new${interactionStep.id}`, + questionText: interactionStep.question, + scriptOptions: interactionStep.script_options, + answerOption: interactionStep.answer_option, + answerActions: interactionStep.answer_actions, + isDeleted: interactionStep.is_deleted, + campaign_id: newCampaign.id, + parentInteractionId: interactionStep.parent_interaction_id + ? `new${interactionStep.parent_interaction_id}` + : interactionStep.parent_interaction_id, + autoReplyTokens: interactionStep.autoReplyTokens + })); if (interactions.length > 0) { await persistInteractionStepTree( diff --git a/src/server/api/lib/interaction-steps.spec.ts b/src/server/api/lib/interaction-steps.spec.ts index 23e0f16c3..8b190e7b4 100644 --- a/src/server/api/lib/interaction-steps.spec.ts +++ b/src/server/api/lib/interaction-steps.spec.ts @@ -1,3 +1,4 @@ +import type { InteractionStepWithChildren } from "@spoke/spoke-codegen"; import { Pool } from "pg"; import { @@ -6,7 +7,6 @@ import { createInteractionStep, createQuestionResponse } from "../../../../__test__/testbed-preparation/core"; -import type { InteractionStepWithChildren } from "../../../api/interaction-step"; import { config } from "../../../config"; import { withClient } from "../../utils"; import type { InteractionStepRecord } from "../types"; diff --git a/src/server/api/lib/interaction-steps.ts b/src/server/api/lib/interaction-steps.ts index 2b6980cb8..5db78fc30 100644 --- a/src/server/api/lib/interaction-steps.ts +++ b/src/server/api/lib/interaction-steps.ts @@ -1,10 +1,23 @@ /* eslint-disable import/prefer-default-export */ +import type { InteractionStepWithChildren } from "@spoke/spoke-codegen"; import type { Knex } from "knex"; -import type { InteractionStepWithChildren } from "../../../api/interaction-step"; import { r } from "../../models"; import type { CampaignRecord } from "../types"; +const mapTokensToTriggers = (tokens: string[], stepId: number) => { + return tokens.map((token: string) => { + return { + interaction_step_id: stepId, + token + }; + }); +}; + +const removeMatchingTokens = (tokens1: string[], tokens2: string[]) => { + return tokens1.filter((token: string) => !tokens2.includes(token)); +}; + export const persistInteractionStepNode = async ( campaignId: number, rootInteractionStep: InteractionStepWithChildren, @@ -19,6 +32,7 @@ export const persistInteractionStepNode = async ( // Update the parent interaction step ID if this step has a reference to a temporary ID // and the parent has since been inserted const { parentInteractionId } = rootInteractionStep; + if (parentInteractionId && temporaryIdMap[parentInteractionId]) { rootInteractionStep.parentInteractionId = temporaryIdMap[parentInteractionId]; @@ -31,6 +45,8 @@ export const persistInteractionStepNode = async ( answer_actions: rootInteractionStep.answerActions }; + const tokens = rootInteractionStep.autoReplyTokens as string[]; + if (rootInteractionStep.id.indexOf("new") !== -1) { // Insert new interaction steps const [{ id: newId }] = await knexTrx("interaction_step") @@ -46,24 +62,63 @@ export const persistInteractionStepNode = async ( temporaryIdMap[rootInteractionStep.id] = newId; rootStepId = newId; + + if (tokens?.length) { + const triggers = mapTokensToTriggers(tokens, newId); + await knexTrx("auto_reply_trigger").insert(triggers); + } } else { // Update the interaction step record await knexTrx("interaction_step") .where({ id: rootInteractionStep.id }) .update(payload) .returning("id"); + + const existingTokens = await r + .reader("auto_reply_trigger") + .where({ interaction_step_id: rootInteractionStep.id }) + .pluck("token"); + + if (tokens && existingTokens) { + const tokensToInsert = removeMatchingTokens(tokens, existingTokens); + const triggersToInsert = mapTokensToTriggers( + tokensToInsert, + parseInt(rootInteractionStep.id, 10) + ); + + if (triggersToInsert.length) + await knexTrx("auto_reply_trigger").insert(triggersToInsert); + + const tokensToDelete = removeMatchingTokens(existingTokens, tokens); + + await knexTrx("auto_reply_trigger") + .where({ interaction_step_id: rootInteractionStep.id }) + .whereIn("token", tokensToDelete) + .delete(); + } } // Persist child interaction steps - const childStepIds = await Promise.all( - rootInteractionStep.interactionSteps.map((childStep) => - persistInteractionStepNode(campaignId, childStep, knexTrx, temporaryIdMap) - ) - ).then((childResults) => - childResults.reduce((acc, childIds) => acc.concat(childIds), []) - ); + const childSteps = rootInteractionStep.interactionSteps; + if (childSteps) { + const childStepsWithChildren = childSteps as InteractionStepWithChildren[]; + + const childStepIds = await Promise.all( + childStepsWithChildren.map((childStep) => + persistInteractionStepNode( + campaignId, + childStep, + knexTrx, + temporaryIdMap + ) + ) + ).then((childResults) => + childResults.reduce((acc, childIds) => acc.concat(childIds), []) + ); - return childStepIds.concat([rootStepId]); + return childStepIds.concat([rootStepId]); + } + return [rootStepId]; }; export const persistInteractionStepTree = async ( @@ -118,9 +173,18 @@ export const persistInteractionStepTree = async ( from steps_to_delete del where ins.id = del.id and for_update returning * + ), + + delete_triggers as ( + delete from auto_reply_trigger + using steps_to_delete del + where interaction_step_id = del.id + returning * ) - select count(*) from delete_steps union select count(*) from update_steps + select count(*) from delete_steps + union select count(*) from update_steps + union select count(*) from delete_triggers `, [campaignId, stepIds] ); diff --git a/src/server/api/lib/message-sending.js b/src/server/api/lib/message-sending.js index 288a8013d..29f392829 100644 --- a/src/server/api/lib/message-sending.js +++ b/src/server/api/lib/message-sending.js @@ -1,6 +1,7 @@ import groupBy from "lodash/groupBy"; import { config } from "../../../config"; +import { optOutTriggers } from "../../../lib/opt-out-triggers"; import { eventBus, EventType } from "../../event-bus"; import { queueExternalSyncForAction } from "../../lib/external-systems"; import { cacheableData, r } from "../../models"; @@ -16,23 +17,6 @@ export const SpokeSendStatus = Object.freeze({ NotAttempted: "NOT_ATTEMPTED" }); -const OPT_OUT_TRIGGERS = [ - "stop", - "stop all", - "stopall", - "unsub", - "unsubscribe", - "cancel", - "end", - "quit", - "stop2quit", - "stop 2 quit", - "stop=quit", - "stop = quit", - "stop to quit", - "stoptoquit" -]; - /** * Return a list of messaing services for an organization that are candidates for assignment. * @@ -386,46 +370,127 @@ export async function saveNewIncomingMessage(messageInstance) { }; eventBus.emit(EventType.MessageReceived, payload); - const cleanedUpText = text.toLowerCase().trim(); + const noPunctuationText = text.replace(/[,.!]/g, ""); + const cleanedUpText = noPunctuationText.toLowerCase().trim(); // Separate update fields according to: https://stackoverflow.com/a/42307979 let updateQuery = r.knex("campaign_contact").limit(1); - if (OPT_OUT_TRIGGERS.includes(cleanedUpText)) { - updateQuery = updateQuery.update({ message_status: "closed" }); - - const { id: organizationId } = await r - .knex("organization") - .first("organization.id") - .join("campaign", "organization_id", "=", "organization.id") - .join("assignment", "campaign_id", "=", "campaign.id") - .where({ "assignment.id": assignment_id }); - - const optOutId = await cacheableData.optOut.save(r.knex, { - cell: contact_number, - reason: "Automatic OptOut", - assignmentId: assignment_id, - organizationId - }); - - await queueExternalSyncForAction(ActionType.OptOut, optOutId); - } else { - updateQuery = updateQuery.update({ message_status: "needsResponse" }); + // Prioritize auto opt outs > auto replies > regular inbound message handling + const handleOptOut = optOutTriggers.includes(cleanedUpText); + const cc_id = messageInstance.campaign_contact_id; + + let rowCount; + if (!handleOptOut && config.ENABLE_AUTO_REPLIES) { + ({ + rows: [{ count: rowCount }] + } = await r.knex.raw( + ` + with cc as (select * from campaign_contact where id = ?), + step_to_send as ( + select art.* from auto_reply_trigger art + cross join cc + join interaction_step ins on art.interaction_step_id = ins.id + where token = ? + and ( + -- if a trigger exists, it will be associated with + -- an interaction step whose parent has a question_response record + ins.parent_interaction_id in ( + select id from interaction_step child_steps + where parent_interaction_id in ( + select interaction_step_id from question_response qr + where campaign_contact_id = cc.id + order by qr.id desc + limit 1 + ) + or ( -- there is no question_response yet and the parent_interaction_id is null + ins.parent_interaction_id = ( + select id from interaction_step root_step + where parent_interaction_id is null + and campaign_id = cc.campaign_id + ) + and not exists ( + select interaction_step_id from question_response qr + where campaign_contact_id = cc.id + order by qr.id desc + limit 1 + ) + ) + ) + ) + ), + mark_qr as ( + insert into question_response(campaign_contact_id, interaction_step_id, value) + select ?, ins.parent_interaction_id, ins.answer_option + from step_to_send sts + join interaction_step ins on sts.interaction_step_id = ins.id + returning * + ), + send_message as ( + select graphile_worker.add_job( + identifier := 'retry-interaction-step'::text, + payload := json_build_object( + 'campaignContactId', cc.id, + 'campaignId', cc.campaign_id, + 'unassignAfterSend', false, + 'interactionStepId', step_to_send.interaction_step_id + ), + job_key := format('%s|%s', 'retry-interaction-step', cc.id), + queue_name := null, + max_attempts := 1, + -- run between 2-3 minutes in the future + run_at := now() + interval '2 minutes' + random() * interval '1 minute', + -- prioritize in order as: autoassignment, autosending, handle delivery reports + priority := 4 + ) + from step_to_send + cross join cc + ) + select count(*) from mark_qr + union select count(*) from send_message + `, + [cc_id, cleanedUpText, cc_id] + )); } - - // Prefer to match on campaign contact ID - if (messageInstance.campaign_contact_id) { - updateQuery = updateQuery.where({ - id: messageInstance.campaign_contact_id - }); - } else { - updateQuery = updateQuery.where({ - assignment_id: messageInstance.assignment_id, - cell: messageInstance.contact_number - }); + const autoReplyCount = parseInt(rowCount, 10); + + if (handleOptOut || Number.isNaN(autoReplyCount) || autoReplyCount === 0) { + const updateColumns = { auto_reply_eligible: false }; + updateColumns.message_status = handleOptOut ? "closed" : "needsResponse"; + updateQuery.update(updateColumns); + + if (handleOptOut) { + const { id: organizationId } = await r + .knex("organization") + .first("organization.id") + .join("campaign", "organization_id", "=", "organization.id") + .join("assignment", "campaign_id", "=", "campaign.id") + .where({ "assignment.id": assignment_id }); + + const optOutId = await cacheableData.optOut.save(r.knex, { + cell: contact_number, + reason: "Automatic OptOut", + assignmentId: assignment_id, + organizationId + }); + + await queueExternalSyncForAction(ActionType.OptOut, optOutId); + } + + // Prefer to match on campaign contact ID + if (messageInstance.campaign_contact_id) { + updateQuery = updateQuery.where({ + id: messageInstance.campaign_contact_id + }); + } else { + updateQuery = updateQuery.where({ + assignment_id: messageInstance.assignment_id, + cell: messageInstance.contact_number + }); + } + + await updateQuery; } - - await updateQuery; } /** diff --git a/src/server/api/lib/message-sending.spec.ts b/src/server/api/lib/message-sending.spec.ts new file mode 100644 index 000000000..96090f1ee --- /dev/null +++ b/src/server/api/lib/message-sending.spec.ts @@ -0,0 +1,191 @@ +import type { PoolClient } from "pg"; +import { Pool } from "pg"; +import supertest from "supertest"; + +import { createOrgAndSession } from "../../../../__test__/lib/session"; +import { + assignContacts, + createAutoReplyTrigger, + createCompleteCampaign, + createInteractionStep, + createMessage +} from "../../../../__test__/testbed-preparation/core"; +import { UserRoleType } from "../../../api/organization-membership"; +import { config } from "../../../config"; +import { createApp } from "../../app"; +import { withClient } from "../../utils"; +import type { CampaignContactRecord } from "../types"; + +const sendReply = async ( + agent: supertest.SuperAgentTest, + cookies: Record, + campaignContactId: number, + message: string +) => + agent + .post(`/graphql`) + .set(cookies) + .send({ + operationName: "SendReply", + variables: { + id: `${campaignContactId}`, + message + }, + query: ` + mutation SendReply($id: String!, $message: String!) { + sendReply(id: $id, message: $message) { + id + } + } + ` + }); + +const createTestBed = async ( + client: PoolClient, + agent: supertest.SuperAgentTest +) => { + const { organization, user, cookies } = await createOrgAndSession(client, { + agent, + role: UserRoleType.OWNER + }); + + const { + contacts: [contact], + assignments: [assignment], + campaign + } = await createCompleteCampaign(client, { + organization: { id: organization.id }, + texters: 1, + contacts: 1 + }); + + await assignContacts(client, assignment.id, campaign.id, 1); + await createMessage(client, { + assignmentId: assignment.id, + campaignContactId: contact.id, + contactNumber: contact.cell, + text: "Hi! Want to attend my cool event?" + }); + + const rootStep = await createInteractionStep(client, { + campaignId: campaign.id + }); + + const childStep = await createInteractionStep(client, { + campaignId: campaign.id, + parentInteractionId: rootStep.id + }); + + await createAutoReplyTrigger(client, { + interactionStepId: childStep.id, + token: "yes" + }); + + return { organization, user, cookies, contact, assignment }; +}; + +describe("automatic message handling", () => { + let pool: Pool; + let agent: supertest.SuperAgentTest; + + beforeAll(async () => { + pool = new Pool({ connectionString: config.TEST_DATABASE_URL }); + const app = await createApp(); + agent = supertest.agent(app); + }); + + afterAll(async () => { + if (pool) await pool.end(); + }); + + test("does not opt out a contact who says START", async () => { + const testbed = await withClient(pool, async (client) => { + return createTestBed(client, agent); + }); + + await sendReply(agent, testbed.cookies, testbed.contact.id, "START"); + const { + rows: [replyContact] + } = await pool.query( + `select is_opted_out from campaign_contact where id = $1`, + [testbed.contact.id] + ); + + expect(replyContact.is_opted_out).toBe(false); + }); + + test("opts out a contact who says STOP", async () => { + const testbed = await withClient(pool, async (client) => { + return createTestBed(client, agent); + }); + + await sendReply(agent, testbed.cookies, testbed.contact.id, "STOP"); + const { + rows: [replyContact] + } = await pool.query( + `select is_opted_out from campaign_contact where id = $1`, + [testbed.contact.id] + ); + + expect(replyContact.is_opted_out).toBe(true); + }); + + test("does not respond to a contact who says YES! with no auto reply configured for the campaign", async () => { + const testbed = await withClient(pool, async (client) => { + return createTestBed(client, agent); + }); + + await sendReply(agent, testbed.cookies, testbed.contact.id, "YES"); + const { + rows: [msgs] + } = await pool.query( + `select count(*) from message where campaign_contact_id = $1`, + [testbed.contact.id] + ); + + const msgCount = parseInt(msgs.count, 10); + expect(msgCount).toBe(2); + }); + + test("does not respond to a contact who says Yes, where? with a YES auto reply configured for the campaign", async () => { + const testbed = await withClient(pool, async (client) => { + return createTestBed(client, agent); + }); + + await sendReply(agent, testbed.cookies, testbed.contact.id, "Yes, where?"); + const { + rows: [retryJobs] + } = await pool.query( + ` + select count(*) from graphile_worker.jobs + where task_identifier = 'retry-interaction-step' + and payload->>'campaignContactId' = $1 + `, + [testbed.contact.id] + ); + + const retryJobsCount = parseInt(retryJobs.count, 10); + expect(retryJobsCount).toBe(0); + }); + + test("responds to a contact who says YES! with a YES auto reply configured for the campaign", async () => { + const testbed = await withClient(pool, async (client) => { + return createTestBed(client, agent); + }); + + await sendReply(agent, testbed.cookies, testbed.contact.id, "YES!"); + const { + rows: [retryJobs] + } = await pool.query( + ` + select count(*) from graphile_worker.jobs + where task_identifier = 'retry-interaction-step' + and payload->>'campaignContactId' = $1 + `, + [testbed.contact.id] + ); + + const retryJobsCount = parseInt(retryJobs.count, 10); + expect(retryJobsCount).toBe(1); + }); +}); diff --git a/src/server/api/root-mutations.ts b/src/server/api/root-mutations.ts index bf6fb28fe..5fb317de8 100644 --- a/src/server/api/root-mutations.ts +++ b/src/server/api/root-mutations.ts @@ -3565,6 +3565,22 @@ const rootMutations = { }); return true; + }, + markForManualReply: async (_root, { campaignContactId }) => { + return r.knex.transaction(async (trx) => { + const [contact] = await trx("campaign_contact") + .where({ id: campaignContactId }) + .update({ auto_reply_eligible: false }) + .returning(["id", "auto_reply_eligible"]); + + await trx.raw(` + delete from graphile_worker.jobs + where task_identifier = 'retry-interaction-step' + and (payload->>'campaignContactId')::integer = ${campaignContactId} + `); + + return contact; + }); } } }; diff --git a/src/server/api/types.ts b/src/server/api/types.ts index cb1a3a36a..f94a249d3 100644 --- a/src/server/api/types.ts +++ b/src/server/api/types.ts @@ -211,6 +211,14 @@ export interface QuestionResponseRecord { is_deleted: boolean; } +export interface AutoReplyTriggerRecord { + id: number; + interaction_step_id: number; + token: string; + created_at: string; + updated_at: string; +} + export enum MessageSendStatus { Queued = "QUEUED", Sending = "SENDING", diff --git a/src/server/tasks/assign-texters.spec.ts b/src/server/tasks/assign-texters.spec.ts index 807c583db..6e48d9b3f 100644 --- a/src/server/tasks/assign-texters.spec.ts +++ b/src/server/tasks/assign-texters.spec.ts @@ -2,6 +2,7 @@ import type { PoolClient } from "pg"; import { Pool } from "pg"; import { + assignContacts, createCompleteCampaign, createTexter } from "../../../__test__/testbed-preparation/core"; @@ -39,27 +40,6 @@ const texterContactCount = async ( return count; }; -const assignContacts = async ( - client: PoolClient, - assignmentId: number, - campaignId: number, - count: number -) => { - await client.query( - ` - update campaign_contact - set assignment_id = $1 - where id in ( - select id from campaign_contact - where campaign_id = $2 - and assignment_id is null - limit $3 - ) - `, - [assignmentId, campaignId, count] - ); -}; - describe("assign-texters", () => { let pool: Pool; diff --git a/src/server/tasks/retry-interaction-step.ts b/src/server/tasks/retry-interaction-step.ts index 40bbedfb8..aba698908 100644 --- a/src/server/tasks/retry-interaction-step.ts +++ b/src/server/tasks/retry-interaction-step.ts @@ -1,10 +1,8 @@ +import type { CampaignContact, MessageInput, User } from "@spoke/spoke-codegen"; import type { Task } from "graphile-worker"; import sample from "lodash/sample"; import md5 from "md5"; -import type { CampaignContact } from "../../api/campaign-contact"; -import type { MessageInput } from "../../api/types"; -import type { User } from "../../api/user"; import { recordToCamelCase } from "../../lib/attributes"; import { applyScript } from "../../lib/scripts"; import { sendMessage } from "../api/lib/send-message"; @@ -22,6 +20,7 @@ export const TASK_IDENTIFIER = "retry-interaction-step"; export interface RetryInteractionStepPayload { campaignContactId: number; unassignAfterSend?: boolean; + interactionStepId?: number; } interface RetryInteractionStepRecord { @@ -35,7 +34,7 @@ export const retryInteractionStep: Task = async ( payload: RetryInteractionStepPayload, helpers ) => { - const { campaignContactId } = payload; + const { campaignContactId, interactionStepId } = payload; const { rows: [record] @@ -52,9 +51,12 @@ export const retryInteractionStep: Task = async ( join public.user u on u.id = a.user_id where cc.id = $1 - and istep.parent_interaction_id is null + and ( + ($2::integer is null and istep.parent_interaction_id is null) + or istep.id = $2::integer + ) `, - [campaignContactId] + [campaignContactId, interactionStepId] ); if (!record) @@ -82,7 +84,7 @@ export const retryInteractionStep: Task = async ( }; const texter = recordToCamelCase(user); const customFields = Object.keys(JSON.parse(contact.customFields)); - const campaignVariableIds = campaignVariables.map(({ id }) => id); + const campaignVariableIds = campaignVariables.map(({ id }) => id.toString()); const body = applyScript({ script,