fleet/website/api/helpers/salesforce/update-or-create-contact-and-account.js

413 lines
22 KiB
JavaScript
Vendored

module.exports = {
friendlyName: 'Update or create contact and account',
description: 'Upsert contact±account into Salesforce given fresh data about a particular person, and fresh IQ-enrichment data about the person and account.',
inputs: {
// Find by…
emailAddress: { type: 'string' },
linkedinUrl: { type: 'string' },
// Set…
firstName: { type: 'string'},
lastName: { type: 'string'},
jobTitle: {type: 'string'},
organization: { type: 'string' },
description: { type: 'string' },
primaryBuyingSituation: { type: 'string' },
psychologicalStage: {
type: 'string',
isIn: [
'1 - Unaware',
'2 - Aware',
'3 - Intrigued',
'4 - Has use case',
'5 - Personally confident',
'6 - Has team buy-in'
]
},
psychologicalStageChangeReason: {
type: 'string',
example: 'Website - Organic start flow'
},
contactSource: {
type: 'string',
isIn: [
'Website - Contact forms',
'Website - Sign up',
'Website - Newsletter',
'LinkedIn - Comment',
'LinkedIn - Reaction',
'LinkedIn - Share',
'LinkedIn - Liked the LinkedIn company page',
'Event - Marketing-led event',
'Event - Sales-led event',
],
},
getStartedResponses: {
type: 'string',
},
intentSignal: {
type: 'string',
isIn: [
'Subscribed to the Fleet newsletter',
// 'Signed up for a fleetdm.com account',//
// 'Submitted the "Talk to us" form',
// 'Submitted the "Send a message" form',
],
}
},
exits: {
success: {
outputType: {
salesforceAccountId: 'string',
salesforceContactId: 'string'
}
},
couldNotCreateorUpdateContact: {
description: 'A Contact record could not be updated in the CRM for this user.',
}
},
fn: async function ({emailAddress, linkedinUrl, firstName, lastName, organization, jobTitle, primaryBuyingSituation, psychologicalStage, psychologicalStageChangeReason, contactSource, description, getStartedResponses, intentSignal}) {
// Return undefined if we're not running in a production environment.
if(sails.config.environment !== 'production') {
sails.log.verbose('Skipping Salesforce integration...');
return {
salesforceAccountId: undefined,
salesforceContactId: undefined
};
}
require('assert')(sails.config.custom.salesforceIntegrationUsername);
require('assert')(sails.config.custom.salesforceIntegrationPasskey);
require('assert')(sails.config.custom.iqSecret);
require('assert')(sails.config.custom.RX_PROTOCOL_AND_COMMON_SUBDOMAINS);
if(!emailAddress && !linkedinUrl){
throw new Error('UsageError: when updating or creating a contact and account in salesforce, either an email or linkedInUrl is required.');
}
//
// ╦ ╔═╗╔═╗╦╔╗╔ ╔╦╗╔═╗ ╔═╗╔═╗╦ ╔═╗╔═╗╔═╗╔═╗╦═╗╔═╗╔═╗
// ║ ║ ║║ ╦║║║║ ║ ║ ║ ╚═╗╠═╣║ ║╣ ╚═╗╠╣ ║ ║╠╦╝║ ║╣
// ╩═╝╚═╝╚═╝╩╝╚╝ ╩ ╚═╝ ╚═╝╩ ╩╩═╝╚═╝╚═╝╚ ╚═╝╩╚═╚═╝╚═╝
// Log in to Salesforce.
let jsforce = require('jsforce');
let salesforceConnection = new jsforce.Connection({
loginUrl : 'https://fleetdm.my.salesforce.com'
});
await salesforceConnection.login(sails.config.custom.salesforceIntegrationUsername, sails.config.custom.salesforceIntegrationPasskey);
let salesforceContactId;
let salesforceAccountId;
// ╔╗ ╦ ╦╦╦ ╔╦╗ ╦ ╦╔═╗╦ ╦ ╦╔═╗╔═╗ ╔╦╗╔═╗ ╔═╗╔═╗╔╦╗
// ╠╩╗║ ║║║ ║║ ╚╗╔╝╠═╣║ ║ ║║╣ ╚═╗ ║ ║ ║ ╚═╗║╣ ║
// ╚═╝╚═╝╩╩═╝═╩╝ ╚╝ ╩ ╩╩═╝╚═╝╚═╝╚═╝ ╩ ╚═╝ ╚═╝╚═╝ ╩
// Build a dictionary of values we'll update/create a contact record with.
let valuesToSet = {};
if(emailAddress){
valuesToSet.Email = emailAddress;
}
if(linkedinUrl){
valuesToSet.LinkedIn_profile__c = linkedinUrl;// eslint-disable-line camelcase
}
if(primaryBuyingSituation) {
valuesToSet.Primary_buying_situation__c = primaryBuyingSituation;// eslint-disable-line camelcase
}
if(getStartedResponses) {
valuesToSet.Website_questionnaire_answers__c = getStartedResponses;// eslint-disable-line camelcase
}
if(description) {
valuesToSet.Description = description;
}
if(intentSignal) {
valuesToSet.Intent_signals__c = intentSignal;// eslint-disable-line camelcase
}
if(jobTitle) {
valuesToSet.Title = jobTitle;
}
// ╦ ╔═╗╔═╗╦╔═ ╔═╗╔═╗╦═╗ ╔═╗═╗ ╦╦╔═╗╔╦╗╦╔╗╔╔═╗ ╔═╗╔═╗╔╗╔╔╦╗╔═╗╔═╗╔╦╗
// ║ ║ ║║ ║╠╩╗ ╠╣ ║ ║╠╦╝ ║╣ ╔╩╦╝║╚═╗ ║ ║║║║║ ╦ ║ ║ ║║║║ ║ ╠═╣║ ║
// ╩═╝╚═╝╚═╝╩ ╩ ╚ ╚═╝╩╚═ ╚═╝╩ ╚═╩╚═╝ ╩ ╩╝╚╝╚═╝ ╚═╝╚═╝╝╚╝ ╩ ╩ ╩╚═╝ ╩
// Search for an existing Contact record using the provided email address or linkedIn profile URL.
let existingContactRecord;
if(emailAddress) {
existingContactRecord = await salesforceConnection.sobject('Contact')
.findOne({
Email: emailAddress,
});
if(!existingContactRecord){
// If no contacts are found when searching by the provided email address, look for existing contact records that have a "last email associated by fleetdm.com" set to the providded email address.
existingContactRecord = await salesforceConnection.sobject('Contact')
.findOne({
Last_email_associated_by_fleetdm_com__c: emailAddress, // eslint-disable-line camelcase
});
// If we matched a contact record by searchign last email associated by fleetdm.com, remove the email address from the update criteria.
if(existingContactRecord){
delete valuesToSet.Email;
}
}
} else if(linkedinUrl) {
existingContactRecord = await salesforceConnection.sobject('Contact')
.findOne({
LinkedIn_profile__c: linkedinUrl // eslint-disable-line camelcase
});
if(!existingContactRecord){
// If no contacts are found when searching by the provided linkedIn url, look for an existing contact records that have a modified linkedIn profile url.
existingContactRecord = await salesforceConnection.sobject('Contact')
.findOne({
LinkedIn_profile__c: linkedinUrl.replace(sails.config.custom.RX_PROTOCOL_AND_COMMON_SUBDOMAINS, '') // eslint-disable-line camelcase
});
}
}
// ╔╗╔╔═╗ ╔═╗╔═╗╔╗╔╔╦╗╔═╗╔═╗╔╦╗ ╔═╗╔═╗╦ ╦╔╗╔╔╦╗
// ║║║║ ║ ║ ║ ║║║║ ║ ╠═╣║ ║ ╠╣ ║ ║║ ║║║║ ║║
// ╝╚╝╚═╝ ╚═╝╚═╝╝╚╝ ╩ ╩ ╩╚═╝ ╩ ╚ ╚═╝╚═╝╝╚╝═╩╝
if(!existingContactRecord) {
// Otherwise, we'll enrich the information we have, and check for an existing account.
if(linkedinUrl){
// If linkedinUrl was provided, strip the protocol and subdomain from the URL.
linkedinUrl = linkedinUrl.replace(sails.config.custom.RX_PROTOCOL_AND_COMMON_SUBDOMAINS, '');
}
// Send the information we have to the enrichment helper.
let enrichmentData = await sails.helpers.iq.getEnriched(emailAddress, linkedinUrl, firstName, lastName, organization);
// Add information from the enrichmentData to the values to set on the new Contact record.
if(enrichmentData.person && enrichmentData.person.linkedinUrl){
valuesToSet.LinkedIn_profile__c = enrichmentData.person.linkedinUrl;// eslint-disable-line camelcase
}
if(enrichmentData.person && enrichmentData.person.title){
valuesToSet.Title = enrichmentData.person.title;
}
let salesforceAccountOwnerId;
if(!enrichmentData.employer || !enrichmentData.employer.emailDomain || !enrichmentData.employer.organization) {
// Special sacrificial meat cave where the contacts with no organization go.
// https://fleetdm.lightning.force.com/lightning/r/Account/0014x000025JC8DAAW/view
salesforceAccountId = '0014x000025JC8DAAW';
salesforceAccountOwnerId = '0054x00000735wDAAQ';// « "Integrations admin" user.
} else {
//
// ╦ ╔═╗╔═╗╦╔═ ╔═╗╔═╗╦═╗ ╔═╗═╗ ╦╦╔═╗╔╦╗╦╔╗╔╔═╗ ╔═╗╔═╗╔═╗╔═╗╦ ╦╔╗╔╔╦╗
// ║ ║ ║║ ║╠╩╗ ╠╣ ║ ║╠╦╝ ║╣ ╔╩╦╝║╚═╗ ║ ║║║║║ ╦ ╠═╣║ ║ ║ ║║ ║║║║ ║
// ╩═╝╚═╝╚═╝╩ ╩ ╚ ╚═╝╩╚═ ╚═╝╩ ╚═╩╚═╝ ╩ ╩╝╚╝╚═╝ ╩ ╩╚═╝╚═╝╚═╝╚═╝╝╚╝ ╩
// Search for an existing Account record by the organization returned from the getEnriched helper.
let existingAccountRecord = await salesforceConnection.sobject('Account')
.findOne({
'Name': enrichmentData.employer.organization,
// 'LinkedIn_company_URL__c': enrichmentData.employer.linkedinCompanyPageUrl // TODO: if this information is not present on an existing account, nothing will be returned.
});
// If we didn't find an account that's name exaclty matches, we'll do another search using the provided email domain.
if(!existingAccountRecord){
existingAccountRecord = await salesforceConnection.sobject('Account')
.findOne({
'Website': enrichmentData.employer.emailDomain,
// 'LinkedIn_company_URL__c': enrichmentData.employer.linkedinCompanyPageUrl // TODO: if this information is not present on an existing account, nothing will be returned.
});
}
// If we didn't find an existing account by name or website, try to get the global domain of the user's organization and look for a matching account record.
if(!existingAccountRecord) {
let glboalDomainPrompt = `Given this domain "${enrichmentData.employer.emailDomain}", assuming we want a global customer account entry in our CRM, what might be the equivalent global domain? If the website is already the global domain, respond with that. (Respond only with the domain, as a JSON string.)`;
let globalDomain = await sails.helpers.ai.prompt.with({prompt: glboalDomainPrompt, baseModel:'gpt-5-nano-2025-08-07', expectJson: true})
.tolerate((err)=>{
sails.log.warn(`When trying to ask ChatGPT about the global domain of an organization for a user, an error occurred. Full error: ${require('util').inspect(err, {depth: 2})}`);
// If an error occurs getting the global domain, return the emailDomain from the get-enriched helper.
return enrichmentData.employer.emailDomain;// Note: This will make the account search below
});
// Now look for an account record with this global domain.
existingAccountRecord = await salesforceConnection.sobject('Account')
.findOne({
'Website': globalDomain,
// 'LinkedIn_company_URL__c': enrichmentData.employer.linkedinCompanyPageUrl // TODO: if this information is not present on an existing account, nothing will be returned.
});
// Update the employer's emailDomain in the enrichmentData to be the global domain.
enrichmentData.employer.emailDomain = globalDomain;
}
// console.log(existingAccountRecord);
// If we found an exisitng account, we'll assign the new contact to the account owner.
if(existingAccountRecord) {
// Store the ID of the Account record we found.
salesforceAccountId = existingAccountRecord.Id;
salesforceAccountOwnerId = existingAccountRecord.OwnerId;
// console.log('exising account found!', salesforceAccountId);
} else {
// ╔═╗╦═╗╔═╗╔═╗╔╦╗╔═╗ ╔╗╔╔═╗╦ ╦ ╔═╗╔═╗╔═╗╔═╗╦ ╦╔╗╔╔╦╗
// ║ ╠╦╝║╣ ╠═╣ ║ ║╣ ║║║║╣ ║║║ ╠═╣║ ║ ║ ║║ ║║║║ ║
// ╚═╝╩╚═╚═╝╩ ╩ ╩ ╚═╝ ╝╚╝╚═╝╚╩╝ ╩ ╩╚═╝╚═╝╚═╝╚═╝╝╚╝ ╩
// If no existing account record was found, create a new one, and assign it to the "Integrations Admin" user.
salesforceAccountOwnerId = '0054x00000735wDAAQ';// « "Integrations admin" user.
// Create a timestamp to use for the new account's assigned date.
let today = new Date();
let nowOn = today.toISOString().replace('Z', '+0000');
require('assert')(typeof enrichmentData.employer.numberOfEmployees === 'number');
let newAccountRecord = await salesforceConnection.sobject('Account')
.create({
Account_Assigned_date__c: nowOn,// eslint-disable-line camelcase
// eslint-disable-next-line camelcase
Current_Assignment_Reason__c: 'Inbound Lead',// TODO verify that this matters. if not, do not set it.
Prospect_Status__c: 'Assigned',// eslint-disable-line camelcase
Name: enrichmentData.employer.organization,// IFWMIH: We know organization exists
Website: enrichmentData.employer.emailDomain,
LinkedIn_company_URL__c: enrichmentData.employer.linkedinCompanyPageUrl,// eslint-disable-line camelcase
NumberOfEmployees: enrichmentData.employer.numberOfEmployees,
OwnerId: salesforceAccountOwnerId
});
salesforceAccountId = newAccountRecord.id;
}//fi
// console.log('New account created!', salesforceAccountId);
}//fi
// Only add contactSource to valuesToSet if we're creating a new contact record.
if(contactSource) {
valuesToSet.Contact_source__c = contactSource;// eslint-disable-line camelcase
}
// console.log(`creating new Contact record.`)
// ╔═╗╦═╗╔═╗╔═╗╔╦╗╔═╗ ╔╗╔╔═╗╦ ╦ ╔═╗╔═╗╔╗╔╔╦╗╔═╗╔═╗╔╦╗
// ║ ╠╦╝║╣ ╠═╣ ║ ║╣ ║║║║╣ ║║║ ║ ║ ║║║║ ║ ╠═╣║ ║
// ╚═╝╩╚═╚═╝╩ ╩ ╩ ╚═╝ ╝╚╝╚═╝╚╩╝ ╚═╝╚═╝╝╚╝ ╩ ╩ ╩╚═╝ ╩
let duplicateContactWasFound = false;
let newContactRecord = await sails.helpers.flow.build(async ()=>{
return await salesforceConnection.sobject('Contact')
.create({
AccountId: salesforceAccountId,
OwnerId: salesforceAccountOwnerId,
FirstName: firstName ? firstName : '?',
LastName: lastName ? lastName : '?',
...valuesToSet,
});
})// If Salesforce returns a duplicates_detected error message, use the first duplicate record returned in the error.
.tolerate({errorCode: 'DUPLICATES_DETECTED'}, (err)=>{
// Get the first matched duplicate record returned in the error returned by Salesforce.
let firstContactRecordMatchedByDuplicateRule = _.get(err, 'duplicateResult.matchResults[0].matchRecords[0].record.Id');
if(firstContactRecordMatchedByDuplicateRule) {
duplicateContactWasFound = true;
return {id: firstContactRecordMatchedByDuplicateRule};
} else {
// If the error returned from Salesforce does not contain a list of matched records (TODO: Why would this ever happen?), log a warning to alert us, and return undefined.
sails.log.warn(`When trying to create a new Contact record (email: ${emailAddress}), the CRM returned a DUPLICATES_DETECTED error, but returned no list of potential duplicate records. Full error: ${require('util').inspect(err, {depth: null})}`);
return;
}
});
// Throw a couldNotCreateOrUpdateContact response if no duplicate records were returned in the error from Salesforce. (I'm not sure why/if this would ever happen)
if(!newContactRecord) {
throw 'couldNotCreateorUpdateContact';
}
salesforceContactId = newContactRecord.id;
if(!duplicateContactWasFound) {
// Since we've created a new contact, we'll update the psychological stage to be either '2 - Aware', or whatever psystage was provided.
// This causes it to appear as an edit in our CRM and helps reporting.
await salesforceConnection.sobject('Contact')
.update({
Id: salesforceContactId,
Stage__c: psychologicalStage ? psychologicalStage : '2 - Aware',// eslint-disable-line camelcase
Psystage_change_reason__c: psychologicalStageChangeReason ? psychologicalStageChangeReason : null,// eslint-disable-line camelcase
});
// console.log(`Created ${newContactRecord.id}`);
} else {
// If we found a duplicate record when trying to create a new contact, we'll proceed as if we had found an existing contact and we'll update
// that contact so the next time this helper runs for this user, it will find the correct contact without attempting to create a new one.
existingContactRecord = await salesforceConnection.sobject('Contact')
.findOne({
Id: salesforceContactId,
});
// If an email address was provided, and the existing contact has an email address set, remove it from the ValuesToSet dictionairy and set it as the "last email associated by fleetdm.com"
if(emailAddress && existingContactRecord.Email){
delete valuesToSet.Email;
valuesToSet.Last_email_associated_by_fleetdm_com__c = emailAddress;// eslint-disable-line camelcase
}
// If a contact souce was provided, since we found an existing contact when trying to create one, remove it from the valuesToSet.
if(contactSource) {
delete valuesToSet.Contact_source__c;
}
}
}//fi
// ╦ ╦╔═╗╔╦╗╔═╗╔╦╗╔═╗ ╔═╗═╗ ╦╦╔═╗╔╦╗╦╔╗╔╔═╗ ╔═╗╔═╗╔╗╔╔╦╗╔═╗╔═╗╔╦╗
// ║ ║╠═╝ ║║╠═╣ ║ ║╣ ║╣ ╔╩╦╝║╚═╗ ║ ║║║║║ ╦ ║ ║ ║║║║ ║ ╠═╣║ ║
// ╚═╝╩ ═╩╝╩ ╩ ╩ ╚═╝ ╚═╝╩ ╚═╩╚═╝ ╩ ╩╝╚╝╚═╝ ╚═╝╚═╝╝╚╝ ╩ ╩ ╩╚═╝ ╩
if(existingContactRecord) {
// If a description was provided and the contact has a description, append the new description to it.
if(description && existingContactRecord.Description) {
valuesToSet.Description = existingContactRecord.Description + '\n' + description;
}
// If we're updating a contact, add psychologicalStage and psychologicalStageChangeReason to the dictionary of valuesToSet.
if(psychologicalStage) {
valuesToSet.Stage__c = psychologicalStage;// eslint-disable-line camelcase
}
if(psychologicalStageChangeReason) {
valuesToSet.Psystage_change_reason__c = psychologicalStageChangeReason;// eslint-disable-line camelcase
}
// If an intent signal was specified, add it to the list of intent signals on the exisitng contact.
// Note: intent signals values are stored as a single string in salesforce, separated by a semicolon.
if(intentSignal && existingContactRecord.Intent_signals__c) {
// Convert the string from the Salesforce record into an array.
let existingContactIntentSignalsAsAnArray = existingContactRecord.Intent_signals__c.split(';');
// If this intent signal is not included in the exisitng contacts intent signals, add it.
if(!existingContactIntentSignalsAsAnArray.includes(intentSignal)) {
existingContactIntentSignalsAsAnArray.push(intentSignal);
// Convert the array back into a string to send it to Salesforce.
valuesToSet.Intent_signals__c = existingContactIntentSignalsAsAnArray.join(';');// eslint-disable-line camelcase
} else {
// Otherwise, if the existing contact already has this intent signal tracked, remove it from the valuesToSet
delete valuesToSet.Intent_signals__c;
}
}
// Check the existing contact record's psychologicalStage (If it is set).
if(psychologicalStage && existingContactRecord.Stage__c !== null) {
let recordsCurrentPsyStage = existingContactRecord.Stage__c;
// Because each psychological stage starts with a number, we'll get the first character in the record's current psychological stage and the new psychological stage to make comparison easier.
let psyStageStageNumberToChangeTo = Number(psychologicalStage[0]);
let recordsCurrentPsyStageNumber = Number(recordsCurrentPsyStage[0]);
if(psyStageStageNumberToChangeTo < recordsCurrentPsyStageNumber) {
// If a psychological stage regression is caused by anything other than the start flow, remove the updated value.
// This is done to prevent automated psyStage regressions caused by users taking other action on the website. (e.g, Booking a meeting or requesting Fleet swag.)
if(psychologicalStageChangeReason && psychologicalStageChangeReason !== 'Website - Organic start flow') {
delete valuesToSet.Stage__c;
delete valuesToSet.Psystage_change_reason__c;
}
}
}
// console.log(`Exisitng contact found! ${existingContactRecord.Id}`);
// If we found an existing contact, we'll update it with the information provided.
salesforceContactId = existingContactRecord.Id;
await salesforceConnection.sobject('Contact')
.update({
Id: salesforceContactId,
...valuesToSet,
});
salesforceAccountId = existingContactRecord.AccountId;
// console.log(`${salesforceContactId} updated!`);
}
return {
salesforceAccountId,
salesforceContactId
};
}
};