This repository has been archived on 2025-05-04. You can view files and clone it, but you cannot make any changes to it's state, such as pushing and creating new issues, pull requests or comments.
codecombat/scripts/updateCloseIoLeads.js
Matt Lott 1feb8a26a4 Add duplicate mail checks to updateCloseIoLeads.js
Fetching leads by name via Close.io API is not reliable.  Adding two
checks to protect against sending duplicate emails:
1. Query leads via recipient email to avoid creating duplicate leads.
Duplicate leads can result in duplicate automatic emails sent.
2. Lookup lead email activity when sending mails, and compare email
templates and recipient address.
2016-04-23 16:47:10 -07:00

681 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Upsert new lead data into Close.io
'use strict';
if (process.argv.length !== 7) {
log("Usage: node <script> <Close.io general API key> <Close.io mail API key1> <Close.io mail API key2> <Intercom 'App ID:API key'> <mongo connection Url>");
process.exit();
}
// TODO: Test multiple contacts
// TODO: Support multiple emails for the same contact (i.e diff trial and coco emails)
// TODO: Update notes with new data (e.g. coco user or intercom url)
// TODO: Find/fix case-sensitive bugs
// TODO: Use generators and promises
// TODO: Reduce response data via _fields param
// Save as custom fields instead of user-specific lead notes
const commonTrialProperties = ['organization', 'city', 'state', 'country'];
// Old properties which are deprecated or moved
const customFieldsToRemove = [
'coco_name', 'coco_firstName', 'coco_lastName', 'coco_gender', 'coco_numClassrooms', 'coco_numStudents', 'coco_role', 'coco_schoolName', 'coco_stats', 'coco_lastLevel',
'email', 'intercom_url', 'name',
'trial_created', 'trial_educationLevel', 'trial_phoneNumber', 'trial_email', 'trial_location', 'trial_name', 'trial_numStudents', 'trial_role', 'trial_userID', 'userID', 'trial_organization', 'trial_city', 'trial_state', 'trial_country',
'demo_request_organization', 'demo_request_city', 'demo_request_state', 'demo_request_country'
];
// Skip these problematic leads
const leadsToSkip = ['6 sınıflar', 'fdsafd', 'ashtasht', 'matt+20160404teacher3 school', 'sdfdsf', 'ddddd', 'dsfadsaf', "Nolan's School of Wonders"];
const demoRequestEmailTemplates = ['tmpl_s7BZiydyCHOMMeXAcqRZzqn0fOtk0yOFlXSZ412MSGm', 'tmpl_cGb6m4ssDvqjvYd8UaG6cacvtSXkZY3vj9b9lSmdQrf'];
const createTeacherEmailTemplates = ['tmpl_i5bQ2dOlMdZTvZil21bhTx44JYoojPbFkciJ0F560mn', 'tmpl_CEZ9PuE1y4PRvlYiKB5kRbZAQcTIucxDvSeqvtQW57G'];
const emailDelayMinutes = 27;
const scriptStartTime = new Date();
const closeIoApiKey = process.argv[2];
const closeIoMailApiKeys = [process.argv[3], process.argv[4]]; // Automatic mails sent as API owners
const intercomAppIdApiKey = process.argv[5];
const intercomAppId = intercomAppIdApiKey.split(':')[0];
const intercomApiKey = intercomAppIdApiKey.split(':')[1];
const mongoConnUrl = process.argv[6];
const MongoClient = require('mongodb').MongoClient;
const async = require('async');
const request = require('request');
const earliestDate = new Date();
earliestDate.setUTCDate(earliestDate.getUTCDate() - 10);
// log('DEBUG: Finding leads..');
findLeads((err, leads) => {
if (err) {
console.error(err);
return;
}
log(`Num leads ${Object.keys(leads).length}`);
// log('DEBUG: Adding Intercom data..');
addIntercomData(leads, (err) => {
if (err) {
console.error(err);
return;
}
// log('DEBUG: Updating leads..');
updateLeads(leads, (err) => {
if (err) {
console.error(err);
return;
}
log("Script runtime: " + (new Date() - scriptStartTime));
});
});
});
/* Helpers */
class Lead {
constructor(name) {
this.contacts = {};
this.custom = {};
this.name = name;
}
addClassroom(email, classroom) {
if (!this.contacts[email.toLowerCase()]) this.contacts[email.toLowerCase()] = {};
const contact = this.contacts[email.toLowerCase()];
contact.numClassrooms = contact.numClassrooms ? contact.numClassrooms + 1 : 1;
if (classroom.members && classroom.members.length) {
contact.numStudents = contact.numStudents ? contact.numStudents + classroom.members.length : classroom.members.length;
}
}
addIntercomUser(email, user) {
if (user && user.id) {
if (!this.contacts[email.toLowerCase()]) this.contacts[email.toLowerCase()] = {};
this.contacts[email.toLowerCase()].intercomUrl = `https://app.intercom.io/a/apps/${intercomAppId}/users/${user.id}/`;
}
}
addTrialRequest(email, trial) {
if (!this.contacts[email.toLowerCase()]) this.contacts[email.toLowerCase()] = {};
if (trial.properties.firstName && trial.properties.lastName) {
this.contacts[email.toLowerCase()].name = `${trial.properties.firstName} ${trial.properties.lastName}`;
}
else if (trial.properties.name) {
this.contacts[email.toLowerCase()].name = trial.properties.name;
}
this.contacts[email.toLowerCase()].trial = trial;
}
addUser(email, user) {
this.contacts[email.toLowerCase()].user = user;
}
getLeadPostData() {
const postData = {
display_name: this.name,
name: this.name,
status: 'Auto Attempted',
contacts: this.getContactsPostData(),
custom: {
lastUpdated: new Date(),
'Lead Origin': this.getLeadOrigin()
}
};
for (const email in this.contacts) {
const props = this.contacts[email].trial.properties;
if (props) {
for (const prop in props) {
if (commonTrialProperties.indexOf(prop) >= 0) {
postData.custom[`demo_${prop}`] = props[prop];
}
}
}
}
return postData;
}
getLeadPutData(currentLead) {
// console.log('DEBUG: getLeadPutData', currentLead.name);
const putData = {};
const currentCustom = currentLead.custom || {};
if (!currentCustom['Lead Origin']) {
putData['custom.Lead Origin'] = this.getLeadOrigin();
}
for (const email in this.contacts) {
const props = this.contacts[email].trial.properties;
if (props) {
for (const prop in props) {
if (commonTrialProperties.indexOf(prop) >= 0 && currentCustom[`demo_${prop}`] !== props[prop] && currentCustom[`demo_${prop}`].indexOf(props[prop]) < 0) {
putData[`custom.demo_${prop}`] = props[prop];
}
}
}
}
for (const field of customFieldsToRemove) {
if (currentCustom[field]) {
putData[`custom.${field}`] = null;
}
}
if (Object.keys(putData).length > 0) {
putData[`custom.lastUpdated`] = new Date();
}
return putData;
}
getLeadOrigin() {
for (const email in this.contacts) {
const props = this.contacts[email].trial.properties;
switch (props.siteOrigin) {
case 'create teacher':
return 'Create Teacher';
case 'convert teacher':
return 'Convert Teacher';
}
}
return 'Demo Request';
}
getContactsPostData(existingLead) {
const postData = [];
const existingEmails = {};
if (existingLead) {
const existingContacts = existingLead.contacts || [];
for (const contact of existingContacts) {
const emails = contact.emails || [];
for (const email of emails) {
existingEmails[email.email.toLowerCase()] = true;
}
}
}
for (const email in this.contacts) {
if (existingEmails[email]) continue;
const contact = this.contacts[email];
const data = {
emails: [{email: email}],
name: contact.name
}
const props = contact.trial.properties;
if (props.phoneNumber) {
data.phones = [{phone: props.phoneNumber}];
}
if (props.role) {
data.title = props.role;
}
else if (contact.user || contact.user.role) {
data.title = contact.user.role;
}
postData.push(data);
}
return postData;
}
getNotesPostData(currentNotes) {
// Post activity notes for each contact
function noteExists(email) {
if (currentNotes) {
for (const note of currentNotes) {
if (note.note.indexOf(email) >= 0) {
return true;
}
}
}
return false;
}
const notes = [];
for (const email in this.contacts) {
if (!noteExists(email)) {
const contact = this.contacts[email];
let noteData = "";
const trial = contact.trial
if (trial.properties) {
const props = trial.properties;
if (props.name) {
noteData += `${props.name}\n`;
}
if (props.email) {
noteData += `demo_email: ${props.email.toLowerCase()}\n`;
}
if (trial.created) {
noteData += `demo_request: ${trial.created}\n`;
}
if (props.educationLevel) {
noteData += `demo_educationLevel: ${props.educationLevel.join(', ')}\n`;
}
for (const prop in props) {
if (['email', 'educationLevel', 'created'].indexOf(prop) >= 0 || commonTrialProperties.indexOf(prop) >= 0) continue;
noteData += `demo_${prop}: ${props[prop]}\n`;
}
}
if (contact.intercomUrl) noteData += `intercom_url: ${contact.intercomUrl}\n`;
if (contact.user) {
const user = contact.user
noteData += `coco_userID: ${user._id}\n`;
if (user.firstName) noteData += `coco_firstName: ${user.firstName}\n`;
if (user.lastName) noteData += `coco_lastName: ${user.lastName}\n`;
if (user.name) noteData += `coco_name: ${user.name}\n`;
if (user.emaillower) noteData += `coco_email: ${user.emailLower}\n`;
if (user.gender) noteData += `coco_gender: ${user.gender}\n`;
if (user.lastLevel) noteData += `coco_lastLevel: ${user.lastLevel}\n`;
if (user.role) noteData += `coco_role: ${user.role}\n`;
if (user.schoolName) noteData += `coco_schoolName: ${user.schoolName}\n`;
if (user.stats && user.stats.gamesCompleted) noteData += `coco_gamesCompleted: ${user.stats.gamesCompleted}\n`;
noteData += `coco_preferredLanguage: ${user.preferredLanguage || 'en-US'}\n`;
}
if (contact.numClassrooms) {
noteData += `coco_numClassrooms: ${contact.numClassrooms}\n`
}
if (contact.numStudents) {
noteData += `coco_numStudents: ${contact.numStudents}\n`
}
notes.push(noteData);
}
}
return notes;
}
}
function findLeads(done) {
MongoClient.connect(mongoConnUrl, (err, db) => {
if (err) return done(err);
// Recent trial requests
const query = {$and: [{created: {$gte: earliestDate}}, {type: 'course'}]};
db.collection('trial.requests').find(query).toArray((err, trialRequests) => {
if (err) {
db.close();
return done(err);
}
const leads = {};
const emailLeadMap = {};
const emails = [];
for (const trialRequest of trialRequests) {
if (!trialRequest.properties || !trialRequest.properties.email) continue;
const email = trialRequest.properties.email.toLowerCase();
emails.push(email);
const name = trialRequest.properties.organization || trialRequest.properties.name || email;
if (!leads[name]) leads[name] = new Lead(name);
leads[name].addTrialRequest(email, trialRequest);
emailLeadMap[email] = leads[name];
}
// Users for trial requests
const query = {$and: [
{emailLower: {$in: emails}},
{anonymous: false}
]};
db.collection('users').find(query).toArray((err, users) => {
if (err) {
db.close();
return done(err);
}
const userIDs = [];
const userLeadMap = {};
const userEmailMap = {};
for (const user of users) {
const email = user.emailLower;
emailLeadMap[email].addUser(email, user);
userIDs.push(user._id);
userLeadMap[user._id.valueOf()] = emailLeadMap[email];
userEmailMap[user._id.valueOf()] = email;
}
// Classrooms for users
const query = {ownerID: {$in: userIDs}};
db.collection('classrooms').find(query).toArray((err, classrooms) => {
if (err) {
db.close();
return done(err);
}
for (const classroom of classrooms) {
userLeadMap[classroom.ownerID.valueOf()].addClassroom(userEmailMap[classroom.ownerID.valueOf()], classroom);
}
db.close();
return done(null, leads);
});
});
});
});
}
function createAddIntercomDataFn(lead, email) {
return (done) => {
const options = {
url: `https://api.intercom.io/users?email=${encodeURIComponent(email)}`,
auth: {
user: intercomAppId,
pass: intercomApiKey
},
headers: {
'Accept': 'application/json'
}
};
request.get(options, (error, response, body) => {
if (error) return done(error);
try {
const user = JSON.parse(body);
lead.addIntercomUser(email, user);
}
catch (err) {
console.log(err);
console.log(body);
}
return done();
});
};
}
function addIntercomData(leads, done) {
const tasks = []
for (const name in leads) {
for (const email in leads[name].contacts) {
tasks.push(createAddIntercomDataFn(leads[name], email));
}
}
async.parallel(tasks, (err, results) => {
return done(err);
});
}
function updateExistingLead(lead, existingLead, done) {
// console.log('DEBUG: updateExistingLead', existingLead.id);
const putData = lead.getLeadPutData(existingLead);
const options = {
uri: `https://${closeIoApiKey}:X@app.close.io/api/v1/lead/${existingLead.id}/`,
body: JSON.stringify(putData)
};
request.put(options, (error, response, body) => {
if (error) return done(error);
const result = JSON.parse(body);
if (result.errors || result['field-errors']) {
console.error(`Update existing lead PUT error for ${lead.name}`);
console.error(body);
// console.log(putData);
return done();
}
// Add contacts
const newContacts = lead.getContactsPostData(existingLead);
const tasks = []
for (const newContact of newContacts) {
newContact.lead_id = existingLead.id;
tasks.push(createAddContactFn(newContact, lead, existingLead));
}
async.parallel(tasks, (err, results) => {
if (err) return done(err);
// Add notes
const url = `https://${closeIoApiKey}:X@app.close.io/api/v1/activity/note/?lead_id=${existingLead.id}`;
request.get(url, (error, response, body) => {
if (error) return done(error);
const currentNotes = JSON.parse(body).data;
const newNotes = lead.getNotesPostData(currentNotes);
const tasks = []
for (const newNote of newNotes) {
tasks.push(createAddNoteFn(existingLead.id, newNote));
}
async.parallel(tasks, (err, results) => {
return done(err);
});
});
});
});
}
function saveNewLead(lead, done) {
// console.log('DEBUG: saveNewLead', lead.name);
const postData = lead.getLeadPostData();
const options = {
uri: `https://${closeIoApiKey}:X@app.close.io/api/v1/lead/`,
body: JSON.stringify(postData)
};
request.post(options, (error, response, body) => {
if (error) return done(error);
const existingLead = JSON.parse(body);
if (existingLead.errors || existingLead['field-errors']) {
console.error(`New lead POST error for ${lead.name}`);
console.error(body);
// console.error(JSON.stringify(postData, null, 2));
return done();
}
// Add notes
const newNotes = lead.getNotesPostData();
const tasks = []
for (const newNote of newNotes) {
tasks.push(createAddNoteFn(existingLead.id, newNote));
}
async.parallel(tasks, (err, results) => {
if (err) return done(err);
// Send emails to new contacts
const tasks = [];
for (const contact of existingLead.contacts) {
for (const email of contact.emails) {
if (['create teacher', 'convert teacher'].indexOf(lead.contacts[email.email].trial.properties.siteOrigin) >= 0) {
tasks.push(createSendEmailFn(email.email, existingLead.id, contact.id, getRandomEmailTemplate(createTeacherEmailTemplates)));
}
else {
tasks.push(createSendEmailFn(email.email, existingLead.id, contact.id, getRandomEmailTemplate(demoRequestEmailTemplates)));
}
}
}
async.parallel(tasks, (err, results) => {
return done(err);
});
});
});
}
function createFindExistingLeadFn(email, name, existingLeads) {
return (done) => {
// console.log('DEBUG: findEmailLead', email);
const query = `recipient:"${email}"`;
const url = `https://${closeIoApiKey}:X@app.close.io/api/v1/lead/?query=${encodeURIComponent(query)}`;
request.get(url, (error, response, body) => {
if (error) return done(error);
try {
const data = JSON.parse(body);
if (data.total_results > 0) {
if (!existingLeads[name]) existingLeads[name] = [];
for (const lead of data.data) {
existingLeads[name].push(lead);
}
}
return done();
} catch (error) {
// console.log(url);
// console.log(error);
// console.log(body);
return done();
}
});
};
}
function createUpdateLeadFn(lead, existingLeads) {
return (done) => {
// console.log('DEBUG: updateLead', lead.name);
const query = `name:"${lead.name}"`;
const url = `https://${closeIoApiKey}:X@app.close.io/api/v1/lead/?query=${encodeURIComponent(query)}`;
request.get(url, (error, response, body) => {
if (error) return done(error);
try {
const data = JSON.parse(body);
if (data.total_results === 0) {
if (existingLeads[lead.name.toLowerCase()]) {
if (existingLeads[lead.name.toLowerCase()].length === 1) {
console.log(`DEBUG: Using lead from email lookup: ${lead.name}`);
return updateExistingLead(lead, existingLeads[lead.name.toLowerCase()][0], done);
}
console.error(`ERROR: ${existingLeads[lead.name.toLowerCase()].length} email leads found for ${lead.name}`);
return done();
}
return saveNewLead(lead, done);
}
if (data.total_results > 1) {
console.error(`ERROR: ${data.total_results} leads found for ${lead.name}`);
return done();
}
return updateExistingLead(lead, data.data[0], done);
} catch (error) {
// console.log(url);
// console.log(error);
// console.log(body);
return done();
}
});
};
}
function createAddContactFn(postData, internalLead, externalLead) {
return (done) => {
// console.log('DEBUG: addContact', postData.lead_id);
const options = {
uri: `https://${closeIoApiKey}:X@app.close.io/api/v1/contact/`,
body: JSON.stringify(postData)
};
request.post(options, (error, response, body) => {
if (error) return done(error);
const newContact = JSON.parse(body);
if (newContact.errors || newContact['field-errors']) {
console.error(`New Contact POST error for ${postData.lead_id}`);
console.error(body);
return done();
}
// Send emails to new contact
const email = postData.emails[0].email;
if (['create teacher', 'convert teacher'].indexOf(internalLead.contacts[email].trial.properties.siteOrigin) >= 0) {
return sendMail(email, externalLead.id, newContact.id, getRandomEmailTemplate(createTeacherEmailTemplates), done);
}
else {
return sendMail(email, externalLead.id, newContact.id, getRandomEmailTemplate(demoRequestEmailTemplates), done);
}
});
};
}
function createAddNoteFn(leadId, newNote) {
return (done) => {
// console.log('DEBUG: addNote', leadId);
const notePostData = {
note: newNote,
lead_id: leadId
};
const options = {
uri: `https://${closeIoApiKey}:X@app.close.io/api/v1/activity/note/`,
body: JSON.stringify(notePostData)
};
request.post(options, (error, response, body) => {
if (error) return done(error);
const result = JSON.parse(body);
if (result.errors || result['field-errors']) {
console.error(`New note POST error for ${leadId}`);
console.error(body);
// console.error(notePostData);
}
return done();
});
};
}
function getRandomEmailTemplate(templates) {
if (templates.length < 0) return '';
return templates[Math.floor(Math.random() * templates.length)];
}
function isSameEmailTemplateType(template1, template2) {
if (createTeacherEmailTemplates.indexOf(template1) >= 0 && createTeacherEmailTemplates.indexOf(template2) >= 0) {
return true;
}
if (demoRequestEmailTemplates.indexOf(template1) >= 0 && demoRequestEmailTemplates.indexOf(template2) >= 0) {
return true;
}
return false;
}
function getRandomEmailApiKey() {
if (closeIoMailApiKeys.length < 0) return;
return closeIoMailApiKeys[Math.floor(Math.random() * closeIoMailApiKeys.length)];
}
function createSendEmailFn(email, leadId, contactId, template) {
return (done) => {
return sendMail(email, leadId, contactId, template, done);
};
}
function sendMail(toEmail, leadId, contactId, template, done) {
// console.log('DEBUG: sendMail', toEmail, leadId, contactId, template);
// Check for previously sent email
const url = `https://${closeIoApiKey}:X@app.close.io/api/v1/activity/email/?lead_id=${leadId}`;
request.get(url, (error, response, body) => {
if (error) return done(error);
try {
const data = JSON.parse(body);
for (const emailData of data.data) {
if (!isSameEmailTemplateType(emailData.template_id, template)) continue;
for (const email of emailData.to) {
if (email.toLowerCase() === toEmail.toLowerCase()) {
console.error("ERROR: sending duplicate email:", toEmail, leadId, contactId, template, emailData.contact_id);
return done();
}
}
}
}
catch (err) {
console.log(err);
console.log(body);
return done();
}
// Send mail
const dateScheduled = new Date();
dateScheduled.setUTCMinutes(dateScheduled.getUTCMinutes() + emailDelayMinutes);
const postData = {
to: [toEmail],
contact_id: contactId,
lead_id: leadId,
template_id: template,
status: 'scheduled',
date_scheduled: dateScheduled
};
const options = {
uri: `https://${getRandomEmailApiKey()}:X@app.close.io/api/v1/activity/email/`,
body: JSON.stringify(postData)
};
request.post(options, (error, response, body) => {
if (error) return done(error);
const result = JSON.parse(body);
if (result.errors || result['field-errors']) {
const errorMessage = `Send email POST error for ${toEmail} ${leadId} ${contactId}`;
console.error(errorMessage);
console.error(body);
// console.error(postData);
return done(errorMessage);
}
return done();
});
});
}
function updateLeads(leads, done) {
// 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 name in leads) {
if (leadsToSkip.indexOf(name) >= 0) continue;
for (const email in leads[name].contacts) {
tasks.push(createFindExistingLeadFn(email.toLowerCase(), name.toLowerCase(), existingLeads));
}
}
async.parallel(tasks, (err, results) => {
const tasks = [];
for (const name in leads) {
if (leadsToSkip.indexOf(name) >= 0) continue;
tasks.push(createUpdateLeadFn(leads[name], existingLeads));
}
async.parallel(tasks, (err, results) => {
return done(err);
});
});
}
function log(str) {
console.log(new Date().toISOString() + " " + str);
}