Files
piratepoet/mailpoet/lib/Subscribers/SubscriberListingRepository.php
David Remer ec987b133d Categorize (un)subscribed users of a list correctly
To evaluate whether a user is subscribed to a list or unsubscribed you need to take into account
the status of the current subscription and not only the global status. To be unsubscribed you are
either globally or per list unsubscribed. To be subscribed you need to be globally and per list
subscribed

[MAILPOET-4487]
2023-08-18 13:35:13 +02:00

440 lines
16 KiB
PHP

<?php declare(strict_types = 1);
namespace MailPoet\Subscribers;
use MailPoet\Entities\SegmentEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Entities\TagEntity;
use MailPoet\Listing\ListingDefinition;
use MailPoet\Listing\ListingRepository;
use MailPoet\Segments\DynamicSegments\FilterHandler;
use MailPoet\Segments\SegmentSubscribersRepository;
use MailPoet\Util\Helpers;
use MailPoetVendor\Doctrine\DBAL\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;
/** @var null | ListingDefinition */
private $definition = null;
public function __construct(
EntityManager $entityManager,
FilterHandler $dynamicSegmentsFilter,
SegmentSubscribersRepository $segmentSubscribersRepository,
SubscribersCountsController $subscribersCountsController
) {
parent::__construct($entityManager);
$this->dynamicSegmentsFilter = $dynamicSegmentsFilter;
$this->entityManager = $entityManager;
$this->segmentSubscribersRepository = $segmentSubscribersRepository;
$this->subscribersCountsController = $subscribersCountsController;
}
public function getData(ListingDefinition $definition): array {
$this->definition = $definition;
$dynamicSegment = $this->getDynamicSegmentFromFilters($definition);
if ($dynamicSegment === null) {
return parent::getData($definition);
}
return $this->getDataForDynamicSegment($definition, $dynamicSegment);
}
public function getCount(ListingDefinition $definition): int {
$this->definition = $definition;
$dynamicSegment = $this->getDynamicSegmentFromFilters($definition);
if ($dynamicSegment === null) {
return parent::getCount($definition);
}
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
$subscribersIdsQuery = $this->entityManager
->getConnection()
->createQueryBuilder()
->select("count(DISTINCT $subscribersTable.id)")
->from($subscribersTable);
$subscribersIdsQuery = $this->applyConstraintsForDynamicSegment($subscribersIdsQuery, $definition, $dynamicSegment);
return (int)$subscribersIdsQuery->execute()->fetchColumn();
}
public function getActionableIds(ListingDefinition $definition): array {
$this->definition = $definition;
$ids = $definition->getSelection();
if (!empty($ids)) {
return $ids;
}
$dynamicSegment = $this->getDynamicSegmentFromFilters($definition);
if ($dynamicSegment === null) {
return parent::getActionableIds($definition);
}
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
$subscribersIdsQuery = $this->entityManager
->getConnection()
->createQueryBuilder()
->select("DISTINCT $subscribersTable.id")
->from($subscribersTable);
$subscribersIdsQuery = $this->applyConstraintsForDynamicSegment($subscribersIdsQuery, $definition, $dynamicSegment);
$idsStatement = $subscribersIdsQuery->execute();
$result = $idsStatement->fetchAll();
return array_column($result, 'id');
}
protected function applySelectClause(QueryBuilder $queryBuilder) {
$queryBuilder->select("PARTIAL s.{id,email,firstName,lastName,status,createdAt,updatedAt,countConfirmations,wpUserId,isWoocommerceUser,engagementScore}");
}
protected function applyFromClause(QueryBuilder $queryBuilder) {
$queryBuilder->from(SubscriberEntity::class, 's');
}
protected function applyGroup(QueryBuilder $queryBuilder, string $group) {
// include/exclude deleted
if ($group === 'trash') {
$queryBuilder->andWhere('s.deletedAt IS NOT NULL');
} else {
$queryBuilder->andWhere('s.deletedAt IS NULL');
}
if (!in_array($group, self::$supportedStatuses)) {
return;
}
if (!in_array($group, [SubscriberEntity::STATUS_SUBSCRIBED, SubscriberEntity::STATUS_UNSUBSCRIBED])) {
$queryBuilder
->andWhere('s.status = :status')
->setParameter('status', $group);
return;
}
$segment = $this->definition && array_key_exists('segment', $this->definition->getFilters()) ? $this->entityManager->find(SegmentEntity::class, (int)$this->definition->getFilters()['segment']) : null;
if (!$segment instanceof SegmentEntity || !$segment->isStatic()) {
$queryBuilder
->andWhere('s.status = :status')
->setParameter('status', $group);
return;
}
$operator = $group === SubscriberEntity::STATUS_SUBSCRIBED ? 'AND' : 'OR';
$queryBuilder
->andWhere('(s.status = :status ' . $operator . ' ss.status = :status)')
->setParameter('status', $group);
}
protected function applySearch(QueryBuilder $queryBuilder, string $search) {
$search = Helpers::escapeSearch($search);
$queryBuilder
->andWhere('s.email LIKE :search or s.firstName LIKE :search or s.lastName LIKE :search')
->setParameter('search', "%$search%");
}
protected function applyFilters(QueryBuilder $queryBuilder, array $filters) {
if (isset($filters['segment'])) {
if ($filters['segment'] === self::FILTER_WITHOUT_LIST) {
$this->segmentSubscribersRepository->addConstraintsForSubscribersWithoutSegment($queryBuilder);
} else {
$segment = $this->entityManager->find(SegmentEntity::class, (int)$filters['segment']);
if ($segment instanceof SegmentEntity && $segment->isStatic()) {
$queryBuilder->join('s.subscriberSegments', 'ss', Join::WITH, 'ss.segment = :ssSegment')
->setParameter('ssSegment', $segment->getId());
}
}
}
// filtering by minimal updated at
if (isset($filters['minUpdatedAt']) && $filters['minUpdatedAt'] instanceof \DateTimeInterface) {
$queryBuilder->andWhere('s.updatedAt >= :updatedAt')
->setParameter('updatedAt', $filters['minUpdatedAt']);
}
if (isset($filters['tag'])) {
$tag = $this->entityManager->find(TagEntity::class, (int)$filters['tag']);
if ($tag) {
$queryBuilder->join('s.subscriberTags', 'st', Join::WITH, 'st.tag = :stTag')
->setParameter('stTag', $tag);
}
}
}
protected function applyParameters(QueryBuilder $queryBuilder, array $parameters) {
// nothing to do here
}
protected function applySorting(QueryBuilder $queryBuilder, string $sortBy, string $sortOrder) {
if (!$sortBy) {
$sortBy = self::DEFAULT_SORT_BY;
}
$queryBuilder->addOrderBy("s.$sortBy", $sortOrder);
}
public function getGroups(ListingDefinition $definition): array {
$queryBuilder = clone $this->queryBuilder;
$this->applyFromClause($queryBuilder);
$groupCounts = [
SubscriberEntity::STATUS_SUBSCRIBED => 0,
SubscriberEntity::STATUS_UNCONFIRMED => 0,
SubscriberEntity::STATUS_UNSUBSCRIBED => 0,
SubscriberEntity::STATUS_INACTIVE => 0,
SubscriberEntity::STATUS_BOUNCED => 0,
'trash' => 0,
];
foreach (array_keys($groupCounts) as $group) {
$groupDefinition = $group === $definition->getGroup() ? $definition : new ListingDefinition(
$group,
$definition->getFilters(),
$definition->getSearch(),
$definition->getParameters(),
$definition->getSortBy(),
$definition->getSortOrder(),
$definition->getOffset(),
$definition->getLimit(),
$definition->getSelection()
);
$groupCounts[$group] = $this->getCount($groupDefinition);
}
$trashedCount = $groupCounts['trash'];
unset($groupCounts['trash']);
$totalCount = (int)array_sum($groupCounts);
return [
[
'name' => 'all',
'label' => __('All', 'mailpoet'),
'count' => $totalCount,
],
[
'name' => SubscriberEntity::STATUS_SUBSCRIBED,
'label' => __('Subscribed', 'mailpoet'),
'count' => $groupCounts[SubscriberEntity::STATUS_SUBSCRIBED],
],
[
'name' => SubscriberEntity::STATUS_UNCONFIRMED,
'label' => __('Unconfirmed', 'mailpoet'),
'count' => $groupCounts[SubscriberEntity::STATUS_UNCONFIRMED],
],
[
'name' => SubscriberEntity::STATUS_UNSUBSCRIBED,
'label' => __('Unsubscribed', 'mailpoet'),
'count' => $groupCounts[SubscriberEntity::STATUS_UNSUBSCRIBED],
],
[
'name' => SubscriberEntity::STATUS_INACTIVE,
'label' => __('Inactive', 'mailpoet'),
'count' => $groupCounts[SubscriberEntity::STATUS_INACTIVE],
],
[
'name' => SubscriberEntity::STATUS_BOUNCED,
'label' => __('Bounced', 'mailpoet'),
'count' => $groupCounts[SubscriberEntity::STATUS_BOUNCED],
],
[
'name' => 'trash',
'label' => __('Trash', 'mailpoet'),
'count' => $trashedCount,
],
];
}
public function getFilters(ListingDefinition $definition): array {
return [
'segment' => $this->getSegmentFilter($definition),
'tag' => $this->getTagsFilter($definition),
];
}
/**
* @return array<array{label: string, value: string|int}>
*/
private function getSegmentFilter(ListingDefinition $definition): array {
$group = $definition->getGroup();
$subscribersWithoutSegmentStats = $this->subscribersCountsController->getSubscribersWithoutSegmentStatisticsCount();
$key = $group ?: 'all';
$subscribersWithoutSegmentCount = $subscribersWithoutSegmentStats[$key];
$subscribersWithoutSegmentLabel = sprintf(
// translators: %s is the number of subscribers without a list.
__('Subscribers without a list (%s)', 'mailpoet'),
number_format((float)$subscribersWithoutSegmentCount)
);
$queryBuilder = clone $this->queryBuilder;
$queryBuilder
->select('s')
->from(SegmentEntity::class, 's');
if ($group !== 'trash') {
$queryBuilder->andWhere('s.deletedAt IS NULL');
}
// format segment list
$allSubscribersList = [
'label' => __('All Lists', 'mailpoet'),
'value' => '',
];
$withoutSegmentList = [
'label' => $subscribersWithoutSegmentLabel,
'value' => self::FILTER_WITHOUT_LIST,
];
$segmentList = [];
foreach ($queryBuilder->getQuery()->getResult() as $segment) {
$key = $group ?: 'all';
if ($segment->isStatic()) {
$count = $this->subscribersCountsController->getSegmentGlobalStatusStatisticsCount($segment);
} else {
$count = $this->subscribersCountsController->getSegmentStatisticsCount($segment);
}
$subscribersCount = (float)$count[$key];
// filter segments without subscribers
if (!$subscribersCount) {
continue;
}
$segmentList[] = [
'label' => sprintf('%s (%s)', $segment->getName(), number_format($subscribersCount)),
'value' => $segment->getId(),
];
}
usort($segmentList, function($a, $b) {
return strcasecmp($a['label'], $b['label']);
});
array_unshift($segmentList, $allSubscribersList, $withoutSegmentList);
return $segmentList;
}
/**
* @return array<int, array{label: string, value: string|int}>
*/
private function getTagsFilter(ListingDefinition $definition): array {
$group = $definition->getGroup();
$allTagsList = [
'label' => __('All Tags', 'mailpoet'),
'value' => '',
];
$status = in_array($group, ['all', 'trash']) ? null : $group;
$isDeleted = $group === 'trash';
$tagsStatistics = $this->subscribersCountsController->getTagsStatisticsCount($status, $isDeleted);
$tagsList = [];
foreach ($tagsStatistics as $tagStatistics) {
$tagsList[] = [
'label' => sprintf('%s (%s)', $tagStatistics['name'], number_format((float)$tagStatistics['subscribersCount'])),
'value' => $tagStatistics['id'],
];
}
array_unshift($tagsList, $allTagsList);
return $tagsList;
}
private function getDataForDynamicSegment(ListingDefinition $definition, SegmentEntity $segment) {
$queryBuilder = clone $this->queryBuilder;
$sortBy = Helpers::underscoreToCamelCase($definition->getSortBy()) ?: self::DEFAULT_SORT_BY;
$this->applySelectClause($queryBuilder);
$this->applyFromClause($queryBuilder);
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
$subscribersIdsQuery = $this->entityManager
->getConnection()
->createQueryBuilder()
->select("DISTINCT $subscribersTable.id")
->from($subscribersTable);
$subscribersIdsQuery = $this->applyConstraintsForDynamicSegment($subscribersIdsQuery, $definition, $segment);
$subscribersIdsQuery->orderBy("$subscribersTable." . Helpers::camelCaseToUnderscore($sortBy), $definition->getSortOrder());
$subscribersIdsQuery->setFirstResult($definition->getOffset());
$subscribersIdsQuery->setMaxResults($definition->getLimit());
$idsStatement = $subscribersIdsQuery->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;
}
}