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,750 @@
<?php
namespace Mautic\CampaignBundle\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\Criteria;
use Doctrine\Common\Collections\Order;
use Doctrine\ORM\Mapping as ORM;
use Mautic\ApiBundle\Serializer\Driver\ApiMetadataDriver;
use Mautic\CampaignBundle\Validator\Constraints\NoOrphanEvents;
use Mautic\CategoryBundle\Entity\Category;
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
use Mautic\CoreBundle\Entity\FormEntity;
use Mautic\CoreBundle\Entity\OptimisticLockInterface;
use Mautic\CoreBundle\Entity\OptimisticLockTrait;
use Mautic\CoreBundle\Entity\PublishStatusIconAttributesInterface;
use Mautic\CoreBundle\Entity\UuidInterface;
use Mautic\CoreBundle\Entity\UuidTrait;
use Mautic\FormBundle\Entity\Form;
use Mautic\LeadBundle\Entity\Lead as Contact;
use Mautic\LeadBundle\Entity\LeadList;
use Mautic\ProjectBundle\Entity\Project;
use Mautic\ProjectBundle\Entity\ProjectTrait;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Mapping\ClassMetadata;
#[ApiResource(
operations: [
new GetCollection(security: "is_granted('campaign:campaigns:viewown')"),
new Post(security: "is_granted('campaign:campaigns:create')"),
new Get(security: "is_granted('campaign:campaigns:viewown')"),
new Put(security: "is_granted('campaign:campaigns:editown')"),
new Patch(security: "is_granted('campaign:campaigns:editother')"),
new Delete(security: "is_granted('campaign:campaigns:deleteown')"),
],
normalizationContext: [
'groups' => ['campaign:read'],
'swagger_definition_name' => 'Read',
'api_included' => ['category', 'events', 'lists', 'forms', 'fields', 'actions'],
],
denormalizationContext: [
'groups' => ['campaign:write'],
'swagger_definition_name' => 'Write',
]
)]
class Campaign extends FormEntity implements PublishStatusIconAttributesInterface, OptimisticLockInterface, UuidInterface
{
use UuidTrait;
use OptimisticLockTrait;
use ProjectTrait;
public const TABLE_NAME = 'campaigns';
public const ENTITY_NAME = 'campaign';
/**
* @var int
*/
#[Groups(['campaign:read', 'campaign:write'])]
private $id;
/**
* @var string|null
*/
#[Groups(['campaign:read', 'campaign:write'])]
private $name;
/**
* @var string|null
*/
#[Groups(['campaign:read', 'campaign:write'])]
private $description;
/**
* @var \DateTimeInterface|null
*/
#[Groups(['campaign:read', 'campaign:write'])]
private $publishUp;
/**
* @var \DateTimeInterface|null
*/
#[Groups(['campaign:read', 'campaign:write'])]
private $publishDown;
#[Groups(['campaign:read', 'campaign:write'])]
public ?\DateTimeInterface $deleted = null;
/**
* @var Category|null
**/
#[Groups(['campaign:read', 'campaign:write'])]
private $category;
/**
* @var Collection<int, Event>|ArrayCollection<int, Event>
*/
#[Groups(['campaign:read', 'campaign:write'])]
private $events;
/**
* @var ArrayCollection<int, Lead>
*/
#[Groups(['campaign:read', 'campaign:write'])]
private Collection $leads;
/**
* @var Collection<int, LeadList>
*/
#[Groups(['campaign:read', 'campaign:write'])]
private Collection $lists;
/**
* @var Collection<int, Form>
*/
#[Groups(['campaign:read', 'campaign:write'])]
private Collection $forms;
#[Groups(['campaign:read', 'campaign:write'])]
private array $canvasSettings = [];
#[Groups(['campaign:read', 'campaign:write'])]
private bool $allowRestart = false;
public function __construct()
{
$this->events = new ArrayCollection();
$this->leads = new ArrayCollection();
$this->lists = new ArrayCollection();
$this->forms = new ArrayCollection();
$this->initializeProjects();
}
public function __clone()
{
$this->leads = new ArrayCollection();
$this->events = new ArrayCollection();
$this->lists = new ArrayCollection();
$this->forms = new ArrayCollection();
$this->id = null;
parent::__clone();
}
public static function loadMetadata(ORM\ClassMetadata $metadata): void
{
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable(self::TABLE_NAME)
->setCustomRepositoryClass(CampaignRepository::class);
$builder->addIdColumns();
$builder->addPublishDates();
$builder->addCategory();
$builder->createOneToMany('events', Event::class)
->setIndexBy('id')
->setOrderBy(['order' => 'ASC'])
->mappedBy('campaign')
->cascadeAll()
->fetchExtraLazy()
->build();
$builder->createOneToMany('leads', Lead::class)
->mappedBy('campaign')
->fetchExtraLazy()
->build();
$builder->createManyToMany('lists', LeadList::class)
->setJoinTable('campaign_leadlist_xref')
->setIndexBy('id')
->addInverseJoinColumn('leadlist_id', 'id', false, false, 'CASCADE')
->addJoinColumn('campaign_id', 'id', true, false, 'CASCADE')
->build();
$builder->createManyToMany('forms', Form::class)
->setJoinTable('campaign_form_xref')
->setIndexBy('id')
->addInverseJoinColumn('form_id', 'id', false, false, 'CASCADE')
->addJoinColumn('campaign_id', 'id', true, false, 'CASCADE')
->build();
$builder->createField('canvasSettings', 'array')
->columnName('canvas_settings')
->nullable()
->build();
$builder->addNamedField('allowRestart', 'boolean', 'allow_restart');
$builder->addNullableField('deleted', 'datetime');
self::addVersionField($builder);
static::addUuidField($builder);
self::addProjectsField($builder, 'campaign_projects_xref', 'campaign_id');
}
public static function loadValidatorMetadata(ClassMetadata $metadata): void
{
$metadata->addPropertyConstraint(
'name',
new Assert\NotBlank(
[
'message' => 'mautic.core.name.required',
]
)
);
$metadata->addConstraint(new NoOrphanEvents());
}
/**
* Prepares the metadata for API usage.
*/
public static function loadApiMetadata(ApiMetadataDriver $metadata): void
{
$metadata->setGroupPrefix('campaign')
->addListProperties(
[
'id',
'name',
'category',
'description',
]
)
->addProperties(
[
'allowRestart',
'publishUp',
'publishDown',
'events',
'forms',
'lists', // @deprecated, will be renamed to 'segments' in 3.0.0
'canvasSettings',
]
)
->setGroupPrefix('campaignBasic')
->addListProperties(
[
'id',
'name',
'description',
'allowRestart',
'events',
'publishUp',
'publishDown',
'deleted',
]
)
->build();
self::addProjectsInLoadApiMetadata($metadata, 'campaign');
}
public function convertToArray(): array
{
return get_object_vars($this);
}
/**
* @param string $prop
* @param mixed $val
*/
protected function isChanged($prop, $val)
{
$getter = 'get'.ucfirst($prop);
$current = $this->$getter();
if ('category' == $prop) {
$currentId = ($current) ? $current->getId() : '';
$newId = ($val) ? $val->getId() : null;
if ($currentId != $newId) {
$this->changes[$prop] = [$currentId, $newId];
}
} elseif ('projects' === $prop) {
// Initialize project tracking on first change
if (!isset($this->changes['projects']['old'])) {
$currentProjects = array_map(fn ($project) => $project->getName(), iterator_to_array($current));
$this->changes['projects'] = [
'old' => $currentProjects,
'new' => $currentProjects,
];
}
// Update the new state based on the operation
if ($val instanceof Project) {
// Add project if not already in the list
$projectName = $val->getName();
if (!in_array($projectName, $this->changes['projects']['new'], true)) {
$this->changes['projects']['new'][] = $projectName;
}
} else {
// Remove project from the list
$this->changes['projects']['new'] = array_values(
array_diff($this->changes['projects']['new'], [$val])
);
}
} else {
parent::isChanged($prop, $val);
}
}
public function getId(): ?int
{
return $this->id;
}
/**
* Override to convert projects changes to final format.
*/
public function getChanges($includePast = false)
{
$changes = parent::getChanges($includePast);
// Convert projects format if it exists and is in the intermediate format
if (isset($changes['projects']['old']) && isset($changes['projects']['new'])) {
$changes['projects'] = [
implode(', ', $changes['projects']['old']),
implode(', ', $changes['projects']['new']),
];
}
return $changes;
}
/**
* @param string $description
*
* @return Campaign
*/
public function setDescription($description)
{
$this->isChanged('description', $description);
$this->description = $description;
return $this;
}
/**
* @return string
*/
public function getDescription()
{
return $this->description;
}
/**
* @return Campaign
*/
public function setName(string $name)
{
$this->isChanged('name', $name);
$this->name = $name;
return $this;
}
/**
* @return string
*/
public function getName()
{
return $this->name;
}
/**
* Calls $this->addEvent on every item in the collection.
*
* @return Campaign
*/
public function addEvents(array $events)
{
foreach ($events as $id => $event) {
$this->addEvent($id, $event);
}
return $this;
}
/**
* @return Campaign
*/
public function addEvent($key, Event $event)
{
if ($changes = $event->getChanges()) {
$this->changes['events']['added'][$key] = [$key, $changes];
}
$this->events[$key] = $event;
return $this;
}
public function removeEvent(Event $event): void
{
$this->changes['events']['removed'][$event->getId()] = $event->getName();
$this->events->removeElement($event);
}
/**
* @return ArrayCollection<int, Event>
*/
public function getEvents()
{
return $this->events;
}
public function getRootEvents(): ArrayCollection
{
$criteria = Criteria::create()->where(
Criteria::expr()->andX(
Criteria::expr()->isNull('parent'),
Criteria::expr()->isNull('deleted')
)
);
$events = $this->getEvents()->matching($criteria);
return $this->reindexEventsByIdKey($events);
}
public function getInactionBasedEvents(): ArrayCollection
{
$criteria = Criteria::create()->where(Criteria::expr()->eq('decisionPath', Event::PATH_INACTION));
$events = $this->getEvents()->matching($criteria);
return $this->reindexEventsByIdKey($events);
}
/**
* @param string $type
*
* @return ArrayCollection<int,Event>
*/
public function getEventsByType($type): ArrayCollection
{
$criteria = Criteria::create()->where(Criteria::expr()->eq('eventType', $type));
$events = $this->getEvents()->matching($criteria);
return $this->reindexEventsByIdKey($events);
}
/**
* @return ArrayCollection<int, Event>
*/
public function getEmailSendEvents(): ArrayCollection
{
$criteria = Criteria::create()->where(Criteria::expr()->eq('type', 'email.send'));
$events = $this->getEvents()->matching($criteria);
// Doctrine loses the indexBy mapping definition when using matching so we have to manually reset them.
// @see https://github.com/doctrine/doctrine2/issues/4693
$keyedArrayCollection = new ArrayCollection();
/** @var Event $event */
foreach ($events as $event) {
$keyedArrayCollection->set($event->getId(), $event);
}
return $keyedArrayCollection;
}
public function isEmailCampaign(): bool
{
$criteria = Criteria::create()->where(Criteria::expr()->eq('type', 'email.send'))->setMaxResults(1);
$emailEvent = $this->getEvents()->matching($criteria);
return !$emailEvent->isEmpty();
}
/**
* @param ?\DateTime $publishUp
*
* @return Campaign
*/
public function setPublishUp($publishUp)
{
$this->isChanged('publishUp', $publishUp);
$this->publishUp = $publishUp;
return $this;
}
/**
* @return \DateTimeInterface|null
*/
public function getPublishUp()
{
return $this->publishUp;
}
/**
* @param ?\DateTime $publishDown
*
* @return Campaign
*/
public function setPublishDown($publishDown)
{
$this->isChanged('publishDown', $publishDown);
$this->publishDown = $publishDown;
return $this;
}
/**
* @return \DateTimeInterface
*/
public function getPublishDown()
{
return $this->publishDown;
}
/**
* @return mixed
*/
public function getCategory()
{
return $this->category;
}
/**
* @param mixed $category
*/
public function setCategory($category): void
{
$this->isChanged('category', $category);
$this->category = $category;
}
/**
* @return Campaign
*/
public function addLead($key, Lead $lead)
{
$action = ($this->leads->contains($lead)) ? 'updated' : 'added';
$leadEntity = $lead->getLead();
$this->changes['leads'][$action][$leadEntity->getId()] = $leadEntity->getPrimaryIdentifier();
$this->leads[$key] = $lead;
return $this;
}
public function removeLead(Lead $lead): void
{
$leadEntity = $lead->getLead();
$this->changes['leads']['removed'][$leadEntity->getId()] = $leadEntity->getPrimaryIdentifier();
$this->leads->removeElement($lead);
}
/**
* @return Lead[]|Collection
*/
public function getLeads()
{
return $this->leads;
}
/**
* @return ArrayCollection<int, LeadList>
*/
public function getLists()
{
return $this->lists;
}
/**
* @return Campaign
*/
public function addList(LeadList $list)
{
$this->lists[$list->getId()] = $list;
$this->changes['lists']['added'][$list->getId()] = $list->getName();
return $this;
}
public function removeList(LeadList $list): void
{
$this->changes['lists']['removed'][$list->getId()] = $list->getName();
$this->lists->removeElement($list);
}
/**
* @return ArrayCollection<int, Form>
*/
public function getForms()
{
return $this->forms;
}
/**
* @return Campaign
*/
public function addForm(Form $form)
{
$this->forms[$form->getId()] = $form;
$this->changes['forms']['added'][$form->getId()] = $form->getName();
return $this;
}
public function removeForm(Form $form): void
{
$this->changes['forms']['removed'][$form->getId()] = $form->getName();
$this->forms->removeElement($form);
}
/**
* @return mixed
*/
public function getCanvasSettings()
{
return $this->canvasSettings;
}
public function setCanvasSettings(array $canvasSettings): void
{
$this->canvasSettings = $canvasSettings;
}
/**
* Check if there are any orphan events that are not connected to any parent node.
*/
public function hasOrphanEvents(): bool
{
$canvasSettings = $this->getCanvasSettings() ?? [];
if (empty($canvasSettings['nodes'])) {
return false;
}
// Extract event IDs from canvas nodes (excludes 'lists', 'forms' and other non-event nodes)
$eventIds = array_filter(
array_column($canvasSettings['nodes'], 'id'),
fn ($id) => !in_array($id, ['lists', 'forms'])
);
if (empty($eventIds)) {
return false;
}
// Extract connected event IDs from connections
$connectedEventIds = [];
if (!empty($canvasSettings['connections'])) {
$connectedEventIds = array_filter(array_column($canvasSettings['connections'], 'targetId'));
}
return !empty(array_diff($eventIds, $connectedEventIds));
}
public function getAllowRestart(): bool
{
return (bool) $this->allowRestart;
}
public function allowRestart(): bool
{
return $this->getAllowRestart();
}
/**
* @param bool $allowRestart
*
* @return Campaign
*/
public function setAllowRestart($allowRestart)
{
$allowRestart = (bool) $allowRestart;
$this->isChanged('allowRestart', $allowRestart);
$this->allowRestart = $allowRestart;
return $this;
}
public function setDeleted(?\DateTimeInterface $deleted): void
{
$this->isChanged('deleted', $deleted);
$this->deleted = $deleted;
}
public function isDeleted(): bool
{
return !is_null($this->deleted);
}
/**
* Get contact membership.
*/
public function getContactMembership(Contact $contact): Collection
{
return $this->leads->matching(
Criteria::create()
->where(Criteria::expr()->eq('lead', $contact))
->orderBy(['dateAdded' => Order::Descending->value])
);
}
public function getOnclickMethod(): string
{
return 'Mautic.confirmationCampaignPublishStatus(mQuery(this));';
}
public function getDataAttributes(): array
{
return [
'data-toggle' => 'confirmation',
'data-confirm-callback' => 'confirmCallbackCampaignPublishStatus',
'data-cancel-callback' => 'dismissConfirmation',
];
}
public function getTranslationKeysDataAttributes(): array
{
return [
'data-message' => 'mautic.campaign.form.confirmation.message',
'data-confirm-text' => 'mautic.campaign.form.confirmation.confirm_text',
'data-cancel-text' => 'mautic.campaign.form.confirmation.cancel_text',
];
}
/**
* Re-index collection by event ID to work around Doctrine's indexBy mapping issue.
*
* @see https://github.com/doctrine/doctrine2/issues/4693
*/
private function reindexEventsByIdKey(Collection $events): ArrayCollection
{
// Doctrine loses the indexBy mapping definition when using matching so we have to manually reset them.
// @see https://github.com/doctrine/doctrine2/issues/4693
$keyedArrayCollection = new ArrayCollection();
/** @var Event $event */
foreach ($events as $event) {
$keyedArrayCollection->set($event->getId(), $event);
}
unset($events);
return $keyedArrayCollection;
}
}

