{
+ autoHideDuration={4000}
+ onClose={() => {
this.setState({ notifications: false });
}}
/>
diff --git a/src/containers/UserMenu.jsx b/src/containers/UserMenu.jsx
index 06bf6b402..13dc84a8d 100644
--- a/src/containers/UserMenu.jsx
+++ b/src/containers/UserMenu.jsx
@@ -116,7 +116,8 @@ export class UserMenuBase extends Component {
if (!currentUser) {
return ;
}
- const organizations = currentUser.texterOrganizations;
+ const organizations = [...currentUser.texterOrganizations]
+ .sort((a,b)=> a.name.toLowerCase()>b.name.toLowerCase() ? 1 : -1)
const isSuperAdmin = currentUser.is_superadmin;
return (
diff --git a/src/extensions/action-handlers/ngpvan-action.js b/src/extensions/action-handlers/ngpvan-action.js
index ad08d6ff7..100fbbdc7 100644
--- a/src/extensions/action-handlers/ngpvan-action.js
+++ b/src/extensions/action-handlers/ngpvan-action.js
@@ -370,15 +370,22 @@ export async function getClientChoiceData(organization) {
// Besides this returning true, "test-action" will also need to be added to
// process.env.ACTION_HANDLERS
export async function available(organization) {
- let result =
- (hasConfig("NGP_VAN_API_KEY", organization) ||
- hasConfig("NGP_VAN_API_KEY_ENCRYPTED", organization)) &&
- hasConfig("NGP_VAN_APP_NAME", organization);
+ const hasVanApiKey = hasConfig("NGP_VAN_API_KEY", organization);
+ const hasVanApiKeyEncrypted = hasConfig("NGP_VAN_API_KEY_ENCRYPTED", organization);
+ const hasVanAppName = hasConfig("NGP_VAN_APP_NAME", organization);
+
+ let result = (hasVanApiKey || hasVanApiKeyEncrypted) && hasVanAppName;
if (!result) {
// eslint-disable-next-line no-console
console.info(
- "ngpvan-action unavailable. Missing one or more required environment variables"
+ `${organization.name} :: ngpvan-action unavailable. Status:
+ Needs either:\n
+ \tNGP_VAN_API_KEY: ${hasVanApiKey}\n
+ \tNGP_VAN_API_KEY_ENCRYPTED: ${hasVanApiKeyEncrypted}\n
+ Needs:\n
+ \tNGP_VAN_APP_NAME: ${hasVanAppName}
+ `
);
}
diff --git a/src/extensions/contact-loaders/ngpvan/index.js b/src/extensions/contact-loaders/ngpvan/index.js
index 02d8df3f9..3ca444567 100644
--- a/src/extensions/contact-loaders/ngpvan/index.js
+++ b/src/extensions/contact-loaders/ngpvan/index.js
@@ -56,16 +56,23 @@ export async function available(organization, user) {
// / If this is instantaneous, you can have it be 0 (i.e. always), but if it takes time
// / to e.g. verify credentials or test server availability,
// / then it's better to allow the result to be cached
+
+ const hasRawKey = hasConfig("NGP_VAN_API_KEY", organization);
+ const hasEncryptedKey = hasConfig("NGP_VAN_API_KEY_ENCRYPTED", organization)
+ const hasAppName = hasConfig("NGP_VAN_APP_NAME", organization);
+ const hasWebhook = hasConfig("NGP_VAN_WEBHOOK_BASE_URL", organization)
- const result =
- (hasConfig("NGP_VAN_API_KEY", organization) ||
- hasConfig("NGP_VAN_API_KEY_ENCRYPTED", organization)) &&
- hasConfig("NGP_VAN_APP_NAME", organization) &&
- hasConfig("NGP_VAN_WEBHOOK_BASE_URL", organization);
+ const result = (hasRawKey || hasEncryptedKey) && hasAppName && hasWebhook;
if (!result) {
console.log(
- "ngpvan contact loader unavailable. Missing one or more required environment variables."
+ `${organization.name} :: ngpvan contact loader unavailable. Status:\n
+ Needs one:\n
+ \tNGP_VAN_API_KEY: ${hasRawKey}\n
+ \tNGP_VAN_API_KEY_ENCRYPTED: ${hasEncryptedKey}\n
+ Needs both:\n
+ \tNGP_VAN_APP_NAME: ${hasAppName}\n
+ \tNGP_VAN_WEBHOOK_BASE_URL: ${hasWebhook}`
);
}
@@ -145,7 +152,7 @@ export async function getClientChoiceData(organization, campaign, user) {
}
}
} catch (error) {
- const message = `Error retrieving saved list metadata from VAN ${error}`;
+ const message = `${organization.name} :: Error retrieving saved list metadata from VAN ${error}`;
// eslint-disable-next-line no-console
console.log(message);
return { data: `${JSON.stringify({ error: message })}` };
diff --git a/src/extensions/contact-loaders/past-contacts/index.js b/src/extensions/contact-loaders/past-contacts/index.js
index 4e2ba9c5c..ec4e28188 100644
--- a/src/extensions/contact-loaders/past-contacts/index.js
+++ b/src/extensions/contact-loaders/past-contacts/index.js
@@ -1,7 +1,7 @@
import { completeContactLoad, failedContactLoad } from "../../../workers/jobs";
import { r, cacheableData } from "../../../server/models";
import { getConfig, hasConfig } from "../../../server/api/lib/config";
-import queryString from "query-string";
+import queryString from "node:querystring";
import { getConversationFiltersFromQuery } from "../../../lib";
import { getConversations } from "../../../server/api/conversations";
import { getTags } from "../../../server/api/tag";
diff --git a/src/extensions/contact-loaders/s3-pull/index.js b/src/extensions/contact-loaders/s3-pull/index.js
index 2e11fec62..61dfec43e 100644
--- a/src/extensions/contact-loaders/s3-pull/index.js
+++ b/src/extensions/contact-loaders/s3-pull/index.js
@@ -189,7 +189,9 @@ export async function loadContactS3PullProcessFile(jobEvent, contextVars) {
return { alreadyComplete: 1 };
}
- await r.knex.batchInsert("campaign_contact", insertRows);
+ await r.knex.batchInsert("campaign_contact", insertRows).catch(e => {
+ console.error("Error with S3 pull batch insertion for campaign", campaign_id, e);
+ });
}
if (fileIndex < manifestData.entries.length - 1) {
diff --git a/src/extensions/message-handlers/auto-optout/index.js b/src/extensions/message-handlers/auto-optout/index.js
index 64a900a5c..0b750fd64 100644
--- a/src/extensions/message-handlers/auto-optout/index.js
+++ b/src/extensions/message-handlers/auto-optout/index.js
@@ -1,10 +1,18 @@
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 =
"W3sicmVnZXgiOiAiXlxccypzdG9wXFxifFxcYnJlbW92ZSBtZVxccyokfHJlbW92ZSBteSBuYW1lfFxcYnRha2UgbWUgb2ZmIHRoXFx3KyBsaXN0fFxcYmxvc2UgbXkgbnVtYmVyfGRvblxcVz90IGNvbnRhY3QgbWV8ZGVsZXRlIG15IG51bWJlcnxJIG9wdCBvdXR8c3RvcDJxdWl0fHN0b3BhbGx8Xlxccyp1bnN1YnNjcmliZVxccyokfF5cXHMqY2FuY2VsXFxzKiR8XlxccyplbmRcXHMqJHxeXFxzKnF1aXRcXHMqJCIsICJyZWFzb24iOiAic3RvcCJ9XQ==";
+// DEFAULT_AUTO_OPTOUT_REGEX_LIST_BASE64 converts to:
+
+// [{"regex": "^\\s*stop\\b|\\bremove me\\s*$|remove my name|\\btake me off th\\w+ list|
+// \\blose my number|don\\W?t contact me|delete my number|I opt out|stop2quit|stopall|
+// ^\\s*unsubscribe\\s*$|^\\s*cancel\\s*$|^\\s*end\\s*$|^\\s*quit\\s*$",
+// "reason": "stop"}]
+
export const serverAdministratorInstructions = () => {
return {
description: `
@@ -43,26 +51,29 @@ export const available = organization => {
}
};
+// Part of the auto-opt out process.
+// checks if message recieved states something like "stop", "quit", or "stop2quit"
export const preMessageSave = async ({ messageToSave, organization }) => {
- if (messageToSave.is_from_contact) {
+ if (messageToSave.is_from_contact) { // checks if message is from the contact
const config = Buffer.from(
getConfig("AUTO_OPTOUT_REGEX_LIST_BASE64", organization) ||
DEFAULT_AUTO_OPTOUT_REGEX_LIST_BASE64,
"base64"
- ).toString();
+ ).toString(); // converts DEFAULT_AUTO_OPTOUT_REGEX_LIST_BASE64 to regex
+ // can be custom set in .env w/ AUTO_OPTOUT_REGEX_LIST_BASE64
const regexList = JSON.parse(config || "[]");
- const matches = regexList.filter(matcher => {
+ const matches = regexList.filter(matcher => { // checks if message contains opt-out langauge
const re = new RegExp(matcher.regex, "i");
return String(messageToSave.text).match(re);
});
- // console.log("auto-optout", matches, messageToSave.text, regexList);
- if (matches.length) {
+ if (matches.length) { // if more than one match, opt-out
console.log(
"auto-optout MATCH",
- messageToSave.campaign_contact_id,
- matches
+ `| campaign_contact_id: ${messageToSave.campaign_contact_id}`,
+ `| reason: "${matches[0].reason}"`
);
- const reason = matches[0].reason || "auto_optout";
+ const reason = matches[0].reason || "auto_optout"; // with default opt-out regex,
+ // reason will always be "stop"
messageToSave.error_code = -133;
return {
contactUpdates: {
@@ -89,8 +100,8 @@ export const postMessageSave = async ({
if (message.is_from_contact && handlerContext.autoOptOutReason) {
console.log(
"auto-optout.postMessageSave",
- message.campaign_contact_id,
- handlerContext.autoOptOutReason
+ `| campaign_contact_id: ${message.campaign_contact_id}`,
+ `| opt-out reason: ${handlerContext.autoOptOutReason}`
);
let contact = await cacheableData.campaignContact.load(
message.campaign_contact_id,
@@ -139,10 +150,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,
@@ -153,4 +168,4 @@ export const postMessageSave = async ({
});
}
}
-};
+};
\ No newline at end of file
diff --git a/src/extensions/service-vendors/twilio/index.js b/src/extensions/service-vendors/twilio/index.js
index 908dddccd..1f6a90d12 100644
--- a/src/extensions/service-vendors/twilio/index.js
+++ b/src/extensions/service-vendors/twilio/index.js
@@ -582,7 +582,21 @@ export async function handleIncomingMessage(message) {
const finalMessage = await convertMessagePartsToMessage([
pendingMessagePart
]);
- console.log("Contact reply", finalMessage, pendingMessagePart);
+ console.log(
+ "Contact Reply\n",
+ `\t| Message Status: ${finalMessage.send_status}\n`,
+ `\t| From Contact? : ${finalMessage.is_from_contact}\n`,
+ `\t| Contact Number: ${finalMessage.contact_number}\n`,
+ `\t| User Number: ${finalMessage.user_number}\n`,
+ `\t| Text: ${finalMessage.text.replace(/(\r\n|\n|\r)/gm, " ").substring(0, 45)}\n`,
+ `\t| Error Code: ${finalMessage.error_code}\n`,
+ `\t| Service: ${finalMessage.service || pendingMessagePart.service}\n`,
+ `\t| Media: ${finalMessage.media.length === 0 ? "No media" : finalMessage.media}\n`,
+ `\t| Message Service SID: ${finalMessage.messageservice_sid}\n`,
+ `\t| Service ID: ${finalMessage.service_id}\n`,
+ `\t| Parent ID: ${pendingMessagePart.parent_id}\n`,
+ `\t| User ID: ${finalMessage.user_id}`,
+ );
if (finalMessage) {
if (message.spokeCreatedAt) {
finalMessage.created_at = message.spokeCreatedAt;
diff --git a/src/routes.jsx b/src/routes.jsx
index 8b82f290c..7fce13cfc 100644
--- a/src/routes.jsx
+++ b/src/routes.jsx
@@ -23,6 +23,7 @@ import CreateOrganization from "./containers/CreateOrganization";
import CreateAdditionalOrganization from "./containers/CreateAdditionalOrganization";
import AdminOrganizationsDashboard from "./containers/AdminOrganizationsDashboard";
import JoinTeam from "./containers/JoinTeam";
+import AssignReplies from "./containers/AssignReplies";
import Home from "./containers/Home";
import Settings from "./containers/Settings";
import Tags from "./containers/Tags";
@@ -275,6 +276,11 @@ export default function makeRoutes(requireAuth = () => {}) {
component={CreateAdditionalOrganization}
onEnter={requireAuth}
/>
+
{
+ const features = getFeatures(campaign);
+ return features.REPLY_BATCH_SIZE || 200;
+ },
+ useDynamicReplies: campaign => {
+ const features = getFeatures(campaign);
+ return features.USE_DYNAMIC_REPLIES ? features.USE_DYNAMIC_REPLIES : false;
+ },
responseWindow: campaign => campaign.response_window || 48,
organization: async (campaign, _, { loaders }) =>
campaign.organization ||
diff --git a/src/server/api/conversations.js b/src/server/api/conversations.js
index 969769fd3..ab9072921 100644
--- a/src/server/api/conversations.js
+++ b/src/server/api/conversations.js
@@ -4,6 +4,7 @@ import { addWhereClauseForContactsFilterMessageStatusIrrespectiveOfPastDue } fro
import { addCampaignsFilterToQuery } from "./campaign";
import { log } from "../../lib";
import { getConfig } from "../api/lib/config";
+import { isSqlite } from "../models/index";
function getConversationsJoinsAndWhereClause(
queryParam,
@@ -74,6 +75,13 @@ function getConversationsJoinsAndWhereClause(
contactsFilter && contactsFilter.messageStatus
);
+ if (contactsFilter.updatedAtGt) {
+ query = query.andWhere(function() {this.where('updated_at', '>', contactsFilter.updatedAtGt)})
+ }
+ if (contactsFilter.updatedAtLt) {
+ query = query.andWhere(function() {this.where('updated_at', '<', contactsFilter.updatedAtLt)})
+ }
+
if (contactsFilter) {
if ("isOptedOut" in contactsFilter) {
query.where("is_opted_out", contactsFilter.isOptedOut);
@@ -126,6 +134,10 @@ function getConversationsJoinsAndWhereClause(
);
}
}
+
+ if (contactsFilter.orderByRaw) {
+ query = query.orderByRaw(contactsFilter.orderByRaw);
+ }
}
return query;
@@ -146,6 +158,12 @@ function mapQueryFieldsToResolverFields(queryResult, fieldsMap) {
}
return key;
});
+ if (typeof data.updated_at != "undefined") {
+ data.updated_at = (
+ data.updated_at instanceof Date || !data.updated_at
+ ? data.updated_at || null
+ : new Date(data.updated_at))
+ }
return data;
}
@@ -187,20 +205,20 @@ export async function getConversations(
.offset(cursor.offset);
}
console.log(
- "getConversations sql",
- awsContext && awsContext.awsRequestId,
- cursor,
- assignmentsFilter,
- offsetLimitQuery.toString()
+ `Org Id: ${organizationId} :: getConversations sql -- \n`,
+ `\tawsContext: ${awsContext && awsContext.awsRequestId ? true : false}\n`,
+ `\tcursor: limit=${cursor.limit}, offset=${cursor.offset}\n`,
+ `\tassignmentsFilter: ${Object.keys(assignmentsFilter).length > 0 ? assignmentsFilter : "no filter"}\n`,
+ `\toffsetLimitQuery: ${offsetLimitQuery.toString()}`
);
const ccIdRows = await offsetLimitQuery;
console.log(
- "getConversations contact ids",
- awsContext && awsContext.awsRequestId,
- Number(new Date()) - Number(starttime),
- ccIdRows.length
+ `Org Id: ${organizationId} :: getConversations query1 contact ids -- \n`,
+ `\tawsContext: ${awsContext && awsContext.awsRequestId === undefined ? true : false}\n`,
+ `\ttime: ${Number(new Date()) - Number(starttime)}ms\n`,
+ `\tccIdRows length: ${ccIdRows.length}`
);
const ccIds = ccIdRows.map(ccIdRow => {
return ccIdRow.cc_id;
@@ -254,10 +272,10 @@ export async function getConversations(
query = query.orderBy("cc_id", "desc").orderBy("message.id");
const conversationRows = await query;
console.log(
- "getConversations query2 result",
- awsContext && awsContext.awsRequestId,
- Number(new Date()) - Number(starttime),
- conversationRows.length
+ `Org Id: ${organizationId} :: getConversations query2 conversations -- \n`,
+ `\tawsContext: ${awsContext && awsContext.awsRequestId === undefined ? true : false}\n`,
+ `\ttime: ${Number(new Date()) - Number(starttime)}ms\n`,
+ `\tconversationRows lenght: ${conversationRows.length}`
);
/* collapse the rows to produce an array of objects, with each object
* containing the fields for one conversation, each having an array of
@@ -320,8 +338,9 @@ export async function getConversations(
/* Query #3 -- get the count of all conversations matching the criteria.
* We need this to show total number of conversations to support paging */
console.log(
- "getConversations query3",
- Number(new Date()) - Number(starttime)
+ "getConversations query3 total count + time for total completion of queries\n",
+ `\ttime: ${Number(new Date()) - Number(starttime)}ms\n`,
+ `\ttotal conversations: ${conversations.length}`
);
const conversationsCountQuery = getConversationsJoinsAndWhereClause(
r.knexReadOnly,
@@ -336,7 +355,9 @@ export async function getConversations(
let conversationCount;
try {
conversationCount = await r.getCount(
- conversationsCountQuery.timeout(4000, { cancel: true })
+ !isSqlite ?
+ conversationsCountQuery.timeout(4000, { cancel: true }) :
+ conversationsCountQuery
);
} catch (err) {
// default fake value that means 'a lot'
diff --git a/src/server/api/lib/import-script.js b/src/server/api/lib/import-script.js
index b77cb7f70..924c01939 100644
--- a/src/server/api/lib/import-script.js
+++ b/src/server/api/lib/import-script.js
@@ -5,19 +5,35 @@ import { compose, map, reduce, getOr, find, filter, has } from "lodash/fp";
import { r, cacheableData } from "../../models";
import { getConfig } from "./config";
+import { base64ToString } from "./utils";
const textRegex = RegExp(".*[A-Za-z0-9]+.*");
const getDocument = async documentId => {
- const auth = google.auth.fromJSON(JSON.parse(getConfig("GOOGLE_SECRET")));
- auth.scopes = ["https://www.googleapis.com/auth/documents"];
+ let result = null;
+ let base64Key = getConfig("BASE64_GOOGLE_SECRET");
+
+ if (!base64Key) {
+ throw new Error('The BASE64_GOOGLE_SECRET enviroment variable was not found!');
+ }
+
+ // decodes
+ let key = base64ToString(base64Key);
+
+ try {
+ key = JSON.parse(key);
+ } catch(err) {
+ throw new Error('BASE64_GOOGLE_SECRET failed to parse', err);
+ };
+
+ const auth = google.auth.fromJSON(key);
+ auth.scopes = ["https://www.googleapis.com/auth/documents.readonly"];
const docs = google.docs({
version: "v1",
auth
});
- let result = null;
try {
result = await docs.documents.get({
documentId
diff --git a/src/server/api/lib/utils.js b/src/server/api/lib/utils.js
index c0b9218b0..128d84a30 100644
--- a/src/server/api/lib/utils.js
+++ b/src/server/api/lib/utils.js
@@ -60,3 +60,11 @@ export const groupCannedResponses = cannedResponses => {
export const replaceAll = (str, find, replace) =>
str.replace(new RegExp(escapeRegExp(find), "g"), replace);
+
+export const base64ToString = (str) => {
+ if(str && typeof(str) === "string") {
+ const buff = new Buffer.from(str, 'base64');
+ return buff.toString('utf-8');
+ }
+ return "";
+}
diff --git a/src/server/api/message.js b/src/server/api/message.js
index 0f9aa5fd0..b46530751 100644
--- a/src/server/api/message.js
+++ b/src/server/api/message.js
@@ -4,9 +4,14 @@ import { Message } from "../models";
export const resolvers = {
Message: {
...mapFieldsToModel(
- ["text", "userNumber", "contactNumber", "createdAt", "isFromContact"],
+ ["text", "userNumber", "contactNumber", "isFromContact"],
Message
),
+ createdAt: msg => (
+ msg.created_at instanceof Date || !msg.created_at
+ ? msg.created_at || null
+ : new Date(msg.created_at)
+ ),
media: msg =>
// Sometimes it's array, sometimes string. Maybe db vs. cache?
typeof msg.media === "string" ? JSON.parse(msg.media) : msg.media || [],
diff --git a/src/server/api/mutations/getOptOutMessage.js b/src/server/api/mutations/getOptOutMessage.js
new file mode 100644
index 000000000..541ee18c0
--- /dev/null
+++ b/src/server/api/mutations/getOptOutMessage.js
@@ -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;
+ }
+};
diff --git a/src/server/api/mutations/index.js b/src/server/api/mutations/index.js
index 94e5b2369..eb596c0ad 100644
--- a/src/server/api/mutations/index.js
+++ b/src/server/api/mutations/index.js
@@ -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";
diff --git a/src/server/api/schema.js b/src/server/api/schema.js
index 50d118437..86730b90a 100644
--- a/src/server/api/schema.js
+++ b/src/server/api/schema.js
@@ -18,6 +18,7 @@ import {
Organization,
Tag,
UserOrganization,
+ isSqlite,
r,
cacheableData
} from "../models";
@@ -60,6 +61,7 @@ import {
buyPhoneNumbers,
deletePhoneNumbers,
findNewCampaignContact,
+ getOptOutMessage,
joinOrganization,
editOrganization,
releaseContacts,
@@ -191,7 +193,9 @@ async function editCampaign(id, campaign, loaders, user, origCampaignRecord) {
textingHoursStart,
textingHoursEnd,
timezone,
- serviceManagers
+ serviceManagers,
+ useDynamicReplies,
+ replyBatchSize
} = campaign;
// some changes require ADMIN and we recheck below
const organizationId =
@@ -257,6 +261,17 @@ async function editCampaign(id, campaign, loaders, user, origCampaignRecord) {
});
campaignUpdates.features = JSON.stringify(features);
}
+ if (useDynamicReplies) {
+ Object.assign(features, {
+ "USE_DYNAMIC_REPLIES": true,
+ "REPLY_BATCH_SIZE": replyBatchSize
+ })
+ } else {
+ Object.assign(features, {
+ "USE_DYNAMIC_REPLIES": false
+ })
+ }
+ campaignUpdates.features = JSON.stringify(features);
let changed = Boolean(Object.keys(campaignUpdates).length);
if (changed) {
@@ -393,11 +408,7 @@ async function editCampaign(id, campaign, loaders, user, origCampaignRecord) {
});
// hacky easter egg to force reload campaign contacts
- if (
- r.redis &&
- campaignUpdates.description &&
- campaignUpdates.description.endsWith("..")
- ) {
+ if (r.redis && campaignUpdates.description?.endsWith("..")) {
// some asynchronous cache-priming
console.log(
"force-loading loadCampaignCache",
@@ -422,6 +433,11 @@ async function updateInteractionSteps(
origCampaignRecord,
idMap = {}
) {
+ // Allows cascade delete for SQLite
+ if (isSqlite) {
+ await r.knex.raw("PRAGMA foreign_keys = ON");
+ }
+
for (let i = 0; i < interactionSteps.length; i++) {
const is = interactionSteps[i];
// map the interaction step ids for new ones
@@ -762,6 +778,7 @@ const rootMutations = {
return await cacheableData.organization.load(organizationId);
},
+ getOptOutMessage,
updateOptOutMessage: async (
_,
{ organizationId, optOutMessage },
@@ -1262,6 +1279,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);
}
}
@@ -1413,6 +1439,63 @@ const rootMutations = {
newTexterUserId
);
},
+ dynamicReassign: async (
+ _,
+ {
+ joinToken,
+ campaignId
+ },
+ { user }
+ ) => {
+ // verify permissions
+ const campaign = await r
+ .knex("campaign")
+ .where({
+ id: campaignId,
+ join_token: joinToken,
+ })
+ .first();
+ const INVALID_REASSIGN = () => {
+ const error = new GraphQLError("Invalid reassign request - organization not found");
+ error.code = "INVALID_REASSIGN";
+ return error;
+ };
+ if (!campaign) {
+ throw INVALID_REASSIGN();
+ }
+ const organization = await cacheableData.organization.load(
+ campaign.organization_id
+ );
+ if (!organization) {
+ throw INVALID_REASSIGN();
+ }
+ const maxContacts = getConfig("MAX_REPLIES_PER_TEXTER", organization) ?? 200;
+ let d = new Date();
+ d.setHours(d.getHours() - 1);
+ const contactsFilter = { messageStatus: 'needsResponse', isOptedOut: false, listSize: maxContacts, orderByRaw: "updated_at DESC", updatedAtLt: d}
+ const campaignsFilter = {
+ campaignId: campaignId
+ };
+
+ await accessRequired(
+ user,
+ organization.id,
+ "TEXTER",
+ /* superadmin*/ true
+ );
+ const { campaignIdContactIdsMap } = await getCampaignIdContactIdsMaps(
+ organization.id,
+ {
+ campaignsFilter,
+ contactsFilter,
+ }
+ );
+ await reassignConversations(
+ campaignIdContactIdsMap,
+ user.id
+ );
+ return organization.id;
+ },
importCampaignScript: async (_, { campaignId, url }, { user }) => {
const campaign = await cacheableData.campaign.load(campaignId);
await accessRequired(user, campaign.organization_id, "ADMIN", true);
diff --git a/src/server/lib/http-request.js b/src/server/lib/http-request.js
index d39df3e87..3e953f667 100644
--- a/src/server/lib/http-request.js
+++ b/src/server/lib/http-request.js
@@ -1,5 +1,4 @@
import originalFetch from "node-fetch";
-import { AbortController } from "abort-controller";
import { log } from "../../lib";
import { sleep } from "../../workers/lib";
import { v4 as uuid } from "uuid";
diff --git a/src/server/middleware/render-index.js b/src/server/middleware/render-index.js
index d53cdd473..cc2357fbd 100644
--- a/src/server/middleware/render-index.js
+++ b/src/server/middleware/render-index.js
@@ -1,7 +1,28 @@
import { hasConfig, getConfig } from "../api/lib/config";
import { getProcessEnvTz, getProcessEnvDstReferenceTimezone } from "../../lib";
+import { base64ToString } from "../api/lib/utils";
-const canGoogleImport = hasConfig("GOOGLE_SECRET");
+const canGoogleImport = hasConfig("BASE64_GOOGLE_SECRET");
+
+const googleClientEmail = () => {
+ let output;
+ if (canGoogleImport) {
+ try {
+ const s_GOOGLE_SECRET = base64ToString(process.env.BASE64_GOOGLE_SECRET);
+ output = (JSON.parse((
+ s_GOOGLE_SECRET
+ .replace(/(\r\n|\n|\r)/gm, ""))) // new lines gum up parsing
+ .client_email)
+ .replaceAll(" ", "");
+ } catch (err) {
+ console.error(`
+ Google API failed to load client email.
+ Please check your BASE64_GOOGLE_SECRET environment variable is intact: `,
+ err);
+ }
+ }
+ return (output || "");
+};
const rollbarScript = process.env.ROLLBAR_CLIENT_TOKEN
? `