From 861c8731bda56d2b38d0e93199201b7d820847fd Mon Sep 17 00:00:00 2001 From: Aashish John Date: Thu, 17 Aug 2023 11:59:32 -0400 Subject: [PATCH 01/19] chore: use bool for false --- src/config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config.js b/src/config.js index b9cf1ec49..8ab26c648 100644 --- a/src/config.js +++ b/src/config.js @@ -355,7 +355,7 @@ const validators = { }), DISABLE_SIDEBAR_BADGES: bool({ desc: "Whether to disable showing the badge counts on the admin sidebar.", - default: "false", + default: false, isClient: true }), EMAIL_FROM: email({ From 04550bc3e64a8dd0b92f8641747e9e2f68188197 Mon Sep 17 00:00:00 2001 From: Aashish John Date: Thu, 17 Aug 2023 12:18:35 -0400 Subject: [PATCH 02/19] chore: separate feature flags --- src/config.js | 195 ++++++++++++++++++++++++++------------------------ 1 file changed, 100 insertions(+), 95 deletions(-) diff --git a/src/config.js b/src/config.js index 8ab26c648..77ec3f103 100644 --- a/src/config.js +++ b/src/config.js @@ -23,12 +23,6 @@ const validators = { desc: "ActionKit API secret.", default: undefined }), - ALLOW_SEND_ALL: bool({ - desc: - "Whether to allow sending all messages in a campaign at once. NOT LEGAL IN US.", - default: false, - isClient: true - }), ALTERNATE_LOGIN_URL: url({ desc: 'When set, the "Login" link on the home page will direct to this alternate URL', @@ -61,11 +55,6 @@ const validators = { desc: "Webhook URL to notify when a texter assignment is requested.", default: undefined }), - ASSIGNMENT_REQUESTED_URL_REQUIRED: bool({ - desc: - "Require successful assignment requested webhook to persist assignment request in Spoke.", - default: false - }), ASSIGNMENT_REQUESTED_TOKEN: str({ desc: "Bearer token to use as authorization with ASSIGNMENT_REQUESTED_URL.", default: undefined @@ -80,12 +69,6 @@ const validators = { "Comma separated list of team IDs to restrict 'assignment complete' notifications to.", default: "" }), - ASSIGNMENT_SHOW_REQUESTS_AVAILABLE: bool({ - desc: - "If enabled, display icons on the home page organization selection list indicating availability of assignments.", - default: false, - isClient: true - }), ASSIGNMENT_MANAGER_DATABASE_URL: str({ desc: "Database url of external assignent manager - used by 'update-sms-spanish-speakers' cron job", @@ -106,15 +89,6 @@ const validators = { desc: "Client secret from Auth0 app.", default: undefined }), - AUTO_HANDLE_REQUESTS: bool({ - desc: "Whether to auto handle requests after submission", - default: false - }), - ENABLE_AUTOSENDING: bool({ - desc: "Whether autosending 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", @@ -124,10 +98,6 @@ const validators = { desc: "How many requests to handle at once", default: 1 }), - AWS_ACCESS_AVAILABLE: bool({ - desc: "Enable or disable S3 campaign exports within Amazon Lambda.", - default: false - }), AWS_ENDPOINT: url({ desc: "An alternate endpoint to use with the AWS SDK. This allows uploading to S3-compatible services such as Wasabi and Google Cloud Storage.", @@ -273,14 +243,6 @@ const validators = { choice: ["require"], default: undefined }), - DEBUG_INCOMING_MESSAGES: bool({ - desc: "Emit console.log on events related to handleIncomingMessages.", - default: false - }), - DEBUG_SCALING: bool({ - desc: "Emit console.log on events related to scaling issues.", - default: false - }), DEFAULT_SERVICE: str({ desc: "Default SMS service.", choices: ["assemble-numbers", "twilio", "nexmo", "fakeservice"], @@ -323,26 +285,11 @@ const validators = { default: false, isClient: true }), - ENABLE_TROLLBOT: bool({ - desc: "Whether to enable trollbot", - default: false, - isClient: true - }), TROLL_ALERT_PERIOD_MINUTES: num({ desc: "The interval length in minutes that each troll patrol sweep will examine messages within.", default: 6 }), - ENABLE_CAMPAIGN_GROUPS: bool({ - desc: "Whether to enable campaign groups", - default: false, - isClient: true - }), - ENABLE_SHORTLINK_DOMAINS: bool({ - desc: "Whether to enable shortlink domains", - default: false, - isClient: true - }), DISABLE_TEXTER_NOTIFICATIONS: bool({ desc: "Whether to disable texter notifications – if true, should be implemented externally.", @@ -387,10 +334,6 @@ const validators = { desc: "Email server user. Required for custom SMTP server usage.", default: undefined }), - ENABLE_MONTHLY_ORG_MESSAGE_LIMITS: bool({ - desc: "Whether to enable monthly, per organization message limits", - default: false - }), ENCRYPTION_ALGORITHM: str({ desc: "Encryption algorithm to use with crypto package.", choices: ["aes256"], @@ -406,11 +349,6 @@ const validators = { choices: ["hex"], default: "hex" }), - EXPERIMENTAL_VAN_SYNC: bool({ - desc: "Use experimental real-time VAN sync", - default: false, - isClient: true - }), EXPORT_DRIVER: str({ desc: "Which cloud storage driver to use for exports.", choices: ["s3", "gs-json"], // eventually add support for GCP w/ HMAC interoperability: ["gs"] @@ -420,11 +358,6 @@ const validators = { desc: "Chunk size to use for exporting campaign contacts and messages.", default: 1000 }), - FIX_ORGLESS: bool({ - desc: - "Set to true only if you want to run the job that automatically assigns the default org (see DEFAULT_ORG) to new users who have no assigned org.", - default: false - }), GOOGLE_APPLICATION_CREDENTIALS: str({ desc: "JSON token for service account", default: undefined @@ -461,10 +394,6 @@ const validators = { "Whether jobs should be performed syncronously. Requires JOBS_SAME_PROCESS.", default: false }), - LAMBDA_DEBUG_LOG: bool({ - desc: "When true, log each lambda event to the console.", - default: false - }), LARGE_CAMPAIGN_THRESHOLD: num({ desc: 'Threshold for what qualifies as a "large campaign"', default: 100 * 1000 @@ -529,21 +458,10 @@ const validators = { default: "development", isClient: true }), - NOT_IN_USA: bool({ - desc: - "A flag to affirmatively indicate the ability to use features that are discouraged or not legally usable in the United States. Consult with an attorney about the implications for doing so. Default assumes a USA legal context.", - default: false, - isClient: true - }), OPT_OUT_MESSAGE: str({ desc: "Spoke instance-wide default for opt out message.", default: undefined }), - OPTOUTS_SHARE_ALL_ORGS: bool({ - desc: - "Can be set to true if opt outs should be respected per instance and across organizations.", - default: false - }), OUTPUT_DIR: str({ desc: "Directory path for packaged files should be saved to. Required.", default: "./build" @@ -660,11 +578,6 @@ const validators = { "If set, then on post-install (often from deploying) a message will be posted to a slack channel's #spoke channel", default: undefined }), - SLACK_SYNC_CHANNELS: bool({ - desc: - "If true, Spoke team membership will be synced with matching Slack channel membership", - default: false - }), SLACK_SYNC_CHANNELS_CRONTAB: str({ desc: "The crontab schedule to run the team sync on", default: "*/10 * * * *" @@ -685,12 +598,6 @@ const validators = { default: true, isClient: true }), - TERMS_REQUIRE: bool({ - desc: - "Require texters to accept the Terms page before they can start texting.", - default: false, - isClient: true - }), TEST_DATABASE_URL: url({ desc: "Testing database connection URL", example: "postgres://username:password@127.0.0.1:5432/db_name", @@ -828,13 +735,111 @@ const validators = { }) }; -const config = envalid.cleanEnv(process.env, validators, { +const env = envalid.cleanEnv(process.env, validators, { strict: true }); const clientConfig = pickBy( - { ...config }, + { ...env }, (value, key) => validators[key].isClient ); +const config = { + ...env, + // feature flags + ALLOW_SEND_ALL: bool({ + desc: + "Whether to allow sending all messages in a campaign at once. NOT LEGAL IN US.", + default: false, + isClient: true + }), + ASSIGNMENT_SHOW_REQUESTS_AVAILABLE: bool({ + desc: + "If enabled, display icons on the home page organization selection list indicating availability of assignments.", + default: false, + isClient: true + }), + AUTO_HANDLE_REQUESTS: bool({ + desc: "Whether to auto handle requests after submission", + default: false + }), + ENABLE_AUTOSENDING: bool({ + desc: "Whether autosending is enabled", + default: false, + isClient: true + }), + ENABLE_AUTO_REPLIES: bool({ + desc: "Whether auto reply handling is enabled", + default: false, + isClient: true + }), + AWS_ACCESS_AVAILABLE: bool({ + desc: "Enable or disable S3 campaign exports within Amazon Lambda.", + default: false + }), + DEBUG_INCOMING_MESSAGES: bool({ + desc: "Emit console.log on events related to handleIncomingMessages.", + default: false + }), + DEBUG_SCALING: bool({ + desc: "Emit console.log on events related to scaling issues.", + default: false + }), + ENABLE_TROLLBOT: bool({ + desc: "Whether to enable trollbot", + default: false, + isClient: true + }), + ENABLE_CAMPAIGN_GROUPS: bool({ + desc: "Whether to enable campaign groups", + default: false, + isClient: true + }), + ENABLE_SHORTLINK_DOMAINS: bool({ + desc: "Whether to enable shortlink domains", + default: false, + isClient: true + }), + ENABLE_MONTHLY_ORG_MESSAGE_LIMITS: bool({ + desc: "Whether to enable monthly, per organization message limits", + default: false + }), + EXPERIMENTAL_VAN_SYNC: bool({ + desc: "Use experimental real-time VAN sync", + default: false, + isClient: true + }), + FIX_ORGLESS: bool({ + desc: + "Set to true only if you want to run the job that automatically assigns the default org (see DEFAULT_ORG) to new users who have no assigned org.", + default: false + }), + LAMBDA_DEBUG_LOG: bool({ + desc: "When true, log each lambda event to the console.", + default: false + }), + NOT_IN_USA: bool({ + desc: + "A flag to affirmatively indicate the ability to use features that are discouraged or not legally usable in the United States. Consult with an attorney about the implications for doing so. Default assumes a USA legal context.", + default: false, + isClient: true + }), + OPTOUTS_SHARE_ALL_ORGS: bool({ + desc: + "Can be set to true if opt outs should be respected per instance and across organizations.", + default: false + }), + SLACK_SYNC_CHANNELS: bool({ + desc: + "If true, Spoke team membership will be synced with matching Slack channel membership", + default: false + }), + TERMS_REQUIRE: bool({ + desc: + "Require texters to accept the Terms page before they can start texting.", + default: false, + isClient: true + }) +}; + module.exports = { config, clientConfig, ServerMode }; From 65cbab776355cf1256b5f462cb43705c9fc8e3d3 Mon Sep 17 00:00:00 2001 From: Aashish John Date: Thu, 17 Aug 2023 12:26:28 -0400 Subject: [PATCH 03/19] chore: separate node env bools --- src/config.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/config.js b/src/config.js index 77ec3f103..4a1dbcefc 100644 --- a/src/config.js +++ b/src/config.js @@ -739,12 +739,19 @@ const env = envalid.cleanEnv(process.env, validators, { strict: true }); +const envConfig = { + isDevelopment: env.isDevelopment, + isProduction: env.isProduction, + isTest: env.isTest +}; + const clientConfig = pickBy( { ...env }, (value, key) => validators[key].isClient ); const config = { + ...envConfig, ...env, // feature flags ALLOW_SEND_ALL: bool({ From 648bcc21c69371b14e72d40224a66a8b0aa2f33d Mon Sep 17 00:00:00 2001 From: Aashish John Date: Thu, 17 Aug 2023 12:27:28 -0400 Subject: [PATCH 04/19] chore(config): check test environment for feature flags --- src/config.js | 41 ++++++++++++++++++----------------------- 1 file changed, 18 insertions(+), 23 deletions(-) diff --git a/src/config.js b/src/config.js index 4a1dbcefc..81420b628 100644 --- a/src/config.js +++ b/src/config.js @@ -757,94 +757,89 @@ const config = { ALLOW_SEND_ALL: bool({ desc: "Whether to allow sending all messages in a campaign at once. NOT LEGAL IN US.", - default: false, + default: env.isTest, isClient: true }), ASSIGNMENT_SHOW_REQUESTS_AVAILABLE: bool({ desc: "If enabled, display icons on the home page organization selection list indicating availability of assignments.", - default: false, + default: env.isTest, isClient: true }), AUTO_HANDLE_REQUESTS: bool({ desc: "Whether to auto handle requests after submission", - default: false + default: env.isTest }), ENABLE_AUTOSENDING: bool({ desc: "Whether autosending is enabled", - default: false, - isClient: true - }), - ENABLE_AUTO_REPLIES: bool({ - desc: "Whether auto reply handling is enabled", - default: false, + default: env.isTest, isClient: true }), AWS_ACCESS_AVAILABLE: bool({ desc: "Enable or disable S3 campaign exports within Amazon Lambda.", - default: false + default: env.isTest }), DEBUG_INCOMING_MESSAGES: bool({ desc: "Emit console.log on events related to handleIncomingMessages.", - default: false + default: env.isTest }), DEBUG_SCALING: bool({ desc: "Emit console.log on events related to scaling issues.", - default: false + default: env.isTest }), ENABLE_TROLLBOT: bool({ desc: "Whether to enable trollbot", - default: false, + default: env.isTest, isClient: true }), ENABLE_CAMPAIGN_GROUPS: bool({ desc: "Whether to enable campaign groups", - default: false, + default: env.isTest, isClient: true }), ENABLE_SHORTLINK_DOMAINS: bool({ desc: "Whether to enable shortlink domains", - default: false, + default: env.isTest, isClient: true }), ENABLE_MONTHLY_ORG_MESSAGE_LIMITS: bool({ desc: "Whether to enable monthly, per organization message limits", - default: false + default: env.isTest }), EXPERIMENTAL_VAN_SYNC: bool({ desc: "Use experimental real-time VAN sync", - default: false, + default: env.isTest, isClient: true }), FIX_ORGLESS: bool({ desc: "Set to true only if you want to run the job that automatically assigns the default org (see DEFAULT_ORG) to new users who have no assigned org.", - default: false + default: env.isTest }), LAMBDA_DEBUG_LOG: bool({ desc: "When true, log each lambda event to the console.", - default: false + default: env.isTest }), NOT_IN_USA: bool({ desc: "A flag to affirmatively indicate the ability to use features that are discouraged or not legally usable in the United States. Consult with an attorney about the implications for doing so. Default assumes a USA legal context.", - default: false, + default: env.isTest, isClient: true }), OPTOUTS_SHARE_ALL_ORGS: bool({ desc: "Can be set to true if opt outs should be respected per instance and across organizations.", - default: false + default: env.isTest }), SLACK_SYNC_CHANNELS: bool({ desc: "If true, Spoke team membership will be synced with matching Slack channel membership", - default: false + default: env.isTest }), TERMS_REQUIRE: bool({ desc: "Require texters to accept the Terms page before they can start texting.", - default: false, + default: env.isTest, isClient: true }) }; From b918e1842451c54d87723eb4ac542ffeadbd67bc Mon Sep 17 00:00:00 2001 From: Aashish John Date: Fri, 11 Aug 2023 16:27:56 -0400 Subject: [PATCH 05/19] chore: pull up assign texter method --- __test__/testbed-preparation/core.ts | 21 +++++++++++++++++++++ src/server/tasks/assign-texters.spec.ts | 22 +--------------------- 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/__test__/testbed-preparation/core.ts b/__test__/testbed-preparation/core.ts index 7dff6c5bc..ce55e155e 100644 --- a/__test__/testbed-preparation/core.ts +++ b/__test__/testbed-preparation/core.ts @@ -524,3 +524,24 @@ export const createQuestionResponse = async ( [options.value, options.campaignContactId, options.interactionStepId] ) .then(({ rows: [questionResponse] }) => questionResponse); + +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/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; From 6fd62dedc83648cef36cdce6e15b2bdd0659e9b5 Mon Sep 17 00:00:00 2001 From: Aashish John Date: Fri, 11 Aug 2023 16:30:06 -0400 Subject: [PATCH 06/19] test: add auto reply tests --- __test__/testbed-preparation/core.ts | 47 +++-- src/server/api/lib/message-sending.spec.ts | 191 +++++++++++++++++++++ src/server/api/types.ts | 8 + 3 files changed, 234 insertions(+), 12 deletions(-) create mode 100644 src/server/api/lib/message-sending.spec.ts diff --git a/__test__/testbed-preparation/core.ts b/__test__/testbed-preparation/core.ts index ce55e155e..8660ab0d5 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 ?? {}), @@ -525,6 +528,26 @@ export const createQuestionResponse = async ( ) .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, 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/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", From 0922cf00d2b1c9bdce48fd9b514d72d82d165b0a Mon Sep 17 00:00:00 2001 From: Aashish John Date: Fri, 11 Aug 2023 16:33:13 -0400 Subject: [PATCH 07/19] chore: add auto reply trigger schema changes --- libs/gql-schema/campaign-contact.ts | 2 + libs/gql-schema/interaction-step.ts | 2 + .../20230806003928_support-auto-replies.js | 67 +++++ ...30811182109_auto-replies-autoassignment.js | 234 ++++++++++++++++++ src/schema.graphql | 3 + src/server/api/interaction-step.js | 9 +- src/server/api/lib/campaign.ts | 60 +++-- src/server/api/lib/interaction-steps.ts | 82 +++++- 8 files changed, 427 insertions(+), 32 deletions(-) create mode 100644 migrations/20230806003928_support-auto-replies.js create mode 100644 migrations/20230811182109_auto-replies-autoassignment.js 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..6066870f2 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,6 +19,7 @@ export const schema = ` scriptOptions: [String]! answerOption: String answerActions: String + autoReplyTokens: [String] parentInteractionId: String isDeleted: Boolean createdAt: Date diff --git a/migrations/20230806003928_support-auto-replies.js b/migrations/20230806003928_support-auto-replies.js new file mode 100644 index 000000000..757c37901 --- /dev/null +++ b/migrations/20230806003928_support-auto-replies.js @@ -0,0 +1,67 @@ +/** + * @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(); + ` + ) + .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/schema.graphql b/src/schema.graphql index 1976af0c9..1d8474d1b 100644 --- a/src/schema.graphql +++ b/src/schema.graphql @@ -784,6 +784,7 @@ type InteractionStep { scriptOptions: [String]! answerOption: String parentInteractionId: String + autoReplyTokens: [String] isDeleted: Boolean answerActions: String questionResponse(campaignContactId: String): QuestionResponse @@ -939,6 +940,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.ts b/src/server/api/lib/interaction-steps.ts index 2b6980cb8..18c8251b5 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,61 @@ 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"); + + 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 +171,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] ); From c5eb9e73fc1cd2194db0301db174656e7e06de46 Mon Sep 17 00:00:00 2001 From: Aashish John Date: Fri, 11 Aug 2023 16:55:15 -0400 Subject: [PATCH 08/19] refactor: pull up opt out triggers --- src/lib/opt-out-triggers.ts | 18 ++++++++++++++++++ src/server/api/lib/message-sending.js | 25 ++++++------------------- 2 files changed, 24 insertions(+), 19 deletions(-) create mode 100644 src/lib/opt-out-triggers.ts 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/server/api/lib/message-sending.js b/src/server/api/lib/message-sending.js index 288a8013d..f82dd5fcb 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,12 +370,15 @@ 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)) { + // Prioritize auto opt outs > auto replies > regular inbound message handling + const handleOptOut = optOutTriggers.includes(cleanedUpText); + if (handleOptOut) { updateQuery = updateQuery.update({ message_status: "closed" }); const { id: organizationId } = await r From ffc2763eab0e98c8e0ba18a78bd8228caeabc10b Mon Sep 17 00:00:00 2001 From: Aashish John Date: Fri, 11 Aug 2023 17:04:09 -0400 Subject: [PATCH 09/19] chore: update task types --- src/server/tasks/retry-interaction-step.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/server/tasks/retry-interaction-step.ts b/src/server/tasks/retry-interaction-step.ts index 40bbedfb8..33498bec9 100644 --- a/src/server/tasks/retry-interaction-step.ts +++ b/src/server/tasks/retry-interaction-step.ts @@ -22,6 +22,7 @@ export const TASK_IDENTIFIER = "retry-interaction-step"; export interface RetryInteractionStepPayload { campaignContactId: number; unassignAfterSend?: boolean; + interactionStepId?: number; } interface RetryInteractionStepRecord { @@ -82,7 +83,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, From 4bb91e928030d1ca90228e19d22fd489a0daedbe Mon Sep 17 00:00:00 2001 From: Aashish John Date: Fri, 11 Aug 2023 17:05:03 -0400 Subject: [PATCH 10/19] feat: support auto reply handling --- src/server/api/lib/message-sending.js | 144 ++++++++++++++++----- src/server/tasks/retry-interaction-step.ts | 13 +- 2 files changed, 118 insertions(+), 39 deletions(-) diff --git a/src/server/api/lib/message-sending.js b/src/server/api/lib/message-sending.js index f82dd5fcb..29f392829 100644 --- a/src/server/api/lib/message-sending.js +++ b/src/server/api/lib/message-sending.js @@ -378,41 +378,119 @@ export async function saveNewIncomingMessage(messageInstance) { // Prioritize auto opt outs > auto replies > regular inbound message handling const handleOptOut = optOutTriggers.includes(cleanedUpText); - if (handleOptOut) { - 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" }); + 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/tasks/retry-interaction-step.ts b/src/server/tasks/retry-interaction-step.ts index 33498bec9..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"; @@ -36,7 +34,7 @@ export const retryInteractionStep: Task = async ( payload: RetryInteractionStepPayload, helpers ) => { - const { campaignContactId } = payload; + const { campaignContactId, interactionStepId } = payload; const { rows: [record] @@ -53,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) From 18c1004a79c6ae53618ee0c37e8714d76acd78c9 Mon Sep 17 00:00:00 2001 From: Aashish John Date: Fri, 11 Aug 2023 17:17:06 -0400 Subject: [PATCH 11/19] fix: skip token comparisons when not needed --- src/server/api/lib/interaction-steps.ts | 32 +++++++++++++------------ 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/src/server/api/lib/interaction-steps.ts b/src/server/api/lib/interaction-steps.ts index 18c8251b5..5db78fc30 100644 --- a/src/server/api/lib/interaction-steps.ts +++ b/src/server/api/lib/interaction-steps.ts @@ -79,21 +79,23 @@ export const persistInteractionStepNode = async ( .where({ interaction_step_id: rootInteractionStep.id }) .pluck("token"); - 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(); + 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 From 22e85e2eeb5bc8d5b2affbd5a825d4aeb582718e Mon Sep 17 00:00:00 2001 From: Aashish John Date: Fri, 11 Aug 2023 17:18:34 -0400 Subject: [PATCH 12/19] style: fix indenting --- __test__/testbed-preparation/core.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/__test__/testbed-preparation/core.ts b/__test__/testbed-preparation/core.ts index 8660ab0d5..3b1e854a1 100644 --- a/__test__/testbed-preparation/core.ts +++ b/__test__/testbed-preparation/core.ts @@ -556,15 +556,15 @@ export const assignContacts = async ( ) => { 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 - ) - `, + 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] ); }; From a6da77fd74f3a2340232b525927e9f00ae903941 Mon Sep 17 00:00:00 2001 From: Aashish John Date: Fri, 11 Aug 2023 17:31:15 -0400 Subject: [PATCH 13/19] fix: add updated at trigger --- migrations/20230806003928_support-auto-replies.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/migrations/20230806003928_support-auto-replies.js b/migrations/20230806003928_support-auto-replies.js index 757c37901..c4fb1534d 100644 --- a/migrations/20230806003928_support-auto-replies.js +++ b/migrations/20230806003928_support-auto-replies.js @@ -41,6 +41,12 @@ exports.up = function up(knex) { 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) => { From 6cebadf9f65fe8d6b625ee50a2178bcc191e91b2 Mon Sep 17 00:00:00 2001 From: Aashish John Date: Fri, 11 Aug 2023 18:01:17 -0400 Subject: [PATCH 14/19] chore: fix test types --- src/server/api/lib/interaction-steps.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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"; From b574751202c618344683da5ca1a6f1eadac187b9 Mon Sep 17 00:00:00 2001 From: Aashish John Date: Sat, 12 Aug 2023 11:12:22 -0400 Subject: [PATCH 15/19] fix: check retry job count consistently --- src/server/api/lib/message-sending.spec.ts | 98 ++++++++++++---------- 1 file changed, 52 insertions(+), 46 deletions(-) diff --git a/src/server/api/lib/message-sending.spec.ts b/src/server/api/lib/message-sending.spec.ts index 96090f1ee..e0671d3e7 100644 --- a/src/server/api/lib/message-sending.spec.ts +++ b/src/server/api/lib/message-sending.spec.ts @@ -42,7 +42,8 @@ const sendReply = async ( const createTestBed = async ( client: PoolClient, - agent: supertest.SuperAgentTest + agent: supertest.SuperAgentTest, + opts?: { createAutoReplies?: boolean } ) => { const { organization, user, cookies } = await createOrgAndSession(client, { agent, @@ -67,19 +68,31 @@ const createTestBed = async ( text: "Hi! Want to attend my cool event?" }); - const rootStep = await createInteractionStep(client, { - campaignId: campaign.id - }); + if (opts?.createAutoReplies) { + const rootStep = await createInteractionStep(client, { + campaignId: campaign.id + }); - const childStep = await createInteractionStep(client, { - campaignId: campaign.id, - parentInteractionId: rootStep.id - }); + const yesStep = await createInteractionStep(client, { + campaignId: campaign.id, + parentInteractionId: rootStep.id + }); - await createAutoReplyTrigger(client, { - interactionStepId: childStep.id, - token: "yes" - }); + await createAutoReplyTrigger(client, { + interactionStepId: yesStep.id, + token: "yes" + }); + + const maybeStep = await createInteractionStep(client, { + campaignId: campaign.id, + parentInteractionId: rootStep.id + }); + + await createAutoReplyTrigger(client, { + interactionStepId: maybeStep.id, + token: "maybe" + }); + } return { organization, user, cookies, contact, assignment }; }; @@ -88,6 +101,22 @@ describe("automatic message handling", () => { let pool: Pool; let agent: supertest.SuperAgentTest; + const checkRetryJobCountForContact = async (contactId: number) => { + const { + rows: [retryJobs] + } = await pool.query( + ` + select count(*) from graphile_worker.jobs + where task_identifier = 'retry-interaction-step' + and payload->>'campaignContactId' = $1 + `, + [contactId] + ); + + const retryJobsCount = parseInt(retryJobs.count, 10); + return retryJobsCount; + }; + beforeAll(async () => { pool = new Pool({ connectionString: config.TEST_DATABASE_URL }); const app = await createApp(); @@ -136,56 +165,33 @@ describe("automatic message handling", () => { }); 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 retryJobsCount = await checkRetryJobCountForContact( + testbed.contact.id ); - - const msgCount = parseInt(msgs.count, 10); - expect(msgCount).toBe(2); + expect(retryJobsCount).toBe(0); }); - test("does not respond to a contact who says Yes, where? with a YES auto reply configured for the campaign", async () => { + test("does not respond to a contact who says Yes, where? with a YES and MAYBE auto reply configured for the campaign", async () => { const testbed = await withClient(pool, async (client) => { - return createTestBed(client, agent); + return createTestBed(client, agent, { createAutoReplies: true }); }); 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 = await checkRetryJobCountForContact( + 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 () => { + test("responds appropriately to a contact who says YES! with a YES and MAYBE auto reply configured for the campaign", async () => { const testbed = await withClient(pool, async (client) => { - return createTestBed(client, agent); + return createTestBed(client, agent, { createAutoReplies: true }); }); 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 = await checkRetryJobCountForContact( + testbed.contact.id ); - - const retryJobsCount = parseInt(retryJobs.count, 10); expect(retryJobsCount).toBe(1); }); }); From 0df3e942d66a2a1268bdfb83e099234d0f2ad379 Mon Sep 17 00:00:00 2001 From: Aashish John Date: Fri, 11 Aug 2023 18:38:27 -0400 Subject: [PATCH 16/19] feat: set contact ineligible for auto replies --- libs/gql-schema/schema.ts | 1 + .../src/graphql/message-sending.graphql | 7 +++++++ .../MessageColumn/MessageResponse.tsx | 16 +++++++++++----- src/schema.graphql | 1 + src/server/api/root-mutations.ts | 16 ++++++++++++++++ 5 files changed, 36 insertions(+), 5 deletions(-) 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/src/containers/AdminIncomingMessageList/components/IncomingMessageList/MessageColumn/MessageResponse.tsx b/src/containers/AdminIncomingMessageList/components/IncomingMessageList/MessageColumn/MessageResponse.tsx index 314d9df4d..687c3713d 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"; @@ -41,6 +42,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 +67,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/schema.graphql b/src/schema.graphql index 1d8474d1b..ca775fa5c 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 { 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; + }); } } }; From 6daf111462508229dfa5b800a33fee8771aaa1d9 Mon Sep 17 00:00:00 2001 From: Aashish John Date: Fri, 11 Aug 2023 18:37:45 -0400 Subject: [PATCH 17/19] chore(message response): fix types --- .../IncomingMessageList/MessageColumn/MessageResponse.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/containers/AdminIncomingMessageList/components/IncomingMessageList/MessageColumn/MessageResponse.tsx b/src/containers/AdminIncomingMessageList/components/IncomingMessageList/MessageColumn/MessageResponse.tsx index 687c3713d..1d141460c 100644 --- a/src/containers/AdminIncomingMessageList/components/IncomingMessageList/MessageColumn/MessageResponse.tsx +++ b/src/containers/AdminIncomingMessageList/components/IncomingMessageList/MessageColumn/MessageResponse.tsx @@ -31,6 +31,8 @@ const messageSchema = yup.object({ .max(window.MAX_MESSAGE_LENGTH) }); +type MessageFormValue = { messageText: string }; + const MessageResponse: React.FC = ({ conversation, value, From 064fc3d1ec436dfabba7bae9b67d3ffeac7db8c8 Mon Sep 17 00:00:00 2001 From: Aashish John Date: Sat, 12 Aug 2023 12:45:35 -0400 Subject: [PATCH 18/19] test: use more specific happy path test --- src/server/api/lib/message-sending.spec.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/server/api/lib/message-sending.spec.ts b/src/server/api/lib/message-sending.spec.ts index e0671d3e7..645227b23 100644 --- a/src/server/api/lib/message-sending.spec.ts +++ b/src/server/api/lib/message-sending.spec.ts @@ -183,15 +183,27 @@ describe("automatic message handling", () => { expect(retryJobsCount).toBe(0); }); - test("responds appropriately to a contact who says YES! with a YES and MAYBE auto reply configured for the campaign", async () => { + test("queues appropriate response to a contact who says YES! with a YES and MAYBE auto reply configured for the campaign", async () => { const testbed = await withClient(pool, async (client) => { return createTestBed(client, agent, { createAutoReplies: true }); }); await sendReply(agent, testbed.cookies, testbed.contact.id, "YES!"); - const retryJobsCount = await checkRetryJobCountForContact( - testbed.contact.id + + const { + rows: [retryJobsWithYesTrigger] + } = await pool.query( + ` + select count(*) from graphile_worker.jobs j + join auto_reply_trigger art on (payload->>'interactionStepId')::int = art.interaction_step_id + where task_identifier = 'retry-interaction-step' + and payload->>'campaignContactId' = $1 + and token = 'yes' + `, + [testbed.contact.id] ); + + const retryJobsCount = parseInt(retryJobsWithYesTrigger.count, 10); expect(retryJobsCount).toBe(1); }); }); From 063fda4f97c05441690cd3d593ca552f698a9ab1 Mon Sep 17 00:00:00 2001 From: Aashish John Date: Thu, 17 Aug 2023 12:45:04 -0400 Subject: [PATCH 19/19] fix: add auto replies envvar back to config --- src/config.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/config.js b/src/config.js index 81420b628..b0813ecec 100644 --- a/src/config.js +++ b/src/config.js @@ -775,6 +775,11 @@ const config = { default: env.isTest, isClient: true }), + ENABLE_AUTO_REPLIES: bool({ + desc: "Whether auto reply handling is enabled", + default: false, + isClient: true + }), AWS_ACCESS_AVAILABLE: bool({ desc: "Enable or disable S3 campaign exports within Amazon Lambda.", default: env.isTest