mautic / plugins /MauticCrmBundle /Api /SalesforceApi.php
chrisbryan17's picture
Upload folder using huggingface_hub
d2897cd verified
<?php
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;
}
}