PluginProbe ʕ •ᴥ•ʔ
MailPoet – Newsletters, Email Marketing, and Automation / 5.34.2
MailPoet – Newsletters, Email Marketing, and Automation v5.34.2
5.34.2 5.34.1 5.34.0 5.33.1 5.33.0 5.32.0 5.31.0 5.30.0 5.29.0 5.28.1 5.28.0 5.27.0 5.26.0 5.26.1 5.25.0 5.24.0 4.43.0 4.43.1 4.44.0 4.44.1 4.45.0 4.46.0 4.47.0 4.48.0 4.48.1 4.48.2 4.49.0 4.49.1 4.5.0 4.5.1 4.5.2 4.50.0 4.50.1 4.51.0 4.51.1 4.51.2 4.52.0 4.53.0 4.54.0 4.55.0 4.56.0 4.57.0 4.58.0 4.58.1 4.58.2 4.6.0 4.6.1 4.6.2 4.7.0 4.7.1 4.8.0 4.8.1 4.9.0 5.0.0 5.0.1 5.0.2 5.1.0 5.1.1 5.10.0 5.10.1 5.11.0 5.12.0 5.12.1 5.12.10 5.12.11 5.12.12 5.12.13 5.12.2 5.12.3 5.12.4 5.12.5 5.12.6 5.12.7 5.12.8 5.12.9 5.13.0 5.13.1 5.13.2 5.14.0 5.14.1 5.14.2 5.14.3 5.15.0 5.15.1 5.16.0 5.16.1 5.16.2 5.16.3 5.16.4 5.17.0 5.17.1 5.17.2 5.17.3 5.17.4 5.17.5 5.17.6 5.18.0 5.19.0 5.2.0 5.2.1 5.2.2 5.2.3 5.20.0 5.21.0 5.21.1 5.21.2 5.21.3 5.22.0 5.22.1 5.22.2 5.22.3 5.22.4 5.23.0 5.23.1 5.23.2 5.3.0 5.3.1 5.3.2 5.3.3 5.3.4 5.3.5 5.3.6 5.3.7 5.4.0 5.4.1 5.4.2 5.5.0 5.5.1 5.5.2 5.6.0 5.6.1 5.6.2 5.6.3 5.6.4 5.7.0 5.7.1 5.8.0 5.8.1 5.9.0 3.0.0-beta.15 3.7.1 3.0.0-beta.16 3.7.2 3.0.0-beta.17 3.7.3 3.0.0-beta.18 3.7.4 3.0.0-beta.19 3.7.5 3.0.0-beta.2 3.7.6 3.0.0-beta.20 3.7.8 3.0.0-beta.21 3.70.0 3.0.0-beta.22 3.71.0 3.0.0-beta.23 3.71.1 3.0.0-beta.23.1 3.71.2 3.0.0-beta.23.2 3.71.3 3.0.0-beta.24 3.72.0 3.0.0-beta.25 3.73.0 3.0.0-beta.26 3.73.1 3.0.0-beta.27 3.73.2 3.0.0-beta.28 3.74.0 3.0.0-beta.29 3.74.1 3.0.0-beta.3 3.74.2 3.0.0-beta.30 3.74.3 3.0.0-beta.31 3.75.0 3.0.0-beta.32 3.75.1 3.0.0-beta.33 3.76.0 3.0.0-beta.33.1 3.77.0 3.0.0-beta.34.0.0 3.77.1 3.0.0-beta.36.0.0 3.78.0 3.0.0-beta.36.0.1 3.79.0 3.0.0-beta.36.2.0 3.8 3.0.0-beta.36.3.0 3.8.1 3.0.0-beta.36.3.1 3.8.2 3.0.0-beta.37.0.0 3.8.3 3.0.0-beta.4 3.8.4 3.0.0-beta.5 3.8.5 3.0.0-beta.6 3.8.6 3.0.0-beta.7 3.80.0 3.0.0-beta.7.1 3.81.0 3.0.0-beta.8 3.82.0 3.0.0-beta.9 3.83.0 3.0.0-rc.1.0.0 3.84.0 3.0.0-rc.1.0.1 3.84.1 3.0.0-rc.1.0.2 3.85.0 3.0.0-rc.1.0.3 3.85.1 3.0.0-rc.1.0.4 3.86.0 3.0.0-rc.2.0.0 3.87.0 3.0.0-rc.2.0.1 3.87.1 3.0.0-rc.2.0.2 3.87.2 3.0.0-rc.2.0.3 3.88.0 3.0.1 3.88.1 3.0.2 3.88.2 3.0.3 3.89.0 3.0.4 3.89.1 3.0.5 3.89.2 3.0.6 3.89.3 3.0.7 3.89.4 3.0.8 3.9.0 3.0.9 3.9.1 3.1.0 3.90.0 3.10 3.90.1 3.10.1 3.90.2 3.100.0 3.91.0 3.100.1 3.91.1 3.100.2 3.92.0 3.101.0 3.92.1 3.101.1 3.93.0 3.102.0 3.93.1 3.102.1 3.94.0 3.103.0 3.95.0 3.103.1 3.95.1 3.11.0 3.96.0 3.11.1 3.96.1 3.11.2 3.97.0 3.11.3 3.98.0 3.11.4 3.98.1 3.11.5 3.99.0 3.12.0 3.99.1 3.12.1 4.0.0 3.13.0 4.0.1 3.14.0 4.1.0 3.14.1 4.1.1 3.15.0 4.10.0 3.16.0 4.11.0 3.16.1 4.11.1 3.16.2 4.12.0 3.16.3 4.12.1 3.17.0 4.12.2 3.17.1 4.13.0 3.17.2 4.14.0 3.18.0 4.15.0 3.18.1 4.16.0 3.18.2 4.17.0 3.19.0 4.17.1 3.19.1 4.18.0 3.19.2 4.18.1 3.19.3 4.19.0 3.2.0 4.2.0 3.2.1 4.20.0 3.2.2 4.20.1 3.2.3 4.20.2 3.2.4 4.21.0 3.2.5 4.22.0 3.20.0 4.22.1 3.21.0 4.22.2 3.21.1 4.23.0 3.22.0 4.24.0 3.23.0 4.25.0 3.23.1 4.26.0 3.23.2 4.26.1 3.24.0 4.27.0 3.25.0 4.28.0 3.25.1 4.29.0 3.26.0 4.3.0 3.26.1 4.3.1 3.27.0 4.30.0 3.28.0 4.31.0 3.29.0 4.31.1 3.3.0 4.32.0 3.3.1 4.33.0 3.3.2 4.34.0 3.3.3 4.35.0 3.3.4 4.35.1 3.3.5 4.36.0 3.3.6 4.37.0 3.30.0 4.38.0 3.31.0 4.39.0 3.31.1 4.4.0 3.32.0 4.40.0 3.32.1 4.41.0 3.32.2 4.41.1 3.33.0 4.41.2 3.34.0 4.41.3 3.34.1 4.42.0 3.34.2 4.42.1 3.34.3 3.34.4 3.35.0 3.35.1 3.35.3 3.35.4 3.36.0 3.37.0 3.37.1 3.37.2 3.37.3 3.38.0 3.38.1 3.39.0 3.39.1 3.39.2 3.4.0 3.4.1 3.4.2 3.4.3 3.4.4 3.40.0 3.40.1 3.41.0 3.41.1 3.41.2 3.42.0 3.42.1 3.42.2 3.42.3 3.43.0 3.43.1 3.44.0 3.45.0 3.45.1 3.46.0 3.46.1 3.46.10 3.46.11 3.46.12 3.46.13 3.46.14 3.46.2 3.46.3 3.46.4 3.46.5 3.46.6 3.46.7 3.46.8 3.46.9 3.47.0 3.47.1 3.47.10 3.47.11 3.47.2 3.47.3 3.47.5 3.47.6 3.47.7 3.47.9 3.48.0 3.48.1 3.49.0 3.49.1 3.5.0 3.5.1 3.50.0 3.51.0 3.51.1 3.51.2 3.52.0 3.53.0 3.54.0 3.54.1 3.54.2 3.54.3 3.55.0 3.55.1 3.56.0 3.56.1 3.56.2 3.57.0 3.57.1 3.58.0 3.59.0 3.59.1 3.59.2 3.6.0 3.6.1 3.6.2 3.6.3 3.6.4 3.6.5 3.6.6 3.6.7 3.60.0 3.60.1 3.60.10 3.60.11 3.60.12 3.60.2 3.60.3 3.60.4 3.60.6 3.60.7 3.60.8 3.60.9 3.61.0 3.62.0 3.62.1 3.63.0 3.64.0 3.64.1 3.64.2 3.64.3 3.65.0 trunk 3.65.1 3.0.0 3.66.0 3.0.0-beta.1 3.67.0 3.0.0-beta.10 3.67.1 3.0.0-beta.11 3.68.0 3.0.0-beta.12 3.69.0 3.0.0-beta.13 3.69.1 3.0.0-beta.14 3.7.0
mailpoet / lib / Subscribers / SubscriberListingRepository.php
mailpoet / lib / Subscribers Last commit date
ConfirmationEmailTemplate 1 month ago ImportExport 2 weeks ago RestApi 3 days ago Statistics 1 month ago BulkActionController.php 3 days ago BulkActionException.php 2 months ago BulkConfirmationEmailResender.php 2 months ago ConfirmationEmailCustomizer.php 2 months ago ConfirmationEmailMailer.php 2 months ago ConfirmationEmailResolver.php 2 months ago EngagementDataBackfiller.php 2 months ago InactiveSubscribersController.php 5 days ago LinkTokens.php 2 months ago NewSubscriberNotificationMailer.php 2 months ago RequiredCustomFieldValidator.php 2 months ago SegmentsCountRecalculator.php 2 weeks ago Source.php 2 months ago SubscriberActions.php 2 months ago SubscriberCustomFieldRepository.php 3 years ago SubscriberIPsRepository.php 2 years ago SubscriberLimitNotificationEvaluator.php 2 months ago SubscriberLimitNotificationMailer.php 2 months ago SubscriberLimitNotificationScheduler.php 2 months ago SubscriberListingRepository.php 2 weeks ago SubscriberPersonalDataEraser.php 2 months ago SubscriberSaveController.php 3 days ago SubscriberSegmentRepository.php 2 weeks ago SubscriberSubscribeController.php 2 weeks ago SubscriberTagRepository.php 4 years ago SubscribersCountsController.php 2 weeks ago SubscribersEmailCountsController.php 2 weeks ago SubscribersRepository.php 3 days ago TrackingConsentController.php 5 days ago index.php 3 years ago
SubscriberListingRepository.php
1345 lines
1 <?php declare(strict_types = 1);
2
3 namespace MailPoet\Subscribers;
4
5 if (!defined('ABSPATH')) exit;
6
7
8 use MailPoet\Entities\SegmentEntity;
9 use MailPoet\Entities\StatisticsNewsletterEntity;
10 use MailPoet\Entities\SubscriberEntity;
11 use MailPoet\Entities\SubscriberSegmentEntity;
12 use MailPoet\Entities\SubscriberTagEntity;
13 use MailPoet\Entities\TagEntity;
14 use MailPoet\Listing\ListingDefinition;
15 use MailPoet\Listing\ListingRepository;
16 use MailPoet\Segments\DynamicSegments\FilterHandler;
17 use MailPoet\Segments\SegmentSubscribersRepository;
18 use MailPoet\Subscribers\Statistics\SubscriberStatisticsRepository;
19 use MailPoet\Util\Helpers;
20 use MailPoetVendor\Doctrine\DBAL\ArrayParameterType;
21 use MailPoetVendor\Doctrine\DBAL\ParameterType;
22 use MailPoetVendor\Doctrine\DBAL\Query\QueryBuilder as DBALQueryBuilder;
23 use MailPoetVendor\Doctrine\ORM\EntityManager;
24 use MailPoetVendor\Doctrine\ORM\Query\Expr\Join;
25 use MailPoetVendor\Doctrine\ORM\QueryBuilder;
26
27 class SubscriberListingRepository extends ListingRepository {
28 public const FILTER_WITHOUT_LIST = 'without-list';
29
30 const DEFAULT_SORT_BY = 'createdAt';
31
32 private const ENGAGEMENT_SCORE_UNKNOWN = 'unknown';
33 private const ENGAGEMENT_SCORE_DORMANT = 'dormant';
34 private const ENGAGEMENT_SCORE_LOW = 'low';
35 private const ENGAGEMENT_SCORE_GOOD = 'good';
36 private const ENGAGEMENT_SCORE_EXCELLENT = 'excellent';
37 private const ENGAGEMENT_SCORE_LOW_MAX = 20;
38 private const ENGAGEMENT_SCORE_GOOD_MIN = 20;
39 private const ENGAGEMENT_SCORE_GOOD_MAX = 50;
40 private const ENGAGEMENT_SCORE_EXCELLENT_MIN = 50;
41 private const BULK_RESEND_REASONS = [
42 'batch_limit',
43 'not_unconfirmed',
44 'deleted',
45 'max_confirmations_reached',
46 'recently_sent',
47 'too_old',
48 'outside_scope',
49 'not_found',
50 ];
51
52 private static $supportedStatuses = [
53 SubscriberEntity::STATUS_SUBSCRIBED,
54 SubscriberEntity::STATUS_UNSUBSCRIBED,
55 SubscriberEntity::STATUS_INACTIVE,
56 SubscriberEntity::STATUS_BOUNCED,
57 SubscriberEntity::STATUS_UNCONFIRMED,
58 ];
59
60 /** @var FilterHandler */
61 private $dynamicSegmentsFilter;
62
63 /** @var EntityManager */
64 private $entityManager;
65
66 /** @var SegmentSubscribersRepository */
67 private $segmentSubscribersRepository;
68
69 /** @var SubscribersCountsController */
70 private $subscribersCountsController;
71
72 /** @var null | ListingDefinition */
73 private $definition = null;
74
75 public function __construct(
76 EntityManager $entityManager,
77 FilterHandler $dynamicSegmentsFilter,
78 SegmentSubscribersRepository $segmentSubscribersRepository,
79 SubscribersCountsController $subscribersCountsController
80 ) {
81 parent::__construct($entityManager);
82 $this->dynamicSegmentsFilter = $dynamicSegmentsFilter;
83 $this->entityManager = $entityManager;
84 $this->segmentSubscribersRepository = $segmentSubscribersRepository;
85 $this->subscribersCountsController = $subscribersCountsController;
86 }
87
88 public function getData(ListingDefinition $definition): array {
89 $this->definition = $definition;
90 $dynamicSegment = $this->getDynamicSegmentFromFilters($definition);
91 if ($dynamicSegment === null) {
92 return parent::getData($definition);
93 }
94 return $this->getDataForDynamicSegment($definition, $dynamicSegment);
95 }
96
97 public function getCount(ListingDefinition $definition): int {
98 $this->definition = $definition;
99 $dynamicSegment = $this->getDynamicSegmentFromFilters($definition);
100 if ($dynamicSegment === null) {
101 return parent::getCount($definition);
102 }
103 $subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
104 $subscribersIdsQuery = $this->entityManager
105 ->getConnection()
106 ->createQueryBuilder()
107 ->select("count(DISTINCT $subscribersTable.id)")
108 ->from($subscribersTable);
109 $subscribersIdsQuery = $this->applyConstraintsForDynamicSegment($subscribersIdsQuery, $definition, $dynamicSegment);
110 return (int)$subscribersIdsQuery->execute()->fetchOne();
111 }
112
113 public function getActionableIds(ListingDefinition $definition): array {
114 $this->definition = $definition;
115 $ids = $definition->getSelection();
116 if (!empty($ids)) {
117 return $ids;
118 }
119 $dynamicSegment = $this->getDynamicSegmentFromFilters($definition);
120 if ($dynamicSegment === null) {
121 return parent::getActionableIds($definition);
122 }
123 $subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
124 $subscribersIdsQuery = $this->entityManager
125 ->getConnection()
126 ->createQueryBuilder()
127 ->select("DISTINCT $subscribersTable.id")
128 ->from($subscribersTable);
129 $subscribersIdsQuery = $this->applyConstraintsForDynamicSegment($subscribersIdsQuery, $definition, $dynamicSegment);
130 $idsStatement = $subscribersIdsQuery->execute();
131 $result = $idsStatement->fetchAll();
132 return array_column($result, 'id');
133 }
134
135 /**
136 * @return array{selected_count: int, eligible_count: int, queued_ids: int[], skipped_by_reason: array<string, int>}
137 */
138 public function getConfirmationEmailResendQueueData(
139 ListingDefinition $definition,
140 \DateTimeInterface $recentCutoff,
141 \DateTimeInterface $oldestLifecycleDate,
142 int $maxConfirmationEmails,
143 int $limit,
144 bool $hasExplicitSelection = false
145 ): array {
146 $selectedIds = $this->normalizeSelectedIds($definition->getSelection());
147 $skippedByReason = array_fill_keys(self::BULK_RESEND_REASONS, 0);
148 $base = $this->createBulkResendBaseQuery($definition);
149 $idColumn = $base['id_column'];
150
151 if ($hasExplicitSelection) {
152 if (!$selectedIds) {
153 $selectedCount = count($definition->getSelection());
154 $skippedByReason['not_found'] = $selectedCount;
155 return [
156 'selected_count' => $selectedCount,
157 'eligible_count' => 0,
158 'queued_ids' => [],
159 'skipped_by_reason' => $skippedByReason,
160 ];
161 }
162 $selectedCount = count($selectedIds);
163 $skippedByReason = $this->getExplicitSelectionScopeSkippedCounts($selectedIds, $skippedByReason);
164 $scopeSkippedCount = $skippedByReason['deleted'] + $skippedByReason['not_unconfirmed'] + $skippedByReason['not_found'];
165 $base['query']->andWhere("$idColumn IN (:selected_ids)")
166 ->setParameter('selected_ids', $selectedIds, ArrayParameterType::INTEGER);
167 } else {
168 $selectedCount = 0;
169 $scopeSkippedCount = 0;
170 }
171
172 $counts = $this->getBulkResendEligibilityCounts(clone $base['query'], $idColumn, $recentCutoff, $oldestLifecycleDate, $maxConfirmationEmails);
173 $inScopeCount = $counts['in_scope_count'];
174 if (!$hasExplicitSelection) {
175 $selectedCount = $inScopeCount;
176 }
177 $skippedByReason['max_confirmations_reached'] = $counts['max_confirmations_reached'];
178 $skippedByReason['recently_sent'] = $counts['recently_sent'];
179 $skippedByReason['too_old'] = $counts['too_old'];
180 $eligibleCount = $counts['eligible'];
181
182 $eligibleQuery = $this->addEligiblePredicates(clone $base['query'], $idColumn, $recentCutoff, $oldestLifecycleDate, $maxConfirmationEmails);
183 $queuedIds = $this->fetchBulkResendIds($eligibleQuery, $idColumn, $limit);
184 $skippedByReason['batch_limit'] = max(0, $eligibleCount - count($queuedIds));
185
186 if ($selectedIds) {
187 $skippedByReason['outside_scope'] += max(0, $selectedCount - $inScopeCount - $scopeSkippedCount);
188 }
189
190 return [
191 'selected_count' => $selectedCount,
192 'eligible_count' => $eligibleCount,
193 'queued_ids' => $queuedIds,
194 'skipped_by_reason' => $skippedByReason,
195 ];
196 }
197
198 /**
199 * @param int[] $selectedIds
200 * @param array<string, int> $skippedByReason
201 * @return array<string, int>
202 */
203 private function getExplicitSelectionScopeSkippedCounts(array $selectedIds, array $skippedByReason): array {
204 $subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
205 $rows = $this->entityManager->getConnection()->executeQuery(
206 "SELECT `id`, `status`, `deleted_at`
207 FROM $subscribersTable
208 WHERE `id` IN (:selected_ids)",
209 ['selected_ids' => $selectedIds],
210 ['selected_ids' => ArrayParameterType::INTEGER]
211 )->fetchAllAssociative();
212
213 $existingIds = [];
214 foreach ($rows as $row) {
215 $existingIds[] = $this->toInt($row['id'] ?? 0);
216 if (!empty($row['deleted_at'])) {
217 $skippedByReason['deleted']++;
218 } elseif (($row['status'] ?? null) !== SubscriberEntity::STATUS_UNCONFIRMED) {
219 $skippedByReason['not_unconfirmed']++;
220 }
221 }
222 $skippedByReason['not_found'] = count(array_diff($selectedIds, $existingIds));
223
224 return $skippedByReason;
225 }
226
227 /**
228 * @return array{query: DBALQueryBuilder, id_column: string}
229 */
230 private function createBulkResendBaseQuery(ListingDefinition $definition): array {
231 $dynamicSegment = $this->getDynamicSegmentFromFilters($definition);
232 if ($dynamicSegment instanceof SegmentEntity) {
233 $subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
234 $query = $this->entityManager->getConnection()->createQueryBuilder()
235 ->select("DISTINCT $subscribersTable.id")
236 ->from($subscribersTable);
237 $query = $this->applyConstraintsForDynamicSegment($query, $definition, $dynamicSegment);
238 return ['query' => $query, 'id_column' => "$subscribersTable.id"];
239 }
240
241 $query = $this->entityManager->getConnection()->createQueryBuilder();
242 $subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
243 $query->select('DISTINCT s.id')
244 ->from($subscribersTable, 's');
245
246 $this->applyBulkResendListingConstraints($query, $definition);
247 return ['query' => $query, 'id_column' => 's.id'];
248 }
249
250 private function applyBulkResendListingConstraints(DBALQueryBuilder $query, ListingDefinition $definition): void {
251 $group = $definition->getGroup();
252 if ($group === 'trash') {
253 $query->andWhere('s.deleted_at IS NOT NULL');
254 } else {
255 $query->andWhere('s.deleted_at IS NULL');
256 }
257 if ($group && in_array($group, self::$supportedStatuses, true)) {
258 $query->andWhere('s.status = :listing_status')
259 ->setParameter('listing_status', $group);
260 }
261
262 $search = $definition->getSearch();
263 if ($search && strlen(trim($search)) > 0) {
264 $search = Helpers::escapeSearch($search);
265 $query
266 ->andWhere('(s.email LIKE :search OR s.first_name LIKE :search OR s.last_name LIKE :search)')
267 ->setParameter('search', "%$search%");
268 }
269
270 $filters = $definition->getFilters();
271 if (isset($filters['segment'])) {
272 if ($filters['segment'] === self::FILTER_WITHOUT_LIST) {
273 $this->segmentSubscribersRepository->addConstraintsForSubscribersWithoutSegmentToDBAL($query);
274 } else {
275 $segment = $this->entityManager->find(SegmentEntity::class, (int)$filters['segment']);
276 if ($segment instanceof SegmentEntity && $segment->isStatic()) {
277 $subscriberSegmentsTable = $this->entityManager->getClassMetadata(SubscriberSegmentEntity::class)->getTableName();
278 $query->join('s', $subscriberSegmentsTable, 'ss', 'ss.subscriber_id = s.id AND ss.segment_id = :segment_id')
279 ->setParameter('segment_id', $segment->getId(), ParameterType::INTEGER);
280 }
281 }
282 }
283
284 if (isset($filters['tag'])) {
285 $tag = $this->entityManager->find(TagEntity::class, (int)$filters['tag']);
286 if ($tag instanceof TagEntity) {
287 $subscriberTagsTable = $this->entityManager->getClassMetadata(SubscriberTagEntity::class)->getTableName();
288 $query->join('s', $subscriberTagsTable, 'st', 'st.subscriber_id = s.id AND st.tag_id = :tag_id')
289 ->setParameter('tag_id', $tag->getId(), ParameterType::INTEGER);
290 }
291 }
292
293 if (isset($filters['minUpdatedAt']) && $filters['minUpdatedAt'] instanceof \DateTimeInterface) {
294 $query->andWhere('s.updated_at >= :updated_at')
295 ->setParameter('updated_at', $filters['minUpdatedAt']->format('Y-m-d H:i:s'), ParameterType::STRING);
296 }
297
298 $statusInclude = $this->sanitizeStatusFilter($filters['statusInclude'] ?? []);
299 if ($statusInclude) {
300 $query->andWhere('s.status IN (:status_include)')
301 ->setParameter('status_include', $statusInclude, ArrayParameterType::STRING);
302 }
303
304 $statusExclude = $this->sanitizeStatusFilter($filters['statusExclude'] ?? []);
305 if ($statusExclude) {
306 $query->andWhere('s.status NOT IN (:status_exclude)')
307 ->setParameter('status_exclude', $statusExclude, ArrayParameterType::STRING);
308 }
309
310 $createdAtFrom = $filters['createdAtFrom'] ?? null;
311 if ($createdAtFrom && is_string($createdAtFrom) && $this->isValidDateTime($createdAtFrom)) {
312 $query->andWhere('s.created_at >= :created_at_from')
313 ->setParameter('created_at_from', $createdAtFrom, ParameterType::STRING);
314 }
315
316 $createdAtTo = $filters['createdAtTo'] ?? null;
317 if ($createdAtTo && is_string($createdAtTo) && $this->isValidDateTime($createdAtTo)) {
318 $query->andWhere('s.created_at <= :created_at_to')
319 ->setParameter('created_at_to', $createdAtTo, ParameterType::STRING);
320 }
321
322 $engagementScoreInclude = $filters['engagementScoreInclude'] ?? [];
323 if (!empty($engagementScoreInclude)) {
324 $engagementScoreInclude = is_array($engagementScoreInclude) ? $engagementScoreInclude : [$engagementScoreInclude];
325 if (in_array(self::ENGAGEMENT_SCORE_DORMANT, $engagementScoreInclude, true)) {
326 $query->setParameter('engagement_score_recent_cutoff', (new \DateTimeImmutable('-1 year'))->format('Y-m-d H:i:s'), ParameterType::STRING);
327 }
328 $conditions = $this->getDbalEngagementScoreConditions($engagementScoreInclude);
329 if ($conditions) {
330 $query->andWhere('(' . implode(' OR ', $conditions) . ')');
331 }
332 }
333
334 $engagementScoreExclude = $filters['engagementScoreExclude'] ?? [];
335 if (!empty($engagementScoreExclude)) {
336 $engagementScoreExclude = is_array($engagementScoreExclude) ? $engagementScoreExclude : [$engagementScoreExclude];
337 if (in_array(self::ENGAGEMENT_SCORE_DORMANT, $engagementScoreExclude, true)) {
338 $query->setParameter('engagement_score_recent_cutoff', (new \DateTimeImmutable('-1 year'))->format('Y-m-d H:i:s'), ParameterType::STRING);
339 }
340 foreach ($engagementScoreExclude as $score) {
341 if ($score === self::ENGAGEMENT_SCORE_UNKNOWN) {
342 $query->andWhere('NOT ' . $this->getDbalUnknownEngagementScoreCondition());
343 } elseif ($score === self::ENGAGEMENT_SCORE_DORMANT) {
344 $query->andWhere('NOT ' . $this->getDbalDormantEngagementScoreCondition());
345 } elseif ($score === self::ENGAGEMENT_SCORE_LOW) {
346 $query->andWhere(sprintf('(s.engagement_score >= %d OR s.engagement_score IS NULL)', self::ENGAGEMENT_SCORE_LOW_MAX));
347 } elseif ($score === self::ENGAGEMENT_SCORE_GOOD) {
348 $query->andWhere(sprintf('(s.engagement_score < %d OR s.engagement_score >= %d OR s.engagement_score IS NULL)', self::ENGAGEMENT_SCORE_GOOD_MIN, self::ENGAGEMENT_SCORE_GOOD_MAX));
349 } elseif ($score === self::ENGAGEMENT_SCORE_EXCELLENT) {
350 $query->andWhere(sprintf('(s.engagement_score < %d OR s.engagement_score IS NULL)', self::ENGAGEMENT_SCORE_EXCELLENT_MIN));
351 }
352 }
353 }
354 }
355
356 /**
357 * @param mixed $statuses
358 * @return string[]
359 */
360 private function sanitizeStatusFilter($statuses): array {
361 $statuses = is_array($statuses) ? $statuses : [$statuses];
362 $statuses = array_filter($statuses, function($status) {
363 return is_string($status) && in_array($status, self::$supportedStatuses, true);
364 });
365 return array_values(array_unique($statuses));
366 }
367
368 /**
369 * @param mixed[] $scores
370 * @return string[]
371 */
372 private function getDbalEngagementScoreConditions(array $scores): array {
373 $conditions = [];
374 if (in_array(self::ENGAGEMENT_SCORE_UNKNOWN, $scores, true)) {
375 $conditions[] = $this->getDbalUnknownEngagementScoreCondition();
376 }
377 if (in_array(self::ENGAGEMENT_SCORE_DORMANT, $scores, true)) {
378 $conditions[] = $this->getDbalDormantEngagementScoreCondition();
379 }
380 if (in_array(self::ENGAGEMENT_SCORE_LOW, $scores, true)) {
381 $conditions[] = sprintf('(s.engagement_score < %d)', self::ENGAGEMENT_SCORE_LOW_MAX);
382 }
383 if (in_array(self::ENGAGEMENT_SCORE_GOOD, $scores, true)) {
384 $conditions[] = sprintf('(s.engagement_score >= %d AND s.engagement_score < %d)', self::ENGAGEMENT_SCORE_GOOD_MIN, self::ENGAGEMENT_SCORE_GOOD_MAX);
385 }
386 if (in_array(self::ENGAGEMENT_SCORE_EXCELLENT, $scores, true)) {
387 $conditions[] = sprintf('(s.engagement_score >= %d)', self::ENGAGEMENT_SCORE_EXCELLENT_MIN);
388 }
389 return $conditions;
390 }
391
392 private function getDbalUnknownEngagementScoreCondition(): string {
393 $lifetimeSentCount = $this->getDbalSentCountSubquery('s.id');
394 return sprintf(
395 '(s.engagement_score IS NULL AND %s < %d)',
396 $lifetimeSentCount,
397 SubscriberStatisticsRepository::MIN_SENT_EMAILS_FOR_ENGAGEMENT_SCORE
398 );
399 }
400
401 private function getDbalDormantEngagementScoreCondition(): string {
402 $lifetimeSentCount = $this->getDbalSentCountSubquery('s.id');
403 $recentSentCount = $this->getDbalSentCountSubquery('s.id', 'engagement_score_recent_cutoff');
404 return sprintf(
405 '(s.engagement_score IS NULL AND %s >= %d AND %s < %d)',
406 $lifetimeSentCount,
407 SubscriberStatisticsRepository::MIN_SENT_EMAILS_FOR_ENGAGEMENT_SCORE,
408 $recentSentCount,
409 SubscriberStatisticsRepository::MIN_SENT_EMAILS_FOR_ENGAGEMENT_SCORE
410 );
411 }
412
413 private function getDbalSentCountSubquery(string $subscriberIdColumn, ?string $sentAtParameter = null): string {
414 $statisticsNewslettersTable = $this->entityManager->getClassMetadata(StatisticsNewsletterEntity::class)->getTableName();
415 $sentAtCondition = $sentAtParameter ? " AND engagement_score_stats.sent_at >= :$sentAtParameter" : '';
416 return sprintf(
417 '(SELECT COUNT(DISTINCT engagement_score_stats.newsletter_id) FROM %s engagement_score_stats WHERE engagement_score_stats.subscriber_id = %s%s)',
418 $statisticsNewslettersTable,
419 $subscriberIdColumn,
420 $sentAtCondition
421 );
422 }
423
424 /**
425 * @return array{in_scope_count: int, max_confirmations_reached: int, recently_sent: int, too_old: int, eligible: int}
426 */
427 private function getBulkResendEligibilityCounts(
428 DBALQueryBuilder $query,
429 string $idColumn,
430 \DateTimeInterface $recentCutoff,
431 \DateTimeInterface $oldestLifecycleDate,
432 int $maxConfirmationEmails
433 ): array {
434 $countQuery = clone $query;
435 $countConfirmationColumn = $this->column($idColumn, 'count_confirmations');
436 $lastConfirmationEmailSentAtColumn = $this->column($idColumn, 'last_confirmation_email_sent_at');
437 $lifecycleDateExpression = 'COALESCE(' . $this->column($idColumn, 'last_subscribed_at') . ', ' . $this->column($idColumn, 'created_at') . ')';
438 $belowMaxConfirmations = "$countConfirmationColumn < :max_confirmation_emails";
439 $maxConfirmationsReached = "$countConfirmationColumn >= :max_confirmation_emails";
440 $recentlySent = "$lastConfirmationEmailSentAtColumn IS NOT NULL AND $lastConfirmationEmailSentAtColumn > :recent_cutoff";
441 $notRecentlySent = "($lastConfirmationEmailSentAtColumn IS NULL OR $lastConfirmationEmailSentAtColumn <= :recent_cutoff)";
442 $tooOld = "$lifecycleDateExpression < :oldest_lifecycle_date";
443 $notTooOld = "$lifecycleDateExpression >= :oldest_lifecycle_date";
444
445 $countQuery->select(implode(', ', [
446 "COUNT(DISTINCT $idColumn) AS in_scope_count",
447 "COUNT(DISTINCT CASE WHEN $maxConfirmationsReached THEN $idColumn END) AS max_confirmations_reached",
448 "COUNT(DISTINCT CASE WHEN $belowMaxConfirmations AND $recentlySent THEN $idColumn END) AS recently_sent",
449 "COUNT(DISTINCT CASE WHEN $belowMaxConfirmations AND $notRecentlySent AND $tooOld THEN $idColumn END) AS too_old",
450 "COUNT(DISTINCT CASE WHEN $belowMaxConfirmations AND $notRecentlySent AND $notTooOld THEN $idColumn END) AS eligible",
451 ]))
452 ->setParameter('max_confirmation_emails', $maxConfirmationEmails, ParameterType::INTEGER)
453 ->setParameter('recent_cutoff', $recentCutoff->format('Y-m-d H:i:s'), ParameterType::STRING)
454 ->setParameter('oldest_lifecycle_date', $oldestLifecycleDate->format('Y-m-d H:i:s'), ParameterType::STRING);
455
456 $row = $countQuery->executeQuery()->fetchAssociative() ?: [];
457 return [
458 'in_scope_count' => $this->toInt($row['in_scope_count'] ?? 0),
459 'max_confirmations_reached' => $this->toInt($row['max_confirmations_reached'] ?? 0),
460 'recently_sent' => $this->toInt($row['recently_sent'] ?? 0),
461 'too_old' => $this->toInt($row['too_old'] ?? 0),
462 'eligible' => $this->toInt($row['eligible'] ?? 0),
463 ];
464 }
465
466 /**
467 * @return int[]
468 */
469 private function fetchBulkResendIds(DBALQueryBuilder $query, string $idColumn, int $limit): array {
470 $query->select("DISTINCT $idColumn AS id")
471 ->orderBy($idColumn, 'ASC')
472 ->setMaxResults($limit);
473 return array_map(function($id): int {
474 return $this->toInt($id);
475 }, $query->executeQuery()->fetchFirstColumn());
476 }
477
478 private function addEligiblePredicates(DBALQueryBuilder $query, string $idColumn, \DateTimeInterface $recentCutoff, \DateTimeInterface $oldestLifecycleDate, int $maxConfirmationEmails): DBALQueryBuilder {
479 return $this->addNotTooOldPredicate(
480 $this->addNotRecentPredicate(
481 $this->addBelowMaxConfirmationPredicate($query, $idColumn, $maxConfirmationEmails),
482 $idColumn,
483 $recentCutoff
484 ),
485 $idColumn,
486 $oldestLifecycleDate
487 );
488 }
489
490 private function addBelowMaxConfirmationPredicate(DBALQueryBuilder $query, string $idColumn, int $maxConfirmationEmails): DBALQueryBuilder {
491 $query->andWhere($this->column($idColumn, 'count_confirmations') . ' < :max_confirmation_emails')
492 ->setParameter('max_confirmation_emails', $maxConfirmationEmails, ParameterType::INTEGER);
493 return $query;
494 }
495
496 private function addNotRecentPredicate(DBALQueryBuilder $query, string $idColumn, \DateTimeInterface $recentCutoff): DBALQueryBuilder {
497 $column = $this->column($idColumn, 'last_confirmation_email_sent_at');
498 $query->andWhere("($column IS NULL OR $column <= :recent_cutoff)")
499 ->setParameter('recent_cutoff', $recentCutoff->format('Y-m-d H:i:s'), ParameterType::STRING);
500 return $query;
501 }
502
503 private function addNotTooOldPredicate(DBALQueryBuilder $query, string $idColumn, \DateTimeInterface $oldestLifecycleDate): DBALQueryBuilder {
504 $query->andWhere('COALESCE(' . $this->column($idColumn, 'last_subscribed_at') . ', ' . $this->column($idColumn, 'created_at') . ') >= :oldest_lifecycle_date')
505 ->setParameter('oldest_lifecycle_date', $oldestLifecycleDate->format('Y-m-d H:i:s'), ParameterType::STRING);
506 return $query;
507 }
508
509 private function column(string $idColumn, string $column): string {
510 if ($idColumn === 's.id') {
511 return "s.$column";
512 }
513 $table = substr($idColumn, 0, -3);
514 return "$table.$column";
515 }
516
517 /**
518 * @param mixed[] $ids
519 * @return int[]
520 */
521 private function normalizeSelectedIds(array $ids): array {
522 $ids = array_map(function($id): int {
523 return $this->toInt($id);
524 }, $ids);
525 $ids = array_filter($ids, static function(int $id): bool {
526 return $id > 0;
527 });
528 return array_values(array_unique($ids));
529 }
530
531 private function toInt($value): int {
532 if (is_int($value)) {
533 return $value;
534 }
535 if (is_string($value) || is_float($value) || is_bool($value)) {
536 return (int)$value;
537 }
538 return 0;
539 }
540
541 protected function applySelectClause(QueryBuilder $queryBuilder) {
542 $queryBuilder->select("PARTIAL s.{id,email,firstName,lastName,status,createdAt,deletedAt,updatedAt,countConfirmations,wpUserId,isWoocommerceUser,engagementScore,lastSubscribedAt}");
543 }
544
545 protected function applyFromClause(QueryBuilder $queryBuilder) {
546 $queryBuilder->from(SubscriberEntity::class, 's');
547 }
548
549 protected function applyGroup(QueryBuilder $queryBuilder, string $group) {
550 // include/exclude deleted
551 if ($group === 'trash') {
552 $queryBuilder->andWhere('s.deletedAt IS NOT NULL');
553 } else {
554 $queryBuilder->andWhere('s.deletedAt IS NULL');
555 }
556
557 if (!in_array($group, self::$supportedStatuses)) {
558 return;
559 }
560
561 $staticSegment = $this->getStaticSegmentFromDefinition();
562
563 if (in_array($group, [SubscriberEntity::STATUS_SUBSCRIBED, SubscriberEntity::STATUS_UNSUBSCRIBED]) && $staticSegment) {
564 $operator = $group === SubscriberEntity::STATUS_SUBSCRIBED ? 'AND' : 'OR';
565 $queryBuilder
566 ->andWhere('(s.status = :status ' . $operator . ' ss.status = :status)')
567 ->setParameter('status', $group);
568 return;
569 }
570
571 $queryBuilder
572 ->andWhere('s.status = :status')
573 ->setParameter('status', $group);
574
575 // Under a static list, a member unsubscribed from THIS list belongs in the
576 // unsubscribed tab — not in inactive/unconfirmed/bounced. Excluding them here
577 // keeps the listed rows in step with the per-list statistics tab counts.
578 // ss.status is only ever subscribed/unsubscribed, so this is an equality
579 // (ss.status = subscribed) rather than != unsubscribed — same rows, but an
580 // equality seek the (segment_id, status, subscriber_id) index can use, and
581 // it mirrors the count query in SegmentSubscribersRepository.
582 if ($staticSegment) {
583 $queryBuilder
584 ->andWhere('ss.status = :ssSubscribed')
585 ->setParameter('ssSubscribed', SubscriberEntity::STATUS_SUBSCRIBED);
586 }
587 }
588
589 private function getStaticSegmentFromDefinition(): ?SegmentEntity {
590 if (!$this->definition) {
591 return null;
592 }
593 $filters = $this->definition->getFilters();
594 if (empty($filters['segment']) || $filters['segment'] === self::FILTER_WITHOUT_LIST) {
595 return null;
596 }
597 $segment = $this->entityManager->find(SegmentEntity::class, (int)$filters['segment']);
598 return ($segment instanceof SegmentEntity && $segment->isStatic()) ? $segment : null;
599 }
600
601 protected function applySearch(QueryBuilder $queryBuilder, string $search, array $parameters = []) {
602 $search = Helpers::escapeSearch($search);
603 $queryBuilder
604 ->andWhere('s.email LIKE :search or s.firstName LIKE :search or s.lastName LIKE :search')
605 ->setParameter('search', "%$search%");
606 }
607
608 protected function applyFilters(QueryBuilder $queryBuilder, array $filters) {
609 if (isset($filters['segment'])) {
610 if ($filters['segment'] === self::FILTER_WITHOUT_LIST) {
611 $this->segmentSubscribersRepository->addConstraintsForSubscribersWithoutSegment($queryBuilder);
612 } else {
613 $segment = $this->entityManager->find(SegmentEntity::class, (int)$filters['segment']);
614 if ($segment instanceof SegmentEntity && $segment->isStatic()) {
615 $queryBuilder->join('s.subscriberSegments', 'ss', Join::WITH, 'ss.segment = :ssSegment')
616 ->setParameter('ssSegment', $segment->getId());
617 }
618 }
619 }
620
621 // filtering by minimal updated at
622 if (isset($filters['minUpdatedAt']) && $filters['minUpdatedAt'] instanceof \DateTimeInterface) {
623 $queryBuilder->andWhere('s.updatedAt >= :updatedAt')
624 ->setParameter('updatedAt', $filters['minUpdatedAt']);
625 }
626
627 if (isset($filters['tag'])) {
628 $tag = $this->entityManager->find(TagEntity::class, (int)$filters['tag']);
629 if ($tag) {
630 $queryBuilder->join('s.subscriberTags', 'st', Join::WITH, 'st.tag = :stTag')
631 ->setParameter('stTag', $tag);
632 }
633 }
634
635 // Status inclusion filter
636 $statusInclude = $filters['statusInclude'] ?? [];
637 if (!empty($statusInclude)) {
638 $statusInclude = is_array($statusInclude) ? $statusInclude : [$statusInclude];
639 // Sanitize: only allow valid status values
640 $statusInclude = array_filter($statusInclude, function($status) {
641 return is_string($status) && in_array($status, self::$supportedStatuses, true);
642 });
643 if (!empty($statusInclude)) {
644 $queryBuilder->andWhere('s.status IN (:statusInclude)')
645 ->setParameter('statusInclude', $statusInclude);
646 }
647 }
648
649 // Status exclusion filter
650 $statusExclude = $filters['statusExclude'] ?? [];
651 if (!empty($statusExclude)) {
652 $statusExclude = is_array($statusExclude) ? $statusExclude : [$statusExclude];
653 // Sanitize: only allow valid status values
654 $statusExclude = array_filter($statusExclude, function($status) {
655 return is_string($status) && in_array($status, self::$supportedStatuses, true);
656 });
657 if (!empty($statusExclude)) {
658 $queryBuilder->andWhere('s.status NOT IN (:statusExclude)')
659 ->setParameter('statusExclude', $statusExclude);
660 }
661 }
662
663 // Filter by created_at date
664 $createdAtFrom = $filters['createdAtFrom'] ?? null;
665 if ($createdAtFrom && is_string($createdAtFrom) && $this->isValidDateTime($createdAtFrom)) {
666 $queryBuilder
667 ->andWhere('s.createdAt >= :createdAtFrom')
668 ->setParameter('createdAtFrom', $createdAtFrom);
669 }
670
671 $createdAtTo = $filters['createdAtTo'] ?? null;
672 if ($createdAtTo && is_string($createdAtTo) && $this->isValidDateTime($createdAtTo)) {
673 $queryBuilder
674 ->andWhere('s.createdAt <= :createdAtTo')
675 ->setParameter('createdAtTo', $createdAtTo);
676 }
677
678 // Filter by engagement score (include)
679 $engagementScoreInclude = $filters['engagementScoreInclude'] ?? [];
680 if (!empty($engagementScoreInclude)) {
681 $engagementScoreInclude = is_array($engagementScoreInclude) ? $engagementScoreInclude : [$engagementScoreInclude];
682 if (in_array(self::ENGAGEMENT_SCORE_DORMANT, $engagementScoreInclude, true)) {
683 $queryBuilder->setParameter('engagementScoreRecentCutoff', new \DateTimeImmutable('-1 year'));
684 }
685 $conditions = $this->getDqlEngagementScoreConditions($engagementScoreInclude, 'engagementScoreInclude');
686
687 if (!empty($conditions)) {
688 $queryBuilder->andWhere('(' . implode(' OR ', $conditions) . ')');
689 }
690 }
691
692 // Filter by engagement score (exclude)
693 $engagementScoreExclude = $filters['engagementScoreExclude'] ?? [];
694 if (!empty($engagementScoreExclude)) {
695 $engagementScoreExclude = is_array($engagementScoreExclude) ? $engagementScoreExclude : [$engagementScoreExclude];
696 if (in_array(self::ENGAGEMENT_SCORE_DORMANT, $engagementScoreExclude, true)) {
697 $queryBuilder->setParameter('engagementScoreRecentCutoff', new \DateTimeImmutable('-1 year'));
698 }
699
700 if (in_array(self::ENGAGEMENT_SCORE_UNKNOWN, $engagementScoreExclude, true)) {
701 $queryBuilder->andWhere('NOT ' . $this->getDqlUnknownEngagementScoreCondition('engagementScoreExclude'));
702 }
703 if (in_array(self::ENGAGEMENT_SCORE_DORMANT, $engagementScoreExclude, true)) {
704 $queryBuilder->andWhere('NOT ' . $this->getDqlDormantEngagementScoreCondition('engagementScoreExclude'));
705 }
706 if (in_array(self::ENGAGEMENT_SCORE_LOW, $engagementScoreExclude, true)) {
707 $queryBuilder->andWhere(sprintf(
708 '(s.engagementScore >= %d OR s.engagementScore IS NULL)',
709 self::ENGAGEMENT_SCORE_LOW_MAX
710 ));
711 }
712 if (in_array(self::ENGAGEMENT_SCORE_GOOD, $engagementScoreExclude, true)) {
713 $queryBuilder->andWhere(sprintf(
714 '(s.engagementScore < %d OR s.engagementScore >= %d OR s.engagementScore IS NULL)',
715 self::ENGAGEMENT_SCORE_GOOD_MIN,
716 self::ENGAGEMENT_SCORE_GOOD_MAX
717 ));
718 }
719 if (in_array(self::ENGAGEMENT_SCORE_EXCELLENT, $engagementScoreExclude, true)) {
720 $queryBuilder->andWhere(sprintf(
721 '(s.engagementScore < %d OR s.engagementScore IS NULL)',
722 self::ENGAGEMENT_SCORE_EXCELLENT_MIN
723 ));
724 }
725 }
726 }
727
728 /**
729 * @param mixed[] $scores
730 * @return string[]
731 */
732 private function getDqlEngagementScoreConditions(array $scores, string $aliasPrefix): array {
733 $conditions = [];
734 if (in_array(self::ENGAGEMENT_SCORE_UNKNOWN, $scores, true)) {
735 $conditions[] = $this->getDqlUnknownEngagementScoreCondition($aliasPrefix);
736 }
737 if (in_array(self::ENGAGEMENT_SCORE_DORMANT, $scores, true)) {
738 $conditions[] = $this->getDqlDormantEngagementScoreCondition($aliasPrefix);
739 }
740 if (in_array(self::ENGAGEMENT_SCORE_LOW, $scores, true)) {
741 $conditions[] = sprintf(
742 '(s.engagementScore < %d)',
743 self::ENGAGEMENT_SCORE_LOW_MAX
744 );
745 }
746 if (in_array(self::ENGAGEMENT_SCORE_GOOD, $scores, true)) {
747 $conditions[] = sprintf(
748 '(s.engagementScore >= %d AND s.engagementScore < %d)',
749 self::ENGAGEMENT_SCORE_GOOD_MIN,
750 self::ENGAGEMENT_SCORE_GOOD_MAX
751 );
752 }
753 if (in_array(self::ENGAGEMENT_SCORE_EXCELLENT, $scores, true)) {
754 $conditions[] = sprintf(
755 '(s.engagementScore >= %d)',
756 self::ENGAGEMENT_SCORE_EXCELLENT_MIN
757 );
758 }
759 return $conditions;
760 }
761
762 private function getDqlUnknownEngagementScoreCondition(string $aliasPrefix): string {
763 $lifetimeSentCount = $this->getDqlSentCountSubquery($aliasPrefix . 'TotalStats');
764 return sprintf(
765 '(s.engagementScore IS NULL AND %s < %d)',
766 $lifetimeSentCount,
767 SubscriberStatisticsRepository::MIN_SENT_EMAILS_FOR_ENGAGEMENT_SCORE
768 );
769 }
770
771 private function getDqlDormantEngagementScoreCondition(string $aliasPrefix): string {
772 $lifetimeSentCount = $this->getDqlSentCountSubquery($aliasPrefix . 'LifetimeStats');
773 $recentSentCount = $this->getDqlSentCountSubquery($aliasPrefix . 'RecentStats', 'engagementScoreRecentCutoff');
774 return sprintf(
775 '(s.engagementScore IS NULL AND %s >= %d AND %s < %d)',
776 $lifetimeSentCount,
777 SubscriberStatisticsRepository::MIN_SENT_EMAILS_FOR_ENGAGEMENT_SCORE,
778 $recentSentCount,
779 SubscriberStatisticsRepository::MIN_SENT_EMAILS_FOR_ENGAGEMENT_SCORE
780 );
781 }
782
783 private function getDqlSentCountSubquery(string $alias, ?string $sentAtParameter = null): string {
784 $sentAtCondition = $sentAtParameter ? " AND $alias.sentAt >= :$sentAtParameter" : '';
785 return sprintf(
786 '(SELECT COUNT(DISTINCT %s.newsletter) FROM %s %s WHERE %s.subscriber = s%s)',
787 $alias,
788 StatisticsNewsletterEntity::class,
789 $alias,
790 $alias,
791 $sentAtCondition
792 );
793 }
794
795 private function isValidDateTime(string $dateTime): bool {
796 try {
797 new \DateTime($dateTime);
798 return true;
799 } catch (\Exception $e) {
800 return false;
801 }
802 }
803
804 protected function applyParameters(QueryBuilder $queryBuilder, array $parameters) {
805 // nothing to do here
806 }
807
808 protected function applySorting(QueryBuilder $queryBuilder, string $sortBy, string $sortOrder) {
809 if (!$sortBy) {
810 $sortBy = self::DEFAULT_SORT_BY;
811 }
812 $queryBuilder->addOrderBy("s.$sortBy", $sortOrder);
813 if ($sortBy !== 'id') {
814 // Deterministic tiebreaker so pagination stays stable when the sorted
815 // column has duplicate values. created_at has per-second granularity, so
816 // large or imported lists tie often; pairing it with id also matches the
817 // deleted_at_created index (deleted_at, created_at + implicit id), which
818 // serves the default listing with the WHERE pinning deleted_at, keeping
819 // the sort index-backed.
820 $queryBuilder->addOrderBy('s.id', $sortOrder);
821 }
822 }
823
824 public function getGroups(ListingDefinition $definition): array {
825 return $this->formatGroups($this->getGroupCountsForDefinition($definition)['counts']);
826 }
827
828 /**
829 * Count and status groups from a single computation, so the listing endpoint
830 * can drop the separate getCount() query: on the non-segment path both come
831 * from one grouped scan, and the current group's count is read straight from
832 * the buckets.
833 *
834 * @return array{count: int, groups: array<int, array<string, mixed>>}
835 */
836 public function getCountAndGroups(ListingDefinition $definition): array {
837 $computed = $this->getGroupCountsForDefinition($definition);
838 return [
839 'count' => $this->countForCurrentGroup($definition, $computed['counts'], $computed['consolidated']),
840 'groups' => $this->formatGroups($computed['counts']),
841 ];
842 }
843
844 /**
845 * The status-tab counts, resolved through a three-tier cascade that prefers a
846 * cron-warmed cache and only computes live when nothing cheaper fits. Cheapest
847 * first:
848 *
849 * 1. Global cache — unfiltered "All Lists" view (no segment, no search, no
850 * other filter). Served from the cron-warmed global status counts.
851 * 2. Per-segment cache — a plain single-list filter and nothing else. Served
852 * from that segment's cron-warmed statistics.
853 * 3. Live fetch — anything narrower (search, tag, status, dates,
854 * engagement...). No cache matches, so the counts are computed now: a
855 * grouped scan on the non-segment path, or one count per status bucket
856 * when a segment redefines the buckets.
857 *
858 * Why a segment can't share the non-segment grouped scan: a static segment
859 * redefines the subscribed/unsubscribed buckets in terms of the per-list
860 * status (s.status OR/AND ss.status), and a dynamic segment routes counts
861 * through an id subquery — neither is a single GROUP BY over s.status, so they
862 * fall back to one count per group (still cheap: scoped to one segment).
863 *
864 * 'consolidated' promises that the per-status buckets partition the whole
865 * non-deleted population exactly, so their sum IS the "all" total and
866 * countForCurrentGroup can skip a separate count. It is true only for the
867 * global/grouped scan; the live per-status path leaves it false because those
868 * ad-hoc counts are not guaranteed to add up to "all" (e.g. a segment member
869 * whose status lands in no displayed bucket).
870 *
871 * @return array{counts: array<string, int>, consolidated: bool}
872 */
873 private function getGroupCountsForDefinition(ListingDefinition $definition): array {
874 if (!$this->hasSegmentListFilter($definition)) {
875 // The unfiltered "All Lists" view maps to the cron-warmed global status
876 // counts — the default, most-loaded view, served from cache instead of a
877 // grouped scan over every subscriber on each page load.
878 if ($this->canUseGlobalStatisticsCache($definition)) {
879 return ['counts' => $this->globalStatisticsToCounts(), 'consolidated' => true];
880 }
881 return ['counts' => $this->getGroupCounts($definition), 'consolidated' => true];
882 }
883
884 // A plain list filter (no search, no other filter) maps exactly to the
885 // cron-warmed per-segment statistics — single source of truth, served from
886 // cache instead of recomputing a count per status tab on every page load.
887 if ($this->canUseSegmentStatisticsCache($definition)) {
888 $segment = $this->getSegmentFromDefinition($definition);
889 if ($segment instanceof SegmentEntity) {
890 // 'consolidated' stays false here even though the buckets do partition
891 // the members exactly: countForCurrentGroup recognises this same case
892 // via canUseSegmentStatisticsCache($definition), so the "all" tab still
893 // takes the sum-of-buckets path. The two conditions are equivalent for
894 // this branch — the flag would be redundant, not contradictory.
895 return ['counts' => $this->segmentStatisticsToCounts($segment), 'consolidated' => false];
896 }
897 }
898
899 // Search or an extra filter narrows the list beyond plain membership, so the
900 // cache can't answer it — compute live for that ad-hoc query.
901 return ['counts' => $this->getGroupCountsPerStatus($definition), 'consolidated' => false];
902 }
903
904 /**
905 * The per-segment statistics cache reflects plain list membership and status.
906 * It can stand in for the tab counts only when nothing else narrows the set —
907 * no search and no other active filter (tag, status, dates, engagement).
908 */
909 private function canUseSegmentStatisticsCache(ListingDefinition $definition): bool {
910 if ($this->isSearchActive($definition)) {
911 return false;
912 }
913 $filters = $definition->getFilters() ?: [];
914 unset($filters['segment']);
915 foreach ($filters as $value) {
916 if (!empty($value)) {
917 return false;
918 }
919 }
920 return true;
921 }
922
923 /**
924 * Map the cached per-segment statistics onto the listing's status-keyed counts
925 * array. "all" is read in formatGroups/countForCurrentGroup as the sum of the
926 * status buckets, which equals the cached "all" because the buckets partition
927 * the non-deleted members exactly.
928 *
929 * @return array<string, int>
930 */
931 private function segmentStatisticsToCounts(SegmentEntity $segment): array {
932 $stats = $this->subscribersCountsController->getSegmentStatisticsCount($segment);
933 $counts = array_fill_keys(self::$supportedStatuses, 0);
934 $counts['trash'] = 0;
935 foreach (self::$supportedStatuses as $status) {
936 $counts[$status] = (int)($stats[$status] ?? 0);
937 }
938 $counts['trash'] = (int)($stats['trash'] ?? 0);
939 return $counts;
940 }
941
942 private function getSegmentFromDefinition(ListingDefinition $definition): ?SegmentEntity {
943 $filters = $definition->getFilters();
944 if (empty($filters['segment']) || $filters['segment'] === self::FILTER_WITHOUT_LIST) {
945 return null;
946 }
947 $segment = $this->entityManager->find(SegmentEntity::class, (int)$filters['segment']);
948 return $segment instanceof SegmentEntity ? $segment : null;
949 }
950
951 private function isSearchActive(ListingDefinition $definition): bool {
952 $search = $definition->getSearch();
953 return $search !== null && strlen(trim($search)) > 0;
954 }
955
956 /**
957 * The global status counts cover the unfiltered listing only — no search and
958 * no active filter at all. Any filter (tag, status, dates, engagement) narrows
959 * the set below "all subscribers", so the cache no longer matches.
960 */
961 private function canUseGlobalStatisticsCache(ListingDefinition $definition): bool {
962 $search = $definition->getSearch();
963 if ($search !== null && strlen(trim($search)) > 0) {
964 return false;
965 }
966 $filters = $definition->getFilters() ?: [];
967 foreach ($filters as $value) {
968 if (!empty($value)) {
969 return false;
970 }
971 }
972 return true;
973 }
974
975 /**
976 * @return array<string, int>
977 */
978 private function globalStatisticsToCounts(): array {
979 $stats = $this->subscribersCountsController->getGlobalStatusStatisticsCount();
980 $counts = array_fill_keys(self::$supportedStatuses, 0);
981 $counts['trash'] = 0;
982 foreach (self::$supportedStatuses as $status) {
983 $counts[$status] = (int)($stats[$status] ?? 0);
984 }
985 $counts['trash'] = (int)($stats['trash'] ?? 0);
986 return $counts;
987 }
988
989 /**
990 * The current group's count read from the precomputed buckets — identical to
991 * getCount($definition). The one case the buckets can't reproduce exactly is
992 * the "all" tab under a segment filter, where members pending in the list
993 * fall into no status bucket, so that defers to getCount.
994 *
995 * @param array<string, int> $counts
996 */
997 private function countForCurrentGroup(ListingDefinition $definition, array $counts, bool $consolidated): int {
998 $group = $definition->getGroup() ?: 'all';
999 if ($group === 'all') {
1000 // When the counts come from the consolidated scan or the per-segment
1001 // statistics cache, the status buckets partition the non-deleted members
1002 // exactly, so their sum is the "all" total — no extra query needed.
1003 if ($consolidated || $this->canUseSegmentStatisticsCache($definition)) {
1004 $total = 0;
1005 foreach (self::$supportedStatuses as $status) {
1006 $total += $counts[$status];
1007 }
1008 return $total;
1009 }
1010 return $this->getCount($definition);
1011 }
1012 return array_key_exists($group, $counts) ? $counts[$group] : $this->getCount($definition);
1013 }
1014
1015 /**
1016 * @param array<string, int> $counts
1017 * @return array<int, array<string, mixed>>
1018 */
1019 private function formatGroups(array $counts): array {
1020 $totalCount = 0;
1021 foreach (self::$supportedStatuses as $status) {
1022 $totalCount += $counts[$status];
1023 }
1024
1025 return [
1026 [
1027 'name' => 'all',
1028 'label' => __('All', 'mailpoet'),
1029 'count' => $totalCount,
1030 ],
1031 [
1032 'name' => SubscriberEntity::STATUS_SUBSCRIBED,
1033 'label' => __('Subscribed', 'mailpoet'),
1034 'count' => $counts[SubscriberEntity::STATUS_SUBSCRIBED],
1035 ],
1036 [
1037 'name' => SubscriberEntity::STATUS_UNCONFIRMED,
1038 'label' => __('Unconfirmed', 'mailpoet'),
1039 'count' => $counts[SubscriberEntity::STATUS_UNCONFIRMED],
1040 ],
1041 [
1042 'name' => SubscriberEntity::STATUS_UNSUBSCRIBED,
1043 'label' => __('Unsubscribed', 'mailpoet'),
1044 'count' => $counts[SubscriberEntity::STATUS_UNSUBSCRIBED],
1045 ],
1046 [
1047 'name' => SubscriberEntity::STATUS_INACTIVE,
1048 'label' => __('Inactive', 'mailpoet'),
1049 'count' => $counts[SubscriberEntity::STATUS_INACTIVE],
1050 ],
1051 [
1052 'name' => SubscriberEntity::STATUS_BOUNCED,
1053 'label' => __('Bounced', 'mailpoet'),
1054 'count' => $counts[SubscriberEntity::STATUS_BOUNCED],
1055 ],
1056 [
1057 'name' => 'trash',
1058 'label' => __('Trash', 'mailpoet'),
1059 'count' => $counts['trash'],
1060 ],
1061 ];
1062 }
1063
1064 /**
1065 * Count every status tab in a single grouped scan plus one trash count,
1066 * instead of a separate COUNT per group. Valid whenever each group's
1067 * predicate is a plain `s.status` (no static/dynamic segment list filter).
1068 *
1069 * @return array<string, int>
1070 */
1071 private function getGroupCounts(ListingDefinition $definition): array {
1072 $counts = array_fill_keys(self::$supportedStatuses, 0);
1073 $counts['trash'] = 0;
1074
1075 $statusQuery = $this->createGroupCountQueryBuilder($definition);
1076 $statusQuery
1077 ->andWhere('s.deletedAt IS NULL')
1078 ->select('s.status AS status, COUNT(DISTINCT s.id) AS subscribersCount')
1079 ->groupBy('s.status');
1080 foreach ($statusQuery->getQuery()->getResult() as $row) {
1081 if (array_key_exists($row['status'], $counts)) {
1082 $counts[$row['status']] = (int)$row['subscribersCount'];
1083 }
1084 }
1085
1086 $trashQuery = $this->createGroupCountQueryBuilder($definition);
1087 $trashQuery
1088 ->andWhere('s.deletedAt IS NOT NULL')
1089 ->select('COUNT(DISTINCT s.id)');
1090 $counts['trash'] = (int)$trashQuery->getQuery()->getSingleScalarResult();
1091
1092 return $counts;
1093 }
1094
1095 /**
1096 * Shared FROM + active filters/search for the group counts. Deliberately
1097 * skips applyGroup so a single query can bucket every status at once.
1098 */
1099 private function createGroupCountQueryBuilder(ListingDefinition $definition): QueryBuilder {
1100 $queryBuilder = clone $this->queryBuilder;
1101 $this->applyFromClause($queryBuilder);
1102
1103 $search = $definition->getSearch();
1104 if ($search !== null && strlen(trim($search)) > 0) {
1105 $this->applySearch($queryBuilder, $search, $definition->getParameters() ?: []);
1106 }
1107
1108 $filters = $definition->getFilters();
1109 if ($filters) {
1110 $this->applyFilters($queryBuilder, $filters);
1111 }
1112
1113 $parameters = $definition->getParameters();
1114 if ($parameters) {
1115 $this->applyParameters($queryBuilder, $parameters);
1116 }
1117
1118 return $queryBuilder;
1119 }
1120
1121 /**
1122 * Fallback for static/dynamic segment filters, where a group's predicate is
1123 * more than a plain `s.status`. One count per group, scoped to the segment.
1124 *
1125 * @return array<string, int>
1126 */
1127 private function getGroupCountsPerStatus(ListingDefinition $definition): array {
1128 $counts = array_fill_keys(self::$supportedStatuses, 0);
1129 $counts['trash'] = 0;
1130 foreach (array_keys($counts) as $group) {
1131 $groupDefinition = $group === $definition->getGroup() ? $definition : new ListingDefinition(
1132 $group,
1133 $definition->getFilters(),
1134 $definition->getSearch(),
1135 $definition->getParameters(),
1136 $definition->getSortBy(),
1137 $definition->getSortOrder(),
1138 $definition->getOffset(),
1139 $definition->getLimit(),
1140 $definition->getSelection()
1141 );
1142 $counts[$group] = $this->getCount($groupDefinition);
1143 }
1144 return $counts;
1145 }
1146
1147 private function hasSegmentListFilter(ListingDefinition $definition): bool {
1148 $filters = $definition->getFilters();
1149 if (empty($filters['segment']) || $filters['segment'] === self::FILTER_WITHOUT_LIST) {
1150 return false;
1151 }
1152 return $this->entityManager->find(SegmentEntity::class, (int)$filters['segment']) instanceof SegmentEntity;
1153 }
1154
1155 public function getFilters(ListingDefinition $definition): array {
1156 return [
1157 'segment' => $this->getSegmentFilter($definition),
1158 'tag' => $this->getTagsFilter($definition),
1159 ];
1160 }
1161
1162 /**
1163 * @return array<array{label: string, value: string|int}>
1164 */
1165 private function getSegmentFilter(ListingDefinition $definition): array {
1166 $group = $definition->getGroup();
1167
1168 $subscribersWithoutSegmentStats = $this->subscribersCountsController->getSubscribersWithoutSegmentStatisticsCount();
1169 $key = $group ?: 'all';
1170 $subscribersWithoutSegmentCount = $subscribersWithoutSegmentStats[$key];
1171
1172 $subscribersWithoutSegmentLabel = sprintf(
1173 // translators: %s is the number of subscribers without a list.
1174 __('Subscribers without a list (%s)', 'mailpoet'),
1175 number_format((float)$subscribersWithoutSegmentCount)
1176 );
1177
1178 $queryBuilder = clone $this->queryBuilder;
1179 $queryBuilder
1180 ->select('s')
1181 ->from(SegmentEntity::class, 's');
1182 if ($group !== 'trash') {
1183 $queryBuilder->andWhere('s.deletedAt IS NULL');
1184 }
1185
1186 // format segment list
1187 $allSubscribersList = [
1188 'label' => __('All Lists', 'mailpoet'),
1189 'value' => '',
1190 ];
1191
1192 $withoutSegmentList = [
1193 'label' => $subscribersWithoutSegmentLabel,
1194 'value' => self::FILTER_WITHOUT_LIST,
1195 ];
1196
1197 $segmentList = [];
1198 foreach ($queryBuilder->getQuery()->getResult() as $segment) {
1199 $key = $group ?: 'all';
1200 $count = $this->subscribersCountsController->getSegmentStatisticsCount($segment);
1201 $subscribersCount = (float)$count[$key];
1202 // filter segments without subscribers
1203 if (!$subscribersCount) {
1204 continue;
1205 }
1206 $segmentList[] = [
1207 'label' => sprintf('%s (%s)', $segment->getName(), number_format($subscribersCount)),
1208 'value' => $segment->getId(),
1209 ];
1210 }
1211
1212 usort($segmentList, function($a, $b) {
1213 return strcasecmp($a['label'], $b['label']);
1214 });
1215
1216 array_unshift($segmentList, $allSubscribersList, $withoutSegmentList);
1217 return $segmentList;
1218 }
1219
1220 /**
1221 * @return array<int, array{label: string, value: string|int}>
1222 */
1223 private function getTagsFilter(ListingDefinition $definition): array {
1224 $group = $definition->getGroup();
1225
1226 $allTagsList = [
1227 'label' => __('All Tags', 'mailpoet'),
1228 'value' => '',
1229 ];
1230
1231 $status = in_array($group, ['all', 'trash']) ? null : $group;
1232 $isDeleted = $group === 'trash';
1233 $tagsStatistics = $this->subscribersCountsController->getTagsStatisticsCount($status, $isDeleted);
1234
1235 $tagsList = [];
1236 foreach ($tagsStatistics as $tagStatistics) {
1237 $tagsList[] = [
1238 'label' => sprintf('%s (%s)', $tagStatistics['name'], number_format((float)$tagStatistics['subscribersCount'])),
1239 'value' => $tagStatistics['id'],
1240 ];
1241 }
1242
1243 array_unshift($tagsList, $allTagsList);
1244 return $tagsList;
1245 }
1246
1247 private function getDataForDynamicSegment(ListingDefinition $definition, SegmentEntity $segment) {
1248 $queryBuilder = clone $this->queryBuilder;
1249 $sortBy = Helpers::underscoreToCamelCase($definition->getSortBy()) ?: self::DEFAULT_SORT_BY;
1250 $sortBy = $this->getDynamicSegmentSortBy($sortBy);
1251 $sortOrder = $this->normalizeSortOrder($definition->getSortOrder());
1252 $this->applySelectClause($queryBuilder);
1253 $this->applyFromClause($queryBuilder);
1254
1255 $subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
1256 $subscribersIdsQuery = $this->entityManager
1257 ->getConnection()
1258 ->createQueryBuilder()
1259 ->select("DISTINCT $subscribersTable.id")
1260 ->from($subscribersTable);
1261 $subscribersIdsQuery = $this->applyConstraintsForDynamicSegment($subscribersIdsQuery, $definition, $segment);
1262 $subscribersIdsQuery->orderBy($this->getDynamicSegmentSortColumn($sortBy, $subscribersTable), $sortOrder);
1263 if ($sortBy !== 'id') {
1264 // The page boundary is cut here, so this query needs the same id
1265 // tiebreaker as applySorting() for pagination to stay stable.
1266 $subscribersIdsQuery->addOrderBy($this->getDynamicSegmentSortColumn('id', $subscribersTable), $sortOrder);
1267 }
1268 $subscribersIdsQuery->setFirstResult($definition->getOffset());
1269 $subscribersIdsQuery->setMaxResults($definition->getLimit());
1270
1271 $idsStatement = $subscribersIdsQuery->executeQuery();
1272 $result = $idsStatement->fetchAll();
1273 $ids = array_column($result, 'id');
1274 if (count($ids)) {
1275 $queryBuilder->andWhere('s.id IN (:subscriberIds)')
1276 ->setParameter('subscriberIds', $ids);
1277 } else {
1278 $queryBuilder->andWhere('0 = 1'); // Don't return any subscribers if no ids found
1279 }
1280 $this->applySorting($queryBuilder, $sortBy, $sortOrder);
1281 return $queryBuilder->getQuery()->getResult();
1282 }
1283
1284 private function getDynamicSegmentSortBy(string $sortBy): string {
1285 $metadata = $this->entityManager->getClassMetadata(SubscriberEntity::class);
1286 return $metadata->hasField($sortBy) ? $sortBy : self::DEFAULT_SORT_BY;
1287 }
1288
1289 private function getDynamicSegmentSortColumn(string $sortBy, string $subscribersTable): string {
1290 $metadata = $this->entityManager->getClassMetadata(SubscriberEntity::class);
1291 $column = $metadata->getColumnName($sortBy);
1292 $connection = $this->entityManager->getConnection();
1293 return sprintf(
1294 '%s.%s',
1295 $connection->quoteIdentifier($subscribersTable),
1296 $connection->quoteIdentifier($column)
1297 );
1298 }
1299
1300 private function applyConstraintsForDynamicSegment(
1301 DBALQueryBuilder $subscribersQuery,
1302 ListingDefinition $definition,
1303 SegmentEntity $segment
1304 ) {
1305 // Apply dynamic segments filters
1306 $subscribersQuery = $this->dynamicSegmentsFilter->apply($subscribersQuery, $segment);
1307 // Apply group, search to fetch only necessary ids
1308 $subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
1309 if ($definition->getSearch()) {
1310 $search = Helpers::escapeSearch((string)$definition->getSearch());
1311 $subscribersQuery
1312 ->andWhere("$subscribersTable.email LIKE :search or $subscribersTable.first_name LIKE :search or $subscribersTable.last_name LIKE :search")
1313 ->setParameter('search', "%$search%");
1314 }
1315 if ($definition->getGroup()) {
1316 if ($definition->getGroup() === 'trash') {
1317 $subscribersQuery->andWhere("$subscribersTable.deleted_at IS NOT NULL");
1318 } else {
1319 $subscribersQuery->andWhere("$subscribersTable.deleted_at IS NULL");
1320 }
1321 if (in_array($definition->getGroup(), self::$supportedStatuses)) {
1322 $subscribersQuery
1323 ->andWhere("$subscribersTable.status = :status")
1324 ->setParameter('status', $definition->getGroup());
1325 }
1326 }
1327 return $subscribersQuery;
1328 }
1329
1330 private function getDynamicSegmentFromFilters(ListingDefinition $definition): ?SegmentEntity {
1331 $filters = $definition->getFilters();
1332 if (!$filters || !isset($filters['segment'])) {
1333 return null;
1334 }
1335 if ($filters['segment'] === self::FILTER_WITHOUT_LIST) {
1336 return null;
1337 }
1338 $segment = $this->entityManager->find(SegmentEntity::class, (int)$filters['segment']);
1339 if (!$segment instanceof SegmentEntity) {
1340 return null;
1341 }
1342 return $segment->isStatic() ? null : $segment;
1343 }
1344 }
1345