diff --git a/__test__/extensions/message-handlers/ngpvan-optout.test.js b/__test__/extensions/message-handlers/ngpvan-optout.test.js new file mode 100644 index 000000000..7c7ff9dac --- /dev/null +++ b/__test__/extensions/message-handlers/ngpvan-optout.test.js @@ -0,0 +1,240 @@ +const VanOptOut = require("../../../src/extensions/message-handlers/ngpvan-optout"); +const VanUtil = require("../../../src/extensions/contact-loaders/ngpvan/util"); +const HttpRequest = require("../../../src/server/lib/http-request"); + +describe("extensions.message-handlers.ngpvan-optout", () => { + afterEach(async () => { + jest.restoreAllMocks(); + }); + + describe("postMessageSave", () => { + let message; + let contact; + let organization; + let handlerContext; + + beforeEach(async () => { + message = { + campaign_contact_id: "1234", + contact_number: "(123)-456-7890", + is_from_contact: true + }; + + organization = { + id: 1 + }; + + handlerContext = { + autoOptOutReason: "stop" + } + + jest.spyOn(VanOptOut, "available").mockReturnValue(true); + jest.spyOn(VanOptOut, "dbQuery").mockReturnValue([{custom_fields: '{"VanID": 1234}'}]); + + jest.spyOn(VanUtil.default, "getAuth").mockReturnValue("*****"); + + jest.spyOn(HttpRequest, "default").mockReturnValue(null); + }); + + it("delegates to its dependencies and DOES post to NGP VAN", async () => { + const result = await VanOptOut.postMessageSave({ + handlerContext, + organization, + message + }); + + expect(result).toEqual({}); + + expect(HttpRequest.default.mock.calls).toEqual( + [ + [ + "https://api.securevan.com/v4/people/1234/canvassResponses", + { + "method": "POST", + "retries": 1, + "timeout": 32000, + "headers": { + "Authorization": "*****", + "accept": "text/plain", + "Content-Type": "application/json" + }, + "body": `{"canvassContext":{"inputTypeId":11,"phone":{"dialingPrefix":"1"`+ + `,"phoneNumber":"123-456-7890","smsOptInStatus":"O"}},"resultCodeId":130}`, + "validStatuses": [204], + "compress": false + } + ] + ] + ); + }); + + describe("when the handler is not available", () => { + beforeEach(async () => { + VanOptOut.available.mockReturnValue(false); + }); + + it("returns an empty object and DOES NOT post to NGP VAN", async () => { + const result = await VanOptOut.postMessageSave({ + handlerContext, + organization, + message + }); + + expect(result).toEqual({}); + expect(HttpRequest.default.mock.calls).toHaveLength(0); + }); + }); + + describe("when message is null or undefined", () => { + beforeEach(async () => { + handlerContext = {} + }); + + it("returns an empty object and DOES NOT post to NGP VAN", async () => { + const result = await VanOptOut.postMessageSave({ + handlerContext, + organization, + message + }); + + expect(result).toEqual({}); + expect(HttpRequest.default.mock.calls).toHaveLength(0); + }); + }); + + describe("when no VAN Id is inclued", () => { + beforeEach(async () => { + VanOptOut.dbQuery.mockReturnValue({}); + }); + + it("returns an empty object and DOES NOT post to NGP VAN", async () => { + const result = await VanOptOut.postMessageSave({ + handlerContext, + organization, + message + }); + + expect(result).toEqual({}); + expect(HttpRequest.default.mock.calls).toHaveLength(0); + }) + }) + + describe("when alternate VAN ID is included", () => { + beforeEach(async () => { + VanOptOut.dbQuery.mockReturnValue([{custom_fields: '{"vanid": 1234}'}]) + }); + + it("still works and DOES post to NGP VAN", async () => { + const result = await VanOptOut.postMessageSave({ + handlerContext, + organization, + message + }); + + expect(result).toEqual({}); + expect(HttpRequest.default.mock.calls).toEqual( + [ + [ + "https://api.securevan.com/v4/people/1234/canvassResponses", + { + "method": "POST", + "retries": 1, + "timeout": 32000, + "headers": { + "Authorization": "*****", + "accept": "text/plain", + "Content-Type": "application/json" + }, + "body": `{"canvassContext":{"inputTypeId":11,"phone":{"dialingPrefix":"1"`+ + `,"phoneNumber":"123-456-7890","smsOptInStatus":"O"}},"resultCodeId":130}`, + "validStatuses": [204], + "compress": false + } + ] + ] + ); + }) + }); + + describe("when no contact number is included in the message object", () => { + beforeEach(async () => { + message = { + ...message, + contact_number: "" + }; + }); + + it("returns an object and DOES NOT post to NGP VAN", async () => { + const result = await VanOptOut.postMessageSave({ + handlerContext, + organization, + message + }); + + expect(result).toEqual({}); + expect(HttpRequest.default.mock.calls).toHaveLength(0); + }); + }); + + describe("when the alternate optOutReason is passed in the handlerContext object", () => { + beforeEach(async () => { + handlerContext = { + optOutReason: "manual" + } + }); + + it("returns an empty object and DOES post to NGP VAN", async () => { + const result = await VanOptOut.postMessageSave({ + handlerContext, + organization, + message + }) + + expect(result).toEqual({}); + expect(HttpRequest.default.mock.calls).toEqual( + [ + [ + "https://api.securevan.com/v4/people/1234/canvassResponses", + { + "method": "POST", + "retries": 1, + "timeout": 32000, + "headers": { + "Authorization": "*****", + "accept": "text/plain", + "Content-Type": "application/json" + }, + "body": `{"canvassContext":{"inputTypeId":11,"phone":{"dialingPrefix":"1"`+ + `,"phoneNumber":"123-456-7890","smsOptInStatus":"O"}},"resultCodeId":130}`, + "validStatuses": [204], + "compress": false + } + ] + ] + ); + }) + }) + + // Skipping as there is a world where we opt out someone + // even when the message is not from them originally + describe.skip("when the message is not from the contact", () => { + beforeEach(async () => { + message = { + ...message, + is_from_contact: false + }; + }); + + it("returns and empty obejct and DOES NOT post to NGP VAN", async () => { + const result = await VanOptOut.postMessageSave({ + handlerContext, + organization, + message + }); + + expect(result).toEqual({}); + expect(HttpRequest.default.mock.calls).toHaveLength(0); + }); + }); + }); +}); diff --git a/docs/HOWTO-use-message-handlers.md b/docs/HOWTO-use-message-handlers.md index 53a98960e..2abb73ad5 100644 --- a/docs/HOWTO-use-message-handlers.md +++ b/docs/HOWTO-use-message-handlers.md @@ -33,6 +33,14 @@ This is especially useful to auto-optout hostile contact replies so texters do n need to see them. Additionally the JSON object can encode a "reason_code" that will be logged in the opt_out table record. +### ngpvan-optout + +Sends a POST request to NGP VAN to update a record that they are now opted out from +future campaigns. + +Requires that the contact have `vanid` or `VanID` in their custom_feilds. +Requires auto-optout to be enabled. + ### profanity-tagger Before you enable a custom regular expression with auto-optout, we recommend strongly diff --git a/src/extensions/message-handlers/ngpvan-optout/index.js b/src/extensions/message-handlers/ngpvan-optout/index.js new file mode 100644 index 000000000..b6bcad328 --- /dev/null +++ b/src/extensions/message-handlers/ngpvan-optout/index.js @@ -0,0 +1,125 @@ +import { hasConfig, getConfig } from "../../../server/api/lib/config"; +import { r } from "../../../server/models"; +import httpRequest from "../../../server/lib/http-request"; +import { + getCountryCode, + getDashedPhoneNumberDisplay +} from "../../../lib/phone-format"; +import Van from "../../contact-loaders/ngpvan/util"; + +export const serverAdministratorInstructions = () => { + return { + description: ` + Update the contact in VAN with an opt out status + if and only if the internal Spoke auto-optout triggers. + Manual opt outs are handled by a different process. + `, + setupInstructions: ` + This message handler is dependent on the ngpvan-action Action Handler, + and the auto-optout Message Handler. + Follow their setup instructions. + Additionally, "ngpvan-optout" must be added to the message handler + environment variable. + `, + // Does this include NGP_VAN env variables and what not? + environmentVariables: [] + }; +} + +export const dbQuery = async campaignContactId => { + return await r + .knex("campaign_contact") + .select("custom_fields") + .where("id", campaignContactId) +} + +export const available = organization => + (hasConfig("NGP_VAN_API_KEY", organization) || + hasConfig("NGP_VAN_API_KEY_ENCRYPTED", organization)) && + hasConfig("NGP_VAN_APP_NAME", organization) && + getConfig("MESSAGE_HANDLERS", organization).indexOf("auto-optout") !== -1; + +/* + * Sends a request to VAN to place an opt out tag to an individual. + */ +export const postMessageSave = async ({ + handlerContext, + organization, + message +}) => { + // Redundent, but other message-handlers check this first as well + if (!exports.available(organization)) return {}; + + let query; // store custom_fields of the contact + let customField; + let vanId; // vanid of contact + let cell; // phone number that sent opt out message + let phoneCountry; // The coutnry code + let url; // url of VAN api + + // If no message or optOut, return + if ( + !message || + !(handlerContext.autoOptOutReason || handlerContext.optOutReason) + ) return {}; + + + try { + query = await exports.dbQuery(message.campaign_contact_id); + customField = JSON.parse(query[0]["custom_fields"] || "{}"); + + vanId = customField["VanID"] || customField["vanid"]; + cell = message["contact_number"] || ""; + } catch (exception) { + console.error( + `postMessageSave.ngpvan-optout ERROR finding contact or ` + + `parsing custom fields for contact ${message.campaign_contact_id}` + ) + } + + // if no van id or cell #, return + if (!vanId || !cell) return {}; + + phoneCountry = process.env.PHONE_NUMBER_COUNTRY || "US"; + cell = getDashedPhoneNumberDisplay(cell, phoneCountry); + + url = Van.makeUrl(`v4/people/${vanId}/canvassResponses`, organization); + + // https://docs.ngpvan.com/reference/peoplevanidcanvassresponses + const body = { + "canvassContext": { + "inputTypeId": 11, // API input + "phone": { + "dialingPrefix": getCountryCode(cell, phoneCountry).toString(), + "phoneNumber": cell, + "smsOptInStatus": "O" // opt out status + } + }, + // Do Not Text result code + // Unsure if this is specfic to each VAN committe ? + "resultCodeId": 130 + }; + + console.log( + `ngpvan-optout.postMessageSave VAN ID : ${vanId} ` + + `: ${handlerContext.autoOptOutReason || handlerContext.optOutReason}` + ); + + await httpRequest(url, { + method: "POST", + retries: 1, + timeout: Van.getNgpVanTimeout(organization), + headers: { + Authorization: await Van.getAuth(organization), + "accept": "text/plain", + "Content-Type": "application/json" + }, + body: JSON.stringify(body), + validStatuses: [204], + compress: false + }) + + return {}; +} + + diff --git a/src/server/api/schema.js b/src/server/api/schema.js index 86730b90a..a00df67f0 100644 --- a/src/server/api/schema.js +++ b/src/server/api/schema.js @@ -6,7 +6,6 @@ import _ from "lodash"; import { gzip, makeTree, getHighestRole } from "../../lib"; import { capitalizeWord, groupCannedResponses } from "./lib/utils"; import httpRequest from "../lib/http-request"; -import ownedPhoneNumber from "./lib/owned-phone-number"; import { getIngestMethod } from "../../extensions/contact-loaders"; import { @@ -82,6 +81,12 @@ import { Jobs } from "../../workers/job-processes"; import { Tasks } from "../../workers/tasks"; const uuidv4 = require("uuid").v4; +const Van = require("../../extensions/action-handlers/ngpvan-action.js"); + +import { + available, + postMessageSave as optOutInVan +} from "../../extensions/message-handlers/ngpvan-optout"; // This function determines whether a field was requested // in a graphql query. Each graphql resolver receives a fourth parameter, @@ -1292,7 +1297,7 @@ const rootMutations = { } } return finalContacts; - }, + }, // createOptOut: async ( _, { optOut, campaignContactId, noReply }, @@ -1342,6 +1347,21 @@ const rootMutations = { const newContact = cacheableData.campaignContact.updateCacheForOptOut( contact ); + + if (!available(organization)) return newContact; + + // Reusing VAN opt out message-handler + await optOutInVan({ + handlerContext: { + optOutReason: "manual" + }, + organization, + message: { + campaign_contact_id: campaignContactId, + contact_number: contact.cell + } + }) + return newContact; }, deleteQuestionResponses: async (