Initial commit: CloudOps infrastructure platform
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\LeadBundle\Services;
|
||||
|
||||
use Mautic\CoreBundle\Helper\CoreParametersHelper;
|
||||
use Mautic\LeadBundle\Model\FieldModel;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
class ContactColumnsDictionary
|
||||
{
|
||||
/**
|
||||
* @var mixed[]
|
||||
*/
|
||||
private array $fieldList = [];
|
||||
|
||||
public function __construct(
|
||||
protected FieldModel $fieldModel,
|
||||
private TranslatorInterface $translator,
|
||||
private CoreParametersHelper $coreParametersHelper,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getColumns(): array
|
||||
{
|
||||
$columns = array_flip($this->coreParametersHelper->get('contact_columns', []));
|
||||
$fields = $this->getFields();
|
||||
foreach ($columns as $alias=>&$column) {
|
||||
if (isset($fields[$alias])) {
|
||||
$column = $fields[$alias];
|
||||
}
|
||||
}
|
||||
|
||||
return $columns;
|
||||
}
|
||||
|
||||
public function getFields(): array
|
||||
{
|
||||
if ([] === $this->fieldList) {
|
||||
$this->fieldList['name'] = sprintf(
|
||||
'%s %s',
|
||||
$this->translator->trans('mautic.core.firstname'),
|
||||
$this->translator->trans('mautic.core.lastname')
|
||||
);
|
||||
$this->fieldList['email'] = $this->translator->trans('mautic.core.type.email');
|
||||
$this->fieldList['location'] = $this->translator->trans('mautic.lead.lead.thead.location');
|
||||
$this->fieldList['stage'] = $this->translator->trans('mautic.lead.stage.label');
|
||||
$this->fieldList['points'] = $this->translator->trans('mautic.lead.points');
|
||||
$this->fieldList['last_active'] = $this->translator->trans('mautic.lead.lastactive');
|
||||
$this->fieldList['id'] = $this->translator->trans('mautic.core.id');
|
||||
$this->fieldList = $this->fieldList + $this->fieldModel->getFieldList(false);
|
||||
}
|
||||
|
||||
return $this->fieldList;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\LeadBundle\Services;
|
||||
|
||||
use Mautic\LeadBundle\Event\SegmentDictionaryGenerationEvent;
|
||||
use Mautic\LeadBundle\Exception\FilterNotFoundException;
|
||||
use Mautic\LeadBundle\LeadEvents;
|
||||
use Mautic\LeadBundle\Segment\Query\Filter\BaseFilterQueryBuilder;
|
||||
use Mautic\LeadBundle\Segment\Query\Filter\ChannelClickQueryBuilder;
|
||||
use Mautic\LeadBundle\Segment\Query\Filter\DoNotContactFilterQueryBuilder;
|
||||
use Mautic\LeadBundle\Segment\Query\Filter\ForeignFuncFilterQueryBuilder;
|
||||
use Mautic\LeadBundle\Segment\Query\Filter\ForeignValueFilterQueryBuilder;
|
||||
use Mautic\LeadBundle\Segment\Query\Filter\IntegrationCampaignFilterQueryBuilder;
|
||||
use Mautic\LeadBundle\Segment\Query\Filter\SessionsFilterQueryBuilder;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
|
||||
class ContactSegmentFilterDictionary
|
||||
{
|
||||
/**
|
||||
* @var mixed[]
|
||||
*/
|
||||
private $filters = [];
|
||||
|
||||
public function __construct(
|
||||
private EventDispatcherInterface $dispatcher,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return mixed[]
|
||||
*/
|
||||
public function getFilters()
|
||||
{
|
||||
if (empty($this->filters)) {
|
||||
$this->setDefaultFilters();
|
||||
$this->fetchFiltersFromSubscribers();
|
||||
}
|
||||
|
||||
return $this->filters;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $filterKey
|
||||
*
|
||||
* @return mixed[]
|
||||
*
|
||||
* @throws FilterNotFoundException
|
||||
*/
|
||||
public function getFilter($filterKey)
|
||||
{
|
||||
if (array_key_exists($filterKey, $this->getFilters())) {
|
||||
return $this->filters[$filterKey];
|
||||
}
|
||||
|
||||
throw new FilterNotFoundException("Filter '{$filterKey}' does not exist");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $filterKey
|
||||
* @param string $property
|
||||
*
|
||||
* @return string|int
|
||||
*
|
||||
* @throws FilterNotFoundException
|
||||
*/
|
||||
public function getFilterProperty($filterKey, $property)
|
||||
{
|
||||
$filter = $this->getFilter($filterKey);
|
||||
|
||||
if (array_key_exists($property, $filter)) {
|
||||
return $filter[$property];
|
||||
}
|
||||
|
||||
throw new FilterNotFoundException("Filter '{$filterKey}' does not have property '{$property}' exist");
|
||||
}
|
||||
|
||||
private function setDefaultFilters(): void
|
||||
{
|
||||
$this->filters['lead_email_read_count'] = [
|
||||
'type' => ForeignFuncFilterQueryBuilder::getServiceId(),
|
||||
'foreign_table' => 'email_stats',
|
||||
'foreign_table_field' => 'lead_id',
|
||||
'table' => 'leads',
|
||||
'table_field' => 'id',
|
||||
'func' => 'sum',
|
||||
'field' => 'open_count',
|
||||
'null_value' => 0,
|
||||
];
|
||||
$this->filters['lead_email_received'] = [
|
||||
'type' => ForeignValueFilterQueryBuilder::getServiceId(),
|
||||
'foreign_table_field' => 'lead_id',
|
||||
'foreign_table' => 'email_stats',
|
||||
'field' => 'email_id',
|
||||
'where' => 'email_stats.is_read = 1',
|
||||
];
|
||||
$this->filters['hit_url_count'] = [
|
||||
'type' => ForeignFuncFilterQueryBuilder::getServiceId(),
|
||||
'foreign_table' => 'page_hits',
|
||||
'foreign_table_field' => 'lead_id',
|
||||
'table' => 'leads',
|
||||
'table_field' => 'id',
|
||||
'func' => 'count',
|
||||
'field' => 'id',
|
||||
];
|
||||
$this->filters['lead_email_read_date'] = [
|
||||
'type' => ForeignValueFilterQueryBuilder::getServiceId(),
|
||||
'foreign_table' => 'email_stats',
|
||||
'field' => 'date_read',
|
||||
];
|
||||
$this->filters['lead_email_sent_date'] = [
|
||||
'type' => ForeignValueFilterQueryBuilder::getServiceId(),
|
||||
'foreign_table' => 'email_stats',
|
||||
'field' => 'date_sent',
|
||||
];
|
||||
$this->filters['hit_url_date'] = [
|
||||
'type' => ForeignValueFilterQueryBuilder::getServiceId(),
|
||||
'foreign_table' => 'page_hits',
|
||||
'field' => 'date_hit',
|
||||
];
|
||||
$this->filters['dnc_bounced'] = [
|
||||
'type' => DoNotContactFilterQueryBuilder::getServiceId(),
|
||||
];
|
||||
$this->filters['dnc_bounced_sms'] = [
|
||||
'type' => DoNotContactFilterQueryBuilder::getServiceId(),
|
||||
];
|
||||
$this->filters['dnc_unsubscribed'] = [
|
||||
'type' => DoNotContactFilterQueryBuilder::getServiceId(),
|
||||
];
|
||||
$this->filters['dnc_manual_email'] = [
|
||||
'type' => DoNotContactFilterQueryBuilder::getServiceId(),
|
||||
];
|
||||
$this->filters['dnc_unsubscribed_sms'] = [
|
||||
'type' => DoNotContactFilterQueryBuilder::getServiceId(),
|
||||
];
|
||||
$this->filters['dnc_manual_sms'] = [
|
||||
'type' => DoNotContactFilterQueryBuilder::getServiceId(),
|
||||
];
|
||||
$this->filters['leadlist'] = [
|
||||
'type' => ForeignValueFilterQueryBuilder::getServiceId(),
|
||||
'foreign_table' => 'lead_lists_leads',
|
||||
'field' => 'leadlist_id',
|
||||
'where' => 'lead_lists_leads.manually_removed = 0',
|
||||
];
|
||||
$this->filters['globalcategory'] = [
|
||||
'type' => ForeignValueFilterQueryBuilder::getServiceId(),
|
||||
'foreign_table' => 'lead_categories',
|
||||
'field' => 'category_id',
|
||||
];
|
||||
$this->filters['tags'] = [
|
||||
'type' => ForeignValueFilterQueryBuilder::getServiceId(),
|
||||
'foreign_table' => 'lead_tags_xref',
|
||||
'field' => 'tag_id',
|
||||
];
|
||||
$this->filters['lead_email_sent'] = [
|
||||
'type' => ForeignValueFilterQueryBuilder::getServiceId(),
|
||||
'foreign_table' => 'email_stats',
|
||||
'field' => 'email_id',
|
||||
];
|
||||
$this->filters['device_type'] = [
|
||||
'type' => ForeignValueFilterQueryBuilder::getServiceId(),
|
||||
'foreign_table' => 'lead_devices',
|
||||
'field' => 'device',
|
||||
];
|
||||
$this->filters['device_brand'] = [
|
||||
'type' => ForeignValueFilterQueryBuilder::getServiceId(),
|
||||
'foreign_table' => 'lead_devices',
|
||||
'field' => 'device_brand',
|
||||
];
|
||||
$this->filters['device_os'] = [
|
||||
'type' => ForeignValueFilterQueryBuilder::getServiceId(),
|
||||
'foreign_table' => 'lead_devices',
|
||||
'field' => 'device_os_name',
|
||||
];
|
||||
$this->filters['device_model'] = [
|
||||
'type' => ForeignValueFilterQueryBuilder::getServiceId(),
|
||||
'foreign_table' => 'lead_devices',
|
||||
'field' => 'device_model',
|
||||
];
|
||||
$this->filters['stage'] = [
|
||||
'type' => BaseFilterQueryBuilder::getServiceId(),
|
||||
'foreign_table' => 'leads',
|
||||
'field' => 'stage_id',
|
||||
];
|
||||
$this->filters['notification'] = [
|
||||
'type' => ForeignValueFilterQueryBuilder::getServiceId(),
|
||||
'foreign_table' => 'push_ids',
|
||||
'field' => 'id',
|
||||
];
|
||||
$this->filters['page_id'] = [
|
||||
'type' => ForeignValueFilterQueryBuilder::getServiceId(),
|
||||
'foreign_table' => 'page_hits',
|
||||
'foreign_field' => 'page_id',
|
||||
];
|
||||
$this->filters['redirect_id'] = [
|
||||
'type' => ForeignValueFilterQueryBuilder::getServiceId(),
|
||||
'foreign_table' => 'page_hits',
|
||||
'foreign_field' => 'redirect_id',
|
||||
];
|
||||
$this->filters['source'] = [
|
||||
'type' => ForeignValueFilterQueryBuilder::getServiceId(),
|
||||
'foreign_table' => 'page_hits',
|
||||
'foreign_field' => 'source',
|
||||
];
|
||||
$this->filters['hit_url'] = [
|
||||
'type' => ForeignValueFilterQueryBuilder::getServiceId(),
|
||||
'foreign_table' => 'page_hits',
|
||||
'field' => 'url',
|
||||
];
|
||||
$this->filters['referer'] = [
|
||||
'type' => ForeignValueFilterQueryBuilder::getServiceId(),
|
||||
'foreign_table' => 'page_hits',
|
||||
];
|
||||
$this->filters['source_id'] = [
|
||||
'type' => ForeignValueFilterQueryBuilder::getServiceId(),
|
||||
'foreign_table' => 'page_hits',
|
||||
];
|
||||
$this->filters['url_title'] = [
|
||||
'type' => ForeignValueFilterQueryBuilder::getServiceId(),
|
||||
'foreign_table' => 'page_hits',
|
||||
];
|
||||
$this->filters['email_id'] = [ // kept as email_id for BC
|
||||
'type' => ChannelClickQueryBuilder::getServiceId(),
|
||||
];
|
||||
$this->filters['email_clicked_link_date'] = [
|
||||
'type' => ChannelClickQueryBuilder::getServiceId(),
|
||||
];
|
||||
$this->filters['sms_clicked_link'] = [
|
||||
'type' => ChannelClickQueryBuilder::getServiceId(),
|
||||
];
|
||||
$this->filters['sms_clicked_link_date'] = [
|
||||
'type' => ChannelClickQueryBuilder::getServiceId(),
|
||||
];
|
||||
$this->filters['sessions'] = [
|
||||
'type' => SessionsFilterQueryBuilder::getServiceId(),
|
||||
];
|
||||
$this->filters['integration_campaigns'] = [
|
||||
'type' => IntegrationCampaignFilterQueryBuilder::getServiceId(),
|
||||
];
|
||||
$this->filters['utm_campaign'] = [
|
||||
'type' => ForeignValueFilterQueryBuilder::getServiceId(),
|
||||
'foreign_table' => 'lead_utmtags',
|
||||
];
|
||||
$this->filters['utm_content'] = [
|
||||
'type' => ForeignValueFilterQueryBuilder::getServiceId(),
|
||||
'foreign_table' => 'lead_utmtags',
|
||||
];
|
||||
$this->filters['utm_medium'] = [
|
||||
'type' => ForeignValueFilterQueryBuilder::getServiceId(),
|
||||
'foreign_table' => 'lead_utmtags',
|
||||
];
|
||||
$this->filters['utm_source'] = [
|
||||
'type' => ForeignValueFilterQueryBuilder::getServiceId(),
|
||||
'foreign_table' => 'lead_utmtags',
|
||||
];
|
||||
$this->filters['utm_term'] = [
|
||||
'type' => ForeignValueFilterQueryBuilder::getServiceId(),
|
||||
'foreign_table' => 'lead_utmtags',
|
||||
];
|
||||
$this->filters['campaign'] = [
|
||||
'type' => ForeignValueFilterQueryBuilder::getServiceId(),
|
||||
'foreign_table' => 'campaign_leads',
|
||||
'field' => 'campaign_id',
|
||||
'where' => 'campaign_leads.manually_removed = 0',
|
||||
];
|
||||
$this->filters['lead_asset_download'] = [
|
||||
'type' => ForeignValueFilterQueryBuilder::getServiceId(),
|
||||
'foreign_table' => 'asset_downloads',
|
||||
'field' => 'asset_id',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Other bundles can add more filters by subscribing to this event.
|
||||
*/
|
||||
private function fetchFiltersFromSubscribers(): void
|
||||
{
|
||||
if ($this->dispatcher->hasListeners(LeadEvents::SEGMENT_DICTIONARY_ON_GENERATE)) {
|
||||
$event = new SegmentDictionaryGenerationEvent($this->filters);
|
||||
$this->dispatcher->dispatch($event, LeadEvents::SEGMENT_DICTIONARY_ON_GENERATE);
|
||||
$this->filters = $event->getTranslations();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
<?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)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mautic\LeadBundle\Services;
|
||||
|
||||
use Mautic\CoreBundle\Helper\Tree\IntNode;
|
||||
use Mautic\CoreBundle\Helper\Tree\NodeInterface;
|
||||
use Mautic\LeadBundle\Entity\LeadList;
|
||||
use Mautic\LeadBundle\Model\ListModel;
|
||||
use Symfony\Component\Routing\RouterInterface;
|
||||
|
||||
class SegmentDependencyTreeFactory
|
||||
{
|
||||
/**
|
||||
* @var int[]
|
||||
*/
|
||||
private array $usedSegmentIds = [];
|
||||
|
||||
public function __construct(
|
||||
private ListModel $segmentModel,
|
||||
private RouterInterface $router,
|
||||
) {
|
||||
}
|
||||
|
||||
public function buildTree(LeadList $segment, ?NodeInterface $rootNode = null): NodeInterface
|
||||
{
|
||||
$rootNode ??= new IntNode($segment->getId());
|
||||
$childSegments = $this->findChildSegments($segment);
|
||||
|
||||
$rootNode->addParam('name', $segment->getName());
|
||||
$rootNode->addParam('link', $this->generateSegmentDetailRoute($segment));
|
||||
|
||||
$this->usedSegmentIds[] = $segment->getId();
|
||||
|
||||
foreach ($childSegments as $childSegment) {
|
||||
$childNode = new IntNode($childSegment->getId());
|
||||
$rootNode->addChild($childNode);
|
||||
$childNode->addParam('name', $childSegment->getName());
|
||||
$childNode->addParam('link', $this->generateSegmentDetailRoute($childSegment));
|
||||
|
||||
// Be aware of the loops here. We must stop building children
|
||||
// and report the problem instead if there is a loop or duplicate segments.
|
||||
if (!in_array($childSegment->getId(), $this->usedSegmentIds)) {
|
||||
$this->buildTree($childSegment, $childNode);
|
||||
} else {
|
||||
$childNode->addParam('message', 'This segment already exists in the segment dependency tree');
|
||||
}
|
||||
}
|
||||
|
||||
return $rootNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return LeadList[]
|
||||
*/
|
||||
private function findChildSegments(LeadList $segment): array
|
||||
{
|
||||
$segmentMembershipFilters = array_filter(
|
||||
$segment->getFilters(),
|
||||
fn (array $filter): bool => 'leadlist' === $filter['type']
|
||||
);
|
||||
|
||||
if (!$segmentMembershipFilters) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$childSegmentIds = [];
|
||||
|
||||
foreach ($segmentMembershipFilters as $filter) {
|
||||
// Old segments don't use properties array.
|
||||
$segmentIds = $filter['properties']['filter'] ?? $filter['filter'];
|
||||
foreach ($segmentIds as $childSegmentId) {
|
||||
$childSegmentIds[] = (int) $childSegmentId;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->segmentModel->getRepository()->findBy(['id' => $childSegmentIds]);
|
||||
}
|
||||
|
||||
private function generateSegmentDetailRoute(LeadList $segment): string
|
||||
{
|
||||
return $this->router->generate(
|
||||
'mautic_segment_action',
|
||||
[
|
||||
'objectAction' => 'view',
|
||||
'objectId' => $segment->getId(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user