Files
piratepoet/mailpoet/lib/Subscribers/SubscriberListingRepository.php
John Oleksowicz cf78783ef3 Include empty segments in subscribers filters
Although it doesn't make senses to select a segment with 0 subscribers
directly from the dropdown, we allow users to click a "view subscribers"
link or, with MAILPOET-4244, click a badge to see all the subscribers of
 that segment. The UI on the subscribers page indicates which segment is
  selected by selecting it in the dropdown.

 If we exclude lists with 0 subscribers (or 0 calculated
 subscribers), the filter selection is blank and the user has no way of
 knowing what filter is being applied. By including these empty lists
 we're giving users a way of knowing why there are 0 subscribers being
 shown.

MAILPOET-4244
2022-06-13 12:37:42 +02:00

369 lines
13 KiB
PHP

<?php declare(strict_types = 1);
namespace MailPoet\Subscribers;
use MailPoet\Entities\SegmentEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Listing\ListingDefinition;
use MailPoet\Listing\ListingRepository;
use MailPoet\Segments\DynamicSegments\FilterHandler;
use MailPoet\Segments\SegmentSubscribersRepository;
use MailPoet\Util\Helpers;
use MailPoet\WP\Functions as WPFunctions;
use MailPoetVendor\Doctrine\DBAL\Driver\Statement;
use MailPoetVendor\Doctrine\DBAL\Query\QueryBuilder as DBALQueryBuilder;
use MailPoetVendor\Doctrine\ORM\EntityManager;
use MailPoetVendor\Doctrine\ORM\Query\Expr\Join;
use MailPoetVendor\Doctrine\ORM\QueryBuilder;
class SubscriberListingRepository extends ListingRepository {
public const FILTER_WITHOUT_LIST = 'without-list';
const DEFAULT_SORT_BY = 'createdAt';
private static $supportedStatuses = [
SubscriberEntity::STATUS_SUBSCRIBED,
SubscriberEntity::STATUS_UNSUBSCRIBED,
SubscriberEntity::STATUS_INACTIVE,
SubscriberEntity::STATUS_BOUNCED,
SubscriberEntity::STATUS_UNCONFIRMED,
];
/** @var FilterHandler */
private $dynamicSegmentsFilter;
/** @var EntityManager */
private $entityManager;
/** @var SegmentSubscribersRepository */
private $segmentSubscribersRepository;
/** @var SubscribersCountsController */
private $subscribersCountsController;
public function __construct(
EntityManager $entityManager,
FilterHandler $dynamicSegmentsFilter,
SegmentSubscribersRepository $segmentSubscribersRepository,
SubscribersCountsController $subscribersCountsController
) {
parent::__construct($entityManager);
$this->dynamicSegmentsFilter = $dynamicSegmentsFilter;
$this->entityManager = $entityManager;
$this->segmentSubscribersRepository = $segmentSubscribersRepository;
$this->subscribersCountsController = $subscribersCountsController;
}
public function getData(ListingDefinition $definition): array {
$dynamicSegment = $this->getDynamicSegmentFromFilters($definition);
if ($dynamicSegment === null) {
return parent::getData($definition);
}
return $this->getDataForDynamicSegment($definition, $dynamicSegment);
}
public function getCount(ListingDefinition $definition): int {
$dynamicSegment = $this->getDynamicSegmentFromFilters($definition);
if ($dynamicSegment === null) {
return parent::getCount($definition);
}
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
$subscribersIdsQuery = $this->entityManager
->getConnection()
->createQueryBuilder()
->select("count(DISTINCT $subscribersTable.id)")
->from($subscribersTable);
$subscribersIdsQuery = $this->applyConstraintsForDynamicSegment($subscribersIdsQuery, $definition, $dynamicSegment);
return (int)$subscribersIdsQuery->execute()->fetchColumn();
}
public function getActionableIds(ListingDefinition $definition): array {
$ids = $definition->getSelection();
if (!empty($ids)) {
return $ids;
}
$dynamicSegment = $this->getDynamicSegmentFromFilters($definition);
if ($dynamicSegment === null) {
return parent::getActionableIds($definition);
}
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
$subscribersIdsQuery = $this->entityManager
->getConnection()
->createQueryBuilder()
->select("DISTINCT $subscribersTable.id")
->from($subscribersTable);
$subscribersIdsQuery = $this->applyConstraintsForDynamicSegment($subscribersIdsQuery, $definition, $dynamicSegment);
$idsStatement = $subscribersIdsQuery->execute();
$result = $idsStatement->fetchAll();
return array_column($result, 'id');
}
protected function applySelectClause(QueryBuilder $queryBuilder) {
$queryBuilder->select("PARTIAL s.{id,email,firstName,lastName,status,createdAt,countConfirmations,wpUserId,isWoocommerceUser,engagementScore}");
}
protected function applyFromClause(QueryBuilder $queryBuilder) {
$queryBuilder->from(SubscriberEntity::class, 's');
}
protected function applyGroup(QueryBuilder $queryBuilder, string $group) {
// include/exclude deleted
if ($group === 'trash') {
$queryBuilder->andWhere('s.deletedAt IS NOT NULL');
} else {
$queryBuilder->andWhere('s.deletedAt IS NULL');
}
if (!in_array($group, self::$supportedStatuses)) {
return;
}
$queryBuilder
->andWhere('s.status = :status')
->setParameter('status', $group);
}
protected function applySearch(QueryBuilder $queryBuilder, string $search) {
$search = Helpers::escapeSearch($search);
$queryBuilder
->andWhere('s.email LIKE :search or s.firstName LIKE :search or s.lastName LIKE :search')
->setParameter('search', "%$search%");
}
protected function applyFilters(QueryBuilder $queryBuilder, array $filters) {
if (!isset($filters['segment'])) {
return;
}
if ($filters['segment'] === self::FILTER_WITHOUT_LIST) {
$this->segmentSubscribersRepository->addConstraintsForSubscribersWithoutSegment($queryBuilder);
return;
}
$segment = $this->entityManager->find(SegmentEntity::class, (int)$filters['segment']);
if (!$segment instanceof SegmentEntity) {
return;
}
if ($segment->isStatic()) {
$queryBuilder->join('s.subscriberSegments', 'ss', Join::WITH, 'ss.segment = :ssSegment')
->setParameter('ssSegment', $segment->getId());
return;
}
}
protected function applyParameters(QueryBuilder $queryBuilder, array $parameters) {
// nothing to do here
}
protected function applySorting(QueryBuilder $queryBuilder, string $sortBy, string $sortOrder) {
if (!$sortBy) {
$sortBy = self::DEFAULT_SORT_BY;
}
$queryBuilder->addOrderBy("s.$sortBy", $sortOrder);
}
public function getGroups(ListingDefinition $definition): array {
$queryBuilder = clone $this->queryBuilder;
$this->applyFromClause($queryBuilder);
$groupCounts = [
SubscriberEntity::STATUS_SUBSCRIBED => 0,
SubscriberEntity::STATUS_UNCONFIRMED => 0,
SubscriberEntity::STATUS_UNSUBSCRIBED => 0,
SubscriberEntity::STATUS_INACTIVE => 0,
SubscriberEntity::STATUS_BOUNCED => 0,
'trash' => 0,
];
foreach (array_keys($groupCounts) as $group) {
$groupDefinition = $group === $definition->getGroup() ? $definition : new ListingDefinition(
$group,
$definition->getFilters(),
$definition->getSearch(),
$definition->getParameters(),
$definition->getSortBy(),
$definition->getSortOrder(),
$definition->getOffset(),
$definition->getLimit(),
$definition->getSelection()
);
$groupCounts[$group] = $this->getCount($groupDefinition);
}
$trashedCount = $groupCounts['trash'];
unset($groupCounts['trash']);
$totalCount = (int)array_sum($groupCounts);
return [
[
'name' => 'all',
'label' => WPFunctions::get()->__('All', 'mailpoet'),
'count' => $totalCount,
],
[
'name' => SubscriberEntity::STATUS_SUBSCRIBED,
'label' => WPFunctions::get()->__('Subscribed', 'mailpoet'),
'count' => $groupCounts[SubscriberEntity::STATUS_SUBSCRIBED],
],
[
'name' => SubscriberEntity::STATUS_UNCONFIRMED,
'label' => WPFunctions::get()->__('Unconfirmed', 'mailpoet'),
'count' => $groupCounts[SubscriberEntity::STATUS_UNCONFIRMED],
],
[
'name' => SubscriberEntity::STATUS_UNSUBSCRIBED,
'label' => WPFunctions::get()->__('Unsubscribed', 'mailpoet'),
'count' => $groupCounts[SubscriberEntity::STATUS_UNSUBSCRIBED],
],
[
'name' => SubscriberEntity::STATUS_INACTIVE,
'label' => WPFunctions::get()->__('Inactive', 'mailpoet'),
'count' => $groupCounts[SubscriberEntity::STATUS_INACTIVE],
],
[
'name' => SubscriberEntity::STATUS_BOUNCED,
'label' => WPFunctions::get()->__('Bounced', 'mailpoet'),
'count' => $groupCounts[SubscriberEntity::STATUS_BOUNCED],
],
[
'name' => 'trash',
'label' => WPFunctions::get()->__('Trash', 'mailpoet'),
'count' => $trashedCount,
],
];
}
public function getFilters(ListingDefinition $definition): array {
$group = $definition->getGroup();
$subscribersWithoutSegmentStats = $this->subscribersCountsController->getSubscribersWithoutSegmentStatisticsCount();
$key = $group ?: 'all';
$subscribersWithoutSegmentCount = $subscribersWithoutSegmentStats[$key];
$subscribersWithoutSegmentLabel = sprintf(
__('Subscribers without a list (%s)', 'mailpoet'),
number_format((float)$subscribersWithoutSegmentCount)
);
$queryBuilder = clone $this->queryBuilder;
$queryBuilder
->select('s')
->from(SegmentEntity::class, 's');
if ($group === 'trash') {
$queryBuilder->andWhere('s.deletedAt IS NOT NULL');
} else {
$queryBuilder->andWhere('s.deletedAt IS NULL');
}
// format segment list
$allSubscribersList = [
'label' => WPFunctions::get()->__('All Lists', 'mailpoet'),
'value' => '',
];
$withoutSegmentList = [
'label' => $subscribersWithoutSegmentLabel,
'value' => self::FILTER_WITHOUT_LIST,
];
$segmentList = [];
foreach ($queryBuilder->getQuery()->getResult() as $segment) {
$key = $group ?: 'all';
if ($segment->isStatic()) {
$count = $this->subscribersCountsController->getSegmentGlobalStatusStatisticsCount($segment);
} else {
$count = $this->subscribersCountsController->getSegmentStatisticsCount($segment);
}
$segmentList[] = [
'label' => sprintf('%s (%s)', $segment->getName(), number_format((float)$count[$key])),
'value' => $segment->getId(),
];
}
usort($segmentList, function($a, $b) {
return strcasecmp($a['label'], $b['label']);
});
array_unshift($segmentList, $allSubscribersList, $withoutSegmentList);
return ['segment' => $segmentList];
}
private function getDataForDynamicSegment(ListingDefinition $definition, SegmentEntity $segment) {
$queryBuilder = clone $this->queryBuilder;
$sortBy = Helpers::underscoreToCamelCase($definition->getSortBy()) ?: self::DEFAULT_SORT_BY;
$this->applySelectClause($queryBuilder);
$this->applyFromClause($queryBuilder);
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
$subscribersIdsQuery = $this->entityManager
->getConnection()
->createQueryBuilder()
->select("DISTINCT $subscribersTable.id")
->from($subscribersTable);
$subscribersIdsQuery = $this->applyConstraintsForDynamicSegment($subscribersIdsQuery, $definition, $segment);
$subscribersIdsQuery->orderBy("$subscribersTable." . Helpers::camelCaseToUnderscore($sortBy), $definition->getSortOrder());
$subscribersIdsQuery->setFirstResult($definition->getOffset());
$subscribersIdsQuery->setMaxResults($definition->getLimit());
$idsStatement = $subscribersIdsQuery->execute();
// This shouldn't happen because execute on select SQL always returns Statement, but PHPStan doesn't know that
if (!$idsStatement instanceof Statement) {
$queryBuilder->andWhere('0 = 1');
return;
}
$result = $idsStatement->fetchAll();
$ids = array_column($result, 'id');
if (count($ids)) {
$queryBuilder->andWhere('s.id IN (:subscriberIds)')
->setParameter('subscriberIds', $ids);
} else {
$queryBuilder->andWhere('0 = 1'); // Don't return any subscribers if no ids found
}
$this->applySorting($queryBuilder, $sortBy, $definition->getSortOrder());
return $queryBuilder->getQuery()->getResult();
}
private function applyConstraintsForDynamicSegment(
DBALQueryBuilder $subscribersQuery,
ListingDefinition $definition,
SegmentEntity $segment
) {
// Apply dynamic segments filters
$subscribersQuery = $this->dynamicSegmentsFilter->apply($subscribersQuery, $segment);
// Apply group, search to fetch only necessary ids
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
if ($definition->getSearch()) {
$search = Helpers::escapeSearch((string)$definition->getSearch());
$subscribersQuery
->andWhere("$subscribersTable.email LIKE :search or $subscribersTable.first_name LIKE :search or $subscribersTable.last_name LIKE :search")
->setParameter('search', "%$search%");
}
if ($definition->getGroup()) {
if ($definition->getGroup() === 'trash') {
$subscribersQuery->andWhere("$subscribersTable.deleted_at IS NOT NULL");
} else {
$subscribersQuery->andWhere("$subscribersTable.deleted_at IS NULL");
}
if (in_array($definition->getGroup(), self::$supportedStatuses)) {
$subscribersQuery
->andWhere("$subscribersTable.status = :status")
->setParameter('status', $definition->getGroup());
}
}
return $subscribersQuery;
}
private function getDynamicSegmentFromFilters(ListingDefinition $definition): ?SegmentEntity {
$filters = $definition->getFilters();
if (!$filters || !isset($filters['segment'])) {
return null;
}
if ($filters['segment'] === self::FILTER_WITHOUT_LIST) {
return null;
}
$segment = $this->entityManager->find(SegmentEntity::class, (int)$filters['segment']);
if (!$segment instanceof SegmentEntity) {
return null;
}
return $segment->isStatic() ? null : $segment;
}
}