This commit is contained in:
emmymayo
2025-02-05 23:15:46 +01:00
commit 7269c99357
16995 changed files with 3389680 additions and 0 deletions
@@ -0,0 +1,38 @@
<?php declare(strict_types = 1);
namespace MailPoet\Segments\DynamicSegments;
if (!defined('ABSPATH')) exit;
use MailPoet\Doctrine\Repository;
use MailPoet\Entities\DynamicSegmentFilterEntity;
use MailPoetVendor\Doctrine\ORM\EntityManager;
/**
* @extends Repository<DynamicSegmentFilterEntity>
*/
class DynamicSegmentFilterRepository extends Repository {
public function __construct(
EntityManager $entityManager
) {
parent::__construct($entityManager);
}
protected function getEntityClassName() {
return DynamicSegmentFilterEntity::class;
}
public function findOnyByFilterTypeAndAction(string $filterType, string $action): ?DynamicSegmentFilterEntity {
return $this->entityManager->createQueryBuilder()
->select('dsf')
->from(DynamicSegmentFilterEntity::class, 'dsf')
->where('dsf.filterData.filterType = :filterType')
->andWhere('dsf.filterData.action = :action')
->setParameter('filterType', $filterType)
->setParameter('action', $action)
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
}
}
@@ -0,0 +1,18 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Segments\DynamicSegments;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\SegmentEntity;
use MailPoet\Segments\SegmentListingRepository;
use MailPoetVendor\Doctrine\ORM\QueryBuilder;
class DynamicSegmentsListingRepository extends SegmentListingRepository {
protected function applyParameters(QueryBuilder $queryBuilder, array $parameters): void {
$queryBuilder
->andWhere('s.type = :type')
->setParameter('type', SegmentEntity::TYPE_DYNAMIC);
}
}
@@ -0,0 +1,29 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Segments\DynamicSegments\Exceptions;
if (!defined('ABSPATH')) exit;
use MailPoet\InvalidStateException;
class InvalidFilterException extends InvalidStateException {
const MISSING_TYPE = 1;
const INVALID_TYPE = 2;
const MISSING_ROLE = 3;
const MISSING_ACTION = 4;
const MISSING_NEWSLETTER_ID = 5;
const MISSING_CATEGORY_ID = 6;
const MISSING_PRODUCT_ID = 7;
const INVALID_EMAIL_ACTION = 8;
const MISSING_VALUE = 9;
const MISSING_NUMBER_OF_ORDERS_FIELDS = 10;
const MISSING_TOTAL_SPENT_FIELDS = 11;
const INVALID_DATE_VALUE = 12;
const MISSING_COUNTRY = 13;
const MISSING_FILTER = 14;
const MISSING_OPERATOR = 15;
const MISSING_PLAN_ID = 16;
const MISSING_SINGLE_ORDER_VALUE_FIELDS = 17;
const MISSING_AVERAGE_SPENT_FIELDS = 18;
};
@@ -0,0 +1,591 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Segments\DynamicSegments;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\DynamicSegmentFilterData;
use MailPoet\Segments\DynamicSegments\Exceptions\InvalidFilterException;
use MailPoet\Segments\DynamicSegments\Filters\AutomationsEvents;
use MailPoet\Segments\DynamicSegments\Filters\DateFilterHelper;
use MailPoet\Segments\DynamicSegments\Filters\EmailAction;
use MailPoet\Segments\DynamicSegments\Filters\EmailActionClickAny;
use MailPoet\Segments\DynamicSegments\Filters\EmailOpensAbsoluteCountAction;
use MailPoet\Segments\DynamicSegments\Filters\EmailsReceived;
use MailPoet\Segments\DynamicSegments\Filters\FilterHelper;
use MailPoet\Segments\DynamicSegments\Filters\MailPoetCustomFields;
use MailPoet\Segments\DynamicSegments\Filters\NumberOfClicks;
use MailPoet\Segments\DynamicSegments\Filters\SubscriberDateField;
use MailPoet\Segments\DynamicSegments\Filters\SubscriberScore;
use MailPoet\Segments\DynamicSegments\Filters\SubscriberSegment;
use MailPoet\Segments\DynamicSegments\Filters\SubscriberSubscribedViaForm;
use MailPoet\Segments\DynamicSegments\Filters\SubscriberTag;
use MailPoet\Segments\DynamicSegments\Filters\SubscriberTextField;
use MailPoet\Segments\DynamicSegments\Filters\WooCommerceAverageSpent;
use MailPoet\Segments\DynamicSegments\Filters\WooCommerceCategory;
use MailPoet\Segments\DynamicSegments\Filters\WooCommerceCountry;
use MailPoet\Segments\DynamicSegments\Filters\WooCommerceCustomerTextField;
use MailPoet\Segments\DynamicSegments\Filters\WooCommerceFirstOrder;
use MailPoet\Segments\DynamicSegments\Filters\WooCommerceMembership;
use MailPoet\Segments\DynamicSegments\Filters\WooCommerceNumberOfOrders;
use MailPoet\Segments\DynamicSegments\Filters\WooCommerceNumberOfReviews;
use MailPoet\Segments\DynamicSegments\Filters\WooCommerceProduct;
use MailPoet\Segments\DynamicSegments\Filters\WooCommercePurchaseDate;
use MailPoet\Segments\DynamicSegments\Filters\WooCommercePurchasedWithAttribute;
use MailPoet\Segments\DynamicSegments\Filters\WooCommerceSingleOrderValue;
use MailPoet\Segments\DynamicSegments\Filters\WooCommerceSubscription;
use MailPoet\Segments\DynamicSegments\Filters\WooCommerceTag;
use MailPoet\Segments\DynamicSegments\Filters\WooCommerceTotalSpent;
use MailPoet\Segments\DynamicSegments\Filters\WooCommerceUsedCouponCode;
use MailPoet\Segments\DynamicSegments\Filters\WooCommerceUsedPaymentMethod;
use MailPoet\Segments\DynamicSegments\Filters\WooCommerceUsedShippingMethod;
use MailPoet\WP\Functions as WPFunctions;
class FilterDataMapper {
private WPFunctions $wp;
private DateFilterHelper $dateFilterHelper;
private WooCommerceNumberOfReviews $wooCommerceNumberOfReviews;
private FilterHelper $filterHelper;
private WooCommerceUsedCouponCode $wooCommerceUsedCouponCode;
private WooCommerceTag $wooCommerceTag;
private WooCommercePurchasedWithAttribute $wooCommercePurchasedWithAttribute;
public function __construct(
WPFunctions $wp,
DateFilterHelper $dateFilterHelper,
FilterHelper $filterHelper,
WooCommerceNumberOfReviews $wooCommerceNumberOfReviews,
WooCommerceUsedCouponCode $wooCommerceUsedCouponCode,
WooCommercePurchasedWithAttribute $wooCommercePurchasedWithAttribute,
WooCommerceTag $wooCommerceTag
) {
$this->wp = $wp;
$this->dateFilterHelper = $dateFilterHelper;
$this->filterHelper = $filterHelper;
$this->wooCommerceNumberOfReviews = $wooCommerceNumberOfReviews;
$this->wooCommerceUsedCouponCode = $wooCommerceUsedCouponCode;
$this->wooCommercePurchasedWithAttribute = $wooCommercePurchasedWithAttribute;
$this->wooCommerceTag = $wooCommerceTag;
}
/**
* @param array $data
* @return DynamicSegmentFilterData[]
*/
public function map(array $data = []): array {
if (!isset($data['filters']) || count($data['filters'] ?? []) < 1) {
throw new InvalidFilterException('Filters are missing', InvalidFilterException::MISSING_FILTER);
}
$processFilter = function ($filter, $data) {
$filter['connect'] = $data['filters_connect'] ?? DynamicSegmentFilterData::CONNECT_TYPE_AND;
return $this->createFilter($filter);
};
$wpFilterName = 'mailpoet_dynamic_segments_filters_map';
if ($this->wp->hasFilter($wpFilterName)) {
return $this->wp->applyFilters($wpFilterName, $data, $processFilter);
}
$filter = reset($data['filters']);
return [$processFilter($filter, $data)];
}
private function createFilter(array $filterData): DynamicSegmentFilterData {
if (isset($filterData['days']) && !isset($filterData['timeframe'])) {
// Backwards compatibility for filters created before time period component had "over all time" option
$filterData['timeframe'] = DynamicSegmentFilterData::TIMEFRAME_IN_THE_LAST;
}
switch ($this->getSegmentType($filterData)) {
case DynamicSegmentFilterData::TYPE_AUTOMATIONS:
return $this->createAutomations($filterData);
case DynamicSegmentFilterData::TYPE_USER_ROLE:
return $this->createSubscriber($filterData);
case DynamicSegmentFilterData::TYPE_EMAIL:
return $this->createEmail($filterData);
case DynamicSegmentFilterData::TYPE_WOOCOMMERCE:
return $this->createWooCommerce($filterData);
case DynamicSegmentFilterData::TYPE_WOOCOMMERCE_MEMBERSHIP:
return $this->createWooCommerceMembership($filterData);
case DynamicSegmentFilterData::TYPE_WOOCOMMERCE_SUBSCRIPTION:
return $this->createWooCommerceSubscription($filterData);
default:
throw new InvalidFilterException('Invalid type', InvalidFilterException::INVALID_TYPE);
}
}
/**
* @throws InvalidFilterException
*/
private function getSegmentType(array $data): string {
if (!isset($data['segmentType'])) {
throw new InvalidFilterException('Segment type is not set', InvalidFilterException::MISSING_TYPE);
}
return $data['segmentType'];
}
private function createAutomations(array $data): DynamicSegmentFilterData {
if (empty($data['action'])) {
throw new InvalidFilterException('Missing automations filter action', InvalidFilterException::MISSING_ACTION);
}
if (in_array($data['action'], AutomationsEvents::SUPPORTED_ACTIONS)) {
if (
!isset($data['operator']) || !in_array($data['operator'], [
DynamicSegmentFilterData::OPERATOR_ANY,
DynamicSegmentFilterData::OPERATOR_ALL,
DynamicSegmentFilterData::OPERATOR_NONE,
])
) {
throw new InvalidFilterException('Missing operator', InvalidFilterException::MISSING_OPERATOR);
}
if (
!isset($data['automation_ids'])
|| !is_array($data['automation_ids'])
|| count($data['automation_ids']) < 1
) {
throw new InvalidFilterException('Missing automation IDs', InvalidFilterException::MISSING_VALUE);
}
return new DynamicSegmentFilterData(DynamicSegmentFilterData::TYPE_AUTOMATIONS, $data['action'], [
'action' => $data['action'],
'automation_ids' => $data['automation_ids'],
'operator' => $data['operator'],
'connect' => $data['connect'],
]);
}
throw new InvalidFilterException('Unknown automations action', InvalidFilterException::MISSING_ACTION);
}
private function createSubscriber(array $data): DynamicSegmentFilterData {
if (empty($data['action'])) {
$data['action'] = DynamicSegmentFilterData::TYPE_USER_ROLE;
}
if ($data['action'] === SubscriberScore::TYPE) {
if (!isset($data['value'])) {
throw new InvalidFilterException('Missing engagement score value', InvalidFilterException::MISSING_VALUE);
}
return new DynamicSegmentFilterData(DynamicSegmentFilterData::TYPE_USER_ROLE, $data['action'], [
'value' => $data['value'],
'operator' => $data['operator'] ?? SubscriberScore::HIGHER_THAN,
'connect' => $data['connect'],
]);
}
if ($data['action'] === SubscriberSegment::TYPE) {
if (empty($data['segments'])) {
throw new InvalidFilterException('Missing segments', InvalidFilterException::MISSING_VALUE);
}
return new DynamicSegmentFilterData(DynamicSegmentFilterData::TYPE_USER_ROLE, $data['action'], [
'segments' => array_map(function ($segmentId) {
return intval($segmentId);
}, $data['segments']),
'operator' => $data['operator'] ?? DynamicSegmentFilterData::OPERATOR_ANY,
'connect' => $data['connect'],
]);
}
if ($data['action'] === MailPoetCustomFields::TYPE) {
if (empty($data['custom_field_id'])) {
throw new InvalidFilterException('Missing custom field id', InvalidFilterException::MISSING_VALUE);
}
if (empty($data['custom_field_type'])) {
throw new InvalidFilterException('Missing custom field type', InvalidFilterException::MISSING_VALUE);
}
if (!isset($data['value'])) {
throw new InvalidFilterException('Missing value', InvalidFilterException::MISSING_VALUE);
}
$filterData = [
'value' => $data['value'],
'custom_field_id' => $data['custom_field_id'],
'custom_field_type' => $data['custom_field_type'],
'connect' => $data['connect'],
];
if (!empty($data['date_type'])) {
$filterData['date_type'] = $data['date_type'];
}
if (!empty($data['operator'])) {
$filterData['operator'] = $data['operator'];
}
return new DynamicSegmentFilterData(DynamicSegmentFilterData::TYPE_USER_ROLE, $data['action'], $filterData);
}
if ($data['action'] === SubscriberTag::TYPE) {
if (empty($data['tags'])) {
throw new InvalidFilterException('Missing tags', InvalidFilterException::MISSING_VALUE);
}
return new DynamicSegmentFilterData(DynamicSegmentFilterData::TYPE_USER_ROLE, $data['action'], [
'tags' => array_map(function ($tagId) {
return intval($tagId);
}, $data['tags']),
'operator' => $data['operator'] ?? DynamicSegmentFilterData::OPERATOR_ANY,
'connect' => $data['connect'],
]);
}
if ($data['action'] === SubscriberSubscribedViaForm::TYPE) {
if (!isset($data['form_ids']) || empty($data['form_ids'])) {
throw new InvalidFilterException('Missing at least one form ID', InvalidFilterException::MISSING_VALUE);
}
if (!isset($data['operator']) || !in_array($data['operator'], [DynamicSegmentFilterData::OPERATOR_ANY, DynamicSegmentFilterData::OPERATOR_NONE])) {
throw new InvalidFilterException('Missing valid operator', InvalidFilterException::MISSING_VALUE);
}
return new DynamicSegmentFilterData(DynamicSegmentFilterData::TYPE_USER_ROLE, $data['action'], [
'form_ids' => array_map(function($formId) {
return intval($formId);
}, $data['form_ids']),
'operator' => $data['operator'],
'connect' => $data['connect'],
]);
}
if (in_array($data['action'], SubscriberTextField::TYPES)) {
if (empty($data['value'])) {
throw new InvalidFilterException('Missing value', InvalidFilterException::MISSING_VALUE);
}
if (empty($data['operator'])) {
throw new InvalidFilterException('Missing operator', InvalidFilterException::MISSING_OPERATOR);
}
if (!in_array($data['operator'], DynamicSegmentFilterData::TEXT_FIELD_OPERATORS)) {
throw new InvalidFilterException('Invalid operator', InvalidFilterException::MISSING_OPERATOR);
}
return new DynamicSegmentFilterData(DynamicSegmentFilterData::TYPE_USER_ROLE, $data['action'], [
'value' => $data['value'],
'operator' => $data['operator'],
'action' => $data['action'],
'connect' => $data['connect'],
]);
}
if (in_array($data['action'], SubscriberDateField::TYPES)) {
if (empty($data['value'])) {
throw new InvalidFilterException('Missing date value', InvalidFilterException::MISSING_VALUE);
}
if (empty($data['operator'])) {
throw new InvalidFilterException('Missing operator', InvalidFilterException::MISSING_OPERATOR);
}
if (!in_array($data['operator'], $this->dateFilterHelper->getValidOperators())) {
throw new InvalidFilterException('Invalid operator', InvalidFilterException::MISSING_OPERATOR);
}
return new DynamicSegmentFilterData(DynamicSegmentFilterData::TYPE_USER_ROLE, $data['action'], [
'value' => $data['value'],
'operator' => $data['operator'],
'connect' => $data['connect'],
]);
}
if (empty($data['wordpressRole'])) {
throw new InvalidFilterException('Missing role', InvalidFilterException::MISSING_ROLE);
}
return new DynamicSegmentFilterData(DynamicSegmentFilterData::TYPE_USER_ROLE, $data['action'], [
'wordpressRole' => $data['wordpressRole'],
'operator' => $data['operator'] ?? DynamicSegmentFilterData::OPERATOR_ANY,
'connect' => $data['connect'],
]);
}
/**
* @throws InvalidFilterException
*/
private function createEmail(array $data): DynamicSegmentFilterData {
if (empty($data['action'])) {
throw new InvalidFilterException('Missing action', InvalidFilterException::MISSING_ACTION);
}
if (!in_array($data['action'], EmailAction::ALLOWED_ACTIONS)) {
throw new InvalidFilterException('Invalid email action', InvalidFilterException::INVALID_EMAIL_ACTION);
}
if (
($data['action'] === EmailOpensAbsoluteCountAction::TYPE)
|| ($data['action'] === EmailOpensAbsoluteCountAction::MACHINE_TYPE)
) {
return $this->createEmailOpensAbsoluteCount($data);
}
if ($data['action'] === EmailActionClickAny::TYPE) {
return new DynamicSegmentFilterData(DynamicSegmentFilterData::TYPE_EMAIL, $data['action'], [
'connect' => $data['connect'],
]);
}
$filterData = [
'connect' => $data['connect'],
'operator' => $data['operator'] ?? DynamicSegmentFilterData::OPERATOR_ANY,
];
if ($data['action'] === EmailsReceived::ACTION) {
$this->filterHelper->validateDaysPeriodData($data);
if (!isset($data['emails'])) {
throw new InvalidFilterException('Missing email count value', InvalidFilterException::MISSING_VALUE);
}
$filterData['emails'] = $data['emails'];
$filterData['operator'] = $data['operator'];
$filterData['timeframe'] = $data['timeframe'];
$filterData['connect'] = $data['connect'];
$filterData['days'] = $data['days'] ?? 0;
return new DynamicSegmentFilterData(DynamicSegmentFilterData::TYPE_EMAIL, $data['action'], $filterData);
}
if ($data['action'] === NumberOfClicks::ACTION) {
$this->filterHelper->validateDaysPeriodData($data);
if (!isset($data['clicks'])) {
throw new InvalidFilterException('Missing click count value', InvalidFilterException::MISSING_VALUE);
}
$filterData['clicks'] = $data['clicks'];
$filterData['operator'] = $data['operator'];
$filterData['timeframe'] = $data['timeframe'];
$filterData['connect'] = $data['connect'];
$filterData['days'] = $data['days'] ?? 0;
return new DynamicSegmentFilterData(DynamicSegmentFilterData::TYPE_EMAIL, $data['action'], $filterData);
}
if (($data['action'] === EmailAction::ACTION_CLICKED)) {
if (empty($data['newsletter_id'])) {
throw new InvalidFilterException('Missing newsletter id', InvalidFilterException::MISSING_NEWSLETTER_ID);
}
$filterData['newsletter_id'] = $data['newsletter_id'];
} else {
if (empty($data['newsletters']) || !is_array($data['newsletters'])) {
throw new InvalidFilterException('Missing newsletter', InvalidFilterException::MISSING_NEWSLETTER_ID);
}
$filterData['newsletters'] = array_map(function ($segmentId) {
return intval($segmentId);
}, $data['newsletters']);
}
$filterType = DynamicSegmentFilterData::TYPE_EMAIL;
$action = $data['action'];
if (isset($data['link_ids']) && is_array($data['link_ids'])) {
$filterData['link_ids'] = array_map('intval', $data['link_ids']);
if (!isset($data['operator'])) {
throw new InvalidFilterException('Missing operator', InvalidFilterException::MISSING_OPERATOR);
}
$filterData['operator'] = $data['operator'];
}
return new DynamicSegmentFilterData($filterType, $action, $filterData);
}
/**
* @throws InvalidFilterException
*/
private function createEmailOpensAbsoluteCount(array $data): DynamicSegmentFilterData {
if (!isset($data['opens'])) {
throw new InvalidFilterException('Missing number of opens', InvalidFilterException::MISSING_VALUE);
}
$this->filterHelper->validateDaysPeriodData($data);
$filterData = [
'opens' => $data['opens'],
'days' => $data['days'] ?? 0,
'operator' => $data['operator'] ?? 'more',
'timeframe' => $data['timeframe'] ?? DynamicSegmentFilterData::TIMEFRAME_IN_THE_LAST, // backwards compatibility
'connect' => $data['connect'],
];
$filterType = DynamicSegmentFilterData::TYPE_EMAIL;
$action = $data['action'];
return new DynamicSegmentFilterData($filterType, $action, $filterData);
}
/**
* @throws InvalidFilterException
*/
private function createWooCommerce(array $data): DynamicSegmentFilterData {
if (empty($data['action'])) {
throw new InvalidFilterException('Missing action', InvalidFilterException::MISSING_ACTION);
}
$filterData = [
'connect' => $data['connect'],
];
$filterType = DynamicSegmentFilterData::TYPE_WOOCOMMERCE;
$action = $data['action'];
if ($data['action'] === WooCommerceCategory::ACTION_CATEGORY) {
if (!isset($data['category_ids'])) {
throw new InvalidFilterException('Missing category', InvalidFilterException::MISSING_CATEGORY_ID);
}
if (!isset($data['operator'])) {
throw new InvalidFilterException('Missing operator', InvalidFilterException::MISSING_OPERATOR);
}
$filterData['operator'] = $data['operator'];
$filterData['category_ids'] = $data['category_ids'];
} elseif ($data['action'] === WooCommerceProduct::ACTION_PRODUCT) {
if (!isset($data['product_ids'])) {
throw new InvalidFilterException('Missing product', InvalidFilterException::MISSING_PRODUCT_ID);
}
if (!isset($data['operator'])) {
throw new InvalidFilterException('Missing operator', InvalidFilterException::MISSING_OPERATOR);
}
$filterData['operator'] = $data['operator'];
$filterData['product_ids'] = $data['product_ids'];
} elseif ($data['action'] === WooCommerceCountry::ACTION_CUSTOMER_COUNTRY) {
if (!isset($data['country_code'])) {
throw new InvalidFilterException('Missing country', InvalidFilterException::MISSING_COUNTRY);
}
$filterData['country_code'] = $data['country_code'];
$filterData['operator'] = $data['operator'] ?? DynamicSegmentFilterData::OPERATOR_ANY;
} elseif (in_array($data['action'], WooCommerceNumberOfOrders::ACTIONS)) {
$this->filterHelper->validateDaysPeriodData($data);
if (
!isset($data['number_of_orders_type'])
|| !isset($data['number_of_orders_count']) || $data['number_of_orders_count'] < 0
) {
throw new InvalidFilterException('Missing required fields', InvalidFilterException::MISSING_NUMBER_OF_ORDERS_FIELDS);
}
$filterData['number_of_orders_type'] = $data['number_of_orders_type'];
$filterData['number_of_orders_count'] = $data['number_of_orders_count'];
$filterData['days'] = $data['days'] ?? 0;
$filterData['timeframe'] = $data['timeframe'];
} elseif ($data['action'] === WooCommerceNumberOfReviews::ACTION) {
$this->wooCommerceNumberOfReviews->validateFilterData($data);
$filterData['days'] = $data['days'];
$filterData['count_type'] = $data['count_type'];
$filterData['count'] = $data['count'];
$filterData['rating'] = $data['rating'];
$filterData['timeframe'] = $data['timeframe'];
} elseif ($data['action'] === WooCommerceTotalSpent::ACTION_TOTAL_SPENT) {
$this->filterHelper->validateDaysPeriodData($data);
if (
!isset($data['total_spent_type'])
|| !isset($data['total_spent_amount']) || $data['total_spent_amount'] < 0
) {
throw new InvalidFilterException('Missing required fields', InvalidFilterException::MISSING_TOTAL_SPENT_FIELDS);
}
$filterData['total_spent_type'] = $data['total_spent_type'];
$filterData['total_spent_amount'] = $data['total_spent_amount'];
$filterData['days'] = $data['days'] ?? 0;
$filterData['timeframe'] = $data['timeframe'];
} elseif ($data['action'] === WooCommerceSingleOrderValue::ACTION_SINGLE_ORDER_VALUE) {
$this->filterHelper->validateDaysPeriodData($data);
if (
!isset($data['single_order_value_type'])
|| !isset($data['single_order_value_amount']) || $data['single_order_value_amount'] < 0
) {
throw new InvalidFilterException('Missing required fields', InvalidFilterException::MISSING_SINGLE_ORDER_VALUE_FIELDS);
}
$filterData['single_order_value_type'] = $data['single_order_value_type'];
$filterData['single_order_value_amount'] = $data['single_order_value_amount'];
$filterData['days'] = $data['days'] ?? 0;
$filterData['timeframe'] = $data['timeframe'];
} elseif (in_array($data['action'], [WooCommercePurchaseDate::ACTION, WooCommerceFirstOrder::ACTION])) {
$filterData['operator'] = $data['operator'];
$filterData['value'] = $data['value'];
} elseif ($data['action'] === WooCommerceAverageSpent::ACTION) {
$this->filterHelper->validateDaysPeriodData($data);
if (
!isset($data['average_spent_type'])
|| !isset($data['average_spent_amount']) || $data['average_spent_amount'] < 0
) {
throw new InvalidFilterException('Missing required fields', InvalidFilterException::MISSING_AVERAGE_SPENT_FIELDS);
}
$filterData['days'] = $data['days'] ?? 0;
$filterData['timeframe'] = $data['timeframe'];
$filterData['average_spent_amount'] = $data['average_spent_amount'];
$filterData['average_spent_type'] = $data['average_spent_type'];
} elseif ($data['action'] === WooCommerceUsedPaymentMethod::ACTION) {
if (!isset($data['operator']) || !in_array($data['operator'], WooCommerceUsedPaymentMethod::VALID_OPERATORS, true)) {
throw new InvalidFilterException('Missing operator', InvalidFilterException::MISSING_OPERATOR);
}
if (!isset($data['payment_methods']) || !is_array($data['payment_methods']) || empty($data['payment_methods'])) {
throw new InvalidFilterException('Missing payment gateways', InvalidFilterException::MISSING_VALUE);
}
$this->filterHelper->validateDaysPeriodData($data);
$filterData['operator'] = $data['operator'];
$filterData['payment_methods'] = $data['payment_methods'];
$filterData['days'] = intval($data['days'] ?? 0);
$filterData['timeframe'] = $data['timeframe'];
} elseif ($data['action'] === WooCommerceUsedShippingMethod::ACTION) {
if (!isset($data['operator']) || !in_array($data['operator'], WooCommerceUsedShippingMethod::VALID_OPERATORS, true)) {
throw new InvalidFilterException('Missing operator', InvalidFilterException::MISSING_OPERATOR);
}
if (!isset($data['shipping_methods']) || !is_array($data['shipping_methods']) || empty($data['shipping_methods'])) {
throw new InvalidFilterException('Missing shipping methods', InvalidFilterException::MISSING_VALUE);
}
$this->filterHelper->validateDaysPeriodData($data);
$filterData['operator'] = $data['operator'];
$filterData['shipping_methods'] = $data['shipping_methods'];
$filterData['days'] = intval($data['days'] ?? 0);
$filterData['timeframe'] = $data['timeframe'];
} elseif (in_array($data['action'], WooCommerceCustomerTextField::ACTIONS)) {
if (empty($data['value'])) {
throw new InvalidFilterException('Missing value', InvalidFilterException::MISSING_VALUE);
}
if (empty($data['operator'])) {
throw new InvalidFilterException('Missing operator', InvalidFilterException::MISSING_OPERATOR);
}
if (!in_array($data['operator'], DynamicSegmentFilterData::TEXT_FIELD_OPERATORS)) {
throw new InvalidFilterException('Invalid operator', InvalidFilterException::MISSING_OPERATOR);
}
$filterData['value'] = $data['value'];
$filterData['operator'] = $data['operator'];
$filterData['action'] = $data['action'];
} elseif ($data['action'] === WooCommerceUsedCouponCode::ACTION) {
$this->wooCommerceUsedCouponCode->validateFilterData($data);
$filterData['operator'] = $data['operator'];
$filterData['coupon_code_ids'] = $data['coupon_code_ids'];
$filterData['days'] = $data['days'];
$filterData['timeframe'] = $data['timeframe'];
} elseif ($data['action'] === WooCommercePurchasedWithAttribute::ACTION) {
$this->wooCommercePurchasedWithAttribute->validateFilterData($data);
$filterData['operator'] = $data['operator'];
$filterData['attribute_taxonomy_slug'] = $data['attribute_taxonomy_slug'] ?? null;
$filterData['attribute_term_ids'] = $data['attribute_term_ids'] ?? null;
$filterData['attribute_type'] = $data['attribute_type'];
$filterData['attribute_local_name'] = $data['attribute_local_name'] ?? null;
$filterData['attribute_local_values'] = $data['attribute_local_values'] ?? null;
} elseif ($data['action'] === WooCommerceTag::ACTION) {
$this->wooCommerceTag->validateFilterData($data);
$filterData['operator'] = $data['operator'];
$filterData['tag_ids'] = $data['tag_ids'];
} else {
throw new InvalidFilterException("Unknown action " . $data['action'], InvalidFilterException::MISSING_ACTION);
}
return new DynamicSegmentFilterData($filterType, $action, $filterData);
}
/**
* @throws InvalidFilterException
*/
private function createWooCommerceMembership(array $data): DynamicSegmentFilterData {
if (empty($data['action'])) {
throw new InvalidFilterException('Missing action', InvalidFilterException::MISSING_ACTION);
}
$filterData = [
'connect' => $data['connect'],
];
$filterType = DynamicSegmentFilterData::TYPE_WOOCOMMERCE_MEMBERSHIP;
$action = $data['action'];
if ($data['action'] === WooCommerceMembership::ACTION_MEMBER_OF) {
if (!isset($data['plan_ids']) || !is_array($data['plan_ids'])) {
throw new InvalidFilterException('Missing plan', InvalidFilterException::MISSING_PLAN_ID);
}
if (!isset($data['operator'])) {
throw new InvalidFilterException('Missing operator', InvalidFilterException::MISSING_OPERATOR);
}
$filterData['operator'] = $data['operator'];
$filterData['plan_ids'] = $data['plan_ids'];
} else {
throw new InvalidFilterException("Unknown action " . $data['action'], InvalidFilterException::MISSING_ACTION);
}
return new DynamicSegmentFilterData($filterType, $action, $filterData);
}
/**
* @throws InvalidFilterException
*/
private function createWooCommerceSubscription(array $data): DynamicSegmentFilterData {
if (empty($data['action'])) {
throw new InvalidFilterException('Missing action', InvalidFilterException::MISSING_ACTION);
}
$filterData = [
'connect' => $data['connect'],
];
$filterType = DynamicSegmentFilterData::TYPE_WOOCOMMERCE_SUBSCRIPTION;
$action = $data['action'];
if ($data['action'] === WooCommerceSubscription::ACTION_HAS_ACTIVE) {
if (!isset($data['product_ids']) || !is_array($data['product_ids'])) {
throw new InvalidFilterException('Missing product', InvalidFilterException::MISSING_PRODUCT_ID);
}
if (!isset($data['operator'])) {
throw new InvalidFilterException('Missing operator', InvalidFilterException::MISSING_OPERATOR);
}
$filterData['operator'] = $data['operator'];
$filterData['product_ids'] = $data['product_ids'];
} else {
throw new InvalidFilterException("Unknown action " . $data['action'], InvalidFilterException::MISSING_ACTION);
}
return new DynamicSegmentFilterData($filterType, $action, $filterData);
}
}
@@ -0,0 +1,318 @@
<?php declare(strict_types = 1);
namespace MailPoet\Segments\DynamicSegments;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\DynamicSegmentFilterData;
use MailPoet\Entities\DynamicSegmentFilterEntity;
use MailPoet\Segments\DynamicSegments\Exceptions\InvalidFilterException;
use MailPoet\Segments\DynamicSegments\Filters\AutomationsEvents;
use MailPoet\Segments\DynamicSegments\Filters\EmailAction;
use MailPoet\Segments\DynamicSegments\Filters\EmailActionClickAny;
use MailPoet\Segments\DynamicSegments\Filters\EmailOpensAbsoluteCountAction;
use MailPoet\Segments\DynamicSegments\Filters\EmailsReceived;
use MailPoet\Segments\DynamicSegments\Filters\Filter;
use MailPoet\Segments\DynamicSegments\Filters\MailPoetCustomFields;
use MailPoet\Segments\DynamicSegments\Filters\NumberOfClicks;
use MailPoet\Segments\DynamicSegments\Filters\SubscriberDateField;
use MailPoet\Segments\DynamicSegments\Filters\SubscriberScore;
use MailPoet\Segments\DynamicSegments\Filters\SubscriberSegment;
use MailPoet\Segments\DynamicSegments\Filters\SubscriberSubscribedViaForm;
use MailPoet\Segments\DynamicSegments\Filters\SubscriberTag;
use MailPoet\Segments\DynamicSegments\Filters\SubscriberTextField;
use MailPoet\Segments\DynamicSegments\Filters\UserRole;
use MailPoet\Segments\DynamicSegments\Filters\WooCommerceAverageSpent;
use MailPoet\Segments\DynamicSegments\Filters\WooCommerceCategory;
use MailPoet\Segments\DynamicSegments\Filters\WooCommerceCountry;
use MailPoet\Segments\DynamicSegments\Filters\WooCommerceCustomerTextField;
use MailPoet\Segments\DynamicSegments\Filters\WooCommerceFirstOrder;
use MailPoet\Segments\DynamicSegments\Filters\WooCommerceMembership;
use MailPoet\Segments\DynamicSegments\Filters\WooCommerceNumberOfOrders;
use MailPoet\Segments\DynamicSegments\Filters\WooCommerceNumberOfReviews;
use MailPoet\Segments\DynamicSegments\Filters\WooCommerceProduct;
use MailPoet\Segments\DynamicSegments\Filters\WooCommercePurchaseDate;
use MailPoet\Segments\DynamicSegments\Filters\WooCommercePurchasedWithAttribute;
use MailPoet\Segments\DynamicSegments\Filters\WooCommerceSingleOrderValue;
use MailPoet\Segments\DynamicSegments\Filters\WooCommerceSubscription;
use MailPoet\Segments\DynamicSegments\Filters\WooCommerceTag;
use MailPoet\Segments\DynamicSegments\Filters\WooCommerceTotalSpent;
use MailPoet\Segments\DynamicSegments\Filters\WooCommerceUsedCouponCode;
use MailPoet\Segments\DynamicSegments\Filters\WooCommerceUsedPaymentMethod;
use MailPoet\Segments\DynamicSegments\Filters\WooCommerceUsedShippingMethod;
class FilterFactory {
/** @var EmailAction */
private $emailAction;
/** @var UserRole */
private $userRole;
/** @var WooCommerceAverageSpent */
private $wooCommerceAverageSpent;
/** @var WooCommerceProduct */
private $wooCommerceProduct;
/** @var WooCommerceCategory */
private $wooCommerceCategory;
/** @var WooCommerceCountry */
private $wooCommerceCountry;
/** @var WooCommerceNumberOfOrders */
private $wooCommerceNumberOfOrders;
/** @var WooCommercePurchaseDate */
private $wooCommercePurchaseDate;
/** @var WooCommerceSingleOrderValue */
private $wooCommerceSingleOrderValue;
/** @var WooCommerceTotalSpent */
private $wooCommerceTotalSpent;
/** @var WooCommerceMembership */
private $wooCommerceMembership;
/** @var WooCommerceSubscription */
private $wooCommerceSubscription;
/** @var EmailOpensAbsoluteCountAction */
private $emailOpensAbsoluteCount;
/** @var SubscriberScore */
private $subscriberScore;
/** @var MailPoetCustomFields */
private $mailPoetCustomFields;
/** @var SubscriberSegment */
private $subscriberSegment;
/** @var SubscriberTag */
private $subscriberTag;
/** @var EmailActionClickAny */
private $emailActionClickAny;
/** @var SubscriberSubscribedViaForm */
private $subscribedViaForm;
/** @var SubscriberTextField */
private $subscriberTextField;
/** @var WooCommerceUsedPaymentMethod */
private $wooCommerceUsedPaymentMethod;
/** @var WooCommerceUsedShippingMethod */
private $wooCommerceUsedShippingMethod;
/** @var WooCommerceCustomerTextField */
private $wooCommerceCustomerTextField;
/** @var SubscriberDateField */
private $subscriberDateField;
/** @var AutomationsEvents */
private $automationsEvents;
/** @var WooCommerceNumberOfReviews */
private $wooCommerceNumberOfReviews;
/** @var WooCommerceUsedCouponCode */
private $wooCommerceUsedCouponCode;
/** @var WooCommerceFirstOrder */
private $wooCommerceFirstOrder;
/** @var EmailsReceived */
private $emailsReceived;
/** @var NumberOfClicks */
private $numberOfClicks;
private WooCommercePurchasedWithAttribute $wooCommercePurchasedWithAttribute;
private WooCommerceTag $wooCommerceTag;
public function __construct(
EmailAction $emailAction,
EmailActionClickAny $emailActionClickAny,
UserRole $userRole,
MailPoetCustomFields $mailPoetCustomFields,
WooCommerceProduct $wooCommerceProduct,
WooCommerceCategory $wooCommerceCategory,
WooCommerceCountry $wooCommerceCountry,
WooCommerceCustomerTextField $wooCommerceCustomerTextField,
EmailOpensAbsoluteCountAction $emailOpensAbsoluteCount,
WooCommerceNumberOfOrders $wooCommerceNumberOfOrders,
WooCommerceNumberOfReviews $wooCommerceNumberOfReviews,
WooCommerceTotalSpent $wooCommerceTotalSpent,
WooCommerceMembership $wooCommerceMembership,
WooCommerceFirstOrder $wooCommerceFirstOrder,
WooCommercePurchaseDate $wooCommercePurchaseDate,
WooCommerceSubscription $wooCommerceSubscription,
SubscriberScore $subscriberScore,
SubscriberTag $subscriberTag,
SubscriberSegment $subscriberSegment,
SubscriberSubscribedViaForm $subscribedViaForm,
WooCommerceSingleOrderValue $wooCommerceSingleOrderValue,
WooCommerceAverageSpent $wooCommerceAverageSpent,
WooCommerceTag $wooCommerceTag,
WooCommerceUsedCouponCode $wooCommerceUsedCouponCode,
WooCommerceUsedPaymentMethod $wooCommerceUsedPaymentMethod,
WooCommerceUsedShippingMethod $wooCommerceUsedShippingMethod,
SubscriberTextField $subscriberTextField,
SubscriberDateField $subscriberDateField,
AutomationsEvents $automationsEvents,
EmailsReceived $emailsReceived,
NumberOfClicks $numberOfClicks,
WooCommercePurchasedWithAttribute $wooCommercePurchasedWithAttribute
) {
$this->emailAction = $emailAction;
$this->userRole = $userRole;
$this->wooCommerceProduct = $wooCommerceProduct;
$this->wooCommerceCategory = $wooCommerceCategory;
$this->wooCommerceCountry = $wooCommerceCountry;
$this->wooCommerceNumberOfOrders = $wooCommerceNumberOfOrders;
$this->wooCommerceNumberOfReviews = $wooCommerceNumberOfReviews;
$this->wooCommerceMembership = $wooCommerceMembership;
$this->wooCommercePurchaseDate = $wooCommercePurchaseDate;
$this->wooCommerceSubscription = $wooCommerceSubscription;
$this->emailOpensAbsoluteCount = $emailOpensAbsoluteCount;
$this->wooCommerceTotalSpent = $wooCommerceTotalSpent;
$this->subscriberScore = $subscriberScore;
$this->subscriberTag = $subscriberTag;
$this->mailPoetCustomFields = $mailPoetCustomFields;
$this->subscriberSegment = $subscriberSegment;
$this->emailActionClickAny = $emailActionClickAny;
$this->wooCommerceSingleOrderValue = $wooCommerceSingleOrderValue;
$this->subscriberTextField = $subscriberTextField;
$this->subscribedViaForm = $subscribedViaForm;
$this->wooCommerceAverageSpent = $wooCommerceAverageSpent;
$this->wooCommerceUsedPaymentMethod = $wooCommerceUsedPaymentMethod;
$this->wooCommerceUsedShippingMethod = $wooCommerceUsedShippingMethod;
$this->wooCommerceCustomerTextField = $wooCommerceCustomerTextField;
$this->automationsEvents = $automationsEvents;
$this->subscriberDateField = $subscriberDateField;
$this->wooCommerceUsedCouponCode = $wooCommerceUsedCouponCode;
$this->wooCommerceFirstOrder = $wooCommerceFirstOrder;
$this->emailsReceived = $emailsReceived;
$this->numberOfClicks = $numberOfClicks;
$this->wooCommercePurchasedWithAttribute = $wooCommercePurchasedWithAttribute;
$this->wooCommerceTag = $wooCommerceTag;
}
public function getFilterForFilterEntity(DynamicSegmentFilterEntity $filter): Filter {
$filterData = $filter->getFilterData();
$filterType = $filterData->getFilterType();
$action = $filterData->getAction();
switch ($filterType) {
case DynamicSegmentFilterData::TYPE_AUTOMATIONS:
return $this->automationsEvents;
case DynamicSegmentFilterData::TYPE_USER_ROLE:
return $this->userRole($action);
case DynamicSegmentFilterData::TYPE_EMAIL:
return $this->email($action);
case DynamicSegmentFilterData::TYPE_WOOCOMMERCE_MEMBERSHIP:
return $this->wooCommerceMembership();
case DynamicSegmentFilterData::TYPE_WOOCOMMERCE_SUBSCRIPTION:
return $this->wooCommerceSubscription();
case DynamicSegmentFilterData::TYPE_WOOCOMMERCE:
return $this->wooCommerce($action);
default:
throw new InvalidFilterException('Invalid type', InvalidFilterException::INVALID_TYPE);
}
}
/**
* @param ?string $action
*
* @return MailPoetCustomFields|SubscriberScore|SubscriberSegment|UserRole|SubscriberTag|SubscriberTextField|SubscriberSubscribedViaForm|SubscriberDateField
*/
private function userRole(?string $action) {
if ($action === SubscriberScore::TYPE) {
return $this->subscriberScore;
} elseif ($action === MailPoetCustomFields::TYPE) {
return $this->mailPoetCustomFields;
} elseif ($action === SubscriberSegment::TYPE) {
return $this->subscriberSegment;
} elseif ($action === SubscriberTag::TYPE) {
return $this->subscriberTag;
} elseif ($action === SubscriberSubscribedViaForm::TYPE) {
return $this->subscribedViaForm;
} elseif (in_array($action, SubscriberTextField::TYPES)) {
return $this->subscriberTextField;
} elseif (in_array($action, SubscriberDateField::TYPES)) {
return $this->subscriberDateField;
}
return $this->userRole;
}
/**
* @param ?string $action
* @return EmailAction|EmailActionClickAny|EmailOpensAbsoluteCountAction|EmailsReceived|NumberOfClicks
*/
private function email(?string $action) {
$countActions = [EmailOpensAbsoluteCountAction::TYPE, EmailOpensAbsoluteCountAction::MACHINE_TYPE];
if (in_array($action, $countActions)) {
return $this->emailOpensAbsoluteCount;
} elseif ($action === EmailActionClickAny::TYPE) {
return $this->emailActionClickAny;
} elseif ($action === EmailsReceived::ACTION) {
return $this->emailsReceived;
} elseif ($action === NumberOfClicks::ACTION) {
return $this->numberOfClicks;
}
return $this->emailAction;
}
private function wooCommerceMembership(): WooCommerceMembership {
return $this->wooCommerceMembership;
}
private function wooCommerceSubscription(): WooCommerceSubscription {
return $this->wooCommerceSubscription;
}
/**
* @param ?string $action
* @return Filter
*/
private function wooCommerce(?string $action) {
if ($action === WooCommerceProduct::ACTION_PRODUCT) {
return $this->wooCommerceProduct;
} elseif (in_array($action, WooCommerceNumberOfOrders::ACTIONS)) {
return $this->wooCommerceNumberOfOrders;
} elseif ($action === WooCommerceTotalSpent::ACTION_TOTAL_SPENT) {
return $this->wooCommerceTotalSpent;
} elseif ($action === WooCommerceCountry::ACTION_CUSTOMER_COUNTRY) {
return $this->wooCommerceCountry;
} elseif ($action === WooCommerceSingleOrderValue::ACTION_SINGLE_ORDER_VALUE) {
return $this->wooCommerceSingleOrderValue;
} elseif ($action === WooCommercePurchaseDate::ACTION) {
return $this->wooCommercePurchaseDate;
} elseif ($action === WooCommerceAverageSpent::ACTION) {
return $this->wooCommerceAverageSpent;
} elseif ($action === WooCommerceUsedPaymentMethod::ACTION) {
return $this->wooCommerceUsedPaymentMethod;
} elseif ($action === WooCommerceUsedShippingMethod::ACTION) {
return $this->wooCommerceUsedShippingMethod;
} elseif ($action === WooCommerceNumberOfReviews::ACTION) {
return $this->wooCommerceNumberOfReviews;
} elseif (in_array($action, WooCommerceCustomerTextField::ACTIONS)) {
return $this->wooCommerceCustomerTextField;
} elseif ($action === WooCommerceUsedCouponCode::ACTION) {
return $this->wooCommerceUsedCouponCode;
} elseif ($action === WooCommerceFirstOrder::ACTION) {
return $this->wooCommerceFirstOrder;
} elseif ($action === WooCommercePurchasedWithAttribute::ACTION) {
return $this->wooCommercePurchasedWithAttribute;
} elseif ($action == WooCommerceTag::ACTION) {
return $this->wooCommerceTag;
}
return $this->wooCommerceCategory;
}
}
@@ -0,0 +1,97 @@
<?php declare(strict_types = 1);
namespace MailPoet\Segments\DynamicSegments;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\DynamicSegmentFilterData;
use MailPoet\Entities\SegmentEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Segments\SegmentDependencyValidator;
use MailPoetVendor\Doctrine\DBAL\Query\QueryBuilder;
use MailPoetVendor\Doctrine\ORM\EntityManager;
class FilterHandler {
/** @var EntityManager */
private $entityManager;
/** @var SegmentDependencyValidator */
private $segmentDependencyValidator;
/** @var FilterFactory */
private $filterFactory;
public function __construct(
EntityManager $entityManager,
SegmentDependencyValidator $segmentDependencyValidator,
FilterFactory $filterFactory
) {
$this->entityManager = $entityManager;
$this->segmentDependencyValidator = $segmentDependencyValidator;
$this->filterFactory = $filterFactory;
}
public function apply(QueryBuilder $queryBuilder, SegmentEntity $segment): QueryBuilder {
$filters = $segment->getDynamicFilters();
$filterSelects = [];
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
$pluginsForAllFiltersMissing = $this->segmentDependencyValidator->getMissingPluginsByAllFilters($filters);
foreach ($filters as $filter) {
$subscribersIdsQuery = $this->entityManager
->getConnection()
->createQueryBuilder()
->select("DISTINCT $subscribersTable.id as inner_subscriber_id")
->from($subscribersTable);
// When a required plugin is missing we want to return empty result
if ($pluginsForAllFiltersMissing || $this->segmentDependencyValidator->getMissingPluginsByFilter($filter)) {
$subscribersIdsQuery->andWhere('1 = 0');
} else {
$this->filterFactory->getFilterForFilterEntity($filter)->apply($subscribersIdsQuery, $filter);
}
$filterSelects[] = $subscribersIdsQuery->getSQL();
$queryBuilder->setParameters(
array_merge(
$subscribersIdsQuery->getParameters(),
$queryBuilder->getParameters()
),
array_merge(
$subscribersIdsQuery->getParameterTypes(),
$queryBuilder->getParameterTypes()
)
);
}
$this->joinSubqueries($queryBuilder, $segment, $filterSelects);
return $queryBuilder;
}
private function joinSubqueries(QueryBuilder $queryBuilder, SegmentEntity $segment, array $subQueries): QueryBuilder {
$filter = $segment->getDynamicFilters()->first();
if (!$filter) return $queryBuilder;
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
if ($segment->getFiltersConnectOperator() === DynamicSegmentFilterData::CONNECT_TYPE_OR) {
// the final query: SELECT * FROM subscribers INNER JOIN (filter_select1 UNION filter_select2) filtered_subscribers ON filtered_subscribers.inner_subscriber_id = id
$queryBuilder->innerJoin(
$subscribersTable,
sprintf('(%s)', join(' UNION ', $subQueries)),
'filtered_subscribers',
"filtered_subscribers.inner_subscriber_id = $subscribersTable.id"
);
return $queryBuilder;
}
foreach ($subQueries as $key => $subQuery) {
// we need a unique name for each subquery so that we can join them together in the sql query - just make sure the identifier starts with a letter, not a number
$subqueryName = 'a' . $key;
$queryBuilder->innerJoin(
$subscribersTable,
"($subQuery)",
$subqueryName,
"$subqueryName.inner_subscriber_id = $subscribersTable.id"
);
}
return $queryBuilder;
}
}
@@ -0,0 +1,123 @@
<?php declare(strict_types = 1);
namespace MailPoet\Segments\DynamicSegments\Filters;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Automation;
use MailPoet\Automation\Engine\Data\AutomationRun;
use MailPoet\Automation\Engine\Storage\AutomationStorage;
use MailPoet\Entities\DynamicSegmentFilterData;
use MailPoet\Entities\DynamicSegmentFilterEntity;
use MailPoetVendor\Doctrine\DBAL\ArrayParameterType;
use MailPoetVendor\Doctrine\DBAL\Query\QueryBuilder;
class AutomationsEvents implements Filter {
const SUPPORTED_ACTIONS = [
self::ENTERED_ACTION,
self::EXITED_ACTION,
];
const ENTERED_ACTION = 'enteredAutomation';
const EXITED_ACTION = 'exitedAutomation';
const AUTOMATION_IDS_PARAM = 'automation_ids';
/** @var FilterHelper */
private $filterHelper;
/** @var AutomationStorage */
private $automationStorage;
public function __construct(
FilterHelper $filterHelper,
AutomationStorage $automationStorage
) {
$this->filterHelper = $filterHelper;
$this->automationStorage = $automationStorage;
}
public function apply(QueryBuilder $queryBuilder, DynamicSegmentFilterEntity $filter): QueryBuilder {
$filterData = $filter->getFilterData();
$action = $filterData->getParam('action');
$operator = $filterData->getParam('operator');
$automationIds = $filterData->getParam(self::AUTOMATION_IDS_PARAM);
switch ($operator) {
case DynamicSegmentFilterData::OPERATOR_ANY:
$this->applyForAnyOperator($queryBuilder, $action, $automationIds);
break;
case DynamicSegmentFilterData::OPERATOR_ALL:
$this->applyForAllOperator($queryBuilder, $action, $automationIds);
break;
case DynamicSegmentFilterData::OPERATOR_NONE:
$subscribersTable = $this->filterHelper->getSubscribersTable();
$subQuery = $this->filterHelper->getNewSubscribersQueryBuilder();
$this->applyForAnyOperator($subQuery, $action, $automationIds);
$queryBuilder->andWhere($queryBuilder->expr()->notIn("$subscribersTable.id", $this->filterHelper->getInterpolatedSQL($subQuery)));
break;
}
return $queryBuilder;
}
private function applyForAnyOperator(QueryBuilder $queryBuilder, $action, $automationIds) {
$subscribersTable = $this->filterHelper->getSubscribersTable();
$automationsTable = $this->filterHelper->getPrefixedTable('mailpoet_automations');
$automationRunsTable = $this->filterHelper->getPrefixedTable('mailpoet_automation_runs');
$automationRunSubjectsTable = $this->filterHelper->getPrefixedTable('mailpoet_automation_run_subjects');
$automationIdsParam = $this->filterHelper->getUniqueParameterName('automationIds');
$queryBuilder
->innerJoin(
$subscribersTable,
$automationRunSubjectsTable,
'subjects',
"subjects.key = 'mailpoet:subscriber' AND subjects.args = CONCAT('{\"subscriber_id\":', $subscribersTable.id, '}')"
)
->innerJoin(
'subjects',
$automationRunsTable,
'runs',
'subjects.automation_run_id = runs.id'
)
->innerJoin(
'runs',
$automationsTable,
'automations',
'automations.id = runs.automation_id'
)
->andWhere("automations.id IN (:$automationIdsParam)")
->setParameter($automationIdsParam, $automationIds, ArrayParameterType::STRING);
if ($action === self::EXITED_ACTION) {
$statusParam = $this->filterHelper->getUniqueParameterName('status');
$queryBuilder
->andWhere("runs.status != :$statusParam")
->setParameter($statusParam, AutomationRun::STATUS_RUNNING);
}
}
private function applyForAllOperator(QueryBuilder $queryBuilder, $action, $automationIds) {
$this->applyForAnyOperator($queryBuilder, $action, $automationIds);
$queryBuilder
->groupBy('inner_subscriber_id')
->having("COUNT(DISTINCT automations.id) = " . count($automationIds));
}
public function getLookupData(DynamicSegmentFilterData $filterData): array {
$automationIds = $filterData->getArrayParam(self::AUTOMATION_IDS_PARAM);
$lookupData = [
'automations' => [],
];
foreach ($automationIds as $automationId) {
$automation = $this->automationStorage->getAutomation(intval($automationId));
if ($automation instanceof Automation) {
$lookupData['automations'][$automationId] = $automation->getName();
}
}
return $lookupData;
}
}
@@ -0,0 +1,79 @@
<?php declare(strict_types = 1);
namespace MailPoet\Segments\DynamicSegments\Filters;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\DynamicSegmentFilterEntity;
use MailPoet\Segments\DynamicSegments\Exceptions\InvalidFilterException;
use MailPoetVendor\Carbon\CarbonImmutable;
class DateFilterHelper {
const BEFORE = 'before';
const AFTER = 'after';
const ON = 'on';
const ON_OR_BEFORE = 'onOrBefore';
const ON_OR_AFTER = 'onOrAfter';
const NOT_ON = 'notOn';
const IN_THE_LAST = 'inTheLast';
const NOT_IN_THE_LAST = 'notInTheLast';
public function getValidOperators(): array {
return array_merge(
$this->getAbsoluteDateOperators(),
$this->getRelativeDateOperators()
);
}
public function getAbsoluteDateOperators(): array {
return [
self::BEFORE,
self::AFTER,
self::ON,
self::ON_OR_BEFORE,
self::ON_OR_AFTER,
self::NOT_ON,
];
}
public function getRelativeDateOperators(): array {
return [
self::IN_THE_LAST,
self::NOT_IN_THE_LAST,
];
}
public function getDateStringForOperator(string $operator, string $value): string {
if (in_array($operator, self::getAbsoluteDateOperators())) {
$carbon = CarbonImmutable::createFromFormat('Y-m-d', $value);
if (!$carbon instanceof CarbonImmutable) {
throw new InvalidFilterException('Invalid date value', InvalidFilterException::INVALID_DATE_VALUE);
}
} else if (in_array($operator, self::getRelativeDateOperators())) {
$carbon = CarbonImmutable::now()->subDays(intval($value) - 1);
} else {
throw new InvalidFilterException('Incorrect value for operator', InvalidFilterException::MISSING_VALUE);
}
return $carbon->toDateString();
}
public function getDateValueFromFilter(DynamicSegmentFilterEntity $filter): string {
$filterData = $filter->getFilterData();
$dateValue = $filterData->getParam('value');
if (!is_string($dateValue)) {
throw new InvalidFilterException('Incorrect value for date', InvalidFilterException::INVALID_DATE_VALUE);
}
return $dateValue;
}
public function getOperatorFromFilter(DynamicSegmentFilterEntity $filter): string {
$filterData = $filter->getFilterData();
$operator = $filterData->getParam('operator');
if (!is_string($operator) || !in_array($operator, $this->getValidOperators())) {
throw new InvalidFilterException('Incorrect value for operator', InvalidFilterException::MISSING_VALUE);
}
return $operator;
}
}
@@ -0,0 +1,274 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Segments\DynamicSegments\Filters;
if (!defined('ABSPATH')) exit;
use MailPoet\Cron\Workers\StatsNotifications\NewsletterLinkRepository;
use MailPoet\Entities\DynamicSegmentFilterData;
use MailPoet\Entities\DynamicSegmentFilterEntity;
use MailPoet\Entities\NewsletterEntity;
use MailPoet\Entities\NewsletterLinkEntity;
use MailPoet\Entities\StatisticsClickEntity;
use MailPoet\Entities\StatisticsNewsletterEntity;
use MailPoet\Entities\StatisticsOpenEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Entities\UserAgentEntity;
use MailPoet\Newsletter\NewslettersRepository;
use MailPoet\Util\Security;
use MailPoetVendor\Doctrine\DBAL\ArrayParameterType;
use MailPoetVendor\Doctrine\DBAL\Query\QueryBuilder;
use MailPoetVendor\Doctrine\ORM\EntityManager;
class EmailAction implements Filter {
const ACTION_OPENED = 'opened';
const ACTION_MACHINE_OPENED = 'machineOpened';
/** @deprecated */
const ACTION_NOT_OPENED = 'notOpened';
const ACTION_CLICKED = 'clicked';
const ACTION_WAS_SENT = 'wasSent';
/** @deprecated */
const ACTION_NOT_CLICKED = 'notClicked';
const ALLOWED_ACTIONS = [
self::ACTION_OPENED,
self::ACTION_MACHINE_OPENED,
self::ACTION_CLICKED,
self::ACTION_WAS_SENT,
EmailActionClickAny::TYPE,
EmailOpensAbsoluteCountAction::TYPE,
EmailOpensAbsoluteCountAction::MACHINE_TYPE,
EmailsReceived::ACTION,
NumberOfClicks::ACTION,
];
/** @var EntityManager */
private $entityManager;
/** @var FilterHelper */
private $filterHelper;
/** @var NewslettersRepository */
private $newslettersRepository;
/** @var NewsletterLinkRepository */
private $newsletterLinkRepository;
public function __construct(
EntityManager $entityManager,
FilterHelper $filterHelper,
NewslettersRepository $newslettersRepository,
NewsletterLinkRepository $newsletterLinkRepository
) {
$this->entityManager = $entityManager;
$this->filterHelper = $filterHelper;
$this->newslettersRepository = $newslettersRepository;
$this->newsletterLinkRepository = $newsletterLinkRepository;
}
public function apply(QueryBuilder $queryBuilder, DynamicSegmentFilterEntity $filter): QueryBuilder {
$filterData = $filter->getFilterData();
$action = $filterData->getAction();
$parameterSuffix = (string)($filter->getId() ?? Security::generateRandomString());
if ($action === self::ACTION_CLICKED) {
return $this->applyForClickedActions($queryBuilder, $filterData, $parameterSuffix);
} elseif ($action === self::ACTION_WAS_SENT) {
return $this->applyForWasSentAction($queryBuilder, $filterData, $parameterSuffix);
} else {
return $this->applyForOpenedActions($queryBuilder, $filterData, $parameterSuffix);
}
}
private function applyForClickedActions(QueryBuilder $queryBuilder, DynamicSegmentFilterData $filterData, string $parameterSuffix): QueryBuilder {
$operator = $filterData->getParam('operator') ?? DynamicSegmentFilterData::OPERATOR_ANY;
$action = $filterData->getAction();
$newsletterId = $filterData->getParam('newsletter_id');
$linkIds = $filterData->getParam('link_ids');
if (!is_array($linkIds)) {
$linkIds = [];
}
$statsSentTable = $this->entityManager->getClassMetadata(StatisticsNewsletterEntity::class)->getTableName();
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
$statsTable = $this->entityManager->getClassMetadata(StatisticsClickEntity::class)->getTableName();
$where = '1';
if (($action === self::ACTION_NOT_CLICKED) || ($operator === DynamicSegmentFilterData::OPERATOR_NONE)) {
$queryBuilder = $queryBuilder->innerJoin(
$subscribersTable,
$statsSentTable,
'statssent',
"$subscribersTable.id = statssent.subscriber_id AND statssent.newsletter_id = :newsletter" . $parameterSuffix
)->leftJoin(
'statssent',
$statsTable,
'stats',
$this->createNotStatsJoinCondition($parameterSuffix, $linkIds)
)->setParameter('newsletter' . $parameterSuffix, $newsletterId);
$where .= ' AND stats.id IS NULL';
} else {
$queryBuilder = $queryBuilder->innerJoin(
$subscribersTable,
$statsTable,
'stats',
"stats.subscriber_id = $subscribersTable.id AND stats.newsletter_id = :newsletter" . $parameterSuffix
)->setParameter('newsletter' . $parameterSuffix, $newsletterId);
}
if ($action === EmailAction::ACTION_CLICKED && $operator !== DynamicSegmentFilterData::OPERATOR_NONE && $linkIds) {
$where .= ' AND stats.link_id IN (:links' . $parameterSuffix . ')';
}
if ($operator === DynamicSegmentFilterData::OPERATOR_ALL) {
$queryBuilder->groupBy('subscriber_id');
if ($linkIds) {
$queryBuilder->having('COUNT(1) = ' . count($linkIds));
} else {
// Case when a user selects all of, but doesn't specify links == all of all links.
$linksTable = $this->entityManager->getClassMetadata(NewsletterLinkEntity::class)->getTableName();
$linksQueryBuilder = $this->entityManager->getConnection()->createQueryBuilder();
$linkCount = $linksQueryBuilder->select('count(id)')
->from($linksTable)
->where('newsletter_id = :newsletter_id')
->setParameter('newsletter_id', $newsletterId)
->execute()
->fetchOne();
$queryBuilder->having('COUNT(1) = ' . $linkCount);
}
}
$queryBuilder = $queryBuilder->andWhere($where);
if ($linkIds) {
$queryBuilder = $queryBuilder
->setParameter('links' . $parameterSuffix, $linkIds, ArrayParameterType::STRING);
}
return $queryBuilder;
}
private function applyForOpenedActions(QueryBuilder $queryBuilder, DynamicSegmentFilterData $filterData, string $parameterSuffix): QueryBuilder {
$operator = $filterData->getParam('operator') ?? DynamicSegmentFilterData::OPERATOR_ANY;
$action = $filterData->getAction();
$newsletters = $filterData->getParam('newsletters');
$statsSentTable = $this->entityManager->getClassMetadata(StatisticsNewsletterEntity::class)->getTableName();
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
$statsTable = $this->entityManager->getClassMetadata(StatisticsOpenEntity::class)->getTableName();
$where = '1';
if ($operator === DynamicSegmentFilterData::OPERATOR_NONE) {
$queryBuilder = $queryBuilder->innerJoin(
$subscribersTable,
$statsSentTable,
'statssent',
"$subscribersTable.id = statssent.subscriber_id AND statssent.newsletter_id IN (:newsletters" . $parameterSuffix . ')'
)->leftJoin(
'statssent',
$statsTable,
'stats',
"statssent.subscriber_id = stats.subscriber_id AND stats.newsletter_id IN (:newsletters" . $parameterSuffix . ')'
)->setParameter('newsletters' . $parameterSuffix, $newsletters, ArrayParameterType::INTEGER);
$where .= ' AND stats.id IS NULL';
} else {
$queryBuilder = $queryBuilder->innerJoin(
$subscribersTable,
$statsTable,
'stats',
"stats.subscriber_id = $subscribersTable.id AND stats.newsletter_id IN (:newsletters" . $parameterSuffix . ')'
)->setParameter('newsletters' . $parameterSuffix, $newsletters, ArrayParameterType::INTEGER);
if ($operator === DynamicSegmentFilterData::OPERATOR_ALL) {
$queryBuilder->groupBy('subscriber_id');
$queryBuilder->having('COUNT(1) = ' . count($newsletters));
}
}
if (($action === EmailAction::ACTION_OPENED) && ($operator !== DynamicSegmentFilterData::OPERATOR_NONE)) {
$queryBuilder->andWhere('stats.user_agent_type = :userAgentType')
->setParameter('userAgentType', UserAgentEntity::USER_AGENT_TYPE_HUMAN);
}
if ($action === EmailAction::ACTION_MACHINE_OPENED) {
$queryBuilder->andWhere('(stats.user_agent_type = :userAgentType)')
->setParameter('userAgentType', UserAgentEntity::USER_AGENT_TYPE_MACHINE);
}
$queryBuilder = $queryBuilder->andWhere($where);
return $queryBuilder;
}
private function createNotStatsJoinCondition(string $parameterSuffix, array $linkIds = null): string {
$clause = "statssent.subscriber_id = stats.subscriber_id AND stats.newsletter_id = :newsletter" . $parameterSuffix;
if ($linkIds) {
$clause .= ' AND stats.link_id IN (:links' . $parameterSuffix . ')';
}
return $clause;
}
private function applyForWasSentAction(QueryBuilder $queryBuilder, DynamicSegmentFilterData $filterData, string $parameterSuffix): QueryBuilder {
$newsletters = (array)$filterData->getParam('newsletters');
$operator = $filterData->getParam('operator') ?? DynamicSegmentFilterData::OPERATOR_ANY;
$subscribersTable = $this->filterHelper->getSubscribersTable();
$statisticsNewslettersTable = $this->entityManager->getClassMetadata(StatisticsNewsletterEntity::class)->getTableName();
if ($operator === DynamicSegmentFilterData::OPERATOR_NONE) {
$queryBuilder->leftJoin(
$this->filterHelper->getSubscribersTable(),
$statisticsNewslettersTable,
'statisticsNewsletter',
"$subscribersTable.id = statisticsNewsletter.subscriber_id AND statisticsNewsletter.newsletter_id IN (:newsletters" . $parameterSuffix . ')'
)
->setParameter('newsletters' . $parameterSuffix, $newsletters, ArrayParameterType::INTEGER)
->andWhere('statisticsNewsletter.subscriber_id IS NULL');
} else {
$queryBuilder->innerJoin(
$subscribersTable,
$statisticsNewslettersTable,
'statisticsNewsletter',
"statisticsNewsletter.subscriber_id = $subscribersTable.id AND statisticsNewsletter.newsletter_id IN (:newsletters" . $parameterSuffix . ')'
)->setParameter('newsletters' . $parameterSuffix, $newsletters, ArrayParameterType::INTEGER);
if ($operator === DynamicSegmentFilterData::OPERATOR_ALL) {
$queryBuilder->groupBy('subscriber_id');
$queryBuilder->having('COUNT(1) = ' . count($newsletters));
}
}
return $queryBuilder;
}
public function getLookupData(DynamicSegmentFilterData $filterData): array {
$lookupData = [
'newsletters' => [],
'links' => [],
];
$newsletterIds = $filterData->getParam('newsletters');
if (!is_array($newsletterIds)) {
$newsletterIds = [];
}
// Clicked action only supports single newsletter ID
$singularNewsletterId = $filterData->getParam('newsletter_id');
if (!is_null($singularNewsletterId)) {
$newsletterIds[] = $singularNewsletterId;
}
$linkIds = $filterData->getParam('link_ids');
if (!is_array($linkIds)) {
$linkIds = [];
}
foreach ($newsletterIds as $newsletterId) {
$newsletter = $this->newslettersRepository->findOneById($newsletterId);
if ($newsletter instanceof NewsletterEntity) {
$lookupData['newsletters'][$newsletterId] = $newsletter->getSubject();
}
}
foreach ($linkIds as $linkId) {
$link = $this->newsletterLinkRepository->findOneById($linkId);
if ($link instanceof NewsletterLinkEntity) {
$lookupData['links'][$linkId] = $link->getUrl();
}
}
return $lookupData;
}
}
@@ -0,0 +1,58 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Segments\DynamicSegments\Filters;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\DynamicSegmentFilterData;
use MailPoet\Entities\DynamicSegmentFilterEntity;
use MailPoet\Entities\NewsletterLinkEntity;
use MailPoet\Entities\StatisticsClickEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoetVendor\Doctrine\DBAL\Query\QueryBuilder;
use MailPoetVendor\Doctrine\ORM\EntityManager;
class EmailActionClickAny implements Filter {
const TYPE = 'clickedAny';
/** @var EntityManager */
private $entityManager;
public function __construct(
EntityManager $entityManager
) {
$this->entityManager = $entityManager;
}
public function apply(QueryBuilder $queryBuilder, DynamicSegmentFilterEntity $filter): QueryBuilder {
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
$newsletterLinksTable = $this->entityManager->getClassMetadata(NewsletterLinkEntity::class)->getTableName();
$statsTable = $this->entityManager->getClassMetadata(StatisticsClickEntity::class)->getTableName();
$excludedLinks = [
'[link:subscription_unsubscribe_url]',
'[link:subscription_instant_unsubscribe_url]',
'[link:newsletter_view_in_browser_url]',
'[link:subscription_manage_url]',
];
$queryBuilder = $queryBuilder->innerJoin(
$subscribersTable,
$statsTable,
'stats',
"stats.subscriber_id = $subscribersTable.id"
)->innerJoin(
'stats',
$newsletterLinksTable,
'newsletterLinks',
"stats.link_id = newsletterLinks.id AND newsletterLinks.URL NOT IN ('" . join("', '", $excludedLinks) . "')"
);
return $queryBuilder;
}
public function getLookupData(DynamicSegmentFilterData $filterData): array {
return [];
}
}
@@ -0,0 +1,82 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Segments\DynamicSegments\Filters;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\DynamicSegmentFilterData;
use MailPoet\Entities\DynamicSegmentFilterEntity;
use MailPoet\Entities\StatisticsOpenEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Entities\UserAgentEntity;
use MailPoet\Util\Security;
use MailPoetVendor\Carbon\CarbonImmutable;
use MailPoetVendor\Doctrine\DBAL\Query\QueryBuilder;
use MailPoetVendor\Doctrine\ORM\EntityManager;
class EmailOpensAbsoluteCountAction implements Filter {
const TYPE = 'opensAbsoluteCount';
const MACHINE_TYPE = 'machineOpensAbsoluteCount';
/** @var EntityManager */
private $entityManager;
public function __construct(
EntityManager $entityManager
) {
$this->entityManager = $entityManager;
}
public function apply(QueryBuilder $queryBuilder, DynamicSegmentFilterEntity $filter): QueryBuilder {
$filterData = $filter->getFilterData();
$days = $filterData->getParam('days');
$operator = $filterData->getParam('operator');
$action = $filterData->getAction();
$timeframe = $filterData->getParam('timeframe');
$parameterSuffix = $filter->getId() ?? Security::generateRandomString();
$statsTable = $this->entityManager->getClassMetadata(StatisticsOpenEntity::class)->getTableName();
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
if ($timeframe === DynamicSegmentFilterData::TIMEFRAME_ALL_TIME) {
$queryBuilder->leftJoin(
$subscribersTable,
$statsTable,
'opens',
"{$subscribersTable}.id = opens.subscriber_id AND opens.user_agent_type = :userAgentType{$parameterSuffix}"
);
} else {
$queryBuilder->leftJoin(
$subscribersTable,
$statsTable,
'opens',
"{$subscribersTable}.id = opens.subscriber_id AND opens.created_at > :newer{$parameterSuffix} AND opens.user_agent_type = :userAgentType{$parameterSuffix}"
);
$queryBuilder->setParameter('newer' . $parameterSuffix, CarbonImmutable::now()->subDays($days)->startOfDay());
}
$queryBuilder->groupBy("$subscribersTable.id");
if ($operator === 'equals') {
$queryBuilder->having("count(opens.id) = :opens" . $parameterSuffix);
} else if ($operator === 'not_equals') {
$queryBuilder->having("count(opens.id) != :opens" . $parameterSuffix);
} else if ($operator === 'less') {
$queryBuilder->having("count(opens.id) < :opens" . $parameterSuffix);
} else {
$queryBuilder->having("count(opens.id) > :opens" . $parameterSuffix);
}
$queryBuilder->setParameter('opens' . $parameterSuffix, $filterData->getParam('opens'));
if ($action === EmailOpensAbsoluteCountAction::TYPE) {
$queryBuilder->setParameter('userAgentType' . $parameterSuffix, UserAgentEntity::USER_AGENT_TYPE_HUMAN);
} else {
$queryBuilder->setParameter('userAgentType' . $parameterSuffix, UserAgentEntity::USER_AGENT_TYPE_MACHINE);
}
return $queryBuilder;
}
public function getLookupData(DynamicSegmentFilterData $filterData): array {
return [];
}
}
@@ -0,0 +1,69 @@
<?php declare(strict_types = 1);
namespace MailPoet\Segments\DynamicSegments\Filters;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\DynamicSegmentFilterData;
use MailPoet\Entities\DynamicSegmentFilterEntity;
use MailPoet\Entities\StatisticsNewsletterEntity;
use MailPoetVendor\Carbon\CarbonImmutable;
use MailPoetVendor\Doctrine\DBAL\Query\QueryBuilder;
use MailPoetVendor\Doctrine\ORM\EntityManager;
class EmailsReceived implements Filter {
const ACTION = 'numberReceived';
/** @var EntityManager */
private $entityManager;
/** @var FilterHelper */
private $filterHelper;
public function __construct(
EntityManager $entityManager,
FilterHelper $filterHelper
) {
$this->entityManager = $entityManager;
$this->filterHelper = $filterHelper;
}
public function apply(QueryBuilder $queryBuilder, DynamicSegmentFilterEntity $filter): QueryBuilder {
$filterData = $filter->getFilterData();
$emailCount = $filterData->getIntParam('emails');
$operator = $filterData->getStringParam('operator');
$timeframe = $filterData->getStringParam('timeframe');
$statsTable = $this->entityManager->getClassMetadata(StatisticsNewsletterEntity::class)->getTableName();
$subscribersTable = $this->filterHelper->getSubscribersTable();
if ($timeframe === DynamicSegmentFilterData::TIMEFRAME_ALL_TIME) {
$queryBuilder->leftJoin($subscribersTable, $statsTable, 'emails', "{$subscribersTable}.id = emails.subscriber_id");
} else {
$days = $filterData->getIntParam('days');
$dateParam = $this->filterHelper->getUniqueParameterName('days');
$queryBuilder->leftJoin($subscribersTable, $statsTable, 'emails', "{$subscribersTable}.id = emails.subscriber_id AND emails.sent_at >= :$dateParam");
$queryBuilder->setParameter($dateParam, CarbonImmutable::now()->subDays($days)->startOfDay());
}
$queryBuilder->groupBy("$subscribersTable.id");
$emailCountParam = $this->filterHelper->getUniqueParameterName('emails');
if ($operator === 'equals') {
$queryBuilder->having("count(emails.id) = :$emailCountParam");
} else if ($operator === 'not_equals') {
$queryBuilder->having("count(emails.id) != :$emailCountParam");
} else if ($operator === 'less') {
$queryBuilder->having("count(emails.id) < :$emailCountParam");
} else {
$queryBuilder->having("count(emails.id) > :$emailCountParam");
}
$queryBuilder->setParameter($emailCountParam, $emailCount);
return $queryBuilder;
}
public function getLookupData(DynamicSegmentFilterData $filterData): array {
return [];
}
}
@@ -0,0 +1,26 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Segments\DynamicSegments\Filters;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\DynamicSegmentFilterData;
use MailPoet\Entities\DynamicSegmentFilterEntity;
use MailPoetVendor\Doctrine\DBAL\Query\QueryBuilder;
interface Filter {
public function apply(QueryBuilder $queryBuilder, DynamicSegmentFilterEntity $filter): QueryBuilder;
/**
* At sending time, we store the current state of every filter so we can tell in the future how it was configured. This
* method should be used to return any data that might change after sending time. For example, if a filter stores IDs
* of related entities, we should try to look up descriptive names for those entities in case they get deleted or
* renamed later.
*
* @param DynamicSegmentFilterData $filterData
*
* @return array
*/
public function getLookupData(DynamicSegmentFilterData $filterData): array;
}
@@ -0,0 +1,87 @@
<?php declare(strict_types = 1);
namespace MailPoet\Segments\DynamicSegments\Filters;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\DynamicSegmentFilterData;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Segments\DynamicSegments\Exceptions\InvalidFilterException;
use MailPoet\Util\Security;
use MailPoetVendor\Doctrine\DBAL\Query\QueryBuilder;
use MailPoetVendor\Doctrine\ORM\EntityManager;
class FilterHelper {
/** @var EntityManager */
private $entityManager;
public function __construct(
EntityManager $entityManager
) {
$this->entityManager = $entityManager;
}
public function getPrefixedTable(string $table): string {
global $wpdb;
return sprintf('%s%s', $wpdb->prefix, $table);
}
public function getNewSubscribersQueryBuilder(): QueryBuilder {
return $this->entityManager
->getConnection()
->createQueryBuilder()
->select($this->getSubscribersTable() . '.id')
->from($this->getSubscribersTable());
}
public function getSubscribersTable(): string {
return $this->getTableForEntity(SubscriberEntity::class);
}
/**
* @param class-string<object> $entityClass
*/
public function getTableForEntity(string $entityClass): string {
return $this->entityManager->getClassMetadata($entityClass)->getTableName();
}
public function getInterpolatedSQL(QueryBuilder $query): string {
$sql = $query->getSQL();
$params = $query->getParameters();
$search = array_map(function($key) {
return ":$key";
}, array_keys($params));
$replace = array_map(function($value) use ($query) {
if (is_array($value)) {
$quotedValues = array_map(function($arrayValue) use ($query) {
return $query->expr()->literal($arrayValue);
}, $value);
return implode(',', $quotedValues);
}
return $query->expr()->literal($value);
}, array_values($params));
return str_replace($search, $replace, $sql);
}
public function getUniqueParameterName(string $parameter): string {
$suffix = Security::generateRandomString();
return sprintf("%s_%s", $parameter, $suffix);
}
public function validateDaysPeriodData(array $data): void {
if (!isset($data['timeframe']) || !in_array($data['timeframe'], [DynamicSegmentFilterData::TIMEFRAME_ALL_TIME, DynamicSegmentFilterData::TIMEFRAME_IN_THE_LAST], true)) {
throw new InvalidFilterException('Missing timeframe type', InvalidFilterException::MISSING_VALUE);
}
if ($data['timeframe'] === DynamicSegmentFilterData::TIMEFRAME_ALL_TIME) {
return;
}
$days = intval($data['days'] ?? null);
if ($days < 1) {
throw new InvalidFilterException('Missing number of days', InvalidFilterException::MISSING_VALUE);
}
}
}
@@ -0,0 +1,192 @@
<?php declare(strict_types = 1);
namespace MailPoet\Segments\DynamicSegments\Filters;
if (!defined('ABSPATH')) exit;
use MailPoet\CustomFields\CustomFieldsRepository;
use MailPoet\Entities\CustomFieldEntity;
use MailPoet\Entities\DynamicSegmentFilterData;
use MailPoet\Entities\DynamicSegmentFilterEntity;
use MailPoet\Entities\SubscriberCustomFieldEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Segments\DynamicSegments\Exceptions\InvalidFilterException;
use MailPoet\Util\Helpers;
use MailPoet\Util\Security;
use MailPoetVendor\Doctrine\DBAL\Query\QueryBuilder;
use MailPoetVendor\Doctrine\ORM\EntityManager;
class MailPoetCustomFields implements Filter {
const TYPE = 'mailpoetCustomField';
/** @var EntityManager */
private $entityManager;
/** @var CustomFieldsRepository */
private $customFieldsRepository;
public function __construct(
EntityManager $entityManager,
CustomFieldsRepository $customFieldsRepository
) {
$this->entityManager = $entityManager;
$this->customFieldsRepository = $customFieldsRepository;
}
public function apply(QueryBuilder $queryBuilder, DynamicSegmentFilterEntity $filter): QueryBuilder {
$filterData = $filter->getFilterData();
$customFieldType = $filterData->getParam('custom_field_type');
$customFieldId = $filterData->getParam('custom_field_id');
$parameterSuffix = (string)($filter->getId() ?? Security::generateRandomString());
$customFieldIdParam = 'customFieldId' . $parameterSuffix;
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
$subscribersCustomFieldTable = $this->entityManager->getClassMetadata(SubscriberCustomFieldEntity::class)->getTableName();
$queryBuilder->leftJoin(
$subscribersTable,
$subscribersCustomFieldTable,
'subscribers_custom_field',
"$subscribersTable.id = subscribers_custom_field.subscriber_id AND subscribers_custom_field.custom_field_id = :$customFieldIdParam"
);
$queryBuilder->setParameter($customFieldIdParam, $customFieldId);
$valueParam = 'value' . $parameterSuffix;
if (
($customFieldType === CustomFieldEntity::TYPE_TEXT)
|| ($customFieldType === CustomFieldEntity::TYPE_TEXTAREA)
|| ($customFieldType === CustomFieldEntity::TYPE_RADIO)
|| ($customFieldType === CustomFieldEntity::TYPE_SELECT)
) {
$queryBuilder = $this->applyEquality($queryBuilder, $filter, $valueParam);
}
if ($customFieldType === CustomFieldEntity::TYPE_CHECKBOX) {
$queryBuilder = $this->applyForCheckbox($queryBuilder, $filter);
}
if ($customFieldType === CustomFieldEntity::TYPE_DATE) {
$queryBuilder = $this->applyForDate($queryBuilder, $filter, $valueParam);
}
return $queryBuilder;
}
private function applyForDate(QueryBuilder $queryBuilder, DynamicSegmentFilterEntity $filter, string $valueParam): QueryBuilder {
$filterData = $filter->getFilterData();
$dateType = $filterData->getParam('date_type');
$value = $filterData->getParam('value');
$operator = $filterData->getParam('operator');
$queryBuilder->setParameter($valueParam, $value);
if ($operator === DynamicSegmentFilterData::IS_BLANK) {
$queryBuilder->andWhere('subscribers_custom_field.value IS NULL');
return $queryBuilder;
} elseif ($operator === DynamicSegmentFilterData::IS_NOT_BLANK) {
$queryBuilder->andWhere('subscribers_custom_field.value IS NOT NULL');
return $queryBuilder;
} elseif ($dateType === 'month') {
return $this->applyForDateMonth($queryBuilder, $valueParam);
} elseif ($dateType === 'year') {
return $this->applyForDateYear($queryBuilder, $operator, $valueParam);
}
return $this->applyForDateEqual($queryBuilder, $operator, $valueParam);
}
private function applyForDateMonth(QueryBuilder $queryBuilder, string $valueParam): QueryBuilder {
$queryBuilder->andWhere("month(subscribers_custom_field.value) = month(:$valueParam)");
return $queryBuilder;
}
private function applyForDateYear(QueryBuilder $queryBuilder, ?string $operator, string $valueParam): QueryBuilder {
if ($operator === 'before') {
$queryBuilder->andWhere("year(subscribers_custom_field.value) < year(:$valueParam)");
} elseif ($operator === 'after') {
$queryBuilder->andWhere("year(subscribers_custom_field.value) > year(:$valueParam)");
} else {
$queryBuilder->andWhere("year(subscribers_custom_field.value) = year(:$valueParam)");
}
return $queryBuilder;
}
private function applyForDateEqual(QueryBuilder $queryBuilder, ?string $operator, string $valueParam): QueryBuilder {
if ($operator === 'before') {
$queryBuilder->andWhere("subscribers_custom_field.value < :$valueParam");
} elseif ($operator === 'after') {
$queryBuilder->andWhere("subscribers_custom_field.value > :$valueParam");
} else {
// we always save full date in the database: 2018-03-01 00:00:00
// so this works even for year_month where we save the first day of the month
$queryBuilder->andWhere("subscribers_custom_field.value = :$valueParam");
}
return $queryBuilder;
}
private function applyForCheckbox(QueryBuilder $queryBuilder, DynamicSegmentFilterEntity $filter): QueryBuilder {
$filterData = $filter->getFilterData();
$value = $filterData->getParam('value');
$operator = $filterData->getParam('operator');
if ($operator === DynamicSegmentFilterData::IS_BLANK) {
$queryBuilder->andWhere('subscribers_custom_field.value IS NULL');
} elseif ($operator === DynamicSegmentFilterData::IS_NOT_BLANK) {
$queryBuilder->andWhere('subscribers_custom_field.value IS NOT NULL');
} elseif ($value === '1') {
$queryBuilder->andWhere('subscribers_custom_field.value = 1');
} elseif ($value === '0') {
$queryBuilder->andWhere('subscribers_custom_field.value <> 1');
}
return $queryBuilder;
}
private function applyEquality(QueryBuilder $queryBuilder, DynamicSegmentFilterEntity $filter, string $valueParam): QueryBuilder {
$filterData = $filter->getFilterData();
$operator = $filterData->getParam('operator');
$value = $filterData->getParam('value');
$requiresValue = !in_array($operator, [DynamicSegmentFilterData::IS_BLANK, DynamicSegmentFilterData::IS_NOT_BLANK]);
if ($requiresValue && !is_string($value)) {
throw new InvalidFilterException('Missing required value', InvalidFilterException::MISSING_VALUE);
}
/** @var string $value - for PhpStan */
if ($operator === 'equals') {
$queryBuilder->andWhere("subscribers_custom_field.value = :$valueParam");
$queryBuilder->setParameter($valueParam, $value);
} elseif ($operator === 'not_equals') {
$queryBuilder->andWhere("subscribers_custom_field.value != :$valueParam");
$queryBuilder->orWhere('subscribers_custom_field.value IS NULL');
$queryBuilder->setParameter($valueParam, $value);
} elseif ($operator === 'more_than') {
$queryBuilder->andWhere("subscribers_custom_field.value > :$valueParam");
$queryBuilder->setParameter($valueParam, $value);
} elseif ($operator === 'less_than') {
$queryBuilder->andWhere("subscribers_custom_field.value < :$valueParam");
$queryBuilder->setParameter($valueParam, $value);
} elseif ($operator === DynamicSegmentFilterData::IS_BLANK) {
$queryBuilder->andWhere("subscribers_custom_field.value IS NULL OR subscribers_custom_field.value = ''");
} elseif ($operator === DynamicSegmentFilterData::IS_NOT_BLANK) {
$queryBuilder->andWhere("subscribers_custom_field.value IS NOT NULL AND subscribers_custom_field.value != ''");
} elseif ($operator === 'not_contains') {
$queryBuilder->andWhere("subscribers_custom_field.value NOT LIKE :$valueParam");
$queryBuilder->setParameter($valueParam, '%' . Helpers::escapeSearch($value) . '%');
} else {
$queryBuilder->andWhere("subscribers_custom_field.value LIKE :$valueParam");
$queryBuilder->setParameter($valueParam, '%' . Helpers::escapeSearch($value) . '%');
}
return $queryBuilder;
}
public function getLookupData(DynamicSegmentFilterData $filterData): array {
$lookupData = [
'customFields' => [],
];
$customFieldId = $filterData->getIntParam('custom_field_id');
$customField = $this->customFieldsRepository->findOneById($customFieldId);
if ($customField instanceof CustomFieldEntity) {
$lookupData['customFields'][$customFieldId] = $customField->getName();
}
return $lookupData;
}
}
@@ -0,0 +1,69 @@
<?php declare(strict_types = 1);
namespace MailPoet\Segments\DynamicSegments\Filters;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\DynamicSegmentFilterData;
use MailPoet\Entities\DynamicSegmentFilterEntity;
use MailPoet\Entities\StatisticsClickEntity;
use MailPoetVendor\Carbon\CarbonImmutable;
use MailPoetVendor\Doctrine\DBAL\Query\QueryBuilder;
use MailPoetVendor\Doctrine\ORM\EntityManager;
class NumberOfClicks implements Filter {
const ACTION = 'numberOfClicks';
/** @var EntityManager */
private $entityManager;
/** @var FilterHelper */
private $filterHelper;
public function __construct(
EntityManager $entityManager,
FilterHelper $filterHelper
) {
$this->entityManager = $entityManager;
$this->filterHelper = $filterHelper;
}
public function apply(QueryBuilder $queryBuilder, DynamicSegmentFilterEntity $filter): QueryBuilder {
$filterData = $filter->getFilterData();
$clickCount = $filterData->getIntParam('clicks');
$operator = $filterData->getStringParam('operator');
$timeframe = $filterData->getStringParam('timeframe');
$statsTable = $this->entityManager->getClassMetadata(StatisticsClickEntity::class)->getTableName();
$subscribersTable = $this->filterHelper->getSubscribersTable();
if ($timeframe === DynamicSegmentFilterData::TIMEFRAME_ALL_TIME) {
$queryBuilder->leftJoin($subscribersTable, $statsTable, 'clicks', "{$subscribersTable}.id = clicks.subscriber_id");
} else {
$days = $filterData->getIntParam('days');
$dateParam = $this->filterHelper->getUniqueParameterName('days');
$queryBuilder->leftJoin($subscribersTable, $statsTable, 'clicks', "{$subscribersTable}.id = clicks.subscriber_id AND clicks.created_at >= :$dateParam");
$queryBuilder->setParameter($dateParam, CarbonImmutable::now()->subDays($days)->startOfDay());
}
$queryBuilder->groupBy("$subscribersTable.id");
$clicksCountParam = $this->filterHelper->getUniqueParameterName('clicks');
if ($operator === 'equals') {
$queryBuilder->having("count(clicks.id) = :$clicksCountParam");
} else if ($operator === 'not_equals') {
$queryBuilder->having("count(clicks.id) != :$clicksCountParam");
} else if ($operator === 'less') {
$queryBuilder->having("count(clicks.id) < :$clicksCountParam");
} else {
$queryBuilder->having("count(clicks.id) > :$clicksCountParam");
}
$queryBuilder->setParameter($clicksCountParam, $clickCount);
return $queryBuilder;
}
public function getLookupData(DynamicSegmentFilterData $filterData): array {
return [];
}
}
@@ -0,0 +1,114 @@
<?php declare(strict_types = 1);
namespace MailPoet\Segments\DynamicSegments\Filters;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\DynamicSegmentFilterData;
use MailPoet\Entities\DynamicSegmentFilterEntity;
use MailPoet\Segments\DynamicSegments\Exceptions\InvalidFilterException;
use MailPoetVendor\Doctrine\DBAL\Query\QueryBuilder;
class SubscriberDateField implements Filter {
const LAST_CLICK_DATE = 'lastClickDate';
const LAST_ENGAGEMENT_DATE = 'lastEngagementDate';
const LAST_PURCHASE_DATE = 'lastPurchaseDate';
const LAST_OPEN_DATE = 'lastOpenDate';
const LAST_PAGE_VIEW_DATE = 'lastPageViewDate';
const LAST_SENDING_DATE = 'lastSendingDate';
// Slightly different naming due to backwards compatibility
const SUBSCRIBED_DATE = 'subscribedDate';
const TYPES = [
self::LAST_CLICK_DATE,
self::LAST_ENGAGEMENT_DATE,
self::LAST_PURCHASE_DATE,
self::LAST_OPEN_DATE,
self::LAST_PAGE_VIEW_DATE,
self::LAST_SENDING_DATE,
self::SUBSCRIBED_DATE,
];
/** @var DateFilterHelper */
private $dateFilterHelper;
/** @var FilterHelper */
private $filterHelper;
public function __construct(
FilterHelper $filterHelper,
DateFilterHelper $dateFilterHelper
) {
$this->filterHelper = $filterHelper;
$this->dateFilterHelper = $dateFilterHelper;
}
public function apply(QueryBuilder $queryBuilder, DynamicSegmentFilterEntity $filter): QueryBuilder {
$operator = $this->dateFilterHelper->getOperatorFromFilter($filter);
$action = $filter->getFilterData()->getAction();
$value = $this->dateFilterHelper->getDateValueFromFilter($filter);
$parameter = $this->filterHelper->getUniqueParameterName('date');
$date = $this->dateFilterHelper->getDateStringForOperator($operator, $value);
if (!is_string($action)) {
throw new InvalidFilterException('Missing action', InvalidFilterException::MISSING_ACTION);
}
$columnName = $this->getColumnNameForAction($action);
switch ($operator) {
case DateFilterHelper::BEFORE:
case DateFilterHelper::NOT_IN_THE_LAST:
$queryBuilder->andWhere("DATE($columnName) < :$parameter");
break;
case DateFilterHelper::AFTER:
$queryBuilder->andWhere("DATE($columnName) > :$parameter");
break;
case DateFilterHelper::ON:
$queryBuilder->andWhere("DATE($columnName) = :$parameter");
break;
case DateFilterHelper::ON_OR_BEFORE:
$queryBuilder->andWhere("DATE($columnName) <= :$parameter");
break;
case DateFilterHelper::NOT_ON:
$queryBuilder->andWhere("DATE($columnName) != :$parameter");
break;
case DateFilterHelper::IN_THE_LAST:
case DateFilterHelper::ON_OR_AFTER:
$queryBuilder->andWhere("DATE($columnName) >= :$parameter");
break;
default:
throw new InvalidFilterException('Incorrect value for operator', InvalidFilterException::MISSING_VALUE);
}
$queryBuilder->setParameter($parameter, $date);
return $queryBuilder;
}
public function getColumnNameForAction(string $action): string {
switch ($action) {
case self::LAST_CLICK_DATE:
return 'last_click_at';
case self::LAST_ENGAGEMENT_DATE:
return 'last_engagement_at';
case self::LAST_PURCHASE_DATE:
return 'last_purchase_at';
case self::LAST_OPEN_DATE:
return 'last_open_at';
case self::LAST_PAGE_VIEW_DATE:
return 'last_page_view_at';
case self::SUBSCRIBED_DATE:
return 'last_subscribed_at';
case self::LAST_SENDING_DATE:
return 'last_sending_at';
default:
throw new InvalidFilterException('Invalid action', InvalidFilterException::MISSING_ACTION);
}
}
public function getLookupData(DynamicSegmentFilterData $filterData): array {
return [];
}
}
@@ -0,0 +1,54 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Segments\DynamicSegments\Filters;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\DynamicSegmentFilterData;
use MailPoet\Entities\DynamicSegmentFilterEntity;
use MailPoet\Segments\DynamicSegments\Exceptions\InvalidFilterException;
use MailPoet\Util\Security;
use MailPoetVendor\Doctrine\DBAL\Query\QueryBuilder;
class SubscriberScore implements Filter {
const TYPE = 'subscriberScore';
const HIGHER_THAN = 'higherThan';
const LOWER_THAN = 'lowerThan';
const EQUALS = 'equals';
const NOT_EQUALS = 'not_equals';
const UNKNOWN = 'unknown';
const NOT_UNKNOWN = 'not_unknown';
public function apply(QueryBuilder $queryBuilder, DynamicSegmentFilterEntity $filter): QueryBuilder {
$filterData = $filter->getFilterData();
$value = $filterData->getParam('value');
$operator = $filterData->getParam('operator');
$parameterSuffix = $filter->getId() ?: Security::generateRandomString();
$parameter = 'score' . $parameterSuffix;
if ($operator === self::HIGHER_THAN) {
$queryBuilder->andWhere("engagement_score > :$parameter");
} elseif ($operator === self::LOWER_THAN) {
$queryBuilder->andWhere("engagement_score < :$parameter");
} elseif ($operator === self::EQUALS) {
$queryBuilder->andWhere("engagement_score = :$parameter");
} elseif ($operator === self::NOT_EQUALS) {
$queryBuilder->andWhere("engagement_score != :$parameter");
} elseif ($operator === self::UNKNOWN) {
$queryBuilder->andWhere("engagement_score IS NULL");
} elseif ($operator === self::NOT_UNKNOWN) {
$queryBuilder->andWhere("engagement_score IS NOT NULL");
} else {
throw new InvalidFilterException('Incorrect value for operator', InvalidFilterException::MISSING_VALUE);
}
$queryBuilder->setParameter($parameter, (int)$value);
return $queryBuilder;
}
public function getLookupData(DynamicSegmentFilterData $filterData): array {
return [];
}
}
@@ -0,0 +1,83 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Segments\DynamicSegments\Filters;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\DynamicSegmentFilterData;
use MailPoet\Entities\DynamicSegmentFilterEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Entities\SubscriberSegmentEntity;
use MailPoet\Segments\SegmentsRepository;
use MailPoet\Util\Security;
use MailPoetVendor\Doctrine\DBAL\ArrayParameterType;
use MailPoetVendor\Doctrine\DBAL\Query\QueryBuilder;
use MailPoetVendor\Doctrine\ORM\EntityManager;
class SubscriberSegment implements Filter {
const TYPE = 'subscribedToList';
/** @var EntityManager */
private $entityManager;
/** @var SegmentsRepository */
private $segmentsRepository;
public function __construct(
EntityManager $entityManager,
SegmentsRepository $segmentsRepository
) {
$this->entityManager = $entityManager;
$this->segmentsRepository = $segmentsRepository;
}
public function apply(QueryBuilder $queryBuilder, DynamicSegmentFilterEntity $filter): QueryBuilder {
$filterData = $filter->getFilterData();
$segments = $filterData->getParam('segments');
$operator = $filterData->getParam('operator');
$parameterSuffix = $filter->getId() ?: Security::generateRandomString();
$statusSubscribedParam = 'subscribed' . $parameterSuffix;
$segmentsParam = 'segments' . $parameterSuffix;
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
$subscriberSegmentTable = $this->entityManager->getClassMetadata(SubscriberSegmentEntity::class)->getTableName();
$queryBuilder->leftJoin(
$subscribersTable,
$subscriberSegmentTable,
'subscriber_segments',
"$subscribersTable.id = subscriber_segments.subscriber_id"
. ' AND subscriber_segments.status = :' . $statusSubscribedParam
. ' AND subscriber_segments.segment_id IN (:' . $segmentsParam . ')'
);
$queryBuilder->setParameter($statusSubscribedParam, SubscriberEntity::STATUS_SUBSCRIBED);
$queryBuilder->setParameter($segmentsParam, $segments, ArrayParameterType::INTEGER);
if ($operator === DynamicSegmentFilterData::OPERATOR_NONE) {
$queryBuilder->andWhere('subscriber_segments.id IS NULL');
} else {
$queryBuilder->andWhere('subscriber_segments.id IS NOT NULL');
}
if ($operator === DynamicSegmentFilterData::OPERATOR_ALL) {
$queryBuilder->groupBy('subscriber_id');
$queryBuilder->having('COUNT(1) = ' . count($segments));
}
return $queryBuilder;
}
public function getLookupData(DynamicSegmentFilterData $filterData): array {
$lookupData = [
'segments' => [],
];
$segmentIds = $filterData->getArrayParam('segments');
$segments = $this->segmentsRepository->findBy(['id' => $segmentIds]);
foreach ($segments as $segment) {
$lookupData['segments'][$segment->getId()] = $segment->getName();
}
return $lookupData;
}
}
@@ -0,0 +1,76 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Segments\DynamicSegments\Filters;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\DynamicSegmentFilterData;
use MailPoet\Entities\DynamicSegmentFilterEntity;
use MailPoet\Entities\StatisticsFormEntity;
use MailPoet\Form\FormsRepository;
use MailPoetVendor\Doctrine\DBAL\ArrayParameterType;
use MailPoetVendor\Doctrine\DBAL\Query\QueryBuilder;
class SubscriberSubscribedViaForm implements Filter {
const TYPE = 'subscribedViaForm';
/** @var FilterHelper */
private $filterHelper;
/** @var FormsRepository */
private $formsRepository;
public function __construct(
FilterHelper $filterHelper,
FormsRepository $formsRepository
) {
$this->filterHelper = $filterHelper;
$this->formsRepository = $formsRepository;
}
public function apply(QueryBuilder $queryBuilder, DynamicSegmentFilterEntity $filter): QueryBuilder {
$filterData = $filter->getFilterData();
$formIds = $filterData->getParam('form_ids');
$operator = $filterData->getParam('operator');
$subscribersTable = $this->filterHelper->getSubscribersTable();
$formStatsTable = $this->filterHelper->getTableForEntity(StatisticsFormEntity::class);
$formIdsParam = $this->filterHelper->getUniqueParameterName('formIds');
if ($operator === DynamicSegmentFilterData::OPERATOR_ANY) {
$queryBuilder->innerJoin(
$subscribersTable,
$formStatsTable,
'statisticsForms',
"$subscribersTable.id = statisticsForms.subscriber_id"
);
$queryBuilder->andWhere("statisticsForms.form_id IN (:$formIdsParam)");
} elseif ($operator === DynamicSegmentFilterData::OPERATOR_NONE) {
$queryBuilder->leftJoin(
$subscribersTable,
$formStatsTable,
'statisticsForms',
"$subscribersTable.id = statisticsForms.subscriber_id AND statisticsForms.form_id IN (:$formIdsParam)"
);
$queryBuilder->andWhere("statisticsForms.subscriber_id IS NULL");
}
$queryBuilder->setParameter($formIdsParam, $formIds, ArrayParameterType::INTEGER);
return $queryBuilder;
}
public function getLookupData(DynamicSegmentFilterData $filterData): array {
$lookupData = [
'forms' => [],
];
$formIds = $filterData->getArrayParam('form_ids');
$forms = $this->formsRepository->findBy(['id' => $formIds]);
foreach ($forms as $form) {
$lookupData['forms'][$form->getId()] = $form->getName();
}
return $lookupData;
}
}
@@ -0,0 +1,41 @@
<?php declare(strict_types = 1);
namespace MailPoet\Segments\DynamicSegments\Filters;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\DynamicSegmentFilterData;
use MailPoet\Entities\DynamicSegmentFilterEntity;
use MailPoet\WP\Functions as WPFunctions;
use MailPoetVendor\Doctrine\DBAL\Query\QueryBuilder;
/**
* The filters in this class are primarily intended for the premium plugin
*/
class SubscriberTag implements Filter {
const TYPE = 'subscriberTag';
/** @var WPFunctions */
private $wp;
public function __construct(
WPFunctions $wp
) {
$this->wp = $wp;
}
public function apply(QueryBuilder $queryBuilder, DynamicSegmentFilterEntity $filter): QueryBuilder {
$this->wp->applyFilters('mailpoet_dynamic_segments_filter_subscriber_tag_apply', $queryBuilder, $filter);
return $queryBuilder;
}
public function getLookupData(DynamicSegmentFilterData $filterData): array {
$default = ['tags' => []];
$filteredLookupData = $this->wp->applyFilters('mailpoet_dynamic_segments_filter_subscriber_tag_getLookupData', $default, $filterData);
if (is_array($filteredLookupData)) {
return $filteredLookupData;
}
return $default;
}
}
@@ -0,0 +1,107 @@
<?php declare(strict_types = 1);
namespace MailPoet\Segments\DynamicSegments\Filters;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\DynamicSegmentFilterData;
use MailPoet\Entities\DynamicSegmentFilterEntity;
use MailPoet\Segments\DynamicSegments\Exceptions\InvalidFilterException;
use MailPoet\Util\Helpers;
use MailPoetVendor\Doctrine\DBAL\Query\QueryBuilder;
class SubscriberTextField implements Filter {
const FIRST_NAME = 'subscriberFirstName';
const LAST_NAME = 'subscriberLastName';
const EMAIL = 'subscriberEmail';
const TYPES = [self::FIRST_NAME, self::LAST_NAME, self::EMAIL];
/** @var FilterHelper */
private $filterHelper;
public function __construct(
FilterHelper $filterHelper
) {
$this->filterHelper = $filterHelper;
}
public function apply(QueryBuilder $queryBuilder, DynamicSegmentFilterEntity $filter): QueryBuilder {
$filterData = $filter->getFilterData();
$action = $filterData->getParam('action');
$value = $filterData->getParam('value');
$operator = $filterData->getParam('operator');
if (!is_string($action)) {
throw new InvalidFilterException('Missing action', InvalidFilterException::MISSING_ACTION);
}
if (!is_string($value)) {
throw new InvalidFilterException('Missing value', InvalidFilterException::MISSING_VALUE);
}
if (!is_string($operator)) {
throw new InvalidFilterException('Missing operator', InvalidFilterException::MISSING_OPERATOR);
}
$columnName = $this->getColumnNameForAction($action);
$parameter = $this->filterHelper->getUniqueParameterName('subscriberText');
switch ($operator) {
case DynamicSegmentFilterData::OPERATOR_IS:
$queryBuilder->andWhere("$columnName = :$parameter");
break;
case DynamicSegmentFilterData::OPERATOR_IS_NOT:
$queryBuilder->andWhere("$columnName != :$parameter");
break;
case DynamicSegmentFilterData::OPERATOR_CONTAINS:
$queryBuilder->andWhere($queryBuilder->expr()->like($columnName, ":$parameter"));
$value = '%' . Helpers::escapeSearch($value) . '%';
break;
case DynamicSegmentFilterData::OPERATOR_NOT_CONTAINS:
$queryBuilder->andWhere($queryBuilder->expr()->notLike($columnName, ":$parameter"));
$value = '%' . Helpers::escapeSearch($value) . '%';
break;
case DynamicSegmentFilterData::OPERATOR_STARTS_WITH:
$queryBuilder->andWhere($queryBuilder->expr()->like($columnName, ":$parameter"));
$value = Helpers::escapeSearch($value) . '%';
break;
case DynamicSegmentFilterData::OPERATOR_NOT_STARTS_WITH:
$queryBuilder->andWhere($queryBuilder->expr()->notLike($columnName, ":$parameter"));
$value = Helpers::escapeSearch($value) . '%';
break;
case DynamicSegmentFilterData::OPERATOR_ENDS_WITH:
$queryBuilder->andWhere($queryBuilder->expr()->like($columnName, ":$parameter"));
$value = '%' . Helpers::escapeSearch($value);
break;
case DynamicSegmentFilterData::OPERATOR_NOT_ENDS_WITH:
$queryBuilder->andWhere($queryBuilder->expr()->notLike($columnName, ":$parameter"));
$value = '%' . Helpers::escapeSearch($value);
break;
default:
throw new InvalidFilterException('Invalid operator', InvalidFilterException::MISSING_OPERATOR);
}
$queryBuilder->setParameter($parameter, $value);
return $queryBuilder;
}
private function getColumnNameForAction(string $field): string {
switch ($field) {
case self::FIRST_NAME:
return 'first_name';
case self::LAST_NAME:
return 'last_name';
case self::EMAIL:
return 'email';
}
throw new InvalidFilterException('Invalid action');
}
public function getLookupData(DynamicSegmentFilterData $filterData): array {
return [];
}
}
@@ -0,0 +1,98 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Segments\DynamicSegments\Filters;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\DynamicSegmentFilterData;
use MailPoet\Entities\DynamicSegmentFilterEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\InvalidStateException;
use MailPoet\Segments\DynamicSegments\Exceptions\InvalidFilterException;
use MailPoet\Util\Security;
use MailPoetVendor\Doctrine\DBAL\Query\QueryBuilder;
use MailPoetVendor\Doctrine\ORM\EntityManager;
class UserRole implements Filter {
const TYPE = 'wordpressRole';
/** @var EntityManager */
private $entityManager;
public function __construct(
EntityManager $entityManager
) {
$this->entityManager = $entityManager;
}
public function apply(QueryBuilder $queryBuilder, DynamicSegmentFilterEntity $filter): QueryBuilder {
global $wpdb;
$filterData = $filter->getFilterData();
$role = $filterData->getParam('wordpressRole');
$operator = $filterData->getParam('operator');
if (!$role) {
throw new InvalidFilterException('Missing role', InvalidFilterException::MISSING_ROLE);
}
if (!is_array($role)) {
// compatibility with the older segment before multiple roles were added
$role = [$role];
}
if (!$operator) {
$operator = DynamicSegmentFilterData::OPERATOR_ANY;
}
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
$parameterSuffix = ((string)$filter->getId()) . Security::generateRandomString();
$condition = $this->createCondition($role, $operator, $parameterSuffix);
$qb = $queryBuilder->join($subscribersTable, $wpdb->users, 'wpusers', "$subscribersTable.wp_user_id = wpusers.id")
->join('wpusers', $wpdb->usermeta, 'wpusermeta', 'wpusers.id = wpusermeta.user_id')
->andWhere("wpusermeta.meta_key = '{$wpdb->prefix}capabilities' AND (" . $condition . ')');
foreach ($role as $key => $userRole) {
$qb->setParameter('role' . $key . $parameterSuffix, '%"' . $userRole . '"%');
}
return $qb;
}
/**
* @param string[] $roles
* @param string $operator
* @param string $parameterSuffix
* @return string
*/
private function createCondition(array $roles, string $operator, $parameterSuffix): string {
$sqlParts = [];
foreach ($roles as $key => $role) {
if ($operator === DynamicSegmentFilterData::OPERATOR_NONE) {
$sqlParts[] = '(wpusermeta.meta_value NOT LIKE :role' . $key . $parameterSuffix . ')';
} else {
$sqlParts[] = '(wpusermeta.meta_value LIKE :role' . $key . $parameterSuffix . ')';
}
}
if (($operator === DynamicSegmentFilterData::OPERATOR_NONE) || ($operator === DynamicSegmentFilterData::OPERATOR_ALL)) {
return join(' AND ', $sqlParts);
}
return join(' OR ', $sqlParts);
}
public function getLookupData(DynamicSegmentFilterData $filterData): array {
global $wp_roles;
$lookupData = [
'roles' => [],
];
$roles = $filterData->getParam('wordpressRole');
if (is_string($roles)) {
$roles = [$roles];
}
if (!is_array($roles)) {
throw new InvalidStateException();
}
foreach ($roles as $roleSlug) {
$roleData = $wp_roles->roles[$roleSlug] ?? null;
if (is_array($roleData)) {
$lookupData['roles'][$roleSlug] = $roleData['name'];
}
}
return $lookupData;
}
}
@@ -0,0 +1,74 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Segments\DynamicSegments\Filters;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\DynamicSegmentFilterData;
use MailPoet\Entities\DynamicSegmentFilterEntity;
use MailPoetVendor\Carbon\Carbon;
use MailPoetVendor\Doctrine\DBAL\Query\QueryBuilder;
class WooCommerceAverageSpent implements Filter {
const ACTION = 'averageSpent';
/** @var WooFilterHelper */
private $wooFilterHelper;
/** @var FilterHelper */
private $filterHelper;
public function __construct(
FilterHelper $filterHelper,
WooFilterHelper $wooFilterHelper
) {
$this->filterHelper = $filterHelper;
$this->wooFilterHelper = $wooFilterHelper;
}
public function apply(QueryBuilder $queryBuilder, DynamicSegmentFilterEntity $filter): QueryBuilder {
$filterData = $filter->getFilterData();
$operator = $filterData->getParam('average_spent_type');
$amount = $filterData->getParam('average_spent_amount');
$timeframe = $filterData->getParam('timeframe');
$orderStatsAlias = $this->wooFilterHelper->applyOrderStatusFilter($queryBuilder);
if ($timeframe !== DynamicSegmentFilterData::TIMEFRAME_ALL_TIME) {
/** @var int $days */
$days = $filterData->getParam('days');
$days = intval($days);
$date = Carbon::now()->subDays($days);
$dateParam = $this->filterHelper->getUniqueParameterName('date');
$queryBuilder
->andWhere("$orderStatsAlias.date_created >= :$dateParam")
->setParameter($dateParam, $date->toDateTimeString());
}
$queryBuilder->groupBy('inner_subscriber_id');
$amountParam = $this->filterHelper->getUniqueParameterName('amount');
if ($operator === '=') {
$queryBuilder->having("AVG($orderStatsAlias.total_sales) = :$amountParam");
} elseif ($operator === '!=') {
$queryBuilder->having("AVG($orderStatsAlias.total_sales) != :$amountParam");
} elseif ($operator === '>') {
$queryBuilder->having("AVG($orderStatsAlias.total_sales) > :$amountParam");
} elseif ($operator === '<') {
$queryBuilder->having("AVG($orderStatsAlias.total_sales) < :$amountParam");
} elseif ($operator === '<=') {
$queryBuilder->having("AVG($orderStatsAlias.total_sales) <= :$amountParam");
} elseif ($operator === '>=') {
$queryBuilder->having("AVG($orderStatsAlias.total_sales) >= :$amountParam");
}
$queryBuilder->setParameter($amountParam, $amount);
return $queryBuilder;
}
public function getLookupData(DynamicSegmentFilterData $filterData): array {
return [];
}
}
@@ -0,0 +1,172 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Segments\DynamicSegments\Filters;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\DynamicSegmentFilterData;
use MailPoet\Entities\DynamicSegmentFilterEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Util\Security;
use MailPoet\WP\Functions as WPFunctions;
use MailPoetVendor\Doctrine\DBAL\ArrayParameterType;
use MailPoetVendor\Doctrine\DBAL\Query\QueryBuilder;
use MailPoetVendor\Doctrine\ORM\EntityManager;
use WP_Term;
class WooCommerceCategory implements Filter {
const ACTION_CATEGORY = 'purchasedCategory';
/** @var EntityManager */
private $entityManager;
/** @var WPFunctions */
private $wp;
/** @var WooFilterHelper */
private $wooFilterHelper;
/** @var FilterHelper */
private $filterHelper;
public function __construct(
EntityManager $entityManager,
FilterHelper $filterHelper,
WooFilterHelper $wooFilterHelper,
WPFunctions $wp
) {
$this->entityManager = $entityManager;
$this->wp = $wp;
$this->wooFilterHelper = $wooFilterHelper;
$this->filterHelper = $filterHelper;
}
public function apply(QueryBuilder $queryBuilder, DynamicSegmentFilterEntity $filter): QueryBuilder {
$filterData = $filter->getFilterData();
$operator = $filterData->getOperator();
$categoryIds = (array)$filterData->getParam('category_ids');
$categoryIdswithChildrenIds = $this->getCategoriesWithChildren($categoryIds);
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
$parameterSuffix = $filter->getId() ?: Security::generateRandomString();
$parameterSuffix = (string)$parameterSuffix;
if ($operator === DynamicSegmentFilterData::OPERATOR_ANY) {
$orderStatsAlias = $this->wooFilterHelper->applyOrderStatusFilter($queryBuilder);
$this->applyProductJoin($queryBuilder, $orderStatsAlias);
$this->applyTermRelationshipsJoin($queryBuilder);
$this->applyTermTaxonomyJoin($queryBuilder, $parameterSuffix);
} elseif ($operator === DynamicSegmentFilterData::OPERATOR_ALL) {
$subQueryCount = 1;
foreach ($categoryIds as $categoryId) {
$uniqueParamaterSuffix = Security::generateRandomString();
$categoryIdWithChildrenIds = $this->getCategoriesWithChildren([$categoryId]);
$subQuery = $this->filterHelper->getNewSubscribersQueryBuilder();
$orderStatsAlias = $this->wooFilterHelper->applyOrderStatusFilter($subQuery);
$this->applyProductJoin($subQuery, $orderStatsAlias);
$this->applyTermRelationshipsJoin($subQuery);
$this->applyTermTaxonomyJoin($subQuery, $uniqueParamaterSuffix);
$subQuery->setParameter("category_$uniqueParamaterSuffix", $categoryIdWithChildrenIds, ArrayParameterType::STRING);
$alias = sprintf("subQuery%s", $subQueryCount);
$queryBuilder->innerJoin(
$subscribersTable,
sprintf("(%s)", $this->filterHelper->getInterpolatedSQL($subQuery)),
$alias,
"$subscribersTable.id = $alias.id"
);
$subQueryCount++;
}
} elseif ($operator === DynamicSegmentFilterData::OPERATOR_NONE) {
// subQuery with subscriber ids that bought products
$subQuery = $this->createQueryBuilder($subscribersTable);
$subQuery->select("DISTINCT $subscribersTable.id");
$orderStatsAlias = $this->wooFilterHelper->applyOrderStatusFilter($subQuery);
$subQuery = $this->applyProductJoin($subQuery, $orderStatsAlias);
$subQuery = $this->applyTermRelationshipsJoin($subQuery);
$subQuery = $this->applyTermTaxonomyJoin($subQuery, $parameterSuffix);
// apply subQuery for negation
$queryBuilder->where("$subscribersTable.id NOT IN ({$this->filterHelper->getInterpolatedSQL($subQuery)})");
}
return $queryBuilder
->setParameter("category_$parameterSuffix", $categoryIdswithChildrenIds, ArrayParameterType::STRING);
}
private function applyProductJoin(QueryBuilder $queryBuilder, string $orderStatsAlias): QueryBuilder {
global $wpdb;
return $queryBuilder->innerJoin(
$orderStatsAlias,
$wpdb->prefix . 'wc_order_product_lookup',
'product',
"$orderStatsAlias.order_id = product.order_id"
);
}
private function applyTermRelationshipsJoin(QueryBuilder $queryBuilder): QueryBuilder {
global $wpdb;
return $queryBuilder->join(
'product',
$wpdb->term_relationships, // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
'term_relationships',
'product.product_id = term_relationships.object_id'
);
}
private function applyTermTaxonomyJoin(QueryBuilder $queryBuilder, string $parameterSuffix): QueryBuilder {
global $wpdb;
return $queryBuilder->innerJoin(
'term_relationships',
$wpdb->term_taxonomy, // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
'term_taxonomy',
"term_taxonomy.term_taxonomy_id=term_relationships.term_taxonomy_id
AND
term_taxonomy.term_id IN (:category_$parameterSuffix)"
);
}
private function createQueryBuilder(string $table): QueryBuilder {
return $this->entityManager->getConnection()
->createQueryBuilder()
->from($table);
}
private function getCategoriesWithChildren(array $categoriesId): array {
$allIds = [];
foreach ($categoriesId as $categoryId) {
$allIds = array_merge($allIds, $this->getAllCategoryIds($categoryId));
}
return array_unique($allIds);
}
private function getAllCategoryIds(int $categoryId): array {
$subcategories = $this->wp->getTerms(['taxonomy' => 'product_cat', 'child_of' => $categoryId, 'hide_empty' => false]);
if (!is_array($subcategories) || empty($subcategories)) {
return [$categoryId];
}
$ids = array_map(function($category) {
return $category->term_id; // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
}, $subcategories);
$ids[] = $categoryId;
return $ids;
}
public function getLookupData(DynamicSegmentFilterData $filterData): array {
$lookupData = [
'categories' => [],
];
$categoryIds = $filterData->getArrayParam('category_ids');
$terms = $this->wp->getTerms(['taxonomy' => 'product_cat', 'include' => $categoryIds, 'hide_empty' => false]);
/** @var WP_Term[] $terms */
foreach ($terms as $term) {
$lookupData['categories'][$term->term_id] = $term->name; // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
}
return $lookupData;
}
}
@@ -0,0 +1,86 @@
<?php declare(strict_types = 1);
namespace MailPoet\Segments\DynamicSegments\Filters;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\DynamicSegmentFilterData;
use MailPoet\Entities\DynamicSegmentFilterEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Util\DBCollationChecker;
use MailPoet\Util\Security;
use MailPoetVendor\Doctrine\DBAL\Query\QueryBuilder;
use MailPoetVendor\Doctrine\ORM\EntityManager;
class WooCommerceCountry implements Filter {
const ACTION_CUSTOMER_COUNTRY = 'customerInCountry';
/** @var EntityManager */
private $entityManager;
/** @var DBCollationChecker */
private $collationChecker;
public function __construct(
EntityManager $entityManager,
DBCollationChecker $collationChecker
) {
$this->entityManager = $entityManager;
$this->collationChecker = $collationChecker;
}
public function apply(QueryBuilder $queryBuilder, DynamicSegmentFilterEntity $filter): QueryBuilder {
global $wpdb;
$filterData = $filter->getFilterData();
$countryCode = $filterData->getParam('country_code');
if (!is_array($countryCode)) {
$countryCode = [(string)$countryCode];
}
$operator = $filterData->getParam('operator');
if (!$operator) {
$operator = DynamicSegmentFilterData::OPERATOR_ANY;
}
$countryFilterParam = ((string)$filter->getId()) . Security::generateRandomString();
$condition = $this->createCondition($countryCode, $operator, $countryFilterParam);
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
$collation = $this->collationChecker->getCollateIfNeeded(
$subscribersTable,
'email',
$wpdb->prefix . 'wc_customer_lookup',
'email'
);
$qb = $queryBuilder->innerJoin(
$subscribersTable,
$wpdb->prefix . 'wc_customer_lookup',
'customer',
"$subscribersTable.email = customer.email $collation"
)->where($condition);
foreach ($countryCode as $key => $userCountryCode) {
$qb->setParameter('countryCode' . $key . $countryFilterParam, '%' . $userCountryCode . '%');
}
return $qb;
}
private function createCondition(array $countryCodes, string $operator, string $countryFilterParam): string {
$sqlParts = [];
foreach ($countryCodes as $key => $userCountryCode) {
if ($operator === DynamicSegmentFilterData::OPERATOR_NONE) {
$sqlParts[] = '(customer.country NOT LIKE :countryCode' . $key . $countryFilterParam . ')';
} else {
$sqlParts[] = '(customer.country LIKE :countryCode' . $key . $countryFilterParam . ')';
}
}
if ($operator === DynamicSegmentFilterData::OPERATOR_NONE) {
return join(' AND ', $sqlParts);
}
return join(' OR ', $sqlParts);
}
public function getLookupData(DynamicSegmentFilterData $filterData): array {
return [];
}
}
@@ -0,0 +1,110 @@
<?php declare(strict_types = 1);
namespace MailPoet\Segments\DynamicSegments\Filters;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\DynamicSegmentFilterData;
use MailPoet\Entities\DynamicSegmentFilterEntity;
use MailPoet\Segments\DynamicSegments\Exceptions\InvalidFilterException;
use MailPoet\Util\Helpers;
use MailPoetVendor\Doctrine\DBAL\Query\QueryBuilder;
class WooCommerceCustomerTextField implements Filter {
const CITY = 'customerInCity';
const POSTAL_CODE = 'customerInPostalCode';
const ACTIONS = [self::CITY, self::POSTAL_CODE];
/** @var FilterHelper */
private $filterHelper;
/** @var WooFilterHelper */
private $wooFilterHelper;
public function __construct(
FilterHelper $filterHelper,
WooFilterHelper $wooFilterHelper
) {
$this->filterHelper = $filterHelper;
$this->wooFilterHelper = $wooFilterHelper;
}
public function apply(QueryBuilder $queryBuilder, DynamicSegmentFilterEntity $filter): QueryBuilder {
$filterData = $filter->getFilterData();
$action = $filterData->getParam('action');
$value = $filterData->getParam('value');
$operator = $filterData->getParam('operator');
if (!is_string($action)) {
throw new InvalidFilterException('Missing action', InvalidFilterException::MISSING_ACTION);
}
if (!is_string($value)) {
throw new InvalidFilterException('Missing value', InvalidFilterException::MISSING_VALUE);
}
if (!is_string($operator)) {
throw new InvalidFilterException('Missing operator', InvalidFilterException::MISSING_OPERATOR);
}
$customerLookupAlias = $this->wooFilterHelper->applyCustomerLookupJoin($queryBuilder);
$column = sprintf("%s.%s", $customerLookupAlias, $this->getColumnNameForAction($action));
$parameter = $this->filterHelper->getUniqueParameterName('customerTextField');
switch ($operator) {
case DynamicSegmentFilterData::OPERATOR_IS:
$queryBuilder->andWhere("$column = :$parameter");
break;
case DynamicSegmentFilterData::OPERATOR_IS_NOT:
$queryBuilder->andWhere("$column != :$parameter");
break;
case DynamicSegmentFilterData::OPERATOR_CONTAINS:
$queryBuilder->andWhere($queryBuilder->expr()->like($column, ":$parameter"));
$value = '%' . Helpers::escapeSearch($value) . '%';
break;
case DynamicSegmentFilterData::OPERATOR_NOT_CONTAINS:
$queryBuilder->andWhere($queryBuilder->expr()->notLike($column, ":$parameter"));
$value = '%' . Helpers::escapeSearch($value) . '%';
break;
case DynamicSegmentFilterData::OPERATOR_STARTS_WITH:
$queryBuilder->andWhere($queryBuilder->expr()->like($column, ":$parameter"));
$value = Helpers::escapeSearch($value) . '%';
break;
case DynamicSegmentFilterData::OPERATOR_NOT_STARTS_WITH:
$queryBuilder->andWhere($queryBuilder->expr()->notLike($column, ":$parameter"));
$value = Helpers::escapeSearch($value) . '%';
break;
case DynamicSegmentFilterData::OPERATOR_ENDS_WITH:
$queryBuilder->andWhere($queryBuilder->expr()->like($column, ":$parameter"));
$value = '%' . Helpers::escapeSearch($value);
break;
case DynamicSegmentFilterData::OPERATOR_NOT_ENDS_WITH:
$queryBuilder->andWhere($queryBuilder->expr()->notLike($column, ":$parameter"));
$value = '%' . Helpers::escapeSearch($value);
break;
default:
throw new InvalidFilterException('Invalid operator', InvalidFilterException::MISSING_OPERATOR);
}
$queryBuilder->setParameter($parameter, $value);
return $queryBuilder;
}
private function getColumnNameForAction(string $field): string {
switch ($field) {
case self::CITY:
return 'city';
case self::POSTAL_CODE:
return 'postcode';
}
throw new InvalidFilterException('Invalid action');
}
public function getLookupData(DynamicSegmentFilterData $filterData): array {
return [];
}
}
@@ -0,0 +1,89 @@
<?php declare(strict_types = 1);
namespace MailPoet\Segments\DynamicSegments\Filters;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\DynamicSegmentFilterData;
use MailPoet\Entities\DynamicSegmentFilterEntity;
use MailPoet\Segments\DynamicSegments\Exceptions\InvalidFilterException;
use MailPoetVendor\Doctrine\DBAL\Query\QueryBuilder;
class WooCommerceFirstOrder implements Filter {
const ACTION = 'firstOrder';
/** @var DateFilterHelper */
private $dateFilterHelper;
/** @var FilterHelper */
private $filterHelper;
/** @var WooFilterHelper */
private $wooFilterHelper;
public function __construct(
DateFilterHelper $dateFilterHelper,
FilterHelper $filterHelper,
WooFilterHelper $wooFilterHelper
) {
$this->dateFilterHelper = $dateFilterHelper;
$this->filterHelper = $filterHelper;
$this->wooFilterHelper = $wooFilterHelper;
}
public function apply(QueryBuilder $queryBuilder, DynamicSegmentFilterEntity $filter): QueryBuilder {
$operator = $this->dateFilterHelper->getOperatorFromFilter($filter);
$dateValue = $this->dateFilterHelper->getDateValueFromFilter($filter);
$date = $this->dateFilterHelper->getDateStringForOperator($operator, $dateValue);
$subscribersTable = $this->filterHelper->getSubscribersTable();
if (in_array($operator, [DateFilterHelper::NOT_ON, DateFilterHelper::NOT_IN_THE_LAST])) {
$subQuery = $this->filterHelper->getNewSubscribersQueryBuilder();
$this->applyConditionsToQueryBuilder($operator, $date, $subQuery);
$queryBuilder->andWhere($queryBuilder->expr()->notIn("{$subscribersTable}.id", $this->filterHelper->getInterpolatedSQL($subQuery)));
} else {
$this->applyConditionsToQueryBuilder($operator, $date, $queryBuilder);
}
return $queryBuilder;
}
private function applyConditionsToQueryBuilder(string $operator, string $date, QueryBuilder $queryBuilder): QueryBuilder {
$orderStatsAlias = $this->wooFilterHelper->applyOrderStatusFilter($queryBuilder);
$dateParam = $this->filterHelper->getUniqueParameterName('date');
$subscribersTable = $this->filterHelper->getSubscribersTable();
$queryBuilder->groupBy("$subscribersTable.id");
switch ($operator) {
case DateFilterHelper::BEFORE:
$queryBuilder->andHaving("MIN($orderStatsAlias.date_created) < :$dateParam");
break;
case DateFilterHelper::AFTER:
$queryBuilder->andHaving("MIN($orderStatsAlias.date_created) > :$dateParam");
break;
case DateFilterHelper::IN_THE_LAST:
case DateFilterHelper::NOT_IN_THE_LAST:
case DateFilterHelper::ON_OR_AFTER:
$queryBuilder->andHaving("MIN($orderStatsAlias.date_created) >= :$dateParam");
break;
case DateFilterHelper::ON:
case DateFilterHelper::NOT_ON:
$queryBuilder->andHaving("MIN($orderStatsAlias.date_created) = :$dateParam");
break;
case DateFilterHelper::ON_OR_BEFORE:
$queryBuilder->andHaving("MIN($orderStatsAlias.date_created) <= :$dateParam");
break;
default:
throw new InvalidFilterException('Incorrect value for operator', InvalidFilterException::MISSING_VALUE);
}
$queryBuilder->setParameter($dateParam, $date);
return $queryBuilder;
}
public function getLookupData(DynamicSegmentFilterData $filterData): array {
return [];
}
}
@@ -0,0 +1,94 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Segments\DynamicSegments\Filters;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\DynamicSegmentFilterData;
use MailPoet\Entities\DynamicSegmentFilterEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Util\Security;
use MailPoetVendor\Doctrine\DBAL\ArrayParameterType;
use MailPoetVendor\Doctrine\DBAL\Query\QueryBuilder;
use MailPoetVendor\Doctrine\ORM\EntityManager;
class WooCommerceMembership implements Filter {
const ACTION_MEMBER_OF = 'isMemberOf';
/** @var EntityManager */
private $entityManager;
public function __construct(
EntityManager $entityManager
) {
$this->entityManager = $entityManager;
}
public function apply(QueryBuilder $queryBuilder, DynamicSegmentFilterEntity $filter): QueryBuilder {
$filterData = $filter->getFilterData();
/** @var array */
$planIds = $filterData->getParam('plan_ids');
$operator = $filterData->getParam('operator');
$parameterSuffix = $filter->getId() ?: Security::generateRandomString();
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
// ALL OF
if ($operator === DynamicSegmentFilterData::OPERATOR_ALL) {
$this->applyPostJoin($queryBuilder);
$this->applyParentPostJoin($queryBuilder);
return $queryBuilder
->andWhere("posts.post_parent IN (:plans" . $parameterSuffix . ")")
->groupBy("$subscribersTable.id")
->having("COUNT($subscribersTable.id) = :count$parameterSuffix")
->setParameter('plans' . $parameterSuffix, $planIds, ArrayParameterType::STRING)
->setParameter('count' . $parameterSuffix, count($planIds));
}
// NONE OF
if ($operator === DynamicSegmentFilterData::OPERATOR_NONE) {
$subQueryBuilder = $this->entityManager->getConnection()
->createQueryBuilder()
->from($subscribersTable)
->select("DISTINCT $subscribersTable.id");
$this->applyPostJoin($subQueryBuilder);
$this->applyParentPostJoin($subQueryBuilder);
$subQueryBuilder
->andWhere("posts.post_parent IN (:plans" . $parameterSuffix . ")");
return $queryBuilder->where("{$subscribersTable}.id NOT IN ({$subQueryBuilder->getSQL()})")
->setParameter('plans' . $parameterSuffix, $planIds, ArrayParameterType::STRING);
}
// ANY
$this->applyPostJoin($queryBuilder);
$this->applyParentPostJoin($queryBuilder);
return $queryBuilder
->andWhere("posts.post_parent IN (:plans" . $parameterSuffix . ")")
->setParameter('plans' . $parameterSuffix, $planIds, ArrayParameterType::STRING);
}
private function applyPostJoin(QueryBuilder $queryBuilder): QueryBuilder {
global $wpdb;
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
return $queryBuilder->innerJoin(
$subscribersTable,
$wpdb->posts,
'posts',
"posts.post_type = 'wc_user_membership' AND posts.post_status IN ('wcm-active', 'wcm-complimentary', 'wcm-free_trial', 'wcm-pending') AND posts.post_author=$subscribersTable.wp_user_id"
);
}
private function applyParentPostJoin(QueryBuilder $queryBuilder): QueryBuilder {
global $wpdb;
return $queryBuilder->innerJoin(
'posts',
$wpdb->posts,
'parentposts',
"posts.post_parent = parentposts.id AND parentposts.post_type = 'wc_membership_plan' AND parentposts.post_status = 'publish'"
);
}
public function getLookupData(DynamicSegmentFilterData $filterData): array {
return [];
}
}
@@ -0,0 +1,139 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Segments\DynamicSegments\Filters;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\DynamicSegmentFilterData;
use MailPoet\Entities\DynamicSegmentFilterEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Util\DBCollationChecker;
use MailPoet\Util\Security;
use MailPoetVendor\Carbon\Carbon;
use MailPoetVendor\Doctrine\DBAL\ArrayParameterType;
use MailPoetVendor\Doctrine\DBAL\Query\QueryBuilder;
use MailPoetVendor\Doctrine\ORM\EntityManager;
class WooCommerceNumberOfOrders implements Filter {
const ACTION_NUMBER_OF_ORDERS = 'numberOfOrders';
const ACTION_NUMBER_OF_ORDERS_WITH_COUPON = 'numberOfOrdersWithCoupon';
const ACTIONS = [
self::ACTION_NUMBER_OF_ORDERS,
self::ACTION_NUMBER_OF_ORDERS_WITH_COUPON,
];
/** @var EntityManager */
private $entityManager;
/** @var DBCollationChecker */
private $collationChecker;
/** @var WooFilterHelper */
private $wooFilterHelper;
public function __construct(
EntityManager $entityManager,
DBCollationChecker $collationChecker,
WooFilterHelper $wooFilterHelper
) {
$this->entityManager = $entityManager;
$this->collationChecker = $collationChecker;
$this->wooFilterHelper = $wooFilterHelper;
}
public function apply(QueryBuilder $queryBuilder, DynamicSegmentFilterEntity $filter): QueryBuilder {
global $wpdb;
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
$filterData = $filter->getFilterData();
/** @var string $type - for PHPStan because strval() doesn't accept a value of mixed */
$type = $filterData->getParam('number_of_orders_type');
$type = strval($type);
/** @var string $count - for PHPStan because intval() doesn't accept a value of mixed */
$count = $filterData->getParam('number_of_orders_count');
$count = intval($count);
$isAllTime = $filterData->getParam('timeframe') === DynamicSegmentFilterData::TIMEFRAME_ALL_TIME;
$parameterSuffix = $filter->getId() ?? Security::generateRandomString();
$collation = $this->collationChecker->getCollateIfNeeded(
$subscribersTable,
'email',
$wpdb->prefix . 'wc_customer_lookup',
'email'
);
$days = $filterData->getParam('days');
$date = Carbon::now()->subDays($days);
$joinCondition = $isAllTime
? 'customer.customer_id = orderStats.customer_id AND orderStats.status IN (:allowedStatuses' . $parameterSuffix . ')'
: 'customer.customer_id = orderStats.customer_id AND orderStats.date_created >= :date' . $parameterSuffix . ' AND orderStats.status IN (:allowedStatuses' . $parameterSuffix . ')';
$subQuery = $this->entityManager->getConnection()
->createQueryBuilder()
->from($wpdb->prefix . 'wc_customer_lookup', "customer")
->select("customer.email $collation as email")
->addSelect("orderStats.order_id as oder_stats_id")
->leftJoin(
'customer',
$wpdb->prefix . 'wc_order_stats',
'orderStats',
$joinCondition
);
$action = $filterData->getAction();
if ($action === self::ACTION_NUMBER_OF_ORDERS_WITH_COUPON) {
$subQuery->innerJoin('orderStats', $wpdb->prefix . 'wc_order_coupon_lookup', 'couponLookup', 'orderStats.order_id = couponLookup.order_id');
}
$queryBuilder->add('join', [
$subscribersTable => [
/**
* Based the combination of $type and $count we may need to include none-customer subscribers
* in this case we'll need to leftJoin subscribers table to result of the sub-query defined above,
* in all other cases innerJoin gets us the expected records.
*/
'joinType' => $this-> shouldIncludeNoneCustomerSubscribers($type, $count) ? 'left' : 'inner',
'joinTable' => "({$subQuery->getSQL()})",
'joinAlias' => 'selectedCustomers',
'joinCondition' => "$subscribersTable.email = selectedCustomers.email $collation",
],
], \true)
->setParameter('date' . $parameterSuffix, $date->toDateTimeString())
->setParameter('allowedStatuses' . $parameterSuffix, $this->wooFilterHelper->defaultIncludedStatuses(), ArrayParameterType::STRING)
->groupBy('inner_subscriber_id');
if ($type === '=') {
$queryBuilder->having('COUNT(oder_stats_id) = :count' . $parameterSuffix);
} elseif ($type === '!=') {
$queryBuilder->having('COUNT(oder_stats_id) != :count' . $parameterSuffix);
} elseif ($type === '>') {
$queryBuilder->having('COUNT(oder_stats_id) > :count' . $parameterSuffix);
} elseif ($type === '<') {
$queryBuilder->having('COUNT(oder_stats_id) < :count' . $parameterSuffix);
}
$queryBuilder->setParameter('count' . $parameterSuffix, $count, 'integer');
return $queryBuilder;
}
private function shouldIncludeNoneCustomerSubscribers(string $type, int $count): bool {
if ($type === '=') {
return $count === 0;
} elseif ($type === '!=') {
return true;
} elseif ($type === '>') {
return $count < 0;
} elseif ($type === '<') {
return true;
}
return false;
}
public function getLookupData(DynamicSegmentFilterData $filterData): array {
return [];
}
}
@@ -0,0 +1,144 @@
<?php declare(strict_types = 1);
namespace MailPoet\Segments\DynamicSegments\Filters;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\DynamicSegmentFilterData;
use MailPoet\Entities\DynamicSegmentFilterEntity;
use MailPoet\Segments\DynamicSegments\Exceptions\InvalidFilterException;
use MailPoet\Util\DBCollationChecker;
use MailPoetVendor\Carbon\Carbon;
use MailPoetVendor\Doctrine\DBAL\Query\QueryBuilder;
class WooCommerceNumberOfReviews implements Filter {
const ACTION = 'numberOfReviews';
/** @var DBCollationChecker */
private $collationChecker;
/** @var FilterHelper */
private $filterHelper;
public function __construct(
DBCollationChecker $collationChecker,
FilterHelper $filterHelper
) {
$this->collationChecker = $collationChecker;
$this->filterHelper = $filterHelper;
}
public function apply(QueryBuilder $queryBuilder, DynamicSegmentFilterEntity $filter): QueryBuilder {
$commentsTable = $this->filterHelper->getPrefixedTable('comments');
$commentMetaTable = $this->filterHelper->getPrefixedTable('commentmeta');
$filterData = $filter->getFilterData();
$this->validateFilterData((array)$filterData->getData());
/** @var string $type - for PHPStan because strval() doesn't accept a value of mixed */
$type = $filterData->getParam('count_type');
$type = strval($type);
/** @var string $rating - for PHPStan because strval() doesn't accept a value of mixed */
$rating = $filterData->getParam('rating');
$rating = strval($rating);
/** @var int $days - for PHPStan because intval() doesn't accept a value of mixed */
$days = $filterData->getParam('days');
$days = intval($days);
/** @var int $count - for PHPStan because intval() doesn't accept a value of mixed */
$count = $filterData->getParam('count');
$count = intval($count);
$subscribersTable = $this->filterHelper->getSubscribersTable();
$collation = $this->collationChecker->getCollateIfNeeded(
$subscribersTable,
'email',
$commentsTable,
'comment_author_email'
);
$isAllTime = $filterData->getParam('timeframe') === DynamicSegmentFilterData::TIMEFRAME_ALL_TIME;
$joinCondition = "$subscribersTable.email = comments.comment_author_email $collation
AND comments.comment_type = 'review'";
if (!$isAllTime) {
$date = Carbon::now()->subDays($days);
$dateParam = $this->filterHelper->getUniqueParameterName('date');
$joinCondition .= " AND comments.comment_date >= :$dateParam";
$queryBuilder->setParameter($dateParam, $date->toDateTimeString());
}
$commentMetaJoinCondition = "comments.comment_ID = commentmeta.comment_id AND commentmeta.meta_key = 'rating'";
if ($rating !== 'any') {
$ratingParam = $this->filterHelper->getUniqueParameterName('rating');
$commentMetaJoinCondition .= "AND commentmeta.meta_value = :$ratingParam";
$queryBuilder->setParameter($ratingParam, $rating);
}
$queryBuilder
->leftJoin(
$subscribersTable,
$commentsTable,
'comments',
$joinCondition
)->leftJoin(
'comments',
$commentMetaTable,
'commentmeta',
$commentMetaJoinCondition
);
$queryBuilder->groupBy('inner_subscriber_id');
$countParam = $this->filterHelper->getUniqueParameterName('count');
switch ($type) {
case '=':
$queryBuilder->having("COUNT(commentmeta.meta_value) = :$countParam");
break;
case '!=':
$queryBuilder->having("COUNT(commentmeta.meta_value) != :$countParam");
break;
case '>':
$queryBuilder->having("COUNT(commentmeta.meta_value) > :$countParam");
break;
case '<':
$queryBuilder->having("COUNT(commentmeta.meta_value) < :$countParam");
break;
}
$queryBuilder->setParameter($countParam, $count, 'integer');
return $queryBuilder;
}
public function validateFilterData(array $data): void {
if (!isset($data['rating'])) {
throw new InvalidFilterException('Missing rating', InvalidFilterException::MISSING_VALUE);
}
$validRatings = ['1', '2', '3', '4', '5', 'any'];
if (!in_array($data['rating'], $validRatings, true)) {
throw new InvalidFilterException('Invalid rating', InvalidFilterException::MISSING_VALUE);
}
if (!isset($data['count_type'])) {
throw new InvalidFilterException('Missing count type', InvalidFilterException::MISSING_VALUE);
}
$type = $data['count_type'];
$validTypes = [
'=',
'!=',
'>',
'<',
];
if (!in_array($type, $validTypes, true)) {
throw new InvalidFilterException('Invalid count type', InvalidFilterException::INVALID_TYPE);
}
if (!isset($data['count'])) {
throw new InvalidFilterException('Missing review count', InvalidFilterException::MISSING_VALUE);
}
$this->filterHelper->validateDaysPeriodData($data);
}
public function getLookupData(DynamicSegmentFilterData $filterData): array {
return [];
}
}
@@ -0,0 +1,109 @@
<?php declare(strict_types = 1);
namespace MailPoet\Segments\DynamicSegments\Filters;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\DynamicSegmentFilterData;
use MailPoet\Entities\DynamicSegmentFilterEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Util\Security;
use MailPoet\WooCommerce\Helper;
use MailPoetVendor\Doctrine\DBAL\ArrayParameterType;
use MailPoetVendor\Doctrine\DBAL\Query\QueryBuilder;
use MailPoetVendor\Doctrine\ORM\EntityManager;
use WC_Product;
class WooCommerceProduct implements Filter {
const ACTION_PRODUCT = 'purchasedProduct';
/** @var EntityManager */
private $entityManager;
/** @var WooFilterHelper */
private $wooFilterHelper;
/** @var FilterHelper */
private $filterHelper;
/** @var Helper */
private $wooHelper;
public function __construct(
EntityManager $entityManager,
FilterHelper $filterHelper,
Helper $wooHelper,
WooFilterHelper $wooFilterHelper
) {
$this->entityManager = $entityManager;
$this->wooFilterHelper = $wooFilterHelper;
$this->filterHelper = $filterHelper;
$this->wooHelper = $wooHelper;
}
public function apply(QueryBuilder $queryBuilder, DynamicSegmentFilterEntity $filter): QueryBuilder {
$filterData = $filter->getFilterData();
$operator = $filterData->getOperator();
$productIds = $filterData->getParam('product_ids');
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
$parameterSuffix = $filter->getId() ?? Security::generateRandomString();
if ($operator === DynamicSegmentFilterData::OPERATOR_ANY) {
$orderStatsAlias = $this->wooFilterHelper->applyOrderStatusFilter($queryBuilder);
$this->applyProductJoin($queryBuilder, $orderStatsAlias);
$queryBuilder->andWhere("product.product_id IN (:products_{$parameterSuffix})");
} elseif ($operator === DynamicSegmentFilterData::OPERATOR_ALL) {
$orderStatsAlias = $this->wooFilterHelper->applyOrderStatusFilter($queryBuilder);
$this->applyProductJoin($queryBuilder, $orderStatsAlias);
$queryBuilder->andWhere("product.product_id IN (:products_{$parameterSuffix})")
->groupBy("{$subscribersTable}.id, $orderStatsAlias.order_id")
->having("COUNT($orderStatsAlias.order_id) = :count" . $parameterSuffix)
->setParameter('count' . $parameterSuffix, count($productIds));
} elseif ($operator === DynamicSegmentFilterData::OPERATOR_NONE) {
// subQuery with subscriber ids that bought products
$subQuery = $this->createQueryBuilder($subscribersTable);
$subQuery->select("DISTINCT $subscribersTable.id");
$orderStatsAlias = $this->wooFilterHelper->applyOrderStatusFilter($subQuery);
$subQuery = $this->applyProductJoin($subQuery, $orderStatsAlias);
$subQuery->andWhere("product.product_id IN (:products_{$parameterSuffix})");
// application subQuery for negation
$queryBuilder->where("{$subscribersTable}.id NOT IN ({$this->filterHelper->getInterpolatedSQL($subQuery)})");
}
return $queryBuilder
->setParameter("products_{$parameterSuffix}", $productIds, ArrayParameterType::STRING);
}
private function applyProductJoin(QueryBuilder $queryBuilder, string $orderStatsAlias): QueryBuilder {
global $wpdb;
return $queryBuilder->innerJoin(
$orderStatsAlias,
$wpdb->prefix . 'wc_order_product_lookup',
'product',
"$orderStatsAlias.order_id = product.order_id"
);
}
private function createQueryBuilder(string $table): QueryBuilder {
return $this->entityManager->getConnection()
->createQueryBuilder()
->from($table);
}
public function getLookupData(DynamicSegmentFilterData $filterData): array {
$lookupData = ['products' => []];
if (!$this->wooHelper->isWooCommerceActive()) {
return $lookupData;
}
$productIds = $filterData->getArrayParam('product_ids');
foreach ($productIds as $productId) {
$product = $this->wooHelper->wcGetProduct($productId);
if ($product instanceof WC_Product) {
$lookupData['products'][$productId] = $product->get_name();
}
}
return $lookupData;
}
}
@@ -0,0 +1,87 @@
<?php declare(strict_types = 1);
namespace MailPoet\Segments\DynamicSegments\Filters;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\DynamicSegmentFilterData;
use MailPoet\Entities\DynamicSegmentFilterEntity;
use MailPoet\Segments\DynamicSegments\Exceptions\InvalidFilterException;
use MailPoetVendor\Doctrine\DBAL\Query\QueryBuilder;
class WooCommercePurchaseDate implements Filter {
const ACTION = 'purchaseDate';
/** @var DateFilterHelper */
private $dateFilterHelper;
/** @var FilterHelper */
private $filterHelper;
/** @var WooFilterHelper */
private $wooFilterHelper;
public function __construct(
DateFilterHelper $dateFilterHelper,
FilterHelper $filterHelper,
WooFilterHelper $wooFilterHelper
) {
$this->dateFilterHelper = $dateFilterHelper;
$this->filterHelper = $filterHelper;
$this->wooFilterHelper = $wooFilterHelper;
}
public function apply(QueryBuilder $queryBuilder, DynamicSegmentFilterEntity $filter): QueryBuilder {
$operator = $this->dateFilterHelper->getOperatorFromFilter($filter);
$dateValue = $this->dateFilterHelper->getDateValueFromFilter($filter);
$date = $this->dateFilterHelper->getDateStringForOperator($operator, $dateValue);
$subscribersTable = $this->filterHelper->getSubscribersTable();
if (in_array($operator, [DateFilterHelper::NOT_ON, DateFilterHelper::NOT_IN_THE_LAST])) {
$subQuery = $this->filterHelper->getNewSubscribersQueryBuilder();
$this->applyConditionsToQueryBuilder($operator, $date, $subQuery);
$queryBuilder->andWhere($queryBuilder->expr()->notIn("{$subscribersTable}.id", $this->filterHelper->getInterpolatedSQL($subQuery)));
} else {
$this->applyConditionsToQueryBuilder($operator, $date, $queryBuilder);
}
return $queryBuilder;
}
private function applyConditionsToQueryBuilder(string $operator, string $date, QueryBuilder $queryBuilder): QueryBuilder {
$orderStatsAlias = $this->wooFilterHelper->applyOrderStatusFilter($queryBuilder);
$dateParam = $this->filterHelper->getUniqueParameterName('date');
switch ($operator) {
case DateFilterHelper::BEFORE:
$queryBuilder->andWhere("DATE($orderStatsAlias.date_created) < :$dateParam");
break;
case DateFilterHelper::AFTER:
$queryBuilder->andWhere("DATE($orderStatsAlias.date_created) > :$dateParam");
break;
case DateFilterHelper::IN_THE_LAST:
case DateFilterHelper::NOT_IN_THE_LAST:
case DateFilterHelper::ON_OR_AFTER:
$queryBuilder->andWhere("DATE($orderStatsAlias.date_created) >= :$dateParam");
break;
case DateFilterHelper::ON:
case DateFilterHelper::NOT_ON:
$queryBuilder->andWhere("DATE($orderStatsAlias.date_created) = :$dateParam");
break;
case DateFilterHelper::ON_OR_BEFORE:
$queryBuilder->andWhere("DATE($orderStatsAlias.date_created) <= :$dateParam");
break;
default:
throw new InvalidFilterException('Incorrect value for operator', InvalidFilterException::MISSING_VALUE);
}
$queryBuilder->setParameter($dateParam, $date);
return $queryBuilder;
}
public function getLookupData(DynamicSegmentFilterData $filterData): array {
return [];
}
}
@@ -0,0 +1,211 @@
<?php declare(strict_types = 1);
namespace MailPoet\Segments\DynamicSegments\Filters;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\DynamicSegmentFilterData;
use MailPoet\Entities\DynamicSegmentFilterEntity;
use MailPoet\Segments\DynamicSegments\Exceptions\InvalidFilterException;
use MailPoet\WP\Functions;
use MailPoetVendor\Doctrine\DBAL\ArrayParameterType;
use MailPoetVendor\Doctrine\DBAL\Query\QueryBuilder;
class WooCommercePurchasedWithAttribute implements Filter {
const ACTION = 'purchasedWithAttribute';
const TYPE_LOCAL = 'local';
const TYPE_TAXONOMY = 'taxonomy';
private WooFilterHelper $wooFilterHelper;
private FilterHelper $filterHelper;
private Functions $wp;
public function __construct(
FilterHelper $filterHelper,
WooFilterHelper $wooFilterHelper,
Functions $wp
) {
$this->wooFilterHelper = $wooFilterHelper;
$this->filterHelper = $filterHelper;
$this->wp = $wp;
}
public function apply(QueryBuilder $queryBuilder, DynamicSegmentFilterEntity $filter): QueryBuilder {
$filterData = $filter->getFilterData();
$this->validateFilterData((array)$filterData->getData());
$type = $filterData->getStringParam('attribute_type');
if ($type === self::TYPE_LOCAL) {
$this->applyForLocalAttribute($queryBuilder, $filterData);
} elseif ($type === self::TYPE_TAXONOMY) {
$this->applyForTaxonomyAttribute($queryBuilder, $filterData);
}
return $queryBuilder;
}
private function applyForTaxonomyAnyOperator(QueryBuilder $queryBuilder, DynamicSegmentFilterData $filterData): void {
$attributeTaxonomySlug = $filterData->getStringParam('attribute_taxonomy_slug');
$attributeTermIds = $filterData->getArrayParam('attribute_term_ids');
$termIdsParam = $this->filterHelper->getUniqueParameterName('attribute_term_ids');
$orderStatsAlias = $this->wooFilterHelper->applyOrderStatusFilter($queryBuilder);
$productAlias = $this->applyProductJoin($queryBuilder, $orderStatsAlias);
$attributeAlias = $this->applyTaxonomyAttributeJoin($queryBuilder, $productAlias, $attributeTaxonomySlug);
$queryBuilder->andWhere("$attributeAlias.term_id IN (:$termIdsParam)");
$queryBuilder->setParameter($termIdsParam, $attributeTermIds, ArrayParameterType::STRING);
}
private function applyProductJoin(QueryBuilder $queryBuilder, string $orderStatsAlias, string $alias = 'product'): string {
$queryBuilder->innerJoin(
$orderStatsAlias,
$this->filterHelper->getPrefixedTable('wc_order_product_lookup'),
$alias,
"$orderStatsAlias.order_id = product.order_id"
);
return $alias;
}
private function applyTaxonomyAttributeJoin(QueryBuilder $queryBuilder, string $productAlias, $taxonomySlug, string $alias = 'attribute'): string {
$queryBuilder->innerJoin(
$productAlias,
$this->filterHelper->getPrefixedTable('wc_product_attributes_lookup'),
$alias,
"product.product_id = attribute.product_id AND attribute.taxonomy = '$taxonomySlug'"
);
return $alias;
}
public function getLookupData(DynamicSegmentFilterData $filterData): array {
$type = $filterData->getStringParam('attribute_type');
if ($type !== self::TYPE_TAXONOMY) {
return [];
}
$slug = $filterData->getStringParam('attribute_taxonomy_slug');
$lookupData = [
'attribute' => $slug,
];
$termIds = $filterData->getArrayParam('attribute_term_ids');
$terms = $this->wp->getTerms([
'taxonomy' => $slug,
'include' => $termIds,
'hide_empty' => false,
]);
$lookupData['terms'] = array_map(function($term) {
return $term->name;
}, $terms);
return $lookupData;
}
public function validateFilterData(array $data): void {
$operator = $data['operator'] ?? null;
if (
!in_array($operator, [
DynamicSegmentFilterData::OPERATOR_ANY,
DynamicSegmentFilterData::OPERATOR_ALL,
DynamicSegmentFilterData::OPERATOR_NONE,
])
) {
throw new InvalidFilterException('Missing operator', InvalidFilterException::MISSING_OPERATOR);
}
$this->validateAttributeData($data);
}
public function validateAttributeData(array $data): void {
$type = $data['attribute_type'];
if (!in_array($type, [self::TYPE_LOCAL, self::TYPE_TAXONOMY], true)) {
throw new InvalidFilterException('Invalid attribute type', InvalidFilterException::INVALID_TYPE);
}
if ($type === self::TYPE_LOCAL) {
$name = $data['attribute_local_name'] ?? null;
if (!is_string($name) || strlen($name) === 0) {
throw new InvalidFilterException('Missing attribute', InvalidFilterException::MISSING_VALUE);
}
$values = $data['attribute_local_values'] ?? [];
if (!is_array($values) || count($values) === 0) {
throw new InvalidFilterException('Missing attribute values', InvalidFilterException::MISSING_VALUE);
}
}
if ($type === self::TYPE_TAXONOMY) {
$attribute_taxonomy_slug = $data['attribute_taxonomy_slug'] ?? null;
if (!is_string($attribute_taxonomy_slug) || strlen($attribute_taxonomy_slug) === 0) {
throw new InvalidFilterException('Missing attribute', InvalidFilterException::MISSING_VALUE);
}
if (!isset($data['attribute_term_ids']) || !is_array($data['attribute_term_ids']) || count($data['attribute_term_ids']) === 0) {
throw new InvalidFilterException('Missing attribute terms', InvalidFilterException::MISSING_VALUE);
}
}
}
private function applyForTaxonomyAttribute(QueryBuilder $queryBuilder, DynamicSegmentFilterData $filterData) {
$operator = $filterData->getOperator();
if ($operator === DynamicSegmentFilterData::OPERATOR_ANY) {
$this->applyForTaxonomyAnyOperator($queryBuilder, $filterData);
} elseif ($operator === DynamicSegmentFilterData::OPERATOR_ALL) {
$this->applyForTaxonomyAnyOperator($queryBuilder, $filterData);
$countParam = $this->filterHelper->getUniqueParameterName('count');
$queryBuilder
->groupBy('inner_subscriber_id')
->having("COUNT(DISTINCT attribute.term_id) = :$countParam")
->setParameter($countParam, count($filterData->getArrayParam('attribute_term_ids')));
} elseif ($operator === DynamicSegmentFilterData::OPERATOR_NONE) {
$subQuery = $this->filterHelper->getNewSubscribersQueryBuilder();
$this->applyForTaxonomyAnyOperator($subQuery, $filterData);
$subscribersTable = $this->filterHelper->getSubscribersTable();
$queryBuilder->where("{$subscribersTable}.id NOT IN ({$this->filterHelper->getInterpolatedSQL($subQuery)})");
}
}
private function applyForLocalAttribute(QueryBuilder $queryBuilder, DynamicSegmentFilterData $filterData): void {
$operator = $filterData->getOperator();
if ($operator === DynamicSegmentFilterData::OPERATOR_ANY) {
$this->applyForLocalAnyAttribute($queryBuilder, $filterData);
} elseif ($operator === DynamicSegmentFilterData::OPERATOR_ALL) {
$this->applyForLocalAnyAttribute($queryBuilder, $filterData);
$countParam = $this->filterHelper->getUniqueParameterName('count');
$queryBuilder
->groupBy('inner_subscriber_id')
->having("COUNT(DISTINCT postmeta.meta_value) = :$countParam")
->setParameter($countParam, count($filterData->getArrayParam('attribute_local_values')));
} elseif ($operator === DynamicSegmentFilterData::OPERATOR_NONE) {
$subQuery = $this->filterHelper->getNewSubscribersQueryBuilder();
$this->applyForLocalAnyAttribute($subQuery, $filterData);
$subscribersTable = $this->filterHelper->getSubscribersTable();
$queryBuilder->where("{$subscribersTable}.id NOT IN ({$this->filterHelper->getInterpolatedSQL($subQuery)})");
}
}
private function applyForLocalAnyAttribute(QueryBuilder $queryBuilder, DynamicSegmentFilterData $filterData): void {
$attributeName = $filterData->getStringParam('attribute_local_name');
$attributeValues = $filterData->getArrayParam('attribute_local_values');
$valuesParam = $this->filterHelper->getUniqueParameterName('attribute_values');
$keyParam = $this->filterHelper->getUniqueParameterName('attribute_name');
$orderStatsAlias = $this->wooFilterHelper->applyOrderStatusFilter($queryBuilder);
$productAlias = $this->applyProductJoin($queryBuilder, $orderStatsAlias);
$queryBuilder->innerJoin(
$productAlias,
$this->filterHelper->getPrefixedTable('postmeta'),
'postmeta',
"$productAlias.product_id = postmeta.post_id AND postmeta.meta_key = :$keyParam AND postmeta.meta_value IN (:$valuesParam)"
);
$queryBuilder->setParameter($keyParam, sprintf("attribute_%s", $attributeName));
$queryBuilder->setParameter($valuesParam, $attributeValues, ArrayParameterType::STRING);
}
}
@@ -0,0 +1,69 @@
<?php declare(strict_types = 1);
namespace MailPoet\Segments\DynamicSegments\Filters;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\DynamicSegmentFilterData;
use MailPoet\Entities\DynamicSegmentFilterEntity;
use MailPoet\Util\Security;
use MailPoetVendor\Carbon\Carbon;
use MailPoetVendor\Doctrine\DBAL\Query\QueryBuilder;
class WooCommerceSingleOrderValue implements Filter {
const ACTION_SINGLE_ORDER_VALUE = 'singleOrderValue';
/** @var WooFilterHelper */
private $wooFilterHelper;
public function __construct(
WooFilterHelper $wooFilterHelper
) {
$this->wooFilterHelper = $wooFilterHelper;
}
public function apply(QueryBuilder $queryBuilder, DynamicSegmentFilterEntity $filter): QueryBuilder {
$filterData = $filter->getFilterData();
$type = $filterData->getParam('single_order_value_type');
$amount = $filterData->getParam('single_order_value_amount');
$isAllTime = $filterData->getParam('timeframe') === DynamicSegmentFilterData::TIMEFRAME_ALL_TIME;
$parameterSuffix = $filter->getId() ?? Security::generateRandomString();
$orderStatsAlias = $this->wooFilterHelper->applyOrderStatusFilter($queryBuilder);
if (!$isAllTime) {
$days = $filterData->getParam('days');
if (!is_string($days)) {
$days = '1'; // Default to last day
}
$date = Carbon::now()->subDays((int)$days);
$dateParam = "date_$parameterSuffix";
$queryBuilder
->andWhere("$orderStatsAlias.date_created >= :$dateParam")
->setParameter($dateParam, $date->toDateTimeString());
}
if ($type === '=') {
$queryBuilder->andWhere("$orderStatsAlias.total_sales = :amount" . $parameterSuffix);
} elseif ($type === '!=') {
$queryBuilder->andWhere("$orderStatsAlias.total_sales != :amount" . $parameterSuffix);
} elseif ($type === '>') {
$queryBuilder->andWhere("$orderStatsAlias.total_sales > :amount" . $parameterSuffix);
} elseif ($type === '>=') {
$queryBuilder->andWhere("$orderStatsAlias.total_sales >= :amount" . $parameterSuffix);
} elseif ($type === '<') {
$queryBuilder->andWhere("$orderStatsAlias.total_sales < :amount" . $parameterSuffix);
} elseif ($type === '<=') {
$queryBuilder->andWhere("$orderStatsAlias.total_sales <= :amount" . $parameterSuffix);
}
$queryBuilder->setParameter('amount' . $parameterSuffix, $amount);
return $queryBuilder;
}
public function getLookupData(DynamicSegmentFilterData $filterData): array {
return [];
}
}
@@ -0,0 +1,148 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Segments\DynamicSegments\Filters;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\DynamicSegmentFilterData;
use MailPoet\Entities\DynamicSegmentFilterEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Util\DBCollationChecker;
use MailPoet\Util\Security;
use MailPoet\WooCommerce\Helper as WooCommerceHelper;
use MailPoetVendor\Doctrine\DBAL\ArrayParameterType;
use MailPoetVendor\Doctrine\DBAL\Query\QueryBuilder;
use MailPoetVendor\Doctrine\ORM\EntityManager;
class WooCommerceSubscription implements Filter {
const ACTION_HAS_ACTIVE = 'hasActiveSubscription';
/** @var EntityManager */
private $entityManager;
/** @var WooCommerceHelper */
private $woocommerceHelper;
/** @var DBCollationChecker */
private $collationChecker;
public function __construct(
EntityManager $entityManager,
DBCollationChecker $collationChecker,
WooCommerceHelper $woocommerceHelper
) {
$this->entityManager = $entityManager;
$this->collationChecker = $collationChecker;
$this->woocommerceHelper = $woocommerceHelper;
}
public function apply(QueryBuilder $queryBuilder, DynamicSegmentFilterEntity $filter): QueryBuilder {
$filterData = $filter->getFilterData();
$productIds = $filterData->getParam('product_ids');
$operator = $filterData->getParam('operator');
$parameterSuffix = $filter->getId() ?: Security::generateRandomString();
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
// ALL OF
if ($operator === DynamicSegmentFilterData::OPERATOR_ALL) {
$this->applyPostmetaAndPostJoin($queryBuilder);
$this->applyOrderItemsJoin($queryBuilder);
$this->applyOrderItemmetaJoin($queryBuilder);
return $queryBuilder
->andWhere("itemmeta.meta_value IN (:products" . $parameterSuffix . ")")
->groupBy("$subscribersTable.id")
->having("COUNT($subscribersTable.id) = :count$parameterSuffix")
->setParameter('products' . $parameterSuffix, $productIds, ArrayParameterType::STRING)
->setParameter('count' . $parameterSuffix, count($productIds));
}
// NONE OF
if ($operator === DynamicSegmentFilterData::OPERATOR_NONE) {
$subQueryBuilder = $this->entityManager->getConnection()
->createQueryBuilder()
->from($subscribersTable)
->select("DISTINCT $subscribersTable.id");
$this->applyPostmetaAndPostJoin($subQueryBuilder);
$this->applyOrderItemsJoin($subQueryBuilder);
$this->applyOrderItemmetaJoin($subQueryBuilder);
$subQueryBuilder
->andWhere("itemmeta.meta_value IN (:products" . $parameterSuffix . ")");
return $queryBuilder->where("{$subscribersTable}.id NOT IN ({$subQueryBuilder->getSQL()})")
->setParameter('products' . $parameterSuffix, $productIds, ArrayParameterType::STRING);
}
// ANY
$this->applyPostmetaAndPostJoin($queryBuilder);
$this->applyOrderItemsJoin($queryBuilder);
$this->applyOrderItemmetaJoin($queryBuilder);
return $queryBuilder
->andWhere("itemmeta.meta_value IN (:products" . $parameterSuffix . ")")
->setParameter('products' . $parameterSuffix, $productIds, ArrayParameterType::STRING);
}
private function applyPostmetaAndPostJoin(QueryBuilder $queryBuilder): QueryBuilder {
global $wpdb;
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
if ($this->woocommerceHelper->isWooCommerceCustomOrdersTableEnabled()) {
$collation = $this->collationChecker->getCollateIfNeeded(
$subscribersTable,
'email',
$wpdb->prefix . 'wc_orders',
'billing_email'
);
return $queryBuilder->innerJoin(
$subscribersTable,
$wpdb->prefix . 'wc_orders',
'wc_orders',
"{$subscribersTable}.email = wc_orders.billing_email $collation AND wc_orders.status IN('wc-active', 'wc-pending-cancel')"
);
}
return $queryBuilder->innerJoin(
$subscribersTable,
$wpdb->postmeta,
'postmeta',
"postmeta.meta_key = '_customer_user' AND $subscribersTable.wp_user_id=postmeta.meta_value"
)->innerJoin(
'postmeta',
$wpdb->posts,
'posts',
"postmeta.post_id = posts.id AND posts.post_type = 'shop_subscription' AND posts.post_status IN('wc-active', 'wc-pending-cancel')"
);
}
private function applyOrderItemsJoin(QueryBuilder $queryBuilder): QueryBuilder {
global $wpdb;
if ($this->woocommerceHelper->isWooCommerceCustomOrdersTableEnabled()) {
return $queryBuilder->innerJoin(
'wc_orders',
$wpdb->prefix . 'woocommerce_order_items',
'items',
"wc_orders.id = items.order_id AND order_item_type = 'line_item'"
);
}
return $queryBuilder->innerJoin(
'postmeta',
$wpdb->prefix . 'woocommerce_order_items',
'items',
"postmeta.post_id = items.order_id AND order_item_type = 'line_item'"
);
}
private function applyOrderItemmetaJoin(QueryBuilder $queryBuilder): QueryBuilder {
global $wpdb;
return $queryBuilder->innerJoin(
'items',
$wpdb->prefix . 'woocommerce_order_itemmeta',
'itemmeta',
"itemmeta.order_item_id=items.order_item_id AND itemmeta.meta_key='_product_id'"
);
}
public function getLookupData(DynamicSegmentFilterData $filterData): array {
return [];
}
}
@@ -0,0 +1,119 @@
<?php declare(strict_types = 1);
namespace MailPoet\Segments\DynamicSegments\Filters;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\DynamicSegmentFilterData;
use MailPoet\Entities\DynamicSegmentFilterEntity;
use MailPoet\Segments\DynamicSegments\Exceptions\InvalidFilterException;
use MailPoet\WP\Functions as WPFunctions;
use MailPoetVendor\Doctrine\DBAL\ArrayParameterType;
use MailPoetVendor\Doctrine\DBAL\Query\QueryBuilder;
use WP_Term;
class WooCommerceTag implements Filter {
const ACTION = 'purchasedTag';
private WPFunctions $wp;
private WooFilterHelper $wooFilterHelper;
private FilterHelper $filterHelper;
public function __construct(
FilterHelper $filterHelper,
WooFilterHelper $wooFilterHelper,
WPFunctions $wp
) {
$this->wp = $wp;
$this->wooFilterHelper = $wooFilterHelper;
$this->filterHelper = $filterHelper;
}
public function apply(QueryBuilder $queryBuilder, DynamicSegmentFilterEntity $filter): QueryBuilder {
$filterData = $filter->getFilterData();
$this->validateFilterData((array)$filterData->getData());
$operator = $filterData->getOperator();
if ($operator === DynamicSegmentFilterData::OPERATOR_ANY) {
$this->applyForAnyOperator($queryBuilder, $filterData);
} elseif ($operator === DynamicSegmentFilterData::OPERATOR_ALL) {
$this->applyForAnyOperator($queryBuilder, $filterData);
$countParam = $this->filterHelper->getUniqueParameterName('tagCount');
$queryBuilder->groupBy('inner_subscriber_id')
->having("COUNT(DISTINCT term_taxonomy.term_id) = :$countParam")
->setParameter($countParam, count($filterData->getArrayParam('tag_ids')));
} elseif ($operator === DynamicSegmentFilterData::OPERATOR_NONE) {
$subQuery = $this->filterHelper->getNewSubscribersQueryBuilder();
$this->applyForAnyOperator($subQuery, $filterData);
$subscribersTable = $this->filterHelper->getSubscribersTable();
$queryBuilder->andWhere($queryBuilder->expr()->notIn("$subscribersTable.id", $this->filterHelper->getInterpolatedSQL($subQuery)));
}
return $queryBuilder;
}
public function applyForAnyOperator(QueryBuilder $queryBuilder, DynamicSegmentFilterData $filterData): void {
$orderStatsAlias = $this->wooFilterHelper->applyOrderStatusFilter($queryBuilder);
$tagIdsParam = $this->filterHelper->getUniqueParameterName('tagIds');
$productAlias = $this->applyProductJoin($queryBuilder, $orderStatsAlias);
$queryBuilder->join(
$productAlias,
$this->filterHelper->getPrefixedTable('term_relationships'),
'term_relationships',
'product.product_id = term_relationships.object_id'
);
$queryBuilder->innerJoin(
'term_relationships',
$this->filterHelper->getPrefixedTable('term_taxonomy'),
'term_taxonomy',
"term_taxonomy.term_taxonomy_id = term_relationships.term_taxonomy_id
AND
term_taxonomy.term_id IN (:$tagIdsParam)"
);
$queryBuilder->setParameter($tagIdsParam, $filterData->getArrayParam('tag_ids'), ArrayParameterType::STRING);
}
private function applyProductJoin(QueryBuilder $queryBuilder, string $orderStatsAlias, string $productAlias = 'product'): string {
$queryBuilder->innerJoin(
$orderStatsAlias,
$this->filterHelper->getPrefixedTable('wc_order_product_lookup'),
$productAlias,
"$orderStatsAlias.order_id = product.order_id"
);
return $productAlias;
}
public function validateFilterData(array $data): void {
$operator = $data['operator'] ?? null;
if (
!in_array($operator, [
DynamicSegmentFilterData::OPERATOR_ANY,
DynamicSegmentFilterData::OPERATOR_ALL,
DynamicSegmentFilterData::OPERATOR_NONE,
])
) {
throw new InvalidFilterException('Missing operator', InvalidFilterException::MISSING_OPERATOR);
}
if (!is_array($data['tag_ids'] ?? null) || count($data['tag_ids']) === 0) {
throw new InvalidFilterException('Missing tag ids');
}
}
public function getLookupData(DynamicSegmentFilterData $filterData): array {
$lookupData = [
'tags' => [],
];
$tagIds = $filterData->getArrayParam('tag_ids');
$terms = $this->wp->getTerms(['taxonomy' => 'product_tag', 'include' => $tagIds, 'hide_empty' => false]);
/** @var WP_Term[] $terms */
foreach ($terms as $term) {
$lookupData['tags'][$term->term_id] = $term->name; // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
}
return $lookupData;
}
}
@@ -0,0 +1,63 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Segments\DynamicSegments\Filters;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\DynamicSegmentFilterData;
use MailPoet\Entities\DynamicSegmentFilterEntity;
use MailPoet\Util\Security;
use MailPoetVendor\Carbon\Carbon;
use MailPoetVendor\Doctrine\DBAL\Query\QueryBuilder;
class WooCommerceTotalSpent implements Filter {
const ACTION_TOTAL_SPENT = 'totalSpent';
/** @var WooFilterHelper */
private $wooFilterHelper;
public function __construct(
WooFilterHelper $wooFilterHelper
) {
$this->wooFilterHelper = $wooFilterHelper;
}
public function apply(QueryBuilder $queryBuilder, DynamicSegmentFilterEntity $filter): QueryBuilder {
$filterData = $filter->getFilterData();
$type = $filterData->getParam('total_spent_type');
$amount = $filterData->getParam('total_spent_amount');
$isAllTime = $filterData->getParam('timeframe') === DynamicSegmentFilterData::TIMEFRAME_ALL_TIME;
$parameterSuffix = $filter->getId() ?? Security::generateRandomString();
$orderStatsAlias = $this->wooFilterHelper->applyOrderStatusFilter($queryBuilder);
if (!$isAllTime) {
$days = $filterData->getParam('days');
$date = Carbon::now()->subDays($days);
$dateParam = "date_$parameterSuffix";
$queryBuilder->andWhere("$orderStatsAlias.date_created >= :$dateParam")
->setParameter($dateParam, $date->toDateTimeString());
}
$queryBuilder->groupBy('inner_subscriber_id');
if ($type === '=') {
$queryBuilder->having("SUM($orderStatsAlias.total_sales) = :amount" . $parameterSuffix);
} elseif ($type === '!=') {
$queryBuilder->having("SUM($orderStatsAlias.total_sales) != :amount" . $parameterSuffix);
} elseif ($type === '>') {
$queryBuilder->having("SUM($orderStatsAlias.total_sales) > :amount" . $parameterSuffix);
} elseif ($type === '<') {
$queryBuilder->having("SUM($orderStatsAlias.total_sales) < :amount" . $parameterSuffix);
}
$queryBuilder->setParameter('amount' . $parameterSuffix, $amount);
return $queryBuilder;
}
public function getLookupData(DynamicSegmentFilterData $filterData): array {
return [];
}
}
@@ -0,0 +1,135 @@
<?php declare(strict_types = 1);
namespace MailPoet\Segments\DynamicSegments\Filters;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\DynamicSegmentFilterData;
use MailPoet\Entities\DynamicSegmentFilterEntity;
use MailPoet\Segments\DynamicSegments\Exceptions\InvalidFilterException;
use MailPoet\WooCommerce\Helper;
use MailPoetVendor\Carbon\Carbon;
use MailPoetVendor\Doctrine\DBAL\ArrayParameterType;
use MailPoetVendor\Doctrine\DBAL\Query\QueryBuilder;
class WooCommerceUsedCouponCode implements Filter {
const ACTION = 'usedCouponCode';
const COUPON_CODE_IDS_KEY = 'coupon_code_ids';
/** @var WooFilterHelper */
private $wooFilterHelper;
/** @var FilterHelper */
private $filterHelper;
/** @var Helper */
private $wooHelper;
public function __construct(
WooFilterHelper $wooFilterHelper,
Helper $wooHelper,
FilterHelper $filterHelper
) {
$this->wooFilterHelper = $wooFilterHelper;
$this->filterHelper = $filterHelper;
$this->wooHelper = $wooHelper;
}
public function apply(QueryBuilder $queryBuilder, DynamicSegmentFilterEntity $filter): QueryBuilder {
$filterData = $filter->getFilterData();
$this->validateFilterData((array)$filterData->getData());
$operator = $filterData->getParam('operator');
switch ($operator) {
case DynamicSegmentFilterData::OPERATOR_ALL:
$this->applyForAllOperator($queryBuilder, $filter);
break;
case DynamicSegmentFilterData::OPERATOR_ANY:
$this->applyForAnyOperator($queryBuilder, $filter);
break;
case DynamicSegmentFilterData::OPERATOR_NONE:
$subQuery = $this->filterHelper->getNewSubscribersQueryBuilder();
$this->applyForAnyOperator($subQuery, $filter);
$subscribersTable = $this->filterHelper->getSubscribersTable();
$queryBuilder->andWhere($queryBuilder->expr()->notIn("$subscribersTable.id", $this->filterHelper->getInterpolatedSQL($subQuery)));
break;
}
return $queryBuilder;
}
private function applyForAnyOperator(QueryBuilder $queryBuilder, DynamicSegmentFilterEntity $filter): void {
$filterData = $filter->getFilterData();
$couponIds = (array)$filterData->getParam(self::COUPON_CODE_IDS_KEY);
$isAllTime = $filterData->getParam('timeframe') === DynamicSegmentFilterData::TIMEFRAME_ALL_TIME;
$orderStatsAlias = $this->wooFilterHelper->applyOrderStatusFilter($queryBuilder);
if (!$isAllTime) {
/** @var int $days */
$days = $filterData->getParam('days');
$date = Carbon::now()->subDays($days);
$dateParam = $this->filterHelper->getUniqueParameterName('date');
$queryBuilder->andWhere("$orderStatsAlias.date_created >= :$dateParam")
->setParameter($dateParam, $date->toDateTimeString());
}
$queryBuilder->innerJoin(
$orderStatsAlias,
$this->filterHelper->getPrefixedTable('wc_order_coupon_lookup'),
'couponLookup',
"$orderStatsAlias.order_id = couponLookup.order_id"
);
$couponCodeIdsParam = $this->filterHelper->getUniqueParameterName('couponCodeIds');
$queryBuilder
->andWhere("couponLookup.coupon_id IN (:$couponCodeIdsParam)")
->setParameter($couponCodeIdsParam, $couponIds, ArrayParameterType::INTEGER);
}
private function applyForAllOperator(QueryBuilder $queryBuilder, DynamicSegmentFilterEntity $filter): void {
$this->applyForAnyOperator($queryBuilder, $filter);
$filterData = $filter->getFilterData();
$couponIds = (array)$filterData->getParam(self::COUPON_CODE_IDS_KEY);
$queryBuilder->groupBy('inner_subscriber_id')
->having("COUNT(DISTINCT couponLookup.coupon_id) = " . count(array_unique($couponIds)));
}
public function validateFilterData(array $data): void {
$this->filterHelper->validateDaysPeriodData($data);
$couponCodeIds = $data[self::COUPON_CODE_IDS_KEY] ?? [];
if (count($couponCodeIds) === 0) {
throw new InvalidFilterException('Missing coupon code IDs', InvalidFilterException::MISSING_VALUE);
}
$operator = $data['operator'] ?? null;
if (
!in_array($operator, [
DynamicSegmentFilterData::OPERATOR_ANY,
DynamicSegmentFilterData::OPERATOR_ALL,
DynamicSegmentFilterData::OPERATOR_NONE,
])
) {
throw new InvalidFilterException('Missing operator', InvalidFilterException::MISSING_OPERATOR);
}
}
public function getLookupData(DynamicSegmentFilterData $filterData): array {
$lookupData = ['coupons' => []];
if (!$this->wooHelper->isWooCommerceActive()) {
return $lookupData;
}
$couponIds = $filterData->getArrayParam(self::COUPON_CODE_IDS_KEY);
foreach ($couponIds as $couponId) {
$couponCode = $this->wooHelper->wcGetCouponCodeById((int)$couponId);
if (!empty($couponCode)) {
$lookupData['coupons'][$couponId] = $couponCode;
}
}
return $lookupData;
}
}
@@ -0,0 +1,163 @@
<?php declare(strict_types = 1);
namespace MailPoet\Segments\DynamicSegments\Filters;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\DynamicSegmentFilterData;
use MailPoet\Entities\DynamicSegmentFilterEntity;
use MailPoet\Segments\DynamicSegments\Exceptions\InvalidFilterException;
use MailPoet\WooCommerce\Helper;
use MailPoetVendor\Carbon\Carbon;
use MailPoetVendor\Doctrine\DBAL\ArrayParameterType;
use MailPoetVendor\Doctrine\DBAL\Query\QueryBuilder;
use WC_Payment_Gateway;
class WooCommerceUsedPaymentMethod implements Filter {
const ACTION = 'usedPaymentMethod';
const VALID_OPERATORS = [
DynamicSegmentFilterData::OPERATOR_NONE,
DynamicSegmentFilterData::OPERATOR_ANY,
DynamicSegmentFilterData::OPERATOR_ALL,
];
/** @var WooFilterHelper */
private $wooFilterHelper;
/** @var Helper */
private $wooHelper;
/** @var FilterHelper */
private $filterHelper;
public function __construct(
FilterHelper $filterHelper,
WooFilterHelper $wooFilterHelper,
Helper $wooHelper
) {
$this->wooFilterHelper = $wooFilterHelper;
$this->wooHelper = $wooHelper;
$this->filterHelper = $filterHelper;
}
public function apply(QueryBuilder $queryBuilder, DynamicSegmentFilterEntity $filter): QueryBuilder {
$filterData = $filter->getFilterData();
$operator = $filterData->getParam('operator');
$paymentMethods = $filterData->getParam('payment_methods');
$days = $filterData->getParam('days');
$isAllTime = $filterData->getParam('timeframe') === DynamicSegmentFilterData::TIMEFRAME_ALL_TIME;
if (!is_string($operator) || !in_array($operator, self::VALID_OPERATORS, true)) {
throw new InvalidFilterException('Invalid operator', InvalidFilterException::MISSING_OPERATOR);
}
if (!is_array($paymentMethods) || count($paymentMethods) < 1) {
throw new InvalidFilterException('Missing payment methods', InvalidFilterException::MISSING_VALUE);
}
$data = $filterData->getData();
$this->filterHelper->validateDaysPeriodData((array)$data);
$includedStatuses = array_keys($this->wooHelper->getOrderStatuses());
$failedKey = array_search('wc-failed', $includedStatuses, true);
if ($failedKey !== false) {
unset($includedStatuses[$failedKey]);
}
$date = is_int($days) ? Carbon::now()->subDays($days) : Carbon::now();
switch ($operator) {
case DynamicSegmentFilterData::OPERATOR_ANY:
$this->applyForAnyOperator($queryBuilder, $includedStatuses, $paymentMethods, $date, $isAllTime);
break;
case DynamicSegmentFilterData::OPERATOR_ALL:
$this->applyForAllOperator($queryBuilder, $includedStatuses, $paymentMethods, $date, $isAllTime);
break;
case DynamicSegmentFilterData::OPERATOR_NONE:
$subQuery = $this->filterHelper->getNewSubscribersQueryBuilder();
$this->applyForAnyOperator($subQuery, $includedStatuses, $paymentMethods, $date, $isAllTime);
$subscribersTable = $this->filterHelper->getSubscribersTable();
$queryBuilder->andWhere($queryBuilder->expr()->notIn("$subscribersTable.id", $this->filterHelper->getInterpolatedSQL($subQuery)));
break;
}
return $queryBuilder;
}
private function applyForAnyOperator(QueryBuilder $queryBuilder, array $includedStatuses, array $paymentMethods, Carbon $date, bool $isAllTime): void {
if ($this->wooHelper->isWooCommerceCustomOrdersTableEnabled()) {
$this->applyCustomOrderTableJoin($queryBuilder, $includedStatuses, $paymentMethods, $date, $isAllTime);
} else {
$this->applyPostmetaOrderJoin($queryBuilder, $includedStatuses, $paymentMethods, $date, $isAllTime);
}
}
private function applyForAllOperator(QueryBuilder $queryBuilder, array $includedStatuses, array $paymentMethods, Carbon $date, bool $isAllTime): void {
if ($this->wooHelper->isWooCommerceCustomOrdersTableEnabled()) {
$ordersAlias = $this->applyCustomOrderTableJoin($queryBuilder, $includedStatuses, $paymentMethods, $date, $isAllTime);
$queryBuilder->groupBy('inner_subscriber_id')
->having("COUNT(DISTINCT $ordersAlias.payment_method) = " . count($paymentMethods));
} else {
$postmetaAlias = $this->applyPostmetaOrderJoin($queryBuilder, $includedStatuses, $paymentMethods, $date, $isAllTime);
$queryBuilder->groupBy('inner_subscriber_id')->having("COUNT(DISTINCT $postmetaAlias.meta_value) = " . count($paymentMethods));
}
}
private function applyPostmetaOrderJoin(QueryBuilder $queryBuilder, array $includedStatuses, array $paymentMethods, Carbon $date, bool $isAllTime, string $postmetaAlias = 'postmeta'): string {
$paymentMethodParam = $this->filterHelper->getUniqueParameterName('paymentMethod');
$paymentMethodMetaKeyParam = $this->filterHelper->getUniqueParameterName('paymentMethod');
$postMetaTable = $this->filterHelper->getPrefixedTable('postmeta');
$orderStatsAlias = $this->wooFilterHelper->applyOrderStatusFilter($queryBuilder, $includedStatuses);
$queryBuilder
->innerJoin($orderStatsAlias, $postMetaTable, $postmetaAlias, "$orderStatsAlias.order_id = $postmetaAlias.post_id")
->andWhere("postmeta.meta_key = :$paymentMethodMetaKeyParam")
->andWhere("postmeta.meta_value IN (:$paymentMethodParam)")
->setParameter($paymentMethodMetaKeyParam, '_payment_method')
->setParameter($paymentMethodParam, $paymentMethods, ArrayParameterType::STRING);
if (!$isAllTime) {
$dateParam = $this->filterHelper->getUniqueParameterName('date');
$queryBuilder
->andWhere("$orderStatsAlias.date_created >= :$dateParam")
->setParameter($dateParam, $date->toDateTimeString());
}
return $postmetaAlias;
}
private function applyCustomOrderTableJoin(QueryBuilder $queryBuilder, array $includedStatuses, array $paymentMethods, Carbon $date, bool $isAllTime, string $ordersAlias = 'orders'): string {
$paymentMethodParam = $this->filterHelper->getUniqueParameterName('paymentMethod');
$ordersTable = $this->wooHelper->getOrdersTableName();
$orderStatsAlias = $this->wooFilterHelper->applyOrderStatusFilter($queryBuilder, $includedStatuses);
$queryBuilder
->innerJoin($orderStatsAlias, $ordersTable, 'orders', "$orderStatsAlias.order_id = orders.id")
->andWhere("$ordersAlias.payment_method IN (:$paymentMethodParam)")
->setParameter($paymentMethodParam, $paymentMethods, ArrayParameterType::STRING);
if (!$isAllTime) {
$dateParam = $this->filterHelper->getUniqueParameterName('date');
$queryBuilder
->andWhere("$orderStatsAlias.date_created >= :$dateParam")
->setParameter($dateParam, $date->toDateTimeString());
}
return $ordersAlias;
}
public function getLookupData(DynamicSegmentFilterData $filterData): array {
$lookupData = [
'paymentMethods' => [],
];
if (!$this->wooHelper->isWooCommerceActive()) {
return $lookupData;
}
$paymentMethods = $filterData->getArrayParam('payment_methods');
$allGateways = $this->wooHelper->getPaymentGateways()->payment_gateways();
foreach ($paymentMethods as $paymentMethod) {
if (isset($allGateways[$paymentMethod]) && $allGateways[$paymentMethod] instanceof WC_Payment_Gateway) {
$lookupData['paymentMethods'][$paymentMethod] = $allGateways[$paymentMethod]->get_method_title();
}
}
return $lookupData;
}
}
@@ -0,0 +1,161 @@
<?php declare(strict_types = 1);
namespace MailPoet\Segments\DynamicSegments\Filters;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\DynamicSegmentFilterData;
use MailPoet\Entities\DynamicSegmentFilterEntity;
use MailPoet\Segments\DynamicSegments\Exceptions\InvalidFilterException;
use MailPoet\WooCommerce\Helper;
use MailPoetVendor\Carbon\Carbon;
use MailPoetVendor\Doctrine\DBAL\ArrayParameterType;
use MailPoetVendor\Doctrine\DBAL\Query\QueryBuilder;
class WooCommerceUsedShippingMethod implements Filter {
const ACTION = 'usedShippingMethod';
const VALID_OPERATORS = [
DynamicSegmentFilterData::OPERATOR_NONE,
DynamicSegmentFilterData::OPERATOR_ANY,
DynamicSegmentFilterData::OPERATOR_ALL,
];
/** @var WooFilterHelper */
private $wooFilterHelper;
/** @var Helper */
private $wooHelper;
/** @var FilterHelper */
private $filterHelper;
public function __construct(
FilterHelper $filterHelper,
WooFilterHelper $wooFilterHelper,
Helper $wooHelper
) {
$this->wooFilterHelper = $wooFilterHelper;
$this->wooHelper = $wooHelper;
$this->filterHelper = $filterHelper;
}
public function apply(QueryBuilder $queryBuilder, DynamicSegmentFilterEntity $filter): QueryBuilder {
$filterData = $filter->getFilterData();
$operator = $filterData->getParam('operator');
$shippingMethodInstanceIds = $filterData->getParam('shipping_methods');
$isAllTime = $filterData->getParam('timeframe') === DynamicSegmentFilterData::TIMEFRAME_ALL_TIME;
$days = $filterData->getParam('days');
if (!is_string($operator) || !in_array($operator, self::VALID_OPERATORS, true)) {
throw new InvalidFilterException('Invalid operator', InvalidFilterException::MISSING_OPERATOR);
}
if (!is_array($shippingMethodInstanceIds) || empty($shippingMethodInstanceIds)) {
throw new InvalidFilterException('Missing shipping methods', InvalidFilterException::MISSING_VALUE);
}
$data = $filterData->getData();
$this->filterHelper->validateDaysPeriodData((array)$data);
$includedStatuses = array_keys($this->wooHelper->getOrderStatuses());
$failedKey = array_search('wc-failed', $includedStatuses, true);
if ($failedKey !== false) {
unset($includedStatuses[$failedKey]);
}
$date = is_int($days) ? Carbon::now()->subDays($days) : Carbon::now();
switch ($operator) {
case DynamicSegmentFilterData::OPERATOR_ANY:
$this->applyForAnyOperator($queryBuilder, $includedStatuses, $shippingMethodInstanceIds, $date, $isAllTime);
break;
case DynamicSegmentFilterData::OPERATOR_ALL:
$this->applyForAllOperator($queryBuilder, $includedStatuses, $shippingMethodInstanceIds, $date, $isAllTime);
break;
case DynamicSegmentFilterData::OPERATOR_NONE:
$this->applyForNoneOperator($queryBuilder, $includedStatuses, $shippingMethodInstanceIds, $date, $isAllTime);
break;
}
return $queryBuilder;
}
public function getLookupData(DynamicSegmentFilterData $filterData): array {
$lookupData = ['shippingMethods' => []];
if (!$this->wooHelper->isWooCommerceActive()) {
return $lookupData;
}
$allMethods = $this->wooHelper->getShippingMethodInstancesData();
$configuredShippingMethodInstanceIds = $filterData->getArrayParam('shipping_methods');
foreach ($configuredShippingMethodInstanceIds as $instanceId) {
if (isset($allMethods[$instanceId])) {
$data = $allMethods[$instanceId];
$lookupData['shippingMethods'][$instanceId] = $data['name'];
}
}
return $lookupData;
}
private function applyForAnyOperator(QueryBuilder $queryBuilder, array $includedStatuses, array $shippingMethodInstanceIds, Carbon $date, bool $isAllTime): void {
$instanceIdsParam = $this->filterHelper->getUniqueParameterName('instanceIds');
$orderItemsTable = $this->filterHelper->getPrefixedTable('woocommerce_order_items');
$orderItemsTableAlias = 'orderItems';
$orderItemMetaTable = $this->filterHelper->getPrefixedTable('woocommerce_order_itemmeta');
$orderItemMetaTableAlias = 'orderItemMeta';
$orderStatsAlias = $this->wooFilterHelper->applyOrderStatusFilter($queryBuilder, $includedStatuses);
$queryBuilder
->innerJoin($orderStatsAlias, $orderItemsTable, $orderItemsTableAlias, "$orderStatsAlias.order_id = $orderItemsTableAlias.order_id")
->innerJoin($orderItemsTableAlias, $orderItemMetaTable, $orderItemMetaTableAlias, "$orderItemsTableAlias.order_item_id = $orderItemMetaTableAlias.order_item_id")
->andWhere("$orderItemsTableAlias.order_item_type = 'shipping'")
->andWhere("$orderItemMetaTableAlias.meta_key = 'instance_id'")
->andWhere("$orderItemMetaTableAlias.meta_value IN (:$instanceIdsParam)")
->setParameter($instanceIdsParam, $shippingMethodInstanceIds, ArrayParameterType::STRING);
if (!$isAllTime) {
$dateParam = $this->filterHelper->getUniqueParameterName('date');
$queryBuilder
->andWhere("$orderStatsAlias.date_created >= :$dateParam")
->setParameter($dateParam, $date->toDateTimeString());
}
}
private function applyForAllOperator(QueryBuilder $queryBuilder, array $includedStatuses, array $shippingMethodInstanceIds, Carbon $date, bool $isAllTime): void {
$orderItemTypeParam = $this->filterHelper->getUniqueParameterName('orderItemType');
$instanceIdsParam = $this->filterHelper->getUniqueParameterName('instanceIds');
$orderItemsTable = $this->filterHelper->getPrefixedTable('woocommerce_order_items');
$orderItemsTableAlias = 'orderItems';
$orderItemMetaTable = $this->filterHelper->getPrefixedTable('woocommerce_order_itemmeta');
$orderItemMetaTableAlias = 'orderItemMeta';
$orderStatsAlias = $this->wooFilterHelper->applyOrderStatusFilter($queryBuilder, $includedStatuses);
$queryBuilder
->innerJoin($orderStatsAlias, $orderItemsTable, $orderItemsTableAlias, "$orderStatsAlias.order_id = $orderItemsTableAlias.order_id")
->innerJoin($orderItemsTableAlias, $orderItemMetaTable, $orderItemMetaTableAlias, "$orderItemsTableAlias.order_item_id = $orderItemMetaTableAlias.order_item_id")
->andWhere("$orderItemsTableAlias.order_item_type = :$orderItemTypeParam")
->andWhere("$orderItemMetaTableAlias.meta_key = 'instance_id'")
->andWhere("$orderItemMetaTableAlias.meta_value IN (:$instanceIdsParam)")
->setParameter($orderItemTypeParam, 'shipping')
->setParameter($instanceIdsParam, $shippingMethodInstanceIds, ArrayParameterType::STRING)
->groupBy('inner_subscriber_id')
->having("COUNT(DISTINCT($orderItemMetaTableAlias.meta_value)) = " . count($shippingMethodInstanceIds));
if (!$isAllTime) {
$dateParam = $this->filterHelper->getUniqueParameterName('date');
$queryBuilder
->andWhere("$orderStatsAlias.date_created >= :$dateParam")
->setParameter($dateParam, $date->toDateTimeString());
}
}
private function applyForNoneOperator(QueryBuilder $queryBuilder, array $includedStatuses, array $shippingMethodInstanceIds, Carbon $date, bool $isAllTime): void {
$subQuery = $this->filterHelper->getNewSubscribersQueryBuilder();
$this->applyForAnyOperator($subQuery, $includedStatuses, $shippingMethodInstanceIds, $date, $isAllTime);
$subscribersTable = $this->filterHelper->getSubscribersTable();
$queryBuilder->andWhere($queryBuilder->expr()->notIn("$subscribersTable.id", $this->filterHelper->getInterpolatedSQL($subQuery)));
}
}
@@ -0,0 +1,98 @@
<?php declare(strict_types = 1);
namespace MailPoet\Segments\DynamicSegments\Filters;
if (!defined('ABSPATH')) exit;
use MailPoet\Util\DBCollationChecker;
use MailPoetVendor\Doctrine\DBAL\ArrayParameterType;
use MailPoetVendor\Doctrine\DBAL\Query\QueryBuilder;
class WooFilterHelper {
/** @var DBCollationChecker */
private $collationChecker;
/** @var FilterHelper */
private $filterHelper;
public function __construct(
DBCollationChecker $collationChecker,
FilterHelper $filterHelper
) {
$this->collationChecker = $collationChecker;
$this->filterHelper = $filterHelper;
}
public function defaultIncludedStatuses(): array {
return ['wc-processing', 'wc-completed'];
}
/**
* @param QueryBuilder $queryBuilder
* @param string $customerAlias
* @return string - The alias of the joined customer lookup table
*/
public function applyCustomerLookupJoin(QueryBuilder $queryBuilder, string $customerAlias = 'customer'): string {
$subscribersTable = $this->filterHelper->getSubscribersTable();
$collation = $this->collationChecker->getCollateIfNeeded(
$subscribersTable,
'email',
$this->customerLookupTable(),
'email'
);
$queryBuilder->innerJoin(
$subscribersTable,
$this->customerLookupTable(),
$customerAlias,
"$subscribersTable.email = $customerAlias.email $collation"
);
return $customerAlias;
}
/**
* @param QueryBuilder $queryBuilder
* @param string $orderStatsAlias
* @return string - The alias of the joined order stats table
*/
public function applyCustomerOrderJoin(QueryBuilder $queryBuilder, string $orderStatsAlias = 'orderStats'): string {
$customerAlias = $this->applyCustomerLookupJoin($queryBuilder);
$queryBuilder->innerJoin(
$customerAlias,
$this->orderStatsTable(),
$orderStatsAlias,
"$customerAlias.customer_id = $orderStatsAlias.customer_id"
);
return $orderStatsAlias;
}
/**
* @param QueryBuilder $queryBuilder
* @param array|null $allowedStatuses
* @return string - The alias of the joined order stats table
*/
public function applyOrderStatusFilter(QueryBuilder $queryBuilder, array $allowedStatuses = null): string {
if (is_null($allowedStatuses)) {
$allowedStatuses = $this->defaultIncludedStatuses();
}
$statusParam = $this->filterHelper->getUniqueParameterName('status');
$orderStatsAlias = $this->applyCustomerOrderJoin($queryBuilder);
$queryBuilder->andWhere("$orderStatsAlias.status IN (:$statusParam)");
$queryBuilder->setParameter($statusParam, $allowedStatuses, ArrayParameterType::STRING);
return $orderStatsAlias;
}
private function customerLookupTable(): string {
return $this->filterHelper->getPrefixedTable('wc_customer_lookup');
}
private function orderStatsTable(): string {
return $this->filterHelper->getPrefixedTable('wc_order_stats');
}
}
@@ -0,0 +1,73 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Segments\DynamicSegments;
if (!defined('ABSPATH')) exit;
use MailPoet\ConflictException;
use MailPoet\Entities\SegmentEntity;
use MailPoet\NotFoundException;
use MailPoet\Segments\SegmentsRepository;
use MailPoetVendor\Doctrine\ORM\EntityManager;
use MailPoetVendor\Doctrine\ORM\ORMException;
class SegmentSaveController {
/** @var SegmentsRepository */
private $segmentsRepository;
/** @var FilterDataMapper */
private $filterDataMapper;
/** @var EntityManager */
private $entityManager;
public function __construct(
SegmentsRepository $segmentsRepository,
FilterDataMapper $filterDataMapper,
EntityManager $entityManager
) {
$this->segmentsRepository = $segmentsRepository;
$this->filterDataMapper = $filterDataMapper;
$this->entityManager = $entityManager;
}
/**
* @throws ConflictException
* @throws NotFoundException
* @throws Exceptions\InvalidFilterException
* @throws ORMException
*/
public function save(array $data = []): SegmentEntity {
$id = isset($data['id']) ? (int)$data['id'] : null;
$name = $data['name'] ?? '';
if (!$this->segmentsRepository->isNameUnique($name, null) && isset($data['force_creation']) && $data['force_creation'] === 'true') {
$name = $name . ' (' . wp_generate_password(5, false) . ')';
}
$description = $data['description'] ?? '';
$filtersData = $this->filterDataMapper->map($data);
return $this->segmentsRepository->createOrUpdate($name, $description, SegmentEntity::TYPE_DYNAMIC, $filtersData, $id);
}
public function duplicate(SegmentEntity $segmentEntity): SegmentEntity {
$duplicate = clone $segmentEntity;
// translators: %s is the name of the segment
$duplicate->setName(sprintf(__('Copy of %s', 'mailpoet'), $segmentEntity->getName()));
$this->segmentsRepository->verifyNameIsUnique($duplicate->getName(), $duplicate->getId());
$this->entityManager->wrapInTransaction(function(EntityManager $entityManager) use ($duplicate, $segmentEntity) {
foreach ($segmentEntity->getDynamicFilters() as $dynamicFilter) {
$duplicateFilter = clone $dynamicFilter;
$duplicate->addDynamicFilter($duplicateFilter);
$duplicateFilter->setSegment($duplicate);
$entityManager->persist($duplicateFilter);
}
$entityManager->persist($duplicate);
$entityManager->flush();
});
return $duplicate;
}
}
@@ -0,0 +1 @@
<?php
@@ -0,0 +1,154 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Segments;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\DynamicSegmentFilterData;
use MailPoet\Entities\DynamicSegmentFilterEntity;
use MailPoet\Entities\SegmentEntity;
use MailPoet\Segments\DynamicSegments\Filters\SubscriberTag;
use MailPoet\Util\License\Features\Subscribers as SubscribersFeature;
use MailPoet\WP\Functions as WPFunctions;
use MailPoetVendor\Doctrine\Common\Collections\Collection;
class SegmentDependencyValidator {
private const MAILPOET_PREMIUM_PLUGIN = [
'id' => 'mailpoet-premium/mailpoet-premium.php',
'name' => 'MailPoet Premium',
];
private const WOOCOMMERCE_PLUGIN = [
'id' => 'woocommerce/woocommerce.php',
'name' => 'WooCommerce',
];
private const WOOCOMMERCE_MEMBERSHIPS_PLUGIN = [
'id' => 'woocommerce-memberships/woocommerce-memberships.php',
'name' => 'WooCommerce Memberships',
];
private const WOOCOMMERCE_SUBSCRIPTIONS_PLUGIN = [
'id' => 'woocommerce-subscriptions/woocommerce-subscriptions.php',
'name' => 'WooCommerce Subscriptions',
];
private const REQUIRED_PLUGINS_BY_TYPE = [
DynamicSegmentFilterData::TYPE_WOOCOMMERCE => [
self::WOOCOMMERCE_PLUGIN,
],
DynamicSegmentFilterData::TYPE_WOOCOMMERCE_MEMBERSHIP => [
self::WOOCOMMERCE_MEMBERSHIPS_PLUGIN,
self::WOOCOMMERCE_PLUGIN,
],
DynamicSegmentFilterData::TYPE_WOOCOMMERCE_SUBSCRIPTION => [
self::WOOCOMMERCE_SUBSCRIPTIONS_PLUGIN,
self::WOOCOMMERCE_PLUGIN,
],
];
private const REQUIRED_PLUGINS_BY_TYPE_AND_ACTION = [
DynamicSegmentFilterData::TYPE_USER_ROLE => [
SubscriberTag::TYPE => [
self::MAILPOET_PREMIUM_PLUGIN,
],
],
];
/** @var SubscribersFeature */
private $subscribersFeature;
/** @var WPFunctions */
private $wp;
public function __construct(
SubscribersFeature $subscribersFeature,
WPFunctions $wp
) {
$this->subscribersFeature = $subscribersFeature;
$this->wp = $wp;
}
/**
* @return string[]
*/
public function getMissingPluginsBySegment(SegmentEntity $segment): array {
$dynamicFilters = $segment->getDynamicFilters();
$missingPluginNames = $this->getMissingPluginsByAllFilters($dynamicFilters);
foreach ($dynamicFilters as $dynamicFilter) {
$missingPlugins = $this->getMissingPluginsByFilter($dynamicFilter);
if (!$missingPlugins) {
continue;
}
foreach ($missingPlugins as $plugin) {
$missingPluginNames[] = $plugin['name'];
}
}
return array_unique($missingPluginNames);
}
/**
* @param Collection<int, DynamicSegmentFilterEntity> $dynamicFilters
*/
public function getMissingPluginsByAllFilters(Collection $dynamicFilters): array {
$missingPluginNames = [];
if (
count($dynamicFilters) > 1
&& (!$this->wp->isPluginActive(self::MAILPOET_PREMIUM_PLUGIN['id'])
|| !$this->subscribersFeature->hasValidPremiumKey()
|| $this->subscribersFeature->check())
) {
$missingPluginNames[] = self::MAILPOET_PREMIUM_PLUGIN['name'];
}
return $missingPluginNames;
}
public function getMissingPluginsByFilter(DynamicSegmentFilterEntity $dynamicSegmentFilter): array {
$config = $this->getRequiredPluginsConfig(
$dynamicSegmentFilter->getFilterData()->getFilterType() ?? '',
$dynamicSegmentFilter->getFilterData()->getAction()
);
return $this->getMissingPlugins($config);
}
public function canUseDynamicFilterType(string $type): bool {
$config = $this->getRequiredPluginsConfig($type);
return empty($this->getMissingPlugins($config));
}
private function getRequiredPluginsConfig(string $type, ?string $action = null): array {
$requiredPlugins = [];
if (isset(self::REQUIRED_PLUGINS_BY_TYPE[$type])) {
$requiredPlugins = self::REQUIRED_PLUGINS_BY_TYPE[$type];
}
if (isset(self::REQUIRED_PLUGINS_BY_TYPE_AND_ACTION[$type][$action])) {
$requiredPlugins = array_merge($requiredPlugins, self::REQUIRED_PLUGINS_BY_TYPE_AND_ACTION[$type][$action]);
}
return $requiredPlugins;
}
private function getMissingPlugins(array $config): array {
$missingPlugins = [];
foreach ($config as $requiredPlugin) {
if (isset($requiredPlugin['id']) && !$this->wp->isPluginActive($requiredPlugin['id'])) {
$missingPlugins[] = $requiredPlugin;
}
}
return $missingPlugins;
}
public function getCustomErrorMessage($missingPlugin) {
if (
$missingPlugin === self::MAILPOET_PREMIUM_PLUGIN['name']
&& $this->wp->isPluginActive(self::MAILPOET_PREMIUM_PLUGIN['id'])
&& (!$this->subscribersFeature->hasValidPremiumKey() || $this->subscribersFeature->check())
) {
return [
'message' => __('Your current MailPoet plan does not support advanced segments. Please [link]upgrade to a MailPoet Premium plan[/link] to reactivate this segment.', 'mailpoet'),
'link' => 'https://account.mailpoet.com',
];
}
return false;
}
}
@@ -0,0 +1,107 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Segments;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\SegmentEntity;
use MailPoet\Listing\ListingDefinition;
use MailPoet\Listing\ListingRepository;
use MailPoet\Util\Helpers;
use MailPoetVendor\Doctrine\ORM\EntityManager;
use MailPoetVendor\Doctrine\ORM\QueryBuilder;
class SegmentListingRepository extends ListingRepository {
const DEFAULT_SORT_BY = 'name';
/** @var WooCommerce */
private $wooCommerce;
public function __construct(
EntityManager $entityManager,
WooCommerce $wooCommerce
) {
parent::__construct($entityManager);
$this->wooCommerce = $wooCommerce;
}
protected function applySelectClause(QueryBuilder $queryBuilder) {
$queryBuilder->select("PARTIAL s.{id,name,type,description,createdAt,updatedAt,deletedAt,averageEngagementScore}");
}
protected function applyFromClause(QueryBuilder $queryBuilder) {
$queryBuilder->from(SegmentEntity::class, 's');
}
protected function applyGroup(QueryBuilder $queryBuilder, string $group) {
if ($group === 'trash') {
$queryBuilder->andWhere('s.deletedAt IS NOT NULL');
} else {
$queryBuilder->andWhere('s.deletedAt IS NULL');
}
}
protected function applySearch(QueryBuilder $queryBuilder, string $search, array $parameters = []) {
$search = Helpers::escapeSearch($search);
$queryBuilder
->andWhere('s.name LIKE :search or s.description LIKE :search')
->setParameter('search', "%$search%");
}
protected function applyFilters(QueryBuilder $queryBuilder, array $filters) {
}
protected function applyParameters(QueryBuilder $queryBuilder, array $parameters) {
$types = [SegmentEntity::TYPE_DEFAULT, SegmentEntity::TYPE_WP_USERS];
if ($this->wooCommerce->shouldShowWooCommerceSegment()) {
$types[] = SegmentEntity::TYPE_WC_USERS;
}
$queryBuilder
->andWhere('s.type IN (:type)')
->setParameter('type', $types);
}
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);
$this->applyParameters($queryBuilder, $definition->getParameters());
$queryBuilder->select('count(s.id)');
if (!$this->wooCommerce->shouldShowWooCommerceSegment()) {
$queryBuilder
->andWhere('s.type != :wcUsers')
->setParameter('wcUsers', SegmentEntity::TYPE_WC_USERS);
}
$allQueryBuilder = clone $queryBuilder;
$trashedQueryBuilder = clone $queryBuilder;
$allQueryBuilder->andWhere('s.deletedAt IS NULL');
$allCount = (int)$allQueryBuilder->getQuery()->getSingleScalarResult();
$trashedQueryBuilder->andWhere('s.deletedAt IS NOT NULL');
$trashedCount = (int)$trashedQueryBuilder->getQuery()->getSingleScalarResult();
return [
[
'name' => 'all',
'label' => __('All', 'mailpoet'),
'count' => $allCount,
],
[
'name' => 'trash',
'label' => __('Trash', 'mailpoet'),
'count' => $trashedCount,
],
];
}
}
@@ -0,0 +1,73 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Segments;
if (!defined('ABSPATH')) exit;
use MailPoet\ConflictException;
use MailPoet\Entities\SegmentEntity;
use MailPoet\Entities\SubscriberSegmentEntity;
use MailPoet\NotFoundException;
use MailPoetVendor\Doctrine\ORM\EntityManager;
use MailPoetVendor\Doctrine\ORM\ORMException;
class SegmentSaveController {
/** @var SegmentsRepository */
private $segmentsRepository;
/** @var EntityManager */
private $entityManager;
public function __construct(
SegmentsRepository $segmentsRepository,
EntityManager $entityManager
) {
$this->segmentsRepository = $segmentsRepository;
$this->entityManager = $entityManager;
}
/**
* @throws ConflictException
* @throws NotFoundException
* @throws ORMException
*/
public function save(array $data = []): SegmentEntity {
$id = isset($data['id']) ? (int)$data['id'] : null;
$name = $data['name'] ?? '';
$description = $data['description'] ?? '';
$displayInManageSubPage = isset($data['showInManageSubscriptionPage']) ? (int)$data['showInManageSubscriptionPage'] : false;
return $this->segmentsRepository->createOrUpdate($name, $description, SegmentEntity::TYPE_DEFAULT, [], $id, (bool)$displayInManageSubPage);
}
/**
* @throws ConflictException
*/
public function duplicate(SegmentEntity $segmentEntity): SegmentEntity {
$duplicate = clone $segmentEntity;
// translators: %s is the name of the segment
$duplicate->setName(sprintf(__('Copy of %s', 'mailpoet'), $segmentEntity->getName()));
$this->segmentsRepository->verifyNameIsUnique($duplicate->getName(), $duplicate->getId());
$this->entityManager->transactional(function (EntityManager $entityManager) use ($duplicate, $segmentEntity) {
$entityManager->persist($duplicate);
$entityManager->flush();
$subscriberSegmentTable = $entityManager->getClassMetadata(SubscriberSegmentEntity::class)->getTableName();
$conn = $this->entityManager->getConnection();
$stmt = $conn->prepare("
INSERT INTO $subscriberSegmentTable (segment_id, subscriber_id, status, created_at)
SELECT :duplicateId, subscriber_id, status, NOW()
FROM $subscriberSegmentTable
WHERE segment_id = :segmentId
");
$stmt->bindValue('duplicateId', $duplicate->getId());
$stmt->bindValue('segmentId', $segmentEntity->getId());
$stmt->executeQuery();
});
return $duplicate;
}
}
@@ -0,0 +1,496 @@
<?php declare(strict_types = 1);
namespace MailPoet\Segments;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\DynamicSegmentFilterData;
use MailPoet\Entities\DynamicSegmentFilterEntity;
use MailPoet\Entities\SegmentEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Entities\SubscriberSegmentEntity;
use MailPoet\InvalidStateException;
use MailPoet\NotFoundException;
use MailPoet\Segments\DynamicSegments\Exceptions\InvalidFilterException;
use MailPoet\Segments\DynamicSegments\FilterHandler;
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\Query\Expr\Join;
use MailPoetVendor\Doctrine\ORM\QueryBuilder as ORMQueryBuilder;
class SegmentSubscribersRepository {
/** @var EntityManager */
private $entityManager;
/** @var FilterHandler */
private $filterHandler;
/** @var SegmentsRepository */
private $segmentsRepository;
public function __construct(
EntityManager $entityManager,
FilterHandler $filterHandler,
SegmentsRepository $segmentsRepository
) {
$this->entityManager = $entityManager;
$this->filterHandler = $filterHandler;
$this->segmentsRepository = $segmentsRepository;
}
public function findSubscribersIdsInSegment(int $segmentId, array $candidateIds = null): array {
return $this->loadSubscriberIdsInSegment($segmentId, $candidateIds);
}
public function getSubscriberIdsInSegment(int $segmentId): array {
return $this->loadSubscriberIdsInSegment($segmentId);
}
public function getSubscribersCount(int $segmentId, string $status = null): int {
$segment = $this->getSegment($segmentId);
$result = $this->getSubscribersStatisticsCount($segment);
return (int)$result[$status ?: 'all'];
}
public function getSubscribersCountBySegmentIds(array $segmentIds, string $status = null, ?int $filterSegmentId = null): int {
$segmentRepository = $this->entityManager->getRepository(SegmentEntity::class);
$segments = $segmentRepository->findBy(['id' => $segmentIds]);
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
$queryBuilder = $this->createCountQueryBuilder();
$subQueries = [];
foreach ($segments as $segment) {
$segmentQb = $this->createCountQueryBuilder();
$segmentQb->select("{$subscribersTable}.id AS inner_id");
if ($segment->isStatic()) {
$segmentQb = $this->filterSubscribersInStaticSegment($segmentQb, $segment, $status);
} else {
$segmentQb = $this->filterSubscribersInDynamicSegment($segmentQb, $segment, $status);
}
// inner parameters and types have to be merged to outer queryBuilder
$queryBuilder->setParameters(array_merge(
$segmentQb->getParameters(),
$queryBuilder->getParameters()
), array_merge(
$segmentQb->getParameterTypes(),
$queryBuilder->getParameterTypes()
));
$subQueries[] = $segmentQb->getSQL();
}
$queryBuilder->innerJoin(
$subscribersTable,
sprintf('(%s)', join(' UNION ', $subQueries)),
'inner_subscribers',
"inner_subscribers.inner_id = {$subscribersTable}.id"
);
try {
if (is_int($filterSegmentId)) {
$filterSegment = $this->segmentsRepository->verifyDynamicSegmentExists($filterSegmentId);
$filterSegmentQb = $this->createCountQueryBuilder();
$filterSegmentQb->select("{$subscribersTable}.id AS filter_segment_subscriber_id");
$filterSegmentQb = $this->filterSubscribersInDynamicSegment($filterSegmentQb, $filterSegment, $status);
$queryBuilder->setParameters(array_merge($filterSegmentQb->getParameters(), $queryBuilder->getParameters()), array_merge($filterSegmentQb->getParameterTypes(), $queryBuilder->getParameterTypes()));
$queryBuilder->innerJoin(
$subscribersTable,
sprintf('(%s)', $filterSegmentQb->getSQL()),
'filter_segment',
"filter_segment.filter_segment_subscriber_id = {$subscribersTable}.id"
);
}
} catch (InvalidStateException $exception) {
return 0;
}
$statement = $this->executeQuery($queryBuilder);
/** @var string $result */
$result = $statement->fetchOne();
return (int)$result;
}
/**
* @param DynamicSegmentFilterData[] $filters
* @return int
* @throws InvalidStateException
*/
public function getDynamicSubscribersCount(array $filters): int {
$segment = new SegmentEntity('temporary segment', SegmentEntity::TYPE_DYNAMIC, '');
foreach ($filters as $filter) {
$segment->addDynamicFilter(new DynamicSegmentFilterEntity($segment, $filter));
}
$queryBuilder = $this->createDynamicStatisticsQueryBuilder();
$queryBuilder = $this->filterSubscribersInDynamicSegment($queryBuilder, $segment, null);
$statement = $this->executeQuery($queryBuilder);
/** @var array{all:string} $result */
$result = $statement->fetch();
return (int)$result['all'];
}
private function createCountQueryBuilder(): QueryBuilder {
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
return $this->entityManager
->getConnection()
->createQueryBuilder()
->select("count(DISTINCT $subscribersTable.id)")
->from($subscribersTable);
}
private function createDynamicStatisticsQueryBuilder(): QueryBuilder {
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
return $this->entityManager
->getConnection()
->createQueryBuilder()
->from($subscribersTable)
->addSelect("IFNULL(SUM(
CASE WHEN $subscribersTable.deleted_at IS NULL
THEN 1 ELSE 0 END
), 0) as `all`")
->addSelect("IFNULL(SUM(
CASE WHEN $subscribersTable.deleted_at IS NOT NULL
THEN 1 ELSE 0 END
), 0) as trash")
->addSelect("IFNULL(SUM(
CASE WHEN $subscribersTable.status = :status_subscribed AND $subscribersTable.deleted_at IS NULL
THEN 1 ELSE 0 END
), 0) as :status_subscribed")
->addSelect("IFNULL(SUM(
CASE WHEN $subscribersTable.status = :status_unsubscribed AND $subscribersTable.deleted_at IS NULL
THEN 1 ELSE 0 END
), 0) as :status_unsubscribed")
->addSelect("IFNULL(SUM(
CASE WHEN $subscribersTable.status = :status_inactive AND $subscribersTable.deleted_at IS NULL
THEN 1 ELSE 0 END
), 0) as :status_inactive")
->addSelect("IFNULL(SUM(
CASE WHEN $subscribersTable.status = :status_unconfirmed AND $subscribersTable.deleted_at IS NULL
THEN 1 ELSE 0 END
), 0) as :status_unconfirmed")
->addSelect("IFNULL(SUM(
CASE WHEN $subscribersTable.status = :status_bounced AND $subscribersTable.deleted_at IS NULL
THEN 1 ELSE 0 END
), 0) as :status_bounced")
->setParameter('status_subscribed', SubscriberEntity::STATUS_SUBSCRIBED)
->setParameter('status_unsubscribed', SubscriberEntity::STATUS_UNSUBSCRIBED)
->setParameter('status_inactive', SubscriberEntity::STATUS_INACTIVE)
->setParameter('status_unconfirmed', SubscriberEntity::STATUS_UNCONFIRMED)
->setParameter('status_bounced', SubscriberEntity::STATUS_BOUNCED);
}
private function createStaticStatisticsQueryBuilder(SegmentEntity $segment): QueryBuilder {
$subscriberSegmentTable = $this->entityManager->getClassMetadata(SubscriberSegmentEntity::class)->getTableName();
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
return $this->entityManager
->getConnection()
->createQueryBuilder()
->from($subscriberSegmentTable, 'subscriber_segment')
->where('subscriber_segment.segment_id = :segment_id')
->setParameter('segment_id', $segment->getId())
->join('subscriber_segment', $subscribersTable, 'subscribers', 'subscribers.id = subscriber_segment.subscriber_id')
->addSelect('IFNULL(SUM(
CASE WHEN subscribers.deleted_at IS NULL
THEN 1 ELSE 0 END
), 0) as `all`')
->addSelect('IFNULL(SUM(
CASE WHEN subscribers.deleted_at IS NOT NULL
THEN 1 ELSE 0 END
), 0) as trash')
->addSelect('IFNULL(SUM(
CASE WHEN subscribers.status = :status_subscribed AND subscriber_segment.status = :status_subscribed AND subscribers.deleted_at IS NULL
THEN 1 ELSE 0 END
), 0) as :status_subscribed')
->addSelect('IFNULL(SUM(
CASE WHEN (subscribers.status = :status_unsubscribed OR subscriber_segment.status = :status_unsubscribed) AND subscribers.deleted_at IS NULL
THEN 1 ELSE 0 END
), 0) as :status_unsubscribed')
->addSelect('IFNULL(SUM(
CASE WHEN subscribers.status = :status_inactive AND subscriber_segment.status != :status_unsubscribed AND subscribers.deleted_at IS NULL
THEN 1 ELSE 0 END
), 0) as :status_inactive')
->addSelect('IFNULL(SUM(
CASE WHEN subscribers.status = :status_unconfirmed AND subscriber_segment.status != :status_unsubscribed AND subscribers.deleted_at IS NULL
THEN 1 ELSE 0 END
), 0) as :status_unconfirmed')
->addSelect('IFNULL(SUM(
CASE WHEN subscribers.status = :status_bounced AND subscriber_segment.status != :status_unsubscribed AND subscribers.deleted_at IS NULL
THEN 1 ELSE 0 END
), 0) as :status_bounced')
->setParameter('status_subscribed', SubscriberEntity::STATUS_SUBSCRIBED)
->setParameter('status_unsubscribed', SubscriberEntity::STATUS_UNSUBSCRIBED)
->setParameter('status_inactive', SubscriberEntity::STATUS_INACTIVE)
->setParameter('status_unconfirmed', SubscriberEntity::STATUS_UNCONFIRMED)
->setParameter('status_bounced', SubscriberEntity::STATUS_BOUNCED);
}
private function createStaticGlobalStatusStatisticsQueryBuilder(SegmentEntity $segment): QueryBuilder {
$subscriberSegmentTable = $this->entityManager->getClassMetadata(SubscriberSegmentEntity::class)->getTableName();
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
return $this->entityManager
->getConnection()
->createQueryBuilder()
->from($subscriberSegmentTable, 'subscriber_segment')
->where('subscriber_segment.segment_id = :segment_id')
->setParameter('segment_id', $segment->getId())
->join('subscriber_segment', $subscribersTable, 'subscribers', 'subscribers.id = subscriber_segment.subscriber_id')
->addSelect('IFNULL(SUM(
CASE WHEN subscribers.deleted_at IS NULL
THEN 1 ELSE 0 END
), 0) as `all`')
->addSelect('IFNULL(SUM(
CASE WHEN subscribers.deleted_at IS NOT NULL
THEN 1 ELSE 0 END
), 0) as trash')
->addSelect('IFNULL(SUM(
CASE WHEN subscribers.status = :status_subscribed AND subscribers.deleted_at IS NULL
THEN 1 ELSE 0 END
), 0) as :status_subscribed')
->addSelect('IFNULL(SUM(
CASE WHEN subscribers.status = :status_unsubscribed AND subscribers.deleted_at IS NULL
THEN 1 ELSE 0 END
), 0) as :status_unsubscribed')
->addSelect('IFNULL(SUM(
CASE WHEN subscribers.status = :status_inactive AND subscribers.deleted_at IS NULL
THEN 1 ELSE 0 END
), 0) as :status_inactive')
->addSelect('IFNULL(SUM(
CASE WHEN subscribers.status = :status_unconfirmed AND subscribers.deleted_at IS NULL
THEN 1 ELSE 0 END
), 0) as :status_unconfirmed')
->addSelect('IFNULL(SUM(
CASE WHEN subscribers.status = :status_bounced AND subscribers.deleted_at IS NULL
THEN 1 ELSE 0 END
), 0) as :status_bounced')
->setParameter('status_subscribed', SubscriberEntity::STATUS_SUBSCRIBED)
->setParameter('status_unsubscribed', SubscriberEntity::STATUS_UNSUBSCRIBED)
->setParameter('status_inactive', SubscriberEntity::STATUS_INACTIVE)
->setParameter('status_unconfirmed', SubscriberEntity::STATUS_UNCONFIRMED)
->setParameter('status_bounced', SubscriberEntity::STATUS_BOUNCED);
}
public function getSubscribersWithoutSegmentCount(): int {
$queryBuilder = $this->entityManager->createQueryBuilder();
$queryBuilder
->select('COUNT(DISTINCT s) AS subscribersCount')
->from(SubscriberEntity::class, 's');
$this->addConstraintsForSubscribersWithoutSegment($queryBuilder);
return (int)$queryBuilder->getQuery()->getSingleScalarResult();
}
public function getSubscribersWithoutSegmentStatisticsCount(): array {
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
$queryBuilder = $this->entityManager
->getConnection()
->createQueryBuilder();
$queryBuilder
->addSelect('IFNULL(SUM(
CASE WHEN s.deleted_at IS NULL
THEN 1 ELSE 0 END
), 0) as `all`')
->addSelect('IFNULL(SUM(
CASE WHEN s.deleted_at IS NOT NULL
THEN 1 ELSE 0 END
), 0) as trash')
->addSelect('IFNULL(SUM(
CASE WHEN s.status = :status_subscribed AND s.deleted_at IS NULL
THEN 1 ELSE 0 END
), 0) as :status_subscribed')
->addSelect('IFNULL(SUM(
CASE WHEN s.status = :status_unsubscribed AND s.deleted_at IS NULL
THEN 1 ELSE 0 END
), 0) as :status_unsubscribed')
->addSelect('IFNULL(SUM(
CASE WHEN s.status = :status_inactive AND s.deleted_at IS NULL
THEN 1 ELSE 0 END
), 0) as :status_inactive')
->addSelect('IFNULL(SUM(
CASE WHEN s.status = :status_unconfirmed AND s.deleted_at IS NULL
THEN 1 ELSE 0 END
), 0) as :status_unconfirmed')
->addSelect('IFNULL(SUM(
CASE WHEN s.status = :status_bounced AND s.deleted_at IS NULL
THEN 1 ELSE 0 END
), 0) as :status_bounced')
->from($subscribersTable, 's')
->setParameter('status_subscribed', SubscriberEntity::STATUS_SUBSCRIBED)
->setParameter('status_unsubscribed', SubscriberEntity::STATUS_UNSUBSCRIBED)
->setParameter('status_inactive', SubscriberEntity::STATUS_INACTIVE)
->setParameter('status_unconfirmed', SubscriberEntity::STATUS_UNCONFIRMED)
->setParameter('status_bounced', SubscriberEntity::STATUS_BOUNCED);
$this->addConstraintsForSubscribersWithoutSegmentToDBAL($queryBuilder);
$statement = $this->executeQuery($queryBuilder);
$result = $statement->fetch();
return $result;
}
public function addConstraintsForSubscribersWithoutSegment(ORMQueryBuilder $queryBuilder): void {
$deletedSegmentsQueryBuilder = $this->entityManager->createQueryBuilder();
$deletedSegmentsQueryBuilder->select('sg.id')
->from(SegmentEntity::class, 'sg')
->where($deletedSegmentsQueryBuilder->expr()->isNotNull('sg.deletedAt'));
$queryBuilder
->leftJoin(
's.subscriberSegments',
'ssg',
Join::WITH,
(string)$queryBuilder->expr()->andX(
$queryBuilder->expr()->eq('ssg.subscriber', 's.id'),
$queryBuilder->expr()->eq('ssg.status', ':statusSubscribed'),
$queryBuilder->expr()->notIn('ssg.segment', $deletedSegmentsQueryBuilder->getDQL())
)
)
->andWhere('ssg.id IS NULL')
->setParameter('statusSubscribed', SubscriberEntity::STATUS_SUBSCRIBED);
}
public function addConstraintsForSubscribersWithoutSegmentToDBAL(QueryBuilder $queryBuilder): void {
$deletedSegmentsQueryBuilder = $this->entityManager->createQueryBuilder();
$subscribersSegmentTable = $this->entityManager->getClassMetadata(SubscriberSegmentEntity::class)->getTableName();
$deletedSegmentsQueryBuilder->select('sg.id')
->from(SegmentEntity::class, 'sg')
->where($deletedSegmentsQueryBuilder->expr()->isNotNull('sg.deletedAt'));
$queryBuilder
->leftJoin(
's',
$subscribersSegmentTable,
'ssg',
(string)$queryBuilder->expr()->and(
$queryBuilder->expr()->eq('ssg.subscriber_id', 's.id'),
$queryBuilder->expr()->eq('ssg.status', ':statusSubscribed'),
$queryBuilder->expr()->notIn('ssg.segment_id', $deletedSegmentsQueryBuilder->getQuery()->getSQL())
)
)
->andWhere('ssg.id IS NULL')
->setParameter('statusSubscribed', SubscriberEntity::STATUS_SUBSCRIBED);
}
private function loadSubscriberIdsInSegment(int $segmentId, array $candidateIds = null): array {
$segment = $this->getSegment($segmentId);
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
$queryBuilder = $this->entityManager
->getConnection()
->createQueryBuilder()
->select("DISTINCT $subscribersTable.id")
->from($subscribersTable);
if ($segment->isStatic()) {
$queryBuilder = $this->filterSubscribersInStaticSegment($queryBuilder, $segment, SubscriberEntity::STATUS_SUBSCRIBED);
} else {
$queryBuilder = $this->filterSubscribersInDynamicSegment($queryBuilder, $segment, SubscriberEntity::STATUS_SUBSCRIBED);
}
if ($candidateIds) {
$queryBuilder->andWhere("$subscribersTable.id IN (:candidateIds)")
->setParameter('candidateIds', $candidateIds, ArrayParameterType::STRING);
}
$statement = $this->executeQuery($queryBuilder);
$result = $statement->fetchAll();
return array_column($result, 'id');
}
private function filterSubscribersInStaticSegment(
QueryBuilder $queryBuilder,
SegmentEntity $segment,
string $status = null
): QueryBuilder {
$subscribersSegmentsTable = $this->entityManager->getClassMetadata(SubscriberSegmentEntity::class)->getTableName();
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
$parameterName = "segment_{$segment->getId()}"; // When we use this method more times the parameter name has to be unique
$queryBuilder = $queryBuilder->join(
$subscribersTable,
$subscribersSegmentsTable,
'subsegment',
"subsegment.subscriber_id = $subscribersTable.id AND subsegment.segment_id = :$parameterName"
)->andWhere("$subscribersTable.deleted_at IS NULL")
->setParameter($parameterName, $segment->getId());
if ($status) {
$queryBuilder = $queryBuilder->andWhere("$subscribersTable.status = :status")
->andWhere("subsegment.status = :status")
->setParameter('status', $status);
}
return $queryBuilder;
}
private function filterSubscribersInDynamicSegment(
QueryBuilder $queryBuilder,
SegmentEntity $segment,
string $status = null
): QueryBuilder {
$filters = [];
$dynamicFilters = $segment->getDynamicFilters();
foreach ($dynamicFilters as $dynamicFilter) {
$filters[] = $dynamicFilter->getFilterData();
}
// We don't allow dynamic segment without filers since it would return all subscribers
// For BC compatibility fetching an empty result
if (count($filters) === 0) {
return $queryBuilder->andWhere('0 = 1');
} elseif ($segment instanceof SegmentEntity) {
try {
$queryBuilder = $this->filterHandler->apply($queryBuilder, $segment);
} catch (InvalidFilterException $e) {
// If a segment has an invalid filter, we should simply consider it empty instead of throwing
// an unhandled error. Unhandled errors here can break many admin pages.
$queryBuilder->andWhere('0 = 1');
}
}
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
$queryBuilder = $queryBuilder->andWhere("$subscribersTable.deleted_at IS NULL");
if ($status) {
$queryBuilder = $queryBuilder->andWhere("$subscribersTable.status = :status")
->setParameter('status', $status);
}
return $queryBuilder;
}
private function getSegment(int $id): SegmentEntity {
$segment = $this->entityManager->find(SegmentEntity::class, $id);
if (!$segment instanceof SegmentEntity) {
throw new NotFoundException('Segment not found');
}
return $segment;
}
private function executeQuery(QueryBuilder $queryBuilder): Result {
$result = $queryBuilder->execute();
// Execute for select always returns statement but PHP Stan doesn't know that :(
if (!$result instanceof Result) {
throw new InvalidStateException('Invalid query.');
}
return $result;
}
public function getSubscribersGlobalStatusStatisticsCount(SegmentEntity $segment): array {
if ($segment->isStatic()) {
$queryBuilder = $this->createStaticGlobalStatusStatisticsQueryBuilder($segment);
} else {
$queryBuilder = $this->createDynamicStatisticsQueryBuilder();
$this->filterSubscribersInDynamicSegment($queryBuilder, $segment);
}
$statement = $this->executeQuery($queryBuilder);
return $statement->fetch();
}
public function getSubscribersStatisticsCount(SegmentEntity $segment): array {
if ($segment->isStatic()) {
$queryBuilder = $this->createStaticStatisticsQueryBuilder($segment);
} else {
$queryBuilder = $this->createDynamicStatisticsQueryBuilder();
$this->filterSubscribersInDynamicSegment($queryBuilder, $segment);
}
$statement = $this->executeQuery($queryBuilder);
return $statement->fetch();
}
}
@@ -0,0 +1,69 @@
<?php declare(strict_types = 1);
namespace MailPoet\Segments;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\SegmentEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Segments\DynamicSegments\FilterHandler;
use MailPoetVendor\Doctrine\DBAL\Result;
use MailPoetVendor\Doctrine\ORM\EntityManager;
class SegmentsFinder {
/** @var EntityManager */
private $entityManager;
/** @var FilterHandler */
private $filterHandler;
/** @var SegmentsRepository */
private $segmentsRepository;
public function __construct(
EntityManager $entityManager,
FilterHandler $filterHandler,
SegmentsRepository $segmentsRepository
) {
$this->entityManager = $entityManager;
$this->filterHandler = $filterHandler;
$this->segmentsRepository = $segmentsRepository;
}
/** @return SegmentEntity[] */
public function findSegments(SubscriberEntity $subscriber): array {
return array_merge(
$this->findStaticSegments($subscriber),
$this->findDynamicSegments($subscriber)
);
}
/** @return SegmentEntity[] */
public function findStaticSegments(SubscriberEntity $subscriber): array {
return $subscriber->getSegments()->toArray();
}
/** @return SegmentEntity[] */
public function findDynamicSegments(SubscriberEntity $subscriber): array {
$segments = $this->segmentsRepository->findBy([
'type' => SegmentEntity::TYPE_DYNAMIC,
]);
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
$queryBuilder = $this->entityManager->getConnection()->createQueryBuilder()
->select('id')
->from($subscribersTable)
->where('id = :subscriberId')
->setParameter('subscriberId', $subscriber->getId());
$matchingSegments = [];
foreach ($segments as $segment) {
$result = $this->filterHandler->apply(clone $queryBuilder, $segment)->execute();
if ($result instanceof Result && $result->fetchOne()) {
$matchingSegments[] = $segment;
}
}
return $matchingSegments;
}
}
@@ -0,0 +1,369 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Segments;
if (!defined('ABSPATH')) exit;
use DateTime;
use MailPoet\ConflictException;
use MailPoet\Doctrine\Repository;
use MailPoet\Entities\DynamicSegmentFilterData;
use MailPoet\Entities\DynamicSegmentFilterEntity;
use MailPoet\Entities\NewsletterSegmentEntity;
use MailPoet\Entities\SegmentEntity;
use MailPoet\Entities\SubscriberSegmentEntity;
use MailPoet\Form\FormsRepository;
use MailPoet\InvalidStateException;
use MailPoet\Logging\LoggerFactory;
use MailPoet\Newsletter\Segment\NewsletterSegmentRepository;
use MailPoet\NotFoundException;
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\ORMException;
/**
* @extends Repository<SegmentEntity>
*/
class SegmentsRepository extends Repository {
/** @var NewsletterSegmentRepository */
private $newsletterSegmentRepository;
/** @var FormsRepository */
private $formsRepository;
/** @var WPFunctions */
private $wp;
/** @var LoggerFactory */
private $loggerFactory;
public function __construct(
EntityManager $entityManager,
NewsletterSegmentRepository $newsletterSegmentRepository,
FormsRepository $formsRepository,
WPFunctions $wp,
LoggerFactory $loggerFactory
) {
parent::__construct($entityManager);
$this->newsletterSegmentRepository = $newsletterSegmentRepository;
$this->formsRepository = $formsRepository;
$this->wp = $wp;
$this->loggerFactory = $loggerFactory;
}
protected function getEntityClassName() {
return SegmentEntity::class;
}
/**
* @param string[] $types
* @return SegmentEntity[]
*/
public function findByTypeNotIn(array $types): array {
return $this->doctrineRepository->createQueryBuilder('s')
->select('s')
->where('s.type NOT IN (:types)')
->setParameter('types', $types)
->getQuery()
->getResult();
}
public function getWPUsersSegment(): SegmentEntity {
$cached = current(
array_filter(
$this->getAllFromIdentityMap(),
fn(SegmentEntity $segment) => $segment->getType() === SegmentEntity::TYPE_WP_USERS
)
);
if ($cached) {
return $cached;
}
$segment = $this->findOneBy(['type' => SegmentEntity::TYPE_WP_USERS]);
if (!$segment) {
// create the wp users segment
$segment = new SegmentEntity(
__('WordPress Users', 'mailpoet'),
SegmentEntity::TYPE_WP_USERS,
__('This list contains all of your WordPress users.', 'mailpoet')
);
$this->entityManager->persist($segment);
$this->entityManager->flush();
}
return $segment;
}
public function getWooCommerceSegment(): SegmentEntity {
$cached = current(
array_filter(
$this->getAllFromIdentityMap(),
fn(SegmentEntity $segment) => $segment->getType() === SegmentEntity::TYPE_WC_USERS
)
);
if ($cached) {
return $cached;
}
$segment = $this->findOneBy(['type' => SegmentEntity::TYPE_WC_USERS]);
if (!$segment) {
// create the WooCommerce customers segment
$segment = new SegmentEntity(
__('WooCommerce Customers', 'mailpoet'),
SegmentEntity::TYPE_WC_USERS,
__('This list contains all of your WooCommerce customers.', 'mailpoet')
);
$this->entityManager->persist($segment);
$this->entityManager->flush();
}
return $segment;
}
public function getCountsPerType(): array {
$results = $this->doctrineRepository->createQueryBuilder('s')
->select('s.type, COUNT(s) as cnt')
->where('s.deletedAt IS NULL')
->groupBy('s.type')
->getQuery()
->getResult();
$countMap = [];
foreach ($results as $result) {
$countMap[$result['type']] = (int)$result['cnt'];
}
return $countMap;
}
public function isNameUnique(string $name, ?int $id): bool {
$qb = $this->doctrineRepository->createQueryBuilder('s')
->select('s')
->where('s.name = :name')
->setParameter('name', $name);
if ($id !== null) {
$qb->andWhere('s.id != :id')
->setParameter('id', $id);
}
$results = $qb->getQuery()
->getResult();
return count($results) === 0;
}
/**
* @throws ConflictException
*/
public function verifyNameIsUnique(string $name, ?int $id): void {
if (!$this->isNameUnique($name, $id)) {
throw new ConflictException("Could not create new segment with name [{$name}] because a segment with that name already exists.");
}
}
/**
* @param int $id
*
* @return SegmentEntity
* @throws InvalidStateException
*/
public function verifyDynamicSegmentExists(int $id): SegmentEntity {
try {
$dynamicSegment = $this->findOneById($id);
if (!$dynamicSegment instanceof SegmentEntity) {
throw InvalidStateException::create()->withMessage(sprintf("Could not find segment with ID '%s'.", $id));
}
if ($dynamicSegment->getType() !== SegmentEntity::TYPE_DYNAMIC) {
throw InvalidStateException::create()->withMessage(sprintf("Segment with ID '%s' is not a dynamic segment. Its type is %s.", $id, $dynamicSegment->getType()));
}
} catch (InvalidStateException $exception) {
$this->loggerFactory->getLogger(LoggerFactory::TOPIC_SEGMENTS)->error(sprintf("Could not verify existence of dynamic segment: %s", $exception->getMessage()));
throw $exception;
}
return $dynamicSegment;
}
/**
* @param DynamicSegmentFilterData[] $filtersData
* @throws ConflictException
* @throws NotFoundException
* @throws ORMException
*/
public function createOrUpdate(
string $name,
string $description = '',
string $type = SegmentEntity::TYPE_DEFAULT,
array $filtersData = [],
?int $id = null,
bool $displayInManageSubscriptionPage = true
): SegmentEntity {
$displayInManageSubPage = $type === SegmentEntity::TYPE_DEFAULT ? $displayInManageSubscriptionPage : false;
if ($id) {
$segment = $this->findOneById($id);
if (!$segment instanceof SegmentEntity) {
throw new NotFoundException("Segment with ID [{$id}] was not found.");
}
if ($name !== $segment->getName()) {
$this->verifyNameIsUnique($name, $id);
$segment->setName($name);
}
$segment->setDescription($description);
$segment->setDisplayInManageSubscriptionPage($displayInManageSubPage);
} else {
$this->verifyNameIsUnique($name, $id);
$segment = new SegmentEntity($name, $type, $description);
$segment->setDisplayInManageSubscriptionPage($displayInManageSubPage);
$this->persist($segment);
}
// We want to remove redundant filters before update
while ($segment->getDynamicFilters()->count() > count($filtersData)) {
$filterEntity = $segment->getDynamicFilters()->last();
if ($filterEntity) {
$segment->getDynamicFilters()->removeElement($filterEntity);
$this->entityManager->remove($filterEntity);
}
}
$createOrUpdateFilter = function ($filterData, $key) use ($segment) {
if ($filterData instanceof DynamicSegmentFilterData) {
$filterEntity = $segment->getDynamicFilters()->get($key);
if (!$filterEntity instanceof DynamicSegmentFilterEntity) {
$filterEntity = new DynamicSegmentFilterEntity($segment, $filterData);
$segment->getDynamicFilters()->add($filterEntity);
$this->entityManager->persist($filterEntity);
} else {
$filterEntity->setFilterData($filterData);
}
}
};
$wpActionName = 'mailpoet_dynamic_segments_filters_save';
if ($this->wp->hasAction($wpActionName)) {
$this->wp->doAction($wpActionName, $createOrUpdateFilter, $filtersData);
} else {
$filterData = reset($filtersData);
$key = key($filtersData);
$createOrUpdateFilter($filterData, $key);
}
$this->flush();
return $segment;
}
public function bulkDelete(array $ids, string $type = SegmentEntity::TYPE_DEFAULT): int {
if (empty($ids)) {
return 0;
}
$count = 0;
$this->entityManager->transactional(function (EntityManager $entityManager) use ($ids, $type, &$count) {
$subscriberSegmentTable = $entityManager->getClassMetadata(SubscriberSegmentEntity::class)->getTableName();
$segmentTable = $entityManager->getClassMetadata(SegmentEntity::class)->getTableName();
$segmentFiltersTable = $entityManager->getClassMetadata(DynamicSegmentFilterEntity::class)->getTableName();
$entityManager->getConnection()->executeStatement("
DELETE ss FROM $subscriberSegmentTable ss
JOIN $segmentTable s ON ss.`segment_id` = s.`id`
WHERE ss.`segment_id` IN (:ids)
AND s.`type` = :type
", [
'ids' => $ids,
'type' => $type,
], ['ids' => ArrayParameterType::INTEGER]);
$entityManager->getConnection()->executeStatement("
DELETE df FROM $segmentFiltersTable df
WHERE df.`segment_id` IN (:ids)
", [
'ids' => $ids,
], ['ids' => ArrayParameterType::INTEGER]);
$queryBuilder = $entityManager->createQueryBuilder();
$count = $queryBuilder->delete(SegmentEntity::class, 's')
->where('s.id IN (:ids)')
->andWhere('s.type = :type')
->setParameter('ids', $ids, ArrayParameterType::INTEGER)
->setParameter('type', $type, ParameterType::STRING)
->getQuery()->execute();
$queryBuilder = $entityManager->createQueryBuilder();
$queryBuilder->delete(NewsletterSegmentEntity::class, 'ns')
->where('ns.segment IN (:ids)')
->setParameter('ids', $ids, ArrayParameterType::INTEGER)
->getQuery()->execute();
});
return $count;
}
public function bulkTrash(array $ids, string $type = SegmentEntity::TYPE_DEFAULT): int {
$activelyUsedInNewsletters = $this->newsletterSegmentRepository->getSubjectsOfActivelyUsedEmailsForSegments($ids);
$activelyUsedInForms = $this->formsRepository->getNamesOfFormsForSegments();
$activelyUsed = array_unique(array_merge(array_keys($activelyUsedInNewsletters), array_keys($activelyUsedInForms)));
$ids = array_diff($ids, $activelyUsed);
return $this->updateDeletedAt($ids, new Carbon(), $type);
}
public function doTrash(array $ids, string $type = SegmentEntity::TYPE_DEFAULT): int {
return $this->updateDeletedAt($ids, new Carbon(), $type);
}
public function bulkRestore(array $ids, string $type = SegmentEntity::TYPE_DEFAULT): int {
return $this->updateDeletedAt($ids, null, $type);
}
private function updateDeletedAt(array $ids, ?DateTime $deletedAt, string $type): int {
if (empty($ids)) {
return 0;
}
$rows = $this->entityManager->createQueryBuilder()->update(SegmentEntity::class, 's')
->set('s.deletedAt', ':deletedAt')
->where('s.id IN (:ids)')
->andWhere('s.type IN (:type)')
->setParameter('deletedAt', $deletedAt)
->setParameter('ids', $ids)
->setParameter('type', $type)
->getQuery()->execute();
return $rows;
}
public function findByUpdatedScoreNotInLastDay(int $limit): array {
$dateTime = (new Carbon())->subDay();
return $this->entityManager->createQueryBuilder()
->select('s')
->from(SegmentEntity::class, 's')
->where('s.averageEngagementScoreUpdatedAt IS NULL')
->orWhere('s.averageEngagementScoreUpdatedAt < :dateTime')
->setParameter('dateTime', $dateTime)
->getQuery()
->setMaxResults($limit)
->getResult();
}
/**
* Returns count of segments that have more than one dynamic filter
*/
public function getSegmentCountWithMultipleFilters(): int {
$segmentFiltersTable = $this->entityManager->getClassMetadata(DynamicSegmentFilterEntity::class)->getTableName();
$qbInner = $this->entityManager->getConnection()->createQueryBuilder()
->select('COUNT(DISTINCT sf.id) AS segmentCount')
->from($segmentFiltersTable, 'sf')
->groupBy('sf.segment_id')
->having('COUNT(sf.id) > 1');
/** @var null|int $result */
$result = $this->entityManager->getConnection()->createQueryBuilder()
->select('count(*)')
->from(sprintf('(%s) as subCounts', $qbInner->getSQL()))
->execute()
->fetchOne();
return (int)$result;
}
}
@@ -0,0 +1,107 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Segments;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\SegmentEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Subscribers\SubscribersCountsController;
use MailPoetVendor\Doctrine\DBAL\ArrayParameterType;
use MailPoetVendor\Doctrine\DBAL\Result;
use MailPoetVendor\Doctrine\ORM\EntityManager;
class SegmentsSimpleListRepository {
/** @var EntityManager */
private $entityManager;
/** @var SubscribersCountsController */
private $subscribersCountsController;
public function __construct(
EntityManager $entityManager,
SubscribersCountsController $subscribersCountsController
) {
$this->entityManager = $entityManager;
$this->subscribersCountsController = $subscribersCountsController;
}
/**
* This fetches list of all segments basic data and count of subscribed subscribers.
* @return array<array{id: string, name: string, type: string, subscribers: int}>
*/
public function getListWithSubscribedSubscribersCounts(array $segmentTypes = []): array {
return $this->getList(
$segmentTypes,
SubscriberEntity::STATUS_SUBSCRIBED
);
}
/**
* This fetches list of all segments basic data and count of subscribers associated to a segment regardless their subscription status.
* @return array<array{id: string, name: string, type: string, subscribers: int}>
*/
public function getListWithAssociatedSubscribersCounts(array $segmentTypes = []): array {
return $this->getList(
$segmentTypes
);
}
/**
* Adds a virtual segment with for subscribers without list
* @return array<array{id: string, name: string, type: string, subscribers: int}>
*/
public function addVirtualSubscribersWithoutListSegment(array $segments): array {
$withoutSegmentStats = $this->subscribersCountsController->getSubscribersWithoutSegmentStatisticsCount();
$segments[] = [
'id' => '0',
'type' => SegmentEntity::TYPE_WITHOUT_LIST,
'name' => __('Subscribers without a list', 'mailpoet'),
'subscribers' => $withoutSegmentStats['all'],
];
return $segments;
}
/**
* @return array<array{id: string, name: string, type: string, subscribers: int}>
*/
private function getList(
array $segmentTypes = [],
string $subscriberGlobalStatus = null
): array {
$segmentsTable = $this->entityManager->getClassMetadata(SegmentEntity::class)->getTableName();
$segmentsDataQuery = $this->entityManager
->getConnection()
->createQueryBuilder();
$segmentsDataQuery->select(
"segments.id, segments.name, segments.type"
)->from($segmentsTable, 'segments')
->where('segments.deleted_at IS NULL')
->orderBy('segments.name');
if (!empty($segmentTypes)) {
$segmentsDataQuery
->andWhere('segments.type IN (:typesParam)')
->setParameter('typesParam', $segmentTypes, ArrayParameterType::STRING);
}
$result = $segmentsDataQuery->executeQuery();
if (!$result instanceof Result) {
return [];
}
$segments = $result->fetchAll();
// Fetch subscribers counts for static and dynamic segments and correct data types
foreach ($segments as $key => $segment) {
// BC compatibility fix. PHP8.1+ returns integer but JS apps expect string
$segments[$key]['id'] = (string)$segment['id'];
$statisticsKey = $subscriberGlobalStatus ?: 'all';
$segments[$key]['subscribers'] = (int)$this->subscribersCountsController->getSegmentStatisticsCountById($segment['id'])[$statisticsKey];
}
/* @var array<array{id: string, name: string, type: string, subscribers: int}> */
return $segments;
}
}
@@ -0,0 +1,217 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Segments;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\ScheduledTaskEntity;
use MailPoet\Entities\ScheduledTaskSubscriberEntity;
use MailPoet\Entities\SegmentEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Entities\SubscriberSegmentEntity;
use MailPoet\InvalidStateException;
use MailPoetVendor\Doctrine\DBAL\ArrayParameterType;
use MailPoetVendor\Doctrine\DBAL\ParameterType;
use MailPoetVendor\Doctrine\ORM\EntityManager;
class SubscribersFinder {
/** @var SegmentSubscribersRepository */
private $segmentSubscriberRepository;
/** @var SegmentsRepository */
private $segmentsRepository;
/** @var EntityManager */
private $entityManager;
public function __construct(
SegmentSubscribersRepository $segmentSubscriberRepository,
SegmentsRepository $segmentsRepository,
EntityManager $entityManager
) {
$this->segmentSubscriberRepository = $segmentSubscriberRepository;
$this->segmentsRepository = $segmentsRepository;
$this->entityManager = $entityManager;
}
/**
* @return array
* @throws InvalidStateException
*/
public function findSubscribersInSegments($subscribersToProcessIds, $newsletterSegmentsIds, ?int $filterSegmentId = null) {
$result = [];
foreach ($newsletterSegmentsIds as $segmentId) {
$segment = $this->segmentsRepository->findOneById($segmentId);
if (!$segment instanceof SegmentEntity) {
continue; // skip deleted segments
}
$result = array_merge($result, $this->findSubscribersInSegment($segment, $subscribersToProcessIds));
}
if (is_int($filterSegmentId)) {
$filterSegment = $this->segmentsRepository->verifyDynamicSegmentExists($filterSegmentId);
$idsInFilterSegment = $this->findSubscribersInSegment($filterSegment, $subscribersToProcessIds);
$result = array_intersect($result, $idsInFilterSegment);
}
return $this->unique($result);
}
private function findSubscribersInSegment(SegmentEntity $segment, $subscribersToProcessIds): array {
try {
return $this->segmentSubscriberRepository->findSubscribersIdsInSegment((int)$segment->getId(), $subscribersToProcessIds);
} catch (InvalidStateException $e) {
return [];
}
}
/**
* @param ScheduledTaskEntity $task
* @param array<int> $segmentIds
*
* @return void
*/
public function addSubscribersToTaskFromSegments(ScheduledTaskEntity $task, array $segmentIds, ?int $filterSegmentId = null): void {
// Prepare subscribers on the DB side for performance reasons
if (is_int($filterSegmentId)) {
try {
$this->segmentsRepository->verifyDynamicSegmentExists($filterSegmentId);
} catch (InvalidStateException $exception) {
return;
}
}
$staticSegmentIds = [];
$dynamicSegmentIds = [];
foreach ($segmentIds as $segment) {
$segment = $this->segmentsRepository->findOneById($segment);
if ($segment instanceof SegmentEntity) {
if ($segment->isStatic()) {
$staticSegmentIds[] = (int)$segment->getId();
} elseif ($segment->getType() === SegmentEntity::TYPE_DYNAMIC) {
$dynamicSegmentIds[] = (int)$segment->getId();
}
}
}
$count = 0;
if (!empty($staticSegmentIds)) {
$count += $this->addSubscribersToTaskFromStaticSegments($task, $staticSegmentIds, $filterSegmentId);
}
if (!empty($dynamicSegmentIds)) {
$count += $this->addSubscribersToTaskFromDynamicSegments($task, $dynamicSegmentIds, $filterSegmentId);
}
if ($count > 0) {
$this->entityManager->refresh($task);
}
}
/**
* @param ScheduledTaskEntity $task
* @param array<int> $segmentIds
*
* @return int
*/
private function addSubscribersToTaskFromStaticSegments(ScheduledTaskEntity $task, array $segmentIds, ?int $filterSegmentId = null) {
$scheduledTaskSubscriberTable = $this->entityManager->getClassMetadata(ScheduledTaskSubscriberEntity::class)->getTableName();
$subscriberSegmentTable = $this->entityManager->getClassMetadata(SubscriberSegmentEntity::class)->getTableName();
$subscriberTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
$connection = $this->entityManager->getConnection();
$selectQueryBuilder = $connection->createQueryBuilder();
$selectQueryBuilder
->select('DISTINCT :task_id as task_id', 'subscribers.id as subscriber_id', ':processed as processed')
->from($subscriberSegmentTable, 'relation')
->join('relation', $subscriberTable, 'subscribers', 'subscribers.id = relation.subscriber_id')
->where('subscribers.deleted_at IS NULL')
->andWhere('subscribers.status = :subscribers_status')
->andWhere('relation.status = :relation_status')
->andWhere($selectQueryBuilder->expr()->in('relation.segment_id', ':segment_ids'))
->setParameter('task_id', $task->getId(), ParameterType::INTEGER)
->setParameter('processed', ScheduledTaskSubscriberEntity::STATUS_UNPROCESSED, ParameterType::INTEGER)
->setParameter('subscribers_status', SubscriberEntity::STATUS_SUBSCRIBED, ParameterType::STRING)
->setParameter('relation_status', SubscriberEntity::STATUS_SUBSCRIBED, ParameterType::STRING)
->setParameter('segment_ids', $segmentIds, ArrayParameterType::INTEGER);
if ($filterSegmentId) {
$filterSegmentSubscriberIds = $this->segmentSubscriberRepository->findSubscribersIdsInSegment($filterSegmentId);
$selectQueryBuilder
->andWhere($selectQueryBuilder->expr()->in('subscribers.id', ':filterSegmentSubscriberIds'))
->setParameter('filterSegmentSubscriberIds', $filterSegmentSubscriberIds, ArrayParameterType::INTEGER);
}
// queryBuilder doesn't support INSERT IGNORE directly
$sql = "INSERT IGNORE INTO $scheduledTaskSubscriberTable (task_id, subscriber_id, processed) " . $selectQueryBuilder->getSQL();
$result = $connection->executeQuery($sql, $selectQueryBuilder->getParameters(), $selectQueryBuilder->getParameterTypes());
return (int)$result->rowCount();
}
/**
* @param ScheduledTaskEntity $task
* @param array<int> $segmentIds
*
* @return int
*/
private function addSubscribersToTaskFromDynamicSegments(ScheduledTaskEntity $task, array $segmentIds, ?int $filterSegmentId = null) {
$count = 0;
foreach ($segmentIds as $segmentId) {
$count += $this->addSubscribersToTaskFromDynamicSegment($task, (int)$segmentId, $filterSegmentId);
}
return $count;
}
private function addSubscribersToTaskFromDynamicSegment(ScheduledTaskEntity $task, int $segmentId, ?int $filterSegmentId) {
$count = 0;
$subscriberIds = $this->segmentSubscriberRepository->getSubscriberIdsInSegment($segmentId);
if ($filterSegmentId) {
$filterSegmentSubscriberIds = $this->segmentSubscriberRepository->getSubscriberIdsInSegment($filterSegmentId);
$subscriberIds = array_intersect($subscriberIds, $filterSegmentSubscriberIds);
}
if ($subscriberIds) {
$count += $this->addSubscribersToTaskByIds($task, $subscriberIds);
}
return $count;
}
private function addSubscribersToTaskByIds(ScheduledTaskEntity $task, array $subscriberIds) {
$scheduledTaskSubscriberTable = $this->entityManager->getClassMetadata(ScheduledTaskSubscriberEntity::class)->getTableName();
$subscriberTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
$connection = $this->entityManager->getConnection();
$result = $connection->executeQuery(
"INSERT IGNORE INTO $scheduledTaskSubscriberTable
(task_id, subscriber_id, processed)
SELECT DISTINCT ? as task_id, subscribers.`id` as subscriber_id, ? as processed
FROM $subscriberTable subscribers
WHERE subscribers.`deleted_at` IS NULL
AND subscribers.`status` = ?
AND subscribers.`id` IN (?)",
[
$task->getId(),
ScheduledTaskSubscriberEntity::STATUS_UNPROCESSED,
SubscriberEntity::STATUS_SUBSCRIBED,
$subscriberIds,
],
[
ParameterType::INTEGER,
ParameterType::INTEGER,
ParameterType::STRING,
ArrayParameterType::INTEGER,
]
);
return $result->rowCount();
}
private function unique(array $subscriberIds) {
$result = [];
foreach ($subscriberIds as $id) {
$result[$id] = $id;
}
return $result;
}
}
@@ -0,0 +1,412 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Segments;
if (!defined('ABSPATH')) exit;
use MailPoet\Config\SubscriberChangesNotifier;
use MailPoet\DI\ContainerWrapper;
use MailPoet\Doctrine\WPDB\Connection;
use MailPoet\Entities\SegmentEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Entities\SubscriberSegmentEntity;
use MailPoet\Newsletter\Scheduler\WelcomeScheduler;
use MailPoet\Services\Validator;
use MailPoet\Settings\SettingsController;
use MailPoet\Subscribers\ConfirmationEmailMailer;
use MailPoet\Subscribers\Source;
use MailPoet\Subscribers\SubscriberSegmentRepository;
use MailPoet\Subscribers\SubscribersRepository;
use MailPoet\WooCommerce\Helper as WooCommerceHelper;
use MailPoet\WP\Functions as WPFunctions;
use MailPoetVendor\Carbon\Carbon;
use MailPoetVendor\Doctrine\ORM\EntityManager;
class WP {
/** @var WPFunctions */
private $wp;
/** @var WelcomeScheduler */
private $welcomeScheduler;
/** @var WooCommerceHelper */
private $wooHelper;
/** @var SubscribersRepository */
private $subscribersRepository;
/** @var SubscriberChangesNotifier */
private $subscriberChangesNotifier;
private $subscriberSegmentRepository;
/** @var Validator */
private $validator;
/** @var SegmentsRepository */
private $segmentsRepository;
/** @var EntityManager */
private $entityManager;
/** @var string */
private $subscribersTable;
/** @var \MailPoetVendor\Doctrine\DBAL\Connection */
private $databaseConnection;
public function __construct(
WPFunctions $wp,
WelcomeScheduler $welcomeScheduler,
WooCommerceHelper $wooHelper,
SubscribersRepository $subscribersRepository,
SubscriberSegmentRepository $subscriberSegmentRepository,
SubscriberChangesNotifier $subscriberChangesNotifier,
Validator $validator,
SegmentsRepository $segmentsRepository,
EntityManager $entityManager
) {
$this->wp = $wp;
$this->welcomeScheduler = $welcomeScheduler;
$this->wooHelper = $wooHelper;
$this->subscribersRepository = $subscribersRepository;
$this->subscriberSegmentRepository = $subscriberSegmentRepository;
$this->subscriberChangesNotifier = $subscriberChangesNotifier;
$this->validator = $validator;
$this->segmentsRepository = $segmentsRepository;
$this->entityManager = $entityManager;
$this->databaseConnection = $this->entityManager->getConnection();
$this->subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
}
/**
* @param int $wpUserId
* @param array|false $oldWpUserData
*/
public function synchronizeUser(int $wpUserId, $oldWpUserData = false): void {
$wpUser = \get_userdata($wpUserId);
if ($wpUser === false) return;
$subscriber = $this->subscribersRepository->findOneBy(['wpUserId' => $wpUserId]);
$currentFilter = $this->wp->currentFilter();
// Delete
if (in_array($currentFilter, ['delete_user', 'deleted_user', 'remove_user_from_blog'])) {
if ($subscriber instanceof SubscriberEntity) {
$this->deleteSubscriber($subscriber);
}
return;
}
$this->handleCreatingOrUpdatingSubscriber($currentFilter, $wpUser, $subscriber, $oldWpUserData);
}
private function deleteSubscriber(SubscriberEntity $subscriber): void {
$this->subscribersRepository->remove($subscriber);
$this->subscribersRepository->flush();
}
/**
* @param string $currentFilter
* @param \WP_User $wpUser
* @param ?SubscriberEntity $subscriber
* @param array|false $oldWpUserData
*/
private function handleCreatingOrUpdatingSubscriber(string $currentFilter, \WP_User $wpUser, ?SubscriberEntity $subscriber = null, $oldWpUserData = false): void {
// Add or update
$wpSegment = $this->segmentsRepository->getWPUsersSegment();
// find subscriber by email when is null
if (is_null($subscriber)) {
$subscriber = $this->subscribersRepository->findOneBy(['email' => $wpUser->user_email]); // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
}
// get first name & last name
$firstName = html_entity_decode($wpUser->first_name); // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
$lastName = html_entity_decode($wpUser->last_name); // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
if (empty($wpUser->first_name) && empty($wpUser->last_name)) { // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
$firstName = html_entity_decode($wpUser->display_name); // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
}
$signupConfirmationEnabled = SettingsController::getInstance()->get('signup_confirmation.enabled');
$status = $signupConfirmationEnabled ? SubscriberEntity::STATUS_UNCONFIRMED : SubscriberEntity::STATUS_SUBSCRIBED;
// we want to mark a new subscriber as unsubscribe when the checkbox from registration is unchecked
if (isset($_POST['mailpoet']['subscribe_on_register_active']) && (bool)$_POST['mailpoet']['subscribe_on_register_active'] === true) {
$status = SubscriberEntity::STATUS_UNSUBSCRIBED;
}
// subscriber data
$data = [
'wp_user_id' => $wpUser->ID,
'email' => $wpUser->user_email, // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
'first_name' => $firstName,
'last_name' => $lastName,
'status' => $status,
'source' => Source::WORDPRESS_USER,
];
if (!is_null($subscriber)) {
$data['id'] = $subscriber->getId();
unset($data['status']); // don't override status for existing users
unset($data['source']); // don't override status for existing users
}
$addingNewUserToDisabledWPSegment = $wpSegment->getDeletedAt() !== null && $currentFilter === 'user_register';
$otherActiveSegments = [];
if ($subscriber) {
$otherActiveSegments = array_filter($subscriber->getSegments()->toArray() ?? [], function (SegmentEntity $segment) {
return $segment->getType() !== SegmentEntity::TYPE_WP_USERS && $segment->getDeletedAt() === null;
});
}
$isWooCustomer = $this->wooHelper->isWooCommerceActive() && in_array('customer', $wpUser->roles, true);
// When WP Segment is disabled force trashed state and unconfirmed status for new WPUsers without active segment
// or who are not WooCommerce customers at the same time since customers are added to the WooCommerce list
if ($addingNewUserToDisabledWPSegment && !$otherActiveSegments && !$isWooCustomer) {
$data['deleted_at'] = Carbon::now()->millisecond(0);
$data['status'] = SubscriberEntity::STATUS_UNCONFIRMED;
}
try {
$subscriber = $this->createOrUpdateSubscriber($data, $subscriber);
} catch (\Exception $e) {
return; // fails silently as this was the behavior of this methods before the Doctrine refactor.
}
// add subscriber to the WP Users segment
$this->subscriberSegmentRepository->subscribeToSegments(
$subscriber,
[$wpSegment]
);
if (!$signupConfirmationEnabled && $subscriber->getStatus() === SubscriberEntity::STATUS_SUBSCRIBED && $currentFilter === 'user_register') {
$subscriberSegment = $this->subscriberSegmentRepository->findOneBy([
'subscriber' => $subscriber->getId(),
'segment' => $wpSegment->getId(),
]);
if (!is_null($subscriberSegment)) {
$this->wp->doAction('mailpoet_segment_subscribed', $subscriberSegment);
}
}
$subscribeOnRegisterEnabled = SettingsController::getInstance()->get('subscribe.on_register.enabled');
$sendConfirmationEmail =
$signupConfirmationEnabled
&& $subscribeOnRegisterEnabled
&& $currentFilter !== 'profile_update'
&& !$addingNewUserToDisabledWPSegment;
if ($sendConfirmationEmail && ($subscriber->getStatus() === SubscriberEntity::STATUS_UNCONFIRMED)) {
/** @var ConfirmationEmailMailer $confirmationEmailMailer */
$confirmationEmailMailer = ContainerWrapper::getInstance()->get(ConfirmationEmailMailer::class);
try {
$confirmationEmailMailer->sendConfirmationEmailOnce($subscriber);
} catch (\Exception $e) {
// ignore errors
}
}
// welcome email
$scheduleWelcomeNewsletter = false;
if (in_array($currentFilter, ['profile_update', 'user_register', 'add_user_role', 'set_user_role'])) {
$scheduleWelcomeNewsletter = true;
}
if ($scheduleWelcomeNewsletter === true) {
$this->welcomeScheduler->scheduleWPUserWelcomeNotification(
$subscriber->getId(),
(array)$wpUser,
(array)$oldWpUserData
);
}
}
private function createOrUpdateSubscriber(array $data, ?SubscriberEntity $subscriber = null): SubscriberEntity {
if (is_null($subscriber)) {
$subscriber = new SubscriberEntity();
}
$subscriber->setWpUserId($data['wp_user_id']);
$subscriber->setEmail($data['email']);
$subscriber->setFirstName($data['first_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['deleted_at'])) {
$subscriber->setDeletedAt($data['deleted_at']);
}
$this->subscribersRepository->persist($subscriber);
$this->subscribersRepository->flush();
return $subscriber;
}
public function synchronizeUsers(): bool {
// Temporarily skip synchronization in WP Playground.
// Some of the queries are not yet supported by the SQLite integration.
if (Connection::isSQLite()) {
return true;
}
// Save timestamp about changes and update before insert
$this->subscriberChangesNotifier->subscribersBatchCreate();
$this->subscriberChangesNotifier->subscribersBatchUpdate();
$updatedUsersEmails = $this->updateSubscribersEmails();
$insertedUsersEmails = $this->insertSubscribers();
$this->removeUpdatedSubscribersWithInvalidEmail(array_merge($updatedUsersEmails, $insertedUsersEmails));
// There is high chance that an update will be made
$this->subscriberChangesNotifier->subscribersBatchUpdate();
unset($updatedUsersEmails);
unset($insertedUsersEmails);
$this->updateFirstNames();
$this->updateLastNames();
$this->updateFirstNameIfMissing();
$this->insertUsersToSegment();
$this->removeOrphanedSubscribers();
$this->subscribersRepository->invalidateTotalSubscribersCache();
$this->subscribersRepository->refreshAll();
return true;
}
private function removeUpdatedSubscribersWithInvalidEmail(array $updatedEmails): void {
$invalidWpUserIds = array_map(function($item) {
return $item['id'];
},
array_filter($updatedEmails, function($updatedEmail) {
return !$this->validator->validateEmail($updatedEmail['email']) && $updatedEmail['id'] !== null;
}));
if (!$invalidWpUserIds) {
return;
}
$this->subscribersRepository->removeByWpUserIds($invalidWpUserIds);
}
private function updateSubscribersEmails(): array {
global $wpdb;
$stmt = $this->databaseConnection->executeQuery('SELECT NOW();');
$startTime = $stmt->fetchOne();
if (!is_string($startTime)) {
throw new \RuntimeException("Failed to fetch the current time.");
}
$updateSql =
"UPDATE IGNORE {$this->subscribersTable} s
INNER JOIN {$wpdb->users} as wu ON s.wp_user_id = wu.id
SET s.email = wu.user_email";
$this->databaseConnection->executeStatement($updateSql);
$selectSql =
"SELECT wp_user_id as id, email FROM {$this->subscribersTable}
WHERE updated_at >= '{$startTime}'";
$updatedEmails = $this->databaseConnection->fetchAllAssociative($selectSql);
return $updatedEmails;
}
private function insertSubscribers(): array {
global $wpdb;
$wpSegment = $this->segmentsRepository->getWPUsersSegment();
if ($wpSegment->getDeletedAt() !== null) {
$subscriberStatus = SubscriberEntity::STATUS_UNCONFIRMED;
$deletedAt = 'CURRENT_TIMESTAMP()';
} else {
$signupConfirmationEnabled = SettingsController::getInstance()->get('signup_confirmation.enabled');
$subscriberStatus = $signupConfirmationEnabled ? SubscriberEntity::STATUS_UNCONFIRMED : SubscriberEntity::STATUS_SUBSCRIBED;
$deletedAt = 'null';
}
// Fetch users that are not in the subscribers table
$selectSql =
"SELECT u.id, u.user_email as email
FROM {$wpdb->users} u
LEFT JOIN {$this->subscribersTable} AS s ON s.wp_user_id = u.id
WHERE s.wp_user_id IS NULL AND u.user_email != ''";
$insertedUserIds = $this->databaseConnection->fetchAllAssociative($selectSql);
// Insert new users into the subscribers table
$insertSql =
"INSERT IGNORE INTO {$this->subscribersTable} (wp_user_id, email, status, created_at, `source`, deleted_at)
SELECT wu.id, wu.user_email, :subscriberStatus, CURRENT_TIMESTAMP(), :source, {$deletedAt}
FROM {$wpdb->users} wu
LEFT JOIN {$this->subscribersTable} s ON wu.id = s.wp_user_id
WHERE s.wp_user_id IS NULL AND wu.user_email != ''
ON DUPLICATE KEY UPDATE wp_user_id = wu.id";
$stmt = $this->databaseConnection->prepare($insertSql);
$stmt->bindValue('subscriberStatus', $subscriberStatus);
$stmt->bindValue('source', Source::WORDPRESS_USER);
$stmt->executeStatement();
return $insertedUserIds;
}
private function updateFirstNames(): void {
global $wpdb;
$sql =
"UPDATE {$this->subscribersTable} s
JOIN {$wpdb->usermeta} as wpum ON s.wp_user_id = wpum.user_id AND wpum.meta_key = 'first_name'
SET s.first_name = SUBSTRING(wpum.meta_value, 1, 255)
WHERE s.first_name = ''
AND s.wp_user_id IS NOT NULL
AND wpum.meta_value IS NOT NULL";
$this->databaseConnection->executeStatement($sql);
}
private function updateLastNames(): void {
global $wpdb;
$sql =
"UPDATE {$this->subscribersTable} s
JOIN {$wpdb->usermeta} as wpum ON s.wp_user_id = wpum.user_id AND wpum.meta_key = 'last_name'
SET s.last_name = SUBSTRING(wpum.meta_value, 1, 255)
WHERE s.last_name = ''
AND s.wp_user_id IS NOT NULL
AND wpum.meta_value IS NOT NULL";
$this->databaseConnection->executeStatement($sql);
}
private function updateFirstNameIfMissing(): void {
global $wpdb;
$sql =
"UPDATE {$this->subscribersTable} s
JOIN {$wpdb->users} wu ON s.wp_user_id = wu.id
SET s.first_name = wu.display_name
WHERE s.first_name = ''
AND s.wp_user_id IS NOT NULL";
$this->databaseConnection->executeStatement($sql);
}
private function insertUsersToSegment(): void {
$wpSegment = $this->segmentsRepository->getWPUsersSegment();
$subscribersSegmentTable = $this->entityManager->getClassMetadata(SubscriberSegmentEntity::class)->getTableName();
$sql =
"INSERT IGNORE INTO {$subscribersSegmentTable} (subscriber_id, segment_id, created_at)
SELECT s.id, '{$wpSegment->getId()}', CURRENT_TIMESTAMP() FROM {$this->subscribersTable} s
WHERE s.wp_user_id > 0";
$this->databaseConnection->executeStatement($sql);
}
private function removeOrphanedSubscribers(): void {
$this->subscribersRepository->removeOrphanedSubscribersFromWpSegment();
}
}
@@ -0,0 +1,633 @@
<?php declare(strict_types = 1);
namespace MailPoet\Segments;
if (!defined('ABSPATH')) exit;
use MailPoet\Config\Env;
use MailPoet\Config\SubscriberChangesNotifier;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Entities\SubscriberSegmentEntity;
use MailPoet\Services\Validator;
use MailPoet\Settings\SettingsController;
use MailPoet\Subscribers\Source;
use MailPoet\Subscribers\SubscriberSaveController;
use MailPoet\Subscribers\SubscriberSegmentRepository;
use MailPoet\Subscribers\SubscribersRepository;
use MailPoet\WooCommerce\Helper as WCHelper;
use MailPoet\WooCommerce\Subscription;
use MailPoet\WP\Functions as WPFunctions;
use MailPoetVendor\Carbon\Carbon;
use MailPoetVendor\Doctrine\DBAL\ArrayParameterType;
use MailPoetVendor\Doctrine\DBAL\Connection;
use MailPoetVendor\Doctrine\DBAL\ParameterType;
use MailPoetVendor\Doctrine\ORM\EntityManager;
class WooCommerce {
/** @var SettingsController */
private $settings;
/** @var WPFunctions */
private $wp;
/** @var WP */
private $wpSegment;
/** @var string|null */
private $mailpoetEmailCollation;
/** @var string|null */
private $wpPostmetaValueCollation;
/** @var SubscribersRepository */
private $subscribersRepository;
/** @var SegmentsRepository */
private $segmentsRepository;
/** @var SubscriberSegmentRepository */
private $subscriberSegmentRepository;
/** @var SubscriberSaveController */
private $subscriberSaveController;
/** @var WCHelper */
private $woocommerceHelper;
/** @var EntityManager */
private $entityManager;
/** @var Connection */
private $connection;
/** @var SubscriberChangesNotifier */
private $subscriberChangesNotifier;
/** @var Validator */
private $validator;
public function __construct(
SettingsController $settings,
WPFunctions $wp,
WCHelper $woocommerceHelper,
SubscribersRepository $subscribersRepository,
SegmentsRepository $segmentsRepository,
SubscriberSegmentRepository $subscriberSegmentRepository,
SubscriberSaveController $subscriberSaveController,
WP $wpSegment,
EntityManager $entityManager,
Connection $connection,
SubscriberChangesNotifier $subscriberChangesNotifier,
Validator $validator
) {
$this->settings = $settings;
$this->wp = $wp;
$this->wpSegment = $wpSegment;
$this->subscribersRepository = $subscribersRepository;
$this->segmentsRepository = $segmentsRepository;
$this->subscriberSegmentRepository = $subscriberSegmentRepository;
$this->subscriberSaveController = $subscriberSaveController;
$this->woocommerceHelper = $woocommerceHelper;
$this->entityManager = $entityManager;
$this->connection = $connection;
$this->subscriberChangesNotifier = $subscriberChangesNotifier;
$this->validator = $validator;
}
public function shouldShowWooCommerceSegment(): bool {
$isWoocommerceActive = $this->woocommerceHelper->isWooCommerceActive();
$woocommerceUserExists = $this->subscribersRepository->woocommerceUserExists();
if (!$isWoocommerceActive && !$woocommerceUserExists) {
return false;
}
return true;
}
public function synchronizeRegisteredCustomer(int $wpUserId, ?string $currentFilter = null): bool {
$wcSegment = $this->segmentsRepository->getWooCommerceSegment();
$currentFilter = $currentFilter ?: $this->wp->currentFilter();
switch ($currentFilter) {
case 'woocommerce_delete_customer':
// subscriber should be already deleted in WP users sync
$this->unsubscribeUsersFromSegment(); // remove leftover association
break;
case 'woocommerce_new_customer':
case 'woocommerce_created_customer':
$newCustomer = true;
case 'woocommerce_update_customer':
default:
$wpUser = $this->wp->getUserdata($wpUserId);
$subscriber = $this->subscribersRepository->findOneBy(['wpUserId' => $wpUserId]);
if ($wpUser === false || $subscriber === null) {
// registered customers should exist as WP users and WP segment subscribers
return false;
}
$data = [
'is_woocommerce_user' => 1,
];
if (!empty($newCustomer)) {
$data['source'] = Source::WOOCOMMERCE_USER;
}
$data['id'] = $subscriber->getId();
if ($wpUser->first_name) { // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
$data['first_name'] = $wpUser->first_name; // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
}
if ($wpUser->last_name) { // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
$data['last_name'] = $wpUser->last_name; // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
}
$subscriber = $this->subscriberSaveController->createOrUpdate($data, $subscriber);
// add subscriber to the WooCommerce Customers segment when relation doesn't exist
$subscriberSegment = $this->subscriberSegmentRepository->findOneBy(['subscriber' => $subscriber, 'segment' => $wcSegment]);
if (!$subscriberSegment && $this->shouldSubscribeToWooSegment()) {
$this->subscriberSegmentRepository->subscribeToSegments(
$subscriber,
[$wcSegment]
);
}
break;
}
return true;
}
/**
* Should subscribe to the Woo segment when creating a new woo customer and not on checkout
* or when on checkout and MailPoet subscribe optin is enabled and checked.
*/
protected function shouldSubscribeToWooSegment(): bool {
$checkoutOptinEnabled = (bool)$this->settings->get(Subscription::OPTIN_ENABLED_SETTING_NAME);
$checkoutOptinChecked = !empty($_POST[Subscription::CHECKOUT_OPTIN_INPUT_NAME]);
return !$this->woocommerceHelper->isCheckoutRequest() || ($checkoutOptinEnabled && $checkoutOptinChecked);
}
public function synchronizeGuestCustomer(int $orderId): void {
$wcOrder = $this->woocommerceHelper->wcGetOrder($orderId);
if (!$wcOrder instanceof \WC_Order) return;
$signupConfirmation = $this->settings->get('signup_confirmation');
$status = SubscriberEntity::STATUS_UNSUBSCRIBED;
if ((bool)$signupConfirmation['enabled'] === false) {
$status = SubscriberEntity::STATUS_SUBSCRIBED;
}
$email = $this->insertSubscriberFromOrder($wcOrder, $status);
if (empty($email)) {
return;
}
$subscriber = $this->subscribersRepository->findOneBy(['email' => $email]);
if ($subscriber) {
$firstName = $wcOrder->get_billing_first_name(); // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
$lastName = $wcOrder->get_billing_last_name(); // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
if ($firstName) {
$subscriber->setFirstName($firstName);
}
if ($lastName) {
$subscriber->setLastName($lastName);
}
if ($firstName || $lastName) {
$this->subscribersRepository->flush();
}
}
}
public function synchronizeCustomers(int $lastCheckedOrderId = 0, ?int $highestOrderId = null, int $batchSize = 1000): int {
$this->wpSegment->synchronizeUsers(); // synchronize registered users
$this->markRegisteredCustomers();
$processedOrders = $this->insertSubscribersFromOrders($lastCheckedOrderId, $batchSize);
$this->updateNames($processedOrders);
$lastCheckedOrderId = $lastCheckedOrderId + $batchSize;
if (!$highestOrderId || $lastCheckedOrderId >= $highestOrderId) {
$this->insertUsersToSegment();
$this->unsubscribeUsersFromSegment();
$this->removeOrphanedSubscribers();
$this->updateStatus();
$this->updateGlobalStatus();
}
$this->subscribersRepository->invalidateTotalSubscribersCache();
return $lastCheckedOrderId;
}
private function ensureColumnCollation(): void {
if ($this->mailpoetEmailCollation && $this->wpPostmetaValueCollation) {
return;
}
global $wpdb;
$mailpoetEmailColumn = $wpdb->get_row($wpdb->prepare(
"SHOW FULL COLUMNS FROM %i WHERE Field = 'email'",
$this->subscribersRepository->getTableName()
));
$this->mailpoetEmailCollation = $mailpoetEmailColumn->Collation; // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
$wpPostmetaValueColumn = $wpdb->get_row($wpdb->prepare(
"SHOW FULL COLUMNS FROM %i WHERE Field = 'meta_value'",
$wpdb->postmeta
));
$this->wpPostmetaValueCollation = $wpPostmetaValueColumn->Collation; // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
}
/**
* In MySQL, if you have the same charset and collation in joined tables' columns it's perfect;
* if you have different charsets, utf8 and utf8mb4, it works too; but if you have the same charset
* with different collations, e.g. utf8mb4_unicode_ci and utf8mb4_unicode_520_ci, it will fail
* with an 'Illegal mix of collations' error. That's why we need an optional COLLATE clause to fix this.
*/
private function needsCollationChange(): bool {
$this->ensureColumnCollation();
$collation1 = (string)$this->mailpoetEmailCollation;
$collation2 = (string)$this->wpPostmetaValueCollation;
if ($collation1 === $collation2) {
return false;
}
[$charset1] = explode('_', $collation1);
[$charset2] = explode('_', $collation2);
return $charset1 === $charset2;
}
private function markRegisteredCustomers(): void {
// Mark WP users having a customer role as WooCommerce subscribers
global $wpdb;
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
$this->connection->executeQuery("
UPDATE LOW_PRIORITY {$subscribersTable} mps
JOIN {$wpdb->users} wu ON mps.wp_user_id = wu.id
JOIN {$wpdb->usermeta} wpum ON wu.id = wpum.user_id AND wpum.meta_key = :capabilities
SET is_woocommerce_user = 1, source = :source
WHERE wpum.meta_value LIKE '%\"customer\"%'
", ['capabilities' => $wpdb->prefix . 'capabilities', 'source' => Source::WOOCOMMERCE_USER]);
}
private function insertSubscriberFromOrder(\WC_Order $wcOrder, string $status): ?string {
$email = $wcOrder->get_billing_email();
if (!$email || !$this->validator->validateEmail($email)) {
return null;
}
$this->insertSubscribers([$email], $status);
return $email;
}
/**
* @return array<string, int>
*/
private function insertSubscribersFromOrders(int $lastProcessedOrderId, int $batchSize): array {
global $wpdb;
$parameters = [
'lowestOrderId' => $lastProcessedOrderId,
'highestOrderId' => $lastProcessedOrderId + $batchSize,
];
$parametersType = [
'lowestOrderId' => ParameterType::INTEGER,
'highestOrderId' => ParameterType::INTEGER,
];
if ($this->woocommerceHelper->isWooCommerceCustomOrdersTableEnabled()) {
$ordersTable = $this->woocommerceHelper->getOrdersTableName();
$query = "SELECT id AS order_id, billing_email AS email
FROM `{$ordersTable}`
WHERE type = 'shop_order' AND billing_email != '' AND (id > :lowestOrderId AND id <= :highestOrderId)
ORDER BY id";
} else {
$query = "SELECT wpp.id AS order_id, wppm.meta_value AS email
FROM `{$wpdb->posts}` wpp
JOIN `{$wpdb->postmeta}` wppm ON wpp.ID = wppm.post_id AND wppm.meta_key = '_billing_email' AND wppm.meta_value != ''
WHERE wpp.post_type = 'shop_order'
AND (wpp.ID > :lowestOrderId AND wpp.ID <= :highestOrderId)
ORDER BY wpp.id";
}
$result = $this->connection->executeQuery($query, $parameters, $parametersType)->fetchAllAssociative();
$processedOrders = [];
foreach ($result as $item) {
if (!$this->validator->validateEmail($item['email'])) {
continue;
}
// because data in result are sorted by id, we can replace the previous order id
$processedOrders[(string)$item['email']] = (int)$item['order_id'];
}
if (count($processedOrders)) {
$this->insertSubscribers(array_keys($processedOrders));
}
return $processedOrders;
}
private function insertSubscribers(array $emails, string $status = SubscriberEntity::STATUS_SUBSCRIBED): int {
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
$subscribersValues = [];
$now = Carbon::now()->format('Y-m-d H:i:s');
$source = Source::WOOCOMMERCE_USER;
foreach ($emails as $email) {
/** @var string $email */
$email = $this->connection->quote($email);
$email = strval($email);
$subscribersValues[] = "(1, {$email}, '{$status}', '{$now}', '{$now}', '{$source}')";
}
// Save timestamp about changes before insert
$this->subscriberChangesNotifier->subscribersBatchUpdate();
// Update existing subscribers
$this->connection->executeQuery('
UPDATE ' . $subscribersTable . ' mps
SET mps.is_woocommerce_user = 1
WHERE mps.email IN (:emails)
', ['emails' => $emails], ['emails' => ArrayParameterType::STRING]);
// Save timestamp about new subscribers before insert
$this->subscriberChangesNotifier->subscribersBatchCreate();
// Insert new subscribers
$this->connection->executeQuery('
INSERT IGNORE INTO ' . $subscribersTable . ' (`is_woocommerce_user`, `email`, `status`, `created_at`, `last_subscribed_at`, `source`) VALUES
' . implode(',', $subscribersValues) . '
');
return count($emails);
}
/**
* @param array<string, int> $orders
*/
private function updateNames(array $orders): void {
global $wpdb;
if (!$orders) {
return;
}
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
if ($this->woocommerceHelper->isWooCommerceCustomOrdersTableEnabled()) {
$addressesTableName = $this->woocommerceHelper->getAddressesTableName();
$metaData = [];
$results = $this->connection->executeQuery(
"
SELECT order_id, first_name, last_name
FROM {$addressesTableName}
WHERE order_id IN (:orderIds) and address_type = 'billing'",
['orderIds' => array_values($orders)],
['orderIds' => ArrayParameterType::INTEGER]
)->fetchAllAssociative();
// format data in the same format that is used when querying wp_postmeta (see below).
foreach ($results as $result) {
$firstNameData['post_id'] = $result['order_id'];
$firstNameData['meta_key'] = '_billing_first_name';
$firstNameData['meta_value'] = $result['first_name'];
$metaData[] = $firstNameData;
$lastNameData['post_id'] = $result['order_id'];
$lastNameData['meta_key'] = '_billing_last_name';
$lastNameData['meta_value'] = $result['last_name'];
$metaData[] = $lastNameData;
}
} else {
$metaKeys = [
'_billing_first_name',
'_billing_last_name',
];
$metaData = $this->connection->executeQuery(
"
SELECT post_id, meta_key, meta_value
FROM {$wpdb->postmeta}
WHERE meta_key IN ('_billing_first_name', '_billing_last_name') AND post_id IN (:postIds)
",
['metaKeys' => $metaKeys, 'postIds' => array_values($orders)],
['metaKeys' => ArrayParameterType::STRING, 'postIds' => ArrayParameterType::INTEGER]
)->fetchAllAssociative();
}
$subscribersData = [];
foreach ($orders as $email => $postId) {
$subscribersData[$postId]['email'] = $email;
}
foreach ($metaData as $row) {
if (!$row['meta_value']) {
continue;
}
$subscribersData[$row['post_id']][$row['meta_key']] = $row['meta_value'];
}
$now = (Carbon::now())->format('Y-m-d H:i:s');
foreach ($subscribersData as $subscriber) {
$data = [];
$data['woocommerce_synced_at'] = $now;
if (!empty($subscriber['_billing_first_name'])) $data['first_name'] = $subscriber['_billing_first_name'];
if (!empty($subscriber['_billing_last_name'])) $data['last_name'] = $subscriber['_billing_last_name'];
$this->connection->update($subscribersTable, $data, ['email' => $subscriber['email']]);
}
}
private function insertUsersToSegment(): void {
$wcSegment = $this->segmentsRepository->getWooCommerceSegment();
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
$subscriberSegmentsTable = $this->entityManager->getClassMetadata(SubscriberSegmentEntity::class)->getTableName();
// Subscribe WC users to segment
$this->connection->executeQuery(
"
INSERT IGNORE INTO {$subscriberSegmentsTable} (subscriber_id, segment_id, created_at)
SELECT id, :segmentId, CURRENT_TIMESTAMP()
FROM {$subscribersTable}
WHERE is_woocommerce_user = 1
",
['segmentId' => $wcSegment->getId()],
['segmentId' => ParameterType::INTEGER]
);
}
private function unsubscribeUsersFromSegment(): void {
$wcSegment = $this->segmentsRepository->getWooCommerceSegment();
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
$subscriberSegmentsTable = $this->entityManager->getClassMetadata(SubscriberSegmentEntity::class)->getTableName();
// Unsubscribe non-WC or invalid users from segment
$this->connection->executeQuery(
"
DELETE mpss FROM {$subscriberSegmentsTable} mpss
LEFT JOIN {$subscribersTable} mps ON mpss.subscriber_id = mps.id
WHERE mpss.segment_id = :segmentId AND (mps.is_woocommerce_user = 0 OR mps.email = '' OR mps.email IS NULL)
",
['segmentId' => $wcSegment->getId()],
['segmentId' => ParameterType::INTEGER]
);
}
private function updateGlobalStatus(): void {
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
$subscriberSegmentsTable = $this->entityManager->getClassMetadata(SubscriberSegmentEntity::class)->getTableName();
$wcSegment = $this->segmentsRepository->getWooCommerceSegment();
// Set global status unsubscribed to all woocommerce users without any segment
$this->connection->executeQuery(
"
UPDATE {$subscribersTable} mps
LEFT JOIN {$subscriberSegmentsTable} mpss ON mpss.subscriber_id = mps.id
SET mps.status = :statusUnsubscribed
WHERE mpss.id IS NULL
AND mps.is_woocommerce_user = 1
",
['statusUnsubscribed' => SubscriberEntity::STATUS_UNSUBSCRIBED],
['statusUnsubscribed' => ParameterType::INTEGER]
);
// SET global status unsubscribed to all woocommerce users who have only 1 segment and it is woocommerce segment and they are not subscribed
// You can't specify target table 'mps' for update in FROM clause
$this->connection->executeQuery(
"
UPDATE {$subscribersTable} mps
JOIN {$subscriberSegmentsTable} mpss ON mps.id = mpss.subscriber_id AND mpss.segment_id = :segmentId AND mpss.status = :statusUnsubscribed
SET mps.status = :statusUnsubscribed
WHERE mps.id IN (
SELECT s.id -- get all subscribers with exactly 1 segment
FROM (SELECT id FROM {$subscribersTable} WHERE is_woocommerce_user = 1) s
JOIN {$subscriberSegmentsTable} ss on s.id = ss.subscriber_id
GROUP BY s.id
HAVING COUNT(ss.id) = 1
)
",
['statusUnsubscribed' => SubscriberEntity::STATUS_UNSUBSCRIBED, 'segmentId' => $wcSegment->getId()],
['statusUnsubscribed' => ParameterType::STRING, 'segmentId' => ParameterType::INTEGER]
);
}
private function removeOrphanedSubscribers(): void {
// Remove orphaned WooCommerce segment subscribers (not having a matching WC customer email),
// e.g. if WC orders were deleted directly from the database
// or a customer role was revoked and a user has no orders
global $wpdb;
$wcSegment = $this->segmentsRepository->getWooCommerceSegment();
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
$subscriberSegmentsTable = $this->entityManager->getClassMetadata(SubscriberSegmentEntity::class)->getTableName();
// Unmark registered customers
// Insert WC customer IDs to a temporary table for left join to use an index
$tmpTableName = Env::$dbPrefix . 'tmp_wc_ids';
// Registered users with orders
if ($this->woocommerceHelper->isWooCommerceCustomOrdersTableEnabled()) {
$ordersTable = $this->woocommerceHelper->getOrdersTableName();
$registeredCustomersSubQuery = "SELECT DISTINCT customer_id AS id FROM `{$ordersTable}` WHERE type = 'shop_order'";
} else {
$registeredCustomersSubQuery = "SELECT DISTINCT wppm.meta_value AS id FROM {$wpdb->postmeta} wppm
JOIN {$wpdb->posts} wpp ON wppm.post_id = wpp.ID
AND wpp.post_type = 'shop_order'
WHERE wppm.meta_key = '_customer_user'";
}
$this->connection->executeQuery("
CREATE TEMPORARY TABLE {$tmpTableName}
(`id` int(11) unsigned NOT NULL, UNIQUE(`id`), PRIMARY KEY (`id`)) AS
{$registeredCustomersSubQuery}
");
// Registered users with a customer role
$this->connection->executeQuery("
INSERT IGNORE INTO {$tmpTableName}
SELECT DISTINCT wpum.user_id AS id FROM {$wpdb->usermeta} wpum
WHERE wpum.meta_key = :capabilities AND wpum.meta_value LIKE '%\"customer\"%'
", ['capabilities' => $wpdb->prefix . 'capabilities']);
// Unmark WC list registered users which aren't WC customers anymore
$subQb = $this->connection->createQueryBuilder();
$subQb->select('mps.id')
->from($subscribersTable, 'mps')
->join('mps', $subscriberSegmentsTable, 'mpss', 'mps.id = mpss.subscriber_id AND mpss.segment_id = :segmentId')
->leftJoin('mps', $tmpTableName, 'wctmp', 'mps.wp_user_id = wctmp.id')
->where('mps.is_woocommerce_user = 1')
->andWhere('wctmp.id IS NULL')
->andWhere('mps.wp_user_id IS NOT NULL');
$qb = $this->connection->createQueryBuilder();
$qb->update($subscribersTable)
->set('is_woocommerce_user', '0')
->where("id IN (SELECT id FROM ({$subQb->getSQL()}) AS sq) ")
->setParameter('segmentId', $wcSegment->getId());
$qb->execute();
$this->connection->executeQuery("DROP TABLE {$tmpTableName}");
// Remove guest customers
// Insert WC customer emails to a temporary table and ensure matching collations
// between MailPoet and WooCommerce emails for left join to use an index
$tmpTableName = Env::$dbPrefix . 'tmp_wc_emails';
if ($this->needsCollationChange()) {
$collation = "COLLATE $this->mailpoetEmailCollation";
} else {
$collation = "COLLATE $this->wpPostmetaValueCollation";
}
if ($this->woocommerceHelper->isWooCommerceCustomOrdersTableEnabled()) {
$ordersTable = $this->woocommerceHelper->getOrdersTableName();
$guestCustomersSubQuery = "SELECT DISTINCT billing_email AS email FROM `{$ordersTable}` WHERE type = 'shop_order' AND billing_email IS NOT NULL AND billing_email != ''";
} else {
$guestCustomersSubQuery = "SELECT DISTINCT wppm.meta_value AS email FROM {$wpdb->postmeta} wppm
JOIN {$wpdb->posts} wpp ON wppm.post_id = wpp.ID
AND wpp.post_type = 'shop_order'
WHERE wppm.meta_key = '_billing_email'";
}
$this->connection->executeQuery("
CREATE TEMPORARY TABLE {$tmpTableName}
(`email` varchar(150) NOT NULL, UNIQUE(`email`), PRIMARY KEY (`email`)) {$collation}
{$guestCustomersSubQuery}
");
// Remove WC list guest users which aren't WC customers anymore
$subQb = $this->connection->createQueryBuilder();
$subQb->select('mps.id')
->from($subscribersTable, 'mps')
->join('mps', $subscriberSegmentsTable, 'mpss', 'mps.id = mpss.subscriber_id AND mpss.segment_id = :segmentId')
->leftJoin('mps', $tmpTableName, 'wctmp', 'mps.email = wctmp.email')
->where('mps.is_woocommerce_user = 1')
->andWhere('wctmp.email IS NULL')
->andWhere('mps.wp_user_id IS NULL');
$qb = $this->connection->createQueryBuilder();
$qb->delete($subscribersTable)
->where("id IN (SELECT id FROM ({$subQb->getSQL()}) AS sq) ")
->setParameter('segmentId', $wcSegment->getId());
$qb->execute();
$this->connection->executeQuery("DROP TABLE {$tmpTableName}");
}
private function updateStatus(): void {
$subscribeOldCustomers = $this->settings->get('mailpoet_subscribe_old_woocommerce_customers.enabled', false);
if ($subscribeOldCustomers !== "1") {
$status = SubscriberEntity::STATUS_UNSUBSCRIBED;
} else {
$status = SubscriberEntity::STATUS_SUBSCRIBED;
}
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
$subscriberSegmentsTable = $this->entityManager->getClassMetadata(SubscriberSegmentEntity::class)->getTableName();
$wcSegment = $this->segmentsRepository->getWooCommerceSegment();
$this->connection->executeQuery(
"
UPDATE LOW_PRIORITY {$subscriberSegmentsTable} AS mpss
JOIN {$subscribersTable} AS mps ON mpss.subscriber_id = mps.id
SET mpss.status = :status
WHERE
mpss.segment_id = :segmentId
AND mps.confirmed_at IS NULL
AND mps.confirmed_ip IS NULL
AND mps.is_woocommerce_user = 1
",
['status' => $status, 'segmentId' => $wcSegment->getId()],
['status' => ParameterType::STRING, 'segmentId' => ParameterType::INTEGER]
);
}
}
@@ -0,0 +1 @@
<?php