Files
CloudOps/docker-compose/mautic-setup/mautic-backup-files/docroot/app/bundles/LeadBundle/Services/PeakInteractionTimer.php

320 lines
12 KiB
PHP
Executable File

<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Services;
use Mautic\CacheBundle\Cache\CacheProviderInterface;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\EmailBundle\Entity\StatRepository;
use Mautic\FormBundle\Entity\SubmissionRepository;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\PageBundle\Entity\HitRepository;
class PeakInteractionTimer
{
public const DEFAULT_BEST_HOUR_START = 9; // 9 AM
public const DEFAULT_BEST_HOUR_END = 12; // 12 PM
public const DEFAULT_BEST_DAYS = [2, 1, 4]; // Tuesday, Monday, Thursday
public const DEFAULT_FETCH_INTERACTIONS_FROM = '-60 days';
public const DEFAULT_FETCH_LIMIT = 50;
public const DEFAULT_CACHE_TIMEOUT = 43800; // in minutes ~ 1 month
public const MIN_INTERACTIONS = 5;
public const DEFAULT_MAX_OPTIMAL_DAYS = 3;
private const MINUTES_START_OF_HOUR = 0; // Start of the hour
private const HOUR_FORMAT = 'G'; // 0 through 23
private const DAY_FORMAT = 'N'; // ISO 8601 numeric representation of the day of the week
private ?\DateTimeZone $defaultTimezone = null;
private int $cacheTimeout;
private int $bestHourStart;
private int $bestDefaultHourStart;
private int $bestHourEnd;
private int $bestDefaultHourEnd;
/** @var int[] */
private array $bestDays;
/** @var int[] */
private array $bestDefaultDays;
private string $fetchInteractionsFrom;
private int $fetchLimit;
private int $maxOptimalDays;
public function __construct(
private CoreParametersHelper $coreParametersHelper,
private StatRepository $statRepository,
private HitRepository $hitRepository,
private SubmissionRepository $submissionRepository,
private CacheProviderInterface $cacheProvider,
) {
$this->cacheTimeout = $this->coreParametersHelper->get('peak_interaction_timer_cache_timeout');
$this->bestDefaultHourStart = $this->coreParametersHelper->get('peak_interaction_timer_best_default_hour_start');
$this->bestDefaultHourEnd = $this->coreParametersHelper->get('peak_interaction_timer_best_default_hour_end');
$this->bestDefaultDays = $this->coreParametersHelper->get('peak_interaction_timer_best_default_days');
$this->fetchInteractionsFrom = $this->coreParametersHelper->get('peak_interaction_timer_fetch_interactions_from');
$this->fetchLimit = $this->coreParametersHelper->get('peak_interaction_timer_fetch_limit');
$this->maxOptimalDays = count($this->bestDefaultDays);
}
/**
* Get the optimal time for a contact.
*/
public function getOptimalTime(Lead $contact): \DateTime
{
$this->resetBias();
$currentDateTime = $this->getContactDateTime($contact);
$interactions = $this->getContactInteractions($contact, $currentDateTime->getTimezone());
if (count($interactions) >= self::MIN_INTERACTIONS) {
$hours = array_column($interactions, 'hourOfDay');
[$this->bestHourStart, $this->bestHourEnd] = $this->calculateOptimalTime($hours);
}
return $this->isTimeOptimal($currentDateTime)
? $currentDateTime
: $this->getAdjustedDateTime($currentDateTime);
}
/**
* Get the optimal time and day for a contact.
*/
public function getOptimalTimeAndDay(Lead $contact): \DateTime
{
$this->resetBias();
$currentDateTime = $this->getContactDateTime($contact);
$interactions = $this->getContactInteractions($contact, $currentDateTime->getTimezone());
if (count($interactions) >= self::MIN_INTERACTIONS) {
$hours = array_column($interactions, 'hourOfDay');
$days = array_column($interactions, 'dayOfWeek');
[$this->bestHourStart, $this->bestHourEnd] = $this->calculateOptimalTime($hours);
$this->bestDays = $this->calculateOptimalDays($days);
}
return $this->isDayAndTimeOptimal($currentDateTime)
? $currentDateTime
: $this->findOptimalDateTime($currentDateTime);
}
private function resetBias(): void
{
$this->bestHourStart = (int) $this->bestDefaultHourStart;
$this->bestHourEnd = (int) $this->bestDefaultHourEnd;
$bestDays = array_map('intval', $this->bestDefaultDays);
$this->bestDays = !empty($bestDays) ? $bestDays : self::DEFAULT_BEST_DAYS;
$this->maxOptimalDays = count($this->bestDays);
}
private function isTimeOptimal(\DateTime $dateTime): bool
{
$hour = (int) $dateTime->format(self::HOUR_FORMAT);
return $hour >= $this->bestHourStart && $hour < $this->bestHourEnd;
}
private function isDayAndTimeOptimal(\DateTime $dateTime): bool
{
return in_array((int) $dateTime->format(self::DAY_FORMAT), $this->bestDays, true) && $this->isTimeOptimal($dateTime);
}
private function getAdjustedDateTime(\DateTime $dateTime): \DateTime
{
$adjustedDateTime = clone $dateTime;
$adjustedDateTime->setTime($this->bestHourStart, self::MINUTES_START_OF_HOUR);
return $adjustedDateTime <= $dateTime
? $adjustedDateTime->modify('+1 day')
: $adjustedDateTime;
}
private function findOptimalDateTime(\DateTime $dateTime): \DateTime
{
$optimalDateTime = $this->getAdjustedDateTime($dateTime);
while (!in_array((int) $optimalDateTime->format(self::DAY_FORMAT), $this->bestDays, true)) {
$optimalDateTime->modify('+1 day');
}
return $optimalDateTime;
}
private function getContactDateTime(Lead $contact): \DateTime
{
$timezone = $contact->getTimezone() ? new \DateTimeZone($contact->getTimezone()) : $this->getDefaultTimezone();
return $this->getCurrentDateTime($timezone);
}
protected function getCurrentDateTime(\DateTimeZone $timezone): \DateTime
{
return new \DateTime('now', $timezone);
}
private function getDefaultTimezone(): \DateTimeZone
{
return $this->defaultTimezone ??= new \DateTimeZone(
$this->coreParametersHelper->get('default_timezone', 'UTC')
);
}
/**
* @return array<int, array<string, mixed>>
*/
private function getContactInteractions(Lead $contact, \DateTimeZone $dateTimeZone): array
{
$cacheItem = $this->cacheProvider->getItem('contact.interactions.'.$contact->getId());
if ($cacheItem->isHit()) {
$interactions = $cacheItem->get();
} else {
$fetchInteractionsFromDate = $this->getCurrentDateTime($dateTimeZone)
->modify($this->fetchInteractionsFrom);
$emailReads = $this->getLeadStats($contact->getId(), $fetchInteractionsFromDate);
$pageHits = $this->getLeadHits($contact->getId(), $fetchInteractionsFromDate);
$formSubmissions = $this->getFormSubmissions($contact->getId(), $fetchInteractionsFromDate);
$emailReadInteractions = $this->processInteractions($emailReads, 'email.read', $dateTimeZone);
$pageHitInteractions = $this->processInteractions($pageHits, 'page.hit', $dateTimeZone);
$formInteractions = $this->processInteractions($formSubmissions, 'form.submit', $dateTimeZone);
$interactions = array_merge($emailReadInteractions, $pageHitInteractions, $formInteractions);
$cacheItem->set($interactions);
$cacheItem->expiresAfter($this->cacheTimeout * 60);
$this->cacheProvider->save($cacheItem);
}
return $interactions;
}
/**
* @param array<int, array<string, mixed>> $interactionsData
*
* @return array<int, array<string, mixed>>
*
* @throws \Exception
*/
private function processInteractions(array $interactionsData, string $type, \DateTimeZone $dateTimeZone): array
{
$interactions = [];
$registeredInteractions = []; // Keep track of registered interactions to ensure one interaction type per hour
foreach ($interactionsData as $interaction) {
$dateKey = match ($type) {
'email.read' => 'dateRead',
'page.hit' => 'dateHit',
'form.submit' => 'dateSubmitted',
default => throw new \Exception('Unhandled interaction type: '.$type),
};
$interactionDate = $interaction[$dateKey];
$interactionDate->setTimezone($dateTimeZone);
$interactionKey = $type.':'.$interactionDate->format('Y-m-d_H');
if (!in_array($interactionKey, $registeredInteractions)) {
$interactions[] = [
'type' => $type,
'date' => $interactionDate->format('Y-m-d H:i:s'),
'hourOfDay' => (int) $interactionDate->format(self::HOUR_FORMAT),
'dayOfWeek' => (int) $interactionDate->format(self::DAY_FORMAT),
'time' => $interactionDate->format('H:i:s'),
];
$registeredInteractions[] = $interactionKey;
}
}
return $interactions;
}
/**
* @return array<int, array<string, mixed>>
*/
private function getLeadStats(int $leadId, ?\DateTime $fromDate = null): array
{
return $this->statRepository->getLeadStats($leadId, [
'order' => ['timestamp', 'DESC'],
'limit' => $this->fetchLimit,
'state' => 'read',
'basic_select' => true,
'fromDate' => $fromDate,
]);
}
/**
* @return array<int, array<string, mixed>>
*/
private function getLeadHits(int $leadId, ?\DateTime $fromDate = null): array
{
return $this->hitRepository->getLeadHits($leadId, [
'order' => ['timestamp', 'DESC'],
'limit' => $this->fetchLimit,
'fromDate' => $fromDate,
]);
}
/**
* @return array<int, array<string, mixed>>
*/
private function getFormSubmissions(int $leadId, ?\DateTime $fromDate = null): array
{
return $this->submissionRepository->getSubmissions([
'leadId' => $leadId,
'order' => ['timestamp', 'DESC'],
'limit' => $this->fetchLimit,
'fromDate' => $fromDate,
]);
}
/**
* Calculates the optimal time range based on an array of elements.
*
* @param int[] $elements Hours (0-23)
*
* @return int[] Hours (0-23)
*/
private function calculateOptimalTime(array $elements): array
{
sort($elements);
$count = count($elements);
if ($count > 0) {
$middleIndex = (int) floor(($count - 1) / 2);
$result = $elements[$middleIndex];
} else {
throw new \Exception('Not enough elements to calculate optimal time');
}
$start = ($result + 23) % 24; // hour before
$end = ($result + 1) % 24; // hour after
// Return the start and end hours as an array
return [$start, $end];
}
/**
* Calculates the optimal days based on the frequency of elements.
*
* @param int[] $elements Days of the week (ISO 8601)
*
* @return int[] Days of the week (ISO 8601)
*
* @throws \Exception
*/
private function calculateOptimalDays(array $elements): array
{
if (0 === count($elements)) {
throw new \Exception('Not enough elements to calculate optimal days');
}
// Count the frequency of each element.
$frequency = array_count_values($elements);
// Sort frequencies in descending order.
arsort($frequency);
// Get the elements sorted by frequency.
$optimalDays = array_keys($frequency);
// Return the top elements up to the max optimal days limit.
return array_slice($optimalDays, 0, min($this->maxOptimalDays, count($optimalDays)));
}
}