Initial commit: CloudOps infrastructure platform

This commit is contained in:
root
2026-04-09 19:58:57 +02:00
commit 1166a52f26
7762 changed files with 839452 additions and 0 deletions

View File

@@ -0,0 +1,990 @@
<?php
namespace MauticPlugin\MauticCrmBundle\Integration;
use Mautic\CoreBundle\Form\Type\YesNoButtonGroupType;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\PluginBundle\Entity\IntegrationEntity;
use Mautic\PluginBundle\Entity\IntegrationEntityRepository;
use Mautic\PluginBundle\Exception\ApiErrorException;
use Mautic\PluginBundle\Integration\IntegrationObject;
use MauticPlugin\MauticCrmBundle\Form\Type\IntegrationCampaignsTaskType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilder;
/**
* @method \MauticPlugin\MauticCrmBundle\Api\ConnectwiseApi getApiHelper()
*/
class ConnectwiseIntegration extends CrmAbstractIntegration
{
public const PAGESIZE = 200;
public function getName(): string
{
return 'Connectwise';
}
public function getSupportedFeatures(): array
{
return ['push_lead', 'get_leads'];
}
/**
* @return array<string, string>
*/
public function getRequiredKeyFields(): array
{
return [
'username' => 'mautic.connectwise.form.integrator',
'password' => 'mautic.connectwise.form.privatekey',
'site' => 'mautic.connectwise.form.site',
'appcookie' => 'mautic.connectwise.form.cookie',
];
}
/**
* Get the array key for application cookie.
*/
public function getClientId(): string
{
return 'appcookie';
}
/**
* Get the array key for companyid.
*/
public function getCompanyIdKey(): string
{
return 'companyid';
}
/**
* Get the array key for client id.
*/
public function getIntegrator(): string
{
return 'username';
}
/**
* Get the array key for client id.
*/
public function getConnectwiseUrl(): string
{
return 'site';
}
/**
* Get the array key for client secret.
*/
public function getClientSecretKey(): string
{
return 'password';
}
public function getSecretKeys(): array
{
return [
'password', 'appcookie',
];
}
public function getApiUrl(): string
{
return sprintf('%s/v4_6_release/apis/3.0', $this->keys['site']);
}
public function getAuthLoginUrl(): string
{
return $this->router->generate('mautic_integration_auth_callback', ['integration' => $this->getName()]);
}
/**
* @return bool
*/
public function authCallback($settings = [], $parameters = [])
{
$url = $this->getApiUrl();
$error = false;
try {
$response = $this->makeRequest($url.'/system/members/', $parameters, 'GET', $settings);
foreach ($response as $key => $r) {
$key = preg_replace('/[\r\n]+/', '', $key);
switch ($key) {
case '<!DOCTYPE_html_PUBLIC_"-//W3C//DTD_XHTML_1_0_Strict//EN"_"http://www_w3_org/TR/xhtml1/DTD/xhtml1-strict_dtd"><html_xmlns':
$error = '404 not found error';
break;
case 'code':
$error = $response['message'].' '.$r;
break;
}
}
if (!$error) {
$data = ['username' => $this->keys['username'], 'password' => $this->keys['password']];
$this->extractAuthKeys($data, 'username');
}
} catch (\Exception $e) {
return $e->getMessage();
}
return $error;
}
/**
* Append ClientID into header to enable authentication.
*
* @param string $url
* @param array<mixed> $parameters
* @param string $method
* @param array<mixed> $settings
* @param string $authType
*
* @return array<mixed>
*/
public function prepareRequest($url, $parameters, $method, $settings, $authType): array
{
[$parameters,$headers] = parent::prepareRequest($url, $parameters, $method, $settings, $authType);
$headers['clientId'] = $this->keys['appcookie']; // Even though it is called appcookie it is ClientID
return [$parameters, $headers];
}
public function getAuthenticationType(): string
{
return 'basic';
}
public function getDataPriority(): bool
{
return true;
}
/**
* 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 array $settings
*
* @return array|mixed
*/
public function getFormLeadFields($settings = [])
{
return $this->getFormFieldsByObject('Contact', $settings);
}
/**
* @return mixed[]
*/
public function getAvailableLeadFields($settings = []): array
{
$cwFields = [];
if (isset($settings['feature_settings']['objects'])) {
$cwObjects = $settings['feature_settings']['objects'];
} else {
$cwObjects['Contact'] = 'Contact';
}
if (!$this->isAuthorized()) {
return [];
}
switch ($cwObjects) {
case isset($cwObjects['Contact']):
$contactFields = $this->getContactFields();
$cwFields['Contact'] = $this->setFields($contactFields);
break;
case isset($cwObjects['company']):
$company = $this->getCompanyFields();
$cwFields['company'] = $this->setFields($company);
break;
}
return $cwFields;
}
public function setFields($fields): array
{
$cwFields = [];
foreach ($fields as $fieldName => $field) {
if (in_array($field['type'], ['string', 'boolean', 'ref'])) {
$cwFields[$fieldName] = [
'type' => $field['type'],
'label' => ucfirst($fieldName),
'required' => $field['required'],
];
}
}
return $cwFields;
}
/**
* @param \Mautic\PluginBundle\Integration\Form|FormBuilder $builder
* @param array $data
* @param string $formArea
*/
public function appendToForm(&$builder, $data, $formArea): void
{
if ('features' == $formArea) {
$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(
'objects',
ChoiceType::class,
[
'choices' => [
'mautic.connectwise.object.contact' => 'Contact',
'mautic.connectwise.object.company' => 'company',
],
'expanded' => true,
'multiple' => true,
'label' => 'mautic.connectwise.form.objects_to_pull_from',
'label_attr' => ['class' => ''],
'placeholder' => false,
'required' => false,
]
);
}
if ('integration' == $formArea) {
if ($this->isAuthorized()) {
$builder->add(
'push_activities',
YesNoButtonGroupType::class,
[
'label' => 'mautic.plugin.config.push.activities',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
],
'data' => (!isset($data['push_activities'])) ? true : $data['push_activities'],
'required' => false,
]
);
$builder->add(
'campaign_task',
IntegrationCampaignsTaskType::class,
[
'label' => false,
'attr' => [
'data-hide-on' => '{"campaignevent_properties_config_push_activities_0":"checked"}',
],
'data' => $data['campaign_task'] ?? [],
]);
}
}
}
/**
* @return array of company fields for connectwise
*/
public function getCompanyFields(): array
{
return [
'identifier' => ['type' => 'string', 'required' => true],
'name' => ['type' => 'string', 'required' => true],
'addressLine1' => ['type' => 'string', 'required' => false],
'addressLine2' => ['type' => 'string', 'required' => false],
'city' => ['type' => 'string', 'required' => false],
'state' => ['type' => 'string', 'required' => false],
'zip' => ['type' => 'string', 'required' => false],
'phoneNumber' => ['type' => 'string', 'required' => false],
'faxNumber' => ['type' => 'string', 'required' => false],
'website' => ['type' => 'string', 'required' => false],
'territoryId' => ['type' => 'string', 'required' => false],
'marketId' => ['type' => 'string', 'required' => false],
'accountNumber' => ['type' => 'string', 'required' => false],
'dateAcquired' => ['type' => 'string', 'required' => false],
'annualRevenue' => ['type' => 'string', 'required' => false],
'numberOfEmployees' => ['type' => 'string', 'required' => false],
'leadSource' => ['type' => 'string', 'required' => false],
'leadFlag' => ['type' => 'boolean', 'required' => false],
'unsubscribeFlag' => ['type' => 'boolean', 'required' => false],
'calendarId' => ['type' => 'string', 'required' => false],
'userDefinedField1' => ['type' => 'string', 'required' => false],
'userDefinedField2' => ['type' => 'string', 'required' => false],
'userDefinedField3' => ['type' => 'string', 'required' => false],
'userDefinedField4' => ['type' => 'string', 'required' => false],
'userDefinedField5' => ['type' => 'string', 'required' => false],
'userDefinedField6' => ['type' => 'string', 'required' => false],
'userDefinedField7' => ['type' => 'string', 'required' => false],
'userDefinedField8' => ['type' => 'string', 'required' => false],
'userDefinedField9' => ['type' => 'string', 'required' => false],
'userDefinedField10' => ['type' => 'string', 'required' => false],
'vendorIdentifier' => ['type' => 'string', 'required' => false],
'taxIdentifier' => ['type' => 'string', 'required' => false],
'invoiceToEmailAddress' => ['type' => 'string', 'required' => false],
'invoiceCCEmailAddress' => ['type' => 'string', 'required' => false],
'deletedFlag' => ['type' => 'boolean', 'required' => false],
'dateDeleted' => ['type' => 'string', 'required' => false],
'deletedBy' => ['type' => 'string', 'required' => false],
// todo 'customFields' => 'array',
];
}
/**
* @return array of contact fields for connectwise
*/
public function getContactFields(): array
{
return [
'firstName' => ['type' => 'string', 'required' => true],
'lastName' => ['type' => 'string', 'required' => false],
'type' => ['type' => 'string', 'required' => false],
'company' => ['type' => 'ref', 'required' => false, 'value' => 'name'],
'addressLine1' => ['type' => 'string', 'required' => false],
'addressLine2' => ['type' => 'string', 'required' => false],
'city' => ['type' => 'string', 'required' => false],
'state' => ['type' => 'string', 'required' => false],
'zip' => ['type' => 'string', 'required' => false],
'country' => ['type' => 'string', 'required' => false],
'inactiveFlag' => ['type' => 'string', 'required' => false],
'securityIdentifier' => ['type' => 'string', 'required' => false],
'managerContactId' => ['type' => 'string', 'required' => false],
'assistantContactId' => ['type' => 'string', 'required' => false],
'title' => ['type' => 'string', 'required' => false],
'school' => ['type' => 'string', 'required' => false],
'nickName' => ['type' => 'string', 'required' => false],
'marriedFlag' => ['type' => 'boolean', 'required' => false],
'childrenFlag' => ['type' => 'boolean', 'required' => false],
'significantOther' => ['type' => 'string', 'required' => false],
'portalPassword' => ['type' => 'string', 'required' => false],
'portalSecurityLevel' => ['type' => 'string', 'required' => false],
'disablePortalLoginFlag' => ['type' => 'boolean', 'required' => false],
'unsubscribeFlag' => ['type' => 'boolean', 'required' => false],
'userDefinedField1' => ['type' => 'string', 'required' => false],
'userDefinedField2' => ['type' => 'string', 'required' => false],
'userDefinedField3' => ['type' => 'string', 'required' => false],
'userDefinedField4' => ['type' => 'string', 'required' => false],
'userDefinedField5' => ['type' => 'string', 'required' => false],
'userDefinedField6' => ['type' => 'string', 'required' => false],
'userDefinedField7' => ['type' => 'string', 'required' => false],
'userDefinedField8' => ['type' => 'string', 'required' => false],
'userDefinedField9' => ['type' => 'string', 'required' => false],
'userDefinedField10' => ['type' => 'string', 'required' => false],
'gender' => ['type' => 'string', 'required' => false],
'birthDay' => ['type' => 'string', 'required' => false],
'anniversary' => ['type' => 'string', 'required' => false],
'presence' => ['type' => 'string', 'required' => false],
'mobileGuid' => ['type' => 'string', 'required' => false],
'facebookUrl' => ['type' => 'string', 'required' => false],
'twitterUrl' => ['type' => 'string', 'required' => false],
'linkedInUrl' => ['type' => 'string', 'required' => false],
'defaultBillingFlag' => ['type' => 'boolean', 'required' => false],
'communicationItems' => [
'type' => 'array',
'required' => false,
'items' => [
'name' => ['type' => 'name'],
'value' => 'value',
'keys' => ['Email', 'Direct', 'Fax', 'Cell'],
],
],
'Direct' => ['type' => 'string', 'required' => false, 'configOnly' => true],
'Cell' => ['type' => 'string', 'required' => false, 'configOnly' => true],
'Email' => ['type' => 'string', 'required' => true, 'configOnly' => true],
'Fax' => ['type' => 'string', 'required' => false, 'configOnly' => true],
];
}
/**
* Get Contacts from connectwise.
*
* @param array $params
*/
public function getLeads($params = [], $query = null, &$executed = null, $result = [], $object = 'Contact'): int
{
return $this->getRecords($params, $object);
}
/**
* Get Companies from connectwise.
*/
public function getCompanies(array $params = []): int
{
return $this->getRecords($params, 'company');
}
public function getRecords($params, $object): int
{
if (!$this->isAuthorized()) {
return 0;
}
$page = 1;
$executed = 0;
$integrationEntities = [];
try {
while ($records = ('Contact' == $object)
? $this->getApiHelper()->getContacts($params, $page)
: $this->getApiHelper()->getCompanies($params, $page)) {
$mauticReferenceObject = ('Contact' == $object) ? 'lead' : 'company';
foreach ($records as $record) {
if (is_array($record)) {
$id = $record['id'];
$formattedData = $this->amendLeadDataBeforeMauticPopulate($record, $object);
$entity = ('Contact' == $object)
? $this->getMauticLead($formattedData)
: $this->getMauticCompany(
$formattedData,
'company'
);
if ($entity) {
$integrationEntities[] = $this->saveSyncedData($entity, $object, $mauticReferenceObject, $id);
$this->em->detach($entity);
unset($entity);
++$executed;
}
}
}
if ($integrationEntities) {
$this->em->getRepository(IntegrationEntity::class)->saveEntities($integrationEntities);
$this->integrationEntityModel->getRepository()->detachEntities($integrationEntities);
}
// No use checking the next page if there are less records than the requested page size
if (count($records) < self::PAGESIZE) {
break;
}
++$page;
}
return $executed;
} catch (\Exception $e) {
if (404 !== $e->getCode()) {
$this->logIntegrationError($e);
}
}
return $executed;
}
/**
* Ammend mapped lead data before creating to Mautic.
*
* @return mixed[]
*/
public function amendLeadDataBeforeMauticPopulate($data, $object): array
{
$fieldsValues = [];
if (empty($data)) {
return $fieldsValues;
}
if ('Contact' == $object) {
$fields = $this->getContactFields();
} else {
$fields = $this->getCompanyFields();
}
foreach ($data as $key => $field) {
if (isset($fields[$key])) {
$name = $key;
if ('array' == $fields[$key]['type']) {
$items = $fields[$key]['items'];
foreach ($field as $item) {
if (is_array($item[key($items['name'])])) {
foreach ($item[key($items['name'])] as $nameKey => $nameField) {
if ($nameKey == $items['name'][key($items['name'])]) {
$name = $nameField;
}
}
}
$fieldsValues[$name] = $item[$items['value']];
}
} elseif ('ref' == $fields[$key]['type']) {
$fieldsValues[$name] = $field[$fields[$key]['value']];
} else {
$fieldsValues[$name] = $field;
}
}
}
if (isset($data['id'])) {
$fieldsValues['id'] = $data['id'];
}
return $fieldsValues;
}
public function saveSyncedData($entity, $object, $mauticObjectReference, $integrationEntityId): IntegrationEntity
{
/** @var IntegrationEntityRepository $integrationEntityRepo */
$integrationEntityRepo = $this->em->getRepository(IntegrationEntity::class);
$integrationEntities = $integrationEntityRepo->getIntegrationEntities(
$this->getName(),
$object,
$mauticObjectReference,
[$entity->getId()]
);
if ($integrationEntities) {
$integrationEntity = reset($integrationEntities);
$integrationEntity->setLastSyncDate(new \DateTime());
} else {
$integrationEntity = new IntegrationEntity();
$integrationEntity->setDateAdded(new \DateTime());
$integrationEntity->setIntegration($this->getName());
$integrationEntity->setIntegrationEntity($object);
$integrationEntity->setIntegrationEntityId($integrationEntityId);
$integrationEntity->setInternalEntity($mauticObjectReference);
$integrationEntity->setInternalEntityId($entity->getId());
}
return $integrationEntity;
}
/**
* @param array|Lead $lead
* @param array $config
*
* @throws ApiErrorException
*/
public function pushLead($lead, $config = []): bool
{
$config = $this->mergeConfigToFeatureSettings($config);
$personFound = false;
$leadPushed = false;
$object = 'Contact';
if (empty($config['leadFields']) || !$lead->getEmail()) {
return $leadPushed;
}
// findLead first
$cwContactExists = $this->getApiHelper()->getContacts(['Email' => $lead->getEmail()]);
if (!empty($cwContactExists)) {
$personFound = true;
}
$personData = [];
try {
if ($personFound) {
foreach ($cwContactExists as $cwContact) { // go through array of contacts found since Connectwise lets you duplicate records with same email address
$mappedData = $this->getMappedFields($object, $lead, $personFound, $config, $cwContact);
if (!empty($mappedData)) {
$personData = $this->getApiHelper()->updateContact($mappedData, $cwContact['id']);
} else {
$personData['id'] = $cwContact['id'];
}
}
} else {
$mappedData = $this->getMappedFields($object, $lead, $personFound, $config);
$personData = $this->getApiHelper()->createContact($mappedData);
}
if (!empty($personData['id'])) {
$id = $personData['id'];
$integrationEntities[] = $this->saveSyncedData($lead, $object, 'lead', $id);
if (isset($config['push_activities']) and true == $config['push_activities']) {
$savedEntity = $this->createActivity($config['campaign_task'], $id, $lead->getId());
if ($savedEntity instanceof IntegrationEntity) {
$integrationEntities[] = $savedEntity;
}
}
$this->em->getRepository(IntegrationEntity::class)->saveEntities($integrationEntities);
$this->integrationEntityModel->getRepository()->detachEntities($integrationEntities);
$leadPushed = true;
}
} catch (\Exception $e) {
if ($e instanceof ApiErrorException) {
$e->setContact($lead);
}
$this->logIntegrationError($e);
}
return $leadPushed;
}
public function getMappedFields($object, $lead, $personFound, $config, $cwContactData = []): array
{
$fieldsToUpdateInCW = isset($config['update_mautic']) && $personFound ? array_keys($config['update_mautic'], 1) : [];
$objectFields = $this->prepareFieldsForPush($this->getContactFields());
$leadFields = $config['leadFields'];
$cwContactExists = $this->amendLeadDataBeforeMauticPopulate($cwContactData, $object);
$communicationItems = $cwContactData['communicationItems'] ?? [];
$leadFields = array_diff_key($leadFields, array_flip($fieldsToUpdateInCW));
$leadFields = $this->getBlankFieldsToUpdate($leadFields, $cwContactExists, $objectFields, $config);
return $this->populateLeadData(
$lead,
[
'leadFields' => $leadFields,
'object' => 'Contact',
'feature_settings' => [
'objects' => $config['objects'],
],
'update' => $personFound,
'communicationItems' => $communicationItems,
]
);
}
/**
* Match lead data with integration fields.
*/
public function populateLeadData($lead, $config = []): array
{
if ($lead instanceof Lead) {
$fields = $lead->getFields(true);
} else {
$fields = $lead;
}
$leadFields = $config['leadFields'];
if (empty($leadFields)) {
return [];
}
$availableFields = $this->getContactFields();
$unknown = $this->translator->trans('mautic.integration.form.lead.unknown');
$matched = [];
foreach ($availableFields as $key => $field) {
$integrationKey = $matchIntegrationKey = $key;
if (isset($field['configOnly'])) {
continue;
}
if ('communicationItems' == $integrationKey) {
$communicationItems = [];
foreach ($field['items']['keys'] as $keyItem => $item) {
$defaultValue = [];
$keyExists = false;
if (isset($leadFields[$item])) {
if ('Email' == $item) {
$defaultValue = ['defaultFlag' => true];
}
$mauticKey = $leadFields[$item];
if (isset($fields[$mauticKey]) && !empty($fields[$mauticKey]['value'])) {
foreach ($config['communicationItems'] as $key => $ci) {
if ($ci['type']['id'] == $keyItem + 1) {
$config['communicationItems'][$key]['value'] = $fields[$mauticKey]['value'];
$keyExists = true;
}
}
if (!$keyExists) {
$type = [
'type' => ['id' => $keyItem + 1, 'name' => $item], ];
$values = array_merge(['value' => $this->cleanPushData($fields[$mauticKey]['value'])], $defaultValue);
$communicationItems[] = array_merge($type, $values);
}
}
}
}
if ($config['update']) {
$communicationItems = array_merge($config['communicationItems'], $communicationItems);
}
if (!empty($communicationItems)) {
$matched[$integrationKey] = $communicationItems;
}
}
if ('company' === $integrationKey && !empty($fields['company']['value'])) {
try {
$foundCompanies = $this->getApiHelper()->getCompanies([
'conditions' => [
sprintf('Name = "%s"', $fields['company']['value']),
],
]);
$matched['company'] = ['identifier' => $foundCompanies[0]['identifier']];
} catch (ApiErrorException) {
// No matching companies were found
}
continue;
}
if (isset($leadFields[$integrationKey])) {
$mauticKey = $leadFields[$integrationKey];
if (isset($fields[$mauticKey]) && !empty($fields[$mauticKey]['value'])) {
$matched[$matchIntegrationKey] = $this->cleanPushData($fields[$mauticKey]['value']);
}
}
if (!empty($field['required']) && empty($matched[$matchIntegrationKey]) && !$config['update']) {
$matched[$matchIntegrationKey] = $unknown;
}
}
if ($config['update']) {
$updateFields = [];
foreach ($matched as $key => $field) {
$updateFields[] = [
'op' => 'replace',
'path' => $key,
'value' => $field,
];
}
$matched = $updateFields;
}
return $matched;
}
/**
* @param array $objects
*
* @return array
*/
protected function cleanPriorityFields($fieldsToUpdate, $objects = null)
{
if (null === $objects) {
$objects = ['Leads', 'Contacts'];
}
if (isset($fieldsToUpdate['leadFields']) && is_array($objects)) {
// Pass in the whole config
$fields = $fieldsToUpdate['leadFields'];
} else {
$fields = array_flip($fieldsToUpdate);
}
return $this->prepareFieldsForSync($fields, $fieldsToUpdate, $objects);
}
/**
* @param string $priorityObject
*
* @return mixed
*/
protected function getPriorityFieldsForMautic($config, $object = null, $priorityObject = 'mautic')
{
if ('company' == $object) {
$priority = parent::getPriorityFieldsForMautic($config, $object, 'mautic_company');
$fields = array_intersect_key($config['companyFields'], $priority);
} else {
$fields = parent::getPriorityFieldsForMautic($config, $object, $priorityObject);
}
return ($object && isset($fields[$object])) ? $fields[$object] : $fields;
}
/**
* @return array
*
* @throws \Exception
*/
public function getCampaigns()
{
$campaigns = [];
try {
$campaigns = $this->getApiHelper()->getCampaigns();
} catch (\Exception $e) {
$this->logIntegrationError($e);
}
return $campaigns;
}
/**
* @throws \Exception
*/
public function getCampaignChoices(): array
{
$choices = [];
$campaigns = $this->getCampaigns();
foreach ($campaigns as $campaign) {
if (isset($campaign['id'])) {
$choices[] = [
'value' => $campaign['id'],
'label' => $campaign['name'],
];
}
}
return $choices;
}
public function getCampaignMembers($campaignId): bool
{
if (!$this->isAuthorized()) {
return false;
}
try {
$page = 1;
while ($campaignsMembersResults = $this->getApiHelper()->getCampaignMembers($campaignId, $page)) {
$campaignMemberObject = new IntegrationObject('CampaignMember', 'lead');
$recordList = $this->getRecordList($campaignsMembersResults, 'id');
$contacts = (array) $this->integrationEntityModel->getSyncedRecords(new IntegrationObject('Contact', 'lead'), $this->getName(), $recordList);
$existingContactsIds = array_column(array_filter(
$contacts,
fn ($contact): bool => 'lead' === $contact['internal_entity']
), 'integration_entity_id');
$contactsToFetch = array_diff_key($recordList, array_flip($existingContactsIds));
if (!empty($contactsToFetch)) {
$listOfContactsToFetch = implode(',', array_keys($contactsToFetch));
$params['Ids'] = $listOfContactsToFetch;
$this->getLeads($params);
}
$saveCampaignMembers = array_merge($existingContactsIds, array_keys($contactsToFetch));
$this->saveCampaignMembers($saveCampaignMembers, $campaignMemberObject, $campaignId);
if (count($campaignsMembersResults) < self::PAGESIZE) {
// No use continuing as we have less results than page size
break;
}
++$page;
}
return true;
} catch (\Exception $e) {
if (404 !== $e->getCode()) {
$this->logIntegrationError($e);
}
}
return false;
}
public function saveCampaignMembers($allCampaignMembers, $campaignMemberObject, $campaignId): void
{
if (empty($allCampaignMembers)) {
return;
}
$persistEntities = [];
$recordList = $this->getRecordList($allCampaignMembers);
$mauticObject = new IntegrationObject('Contact', 'lead');
$contacts = $this->integrationEntityModel->getSyncedRecords($mauticObject, $this->getName(), $recordList);
// first find existing campaign members.
foreach ($contacts as $contact) {
$existingCampaignMember = $this->integrationEntityModel->getSyncedRecords($campaignMemberObject, $this->getName(), $campaignId, $contact['internal_entity_id']);
if (empty($existingCampaignMember)) {
$persistEntities[] = $this->createIntegrationEntity(
$campaignMemberObject->getType(),
$campaignId,
$campaignMemberObject->getInternalType(),
$contact['internal_entity_id'],
[],
false
);
}
}
if ($persistEntities) {
$this->em->getRepository(IntegrationEntity::class)->saveEntities($persistEntities);
$this->integrationEntityModel->getRepository()->detachEntities($persistEntities);
unset($persistEntities);
}
}
public function getRecordList($records, $index = null): array
{
$recordList = [];
foreach ($records as $record) {
if ($index && isset($record[$index])) {
$record = $record[$index];
}
$recordList[$record] = [
'id' => $record,
];
}
return $recordList;
}
/**
* @throws ApiErrorException
*/
public function getActivityTypes(): array
{
$activities = [];
$cwActivities = $this->getApiHelper()->getActivityTypes();
foreach ($cwActivities as $cwActivity) {
if (isset($cwActivity['id'])) {
$activities[$cwActivity['id']] = $cwActivity['name'];
}
}
return $activities;
}
/**
* @throws ApiErrorException
*/
public function getMembers(): array
{
$members = [];
$cwMembers = $this->getApiHelper()->getMembers();
foreach ($cwMembers as $cwMember) {
if (isset($cwMember['id'])) {
$members[$cwMember['id']] = $cwMember['identifier'];
}
}
return $members;
}
/**
* @throws ApiErrorException
*/
public function createActivity($config, $cwContactId, $leadId): ?IntegrationEntity
{
if ($cwContactId and !empty($config['activity_name'])) {
$activity = [
'name' => $config['activity_name'],
'type' => ['id' => $config['campaign_activity_type']],
'assignTo' => ['id' => $config['campaign_members']],
'contact' => ['id' => $cwContactId],
];
$activities = $this->getApiHelper()->postActivity($activity);
if (isset($activities['id'])) {
return $this->createIntegrationEntity('Activities', $activities['id'], 'lead', $leadId, null, false);
}
}
return null;
}
}