View File

@@ -0,0 +1,678 @@
<?php
namespace Mautic\CampaignBundle\Entity;
use Doctrine\DBAL\Cache\QueryCacheProfile;
use Doctrine\DBAL\Exception;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Query\Expr;
use Mautic\CampaignBundle\Entity\Result\CountResult;
use Mautic\CampaignBundle\Executioner\ContactFinder\Limiter\ContactLimiter;
use Mautic\CoreBundle\Entity\CommonRepository;
use Mautic\ProjectBundle\Entity\ProjectRepositoryTrait;
/**
* @extends CommonRepository<Campaign>
*/
class CampaignRepository extends CommonRepository
{
use ContactLimiterTrait;
use ReplicaConnectionTrait;
use ProjectRepositoryTrait;
public function getEntities(array $args = [])
{
$q = $this->getEntityManager()->createQueryBuilder();
$q->select($this->getTableAlias().', cat')
->from(Campaign::class, $this->getTableAlias(), $this->getTableAlias().'.id')
->leftJoin($this->getTableAlias().'.category', 'cat');
if (!empty($args['joinLists'])) {
$q->leftJoin($this->getTableAlias().'.lists', 'l');
}
if (!empty($args['joinForms'])) {
$q->leftJoin($this->getTableAlias().'.forms', 'f');
}
$q->where($q->expr()->isNull($this->getTableAlias().'.deleted'));
$args['qb'] = $q;
return parent::getEntities($args);
}
public function setCampaignAsDeleted(int $campaignId): void
{
$dateTime = (new \DateTime())->format('Y-m-d H:i:s');
$this->getEntityManager()->getConnection()->update(
MAUTIC_TABLE_PREFIX.Event::TABLE_NAME,
['deleted' => $dateTime],
['campaign_id' => $campaignId]
);
$this->getEntityManager()->getConnection()->update(
MAUTIC_TABLE_PREFIX.Campaign::TABLE_NAME,
['deleted' => $dateTime, 'is_published' => 0],
['id' => $campaignId]
);
}
/**
* Returns a list of all published (and active) campaigns (optionally for a specific lead).
*
* @param bool $forList If true, returns ID and name only
* @param bool $viewOther If true, returns all the campaigns
*
* @return array
*/
public function getPublishedCampaigns($specificId = null, ?int $leadId = null, $forList = false, $viewOther = false)
{
$q = $this->getEntityManager()->createQueryBuilder()
->from(Campaign::class, 'c', 'c.id');
if ($forList && $leadId) {
$q->select('partial c.{id, name}, partial l.{campaign, lead, dateAdded, manuallyAdded, manuallyRemoved}, partial ll.{id}');
} elseif ($forList) {
$q->select('partial c.{id, name}, partial ll.{id}');
} else {
$q->select('c, l, partial ll.{id}')
->leftJoin('c.events', 'e')
->leftJoin('e.log', 'o');
}
if ($leadId || !$forList) {
$q->leftJoin('c.leads', 'l');
}
$q->leftJoin('c.lists', 'll')
->where($this->getPublishedByDateExpression($q));
if (!$viewOther) {
$q->andWhere($q->expr()->eq('c.createdBy', ':id'))
->setParameter('id', $this->currentUser->getId());
}
if (!empty($specificId)) {
$q->andWhere(
$q->expr()->eq('c.id', (int) $specificId)
);
}
if (!empty($leadId)) {
$q->andWhere(
$q->expr()->eq('IDENTITY(l.lead)', (int) $leadId)
);
$q->andWhere(
$q->expr()->eq('l.manuallyRemoved', ':manuallyRemoved')
)->setParameter('manuallyRemoved', false);
}
return $q->getQuery()->getArrayResult();
}
/**
* Returns a list of all published (and active) campaigns that specific lead lists are part of.
*
* @param int|array $leadLists
*/
public function getPublishedCampaignsByLeadLists($leadLists): array
{
if (!is_array($leadLists)) {
$leadLists = [(int) $leadLists];
} else {
foreach ($leadLists as &$id) {
$id = (int) $id;
}
}
$q = $this->getEntityManager()->getConnection()->createQueryBuilder()
->select('c.id, c.name, ll.leadlist_id as list_id')
->from(MAUTIC_TABLE_PREFIX.'campaigns', 'c');
$q->join('c', MAUTIC_TABLE_PREFIX.'campaign_leadlist_xref', 'll', 'c.id = ll.campaign_id')
->where($this->getPublishedByDateExpression($q));
$q->andWhere(
$q->expr()->in('ll.leadlist_id', $leadLists)
);
$results = $q->executeQuery()->fetchAllAssociative();
$campaigns = [];
foreach ($results as $result) {
if (!isset($campaigns[$result['id']])) {
$campaigns[$result['id']] = [
'id' => $result['id'],
'name' => $result['name'],
'lists' => [],
];
}
$campaigns[$result['id']]['lists'][$result['list_id']] = [
'id' => $result['list_id'],
];
}
return $campaigns;
}
/**
* Get array of list IDs assigned to this campaign.
*
* @param int|null $id
*/
public function getCampaignListIds($id = null): array
{
$q = $this->getEntityManager()->getConnection()->createQueryBuilder()
->from(MAUTIC_TABLE_PREFIX.'campaign_leadlist_xref', 'cl');
if ($id) {
$q->select('cl.leadlist_id')
->where(
$q->expr()->eq('cl.campaign_id', $id)
);
} else {
// Retrieve a list of unique IDs that are assigned to a campaign
$q->select('DISTINCT cl.leadlist_id');
}
$lists = [];
$results = $q->executeQuery()->fetchAllAssociative();
foreach ($results as $r) {
$lists[] = $r['leadlist_id'];
}
return $lists;
}
/**
* Get array of list IDs => name assigned to this campaign.
*/
public function getCampaignListSources($id): array
{
$q = $this->getEntityManager()->getConnection()->createQueryBuilder()
->select('cl.leadlist_id, l.name')
->from(MAUTIC_TABLE_PREFIX.'campaign_leadlist_xref', 'cl')
->join('cl', MAUTIC_TABLE_PREFIX.'lead_lists', 'l', 'l.id = cl.leadlist_id');
$q->where(
$q->expr()->eq('cl.campaign_id', $id)
);
$lists = [];
$results = $q->executeQuery()->fetchAllAssociative();
foreach ($results as $r) {
$lists[$r['leadlist_id']] = $r['name'];
}
return $lists;
}
/**
* Get array of form IDs => name assigned to this campaign.
*/
public function getCampaignFormSources($id): array
{
$q = $this->getEntityManager()->getConnection()->createQueryBuilder()
->select('cf.form_id, f.name')
->from(MAUTIC_TABLE_PREFIX.'campaign_form_xref', 'cf')
->join('cf', MAUTIC_TABLE_PREFIX.'forms', 'f', 'f.id = cf.form_id');
$q->where(
$q->expr()->eq('cf.campaign_id', $id)
);
$forms = [];
$results = $q->executeQuery()->fetchAllAssociative();
foreach ($results as $r) {
$forms[$r['form_id']] = $r['name'];
}
return $forms;
}
/**
* @return array
*/
public function findByFormId($formId)
{
$q = $this->createQueryBuilder('c')
->join('c.forms', 'f');
$q->where(
$q->expr()->eq('f.id', $formId)
);
return $q->getQuery()->getResult();
}
public function getTableAlias(): string
{
return 'c';
}
/**
* @param \Doctrine\ORM\QueryBuilder|\Doctrine\DBAL\Query\QueryBuilder $q
*/
protected function addCatchAllWhereClause($q, $filter): array
{
return $this->addStandardCatchAllWhereClause($q, $filter, [
'c.name',
'c.description',
]);
}
/**
* @param \Doctrine\ORM\QueryBuilder|\Doctrine\DBAL\Query\QueryBuilder $q
*/
protected function addSearchCommandWhereClause($q, $filter): array
{
[$expr, $parameters] = $this->addStandardSearchCommandWhereClause($q, $filter);
if ($expr) {
return [$expr, $parameters];
}
$unique = $this->generateRandomParameterName();
switch ($filter->command) {
case $this->translator->trans('mautic.campaign.campaign.searchcommand.isexpired'):
case $this->translator->trans('mautic.campaign.campaign.searchcommand.isexpired', [], null, 'en_US'):
$expr = $q->expr()->and(
$q->expr()->eq('c.isPublished', ":$unique"),
$q->expr()->isNotNull('c.publishDown'),
$q->expr()->neq('c.publishDown', $q->expr()->literal('')),
$q->expr()->lt('c.publishDown', 'CURRENT_TIMESTAMP()')
);
$forceParameters = [$unique => true];
break;
case $this->translator->trans('mautic.campaign.campaign.searchcommand.ispending'):
case $this->translator->trans('mautic.campaign.campaign.searchcommand.ispending', [], null, 'en_US'):
$expr = $q->expr()->and(
$q->expr()->eq('c.isPublished', ":$unique"),
$q->expr()->isNotNull('c.publishUp'),
$q->expr()->neq('c.publishUp', $q->expr()->literal('')),
$q->expr()->gt('c.publishUp', 'CURRENT_TIMESTAMP()')
);
$forceParameters = [$unique => true];
break;
case $this->translator->trans('mautic.project.searchcommand.name'):
case $this->translator->trans('mautic.project.searchcommand.name', [], null, 'en_US'):
return $this->handleProjectFilter(
$this->_em->getConnection()->createQueryBuilder(),
'campaign_id',
'campaign_projects_xref',
$this->getTableAlias(),
$filter->string,
$filter->not
);
}
if ($expr && $filter->not) {
$expr = $q->expr()->not($expr);
}
if (!empty($forceParameters)) {
$parameters = $forceParameters;
}
return [$expr, $parameters];
}
/**
* @return string[]
*/
public function getSearchCommands(): array
{
return array_merge([
'mautic.campaign.campaign.searchcommand.isexpired',
'mautic.campaign.campaign.searchcommand.ispending',
'mautic.project.searchcommand.name',
], $this->getStandardSearchCommands());
}
/**
* Get a list of popular (by logs) campaigns.
*
* @param int $limit
*/
public function getPopularCampaigns($limit = 10): array
{
$q = $this->getEntityManager()->getConnection()->createQueryBuilder();
$q->select('count(cl.ip_id) as hits, c.id AS campaign_id, c.name')
->from(MAUTIC_TABLE_PREFIX.'campaign_lead_event_log', 'cl')
->leftJoin('cl', MAUTIC_TABLE_PREFIX.'campaigns', 'c', 'cl.campaign_id = c.id')
->orderBy('hits', 'DESC')
->groupBy('c.id, c.name')
->setMaxResults($limit);
$expr = $this->getPublishedByDateExpression($q, 'c');
$q->where($expr);
return $q->executeQuery()->fetchAllAssociative();
}
public function getCountsForPendingContacts($campaignId, array $pendingEvents, ContactLimiter $limiter): CountResult
{
$q = $this->getReplicaConnection($limiter)->createQueryBuilder();
$q->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(
$q->expr()->and(
$q->expr()->eq('cl.campaign_id', (int) $campaignId),
$q->expr()->eq('cl.manually_removed', ':false')
)
)
->setParameter('false', false, 'boolean');
$this->updateQueryFromContactLimiter('cl', $q, $limiter, true);
if (count($pendingEvents) > 0) {
$sq = $this->getEntityManager()->getConnection()->createQueryBuilder();
$sq->select('null')
->from(MAUTIC_TABLE_PREFIX.'campaign_lead_event_log', 'e')
->where(
$sq->expr()->and(
$sq->expr()->eq('cl.lead_id', 'e.lead_id'),
$sq->expr()->eq('e.rotation', 'cl.rotation'),
$sq->expr()->in('e.event_id', $pendingEvents)
)
);
$this->updateQueryFromContactLimiter('e', $sq, $limiter, true);
$q->andWhere(
sprintf('NOT EXISTS (%s)', $sq->getSQL())
);
}
$result = $q->executeQuery()->fetchAssociative();
return new CountResult($result['the_count'], $result['min_id'], $result['max_id']);
}
/**
* Get pending contact IDs for a campaign.
*/
public function getPendingContactIds($campaignId, ContactLimiter $limiter): array
{
if ($limiter->hasCampaignLimit() && 0 === $limiter->getCampaignLimitRemaining()) {
return [];
}
$q = $this->getReplicaConnection($limiter)->createQueryBuilder();
$q->select('cl.lead_id')
->from(MAUTIC_TABLE_PREFIX.'campaign_leads', 'cl')
->where(
$q->expr()->and(
$q->expr()->eq('cl.campaign_id', (int) $campaignId),
$q->expr()->eq('cl.manually_removed', ':false')
)
)
->setParameter('false', false, 'boolean')
->orderBy('cl.lead_id', 'ASC');
$this->updateQueryFromContactLimiter('cl', $q, $limiter);
// Only leads that have not started the campaign
$sq = $this->getReplicaConnection($limiter)->createQueryBuilder();
$sq->select('null')
->from(MAUTIC_TABLE_PREFIX.'campaign_lead_event_log', 'e')
->where(
$sq->expr()->and(
$sq->expr()->eq('e.lead_id', 'cl.lead_id'),
$sq->expr()->eq('e.campaign_id', (int) $campaignId),
$sq->expr()->eq('e.rotation', 'cl.rotation')
)
);
$q->andWhere(
sprintf('NOT EXISTS (%s)', $sq->getSQL())
);
if ($limiter->hasCampaignLimit() && $limiter->getCampaignLimitRemaining() < $limiter->getBatchLimit()) {
$q->setMaxResults($limiter->getCampaignLimitRemaining());
}
$results = $q->executeQuery()->fetchAllAssociative();
$leads = [];
foreach ($results as $r) {
$leads[] = $r['lead_id'];
}
unset($results);
if ($limiter->hasCampaignLimit()) {
$limiter->reduceCampaignLimitRemaining(count($leads));
}
return $leads;
}
/**
* Get a count of leads that belong to the campaign.
*
* @param int $campaignId
* @param int $leadId Optional lead ID to check if lead is part of campaign
* @param array $pendingEvents List of specific events to rule out
*
* @throws \Doctrine\DBAL\Cache\CacheException
*/
public function getCampaignLeadCount($campaignId, $leadId = null, $pendingEvents = [], ?\DateTimeInterface $dateFrom = null, ?\DateTimeInterface $dateTo = null): int
{
$q = $this->getReplicaConnection()->createQueryBuilder();
$q->select('count(cl.lead_id) as lead_count')
->from(MAUTIC_TABLE_PREFIX.'campaign_leads', 'cl')
->where(
$q->expr()->and(
$q->expr()->eq('cl.campaign_id', (int) $campaignId),
$q->expr()->eq('cl.manually_removed', ':false')
)
)
->setParameter('false', false, Types::BOOLEAN);
if ($leadId) {
$q->andWhere(
$q->expr()->eq('cl.lead_id', (int) $leadId)
);
}
if ($dateFrom && $dateTo) {
$q->andWhere('cl.date_added BETWEEN FROM_UNIXTIME(:dateFrom) AND FROM_UNIXTIME(:dateTo)')
->setParameter('dateFrom', $dateFrom->getTimestamp(), \PDO::PARAM_INT)
->setParameter('dateTo', $dateTo->getTimestamp(), \PDO::PARAM_INT);
}
if (count($pendingEvents) > 0) {
$sq = $this->getReplicaConnection()->createQueryBuilder();
$sq->select('null')
->from(MAUTIC_TABLE_PREFIX.'campaign_lead_event_log', 'e')
->where(
$sq->expr()->and(
$sq->expr()->eq('cl.lead_id', 'e.lead_id'),
$sq->expr()->in('e.event_id', $pendingEvents)
)
);
if ($dateFrom && $dateTo) {
$sq->andWhere('cl.date_triggered BETWEEN FROM_UNIXTIME(:dateFrom) AND FROM_UNIXTIME(:dateTo)')
->setParameter('dateFrom', $dateFrom->getTimestamp(), \PDO::PARAM_INT)
->setParameter('dateTo', $dateTo->getTimestamp(), \PDO::PARAM_INT);
}
$q->andWhere(
sprintf('NOT EXISTS (%s)', $sq->getSQL())
);
}
if ($this->getReplicaConnection()->getConfiguration()->getResultCache()) {
$results = $this->getReplicaConnection()->executeCacheQuery(
$q->getSQL(),
$q->getParameters(),
$q->getParameterTypes(),
new QueryCacheProfile(600)
)->fetchAllAssociative();
} else {
$results = $q->executeQuery()->fetchAllAssociative();
}
return (int) $results[0]['lead_count'];
}
/**
* Get lead data of a campaign.
*
* @param int $start
* @param bool|false $limit
* @param array $select
*
* @return mixed[]
*/
public function getCampaignLeads($campaignId, $start = 0, $limit = false, $select = ['cl.lead_id']): array
{
$q = $this->getReplicaConnection()->createQueryBuilder();
$q->select($select)
->from(MAUTIC_TABLE_PREFIX.'campaign_leads', 'cl')
->where(
$q->expr()->and(
$q->expr()->eq('cl.campaign_id', (int) $campaignId),
$q->expr()->eq('cl.manually_removed', ':false')
)
)
->setParameter('false', false, 'boolean')
->orderBy('cl.lead_id', 'ASC');
if (!empty($limit)) {
$q->setFirstResult($start)
->setMaxResults($limit);
}
return $q->executeQuery()->fetchAllAssociative();
}
/**
* @return mixed
*/
public function getContactSingleSegmentByCampaign($contactId, $campaignId)
{
$q = $this->getEntityManager()->getConnection()->createQueryBuilder();
return $q->select('ll.id, ll.name')
->from(MAUTIC_TABLE_PREFIX.'lead_lists', 'll')
->join('ll', MAUTIC_TABLE_PREFIX.'lead_lists_leads', 'lll', 'lll.leadlist_id = ll.id and lll.lead_id = :contactId and lll.manually_removed = 0')
->join('ll', MAUTIC_TABLE_PREFIX.'campaign_leadlist_xref', 'clx', 'clx.leadlist_id = ll.id and clx.campaign_id = :campaignId')
->setParameter('contactId', (int) $contactId)
->setParameter('campaignId', (int) $campaignId)
->setMaxResults(1)
->executeQuery()
->fetchAssociative();
}
/**
* @param int $segmentId
* @param array $campaignIds
*/
public function getCampaignsSegmentShare($segmentId, $campaignIds = []): array
{
$q = $this->getEntityManager()->getConnection()->createQueryBuilder();
$q->select('c.id, c.name, ROUND(IFNULL(COUNT(DISTINCT t.lead_id)/COUNT(DISTINCT cl.lead_id)*100, 0),1) segmentCampaignShare');
$q->from(MAUTIC_TABLE_PREFIX.'campaigns', 'c')
->leftJoin('c', MAUTIC_TABLE_PREFIX.'campaign_leads', 'cl', 'cl.campaign_id = c.id AND cl.manually_removed = 0')
->leftJoin('cl',
'(SELECT lll.lead_id AS ll, lll.lead_id FROM '.MAUTIC_TABLE_PREFIX.'lead_lists_leads lll WHERE lll.leadlist_id = '.$segmentId
.' AND lll.manually_removed = 0)',
't',
't.lead_id = cl.lead_id'
);
$q->groupBy('c.id');
if (!empty($campaignIds)) {
$q->where($q->expr()->in('c.id', $campaignIds));
}
return $q->executeQuery()->fetchAllAssociative();
}
/**
* Searches for emails assigned to campaign and returns associative array of email ids in format:.
*
* array (size=1)
* 0 =>
* array (size=2)
* 'channelId' => int 18
*
* or empty array if nothing found.
*
* @param int $id
*/
public function fetchEmailIdsById($id): array
{
$emails = $this->getEntityManager()
->createQueryBuilder()
->select('e.channelId')
->from(Campaign::class, $this->getTableAlias(), $this->getTableAlias().'.id')
->leftJoin(
$this->getTableAlias().'.events',
'e',
Expr\Join::WITH,
"e.channel = '".Event::CHANNEL_EMAIL."'"
)
->where($this->getTableAlias().'.id = :id')
->setParameter('id', $id)
->andWhere('e.channelId IS NOT NULL')
->getQuery()
->setHydrationMode(\Doctrine\ORM\Query::HYDRATE_ARRAY)
->getResult();
$return = [];
foreach ($emails as $email) {
// Every channelId represents e-mail ID
$return[] = $email['channelId']; // mautic_campaign_events.channel_id
}
return $return;
}
/**
* @return array<int, int>
*/
public function getCampaignIdsWithDependenciesOnEmail(int $emailId): array
{
$query = $this->getEntityManager()
->createQueryBuilder()
->select($this->getTableAlias().'.id')
->distinct()
->from(Campaign::class, $this->getTableAlias(), $this->getTableAlias().'.id')
->leftJoin(
$this->getTableAlias().'.events',
'e',
Expr\Join::WITH,
"e.channel = '".Event::CHANNEL_EMAIL."'"
)
->where('e.channelId = :emailId')
->setParameter('emailId', $emailId)
->getQuery();
return array_unique(array_map(fn ($val): int => (int) $val, $query->getSingleColumnResult()));
}
/**
* @return array<string, mixed>
*
* @throws Exception
*/
public function getCampaignPublishAndVersionData(int $campaignId): array
{
$result = $this->getEntityManager()->getConnection()
->executeQuery(
'SELECT is_published, version FROM '.MAUTIC_TABLE_PREFIX.'campaigns WHERE id = ? FOR UPDATE',
[$campaignId],
[\PDO::PARAM_INT]
)->fetchAssociative();
return $result ?: [];
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace Mautic\CampaignBundle\Entity;
interface ChannelInterface
{
/**
* @return string
*/
public function getChannel();
public function setChannel($channel): void;
/**
* @return int|string
*/
public function getChannelId();
public function setChannelId($id): void;
}

View File

@@ -0,0 +1,111 @@
<?php
namespace Mautic\CampaignBundle\Entity;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Query\QueryBuilder as DbalQueryBuilder;
use Doctrine\ORM\QueryBuilder as OrmQueryBuilder;
use Mautic\CampaignBundle\Executioner\ContactFinder\Limiter\ContactLimiter;
trait ContactLimiterTrait
{
/**
* @param string $alias
* @param bool $isCount
*/
private function updateQueryFromContactLimiter($alias, DbalQueryBuilder $qb, ContactLimiter $contactLimiter, $isCount = false): void
{
$minContactId = $contactLimiter->getMinContactId();
$maxContactId = $contactLimiter->getMaxContactId();
if ($contactId = $contactLimiter->getContactId()) {
$qb->andWhere(
$qb->expr()->eq("$alias.lead_id", ':contactId')
)
->setParameter('contactId', $contactId, \Doctrine\DBAL\ParameterType::INTEGER);
} elseif ($contactIds = $contactLimiter->getContactIdList()) {
$qb->andWhere(
$qb->expr()->in("$alias.lead_id", ':contactIds')
)
->setParameter('contactIds', $contactIds, ArrayParameterType::INTEGER);
} elseif ($minContactId && $maxContactId) {
$qb->andWhere(
"$alias.lead_id BETWEEN :minContactId AND :maxContactId"
)
->setParameter('minContactId', $minContactId, \Doctrine\DBAL\ParameterType::INTEGER)
->setParameter('maxContactId', $maxContactId, \Doctrine\DBAL\ParameterType::INTEGER);
} elseif ($minContactId) {
$qb->andWhere(
$qb->expr()->gte("$alias.lead_id", ':minContactId')
)
->setParameter('minContactId', $minContactId, \Doctrine\DBAL\ParameterType::INTEGER);
} elseif ($maxContactId) {
$qb->andWhere(
$qb->expr()->lte("$alias.lead_id", ':maxContactId')
)
->setParameter('maxContactId', $maxContactId, \Doctrine\DBAL\ParameterType::INTEGER);
}
if ($threadId = $contactLimiter->getThreadId()) {
if ($maxThreads = $contactLimiter->getMaxThreads()) {
if ($threadId <= $maxThreads) {
$qb->andWhere("MOD(($alias.lead_id + :threadShift), :maxThreads) = 0")
->setParameter('threadShift', $threadId - 1, \Doctrine\DBAL\ParameterType::INTEGER)
->setParameter('maxThreads', $maxThreads, \Doctrine\DBAL\ParameterType::INTEGER);
}
}
}
if (!$isCount && $limit = $contactLimiter->getBatchLimit()) {
$qb->setMaxResults($limit);
}
}
/**
* @param string $alias
* @param bool $isCount
*/
private function updateOrmQueryFromContactLimiter($alias, OrmQueryBuilder $qb, ContactLimiter $contactLimiter, $isCount = false): void
{
$minContactId = $contactLimiter->getMinContactId();
$maxContactId = $contactLimiter->getMaxContactId();
if ($contactId = $contactLimiter->getContactId()) {
$qb->andWhere(
$qb->expr()->eq("IDENTITY($alias.lead)", ':contact')
)
->setParameter('contact', $contactId, \Doctrine\DBAL\ParameterType::INTEGER);
} elseif ($contactIds = $contactLimiter->getContactIdList()) {
$qb->andWhere(
$qb->expr()->in("IDENTITY($alias.lead)", ':contactIds')
)
->setParameter('contactIds', $contactIds, ArrayParameterType::INTEGER);
} elseif ($minContactId && $maxContactId) {
$qb->andWhere(
"IDENTITY($alias.lead) BETWEEN :minContactId AND :maxContactId"
)
->setParameter('minContactId', $minContactId, \Doctrine\DBAL\ParameterType::INTEGER)
->setParameter('maxContactId', $maxContactId, \Doctrine\DBAL\ParameterType::INTEGER);
} elseif ($minContactId) {
$qb->andWhere(
$qb->expr()->gte("IDENTITY($alias.lead)", ':minContactId')
)
->setParameter('minContactId', $minContactId, \Doctrine\DBAL\ParameterType::INTEGER);
} elseif ($maxContactId) {
$qb->andWhere(
$qb->expr()->lte("IDENTITY($alias.lead)", ':maxContactId')
)
->setParameter('maxContactId', $maxContactId, \Doctrine\DBAL\ParameterType::INTEGER);
}
if ($threadId = $contactLimiter->getThreadId()) {
if ($maxThreads = $contactLimiter->getMaxThreads()) {
$qb->andWhere("MOD((IDENTITY($alias.lead) + :threadShift), :maxThreads) = 0")
->setParameter('threadShift', $threadId - 1, \Doctrine\DBAL\ParameterType::INTEGER)
->setParameter('maxThreads', $maxThreads, \Doctrine\DBAL\ParameterType::INTEGER);
}
}
if (!$isCount && $limit = $contactLimiter->getBatchLimit()) {
$qb->setMaxResults($limit);
}
}
}

View File

@@ -0,0 +1,437 @@
<?php
namespace Mautic\CampaignBundle\Entity;
use Doctrine\Common\Collections\Order;
use Doctrine\DBAL\ArrayParameterType;
use Mautic\CoreBundle\Entity\CommonRepository;
/**
* @extends CommonRepository<Event>
*/
class EventRepository extends CommonRepository
{
/**
* Get a list of entities.
*
* @param mixed[] $args
*
* @return \Doctrine\ORM\Tools\Pagination\Paginator<object>|object[]|mixed[]
*/
public function getEntities(array $args = [])
{
$select = 'e';
$q = $this
->createQueryBuilder('e')
->join('e.campaign', 'c');
if (!empty($args['campaign_id'])) {
$q->andWhere(
$q->expr()->eq('IDENTITY(e.campaign)', (int) $args['campaign_id'])
);
}
if (empty($args['ignore_children'])) {
$select .= ', ec, ep';
$q->leftJoin('e.children', 'ec')
->leftJoin('e.parent', 'ep');
}
$q->select($select);
$args['qb'] = $q;
return parent::getEntities($args);
}
/**
* @param int $contactId
* @param string $type
*
* @return array
*/
public function getContactPendingEvents($contactId, $type)
{
// Limit to events that hasn't been executed or scheduled yet
$eventQb = $this->getEntityManager()->createQueryBuilder();
$eventQb->select('IDENTITY(log_event.event)')
->from(LeadEventLog::class, 'log_event')
->where(
$eventQb->expr()->andX(
$eventQb->expr()->eq('log_event.event', 'e'),
$eventQb->expr()->eq('log_event.lead', 'l.lead'),
$eventQb->expr()->eq('log_event.rotation', 'l.rotation')
)
);
// Limit to events that has no parent or whose parent has already been executed
$parentQb = $this->getEntityManager()->createQueryBuilder();
$parentQb->select('parent_log_event.id')
->from(LeadEventLog::class, 'parent_log_event')
->where(
$parentQb->expr()->eq('parent_log_event.event', 'e.parent'),
$parentQb->expr()->eq('parent_log_event.lead', 'l.lead'),
$parentQb->expr()->eq('parent_log_event.rotation', 'l.rotation'),
$parentQb->expr()->eq('parent_log_event.isScheduled', 0)
);
$q = $this->createQueryBuilder('e', 'e.id');
$q->select('e,c')
->innerJoin('e.campaign', 'c')
->innerJoin('c.leads', 'l')
->where(
$q->expr()->andX(
$q->expr()->eq('c.isPublished', 1),
$q->expr()->orX(
$q->expr()->isNull('c.publishUp'),
$q->expr()->lt('c.publishUp', 'CURRENT_TIMESTAMP()'),
),
$q->expr()->orX(
$q->expr()->isNull('c.publishDown'),
$q->expr()->gt('c.publishDown', 'CURRENT_TIMESTAMP()'),
),
$q->expr()->isNull('c.deleted'),
$q->expr()->eq('e.type', ':type'),
$q->expr()->isNull('e.deleted'),
$q->expr()->eq('IDENTITY(l.lead)', ':contactId'),
$q->expr()->eq('l.manuallyRemoved', 0),
$q->expr()->notIn('e.id', $eventQb->getDQL()),
$q->expr()->orX(
$q->expr()->isNull('e.parent'),
$q->expr()->exists($parentQb->getDQL())
)
)
)
->setParameter('type', $type)
->setParameter('contactId', (int) $contactId);
return $q->getQuery()->getResult();
}
/**
* Get array of events by parent.
*
* @param int $parentId
* @param string|null $decisionPath
* @param string|null $eventType
*
* @return array
*/
public function getEventsByParent($parentId, $decisionPath = null, $eventType = null)
{
$q = $this->getEntityManager()->createQueryBuilder();
$q->select('e')
->from(Event::class, 'e', 'e.id')
->where(
$q->expr()->eq('IDENTITY(e.parent)', (int) $parentId)
);
if (null !== $decisionPath) {
$q->andWhere(
$q->expr()->eq('e.decisionPath', ':decisionPath')
)
->setParameter('decisionPath', $decisionPath);
}
if (null !== $eventType) {
$q->andWhere(
$q->expr()->eq('e.eventType', ':eventType')
)
->setParameter('eventType', $eventType);
}
return $q->getQuery()->getArrayResult();
}
/**
* @param int $campaignId
* @param bool $ignoreDeleted
*
* @return array<int,mixed[]>
*/
public function getCampaignEvents($campaignId, $ignoreDeleted = true): array
{
$q = $this->getEntityManager()->createQueryBuilder();
$q->select('e, IDENTITY(e.parent)')
->from(Event::class, 'e', 'e.id')
->where(
$q->expr()->eq('IDENTITY(e.campaign)', (int) $campaignId)
)
->orderBy('e.order', Order::Ascending->value);
if ($ignoreDeleted) {
$q->andWhere($q->expr()->isNull('e.deleted'));
}
$results = $q->getQuery()->getArrayResult();
// Fix the parent ID
$events = [];
foreach ($results as $id => $r) {
$r[0]['parent_id'] = $r[1];
$events[$id] = $r[0];
}
unset($results);
return $events;
}
/**
* @return string[]
*/
public function getCampaignEventIds(int $campaignId): array
{
$q = $this->getEntityManager()->getConnection()->createQueryBuilder();
$q->select('e.id')
->from(MAUTIC_TABLE_PREFIX.Event::TABLE_NAME, 'e')
->where($q->expr()->eq('e.campaign_id', $campaignId));
return array_column($q->executeQuery()->fetchAllAssociative(), 'id');
}
/**
* Get array of events with stats.
*
* @param array $args
*
* @return array
*/
public function getEvents($args = [])
{
$q = $this->createQueryBuilder('e')
->select('e, ec, ep')
->join('e.campaign', 'c')
->leftJoin('e.children', 'ec')
->leftJoin('e.parent', 'ep')
->orderBy('e.order');
if (!empty($args['campaigns'])) {
$q->andWhere($q->expr()->in('e.campaign', ':campaigns'))
->setParameter('campaigns', $args['campaigns']);
}
if (isset($args['positivePathOnly'])) {
$q->andWhere(
$q->expr()->orX(
$q->expr()->neq(
'e.decisionPath',
$q->expr()->literal('no')
),
$q->expr()->isNull('e.decisionPath')
)
);
}
return $q->getQuery()->getArrayResult();
}
/**
* Null event parents in preparation for deleI'lting a campaign.
*
* @param int $campaignId
*/
public function nullEventParents($campaignId): void
{
$this->getEntityManager()->getConnection()->update(
MAUTIC_TABLE_PREFIX.'campaign_events',
['parent_id' => null],
['campaign_id' => (int) $campaignId]
);
}
/**
* Null event parents in preparation for deleting events from a campaign.
*
* @param string[] $events
*/
public function nullEventRelationships($events): void
{
$qb = $this->getEntityManager()->getConnection()->createQueryBuilder();
$qb->update(MAUTIC_TABLE_PREFIX.'campaign_events')
->set('parent_id', ':null')
->setParameter('null', null)
->where(
$qb->expr()->in('parent_id', $events)
)
->executeStatement();
}
/**
* @param string[] $eventIds
*/
public function deleteEvents(array $eventIds): void
{
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->delete(Event::class, 'e')
->where($qb->expr()->in('e.id', ':event_ids'))
->setParameter('event_ids', $eventIds, ArrayParameterType::INTEGER)
->getQuery()
->execute();
}
/**
* @param string[] $eventIds
*/
public function setEventsAsDeleted(array $eventIds): void
{
$dateTime = (new \DateTime())->format('Y-m-d H:i:s');
$qb = $this->getEntityManager()->getConnection()->createQueryBuilder();
$qb->update(MAUTIC_TABLE_PREFIX.Event::TABLE_NAME)
->set('deleted', ':deleted')
->setParameter('deleted', $dateTime)
->where(
$qb->expr()->in('id', $eventIds)
)
->executeStatement();
}
public function getTableAlias(): string
{
return 'e';
}
/**
* For the API.
*
* @return string[]
*/
public function getSearchCommands(): array
{
return $this->getStandardSearchCommands();
}
/**
* Get an array of events that have been triggered by this lead.
*/
public function getLeadTriggeredEvents($leadId): array
{
$q = $this->getEntityManager()->createQueryBuilder()
->select('e, c, l')
->from(Event::class, 'e')
->join('e.campaign', 'c')
->join('e.log', 'l');
// make sure the published up and down dates are good
$q->where($q->expr()->eq('IDENTITY(l.lead)', (int) $leadId));
$results = $q->getQuery()->getArrayResult();
$return = [];
foreach ($results as $r) {
$return[$r['id']] = $r;
}
return $return;
}
/**
* {@inheritdoc}
*
* For the API
*/
protected function addCatchAllWhereClause($q, $filter): array
{
return $this->addStandardCatchAllWhereClause(
$q,
$filter,
[
$this->getTableAlias().'.name',
]
);
}
/**
* {@inheritdoc}
*
* For the API
*/
protected function addSearchCommandWhereClause($q, $filter): array
{
return $this->addStandardSearchCommandWhereClause($q, $filter);
}
/**
* Update the failed count using DBAL to avoid
* race conditions and deadlocks.
*/
public function incrementFailedCount(Event $event): int
{
$q = $this->_em->getConnection()->createQueryBuilder();
$q->update(MAUTIC_TABLE_PREFIX.'campaign_events')
->set('failed_count', 'failed_count + 1')
->where($q->expr()->eq('id', ':id'))
->setParameter('id', $event->getId());
$q->executeStatement();
return $this->getFailedCount($event);
}
/**
* Update the failed count using DBAL to avoid
* race conditions and deadlocks.
*/
public function decreaseFailedCount(Event $event): void
{
$q = $this->_em->getConnection()->createQueryBuilder();
$q->update(MAUTIC_TABLE_PREFIX.'campaign_events')
->set('failed_count', 'failed_count - 1')
->where($q->expr()->eq('id', ':id'))
->andWhere($q->expr()->gt('failed_count', 0))
->setParameter('id', $event->getId());
$q->executeStatement();
}
/**
* Get the up to date failed count
* for the given Event.
*/
public function getFailedCount(Event $event): int
{
$q = $this->_em->getConnection()->createQueryBuilder();
$q->select('failed_count')
->from(MAUTIC_TABLE_PREFIX.'campaign_events')
->where($q->expr()->eq('id', ':id'))
->setParameter('id', $event->getId());
return (int) $q->executeQuery()->fetchOne();
}
/**
* Reset the failed_count's for all events
* within the given Campaign.
*/
public function resetFailedCountsForEventsInCampaign(Campaign $campaign): void
{
$q = $this->_em->getConnection()->createQueryBuilder();
$q->update(MAUTIC_TABLE_PREFIX.'campaign_events')
->set('failed_count', ':failedCount')
->where($q->expr()->eq('campaign_id', ':campaignId'))
->setParameter('failedCount', 0)
->setParameter('campaignId', $campaign->getId());
$q->executeStatement();
}
/**
* Get the count of failed event for Lead/Event.
*/
public function getFailedCountLeadEvent(int $leadId, int $eventId): int
{
$q = $this->_em->getConnection()->createQueryBuilder();
$q->select('count(le.id)')
->from(MAUTIC_TABLE_PREFIX.'campaign_lead_event_log', 'le')
->innerJoin('le', MAUTIC_TABLE_PREFIX.'campaign_lead_event_failed_log', 'fle', 'le.id = fle.log_id')
->where('le.lead_id = :leadId')
->andWhere('le.event_id = :eventId')
->setParameters(['leadId' => $leadId, 'eventId' => $eventId]);
return (int) $q->executeQuery()->fetchOne();
}
}

View File

@@ -0,0 +1,123 @@
<?php
namespace Mautic\CampaignBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Mautic\ApiBundle\Serializer\Driver\ApiMetadataDriver;
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
class FailedLeadEventLog
{
/**
* @var LeadEventLog
*/
private $log;
/**
* @var \DateTimeInterface
*/
private $dateAdded;
/**
* @var string|null
*/
private $reason;
public static function loadMetadata(ORM\ClassMetadata $metadata): void
{
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable('campaign_lead_event_failed_log')
->setCustomRepositoryClass(FailedLeadEventLogRepository::class)
->addIndex(['date_added'], 'campaign_event_failed_date');
$builder->createOneToOne('log', 'LeadEventLog')
->makePrimaryKey()
->inversedBy('failedLog')
->addJoinColumn('log_id', 'id', false, false, 'CASCADE')
->build();
$builder->addDateAdded();
$builder->addNullableField('reason', 'text');
}
/**
* Prepares the metadata for API usage.
*/
public static function loadApiMetadata(ApiMetadataDriver $metadata): void
{
$metadata->setGroupPrefix('campaignEventFailedLog')
->addProperties(
[
'dateAdded',
'reason',
]
)
->build();
}
/**
* @return LeadEventLog
*/
public function getLog()
{
return $this->log;
}
/**
* @return FailedLeadEventLog
*/
public function setLog(?LeadEventLog $log = null)
{
$this->log = $log;
if ($log) {
$log->setFailedLog($this);
}
return $this;
}
/**
* @return \DateTimeInterface
*/
public function getDateAdded()
{
return $this->dateAdded;
}
/**
* @return FailedLeadEventLog
*/
public function setDateAdded(?\DateTime $dateAdded = null)
{
if (null === $dateAdded) {
$dateAdded = new \DateTime();
}
$this->dateAdded = $dateAdded;
return $this;
}
/**
* @return string
*/
public function getReason()
{
return $this->reason;
}
/**
* @param string $reason
*
* @return FailedLeadEventLog
*/
public function setReason($reason)
{
$this->reason = $reason;
return $this;
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace Mautic\CampaignBundle\Entity;
use Doctrine\DBAL\ArrayParameterType;
use Mautic\CoreBundle\Entity\CommonRepository;
/**
* @extends CommonRepository<FailedLeadEventLog>
*/
class FailedLeadEventLogRepository extends CommonRepository
{
/**
* @param array<string|int> $ids
*/
public function deleteByIds(array $ids): void
{
if (!$ids) {
return;
}
$this->_em->getConnection()
->createQueryBuilder()
->delete(MAUTIC_TABLE_PREFIX.'campaign_lead_event_failed_log')
->where('log_id IN (:ids)')
->setParameter('ids', $ids, ArrayParameterType::STRING)
->executeStatement();
}
}

View File

@@ -0,0 +1,241 @@
<?php
namespace Mautic\CampaignBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Mautic\ApiBundle\Serializer\Driver\ApiMetadataDriver;
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
class Lead
{
/**
* @var Campaign
*/
private $campaign;
/**
* @var \Mautic\LeadBundle\Entity\Lead
*/
private $lead;
/**
* @var \DateTimeInterface
**/
private $dateAdded;
/**
* @var \DateTimeInterface
*/
private $dateLastExited;
/**
* @var bool
*/
private $manuallyRemoved = false;
/**
* @var bool
*/
private $manuallyAdded = false;
/**
* @var int
*/
private $rotation = 1;
public static function loadMetadata(ORM\ClassMetadata $metadata): void
{
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable('campaign_leads')
->setCustomRepositoryClass(LeadRepository::class)
->addIndex(['date_added'], 'campaign_leads_date_added')
->addIndex(['date_last_exited'], 'campaign_leads_date_exited')
->addIndex(['campaign_id', 'manually_removed', 'lead_id', 'rotation'], 'campaign_leads');
$builder->createManyToOne('campaign', 'Campaign')
->makePrimaryKey()
->inversedBy('leads')
->addJoinColumn('campaign_id', 'id', false, false, 'CASCADE')
->build();
$builder->addLead(false, 'CASCADE', true);
$builder->addDateAdded();
$builder->createField('manuallyRemoved', 'boolean')
->columnName('manually_removed')
->build();
$builder->createField('manuallyAdded', 'boolean')
->columnName('manually_added')
->build();
$builder->addNamedField('dateLastExited', 'datetime', 'date_last_exited', true);
$builder->addField('rotation', 'integer');
}
/**
* Prepares the metadata for API usage.
*/
public static function loadApiMetadata(ApiMetadataDriver $metadata): void
{
$metadata->setGroupPrefix('campaignLead')
->addListProperties(
[
'dateAdded',
'manuallyRemoved',
'manuallyAdded',
'rotation',
'dateLastExited',
]
)
->addProperties(
[
'lead',
'campaign',
]
)
->build();
}
/**
* @return \DateTimeInterface
*/
public function getDateAdded()
{
return $this->dateAdded;
}
/**
* @param \DateTime $date
*/
public function setDateAdded($date): void
{
$this->dateAdded = $date;
}
/**
* @return \Mautic\LeadBundle\Entity\Lead
*/
public function getLead()
{
return $this->lead;
}
public function setLead(\Mautic\LeadBundle\Entity\Lead $lead): void
{
$this->lead = $lead;
}
/**
* @return Campaign
*/
public function getCampaign()
{
return $this->campaign;
}
public function setCampaign(Campaign $campaign): void
{
$this->campaign = $campaign;
}
/**
* @return bool
*/
public function getManuallyRemoved()
{
return $this->manuallyRemoved;
}
/**
* @param bool $manuallyRemoved
*/
public function setManuallyRemoved($manuallyRemoved): void
{
$this->manuallyRemoved = $manuallyRemoved;
}
/**
* @return bool
*/
public function wasManuallyRemoved()
{
return $this->manuallyRemoved;
}
/**
* @return bool
*/
public function getManuallyAdded()
{
return $this->manuallyAdded;
}
/**
* @param bool $manuallyAdded
*/
public function setManuallyAdded($manuallyAdded): void
{
$this->manuallyAdded = $manuallyAdded;
}
/**
* @return bool
*/
public function wasManuallyAdded()
{
return $this->manuallyAdded;
}
/**
* @return int
*/
public function getRotation()
{
return $this->rotation;
}
/**
* @param int $rotation
*
* @return Lead
*/
public function setRotation($rotation)
{
$this->rotation = (int) $rotation;
return $this;
}
/**
* @return $this
*/
public function startNewRotation()
{
++$this->rotation;
$this->dateAdded = new \DateTime();
return $this;
}
/**
* @return \DateTimeInterface|null
*/
public function getDateLastExited()
{
return $this->dateLastExited;
}
/**
* @return Lead
*/
public function setDateLastExited(?\DateTime $dateLastExited = null)
{
$this->dateLastExited = $dateLastExited;
return $this;
}
}

View File

@@ -0,0 +1,566 @@
<?php
namespace Mautic\CampaignBundle\Entity;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Mautic\ApiBundle\Serializer\Driver\ApiMetadataDriver;
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
use Mautic\CoreBundle\Entity\IpAddress;
use Mautic\CoreBundle\Entity\OptimisticLockInterface;
use Mautic\CoreBundle\Entity\OptimisticLockTrait;
use Mautic\LeadBundle\Entity\Lead as LeadEntity;
class LeadEventLog implements ChannelInterface, OptimisticLockInterface
{
use OptimisticLockTrait;
public const TABLE_NAME = 'campaign_lead_event_log';
/**
* @var string|null
*/
private $id;
/**
* @var Event
*/
private $event;
/**
* @var LeadEntity
*/
private $lead;
/**
* @var Campaign|null
*/
private $campaign;
/**
* @var IpAddress|null
*/
private $ipAddress;
/**
* @var \DateTimeInterface|null
**/
private $dateTriggered;
/**
* @var bool
*/
private $isScheduled = false;
/**
* @var \DateTimeInterface|null
*/
private $triggerDate;
/**
* @var bool
*/
private $systemTriggered = false;
/**
* @var array
*/
private $metadata = [];
/**
* @var bool|null
*/
private $nonActionPathTaken = false;
/**
* @var string|null
*/
private $channel;
/**
* @var int|null
*/
private $channelId;
/**
* @var bool|null
*/
private $previousScheduledState;
/**
* @var int
*/
private $rotation = 1;
/**
* @var FailedLeadEventLog|null
*/
private $failedLog;
/**
* Subscribers can fail log with custom reschedule interval.
*
* @var \DateInterval|null
*/
private $rescheduleInterval;
private ?\DateTime $dateQueued = null;
public static function loadMetadata(ORM\ClassMetadata $metadata): void
{
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable(self::TABLE_NAME)
->setCustomRepositoryClass(LeadEventLogRepository::class)
->addIndex(['is_scheduled', 'lead_id'], 'campaign_event_upcoming_search')
->addIndex(['campaign_id', 'is_scheduled', 'trigger_date'], 'campaign_event_schedule_counts')
->addIndex(['date_triggered'], 'campaign_date_triggered')
->addIndex(['lead_id', 'campaign_id', 'rotation'], 'campaign_leads')
->addIndex(['channel', 'channel_id', 'lead_id'], 'campaign_log_channel')
->addIndex(['campaign_id', 'event_id', 'date_triggered'], 'campaign_actions')
->addIndex(['campaign_id', 'date_triggered', 'event_id', 'non_action_path_taken'], 'campaign_stats')
->addIndex(['trigger_date'], 'campaign_trigger_date_order')
->addIndex(['is_scheduled', 'event_id', 'trigger_date'], 'idx_scheduled_events')
->addUniqueConstraint(['event_id', 'lead_id', 'rotation'], 'campaign_rotation');
$builder->addBigIntIdField();
$builder->createManyToOne('event', 'Event')
->inversedBy('log')
->addJoinColumn('event_id', 'id', false, false)
->build();
$builder->addLead(false, 'CASCADE');
$builder->addField('rotation', 'integer');
$builder->createManyToOne('campaign', 'Campaign')
->addJoinColumn('campaign_id', 'id')
->build();
$builder->addIpAddress(true);
$builder->createField('dateTriggered', 'datetime')
->columnName('date_triggered')
->nullable()
->build();
$builder->createField('isScheduled', 'boolean')
->columnName('is_scheduled')
->build();
$builder->createField('triggerDate', 'datetime')
->columnName('trigger_date')
->nullable()
->build();
$builder->createField('systemTriggered', 'boolean')
->columnName('system_triggered')
->build();
$builder->createField('metadata', 'array')
->nullable()
->build();
$builder->createField('channel', 'string')
->nullable()
->build();
$builder->addNamedField('channelId', 'integer', 'channel_id', true);
$builder->addNullableField('nonActionPathTaken', 'boolean', 'non_action_path_taken');
$builder->createOneToOne('failedLog', 'FailedLeadEventLog')
->mappedBy('log')
->fetchExtraLazy()
->cascadeAll()
->build();
$builder->createField('dateQueued', Types::DATETIME_MUTABLE)
->columnName('date_queued')
->nullable()
->build();
self::addVersionField($builder);
}
/**
* Prepares the metadata for API usage.
*/
public static function loadApiMetadata(ApiMetadataDriver $metadata): void
{
$metadata->setGroupPrefix('campaignEventLog')
->addProperties(
[
'ipAddress',
'dateTriggered',
'isScheduled',
'triggerDate',
'metadata',
'nonActionPathTaken',
'channel',
'channelId',
'rotation',
]
)
// Add standalone groups
->setGroupPrefix('campaignEventStandaloneLog')
->addProperties(
[
'event',
'lead',
'campaign',
'ipAddress',
'dateTriggered',
'isScheduled',
'triggerDate',
'metadata',
'nonActionPathTaken',
'channel',
'channelId',
'rotation',
]
)
->build();
}
public function getId(): int
{
return (int) $this->id;
}
/**
* @return \DateTimeInterface|null
*/
public function getDateTriggered()
{
return $this->dateTriggered;
}
/**
* @return $this
*/
public function setDateTriggered(?\DateTimeInterface $dateTriggered = null)
{
$this->dateTriggered = $dateTriggered;
if (null !== $dateTriggered) {
$this->setIsScheduled(false);
}
return $this;
}
/**
* @return IpAddress|null
*/
public function getIpAddress()
{
return $this->ipAddress;
}
/**
* @return $this
*/
public function setIpAddress(IpAddress $ipAddress)
{
$this->ipAddress = $ipAddress;
return $this;
}
/**
* @return LeadEntity|null
*/
public function getLead()
{
return $this->lead;
}
/**
* @return $this
*/
public function setLead(LeadEntity $lead)
{
$this->lead = $lead;
return $this;
}
/**
* @return Event|null
*/
public function getEvent()
{
return $this->event;
}
/***
* @param $event
*
* @return $this
*/
public function setEvent(Event $event)
{
$this->event = $event;
if (!$this->campaign) {
$this->setCampaign($event->getCampaign());
}
return $this;
}
/**
* @return bool
*/
public function getIsScheduled()
{
return $this->isScheduled;
}
/**
* @param bool $isScheduled
*
* @return $this
*/
public function setIsScheduled($isScheduled)
{
if (null === $this->previousScheduledState) {
$this->previousScheduledState = $this->isScheduled;
}
$this->isScheduled = $isScheduled;
return $this;
}
/**
* If isScheduled was changed, this will have the previous state.
*
* @return bool|null
*/
public function getPreviousScheduledState()
{
return $this->previousScheduledState;
}
/**
* @return \DateTimeInterface|null
*/
public function getTriggerDate()
{
return $this->triggerDate;
}
/**
* @return $this
*/
public function setTriggerDate(?\DateTimeInterface $triggerDate = null)
{
$this->triggerDate = $triggerDate;
$this->setIsScheduled(true);
return $this;
}
/**
* @return Campaign|null
*/
public function getCampaign()
{
return $this->campaign;
}
/**
* @return $this
*/
public function setCampaign(Campaign $campaign)
{
$this->campaign = $campaign;
return $this;
}
/**
* @return bool
*/
public function getSystemTriggered()
{
return $this->systemTriggered;
}
/**
* @param bool $systemTriggered
*
* @return $this
*/
public function setSystemTriggered($systemTriggered)
{
$this->systemTriggered = $systemTriggered;
return $this;
}
/**
* @return bool
*/
public function getNonActionPathTaken()
{
return $this->nonActionPathTaken;
}
/**
* @param bool $nonActionPathTaken
*
* @return $this
*/
public function setNonActionPathTaken($nonActionPathTaken)
{
$this->nonActionPathTaken = $nonActionPathTaken;
return $this;
}
/**
* @return mixed[]|null
*/
public function getMetadata()
{
return $this->metadata;
}
/**
* @param mixed[] $metadata
*/
public function appendToMetadata($metadata): void
{
if (!is_array($metadata)) {
// Assumed output for timeline BC for <2.14
$metadata = ['timeline' => $metadata];
}
$this->metadata = array_merge($this->metadata, $metadata);
}
/**
* @param mixed[] $metadata
*
* @return $this
*/
public function setMetadata($metadata)
{
if (!is_array($metadata)) {
// Assumed output for timeline
$metadata = ['timeline' => $metadata];
}
$this->metadata = $metadata;
return $this;
}
/**
* @return string|null
*/
public function getChannel()
{
return $this->channel;
}
/**
* @param string $channel
*/
public function setChannel($channel): void
{
$this->channel = $channel;
}
/**
* @return int|null
*/
public function getChannelId()
{
return $this->channelId;
}
/**
* @param int|null $channelId
*/
public function setChannelId($channelId): void
{
$this->channelId = $channelId;
}
/**
* @return int|null
*/
public function getRotation()
{
return $this->rotation;
}
/**
* @param int $rotation
*
* @return LeadEventLog
*/
public function setRotation($rotation)
{
$this->rotation = (int) $rotation;
return $this;
}
/**
* @return FailedLeadEventLog|null
*/
public function getFailedLog()
{
return $this->failedLog;
}
/**
* @return $this
*/
public function setFailedLog(?FailedLeadEventLog $log = null)
{
$this->failedLog = $log;
return $this;
}
public function isFailed(): bool
{
$log = $this->getFailedLog();
return !empty($log);
}
public function isSuccess(): bool
{
return !$this->isFailed();
}
public function setRescheduleInterval(?\DateInterval $interval): void
{
$this->rescheduleInterval = $interval;
}
public function getRescheduleInterval(): ?\DateInterval
{
return $this->rescheduleInterval;
}
public function getDateQueued(): ?\DateTime
{
return $this->dateQueued;
}
public function setDateQueued(?\DateTime $dateQueued): LeadEventLog
{
$this->dateQueued = $dateQueued;
return $this;
}
}

View File

@@ -0,0 +1,699 @@
<?php
namespace Mautic\CampaignBundle\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Cache\QueryCacheProfile;
use Doctrine\DBAL\Types\Types;
use Mautic\CampaignBundle\Executioner\ContactFinder\Limiter\ContactLimiter;
use Mautic\CoreBundle\Entity\CommonRepository;
use Mautic\CoreBundle\Helper\Chart\ChartQuery;
use Mautic\LeadBundle\Entity\TimelineTrait;
/**
* @extends CommonRepository<LeadEventLog>
*/
class LeadEventLogRepository extends CommonRepository
{
use TimelineTrait;
use ContactLimiterTrait;
use ReplicaConnectionTrait;
public const LOG_DELETE_BATCH_SIZE = 5000;
public function getEntities(array $args = [])
{
$alias = $this->getTableAlias();
$q = $this
->createQueryBuilder($alias)
->join($alias.'.ipAddress', 'i');
if (empty($args['campaign_id'])) {
$q->join($alias.'.event', 'e')
->join($alias.'.campaign', 'c');
} else {
$q->andWhere(
$q->expr()->eq('IDENTITY('.$this->getTableAlias().'.campaign)', (int) $args['campaign_id'])
);
}
if (!empty($args['contact_id'])) {
$q->andWhere(
$q->expr()->eq('IDENTITY('.$this->getTableAlias().'.lead)', (int) $args['contact_id'])
);
}
$args['qb'] = $q;
return parent::getEntities($args);
}
public function getTableAlias(): string
{
return 'll';
}
/**
* Get a lead's page event log.
*
* @param int|null $leadId
*
* @return array
*/
public function getLeadLogs($leadId = null, array $options = [])
{
$query = $this->getEntityManager()
->getConnection()
->createQueryBuilder()
->select('ll.id as log_id,
ll.event_id,
ll.campaign_id,
ll.date_triggered as dateTriggered,
e.name AS event_name,
e.description AS event_description,
e.parent_id AS parent_id,
e.decision_path AS decision_path,
c.name AS campaign_name,
c.description AS campaign_description,
ll.metadata,
e.type,
ll.is_scheduled as isScheduled,
ll.trigger_date as triggerDate,
ll.channel,
ll.channel_id as channel_id,
ll.lead_id,
fl.reason as fail_reason
'
)
->add('from', [
'table' => MAUTIC_TABLE_PREFIX.'campaign_lead_event_log',
'alias' => 'll',
'hint' => 'USE INDEX ('.MAUTIC_TABLE_PREFIX.'campaign_date_triggered)',
], true)
->join('ll', MAUTIC_TABLE_PREFIX.'campaign_events', 'e', 'll.event_id = e.id')
->join('ll', MAUTIC_TABLE_PREFIX.'campaigns', 'c', 'll.campaign_id = c.id')
->leftJoin('ll', MAUTIC_TABLE_PREFIX.'campaign_lead_event_failed_log', 'fl', 'fl.log_id = ll.id')
->andWhere('e.event_type != :eventType')
->setParameter('eventType', 'decision');
if ($leadId) {
$query->where('ll.lead_id = :leadId')
->setParameter('leadId', $leadId);
}
if (isset($options['scheduledState'])) {
if ($options['scheduledState']) {
// Include cancelled as well
$query->andWhere(
$query->expr()->or(
$query->expr()->eq('ll.is_scheduled', ':scheduled'),
$query->expr()->and(
$query->expr()->eq('ll.is_scheduled', 0),
$query->expr()->isNull('ll.date_triggered')
)
)
);
} else {
$query->andWhere(
$query->expr()->eq('ll.is_scheduled', ':scheduled')
);
}
$query->setParameter('scheduled', $options['scheduledState'], 'boolean');
}
if (isset($options['search']) && $options['search']) {
$query->andWhere(
$query->expr()->or(
$query->expr()->like('e.name', ':search'),
$query->expr()->like('e.description', ':search'),
$query->expr()->like('c.name', ':search'),
$query->expr()->like('c.description', ':search')
)
)->setParameter('search', '%'.$options['search'].'%');
}
return $this->getTimelineResults($query, $options, 'e.name', 'll.date_triggered', ['metadata'], ['dateTriggered', 'triggerDate'], null, 'll.id');
}
/**
* Get a lead's upcoming events.
*/
public function getUpcomingEvents(?array $options = null): array
{
$leadIps = [];
$query = $this->_em->getConnection()->createQueryBuilder();
$query->from(MAUTIC_TABLE_PREFIX.'campaign_lead_event_log', 'll')
->select('ll.event_id,
ll.campaign_id,
ll.trigger_date,
ll.lead_id,
e.name AS event_name,
e.description AS event_description,
c.name AS campaign_name,
c.description AS campaign_description,
ll.metadata,
CONCAT(CONCAT(l.firstname, \' \'), l.lastname) AS lead_name')
->leftJoin('ll', MAUTIC_TABLE_PREFIX.'campaign_events', 'e', 'e.id = ll.event_id')
->leftJoin('ll', MAUTIC_TABLE_PREFIX.'campaigns', 'c', 'c.id = e.campaign_id')
->leftJoin('ll', MAUTIC_TABLE_PREFIX.'leads', 'l', 'l.id = ll.lead_id')
->where($query->expr()->eq('ll.is_scheduled', 1))
->andWhere('ll.trigger_date > NOW()');
if (isset($options['lead'])) {
/** @var \Mautic\CoreBundle\Entity\IpAddress $ip */
foreach ($options['lead']->getIpAddresses() as $ip) {
$leadIps[] = $ip->getId();
}
$query->andWhere('ll.lead_id = :leadId')
->setParameter('leadId', $options['lead']->getId());
}
if (isset($options['type'])) {
$query->andwhere('e.type = :type')
->setParameter('type', $options['type']);
}
if (isset($options['eventType'])) {
if (is_array($options['eventType'])) {
$query->andWhere(
$query->expr()->in('e.event_type', array_map([$query->expr(), 'literal'], $options['eventType']))
);
} else {
$query->andwhere('e.event_type = :eventTypes')
->setParameter('eventTypes', $options['eventType']);
}
}
if (isset($options['limit'])) {
$query->setMaxResults($options['limit']);
} else {
$query->setMaxResults(10);
}
$query->orderBy('ll.trigger_date');
if (empty($options['canViewOthers']) && isset($this->currentUser)) {
$query->andWhere('c.created_by = :userId')
->setParameter('userId', $this->currentUser->getId());
}
return $query->executeQuery()->fetchAllAssociative();
}
/**
* @param int $campaignId
* @param bool $excludeScheduled
* @param bool $excludeNegative
* @param bool $all
*
* @throws \Doctrine\DBAL\Cache\CacheException
*/
public function getCampaignLogCounts(
$campaignId,
$excludeScheduled = false,
$excludeNegative = true,
$all = false,
?\DateTimeInterface $dateFrom = null,
?\DateTimeInterface $dateTo = null,
?int $eventId = null,
): array {
$join = $all ? 'leftJoin' : 'innerJoin';
$q = $this->_em->getConnection()->createQueryBuilder();
$q->from(MAUTIC_TABLE_PREFIX.'campaign_lead_event_log', 'o');
$q->$join(
'o',
MAUTIC_TABLE_PREFIX.'campaign_leads',
'l',
'l.campaign_id = '.(int) $campaignId.' and o.lead_id = l.lead_id'
);
$expr = $q->expr()->and(
$q->expr()->eq('o.campaign_id', (int) $campaignId)
);
if ($eventId) {
$expr = $expr->with(
$q->expr()->eq('o.event_id', $eventId)
);
}
$groupBy = 'o.event_id';
if ($excludeNegative) {
$q->select('o.event_id, count(o.lead_id) as lead_count');
$expr = $expr->with(
$q->expr()->or(
$q->expr()->isNull('o.non_action_path_taken'),
$q->expr()->eq('o.non_action_path_taken', ':false')
)
);
} else {
$q->select('o.event_id, count(o.lead_id) as lead_count, o.non_action_path_taken');
$groupBy .= ', o.non_action_path_taken';
}
if ($excludeScheduled) {
$expr = $expr->with(
$q->expr()->eq('o.is_scheduled', ':false')
);
}
// Exclude failed events
$failedSq = $this->getReplicaConnection()->createQueryBuilder();
$failedSq->select('null')
->from(MAUTIC_TABLE_PREFIX.'campaign_lead_event_failed_log', 'fe')
->where(
$failedSq->expr()->eq('fe.log_id', 'o.id')
);
if ($dateFrom && $dateTo) {
$failedSq->andWhere('fe.date_added BETWEEN FROM_UNIXTIME(:dateFrom) AND FROM_UNIXTIME(:dateTo)')
->setParameter('dateFrom', $dateFrom->getTimestamp(), \PDO::PARAM_INT)
->setParameter('dateTo', $dateTo->getTimestamp(), \PDO::PARAM_INT);
}
$expr = $expr->with(
sprintf('NOT EXISTS (%s)', $failedSq->getSQL())
);
$q->where($expr)
->setParameter('false', false, 'boolean')
->groupBy($groupBy);
if ($dateFrom && $dateTo) {
$q->andWhere('o.date_triggered BETWEEN FROM_UNIXTIME(:dateFrom) AND FROM_UNIXTIME(:dateTo)')
->setParameter('dateFrom', $dateFrom->getTimestamp(), \PDO::PARAM_INT)
->setParameter('dateTo', $dateTo->getTimestamp(), \PDO::PARAM_INT);
}
if ($this->_em->getConnection()->getConfiguration()->getResultCache()) {
$results = $this->_em->getConnection()->executeCacheQuery(
$q->getSQL(),
$q->getParameters(),
$q->getParameterTypes(),
new QueryCacheProfile(600)
)->fetchAllAssociative();
} else {
$results = $q->executeQuery()->fetchAllAssociative();
}
$return = [];
// group by event id
foreach ($results as $l) {
if (!$excludeNegative) {
if (!isset($return[$l['event_id']])) {
$return[$l['event_id']] = [
0 => 0,
1 => 0,
];
}
$key = (int) $l['non_action_path_taken'] ? 0 : 1;
$return[$l['event_id']][$key] = (int) $l['lead_count'];
} else {
$return[$l['event_id']] = (int) $l['lead_count'];
}
}
return $return;
}
/**
* 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->_em->getConnection()->createQueryBuilder()
->select('cl.event_id')
->from(MAUTIC_TABLE_PREFIX.'campaign_lead_event_log', 'cl')
->where('cl.lead_id = '.$toLeadId)
->executeQuery()
->fetchAllAssociative();
$exists = [];
foreach ($results as $r) {
$exists[] = $r['event_id'];
}
$q = $this->_em->getConnection()->createQueryBuilder();
$q->update(MAUTIC_TABLE_PREFIX.'campaign_lead_event_log')
->set('lead_id', (int) $toLeadId)
->where('lead_id = '.(int) $fromLeadId);
if (!empty($exists)) {
$q->andWhere(
$q->expr()->notIn('event_id', $exists)
)->executeStatement();
// Delete remaining leads as the new lead already belongs
$this->_em->getConnection()->createQueryBuilder()
->delete(MAUTIC_TABLE_PREFIX.'campaign_lead_event_log')
->where('lead_id = '.(int) $fromLeadId)
->executeStatement();
} else {
$q->executeStatement();
}
}
public function getChartQuery($options): array
{
$chartQuery = new ChartQuery($this->getReplicaConnection(), $options['dateFrom'], $options['dateTo']);
// Load points for selected period
$query = $this->getReplicaConnection()->createQueryBuilder();
$query->select('ll.id, ll.date_triggered')
->from(MAUTIC_TABLE_PREFIX.'campaign_lead_event_log', 'll')
->join('ll', MAUTIC_TABLE_PREFIX.'campaign_events', 'e', 'e.id = ll.event_id');
if (isset($options['channel'])) {
$query->andWhere('e.channel = '.$query->expr()->literal($options['channel']));
}
if (isset($options['channelId'])) {
$query->andWhere('e.channel_id = '.(int) $options['channelId']);
}
if (isset($options['type'])) {
$query->andWhere('e.type = '.$query->expr()->literal($options['type']));
}
if (isset($options['logChannel'])) {
$query->andWhere('ll.channel = '.$query->expr()->literal($options['logChannel']));
}
if (isset($options['logChannelId'])) {
$query->andWhere('ll.channel_id = '.(int) $options['logChannelId']);
}
$isScheduled = isset($options['is_scheduled']) ? 1 : 0;
$query->andWhere('ll.is_scheduled = '.$isScheduled);
return $chartQuery->fetchTimeData('('.$query.')', 'date_triggered');
}
/**
* @param int $eventId
*
* @return ArrayCollection
*
* @throws \Doctrine\ORM\Query\QueryException
*/
public function getScheduled($eventId, \DateTime $now, ContactLimiter $limiter)
{
if ($limiter->hasCampaignLimit() && 0 === $limiter->getCampaignLimitRemaining()) {
return new ArrayCollection();
}
$this->getReplicaConnection($limiter);
$q = $this->createQueryBuilder('o');
$q->select('o, e, c')
->indexBy('o', 'o.id')
->innerJoin('o.event', 'e')
->innerJoin('e.campaign', 'c')
->where(
$q->expr()->andX(
$q->expr()->eq('IDENTITY(o.event)', ':eventId'),
$q->expr()->eq('o.isScheduled', ':true'),
$q->expr()->lte('o.triggerDate', ':now'),
$q->expr()->eq('c.isPublished', 1)
)
)
->setParameter('eventId', (int) $eventId)
->setParameter('now', $now)
->setParameter('true', true, Types::BOOLEAN);
$this->updateOrmQueryFromContactLimiter('o', $q, $limiter);
if ($limiter->hasCampaignLimit() && $limiter->getCampaignLimitRemaining() < $limiter->getBatchLimit()) {
$q->setMaxResults($limiter->getCampaignLimitRemaining());
}
$result = new ArrayCollection($q->getQuery()->getResult());
if ($limiter->hasCampaignLimit()) {
$limiter->reduceCampaignLimitRemaining($result->count());
}
return $result;
}
/**
* @throws \Doctrine\ORM\Query\QueryException
*/
public function getScheduledByIds(array $ids): ArrayCollection
{
$this->getReplicaConnection();
$q = $this->createQueryBuilder('o');
$q->select('o, e, c')
->indexBy('o', 'o.id')
->innerJoin('o.event', 'e')
->innerJoin('e.campaign', 'c')
->where(
$q->expr()->andX(
$q->expr()->in('o.id', $ids),
$q->expr()->eq('o.isScheduled', 1),
$q->expr()->eq('c.isPublished', 1),
$q->expr()->isNull('c.deleted'),
$q->expr()->isNull('e.deleted')
)
);
return new ArrayCollection($q->getQuery()->getResult());
}
/**
* @param int $campaignId
*/
public function getScheduledCounts($campaignId, \DateTime $date, ContactLimiter $limiter): array
{
$now = clone $date;
$now->setTimezone(new \DateTimeZone('UTC'));
$q = $this->getReplicaConnection($limiter)->createQueryBuilder();
$expr = $q->expr()->and(
$q->expr()->eq('l.campaign_id', ':campaignId'),
$q->expr()->eq('l.is_scheduled', ':true'),
$q->expr()->lte('l.trigger_date', ':now'),
$q->expr()->eq('c.is_published', 1)
);
$this->updateQueryFromContactLimiter('l', $q, $limiter, true);
$results = $q->select('COUNT(*) as event_count, l.event_id')
->from(MAUTIC_TABLE_PREFIX.'campaign_lead_event_log', 'l')
->join('l', MAUTIC_TABLE_PREFIX.'campaigns', 'c', 'l.campaign_id = c.id')
->where($expr)
->setParameter('campaignId', (int) $campaignId)
->setParameter('now', $now->format('Y-m-d H:i:s'))
->setParameter('true', true, \PDO::PARAM_BOOL)
->groupBy('l.event_id')
->executeQuery()
->fetchAllAssociative();
$events = [];
foreach ($results as $result) {
$events[$result['event_id']] = (int) $result['event_count'];
}
return $events;
}
public function getDatesExecuted($eventId, array $contactIds): array
{
$qb = $this->getReplicaConnection()->createQueryBuilder();
$qb->select('log.lead_id, log.date_triggered, log.is_scheduled')
->from(MAUTIC_TABLE_PREFIX.'campaign_lead_event_log', 'log')
->where(
$qb->expr()->and(
$qb->expr()->eq('log.event_id', $eventId),
$qb->expr()->in('log.lead_id', $contactIds)
)
);
$results = $qb->executeQuery()->fetchAllAssociative();
$dates = [];
foreach ($results as $result) {
$dates[$result['lead_id']] = new \DateTime($result['date_triggered'], new \DateTimeZone('UTC'));
if (1 === (int) $result['is_scheduled']) {
unset($dates[$result['lead_id']]);
}
}
return $dates;
}
public function getOldestTriggeredDate(): ?\DateTime
{
$qb = $this->getReplicaConnection()->createQueryBuilder();
$qb->select('log.date_triggered')
->from(MAUTIC_TABLE_PREFIX.'campaign_lead_event_log', 'log')
->orderBy('log.date_triggered', 'ASC')
->setMaxResults(1);
$results = $qb->executeQuery()->fetchAllAssociative();
return isset($results[0]['date_triggered']) ? new \DateTime($results[0]['date_triggered']) : null;
}
/**
* @param int $contactId
* @param int $campaignId
* @param int $rotation
*/
public function hasBeenInCampaignRotation($contactId, $campaignId, $rotation): bool
{
$qb = $this->getReplicaConnection()->createQueryBuilder();
$qb->select('log.rotation')
->from(MAUTIC_TABLE_PREFIX.'campaign_lead_event_log', 'log')
->where(
$qb->expr()->and(
$qb->expr()->eq('log.lead_id', ':contactId'),
$qb->expr()->eq('log.campaign_id', ':campaignId'),
$qb->expr()->in('log.rotation', ':rotation')
)
)
->setParameter('contactId', (int) $contactId)
->setParameter('campaignId', (int) $campaignId)
->setParameter('rotation', (int) $rotation)
->setMaxResults(1);
$results = $qb->executeQuery()->fetchAllAssociative();
return !empty($results);
}
/**
* @param string $message
*
* @throws \Doctrine\DBAL\Exception
*/
public function unscheduleEvents(Lead $campaignMember, $message): void
{
$contactId = $campaignMember->getLead()->getId();
$campaignId = $campaignMember->getCampaign()->getId();
$rotation = $campaignMember->getRotation();
$dateAdded = (new \DateTime('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s');
// Insert entries into the failed log so it's known why they were never executed
$prefix = MAUTIC_TABLE_PREFIX;
$sql = <<<SQL
REPLACE INTO {$prefix}campaign_lead_event_failed_log( `log_id`, `date_added`, `reason`)
SELECT id, :dateAdded as date_added, :message as reason from {$prefix}campaign_lead_event_log
WHERE is_scheduled = 1 AND lead_id = :contactId AND campaign_id = :campaignId AND rotation = :rotation
SQL;
$connection = $this->getEntityManager()->getConnection();
$stmt = $connection->prepare($sql);
$stmt->bindValue('dateAdded', $dateAdded, \PDO::PARAM_STR);
$stmt->bindValue('message', $message, \PDO::PARAM_STR);
$stmt->bindValue('contactId', $contactId, \PDO::PARAM_INT);
$stmt->bindValue('campaignId', $campaignId, \PDO::PARAM_INT);
$stmt->bindValue('rotation', $rotation, \PDO::PARAM_INT);
$stmt->executeStatement();
// Now unschedule them
$qb = $connection->createQueryBuilder();
$qb->update(MAUTIC_TABLE_PREFIX.'campaign_lead_event_log')
->set('is_scheduled', 0)
->where(
$qb->expr()->and(
$qb->expr()->eq('is_scheduled', 1),
$qb->expr()->eq('lead_id', ':contactId'),
$qb->expr()->eq('campaign_id', ':campaignId'),
$qb->expr()->eq('rotation', ':rotation')
)
)
->setParameters(
[
'contactId' => (int) $contactId,
'campaignId' => (int) $campaignId,
'rotation' => (int) $rotation,
]
)
->executeStatement();
}
public function removeEventLogsByCampaignId(int $campaignId): void
{
$table_name = $this->getTableName();
$sql = "DELETE FROM {$table_name} WHERE campaign_id = (?) LIMIT ".self::LOG_DELETE_BATCH_SIZE;
$conn = $this->getEntityManager()->getConnection();
$deleteEntries = true;
while ($deleteEntries) {
$deleteEntries = $conn->executeStatement($sql, [$campaignId], [Types::INTEGER]);
}
}
/**
* @param string[] $eventIds
*/
public function removeEventLogs(array $eventIds): void
{
$table_name = $this->getTableName();
$sql = "DELETE FROM {$table_name} WHERE event_id IN (?) ORDER BY event_id ASC LIMIT ".self::LOG_DELETE_BATCH_SIZE;
$conn = $this->getEntityManager()->getConnection();
$deleteEntries = true;
while ($deleteEntries) {
$deleteEntries = $conn->executeStatement($sql, [$eventIds], [ArrayParameterType::INTEGER]);
}
}
/**
* Check if last lead/event failed.
*
* @throws \Doctrine\ORM\NonUniqueResultException
*/
public function isLastFailed(int $leadId, int $eventId): bool
{
/** @var LeadEventLog $log */
$log = $this->findOneBy(['lead' => $leadId, 'event' => $eventId], ['dateTriggered' => 'DESC']);
if (null !== $log && null !== $log->getFailedLog()) {
return true;
}
return false;
}
public function deleteAnonymousContacts(): int
{
$conn = $this->getEntityManager()->getConnection();
$tableName = $this->getTableName();
$leadsTableName = MAUTIC_TABLE_PREFIX.'leads';
$tempTableName = 'to_delete';
$conn->executeQuery(sprintf('DROP TEMPORARY TABLE IF EXISTS %s', $tempTableName));
$conn->executeQuery(sprintf('CREATE TEMPORARY TABLE %s select id AS lead_id from %s where date_identified is null;', $tempTableName, $leadsTableName));
$deleteQuery = sprintf('DELETE lll FROM %s lll JOIN (SELECT lead_id FROM %s LIMIT %d) d USING (lead_id); ', $tableName, $tempTableName, self::LOG_DELETE_BATCH_SIZE);
$deletedRecordCount= 0;
while ($deletedRows = $conn->executeQuery($deleteQuery)->rowCount()) {
$deletedRecordCount += $deletedRows;
}
return $deletedRecordCount;
}
/**
* @param string[] $ids
*/
public function markEventLogsQueued(array $ids): void
{
if (!$ids) {
return;
}
$this->getEntityManager()
->getConnection()
->createQueryBuilder()
->update($this->getTableName())
->set('date_queued', 'NOW()')
->where('id IN (:ids)')
->setParameter('ids', $ids, ArrayParameterType::STRING)
->executeStatement();
}
}

View File

@@ -0,0 +1,652 @@
<?php
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;
public const DELETE_BATCH_SIZE = 5000;
/**
* 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)
)
)->orderBy('ll.lead_id');
$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")
->orderBy("$leadAlias.country", 'ASC')
->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();
}
public function deleteAnonymousContacts(): int
{
$conn = $this->getEntityManager()->getConnection();
$tableName = $this->getTableName();
$leadsTableName = MAUTIC_TABLE_PREFIX.'leads';
$tempTableName = 'to_delete';
$conn->executeQuery(sprintf('DROP TEMPORARY TABLE IF EXISTS %s', $tempTableName));
$conn->executeQuery(sprintf('CREATE TEMPORARY TABLE %s select DISTINCT lll.lead_id from %s lll join %s l on l.id = lll.lead_id where l.date_identified is null;', $tempTableName, $tableName, $leadsTableName));
$deleteQuery = sprintf('DELETE lll FROM %s lll JOIN (SELECT lead_id FROM %s LIMIT %d) d USING (lead_id); ', $tableName, $tempTableName, self::DELETE_BATCH_SIZE);
$deletedRecordCount= 0;
while ($deletedRows = $conn->executeQuery($deleteQuery)->rowCount()) {
$deletedRecordCount += $deletedRows;
}
return $deletedRecordCount;
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace Mautic\CampaignBundle\Entity;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Connections\PrimaryReadReplicaConnection;
use Mautic\CampaignBundle\Executioner\ContactFinder\Limiter\ContactLimiter;
/**
* Trait ReplicaConnectionTrait.
*/
trait ReplicaConnectionTrait
{
/**
* Get a connection, preferring a replica connection if available and prudent.
*
* If a query is being executed with a limiter with specific contacts
* then this could be a real-time request being handled so we should avoid forcing a replica connection.
*/
private function getReplicaConnection(?ContactLimiter $limiter = null): Connection
{
/** @var Connection $connection */
$connection = $this->getEntityManager()->getConnection();
if ($connection instanceof PrimaryReadReplicaConnection) {
if (
!$limiter
|| !($limiter->getContactId() || $limiter->getContactIdList())
) {
$connection->ensureConnectedToReplica();
}
}
return $connection;
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace Mautic\CampaignBundle\Entity\Result;
class CountResult
{
private int $count;
private int $minId;
private int $maxId;
public function __construct($count, $minId, $maxId)
{
$this->count = (int) $count;
$this->minId = (int) $minId;
$this->maxId = (int) $maxId;
}
public function getCount(): int
{
return $this->count;
}
public function getMinId(): int
{
return $this->minId;
}
public function getMaxId(): int
{
return $this->maxId;
}
}

View File

@@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
namespace Mautic\CampaignBundle\Entity;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
class Summary
{
public const TABLE_NAME = 'campaign_summary';
/**
* @var int|null
*/
private $id;
/**
* @var \DateTimeImmutable|null
**/
private $dateTriggered;
/**
* @var int
*/
private $scheduledCount = 0;
/**
* @var int
*/
private $triggeredCount = 0;
/**
* @var int
*/
private $nonActionPathTakenCount = 0;
/**
* @var int
*/
private $failedCount = 0;
/**
* @var Event|null
*/
private $event;
/**
* @var Campaign|null
*/
private $campaign;
/**
* @var int|null
*/
private $logCountsProcessed = 0;
public static function loadMetadata(ORM\ClassMetadata $metadata): void
{
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable(self::TABLE_NAME)
->setCustomRepositoryClass(SummaryRepository::class)
->addUniqueConstraint(['campaign_id', 'event_id', 'date_triggered'], 'campaign_event_date_triggered');
$builder->addId();
$builder->createManyToOne('campaign', Campaign::class)
->addJoinColumn('campaign_id', 'id')
->fetchExtraLazy()
->build();
$builder->createManyToOne('event', Event::class)
->addJoinColumn('event_id', 'id', false, false, 'CASCADE')
->fetchExtraLazy()
->build();
$builder->addNullableField('dateTriggered', Types::DATETIME_IMMUTABLE, 'date_triggered');
$builder->addNamedField('scheduledCount', Types::INTEGER, 'scheduled_count');
$builder->addNamedField('triggeredCount', Types::INTEGER, 'triggered_count');
$builder->addNamedField('nonActionPathTakenCount', Types::INTEGER, 'non_action_path_taken_count');
$builder->addNamedField('failedCount', Types::INTEGER, 'failed_count');
$builder->addNamedField('logCountsProcessed', Types::INTEGER, 'log_counts_processed', true);
}
public function getScheduledCount(): ?int
{
return $this->scheduledCount;
}
public function setScheduledCount(int $scheduledCount): void
{
$this->scheduledCount = $scheduledCount;
}
public function getTriggeredCount(): ?int
{
return $this->triggeredCount;
}
public function setTriggeredCount(int $triggeredCount): void
{
$this->triggeredCount = $triggeredCount;
}
public function getNonActionPathTakenCount(): ?int
{
return $this->nonActionPathTakenCount;
}
public function setNonActionPathTakenCount(int $nonActionPathTakenCount): void
{
$this->nonActionPathTakenCount = $nonActionPathTakenCount;
}
public function getFailedCount(): ?int
{
return $this->failedCount;
}
public function setFailedCount(int $failedCount): void
{
$this->failedCount = $failedCount;
}
public function getCampaign(): ?Campaign
{
return $this->campaign;
}
public function setCampaign(Campaign $campaign): void
{
$this->campaign = $campaign;
}
public function getEvent(): ?Event
{
return $this->event;
}
public function setEvent(Event $event): void
{
$this->event = $event;
if (!$this->campaign) {
$this->setCampaign($event->getCampaign());
}
}
public function getDateTriggered(): ?\DateTimeInterface
{
return $this->dateTriggered;
}
public function setDateTriggered(?\DateTimeImmutable $dateTriggered = null): void
{
$this->dateTriggered = $dateTriggered;
}
public function getId(): ?int
{
return $this->id;
}
public function getLogCountsProcessed(): ?int
{
return $this->logCountsProcessed;
}
public function setLogCountsProcessed(?int $logCountsProcessed): void
{
$this->logCountsProcessed = $logCountsProcessed;
}
}

View File

@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace Mautic\CampaignBundle\Entity;
use Mautic\CoreBundle\Entity\CommonRepository;
use Mautic\LeadBundle\Entity\TimelineTrait;
/**
* @extends CommonRepository<Summary>
*/
class SummaryRepository extends CommonRepository
{
use TimelineTrait;
use ContactLimiterTrait;
public function getTableAlias(): string
{
return 's';
}
/**
* @return array<int|string, array<int|string, int|string>>
*/
public function getCampaignLogCounts(
int $campaignId,
?\DateTimeInterface $dateFrom = null,
?\DateTimeInterface $dateTo = null,
): array {
$q = $this->_em->getConnection()->createQueryBuilder()
->select(
[
'cs.event_id',
'SUM(cs.scheduled_count) as scheduled_count',
'SUM(cs.triggered_count) as triggered_count',
'SUM(cs.non_action_path_taken_count) as non_action_path_taken_count',
'SUM(cs.failed_count) as failed_count',
'SUM(cs.log_counts_processed) as log_counts_processed',
]
)
->from(MAUTIC_TABLE_PREFIX.'campaign_summary', 'cs')
->where('cs.campaign_id = '.(int) $campaignId)
->groupBy('cs.event_id');
if ($dateFrom && $dateTo) {
$q->andWhere('cs.date_triggered BETWEEN FROM_UNIXTIME(:dateFrom) AND FROM_UNIXTIME(:dateTo)')
->setParameter('dateFrom', $dateFrom->getTimestamp(), \PDO::PARAM_INT)
->setParameter('dateTo', $dateTo->getTimestamp(), \PDO::PARAM_INT);
}
$results = $q->executeQuery()->fetchAllAssociative();
$return = [];
// Group by event id
foreach ($results as $row) {
$return[$row['event_id']] = [
0 => (int) $row['non_action_path_taken_count'],
1 => (int) $row['triggered_count'] + (int) $row['scheduled_count'],
2 => (int) $row['log_counts_processed'],
];
}
return $return;
}
/**
* Get the oldest triggered time for back-filling historical data.
*/
public function getOldestTriggeredDate(): ?\DateTimeInterface
{
$qb = $this->getEntityManager()->getConnection()->createQueryBuilder();
$qb->select('cs.date_triggered')
->from(MAUTIC_TABLE_PREFIX.'campaign_summary', 'cs')
->orderBy('cs.date_triggered', 'ASC')
->setMaxResults(1);
$results = $qb->executeQuery()->fetchAllAssociative();
return isset($results[0]['date_triggered']) ? new \DateTime($results[0]['date_triggered']) : null;
}
/**
* Regenerate summary entries for a given time frame.
*
* @throws \Doctrine\DBAL\Exception
*/
public function summarize(
\DateTimeInterface $dateFrom,
\DateTimeInterface $dateTo,
?int $campaignId = null,
?int $eventId = null,
): void {
$dateFromTsActual = $dateFrom->getTimestamp();
$dateToTsActual = $dateTo->getTimestamp();
$intervalInSeconds= 3600;
$dateFromStartWithZeroMinutes = $dateFromTsActual - ($dateFromTsActual % $intervalInSeconds);
$numberOfIntervals = ceil(($dateToTsActual - $dateFromStartWithZeroMinutes) / $intervalInSeconds);
for ($interval = 0; $interval < $numberOfIntervals; ++$interval) {
$dateFromTs = date('Y-m-d H:i:s', $dateFromStartWithZeroMinutes + ($interval * $intervalInSeconds));
$dateToTs = date('Y-m-d H:i:s', strtotime($dateFromTs) + ($intervalInSeconds - 1));
$sql = 'INSERT INTO '.MAUTIC_TABLE_PREFIX.'campaign_summary '.
' (campaign_id, event_id, date_triggered, scheduled_count, non_action_path_taken_count, failed_count, triggered_count, log_counts_processed) '.
' SELECT * FROM (SELECT '.
' mclel.campaign_id AS campaign_id, '.
' mclel.event_id AS event_id, '.
' "'.$dateFromTs.'" AS date_triggered_i, '.
' SUM(IF(mclel.is_scheduled = 1 AND mclel.trigger_date > NOW(), 1, 0)) AS scheduled_count_i, '.
' SUM(IF(mclel.is_scheduled = 1 AND mclel.trigger_date > NOW(), 0, mclel.non_action_path_taken)) AS non_action_path_taken_count_i, '.
' SUM(IF((mclel.is_scheduled = 1 AND mclel.trigger_date > NOW()) OR mclel.non_action_path_taken, 0, mclefl.log_id IS NOT NULL)) AS failed_count_i, '.
' SUM(IF((mclel.is_scheduled = 1 AND mclel.trigger_date > NOW()) OR mclel.non_action_path_taken OR mclefl.log_id IS NOT NULL, 0, 1)) AS triggered_count_i, '.
' COUNT((SELECT mcl.campaign_id FROM '.MAUTIC_TABLE_PREFIX.'campaign_leads mcl
WHERE mcl.campaign_id = mclel.campaign_id
AND mclel.lead_id = mcl.lead_id
AND mclel.is_scheduled = 0
AND mclel.date_triggered IS NOT NULL
AND NOT EXISTS(SELECT NULL FROM '.MAUTIC_TABLE_PREFIX.'campaign_lead_event_failed_log mclefl2
WHERE mclefl2.log_id = mclel.id AND mclefl2.date_added BETWEEN "'.$dateFromTs.'" AND "'.$dateToTs.'")
)) AS log_counts_processed_i '.
' FROM '.MAUTIC_TABLE_PREFIX.'campaign_lead_event_log mclel LEFT JOIN '.MAUTIC_TABLE_PREFIX.'campaign_lead_event_failed_log mclefl ON mclefl.log_id = mclel.id '.
' WHERE (mclel.date_triggered BETWEEN "'.$dateFromTs.'" AND "'.$dateToTs.'") ';
if ($campaignId) {
$sql .= ' AND mclel.campaign_id = '.$campaignId;
}
if ($eventId) {
$sql .= ' AND mclel.event_id = '.$eventId;
}
$sql .= ' GROUP BY mclel.campaign_id, mclel.event_id) AS `s` '.
' ON DUPLICATE KEY UPDATE '.
' scheduled_count = s.scheduled_count_i, '.
' non_action_path_taken_count = s.non_action_path_taken_count_i, '.
' failed_count = s.failed_count_i, '.
' triggered_count = s.triggered_count_i, '.
' log_counts_processed = s.log_counts_processed_i;';
$this->getEntityManager()->getConnection()->executeStatement($sql);
}
}
}