Spaces:
No application file
No application file
namespace MauticPlugin\MauticCrmBundle\Api; | |
use Mautic\PluginBundle\Exception\ApiErrorException; | |
use MauticPlugin\MauticCrmBundle\Api\Salesforce\Exception\RetryRequestException; | |
use MauticPlugin\MauticCrmBundle\Api\Salesforce\Helper\RequestUrl; | |
use MauticPlugin\MauticCrmBundle\Integration\CrmAbstractIntegration; | |
use MauticPlugin\MauticCrmBundle\Integration\SalesforceIntegration; | |
/** | |
* @property SalesforceIntegration $integration | |
*/ | |
class SalesforceApi extends CrmApi | |
{ | |
protected $object = 'Lead'; | |
protected $requestSettings = [ | |
'encode_parameters' => 'json', | |
]; | |
protected $apiRequestCounter = 0; | |
protected $requestCounter = 1; | |
protected $maxLockRetries = 3; | |
private bool $optOutFieldAccessible = true; | |
public function __construct(CrmAbstractIntegration $integration) | |
{ | |
parent::__construct($integration); | |
$this->requestSettings['curl_options'] = [ | |
CURLOPT_SSLVERSION => defined('CURL_SSLVERSION_TLSv1_2') ? CURL_SSLVERSION_TLSv1_2 : 6, | |
]; | |
} | |
/** | |
* @param array $elementData | |
* @param string $method | |
* @param bool $isRetry | |
* | |
* @return mixed|string | |
* | |
* @throws ApiErrorException | |
*/ | |
public function request($operation, $elementData = [], $method = 'GET', $isRetry = false, $object = null, $queryUrl = null) | |
{ | |
if (!$object) { | |
$object = $this->object; | |
} | |
$requestUrl = RequestUrl::get($this->integration->getApiUrl(), $queryUrl, $operation, $object); | |
$settings = $this->requestSettings; | |
if ('PATCH' == $method) { | |
$settings['headers'] = ['Sforce-Auto-Assign' => 'FALSE']; | |
} | |
// Query commands can have long wait time while SF builds response as the offset increases | |
$settings['request_timeout'] = 300; | |
// Wrap in a isAuthorized to refresh token if applicable | |
$response = $this->integration->makeRequest($requestUrl, $elementData, $method, $settings); | |
++$this->apiRequestCounter; | |
try { | |
$this->analyzeResponse($response, $isRetry); | |
} catch (RetryRequestException) { | |
return $this->request($operation, $elementData, $method, true, $object, $queryUrl); | |
} | |
return $response; | |
} | |
/** | |
* @return mixed|string | |
* | |
* @throws ApiErrorException | |
*/ | |
public function getLeadFields($object = null) | |
{ | |
if ('company' == $object) { | |
$object = 'Account'; // salesforce object name | |
} | |
return $this->request('describe', [], 'GET', false, $object); | |
} | |
/** | |
* @throws ApiErrorException | |
*/ | |
public function getPerson(array $data): array | |
{ | |
$config = $this->integration->mergeConfigToFeatureSettings([]); | |
$queryUrl = $this->integration->getQueryUrl(); | |
$sfRecords = [ | |
'Contact' => [], | |
'Lead' => [], | |
]; | |
// try searching for lead as this has been changed before in updated done to the plugin | |
if (isset($config['objects']) && false !== array_search('Contact', $config['objects']) && !empty($data['Contact']['Email'])) { | |
$fields = $this->integration->getFieldsForQuery('Contact'); | |
unset($fields[array_search('HasOptedOutOfEmail', $fields)]); | |
$fields[] = 'Id'; | |
$fields = implode(', ', array_unique($fields)); | |
$findContact = 'select '.$fields.' from Contact where email = \''.$this->escapeQueryValue($data['Contact']['Email']).'\''; | |
$response = $this->request('query', ['q' => $findContact], 'GET', false, null, $queryUrl); | |
if (!empty($response['records'])) { | |
$sfRecords['Contact'] = $response['records']; | |
} | |
} | |
if (!empty($data['Lead']['Email'])) { | |
$fields = $this->integration->getFieldsForQuery('Lead'); | |
unset($fields[array_search('HasOptedOutOfEmail', $fields)]); | |
$fields[] = 'Id'; | |
$fields = implode(', ', array_unique($fields)); | |
$findLead = 'select '.$fields.' from Lead where email = \''.$this->escapeQueryValue($data['Lead']['Email']).'\' and ConvertedContactId = NULL'; | |
$response = $this->request('queryAll', ['q' => $findLead], 'GET', false, null, $queryUrl); | |
if (!empty($response['records'])) { | |
$sfRecords['Lead'] = $response['records']; | |
} | |
} | |
return $sfRecords; | |
} | |
/** | |
* @throws ApiErrorException | |
*/ | |
public function getCompany(array $data): array | |
{ | |
$config = $this->integration->mergeConfigToFeatureSettings([]); | |
$queryUrl = $this->integration->getQueryUrl(); | |
$sfRecords = [ | |
'Account' => [], | |
]; | |
$appendToQuery = ''; | |
// try searching for lead as this has been changed before in updated done to the plugin | |
if (isset($config['objects']) && false !== array_search('company', $config['objects']) && !empty($data['company']['Name'])) { | |
$fields = $this->integration->getFieldsForQuery('Account'); | |
if (!empty($data['company']['BillingCountry'])) { | |
$appendToQuery .= ' and BillingCountry = \''.$this->escapeQueryValue($data['company']['BillingCountry']).'\''; | |
} | |
if (!empty($data['company']['BillingCity'])) { | |
$appendToQuery .= ' and BillingCity = \''.$this->escapeQueryValue($data['company']['BillingCity']).'\''; | |
} | |
if (!empty($data['company']['BillingState'])) { | |
$appendToQuery .= ' and BillingState = \''.$this->escapeQueryValue($data['company']['BillingState']).'\''; | |
} | |
$fields[] = 'Id'; | |
$fields = implode(', ', array_unique($fields)); | |
$query = 'select '.$fields.' from Account where Name = \''.$this->escapeQueryValue($data['company']['Name']).'\''.$appendToQuery; | |
$response = $this->request('queryAll', ['q' => $query], 'GET', false, null, $queryUrl); | |
if (!empty($response['records'])) { | |
$sfRecords['company'] = $response['records']; | |
} | |
} | |
return $sfRecords; | |
} | |
/** | |
* @return array|mixed|string | |
* | |
* @throws ApiErrorException | |
*/ | |
public function createLead(array $data) | |
{ | |
$createdLeadData = []; | |
if (isset($data['Email'])) { | |
$createdLeadData = $this->createObject($data, 'Lead'); | |
} | |
return $createdLeadData; | |
} | |
/** | |
* @return mixed|string | |
* | |
* @throws ApiErrorException | |
*/ | |
public function createObject(array $data, $sfObject) | |
{ | |
$objectData = $this->request('', $data, 'POST', false, $sfObject); | |
$this->integration->getLogger()->debug('SALESFORCE: POST createObject '.$sfObject.' '.var_export($data, true).var_export($objectData, true)); | |
if (isset($objectData['id'])) { | |
// Salesforce is inconsistent it seems | |
$objectData['Id'] = $objectData['id']; | |
} | |
return $objectData; | |
} | |
/** | |
* @return mixed|string | |
* | |
* @throws ApiErrorException | |
*/ | |
public function updateObject(array $data, $sfObject, $sfObjectId) | |
{ | |
$objectData = $this->request('', $data, 'PATCH', false, $sfObject.'/'.$sfObjectId); | |
$this->integration->getLogger()->debug('SALESFORCE: PATCH updateObject '.$sfObject.' '.var_export($data, true).var_export($objectData, true)); | |
// Salesforce is inconsistent it seems | |
$objectData['Id'] = $objectData['id'] = $sfObjectId; | |
return $objectData; | |
} | |
/** | |
* @return mixed|string | |
* | |
* @throws ApiErrorException | |
*/ | |
public function syncMauticToSalesforce(array $data) | |
{ | |
$queryUrl = $this->integration->getCompositeUrl(); | |
return $this->request('composite/', $data, 'POST', false, null, $queryUrl); | |
} | |
/** | |
* @return array<mixed> | |
* | |
* @throws ApiErrorException | |
*/ | |
public function createLeadActivity(array $activity, $object): array | |
{ | |
$config = $this->integration->getIntegrationSettings()->getFeatureSettings(); | |
$namespace = (!empty($config['namespace'])) ? $config['namespace'].'__' : ''; | |
$mActivityObjectName = $namespace.'mautic_timeline__c'; | |
$activityData = []; | |
if (!empty($activity)) { | |
foreach ($activity as $sfId => $records) { | |
foreach ($records['records'] as $record) { | |
$body = [ | |
$namespace.'ActivityDate__c' => $record['dateAdded']->format('c'), | |
$namespace.'Description__c' => $record['description'], | |
'Name' => substr($record['name'], 0, 80), | |
$namespace.'Mautic_url__c' => $records['leadUrl'], | |
$namespace.'ReferenceId__c' => $record['id'].'-'.$sfId, | |
]; | |
if ('Lead' === $object) { | |
$body[$namespace.'WhoId__c'] = $sfId; | |
} elseif ('Contact' === $object) { | |
$body[$namespace.'contact_id__c'] = $sfId; | |
} | |
$activityData[] = [ | |
'method' => 'POST', | |
'url' => '/services/data/v38.0/sobjects/'.$mActivityObjectName, | |
'referenceId' => $record['id'].'-'.$sfId, | |
'body' => $body, | |
]; | |
} | |
} | |
if (!empty($activityData)) { | |
$request = []; | |
$request['allOrNone'] = 'false'; | |
$chunked = array_chunk($activityData, 25); | |
$results = []; | |
foreach ($chunked as $chunk) { | |
// We can only submit 25 at a time | |
if ($chunk) { | |
$request['compositeRequest'] = $chunk; | |
$result = $this->syncMauticToSalesforce($request); | |
$results[] = $result; | |
$this->integration->getLogger()->debug('SALESFORCE: Activity response '.var_export($result, true)); | |
} | |
} | |
return $results; | |
} | |
} | |
return []; | |
} | |
/** | |
* Get Salesforce leads. | |
* | |
* @param mixed $query String for a SOQL query or array to build query | |
* @param string $object | |
* | |
* @return mixed|string | |
* | |
* @throws ApiErrorException | |
*/ | |
public function getLeads($query, $object) | |
{ | |
$queryUrl = $this->integration->getQueryUrl(); | |
if (defined('MAUTIC_ENV') && MAUTIC_ENV === 'dev') { | |
// Easier for testing | |
$this->requestSettings['headers']['Sforce-Query-Options'] = 'batchSize=200'; | |
} | |
if (!is_array($query)) { | |
return $this->request('queryAll', ['q' => $query], 'GET', false, null, $queryUrl); | |
} | |
if (!empty($query['nextUrl'])) { | |
return $this->request(null, [], 'GET', false, null, $query['nextUrl']); | |
} | |
$organizationCreatedDate = $this->getOrganizationCreatedDate(); | |
$fields = $this->integration->getFieldsForQuery($object); | |
if (!empty($fields) && isset($query['start'])) { | |
if (strtotime($query['start']) < strtotime($organizationCreatedDate)) { | |
$query['start'] = date('c', strtotime($organizationCreatedDate.' +1 hour')); | |
} | |
$fields[] = 'Id'; | |
return $this->requestQueryAllAndHandle($queryUrl, $fields, $object, $query); | |
} | |
return [ | |
'totalSize' => 0, | |
'records' => [], | |
]; | |
} | |
/** | |
* Perform queryAll request and retry if HasOptedOutOfEmail is not accessible. | |
* | |
* @param array<mixed> $fields | |
* @param array<mixed> $query | |
* | |
* @return mixed|string | |
* | |
* @throws ApiErrorException | |
*/ | |
private function requestQueryAllAndHandle(string $queryUrl, array $fields, string $object, array $query) | |
{ | |
$config = $this->integration->mergeConfigToFeatureSettings([]); | |
if (isset($config['updateOwner']) && isset($config['updateOwner'][0]) && 'updateOwner' == $config['updateOwner'][0]) { | |
$fields[] = 'Owner.Name'; | |
$fields[] = 'Owner.Email'; | |
} | |
$fields = array_unique($fields); | |
$ignoreConvertedLeads = ('Lead' == $object) ? ' and ConvertedContactId = NULL' : ''; | |
if (!$this->isOptOutFieldAccessible()) { // If not opt-out is supported; unset it | |
unset($fields[array_search('HasOptedOutOfEmail', $fields)]); | |
} | |
$baseQuery = 'SELECT %s from '.$object.' where SystemModStamp>='.$query['start'].' and SystemModStamp<='.$query['end'].' and isDeleted = false' | |
.$ignoreConvertedLeads; | |
try { | |
$leadsQuery = sprintf($baseQuery, join(', ', $fields)); | |
$response = $this->request('queryAll', ['q' => $leadsQuery], 'GET', false, null, $queryUrl); | |
} catch (ApiErrorException $e) { | |
if (!preg_match("/No such column 'HasOptedOutOfEmail' on entity '([^']+)'/", $e->getMessage(), $matches)) { | |
throw $e; | |
} | |
// Unset field as it is not accessible | |
unset($fields[array_search('HasOptedOutOfEmail', $fields)]); | |
// Disable the use of the HasOptedOutOfEmail field for future requests | |
$this->setOptOutFieldAccessible(false); | |
// Notify all admins of this error | |
$this->integration->upsertUnreadAdminsNotification( | |
$this->integration->getTranslator()->trans('mautic.salesforce.error.opt-out_permission.header'), | |
$this->integration->getTranslator()->trans('mautic.salesforce.error.opt-out_permission.message') | |
); | |
$leadsQuery = sprintf($baseQuery, join(', ', $fields)); | |
$response = $this->request('queryAll', ['q' => $leadsQuery], 'GET', true, null, $queryUrl); | |
} | |
return $response; | |
} | |
/** | |
* @return bool|mixed | |
* | |
* @throws ApiErrorException | |
*/ | |
public function getOrganizationCreatedDate() | |
{ | |
$cache = $this->integration->getCache(); | |
if (!$organizationCreatedDate = $cache->get('organization.created_date')) { | |
$queryUrl = $this->integration->getQueryUrl(); | |
$organization = $this->request('query', ['q' => 'SELECT CreatedDate from Organization'], 'GET', false, null, $queryUrl); | |
$organizationCreatedDate = $organization['records'][0]['CreatedDate']; | |
$cache->set('organization.created_date', $organizationCreatedDate); | |
} | |
return $organizationCreatedDate; | |
} | |
/** | |
* @return mixed|string | |
* | |
* @throws ApiErrorException | |
*/ | |
public function getCampaigns() | |
{ | |
$campaignQuery = 'Select Id, Name from Campaign where isDeleted = false'; | |
$queryUrl = $this->integration->getQueryUrl(); | |
return $this->request('query', ['q' => $campaignQuery], 'GET', false, null, $queryUrl); | |
} | |
/** | |
* @param mixed $modifiedSince | |
* | |
* @return mixed|string | |
* | |
* @throws ApiErrorException | |
*/ | |
public function getCampaignMembers($campaignId, $modifiedSince = null, $queryUrl = null) | |
{ | |
$defaultSettings = $this->requestSettings; | |
// Control batch size to prevent URL too long errors when fetching contact details via SOQL and to control Doctrine RAM usage for | |
// Mautic IntegrationEntity objects | |
$this->requestSettings['headers']['Sforce-Query-Options'] = 'batchSize=200'; | |
if (null === $queryUrl) { | |
$queryUrl = $this->integration->getQueryUrl().'/query'; | |
} | |
$query = "Select CampaignId, ContactId, LeadId, isDeleted from CampaignMember where CampaignId = '".trim($campaignId)."'"; | |
if ($modifiedSince) { | |
$query .= ' and SystemModStamp >= '.$modifiedSince; | |
} | |
$results = $this->request(null, ['q' => $query], 'GET', false, null, $queryUrl); | |
$this->requestSettings = $defaultSettings; | |
return $results; | |
} | |
/** | |
* @throws ApiErrorException | |
*/ | |
public function checkCampaignMembership($campaignId, $object, array $people): array | |
{ | |
$campaignMembers = []; | |
if (!empty($people)) { | |
$idField = "{$object}Id"; | |
$query = "Select Id, $idField from CampaignMember where CampaignId = '".$campaignId | |
."' and $idField in ('".implode("','", $people)."')"; | |
$foundCampaignMembers = $this->request('query', ['q' => $query], 'GET', false, null, $this->integration->getQueryUrl()); | |
if (!empty($foundCampaignMembers['records'])) { | |
foreach ($foundCampaignMembers['records'] as $member) { | |
$campaignMembers[$member[$idField]] = $member['Id']; | |
} | |
} | |
} | |
return $campaignMembers; | |
} | |
/** | |
* @return mixed|string | |
* | |
* @throws ApiErrorException | |
*/ | |
public function getCampaignMemberStatus($campaignId) | |
{ | |
$campaignQuery = "Select Id, Label from CampaignMemberStatus where isDeleted = false and CampaignId='".$campaignId."'"; | |
$queryUrl = $this->integration->getQueryUrl(); | |
return $this->request('query', ['q' => $campaignQuery], 'GET', false, null, $queryUrl); | |
} | |
/** | |
* @return int | |
*/ | |
public function getRequestCounter() | |
{ | |
$count = $this->apiRequestCounter; | |
$this->apiRequestCounter = 0; | |
return $count; | |
} | |
/** | |
* @return mixed|string | |
* | |
* @throws ApiErrorException | |
*/ | |
public function getCompaniesByName(array $names, $requiredFieldString) | |
{ | |
$names = array_map([$this, 'escapeQueryValue'], $names); | |
$queryUrl = $this->integration->getQueryUrl(); | |
$findQuery = 'select Id, '.$requiredFieldString.' from Account where isDeleted = false and Name in (\''.implode("','", $names).'\')'; | |
return $this->request('query', ['q' => $findQuery], 'GET', false, null, $queryUrl); | |
} | |
/** | |
* @return mixed|string | |
* | |
* @throws ApiErrorException | |
*/ | |
public function getCompaniesById(array $ids, $requiredFieldString) | |
{ | |
$findQuery = 'select isDeleted, Id, '.$requiredFieldString.' from Account where Id in (\''.implode("','", $ids).'\')'; | |
$queryUrl = $this->integration->getQueryUrl(); | |
return $this->request('queryAll', ['q' => $findQuery], 'GET', false, null, $queryUrl); | |
} | |
/** | |
* @param mixed $response | |
* @param bool $isRetry | |
* | |
* @throws ApiErrorException | |
* @throws RetryRequestException | |
*/ | |
private function analyzeResponse($response, $isRetry): void | |
{ | |
if (is_array($response)) { | |
if (!empty($response['errors'])) { | |
throw new ApiErrorException(implode(', ', $response['errors'])); | |
} | |
foreach ($response as $lineItem) { | |
if (!is_array($lineItem)) { | |
continue; | |
} | |
$lineItemForInvalidSession = $lineItem; | |
$lineItemForInvalidSession['errorCode'] = 'INVALID_SESSION_ID'; | |
if (!empty($lineItemForInvalidSession['message']) && str_contains($lineItemForInvalidSession['message'], '"errorCode":"INVALID_SESSION_ID"') && $error = $this->processError($lineItemForInvalidSession, $isRetry)) { | |
$errors[] = $error; | |
continue; | |
} | |
if (!empty($lineItem['errorCode']) && $error = $this->processError($lineItem, $isRetry)) { | |
$errors[] = $error; | |
} | |
} | |
if (!empty($errors)) { | |
throw new ApiErrorException(implode(', ', $errors)); | |
} | |
} | |
} | |
/** | |
* @return string|false | |
* | |
* @throws ApiErrorException | |
* @throws RetryRequestException | |
*/ | |
private function processError(array $error, $isRetry) | |
{ | |
switch ($error['errorCode']) { | |
case 'INVALID_SESSION_ID': | |
$this->revalidateSession($isRetry); | |
break; | |
case 'UNABLE_TO_LOCK_ROW': | |
$this->checkIfLockedRequestShouldBeRetried(); | |
break; | |
} | |
if (!empty($error['message'])) { | |
return $error['message']; | |
} | |
return false; | |
} | |
/** | |
* @throws ApiErrorException | |
* @throws RetryRequestException | |
*/ | |
private function revalidateSession($isRetry): void | |
{ | |
if ($refreshError = $this->integration->authCallback(['use_refresh_token' => true])) { | |
throw new ApiErrorException($refreshError); | |
} | |
if (!$isRetry) { | |
throw new RetryRequestException(); | |
} | |
} | |
/** | |
* @throws RetryRequestException | |
*/ | |
private function checkIfLockedRequestShouldBeRetried(): bool | |
{ | |
// The record is locked so let's wait a a few seconds and retry | |
if ($this->requestCounter < $this->maxLockRetries) { | |
sleep($this->requestCounter * 3); | |
++$this->requestCounter; | |
throw new RetryRequestException(); | |
} | |
$this->requestCounter = 1; | |
return false; | |
} | |
/** | |
* @return bool|float|mixed|string | |
*/ | |
private function escapeQueryValue($value) | |
{ | |
// SF uses backslashes as escape delimeter | |
// Remember that PHP uses \ as an escape. Therefore, to replace a single backslash with 2, must use 2 and 4 | |
$value = str_replace('\\', '\\\\', $value); | |
// Apply general formatting/cleanup | |
$value = $this->integration->cleanPushData($value); | |
// Escape single quotes | |
$value = str_replace("'", "\'", $value); | |
return $value; | |
} | |
public function isOptOutFieldAccessible(): bool | |
{ | |
return $this->optOutFieldAccessible; | |
} | |
public function setOptOutFieldAccessible(bool $optOutFieldAccessible): SalesforceApi | |
{ | |
$this->optOutFieldAccessible = $optOutFieldAccessible; | |
return $this; | |
} | |
} | |