View File

@@ -0,0 +1,659 @@
<?php
namespace MauticPlugin\MauticCrmBundle\Integration;
use Doctrine\ORM\EntityManager;
use Mautic\CoreBundle\Helper\CacheStorageHelper;
use Mautic\CoreBundle\Helper\EncryptionHelper;
use Mautic\CoreBundle\Helper\PathsHelper;
use Mautic\CoreBundle\Model\NotificationModel;
use Mautic\LeadBundle\DataObject\LeadManipulator;
use Mautic\LeadBundle\Entity\Company;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Field\FieldsWithUniqueIdentifier;
use Mautic\LeadBundle\Helper\IdentifyCompanyHelper;
use Mautic\LeadBundle\Model\CompanyModel;
use Mautic\LeadBundle\Model\DoNotContact as DoNotContactModel;
use Mautic\LeadBundle\Model\FieldModel;
use Mautic\LeadBundle\Model\LeadModel;
use Mautic\PluginBundle\Entity\Integration;
use Mautic\PluginBundle\Integration\AbstractIntegration;
use Mautic\PluginBundle\Model\IntegrationEntityModel;
use MauticPlugin\MauticCrmBundle\Api\CrmApi;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
abstract class CrmAbstractIntegration extends AbstractIntegration
{
protected $auth;
protected $helper;
public function __construct(EventDispatcherInterface $eventDispatcher, CacheStorageHelper $cacheStorageHelper, EntityManager $entityManager, RequestStack $requestStack, RouterInterface $router, TranslatorInterface $translator, LoggerInterface $logger, EncryptionHelper $encryptionHelper, LeadModel $leadModel, CompanyModel $companyModel, PathsHelper $pathsHelper, NotificationModel $notificationModel, FieldModel $fieldModel, IntegrationEntityModel $integrationEntityModel, DoNotContactModel $doNotContact, protected FieldsWithUniqueIdentifier $fieldsWithUniqueIdentifier)
{
parent::__construct($eventDispatcher, $cacheStorageHelper, $entityManager, $requestStack, $router, $translator, $logger, $encryptionHelper, $leadModel, $companyModel, $pathsHelper, $notificationModel, $fieldModel, $integrationEntityModel, $doNotContact, $fieldsWithUniqueIdentifier);
}
public function setIntegrationSettings(Integration $settings): void
{
// make sure URL does not have ending /
$keys = $this->getDecryptedApiKeys($settings);
if (isset($keys['url']) && str_ends_with($keys['url'], '/')) {
$keys['url'] = substr($keys['url'], 0, -1);
$this->encryptAndSetApiKeys($keys, $settings);
}
parent::setIntegrationSettings($settings);
}
/**
* @return string
*/
public function getAuthenticationType()
{
return 'rest';
}
/**
* @return array
*/
public function getSupportedFeatures()
{
return ['push_lead', 'get_leads'];
}
/**
* @param Lead|array $lead
* @param array $config
*
* @return array|bool
*/
public function pushLead($lead, $config = [])
{
$config = $this->mergeConfigToFeatureSettings($config);
if (empty($config['leadFields'])) {
return [];
}
$mappedData = $this->populateLeadData($lead, $config);
$this->amendLeadDataBeforePush($mappedData);
if (empty($mappedData)) {
return false;
}
try {
if ($this->isAuthorized()) {
$this->getApiHelper()->createLead($mappedData, $lead);
return true;
}
} catch (\Exception $e) {
$this->logIntegrationError($e);
}
return false;
}
/**
* @param array $params
*/
public function getLeads($params, $query, &$executed, $result = [], $object = 'Lead')
{
$executed = null;
$query = $this->getFetchQuery($params);
try {
if ($this->isAuthorized()) {
$result = $this->getApiHelper()->getLeads($query);
return $this->amendLeadDataBeforeMauticPopulate($result, $object);
}
} catch (\Exception $e) {
$this->logIntegrationError($e);
}
return $executed;
}
/**
* Amend mapped lead data before pushing to CRM.
*/
public function amendLeadDataBeforePush(&$mappedData): void
{
}
/**
* get query to fetch lead data.
*/
public function getFetchQuery($config)
{
return null;
}
/**
* Ammend mapped lead data before creating to Mautic.
*/
public function amendLeadDataBeforeMauticPopulate($data, $object)
{
return null;
}
/**
* @return string
*/
public function getClientIdKey()
{
return 'client_id';
}
/**
* @return string
*/
public function getClientSecretKey()
{
return 'client_secret';
}
public function sortFieldsAlphabetically(): bool
{
return false;
}
/**
* Get the API helper.
*
* @return CrmApi
*/
public function getApiHelper()
{
if (empty($this->helper)) {
$class = '\\MauticPlugin\\MauticCrmBundle\\Api\\'.$this->getName().'Api';
$this->helper = new $class($this);
}
return $this->helper;
}
/**
* @param array $params
*/
public function pushLeadActivity($params = [])
{
return null;
}
/**
* @param int|string[]|int[] $leadId
*
* @return mixed[]
*/
public function getLeadData(?\DateTime $startDate = null, ?\DateTime $endDate = null, $leadId = [])
{
$leadIds = (!is_array($leadId)) ? [$leadId] : $leadId;
$leadActivity = [];
$config = $this->mergeConfigToFeatureSettings();
if (!isset($config['activityEvents'])) {
// BC for pre 2.11.0
$config['activityEvents'] = ['point.gained', 'form.submitted', 'email.read'];
} elseif (empty($config['activityEvents'])) {
// Inclusive filter meaning we only send events if something is selected
return [];
}
$filters = [
'search' => '',
'includeEvents' => $config['activityEvents'],
'excludeEvents' => [],
];
if ($startDate) {
$filters['dateFrom'] = $startDate;
$filters['dateTo'] = $endDate;
}
foreach ($leadIds as $leadId) {
$i = 0;
$activity = [];
$lead = $this->em->getReference(Lead::class, $leadId);
$page = 1;
while (true) {
$engagements = $this->leadModel->getEngagements($lead, $filters, null, $page, 100, false);
$events = $engagements[0]['events'];
if (empty($events)) {
break;
}
// inject lead into events
foreach ($events as $event) {
$link = '';
$label = $event['eventLabel'] ?? $event['eventType'];
if (is_array($label)) {
$link = $label['href'];
$label = $label['label'];
}
$activity[$i]['eventType'] = $event['eventType'];
$activity[$i]['name'] = $event['eventType'].' - '.$label;
$activity[$i]['description'] = $link;
$activity[$i]['dateAdded'] = $event['timestamp'];
$id = match ($event['eventType']) {
'point.gained' => str_replace($event['eventType'], 'pointChange', $event['eventId']),
'form.submitted' => str_replace($event['eventType'], 'formSubmission', $event['eventId']),
'email.read' => str_replace($event['eventType'], 'emailStat', $event['eventId']),
default => str_replace(' ', '', ucwords(str_replace('.', ' ', $event['eventId']))),
};
$activity[$i]['id'] = $id;
++$i;
}
++$page;
// Lots of entities will be loaded into memory while compiling these events so let's prevent memory overload by clearing the EM
$entityToNotDetach = [Integration::class, \Mautic\PluginBundle\Entity\Plugin::class];
$loadedEntities = $this->em->getUnitOfWork()->getIdentityMap();
foreach ($loadedEntities as $name => $loadedEntitySet) {
if (!in_array($name, $entityToNotDetach, true)) {
continue;
}
foreach ($loadedEntitySet as $loadedEntity) {
$this->em->detach($loadedEntity);
}
}
}
$leadActivity[$leadId] = [
'records' => $activity,
];
unset($activity);
}
return $leadActivity;
}
/**
* @return Company|null
*/
public function getMauticCompany($data, $object = null)
{
if (is_object($data)) {
// Convert to array in all levels
$data = json_encode(json_decode($data, true));
} elseif (is_string($data)) {
// Assume JSON
$data = json_decode($data, true);
}
$config = $this->mergeConfigToFeatureSettings([]);
$matchedFields = $this->populateMauticLeadData($data, $config, 'company');
$companyFieldTypes = $this->fieldModel->getFieldListWithProperties('company');
foreach ($matchedFields as $companyField => $value) {
if (isset($companyFieldTypes[$companyField]['type'])) {
switch ($companyFieldTypes[$companyField]['type']) {
case 'text':
$matchedFields[$companyField] = substr($value, 0, 255);
break;
case 'date':
$date = new \DateTime($value);
$matchedFields[$companyField] = $date->format('Y-m-d');
break;
case 'datetime':
$date = new \DateTime($value);
$matchedFields[$companyField] = $date->format('Y-m-d H:i:s');
break;
}
}
}
// Default to new company
$company = new Company();
$existingCompany = IdentifyCompanyHelper::identifyLeadsCompany($matchedFields, null, $this->companyModel);
if (!empty($existingCompany[2])) {
$company = $existingCompany[2];
}
if (!empty($existingCompany[2])) {
$fieldsToUpdate = $this->getPriorityFieldsForMautic($config, $object, 'mautic_company');
$fieldsToUpdate = array_intersect_key($config['companyFields'], $fieldsToUpdate);
$matchedFields = array_intersect_key($matchedFields, array_flip($fieldsToUpdate));
} else {
$matchedFields = $this->hydrateCompanyName($matchedFields);
// If we don't have an company name, don't create the company because it'll result in what looks like an "empty" company
if (empty($matchedFields['companyname'])) {
return null;
}
}
$this->companyModel->setFieldValues($company, $matchedFields, false);
$this->companyModel->saveEntity($company, false);
return $company;
}
/**
* Create or update existing Mautic lead from the integration's profile data.
*
* @param mixed $data Profile data from integration
* @param bool|true $persist Set to false to not persist lead to the database in this method
* @param array|null $socialCache
* @param mixed|null $identifiers
* @param string|null $object
*
* @return Lead
*/
public function getMauticLead($data, $persist = true, $socialCache = null, $identifiers = null, $object = null)
{
if (is_object($data)) {
// Convert to array in all levels
$data = json_encode(json_decode($data, true));
} elseif (is_string($data)) {
// Assume JSON
$data = json_decode($data, true);
}
$config = $this->mergeConfigToFeatureSettings([]);
// Match that data with mapped lead fields
$matchedFields = $this->populateMauticLeadData($data, $config);
if (empty($matchedFields)) {
return;
}
// Find unique identifier fields used by the integration
/** @var LeadModel $leadModel */
$leadModel = $this->leadModel;
$uniqueLeadFields = $this->fieldsWithUniqueIdentifier->getFieldsWithUniqueIdentifier();
$uniqueLeadFieldData = [];
$leadFieldTypes = $this->fieldModel->getFieldListWithProperties();
foreach ($matchedFields as $leadField => $value) {
if (array_key_exists($leadField, $uniqueLeadFields) && !empty($value)) {
$uniqueLeadFieldData[$leadField] = $value;
}
$fieldType = $leadFieldTypes[$leadField]['type'] ?? '';
$matchedFields[$leadField] = $this->limitString($value, $fieldType);
}
if (count(array_diff_key($uniqueLeadFields, $matchedFields)) == count($uniqueLeadFields)) {
// return if uniqueIdentifiers have no data set to avoid duplicating leads.
$this->logger->debug('getMauticLead: No unique identifiers', [
'uniqueLeadFields' => $uniqueLeadFields,
'matchedFields' => $matchedFields,
]);
return;
}
// Default to new lead
$lead = new Lead();
$lead->setNewlyCreated(true);
if (count($uniqueLeadFieldData)) {
$existingLeads = $this->em->getRepository(Lead::class)
->getLeadsByUniqueFields($uniqueLeadFieldData);
if (!empty($existingLeads)) {
$lead = array_shift($existingLeads);
}
}
$leadFields = $this->cleanPriorityFields($config, $object);
if (!$lead->isNewlyCreated()) {
$params = $this->commandParameters;
$this->getLeadDoNotContactByDate('email', $matchedFields, $object, $lead, $data, $params);
// Use only prioirty fields if updating
$fieldsToUpdateInMautic = $this->getPriorityFieldsForMautic($config, $object, 'mautic');
if (empty($fieldsToUpdateInMautic)) {
$this->logger->debug('getMauticLead: No fields to update in Mautic', ['config' => $config, 'object' => $object]);
return;
}
$fieldsToUpdateInMautic = array_intersect_key($leadFields, $fieldsToUpdateInMautic);
$matchedFields = array_intersect_key($matchedFields, array_flip($fieldsToUpdateInMautic));
if (isset($config['updateBlanks']) && isset($config['updateBlanks'][0]) && 'updateBlanks' == $config['updateBlanks'][0]) {
$matchedFields = $this->getBlankFieldsToUpdateInMautic($matchedFields, $lead->getFields(true), $leadFields, $data, $object);
}
}
$leadModel->setFieldValues($lead, $matchedFields, false, false);
if (!empty($socialCache)) {
// Update the social cache
$leadSocialCache = $lead->getSocialCache();
if (!isset($leadSocialCache[$this->getName()])) {
$leadSocialCache[$this->getName()] = [];
}
$leadSocialCache[$this->getName()] = array_merge($leadSocialCache[$this->getName()], $socialCache);
// Check for activity while here
if (null !== $identifiers && in_array('public_activity', $this->getSupportedFeatures())) {
$this->getPublicActivity($identifiers, $leadSocialCache[$this->getName()]);
}
$lead->setSocialCache($leadSocialCache);
}
// Update the internal info integration object that has updated the record
if (isset($data['internal'])) {
$internalInfo = $lead->getInternal();
$internalInfo[$this->getName()] = $data['internal'];
$lead->setInternal($internalInfo);
}
// Update the owner if it matches (needs to be set by the integration) when fetching the data
if (isset($data['owner_email']) && isset($config['updateOwner']) && isset($config['updateOwner'][0])
&& 'updateOwner' == $config['updateOwner'][0]
) {
if ($mauticUser = $this->em->getRepository(\Mautic\UserBundle\Entity\User::class)->findOneBy(['email' => $data['owner_email']])) {
$lead->setOwner($mauticUser);
}
}
if ($persist && !empty($lead->getChanges(true))) {
// Only persist if instructed to do so as it could be that calling code needs to manipulate the lead prior to executing event listeners
$lead->setManipulator(new LeadManipulator(
'plugin',
$this->getName(),
null,
$this->getDisplayName()
));
$leadModel->saveEntity($lead, false);
}
return $lead;
}
/**
* @return array|mixed
*/
protected function getFormFieldsByObject($object, $settings = [])
{
$settings['feature_settings']['objects'] = [$object => $object];
$fields = ($this->isAuthorized()) ? $this->getAvailableLeadFields($settings) : [];
return $fields[$object] ?? [];
}
/**
* @param string $priorityObject
*
* @return array
*/
protected function getPriorityFieldsForMautic($config, $entityObject = null, $priorityObject = 'mautic')
{
return $this->cleanPriorityFields(
$this->getFieldsByPriority($config, $priorityObject, 1),
$entityObject
);
}
/**
* @param string $priorityObject
*
* @return array
*/
protected function getPriorityFieldsForIntegration($config, $entityObject = null, $priorityObject = 'mautic')
{
return $this->cleanPriorityFields(
$this->getFieldsByPriority($config, $priorityObject, 0),
$entityObject
);
}
/**
* @param string $priorityObject
*
* @return array
*/
protected function getFieldsByPriority(array $config, $priorityObject, $direction)
{
return isset($config['update_'.$priorityObject]) ? array_keys($config['update_'.$priorityObject], $direction) : array_keys($config['leadFields'] ?? []);
}
/**
* @param array $objects
*
* @return array
*/
protected function cleanPriorityFields($fieldsToUpdate, $objects = null)
{
if (!isset($fieldsToUpdate['leadFields'])) {
return $fieldsToUpdate;
}
if (null === $objects || is_array($objects)) {
return $fieldsToUpdate['leadFields'];
}
return $fieldsToUpdate['leadFields'][$objects] ?? $fieldsToUpdate;
}
/**
* @return array
*/
protected function getSyncTimeframeDates(array $params)
{
$fromDate = (isset($params['start'])) ? \DateTime::createFromFormat(\DateTime::ISO8601, $params['start'])->format('Y-m-d H:i:s')
: null;
$toDate = (isset($params['end'])) ? \DateTime::createFromFormat(\DateTime::ISO8601, $params['end'])->format('Y-m-d H:i:s')
: null;
return [$fromDate, $toDate];
}
public function getBlankFieldsToUpdateInMautic($matchedFields, $leadFieldValues, $objectFields, $integrationData, $object = 'Lead')
{
foreach ($objectFields as $integrationField => $mauticField) {
if (isset($leadFieldValues[$mauticField]) && empty($leadFieldValues[$mauticField]['value']) && !empty($integrationData[$integrationField.'__'.$object]) && $this->translator->trans('mautic.integration.form.lead.unknown') !== $integrationData[$integrationField.'__'.$object]) {
$matchedFields[$mauticField] = $integrationData[$integrationField.'__'.$object];
}
}
return $matchedFields;
}
public function getBlankFieldsToUpdate($fields, $sfRecord, $objectFields, $config)
{
// check if update blank fields is selected
if (isset($config['updateBlanks']) && isset($config['updateBlanks'][0])
&& 'updateBlanks' == $config['updateBlanks'][0]
&& !empty($sfRecord)
&& isset($objectFields['required']['fields'])
) {
foreach ($sfRecord as $fieldName => $sfField) {
if (array_key_exists($fieldName, $objectFields['required']['fields'])) {
continue; // this will be treated differently
}
if (empty($sfField) && array_key_exists($fieldName, $objectFields['create']) && !array_key_exists($fieldName, $fields)) {
// map to mautic field
$fields[$fieldName] = $objectFields['create'][$fieldName];
}
}
}
return $fields;
}
/**
* @return array
*/
protected function prepareFieldsForPush($fields)
{
$fieldMappings = [];
$required = [];
$config = $this->mergeConfigToFeatureSettings();
$leadFields = $config['leadFields'];
foreach ($fields as $key => $field) {
if ($field['required']) {
$required[$key] = $field;
}
}
$fieldMappings['required'] = [
'fields' => $required,
];
$fieldMappings['create'] = $leadFields;
return $fieldMappings;
}
/**
* @return array
*/
private function hydrateCompanyName(array $matchedFields)
{
if (!empty($matchedFields['companyname'])) {
return $matchedFields;
}
if (!empty($matchedFields['companywebsite'])) {
$matchedFields['companyname'] = $matchedFields['companywebsite'];
return $matchedFields;
}
// We need something as company name so save whatever we have
if ($firstMatchedField = reset($matchedFields)) {
$matchedFields['companyname'] = $firstMatchedField;
return $matchedFields;
}
return $matchedFields;
}
/**
* Limits the string.
*
* @param mixed $value
* @param string $fieldType
*
* @return mixed
*/
protected function limitString($value, $fieldType = '')
{
// We must not convert boolean values to string, otherwise "false" will be converted to an empty string.
// "False" has to be converted to 0 instead.
if (('text' == $fieldType) && !is_bool($value)) {
return substr($value, 0, 255);
}
return $value;
}
}

