NewsletterReplayMetadata.php
2 months ago
ScheduledTaskSubscribersListingRepository.php
2 years ago
ScheduledTaskSubscribersRepository.php
1 month ago
ScheduledTasksRepository.php
1 month ago
SendingQueuesRepository.php
1 week ago
TimeZoneCampaignScheduler.php
1 week ago
index.php
3 years ago
TimeZoneCampaignScheduler.php
615 lines
| 1 | <?php declare(strict_types = 1); |
| 2 | |
| 3 | namespace MailPoet\Newsletter\Sending; |
| 4 | |
| 5 | if (!defined('ABSPATH')) exit; |
| 6 | |
| 7 | |
| 8 | use MailPoet\Cron\Workers\SendingQueue\SendingQueue as SendingQueueWorker; |
| 9 | use MailPoet\Entities\NewsletterEntity; |
| 10 | use MailPoet\Entities\NewsletterOptionFieldEntity; |
| 11 | use MailPoet\Entities\ScheduledTaskEntity; |
| 12 | use MailPoet\Entities\SendingQueueEntity; |
| 13 | use MailPoet\Entities\SubscriberEntity; |
| 14 | use MailPoet\Features\FeaturesController; |
| 15 | use MailPoet\Segments\SubscribersFinder; |
| 16 | use MailPoet\Subscribers\SubscribersRepository; |
| 17 | use MailPoet\Util\License\Features\CapabilitiesManager; |
| 18 | use MailPoet\Util\Security; |
| 19 | use MailPoet\WP\Functions as WPFunctions; |
| 20 | use MailPoetVendor\Carbon\Carbon; |
| 21 | use MailPoetVendor\Doctrine\ORM\EntityManager; |
| 22 | |
| 23 | class TimeZoneCampaignScheduler { |
| 24 | public const SCHEDULE_MODE_WEBSITE_TIME = 'website_time'; |
| 25 | public const SCHEDULE_MODE_SUBSCRIBER_TIMEZONE = 'subscriber_timezone'; |
| 26 | public const META_SEND_BY_TIMEZONE = 'sendByTimezone'; |
| 27 | public const META_TIMEZONE_CAMPAIGN_ID = 'timezoneCampaignId'; |
| 28 | public const META_SELECTED_LOCAL_DATE = 'selectedLocalDate'; |
| 29 | public const META_SELECTED_LOCAL_TIME = 'selectedLocalTime'; |
| 30 | public const META_GROUP_TIMEZONE = 'groupTimezone'; |
| 31 | public const META_FALLBACK_USED = 'fallbackUsed'; |
| 32 | public const META_SITE_TIMEZONE = 'siteTimezone'; |
| 33 | public const META_FIRST_SCHEDULED_AT = 'firstScheduledAt'; |
| 34 | public const META_LAST_SCHEDULED_AT = 'lastScheduledAt'; |
| 35 | public const META_NEXT_SCHEDULED_AT = 'nextScheduledAt'; |
| 36 | public const META_TIMEZONE_BREAKDOWN = 'timezoneBreakdown'; |
| 37 | private const LEAD_TIME_HOURS = 24; |
| 38 | |
| 39 | private CapabilitiesManager $capabilitiesManager; |
| 40 | private EntityManager $entityManager; |
| 41 | private FeaturesController $featuresController; |
| 42 | private ScheduledTasksRepository $scheduledTasksRepository; |
| 43 | private ScheduledTaskSubscribersRepository $scheduledTaskSubscribersRepository; |
| 44 | private SendingQueuesRepository $sendingQueuesRepository; |
| 45 | private SubscribersFinder $subscribersFinder; |
| 46 | private SubscribersRepository $subscribersRepository; |
| 47 | private WPFunctions $wp; |
| 48 | |
| 49 | public function __construct( |
| 50 | CapabilitiesManager $capabilitiesManager, |
| 51 | EntityManager $entityManager, |
| 52 | FeaturesController $featuresController, |
| 53 | ScheduledTasksRepository $scheduledTasksRepository, |
| 54 | ScheduledTaskSubscribersRepository $scheduledTaskSubscribersRepository, |
| 55 | SendingQueuesRepository $sendingQueuesRepository, |
| 56 | SubscribersFinder $subscribersFinder, |
| 57 | SubscribersRepository $subscribersRepository, |
| 58 | WPFunctions $wp |
| 59 | ) { |
| 60 | $this->capabilitiesManager = $capabilitiesManager; |
| 61 | $this->entityManager = $entityManager; |
| 62 | $this->featuresController = $featuresController; |
| 63 | $this->scheduledTasksRepository = $scheduledTasksRepository; |
| 64 | $this->scheduledTaskSubscribersRepository = $scheduledTaskSubscribersRepository; |
| 65 | $this->sendingQueuesRepository = $sendingQueuesRepository; |
| 66 | $this->subscribersFinder = $subscribersFinder; |
| 67 | $this->subscribersRepository = $subscribersRepository; |
| 68 | $this->wp = $wp; |
| 69 | } |
| 70 | |
| 71 | public function isSubscriberTimeZoneMode(NewsletterEntity $newsletter): bool { |
| 72 | return $newsletter->getType() === NewsletterEntity::TYPE_STANDARD |
| 73 | && $newsletter->getOptionValue(NewsletterOptionFieldEntity::NAME_SCHEDULE_MODE) === self::SCHEDULE_MODE_SUBSCRIBER_TIMEZONE; |
| 74 | } |
| 75 | |
| 76 | /** |
| 77 | * @throws \Exception |
| 78 | */ |
| 79 | public function schedule(NewsletterEntity $newsletter): SendingQueueEntity { |
| 80 | if (!$this->featuresController->isSupported(FeaturesController::FEATURE_SEND_BY_TIMEZONE)) { |
| 81 | throw new \Exception(__('Send by subscriber time zone is not available.', 'mailpoet'), 400); |
| 82 | } |
| 83 | if (!$this->isSubscriberTimeZoneMode($newsletter)) { |
| 84 | throw new \Exception(__('Send by subscriber time zone is available only for standard newsletters.', 'mailpoet'), 400); |
| 85 | } |
| 86 | $capability = $this->capabilitiesManager->getCapability('sendByTimezone'); |
| 87 | if ($capability && $capability->isRestricted) { |
| 88 | throw new \Exception(__('Send by subscriber time zone requires a paid MailPoet plan.', 'mailpoet'), 403); |
| 89 | } |
| 90 | if (!$this->canReplaceScheduledQueues($newsletter)) { |
| 91 | throw new \Exception(__('This email can no longer be edited because one or more time zone batches have already started.', 'mailpoet'), 400); |
| 92 | } |
| 93 | |
| 94 | $selectedLocalDate = $this->getRequiredOption($newsletter, NewsletterOptionFieldEntity::NAME_SCHEDULED_LOCAL_DATE); |
| 95 | $selectedLocalTime = $this->normalizeLocalTime($this->getRequiredOption($newsletter, NewsletterOptionFieldEntity::NAME_SCHEDULED_LOCAL_TIME)); |
| 96 | $this->validateLocalDate($selectedLocalDate); |
| 97 | $this->validateLocalTime($selectedLocalTime); |
| 98 | |
| 99 | $subscriberIds = $this->subscribersFinder->getSubscriberIdsFromSegments($newsletter->getSegmentIds(), $newsletter->getFilterSegmentId()); |
| 100 | if ($subscriberIds === []) { |
| 101 | throw new \Exception(__('There are no subscribers in that list!', 'mailpoet')); |
| 102 | } |
| 103 | |
| 104 | $siteTimeZone = $this->wp->wpTimezone(); |
| 105 | $siteTimeZoneName = $siteTimeZone->getName(); |
| 106 | $groups = $this->groupSubscribersByTimeZone($subscriberIds, $siteTimeZoneName); |
| 107 | if ($groups === []) { |
| 108 | throw new \Exception(__('There are no subscribers in that list!', 'mailpoet')); |
| 109 | } |
| 110 | |
| 111 | $schedule = $this->buildGroupSchedule($groups, $selectedLocalDate, $selectedLocalTime); |
| 112 | $this->validateLeadTime($schedule); |
| 113 | |
| 114 | $campaignId = Security::generateRandomString(16); |
| 115 | $firstScheduledAt = $schedule[0]['scheduledAt']->format('Y-m-d H:i:s'); |
| 116 | $lastScheduledAt = $schedule[count($schedule) - 1]['scheduledAt']->format('Y-m-d H:i:s'); |
| 117 | |
| 118 | $this->entityManager->beginTransaction(); |
| 119 | try { |
| 120 | $this->deleteReplaceableScheduledQueues($newsletter); |
| 121 | $createdQueues = []; |
| 122 | |
| 123 | foreach ($schedule as $group) { |
| 124 | $task = new ScheduledTaskEntity(); |
| 125 | $task->setType(SendingQueueWorker::TASK_TYPE); |
| 126 | $task->setPriority(ScheduledTaskEntity::PRIORITY_MEDIUM); |
| 127 | $task->setStatus(ScheduledTaskEntity::STATUS_SCHEDULED); |
| 128 | $task->setScheduledAt($group['scheduledAt']); |
| 129 | |
| 130 | $queue = new SendingQueueEntity(); |
| 131 | $queue->setNewsletter($newsletter); |
| 132 | $queue->setTask($task); |
| 133 | $queue->setMeta([ |
| 134 | self::META_SEND_BY_TIMEZONE => true, |
| 135 | self::META_TIMEZONE_CAMPAIGN_ID => $campaignId, |
| 136 | self::META_SELECTED_LOCAL_DATE => $selectedLocalDate, |
| 137 | self::META_SELECTED_LOCAL_TIME => $selectedLocalTime, |
| 138 | self::META_GROUP_TIMEZONE => $group['timeZone'], |
| 139 | self::META_FALLBACK_USED => $group['fallbackUsed'], |
| 140 | self::META_SITE_TIMEZONE => $siteTimeZoneName, |
| 141 | self::META_FIRST_SCHEDULED_AT => $firstScheduledAt, |
| 142 | self::META_LAST_SCHEDULED_AT => $lastScheduledAt, |
| 143 | ]); |
| 144 | |
| 145 | $this->scheduledTasksRepository->persist($task); |
| 146 | $this->sendingQueuesRepository->persist($queue); |
| 147 | $this->entityManager->flush(); |
| 148 | |
| 149 | $this->scheduledTaskSubscribersRepository->addSubscribersByIds($task, $group['subscriberIds']); |
| 150 | $this->sendingQueuesRepository->updateCounts($queue); |
| 151 | $createdQueues[] = $queue; |
| 152 | } |
| 153 | |
| 154 | $newsletter->setStatus(NewsletterEntity::STATUS_SCHEDULED); |
| 155 | $this->entityManager->flush(); |
| 156 | $this->entityManager->commit(); |
| 157 | } catch (\Throwable $exception) { |
| 158 | $this->entityManager->rollback(); |
| 159 | throw $exception; |
| 160 | } |
| 161 | |
| 162 | return $createdQueues[0]; |
| 163 | } |
| 164 | |
| 165 | public function isTimeZoneQueue(SendingQueueEntity $queue): bool { |
| 166 | $meta = $queue->getMeta() ?? []; |
| 167 | return !empty($meta[self::META_SEND_BY_TIMEZONE]) && !empty($meta[self::META_TIMEZONE_CAMPAIGN_ID]); |
| 168 | } |
| 169 | |
| 170 | public function getCampaignId(SendingQueueEntity $queue): ?string { |
| 171 | if (!$this->isTimeZoneQueue($queue)) { |
| 172 | return null; |
| 173 | } |
| 174 | $meta = $queue->getMeta() ?? []; |
| 175 | return is_string($meta[self::META_TIMEZONE_CAMPAIGN_ID] ?? null) ? $meta[self::META_TIMEZONE_CAMPAIGN_ID] : null; |
| 176 | } |
| 177 | |
| 178 | /** @return SendingQueueEntity[] */ |
| 179 | public function getCampaignQueues(SendingQueueEntity $queue): array { |
| 180 | $campaignId = $this->getCampaignId($queue); |
| 181 | $newsletter = $queue->getNewsletter(); |
| 182 | if (!$campaignId || !$newsletter instanceof NewsletterEntity) { |
| 183 | return [$queue]; |
| 184 | } |
| 185 | return $this->getCampaignQueuesById($newsletter, $campaignId); |
| 186 | } |
| 187 | |
| 188 | /** Resolves replay sources directly so newsletters with many replays stay cheap to inspect. */ |
| 189 | public function resolveTimeZoneCampaignQueue(NewsletterEntity $newsletter): ?SendingQueueEntity { |
| 190 | $latestQueue = $newsletter->getLatestQueue(); |
| 191 | if ($latestQueue === null) { |
| 192 | return null; |
| 193 | } |
| 194 | if ($this->isTimeZoneQueue($latestQueue)) { |
| 195 | return $latestQueue; |
| 196 | } |
| 197 | |
| 198 | $meta = $latestQueue->getMeta() ?? []; |
| 199 | if (!NewsletterReplayMetadata::isLatestNewsletterReplayMeta($meta)) { |
| 200 | return null; |
| 201 | } |
| 202 | $sourceQueueId = (int)($meta[NewsletterReplayMetadata::REPLAY_SOURCE_QUEUE_ID] ?? 0); |
| 203 | if ($sourceQueueId <= 0) { |
| 204 | return null; |
| 205 | } |
| 206 | |
| 207 | $sourceQueue = $this->sendingQueuesRepository->findOneBy([ |
| 208 | 'id' => $sourceQueueId, |
| 209 | 'newsletter' => $newsletter, |
| 210 | ]); |
| 211 | return $sourceQueue instanceof SendingQueueEntity && $this->isTimeZoneQueue($sourceQueue) |
| 212 | ? $sourceQueue |
| 213 | : null; |
| 214 | } |
| 215 | |
| 216 | public function hasIncompleteCampaignQueues(SendingQueueEntity $queue): bool { |
| 217 | foreach ($this->getCampaignQueues($queue) as $campaignQueue) { |
| 218 | $task = $campaignQueue->getTask(); |
| 219 | if (!$task instanceof ScheduledTaskEntity) { |
| 220 | continue; |
| 221 | } |
| 222 | if ( |
| 223 | !in_array( |
| 224 | $task->getStatus(), |
| 225 | [ |
| 226 | ScheduledTaskEntity::STATUS_COMPLETED, |
| 227 | ScheduledTaskEntity::STATUS_CANCELLED, |
| 228 | ScheduledTaskEntity::STATUS_INVALID, |
| 229 | ], |
| 230 | true |
| 231 | ) |
| 232 | ) { |
| 233 | return true; |
| 234 | } |
| 235 | } |
| 236 | return false; |
| 237 | } |
| 238 | |
| 239 | public function pauseCampaign(SendingQueueEntity $queue): void { |
| 240 | foreach ($this->getCampaignQueues($queue) as $campaignQueue) { |
| 241 | // Mirrors the guard in resumeCampaign(): a queue is only "fully processed" |
| 242 | // when it has work to do AND all of it has been done. A zero-count queue |
| 243 | // is still pending and must be paused. |
| 244 | if ($campaignQueue->getCountTotal() > 0 && $campaignQueue->getCountProcessed() === $campaignQueue->getCountTotal()) { |
| 245 | continue; |
| 246 | } |
| 247 | $task = $campaignQueue->getTask(); |
| 248 | if ($task instanceof ScheduledTaskEntity) { |
| 249 | $task->setStatus(ScheduledTaskEntity::STATUS_PAUSED); |
| 250 | } |
| 251 | } |
| 252 | $this->entityManager->flush(); |
| 253 | } |
| 254 | |
| 255 | public function resumeCampaign(SendingQueueEntity $queue): void { |
| 256 | $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); |
| 257 | $newsletter = $queue->getNewsletter(); |
| 258 | $hasDueBatch = false; |
| 259 | $hasPendingBatch = false; |
| 260 | foreach ($this->getCampaignQueues($queue) as $campaignQueue) { |
| 261 | $task = $campaignQueue->getTask(); |
| 262 | if (!$task instanceof ScheduledTaskEntity) { |
| 263 | continue; |
| 264 | } |
| 265 | if ($campaignQueue->getCountProcessed() === $campaignQueue->getCountTotal() && $campaignQueue->getCountTotal() > 0) { |
| 266 | // Mirrors SendingQueuesRepository::resume(): when pause interrupted the worker after all |
| 267 | // recipients were processed but before STATUS_COMPLETED was set, finalize processedAt now |
| 268 | // so aggregate "processed at" reporting is not left null for this batch. |
| 269 | $task->setProcessedAt(Carbon::now()->millisecond(0)); |
| 270 | $task->setStatus(ScheduledTaskEntity::STATUS_COMPLETED); |
| 271 | continue; |
| 272 | } |
| 273 | $hasPendingBatch = true; |
| 274 | $scheduledAt = $task->getScheduledAt(); |
| 275 | if ($scheduledAt && $scheduledAt > $now) { |
| 276 | $task->setStatus(ScheduledTaskEntity::STATUS_SCHEDULED); |
| 277 | } else { |
| 278 | $task->setStatus(null); |
| 279 | $hasDueBatch = true; |
| 280 | } |
| 281 | } |
| 282 | |
| 283 | if ($newsletter instanceof NewsletterEntity) { |
| 284 | if (!$hasPendingBatch) { |
| 285 | // All batches are already completed: do not demote a finished campaign back to |
| 286 | // SCHEDULED. No task remains for the scheduler to pick up, so doing so would |
| 287 | // leave the newsletter permanently stuck in the wrong status. |
| 288 | if ($newsletter->canBeSetSent()) { |
| 289 | $newsletter->setStatus(NewsletterEntity::STATUS_SENT); |
| 290 | } |
| 291 | } else { |
| 292 | $newsletter->setStatus($hasDueBatch ? NewsletterEntity::STATUS_SENDING : NewsletterEntity::STATUS_SCHEDULED); |
| 293 | } |
| 294 | } |
| 295 | $this->entityManager->flush(); |
| 296 | } |
| 297 | |
| 298 | /** |
| 299 | * @return array{status:?string,scheduledAt:\DateTimeInterface|null,processedAt:\DateTimeInterface|null,countTotal:int,countProcessed:int,countToProcess:int,meta:array<string,mixed>}|null |
| 300 | */ |
| 301 | public function getAggregateQueueData(SendingQueueEntity $queue): ?array { |
| 302 | if (!$this->isTimeZoneQueue($queue)) { |
| 303 | return null; |
| 304 | } |
| 305 | |
| 306 | $queues = $this->getCampaignQueues($queue); |
| 307 | $countTotal = 0; |
| 308 | $countProcessed = 0; |
| 309 | $countToProcess = 0; |
| 310 | $firstScheduledAt = null; |
| 311 | $lastScheduledAt = null; |
| 312 | $nextScheduledAt = null; |
| 313 | $lastProcessedAt = null; |
| 314 | $breakdown = []; |
| 315 | $statuses = []; |
| 316 | $meta = $queue->getMeta() ?? []; |
| 317 | |
| 318 | foreach ($queues as $campaignQueue) { |
| 319 | $task = $campaignQueue->getTask(); |
| 320 | if (!$task instanceof ScheduledTaskEntity) { |
| 321 | continue; |
| 322 | } |
| 323 | $countTotal += $campaignQueue->getCountTotal(); |
| 324 | $countProcessed += $campaignQueue->getCountProcessed(); |
| 325 | $countToProcess += $campaignQueue->getCountToProcess(); |
| 326 | $statuses[] = $task->getStatus(); |
| 327 | $scheduledAt = $task->getScheduledAt(); |
| 328 | if ($scheduledAt) { |
| 329 | $firstScheduledAt = $this->minDate($firstScheduledAt, $scheduledAt); |
| 330 | $lastScheduledAt = $this->maxDate($lastScheduledAt, $scheduledAt); |
| 331 | if ($task->getStatus() === ScheduledTaskEntity::STATUS_SCHEDULED || $task->getStatus() === ScheduledTaskEntity::STATUS_PAUSED) { |
| 332 | $nextScheduledAt = $this->minDate($nextScheduledAt, $scheduledAt); |
| 333 | } |
| 334 | } |
| 335 | $processedAt = $task->getProcessedAt(); |
| 336 | if ($processedAt) { |
| 337 | $lastProcessedAt = $this->maxDate($lastProcessedAt, $processedAt); |
| 338 | } |
| 339 | $queueMeta = $campaignQueue->getMeta() ?? []; |
| 340 | $breakdown[] = [ |
| 341 | 'timezone' => $queueMeta[self::META_GROUP_TIMEZONE] ?? null, |
| 342 | 'fallback_used' => $queueMeta[self::META_FALLBACK_USED] ?? false, |
| 343 | 'scheduled_at' => $scheduledAt ? $scheduledAt->format('Y-m-d H:i:s') : null, |
| 344 | 'status' => $task->getStatus(), |
| 345 | 'in_progress' => (bool)$task->getInProgress(), |
| 346 | 'count_total' => $campaignQueue->getCountTotal(), |
| 347 | 'count_processed' => $campaignQueue->getCountProcessed(), |
| 348 | 'count_to_process' => $campaignQueue->getCountToProcess(), |
| 349 | ]; |
| 350 | } |
| 351 | |
| 352 | $meta[self::META_FIRST_SCHEDULED_AT] = $firstScheduledAt ? $firstScheduledAt->format('Y-m-d H:i:s') : null; |
| 353 | $meta[self::META_LAST_SCHEDULED_AT] = $lastScheduledAt ? $lastScheduledAt->format('Y-m-d H:i:s') : null; |
| 354 | $meta[self::META_NEXT_SCHEDULED_AT] = $nextScheduledAt ? $nextScheduledAt->format('Y-m-d H:i:s') : null; |
| 355 | $meta[self::META_TIMEZONE_BREAKDOWN] = $breakdown; |
| 356 | |
| 357 | return [ |
| 358 | 'status' => $this->resolveAggregateStatus($statuses), |
| 359 | 'scheduledAt' => $nextScheduledAt ?: $firstScheduledAt, |
| 360 | 'processedAt' => $lastProcessedAt, |
| 361 | 'countTotal' => $countTotal, |
| 362 | 'countProcessed' => $countProcessed, |
| 363 | 'countToProcess' => $countToProcess, |
| 364 | 'meta' => $meta, |
| 365 | ]; |
| 366 | } |
| 367 | |
| 368 | private function getRequiredOption(NewsletterEntity $newsletter, string $optionName): string { |
| 369 | $value = $newsletter->getOptionValue($optionName); |
| 370 | return is_string($value) ? $value : ''; |
| 371 | } |
| 372 | |
| 373 | private function normalizeLocalTime(string $time): string { |
| 374 | if (strlen($time) === 5) { |
| 375 | return "{$time}:00"; |
| 376 | } |
| 377 | return $time; |
| 378 | } |
| 379 | |
| 380 | /** |
| 381 | * @throws \Exception |
| 382 | */ |
| 383 | private function validateLocalDate(string $date): void { |
| 384 | $dateTime = \DateTimeImmutable::createFromFormat('!Y-m-d', $date); |
| 385 | if (!$dateTime || $dateTime->format('Y-m-d') !== $date) { |
| 386 | throw new \Exception(__('Please enter a valid scheduled date.', 'mailpoet'), 400); |
| 387 | } |
| 388 | } |
| 389 | |
| 390 | /** |
| 391 | * @throws \Exception |
| 392 | */ |
| 393 | private function validateLocalTime(string $time): void { |
| 394 | $dateTime = \DateTimeImmutable::createFromFormat('!H:i:s', $time); |
| 395 | if ( |
| 396 | !$dateTime |
| 397 | || $dateTime->format('H:i:s') !== $time |
| 398 | || ((int)$dateTime->format('i')) % 15 !== 0 |
| 399 | || ((int)$dateTime->format('s')) !== 0 |
| 400 | ) { |
| 401 | throw new \Exception(__('Please enter a valid scheduled time.', 'mailpoet'), 400); |
| 402 | } |
| 403 | } |
| 404 | |
| 405 | /** |
| 406 | * @param int[] $subscriberIds |
| 407 | * @return array<string,array{timeZone:string,fallbackUsed:bool,subscriberIds:int[]}> |
| 408 | */ |
| 409 | private function groupSubscribersByTimeZone(array $subscriberIds, string $siteTimeZoneName): array { |
| 410 | $groups = []; |
| 411 | foreach (array_chunk($subscriberIds, 1000) as $idsChunk) { |
| 412 | $subscribers = $this->subscribersRepository->findBy(['id' => $idsChunk]); |
| 413 | foreach ($subscribers as $subscriber) { |
| 414 | if (!$subscriber instanceof SubscriberEntity || !$subscriber->getId()) { |
| 415 | continue; |
| 416 | } |
| 417 | $timeZone = SubscriberEntity::sanitizeTimeZone($subscriber->getTimeZone()); |
| 418 | $fallbackUsed = $timeZone === null; |
| 419 | $resolvedTimeZone = $timeZone ?: $siteTimeZoneName; |
| 420 | $key = $resolvedTimeZone . ':' . (int)$fallbackUsed; |
| 421 | if (!isset($groups[$key])) { |
| 422 | $groups[$key] = [ |
| 423 | 'timeZone' => $resolvedTimeZone, |
| 424 | 'fallbackUsed' => $fallbackUsed, |
| 425 | 'subscriberIds' => [], |
| 426 | ]; |
| 427 | } |
| 428 | $groups[$key]['subscriberIds'][] = (int)$subscriber->getId(); |
| 429 | } |
| 430 | } |
| 431 | return $groups; |
| 432 | } |
| 433 | |
| 434 | /** |
| 435 | * @param array<string,array{timeZone:string,fallbackUsed:bool,subscriberIds:int[]}> $groups |
| 436 | * @return array<int,array{timeZone:string,fallbackUsed:bool,subscriberIds:int[],scheduledAt:\DateTimeImmutable}> |
| 437 | */ |
| 438 | private function buildGroupSchedule(array $groups, string $selectedLocalDate, string $selectedLocalTime): array { |
| 439 | $schedule = []; |
| 440 | foreach ($groups as $group) { |
| 441 | $scheduledAt = new \DateTimeImmutable( |
| 442 | "{$selectedLocalDate} {$selectedLocalTime}", |
| 443 | new \DateTimeZone($group['timeZone']) |
| 444 | ); |
| 445 | $scheduledAt = $scheduledAt->setTimezone(new \DateTimeZone('UTC')); |
| 446 | $schedule[] = [ |
| 447 | 'timeZone' => $group['timeZone'], |
| 448 | 'fallbackUsed' => $group['fallbackUsed'], |
| 449 | 'subscriberIds' => $group['subscriberIds'], |
| 450 | 'scheduledAt' => $scheduledAt, |
| 451 | ]; |
| 452 | } |
| 453 | usort($schedule, function(array $a, array $b): int { |
| 454 | return $a['scheduledAt'] <=> $b['scheduledAt']; |
| 455 | }); |
| 456 | return $schedule; |
| 457 | } |
| 458 | |
| 459 | /** |
| 460 | * @param array<int,array{timeZone:string,fallbackUsed:bool,subscriberIds:int[],scheduledAt:\DateTimeImmutable}> $schedule |
| 461 | * @throws \Exception |
| 462 | */ |
| 463 | private function validateLeadTime(array $schedule): void { |
| 464 | $earliest = $schedule[0]['scheduledAt']; |
| 465 | $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); |
| 466 | if ($earliest <= $now) { |
| 467 | throw new \Exception(__('Subscriber time zone scheduling cannot include time zones that have already passed.', 'mailpoet'), 400); |
| 468 | } |
| 469 | if ($earliest < $now->modify('+' . self::LEAD_TIME_HOURS . ' hours')) { |
| 470 | throw new \Exception(sprintf( |
| 471 | // translators: %d is the minimum number of hours required before the first timezone batch can send. |
| 472 | __('Subscriber time zone scheduling requires at least %d hours of lead time.', 'mailpoet'), |
| 473 | self::LEAD_TIME_HOURS |
| 474 | ), 400); |
| 475 | } |
| 476 | } |
| 477 | |
| 478 | public function canReplaceScheduledCampaign(NewsletterEntity $newsletter): bool { |
| 479 | foreach ($this->getTimeZoneQueuesForNewsletter($newsletter) as $queue) { |
| 480 | if (!$this->isReplaceableScheduledQueue($queue)) { |
| 481 | return false; |
| 482 | } |
| 483 | } |
| 484 | return true; |
| 485 | } |
| 486 | |
| 487 | public function canReplaceScheduledQueues(NewsletterEntity $newsletter): bool { |
| 488 | foreach ($this->getQueuesForNewsletter($newsletter) as $queue) { |
| 489 | if ($this->isTerminalQueue($queue)) { |
| 490 | continue; |
| 491 | } |
| 492 | if (!$this->isReplaceableScheduledQueue($queue)) { |
| 493 | return false; |
| 494 | } |
| 495 | } |
| 496 | return true; |
| 497 | } |
| 498 | |
| 499 | public function deleteReplaceableScheduledQueues(NewsletterEntity $newsletter): void { |
| 500 | foreach ($this->getQueuesForNewsletter($newsletter) as $queue) { |
| 501 | if ($this->isReplaceableScheduledQueue($queue)) { |
| 502 | $this->deleteQueue($queue); |
| 503 | } |
| 504 | } |
| 505 | $this->entityManager->flush(); |
| 506 | } |
| 507 | |
| 508 | public function deleteScheduledCampaignQueues(NewsletterEntity $newsletter): void { |
| 509 | foreach ($this->getTimeZoneQueuesForNewsletter($newsletter) as $queue) { |
| 510 | $this->deleteQueue($queue); |
| 511 | } |
| 512 | $this->entityManager->flush(); |
| 513 | } |
| 514 | |
| 515 | /** @return SendingQueueEntity[] */ |
| 516 | private function getQueuesForNewsletter(NewsletterEntity $newsletter): array { |
| 517 | return $this->sendingQueuesRepository->findBy(['newsletter' => $newsletter]); |
| 518 | } |
| 519 | |
| 520 | /** @return SendingQueueEntity[] */ |
| 521 | private function getTimeZoneQueuesForNewsletter(NewsletterEntity $newsletter): array { |
| 522 | return array_values(array_filter($this->getQueuesForNewsletter($newsletter), function(SendingQueueEntity $queue): bool { |
| 523 | return $this->isTimeZoneQueue($queue); |
| 524 | })); |
| 525 | } |
| 526 | |
| 527 | private function isReplaceableScheduledQueue(SendingQueueEntity $queue): bool { |
| 528 | $task = $queue->getTask(); |
| 529 | return $task instanceof ScheduledTaskEntity |
| 530 | && $queue->getCountProcessed() === 0 |
| 531 | && !$task->getInProgress() |
| 532 | && in_array($task->getStatus(), [ScheduledTaskEntity::STATUS_SCHEDULED, ScheduledTaskEntity::STATUS_PAUSED], true); |
| 533 | } |
| 534 | |
| 535 | private function isTerminalQueue(SendingQueueEntity $queue): bool { |
| 536 | $task = $queue->getTask(); |
| 537 | return $task instanceof ScheduledTaskEntity |
| 538 | && in_array( |
| 539 | $task->getStatus(), |
| 540 | [ |
| 541 | ScheduledTaskEntity::STATUS_COMPLETED, |
| 542 | ScheduledTaskEntity::STATUS_CANCELLED, |
| 543 | ScheduledTaskEntity::STATUS_INVALID, |
| 544 | ], |
| 545 | true |
| 546 | ); |
| 547 | } |
| 548 | |
| 549 | private function deleteQueue(SendingQueueEntity $queue): void { |
| 550 | $task = $queue->getTask(); |
| 551 | if ($task instanceof ScheduledTaskEntity) { |
| 552 | $this->scheduledTaskSubscribersRepository->deleteByScheduledTask($task); |
| 553 | } |
| 554 | $this->sendingQueuesRepository->remove($queue); |
| 555 | if ($task instanceof ScheduledTaskEntity) { |
| 556 | $this->scheduledTasksRepository->remove($task); |
| 557 | } |
| 558 | } |
| 559 | |
| 560 | /** @return SendingQueueEntity[] */ |
| 561 | private function getCampaignQueuesById(NewsletterEntity $newsletter, string $campaignId): array { |
| 562 | $queues = array_filter($this->getTimeZoneQueuesForNewsletter($newsletter), function(SendingQueueEntity $queue) use ($campaignId): bool { |
| 563 | return $this->getCampaignId($queue) === $campaignId; |
| 564 | }); |
| 565 | usort($queues, function(SendingQueueEntity $a, SendingQueueEntity $b): int { |
| 566 | $taskA = $a->getTask(); |
| 567 | $taskB = $b->getTask(); |
| 568 | $scheduledA = $taskA instanceof ScheduledTaskEntity ? $taskA->getScheduledAt() : null; |
| 569 | $scheduledB = $taskB instanceof ScheduledTaskEntity ? $taskB->getScheduledAt() : null; |
| 570 | if (!$scheduledA || !$scheduledB) { |
| 571 | return (int)$a->getId() <=> (int)$b->getId(); |
| 572 | } |
| 573 | return $scheduledA <=> $scheduledB; |
| 574 | }); |
| 575 | return $queues; |
| 576 | } |
| 577 | |
| 578 | /** |
| 579 | * Resolves the aggregate status of a multi-batch time zone campaign from the per-batch statuses. |
| 580 | * |
| 581 | * The priority is intentionally explicit (instead of falling back to "the first status in the |
| 582 | * list") so that the result is deterministic and reflects what the campaign is doing, not the |
| 583 | * order in which batches happen to be sorted. |
| 584 | */ |
| 585 | private function resolveAggregateStatus(array $statuses): ?string { |
| 586 | if ($statuses === []) { |
| 587 | return null; |
| 588 | } |
| 589 | if (in_array(ScheduledTaskEntity::STATUS_PAUSED, $statuses, true)) { |
| 590 | return ScheduledTaskEntity::STATUS_PAUSED; |
| 591 | } |
| 592 | if (in_array(null, $statuses, true)) { |
| 593 | return null; |
| 594 | } |
| 595 | if (in_array(ScheduledTaskEntity::STATUS_SCHEDULED, $statuses, true)) { |
| 596 | return ScheduledTaskEntity::STATUS_SCHEDULED; |
| 597 | } |
| 598 | if (in_array(ScheduledTaskEntity::STATUS_COMPLETED, $statuses, true)) { |
| 599 | return ScheduledTaskEntity::STATUS_COMPLETED; |
| 600 | } |
| 601 | if (in_array(ScheduledTaskEntity::STATUS_CANCELLED, $statuses, true)) { |
| 602 | return ScheduledTaskEntity::STATUS_CANCELLED; |
| 603 | } |
| 604 | return ScheduledTaskEntity::STATUS_INVALID; |
| 605 | } |
| 606 | |
| 607 | private function minDate(?\DateTimeInterface $current, \DateTimeInterface $candidate): \DateTimeInterface { |
| 608 | return $current && $current <= $candidate ? $current : $candidate; |
| 609 | } |
| 610 | |
| 611 | private function maxDate(?\DateTimeInterface $current, \DateTimeInterface $candidate): \DateTimeInterface { |
| 612 | return $current && $current >= $candidate ? $current : $candidate; |
| 613 | } |
| 614 | } |
| 615 |