From efdba41aaada6d663cf89a8cfd6de97ce95bf70c Mon Sep 17 00:00:00 2001 From: Ruby Engelhart Date: Wed, 18 Dec 2024 09:51:43 -0500 Subject: [PATCH 1/9] ran yarn, got a different yarn.lock --- yarn.lock | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/yarn.lock b/yarn.lock index a5221d1ab..aaf6945df 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17755,7 +17755,7 @@ string-natural-compare@^3.0.1: resolved "https://registry.npmjs.org/string-natural-compare/-/string-natural-compare-3.0.1.tgz" integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw== -"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": version "4.2.3" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -17773,6 +17773,15 @@ string-width@^1.0.1: is-fullwidth-code-point "^1.0.0" strip-ansi "^3.0.0" +"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + string-width@^2.1.1: version "2.1.1" resolved "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz" @@ -19637,7 +19646,7 @@ workbox-window@6.6.1: "@types/trusted-types" "^2.0.2" workbox-core "6.6.1" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -19672,6 +19681,15 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz" From 46ac8b6a324d650c914b47d8192a136a07b7d87e Mon Sep 17 00:00:00 2001 From: engelhartrueben Date: Thu, 19 Dec 2024 21:05:14 -0500 Subject: [PATCH 2/9] Introduce opt_in table to keep track of contacts that opt into messaging. Add "is_opted_in" to campaign_contact table. --- migrations/20241217211012_add_optin_table.js | 32 +++++++++++++++++++ ...942_add_is_opted_in_to_campaign_contact.js | 26 +++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 migrations/20241217211012_add_optin_table.js create mode 100644 migrations/20241218161942_add_is_opted_in_to_campaign_contact.js diff --git a/migrations/20241217211012_add_optin_table.js b/migrations/20241217211012_add_optin_table.js new file mode 100644 index 000000000..c677c25b7 --- /dev/null +++ b/migrations/20241217211012_add_optin_table.js @@ -0,0 +1,32 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +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"); +}; diff --git a/migrations/20241218161942_add_is_opted_in_to_campaign_contact.js b/migrations/20241218161942_add_is_opted_in_to_campaign_contact.js new file mode 100644 index 000000000..8a1ae7f1d --- /dev/null +++ b/migrations/20241218161942_add_is_opted_in_to_campaign_contact.js @@ -0,0 +1,26 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = async knex => { + await knex.schema.alterTable("campaign_contact", table => { + table + .boolean("is_opted_in") + .defaultTo(false); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = async knex => { + const isSqlite = /sqlite/.test(knex.client.config.client); + if (!isSqlite) { + await knex.schema.alterTable("campaign_contact", table => { + table + .boolean("is_opted_in") + .defaultTo(false); + }); + } +}; From fd98424c9945aca5c222d3d5dee6cf894c0376c9 Mon Sep 17 00:00:00 2001 From: engelhartrueben Date: Thu, 19 Dec 2024 21:06:32 -0500 Subject: [PATCH 3/9] add opt_in model --- src/server/models/index.js | 4 ++++ src/server/models/opt-in.js | 28 ++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 src/server/models/opt-in.js diff --git a/src/server/models/index.js b/src/server/models/index.js index 093416fa4..58b877325 100644 --- a/src/server/models/index.js +++ b/src/server/models/index.js @@ -11,6 +11,7 @@ import CampaignContact from "./campaign-contact"; import InteractionStep from "./interaction-step"; import QuestionResponse from "./question-response"; import OptOut from "./opt-out"; +import OptIn from "./opt-in"; import JobRequest from "./job-request"; import Invite from "./invite"; import CannedResponse from "./canned-response"; @@ -64,6 +65,7 @@ const tableList = [ "log", "message", "opt_out", // good candidate + "opt_in", "pending_message_part", "question_response", "tag", @@ -131,6 +133,7 @@ const createLoaders = () => ({ jobRequest: createLoader(JobRequest), message: createLoader(Message), optOut: createLoader(OptOut), + optIn: createLoader(OptIn), pendingMessagePart: createLoader(PendingMessagePart), questionResponse: createLoader(QuestionResponse), userCell: createLoader(UserCell), @@ -165,6 +168,7 @@ export { JobRequest, Message, OptOut, + OptIn, Organization, PendingMessagePart, CannedResponse, diff --git a/src/server/models/opt-in.js b/src/server/models/opt-in.js new file mode 100644 index 000000000..0f3815524 --- /dev/null +++ b/src/server/models/opt-in.js @@ -0,0 +1,28 @@ +import thinky from "./thinky"; +const type = thinky.type; +import { optionalString, requiredString, timestamp } from "./custom-types"; + +import Organization from "./organization"; +import Assignment from "./assignment"; + +const OptIn = thinky.createModel( + "opt_in", + type + .object() + .schema({ + id: type.string(), + cell: requiredString(), + assignment_id: optionalString(), + organization_id: requiredString(), + reason_code: optionalString(), + created_at: timestamp() + }) + .allowExtra(false), + { noAutoCreation: true, dependencies: [Organization, Assignment] } +); + +OptIn.ensureIndex("cell"); +OptIn.ensureIndex("assignment_id"); +OptIn.ensureIndex("organization_id"); + +export default OptIn; From 2f21dd1cb0dd18cb4c7c4721d851ddd6cd31221d Mon Sep 17 00:00:00 2001 From: engelhartrueben Date: Thu, 19 Dec 2024 21:06:53 -0500 Subject: [PATCH 4/9] add opt_in to cacheable queries. --- src/server/models/cacheable_queries/index.js | 2 + src/server/models/cacheable_queries/opt-in.js | 144 ++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 src/server/models/cacheable_queries/opt-in.js diff --git a/src/server/models/cacheable_queries/index.js b/src/server/models/cacheable_queries/index.js index 5a6ce98b6..58d093cc8 100644 --- a/src/server/models/cacheable_queries/index.js +++ b/src/server/models/cacheable_queries/index.js @@ -5,6 +5,7 @@ import cannedResponse from "./canned-response"; import organizationContact from "./organization-contact"; import message from "./message"; import optOut from "./opt-out"; +import optIn from "./opt-in"; import organization from "./organization"; import questionResponse from "./question-response"; import { tagCampaignContactCache as tagCampaignContact } from "./tag-campaign-contact"; @@ -18,6 +19,7 @@ const cacheableData = { organizationContact, message, optOut, + optIn, organization, questionResponse, tagCampaignContact, diff --git a/src/server/models/cacheable_queries/opt-in.js b/src/server/models/cacheable_queries/opt-in.js new file mode 100644 index 000000000..fe06641bb --- /dev/null +++ b/src/server/models/cacheable_queries/opt-in.js @@ -0,0 +1,144 @@ +import { r } from "../../models"; + +// TODO: Add OPTINS_SHARE_ALL_ORGS to env doc + +const orgCacheKey = orgId => + !!process.env.OPTINS_SHARE_ALL_ORGS + ? `${process.env.CAHCE_PREFIX || ""}:optins` + : `${process.env.CACHE_PREFIS || ""}:optins-${orgId}`; + +const sharingOptIns = !!process.env.OPTINS_SHARE_ALL_ORGS + +// Probably not needed, but good to offload stress from +// db. +const loadMany = async organizationId => { + if (r.redis) { + let dbQuery = r + .knex("opt_in") + .select("cell") + .orderBy("id", "desc") + .limit(process.env.OPTINS_CACHE_MAX || 1000000); + + if (!sharingOptIns) { + dbQuery = dbQuery.where("organization_id", organizationId); + } + const dbResult = await dbQuery; + const cellOptIns = dbResult.map(rec => rec.cell); + const hashKey = orgCacheKey(organizationId); + + await r.redis.SADD(hashKey, ["0"]); + await r.redis.expire(hashKey, 43200); + + for ( + let i100 = 0, l100 = Math.ceil(cellOptOuts.length / 100); + i100 < l100; + i100++ + ) { + await r.redis.SADD( + hashKey, + cellOptIns.slice(100 * i100, 100 * i100 + 100) + ); + } + return cellOptIns.length; + } +} + +const updateIsOptedIn = async queryModifier => { + // update all organization/instance's active campaigns + const optInContactQuery = r + .knex("campaign_contact") + .join("campaign", "campaign_contact.id", "campaign.id") + .where("campaign.is_archived", false) + .select("campaign_contact.id"); + + return await r + .knex("campaign_contact") + .whereIn( + "id", + queryModifier ? queryModifier(optInContactQuery) : optInContactQuery + ) + .update("is_opted_in", true) + .update({ + is_opted_in: true + }) +} + +const optInCache = { + clearQuery: async ({ cell, organizationId }) => { + if (r.redis) { + if (cell) { + await r.redis.sdel(orgCacheKey(organizationId), cell); + } else { + await r.redis.DEL(orgCacheKey(organizationId)); + } + } + }, + query: async ({ cell, organizationId }) => { + const accountingForOrgSharing = !sharingOptIns + ? { cell, organization_id: organizationId } + : { cell }; + + if (r.redis) { + const hashKey = orgCacheKey(organizationId); + const [exists, isMember] = await r.redis + .MULTI() + .EXISTS(hashKey) + .SISMEMBER(hashKey, cell) + .exec(); + if (exists) { + return isMember; + } + + loadMany(organizationId) + .then(optInCount => { + if (!global.TEST_ENVIRONMENT) { + console.log( + "optInCache loaded for organization", + organizationId, + optInCount + ); + } + }) + .catch(err => { + console.log( + "optInCache Error for organization", + organizationId, + err + ); + }); + } + const dbResult = await r + .knex("opt_in") + .select("cell") + .where(accountingForOrgSharing) + .limit(1); + return dbResult.length > 0 + }, + save: async ({ + cell, + campaign, + assignmentId, + reason + }) => { + const organizationId = campaign.organization_id; + if (r.redis) { + const hashKey = orgCacheKey(organizationId); + const exists = await r.redis.exists(hashKey); + if (exists) { + await r.redis.SADD(hashKey, cell); + } + } + + // place into db + await r.knex("opt_in").insert({ + assignment_id: assignmentId, + organization_id: organizationId, + reason_code: reason, + cell + }); + }, + loadMany, + updateIsOptedIn +} + +export default optInCache; \ No newline at end of file From a530bad6cc677bc4e73912530ce1873884a528e1 Mon Sep 17 00:00:00 2001 From: engelhartrueben Date: Thu, 19 Dec 2024 21:10:27 -0500 Subject: [PATCH 5/9] add auto-optin message handler. --- .../message-handlers/auto-optin/index.js | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 src/extensions/message-handlers/auto-optin/index.js diff --git a/src/extensions/message-handlers/auto-optin/index.js b/src/extensions/message-handlers/auto-optin/index.js new file mode 100644 index 000000000..1efd60a57 --- /dev/null +++ b/src/extensions/message-handlers/auto-optin/index.js @@ -0,0 +1,97 @@ +import { getConfig, getFeatures } from "../../../server/api/lib/config"; +import { cacheableData } from "../../../server/models"; + +const DEFAULT_AUTO_OPTIN_REGEX_LIST_BASE64 = + "W3sicmVnZXgiOiAiXlNUQVJUJCIsICJyZWFzb24iOiAic3RhcnQifV0="; + +// DEFAULT_AUTO_OPTIN_REGEX_LIST_BASE64 decodes to: +// [{"regex": "^START$"", "reason": "start"}] + +export const serverAdministratorInstructions = () => { + return { + description: `Can add people to opt in list depending on the reply`, + setupInstructions: ` + Add "auto-optin" to message hanlder environment variable. + Can change default optin langauge by adding AUTO_OPTIN_REGEX_LIST_BASE64 to + \`.env\`. This variable should be a JSON object encoded in base64. See line 8 for + current structure. + `, + environmentVariables: ["AUTO_OPTIN_REGEX_LIST_BASE64"] + } +} + +export const available = organization => { + const config = getConfig("AUTO_OPTIN_REGEX_LIST_BASE64", organization) || + DEFAULT_AUTO_OPTIN_REGEX_LIST_BASE64; + + if (!config) return false; + try { + JSON.parse(Buffer.from(config, "base64").toString()); + return true; + } catch (exception) { + console.log( + "message-handler/auto-optin JSON parse of AUTO_OPTIN_REGEX_LIST_BASE64 failed", + exception + ); + return false; + } +} + +export const preMessageSave = ({ messageToSave, organization }) => { + if (messageToSave.is_from_contact) { + const config = Buffer.from( + getConfig("AUTO_OPTIN_REGEX_LIST_BASE64", organization) || + DEFAULT_AUTO_OPTIN_REGEX_LIST_BASE64, + "base64" + ).toString(); + const regexList = JSON.parse(config || "[]"); + const matches = regexList.filter(matcher => { + const re = new RegExp(matcher.regex); // Want case sensitivity, probably? + return String(messageToSave.text).match(re); + }) + if (matches.length) { + console.log( + `auto-optin MATCH ${messageToSave.campaign_contact_id}` + ); + const reason = matches[0].reason || "auto_optin"; + + // UNSURE OF THIS RETURN + return { + contactUpdates: { + is_opted_in: true + }, + handlerContext: { + autoOptInReason: reason, + }, + messageToSave + }; + } + } +} + +export const postMessageSave = async ({ + message, + organization, + handlerContext, + campaign +}) => { + if (!message.is_from_contact || !handlerContext.autoOptInReason) return; + + console.log( + `auto-optin.postMessageSave ${message.campaign_contact_id}` + ); + + let contact = await cacheableData.campaignContact.load( + message.campaign_contact_id, + { cahceOnly: true} + ); + campaign = campaign || { organization_id: organization.id}; + + // Save to DB + await cacheableData.optIn.save({ + cell: message.contact_number, + campaign, + assignmentId: (contact && contact.assignment_id) || null, + reason: handlerContext.autoOptInReason + }); +} \ No newline at end of file From f3500ec6fbea5e254827697e782307fcb4ce45f2 Mon Sep 17 00:00:00 2001 From: engelhartrueben Date: Thu, 19 Dec 2024 21:13:05 -0500 Subject: [PATCH 6/9] add auto-optin docuemntation to message handler doc --- docs/HOWTO-use-message-handlers.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/HOWTO-use-message-handlers.md b/docs/HOWTO-use-message-handlers.md index 53a98960e..63744f3f7 100644 --- a/docs/HOWTO-use-message-handlers.md +++ b/docs/HOWTO-use-message-handlers.md @@ -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 From 6bc79a010496c839366799d2de0e2accc50bb2c8 Mon Sep 17 00:00:00 2001 From: engelhartrueben Date: Thu, 19 Dec 2024 21:15:36 -0500 Subject: [PATCH 7/9] Add AUTO_OPTIN_REGEX_LIST_BASE64 to env doc --- docs/REFERENCE-environment_variables.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/REFERENCE-environment_variables.md b/docs/REFERENCE-environment_variables.md index 2eaa355cd..4c4ed0970 100644 --- a/docs/REFERENCE-environment_variables.md +++ b/docs/REFERENCE-environment_variables.md @@ -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. | From 53112d7810d4525b5587ca986854c47c5e3eac03 Mon Sep 17 00:00:00 2001 From: engelhartrueben Date: Thu, 19 Dec 2024 21:17:40 -0500 Subject: [PATCH 8/9] Add OPTINS_SHARE_ALL_ORGS to env doc --- docs/REFERENCE-environment_variables.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/REFERENCE-environment_variables.md b/docs/REFERENCE-environment_variables.md index 4c4ed0970..a58537ef5 100644 --- a/docs/REFERENCE-environment_variables.md +++ b/docs/REFERENCE-environment_variables.md @@ -88,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`. | From 8cdf519d9732e70e82fed087ecd545fd69cdbe76 Mon Sep 17 00:00:00 2001 From: engelhartrueben Date: Mon, 23 Dec 2024 15:40:31 -0500 Subject: [PATCH 9/9] add tests for auto-optin message handler --- .../message-handlers/auto-optin.test.js | 321 ++++++++++++++++++ 1 file changed, 321 insertions(+) create mode 100644 __test__/extensions/message-handlers/auto-optin.test.js diff --git a/__test__/extensions/message-handlers/auto-optin.test.js b/__test__/extensions/message-handlers/auto-optin.test.js new file mode 100644 index 000000000..f3ff9a462 --- /dev/null +++ b/__test__/extensions/message-handlers/auto-optin.test.js @@ -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 + }) + }) + ); + }); +}); \ No newline at end of file