Spaces:
No application file
No application file
namespace Mautic\CampaignBundle\Entity; | |
use Doctrine\DBAL\Query\QueryBuilder; | |
use Mautic\CampaignBundle\Entity\Result\CountResult; | |
use Mautic\CampaignBundle\Executioner\ContactFinder\Limiter\ContactLimiter; | |
use Mautic\CoreBundle\Entity\CommonRepository; | |
/** | |
* @extends CommonRepository<Lead> | |
*/ | |
class LeadRepository extends CommonRepository | |
{ | |
use ContactLimiterTrait; | |
use ReplicaConnectionTrait; | |
/** | |
* Get the details of leads added to a campaign. | |
*/ | |
public function getLeadDetails($campaignId, $leads = null): array | |
{ | |
$q = $this->getEntityManager()->createQueryBuilder() | |
->from(Lead::class, 'lc') | |
->select('lc') | |
->leftJoin('lc.campaign', 'c') | |
->leftJoin('lc.lead', 'l'); | |
$q->where( | |
$q->expr()->eq('c.id', ':campaign') | |
)->setParameter('campaign', $campaignId); | |
if (!empty($leads)) { | |
$q->andWhere( | |
$q->expr()->in('l.id', ':leads') | |
)->setParameter('leads', $leads); | |
} | |
$results = $q->getQuery()->getArrayResult(); | |
$return = []; | |
foreach ($results as $r) { | |
$return[$r['lead_id']][] = $r; | |
} | |
return $return; | |
} | |
/** | |
* Get leads for a specific campaign. | |
* | |
* @return array | |
*/ | |
public function getLeads($campaignId, $eventId = null) | |
{ | |
$q = $this->getEntityManager()->createQueryBuilder() | |
->from(Lead::class, 'lc') | |
->select('lc, l') | |
->leftJoin('lc.campaign', 'c') | |
->leftJoin('lc.lead', 'l'); | |
$q->where( | |
$q->expr()->andX( | |
$q->expr()->eq('lc.manuallyRemoved', ':false'), | |
$q->expr()->eq('c.id', ':campaign') | |
) | |
) | |
->setParameter('false', false, 'boolean') | |
->setParameter('campaign', $campaignId); | |
if (null != $eventId) { | |
$dq = $this->getEntityManager()->createQueryBuilder(); | |
$dq->select('el.id') | |
->from(LeadEventLog::class, 'ell') | |
->leftJoin('ell.lead', 'el') | |
->leftJoin('ell.event', 'ev') | |
->where( | |
$dq->expr()->eq('ev.id', ':eventId') | |
); | |
$q->andWhere('l.id NOT IN('.$dq->getDQL().')') | |
->setParameter('eventId', $eventId); | |
} | |
return $q->getQuery()->getResult(); | |
} | |
/** | |
* Updates lead ID (e.g. after a lead merge). | |
*/ | |
public function updateLead($fromLeadId, $toLeadId): void | |
{ | |
// First check to ensure the $toLead doesn't already exist | |
$results = $this->getEntityManager()->getConnection()->createQueryBuilder() | |
->select('cl.campaign_id') | |
->from(MAUTIC_TABLE_PREFIX.'campaign_leads', 'cl') | |
->where('cl.lead_id = '.$toLeadId) | |
->executeQuery() | |
->fetchAllAssociative(); | |
$campaigns = []; | |
foreach ($results as $r) { | |
$campaigns[] = $r['campaign_id']; | |
} | |
$q = $this->getEntityManager()->getConnection()->createQueryBuilder(); | |
$q->update(MAUTIC_TABLE_PREFIX.'campaign_leads') | |
->set('lead_id', (int) $toLeadId) | |
->where('lead_id = '.(int) $fromLeadId); | |
if (!empty($campaigns)) { | |
$q->andWhere( | |
$q->expr()->notIn('campaign_id', $campaigns) | |
)->executeStatement(); | |
// Delete remaining leads as the new lead already belongs | |
$this->getEntityManager()->getConnection()->createQueryBuilder() | |
->delete(MAUTIC_TABLE_PREFIX.'campaign_leads') | |
->where('lead_id = '.(int) $fromLeadId) | |
->executeStatement(); | |
} else { | |
$q->executeStatement(); | |
} | |
} | |
/** | |
* Check Lead in campaign. | |
* | |
* @param Lead $lead | |
* @param array $options | |
*/ | |
public function checkLeadInCampaigns($lead, $options = []): bool | |
{ | |
if (empty($options['campaigns'])) { | |
return false; | |
} | |
$q = $this->_em->getConnection()->createQueryBuilder(); | |
$q->select('l.campaign_id') | |
->from(MAUTIC_TABLE_PREFIX.'campaign_leads', 'l'); | |
$q->where( | |
$q->expr()->and( | |
$q->expr()->eq('l.lead_id', ':leadId'), | |
$q->expr()->in('l.campaign_id', $options['campaigns']) | |
) | |
); | |
if (!empty($options['dataAddedLimit'])) { | |
$q->andWhere($q->expr() | |
->{$options['expr']}('l.date_added', ':dateAdded')) | |
->setParameter('dateAdded', $options['dateAdded']); | |
} | |
$q->setParameter('leadId', $lead->getId()); | |
return (bool) $q->executeQuery()->fetchOne(); | |
} | |
/** | |
* @param int $campaignId | |
* @param int $decisionId | |
* @param int $parentDecisionId | |
* | |
* @return array<string, \DateTimeInterface> | |
*/ | |
public function getInactiveContacts($campaignId, $decisionId, $parentDecisionId, ContactLimiter $limiter): array | |
{ | |
// Main query | |
$q = $this->getReplicaConnection($limiter)->createQueryBuilder(); | |
$q->select('l.lead_id, l.date_added') | |
->from(MAUTIC_TABLE_PREFIX.'campaign_leads', 'l') | |
->where( | |
$q->expr()->and( | |
$q->expr()->eq('l.campaign_id', ':campaignId'), | |
$q->expr()->eq('l.manually_removed', 0) | |
) | |
) | |
// Order by ID so we can query by greater than X contact ID when batching | |
->orderBy('l.lead_id') | |
->setMaxResults($limiter->getBatchLimit()) | |
->setParameter('campaignId', (int) $campaignId) | |
->setParameter('decisionId', (int) $decisionId); | |
// Contact IDs | |
$this->updateQueryFromContactLimiter('l', $q, $limiter); | |
// Limit to events that have not been executed or scheduled yet | |
$eventQb = $this->getReplicaConnection($limiter)->createQueryBuilder(); | |
$eventQb->select('null') | |
->from(MAUTIC_TABLE_PREFIX.'campaign_lead_event_log', 'log') | |
->where( | |
$eventQb->expr()->and( | |
$eventQb->expr()->eq('log.event_id', ':decisionId'), | |
$eventQb->expr()->eq('log.lead_id', 'l.lead_id'), | |
$eventQb->expr()->eq('log.rotation', 'l.rotation') | |
) | |
); | |
$q->andWhere( | |
sprintf('NOT EXISTS (%s)', $eventQb->getSQL()) | |
); | |
if ($parentDecisionId) { | |
// Limit to events whose grandparent has already been executed | |
$grandparentQb = $this->getReplicaConnection($limiter)->createQueryBuilder(); | |
$grandparentQb->select('null') | |
->from(MAUTIC_TABLE_PREFIX.'campaign_lead_event_log', 'grandparent_log') | |
->where( | |
$grandparentQb->expr()->eq('grandparent_log.event_id', ':grandparentId'), | |
$grandparentQb->expr()->eq('grandparent_log.lead_id', 'l.lead_id'), | |
$grandparentQb->expr()->eq('grandparent_log.rotation', 'l.rotation') | |
); | |
$q->setParameter('grandparentId', (int) $parentDecisionId); | |
$q->andWhere( | |
sprintf('EXISTS (%s)', $grandparentQb->getSQL()) | |
); | |
} else { | |
// Limit to events that have no grandparent and any of events was already executed by jump to event | |
$anyEventQb = $this->getReplicaConnection($limiter)->createQueryBuilder(); | |
$anyEventQb->select('null') | |
->from(MAUTIC_TABLE_PREFIX.'campaign_lead_event_log', 'any_log') | |
->where( | |
$anyEventQb->expr()->eq('any_log.lead_id', 'l.lead_id'), | |
$anyEventQb->expr()->eq('any_log.campaign_id', 'l.campaign_id'), | |
$anyEventQb->expr()->eq('any_log.rotation', 'l.rotation') | |
); | |
$q->andWhere( | |
sprintf('NOT EXISTS (%s)', $anyEventQb->getSQL()) | |
); | |
} | |
if ($limiter->hasCampaignLimit() && $limiter->getCampaignLimitRemaining() < $limiter->getBatchLimit()) { | |
$q->setMaxResults($limiter->getCampaignLimitRemaining()); | |
} | |
$results = $q->executeQuery()->fetchAllAssociative(); | |
$contacts = []; | |
foreach ($results as $result) { | |
$contacts[$result['lead_id']] = new \DateTime($result['date_added'], new \DateTimeZone('UTC')); | |
} | |
if ($limiter->hasCampaignLimit()) { | |
$limiter->reduceCampaignLimitRemaining(count($contacts)); | |
} | |
return $contacts; | |
} | |
/** | |
* This is approximate because the query that fetches contacts per decision is based on if the grandparent has been executed or not. | |
*/ | |
public function getInactiveContactCount($campaignId, array $decisionIds, ContactLimiter $limiter): int | |
{ | |
// We have to loop over each decision to get a count or else any contact that has executed any single one of the decision IDs | |
// will not be included potentially resulting in not having the inactive path analyzed | |
$totalCount = 0; | |
foreach ($decisionIds as $decisionId) { | |
// Main query | |
$q = $this->getReplicaConnection()->createQueryBuilder(); | |
$q->select('count(*)') | |
->from(MAUTIC_TABLE_PREFIX.'campaign_leads', 'l') | |
->where( | |
$q->expr()->and( | |
$q->expr()->eq('l.campaign_id', ':campaignId'), | |
$q->expr()->eq('l.manually_removed', 0) | |
) | |
) | |
// Order by ID so we can query by greater than X contact ID when batching | |
->orderBy('l.lead_id') | |
->setParameter('campaignId', (int) $campaignId); | |
// Contact IDs | |
$this->updateQueryFromContactLimiter('l', $q, $limiter, true); | |
// Limit to events that have not been executed or scheduled yet | |
$eventQb = $this->getReplicaConnection($limiter)->createQueryBuilder(); | |
$eventQb->select('null') | |
->from(MAUTIC_TABLE_PREFIX.'campaign_lead_event_log', 'log') | |
->where( | |
$eventQb->expr()->and( | |
$eventQb->expr()->eq('log.event_id', $decisionId), | |
$eventQb->expr()->eq('log.lead_id', 'l.lead_id'), | |
$eventQb->expr()->eq('log.rotation', 'l.rotation') | |
) | |
); | |
$q->andWhere( | |
sprintf('NOT EXISTS (%s)', $eventQb->getSQL()) | |
); | |
$totalCount += (int) $q->executeQuery()->fetchOne(); | |
} | |
return $totalCount; | |
} | |
public function getCampaignMembers(array $contactIds, Campaign $campaign): array | |
{ | |
$qb = $this->createQueryBuilder('l'); | |
$qb->where( | |
$qb->expr()->andX( | |
$qb->expr()->eq('l.campaign', ':campaign'), | |
$qb->expr()->in('IDENTITY(l.lead)', ':contactIds') | |
) | |
) | |
->setParameter('campaign', $campaign) | |
->setParameter('contactIds', $contactIds, \Doctrine\DBAL\ArrayParameterType::INTEGER); | |
$results = $qb->getQuery()->getResult(); | |
$campaignMembers = []; | |
/** @var Lead $result */ | |
foreach ($results as $result) { | |
$campaignMembers[$result->getLead()->getId()] = $result; | |
} | |
return $campaignMembers; | |
} | |
public function getContactRotations(array $contactIds, $campaignId): array | |
{ | |
$qb = $this->getEntityManager()->getConnection()->createQueryBuilder(); | |
$qb->select('cl.lead_id, cl.rotation, cl.manually_removed') | |
->from(MAUTIC_TABLE_PREFIX.'campaign_leads', 'cl') | |
->where( | |
$qb->expr()->and( | |
$qb->expr()->eq('cl.campaign_id', ':campaignId'), | |
$qb->expr()->in('cl.lead_id', ':contactIds') | |
) | |
) | |
->setParameter('campaignId', (int) $campaignId) | |
->setParameter('contactIds', $contactIds, \Doctrine\DBAL\ArrayParameterType::INTEGER); | |
$results = $qb->executeQuery()->fetchAllAssociative(); | |
$contactRotations = []; | |
foreach ($results as $result) { | |
$contactRotations[$result['lead_id']] = ['rotation' => $result['rotation'], 'manually_removed' => $result['manually_removed']]; | |
} | |
return $contactRotations; | |
} | |
/** | |
* @param int $campaignId | |
* @param bool $campaignCanBeRestarted | |
*/ | |
public function getCountsForCampaignContactsBySegment($campaignId, ContactLimiter $limiter, $campaignCanBeRestarted = false): CountResult | |
{ | |
if (!$segments = $this->getCampaignSegments($campaignId)) { | |
return new CountResult(0, 0, 0); | |
} | |
$qb = $this->getReplicaConnection($limiter)->createQueryBuilder(); | |
$qb->select('min(ll.lead_id) as min_id, max(ll.lead_id) as max_id, count(distinct(ll.lead_id)) as the_count') | |
->from(MAUTIC_TABLE_PREFIX.'lead_lists_leads', 'll') | |
->where( | |
$qb->expr()->and( | |
$qb->expr()->eq('ll.manually_removed', 0), | |
$qb->expr()->in('ll.leadlist_id', $segments) | |
) | |
); | |
$this->updateQueryFromContactLimiter('ll', $qb, $limiter, true); | |
$this->updateQueryWithExistingMembershipExclusion((int) $campaignId, $qb, (bool) $campaignCanBeRestarted); | |
if (!$campaignCanBeRestarted) { | |
$this->updateQueryWithHistoryExclusion($campaignId, $qb); | |
} | |
$result = $qb->executeQuery()->fetchAssociative(); | |
return new CountResult($result['the_count'], $result['min_id'], $result['max_id']); | |
} | |
/** | |
* Get all contacts based on the campaigns segment | |
* a limit for how many contacts to process at one time | |
* and the campaign setting if a contact is allowed to restart | |
* a campaign. | |
* | |
* @param int $campaignId | |
* @param bool $campaignCanBeRestarted | |
* | |
* @return array<int|string, string> | |
*/ | |
public function getCampaignContactsBySegments($campaignId, ContactLimiter $limiter, $campaignCanBeRestarted = false): array | |
{ | |
if (!$segments = $this->getCampaignSegments($campaignId)) { | |
return []; | |
} | |
$qb = $this->getReplicaConnection($limiter)->createQueryBuilder(); | |
$qb->select('distinct(ll.lead_id) as id') | |
->from(MAUTIC_TABLE_PREFIX.'lead_lists_leads', 'll') | |
->where( | |
$qb->expr()->and( | |
$qb->expr()->eq('ll.manually_removed', 0), | |
$qb->expr()->in('ll.leadlist_id', $segments) | |
) | |
); | |
$this->updateQueryFromContactLimiter('ll', $qb, $limiter); | |
$this->updateQueryWithExistingMembershipExclusion((int) $campaignId, $qb, (bool) $campaignCanBeRestarted); | |
if (!$campaignCanBeRestarted) { | |
$this->updateQueryWithHistoryExclusion($campaignId, $qb); | |
} | |
$results = $qb->executeQuery()->fetchAllAssociative(); | |
$contacts = []; | |
foreach ($results as $result) { | |
$contacts[$result['id']] = $result['id']; | |
} | |
return $contacts; | |
} | |
/** | |
* @param int $campaignId | |
*/ | |
public function getCountsForOrphanedContactsBySegments($campaignId, ContactLimiter $limiter): CountResult | |
{ | |
$segments = $this->getCampaignSegments($campaignId); | |
$qb = $this->getReplicaConnection($limiter)->createQueryBuilder(); | |
$qb->select('min(cl.lead_id) as min_id, max(cl.lead_id) as max_id, count(cl.lead_id) as the_count') | |
->from(MAUTIC_TABLE_PREFIX.'campaign_leads', 'cl') | |
->where( | |
$qb->expr()->and( | |
$qb->expr()->eq('cl.campaign_id', (int) $campaignId), | |
$qb->expr()->eq('cl.manually_removed', 0), | |
$qb->expr()->eq('cl.manually_added', 0) | |
) | |
); | |
$this->updateQueryFromContactLimiter('cl', $qb, $limiter, true); | |
$this->updateQueryWithSegmentMembershipExclusion($segments, $qb); | |
$result = $qb->executeQuery()->fetchAssociative(); | |
return new CountResult($result['the_count'], $result['min_id'], $result['max_id']); | |
} | |
public function getOrphanedContacts($campaignId, ContactLimiter $limiter): array | |
{ | |
$segments = $this->getCampaignSegments($campaignId); | |
$qb = $this->getReplicaConnection($limiter)->createQueryBuilder(); | |
$qb->select('cl.lead_id as id') | |
->from(MAUTIC_TABLE_PREFIX.'campaign_leads', 'cl') | |
->where( | |
$qb->expr()->and( | |
$qb->expr()->eq('cl.campaign_id', (int) $campaignId), | |
$qb->expr()->eq('cl.manually_removed', 0), | |
$qb->expr()->eq('cl.manually_added', 0) | |
) | |
); | |
$this->updateQueryFromContactLimiter('cl', $qb, $limiter, false); | |
$this->updateQueryWithSegmentMembershipExclusion($segments, $qb); | |
$results = $qb->executeQuery()->fetchAllAssociative(); | |
$contacts = []; | |
foreach ($results as $result) { | |
$contacts[$result['id']] = $result['id']; | |
} | |
return $contacts; | |
} | |
/** | |
* Takes an array of contact ID's and increments | |
* their current rotation in a campaign by 1. | |
* | |
* @param int[] $contactIds | |
* @param int $campaignId | |
*/ | |
public function incrementCampaignRotationForContacts(array $contactIds, $campaignId): void | |
{ | |
$q = $this->getEntityManager()->getConnection()->createQueryBuilder(); | |
$q->update(MAUTIC_TABLE_PREFIX.'campaign_leads', 'cl') | |
->set('cl.rotation', 'cl.rotation + 1') | |
->where( | |
$q->expr()->and( | |
$q->expr()->in('cl.lead_id', ':contactIds'), | |
$q->expr()->eq('cl.campaign_id', ':campaignId') | |
) | |
) | |
->setParameter('contactIds', $contactIds, \Doctrine\DBAL\ArrayParameterType::INTEGER) | |
->setParameter('campaignId', (int) $campaignId) | |
->executeStatement(); | |
} | |
private function getCampaignSegments($campaignId): array | |
{ | |
// Get published segments for this campaign | |
$segmentResults = $this->getEntityManager()->getConnection()->createQueryBuilder() | |
->select('cl.leadlist_id') | |
->from(MAUTIC_TABLE_PREFIX.'campaign_leadlist_xref', 'cl') | |
->join('cl', MAUTIC_TABLE_PREFIX.'lead_lists', 'll', 'll.id = cl.leadlist_id and ll.is_published = 1') | |
->where('cl.campaign_id = '.(int) $campaignId) | |
->executeQuery() | |
->fetchAllAssociative(); | |
if (empty($segmentResults)) { | |
// No segments so no contacts | |
return []; | |
} | |
$segments = []; | |
foreach ($segmentResults as $result) { | |
$segments[] = $result['leadlist_id']; | |
} | |
return $segments; | |
} | |
private function updateQueryWithExistingMembershipExclusion(int $campaignId, QueryBuilder $qb, bool $campaignCanBeRestarted = false): void | |
{ | |
$membershipConditions = $qb->expr()->and( | |
$qb->expr()->eq('cl.lead_id', 'll.lead_id'), | |
$qb->expr()->eq('cl.campaign_id', (int) $campaignId) | |
); | |
if ($campaignCanBeRestarted) { | |
$alreadyInCampaign = $qb->expr()->eq('cl.manually_removed', 0); | |
$removedFromCampaignManually = $qb->expr()->and( | |
$qb->expr()->eq('cl.manually_removed', 1), | |
$qb->expr()->isNull('cl.date_last_exited'), | |
); | |
$membershipConditions = $qb->expr()->and( | |
$membershipConditions, | |
$qb->expr()->or($alreadyInCampaign, $removedFromCampaignManually) | |
); | |
} | |
$subq = $this->getEntityManager()->getConnection()->createQueryBuilder() | |
->select('null') | |
->from(MAUTIC_TABLE_PREFIX.'campaign_leads', 'cl') | |
->where( | |
$qb->expr()->and($membershipConditions) | |
); | |
$qb->andWhere( | |
sprintf('NOT EXISTS (%s)', $subq->getSQL()) | |
); | |
} | |
private function updateQueryWithSegmentMembershipExclusion(array $segments, QueryBuilder $qb): void | |
{ | |
if (0 === count($segments)) { | |
// No segments so nothing to exclude | |
return; | |
} | |
$subq = $this->getEntityManager()->getConnection()->createQueryBuilder() | |
->select('null') | |
->from(MAUTIC_TABLE_PREFIX.'lead_lists_leads', 'll') | |
->where( | |
$qb->expr()->and( | |
$qb->expr()->eq('ll.lead_id', 'cl.lead_id'), | |
$qb->expr()->eq('ll.manually_removed', 0), | |
$qb->expr()->in('ll.leadlist_id', $segments) | |
) | |
); | |
$qb->andWhere( | |
sprintf('NOT EXISTS (%s)', $subq->getSQL()) | |
); | |
} | |
/** | |
* Exclude contacts with any previous campaign history; this is mainly BC for pre 2.14.0 where the membership entry was deleted. | |
*/ | |
private function updateQueryWithHistoryExclusion($campaignId, QueryBuilder $qb): void | |
{ | |
$subq = $this->getEntityManager()->getConnection()->createQueryBuilder() | |
->select('null') | |
->from(MAUTIC_TABLE_PREFIX.'campaign_lead_event_log', 'el') | |
->where( | |
$qb->expr()->and( | |
$qb->expr()->eq('el.lead_id', 'll.lead_id'), | |
$qb->expr()->eq('el.campaign_id', (int) $campaignId) | |
) | |
); | |
$qb->andWhere( | |
sprintf('NOT EXISTS (%s)', $subq->getSQL()) | |
); | |
} | |
/** | |
* @return array{}|array<int, array<string, string|null>> | |
* | |
* @throws \Doctrine\DBAL\Exception | |
*/ | |
public function getCampaignMembersGroupByCountry(Campaign $campaign, \DateTimeImmutable $dateFromObject, \DateTimeImmutable $dateToObject): array | |
{ | |
$queryBuilder = $this->getEntityManager()->getConnection()->createQueryBuilder(); | |
$leadCampaignAlias = 'lc'; | |
$leadAlias = 'l'; | |
$queryBuilder->select( | |
"$leadAlias.country", | |
'count(id) AS contacts' | |
) | |
->from(MAUTIC_TABLE_PREFIX.'campaign_leads', $leadCampaignAlias) | |
->leftJoin( | |
$leadCampaignAlias, | |
MAUTIC_TABLE_PREFIX.'leads', | |
$leadAlias, | |
"$leadAlias.id = $leadCampaignAlias.lead_id" | |
) | |
->andWhere("$leadCampaignAlias.campaign_id = :campaign") | |
->andWhere("$leadCampaignAlias.manually_removed = :false") | |
->andWhere("$leadCampaignAlias.date_added BETWEEN :dateFrom AND :dateTo") | |
->groupBy("$leadAlias.country") | |
->setParameter('campaign', $campaign->getId()) | |
->setParameter('false', false) | |
->setParameter('dateFrom', $dateFromObject->format('Y-m-d H:i:s')) | |
->setParameter('dateTo', $dateToObject->setTime(23, 59, 59)->format('Y-m-d H:i:s')); | |
return $queryBuilder->executeQuery()->fetchAllAssociative(); | |
} | |
} | |