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,145 @@
<?php
namespace Mautic\EmailBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
class Copy
{
/**
* MD5 hash of the content.
*
* @var string
*/
private $id;
/**
* @var \DateTimeInterface
*/
private $dateCreated;
/**
* @var string|null
*/
private $body;
private ?string $bodyText = null;
/**
* @var string|null
*/
private $subject;
public static function loadMetadata(ORM\ClassMetadata $metadata): void
{
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable('email_copies')
->setCustomRepositoryClass(CopyRepository::class);
$builder->createField('id', 'string')
->makePrimaryKey()
->length(32)
->build();
$builder->createField('dateCreated', 'datetime')
->columnName('date_created')
->build();
$builder->addNullableField('body', 'text');
$builder->addNullableField('bodyText', 'text', 'body_text');
$builder->addNullableField('subject', 'text');
}
/**
* @return $this
*/
public function setId($id)
{
$this->id = $id;
return $this;
}
/**
* @return int
*/
public function getId()
{
return $this->id;
}
/**
* @return \DateTimeInterface
*/
public function getDateCreated()
{
return $this->dateCreated;
}
/**
* @param \DateTime $dateCreated
*
* @return Copy
*/
public function setDateCreated($dateCreated)
{
$this->dateCreated = $dateCreated;
return $this;
}
/**
* @return string
*/
public function getBody()
{
return $this->body;
}
/**
* @param string $body
*
* @return Copy
*/
public function setBody($body)
{
$this->body = $body;
return $this;
}
/**
* @return mixed
*/
public function getSubject()
{
return $this->subject;
}
/**
* @param mixed $subject
*
* @return Copy
*/
public function setSubject($subject)
{
$this->subject = $subject;
return $this;
}
public function getBodyText(): ?string
{
return $this->bodyText;
}
public function setBodyText(?string $bodyText): self
{
$this->bodyText = $bodyText;
return $this;
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace Mautic\EmailBundle\Entity;
use Doctrine\ORM\NoResultException;
use Mautic\CoreBundle\Entity\CommonRepository;
/**
* @extends CommonRepository<Copy>
*/
class CopyRepository extends CommonRepository
{
/**
* @param string $hash
* @param string $subject
* @param string $body
* @param string $bodyText
*/
public function saveCopy($hash, $subject, $body, $bodyText): bool
{
$db = $this->getEntityManager()->getConnection();
try {
$db->insert(
MAUTIC_TABLE_PREFIX.'email_copies',
[
'id' => $hash,
'body' => $body,
'body_text' => $bodyText,
'subject' => $subject,
'date_created' => (new \DateTime())->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d H:i:s'),
]
);
return true;
} catch (\Exception $e) {
error_log($e);
return false;
}
}
/**
* @param string $string md5 hash or content
*
* @return array
*/
public function findByHash($string, $subject = null)
{
if (null !== $subject) {
// Combine subject with $string and hash together
$string = $subject.$string;
}
// Assume that $string is already a md5 hash if 32 characters
$hash = (32 !== strlen($string)) ? $hash = md5($string) : $string;
$q = $this->createQueryBuilder($this->getTableAlias());
$q->where(
$q->expr()->eq($this->getTableAlias().'.id', ':id')
)
->setParameter('id', $hash);
try {
$result = $q->getQuery()->getSingleResult();
} catch (NoResultException) {
$result = null;
}
return $result;
}
public function getTableAlias(): string
{
return 'ec';
}
}

View File

@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace Mautic\EmailBundle\Entity;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Events;
use Doctrine\ORM\Mapping as ORM;
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
class EmailDraft
{
private int $id;
public function __construct(private Email $email, private ?string $html, private ?string $template, private ?bool $publicPreview = true)
{
}
public static function loadMetadata(ORM\ClassMetadata $metadata): void
{
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable('emails_draft')
->setCustomRepositoryClass(EmailDraftRepository::class)
->addLifecycleEvent('cleanUrlsInContent', Events::preUpdate)
->addLifecycleEvent('cleanUrlsInContent', Events::prePersist);
$builder->addId();
$builder->addNullableField('html', Types::TEXT);
$builder->addNullableField('template', Types::STRING);
$builder->createField('publicPreview', Types::BOOLEAN)
->columnName('public_preview')
->nullable(false)
->option('default', 1)
->build();
$builder->createOneToOne('email', Email::class)
->inversedBy('draft')
->addJoinColumn('email_id', 'id', false)
->build();
}
/**
* Lifecycle callback to clean URLs in the content.
*/
public function cleanUrlsInContent(): void
{
$this->decodeAmpersands($this->html);
}
/**
* Check all links in content and decode &amp;
* This even works with double encoded ampersands.
*/
private function decodeAmpersands(string &$content): void
{
if (preg_match_all('/((https?|ftps?):\/\/)([a-zA-Z0-9-\.{}]*[a-zA-Z0-9=}]*)(\??)([^\s\"\]]+)?/i', $content, $matches)) {
foreach ($matches[0] as $url) {
$newUrl = $url;
while (str_contains($newUrl, '&amp;')) {
$newUrl = str_replace('&amp;', '&', $newUrl);
}
$content = str_replace($url, $newUrl, $content);
}
}
}
public function getId(): int
{
return $this->id;
}
public function getEmail(): Email
{
return $this->email;
}
public function getHtml(): string
{
return $this->html;
}
public function setEmail(Email $email): void
{
$this->email = $email;
}
public function setHtml(string $html): void
{
$this->html = $html;
}
public function getTemplate(): string
{
return $this->template;
}
public function setTemplate(string $template): void
{
$this->template = $template;
}
public function isPublicPreview(): bool
{
return $this->publicPreview;
}
public function getPublishStatus(): bool
{
return $this->publicPreview;
}
public function setPublicPreview(bool $publicPreview): void
{
$this->publicPreview = $publicPreview;
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Mautic\EmailBundle\Entity;
use Mautic\CoreBundle\Entity\CommonRepository;
class EmailDraftRepository extends CommonRepository
{
}

View File

@@ -0,0 +1,97 @@
<?php
namespace Mautic\EmailBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Mautic\ApiBundle\Serializer\Driver\ApiMetadataDriver;
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
use Ramsey\Uuid\Uuid;
class EmailReply
{
private string $id;
private \DateTimeInterface $dateReplied;
public static function loadMetadata(ORM\ClassMetadata $metadata): void
{
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable('email_stat_replies')
->setCustomRepositoryClass(EmailReplyRepository::class)
->addIndex(['stat_id', 'message_id'], 'email_replies')
->addIndex(['date_replied'], 'date_email_replied');
$builder->addUuid();
$builder->createManyToOne('stat', Stat::class)
->inversedBy('replies')
->addJoinColumn('stat_id', 'id', false, false, 'CASCADE')
->build();
$builder->createField('dateReplied', 'datetime')
->columnName('date_replied')
->build();
$builder->createField('messageId', 'string')
->columnName('message_id')
->build();
}
/**
* Prepares the metadata for API usage.
*/
public static function loadApiMetadata(ApiMetadataDriver $metadata): void
{
$metadata->setGroupPrefix('emailReply')
->addProperties(
[
'uuid',
'dateReplied',
'messageId',
]
)
->build();
}
/**
* @param string $messageId
*/
public function __construct(
private Stat $stat,
private $messageId,
?\DateTime $dateReplied = null,
) {
$this->id = Uuid::uuid4()->toString();
$this->dateReplied = $dateReplied ?? new \DateTime();
}
public function getId(): string
{
return $this->id;
}
/**
* @return Stat
*/
public function getStat()
{
return $this->stat;
}
/**
* @return \DateTimeInterface
*/
public function getDateReplied()
{
return $this->dateReplied;
}
/**
* @return string
*/
public function getMessageId()
{
return $this->messageId;
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace Mautic\EmailBundle\Entity;
use Mautic\CoreBundle\Entity\CommonRepository;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Entity\TimelineTrait;
/**
* @extends CommonRepository<EmailReply>
*/
final class EmailReplyRepository extends CommonRepository implements EmailReplyRepositoryInterface
{
use TimelineTrait;
/**
* @param int|Lead|null $leadId
*
* @return array
*/
public function getByLeadIdForTimeline($leadId, $options)
{
if ($leadId instanceof Lead) {
$leadId = $leadId->getId();
}
$qb = $this->_em->getConnection()->createQueryBuilder();
$qb->from(MAUTIC_TABLE_PREFIX.'email_stat_replies', 'reply')
->innerJoin('reply', MAUTIC_TABLE_PREFIX.'email_stats', 'stat', 'reply.stat_id = stat.id')
->leftJoin('stat', MAUTIC_TABLE_PREFIX.'emails', 'email', 'stat.email_id = email.id')
->leftJoin('stat', MAUTIC_TABLE_PREFIX.'email_copies', 'email_copy', 'stat.copy_id = email_copy.id');
if (null !== $leadId) {
$qb->andWhere('stat.lead_id = :leadId')
->setParameter('leadId', $leadId);
}
if (!empty($options['fromDate'])) {
/** @var \DateTime $fromDate */
$fromDate = $options['fromDate'];
$qb->andWhere('reply.date_replied >= :fromDate')
->setParameter('fromDate', $fromDate->format('Y-m-d H:i:s'));
}
if (!empty($options['toDate'])) {
/** @var \DateTime $toDate */
$toDate = $options['toDate'];
$qb->andWhere('reply.date_replied <= :toDate')
->setParameter('toDate', $toDate->format('Y-m-d H:i:s'));
}
$qb->addSelect('reply.id')
->addSelect('reply.date_replied')
->addSelect('stat.lead_id')
->addSelect('email.name AS email_name')
->addSelect('email.subject')
->addSelect('email_copy.subject AS storedSubject');
return $this->getTimelineResults(
$qb,
$options,
'storedSubject, email.subject',
'reply.id',
[],
['date_replied']
);
}
public function getTableAlias(): string
{
return 'reply';
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace Mautic\EmailBundle\Entity;
use Mautic\LeadBundle\Entity\Lead;
/**
* Interface EmailReplyRepositoryInterface.
*/
interface EmailReplyRepositoryInterface
{
/**
* @param int|Lead $leadId
* @param array $options
*
* @return array
*/
public function getByLeadIdForTimeline($leadId, $options);
}

View File

@@ -0,0 +1,846 @@
<?php
namespace Mautic\EmailBundle\Entity;
use Doctrine\DBAL\Exception;
use Doctrine\DBAL\Query\QueryBuilder;
use Doctrine\ORM\Query;
use Doctrine\ORM\Tools\Pagination\Paginator;
use Mautic\ChannelBundle\Entity\MessageQueue;
use Mautic\CoreBundle\Entity\CommonRepository;
use Mautic\CoreBundle\Helper\DateTimeHelper;
use Mautic\LeadBundle\Entity\DoNotContact;
use Mautic\ProjectBundle\Entity\ProjectRepositoryTrait;
/**
* @extends CommonRepository<Email>
*/
class EmailRepository extends CommonRepository
{
use ProjectRepositoryTrait;
public const EMAILS_PREFIX = 'e';
public const DNC_PREFIX = 'dnc';
public const TRACKABLE_PREFIX = 'tr';
public const REDIRECT_PREFIX = 'pr';
/**
* Get an array of do not email.
*
* @param array $leadIds
*/
public function getDoNotEmailList($leadIds = []): array
{
$q = $this->getEntityManager()->getConnection()->createQueryBuilder();
$q->select('l.id, l.email')
->from(MAUTIC_TABLE_PREFIX.'lead_donotcontact', 'dnc')
->leftJoin('dnc', MAUTIC_TABLE_PREFIX.'leads', 'l', 'l.id = dnc.lead_id')
->where($q->expr()->eq('dnc.channel', $q->expr()->literal('email')))
->andWhere($q->expr()->neq('l.email', $q->expr()->literal('')));
if ($leadIds) {
$q->andWhere(
$q->expr()->in('l.id', $leadIds)
);
}
$results = $q->executeQuery()->fetchAllAssociative();
$dnc = [];
foreach ($results as $r) {
$dnc[$r['id']] = strtolower($r['email']);
}
return $dnc;
}
/**
* Check to see if an email is set as do not contact.
*
* @param string $email
*
* @return false|array{id: numeric-string, unsubscribed: bool, bounced: bool, manual: bool, comments: string}
*/
public function checkDoNotEmail($email)
{
$q = $this->getEntityManager()->getConnection()->createQueryBuilder();
$q->select('dnc.*')
->from(MAUTIC_TABLE_PREFIX.'lead_donotcontact', 'dnc')
->leftJoin('dnc', MAUTIC_TABLE_PREFIX.'leads', 'l', 'l.id = dnc.lead_id')
->where($q->expr()->eq('dnc.channel', $q->expr()->literal('email')))
->andWhere('l.email = :email')
->setParameter('email', $email);
$results = $q->executeQuery()->fetchAllAssociative();
$dnc = count($results) ? $results[0] : null;
if (null === $dnc) {
return false;
}
$dnc['reason'] = (int) $dnc['reason'];
return [
'id' => $dnc['id'],
'unsubscribed' => (DoNotContact::UNSUBSCRIBED === $dnc['reason']),
'bounced' => (DoNotContact::BOUNCED === $dnc['reason']),
'manual' => (DoNotContact::MANUAL === $dnc['reason']),
'comments' => $dnc['comments'],
];
}
/**
* Delete DNC row.
*
* @param int $id
*/
public function deleteDoNotEmailEntry($id): void
{
$this->getEntityManager()->getConnection()->delete(MAUTIC_TABLE_PREFIX.'lead_donotcontact', ['id' => (int) $id]);
}
/**
* Get a list of entities.
*
* @return Paginator
*/
public function getEntities(array $args = [])
{
$q = $this->getEntityManager()
->createQueryBuilder()
->select('e')
->from(Email::class, 'e', 'e.id');
if (empty($args['iterable_mode'])) {
$q->leftJoin('e.category', 'c');
if (empty($args['ignoreListJoin']) && (!isset($args['email_type']) || 'list' == $args['email_type'])) {
$q->leftJoin('e.lists', 'l');
}
}
$args['qb'] = $q;
return parent::getEntities($args);
}
/**
* Get amounts of sent and read emails.
*
* @return array
*/
public function getSentReadCount()
{
// Get entities
$q = $this->getEntityManager()->createQueryBuilder();
$q->select('SUM(e.sentCount) as sent_count, SUM(e.readCount) as read_count')
->from(Email::class, 'e');
$results = $q->getQuery()->getSingleResult(Query::HYDRATE_ARRAY);
if (!isset($results['sent_count'])) {
$results['sent_count'] = 0;
}
if (!isset($results['read_count'])) {
$results['read_count'] = 0;
}
return $results;
}
/**
* @param int $emailId
* @param int[]|null $variantIds
* @param int[]|null $listIds
* @param bool $countOnly
* @param int|null $limit
* @param int|null $minContactId
* @param int|null $maxContactId
* @param bool $countWithMaxMin
* @param \DateTime|null $maxDate
*
* @return QueryBuilder|int|array
*/
public function getEmailPendingQuery(
$emailId,
$variantIds = null,
$listIds = null,
$countOnly = false,
$limit = null,
$minContactId = null,
$maxContactId = null,
$countWithMaxMin = false,
$maxDate = null,
?int $maxThreads = null,
?int $threadId = null,
?\DateTimeInterface $sendStopDate = null,
) {
// Do not include leads in the do not contact table
$dncQb = $this->getEntityManager()->getConnection()->createQueryBuilder();
$dncQb->select('dnc.lead_id')
->from(MAUTIC_TABLE_PREFIX.'lead_donotcontact', 'dnc')
->where(
$dncQb->expr()->and(
$dncQb->expr()->eq('dnc.lead_id', 'l.id'),
$dncQb->expr()->eq('dnc.channel', $dncQb->expr()->literal('email'))
));
// Do not include contacts where the message is pending in the message queue
$mqQb = $this->getEntityManager()->getConnection()->createQueryBuilder();
$mqQb->select('mq.lead_id')
->from(MAUTIC_TABLE_PREFIX.'message_queue', 'mq')
->where(
$mqQb->expr()->and(
$mqQb->expr()->eq('mq.lead_id', 'l.id'),
$mqQb->expr()->neq('mq.status', $mqQb->expr()->literal(MessageQueue::STATUS_SENT)),
$mqQb->expr()->eq('mq.channel', $mqQb->expr()->literal('email'))
)
);
// Do not include leads that have already been emailed
$statQb = $this->getEntityManager()->getConnection()->createQueryBuilder();
$statQb->select('stat.lead_id')
->from(MAUTIC_TABLE_PREFIX.'email_stats', 'stat');
$statQb->andWhere($statQb->expr()->isNotNull('stat.lead_id'));
if ($variantIds) {
if (!in_array($emailId, $variantIds)) {
$variantIds[] = (string) $emailId;
}
$statQb->andWhere($statQb->expr()->in('stat.email_id', $variantIds));
$mqQb->andWhere($mqQb->expr()->in('mq.channel_id', $variantIds));
} else {
$statQb->andWhere($statQb->expr()->eq('stat.email_id', (int) $emailId));
$mqQb->andWhere($mqQb->expr()->eq('mq.channel_id', (int) $emailId));
}
// Only include those who belong to the associated lead lists
if (is_null($listIds)) {
// Get a list of lists associated with this email
$lists = $this->getEntityManager()->getConnection()->createQueryBuilder()
->select('el.leadlist_id')
->from(MAUTIC_TABLE_PREFIX.'email_list_xref', 'el')
->where('el.email_id = '.(int) $emailId)
->executeQuery()
->fetchAllAssociative();
$listIds = array_column($lists, 'leadlist_id');
if (empty($listIds)) {
// Prevent fatal error
return ($countOnly) ? 0 : [];
}
} elseif (!is_array($listIds)) {
$listIds = [$listIds];
}
// Main query
$q = $this->getEntityManager()->getConnection()->createQueryBuilder();
// Only include those in associated segments
$segmentQb = $this->getEntityManager()->getConnection()->createQueryBuilder();
$segmentQb->select('ll.lead_id')
->from(MAUTIC_TABLE_PREFIX.'lead_lists_leads', 'll')
->where(
$segmentQb->expr()->and(
$segmentQb->expr()->eq('ll.lead_id', 'l.id'),
$segmentQb->expr()->in('ll.leadlist_id', $listIds),
$segmentQb->expr()->eq('ll.manually_removed', ':false')
)
);
if (null !== $maxDate) {
$segmentQb->andWhere($segmentQb->expr()->lte('ll.date_added', ':max_date'));
$segmentQb->setParameter('max_date', $maxDate, \Doctrine\DBAL\Types\Types::DATETIME_MUTABLE);
}
if ($sendStopDate) {
$segmentQb->andWhere($segmentQb->expr()->lt('ll.date_added', ':sendStopDate'));
$q->setParameter('sendStopDate', (new DateTimeHelper($sendStopDate))->toUtcString());
}
if ($countOnly) {
$q->select('count(*) as count');
if ($countWithMaxMin) {
$q->addSelect('MIN(l.id) as min_id, MAX(l.id) as max_id');
}
} else {
$q->select('l.*');
}
$q->from(MAUTIC_TABLE_PREFIX.'leads', 'l')
->andWhere(sprintf('l.id IN (%s)', $segmentQb->getSQL()))
->andWhere(sprintf('l.id NOT IN (%s)', $dncQb->getSQL()))
->andWhere(sprintf('l.id NOT IN (%s)', $statQb->getSQL()))
->andWhere(sprintf('l.id NOT IN (%s)', $mqQb->getSQL()))
->setParameter('false', false, 'boolean');
$excludedListQb = $this->getExcludedListQuery((int) $emailId);
if ($excludedListQb) {
$q->andWhere(sprintf('l.id NOT IN (%s)', $excludedListQb->getSQL()));
}
// Do not include leads which are not subscribed to the category set for email.
$unsubscribeLeadsQb = $this->getCategoryUnsubscribedLeadsQuery((int) $emailId);
$q->andWhere(sprintf('l.id NOT IN (%s)', $unsubscribeLeadsQb->getSQL()));
$q = $this->setMinMaxIds($q, 'l.id', $minContactId, $maxContactId);
// Has an email
$q->andWhere(
$q->expr()->and(
$q->expr()->isNotNull('l.email'),
$q->expr()->neq('l.email', $q->expr()->literal(''))
)
);
if ($threadId && $maxThreads) {
if ($threadId <= $maxThreads) {
$q->andWhere('MOD((l.id + :threadShift), :maxThreads) = 0')
->setParameter('threadShift', $threadId - 1, \Doctrine\DBAL\ParameterType::INTEGER)
->setParameter('maxThreads', $maxThreads, \Doctrine\DBAL\ParameterType::INTEGER);
}
}
if (!empty($limit)) {
$q->setFirstResult(0)
->setMaxResults($limit);
}
return $q;
}
/**
* @param int $emailId
* @param int[]|null $variantIds
* @param int[]|null $listIds
* @param bool $countOnly
* @param int|null $limit
* @param int|null $minContactId
* @param int|null $maxContactId
* @param bool $countWithMaxMin
*
* @return array|int
*/
public function getEmailPendingLeads(
$emailId,
$variantIds = null,
$listIds = null,
$countOnly = false,
$limit = null,
$minContactId = null,
$maxContactId = null,
$countWithMaxMin = false,
?int $maxThreads = null,
?int $threadId = null,
?\DateTimeInterface $sendStopDate = null,
) {
$q = $this->getEmailPendingQuery(
$emailId,
$variantIds,
$listIds,
$countOnly,
$limit,
$minContactId,
$maxContactId,
$countWithMaxMin,
null,
$maxThreads,
$threadId,
$sendStopDate
);
if (!($q instanceof QueryBuilder)) {
return $q;
}
$results = $q->executeQuery()->fetchAllAssociative();
if ($countOnly && $countWithMaxMin) {
// returns array in format ['count' => #, ['min_id' => #, 'max_id' => #]]
return $results[0];
} elseif ($countOnly) {
return (isset($results[0])) ? $results[0]['count'] : 0;
} else {
$leads = [];
foreach ($results as $r) {
$leads[$r['id']] = $r;
}
return $leads;
}
}
/**
* @param string|array<int|string> $search
* @param int $limit
* @param int $start
* @param bool $viewOther
* @param bool $topLevel
* @param string|null $emailType
* @param int|null $variantParentId
*
* @return array
*/
public function getEmailList($search = '', $limit = 10, $start = 0, $viewOther = false, $topLevel = false, $emailType = null, array $ignoreIds = [], $variantParentId = null)
{
$q = $this->createQueryBuilder('e');
$q->select('partial e.{id, subject, name, language}');
if (!empty($search)) {
if (is_array($search)) {
$search = array_map('intval', $search);
$q->andWhere($q->expr()->in('e.id', ':search'))
->setParameter('search', $search);
} else {
$q->andWhere($q->expr()->like('e.name', ':search'))
->setParameter('search', "%{$search}%");
}
}
if (!$viewOther) {
$q->andWhere($q->expr()->eq('e.createdBy', ':id'))
->setParameter('id', $this->currentUser->getId());
}
if ($topLevel) {
if (true === $topLevel || 'variant' == $topLevel) {
$q->andWhere($q->expr()->isNull('e.variantParent'));
} elseif ('translation' == $topLevel) {
$q->andWhere($q->expr()->isNull('e.translationParent'));
}
}
if ($variantParentId) {
$q->andWhere(
$q->expr()->andX(
$q->expr()->eq('IDENTITY(e.variantParent)', (int) $variantParentId),
$q->expr()->eq('e.id', (int) $variantParentId)
)
);
}
if (!empty($ignoreIds)) {
$q->andWhere($q->expr()->notIn('e.id', ':emailIds'))
->setParameter('emailIds', $ignoreIds);
}
if (!empty($emailType)) {
$q->andWhere(
$q->expr()->eq('e.emailType', $q->expr()->literal($emailType))
);
}
$q->orderBy('e.name');
if (!empty($limit)) {
$q->setFirstResult($start)
->setMaxResults($limit);
}
return $q->getQuery()->getArrayResult();
}
/**
* @return array<string, int>
*/
public function getSentReadNotReadCount(QueryBuilder $queryBuilder): array
{
$queryBuilder->resetQueryPart('groupBy');
$queryBuilder->resetQueryParts(['join']);
$queryBuilder->select('SUM( e.sent_count) as sent_count, SUM( e.read_count) as read_count');
$results = $queryBuilder->executeQuery()->fetchAssociative();
if ($results) {
$results['sent_count'] = (int) $results['sent_count'];
$results['read_count'] = (int) $results['read_count'];
$results['not_read'] = $results['sent_count'] - $results['read_count'];
} else {
$results = [];
$results['not_read'] = $results['sent_count'] = $results['read_count'] = 0;
}
return $results;
}
public function getUnsubscribedCount(QueryBuilder $queryBuilder): int
{
$queryBuilder->resetQueryParts(['join']);
$this->addDNCTableForEmails($queryBuilder);
$queryBuilder->select('e.id as email_id, dnc.lead_id');
$queryBuilder->andWhere('dnc.reason='.DoNotContact::UNSUBSCRIBED);
return $queryBuilder->executeQuery()->rowCount();
}
public function getUniqueClicks(QueryBuilder $queryBuilder): int
{
$this->addTrackableTablesForEmailStats($queryBuilder);
$queryBuilder->select('SUM( tr.unique_hits) as `unique_clicks`');
return (int) $queryBuilder->executeQuery()->fetchOne();
}
private function addTrackableTablesForEmailStats(QueryBuilder $qb): void
{
$trTable = MAUTIC_TABLE_PREFIX.'channel_url_trackables';
$prTable = MAUTIC_TABLE_PREFIX.'page_redirects';
if (!$this->isJoined($qb, $trTable, self::EMAILS_PREFIX, self::TRACKABLE_PREFIX)) {
$qb->leftJoin(
self::EMAILS_PREFIX,
$trTable,
self::TRACKABLE_PREFIX,
'e.id = tr.channel_id AND tr.channel = \'email\''
);
}
if (!$this->isJoined($qb, $prTable, self::TRACKABLE_PREFIX, self::REDIRECT_PREFIX)) {
$qb->leftJoin(
self::TRACKABLE_PREFIX,
$prTable,
self::REDIRECT_PREFIX,
'tr.redirect_id = pr.id'
);
}
}
/**
* Add the Do Not Contact table to the query builder.
*/
private function addDNCTableForEmails(QueryBuilder $qb): void
{
$table = MAUTIC_TABLE_PREFIX.'lead_donotcontact';
if (!$this->isJoined($qb, $table, self::EMAILS_PREFIX, self::DNC_PREFIX)) {
$qb->leftJoin(
self::EMAILS_PREFIX,
$table,
self::DNC_PREFIX,
'e.id = dnc.channel_id AND dnc.channel=\'email\''
);
}
}
private function isJoined(QueryBuilder $query, string $table, string $fromAlias, string $alias): bool
{
$joins = $query->getQueryParts()['join'][$fromAlias] ?? null;
if (empty($joins)) {
return false;
}
foreach ($joins[$fromAlias] as $join) {
if ($join['joinTable'] == $table && $join['joinAlias'] == $alias) {
return true;
}
}
return false;
}
/**
* @param \Doctrine\ORM\QueryBuilder|QueryBuilder $q
* @param object $filter
*/
protected function addCatchAllWhereClause($q, $filter): array
{
return $this->addStandardCatchAllWhereClause($q, $filter, [
'e.name',
'e.subject',
]);
}
/**
* @param \Doctrine\ORM\QueryBuilder|QueryBuilder $q
* @param object $filter
*/
protected function addSearchCommandWhereClause($q, $filter): array
{
[$expr, $parameters] = $this->addStandardSearchCommandWhereClause($q, $filter);
if ($expr) {
return [$expr, $parameters];
}
$command = $filter->command;
$unique = $this->generateRandomParameterName();
$returnParameter = false; // returning a parameter that is not used will lead to a Doctrine error
switch ($command) {
case $this->translator->trans('mautic.email.email.searchcommand.isexpired'):
case $this->translator->trans('mautic.email.email.searchcommand.isexpired', [], null, 'en_US'):
$expr = sprintf(
"(e.isPublished = :%1\$s AND e.publishDown IS NOT NULL AND e.publishDown <> '' AND e.publishDown < CURRENT_TIMESTAMP())",
$unique
);
$forceParameters = [$unique => true];
break;
case $this->translator->trans('mautic.email.email.searchcommand.ispending'):
case $this->translator->trans('mautic.email.email.searchcommand.ispending', [], null, 'en_US'):
$expr = sprintf(
"(e.isPublished = :%1\$s AND e.publishUp IS NOT NULL AND e.publishUp <> '' AND e.publishUp > CURRENT_TIMESTAMP())",
$unique
);
$forceParameters = [$unique => true];
break;
case $this->translator->trans('mautic.core.searchcommand.lang'):
$langUnique = $this->generateRandomParameterName();
$langValue = $filter->string.'_%';
$forceParameters = [
$langUnique => $langValue,
$unique => $filter->string,
];
$expr = '('.$q->expr()->eq('e.language', ":$unique").' OR '.$q->expr()->like('e.language', ":$langUnique").')';
$returnParameter = 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(),
'email_id',
'email_projects_xref',
$this->getTableAlias(),
$filter->string,
$filter->not
);
}
if ($expr && $filter->not) {
$expr = $q->expr()->not($expr);
}
if (!empty($forceParameters)) {
$parameters = $forceParameters;
} elseif ($returnParameter) {
$string = ($filter->strict) ? $filter->string : "%{$filter->string}%";
$parameters = ["$unique" => $string];
}
return [$expr, $parameters];
}
/**
* @return string[]
*/
public function getSearchCommands(): array
{
$commands = [
'mautic.core.searchcommand.ispublished',
'mautic.core.searchcommand.isunpublished',
'mautic.core.searchcommand.isuncategorized',
'mautic.core.searchcommand.ismine',
'mautic.email.email.searchcommand.isexpired',
'mautic.email.email.searchcommand.ispending',
'mautic.core.searchcommand.category',
'mautic.core.searchcommand.lang',
'mautic.project.searchcommand.name',
];
return array_merge($commands, parent::getSearchCommands());
}
/**
* @return array<array<string>>
*/
protected function getDefaultOrder(): array
{
return [
['e.name', 'ASC'],
];
}
public function getTableAlias(): string
{
return 'e';
}
/**
* Resets variant_start_date, variant_read_count, variant_sent_count.
*
* @param string[]|string|int $relatedIds
* @param string $date
*/
public function resetVariants($relatedIds, $date): void
{
if (!is_array($relatedIds)) {
$relatedIds = [(string) $relatedIds];
}
$qb = $this->getEntityManager()->getConnection()->createQueryBuilder();
$qb->update(MAUTIC_TABLE_PREFIX.'emails')
->set('variant_read_count', 0)
->set('variant_sent_count', 0)
->set('variant_start_date', ':date')
->setParameter('date', $date)
->where(
$qb->expr()->in('id', $relatedIds)
)
->executeStatement();
}
public function upCountSent(int $id, int $increaseBy = 1, bool $variant = false): void
{
if ($increaseBy <= 0) {
return;
}
$connection = $this->getEntityManager()->getConnection();
$updateQuery = $connection->createQueryBuilder()
->update(MAUTIC_TABLE_PREFIX.'emails')
->set('sent_count', 'sent_count + :increaseBy')
->where('id = :id');
if ($variant) {
$updateQuery->set('variant_sent_count', 'variant_sent_count + :increaseBy');
}
$updateQuery
->setParameter('increaseBy', $increaseBy)
->setParameter('id', $id);
// Try to execute 3 times before throwing the exception
$retrialLimit = 3;
while ($retrialLimit >= 0) {
try {
$updateQuery->executeStatement();
return;
} catch (Exception $e) {
--$retrialLimit;
if (0 === $retrialLimit) {
throw $e;
}
}
}
}
public function incrementRead(int $emailId, string $statId, bool $isVariant = false): void
{
$q = $this->getEntityManager()->getConnection()->createQueryBuilder();
$subQuery = $this->getEntityManager()->getConnection()->createQueryBuilder()
->select('es.email_id')
->from(MAUTIC_TABLE_PREFIX.'email_stats', 'es')
->where('es.id = :statId')
->andWhere('es.is_read = 1');
$q->update(MAUTIC_TABLE_PREFIX.'emails', 'e')
->set('read_count', 'read_count + 1')
->where(
$q->expr()->and(
$q->expr()->eq('e.id', ':emailId'),
$q->expr()->notIn('e.id', $subQuery->getSQL())
)
)
->setParameter('emailId', $emailId)
->setParameter('statId', $statId);
if ($isVariant) {
$q->set('variant_read_count', 'variant_read_count + 1');
}
// Try to execute 3 times before throwing the exception
$retrialLimit = 3;
while ($retrialLimit >= 0) {
try {
$q->executeStatement();
return;
} catch (Exception $e) {
--$retrialLimit;
if (0 === $retrialLimit) {
throw $e;
}
}
}
}
/**
* @return iterable<Email>
*/
public function getPublishedBroadcastsIterable(?int $id = null): iterable
{
return $this->getPublishedBroadcastsQuery($id)->toIterable();
}
private function getPublishedBroadcastsQuery(?int $id = null): Query
{
$qb = $this->createQueryBuilder($this->getTableAlias());
$expr = $this->getPublishedByDateExpression($qb, null, true, true, false);
$expr->add(
$qb->expr()->eq($this->getTableAlias().'.emailType', $qb->expr()->literal('list'))
);
if (null !== $id && 0 !== $id) {
$expr->add(
$qb->expr()->eq($this->getTableAlias().'.id', (int) $id)
);
}
$qb->where($expr);
return $qb->getQuery();
}
/**
* Set Max and/or Min ID where conditions to the query builder.
*
* @param string $column
* @param int $minContactId
* @param int $maxContactId
*/
private function setMinMaxIds(QueryBuilder $q, $column, $minContactId, $maxContactId): QueryBuilder
{
if ($minContactId && is_numeric($minContactId)) {
$q->andWhere($column.' >= :minContactId');
$q->setParameter('minContactId', $minContactId);
}
if ($maxContactId && is_numeric($maxContactId)) {
$q->andWhere($column.' <= :maxContactId');
$q->setParameter('maxContactId', $maxContactId);
}
return $q;
}
private function getCategoryUnsubscribedLeadsQuery(int $emailId): QueryBuilder
{
$qb = $this->getEntityManager()->getConnection()
->createQueryBuilder();
return $qb->select('lc.lead_id')
->from(MAUTIC_TABLE_PREFIX.'lead_categories', 'lc')
->innerJoin('lc', MAUTIC_TABLE_PREFIX.'emails', 'e', 'e.category_id = lc.category_id')
->where($qb->expr()->eq('e.id', $emailId))
->andWhere('lc.manually_removed = 1');
}
private function getExcludedListQuery(int $emailId): ?QueryBuilder
{
$connection = $this->getEntityManager()
->getConnection();
$excludedListIds = $connection->createQueryBuilder()
->select('eel.leadlist_id')
->from(MAUTIC_TABLE_PREFIX.'email_list_excluded', 'eel')
->where('eel.email_id = :emailId')
->setParameter('emailId', $emailId)
->executeQuery()
->fetchFirstColumn();
if (!$excludedListIds) {
return null;
}
$queryBuilder = $connection->createQueryBuilder();
$queryBuilder->select('ll.lead_id')
->from(MAUTIC_TABLE_PREFIX.'lead_lists_leads', 'll')
->where($queryBuilder->expr()->in('ll.leadlist_id', $excludedListIds));
return $queryBuilder;
}
}

View File

@@ -0,0 +1,653 @@
<?php
namespace Mautic\EmailBundle\Entity;
use Doctrine\Common\Collections\ArrayCollection;
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\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Entity\LeadList;
class Stat
{
/**
* @var int Limit number of stored 'openDetails'
*/
public const MAX_OPEN_DETAILS = 1000;
public const TABLE_NAME = 'email_stats';
private ?string $id = null;
/**
* @var Email|null
*/
private $email;
/**
* @var Lead|null
*/
private $lead;
/**
* @var string
*/
private $emailAddress;
/**
* @var LeadList|null
*/
private $list;
/**
* @var IpAddress|null
*/
private $ipAddress;
private ?\DateTimeInterface $dateSent = null;
/**
* @var bool
*/
private $isRead = false;
/**
* @var bool
*/
private $isFailed = false;
/**
* @var bool
*/
private $viewedInBrowser = false;
/**
* @var \DateTimeInterface|null
*/
private $dateRead;
/**
* @var string|null
*/
private $trackingHash;
/**
* @var int|null
*/
private $retryCount = 0;
/**
* @var string|null
*/
private $source;
/**
* @var int|null
*/
private $sourceId;
/**
* @var array
*/
private $tokens = [];
/**
* @var Copy|null
*/
private $storedCopy;
/**
* @var int|null
*/
private $openCount = 0;
private ?\DateTimeInterface $lastOpened = null;
/**
* @var array
*/
private $openDetails = [];
/**
* @var ArrayCollection|EmailReply[]
*/
private $replies;
/**
* @var array<string,mixed[]>
*/
private $changes = [];
public function __construct()
{
$this->replies = new ArrayCollection();
}
public static function loadMetadata(ORM\ClassMetadata $metadata): void
{
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable(self::TABLE_NAME)
->setCustomRepositoryClass(StatRepository::class)
->addIndex(['email_id', 'lead_id'], 'stat_email_search')
->addIndex(['lead_id', 'email_id'], 'stat_email_search2')
->addIndex(['is_failed'], 'stat_email_failed_search')
->addIndex(['is_read', 'date_sent'], 'is_read_date_sent')
->addIndex(['tracking_hash'], 'stat_email_hash_search')
->addIndex(['source', 'source_id'], 'stat_email_source_search')
->addIndex(['date_sent'], 'email_date_sent')
->addIndex(['date_read', 'lead_id'], 'email_date_read_lead')
->addIndex(['lead_id', 'date_sent'], 'stat_email_lead_id_date_sent')
->addIndex(['email_id', 'is_read'], 'stat_email_email_id_is_read');
$builder->addBigIntIdField();
$builder->createManyToOne('email', 'Email')
->inversedBy('stats')
->addJoinColumn('email_id', 'id', true, false, 'SET NULL')
->build();
$builder->addLead(true, 'SET NULL');
$builder->createField('emailAddress', 'string')
->columnName('email_address')
->build();
$builder->createManyToOne('list', LeadList::class)
->addJoinColumn('list_id', 'id', true, false, 'SET NULL')
->build();
$builder->addIpAddress(true);
$builder->createField('dateSent', 'datetime')
->columnName('date_sent')
->build();
$builder->createField('isRead', 'boolean')
->columnName('is_read')
->build();
$builder->createField('isFailed', 'boolean')
->columnName('is_failed')
->build();
$builder->createField('viewedInBrowser', 'boolean')
->columnName('viewed_in_browser')
->build();
$builder->createField('dateRead', 'datetime')
->columnName('date_read')
->nullable()
->build();
$builder->createField('trackingHash', 'string')
->columnName('tracking_hash')
->nullable()
->build();
$builder->createField('retryCount', 'integer')
->columnName('retry_count')
->nullable()
->build();
$builder->createField('source', 'string')
->nullable()
->build();
$builder->createField('sourceId', 'integer')
->columnName('source_id')
->nullable()
->build();
$builder->createField('tokens', 'array')
->nullable()
->build();
$builder->createManyToOne('storedCopy', Copy::class)
->addJoinColumn('copy_id', 'id', true, false, 'SET NULL')
->build();
$builder->addNullableField('openCount', 'integer', 'open_count');
$builder->addNullableField('lastOpened', 'datetime', 'last_opened');
$builder->addNullableField('openDetails', 'array', 'open_details');
$builder->createOneToMany('replies', EmailReply::class)
->mappedBy('stat')
->fetchExtraLazy()
->cascadeAll()
->build();
}
/**
* Prepares the metadata for API usage.
*/
public static function loadApiMetadata(ApiMetadataDriver $metadata): void
{
$metadata->setGroupPrefix('stat')
->addProperties(
[
'id',
'emailAddress',
'ipAddress',
'dateSent',
'isRead',
'isFailed',
'dateRead',
'retryCount',
'source',
'openCount',
'lastOpened',
'sourceId',
'trackingHash',
'viewedInBrowser',
'lead',
'email',
]
)
->build();
}
public function getDateRead(): ?\DateTimeInterface
{
return $this->dateRead;
}
public function setDateRead(?\DateTimeInterface $dateRead): void
{
$dateRead = $this->toDateTime($dateRead);
$this->addChange('dateRead', $this->dateRead, $dateRead);
$this->dateRead = $dateRead;
}
public function getDateSent(): ?\DateTimeInterface
{
return $this->dateSent;
}
public function setDateSent(?\DateTimeInterface $dateSent): void
{
$dateSent = $this->toDateTime($dateSent);
$this->addChange('dateSent', $this->dateSent, $dateSent);
$this->dateSent = $dateSent;
}
/**
* @return Email|null
*/
public function getEmail()
{
return $this->email;
}
public function setEmail(?Email $email = null): void
{
$this->email = $email;
}
public function getId(): ?string
{
return $this->id;
}
/**
* @return IpAddress|null
*/
public function getIpAddress()
{
return $this->ipAddress;
}
/**
* @param IpAddress|null $ip
*/
public function setIpAddress(IpAddress $ip): void
{
$this->ipAddress = $ip;
}
/**
* @return bool
*/
public function getIsRead()
{
return $this->isRead;
}
/**
* @return bool
*/
public function isRead()
{
return $this->getIsRead();
}
/**
* @param bool $isRead
*/
public function setIsRead($isRead): void
{
$this->addChange('isRead', $this->isRead, $isRead);
$this->isRead = $isRead;
}
/**
* @return Lead|null
*/
public function getLead()
{
return $this->lead;
}
public function setLead(?Lead $lead = null): void
{
$this->lead = $lead;
}
/**
* @return string|null
*/
public function getTrackingHash()
{
return $this->trackingHash;
}
/**
* @param string|null $trackingHash
*/
public function setTrackingHash($trackingHash): void
{
$this->trackingHash = $trackingHash;
}
/**
* @return LeadList|null
*/
public function getList()
{
return $this->list;
}
/**
* @param LeadList|null $list
*/
public function setList($list): void
{
$this->list = $list;
}
/**
* @return int
*/
public function getRetryCount()
{
return $this->retryCount;
}
/**
* @param int $retryCount
*/
public function setRetryCount($retryCount): void
{
$this->addChange('retryCount', $this->retryCount, $retryCount);
$this->retryCount = $retryCount;
}
/**
* Increase the retry count.
*/
public function upRetryCount(): void
{
$this->addChange('retryCount', $this->retryCount, $this->retryCount + 1);
++$this->retryCount;
}
/**
* @return bool
*/
public function getIsFailed()
{
return $this->isFailed;
}
/**
* @param bool $isFailed
*/
public function setIsFailed($isFailed): void
{
$this->addChange('isFailed', $this->isFailed, $isFailed);
$this->isFailed = $isFailed;
}
/**
* @return bool
*/
public function isFailed()
{
return $this->getIsFailed();
}
/**
* @return string|null
*/
public function getEmailAddress()
{
return $this->emailAddress;
}
/**
* @param string|null $emailAddress
*/
public function setEmailAddress($emailAddress): void
{
$this->addChange('emailAddress', $this->emailAddress, $emailAddress);
$this->emailAddress = $emailAddress;
}
/**
* @return bool
*/
public function getViewedInBrowser()
{
return $this->viewedInBrowser;
}
/**
* @param bool $viewedInBrowser
*/
public function setViewedInBrowser($viewedInBrowser): void
{
$this->addChange('viewedInBrowser', $this->viewedInBrowser, $viewedInBrowser);
$this->viewedInBrowser = $viewedInBrowser;
}
/**
* @return string|null
*/
public function getSource()
{
return $this->source;
}
/**
* @param string|null $source
*/
public function setSource($source): void
{
$this->addChange('source', $this->source, $source);
$this->source = $source;
}
/**
* @return int|null
*/
public function getSourceId()
{
return $this->sourceId;
}
/**
* @param int|null $sourceId
*/
public function setSourceId($sourceId): void
{
$this->addChange('sourceId', $this->sourceId, (int) $sourceId);
$this->sourceId = (int) $sourceId;
}
/**
* @return array|null
*/
public function getTokens()
{
return $this->tokens;
}
public function setTokens(array $tokens): void
{
$this->tokens = $tokens;
}
/**
* @return int
*/
public function getOpenCount()
{
return $this->openCount;
}
/**
* @param int $openCount
*
* @return Stat
*/
public function setOpenCount($openCount)
{
$this->addChange('openCount', $this->openCount, $openCount);
$this->openCount = $openCount;
return $this;
}
/**
* @param string $details
*/
public function addOpenDetails($details): void
{
if (self::MAX_OPEN_DETAILS > $this->getOpenCount()) {
$this->openDetails[] = $details;
}
++$this->openCount;
}
/**
* Up the sent count.
*
* @return Stat
*/
public function upOpenCount()
{
$count = (int) $this->openCount + 1;
$this->addChange('openCount', $this->openCount, $count);
$this->openCount = $count;
return $this;
}
public function getLastOpened(): ?\DateTimeInterface
{
return $this->lastOpened;
}
public function setLastOpened(?\DateTimeInterface $lastOpened): self
{
$lastOpened = $this->toDateTime($lastOpened);
$this->addChange('lastOpened', $this->lastOpened, $lastOpened);
$this->lastOpened = $lastOpened;
return $this;
}
/**
* @return array
*/
public function getOpenDetails()
{
return $this->openDetails;
}
/**
* @return Stat
*/
public function setOpenDetails(array $openDetails)
{
$this->openDetails = $openDetails;
return $this;
}
/**
* @return Copy|null
*/
public function getStoredCopy()
{
return $this->storedCopy;
}
/**
* @return Stat
*/
public function setStoredCopy(Copy $storedCopy)
{
$this->storedCopy = $storedCopy;
return $this;
}
/**
* @return ArrayCollection|EmailReply[]
*/
public function getReplies()
{
return $this->replies;
}
public function addReply(EmailReply $reply): void
{
$this->addChange('replyAdded', false, true);
$this->replies[] = $reply;
}
/**
* @return array<string,mixed[]>
*/
public function getChanges(): array
{
return $this->changes;
}
/**
* @param mixed $currentValue
* @param mixed $newValue
*/
private function addChange(string $property, $currentValue, $newValue): void
{
if ($currentValue === $newValue) {
return;
}
$this->changes[$property] = [$currentValue, $newValue];
}
/**
* @param \DateTime|\DateTimeImmutable|null $dateTime
*/
private function toDateTime($dateTime): ?\DateTime
{
return $dateTime instanceof \DateTimeImmutable ? \DateTime::createFromImmutable($dateTime) : $dateTime;
}
}

View File

@@ -0,0 +1,140 @@
<?php
namespace Mautic\EmailBundle\Entity;
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\LeadBundle\Entity\LeadDevice;
class StatDevice
{
public const TABLE_NAME = 'email_stats_devices';
/**
* @var string
*/
private $id;
private ?Stat $stat;
/**
* @var LeadDevice|null
*/
private $device;
/**
* @var IpAddress|null
*/
private $ipAddress;
/**
* @var \DateTimeInterface
*/
private $dateOpened;
public static function loadMetadata(ORM\ClassMetadata $metadata): void
{
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable(self::TABLE_NAME)
->setCustomRepositoryClass(StatDeviceRepository::class)
->addIndex(['date_opened'], 'date_opened_search');
$builder->addBigIntIdField();
$builder->createManyToOne('device', LeadDevice::class)
->addJoinColumn('device_id', 'id', true, false, 'CASCADE')
->build();
$builder->createManyToOne('stat', 'Stat')
->addJoinColumn('stat_id', 'id', true, false, 'CASCADE')
->build();
$builder->addIpAddress(true);
$builder->createField('dateOpened', 'datetime')
->columnName('date_opened')
->build();
}
/**
* Prepares the metadata for API usage.
*/
public static function loadApiMetadata(ApiMetadataDriver $metadata): void
{
$metadata->setGroupPrefix('stat')
->addProperties(
[
'id',
'device',
'ipAddress',
'stat',
]
)
->build();
}
public function getId(): int
{
return (int) $this->id;
}
/**
* @return IpAddress
*/
public function getIpAddress()
{
return $this->ipAddress;
}
/**
* @param mixed $ip
*/
public function setIpAddress(IpAddress $ip): void
{
$this->ipAddress = $ip;
}
public function getStat(): ?Stat
{
return $this->stat;
}
public function setStat(?Stat $stat): void
{
$this->stat = $stat;
}
/**
* @return mixed
*/
public function getDateOpened()
{
return $this->dateOpened;
}
/**
* @param mixed $dateOpened
*/
public function setDateOpened($dateOpened): void
{
$this->dateOpened = $dateOpened;
}
/**
* @return mixed
*/
public function getDevice()
{
return $this->device;
}
/**
* @param mixed $device
*/
public function setDevice(LeadDevice $device): void
{
$this->device = $device;
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace Mautic\EmailBundle\Entity;
use Mautic\CoreBundle\Entity\CommonRepository;
use Mautic\CoreBundle\Helper\DateTimeHelper;
/**
* @extends CommonRepository<StatDevice>
*/
class StatDeviceRepository extends CommonRepository
{
public function getDeviceStats($emailIds, ?\DateTime $fromDate = null, ?\DateTime $toDate = null): array
{
$qb = $this->getEntityManager()->getConnection()->createQueryBuilder();
$qb->select('count(es.id) as count, d.device as device, es.list_id')
->from(MAUTIC_TABLE_PREFIX.'email_stats_devices', 'ed')
->join('ed', MAUTIC_TABLE_PREFIX.'lead_devices', 'd', 'd.id = ed.device_id')
->join('ed', MAUTIC_TABLE_PREFIX.'email_stats', 'es', 'es.id = ed.stat_id');
if (null != $emailIds) {
if (!is_array($emailIds)) {
$emailIds = [(int) $emailIds];
}
$qb->where(
$qb->expr()->in('es.email_id', $emailIds)
);
}
$qb->groupBy('es.list_id, d.device');
if (null !== $fromDate) {
// make sure the date is UTC
$dt = new DateTimeHelper($fromDate);
$qb->andWhere(
$qb->expr()->gte('es.date_read', $qb->expr()->literal($dt->toUtcString()))
);
}
if (null !== $toDate) {
// make sure the date is UTC
$dt = new DateTimeHelper($toDate);
$qb->andWhere(
$qb->expr()->lte('es.date_read', $qb->expr()->literal($dt->toUtcString()))
);
}
return $qb->executeQuery()->fetchAllAssociative();
}
}

View File

@@ -0,0 +1,826 @@
<?php
namespace Mautic\EmailBundle\Entity;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Exception;
use Doctrine\DBAL\Query\QueryBuilder;
use Mautic\CoreBundle\Entity\CommonRepository;
use Mautic\CoreBundle\Helper\Chart\ChartQuery;
use Mautic\CoreBundle\Helper\DateTimeHelper;
use Mautic\LeadBundle\Entity\TimelineTrait;
/**
* @extends CommonRepository<Stat>
*/
class StatRepository extends CommonRepository
{
use TimelineTrait;
/**
* @return mixed
*
* @throws \Doctrine\ORM\NoResultException
* @throws \Doctrine\ORM\NonUniqueResultException
*/
public function getEmailStatus($trackingHash)
{
$q = $this->createQueryBuilder('s');
$q->select('s')
->leftJoin('s.lead', 'l')
->leftJoin('s.email', 'e')
->where(
$q->expr()->eq('s.trackingHash', ':hash')
)
->setParameter('hash', $trackingHash);
$result = $q->getQuery()->getResult();
return (!empty($result)) ? $result[0] : null;
}
/**
* @param int $contactId
* @param int $emailId
*
* @return array
*/
public function getUniqueClickedLinksPerContactAndEmail($contactId, $emailId)
{
$q = $this->_em->getConnection()->createQueryBuilder();
$q->select('distinct ph.url, ph.date_hit')
->from(MAUTIC_TABLE_PREFIX.'page_hits', 'ph')
->where('ph.email_id = :emailId')
->andWhere('ph.lead_id = :leadId')
->setParameter('leadId', $contactId)
->setParameter('emailId', $emailId);
$result = $q->executeQuery()->fetchAllAssociative();
foreach ($result as $row) {
$data[$row['date_hit']] = $row['url'];
}
return $data;
}
/**
* @param int $limit
* @param int|null $createdByUserId
* @param int|null $companyId
* @param int|null $campaignId
* @param int|null $segmentId
*/
public function getSentEmailToContactData(
$limit,
\DateTime $dateFrom,
\DateTime $dateTo,
$createdByUserId = null,
$companyId = null,
$campaignId = null,
$segmentId = null,
): array {
$q = $this->getEntityManager()->getConnection()->createQueryBuilder();
$q->select('s.id, s.lead_id, s.email_address, s.is_read, s.email_id, s.date_sent, s.date_read')
->from(MAUTIC_TABLE_PREFIX.'email_stats', 's')
->leftJoin('s', MAUTIC_TABLE_PREFIX.'emails', 'e', 's.email_id = e.id')
->addSelect('e.name AS email_name')
->leftJoin('s', MAUTIC_TABLE_PREFIX.'page_hits', 'ph', 'ph.source = \'email\' and ph.source_id = s.email_id and ph.lead_id = s.lead_id')
->addSelect('COUNT(ph.id) AS link_hits');
if (null !== $createdByUserId) {
$q->andWhere('e.created_by = :userId')
->setParameter('userId', $createdByUserId);
}
$q->andWhere('s.date_sent BETWEEN :dateFrom AND :dateTo')
->setParameter('dateFrom', $dateFrom->format('Y-m-d H:i:s'))
->setParameter('dateTo', $dateTo->format('Y-m-d H:i:s'));
$companyJoinOnExpr = $q->expr()->and(
$q->expr()->eq('s.lead_id', 'cl.lead_id')
);
if (!empty($companyId)) {
// Must force a one to one relationship
$companyJoinOnExpr->with(
$q->expr()->eq('cl.is_primary', 1)
);
}
$q->leftJoin('s', MAUTIC_TABLE_PREFIX.'companies_leads', 'cl', $companyJoinOnExpr)
->leftJoin('s', MAUTIC_TABLE_PREFIX.'companies', 'c', 'cl.company_id = c.id')
->addSelect('c.id AS company_id')
->addSelect('c.companyname AS company_name');
if (!empty($companyId)) {
$q->andWhere('cl.company_id = :companyId')
->setParameter('companyId', $companyId);
}
$q->leftJoin('s', MAUTIC_TABLE_PREFIX.'campaign_events', 'ce', 's.source = "campaign.event" and s.source_id = ce.id')
->leftJoin('ce', MAUTIC_TABLE_PREFIX.'campaigns', 'campaign', 'ce.campaign_id = campaign.id')
->addSelect('campaign.id AS campaign_id')
->addSelect('campaign.name AS campaign_name');
if (null !== $campaignId) {
$q->andWhere('ce.campaign_id = :campaignId')
->setParameter('campaignId', $campaignId);
}
$q->leftJoin('s', MAUTIC_TABLE_PREFIX.'lead_lists', 'll', 's.list_id = ll.id')
->addSelect('ll.id AS segment_id')
->addSelect('ll.name AS segment_name');
if (null !== $segmentId) {
$sb = $this->getEntityManager()->getConnection()->createQueryBuilder();
$sb->select('null')
->from(MAUTIC_TABLE_PREFIX.'lead_lists_leads', 'lll')
->where(
$sb->expr()->and(
$sb->expr()->eq('lll.leadlist_id', ':segmentId'),
$sb->expr()->eq('lll.lead_id', 'ph.lead_id'),
$sb->expr()->eq('lll.manually_removed', 0)
)
);
// Filter for both broadcasts and campaign related segments
$q->andWhere(
$q->expr()->or(
$q->expr()->eq('s.list_id', ':segmentId'),
$q->expr()->and(
$q->expr()->isNull('s.list_id'),
sprintf('EXISTS (%s)', $sb->getSQL())
)
)
)
->setParameter('segmentId', $segmentId);
}
$q->setMaxResults($limit);
$q->groupBy('s.id');
$q->orderBy('s.id', 'DESC');
return $q->executeQuery()->fetchAllAssociative();
}
/**
* @param array<int,int|string>|int|null $emailIds
* @param array<int,int|string>|int|true|null $listId
*/
public function getSentStats($emailIds, $listId = null): array
{
if (!is_array($emailIds)) {
$emailIds = [(int) $emailIds];
}
$q = $this->_em->getConnection()->createQueryBuilder();
$q->select('s.lead_id')
->from(MAUTIC_TABLE_PREFIX.'email_stats', 's')
->where(
$q->expr()->in('s.email_id', $emailIds)
);
if ($listId) {
$q->andWhere('s.list_id = :list')
->setParameter('list', $listId);
}
$result = $q->executeQuery()->fetchAllAssociative();
// index by lead
$stats = [];
foreach ($result as $r) {
$stats[$r['lead_id']] = $r['lead_id'];
}
unset($result);
return $stats;
}
/**
* @param array<int,int|string>|int|null $emailIds
* @param array<int,int|string>|int|true|null $listId
* @param bool $combined
*
* @return array|int
*/
public function getSentCount($emailIds = null, $listId = null, ?ChartQuery $chartQuery = null, $combined = false)
{
return $this->getStatusCount('is_sent', $emailIds, $listId, $chartQuery, $combined);
}
/**
* @param array<int,int|string>|int|null $emailIds
* @param array<int,int|string>|int|null $listId
* @param bool $combined
*
* @return array|int
*/
public function getReadCount($emailIds = null, $listId = null, ?ChartQuery $chartQuery = null, $combined = false)
{
return $this->getStatusCount('is_read', $emailIds, $listId, $chartQuery, $combined);
}
/**
* @param array<int,int|string>|int|null $emailIds
* @param array<int,int|string>|int|true|null $listId
* @param bool $combined
*
* @return array|int
*/
public function getFailedCount($emailIds = null, $listId = null, ?ChartQuery $chartQuery = null, $combined = false)
{
return $this->getStatusCount('is_failed', $emailIds, $listId, $chartQuery, $combined);
}
/**
* @param string $column
* @param array<int,int|string>|int|null $emailIds
* @param array<int,int|string>|int|true|null $listId
* @param bool $combined
*
* @return array|int
*/
public function getStatusCount($column, $emailIds = null, $listId = null, ?ChartQuery $chartQuery = null, $combined = false)
{
$q = $this->_em->getConnection()->createQueryBuilder();
$q->select('count(s.id) as count')
->from(MAUTIC_TABLE_PREFIX.'email_stats', 's');
if ($emailIds) {
if (!is_array($emailIds)) {
$emailIds = [(int) $emailIds];
}
$q->where(
$q->expr()->in('s.email_id', $emailIds)
);
}
if ($listId) {
if (!$combined) {
if (true === $listId) {
$q->addSelect('s.list_id')
->groupBy('s.list_id');
} elseif (is_array($listId)) {
$q->andWhere(
$q->expr()->in('s.list_id', ':segmentIds')
);
$q->setParameter('segmentIds', $listId, ArrayParameterType::INTEGER);
$q->addSelect('s.list_id')
->groupBy('s.list_id');
} else {
$q->andWhere('s.list_id = :list_id')
->setParameter('list_id', $listId);
}
} else {
$subQ = $this->getEntityManager()->getConnection()->createQueryBuilder();
$subQ->select('null')
->from(MAUTIC_TABLE_PREFIX.'lead_lists_leads', 'list')
->andWhere(
$q->expr()->and(
$q->expr()->in('list.leadlist_id', array_map('intval', $listId)),
$q->expr()->eq('list.lead_id', 's.lead_id')
)
);
$q->andWhere(sprintf('EXISTS (%s)', $subQ->getSQL()));
}
}
if ('is_sent' === $column) {
$q->andWhere('s.is_failed = :false')
->setParameter('false', false, 'boolean');
} else {
$q->andWhere($column.' = :true')
->setParameter('true', true, 'boolean');
}
if ($chartQuery) {
if ('is_read' === $column) {
$chartQuery->applyDateFilters($q, 'date_read', 's');
} else {
$chartQuery->applyDateFilters($q, 'date_sent', 's');
}
}
$results = $q->executeQuery()->fetchAllAssociative();
if ((true === $listId || is_array($listId)) && !$combined) {
// Return list group of counts
$byList = [];
foreach ($results as $result) {
$byList[$result['list_id']] = $result['count'];
}
return $byList;
}
return (isset($results[0])) ? $results[0]['count'] : 0;
}
/**
* @param array<int,int|string>|int $emailIds
*/
public function getOpenedRates($emailIds, ?\DateTime $fromDate = null): array
{
$inIds = (!is_array($emailIds)) ? [$emailIds] : $emailIds;
$sq = $this->_em->getConnection()->createQueryBuilder();
$sq->select('e.email_id, count(e.id) as the_count')
->from(MAUTIC_TABLE_PREFIX.'email_stats', 'e')
->where(
$sq->expr()->and(
$sq->expr()->eq('e.is_failed', ':false'),
$sq->expr()->in('e.email_id', $inIds)
)
)->setParameter('false', false, 'boolean');
if (null !== $fromDate) {
// make sure the date is UTC
$dt = new DateTimeHelper($fromDate);
$sq->andWhere(
$sq->expr()->gte('e.date_sent', $sq->expr()->literal($dt->toUtcString()))
);
}
$sq->groupBy('e.email_id');
// get a total number of sent emails first
$totalCounts = $sq->executeQuery()->fetchAllAssociative();
$return = [];
foreach ($inIds as $id) {
$return[$id] = [
'totalCount' => 0,
'readCount' => 0,
'readRate' => 0,
];
}
foreach ($totalCounts as $t) {
if (null != $t['email_id']) {
$return[$t['email_id']]['totalCount'] = (int) $t['the_count'];
}
}
// now get a read count
$sq->andWhere('e.is_read = :true')
->setParameter('true', true, 'boolean');
$readCounts = $sq->executeQuery()->fetchAllAssociative();
foreach ($readCounts as $r) {
$return[$r['email_id']]['readCount'] = (int) $r['the_count'];
$return[$r['email_id']]['readRate'] = ($return[$r['email_id']]['totalCount']) ?
round(($r['the_count'] / $return[$r['email_id']]['totalCount']) * 100, 2) :
0;
}
return (!is_array($emailIds)) ? $return[$emailIds] : $return;
}
/**
* @param array<int,int|string>|int $emailIds
*
* @return array<int, array<string, mixed>>
*/
public function getOpenedStatIds($emailIds = null, $listId = null): array
{
$q = $this->_em->getConnection()->createQueryBuilder();
$q->select('s.id')
->from(MAUTIC_TABLE_PREFIX.'email_stats', 's');
if ($emailIds) {
if (!is_array($emailIds)) {
$emailIds = [(int) $emailIds];
}
$q->where(
$q->expr()->in('s.email_id', $emailIds)
);
}
$q->andWhere('open_count > 0');
if ($listId) {
$q->andWhere('s.list_id = '.(int) $listId);
}
return $q->executeQuery()->fetchAllAssociative();
}
/**
* Get a lead's email stat.
*
* @param int $leadId
*
* @return array
*
* @throws \Doctrine\ORM\NoResultException
* @throws \Doctrine\ORM\NonUniqueResultException
*/
public function getLeadStats($leadId, array $options = [])
{
$query = $this->getEntityManager()->getConnection()->createQueryBuilder();
$query->from(MAUTIC_TABLE_PREFIX.'email_stats', 's')
->leftJoin('s', MAUTIC_TABLE_PREFIX.'emails', 'e', 's.email_id = e.id')
->leftJoin('s', MAUTIC_TABLE_PREFIX.'email_copies', 'ec', 's.copy_id = ec.id');
if ($leadId) {
$query->andWhere('s.lead_id = :leadId')
->setParameter('leadId', $leadId);
}
if (!empty($options['basic_select'])) {
$query->select(
's.email_id, s.id, s.date_read as dateRead, s.date_sent as dateSent, e.subject, e.name as email_name, s.is_read as isRead, s.is_failed as isFailed, ec.subject as storedSubject'
);
} else {
$query->select(
's.email_id, s.id, s.date_read as dateRead, s.date_sent as dateSent,e.subject, e.name as email_name, s.is_read as isRead, s.is_failed as isFailed, s.viewed_in_browser as viewedInBrowser, s.retry_count as retryCount, s.list_id, l.name as list_name, s.tracking_hash as idHash, s.open_details as openDetails, ec.subject as storedSubject, s.lead_id'
)
->leftJoin('s', MAUTIC_TABLE_PREFIX.'lead_lists', 'l', 's.list_id = l.id');
}
$timestampColumn = 's.date_sent';
if (isset($options['state'])) {
$state = $options['state'];
if ('read' == $state) {
$timestampColumn = 's.date_read';
$query->andWhere(
$query->expr()->eq('s.is_read', 1)
);
} elseif ('failed' == $state) {
$query->andWhere(
$query->expr()->eq('s.is_failed', 1)
);
}
}
if (isset($options['search']) && $options['search']) {
$query->andWhere(
$query->expr()->or(
$query->expr()->like('ec.subject', ':search'),
$query->expr()->like('e.subject', ':search'),
$query->expr()->like('e.name', ':search')
)
)->setParameter('search', '%'.$options['search'].'%');
}
if (isset($options['fromDate']) && $options['fromDate']) {
$dt = new DateTimeHelper($options['fromDate']);
$query->andWhere(
$query->expr()->gte($timestampColumn, ':fromDate')
)->setParameter('fromDate', $dt->toUtcString());
}
$timeToReadParser = function (&$stat): void {
$dateSent = new DateTimeHelper($stat['dateSent']);
if (!empty($stat['dateSent']) && !empty($stat['dateRead'])) {
$stat['timeToRead'] = $dateSent->getDiff($stat['dateRead']);
} else {
$stat['timeToRead'] = false;
}
};
return $this->getTimelineResults(
$query,
$options,
'storedSubject, e.subject',
$timestampColumn,
['openDetails'],
['dateRead', 'dateSent'],
$timeToReadParser,
's.id'
);
}
/**
* Get counts for Sent, Read and Failed emails.
*
* @param QueryBuilder $query
*
* @return array
*
* @throws \Doctrine\ORM\NoResultException
* @throws \Doctrine\ORM\NonUniqueResultException
*/
public function getIgnoredReadFailed($query = null)
{
$query->select('count(es.id) as sent, count(CASE WHEN es.is_read THEN 1 ELSE null END) as "read", count(CASE WHEN es.is_failed THEN 1 ELSE null END) as failed');
$results = $query->executeQuery()->fetchAssociative();
if ($results) {
$results['ignored'] = $results['sent'] - $results['read'] - $results['failed'];
unset($results['sent']);
} else {
$results['ignored'] = $results['sent'] = $results['read'] = $results['failed'] = 0;
}
return $results;
}
/**
* Get pie graph data for Sent, Read and Failed email count.
*
* @param QueryBuilder $query
*
* @throws \Doctrine\ORM\NoResultException
* @throws \Doctrine\ORM\NonUniqueResultException
*/
public function getMostEmails($query, $limit = 10, $offset = 0): array
{
$query
->setMaxResults($limit)
->setFirstResult($offset);
return $query->executeQuery()->fetchAllAssociative();
}
/**
* Get sent counts based grouped by email Id.
*
* @param array $emailIds
*/
public function getSentCounts($emailIds = [], ?\DateTime $fromDate = null): array
{
$q = $this->_em->getConnection()->createQueryBuilder();
$q->select('e.email_id, count(e.id) as sentcount')
->from(MAUTIC_TABLE_PREFIX.'email_stats', 'e')
->where(
$q->expr()->and(
$q->expr()->in('e.email_id', $emailIds),
$q->expr()->eq('e.is_failed', ':false')
)
)->setParameter('false', false, 'boolean');
if (null !== $fromDate) {
// make sure the date is UTC
$dt = new DateTimeHelper($fromDate);
$q->andWhere(
$q->expr()->gte('e.date_read', $q->expr()->literal($dt->toUtcString()))
);
}
$q->groupBy('e.email_id');
// get a total number of sent emails first
$results = $q->executeQuery()->fetchAllAssociative();
$counts = [];
foreach ($results as $r) {
$counts[$r['email_id']] = $r['sentcount'];
}
return $counts;
}
/**
* Updates lead ID (e.g. after a lead merge).
*/
public function updateLead($fromLeadId, $toLeadId): void
{
$q = $this->_em->getConnection()->createQueryBuilder();
$q->update(MAUTIC_TABLE_PREFIX.'email_stats')
->set('lead_id', (int) $toLeadId)
->where('lead_id = '.(int) $fromLeadId)
->executeStatement();
}
/**
* Delete a stat.
*/
public function deleteStat($id): void
{
$this->getEntityManager()->getConnection()->delete(MAUTIC_TABLE_PREFIX.'email_stats', ['id' => (int) $id]);
}
public function deleteStats(array $ids): void
{
$qb = $this->getEntityManager()->getConnection()->createQueryBuilder();
$qb->delete(MAUTIC_TABLE_PREFIX.'email_stats')
->where(
$qb->expr()->in('id', $ids)
)
->executeStatement();
}
public function getTableAlias(): string
{
return 's';
}
/**
* @return array
*/
public function findContactEmailStats($leadId, $emailId)
{
return $this->createQueryBuilder('s')
->where('IDENTITY(s.lead) = :leadId AND IDENTITY(s.email) = :emailId')
->setParameter('leadId', (int) $leadId)
->setParameter('emailId', (int) $emailId)
->getQuery()
->getResult();
}
public function checkContactSentEmail(int $contactId, int $emailId): bool
{
$query = $this->getEntityManager()->getConnection()->createQueryBuilder();
$query->from(MAUTIC_TABLE_PREFIX.'email_stats', 's');
$query->select('1')
->where('s.email_id = :emailId')
->andWhere('s.lead_id = :contactId')
->andWhere('is_failed = 0')
->setParameter('emailId', $emailId)
->setParameter('contactId', $contactId)
->setMaxResults(1);
return (bool) $query->executeQuery()->fetchOne();
}
/**
* @return array Formatted as [contactId => sentCount]
*/
public function getSentCountForContacts(array $contacts, $emailId): array
{
$query = $this->getEntityManager()->getConnection()->createQueryBuilder();
$query->from(MAUTIC_TABLE_PREFIX.'email_stats', 's');
$query->select('count(s.id) as sent_count, s.lead_id')
->where('s.email_id = :email')
->andWhere('s.lead_id in (:contacts)')
->andWhere('s.is_failed = 0')
->setParameter('email', $emailId)
->setParameter('contacts', $contacts, ArrayParameterType::INTEGER)
->groupBy('s.lead_id');
$results = $query->executeQuery()->fetchAllAssociative();
$contacts = [];
foreach ($results as $result) {
$contacts[$result['lead_id']] = $result['sent_count'];
}
return $contacts;
}
/**
* @param array<int> $contacts
*
* @return array<int, array<string, int|float>>
*
* @throws Exception
*/
public function getStatsSummaryForContacts(array $contacts): array
{
$queryBuilder = $this->getEntityManager()->getConnection()->createQueryBuilder();
$subQueryBuilder = $this->getEntityManager()->getConnection()->createQueryBuilder();
$leadAlias = 'l'; // leads
$statsAlias = 'es'; // email_stats
$subQueryAlias = 'sq'; // sub query
$cutAlias = 'cut'; // channel_url_trackables
$pageHitsAlias = 'ph'; // page_hits
// use sub query to get page hits for and unique page hits selected contacts
$subQueryBuilder->select(
"COUNT({$pageHitsAlias}.id) AS hits",
"COUNT(DISTINCT({$pageHitsAlias}.redirect_id)) AS unique_hits",
"{$cutAlias}.channel_id",
"{$pageHitsAlias}.lead_id"
)
->from(MAUTIC_TABLE_PREFIX.'channel_url_trackables', $cutAlias)
->join(
$cutAlias,
MAUTIC_TABLE_PREFIX.'page_hits',
$pageHitsAlias,
"{$cutAlias}.redirect_id = {$pageHitsAlias}.redirect_id AND {$cutAlias}.channel_id = {$pageHitsAlias}.source_id"
)
->where("{$cutAlias}.channel = 'email' AND {$pageHitsAlias}.source = 'email'")
->andWhere("{$pageHitsAlias}.lead_id in (:contacts)")
->setParameter('contacts', $contacts, ArrayParameterType::INTEGER)
->groupBy("{$cutAlias}.channel_id, {$pageHitsAlias}.lead_id");
// main query
$queryBuilder->select(
"{$leadAlias}.id AS `lead_id`",
"COUNT({$statsAlias}.id) AS `sent_count`",
"SUM(IF({$statsAlias}.is_read IS NULL, 0, {$statsAlias}.is_read)) AS `read_count`",
"SUM(IF({$subQueryAlias}.hits is NULL, 0, 1)) AS `clicked_through_count`",
)->from(MAUTIC_TABLE_PREFIX.'email_stats', $statsAlias)
->rightJoin(
$statsAlias,
MAUTIC_TABLE_PREFIX.'leads',
$leadAlias,
"{$statsAlias}.lead_id=l.id"
)->leftJoin(
$statsAlias,
"({$subQueryBuilder->getSQL()})",
$subQueryAlias,
"{$statsAlias}.email_id = {$subQueryAlias}.channel_id AND {$statsAlias}.lead_id = {$subQueryAlias}.lead_id"
)->andWhere("{$leadAlias}.id in (:contacts)")
->setParameter('contacts', $contacts, ArrayParameterType::INTEGER)
->groupBy("{$leadAlias}.id");
$results = $queryBuilder->executeQuery()->fetchAllAssociative();
$contacts = [];
foreach ($results as $result) {
$sentCount = (int) $result['sent_count'];
$readCount = (int) $result['read_count'];
$clickedCount = (int) $result['clicked_through_count'];
$contacts[(int) $result['lead_id']] = [
'sent_count' => $sentCount,
'read_count' => $readCount,
'clicked_count' => $clickedCount,
'open_rate' => round($sentCount > 0 ? ($readCount / $sentCount) : 0, 4),
'click_through_rate' => round($sentCount > 0 ? ($clickedCount / $sentCount) : 0, 4),
'click_through_open_rate' => round($readCount > 0 ? ($clickedCount / $readCount) : 0, 4),
];
}
return $contacts;
}
/**
* @param array<int|string> $emailsIds
* @param array<int> $eventsIds
*
* @return array<int, array<string, int|string>>
*
* @throws Exception
*/
public function getStatsSummaryByCountry(\DateTimeImmutable $dateFrom, \DateTimeImmutable $dateTo, array $emailsIds, string $sourceType = 'email', array $eventsIds = []): array
{
$queryBuilder = $this->getEntityManager()->getConnection()->createQueryBuilder();
$subQueryBuilder = $this->getEntityManager()->getConnection()->createQueryBuilder();
$leadAlias = 'l'; // leads
$statsAlias = 'es'; // email_stats
$subQueryAlias = 'sq'; // sub query
$cutAlias = 'cut'; // channel_url_trackables
$pageHitsAlias = 'ph'; // page_hits
// use sub query to get page hits for and unique page hits selected contacts
$subQueryBuilder->select(
"COUNT({$pageHitsAlias}.id) AS hits",
"COUNT(DISTINCT({$pageHitsAlias}.redirect_id)) AS unique_hits",
"{$cutAlias}.channel_id",
"{$pageHitsAlias}.lead_id"
)
->from(MAUTIC_TABLE_PREFIX.'channel_url_trackables', $cutAlias)
->join(
$cutAlias,
MAUTIC_TABLE_PREFIX.'page_hits',
$pageHitsAlias,
"{$cutAlias}.redirect_id = {$pageHitsAlias}.redirect_id AND {$cutAlias}.channel_id = {$pageHitsAlias}.source_id"
)
->where("{$cutAlias}.channel = 'email' AND {$pageHitsAlias}.source = 'email'")
->andWhere("{$cutAlias}.channel_id in (:emails)")
->groupBy("{$cutAlias}.channel_id, {$pageHitsAlias}.lead_id");
// main query
$queryBuilder->addSelect(
"COUNT({$statsAlias}.id) AS `sent_count`",
"SUM(IF({$statsAlias}.is_read IS NULL, 0, {$statsAlias}.is_read)) AS `read_count`",
"SUM(IF({$subQueryAlias}.hits is NULL, 0, 1)) AS `clicked_through_count`",
)->from(MAUTIC_TABLE_PREFIX.'email_stats', $statsAlias)
->rightJoin(
$statsAlias,
MAUTIC_TABLE_PREFIX.'leads',
$leadAlias,
"{$statsAlias}.lead_id=l.id"
)->leftJoin(
$statsAlias,
"({$subQueryBuilder->getSQL()})",
$subQueryAlias,
"{$statsAlias}.email_id = {$subQueryAlias}.channel_id AND {$statsAlias}.lead_id = {$subQueryAlias}.lead_id"
);
switch ($sourceType) {
case 'campaign':
$queryBuilder->addSelect("{$leadAlias}.country AS `country`")
->andWhere("{$statsAlias}.source_id in (:events)")
->andWhere("{$statsAlias}.source = :source")
->setParameter('emails', $emailsIds, ArrayParameterType::INTEGER)
->setParameter('events', $eventsIds, ArrayParameterType::INTEGER)
->setParameter('source', 'campaign.event');
break;
case 'email':
$queryBuilder->addSelect("{$leadAlias}.country AS `country`")
->andWhere("{$statsAlias}.email_id in (:emails)")
->setParameter('emails', $emailsIds, ArrayParameterType::INTEGER);
}
$queryBuilder->groupBy("{$leadAlias}.country")
->orderBy("{$leadAlias}.country", 'ASC');
$queryBuilder->andWhere("{$statsAlias}.date_sent BETWEEN :dateFrom AND :dateTo");
$queryBuilder->setParameter('dateFrom', $dateFrom->format(DateTimeHelper::FORMAT_DB));
$queryBuilder->setParameter('dateTo', $dateTo->setTime(23, 59, 59)->format('Y-m-d H:i:s'));
return $queryBuilder->executeQuery()->fetchAllAssociative();
}
}