PluginProbe ʕ •ᴥ•ʔ
MailPoet – Newsletters, Email Marketing, and Automation / 5.27.0
MailPoet – Newsletters, Email Marketing, and Automation v5.27.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 3 years ago ImportExport 2 weeks ago Statistics 1 year ago BulkConfirmationEmailResender.php 4 weeks ago ConfirmationEmailCustomizer.php 4 weeks ago ConfirmationEmailMailer.php 4 weeks ago ConfirmationEmailResolver.php 4 weeks ago EngagementDataBackfiller.php 4 weeks ago InactiveSubscribersController.php 4 weeks ago LinkTokens.php 4 weeks ago NewSubscriberNotificationMailer.php 4 weeks ago RequiredCustomFieldValidator.php 4 weeks ago Source.php 3 weeks ago SubscriberActions.php 3 weeks ago SubscriberCustomFieldRepository.php 3 years ago SubscriberIPsRepository.php 2 years ago SubscriberLimitNotificationEvaluator.php 3 weeks ago SubscriberLimitNotificationMailer.php 3 weeks ago SubscriberLimitNotificationScheduler.php 3 weeks ago SubscriberListingRepository.php 4 weeks ago SubscriberPersonalDataEraser.php 4 weeks ago SubscriberSaveController.php 3 weeks ago SubscriberSegmentRepository.php 1 month ago SubscriberSubscribeController.php 3 weeks ago SubscriberTagRepository.php 3 years ago SubscribersCountsController.php 1 month ago SubscribersEmailCountsController.php 1 year ago SubscribersRepository.php 3 weeks ago index.php 3 years ago
SubscriberListingRepository.php
932 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\SubscriberEntity;
10 use MailPoet\Entities\SubscriberSegmentEntity;
11 use MailPoet\Entities\SubscriberTagEntity;
12 use MailPoet\Entities\TagEntity;
13 use MailPoet\Listing\ListingDefinition;
14 use MailPoet\Listing\ListingRepository;
15 use MailPoet\Segments\DynamicSegments\FilterHandler;
16 use MailPoet\Segments\SegmentSubscribersRepository;
17 use MailPoet\Util\Helpers;
18 use MailPoetVendor\Doctrine\DBAL\ArrayParameterType;
19 use MailPoetVendor\Doctrine\DBAL\ParameterType;
20 use MailPoetVendor\Doctrine\DBAL\Query\QueryBuilder as DBALQueryBuilder;
21 use MailPoetVendor\Doctrine\ORM\EntityManager;
22 use MailPoetVendor\Doctrine\ORM\Query\Expr\Join;
23 use MailPoetVendor\Doctrine\ORM\QueryBuilder;
24
25 class SubscriberListingRepository extends ListingRepository {
26 public const FILTER_WITHOUT_LIST = 'without-list';
27
28 const DEFAULT_SORT_BY = 'createdAt';
29
30 private const ENGAGEMENT_SCORE_UNKNOWN = 'unknown';
31 private const ENGAGEMENT_SCORE_LOW = 'low';
32 private const ENGAGEMENT_SCORE_GOOD = 'good';
33 private const ENGAGEMENT_SCORE_EXCELLENT = 'excellent';
34 private const ENGAGEMENT_SCORE_LOW_MAX = 20;
35 private const ENGAGEMENT_SCORE_GOOD_MIN = 20;
36 private const ENGAGEMENT_SCORE_GOOD_MAX = 50;
37 private const ENGAGEMENT_SCORE_EXCELLENT_MIN = 50;
38 private const BULK_RESEND_REASONS = [
39 'batch_limit',
40 'not_unconfirmed',
41 'deleted',
42 'max_confirmations_reached',
43 'recently_sent',
44 'too_old',
45 'outside_scope',
46 'not_found',
47 ];
48
49 private static $supportedStatuses = [
50 SubscriberEntity::STATUS_SUBSCRIBED,
51 SubscriberEntity::STATUS_UNSUBSCRIBED,
52 SubscriberEntity::STATUS_INACTIVE,
53 SubscriberEntity::STATUS_BOUNCED,
54 SubscriberEntity::STATUS_UNCONFIRMED,
55 ];
56
57 /** @var FilterHandler */
58 private $dynamicSegmentsFilter;
59
60 /** @var EntityManager */
61 private $entityManager;
62
63 /** @var SegmentSubscribersRepository */
64 private $segmentSubscribersRepository;
65
66 /** @var SubscribersCountsController */
67 private $subscribersCountsController;
68
69 /** @var null | ListingDefinition */
70 private $definition = null;
71
72 public function __construct(
73 EntityManager $entityManager,
74 FilterHandler $dynamicSegmentsFilter,
75 SegmentSubscribersRepository $segmentSubscribersRepository,
76 SubscribersCountsController $subscribersCountsController
77 ) {
78 parent::__construct($entityManager);
79 $this->dynamicSegmentsFilter = $dynamicSegmentsFilter;
80 $this->entityManager = $entityManager;
81 $this->segmentSubscribersRepository = $segmentSubscribersRepository;
82 $this->subscribersCountsController = $subscribersCountsController;
83 }
84
85 public function getData(ListingDefinition $definition): array {
86 $this->definition = $definition;
87 $dynamicSegment = $this->getDynamicSegmentFromFilters($definition);
88 if ($dynamicSegment === null) {
89 return parent::getData($definition);
90 }
91 return $this->getDataForDynamicSegment($definition, $dynamicSegment);
92 }
93
94 public function getCount(ListingDefinition $definition): int {
95 $this->definition = $definition;
96 $dynamicSegment = $this->getDynamicSegmentFromFilters($definition);
97 if ($dynamicSegment === null) {
98 return parent::getCount($definition);
99 }
100 $subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
101 $subscribersIdsQuery = $this->entityManager
102 ->getConnection()
103 ->createQueryBuilder()
104 ->select("count(DISTINCT $subscribersTable.id)")
105 ->from($subscribersTable);
106 $subscribersIdsQuery = $this->applyConstraintsForDynamicSegment($subscribersIdsQuery, $definition, $dynamicSegment);
107 return (int)$subscribersIdsQuery->execute()->fetchOne();
108 }
109
110 public function getActionableIds(ListingDefinition $definition): array {
111 $this->definition = $definition;
112 $ids = $definition->getSelection();
113 if (!empty($ids)) {
114 return $ids;
115 }
116 $dynamicSegment = $this->getDynamicSegmentFromFilters($definition);
117 if ($dynamicSegment === null) {
118 return parent::getActionableIds($definition);
119 }
120 $subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
121 $subscribersIdsQuery = $this->entityManager
122 ->getConnection()
123 ->createQueryBuilder()
124 ->select("DISTINCT $subscribersTable.id")
125 ->from($subscribersTable);
126 $subscribersIdsQuery = $this->applyConstraintsForDynamicSegment($subscribersIdsQuery, $definition, $dynamicSegment);
127 $idsStatement = $subscribersIdsQuery->execute();
128 $result = $idsStatement->fetchAll();
129 return array_column($result, 'id');
130 }
131
132 /**
133 * @return array{selected_count: int, eligible_count: int, queued_ids: int[], skipped_by_reason: array<string, int>}
134 */
135 public function getConfirmationEmailResendQueueData(
136 ListingDefinition $definition,
137 \DateTimeInterface $recentCutoff,
138 \DateTimeInterface $oldestLifecycleDate,
139 int $maxConfirmationEmails,
140 int $limit,
141 bool $hasExplicitSelection = false
142 ): array {
143 $selectedIds = $this->normalizeSelectedIds($definition->getSelection());
144 $skippedByReason = array_fill_keys(self::BULK_RESEND_REASONS, 0);
145 $base = $this->createBulkResendBaseQuery($definition);
146 $idColumn = $base['id_column'];
147
148 if ($hasExplicitSelection) {
149 if (!$selectedIds) {
150 $selectedCount = count($definition->getSelection());
151 $skippedByReason['not_found'] = $selectedCount;
152 return [
153 'selected_count' => $selectedCount,
154 'eligible_count' => 0,
155 'queued_ids' => [],
156 'skipped_by_reason' => $skippedByReason,
157 ];
158 }
159 $selectedCount = count($selectedIds);
160 $skippedByReason = $this->getExplicitSelectionScopeSkippedCounts($selectedIds, $skippedByReason);
161 $scopeSkippedCount = $skippedByReason['deleted'] + $skippedByReason['not_unconfirmed'] + $skippedByReason['not_found'];
162 $base['query']->andWhere("$idColumn IN (:selected_ids)")
163 ->setParameter('selected_ids', $selectedIds, ArrayParameterType::INTEGER);
164 } else {
165 $selectedCount = 0;
166 $scopeSkippedCount = 0;
167 }
168
169 $counts = $this->getBulkResendEligibilityCounts(clone $base['query'], $idColumn, $recentCutoff, $oldestLifecycleDate, $maxConfirmationEmails);
170 $inScopeCount = $counts['in_scope_count'];
171 if (!$hasExplicitSelection) {
172 $selectedCount = $inScopeCount;
173 }
174 $skippedByReason['max_confirmations_reached'] = $counts['max_confirmations_reached'];
175 $skippedByReason['recently_sent'] = $counts['recently_sent'];
176 $skippedByReason['too_old'] = $counts['too_old'];
177 $eligibleCount = $counts['eligible'];
178
179 $eligibleQuery = $this->addEligiblePredicates(clone $base['query'], $idColumn, $recentCutoff, $oldestLifecycleDate, $maxConfirmationEmails);
180 $queuedIds = $this->fetchBulkResendIds($eligibleQuery, $idColumn, $limit);
181 $skippedByReason['batch_limit'] = max(0, $eligibleCount - count($queuedIds));
182
183 if ($selectedIds) {
184 $skippedByReason['outside_scope'] += max(0, $selectedCount - $inScopeCount - $scopeSkippedCount);
185 }
186
187 return [
188 'selected_count' => $selectedCount,
189 'eligible_count' => $eligibleCount,
190 'queued_ids' => $queuedIds,
191 'skipped_by_reason' => $skippedByReason,
192 ];
193 }
194
195 /**
196 * @param int[] $selectedIds
197 * @param array<string, int> $skippedByReason
198 * @return array<string, int>
199 */
200 private function getExplicitSelectionScopeSkippedCounts(array $selectedIds, array $skippedByReason): array {
201 $subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
202 $rows = $this->entityManager->getConnection()->executeQuery(
203 "SELECT `id`, `status`, `deleted_at`
204 FROM $subscribersTable
205 WHERE `id` IN (:selected_ids)",
206 ['selected_ids' => $selectedIds],
207 ['selected_ids' => ArrayParameterType::INTEGER]
208 )->fetchAllAssociative();
209
210 $existingIds = [];
211 foreach ($rows as $row) {
212 $existingIds[] = $this->toInt($row['id'] ?? 0);
213 if (!empty($row['deleted_at'])) {
214 $skippedByReason['deleted']++;
215 } elseif (($row['status'] ?? null) !== SubscriberEntity::STATUS_UNCONFIRMED) {
216 $skippedByReason['not_unconfirmed']++;
217 }
218 }
219 $skippedByReason['not_found'] = count(array_diff($selectedIds, $existingIds));
220
221 return $skippedByReason;
222 }
223
224 /**
225 * @return array{query: DBALQueryBuilder, id_column: string}
226 */
227 private function createBulkResendBaseQuery(ListingDefinition $definition): array {
228 $dynamicSegment = $this->getDynamicSegmentFromFilters($definition);
229 if ($dynamicSegment instanceof SegmentEntity) {
230 $subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
231 $query = $this->entityManager->getConnection()->createQueryBuilder()
232 ->select("DISTINCT $subscribersTable.id")
233 ->from($subscribersTable);
234 $query = $this->applyConstraintsForDynamicSegment($query, $definition, $dynamicSegment);
235 return ['query' => $query, 'id_column' => "$subscribersTable.id"];
236 }
237
238 $query = $this->entityManager->getConnection()->createQueryBuilder();
239 $subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
240 $query->select('DISTINCT s.id')
241 ->from($subscribersTable, 's');
242
243 $this->applyBulkResendListingConstraints($query, $definition);
244 return ['query' => $query, 'id_column' => 's.id'];
245 }
246
247 private function applyBulkResendListingConstraints(DBALQueryBuilder $query, ListingDefinition $definition): void {
248 $group = $definition->getGroup();
249 if ($group === 'trash') {
250 $query->andWhere('s.deleted_at IS NOT NULL');
251 } else {
252 $query->andWhere('s.deleted_at IS NULL');
253 }
254 if ($group && in_array($group, self::$supportedStatuses, true)) {
255 $query->andWhere('s.status = :listing_status')
256 ->setParameter('listing_status', $group);
257 }
258
259 $search = $definition->getSearch();
260 if ($search && strlen(trim($search)) > 0) {
261 $search = Helpers::escapeSearch($search);
262 $query
263 ->andWhere('(s.email LIKE :search OR s.first_name LIKE :search OR s.last_name LIKE :search)')
264 ->setParameter('search', "%$search%");
265 }
266
267 $filters = $definition->getFilters();
268 if (isset($filters['segment'])) {
269 if ($filters['segment'] === self::FILTER_WITHOUT_LIST) {
270 $this->segmentSubscribersRepository->addConstraintsForSubscribersWithoutSegmentToDBAL($query);
271 } else {
272 $segment = $this->entityManager->find(SegmentEntity::class, (int)$filters['segment']);
273 if ($segment instanceof SegmentEntity && $segment->isStatic()) {
274 $subscriberSegmentsTable = $this->entityManager->getClassMetadata(SubscriberSegmentEntity::class)->getTableName();
275 $query->join('s', $subscriberSegmentsTable, 'ss', 'ss.subscriber_id = s.id AND ss.segment_id = :segment_id')
276 ->setParameter('segment_id', $segment->getId(), ParameterType::INTEGER);
277 }
278 }
279 }
280
281 if (isset($filters['tag'])) {
282 $tag = $this->entityManager->find(TagEntity::class, (int)$filters['tag']);
283 if ($tag instanceof TagEntity) {
284 $subscriberTagsTable = $this->entityManager->getClassMetadata(SubscriberTagEntity::class)->getTableName();
285 $query->join('s', $subscriberTagsTable, 'st', 'st.subscriber_id = s.id AND st.tag_id = :tag_id')
286 ->setParameter('tag_id', $tag->getId(), ParameterType::INTEGER);
287 }
288 }
289
290 if (isset($filters['minUpdatedAt']) && $filters['minUpdatedAt'] instanceof \DateTimeInterface) {
291 $query->andWhere('s.updated_at >= :updated_at')
292 ->setParameter('updated_at', $filters['minUpdatedAt']->format('Y-m-d H:i:s'), ParameterType::STRING);
293 }
294
295 $statusInclude = $this->sanitizeStatusFilter($filters['statusInclude'] ?? []);
296 if ($statusInclude) {
297 $query->andWhere('s.status IN (:status_include)')
298 ->setParameter('status_include', $statusInclude, ArrayParameterType::STRING);
299 }
300
301 $statusExclude = $this->sanitizeStatusFilter($filters['statusExclude'] ?? []);
302 if ($statusExclude) {
303 $query->andWhere('s.status NOT IN (:status_exclude)')
304 ->setParameter('status_exclude', $statusExclude, ArrayParameterType::STRING);
305 }
306
307 $createdAtFrom = $filters['createdAtFrom'] ?? null;
308 if ($createdAtFrom && is_string($createdAtFrom) && $this->isValidDateTime($createdAtFrom)) {
309 $query->andWhere('s.created_at >= :created_at_from')
310 ->setParameter('created_at_from', $createdAtFrom, ParameterType::STRING);
311 }
312
313 $createdAtTo = $filters['createdAtTo'] ?? null;
314 if ($createdAtTo && is_string($createdAtTo) && $this->isValidDateTime($createdAtTo)) {
315 $query->andWhere('s.created_at <= :created_at_to')
316 ->setParameter('created_at_to', $createdAtTo, ParameterType::STRING);
317 }
318
319 $engagementScoreInclude = $filters['engagementScoreInclude'] ?? [];
320 if (!empty($engagementScoreInclude)) {
321 $conditions = $this->getEngagementScoreConditions(is_array($engagementScoreInclude) ? $engagementScoreInclude : [$engagementScoreInclude]);
322 if ($conditions) {
323 $query->andWhere('(' . implode(' OR ', $conditions) . ')');
324 }
325 }
326
327 $engagementScoreExclude = $filters['engagementScoreExclude'] ?? [];
328 if (!empty($engagementScoreExclude)) {
329 foreach (is_array($engagementScoreExclude) ? $engagementScoreExclude : [$engagementScoreExclude] as $score) {
330 if ($score === self::ENGAGEMENT_SCORE_UNKNOWN) {
331 $query->andWhere('s.engagement_score IS NOT NULL');
332 } elseif ($score === self::ENGAGEMENT_SCORE_LOW) {
333 $query->andWhere(sprintf('(s.engagement_score >= %d OR s.engagement_score IS NULL)', self::ENGAGEMENT_SCORE_LOW_MAX));
334 } elseif ($score === self::ENGAGEMENT_SCORE_GOOD) {
335 $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));
336 } elseif ($score === self::ENGAGEMENT_SCORE_EXCELLENT) {
337 $query->andWhere(sprintf('(s.engagement_score < %d OR s.engagement_score IS NULL)', self::ENGAGEMENT_SCORE_EXCELLENT_MIN));
338 }
339 }
340 }
341 }
342
343 /**
344 * @param mixed $statuses
345 * @return string[]
346 */
347 private function sanitizeStatusFilter($statuses): array {
348 $statuses = is_array($statuses) ? $statuses : [$statuses];
349 $statuses = array_filter($statuses, function($status) {
350 return is_string($status) && in_array($status, self::$supportedStatuses, true);
351 });
352 return array_values(array_unique($statuses));
353 }
354
355 /**
356 * @param mixed[] $scores
357 * @return string[]
358 */
359 private function getEngagementScoreConditions(array $scores): array {
360 $conditions = [];
361 if (in_array(self::ENGAGEMENT_SCORE_UNKNOWN, $scores, true)) {
362 $conditions[] = '(s.engagement_score IS NULL)';
363 }
364 if (in_array(self::ENGAGEMENT_SCORE_LOW, $scores, true)) {
365 $conditions[] = sprintf('(s.engagement_score < %d)', self::ENGAGEMENT_SCORE_LOW_MAX);
366 }
367 if (in_array(self::ENGAGEMENT_SCORE_GOOD, $scores, true)) {
368 $conditions[] = sprintf('(s.engagement_score >= %d AND s.engagement_score < %d)', self::ENGAGEMENT_SCORE_GOOD_MIN, self::ENGAGEMENT_SCORE_GOOD_MAX);
369 }
370 if (in_array(self::ENGAGEMENT_SCORE_EXCELLENT, $scores, true)) {
371 $conditions[] = sprintf('(s.engagement_score >= %d)', self::ENGAGEMENT_SCORE_EXCELLENT_MIN);
372 }
373 return $conditions;
374 }
375
376 /**
377 * @return array{in_scope_count: int, max_confirmations_reached: int, recently_sent: int, too_old: int, eligible: int}
378 */
379 private function getBulkResendEligibilityCounts(
380 DBALQueryBuilder $query,
381 string $idColumn,
382 \DateTimeInterface $recentCutoff,
383 \DateTimeInterface $oldestLifecycleDate,
384 int $maxConfirmationEmails
385 ): array {
386 $countQuery = clone $query;
387 $countConfirmationColumn = $this->column($idColumn, 'count_confirmations');
388 $lastConfirmationEmailSentAtColumn = $this->column($idColumn, 'last_confirmation_email_sent_at');
389 $lifecycleDateExpression = 'COALESCE(' . $this->column($idColumn, 'last_subscribed_at') . ', ' . $this->column($idColumn, 'created_at') . ')';
390 $belowMaxConfirmations = "$countConfirmationColumn < :max_confirmation_emails";
391 $maxConfirmationsReached = "$countConfirmationColumn >= :max_confirmation_emails";
392 $recentlySent = "$lastConfirmationEmailSentAtColumn IS NOT NULL AND $lastConfirmationEmailSentAtColumn > :recent_cutoff";
393 $notRecentlySent = "($lastConfirmationEmailSentAtColumn IS NULL OR $lastConfirmationEmailSentAtColumn <= :recent_cutoff)";
394 $tooOld = "$lifecycleDateExpression < :oldest_lifecycle_date";
395 $notTooOld = "$lifecycleDateExpression >= :oldest_lifecycle_date";
396
397 $countQuery->select(implode(', ', [
398 "COUNT(DISTINCT $idColumn) AS in_scope_count",
399 "COUNT(DISTINCT CASE WHEN $maxConfirmationsReached THEN $idColumn END) AS max_confirmations_reached",
400 "COUNT(DISTINCT CASE WHEN $belowMaxConfirmations AND $recentlySent THEN $idColumn END) AS recently_sent",
401 "COUNT(DISTINCT CASE WHEN $belowMaxConfirmations AND $notRecentlySent AND $tooOld THEN $idColumn END) AS too_old",
402 "COUNT(DISTINCT CASE WHEN $belowMaxConfirmations AND $notRecentlySent AND $notTooOld THEN $idColumn END) AS eligible",
403 ]))
404 ->setParameter('max_confirmation_emails', $maxConfirmationEmails, ParameterType::INTEGER)
405 ->setParameter('recent_cutoff', $recentCutoff->format('Y-m-d H:i:s'), ParameterType::STRING)
406 ->setParameter('oldest_lifecycle_date', $oldestLifecycleDate->format('Y-m-d H:i:s'), ParameterType::STRING);
407
408 $row = $countQuery->executeQuery()->fetchAssociative() ?: [];
409 return [
410 'in_scope_count' => $this->toInt($row['in_scope_count'] ?? 0),
411 'max_confirmations_reached' => $this->toInt($row['max_confirmations_reached'] ?? 0),
412 'recently_sent' => $this->toInt($row['recently_sent'] ?? 0),
413 'too_old' => $this->toInt($row['too_old'] ?? 0),
414 'eligible' => $this->toInt($row['eligible'] ?? 0),
415 ];
416 }
417
418 /**
419 * @return int[]
420 */
421 private function fetchBulkResendIds(DBALQueryBuilder $query, string $idColumn, int $limit): array {
422 $query->select("DISTINCT $idColumn AS id")
423 ->orderBy($idColumn, 'ASC')
424 ->setMaxResults($limit);
425 return array_map(function($id): int {
426 return $this->toInt($id);
427 }, $query->executeQuery()->fetchFirstColumn());
428 }
429
430 private function addEligiblePredicates(DBALQueryBuilder $query, string $idColumn, \DateTimeInterface $recentCutoff, \DateTimeInterface $oldestLifecycleDate, int $maxConfirmationEmails): DBALQueryBuilder {
431 return $this->addNotTooOldPredicate(
432 $this->addNotRecentPredicate(
433 $this->addBelowMaxConfirmationPredicate($query, $idColumn, $maxConfirmationEmails),
434 $idColumn,
435 $recentCutoff
436 ),
437 $idColumn,
438 $oldestLifecycleDate
439 );
440 }
441
442 private function addBelowMaxConfirmationPredicate(DBALQueryBuilder $query, string $idColumn, int $maxConfirmationEmails): DBALQueryBuilder {
443 $query->andWhere($this->column($idColumn, 'count_confirmations') . ' < :max_confirmation_emails')
444 ->setParameter('max_confirmation_emails', $maxConfirmationEmails, ParameterType::INTEGER);
445 return $query;
446 }
447
448 private function addNotRecentPredicate(DBALQueryBuilder $query, string $idColumn, \DateTimeInterface $recentCutoff): DBALQueryBuilder {
449 $column = $this->column($idColumn, 'last_confirmation_email_sent_at');
450 $query->andWhere("($column IS NULL OR $column <= :recent_cutoff)")
451 ->setParameter('recent_cutoff', $recentCutoff->format('Y-m-d H:i:s'), ParameterType::STRING);
452 return $query;
453 }
454
455 private function addNotTooOldPredicate(DBALQueryBuilder $query, string $idColumn, \DateTimeInterface $oldestLifecycleDate): DBALQueryBuilder {
456 $query->andWhere('COALESCE(' . $this->column($idColumn, 'last_subscribed_at') . ', ' . $this->column($idColumn, 'created_at') . ') >= :oldest_lifecycle_date')
457 ->setParameter('oldest_lifecycle_date', $oldestLifecycleDate->format('Y-m-d H:i:s'), ParameterType::STRING);
458 return $query;
459 }
460
461 private function column(string $idColumn, string $column): string {
462 if ($idColumn === 's.id') {
463 return "s.$column";
464 }
465 $table = substr($idColumn, 0, -3);
466 return "$table.$column";
467 }
468
469 /**
470 * @param mixed[] $ids
471 * @return int[]
472 */
473 private function normalizeSelectedIds(array $ids): array {
474 $ids = array_map(function($id): int {
475 return $this->toInt($id);
476 }, $ids);
477 $ids = array_filter($ids, static function(int $id): bool {
478 return $id > 0;
479 });
480 return array_values(array_unique($ids));
481 }
482
483 private function toInt($value): int {
484 if (is_int($value)) {
485 return $value;
486 }
487 if (is_string($value) || is_float($value) || is_bool($value)) {
488 return (int)$value;
489 }
490 return 0;
491 }
492
493 protected function applySelectClause(QueryBuilder $queryBuilder) {
494 $queryBuilder->select("PARTIAL s.{id,email,firstName,lastName,status,createdAt,deletedAt,updatedAt,countConfirmations,wpUserId,isWoocommerceUser,engagementScore,lastSubscribedAt}");
495 }
496
497 protected function applyFromClause(QueryBuilder $queryBuilder) {
498 $queryBuilder->from(SubscriberEntity::class, 's');
499 }
500
501 protected function applyGroup(QueryBuilder $queryBuilder, string $group) {
502 // include/exclude deleted
503 if ($group === 'trash') {
504 $queryBuilder->andWhere('s.deletedAt IS NOT NULL');
505 } else {
506 $queryBuilder->andWhere('s.deletedAt IS NULL');
507 }
508
509 if (!in_array($group, self::$supportedStatuses)) {
510 return;
511 }
512
513 if (!in_array($group, [SubscriberEntity::STATUS_SUBSCRIBED, SubscriberEntity::STATUS_UNSUBSCRIBED])) {
514 $queryBuilder
515 ->andWhere('s.status = :status')
516 ->setParameter('status', $group);
517 return;
518 }
519
520 $segment = $this->definition && array_key_exists('segment', $this->definition->getFilters()) ? $this->entityManager->find(SegmentEntity::class, (int)$this->definition->getFilters()['segment']) : null;
521 if (!$segment instanceof SegmentEntity || !$segment->isStatic()) {
522 $queryBuilder
523 ->andWhere('s.status = :status')
524 ->setParameter('status', $group);
525 return;
526 }
527
528 $operator = $group === SubscriberEntity::STATUS_SUBSCRIBED ? 'AND' : 'OR';
529 $queryBuilder
530 ->andWhere('(s.status = :status ' . $operator . ' ss.status = :status)')
531 ->setParameter('status', $group);
532 }
533
534 protected function applySearch(QueryBuilder $queryBuilder, string $search, array $parameters = []) {
535 $search = Helpers::escapeSearch($search);
536 $queryBuilder
537 ->andWhere('s.email LIKE :search or s.firstName LIKE :search or s.lastName LIKE :search')
538 ->setParameter('search', "%$search%");
539 }
540
541 protected function applyFilters(QueryBuilder $queryBuilder, array $filters) {
542 if (isset($filters['segment'])) {
543 if ($filters['segment'] === self::FILTER_WITHOUT_LIST) {
544 $this->segmentSubscribersRepository->addConstraintsForSubscribersWithoutSegment($queryBuilder);
545 } else {
546 $segment = $this->entityManager->find(SegmentEntity::class, (int)$filters['segment']);
547 if ($segment instanceof SegmentEntity && $segment->isStatic()) {
548 $queryBuilder->join('s.subscriberSegments', 'ss', Join::WITH, 'ss.segment = :ssSegment')
549 ->setParameter('ssSegment', $segment->getId());
550 }
551 }
552 }
553
554 // filtering by minimal updated at
555 if (isset($filters['minUpdatedAt']) && $filters['minUpdatedAt'] instanceof \DateTimeInterface) {
556 $queryBuilder->andWhere('s.updatedAt >= :updatedAt')
557 ->setParameter('updatedAt', $filters['minUpdatedAt']);
558 }
559
560 if (isset($filters['tag'])) {
561 $tag = $this->entityManager->find(TagEntity::class, (int)$filters['tag']);
562 if ($tag) {
563 $queryBuilder->join('s.subscriberTags', 'st', Join::WITH, 'st.tag = :stTag')
564 ->setParameter('stTag', $tag);
565 }
566 }
567
568 // Status inclusion filter
569 $statusInclude = $filters['statusInclude'] ?? [];
570 if (!empty($statusInclude)) {
571 $statusInclude = is_array($statusInclude) ? $statusInclude : [$statusInclude];
572 // Sanitize: only allow valid status values
573 $statusInclude = array_filter($statusInclude, function($status) {
574 return is_string($status) && in_array($status, self::$supportedStatuses, true);
575 });
576 if (!empty($statusInclude)) {
577 $queryBuilder->andWhere('s.status IN (:statusInclude)')
578 ->setParameter('statusInclude', $statusInclude);
579 }
580 }
581
582 // Status exclusion filter
583 $statusExclude = $filters['statusExclude'] ?? [];
584 if (!empty($statusExclude)) {
585 $statusExclude = is_array($statusExclude) ? $statusExclude : [$statusExclude];
586 // Sanitize: only allow valid status values
587 $statusExclude = array_filter($statusExclude, function($status) {
588 return is_string($status) && in_array($status, self::$supportedStatuses, true);
589 });
590 if (!empty($statusExclude)) {
591 $queryBuilder->andWhere('s.status NOT IN (:statusExclude)')
592 ->setParameter('statusExclude', $statusExclude);
593 }
594 }
595
596 // Filter by created_at date
597 $createdAtFrom = $filters['createdAtFrom'] ?? null;
598 if ($createdAtFrom && is_string($createdAtFrom) && $this->isValidDateTime($createdAtFrom)) {
599 $queryBuilder
600 ->andWhere('s.createdAt >= :createdAtFrom')
601 ->setParameter('createdAtFrom', $createdAtFrom);
602 }
603
604 $createdAtTo = $filters['createdAtTo'] ?? null;
605 if ($createdAtTo && is_string($createdAtTo) && $this->isValidDateTime($createdAtTo)) {
606 $queryBuilder
607 ->andWhere('s.createdAt <= :createdAtTo')
608 ->setParameter('createdAtTo', $createdAtTo);
609 }
610
611 // Filter by engagement score (include)
612 $engagementScoreInclude = $filters['engagementScoreInclude'] ?? [];
613 if (!empty($engagementScoreInclude)) {
614 $engagementScoreInclude = is_array($engagementScoreInclude) ? $engagementScoreInclude : [$engagementScoreInclude];
615 $conditions = [];
616
617 if (in_array(self::ENGAGEMENT_SCORE_UNKNOWN, $engagementScoreInclude, true)) {
618 $conditions[] = '(s.engagementScore IS NULL)';
619 }
620 if (in_array(self::ENGAGEMENT_SCORE_LOW, $engagementScoreInclude, true)) {
621 $conditions[] = sprintf(
622 '(s.engagementScore < %d)',
623 self::ENGAGEMENT_SCORE_LOW_MAX
624 );
625 }
626 if (in_array(self::ENGAGEMENT_SCORE_GOOD, $engagementScoreInclude, true)) {
627 $conditions[] = sprintf(
628 '(s.engagementScore >= %d AND s.engagementScore < %d)',
629 self::ENGAGEMENT_SCORE_GOOD_MIN,
630 self::ENGAGEMENT_SCORE_GOOD_MAX
631 );
632 }
633 if (in_array(self::ENGAGEMENT_SCORE_EXCELLENT, $engagementScoreInclude, true)) {
634 $conditions[] = sprintf(
635 '(s.engagementScore >= %d)',
636 self::ENGAGEMENT_SCORE_EXCELLENT_MIN
637 );
638 }
639
640 if (!empty($conditions)) {
641 $queryBuilder->andWhere('(' . implode(' OR ', $conditions) . ')');
642 }
643 }
644
645 // Filter by engagement score (exclude)
646 $engagementScoreExclude = $filters['engagementScoreExclude'] ?? [];
647 if (!empty($engagementScoreExclude)) {
648 $engagementScoreExclude = is_array($engagementScoreExclude) ? $engagementScoreExclude : [$engagementScoreExclude];
649
650 if (in_array(self::ENGAGEMENT_SCORE_UNKNOWN, $engagementScoreExclude, true)) {
651 $queryBuilder->andWhere('s.engagementScore IS NOT NULL');
652 }
653 if (in_array(self::ENGAGEMENT_SCORE_LOW, $engagementScoreExclude, true)) {
654 $queryBuilder->andWhere(sprintf(
655 '(s.engagementScore >= %d OR s.engagementScore IS NULL)',
656 self::ENGAGEMENT_SCORE_LOW_MAX
657 ));
658 }
659 if (in_array(self::ENGAGEMENT_SCORE_GOOD, $engagementScoreExclude, true)) {
660 $queryBuilder->andWhere(sprintf(
661 '(s.engagementScore < %d OR s.engagementScore >= %d OR s.engagementScore IS NULL)',
662 self::ENGAGEMENT_SCORE_GOOD_MIN,
663 self::ENGAGEMENT_SCORE_GOOD_MAX
664 ));
665 }
666 if (in_array(self::ENGAGEMENT_SCORE_EXCELLENT, $engagementScoreExclude, true)) {
667 $queryBuilder->andWhere(sprintf(
668 '(s.engagementScore < %d OR s.engagementScore IS NULL)',
669 self::ENGAGEMENT_SCORE_EXCELLENT_MIN
670 ));
671 }
672 }
673 }
674
675 private function isValidDateTime(string $dateTime): bool {
676 try {
677 new \DateTime($dateTime);
678 return true;
679 } catch (\Exception $e) {
680 return false;
681 }
682 }
683
684 protected function applyParameters(QueryBuilder $queryBuilder, array $parameters) {
685 // nothing to do here
686 }
687
688 protected function applySorting(QueryBuilder $queryBuilder, string $sortBy, string $sortOrder) {
689 if (!$sortBy) {
690 $sortBy = self::DEFAULT_SORT_BY;
691 }
692 $queryBuilder->addOrderBy("s.$sortBy", $sortOrder);
693 }
694
695 public function getGroups(ListingDefinition $definition): array {
696 $queryBuilder = clone $this->queryBuilder;
697 $this->applyFromClause($queryBuilder);
698
699 $groupCounts = [
700 SubscriberEntity::STATUS_SUBSCRIBED => 0,
701 SubscriberEntity::STATUS_UNCONFIRMED => 0,
702 SubscriberEntity::STATUS_UNSUBSCRIBED => 0,
703 SubscriberEntity::STATUS_INACTIVE => 0,
704 SubscriberEntity::STATUS_BOUNCED => 0,
705 'trash' => 0,
706 ];
707 foreach (array_keys($groupCounts) as $group) {
708 $groupDefinition = $group === $definition->getGroup() ? $definition : new ListingDefinition(
709 $group,
710 $definition->getFilters(),
711 $definition->getSearch(),
712 $definition->getParameters(),
713 $definition->getSortBy(),
714 $definition->getSortOrder(),
715 $definition->getOffset(),
716 $definition->getLimit(),
717 $definition->getSelection()
718 );
719 $groupCounts[$group] = $this->getCount($groupDefinition);
720 }
721
722 $trashedCount = $groupCounts['trash'];
723 unset($groupCounts['trash']);
724 $totalCount = (int)array_sum($groupCounts);
725
726 return [
727 [
728 'name' => 'all',
729 'label' => __('All', 'mailpoet'),
730 'count' => $totalCount,
731 ],
732 [
733 'name' => SubscriberEntity::STATUS_SUBSCRIBED,
734 'label' => __('Subscribed', 'mailpoet'),
735 'count' => $groupCounts[SubscriberEntity::STATUS_SUBSCRIBED],
736 ],
737 [
738 'name' => SubscriberEntity::STATUS_UNCONFIRMED,
739 'label' => __('Unconfirmed', 'mailpoet'),
740 'count' => $groupCounts[SubscriberEntity::STATUS_UNCONFIRMED],
741 ],
742 [
743 'name' => SubscriberEntity::STATUS_UNSUBSCRIBED,
744 'label' => __('Unsubscribed', 'mailpoet'),
745 'count' => $groupCounts[SubscriberEntity::STATUS_UNSUBSCRIBED],
746 ],
747 [
748 'name' => SubscriberEntity::STATUS_INACTIVE,
749 'label' => __('Inactive', 'mailpoet'),
750 'count' => $groupCounts[SubscriberEntity::STATUS_INACTIVE],
751 ],
752 [
753 'name' => SubscriberEntity::STATUS_BOUNCED,
754 'label' => __('Bounced', 'mailpoet'),
755 'count' => $groupCounts[SubscriberEntity::STATUS_BOUNCED],
756 ],
757 [
758 'name' => 'trash',
759 'label' => __('Trash', 'mailpoet'),
760 'count' => $trashedCount,
761 ],
762 ];
763 }
764
765 public function getFilters(ListingDefinition $definition): array {
766 return [
767 'segment' => $this->getSegmentFilter($definition),
768 'tag' => $this->getTagsFilter($definition),
769 ];
770 }
771
772 /**
773 * @return array<array{label: string, value: string|int}>
774 */
775 private function getSegmentFilter(ListingDefinition $definition): array {
776 $group = $definition->getGroup();
777
778 $subscribersWithoutSegmentStats = $this->subscribersCountsController->getSubscribersWithoutSegmentStatisticsCount();
779 $key = $group ?: 'all';
780 $subscribersWithoutSegmentCount = $subscribersWithoutSegmentStats[$key];
781
782 $subscribersWithoutSegmentLabel = sprintf(
783 // translators: %s is the number of subscribers without a list.
784 __('Subscribers without a list (%s)', 'mailpoet'),
785 number_format((float)$subscribersWithoutSegmentCount)
786 );
787
788 $queryBuilder = clone $this->queryBuilder;
789 $queryBuilder
790 ->select('s')
791 ->from(SegmentEntity::class, 's');
792 if ($group !== 'trash') {
793 $queryBuilder->andWhere('s.deletedAt IS NULL');
794 }
795
796 // format segment list
797 $allSubscribersList = [
798 'label' => __('All Lists', 'mailpoet'),
799 'value' => '',
800 ];
801
802 $withoutSegmentList = [
803 'label' => $subscribersWithoutSegmentLabel,
804 'value' => self::FILTER_WITHOUT_LIST,
805 ];
806
807 $segmentList = [];
808 foreach ($queryBuilder->getQuery()->getResult() as $segment) {
809 $key = $group ?: 'all';
810 $count = $this->subscribersCountsController->getSegmentStatisticsCount($segment);
811 $subscribersCount = (float)$count[$key];
812 // filter segments without subscribers
813 if (!$subscribersCount) {
814 continue;
815 }
816 $segmentList[] = [
817 'label' => sprintf('%s (%s)', $segment->getName(), number_format($subscribersCount)),
818 'value' => $segment->getId(),
819 ];
820 }
821
822 usort($segmentList, function($a, $b) {
823 return strcasecmp($a['label'], $b['label']);
824 });
825
826 array_unshift($segmentList, $allSubscribersList, $withoutSegmentList);
827 return $segmentList;
828 }
829
830 /**
831 * @return array<int, array{label: string, value: string|int}>
832 */
833 private function getTagsFilter(ListingDefinition $definition): array {
834 $group = $definition->getGroup();
835
836 $allTagsList = [
837 'label' => __('All Tags', 'mailpoet'),
838 'value' => '',
839 ];
840
841 $status = in_array($group, ['all', 'trash']) ? null : $group;
842 $isDeleted = $group === 'trash';
843 $tagsStatistics = $this->subscribersCountsController->getTagsStatisticsCount($status, $isDeleted);
844
845 $tagsList = [];
846 foreach ($tagsStatistics as $tagStatistics) {
847 $tagsList[] = [
848 'label' => sprintf('%s (%s)', $tagStatistics['name'], number_format((float)$tagStatistics['subscribersCount'])),
849 'value' => $tagStatistics['id'],
850 ];
851 }
852
853 array_unshift($tagsList, $allTagsList);
854 return $tagsList;
855 }
856
857 private function getDataForDynamicSegment(ListingDefinition $definition, SegmentEntity $segment) {
858 $queryBuilder = clone $this->queryBuilder;
859 $sortBy = Helpers::underscoreToCamelCase($definition->getSortBy()) ?: self::DEFAULT_SORT_BY;
860 $this->applySelectClause($queryBuilder);
861 $this->applyFromClause($queryBuilder);
862
863 $subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
864 $subscribersIdsQuery = $this->entityManager
865 ->getConnection()
866 ->createQueryBuilder()
867 ->select("DISTINCT $subscribersTable.id")
868 ->from($subscribersTable);
869 $subscribersIdsQuery = $this->applyConstraintsForDynamicSegment($subscribersIdsQuery, $definition, $segment);
870 $subscribersIdsQuery->orderBy("$subscribersTable." . Helpers::camelCaseToUnderscore($sortBy), $definition->getSortOrder());
871 $subscribersIdsQuery->setFirstResult($definition->getOffset());
872 $subscribersIdsQuery->setMaxResults($definition->getLimit());
873
874 $idsStatement = $subscribersIdsQuery->executeQuery();
875 $result = $idsStatement->fetchAll();
876 $ids = array_column($result, 'id');
877 if (count($ids)) {
878 $queryBuilder->andWhere('s.id IN (:subscriberIds)')
879 ->setParameter('subscriberIds', $ids);
880 } else {
881 $queryBuilder->andWhere('0 = 1'); // Don't return any subscribers if no ids found
882 }
883 $this->applySorting($queryBuilder, $sortBy, $definition->getSortOrder());
884 return $queryBuilder->getQuery()->getResult();
885 }
886
887 private function applyConstraintsForDynamicSegment(
888 DBALQueryBuilder $subscribersQuery,
889 ListingDefinition $definition,
890 SegmentEntity $segment
891 ) {
892 // Apply dynamic segments filters
893 $subscribersQuery = $this->dynamicSegmentsFilter->apply($subscribersQuery, $segment);
894 // Apply group, search to fetch only necessary ids
895 $subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
896 if ($definition->getSearch()) {
897 $search = Helpers::escapeSearch((string)$definition->getSearch());
898 $subscribersQuery
899 ->andWhere("$subscribersTable.email LIKE :search or $subscribersTable.first_name LIKE :search or $subscribersTable.last_name LIKE :search")
900 ->setParameter('search', "%$search%");
901 }
902 if ($definition->getGroup()) {
903 if ($definition->getGroup() === 'trash') {
904 $subscribersQuery->andWhere("$subscribersTable.deleted_at IS NOT NULL");
905 } else {
906 $subscribersQuery->andWhere("$subscribersTable.deleted_at IS NULL");
907 }
908 if (in_array($definition->getGroup(), self::$supportedStatuses)) {
909 $subscribersQuery
910 ->andWhere("$subscribersTable.status = :status")
911 ->setParameter('status', $definition->getGroup());
912 }
913 }
914 return $subscribersQuery;
915 }
916
917 private function getDynamicSegmentFromFilters(ListingDefinition $definition): ?SegmentEntity {
918 $filters = $definition->getFilters();
919 if (!$filters || !isset($filters['segment'])) {
920 return null;
921 }
922 if ($filters['segment'] === self::FILTER_WITHOUT_LIST) {
923 return null;
924 }
925 $segment = $this->entityManager->find(SegmentEntity::class, (int)$filters['segment']);
926 if (!$segment instanceof SegmentEntity) {
927 return null;
928 }
929 return $segment->isStatic() ? null : $segment;
930 }
931 }
932