init
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Statistics;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Entities\NewsletterEntity;
|
||||
use MailPoet\Newsletter\Links\Links as NewsletterLinks;
|
||||
use MailPoet\Settings\TrackingConfig;
|
||||
use MailPoet\Util\Helpers;
|
||||
use MailPoet\Util\SecondLevelDomainNames;
|
||||
use MailPoet\WP\Functions;
|
||||
|
||||
class GATracking {
|
||||
|
||||
/** @var SecondLevelDomainNames */
|
||||
private $secondLevelDomainNames;
|
||||
|
||||
/** @var NewsletterLinks */
|
||||
private $newsletterLinks;
|
||||
|
||||
/** @var Functions */
|
||||
private $wp;
|
||||
|
||||
/** @var TrackingConfig */
|
||||
private $tackingConfig;
|
||||
|
||||
public function __construct(
|
||||
NewsletterLinks $newsletterLinks,
|
||||
Functions $wp,
|
||||
TrackingConfig $trackingConfig
|
||||
) {
|
||||
$this->secondLevelDomainNames = new SecondLevelDomainNames();
|
||||
$this->newsletterLinks = $newsletterLinks;
|
||||
$this->wp = $wp;
|
||||
$this->tackingConfig = $trackingConfig;
|
||||
}
|
||||
|
||||
public function applyGATracking($renderedNewsletter, NewsletterEntity $newsletter, $internalHost = null) {
|
||||
if (!$this->tackingConfig->isEmailTrackingEnabled()) {
|
||||
return $renderedNewsletter;
|
||||
}
|
||||
if ($newsletter->getType() == NewsletterEntity::TYPE_NOTIFICATION_HISTORY && $newsletter->getParent() instanceof NewsletterEntity) {
|
||||
$parentNewsletter = $newsletter->getParent();
|
||||
$field = $parentNewsletter->getGaCampaign();
|
||||
} else {
|
||||
$field = $newsletter->getGaCampaign();
|
||||
}
|
||||
|
||||
return $this->addGAParamsToLinks($renderedNewsletter, $field, $internalHost);
|
||||
}
|
||||
|
||||
private function addGAParamsToLinks($renderedNewsletter, $gaCampaign, $internalHost = null) {
|
||||
// join HTML and TEXT rendered body into a text string
|
||||
$content = Helpers::joinObject($renderedNewsletter);
|
||||
$extractedLinks = $this->newsletterLinks->extract($content);
|
||||
$processedLinks = $this->addParams($extractedLinks, $gaCampaign, $internalHost);
|
||||
list($content, $links) = $this->newsletterLinks->replace($content, $processedLinks);
|
||||
// split the processed body with hashed links back to HTML and TEXT
|
||||
list($renderedNewsletter['html'], $renderedNewsletter['text'])
|
||||
= Helpers::splitObject($content);
|
||||
return $renderedNewsletter;
|
||||
}
|
||||
|
||||
private function addParams($extractedLinks, $gaCampaign, $internalHost = null) {
|
||||
$processedLinks = [];
|
||||
$params = [
|
||||
'utm_source' => 'mailpoet',
|
||||
'utm_medium' => 'email',
|
||||
'utm_source_platform' => 'mailpoet',
|
||||
];
|
||||
if ($gaCampaign) {
|
||||
$params['utm_campaign'] = $gaCampaign;
|
||||
}
|
||||
$internalHost = $internalHost ?: parse_url(home_url(), PHP_URL_HOST);
|
||||
$internalHost = $this->secondLevelDomainNames->get($internalHost);
|
||||
foreach ($extractedLinks as $extractedLink) {
|
||||
if ($extractedLink['type'] !== NewsletterLinks::LINK_TYPE_URL) {
|
||||
continue;
|
||||
} elseif (strpos((string)parse_url($extractedLink['link'], PHP_URL_HOST), $internalHost) === false) {
|
||||
// Process only internal links (i.e. pointing to current site)
|
||||
continue;
|
||||
}
|
||||
|
||||
$link = $extractedLink['link'];
|
||||
|
||||
// Do not overwrite existing query parameters
|
||||
$parsedUrl = parse_url($link);
|
||||
$linkParams = $params;
|
||||
if (isset($parsedUrl['query'])) {
|
||||
foreach (array_keys($params) as $param) {
|
||||
if (strpos($parsedUrl['query'], $param . '=') !== false) {
|
||||
unset($linkParams[$param]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$processedLink = $this->wp->applyFilters(
|
||||
'mailpoet_ga_tracking_link',
|
||||
$this->wp->addQueryArg($linkParams, $link),
|
||||
$extractedLink['link'],
|
||||
$linkParams,
|
||||
$extractedLink['type']
|
||||
);
|
||||
$processedLinks[$link] = [
|
||||
'type' => $extractedLink['type'],
|
||||
'link' => $link,
|
||||
'processed_link' => $processedLink,
|
||||
];
|
||||
}
|
||||
return $processedLinks;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Statistics;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Doctrine\Repository;
|
||||
use MailPoet\Entities\StatisticsBounceEntity;
|
||||
|
||||
/**
|
||||
* @extends Repository<StatisticsBounceEntity>
|
||||
*/
|
||||
class StatisticsBouncesRepository extends Repository {
|
||||
protected function getEntityClassName(): string {
|
||||
return StatisticsBounceEntity::class;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Statistics;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Doctrine\Repository;
|
||||
use MailPoet\Entities\NewsletterEntity;
|
||||
use MailPoet\Entities\NewsletterLinkEntity;
|
||||
use MailPoet\Entities\SendingQueueEntity;
|
||||
use MailPoet\Entities\StatisticsClickEntity;
|
||||
use MailPoet\Entities\SubscriberEntity;
|
||||
use MailPoet\Entities\UserAgentEntity;
|
||||
use MailPoetVendor\Doctrine\ORM\QueryBuilder;
|
||||
|
||||
/**
|
||||
* @extends Repository<StatisticsClickEntity>
|
||||
*/
|
||||
class StatisticsClicksRepository extends Repository {
|
||||
protected function getEntityClassName(): string {
|
||||
return StatisticsClickEntity::class;
|
||||
}
|
||||
|
||||
public function createOrUpdateClickCount(
|
||||
NewsletterLinkEntity $link,
|
||||
SubscriberEntity $subscriber,
|
||||
NewsletterEntity $newsletter,
|
||||
SendingQueueEntity $queue,
|
||||
?UserAgentEntity $userAgent
|
||||
): StatisticsClickEntity {
|
||||
$statistics = $this->findOneBy([
|
||||
'link' => $link,
|
||||
'newsletter' => $newsletter,
|
||||
'subscriber' => $subscriber,
|
||||
'queue' => $queue,
|
||||
]);
|
||||
if (!$statistics instanceof StatisticsClickEntity) {
|
||||
$statistics = new StatisticsClickEntity($newsletter, $queue, $subscriber, $link, 1);
|
||||
if ($userAgent) {
|
||||
$statistics->setUserAgent($userAgent);
|
||||
$statistics->setUserAgentType($userAgent->getUserAgentType());
|
||||
}
|
||||
$this->persist($statistics);
|
||||
} else {
|
||||
$statistics->setCount($statistics->getCount() + 1);
|
||||
}
|
||||
return $statistics;
|
||||
}
|
||||
|
||||
public function getAllForSubscriber(SubscriberEntity $subscriber): QueryBuilder {
|
||||
return $this->entityManager->createQueryBuilder()
|
||||
->select('clicks.id id, queue.newsletterRenderedSubject, clicks.createdAt, link.url, userAgent.userAgent')
|
||||
->from(StatisticsClickEntity::class, 'clicks')
|
||||
->join('clicks.queue', 'queue')
|
||||
->join('clicks.link', 'link')
|
||||
->leftJoin('clicks.userAgent', 'userAgent')
|
||||
->where('clicks.subscriber = :subscriber')
|
||||
->orderBy('link.url')
|
||||
->setParameter('subscriber', $subscriber->getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param SubscriberEntity $subscriber
|
||||
* @param \DateTimeInterface $from
|
||||
* @param \DateTimeInterface $to
|
||||
* @return StatisticsClickEntity[]
|
||||
*/
|
||||
public function findLatestPerNewsletterBySubscriber(SubscriberEntity $subscriber, \DateTimeInterface $from, \DateTimeInterface $to): array {
|
||||
// subquery to find latest click IDs for each newsletter
|
||||
$latestClickIdsPerNewsletterQuery = $this->entityManager->createQueryBuilder()
|
||||
->select('MAX(clicks.id)')
|
||||
->from(StatisticsClickEntity::class, 'clicks')
|
||||
->where('clicks.subscriber = :subscriber')
|
||||
->andWhere('clicks.updatedAt > :from')
|
||||
->andWhere('clicks.updatedAt < :to')
|
||||
->groupBy('clicks.newsletter');
|
||||
|
||||
$expr = $this->entityManager->getExpressionBuilder();
|
||||
return $this->entityManager->createQueryBuilder()
|
||||
->select('c')
|
||||
->from(StatisticsClickEntity::class, 'c')
|
||||
->where(
|
||||
$expr->in(
|
||||
'c.id',
|
||||
$latestClickIdsPerNewsletterQuery->getDQL()
|
||||
)
|
||||
)
|
||||
->setParameter('subscriber', $subscriber)
|
||||
->setParameter('from', $from->format('Y-m-d H:i:s'))
|
||||
->setParameter('to', $to->format('Y-m-d H:i:s'))
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/** @param int[] $ids */
|
||||
public function deleteByNewsletterIds(array $ids): void {
|
||||
$this->entityManager->createQueryBuilder()
|
||||
->delete(StatisticsClickEntity::class, 's')
|
||||
->where('s.newsletter IN (:ids)')
|
||||
->setParameter('ids', $ids)
|
||||
->getQuery()
|
||||
->execute();
|
||||
|
||||
// delete was done via DQL, make sure the entities are also detached from the entity manager
|
||||
$this->detachAll(function (StatisticsClickEntity $entity) use ($ids) {
|
||||
$newsletter = $entity->getNewsletter();
|
||||
return $newsletter && in_array($newsletter->getId(), $ids, true);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Statistics;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Doctrine\Repository;
|
||||
use MailPoet\Entities\FormEntity;
|
||||
use MailPoet\Entities\StatisticsFormEntity;
|
||||
use MailPoet\Entities\SubscriberEntity;
|
||||
|
||||
/**
|
||||
* @extends Repository<StatisticsFormEntity>
|
||||
*/
|
||||
class StatisticsFormsRepository extends Repository {
|
||||
protected function getEntityClassName() {
|
||||
return StatisticsFormEntity::class;
|
||||
}
|
||||
|
||||
public function getTotalSignups(int $formId): int {
|
||||
return $this->countBy(['form' => $formId]);
|
||||
}
|
||||
|
||||
public function record(FormEntity $form, SubscriberEntity $subscriber): ?StatisticsFormEntity {
|
||||
if ($form->getId() > 0 && $subscriber->getId() > 0) {
|
||||
// check if we already have a record for today
|
||||
$statisticsForm = $this->findOneBy(['form' => $form, 'subscriber' => $subscriber]);
|
||||
|
||||
if (!$statisticsForm) {
|
||||
// create a new entry
|
||||
$statisticsForm = new StatisticsFormEntity($form, $subscriber);
|
||||
$this->persist($statisticsForm);
|
||||
$this->flush();
|
||||
}
|
||||
return $statisticsForm;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Statistics;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Doctrine\Repository;
|
||||
use MailPoet\Entities\NewsletterEntity;
|
||||
use MailPoet\Entities\SendingQueueEntity;
|
||||
use MailPoet\Entities\StatisticsNewsletterEntity;
|
||||
use MailPoet\Entities\SubscriberEntity;
|
||||
use MailPoetVendor\Carbon\Carbon;
|
||||
|
||||
/**
|
||||
* @extends Repository<StatisticsNewsletterEntity>
|
||||
*/
|
||||
class StatisticsNewslettersRepository extends Repository {
|
||||
protected function getEntityClassName() {
|
||||
return StatisticsNewsletterEntity::class;
|
||||
}
|
||||
|
||||
public function createMultiple(array $data): void {
|
||||
$entities = [];
|
||||
|
||||
foreach ($data as $value) {
|
||||
if (!empty($value['newsletter_id']) && !empty($value['queue_id']) && !empty($value['subscriber_id'])) {
|
||||
$newsletter = $this->entityManager->getReference(NewsletterEntity::class, $value['newsletter_id']);
|
||||
$queue = $this->entityManager->getReference(SendingQueueEntity::class, $value['queue_id']);
|
||||
$subscriber = $this->entityManager->getReference(SubscriberEntity::class, $value['subscriber_id']);
|
||||
|
||||
if (!$newsletter || !$queue || !$subscriber) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sentAt = Carbon::now()->millisecond(0);
|
||||
$entity = new StatisticsNewsletterEntity($newsletter, $queue, $subscriber, $sentAt);
|
||||
|
||||
$this->entityManager->persist($entity);
|
||||
$entities[] = $entity;
|
||||
}
|
||||
}
|
||||
|
||||
if (count($entities)) {
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
}
|
||||
|
||||
/** @param int[] $ids */
|
||||
public function deleteByNewsletterIds(array $ids): void {
|
||||
$this->entityManager->createQueryBuilder()
|
||||
->delete(StatisticsNewsletterEntity::class, 's')
|
||||
->where('s.newsletter IN (:ids)')
|
||||
->setParameter('ids', $ids)
|
||||
->getQuery()
|
||||
->execute();
|
||||
|
||||
// delete was done via DQL, make sure the entities are also detached from the entity manager
|
||||
$this->detachAll(function (StatisticsNewsletterEntity $entity) use ($ids) {
|
||||
$newsletter = $entity->getNewsletter();
|
||||
return $newsletter && in_array($newsletter->getId(), $ids, true);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Statistics;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Doctrine\Repository;
|
||||
use MailPoet\Entities\SegmentEntity;
|
||||
use MailPoet\Entities\StatisticsOpenEntity;
|
||||
use MailPoet\Entities\SubscriberEntity;
|
||||
use MailPoet\Subscribers\Statistics\SubscriberStatisticsRepository;
|
||||
use MailPoetVendor\Carbon\Carbon;
|
||||
use MailPoetVendor\Doctrine\ORM\EntityManager;
|
||||
use MailPoetVendor\Doctrine\ORM\QueryBuilder;
|
||||
|
||||
/**
|
||||
* @extends Repository<StatisticsOpenEntity>
|
||||
*/
|
||||
class StatisticsOpensRepository extends Repository {
|
||||
/** @var SubscriberStatisticsRepository */
|
||||
private $subscriberStatisticsRepository;
|
||||
|
||||
public function __construct(
|
||||
EntityManager $entityManager,
|
||||
SubscriberStatisticsRepository $subscriberStatisticsRepository
|
||||
) {
|
||||
parent::__construct($entityManager);
|
||||
$this->entityManager = $entityManager;
|
||||
$this->subscriberStatisticsRepository = $subscriberStatisticsRepository;
|
||||
}
|
||||
|
||||
protected function getEntityClassName(): string {
|
||||
return StatisticsOpenEntity::class;
|
||||
}
|
||||
|
||||
public function recalculateSubscriberScore(SubscriberEntity $subscriber): void {
|
||||
$subscriber->setEngagementScoreUpdatedAt(new \DateTimeImmutable());
|
||||
$yearAgo = Carbon::now()->subYear();
|
||||
$newslettersSentCount = $this->subscriberStatisticsRepository->getTotalSentCount($subscriber, $yearAgo);
|
||||
if ($newslettersSentCount < 3) {
|
||||
$subscriber->setEngagementScore(null);
|
||||
$this->entityManager->flush();
|
||||
return;
|
||||
}
|
||||
$opensCount = $this->subscriberStatisticsRepository->getStatisticsOpenCount($subscriber, $yearAgo);
|
||||
$score = ($opensCount / $newslettersSentCount) * 100;
|
||||
$subscriber->setEngagementScore($score);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
public function resetSubscribersScoreCalculation() {
|
||||
$this->entityManager->createQueryBuilder()->update(SubscriberEntity::class, 's')
|
||||
->set('s.engagementScoreUpdatedAt', ':updatedAt')
|
||||
->setParameter('updatedAt', null)
|
||||
->getQuery()->execute();
|
||||
}
|
||||
|
||||
public function recalculateSegmentScore(SegmentEntity $segment): void {
|
||||
$segment->setAverageEngagementScoreUpdatedAt(new \DateTimeImmutable());
|
||||
$avgScore = $this
|
||||
->entityManager
|
||||
->createQueryBuilder()
|
||||
->select('avg(subscriber.engagementScore)')
|
||||
->from(SubscriberEntity::class, 'subscriber')
|
||||
->join('subscriber.subscriberSegments', 'subscriberSegments')
|
||||
->where('subscriberSegments.segment = :segment')
|
||||
->andWhere('subscriber.status = :subscribed')
|
||||
->andWhere('subscriber.deletedAt IS NULL')
|
||||
->andWhere('subscriberSegments.status = :subscribed')
|
||||
->setParameter('segment', $segment)
|
||||
->setParameter('subscribed', SubscriberEntity::STATUS_SUBSCRIBED)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
$segment->setAverageEngagementScore($avgScore === null ? $avgScore : (float)$avgScore);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
public function resetSegmentsScoreCalculation(): void {
|
||||
$this->entityManager->createQueryBuilder()->update(SegmentEntity::class, 's')
|
||||
->set('s.averageEngagementScoreUpdatedAt', ':updatedAt')
|
||||
->setParameter('updatedAt', null)
|
||||
->getQuery()->execute();
|
||||
}
|
||||
|
||||
public function getAllForSubscriber(SubscriberEntity $subscriber): QueryBuilder {
|
||||
return $this->entityManager->createQueryBuilder()
|
||||
->select('opens.id id, queue.newsletterRenderedSubject, opens.createdAt, userAgent.userAgent')
|
||||
->from(StatisticsOpenEntity::class, 'opens')
|
||||
->join('opens.queue', 'queue')
|
||||
->leftJoin('opens.userAgent', 'userAgent')
|
||||
->where('opens.subscriber = :subscriber')
|
||||
->orderBy('queue.newsletterRenderedSubject')
|
||||
->setParameter('subscriber', $subscriber->getId());
|
||||
}
|
||||
|
||||
/** @param int[] $ids */
|
||||
public function deleteByNewsletterIds(array $ids): void {
|
||||
$this->entityManager->createQueryBuilder()
|
||||
->delete(StatisticsOpenEntity::class, 's')
|
||||
->where('s.newsletter IN (:ids)')
|
||||
->setParameter('ids', $ids)
|
||||
->getQuery()
|
||||
->execute();
|
||||
|
||||
// delete was done via DQL, make sure the entities are also detached from the entity manager
|
||||
$this->detachAll(function (StatisticsOpenEntity $entity) use ($ids) {
|
||||
$newsletter = $entity->getNewsletter();
|
||||
return $newsletter && in_array($newsletter->getId(), $ids, true);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Statistics;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Doctrine\Repository;
|
||||
use MailPoet\Entities\StatisticsUnsubscribeEntity;
|
||||
use MailPoetVendor\Carbon\Carbon;
|
||||
|
||||
/**
|
||||
* @extends Repository<StatisticsUnsubscribeEntity>
|
||||
*/
|
||||
class StatisticsUnsubscribesRepository extends Repository {
|
||||
protected function getEntityClassName() {
|
||||
return StatisticsUnsubscribeEntity::class;
|
||||
}
|
||||
|
||||
public function getTotalForMonths(int $forMonths): int {
|
||||
$from = (new Carbon())->subMonths($forMonths);
|
||||
$count = $this->entityManager->createQueryBuilder()
|
||||
->select('count(stats.id)')
|
||||
->from(StatisticsUnsubscribeEntity::class, 'stats')
|
||||
->andWhere('stats.createdAt >= :dateTime')
|
||||
->setParameter('dateTime', $from)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
|
||||
return intval($count);
|
||||
}
|
||||
|
||||
public function getCountPerMethodForMonths(int $forMonths): array {
|
||||
$from = (new Carbon())->subMonths($forMonths);
|
||||
return $this->entityManager->createQueryBuilder()
|
||||
->select('count(stats.id) as count, stats.method as method')
|
||||
->from(StatisticsUnsubscribeEntity::class, 'stats')
|
||||
->andWhere('stats.createdAt >= :dateTime')
|
||||
->groupBy('stats.method')
|
||||
->setParameter('dateTime', $from)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Statistics;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Doctrine\Repository;
|
||||
use MailPoet\Entities\NewsletterEntity;
|
||||
use MailPoet\Entities\SendingQueueEntity;
|
||||
use MailPoet\Entities\StatisticsClickEntity;
|
||||
use MailPoet\Entities\StatisticsWooCommercePurchaseEntity;
|
||||
use MailPoet\WooCommerce\Helper;
|
||||
use MailPoetVendor\Doctrine\DBAL\ArrayParameterType;
|
||||
use MailPoetVendor\Doctrine\DBAL\ParameterType;
|
||||
use MailPoetVendor\Doctrine\ORM\EntityManager;
|
||||
|
||||
/**
|
||||
* @extends Repository<StatisticsWooCommercePurchaseEntity>
|
||||
*/
|
||||
class StatisticsWooCommercePurchasesRepository extends Repository {
|
||||
|
||||
/** @var Helper */
|
||||
private $wooCommerceHelper;
|
||||
|
||||
public function __construct(
|
||||
EntityManager $entityManager,
|
||||
Helper $wooCommerceHelper
|
||||
) {
|
||||
parent::__construct($entityManager);
|
||||
$this->wooCommerceHelper = $wooCommerceHelper;
|
||||
}
|
||||
|
||||
protected function getEntityClassName() {
|
||||
return StatisticsWooCommercePurchaseEntity::class;
|
||||
}
|
||||
|
||||
public function createOrUpdateByClickDataAndOrder(StatisticsClickEntity $click, \WC_Order $order) {
|
||||
// search by subscriber and newsletter IDs (instead of click itself) to avoid duplicities
|
||||
// when a new click from the subscriber appeared since last tracking for given newsletter
|
||||
// (this will keep the originally tracked click - likely the click that led to the order)
|
||||
$statistics = $this->findOneBy([
|
||||
'orderId' => $order->get_id(),
|
||||
'subscriber' => $click->getSubscriber(),
|
||||
'newsletter' => $click->getNewsletter(),
|
||||
]);
|
||||
|
||||
if (!$statistics instanceof StatisticsWooCommercePurchaseEntity) {
|
||||
$newsletter = $click->getNewsletter();
|
||||
$queue = $click->getQueue();
|
||||
if ((!$newsletter instanceof NewsletterEntity) || (!$queue instanceof SendingQueueEntity)) return;
|
||||
$statistics = new StatisticsWooCommercePurchaseEntity(
|
||||
$newsletter,
|
||||
$queue,
|
||||
$click,
|
||||
$order->get_id(),
|
||||
$order->get_currency(),
|
||||
(float)$order->get_remaining_refund_amount(),
|
||||
$order->get_status()
|
||||
);
|
||||
$this->persist($statistics);
|
||||
} else {
|
||||
$statistics->setOrderCurrency($order->get_currency());
|
||||
$statistics->setOrderPriceTotal((float)$order->get_remaining_refund_amount());
|
||||
$statistics->setStatus($order->get_status());
|
||||
}
|
||||
$statistics->setSubscriber($click->getSubscriber());
|
||||
$this->flush();
|
||||
}
|
||||
|
||||
public function getRevenuesByCampaigns(string $currency): array {
|
||||
$revenueStatus = $this->wooCommerceHelper->getPurchaseStates();
|
||||
$revenueStatsTable = $this->entityManager->getClassMetadata(StatisticsWooCommercePurchaseEntity::class)->getTableName();
|
||||
$newsletterTable = $this->entityManager->getClassMetadata(NewsletterEntity::class)->getTableName();
|
||||
|
||||
// The "SELECT MIN(click_id)..." sub-query is used to count each purchase only once.
|
||||
// In the data we track a purchase to multiple newsletters if clicks from multiple newsletters occurred.
|
||||
/** @var array<int, array{revenue: float|int, campaign_id:int, orders_count:int}> $data */
|
||||
$data = $this->entityManager->getConnection()->executeQuery('
|
||||
SELECT
|
||||
SUM(swp.order_price_total) AS revenue,
|
||||
COALESCE(n.parent_id, swp.newsletter_id) AS campaign_id,
|
||||
(
|
||||
CASE
|
||||
WHEN n.type IS NULL THEN \'unknown\'
|
||||
WHEN n.type = :notification_history_type THEN :notification_type
|
||||
ELSE n.type
|
||||
END
|
||||
) AS campaign_type,
|
||||
COUNT(order_id) as orders_count
|
||||
FROM ' . $revenueStatsTable . ' swp
|
||||
LEFT JOIN ' . $newsletterTable . ' n ON
|
||||
n.id = swp.newsletter_id
|
||||
WHERE
|
||||
swp.order_currency = :currency
|
||||
AND swp.status IN (:revenue_status)
|
||||
AND swp.click_id IN (SELECT MIN(click_id) FROM ' . $revenueStatsTable . ' ss GROUP BY order_id)
|
||||
GROUP BY campaign_id, n.type;
|
||||
', [
|
||||
'notification_history_type' => NewsletterEntity::TYPE_NOTIFICATION_HISTORY,
|
||||
'notification_type' => NewsletterEntity::TYPE_NOTIFICATION,
|
||||
'currency' => $currency,
|
||||
'revenue_status' => $revenueStatus,
|
||||
], [
|
||||
'notification_history_type' => ParameterType::STRING,
|
||||
'notification_type' => ParameterType::STRING,
|
||||
'currency' => ParameterType::STRING,
|
||||
'revenue_status' => ArrayParameterType::STRING,
|
||||
])->fetchAllAssociative();
|
||||
|
||||
$data = array_map(function($row) {
|
||||
$row['revenue'] = round(floatval($row['revenue']), 2);
|
||||
$row['orders_count'] = intval($row['orders_count']);
|
||||
return $row;
|
||||
}, $data);
|
||||
return $data;
|
||||
}
|
||||
|
||||
/** @param int[] $ids */
|
||||
public function removeNewsletterDataByNewsletterIds(array $ids): void {
|
||||
$this->entityManager->createQueryBuilder()
|
||||
->update(StatisticsWooCommercePurchaseEntity::class, 'swp')
|
||||
->set('swp.newsletter', ':newsletter')
|
||||
->where('swp.newsletter IN (:ids)')
|
||||
->setParameter('newsletter', null)
|
||||
->setParameter('ids', $ids)
|
||||
->getQuery()
|
||||
->execute();
|
||||
|
||||
// update was done via DQL, make sure the entities are also refreshed in the entity manager
|
||||
$this->refreshAll(function (StatisticsWooCommercePurchaseEntity $entity) use ($ids) {
|
||||
$newsletter = $entity->getNewsletter();
|
||||
return $newsletter && in_array($newsletter->getId(), $ids, true);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Statistics\Track;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Entities\NewsletterEntity;
|
||||
use MailPoet\Entities\NewsletterLinkEntity;
|
||||
use MailPoet\Entities\SendingQueueEntity;
|
||||
use MailPoet\Entities\StatisticsClickEntity;
|
||||
use MailPoet\Entities\SubscriberEntity;
|
||||
use MailPoet\Entities\UserAgentEntity;
|
||||
use MailPoet\Newsletter\Shortcodes\Categories\Link as LinkShortcodeCategory;
|
||||
use MailPoet\Newsletter\Shortcodes\Shortcodes;
|
||||
use MailPoet\Settings\TrackingConfig;
|
||||
use MailPoet\Statistics\StatisticsClicksRepository;
|
||||
use MailPoet\Statistics\UserAgentsRepository;
|
||||
use MailPoet\Subscribers\SubscribersRepository;
|
||||
use MailPoet\Util\Cookies;
|
||||
use MailPoet\WP\Functions as WPFunctions;
|
||||
|
||||
class Clicks {
|
||||
|
||||
const REVENUE_TRACKING_COOKIE_NAME = 'mailpoet_revenue_tracking';
|
||||
const REVENUE_TRACKING_COOKIE_EXPIRY = 60 * 60 * 24 * 14;
|
||||
|
||||
/** @var Cookies */
|
||||
private $cookies;
|
||||
|
||||
/** @var SubscriberCookie */
|
||||
private $subscriberCookie;
|
||||
|
||||
/** @var Shortcodes */
|
||||
private $shortcodes;
|
||||
|
||||
/** @var LinkShortcodeCategory */
|
||||
private $linkShortcodeCategory;
|
||||
|
||||
/** @var Opens */
|
||||
private $opens;
|
||||
|
||||
/** @var StatisticsClicksRepository */
|
||||
private $statisticsClicksRepository;
|
||||
|
||||
/** @var UserAgentsRepository */
|
||||
private $userAgentsRepository;
|
||||
|
||||
/** @var SubscribersRepository */
|
||||
private $subscribersRepository;
|
||||
|
||||
/** @var TrackingConfig */
|
||||
private $trackingConfig;
|
||||
|
||||
public function __construct(
|
||||
Cookies $cookies,
|
||||
SubscriberCookie $subscriberCookie,
|
||||
Shortcodes $shortcodes,
|
||||
Opens $opens,
|
||||
StatisticsClicksRepository $statisticsClicksRepository,
|
||||
UserAgentsRepository $userAgentsRepository,
|
||||
LinkShortcodeCategory $linkShortcodeCategory,
|
||||
SubscribersRepository $subscribersRepository,
|
||||
TrackingConfig $trackingConfig
|
||||
) {
|
||||
$this->cookies = $cookies;
|
||||
$this->subscriberCookie = $subscriberCookie;
|
||||
$this->shortcodes = $shortcodes;
|
||||
$this->linkShortcodeCategory = $linkShortcodeCategory;
|
||||
$this->opens = $opens;
|
||||
$this->statisticsClicksRepository = $statisticsClicksRepository;
|
||||
$this->userAgentsRepository = $userAgentsRepository;
|
||||
$this->subscribersRepository = $subscribersRepository;
|
||||
$this->trackingConfig = $trackingConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \stdClass|null $data
|
||||
*/
|
||||
public function track($data) {
|
||||
if (!$data || empty($data->link)) {
|
||||
return $this->abort();
|
||||
}
|
||||
/** @var SubscriberEntity $subscriber */
|
||||
$subscriber = $data->subscriber;
|
||||
/** @var SendingQueueEntity $queue */
|
||||
$queue = $data->queue;
|
||||
/** @var NewsletterEntity $newsletter */
|
||||
$newsletter = $data->newsletter;
|
||||
/** @var NewsletterLinkEntity $link */
|
||||
$link = $data->link;
|
||||
$wpUserPreview = ($data->preview && ($subscriber->isWPUser()));
|
||||
// log statistics only if the action did not come from
|
||||
// a WP user previewing the newsletter
|
||||
if (!$wpUserPreview) {
|
||||
$userAgent = !empty($data->userAgent) ? $this->userAgentsRepository->findOrCreate($data->userAgent) : null;
|
||||
$statisticsClicks = $this->statisticsClicksRepository->createOrUpdateClickCount(
|
||||
$link,
|
||||
$subscriber,
|
||||
$newsletter,
|
||||
$queue,
|
||||
$userAgent
|
||||
);
|
||||
if (
|
||||
$userAgent instanceof UserAgentEntity &&
|
||||
($userAgent->getUserAgentType() === UserAgentEntity::USER_AGENT_TYPE_HUMAN
|
||||
|| $statisticsClicks->getUserAgentType() === UserAgentEntity::USER_AGENT_TYPE_MACHINE)
|
||||
) {
|
||||
$statisticsClicks->setUserAgent($userAgent);
|
||||
$statisticsClicks->setUserAgentType($userAgent->getUserAgentType());
|
||||
}
|
||||
$this->statisticsClicksRepository->flush();
|
||||
$this->sendRevenueCookie($statisticsClicks);
|
||||
|
||||
$subscriberId = $subscriber->getId();
|
||||
if ($subscriberId) {
|
||||
$this->subscriberCookie->setSubscriberId($subscriberId);
|
||||
}
|
||||
|
||||
// track open event
|
||||
$this->opens->track($data, $displayImage = false);
|
||||
// Update engagement date
|
||||
$this->subscribersRepository->maybeUpdateLastClickAt($subscriber);
|
||||
}
|
||||
$url = $this->processUrl($link->getUrl(), $newsletter, $subscriber, $queue, $wpUserPreview);
|
||||
do_action('mailpoet_link_clicked', $link, $subscriber, $wpUserPreview);
|
||||
$this->redirectToUrl($url);
|
||||
}
|
||||
|
||||
private function sendRevenueCookie(StatisticsClickEntity $clicks) {
|
||||
if ($this->trackingConfig->isCookieTrackingEnabled()) {
|
||||
$this->cookies->set(
|
||||
self::REVENUE_TRACKING_COOKIE_NAME,
|
||||
[
|
||||
'statistics_clicks' => $clicks->getId(),
|
||||
'created_at' => time(),
|
||||
],
|
||||
[
|
||||
'expires' => time() + self::REVENUE_TRACKING_COOKIE_EXPIRY,
|
||||
'path' => '/',
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function processUrl(
|
||||
string $url,
|
||||
NewsletterEntity $newsletter,
|
||||
SubscriberEntity $subscriber,
|
||||
SendingQueueEntity $queue,
|
||||
bool $wpUserPreview
|
||||
) {
|
||||
if (preg_match('/\[link:(?P<action>.*?)\]/', $url, $shortcode)) {
|
||||
if (!$shortcode['action']) $this->abort();
|
||||
$url = $this->linkShortcodeCategory->processShortcodeAction(
|
||||
$shortcode['action'],
|
||||
$newsletter,
|
||||
$subscriber,
|
||||
$queue,
|
||||
$wpUserPreview
|
||||
);
|
||||
} else {
|
||||
$this->shortcodes->setQueue($queue);
|
||||
$this->shortcodes->setNewsletter($newsletter);
|
||||
$this->shortcodes->setSubscriber($subscriber);
|
||||
$this->shortcodes->setWpUserPreview($wpUserPreview);
|
||||
$url = $this->shortcodes->replace($url);
|
||||
}
|
||||
return $url;
|
||||
}
|
||||
|
||||
public function abort() {
|
||||
global $wp_query;// phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
|
||||
WPFunctions::get()->statusHeader(404);
|
||||
$wp_query->set_404();// phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
|
||||
WPFunctions::get()->getTemplatePart((string)404);
|
||||
exit;
|
||||
}
|
||||
|
||||
public function redirectToUrl($url) {
|
||||
header('Location: ' . $url, true, 302);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Statistics\Track;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Entities\NewsletterEntity;
|
||||
use MailPoet\Entities\SendingQueueEntity;
|
||||
use MailPoet\Entities\StatisticsOpenEntity;
|
||||
use MailPoet\Entities\SubscriberEntity;
|
||||
use MailPoet\Entities\UserAgentEntity;
|
||||
use MailPoet\Statistics\StatisticsOpensRepository;
|
||||
use MailPoet\Statistics\UserAgentsRepository;
|
||||
use MailPoet\Subscribers\SubscribersRepository;
|
||||
|
||||
class Opens {
|
||||
/** @var StatisticsOpensRepository */
|
||||
private $statisticsOpensRepository;
|
||||
|
||||
/** @var UserAgentsRepository */
|
||||
private $userAgentsRepository;
|
||||
|
||||
/** @var SubscribersRepository */
|
||||
private $subscribersRepository;
|
||||
|
||||
public function __construct(
|
||||
StatisticsOpensRepository $statisticsOpensRepository,
|
||||
UserAgentsRepository $userAgentsRepository,
|
||||
SubscribersRepository $subscribersRepository
|
||||
) {
|
||||
$this->statisticsOpensRepository = $statisticsOpensRepository;
|
||||
$this->userAgentsRepository = $userAgentsRepository;
|
||||
$this->subscribersRepository = $subscribersRepository;
|
||||
}
|
||||
|
||||
public function track($data, $displayImage = true) {
|
||||
if (!$data) {
|
||||
return $this->returnResponse($displayImage);
|
||||
}
|
||||
/** @var SubscriberEntity $subscriber */
|
||||
$subscriber = $data->subscriber;
|
||||
/** @var SendingQueueEntity $queue */
|
||||
$queue = $data->queue;
|
||||
/** @var NewsletterEntity $newsletter */
|
||||
$newsletter = $data->newsletter;
|
||||
$wpUserPreview = ($data->preview && ($subscriber->isWPUser()));
|
||||
// log statistics only if the action did not come from
|
||||
// a WP user previewing the newsletter
|
||||
if (!$wpUserPreview) {
|
||||
$oldStatistics = $this->statisticsOpensRepository->findOneBy([
|
||||
'subscriber' => $subscriber->getId(),
|
||||
'newsletter' => $newsletter->getId(),
|
||||
'queue' => $queue->getId(),
|
||||
]);
|
||||
// Open was already tracked
|
||||
if ($oldStatistics) {
|
||||
if (!empty($data->userAgent)) {
|
||||
$userAgent = $this->userAgentsRepository->findOrCreate($data->userAgent);
|
||||
if (
|
||||
$userAgent->getUserAgentType() === UserAgentEntity::USER_AGENT_TYPE_HUMAN
|
||||
|| $oldStatistics->getUserAgentType() === UserAgentEntity::USER_AGENT_TYPE_MACHINE
|
||||
) {
|
||||
$oldStatistics->setUserAgent($userAgent);
|
||||
$oldStatistics->setUserAgentType($userAgent->getUserAgentType());
|
||||
$this->statisticsOpensRepository->flush();
|
||||
}
|
||||
}
|
||||
$this->subscribersRepository->maybeUpdateLastOpenAt($subscriber);
|
||||
return $this->returnResponse($displayImage);
|
||||
}
|
||||
$statistics = new StatisticsOpenEntity($newsletter, $queue, $subscriber);
|
||||
if (!empty($data->userAgent)) {
|
||||
$userAgent = $this->userAgentsRepository->findOrCreate($data->userAgent);
|
||||
$statistics->setUserAgent($userAgent);
|
||||
$statistics->setUserAgentType($userAgent->getUserAgentType());
|
||||
}
|
||||
$this->statisticsOpensRepository->persist($statistics);
|
||||
$this->statisticsOpensRepository->flush();
|
||||
$this->subscribersRepository->maybeUpdateLastOpenAt($subscriber);
|
||||
$this->statisticsOpensRepository->recalculateSubscriberScore($subscriber);
|
||||
}
|
||||
return $this->returnResponse($displayImage);
|
||||
}
|
||||
|
||||
public function returnResponse($displayImage) {
|
||||
if (!$displayImage) return;
|
||||
// return 1x1 pixel transparent gif image
|
||||
header('Content-Type: image/gif');
|
||||
|
||||
// Output of base64_decode is predetermined and safe in this case
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
echo base64_decode('R0lGODlhAQABAJAAAP8AAAAAACH5BAUQAAAALAAAAAABAAEAAAICBAEAOw==');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Statistics\Track;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Settings\TrackingConfig;
|
||||
use MailPoet\Util\Cookies;
|
||||
|
||||
class PageViewCookie {
|
||||
const COOKIE_NAME = 'mailpoet_page_view';
|
||||
const COOKIE_EXPIRY = 10 * 365 * 24 * 60 * 60; // 10 years (~ no expiry)
|
||||
|
||||
/** @var Cookies */
|
||||
private $cookies;
|
||||
|
||||
/** @var TrackingConfig */
|
||||
private $trackingConfig;
|
||||
|
||||
public function __construct(
|
||||
Cookies $cookies,
|
||||
TrackingConfig $trackingConfig
|
||||
) {
|
||||
$this->cookies = $cookies;
|
||||
$this->trackingConfig = $trackingConfig;
|
||||
}
|
||||
|
||||
public function getPageViewTimestamp(): ?int {
|
||||
if (!$this->trackingConfig->isCookieTrackingEnabled()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->getTimestampCookie(self::COOKIE_NAME);
|
||||
}
|
||||
|
||||
public function setPageViewTimestamp(int $timestamp): void {
|
||||
if (!$this->trackingConfig->isCookieTrackingEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->cookies->set(
|
||||
self::COOKIE_NAME,
|
||||
['timestamp' => $timestamp],
|
||||
[
|
||||
'expires' => time() + self::COOKIE_EXPIRY,
|
||||
'path' => '/',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
private function getTimestampCookie(string $cookieName): ?int {
|
||||
$data = $this->cookies->get($cookieName);
|
||||
return is_array($data) && $data['timestamp']
|
||||
? (int)$data['timestamp']
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Statistics\Track;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Entities\SubscriberEntity;
|
||||
use MailPoet\Settings\TrackingConfig;
|
||||
use MailPoet\Subscribers\SubscribersRepository;
|
||||
use MailPoet\WooCommerce\Helper as WooCommerceHelper;
|
||||
use MailPoet\WP\Functions as WPFunctions;
|
||||
|
||||
class SubscriberActivityTracker {
|
||||
|
||||
const TRACK_INTERVAL = 60; // 1 minute
|
||||
|
||||
/** @var PageViewCookie */
|
||||
private $pageViewCookie;
|
||||
|
||||
/** @var SubscriberCookie */
|
||||
private $subscriberCookie;
|
||||
|
||||
/** @var SubscribersRepository */
|
||||
private $subscribersRepository;
|
||||
|
||||
/** @var WPFunctions */
|
||||
private $wp;
|
||||
|
||||
/** @var TrackingConfig */
|
||||
private $trackingConfig;
|
||||
|
||||
/** @var callable[] */
|
||||
private $callbacks = [];
|
||||
|
||||
/** @var WooCommerceHelper */
|
||||
private $wooCommerceHelper;
|
||||
|
||||
public function __construct(
|
||||
PageViewCookie $pageViewCookie,
|
||||
SubscriberCookie $subscriberCookie,
|
||||
SubscribersRepository $subscribersRepository,
|
||||
WPFunctions $wp,
|
||||
WooCommerceHelper $wooCommerceHelper,
|
||||
TrackingConfig $trackingConfig
|
||||
) {
|
||||
$this->pageViewCookie = $pageViewCookie;
|
||||
$this->subscriberCookie = $subscriberCookie;
|
||||
$this->subscribersRepository = $subscribersRepository;
|
||||
$this->wp = $wp;
|
||||
$this->wooCommerceHelper = $wooCommerceHelper;
|
||||
$this->trackingConfig = $trackingConfig;
|
||||
}
|
||||
|
||||
public function trackActivity(): bool {
|
||||
// Don't track in admin interface
|
||||
if ($this->wp->isAdmin()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$subscriber = null;
|
||||
$latestTimestamp = $this->getLatestTimestampFromCookie();
|
||||
|
||||
// If cookie tracking is not allowed try use last activity from subscriber data
|
||||
if ($latestTimestamp === null) {
|
||||
$subscriber = $this->getSubscriber();
|
||||
if (!$subscriber) {
|
||||
return false; // Can't determine timestamp
|
||||
}
|
||||
$latestTimestamp = $this->getLatestTimestampFromSubscriber($subscriber);
|
||||
}
|
||||
|
||||
if ($latestTimestamp + self::TRACK_INTERVAL > $this->wp->currentTime('timestamp', true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($subscriber === null) {
|
||||
$subscriber = $this->getSubscriber();
|
||||
}
|
||||
|
||||
if (!$subscriber) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->processTracking($subscriber);
|
||||
return true;
|
||||
}
|
||||
|
||||
public function registerCallback(string $slug, callable $callback): void {
|
||||
$this->callbacks[$slug] = $callback;
|
||||
}
|
||||
|
||||
public function unregisterCallback(string $slug): void {
|
||||
unset($this->callbacks[$slug]);
|
||||
}
|
||||
|
||||
private function processTracking(SubscriberEntity $subscriber): void {
|
||||
$this->subscribersRepository->maybeUpdateLastPageViewAt($subscriber);
|
||||
$this->pageViewCookie->setPageViewTimestamp($this->wp->currentTime('timestamp', true));
|
||||
foreach ($this->callbacks as $callback) {
|
||||
$callback($subscriber);
|
||||
}
|
||||
}
|
||||
|
||||
private function getLatestTimestampFromCookie(): ?int {
|
||||
if ($this->trackingConfig->isCookieTrackingEnabled()) {
|
||||
return $this->pageViewCookie->getPageViewTimestamp() ?? 0;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function getLatestTimestampFromSubscriber(SubscriberEntity $subscriber): int {
|
||||
return $subscriber->getLastEngagementAt() ? $subscriber->getLastEngagementAt()->getTimestamp() : 0;
|
||||
}
|
||||
|
||||
private function getSubscriber(): ?SubscriberEntity {
|
||||
$wpUser = $this->wp->wpGetCurrentUser();
|
||||
if ($wpUser->exists()) {
|
||||
return $this->subscribersRepository->findOneBy(['wpUserId' => $wpUser->ID]);
|
||||
}
|
||||
|
||||
$subscriberId = $this->subscriberCookie->getSubscriberId();
|
||||
if ($subscriberId) {
|
||||
return $this->subscribersRepository->findOneById($subscriberId);
|
||||
}
|
||||
|
||||
if (!$this->wooCommerceHelper->isWooCommerceActive()) {
|
||||
return null;
|
||||
}
|
||||
$wooCommerce = $this->wooCommerceHelper->WC();
|
||||
if (!$wooCommerce || !$wooCommerce->session) {
|
||||
return null;
|
||||
}
|
||||
$customer = $wooCommerce->session->get('customer');
|
||||
if (!is_array($customer) || empty($customer['email'])) {
|
||||
return null;
|
||||
}
|
||||
return $this->subscribersRepository->findOneBy(['email' => $customer['email']]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Statistics\Track;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Settings\TrackingConfig;
|
||||
use MailPoet\Util\Cookies;
|
||||
|
||||
class SubscriberCookie {
|
||||
const COOKIE_NAME = 'mailpoet_subscriber';
|
||||
const COOKIE_NAME_LEGACY = 'mailpoet_abandoned_cart_tracking';
|
||||
const COOKIE_EXPIRY = 10 * 365 * 24 * 60 * 60; // 10 years (~ no expiry)
|
||||
|
||||
/** @var Cookies */
|
||||
private $cookies;
|
||||
|
||||
/** @var TrackingConfig */
|
||||
private $trackingConfig;
|
||||
|
||||
public function __construct(
|
||||
Cookies $cookies,
|
||||
TrackingConfig $trackingConfig
|
||||
) {
|
||||
$this->cookies = $cookies;
|
||||
$this->trackingConfig = $trackingConfig;
|
||||
}
|
||||
|
||||
public function getSubscriberId(): ?int {
|
||||
if (!$this->trackingConfig->isCookieTrackingEnabled()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$subscriberId = $this->getSubscriberIdFromCookie(self::COOKIE_NAME);
|
||||
if ($subscriberId) {
|
||||
return $subscriberId;
|
||||
}
|
||||
|
||||
$subscriberId = $this->getSubscriberIdFromCookie(self::COOKIE_NAME_LEGACY);
|
||||
if ($subscriberId) {
|
||||
$this->setSubscriberId($subscriberId);
|
||||
$this->cookies->delete(self::COOKIE_NAME_LEGACY);
|
||||
return $subscriberId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public function setSubscriberId(int $subscriberId): void {
|
||||
if (!$this->trackingConfig->isCookieTrackingEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->cookies->set(
|
||||
self::COOKIE_NAME,
|
||||
['subscriber_id' => $subscriberId],
|
||||
[
|
||||
'expires' => time() + self::COOKIE_EXPIRY,
|
||||
'path' => '/',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
private function getSubscriberIdFromCookie(string $cookieName): ?int {
|
||||
$data = $this->cookies->get($cookieName);
|
||||
return is_array($data) && $data['subscriber_id']
|
||||
? (int)$data['subscriber_id']
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Statistics\Track;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Entities\SubscriberEntity;
|
||||
use MailPoet\Settings\TrackingConfig;
|
||||
use MailPoet\Subscribers\SubscribersRepository;
|
||||
use MailPoet\WP\Functions as WPFunctions;
|
||||
|
||||
class SubscriberHandler {
|
||||
/** @var SubscriberCookie */
|
||||
private $subscriberCookie;
|
||||
|
||||
/** @var SubscribersRepository */
|
||||
private $subscribersRepository;
|
||||
|
||||
/** @var TrackingConfig */
|
||||
private $trackingConfig;
|
||||
|
||||
/** @var WPFunctions */
|
||||
private $wp;
|
||||
|
||||
public function __construct(
|
||||
SubscriberCookie $subscriberCookie,
|
||||
SubscribersRepository $subscribersRepository,
|
||||
TrackingConfig $trackingConfig,
|
||||
WPFunctions $wp
|
||||
) {
|
||||
$this->subscriberCookie = $subscriberCookie;
|
||||
$this->subscribersRepository = $subscribersRepository;
|
||||
$this->trackingConfig = $trackingConfig;
|
||||
$this->wp = $wp;
|
||||
}
|
||||
|
||||
public function identifyByLogin(?string $login): void {
|
||||
if (is_null($login)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->trackingConfig->isCookieTrackingEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$wpUser = $this->wp->getUserBy('login', $login);
|
||||
if ($wpUser) {
|
||||
$this->identifyByEmail($wpUser->user_email); // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
|
||||
}
|
||||
}
|
||||
|
||||
public function identifyByEmail(string $email): void {
|
||||
if (!$this->trackingConfig->isCookieTrackingEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$subscriber = $this->subscribersRepository->findOneBy(['email' => $email]);
|
||||
if ($subscriber) {
|
||||
$this->setCookieBySubscriber($subscriber);
|
||||
}
|
||||
}
|
||||
|
||||
private function setCookieBySubscriber(SubscriberEntity $subscriber): void {
|
||||
$subscriberId = $subscriber->getId();
|
||||
if ($subscriberId) {
|
||||
$this->subscriberCookie->setSubscriberId($subscriberId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Statistics\Track;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Entities\NewsletterEntity;
|
||||
use MailPoet\Entities\SendingQueueEntity;
|
||||
use MailPoet\Entities\StatisticsUnsubscribeEntity;
|
||||
use MailPoet\Entities\SubscriberEntity;
|
||||
use MailPoet\Newsletter\Sending\SendingQueuesRepository;
|
||||
use MailPoet\Statistics\StatisticsUnsubscribesRepository;
|
||||
use MailPoet\Subscribers\SubscribersRepository;
|
||||
|
||||
class Unsubscribes {
|
||||
/** @var SendingQueuesRepository */
|
||||
private $sendingQueuesRepository;
|
||||
|
||||
/** @var StatisticsUnsubscribesRepository */
|
||||
private $statisticsUnsubscribesRepository;
|
||||
|
||||
/**
|
||||
* @var SubscribersRepository
|
||||
*/
|
||||
private $subscribersRepository;
|
||||
|
||||
public function __construct(
|
||||
SendingQueuesRepository $sendingQueuesRepository,
|
||||
StatisticsUnsubscribesRepository $statisticsUnsubscribesRepository,
|
||||
SubscribersRepository $subscribersRepository
|
||||
) {
|
||||
$this->sendingQueuesRepository = $sendingQueuesRepository;
|
||||
$this->statisticsUnsubscribesRepository = $statisticsUnsubscribesRepository;
|
||||
$this->subscribersRepository = $subscribersRepository;
|
||||
}
|
||||
|
||||
public function track(
|
||||
int $subscriberId,
|
||||
string $source,
|
||||
int $queueId = null,
|
||||
string $meta = null,
|
||||
string $method = StatisticsUnsubscribeEntity::METHOD_UNKNOWN
|
||||
) {
|
||||
$queue = null;
|
||||
$statistics = null;
|
||||
if ($queueId) {
|
||||
$queue = $this->sendingQueuesRepository->findOneById($queueId);
|
||||
}
|
||||
$subscriber = $this->subscribersRepository->findOneById($subscriberId);
|
||||
if (!$subscriber instanceof SubscriberEntity) {
|
||||
return;
|
||||
}
|
||||
if (($queue instanceof SendingQueueEntity)) {
|
||||
$newsletter = $queue->getNewsletter();
|
||||
if ($newsletter instanceof NewsletterEntity) {
|
||||
$statistics = $this->statisticsUnsubscribesRepository->findOneBy(
|
||||
[
|
||||
'queue' => $queue,
|
||||
'newsletter' => $newsletter,
|
||||
'subscriber' => $subscriber,
|
||||
]
|
||||
);
|
||||
if (!$statistics) {
|
||||
$statistics = new StatisticsUnsubscribeEntity($newsletter, $queue, $subscriber);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($statistics === null) {
|
||||
$statistics = new StatisticsUnsubscribeEntity(null, null, $subscriber);
|
||||
}
|
||||
if ($meta !== null) {
|
||||
$statistics->setMeta($meta);
|
||||
}
|
||||
$statistics->setSource($source);
|
||||
$statistics->setMethod($method);
|
||||
$this->statisticsUnsubscribesRepository->persist($statistics);
|
||||
$this->statisticsUnsubscribesRepository->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Statistics\Track;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Entities\NewsletterEntity;
|
||||
use MailPoet\Entities\StatisticsClickEntity;
|
||||
use MailPoet\Entities\SubscriberEntity;
|
||||
use MailPoet\Statistics\StatisticsClicksRepository;
|
||||
use MailPoet\Statistics\StatisticsWooCommercePurchasesRepository;
|
||||
use MailPoet\Subscribers\SubscribersRepository;
|
||||
use MailPoet\Util\Cookies;
|
||||
use MailPoet\WooCommerce\Helper;
|
||||
use WC_Order;
|
||||
|
||||
class WooCommercePurchases {
|
||||
const USE_CLICKS_SINCE_DAYS_AGO = 14;
|
||||
|
||||
/** @var Helper */
|
||||
private $woocommerceHelper;
|
||||
|
||||
/** @var Cookies */
|
||||
private $cookies;
|
||||
|
||||
/** @var StatisticsWooCommercePurchasesRepository */
|
||||
private $statisticsWooCommercePurchasesRepository;
|
||||
|
||||
/** @var StatisticsClicksRepository */
|
||||
private $statisticsClicksRepository;
|
||||
|
||||
/** @var SubscribersRepository */
|
||||
private $subscribersRepository;
|
||||
|
||||
/** @var SubscriberHandler */
|
||||
private $subscriberHandler;
|
||||
|
||||
public function __construct(
|
||||
Helper $woocommerceHelper,
|
||||
StatisticsWooCommercePurchasesRepository $statisticsWooCommercePurchasesRepository,
|
||||
StatisticsClicksRepository $statisticsClicksRepository,
|
||||
SubscribersRepository $subscribersRepository,
|
||||
Cookies $cookies,
|
||||
SubscriberHandler $subscriberHandler
|
||||
) {
|
||||
$this->woocommerceHelper = $woocommerceHelper;
|
||||
$this->cookies = $cookies;
|
||||
$this->statisticsWooCommercePurchasesRepository = $statisticsWooCommercePurchasesRepository;
|
||||
$this->statisticsClicksRepository = $statisticsClicksRepository;
|
||||
$this->subscribersRepository = $subscribersRepository;
|
||||
$this->subscriberHandler = $subscriberHandler;
|
||||
}
|
||||
|
||||
public function trackPurchase($id, $useCookies = true) {
|
||||
|
||||
$order = $this->woocommerceHelper->wcGetOrder($id);
|
||||
if (!$order instanceof WC_Order || $this->trackExistingStatistics($order)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
$from = $this->getFromDate($order);
|
||||
$to = $order->get_date_created();
|
||||
if (is_null($to) || is_null($from)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// track purchases from all clicks matched by order email
|
||||
$processedNewsletterIdsMap = [];
|
||||
$orderEmailClicks = $this->getClicks($order->get_billing_email(), $from, $to);
|
||||
foreach ($orderEmailClicks as $click) {
|
||||
$this->statisticsWooCommercePurchasesRepository->createOrUpdateByClickDataAndOrder($click, $order);
|
||||
$newsletter = $click->getNewsletter();
|
||||
if (!$newsletter instanceof NewsletterEntity) continue;
|
||||
$processedNewsletterIdsMap[$newsletter->getId()] = true;
|
||||
}
|
||||
|
||||
// try to find a subscriber by order email and start tracking
|
||||
$this->subscriberHandler->identifyByEmail($order->get_billing_email());
|
||||
|
||||
if (!$useCookies) {
|
||||
return;
|
||||
}
|
||||
|
||||
// track purchases from clicks matched by cookie email (only for newsletters not tracked by order)
|
||||
$cookieEmailClicks = $this->getClicks($this->getSubscriberEmailFromCookie(), $from, $to);
|
||||
foreach ($cookieEmailClicks as $click) {
|
||||
$newsletter = $click->getNewsletter();
|
||||
if (!$newsletter instanceof NewsletterEntity) continue;
|
||||
if (isset($processedNewsletterIdsMap[$newsletter->getId()])) {
|
||||
continue; // do not track click for newsletters that were already tracked by order email
|
||||
}
|
||||
$this->statisticsWooCommercePurchasesRepository->createOrUpdateByClickDataAndOrder($click, $order);
|
||||
}
|
||||
}
|
||||
|
||||
public function trackRefund($id) {
|
||||
$order = $this->woocommerceHelper->wcGetOrder($id);
|
||||
if (!$order instanceof WC_Order) {
|
||||
return;
|
||||
}
|
||||
$this->trackExistingStatistics($order);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when valid purchase statistics for an order were found.
|
||||
*
|
||||
* @param WC_Order $order
|
||||
* @return bool
|
||||
*/
|
||||
private function trackExistingStatistics(\WC_Order $order): bool {
|
||||
$statistics = $this->statisticsWooCommercePurchasesRepository->findBy(['orderId' => $order->get_id()]);
|
||||
if ($statistics) {
|
||||
foreach ($statistics as $statistic) {
|
||||
if (!$statistic->getClick()) {
|
||||
continue;
|
||||
}
|
||||
$this->statisticsWooCommercePurchasesRepository->createOrUpdateByClickDataAndOrder(
|
||||
$statistic->getClick(),
|
||||
$order
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Limit clicks to 'USE_CLICKS_SINCE_DAYS_AGO' range before order has been created.
|
||||
*
|
||||
* @param WC_Order $order
|
||||
* @return \WC_DateTime|null
|
||||
*/
|
||||
private function getFromDate(\WC_Order $order) {
|
||||
$fromDate = $order->get_date_created();
|
||||
if (is_null($fromDate)) {
|
||||
return null;
|
||||
}
|
||||
$from = clone $fromDate;
|
||||
$from->modify(-self::USE_CLICKS_SINCE_DAYS_AGO . ' days');
|
||||
return $from;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ?string $email
|
||||
* @param \DateTimeInterface $from
|
||||
* @param \DateTimeInterface $to
|
||||
* @return StatisticsClickEntity[]
|
||||
*/
|
||||
private function getClicks(?string $email, \DateTimeInterface $from, \DateTimeInterface $to): array {
|
||||
if (!$email) return [];
|
||||
$subscriber = $this->subscribersRepository->findOneBy(['email' => $email]);
|
||||
if (!$subscriber instanceof SubscriberEntity) {
|
||||
return [];
|
||||
}
|
||||
return $this->statisticsClicksRepository->findLatestPerNewsletterBySubscriber($subscriber, $from, $to);
|
||||
}
|
||||
|
||||
private function getSubscriberEmailFromCookie(): ?string {
|
||||
$cookieData = $this->cookies->get(Clicks::REVENUE_TRACKING_COOKIE_NAME);
|
||||
if (!$cookieData) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$click = $this->statisticsClicksRepository->findOneById($cookieData['statistics_clicks']);
|
||||
} catch (\Exception $e) {
|
||||
return null;
|
||||
}
|
||||
if (!$click instanceof StatisticsClickEntity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$subscriber = $click->getSubscriber();
|
||||
if ($subscriber instanceof SubscriberEntity) {
|
||||
return $subscriber->getEmail();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<?php
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Statistics;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Doctrine\Repository;
|
||||
use MailPoet\Entities\UserAgentEntity;
|
||||
|
||||
/**
|
||||
* @extends Repository<UserAgentEntity>
|
||||
*/
|
||||
class UserAgentsRepository extends Repository {
|
||||
protected function getEntityClassName() {
|
||||
return UserAgentEntity::class;
|
||||
}
|
||||
|
||||
public function findOrCreate(string $userAgent): UserAgentEntity {
|
||||
$hash = (string)crc32($userAgent);
|
||||
$userAgentEntity = $this->findOneBy(['hash' => $hash]);
|
||||
return $userAgentEntity ?? $this->create($userAgent);
|
||||
}
|
||||
|
||||
public function create(string $userAgent): UserAgentEntity {
|
||||
$userAgentEntity = new UserAgentEntity($userAgent);
|
||||
|
||||
$this->entityManager->getConnection()->executeStatement(
|
||||
'INSERT INTO ' . $this->getTableName() . ' (user_agent, hash) VALUES (:user_agent, :hash) ON DUPLICATE KEY UPDATE id = id',
|
||||
[
|
||||
'user_agent' => $userAgentEntity->getUserAgent(),
|
||||
'hash' => $userAgentEntity->getHash(),
|
||||
]
|
||||
);
|
||||
|
||||
/** @var UserAgentEntity $userAgentEntity */
|
||||
$userAgentEntity = $this->findOneBy(['hash' => $userAgentEntity->getHash()]);
|
||||
return $userAgentEntity;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<?php
|
||||
Reference in New Issue
Block a user