Spaces:
No application file
No application file
namespace Mautic\LeadBundle\Event; | |
use Mautic\CoreBundle\Helper\Chart\ChartQuery; | |
use Mautic\CoreBundle\Helper\DateTimeHelper; | |
use Mautic\LeadBundle\Entity\Lead; | |
use Symfony\Contracts\EventDispatcher\Event; | |
class LeadTimelineEvent extends Event | |
{ | |
/** | |
* Container with all filtered events. | |
* | |
* @var array | |
*/ | |
protected $events = []; | |
/** | |
* Container with all registered events types. | |
* | |
* @var array | |
*/ | |
protected $eventTypes = []; | |
/** | |
* Array of filters | |
* search => (string) search term | |
* includeEvents => (array) event types to include | |
* excludeEvents => (array) event types to exclude. | |
* | |
* @var array | |
*/ | |
protected $filters = []; | |
/** | |
* @var array<string, int> | |
*/ | |
protected $totalEvents = []; | |
/** | |
* @var array | |
*/ | |
protected $totalEventsByUnit = []; | |
/** | |
* @var bool | |
*/ | |
protected $countOnly = false; | |
/** | |
* @var \DateTimeInterface|null | |
*/ | |
protected $dateFrom; | |
/** | |
* @var \DateTimeInterface|null | |
*/ | |
protected $dateTo; | |
/** | |
* Time unit to group counts by (M = month, D = day, Y = year, null = no grouping). | |
* | |
* @var string | |
*/ | |
protected $groupUnit; | |
/** | |
* @var ChartQuery | |
*/ | |
protected $chartQuery; | |
/** | |
* @var bool | |
*/ | |
protected $fetchTypesOnly = false; | |
/** | |
* @var array | |
*/ | |
protected $serializerGroups = [ | |
'ipAddressList', | |
]; | |
/** | |
* @param Lead|null $lead Lead entity for the lead the timeline is being generated for | |
* @param int $page | |
* @param int $limit Limit per type | |
* @param bool $forTimeline | |
* @param string|null $siteDomain | |
*/ | |
public function __construct( | |
protected ?Lead $lead = null, | |
array $filters = [], | |
protected ?array $orderBy = null, | |
protected $page = 1, | |
protected $limit = 25, | |
protected $forTimeline = true, | |
protected $siteDomain = null | |
) { | |
$this->filters = !empty($filters) | |
? $filters | |
: | |
[ | |
'search' => '', | |
'includeEvents' => [], | |
'excludeEvents' => [], | |
]; | |
if (!empty($filters['dateFrom'])) { | |
$this->dateFrom = ($filters['dateFrom'] instanceof \DateTime) ? $filters['dateFrom'] : new \DateTime($filters['dateFrom']); | |
} | |
if (!empty($filters['dateTo'])) { | |
$this->dateTo = ($filters['dateTo'] instanceof \DateTime) ? $filters['dateTo'] : new \DateTime($filters['dateTo']); | |
} | |
} | |
/** | |
* Add an event to the container. | |
* | |
* The data should be an associative array with the following data: | |
* 'event' => string The event name | |
* 'timestamp' => \DateTime The timestamp of the event | |
* 'extra' => array An optional array of extra data for the event | |
* | |
* @param array $data Data array for the table | |
*/ | |
public function addEvent(array $data): void | |
{ | |
if ($this->countOnly) { | |
// BC support for old format | |
if ($this->groupUnit && $this->chartQuery) { | |
$countData = [ | |
[ | |
'date' => $data['timestamp'], | |
'count' => 1, | |
], | |
]; | |
$count = $this->chartQuery->completeTimeData($countData); | |
$this->addToCounter($data['event'], $count); | |
} else { | |
if (!isset($this->totalEvents[$data['event']])) { | |
$this->totalEvents[$data['event']] = 0; | |
} | |
++$this->totalEvents[$data['event']]; | |
} | |
} else { | |
if (!isset($this->events[$data['event']])) { | |
$this->events[$data['event']] = []; | |
} | |
if (!$this->isForTimeline()) { | |
// standardize the payload | |
$keepThese = [ | |
'event' => true, | |
'eventId' => true, | |
'eventLabel' => true, | |
'eventType' => true, | |
'timestamp' => true, | |
'contactId' => true, | |
'extra' => true, | |
]; | |
$data = array_intersect_key($data, $keepThese); | |
// Rename extra to details | |
if (isset($data['extra'])) { | |
$data['details'] = $data['extra']; | |
$data['details'] = $this->prepareDetailsForAPI($data['details']); | |
unset($data['extra']); | |
} | |
// Ensure a full URL | |
if ($this->siteDomain && isset($data['eventLabel']) && is_array($data['eventLabel']) && isset($data['eventLabel']['href'])) { | |
// If this does not have a http, then assume a Mautic URL | |
if (!str_contains($data['eventLabel']['href'], '://')) { | |
$data['eventLabel']['href'] = $this->siteDomain.$data['eventLabel']['href']; | |
} | |
} | |
} | |
if (empty($data['eventId'])) { | |
// Every entry should have an eventId so generate one if the listener itself didn't handle this | |
$data['eventId'] = $this->generateEventId($data); | |
} | |
$this->events[$data['event']][] = $data; | |
} | |
} | |
/** | |
* Fetch the events. | |
* | |
* @return array Events sorted by timestamp with most recent event first | |
*/ | |
public function getEvents() | |
{ | |
if (empty($this->events)) { | |
return []; | |
} | |
$events = call_user_func_array('array_merge', array_values($this->events)); | |
foreach ($events as &$e) { | |
if (!$e['timestamp'] instanceof \DateTime) { | |
$dt = new DateTimeHelper($e['timestamp'], 'Y-m-d H:i:s', 'UTC'); | |
$e['timestamp'] = $dt->getDateTime(); | |
unset($dt); | |
} | |
} | |
if (!empty($this->orderBy)) { | |
usort( | |
$events, | |
function ($a, $b) { | |
switch ($this->orderBy[0]) { | |
case 'eventLabel': | |
$aLabel = ''; | |
if (isset($a['eventLabel'])) { | |
$aLabel = (is_array($a['eventLabel'])) ? $a['eventLabel']['label'] : $a['eventLabel']; | |
} | |
$bLabel = ''; | |
if (isset($b['eventLabel'])) { | |
$bLabel = (is_array($b['eventLabel'])) ? $b['eventLabel']['label'] : $b['eventLabel']; | |
} | |
return strnatcmp($aLabel, $bLabel); | |
case 'timestamp': | |
if ($a['timestamp'] == $b['timestamp']) { | |
$aPriority = isset($a['eventPriority']) ? (int) $a['eventPriority'] : 0; | |
$bPriority = isset($b['eventPriority']) ? (int) $b['eventPriority'] : 0; | |
return $aPriority - $bPriority; | |
} | |
return $a['timestamp'] < $b['timestamp'] ? -1 : 1; | |
} | |
} | |
); | |
if ('DESC' == $this->orderBy[1]) { | |
$events = array_reverse($events); | |
} | |
} | |
return $events; | |
} | |
/** | |
* Get the max number of pages for pagination. | |
* | |
* @return float|int | |
*/ | |
public function getMaxPage() | |
{ | |
if (!$this->totalEvents) { | |
return 1; | |
} | |
// Find the type that has the largest number of total records | |
$largest = max($this->totalEvents); | |
// Max page is $largest / $limit | |
return ($largest) ? ceil($largest / $this->limit) : 1; | |
} | |
/** | |
* Add an event type to the container. | |
* | |
* @param string $eventTypeKey Identifier of the event type | |
* @param string $eventTypeName Name of the event type for humans | |
*/ | |
public function addEventType($eventTypeKey, $eventTypeName): void | |
{ | |
$this->eventTypes[$eventTypeKey] = $eventTypeName; | |
} | |
/** | |
* Fetch the event types. | |
* | |
* @return array of available types | |
*/ | |
public function getEventTypes() | |
{ | |
natcasesort($this->eventTypes); | |
return $this->eventTypes; | |
} | |
/** | |
* Fetch the filter array for queries. | |
* | |
* @return array of wanted filteres. Empty == all | |
*/ | |
public function getEventFilters() | |
{ | |
return $this->filters['search']; | |
} | |
/** | |
* Fetch the order for queries. | |
* | |
* @return array|null | |
*/ | |
public function getEventOrder() | |
{ | |
return $this->orderBy; | |
} | |
/** | |
* Fetch start/limit for queries. | |
*/ | |
public function getEventLimit(): array | |
{ | |
return [ | |
'leadId' => ($this->lead instanceof Lead) ? $this->lead->getId() : null, | |
'limit' => $this->limit, | |
'start' => (1 >= $this->page) ? 0 : ($this->page - 1) * $this->limit, | |
]; | |
} | |
public function getQueryOptions(): array | |
{ | |
return array_merge( | |
[ | |
'search' => $this->filters['search'], | |
'order' => $this->orderBy, | |
'paginated' => !$this->countOnly, | |
'unitCounts' => $this->countOnly && $this->groupUnit, | |
'unit' => $this->groupUnit, | |
'fromDate' => $this->dateFrom, | |
'toDate' => $this->dateTo, | |
'chartQuery' => $this->chartQuery, | |
], | |
$this->getEventLimit() | |
); | |
} | |
/** | |
* Fetches the lead being acted on. | |
* | |
* @return Lead | |
*/ | |
public function getLead() | |
{ | |
return $this->lead; | |
} | |
/** | |
* Returns the lead ID if any. | |
*/ | |
public function getLeadId(): ?int | |
{ | |
return ($this->lead instanceof Lead) ? $this->lead->getId() : null; | |
} | |
/** | |
* Determine if an event type should be included. | |
* | |
* @param bool $inclusive | |
*/ | |
public function isApplicable($eventType, $inclusive = false): bool | |
{ | |
if ($this->fetchTypesOnly) { | |
return false; | |
} | |
if (in_array($eventType, $this->filters['excludeEvents'])) { | |
return false; | |
} | |
if (!empty($this->filters['includeEvents'])) { | |
if (!in_array($eventType, $this->filters['includeEvents'])) { | |
return false; | |
} | |
} elseif ($inclusive) { | |
return false; | |
} | |
return true; | |
} | |
/** | |
* Check if the event is getting an engagement count only. | |
* | |
* @return bool | |
*/ | |
public function isEngagementCount() | |
{ | |
return $this->countOnly; | |
} | |
/** | |
* Get the date range to get counts by. | |
*/ | |
public function getCountDateRange(): array | |
{ | |
return ['from' => $this->dateFrom, 'to' => $this->dateTo]; | |
} | |
/** | |
* Get the unit counts are to be grouped by. | |
* | |
* @return string | |
*/ | |
public function getCountGroupingUnit() | |
{ | |
return $this->groupUnit; | |
} | |
/** | |
* Get total number of events for pagination. | |
* | |
* @return mixed[] | |
*/ | |
public function getEventCounter(): array | |
{ | |
// BC support for old formats | |
foreach ($this->events as $type => $events) { | |
if (!isset($this->totalEvents[$type])) { | |
$this->totalEvents[$type] = count($events); | |
} | |
} | |
$counter = [ | |
'total' => array_sum($this->totalEvents), | |
]; | |
if ($this->countOnly && $this->groupUnit) { | |
$counter['byUnit'] = $this->totalEventsByUnit; | |
} | |
return $counter; | |
} | |
/** | |
* Add to the event counters. | |
* | |
* @param int|array $count | |
*/ | |
public function addToCounter($eventType, $count): void | |
{ | |
if (!isset($this->totalEvents[$eventType])) { | |
$this->totalEvents[$eventType] = 0; | |
} | |
if (is_array($count)) { | |
if (isset($count['total'])) { | |
$this->totalEvents[$eventType] += $count['total']; | |
} elseif ($this->isEngagementCount() && $this->groupUnit) { | |
// Group counts across events by unit | |
foreach ($count as $key => $data) { | |
if (!isset($this->totalEventsByUnit[$key])) { | |
$this->totalEventsByUnit[$key] = 0; | |
} | |
$this->totalEventsByUnit[$key] += (int) $data; | |
$this->totalEvents[$eventType] += (int) $data; | |
} | |
} else { | |
$this->totalEvents[$eventType] = array_sum($count); | |
} | |
} else { | |
$this->totalEvents[$eventType] += (int) $count; | |
} | |
} | |
/** | |
* Subtract from the total counter if there is an event that was skipped for whatever reason. | |
*/ | |
public function subtractFromCounter($eventType, $count = 1): void | |
{ | |
$this->totalEvents[$eventType] -= $count; | |
} | |
/** | |
* Calculate engagement counts only. | |
*/ | |
public function setCountOnly(\DateTime $dateFrom, \DateTime $dateTo, $groupUnit = null, ChartQuery $chartQuery = null): void | |
{ | |
$this->countOnly = true; | |
$this->dateFrom = $dateFrom; | |
$this->dateTo = $dateTo; | |
$this->groupUnit = $groupUnit; | |
$this->chartQuery = $chartQuery; | |
} | |
/** | |
* Get chart query helper to format dates. | |
* | |
* @return ChartQuery | |
*/ | |
public function getChartQuery() | |
{ | |
return $this->chartQuery; | |
} | |
/** | |
* Check if the data is to be display for the contact's timeline or used for the API. | |
* | |
* @return bool | |
*/ | |
public function isForTimeline() | |
{ | |
return $this->forTimeline; | |
} | |
/** | |
* Add a serializer group for API formatting. | |
*/ | |
public function addSerializerGroup($group): void | |
{ | |
if (is_array($group)) { | |
$this->serializerGroups = array_merge( | |
$this->serializerGroups, | |
$group | |
); | |
} else { | |
$this->serializerGroups[$group] = $group; | |
} | |
} | |
/** | |
* @return array | |
*/ | |
public function getSerializerGroups() | |
{ | |
return $this->serializerGroups; | |
} | |
/** | |
* Will cause isApplicable to return false for all in order to just compile a list of event types. | |
*/ | |
public function fetchTypesOnly(): void | |
{ | |
$this->fetchTypesOnly = true; | |
} | |
/** | |
* Convert all snake case keys o camel case for API congruency. | |
*/ | |
private function prepareDetailsForAPI(array $details): array | |
{ | |
foreach ($details as $key => &$detailValues) { | |
if (is_array($detailValues)) { | |
$this->prepareDetailsForAPI($detailValues); | |
} | |
if ('lead_id' === $key) { | |
// Don't include this as it should be included in parent as contactId | |
unset($details[$key]); | |
continue; | |
} | |
if (strstr($key, '_')) { | |
$newKey = lcfirst(str_replace('_', '', ucwords($key, '_'))); | |
$details[$newKey] = $details[$key]; | |
unset($details[$key]); | |
} | |
} | |
return $details; | |
} | |
/** | |
* Generate something consistent for this event to identify this log entry. | |
*/ | |
private function generateEventId(array $data): string | |
{ | |
return $data['eventType'].hash('crc32', json_encode($data), false); | |
} | |
} | |