Skip to content

Add auto opt in feature #2534

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 9 commits into
base: main
Choose a base branch
from
321 changes: 321 additions & 0 deletions __test__/extensions/message-handlers/auto-optin.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,321 @@
import {
preMessageSave,
postMessageSave
} from "../../../src/extensions/message-handlers/auto-optin";
import { cacheableData, r } from "../../../src/server/models";

import {
setupTest,
cleanupTest,
createStartedCampaign
} from "../../test_helpers";

const CacheableMessage = require("../../../src/server/models/cacheable_queries/message");
const saveMessage = CacheableMessage.default.save;

const AutoOptin = require("../../../src/extensions/message-handlers/auto-optin");

const config = require("../../../src/server/api/lib/config");

describe("Auto Opt-In Tests", () => {
let messageToSave;
let organization;

beforeEach(() => {
jest.resetAllMocks();

global.DEFAULT_SERVICE = "fakeservice";

jest.spyOn(cacheableData.optIn, "save").mockResolvedValue(null);
jest.spyOn(cacheableData.campaignContact, "load").mockResolvedValue({
id: 1,
assignment_id: 2
});
jest.spyOn(AutoOptin, "available").mockReturnValue(true);
jest.spyOn(config, "getConfig").mockReturnValue("");

messageToSave = {
is_from_contact: true,
contact_number: "+1234567890",
capmaign_contact_id: 1,
text: "START",
campaign_contact_id: 42
};
// I think this is the structure,
// even if wrong, doesnt affect test
organization = 1;
})

afterEach(() => {
global.DEFAULT_SERVICE = "fakeservice";
});

describe("preMessageSave", () => {
it("returns object on default settings", async () => {
const result = preMessageSave({
messageToSave,
organization
});

expect(config.getConfig).toHaveBeenCalled();
expect(result).toEqual({
contactUpdates: {
is_opted_in: true
},
handlerContext: {
autoOptInReason: "start"
},
messageToSave
})
});

it("does not return with a non matching message", async () => {
messageToSave = {
...messageToSave,
text: "just another message"
};

const result = preMessageSave({
messageToSave,
organization
});

expect(config.getConfig).toHaveBeenCalled();
expect(result).toEqual(undefined);
});

it("does not return, even when START is apart of the text", async () => {
// This is inline with DEFAULT_AUTO_OPTIN_REGEX_LIST_BASE64.
// If AUTO_OPTIN_REGEX_LIST_BASE64 is enabled, may change behavior.
messageToSave = {
...messageToSave,
text: "START, but do not opt me in"
};

const result = preMessageSave({
messageToSave,
organization
});

expect(config.getConfig).toHaveBeenCalled();
expect(result).toEqual(undefined);
});

it("returns an object after changing default regex", async () => {
// this also tests autoOptInReason is "optin"
jest.spyOn(config, "getConfig").mockReturnValue(
"W3sicmVnZXgiOiAiXk9QVC1JTiQiLCAicmVhc29uIjogIm9wdGluIn1d"
); // [{"regex": "^OPT-IN$"", "reason": "optin"}]

messageToSave = {
...messageToSave,
text:"OPT-IN"
};

const result = preMessageSave({
messageToSave,
organization
})

expect(result).toEqual({
contactUpdates: {
is_opted_in: true
},
handlerContext: {
autoOptInReason: "optin"
},
messageToSave
})
});

it("tests autoOptInReason defaults to \"auto_optin\" when no reason is given in regex", async () => {
jest.spyOn(config, "getConfig").mockReturnValue(
"W3sicmVnZXgiOiAiXk9QVC1JTiQifV0="
); // [{"regex": "^OPT-IN$"}]

messageToSave = {
...messageToSave,
text: "OPT-IN"
};

const result = preMessageSave({
messageToSave,
organization
});

expect(result).toEqual({
contactUpdates: {
is_opted_in: true
},
handlerContext: {
autoOptInReason: "auto_optin"
},
messageToSave
})
});
})

describe("postMessageSave", () => {
let message;
let organization;
let handlerContext;
let campaign;

beforeEach( async () => {
jest.restoreAllMocks();

global.DEFAULT_SERVICE = "fakeservice";

message = {
is_from_contact: true,
campaign_contact_id: 42
};

organization = {
id: 2
};

handlerContext = {
autoOptInReason: "start"
};

campaign = {}

jest.spyOn(cacheableData.campaignContact, "load").mockReturnValue(null);
jest.spyOn(cacheableData.optIn, "save").mockReturnValue(null);
});

afterEach(async () => {
jest.restoreAllMocks();
global.DEFAULT_SERVICE = "fakeservice";
});

it("saves to optIn table", async () => {
await postMessageSave({
message,
organization,
handlerContext,
campaign
});

expect(cacheableData.campaignContact.load).toHaveBeenCalled();
expect(cacheableData.optIn.save).toHaveBeenCalled();
});

it("does not save to optin table with no handlerContext.autoOptInReason", async () => {
handlerContext = {};

await postMessageSave({
message,
organization,
handlerContext,
campaign
});

expect(cacheableData.campaignContact.load).toHaveBeenCalledTimes(0);
expect(cacheableData.optIn.save).toHaveBeenCalledTimes(0);
});

it("does not save to optin table with when message is not from contact", async () => {
message = {};

await postMessageSave({
message,
organization,
handlerContext,
campaign
});

expect(cacheableData.campaignContact.load).toHaveBeenCalledTimes(0);
expect(cacheableData.optIn.save).toHaveBeenCalledTimes(0);
});
});
});

