Issues (3627)

plugins/MauticCrmBundle/Api/SalesforceApi.php (1 issue)

1
<?php
2
3
namespace MauticPlugin\MauticCrmBundle\Api;
4
5
use Mautic\PluginBundle\Exception\ApiErrorException;
6
use MauticPlugin\MauticCrmBundle\Api\Salesforce\Exception\RetryRequestException;
7
use MauticPlugin\MauticCrmBundle\Api\Salesforce\Helper\RequestUrl;
8
use MauticPlugin\MauticCrmBundle\Integration\CrmAbstractIntegration;
9
use MauticPlugin\MauticCrmBundle\Integration\SalesforceIntegration;
10
11
/**
12
 * @property SalesforceIntegration $integration
13
 */
14
class SalesforceApi extends CrmApi
15
{
16
    protected $object          = 'Lead';
17
    protected $requestSettings = [
18
        'encode_parameters' => 'json',
19
    ];
20
    protected $apiRequestCounter   = 0;
21
    protected $requestCounter      = 1;
22
    protected $maxLockRetries      = 3;
23
24
    public function __construct(CrmAbstractIntegration $integration)
25
    {
26
        parent::__construct($integration);
27
28
        $this->requestSettings['curl_options'] = [
29
            CURLOPT_SSLVERSION => defined('CURL_SSLVERSION_TLSv1_2') ? CURL_SSLVERSION_TLSv1_2 : 6,
30
        ];
31
    }
32
33
    /**
34
     * @param        $operation
35
     * @param array  $elementData
36
     * @param string $method
37
     * @param bool   $isRetry
38
     * @param null   $object
39
     * @param null   $queryUrl
40
     *
41
     * @return mixed|string
42
     *
43
     * @throws ApiErrorException
44
     */
45
    public function request($operation, $elementData = [], $method = 'GET', $isRetry = false, $object = null, $queryUrl = null)
46
    {
47
        if (!$object) {
48
            $object = $this->object;
49
        }
50
51
        $requestUrl = RequestUrl::get($this->integration->getApiUrl(), $queryUrl, $operation, $object);
52
53
        $settings   = $this->requestSettings;
54
        if ('PATCH' == $method) {
55
            $settings['headers'] = ['Sforce-Auto-Assign' => 'FALSE'];
56
        }
57
58
        // Query commands can have long wait time while SF builds response as the offset increases
59
        $settings['request_timeout'] = 300;
60
61
        // Wrap in a isAuthorized to refresh token if applicable
62
        $response = $this->integration->makeRequest($requestUrl, $elementData, $method, $settings);
63
        ++$this->apiRequestCounter;
64
65
        try {
66
            $this->analyzeResponse($response, $isRetry);
67
        } catch (RetryRequestException $exception) {
68
            return $this->request($operation, $elementData, $method, true, $object, $queryUrl);
69
        }
70
71
        return $response;
72
    }
73
74
    /**
75
     * @param null $object
76
     *
77
     * @return mixed|string
78
     *
79
     * @throws ApiErrorException
80
     */
81
    public function getLeadFields($object = null)
82
    {
83
        if ('company' == $object) {
84
            $object = 'Account'; //salesforce object name
85
        }
86
87
        return $this->request('describe', [], 'GET', false, $object);
88
    }
89
90
    /**
91
     * @return array
92
     *
93
     * @throws ApiErrorException
94
     */
95
    public function getPerson(array $data)
96
    {
97
        $config    = $this->integration->mergeConfigToFeatureSettings([]);
98
        $queryUrl  = $this->integration->getQueryUrl();
99
        $sfRecords = [
100
            'Contact' => [],
101
            'Lead'    => [],
102
        ];
103
104
        //try searching for lead as this has been changed before in updated done to the plugin
105
        if (isset($config['objects']) && false !== array_search('Contact', $config['objects']) && !empty($data['Contact']['Email'])) {
106
            $fields      = $this->integration->getFieldsForQuery('Contact');
107
            $fields[]    = 'Id';
108
            $fields      = implode(', ', array_unique($fields));
109
            $findContact = 'select '.$fields.' from Contact where email = \''.$this->escapeQueryValue($data['Contact']['Email']).'\'';
110
            $response    = $this->request('query', ['q' => $findContact], 'GET', false, null, $queryUrl);
111
112
            if (!empty($response['records'])) {
113
                $sfRecords['Contact'] = $response['records'];
114
            }
115
        }
116
117
        if (!empty($data['Lead']['Email'])) {
118
            $fields   = $this->integration->getFieldsForQuery('Lead');
119
            $fields[] = 'Id';
120
            $fields   = implode(', ', array_unique($fields));
121
            $findLead = 'select '.$fields.' from Lead where email = \''.$this->escapeQueryValue($data['Lead']['Email']).'\' and ConvertedContactId = NULL';
122
            $response = $this->request('queryAll', ['q' => $findLead], 'GET', false, null, $queryUrl);
123
124
            if (!empty($response['records'])) {
125
                $sfRecords['Lead'] = $response['records'];
126
            }
127
        }
128
129
        return $sfRecords;
130
    }
131
132
    /**
133
     * @return array
134
     *
135
     * @throws ApiErrorException
136
     */
137
    public function getCompany(array $data)
138
    {
139
        $config    = $this->integration->mergeConfigToFeatureSettings([]);
140
        $queryUrl  = $this->integration->getQueryUrl();
141
        $sfRecords = [
142
            'Account' => [],
143
        ];
144
145
        $appendToQuery = '';
146
147
        //try searching for lead as this has been changed before in updated done to the plugin
148
        if (isset($config['objects']) && false !== array_search('company', $config['objects']) && !empty($data['company']['Name'])) {
149
            $fields = $this->integration->getFieldsForQuery('Account');
150
151
            if (!empty($data['company']['BillingCountry'])) {
152
                $appendToQuery .= ' and BillingCountry =  \''.$this->escapeQueryValue($data['company']['BillingCountry']).'\'';
153
            }
154
            if (!empty($data['company']['BillingCity'])) {
155
                $appendToQuery .= ' and BillingCity =  \''.$this->escapeQueryValue($data['company']['BillingCity']).'\'';
156
            }
157
            if (!empty($data['company']['BillingState'])) {
158
                $appendToQuery .= ' and BillingState =  \''.$this->escapeQueryValue($data['company']['BillingState']).'\'';
159
            }
160
161
            $fields[] = 'Id';
162
            $fields   = implode(', ', array_unique($fields));
163
            $query    = 'select '.$fields.' from Account where Name = \''.$this->escapeQueryValue($data['company']['Name']).'\''.$appendToQuery;
164
            $response = $this->request('queryAll', ['q' => $query], 'GET', false, null, $queryUrl);
165
166
            if (!empty($response['records'])) {
167
                $sfRecords['company'] = $response['records'];
168
            }
169
        }
170
171
        return $sfRecords;
172
    }
173
174
    /**
175
     * @return array|mixed|string
176
     *
177
     * @throws ApiErrorException
178
     */
179
    public function createLead(array $data)
180
    {
181
        $createdLeadData = [];
182
183
        if (isset($data['Email'])) {
184
            $createdLeadData = $this->createObject($data, 'Lead');
185
        }
186
187
        return $createdLeadData;
188
    }
189
190
    /**
191
     * @param $sfObject
192
     *
193
     * @return mixed|string
194
     *
195
     * @throws ApiErrorException
196
     */
197
    public function createObject(array $data, $sfObject)
198
    {
199
        $objectData = $this->request('', $data, 'POST', false, $sfObject);
200
        $this->integration->getLogger()->debug('SALESFORCE: POST createObject '.$sfObject.' '.var_export($data, true).var_export($objectData, true));
201
202
        if (isset($objectData['id'])) {
203
            // Salesforce is inconsistent it seems
204
            $objectData['Id'] = $objectData['id'];
205
        }
206
207
        return $objectData;
208
    }
209
210
    /**
211
     * @param $sfObject
212
     * @param $sfObjectId
213
     *
214
     * @return mixed|string
215
     *
216
     * @throws ApiErrorException
217
     */
218
    public function updateObject(array $data, $sfObject, $sfObjectId)
219
    {
220
        $objectData = $this->request('', $data, 'PATCH', false, $sfObject.'/'.$sfObjectId);
221
        $this->integration->getLogger()->debug('SALESFORCE: PATCH updateObject '.$sfObject.' '.var_export($data, true).var_export($objectData, true));
222
223
        // Salesforce is inconsistent it seems
224
        $objectData['Id'] = $objectData['id'] = $sfObjectId;
225
226
        return $objectData;
227
    }
228
229
    /**
230
     * @return mixed|string
231
     *
232
     * @throws ApiErrorException
233
     */
234
    public function syncMauticToSalesforce(array $data)
235
    {
236
        $queryUrl = $this->integration->getCompositeUrl();
237
238
        return $this->request('composite/', $data, 'POST', false, null, $queryUrl);
239
    }
240
241
    /**
242
     * @param $object
243
     *
244
     * @return array
245
     *
246
     * @throws ApiErrorException
247
     */
248
    public function createLeadActivity(array $activity, $object)
249
    {
250
        $config              = $this->integration->getIntegrationSettings()->getFeatureSettings();
251
        $namespace           = (!empty($config['namespace'])) ? $config['namespace'].'__' : '';
252
        $mActivityObjectName = $namespace.'mautic_timeline__c';
253
        $activityData        = [];
254
255
        if (!empty($activity)) {
256
            foreach ($activity as $sfId => $records) {
257
                foreach ($records['records'] as $record) {
258
                    $body = [
259
                        $namespace.'ActivityDate__c' => $record['dateAdded']->format('c'),
260
                        $namespace.'Description__c'  => $record['description'],
261
                        'Name'                       => substr($record['name'], 0, 80),
262
                        $namespace.'Mautic_url__c'   => $records['leadUrl'],
263
                        $namespace.'ReferenceId__c'  => $record['id'].'-'.$sfId,
264
                    ];
265
266
                    if ('Lead' === $object) {
267
                        $body[$namespace.'WhoId__c'] = $sfId;
268
                    } elseif ('Contact' === $object) {
269
                        $body[$namespace.'contact_id__c'] = $sfId;
270
                    }
271
272
                    $activityData[] = [
273
                        'method'      => 'POST',
274
                        'url'         => '/services/data/v38.0/sobjects/'.$mActivityObjectName,
275
                        'referenceId' => $record['id'].'-'.$sfId,
276
                        'body'        => $body,
277
                    ];
278
                }
279
            }
280
281
            if (!empty($activityData)) {
282
                $request              = [];
283
                $request['allOrNone'] = 'false';
284
                $chunked              = array_chunk($activityData, 25);
285
                $results              = [];
286
                foreach ($chunked as $chunk) {
287
                    // We can only submit 25 at a time
288
                    if ($chunk) {
289
                        $request['compositeRequest'] = $chunk;
290
                        $result                      = $this->syncMauticToSalesforce($request);
291
                        $results[]                   = $result;
292
                        $this->integration->getLogger()->debug('SALESFORCE: Activity response '.var_export($result, true));
293
                    }
294
                }
295
296
                return $results;
297
            }
298
299
            return [];
300
        }
301
    }
302
303
    /**
304
     * Get Salesforce leads.
305
     *
306
     * @param mixed  $query  String for a SOQL query or array to build query
307
     * @param string $object
308
     *
309
     * @return mixed|string
310
     *
311
     * @throws ApiErrorException
312
     */
313
    public function getLeads($query, $object)
314
    {
315
        $queryUrl = $this->integration->getQueryUrl();
316
317
        if (defined('MAUTIC_ENV') && MAUTIC_ENV === 'dev') {
318
            // Easier for testing
319
            $this->requestSettings['headers']['Sforce-Query-Options'] = 'batchSize=200';
320
        }
321
322
        if (!is_array($query)) {
323
            return $this->request('queryAll', ['q' => $query], 'GET', false, null, $queryUrl);
324
        }
325
326
        if (!empty($query['nextUrl'])) {
327
            return $this->request(null, [], 'GET', false, null, $query['nextUrl']);
328
        }
329
330
        $organizationCreatedDate = $this->getOrganizationCreatedDate();
331
        $fields                  = $this->integration->getFieldsForQuery($object);
332
        if (!empty($fields) && isset($query['start'])) {
333
            if (strtotime($query['start']) < strtotime($organizationCreatedDate)) {
0 ignored issues
show
It seems like $organizationCreatedDate can also be of type boolean; however, parameter $datetime of strtotime() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

333
            if (strtotime($query['start']) < strtotime(/** @scrutinizer ignore-type */ $organizationCreatedDate)) {
Loading history...
334
                $query['start'] = date('c', strtotime($organizationCreatedDate.' +1 hour'));
335
            }
336
337
            $fields[] = 'Id';
338
            $fields   = implode(', ', array_unique($fields));
339
340
            $config = $this->integration->mergeConfigToFeatureSettings([]);
341
            if (isset($config['updateOwner']) && isset($config['updateOwner'][0]) && 'updateOwner' == $config['updateOwner'][0]) {
342
                $fields = 'Owner.Name, Owner.Email, '.$fields;
343
            }
344
345
            $ignoreConvertedLeads = ('Lead' == $object) ? ' and ConvertedContactId = NULL' : '';
346
347
            $getLeadsQuery = 'SELECT '.$fields.' from '.$object.' where SystemModStamp>='.$query['start'].' and SystemModStamp<='.$query['end']
348
                .$ignoreConvertedLeads;
349
350
            return $this->request('queryAll', ['q' => $getLeadsQuery], 'GET', false, null, $queryUrl);
351
        }
352
353
        return [
354
            'totalSize' => 0,
355
            'records'   => [],
356
        ];
357
    }
358
359
    /**
360
     * @return bool|mixed
361
     *
362
     * @throws ApiErrorException
363
     */
364
    public function getOrganizationCreatedDate()
365
    {
366
        $cache = $this->integration->getCache();
367
368
        if (!$organizationCreatedDate = $cache->get('organization.created_date')) {
369
            $queryUrl                = $this->integration->getQueryUrl();
370
            $organization            = $this->request('query', ['q' => 'SELECT CreatedDate from Organization'], 'GET', false, null, $queryUrl);
371
            $organizationCreatedDate = $organization['records'][0]['CreatedDate'];
372
            $cache->set('organization.created_date', $organizationCreatedDate);
373
        }
374
375
        return $organizationCreatedDate;
376
    }
377
378
    /**
379
     * @return mixed|string
380
     *
381
     * @throws ApiErrorException
382
     */
383
    public function getCampaigns()
384
    {
385
        $campaignQuery = 'Select Id, Name from Campaign where isDeleted = false';
386
        $queryUrl      = $this->integration->getQueryUrl();
387
388
        return $this->request('query', ['q' => $campaignQuery], 'GET', false, null, $queryUrl);
389
    }
390
391
    /**
392
     * @param      $campaignId
393
     * @param null $modifiedSince
394
     * @param null $queryUrl
395
     *
396
     * @return mixed|string
397
     *
398
     * @throws ApiErrorException
399
     */
400
    public function getCampaignMembers($campaignId, $modifiedSince = null, $queryUrl = null)
401
    {
402
        $defaultSettings = $this->requestSettings;
403
404
        // Control batch size to prevent URL too long errors when fetching contact details via SOQL and to control Doctrine RAM usage for
405
        // Mautic IntegrationEntity objects
406
        $this->requestSettings['headers']['Sforce-Query-Options'] = 'batchSize=200';
407
408
        if (null === $queryUrl) {
409
            $queryUrl = $this->integration->getQueryUrl().'/query';
410
        }
411
412
        $query = "Select CampaignId, ContactId, LeadId, isDeleted from CampaignMember where CampaignId = '".trim($campaignId)."'";
413
        if ($modifiedSince) {
414
            $query .= ' and SystemModStamp >= '.$modifiedSince;
415
        }
416
417
        $results = $this->request(null, ['q' => $query], 'GET', false, null, $queryUrl);
418
419
        $this->requestSettings = $defaultSettings;
420
421
        return $results;
422
    }
423
424
    /**
425
     * @param $campaignId
426
     * @param $object
427
     *
428
     * @return array
429
     *
430
     * @throws ApiErrorException
431
     */
432
    public function checkCampaignMembership($campaignId, $object, array $people)
433
    {
434
        $campaignMembers = [];
435
        if (!empty($people)) {
436
            $idField = "{$object}Id";
437
            $query   = "Select Id, $idField from CampaignMember where CampaignId = '".$campaignId
438
                ."' and $idField in ('".implode("','", $people)."')";
439
440
            $foundCampaignMembers = $this->request('query', ['q' => $query], 'GET', false, null, $this->integration->getQueryUrl());
441
            if (!empty($foundCampaignMembers['records'])) {
442
                foreach ($foundCampaignMembers['records'] as $member) {
443
                    $campaignMembers[$member[$idField]] = $member['Id'];
444
                }
445
            }
446
        }
447
448
        return $campaignMembers;
449
    }
450
451
    /**
452
     * @param $campaignId
453
     *
454
     * @return mixed|string
455
     *
456
     * @throws ApiErrorException
457
     */
458
    public function getCampaignMemberStatus($campaignId)
459
    {
460
        $campaignQuery = "Select Id, Label from CampaignMemberStatus where isDeleted = false and CampaignId='".$campaignId."'";
461
        $queryUrl      = $this->integration->getQueryUrl();
462
463
        return $this->request('query', ['q' => $campaignQuery], 'GET', false, null, $queryUrl);
464
    }
465
466
    /**
467
     * @return int
468
     */
469
    public function getRequestCounter()
470
    {
471
        $count                   = $this->apiRequestCounter;
472
        $this->apiRequestCounter = 0;
473
474
        return $count;
475
    }
476
477
    /**
478
     * @param null $requiredFieldString
479
     *
480
     * @return mixed|string
481
     *
482
     * @throws ApiErrorException
483
     */
484
    public function getCompaniesByName(array $names, $requiredFieldString)
485
    {
486
        $names     = array_map([$this, 'escapeQueryValue'], $names);
487
        $queryUrl  = $this->integration->getQueryUrl();
488
        $findQuery = 'select Id, '.$requiredFieldString.' from Account where isDeleted = false and Name in (\''.implode("','", $names).'\')';
489
490
        return $this->request('query', ['q' => $findQuery], 'GET', false, null, $queryUrl);
491
    }
492
493
    /**
494
     * @param $requiredFieldString
495
     *
496
     * @return mixed|string
497
     *
498
     * @throws ApiErrorException
499
     */
500
    public function getCompaniesById(array $ids, $requiredFieldString)
501
    {
502
        $findQuery = 'select isDeleted, Id, '.$requiredFieldString.' from Account where  Id in (\''.implode("','", $ids).'\')';
503
        $queryUrl  = $this->integration->getQueryUrl();
504
505
        return $this->request('queryAll', ['q' => $findQuery], 'GET', false, null, $queryUrl);
506
    }
507
508
    /**
509
     * @param mixed $response
510
     * @param bool  $isRetry
511
     *
512
     * @throws ApiErrorException
513
     * @throws RetryRequestException
514
     */
515
    private function analyzeResponse($response, $isRetry)
516
    {
517
        if (is_array($response)) {
518
            if (!empty($response['errors'])) {
519
                throw new ApiErrorException(implode(', ', $response['errors']));
520
            }
521
522
            foreach ($response as $lineItem) {
523
                if (is_array($lineItem) && !empty($lineItem['errorCode']) && $error = $this->processError($lineItem, $isRetry)) {
524
                    $errors[] = $error;
525
                }
526
            }
527
528
            if (!empty($errors)) {
529
                throw new ApiErrorException(implode(', ', $errors));
530
            }
531
        }
532
    }
533
534
    /**
535
     * @param $isRetry
536
     *
537
     * @return string|false
538
     *
539
     * @throws ApiErrorException
540
     * @throws RetryRequestException
541
     */
542
    private function processError(array $error, $isRetry)
543
    {
544
        switch ($error['errorCode']) {
545
            case 'INVALID_SESSION_ID':
546
                $this->revalidateSession($isRetry);
547
                break;
548
            case 'UNABLE_TO_LOCK_ROW':
549
                $this->checkIfLockedRequestShouldBeRetried();
550
                break;
551
        }
552
553
        if (!empty($error['message'])) {
554
            return $error['message'];
555
        }
556
557
        return false;
558
    }
559
560
    /**
561
     * @param $isRetry
562
     *
563
     * @throws ApiErrorException
564
     * @throws RetryRequestException
565
     */
566
    private function revalidateSession($isRetry)
567
    {
568
        if ($refreshError = $this->integration->authCallback(['use_refresh_token' => true])) {
569
            throw new ApiErrorException($refreshError);
570
        }
571
572
        if (!$isRetry) {
573
            throw new RetryRequestException();
574
        }
575
    }
576
577
    /**
578
     * @throws RetryRequestException
579
     */
580
    private function checkIfLockedRequestShouldBeRetried()
581
    {
582
        // The record is locked so let's wait a a few seconds and retry
583
        if ($this->requestCounter < $this->maxLockRetries) {
584
            sleep($this->requestCounter * 3);
585
            ++$this->requestCounter;
586
587
            throw new RetryRequestException();
588
        }
589
590
        $this->requestCounter = 1;
591
592
        return false;
593
    }
594
595
    /**
596
     * @param $value
597
     *
598
     * @return bool|float|mixed|string
599
     */
600
    private function escapeQueryValue($value)
601
    {
602
        // SF uses backslashes as escape delimeter
603
        // Remember that PHP uses \ as an escape. Therefore, to replace a single backslash with 2, must use 2 and 4
604
        $value = str_replace('\\', '\\\\', $value);
605
606
        // Escape single quotes
607
        $value = str_replace("'", "\'", $value);
608
609
        // Apply general formatting/cleanup
610
        $value = $this->integration->cleanPushData($value);
611
612
        return $value;
613
    }
614
}
615