576 lines
16 KiB
PHP
Executable File
576 lines
16 KiB
PHP
Executable File
<?php
|
|
|
|
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);
|
|
}
|
|
}
|