describe("Tests for Auto Opt-Out's members getting called from messageCache.save", () => {
let contacts;
let organization;
let texter;

let service;
let messageServiceSID;

beforeEach(async () => {
await cleanupTest();
await setupTest();
jest.restoreAllMocks();

global.MESSAGE_HANDLERS = "auto-optin";

const startedCampaign = await createStartedCampaign();

({
testContacts: contacts,
testTexterUser: texter,
testOrganization: {
data: { createOrganization: organization }
}
} = startedCampaign);

service = "twilio";
messageServiceSID = "some_messsage_service_id";

const messageToContact = {
is_from_contact: false,
contact_number: contacts[0].cell,
campaign_contact_id: contacts[0].id,
send_status: "SENT",
text: "Hi",
service,
texter,
messageservice_sid: messageServiceSID
};

await saveMessage({
messageInstance: messageToContact,
contact: contacts[0],
organization,
texter
});
}, global.DATABASE_SETUP_TEARDOWN_TIMEOUT);

afterEach(async () => {
await cleanupTest();
}, global.DATABASE_SETUP_TEARDOWN_TIMEOUT);

it("gets called", async () => {
const message = {
is_from_contact: true,
contact_number: contacts[0].cell,
service,
messageservice_sid: messageServiceSID,
text: "START",
send_status: "DELIVERED" // ??
};

jest.spyOn(AutoOptin, "preMessageSave").mockResolvedValue(null);
jest.spyOn(AutoOptin, "postMessageSave").mockResolvedValue(null);

await saveMessage({
messageInstance: message
});

expect(AutoOptin.preMessageSave).toHaveBeenCalledWith(
expect.objectContaining({
messageToSave: expect.objectContaining({
text: "START",
contact_number: contacts[0].cell
})
})
);

expect(AutoOptin.postMessageSave).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.objectContaining({
text: "START",
contact_number: contacts[0].cell
})
})
);
});
});
7 changes: 7 additions & 0 deletions docs/HOWTO-use-message-handlers.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ 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.

## auto-optin

When a contact replies with "START" (case sensitive), they are added to a the opt_in
table and marked as opted-in in the campaign_contact table. You may alter the opt-in
language by adding AUTO_OPTIN_REGEX_LIST_BASE64 which should be a JSON object encoded
in base64 following the structure: \`[{\"regex\": \"\",\"reason\": \"\"}]\`

### profanity-tagger

Before you enable a custom regular expression with auto-optout, we recommend strongly
Expand Down
2 changes: 2 additions & 0 deletions docs/REFERENCE-environment_variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
| AUTH0_DOMAIN | Domain name on Auth0 account, should end in `.auth0.com`, e.g. `example.auth0.com`. _Required_. |
| AUTH0_CLIENT_ID | Client ID from Auth0 app. _Required_. |
| AUTH0_CLIENT_SECRET | Client secret from Auth0 app. _Required_. |
| AUTO_OPTIN_REGEX_LIST_BASE64 | JSON object encoded in base64 to specify opt in language following the structure: \`[{\"regex\": \"\",\"reason\": \"\"}]\`. |
| AWS_ACCESS_AVAILABLE | 1 or 0 to enable or disable S3 campaign exports within Amazon Lambda. |
| AWS_ACCESS_KEY_ID | AWS access key ID with access to S3 bucket, required for campaign exports outside Amazon Lambda. |
| AWS_SECRET_ACCESS_KEY | AWS access key secret with access to S3 bucket, required for campaign exports outside Amazon Lambda. |
Expand Down Expand Up @@ -87,6 +88,7 @@
| OPT_OUT_MESSAGE | Spoke instance-wide default for opt out message. |
| OPT_OUT_PER_STATE | Have different opt-out messages per state and org. Defaults to the organization's default opt-out message for non-specified states or when the Smarty Zip Code API is down. Requires the `SMARTY_AUTH_ID` and `SMARTY_AUTH_TOKEN` environment variables. |
| OPTOUTS_SHARE_ALL_ORGS | Can be set to true if opt outs should be respected per instance and across organizations |
| OPTINS_SHARE_ALL_ORGS | Can be set to true if opt ins should be respected per instance and across organizations |
| OUTPUT_DIR | Directory path for packaged files should be saved to. _Required_. |
| OWNER_CONFIGURABLE | If set to `ALL` then organization owners will be able to configure all available options from their Settings section (otherwise only superadmins will). You can also put a comma-separated list of environment variables to white-list specific settable variables here. This gives organization owners a lot of control of internal settings, so enable at your own risk. |
| PASSPORT_STRATEGY | A flag to set passport strategy to use for user authentication. The Auth0 strategy will be used if the value is an empty string or `auth0`. The local strategy will be used if the value is `local`. |
Expand Down
32 changes: 32 additions & 0 deletions migrations/20241217211012_add_optin_table.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = async knex => {
await knex.schema.hasTable("opt_in").then(async exists => {
if (exists) return;
await knex.schema.createTable("opt_in", table => {
table.increments("id")
table.text("cell").notNullable();
table.integer("assignment_id").nullable();;
table.integer("organization_id").notNullable();
// Not in love with "reason_code", but doing so to match
// opt_out table
table.text("reason_code").notNullable().defaultTo("");
table.timestamp("created_at").notNullable().defaultTo(knex.fn.now());

table.index("cell");
table.index("assignment_id");
table.foreign("assignment_id").references("assignment.id");
table.index("organization_id");
table.foreign("organization_id").references("organization.id")
});
});
};

/**
* @param { import("knex").Knex } knex
*/
exports.down = async function(knex) {
return await knex.schema.dropTableIfExists("opt_in");
};
Loading
Loading