Skip to content

Commit

Permalink
Merge pull request #8 from MoveOnOrg/kathy-cpa
Browse files Browse the repository at this point in the history
Support different opt-out messages per state
  • Loading branch information
crayolakat authored Feb 29, 2024
2 parents 476f2ae + acede3f commit b8b651f
Show file tree
Hide file tree
Showing 22 changed files with 563 additions and 169 deletions.
2 changes: 2 additions & 0 deletions __test__/containers/AssignmentTexterContact.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ describe("when contact is not within texting hours...", () => {
let component = mount(
<ThemeContext.Provider value={{ muiTheme }}>
<AssignmentTexterContact
mutations={{}}
texter={propsWithEnforcedTextingHoursCampaign.texter}
campaign={campaign}
assignment={propsWithEnforcedTextingHoursCampaign.assignment}
Expand Down Expand Up @@ -152,6 +153,7 @@ describe("when contact is within texting hours...", () => {
component = mount(
<ThemeContext.Provider value={{ muiTheme }}>
<AssignmentTexterContact
mutations={{}}
texter={propsWithEnforcedTextingHoursCampaign.texter}
campaign={campaign}
assignment={propsWithEnforcedTextingHoursCampaign.assignment}
Expand Down
14 changes: 14 additions & 0 deletions cypress.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
const { defineConfig } = require('cypress')

module.exports = defineConfig({
fixturesFolder: '__test__/cypress/fixtures',
video: true,
e2e: {
setupNodeEvents(on, config) {
return require('./__test__/cypress/plugins/index.js')(on, config)
},
baseUrl: 'http://localhost:3001',
specPattern: '__test__/cypress/integration/*.test.js',
supportFile: '__test__/cypress/support/index.js',
},
})
9 changes: 0 additions & 9 deletions cypress.json

This file was deleted.

2 changes: 1 addition & 1 deletion docs/HOWTO_INTEGRATE_BANDWIDTH.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Bandwidth Integration

Bandwidth.com is a telephone service API company. To use Bandwidth, set `DEFAULT_SERVICE=bandwidth`. The `sticky-sender` and `num-picker` service managers are required for the Bandwidth extension to work. `sticky-sender` must come before `numpicker-basic` in the `SERVICE_MANAGERS` environment variable.
Bandwidth.com is a telephone service API company. To use Bandwidth, set `DEFAULT_SERVICE=bandwidth`. The `sticky-sender` and `numpicker-basic` service managers are required for the Bandwidth extension to work. `sticky-sender` must come before `numpicker-basic` in the `SERVICE_MANAGERS` environment variable.

For setting up a development environment with Bandwidth, first read [this section](HOWTO_DEVELOPMENT_LOCAL_SETUP.md#ngrok).

Expand Down
3 changes: 3 additions & 0 deletions docs/REFERENCE-environment_variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
| NODE_ENV | Node environment type. _Options_: development, production. |
| NOT_IN_USA | 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_: false (i.e. default assumes a USA legal context) |
| 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 |
| 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. |
Expand All @@ -104,6 +105,8 @@
| SESSION_SECRET | Unique key used to encrypt sessions. _Required_. |
| SHOW_SERVER_ERROR | Best practice is to hide errors in production for security purposes which can reveal internal database/system state (even in an open-source project where the code paths are known) |
| SLACK_NOTIFY_URL | If set, then on post-install (often from deploying) a message will be posted to a slack channel's `#spoke` channel |
| SMARTY_AUTH_ID | Smarty API authentication ID. Required when the `OPT_OUT_PER_STATE` environment variable is enabled. |
| SMARTY_AUTH_TOKEN | Smarty API authentication token. Required when the `OPT_OUT_PER_STATE` environment variable is enabled. |
| SUPPRESS_SELF_INVITE | Boolean value to prevent self-invitations. Recommend setting before making sites available to public. _Default_: false. |
| SUPPRESS_DATABASE_AUTOCREATE | Suppress database auto-creation on first start. Mostly just used for test context |
| TERMS_REQUIRE | Require texters to accept the [Terms page](../src/containers/Terms.jsx#L85) before they can start texting. _Default_: false |
Expand Down
1 change: 1 addition & 0 deletions docs/_sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
- [How to configure Auth0 for authentication](HOWTO-configure-auth0.md)
- [Instructions for using Redis in Development and Production](HOWTO_CONNECT_WITH_REDIS.md)
- [How to configure Slack Authentication](HOWTO_INTEGRATE_SLACK_AUTH.md)
- [How to integrate Bandwidth](HOWTO_INTEGRATE_BANDWIDTH.md)
- [How to integrate Twilio](HOWTO_INTEGRATE_TWILIO.md)
- [How to handle high volume using Twilio Functions & Amazon SQS](HOWTO_setup_twilio_amazon_SQS.md)
- [How to Integrate with Action Kit](HOWTO_INTEGRATE_WITH_ACTIONKIT.md)
Expand Down
3 changes: 2 additions & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ module.exports = {
moduleNameMapper: {
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$":
"<rootDir>/__mocks__/fileMock.js",
"\\.(css|less)$": "<rootDir>/__mocks__/styleMock.js"
"\\.(css|less)$": "<rootDir>/__mocks__/styleMock.js",
"^axios$": "axios/dist/node/axios.cjs"
},
collectCoverageFrom: [
"**/*.{js,jsx}",
Expand Down
31 changes: 31 additions & 0 deletions migrations/20240116233906_opt_out_message.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = async function(knex) {
await knex.schema.createTable("opt_out_message", table => {
table.increments();
table.timestamp("created_at").defaultTo(knex.fn.now());
table.text("message").notNullable();
table
.integer("organization_id")
.references("id")
.inTable("organization")
.notNullable();
table.string("state", 2).notNullable();

table.index(
["organization_id", "state"],
"opt_out_message_organization_id_state"
);
table.unique(["organization_id", "state"]);
});
};

/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = async function(knex) {
await knex.schema.dropTableIfExists("opt_out_message");
};
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@
"request": "^2.81.0",
"rethink-knex-adapter": "0.4.20",
"rollbar": "^2.4.4",
"smartystreets-javascript-sdk": "^5.0.0",
"terser-webpack-plugin": "4",
"thinky": "^2.3.3",
"timezonecomplete": "^5.5.0",
Expand All @@ -173,7 +174,7 @@
"@babel/eslint-parser": "^7.19.1",
"babel-jest": "^29.3.1",
"babel-preset-es2017": "^6.24.1",
"cypress": "5.6.0",
"cypress": "13.6.4",
"cypress-file-upload": "^4.0.6",
"cypress-wait-until": "^1.7.1",
"enzyme": "^3.11.0",
Expand Down
5 changes: 5 additions & 0 deletions src/api/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,11 @@ const rootSchema = gql`
organizationId: String!
textingHoursEnforced: Boolean!
): Organization
getOptOutMessage(
organizationId: String
zip: String
defaultMessage: String
): String
updateOptOutMessage(
organizationId: String!
optOutMessage: String!
Expand Down
24 changes: 16 additions & 8 deletions src/components/AssignmentTexter/Controls.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,10 @@ export class AssignmentTexterContactControls extends React.Component {
}

this.state = {
contactOptOutMessage: "",
questionResponses,
filteredCannedResponses: props.campaign.cannedResponses,
optOutMessageText: props.campaign.organization.optOutMessage,
optOutMessageText: "",
responsePopoverOpen: false,
answerPopoverOpen: false,
sideboxCloses: {},
Expand Down Expand Up @@ -341,10 +342,18 @@ export class AssignmentTexterContactControls extends React.Component {
}
};

handleOpenDialog = () => {
handleOpenDialog = async () => {
// delay to avoid accidental tap pass-through with focusing on
// the text field -- this is annoying on mobile where the keyboard
// pops up, inadvertantly
const optOutMessage = (
await this.props.getOptOutMessage(
this.props.organizationId,
this.props.contact.zip,
this.props.campaign.organization.optOutMessage
)
).data.getOptOutMessage;
this.setState({contactOptOutMessage: optOutMessage, optOutMessageText: optOutMessage});
const update = { optOutDialogOpen: true };
if (this.refs.answerButtons) {
// store this, because on-close, we lose this
Expand Down Expand Up @@ -662,19 +671,18 @@ export class AssignmentTexterContactControls extends React.Component {
margin: "9px",
color:
this.state.optOutMessageText ===
this.props.campaign.organization.optOutMessage
this.state.contactOptOutMessage
? "white"
: "#494949",
backgroundColor:
this.state.optOutMessageText ===
this.props.campaign.organization.optOutMessage
this.state.contactOptOutMessage
? "#727272"
: "white"
}}
onClick={() => {
this.setState({
optOutMessageText: this.props.campaign.organization
.optOutMessage
optOutMessageText: this.state.contactOptOutMessage
});
}}
variant="contained"
Expand Down Expand Up @@ -914,8 +922,7 @@ export class AssignmentTexterContactControls extends React.Component {
: opt.answer.value,
nextScript:
(!isCurrentAnswer(opt) &&
opt.answer.nextInteractionStep &&
opt.answer.nextInteractionStep.script) ||
opt.answer.nextInteractionStep?.script) ||
null
});
}}
Expand Down Expand Up @@ -1266,6 +1273,7 @@ AssignmentTexterContactControls.propTypes = {
review: PropTypes.string,

// parent config/callbacks
getOptOutMessage: PropTypes.func,
startingMessage: PropTypes.string,
onMessageFormSubmit: PropTypes.func,
onOptOut: PropTypes.func,
Expand Down
21 changes: 21 additions & 0 deletions src/containers/AssignmentTexterContact.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,7 @@ export class AssignmentTexterContact extends React.Component {
</div>
) : null}
<Controls
getOptOutMessage={this.props.mutations.getOptOutMessage}
handleNavigateNext={this.props.handleNavigateNext}
handleNavigatePrevious={this.props.handleNavigatePrevious}
contact={this.props.contact}
Expand Down Expand Up @@ -564,6 +565,26 @@ const mutations = {
campaignContactId
}
}),
getOptOutMessage: ownProps => (organizationId, zip, defaultMessage) => ({
mutation: gql`
mutation getOptOutMessage(
$organizationId: String
$zip: String
$defaultMessage: String
) {
getOptOutMessage(
organizationId: $organizationId
zip: $zip
defaultMessage: $defaultMessage
)
}
`,
variables: {
organizationId,
zip,
defaultMessage
}
}),
updateContactTags: ownProps => (tags, campaignContactId) => ({
mutation: gql`
mutation updateContactTags(
Expand Down
13 changes: 9 additions & 4 deletions src/extensions/message-handlers/auto-optout/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { getConfig, getFeatures } from "../../../server/api/lib/config";
import { cacheableData } from "../../../server/models";
import { getOptOutMessage } from "../../../server/api/mutations";
import { sendRawMessage } from "../../../server/api/mutations/sendMessage";

const DEFAULT_AUTO_OPTOUT_REGEX_LIST_BASE64 =
Expand Down Expand Up @@ -139,10 +140,14 @@ export const postMessageSave = async ({
contact ||
(await cacheableData.campaignContact.load(message.campaign_contact_id));

const optOutMessage =
getFeatures(organization).opt_out_message ||
getConfig("OPT_OUT_MESSAGE", organization) ||
"I'm opting you out of texts immediately. Have a great day.";
const optOutMessage = await getOptOutMessage(null, {
organizationId: organization.id,
zip: contact.zip,
defaultMessage:
getFeatures(organization).opt_out_message ||
getConfig("OPT_OUT_MESSAGE", organization) ||
"I'm opting you out of texts immediately. Have a great day."
});

await sendRawMessage({
finalText: optOutMessage,
Expand Down
19 changes: 19 additions & 0 deletions src/server/api/mutations/getOptOutMessage.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import optOutMessageCache from "../../models/cacheable_queries/opt-out-message";
import zipStateCache from "../../models/cacheable_queries/zip";

export const getOptOutMessage = async (
_,
{ organizationId, zip, defaultMessage }
) => {
try {
const queryResult = await optOutMessageCache.query({
organizationId: organizationId,
state: await zipStateCache.query({ zip: zip })
});

return queryResult || defaultMessage;
} catch (e) {
console.error(e);
return defaultMessage;
}
};
1 change: 1 addition & 0 deletions src/server/api/mutations/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export { bulkUpdateScript } from "./bulkUpdateScript";
export { buyPhoneNumbers, deletePhoneNumbers } from "./buyPhoneNumbers";
export { editOrganization } from "./editOrganization";
export { findNewCampaignContact } from "./findNewCampaignContact";
export { getOptOutMessage } from "./getOptOutMessage";
export { joinOrganization } from "./joinOrganization";
export { releaseContacts } from "./releaseContacts";
export { sendMessage } from "./sendMessage";
Expand Down
11 changes: 11 additions & 0 deletions src/server/api/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import {
buyPhoneNumbers,
deletePhoneNumbers,
findNewCampaignContact,
getOptOutMessage,
joinOrganization,
editOrganization,
releaseContacts,
Expand Down Expand Up @@ -760,6 +761,7 @@ const rootMutations = {

return await cacheableData.organization.load(organizationId);
},
getOptOutMessage,
updateOptOutMessage: async (
_,
{ organizationId, optOutMessage },
Expand Down Expand Up @@ -1263,6 +1265,15 @@ const rootMutations = {
usedFields[f] = 1;
});
}

if (
getConfig("OPT_OUT_PER_STATE") &&
getConfig("SMARTY_AUTH_ID") &&
getConfig("SMARTY_AUTH_TOKEN")
) {
usedFields.zip = 1;
}

return finalContacts.map(c => (c && { ...c, usedFields }) || c);
}
}
Expand Down
3 changes: 3 additions & 0 deletions src/server/middleware/render-index.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,9 @@ export default function renderIndex(html, css, assetMap) {
window.ASSIGNMENT_CONTACTS_SIDEBAR=${getConfig(
"ASSIGNMENT_CONTACTS_SIDEBAR"
)}
window.OPT_OUT_PER_STATE=${getConfig("OPT_OUT_PER_STATE", null, {
truthy: true
})}
</script>
<script src="${assetMap["bundle.js"]}"></script>
</body>
Expand Down
4 changes: 4 additions & 0 deletions src/server/models/cacheable_queries/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ manually referencing a key inline. All root keys are prefixed by the environmen
* optOut
* SET `optouts${-orgId|}`
* if OPTOUTS_SHARE_ALL_ORGS is set, then orgId=''
* optOutMessage
* KEY `optoutmessages-${orgId}`
* campaign-contact (only when `REDIS_CONTACT_CACHE=1`)
* KEY `contact-${contactId}`
* Besides contact data, also includes `organization_id`, `messageservice_sid`, `zip.city`, `zip.state`
Expand All @@ -128,3 +130,5 @@ manually referencing a key inline. All root keys are prefixed by the environmen
* message (only when `REDIS_CONTACT_CACHE=1`)
* LIST `messages-${contactId}`
* Includes all message data
* zip
* KEY `state-of-${zip}`
Loading

0 comments on commit b8b651f

Please sign in to comment.