{
this.resetPassword(texterId);
}}
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/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 78366f963..0b750fd64 100644
--- a/src/extensions/message-handlers/auto-optout/index.js
+++ b/src/extensions/message-handlers/auto-optout/index.js
@@ -1,5 +1,6 @@
import { getConfig, getFeatures } from "../../../server/api/lib/config";
import { cacheableData } from "../../../server/models";
+import { getOptOutMessage } from "../../../server/api/mutations";
import { sendRawMessage } from "../../../server/api/mutations/sendMessage";
const DEFAULT_AUTO_OPTOUT_REGEX_LIST_BASE64 =
@@ -149,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,
diff --git a/src/network/apollo-client-singleton.js b/src/network/apollo-client-singleton.js
index a86af1405..c6f366841 100644
--- a/src/network/apollo-client-singleton.js
+++ b/src/network/apollo-client-singleton.js
@@ -86,6 +86,15 @@ const cache = new InMemoryCache({
}
}
}
+ },
+ Organization: {
+ fields: {
+ pendingPhoneNumberJobs: {
+ merge(existing = [], incoming) {
+ return incoming;
+ }
+ }
+ }
}
}
});
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 3e29c9dbf..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;
}
@@ -337,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..65e83ef7e 100644
--- a/src/server/api/lib/import-script.js
+++ b/src/server/api/lib/import-script.js
@@ -10,7 +10,7 @@ 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"];
+ auth.scopes = ["https://www.googleapis.com/auth/documents.readonly"];
const docs = google.docs({
version: "v1",
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/getShortCodes.js b/src/server/api/mutations/getShortCodes.js
index 0f5c829ae..e1f84d41c 100644
--- a/src/server/api/mutations/getShortCodes.js
+++ b/src/server/api/mutations/getShortCodes.js
@@ -49,7 +49,8 @@ export const getShortCodes = async (
job_type: Jobs.GET_SHORT_CODES,
locks_queue: false,
payload: JSON.stringify({
- opts: serviceManagerResult.opts || opts
+ opts: serviceManagerResult.opts || opts,
+ areaCode: "Shortcode"
})
});
};
diff --git a/src/server/api/mutations/getTollFreeNumbers.js b/src/server/api/mutations/getTollFreeNumbers.js
index 53d97e370..decc3fa06 100644
--- a/src/server/api/mutations/getTollFreeNumbers.js
+++ b/src/server/api/mutations/getTollFreeNumbers.js
@@ -49,7 +49,8 @@ export const getTollFreeNumbers = async (
job_type: Jobs.GET_TOLL_FREE_NUMBERS,
locks_queue: false,
payload: JSON.stringify({
- opts: serviceManagerResult.opts || opts
+ opts: serviceManagerResult.opts || opts,
+ areaCode: "Tollfree"
})
});
};
diff --git a/src/server/api/mutations/index.js b/src/server/api/mutations/index.js
index b129c77cb..c910a6632 100644
--- a/src/server/api/mutations/index.js
+++ b/src/server/api/mutations/index.js
@@ -5,6 +5,7 @@ export { getShortCodes} from "./getShortCodes";
export { getTollFreeNumbers} from "./getTollFreeNumbers";
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/organization.js b/src/server/api/organization.js
index e35ecfa2a..a985db860 100644
--- a/src/server/api/organization.js
+++ b/src/server/api/organization.js
@@ -325,7 +325,12 @@ export const resolvers = {
await accessRequired(user, organization.id, "ADMIN", true);
const jobs = await r
.knex("job_request")
- .whereIn("job_type", ["buy_phone_numbers", "delete_phone_numbers"])
+ .whereIn("job_type", [
+ "buy_phone_numbers",
+ "delete_phone_numbers",
+ "get_toll_free_numbers",
+ "get_short_codes"
+ ])
.andWhere("organization_id", organization.id)
.orderBy("updated_at", "desc");
return jobs.map(j => {
diff --git a/src/server/api/schema.js b/src/server/api/schema.js
index 15d94c46b..0190c41f4 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";
@@ -62,6 +63,7 @@ import {
getShortCodes,
getTollFreeNumbers,
findNewCampaignContact,
+ getOptOutMessage,
joinOrganization,
editOrganization,
releaseContacts,
@@ -193,7 +195,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 =
@@ -259,6 +263,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) {
@@ -395,11 +410,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",
@@ -424,6 +435,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
@@ -766,6 +782,7 @@ const rootMutations = {
return await cacheableData.organization.load(organizationId);
},
+ getOptOutMessage,
updateOptOutMessage: async (
_,
{ organizationId, optOutMessage },
@@ -1266,6 +1283,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);
}
}
@@ -1417,6 +1443,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/middleware/render-index.js b/src/server/middleware/render-index.js
index d53cdd473..fd5c82b7d 100644
--- a/src/server/middleware/render-index.js
+++ b/src/server/middleware/render-index.js
@@ -3,6 +3,25 @@ import { getProcessEnvTz, getProcessEnvDstReferenceTimezone } from "../../lib";
const canGoogleImport = hasConfig("GOOGLE_SECRET");
+const googleClientEmail = () => {
+ let output;
+ if (canGoogleImport) {
+ try {
+ output = (JSON.parse((
+ process.env.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 GOOGLE_SECRET environment variable is intact: `,
+ err);
+ }
+ }
+ return (output || "");
+};
+
const rollbarScript = process.env.ROLLBAR_CLIENT_TOKEN
? `