View File

@@ -0,0 +1,895 @@
<?php
namespace MauticPlugin\MauticCrmBundle\Integration;
use Mautic\LeadBundle\Entity\Company;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Helper\IdentifyCompanyHelper;
use Mautic\PluginBundle\Entity\IntegrationEntity;
use Mautic\PluginBundle\Entity\IntegrationEntityRepository;
use Mautic\PluginBundle\Exception\ApiErrorException;
use Psr\Http\Message\ResponseInterface;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Output\ConsoleOutput;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilder;
class DynamicsIntegration extends CrmAbstractIntegration
{
public function getName(): string
{
return 'Dynamics';
}
public function getDisplayName(): string
{
return 'Dynamics CRM';
}
public function getSupportedFeatures(): array
{
return ['push_lead', 'get_leads', 'push_leads'];
}
/**
* Return's authentication method such as oauth2, oauth1a, key, etc.
*/
public function getAuthenticationType(): string
{
return 'oauth2';
}
/**
* Return array of key => label elements that will be converted to inputs to
* obtain from the user.
*
* @return array<string, string>
*/
public function getRequiredKeyFields(): array
{
return [
'resource' => 'mautic.integration.dynamics.resource',
'client_id' => 'mautic.integration.dynamics.client_id',
'client_secret' => 'mautic.integration.dynamics.client_secret',
];
}
/**
* @param FormBuilder $builder
* @param array $data
* @param string $formArea
*/
public function appendToForm(&$builder, $data, $formArea): void
{
$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,
]
);
if ('features' === $formArea) {
$builder->add(
'objects',
ChoiceType::class,
[
'choices' => [
'mautic.dynamics.object.contact' => 'contacts',
'mautic.dynamics.object.company' => 'company',
],
'expanded' => true,
'multiple' => true,
'label' => 'mautic.dynamics.form.objects_to_pull_from',
'label_attr' => ['class' => ''],
'placeholder' => false,
'required' => false,
]
);
}
}
public function sortFieldsAlphabetically(): bool
{
return false;
}
/**
* Get the array key for the auth token.
*/
public function getAuthTokenKey(): string
{
return 'access_token';
}
/**
* Get the keys for the refresh token and expiry.
*/
public function getRefreshTokenKeys(): array
{
return ['refresh_token', 'expires_on'];
}
/**
* @return string
*/
public function getApiUrl()
{
return $this->keys['resource'];
}
public function getAccessTokenUrl(): string
{
return 'https://login.microsoftonline.com/common/oauth2/token';
}
public function getAuthenticationUrl(): string
{
return 'https://login.microsoftonline.com/common/oauth2/authorize';
}
public function getAuthLoginUrl(): string
{
$url = parent::getAuthLoginUrl();
return $url.('&resource='.urlencode($this->keys['resource']));
}
/**
* @param bool $inAuthorization
*/
public function getBearerToken($inAuthorization = false)
{
if (!$inAuthorization && isset($this->keys[$this->getAuthTokenKey()])) {
return $this->keys[$this->getAuthTokenKey()];
}
return false;
}
public function getDataPriority(): bool
{
return true;
}
/**
* @return string|array
*/
public function getFormNotes($section)
{
if ('custom' === $section) {
return [
'template' => '@MauticCrm/Integration/dynamics.html.twig',
'parameters' => [
],
];
}
return parent::getFormNotes($section);
}
/**
* @return array
*/
public function populateLeadData($lead, $config = [], $object = 'Contacts')
{
if ('company' === $object) {
$object = 'accounts';
}
$config['object'] = $object;
return parent::populateLeadData($lead, $config);
}
/**
* Get available company fields for choices in the config UI.
*
* @param array $settings
*
* @return array
*/
public function getFormCompanyFields($settings = [])
{
return $this->getFormFieldsByObject('accounts', $settings);
}
/**
* @param array $settings
*
* @return array|mixed
*/
public function getFormLeadFields($settings = [])
{
return $this->getFormFieldsByObject('contacts', $settings);
}
/**
* @param array $settings
*
* @throws ApiErrorException
*/
public function getAvailableLeadFields($settings = []): array
{
$dynamicsFields = [];
$silenceExceptions = $settings['silence_exceptions'] ?? true;
if (isset($settings['feature_settings']['objects'])) {
$dynamicsObjects = $settings['feature_settings']['objects'];
} else {
$settings = $this->settings->getFeatureSettings();
$dynamicsObjects = $settings['objects'] ?? ['contacts'];
}
try {
if ($this->isAuthorized()) {
if (!empty($dynamicsObjects) && is_array($dynamicsObjects)) {
foreach ($dynamicsObjects as $dynamicsObject) {
// Check the cache first
$settings['cache_suffix'] = $cacheSuffix = '.'.$dynamicsObject;
if ($fields = parent::getAvailableLeadFields($settings)) {
$dynamicsFields[$dynamicsObject] = $fields;
continue;
}
$leadObject = $this->getApiHelper()->getLeadFields($dynamicsObject);
if (null === $leadObject || !array_key_exists('value', $leadObject)) {
return [];
}
$fields = $leadObject['value'];
foreach ($fields as $field) {
$type = 'string';
$fieldType = $field['AttributeTypeName']['Value'];
if (in_array($fieldType, [
'LookupType',
'OwnerType',
'PicklistType',
'StateType',
'StatusType',
'UniqueidentifierType',
], true)) {
continue;
} elseif (in_array($fieldType, [
'DoubleType',
'IntegerType',
'MoneyType',
], true)) {
$type = 'int';
} elseif ('Boolean' === $fieldType) {
$type = 'boolean';
} elseif ('DateTimeType' === $fieldType) {
$type = 'datetime';
}
$dynamicsFields[$dynamicsObject][$field['LogicalName']] = [
'type' => $type,
'label' => $field['DisplayName']['UserLocalizedLabel']['Label'],
'dv' => $field['LogicalName'],
'required' => 'ApplicationRequired' === $field['RequiredLevel']['Value'],
];
}
$this->cache->set('leadFields'.$cacheSuffix, $dynamicsFields[$dynamicsObject]);
}
}
}
} catch (ApiErrorException $exception) {
$this->logIntegrationError($exception);
if (!$silenceExceptions) {
throw $exception;
}
return [];
}
return $dynamicsFields;
}
/**
* @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->populateLeadData($lead, $config, 'contacts');
$this->amendLeadDataBeforePush($mappedData);
if (empty($mappedData)) {
return false;
}
try {
if ($this->isAuthorized()) {
$object = 'contacts';
/** @var IntegrationEntityRepository $integrationEntityRepo */
$integrationEntityRepo = $this->em->getRepository(IntegrationEntity::class);
$integrationId = $integrationEntityRepo->getIntegrationsEntityId('Dynamics', $object, 'lead', $lead->getId());
if (!empty($integrationId)) {
$integrationEntityId = $integrationId[0]['integration_entity_id'];
$this->getApiHelper()->updateLead($mappedData, $integrationEntityId);
return $integrationEntityId;
}
/** @var ResponseInterface $response */
$response = $this->getApiHelper()->createLead($mappedData, $lead);
// OData-EntityId: https://clientname.crm.dynamics.com/api/data/v8.2/contacts(9844333b-c955-e711-80f1-c4346bad526c)
$header = $response->getHeader('OData-EntityId');
if (preg_match('/contacts\((.+)\)/', $header, $out)) {
$id = $out[1];
if (empty($integrationId)) {
$integrationEntity = new IntegrationEntity();
$integrationEntity->setDateAdded(new \DateTime());
$integrationEntity->setIntegration('Dynamics');
$integrationEntity->setIntegrationEntity($object);
$integrationEntity->setIntegrationEntityId($id);
$integrationEntity->setInternalEntity('lead');
$integrationEntity->setInternalEntityId($lead->getId());
} else {
$integrationEntity = $integrationEntityRepo->getEntity($integrationId[0]['id']);
}
$integrationEntity->setLastSyncDate(new \DateTime());
$this->em->persist($integrationEntity);
$this->em->flush($integrationEntity);
return $id;
}
return true;
}
} catch (\Exception $e) {
$this->logIntegrationError($e);
}
return false;
}
/**
* @param array $params
* @param array|null $query
*/
public function getLeads($params = [], $query = null, &$executed = null, $result = [], $object = 'contacts'): int
{
if ('Contacts' === $object) {
$object = 'contacts';
}
$executed = 0;
$MAX_RECORDS = 200; // Default max records is 5000
try {
if ($this->isAuthorized()) {
$config = $this->mergeConfigToFeatureSettings();
$fields = $config['leadFields'];
$config['object'] = $object;
$aFields = $this->getAvailableLeadFields($config);
$mappedData = [];
foreach (array_keys($fields) as $k) {
if (isset($aFields[$object][$k])) {
$mappedData[] = $aFields[$object][$k]['dv'];
}
}
$oparams['request_settings']['headers']['Prefer'] = 'odata.maxpagesize='.$MAX_RECORDS;
$oparams['$select'] = implode(',', $mappedData);
if (isset($params['fetchAll'], $params['start']) && !$params['fetchAll']) {
$oparams['$filter'] = sprintf('modifiedon ge %sZ', substr($params['start'], 0, -6));
}
if (isset($params['output']) && $params['output']->getVerbosity() < OutputInterface::VERBOSITY_VERBOSE) {
$progress = new ProgressBar($params['output']);
$progress->start();
}
while (true) {
$data = $this->getApiHelper()->getLeads($oparams);
if (!isset($data['value'])) {
break; // no more data, exit loop
}
$result = $this->amendLeadDataBeforeMauticPopulate($data, $object);
$executed += array_key_exists('value', $data) ? count($data['value']) : count($result);
if (isset($params['output'])) {
if ($params['output']->getVerbosity() >= OutputInterface::VERBOSITY_VERBOSE) {
$params['output']->writeln($result);
} else {
$progress->advance(count($result));
}
}
if (!isset($data['@odata.nextLink'])) {
break; // default exit
}
// prepare next loop
$nextLink = $data['@odata.nextLink'];
$oparams['$skiptoken'] = urldecode(substr($nextLink, strpos($nextLink, '$skiptoken=') + 11));
}
if (isset($params['output']) && $params['output']->getVerbosity() < OutputInterface::VERBOSITY_VERBOSE) {
$progress->finish();
}
}
} catch (\Exception $e) {
$this->logIntegrationError($e);
}
return $executed;
}
/**
* @param array $params
*/
public function getCompanies($params = []): int
{
$executed = 0;
$MAX_RECORDS = 200; // Default max records is 5000
$object = 'company';
try {
if ($this->isAuthorized()) {
$config = $this->mergeConfigToFeatureSettings();
$fields = $config['companyFields'];
$config['object'] = $object;
$aFields = $this->getAvailableLeadFields($config);
$mappedData = [];
if (isset($aFields['company'])) {
$aFields = $aFields['company'];
}
foreach (array_keys($fields) as $k) {
$mappedData[] = $aFields[$k]['dv'];
}
$oparams['request_settings']['headers']['Prefer'] = 'odata.maxpagesize='.$MAX_RECORDS;
$oparams['$select'] = implode(',', $mappedData);
if (isset($params['fetchAll'], $params['start']) && !$params['fetchAll']) {
$oparams['$filter'] = sprintf('modifiedon ge %sZ', substr($params['start'], 0, -6));
}
if (isset($params['output']) && $params['output']->getVerbosity() < OutputInterface::VERBOSITY_VERBOSE) {
$progress = new ProgressBar($params['output']);
$progress->start();
}
while (true) {
$data = $this->getApiHelper()->getCompanies($oparams);
if (!isset($data['value'])) {
break; // no more data, exit loop
}
$result = $this->amendLeadDataBeforeMauticPopulate($data, $object);
$executed += count($result);
if (isset($params['output'])) {
if ($params['output']->getVerbosity() >= OutputInterface::VERBOSITY_VERBOSE) {
$params['output']->writeln($result);
} else {
$progress->advance(count($result));
}
}
if (!isset($data['@odata.nextLink'])) {
break; // default exit
}
// prepare next loop
$nextLink = $data['@odata.nextLink'];
$oparams['$skiptoken'] = urldecode(substr($nextLink, strpos($nextLink, '$skiptoken=') + 11));
}
if (isset($params['output']) && $params['output']->getVerbosity() < OutputInterface::VERBOSITY_VERBOSE) {
$progress->finish();
}
}
} catch (\Exception $e) {
$this->logIntegrationError($e);
}
return $executed;
}
/**
* Amend mapped lead data before creating to Mautic.
*
* @param array $data
* @param string $object
*/
public function amendLeadDataBeforeMauticPopulate($data, $object = null): array
{
if ('company' === $object) {
$object = 'accounts';
} elseif ('Lead' === $object || 'Contact' === $object) {
$object = 'contacts';
}
$config = $this->mergeConfigToFeatureSettings([]);
$result = [];
if (isset($data['value'])) {
$this->em->getConnection()->getConfiguration()->setMiddlewares([]);
$entity = null;
/** @var IntegrationEntityRepository $integrationEntityRepo */
$integrationEntityRepo = $this->em->getRepository(IntegrationEntity::class);
$objects = $data['value'];
$integrationEntities = [];
/** @var array $objects */
foreach ($objects as $entityData) {
$isModified = false;
if ('accounts' === $object) {
$recordId = $entityData['accountid'];
// first try to find integration entity
$integrationId = $integrationEntityRepo->getIntegrationsEntityId('Dynamics', $object, 'company',
null, null, null, false, 0, 0, "'".$recordId."'");
if (count($integrationId)) { // company exists, then update local fields
/** @var Company $entity */
$entity = $this->companyModel->getEntity($integrationId[0]['internal_entity_id']);
$matchedFields = $this->populateMauticLeadData($entityData, $config, 'company');
// Match that data with mapped lead fields
$fieldsToUpdateInMautic = $this->getPriorityFieldsForMautic($config, $object, 'mautic_company');
if (!empty($fieldsToUpdateInMautic)) {
$fieldsToUpdateInMautic = array_intersect_key($config['companyFields'], array_flip($fieldsToUpdateInMautic));
$newMatchedFields = array_intersect_key($matchedFields, array_flip($fieldsToUpdateInMautic));
} else {
$newMatchedFields = $matchedFields;
}
if (!isset($newMatchedFields['companyname'])) {
if (isset($newMatchedFields['companywebsite'])) {
$newMatchedFields['companyname'] = $newMatchedFields['companywebsite'];
}
}
// update values if already empty
foreach ($matchedFields as $field => $value) {
if (empty($entity->getFieldValue($field))) {
$newMatchedFields[$field] = $value;
}
}
// remove unchanged fields
foreach ($newMatchedFields as $k => $v) {
if ($entity->getFieldValue($k) === $v) {
unset($newMatchedFields[$k]);
}
}
if (count($newMatchedFields)) {
$this->companyModel->setFieldValues($entity, $newMatchedFields, false);
$this->companyModel->saveEntity($entity, false);
$isModified = true;
}
} else {
$entity = $this->getMauticCompany($entityData, 'company');
}
if ($entity) {
$result[] = $entity->getName();
}
$mauticObjectReference = 'company';
} elseif ('contacts' === $object) {
$recordId = $entityData['contactid'];
// first try to find integration entity
$integrationId = $integrationEntityRepo->getIntegrationsEntityId('Dynamics', $object, 'lead',
null, null, null, false, 0, 0, "'".$recordId."'");
if (count($integrationId)) { // lead exists, then update
/** @var Lead $entity */
$entity = $this->leadModel->getEntity($integrationId[0]['internal_entity_id']);
$matchedFields = $this->populateMauticLeadData($entityData, $config);
// Match that data with mapped lead fields
$fieldsToUpdateInMautic = $this->getPriorityFieldsForMautic($config, $object, 'mautic');
if (!empty($fieldsToUpdateInMautic)) {
$fieldsToUpdateInMautic = array_intersect_key($config['leadFields'] ?? [], array_flip($fieldsToUpdateInMautic));
$newMatchedFields = array_intersect_key($matchedFields, array_flip($fieldsToUpdateInMautic));
} else {
$newMatchedFields = $matchedFields;
}
// update values if already empty
foreach ($matchedFields as $field => $value) {
if (empty($entity->getFieldValue($field))) {
$newMatchedFields[$field] = $value;
}
}
// remove unchanged fields
foreach ($newMatchedFields as $k => $v) {
if ($entity->getFieldValue($k) === $v) {
unset($newMatchedFields[$k]);
}
}
if (count($newMatchedFields)) {
$this->leadModel->setFieldValues($entity, $newMatchedFields, false, false);
$this->leadModel->saveEntity($entity, false);
$isModified = true;
}
} else {
/** @var Lead $entity */
$entity = $this->getMauticLead($entityData);
}
if ($entity) {
$result[] = $entity->getEmail();
}
// Associate lead company
if (!empty($entityData['parentcustomerid']) // company
&& $entityData['parentcustomerid'] !== $this->translator->trans(
'mautic.integration.form.lead.unknown'
)
) {
$company = IdentifyCompanyHelper::identifyLeadsCompany(
['company' => $entityData['parentcustomerid']],
null,
$this->companyModel
);
if (!empty($company[2])) {
$syncLead = $this->companyModel->addLeadToCompany($company[2], $entity);
$this->em->detach($company[2]);
}
}
$mauticObjectReference = 'lead';
} else {
$this->logIntegrationError(
new \Exception(
sprintf('Received an unexpected object "%s"', $object)
)
);
continue;
}
if ($entity) {
$integrationId = $integrationEntityRepo->getIntegrationsEntityId(
'Dynamics',
$object,
$mauticObjectReference,
$entity->getId()
);
if (0 === count($integrationId)) {
$integrationEntity = new IntegrationEntity();
$integrationEntity->setDateAdded(new \DateTime());
$integrationEntity->setIntegration('Dynamics');
$integrationEntity->setIntegrationEntity($object);
$integrationEntity->setIntegrationEntityId($recordId);
$integrationEntity->setInternalEntity($mauticObjectReference);
$integrationEntity->setInternalEntityId($entity->getId());
$integrationEntities[] = $integrationEntity;
} else {
$integrationEntity = $integrationEntityRepo->getEntity($integrationId[0]['id']);
if ($isModified) {
$integrationEntity->setLastSyncDate(new \DateTime());
$integrationEntities[] = $integrationEntity;
}
}
$this->em->detach($entity);
unset($entity);
}
}
$integrationEntityRepo->saveEntities($integrationEntities);
$this->em->clear();
unset($integrationEntityRepo, $integrationEntities);
}
return $result;
}
/**
* @param array $params
*
* @return mixed[]
*/
public function pushLeads($params = []): array
{
$MAX_RECORDS = (isset($params['limit']) && $params['limit'] < 100) ? $params['limit'] : 100;
if (isset($params['fetchAll']) && $params['fetchAll']) {
$params['start'] = null;
$params['end'] = null;
}
$object = 'contacts';
$config = $this->mergeConfigToFeatureSettings();
$integrationEntityRepo = $this->em->getRepository(IntegrationEntity::class);
$fieldsToUpdateInCrm = isset($config['update_mautic']) ? array_keys($config['update_mautic'], 0) : [];
$leadFields = array_unique(array_values($config['leadFields'] ?? []));
$totalUpdated = $totalCreated = $totalErrors = 0;
if ($key = array_search('mauticContactTimelineLink', $leadFields)) {
unset($leadFields[$key]);
}
if ($key = array_search('mauticContactIsContactableByEmail', $leadFields)) {
unset($leadFields[$key]);
}
if (empty($leadFields)) {
return [0, 0, 0];
}
$fields = implode(', l.', $leadFields);
$fields = 'l.'.$fields;
$availableFields = $this->getAvailableLeadFields(['feature_settings' => ['objects' => [$object]]]);
$fieldsToUpdate[$object] = array_values(array_intersect(array_keys($availableFields[$object]), $fieldsToUpdateInCrm));
$fieldsToUpdate[$object] = array_intersect_key($config['leadFields'] ?? [], array_flip($fieldsToUpdate[$object]));
$progress = false;
$totalToUpdate = array_sum($integrationEntityRepo->findLeadsToUpdate('Dynamics', 'lead', $fields, 0, $params['start'], $params['end'], [$object]));
$totalToCreate = $integrationEntityRepo->findLeadsToCreate('Dynamics', $fields, 0, $params['start'], $params['end']);
$totalToCreate = is_array($totalToCreate) ? count($totalToCreate) : (int) $totalToCreate;
$totalCount = $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");
$output->writeln('<info>This could take some time. Please wait until the process is completed</info>');
$progress = new ProgressBar($output, $totalCount);
}
}
// Start with contacts so we know who is a contact when we go to process converted leads
$leadsToCreateInD = [];
$leadsToUpdateInD = [];
$integrationEntities = [];
$toUpdate = $integrationEntityRepo->findLeadsToUpdate('Dynamics', 'lead', $fields, $totalToUpdate, $params['start'], $params['end'], $object, [])[$object];
if (is_array($toUpdate)) {
$totalUpdated += count($toUpdate);
foreach ($toUpdate as $lead) {
if (isset($lead['email']) && !empty($lead['email'])) {
$key = mb_strtolower($this->cleanPushData($lead['email']));
$lead = $this->getCompoundMauticFields($lead);
$lead['integration_entity'] = $object;
$leadsToUpdateInD[$key] = $lead;
$integrationEntity = $this->em->getReference(IntegrationEntity::class, $lead['id']);
$integrationEntities[] = $integrationEntity->setLastSyncDate(new \DateTime());
}
}
}
unset($toUpdate);
// create lead records, including deleted on D side (last_sync = null)
/** @var array $leadsToCreate */
$leadsToCreate = $integrationEntityRepo->findLeadsToCreate('Dynamics', $fields, $totalToCreate, $params['start'], $params['end']);
if (is_array($leadsToCreate)) {
$totalCreated += count($leadsToCreate);
foreach ($leadsToCreate as $lead) {
if (isset($lead['email']) && !empty($lead['email'])) {
$key = mb_strtolower($this->cleanPushData($lead['email']));
$lead = $this->getCompoundMauticFields($lead);
$lead['integration_entity'] = $object;
$leadsToCreateInD[$key] = $lead;
}
}
}
unset($leadsToCreate);
if (count($integrationEntities)) {
// Persist updated entities if applicable
$integrationEntityRepo->saveEntities($integrationEntities);
$this->integrationEntityModel->getRepository()->detachEntities($integrationEntities);
}
// update contacts
$leadData = [];
$rowNum = 0;
foreach ($leadsToUpdateInD as $lead) {
$mappedData = [];
if (defined('IN_MAUTIC_CONSOLE') && $progress) {
$progress->advance();
}
$existingPerson = $this->getExistingRecord('emailaddress1', $lead['email'], $object);
$objectFields = $this->prepareFieldsForPush($availableFields[$object]);
$fieldsToUpdate[$object] = $this->getBlankFieldsToUpdate($fieldsToUpdate[$object], $existingPerson, $objectFields, $config);
// Match that data with mapped lead fields
foreach ($fieldsToUpdate[$object] as $k => $v) {
foreach ($lead as $dk => $dv) {
if ($v === $dk) {
if ($dv) {
if (isset($availableFields[$object][$k])) {
$mappedData[$availableFields[$object][$k]['dv']] = $dv;
}
}
}
}
}
$leadData[$lead['integration_entity_id']] = $mappedData;
++$rowNum;
// SEND 100 RECORDS AT A TIME
if ($MAX_RECORDS === $rowNum) {
$this->getApiHelper()->updateLeads($leadData, $object);
$leadData = [];
$rowNum = 0;
}
}
$this->getApiHelper()->updateLeads($leadData, $object);
// create contacts
$leadData = [];
$rowNum = 0;
foreach ($leadsToCreateInD as $lead) {
$mappedData = [];
if (defined('IN_MAUTIC_CONSOLE') && $progress) {
$progress->advance();
}
if (!isset($config['leadFields']) || !is_iterable($config['leadFields'])) {
continue;
}
// Match that data with mapped lead fields
foreach ($config['leadFields'] as $k => $v) {
foreach ($lead as $dk => $dv) {
if ($v === $dk) {
if ($dv) {
if (isset($availableFields[$object][$k])) {
$mappedData[$availableFields[$object][$k]['dv']] = $dv;
}
}
}
}
}
$leadData[$lead['internal_entity_id']] = $mappedData;
++$rowNum;
// SEND 100 RECORDS AT A TIME
if ($MAX_RECORDS === $rowNum) {
$ids = $this->getApiHelper()->createLeads($leadData, $object);
$this->createIntegrationEntities($ids, $object, $integrationEntityRepo);
$leadData = [];
$rowNum = 0;
}
}
$ids = $this->getApiHelper()->createLeads($leadData, $object);
$this->createIntegrationEntities($ids, $object, $integrationEntityRepo);
if ($progress) {
$progress->finish();
$output->writeln('');
}
return [$totalUpdated, $totalCreated, $totalErrors];
}
/**
* @param array $ids
* @param IntegrationEntityRepository $integrationEntityRepo
*/
private function createIntegrationEntities($ids, $object, $integrationEntityRepo): void
{
foreach ($ids as $oid => $leadId) {
$this->logger->debug('CREATE INTEGRATION ENTITY: '.$oid);
$integrationId = $integrationEntityRepo->getIntegrationsEntityId('Dynamics', $object,
'lead', null, null, null, false, 0, 0,
"'".$oid."'"
);
if (0 === count($integrationId)) {
$this->createIntegrationEntity($object, $oid, 'lead', $leadId);
}
}
}
private function getExistingRecord($seachColumn, $searchValue, $object = 'contacts')
{
$availableFields = $this->getAvailableLeadFields();
$oparams['$select'] = implode(',', array_keys($availableFields[$object]));
$oparams['$filter'] = $seachColumn.' eq \''.$searchValue.'\'';
$data = $this->getApiHelper()->getLeads($oparams);
return (isset($data['value'][0]) && !empty($data['value'][0])) ? $data['value'][0] : [];
}
}

