![]() Server : Apache System : Linux server2.corals.io 4.18.0-348.2.1.el8_5.x86_64 #1 SMP Mon Nov 15 09:17:08 EST 2021 x86_64 User : corals ( 1002) PHP Version : 7.4.33 Disable Function : exec,passthru,shell_exec,system Directory : /home/corals/mautic.corals.io/plugins/MauticCrmBundle/Integration/ |
<?php namespace MauticPlugin\MauticCrmBundle\Integration; use Doctrine\ORM\ORMException; use Exception; use Mautic\CoreBundle\Entity\Notification; use Mautic\CoreBundle\Entity\Transformer\NotificationArrayTransformer; use Mautic\CoreBundle\Helper\EmojiHelper; use Mautic\CoreBundle\Helper\InputHelper; use Mautic\LeadBundle\Entity\Company; use Mautic\LeadBundle\Entity\DoNotContact; use Mautic\LeadBundle\Entity\Lead; use Mautic\LeadBundle\Helper\IdentifyCompanyHelper; use Mautic\PluginBundle\Entity\IntegrationEntityRepository; use Mautic\PluginBundle\Exception\ApiErrorException; use Mautic\UserBundle\Entity\Role; use Mautic\UserBundle\Entity\User; use MauticPlugin\MauticCrmBundle\Api\SalesforceApi; use MauticPlugin\MauticCrmBundle\Integration\Salesforce\CampaignMember\Fetcher; use MauticPlugin\MauticCrmBundle\Integration\Salesforce\CampaignMember\Organizer; use MauticPlugin\MauticCrmBundle\Integration\Salesforce\Exception\NoObjectsToFetchException; use MauticPlugin\MauticCrmBundle\Integration\Salesforce\Helper\StateValidationHelper; use MauticPlugin\MauticCrmBundle\Integration\Salesforce\Object\CampaignMember; use MauticPlugin\MauticCrmBundle\Integration\Salesforce\ResultsPaginator; use Psr\Cache\InvalidArgumentException; use Symfony\Component\Console\Helper\ProgressBar; use Symfony\Component\Console\Output\ConsoleOutput; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilder; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; /** * @method SalesforceApi getApiHelper() */ class SalesforceIntegration extends CrmAbstractIntegration { /** * @var string [] */ private array $objects = [ 'Lead', 'Contact', 'Account', ]; private string|bool $failureFetchingLeads = false; public function getName(): string { return 'Salesforce'; } /** * Get the array key for clientId. */ public function getClientIdKey(): string { return 'client_id'; } /** * Get the array key for client secret. */ public function getClientSecretKey(): string { return 'client_secret'; } /** * Get the array key for the auth token. */ public function getAuthTokenKey(): string { return 'access_token'; } /** * @return array<string, string> */ public function getRequiredKeyFields(): array { return [ 'client_id' => 'mautic.integration.keyfield.consumerid', 'client_secret' => 'mautic.integration.keyfield.consumersecret', ]; } /** * Get the keys for the refresh token and expiry. */ public function getRefreshTokenKeys(): array { return ['refresh_token', '']; } public function getSupportedFeatures(): array { return ['push_lead', 'get_leads', 'push_leads']; } public function getAccessTokenUrl(): string { $config = $this->mergeConfigToFeatureSettings([]); if (isset($config['sandbox'][0]) and 'sandbox' === $config['sandbox'][0]) { return 'https://test.salesforce.com/services/oauth2/token'; } return 'https://login.salesforce.com/services/oauth2/token'; } public function getAuthenticationUrl(): string { $config = $this->mergeConfigToFeatureSettings([]); if (isset($config['sandbox'][0]) and 'sandbox' === $config['sandbox'][0]) { return 'https://test.salesforce.com/services/oauth2/authorize'; } return 'https://login.salesforce.com/services/oauth2/authorize'; } public function getAuthScope(): string { return 'api refresh_token'; } public function getApiUrl(): string { return sprintf('%s/services/data/v34.0/sobjects', $this->keys['instance_url']); } public function getQueryUrl(): string { return sprintf('%s/services/data/v34.0', $this->keys['instance_url']); } public function getCompositeUrl(): string { return sprintf('%s/services/data/v38.0', $this->keys['instance_url']); } /** * @param bool $inAuthorization */ public function getBearerToken($inAuthorization = false) { if (!$inAuthorization && isset($this->keys[$this->getAuthTokenKey()])) { return $this->keys[$this->getAuthTokenKey()]; } return false; } public function getAuthenticationType(): string { return 'oauth2'; } public function getDataPriority(): bool { return true; } public function updateDncByDate(): bool { $featureSettings = $this->settings->getFeatureSettings(); if (isset($featureSettings['updateDncByDate'][0]) && 'updateDncByDate' === $featureSettings['updateDncByDate'][0]) { return true; } return false; } /** * Get available company fields for choices in the config UI. * * @param array $settings * * @return array */ public function getFormCompanyFields($settings = []) { return $this->getFormFieldsByObject('company', $settings); } /** * @param mixed[] $settings * * @return mixed[] * * @throws \Exception */ public function getFormLeadFields(array $settings = []): array { $leadFields = $this->getFormFieldsByObject('Lead', $settings); $contactFields = $this->getFormFieldsByObject('Contact', $settings); return array_merge($leadFields, $contactFields); } /** * @param array $settings * * @return mixed[] * * @throws InvalidArgumentException */ public function getAvailableLeadFields($settings = []): array { $silenceExceptions = $settings['silence_exceptions'] ?? true; $salesForceObjects = []; if (isset($settings['feature_settings']['objects'])) { $salesForceObjects = $settings['feature_settings']['objects']; } else { $salesForceObjects[] = 'Lead'; } $isRequired = fn (array $field, $object): bool => ('boolean' !== $field['type'] && empty($field['nillable']) && !in_array($field['name'], ['Status', 'Id', 'CreatedDate'])) || ('Lead' == $object && in_array($field['name'], ['Company'])) || (in_array($object, ['Lead', 'Contact']) && 'Email' === $field['name']); $salesFields = []; try { if (!empty($salesForceObjects) and is_array($salesForceObjects)) { foreach ($salesForceObjects as $sfObject) { if ('Account' === $sfObject) { // Match SF object to Mautic's $sfObject = 'company'; } if (isset($sfObject) and 'Activity' == $sfObject) { continue; } $sfObject = trim($sfObject); // Check the cache first $settings['cache_suffix'] = $cacheSuffix = '.'.$sfObject; if ($fields = parent::getAvailableLeadFields($settings)) { if (('company' === $sfObject && isset($fields['Id'])) || isset($fields['Id__'.$sfObject])) { $salesFields[$sfObject] = $fields; continue; } } if ($this->isAuthorized()) { if (!isset($salesFields[$sfObject])) { $fields = $this->getApiHelper()->getLeadFields($sfObject); if (!empty($fields['fields'])) { foreach ($fields['fields'] as $fieldInfo) { if ((!$fieldInfo['updateable'] && (!$fieldInfo['calculated'] && !in_array($fieldInfo['name'], ['Id', 'IsDeleted', 'CreatedDate']))) || !isset($fieldInfo['name']) || (in_array( $fieldInfo['type'], ['reference'] ) && 'AccountId' != $fieldInfo['name']) ) { continue; } $type = match ($fieldInfo['type']) { 'boolean' => 'boolean', 'datetime' => 'datetime', 'date' => 'date', default => 'string', }; if ('company' !== $sfObject) { if ('AccountId' == $fieldInfo['name']) { $fieldInfo['label'] = 'Company'; } $salesFields[$sfObject][$fieldInfo['name'].'__'.$sfObject] = [ 'type' => $type, 'label' => $sfObject.'-'.$fieldInfo['label'], 'required' => $isRequired($fieldInfo, $sfObject), 'group' => $sfObject, 'optionLabel' => $fieldInfo['label'], ]; // CreateDate can be updatable just in Mautic if (in_array($fieldInfo['name'], ['CreatedDate'])) { $salesFields[$sfObject][$fieldInfo['name'].'__'.$sfObject]['update_mautic'] = 1; } } else { $salesFields[$sfObject][$fieldInfo['name']] = [ 'type' => $type, 'label' => $fieldInfo['label'], 'required' => $isRequired($fieldInfo, $sfObject), ]; } } $this->cache->set('leadFields'.$cacheSuffix, $salesFields[$sfObject]); } } asort($salesFields[$sfObject]); } } } } catch (\Exception $e) { $this->logIntegrationError($e); if (!$silenceExceptions) { throw $e; } } return $salesFields; } /** * @return array */ public function getFormNotes($section) { if ('authorization' == $section) { return ['mautic.salesforce.form.oauth_requirements', 'warning']; } return parent::getFormNotes($section); } /** * @return mixed */ public function getFetchQuery($params) { return $params; } /** * @param array<mixed> $params * * @return array<mixed> * * @throws ApiErrorException */ public function amendLeadDataBeforeMauticPopulate($data, $object, $params = []): array { $updated = 0; $created = 0; $counter = 0; $entity = null; $detachClass = null; $mauticObjectReference = null; $integrationMapping = []; $DNCUpdates = []; if (isset($data['records']) and 'Activity' !== $object) { foreach ($data['records'] as $record) { $this->logger->debug('SALESFORCE: amendLeadDataBeforeMauticPopulate record '.var_export($record, true)); if (isset($params['progress'])) { $params['progress']->advance(); } $dataObject = []; if (isset($record['attributes']['type']) && 'Account' == $record['attributes']['type']) { $newName = ''; } else { $newName = '__'.$object; } foreach ($record as $key => $item) { if (is_bool($item)) { $dataObject[$key.$newName] = (int) $item; } else { $dataObject[$key.$newName] = $item; } } if ($dataObject) { $entity = null; switch ($object) { case 'Contact': if (isset($dataObject['Email__Contact'])) { // Sanitize email to make sure we match it // correctly against mautic emails $dataObject['Email__Contact'] = InputHelper::email($dataObject['Email__Contact']); } // get company from account id and assign company name if (isset($dataObject['AccountId__'.$object])) { $companyName = $this->getCompanyName($dataObject['AccountId__'.$object], 'Name'); if ($companyName) { $dataObject['AccountId__'.$object] = $companyName; } else { unset($dataObject['AccountId__'.$object]); // no company was found in Salesforce } } // no break case 'Lead': // Set owner so that it maps if configured to do so if (!empty($dataObject['Owner__Lead']['Email'])) { $dataObject['owner_email'] = $dataObject['Owner__Lead']['Email']; } elseif (!empty($dataObject['Owner__Contact']['Email'])) { $dataObject['owner_email'] = $dataObject['Owner__Contact']['Email']; } if (isset($dataObject['Email__Lead'])) { // Sanitize email to make sure we match it // correctly against mautic_leads emails $dataObject['Email__Lead'] = InputHelper::email($dataObject['Email__Lead']); } // normalize multiselect field foreach ($dataObject as &$dataO) { if (is_string($dataO)) { $dataO = str_replace(';', '|', $dataO); } } $entity = $this->getMauticLead($dataObject, true, null, null, $object); $mauticObjectReference = 'lead'; $detachClass = Lead::class; break; case 'Account': $entity = $this->getMauticCompany($dataObject, 'Account'); $mauticObjectReference = 'company'; $detachClass = Company::class; break; default: $this->logIntegrationError( new \Exception( sprintf('Received an unexpected object without an internalObjectReference "%s"', $object) ) ); break; } if (!$entity) { continue; } $integrationMapping[$entity->getId()] = [ 'entity' => $entity, 'integration_entity_id' => $record['Id'], ]; if (method_exists($entity, 'isNewlyCreated') && $entity->isNewlyCreated()) { ++$created; if (isset($record['HasOptedOutOfEmail'])) { $DNCUpdates[$object][$entity->getEmail()] = [ 'integration_entity_id' => $record['Id'], 'internal_entity_id' => $entity->getId(), 'email' => $entity->getEmail(), 'is_new' => true, 'opted_out' => $record['HasOptedOutOfEmail'], ]; } } else { ++$updated; } ++$counter; if ($counter >= 100) { // Persist integration entities $this->buildIntegrationEntities($integrationMapping, $object, $mauticObjectReference, $params); $counter = 0; $this->em->detach($entity); $integrationMapping = []; } } } if (count($integrationMapping)) { // Persist integration entities $this->buildIntegrationEntities($integrationMapping, $object, $mauticObjectReference, $params); $this->em->detach($entity); } foreach ($DNCUpdates as $objectName => $sfEntity) { $this->pushLeadDoNotContactByDate('email', $sfEntity, $objectName, $params); } unset($data['records']); $this->logger->debug('SALESFORCE: amendLeadDataBeforeMauticPopulate response '.var_export($data, true)); unset($data); $this->persistIntegrationEntities = []; unset($dataObject); } return [$updated, $created]; } /** * @param FormBuilder $builder * @param array $data * @param string $formArea */ public function appendToForm(&$builder, $data, $formArea): void { if ('features' == $formArea) { $builder->add( 'sandbox', ChoiceType::class, [ 'choices' => [ 'mautic.salesforce.sandbox' => 'sandbox', ], 'expanded' => true, 'multiple' => true, 'label' => 'mautic.salesforce.form.sandbox', 'label_attr' => ['class' => 'control-label'], 'placeholder' => false, 'required' => false, 'attr' => [ 'onclick' => 'Mautic.postForm(mQuery(\'form[name="integration_details"]\'),\'\');', ], ] ); $builder->add( 'updateOwner', ChoiceType::class, [ 'choices' => [ 'mautic.salesforce.updateOwner' => 'updateOwner', ], 'expanded' => true, 'multiple' => true, 'label' => 'mautic.salesforce.form.updateOwner', 'label_attr' => ['class' => 'control-label'], 'placeholder' => false, 'required' => false, 'attr' => [ 'onclick' => 'Mautic.postForm(mQuery(\'form[name="integration_details"]\'),\'\');', ], ] ); $builder->add( 'updateBlanks', ChoiceType::class, [ 'choices' => [ 'mautic.integrations.blanks' => 'updateBlanks', ], 'expanded' => true, 'multiple' => true, 'label' => 'mautic.integrations.form.blanks', 'label_attr' => ['class' => 'control-label'], 'placeholder' => false, 'required' => false, ] ); $builder->add( 'updateDncByDate', ChoiceType::class, [ 'choices' => [ 'mautic.integrations.update.dnc.by.date' => 'updateDncByDate', ], 'expanded' => true, 'multiple' => true, 'label' => 'mautic.integrations.form.update.dnc.by.date.label', 'label_attr' => ['class' => 'control-label'], 'placeholder' => false, 'required' => false, ] ); $builder->add( 'objects', ChoiceType::class, [ 'choices' => [ 'mautic.salesforce.object.lead' => 'Lead', 'mautic.salesforce.object.contact' => 'Contact', 'mautic.salesforce.object.company' => 'company', 'mautic.salesforce.object.activity' => 'Activity', ], 'expanded' => true, 'multiple' => true, 'label' => 'mautic.salesforce.form.objects_to_pull_from', 'label_attr' => ['class' => ''], 'placeholder' => false, 'required' => false, ] ); $builder->add( 'activityEvents', ChoiceType::class, [ 'choices' => array_flip($this->leadModel->getEngagementTypes()), // Choice type expects labels as keys 'label' => 'mautic.salesforce.form.activity_included_events', 'label_attr' => [ 'class' => 'control-label', 'data-toggle' => 'tooltip', 'title' => $this->translator->trans('mautic.salesforce.form.activity.events.tooltip'), ], 'multiple' => true, 'empty_data' => ['point.gained', 'form.submitted', 'email.read'], // BC with pre 2.11.0 'required' => false, ] ); $builder->add( 'namespace', TextType::class, [ 'label' => 'mautic.salesforce.form.namespace_prefix', 'label_attr' => ['class' => 'control-label'], 'attr' => ['class' => 'form-control'], 'required' => false, ] ); } } /** * @param array $fields * @param array $keys * @param mixed $object * * @return array */ public function prepareFieldsForSync($fields, $keys, $object = null) { $leadFields = []; if (null === $object) { $object = 'Lead'; } $objects = (!is_array($object)) ? [$object] : $object; if (is_string($object) && 'Account' === $object) { return $fields['companyFields'] ?? $fields; } if (isset($fields['leadFields'])) { $fields = $fields['leadFields']; $keys = array_keys($fields); } foreach ($objects as $obj) { if (!isset($leadFields[$obj])) { $leadFields[$obj] = []; } foreach ($keys as $key) { if (strpos($key, '__'.$obj)) { $newKey = str_replace('__'.$obj, '', $key); if ('Id' === $newKey) { // Don't map Id for push continue; } $leadFields[$obj][$newKey] = $fields[$key]; } } } return (is_array($object)) ? $leadFields : $leadFields[$object]; } /** * @param Lead $lead * @param array $config * * @return array|bool */ public function pushLead($lead, $config = []) { $config = $this->mergeConfigToFeatureSettings($config); if (empty($config['leadFields'])) { return []; } $mappedData = $this->mapContactDataForPush($lead, $config); // No fields are mapped so bail if (empty($mappedData)) { return false; } try { if ($this->isAuthorized()) { $existingPersons = $this->getApiHelper()->getPerson( [ 'Lead' => $mappedData['Lead']['create'] ?? null, 'Contact' => $mappedData['Contact']['create'] ?? null, ] ); $personFound = false; $people = [ 'Contact' => [], 'Lead' => [], ]; foreach (['Contact', 'Lead'] as $object) { if (!empty($existingPersons[$object])) { $fieldsToUpdate = $mappedData[$object]['update']; $fieldsToUpdate = $this->getBlankFieldsToUpdate($fieldsToUpdate, $existingPersons[$object], $mappedData, $config); $personFound = true; foreach ($existingPersons[$object] as $person) { if (!empty($fieldsToUpdate)) { if (isset($fieldsToUpdate['AccountId'])) { $accountId = $this->getCompanyName($fieldsToUpdate['AccountId'], 'Id', 'Name'); if (!$accountId) { // company was not found so create a new company in Salesforce $company = $lead->getPrimaryCompany(); if (!empty($company)) { $company = $this->companyModel->getEntity($company['id']); $sfCompany = $this->pushCompany($company); if ($sfCompany) { $fieldsToUpdate['AccountId'] = key($sfCompany); } } } else { $fieldsToUpdate['AccountId'] = $accountId; } } $personData = $this->getApiHelper()->updateObject($fieldsToUpdate, $object, $person['Id']); } $people[$object][$person['Id']] = $person['Id']; } } if ('Lead' === $object && !$personFound && isset($mappedData[$object]['create'])) { $personData = $this->getApiHelper()->createLead($mappedData[$object]['create']); $people[$object][$personData['Id']] = $personData['Id']; $personFound = true; } if (isset($personData['Id'])) { /** @var IntegrationEntityRepository $integrationEntityRepo */ $integrationEntityRepo = $this->em->getRepository(\Mautic\PluginBundle\Entity\IntegrationEntity::class); $integrationId = $integrationEntityRepo->getIntegrationsEntityId('Salesforce', $object, 'lead', $lead->getId()); $integrationEntity = (empty($integrationId)) ? $this->createIntegrationEntity($object, $personData['Id'], 'lead', $lead->getId(), [], false) : $this->em->getReference(\Mautic\PluginBundle\Entity\IntegrationEntity::class, $integrationId[0]['id']); $integrationEntity->setLastSyncDate($this->getLastSyncDate()); $integrationEntityRepo->saveEntity($integrationEntity); } } // Return success if any Contact or Lead was updated or created return ($personFound) ? $people : false; } } catch (\Exception $e) { if ($e instanceof ApiErrorException) { $e->setContact($lead); } $this->logIntegrationError($e); } return false; } /** * @param Company $company * @param array $config * * @return array|bool */ public function pushCompany($company, $config = []) { $config = $this->mergeConfigToFeatureSettings($config); if (empty($config['companyFields']) || !$company) { return []; } $object = 'company'; $mappedData = $this->mapCompanyDataForPush($company, $config); // No fields are mapped so bail if (empty($mappedData)) { return false; } try { if ($this->isAuthorized()) { $existingCompanies = $this->getApiHelper()->getCompany( [ $object => $mappedData[$object]['create'], ] ); $companyFound = false; $companies = []; if (!empty($existingCompanies[$object])) { $fieldsToUpdate = $mappedData[$object]['update']; $fieldsToUpdate = $this->getBlankFieldsToUpdate($fieldsToUpdate, $existingCompanies[$object], $mappedData, $config); $companyFound = true; foreach ($existingCompanies[$object] as $sfCompany) { if (!empty($fieldsToUpdate)) { $companyData = $this->getApiHelper()->updateObject($fieldsToUpdate, $object, $sfCompany['Id']); } $companies[$sfCompany['Id']] = $sfCompany['Id']; } } if (!$companyFound) { $companyData = $this->getApiHelper()->createObject($mappedData[$object]['create'], 'Account'); $companies[$companyData['Id']] = $companyData['Id']; $companyFound = true; } if (isset($companyData['Id'])) { /** @var IntegrationEntityRepository $integrationEntityRepo */ $integrationEntityRepo = $this->em->getRepository(\Mautic\PluginBundle\Entity\IntegrationEntity::class); $integrationId = $integrationEntityRepo->getIntegrationsEntityId('Salesforce', $object, 'company', $company->getId()); $integrationEntity = (empty($integrationId)) ? $this->createIntegrationEntity($object, $companyData['Id'], 'lead', $company->getId(), [], false) : $this->em->getReference(\Mautic\PluginBundle\Entity\IntegrationEntity::class, $integrationId[0]['id']); $integrationEntity->setLastSyncDate($this->getLastSyncDate()); $integrationEntityRepo->saveEntity($integrationEntity); } // Return success if any company was updated or created return ($companyFound) ? $companies : false; } } catch (\Exception $e) { $this->logIntegrationError($e); } return false; } /** * @param array $params * @param array $result * @param string $object * * @return array|null */ public function getLeads($params = [], $query = null, &$executed = null, $result = [], $object = 'Lead') { if (!$query) { $query = $this->getFetchQuery($params); } if (!is_array($executed)) { $executed = [ 0 => 0, 1 => 0, ]; } try { if ($this->isAuthorized()) { $progress = null; $paginator = new ResultsPaginator($this->logger, $this->keys['instance_url']); while (true) { $result = $this->getApiHelper()->getLeads($query, $object); $paginator->setResults($result); if (isset($params['output']) && !isset($params['progress'])) { $progress = new ProgressBar($params['output'], $paginator->getTotal()); $progress->setFormat(' %current%/%max% [%bar%] %percent:3s%% ('.$object.')'); $params['progress'] = $progress; } [$justUpdated, $justCreated] = $this->amendLeadDataBeforeMauticPopulate($result, $object, $params); $executed[0] += $justUpdated; $executed[1] += $justCreated; if (!$nextUrl = $paginator->getNextResultsUrl()) { // No more records to fetch break; } $query['nextUrl'] = $nextUrl; } if ($progress) { $progress->finish(); } } } catch (\Exception $e) { $this->logIntegrationError($e); $this->failureFetchingLeads = $e->getMessage(); } $this->logger->debug('SALESFORCE: '.$this->getApiHelper()->getRequestCounter().' API requests made for getLeads: '.$object); return $executed; } public function upsertUnreadAdminsNotification(string $header, string $message, string $type = 'error', bool $preventUnreadDuplicates = true): void { $notificationTemplate = new Notification(); $notificationTemplate->setType($type); $notificationTemplate->setIsRead(false); $notificationTemplate->setHeader(EmojiHelper::toHtml(InputHelper::strict_html($header))); $notificationTemplate->setMessage(EmojiHelper::toHtml(InputHelper::strict_html($message))); $notificationTemplate->setIconClass(null); $persistEntities = []; $transformer = new NotificationArrayTransformer(); foreach ($this->getAdminUsers() as $adminUser) { if ($preventUnreadDuplicates) { /* @var Notification|null $exists */ $notificationTemplate->setUser($adminUser); $searchArray = $transformer->transform($notificationTemplate); $search = array_intersect_key( $searchArray, array_flip(['type', 'isRead', 'header', 'message', 'user']) ); $exists = $this->getNotificationModel()->getRepository()->findOneBy($search); if ($exists) { continue; } $notification = clone $notificationTemplate; $notification->setDateAdded(new \DateTime()); // not sure what date to use } $persistEntities[] = $notification; } $this->getNotificationModel()->getRepository()->saveEntities($persistEntities); $this->getNotificationModel()->getRepository()->detachEntities($persistEntities); } /** * Get all enabled admin users. * * @return array|User[] */ private function getAdminUsers(): array { $userRepository = $this->em->getRepository(User::class); $adminRole = $this->em->getRepository(Role::class)->findOneBy(['isAdmin' => true]); return $userRepository->findBy( [ 'role' => $adminRole, 'isPublished' => true, ] ); } /** * @param array $params * * @return array|null */ public function getCompanies($params = [], $query = null, $executed = null) { return $this->getLeads($params, $query, $executed, [], 'Account'); } /** * @param array $params * * @return int|null * * @throws \Exception */ public function pushLeadActivity($params = []) { $executed = null; $query = $this->getFetchQuery($params); $config = $this->mergeConfigToFeatureSettings([]); /** @var SalesforceApi $apiHelper */ $apiHelper = $this->getApiHelper(); $salesForceObjects[] = 'Lead'; if (isset($config['objects']) && !empty($config['objects'])) { $salesForceObjects = $config['objects']; } // Ensure that Contact is attempted before Lead sort($salesForceObjects); /** @var IntegrationEntityRepository $integrationEntityRepo */ $integrationEntityRepo = $this->em->getRepository(\Mautic\PluginBundle\Entity\IntegrationEntity::class); $startDate = new \DateTime($query['start']); $endDate = new \DateTime($query['end']); $limit = 100; foreach ($salesForceObjects as $object) { if (!in_array($object, ['Contact', 'Lead'])) { continue; } try { if ($this->isAuthorized()) { // Get first batch $start = 0; $salesForceIds = $integrationEntityRepo->getIntegrationsEntityId( 'Salesforce', $object, 'lead', null, $startDate->format('Y-m-d H:m:s'), $endDate->format('Y-m-d H:m:s'), true, $start, $limit ); while (!empty($salesForceIds)) { $executed += count($salesForceIds); // Extract a list of lead Ids $leadIds = []; $sfIds = []; foreach ($salesForceIds as $ids) { $leadIds[] = $ids['internal_entity_id']; $sfIds[] = $ids['integration_entity_id']; } // Collect lead activity for this batch $leadActivity = $this->getLeadData( $startDate, $endDate, $leadIds ); $this->logger->debug('SALESFORCE: Syncing activity for '.count($leadActivity).' contacts ('.implode(', ', array_keys($leadActivity)).')'); $this->logger->debug('SALESFORCE: Syncing activity for '.var_export($sfIds, true)); $salesForceLeadData = []; foreach ($salesForceIds as $ids) { $leadId = $ids['internal_entity_id']; if (isset($leadActivity[$leadId])) { $sfId = $ids['integration_entity_id']; $salesForceLeadData[$sfId] = $leadActivity[$leadId]; $salesForceLeadData[$sfId]['id'] = $ids['integration_entity_id']; $salesForceLeadData[$sfId]['leadId'] = $ids['internal_entity_id']; $salesForceLeadData[$sfId]['leadUrl'] = $this->router->generate( 'mautic_plugin_timeline_view', ['integration' => 'Salesforce', 'leadId' => $leadId], UrlGeneratorInterface::ABSOLUTE_URL ); } else { $this->logger->debug('SALESFORCE: No activity found for contact ID '.$leadId); } } if (!empty($salesForceLeadData)) { $apiHelper->createLeadActivity($salesForceLeadData, $object); } else { $this->logger->debug('SALESFORCE: No contact activity to sync'); } // Get the next batch $start += $limit; $salesForceIds = $integrationEntityRepo->getIntegrationsEntityId( 'Salesforce', $object, 'lead', null, $startDate->format('Y-m-d H:m:s'), $endDate->format('Y-m-d H:m:s'), true, $start, $limit ); } } } catch (\Exception $e) { $this->logIntegrationError($e); } } return $executed; } /** * Return key recognized by integration. */ public function convertLeadFieldKey(string $key, $field): string { $search = []; foreach ($this->objects as $object) { $search[] = '__'.$object; } return str_replace($search, '', $key); } /** * @param array $params * * @return mixed[] */ public function pushLeads($params = []): array { $limit = $params['limit'] ?? 100; [$fromDate, $toDate] = $this->getSyncTimeframeDates($params); $config = $this->mergeConfigToFeatureSettings($params); $integrationEntityRepo = $this->getIntegrationEntityRepository(); $totalUpdated = 0; $totalCreated = 0; $totalErrors = 0; [$fieldMapping, $mauticLeadFieldString, $supportedObjects] = $this->prepareFieldsForPush($config); if (empty($fieldMapping)) { return [0, 0, 0, 0]; } $originalLimit = $limit; $progress = false; // Get a total number of contacts to be updated and/or created for the progress counter $totalToUpdate = array_sum( $integrationEntityRepo->findLeadsToUpdate( 'Salesforce', 'lead', $mauticLeadFieldString, false, $fromDate, $toDate, $supportedObjects, [] ) ); $totalToCreate = (in_array('Lead', $supportedObjects)) ? $integrationEntityRepo->findLeadsToCreate( 'Salesforce', $mauticLeadFieldString, false, $fromDate, $toDate ) : 0; $totalCount = $totalToProcess = $totalToCreate + $totalToUpdate; if (defined('IN_MAUTIC_CONSOLE')) { // start with update if ($totalToUpdate + $totalToCreate) { $output = new ConsoleOutput(); $output->writeln("About $totalToUpdate to update and about $totalToCreate to create/update"); $progress = new ProgressBar($output, $totalCount); } } // Start with contacts so we know who is a contact when we go to process converted leads if (count($supportedObjects) > 1) { $sfObject = 'Contact'; } else { $sfObject = array_key_first($supportedObjects); } $noMoreUpdates = false; $trackedContacts = [ 'Contact' => [], 'Lead' => [], ]; // Loop to maximize composite that may include updating contacts, updating leads, and creating leads while ($totalCount > 0) { $limit = $originalLimit; $mauticData = []; $checkEmailsInSF = []; $leadsToSync = []; $processedLeads = []; // Process the updates if (!$noMoreUpdates) { $noMoreUpdates = $this->getMauticContactsToUpdate( $checkEmailsInSF, $mauticLeadFieldString, $sfObject, $trackedContacts, $limit, $fromDate, $toDate, $totalCount ); if ($noMoreUpdates && 'Contact' === $sfObject && isset($supportedObjects['Lead'])) { // Try Leads $sfObject = 'Lead'; $noMoreUpdates = $this->getMauticContactsToUpdate( $checkEmailsInSF, $mauticLeadFieldString, $sfObject, $trackedContacts, $limit, $fromDate, $toDate, $totalCount ); } if ($limit) { // Mainly done for test mocking purposes $limit = $this->getSalesforceSyncLimit($checkEmailsInSF, $limit); } } // If there is still room - grab Mautic leads to create if the Lead object is enabled $sfEntityRecords = []; if ('Lead' === $sfObject && (null === $limit || $limit > 0) && !empty($mauticLeadFieldString)) { try { $sfEntityRecords = $this->getMauticContactsToCreate( $checkEmailsInSF, $fieldMapping, $mauticLeadFieldString, $limit, $fromDate, $toDate, $totalCount, $progress ); } catch (ApiErrorException $exception) { $this->cleanupFromSync($leadsToSync, $exception); } } elseif ($checkEmailsInSF) { $sfEntityRecords = $this->getSalesforceObjectsByEmails($sfObject, $checkEmailsInSF, implode(',', array_keys($fieldMapping[$sfObject]['create']))); if (!isset($sfEntityRecords['records'])) { // Something is wrong so throw an exception to prevent creating a bunch of new leads $this->cleanupFromSync( $leadsToSync, json_encode($sfEntityRecords) ); } } $this->pushLeadDoNotContactByDate('email', $checkEmailsInSF, $sfObject, $params); // We're done if (!$checkEmailsInSF) { break; } $this->prepareMauticContactsToUpdate( $mauticData, $checkEmailsInSF, $processedLeads, $trackedContacts, $leadsToSync, $fieldMapping, $mauticLeadFieldString, $sfEntityRecords, $progress ); // Only create left over if Lead object is enabled in integration settings if ($checkEmailsInSF && isset($fieldMapping['Lead'])) { $this->prepareMauticContactsToCreate( $mauticData, $checkEmailsInSF, $processedLeads, $fieldMapping ); } // Persist pending changes $this->cleanupFromSync($leadsToSync); // Make the request $this->makeCompositeRequest($mauticData, $totalUpdated, $totalCreated, $totalErrors); // Stop gap - if 100% let's kill the script if ($progress && $progress->getProgressPercent() >= 1) { break; } } if ($progress) { $progress->finish(); $output->writeln(''); } $this->logger->debug('SALESFORCE: '.$this->getApiHelper()->getRequestCounter().' API requests made for pushLeads'); // Assume that those not touched are ignored due to not having matching fields, duplicates, etc $totalIgnored = $totalToProcess - ($totalUpdated + $totalCreated + $totalErrors); return [$totalUpdated, $totalCreated, $totalErrors, $totalIgnored]; } /** * @return array */ public function getSalesforceLeadId($lead) { $config = $this->mergeConfigToFeatureSettings([]); $integrationEntityRepo = $this->getIntegrationEntityRepository(); if (isset($config['objects'])) { // try searching for lead as this has been changed before in updated done to the plugin if (false !== array_search('Contact', $config['objects'])) { $resultContact = $integrationEntityRepo->getIntegrationsEntityId('Salesforce', 'Contact', 'lead', $lead->getId()); if ($resultContact) { return $resultContact; } } } return $integrationEntityRepo->getIntegrationsEntityId('Salesforce', 'Lead', 'lead', $lead->getId()); } /** * @return array * * @throws \Exception */ public function getCampaigns() { $campaigns = []; try { $campaigns = $this->getApiHelper()->getCampaigns(); } catch (\Exception $e) { $this->logIntegrationError($e); } return $campaigns; } /** * @return array<mixed> * * @throws \Exception */ public function getCampaignChoices(): array { $choices = []; $campaigns = $this->getCampaigns(); if (!empty($campaigns['records'])) { foreach ($campaigns['records'] as $campaign) { $choices[] = [ 'value' => $campaign['Id'], 'label' => $campaign['Name'], ]; } } return $choices; } /** * @param int $campaignId * * @throws InvalidArgumentException */ public function getCampaignMembers($campaignId): void { $this->failureFetchingLeads = false; /** @var IntegrationEntityRepository $integrationEntityRepo */ $integrationEntityRepo = $this->em->getRepository(\Mautic\PluginBundle\Entity\IntegrationEntity::class); $mixedFields = $this->getIntegrationSettings()->getFeatureSettings(); // Get the last time the campaign was synced to prevent resyncing the entire SF campaign $cacheKey = $this->getName().'.CampaignSync.'.$campaignId; $lastSyncDate = $this->getCache()->get($cacheKey); $syncStarted = (new \DateTime())->format('c'); if (false === $lastSyncDate) { // Sync all records $lastSyncDate = null; } // Consume in batches $paginator = new ResultsPaginator($this->logger, $this->keys['instance_url']); $nextRecordsUrl = null; while (true) { try { $results = $this->getApiHelper()->getCampaignMembers($campaignId, $lastSyncDate, $nextRecordsUrl); $paginator->setResults($results); $organizer = new Organizer($results['records']); $fetcher = new Fetcher($integrationEntityRepo, $organizer, $campaignId); // Create Mautic contacts from Campaign Members if they don't already exist foreach (['Contact', 'Lead'] as $object) { $fields = $this->getMixedLeadFields($mixedFields, $object); try { $query = $fetcher->getQueryForUnknownObjects($fields, $object); $this->getLeads([], $query, $executed, [], $object); if ($this->failureFetchingLeads) { // Something failed while fetching the leads (i.e API error limit) so we have to fail here to prevent the campaign // from caching the timestamp that will cause contacts to not be pulled/added to the segment throw new ApiErrorException($this->failureFetchingLeads); } } catch (NoObjectsToFetchException) { // No more IDs to fetch so break and continue on continue; } } // Create integration entities for members we aren't already tracking $unknownMembers = $fetcher->getUnknownCampaignMembers(); $persistEntities = []; $counter = 0; foreach ($unknownMembers as $mauticContactId) { $persistEntities[] = $this->createIntegrationEntity( CampaignMember::OBJECT, $campaignId, 'lead', $mauticContactId, [], false ); ++$counter; if (20 === $counter) { // Batch to control RAM use $this->em->getRepository(\Mautic\PluginBundle\Entity\IntegrationEntity::class)->saveEntities($persistEntities); $this->integrationEntityModel->getRepository()->detachEntities($persistEntities); $persistEntities = []; $counter = 0; } } // Catch left overs if ($persistEntities) { $this->em->getRepository(\Mautic\PluginBundle\Entity\IntegrationEntity::class)->saveEntities($persistEntities); $this->integrationEntityModel->getRepository()->detachEntities($persistEntities); } unset($unknownMembers, $fetcher, $organizer, $persistEntities); // Do we continue? if (!$nextRecordsUrl = $paginator->getNextResultsUrl()) { // No more results to fetch // Store the latest sync date at the end in case something happens during the actual sync process and it needs to be re-ran $this->cache->set($cacheKey, $syncStarted); break; } } catch (\Exception $e) { $this->logIntegrationError($e); break; } } } public function getMixedLeadFields($fields, $object): array { $mixedFields = array_filter($fields['leadFields'] ?? []); $fields = []; foreach ($mixedFields as $sfField => $mField) { if (str_contains($sfField, '__'.$object)) { $fields[] = str_replace('__'.$object, '', $sfField); } if (str_contains($sfField, '-'.$object)) { $fields[] = str_replace('-'.$object, '', $sfField); } } return $fields; } /** * @return array * * @throws \Exception */ public function getCampaignMemberStatus($campaignId) { $campaignMemberStatus = []; try { $campaignMemberStatus = $this->getApiHelper()->getCampaignMemberStatus($campaignId); } catch (\Exception $e) { $this->logIntegrationError($e); } return $campaignMemberStatus; } public function pushLeadToCampaign(Lead $lead, $campaignId, $status = '', $personIds = null): bool { if (empty($personIds)) { // personIds should have been generated by pushLead() return false; } $mauticData = []; /** @var IntegrationEntityRepository $integrationEntityRepo */ $integrationEntityRepo = $this->em->getRepository(\Mautic\PluginBundle\Entity\IntegrationEntity::class); $body = [ 'Status' => $status, ]; $object = 'CampaignMember'; $url = '/services/data/v38.0/sobjects/'.$object; if (!empty($lead->getEmail())) { $pushPeople = []; $pushObject = null; if (!empty($personIds)) { // Give precendence to Contact CampaignMembers if (!empty($personIds['Contact'])) { $pushObject = 'Contact'; $campaignMembers = $this->getApiHelper()->checkCampaignMembership($campaignId, $pushObject, $personIds[$pushObject]); $pushPeople = $personIds[$pushObject]; } if (empty($campaignMembers) && !empty($personIds['Lead'])) { $pushObject = 'Lead'; $campaignMembers = $this->getApiHelper()->checkCampaignMembership($campaignId, $pushObject, $personIds[$pushObject]); $pushPeople = $personIds[$pushObject]; } } // pushLead should have handled this foreach ($pushPeople as $memberId) { $campaignMappingId = '-'.$campaignId; if (isset($campaignMembers[$memberId])) { $existingCampaignMember = $integrationEntityRepo->getIntegrationsEntityId( 'Salesforce', 'CampaignMember', 'lead', null, null, null, false, 0, 0, [$campaignMembers[$memberId]] ); foreach ($existingCampaignMember as $member) { $integrationEntity = $integrationEntityRepo->getEntity($member['id']); $referenceId = $integrationEntity->getId(); $internalLeadId = $integrationEntity->getInternalEntityId(); } $id = !empty($lead->getId()) ? $lead->getId() : ''; $id .= '-CampaignMember'.$campaignMembers[$memberId]; $id .= !empty($referenceId) ? '-'.$referenceId : ''; $id .= $campaignMappingId; $patchurl = $url.'/'.$campaignMembers[$memberId]; $mauticData[$id] = [ 'method' => 'PATCH', 'url' => $patchurl, 'referenceId' => $id, 'body' => $body, 'httpHeaders' => [ 'Sforce-Auto-Assign' => 'FALSE', ], ]; } else { $id = (!empty($lead->getId()) ? $lead->getId() : '').'-CampaignMemberNew-null'.$campaignMappingId; $mauticData[$id] = [ 'method' => 'POST', 'url' => $url, 'referenceId' => $id, 'body' => array_merge( $body, [ 'CampaignId' => $campaignId, "{$pushObject}Id" => $memberId, ] ), ]; } } $request['allOrNone'] = 'false'; $request['compositeRequest'] = array_values($mauticData); $this->logger->debug('SALESFORCE: pushLeadToCampaign '.var_export($request, true)); $result = $this->getApiHelper()->syncMauticToSalesforce($request); return (bool) array_sum($this->processCompositeResponse($result['compositeResponse'])); } return false; } protected function getSyncKey($email): string { return mb_strtolower($this->cleanPushData($email)); } protected function getMauticContactsToUpdate( &$checkEmailsInSF, $mauticLeadFieldString, &$sfObject, &$trackedContacts, $limit, $fromDate, $toDate, &$totalCount ): bool { // Fetch them separately so we can determine if Leads are already Contacts $toUpdate = $this->getIntegrationEntityRepository()->findLeadsToUpdate( 'Salesforce', 'lead', $mauticLeadFieldString, $limit, $fromDate, $toDate, $sfObject )[$sfObject]; $toUpdateCount = count($toUpdate); $totalCount -= $toUpdateCount; foreach ($toUpdate as $lead) { if (!empty($lead['email'])) { $lead = $this->getCompoundMauticFields($lead); $key = $this->getSyncKey($lead['email']); $trackedContacts[$lead['integration_entity']][$key] = $lead['id']; if ('Contact' == $sfObject) { $this->setContactToSync($checkEmailsInSF, $lead); } elseif (isset($trackedContacts['Contact'][$key])) { // We already know this is a converted contact so just ignore it $integrationEntity = $this->em->getReference( \Mautic\PluginBundle\Entity\IntegrationEntity::class, $lead['id'] ); $this->deleteIntegrationEntities[] = $integrationEntity; $this->logger->debug('SALESFORCE: Converted lead '.$lead['email']); } else { $this->setContactToSync($checkEmailsInSF, $lead); } } } return 0 === $toUpdateCount; } /** * @return array * * @throws ApiErrorException */ protected function getMauticContactsToCreate( &$checkEmailsInSF, $fieldMapping, $mauticLeadFieldString, $limit, $fromDate, $toDate, &$totalCount, $progress = null ) { $integrationEntityRepo = $this->getIntegrationEntityRepository(); $leadsToCreate = $integrationEntityRepo->findLeadsToCreate( 'Salesforce', $mauticLeadFieldString, $limit, $fromDate, $toDate ); $totalCount -= count($leadsToCreate); $foundContacts = []; $sfEntityRecords = [ 'totalSize' => 0, 'records' => [], ]; $error = false; foreach ($leadsToCreate as $lead) { $lead = $this->getCompoundMauticFields($lead); if (isset($lead['email'])) { $this->setContactToSync($checkEmailsInSF, $lead); } elseif ($progress) { $progress->advance(); } } // When creating, we have to check for Contacts first then Lead if (isset($fieldMapping['Contact'])) { $sfEntityRecords = $this->getSalesforceObjectsByEmails('Contact', $checkEmailsInSF, implode(',', array_keys($fieldMapping['Contact']['create']))); if (isset($sfEntityRecords['records'])) { foreach ($sfEntityRecords['records'] as $sfContactRecord) { if (!isset($sfContactRecord['Email'])) { continue; } $key = $this->getSyncKey($sfContactRecord['Email']); $foundContacts[$key] = $key; } } else { $error = json_encode($sfEntityRecords); } } // For any Mautic contacts left over, check to see if existing Leads exist if (isset($fieldMapping['Lead']) && $checkSfLeads = array_diff_key($checkEmailsInSF, $foundContacts)) { $sfLeadRecords = $this->getSalesforceObjectsByEmails('Lead', $checkSfLeads, implode(',', array_keys($fieldMapping['Lead']['create']))); if (isset($sfLeadRecords['records'])) { // Merge contact records with these $sfEntityRecords['records'] = array_merge($sfEntityRecords['records'], $sfLeadRecords['records']); $sfEntityRecords['totalSize'] = (int) $sfEntityRecords['totalSize'] + (int) $sfLeadRecords['totalSize']; } else { $error = json_encode($sfLeadRecords); } } if ($error) { throw new ApiErrorException($error); } unset($leadsToCreate, $checkSfLeads); return $sfEntityRecords; } protected function buildCompositeBody( &$mauticData, $objectFields, $object, &$entity, $objectId = null, $sfRecord = null ): array { $body = []; $updateEntity = []; $company = null; $config = $this->mergeConfigToFeatureSettings([]); if ((isset($entity['email']) && !empty($entity['email'])) || (isset($entity['companyname']) && !empty($entity['companyname']))) { // use a composite patch here that can update and create (one query) every 200 records if (isset($objectFields['update'])) { $fields = ($objectId) ? $objectFields['update'] : $objectFields['create']; if (isset($entity['company']) && isset($entity['integration_entity']) && 'Contact' == $object) { $accountId = $this->getCompanyName($entity['company'], 'Id', 'Name'); if (!$accountId) { // company was not found so create a new company in Salesforce $lead = $this->leadModel->getEntity($entity['internal_entity_id']); if ($lead) { $companies = $this->leadModel->getCompanies($lead); if (!empty($companies)) { foreach ($companies as $companyData) { if ($companyData['is_primary']) { $company = $this->companyModel->getEntity($companyData['company_id']); } } if ($company) { $sfCompany = $this->pushCompany($company); if (!empty($sfCompany)) { $entity['company'] = key($sfCompany); } } } else { unset($entity['company']); } } } else { $entity['company'] = $accountId; } } $fields = $this->getBlankFieldsToUpdate($fields, $sfRecord, $objectFields, $config); } else { $fields = $objectFields; } foreach ($fields as $sfField => $mauticField) { if (isset($entity[$mauticField])) { $fieldType = (isset($objectFields['types']) && isset($objectFields['types'][$sfField])) ? $objectFields['types'][$sfField] : 'string'; if (!empty($entity[$mauticField]) and 'boolean' != $fieldType) { $body[$sfField] = $this->cleanPushData($entity[$mauticField], $fieldType); } elseif ('boolean' == $fieldType) { $body[$sfField] = $this->cleanPushData($entity[$mauticField], $fieldType); } } if (array_key_exists($sfField, $objectFields['required']['fields']) && empty($body[$sfField])) { if (isset($sfRecord[$sfField])) { $body[$sfField] = $sfRecord[$sfField]; if (empty($entity[$mauticField]) && !empty($sfRecord[$sfField]) && $sfRecord[$sfField] !== $this->translator->trans( 'mautic.integration.form.lead.unknown' ) ) { $updateEntity[$mauticField] = $sfRecord[$sfField]; } } else { $body[$sfField] = $this->translator->trans('mautic.integration.form.lead.unknown'); } } } $this->amendLeadDataBeforePush($body); if (!empty($body)) { $url = '/services/data/v38.0/sobjects/'.$object; if ($objectId) { $url .= '/'.$objectId; } $id = $entity['internal_entity_id'].'-'.$object.(!empty($entity['id']) ? '-'.$entity['id'] : ''); $method = ($objectId) ? 'PATCH' : 'POST'; $mauticData[$id] = [ 'method' => $method, 'url' => $url, 'referenceId' => $id, 'body' => $body, 'httpHeaders' => [ 'Sforce-Auto-Assign' => ($objectId) ? 'FALSE' : 'TRUE', ], ]; } } return $updateEntity; } protected function getRequiredFieldString(array $config, array $availableFields, $object): array { $requiredFields = $this->getRequiredFields($availableFields[$object]); if ('company' != $object) { $requiredFields = $this->prepareFieldsForSync($config['leadFields'] ?? [], array_keys($requiredFields), $object); } $requiredString = implode(',', array_keys($requiredFields)); return [$requiredFields, $requiredString]; } protected function prepareFieldsForPush($config): array { $leadFields = array_unique(array_values($config['leadFields'])); $leadFields = array_combine($leadFields, $leadFields); unset($leadFields['mauticContactTimelineLink']); unset($leadFields['mauticContactIsContactableByEmail']); $fieldsToUpdateInSf = $this->getPriorityFieldsForIntegration($config); $fieldKeys = array_keys($config['leadFields']); $supportedObjects = []; $objectFields = []; // Important to have contacts first!! if (false !== array_search('Contact', $config['objects'])) { $supportedObjects['Contact'] = 'Contact'; $fieldsToCreate = $this->prepareFieldsForSync($config['leadFields'] ?? [], $fieldKeys, 'Contact'); $objectFields['Contact'] = [ 'update' => isset($fieldsToUpdateInSf['Contact']) ? array_intersect_key($fieldsToCreate, $fieldsToUpdateInSf['Contact']) : [], 'create' => $fieldsToCreate, ]; } if (false !== array_search('Lead', $config['objects'])) { $supportedObjects['Lead'] = 'Lead'; $fieldsToCreate = $this->prepareFieldsForSync($config['leadFields'] ?? [], $fieldKeys, 'Lead'); $objectFields['Lead'] = [ 'update' => isset($fieldsToUpdateInSf['Lead']) ? array_intersect_key($fieldsToCreate, $fieldsToUpdateInSf['Lead']) : [], 'create' => $fieldsToCreate, ]; } $mauticLeadFieldString = implode(', l.', $leadFields); $mauticLeadFieldString = 'l.'.$mauticLeadFieldString; $availableFields = $this->getAvailableLeadFields(['feature_settings' => ['objects' => $supportedObjects]]); // Setup required fields and field types foreach ($supportedObjects as $object) { $objectFields[$object]['types'] = []; if (isset($availableFields[$object])) { $fieldData = $this->prepareFieldsForSync($availableFields[$object], array_keys($availableFields[$object]), $object); foreach ($fieldData as $fieldName => $field) { $objectFields[$object]['types'][$fieldName] = $field['type'] ?? 'string'; } } [$fields, $string] = $this->getRequiredFieldString( $config, $availableFields, $object ); $objectFields[$object]['required'] = [ 'fields' => $fields, 'string' => $string, ]; } return [$objectFields, $mauticLeadFieldString, $supportedObjects]; } /** * @param string $priorityObject * * @return mixed */ protected function getPriorityFieldsForMautic($config, $object = null, $priorityObject = 'mautic') { $fields = parent::getPriorityFieldsForMautic($config, $object, $priorityObject); return ($object && isset($fields[$object])) ? $fields[$object] : $fields; } /** * @param string $priorityObject * * @return mixed */ protected function getPriorityFieldsForIntegration($config, $object = null, $priorityObject = 'mautic') { $fields = parent::getPriorityFieldsForIntegration($config, $object, $priorityObject); unset($fields['Contact']['Id'], $fields['Lead']['Id']); return ($object && isset($fields[$object])) ? $fields[$object] : $fields; } /** * @param int $totalUpdated * @param int $totalCreated * @param int $totalErrored */ protected function processCompositeResponse($response, &$totalUpdated = 0, &$totalCreated = 0, &$totalErrored = 0): array { if (is_array($response)) { foreach ($response as $item) { $contactId = $integrationEntityId = $campaignId = null; $object = 'Lead'; $internalObject = 'lead'; if (!empty($item['referenceId'])) { $reference = explode('-', $item['referenceId']); if (3 === count($reference)) { [$contactId, $object, $integrationEntityId] = $reference; } elseif (4 === count($reference)) { [$contactId, $object, $integrationEntityId, $campaignId] = $reference; } else { [$contactId, $object] = $reference; } } if (strstr($object, 'CampaignMember')) { $object = 'CampaignMember'; } if ('Account' == $object) { $internalObject = 'company'; } if (isset($item['body'][0]['errorCode'])) { $exception = new ApiErrorException($item['body'][0]['message']); if ('Contact' == $object || $object = 'Lead') { $exception->setContactId($contactId); } $this->logIntegrationError($exception); $integrationEntity = null; if ($integrationEntityId && 'CampaignMember' !== $object) { $integrationEntity = $this->integrationEntityModel->getEntityByIdAndSetSyncDate($integrationEntityId, new \DateTime()); } elseif (isset($campaignId)) { $integrationEntity = $this->integrationEntityModel->getEntityByIdAndSetSyncDate($campaignId, $this->getLastSyncDate()); } elseif ($contactId) { $integrationEntity = $this->createIntegrationEntity( $object, null, $internalObject.'-error', $contactId, null, false ); } if ($integrationEntity) { $integrationEntity->setInternalEntity('ENTITY_IS_DELETED' === $item['body'][0]['errorCode'] ? $internalObject.'-deleted' : $internalObject.'-error') ->setInternal(['error' => $item['body'][0]['message']]); $this->persistIntegrationEntities[] = $integrationEntity; } ++$totalErrored; } elseif (!empty($item['body']['success'])) { if (201 === $item['httpStatusCode']) { // New object created if ('CampaignMember' === $object) { $internal = ['Id' => $item['body']['id']]; } else { $internal = []; } $this->salesforceIdMapping[$contactId] = $item['body']['id']; $this->persistIntegrationEntities[] = $this->createIntegrationEntity( $object, $this->salesforceIdMapping[$contactId], $internalObject, $contactId, $internal, false ); } ++$totalCreated; } elseif (204 === $item['httpStatusCode']) { // Record was updated if ($integrationEntityId) { $integrationEntity = $this->integrationEntityModel->getEntityByIdAndSetSyncDate($integrationEntityId, $this->getLastSyncDate()); if ($integrationEntity) { if (isset($this->salesforceIdMapping[$contactId])) { $integrationEntity->setIntegrationEntityId($this->salesforceIdMapping[$contactId]); } $this->persistIntegrationEntities[] = $integrationEntity; } } elseif (!empty($this->salesforceIdMapping[$contactId])) { // Found in Salesforce so create a new record for it $this->persistIntegrationEntities[] = $this->createIntegrationEntity( $object, $this->salesforceIdMapping[$contactId], $internalObject, $contactId, [], false ); } ++$totalUpdated; } else { $error = 'http status code '.$item['httpStatusCode']; switch (true) { case !empty($item['body'][0]['message']['message']): $error = $item['body'][0]['message']['message']; break; case !empty($item['body']['message']): $error = $item['body']['message']; break; } $exception = new ApiErrorException($error); if (!empty($item['referenceId']) && ('Contact' == $object || $object = 'Lead')) { $exception->setContactId($item['referenceId']); } $this->logIntegrationError($exception); ++$totalErrored; if ($integrationEntityId) { $integrationEntity = $this->integrationEntityModel->getEntityByIdAndSetSyncDate($integrationEntityId, $this->getLastSyncDate()); if ($integrationEntity) { if (isset($this->salesforceIdMapping[$contactId])) { $integrationEntity->setIntegrationEntityId($this->salesforceIdMapping[$contactId]); } $this->persistIntegrationEntities[] = $integrationEntity; } } elseif (!empty($this->salesforceIdMapping[$contactId])) { // Found in Salesforce so create a new record for it $this->persistIntegrationEntities[] = $this->createIntegrationEntity( $object, $this->salesforceIdMapping[$contactId], $internalObject, $contactId, [], false ); } } } } $this->cleanupFromSync(); return [$totalUpdated, $totalCreated]; } /** * @return array */ protected function getSalesforceObjectsByEmails($sfObject, $checkEmailsInSF, $requiredFieldString) { // Salesforce craps out with double quotes and unescaped single quotes $findEmailsInSF = array_map( fn ($lead): string => str_replace("'", "\'", $this->cleanPushData($lead['email'])), $checkEmailsInSF ); $fieldString = "'".implode("','", $findEmailsInSF)."'"; $queryUrl = $this->getQueryUrl(); $findQuery = ('Lead' === $sfObject) ? 'select Id, '.$requiredFieldString.', ConvertedContactId from Lead where isDeleted = false and Email in ('.$fieldString.')' : 'select Id, '.$requiredFieldString.' from Contact where isDeleted = false and Email in ('.$fieldString.')'; return $this->getApiHelper()->request('query', ['q' => $findQuery], 'GET', false, null, $queryUrl); } protected function prepareMauticContactsToUpdate( &$mauticData, &$checkEmailsInSF, &$processedLeads, &$trackedContacts, &$leadsToSync, $objectFields, $mauticLeadFieldString, $sfEntityRecords, $progress = null ) { foreach ($sfEntityRecords['records'] as $sfKey => $sfEntityRecord) { $skipObject = false; $syncLead = false; $sfObject = $sfEntityRecord['attributes']['type']; if (!isset($sfEntityRecord['Email'])) { // This is a record we don't recognize so continue return; } $key = $this->getSyncKey($sfEntityRecord['Email']); if (!isset($sfEntityRecord['Id']) || (!isset($checkEmailsInSF[$key]) && !isset($processedLeads[$key]))) { // This is a record we don't recognize so continue return; } $leadData = $processedLeads[$key] ?? $checkEmailsInSF[$key]; $contactId = $leadData['internal_entity_id']; if ( isset($checkEmailsInSF[$key]) && ( ( 'Lead' === $sfObject && !empty($sfEntityRecord['ConvertedContactId']) ) || ( isset($checkEmailsInSF[$key]['integration_entity']) && 'Contact' === $sfObject && 'Lead' === $checkEmailsInSF[$key]['integration_entity'] ) ) ) { $deleted = false; // This is a converted lead so remove the Lead entity leaving the Contact entity if (!empty($trackedContacts['Lead'][$key])) { $this->deleteIntegrationEntities[] = $this->em->getReference( \Mautic\PluginBundle\Entity\IntegrationEntity::class, $trackedContacts['Lead'][$key] ); $deleted = true; unset($trackedContacts['Lead'][$key]); } if ($contactEntity = $this->checkLeadIsContact($trackedContacts['Contact'], $key, $contactId, $mauticLeadFieldString)) { // This Lead is already a Contact but was not updated for whatever reason if (!$deleted) { $this->deleteIntegrationEntities[] = $this->em->getReference( \Mautic\PluginBundle\Entity\IntegrationEntity::class, $checkEmailsInSF[$key]['id'] ); } // Update the Contact record instead $checkEmailsInSF[$key] = $contactEntity; $trackedContacts['Contact'][$key] = $contactEntity['id']; } else { $id = (!empty($sfEntityRecord['ConvertedContactId'])) ? $sfEntityRecord['ConvertedContactId'] : $sfEntityRecord['Id']; // This contact does not have a Contact record $integrationEntity = $this->createIntegrationEntity( 'Contact', $id, 'lead', $contactId ); $checkEmailsInSF[$key]['integration_entity'] = 'Contact'; $checkEmailsInSF[$key]['integration_entity_id'] = $id; $checkEmailsInSF[$key]['id'] = $integrationEntity; } $this->logger->debug('SALESFORCE: Converted lead '.$sfEntityRecord['Email']); // skip if this is a Lead object since it'll be handled with the Contact entry if ('Lead' === $sfObject) { unset($checkEmailsInSF[$key]); unset($sfEntityRecords['records'][$sfKey]); $skipObject = true; } } if (!$skipObject) { // Only progress if we have a unique Lead and not updating a Salesforce entry duplicate if (!isset($processedLeads[$key])) { if ($progress) { $progress->advance(); } // Mark that this lead has been processed $leadData = $processedLeads[$key] = $checkEmailsInSF[$key]; } // Keep track of Mautic ID to Salesforce ID for the integration table $this->salesforceIdMapping[$contactId] = (!empty($sfEntityRecord['ConvertedContactId'])) ? $sfEntityRecord['ConvertedContactId'] : $sfEntityRecord['Id']; $leadEntity = $this->em->getReference(Lead::class, $leadData['internal_entity_id']); if ($updateLead = $this->buildCompositeBody( $mauticData, $objectFields[$sfObject], $sfObject, $leadData, $sfEntityRecord['Id'], $sfEntityRecord ) ) { // Get the lead entity /* @var Lead $leadEntity */ foreach ($updateLead as $mauticField => $sfValue) { $leadEntity->addUpdatedField($mauticField, $sfValue); } $syncLead = !empty($leadEntity->getChanges(true)); } // Validate if we have a company for this Mautic contact if (!empty($sfEntityRecord['Company']) && $sfEntityRecord['Company'] !== $this->translator->trans( 'mautic.integration.form.lead.unknown' ) ) { $company = IdentifyCompanyHelper::identifyLeadsCompany( ['company' => $sfEntityRecord['Company']], null, $this->companyModel ); if (!empty($company[2])) { $syncLead = $this->companyModel->addLeadToCompany($company[2], $leadEntity); $this->em->detach($company[2]); } } if ($syncLead) { $leadsToSync[] = $leadEntity; } else { $this->em->detach($leadEntity); } } unset($checkEmailsInSF[$key]); } } protected function prepareMauticContactsToCreate( &$mauticData, &$checkEmailsInSF, &$processedLeads, $objectFields ) { foreach ($checkEmailsInSF as $key => $lead) { if (!empty($lead['integration_entity_id'])) { if ($this->buildCompositeBody( $mauticData, $objectFields[$lead['integration_entity']], $lead['integration_entity'], $lead, $lead['integration_entity_id'] ) ) { $this->logger->debug('SALESFORCE: Contact has existing ID so updating '.$lead['email']); } } else { $this->buildCompositeBody( $mauticData, $objectFields['Lead'], 'Lead', $lead ); } $processedLeads[$key] = $checkEmailsInSF[$key]; unset($checkEmailsInSF[$key]); } } /** * @param int $totalUpdated * @param int $totalCreated * @param int $totalErrored */ protected function makeCompositeRequest($mauticData, &$totalUpdated = 0, &$totalCreated = 0, &$totalErrored = 0) { if (empty($mauticData)) { return; } /** @var SalesforceApi $apiHelper */ $apiHelper = $this->getApiHelper(); // We can only send 25 at a time $request = []; $request['allOrNone'] = 'false'; $chunked = array_chunk($mauticData, 25); foreach ($chunked as $chunk) { // We can only submit 25 at a time if ($chunk) { $request['compositeRequest'] = $chunk; $result = $apiHelper->syncMauticToSalesforce($request); $this->logger->debug('SALESFORCE: Sync Composite '.var_export($request, true)); $this->processCompositeResponse($result['compositeResponse'], $totalUpdated, $totalCreated, $totalErrored); } } } /** * @return bool|mixed|string */ protected function setContactToSync(&$checkEmailsInSF, $lead) { $key = $this->getSyncKey($lead['email']); if (isset($checkEmailsInSF[$key])) { // this is a duplicate in Mautic $this->mauticDuplicates[$lead['internal_entity_id']] = 'lead-duplicate'; return false; } $checkEmailsInSF[$key] = $lead; return $key; } /** * @return int */ protected function getSalesforceSyncLimit($currentContactList, $limit) { return $limit - count($currentContactList); } /** * @return array|bool */ protected function checkLeadIsContact(&$trackedContacts, $email, $contactId, $leadFields) { if (empty($trackedContacts[$email])) { // Check if there's an existing entry return $this->getIntegrationEntityRepository()->getIntegrationEntity( $this->getName(), 'Contact', 'lead', $contactId, $leadFields ); } return false; } /** * @param array $objects * * @return array */ protected function cleanPriorityFields($fieldsToUpdate, $objects = null) { if (null === $objects) { $objects = ['Lead', 'Contact']; } if (isset($fieldsToUpdate['leadFields'])) { // Pass in the whole config $fields = $fieldsToUpdate; } else { $fields = array_flip($fieldsToUpdate); } return $this->prepareFieldsForSync($fields, $fieldsToUpdate, $objects); } protected function mapContactDataForPush(Lead $lead, $config): array { $fields = array_keys($config['leadFields'] ?? []); $fieldsToUpdateInSf = $this->getPriorityFieldsForIntegration($config); $fieldMapping = [ 'Lead' => [], 'Contact' => [], ]; $mappedData = [ 'Lead' => [], 'Contact' => [], ]; foreach (['Lead', 'Contact'] as $object) { if (isset($config['objects']) && false !== array_search($object, $config['objects'])) { $fieldMapping[$object]['create'] = $this->prepareFieldsForSync($config['leadFields'] ?? [], $fields, $object); $fieldMapping[$object]['update'] = isset($fieldsToUpdateInSf[$object]) ? array_intersect_key( $fieldMapping[$object]['create'], $fieldsToUpdateInSf[$object] ) : []; // Create an update and $mappedData[$object]['create'] = $this->populateLeadData( $lead, [ 'leadFields' => $fieldMapping[$object]['create'], // map with all fields available 'object' => $object, 'feature_settings' => [ 'objects' => $config['objects'], ], ] ); if (isset($mappedData[$object]['create']['Id'])) { unset($mappedData[$object]['create']['Id']); } $this->amendLeadDataBeforePush($mappedData[$object]['create']); // Set the update fields $mappedData[$object]['update'] = array_intersect_key($mappedData[$object]['create'], $fieldMapping[$object]['update']); } } return $mappedData; } protected function mapCompanyDataForPush(Company $company, $config): array { $object = 'company'; $entity = []; $mappedData = [ $object => [], ]; if (isset($config['objects']) && false !== array_search($object, $config['objects'])) { $fieldKeys = array_keys($config['companyFields']); $fieldsToCreate = $this->prepareFieldsForSync($config['companyFields'], $fieldKeys, 'Account'); $fieldsToUpdateInSf = $this->getPriorityFieldsForIntegration($config, 'Account', 'mautic_company'); $fieldMapping[$object] = [ 'update' => !empty($fieldsToUpdateInSf) ? array_intersect_key($fieldsToCreate, $fieldsToUpdateInSf) : [], 'create' => $fieldsToCreate, ]; $entity['primaryCompany'] = $company->getProfileFields(); // Create an update and $mappedData[$object]['create'] = $this->populateCompanyData( $entity, [ 'companyFields' => $fieldMapping[$object]['create'], // map with all fields available 'object' => $object, 'feature_settings' => [ 'objects' => $config['objects'], ], ] ); if (isset($mappedData[$object]['create']['Id'])) { unset($mappedData[$object]['create']['Id']); } $this->amendLeadDataBeforePush($mappedData[$object]['create']); // Set the update fields $mappedData[$object]['update'] = array_intersect_key($mappedData[$object]['create'], $fieldMapping[$object]['update']); } return $mappedData; } public function amendLeadDataBeforePush(&$mappedData): void { // normalize for multiselect field foreach ($mappedData as &$data) { if (is_string($data)) { $data = str_replace('|', ';', $data); } } $mappedData = StateValidationHelper::validate($mappedData); } /** * @param string $object * * @return array */ public function getFieldsForQuery($object) { $fields = $this->getIntegrationSettings()->getFeatureSettings(); switch ($object) { case 'company': case 'Account': $fields = array_keys(array_filter($fields['companyFields'])); break; default: $mixedFields = array_filter($fields['leadFields'] ?? []); $fields = []; foreach ($mixedFields as $sfField => $mField) { if (str_contains($sfField, '__'.$object)) { $fields[] = str_replace('__'.$object, '', $sfField); } if (str_contains($sfField, '-'.$object)) { $fields[] = str_replace('-'.$object, '', $sfField); } } if (!in_array('HasOptedOutOfEmail', $fields)) { $fields[] = 'HasOptedOutOfEmail'; } } return $fields; } /** * @param string $sfObject * @param string $sfFieldString * * @return mixed * * @throws ApiErrorException */ public function getDncHistory($sfObject, $sfFieldString) { return $this->getDoNotContactHistory($sfObject, $sfFieldString, 'DESC'); } public function getDoNotContactHistory(string $object, string $ids, string $order = 'DESC'): mixed { // get last modified date for do not contact in Salesforce $query = sprintf('Select Field, %sId, CreatedDate, isDeleted, NewValue from %sHistory where Field = \'HasOptedOutOfEmail\' and %sId IN (%s) ORDER BY CreatedDate %s', $object, $object, $object, $ids, $order); $url = $this->getQueryUrl(); return $this->getApiHelper()->request('query', ['q' => $query], 'GET', false, null, $url); } /** * Update the record in each system taking the last modified record. * * @param string $channel * @param string $sfObject * * @throws ApiErrorException */ public function pushLeadDoNotContactByDate($channel, &$sfRecords, $sfObject, $params = []): void { $filters = []; $leadIds = []; $DNCCreatedContacts = []; if (empty($sfRecords) || !isset($sfRecords['mauticContactIsContactableByEmail']) && !$this->updateDncByDate()) { return; } foreach ($sfRecords as $record) { if (empty($record['integration_entity_id'])) { continue; } $leadIds[$record['internal_entity_id']] = $record['integration_entity_id']; $leadEmails[$record['internal_entity_id']] = $record['email']; if (isset($record['opted_out']) && $record['opted_out'] && isset($record['is_new']) && $record['is_new']) { $DNCCreatedContacts[] = $record['internal_entity_id']; } } $sfFieldString = "'".implode("','", $leadIds)."'"; $historySF = $this->getDoNotContactHistory($sfObject, $sfFieldString, 'ASC'); if (count($DNCCreatedContacts)) { $this->updateMauticDNC($DNCCreatedContacts, true); } // if there is no records of when it was modified in SF then just exit if (empty($historySF['records'])) { return; } // get last modified date for donot contact in Mautic $auditLogRepo = $this->em->getRepository(\Mautic\CoreBundle\Entity\AuditLog::class); $filters['search'] = 'dnc_channel_status%'.$channel; $lastModifiedDNCDate = $auditLogRepo->getAuditLogsForLeads(array_flip($leadIds), $filters, ['dateAdded', 'DESC'], $params['start']); $trackedIds = []; foreach ($historySF['records'] as $sfModifiedDNC) { // if we have no history in Mautic, then update the Mautic record if (empty($lastModifiedDNCDate)) { $leads = array_flip($leadIds); $leadId = $leads[$sfModifiedDNC[$sfObject.'Id']]; $this->updateMauticDNC($leadId, $sfModifiedDNC['NewValue']); $key = $this->getSyncKey($leadEmails[$leadId]); unset($sfRecords[$key]['mauticContactIsContactableByEmail']); continue; } foreach ($lastModifiedDNCDate as $logs) { $leadId = $logs['objectId']; if (strtotime($logs['dateAdded']->format('c')) > strtotime($sfModifiedDNC['CreatedDate'])) { $trackedIds[] = $leadId; } if ((isset($leadIds[$leadId]) && $leadIds[$leadId] == $sfModifiedDNC[$sfObject.'Id']) && (strtotime($sfModifiedDNC['CreatedDate']) > strtotime($logs['dateAdded']->format('c'))) && !in_array($leadId, $trackedIds)) { // SF was updated last so update Mautic record $key = $this->getSyncKey($leadEmails[$leadId]); unset($sfRecords[$key]['mauticContactIsContactableByEmail']); $this->updateMauticDNC($leadId, $sfModifiedDNC['NewValue']); $trackedIds[] = $leadId; break; } } } } /** * @param int|int[] $leadId * @param bool $newDncValue */ private function updateMauticDNC($leadId, $newDncValue): void { $leadIds = is_array($leadId) ? $leadId : [$leadId]; foreach ($leadIds as $leadId) { $lead = $this->leadModel->getEntity($leadId); if (true == $newDncValue) { $this->doNotContact->addDncForContact($lead->getId(), 'email', DoNotContact::MANUAL, 'Set by Salesforce', true, true, true); } elseif (false == $newDncValue) { $this->doNotContact->removeDncForContact($lead->getId(), 'email', true); } } } /** * @param array $params * * @return mixed[] */ public function pushCompanies($params = []): array { $limit = $params['limit'] ?? 100; [$fromDate, $toDate] = $this->getSyncTimeframeDates($params); $config = $this->mergeConfigToFeatureSettings($params); $integrationEntityRepo = $this->getIntegrationEntityRepository(); if (!isset($config['companyFields'])) { return [0, 0, 0, 0]; } $totalUpdated = 0; $totalCreated = 0; $totalErrors = 0; $sfObject = 'Account'; // all available fields in Salesforce for Account $availableFields = $this->getAvailableLeadFields(['feature_settings' => ['objects' => [$sfObject]]]); // get company fields from Mautic that have been mapped $mauticCompanyFieldString = implode(', l.', $config['companyFields']); $mauticCompanyFieldString = 'l.'.$mauticCompanyFieldString; $fieldKeys = array_keys($config['companyFields']); $fieldsToCreate = $this->prepareFieldsForSync($config['companyFields'], $fieldKeys, $sfObject); $fieldsToUpdateInSf = $this->getPriorityFieldsForIntegration($config, $sfObject, 'mautic_company'); $objectFields['company'] = [ 'update' => !empty($fieldsToUpdateInSf) ? array_intersect_key($fieldsToCreate, $fieldsToUpdateInSf) : [], 'create' => $fieldsToCreate, ]; [$fields, $string] = $this->getRequiredFieldString( $config, $availableFields, 'company' ); $objectFields['company']['required'] = [ 'fields' => $fields, 'string' => $string, ]; if (empty($objectFields)) { return [0, 0, 0, 0]; } $originalLimit = $limit; $progress = false; // Get a total number of companies to be updated and/or created for the progress counter $totalToUpdate = array_sum( $integrationEntityRepo->findLeadsToUpdate( 'Salesforce', 'company', $mauticCompanyFieldString, false, $fromDate, $toDate, $sfObject, [] ) ); $totalToCreate = $integrationEntityRepo->findLeadsToCreate( 'Salesforce', $mauticCompanyFieldString, false, $fromDate, $toDate, 'company' ); $totalCount = $totalToProcess = $totalToCreate + $totalToUpdate; if (defined('IN_MAUTIC_CONSOLE')) { // start with update if ($totalToUpdate + $totalToCreate) { $output = new ConsoleOutput(); $output->writeln("About $totalToUpdate to update and about $totalToCreate to create/update"); $progress = new ProgressBar($output, $totalCount); } } $noMoreUpdates = false; while ($totalCount > 0) { $limit = $originalLimit; $mauticData = []; $checkCompaniesInSF = []; $companiesToSync = []; $processedCompanies = []; // Process the updates if (!$noMoreUpdates) { $noMoreUpdates = $this->getMauticRecordsToUpdate( $checkCompaniesInSF, $mauticCompanyFieldString, $sfObject, $limit, $fromDate, $toDate, $totalCount, 'company' ); if ($limit) { // Mainly done for test mocking purposes $limit = $this->getSalesforceSyncLimit($checkCompaniesInSF, $limit); } } // If there is still room - grab Mautic companies to create if the Lead object is enabled $sfEntityRecords = []; if ((null === $limit || $limit > 0) && !empty($mauticCompanyFieldString)) { $this->getMauticEntitesToCreate( $checkCompaniesInSF, $mauticCompanyFieldString, $limit, $fromDate, $toDate, $totalCount, $progress ); } if ($checkCompaniesInSF) { $sfEntityRecords = $this->getSalesforceAccountsByName($checkCompaniesInSF, implode(',', array_keys($config['companyFields']))); if (!isset($sfEntityRecords['records'])) { // Something is wrong so throw an exception to prevent creating a bunch of new companies $this->cleanupFromSync( $companiesToSync, json_encode($sfEntityRecords) ); } } // We're done if (!$checkCompaniesInSF) { break; } if (!empty($sfEntityRecords) and isset($sfEntityRecords['records'])) { $this->prepareMauticCompaniesToUpdate( $mauticData, $checkCompaniesInSF, $processedCompanies, $companiesToSync, $objectFields, $sfEntityRecords, $progress ); } // Only create left over if Lead object is enabled in integration settings if ($checkCompaniesInSF) { $this->prepareMauticCompaniesToCreate( $mauticData, $checkCompaniesInSF, $processedCompanies, $objectFields ); } // Persist pending changes $this->cleanupFromSync($companiesToSync); $this->makeCompositeRequest($mauticData, $totalUpdated, $totalCreated, $totalErrors); // Stop gap - if 100% let's kill the script if ($progress && $progress->getProgressPercent() >= 1) { break; } } if ($progress) { $progress->finish(); $output->writeln(''); } $this->logger->debug('SALESFORCE: '.$this->getApiHelper()->getRequestCounter().' API requests made for pushCompanies'); // Assume that those not touched are ignored due to not having matching fields, duplicates, etc $totalIgnored = $totalToProcess - ($totalUpdated + $totalCreated + $totalErrors); if ($totalIgnored < 0) { // this could have been marked as deleted so it was not pushed $totalIgnored = $totalIgnored * -1; } return [$totalUpdated, $totalCreated, $totalErrors, $totalIgnored]; } protected function prepareMauticCompaniesToUpdate( &$mauticData, &$checkCompaniesInSF, &$processedCompanies, &$companiesToSync, $objectFields, $sfEntityRecords, $progress = null ) { foreach ($sfEntityRecords['records'] as $sfEntityRecord) { $syncCompany = false; $update = false; $sfObject = $sfEntityRecord['attributes']['type']; if (!isset($sfEntityRecord['Name'])) { // This is a record we don't recognize so continue return; } $key = $sfEntityRecord['Id']; if (!isset($sfEntityRecord['Id'])) { // This is a record we don't recognize so continue return; } $id = $sfEntityRecord['Id']; if (isset($checkCompaniesInSF[$key])) { $companyData = $processedCompanies[$key] ?? $checkCompaniesInSF[$key]; $update = true; } else { foreach ($checkCompaniesInSF as $mauticKey => $mauticCompanies) { $key = $mauticKey; if (isset($mauticCompanies['companyname']) && $mauticCompanies['companyname'] == $sfEntityRecord['Name']) { $companyData = $processedCompanies[$key] ?? $checkCompaniesInSF[$key]; $companyId = $companyData['internal_entity_id']; $integrationEntity = $this->createIntegrationEntity( $sfObject, $id, 'company', $companyId ); $checkCompaniesInSF[$key]['integration_entity'] = $sfObject; $checkCompaniesInSF[$key]['integration_entity_id'] = $id; $checkCompaniesInSF[$key]['id'] = $integrationEntity->getId(); $update = true; } } } if (!$update) { return; } if (!isset($processedCompanies[$key])) { if ($progress) { $progress->advance(); } // Mark that this lead has been processed $companyData = $processedCompanies[$key] = $checkCompaniesInSF[$key]; } $companyEntity = $this->em->getReference(Company::class, $companyData['internal_entity_id']); if ($updateCompany = $this->buildCompositeBody( $mauticData, $objectFields['company'], $sfObject, $companyData, $sfEntityRecord['Id'], $sfEntityRecord ) ) { // Get the company entity /* @var Lead $leadEntity */ foreach ($updateCompany as $mauticField => $sfValue) { $companyEntity->addUpdatedField($mauticField, $sfValue); } $syncCompany = !empty($companyEntity->getChanges(true)); } if ($syncCompany) { $companiesToSync[] = $companyEntity; } else { $this->em->detach($companyEntity); } unset($checkCompaniesInSF[$key]); } } protected function prepareMauticCompaniesToCreate( &$mauticData, &$checkCompaniesInSF, &$processedCompanies, $objectFields ) { foreach ($checkCompaniesInSF as $key => $company) { if (!empty($company['integration_entity_id']) and array_key_exists($key, $processedCompanies)) { if ($this->buildCompositeBody( $mauticData, $objectFields['company'], $company['integration_entity'], $company, $company['integration_entity_id'] ) ) { $this->logger->debug('SALESFORCE: Company has existing ID so updating '.$company['integration_entity_id']); } } else { $this->buildCompositeBody( $mauticData, $objectFields['company'], 'Account', $company ); } $processedCompanies[$key] = $checkCompaniesInSF[$key]; unset($checkCompaniesInSF[$key]); } } protected function getMauticRecordsToUpdate( &$checkIdsInSF, $mauticEntityFieldString, &$sfObject, $limit, $fromDate, $toDate, &$totalCount, $internalEntity ): bool { // Fetch them separately so we can determine if Leads are already Contacts $toUpdate = $this->getIntegrationEntityRepository()->findLeadsToUpdate( 'Salesforce', $internalEntity, $mauticEntityFieldString, $limit, $fromDate, $toDate, $sfObject )[$sfObject]; $toUpdateCount = count($toUpdate); $totalCount -= $toUpdateCount; foreach ($toUpdate as $entity) { if (!empty($entity['integration_entity_id'])) { $checkIdsInSF[$entity['integration_entity_id']] = $entity; } } return 0 === $toUpdateCount; } protected function getMauticEntitesToCreate( &$checkIdsInSF, $mauticCompanyFieldString, $limit, $fromDate, $toDate, &$totalCount, $progress = null ) { $integrationEntityRepo = $this->getIntegrationEntityRepository(); $entitiesToCreate = $integrationEntityRepo->findLeadsToCreate( 'Salesforce', $mauticCompanyFieldString, $limit, $fromDate, $toDate, 'company' ); $totalCount -= count($entitiesToCreate); foreach ($entitiesToCreate as $entity) { if (isset($entity['companyname'])) { $checkIdsInSF[$entity['internal_entity_id']] = $entity; } elseif ($progress) { $progress->advance(); } } } /** * @throws ApiErrorException * @throws ORMException * @throws \Exception */ protected function getSalesforceAccountsByName(&$checkIdsInSF, $requiredFieldString): array { $searchForIds = []; $searchForNames = []; foreach ($checkIdsInSF as $key => $company) { if (!empty($company['integration_entity_id'])) { $searchForIds[$key] = $company['integration_entity_id']; continue; } if (!empty($company['companyname'])) { $searchForNames[$key] = $company['companyname']; } } $resultsByName = $this->getApiHelper()->getCompaniesByName($searchForNames, $requiredFieldString); $resultsById = []; if (!empty($searchForIds)) { $resultsById = $this->getApiHelper()->getCompaniesById($searchForIds, $requiredFieldString); // mark as deleleted foreach ($resultsById['records'] as $sfId => $record) { if (isset($record['IsDeleted']) && 1 == $record['IsDeleted']) { if ($foundKey = array_search($record['Id'], $searchForIds)) { $integrationEntity = $this->em->getReference(\Mautic\PluginBundle\Entity\IntegrationEntity::class, $checkIdsInSF[$foundKey]['id']); $integrationEntity->setInternalEntity('company-deleted'); $this->persistIntegrationEntities[] = $integrationEntity; unset($checkIdsInSF[$foundKey]); } unset($resultsById['records'][$sfId]); } } } $this->cleanupFromSync(); return array_merge($resultsByName, $resultsById); } public function getCompanyName($accountId, $field, $searchBy = 'Id') { $companyField = null; $accountId = str_replace("'", "\'", $this->cleanPushData($accountId)); $companyQuery = 'Select Id, Name from Account where '.$searchBy.' = \''.$accountId.'\' and IsDeleted = false'; $contactCompany = $this->getApiHelper()->getLeads($companyQuery, 'Account'); if (!empty($contactCompany['records'])) { foreach ($contactCompany['records'] as $company) { if (!empty($company[$field])) { $companyField = $company[$field]; break; } } } return $companyField; } public function getLeadDoNotContactByDate($channel, $matchedFields, $object, $lead, $sfData, $params = []) { if (isset($matchedFields['mauticContactIsContactableByEmail']) and true === $this->updateDncByDate()) { $matchedFields['internal_entity_id'] = $lead->getId(); $matchedFields['integration_entity_id'] = $sfData['Id__'.$object]; $record[$lead->getEmail()] = $matchedFields; $this->pushLeadDoNotContactByDate($channel, $record, $object, $params); return $record[$lead->getEmail()]; } return $matchedFields; } }