init
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Subscribers;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Config\Env;
|
||||
use MailPoet\Entities\NewsletterEntity;
|
||||
use MailPoet\Newsletter\NewslettersRepository;
|
||||
use MailPoet\Newsletter\Renderer\Renderer as NewsletterRenderer;
|
||||
use MailPoet\Settings\SettingsController;
|
||||
use MailPoet\Util\Security;
|
||||
|
||||
class ConfirmationEmailCustomizer {
|
||||
const SETTING_EMAIL_ID = 'signup_confirmation.transactional_email_id';
|
||||
const SETTING_ENABLE_EMAIL_CUSTOMIZER = 'signup_confirmation.use_mailpoet_editor';
|
||||
|
||||
/** @var SettingsController */
|
||||
private $settings;
|
||||
|
||||
/** @var NewslettersRepository */
|
||||
private $newslettersRepository;
|
||||
|
||||
/** @var NewsletterRenderer */
|
||||
private $renderer;
|
||||
|
||||
public function __construct(
|
||||
SettingsController $settings,
|
||||
NewslettersRepository $newslettersRepository,
|
||||
NewsletterRenderer $renderer
|
||||
) {
|
||||
$this->settings = $settings;
|
||||
$this->newslettersRepository = $newslettersRepository;
|
||||
$this->renderer = $renderer;
|
||||
}
|
||||
|
||||
public function init() {
|
||||
$savedEmailId = (bool)$this->settings->get(self::SETTING_EMAIL_ID, false);
|
||||
if (!$savedEmailId) {
|
||||
$email = $this->createNewsletter();
|
||||
if (is_null($email)) return;
|
||||
|
||||
$this->settings->set(self::SETTING_EMAIL_ID, $email->getId());
|
||||
}
|
||||
}
|
||||
|
||||
private function createNewsletter(): ?NewsletterEntity {
|
||||
$emailTemplate = $this->fetchEmailTemplate();
|
||||
|
||||
if (empty($emailTemplate)) {
|
||||
// if it's not able to fetch email template, don't bother creating newsletter
|
||||
return null;
|
||||
}
|
||||
|
||||
$newsletter = new NewsletterEntity;
|
||||
$newsletter->setType(NewsletterEntity::TYPE_CONFIRMATION_EMAIL_CUSTOMIZER);
|
||||
$newsletter->setSubject($this->settings->get('signup_confirmation.subject', 'Confirm your subscription to [site:title]'));
|
||||
$newsletter->setBody($emailTemplate);
|
||||
$newsletter->setHash(Security::generateHash());
|
||||
$this->newslettersRepository->persist($newsletter);
|
||||
$this->newslettersRepository->flush();
|
||||
return $newsletter;
|
||||
}
|
||||
|
||||
private function fetchEmailTemplate() {
|
||||
$templateUrl = Env::$libPath . '/Subscribers/ConfirmationEmailTemplate/template-confirmation.json';
|
||||
$templateString = file_get_contents($templateUrl);
|
||||
$templateArr = json_decode((string)$templateString, true);
|
||||
$template = (array)$templateArr;
|
||||
return $template['body'];
|
||||
}
|
||||
|
||||
public function getNewsletter(): NewsletterEntity {
|
||||
$savedEmailId = $this->settings->get(self::SETTING_EMAIL_ID, false);
|
||||
|
||||
if (empty($savedEmailId)) {
|
||||
$this->init();
|
||||
$savedEmailId = $this->settings->get(self::SETTING_EMAIL_ID);
|
||||
}
|
||||
|
||||
$newsletter = $this->newslettersRepository->findOneById($savedEmailId);
|
||||
if (!$newsletter) {
|
||||
// the newsletter should always be present in the database, if it s not we shouldn't keep using this feature
|
||||
// we need to recreate the newsletter
|
||||
$this->settings->set(self::SETTING_EMAIL_ID, false); // reset
|
||||
$this->init();
|
||||
return $this->getNewsletter();
|
||||
}
|
||||
return $newsletter;
|
||||
}
|
||||
|
||||
public function render(NewsletterEntity $newsletter): ?array {
|
||||
$renderedContent = $this->renderer->renderAsPreview($newsletter);
|
||||
|
||||
if (empty($renderedContent)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$renderedContent['subject'] = $newsletter->getSubject();
|
||||
|
||||
return $renderedContent;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Subscribers;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Cron\Workers\SendingQueue\Tasks\Shortcodes;
|
||||
use MailPoet\Entities\SegmentEntity;
|
||||
use MailPoet\Entities\SubscriberEntity;
|
||||
use MailPoet\Mailer\MailerError;
|
||||
use MailPoet\Mailer\MailerFactory;
|
||||
use MailPoet\Mailer\MailerLog;
|
||||
use MailPoet\Mailer\MetaInfo;
|
||||
use MailPoet\Services\AuthorizedEmailsController;
|
||||
use MailPoet\Services\Bridge;
|
||||
use MailPoet\Settings\SettingsController;
|
||||
use MailPoet\Subscription\SubscriptionUrlFactory;
|
||||
use MailPoet\Util\Helpers;
|
||||
use MailPoet\WP\Functions as WPFunctions;
|
||||
use MailPoetVendor\Html2Text\Html2Text;
|
||||
|
||||
class ConfirmationEmailMailer {
|
||||
|
||||
const MAX_CONFIRMATION_EMAILS = 3;
|
||||
|
||||
/** @var MailerFactory */
|
||||
private $mailerFactory;
|
||||
|
||||
/** @var WPFunctions */
|
||||
private $wp;
|
||||
|
||||
/** @var SettingsController */
|
||||
private $settings;
|
||||
|
||||
/** @var MetaInfo */
|
||||
private $mailerMetaInfo;
|
||||
|
||||
/** @var SubscribersRepository */
|
||||
private $subscribersRepository;
|
||||
|
||||
/** @var SubscriptionUrlFactory */
|
||||
private $subscriptionUrlFactory;
|
||||
|
||||
/** @var ConfirmationEmailCustomizer */
|
||||
private $confirmationEmailCustomizer;
|
||||
|
||||
/** @var array Cache for confirmation emails sent within a request */
|
||||
private $sentEmails = [];
|
||||
|
||||
public function __construct(
|
||||
MailerFactory $mailerFactory,
|
||||
WPFunctions $wp,
|
||||
SettingsController $settings,
|
||||
SubscribersRepository $subscribersRepository,
|
||||
SubscriptionUrlFactory $subscriptionUrlFactory,
|
||||
ConfirmationEmailCustomizer $confirmationEmailCustomizer
|
||||
) {
|
||||
$this->mailerFactory = $mailerFactory;
|
||||
$this->wp = $wp;
|
||||
$this->settings = $settings;
|
||||
$this->mailerMetaInfo = new MetaInfo;
|
||||
$this->subscriptionUrlFactory = $subscriptionUrlFactory;
|
||||
$this->subscribersRepository = $subscribersRepository;
|
||||
$this->confirmationEmailCustomizer = $confirmationEmailCustomizer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use this method if you want to make sure the confirmation email
|
||||
* is not sent multiple times within a single request
|
||||
* e.g. if sending confirmation emails from hooks
|
||||
* @throws \Exception if unable to send the email.
|
||||
*/
|
||||
public function sendConfirmationEmailOnce(SubscriberEntity $subscriber): bool {
|
||||
if (isset($this->sentEmails[$subscriber->getId()])) {
|
||||
return true;
|
||||
}
|
||||
return $this->sendConfirmationEmail($subscriber);
|
||||
}
|
||||
|
||||
public function clearSentEmailsCache(): void {
|
||||
$this->sentEmails = [];
|
||||
}
|
||||
|
||||
public function buildEmailData(string $subject, string $html, string $text): array {
|
||||
return [
|
||||
'subject' => $subject,
|
||||
'body' => [
|
||||
'html' => $html,
|
||||
'text' => $text,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function getMailBody(array $signupConfirmation, SubscriberEntity $subscriber, array $segmentNames): array {
|
||||
$body = nl2br($signupConfirmation['body']);
|
||||
|
||||
// replace list of segments shortcode
|
||||
$body = str_replace(
|
||||
'[lists_to_confirm]',
|
||||
'<strong>' . join(', ', $segmentNames) . '</strong>',
|
||||
$body
|
||||
);
|
||||
|
||||
// replace activation link
|
||||
$body = Helpers::replaceLinkTags(
|
||||
$body,
|
||||
$this->subscriptionUrlFactory->getConfirmationUrl($subscriber),
|
||||
['target' => '_blank'],
|
||||
'activation_link'
|
||||
);
|
||||
|
||||
$subject = Shortcodes::process($signupConfirmation['subject'], null, null, $subscriber, null);
|
||||
|
||||
$body = Shortcodes::process($body, null, null, $subscriber, null);
|
||||
|
||||
//create a text version. @ is important here, Html2Text throws warnings
|
||||
$text = @Html2Text::convert(
|
||||
(mb_detect_encoding($body, 'UTF-8', true)) ? $body : mb_convert_encoding($body, 'UTF-8', mb_list_encodings()),
|
||||
true
|
||||
);
|
||||
|
||||
return $this->buildEmailData($subject, $body, $text);
|
||||
}
|
||||
|
||||
public function getMailBodyWithCustomizer(SubscriberEntity $subscriber, array $segmentNames): array {
|
||||
$newsletter = $this->confirmationEmailCustomizer->getNewsletter();
|
||||
|
||||
$renderedNewsletter = $this->confirmationEmailCustomizer->render($newsletter);
|
||||
|
||||
$stringBody = Helpers::joinObject($renderedNewsletter);
|
||||
|
||||
// replace list of segments shortcode
|
||||
$body = (string)str_replace(
|
||||
'[lists_to_confirm]',
|
||||
join(', ', $segmentNames),
|
||||
$stringBody
|
||||
);
|
||||
|
||||
// replace activation link
|
||||
$body = (string)str_replace(
|
||||
[
|
||||
'http://[activation_link]', // See MAILPOET-5253
|
||||
'[activation_link]',
|
||||
],
|
||||
$this->subscriptionUrlFactory->getConfirmationUrl($subscriber),
|
||||
$body
|
||||
);
|
||||
|
||||
[
|
||||
$html,
|
||||
$text,
|
||||
$subject,
|
||||
] = Helpers::splitObject(Shortcodes::process($body, null, $newsletter, $subscriber, null));
|
||||
|
||||
return $this->buildEmailData($subject, $html, $text);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Exception if unable to send the email.
|
||||
*/
|
||||
public function sendConfirmationEmail(SubscriberEntity $subscriber) {
|
||||
$signupConfirmation = $this->settings->get('signup_confirmation');
|
||||
if ((bool)$signupConfirmation['enabled'] === false) {
|
||||
return false;
|
||||
}
|
||||
if (!$this->wp->isUserLoggedIn() && $subscriber->getConfirmationsCount() >= self::MAX_CONFIRMATION_EMAILS) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$authorizationEmailsValidation = $this->settings->get(AuthorizedEmailsController::AUTHORIZED_EMAIL_ADDRESSES_ERROR_SETTING);
|
||||
$unauthorizedSenderEmail = isset($authorizationEmailsValidation['invalid_sender_address']);
|
||||
if (Bridge::isMPSendingServiceEnabled() && $unauthorizedSenderEmail) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$segments = $subscriber->getSegments()->toArray();
|
||||
$segmentNames = array_map(function(SegmentEntity $segment) {
|
||||
return $segment->getName();
|
||||
}, $segments);
|
||||
|
||||
$IsConfirmationEmailCustomizerEnabled = (bool)$this->settings->get(ConfirmationEmailCustomizer::SETTING_ENABLE_EMAIL_CUSTOMIZER, false);
|
||||
|
||||
$email = $IsConfirmationEmailCustomizerEnabled ?
|
||||
$this->getMailBodyWithCustomizer($subscriber, $segmentNames) :
|
||||
$this->getMailBody($signupConfirmation, $subscriber, $segmentNames);
|
||||
|
||||
// send email
|
||||
$extraParams = [
|
||||
'meta' => $this->mailerMetaInfo->getConfirmationMetaInfo($subscriber),
|
||||
];
|
||||
|
||||
// Don't attempt to send confirmation email when sending is paused
|
||||
$confirmationEmailErrorMessage = __('There was an error when sending a confirmation email for your subscription. Please contact the website owner.', 'mailpoet');
|
||||
if (MailerLog::isSendingPaused()) {
|
||||
throw new \Exception($confirmationEmailErrorMessage);
|
||||
}
|
||||
|
||||
try {
|
||||
$defaultMailer = $this->mailerFactory->getDefaultMailer();
|
||||
$result = $defaultMailer->send($email, $subscriber, $extraParams);
|
||||
} catch (\Exception $e) {
|
||||
MailerLog::processTransactionalEmailError(MailerError::OPERATION_CONNECT, $e->getMessage(), $e->getCode());
|
||||
throw new \Exception($confirmationEmailErrorMessage);
|
||||
}
|
||||
|
||||
if ($result['response'] === false) {
|
||||
if ($result['error'] instanceof MailerError && $result['error']->getLevel() === MailerError::LEVEL_HARD) {
|
||||
MailerLog::processTransactionalEmailError($result['error']->getOperation(), (string)$result['error']->getMessage());
|
||||
}
|
||||
throw new \Exception($confirmationEmailErrorMessage);
|
||||
};
|
||||
|
||||
// E-mail was successfully sent we need to update the MailerLog
|
||||
MailerLog::incrementSentCount();
|
||||
|
||||
if (!$this->wp->isUserLoggedIn()) {
|
||||
$subscriber->setConfirmationsCount($subscriber->getConfirmationsCount() + 1);
|
||||
$this->subscribersRepository->persist($subscriber);
|
||||
$this->subscribersRepository->flush();
|
||||
}
|
||||
$this->sentEmails[$subscriber->getId()] = true;
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<?php
|
||||
+499
File diff suppressed because one or more lines are too long
@@ -0,0 +1,209 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Subscribers;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Entities\StatisticsClickEntity;
|
||||
use MailPoet\Entities\StatisticsNewsletterEntity;
|
||||
use MailPoet\Entities\StatisticsOpenEntity;
|
||||
use MailPoet\Entities\SubscriberEntity;
|
||||
use MailPoet\Segments\DynamicSegments\Filters\FilterHelper;
|
||||
use MailPoet\Segments\DynamicSegments\Filters\WooFilterHelper;
|
||||
use MailPoet\WooCommerce\Helper;
|
||||
use MailPoetVendor\Carbon\Carbon;
|
||||
use MailPoetVendor\Doctrine\DBAL\ArrayParameterType;
|
||||
use MailPoetVendor\Doctrine\DBAL\Result;
|
||||
use MailPoetVendor\Doctrine\ORM\EntityManager;
|
||||
|
||||
class EngagementDataBackfiller {
|
||||
/** @var EntityManager */
|
||||
private $entityManager;
|
||||
|
||||
/** @var Helper */
|
||||
private $wcHelper;
|
||||
|
||||
/** @var WooFilterHelper */
|
||||
private $wooFilterHelper;
|
||||
|
||||
/** @var FilterHelper */
|
||||
private $filterHelper;
|
||||
|
||||
/** @var int */
|
||||
private $lastProcessedSubscriberId = 0;
|
||||
|
||||
public function __construct(
|
||||
EntityManager $entityManager,
|
||||
WooFilterHelper $wooFilterHelper,
|
||||
FilterHelper $filterHelper,
|
||||
Helper $wcHelper
|
||||
) {
|
||||
$this->entityManager = $entityManager;
|
||||
$this->wcHelper = $wcHelper;
|
||||
$this->wooFilterHelper = $wooFilterHelper;
|
||||
$this->filterHelper = $filterHelper;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return SubscriberEntity[]
|
||||
*/
|
||||
public function getBatch(int $lastProcessedId = 0, int $batchSize = 100): array {
|
||||
$subscribers = $this->entityManager->createQueryBuilder()
|
||||
->select('PARTIAL s.{id, email, lastPurchaseAt, lastClickAt, lastOpenAt, lastSendingAt}')
|
||||
->from(SubscriberEntity::class, 's')
|
||||
->where('s.id > :lastProcessedId')
|
||||
->orderBy('s.id', 'ASC')
|
||||
->setMaxResults($batchSize)
|
||||
->setParameter('lastProcessedId', $lastProcessedId)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
if (!is_array($subscribers)) {
|
||||
return [];
|
||||
}
|
||||
return $subscribers;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param SubscriberEntity[] $subscribers
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function updateBatch(array $subscribers): void {
|
||||
$subscriberIds = array_map(function (SubscriberEntity $subscriber) {
|
||||
return $subscriber->getId();
|
||||
}, $subscribers);
|
||||
|
||||
$clickData = $this->getClickDataForBatch($subscriberIds);
|
||||
$openData = $this->getOpenDataForBatch($subscriberIds);
|
||||
$sendingData = $this->getSendingDataForBatch($subscriberIds);
|
||||
$purchaseData = $this->getPurchaseDataForBatch($subscriberIds);
|
||||
|
||||
foreach ($subscribers as $subscriber) {
|
||||
if ($subscriber->getLastPurchaseAt() === null && isset($purchaseData[$subscriber->getId()]['last_purchase_at'])) {
|
||||
$purchaseDate = new Carbon($purchaseData[$subscriber->getId()]['last_purchase_at']);
|
||||
$subscriber->setLastPurchaseAt($purchaseDate);
|
||||
}
|
||||
if ($subscriber->getLastOpenAt() === null && isset($openData[$subscriber->getId()]['last_open_at'])) {
|
||||
$openDate = new Carbon($openData[$subscriber->getId()]['last_open_at']);
|
||||
$subscriber->setLastOpenAt($openDate);
|
||||
}
|
||||
if ($subscriber->getLastClickAt() === null && isset($clickData[$subscriber->getId()]['last_click_at'])) {
|
||||
$clickDate = new Carbon($clickData[$subscriber->getId()]['last_click_at']);
|
||||
$subscriber->setLastClickAt($clickDate);
|
||||
}
|
||||
if ($subscriber->getLastSendingAt() === null && isset($sendingData[$subscriber->getId()]['last_sending_at'])) {
|
||||
$sendingDate = new Carbon($sendingData[$subscriber->getId()]['last_sending_at']);
|
||||
$subscriber->setLastSendingAt($sendingDate);
|
||||
}
|
||||
if (is_int($subscriber->getId())) {
|
||||
$this->lastProcessedSubscriberId = $subscriber->getId();
|
||||
}
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
public function getClickDataForBatch(array $subscriberIds): array {
|
||||
$subscribersTable = $this->filterHelper->getSubscribersTable();
|
||||
$clicksTable = $this->filterHelper->getTableForEntity(StatisticsClickEntity::class);
|
||||
|
||||
$query = $this
|
||||
->entityManager
|
||||
->getConnection()
|
||||
->createQueryBuilder()
|
||||
->select("$subscribersTable.id, MAX(clicks.created_at) as last_click_at")
|
||||
->from($subscribersTable)
|
||||
->innerJoin($subscribersTable, $clicksTable, 'clicks', "$subscribersTable.id = clicks.subscriber_id")
|
||||
->andWhere("$subscribersTable.id IN (:subscriberIds)")
|
||||
->setParameter('subscriberIds', $subscriberIds, ArrayParameterType::INTEGER)
|
||||
->groupBy("$subscribersTable.id");
|
||||
|
||||
$result = $query->execute();
|
||||
if ($result instanceof Result) {
|
||||
return $result->fetchAllAssociativeIndexed();
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
public function getPurchaseDataForBatch(array $subscriberIds): array {
|
||||
if (!$this->wcHelper->isWooCommerceActive()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$subscribersTable = $this->filterHelper->getSubscribersTable();
|
||||
|
||||
$query = $this
|
||||
->entityManager
|
||||
->getConnection()
|
||||
->createQueryBuilder()
|
||||
// The orderStats alias comes from wooFilterHelper->applyOrderStatusFilter, which calls wooFilterHelper->applyCustomerOrderJoin
|
||||
->select("$subscribersTable.id, MAX(orderStats.date_created) as last_purchase_at")
|
||||
->from($subscribersTable)
|
||||
->andWhere("$subscribersTable.id IN (:subscriberIds)")
|
||||
->setParameter('subscriberIds', $subscriberIds, ArrayParameterType::INTEGER);
|
||||
$this->wooFilterHelper->applyOrderStatusFilter($query);
|
||||
$query->groupBy("$subscribersTable.id");
|
||||
|
||||
$result = $query->execute();
|
||||
if ($result instanceof Result) {
|
||||
return $result->fetchAllAssociativeIndexed();
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
public function getOpenDataForBatch(array $subscriberIds): array {
|
||||
$subscribersTable = $this->filterHelper->getSubscribersTable();
|
||||
$opensTable = $this->filterHelper->getTableForEntity(StatisticsOpenEntity::class);
|
||||
|
||||
$query = $this
|
||||
->entityManager
|
||||
->getConnection()
|
||||
->createQueryBuilder()
|
||||
->select("$subscribersTable.id, MAX(opens.created_at) as last_open_at")
|
||||
->from($subscribersTable)
|
||||
->innerJoin($subscribersTable, $opensTable, 'opens', "$subscribersTable.id = opens.subscriber_id")
|
||||
->andWhere("$subscribersTable.id IN (:subscriberIds)")
|
||||
->setParameter('subscriberIds', $subscriberIds, ArrayParameterType::INTEGER)
|
||||
->groupBy("$subscribersTable.id");
|
||||
|
||||
$result = $query->execute();
|
||||
if ($result instanceof Result) {
|
||||
return $result->fetchAllAssociativeIndexed();
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
public function getSendingDataForBatch(array $subscriberIds): array {
|
||||
$subscribersTable = $this->filterHelper->getSubscribersTable();
|
||||
$sendsTable = $this->filterHelper->getTableForEntity(StatisticsNewsletterEntity::class);
|
||||
|
||||
$query = $this
|
||||
->entityManager
|
||||
->getConnection()
|
||||
->createQueryBuilder()
|
||||
->select("$subscribersTable.id, MAX(sends.sent_at) as last_sending_at")
|
||||
->from($subscribersTable)
|
||||
->innerJoin($subscribersTable, $sendsTable, 'sends', "$subscribersTable.id = sends.subscriber_id")
|
||||
->andWhere("$subscribersTable.id IN (:subscriberIds)")
|
||||
->setParameter('subscriberIds', $subscriberIds, ArrayParameterType::INTEGER)
|
||||
->groupBy("$subscribersTable.id");
|
||||
|
||||
$result = $query->execute();
|
||||
if ($result instanceof Result) {
|
||||
return $result->fetchAllAssociativeIndexed();
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int
|
||||
*/
|
||||
public function getLastProcessedSubscriberId(): int {
|
||||
return $this->lastProcessedSubscriberId;
|
||||
}
|
||||
|
||||
public function setLastProcessedSubscriberId(int $id): void {
|
||||
$this->lastProcessedSubscriberId = $id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Subscribers\ImportExport\Export;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Config\Env;
|
||||
use MailPoet\CustomFields\CustomFieldsRepository;
|
||||
use MailPoet\Entities\SegmentEntity;
|
||||
use MailPoet\Segments\SegmentsRepository;
|
||||
use MailPoet\Subscribers\ImportExport\ImportExportFactory;
|
||||
use MailPoet\Subscribers\ImportExport\ImportExportRepository;
|
||||
use MailPoet\Util\Security;
|
||||
use MailPoetVendor\XLSXWriter;
|
||||
|
||||
class Export {
|
||||
const SUBSCRIBER_BATCH_SIZE = 15000;
|
||||
|
||||
public $exportFormatOption;
|
||||
public $subscriberFields;
|
||||
public $subscriberCustomFields;
|
||||
public $formattedSubscriberFields;
|
||||
public $formattedSubscriberFieldsWithList;
|
||||
public $exportPath;
|
||||
public $exportFile;
|
||||
public $exportFileURL;
|
||||
|
||||
/** @var int */
|
||||
private $subscribersOffset;
|
||||
|
||||
/** @var array<SegmentEntity|null> null value is for subscribers without a list */
|
||||
private $segments;
|
||||
|
||||
/** @var int */
|
||||
private $segmentIndex;
|
||||
|
||||
/** @var CustomFieldsRepository */
|
||||
private $customFieldsRepository;
|
||||
|
||||
/** @var ImportExportRepository */
|
||||
private $importExportRepository;
|
||||
|
||||
/** @var SegmentsRepository */
|
||||
private $segmentsRepository;
|
||||
|
||||
public function __construct(
|
||||
CustomFieldsRepository $customFieldsRepository,
|
||||
ImportExportRepository $importExportRepository,
|
||||
SegmentsRepository $segmentsRepository,
|
||||
array $data
|
||||
) {
|
||||
$this->customFieldsRepository = $customFieldsRepository;
|
||||
$this->importExportRepository = $importExportRepository;
|
||||
$this->segmentsRepository = $segmentsRepository;
|
||||
if (strpos((string)@ini_get('disable_functions'), 'set_time_limit') === false) {
|
||||
set_time_limit(0);
|
||||
}
|
||||
|
||||
$this->subscribersOffset = 0;
|
||||
$this->segmentIndex = 0;
|
||||
$this->segments = $this->getSegments($data['segments']);
|
||||
$this->exportFormatOption = $data['export_format_option'];
|
||||
$this->subscriberFields = $data['subscriber_fields'];
|
||||
$this->subscriberCustomFields = $this->getSubscriberCustomFields();
|
||||
$this->formattedSubscriberFields = $this->formatSubscriberFields(
|
||||
$this->subscriberFields,
|
||||
$this->subscriberCustomFields
|
||||
);
|
||||
$this->formattedSubscriberFieldsWithList = $this->formattedSubscriberFields;
|
||||
$this->formattedSubscriberFieldsWithList[] = __('List', 'mailpoet');
|
||||
$this->exportPath = self::getExportPath();
|
||||
$this->exportFile = $this->getExportFile($this->exportFormatOption);
|
||||
$this->exportFileURL = $this->getExportFileURL($this->exportFile);
|
||||
}
|
||||
|
||||
public static function getFilePrefix() {
|
||||
return 'MailPoet_export_';
|
||||
}
|
||||
|
||||
public static function getExportPath() {
|
||||
return Env::$tempPath;
|
||||
}
|
||||
|
||||
public function process(): array {
|
||||
$processedSubscribers = 0;
|
||||
$this->resetCounters();
|
||||
try {
|
||||
if (is_writable($this->exportPath) === false) {
|
||||
throw new \Exception(__('The export file could not be saved on the server.', 'mailpoet'));
|
||||
}
|
||||
if (!extension_loaded('zip') && ($this->exportFormatOption === 'xlsx')) {
|
||||
throw new \Exception(__('Export requires a ZIP extension to be installed on the host.', 'mailpoet'));
|
||||
}
|
||||
$callback = [
|
||||
$this,
|
||||
'generate' . strtoupper($this->exportFormatOption),
|
||||
];
|
||||
if (is_callable($callback)) {
|
||||
$processedSubscribers = call_user_func($callback);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
throw new \Exception($e->getMessage());
|
||||
}
|
||||
return [
|
||||
'totalExported' => $processedSubscribers,
|
||||
'exportFileURL' => $this->exportFileURL,
|
||||
];
|
||||
}
|
||||
|
||||
public function generateCSV(): int {
|
||||
$processedSubscribers = 0;
|
||||
$formattedSubscriberFields = $this->formattedSubscriberFieldsWithList;
|
||||
$cSVFile = fopen($this->exportFile, 'w');
|
||||
if ($cSVFile === false) {
|
||||
throw new \Exception(__('Failed opening file for export.', 'mailpoet'));
|
||||
}
|
||||
$formatCSV = function($row) {
|
||||
return '"' . str_replace('"', '\"', (string)$row) . '"';
|
||||
};
|
||||
// add UTF-8 BOM (3 bytes, hex EF BB BF) at the start of the file for
|
||||
// Excel to automatically recognize the encoding
|
||||
fwrite($cSVFile, chr(0xEF) . chr(0xBB) . chr(0xBF));
|
||||
fwrite(
|
||||
$cSVFile,
|
||||
implode(
|
||||
',',
|
||||
array_map(
|
||||
$formatCSV,
|
||||
$formattedSubscriberFields
|
||||
)
|
||||
) . PHP_EOL
|
||||
);
|
||||
|
||||
while (($subscribers = $this->getSubscribers()) !== null) {
|
||||
$processedSubscribers += count($subscribers);
|
||||
foreach ($subscribers as $subscriber) {
|
||||
$row = $this->formatSubscriberData($subscriber);
|
||||
$row[] = ucwords($subscriber['segment_name']);
|
||||
fwrite($cSVFile, implode(',', array_map($formatCSV, $row)) . "\n");
|
||||
}
|
||||
}
|
||||
fclose($cSVFile);
|
||||
return $processedSubscribers;
|
||||
}
|
||||
|
||||
public function generateXLSX(): int {
|
||||
$processedSubscribers = 0;
|
||||
$xLSXWriter = new XLSXWriter();
|
||||
$xLSXWriter->setAuthor('MailPoet (www.mailpoet.com)');
|
||||
$lastSegment = false;
|
||||
$processedSegments = [];
|
||||
|
||||
while (($subscribers = $this->getSubscribers()) !== null) {
|
||||
$processedSubscribers += count($subscribers);
|
||||
foreach ($subscribers as $i => $subscriber) {
|
||||
$currentSegment = ucwords($subscriber['segment_name']);
|
||||
// Sheet header (1st row) will be written only if:
|
||||
// * This is the first time we're processing a segment
|
||||
// * The previous subscriber's segment is different from the current subscriber's segment
|
||||
// Header will NOT be written if:
|
||||
// * We have already processed the segment. Because SQL results are not
|
||||
// sorted by segment name (due to slow queries when using ORDER BY and LIMIT),
|
||||
// we need to keep track of processed segments so that we do not create header
|
||||
// multiple times when switching from one segment to another and back.
|
||||
if (
|
||||
(!count($processedSegments) || $lastSegment !== $currentSegment) &&
|
||||
(!in_array($lastSegment, $processedSegments) || !in_array($currentSegment, $processedSegments))
|
||||
) {
|
||||
$this->writeXLSX(
|
||||
$xLSXWriter,
|
||||
$subscriber['segment_name'],
|
||||
$this->formattedSubscriberFieldsWithList
|
||||
);
|
||||
$processedSegments[] = $currentSegment;
|
||||
}
|
||||
$lastSegment = ucwords($subscriber['segment_name']);
|
||||
// detect RTL language and set Excel to properly display the sheet
|
||||
$rTLRegex = '/\p{Arabic}|\p{Hebrew}/u';
|
||||
if (
|
||||
!$xLSXWriter->rtl && (
|
||||
preg_grep($rTLRegex, $subscriber) ||
|
||||
preg_grep($rTLRegex, $this->formattedSubscriberFieldsWithList))
|
||||
) {
|
||||
$xLSXWriter->rtl = true;
|
||||
}
|
||||
|
||||
$xlsxData = $this->formatSubscriberData($subscriber);
|
||||
$xlsxData[] = ucwords($subscriber['segment_name']);
|
||||
|
||||
$this->writeXLSX(
|
||||
$xLSXWriter,
|
||||
$lastSegment,
|
||||
$xlsxData
|
||||
);
|
||||
}
|
||||
}
|
||||
$xLSXWriter->writeToFile($this->exportFile);
|
||||
return $processedSubscribers;
|
||||
}
|
||||
|
||||
public function writeXLSX($xLSXWriter, $segment, $data) {
|
||||
return $xLSXWriter->writeSheetRow(ucwords($segment), $data);
|
||||
}
|
||||
|
||||
public function getSubscribers(): ?array {
|
||||
$segment = array_key_exists($this->segmentIndex, $this->segments) ? $this->segments[$this->segmentIndex] : false;
|
||||
if ($segment === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$subscribers = $this->importExportRepository->getSubscribersBatchBySegment(
|
||||
$segment,
|
||||
self::SUBSCRIBER_BATCH_SIZE,
|
||||
$this->subscribersOffset
|
||||
);
|
||||
$this->subscribersOffset += count($subscribers);
|
||||
|
||||
if (count($subscribers) < self::SUBSCRIBER_BATCH_SIZE) {
|
||||
$this->segmentIndex++;
|
||||
$this->subscribersOffset = 0;
|
||||
}
|
||||
|
||||
return $subscribers;
|
||||
}
|
||||
|
||||
public function getExportFileURL($file): string {
|
||||
return sprintf(
|
||||
'%s/%s',
|
||||
Env::$tempUrl,
|
||||
basename($file)
|
||||
);
|
||||
}
|
||||
|
||||
public function getExportFile($format): string {
|
||||
return sprintf(
|
||||
$this->exportPath . '/' . self::getFilePrefix() . '%s.%s',
|
||||
Security::generateRandomString(15),
|
||||
$format
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function getSubscriberCustomFields(): array {
|
||||
$result = [];
|
||||
foreach ($this->customFieldsRepository->findAll() as $customField) {
|
||||
$result[(int)$customField->getId()] = $customField->getName();
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $segmentIds
|
||||
* @return array<SegmentEntity|null> null value is for subscribers without a list
|
||||
*/
|
||||
private function getSegments(array $segmentIds): array {
|
||||
$segments = $this->segmentsRepository->findBy(['id' => $segmentIds]);
|
||||
$result = [];
|
||||
foreach ($segmentIds as $segmentId) {
|
||||
$segmentId = (int)$segmentId;
|
||||
$segment = current(array_filter($segments, function (SegmentEntity $segment) use ($segmentId): bool {
|
||||
return $segment->getId() === $segmentId;
|
||||
})) ?: null;
|
||||
|
||||
if (!$segment && $segmentId !== 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result[] = $segment;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function resetCounters(): void {
|
||||
$this->segmentIndex = 0;
|
||||
$this->subscribersOffset = 0;
|
||||
}
|
||||
|
||||
public function formatSubscriberFields($subscriberFields, $subscriberCustomFields): array {
|
||||
$exportFactory = new ImportExportFactory('export');
|
||||
$translatedFields = $exportFactory->getSubscriberFields();
|
||||
return array_map(function($field) use (
|
||||
$translatedFields, $subscriberCustomFields
|
||||
) {
|
||||
$field = (isset($translatedFields[$field])) ?
|
||||
ucfirst($translatedFields[$field]) :
|
||||
ucfirst($field);
|
||||
return (isset($subscriberCustomFields[$field])) ?
|
||||
ucfirst($subscriberCustomFields[$field]) : $field;
|
||||
}, $subscriberFields);
|
||||
}
|
||||
|
||||
public function formatSubscriberData($subscriber): array {
|
||||
return array_map(function($field) use ($subscriber) {
|
||||
return $subscriber[$field];
|
||||
}, $this->subscriberFields);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<?php
|
||||
@@ -0,0 +1,707 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Subscribers\ImportExport\Import;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\CustomFields\CustomFieldsRepository;
|
||||
use MailPoet\Entities\CustomFieldEntity;
|
||||
use MailPoet\Entities\SubscriberCustomFieldEntity;
|
||||
use MailPoet\Entities\SubscriberEntity;
|
||||
use MailPoet\Entities\SubscriberSegmentEntity;
|
||||
use MailPoet\Entities\SubscriberTagEntity;
|
||||
use MailPoet\Newsletter\Options\NewsletterOptionsRepository;
|
||||
use MailPoet\Segments\WP;
|
||||
use MailPoet\Services\Validator;
|
||||
use MailPoet\Subscribers\ImportExport\ImportExportFactory;
|
||||
use MailPoet\Subscribers\ImportExport\ImportExportRepository;
|
||||
use MailPoet\Subscribers\Source;
|
||||
use MailPoet\Subscribers\SubscribersRepository;
|
||||
use MailPoet\Tags\TagRepository;
|
||||
use MailPoet\Util\DateConverter;
|
||||
use MailPoet\Util\Helpers;
|
||||
use MailPoet\Util\Security;
|
||||
use MailPoet\WP\Functions as WPFunctions;
|
||||
use MailPoetVendor\Carbon\Carbon;
|
||||
|
||||
class Import {
|
||||
/** @var array */
|
||||
public $subscribersData;
|
||||
/** @var array */
|
||||
public $segmentsIds;
|
||||
/** @var string[] */
|
||||
public $tags;
|
||||
/** @var string */
|
||||
public $newSubscribersStatus;
|
||||
/** @var string */
|
||||
public $existingSubscribersStatus;
|
||||
/** @var bool */
|
||||
public $updateSubscribers;
|
||||
/** @var array */
|
||||
public $subscribersFields;
|
||||
/** @var array */
|
||||
public $subscribersCustomFields;
|
||||
/** @var int */
|
||||
public $subscribersCount;
|
||||
/** @var Carbon */
|
||||
public $createdAt;
|
||||
/** @var Carbon */
|
||||
public $updatedAt;
|
||||
/** @var array<string, mixed> */
|
||||
public $requiredSubscribersFields;
|
||||
const DB_QUERY_CHUNK_SIZE = 100;
|
||||
const STATUS_DONT_UPDATE = 'dont_update';
|
||||
|
||||
public const ACTION_CREATE = 'create';
|
||||
public const ACTION_UPDATE = 'update';
|
||||
|
||||
/** @var WP */
|
||||
private $wpSegment;
|
||||
|
||||
/** @var CustomFieldsRepository */
|
||||
private $customFieldsRepository;
|
||||
|
||||
/** @var ImportExportRepository */
|
||||
private $importExportRepository;
|
||||
|
||||
/** @var NewsletterOptionsRepository */
|
||||
private $newsletterOptionsRepository;
|
||||
|
||||
/** @var SubscribersRepository */
|
||||
private $subscriberRepository;
|
||||
|
||||
/** @var TagRepository */
|
||||
private $tagRepository;
|
||||
|
||||
/** @var Validator */
|
||||
private $validator;
|
||||
|
||||
public function __construct(
|
||||
WP $wpSegment,
|
||||
CustomFieldsRepository $customFieldsRepository,
|
||||
ImportExportRepository $importExportRepository,
|
||||
NewsletterOptionsRepository $newsletterOptionsRepository,
|
||||
SubscribersRepository $subscriberRepository,
|
||||
TagRepository $tagRepository,
|
||||
Validator $validator,
|
||||
array $data
|
||||
) {
|
||||
$this->wpSegment = $wpSegment;
|
||||
$this->customFieldsRepository = $customFieldsRepository;
|
||||
$this->importExportRepository = $importExportRepository;
|
||||
$this->newsletterOptionsRepository = $newsletterOptionsRepository;
|
||||
$this->subscriberRepository = $subscriberRepository;
|
||||
$this->tagRepository = $tagRepository;
|
||||
$this->validator = $validator;
|
||||
$this->validateImportData($data);
|
||||
$this->subscribersData = $this->transformSubscribersData(
|
||||
$data['subscribers'],
|
||||
$data['columns']
|
||||
);
|
||||
$this->segmentsIds = $data['segments'];
|
||||
$this->tags = $data['tags'];
|
||||
$this->newSubscribersStatus = $data['newSubscribersStatus'];
|
||||
$this->existingSubscribersStatus = $data['existingSubscribersStatus'];
|
||||
$this->updateSubscribers = $data['updateSubscribers'];
|
||||
$this->subscribersFields = $this->getSubscribersFields(
|
||||
array_keys($data['columns'])
|
||||
);
|
||||
$this->subscribersCustomFields = $this->getCustomSubscribersFields(
|
||||
array_keys($data['columns'])
|
||||
);
|
||||
$this->subscribersCount = (reset($this->subscribersData) === false) ? 0 : count(reset($this->subscribersData));
|
||||
$this->createdAt = Carbon::now()->millisecond(0);
|
||||
$this->updatedAt = Carbon::createFromTimestamp(WPFunctions::get()->currentTime('timestamp', true) + 1);
|
||||
$this->requiredSubscribersFields = [
|
||||
'status' => SubscriberEntity::STATUS_SUBSCRIBED,
|
||||
'first_name' => '',
|
||||
'last_name' => '',
|
||||
'created_at' => $this->createdAt,
|
||||
];
|
||||
}
|
||||
|
||||
public function validateImportData(array $data): void {
|
||||
$requiredDataFields = [
|
||||
'subscribers',
|
||||
'columns',
|
||||
'segments',
|
||||
'timestamp',
|
||||
'newSubscribersStatus',
|
||||
'existingSubscribersStatus',
|
||||
'updateSubscribers',
|
||||
'tags',
|
||||
];
|
||||
// 1. data should contain all required fields
|
||||
// 2. column names should only contain alphanumeric & underscore characters
|
||||
if (
|
||||
count(array_intersect_key(array_flip($requiredDataFields), $data)) !== count($requiredDataFields) ||
|
||||
preg_grep('/[^a-zA-Z0-9_]/', array_keys($data['columns']))
|
||||
) {
|
||||
throw new \Exception(__('Missing or invalid import data.', 'mailpoet'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{created: int, updated:int, segments: array, added_to_segment_with_welcome_notification:bool}
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function process(): array {
|
||||
// validate data based on field validation rules
|
||||
$subscribersData = $this->validateSubscribersData($this->subscribersData);
|
||||
if (!$subscribersData) {
|
||||
throw new \Exception(__('No valid subscribers were found.', 'mailpoet'));
|
||||
}
|
||||
// permanently trash deleted subscribers
|
||||
$this->deleteExistingTrashedSubscribers($subscribersData);
|
||||
|
||||
// split subscribers into "existing" and "new" and free up memory
|
||||
$existingSubscribers = $newSubscribers = [
|
||||
'data' => [],
|
||||
'fields' => $this->subscribersFields,
|
||||
];
|
||||
list($existingSubscribers['data'], $newSubscribers['data'], $wpUsers) =
|
||||
$this->splitSubscribersData($subscribersData);
|
||||
$subscribersData = null;
|
||||
|
||||
// create or update subscribers
|
||||
$createdSubscribers = $updatedSubscribers = [];
|
||||
try {
|
||||
if ($newSubscribers['data']) {
|
||||
// add, if required, missing required fields to new subscribers
|
||||
$newSubscribers = $this->addMissingRequiredFields($newSubscribers);
|
||||
$newSubscribers = $this->setSubscriptionStatusToDefault($newSubscribers, $this->newSubscribersStatus);
|
||||
$newSubscribers = $this->setSource($newSubscribers);
|
||||
$newSubscribers = $this->setLinkToken($newSubscribers);
|
||||
$createdSubscribers =
|
||||
$this->createOrUpdateSubscribers(
|
||||
self::ACTION_CREATE,
|
||||
$newSubscribers,
|
||||
$this->subscribersCustomFields
|
||||
);
|
||||
}
|
||||
|
||||
$updateExistingSubscribersStatus = false;
|
||||
|
||||
if ($existingSubscribers['data']) {
|
||||
$allowedStatuses = [
|
||||
SubscriberEntity::STATUS_SUBSCRIBED,
|
||||
SubscriberEntity::STATUS_UNSUBSCRIBED,
|
||||
SubscriberEntity::STATUS_INACTIVE,
|
||||
];
|
||||
if (in_array($this->existingSubscribersStatus, $allowedStatuses, true)) {
|
||||
$updateExistingSubscribersStatus = true;
|
||||
$existingSubscribers = $this->addField($existingSubscribers, 'status', $this->existingSubscribersStatus);
|
||||
}
|
||||
if ($this->updateSubscribers) {
|
||||
// Update existing subscribers' info (first_name, last_name etc.)
|
||||
// as well as status (optionally) if the status column was added above
|
||||
$updatedSubscribers =
|
||||
$this->createOrUpdateSubscribers(
|
||||
self::ACTION_UPDATE,
|
||||
$existingSubscribers,
|
||||
$this->subscribersCustomFields
|
||||
);
|
||||
if ($wpUsers) {
|
||||
$this->synchronizeWPUsers($wpUsers);
|
||||
}
|
||||
} elseif ($updateExistingSubscribersStatus) {
|
||||
// Only update existing subscribers' status
|
||||
// For this we need to remove all other fields except email and status
|
||||
$existingSubscribers['fields'] = array_intersect($existingSubscribers['fields'], ['email', 'status']);
|
||||
$existingSubscribers['data'] = array_intersect_key($existingSubscribers['data'], array_flip(['email', 'status']));
|
||||
$updatedSubscribers =
|
||||
$this->createOrUpdateSubscribers(
|
||||
self::ACTION_UPDATE,
|
||||
$existingSubscribers
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
throw new \Exception(__('Unable to save imported subscribers.', 'mailpoet'));
|
||||
}
|
||||
|
||||
// check if any subscribers were added to segments that have welcome notifications configured
|
||||
$importFactory = new ImportExportFactory('import');
|
||||
$segments = $importFactory->getSegments();
|
||||
$welcomeNotificationsInSegments =
|
||||
($createdSubscribers || $updatedSubscribers) ?
|
||||
$this->newsletterOptionsRepository->findWelcomeNotificationsForSegments($this->segmentsIds) :
|
||||
false;
|
||||
|
||||
return [
|
||||
'created' => is_array($createdSubscribers) ? count($createdSubscribers) : 0,
|
||||
'updated' => is_array($updatedSubscribers) ? count($updatedSubscribers) : 0,
|
||||
'segments' => $segments,
|
||||
'added_to_segment_with_welcome_notification' =>
|
||||
($welcomeNotificationsInSegments) ? true : false,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $subscribersData
|
||||
* @return false|array
|
||||
*/
|
||||
public function validateSubscribersData(array $subscribersData) {
|
||||
$invalidRecords = [];
|
||||
foreach ($subscribersData as $column => &$data) {
|
||||
if ($column === 'email') {
|
||||
$data = array_map(
|
||||
function($index, $email) use(&$invalidRecords) {
|
||||
if (!$this->validator->validateNonRoleEmail($email)) {
|
||||
$invalidRecords[] = $index;
|
||||
}
|
||||
return strtolower($email);
|
||||
},
|
||||
array_keys($data),
|
||||
$data
|
||||
);
|
||||
}
|
||||
if (in_array($column, ['created_at', 'confirmed_at'], true)) {
|
||||
$data = $this->validateDateTime($data, $invalidRecords);
|
||||
}
|
||||
if (in_array($column, ['confirmed_ip', 'subscribed_ip'], true)) {
|
||||
$data = array_map(
|
||||
function($index, $ip) {
|
||||
if (!filter_var($ip, FILTER_VALIDATE_IP)) {
|
||||
// if invalid or empty, we allow the import but remove the IP
|
||||
return null;
|
||||
}
|
||||
return $ip;
|
||||
},
|
||||
array_keys($data),
|
||||
$data
|
||||
);
|
||||
}
|
||||
// if this is a custom column
|
||||
if (in_array($column, $this->subscribersCustomFields)) {
|
||||
$customField = $this->customFieldsRepository->findOneById($column);
|
||||
if (!$customField instanceof CustomFieldEntity) {
|
||||
continue;
|
||||
}
|
||||
// validate date type
|
||||
if ($customField->getType() === CustomFieldEntity::TYPE_DATE) {
|
||||
$data = $this->validateDateTime($data, $invalidRecords);
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($invalidRecords) {
|
||||
foreach ($subscribersData as $column => &$data) {
|
||||
$data = array_diff_key($data, array_flip($invalidRecords));
|
||||
$data = array_values($data);
|
||||
}
|
||||
}
|
||||
if (empty($subscribersData['email'])) return false;
|
||||
return $subscribersData;
|
||||
}
|
||||
|
||||
private function validateDateTime(array $data, array &$invalidRecords): array {
|
||||
$siteUsesCustomFormat = WPFunctions::get()->getOption('date_format') === 'd/m/Y';
|
||||
if ($siteUsesCustomFormat) {
|
||||
return $this->validateDateTimeAttemptCustomFormat($data, $invalidRecords);
|
||||
}
|
||||
|
||||
$validationRule = 'datetime';
|
||||
return array_map(
|
||||
function ($index, $date) use ($validationRule, &$invalidRecords) {
|
||||
if (empty($date)) return $date;
|
||||
$date = (new DateConverter())->convertDateToDatetime($date, $validationRule);
|
||||
if (!$date) {
|
||||
$invalidRecords[] = $index;
|
||||
}
|
||||
return $date;
|
||||
},
|
||||
array_keys($data),
|
||||
$data
|
||||
);
|
||||
}
|
||||
|
||||
private function validateDateTimeAttemptCustomFormat(array $data, array &$invalidRecords): array {
|
||||
$validationRule = 'datetime';
|
||||
$dateTimeDates = $data;
|
||||
$dateTimeInvalidRecords = $invalidRecords;
|
||||
$datetimeErrorCount = 0;
|
||||
|
||||
$validationRuleCustom = 'd/m/Y';
|
||||
$customFormatDates = $data;
|
||||
$customFormatInvalidRecords = $invalidRecords;
|
||||
$customFormatErrorCount = 0;
|
||||
|
||||
// We attempt converting with both date formats
|
||||
foreach ($data as $index => $date) {
|
||||
if (empty($date)) {
|
||||
$dateTimeDates[$index] = $date;
|
||||
$customFormatDates[$index] = $date;
|
||||
continue;
|
||||
};
|
||||
$dateTimeDates[$index] = (new DateConverter())->convertDateToDatetime($date, $validationRule);
|
||||
if (!$dateTimeDates[$index]) {
|
||||
$datetimeErrorCount ++;
|
||||
$dateTimeInvalidRecords[] = $index;
|
||||
}
|
||||
$customFormatDates[$index] = (new DateConverter())->convertDateToDatetime($date, $validationRuleCustom);
|
||||
if (!$customFormatDates[$index]) {
|
||||
$customFormatErrorCount ++;
|
||||
$customFormatInvalidRecords[] = $index;
|
||||
}
|
||||
}
|
||||
|
||||
if ($customFormatErrorCount < $datetimeErrorCount) {
|
||||
$invalidRecords = $customFormatInvalidRecords;
|
||||
return $customFormatDates;
|
||||
}
|
||||
|
||||
$invalidRecords = $dateTimeInvalidRecords;
|
||||
return $dateTimeDates;
|
||||
}
|
||||
|
||||
public function transformSubscribersData(array $subscribers, array $columns): array {
|
||||
$transformedSubscribers = [];
|
||||
foreach ($columns as $column => $data) {
|
||||
$transformedSubscribers[$column] = array_column($subscribers, $data['index']);
|
||||
}
|
||||
return $transformedSubscribers;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $subscribersData
|
||||
* @return array{array|false,array,array|false}
|
||||
*/
|
||||
public function splitSubscribersData(array $subscribersData): array {
|
||||
// $subscribers_data is an two-dimensional associative array
|
||||
// of all subscribers being imported: [field => [value1, value2], field => [value1, value2], ...]
|
||||
$tempExistingSubscribers = [];
|
||||
foreach (array_chunk($subscribersData['email'], self::DB_QUERY_CHUNK_SIZE) as $subscribersEmails) {
|
||||
// create a two-dimensional indexed array of all existing subscribers
|
||||
// with just wp_user_id and email fields: [[wp_user_id, email], [wp_user_id, email], ...]
|
||||
$tempExistingSubscribers = array_merge(
|
||||
$tempExistingSubscribers,
|
||||
$this->subscriberRepository->findWpUserIdAndEmailByEmails($subscribersEmails)
|
||||
);
|
||||
}
|
||||
if (!$tempExistingSubscribers) {
|
||||
return [
|
||||
false, // existing subscribers
|
||||
$subscribersData, // new subscribers
|
||||
false, // WP users
|
||||
];
|
||||
}
|
||||
// extract WP users ids into a simple indexed array: [wp_user_id_1, wp_user_id_2, ...]
|
||||
$wpUsers = array_filter(array_column($tempExistingSubscribers, 'wp_user_id'));
|
||||
// create a new two-dimensional associative array with existing subscribers ($existing_subscribers)
|
||||
// and reduce $subscribers_data to only new subscribers by removing existing subscribers
|
||||
$existingSubscribers = [];
|
||||
$subscribersEmails = array_flip($subscribersData['email']);
|
||||
foreach ($tempExistingSubscribers as $tempExistingSubscriber) {
|
||||
$existingSubscriberKey = $subscribersEmails[$tempExistingSubscriber['email']];
|
||||
foreach ($subscribersData as $field => &$value) {
|
||||
$existingSubscribers[$field][] = $value[$existingSubscriberKey];
|
||||
unset($value[$existingSubscriberKey]);
|
||||
}
|
||||
}
|
||||
$newSubscribers = $subscribersData;
|
||||
// reindex array after unsetting elements
|
||||
$newSubscribers = array_map('array_values', $newSubscribers);
|
||||
// remove empty values
|
||||
$newSubscribers = array_filter($newSubscribers);
|
||||
return [
|
||||
$existingSubscribers,
|
||||
$newSubscribers,
|
||||
$wpUsers,
|
||||
];
|
||||
}
|
||||
|
||||
public function deleteExistingTrashedSubscribers(array $subscribersData): void {
|
||||
$existingTrashedRecords = array_filter(
|
||||
array_map(function($subscriberEmails) {
|
||||
return $this->subscriberRepository->findIdsOfDeletedByEmails($subscriberEmails);
|
||||
}, array_chunk($subscribersData['email'], self::DB_QUERY_CHUNK_SIZE))
|
||||
);
|
||||
$existingTrashedRecords = Helpers::flattenArray($existingTrashedRecords);
|
||||
if (!$existingTrashedRecords) {
|
||||
return;
|
||||
}
|
||||
foreach (array_chunk($existingTrashedRecords, self::DB_QUERY_CHUNK_SIZE) as $subscriberIds) {
|
||||
$this->subscriberRepository->bulkDelete($subscriberIds);
|
||||
}
|
||||
}
|
||||
|
||||
public function addMissingRequiredFields(array $subscribers): array {
|
||||
foreach (array_keys($this->requiredSubscribersFields) as $requiredField) {
|
||||
$subscribers = $this->addField($subscribers, $requiredField, $this->requiredSubscribersFields[$requiredField]);
|
||||
}
|
||||
return $subscribers;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $subscribers
|
||||
* @param string $fieldName
|
||||
* @param mixed $fieldValue
|
||||
* @return array
|
||||
*/
|
||||
private function addField(array $subscribers, string $fieldName, $fieldValue): array {
|
||||
if (in_array($fieldName, $subscribers['fields'])) return $subscribers;
|
||||
|
||||
$subscribersCount = count($subscribers['data'][key($subscribers['data'])]);
|
||||
$subscribers['data'][$fieldName] = array_fill(
|
||||
0,
|
||||
$subscribersCount,
|
||||
$fieldValue
|
||||
);
|
||||
$subscribers['fields'][] = $fieldName;
|
||||
|
||||
return $subscribers;
|
||||
}
|
||||
|
||||
private function setSubscriptionStatusToDefault(array $subscribersData, string $defaultStatus): array {
|
||||
if (!in_array('status', $subscribersData['fields'])) return $subscribersData;
|
||||
$subscribersData['data']['status'] = array_map(function() use ($defaultStatus) {
|
||||
return $defaultStatus;
|
||||
}, $subscribersData['data']['status']);
|
||||
|
||||
if ($defaultStatus === SubscriberEntity::STATUS_SUBSCRIBED) {
|
||||
if (!in_array('last_subscribed_at', $subscribersData['fields'])) {
|
||||
$subscribersData['fields'][] = 'last_subscribed_at';
|
||||
}
|
||||
$subscribersData['data']['last_subscribed_at'] = array_map(function() {
|
||||
return $this->createdAt;
|
||||
}, $subscribersData['data']['status']);
|
||||
}
|
||||
return $subscribersData;
|
||||
}
|
||||
|
||||
private function setSource(array $subscribersData): array {
|
||||
$subscribersCount = count($subscribersData['data'][key($subscribersData['data'])]);
|
||||
$subscribersData['fields'][] = 'source';
|
||||
$subscribersData['data']['source'] = array_fill(
|
||||
0,
|
||||
$subscribersCount,
|
||||
Source::IMPORTED
|
||||
);
|
||||
return $subscribersData;
|
||||
}
|
||||
|
||||
private function setLinkToken(array $subscribersData): array {
|
||||
$subscribersCount = count($subscribersData['data'][key($subscribersData['data'])]);
|
||||
$subscribersData['fields'][] = 'link_token';
|
||||
$subscribersData['data']['link_token'] = array_map(
|
||||
function () {
|
||||
return Security::generateRandomString(SubscriberEntity::LINK_TOKEN_LENGTH);
|
||||
},
|
||||
array_fill(0, $subscribersCount, null)
|
||||
);
|
||||
return $subscribersData;
|
||||
}
|
||||
|
||||
public function getSubscribersFields(array $subscribersFields): array {
|
||||
return array_values(
|
||||
array_filter(
|
||||
array_map(function($field) {
|
||||
if (!is_int($field)) return $field;
|
||||
}, $subscribersFields)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $subscribersFields
|
||||
* @return int[]
|
||||
*/
|
||||
public function getCustomSubscribersFields(array $subscribersFields): array {
|
||||
return array_values(
|
||||
array_filter(
|
||||
array_map(function($field) {
|
||||
if (is_int($field)) return $field;
|
||||
}, $subscribersFields)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public function createOrUpdateSubscribers(
|
||||
string $action,
|
||||
array $subscribersData,
|
||||
array $subscribersCustomFields = []
|
||||
): ?array {
|
||||
$subscribersCount = count($subscribersData['data'][key($subscribersData['data'])]);
|
||||
$subscribers = array_map(function($index) use ($subscribersData) {
|
||||
return array_map(function($field) use ($index, $subscribersData) {
|
||||
return $subscribersData['data'][$field][$index];
|
||||
}, $subscribersData['fields']);
|
||||
}, range(0, $subscribersCount - 1));
|
||||
foreach (array_chunk($subscribers, self::DB_QUERY_CHUNK_SIZE) as $data) {
|
||||
if ($action === self::ACTION_CREATE) {
|
||||
$this->importExportRepository->insertMultiple(
|
||||
SubscriberEntity::class,
|
||||
$subscribersData['fields'],
|
||||
$data
|
||||
);
|
||||
} elseif ($action === self::ACTION_UPDATE) {
|
||||
$this->importExportRepository->updateMultiple(
|
||||
SubscriberEntity::class,
|
||||
$subscribersData['fields'],
|
||||
$data,
|
||||
$this->updatedAt
|
||||
);
|
||||
}
|
||||
}
|
||||
$createdOrUpdatedSubscribers = [];
|
||||
foreach (array_chunk($subscribersData['data']['email'], self::DB_QUERY_CHUNK_SIZE) as $emails) {
|
||||
foreach ($this->subscriberRepository->findIdAndEmailByEmails($emails) as $createdOrUpdatedSubscriber) {
|
||||
// ensure emails loaded from the DB are lowercased (imported emails are lowercased as well)
|
||||
$createdOrUpdatedSubscriber['email'] = mb_strtolower($createdOrUpdatedSubscriber['email']);
|
||||
$createdOrUpdatedSubscribers[] = $createdOrUpdatedSubscriber;
|
||||
}
|
||||
}
|
||||
if (empty($createdOrUpdatedSubscribers)) return null;
|
||||
|
||||
$this->subscriberRepository->invalidateTotalSubscribersCache();
|
||||
$createdOrUpdatedSubscribersIds = array_column($createdOrUpdatedSubscribers, 'id');
|
||||
if ($subscribersCustomFields) {
|
||||
$this->createOrUpdateCustomFields(
|
||||
$action,
|
||||
$createdOrUpdatedSubscribers,
|
||||
$subscribersData,
|
||||
$subscribersCustomFields
|
||||
);
|
||||
}
|
||||
$this->addSubscribersToSegments(
|
||||
$createdOrUpdatedSubscribersIds,
|
||||
$this->segmentsIds
|
||||
);
|
||||
$this->addTagsToSubscribers(
|
||||
$createdOrUpdatedSubscribersIds,
|
||||
$this->tags
|
||||
);
|
||||
return $createdOrUpdatedSubscribers;
|
||||
}
|
||||
|
||||
public function createOrUpdateCustomFields(
|
||||
string $action,
|
||||
array $createdOrUpdatedSubscribers,
|
||||
array $subscribersData,
|
||||
array $subscribersCustomFieldsIds
|
||||
): void {
|
||||
// check if custom fields exist in the database
|
||||
$subscribersCustomFieldsIds = array_map(function(CustomFieldEntity $customField): int {
|
||||
return (int)$customField->getId();
|
||||
}, $this->customFieldsRepository->findBy(['id' => $subscribersCustomFieldsIds]));
|
||||
if (!$subscribersCustomFieldsIds) {
|
||||
return;
|
||||
}
|
||||
// assemble a two-dimensional array: [[custom_field_id, subscriber_id, value], [custom_field_id, subscriber_id, value], ...]
|
||||
$subscribersCustomFieldsData = [];
|
||||
$subscribersEmails = array_flip($subscribersData['data']['email']);
|
||||
foreach ($createdOrUpdatedSubscribers as $createdOrUpdatedSubscriber) {
|
||||
$subscriberIndex = $subscribersEmails[$createdOrUpdatedSubscriber['email']];
|
||||
foreach ($subscribersData['data'] as $field => $values) {
|
||||
// exclude non-custom fields
|
||||
if (!is_int($field)) continue;
|
||||
$subscribersCustomFieldsData[] = [
|
||||
(int)$field,
|
||||
$createdOrUpdatedSubscriber['id'],
|
||||
$values[$subscriberIndex],
|
||||
$this->createdAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
$columns = [
|
||||
'custom_field_id',
|
||||
'subscriber_id',
|
||||
'value',
|
||||
'created_at',
|
||||
];
|
||||
$customFieldCount = count($subscribersCustomFieldsIds);
|
||||
$customFieldBatchSize = (int)(round(self::DB_QUERY_CHUNK_SIZE / $customFieldCount) * $customFieldCount);
|
||||
$customFieldBatchSize = ($customFieldBatchSize > 0) ? $customFieldBatchSize : 1;
|
||||
foreach (array_chunk($subscribersCustomFieldsData, $customFieldBatchSize) as $subscribersCustomFieldsDataChunk) {
|
||||
$this->importExportRepository->insertMultiple(
|
||||
SubscriberCustomFieldEntity::class,
|
||||
$columns,
|
||||
$subscribersCustomFieldsDataChunk
|
||||
);
|
||||
if ($action === self::ACTION_UPDATE) {
|
||||
$this->importExportRepository->updateMultiple(
|
||||
SubscriberCustomFieldEntity::class,
|
||||
$columns,
|
||||
$subscribersCustomFieldsDataChunk,
|
||||
$this->updatedAt
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int[] $wpUsers
|
||||
* @return array
|
||||
*/
|
||||
public function synchronizeWPUsers(array $wpUsers): array {
|
||||
$users = array_map([$this->wpSegment, 'synchronizeUser'], $wpUsers);
|
||||
$this->subscriberRepository->invalidateTotalSubscribersCache();
|
||||
return $users;
|
||||
}
|
||||
|
||||
public function addSubscribersToSegments(array $subscribersIds, array $segmentsIds): void {
|
||||
$columns = [
|
||||
'subscriber_id',
|
||||
'segment_id',
|
||||
'created_at',
|
||||
];
|
||||
foreach ($segmentsIds as $segmentId) {
|
||||
foreach (array_chunk($subscribersIds, self::DB_QUERY_CHUNK_SIZE) as $subscriberIdsChunk) {
|
||||
$data = [];
|
||||
$data = array_merge($data, array_map(function ($subscriberId) use ($segmentId): array {
|
||||
return [
|
||||
$subscriberId,
|
||||
$segmentId,
|
||||
$this->createdAt,
|
||||
];
|
||||
}, $subscriberIdsChunk));
|
||||
|
||||
$this->importExportRepository->insertMultiple(
|
||||
SubscriberSegmentEntity::class,
|
||||
$columns,
|
||||
$data
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int[] $subscribersIds
|
||||
* @param string[] $tagNames
|
||||
*/
|
||||
public function addTagsToSubscribers(array $subscribersIds, array $tagNames): void {
|
||||
$tagIds = [];
|
||||
foreach ($tagNames as $tagName) {
|
||||
$tag = $this->tagRepository->findOneBy(['name' => $tagName]);
|
||||
if (!$tag) {
|
||||
$tag = $this->tagRepository->createOrUpdate(['name' => $tagName]);
|
||||
}
|
||||
$tagIds[] = $tag->getId();
|
||||
}
|
||||
|
||||
$columns = [
|
||||
'subscriber_id',
|
||||
'tag_id',
|
||||
'created_at',
|
||||
];
|
||||
foreach ($tagIds as $tagId) {
|
||||
foreach (array_chunk($subscribersIds, self::DB_QUERY_CHUNK_SIZE) as $subscriberIdsChunk) {
|
||||
$data = [];
|
||||
$data = array_merge($data, array_map(function ($subscriberId) use ($tagId): array {
|
||||
return [
|
||||
$subscriberId,
|
||||
$tagId,
|
||||
$this->createdAt,
|
||||
];
|
||||
}, $subscriberIdsChunk));
|
||||
|
||||
$this->importExportRepository->insertMultiple(
|
||||
SubscriberTagEntity::class,
|
||||
$columns,
|
||||
$data
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Subscribers\ImportExport\Import;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Util\Helpers;
|
||||
|
||||
class MailChimp {
|
||||
private const API_BASE_URI = 'https://user:%s@%s.api.mailchimp.com/3.0/';
|
||||
private const API_KEY_REGEX = '/[a-zA-Z0-9]{32}-[a-zA-Z0-9]{2,4}$/';
|
||||
private const API_BATCH_SIZE = 100;
|
||||
|
||||
/** @var false|string */
|
||||
public $apiKey;
|
||||
/** @var int */
|
||||
public $maxPostSize;
|
||||
/** @var false|string */
|
||||
public $dataCenter;
|
||||
/** @var MailChimpDataMapper */
|
||||
private $mapper;
|
||||
|
||||
public function __construct(
|
||||
string $apiKey
|
||||
) {
|
||||
$this->apiKey = $this->getAPIKey($apiKey);
|
||||
$this->maxPostSize = (int)Helpers::getMaxPostSize('bytes');
|
||||
$this->dataCenter = $this->getDataCenter($this->apiKey);
|
||||
$this->mapper = new MailChimpDataMapper();
|
||||
}
|
||||
|
||||
public function getLists(): array {
|
||||
if (!$this->apiKey || !$this->dataCenter) {
|
||||
$this->throwException('API');
|
||||
}
|
||||
|
||||
$lists = [];
|
||||
$count = 0;
|
||||
while (true) {
|
||||
$data = $this->getApiData('lists', $count);
|
||||
if ($data === null) {
|
||||
$this->throwException('lists');
|
||||
break;
|
||||
}
|
||||
|
||||
$count += count($data['lists']);
|
||||
foreach ($data['lists'] as $list) {
|
||||
$lists[] = [
|
||||
'id' => $list['id'],
|
||||
'name' => $list['name'],
|
||||
];
|
||||
}
|
||||
|
||||
if ($data['total_items'] <= $count) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $lists;
|
||||
}
|
||||
|
||||
public function getSubscribers(array $lists = []): array {
|
||||
if (!$this->apiKey || !$this->dataCenter) {
|
||||
$this->throwException('API');
|
||||
}
|
||||
|
||||
if (!$lists) {
|
||||
$this->throwException('lists');
|
||||
}
|
||||
|
||||
$subscribers = [];
|
||||
$duplicate = [];
|
||||
$disallowed = [];
|
||||
foreach ($lists as $list) {
|
||||
$count = 0;
|
||||
while (true) {
|
||||
$data = $this->getApiData("lists/{$list}/members", $count);
|
||||
if ($data === null) {
|
||||
$this->throwException('lists');
|
||||
break;
|
||||
}
|
||||
$count += count($data['members']);
|
||||
foreach ($data['members'] as $member) {
|
||||
$emailAddress = $member['email_address'];
|
||||
if (!$this->isSubscriberAllowed($member)) {
|
||||
$disallowed[$emailAddress] = $this->mapper->mapMember($member);
|
||||
} elseif (isset($subscribers[$emailAddress])) {
|
||||
$duplicate[$emailAddress] = $this->mapper->mapMember($member);
|
||||
} else {
|
||||
$subscribers[$emailAddress] = $this->mapper->mapMember($member);
|
||||
}
|
||||
}
|
||||
|
||||
if ($data['total_items'] <= $count) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!count($subscribers)) {
|
||||
$this->throwException('subscribers');
|
||||
}
|
||||
|
||||
return [
|
||||
'subscribers' => array_values($subscribers),
|
||||
'invalid' => [],
|
||||
'duplicate' => $duplicate,
|
||||
'disallowed' => $disallowed,
|
||||
'role' => [],
|
||||
'header' => $this->mapper->getMembersHeader(),
|
||||
'subscribersCount' => count($subscribers),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|false $apiKey
|
||||
* @return false|string
|
||||
*/
|
||||
public function getDataCenter($apiKey) {
|
||||
if (!$apiKey) return false;
|
||||
$apiKeyParts = explode('-', $apiKey);
|
||||
return end($apiKeyParts);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $apiKey
|
||||
* @return false|string
|
||||
*/
|
||||
public function getAPIKey(string $apiKey) {
|
||||
return (preg_match(self::API_KEY_REGEX, $apiKey)) ? $apiKey : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $error
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function throwException(string $error): void {
|
||||
$errorMessage = __('Unknown MailChimp error.', 'mailpoet');
|
||||
switch ($error) {
|
||||
case 'API':
|
||||
$errorMessage = __('Invalid API Key.', 'mailpoet');
|
||||
break;
|
||||
case 'size':
|
||||
$errorMessage = __('The information received from MailChimp is too large for processing. Please limit the number of lists!', 'mailpoet');
|
||||
break;
|
||||
case 'subscribers':
|
||||
$errorMessage = __('Did not find any active subscribers.', 'mailpoet');
|
||||
break;
|
||||
case 'lists':
|
||||
$errorMessage = __('Did not find any valid lists.', 'mailpoet');
|
||||
break;
|
||||
}
|
||||
throw new \Exception($errorMessage);
|
||||
}
|
||||
|
||||
public function isSubscriberAllowed(array $subscriber): bool {
|
||||
if (in_array($subscriber['status'], ['unsubscribed', 'cleaned', 'pending'], true)) {
|
||||
return false;
|
||||
}
|
||||
if ($subscriber['member_rating'] < 2) {
|
||||
return false;
|
||||
}
|
||||
// Rate 1 is on MailChimp API equal to 100% and we don't want to import avg_open_rate lower than 5%
|
||||
if ($subscriber['stats']['avg_open_rate'] < 0.05) {
|
||||
return false;
|
||||
}
|
||||
// We don't want to import avg_click_rate lower than 0.5%
|
||||
if ($subscriber['stats']['avg_click_rate'] < 0.005) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function getApiData(string $endpoint, int $offset): ?array {
|
||||
$url = sprintf(self::API_BASE_URI, $this->apiKey, $this->dataCenter);
|
||||
$url .= $endpoint . '?' . http_build_query([
|
||||
'count' => self::API_BATCH_SIZE,
|
||||
'offset' => $offset,
|
||||
]);
|
||||
|
||||
$connection = @fopen($url, 'r');
|
||||
if (!$connection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$bytesFetched = 0;
|
||||
$response = '';
|
||||
while (!feof($connection)) {
|
||||
$buffer = fgets($connection, 4096);
|
||||
if (!is_string($buffer)) {
|
||||
return null;
|
||||
}
|
||||
if (trim($buffer) !== '') {
|
||||
$response .= $buffer;
|
||||
}
|
||||
$bytesFetched += strlen((string)$buffer);
|
||||
if ($bytesFetched > $this->maxPostSize) {
|
||||
$this->throwException('size');
|
||||
}
|
||||
}
|
||||
fclose($connection);
|
||||
|
||||
return json_decode($response, true);
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Subscribers\ImportExport\Import;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
class MailChimpDataMapper {
|
||||
public function getMembersHeader(): array {
|
||||
return [
|
||||
'email_address',
|
||||
'status',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'address',
|
||||
'phone',
|
||||
'birthday',
|
||||
'ip_signup',
|
||||
'timestamp_signup',
|
||||
'ip_opt',
|
||||
'timestamp_opt',
|
||||
'member_rating',
|
||||
'last_changed',
|
||||
'language',
|
||||
'vip',
|
||||
'email_client',
|
||||
'latitude',
|
||||
'longitude',
|
||||
'gmtoff',
|
||||
'dstoff',
|
||||
'country_code',
|
||||
'timezone',
|
||||
'source',
|
||||
];
|
||||
}
|
||||
|
||||
public function mapMember(array $member): array {
|
||||
return [
|
||||
$member['email_address'],
|
||||
$member['status'],
|
||||
$member['merge_fields']['FNAME'] ?? '',
|
||||
$member['merge_fields']['LNAME'] ?? '',
|
||||
is_array($member['merge_fields']['ADDRESS']) ? implode(' ', $member['merge_fields']['ADDRESS'] ?? []) : $member['merge_fields']['ADDRESS'],
|
||||
$member['merge_fields']['PHONE'] ?? '',
|
||||
$member['merge_fields']['BIRTHDAY'] ?? '',
|
||||
$member['ip_signup'],
|
||||
$member['timestamp_signup'],
|
||||
$member['ip_opt'],
|
||||
$member['timestamp_opt'],
|
||||
$member['member_rating'],
|
||||
$member['last_changed'],
|
||||
$member['language'],
|
||||
$member['vip'],
|
||||
$member['email_client'],
|
||||
$member['location']['latitude'] ?? '',
|
||||
$member['location']['longitude'] ?? '',
|
||||
$member['location']['gmtoff'] ?? '',
|
||||
$member['location']['dstoff'] ?? '',
|
||||
$member['location']['country_code'] ?? '',
|
||||
$member['location']['timezone'] ?? '',
|
||||
$member['source'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<?php
|
||||
@@ -0,0 +1,194 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Subscribers\ImportExport;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\CustomFields\CustomFieldsRepository;
|
||||
use MailPoet\DI\ContainerWrapper;
|
||||
use MailPoet\Entities\SegmentEntity;
|
||||
use MailPoet\Entities\TagEntity;
|
||||
use MailPoet\Segments\SegmentsSimpleListRepository;
|
||||
use MailPoet\Tags\TagRepository;
|
||||
use MailPoet\Util\Helpers;
|
||||
|
||||
class ImportExportFactory {
|
||||
const IMPORT_ACTION = 'import';
|
||||
const EXPORT_ACTION = 'export';
|
||||
|
||||
/** @var string|null */
|
||||
public $action;
|
||||
|
||||
/** @var SegmentsSimpleListRepository */
|
||||
private $segmentsListRepository;
|
||||
|
||||
/** @var CustomFieldsRepository */
|
||||
private $customFieldsRepository;
|
||||
|
||||
/** @var TagRepository */
|
||||
private $tagRepository;
|
||||
|
||||
public function __construct(
|
||||
$action = null
|
||||
) {
|
||||
$this->action = $action;
|
||||
$this->segmentsListRepository = ContainerWrapper::getInstance()->get(SegmentsSimpleListRepository::class);
|
||||
$this->customFieldsRepository = ContainerWrapper::getInstance()->get(CustomFieldsRepository::class);
|
||||
$this->tagRepository = ContainerWrapper::getInstance()->get(TagRepository::class);
|
||||
}
|
||||
|
||||
public function getSegments() {
|
||||
if ($this->action === self::IMPORT_ACTION) {
|
||||
$segments = $this->segmentsListRepository->getListWithSubscribedSubscribersCounts([SegmentEntity::TYPE_DEFAULT]);
|
||||
} else {
|
||||
$segments = $this->segmentsListRepository->getListWithAssociatedSubscribersCounts();
|
||||
$segments = $this->segmentsListRepository->addVirtualSubscribersWithoutListSegment($segments);
|
||||
$segments = array_values(array_filter($segments, function($segment) {
|
||||
return $segment['subscribers'] > 0;
|
||||
}));
|
||||
}
|
||||
|
||||
return array_map(function($segment) {
|
||||
return [
|
||||
'id' => $segment['id'],
|
||||
'name' => esc_attr($segment['name']),
|
||||
'count' => $segment['subscribers'],
|
||||
];
|
||||
}, $segments);
|
||||
}
|
||||
|
||||
public function getSubscriberFields() {
|
||||
$fields = [
|
||||
'email' => __('Email', 'mailpoet'),
|
||||
'first_name' => __('First name', 'mailpoet'),
|
||||
'last_name' => __('Last name', 'mailpoet'),
|
||||
'subscribed_ip' => __('Subscription IP', 'mailpoet'),
|
||||
'created_at' => __('Subscription time', 'mailpoet'),
|
||||
'confirmed_at' => __('Confirmation time', 'mailpoet'),
|
||||
'confirmed_ip' => __('Confirmation IP', 'mailpoet'),
|
||||
];
|
||||
if ($this->action === 'export') {
|
||||
$fields = array_merge(
|
||||
$fields,
|
||||
[
|
||||
'list_status' => _x('List status', 'Subscription status', 'mailpoet'),
|
||||
'global_status' => _x('Global status', 'Subscription status', 'mailpoet'),
|
||||
]
|
||||
);
|
||||
}
|
||||
return $fields;
|
||||
}
|
||||
|
||||
public function formatSubscriberFields($subscriberFields) {
|
||||
return array_map(function($fieldId, $fieldName) {
|
||||
return [
|
||||
'id' => $fieldId,
|
||||
'name' => $fieldName,
|
||||
'text' => $fieldName, // Required for select2 default functionality
|
||||
'type' => ($fieldId === 'confirmed_at' || $fieldId === 'created_at') ? 'date' : null,
|
||||
'custom' => false,
|
||||
];
|
||||
}, array_keys($subscriberFields), $subscriberFields);
|
||||
}
|
||||
|
||||
public function getSubscriberCustomFields() {
|
||||
return $this->customFieldsRepository->findAllAsArray();
|
||||
}
|
||||
|
||||
public function formatSubscriberCustomFields($subscriberCustomFields) {
|
||||
return array_map(function($field) {
|
||||
return [
|
||||
'id' => $field['id'],
|
||||
'name' => $field['name'],
|
||||
'text' => $field['name'], // Required for select2 default functionality
|
||||
'type' => $field['type'],
|
||||
'params' => unserialize($field['params']),
|
||||
'custom' => true,
|
||||
];
|
||||
}, $subscriberCustomFields);
|
||||
}
|
||||
|
||||
public function formatFieldsForSelect2(
|
||||
$subscriberFields,
|
||||
$subscriberCustomFields
|
||||
) {
|
||||
$actions = ($this->action === 'import') ?
|
||||
[
|
||||
[
|
||||
'id' => 'ignore',
|
||||
'name' => __('Ignore field...', 'mailpoet'),
|
||||
'text' => __('Ignore field...', 'mailpoet'), // Required for select2 default functionality
|
||||
],
|
||||
[
|
||||
'id' => 'create',
|
||||
'name' => __('Create new field...', 'mailpoet'),
|
||||
'text' => __('Create new field...', 'mailpoet'), // Required for select2 default functionality
|
||||
],
|
||||
] :
|
||||
[
|
||||
[
|
||||
'id' => 'select',
|
||||
'name' => __('Select all...', 'mailpoet'),
|
||||
'text' => __('Select all...', 'mailpoet'), // Required for select2 default functionality
|
||||
],
|
||||
[
|
||||
'id' => 'deselect',
|
||||
'name' => __('Deselect all...', 'mailpoet'),
|
||||
'text' => __('Deselect all...', 'mailpoet'), // Required for select2 default functionality
|
||||
],
|
||||
];
|
||||
$select2Fields = [
|
||||
[
|
||||
'name' => __('Actions', 'mailpoet'),
|
||||
'text' => __('Actions', 'mailpoet'), // Required for select2 default functionality
|
||||
'children' => $actions,
|
||||
],
|
||||
[
|
||||
'name' => __('System fields', 'mailpoet'),
|
||||
'text' => __('System fields', 'mailpoet'), // Required for select2 default functionality
|
||||
'children' => $this->formatSubscriberFields($subscriberFields),
|
||||
],
|
||||
];
|
||||
if ($subscriberCustomFields) {
|
||||
array_push($select2Fields, [
|
||||
'name' => __('User fields', 'mailpoet'),
|
||||
'text' => __('User fields', 'mailpoet'), // Required for select2 default functionality
|
||||
'children' => $this->formatSubscriberCustomFields(
|
||||
$subscriberCustomFields
|
||||
),
|
||||
]);
|
||||
}
|
||||
return $select2Fields;
|
||||
}
|
||||
|
||||
public function bootstrap() {
|
||||
$subscriberFields = $this->getSubscriberFields();
|
||||
$subscriberCustomFields = $this->getSubscriberCustomFields();
|
||||
$data['segments'] = json_encode($this->getSegments());
|
||||
$data['subscriberFieldsSelect2'] = json_encode(
|
||||
$this->formatFieldsForSelect2(
|
||||
$subscriberFields,
|
||||
$subscriberCustomFields
|
||||
)
|
||||
);
|
||||
if ($this->action === 'import') {
|
||||
$data['subscriberFields'] = json_encode(
|
||||
array_merge(
|
||||
$this->formatSubscriberFields($subscriberFields),
|
||||
$this->formatSubscriberCustomFields($subscriberCustomFields)
|
||||
)
|
||||
);
|
||||
$data['maxPostSizeBytes'] = Helpers::getMaxPostSize('bytes');
|
||||
$data['maxPostSize'] = Helpers::getMaxPostSize();
|
||||
$data['tags'] = array_map(function (TagEntity $tag): array {
|
||||
return [
|
||||
'id' => $tag->getId(),
|
||||
'name' => $tag->getName(),
|
||||
];
|
||||
}, $this->tagRepository->findAll());
|
||||
}
|
||||
$data['zipExtensionLoaded'] = extension_loaded('zip');
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Subscribers\ImportExport;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use DateTime;
|
||||
use MailPoet\Config\SubscriberChangesNotifier;
|
||||
use MailPoet\Entities\CustomFieldEntity;
|
||||
use MailPoet\Entities\SegmentEntity;
|
||||
use MailPoet\Entities\SubscriberCustomFieldEntity;
|
||||
use MailPoet\Entities\SubscriberEntity;
|
||||
use MailPoet\Entities\SubscriberSegmentEntity;
|
||||
use MailPoet\Segments\DynamicSegments\FilterHandler;
|
||||
use MailPoet\Subscribers\SubscriberCustomFieldRepository;
|
||||
use MailPoet\Subscribers\SubscribersRepository;
|
||||
use MailPoetVendor\Doctrine\DBAL\ArrayParameterType;
|
||||
use MailPoetVendor\Doctrine\DBAL\Query\QueryBuilder;
|
||||
use MailPoetVendor\Doctrine\DBAL\Result;
|
||||
use MailPoetVendor\Doctrine\ORM\EntityManager;
|
||||
use MailPoetVendor\Doctrine\ORM\Mapping\ClassMetadata;
|
||||
|
||||
class ImportExportRepository {
|
||||
private const IGNORED_COLUMNS_FOR_BULK_UPDATE = [
|
||||
SubscriberEntity::class => [
|
||||
'wp_user_id',
|
||||
'is_woocommerce_user',
|
||||
'email',
|
||||
'created_at',
|
||||
'last_subscribed_at',
|
||||
],
|
||||
SubscriberCustomFieldEntity::class => [
|
||||
'created_at',
|
||||
],
|
||||
SubscriberSegmentEntity::class => [
|
||||
'created_at',
|
||||
],
|
||||
];
|
||||
|
||||
private const KEY_COLUMNS_FOR_BULK_UPDATE = [
|
||||
SubscriberEntity::class => [
|
||||
'email',
|
||||
],
|
||||
SubscriberCustomFieldEntity::class => [
|
||||
'subscriber_id',
|
||||
'custom_field_id',
|
||||
],
|
||||
];
|
||||
|
||||
/** @var EntityManager */
|
||||
protected $entityManager;
|
||||
|
||||
/** @var SubscriberChangesNotifier */
|
||||
private $subscriberChangesNotifier;
|
||||
|
||||
/** @var FilterHandler */
|
||||
private $filterHandler;
|
||||
|
||||
/** @var SubscribersRepository */
|
||||
private $subscribersRepository;
|
||||
|
||||
/** @var SubscriberCustomFieldRepository */
|
||||
private $subscriberCustomFieldRepository;
|
||||
|
||||
public function __construct(
|
||||
EntityManager $entityManager,
|
||||
SubscriberChangesNotifier $changesNotifier,
|
||||
FilterHandler $filterHandler,
|
||||
SubscribersRepository $subscribersRepository,
|
||||
SubscriberCustomFieldRepository $subscriberCustomFieldRepository
|
||||
) {
|
||||
$this->entityManager = $entityManager;
|
||||
$this->subscriberChangesNotifier = $changesNotifier;
|
||||
$this->filterHandler = $filterHandler;
|
||||
$this->subscribersRepository = $subscribersRepository;
|
||||
$this->subscriberCustomFieldRepository = $subscriberCustomFieldRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param class-string<object> $className
|
||||
* @return ClassMetadata<object>
|
||||
*/
|
||||
protected function getClassMetadata(string $className): ClassMetadata {
|
||||
return $this->entityManager->getClassMetadata($className);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param class-string<object> $className
|
||||
*/
|
||||
protected function getTableName(string $className): string {
|
||||
return $this->getClassMetadata($className)->getTableName();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param class-string<object> $className
|
||||
*/
|
||||
protected function getTableColumns(string $className): array {
|
||||
return $this->getClassMetadata($className)->getColumnNames();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param class-string<object> $className
|
||||
*/
|
||||
public function insertMultiple(
|
||||
string $className,
|
||||
array $columns,
|
||||
array $data
|
||||
): int {
|
||||
$tableName = $this->getTableName($className);
|
||||
|
||||
if (!$columns || !$data) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
$parameters = [];
|
||||
foreach ($data as $key => $item) {
|
||||
$paramNames = array_map(function (string $parameter) use ($key): string {
|
||||
return ":{$parameter}_{$key}";
|
||||
}, $columns);
|
||||
|
||||
foreach ($item as $columnKey => $column) {
|
||||
// We need to remove the colon character from the query parameter name that is passed to the query builder
|
||||
$parameters[substr($paramNames[$columnKey], 1)] = $column;
|
||||
}
|
||||
$rows[] = "(" . implode(', ', $paramNames) . ")";
|
||||
}
|
||||
|
||||
$count = (int)$this->entityManager->getConnection()->executeStatement("
|
||||
INSERT IGNORE INTO {$tableName} (`" . implode("`, `", $columns) . "`) VALUES
|
||||
" . implode(", \n", $rows) . "
|
||||
", $parameters);
|
||||
$this->notifyCreations($className, $columns, $data);
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param class-string<object> $className
|
||||
*/
|
||||
public function updateMultiple(
|
||||
string $className,
|
||||
array $columns,
|
||||
array $data,
|
||||
?DateTime $updatedAt = null
|
||||
): int {
|
||||
$tableName = $this->getTableName($className);
|
||||
$entityColumns = $this->getTableColumns($className);
|
||||
|
||||
if (!$columns || !$data) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$parameters = [];
|
||||
$parameterTypes = [];
|
||||
$keyColumns = self::KEY_COLUMNS_FOR_BULK_UPDATE[$className] ?? [];
|
||||
if (!$keyColumns) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$keyColumnsConditions = [];
|
||||
foreach ($keyColumns as $keyColumn) {
|
||||
$columnIndex = array_search($keyColumn, $columns);
|
||||
$parameters[$keyColumn] = array_map(function(array $row) use ($columnIndex) {
|
||||
return $row[$columnIndex];
|
||||
}, $data);
|
||||
$parameterTypes[$keyColumn] = ArrayParameterType::STRING;
|
||||
$keyColumnsConditions[] = "{$keyColumn} IN (:{$keyColumn})";
|
||||
}
|
||||
|
||||
$ignoredColumns = self::IGNORED_COLUMNS_FOR_BULK_UPDATE[$className] ?? ['created_at'];
|
||||
$updateColumns = array_map(function($columnName) use ($keyColumns, $columns, $data, &$parameters): string {
|
||||
$values = [];
|
||||
foreach ($data as $index => $row) {
|
||||
$keyCondition = array_map(function($keyColumn) use ($index, $row, $columns, &$parameters): string {
|
||||
$parameters["{$keyColumn}_{$index}"] = $row[array_search($keyColumn, $columns)];
|
||||
return "{$keyColumn} = :{$keyColumn}_{$index}";
|
||||
}, $keyColumns);
|
||||
$values[] = "WHEN " . implode(' AND ', $keyCondition) . " THEN :{$columnName}_{$index}";
|
||||
$parameters["{$columnName}_{$index}"] = $row[array_search($columnName, $columns)];
|
||||
}
|
||||
return "{$columnName} = (CASE " . implode("\n", $values) . " END)";
|
||||
}, array_diff($columns, $ignoredColumns));
|
||||
|
||||
if ($updatedAt && in_array('updated_at', $entityColumns, true)) {
|
||||
$parameters['updated_at'] = $updatedAt;
|
||||
$updateColumns[] = "updated_at = :updated_at";
|
||||
}
|
||||
|
||||
// we want to reset deleted_at for updated rows
|
||||
if (in_array('deleted_at', $entityColumns, true)) {
|
||||
$updateColumns[] = 'deleted_at = NULL';
|
||||
}
|
||||
|
||||
$count = (int)$this->entityManager->getConnection()->executeStatement("
|
||||
UPDATE {$tableName} SET
|
||||
" . implode(", \n", $updateColumns) . "
|
||||
WHERE
|
||||
" . implode(' AND ', $keyColumnsConditions) . "
|
||||
", $parameters, $parameterTypes);
|
||||
$this->notifyUpdates($className, $columns, $data);
|
||||
if ($className === SubscriberEntity::class) {
|
||||
$this->subscribersRepository->refreshAll();
|
||||
}
|
||||
if ($className === SubscriberCustomFieldEntity::class) {
|
||||
$this->subscriberCustomFieldRepository->refreshAll();
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
|
||||
public function getSubscribersBatchBySegment(?SegmentEntity $segment, int $limit, int $offset = 0): array {
|
||||
$subscriberSegmentTable = $this->getTableName(SubscriberSegmentEntity::class);
|
||||
$subscriberTable = $this->getTableName(SubscriberEntity::class);
|
||||
$segmentTable = $this->getTableName(SegmentEntity::class);
|
||||
|
||||
$qb = $this->createSubscribersQueryBuilder($limit, $offset);
|
||||
$qb = $this->addSubscriberCustomFieldsToQueryBuilder($qb);
|
||||
|
||||
if (!$segment || $segment->isStatic()) {
|
||||
// joining with the segments table is used only when there is no segment or for static segments.
|
||||
// this because dynamic segments don't have a corresponding entry in the segments table.
|
||||
$qb->leftJoin($subscriberSegmentTable, $segmentTable, $segmentTable, "{$segmentTable}.id = {$subscriberSegmentTable}.segment_id")
|
||||
->groupBy("{$subscriberTable}.id, {$segmentTable}.id");
|
||||
}
|
||||
|
||||
if (!$segment) {
|
||||
// if there are subscribers who do not belong to any segment, use
|
||||
// a CASE function to group them under "Not In Segment"
|
||||
$qb->addSelect("'" . __('Not In Segment', 'mailpoet') . "' AS segment_name")
|
||||
->leftJoin($subscriberTable, $subscriberTable, 's2', "{$subscriberTable}.id = s2.id")
|
||||
->leftJoin('s2', $subscriberSegmentTable, 'ssg2', "s2.id = ssg2.subscriber_id AND ssg2.status = :statusSubscribed AND {$segmentTable}.id <> ssg2.segment_id")
|
||||
->leftJoin('ssg2', $segmentTable, 'sg2', 'ssg2.segment_id = sg2.id AND sg2.deleted_at IS NULL')
|
||||
->andWhere("({$subscriberSegmentTable}.status != :statusSubscribed OR {$subscriberSegmentTable}.id IS NULL OR {$segmentTable}.deleted_at IS NOT NULL)")
|
||||
->andWhere('sg2.id IS NULL')
|
||||
->setParameter('statusSubscribed', SubscriberEntity::STATUS_SUBSCRIBED);
|
||||
} elseif ($segment->isStatic()) {
|
||||
$qb->addSelect("{$segmentTable}.name AS segment_name")
|
||||
->andWhere("{$subscriberSegmentTable}.segment_id = :segmentId")
|
||||
->setParameter('segmentId', $segment->getId());
|
||||
} else {
|
||||
// Dynamic segments don't have a relation to the segment table,
|
||||
// So we need to use a placeholder
|
||||
$qb->addSelect(":segmentName AS segment_name")
|
||||
->setParameter('segmentName', $segment->getName())
|
||||
->groupBy("{$subscriberTable}.id");
|
||||
$qb = $this->filterHandler->apply($qb, $segment);
|
||||
}
|
||||
|
||||
$statement = $qb->execute();
|
||||
return $statement instanceof Result ? $statement->fetchAll() : [];
|
||||
}
|
||||
|
||||
private function createSubscribersQueryBuilder(int $limit, int $offset): QueryBuilder {
|
||||
$subscriberSegmentTable = $this->getTableName(SubscriberSegmentEntity::class);
|
||||
$subscriberTable = $this->getTableName(SubscriberEntity::class);
|
||||
|
||||
return $this->entityManager->getConnection()->createQueryBuilder()
|
||||
->select("
|
||||
{$subscriberTable}.first_name,
|
||||
{$subscriberTable}.last_name,
|
||||
{$subscriberTable}.email,
|
||||
{$subscriberTable}.subscribed_ip,
|
||||
{$subscriberTable}.confirmed_at,
|
||||
{$subscriberTable}.confirmed_ip,
|
||||
{$subscriberTable}.created_at,
|
||||
{$subscriberTable}.status AS global_status,
|
||||
{$subscriberSegmentTable}.status AS list_status
|
||||
")
|
||||
->from($subscriberTable)
|
||||
->leftJoin($subscriberTable, $subscriberSegmentTable, $subscriberSegmentTable, "{$subscriberTable}.id = {$subscriberSegmentTable}.subscriber_id")
|
||||
->andWhere("{$subscriberTable}.deleted_at IS NULL")
|
||||
->orderBy("{$subscriberTable}.id")
|
||||
->setFirstResult($offset)
|
||||
->setMaxResults($limit);
|
||||
}
|
||||
|
||||
private function addSubscriberCustomFieldsToQueryBuilder(QueryBuilder $qb): QueryBuilder {
|
||||
$segmentsTable = $this->getTableName(SubscriberEntity::class);
|
||||
$customFieldsTable = $this->getTableName(CustomFieldEntity::class);
|
||||
$subscriberCustomFieldTable = $this->getTableName(SubscriberCustomFieldEntity::class);
|
||||
|
||||
$customFields = $this->entityManager->getConnection()->createQueryBuilder()
|
||||
->select("{$customFieldsTable}.*")
|
||||
->from($customFieldsTable)
|
||||
->execute();
|
||||
|
||||
$customFields = $customFields->fetchAll();
|
||||
|
||||
foreach ($customFields as $customField) {
|
||||
$customFieldId = "customFieldId{$customField['id']}export";
|
||||
$qb->addSelect("MAX(CASE WHEN {$customFieldsTable}.id = :{$customFieldId} THEN {$subscriberCustomFieldTable}.value END) AS :{$customFieldId}")
|
||||
->setParameter($customFieldId, $customField['id']);
|
||||
}
|
||||
|
||||
$qb->leftJoin($segmentsTable, $subscriberCustomFieldTable, $subscriberCustomFieldTable, "{$segmentsTable}.id = {$subscriberCustomFieldTable}.subscriber_id")
|
||||
->leftJoin($subscriberCustomFieldTable, $customFieldsTable, $customFieldsTable, "{$customFieldsTable}.id = {$subscriberCustomFieldTable}.custom_field_id");
|
||||
|
||||
return $qb;
|
||||
}
|
||||
|
||||
private function notifyCreations(string $className, array $columns, array $data): void {
|
||||
if ($className === SubscriberEntity::class) {
|
||||
$ids = $this->getIdsByEmail($className, $columns, $data);
|
||||
$this->subscriberChangesNotifier->subscribersCreated($ids);
|
||||
}
|
||||
}
|
||||
|
||||
private function notifyUpdates(string $className, array $columns, array $data): void {
|
||||
if ($className === SubscriberEntity::class) {
|
||||
$ids = $this->getIdsByEmail($className, $columns, $data);
|
||||
$this->subscriberChangesNotifier->subscribersUpdated($ids);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param class-string<object> $className
|
||||
*/
|
||||
private function getIdsByEmail(string $className, array $columns, array $data): array {
|
||||
$tableName = $this->getTableName($className);
|
||||
$emailIndex = array_search('email', $columns);
|
||||
if ($emailIndex === false) {
|
||||
return [];
|
||||
}
|
||||
$emails = [];
|
||||
foreach ($data as $item) {
|
||||
$emails[] = $item[$emailIndex];
|
||||
}
|
||||
// get ids for updated/created rows
|
||||
return $this->entityManager->getConnection()->executeQuery("
|
||||
SELECT id
|
||||
FROM {$tableName}
|
||||
WHERE email IN (:emails)
|
||||
", ['emails' => $emails], ['emails' => ArrayParameterType::STRING])->fetchFirstColumn();
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Subscribers\ImportExport\PersonalDataExporters;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Statistics\StatisticsClicksRepository;
|
||||
|
||||
class NewsletterClicksExporter extends NewsletterStatsBaseExporter {
|
||||
protected $statsClassName = StatisticsClicksRepository::class;
|
||||
|
||||
protected function getEmailStats(array $row) {
|
||||
$newsletterData = [];
|
||||
$newsletterData[] = [
|
||||
'name' => __('Email subject', 'mailpoet'),
|
||||
'value' => $row['newsletterRenderedSubject'],
|
||||
];
|
||||
$newsletterData[] = [
|
||||
'name' => __('Timestamp of the click event', 'mailpoet'),
|
||||
'value' => $row['createdAt']->format("Y-m-d H:i:s"),
|
||||
];
|
||||
$newsletterData[] = [
|
||||
'name' => __('URL', 'mailpoet'),
|
||||
'value' => $row['url'],
|
||||
];
|
||||
|
||||
if (!is_null($row['userAgent'])) {
|
||||
$userAgent = $row['userAgent'];
|
||||
} else {
|
||||
$userAgent = __('Unknown', 'mailpoet');
|
||||
}
|
||||
|
||||
$newsletterData[] = [
|
||||
'name' => __('User-agent', 'mailpoet'),
|
||||
'value' => $userAgent,
|
||||
];
|
||||
|
||||
return [
|
||||
'group_id' => 'mailpoet-newsletter-clicks',
|
||||
'group_label' => __('MailPoet Emails Clicks', 'mailpoet'),
|
||||
'item_id' => 'newsletter-' . $row['id'],
|
||||
'data' => $newsletterData,
|
||||
];
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Subscribers\ImportExport\PersonalDataExporters;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Statistics\StatisticsOpensRepository;
|
||||
|
||||
class NewsletterOpensExporter extends NewsletterStatsBaseExporter {
|
||||
protected $statsClassName = StatisticsOpensRepository::class;
|
||||
|
||||
protected function getEmailStats(array $row): array {
|
||||
$newsletterData = [];
|
||||
$newsletterData[] = [
|
||||
'name' => __('Email subject', 'mailpoet'),
|
||||
'value' => $row['newsletterRenderedSubject'],
|
||||
];
|
||||
$newsletterData[] = [
|
||||
'name' => __('Timestamp of the open event', 'mailpoet'),
|
||||
'value' => $row['createdAt']->format("Y-m-d H:i:s"),
|
||||
];
|
||||
|
||||
if (!is_null($row['userAgent'])) {
|
||||
$userAgent = $row['userAgent'];
|
||||
} else {
|
||||
$userAgent = __('Unknown', 'mailpoet');
|
||||
}
|
||||
|
||||
$newsletterData[] = [
|
||||
'name' => __('User-agent', 'mailpoet'),
|
||||
'value' => $userAgent,
|
||||
];
|
||||
|
||||
return [
|
||||
'group_id' => 'mailpoet-newsletter-opens',
|
||||
'group_label' => __('MailPoet Emails Opens', 'mailpoet'),
|
||||
'item_id' => 'newsletter-' . $row['id'],
|
||||
'data' => $newsletterData,
|
||||
];
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Subscribers\ImportExport\PersonalDataExporters;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\DI\ContainerWrapper;
|
||||
use MailPoet\Entities\SubscriberEntity;
|
||||
use MailPoet\Statistics\StatisticsClicksRepository;
|
||||
use MailPoet\Statistics\StatisticsOpensRepository;
|
||||
use MailPoet\Subscribers\SubscribersRepository;
|
||||
|
||||
abstract class NewsletterStatsBaseExporter {
|
||||
|
||||
const LIMIT = 100;
|
||||
|
||||
/** @var class-string<StatisticsClicksRepository>|class-string<StatisticsOpensRepository> */
|
||||
protected $statsClassName;
|
||||
|
||||
protected $subscriberRepository;
|
||||
|
||||
public function __construct(
|
||||
SubscribersRepository $subscribersRepository
|
||||
) {
|
||||
$this->subscriberRepository = $subscribersRepository;
|
||||
}
|
||||
|
||||
public function export($email, $page = 1): array {
|
||||
$data = [];
|
||||
$subscriber = $this->subscriberRepository->findOneBy(['email' => trim($email)]);
|
||||
|
||||
if ($subscriber instanceof SubscriberEntity) {
|
||||
$data = $this->getSubscriberData($subscriber, $page);
|
||||
}
|
||||
|
||||
return [
|
||||
'data' => $data,
|
||||
'done' => count($data) < self::LIMIT,
|
||||
];
|
||||
}
|
||||
|
||||
private function getSubscriberData(SubscriberEntity $subscriber, $page): array {
|
||||
$result = [];
|
||||
|
||||
$statsClass = ContainerWrapper::getInstance()->get($this->statsClassName);
|
||||
|
||||
/** @var array[] $statistics */
|
||||
$statistics = $statsClass->getAllForSubscriber($subscriber)
|
||||
->setMaxResults(self::LIMIT)
|
||||
->setFirstResult(self::LIMIT * ($page - 1))
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
foreach ($statistics as $row) {
|
||||
$result[] = $this->getEmailStats($row);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
protected abstract function getEmailStats(array $row);
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Subscribers\ImportExport\PersonalDataExporters;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Entities\SubscriberEntity;
|
||||
use MailPoet\Newsletter\NewslettersRepository;
|
||||
use MailPoet\Newsletter\Statistics\NewsletterStatisticsRepository;
|
||||
use MailPoet\Newsletter\Url as NewsletterUrl;
|
||||
use MailPoet\Subscribers\SubscribersRepository;
|
||||
use MailPoet\WP\DateTime;
|
||||
|
||||
class NewslettersExporter {
|
||||
|
||||
const LIMIT = 100;
|
||||
|
||||
/** @var NewsletterUrl */
|
||||
private $newsletterUrl;
|
||||
|
||||
/*** @var SubscribersRepository */
|
||||
private $subscribersRepository;
|
||||
|
||||
/*** @var NewslettersRepository */
|
||||
private $newslettersRepository;
|
||||
|
||||
/*** @var NewsletterStatisticsRepository */
|
||||
private $newsletterStatisticsRepository;
|
||||
|
||||
public function __construct(
|
||||
NewsletterUrl $newsletterUrl,
|
||||
SubscribersRepository $subscribersRepository,
|
||||
NewslettersRepository $newslettersRepository,
|
||||
NewsletterStatisticsRepository $newsletterStatisticsRepository
|
||||
) {
|
||||
$this->newsletterUrl = $newsletterUrl;
|
||||
$this->subscribersRepository = $subscribersRepository;
|
||||
$this->newslettersRepository = $newslettersRepository;
|
||||
$this->newsletterStatisticsRepository = $newsletterStatisticsRepository;
|
||||
}
|
||||
|
||||
public function export($email, $page = 1) {
|
||||
$data = $this->exportSubscriber($this->subscribersRepository->findOneBy(['email' => trim($email)]), $page);
|
||||
return [
|
||||
'data' => $data,
|
||||
'done' => count($data) < self::LIMIT,
|
||||
];
|
||||
}
|
||||
|
||||
private function exportSubscriber(?SubscriberEntity $subscriber, $page) {
|
||||
if (!$subscriber) return [];
|
||||
|
||||
$result = [];
|
||||
|
||||
$statistics = $this->newsletterStatisticsRepository->getAllForSubscriber(
|
||||
$subscriber,
|
||||
self::LIMIT,
|
||||
self::LIMIT * ($page - 1)
|
||||
);
|
||||
|
||||
$newsletters = $this->loadNewsletters($statistics);
|
||||
|
||||
foreach ($statistics as $row) {
|
||||
$result[] = $this->exportNewsletter($row, $newsletters, $subscriber);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function exportNewsletter($statisticsRow, $newsletters, $subscriber) {
|
||||
$newsletterData = [];
|
||||
$newsletterData[] = [
|
||||
'name' => __('Email subject', 'mailpoet'),
|
||||
'value' => $statisticsRow['newsletter_rendered_subject'],
|
||||
];
|
||||
$newsletterData[] = [
|
||||
'name' => __('Sent at', 'mailpoet'),
|
||||
'value' => $statisticsRow['sent_at']
|
||||
? $statisticsRow['sent_at']->format(DateTime::DEFAULT_DATE_TIME_FORMAT)
|
||||
: '',
|
||||
];
|
||||
if (!empty($statisticsRow['opened_at'])) {
|
||||
$newsletterData[] = [
|
||||
'name' => __('Opened', 'mailpoet'),
|
||||
'value' => 'Yes',
|
||||
];
|
||||
$newsletterData[] = [
|
||||
'name' => __('Opened at', 'mailpoet'),
|
||||
'value' => $statisticsRow['opened_at']->format(DateTime::DEFAULT_DATE_TIME_FORMAT),
|
||||
];
|
||||
} else {
|
||||
$newsletterData[] = [
|
||||
'name' => __('Opened', 'mailpoet'),
|
||||
'value' => __('No', 'mailpoet'),
|
||||
];
|
||||
}
|
||||
if (isset($newsletters[$statisticsRow['newsletter_id']])) {
|
||||
$newsletterData[] = [
|
||||
'name' => __('Email preview', 'mailpoet'),
|
||||
'value' => $this->newsletterUrl->getViewInBrowserUrl(
|
||||
$newsletters[$statisticsRow['newsletter_id']],
|
||||
$subscriber
|
||||
),
|
||||
];
|
||||
}
|
||||
return [
|
||||
'group_id' => 'mailpoet-newsletters',
|
||||
'group_label' => __('MailPoet Emails Sent', 'mailpoet'),
|
||||
'item_id' => 'newsletter-' . $statisticsRow['newsletter_id'],
|
||||
'data' => $newsletterData,
|
||||
];
|
||||
}
|
||||
|
||||
private function loadNewsletters($statistics) {
|
||||
$newsletterIds = array_map(function ($statisticsRow) {
|
||||
return $statisticsRow['newsletter_id'];
|
||||
}, $statistics);
|
||||
|
||||
if (empty($newsletterIds)) return [];
|
||||
|
||||
$newsletters = $this->newslettersRepository->findBy(['id' => $newsletterIds]);
|
||||
|
||||
$result = [];
|
||||
foreach ($newsletters as $newsletter) {
|
||||
$result[$newsletter->getId()] = $newsletter;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Subscribers\ImportExport\PersonalDataExporters;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Entities\SubscriberEntity;
|
||||
use MailPoet\Entities\SubscriberSegmentEntity;
|
||||
use MailPoet\Subscribers\SubscribersRepository;
|
||||
use MailPoet\WP\DateTime;
|
||||
|
||||
class SegmentsExporter {
|
||||
|
||||
/*** @var SubscribersRepository */
|
||||
private $subscribersRepository;
|
||||
|
||||
public function __construct(
|
||||
SubscribersRepository $subscribersRepository
|
||||
) {
|
||||
$this->subscribersRepository = $subscribersRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $email
|
||||
* @return array(data: mixed[], done: boolean)
|
||||
*/
|
||||
public function export(string $email): array {
|
||||
return [
|
||||
'data' => $this->exportSubscriber($this->subscribersRepository->findOneBy(['email' => trim($email)])),
|
||||
'done' => true,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param SubscriberEntity|null $subscriber
|
||||
* @return mixed[]
|
||||
*/
|
||||
private function exportSubscriber(?SubscriberEntity $subscriber): array {
|
||||
if (!$subscriber) return [];
|
||||
|
||||
$result = [];
|
||||
$segments = $subscriber->getSubscriberSegments();
|
||||
|
||||
foreach ($segments as $segment) {
|
||||
$result[] = $this->exportSegment($segment);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param SubscriberSegmentEntity $segment
|
||||
* @return mixed[]
|
||||
*/
|
||||
private function exportSegment(SubscriberSegmentEntity $segment): array {
|
||||
$segmentData = [];
|
||||
$segmentData[] = [
|
||||
'name' => __('List name', 'mailpoet'),
|
||||
'value' => $segment->getSegment() ? $segment->getSegment()->getName() : '',
|
||||
];
|
||||
$segmentData[] = [
|
||||
'name' => __('Subscription status', 'mailpoet'),
|
||||
'value' => $segment->getStatus(),
|
||||
];
|
||||
$segmentData[] = [
|
||||
'name' => __('Timestamp of the subscription (or last change of the subscription status)', 'mailpoet'),
|
||||
'value' => $segment->getUpdatedAt()->format(DateTime::DEFAULT_DATE_TIME_FORMAT),
|
||||
];
|
||||
return [
|
||||
'group_id' => 'mailpoet-lists',
|
||||
'group_label' => __('MailPoet Mailing Lists', 'mailpoet'),
|
||||
'item_id' => 'list-' . ($segment->getSegment() ? $segment->getSegment()->getId() : ''),
|
||||
'data' => $segmentData,
|
||||
];
|
||||
}
|
||||
}
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Subscribers\ImportExport\PersonalDataExporters;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\CustomFields\CustomFieldsRepository;
|
||||
use MailPoet\Entities\CustomFieldEntity;
|
||||
use MailPoet\Entities\SubscriberEntity;
|
||||
use MailPoet\Subscribers\Source;
|
||||
use MailPoet\Subscribers\SubscribersRepository;
|
||||
use MailPoet\WP\DateTime;
|
||||
|
||||
class SubscriberExporter {
|
||||
/*** @var SubscribersRepository */
|
||||
private $subscribersRepository;
|
||||
|
||||
/*** @var CustomFieldsRepository */
|
||||
private $customFieldsRepository;
|
||||
|
||||
/*** @var array<int, string> */
|
||||
private $customFields = [];
|
||||
|
||||
public function __construct(
|
||||
SubscribersRepository $subscribersRepository,
|
||||
CustomFieldsRepository $customFieldsRepository
|
||||
) {
|
||||
$this->subscribersRepository = $subscribersRepository;
|
||||
$this->customFieldsRepository = $customFieldsRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $email
|
||||
* @return array(data: mixed[], done: boolean)
|
||||
*/
|
||||
public function export(string $email): array {
|
||||
return [
|
||||
'data' => $this->exportSubscriber($this->subscribersRepository->findOneBy(['email' => trim($email)])),
|
||||
'done' => true,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param SubscriberEntity|null $subscriber
|
||||
* @return array|mixed[][]
|
||||
*/
|
||||
private function exportSubscriber(?SubscriberEntity $subscriber): array {
|
||||
if (!$subscriber) return [];
|
||||
return [[
|
||||
'group_id' => 'mailpoet-subscriber',
|
||||
'group_label' => __('MailPoet Subscriber Data', 'mailpoet'),
|
||||
'item_id' => 'subscriber-' . $subscriber->getId(),
|
||||
'data' => $this->getSubscriberExportData($subscriber),
|
||||
]];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param SubscriberEntity $subscriber
|
||||
* @return mixed[][]
|
||||
*/
|
||||
private function getSubscriberExportData(SubscriberEntity $subscriber): array {
|
||||
$customFields = $this->getCustomFields();
|
||||
$result = [
|
||||
[
|
||||
'name' => __('First Name', 'mailpoet'),
|
||||
'value' => $subscriber->getFirstName(),
|
||||
],
|
||||
[
|
||||
'name' => __('Last Name', 'mailpoet'),
|
||||
'value' => $subscriber->getLastName(),
|
||||
],
|
||||
[
|
||||
'name' => __('Email', 'mailpoet'),
|
||||
'value' => $subscriber->getEmail(),
|
||||
],
|
||||
[
|
||||
'name' => __('Status', 'mailpoet'),
|
||||
'value' => $subscriber->getStatus(),
|
||||
],
|
||||
];
|
||||
if ($subscriber->getSubscribedIp()) {
|
||||
$result[] = [
|
||||
'name' => __('Subscribed IP', 'mailpoet'),
|
||||
'value' => $subscriber->getSubscribedIp(),
|
||||
];
|
||||
}
|
||||
if ($subscriber->getConfirmedIp()) {
|
||||
$result[] = [
|
||||
'name' => __('Confirmed IP', 'mailpoet'),
|
||||
'value' => $subscriber->getConfirmedIp(),
|
||||
];
|
||||
}
|
||||
$result[] = [
|
||||
'name' => __('Created at', 'mailpoet'),
|
||||
'value' => $subscriber->getCreatedAt()
|
||||
? $subscriber->getCreatedAt()->format(DateTime::DEFAULT_DATE_TIME_FORMAT)
|
||||
: '',
|
||||
];
|
||||
|
||||
foreach ($subscriber->getSubscriberCustomFields() as $subscriberCustomField) {
|
||||
$customField = $subscriberCustomField->getCustomField();
|
||||
if (!$customField instanceof CustomFieldEntity) {
|
||||
continue;
|
||||
}
|
||||
$customFieldId = $customField->getId();
|
||||
if (isset($this->getCustomFields()[$customFieldId])) {
|
||||
$result[] = [
|
||||
'name' => $customFields[$customFieldId],
|
||||
'value' => $subscriberCustomField->getValue(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$result[] = [
|
||||
'name' => __("Subscriber's subscription source", 'mailpoet'),
|
||||
'value' => $this->formatSource($subscriber->getSource()),
|
||||
];
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function getCustomFields(): array {
|
||||
if (!empty($this->customFields)) {
|
||||
return $this->customFields;
|
||||
}
|
||||
|
||||
$fields = $this->customFieldsRepository->findAll();
|
||||
foreach ($fields as $field) {
|
||||
$this->customFields[$field->getId()] = $field->getName();
|
||||
}
|
||||
return $this->customFields;
|
||||
}
|
||||
|
||||
private function formatSource(string $source): string {
|
||||
switch ($source) {
|
||||
case Source::WORDPRESS_USER:
|
||||
return __('Subscriber information synchronized via WP user sync', 'mailpoet');
|
||||
case Source::FORM:
|
||||
return __('Subscription via a MailPoet subscription form', 'mailpoet');
|
||||
case Source::API:
|
||||
return __('Added by a 3rd party via MailPoet API', 'mailpoet');
|
||||
case Source::ADMINISTRATOR:
|
||||
return __('Created by the administrator', 'mailpoet');
|
||||
case Source::IMPORTED:
|
||||
return __('Imported by the administrator', 'mailpoet');
|
||||
default:
|
||||
return __('Unknown', 'mailpoet');
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
<?php
|
||||
@@ -0,0 +1 @@
|
||||
<?php
|
||||
@@ -0,0 +1,189 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Subscribers;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Entities\ScheduledTaskEntity;
|
||||
use MailPoet\Entities\ScheduledTaskSubscriberEntity;
|
||||
use MailPoet\Entities\SendingQueueEntity;
|
||||
use MailPoet\Entities\SubscriberEntity;
|
||||
use MailPoetVendor\Carbon\Carbon;
|
||||
use MailPoetVendor\Doctrine\DBAL\ArrayParameterType;
|
||||
use MailPoetVendor\Doctrine\DBAL\ParameterType;
|
||||
use MailPoetVendor\Doctrine\ORM\EntityManager;
|
||||
|
||||
class InactiveSubscribersController {
|
||||
|
||||
const UNOPENED_EMAILS_THRESHOLD = 3;
|
||||
const LIFETIME_EMAILS_THRESHOLD = 10;
|
||||
|
||||
private $processedTaskIdsTableCreated = false;
|
||||
|
||||
/** @var EntityManager */
|
||||
private $entityManager;
|
||||
|
||||
public function __construct(
|
||||
EntityManager $entityManager
|
||||
) {
|
||||
$this->entityManager = $entityManager;
|
||||
}
|
||||
|
||||
public function markInactiveSubscribers(int $daysToInactive, int $batchSize, ?int $startId = null, ?int $unopenedEmails = self::UNOPENED_EMAILS_THRESHOLD) {
|
||||
$thresholdDate = $this->getThresholdDate($daysToInactive);
|
||||
return $this->deactivateSubscribers($thresholdDate, $batchSize, $startId, $unopenedEmails);
|
||||
}
|
||||
|
||||
public function markActiveSubscribers(int $daysToInactive, int $batchSize): int {
|
||||
$thresholdDate = $this->getThresholdDate($daysToInactive);
|
||||
return $this->activateSubscribers($thresholdDate, $batchSize);
|
||||
}
|
||||
|
||||
public function reactivateInactiveSubscribers(): void {
|
||||
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
|
||||
$reactivateAllInactiveQuery = "
|
||||
UPDATE {$subscribersTable} SET status = :statusSubscribed WHERE status = :statusInactive
|
||||
";
|
||||
$this->entityManager->getConnection()->executeQuery($reactivateAllInactiveQuery, [
|
||||
'statusSubscribed' => SubscriberEntity::STATUS_SUBSCRIBED,
|
||||
'statusInactive' => SubscriberEntity::STATUS_INACTIVE,
|
||||
]);
|
||||
}
|
||||
|
||||
private function getThresholdDate(int $daysToInactive): Carbon {
|
||||
$now = new Carbon();
|
||||
return $now->subDays($daysToInactive);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int
|
||||
*/
|
||||
private function deactivateSubscribers(Carbon $thresholdDate, int $batchSize, ?int $startId = null, ?int $unopenedEmails = self::UNOPENED_EMAILS_THRESHOLD) {
|
||||
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
|
||||
$scheduledTasksTable = $this->entityManager->getClassMetadata(ScheduledTaskEntity::class)->getTableName();
|
||||
$scheduledTaskSubscribersTable = $this->entityManager->getClassMetadata(ScheduledTaskSubscriberEntity::class)->getTableName();
|
||||
$sendingQueuesTable = $this->entityManager->getClassMetadata(SendingQueueEntity::class)->getTableName();
|
||||
$connection = $this->entityManager->getConnection();
|
||||
|
||||
$thresholdDateIso = $thresholdDate->toDateTimeString();
|
||||
$dayAgo = new Carbon();
|
||||
$dayAgoIso = $dayAgo->subDay()->toDateTimeString();
|
||||
|
||||
// Temporary table with processed tasks from threshold date up to yesterday
|
||||
$processedTaskIdsTable = 'inactive_task_ids';
|
||||
if (!$this->processedTaskIdsTableCreated) {
|
||||
$processedTaskIdsTableSql = "
|
||||
CREATE TEMPORARY TABLE IF NOT EXISTS {$processedTaskIdsTable}
|
||||
(INDEX task_id_ids (id), PRIMARY KEY (`id`))
|
||||
SELECT DISTINCT task_id as id FROM {$sendingQueuesTable} as sq
|
||||
JOIN {$scheduledTasksTable} as st ON sq.task_id = st.id
|
||||
WHERE st.processed_at > :thresholdDate
|
||||
AND st.processed_at < :dayAgo
|
||||
";
|
||||
$connection->executeQuery($processedTaskIdsTableSql, [
|
||||
'thresholdDate' => $thresholdDateIso,
|
||||
'dayAgo' => $dayAgoIso,
|
||||
]);
|
||||
$this->processedTaskIdsTableCreated = true;
|
||||
}
|
||||
|
||||
// Select subscribers who received at least a number of emails after threshold date and subscribed before that
|
||||
$startId = (int)$startId;
|
||||
$endId = $startId + $batchSize;
|
||||
$lifetimeEmailsThreshold = self::LIFETIME_EMAILS_THRESHOLD;
|
||||
$inactiveSubscriberIdsTmpTable = 'inactive_subscriber_ids';
|
||||
$connection->executeQuery(
|
||||
"
|
||||
CREATE TEMPORARY TABLE IF NOT EXISTS {$inactiveSubscriberIdsTmpTable}
|
||||
(UNIQUE subscriber_id (id), PRIMARY KEY (`id`))
|
||||
SELECT s.id FROM {$subscribersTable} as s
|
||||
JOIN {$scheduledTaskSubscribersTable} as sts USE INDEX (subscriber_id) ON s.id = sts.subscriber_id
|
||||
JOIN {$processedTaskIdsTable} task_ids ON task_ids.id = sts.task_id
|
||||
WHERE s.last_subscribed_at < :thresholdDate
|
||||
AND s.status = :status
|
||||
AND s.id >= :startId
|
||||
AND s.id < :endId
|
||||
AND s.email_count >= {$lifetimeEmailsThreshold}
|
||||
GROUP BY s.id
|
||||
HAVING count(s.id) >= :unopenedEmailsThreshold
|
||||
",
|
||||
[
|
||||
'thresholdDate' => $thresholdDateIso,
|
||||
'status' => SubscriberEntity::STATUS_SUBSCRIBED,
|
||||
'startId' => $startId,
|
||||
'endId' => $endId,
|
||||
'unopenedEmailsThreshold' => $unopenedEmails,
|
||||
]
|
||||
);
|
||||
|
||||
$result = $connection->executeQuery("
|
||||
SELECT isi.id FROM {$inactiveSubscriberIdsTmpTable} isi
|
||||
LEFT OUTER JOIN {$subscribersTable} as s ON isi.id = s.id AND GREATEST(
|
||||
COALESCE(s.last_engagement_at, '0'),
|
||||
COALESCE(s.last_subscribed_at, '0'),
|
||||
COALESCE(s.created_at, '0')
|
||||
) > :thresholdDate
|
||||
WHERE s.id IS NULL
|
||||
", [
|
||||
'thresholdDate' => $thresholdDateIso,
|
||||
]);
|
||||
$idsToDeactivate = $result->fetchAllAssociative();
|
||||
|
||||
$connection->executeQuery("DROP TABLE {$inactiveSubscriberIdsTmpTable}");
|
||||
|
||||
$idsToDeactivate = array_map(
|
||||
function ($id) {
|
||||
return (int)$id['id'];
|
||||
},
|
||||
$idsToDeactivate
|
||||
);
|
||||
if (!count($idsToDeactivate)) {
|
||||
return 0;
|
||||
}
|
||||
$connection->executeQuery("UPDATE {$subscribersTable} SET status = :statusInactive WHERE id IN (:idsToDeactivate)", [
|
||||
'statusInactive' => SubscriberEntity::STATUS_INACTIVE,
|
||||
'idsToDeactivate' => $idsToDeactivate,
|
||||
], ['idsToDeactivate' => ArrayParameterType::INTEGER]);
|
||||
return count($idsToDeactivate);
|
||||
}
|
||||
|
||||
private function activateSubscribers(Carbon $thresholdDate, int $batchSize): int {
|
||||
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
|
||||
$connection = $this->entityManager->getConnection();
|
||||
|
||||
$idsToActivate = $connection->executeQuery("
|
||||
SELECT s.id
|
||||
FROM {$subscribersTable} s
|
||||
LEFT OUTER JOIN {$subscribersTable} s2 ON s.id = s2.id AND GREATEST(
|
||||
COALESCE(s2.last_engagement_at, '0'),
|
||||
COALESCE(s2.last_subscribed_at, '0'),
|
||||
COALESCE(s2.created_at, '0')
|
||||
) > :thresholdDate
|
||||
WHERE s.last_subscribed_at < :thresholdDate
|
||||
AND s.status = :statusInactive
|
||||
AND s2.id IS NOT NULL
|
||||
GROUP BY s.id
|
||||
LIMIT :batchSize
|
||||
", [
|
||||
'thresholdDate' => $thresholdDate,
|
||||
'statusInactive' => SubscriberEntity::STATUS_INACTIVE,
|
||||
'batchSize' => $batchSize,
|
||||
], ['batchSize' => ParameterType::INTEGER])->fetchAllAssociative();
|
||||
|
||||
$idsToActivate = array_map(
|
||||
function($id) {
|
||||
return (int)$id['id'];
|
||||
},
|
||||
$idsToActivate
|
||||
);
|
||||
if (!count($idsToActivate)) {
|
||||
return 0;
|
||||
}
|
||||
$connection->executeQuery("UPDATE {$subscribersTable} SET status = :statusSubscribed WHERE id IN (:idsToActivate)", [
|
||||
'statusSubscribed' => SubscriberEntity::STATUS_SUBSCRIBED,
|
||||
'idsToActivate' => $idsToActivate,
|
||||
], ['idsToActivate' => ArrayParameterType::INTEGER]);
|
||||
return count($idsToActivate);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Subscribers;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Entities\SubscriberEntity;
|
||||
|
||||
class LinkTokens {
|
||||
private const OBSOLETE_LINK_TOKEN_LENGTH = 6;
|
||||
|
||||
/** @var SubscribersRepository */
|
||||
private $subscribersRepository;
|
||||
|
||||
public function __construct(
|
||||
SubscribersRepository $subscribersRepository
|
||||
) {
|
||||
$this->subscribersRepository = $subscribersRepository;
|
||||
}
|
||||
|
||||
public function getToken(SubscriberEntity $subscriber): string {
|
||||
if ($subscriber->getLinkToken() === null) {
|
||||
$subscriber->setLinkToken($this->generateToken($subscriber->getEmail()));
|
||||
$this->subscribersRepository->flush();
|
||||
}
|
||||
return (string)$subscriber->getLinkToken();
|
||||
}
|
||||
|
||||
public function verifyToken(SubscriberEntity $subscriber, string $token) {
|
||||
$databaseToken = $this->getToken($subscriber);
|
||||
$requestToken = substr($token, 0, strlen($databaseToken));
|
||||
return hash_equals($databaseToken, $requestToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only for backward compatibility for old tokens
|
||||
*/
|
||||
private function generateToken(?string $email, int $length = self::OBSOLETE_LINK_TOKEN_LENGTH): ?string {
|
||||
if ($email !== null) {
|
||||
$authKey = '';
|
||||
if (defined('AUTH_KEY')) {
|
||||
$authKey = AUTH_KEY;
|
||||
}
|
||||
return substr(md5($authKey . $email), 0, $length);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Subscribers;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Config\Renderer;
|
||||
use MailPoet\Entities\SegmentEntity;
|
||||
use MailPoet\Entities\SubscriberEntity;
|
||||
use MailPoet\Mailer\MailerFactory;
|
||||
use MailPoet\Mailer\MetaInfo;
|
||||
use MailPoet\Settings\SettingsController;
|
||||
use MailPoet\WP\Functions as WPFunctions;
|
||||
|
||||
class NewSubscriberNotificationMailer {
|
||||
const SETTINGS_KEY = 'subscriber_email_notification';
|
||||
|
||||
/** @var MailerFactory */
|
||||
private $mailerFactory;
|
||||
|
||||
/** @var Renderer */
|
||||
private $renderer;
|
||||
|
||||
/** @var SettingsController */
|
||||
private $settings;
|
||||
|
||||
/** @var MetaInfo */
|
||||
private $mailerMetaInfo;
|
||||
|
||||
public function __construct(
|
||||
MailerFactory $mailerFactory,
|
||||
Renderer $renderer,
|
||||
SettingsController $settings
|
||||
) {
|
||||
$this->mailerFactory = $mailerFactory;
|
||||
$this->renderer = $renderer;
|
||||
$this->settings = $settings;
|
||||
$this->mailerMetaInfo = new MetaInfo();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param SubscriberEntity $subscriber
|
||||
* @param SegmentEntity[] $segments
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function send(SubscriberEntity $subscriber, array $segments): void {
|
||||
$settings = $this->settings->get(NewSubscriberNotificationMailer::SETTINGS_KEY);
|
||||
if ($this->isDisabled($settings)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$extraParams = [
|
||||
'meta' => $this->mailerMetaInfo->getNewSubscriberNotificationMetaInfo(),
|
||||
];
|
||||
$this->mailerFactory->getDefaultMailer()->send($this->constructNewsletter($subscriber, $segments), $settings['address'], $extraParams);
|
||||
} catch (\Exception $e) {
|
||||
if (WP_DEBUG) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function isDisabled($settings) {
|
||||
if (!is_array($settings)) {
|
||||
return true;
|
||||
}
|
||||
if (!isset($settings['enabled'])) {
|
||||
return true;
|
||||
}
|
||||
if (!isset($settings['address'])) {
|
||||
return true;
|
||||
}
|
||||
if (empty(trim($settings['address']))) {
|
||||
return true;
|
||||
}
|
||||
return !(bool)$settings['enabled'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param SubscriberEntity $subscriber
|
||||
* @param SegmentEntity[] $segments
|
||||
*
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function constructNewsletter(SubscriberEntity $subscriber, array $segments) {
|
||||
$segmentNames = $this->getSegmentNames($segments);
|
||||
$context = [
|
||||
'subscriber_email' => $subscriber->getEmail(),
|
||||
'segments_names' => $segmentNames,
|
||||
'link_settings' => WPFunctions::get()->getSiteUrl(null, '/wp-admin/admin.php?page=mailpoet-settings'),
|
||||
'link_premium' => WPFunctions::get()->getSiteUrl(null, '/wp-admin/admin.php?page=mailpoet-upgrade'),
|
||||
];
|
||||
return [
|
||||
// translators: %s is name of the segment.
|
||||
'subject' => sprintf(__('New subscriber to %s', 'mailpoet'), $segmentNames),
|
||||
'body' => [
|
||||
'html' => $this->renderer->render('emails/newSubscriberNotification.html', $context),
|
||||
'text' => $this->renderer->render('emails/newSubscriberNotification.txt', $context),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param SegmentEntity[] $segments
|
||||
* @return string
|
||||
*/
|
||||
private function getSegmentNames(array $segments): string {
|
||||
$names = [];
|
||||
foreach ($segments as $segment) {
|
||||
$names[] = $segment->getName();
|
||||
}
|
||||
return implode(', ', $names);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Subscribers;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use Exception;
|
||||
use MailPoet\CustomFields\CustomFieldsRepository;
|
||||
use MailPoet\Entities\FormEntity;
|
||||
|
||||
class RequiredCustomFieldValidator {
|
||||
/** @var CustomFieldsRepository */
|
||||
private $customFieldRepository;
|
||||
|
||||
public function __construct(
|
||||
CustomFieldsRepository $customFieldRepository
|
||||
) {
|
||||
$this->customFieldRepository = $customFieldRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $data
|
||||
* @param FormEntity|null $form
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function validate(array $data, FormEntity $form = null) {
|
||||
$allCustomFields = $this->getCustomFields($form);
|
||||
foreach ($allCustomFields as $customFieldId => $customFieldName) {
|
||||
if ($this->isCustomFieldMissing($customFieldId, $data)) {
|
||||
throw new Exception(
|
||||
// translators: %s is the name of the custom field.
|
||||
sprintf(__('Missing value for custom field "%s"', 'mailpoet'), $customFieldName)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function isCustomFieldMissing(int $customFieldId, array $data): bool {
|
||||
if (!array_key_exists($customFieldId, $data) && !array_key_exists('cf_' . $customFieldId, $data)) {
|
||||
return true;
|
||||
}
|
||||
if (isset($data[$customFieldId]) && !$data[$customFieldId]) {
|
||||
return true;
|
||||
}
|
||||
if (isset($data['cf_' . $customFieldId]) && !$data['cf_' . $customFieldId]) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private function getCustomFields(FormEntity $form = null): array {
|
||||
$result = [];
|
||||
|
||||
if ($form) {
|
||||
$ids = $this->getFormCustomFieldIds($form);
|
||||
if (!$ids) {
|
||||
return [];
|
||||
}
|
||||
$requiredCustomFields = $this->customFieldRepository->findBy(['id' => $ids]);
|
||||
} else {
|
||||
$requiredCustomFields = $this->customFieldRepository->findAll();
|
||||
}
|
||||
|
||||
foreach ($requiredCustomFields as $customField) {
|
||||
$params = $customField->getParams();
|
||||
if (is_array($params) && isset($params['required']) && $params['required']) {
|
||||
$result[$customField->getId()] = $customField->getName();
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int[]
|
||||
*/
|
||||
private function getFormCustomFieldIds(FormEntity $form): array {
|
||||
$formFields = $form->getBlocksByTypes(FormEntity::FORM_FIELD_TYPES);
|
||||
$customFieldIds = [];
|
||||
foreach ($formFields as $formField) {
|
||||
if (isset($formField['id']) && is_numeric($formField['id'])) {
|
||||
$customFieldIds[] = (int)$formField['id'];
|
||||
}
|
||||
}
|
||||
return $customFieldIds;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Subscribers;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
class Source {
|
||||
const FORM = 'form';
|
||||
const IMPORTED = 'imported';
|
||||
const ADMINISTRATOR = 'administrator';
|
||||
const API = 'api';
|
||||
const WORDPRESS_USER = 'wordpress_user';
|
||||
const WOOCOMMERCE_USER = 'woocommerce_user';
|
||||
const WOOCOMMERCE_CHECKOUT = 'woocommerce_checkout';
|
||||
const UNKNOWN = 'unknown';
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Subscribers\Statistics;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Newsletter\Statistics\WooCommerceRevenue;
|
||||
|
||||
class SubscriberStatistics {
|
||||
|
||||
/** @var int */
|
||||
private $clickCount;
|
||||
|
||||
/** @var int */
|
||||
private $openCount;
|
||||
|
||||
/** @var int */
|
||||
private $machineOpenCount;
|
||||
|
||||
/** @var int */
|
||||
private $totalSentCount;
|
||||
|
||||
/** @var WooCommerceRevenue|null */
|
||||
private $wooCommerceRevenue;
|
||||
|
||||
public function __construct(
|
||||
$clickCount,
|
||||
$openCount,
|
||||
$machineOpenCount,
|
||||
$totalSentCount,
|
||||
$wooCommerceRevenue = null
|
||||
) {
|
||||
$this->clickCount = $clickCount;
|
||||
$this->openCount = $openCount;
|
||||
$this->machineOpenCount = $machineOpenCount;
|
||||
$this->totalSentCount = $totalSentCount;
|
||||
$this->wooCommerceRevenue = $wooCommerceRevenue;
|
||||
}
|
||||
|
||||
public function getClickCount(): int {
|
||||
return $this->clickCount;
|
||||
}
|
||||
|
||||
public function getOpenCount(): int {
|
||||
return $this->openCount;
|
||||
}
|
||||
|
||||
public function getMachineOpenCount(): int {
|
||||
return $this->machineOpenCount;
|
||||
}
|
||||
|
||||
public function getTotalSentCount(): int {
|
||||
return $this->totalSentCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return WooCommerceRevenue|null
|
||||
*/
|
||||
public function getWooCommerceRevenue() {
|
||||
return $this->wooCommerceRevenue;
|
||||
}
|
||||
}
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Subscribers\Statistics;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Doctrine\Repository;
|
||||
use MailPoet\Entities\StatisticsClickEntity;
|
||||
use MailPoet\Entities\StatisticsNewsletterEntity;
|
||||
use MailPoet\Entities\StatisticsOpenEntity;
|
||||
use MailPoet\Entities\StatisticsWooCommercePurchaseEntity;
|
||||
use MailPoet\Entities\SubscriberEntity;
|
||||
use MailPoet\Entities\UserAgentEntity;
|
||||
use MailPoet\Newsletter\Statistics\WooCommerceRevenue;
|
||||
use MailPoet\Settings\TrackingConfig;
|
||||
use MailPoet\WooCommerce\Helper as WCHelper;
|
||||
use MailPoetVendor\Carbon\Carbon;
|
||||
use MailPoetVendor\Doctrine\ORM\EntityManager;
|
||||
use MailPoetVendor\Doctrine\ORM\QueryBuilder;
|
||||
|
||||
/**
|
||||
* @extends Repository<SubscriberEntity>
|
||||
*/
|
||||
class SubscriberStatisticsRepository extends Repository {
|
||||
|
||||
/** @var WCHelper */
|
||||
private $wcHelper;
|
||||
|
||||
/** @var TrackingConfig */
|
||||
private $trackingConfig;
|
||||
|
||||
public function __construct(
|
||||
EntityManager $entityManager,
|
||||
WCHelper $wcHelper,
|
||||
TrackingConfig $trackingConfig
|
||||
) {
|
||||
parent::__construct($entityManager);
|
||||
$this->wcHelper = $wcHelper;
|
||||
$this->trackingConfig = $trackingConfig;
|
||||
}
|
||||
|
||||
protected function getEntityClassName() {
|
||||
return SubscriberEntity::class;
|
||||
}
|
||||
|
||||
public function getStatistics(SubscriberEntity $subscriber, ?Carbon $startTime = null) {
|
||||
return new SubscriberStatistics(
|
||||
$this->getStatisticsClickCount($subscriber, $startTime),
|
||||
$this->getStatisticsOpenCount($subscriber, $startTime),
|
||||
$this->getStatisticsMachineOpenCount($subscriber, $startTime),
|
||||
$this->getTotalSentCount($subscriber, $startTime),
|
||||
$this->getWooCommerceRevenue($subscriber, $startTime)
|
||||
);
|
||||
}
|
||||
|
||||
public function getStatisticsClickCount(SubscriberEntity $subscriber, ?Carbon $startTime = null): int {
|
||||
$queryBuilder = $this->getStatisticsCountQuery(StatisticsClickEntity::class, $subscriber);
|
||||
if ($startTime) {
|
||||
$this->applyDateConstraint($queryBuilder, $startTime);
|
||||
}
|
||||
return (int)$queryBuilder
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
}
|
||||
|
||||
public function getStatisticsOpenCountQuery(SubscriberEntity $subscriber, ?Carbon $startTime = null): QueryBuilder {
|
||||
$queryBuilder = $this->getStatisticsCountQuery(StatisticsOpenEntity::class, $subscriber);
|
||||
if ($startTime) {
|
||||
$this->applyDateConstraint($queryBuilder, $startTime);
|
||||
}
|
||||
return $queryBuilder;
|
||||
}
|
||||
|
||||
public function getStatisticsOpenCount(SubscriberEntity $subscriber, ?Carbon $startTime = null): int {
|
||||
$queryBuilder = $this->getStatisticsOpenCountQuery($subscriber, $startTime);
|
||||
if ($this->trackingConfig->areOpensSeparated()) {
|
||||
$queryBuilder
|
||||
->andWhere('(stats.userAgentType = :userAgentType)')
|
||||
->setParameter('userAgentType', UserAgentEntity::USER_AGENT_TYPE_HUMAN);
|
||||
}
|
||||
return (int)$queryBuilder
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
}
|
||||
|
||||
public function getStatisticsMachineOpenCount(SubscriberEntity $subscriber, ?Carbon $startTime = null): int {
|
||||
return (int)$this->getStatisticsOpenCountQuery($subscriber, $startTime)
|
||||
->andWhere('(stats.userAgentType = :userAgentType)')
|
||||
->setParameter('userAgentType', UserAgentEntity::USER_AGENT_TYPE_MACHINE)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
}
|
||||
|
||||
public function getTotalSentCount(SubscriberEntity $subscriber, ?Carbon $startTime = null): int {
|
||||
$queryBuilder = $this->getStatisticsCountQuery(StatisticsNewsletterEntity::class, $subscriber);
|
||||
if ($startTime) {
|
||||
$queryBuilder
|
||||
->andWhere('stats.sentAt >= :dateTime')
|
||||
->setParameter('dateTime', $startTime);
|
||||
}
|
||||
return (int)$queryBuilder
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
}
|
||||
|
||||
public function getStatisticsCountQuery(string $entityName, SubscriberEntity $subscriber): QueryBuilder {
|
||||
return $this->entityManager->createQueryBuilder()
|
||||
->select('COUNT(DISTINCT stats.newsletter) as cnt')
|
||||
->from($entityName, 'stats')
|
||||
->where('stats.subscriber = :subscriber')
|
||||
->setParameter('subscriber', $subscriber);
|
||||
}
|
||||
|
||||
public function getWooCommerceRevenue(SubscriberEntity $subscriber, ?Carbon $startTime = null): ?WooCommerceRevenue {
|
||||
if (!$this->wcHelper->isWooCommerceActive()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$revenueStatus = $this->wcHelper->getPurchaseStates();
|
||||
$currency = $this->wcHelper->getWoocommerceCurrency();
|
||||
$queryBuilder = $this->entityManager->createQueryBuilder()
|
||||
->select('stats.orderPriceTotal')
|
||||
->from(StatisticsWooCommercePurchaseEntity::class, 'stats')
|
||||
->where('stats.subscriber = :subscriber')
|
||||
->andWhere('stats.orderCurrency = :currency')
|
||||
->setParameter('subscriber', $subscriber)
|
||||
->setParameter('currency', $currency)
|
||||
->andWhere('stats.status IN (:revenue_status)')
|
||||
->setParameter('subscriber', $subscriber)
|
||||
->setParameter('currency', $currency)
|
||||
->setParameter('revenue_status', $revenueStatus)
|
||||
->groupBy('stats.orderId, stats.orderPriceTotal');
|
||||
if ($startTime) {
|
||||
$queryBuilder
|
||||
->andWhere('stats.createdAt >= :dateTime')
|
||||
->setParameter('dateTime', $startTime);
|
||||
}
|
||||
$purchases =
|
||||
$queryBuilder->getQuery()
|
||||
->getResult();
|
||||
$sum = array_sum(array_column($purchases, 'orderPriceTotal'));
|
||||
return new WooCommerceRevenue(
|
||||
$currency,
|
||||
(float)$sum,
|
||||
count($purchases),
|
||||
$this->wcHelper
|
||||
);
|
||||
}
|
||||
|
||||
private function applyDateConstraint(QueryBuilder $queryBuilder, Carbon $startTime): QueryBuilder {
|
||||
$queryBuilder->join(StatisticsNewsletterEntity::class, 'sent_stats', 'WITH', 'stats.newsletter = sent_stats.newsletter AND stats.subscriber = sent_stats.subscriber AND sent_stats.sentAt >= :dateTime')
|
||||
->setParameter('dateTime', $startTime);
|
||||
|
||||
return $queryBuilder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<?php
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Subscribers;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Entities\SubscriberEntity;
|
||||
use MailPoet\Newsletter\Scheduler\WelcomeScheduler;
|
||||
use MailPoet\Segments\SegmentsRepository;
|
||||
use MailPoet\Settings\SettingsController;
|
||||
use MailPoet\Util\Helpers;
|
||||
|
||||
class SubscriberActions {
|
||||
|
||||
/** @var SettingsController */
|
||||
private $settings;
|
||||
|
||||
/** @var NewSubscriberNotificationMailer */
|
||||
private $newSubscriberNotificationMailer;
|
||||
|
||||
/** @var ConfirmationEmailMailer */
|
||||
private $confirmationEmailMailer;
|
||||
|
||||
/** @var WelcomeScheduler */
|
||||
private $welcomeScheduler;
|
||||
|
||||
/** @var SubscriberSaveController */
|
||||
private $subscriberSaveController;
|
||||
|
||||
/** @var SubscribersRepository */
|
||||
private $subscribersRepository;
|
||||
|
||||
/** @var SubscriberSegmentRepository */
|
||||
private $subscriberSegmentRepository;
|
||||
|
||||
/** @var SegmentsRepository */
|
||||
private $segmentsRepository;
|
||||
|
||||
public function __construct(
|
||||
SettingsController $settings,
|
||||
NewSubscriberNotificationMailer $newSubscriberNotificationMailer,
|
||||
ConfirmationEmailMailer $confirmationEmailMailer,
|
||||
WelcomeScheduler $welcomeScheduler,
|
||||
SegmentsRepository $segmentsRepository,
|
||||
SubscriberSaveController $subscriberSaveController,
|
||||
SubscribersRepository $subscribersRepository,
|
||||
SubscriberSegmentRepository $subscriberSegmentRepository
|
||||
) {
|
||||
$this->settings = $settings;
|
||||
$this->newSubscriberNotificationMailer = $newSubscriberNotificationMailer;
|
||||
$this->confirmationEmailMailer = $confirmationEmailMailer;
|
||||
$this->welcomeScheduler = $welcomeScheduler;
|
||||
$this->subscriberSaveController = $subscriberSaveController;
|
||||
$this->subscribersRepository = $subscribersRepository;
|
||||
$this->subscriberSegmentRepository = $subscriberSegmentRepository;
|
||||
$this->segmentsRepository = $segmentsRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns SubscriberEntity and associative array with some metadata related to the subscription (e.g. ['confirmationEmailResult' => $exception])
|
||||
* @return array{0: SubscriberEntity, 1: array{confirmationEmailResult: bool|\Exception}}
|
||||
*/
|
||||
public function subscribe($subscriberData = [], $segmentIds = []): array {
|
||||
// filter out keys from the subscriber_data array
|
||||
// that should not be editable when subscribing
|
||||
$subscriberData = $this->subscriberSaveController->filterOutReservedColumns($subscriberData);
|
||||
|
||||
$signupConfirmationEnabled = (bool)$this->settings->get(
|
||||
'signup_confirmation.enabled'
|
||||
);
|
||||
|
||||
$subscriberData['subscribed_ip'] = Helpers::getIP();
|
||||
|
||||
$subscriber = $this->subscribersRepository->findOneBy(['email' => $subscriberData['email']]);
|
||||
if (!$subscriber && !isset($subscriberData['source'])) {
|
||||
$subscriberData['source'] = Source::FORM;
|
||||
}
|
||||
|
||||
if (!$subscriber || !$signupConfirmationEnabled) {
|
||||
// create new subscriber or update if no confirmation is required
|
||||
$subscriber = $this->subscriberSaveController->createOrUpdate($subscriberData, $subscriber);
|
||||
// custom fields should use the same approach as the subscriber main data that means to wait on confirmation
|
||||
$this->subscriberSaveController->updateCustomFields($subscriberData, $subscriber);
|
||||
} else {
|
||||
// store subscriber data to be updated after confirmation
|
||||
$unconfirmedData = $this->subscriberSaveController->filterOutReservedColumns($subscriberData);
|
||||
$unconfirmedData = json_encode($unconfirmedData);
|
||||
$subscriber->setUnconfirmedData($unconfirmedData ?: null);
|
||||
}
|
||||
|
||||
// restore trashed subscriber
|
||||
if ($subscriber->getDeletedAt()) {
|
||||
$subscriber->setDeletedAt(null);
|
||||
}
|
||||
|
||||
// set status depending on signup confirmation setting
|
||||
if ($subscriber->getStatus() !== SubscriberEntity::STATUS_SUBSCRIBED) {
|
||||
if ($signupConfirmationEnabled === true) {
|
||||
$subscriber->setStatus(SubscriberEntity::STATUS_UNCONFIRMED);
|
||||
} else {
|
||||
$subscriber->setStatus(SubscriberEntity::STATUS_SUBSCRIBED);
|
||||
}
|
||||
}
|
||||
|
||||
$this->subscribersRepository->flush();
|
||||
|
||||
$metaData = ['confirmationEmailResult' => false];
|
||||
// link subscriber to segments
|
||||
$segments = $this->segmentsRepository->findBy(['id' => $segmentIds]);
|
||||
$this->subscriberSegmentRepository->subscribeToSegments($subscriber, $segments);
|
||||
|
||||
try {
|
||||
$metaData['confirmationEmailResult'] = $this->confirmationEmailMailer->sendConfirmationEmailOnce($subscriber);
|
||||
} catch (\Exception $e) {
|
||||
$metaData['confirmationEmailResult'] = $e;
|
||||
}
|
||||
|
||||
// We want to send the notification on subscribe only when signupConfirmation is disabled
|
||||
if ($signupConfirmationEnabled === false && $subscriber->getStatus() === SubscriberEntity::STATUS_SUBSCRIBED) {
|
||||
$this->newSubscriberNotificationMailer->send($subscriber, $this->segmentsRepository->findBy(['id' => $segmentIds]));
|
||||
|
||||
$this->welcomeScheduler->scheduleSubscriberWelcomeNotification(
|
||||
$subscriber->getId(),
|
||||
$segmentIds
|
||||
);
|
||||
}
|
||||
|
||||
return [$subscriber, $metaData];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Subscribers;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Doctrine\Repository;
|
||||
use MailPoet\Entities\CustomFieldEntity;
|
||||
use MailPoet\Entities\SubscriberCustomFieldEntity;
|
||||
use MailPoet\Entities\SubscriberEntity;
|
||||
|
||||
/**
|
||||
* @extends Repository<SubscriberCustomFieldEntity>
|
||||
*/
|
||||
class SubscriberCustomFieldRepository extends Repository {
|
||||
protected function getEntityClassName() {
|
||||
return SubscriberCustomFieldEntity::class;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|array|null $value
|
||||
*/
|
||||
public function createOrUpdate(SubscriberEntity $subscriber, CustomFieldEntity $customField, $value): SubscriberCustomFieldEntity {
|
||||
$subscriberCustomField = $this->findOneBy(['subscriber' => $subscriber, 'customField' => $customField]);
|
||||
if ($subscriberCustomField instanceof SubscriberCustomFieldEntity) {
|
||||
$subscriberCustomField->setValue($value);
|
||||
} else {
|
||||
$subscriberCustomField = new SubscriberCustomFieldEntity($subscriber, $customField, $value);
|
||||
$this->entityManager->persist($subscriberCustomField);
|
||||
$subscriber->getSubscriberCustomFields()->add($subscriberCustomField);
|
||||
}
|
||||
$this->entityManager->flush();
|
||||
return $subscriberCustomField;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Subscribers;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Doctrine\Repository;
|
||||
use MailPoet\Entities\SubscriberIPEntity;
|
||||
use MailPoetVendor\Carbon\Carbon;
|
||||
|
||||
/**
|
||||
* @extends Repository<SubscriberIPEntity>
|
||||
*/
|
||||
class SubscriberIPsRepository extends Repository {
|
||||
protected function getEntityClassName() {
|
||||
return SubscriberIPEntity::class;
|
||||
}
|
||||
|
||||
public function findOneByIPAndCreatedAtAfterTimeInSeconds(string $ip, int $seconds): ?SubscriberIPEntity {
|
||||
return $this->entityManager->createQueryBuilder()
|
||||
->select('sip')
|
||||
->from(SubscriberIPEntity::class, 'sip')
|
||||
->where('sip.ip = :ip')
|
||||
->andWhere('sip.createdAt >= :timeThreshold')
|
||||
->setParameter('ip', $ip)
|
||||
->setParameter('timeThreshold', (new Carbon())->subSeconds($seconds))
|
||||
->setMaxResults(1)
|
||||
->getQuery()
|
||||
->getOneOrNullResult();
|
||||
}
|
||||
|
||||
public function getCountByIPAndCreatedAtAfterTimeInSeconds(string $ip, int $seconds): int {
|
||||
return (int)$this->entityManager->createQueryBuilder()
|
||||
->select('COUNT(sip)')
|
||||
->from(SubscriberIPEntity::class, 'sip')
|
||||
->where('sip.ip = :ip')
|
||||
->andWhere('sip.createdAt >= :timeThreshold')
|
||||
->setParameter('ip', $ip)
|
||||
->setParameter('timeThreshold', (new Carbon())->subSeconds($seconds))
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
}
|
||||
|
||||
public function deleteCreatedAtBeforeTimeInSeconds(int $seconds): int {
|
||||
return (int)$this->entityManager->createQueryBuilder()
|
||||
->delete()
|
||||
->from(SubscriberIPEntity::class, 'sip')
|
||||
->where('sip.createdAt < :timeThreshold')
|
||||
->setParameter('timeThreshold', (new Carbon())->subSeconds($seconds))
|
||||
->getQuery()
|
||||
->execute();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Subscribers;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Entities\SegmentEntity;
|
||||
use MailPoet\Entities\SubscriberEntity;
|
||||
use MailPoet\Entities\TagEntity;
|
||||
use MailPoet\Listing\ListingDefinition;
|
||||
use MailPoet\Listing\ListingRepository;
|
||||
use MailPoet\Segments\DynamicSegments\FilterHandler;
|
||||
use MailPoet\Segments\SegmentSubscribersRepository;
|
||||
use MailPoet\Util\Helpers;
|
||||
use MailPoetVendor\Doctrine\DBAL\Query\QueryBuilder as DBALQueryBuilder;
|
||||
use MailPoetVendor\Doctrine\ORM\EntityManager;
|
||||
use MailPoetVendor\Doctrine\ORM\Query\Expr\Join;
|
||||
use MailPoetVendor\Doctrine\ORM\QueryBuilder;
|
||||
|
||||
class SubscriberListingRepository extends ListingRepository {
|
||||
public const FILTER_WITHOUT_LIST = 'without-list';
|
||||
|
||||
const DEFAULT_SORT_BY = 'createdAt';
|
||||
|
||||
private static $supportedStatuses = [
|
||||
SubscriberEntity::STATUS_SUBSCRIBED,
|
||||
SubscriberEntity::STATUS_UNSUBSCRIBED,
|
||||
SubscriberEntity::STATUS_INACTIVE,
|
||||
SubscriberEntity::STATUS_BOUNCED,
|
||||
SubscriberEntity::STATUS_UNCONFIRMED,
|
||||
];
|
||||
|
||||
/** @var FilterHandler */
|
||||
private $dynamicSegmentsFilter;
|
||||
|
||||
/** @var EntityManager */
|
||||
private $entityManager;
|
||||
|
||||
/** @var SegmentSubscribersRepository */
|
||||
private $segmentSubscribersRepository;
|
||||
|
||||
/** @var SubscribersCountsController */
|
||||
private $subscribersCountsController;
|
||||
|
||||
/** @var null | ListingDefinition */
|
||||
private $definition = null;
|
||||
|
||||
public function __construct(
|
||||
EntityManager $entityManager,
|
||||
FilterHandler $dynamicSegmentsFilter,
|
||||
SegmentSubscribersRepository $segmentSubscribersRepository,
|
||||
SubscribersCountsController $subscribersCountsController
|
||||
) {
|
||||
parent::__construct($entityManager);
|
||||
$this->dynamicSegmentsFilter = $dynamicSegmentsFilter;
|
||||
$this->entityManager = $entityManager;
|
||||
$this->segmentSubscribersRepository = $segmentSubscribersRepository;
|
||||
$this->subscribersCountsController = $subscribersCountsController;
|
||||
}
|
||||
|
||||
public function getData(ListingDefinition $definition): array {
|
||||
$this->definition = $definition;
|
||||
$dynamicSegment = $this->getDynamicSegmentFromFilters($definition);
|
||||
if ($dynamicSegment === null) {
|
||||
return parent::getData($definition);
|
||||
}
|
||||
return $this->getDataForDynamicSegment($definition, $dynamicSegment);
|
||||
}
|
||||
|
||||
public function getCount(ListingDefinition $definition): int {
|
||||
$this->definition = $definition;
|
||||
$dynamicSegment = $this->getDynamicSegmentFromFilters($definition);
|
||||
if ($dynamicSegment === null) {
|
||||
return parent::getCount($definition);
|
||||
}
|
||||
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
|
||||
$subscribersIdsQuery = $this->entityManager
|
||||
->getConnection()
|
||||
->createQueryBuilder()
|
||||
->select("count(DISTINCT $subscribersTable.id)")
|
||||
->from($subscribersTable);
|
||||
$subscribersIdsQuery = $this->applyConstraintsForDynamicSegment($subscribersIdsQuery, $definition, $dynamicSegment);
|
||||
return (int)$subscribersIdsQuery->execute()->fetchOne();
|
||||
}
|
||||
|
||||
public function getActionableIds(ListingDefinition $definition): array {
|
||||
$this->definition = $definition;
|
||||
$ids = $definition->getSelection();
|
||||
if (!empty($ids)) {
|
||||
return $ids;
|
||||
}
|
||||
$dynamicSegment = $this->getDynamicSegmentFromFilters($definition);
|
||||
if ($dynamicSegment === null) {
|
||||
return parent::getActionableIds($definition);
|
||||
}
|
||||
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
|
||||
$subscribersIdsQuery = $this->entityManager
|
||||
->getConnection()
|
||||
->createQueryBuilder()
|
||||
->select("DISTINCT $subscribersTable.id")
|
||||
->from($subscribersTable);
|
||||
$subscribersIdsQuery = $this->applyConstraintsForDynamicSegment($subscribersIdsQuery, $definition, $dynamicSegment);
|
||||
$idsStatement = $subscribersIdsQuery->execute();
|
||||
$result = $idsStatement->fetchAll();
|
||||
return array_column($result, 'id');
|
||||
}
|
||||
|
||||
protected function applySelectClause(QueryBuilder $queryBuilder) {
|
||||
$queryBuilder->select("PARTIAL s.{id,email,firstName,lastName,status,createdAt,updatedAt,countConfirmations,wpUserId,isWoocommerceUser,engagementScore,lastSubscribedAt}");
|
||||
}
|
||||
|
||||
protected function applyFromClause(QueryBuilder $queryBuilder) {
|
||||
$queryBuilder->from(SubscriberEntity::class, 's');
|
||||
}
|
||||
|
||||
protected function applyGroup(QueryBuilder $queryBuilder, string $group) {
|
||||
// include/exclude deleted
|
||||
if ($group === 'trash') {
|
||||
$queryBuilder->andWhere('s.deletedAt IS NOT NULL');
|
||||
} else {
|
||||
$queryBuilder->andWhere('s.deletedAt IS NULL');
|
||||
}
|
||||
|
||||
if (!in_array($group, self::$supportedStatuses)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!in_array($group, [SubscriberEntity::STATUS_SUBSCRIBED, SubscriberEntity::STATUS_UNSUBSCRIBED])) {
|
||||
$queryBuilder
|
||||
->andWhere('s.status = :status')
|
||||
->setParameter('status', $group);
|
||||
return;
|
||||
}
|
||||
|
||||
$segment = $this->definition && array_key_exists('segment', $this->definition->getFilters()) ? $this->entityManager->find(SegmentEntity::class, (int)$this->definition->getFilters()['segment']) : null;
|
||||
if (!$segment instanceof SegmentEntity || !$segment->isStatic()) {
|
||||
$queryBuilder
|
||||
->andWhere('s.status = :status')
|
||||
->setParameter('status', $group);
|
||||
return;
|
||||
}
|
||||
|
||||
$operator = $group === SubscriberEntity::STATUS_SUBSCRIBED ? 'AND' : 'OR';
|
||||
$queryBuilder
|
||||
->andWhere('(s.status = :status ' . $operator . ' ss.status = :status)')
|
||||
->setParameter('status', $group);
|
||||
}
|
||||
|
||||
protected function applySearch(QueryBuilder $queryBuilder, string $search, array $parameters = []) {
|
||||
$search = Helpers::escapeSearch($search);
|
||||
$queryBuilder
|
||||
->andWhere('s.email LIKE :search or s.firstName LIKE :search or s.lastName LIKE :search')
|
||||
->setParameter('search', "%$search%");
|
||||
}
|
||||
|
||||
protected function applyFilters(QueryBuilder $queryBuilder, array $filters) {
|
||||
if (isset($filters['segment'])) {
|
||||
if ($filters['segment'] === self::FILTER_WITHOUT_LIST) {
|
||||
$this->segmentSubscribersRepository->addConstraintsForSubscribersWithoutSegment($queryBuilder);
|
||||
} else {
|
||||
$segment = $this->entityManager->find(SegmentEntity::class, (int)$filters['segment']);
|
||||
if ($segment instanceof SegmentEntity && $segment->isStatic()) {
|
||||
$queryBuilder->join('s.subscriberSegments', 'ss', Join::WITH, 'ss.segment = :ssSegment')
|
||||
->setParameter('ssSegment', $segment->getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// filtering by minimal updated at
|
||||
if (isset($filters['minUpdatedAt']) && $filters['minUpdatedAt'] instanceof \DateTimeInterface) {
|
||||
$queryBuilder->andWhere('s.updatedAt >= :updatedAt')
|
||||
->setParameter('updatedAt', $filters['minUpdatedAt']);
|
||||
}
|
||||
|
||||
if (isset($filters['tag'])) {
|
||||
$tag = $this->entityManager->find(TagEntity::class, (int)$filters['tag']);
|
||||
if ($tag) {
|
||||
$queryBuilder->join('s.subscriberTags', 'st', Join::WITH, 'st.tag = :stTag')
|
||||
->setParameter('stTag', $tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function applyParameters(QueryBuilder $queryBuilder, array $parameters) {
|
||||
// nothing to do here
|
||||
}
|
||||
|
||||
protected function applySorting(QueryBuilder $queryBuilder, string $sortBy, string $sortOrder) {
|
||||
if (!$sortBy) {
|
||||
$sortBy = self::DEFAULT_SORT_BY;
|
||||
}
|
||||
$queryBuilder->addOrderBy("s.$sortBy", $sortOrder);
|
||||
}
|
||||
|
||||
public function getGroups(ListingDefinition $definition): array {
|
||||
$queryBuilder = clone $this->queryBuilder;
|
||||
$this->applyFromClause($queryBuilder);
|
||||
|
||||
$groupCounts = [
|
||||
SubscriberEntity::STATUS_SUBSCRIBED => 0,
|
||||
SubscriberEntity::STATUS_UNCONFIRMED => 0,
|
||||
SubscriberEntity::STATUS_UNSUBSCRIBED => 0,
|
||||
SubscriberEntity::STATUS_INACTIVE => 0,
|
||||
SubscriberEntity::STATUS_BOUNCED => 0,
|
||||
'trash' => 0,
|
||||
];
|
||||
foreach (array_keys($groupCounts) as $group) {
|
||||
$groupDefinition = $group === $definition->getGroup() ? $definition : new ListingDefinition(
|
||||
$group,
|
||||
$definition->getFilters(),
|
||||
$definition->getSearch(),
|
||||
$definition->getParameters(),
|
||||
$definition->getSortBy(),
|
||||
$definition->getSortOrder(),
|
||||
$definition->getOffset(),
|
||||
$definition->getLimit(),
|
||||
$definition->getSelection()
|
||||
);
|
||||
$groupCounts[$group] = $this->getCount($groupDefinition);
|
||||
}
|
||||
|
||||
$trashedCount = $groupCounts['trash'];
|
||||
unset($groupCounts['trash']);
|
||||
$totalCount = (int)array_sum($groupCounts);
|
||||
|
||||
return [
|
||||
[
|
||||
'name' => 'all',
|
||||
'label' => __('All', 'mailpoet'),
|
||||
'count' => $totalCount,
|
||||
],
|
||||
[
|
||||
'name' => SubscriberEntity::STATUS_SUBSCRIBED,
|
||||
'label' => __('Subscribed', 'mailpoet'),
|
||||
'count' => $groupCounts[SubscriberEntity::STATUS_SUBSCRIBED],
|
||||
],
|
||||
[
|
||||
'name' => SubscriberEntity::STATUS_UNCONFIRMED,
|
||||
'label' => __('Unconfirmed', 'mailpoet'),
|
||||
'count' => $groupCounts[SubscriberEntity::STATUS_UNCONFIRMED],
|
||||
],
|
||||
[
|
||||
'name' => SubscriberEntity::STATUS_UNSUBSCRIBED,
|
||||
'label' => __('Unsubscribed', 'mailpoet'),
|
||||
'count' => $groupCounts[SubscriberEntity::STATUS_UNSUBSCRIBED],
|
||||
],
|
||||
[
|
||||
'name' => SubscriberEntity::STATUS_INACTIVE,
|
||||
'label' => __('Inactive', 'mailpoet'),
|
||||
'count' => $groupCounts[SubscriberEntity::STATUS_INACTIVE],
|
||||
],
|
||||
[
|
||||
'name' => SubscriberEntity::STATUS_BOUNCED,
|
||||
'label' => __('Bounced', 'mailpoet'),
|
||||
'count' => $groupCounts[SubscriberEntity::STATUS_BOUNCED],
|
||||
],
|
||||
[
|
||||
'name' => 'trash',
|
||||
'label' => __('Trash', 'mailpoet'),
|
||||
'count' => $trashedCount,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function getFilters(ListingDefinition $definition): array {
|
||||
return [
|
||||
'segment' => $this->getSegmentFilter($definition),
|
||||
'tag' => $this->getTagsFilter($definition),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<array{label: string, value: string|int}>
|
||||
*/
|
||||
private function getSegmentFilter(ListingDefinition $definition): array {
|
||||
$group = $definition->getGroup();
|
||||
|
||||
$subscribersWithoutSegmentStats = $this->subscribersCountsController->getSubscribersWithoutSegmentStatisticsCount();
|
||||
$key = $group ?: 'all';
|
||||
$subscribersWithoutSegmentCount = $subscribersWithoutSegmentStats[$key];
|
||||
|
||||
$subscribersWithoutSegmentLabel = sprintf(
|
||||
// translators: %s is the number of subscribers without a list.
|
||||
__('Subscribers without a list (%s)', 'mailpoet'),
|
||||
number_format((float)$subscribersWithoutSegmentCount)
|
||||
);
|
||||
|
||||
$queryBuilder = clone $this->queryBuilder;
|
||||
$queryBuilder
|
||||
->select('s')
|
||||
->from(SegmentEntity::class, 's');
|
||||
if ($group !== 'trash') {
|
||||
$queryBuilder->andWhere('s.deletedAt IS NULL');
|
||||
}
|
||||
|
||||
// format segment list
|
||||
$allSubscribersList = [
|
||||
'label' => __('All Lists', 'mailpoet'),
|
||||
'value' => '',
|
||||
];
|
||||
|
||||
$withoutSegmentList = [
|
||||
'label' => $subscribersWithoutSegmentLabel,
|
||||
'value' => self::FILTER_WITHOUT_LIST,
|
||||
];
|
||||
|
||||
$segmentList = [];
|
||||
foreach ($queryBuilder->getQuery()->getResult() as $segment) {
|
||||
$key = $group ?: 'all';
|
||||
$count = $this->subscribersCountsController->getSegmentStatisticsCount($segment);
|
||||
$subscribersCount = (float)$count[$key];
|
||||
// filter segments without subscribers
|
||||
if (!$subscribersCount) {
|
||||
continue;
|
||||
}
|
||||
$segmentList[] = [
|
||||
'label' => sprintf('%s (%s)', $segment->getName(), number_format($subscribersCount)),
|
||||
'value' => $segment->getId(),
|
||||
];
|
||||
}
|
||||
|
||||
usort($segmentList, function($a, $b) {
|
||||
return strcasecmp($a['label'], $b['label']);
|
||||
});
|
||||
|
||||
array_unshift($segmentList, $allSubscribersList, $withoutSegmentList);
|
||||
return $segmentList;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{label: string, value: string|int}>
|
||||
*/
|
||||
private function getTagsFilter(ListingDefinition $definition): array {
|
||||
$group = $definition->getGroup();
|
||||
|
||||
$allTagsList = [
|
||||
'label' => __('All Tags', 'mailpoet'),
|
||||
'value' => '',
|
||||
];
|
||||
|
||||
$status = in_array($group, ['all', 'trash']) ? null : $group;
|
||||
$isDeleted = $group === 'trash';
|
||||
$tagsStatistics = $this->subscribersCountsController->getTagsStatisticsCount($status, $isDeleted);
|
||||
|
||||
$tagsList = [];
|
||||
foreach ($tagsStatistics as $tagStatistics) {
|
||||
$tagsList[] = [
|
||||
'label' => sprintf('%s (%s)', $tagStatistics['name'], number_format((float)$tagStatistics['subscribersCount'])),
|
||||
'value' => $tagStatistics['id'],
|
||||
];
|
||||
}
|
||||
|
||||
array_unshift($tagsList, $allTagsList);
|
||||
return $tagsList;
|
||||
}
|
||||
|
||||
private function getDataForDynamicSegment(ListingDefinition $definition, SegmentEntity $segment) {
|
||||
$queryBuilder = clone $this->queryBuilder;
|
||||
$sortBy = Helpers::underscoreToCamelCase($definition->getSortBy()) ?: self::DEFAULT_SORT_BY;
|
||||
$this->applySelectClause($queryBuilder);
|
||||
$this->applyFromClause($queryBuilder);
|
||||
|
||||
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
|
||||
$subscribersIdsQuery = $this->entityManager
|
||||
->getConnection()
|
||||
->createQueryBuilder()
|
||||
->select("DISTINCT $subscribersTable.id")
|
||||
->from($subscribersTable);
|
||||
$subscribersIdsQuery = $this->applyConstraintsForDynamicSegment($subscribersIdsQuery, $definition, $segment);
|
||||
$subscribersIdsQuery->orderBy("$subscribersTable." . Helpers::camelCaseToUnderscore($sortBy), $definition->getSortOrder());
|
||||
$subscribersIdsQuery->setFirstResult($definition->getOffset());
|
||||
$subscribersIdsQuery->setMaxResults($definition->getLimit());
|
||||
|
||||
$idsStatement = $subscribersIdsQuery->executeQuery();
|
||||
$result = $idsStatement->fetchAll();
|
||||
$ids = array_column($result, 'id');
|
||||
if (count($ids)) {
|
||||
$queryBuilder->andWhere('s.id IN (:subscriberIds)')
|
||||
->setParameter('subscriberIds', $ids);
|
||||
} else {
|
||||
$queryBuilder->andWhere('0 = 1'); // Don't return any subscribers if no ids found
|
||||
}
|
||||
$this->applySorting($queryBuilder, $sortBy, $definition->getSortOrder());
|
||||
return $queryBuilder->getQuery()->getResult();
|
||||
}
|
||||
|
||||
private function applyConstraintsForDynamicSegment(
|
||||
DBALQueryBuilder $subscribersQuery,
|
||||
ListingDefinition $definition,
|
||||
SegmentEntity $segment
|
||||
) {
|
||||
// Apply dynamic segments filters
|
||||
$subscribersQuery = $this->dynamicSegmentsFilter->apply($subscribersQuery, $segment);
|
||||
// Apply group, search to fetch only necessary ids
|
||||
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
|
||||
if ($definition->getSearch()) {
|
||||
$search = Helpers::escapeSearch((string)$definition->getSearch());
|
||||
$subscribersQuery
|
||||
->andWhere("$subscribersTable.email LIKE :search or $subscribersTable.first_name LIKE :search or $subscribersTable.last_name LIKE :search")
|
||||
->setParameter('search', "%$search%");
|
||||
}
|
||||
if ($definition->getGroup()) {
|
||||
if ($definition->getGroup() === 'trash') {
|
||||
$subscribersQuery->andWhere("$subscribersTable.deleted_at IS NOT NULL");
|
||||
} else {
|
||||
$subscribersQuery->andWhere("$subscribersTable.deleted_at IS NULL");
|
||||
}
|
||||
if (in_array($definition->getGroup(), self::$supportedStatuses)) {
|
||||
$subscribersQuery
|
||||
->andWhere("$subscribersTable.status = :status")
|
||||
->setParameter('status', $definition->getGroup());
|
||||
}
|
||||
}
|
||||
return $subscribersQuery;
|
||||
}
|
||||
|
||||
private function getDynamicSegmentFromFilters(ListingDefinition $definition): ?SegmentEntity {
|
||||
$filters = $definition->getFilters();
|
||||
if (!$filters || !isset($filters['segment'])) {
|
||||
return null;
|
||||
}
|
||||
if ($filters['segment'] === self::FILTER_WITHOUT_LIST) {
|
||||
return null;
|
||||
}
|
||||
$segment = $this->entityManager->find(SegmentEntity::class, (int)$filters['segment']);
|
||||
if (!$segment instanceof SegmentEntity) {
|
||||
return null;
|
||||
}
|
||||
return $segment->isStatic() ? null : $segment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Subscribers;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Entities\SubscriberEntity;
|
||||
use MailPoetVendor\Doctrine\ORM\EntityManager;
|
||||
|
||||
class SubscriberPersonalDataEraser {
|
||||
/** @var SubscribersRepository */
|
||||
private $subscribersRepository;
|
||||
|
||||
/** @var EntityManager */
|
||||
private $entityManager;
|
||||
|
||||
/** @var SubscriberCustomFieldRepository */
|
||||
private $subscriberCustomFieldRepository;
|
||||
|
||||
public function __construct(
|
||||
SubscribersRepository $subscribersRepository,
|
||||
EntityManager $entityManager,
|
||||
SubscriberCustomFieldRepository $subscriberCustomFieldRepository
|
||||
) {
|
||||
$this->subscribersRepository = $subscribersRepository;
|
||||
$this->entityManager = $entityManager;
|
||||
$this->subscriberCustomFieldRepository = $subscriberCustomFieldRepository;
|
||||
}
|
||||
|
||||
public function erase($email) {
|
||||
if (empty($email)) {
|
||||
return [
|
||||
'items_removed' => false,
|
||||
'items_retained' => false,
|
||||
'messages' => [],
|
||||
'done' => true,
|
||||
];
|
||||
}
|
||||
$subscriber = $this->subscribersRepository->findOneBy(['email' => trim($email)]);
|
||||
$itemRemoved = false;
|
||||
$itemsRetained = true;
|
||||
if ($subscriber) {
|
||||
$this->eraseCustomFields($subscriber);
|
||||
$this->anonymizeSubscriberData($subscriber);
|
||||
$itemRemoved = true;
|
||||
$itemsRetained = false;
|
||||
}
|
||||
|
||||
return [
|
||||
'items_removed' => $itemRemoved,
|
||||
'items_retained' => $itemsRetained,
|
||||
'messages' => [],
|
||||
'done' => true,
|
||||
];
|
||||
}
|
||||
|
||||
private function eraseCustomFields(SubscriberEntity $subscriber) {
|
||||
$customFields = $this->subscriberCustomFieldRepository->findBy(['subscriber' => $subscriber]);
|
||||
foreach ($customFields as $customField) {
|
||||
$customField->setValue('');
|
||||
$this->entityManager->persist($customField);
|
||||
}
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
private function anonymizeSubscriberData(SubscriberEntity $subscriber) {
|
||||
$subscriber->setEmail(sprintf('deleted-%s@site.invalid', bin2hex(random_bytes(12)))); // phpcs:ignore
|
||||
$subscriber->setFirstName('Anonymous');
|
||||
$subscriber->setLastName('Anonymous');
|
||||
$subscriber->setStatus(SubscriberEntity::STATUS_UNSUBSCRIBED);
|
||||
$subscriber->setSubscribedIp('0.0.0.0');
|
||||
$subscriber->setConfirmedIp('0.0.0.0');
|
||||
$subscriber->setUnconfirmedData('');
|
||||
$this->entityManager->persist($subscriber);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Subscribers;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\ConflictException;
|
||||
use MailPoet\CustomFields\CustomFieldsRepository;
|
||||
use MailPoet\Doctrine\Validator\ValidationException;
|
||||
use MailPoet\Entities\StatisticsUnsubscribeEntity;
|
||||
use MailPoet\Entities\SubscriberEntity;
|
||||
use MailPoet\Entities\SubscriberSegmentEntity;
|
||||
use MailPoet\Entities\SubscriberTagEntity;
|
||||
use MailPoet\Newsletter\Scheduler\WelcomeScheduler;
|
||||
use MailPoet\Segments\SegmentsRepository;
|
||||
use MailPoet\Settings\SettingsController;
|
||||
use MailPoet\Statistics\Track\Unsubscribes;
|
||||
use MailPoet\Tags\TagRepository;
|
||||
use MailPoet\Util\Security;
|
||||
use MailPoet\WP\Functions as WPFunctions;
|
||||
use MailPoetVendor\Carbon\Carbon;
|
||||
|
||||
class SubscriberSaveController {
|
||||
/** @var CustomFieldsRepository */
|
||||
private $customFieldsRepository;
|
||||
|
||||
/** @var Security */
|
||||
private $security;
|
||||
|
||||
/** @var SettingsController */
|
||||
private $settings;
|
||||
|
||||
/** @var SegmentsRepository */
|
||||
private $segmentsRepository;
|
||||
|
||||
/** @var SubscriberCustomFieldRepository */
|
||||
private $subscriberCustomFieldRepository;
|
||||
|
||||
/** @var SubscribersRepository */
|
||||
private $subscribersRepository;
|
||||
|
||||
/** @var SubscriberSegmentRepository */
|
||||
private $subscriberSegmentRepository;
|
||||
|
||||
/** @var SubscriberTagRepository */
|
||||
private $subscriberTagRepository;
|
||||
|
||||
/** @var TagRepository */
|
||||
private $tagRepository;
|
||||
|
||||
/** @var Unsubscribes */
|
||||
private $unsubscribesTracker;
|
||||
|
||||
/** @var WelcomeScheduler */
|
||||
private $welcomeScheduler;
|
||||
|
||||
/** @var WPFunctions */
|
||||
private $wp;
|
||||
|
||||
public function __construct(
|
||||
CustomFieldsRepository $customFieldsRepository,
|
||||
Security $security,
|
||||
SettingsController $settings,
|
||||
SegmentsRepository $segmentsRepository,
|
||||
SubscriberCustomFieldRepository $subscriberCustomFieldRepository,
|
||||
SubscribersRepository $subscribersRepository,
|
||||
SubscriberSegmentRepository $subscriberSegmentRepository,
|
||||
SubscriberTagRepository $subscriberTagRepository,
|
||||
TagRepository $tagRepository,
|
||||
Unsubscribes $unsubscribesTracker,
|
||||
WelcomeScheduler $welcomeScheduler,
|
||||
WPFunctions $wp
|
||||
) {
|
||||
$this->customFieldsRepository = $customFieldsRepository;
|
||||
$this->security = $security;
|
||||
$this->settings = $settings;
|
||||
$this->segmentsRepository = $segmentsRepository;
|
||||
$this->subscriberSegmentRepository = $subscriberSegmentRepository;
|
||||
$this->subscribersRepository = $subscribersRepository;
|
||||
$this->subscriberCustomFieldRepository = $subscriberCustomFieldRepository;
|
||||
$this->tagRepository = $tagRepository;
|
||||
$this->unsubscribesTracker = $unsubscribesTracker;
|
||||
$this->welcomeScheduler = $welcomeScheduler;
|
||||
$this->wp = $wp;
|
||||
$this->subscriberTagRepository = $subscriberTagRepository;
|
||||
}
|
||||
|
||||
public function filterOutReservedColumns(array $subscriberData): array {
|
||||
$reservedColumns = [
|
||||
'id',
|
||||
'wp_user_id',
|
||||
'is_woocommerce_user',
|
||||
'status',
|
||||
'subscribed_ip',
|
||||
'confirmed_ip',
|
||||
'confirmed_at',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'deleted_at',
|
||||
'unconfirmed_data',
|
||||
];
|
||||
return array_diff_key(
|
||||
$subscriberData,
|
||||
array_flip($reservedColumns)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ConflictException
|
||||
* @throws ValidationException
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function save(array $data): SubscriberEntity {
|
||||
if (!empty($data)) {
|
||||
$data = $this->wp->stripslashesDeep($data);
|
||||
}
|
||||
|
||||
if (empty($data['segments'])) {
|
||||
$data['segments'] = [];
|
||||
}
|
||||
$data['segments'] = array_merge($data['segments'], $this->getNonDefaultSubscribedSegments($data));
|
||||
$newSegments = $this->findNewSegments($data);
|
||||
|
||||
if (empty($data['tags'])) {
|
||||
$data['tags'] = [];
|
||||
}
|
||||
|
||||
$oldSubscriber = $this->findSubscriber($data);
|
||||
$oldStatus = $oldSubscriber ? $oldSubscriber->getStatus() : null;
|
||||
if (
|
||||
$oldSubscriber instanceof SubscriberEntity
|
||||
&& isset($data['status'])
|
||||
&& ($data['status'] === SubscriberEntity::STATUS_UNSUBSCRIBED)
|
||||
&& ($oldSubscriber->getStatus() !== SubscriberEntity::STATUS_UNSUBSCRIBED)
|
||||
) {
|
||||
$currentUser = $this->wp->wpGetCurrentUser();
|
||||
$this->unsubscribesTracker->track(
|
||||
(int)$oldSubscriber->getId(),
|
||||
StatisticsUnsubscribeEntity::SOURCE_ADMINISTRATOR,
|
||||
null,
|
||||
$currentUser->display_name // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
|
||||
);
|
||||
}
|
||||
|
||||
if (isset($data['email']) && $this->isNewEmail($data['email'], $oldSubscriber)) {
|
||||
$this->verifyEmailIsUnique($data['email']);
|
||||
}
|
||||
|
||||
$subscriber = $this->createOrUpdate($data, $oldSubscriber);
|
||||
|
||||
$this->updateCustomFields($data, $subscriber);
|
||||
$this->updateTags($data, $subscriber);
|
||||
|
||||
$segments = isset($data['segments']) ? $this->findSegments($data['segments']) : null;
|
||||
// check for status change
|
||||
if (
|
||||
$oldStatus === SubscriberEntity::STATUS_SUBSCRIBED
|
||||
&& $subscriber->getStatus() === SubscriberEntity::STATUS_UNSUBSCRIBED
|
||||
) {
|
||||
// make sure we unsubscribe the user from all segments
|
||||
$this->subscriberSegmentRepository->unsubscribeFromSegments($subscriber);
|
||||
} elseif ($segments !== null) {
|
||||
$this->subscriberSegmentRepository->resetSubscriptions($subscriber, $segments);
|
||||
}
|
||||
|
||||
if (!empty($newSegments)) {
|
||||
$this->welcomeScheduler->scheduleSubscriberWelcomeNotification($subscriber->getId(), $newSegments);
|
||||
}
|
||||
|
||||
// when global status changes to subscribed, fire subscribed hook for all subscribed segments
|
||||
if (
|
||||
$subscriber->getStatus() === SubscriberEntity::STATUS_SUBSCRIBED
|
||||
&& $oldStatus !== null // don't trigger for new subscribers (handled in subscriber segments repository)
|
||||
&& $oldStatus !== SubscriberEntity::STATUS_SUBSCRIBED
|
||||
) {
|
||||
$segments = $subscriber->getSubscriberSegments();
|
||||
foreach ($segments as $subscriberSegment) {
|
||||
if ($subscriberSegment->getStatus() === SubscriberEntity::STATUS_SUBSCRIBED) {
|
||||
$this->wp->doAction('mailpoet_segment_subscribed', $subscriberSegment);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $subscriber;
|
||||
}
|
||||
|
||||
private function getNonDefaultSubscribedSegments(array $data): array {
|
||||
if (!isset($data['id']) || (int)$data['id'] <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$subscribedSegments = $this->subscriberSegmentRepository->getNonDefaultSubscribedSegments($data['id']);
|
||||
return array_filter(array_map(function(SubscriberSegmentEntity $subscriberSegment): int {
|
||||
$segment = $subscriberSegment->getSegment();
|
||||
if (!$segment) {
|
||||
return 0;
|
||||
}
|
||||
return (int)$segment->getId();
|
||||
}, $subscribedSegments));
|
||||
}
|
||||
|
||||
private function findSegments(array $segmentIds): array {
|
||||
return $this->segmentsRepository->findBy(['id' => $segmentIds]);
|
||||
}
|
||||
|
||||
private function findNewSegments(array $data): array {
|
||||
$oldSegmentIds = [];
|
||||
if (isset($data['id']) && (int)$data['id'] > 0) {
|
||||
$subscribersSegments = $this->subscriberSegmentRepository->findBy(['subscriber' => $data['id']]);
|
||||
foreach ($subscribersSegments as $subscribersSegment) {
|
||||
$segment = $subscribersSegment->getSegment();
|
||||
if (!$segment) {
|
||||
continue;
|
||||
}
|
||||
$oldSegmentIds[] = (int)$segment->getId();
|
||||
}
|
||||
}
|
||||
|
||||
return array_diff($data['segments'], $oldSegmentIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function createOrUpdate(array $data, ?SubscriberEntity $subscriber): SubscriberEntity {
|
||||
if (!$subscriber) {
|
||||
$subscriber = $this->createSubscriber();
|
||||
if (!isset($data['source'])) $data['source'] = Source::ADMINISTRATOR;
|
||||
}
|
||||
|
||||
if (isset($data['email'])) $subscriber->setEmail($data['email']);
|
||||
if (isset($data['first_name'])) $subscriber->setFirstName($data['first_name']);
|
||||
if (isset($data['last_name'])) $subscriber->setLastName($data['last_name']);
|
||||
if (isset($data['status'])) $subscriber->setStatus($data['status']);
|
||||
if (isset($data['source'])) $subscriber->setSource($data['source']);
|
||||
if (isset($data['wp_user_id'])) $subscriber->setWpUserId($data['wp_user_id']);
|
||||
if (isset($data['subscribed_ip'])) $subscriber->setSubscribedIp($data['subscribed_ip']);
|
||||
if (isset($data['confirmed_ip'])) $subscriber->setConfirmedIp($data['confirmed_ip']);
|
||||
if (isset($data['is_woocommerce_user'])) $subscriber->setIsWoocommerceUser((bool)$data['is_woocommerce_user']);
|
||||
$createdAt = isset($data['created_at']) ? Carbon::createFromFormat('Y-m-d H:i:s', $data['created_at']) : null;
|
||||
if ($createdAt) $subscriber->setCreatedAt($createdAt);
|
||||
$confirmedAt = isset($data['confirmed_at']) ? Carbon::createFromFormat('Y-m-d H:i:s', $data['confirmed_at']) : null;
|
||||
if ($confirmedAt) $subscriber->setConfirmedAt($confirmedAt);
|
||||
|
||||
// wipe any unconfirmed data at this point
|
||||
$subscriber->setUnconfirmedData(null);
|
||||
|
||||
// Validate the email (Saving group) + everything else (Default group)
|
||||
$subscriber->setValidationGroups(['Saving', 'Default']);
|
||||
|
||||
try {
|
||||
$this->subscribersRepository->persist($subscriber);
|
||||
$this->subscribersRepository->flush();
|
||||
} catch (ValidationException $exception) {
|
||||
// detach invalid entity because it can block another work with doctrine
|
||||
$this->subscribersRepository->detach($subscriber);
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
return $subscriber;
|
||||
}
|
||||
|
||||
private function isNewEmail(string $email, ?SubscriberEntity $subscriber): bool {
|
||||
if ($subscriber && ($subscriber->getEmail() === $email)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ConflictException
|
||||
*/
|
||||
private function verifyEmailIsUnique(string $email): void {
|
||||
$existingSubscriber = $this->subscribersRepository->findOneBy(['email' => $email]);
|
||||
if ($existingSubscriber) {
|
||||
// translators: %s is email address which already exists.
|
||||
$exceptionMessage = sprintf(__('A subscriber with E-mail "%s" already exists.', 'mailpoet'), $email);
|
||||
throw new ConflictException($exceptionMessage);
|
||||
}
|
||||
}
|
||||
|
||||
private function createSubscriber(): SubscriberEntity {
|
||||
$subscriber = new SubscriberEntity();
|
||||
$subscriber->setUnsubscribeToken($this->security->generateUnsubscribeTokenByEntity($subscriber));
|
||||
$subscriber->setLinkToken(Security::generateHash(SubscriberEntity::LINK_TOKEN_LENGTH));
|
||||
$subscriber->setStatus(!$this->settings->get('signup_confirmation.enabled') ? SubscriberEntity::STATUS_SUBSCRIBED : SubscriberEntity::STATUS_UNCONFIRMED);
|
||||
|
||||
return $subscriber;
|
||||
}
|
||||
|
||||
private function findSubscriber(array &$data): ?SubscriberEntity {
|
||||
$subscriber = null;
|
||||
if (isset($data['id']) && (int)$data['id'] > 0) {
|
||||
$subscriber = $this->subscribersRepository->findOneById(((int)$data['id']));
|
||||
unset($data['id']);
|
||||
}
|
||||
|
||||
if (!$subscriber && !empty($data['email'])) {
|
||||
$subscriber = $this->subscribersRepository->findOneBy(['email' => $data['email']]);
|
||||
if ($subscriber) {
|
||||
unset($data['email']);
|
||||
}
|
||||
}
|
||||
|
||||
return $subscriber;
|
||||
}
|
||||
|
||||
public function updateCustomFields(array $data, SubscriberEntity $subscriber): void {
|
||||
$customFieldsMap = [];
|
||||
foreach ($data as $key => $value) {
|
||||
if (strpos($key, 'cf_') === 0) {
|
||||
$customFieldsMap[(int)substr($key, 3)] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($customFieldsMap)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$customFields = $this->customFieldsRepository->findBy(['id' => array_keys($customFieldsMap)]);
|
||||
foreach ($customFields as $customField) {
|
||||
$this->subscriberCustomFieldRepository->createOrUpdate($subscriber, $customField, $customFieldsMap[$customField->getId()]);
|
||||
}
|
||||
}
|
||||
|
||||
private function updateTags(array $data, SubscriberEntity $subscriber): void {
|
||||
$removedTags = [];
|
||||
|
||||
/**
|
||||
* $data['tags'] is either an array of arrays containing name, id etc. of the tag or an array of strings - the names
|
||||
* of the tag.
|
||||
*
|
||||
* Therefore we map it to be only an array of strings, containing the names of the tag.
|
||||
*/
|
||||
$tags = array_map(
|
||||
function($tag): string {
|
||||
if (is_array($tag)) {
|
||||
return array_key_exists('name', $tag) ? (string)$tag['name'] : '';
|
||||
}
|
||||
return (string)$tag;
|
||||
},
|
||||
(array)$data['tags']
|
||||
);
|
||||
foreach ($subscriber->getSubscriberTags() as $subscriberTag) {
|
||||
$tag = $subscriberTag->getTag();
|
||||
if (!$tag || !in_array($tag->getName(), $tags, true)) {
|
||||
$subscriber->getSubscriberTags()->removeElement($subscriberTag);
|
||||
$removedTags[] = $subscriberTag;
|
||||
}
|
||||
}
|
||||
|
||||
$newlyAddedTags = [];
|
||||
foreach ($tags as $tagName) {
|
||||
$tag = $this->tagRepository->createOrUpdate(['name' => $tagName]);
|
||||
$subscriberTag = $subscriber->getSubscriberTag($tag);
|
||||
if (!$subscriberTag) {
|
||||
$subscriberTag = new SubscriberTagEntity($tag, $subscriber);
|
||||
$subscriber->getSubscriberTags()->add($subscriberTag);
|
||||
$this->subscriberTagRepository->persist($subscriberTag);
|
||||
$newlyAddedTags[] = $subscriberTag;
|
||||
}
|
||||
}
|
||||
$this->subscriberTagRepository->flush();
|
||||
foreach ($newlyAddedTags as $subscriberTag) {
|
||||
$this->wp->doAction('mailpoet_subscriber_tag_added', $subscriberTag);
|
||||
}
|
||||
foreach ($removedTags as $subscriberTag) {
|
||||
$this->wp->doAction('mailpoet_subscriber_tag_removed', $subscriberTag);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Subscribers;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Doctrine\Repository;
|
||||
use MailPoet\Entities\SegmentEntity;
|
||||
use MailPoet\Entities\SubscriberEntity;
|
||||
use MailPoet\Entities\SubscriberSegmentEntity;
|
||||
use MailPoet\WP\Functions as WPFunctions;
|
||||
use MailPoetVendor\Doctrine\ORM\EntityManager;
|
||||
use MailPoetVendor\Doctrine\ORM\Query\Expr\Join;
|
||||
|
||||
/**
|
||||
* @extends Repository<SubscriberSegmentEntity>
|
||||
*/
|
||||
class SubscriberSegmentRepository extends Repository {
|
||||
/** @var WPFunctions */
|
||||
private $wp;
|
||||
|
||||
public function __construct(
|
||||
EntityManager $entityManager,
|
||||
WPFunctions $wp
|
||||
) {
|
||||
parent::__construct($entityManager);
|
||||
$this->wp = $wp;
|
||||
}
|
||||
|
||||
protected function getEntityClassName() {
|
||||
return SubscriberSegmentEntity::class;
|
||||
}
|
||||
|
||||
public function getNonDefaultSubscribedSegments(int $subscriberId): array {
|
||||
$qb = $this->entityManager->createQueryBuilder();
|
||||
return $qb->select('ss')
|
||||
->from(SubscriberSegmentEntity::class, 'ss')
|
||||
->join('ss.segment', 'seg', Join::WITH, 'seg.type != :typeDefault')
|
||||
->where('ss.subscriber = :subscriberId')
|
||||
->andWhere('ss.status = :subscribed')
|
||||
->setParameter('subscriberId', $subscriberId)
|
||||
->setParameter('subscribed', SubscriberEntity::STATUS_SUBSCRIBED)
|
||||
->setParameter('typeDefault', SegmentEntity::TYPE_DEFAULT)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param SegmentEntity[] $segments
|
||||
*/
|
||||
public function unsubscribeFromSegments(SubscriberEntity $subscriber, array $segments = []): void {
|
||||
$subscriber->setConfirmationsCount(0);
|
||||
|
||||
if (!empty($segments)) {
|
||||
// unsubscribe from segments
|
||||
foreach ($segments as $segment) {
|
||||
// do not remove subscriptions to the WP Users segment
|
||||
if ($segment->getType() === SegmentEntity::TYPE_WP_USERS) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->createOrUpdate($subscriber, $segment, SubscriberEntity::STATUS_UNSUBSCRIBED);
|
||||
}
|
||||
$this->entityManager->flush();
|
||||
} else {
|
||||
$subscriberSegmentTable = $this->entityManager->getClassMetadata(SubscriberSegmentEntity::class)->getTableName();
|
||||
$segmentTable = $this->entityManager->getClassMetadata(SegmentEntity::class)->getTableName();
|
||||
$this->entityManager->getConnection()->executeStatement("
|
||||
UPDATE $subscriberSegmentTable ss
|
||||
JOIN $segmentTable s ON s.`id` = ss.`segment_id` AND ss.`subscriber_id` = :subscriberId
|
||||
SET ss.`status` = :status
|
||||
WHERE s.`type` != :typeWordPress
|
||||
", [
|
||||
'subscriberId' => $subscriber->getId(),
|
||||
'status' => SubscriberEntity::STATUS_UNSUBSCRIBED,
|
||||
'typeWordPress' => SegmentEntity::TYPE_WP_USERS,
|
||||
]);
|
||||
// Refresh SubscriberSegments status
|
||||
foreach ($subscriber->getSubscriberSegments() as $subscriberSegment) {
|
||||
$this->entityManager->refresh($subscriberSegment);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function resetSubscriptions(SubscriberEntity $subscriber, array $segments): void {
|
||||
// Already existing subscriptions are stored in $existingSegments. Their IDs in $existingSegmentIds.
|
||||
$existingSegments = array_values(array_filter(array_map(
|
||||
function(SubscriberSegmentEntity $subscriberSegmentEntity): ?SegmentEntity {
|
||||
return $subscriberSegmentEntity->getSegment();
|
||||
},
|
||||
$this->findBy(['subscriber' => $subscriber, 'status' => SubscriberEntity::STATUS_SUBSCRIBED])
|
||||
)));
|
||||
$existingSegmentIds = array_map(
|
||||
function(SegmentEntity $segment): int {
|
||||
return $segment->getId() ?? 0;
|
||||
},
|
||||
$existingSegments
|
||||
);
|
||||
|
||||
// $segmentIds are the IDs of the segments we want the user to be subscribed to.
|
||||
$segmentIds = array_map(
|
||||
function(SegmentEntity $segment): int {
|
||||
return $segment->getId() ?? 0;
|
||||
},
|
||||
$segments
|
||||
);
|
||||
|
||||
// $unsubscribedSegments are the segment IDs to which we need to unsubscribe.
|
||||
$unsubscribedSegments = array_diff($existingSegmentIds, $segmentIds);
|
||||
|
||||
// $newlySubscribedSegments are the segment IDs to which we need to newly subscribe.
|
||||
$newlySubscribedSegments = array_diff($segmentIds, $existingSegmentIds);
|
||||
if (!$newlySubscribedSegments && !$unsubscribedSegments) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The segments we need to unsubscribe.
|
||||
$unsubscribe = array_filter(
|
||||
$existingSegments,
|
||||
function(SegmentEntity $segment) use ($unsubscribedSegments): bool {
|
||||
return in_array($segment->getId(), $unsubscribedSegments);
|
||||
}
|
||||
);
|
||||
|
||||
// The segments we need to newly subscribe.
|
||||
$subscribe = array_filter(
|
||||
$segments,
|
||||
function(SegmentEntity $segment) use ($newlySubscribedSegments): bool {
|
||||
return in_array($segment->getId(), $newlySubscribedSegments);
|
||||
}
|
||||
);
|
||||
if ($unsubscribe) {
|
||||
$this->unsubscribeFromSegments($subscriber, $unsubscribe);
|
||||
}
|
||||
if ($subscribe) {
|
||||
$this->subscribeToSegments($subscriber, $subscribe);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param SegmentEntity[] $segments
|
||||
*/
|
||||
public function subscribeToSegments(SubscriberEntity $subscriber, array $segments, bool $skipHooks = false): void {
|
||||
foreach ($segments as $segment) {
|
||||
$this->createOrUpdate($subscriber, $segment, SubscriberEntity::STATUS_SUBSCRIBED, $skipHooks);
|
||||
}
|
||||
}
|
||||
|
||||
public function createOrUpdate(
|
||||
SubscriberEntity $subscriber,
|
||||
SegmentEntity $segment,
|
||||
string $status,
|
||||
bool $skipHooks = false
|
||||
): SubscriberSegmentEntity {
|
||||
$subscriberSegment = $this->findOneBy(['segment' => $segment, 'subscriber' => $subscriber]);
|
||||
|
||||
$oldStatus = null;
|
||||
if ($subscriberSegment instanceof SubscriberSegmentEntity) {
|
||||
$oldStatus = $subscriberSegment->getStatus();
|
||||
$subscriberSegment->setStatus($status);
|
||||
} else {
|
||||
$subscriberSegment = new SubscriberSegmentEntity($segment, $subscriber, $status);
|
||||
$subscriber->getSubscriberSegments()->add($subscriberSegment);
|
||||
$this->entityManager->persist($subscriberSegment);
|
||||
}
|
||||
$this->entityManager->flush();
|
||||
|
||||
// fire subscribed hook for new subscriptions
|
||||
if (
|
||||
!$skipHooks
|
||||
&& $subscriber->getStatus() === SubscriberEntity::STATUS_SUBSCRIBED
|
||||
&& $subscriberSegment->getStatus() === SubscriberEntity::STATUS_SUBSCRIBED
|
||||
&& $oldStatus !== SubscriberEntity::STATUS_SUBSCRIBED
|
||||
) {
|
||||
$this->wp->doAction('mailpoet_segment_subscribed', $subscriberSegment);
|
||||
}
|
||||
|
||||
return $subscriberSegment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Subscribers;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Captcha\CaptchaConstants;
|
||||
use MailPoet\Captcha\CaptchaSession;
|
||||
use MailPoet\Captcha\Validator\CaptchaValidator;
|
||||
use MailPoet\Captcha\Validator\RecaptchaValidator;
|
||||
use MailPoet\Captcha\Validator\ValidationError;
|
||||
use MailPoet\Entities\FormEntity;
|
||||
use MailPoet\Entities\SubscriberEntity;
|
||||
use MailPoet\Entities\SubscriberTagEntity;
|
||||
use MailPoet\Form\FormsRepository;
|
||||
use MailPoet\Form\Util\FieldNameObfuscator;
|
||||
use MailPoet\NotFoundException;
|
||||
use MailPoet\Segments\SubscribersFinder;
|
||||
use MailPoet\Settings\SettingsController;
|
||||
use MailPoet\Statistics\StatisticsFormsRepository;
|
||||
use MailPoet\Subscription\Throttling as SubscriptionThrottling;
|
||||
use MailPoet\Tags\TagRepository;
|
||||
use MailPoet\UnexpectedValueException;
|
||||
use MailPoet\WP\Functions as WPFunctions;
|
||||
|
||||
class SubscriberSubscribeController {
|
||||
/** @var FormsRepository */
|
||||
private $formsRepository;
|
||||
|
||||
/** @var CaptchaSession */
|
||||
private $captchaSession;
|
||||
|
||||
/** @var FieldNameObfuscator */
|
||||
private $fieldNameObfuscator;
|
||||
|
||||
/** @var SettingsController */
|
||||
private $settings;
|
||||
|
||||
/** @var RequiredCustomFieldValidator */
|
||||
private $requiredCustomFieldValidator;
|
||||
|
||||
/** @var SubscriberActions */
|
||||
private $subscriberActions;
|
||||
|
||||
/** @var WPFunctions */
|
||||
private $wp;
|
||||
|
||||
/** @var SubscriptionThrottling */
|
||||
private $throttling;
|
||||
|
||||
/** @var StatisticsFormsRepository */
|
||||
private $statisticsFormsRepository;
|
||||
|
||||
/** @var SubscribersFinder */
|
||||
private $subscribersFinder;
|
||||
|
||||
/** @var TagRepository */
|
||||
private $tagRepository;
|
||||
|
||||
/** @var SubscriberTagRepository */
|
||||
private $subscriberTagRepository;
|
||||
/** @var CaptchaValidator */
|
||||
private $builtInCaptchaValidator;
|
||||
|
||||
/** @var RecaptchaValidator */
|
||||
private $recaptchaValidator;
|
||||
|
||||
public function __construct(
|
||||
CaptchaSession $captchaSession,
|
||||
SubscriberActions $subscriberActions,
|
||||
SubscribersFinder $subscribersFinder,
|
||||
SubscriptionThrottling $throttling,
|
||||
FieldNameObfuscator $fieldNameObfuscator,
|
||||
RequiredCustomFieldValidator $requiredCustomFieldValidator,
|
||||
SettingsController $settings,
|
||||
FormsRepository $formsRepository,
|
||||
StatisticsFormsRepository $statisticsFormsRepository,
|
||||
TagRepository $tagRepository,
|
||||
SubscriberTagRepository $subscriberTagRepository,
|
||||
WPFunctions $wp,
|
||||
CaptchaValidator $builtInCaptchaValidator,
|
||||
RecaptchaValidator $recaptchaValidator
|
||||
) {
|
||||
$this->formsRepository = $formsRepository;
|
||||
$this->captchaSession = $captchaSession;
|
||||
$this->requiredCustomFieldValidator = $requiredCustomFieldValidator;
|
||||
$this->fieldNameObfuscator = $fieldNameObfuscator;
|
||||
$this->settings = $settings;
|
||||
$this->subscriberActions = $subscriberActions;
|
||||
$this->subscribersFinder = $subscribersFinder;
|
||||
$this->wp = $wp;
|
||||
$this->throttling = $throttling;
|
||||
$this->statisticsFormsRepository = $statisticsFormsRepository;
|
||||
$this->tagRepository = $tagRepository;
|
||||
$this->subscriberTagRepository = $subscriberTagRepository;
|
||||
$this->builtInCaptchaValidator = $builtInCaptchaValidator;
|
||||
$this->recaptchaValidator = $recaptchaValidator;
|
||||
}
|
||||
|
||||
public function subscribe(array $data): array {
|
||||
$form = $this->getForm($data);
|
||||
|
||||
if (!empty($data['email'])) {
|
||||
throw new UnexpectedValueException(__('Please leave the first field empty.', 'mailpoet'));
|
||||
}
|
||||
|
||||
$captchaSettings = $this->settings->get('captcha');
|
||||
$data = $this->initCaptcha($captchaSettings, $form, $data);
|
||||
$data = $this->deobfuscateFormPayload($data);
|
||||
|
||||
try {
|
||||
$this->requiredCustomFieldValidator->validate($data, $form);
|
||||
} catch (\Exception $e) {
|
||||
throw new UnexpectedValueException($e->getMessage());
|
||||
}
|
||||
|
||||
$segmentIds = $this->getSegmentIds($form, $data['segments'] ?? []);
|
||||
unset($data['segments']);
|
||||
|
||||
$meta = $this->validateCaptcha($captchaSettings, $data);
|
||||
if (isset($meta['error'])) {
|
||||
return $meta;
|
||||
}
|
||||
|
||||
// only accept fields defined in the form
|
||||
$formFieldIds = array_filter(array_map(function (array $formField): ?string {
|
||||
if (!isset($formField['id'])) {
|
||||
return null;
|
||||
}
|
||||
return is_numeric($formField['id']) ? "cf_{$formField['id']}" : $formField['id'];
|
||||
}, $form->getBlocksByTypes(FormEntity::FORM_FIELD_TYPES)));
|
||||
$data = array_intersect_key($data, array_flip($formFieldIds));
|
||||
|
||||
// make sure we don't allow too many subscriptions with the same ip address
|
||||
$timeout = $this->throttling->throttle();
|
||||
|
||||
if ($timeout > 0) {
|
||||
$timeToWait = $this->throttling->secondsToTimeString($timeout);
|
||||
$meta['refresh_captcha'] = true;
|
||||
// translators: %s is the amount of time the user has to wait.
|
||||
$meta['error'] = sprintf(__('You need to wait %s before subscribing again.', 'mailpoet'), $timeToWait);
|
||||
return $meta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires before a subscription gets created.
|
||||
* To interrupt the subscription process, you can throw an MailPoet\Exception.
|
||||
* The error message will then be displayed to the user.
|
||||
*
|
||||
* @param array $data The subscription data.
|
||||
* @param array $segmentIds The segment IDs the user gets subscribed to.
|
||||
* @param FormEntity $form The form the user used to subscribe.
|
||||
*/
|
||||
$this->wp->doAction('mailpoet_subscription_before_subscribe', $data, $segmentIds, $form);
|
||||
|
||||
[$subscriber, $subscriptionMeta] = $this->subscriberActions->subscribe($data, $segmentIds);
|
||||
|
||||
if (!empty($captchaSettings['type']) && $captchaSettings['type'] === CaptchaConstants::TYPE_BUILTIN && isset($data['captcha_session_id'])) {
|
||||
// Captcha has been verified, invalidate the session vars
|
||||
$this->captchaSession->reset($data['captcha_session_id']);
|
||||
}
|
||||
|
||||
// record form statistics
|
||||
$this->statisticsFormsRepository->record($form, $subscriber);
|
||||
|
||||
$formSettings = $form->getSettings();
|
||||
|
||||
// add tags to subscriber if they are filled
|
||||
$this->addTagsToSubscriber($formSettings['tags'] ?? [], $subscriber);
|
||||
|
||||
// Confirmation email failed. We want to show the error message
|
||||
if ($subscriptionMeta['confirmationEmailResult'] instanceof \Exception) {
|
||||
$meta['error'] = $subscriptionMeta['confirmationEmailResult']->getMessage();
|
||||
return $meta;
|
||||
}
|
||||
|
||||
if (!empty($formSettings['on_success'])) {
|
||||
if ($formSettings['on_success'] === 'page') {
|
||||
// redirect to a page on a success, pass the page url in the meta
|
||||
$meta['redirect_url'] = $this->wp->getPermalink($formSettings['success_page']);
|
||||
} else if ($formSettings['on_success'] === 'url') {
|
||||
$meta['redirect_url'] = $formSettings['success_url'];
|
||||
}
|
||||
}
|
||||
|
||||
return $meta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the subscriber is subscribed to any segments in the form
|
||||
*
|
||||
* @param FormEntity $form The form entity
|
||||
* @param SubscriberEntity $subscriber The subscriber entity
|
||||
* @return bool True if the subscriber is subscribed to any of the segments in the form
|
||||
*/
|
||||
public function isSubscribedToAnyFormSegments(FormEntity $form, SubscriberEntity $subscriber): bool {
|
||||
$formSegments = array_merge($form->getSegmentBlocksSegmentIds(), $form->getSettingsSegmentIds());
|
||||
|
||||
$subscribersFound = $this->subscribersFinder->findSubscribersInSegments([$subscriber->getId()], $formSegments);
|
||||
if (!empty($subscribersFound)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function deobfuscateFormPayload($data): array {
|
||||
return $this->fieldNameObfuscator->deobfuscateFormPayload($data);
|
||||
}
|
||||
|
||||
private function initCaptcha(?array $captchaSettings, FormEntity $form, array $data): array {
|
||||
if (
|
||||
!$captchaSettings
|
||||
|| !isset($captchaSettings['type'])
|
||||
|| $captchaSettings['type'] !== CaptchaConstants::TYPE_BUILTIN
|
||||
) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
// When serving the built-in CAPTCHA for the first time, generate a new session ID.
|
||||
if (!isset($data['captcha_session_id'])) {
|
||||
$data['captcha_session_id'] = $this->captchaSession->generateSessionId();
|
||||
}
|
||||
$sessionId = $data['captcha_session_id'];
|
||||
|
||||
if (!isset($data['captcha'])) {
|
||||
// Save form data to session
|
||||
$this->captchaSession->setFormData($sessionId, array_merge($data, ['form_id' => $form->getId()]));
|
||||
} elseif ($this->captchaSession->getFormData($sessionId)) {
|
||||
// Restore form data from session
|
||||
$data = array_merge($this->captchaSession->getFormData($sessionId), ['captcha' => $data['captcha']]);
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function validateCaptcha($captchaSettings, $data): array {
|
||||
if (empty($captchaSettings['type'])) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
if ($captchaSettings['type'] === CaptchaConstants::TYPE_BUILTIN) {
|
||||
$this->builtInCaptchaValidator->validate($data);
|
||||
}
|
||||
if (CaptchaConstants::isReCaptcha($captchaSettings['type'])) {
|
||||
$this->recaptchaValidator->validate($data);
|
||||
}
|
||||
} catch (ValidationError $error) {
|
||||
return $error->getMeta();
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private function getSegmentIds(FormEntity $form, array $segmentIds): array {
|
||||
|
||||
// If form contains segment selection blocks allow only segments ids configured in those blocks
|
||||
$segmentBlocksSegmentIds = $form->getSegmentBlocksSegmentIds();
|
||||
if (!empty($segmentBlocksSegmentIds)) {
|
||||
$segmentIds = array_intersect($segmentIds, $segmentBlocksSegmentIds);
|
||||
} else {
|
||||
$segmentIds = $form->getSettingsSegmentIds();
|
||||
}
|
||||
|
||||
if (empty($segmentIds)) {
|
||||
throw new UnexpectedValueException(__('Please select a list.', 'mailpoet'));
|
||||
}
|
||||
|
||||
return $segmentIds;
|
||||
}
|
||||
|
||||
private function getForm(array $data): FormEntity {
|
||||
$formId = (isset($data['form_id']) ? (int)$data['form_id'] : false);
|
||||
$form = $this->formsRepository->findOneById($formId);
|
||||
|
||||
if (!$form) {
|
||||
throw new NotFoundException(__('Please specify a valid form ID.', 'mailpoet'));
|
||||
}
|
||||
|
||||
return $form;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $tagNames
|
||||
*/
|
||||
private function addTagsToSubscriber(array $tagNames, SubscriberEntity $subscriber): void {
|
||||
foreach ($tagNames as $tagName) {
|
||||
$tag = $this->tagRepository->createOrUpdate(['name' => $tagName]);
|
||||
|
||||
$subscriberTag = $subscriber->getSubscriberTag($tag);
|
||||
if (!$subscriberTag) {
|
||||
$subscriberTag = new SubscriberTagEntity($tag, $subscriber);
|
||||
$subscriber->getSubscriberTags()->add($subscriberTag);
|
||||
$this->subscriberTagRepository->persist($subscriberTag);
|
||||
$this->subscriberTagRepository->flush();
|
||||
$this->wp->doAction('mailpoet_subscriber_tag_added', $subscriberTag);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Subscribers;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Doctrine\Repository;
|
||||
use MailPoet\Entities\SubscriberTagEntity;
|
||||
|
||||
/**
|
||||
* @extends Repository<SubscriberTagEntity>
|
||||
*/
|
||||
class SubscriberTagRepository extends Repository {
|
||||
protected function getEntityClassName() {
|
||||
return SubscriberTagEntity::class;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Subscribers;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Cache\TransientCache;
|
||||
use MailPoet\Entities\SegmentEntity;
|
||||
use MailPoet\Entities\SubscriberEntity;
|
||||
use MailPoet\InvalidStateException;
|
||||
use MailPoet\Segments\SegmentsRepository;
|
||||
use MailPoet\Segments\SegmentSubscribersRepository;
|
||||
use MailPoet\Tags\TagRepository;
|
||||
use MailPoet\Util\License\Features\Subscribers as SubscribersFeature;
|
||||
use MailPoetVendor\Carbon\Carbon;
|
||||
|
||||
class SubscribersCountsController {
|
||||
/** @var SegmentsRepository */
|
||||
private $segmentsRepository;
|
||||
|
||||
/** @var SegmentSubscribersRepository */
|
||||
private $segmentSubscribersRepository;
|
||||
|
||||
/** @var SubscribersRepository */
|
||||
private $subscribersRepository;
|
||||
|
||||
/** @var TagRepository */
|
||||
private $tagRepository;
|
||||
|
||||
/** @var TransientCache */
|
||||
private $transientCache;
|
||||
|
||||
/** @var SubscribersFeature */
|
||||
private $subscribersFeature;
|
||||
|
||||
public function __construct(
|
||||
SegmentsRepository $segmentsRepository,
|
||||
SegmentSubscribersRepository $segmentSubscribersRepository,
|
||||
SubscribersRepository $subscribersRepository,
|
||||
TagRepository $subscriberTagRepository,
|
||||
TransientCache $transientCache,
|
||||
SubscribersFeature $subscribersFeature
|
||||
) {
|
||||
|
||||
$this->segmentSubscribersRepository = $segmentSubscribersRepository;
|
||||
$this->transientCache = $transientCache;
|
||||
$this->segmentsRepository = $segmentsRepository;
|
||||
$this->subscribersRepository = $subscribersRepository;
|
||||
$this->tagRepository = $subscriberTagRepository;
|
||||
$this->subscribersFeature = $subscribersFeature;
|
||||
}
|
||||
|
||||
public function getSubscribersWithoutSegmentStatisticsCount(): array {
|
||||
$result = $this->getCacheItem(TransientCache::SUBSCRIBERS_STATISTICS_COUNT_KEY, 0)['item'] ?? null;
|
||||
if (!$result) {
|
||||
$result = $this->recalculateSubscribersWithoutSegmentStatisticsCache();
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function getSegmentStatisticsCount(SegmentEntity $segment): array {
|
||||
$result = $this->getCacheItem(TransientCache::SUBSCRIBERS_STATISTICS_COUNT_KEY, (int)$segment->getId())['item'] ?? null;
|
||||
if (!$result) {
|
||||
$result = $this->recalculateSegmentStatisticsCache($segment);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function getSegmentStatisticsCountById(int $segmentId): array {
|
||||
$result = $this->getCacheItem(TransientCache::SUBSCRIBERS_STATISTICS_COUNT_KEY, $segmentId)['item'] ?? null;
|
||||
if (!$result) {
|
||||
$segment = $this->segmentsRepository->findOneById($segmentId);
|
||||
if (!$segment) {
|
||||
throw new InvalidStateException();
|
||||
}
|
||||
$result = $this->recalculateSegmentStatisticsCache($segment);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function getHomepageStatistics(): array {
|
||||
$result = $this->getCacheItem(TransientCache::SUBSCRIBERS_HOMEPAGE_STATISTICS_COUNT_KEY, 0)['item'] ?? [];
|
||||
if (!$result) {
|
||||
$result = $this->recalculateHomepageStatisticsCache();
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function recalculateSegmentStatisticsCache(SegmentEntity $segment): array {
|
||||
$result = $this->segmentSubscribersRepository->getSubscribersStatisticsCount($segment);
|
||||
$this->setCacheItem(
|
||||
TransientCache::SUBSCRIBERS_STATISTICS_COUNT_KEY,
|
||||
$result,
|
||||
(int)$segment->getId()
|
||||
);
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function recalculateSubscribersWithoutSegmentStatisticsCache(): array {
|
||||
$result = $this->segmentSubscribersRepository->getSubscribersWithoutSegmentStatisticsCount();
|
||||
$this->setCacheItem(TransientCache::SUBSCRIBERS_STATISTICS_COUNT_KEY, $result, 0);
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function recalculateHomepageStatisticsCache(): array {
|
||||
$thirtyDaysAgo = Carbon::now()->millisecond(0)->subDays(30);
|
||||
$result = [];
|
||||
$result['listsDataSubscribed'] = $this->subscribersRepository->getListLevelCountsOfSubscribedAfter($thirtyDaysAgo);
|
||||
$result['listsDataUnsubscribed'] = $this->subscribersRepository->getListLevelCountsOfUnsubscribedAfter($thirtyDaysAgo);
|
||||
$result['subscribedCount'] = $this->subscribersRepository->getCountOfLastSubscribedAfter($thirtyDaysAgo);
|
||||
$result['unsubscribedCount'] = $this->subscribersRepository->getCountOfUnsubscribedAfter($thirtyDaysAgo);
|
||||
$result['subscribedSubscribersCount'] = $this->subscribersRepository->getCountOfSubscribersForStates([SubscriberEntity::STATUS_SUBSCRIBED]);
|
||||
$this->setCacheItem(
|
||||
TransientCache::SUBSCRIBERS_HOMEPAGE_STATISTICS_COUNT_KEY,
|
||||
$result,
|
||||
0
|
||||
);
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function removeRedundancyFromStatisticsCache() {
|
||||
$segments = $this->segmentsRepository->findAll();
|
||||
$segmentIds = array_map(function (SegmentEntity $segment): int {
|
||||
return (int)$segment->getId();
|
||||
}, $segments);
|
||||
foreach ($this->transientCache->getItems(TransientCache::SUBSCRIBERS_STATISTICS_COUNT_KEY) as $id => $item) {
|
||||
if (!in_array($id, $segmentIds)) {
|
||||
$this->transientCache->invalidateItem(TransientCache::SUBSCRIBERS_STATISTICS_COUNT_KEY, $id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use cache only if subscribers count is above minimum
|
||||
*/
|
||||
private function getCacheItem(string $key, int $id): ?array {
|
||||
if ($this->subscribersFeature->isSubscribersCountEnoughForCache()) {
|
||||
return $this->transientCache->getItem($key, $id);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private function setCacheItem(string $key, array $item, int $id): void {
|
||||
if ($this->subscribersFeature->isSubscribersCountEnoughForCache()) {
|
||||
$this->transientCache->setItem($key, $item, $id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{id: int, name: string, subscribersCount: int}>
|
||||
*/
|
||||
public function getTagsStatisticsCount(?string $status, bool $isDeleted): array {
|
||||
return $this->tagRepository->getSubscriberStatisticsCount($status, $isDeleted);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Subscribers;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Entities\ScheduledTaskEntity;
|
||||
use MailPoet\Entities\ScheduledTaskSubscriberEntity;
|
||||
use MailPoet\Entities\SubscriberEntity;
|
||||
use MailPoetVendor\Carbon\Carbon;
|
||||
use MailPoetVendor\Doctrine\DBAL\ParameterType;
|
||||
use MailPoetVendor\Doctrine\ORM\EntityManager;
|
||||
|
||||
class SubscribersEmailCountsController {
|
||||
/** @var EntityManager */
|
||||
private $entityManager;
|
||||
|
||||
/** @var string */
|
||||
private $subscribersTable;
|
||||
|
||||
/** @var string */
|
||||
private $scheduledTasksTable;
|
||||
|
||||
public function __construct(
|
||||
EntityManager $entityManager
|
||||
) {
|
||||
$this->entityManager = $entityManager;
|
||||
$this->subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
|
||||
$this->scheduledTasksTable = $this->entityManager->getClassMetadata(ScheduledTaskEntity::class)->getTableName();
|
||||
}
|
||||
|
||||
public function updateSubscribersEmailCounts(?\DateTimeInterface $dateLastProcessed, int $batchSize, ?int $startId = null): array {
|
||||
$scheduledTaskSubscribersTable = $this->entityManager->getClassMetadata(ScheduledTaskSubscriberEntity::class)->getTableName();
|
||||
|
||||
$connection = $this->entityManager->getConnection();
|
||||
|
||||
$dayAgo = new Carbon();
|
||||
$dayAgoIso = $dayAgo->subDay()->toDateTimeString();
|
||||
|
||||
$startId = (int)$startId;
|
||||
|
||||
// Return if there are no new sending tasks
|
||||
if ($dateLastProcessed && !$this->newSendingTasksSince($dateLastProcessed)) {
|
||||
return [0, 0];
|
||||
}
|
||||
// Return if there are no subscribers to update
|
||||
[$countSubscribersToUpdate, $endId] = $this->countAndMaxOfSubscribersInRange($startId, $batchSize);
|
||||
if (!$countSubscribersToUpdate) {
|
||||
return [0, 0];
|
||||
}
|
||||
|
||||
$queryParams = [
|
||||
'startId' => $startId,
|
||||
'endId' => $endId,
|
||||
'dayAgo' => $dayAgoIso,
|
||||
];
|
||||
if ($dateLastProcessed) {
|
||||
$carbonDateLastProcessed = Carbon::createFromTimestamp($dateLastProcessed->getTimestamp());
|
||||
$dateFromIso = ($carbonDateLastProcessed->subDay())->toDateTimeString();
|
||||
$queryParams['dateFrom'] = $dateFromIso;
|
||||
}
|
||||
// If $dateLastProcessed provided, increment value, otherwise count all and reset value
|
||||
$initUpdateValue = $dateLastProcessed ? 's.email_count' : '';
|
||||
$dateLastProcessedSql = $dateLastProcessed ? ' AND st.processed_at >= :dateFrom' : '';
|
||||
|
||||
$connection->executeQuery(
|
||||
"
|
||||
UPDATE {$this->subscribersTable} as s
|
||||
JOIN (
|
||||
SELECT s.id, COUNT(st.id) as email_count
|
||||
FROM {$this->subscribersTable} as s
|
||||
JOIN {$scheduledTaskSubscribersTable} as sts ON s.id = sts.subscriber_id
|
||||
JOIN {$this->scheduledTasksTable} as st ON st.id = sts.task_id
|
||||
WHERE s.id >= :startId
|
||||
AND s.id <= :endId
|
||||
AND st.type = 'sending'
|
||||
AND st.processed_at IS NOT NULL
|
||||
AND st.processed_at < :dayAgo
|
||||
{$dateLastProcessedSql}
|
||||
GROUP BY s.id
|
||||
) counts ON counts.id = s.id
|
||||
SET s.email_count = {$initUpdateValue} + IFNULL(counts.email_count, 0)
|
||||
",
|
||||
$queryParams
|
||||
);
|
||||
|
||||
return [$countSubscribersToUpdate, $endId];
|
||||
}
|
||||
|
||||
private function newSendingTasksSince(\DateTimeInterface $dateLastProcessed): bool {
|
||||
$carbonDateLastProcessed = Carbon::createFromTimestamp($dateLastProcessed->getTimestamp());
|
||||
$dateFromIso = ($carbonDateLastProcessed->subDay())->toDateTimeString();
|
||||
$queryParams['dateFrom'] = $dateFromIso;
|
||||
$dayAgo = new Carbon();
|
||||
$dayAgoIso = $dayAgo->subDay()->toDateTimeString();
|
||||
$queryParams['dayAgo'] = $dayAgoIso;
|
||||
|
||||
$result = $this->entityManager->getConnection()->executeQuery(
|
||||
"
|
||||
SELECT count(id) FROM {$this->scheduledTasksTable}
|
||||
WHERE type = 'sending'
|
||||
AND processed_at IS NOT NULL
|
||||
AND processed_at < :dayAgo
|
||||
AND processed_at >= :dateFrom
|
||||
",
|
||||
$queryParams
|
||||
)->fetchNumeric();
|
||||
|
||||
/** @var int[] $result - it's required for PHPStan */
|
||||
return is_array($result) && isset($result[0]) && ((int)$result[0] > 0);
|
||||
}
|
||||
|
||||
private function countAndMaxOfSubscribersInRange(int $startId, int $batchSize): array {
|
||||
$result = $this->entityManager->getConnection()->executeQuery(
|
||||
"
|
||||
SELECT COUNT(ids.id) as count, COALESCE(MAX(ids.id), 0) as max FROM (
|
||||
SELECT s.id FROM {$this->subscribersTable} as s
|
||||
WHERE s.id >= :startId
|
||||
ORDER BY s.id
|
||||
LIMIT :batchSize
|
||||
) ids
|
||||
",
|
||||
[
|
||||
'startId' => $startId,
|
||||
'batchSize' => $batchSize,
|
||||
],
|
||||
[
|
||||
'startId' => ParameterType::INTEGER,
|
||||
'batchSize' => ParameterType::INTEGER,
|
||||
]
|
||||
);
|
||||
|
||||
/** @var array{0: array{count:int, max:int}} $subscribersInRange */
|
||||
$subscribersInRange = $result->fetchAllAssociative();
|
||||
|
||||
return [intval($subscribersInRange[0]['count']), intval($subscribersInRange[0]['max'])];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,681 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Subscribers;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use DateTimeInterface;
|
||||
use MailPoet\Config\SubscriberChangesNotifier;
|
||||
use MailPoet\Doctrine\Repository;
|
||||
use MailPoet\Entities\SegmentEntity;
|
||||
use MailPoet\Entities\StatisticsUnsubscribeEntity;
|
||||
use MailPoet\Entities\SubscriberCustomFieldEntity;
|
||||
use MailPoet\Entities\SubscriberEntity;
|
||||
use MailPoet\Entities\SubscriberSegmentEntity;
|
||||
use MailPoet\Entities\SubscriberTagEntity;
|
||||
use MailPoet\Entities\TagEntity;
|
||||
use MailPoet\Segments\SegmentsRepository;
|
||||
use MailPoet\Util\License\Features\Subscribers;
|
||||
use MailPoet\WP\Functions as WPFunctions;
|
||||
use MailPoetVendor\Carbon\Carbon;
|
||||
use MailPoetVendor\Doctrine\DBAL\ArrayParameterType;
|
||||
use MailPoetVendor\Doctrine\DBAL\ParameterType;
|
||||
use MailPoetVendor\Doctrine\ORM\EntityManager;
|
||||
use MailPoetVendor\Doctrine\ORM\Query\Expr\Join;
|
||||
|
||||
/**
|
||||
* @extends Repository<SubscriberEntity>
|
||||
*/
|
||||
class SubscribersRepository extends Repository {
|
||||
/** @var WPFunctions */
|
||||
private $wp;
|
||||
|
||||
protected $ignoreColumnsForUpdate = [
|
||||
'wp_user_id',
|
||||
'is_woocommerce_user',
|
||||
'email',
|
||||
'created_at',
|
||||
'last_subscribed_at',
|
||||
];
|
||||
|
||||
/** @var SubscriberChangesNotifier */
|
||||
private $changesNotifier;
|
||||
|
||||
/** @var SegmentsRepository */
|
||||
private $segmentsRepository;
|
||||
|
||||
public function __construct(
|
||||
EntityManager $entityManager,
|
||||
SubscriberChangesNotifier $changesNotifier,
|
||||
WPFunctions $wp,
|
||||
SegmentsRepository $segmentsRepository
|
||||
) {
|
||||
$this->wp = $wp;
|
||||
parent::__construct($entityManager);
|
||||
$this->changesNotifier = $changesNotifier;
|
||||
$this->segmentsRepository = $segmentsRepository;
|
||||
}
|
||||
|
||||
protected function getEntityClassName() {
|
||||
return SubscriberEntity::class;
|
||||
}
|
||||
|
||||
public function getTotalSubscribers(): int {
|
||||
return $this->getCountOfSubscribersForStates([
|
||||
SubscriberEntity::STATUS_SUBSCRIBED,
|
||||
SubscriberEntity::STATUS_UNCONFIRMED,
|
||||
SubscriberEntity::STATUS_INACTIVE,
|
||||
]);
|
||||
}
|
||||
|
||||
public function getCountOfSubscribersForStates(array $states): int {
|
||||
$query = $this->entityManager
|
||||
->createQueryBuilder()
|
||||
->select('count(n.id)')
|
||||
->from(SubscriberEntity::class, 'n')
|
||||
->where('n.deletedAt IS NULL AND n.status IN (:statuses)')
|
||||
->setParameter('statuses', $states)
|
||||
->getQuery();
|
||||
return intval($query->getSingleScalarResult());
|
||||
}
|
||||
|
||||
public function invalidateTotalSubscribersCache(): void {
|
||||
$this->wp->deleteTransient(Subscribers::SUBSCRIBERS_COUNT_CACHE_KEY);
|
||||
}
|
||||
|
||||
public function findBySegment(int $segmentId): array {
|
||||
return $this->entityManager
|
||||
->createQueryBuilder()
|
||||
->select('s')
|
||||
->from(SubscriberEntity::class, 's')
|
||||
->join('s.subscriberSegments', 'ss', Join::WITH, 'ss.segment = :segment')
|
||||
->setParameter('segment', $segmentId)
|
||||
->getQuery()->getResult();
|
||||
}
|
||||
|
||||
public function findExclusiveSubscribersBySegment(int $segmentId): array {
|
||||
return $this->entityManager->createQueryBuilder()
|
||||
->select('s')
|
||||
->from(SubscriberEntity::class, 's')
|
||||
->join('s.subscriberSegments', 'ss', Join::WITH, 'ss.segment = :segment')
|
||||
->leftJoin('s.subscriberSegments', 'ss2', Join::WITH, 'ss2.segment <> :segment AND ss2.status = :subscribed')
|
||||
->leftJoin('ss2.segment', 'seg', Join::WITH, 'seg.deletedAt IS NULL')
|
||||
->groupBy('s.id')
|
||||
->andHaving('COUNT(seg.id) = 0')
|
||||
->setParameter('segment', $segmentId)
|
||||
->setParameter('subscribed', SubscriberEntity::STATUS_SUBSCRIBED)
|
||||
->getQuery()->getResult();
|
||||
}
|
||||
|
||||
public function getWooCommerceSegmentSubscriber(string $email): ?SubscriberEntity {
|
||||
$subscriber = $this->doctrineRepository->createQueryBuilder('s')
|
||||
->join('s.subscriberSegments', 'ss')
|
||||
->join('ss.segment', 'sg', Join::WITH, 'sg.type = :typeWcUsers')
|
||||
->where('s.isWoocommerceUser = 1')
|
||||
->andWhere('s.status IN (:subscribed, :unconfirmed)')
|
||||
->andWhere('ss.status = :subscribed')
|
||||
->andWhere('s.email = :email')
|
||||
->setParameter('typeWcUsers', SegmentEntity::TYPE_WC_USERS)
|
||||
->setParameter('subscribed', SubscriberEntity::STATUS_SUBSCRIBED)
|
||||
->setParameter('unconfirmed', SubscriberEntity::STATUS_UNCONFIRMED)
|
||||
->setParameter('email', $email)
|
||||
->setMaxResults(1)
|
||||
->getQuery()
|
||||
->getOneOrNullResult();
|
||||
return $subscriber instanceof SubscriberEntity ? $subscriber : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int - number of processed ids
|
||||
*/
|
||||
public function bulkTrash(array $ids): int {
|
||||
if (empty($ids)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->entityManager->createQueryBuilder()
|
||||
->update(SubscriberEntity::class, 's')
|
||||
->set('s.deletedAt', 'CURRENT_TIMESTAMP()')
|
||||
->where('s.id IN (:ids)')
|
||||
->setParameter('ids', $ids)
|
||||
->getQuery()->execute();
|
||||
|
||||
$this->changesNotifier->subscribersUpdated($ids);
|
||||
$this->invalidateTotalSubscribersCache();
|
||||
return count($ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int - number of processed ids
|
||||
*/
|
||||
public function bulkRestore(array $ids): int {
|
||||
if (empty($ids)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->entityManager->createQueryBuilder()
|
||||
->update(SubscriberEntity::class, 's')
|
||||
->set('s.deletedAt', ':deletedAt')
|
||||
->where('s.id IN (:ids)')
|
||||
->setParameter('deletedAt', null)
|
||||
->setParameter('ids', $ids)
|
||||
->getQuery()->execute();
|
||||
|
||||
$this->changesNotifier->subscribersUpdated($ids);
|
||||
$this->invalidateTotalSubscribersCache();
|
||||
return count($ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int - number of processed ids
|
||||
*/
|
||||
public function bulkDelete(array $ids): int {
|
||||
if (empty($ids)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
$this->entityManager->transactional(function (EntityManager $entityManager) use ($ids, &$count) {
|
||||
// Delete subscriber segments
|
||||
$this->removeSubscribersFromAllSegments($ids);
|
||||
|
||||
// Delete subscriber custom fields
|
||||
$subscriberCustomFieldTable = $entityManager->getClassMetadata(SubscriberCustomFieldEntity::class)->getTableName();
|
||||
$subscriberTable = $entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
|
||||
$entityManager->getConnection()->executeStatement("
|
||||
DELETE scs FROM $subscriberCustomFieldTable scs
|
||||
JOIN $subscriberTable s ON s.`id` = scs.`subscriber_id`
|
||||
WHERE scs.`subscriber_id` IN (:ids)
|
||||
AND s.`is_woocommerce_user` = false
|
||||
AND s.`wp_user_id` IS NULL
|
||||
", ['ids' => $ids], ['ids' => ArrayParameterType::INTEGER]);
|
||||
|
||||
// Delete subscriber tags
|
||||
$subscriberTagTable = $entityManager->getClassMetadata(SubscriberTagEntity::class)->getTableName();
|
||||
$entityManager->getConnection()->executeStatement("
|
||||
DELETE st FROM $subscriberTagTable st
|
||||
JOIN $subscriberTable s ON s.`id` = st.`subscriber_id`
|
||||
WHERE st.`subscriber_id` IN (:ids)
|
||||
AND s.`is_woocommerce_user` = false
|
||||
AND s.`wp_user_id` IS NULL
|
||||
", ['ids' => $ids], ['ids' => ArrayParameterType::INTEGER]);
|
||||
|
||||
$queryBuilder = $entityManager->createQueryBuilder();
|
||||
$count = $queryBuilder->delete(SubscriberEntity::class, 's')
|
||||
->where('s.id IN (:ids)')
|
||||
->andWhere('s.wpUserId IS NULL')
|
||||
->andWhere('s.isWoocommerceUser = false')
|
||||
->setParameter('ids', $ids)
|
||||
->getQuery()->execute();
|
||||
});
|
||||
|
||||
$this->changesNotifier->subscribersDeleted($ids);
|
||||
$this->invalidateTotalSubscribersCache();
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int - number of processed ids
|
||||
*/
|
||||
public function bulkRemoveFromSegment(SegmentEntity $segment, array $ids): int {
|
||||
if (empty($ids)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$subscriberSegmentsTable = $this->entityManager->getClassMetadata(SubscriberSegmentEntity::class)->getTableName();
|
||||
$count = (int)$this->entityManager->getConnection()->executeStatement("
|
||||
DELETE ss FROM $subscriberSegmentsTable ss
|
||||
WHERE ss.`subscriber_id` IN (:ids)
|
||||
AND ss.`segment_id` = :segment_id
|
||||
", ['ids' => $ids, 'segment_id' => $segment->getId()], ['ids' => ArrayParameterType::INTEGER]);
|
||||
|
||||
$this->changesNotifier->subscribersUpdated($ids);
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int - number of processed ids
|
||||
*/
|
||||
public function bulkRemoveFromAllSegments(array $ids): int {
|
||||
$count = $this->removeSubscribersFromAllSegments($ids);
|
||||
$this->changesNotifier->subscribersUpdated($ids);
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int - number of processed ids
|
||||
*/
|
||||
public function bulkAddToSegment(SegmentEntity $segment, array $ids): int {
|
||||
$count = $this->addSubscribersToSegment($segment, $ids);
|
||||
$this->changesNotifier->subscribersUpdated($ids);
|
||||
return $count;
|
||||
}
|
||||
|
||||
public function woocommerceUserExists(): bool {
|
||||
$subscribers = $this->entityManager
|
||||
->createQueryBuilder()
|
||||
->select('s')
|
||||
->from(SubscriberEntity::class, 's')
|
||||
->join('s.subscriberSegments', 'ss')
|
||||
->join('ss.segment', 'segment')
|
||||
->where('segment.type = :segmentType')
|
||||
->setParameter('segmentType', SegmentEntity::TYPE_WC_USERS)
|
||||
->andWhere('s.isWoocommerceUser = true')
|
||||
->getQuery()
|
||||
->setMaxResults(1)
|
||||
->execute();
|
||||
|
||||
return count($subscribers) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int - number of processed ids
|
||||
*/
|
||||
public function bulkMoveToSegment(SegmentEntity $segment, array $ids): int {
|
||||
if (empty($ids)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->removeSubscribersFromAllSegments($ids);
|
||||
$count = $this->addSubscribersToSegment($segment, $ids);
|
||||
|
||||
$this->changesNotifier->subscribersUpdated($ids);
|
||||
return $count;
|
||||
}
|
||||
|
||||
public function bulkUnsubscribe(array $ids): int {
|
||||
$this->entityManager->createQueryBuilder()
|
||||
->update(SubscriberEntity::class, 's')
|
||||
->set('s.status', ':status')
|
||||
->where('s.id IN (:ids)')
|
||||
->setParameter('status', SubscriberEntity::STATUS_UNSUBSCRIBED)
|
||||
->setParameter('ids', $ids)
|
||||
->getQuery()->execute();
|
||||
|
||||
$this->changesNotifier->subscribersUpdated($ids);
|
||||
$this->invalidateTotalSubscribersCache();
|
||||
return count($ids);
|
||||
}
|
||||
|
||||
public function bulkUpdateLastSendingAt(array $ids, DateTimeInterface $dateTime): int {
|
||||
if (empty($ids)) {
|
||||
return 0;
|
||||
}
|
||||
$this->entityManager->createQueryBuilder()
|
||||
->update(SubscriberEntity::class, 's')
|
||||
->set('s.lastSendingAt', ':lastSendingAt')
|
||||
->where('s.id IN (:ids)')
|
||||
->setParameter('lastSendingAt', $dateTime)
|
||||
->setParameter('ids', $ids)
|
||||
->getQuery()
|
||||
->execute();
|
||||
return count($ids);
|
||||
}
|
||||
|
||||
public function bulkUpdateEngagementScoreUpdatedAt(array $ids, ?DateTimeInterface $dateTime): void {
|
||||
if (empty($ids)) {
|
||||
return;
|
||||
}
|
||||
$this->entityManager->createQueryBuilder()
|
||||
->update(SubscriberEntity::class, 's')
|
||||
->set('s.engagementScoreUpdatedAt', ':dateTime')
|
||||
->where('s.id IN (:ids)')
|
||||
->setParameter('dateTime', $dateTime)
|
||||
->setParameter('ids', $ids)
|
||||
->getQuery()
|
||||
->execute();
|
||||
}
|
||||
|
||||
public function findWpUserIdAndEmailByEmails(array $emails): array {
|
||||
return $this->entityManager->createQueryBuilder()
|
||||
->select('s.wpUserId AS wp_user_id, LOWER(s.email) AS email')
|
||||
->from(SubscriberEntity::class, 's')
|
||||
->where('s.email IN (:emails)')
|
||||
->setParameter('emails', $emails)
|
||||
->getQuery()->getResult();
|
||||
}
|
||||
|
||||
public function findIdAndEmailByEmails(array $emails): array {
|
||||
return $this->entityManager->createQueryBuilder()
|
||||
->select('s.id, s.email')
|
||||
->from(SubscriberEntity::class, 's')
|
||||
->where('s.email IN (:emails)')
|
||||
->setParameter('emails', $emails)
|
||||
->getQuery()->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int[]
|
||||
*/
|
||||
public function findIdsOfDeletedByEmails(array $emails): array {
|
||||
return $this->entityManager->createQueryBuilder()
|
||||
->select('s.id')
|
||||
->from(SubscriberEntity::class, 's')
|
||||
->where('s.email IN (:emails)')
|
||||
->andWhere('s.deletedAt IS NOT NULL')
|
||||
->setParameter('emails', $emails)
|
||||
->getQuery()->getResult();
|
||||
}
|
||||
|
||||
public function getCurrentWPUser(): ?SubscriberEntity {
|
||||
$wpUser = WPFunctions::get()->wpGetCurrentUser();
|
||||
if (empty($wpUser->ID)) {
|
||||
return null; // Don't look up a subscriber for guests
|
||||
}
|
||||
return $this->findOneBy(['wpUserId' => $wpUser->ID]);
|
||||
}
|
||||
|
||||
public function findByUpdatedScoreNotInLastMonth(int $limit): array {
|
||||
$dateTime = (new Carbon())->subMonths(1);
|
||||
return $this->entityManager->createQueryBuilder()
|
||||
->select('s')
|
||||
->from(SubscriberEntity::class, 's')
|
||||
->where('s.engagementScoreUpdatedAt IS NULL')
|
||||
->orWhere('s.engagementScoreUpdatedAt < :dateTime')
|
||||
->setParameter('dateTime', $dateTime)
|
||||
->getQuery()
|
||||
->setMaxResults($limit)
|
||||
->getResult();
|
||||
}
|
||||
|
||||
public function maybeUpdateLastEngagement(SubscriberEntity $subscriberEntity): void {
|
||||
$now = $this->getCurrentDateTime();
|
||||
// Do not update engagement if was recently updated to avoid unnecessary updates in DB
|
||||
if ($subscriberEntity->getLastEngagementAt() && $subscriberEntity->getLastEngagementAt() > $now->subMinute()) {
|
||||
return;
|
||||
}
|
||||
// Update last engagement
|
||||
$subscriberEntity->setLastEngagementAt($now);
|
||||
$this->flush();
|
||||
}
|
||||
|
||||
public function maybeUpdateLastOpenAt(SubscriberEntity $subscriberEntity): void {
|
||||
$now = $this->getCurrentDateTime();
|
||||
// Avoid unnecessary DB calls
|
||||
if ($subscriberEntity->getLastOpenAt() && $subscriberEntity->getLastOpenAt() > $now->subMinute()) {
|
||||
return;
|
||||
}
|
||||
$subscriberEntity->setLastOpenAt($now);
|
||||
$subscriberEntity->setLastEngagementAt($now);
|
||||
$this->flush();
|
||||
}
|
||||
|
||||
public function maybeUpdateLastClickAt(SubscriberEntity $subscriberEntity): void {
|
||||
$now = $this->getCurrentDateTime();
|
||||
// Avoid unnecessary DB calls
|
||||
if ($subscriberEntity->getLastClickAt() && $subscriberEntity->getLastClickAt() > $now->subMinute()) {
|
||||
return;
|
||||
}
|
||||
$subscriberEntity->setLastClickAt($now);
|
||||
$subscriberEntity->setLastEngagementAt($now);
|
||||
$this->flush();
|
||||
}
|
||||
|
||||
public function maybeUpdateLastPurchaseAt(SubscriberEntity $subscriberEntity): void {
|
||||
$now = $this->getCurrentDateTime();
|
||||
// Avoid unnecessary DB calls
|
||||
if ($subscriberEntity->getLastPurchaseAt() && $subscriberEntity->getLastPurchaseAt() > $now->subMinute()) {
|
||||
return;
|
||||
}
|
||||
$subscriberEntity->setLastPurchaseAt($now);
|
||||
$subscriberEntity->setLastEngagementAt($now);
|
||||
$this->flush();
|
||||
}
|
||||
|
||||
public function maybeUpdateLastPageViewAt(SubscriberEntity $subscriberEntity): void {
|
||||
$now = $this->getCurrentDateTime();
|
||||
// Avoid unnecessary DB calls
|
||||
if ($subscriberEntity->getLastPageViewAt() && $subscriberEntity->getLastPageViewAt() > $now->subMinute()) {
|
||||
return;
|
||||
}
|
||||
$subscriberEntity->setLastPageViewAt($now);
|
||||
$subscriberEntity->setLastEngagementAt($now);
|
||||
$this->flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $ids
|
||||
* @return string[]
|
||||
*/
|
||||
public function getUndeletedSubscribersEmailsByIds(array $ids): array {
|
||||
return $this->entityManager->createQueryBuilder()
|
||||
->select('s.email')
|
||||
->from(SubscriberEntity::class, 's')
|
||||
->where('s.deletedAt IS NULL')
|
||||
->andWhere('s.id IN (:ids)')
|
||||
->setParameter('ids', $ids)
|
||||
->getQuery()
|
||||
->getArrayResult();
|
||||
}
|
||||
|
||||
public function getMaxSubscriberId(): int {
|
||||
$maxSubscriberId = $this->entityManager->createQueryBuilder()
|
||||
->select('MAX(s.id)')
|
||||
->from(SubscriberEntity::class, 's')
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
|
||||
return intval($maxSubscriberId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns count of subscribers who subscribed after given date regardless of their current status.
|
||||
* @return int
|
||||
*/
|
||||
public function getCountOfLastSubscribedAfter(\DateTimeInterface $subscribedAfter): int {
|
||||
$result = $this->entityManager->createQueryBuilder()
|
||||
->select('COUNT(s.id)')
|
||||
->from(SubscriberEntity::class, 's')
|
||||
->where('s.lastSubscribedAt > :lastSubscribedAt')
|
||||
->andWhere('s.deletedAt IS NULL')
|
||||
->setParameter('lastSubscribedAt', $subscribedAfter)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
return intval($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns count of subscribers who unsubscribed after given date regardless of their current status.
|
||||
* @return int
|
||||
*/
|
||||
public function getCountOfUnsubscribedAfter(\DateTimeInterface $unsubscribedAfter): int {
|
||||
$result = $this->entityManager->createQueryBuilder()
|
||||
->select('COUNT(DISTINCT s.id)')
|
||||
->from(StatisticsUnsubscribeEntity::class, 'su')
|
||||
->join('su.subscriber', 's')
|
||||
->andWhere('su.createdAt > :unsubscribedAfter')
|
||||
->andWhere('s.deletedAt IS NULL')
|
||||
->setParameter('unsubscribedAfter', $unsubscribedAfter)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
return intval($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns count of subscribers who subscribed to a list after given date regardless of their current global status.
|
||||
*/
|
||||
public function getListLevelCountsOfSubscribedAfter(\DateTimeInterface $date): array {
|
||||
$data = $this->entityManager->createQueryBuilder()
|
||||
->select('seg.id, seg.name, seg.type, seg.averageEngagementScore, COUNT(ss.id) as count')
|
||||
->from(SubscriberSegmentEntity::class, 'ss')
|
||||
->join('ss.subscriber', 's')
|
||||
->join('ss.segment', 'seg')
|
||||
->where('ss.updatedAt > :date')
|
||||
->andWhere('ss.status = :segment_status')
|
||||
->andWhere('s.lastSubscribedAt > :date') // subscriber subscribed at some point after the date
|
||||
->andWhere('s.deletedAt IS NULL')
|
||||
->andWhere('seg.deletedAt IS NULL') // no trashed lists and disabled WP Users list
|
||||
->setParameter('date', $date)
|
||||
->setParameter('segment_status', SubscriberEntity::STATUS_SUBSCRIBED)
|
||||
->groupBy('ss.segment')
|
||||
->getQuery()
|
||||
->getArrayResult();
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns count of subscribers who unsubscribed from a list after given date regardless of their current global status.
|
||||
*/
|
||||
public function getListLevelCountsOfUnsubscribedAfter(\DateTimeInterface $date): array {
|
||||
return $this->entityManager->createQueryBuilder()
|
||||
->select('seg.id, seg.name, seg.type, seg.averageEngagementScore, COUNT(ss.id) as count')
|
||||
->from(SubscriberSegmentEntity::class, 'ss')
|
||||
->join('ss.subscriber', 's')
|
||||
->join('ss.segment', 'seg')
|
||||
->where('ss.updatedAt > :date')
|
||||
->andWhere('ss.status = :segment_status')
|
||||
->andWhere('s.deletedAt IS NULL')
|
||||
->andWhere('seg.deletedAt IS NULL') // no trashed lists and disabled WP Users list
|
||||
->setParameter('date', $date)
|
||||
->setParameter('segment_status', SubscriberEntity::STATUS_UNSUBSCRIBED)
|
||||
->groupBy('ss.segment')
|
||||
->getQuery()
|
||||
->getArrayResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int - number of processed ids
|
||||
*/
|
||||
public function bulkAddTag(TagEntity $tag, array $ids): int {
|
||||
$count = $this->addTagToSubscribers($tag, $ids);
|
||||
$this->changesNotifier->subscribersUpdated($ids);
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int - number of processed ids
|
||||
*/
|
||||
public function bulkRemoveTag(TagEntity $tag, array $ids): int {
|
||||
if (empty($ids)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$subscriberTagsTable = $this->entityManager->getClassMetadata(SubscriberTagEntity::class)->getTableName();
|
||||
$count = (int)$this->entityManager->getConnection()->executeStatement("
|
||||
DELETE st FROM $subscriberTagsTable st
|
||||
WHERE st.`subscriber_id` IN (:ids)
|
||||
AND st.`tag_id` = :tag_id
|
||||
", ['ids' => $ids, 'tag_id' => $tag->getId()], ['ids' => ArrayParameterType::INTEGER]);
|
||||
|
||||
$this->changesNotifier->subscribersUpdated($ids);
|
||||
return $count;
|
||||
}
|
||||
|
||||
public function removeOrphanedSubscribersFromWpSegment(): void {
|
||||
global $wpdb;
|
||||
|
||||
$segmentId = $this->segmentsRepository->getWpUsersSegment()->getId();
|
||||
|
||||
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
|
||||
$subscriberSegmentsTable = $this->entityManager->getClassMetadata(SubscriberSegmentEntity::class)->getTableName();
|
||||
|
||||
$this->entityManager->getConnection()->executeStatement(
|
||||
"DELETE s
|
||||
FROM {$subscribersTable} s
|
||||
INNER JOIN {$subscriberSegmentsTable} ss ON s.id = ss.subscriber_id
|
||||
LEFT JOIN {$wpdb->users} u ON s.wp_user_id = u.id
|
||||
WHERE ss.segment_id = :segmentId AND (u.id IS NULL OR s.email = '')",
|
||||
['segmentId' => $segmentId],
|
||||
['segmentId' => ParameterType::INTEGER]
|
||||
);
|
||||
}
|
||||
|
||||
public function removeByWpUserIds(array $wpUserIds) {
|
||||
$queryBuilder = $this->entityManager->createQueryBuilder();
|
||||
|
||||
$queryBuilder
|
||||
->delete(SubscriberEntity::class, 's')
|
||||
->where('s.wpUserId IN (:wpUserIds)')
|
||||
->setParameter('wpUserIds', $wpUserIds);
|
||||
|
||||
return $queryBuilder->getQuery()->execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int - number of processed ids
|
||||
*/
|
||||
private function removeSubscribersFromAllSegments(array $ids): int {
|
||||
if (empty($ids)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$subscriberSegmentsTable = $this->entityManager->getClassMetadata(SubscriberSegmentEntity::class)->getTableName();
|
||||
$segmentsTable = $this->entityManager->getClassMetadata(SegmentEntity::class)->getTableName();
|
||||
$count = (int)$this->entityManager->getConnection()->executeStatement("
|
||||
DELETE ss FROM $subscriberSegmentsTable ss
|
||||
JOIN $segmentsTable s ON s.id = ss.segment_id AND s.`type` = :typeDefault
|
||||
WHERE ss.`subscriber_id` IN (:ids)
|
||||
", [
|
||||
'ids' => $ids,
|
||||
'typeDefault' => SegmentEntity::TYPE_DEFAULT,
|
||||
], ['ids' => ArrayParameterType::INTEGER]);
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int - number of processed ids
|
||||
*/
|
||||
private function addSubscribersToSegment(SegmentEntity $segment, array $ids): int {
|
||||
if (empty($ids)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$subscribers = $this->entityManager
|
||||
->createQueryBuilder()
|
||||
->select('s')
|
||||
->from(SubscriberEntity::class, 's')
|
||||
->leftJoin('s.subscriberSegments', 'ss', Join::WITH, 'ss.segment = :segment')
|
||||
->where('s.id IN (:ids)')
|
||||
->andWhere('ss.segment IS NULL')
|
||||
->setParameter('ids', $ids)
|
||||
->setParameter('segment', $segment)
|
||||
->getQuery()->execute();
|
||||
|
||||
$this->entityManager->transactional(function (EntityManager $entityManager) use ($subscribers, $segment) {
|
||||
foreach ($subscribers as $subscriber) {
|
||||
$subscriberSegment = new SubscriberSegmentEntity($segment, $subscriber, SubscriberEntity::STATUS_SUBSCRIBED);
|
||||
$this->entityManager->persist($subscriberSegment);
|
||||
}
|
||||
$this->entityManager->flush();
|
||||
});
|
||||
|
||||
return count($subscribers);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int - number of processed ids
|
||||
*/
|
||||
private function addTagToSubscribers(TagEntity $tag, array $ids): int {
|
||||
if (empty($ids)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** @var SubscriberEntity[] $subscribers */
|
||||
$subscribers = $this->entityManager
|
||||
->createQueryBuilder()
|
||||
->select('s')
|
||||
->from(SubscriberEntity::class, 's')
|
||||
->leftJoin('s.subscriberTags', 'st', Join::WITH, 'st.tag = :tag')
|
||||
->where('s.id IN (:ids)')
|
||||
->andWhere('st.tag IS NULL')
|
||||
->setParameter('ids', $ids)
|
||||
->setParameter('tag', $tag)
|
||||
->getQuery()->execute();
|
||||
|
||||
$this->entityManager->wrapInTransaction(function (EntityManager $entityManager) use ($subscribers, $tag) {
|
||||
foreach ($subscribers as $subscriber) {
|
||||
$subscriberTag = new SubscriberTagEntity($tag, $subscriber);
|
||||
$entityManager->persist($subscriberTag);
|
||||
}
|
||||
$entityManager->flush();
|
||||
});
|
||||
|
||||
return count($subscribers);
|
||||
}
|
||||
|
||||
private function getCurrentDateTime(): Carbon {
|
||||
return Carbon::now()->setMilliseconds(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<?php
|
||||
Reference in New Issue
Block a user