View File

@@ -0,0 +1,659 @@
<?php
namespace MauticPlugin\MauticCrmBundle\Integration;
use Doctrine\ORM\EntityManager;
use Mautic\CoreBundle\Helper\ArrayHelper;
use Mautic\CoreBundle\Helper\CacheStorageHelper;
use Mautic\CoreBundle\Helper\EncryptionHelper;
use Mautic\CoreBundle\Helper\PathsHelper;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\CoreBundle\Model\NotificationModel;
use Mautic\LeadBundle\DataObject\LeadManipulator;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Entity\StagesChangeLog;
use Mautic\LeadBundle\Field\FieldsWithUniqueIdentifier;
use Mautic\LeadBundle\Model\CompanyModel;
use Mautic\LeadBundle\Model\DoNotContact;
use Mautic\LeadBundle\Model\FieldModel;
use Mautic\LeadBundle\Model\LeadModel;
use Mautic\PluginBundle\Entity\IntegrationEntityRepository;
use Mautic\PluginBundle\Model\IntegrationEntityModel;
use Mautic\StageBundle\Entity\Stage;
use MauticPlugin\MauticCrmBundle\Api\HubspotApi;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @method HubspotApi getApiHelper()
*/
class HubspotIntegration extends CrmAbstractIntegration
{
public const ACCESS_KEY = 'accessKey';
public function __construct(
EventDispatcherInterface $eventDispatcher,
CacheStorageHelper $cacheStorageHelper,
EntityManager $entityManager,
RequestStack $requestStack,
RouterInterface $router,
TranslatorInterface $translator,
LoggerInterface $logger,
EncryptionHelper $encryptionHelper,
LeadModel $leadModel,
CompanyModel $companyModel,
PathsHelper $pathsHelper,
NotificationModel $notificationModel,
FieldModel $fieldModel,
IntegrationEntityModel $integrationEntityModel,
DoNotContact $doNotContact,
FieldsWithUniqueIdentifier $fieldsWithUniqueIdentifier,
protected UserHelper $userHelper,
) {
parent::__construct(
$eventDispatcher,
$cacheStorageHelper,
$entityManager,
$requestStack,
$router,
$translator,
$logger,
$encryptionHelper,
$leadModel,
$companyModel,
$pathsHelper,
$notificationModel,
$fieldModel,
$integrationEntityModel,
$doNotContact,
$fieldsWithUniqueIdentifier
);
}
public function getName(): string
{
return 'Hubspot';
}
/**
* @return array<string, string>
*/
public function getRequiredKeyFields(): array
{
return [];
}
public function getApiKey(): string
{
return 'hapikey';
}
/**
* Get the array key for the auth token.
*/
public function getAuthTokenKey(): string
{
return 'hapikey';
}
public function getSupportedFeatures(): array
{
return ['push_lead', 'get_leads'];
}
/**
* @param bool $inAuthorization
*
* @return mixed|string|null
*/
public function getBearerToken($inAuthorization = false)
{
$tokenData = $this->getKeys();
return $tokenData[self::ACCESS_KEY] ?? null;
}
/**
* @return array<string, bool>
*/
public function getFormSettings(): array
{
return [
'requires_callback' => false,
'requires_authorization' => false,
];
}
public function getAuthenticationType(): string
{
return $this->getBearerToken() ? 'oauth2' : 'key';
}
public function getApiUrl(): string
{
return 'https://api.hubapi.com';
}
/**
* Get if data priority is enabled in the integration or not default is false.
*/
public function getDataPriority(): bool
{
return true;
}
/**
* 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 array $settings
*
* @return array|mixed
*/
public function getFormLeadFields($settings = [])
{
return $this->getFormFieldsByObject('contacts', $settings);
}
/**
* @return mixed[]
*/
public function getAvailableLeadFields($settings = []): array
{
if ($fields = parent::getAvailableLeadFields()) {
return $fields;
}
$hubsFields = [];
$silenceExceptions = $settings['silence_exceptions'] ?? true;
if (isset($settings['feature_settings']['objects'])) {
$hubspotObjects = $settings['feature_settings']['objects'];
} else {
$settings = $this->settings->getFeatureSettings();
$hubspotObjects = $settings['objects'] ?? ['contacts'];
}
try {
if ($this->isAuthorized()) {
if (!empty($hubspotObjects) and is_array($hubspotObjects)) {
foreach ($hubspotObjects as $object) {
// Check the cache first
$settings['cache_suffix'] = $cacheSuffix = '.'.$object;
if ($fields = parent::getAvailableLeadFields($settings)) {
$hubsFields[$object] = $fields;
continue;
}
$leadFields = $this->getApiHelper()->getLeadFields($object);
if (isset($leadFields)) {
foreach ($leadFields as $fieldInfo) {
$hubsFields[$object][$fieldInfo['name']] = [
'type' => 'string',
'label' => $fieldInfo['label'],
'required' => ('email' === $fieldInfo['name']),
];
if (!empty($fieldInfo['readOnlyValue'])) {
$hubsFields[$object][$fieldInfo['name']]['update_mautic'] = 1;
$hubsFields[$object][$fieldInfo['name']]['readOnly'] = 1;
}
}
}
$this->cache->set('leadFields'.$cacheSuffix, $hubsFields[$object]);
}
}
}
} catch (\Exception $e) {
$this->logIntegrationError($e);
if (!$silenceExceptions) {
throw $e;
}
}
return $hubsFields;
}
/**
* @param array $objects
*
* @return array
*/
protected function cleanPriorityFields($fieldsToUpdate, $objects = null)
{
if (null === $objects) {
$objects = ['Leads', 'Contacts'];
}
if (isset($fieldsToUpdate['leadFields'])) {
// Pass in the whole config
$fields = $fieldsToUpdate['leadFields'];
} else {
$fields = array_flip($fieldsToUpdate);
}
return $this->prepareFieldsForSync($fields, $fieldsToUpdate, $objects);
}
/**
* Format the lead data to the structure that HubSpot requires for the createOrUpdate request.
*
* @param array $leadData All the lead fields mapped
*/
public function formatLeadDataForCreateOrUpdate($leadData, $lead, $updateLink = false): array
{
$formattedLeadData = [];
if (!$updateLink) {
foreach ($leadData as $field => $value) {
if ('lifecyclestage' == $field || 'associatedcompanyid' == $field) {
continue;
}
$formattedLeadData['properties'][] = [
'property' => $field,
'value' => $value,
];
}
}
return $formattedLeadData;
}
public function isAuthorized(): bool
{
$keys = $this->getKeys();
return isset($keys[$this->getAuthTokenKey()]) || isset($keys[self::ACCESS_KEY]);
}
/**
* @return mixed
*/
public function getHubSpotApiKey()
{
$tokenData = $this->getKeys();
return $tokenData[$this->getAuthTokenKey()];
}
/**
* @param FormBuilderInterface $builder
* @param mixed[] $data
* @param string $formArea
*/
public function appendToForm(&$builder, $data, $formArea): void
{
if ('keys' === $formArea) {
$builder->add(
self::ACCESS_KEY,
TextType::class,
[
'label' => 'mautic.hubspot.form.accessKey',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
],
'required' => false,
]
);
$builder->add(
$this->getApiKey(),
TextType::class,
[
'label' => 'mautic.hubspot.form.apikey',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'readonly' => true,
],
'required' => false,
]
);
}
if ('features' == $formArea) {
$builder->add(
'objects',
ChoiceType::class,
[
'choices' => [
'mautic.hubspot.object.contact' => 'contacts',
'mautic.hubspot.object.company' => 'company',
],
'expanded' => true,
'multiple' => true,
'label' => $this->getTranslator()->trans('mautic.crm.form.objects_to_pull_from', ['%crm%' => 'Hubspot']),
'label_attr' => ['class' => ''],
'placeholder' => false,
'required' => false,
]
);
}
}
/**
* @return array
*/
public function amendLeadDataBeforeMauticPopulate($data, $object)
{
if (!isset($data['properties'])) {
return [];
}
foreach ($data['properties'] as $key => $field) {
$value = str_replace(';', '|', $field['value']);
$fieldsValues[$key] = $value;
}
if ('Lead' == $object && !isset($fieldsValues['email'])) {
foreach ($data['identity-profiles'][0]['identities'] as $identifiedProfile) {
if ('EMAIL' == $identifiedProfile['type']) {
$fieldsValues['email'] = $identifiedProfile['value'];
}
}
}
return $fieldsValues;
}
/**
* @param array $params
* @param array $result
* @param string $object
*
* @return array|null
*/
public function getLeads($params = [], $query = null, &$executed = null, $result = [], $object = 'Lead')
{
if (!is_array($executed)) {
$executed = [
0 => 0,
1 => 0,
];
}
try {
if ($this->isAuthorized()) {
$config = $this->mergeConfigToFeatureSettings();
$fields = implode('&property=', array_keys($config['leadFields'] ?? []));
$params['post_append_to_query'] = '&property='.$fields.'&property=lifecyclestage';
$params['Count'] = 100;
$data = $this->getApiHelper()->getContacts($params);
if (isset($data['contacts'])) {
foreach ($data['contacts'] as $contact) {
if (is_array($contact)) {
$contactData = $this->amendLeadDataBeforeMauticPopulate($contact, 'Lead');
$contact = $this->getMauticLead($contactData);
if ($contact && !$contact->isNewlyCreated()) { // updated
$executed[0] = $executed[0] + 1;
} elseif ($contact && $contact->isNewlyCreated()) { // newly created
$executed[1] = $executed[1] + 1;
}
if ($contact) {
$this->em->detach($contact);
}
}
}
if ($data['has-more']) {
$params['vidOffset'] = $data['vid-offset'];
$params['timeOffset'] = $data['time-offset'];
$this->getLeads($params, $query, $executed);
}
}
return $executed;
}
} catch (\Exception $e) {
$this->logIntegrationError($e);
}
return $executed;
}
/**
* @param array $params
* @param bool $id
*/
public function getCompanies($params = [], $id = false, &$executed = null)
{
$results = [];
try {
if ($this->isAuthorized()) {
$params['Count'] = 100;
$data = $this->getApiHelper()->getCompanies($params, $id);
if ($id) {
$results['results'][] = array_merge($results, $data);
} else {
$results['results'] = array_merge($results, $data['results']);
}
foreach ($results['results'] as $company) {
if (isset($company['properties'])) {
$companyData = $this->amendLeadDataBeforeMauticPopulate($company, null);
$company = $this->getMauticCompany($companyData);
if ($id) {
return $company;
}
if ($company) {
++$executed;
$this->em->detach($company);
}
}
}
if (isset($data['hasMore']) and $data['hasMore']) {
$params['offset'] = $data['offset'];
if ($params['offset'] < strtotime($params['start'])) {
$this->getCompanies($params, $id, $executed);
}
}
return $executed;
}
} catch (\Exception $e) {
$this->logIntegrationError($e);
}
return $executed;
}
/**
* Create or update existing Mautic lead from the integration's profile data.
*
* @param mixed $data Profile data from integration
* @param bool|true $persist Set to false to not persist lead to the database in this method
* @param array|null $socialCache
* @param mixed|null $identifiers
* @param string|null $object
*
* @return Lead
*/
public function getMauticLead($data, $persist = true, $socialCache = null, $identifiers = null, $object = null)
{
if (is_object($data)) {
// Convert to array in all levels
$data = json_encode(json_decode($data, true));
} elseif (is_string($data)) {
// Assume JSON
$data = json_decode($data, true);
}
if (isset($data['lifecyclestage'])) {
$stageName = $data['lifecyclestage'];
unset($data['lifecyclestage']);
}
if (isset($data['associatedcompanyid'])) {
$company = $this->getCompanies([], $data['associatedcompanyid']);
unset($data['associatedcompanyid']);
}
if ($lead = parent::getMauticLead($data, false, $socialCache, $identifiers, $object)) {
if (isset($stageName)) {
$stage = $this->em->getRepository(Stage::class)->getStageByName($stageName);
if (empty($stage)) {
$stage = new Stage();
$stage->setName($stageName);
$stages[$stageName] = $stage;
}
if (!$lead->getStage() && $lead->getStage() != $stage) {
$lead->setStage($stage);
// add a contact stage change log
$log = new StagesChangeLog();
$log->setStage($stage);
$log->setEventName($stage->getId().':'.$stage->getName());
$log->setLead($lead);
$log->setActionName(
$this->translator->trans(
'mautic.stage.import.action.name',
[
'%name%' => $this->userHelper->getUser()->getUserIdentifier(),
]
)
);
$log->setDateAdded(new \DateTime());
$lead->stageChangeLog($log);
}
}
if ($persist && !empty($lead->getChanges(true))) {
// Only persist if instructed to do so as it could be that calling code needs to manipulate the lead prior to executing event listeners
try {
$lead->setManipulator(new LeadManipulator(
'plugin',
$this->getName(),
null,
$this->getDisplayName()
));
$this->leadModel->saveEntity($lead, false);
if (isset($company)) {
$this->leadModel->addToCompany($lead, $company);
$this->em->detach($company);
}
} catch (\Exception $exception) {
$this->logger->warning($exception->getMessage());
return;
}
}
}
return $lead;
}
/**
* @param Lead $lead
* @param array $config
*
* @return array|bool
*/
public function pushLead($lead, $config = [])
{
$config = $this->mergeConfigToFeatureSettings($config);
if (empty($config['leadFields'])) {
return [];
}
$object = 'contacts';
$createFields = $config['leadFields'];
$readOnlyFields = $this->getReadOnlyFields($object);
$createFields = array_filter(
$createFields,
function ($createField, $key) use ($readOnlyFields) {
if (!isset($readOnlyFields[$key])) {
return $createField;
}
},
ARRAY_FILTER_USE_BOTH
);
$mappedData = $this->populateLeadData(
$lead,
[
'leadFields' => $createFields,
'object' => $object,
'feature_settings' => ['objects' => $config['objects']],
]
);
$this->amendLeadDataBeforePush($mappedData);
if (empty($mappedData)) {
return false;
}
if ($this->isAuthorized()) {
$leadData = $this->getApiHelper()->createLead($mappedData, $lead);
if (!empty($leadData['vid'])) {
/** @var IntegrationEntityRepository $integrationEntityRepo */
$integrationEntityRepo = $this->em->getRepository(\Mautic\PluginBundle\Entity\IntegrationEntity::class);
$integrationId = $integrationEntityRepo->getIntegrationsEntityId($this->getName(), $object, 'lead', $lead->getId());
$integrationEntity = (empty($integrationId)) ?
$this->createIntegrationEntity(
$object,
$leadData['vid'],
'lead',
$lead->getId(),
[],
false
) : $integrationEntityRepo->getEntity($integrationId[0]['id']);
$integrationEntity->setLastSyncDate($this->getLastSyncDate());
$this->getIntegrationEntityRepository()->saveEntity($integrationEntity);
$this->em->detach($integrationEntity);
}
return true;
}
return false;
}
/**
* Amend mapped lead data before pushing to CRM.
*/
public function amendLeadDataBeforePush(&$mappedData): void
{
foreach ($mappedData as &$data) {
$data = str_replace('|', ';', $data);
}
}
/**
* @throws \Exception
*/
private function getReadOnlyFields($object): ?array
{
$fields = ArrayHelper::getValue($object, $this->getAvailableLeadFields(), []);
return array_filter(
$fields,
function ($field) {
if (!empty($field['readOnly'])) {
return $field;
}
}
);
}
}

