From 43d8bf709a67616037b80521a77a83e85d5afc35 Mon Sep 17 00:00:00 2001 From: Jeff Mann Date: Thu, 3 Sep 2020 16:01:51 -0400 Subject: [PATCH 01/10] Allow encrypted per-org VAN API keys --- .../action-handlers/ngpvan-action.js | 1 + .../contact-loaders/ngpvan/index.js | 1 + src/extensions/contact-loaders/ngpvan/util.js | 6 +++-- .../message-handlers/ngpvan/index.js | 1 + src/server/api/lib/config.js | 11 ++++++++- src/server/api/lib/crypto.js | 23 +++++++++++-------- .../models/cacheable_queries/organization.js | 3 +-- 7 files changed, 31 insertions(+), 15 deletions(-) diff --git a/src/extensions/action-handlers/ngpvan-action.js b/src/extensions/action-handlers/ngpvan-action.js index edff5c2d7..d78ff256a 100644 --- a/src/extensions/action-handlers/ngpvan-action.js +++ b/src/extensions/action-handlers/ngpvan-action.js @@ -362,6 +362,7 @@ export async function getClientChoiceData(organization) { // process.env.ACTION_HANDLERS export async function available(organization) { let result = + !!getConfig("NGP_VAN_API_KEY_ENCRYPTED", organization) && !!getConfig("NGP_VAN_API_KEY", organization) && !!getConfig("NGP_VAN_APP_NAME", organization); diff --git a/src/extensions/contact-loaders/ngpvan/index.js b/src/extensions/contact-loaders/ngpvan/index.js index 8b34bb56f..505bea7a7 100644 --- a/src/extensions/contact-loaders/ngpvan/index.js +++ b/src/extensions/contact-loaders/ngpvan/index.js @@ -58,6 +58,7 @@ export async function available(organization, user) { // / then it's better to allow the result to be cached const result = + !!getConfig("NGP_VAN_API_KEY_ENCRYPTED", organization) && !!getConfig("NGP_VAN_API_KEY", organization) && !!getConfig("NGP_VAN_APP_NAME", organization) && !!getConfig("NGP_VAN_WEBHOOK_BASE_URL", organization); diff --git a/src/extensions/contact-loaders/ngpvan/util.js b/src/extensions/contact-loaders/ngpvan/util.js index 759e70201..cede13141 100644 --- a/src/extensions/contact-loaders/ngpvan/util.js +++ b/src/extensions/contact-loaders/ngpvan/util.js @@ -1,4 +1,4 @@ -import { getConfig } from "../../../server/api/lib/config"; +import { getConfig, hasConfig } from "../../../server/api/lib/config"; export const DEFAULT_NGP_VAN_API_BASE_URL = "https://api.securevan.com"; export const DEFAULT_NGP_VAN_DATABASE_MODE = 0; @@ -7,7 +7,9 @@ export const DEFAULT_NGPVAN_TIMEOUT = 32000; export default class Van { static getAuth = organization => { const appName = getConfig("NGP_VAN_APP_NAME", organization); - const apiKey = getConfig("NGP_VAN_API_KEY", organization); + const apiKey = hasConfig("NGP_VAN_API_KEY_ENCRYPTED", organization) + ? getConfig("NGP_VAN_API_KEY_ENCRYPTED", organization) + : getConfig("NGP_VAN_API_KEY", organization); const databaseMode = getConfig("NGP_VAN_DATABASE_MODE", organization); if (!appName || !apiKey) { diff --git a/src/extensions/message-handlers/ngpvan/index.js b/src/extensions/message-handlers/ngpvan/index.js index 6490668f9..1c4ca017a 100644 --- a/src/extensions/message-handlers/ngpvan/index.js +++ b/src/extensions/message-handlers/ngpvan/index.js @@ -21,6 +21,7 @@ export const serverAdministratorInstructions = () => { }; export const available = organization => + !!getConfig("NGP_VAN_API_KEY_ENCRYPTED", organization) && !!getConfig("NGP_VAN_API_KEY", organization) && !!getConfig("NGP_VAN_APP_NAME", organization); diff --git a/src/server/api/lib/config.js b/src/server/api/lib/config.js index 22098d2c1..41b843181 100644 --- a/src/server/api/lib/config.js +++ b/src/server/api/lib/config.js @@ -1,4 +1,5 @@ import fs from "fs"; +import { symmetricDecrypt } from "./crypto"; // This is for centrally loading config from different environment sources // Especially for large config values (or many) some environments (like AWS Lambda) limit @@ -30,7 +31,15 @@ export function getConfig(key, organization, opts) { // TODO: update to not parse if features is an object (vs. a string) let features = getFeatures(organization); if (features.hasOwnProperty(key)) { - return getOrDefault(features[key], opts && opts.default); + let value = features[key]; + if (key.endsWith('_ENCRYPTED')) { + try { + value = symmetricDecrypt(value); + } catch (e) { + // Can't decrypt, return value as-is. + } + } + return getOrDefault(value, opts && opts.default); } } if (opts && opts.onlyLocal) { diff --git a/src/server/api/lib/crypto.js b/src/server/api/lib/crypto.js index 09f9b1004..3de5f18ab 100644 --- a/src/server/api/lib/crypto.js +++ b/src/server/api/lib/crypto.js @@ -11,26 +11,29 @@ const crypto = require("crypto"); const secret = process.env.SESSION_SECRET || global.SESSION_SECRET; const algorithm = "aes-256-cbc"; -if (!secret) { - throw new Error( - "The SESSION_SECRET environment variable must be set to use crypto functions!" - ); -} - // The encryption key must be exactly 32 (bytes). We pad the SESSION_SECRET to // 32 characters because in the default development environment it is shorter. // In production environments the SESSION_SECRET should be a long random sring. -if (secret.length < 32) { +if (secret && secret.length < 32) { // eslint-disable-next-line no-console console.warn( "Using short (insecure) SESSION_SECRET. Fine for testing, bad for production." ); } -const key = Buffer.concat([Buffer.from(secret)], 32); + +const getKey = () => { + if (!secret) { + throw new Error( + "The SESSION_SECRET environment variable must be set to use crypto functions!" + ); + } + return Buffer.concat([Buffer.from(secret)], 32); +} + const symmetricEncrypt = value => { const iv = crypto.randomBytes(16); - const cipher = crypto.createCipheriv(algorithm, key, iv); + const cipher = crypto.createCipheriv(algorithm, getKey(), iv); let encrypted = cipher.update(value, "utf8", "buffer"); encrypted = Buffer.concat([encrypted, cipher.final("buffer")]); return iv.toString("hex") + ":" + encrypted.toString("hex"); @@ -40,7 +43,7 @@ const symmetricDecrypt = encrypted => { let parts = encrypted.split(":"); const iv = Buffer.from(parts.shift(), "hex"); const encryptedValue = Buffer.from(parts.join(":"), "hex"); - const decipher = crypto.createDecipheriv(algorithm, key, iv); + const decipher = crypto.createDecipheriv(algorithm, getKey(), iv); let decrypted = decipher.update(encryptedValue, "buffer", "utf8"); return decrypted + decipher.final("utf8"); }; diff --git a/src/server/models/cacheable_queries/organization.js b/src/server/models/cacheable_queries/organization.js index d06171f9f..68943c766 100644 --- a/src/server/models/cacheable_queries/organization.js +++ b/src/server/models/cacheable_queries/organization.js @@ -1,6 +1,5 @@ import { r } from "../../models"; import { getConfig, hasConfig } from "../../api/lib/config"; -import { symmetricDecrypt } from "../../api/lib/crypto"; const cacheKey = orgId => `${process.env.CACHE_PREFIX || ""}org-${orgId}`; @@ -22,7 +21,7 @@ const organizationCache = { // Note, allows unencrypted auth tokens to be (manually) stored in the db // @todo: decide if this is necessary, or if UI/envars is sufficient. const authToken = hasOrgToken - ? symmetricDecrypt(getConfig("TWILIO_AUTH_TOKEN_ENCRYPTED", organization)) + ? getConfig("TWILIO_AUTH_TOKEN_ENCRYPTED", organization) : getConfig("TWILIO_AUTH_TOKEN", organization); const accountSid = hasConfig("TWILIO_ACCOUNT_SID", organization) ? getConfig("TWILIO_ACCOUNT_SID", organization) From b0260decd754dd910cd67dc576c4907708a0bebb Mon Sep 17 00:00:00 2001 From: Jeff Mann Date: Fri, 4 Sep 2020 16:54:18 -0400 Subject: [PATCH 02/10] Make OrganizationFeatureSettings reusable --- .../OrganizationFeatureSettings.jsx | 36 +++++++++++++++---- src/containers/Settings.jsx | 9 ++--- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/src/components/OrganizationFeatureSettings.jsx b/src/components/OrganizationFeatureSettings.jsx index 76249ba1e..fd17d091a 100644 --- a/src/components/OrganizationFeatureSettings.jsx +++ b/src/components/OrganizationFeatureSettings.jsx @@ -8,6 +8,7 @@ import { dataTest } from "../lib/attributes"; const configurableFields = { ACTION_HANDLERS: { + category: 'defaults', ready: false, // TODO: let's wait for better interface schema: ({ formValues }) => formValues.settings.actionHandlers @@ -56,6 +57,7 @@ const configurableFields = { } }, ALLOW_SEND_ALL_ENABLED: { + category: 'defaults', schema: () => yup.boolean(), ready: true, component: props => { @@ -96,6 +98,7 @@ const configurableFields = { } }, DEFAULT_BATCHSIZE: { + category: 'defaults', schema: () => yup .number() @@ -119,6 +122,7 @@ const configurableFields = { } }, DEFAULT_RESPONSEWINDOW: { + category: 'defaults', schema: () => yup.number().notRequired(), ready: true, component: props => { @@ -140,6 +144,7 @@ const configurableFields = { } }, MAX_CONTACTS_PER_TEXTER: { + category: 'defaults', schema: () => yup .number() @@ -169,6 +174,7 @@ const configurableFields = { } }, MAX_MESSAGE_LENGTH: { + category: 'defaults', schema: () => yup .number() @@ -204,12 +210,18 @@ export default class OrganizationFeatureSettings extends React.Component { } onChange = formValues => { - console.log("onChange", formValues); - this.setState(formValues, () => { + const newData = { + ...formValues, + unsetFeatures: Object.keys(formValues).filter(f => formValues[f] === "") + } + console.log("onChange", newData); + this.setState(newData, () => { this.props.onChange({ settings: { - featuresJSON: JSON.stringify(this.state), - unsetFeatures: this.state.unsetFeatures + [this.props.category]: { + featuresJSON: JSON.stringify(this.state), + unsetFeatures: this.state.unsetFeatures + } } }); }); @@ -227,10 +239,20 @@ export default class OrganizationFeatureSettings extends React.Component { }); }; + saveDisabled = () => { + if (this.props.saveDisabled !== undefined) { + return this.props.saveDisabled; + } + return !this.props.parentState + || !this.props.parentState[this.props.category] + }; + render() { const schemaObject = {}; const adminItems = Object.keys(configurableFields) - .filter(f => configurableFields[f].ready && this.state.hasOwnProperty(f)) + .filter(f => configurableFields[f].ready + && configurableFields[f].category === this.props.category + && this.state.hasOwnProperty(f)) .map(f => { schemaObject[f] = configurableFields[f].schema({ ...this.props, @@ -250,7 +272,7 @@ export default class OrganizationFeatureSettings extends React.Component { type="submit" onClick={this.props.onSubmit} label={this.props.saveLabel} - disabled={this.props.saveDisabled} + disabled={this.saveDisabled()} {...dataTest("submitOrganizationFeatureSettings")} /> @@ -261,6 +283,8 @@ export default class OrganizationFeatureSettings extends React.Component { OrganizationFeatureSettings.propTypes = { formValues: type.object, + category: type.string, + parentState: type.object, organization: type.object, onChange: type.func, onSubmit: type.func, diff --git a/src/containers/Settings.jsx b/src/containers/Settings.jsx index 69560c890..4ec455409 100644 --- a/src/containers/Settings.jsx +++ b/src/containers/Settings.jsx @@ -375,17 +375,19 @@ class Settings extends React.Component { { const { settings } = this.state; await this.props.mutations.editOrganization({ - settings + settings: settings.defaults }); this.setState({ settings: null }); }} @@ -394,7 +396,6 @@ class Settings extends React.Component { this.setState(formValues); }} saveLabel="Save settings" - saveDisabled={!this.state.settings} /> From 28480b7fffdecc77632148705467b1e1d0a236c8 Mon Sep 17 00:00:00 2001 From: Jeff Mann Date: Fri, 4 Sep 2020 16:55:38 -0400 Subject: [PATCH 03/10] Add VAN settings form --- .../OrganizationFeatureSettings.jsx | 21 +++++++++++- src/containers/Settings.jsx | 32 +++++++++++++++++++ src/server/api/mutations/editOrganization.js | 8 ++++- src/server/api/organization.js | 7 ++-- 4 files changed, 64 insertions(+), 4 deletions(-) diff --git a/src/components/OrganizationFeatureSettings.jsx b/src/components/OrganizationFeatureSettings.jsx index fd17d091a..c82f4852d 100644 --- a/src/components/OrganizationFeatureSettings.jsx +++ b/src/components/OrganizationFeatureSettings.jsx @@ -195,7 +195,26 @@ const configurableFields = { ); } - } + }, + NGP_VAN_API_KEY_ENCRYPTED: { + category: 'ngpvan', + schema: () => + yup + .string() + .nullable() + .max(64) + .notRequired(), + ready: true, + component: props => { + return ( + + ); + } + }, }; export default class OrganizationFeatureSettings extends React.Component { diff --git a/src/containers/Settings.jsx b/src/containers/Settings.jsx index 4ec455409..734a710c9 100644 --- a/src/containers/Settings.jsx +++ b/src/containers/Settings.jsx @@ -401,6 +401,38 @@ class Settings extends React.Component { ) : null} + {this.props.data.organization && + this.props.data.organization.settings ? ( + + + + { + const { settings } = this.state; + await this.props.mutations.editOrganization({ + settings: settings.ngpvan + }); + this.setState({ settings: null }); + }} + onChange={formValues => { + console.log("change", formValues); + this.setState(formValues); + }} + saveLabel="Save settings" + /> + + + ) : null} + {this.props.data.organization && this.props.params.adminPerms ? ( { if ( newFeatureValues.hasOwnProperty(f) && // don't save default values that aren't already overridden - (features.hasOwnProperty(f) || getConfig(f) != newFeatureValues[f]) + (features.hasOwnProperty(f) || getConfig(f) != newFeatureValues[f]) && + // don't save Encrypted placeholder + newFeatureValues[f] !== '' ) { features[f] = newFeatureValues[f]; + if (f.endsWith('_ENCRYPTED')) { + features[f] = symmetricEncrypt(newFeatureValues[f]); + } } }); unsetFeatures.forEach(f => { diff --git a/src/server/api/organization.js b/src/server/api/organization.js index 48ea55656..c00ff8d70 100644 --- a/src/server/api/organization.js +++ b/src/server/api/organization.js @@ -16,7 +16,8 @@ export const ownerConfigurable = { DEFAULT_BATCHSIZE: 1, DEFAULT_RESPONSEWINDOW: 1, MAX_CONTACTS_PER_TEXTER: 1, - MAX_MESSAGE_LENGTH: 1 + MAX_MESSAGE_LENGTH: 1, + NGP_VAN_API_KEY_ENCRYPTED: 1, // MESSAGE_HANDLERS: 1, // There is already an endpoint and widget for this: // opt_out_message: 1 @@ -179,7 +180,9 @@ export const resolvers = { const unsetFeatures = []; getAllowed(organization, user).forEach(f => { if (features.hasOwnProperty(f)) { - visibleFeatures[f] = features[f]; + visibleFeatures[f] = f.endsWith('_ENCRYPTED') + ? '' + : features[f]; } else if (getConfig(f)) { visibleFeatures[f] = getConfig(f); } else { From 1ca62edd073cada7af11a97a154f283f7dc209c4 Mon Sep 17 00:00:00 2001 From: Jeff Mann Date: Tue, 8 Sep 2020 12:31:34 -0400 Subject: [PATCH 04/10] Only show form when a VAN extension is enabled --- src/api/organization.js | 1 + src/containers/Settings.jsx | 3 ++- src/server/api/organization.js | 14 ++++++++++++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/api/organization.js b/src/api/organization.js index daea28c1e..d8cbb2269 100644 --- a/src/api/organization.js +++ b/src/api/organization.js @@ -77,6 +77,7 @@ export const schema = gql` twilioAuthToken: String twilioMessageServiceSid: String fullyConfigured: Boolean + vanEnabled: Boolean! phoneInventoryEnabled: Boolean! campaignPhoneNumbersEnabled: Boolean! pendingPhoneNumberJobs: [BuyPhoneNumbersJobRequest] diff --git a/src/containers/Settings.jsx b/src/containers/Settings.jsx index 734a710c9..d2ab70417 100644 --- a/src/containers/Settings.jsx +++ b/src/containers/Settings.jsx @@ -402,7 +402,7 @@ class Settings extends React.Component { ) : null} {this.props.data.organization && - this.props.data.organization.settings ? ( + this.props.data.organization.vanEnabled ? ( { + await accessRequired(user, organization.id, "SUPERVOLUNTEER"); + return ( + getConfig("ACTION_HANDLERS", organization, { + default: '' + }).includes('ngpvan-action') || + getConfig("CONTACT_LOADERS", organization, { + default: '' + }).includes('ngpvan') || + getConfig("MESSAGE_HANDLERS", organization, { + default: '' + }).includes('ngpvan') + ); + }, phoneInventoryEnabled: async (organization, _, { user }) => { await accessRequired(user, organization.id, "SUPERVOLUNTEER", true); return ( From dba2247179b49d27000215eb33580b161080d678 Mon Sep 17 00:00:00 2001 From: Jeff Mann Date: Wed, 9 Sep 2020 11:12:43 -0400 Subject: [PATCH 05/10] Fix VAN key checks --- src/extensions/action-handlers/ngpvan-action.js | 7 ++++--- src/extensions/contact-loaders/ngpvan/index.js | 6 ++++-- src/extensions/message-handlers/ngpvan/index.js | 7 ++++--- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/extensions/action-handlers/ngpvan-action.js b/src/extensions/action-handlers/ngpvan-action.js index d78ff256a..f4088a367 100644 --- a/src/extensions/action-handlers/ngpvan-action.js +++ b/src/extensions/action-handlers/ngpvan-action.js @@ -362,9 +362,10 @@ export async function getClientChoiceData(organization) { // process.env.ACTION_HANDLERS export async function available(organization) { let result = - !!getConfig("NGP_VAN_API_KEY_ENCRYPTED", organization) && - !!getConfig("NGP_VAN_API_KEY", organization) && - !!getConfig("NGP_VAN_APP_NAME", organization); + ( + !!getConfig("NGP_VAN_API_KEY_ENCRYPTED", organization) || + !!getConfig("NGP_VAN_API_KEY", organization) + ) && !!getConfig("NGP_VAN_APP_NAME", organization); if (!result) { // eslint-disable-next-line no-console diff --git a/src/extensions/contact-loaders/ngpvan/index.js b/src/extensions/contact-loaders/ngpvan/index.js index 505bea7a7..7e59b598f 100644 --- a/src/extensions/contact-loaders/ngpvan/index.js +++ b/src/extensions/contact-loaders/ngpvan/index.js @@ -58,8 +58,10 @@ export async function available(organization, user) { // / then it's better to allow the result to be cached const result = - !!getConfig("NGP_VAN_API_KEY_ENCRYPTED", organization) && - !!getConfig("NGP_VAN_API_KEY", organization) && + ( + !!getConfig("NGP_VAN_API_KEY_ENCRYPTED", organization) || + !!getConfig("NGP_VAN_API_KEY", organization) + ) && !!getConfig("NGP_VAN_APP_NAME", organization) && !!getConfig("NGP_VAN_WEBHOOK_BASE_URL", organization); diff --git a/src/extensions/message-handlers/ngpvan/index.js b/src/extensions/message-handlers/ngpvan/index.js index 1c4ca017a..6d1918735 100644 --- a/src/extensions/message-handlers/ngpvan/index.js +++ b/src/extensions/message-handlers/ngpvan/index.js @@ -21,9 +21,10 @@ export const serverAdministratorInstructions = () => { }; export const available = organization => - !!getConfig("NGP_VAN_API_KEY_ENCRYPTED", organization) && - !!getConfig("NGP_VAN_API_KEY", organization) && - !!getConfig("NGP_VAN_APP_NAME", organization); + ( + !!getConfig("NGP_VAN_API_KEY_ENCRYPTED", organization) || + !!getConfig("NGP_VAN_API_KEY", organization) + ) && !!getConfig("NGP_VAN_APP_NAME", organization); // export const preMessageSave = async () => {}; From 23f418ccdf3df5045cb1daab2a8e7ebbf4ccd2dd Mon Sep 17 00:00:00 2001 From: Jeff Mann Date: Wed, 9 Sep 2020 18:28:33 -0400 Subject: [PATCH 06/10] Take fields as Prop, handle save in component --- .../OrganizationFeatureSettings.jsx | 139 ++++++++---------- src/containers/Settings.jsx | 67 +++++---- src/server/api/organization.js | 1 + 3 files changed, 102 insertions(+), 105 deletions(-) diff --git a/src/components/OrganizationFeatureSettings.jsx b/src/components/OrganizationFeatureSettings.jsx index c82f4852d..a47910926 100644 --- a/src/components/OrganizationFeatureSettings.jsx +++ b/src/components/OrganizationFeatureSettings.jsx @@ -1,14 +1,16 @@ -import type from "prop-types"; +import PropType from "prop-types"; import React from "react"; +import loadData from "../containers/hoc/load-data"; +import gql from "graphql-tag"; import GSForm from "../components/forms/GSForm"; import yup from "yup"; import Form from "react-formal"; import Toggle from "material-ui/Toggle"; +import _ from "lodash"; import { dataTest } from "../lib/attributes"; const configurableFields = { ACTION_HANDLERS: { - category: 'defaults', ready: false, // TODO: let's wait for better interface schema: ({ formValues }) => formValues.settings.actionHandlers @@ -57,7 +59,6 @@ const configurableFields = { } }, ALLOW_SEND_ALL_ENABLED: { - category: 'defaults', schema: () => yup.boolean(), ready: true, component: props => { @@ -98,7 +99,6 @@ const configurableFields = { } }, DEFAULT_BATCHSIZE: { - category: 'defaults', schema: () => yup .number() @@ -122,7 +122,6 @@ const configurableFields = { } }, DEFAULT_RESPONSEWINDOW: { - category: 'defaults', schema: () => yup.number().notRequired(), ready: true, component: props => { @@ -144,7 +143,6 @@ const configurableFields = { } }, MAX_CONTACTS_PER_TEXTER: { - category: 'defaults', schema: () => yup .number() @@ -174,7 +172,6 @@ const configurableFields = { } }, MAX_MESSAGE_LENGTH: { - category: 'defaults', schema: () => yup .number() @@ -196,88 +193,51 @@ const configurableFields = { ); } }, - NGP_VAN_API_KEY_ENCRYPTED: { - category: 'ngpvan', - schema: () => - yup - .string() - .nullable() - .max(64) - .notRequired(), - ready: true, - component: props => { - return ( - - ); - } - }, }; -export default class OrganizationFeatureSettings extends React.Component { +export class OrganizationFeatureSettings extends React.Component { constructor(props) { super(props); - const { formValues } = this.props; + this.fields = this.props.fields || configurableFields; + const { organization } = this.props; const settingsData = - (formValues.settings.featuresJSON && - JSON.parse(formValues.settings.featuresJSON)) || + (organization.settings.featuresJSON && + JSON.parse(organization.settings.featuresJSON)) || {}; - this.state = { ...settingsData, unsetFeatures: [] }; + this.state = { ...settingsData }; } onChange = formValues => { - const newData = { - ...formValues, - unsetFeatures: Object.keys(formValues).filter(f => formValues[f] === "") - } - console.log("onChange", newData); - this.setState(newData, () => { - this.props.onChange({ - settings: { - [this.props.category]: { - featuresJSON: JSON.stringify(this.state), - unsetFeatures: this.state.unsetFeatures - } - } - }); - }); + this.setState({...formValues, changed: true}); }; toggleChange = (key, value) => { - console.log("toggleChange", key, value); - this.setState({ [key]: value }, newData => { - this.props.onChange({ - settings: { - featuresJSON: JSON.stringify(this.state), - unsetFeatures: this.state.unsetFeatures - } - }); - }); + this.setState({[key]: value, changed: true}); }; - saveDisabled = () => { - if (this.props.saveDisabled !== undefined) { - return this.props.saveDisabled; + onSubmit = async () => { + const formValues = _.pick(this.state, Object.keys(this.fields)); + const settings = { + featuresJSON: JSON.stringify(formValues), + unsetFeatures: Object.keys(formValues).filter(f => formValues[f] === "") } - return !this.props.parentState - || !this.props.parentState[this.props.category] - }; + await this.props.mutations.editOrganization({ + settings + }); + this.setState({ changed: false }); + } render() { const schemaObject = {}; - const adminItems = Object.keys(configurableFields) - .filter(f => configurableFields[f].ready - && configurableFields[f].category === this.props.category + const adminItems = Object.keys(this.fields) + .filter(f => this.fields[f].ready && this.state.hasOwnProperty(f)) .map(f => { - schemaObject[f] = configurableFields[f].schema({ + schemaObject[f] = this.fields[f].schema({ ...this.props, ...this.state }); - return configurableFields[f].component({ ...this.props, parent: this }); + return this.fields[f].component({ ...this.props, parent: this }); }); return (
@@ -289,9 +249,9 @@ export default class OrganizationFeatureSettings extends React.Component { {adminItems} @@ -301,12 +261,37 @@ export default class OrganizationFeatureSettings extends React.Component { } OrganizationFeatureSettings.propTypes = { - formValues: type.object, - category: type.string, - parentState: type.object, - organization: type.object, - onChange: type.func, - onSubmit: type.func, - saveLabel: type.string, - saveDisabled: type.bool + organization: PropType.object, + fields: PropType.object, + saveLabel: PropType.string, + mutations: PropType.object }; + +export const editOrganizationGql = gql` + mutation editOrganization( + $organizationId: String! + $organizationChanges: OrganizationInput! + ) { + editOrganization(id: $organizationId, organization: $organizationChanges) { + id + settings { + messageHandlers + actionHandlers + featuresJSON + unsetFeatures + } + } + } +`; + +const mutations = { + editOrganization: ownProps => organizationChanges => ({ + mutation: editOrganizationGql, + variables: { + organizationId: ownProps.organization.id, + organizationChanges + } + }), +}; + +export default loadData({ mutations })(OrganizationFeatureSettings); diff --git a/src/containers/Settings.jsx b/src/containers/Settings.jsx index d2ab70417..c6fd8c059 100644 --- a/src/containers/Settings.jsx +++ b/src/containers/Settings.jsx @@ -380,21 +380,7 @@ class Settings extends React.Component { /> { - const { settings } = this.state; - await this.props.mutations.editOrganization({ - settings: settings.defaults - }); - this.setState({ settings: null }); - }} - onChange={formValues => { - console.log("change", formValues); - this.setState(formValues); - }} saveLabel="Save settings" /> @@ -412,22 +398,47 @@ class Settings extends React.Component { /> { - const { settings } = this.state; - await this.props.mutations.editOrganization({ - settings: settings.ngpvan - }); - this.setState({ settings: null }); + fields={{ + NGP_VAN_API_KEY_ENCRYPTED: { + schema: () => + yup + .string() + .nullable() + .max(64) + .notRequired(), + ready: true, + component: props => { + return ( + + ); + } + }, + NGP_VAN_APP_NAME: { + category: 'ngpvan', + schema: () => + yup + .string() + .nullable() + .max(32) + .notRequired(), + ready: true, + component: props => { + return ( + + ); + } + }, }} - onChange={formValues => { - console.log("change", formValues); - this.setState(formValues); - }} - saveLabel="Save settings" + saveLabel="Save VAN Settings" /> diff --git a/src/server/api/organization.js b/src/server/api/organization.js index 88bedb493..dd4c107a4 100644 --- a/src/server/api/organization.js +++ b/src/server/api/organization.js @@ -18,6 +18,7 @@ export const ownerConfigurable = { MAX_CONTACTS_PER_TEXTER: 1, MAX_MESSAGE_LENGTH: 1, NGP_VAN_API_KEY_ENCRYPTED: 1, + NGP_VAN_APP_NAME: 1, // MESSAGE_HANDLERS: 1, // There is already an endpoint and widget for this: // opt_out_message: 1 From fa4380d1efd8ce99720ae4d98724331e397021c8 Mon Sep 17 00:00:00 2001 From: Ilona Brand Date: Thu, 10 Sep 2020 15:50:13 -0500 Subject: [PATCH 07/10] Add a searchbar to the docs microsite --- docs/index.html | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/index.html b/docs/index.html index 9ab312fe9..5d54b3acf 100644 --- a/docs/index.html +++ b/docs/index.html @@ -21,5 +21,6 @@ } + From 077d9f0077dc1046ca5317f632c3862c99fffec0 Mon Sep 17 00:00:00 2001 From: Jeff Mann Date: Thu, 10 Sep 2020 18:10:30 -0400 Subject: [PATCH 08/10] Add VAN db mode, fix NaN warnings --- .../OrganizationFeatureSettings.jsx | 23 ++++++++------ src/containers/Settings.jsx | 31 +++++++++++++++---- src/server/api/organization.js | 1 + 3 files changed, 39 insertions(+), 16 deletions(-) diff --git a/src/components/OrganizationFeatureSettings.jsx b/src/components/OrganizationFeatureSettings.jsx index a47910926..e4624308d 100644 --- a/src/components/OrganizationFeatureSettings.jsx +++ b/src/components/OrganizationFeatureSettings.jsx @@ -101,9 +101,9 @@ const configurableFields = { DEFAULT_BATCHSIZE: { schema: () => yup - .number() - .integer() - .notRequired(), + .number().integer() + .notRequired().nullable() + .transform(val => isNaN(val) ? null : val), ready: true, component: props => { return ( @@ -122,7 +122,10 @@ const configurableFields = { } }, DEFAULT_RESPONSEWINDOW: { - schema: () => yup.number().notRequired(), + schema: () => yup + .number().integer() + .notRequired().nullable() + .transform(val => isNaN(val) ? null : val), ready: true, component: props => { return ( @@ -145,9 +148,9 @@ const configurableFields = { MAX_CONTACTS_PER_TEXTER: { schema: () => yup - .number() - .integer() - .notRequired(), + .number().integer() + .notRequired().nullable() + .transform(val => isNaN(val) ? null : val), ready: true, component: props => { return ( @@ -174,9 +177,9 @@ const configurableFields = { MAX_MESSAGE_LENGTH: { schema: () => yup - .number() - .integer() - .notRequired(), + .number().integer() + .notRequired().notRequired() + .transform(val => isNaN(val) ? null : val), ready: true, component: props => { return ( diff --git a/src/containers/Settings.jsx b/src/containers/Settings.jsx index c6fd8c059..ac1515492 100644 --- a/src/containers/Settings.jsx +++ b/src/containers/Settings.jsx @@ -404,9 +404,8 @@ class Settings extends React.Component { schema: () => yup .string() - .nullable() .max(64) - .notRequired(), + .notRequired().nullable(), ready: true, component: props => { return ( @@ -419,13 +418,11 @@ class Settings extends React.Component { } }, NGP_VAN_APP_NAME: { - category: 'ngpvan', schema: () => yup .string() - .nullable() - .max(32) - .notRequired(), + .notRequired().nullable() + .max(32), ready: true, component: props => { return ( @@ -437,6 +434,28 @@ class Settings extends React.Component { ); } }, + NGP_VAN_DATABASE_MODE: { + schema: () => + yup.number() + .oneOf([0, 1, null]) + .nullable() + .transform(val => isNaN(val) ? null : val), + ready: true, + component: props => { + return ( + + ); + } + }, }} saveLabel="Save VAN Settings" /> diff --git a/src/server/api/organization.js b/src/server/api/organization.js index dd4c107a4..badef3fb8 100644 --- a/src/server/api/organization.js +++ b/src/server/api/organization.js @@ -19,6 +19,7 @@ export const ownerConfigurable = { MAX_MESSAGE_LENGTH: 1, NGP_VAN_API_KEY_ENCRYPTED: 1, NGP_VAN_APP_NAME: 1, + NGP_VAN_DATABASE_MODE: 1, // MESSAGE_HANDLERS: 1, // There is already an endpoint and widget for this: // opt_out_message: 1 From fed7c012038da1ae4fee6b13e65281283fec738a Mon Sep 17 00:00:00 2001 From: Jeff Mann Date: Fri, 11 Sep 2020 10:44:59 -0400 Subject: [PATCH 09/10] Clean up react console warnings/errors --- src/components/OrganizationFeatureSettings.jsx | 8 ++++++-- src/components/forms/GSSelectField.jsx | 15 +++++++++++---- src/components/forms/GSSubmitButton.jsx | 9 ++++----- src/components/forms/GSTextField.jsx | 10 ++++++++-- src/containers/Settings.jsx | 4 +++- 5 files changed, 32 insertions(+), 14 deletions(-) diff --git a/src/components/OrganizationFeatureSettings.jsx b/src/components/OrganizationFeatureSettings.jsx index e4624308d..2b7fb7d17 100644 --- a/src/components/OrganizationFeatureSettings.jsx +++ b/src/components/OrganizationFeatureSettings.jsx @@ -178,7 +178,7 @@ const configurableFields = { schema: () => yup .number().integer() - .notRequired().notRequired() + .notRequired().nullable() .transform(val => isNaN(val) ? null : val), ready: true, component: props => { @@ -240,7 +240,11 @@ export class OrganizationFeatureSettings extends React.Component { ...this.props, ...this.state }); - return this.fields[f].component({ ...this.props, parent: this }); + return ( +
+ {this.fields[f].component({ ...this.props, parent: this })} +
+ ); }); return (
diff --git a/src/components/forms/GSSelectField.jsx b/src/components/forms/GSSelectField.jsx index 00a11b6f3..7daf1aa6e 100644 --- a/src/components/forms/GSSelectField.jsx +++ b/src/components/forms/GSSelectField.jsx @@ -5,18 +5,25 @@ import { MenuItem } from "material-ui/Menu"; import GSFormField from "./GSFormField"; export default class GSSelectField extends GSFormField { - createMenuItems() { - return this.props.choices.map(({ value, label }) => ( + createMenuItems(choices) { + return choices.map(({ value, label }) => ( )); } render() { + const { + choices, + errors, + invalid, + ...extraProps + } = this.props; + return ( { this.props.onChange(value); }} diff --git a/src/components/forms/GSSubmitButton.jsx b/src/components/forms/GSSubmitButton.jsx index a4c25e595..d4b588895 100644 --- a/src/components/forms/GSSubmitButton.jsx +++ b/src/components/forms/GSSubmitButton.jsx @@ -11,12 +11,12 @@ const styles = { const GSSubmitButton = props => { let icon = ""; - const extraProps = {}; - if (props.isSubmitting) { + const { isSubmitting, ...extraProps } = props; + if (isSubmitting) { extraProps.disabled = true; icon = ( { } return ( -
+
{icon} diff --git a/src/components/forms/GSTextField.jsx b/src/components/forms/GSTextField.jsx index ceb10f76d..72f10e913 100644 --- a/src/components/forms/GSTextField.jsx +++ b/src/components/forms/GSTextField.jsx @@ -4,7 +4,13 @@ import GSFormField from "./GSFormField"; export default class GSTextField extends GSFormField { render() { - let value = this.props.value; + const { + value, + errors, + invalid, + ...extraProps + } = this.props; + return ( event.target.select()} - {...this.props} + {...extraProps} value={value} onChange={event => { this.props.onChange(event.target.value); diff --git a/src/containers/Settings.jsx b/src/containers/Settings.jsx index ac1515492..3b80a9920 100644 --- a/src/containers/Settings.jsx +++ b/src/containers/Settings.jsx @@ -49,7 +49,9 @@ const inlineStyles = { const formatTextingHours = hour => moment(hour, "H").format("h a"); class Settings extends React.Component { state = { - formIsSubmitting: false + formIsSubmitting: false, + textingHoursDialogOpen: false, + twilioDialogOpen: false }; handleSubmitTextingHoursForm = async ({ From a0180871ba4d0a97c66cc67ced8ff9d0b56d4bbd Mon Sep 17 00:00:00 2001 From: Frydafly Date: Tue, 9 Feb 2021 11:38:48 -0500 Subject: [PATCH 10/10] added queries to texter activitiesbased on dashboards I use --- docs/REFERENCE_TEXTER_ACTIVITY_QUERIES.md | 573 +++++++++++++++++++++- 1 file changed, 569 insertions(+), 4 deletions(-) diff --git a/docs/REFERENCE_TEXTER_ACTIVITY_QUERIES.md b/docs/REFERENCE_TEXTER_ACTIVITY_QUERIES.md index e6a928499..9f6c45714 100644 --- a/docs/REFERENCE_TEXTER_ACTIVITY_QUERIES.md +++ b/docs/REFERENCE_TEXTER_ACTIVITY_QUERIES.md @@ -144,8 +144,6 @@ WHERE campaign_id >= 0 ORDER BY 1,2 ``` -#### TO DO (wish list) of some useful SQL query examples, for creating redash dashboard reports: - - all responses to Initial text, excluding opt-outs, and separate out JSON data from `custom_fields` ```sql @@ -165,6 +163,573 @@ AND c.id IN (23,25,28,29,30,31,32) ORDER BY updated_at DESC; ``` -- survey question response counts and percentage of total responses to survey question +- Messages by Organization, Date, Campaign, or Users + +``` sql +SELECT count(*) +FROM message m +left join public."user" u +on u.id = m.user_id +left join campaign_contact n +on m.campaign_contact_id = n.id +left join campaign c +on n.campaign_id = c.ID +left join organization o +on o.ID = c.organization_ID + +-- PLUG IN YOUR DATES, ORGANIZATION NAME, CAMPAIGNS, AND /OR USERS BELOW +where m.created_at = +and o.name= +and c.title= +and CONCAT(u.first_name, ' ', u.last_name)= + +``` + +- Phone Numbers Texted + +``` sql +select count(distinct m.contact_number) from message m +left join public."user" u +on u.id = m.user_id +left join campaign_contact n +on m.campaign_contact_id = n.id +left join campaign c +on n.campaign_id = c.ID +left join organization o +on o.ID = c.organization_ID + +-- PLUG IN YOUR DATES, ORGANIZATION NAME, CAMPAIGNS, AND /OR USERS BELOW +where m.created_at = +and o.name= +and c.title= +and CONCAT(u.first_name, ' ', u.last_name)= +``` + +- Outbound Messages + +```sql + +select count(*) from message m +left join public."user" u +on u.id = m.user_id +left join campaign_contact n +on m.campaign_contact_id = n.id +left join campaign c +on n.campaign_id = c.ID +left join organization o +on o.ID = c.organization_ID + +-- PLUG IN YOUR DATES, ORGANIZATION NAME, CAMPAIGNS, AND /OR USERS BELOW +where m.created_at = +and o.name= +and c.title= +and CONCAT(u.first_name, ' ', u.last_name)= +``` + +- Inbound Messages + +```sql +select count(*) from message m +left join public."user" u +on u.id = m.user_id +left join campaign_contact n +on m.campaign_contact_id = n.id +left join campaign c +on n.campaign_id = c.ID +left join organization o +on o.ID = c.organization_ID +-- PLUG IN YOUR DATES, ORGANIZATION NAME, CAMPAIGNS, AND /OR USERS BELOW +where m.created_at = +and o.name= +and c.title= +and CONCAT(u.first_name, ' ', u.last_name)= +``` +- Texters + +```sql +SELECT count(distinct m.user_id) from message m +left join public."user" u +on u.id = m.user_id +left join campaign_contact n +on m.campaign_contact_id = n.id +left join campaign c +on n.campaign_id = c.ID +left join organization o +on o.ID = c.organization_ID +-- PLUG IN YOUR DATES, ORGANIZATION NAME, CAMPAIGNS, AND /OR USERS BELOW +where m.created_at = +and o.name= +and c.title= +and CONCAT(u.first_name, ' ', u.last_name)= +``` + +- Inbound vs Outbound, Pie Graph + +```sql +select case when m.is_from_contact = true then 'Inbound' else 'Outbound' end as MessageType, count(*) from message m +left join public."user" u +on u.id = m.user_id +left join campaign_contact n +on m.campaign_contact_id = n.id +left join campaign c +on n.campaign_id = c.ID +left join organization o +on o.ID = c.organization_ID + +-- PLUG IN YOUR DATES, ORGANIZATION NAME, CAMPAIGNS, AND /OR USERS BELOW +where m.created_at = +and o.name= +and c.title= +and CONCAT(u.first_name, ' ', u.last_name)= +-- PLUG IN DETAILS ABOVE, OR DO NOT INCLUDE AT ALL + +group by MessageType +``` +- Message Status + +```sql +select n.message_status, count(*) +from campaign_contact n +left join campaign c +on c.id = n.campaign_id +left join organization o +on o.ID = c.organization_ID +w-- PLUG IN YOUR DATES +where m.created_at = +-- PLUG IN DETAILS ABOVE, OR DO NOT INCLUDE AT ALL +group by n.message_status +``` + +- Send Success Rate + +```sql +select m.send_status, count(*) from message m +left join public."user" u +on u.id = m.user_id +left join campaign_contact n +on m.campaign_contact_id = n.id +left join campaign c +on n.campaign_id = c.ID +left join organization o +on o.ID = c.organization_ID +-- PLUG IN YOUR DATES, ORGANIZATION NAME, CAMPAIGNS, AND /OR USERS BELOW +where m.created_at = +and o.name= +and c.title= +and CONCAT(u.first_name, ' ', u.last_name)= +-- PLUG IN DETAILS ABOVE, OR DO NOT INCLUDE AT ALL +group by m.send_status +``` + +- Total Messages by Date + +```sql +select c.title as Campaign, to_char(m.sent_at :: DATE, 'yyyy-mm-dd') as Sent, count(m.ID) as Messages from message m +left join public."user" u +on u.id = m.user_id +left join campaign_contact n +on m.campaign_contact_id = n.id +left join campaign c +on n.campaign_id = c.ID +left join organization o +on o.ID = c.organization_ID +-- PLUG IN YOUR DATES, ORGANIZATION NAME, CAMPAIGNS, AND /OR USERS BELOW +where m.created_at = +and o.name= +and c.title= +and CONCAT(u.first_name, ' ', u.last_name)= +-- PLUG IN DETAILS ABOVE, OR DO NOT INCLUDE AT ALL +group by campaign, sent +``` + +- Activity by Organization + +```sql +SELECT o.name as "Organization", count(*) as "Messages", count(distinct m.contact_number) as "Contacts Texted" +FROM message m + +left join public."user" u +on u.id = m.user_id +left join campaign_contact n +on m.campaign_contact_id = n.id +left join campaign c +on n.campaign_id = c.ID +left join organization o +on o.ID = c.organization_ID + +-- PLUG IN YOUR DATES, ORGANIZATION NAME, CAMPAIGNS, AND /OR USERS BELOW +where m.created_at = +and o.name= +and c.title= +and CONCAT(u.first_name, ' ', u.last_name)= +-- PLUG IN DETAILS ABOVE, OR DO NOT INCLUDE AT ALL + +group by "Organization" +order by "Messages" desc +``` + +- Campaign Questions + + +```sql +select distinct i.question as Questions from interaction_step i +left join campaign c +on c.ID = i.campaign_id +left join campaign_contact t +on t.campaign_id = c.ID +left join message m +on m.campaign_contact_id = t.ID +left join public."user" u +on u.id = m.user_id +left join organization o +on c.organization_ID = o.ID +where i.question <> '' +and i.question LIKE '%?' +-- PLUG IN YOUR DATES, ORGANIZATION NAME, CAMPAIGNS, AND /OR USERS BELOW +and m.created_at = +and o.name= +and c.title= +and CONCAT(u.first_name, ' ', u.last_name)= +-- PLUG IN DETAILS ABOVE, OR DO NOT INCLUDE AT ALL +``` + +- Campaigns + +```sql +select o.name as Organization, c.title as Campaign, to_char(c.created_at :: DATE, 'mm/dd/yyyy') as Created, to_char(c.due_by :: DATE, 'mm/dd/yyyy') as Due from campaign c +left join campaign_contact t +on t.campaign_id = c.ID +left join message m +on m.campaign_contact_id = t.ID +left join public."user" u +on u.id = m.user_id +join organization o +on c.organization_ID = o.ID + +-- PLUG IN YOUR DATES, ORGANIZATION NAME, CAMPAIGNS, AND /OR USERS BELOW +where c.created_at >= +and c.due_by <= +and o.name= +and c.title= +and CONCAT(u.first_name, ' ', u.last_name)= +-- PLUG IN DETAILS ABOVE, OR DO NOT INCLUDE AT ALL + + +group by o.name, c.title, c.created_at, c.due_by +order by c.due_by desc, c.created_at desc +``` + +- Questions & Responses + +```sql +select +--n.external_ID as VANID, +--m.contact_number as Phone, +i.question as Question, +q.value as Response, +count(*) +from message m +left join public."user" u +on u.id = m.user_id +left join campaign_contact n +on m.campaign_contact_id = n.id +left join question_response q +on n.id = q.campaign_contact_id +left join interaction_step i +on i.ID = q.interaction_step_id +left join campaign c +on n.campaign_id = c.ID +left join organization o +on o.ID = c.organization_ID + +where i.question is not null +and i.question != '' +-- PLUG IN YOUR DATES, ORGANIZATION NAME, CAMPAIGNS, AND /OR USERS BELOW +and m.created_at = +and o.name= +and c.title= +and CONCAT(u.first_name, ' ', u.last_name)= +-- PLUG IN DETAILS ABOVE, OR DO NOT INCLUDE AT ALL +group by 1,2 +--group by VANID, Phone, Question, Response +``` + +- User Roles + +```sql +select u.first_name as "First Name", u.last_name as "Last Name", r.role as "Role", o.name as "Organization" from user_organization r +join public."user" u +on u.id = r.user_id +join organization o +on o.id = r.organization_id +-- PLUG IN YOUR DATES, ORGANIZATION NAME, CAMPAIGNS, AND /OR USERS BELOW +and o.name= +and CONCAT(u.first_name, ' ', u.last_name)= +-- PLUG IN DETAILS ABOVE, OR DO NOT INCLUDE AT ALL +order by "Organization" asc, "Role" asc +``` + +- Admins + +```sql +select u.first_name as "First Name", u.last_name as "Last Name", r.role as "Role", o.name as "Organization" from user_organization r +join public."user" u +on u.id = r.user_id +join organization o +on o.id = r.organization_id +where r.role in ('ADMIN', 'OWNER') +-- PLUG IN YOUR DATES, ORGANIZATION NAME, CAMPAIGNS, AND /OR USERS BELOW +and o.name= +and CONCAT(u.first_name, ' ', u.last_name)= +-- PLUG IN DETAILS ABOVE, OR DO NOT INCLUDE AT ALL +order by "Organization" asc, "Role" asc +``` + +- Purchased Phone Numbers + +```sql +select o.name as "Organization", left(p.phone_number, 5) as "Area Code Purchased", count(*) as "Numbers Purchased", count(*)*200 as "Daily Outgoing Messages Allowed", count(*)*0.75 as "Cost" +from owned_phone_number p +left join organization o +on o.id = p.organization_id +-- PLUG IN YOUR DATES, ORGANIZATION NAME, CAMPAIGNS, AND /OR USERS BELOW +where o.name= +-- PLUG IN DETAILS ABOVE, OR DO NOT INCLUDE AT ALL +group by "Organization", "Area Code Purchased" +order by "Organization" asc, "Area Code Purchased" asc +``` + +- Phone Numbers by Campaign + +```sql +select o.name as "Organization", c.title as "Campaign Name", +--p.allocated_to_id as "Campaign ID", +count(*) as "Numbers Purchased", count(*)*200 as "Daily Outgoing Messages Allowed" +from owned_phone_number p +left join organization o +on o.id = p.organization_id +left join campaign c +on o.id = c.organization_id +-- PLUG IN YOUR DATES, ORGANIZATION NAME, CAMPAIGNS, AND /OR USERS BELOW +where o.name= +-- PLUG IN DETAILS ABOVE, OR DO NOT INCLUDE AT ALL +and p.allocated_to != 'messaging_service' +--and [p.allocated_at = daterange] +group by "Organization", "Campaign Name" +order by "Organization" asc +``` + +- Texter Activity by Campaign + +```sql +select +CONCAT(u.first_name, ' ', u.last_name) as "Texter" +, u.email as "Texter Email" +,o.name as "Org" +, c.title as "Campaign" +, count(*) as "Outgoing Messages" +, count(distinct m.contact_number) as "Contacts Texted" + +from message m + + +left join public."user" u +on u.id = m.user_id +left join campaign_contact n +on m.campaign_contact_id = n.id +left join campaign c +on n.campaign_id = c.ID +left join organization o +on o.ID = c.organization_ID + +where m.is_from_contact = false +and u.email is not null +-- PLUG IN YOUR DATES, ORGANIZATION NAME, CAMPAIGNS, AND /OR USERS BELOW +and m.created_at = +and o.name= +and c.title= +and CONCAT(u.first_name, ' ', u.last_name)= +-- PLUG IN DETAILS ABOVE, OR DO NOT INCLUDE AT ALL + + +group by "Org", "Campaign", "Texter", "Texter Email" +order by "Org", "Campaign", "Contacts Texted" desc, "Outgoing Messages" desc +``` + +- Error Codes + +```sql +select case + when m.error_code in (30003,3005,30006) then 'Likely Landline' + when m.error_code = 21611 then 'Not Enough Phone #s Purchased' + when m.error_code = 30007 then 'Carrier Violation' + when m.error_code = '-1' then 'Connection Issue' + else 'Other Error' + end as "Error Code" +, count(*) from message m +left join public."user" u +on u.id = m.user_id +left join campaign_contact n +on m.campaign_contact_id = n.id +left join campaign c +on n.campaign_id = c.ID +left join organization o +on o.ID = c.organization_ID + +-- PLUG IN YOUR DATES, ORGANIZATION NAME, CAMPAIGNS, AND /OR USERS BELOW +where m.created_at = +and o.name= +and c.title= +and CONCAT(u.first_name, ' ', u.last_name)= +-- PLUG IN DETAILS ABOVE, OR DO NOT INCLUDE AT ALL + +group by m.error_code +``` + +- Error Code Details + +```sql +select + o.name as "Organization" +,c.title as "Campaign" +, case + when m.error_code in (30003,30005,30006) then 'Likely Landline' + when m.error_code = 21611 then 'Not Enough Phone #s Purchased' + when m.error_code = 30007 then 'Carrier Violation' + when m.error_code = '-1' then 'Connection Issue' + else 'Other Error' + end as "Error Code" +--, m.error_code as "Error Code" +, count(*) +from message m + +left join public."user" u +on u.id = m.user_id +left join campaign_contact n +on m.campaign_contact_id = n.id +left join campaign c +on n.campaign_id = c.ID +left join organization o +on o.ID = c.organization_ID + +-- PLUG IN YOUR DATES, ORGANIZATION NAME, CAMPAIGNS, AND /OR USERS BELOW +where m.created_at = +and o.name= +and c.title= +and CONCAT(u.first_name, ' ', u.last_name)= +-- PLUG IN DETAILS ABOVE, OR DO NOT INCLUDE AT ALL +and c.title NOT LIKE '%Test%' +and c.title NOT LIKE '%Demo%' +and c.title NOT LIKE 'New Campaign' + +group by "Organization", "Campaign", "Error Code" +order by "Organization" asc, "Campaign", "Error Code" asc +``` + +- Carrier Violations + +```sql +select + o.name as "Organization" +,c.title as "Campaign" +, count(*) as "Error Codes" +from message m + +left join public."user" u +on u.id = m.user_id +left join campaign_contact n +on m.campaign_contact_id = n.id +left join campaign c +on n.campaign_id = c.ID +left join organization o +on o.ID = c.organization_ID +-- PLUG IN YOUR DATES, ORGANIZATION NAME, CAMPAIGNS, AND /OR USERS BELOW +where m.created_at = +and o.name= +and c.title= +and CONCAT(u.first_name, ' ', u.last_name)= +-- PLUG IN DETAILS ABOVE, OR DO NOT INCLUDE AT ALL +and c.title NOT LIKE '%Test%' +and c.title NOT LIKE '%Demo%' +and c.title NOT LIKE 'New Campaign' +and m.error_code = 30007 + +group by "Organization", "Campaign" +order by "Error Codes" desc +``` + +- Flagged Initial Messages -* count of contacts, texters, sent, count (and as percent of total sent) of replies, optouts and wrong numbers, for all campaigns + +```sql +select +distinct right(m.text, 120) as "Message", +o.name as "Org", +c.title as "Campaign" +--,m.sent_at + +from message m + +left join public."user" u +on u.id = m.user_id +left join campaign_contact n +on m.campaign_contact_id = n.id +left join campaign c +on n.campaign_id = c.ID +left join organization o +on o.ID = c.organization_ID +-- PLUG IN YOUR DATES, ORGANIZATION NAME, CAMPAIGNS, AND /OR USERS BELOW +where m.created_at = +and o.name= +and c.title= +and CONCAT(u.first_name, ' ', u.last_name)= +-- PLUG IN DETAILS ABOVE, OR DO NOT INCLUDE AT ALL +and m.is_from_contact = false +and c.title NOT LIKE '%Test%' +and c.title NOT LIKE '%Demo%' +and c.title NOT LIKE 'New Campaign' +and m.error_code = '30007' +order by "Org", "Campaign", "Message" +``` + +- Cost by Org + +```sql +Select "Org", SUM("Cost") as "Cost" from + +( + +SELECT o.name as "Org", count(*)*0.00562 as "Cost" +FROM message m +left join public."user" u +on u.id = m.user_id +left join campaign_contact n +on m.campaign_contact_id = n.id +left join campaign c +on n.campaign_id = c.ID +left join organization o +on o.ID = c.organization_ID +-- PLUG IN YOUR DATES, ORGANIZATION NAME, CAMPAIGNS, AND /OR USERS BELOW +where m.created_at = +and o.name= +and c.title= +and CONCAT(u.first_name, ' ', u.last_name)= +-- PLUG IN DETAILS ABOVE, OR DO NOT INCLUDE AT ALL + group by "Org" + +UNION ALL + +select o.name as "Org", count(*)*0.75 as "Cost" +from owned_phone_number p +left join organization o +on o.id = p.organization_id +-- PLUG IN YOUR ORGANIZATION NAME BELOW +where o.name= +-- PLUG IN DETAILS ABOVE, OR DO NOT INCLUDE AT ALL + + group by "Org" +) s + +group by "Org" +order by "Cost" desc +```