Skip to content

Update Opt-In Status W/ VAN #2524

New issue

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

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

Already on GitHub? Sign in to your account

Open
wants to merge 22 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
8b60a9f
Create ngpvan-optout message handler.
engelhartrueben Nov 25, 2024
f6fb04e
Add auto-optout Message Handler to ngpvan-optout requirments
engelhartrueben Nov 25, 2024
361e16f
Basic introduction to opting out a contact in VAN when manually opted…
engelhartrueben Nov 25, 2024
2c09948
check for VAN ID in custom fields before attempting to send a reques…
engelhartrueben Dec 3, 2024
8a0a398
Add additional note that manual opt outs are handled by a different p…
engelhartrueben Dec 3, 2024
a54cdb0
Test for vanId when manually opting someone out
engelhartrueben Dec 3, 2024
b4d391b
Fixed two minor bugs in ngpvan-optout.
engelhartrueben Dec 5, 2024
e2a3df9
access VanID properly; return in case of failure; prettify
engelhartrueben Dec 5, 2024
8b38d4f
Merge branch 're/manual-van-optout' into re/VAN_auto_opt_out
engelhartrueben Dec 5, 2024
cd4a8ac
clear up faulty logic
Dec 6, 2024
e73268c
add test for ngpvan-optout message-handler
engelhartrueben Dec 9, 2024
c76587f
adjust when contact and handler context are checked
engelhartrueben Dec 9, 2024
7d0bbf4
After some brief testing, there were some bad assumptions made about …
engelhartrueben Dec 10, 2024
f2e098c
Slight refactor from previous.
engelhartrueben Dec 11, 2024
df04c67
refactor away from using the van action-handler post request.
engelhartrueben Dec 13, 2024
c347bb5
tests and ironing out the final logic
engelhartrueben Dec 13, 2024
5e627ce
adjust how manually opting out someone works by reusing the ngpvan-op…
engelhartrueben Dec 13, 2024
cfc3928
add documentation for ngpvan-optout
engelhartrueben Dec 13, 2024
a375f07
skip test which checks if message is from contact. May be a world whe…
engelhartrueben Dec 13, 2024
fa96e28
reason is undefined, set optOutReason to manual
engelhartrueben Dec 13, 2024
122f0c9
add test to check for alternate opt out reason in handlerContext object
engelhartrueben Dec 13, 2024
c463b35
bad copy paste
engelhartrueben Dec 13, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
240 changes: 240 additions & 0 deletions __test__/extensions/message-handlers/ngpvan-optout.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
});
8 changes: 8 additions & 0 deletions docs/HOWTO-use-message-handlers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
125 changes: 125 additions & 0 deletions src/extensions/message-handlers/ngpvan-optout/index.js
Original file line number Diff line number Diff line change
@@ -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 {};
}


Loading
Loading