View File

@@ -0,0 +1,187 @@
<?php
namespace MauticPlugin\MauticCrmBundle\Integration\Salesforce\CampaignMember;
use Mautic\PluginBundle\Entity\IntegrationEntityRepository;
use MauticPlugin\MauticCrmBundle\Integration\Salesforce\Exception\InvalidObjectException;
use MauticPlugin\MauticCrmBundle\Integration\Salesforce\Exception\NoObjectsToFetchException;
use MauticPlugin\MauticCrmBundle\Integration\Salesforce\Object\CampaignMember;
use MauticPlugin\MauticCrmBundle\Integration\Salesforce\Object\Contact;
use MauticPlugin\MauticCrmBundle\Integration\Salesforce\Object\Lead;
use MauticPlugin\MauticCrmBundle\Integration\Salesforce\QueryBuilder;
class Fetcher
{
private array $leads = [];
private array $knownLeadIds = [];
private array $unknownLeadIds = [];
private array $contacts = [];
private array $knownContactIds = [];
private array $unknownContactIds = [];
private array $mauticIds = [];
private array $knownCampaignMembers = [];
/**
* @param string|int $campaignId
*/
public function __construct(
private IntegrationEntityRepository $repo,
private Organizer $organizer,
private $campaignId,
) {
$this->fetchLeads();
$this->fetchContacts();
}
/**
* Return SF query to fetch the object information for a CampaignMember.
*
* @throws NoObjectsToFetchException
* @throws InvalidObjectException
*/
public function getQueryForUnknownObjects(array $fields, $object): string
{
return match ($object) {
Lead::OBJECT => QueryBuilder::getLeadQuery($fields, $this->unknownLeadIds),
Contact::OBJECT => QueryBuilder::getContactQuery($fields, $this->unknownContactIds),
default => throw new InvalidObjectException(),
};
}
/**
* Fetch the Mautic contact IDs that are not already tracked as SF campaign members.
*/
public function getUnknownCampaignMembers(): array
{
// First, find those already tracked as part of this campaign
$this->fetchCampaignMembers();
// Second, find newly created objects
$this->fetchNewlyCreated();
$mauticLeadIds = array_map(
fn ($entity) => $entity['internal_entity_id'],
$this->knownCampaignMembers
);
return array_values(array_diff($this->mauticIds, $mauticLeadIds));
}
/**
* Fetch SF leads already identified.
*/
private function fetchLeads(): void
{
if (!$campaignMembers = $this->organizer->getLeadIds()) {
return;
}
$this->leads = $this->repo->getIntegrationsEntityId(
'Salesforce',
Lead::OBJECT,
'lead',
null,
null,
null,
false,
0,
0,
$campaignMembers
);
foreach ($this->leads as $lead) {
$this->knownLeadIds[] = $lead['integration_entity_id'];
$this->mauticIds[] = $lead['internal_entity_id'];
}
$this->unknownLeadIds = array_values(array_diff($campaignMembers, $this->knownLeadIds));
}
/**
* Fetch SF contacts already identified.
*/
private function fetchContacts(): void
{
if (!$campaignMembers = $this->organizer->getContactIds()) {
return;
}
$this->contacts = $this->repo->getIntegrationsEntityId(
'Salesforce',
Contact::OBJECT,
'lead',
null,
null,
null,
false,
0,
0,
$campaignMembers
);
foreach ($this->contacts as $contact) {
$this->knownContactIds[] = $contact['integration_entity_id'];
$this->mauticIds[] = $contact['internal_entity_id'];
}
$this->unknownContactIds = array_values(array_diff($campaignMembers, $this->knownContactIds));
}
/**
* Fetch SF campaign members already identified.
*/
private function fetchCampaignMembers(): void
{
if (!$this->mauticIds) {
return;
}
$this->knownCampaignMembers = $this->repo->getIntegrationsEntityId(
'Salesforce',
CampaignMember::OBJECT,
'lead',
$this->mauticIds,
null,
null,
false,
0,
0,
$this->campaignId
);
}
/**
* Fetch a list of all identified objects for SF contacts and leads.
*/
private function fetchNewlyCreated(): void
{
if (!$allUnknownContacts = array_merge($this->unknownLeadIds, $this->unknownContactIds)) {
return;
}
$newlyCreated = $this->repo->getIntegrationsEntityId(
'Salesforce',
null,
'lead',
null,
null,
null,
false,
0,
0,
$allUnknownContacts
);
foreach ($newlyCreated as $contact) {
$this->knownContactIds[] = $contact['integration_entity_id'];
$this->mauticIds[] = $contact['internal_entity_id'];
}
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace MauticPlugin\MauticCrmBundle\Integration\Salesforce\CampaignMember;
use MauticPlugin\MauticCrmBundle\Integration\Salesforce\Object\Contact;
use MauticPlugin\MauticCrmBundle\Integration\Salesforce\Object\Lead;
class Organizer
{
/**
* @var array<string, Lead>
*/
private array $leads = [];
/**
* @var array<string, Contact>
*/
private array $contacts = [];
public function __construct(
private array $records,
) {
$this->organize();
}
/**
* @return array<string, Lead>
*/
public function getLeads()
{
return $this->leads;
}
/**
* @return array<int, string>
*/
public function getLeadIds(): array
{
return array_keys($this->leads);
}
/**
* @return array<string, Contact>
*/
public function getContacts()
{
return $this->contacts;
}
/**
* @return array<int, string>
*/
public function getContactIds(): array
{
return array_keys($this->contacts);
}
private function organize(): void
{
foreach ($this->records as $campaignMember) {
$object = !empty($campaignMember['LeadId']) ? 'Lead' : 'Contact';
$objectId = !empty($campaignMember['LeadId']) ? $campaignMember['LeadId'] : $campaignMember['ContactId'];
$isDeleted = ($campaignMember['IsDeleted']) ? true : false;
switch ($object) {
case Lead::OBJECT:
$this->leads[$objectId] = new Lead($objectId, $campaignMember['CampaignId'], $isDeleted);
break;
case Contact::OBJECT:
$this->contacts[$objectId] = new Contact($objectId, $campaignMember['CampaignId'], $isDeleted);
break;
}
}
}
}

View File

@@ -0,0 +1,7 @@
<?php
namespace MauticPlugin\MauticCrmBundle\Integration\Salesforce\Exception;
class InvalidObjectException extends \InvalidArgumentException
{
}

View File

@@ -0,0 +1,7 @@
<?php
namespace MauticPlugin\MauticCrmBundle\Integration\Salesforce\Exception;
class NoObjectsToFetchException extends \Exception
{
}

View File

@@ -0,0 +1,45 @@
<?php
namespace MauticPlugin\MauticCrmBundle\Integration\Salesforce\Helper;
class StateValidationHelper
{
/**
* @var string[]
*/
private static array $supportedCountriesWithStates = [
'United States',
'Canada',
'Australia',
'Brazil',
'China',
'India',
'Ireland',
'Italy',
'Mexico',
];
/**
* Out of the box SF only supports states for the following countries. So in order to prevent SF from rejecting the entire payload, we'll
* only send state if it is supported out of the box by SF.
*
* @return array
*/
public static function validate(array $mappedData)
{
if (!isset($mappedData['State'])) {
return $mappedData;
}
if (
!isset($mappedData['Country'])
|| !in_array($mappedData['Country'], self::$supportedCountriesWithStates)
) {
unset($mappedData['State']);
return $mappedData;
}
return $mappedData;
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace MauticPlugin\MauticCrmBundle\Integration\Salesforce\Object;
class CampaignMember
{
public const OBJECT = 'CampaignMember';
}

View File

@@ -0,0 +1,39 @@
<?php
namespace MauticPlugin\MauticCrmBundle\Integration\Salesforce\Object;
class Contact
{
public const OBJECT = 'Contact';
public function __construct(
private $id,
private $campaignId,
private $isDeleted,
) {
}
/**
* @return mixed
*/
public function getId()
{
return $this->id;
}
/**
* @return mixed
*/
public function getCampaignId()
{
return $this->campaignId;
}
/**
* @return mixed
*/
public function getisDeleted()
{
return $this->isDeleted;
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace MauticPlugin\MauticCrmBundle\Integration\Salesforce\Object;
class Lead
{
public const OBJECT = 'Lead';
public function __construct(
private $id,
private $campaignId,
private $isDeleted,
) {
}
/**
* @return mixed
*/
public function getId()
{
return $this->id;
}
/**
* @return mixed
*/
public function getCampaignId()
{
return $this->campaignId;
}
/**
* @return mixed
*/
public function getisDeleted()
{
return $this->isDeleted;
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace MauticPlugin\MauticCrmBundle\Integration\Salesforce;
use MauticPlugin\MauticCrmBundle\Integration\Salesforce\Exception\NoObjectsToFetchException;
class QueryBuilder
{
/**
* @throws NoObjectsToFetchException
*/
public static function getLeadQuery(array $fields, array $ids): string
{
if (empty($ids)) {
throw new NoObjectsToFetchException();
}
$fieldString = self::getFieldString($fields);
$idString = implode("','", $ids);
return ($idString) ? "SELECT $fieldString from Lead where Id in ('$idString') and ConvertedContactId = NULL" : '';
}
/**
* @throws NoObjectsToFetchException
*/
public static function getContactQuery(array $fields, array $ids): string
{
if (empty($ids)) {
throw new NoObjectsToFetchException();
}
$fieldString = self::getFieldString($fields);
$idString = implode("','", $ids);
return ($idString) ? "SELECT $fieldString from Contact where Id in ('$idString')" : '';
}
private static function getFieldString(array $fields): string
{
$fields[] = 'Id';
return implode(', ', array_unique($fields));
}
}

View File

@@ -0,0 +1,99 @@
<?php
namespace MauticPlugin\MauticCrmBundle\Integration\Salesforce;
use Mautic\PluginBundle\Exception\ApiErrorException;
use Psr\Log\LoggerInterface;
class ResultsPaginator
{
/**
* @var array
*/
private $results;
/**
* @var int
*/
private $totalRecords = 0;
private int $recordCount = 0;
private int $retryCount = 0;
/**
* @var string|null
*/
private $nextRecordsUrl;
/**
* @param string $salesforceBaseUrl
*/
public function __construct(
private LoggerInterface $logger,
private $salesforceBaseUrl,
) {
}
/**
* @return $this
*
* @throws ApiErrorException
*/
public function setResults(array $results)
{
if (!isset($results['records'])) {
throw new ApiErrorException(var_export($results, true));
}
$this->results = $results;
$this->totalRecords = $results['totalSize'];
$this->recordCount += count($results['records']);
return $this;
}
/**
* @return string
*
* @throws ApiErrorException
*/
public function getNextResultsUrl()
{
if (isset($this->results['nextRecordsUrl'])) {
$this->retryCount = 0;
$this->nextRecordsUrl = $this->results['nextRecordsUrl'];
if (!str_contains($this->nextRecordsUrl, $this->salesforceBaseUrl)) {
$this->nextRecordsUrl = $this->salesforceBaseUrl.$this->nextRecordsUrl;
}
return $this->nextRecordsUrl;
}
if ($this->recordCount < $this->totalRecords) {
// Something has gone wrong so try a few more times before giving up
if ($this->retryCount <= 5) {
$this->logger->debug("SALESFORCE: Processed less than total but didn't get a nextRecordsUrl in the response: ".var_export($this->results, true));
usleep(500);
++$this->retryCount;
// Try again
return $this->nextRecordsUrl;
}
// Throw an exception cause something isn't right
throw new ApiErrorException("Expected to process {$this->totalRecords} but only processed {$this->recordCount}: ".var_export($this->results, true));
}
$this->nextRecordsUrl = null;
return '';
}
public function getTotal(): int
{
return (int) $this->totalRecords;
}
}

View File

@@ -0,0 +1,256 @@
<?php
namespace MauticPlugin\MauticCrmBundle\Integration;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
class VtigerIntegration extends CrmAbstractIntegration
{
private string $authorzationError = '';
/**
* Returns the name of the social integration that must match the name of the file.
*/
public function getName(): string
{
return 'Vtiger';
}
public function getSupportedFeatures(): array
{
return ['push_lead'];
}
public function getDisplayName(): string
{
return 'vTiger';
}
/**
* @return array<string, string>
*/
public function getRequiredKeyFields(): array
{
return [
'url' => 'mautic.vtiger.form.url',
'username' => 'mautic.vtiger.form.username',
'accessKey' => 'mautic.vtiger.form.password',
];
}
public function getClientIdKey(): string
{
return 'username';
}
public function getClientSecretKey(): string
{
return 'accessKey';
}
public function getAuthTokenKey(): string
{
return 'sessionName';
}
public function getApiUrl(): string
{
return sprintf('%s/webservice.php', $this->keys['url']);
}
/**
* @return bool|array<mixed>|string
*/
public function isAuthorized()
{
if (!isset($this->keys['url'])) {
return false;
}
$url = $this->getApiUrl();
$parameters = [
'operation' => 'getchallenge',
'username' => $this->keys['username'],
];
$response = $this->makeRequest($url, $parameters, 'GET', ['authorize_session' => true]);
if (empty($response['success'])) {
return $this->getErrorsFromResponse($response);
}
$loginParameters = [
'operation' => 'login',
'username' => $this->keys['username'],
'accessKey' => md5($response['result']['token'].$this->keys['accessKey']),
];
$response = $this->makeRequest($url, $loginParameters, 'POST', ['authorize_session' => true]);
if (empty($response['success'])) {
if (is_array($response) && array_key_exists('error', $response)) {
$this->authorzationError = $response['error']['message'];
}
return false;
} else {
$error = $this->extractAuthKeys($response['result']);
if (empty($error)) {
return true;
} else {
$this->authorzationError = $error;
return false;
}
}
}
public function getAuthLoginUrl(): string
{
return $this->router->generate('mautic_integration_auth_callback', ['integration' => $this->getName()]);
}
/**
* Retrieves and stores tokens returned from oAuthLogin.
*
* @param array $settings
* @param array $parameters
*/
public function authCallback($settings = [], $parameters = []): string|bool
{
$success = $this->isAuthorized();
if (!$success) {
return $this->authorzationError;
}
return false;
}
/**
* @return mixed[]
*/
public function getAvailableLeadFields($settings = []): array
{
$vTigerFields = [];
$silenceExceptions = $settings['silence_exceptions'] ?? true;
if (isset($settings['feature_settings']['objects'])) {
$vTigerObjects = $settings['feature_settings']['objects'];
} else {
$settings = $this->settings->getFeatureSettings();
$vTigerObjects = $settings['objects'] ?? ['contacts'];
}
try {
if ($this->isAuthorized()) {
if (!empty($vTigerObjects) && is_array($vTigerObjects)) {
foreach ($vTigerObjects as $object) {
// The object key for contacts should be 0 for some BC reasons
if ('contacts' == $object) {
$object = 0;
}
// Check the cache first
$settings['cache_suffix'] = $cacheSuffix = '.'.$object;
if ($fields = parent::getAvailableLeadFields($settings)) {
$vTigerFields[$object] = $fields;
continue;
}
// Create the array if it doesn't exist to prevent PHP notices
if (!isset($vTigerFields[$object])) {
$vTigerFields[$object] = [];
}
$leadFields = $this->getApiHelper()->getLeadFields($object);
if (isset($leadFields['fields'])) {
foreach ($leadFields['fields'] as $fieldInfo) {
if (!isset($fieldInfo['name']) || !$fieldInfo['editable'] || in_array(
$fieldInfo['type']['name'],
['owner', 'reference', 'boolean', 'autogenerated']
)
) {
continue;
}
$vTigerFields[$object][$fieldInfo['name']] = [
'type' => 'string',
'label' => $fieldInfo['label'],
'required' => in_array($fieldInfo['name'], ['email', 'accountname']),
];
}
}
$this->cache->set('leadFields'.$cacheSuffix, $vTigerFields[$object]);
}
}
}
} catch (\Exception $e) {
$this->logIntegrationError($e);
if (!$silenceExceptions) {
throw $e;
}
}
return $vTigerFields;
}
/**
* @return array<mixed>
*/
public function getFormNotes($section)
{
if ('leadfield_match' == $section) {
return ['mautic.vtiger.form.field_match_notes', 'info'];
}
return parent::getFormNotes($section);
}
public function amendLeadDataBeforePush(&$mappedData): void
{
if (!empty($mappedData)) {
// vtiger requires assigned_user_id so default to authenticated user
$mappedData['assigned_user_id'] = $this->keys['userId'];
}
}
/**
* @param \Mautic\PluginBundle\Integration\Form|FormBuilder $builder
* @param array $data
* @param string $formArea
*/
public function appendToForm(&$builder, $data, $formArea): void
{
if ('features' == $formArea) {
$builder->add(
'objects',
ChoiceType::class,
[
'choices' => [
'mautic.vtiger.object.contact' => 'contacts',
'mautic.vtiger.object.company' => 'company',
],
'expanded' => true,
'multiple' => true,
'label' => 'mautic.vtiger.form.objects_to_pull_from',
'label_attr' => ['class' => ''],
'placeholder' => false,
'required' => false,
]
);
}
}
/**
* Get available company fields for choices in the config UI.
*
* @param array $settings
*/
public function getFormCompanyFields($settings = []): array
{
return parent::getAvailableLeadFields(['cache_suffix' => '.company']);
}
}