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 +``` 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 @@ } + diff --git a/src/api/organization.js b/src/api/organization.js index 225d00c75..ad38483b9 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! emailEnabled: Boolean phoneInventoryEnabled: Boolean! campaignPhoneNumbersEnabled: Boolean! diff --git a/src/components/OrganizationFeatureSettings.jsx b/src/components/OrganizationFeatureSettings.jsx index 76249ba1e..2b7fb7d17 100644 --- a/src/components/OrganizationFeatureSettings.jsx +++ b/src/components/OrganizationFeatureSettings.jsx @@ -1,9 +1,12 @@ -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 = { @@ -98,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 ( @@ -119,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 ( @@ -142,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 ( @@ -171,9 +177,9 @@ const configurableFields = { MAX_MESSAGE_LENGTH: { schema: () => yup - .number() - .integer() - .notRequired(), + .number().integer() + .notRequired().nullable() + .transform(val => isNaN(val) ? null : val), ready: true, component: props => { return ( @@ -189,54 +195,56 @@ const configurableFields = { ); } - } + }, }; -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 => { - console.log("onChange", formValues); - this.setState(formValues, () => { - this.props.onChange({ - settings: { - 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}); }; + 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] === "") + } + await this.props.mutations.editOrganization({ + settings + }); + this.setState({ changed: false }); + } + render() { const schemaObject = {}; - const adminItems = Object.keys(configurableFields) - .filter(f => configurableFields[f].ready && this.state.hasOwnProperty(f)) + 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 (
@@ -248,9 +256,9 @@ export default class OrganizationFeatureSettings extends React.Component { {adminItems} @@ -260,10 +268,37 @@ export default class OrganizationFeatureSettings extends React.Component { } OrganizationFeatureSettings.propTypes = { - formValues: 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/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 69560c890..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 ({ @@ -375,26 +377,89 @@ class Settings extends React.Component { { - const { settings } = this.state; - await this.props.mutations.editOrganization({ - settings - }); - this.setState({ settings: null }); - }} - onChange={formValues => { - console.log("change", formValues); - this.setState(formValues); - }} saveLabel="Save settings" - saveDisabled={!this.state.settings} + /> + + + ) : null} + + {this.props.data.organization && + this.props.data.organization.vanEnabled ? ( + + + + + yup + .string() + .max(64) + .notRequired().nullable(), + ready: true, + component: props => { + return ( + + ); + } + }, + NGP_VAN_APP_NAME: { + schema: () => + yup + .string() + .notRequired().nullable() + .max(32), + ready: true, + component: props => { + return ( + + ); + } + }, + 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" /> @@ -457,6 +522,7 @@ const queries = { twilioAccountSid twilioAuthToken twilioMessageServiceSid + vanEnabled } } `, diff --git a/src/extensions/action-handlers/ngpvan-action.js b/src/extensions/action-handlers/ngpvan-action.js index edff5c2d7..f4088a367 100644 --- a/src/extensions/action-handlers/ngpvan-action.js +++ b/src/extensions/action-handlers/ngpvan-action.js @@ -362,8 +362,10 @@ export async function getClientChoiceData(organization) { // process.env.ACTION_HANDLERS export async function available(organization) { let result = - !!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 4b4da09e2..788ccdfc4 100644 --- a/src/extensions/contact-loaders/ngpvan/index.js +++ b/src/extensions/contact-loaders/ngpvan/index.js @@ -58,7 +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", 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/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 aec7a037c..a456e2b48 100644 --- a/src/extensions/message-handlers/ngpvan/index.js +++ b/src/extensions/message-handlers/ngpvan/index.js @@ -21,8 +21,10 @@ export const serverAdministratorInstructions = () => { }; export const available = 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 () => {}; 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/api/mutations/editOrganization.js b/src/server/api/mutations/editOrganization.js index dfbc11462..da32ce8c3 100644 --- a/src/server/api/mutations/editOrganization.js +++ b/src/server/api/mutations/editOrganization.js @@ -1,4 +1,5 @@ import { getConfig, getFeatures } from "../lib/config"; +import { symmetricEncrypt } from "../lib/crypto"; import { accessRequired } from "../errors"; import { r, cacheableData } from "../../models"; import { getAllowed } from "../organization"; @@ -22,9 +23,14 @@ export const editOrganization = async (_, { id, organization }, { user }) => { 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 7d5ecc618..ab9456a59 100644 --- a/src/server/api/organization.js +++ b/src/server/api/organization.js @@ -16,7 +16,10 @@ 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, + 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 @@ -179,7 +182,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 { @@ -290,9 +295,24 @@ export const resolvers = { } return true; }, + vanEnabled: async (organization, _, { user }) => { + 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') + ); + }, emailEnabled: async (organization, _, { user }) => { await accessRequired(user, organization.id, "SUPERVOLUNTEER", true); - return Boolean(getConfig("EMAIL_HOST", organization)); + return Boolean(getConfig("EMAIL_HOST", organization) + ); }, phoneInventoryEnabled: async (organization, _, { user }) => { await accessRequired(user, organization.id, "SUPERVOLUNTEER", true); 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)