🐛Fix sales lead assignments

Lead assignments can be incorrect when the user requests licenses
needed via the contact form before the related emails have been added
to the Close.io lead.

We’ll use a custom field for automation sales contact if there’s no
email activity.
This commit is contained in:
Matt Lott 2016-09-20 14:59:47 -07:00
parent 86d54d78c0
commit 12baae7acd
2 changed files with 128 additions and 57 deletions

View file

@ -93,7 +93,11 @@ const parseDomain = require('parse-domain');
const request = require('request'); const request = require('request');
const earliestDate = new Date(); const earliestDate = new Date();
earliestDate.setUTCDate(earliestDate.getUTCDate() - 10); earliestDate.setUTCDate(earliestDate.getUTCDate() - 3);
const apiKeyEmailMap = {};
const emailApiKeyMap = {};
const userApiKeyMap = {};
// ** Main program // ** Main program
@ -116,8 +120,13 @@ function upsertLeads(done) {
addIntercomData(contacts, (err) => { addIntercomData(contacts, (err) => {
if (err) return done(err); if (err) return done(err);
// log('DEBUG: Updating contacts..'); updateCloseApiKeyMaps((err) => {
updateCloseLeads(contacts, (err) => {
// log('DEBUG: Updating contacts..');
updateCloseLeads(contacts, (err) => {
return done(err);
});
return done(err); return done(err);
}); });
}); });
@ -467,6 +476,8 @@ class CocoContact {
'Lead Origin': this.getLeadOrigin() 'Lead Origin': this.getLeadOrigin()
} }
}; };
const emailApiKey = getEmailApiKey(postData.status);
if (apiKeyEmailMap[emailApiKey]) postData.custom['auto_sales_email'] = apiKeyEmailMap[emailApiKey];
const props = this.trialRequest.properties; const props = this.trialRequest.properties;
if (props) { if (props) {
for (const prop in props) { for (const prop in props) {
@ -603,7 +614,7 @@ class CocoContact {
// ** Upsert Close.io methods // ** Upsert Close.io methods
function updateCloseLead(cocoContact, closeLead, userApiKeyMap, done) { function updateCloseLead(cocoContact, closeLead, done) {
// console.log('DEBUG: updateCloseLead', cocoContact.email, closeLead.id); // console.log('DEBUG: updateCloseLead', cocoContact.email, closeLead.id);
const putData = cocoContact.getLeadPutData(closeLead); const putData = cocoContact.getLeadPutData(closeLead);
@ -632,7 +643,7 @@ function updateCloseLead(cocoContact, closeLead, userApiKeyMap, done) {
} }
// Add Close contact // Add Close contact
addContact(cocoContact, closeLead, userApiKeyMap, (err, results) => { addContact(cocoContact, closeLead, (err, results) => {
if (err) return done(err); if (err) return done(err);
// Add Close note // Add Close note
@ -646,7 +657,7 @@ function updateCloseLead(cocoContact, closeLead, userApiKeyMap, done) {
}); });
} }
function saveNewCloseLead(cocoContact, userApiKeyMap, done) { function saveNewCloseLead(cocoContact, done) {
const postData = cocoContact.getLeadPostData(); const postData = cocoContact.getLeadPostData();
// console.log(`DEBUG: saveNewCloseLead ${cocoContact.email} ${postData.status}`); // console.log(`DEBUG: saveNewCloseLead ${cocoContact.email} ${postData.status}`);
const options = { const options = {
@ -683,7 +694,7 @@ function saveNewCloseLead(cocoContact, userApiKeyMap, done) {
} }
const countryCode = getCountryCode(cocoContact.trialRequest.properties.country, [cocoContact.email]); const countryCode = getCountryCode(cocoContact.trialRequest.properties.country, [cocoContact.email]);
const emailTemplate = getEmailTemplate(cocoContact.trialRequest.properties.siteOrigin, postData.status, countryCode); const emailTemplate = getEmailTemplate(cocoContact.trialRequest.properties.siteOrigin, postData.status, countryCode);
sendMail(cocoContact.email, newCloseLead, newContact.id, emailTemplate, userApiKeyMap, emailDelayMinutes, done); sendMail(cocoContact.email, newCloseLead, newContact.id, emailTemplate, emailDelayMinutes, done);
}); });
}); });
} }
@ -713,7 +724,7 @@ function createFindExistingLeadFn(email, existingLeads) {
}; };
} }
function createUpdateCloseLeadFn(cocoContact, existingLeads, userApiKeyMap) { function createUpdateCloseLeadFn(cocoContact, existingLeads) {
// New contact lead matching algorithm: // New contact lead matching algorithm:
// 1. New contact email exists // 1. New contact email exists
// 2. New contact NCES school id exists // 2. New contact NCES school id exists
@ -726,7 +737,7 @@ function createUpdateCloseLeadFn(cocoContact, existingLeads, userApiKeyMap) {
if (existingLeads[cocoContact.email]) { if (existingLeads[cocoContact.email]) {
if (existingLeads[cocoContact.email].length === 1) { if (existingLeads[cocoContact.email].length === 1) {
// console.log(`DEBUG: Using lead from email lookup: ${cocoContact.email}`); // console.log(`DEBUG: Using lead from email lookup: ${cocoContact.email}`);
return updateCloseLead(cocoContact, existingLeads[cocoContact.email][0], userApiKeyMap, done); return updateCloseLead(cocoContact, existingLeads[cocoContact.email][0], done);
} }
console.error(`ERROR: ${existingLeads[cocoContact.email].length} email leads found for ${cocoContact.email}`); console.error(`ERROR: ${existingLeads[cocoContact.email].length} email leads found for ${cocoContact.email}`);
return done(); return done();
@ -758,9 +769,9 @@ function createUpdateCloseLeadFn(cocoContact, existingLeads, userApiKeyMap) {
return done(); return done();
} }
if (data.total_results === 1) { if (data.total_results === 1) {
return updateCloseLead(cocoContact, data.data[0], userApiKeyMap, done); return updateCloseLead(cocoContact, data.data[0], done);
} }
return saveNewCloseLead(cocoContact, userApiKeyMap, done); return saveNewCloseLead(cocoContact, done);
} catch (error) { } catch (error) {
console.log(`ERROR: createUpdateCloseLeadFn ${cocoContact.email}`); console.log(`ERROR: createUpdateCloseLeadFn ${cocoContact.email}`);
console.log(error); console.log(error);
@ -770,7 +781,7 @@ function createUpdateCloseLeadFn(cocoContact, existingLeads, userApiKeyMap) {
}; };
} }
function addContact(cocoContact, closeLead, userApiKeyMap, done) { function addContact(cocoContact, closeLead, done) {
// console.log('DEBUG: addContact', closeLead.id, cocoContact.email); // console.log('DEBUG: addContact', closeLead.id, cocoContact.email);
const postData = cocoContact.getContactPostData(closeLead); const postData = cocoContact.getContactPostData(closeLead);
const options = { const options = {
@ -787,7 +798,7 @@ function addContact(cocoContact, closeLead, userApiKeyMap, done) {
const countryCode = getCountryCode(cocoContact.trialRequest.properties.country, [cocoContact.email]); const countryCode = getCountryCode(cocoContact.trialRequest.properties.country, [cocoContact.email]);
const emailTemplate = getEmailTemplate(cocoContact.trialRequest.properties.siteOrigin, closeLead.status_label, countryCode); const emailTemplate = getEmailTemplate(cocoContact.trialRequest.properties.siteOrigin, closeLead.status_label, countryCode);
sendMail(cocoContact.email, closeLead, newContact.id, emailTemplate, userApiKeyMap, emailDelayMinutes, done); sendMail(cocoContact.email, closeLead, newContact.id, emailTemplate, emailDelayMinutes, done);
}); });
} }
@ -812,10 +823,12 @@ function addNote(cocoContact, closeLead, currentNotes, done) {
}); });
} }
function sendMail(toEmail, closeLead, contactId, template, userApiKeyMap, delayMinutes, done) { function sendMail(toEmail, closeLead, contactId, template, delayMinutes, done) {
// console.log('DEBUG: sendMail', toEmail, leadId, contactId, template, emailApiKey, delayMinutes); // console.log('DEBUG: sendMail', toEmail, leadId, contactId, template, delayMinutes);
let emailApiKey = getEmailApiKey(closeLead.status_label); // Sales contact email precedence: previous email to contact, previous email to lead, lead custom field, lead status default
let emailApiKey = null;
let emailDiffContactApiKey = null;
// Check for previously sent email // Check for previously sent email
const url = `https://${closeIoApiKey}:X@app.close.io/api/v1/activity/email/?lead_id=${closeLead.id}`; const url = `https://${closeIoApiKey}:X@app.close.io/api/v1/activity/email/?lead_id=${closeLead.id}`;
@ -824,12 +837,29 @@ function sendMail(toEmail, closeLead, contactId, template, userApiKeyMap, delayM
try { try {
const data = JSON.parse(body); const data = JSON.parse(body);
for (const emailData of data.data) { for (const emailData of data.data) {
emailApiKey = userApiKeyMap[emailData.user_id] || emailApiKey;
if (!isSameEmailTemplateType(emailData.template_id, template)) continue; // Check for previous email to this contact
for (const email of emailData.to) { if (!emailApiKey && userApiKeyMap[emailData.user_id]) {
if (email.toLowerCase() === toEmail.toLowerCase()) { for (const email of emailData.to) {
console.error("ERROR: sending duplicate email:", toEmail, closeLead.id, contactId, template, emailData.contact_id); if (email.toLowerCase() === toEmail.toLowerCase()) {
return done(); emailApiKey = userApiKeyMap[emailData.user_id];
break;
}
}
}
// Save previous lead email to this lead
if (!emailDiffContactApiKey && !emailApiKey && userApiKeyMap[emailData.user_id]) {
emailDiffContactApiKey = userApiKeyMap[emailData.user_id];
}
// Never send this email template to this contact again
if (isSameEmailTemplateType(emailData.template_id, template)) {
for (const email of emailData.to) {
if (email.toLowerCase() === toEmail.toLowerCase()) {
console.error("ERROR: sending duplicate email:", toEmail, closeLead.id, contactId, template, emailData.contact_id);
return done();
}
} }
} }
} }
@ -840,6 +870,16 @@ function sendMail(toEmail, closeLead, contactId, template, userApiKeyMap, delayM
return done(); return done();
} }
if (!emailApiKey && emailDiffContactApiKey) emailApiKey = emailDiffContactApiKey;
if (!emailApiKey) {
if (closeLead.custom && closeLead.custom['auto_sales_email'] && emailApiKeyMap[closeLead.custom['auto_sales_email']]) {
emailApiKey = emailApiKeyMap[closeLead.custom['auto_sales_email']];
}
else {
emailApiKey = getEmailApiKey(closeLead.status_label);
}
}
// Send mail // Send mail
const dateScheduled = new Date(); const dateScheduled = new Date();
dateScheduled.setUTCMinutes(dateScheduled.getUTCMinutes() + delayMinutes); dateScheduled.setUTCMinutes(dateScheduled.getUTCMinutes() + delayMinutes);
@ -869,39 +909,40 @@ function sendMail(toEmail, closeLead, contactId, template, userApiKeyMap, delayM
} }
function updateCloseLeads(cocoContacts, done) { function updateCloseLeads(cocoContacts, done) {
const userApiKeyMap = {}; // Lookup existing leads via email to protect against direct lead name querying later
// Querying via lead name is unreliable
const existingLeads = {};
const tasks = [];
for (const email in cocoContacts) {
tasks.push(createFindExistingLeadFn(email, existingLeads));
}
async.parallelLimit(tasks, closeParallelLimit, (err, results) => {
if (err) return done(err);
const tasks = [];
for (const email in cocoContacts) {
tasks.push(createUpdateCloseLeadFn(cocoContacts[email], existingLeads));
}
async.series(tasks, done);
});
}
function updateCloseApiKeyMaps(done) {
let createGetUserFn = (apiKey) => { let createGetUserFn = (apiKey) => {
return (done) => { return (done) => {
const url = `https://${apiKey}:X@app.close.io/api/v1/me/?_fields=id`; const url = `https://${apiKey}:X@app.close.io/api/v1/me/?_fields=id,email`;
request.get(url, (error, response, body) => { request.get(url, (error, response, body) => {
if (error) return done(); if (error) return done();
const results = JSON.parse(body); const results = JSON.parse(body);
apiKeyEmailMap[apiKey] = results.email;
emailApiKeyMap[results.email] = apiKey;
userApiKeyMap[results.id] = apiKey; userApiKeyMap[results.id] = apiKey;
return done(); return done();
}); });
}; };
} }
const tasks = []; const tasks = [createGetUserFn(closeIoEuMailApiKey)];
for (const closeIoMailApiKey of closeIoMailApiKeys) { for (const closeIoMailApiKey of closeIoMailApiKeys) {
tasks.push(createGetUserFn(closeIoMailApiKey.apiKey)); tasks.push(createGetUserFn(closeIoMailApiKey.apiKey));
} }
tasks.push(createGetUserFn(closeIoEuMailApiKey)); async.parallelLimit(tasks, closeParallelLimit, done);
async.parallelLimit(tasks, closeParallelLimit, (err, results) => {
if (err) console.log(err);
// Lookup existing leads via email to protect against direct lead name querying later
// Querying via lead name is unreliable
const existingLeads = {};
const tasks = [];
for (const email in cocoContacts) {
tasks.push(createFindExistingLeadFn(email, existingLeads));
}
async.parallelLimit(tasks, closeParallelLimit, (err, results) => {
if (err) return done(err);
const tasks = [];
for (const email in cocoContacts) {
tasks.push(createUpdateCloseLeadFn(cocoContacts[email], existingLeads, userApiKeyMap));
}
async.series(tasks, done);
});
});
} }

View file

@ -3,7 +3,7 @@ log = require 'winston'
request = require 'request' request = require 'request'
apiKey = config.closeIO?.apiKey apiKey = config.closeIO?.apiKey
defaultSalesContactUser = 'user_Fh0uLUkRIKMk2to61ISq8PneyQonuD2i7hes6RhZgDX' defaultSalesContactUserID = 'user_Fh0uLUkRIKMk2to61ISq8PneyQonuD2i7hes6RhZgDX'
module.exports = module.exports =
logError: (msg) -> logError: (msg) ->
@ -50,27 +50,57 @@ module.exports =
return done('ERROR: multiple leads returned for ' + email + ' ' + leads.data.length) return done('ERROR: multiple leads returned for ' + email + ' ' + leads.data.length)
return done() return done()
getSalesContactEmail: (email, done) -> getSalesContactEmail: (userEmail, done) ->
# Sales contact email precedence: previous email to contact, previous email to lead, lead custom field, lead status default
try try
# NOTE: does not work on + email addresses due to Close.io API bug # NOTE: does not work on + email addresses due to Close.io API bug
uri = "https://#{apiKey}:X@app.close.io/api/v1/lead/?query=email_address:#{email}" uri = "https://#{apiKey}:X@app.close.io/api/v1/lead/?query=email_address:#{userEmail}"
request.get uri, (error, response, body) => request.get uri, (error, response, body) =>
return done(error) if error return done(error) if error
leads = JSON.parse(body) leads = JSON.parse(body)
return done("Unexpected leads format: " + body) unless leads.data? return done("Unexpected Close leads format: " + body) unless leads.data?
return done("No existing Close.IO lead found for #{email}") unless leads.data?.length > 0 return done("No existing Close.IO lead found for #{userEmail}") unless leads.data?.length > 0
lead = leads.data[0] lead = leads.data[0]
uri = "https://#{apiKey}:X@app.close.io/api/v1/activity/?lead_id=#{lead.id}" uri = "https://#{apiKey}:X@app.close.io/api/v1/activity/?lead_id=#{lead.id}"
request.get uri, (error, response, body) => request.get uri, (error, response, body) =>
return done(error) if error return done(error) if error
activities = JSON.parse(body) activities = JSON.parse(body)
return done("Unexpected activities format: " + body) unless activities.data? return done("Unexpected activities format: " + body) unless activities.data?
for activity in activities.data when activity._type is 'Email' activityForThisContact = null
if /@codecombat\.(?:com)|(?:nl)/ig.test(activity.sender) and not (activity.sender?.indexOf(config.mail.username) >= 0) and not (activity.sender?.indexOf('brian@codecombat.com') >= 0) activityForThisLead = null
return done(null, activity.sender, activity.user_id, lead.id) for activity in activities.data when activity?._type is 'Email'
return done(null, config.mail.supportSchools, defaultSalesContactUser, lead.id) continue unless /@codecombat\.(?:com)|(?:nl)/ig.test(activity.sender)
continue if activity.sender.indexOf('brian@codecombat.com') >= 0
continue if activity.sender.indexOf(config.mail.username) >= 0
activityForThisLead ?= activity
for email in activity.to or [] when email?.toLowerCase() is userEmail?.toLowerCase()
activityForThisContact ?= activity
if activityForThisContact
return done(null, activityForThisContact.sender, activityForThisContact.user_id, lead.id)
else if activityForThisLead
return done(null, activityForThisLead.sender, activityForThisLead.user_id, lead.id)
if email = lead.custom?['auto_sales_email']
# Have to lookup Close user Id if email from lead custom field
uri = "https://#{apiKey}:X@app.close.io/api/v1/user/?_fields=id,email"
request.get uri, (error, response, body) =>
return done(error) if error
users = JSON.parse(body)
return done("Unexpected Close users format: " + body) unless users.data?
userID = null
for user in users.data or [] when user.email?.toLowerCase() is email.toLowerCase()
userID = user.id
break
if userID
return done(null, email, userID, lead.id)
else
@logError("No user found for leadID=#{lead.id} user=#{userEmail} auto_sales_email=#{lead.custom?['auto_sales_email']}")
return done(null, config.mail.supportSchools, defaultSalesContactUserID, lead.id)
else
return done(null, config.mail.supportSchools, defaultSalesContactUserID, lead.id)
catch error catch error
log.error("closeIO.getSalesContactEmail Error for #{email}: #{JSON.stringify(error)}") log.error("closeIO.getSalesContactEmail Error for #{userEmail}: #{JSON.stringify(error)}")
return done(error) return done(error)
sendMail: (fromAddress, subject, content, salesContactEmail, leadID, done) -> sendMail: (fromAddress, subject, content, salesContactEmail, leadID, done) ->
@ -106,7 +136,7 @@ module.exports =
uri: "https://#{apiKey}:X@app.close.io/api/v1/lead/#{leadID}/" uri: "https://#{apiKey}:X@app.close.io/api/v1/lead/#{leadID}/"
body: JSON.stringify(putData) body: JSON.stringify(putData)
request.put options, (error, response, body) => request.put options, (error, response, body) =>
return done(error) if error return done(error) if error
result = JSON.parse(body) result = JSON.parse(body)
if result.errors or result['field-errors'] if result.errors or result['field-errors']
errorMessage = "Update Close.io lead PUT error for #{teacherEmail} #{leadID}" errorMessage = "Update Close.io lead PUT error for #{teacherEmail} #{leadID}"
@ -123,7 +153,7 @@ module.exports =
uri: "https://#{apiKey}:X@app.close.io/api/v1/task/" uri: "https://#{apiKey}:X@app.close.io/api/v1/task/"
body: JSON.stringify(postData) body: JSON.stringify(postData)
request.post options, (error, response, body) => request.post options, (error, response, body) =>
return done(error) if error return done(error) if error
result = JSON.parse(body) result = JSON.parse(body)
if result.errors or result['field-errors'] if result.errors or result['field-errors']
errorMessage = "Create Close.io call task POST error for #{teacherEmail} #{leadID}" errorMessage = "Create Close.io call task POST error for #{teacherEmail} #{leadID}"
@ -144,7 +174,7 @@ module.exports =
uri: "https://#{apiKey}:X@app.close.io/api/v1/opportunity/" uri: "https://#{apiKey}:X@app.close.io/api/v1/opportunity/"
body: JSON.stringify(postData) body: JSON.stringify(postData)
request.post options, (error, response, body) => request.post options, (error, response, body) =>
return done(error) if error return done(error) if error
result = JSON.parse(body) result = JSON.parse(body)
if result.errors or result['field-errors'] if result.errors or result['field-errors']
errorMessage = "Create Close.io opportunity POST error for #{teacherEmail} #{leadID}" errorMessage = "Create Close.io opportunity POST error for #{teacherEmail} #{leadID}"