Initial commit: CloudOps infrastructure platform

This commit is contained in:
root
2026-04-09 19:58:57 +02:00
commit 1166a52f26
7762 changed files with 839452 additions and 0 deletions

View File

@@ -0,0 +1,47 @@
.col-point-id, .col-pointtrigger-id {
width: 75px;
}
.trigger-event-group-header {
font-size: 1.1em;
font-weight: bold;
}
#triggerEvents .trigger-event-row {
padding: 20px;
margin-bottom: 0 !important;
border: 1px solid var(--border-subtle);
position: relative;
transition: var(--transition-all-productive);
}
#triggerEvents .trigger-event-row .event-label {
font-size: 1.1em;
font-weight: bold;
display: block;
}
#triggerEvents .trigger-event-row .event-descr {
font-size: 0.9em;
display: block;
}
#triggerEvents .trigger-event-row:hover {
background-color: var(--layer-hover);
}
#triggerEvents .trigger-event-row.bg-danger:hover {
background-color: var(--support-error-inverse);
}
#triggerEvents .form-buttons {
float: right;
position: absolute;
top: 50%;
right: 20px;
transform: translateY(-50%);
}
.col-pointtrigger-color {
width: 50px;
}

View File

@@ -0,0 +1,121 @@
//PointBundle
Mautic.pointOnLoad = function (container) {
if (mQuery(container + ' #list-search').length) {
Mautic.activateSearchAutocomplete('list-search', 'point');
}
};
Mautic.pointTriggerOnLoad = function (container) {
if (mQuery(container + ' #list-search').length) {
Mautic.activateSearchAutocomplete('list-search', 'point.trigger');
}
if (mQuery('#triggerEvents')) {
//make the fields sortable
mQuery('#triggerEvents').sortable({
items: '.trigger-event-row',
handle: '.reorder-handle',
stop: function(i) {
mQuery.ajax({
type: "POST",
url: mauticAjaxUrl + "?action=point:reorderTriggerEvents",
data: mQuery('#triggerEvents').sortable("serialize") + "&triggerId=" + mQuery('#pointtrigger_sessionId').val()
});
}
});
mQuery('#triggerEvents .trigger-event-row').on('mouseover.triggerevents', function() {
mQuery(this).find('.form-buttons').removeClass('hide');
}).on('mouseout.triggerevents', function() {
mQuery(this).find('.form-buttons').addClass('hide');
}).on('dblclick.triggerevents', function(event) {
event.preventDefault();
mQuery(this).find('.btn-edit').first().click();
});
}
};
Mautic.pointTriggerEventOnLoad = function (container, response) {
//new action created so append it to the form
if (response.eventHtml) {
var newHtml = response.eventHtml;
var eventId = '#triggerEvent_' + response.eventId;
if (mQuery(eventId).length) {
//replace content
mQuery(eventId).replaceWith(newHtml);
var newField = false;
} else {
//append content
mQuery(newHtml).appendTo('#triggerEvents');
var newField = true;
}
//initialize tooltips
mQuery(eventId + " *[data-toggle='tooltip']").tooltip({html: true});
//activate new stuff
mQuery(eventId + " a[data-toggle='ajax']").click(function (event) {
event.preventDefault();
return Mautic.ajaxifyLink(this, event);
});
//initialize ajax'd modals
mQuery(eventId + " a[data-toggle='ajaxmodal']").on('click.ajaxmodal', function (event) {
event.preventDefault();
Mautic.ajaxifyModal(this, event);
});
mQuery('#triggerEvents .trigger-event-row').off(".triggerevents");
mQuery('#triggerEvents .trigger-event-row').on('mouseover.triggerevents', function() {
mQuery(this).find('.form-buttons').removeClass('hide');
}).on('mouseout.triggerevents', function() {
mQuery(this).find('.form-buttons').addClass('hide');
}).on('dblclick.triggerevents', function(event) {
event.preventDefault();
mQuery(this).find('.btn-edit').first().click();
});
//show events panel
if (!mQuery('#events-panel').hasClass('in')) {
mQuery('a[href="#events-panel"]').trigger('click');
}
if (mQuery('#triggerEventPlaceholder').length) {
mQuery('#triggerEventPlaceholder').remove();
}
}
};
Mautic.getPointActionPropertiesForm = function(actionType) {
Mautic.activateLabelLoadingIndicator('point_type');
var query = "action=point:getActionForm&actionType=" + actionType;
mQuery.ajax({
url: mauticAjaxUrl,
type: "POST",
data: query,
dataType: "json",
success: function (response) {
if (typeof response.html != 'undefined') {
mQuery('#pointActionProperties').html(response.html);
Mautic.onPageLoad('#pointActionProperties', response);
}
},
error: function (request, textStatus, errorThrown) {
Mautic.processAjaxError(request, textStatus, errorThrown);
},
complete: function() {
Mautic.removeLabelLoadingIndicator();
}
});
};
Mautic.EnablesOption = function (urlActionProperty) {
if (urlActionProperty === 'point_properties_returns_within' && mQuery('#point_properties_returns_within').val() > 0) {
mQuery('#point_properties_returns_after').val(0);
} else {
if (urlActionProperty === 'point_properties_returns_after' && mQuery('#point_properties_returns_after').val() > 0) {
mQuery('#point_properties_returns_within').val(0);
}
}
};

View File

@@ -0,0 +1,118 @@
<?php
return [
'routes' => [
'main' => [
'mautic_pointtriggerevent_action' => [
'path' => '/points/triggers/events/{objectAction}/{objectId}',
'controller' => 'Mautic\PointBundle\Controller\TriggerEventController::executeAction',
],
'mautic_pointtrigger_index' => [
'path' => '/points/triggers/{page}',
'controller' => 'Mautic\PointBundle\Controller\TriggerController::indexAction',
],
'mautic_pointtrigger_action' => [
'path' => '/points/triggers/{objectAction}/{objectId}',
'controller' => 'Mautic\PointBundle\Controller\TriggerController::executeAction',
],
'mautic_point.group_index' => [
'path' => '/points/groups/{page}',
'controller' => 'Mautic\PointBundle\Controller\GroupController::indexAction',
],
'mautic_point.group_action' => [
'path' => '/points/groups/{objectAction}/{objectId}',
'controller' => 'Mautic\PointBundle\Controller\GroupController::executeAction',
],
'mautic_point_index' => [
'path' => '/points/{page}',
'controller' => 'Mautic\PointBundle\Controller\PointController::indexAction',
],
'mautic_point_action' => [
'path' => '/points/{objectAction}/{objectId}',
'controller' => 'Mautic\PointBundle\Controller\PointController::executeAction',
],
],
'api' => [
'mautic_api_pointactionsstandard' => [
'standard_entity' => true,
'name' => 'points',
'path' => '/points',
'controller' => Mautic\PointBundle\Controller\Api\PointApiController::class,
],
'mautic_api_getpointactiontypes' => [
'path' => '/points/actions/types',
'controller' => 'Mautic\PointBundle\Controller\Api\PointApiController::getPointActionTypesAction',
],
'mautic_api_pointtriggersstandard' => [
'standard_entity' => true,
'name' => 'triggers',
'path' => '/points/triggers',
'controller' => Mautic\PointBundle\Controller\Api\TriggerApiController::class,
],
'mautic_api_getpointtriggereventtypes' => [
'path' => '/points/triggers/events/types',
'controller' => 'Mautic\PointBundle\Controller\Api\TriggerApiController::getPointTriggerEventTypesAction',
],
'mautic_api_pointtriggerdeleteevents' => [
'path' => '/points/triggers/{triggerId}/events/delete',
'controller' => 'Mautic\PointBundle\Controller\Api\TriggerApiController::deletePointTriggerEventsAction',
'method' => 'DELETE',
],
'mautic_api_adjustcontactpoints' => [
'path' => '/contacts/{leadId}/points/{operator}/{delta}',
'controller' => 'Mautic\PointBundle\Controller\Api\PointApiController::adjustPointsAction',
'method' => 'POST',
],
'mautic_api_pointgroupsstandard' => [
'standard_entity' => true,
'name' => 'pointGroups',
'path' => '/points/groups',
'controller' => Mautic\PointBundle\Controller\Api\PointGroupsApiController::class,
],
'mautic_api_getcontactpointgroups' => [
'path' => '/contacts/{contactId}/points/groups',
'controller' => 'Mautic\PointBundle\Controller\Api\PointGroupsApiController::getContactPointGroupsAction',
],
'mautic_api_getcontactpointgroup' => [
'path' => '/contacts/{contactId}/points/groups/{groupId}',
'controller' => 'Mautic\PointBundle\Controller\Api\PointGroupsApiController::getContactPointGroupAction',
],
'mautic_api_adjustcontactgrouppoints' => [
'path' => '/contacts/{contactId}/points/groups/{groupId}/{operator}/{value}',
'controller' => 'Mautic\PointBundle\Controller\Api\PointGroupsApiController::adjustGroupPointsAction',
'method' => 'POST',
],
],
],
'menu' => [
'main' => [
'mautic.points.menu.root' => [
'id' => 'mautic_points_root',
'iconClass' => 'ri-coins-fill',
'access' => ['point:points:view', 'point:triggers:view', 'point:groups:view'],
'priority' => 30,
'children' => [
'mautic.point.menu.index' => [
'route' => 'mautic_point_index',
'access' => 'point:points:view',
],
'mautic.point.trigger.menu.index' => [
'route' => 'mautic_pointtrigger_index',
'access' => 'point:triggers:view',
],
'mautic.point.group.menu.index' => [
'route' => 'mautic_point.group_index',
'access' => 'point:groups:view',
],
],
],
],
],
'categories' => [
'point' => [
'class' => Mautic\PointBundle\Entity\Point::class,
],
],
];

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use Mautic\CoreBundle\DependencyInjection\MauticCoreExtension;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return function (ContainerConfigurator $configurator): void {
$services = $configurator->services()
->defaults()
->autowire()
->autoconfigure()
->public();
$excludes = [
];
$services->load('Mautic\\PointBundle\\', '../')
->exclude('../{'.implode(',', array_merge(MauticCoreExtension::DEFAULT_EXCLUDES, $excludes)).'}');
$services->load('Mautic\\PointBundle\\Entity\\', '../Entity/*Repository.php')
->tag(Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\ServiceRepositoryCompilerPass::REPOSITORY_SERVICE_TAG);
$services->alias('mautic.point.model.point', Mautic\PointBundle\Model\PointModel::class);
$services->alias('mautic.point.model.triggerevent', Mautic\PointBundle\Model\TriggerEventModel::class);
$services->alias('mautic.point.model.trigger', Mautic\PointBundle\Model\TriggerModel::class);
$services->alias('mautic.point.model.group', Mautic\PointBundle\Model\PointGroupModel::class);
$services->alias('mautic.point.repository.lead_point_log', Mautic\PointBundle\Entity\LeadPointLogRepository::class);
$services->alias('mautic.point.repository.lead_trigger_log', Mautic\PointBundle\Entity\LeadTriggerLogRepository::class);
};

View File

@@ -0,0 +1,69 @@
<?php
namespace Mautic\PointBundle\Controller;
use Mautic\CoreBundle\Controller\AjaxController as CommonAjaxController;
use Mautic\CoreBundle\Helper\InputHelper;
use Mautic\PointBundle\Form\Type\PointActionType;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\Request;
class AjaxController extends CommonAjaxController
{
public function reorderTriggerEventsAction(Request $request): \Symfony\Component\HttpFoundation\JsonResponse
{
$dataArray = ['success' => 0];
$session = $request->getSession();
$triggerId = InputHelper::clean($request->request->get('triggerId'));
$sessionName = 'mautic.point.'.$triggerId.'.triggerevents.modified';
$order = InputHelper::clean($request->request->get('triggerEvent'));
$components = $session->get($sessionName);
if (!empty($order) && !empty($components)) {
$components = array_replace(array_flip($order), $components);
$session->set($sessionName, $components);
$dataArray['success'] = 1;
}
return $this->sendJsonResponse($dataArray);
}
public function getActionFormAction(Request $request, FormFactoryInterface $formFactory): \Symfony\Component\HttpFoundation\JsonResponse
{
$type = InputHelper::clean($request->request->get('actionType'));
$dataArray = [
'success' => 0,
'html' => '',
];
if (!empty($type)) {
// get the HTML for the form
/** @var \Mautic\PointBundle\Model\PointModel $model */
$model = $this->getModel('point');
$actions = $model->getPointActions();
if (isset($actions['actions'][$type])) {
$themes = ['@MauticPoint/FormTheme/Action/_pointaction_properties_row.html.twig'];
if (!empty($actions['actions'][$type]['formTheme'])) {
$themes[] = $actions['actions'][$type]['formTheme'];
}
$formType = (!empty($actions['actions'][$type]['formType'])) ? $actions['actions'][$type]['formType'] : null;
$formTypeOptions = (!empty($actions['actions'][$type]['formTypeOptions'])) ? $actions['actions'][$type]['formTypeOptions'] : [];
$form = $formFactory->create(PointActionType::class, [], ['formType' => $formType, 'formTypeOptions' => $formTypeOptions]);
$html = $this->renderView('@MauticPoint/Point/actionform.html.twig', [
'form' => $form->createView(),
'formThemes' => $themes,
]);
// replace pointaction with point
$html = str_replace('pointaction', 'point', $html);
$dataArray['html'] = $html;
$dataArray['success'] = 1;
}
}
return $this->sendJsonResponse($dataArray);
}
}

View File

@@ -0,0 +1,114 @@
<?php
namespace Mautic\PointBundle\Controller\Api;
use Doctrine\Persistence\ManagerRegistry;
use Mautic\ApiBundle\Controller\CommonApiController;
use Mautic\ApiBundle\Helper\EntityResultHelper;
use Mautic\CoreBundle\Factory\ModelFactory;
use Mautic\CoreBundle\Helper\AppVersion;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\InputHelper;
use Mautic\CoreBundle\Helper\IpLookupHelper;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\LeadBundle\Controller\LeadAccessTrait;
use Mautic\LeadBundle\Model\LeadModel;
use Mautic\PointBundle\Entity\Point;
use Mautic\PointBundle\Model\PointModel;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\RouterInterface;
/**
* @extends CommonApiController<Point>
*/
class PointApiController extends CommonApiController
{
use LeadAccessTrait;
protected LeadModel $leadModel;
/**
* @var PointModel|null
*/
protected $model;
public function __construct(CorePermissions $security, Translator $translator, EntityResultHelper $entityResultHelper, RouterInterface $router, FormFactoryInterface $formFactory, AppVersion $appVersion, RequestStack $requestStack, ManagerRegistry $doctrine, ModelFactory $modelFactory, EventDispatcherInterface $dispatcher, CoreParametersHelper $coreParametersHelper)
{
$leadModel = $modelFactory->getModel('lead');
\assert($leadModel instanceof LeadModel);
$pointModel = $modelFactory->getModel('point');
\assert($pointModel instanceof PointModel);
$this->model = $pointModel;
$this->leadModel = $leadModel;
$this->entityClass = Point::class;
$this->entityNameOne = 'point';
$this->entityNameMulti = 'points';
$this->serializerGroups = ['pointDetails', 'categoryList', 'publishDetails'];
parent::__construct($security, $translator, $entityResultHelper, $router, $formFactory, $appVersion, $requestStack, $doctrine, $modelFactory, $dispatcher, $coreParametersHelper);
}
/**
* Return array of available point action types.
*/
public function getPointActionTypesAction()
{
if (!$this->security->isGranted([$this->permissionBase.':view', $this->permissionBase.':viewown'])) {
return $this->accessDenied();
}
$actionTypes = $this->model->getPointActions();
$view = $this->view(['pointActionTypes' => $actionTypes['list']]);
return $this->handleView($view);
}
/**
* Subtract points from a lead.
*
* @param int $leadId
* @param string $operator
* @param int $delta
*
* @return Response
*/
public function adjustPointsAction(Request $request, IpLookupHelper $ipLookupHelper, $leadId, $operator, $delta)
{
$lead = $this->checkLeadAccess($leadId, 'edit');
if ($lead instanceof Response) {
return $lead;
}
try {
$this->logApiPointChange($request, $ipLookupHelper, $lead, $delta, $operator);
} catch (\Exception $e) {
return $this->returnError($e->getMessage(), Response::HTTP_BAD_REQUEST);
}
return $this->handleView($this->view(['success' => 1], Response::HTTP_OK));
}
/**
* Log the lead points change.
*
* @param int $delta
*/
protected function logApiPointChange(Request $request, IpLookupHelper $ipLookupHelper, $lead, $delta, $operator)
{
$trans = $this->translator;
$ip = $ipLookupHelper->getIpAddress();
$eventName = InputHelper::clean($request->request->get('eventName', $trans->trans('mautic.lead.lead.submitaction.operator_'.$operator)));
$actionName = InputHelper::clean($request->request->get('actionName', $trans->trans('mautic.lead.event.api')));
$lead->adjustPoints($delta, $operator);
$lead->addPointsChangeLogEntry('API', $eventName, $actionName, $delta, $ip);
$this->leadModel->saveEntity($lead, false);
}
}

View File

@@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
namespace Mautic\PointBundle\Controller\Api;
use Doctrine\Persistence\ManagerRegistry;
use Mautic\ApiBundle\Controller\CommonApiController;
use Mautic\ApiBundle\Helper\EntityResultHelper;
use Mautic\CoreBundle\Factory\ModelFactory;
use Mautic\CoreBundle\Helper\AppVersion;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\InputHelper;
use Mautic\CoreBundle\Helper\IpLookupHelper;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\LeadBundle\Model\LeadModel;
use Mautic\PointBundle\Entity\Group;
use Mautic\PointBundle\Model\PointGroupModel;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\RouterInterface;
/**
* @extends CommonApiController<Group>
*/
class PointGroupsApiController extends CommonApiController
{
/**
* @var PointGroupModel
*/
protected $model;
public function __construct(CorePermissions $security, Translator $translator, EntityResultHelper $entityResultHelper, RouterInterface $router, FormFactoryInterface $formFactory, AppVersion $appVersion, RequestStack $requestStack, ManagerRegistry $doctrine, ModelFactory $modelFactory, EventDispatcherInterface $dispatcher, CoreParametersHelper $coreParametersHelper, PointGroupModel $pointGroupModel, private LeadModel $leadModel)
{
$this->model = $pointGroupModel;
$this->entityClass = Group::class;
$this->entityNameOne = 'pointGroup';
$this->entityNameMulti = 'pointGroups';
$this->serializerGroups = ['pointGroupDetails', 'pointGroupList', 'publishDetails'];
parent::__construct($security, $translator, $entityResultHelper, $router, $formFactory, $appVersion, $requestStack, $doctrine, $modelFactory, $dispatcher, $coreParametersHelper);
}
public function getContactPointGroupsAction(int $contactId): Response
{
$contact = $this->leadModel->getEntity($contactId);
if (null === $contact) {
return $this->notFound($this->translator->trans('mautic.lead.event.api.lead.not.found'));
}
if (!$this->checkEntityAccess($contact)) {
return $this->accessDenied();
}
$groupScores = $contact->getGroupScores();
$view = $this->view(
[
'total' => count($groupScores),
'groupScores' => $groupScores,
],
Response::HTTP_OK
);
$context = $view->getContext()->setGroups(['groupContactScoreDetails', 'pointGroupDetails']);
$view->setContext($context);
return $this->handleView($view);
}
public function getContactPointGroupAction(int $contactId, int $groupId): Response
{
$contact = $this->leadModel->getEntity($contactId);
if (null === $contact) {
return $this->notFound($this->translator->trans('mautic.lead.event.api.lead.not.found'));
}
if (!$this->checkEntityAccess($contact)) {
return $this->accessDenied();
}
$pointGroup = $this->model->getEntity($groupId);
if (null === $pointGroup) {
return $this->notFound($this->translator->trans('mautic.lead.event.api.point.group.not.found'));
}
$groupScore = $contact->getGroupScore($pointGroup);
$view = $this->view(
[
'groupScore' => $groupScore,
],
Response::HTTP_OK
);
$context = $view->getContext()->setGroups(['groupContactScoreDetails', 'pointGroupDetails']);
$view->setContext($context);
return $this->handleView($view);
}
public function adjustGroupPointsAction(Request $request, IpLookupHelper $ipLookupHelper, int $contactId, int $groupId, string $operator, int $value): Response
{
$contact = $this->leadModel->getEntity($contactId);
if (null === $contact) {
return $this->notFound($this->translator->trans('mautic.lead.event.api.lead.not.found'));
}
if (!$this->checkEntityAccess($contact)) {
return $this->accessDenied();
}
$pointGroup = $this->model->getEntity($groupId);
if (null === $pointGroup) {
return $this->notFound($this->translator->trans('mautic.lead.event.api.point.group.not.found'));
}
if (!PointGroupModel::isAllowedPointOperation($operator)) {
return $this->badRequest($this->translator->trans('mautic.lead.event.api.operation.not.allowed'));
}
$oldScore = $contact->getGroupScore($pointGroup)?->getScore();
$contact = $this->model->adjustPoints($contact, $pointGroup, $value, $operator);
$newScore = $contact->getGroupScore($pointGroup)->getScore();
$delta = $newScore - ($oldScore ?? 0);
$eventName = InputHelper::clean($request->request->get('eventName', $this->translator->trans('mautic.point.event.manual_change')));
$actionName = InputHelper::clean($request->request->get('actionName', $this->translator->trans('mautic.lead.event.api')));
$contact->addPointsChangeLogEntry(
type: 'API',
name: $eventName,
action: $actionName,
pointChanges: $delta,
ip: $ipLookupHelper->getIpAddress(),
group: $pointGroup
);
$this->leadModel->saveEntity($contact, false);
$view = $this->view(['groupScore' => $contact->getGroupScore($pointGroup)], Response::HTTP_OK);
$context = $view->getContext()->setGroups(['groupContactScoreDetails', 'pointGroupDetails']);
$view->setContext($context);
return $this->handleView($view);
}
}

View File

@@ -0,0 +1,190 @@
<?php
namespace Mautic\PointBundle\Controller\Api;
use Doctrine\Persistence\ManagerRegistry;
use Mautic\ApiBundle\Controller\CommonApiController;
use Mautic\ApiBundle\Helper\EntityResultHelper;
use Mautic\CoreBundle\Factory\ModelFactory;
use Mautic\CoreBundle\Helper\AppVersion;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\PointBundle\Entity\Trigger;
use Mautic\PointBundle\Model\TriggerEventModel;
use Mautic\PointBundle\Model\TriggerModel;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\RouterInterface;
/**
* @extends CommonApiController<Trigger>
*/
class TriggerApiController extends CommonApiController
{
/**
* @var TriggerModel|null
*/
protected $model;
public function __construct(
CorePermissions $security,
Translator $translator,
EntityResultHelper $entityResultHelper,
RouterInterface $router,
FormFactoryInterface $formFactory,
AppVersion $appVersion,
private ?RequestStack $requestStack,
ManagerRegistry $doctrine,
ModelFactory $modelFactory,
EventDispatcherInterface $dispatcher,
CoreParametersHelper $coreParametersHelper,
) {
$triggerModel = $modelFactory->getModel('point.trigger');
\assert($triggerModel instanceof TriggerModel);
$this->model = $triggerModel;
$this->entityClass = Trigger::class;
$this->entityNameOne = 'trigger';
$this->entityNameMulti = 'triggers';
$this->serializerGroups = ['triggerDetails', 'categoryList', 'publishDetails'];
parent::__construct($security, $translator, $entityResultHelper, $router, $formFactory, $appVersion, $requestStack, $doctrine, $modelFactory, $dispatcher, $coreParametersHelper);
}
protected function preSaveEntity(&$entity, $form, $parameters, $action = 'edit')
{
$method = $this->requestStack->getCurrentRequest()->getMethod();
$triggerEventModel = $this->getModel('point.triggerevent');
$isNew = false;
// Set timestamps
$this->model->setTimestamps($entity, true, false);
if (!$entity->getId()) {
$isNew = true;
// Save the entitz first to get the ID.
// Using the repository function to not trigger the listeners twice.
$this->model->getRepository()->saveEntity($entity);
}
$requestTriggerIds = [];
$currentEvents = $entity->getEvents();
// Add events from the request
if (!empty($parameters['events']) && is_array($parameters['events'])) {
foreach ($parameters['events'] as &$eventParams) {
if (empty($eventParams['id'])) {
// Create an unique ID if not set - the following code requires one
$eventParams['id'] = 'new'.hash('sha1', uniqid(mt_rand()));
$triggerEventEntity = $triggerEventModel->getEntity();
} else {
$triggerEventEntity = $triggerEventModel->getEntity($eventParams['id']);
$requestTriggerIds[] = $eventParams['id'];
}
$triggerEventForm = $this->createTriggerEventEntityForm($triggerEventEntity);
$triggerEventForm->submit($eventParams, 'PATCH' !== $method);
if (!($triggerEventForm->isSubmitted() && $triggerEventForm->isValid())) {
$formErrors = $this->getFormErrorMessages($triggerEventForm);
$msg = $this->getFormErrorMessage($formErrors);
return $this->returnError('Trigger events: '.$msg, Response::HTTP_BAD_REQUEST);
}
}
$this->model->setEvents($entity, $parameters['events']);
}
// Remove events which weren't in the PUT request
if (!$isNew && 'PUT' === $method) {
foreach ($currentEvents as $currentEvent) {
if (!in_array($currentEvent->getId(), $requestTriggerIds)) {
$entity->removeTriggerEvent($currentEvent);
}
}
}
}
/**
* @return FormInterface<mixed>
*/
protected function createTriggerEventEntityForm($entity): FormInterface
{
$triggerEventModel = $this->getModel('point.triggerevent');
\assert($triggerEventModel instanceof TriggerEventModel);
return $triggerEventModel->createForm(
$entity,
$this->formFactory,
null,
[
'csrf_protection' => false,
'allow_extra_fields' => true,
]
);
}
/**
* Return array of available point trigger event types.
*/
public function getPointTriggerEventTypesAction()
{
if (!$this->security->isGranted([$this->permissionBase.':view', $this->permissionBase.':viewown'])) {
return $this->accessDenied();
}
$eventTypesRaw = $this->model->getEvents();
$eventTypes = [];
foreach ($eventTypesRaw as $key => $type) {
$eventTypes[$key] = $type['label'];
}
$view = $this->view(['eventTypes' => $eventTypes]);
return $this->handleView($view);
}
/**
* Delete events from a point trigger.
*
* @param int $triggerId
*
* @return Response
*/
public function deletePointTriggerEventsAction($triggerId)
{
if (!$this->security->isGranted([$this->permissionBase.':editown', $this->permissionBase.':editother'], 'MATCH_ONE')) {
return $this->accessDenied();
}
$entity = $this->model->getEntity($triggerId);
if (null === $entity) {
return $this->notFound();
}
$eventsToDelete = $this->requestStack->getCurrentRequest()->get('events');
$currentEvents = $entity->getEvents();
if (!is_array($eventsToDelete)) {
return $this->badRequest('The events attribute must be array.');
}
foreach ($currentEvents as $currentEvent) {
if (in_array($currentEvent->getId(), $eventsToDelete)) {
$entity->removeTriggerEvent($currentEvent);
}
}
$view = $this->view([$this->entityNameOne => $entity]);
return $this->handleView($view);
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace Mautic\PointBundle\Controller;
use Mautic\CoreBundle\Controller\AbstractStandardFormController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class GroupController extends AbstractStandardFormController
{
protected function getTemplateBase(): string
{
return '@MauticPoint/Group';
}
protected function getModelName(): string
{
return 'point.group';
}
/**
* @param int $page
*/
public function indexAction(Request $request, $page = 1): Response
{
return parent::indexStandard($request, $page);
}
/**
* Generates new form and processes post data.
*
* @return JsonResponse|Response
*/
public function newAction(Request $request)
{
return parent::newStandard($request);
}
/**
* Generates edit form and processes post data.
*
* @param int $objectId
* @param bool $ignorePost
*
* @return JsonResponse|Response
*/
public function editAction(Request $request, $objectId, $ignorePost = false)
{
return parent::editStandard($request, $objectId, $ignorePost);
}
/**
* Deletes the entity.
*
* @param int $objectId
*
* @return JsonResponse|RedirectResponse
*/
public function deleteAction(Request $request, $objectId)
{
return parent::deleteStandard($request, $objectId);
}
/**
* Deletes a group of entities.
*
* @return JsonResponse|RedirectResponse
*/
public function batchDeleteAction(Request $request)
{
return parent::batchDeleteStandard($request);
}
}

View File

@@ -0,0 +1,485 @@
<?php
namespace Mautic\PointBundle\Controller;
use Mautic\CoreBundle\Controller\AbstractFormController;
use Mautic\CoreBundle\Factory\PageHelperFactoryInterface;
use Mautic\PointBundle\Entity\Point;
use Mautic\PointBundle\Model\PointModel;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class PointController extends AbstractFormController
{
/**
* @param int $page
*
* @return JsonResponse|Response
*/
public function indexAction(Request $request, PageHelperFactoryInterface $pageHelperFactory, $page = 1)
{
// set some permissions
$permissions = $this->security->isGranted([
'point:points:view',
'point:points:create',
'point:points:edit',
'point:points:delete',
'point:points:publish',
], 'RETURN_ARRAY');
if (!$permissions['point:points:view']) {
return $this->accessDenied();
}
$this->setListFilters();
$pageHelper = $pageHelperFactory->make('mautic.point', $page);
$limit = $pageHelper->getLimit();
$start = $pageHelper->getStart();
$search = $request->get('search', $request->getSession()->get('mautic.point.filter', ''));
$filter = ['string' => $search, 'force' => []];
$orderBy = $request->getSession()->get('mautic.point.orderby', 'p.name');
$orderByDir = $request->getSession()->get('mautic.point.orderbydir', 'ASC');
$pointModel = $this->getModel('point');
\assert($pointModel instanceof PointModel);
$points = $pointModel->getEntities([
'start' => $start,
'limit' => $limit,
'filter' => $filter,
'orderBy' => $orderBy,
'orderByDir' => $orderByDir,
]);
$request->getSession()->set('mautic.point.filter', $search);
$count = count($points);
if ($count && $count < ($start + 1)) {
$lastPage = $pageHelper->countPage($count);
$returnUrl = $this->generateUrl('mautic_point_index', ['page' => $lastPage]);
$pageHelper->rememberPage($lastPage);
return $this->postActionRedirect([
'returnUrl' => $returnUrl,
'viewParameters' => ['page' => $lastPage],
'contentTemplate' => 'Mautic\PointBundle\Controller\PointController::indexAction',
'passthroughVars' => [
'activeLink' => '#mautic_point_index',
'mauticContent' => 'point',
],
]);
}
$pageHelper->rememberPage($page);
// get the list of actions
$actions = $pointModel->getPointActions();
return $this->delegateView([
'viewParameters' => [
'searchValue' => $search,
'items' => $points,
'actions' => $actions['actions'],
'page' => $page,
'limit' => $limit,
'permissions' => $permissions,
'tmpl' => $request->isXmlHttpRequest() ? $request->get('tmpl', 'index') : 'index',
],
'contentTemplate' => '@MauticPoint/Point/list.html.twig',
'passthroughVars' => [
'activeLink' => '#mautic_point_index',
'mauticContent' => 'point',
'route' => $this->generateUrl('mautic_point_index', ['page' => $page]),
],
]);
}
/**
* Generates new form and processes post data.
*
* @param Point $entity
*
* @return JsonResponse|\Symfony\Component\HttpFoundation\RedirectResponse|Response
*/
public function newAction(Request $request, FormFactoryInterface $formFactory, $entity = null)
{
$model = $this->getModel('point');
\assert($model instanceof PointModel);
if (!($entity instanceof Point)) {
/** @var Point $entity */
$entity = $model->getEntity();
}
if (!$this->security->isGranted('point:points:create')) {
return $this->accessDenied();
}
// set the page we came from
$page = $request->getSession()->get('mautic.point.page', 1);
$method = $request->getMethod();
$point = $request->request->all()['point'] ?? [];
$actionType = 'POST' === $method ? ($point['type'] ?? '') : '';
$action = $this->generateUrl('mautic_point_action', ['objectAction' => 'new']);
$actions = $model->getPointActions();
$form = $model->createForm($entity, $formFactory, $action, [
'pointActions' => $actions,
'actionType' => $actionType,
]);
$viewParameters = ['page' => $page];
// /Check for a submitted form and process it
if (Request::METHOD_POST === $method) {
$valid = false;
if (!$cancelled = $this->isFormCancelled($form)) {
if ($valid = $this->isFormValid($form)) {
// form is valid so process the data
$model->saveEntity($entity);
$this->addFlashMessage('mautic.core.notice.created', [
'%name%' => $entity->getName(),
'%menu_link%' => 'mautic_point_index',
'%url%' => $this->generateUrl('mautic_point_action', [
'objectAction' => 'edit',
'objectId' => $entity->getId(),
]),
]);
if ($this->getFormButton($form, ['buttons', 'save'])->isClicked()) {
$returnUrl = $this->generateUrl('mautic_point_index', $viewParameters);
$template = 'Mautic\PointBundle\Controller\PointController::indexAction';
} else {
// return edit view so that all the session stuff is loaded
return $this->editAction($request, $formFactory, $entity->getId(), true);
}
}
} else {
$returnUrl = $this->generateUrl('mautic_point_index', $viewParameters);
$template = 'Mautic\PointBundle\Controller\PointController::indexAction';
}
if ($cancelled || ($valid && $this->getFormButton($form, ['buttons', 'save'])->isClicked())) {
return $this->postActionRedirect([
'returnUrl' => $returnUrl,
'viewParameters' => $viewParameters,
'contentTemplate' => $template,
'passthroughVars' => [
'activeLink' => '#mautic_point_index',
'mauticContent' => 'point',
],
]);
}
}
$themes = ['@MauticPoint/FormTheme/Action/_pointaction_properties_row.html.twig'];
if ($actionType && !empty($actions['actions'][$actionType]['formTheme'])) {
$themes[] = $actions['actions'][$actionType]['formTheme'];
}
return $this->delegateView([
'viewParameters' => [
'tmpl' => $request->isXmlHttpRequest() ? $request->get('tmpl', 'index') : 'index',
'entity' => $entity,
'form' => $form->createView(),
'actions' => $actions['actions'],
'formThemes' => $themes,
],
'contentTemplate' => '@MauticPoint/Point/form.html.twig',
'passthroughVars' => [
'activeLink' => '#mautic_point_index',
'mauticContent' => 'point',
'route' => $this->generateUrl('mautic_point_action', [
'objectAction' => (!empty($valid) ? 'edit' : 'new'), // valid means a new form was applied
'objectId' => $entity->getId(),
]
),
],
]);
}
/**
* Generates edit form and processes post data.
*
* @param int $objectId
* @param bool $ignorePost
*
* @return JsonResponse|\Symfony\Component\HttpFoundation\RedirectResponse|Response
*/
public function editAction(Request $request, FormFactoryInterface $formFactory, $objectId, $ignorePost = false)
{
$model = $this->getModel('point');
\assert($model instanceof PointModel);
$entity = $model->getEntity($objectId);
// set the page we came from
$page = $request->getSession()->get('mautic.point.page', 1);
$viewParameters = ['page' => $page];
// set the return URL
$returnUrl = $this->generateUrl('mautic_point_index', ['page' => $page]);
$postActionVars = [
'returnUrl' => $returnUrl,
'viewParameters' => $viewParameters,
'contentTemplate' => 'Mautic\PointBundle\Controller\PointController::indexAction',
'passthroughVars' => [
'activeLink' => '#mautic_point_index',
'mauticContent' => 'point',
],
];
// form not found
if (null === $entity) {
return $this->postActionRedirect(
array_merge($postActionVars, [
'flashes' => [
[
'type' => 'error',
'msg' => 'mautic.point.error.notfound',
'msgVars' => ['%id%' => $objectId],
],
],
])
);
} elseif (!$this->security->isGranted('point:points:edit')) {
return $this->accessDenied();
} elseif ($model->isLocked($entity)) {
// deny access if the entity is locked
return $this->isLocked($postActionVars, $entity, 'point');
}
$method = $request->getMethod();
$point = $request->request->all()['point'] ?? [];
$actionType = 'POST' === $method ? ($point['type'] ?? '') : $entity->getType();
$action = $this->generateUrl('mautic_point_action', ['objectAction' => 'edit', 'objectId' => $objectId]);
$actions = $model->getPointActions();
$form = $model->createForm($entity, $formFactory, $action, [
'pointActions' => $actions,
'actionType' => $actionType,
]);
// /Check for a submitted form and process it
if (!$ignorePost && 'POST' === $method) {
$valid = false;
if (!$cancelled = $this->isFormCancelled($form)) {
if ($valid = $this->isFormValid($form)) {
// form is valid so process the data
$model->saveEntity($entity, $this->getFormButton($form, ['buttons', 'save'])->isClicked());
$this->addFlashMessage('mautic.core.notice.updated', [
'%name%' => $entity->getName(),
'%menu_link%' => 'mautic_point_index',
'%url%' => $this->generateUrl('mautic_point_action', [
'objectAction' => 'edit',
'objectId' => $entity->getId(),
]),
]);
if ($this->getFormButton($form, ['buttons', 'save'])->isClicked()) {
$returnUrl = $this->generateUrl('mautic_point_index', $viewParameters);
$template = 'Mautic\PointBundle\Controller\PointController::indexAction';
}
}
} else {
// unlock the entity
$model->unlockEntity($entity);
$returnUrl = $this->generateUrl('mautic_point_index', $viewParameters);
$template = 'Mautic\PointBundle\Controller\PointController::indexAction';
}
if ($cancelled || ($valid && $this->getFormButton($form, ['buttons', 'save'])->isClicked())) {
return $this->postActionRedirect(
array_merge($postActionVars, [
'returnUrl' => $returnUrl,
'viewParameters' => $viewParameters,
'contentTemplate' => $template,
])
);
}
} else {
// lock the entity
$model->lockEntity($entity);
}
$themes = ['@MauticPoint/FormTheme/Action/_pointaction_properties_row.html.twig'];
if (!empty($actions['actions'][$actionType]['formTheme'])) {
$themes[] = $actions['actions'][$actionType]['formTheme'];
}
return $this->delegateView([
'viewParameters' => [
'tmpl' => $request->isXmlHttpRequest() ? $request->get('tmpl', 'index') : 'index',
'entity' => $entity,
'form' => $form->createView(),
'actions' => $actions['actions'],
'formThemes' => $themes,
],
'contentTemplate' => '@MauticPoint/Point/form.html.twig',
'passthroughVars' => [
'activeLink' => '#mautic_point_index',
'mauticContent' => 'point',
'route' => $this->generateUrl('mautic_point_action', [
'objectAction' => 'edit',
'objectId' => $entity->getId(),
]
),
],
]);
}
/**
* Clone an entity.
*
* @param int $objectId
*
* @return array|JsonResponse|\Symfony\Component\HttpFoundation\RedirectResponse|Response
*/
public function cloneAction(Request $request, FormFactoryInterface $formFactory, $objectId)
{
$model = $this->getModel('point');
$entity = $model->getEntity($objectId);
if (null != $entity) {
if (!$this->security->isGranted('point:points:create')) {
return $this->accessDenied();
}
$entity = clone $entity;
$entity->setIsPublished(false);
}
return $this->newAction($request, $formFactory, $entity);
}
/**
* Deletes the entity.
*
* @param int $objectId
*
* @return Response
*/
public function deleteAction(Request $request, $objectId)
{
$page = $request->getSession()->get('mautic.point.page', 1);
$returnUrl = $this->generateUrl('mautic_point_index', ['page' => $page]);
$flashes = [];
$postActionVars = [
'returnUrl' => $returnUrl,
'viewParameters' => ['page' => $page],
'contentTemplate' => 'Mautic\PointBundle\Controller\PointController::indexAction',
'passthroughVars' => [
'activeLink' => '#mautic_point_index',
'mauticContent' => 'point',
],
];
if (Request::METHOD_POST === $request->getMethod()) {
$model = $this->getModel('point');
\assert($model instanceof PointModel);
$entity = $model->getEntity($objectId);
if (null === $entity) {
$flashes[] = [
'type' => 'error',
'msg' => 'mautic.point.error.notfound',
'msgVars' => ['%id%' => $objectId],
];
} elseif (!$this->security->isGranted('point:points:delete')) {
return $this->accessDenied();
} elseif ($model->isLocked($entity)) {
return $this->isLocked($postActionVars, $entity, 'point');
}
$model->deleteEntity($entity);
$identifier = $this->translator->trans($entity->getName());
$flashes[] = [
'type' => 'notice',
'msg' => 'mautic.core.notice.deleted',
'msgVars' => [
'%name%' => $identifier,
'%id%' => $objectId,
],
];
} // else don't do anything
return $this->postActionRedirect(
array_merge($postActionVars, [
'flashes' => $flashes,
])
);
}
/**
* Deletes a group of entities.
*/
public function batchDeleteAction(Request $request): Response
{
$page = $request->getSession()->get('mautic.point.page', 1);
$returnUrl = $this->generateUrl('mautic_point_index', ['page' => $page]);
$flashes = [];
$postActionVars = [
'returnUrl' => $returnUrl,
'viewParameters' => ['page' => $page],
'contentTemplate' => 'Mautic\PointBundle\Controller\PointController::indexAction',
'passthroughVars' => [
'activeLink' => '#mautic_point_index',
'mauticContent' => 'point',
],
];
if (Request::METHOD_POST === $request->getMethod()) {
$model = $this->getModel('point');
\assert($model instanceof PointModel);
$ids = json_decode($request->query->get('ids', '{}'));
$deleteIds = [];
// Loop over the IDs to perform access checks pre-delete
foreach ($ids as $objectId) {
$entity = $model->getEntity($objectId);
if (null === $entity) {
$flashes[] = [
'type' => 'error',
'msg' => 'mautic.point.error.notfound',
'msgVars' => ['%id%' => $objectId],
];
} elseif (!$this->security->isGranted('point:points:delete')) {
$flashes[] = $this->accessDenied(true);
} elseif ($model->isLocked($entity)) {
$flashes[] = $this->isLocked($postActionVars, $entity, 'point', true);
} else {
$deleteIds[] = $objectId;
}
}
// Delete everything we are able to
if (!empty($deleteIds)) {
$entities = $model->deleteEntities($deleteIds);
$flashes[] = [
'type' => 'notice',
'msg' => 'mautic.point.notice.batch_deleted',
'msgVars' => [
'%count%' => count($entities),
],
];
}
} // else don't do anything
return $this->postActionRedirect(
array_merge($postActionVars, [
'flashes' => $flashes,
])
);
}
}

View File

@@ -0,0 +1,626 @@
<?php
namespace Mautic\PointBundle\Controller;
use Mautic\CoreBundle\Controller\FormController;
use Mautic\CoreBundle\Factory\PageHelperFactoryInterface;
use Mautic\PointBundle\Entity\Trigger;
use Mautic\PointBundle\Model\TriggerEventModel;
use Mautic\PointBundle\Model\TriggerModel;
use Symfony\Component\Form\FormError;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class TriggerController extends FormController
{
/**
* @param int $page
*
* @return JsonResponse|\Symfony\Component\HttpFoundation\RedirectResponse|Response
*/
public function indexAction(Request $request, PageHelperFactoryInterface $pageHelperFactory, $page = 1)
{
// set some permissions
$permissions = $this->security->isGranted([
'point:triggers:view',
'point:triggers:create',
'point:triggers:edit',
'point:triggers:delete',
'point:triggers:publish',
], 'RETURN_ARRAY');
if (!$permissions['point:triggers:view']) {
return $this->accessDenied();
}
$this->setListFilters();
$pageHelper = $pageHelperFactory->make('mautic.point.trigger', $page);
$limit = $pageHelper->getLimit();
$start = $pageHelper->getStart();
$search = $request->get('search', $request->getSession()->get('mautic.point.trigger.filter', ''));
$filter = ['string' => $search, 'force' => []];
$orderBy = $request->getSession()->get('mautic.point.trigger.orderby', 't.name');
$orderByDir = $request->getSession()->get('mautic.point.trigger.orderbydir', 'ASC');
$triggers = $this->getModel('point.trigger')->getEntities(
[
'start' => $start,
'limit' => $limit,
'filter' => $filter,
'orderBy' => $orderBy,
'orderByDir' => $orderByDir,
]
);
$request->getSession()->set('mautic.point.trigger.filter', $search);
$count = count($triggers);
if ($count && $count < ($start + 1)) {
$lastPage = $pageHelper->countPage($count);
$returnUrl = $this->generateUrl('mautic_pointtrigger_index', ['page' => $lastPage]);
$pageHelper->rememberPage($lastPage);
return $this->postActionRedirect([
'returnUrl' => $returnUrl,
'viewParameters' => ['page' => $lastPage],
'contentTemplate' => 'Mautic\PointBundle\Controller\TriggerController::indexAction',
'passthroughVars' => [
'activeLink' => '#mautic_pointtrigger_index',
'mauticContent' => 'pointTrigger',
],
]);
}
$pageHelper->rememberPage($page);
return $this->delegateView([
'viewParameters' => [
'searchValue' => $search,
'items' => $triggers,
'page' => $page,
'limit' => $limit,
'permissions' => $permissions,
'tmpl' => $request->isXmlHttpRequest() ? $request->get('tmpl', 'index') : 'index',
],
'contentTemplate' => '@MauticPoint/Trigger/list.html.twig',
'passthroughVars' => [
'activeLink' => '#mautic_pointtrigger_index',
'mauticContent' => 'pointTrigger',
'route' => $this->generateUrl('mautic_pointtrigger_index', ['page' => $page]),
],
]);
}
/**
* View a specific trigger.
*
* @param int $objectId
*
* @return array|JsonResponse|\Symfony\Component\HttpFoundation\RedirectResponse|Response
*/
public function viewAction(Request $request, $objectId)
{
$entity = $this->getModel('point.trigger')->getEntity($objectId);
// set the page we came from
$page = $request->getSession()->get('mautic.point.trigger.page', 1);
$permissions = $this->security->isGranted([
'point:triggers:view',
'point:triggers:create',
'point:triggers:edit',
'point:triggers:delete',
'point:triggers:publish',
], 'RETURN_ARRAY');
if (null === $entity) {
// set the return URL
$returnUrl = $this->generateUrl('mautic_pointtrigger_index', ['page' => $page]);
return $this->postActionRedirect([
'returnUrl' => $returnUrl,
'viewParameters' => ['page' => $page],
'contentTemplate' => 'Mautic\PointBundle\Controller\TriggerController::indexAction',
'passthroughVars' => [
'activeLink' => '#mautic_pointtrigger_index',
'mauticContent' => 'pointTrigger',
],
'flashes' => [
[
'type' => 'error',
'msg' => 'mautic.point.trigger.error.notfound',
'msgVars' => ['%id%' => $objectId],
],
],
]);
} elseif (!$permissions['point:triggers:view']) {
return $this->accessDenied();
}
return $this->delegateView([
'viewParameters' => [
'entity' => $entity,
'page' => $page,
'permissions' => $permissions,
],
'contentTemplate' => '@MauticPoint/Trigger/details.html.twig',
'passthroughVars' => [
'activeLink' => '#mautic_pointtrigger_index',
'mauticContent' => 'pointTrigger',
'route' => $this->generateUrl('mautic_pointtrigger_action', [
'objectAction' => 'view',
'objectId' => $entity->getId(), ]
),
],
]);
}
/**
* Generates new form and processes post data.
*
* @param array<mixed> $triggerEvents
*/
public function newAction(Request $request, ?Trigger $entity = null, array $triggerEvents = []): Response
{
/** @var TriggerModel $model */
$model = $this->getModel('point.trigger');
if (!($entity instanceof Trigger)) {
/** @var Trigger $entity */
$entity = $model->getEntity();
}
$session = $request->getSession();
$sessionId = $this->getSessionBase();
if (!$this->security->isGranted('point:triggers:create')) {
return $this->accessDenied();
}
// set the page we came from
$page = $request->getSession()->get('mautic.point.trigger.page', 1);
// set added/updated events
$addEvents = $session->get('mautic.point.'.$sessionId.'.triggerevents.modified', []);
if (!empty($triggerEvents)) {
$addEvents += $triggerEvents;
$session->set('mautic.point.'.$sessionId.'.triggerevents.modified', $triggerEvents);
}
$deletedEvents = $session->get('mautic.point.'.$sessionId.'.triggerevents.deleted', []);
$action = $this->generateUrl('mautic_pointtrigger_action', ['objectAction' => 'new']);
$form = $model->createForm($entity, $this->formFactory, $action);
$form->get('sessionId')->setData($sessionId);
// Check for a submitted form and process it
if ('POST' == $request->getMethod()) {
$valid = false;
if (!$cancelled = $this->isFormCancelled($form)) {
if ($valid = $this->isFormValid($form)) {
// only save events that are not to be deleted
$events = array_diff_key($addEvents, array_flip($deletedEvents));
// make sure that at least one action is selected
if (empty($events)) {
// set the error
$form->addError(new FormError(
$this->translator->trans('mautic.core.value.required', [], 'validators')
));
$valid = false;
} else {
$model->setEvents($entity, $events);
$model->saveEntity($entity);
$this->addFlashMessage('mautic.core.notice.created', [
'%name%' => $entity->getName(),
'%menu_link%' => 'mautic_pointtrigger_index',
'%url%' => $this->generateUrl('mautic_pointtrigger_action', [
'objectAction' => 'edit',
'objectId' => $entity->getId(),
]),
]);
if (!$this->getFormButton($form, ['buttons', 'save'])->isClicked()) {
// unset the clone session.
$this->clearSessionComponents($request, $sessionId);
// return edit view so that all the session stuff is loaded
return $this->editAction($request, $entity->getId(), true);
}
}
}
}
if ($cancelled || ($valid && $this->getFormButton($form, ['buttons', 'save'])->isClicked())) {
$viewParameters = ['page' => $page];
$returnUrl = $this->generateUrl('mautic_pointtrigger_index', $viewParameters);
$template = 'Mautic\PointBundle\Controller\TriggerController::indexAction';
// clear temporary fields
$this->clearSessionComponents($request, $sessionId);
return $this->postActionRedirect([
'returnUrl' => $returnUrl,
'viewParameters' => $viewParameters,
'contentTemplate' => $template,
'passthroughVars' => [
'activeLink' => '#mautic_pointtrigger_index',
'mauticContent' => 'pointTrigger',
],
]);
}
} elseif (!empty($triggerEvents)) {
// The clone part, no need to clear session here.
$addEvents = $triggerEvents;
$deletedEvents = [];
} else {
// clear out existing fields in case the form was refreshed, browser closed, etc
$this->clearSessionComponents($request, $sessionId);
}
return $this->delegateView([
'viewParameters' => [
'events' => $model->getEventGroups(),
'triggerEvents' => $addEvents,
'deletedEvents' => $deletedEvents,
'tmpl' => $request->isXmlHttpRequest() ? $request->get('tmpl', 'index') : 'index',
'entity' => $entity,
'form' => $form->createView(),
'sessionId' => $sessionId,
],
'contentTemplate' => '@MauticPoint/Trigger/form.html.twig',
'passthroughVars' => [
'activeLink' => '#mautic_pointtrigger_index',
'mauticContent' => 'pointTrigger',
'route' => $this->generateUrl('mautic_pointtrigger_action', [
'objectAction' => (!empty($valid) ? 'edit' : 'new'), // valid means a new form was applied
'objectId' => $entity->getId(), ]
),
],
]);
}
/**
* Generates edit form and processes post data.
*
* @param int $objectId
* @param bool $ignorePost
*
* @return JsonResponse|Response
*/
public function editAction(Request $request, $objectId, $ignorePost = false)
{
/** @var TriggerModel $model */
$model = $this->getModel('point.trigger');
$entity = $model->getEntity($objectId);
$session = $request->getSession();
$cleanSlate = true;
// set the page we came from
$page = $request->getSession()->get('mautic.point.trigger.page', 1);
// set the return URL
$returnUrl = $this->generateUrl('mautic_pointtrigger_index', ['page' => $page]);
$postActionVars = [
'returnUrl' => $returnUrl,
'viewParameters' => ['page' => $page],
'contentTemplate' => 'Mautic\PointBundle\Controller\TriggerController::indexAction',
'passthroughVars' => [
'activeLink' => '#mautic_pointtrigger_index',
'mauticContent' => 'pointTrigger',
],
];
// form not found
if (null === $entity) {
return $this->postActionRedirect(
array_merge($postActionVars, [
'flashes' => [
[
'type' => 'error',
'msg' => 'mautic.point.trigger.error.notfound',
'msgVars' => ['%id%' => $objectId],
],
],
])
);
} elseif (!$this->security->isGranted('point:triggers:edit')) {
return $this->accessDenied();
} elseif ($model->isLocked($entity)) {
// deny access if the entity is locked
return $this->isLocked($postActionVars, $entity, 'point.trigger');
}
$action = $this->generateUrl('mautic_pointtrigger_action', ['objectAction' => 'edit', 'objectId' => $objectId]);
$form = $model->createForm($entity, $this->formFactory, $action);
$form->get('sessionId')->setData($objectId);
// /Check for a submitted form and process it
if (!$ignorePost && 'POST' == $request->getMethod()) {
$valid = false;
if (!$cancelled = $this->isFormCancelled($form)) {
// set added/updated events
$addEvents = $session->get('mautic.point.'.$objectId.'.triggerevents.modified', []);
$deletedEvents = $session->get('mautic.point.'.$objectId.'.triggerevents.deleted', []);
$events = array_diff_key($addEvents, array_flip($deletedEvents));
if ($valid = $this->isFormValid($form)) {
// make sure that at least one field is selected
if (empty($events)) {
// set the error
$form->addError(new FormError(
$this->translator->trans('mautic.core.value.required', [], 'validators')
));
$valid = false;
} else {
$model->setEvents($entity, $events);
// form is valid so process the data
$model->saveEntity($entity, $this->getFormButton($form, ['buttons', 'save'])->isClicked());
// delete entities
if (count($deletedEvents)) {
$triggerEventModel = $this->getModel('point.triggerevent');
\assert($triggerEventModel instanceof TriggerEventModel);
$triggerEventModel->deleteEntities($deletedEvents);
}
$session->set('mautic.point.'.$objectId.'.triggerevents.modified', $events);
$session->set('mautic.point.'.$objectId.'.triggerevents.deleted', []);
$this->addFlashMessage('mautic.core.notice.updated', [
'%name%' => $entity->getName(),
'%menu_link%' => 'mautic_pointtrigger_index',
'%url%' => $this->generateUrl('mautic_pointtrigger_action', [
'objectAction' => 'edit',
'objectId' => $entity->getId(),
]),
]);
}
}
} else {
// unlock the entity
$model->unlockEntity($entity);
}
if ($cancelled || ($valid && $this->getFormButton($form, ['buttons', 'save'])->isClicked())) {
$viewParameters = ['page' => $page];
$returnUrl = $this->generateUrl('mautic_pointtrigger_index', $viewParameters);
$template = 'Mautic\PointBundle\Controller\TriggerController::indexAction';
// remove fields from session
$this->clearSessionComponents($request, $objectId);
return $this->postActionRedirect(
array_merge($postActionVars, [
'returnUrl' => $returnUrl,
'viewParameters' => $viewParameters,
'contentTemplate' => $template,
])
);
} elseif ($form->get('buttons')->get('apply')->isClicked()) {
// do not clear session, just reload view with updated session
$cleanSlate = false;
}
} else {
$cleanSlate = true;
// lock the entity
$model->lockEntity($entity);
}
$triggerEvents = [];
if ($cleanSlate) {
// clean slate
$this->clearSessionComponents($request, $objectId);
// load existing events into session
$existingActions = $entity->getEvents()->toArray();
foreach ($existingActions as $a) {
$id = $a->getId();
$action = $a->convertToArray();
unset($action['form']);
$triggerEvents[$id] = $action;
}
$session->set('mautic.point.'.$objectId.'.triggerevents.modified', $triggerEvents);
$deletedEvents = [];
}
return $this->delegateView([
'viewParameters' => [
'events' => $model->getEventGroups(),
'triggerEvents' => $triggerEvents,
'deletedEvents' => $deletedEvents,
'tmpl' => $request->isXmlHttpRequest() ? $request->get('tmpl', 'index') : 'index',
'entity' => $entity,
'form' => $form->createView(),
'sessionId' => $objectId,
],
'contentTemplate' => '@MauticPoint/Trigger/form.html.twig',
'passthroughVars' => [
'activeLink' => '#mautic_pointtrigger_index',
'mauticContent' => 'pointTrigger',
'route' => $this->generateUrl('mautic_pointtrigger_action', [
'objectAction' => 'edit',
'objectId' => $entity->getId(), ]
),
],
]);
}
/**
* Clone an entity.
*
* @param int $objectId
*
* @return array|JsonResponse|\Symfony\Component\HttpFoundation\RedirectResponse|Response
*/
public function cloneAction(Request $request, $objectId)
{
/** @var TriggerModel $model */
$model = $this->getModel('point.trigger');
/** @var Trigger $entity */
$entity = $model->getEntity($objectId);
$triggerEvents = [];
if (null != $entity) {
if (!$this->security->isGranted('point:triggers:create')) {
return $this->accessDenied();
}
$existingActions = $entity->getEvents()->toArray();
$entity = clone $entity;
$entity->setIsPublished(false);
foreach ($existingActions as $key => $action) {
$action = clone $action;
$action->setTrigger($entity);
$entity->addTriggerEvent($key, $action);
$actionArray = $action->convertToArray();
unset($actionArray['form']);
$triggerEvents[] = $actionArray;
}
}
return $this->newAction($request, $entity, $triggerEvents);
}
/**
* Deletes the entity.
*
* @return Response
*/
public function deleteAction(Request $request, $objectId)
{
$page = $request->getSession()->get('mautic.point.trigger.page', 1);
$returnUrl = $this->generateUrl('mautic_pointtrigger_index', ['page' => $page]);
$flashes = [];
$postActionVars = [
'returnUrl' => $returnUrl,
'viewParameters' => ['page' => $page],
'contentTemplate' => 'Mautic\PointBundle\Controller\TriggerController::indexAction',
'passthroughVars' => [
'activeLink' => '#mautic_pointtrigger_index',
'mauticContent' => 'pointTrigger',
],
];
if (Request::METHOD_POST === $request->getMethod()) {
$model = $this->getModel('point.trigger');
\assert($model instanceof TriggerModel);
$entity = $model->getEntity($objectId);
if (null === $entity) {
$flashes[] = [
'type' => 'error',
'msg' => 'mautic.point.trigger.error.notfound',
'msgVars' => ['%id%' => $objectId],
];
} elseif (!$this->security->isGranted('point:triggers:delete')) {
return $this->accessDenied();
} elseif ($model->isLocked($entity)) {
return $this->isLocked($postActionVars, $entity, 'point.trigger');
}
$model->deleteEntity($entity);
$identifier = $this->translator->trans($entity->getName());
$flashes[] = [
'type' => 'notice',
'msg' => 'mautic.core.notice.deleted',
'msgVars' => [
'%name%' => $identifier,
'%id%' => $objectId,
],
];
} // else don't do anything
return $this->postActionRedirect(
array_merge($postActionVars, [
'flashes' => $flashes,
])
);
}
/**
* Deletes a group of entities.
*/
public function batchDeleteAction(Request $request): Response
{
$page = $request->getSession()->get('mautic.point.trigger.page', 1);
$returnUrl = $this->generateUrl('mautic_pointtrigger_index', ['page' => $page]);
$flashes = [];
$postActionVars = [
'returnUrl' => $returnUrl,
'viewParameters' => ['page' => $page],
'contentTemplate' => 'Mautic\PointBundle\Controller\TriggerController::indexAction',
'passthroughVars' => [
'activeLink' => '#mautic_pointtrigger_index',
'mauticContent' => 'pointTrigger',
],
];
if (Request::METHOD_POST === $request->getMethod()) {
$model = $this->getModel('point.trigger');
\assert($model instanceof TriggerModel);
$ids = json_decode($request->query->get('ids', '{}'));
$deleteIds = [];
// Loop over the IDs to perform access checks pre-delete
foreach ($ids as $objectId) {
$entity = $model->getEntity($objectId);
if (null === $entity) {
$flashes[] = [
'type' => 'error',
'msg' => 'mautic.point.trigger.error.notfound',
'msgVars' => ['%id%' => $objectId],
];
} elseif (!$this->security->isGranted('point:triggers:delete')) {
$flashes[] = $this->accessDenied(true);
} elseif ($model->isLocked($entity)) {
$flashes[] = $this->isLocked($postActionVars, $entity, 'point.trigger', true);
} else {
$deleteIds[] = $objectId;
}
}
// Delete everything we are able to
if (!empty($deleteIds)) {
$entities = $model->deleteEntities($deleteIds);
$flashes[] = [
'type' => 'notice',
'msg' => 'mautic.point.trigger.notice.batch_deleted',
'msgVars' => [
'%count%' => count($entities),
],
];
}
} // else don't do anything
return $this->postActionRedirect(
array_merge($postActionVars, [
'flashes' => $flashes,
])
);
}
/**
* Clear field and events from the session.
*/
private function clearSessionComponents(Request $request, $sessionId): void
{
$session = $request->getSession();
$session->remove('mautic.point.'.$sessionId.'.triggerevents.modified');
$session->remove('mautic.point.'.$sessionId.'.triggerevents.deleted');
}
}

View File

@@ -0,0 +1,384 @@
<?php
namespace Mautic\PointBundle\Controller;
use Mautic\CoreBundle\Controller\FormController as CommonFormController;
use Mautic\PointBundle\Entity\TriggerEvent;
use Mautic\PointBundle\Form\Type\TriggerEventType;
use Mautic\PointBundle\Model\TriggerModel;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class TriggerEventController extends CommonFormController
{
/**
* Generates new form and processes post data.
*
* @return Response
*/
public function newAction(Request $request)
{
$success = 0;
$valid = $cancelled = false;
$method = $request->getMethod();
$session = $request->getSession();
if ('POST' == $method) {
$triggerEvent = $request->request->all()['pointtriggerevent'] ?? [];
$eventType = $triggerEvent['type'];
$triggerId = $triggerEvent['triggerId'];
} else {
$eventType = $request->query->get('type');
$triggerId = $request->query->get('triggerId');
$triggerEvent = [
'type' => $eventType,
'triggerId' => $triggerId,
];
}
// ajax only for form fields
if (!$eventType
|| !$request->isXmlHttpRequest()
|| !$this->security->isGranted([
'point:triggers:edit',
'point:triggers:create',
], 'MATCH_ONE')
) {
return $this->modalAccessDenied();
}
// fire the builder event
/** @var TriggerModel $pointTriggerModel */
$pointTriggerModel = $this->getModel('point.trigger');
\assert($pointTriggerModel instanceof TriggerModel);
$events = $pointTriggerModel->getEvents();
$form = $this->formFactory->create(TriggerEventType::class, $triggerEvent, [
'action' => $this->generateUrl('mautic_pointtriggerevent_action', ['objectAction' => 'new']),
'settings' => $events[$eventType],
]);
$form->get('triggerId')->setData($triggerId);
$triggerEvent['settings'] = $events[$eventType];
// Check for a submitted form and process it
if ('POST' == $method) {
if (!$cancelled = $this->isFormCancelled($form)) {
if ($valid = $this->isFormValid($form)) {
$success = 1;
// form is valid so process the data
$keyId = 'new'.hash('sha1', uniqid(mt_rand()));
// save the properties to session
$actions = $session->get('mautic.point.'.$triggerId.'.triggerevents.modified');
$formData = $form->getData();
$triggerEvent = array_merge($triggerEvent, $formData);
$triggerEvent['id'] = $keyId;
if (empty($triggerEvent['name'])) {
// set it to the event default
$triggerEvent['name'] = $this->translator->trans($triggerEvent['settings']['label']);
}
$actions[$keyId] = $triggerEvent;
$session->set('mautic.point.'.$triggerId.'.triggerevents.modified', $actions);
}
}
}
$viewParams = ['type' => $eventType];
if ($cancelled || $valid) {
$closeModal = true;
} else {
if (isset($triggerEvent['settings']['formTheme'])) {
$viewParams['formTheme'] = $triggerEvent['settings']['formTheme'];
}
$closeModal = false;
$viewParams['form'] = $form->createView();
$header = $triggerEvent['settings']['label'];
$viewParams['eventHeader'] = $this->translator->trans($header);
}
$passthroughVars = [
'mauticContent' => 'pointTriggerEvent',
'success' => $success,
'route' => false,
];
if (!empty($keyId)) {
// prevent undefined errors
$entity = new TriggerEvent();
$blank = $entity->convertToArray();
$triggerEvent = array_merge($blank, $triggerEvent);
$template = (empty($triggerEvent['settings']['template'])) ? '@MauticPoint/Event/generic.html.twig'
: $triggerEvent['settings']['template'];
$passthroughVars['eventId'] = $keyId;
$passthroughVars['eventHtml'] = $this->renderView($template, [
'event' => $triggerEvent,
'id' => $keyId,
'sessionId' => $triggerId,
]);
}
if ($closeModal) {
// just close the modal
$passthroughVars['closeModal'] = 1;
return new JsonResponse($passthroughVars);
}
return $this->ajaxAction($request, [
'contentTemplate' => '@MauticPoint/Event/form.html.twig',
'viewParameters' => $viewParams,
'passthroughVars' => $passthroughVars,
]);
}
/**
* Generates edit form and processes post data.
*
* @param int $objectId
*
* @return Response
*/
public function editAction(Request $request, $objectId)
{
$session = $request->getSession();
$method = $request->getMethod();
$triggerEvent = $request->request->all()['pointtriggerevent'] ?? [];
$triggerId = 'POST' === $method ? ($triggerEvent['triggerId'] ?? '') : $request->query->get('triggerId');
$events = $session->get('mautic.point.'.$triggerId.'.triggerevents.modified', []);
$success = 0;
$valid = $cancelled = false;
$triggerEvent = array_key_exists($objectId, $events) ? $events[$objectId] : null;
if (null !== $triggerEvent) {
$eventType = $triggerEvent['type'];
$pointTriggerModel = $this->getModel('point.trigger');
\assert($pointTriggerModel instanceof TriggerModel);
$events = $pointTriggerModel->getEvents();
$triggerEvent['settings'] = $events[$eventType];
// ajax only for form fields
if (!$eventType
|| !$request->isXmlHttpRequest()
|| !$this->security->isGranted([
'point:triggers:edit',
'point:triggers:create',
], 'MATCH_ONE')
) {
return $this->modalAccessDenied();
}
$form = $this->formFactory->create(TriggerEventType::class, $triggerEvent, [
'action' => $this->generateUrl('mautic_pointtriggerevent_action', ['objectAction' => 'edit', 'objectId' => $objectId]),
'settings' => $triggerEvent['settings'],
]);
$form->get('triggerId')->setData($triggerId);
// Check for a submitted form and process it
if ('POST' == $method) {
if (!$cancelled = $this->isFormCancelled($form)) {
if ($valid = $this->isFormValid($form)) {
$success = 1;
// form is valid so process the data
// save the properties to session
$session = $request->getSession();
$events = $session->get('mautic.point.'.$triggerId.'.triggerevents.modified');
/** @var array<mixed> $formData */
$formData = $form->getData();
// overwrite with updated data
$triggerEvent = array_merge($events[$objectId], $formData);
if (empty($triggerEvent['name'])) {
// set it to the event default
$triggerEvent['name'] = $this->translator->trans($triggerEvent['settings']['label']);
}
$events[$objectId] = $triggerEvent;
$session->set('mautic.point.'.$triggerId.'.triggerevents.modified', $events);
// generate HTML for the field
$keyId = $objectId;
}
}
}
$viewParams = ['type' => $eventType];
if ($cancelled || $valid) {
$closeModal = true;
} else {
if (isset($triggerEvent['settings']['formTheme'])) {
$viewParams['formTheme'] = $triggerEvent['settings']['formTheme'];
}
$closeModal = false;
$viewParams['form'] = $form->createView();
$viewParams['eventHeader'] = $this->translator->trans($triggerEvent['settings']['label']);
}
$passthroughVars = [
'mauticContent' => 'pointTriggerEvent',
'success' => $success,
'route' => false,
];
if (!empty($keyId)) {
$passthroughVars['eventId'] = $keyId;
// prevent undefined errors
$entity = new TriggerEvent();
$blank = $entity->convertToArray();
$triggerEvent = array_merge($blank, $triggerEvent);
$template = (empty($triggerEvent['settings']['template'])) ? '@MauticPoint/Event/generic.html.twig'
: $triggerEvent['settings']['template'];
$passthroughVars['eventId'] = $keyId;
$passthroughVars['eventHtml'] = $this->renderView($template, [
'event' => $triggerEvent,
'id' => $keyId,
'sessionId' => $triggerId,
]);
}
if ($closeModal) {
// just close the modal
$passthroughVars['closeModal'] = 1;
return new JsonResponse($passthroughVars);
}
return $this->ajaxAction($request, [
'contentTemplate' => '@MauticPoint/Event/form.html.twig',
'viewParameters' => $viewParams,
'passthroughVars' => $passthroughVars,
]);
}
return new JsonResponse(['success' => 0]);
}
/**
* Deletes the entity.
*
* @param int $objectId
*
* @return JsonResponse
*/
public function deleteAction(Request $request, $objectId)
{
$session = $request->getSession();
$triggerId = $request->get('triggerId');
$events = $session->get('mautic.point.'.$triggerId.'.triggerevents.modified', []);
$delete = $session->get('mautic.point.'.$triggerId.'.triggerevents.deleted', []);
// ajax only for form fields
if (!$request->isXmlHttpRequest()
|| !$this->security->isGranted([
'point:triggers:edit',
'point:triggers:create',
], 'MATCH_ONE')
) {
return $this->accessDenied();
}
$triggerEvent = (array_key_exists($objectId, $events)) ? $events[$objectId] : null;
if ('POST' == $request->getMethod() && null !== $triggerEvent) {
// add the field to the delete list
if (!in_array($objectId, $delete)) {
$delete[] = $objectId;
$session->set('mautic.point.'.$triggerId.'.triggerevents.deleted', $delete);
}
$template = (empty($triggerEvent['settings']['template'])) ? '@MauticPoint/Event/generic.html.twig'
: $triggerEvent['settings']['template'];
// prevent undefined errors
$entity = new TriggerEvent();
$blank = $entity->convertToArray();
$triggerEvent = array_merge($blank, $triggerEvent);
$dataArray = [
'mauticContent' => 'pointTriggerEvent',
'success' => 1,
'target' => '#triggerEvent'.$objectId,
'route' => false,
'eventId' => $objectId,
'eventHtml' => $this->renderView($template, [
'event' => $triggerEvent,
'id' => $objectId,
'deleted' => true,
'sessionId' => $triggerId,
]),
];
} else {
$dataArray = ['success' => 0];
}
return new JsonResponse($dataArray);
}
/**
* Undeletes the entity.
*
* @param int $objectId
*
* @return JsonResponse
*/
public function undeleteAction(Request $request, $objectId)
{
$session = $request->getSession();
$triggerId = $request->get('triggerId');
$events = $session->get('mautic.point.'.$triggerId.'.triggerevents.modified', []);
$delete = $session->get('mautic.point.'.$triggerId.'.triggerevents.deleted', []);
// ajax only for form fields
if (!$request->isXmlHttpRequest()
|| !$this->security->isGranted([
'point:triggers:edit',
'point:triggers:create',
], 'MATCH_ONE')
) {
return $this->accessDenied();
}
$triggerEvent = (array_key_exists($objectId, $events)) ? $events[$objectId] : null;
if ('POST' === $request->getMethod() && null !== $triggerEvent) {
// add the field to the delete list
if (in_array($objectId, $delete)) {
$key = array_search($objectId, $delete);
unset($delete[$key]);
$session->set('mautic.point.'.$triggerId.'.triggerevents.deleted', $delete);
}
$template = (empty($triggerEvent['settings']['template'])) ? '@MauticPoint/Event/generic.html.twig'
: $triggerEvent['settings']['template'];
// prevent undefined errors
$entity = new TriggerEvent();
$blank = $entity->convertToArray();
$triggerEvent = array_merge($blank, $triggerEvent);
$dataArray = [
'mauticContent' => 'pointTriggerEvent',
'success' => 1,
'target' => '#triggerEvent'.$objectId,
'route' => false,
'eventId' => $objectId,
'eventHtml' => $this->renderView($template, [
'event' => $triggerEvent,
'id' => $objectId,
'deleted' => false,
'triggerId' => $triggerId,
]),
];
} else {
$dataArray = ['success' => 0];
}
return new JsonResponse($dataArray);
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Mautic\PointBundle\DependencyInjection;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\Extension;
use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;
class MauticPointExtension extends Extension
{
/**
* @param mixed[] $configs
*/
public function load(array $configs, ContainerBuilder $container): void
{
$loader = new PhpFileLoader($container, new FileLocator(__DIR__.'/../Config'));
$loader->load('services.php');
}
}

View File

@@ -0,0 +1,99 @@
<?php
namespace Mautic\PointBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Mautic\ApiBundle\Serializer\Driver\ApiMetadataDriver;
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
use Mautic\CoreBundle\Entity\FormEntity;
use Mautic\CoreBundle\Entity\UuidInterface;
use Mautic\CoreBundle\Entity\UuidTrait;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Mapping\ClassMetadata;
class Group extends FormEntity implements UuidInterface
{
use UuidTrait;
public const TABLE_NAME = 'point_groups';
public const ENTITY_NAME = 'point_group';
private ?int $id = null;
private ?string $name = '';
private ?string $description = '';
/**
* @param ORM\ClassMetadata<Group> $metadata
*/
public static function loadMetadata(ORM\ClassMetadata $metadata): void
{
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable(self::TABLE_NAME)
->setCustomRepositoryClass(GroupRepository::class);
static::addUuidField($builder);
$builder->addIdColumns();
}
public static function loadValidatorMetadata(ClassMetadata $metadata): void
{
$metadata->addPropertyConstraint('name', new Assert\NotBlank([
'message' => 'mautic.core.name.required',
]));
}
public static function loadApiMetadata(ApiMetadataDriver $metadata): void
{
$metadata->setGroupPrefix('pointGroup')
->addListProperties(
[
'id',
'name',
'description',
]
)
->addProperties(
[
'id',
'name',
'description',
]
)
->build();
}
public function getId(): ?int
{
return $this->id;
}
public function setDescription(?string $description): self
{
$this->isChanged('description', $description);
$this->description = $description;
return $this;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setName(?string $name): self
{
$this->isChanged('name', $name);
$this->name = $name;
return $this;
}
public function getName(): ?string
{
return $this->name;
}
}

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace Mautic\PointBundle\Entity;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Mautic\ApiBundle\Serializer\Driver\ApiMetadataDriver;
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
use Mautic\CoreBundle\Entity\CommonEntity;
use Mautic\LeadBundle\Entity\Lead;
class GroupContactScore extends CommonEntity
{
public const TABLE_NAME = 'point_group_contact_score';
private Lead $contact;
private Group $group;
private int $score;
public function __construct()
{
$this->contact = new Lead();
$this->group = new Group();
$this->score = 0;
}
/**
* @param ORM\ClassMetadata<GroupContactScore> $metadata
*/
public static function loadMetadata(ORM\ClassMetadata $metadata): void
{
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable(self::TABLE_NAME)
->setCustomRepositoryClass(GroupContactScoreRepository::class);
$builder->addContact(false, 'CASCADE', true, 'groupScores');
$builder->createManyToOne('group', Group::class)
->isPrimaryKey()
->addJoinColumn('group_id', 'id', true, false, 'CASCADE')
->build();
$builder->createField('score', Types::INTEGER)
->build();
}
public static function loadApiMetadata(ApiMetadataDriver $metadata): void
{
$metadata->setGroupPrefix('groupContactScore')
->addListProperties(
[
'score',
'group',
]
)
->addProperties(
[
'score',
'group',
]
)
->build();
}
public function getContact(): Lead
{
return $this->contact;
}
public function setContact(Lead $contact): void
{
$this->contact = $contact;
}
public function getGroup(): Group
{
return $this->group;
}
public function setGroup(Group $group): void
{
$this->group = $group;
}
public function getScore(): int
{
return $this->score;
}
public function setScore(int $score): void
{
$this->score = $score;
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Mautic\PointBundle\Entity;
use Mautic\CoreBundle\Entity\CommonRepository;
/**
* @extends CommonRepository<GroupContactScore>
*/
class GroupContactScoreRepository extends CommonRepository
{
public function compareScore(int $leadId, int $groupId, int $score, string $operatorExpr): bool
{
$q = $this->_em->getConnection()->createQueryBuilder();
$q->select('lcs.contact_id')
->from(MAUTIC_TABLE_PREFIX.GroupContactScore::TABLE_NAME, 'lcs');
$expr = $q->expr()->and(
$q->expr()->eq('lcs.contact_id', ':lead'),
$q->expr()->eq('lcs.group_id', ':groupId'),
$q->expr()->$operatorExpr('lcs.score', ':score'),
);
$q->where($expr)
->setParameter('lead', $leadId)
->setParameter('groupId', $groupId)
->setParameter('score', $score);
return false !== $q->executeQuery()->fetchOne();
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Mautic\PointBundle\Entity;
use Mautic\CoreBundle\Entity\CommonRepository;
/**
* @extends CommonRepository<Group>
*/
class GroupRepository extends CommonRepository
{
public function getTableAlias(): string
{
return 'pl';
}
public function getEntities(array $args = [])
{
// Without qb it returns entities indexed by id instead of array indexes
$args['qb'] = $this->createQueryBuilder($this->getTableAlias());
return parent::getEntities($args);
}
}

View File

@@ -0,0 +1,117 @@
<?php
namespace Mautic\PointBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
use Mautic\CoreBundle\Entity\IpAddress;
class LeadPointLog
{
public const TABLE_NAME = 'point_lead_action_log';
/**
* @var Point
**/
private $point;
/**
* @var \Mautic\LeadBundle\Entity\Lead
*/
private $lead;
/**
* @var IpAddress|null
*/
private $ipAddress;
/**
* @var \DateTimeInterface
**/
private $dateFired;
public static function loadMetadata(ORM\ClassMetadata $metadata): void
{
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable(self::TABLE_NAME)
->setCustomRepositoryClass(LeadPointLogRepository::class);
$builder->createManyToOne('point', 'Point')
->isPrimaryKey()
->addJoinColumn('point_id', 'id', true, false, 'CASCADE')
->inversedBy('log')
->build();
$builder->addLead(false, 'CASCADE', true);
$builder->addIpAddress(true);
$builder->createField('dateFired', 'datetime')
->columnName('date_fired')
->build();
}
/**
* @return mixed
*/
public function getDateFired()
{
return $this->dateFired;
}
/**
* @param mixed $dateFired
*/
public function setDateFired($dateFired): void
{
$this->dateFired = $dateFired;
}
/**
* @return IpAddress|null
*/
public function getIpAddress()
{
return $this->ipAddress;
}
/**
* @param IpAddress $ipAddress
*/
public function setIpAddress($ipAddress): void
{
$this->ipAddress = $ipAddress;
}
/**
* @return mixed
*/
public function getLead()
{
return $this->lead;
}
/**
* @param mixed $lead
*/
public function setLead($lead): void
{
$this->lead = $lead;
}
/**
* @return mixed
*/
public function getPoint()
{
return $this->point;
}
/**
* @param mixed $point
*/
public function setPoint($point): void
{
$this->point = $point;
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace Mautic\PointBundle\Entity;
use Mautic\CoreBundle\Entity\CommonRepository;
/**
* @extends CommonRepository<LeadPointLog>
*/
class LeadPointLogRepository extends CommonRepository
{
/**
* Updates lead ID (e.g. after a lead merge).
*/
public function updateLead($fromLeadId, $toLeadId): void
{
// First check to ensure the $toLead doesn't already exist
$results = $this->_em->getConnection()->createQueryBuilder()
->select('pl.point_id')
->from(MAUTIC_TABLE_PREFIX.'point_lead_action_log', 'pl')
->where('pl.lead_id = '.$toLeadId)
->executeQuery()
->fetchAllAssociative();
$actions = [];
foreach ($results as $r) {
$actions[] = $r['point_id'];
}
$q = $this->_em->getConnection()->createQueryBuilder();
$q->update(MAUTIC_TABLE_PREFIX.'point_lead_action_log')
->set('lead_id', (int) $toLeadId)
->where('lead_id = '.(int) $fromLeadId);
if (!empty($actions)) {
$q->andWhere(
$q->expr()->notIn('point_id', $actions)
)->executeStatement();
// Delete remaining leads as the new lead already belongs
$this->_em->getConnection()->createQueryBuilder()
->delete(MAUTIC_TABLE_PREFIX.'point_lead_action_log')
->where('lead_id = '.(int) $fromLeadId)
->executeStatement();
} else {
$q->executeStatement();
}
}
}

View File

@@ -0,0 +1,117 @@
<?php
namespace Mautic\PointBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
use Mautic\CoreBundle\Entity\IpAddress;
class LeadTriggerLog
{
public const TABLE_NAME = 'point_lead_event_log';
/**
* @var TriggerEvent
**/
private $event;
/**
* @var \Mautic\LeadBundle\Entity\Lead
**/
private $lead;
/**
* @var IpAddress|null
**/
private $ipAddress;
/**
* @var \DateTimeInterface
**/
private $dateFired;
public static function loadMetadata(ORM\ClassMetadata $metadata): void
{
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable(self::TABLE_NAME)
->setCustomRepositoryClass(LeadTriggerLogRepository::class);
$builder->createManyToOne('event', 'TriggerEvent')
->isPrimaryKey()
->addJoinColumn('event_id', 'id', false, false, 'CASCADE')
->inversedBy('log')
->build();
$builder->addLead(false, 'CASCADE', true);
$builder->addIpAddress(true);
$builder->createField('dateFired', 'datetime')
->columnName('date_fired')
->build();
}
/**
* @return mixed
*/
public function getDateFired()
{
return $this->dateFired;
}
/**
* @param mixed $dateFired
*/
public function setDateFired($dateFired): void
{
$this->dateFired = $dateFired;
}
/**
* @return IpAddress|null
*/
public function getIpAddress()
{
return $this->ipAddress;
}
/**
* @param IpAddress $ipAddress
*/
public function setIpAddress($ipAddress): void
{
$this->ipAddress = $ipAddress;
}
/**
* @return mixed
*/
public function getLead()
{
return $this->lead;
}
/**
* @param mixed $lead
*/
public function setLead($lead): void
{
$this->lead = $lead;
}
/**
* @return mixed
*/
public function getEvent()
{
return $this->event;
}
/**
* @param mixed $event
*/
public function setEvent($event): void
{
$this->event = $event;
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace Mautic\PointBundle\Entity;
use Mautic\CoreBundle\Entity\CommonRepository;
/**
* @extends CommonRepository<LeadTriggerLog>
*/
class LeadTriggerLogRepository extends CommonRepository
{
/**
* Updates lead ID (e.g. after a lead merge).
*/
public function updateLead($fromLeadId, $toLeadId): void
{
// First check to ensure the $toLead doesn't already exist
$results = $this->_em->getConnection()->createQueryBuilder()
->select('pl.event_id')
->from(MAUTIC_TABLE_PREFIX.'point_lead_event_log', 'pl')
->where('pl.lead_id = '.$toLeadId)
->executeQuery()
->fetchAllAssociative();
$events = [];
foreach ($results as $r) {
$events[] = $r['event_id'];
}
$q = $this->_em->getConnection()->createQueryBuilder();
$q->update(MAUTIC_TABLE_PREFIX.'point_lead_event_log')
->set('lead_id', (int) $toLeadId)
->where('lead_id = '.(int) $fromLeadId);
if (!empty($events)) {
$q->andWhere(
$q->expr()->notIn('event_id', $events)
)->executeStatement();
// Delete remaining leads as the new lead already belongs
$this->_em->getConnection()->createQueryBuilder()
->delete(MAUTIC_TABLE_PREFIX.'point_lead_event_log')
->where('lead_id = '.(int) $fromLeadId)
->executeStatement();
} else {
$q->executeStatement();
}
}
}

View File

@@ -0,0 +1,446 @@
<?php
namespace Mautic\PointBundle\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\Mapping as ORM;
use Mautic\ApiBundle\Serializer\Driver\ApiMetadataDriver;
use Mautic\CategoryBundle\Entity\Category;
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
use Mautic\CoreBundle\Entity\FormEntity;
use Mautic\CoreBundle\Entity\UuidInterface;
use Mautic\CoreBundle\Entity\UuidTrait;
use Mautic\CoreBundle\Helper\IntHelper;
use Mautic\ProjectBundle\Entity\ProjectTrait;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Mapping\ClassMetadata;
#[ApiResource(
operations: [
new GetCollection(security: "is_granted('point:triggers:viewown')"),
new Post(security: "is_granted('point:triggers:create')"),
new Get(security: "is_granted('point:triggers:viewown')"),
new Put(security: "is_granted('point:triggers:editown')"),
new Patch(security: "is_granted('point:triggers:editother')"),
new Delete(security: "is_granted('point:triggers:deleteown')"),
],
normalizationContext: [
'groups' => ['point:read'],
'swagger_definition_name' => 'Read',
'api_included' => ['category'],
],
denormalizationContext: [
'groups' => ['point:write'],
'swagger_definition_name' => 'Write',
]
)]
class Point extends FormEntity implements UuidInterface
{
use UuidTrait;
use ProjectTrait;
public const ENTITY_NAME = 'point';
/**
* @var int
*/
#[Groups(['point:read'])]
private $id;
/**
* @var string
*/
#[Groups(['point:read', 'point:write'])]
private $name;
/**
* @var string|null
*/
#[Groups(['point:read', 'point:write'])]
private $description;
/**
* @var string
*/
#[Groups(['point:read', 'point:write'])]
private $type;
/**
* @var bool
*/
#[Groups(['point:read', 'point:write'])]
private $repeatable = false;
/**
* @var \DateTimeInterface
*/
#[Groups(['point:read', 'point:write'])]
private $publishUp;
/**
* @var \DateTimeInterface
*/
#[Groups(['point:read', 'point:write'])]
private $publishDown;
/**
* @var int
*/
#[Groups(['point:read', 'point:write'])]
private $delta = 0;
/**
* @var array
*/
#[Groups(['point:read', 'point:write'])]
private $properties = [];
/**
* @var ArrayCollection<int,LeadPointLog>
*/
private $log;
/**
* @var Category|null
**/
#[Groups(['point:read', 'point:write'])]
private $category;
#[Groups(['point:read', 'point:write'])]
private ?Group $group = null;
public function __clone()
{
$this->id = null;
parent::__clone();
}
public function __construct()
{
$this->log = new ArrayCollection();
$this->initializeProjects();
}
public static function loadMetadata(ORM\ClassMetadata $metadata): void
{
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable('points')
->setCustomRepositoryClass(PointRepository::class)
->addIndex(['type'], 'point_type_search');
$builder->addIdColumns();
$builder->createField('type', 'string')
->length(50)
->build();
$builder->addPublishDates();
$builder->createField('repeatable', 'boolean')
->build();
$builder->addField('delta', 'integer');
$builder->addField('properties', 'array');
$builder->createOneToMany('log', 'LeadPointLog')
->mappedBy('point')
->cascadePersist()
->cascadeRemove()
->fetchExtraLazy()
->build();
$builder->addCategory();
$builder->createManyToOne('group', Group::class)
->addJoinColumn('group_id', 'id', true, false, 'CASCADE')
->build();
static::addUuidField($builder);
self::addProjectsField($builder, 'point_projects_xref', 'point_id');
}
public static function loadValidatorMetadata(ClassMetadata $metadata): void
{
$metadata->addPropertyConstraint('name', new Assert\NotBlank([
'message' => 'mautic.core.name.required',
]));
$metadata->addPropertyConstraint('type', new Assert\NotBlank([
'message' => 'mautic.point.type.notblank',
]));
$metadata->addPropertyConstraint('delta', new Assert\NotBlank([
'message' => 'mautic.point.delta.notblank',
]));
$metadata->addPropertyConstraint('delta', new Assert\Range([
'min' => IntHelper::MIN_INTEGER_VALUE,
'max' => IntHelper::MAX_INTEGER_VALUE,
]));
}
/**
* Prepares the metadata for API usage.
*/
public static function loadApiMetadata(ApiMetadataDriver $metadata): void
{
$metadata->setGroupPrefix('point')
->addListProperties(
[
'id',
'name',
'category',
'type',
'description',
]
)
->addProperties(
[
'publishUp',
'publishDown',
'delta',
'properties',
'repeatable',
]
)
->build();
self::addProjectsInLoadApiMetadata($metadata, 'point');
}
/**
* @return int
*/
public function getId()
{
return $this->id;
}
/**
* @param array $properties
*
* @return self
*/
public function setProperties($properties)
{
$this->isChanged('properties', $properties);
$this->properties = $properties;
return $this;
}
/**
* @return array
*/
public function getProperties()
{
return $this->properties;
}
/**
* @param string $type
*
* @return self
*/
public function setType($type)
{
$this->isChanged('type', $type);
$this->type = $type;
return $this;
}
/**
* @return string
*/
public function getType()
{
return $this->type;
}
public function convertToArray(): array
{
return get_object_vars($this);
}
/**
* @param string $description
*
* @return self
*/
public function setDescription($description)
{
$this->isChanged('description', $description);
$this->description = $description;
return $this;
}
/**
* @return string
*/
public function getDescription()
{
return $this->description;
}
/**
* @param string $name
*
* @return self
*/
public function setName($name)
{
$this->isChanged('name', $name);
$this->name = $name;
return $this;
}
/**
* @return string
*/
public function getName()
{
return $this->name;
}
/**
* @return self
*/
public function addLog(LeadPointLog $log)
{
$this->log[] = $log;
return $this;
}
public function removeLog(LeadPointLog $log): void
{
$this->log->removeElement($log);
}
/**
* @return \Doctrine\Common\Collections\Collection
*/
public function getLog()
{
return $this->log;
}
/**
* @param \DateTime $publishUp
*
* @return Point
*/
public function setPublishUp($publishUp)
{
$this->isChanged('publishUp', $publishUp);
$this->publishUp = $publishUp;
return $this;
}
/**
* @return \DateTimeInterface
*/
public function getPublishUp()
{
return $this->publishUp;
}
/**
* @param \DateTime $publishDown
*
* @return Point
*/
public function setPublishDown($publishDown)
{
$this->isChanged('publishDown', $publishDown);
$this->publishDown = $publishDown;
return $this;
}
/**
* @return \DateTimeInterface
*/
public function getPublishDown()
{
return $this->publishDown;
}
/**
* @return mixed
*/
public function getCategory()
{
return $this->category;
}
/**
* @param mixed $category
*/
public function setCategory($category): void
{
$this->category = $category;
}
/**
* @return mixed
*/
public function getDelta()
{
return $this->delta;
}
/**
* @param mixed $delta
*/
public function setDelta($delta): void
{
$this->delta = (int) $delta;
}
/**
* @param bool $repeatable
*
* @return Point
*/
public function setRepeatable($repeatable)
{
$this->isChanged('repeatable', $repeatable);
$this->repeatable = $repeatable;
return $this;
}
/**
* @return bool
*/
public function getRepeatable()
{
return $this->repeatable;
}
public function getGroup(): ?Group
{
return $this->group;
}
public function setGroup(?Group $group): void
{
$this->group = $group;
}
}

View File

@@ -0,0 +1,145 @@
<?php
namespace Mautic\PointBundle\Entity;
use Mautic\CoreBundle\Entity\CommonRepository;
use Mautic\ProjectBundle\Entity\ProjectRepositoryTrait;
/**
* @extends CommonRepository<Point>
*/
class PointRepository extends CommonRepository
{
use ProjectRepositoryTrait;
public function getEntities(array $args = [])
{
$q = $this->_em
->createQueryBuilder()
->select($this->getTableAlias().', cat')
->from(Point::class, $this->getTableAlias())
->leftJoin($this->getTableAlias().'.category', 'cat')
->leftJoin($this->getTableAlias().'.group', 'pl');
$args['qb'] = $q;
return parent::getEntities($args);
}
public function getTableAlias(): string
{
return 'p';
}
/**
* Get array of published actions based on type.
*
* @param string $type
*
* @return array
*/
public function getPublishedByType($type)
{
$q = $this->createQueryBuilder('p')
->select('partial p.{id, type, name, delta, repeatable, properties}')
->setParameter('type', $type);
// make sure the published up and down dates are good
$expr = $this->getPublishedByDateExpression($q);
$expr->add($q->expr()->eq('p.type', ':type'));
$q->where($expr);
return $q->getQuery()->getResult();
}
/**
* @param string $type
* @param int $leadId
*/
public function getCompletedLeadActions($type, $leadId): array
{
$q = $this->_em->getConnection()->createQueryBuilder()
->select('p.*')
->from(MAUTIC_TABLE_PREFIX.'point_lead_action_log', 'x')
->innerJoin('x', MAUTIC_TABLE_PREFIX.'points', 'p', 'x.point_id = p.id');
// make sure the published up and down dates are good
$q->where(
$q->expr()->and(
$q->expr()->eq('p.type', ':type'),
$q->expr()->eq('x.lead_id', (int) $leadId)
)
)
->setParameter('type', $type);
$results = $q->executeQuery()->fetchAllAssociative();
$return = [];
foreach ($results as $r) {
$return[$r['id']] = $r;
}
return $return;
}
/**
* @param int $leadId
*/
public function getCompletedLeadActionsByLeadId($leadId): array
{
$q = $this->_em->getConnection()->createQueryBuilder()
->select('p.*')
->from(MAUTIC_TABLE_PREFIX.'point_lead_action_log', 'x')
->innerJoin('x', MAUTIC_TABLE_PREFIX.'points', 'p', 'x.point_id = p.id');
// make sure the published up and down dates are good
$q->where(
$q->expr()->and(
$q->expr()->eq('x.lead_id', (int) $leadId)
)
);
$results = $q->executeQuery()->fetchAllAssociative();
$return = [];
foreach ($results as $r) {
$return[$r['id']] = $r;
}
return $return;
}
protected function addCatchAllWhereClause($q, $filter): array
{
return $this->addStandardCatchAllWhereClause($q, $filter, [
'p.name',
'p.description',
]);
}
protected function addSearchCommandWhereClause($q, $filter): array
{
return match ($filter->command) {
$this->translator->trans('mautic.project.searchcommand.name'), $this->translator->trans('mautic.project.searchcommand.name', [], null, 'en_US') => $this->handleProjectFilter(
$this->_em->getConnection()->createQueryBuilder(),
'point_id',
'point_projects_xref',
$this->getTableAlias(),
$filter->string,
$filter->not
),
default => $this->addStandardSearchCommandWhereClause($q, $filter),
};
}
/**
* @return string[]
*/
public function getSearchCommands(): array
{
return array_merge(['mautic.project.searchcommand.name'], $this->getStandardSearchCommands());
}
}

View File

@@ -0,0 +1,432 @@
<?php
namespace Mautic\PointBundle\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\Mapping as ORM;
use Mautic\ApiBundle\Serializer\Driver\ApiMetadataDriver;
use Mautic\CategoryBundle\Entity\Category;
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
use Mautic\CoreBundle\Entity\FormEntity;
use Mautic\CoreBundle\Entity\UuidInterface;
use Mautic\CoreBundle\Entity\UuidTrait;
use Mautic\ProjectBundle\Entity\ProjectTrait;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Mapping\ClassMetadata;
#[ApiResource(
operations: [
new GetCollection(security: "is_granted('point:triggers:viewown')"),
new Post(security: "is_granted('point:triggers:create')"),
new Get(security: "is_granted('point:triggers:viewown')"),
new Put(security: "is_granted('point:triggers:editown')"),
new Patch(security: "is_granted('point:triggers:editother')"),
new Delete(security: "is_granted('point:triggers:deleteown')"),
],
normalizationContext: [
'groups' => ['trigger:read'],
'swagger_definition_name' => 'Read',
'api_included' => ['category', 'events'],
],
denormalizationContext: [
'groups' => ['trigger:write'],
'swagger_definition_name' => 'Write',
]
)]
class Trigger extends FormEntity implements UuidInterface
{
use UuidTrait;
use ProjectTrait;
public const ENTITY_NAME = 'point_trigger';
/**
* @var int
*/
#[Groups(['trigger:read'])]
private $id;
/**
* @var string
*/
#[Groups(['trigger:read', 'trigger:write'])]
private $name;
/**
* @var string|null
*/
#[Groups(['trigger:read', 'trigger:write'])]
private $description;
/**
* @var \DateTimeInterface
*/
#[Groups(['trigger:read', 'trigger:write'])]
private $publishUp;
/**
* @var \DateTimeInterface
*/
#[Groups(['trigger:read', 'trigger:write'])]
private $publishDown;
/**
* @var int
*/
#[Groups(['trigger:read', 'trigger:write'])]
private $points = 0;
/**
* @var string
*/
#[Groups(['trigger:read', 'trigger:write'])]
private $color = 'a0acb8';
/**
* @var bool
*/
#[Groups(['trigger:read', 'trigger:write'])]
private $triggerExistingLeads = false;
/**
* @var Category|null
**/
#[Groups(['trigger:read', 'trigger:write'])]
private $category;
/**
* @var ArrayCollection<int, TriggerEvent>
*/
#[Groups(['trigger:read', 'trigger:write'])]
private $events;
#[Groups(['trigger:read', 'trigger:write'])]
private ?Group $group = null;
public function __clone()
{
$this->id = null;
parent::__clone();
}
public function __construct()
{
$this->events = new ArrayCollection();
$this->initializeProjects();
}
public static function loadMetadata(ORM\ClassMetadata $metadata): void
{
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable('point_triggers')
->setCustomRepositoryClass(TriggerRepository::class);
$builder->addIdColumns();
$builder->addPublishDates();
$builder->addField('points', 'integer');
$builder->createField('color', 'string')
->length(7)
->build();
$builder->createField('triggerExistingLeads', 'boolean')
->columnName('trigger_existing_leads')
->build();
$builder->addCategory();
$builder->createOneToMany('events', 'TriggerEvent')
->setIndexBy('id')
->setOrderBy(['order' => 'ASC'])
->mappedBy('trigger')
->cascadeAll()
->fetchExtraLazy()
->build();
$builder->createManyToOne('group', Group::class)
->addJoinColumn('group_id', 'id', true, false, 'CASCADE')
->build();
static::addUuidField($builder);
self::addProjectsField($builder, 'point_trigger_projects_xref', 'point_trigger_id');
}
public static function loadValidatorMetadata(ClassMetadata $metadata): void
{
$metadata->addPropertyConstraint('name', new Assert\NotBlank([
'message' => 'mautic.core.name.required',
]));
}
/**
* Prepares the metadata for API usage.
*/
public static function loadApiMetadata(ApiMetadataDriver $metadata): void
{
$metadata->setGroupPrefix('trigger')
->addListProperties(
[
'id',
'name',
'category',
'description',
]
)
->addProperties(
[
'publishUp',
'publishDown',
'points',
'color',
'events',
'triggerExistingLeads',
]
)
->build();
self::addProjectsInLoadApiMetadata($metadata, 'trigger');
}
/**
* @param string $prop
* @param mixed $val
*/
protected function isChanged($prop, $val)
{
if ('events' == $prop) {
// changes are already computed so just add them
$this->changes[$prop][$val[0]] = $val[1];
} else {
parent::isChanged($prop, $val);
}
}
/**
* Get id.
*
* @return int
*/
public function getId()
{
return $this->id;
}
/**
* Set description.
*
* @param string $description
*
* @return Trigger
*/
public function setDescription($description)
{
$this->isChanged('description', $description);
$this->description = $description;
return $this;
}
/**
* Get description.
*
* @return string
*/
public function getDescription()
{
return $this->description;
}
/**
* Set name.
*
* @param string $name
*
* @return Trigger
*/
public function setName($name)
{
$this->isChanged('name', $name);
$this->name = $name;
return $this;
}
/**
* Get name.
*
* @return string
*/
public function getName()
{
return $this->name;
}
/**
* Add events.
*
* @return Point
*/
public function addTriggerEvent($key, TriggerEvent $event)
{
if ($changes = $event->getChanges()) {
$this->isChanged('events', [$key, $changes]);
}
$this->events[$key] = $event;
return $this;
}
/**
* Remove events.
*/
public function removeTriggerEvent(TriggerEvent $event): void
{
$this->events->removeElement($event);
}
/**
* Get events.
*
* @return \Doctrine\Common\Collections\Collection
*/
public function getEvents()
{
return $this->events;
}
/**
* Set publishUp.
*
* @param \DateTime $publishUp
*
* @return Point
*/
public function setPublishUp($publishUp)
{
$this->isChanged('publishUp', $publishUp);
$this->publishUp = $publishUp;
return $this;
}
/**
* Get publishUp.
*
* @return \DateTimeInterface
*/
public function getPublishUp()
{
return $this->publishUp;
}
/**
* Set publishDown.
*
* @param \DateTime $publishDown
*
* @return Point
*/
public function setPublishDown($publishDown)
{
$this->isChanged('publishDown', $publishDown);
$this->publishDown = $publishDown;
return $this;
}
/**
* Get publishDown.
*
* @return \DateTimeInterface
*/
public function getPublishDown()
{
return $this->publishDown;
}
/**
* @return mixed
*/
public function getPoints()
{
return $this->points;
}
/**
* @param mixed $points
*/
public function setPoints($points): void
{
$this->isChanged('points', $points);
$this->points = $points;
}
/**
* @return mixed
*/
public function getColor()
{
return $this->color;
}
/**
* @param mixed $color
*/
public function setColor($color): void
{
$this->color = $color;
}
/**
* @return mixed
*/
public function getTriggerExistingLeads()
{
return $this->triggerExistingLeads;
}
/**
* @param mixed $triggerExistingLeads
*/
public function setTriggerExistingLeads($triggerExistingLeads): void
{
$this->triggerExistingLeads = $triggerExistingLeads;
}
/**
* @return mixed
*/
public function getCategory()
{
return $this->category;
}
/**
* @param mixed $category
*/
public function setCategory($category): void
{
$this->category = $category;
}
public function getGroup(): ?Group
{
return $this->group;
}
public function setGroup(Group $group): void
{
$this->group = $group;
}
}

View File

@@ -0,0 +1,333 @@
<?php
namespace Mautic\PointBundle\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\Mapping as ORM;
use Mautic\ApiBundle\Serializer\Driver\ApiMetadataDriver;
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
use Mautic\CoreBundle\Entity\UuidInterface;
use Mautic\CoreBundle\Entity\UuidTrait;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(security: "is_granted('point:triggers:viewown')"),
new Post(security: "is_granted('point:triggers:create')"),
new Get(security: "is_granted('point:triggers:viewown')"),
new Put(security: "is_granted('point:triggers:editown')"),
new Patch(security: "is_granted('point:triggers:editother')"),
new Delete(security: "is_granted('point:triggers:deleteown')"),
],
normalizationContext: [
'groups' => ['trigger_event:read'],
'swagger_definition_name' => 'Read',
],
denormalizationContext: [
'groups' => ['trigger_event:write'],
'swagger_definition_name' => 'Write',
]
)]
class TriggerEvent implements UuidInterface
{
use UuidTrait;
/**
* @var int|null
*/
#[Groups(['trigger_event:read'])]
private $id;
/**
* @var string
*/
#[Groups(['trigger_event:read', 'trigger_event:write'])]
private $name;
/**
* @var string|null
*/
#[Groups(['trigger_event:read', 'trigger_event:write'])]
private $description;
/**
* @var string
*/
#[Groups(['trigger_event:read', 'trigger_event:write'])]
private $type;
/**
* @var int
*/
#[Groups(['trigger_event:read', 'trigger_event:write'])]
private $order = 0;
/**
* @var array
*/
#[Groups(['trigger_event:read', 'trigger_event:write'])]
private $properties = [];
/**
* @var Trigger
*/
#[Groups(['trigger_event:read', 'trigger_event:write'])]
private $trigger;
/**
* @var ArrayCollection<int,LeadTriggerLog>
*/
private $log;
/**
* @var array
*/
private $changes;
public function __clone(): void
{
$this->id = null;
}
public function __construct()
{
$this->log = new ArrayCollection();
}
public static function loadMetadata(ORM\ClassMetadata $metadata): void
{
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable('point_trigger_events')
->setCustomRepositoryClass(TriggerEventRepository::class)
->addIndex(['type'], 'trigger_type_search');
$builder->addIdColumns();
$builder->createField('type', 'string')
->length(50)
->build();
$builder->createField('order', 'integer')
->columnName('action_order')
->build();
$builder->addField('properties', 'array');
$builder->createManyToOne('trigger', 'Trigger')
->inversedBy('events')
->addJoinColumn('trigger_id', 'id', false, false, 'CASCADE')
->build();
$builder->createOneToMany('log', 'LeadTriggerLog')
->mappedBy('event')
->cascadePersist()
->cascadeRemove()
->fetchExtraLazy()
->build();
static::addUuidField($builder);
}
/**
* Prepares the metadata for API usage.
*/
public static function loadApiMetadata(ApiMetadataDriver $metadata): void
{
$metadata->setGroupPrefix('trigger')
->addProperties(
[
'id',
'name',
'description',
'type',
'order',
'properties',
]
)
->build();
}
private function isChanged($prop, $val): void
{
if ($this->$prop != $val) {
$this->changes[$prop] = [$this->$prop, $val];
}
}
/**
* @return array
*/
public function getChanges()
{
return $this->changes;
}
/**
* @return int
*/
public function getId()
{
return $this->id;
}
/**
* @param int $order
*
* @return TriggerEvent
*/
public function setOrder($order)
{
$this->isChanged('order', $order);
$this->order = $order;
return $this;
}
/**
* @return int
*/
public function getOrder()
{
return $this->order;
}
/**
* @param array $properties
*
* @return TriggerEvent
*/
public function setProperties($properties)
{
$this->isChanged('properties', $properties);
$this->properties = $properties;
return $this;
}
/**
* @return array
*/
public function getProperties()
{
return $this->properties;
}
/**
* @return self
*/
public function setTrigger(Trigger $trigger)
{
$this->trigger = $trigger;
return $this;
}
/**
* @return Trigger
*/
public function getTrigger()
{
return $this->trigger;
}
/**
* @param string $type
*
* @return TriggerEvent
*/
public function setType($type)
{
$this->isChanged('type', $type);
$this->type = $type;
return $this;
}
/**
* @return string
*/
public function getType()
{
return $this->type;
}
public function convertToArray(): array
{
return get_object_vars($this);
}
/**
* @param string $description
*
* @return TriggerEvent
*/
public function setDescription($description)
{
$this->isChanged('description', $description);
$this->description = $description;
return $this;
}
/**
* @return string
*/
public function getDescription()
{
return $this->description;
}
/**
* @param string $name
*
* @return TriggerEvent
*/
public function setName($name)
{
$this->isChanged('name', $name);
$this->name = $name;
return $this;
}
/**
* @return string
*/
public function getName()
{
return $this->name;
}
/**
* @return self
*/
public function addLog(LeadTriggerLog $log)
{
$this->log[] = $log;
return $this;
}
public function removeLog(LeadTriggerLog $log): void
{
$this->log->removeElement($log);
}
/**
* @return \Doctrine\Common\Collections\Collection
*/
public function getLog()
{
return $this->log;
}
}

View File

@@ -0,0 +1,149 @@
<?php
namespace Mautic\PointBundle\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Mautic\CoreBundle\Entity\CommonRepository;
/**
* @extends CommonRepository<TriggerEvent>
*/
class TriggerEventRepository extends CommonRepository
{
/**
* Get array of published triggers based on point total.
*
* @param int $points
*
* @return array
*/
public function getPublishedByPointTotal($points)
{
$q = $this->createQueryBuilder('a')
->select('partial a.{id, type, name, properties}, partial r.{id, name, points, color}')
->leftJoin('a.trigger', 'r')
->orderBy('a.order,r.points');
// make sure the published up and down dates are good
$expr = $this->getPublishedByDateExpression($q, 'r');
$expr->add(
$q->expr()->lte('r.points', (int) $points)
);
$q->where($expr);
$q->andWhere('r.group IS NULL');
return $q->getQuery()->getArrayResult();
}
/**
* @param ArrayCollection<int,GroupContactScore> $groupScores
*
* @return mixed[]
*/
public function getPublishedByGroupScore(Collection $groupScores)
{
if ($groupScores->isEmpty()) {
return [];
}
$q = $this->createQueryBuilder('a')
->select('partial a.{id, type, name, properties}, partial r.{id, name, points, color}, partial pl.{id, name}')
->leftJoin('a.trigger', 'r')
->leftJoin('r.group', 'pl')
->orderBy('a.order');
// make sure the published up and down dates are good
$expr = $this->getPublishedByDateExpression($q, 'r');
$groupsExpr = $q->expr()->orX();
/** @var GroupContactScore $score */
foreach ($groupScores as $score) {
$groupsExpr->add(
$q->expr()->andX(
$q->expr()->eq('pl.id', $score->getGroup()->getId()),
$q->expr()->lte('r.points', $score->getScore())
)
);
}
$q->where($expr);
$q->andWhere($groupsExpr);
$q->andWhere('r.group IS NOT NULL');
return $q->getQuery()->getArrayResult();
}
/**
* Get array of published actions based on type.
*
* @param string $type
*
* @return array
*/
public function getPublishedByType($type)
{
$q = $this->createQueryBuilder('e')
->select('partial e.{id, type, name, properties}, partial t.{id, name, points, color}')
->join('e.trigger', 't')
->orderBy('e.order');
// make sure the published up and down dates are good
$expr = $this->getPublishedByDateExpression($q);
$expr->add(
$q->expr()->eq('e.type', ':type')
);
$q->where($expr)
->setParameter('type', $type);
return $q->getQuery()->getResult();
}
/**
* @param int $leadId
*/
public function getLeadTriggeredEvents($leadId): array
{
$q = $this->_em->getConnection()->createQueryBuilder()
->select('e.*')
->from(MAUTIC_TABLE_PREFIX.'point_lead_event_log', 'x')
->innerJoin('x', MAUTIC_TABLE_PREFIX.'point_trigger_events', 'e', 'x.event_id = e.id')
->innerJoin('e', MAUTIC_TABLE_PREFIX.'point_triggers', 't', 'e.trigger_id = t.id');
// make sure the published up and down dates are good
$q->where($q->expr()->eq('x.lead_id', (int) $leadId));
$results = $q->executeQuery()->fetchAllAssociative();
$return = [];
foreach ($results as $r) {
$return[$r['id']] = $r;
}
return $return;
}
/**
* @param int $eventId
*/
public function getLeadsForEvent($eventId): array
{
$results = $this->_em->getConnection()->createQueryBuilder()
->select('e.lead_id')
->from(MAUTIC_TABLE_PREFIX.'point_lead_event_log', 'e')
->where('e.event_id = '.(int) $eventId)
->executeQuery()
->fetchAllAssociative();
$return = [];
foreach ($results as $r) {
$return[] = $r['lead_id'];
}
return $return;
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace Mautic\PointBundle\Entity;
use Doctrine\Common\Collections\Order;
use Mautic\CoreBundle\Entity\CommonRepository;
use Mautic\ProjectBundle\Entity\ProjectRepositoryTrait;
/**
* @extends CommonRepository<Trigger>
*/
class TriggerRepository extends CommonRepository
{
use ProjectRepositoryTrait;
public function getEntities(array $args = [])
{
$q = $this->_em
->createQueryBuilder()
->select($this->getTableAlias().', cat')
->from(Trigger::class, $this->getTableAlias())
->leftJoin($this->getTableAlias().'.category', 'cat')
->leftJoin($this->getTableAlias().'.group', 'pl');
$args['qb'] = $q;
return parent::getEntities($args);
}
/**
* Get a list of published triggers with color and points.
*
* @return array
*/
public function getTriggerColors()
{
$q = $this->_em->createQueryBuilder()
->select('partial t.{id, color, points}')
->from(Trigger::class, 't', 't.id');
$q->where($this->getPublishedByDateExpression($q));
$q->orderBy('t.points', Order::Ascending->value);
return $q->getQuery()->getArrayResult();
}
public function getTableAlias(): string
{
return 't';
}
protected function addCatchAllWhereClause($q, $filter): array
{
return $this->addStandardCatchAllWhereClause($q, $filter, [
't.name',
't.description',
]);
}
protected function addSearchCommandWhereClause($q, $filter): array
{
return match ($filter->command) {
$this->translator->trans('mautic.project.searchcommand.name'), $this->translator->trans('mautic.project.searchcommand.name', [], null, 'en_US') => $this->handleProjectFilter(
$this->_em->getConnection()->createQueryBuilder(),
'point_trigger_id',
'point_trigger_projects_xref',
$this->getTableAlias(),
$filter->string,
$filter->not
),
// Handle standard search commands
default => $this->addStandardSearchCommandWhereClause($q, $filter),
};
}
/**
* @return string[]
*/
public function getSearchCommands(): array
{
return array_merge(['mautic.project.searchcommand.name'], $this->getStandardSearchCommands());
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Mautic\PointBundle\Event;
use Mautic\PointBundle\Entity\Group;
final class GroupEvent
{
public function __construct(
private Group $entity,
) {
}
public function getGroup(): Group
{
return $this->entity;
}
public function setGroup(Group $group): void
{
$this->entity = $group;
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Mautic\PointBundle\Event;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\PointBundle\Entity\GroupContactScore;
final class GroupScoreChangeEvent
{
public function __construct(
private GroupContactScore $groupContactScore,
private int $oldScore,
private int $newScore,
) {
}
public function getGroupContactScore(): GroupContactScore
{
return $this->groupContactScore;
}
public function getContact(): Lead
{
return $this->groupContactScore->getContact();
}
public function getNewScore(): int
{
return $this->newScore;
}
public function getOldScore(): int
{
return $this->oldScore;
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace Mautic\PointBundle\Event;
use Mautic\CoreBundle\Event\CommonEvent;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\PointBundle\Entity\Point;
class PointActionEvent extends CommonEvent
{
public function __construct(
protected Point $point,
protected Lead $lead,
) {
}
/**
* @return Point
*/
public function getPoint()
{
return $this->point;
}
public function setPoint(Point $point): void
{
$this->point = $point;
}
/**
* @return Lead
*/
public function getLead()
{
return $this->lead;
}
public function setLead(Lead $lead): void
{
$this->lead = $lead;
}
}

View File

@@ -0,0 +1,118 @@
<?php
namespace Mautic\PointBundle\Event;
use Symfony\Component\Process\Exception\InvalidArgumentException;
use Symfony\Contracts\EventDispatcher\Event;
use Symfony\Contracts\Translation\TranslatorInterface;
class PointBuilderEvent extends Event
{
private array $actions = [];
public function __construct(
private TranslatorInterface $translator,
) {
}
/**
* Adds an action to the list of available .
*
* @param string $key - a unique identifier; it is recommended that it be namespaced i.e. lead.action
* @param array $action - can contain the following keys:
* 'label' => (required) what to display in the list
* 'description' => (optional) short description of event
* 'template' => (optional) template to use for the action's HTML in the point builder
* i.e AcmeMyBundle:PointAction:theaction.html.twig
* 'formType' => (optional) name of the form type SERVICE for the action; will use a default form with point change only
* 'formTypeOptions' => (optional) array of options to pass to formType
* 'callback' => (optional) callback function that will be passed when the action is triggered; return true to
* change the configured points or false to ignore the action
* The callback function can receive the following arguments by name (via ReflectionMethod::invokeArgs())
* Mautic\LeadBundle\Entity\Lead $lead
* $eventDetails - variable sent from firing function to call back function
* array $action = array(
* 'id' => int
* 'type' => string
* 'name' => string
* 'properties' => array()
* )
*
* @throws InvalidArgumentException
*/
public function addAction($key, array $action): void
{
if (array_key_exists($key, $this->actions)) {
throw new InvalidArgumentException("The key, '$key' is already used by another action. Please use a different key.");
}
// check for required keys and that given functions are callable
$this->verifyComponent(
['group', 'label'],
['callback'],
$action
);
// translate the label and group
$action['label'] = $this->translator->trans($action['label']);
$action['group'] = $this->translator->trans($action['group']);
$this->actions[$key] = $action;
}
/**
* @return array
*/
public function getActions()
{
uasort($this->actions, fn ($a, $b): int => strnatcasecmp(
$a['label'], $b['label']));
return $this->actions;
}
/**
* Gets a list of actions supported by the choice form field.
*/
public function getActionList(): array
{
$list = [];
$actions = $this->getActions();
foreach ($actions as $k => $a) {
$list[$k] = $a['label'];
}
return $list;
}
/**
* @return mixed[]
*/
public function getActionChoices(): array
{
$choices = [];
foreach ($this->actions as $k => $c) {
$choices[$c['group']][$c['label']] = $k;
}
return $choices;
}
/**
* @throws InvalidArgumentException
*/
private function verifyComponent(array $keys, array $methods, array $component): void
{
foreach ($keys as $k) {
if (!array_key_exists($k, $component)) {
throw new InvalidArgumentException("The key, '$k' is missing.");
}
}
foreach ($methods as $m) {
if (isset($component[$m]) && !is_callable($component[$m], true)) {
throw new InvalidArgumentException($component[$m].' is not callable. Please ensure that it exists and that it is a fully qualified namespace.');
}
}
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace Mautic\PointBundle\Event;
use Mautic\CoreBundle\Event\CommonEvent;
use Mautic\PointBundle\Entity\Point;
class PointEvent extends CommonEvent
{
/**
* @param bool $isNew
*/
public function __construct(Point &$point, $isNew = false)
{
$this->entity = &$point;
$this->isNew = $isNew;
}
/**
* @return Point
*/
public function getPoint()
{
return $this->entity;
}
public function setPoint(Point $point): void
{
$this->entity = $point;
}
}

View File

@@ -0,0 +1,90 @@
<?php
namespace Mautic\PointBundle\Event;
use Symfony\Component\Process\Exception\InvalidArgumentException;
use Symfony\Contracts\EventDispatcher\Event;
use Symfony\Contracts\Translation\TranslatorInterface;
class TriggerBuilderEvent extends Event
{
private array $events = [];
public function __construct(
private TranslatorInterface $translator,
) {
}
/**
* Adds an action to the list of available .
*
* @param string $key - a unique identifier; it is recommended that it be namespaced i.e. lead.action
* @param array $event - can contain the following keys:
* 'label' => (required) what to display in the list
* 'description' => (optional) short description of event
* 'template' => (optional) template to use for the action's HTML in the point builder
* i.e AcmeMyBundle:PointAction:theaction.html.twig
* 'formType' => (optional) name of the form type SERVICE for the action
* 'formTypeOptions' => (optional) array of options to pass to formType
* 'callback' => (required) callback function that will be passed when the action is triggered
* The callback function can receive the following arguments by name (via ReflectionMethod::invokeArgs())
* Mautic\PointBundle\Entity\TriggerEvent $event
* Mautic\LeadBundle\Entity\Lead $lead
*
* @throws InvalidArgumentException
*/
public function addEvent($key, array $event): void
{
if (array_key_exists($key, $this->events)) {
throw new InvalidArgumentException("The key, '$key' is already used by another action. Please use a different key.");
}
// check for required keys and that given functions are callable
$this->verifyComponent(
['group', 'label'],
['callback'],
$event
);
// Support for old way with callback and new event based system
// Could be removed after all events will be refactored to events. The key 'eventName' will be mandatory and 'callback' will be removed.
if (!array_key_exists('callback', $event) && !array_key_exists('eventName', $event)) {
throw new InvalidArgumentException("One of the 'callback' or 'eventName' has to be provided. Use 'eventName' for new code");
}
$event['label'] = $this->translator->trans($event['label']);
$event['group'] = $this->translator->trans($event['group']);
$event['description'] = (isset($event['description'])) ? $this->translator->trans($event['description']) : '';
$this->events[$key] = $event;
}
/**
* @return array
*/
public function getEvents()
{
uasort($this->events, fn ($a, $b): int => strnatcasecmp(
$a['label'], $b['label']));
return $this->events;
}
/**
* @throws InvalidArgumentException
*/
private function verifyComponent(array $keys, array $methods, array $component): void
{
foreach ($keys as $k) {
if (!array_key_exists($k, $component)) {
throw new InvalidArgumentException("The key, '$k' is missing.");
}
}
foreach ($methods as $m) {
if (isset($component[$m]) && !is_callable($component[$m], true)) {
throw new InvalidArgumentException($component[$m].' is not callable. Please ensure that it exists and that it is a fully qualified namespace.');
}
}
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace Mautic\PointBundle\Event;
use Mautic\CoreBundle\Event\CommonEvent;
use Mautic\PointBundle\Entity\Trigger;
class TriggerEvent extends CommonEvent
{
/**
* @var Trigger
*/
protected $entity;
/**
* @param bool $isNew
*/
public function __construct(
Trigger &$trigger,
protected $isNew = false,
) {
$this->entity = &$trigger;
}
/**
* @return Trigger
*/
public function getTrigger()
{
return $this->entity;
}
public function setTrigger(Trigger $trigger): void
{
$this->entity = $trigger;
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace Mautic\PointBundle\Event;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\PointBundle\Entity\TriggerEvent as TriggerEventEntity;
use Symfony\Contracts\EventDispatcher\Event;
class TriggerExecutedEvent extends Event
{
private ?bool $result = null;
public function __construct(
private TriggerEventEntity $triggerEvent,
private Lead $lead,
) {
}
/**
* @return TriggerEventEntity
*/
public function getTriggerEvent()
{
return $this->triggerEvent;
}
/**
* @return Lead
*/
public function getLead()
{
return $this->lead;
}
/**
* @return bool
*/
public function getResult()
{
return $this->result;
}
public function setSucceded(): void
{
$this->result = true;
}
public function setFailed(): void
{
$this->result = false;
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace Mautic\PointBundle\EventListener;
use Mautic\DashboardBundle\Event\WidgetDetailEvent;
use Mautic\DashboardBundle\EventListener\DashboardSubscriber as MainDashboardSubscriber;
use Mautic\PointBundle\Model\PointModel;
class DashboardSubscriber extends MainDashboardSubscriber
{
/**
* Define the name of the bundle/category of the widget(s).
*
* @var string
*/
protected $bundle = 'point';
/**
* Define the widget(s).
*
* @var string
*/
protected $types = [
'points.in.time' => [],
];
/**
* Define permissions to see those widgets.
*
* @var array
*/
protected $permissions = [
'point:points:viewown',
'point:points:viewother',
];
public function __construct(
protected PointModel $pointModel,
) {
}
/**
* Set a widget detail when needed.
*/
public function onWidgetDetailGenerate(WidgetDetailEvent $event): void
{
$this->checkPermissions($event);
$canViewOthers = $event->hasPermission('point:points:viewother');
if ('points.in.time' == $event->getType()) {
$widget = $event->getWidget();
$params = $widget->getParams();
if (!$event->isCached()) {
$event->setTemplateData([
'chartType' => 'line',
'chartHeight' => $widget->getHeight() - 80,
'chartData' => $this->pointModel->getPointLineChartData(
$params['timeUnit'],
$params['dateFrom'],
$params['dateTo'],
$params['dateFormat'],
[],
$canViewOthers
),
]);
}
$event->setTemplate('@MauticCore/Helper/chart.html.twig');
$event->stopPropagation();
}
}
}

View File

@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace Mautic\PointBundle\EventListener;
use Doctrine\ORM\EntityManagerInterface;
use Mautic\CoreBundle\Event\EntityExportEvent;
use Mautic\CoreBundle\Event\EntityImportAnalyzeEvent;
use Mautic\CoreBundle\Event\EntityImportEvent;
use Mautic\CoreBundle\Event\EntityImportUndoEvent;
use Mautic\CoreBundle\EventListener\ImportExportTrait;
use Mautic\CoreBundle\Helper\IpLookupHelper;
use Mautic\CoreBundle\Model\AuditLogModel;
use Mautic\PointBundle\Entity\Group;
use Mautic\PointBundle\Model\PointGroupModel;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
final class GroupImportExportSubscriber implements EventSubscriberInterface
{
use ImportExportTrait;
public function __construct(
private PointGroupModel $pointGroupModel,
private EntityManagerInterface $entityManager,
private AuditLogModel $auditLogModel,
private IpLookupHelper $ipLookupHelper,
private DenormalizerInterface $serializer,
) {
}
public static function getSubscribedEvents(): array
{
return [
EntityExportEvent::class => ['onPointGroupExport', 0],
EntityImportEvent::class => ['onPointGroupImport', 0],
EntityImportUndoEvent::class => ['onUndoImport', 0],
EntityImportAnalyzeEvent::class => ['onDuplicationCheck', 0],
];
}
public function onPointGroupExport(EntityExportEvent $event): void
{
if (Group::ENTITY_NAME !== $event->getEntityName()) {
return;
}
$pointGroupId = $event->getEntityId();
$pointGroup = $this->pointGroupModel->getEntity($pointGroupId);
if (!$pointGroup) {
return;
}
$pointGroupData = [
'id' => $pointGroup->getId(),
'name' => $pointGroup->getName(),
'description' => $pointGroup->getDescription(),
'is_published'=> $pointGroup->isPublished(),
'uuid' => $pointGroup->getUuid(),
];
$event->addEntity(Group::ENTITY_NAME, $pointGroupData);
$this->logAction('export', $pointGroup->getId(), $pointGroupData);
}
public function onPointGroupImport(EntityImportEvent $event): void
{
if (Group::ENTITY_NAME !== $event->getEntityName() || !$event->getEntityData()) {
return;
}
$stats = [
EntityImportEvent::NEW => ['names' => [], 'ids' => [], 'count' => 0],
EntityImportEvent::UPDATE => ['names' => [], 'ids' => [], 'count' => 0],
];
foreach ($event->getEntityData() as $element) {
$group = $this->entityManager->getRepository(Group::class)->findOneBy(['uuid' => $element['uuid']]);
$isNew = !$group;
$group ??= new Group();
$this->serializer->denormalize(
$element,
Group::class,
null,
['object_to_populate' => $group]
);
$this->pointGroupModel->saveEntity($group);
$event->addEntityIdMap((int) $element['id'], $group->getId());
$status = $isNew ? EntityImportEvent::NEW : EntityImportEvent::UPDATE;
$stats[$status]['names'][] = $group->getName();
$stats[$status]['ids'][] = $group->getId();
++$stats[$status]['count'];
$this->logAction('import', $group->getId(), $element);
}
foreach ($stats as $status => $info) {
if ($info['count'] > 0) {
$event->setStatus($status, [Group::ENTITY_NAME => $info]);
}
}
}
public function onUndoImport(EntityImportUndoEvent $event): void
{
if (Group::ENTITY_NAME !== $event->getEntityName()) {
return;
}
$summary = $event->getSummary();
if (!isset($summary['ids']) || empty($summary['ids'])) {
return;
}
foreach ($summary['ids'] as $id) {
$entity = $this->entityManager->getRepository(Group::class)->find($id);
if ($entity) {
$this->entityManager->remove($entity);
$this->logAction('undo_import', $id, ['deletedEntity' => Group::class]);
}
}
$this->entityManager->flush();
}
public function onDuplicationCheck(EntityImportAnalyzeEvent $event): void
{
$this->performDuplicationCheck(
$event,
Group::ENTITY_NAME,
Group::class,
'name',
$this->entityManager
);
}
/**
* @param array<string, mixed> $details
*/
private function logAction(string $action, int $objectId, array $details): void
{
$this->auditLogModel->writeToLog([
'bundle' => 'point',
'object' => 'pointGroup',
'objectId' => $objectId,
'action' => $action,
'details' => $details,
'ipAddress' => $this->ipLookupHelper->getIpAddressFromRequest(),
]);
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Mautic\PointBundle\EventListener;
use Mautic\PointBundle\Event\GroupScoreChangeEvent;
use Mautic\PointBundle\Model\TriggerModel;
use Mautic\PointBundle\PointGroupEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class GroupScoreSubscriber implements EventSubscriberInterface
{
public function __construct(
private TriggerModel $triggerModel,
) {
}
public static function getSubscribedEvents(): array
{
return [
PointGroupEvents::SCORE_CHANGE => ['onGroupScoreChange', 0],
];
}
public function onGroupScoreChange(GroupScoreChangeEvent $event): void
{
$this->triggerModel->triggerEvents($event->getContact());
}
}

View File

@@ -0,0 +1,114 @@
<?php
namespace Mautic\PointBundle\EventListener;
use Mautic\LeadBundle\Entity\PointsChangeLogRepository;
use Mautic\LeadBundle\Event\LeadEvent;
use Mautic\LeadBundle\Event\LeadMergeEvent;
use Mautic\LeadBundle\Event\LeadTimelineEvent;
use Mautic\LeadBundle\Event\PointsChangeEvent;
use Mautic\LeadBundle\LeadEvents;
use Mautic\PointBundle\Entity\LeadPointLogRepository;
use Mautic\PointBundle\Entity\LeadTriggerLogRepository;
use Mautic\PointBundle\Model\TriggerModel;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class LeadSubscriber implements EventSubscriberInterface
{
public function __construct(
private TriggerModel $triggerModel,
private TranslatorInterface $translator,
private PointsChangeLogRepository $pointsChangeLogRepository,
private LeadPointLogRepository $leadPointLogRepository,
private LeadTriggerLogRepository $leadTriggerLogRepository,
) {
}
public static function getSubscribedEvents(): array
{
return [
LeadEvents::LEAD_POINTS_CHANGE => ['onLeadPointsChange', 0],
LeadEvents::TIMELINE_ON_GENERATE => ['onTimelineGenerate', 0],
LeadEvents::LEAD_POST_MERGE => ['onLeadMerge', 0],
LeadEvents::LEAD_POST_SAVE => ['onLeadSave', -1],
];
}
/**
* Trigger applicable events for the lead.
*/
public function onLeadPointsChange(PointsChangeEvent $event): void
{
$this->triggerModel->triggerEvents($event->getLead());
}
/**
* Handle point triggers for new leads (including 0 point triggers).
*/
public function onLeadSave(LeadEvent $event): void
{
if ($event->isNew()) {
$this->triggerModel->triggerEvents($event->getLead());
}
}
/**
* Compile events for the lead timeline.
*/
public function onTimelineGenerate(LeadTimelineEvent $event): void
{
// Set available event types
$eventTypeKey = 'point.gained';
$eventTypeName = $this->translator->trans('mautic.point.event.gained');
$event->addEventType($eventTypeKey, $eventTypeName);
$event->addSerializerGroup('pointList');
if (!$event->isApplicable($eventTypeKey)) {
return;
}
$logs = $this->pointsChangeLogRepository->getLeadTimelineEvents($event->getLeadId(), $event->getQueryOptions());
// Add to counter
$event->addToCounter($eventTypeKey, $logs);
if (!$event->isEngagementCount()) {
// Add the logs to the event array
foreach ($logs['results'] as $log) {
$eventLabel = $log['eventName'].' / '.$log['delta'];
if (!empty($log['groupName'])) {
$eventLabel .= ' ('.$log['groupName'].')';
}
$event->addEvent(
[
'event' => $eventTypeKey,
'eventId' => $eventTypeKey.$log['id'],
'eventLabel' => $eventLabel,
'eventType' => $eventTypeName,
'timestamp' => $log['dateAdded'],
'extra' => [
'log' => $log,
],
'icon' => 'ri-calculator-line',
'contactId' => $log['lead_id'],
]
);
}
}
}
public function onLeadMerge(LeadMergeEvent $event): void
{
$this->leadPointLogRepository->updateLead(
$event->getLoser()->getId(),
$event->getVictor()->getId()
);
$this->leadTriggerLogRepository->updateLead(
$event->getLoser()->getId(),
$event->getVictor()->getId()
);
}
}

View File

@@ -0,0 +1,100 @@
<?php
namespace Mautic\PointBundle\EventListener;
use Mautic\CoreBundle\Helper\IpLookupHelper;
use Mautic\CoreBundle\Model\AuditLogModel;
use Mautic\PointBundle\Event as Events;
use Mautic\PointBundle\PointEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class PointSubscriber implements EventSubscriberInterface
{
public function __construct(
private IpLookupHelper $ipLookupHelper,
private AuditLogModel $auditLogModel,
) {
}
public static function getSubscribedEvents(): array
{
return [
PointEvents::POINT_POST_SAVE => ['onPointPostSave', 0],
PointEvents::POINT_POST_DELETE => ['onPointDelete', 0],
PointEvents::TRIGGER_POST_SAVE => ['onTriggerPostSave', 0],
PointEvents::TRIGGER_POST_DELETE => ['onTriggerDelete', 0],
];
}
/**
* Add an entry to the audit log.
*/
public function onPointPostSave(Events\PointEvent $event): void
{
$point = $event->getPoint();
if ($details = $event->getChanges()) {
$log = [
'bundle' => 'point',
'object' => 'point',
'objectId' => $point->getId(),
'action' => ($event->isNew()) ? 'create' : 'update',
'details' => $details,
'ipAddress' => $this->ipLookupHelper->getIpAddressFromRequest(),
];
$this->auditLogModel->writeToLog($log);
}
}
/**
* Add a delete entry to the audit log.
*/
public function onPointDelete(Events\PointEvent $event): void
{
$point = $event->getPoint();
$log = [
'bundle' => 'point',
'object' => 'point',
'objectId' => $point->deletedId,
'action' => 'delete',
'details' => ['name' => $point->getName()],
'ipAddress' => $this->ipLookupHelper->getIpAddressFromRequest(),
];
$this->auditLogModel->writeToLog($log);
}
/**
* Add an entry to the audit log.
*/
public function onTriggerPostSave(Events\TriggerEvent $event): void
{
$trigger = $event->getTrigger();
if ($details = $event->getChanges()) {
$log = [
'bundle' => 'point',
'object' => 'trigger',
'objectId' => $trigger->getId(),
'action' => ($event->isNew()) ? 'create' : 'update',
'details' => $details,
'ipAddress' => $this->ipLookupHelper->getIpAddressFromRequest(),
];
$this->auditLogModel->writeToLog($log);
}
}
/**
* Add a delete entry to the audit log.
*/
public function onTriggerDelete(Events\TriggerEvent $event): void
{
$trigger = $event->getTrigger();
$log = [
'bundle' => 'point',
'object' => 'trigger',
'objectId' => $trigger->deletedId,
'action' => 'delete',
'details' => ['name' => $trigger->getName()],
'ipAddress' => $this->ipLookupHelper->getIpAddressFromRequest(),
];
$this->auditLogModel->writeToLog($log);
}
}

View File

@@ -0,0 +1,103 @@
<?php
namespace Mautic\PointBundle\EventListener;
use Mautic\LeadBundle\Report\FieldsBuilder;
use Mautic\PointBundle\Entity\Group;
use Mautic\PointBundle\Entity\GroupContactScore;
use Mautic\ReportBundle\Event\ReportBuilderEvent;
use Mautic\ReportBundle\Event\ReportGeneratorEvent;
use Mautic\ReportBundle\ReportEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class ReportSubscriber implements EventSubscriberInterface
{
public const CONTEXT_GROUP_SCORE = 'group.score';
public const GROUP_PREFIX = 'pl';
public const GROUP_SCORE_PREFIX = 'ls';
public const GROUP_COLUMNS = [
self::GROUP_PREFIX.'.id' => [
'alias' => 'group_id',
'label' => 'mautic.point.report.group_id',
'type' => 'int',
],
self::GROUP_PREFIX.'.name' => [
'alias' => 'group_name',
'label' => 'mautic.point.report.group_name',
'type' => 'string',
],
self::GROUP_SCORE_PREFIX.'.score' => [
'alias' => 'group_score',
'label' => 'mautic.point.report.group_score',
'type' => 'int',
],
];
/**
* @var string[]
*/
private array $reportContexts = [
self::CONTEXT_GROUP_SCORE,
];
public function __construct(
private FieldsBuilder $fieldsBuilder,
) {
}
public static function getSubscribedEvents(): array
{
return [
ReportEvents::REPORT_ON_BUILD => ['onReportBuilder', -10],
ReportEvents::REPORT_ON_GENERATE => ['onReportGenerate', -10],
];
}
public function onReportBuilder(ReportBuilderEvent $event): void
{
if (!$event->checkContext($this->reportContexts)) {
return;
}
if ($event->checkContext(self::CONTEXT_GROUP_SCORE)) {
$columns = array_merge(
self::GROUP_COLUMNS,
$event->getLeadColumns()
);
$filters = array_merge(
$columns,
$this->fieldsBuilder->getLeadFilter('l.', 's.')
);
$data = [
'display_name' => 'mautic.point.group.report.table',
'columns' => $columns,
'filters' => $filters,
];
$event->addTable(self::CONTEXT_GROUP_SCORE, $data, 'contacts');
}
}
public function onReportGenerate(ReportGeneratorEvent $event): void
{
if (!$event->checkContext($this->reportContexts)) {
return;
}
$qb = $event->getQueryBuilder();
if ($event->checkContext(self::CONTEXT_GROUP_SCORE)) {
$qb->from(MAUTIC_TABLE_PREFIX.GroupContactScore::TABLE_NAME, self::GROUP_SCORE_PREFIX)
->leftJoin(self::GROUP_SCORE_PREFIX, MAUTIC_TABLE_PREFIX.Group::TABLE_NAME, self::GROUP_PREFIX, self::GROUP_SCORE_PREFIX.'.group_id = '.self::GROUP_PREFIX.'.id')
->leftJoin(self::GROUP_SCORE_PREFIX, MAUTIC_TABLE_PREFIX.'leads', 'l', self::GROUP_SCORE_PREFIX.'.contact_id = l.id');
if ($event->hasFilter('s.leadlist_id')) {
$qb->join('l', MAUTIC_TABLE_PREFIX.'lead_lists_leads', 's', 's.lead_id = l.id AND s.manually_removed = 0');
}
}
$event->setQueryBuilder($qb);
}
}

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace Mautic\PointBundle\EventListener;
use Mautic\CoreBundle\CoreEvents;
use Mautic\CoreBundle\DTO\GlobalSearchFilterDTO;
use Mautic\CoreBundle\Event as MauticEvents;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Service\GlobalSearch;
use Mautic\PointBundle\Model\PointGroupModel;
use Mautic\PointBundle\Model\PointModel;
use Mautic\PointBundle\Model\TriggerModel;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class SearchSubscriber implements EventSubscriberInterface
{
public function __construct(
private PointModel $pointModel,
private TriggerModel $pointTriggerModel,
private PointGroupModel $pointGroupModel,
private CorePermissions $security,
private GlobalSearch $globalSearch,
) {
}
public static function getSubscribedEvents(): array
{
return [
CoreEvents::GLOBAL_SEARCH => [
['onGlobalSearchPointActions', 0],
['onGlobalSearchPointTriggers', 0],
['onGlobalSearchPointGroup', 0],
],
CoreEvents::BUILD_COMMAND_LIST => ['onBuildCommandList', 0],
];
}
public function onGlobalSearchPointActions(MauticEvents\GlobalSearchEvent $event): void
{
$filterDTO = new GlobalSearchFilterDTO($event->getSearchString());
$results = $this->globalSearch->performSearch(
$filterDTO,
$this->pointModel,
'@MauticPoint/SubscribedEvents/Search/global_point.html.twig'
);
if (!empty($results)) {
$event->addResults('mautic.point.actions.header.index', $results);
}
}
public function onGlobalSearchPointGroup(MauticEvents\GlobalSearchEvent $event): void
{
$filterDTO = new GlobalSearchFilterDTO($event->getSearchString());
$results = $this->globalSearch->performSearch(
$filterDTO,
$this->pointGroupModel,
'@MauticPoint/SubscribedEvents/Search/global_group.html.twig'
);
if (!empty($results)) {
$event->addResults('mautic.point.group.header.index', $results);
}
}
public function onGlobalSearchPointTriggers(MauticEvents\GlobalSearchEvent $event): void
{
$filterDTO = new GlobalSearchFilterDTO($event->getSearchString());
$results = $this->globalSearch->performSearch(
$filterDTO,
$this->pointTriggerModel,
'@MauticPoint/SubscribedEvents/Search/global_trigger.html.twig'
);
if (!empty($results)) {
$event->addResults('mautic.point.trigger.header.index', $results);
}
}
public function onBuildCommandList(MauticEvents\CommandListEvent $event): void
{
$security = $this->security;
if ($security->isGranted('point:points:view')) {
$event->addCommands(
'mautic.point.actions.header.index',
$this->pointModel->getCommandList()
);
}
}
}

View File

@@ -0,0 +1,79 @@
<?php
namespace Mautic\PointBundle\EventListener;
use Mautic\LeadBundle\Event\LeadListFiltersChoicesEvent;
use Mautic\LeadBundle\Event\SegmentDictionaryGenerationEvent;
use Mautic\LeadBundle\LeadEvents;
use Mautic\LeadBundle\Provider\TypeOperatorProviderInterface;
use Mautic\LeadBundle\Segment\Query\Filter\ForeignValueFilterQueryBuilder;
use Mautic\PointBundle\Entity\Group;
use Mautic\PointBundle\Entity\GroupRepository;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class SegmentFilterSubscriber implements EventSubscriberInterface
{
public function __construct(
private GroupRepository $groupRepository,
private TypeOperatorProviderInterface $typeOperatorProvider,
private TranslatorInterface $translator,
) {
}
public static function getSubscribedEvents(): array
{
return [
LeadEvents::LIST_FILTERS_CHOICES_ON_GENERATE => [
['onGenerateSegmentFiltersAddPointGroups', -10],
],
LeadEvents::SEGMENT_DICTIONARY_ON_GENERATE => [
['onSegmentDictionaryGenerate', 0],
],
];
}
public function onGenerateSegmentFiltersAddPointGroups(LeadListFiltersChoicesEvent $event): void
{
// Only show for segments and not dynamic content addressed by https://github.com/mautic/mautic/pull/9260
if (!$event->isForSegmentation()) {
return;
}
$groups = $this->groupRepository->getEntities();
$choices = [];
/** @var Group $group */
foreach ($groups as $group) {
$choices['group_points_'.$group->getId()] = [
'label' => $this->translator->trans('mautic.lead.lead.event.grouppoints', ['%group%' => $group->getName()]),
'properties' => ['type' => 'number'],
'operators' => $this->typeOperatorProvider->getOperatorsForFieldType('default'),
'object' => 'lead',
];
}
foreach ($choices as $alias => $fieldOptions) {
$event->addChoice('groups', $alias, $fieldOptions);
}
}
public function onSegmentDictionaryGenerate(SegmentDictionaryGenerationEvent $event): void
{
$groups = $this->groupRepository->getEntities();
/** @var Group $group */
foreach ($groups as $group) {
$event->addTranslation('group_points_'.$group->getId(), [
'type' => ForeignValueFilterQueryBuilder::getServiceId(),
'foreign_table' => 'point_group_contact_score',
'foreign_table_field' => 'contact_id',
'table' => 'leads',
'table_field' => 'id',
'field' => 'score',
'where' => 'point_group_contact_score.group_id = '.$group->getId(),
'null_value' => 0,
]);
}
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace Mautic\PointBundle\EventListener;
use Doctrine\ORM\EntityManager;
use Mautic\CoreBundle\EventListener\CommonStatsSubscriber;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\PointBundle\Entity\LeadPointLog;
use Mautic\PointBundle\Entity\LeadTriggerLog;
class StatsSubscriber extends CommonStatsSubscriber
{
public function __construct(CorePermissions $security, EntityManager $entityManager)
{
parent::__construct($security, $entityManager);
$this->addContactRestrictedRepositories(
[
LeadPointLog::class,
LeadTriggerLog::class,
]
);
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace Mautic\PointBundle\Form\Type;
use Doctrine\ORM\EntityManager;
use Mautic\CoreBundle\Form\DataTransformer\IdToEntityModelTransformer;
use Mautic\PointBundle\Entity\Group;
use Mautic\PointBundle\Entity\GroupRepository;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* @extends AbstractType<GroupListType>
*/
class GroupListType extends AbstractType
{
public function __construct(
private EntityManager $em,
private GroupRepository $repo,
) {
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
if (true === $options['return_entity']) {
$transformer = new IdToEntityModelTransformer($this->em, Group::class, 'id');
$builder->addModelTransformer($transformer);
}
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'choices' => function (Options $options): array {
$groups = $this->repo->getEntities();
$choices = [];
foreach ($groups as $l) {
$choices[$l->getName().' ('.$l->getId().')'] = $l->getId();
}
return $choices;
},
'label' => 'mautic.point.group.form.group',
'label_attr' => ['class' => 'control-label'],
'multiple' => false,
'required' => false,
'return_entity' => true,
]);
}
public function getParent(): string
{
return ChoiceType::class;
}
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace Mautic\PointBundle\Form\Type;
use Mautic\CoreBundle\Form\EventListener\CleanFormSubscriber;
use Mautic\CoreBundle\Form\EventListener\FormExitSubscriber;
use Mautic\CoreBundle\Form\Type\FormButtonsType;
use Mautic\CoreBundle\Form\Type\YesNoButtonGroupType;
use Mautic\PointBundle\Entity\Group;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* @extends AbstractType<GroupType>
*/
class GroupType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->addEventSubscriber(new CleanFormSubscriber(['description' => 'html']));
$builder->addEventSubscriber(new FormExitSubscriber('point.group', $options));
$builder->add('name', TextType::class, [
'label' => 'mautic.core.name',
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control'],
]);
$builder->add(
'description',
TextareaType::class,
[
'label' => 'mautic.core.description',
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control editor'],
'required' => false,
]
);
$data = false;
if (!empty($options['data']) && $options['data'] instanceof Group) {
$data = $options['data']->isPublished(false);
}
$builder->add('isPublished', YesNoButtonGroupType::class, [
'label' => 'mautic.core.form.available',
'data' => $data,
]);
$builder->add('buttons', FormButtonsType::class);
if (!empty($options['action'])) {
$builder->setAction($options['action']);
}
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults(
[
'data_class' => Group::class,
]
);
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace Mautic\PointBundle\Form\Type;
use Mautic\CoreBundle\Form\EventListener\CleanFormSubscriber;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* @extends AbstractType<array<mixed>>
*/
class PointActionType extends AbstractType
{
/**
* @param FormBuilderInterface<array<mixed>|null> $builder
* @param array<string, mixed> $options
*/
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$masks = [];
$formTypeOptions = [
'label' => false,
];
if (!empty($options['formTypeOptions'])) {
$formTypeOptions = array_merge($formTypeOptions, $options['formTypeOptions']);
}
if (isset($options['formType'])) {
$builder->add('properties', $options['formType'], $formTypeOptions);
}
if (isset($options['settings']['formTypeCleanMasks'])) {
$masks['properties'] = $options['settings']['formTypeCleanMasks'];
}
$builder->addEventSubscriber(new CleanFormSubscriber($masks));
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'formType' => null,
'formTypeOptions' => [],
]);
}
public function getBlockPrefix(): string
{
return 'pointaction';
}
}

View File

@@ -0,0 +1,181 @@
<?php
namespace Mautic\PointBundle\Form\Type;
use Mautic\CategoryBundle\Form\Type\CategoryListType;
use Mautic\CoreBundle\Form\EventListener\CleanFormSubscriber;
use Mautic\CoreBundle\Form\EventListener\FormExitSubscriber;
use Mautic\CoreBundle\Form\Type\FormButtonsType;
use Mautic\CoreBundle\Form\Type\PublishDownDateType;
use Mautic\CoreBundle\Form\Type\PublishUpDateType;
use Mautic\CoreBundle\Form\Type\YesNoButtonGroupType;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\PointBundle\Entity\Point;
use Mautic\ProjectBundle\Form\Type\ProjectType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* @extends AbstractType<Point>
*/
class PointType extends AbstractType
{
public function __construct(
private CorePermissions $security,
) {
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->addEventSubscriber(new CleanFormSubscriber(['description' => 'html']));
$builder->addEventSubscriber(new FormExitSubscriber('point', $options));
$builder->add(
'name',
TextType::class,
[
'label' => 'mautic.core.name',
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control'],
]
);
$builder->add(
'description',
TextareaType::class,
[
'label' => 'mautic.core.description',
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control editor'],
'required' => false,
]
);
$builder->add(
'type',
ChoiceType::class,
[
'choices' => $options['pointActions']['choices'],
'placeholder' => '',
'label' => 'mautic.point.form.type',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'onchange' => 'Mautic.getPointActionPropertiesForm(this.value);',
],
]
);
$builder->add(
'delta',
NumberType::class,
[
'label' => 'mautic.point.action.delta',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.point.action.delta.help',
],
'scale' => 0,
]
);
$type = (!empty($options['actionType'])) ? $options['actionType'] : $options['data']->getType();
if ($type && !empty($options['pointActions']['actions'][$type]['formType'])) {
$formType = $options['pointActions']['actions'][$type]['formType'];
$properties = ($options['data']) ? $options['data']->getProperties() : [];
$builder->add(
'properties',
$formType,
[
'label' => false,
'data' => $properties,
]
);
}
$builder->add(
'group',
GroupListType::class,
[
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.point.group.form.group_descr',
],
]
);
if (!empty($options['data']) && $options['data'] instanceof Point) {
$readonly = !$this->security->hasEntityAccess(
'point:points:publishown',
'point:points:publishother',
$options['data']->getCreatedBy()
);
$data = $options['data']->isPublished(false);
} elseif (!$this->security->isGranted('point:points:publishown')) {
$readonly = true;
$data = false;
} else {
$readonly = false;
$data = true;
}
$builder->add(
'isPublished',
YesNoButtonGroupType::class,
[
'data' => $data,
'attr' => [
'readonly' => $readonly,
],
]
);
$builder->add(
'repeatable',
YesNoButtonGroupType::class,
[
'label' => 'mautic.point.form.repeat',
'data' => $options['data']->getRepeatable() ?: false,
'attr' => [
'tooltip' => 'mautic.point.form.repeat.help',
],
'yes_label' => 'mautic.point.form.repeat.yes',
'no_label' => 'mautic.point.form.repeat.no',
]
);
$builder->add('publishUp', PublishUpDateType::class);
$builder->add('publishDown', PublishDownDateType::class);
$builder->add(
'category',
CategoryListType::class,
[
'bundle' => 'point',
]
);
$builder->add('projects', ProjectType::class);
$builder->add('buttons', FormButtonsType::class);
if (!empty($options['action'])) {
$builder->setAction($options['action']);
}
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults(['data_class' => Point::class]);
$resolver->setRequired(['pointActions']);
$resolver->setDefined(['actionType']);
}
}

View File

@@ -0,0 +1,114 @@
<?php
namespace Mautic\PointBundle\Form\Type;
use Mautic\CoreBundle\Form\EventListener\CleanFormSubscriber;
use Mautic\CoreBundle\Form\Type\FormButtonsType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* @extends AbstractType<array<mixed>>
*/
class TriggerEventType extends AbstractType
{
/**
* @param FormBuilderInterface<array<mixed>|null> $builder
* @param array<string, mixed> $options
*/
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$masks = ['description' => 'html'];
$builder->add(
'name',
TextType::class,
[
'label' => 'mautic.core.name',
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control'],
'required' => false,
]
);
$builder->add(
'description',
TextareaType::class,
[
'label' => 'mautic.core.description',
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control editor'],
'required' => false,
]
);
if (!empty($options['settings']['formType'])) {
$properties = (!empty($options['data']['properties'])) ? $options['data']['properties'] : null;
$formTypeOptions = [
'label' => false,
'data' => $properties,
];
if (!empty($options['settings']['formTypeOptions'])) {
$formTypeOptions = array_merge($formTypeOptions, $options['settings']['formTypeOptions']);
}
if (isset($options['settings']['formTypeCleanMasks'])) {
$masks['properties'] = $options['settings']['formTypeCleanMasks'];
}
$builder->add('properties', $options['settings']['formType'], $formTypeOptions);
}
$builder->add('type', HiddenType::class);
$update = !empty($properties);
if (!empty($update)) {
$btnValue = 'mautic.core.form.update';
$btnIcon = 'ri-edit-line';
} else {
$btnValue = 'mautic.core.form.add';
$btnIcon = 'ri-add-line';
}
$builder->add(
'buttons',
FormButtonsType::class,
[
'save_text' => $btnValue,
'save_icon' => $btnIcon,
'apply_text' => false,
'container_class' => 'bottom-form-buttons',
]
);
$builder->add(
'triggerId',
HiddenType::class,
[
'mapped' => false,
]
);
$builder->addEventSubscriber(new CleanFormSubscriber($masks));
if (!empty($options['action'])) {
$builder->setAction($options['action']);
}
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults(['settings' => false]);
$resolver->setRequired(['settings']);
}
public function getBlockPrefix(): string
{
return 'pointtriggerevent';
}
}

View File

@@ -0,0 +1,174 @@
<?php
namespace Mautic\PointBundle\Form\Type;
use Mautic\CategoryBundle\Form\Type\CategoryListType;
use Mautic\CoreBundle\Form\EventListener\CleanFormSubscriber;
use Mautic\CoreBundle\Form\EventListener\FormExitSubscriber;
use Mautic\CoreBundle\Form\Type\FormButtonsType;
use Mautic\CoreBundle\Form\Type\PublishDownDateType;
use Mautic\CoreBundle\Form\Type\PublishUpDateType;
use Mautic\CoreBundle\Form\Type\YesNoButtonGroupType;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\PointBundle\Entity\Trigger;
use Mautic\ProjectBundle\Form\Type\ProjectType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* @extends AbstractType<Trigger>
*/
class TriggerType extends AbstractType
{
public function __construct(
private CorePermissions $security,
) {
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->addEventSubscriber(new CleanFormSubscriber(['description' => 'html']));
$builder->addEventSubscriber(new FormExitSubscriber('point', $options));
$builder->add(
'name',
TextType::class,
[
'label' => 'mautic.core.name',
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control'],
]
);
$builder->add(
'description',
TextareaType::class,
[
'label' => 'mautic.core.description',
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control editor'],
'required' => false,
]
);
$builder->add(
'category',
CategoryListType::class,
[
'bundle' => 'point',
]
);
$builder->add('projects', ProjectType::class);
$builder->add(
'points',
NumberType::class,
[
'label' => 'mautic.point.trigger.form.points',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.point.trigger.form.points_descr',
],
'required' => false,
]
);
$color = $options['data']->getColor();
$builder->add(
'color',
TextType::class,
[
'label' => 'mautic.point.trigger.form.color',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'data-toggle' => 'color',
'tooltip' => 'mautic.point.trigger.form.color_descr',
],
'required' => false,
'data' => (!empty($color)) ? $color : 'a0acb8',
'empty_data' => 'a0acb8',
]
);
$builder->add(
'group',
GroupListType::class,
[
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.point.group.form.group_descr',
],
]
);
$builder->add(
'triggerExistingLeads',
YesNoButtonGroupType::class,
[
'label' => 'mautic.point.trigger.form.existingleads',
]
);
if (!empty($options['data']) && $options['data']->getId()) {
$readonly = !$this->security->isGranted('point:triggers:publish');
$data = $options['data']->isPublished(false);
} elseif (!$this->security->isGranted('point:triggers:publish')) {
$readonly = true;
$data = false;
} else {
$readonly = false;
$data = false;
}
$builder->add(
'isPublished',
YesNoButtonGroupType::class,
[
'data' => $data,
'attr' => [
'readonly' => $readonly,
],
]
);
$builder->add('publishUp', PublishUpDateType::class);
$builder->add('publishDown', PublishDownDateType::class);
$builder->add(
'sessionId',
HiddenType::class,
[
'mapped' => false,
]
);
$builder->add('buttons', FormButtonsType::class);
if (!empty($options['action'])) {
$builder->setAction($options['action']);
}
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults(
[
'data_class' => Trigger::class,
]
);
}
public function getBlockPrefix(): string
{
return 'pointtrigger';
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace Mautic\PointBundle\Helper;
use Mautic\LeadBundle\Entity\Lead;
class EventHelper
{
/**
* @param Lead $lead
* @param array $action
*
* @return int
*/
public static function engagePointAction($lead, $action)
{
static $initiated = [];
$pointsChange = 0;
// only initiate once per lead per type
if (empty($initiated[$lead->getId()][$action['type']])) {
if (!empty($action['points'])) {
$pointsChange = $action['points'];
$initiated[$lead->getId()][$action['type']] = true;
}
}
return $pointsChange;
}
}

View File

@@ -0,0 +1,9 @@
<?php
namespace Mautic\PointBundle;
use Symfony\Component\HttpKernel\Bundle\Bundle;
class MauticPointBundle extends Bundle
{
}

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace Mautic\PointBundle\Model;
use Mautic\CoreBundle\Model\FormModel as CommonFormModel;
use Mautic\CoreBundle\Model\GlobalSearchInterface;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\PointBundle\Entity\Group;
use Mautic\PointBundle\Entity\GroupContactScore;
use Mautic\PointBundle\Entity\GroupRepository;
use Mautic\PointBundle\Event as Events;
use Mautic\PointBundle\Form\Type\GroupType;
use Mautic\PointBundle\PointGroupEvents;
use Symfony\Component\Form\FormFactory;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
use Symfony\Contracts\EventDispatcher\Event;
/**
* @extends CommonFormModel<Group>
*/
class PointGroupModel extends CommonFormModel implements GlobalSearchInterface
{
public function getRepository(): GroupRepository
{
return $this->em->getRepository(Group::class);
}
public function getPermissionBase(): string
{
return 'point:groups';
}
/**
* @param object $entity
* @param FormFactory $formFactory
* @param string|null $action
* @param array<string,string> $options
*
* @throws MethodNotAllowedHttpException
*/
public function createForm($entity, $formFactory, $action = null, $options = []): FormInterface
{
if (!$entity instanceof Group) {
throw new MethodNotAllowedHttpException(['Group']);
}
if (!empty($action)) {
$options['action'] = $action;
}
return $formFactory->create(GroupType::class, $entity, $options);
}
/**
* Get a specific entity or generate a new one if id is empty.
*
* @param int $id
*/
public function getEntity($id = null): ?Group
{
if (null === $id) {
return new Group();
}
return parent::getEntity($id);
}
/**
* @throws MethodNotAllowedHttpException
*/
protected function dispatchEvent($action, &$entity, $isNew = false, ?Event $event = null): ?Event
{
if (!$entity instanceof Group) {
throw new MethodNotAllowedHttpException(['Group']);
}
switch ($action) {
case 'pre_save':
$name = PointGroupEvents::GROUP_PRE_SAVE;
break;
case 'post_save':
$name = PointGroupEvents::GROUP_POST_SAVE;
break;
case 'pre_delete':
$name = PointGroupEvents::GROUP_PRE_DELETE;
break;
case 'post_delete':
$name = PointGroupEvents::GROUP_POST_DELETE;
break;
default:
return null;
}
if ($this->dispatcher->hasListeners($name)) {
if (empty($event)) {
$event = new Events\GroupEvent($entity);
}
$this->dispatcher->dispatch($event, $name);
return $event;
}
return null;
}
public function adjustPoints(Lead $contact, Group $group, int $points, string $operator = Lead::POINTS_ADD): Lead
{
$contactScore = $contact->getGroupScore($group);
if (empty($contactScore)) {
$contactScore = new GroupContactScore();
$contactScore->setContact($contact);
$contactScore->setGroup($group);
$contactScore->setScore(0);
$contact->addGroupScore($contactScore);
}
$oldScore = $contactScore->getScore();
$newScore = $oldScore;
match ($operator) {
Lead::POINTS_ADD => $newScore += $points,
Lead::POINTS_SUBTRACT => $newScore -= $points,
Lead::POINTS_MULTIPLY => $newScore *= $points,
Lead::POINTS_DIVIDE => $newScore /= $points,
Lead::POINTS_SET => $newScore = $points,
default => throw new \UnexpectedValueException('Invalid operator'),
};
$contactScore->setScore($newScore);
$this->em->persist($contactScore);
$this->em->flush();
$scoreChangeEvent = new Events\GroupScoreChangeEvent($contactScore, $oldScore, $newScore);
$this->dispatcher->dispatch($scoreChangeEvent, PointGroupEvents::SCORE_CHANGE);
return $contact;
}
public static function isAllowedPointOperation(string $operator): bool
{
return in_array($operator, [Lead::POINTS_ADD, Lead::POINTS_SUBTRACT, Lead::POINTS_MULTIPLY, Lead::POINTS_DIVIDE, Lead::POINTS_SET]);
}
}

View File

@@ -0,0 +1,371 @@
<?php
namespace Mautic\PointBundle\Model;
use Doctrine\ORM\EntityManager;
use Mautic\CoreBundle\Helper\Chart\ChartQuery;
use Mautic\CoreBundle\Helper\Chart\LineChart;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\IpLookupHelper;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\CoreBundle\Model\FormModel as CommonFormModel;
use Mautic\CoreBundle\Model\GlobalSearchInterface;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Model\LeadModel;
use Mautic\LeadBundle\Tracker\ContactTracker;
use Mautic\PointBundle\Entity\LeadPointLog;
use Mautic\PointBundle\Entity\Point;
use Mautic\PointBundle\Entity\PointRepository;
use Mautic\PointBundle\Event\PointActionEvent;
use Mautic\PointBundle\Event\PointBuilderEvent;
use Mautic\PointBundle\Event\PointEvent;
use Mautic\PointBundle\Form\Type\PointType;
use Mautic\PointBundle\PointEvents;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Contracts\EventDispatcher\Event;
use Symfony\Contracts\Service\ResetInterface;
/**
* @extends CommonFormModel<Point>
*/
class PointModel extends CommonFormModel implements GlobalSearchInterface, ResetInterface
{
/**
* @var array<string, mixed>
*/
private array $actions = [];
public function __construct(
protected RequestStack $requestStack,
protected IpLookupHelper $ipLookupHelper,
protected LeadModel $leadModel,
private ContactTracker $contactTracker,
EntityManager $em,
CorePermissions $security,
EventDispatcherInterface $dispatcher,
UrlGeneratorInterface $router,
Translator $translator,
UserHelper $userHelper,
LoggerInterface $mauticLogger,
CoreParametersHelper $coreParametersHelper,
private PointGroupModel $pointGroupModel,
) {
parent::__construct($em, $security, $dispatcher, $router, $translator, $userHelper, $mauticLogger, $coreParametersHelper);
}
/**
* @return PointRepository
*/
public function getRepository()
{
return $this->em->getRepository(Point::class);
}
public function getPermissionBase(): string
{
return 'point:points';
}
/**
* @throws MethodNotAllowedHttpException
*/
public function createForm($entity, FormFactoryInterface $formFactory, $action = null, $options = []): \Symfony\Component\Form\FormInterface
{
if (!$entity instanceof Point) {
throw new MethodNotAllowedHttpException(['Point']);
}
if (!empty($action)) {
$options['action'] = $action;
}
if (empty($options['pointActions'])) {
$options['pointActions'] = $this->getPointActions();
}
return $formFactory->create(PointType::class, $entity, $options);
}
public function getEntity($id = null): ?Point
{
if (null === $id) {
return new Point();
}
return parent::getEntity($id);
}
/**
* @throws MethodNotAllowedHttpException
*/
protected function dispatchEvent($action, &$entity, $isNew = false, ?Event $event = null): ?Event
{
if (!$entity instanceof Point) {
throw new MethodNotAllowedHttpException(['Point']);
}
switch ($action) {
case 'pre_save':
$name = PointEvents::POINT_PRE_SAVE;
break;
case 'post_save':
$name = PointEvents::POINT_POST_SAVE;
break;
case 'pre_delete':
$name = PointEvents::POINT_PRE_DELETE;
break;
case 'post_delete':
$name = PointEvents::POINT_POST_DELETE;
break;
default:
return null;
}
if ($this->dispatcher->hasListeners($name)) {
if (empty($event)) {
$event = new PointEvent($entity, $isNew);
$event->setEntityManager($this->em);
}
$this->dispatcher->dispatch($event, $name);
return $event;
}
return null;
}
/**
* Gets array of custom actions from bundles subscribed PointEvents::POINT_ON_BUILD.
*
* @return mixed
*/
public function getPointActions()
{
if ([] === $this->actions) {
// build them
$this->actions = [];
$event = new PointBuilderEvent($this->translator);
$this->dispatcher->dispatch($event, PointEvents::POINT_ON_BUILD);
$this->actions['actions'] = $event->getActions();
$this->actions['list'] = $event->getActionList();
$this->actions['choices'] = $event->getActionChoices();
}
return $this->actions;
}
/**
* Triggers a specific point change.
*
* @param mixed $eventDetails passthrough from function triggering action to the callback function
* @param mixed $typeId Something unique to the triggering event to prevent unnecessary duplicate calls
* @param bool $allowUserRequest
*
* @throws \ReflectionException
*/
public function triggerAction($type, $eventDetails = null, $typeId = null, ?Lead $lead = null, $allowUserRequest = false): void
{
// only trigger actions for not logged Mautic users
if (!$this->security->isAnonymous() && !$allowUserRequest) {
return;
}
if (null !== $typeId && MAUTIC_ENV === 'prod' && null !== $this->requestStack->getMainRequest()) {
// let's prevent some unnecessary DB calls
$session = $this->requestStack->getMainRequest()->getSession();
$triggeredEvents = $session->get('mautic.triggered.point.actions', []);
if (in_array($typeId, $triggeredEvents)) {
return;
}
$triggeredEvents[] = $typeId;
$session->set('mautic.triggered.point.actions', $triggeredEvents);
}
// find all the actions for published points
/** @var PointRepository $repo */
$repo = $this->getRepository();
$availablePoints = $repo->getPublishedByType($type);
if (empty($availablePoints)) {
return;
}
$ipAddress = $this->ipLookupHelper->getIpAddress();
$hasLeadPointChanges = false;
if (null === $lead) {
$lead = $this->contactTracker->getContact();
if (null === $lead || !$lead->getId()) {
return;
}
}
// get available actions
$availableActions = $this->getPointActions();
// get a list of actions that has already been performed on this lead
$completedActions = $repo->getCompletedLeadActions($type, $lead->getId());
$persist = [];
/** @var Point $action */
foreach ($availablePoints as $action) {
// if it's already been done or not repeatable, then skip it
if (!$action->getRepeatable() && isset($completedActions[$action->getId()])) {
continue;
}
// make sure the action still exists
if (!isset($availableActions['actions'][$action->getType()])) {
continue;
}
$settings = $availableActions['actions'][$action->getType()];
$args = [
'action' => [
'id' => $action->getId(),
'type' => $action->getType(),
'name' => $action->getName(),
'properties' => $action->getProperties(),
'points' => $action->getDelta(),
],
'lead' => $lead,
'eventDetails' => $eventDetails,
];
$callback = $settings['callback'] ?? [\Mautic\PointBundle\Helper\EventHelper::class, 'engagePointAction'];
if (is_callable($callback)) {
$object = null;
if (is_array($callback)) {
$reflection = new \ReflectionMethod($callback[0], $callback[1]);
if (is_object($callback[0])) {
$object = $callback[0];
}
} elseif (str_contains($callback, '::')) {
$parts = explode('::', $callback);
$reflection = new \ReflectionMethod($parts[0], $parts[1]);
} else {
$reflection = new \ReflectionMethod(null, $callback);
}
$pass = [];
foreach ($reflection->getParameters() as $param) {
if (isset($args[$param->getName()])) {
$pass[] = $args[$param->getName()];
} else {
$pass[] = null;
}
}
$pointsChange = $reflection->invokeArgs($object, $pass);
if ($pointsChange) {
$delta = $action->getDelta();
$pointsChangeLogEntryName = $action->getId().': '.$action->getName();
$pointGroup = $action->getGroup();
if (!empty($pointGroup)) {
$this->pointGroupModel->adjustPoints($lead, $pointGroup, $delta);
} else {
$lead->adjustPoints($delta);
}
$hasLeadPointChanges = true;
$parsed = explode('.', $action->getType());
$lead->addPointsChangeLogEntry(
$parsed[0],
$pointsChangeLogEntryName,
$parsed[1],
$delta,
$ipAddress,
$pointGroup
);
$event = new PointActionEvent($action, $lead);
$this->dispatcher->dispatch($event, PointEvents::POINT_ON_ACTION);
if (!$action->getRepeatable()) {
$log = new LeadPointLog();
$log->setIpAddress($ipAddress);
$log->setPoint($action);
$log->setLead($lead);
$log->setDateFired(new \DateTime());
$persist[] = $log;
}
}
}
}
if (!empty($persist)) {
$this->getRepository()->saveEntities($persist);
$this->getRepository()->detachEntities($persist);
}
if ($hasLeadPointChanges) {
$this->leadModel->saveEntity($lead);
}
}
/**
* Get line chart data of points.
*
* @param string $unit {@link php.net/manual/en/function.date.php#refsect1-function.date-parameters}
* @param string $dateFormat
* @param array $filter
* @param bool $canViewOthers
*/
public function getPointLineChartData($unit, \DateTime $dateFrom, \DateTime $dateTo, $dateFormat = null, $filter = [], $canViewOthers = true): array
{
$chart = new LineChart($unit, $dateFrom, $dateTo, $dateFormat);
$query = new ChartQuery($this->em->getConnection(), $dateFrom, $dateTo);
$q = $query->prepareTimeDataQuery('lead_points_change_log', 'date_added', $filter);
if (!$canViewOthers) {
$q->join('t', MAUTIC_TABLE_PREFIX.'leads', 'l', 'l.id = t.lead_id')
->andWhere('l.owner_id = :userId')
->setParameter('userId', $this->userHelper->getUser()->getId());
}
$data = $query->loadAndBuildTimeData($q);
$chart->setDataset($this->translator->trans('mautic.point.changes'), $data);
return $chart->render();
}
/**
* @return array<int, int>
*/
public function getPointActionIdsWithDependenciesOnEmail(int $emailId): array
{
$filter = [
'force' => [
['column' => 'p.type', 'expr' => 'in', 'value' => ['email.send', 'email.open']],
],
];
$entities = $this->getEntities(
[
'filter' => $filter,
]
);
$pointActionIds = [];
foreach ($entities as $entity) {
$properties = $entity->getProperties();
if (in_array($emailId, $properties['emails'] ?? [])) {
$pointActionIds[] = $entity->getId();
}
}
return array_unique($pointActionIds);
}
public function reset(): void
{
$this->actions = [];
}
}

View File

@@ -0,0 +1,141 @@
<?php
namespace Mautic\PointBundle\Model;
use Mautic\CoreBundle\Model\FormModel as CommonFormModel;
use Mautic\PointBundle\Entity\TriggerEvent;
use Mautic\PointBundle\Entity\TriggerEventRepository;
use Mautic\PointBundle\Form\Type\TriggerEventType;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
/**
* @extends CommonFormModel<TriggerEvent>
*/
class TriggerEventModel extends CommonFormModel
{
/**
* @return TriggerEventRepository
*/
public function getRepository()
{
return $this->em->getRepository(TriggerEvent::class);
}
public function getPermissionBase(): string
{
return 'point:triggers';
}
public function getEntity($id = null): ?TriggerEvent
{
if (null === $id) {
return new TriggerEvent();
}
return parent::getEntity($id);
}
/**
* @throws MethodNotAllowedHttpException
*/
public function createForm($entity, FormFactoryInterface $formFactory, $action = null, $options = []): \Symfony\Component\Form\FormInterface
{
if (!$entity instanceof TriggerEvent) {
throw new MethodNotAllowedHttpException(['Trigger']);
}
if (!empty($action)) {
$options['action'] = $action;
}
return $formFactory->create(TriggerEventType::class, $entity, $options);
}
/**
* Get segments which are dependent on given segment.
*
* @param int $segmentId
*/
public function getReportIdsWithDependenciesOnSegment($segmentId): array
{
$filter = [
'force' => [
['column' => 'e.type', 'expr' => 'eq', 'value'=>'lead.changelists'],
],
];
$entities = $this->getEntities(
[
'filter' => $filter,
]
);
$dependents = [];
foreach ($entities as $entity) {
$retrFilters = $entity->getProperties();
foreach ($retrFilters as $eachFilter) {
if (in_array($segmentId, $eachFilter)) {
$dependents[] = $entity->getTrigger()->getId();
}
}
}
return $dependents;
}
/**
* @return array<int, int>
*/
public function getPointTriggerIdsWithDependenciesOnEmail(int $emailId): array
{
$filter = [
'force' => [
['column' => 'e.type', 'expr' => 'in', 'value' => ['email.send', 'email.send_to_user']],
],
];
$entities = $this->getEntities(
[
'filter' => $filter,
]
);
$triggerIds = [];
foreach ($entities as $entity) {
$properties = $entity->getProperties();
if (isset($properties['email']) && (int) $properties['email'] === $emailId) {
$triggerIds[] = $entity->getTrigger()->getId();
}
if (isset($properties['useremail']['email']) && (int) $properties['useremail']['email'] === $emailId) {
$triggerIds[] = $entity->getTrigger()->getId();
}
}
return array_unique($triggerIds);
}
/**
* @return array<int, int>
*/
public function getPointTriggerIdsWithDependenciesOnTag(string $tagName): array
{
$filter = [
'force' => [
['column' => 'e.type', 'expr' => 'eq', 'value' => 'lead.changetags'],
],
];
$entities = $this->getEntities(
[
'filter' => $filter,
]
);
$triggerIds = [];
foreach ($entities as $entity) {
$properties = $entity->getProperties();
foreach ($properties as $property) {
if (in_array($tagName, $property)) {
$triggerIds[] = $entity->getTrigger()->getId();
}
}
}
return array_unique($triggerIds);
}
}

View File

@@ -0,0 +1,449 @@
<?php
namespace Mautic\PointBundle\Model;
use Doctrine\ORM\EntityManagerInterface;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\DateTimeHelper;
use Mautic\CoreBundle\Helper\IpLookupHelper;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\CoreBundle\Model\FormModel as CommonFormModel;
use Mautic\CoreBundle\Model\GlobalSearchInterface;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Entity\LeadRepository;
use Mautic\LeadBundle\Model\LeadModel;
use Mautic\LeadBundle\Tracker\ContactTracker;
use Mautic\PointBundle\Entity\GroupContactScore;
use Mautic\PointBundle\Entity\LeadTriggerLog;
use Mautic\PointBundle\Entity\Trigger;
use Mautic\PointBundle\Entity\TriggerEvent;
use Mautic\PointBundle\Event as Events;
use Mautic\PointBundle\Event\TriggerBuilderEvent;
use Mautic\PointBundle\Form\Type\TriggerType;
use Mautic\PointBundle\PointEvents;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Contracts\EventDispatcher\Event;
/**
* @extends CommonFormModel<Trigger>
*/
class TriggerModel extends CommonFormModel implements GlobalSearchInterface
{
protected $triggers = [];
/**
* @var array<string, mixed[]>
*/
private $cachedEvents = [];
public function __construct(
protected IpLookupHelper $ipLookupHelper,
protected LeadModel $leadModel,
protected TriggerEventModel $pointTriggerEventModel,
private ContactTracker $contactTracker,
EntityManagerInterface $em,
CorePermissions $security,
EventDispatcherInterface $dispatcher,
UrlGeneratorInterface $router,
Translator $translator,
UserHelper $userHelper,
LoggerInterface $mauticLogger,
CoreParametersHelper $coreParametersHelper,
) {
parent::__construct($em, $security, $dispatcher, $router, $translator, $userHelper, $mauticLogger, $coreParametersHelper);
}
/**
* @return \Mautic\PointBundle\Entity\TriggerRepository
*/
public function getRepository()
{
return $this->em->getRepository(Trigger::class);
}
/**
* Retrieves an instance of the TriggerEventRepository.
*
* @return \Mautic\PointBundle\Entity\TriggerEventRepository
*/
public function getEventRepository()
{
return $this->em->getRepository(TriggerEvent::class);
}
public function getPermissionBase(): string
{
return 'point:triggers';
}
/**
* @throws MethodNotAllowedHttpException
*/
public function createForm($entity, FormFactoryInterface $formFactory, $action = null, $options = []): \Symfony\Component\Form\FormInterface
{
if (!$entity instanceof Trigger) {
throw new MethodNotAllowedHttpException(['Trigger']);
}
if (!empty($action)) {
$options['action'] = $action;
}
return $formFactory->create(TriggerType::class, $entity, $options);
}
/**
* @param Trigger $entity
* @param bool $unlock
*/
public function saveEntity($entity, $unlock = true): void
{
$isNew = ($entity->getId()) ? false : true;
parent::saveEntity($entity, $unlock);
// should we trigger for existing leads?
if ($entity->getTriggerExistingLeads() && $entity->isPublished()) {
$events = $entity->getEvents();
$repo = $this->getEventRepository();
$persist = [];
$ipAddress = $this->ipLookupHelper->getIpAddress();
$pointGroup = $entity->getGroup();
/** @var LeadRepository $leadRepository */
$leadRepository = $this->em->getRepository(Lead::class);
foreach ($events as $event) {
$args = [
'filter' => [
'force' => [
[
'column' => 'l.date_added',
'expr' => 'lte',
'value' => (new DateTimeHelper($entity->getDateAdded()))->toUtcString(),
],
],
],
];
if (!$pointGroup) {
$args['filter']['force'][] = [
'column' => 'l.points',
'expr' => 'gte',
'value' => $entity->getPoints(),
];
} else {
$args['qb'] = $leadRepository->getEntitiesDbalQueryBuilder()
->leftJoin('l', MAUTIC_TABLE_PREFIX.GroupContactScore::TABLE_NAME, 'pls', 'l.id = pls.contact_id');
$args['filter']['force'][] = [
'column' => 'pls.score',
'expr' => 'gte',
'value' => $entity->getPoints(),
];
$args['filter']['force'][] = [
'column' => 'pls.group_id',
'expr' => 'eq',
'value' => $entity->getGroup()->getId(),
];
}
if (!$isNew) {
// get a list of leads that has already had this event applied
$leadIds = $repo->getLeadsForEvent($event->getId());
if (!empty($leadIds)) {
$args['filter']['force'][] = [
'column' => 'l.id',
'expr' => 'notIn',
'value' => $leadIds,
];
}
}
// get a list of leads that are before the trigger's date_added and trigger if not already done so
$leads = $this->leadModel->getEntities($args);
/** @var Lead $l */
foreach ($leads as $l) {
if ($this->triggerEvent($event->convertToArray(), $l, true)) {
$log = new LeadTriggerLog();
$log->setIpAddress($ipAddress);
$log->setEvent($event);
$log->setLead($l);
$log->setDateFired(new \DateTime());
$event->addLog($log);
$persist[] = $event;
}
}
}
if (!empty($persist)) {
$repo->saveEntities($persist);
}
}
}
public function getEntity($id = null): ?Trigger
{
if (null === $id) {
return new Trigger();
}
return parent::getEntity($id);
}
/**
* @throws MethodNotAllowedHttpException
*/
protected function dispatchEvent($action, &$entity, $isNew = false, ?Event $event = null): ?Event
{
if (!$entity instanceof Trigger) {
throw new MethodNotAllowedHttpException(['Trigger']);
}
switch ($action) {
case 'pre_save':
$name = PointEvents::TRIGGER_PRE_SAVE;
break;
case 'post_save':
$name = PointEvents::TRIGGER_POST_SAVE;
break;
case 'pre_delete':
$name = PointEvents::TRIGGER_PRE_DELETE;
break;
case 'post_delete':
$name = PointEvents::TRIGGER_POST_DELETE;
break;
default:
return null;
}
if ($this->dispatcher->hasListeners($name)) {
if (empty($event)) {
$event = new Events\TriggerEvent($entity, $isNew);
}
$this->dispatcher->dispatch($event, $name);
return $event;
}
return null;
}
/**
* @param array $sessionEvents
*/
public function setEvents(Trigger $entity, $sessionEvents): void
{
$order = 1;
$existingActions = $entity->getEvents();
foreach ($sessionEvents as $properties) {
$isNew = (!empty($properties['id']) && isset($existingActions[$properties['id']])) ? false : true;
$event = !$isNew ? $existingActions[$properties['id']] : new TriggerEvent();
foreach ($properties as $f => $v) {
if (in_array($f, ['id', 'order'])) {
continue;
}
$func = 'set'.ucfirst($f);
if (method_exists($event, $func)) {
$event->$func($v);
}
}
$event->setTrigger($entity);
$event->setOrder($order);
++$order;
$entity->addTriggerEvent($properties['id'], $event);
}
// Persist if editing the trigger
if ($entity->getId()) {
$this->pointTriggerEventModel->saveEntities($entity->getEvents());
}
}
/**
* Gets array of custom events from bundles subscribed PointEvents::TRIGGER_ON_BUILD.
*
* @return mixed[]
*/
public function getEvents()
{
if (empty($this->cachedEvents)) {
$event = new TriggerBuilderEvent($this->translator);
$this->dispatcher->dispatch($event, PointEvents::TRIGGER_ON_BUILD);
$this->cachedEvents = $event->getEvents();
}
return $this->cachedEvents;
}
/**
* Gets array of custom events from bundles inside groups.
*
* @return mixed[]
*/
public function getEventGroups(): array
{
$events = $this->getEvents();
$groups = [];
foreach ($events as $key => $event) {
$groups[$event['group']][$key] = $event;
}
return $groups;
}
/**
* Triggers a specific event.
*
* @param array $event triggerEvent converted to array
* @param bool $force
*
* @return bool Was event triggered
*/
public function triggerEvent($event, ?Lead $lead = null, $force = false)
{
// only trigger events for anonymous users
if (!$force && !$this->security->isAnonymous()) {
return false;
}
if (null === $lead) {
$lead = $this->contactTracker->getContact();
}
if (!$force) {
// get a list of events that has already been performed on this lead
$appliedEvents = $this->getEventRepository()->getLeadTriggeredEvents($lead->getId());
// if it's already been done, then skip it
if (isset($appliedEvents[$event['id']])) {
return false;
}
}
$availableEvents = $this->getEvents();
$eventType = $event['type'];
// make sure the event still exists
if (!isset($availableEvents[$eventType])) {
return false;
}
$settings = $availableEvents[$eventType];
if (isset($settings['callback']) && is_callable($settings['callback'])) {
return $this->invokeCallback($event, $lead, $settings);
} else {
/** @var TriggerEvent $triggerEvent */
$triggerEvent = $this->getEventRepository()->find($event['id']);
$triggerExecutedEvent = new Events\TriggerExecutedEvent($triggerEvent, $lead);
$this->dispatcher->dispatch($triggerExecutedEvent, $settings['eventName']);
return $triggerExecutedEvent->getResult();
}
}
private function invokeCallback($event, Lead $lead, array $settings): mixed
{
$args = [
'event' => $event,
'lead' => $lead,
'config' => $event['properties'],
];
if (is_array($settings['callback'])) {
$reflection = new \ReflectionMethod($settings['callback'][0], $settings['callback'][1]);
} elseif (str_contains($settings['callback'], '::')) {
$parts = explode('::', $settings['callback']);
$reflection = new \ReflectionMethod($parts[0], $parts[1]);
} else {
$reflection = new \ReflectionMethod(null, $settings['callback']);
}
$pass = [];
foreach ($reflection->getParameters() as $param) {
if (isset($args[$param->getName()])) {
$pass[] = $args[$param->getName()];
} else {
$pass[] = null;
}
}
return $reflection->invokeArgs($this, $pass);
}
/**
* Trigger events for the current lead.
*/
public function triggerEvents(Lead $lead): void
{
$points = $lead->getPoints();
// find all published triggers that is applicable to this points
/** @var \Mautic\PointBundle\Entity\TriggerEventRepository $repo */
$repo = $this->getEventRepository();
$events = $repo->getPublishedByPointTotal($points);
$groupEvents = $repo->getPublishedByGroupScore($lead->getGroupScores());
$events = array_merge($events, $groupEvents);
if (!empty($events)) {
// get a list of actions that has already been applied to this lead
$appliedEvents = $repo->getLeadTriggeredEvents($lead->getId());
$ipAddress = $this->ipLookupHelper->getIpAddress();
$persist = [];
foreach ($events as $event) {
if (isset($appliedEvents[$event['id']])) {
// don't apply the event to the lead if it's already been done
continue;
}
if ($this->triggerEvent($event, $lead, true)) {
$log = new LeadTriggerLog();
$log->setIpAddress($ipAddress);
$log->setEvent($triggerEvent = $this->getEventRepository()->find($event['id']));
$log->setLead($lead);
$log->setDateFired(new \DateTime());
$persist[] = $log;
}
}
if (!empty($persist)) {
$this->getEventRepository()->saveEntities($persist);
$this->getEventRepository()->detachEntities($persist);
}
}
}
/**
* Returns configured color based on passed in $points.
*
* @return string
*/
public function getColorForLeadPoints($points)
{
if (!$this->triggers) {
$this->triggers = $this->getRepository()->getTriggerColors();
}
foreach ($this->triggers as $trigger) {
if ($points >= $trigger['points']) {
return $trigger['color'];
}
}
return '';
}
}

View File

@@ -0,0 +1,126 @@
<?php
namespace Mautic\PointBundle;
/**
* Events available for PointBundle.
*/
final class PointEvents
{
/**
* The mautic.point_pre_save event is thrown right before a form is persisted.
*
* The event listener receives a Mautic\PointBundle\Event\PointEvent instance.
*
* @var string
*/
public const POINT_PRE_SAVE = 'mautic.point_pre_save';
/**
* The mautic.point_post_save event is thrown right after a form is persisted.
*
* The event listener receives a Mautic\PointBundle\Event\PointEvent instance.
*
* @var string
*/
public const POINT_POST_SAVE = 'mautic.point_post_save';
/**
* The mautic.point_pre_delete event is thrown before a form is deleted.
*
* The event listener receives a Mautic\PointBundle\Event\PointEvent instance.
*
* @var string
*/
public const POINT_PRE_DELETE = 'mautic.point_pre_delete';
/**
* The mautic.point_post_delete event is thrown after a form is deleted.
*
* The event listener receives a Mautic\PointBundle\Event\PointEvent instance.
*
* @var string
*/
public const POINT_POST_DELETE = 'mautic.point_post_delete';
/**
* The mautic.point_on_build event is thrown before displaying the point builder form to allow adding of custom actions.
*
* The event listener receives a Mautic\PointBundle\Event\PointBuilderEvent instance.
*
* @var string
*/
public const POINT_ON_BUILD = 'mautic.point_on_build';
/**
* The mautic.point_on_action event is thrown to execute a point action.
*
* The event listener receives a Mautic\PointBundle\Event\PointActionEvent instance.
*
* @var string
*/
public const POINT_ON_ACTION = 'mautic.point_on_action';
/**
* The mautic.point_pre_save event is thrown right before a form is persisted.
*
* The event listener receives a Mautic\PointBundle\Event\TriggerEvent instance.
*
* @var string
*/
public const TRIGGER_PRE_SAVE = 'mautic.trigger_pre_save';
/**
* The mautic.trigger_post_save event is thrown right after a form is persisted.
*
* The event listener receives a Mautic\PointBundle\Event\TriggerEvent instance.
*
* @var string
*/
public const TRIGGER_POST_SAVE = 'mautic.trigger_post_save';
/**
* The mautic.trigger_pre_delete event is thrown before a form is deleted.
*
* The event listener receives a Mautic\PointBundle\Event\TriggerEvent instance.
*
* @var string
*/
public const TRIGGER_PRE_DELETE = 'mautic.trigger_pre_delete';
/**
* The mautic.trigger_post_delete event is thrown after a form is deleted.
*
* The event listener receives a Mautic\PointBundle\Event\TriggerEvent instance.
*
* @var string
*/
public const TRIGGER_POST_DELETE = 'mautic.trigger_post_delete';
/**
* The mautic.trigger_on_build event is thrown before displaying the trigger builder form to allow adding of custom actions.
*
* The event listener receives a Mautic\PointBundle\Event\TriggerBuilderEvent instance.
*
* @var string
*/
public const TRIGGER_ON_BUILD = 'mautic.trigger_on_build';
/**
* The mautic.trigger_on_event_execute event is thrown to execute a trigger event.
*
* The event listener receives a Mautic\PointBundle\Event\TriggerExecutedEvent instance.
*
* @var string
*/
public const TRIGGER_ON_EVENT_EXECUTE = 'mautic.trigger_on_event_execute';
/**
* The mautic.trigger_on_lead_segments_change event is thrown to change lead's segments.
*
* The event listener receives a Mautic\PointBundle\Event\TriggerExecutedEvent instance.
*
* @var string
*/
public const TRIGGER_ON_LEAD_SEGMENTS_CHANGE = 'mautic.trigger_on_lead_segments_change';
}

View File

@@ -0,0 +1,51 @@
<?php
namespace Mautic\PointBundle;
final class PointGroupEvents
{
/**
* The mautic.group_pre_save event is thrown right before a form is persisted.
*
* The event listener receives a Mautic\PointBundle\Event\ScoringCategoryEvent instance.
*
* @var string
*/
public const GROUP_PRE_SAVE = 'mautic.group_pre_save';
/**
* The mautic.group_post_save event is thrown right after a form is persisted.
*
* The event listener receives a Mautic\PointBundle\Event\ScoringCategoryEvent instance.
*
* @var string
*/
public const GROUP_POST_SAVE = 'mautic.group_post_save';
/**
* The mautic.group_pre_delete event is thrown before a form is deleted.
*
* The event listener receives a Mautic\PointBundle\Event\ScoringCategoryEvent instance.
*
* @var string
*/
public const GROUP_PRE_DELETE = 'mautic.group_pre_delete';
/**
* The mautic.group_post_delete event is thrown after a form is deleted.
*
* The event listener receives a Mautic\PointBundle\Event\ScoringCategoryEvent instance.
*
* @var string
*/
public const GROUP_POST_DELETE = 'mautic.group_post_delete';
/**
* The mautic.group_contact_score_change event is dispatched if a group contact score changes.
*
* The event listener receives a Mautic\PointBundle\Event\GroupScoreChangeEvent instance.
*
* @var string
*/
public const SCORE_CHANGE = 'mautic.group_contact_score_change';
}

View File

@@ -0,0 +1,20 @@
{#
Variables
- deleted
- id
- route
- sessionId
#}
{% set action = deleted ? 'undelete' : 'delete' %}
{% set iconClass = deleted ? 'ri-arrow-go-back-line' : 'ri-close-line' %}
{% set btnClass = deleted ? 'btn-warning' : 'btn-danger' %}
{% set route = route|default('mautic_pointtriggerevent_action') %}
<div class="form-buttons hide">
<a data-toggle="ajaxmodal" data-target="#triggerEventModal" href="{{ path(route, {'objectAction': 'edit', 'objectId': id, 'triggerId': sessionId}) }}" class="btn btn-primary btn-xs btn-edit">
<i class="ri-edit-line"></i>
</a>
<a data-menu-link="mautic_point_index" data-toggle="ajax" data-ignore-formexit="true" data-method="POST" data-hide-loadingbar="true" href="{{ path(route, {'objectAction': action, 'objectId': id, 'triggerId': sessionId}) }}" class="btn {{ btnClass }} btn-xs">
<i class="{{ iconClass }}"></i>
</a>
<i class="ri-fw ri-more-2-line reorder-handle"></i>
</div>

View File

@@ -0,0 +1,9 @@
{% if formTheme is defined %}
{% form_theme form formTheme %}
{% endif %}
<div class="bundle-form">
<div class="bundle-form-header">
<h3>{{ eventHeader }}</h3>
</div>
{{ form(form) }}
</div>

View File

@@ -0,0 +1,20 @@
{#
Variables
- event
- id
- sessionId
- deleted (bool, optional)
#}
{% set deleted = deleted|default(false) %}
<div class="trigger-event-row {% if deleted %}bg-danger{% endif %}" id="triggerEvent_{{ id }}">
{{ include('@MauticPoint/Event/actions.html.twig', {
'deleted': deleted,
'id': id,
'route': 'mautic_pointtriggerevent_action',
'sessionId': sessionId|default(''),
}) }}
<span class="trigger-event-label">{{ event.name }}</span>
{% if event.description is not empty %}
<span class="trigger-event-descr">{{ event.description|purify }}</span>
{% endif %}
</div>

View File

@@ -0,0 +1,3 @@
{% block _pointaction_properties_row %}
{{ form_widget(form) }}
{% endblock %}

View File

@@ -0,0 +1,33 @@
{% extends '@MauticCore/Default/content.html.twig' %}
{% block mauticContent %}group{% endblock %}
{% block headerTitle %}
{% if entity.id %}
{{ 'mautic.point.group.menu.edit'|trans }}
{% else %}
{{ 'mautic.point.group.menu.new'|trans }}
{% endif %}
{% endblock %}
{% block content %}
{{ form_start(form) }}
<!-- start: box layout -->
<div class="box-layout">
<!-- container -->
<div class="col-md-9 height-auto bdr-r">
<div class="pa-md">
{{ form_row(form.name) }}
{{ form_row(form.description) }}
</div>
</div>
<div class="col-md-3 height-auto">
<div class="pr-lg pl-lg pt-md pb-md">
{{ form_row(form.isPublished) }}
</div>
</div>
</div>
<!-- end: box layout -->
{{ form_end(form) }}
{% endblock %}

View File

@@ -0,0 +1,139 @@
{% set isIndex = 'index' == tmpl ? true : false %}
{% set tmpl = 'list' %}
{% extends isIndex ? '@MauticCore/Default/content.html.twig' : '@MauticCore/Default/raw_output.html.twig' %}
{% block mauticContent %}group{% endblock %}
{% block headerTitle %}{{ 'mautic.point.group.header.index'|trans }}{% endblock %}
{% block content %}
<div id="page-list-wrapper" class="{% if items|length > 0 or searchValue is not empty %}panel {% endif %}panel-default">
{{- include('@MauticCore/Helper/list_toolbar.html.twig', {
'searchValue': searchValue,
'action': currentRoute,
'page_actions': {
'templateButtons': {
'new': permissions['point:groups:create']
},
'routeBase': 'point.group',
'langVar': 'point.group'
},
'bulk_actions': {
'langVar': 'point.group',
'routeBase': 'point.group',
'templateButtons': {
'delete': permissions['point:groups:delete'],
},
},
}) -}}
<div class="page-list">
{% if items is defined and items is not empty %}
<div class="table-responsive">
<table class="table table-hover" id="groupTable">
<thead>
<tr>
{{ include('@MauticCore/Helper/tableheader.html.twig', {
'checkall': 'true',
'target': '#groupTable',
}) }}
{{ include(
'@MauticCore/Helper/tableheader.html.twig',
{
'sessionVar': 'point.group',
'orderBy' : 'pl.name',
'text' : 'mautic.core.name',
'class' : 'col-group-name',
'default' : true,
}
) }}
{{ include(
'@MauticCore/Helper/tableheader.html.twig',
{
'sessionVar' : 'point.group',
'orderBy' : 'pl.id',
'text' : 'mautic.core.id',
'class' : 'visible-md visible-lg col-group-id',
}
) }}
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>
{{- include('@MauticCore/Helper/list_actions.html.twig', {
item: item,
templateButtons: {
edit: permissions['point:groups:edit'],
delete: permissions['point:groups:delete'],
},
routeBase: 'point.group',
langVar: 'point.group',
}) -}}
</td>
<td>
<div>
{{- include(
'@MauticCore/Helper/publishstatus_icon.html.twig',
{
'item': item, 'model': 'point.group'
}
)}}
{% if permissions['point:groups:edit'] %}
<a href="{{ path('mautic_point.group_action', {
objectAction: 'edit',
objectId: item.getId()
}) }}" data-toggle="ajax">
{{ item.getName() }}
</a>
{% else %}
{{ item.getName() }}
{% endif %}
</div>
{% set description = item.getDescription() %}
{{ include('@MauticCore/Helper/description--inline.html.twig', {
'description': item.description
}) }}
</td>
<td class="visible-md visible-lg">{{ item.getId() }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="panel-footer">
{{- include('@MauticCore/Helper/pagination.html.twig', {
'totalItems' : items|length,
'page' : page,
'limit' : limit,
'baseUrl' : path('mautic_point.group_index'),
'sessionVar' : 'point.group',
}) -}}
</div>
</div>
{% else %}
{% if searchValue is not empty %}
{{ include('@MauticCore/Helper/noresults.html.twig') }}
{% else %}
<div class="mt-80 col-md-offset-2 col-lg-offset-3 col-md-8 col-lg-5 height-auto">
{% set childContainer %}
<div class="mb-md">
{% include '@MauticCore/Components/pictogram.html.twig' with {
'pictogram': 'filter-and-group-data',
'size': '80'
} %}
</div>
{% endset %}
{{ include('@MauticCore/Components/content-block.html.twig', {
heading: 'mautic.point.group.onboarding.heading',
subheading: 'mautic.point.group.onboarding.subheading',
copy: 'mautic.point.group.onboarding.copy',
childContainer: childContainer,
}) }}
</div>
{% endif %}
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,141 @@
{% if items|length > 0 %}
<div class="table-responsive page-list">
<table class="table table-hover point-list" id="pointTable">
<thead>
<tr>
{{ include('@MauticCore/Helper/tableheader.html.twig', {
'checkall': 'true',
'target': '#pointTable',
}) }}
{{ include('@MauticCore/Helper/tableheader.html.twig', {
'sessionVar': 'point',
'orderBy': 'p.name',
'text': 'mautic.core.name',
'class': 'col-point-name',
'default': true,
}) }}
{{ include('@MauticCore/Helper/tableheader.html.twig', {
'sessionVar': 'point',
'orderBy': 'cat.title',
'text': 'mautic.core.category',
'class': 'visible-md visible-lg col-point-category',
}) }}
{{ include('@MauticCore/Helper/tableheader.html.twig', {
'sessionVar': 'point',
'orderBy': 'pl.name',
'text': 'mautic.point.thead.group',
'class': 'visible-md visible-lg col-point-group',
}) }}
{{ include('@MauticCore/Helper/tableheader.html.twig', {
'sessionVar': 'point',
'orderBy': 'p.delta',
'text': 'mautic.point.thead.delta',
'class': 'visible-md visible-lg col-point-delta',
}) }}
<th class="col-point-action">{{ 'mautic.point.thead.action'|trans }}</th>
{{ include('@MauticCore/Helper/tableheader.html.twig', {
'sessionVar': 'point',
'orderBy': 'p.id',
'text': 'mautic.core.id',
'class': 'visible-md visible-lg col-point-id',
}) }}
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>
{{ include('@MauticCore/Helper/list_actions.html.twig', {
'item': item,
'templateButtons': {
'edit': permissions['point:points:edit'],
'clone': permissions['point:points:create'],
'delete': permissions['point:points:delete'],
},
'routeBase': 'point',
}) }}
</td>
<td>
<div>
{{ include('@MauticCore/Helper/publishstatus_icon.html.twig', {'item': item, 'model': 'point'}) }}
{% if permissions['point:points:edit'] %}
<a href="{{ path('mautic_point_action', {'objectAction': 'edit', 'objectId': item.id}) }}" data-toggle="ajax">
{{ item.name }}
</a>
{% else %}
{{ item.name }}
{% endif %}
{{ customContent('point.name', _context) }}
</div>
{{ include('@MauticCore/Helper/description--inline.html.twig', {
'description': item.description
}) }}
</td>
<td class="visible-md visible-lg">
{{ include('@MauticCore/Modules/category--expanded.html.twig', {'category': item.category}) }}
</td>
<td class="visible-md visible-lg">
{% set group = item.group %}
{% set groupName = group.name|default('mautic.point.group.form.nogroup'|trans) %}
{{ groupName }}
</td>
<td class="visible-md visible-lg">{{ item.delta }}</td>
<td>{{ actions[item.type].label|default('')|trans }}</td>
<td class="visible-md visible-lg">{{ item.id }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="panel-footer">
{{ include('@MauticCore/Helper/pagination.html.twig', {
'totalItems': items|length,
'page': page,
'limit': limit,
'menuLinkId': 'mautic_point_index',
'baseUrl': path('mautic_point_index'),
'sessionVar': 'point',
}) }}
</div>
{% else %}
{% if searchValue is not empty %}
{{ include('@MauticCore/Helper/noresults.html.twig', {'tip': 'mautic.point.action.noresults.tip'}) }}
{% else %}
<div class="mt-80 col-md-offset-2 col-lg-offset-3 col-md-8 col-lg-5 height-auto">
{% set childContainer %}
<div class="mb-md">
{% include '@MauticCore/Components/pictogram.html.twig' with {
'pictogram': 'user--insights',
'size': '80'
} %}
</div>
{{ include('@MauticCore/Components/content-item-row.html.twig', {
type: 'default',
eyebrow: 'mautic.point.action.empty.understand',
heading: 'mautic.point.action.empty.what_are_point_actions',
copy: 'mautic.point.action.empty.what_are_point_actions_desc',
}) }}
{{ include('@MauticCore/Components/content-item-row.html.twig', {
type: 'default',
eyebrow: 'mautic.point.action.empty.purpose',
heading: 'mautic.point.action.empty.score_contacts_heading',
copy: 'mautic.point.action.empty.score_contacts_desc',
}) }}
{% endset %}
{{ include('@MauticCore/Components/content-block.html.twig', {
heading: 'mautic.point.action.empty.heading',
subheading: 'mautic.point.action.empty.subheading',
childContainer: childContainer,
}) }}
</div>
{% endif %}
{% endif %}

View File

@@ -0,0 +1,4 @@
{% form_theme form with formThemes %}
{% for child in form %}
{{ form_row(child) }}
{% endfor %}

View File

@@ -0,0 +1,54 @@
{% form_theme form with formThemes %}
{% extends '@MauticCore/Default/content.html.twig' %}
{% block mauticContent 'point' %}
{%- block headerTitle -%}
{%- if entity.id -%}
{{- 'mautic.point.menu.edit'|trans({'%name%': entity.name|trans}) -}}
{%- else -%}
{{- 'mautic.point.menu.new'|trans -}}
{%- endif -%}
{%- endblock -%}
{% block content %}
{{ form_start(form) }}
<!-- start: box layout -->
<div class="box-layout">
<!-- container -->
<div class="col-md-9 height-auto bdr-r">
<div class="row">
<div class="col-md-6">
<div class="pa-md">
{{ form_row(form.name) }}
{{ form_row(form.description) }}
</div>
</div>
<div class="col-md-6">
<div class="pa-md">
{{ form_row(form.delta) }}
{{ form_row(form.type) }}
<div id="pointActionProperties">
{% if form.properties is defined %}
{{ form_row(form.properties) }}
{% endif %}
</div>
{{ form_row(form.group) }}
</div>
</div>
</div>
</div>
<div class="col-md-3 height-auto">
<div class="pr-lg pl-lg pt-md pb-md">
{{ form_row(form.category) }}
{{ form_row(form.projects) }}
{{ form_row(form.isPublished) }}
{{ form_row(form.repeatable) }}
{{ form_row(form.publishUp) }}
{{ form_row(form.publishDown) }}
</div>
</div>
</div>
<!-- end: box layout -->
{{ form_end(form) }}
{% endblock %}

View File

@@ -0,0 +1,67 @@
{% set isIndex = 'index' == tmpl %}
{% set tmpl = 'list' %}
{% extends isIndex ? '@MauticCore/Default/content.html.twig' : '@MauticCore/Default/raw_output.html.twig' %}
{% block mauticContent 'point' %}
{% block headerTitle 'mautic.points.menu.root'|trans %}
{% block content %}
{% if isIndex %}
<div id="page-list-wrapper" class="{% if items|length > 0 or searchValue is not empty %}panel {% endif %}panel-default">
{{ include('@MauticCore/Helper/list_toolbar.html.twig', {
'searchValue': searchValue,
'action': currentRoute,
'page_actions': {
'templateButtons': {
'new': permissions['point:points:create'],
},
'routeBase': 'point',
},
'bulk_actions': {
'routeBase': 'point',
'templateButtons': {
'delete': permissions['point:points:delete'],
},
},
'quickFilters': [
{
'search': 'mautic.core.searchcommand.ispublished',
'label': 'mautic.core.form.active',
'tooltip': 'mautic.core.searchcommand.ispublished.description',
'icon': 'ri-check-line'
},
{
'search': 'mautic.core.searchcommand.isunpublished',
'label': 'mautic.core.form.inactive',
'tooltip': 'mautic.core.searchcommand.isunpublished.description',
'icon': 'ri-close-line'
},
{
'search': 'mautic.core.searchcommand.isuncategorized',
'label': 'mautic.core.form.uncategorized',
'tooltip': 'mautic.core.searchcommand.isuncategorized.description',
'icon': 'ri-folder-unknow-line'
},
{
'search': 'mautic.core.searchcommand.ismine',
'label': 'mautic.core.searchcommand.ismine.label',
'tooltip': 'mautic.core.searchcommand.ismine.description',
'icon': 'ri-user-line'
}
]
}) }}
<div class="page-list">
{% endif %}
{{ include('@MauticPoint/Point/_list.html.twig') }}
{% if isIndex %}
</div>
</div>
{{ include('@MauticCore/Modules/protip.html.twig', {
tip: random(['mautic.protip.points.decay', 'mautic.protip.points.reduce', 'mautic.protip.points.limit'])
}) }}
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,16 @@
{% if showMore is defined %}
<a href="{{ url('mautic_point.group_index', {'search': searchString}) }}" data-toggle="ajax">
<span>{{ 'mautic.core.search.more'|trans({'%count%': remaining}) }}</span>
</a>
{% else %}
<a href="{{ url('mautic_point.group_action', {'objectAction': 'edit', 'objectId': item.id}) }}">
<span class="fw-sb">{{ item.name }}</span>
<span class="ml-4 mr-sm">#{{ item.id }}</span>
{{- include('@MauticCore/Helper/publishstatus_badge.html.twig', {
'entity': item,
'status': 'active',
'simplified': 'true'
}) -}}
</a>
<div class="clearfix"></div>
{% endif %}

View File

@@ -0,0 +1,17 @@
{% if showMore is defined %}
<a href="{{ url('mautic_point_index', {'search': searchString}) }}" data-toggle="ajax">
<span>{{ 'mautic.core.search.more'|trans({'%count%': remaining}) }}</span>
</a>
</div>
{% else %}
<a href="{{ url('mautic_point_action', {'objectAction': 'edit', 'objectId': item.id}) }}">
<span class="fw-sb">{{ item.name }}</span>
<span class="ml-4 mr-sm">#{{ item.id }}</span>
{{- include('@MauticCore/Helper/publishstatus_badge.html.twig', {
'entity': item,
'status': 'active',
'simplified': 'true'
}) -}}
</a>
<div class="clearfix"></div>
{% endif %}

View File

@@ -0,0 +1,16 @@
{% if showMore is defined %}
<a href="{{ url('mautic_pointtrigger_index', {'search': searchString}) }}" data-toggle="ajax">
<span>{{ 'mautic.core.search.more'|trans({'%count%': remaining}) }}</span>
</a>
{% else %}
<a href="{{ url('mautic_pointtrigger_action', {'objectAction': 'edit', 'objectId': item.id}) }}">
<span class="fw-sb">{{ item.name }}</span>
<span class="ml-4 mr-sm">#{{ item.id }}</span>
{{- include('@MauticCore/Helper/publishstatus_badge.html.twig', {
'entity': item,
'status': 'active',
'simplified': 'true'
}) -}}
</a>
<div class="clearfix"></div>
{% endif %}

View File

@@ -0,0 +1,170 @@
{% if items|length > 0 %}
<div class="table-responsive">
<table class="table table-hover pointtrigger-list" id="triggerTable">
<thead>
<tr>
{{ include('@MauticCore/Helper/tableheader.html.twig', {
'checkall': 'true',
'target': '#triggerTable',
}) }}
{{ include('@MauticCore/Helper/tableheader.html.twig', {
'sessionVar': 'point.trigger',
'orderBy': 't.name',
'text': 'mautic.core.name',
'class': 'col-pointtrigger-name',
'default': true,
}) }}
{{ include('@MauticCore/Helper/tableheader.html.twig', {
'sessionVar': 'point.trigger',
'orderBy': 'cat.title',
'text': 'mautic.core.category',
'class': 'col-pointtrigger-category visible-md visible-lg',
}) }}
{{ include('@MauticCore/Helper/tableheader.html.twig', {
'sessionVar': 'point.trigger',
'orderBy': 'pl.name',
'text': 'mautic.point.thead.group',
'class': 'visible-md visible-lg col-point-group',
}) }}
{{ include('@MauticCore/Helper/tableheader.html.twig', {
'sessionVar': 'point.trigger',
'orderBy': 't.points',
'text': 'mautic.point.trigger.thead.points',
'class': 'col-pointtrigger-points',
}) }}
{{ include('@MauticCore/Helper/tableheader.html.twig', {
'sessionVar': 'point.trigger',
'orderBy': 't.id',
'text': 'mautic.core.id',
'class': 'col-pointtrigger-id visible-md visible-lg',
}) }}
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>
{{ include('@MauticCore/Helper/list_actions.html.twig', {
'item': item,
'templateButtons': {
'edit': permissions['point:triggers:edit'],
'clone': permissions['point:triggers:create'],
'delete': permissions['point:triggers:delete'],
},
'routeBase': 'pointtrigger',
'langVar': 'point.trigger',
}) }}
</td>
<td>
<div>
{{ include('@MauticCore/Helper/publishstatus_icon.html.twig', {'item': item, 'model': 'point.trigger'}) }}
{% if permissions['point:triggers:edit'] %}
<a href="{{ path('mautic_pointtrigger_action', {'objectAction': 'edit', 'objectId': item.id}) }}" data-toggle="ajax">
{{ item.name }}
</a>
{% else %}
{{ item.name }}
{% endif %}
{{ customContent('trigger.name', _context) }}
</div>
{{ include('@MauticCore/Helper/description--inline.html.twig', {
'description': item.description
}) }}
</td>
<td class="visible-md visible-lg">
{{ include('@MauticCore/Modules/category--expanded.html.twig', {'category': item.category}) }}
</td>
<td class="visible-md visible-lg">
{{ item.group.name|default('mautic.point.group.form.nogroup'|trans) }}
</td>
<td>{{ item.points }}</td>
<td class="visible-md visible-lg">{{ item.id }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="panel-footer">
{{ include('@MauticCore/Helper/pagination.html.twig', {
'totalItems': items|length,
'page': page,
'limit': limit,
'menuLinkId': 'mautic_pointtrigger_index',
'baseUrl': path('mautic_pointtrigger_index'),
'sessionVar': 'point.trigger',
}) }}
</div>
{% else %}
{% if searchValue is not empty %}
{{ include('@MauticCore/Helper/noresults.html.twig', {'tip': 'mautic.point.trigger.noresults.tip'}) }}
{% else %}
<div class="mt-80 col-md-offset-2 col-lg-offset-3 col-md-8 col-lg-5 height-auto">
{% set childContainer %}
<div class="mb-md">
{% include '@MauticCore/Components/pictogram.html.twig' with {
'pictogram': 'react-to-data',
'size': '80'
} %}
</div>
{{ include('@MauticCore/Components/content-item-row.html.twig', {
type: 'default',
eyebrow: 'mautic.point.trigger.onboarding.eyebrow',
heading: 'mautic.point.trigger.onboarding.heading',
copy: 'mautic.point.trigger.onboarding.copy',
}) }}
{% set triggersUseContainer %}
<div class="mb-md">
{% include '@MauticCore/Components/pictogram.html.twig' with {
'pictogram': 'construct',
'size': '64'
} %}
</div>
<div class="type-body-02">
{% include '@MauticCore/Components/list--styled.html.twig' with {
'type': 'ordered',
'style': 'numbers',
'items': [
'mautic.point.trigger.onboarding.step1',
'mautic.point.trigger.onboarding.step2',
'mautic.point.trigger.onboarding.step3',
]
} %}
</div>
<div class="mt-lg">
{{ include('@MauticCore/Notification/inline_notification.html.twig', {
'title': 'mautic.point.trigger.onboarding.notification.title',
'content': 'mautic.point.trigger.onboarding.notification.content',
'alert_type': 'success',
'dismissible': false,
}) }}
</div>
{% endset %}
{{ include('@MauticCore/Components/content-group.html.twig', {
heading: 'mautic.point.trigger.onboarding.group.heading',
childContainer: triggersUseContainer,
cta: {
'label': 'mautic.point.trigger.onboarding.cta',
'link': path('mautic_pointtrigger_action', {'objectAction': 'new'}),
attributes: {
'data-toggle': 'ajax',
},
}
}) }}
{% endset %}
{{ include('@MauticCore/Components/content-block.html.twig', {
heading: 'mautic.point.trigger.empty.heading',
subheading: 'mautic.point.trigger.empty.subheading',
childContainer: childContainer,
}) }}
</div>
{% endif %}
{% endif %}

View File

@@ -0,0 +1,23 @@
{% extends '@MauticCore/Default/content.html.twig' %}
{% block mauticContent 'pointTrigger' %}
{% block headerTitle entity.name %}
{% block actions %}
{{ include('@MauticCore/Helper/page_actions.html.twig', {
'item': entity,
'templateButtons': {
'edit': permissions['point:triggers:edit'],
'delete': permissions['point:triggers:delete'],
},
'routeBase': 'pointtrigger',
'langVar': 'point.trigger',
}) }}
{% endblock %}
{% block content %}
<div class="scrollable trigger-details">
{# @todo - output trigger details/actions #}
</div>
{% endblock %}

View File

@@ -0,0 +1,114 @@
{% extends '@MauticCore/Default/content.html.twig' %}
{% block mauticContent 'pointTrigger' %}
{%- block headerTitle -%}
{%- if entity.id -%}
{{ 'mautic.point.trigger.header.edit'|trans({'%name%': entity.name|trans}) }}
{%- else -%}
{{ 'mautic.point.trigger.header.new'|trans }}
{%- endif -%}
{%- endblock -%}
{% block modal include('@MauticCore/Helper/modal.html.twig', {
'id': 'triggerEventModal',
'header': 'mautic.point.trigger.form.modalheader'|trans,
'footerButtons': true
}) %}
{% block content %}
{{ form_start(form) }}
<div class="box-layout">
<div class="col-md-9 height-auto">
<div class="row">
<div class="col-xs-12">
<!-- tabs controls -->
<ul class="nav nav-tabs nav-tabs-contained">
<li class="active"><a href="#details-container" role="tab" data-toggle="tab">{{ 'mautic.core.details'|trans }}</a></li>
<li class=""><a href="#events-container" role="tab" data-toggle="tab">{{ 'mautic.point.trigger.tab.events'|trans }}</a></li>
</ul>
<!--/ tabs controls -->
<div class="tab-content pa-md">
<div class="tab-pane fade in active bdr-w-0 height-auto" id="details-container">
<div class="row">
<div class="col-md-6">
<div class="pa-md">
{{ form_row(form.name) }}
{{ form_row(form.description, {'attr': {'class': 'form-control editor'}}) }}
</div>
</div>
<div class="col-md-6">
<div class="pa-md">
{{ form_row(form.points) }}
{{ form_row(form.color) }}
{{ form_row(form.group) }}
{{ form_row(form.triggerExistingLeads) }}
</div>
</div>
</div>
</div>
<div class="tab-pane fade bdr-w-0" id="events-container">
<div id="triggerEvents">
<div class="mb-md">
<div class="dropdown chosen-container">
<button class="btn chosen-single dropdown-toggle" type="button" data-toggle="dropdown">
{{ 'mautic.point.trigger.event.add'|trans }}
<span class="caret"></span>
</button>
<ul class="dropdown-menu" role="menu">
{% for group, event in events %}
<li role="presentation" class="dropdown-header">
{{ group }}
</li>
{% for k, e in event %}
<li id="event_{{ k }}">
<a data-toggle="ajaxmodal"
data-target="#triggerEventModal"
class="list-group-item"
href="{{ path('mautic_pointtriggerevent_action', {'objectAction': 'new', 'type': k, 'tmpl': 'event', 'triggerId': sessionId}) }}">
<div data-toggle="tooltip" title="{{ e.description }}">
<span>{{ e.label }}</span>
</div>
</a>
</li>
{% endfor %}
{% endfor %}
</ul>
</div>
</div>
{% for event in triggerEvents %}
{% set template = event.settings.template|default('@MauticPoint/Event/generic.html.twig') %}
<!-- start: "{{ template }}" -->
{{ include(template, {
'event': event,
'id': event.id,
'deleted': (event.id in deletedEvents),
'sessionId': sessionId,
}, with_context=false) }}
<!--/ start: "{{ template }}" -->
{% else %}
<div class="alert alert-info" id="triggerEventPlaceholder">
<p>{{ 'mautic.point.trigger.addevent'|trans }}</p>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 height-auto bdr-l">
<div class="pr-lg pl-lg pt-md pb-md">
{{ form_row(form.category) }}
{{ form_row(form.projects) }}
{{ form_row(form.isPublished) }}
{{ form_row(form.publishUp) }}
{{ form_row(form.publishDown) }}
</div>
</div>
</div>
{{ form_end(form) }}
{% endblock %}

View File

@@ -0,0 +1,69 @@
{% set isIndex = 'index' == tmpl %}
{% set tmpl = 'list' %}
{% extends isIndex ? '@MauticCore/Default/content.html.twig' : '@MauticCore/Default/raw_output.html.twig' %}
{% block mauticContent 'pointTrigger' %}
{% block headerTitle 'mautic.point.trigger.header.index'|trans %}
{% block content %}
{% if isIndex %}
<div id="page-list-wrapper" class="{% if items|length > 0 or searchValue is not empty %}panel {% endif %}panel-default">
{{ include('@MauticCore/Helper/list_toolbar.html.twig', {
'searchValue': searchValue,
'action': currentRoute,
'page_actions': {
'templateButtons': {
'new': permissions['point:triggers:create'],
},
'routeBase': 'pointtrigger',
'langVar': 'point.trigger',
},
'bulk_actions': {
'langVar': 'point.trigger',
'routeBase': 'pointtrigger',
'templateButtons': {
'delete': permissions['point:triggers:delete'],
},
},
'quickFilters': [
{
'search': 'mautic.core.searchcommand.ispublished',
'label': 'mautic.core.form.active',
'tooltip': 'mautic.core.searchcommand.ispublished.description',
'icon': 'ri-check-line'
},
{
'search': 'mautic.core.searchcommand.isunpublished',
'label': 'mautic.core.form.inactive',
'tooltip': 'mautic.core.searchcommand.isunpublished.description',
'icon': 'ri-close-line'
},
{
'search': 'mautic.core.searchcommand.isuncategorized',
'label': 'mautic.core.form.uncategorized',
'tooltip': 'mautic.core.searchcommand.isuncategorized.description',
'icon': 'ri-folder-unknow-line'
},
{
'search': 'mautic.core.searchcommand.ismine',
'label': 'mautic.core.searchcommand.ismine.label',
'tooltip': 'mautic.core.searchcommand.ismine.description',
'icon': 'ri-user-line'
}
]
}) }}
<div class="page-list">
{% endif %}
{{ include('@MauticPoint/Trigger/_list.html.twig') }}
{% if isIndex %}
</div>
</div>
{{ include('@MauticCore/Modules/protip.html.twig', {
tip: random(['mautic.protip.triggers.behavioral'])
}) }}
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,29 @@
<?php
namespace Mautic\PointBundle\Security\Permissions;
use Mautic\CoreBundle\Security\Permissions\AbstractPermissions;
use Symfony\Component\Form\FormBuilderInterface;
class PointPermissions extends AbstractPermissions
{
public function __construct($params)
{
parent::__construct($params);
$this->addStandardPermissions(['points', 'triggers', 'groups', 'categories']);
}
public function getName(): string
{
return 'point';
}
public function buildForm(FormBuilderInterface &$builder, array $options, array $data): void
{
$this->addStandardFormFields('point', 'categories', $builder, $data);
$this->addStandardFormFields('point', 'points', $builder, $data);
$this->addStandardFormFields('point', 'triggers', $builder, $data);
$this->addStandardFormFields('point', 'groups', $builder, $data);
}
}

View File

@@ -0,0 +1,221 @@
<?php
declare(strict_types=1);
namespace Mautic\PointBundle\Tests\Controller\Api;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Entity\PointsChangeLog;
use Mautic\PointBundle\Entity\Group;
use Symfony\Component\HttpFoundation\Response;
final class PointGroupsApiControllerTest extends MauticMysqlTestCase
{
public function testPointGroupCRUDActions(): void
{
/** @var Translator $translator */
$translator = static::getContainer()->get('translator');
// Create a new point group
$this->client->request('POST', '/api/points/groups/new', [
'name' => 'New Point Group',
'description' => 'Description of the new point group',
]);
$createResponse = $this->client->getResponse();
$this->assertSame(Response::HTTP_CREATED, $createResponse->getStatusCode());
$responseData = json_decode($createResponse->getContent(), true);
$this->assertArrayHasKey('pointGroup', $responseData);
$createdData = $responseData['pointGroup'];
$this->assertArrayHasKey('id', $createdData);
$this->assertEquals('New Point Group', $createdData['name']);
$this->assertEquals('Description of the new point group', $createdData['description']);
// Retrieve all point groups
$this->client->request('GET', '/api/points/groups');
$getAllResponse = $this->client->getResponse();
$this->assertSame(Response::HTTP_OK, $getAllResponse->getStatusCode());
$responseData = json_decode($getAllResponse->getContent(), true);
$this->assertArrayHasKey('pointGroups', $responseData);
$this->assertEquals(1, $responseData['total']);
$allData = $responseData['pointGroups'];
$this->assertIsArray($allData);
$this->assertArrayHasKey(0, $allData); // Ensure the response is array-indexed from 0
$this->assertCount(1, $allData);
// Update the created point group
$updatePayload = [
'name' => 'Updated Point Group Name',
'description' => 'Updated description of the point group',
];
$this->client->request('PATCH', "/api/points/groups/{$createdData['id']}/edit", $updatePayload);
$updateResponse = $this->client->getResponse();
$this->assertSame(Response::HTTP_OK, $updateResponse->getStatusCode());
$responseData = json_decode($updateResponse->getContent(), true);
$this->assertArrayHasKey('pointGroup', $responseData);
$updatedData = $responseData['pointGroup'];
$this->assertEquals('Updated Point Group Name', $updatedData['name']);
$this->assertEquals('Updated description of the point group', $updatedData['description']);
// Delete the created point group
$this->client->request('DELETE', "/api/points/groups/{$createdData['id']}/delete");
$deleteResponse = $this->client->getResponse();
$this->assertSame(Response::HTTP_OK, $deleteResponse->getStatusCode());
$responseData = json_decode($deleteResponse->getContent(), true);
$this->assertArrayHasKey('pointGroup', $responseData);
$deleteData = $responseData['pointGroup'];
$this->assertEquals('Updated Point Group Name', $deleteData['name']);
$this->assertEquals('Updated description of the point group', $deleteData['description']);
// Try to GET the group that should no longer exist
$this->client->request('GET', "/api/points/groups/{$createdData['id']}");
$getResponse = $this->client->getResponse();
$this->assertSame(Response::HTTP_NOT_FOUND, $getResponse->getStatusCode());
$responseData = json_decode($getResponse->getContent(), true);
$this->assertArrayHasKey('errors', $responseData);
$this->assertCount(1, $responseData['errors']);
$this->assertSame(Response::HTTP_NOT_FOUND, $responseData['errors'][0]['code']);
$this->assertSame($translator->trans('mautic.core.error.notfound', [], 'flashes'), $responseData['errors'][0]['message']);
}
public function testContactGroupPointsActions(): void
{
/** @var Translator $translator */
$translator = static::getContainer()->get('translator');
// Arrange
$contact = $this->createContact('test@example.com');
$pointGroupA = $this->createGroup('Group A');
$pointGroupB = $this->createGroup('Group B');
$this->em->flush();
// Act & Assert
$this->adjustPointsAndAssert($contact, $pointGroupA, 'plus', 10, 10);
$this->adjustPointsAndAssert($contact, $pointGroupA, 'minus', 2, 8);
$this->adjustPointsAndAssert($contact, $pointGroupA, 'divide', 2, 4);
$this->adjustPointsAndAssert($contact, $pointGroupA, 'times', 4, 16);
$this->adjustPointsAndAssert($contact, $pointGroupB, 'set', 21, 21);
// Test GET all contact's point groups endpoint
$this->assertContactPointGroups($contact, [
[
'score' => 16,
'group' => [
'id' => $pointGroupA->getId(),
'name' => 'Group A',
'description' => '',
],
],
[
'score' => 21,
'group' => [
'id' => $pointGroupB->getId(),
'name' => 'Group B',
'description' => '',
],
],
]);
// Test GET single contact's point group endpoint
$this->assertContactSinglePointGroup($contact, $pointGroupA, 16);
$this->assertContactSinglePointGroup($contact, $pointGroupB, 21);
$this->assertPointsChangeLogEntries($contact, [
['delta' => 10, 'groupId' => $pointGroupA->getId()],
['delta' => -2, 'groupId' => $pointGroupA->getId()],
['delta' => -4, 'groupId' => $pointGroupA->getId()],
['delta' => 12, 'groupId' => $pointGroupA->getId()],
['delta' => 21, 'groupId' => $pointGroupB->getId()],
]);
// Try to GET the group points that should not exist
$this->client->request('GET', "/api/contacts/{$contact->getId()}/points/groups/0");
$response = $this->client->getResponse();
$this->assertSame(Response::HTTP_NOT_FOUND, $response->getStatusCode());
$responseData = json_decode($response->getContent(), true);
$this->assertArrayHasKey('errors', $responseData);
$this->assertCount(1, $responseData['errors']);
$this->assertSame(Response::HTTP_NOT_FOUND, $responseData['errors'][0]['code']);
$this->assertSame($translator->trans('mautic.lead.event.api.point.group.not.found'), $responseData['errors'][0]['message']);
// Try to GET the group points for a contact that should not exist
$this->client->request('GET', '/api/contacts/0/points/groups/0');
$response = $this->client->getResponse();
$this->assertSame(Response::HTTP_NOT_FOUND, $response->getStatusCode());
$responseData = json_decode($response->getContent(), true);
$this->assertArrayHasKey('errors', $responseData);
$this->assertCount(1, $responseData['errors']);
$this->assertSame(Response::HTTP_NOT_FOUND, $responseData['errors'][0]['code']);
$this->assertSame($translator->trans('mautic.lead.event.api.lead.not.found'), $responseData['errors'][0]['message']);
}
private function adjustPointsAndAssert(Lead $contact, Group $pointGroup, string $operator, int $value, int $expectedScore): void
{
$this->client->request('POST', "/api/contacts/{$contact->getId()}/points/groups/{$pointGroup->getId()}/$operator/{$value}");
$adjustResponse = $this->client->getResponse();
$this->assertSame(Response::HTTP_OK, $adjustResponse->getStatusCode());
$responseData = json_decode($adjustResponse->getContent(), true);
$this->assertSame($expectedScore, $responseData['groupScore']['score']);
}
/**
* @param array<int, array<string, mixed>> $expectedGroups
*/
private function assertContactPointGroups(Lead $contact, array $expectedGroups): void
{
$this->client->request('GET', "/api/contacts/{$contact->getId()}/points/groups");
$response = $this->client->getResponse();
$this->assertSame(Response::HTTP_OK, $response->getStatusCode());
$responseData = json_decode($response->getContent(), true);
$this->assertSame(count($expectedGroups), $responseData['total']);
$this->assertSame($expectedGroups, $responseData['groupScores']);
}
private function assertContactSinglePointGroup(Lead $contact, Group $pointGroup, int $expectedScore): void
{
$this->client->request('GET', "/api/contacts/{$contact->getId()}/points/groups/{$pointGroup->getId()}");
$response = $this->client->getResponse();
$this->assertSame(Response::HTTP_OK, $response->getStatusCode());
$responseData = json_decode($response->getContent(), true);
$this->assertSame($expectedScore, $responseData['groupScore']['score']);
}
/**
* @param array<int, array<string, mixed>> $expectedEntries
*/
private function assertPointsChangeLogEntries(Lead $contact, array $expectedEntries): void
{
$logs = $this->em->getRepository(PointsChangeLog::class)->findBy(['lead' => $contact->getId()]);
$this->assertCount(count($expectedEntries), $logs);
foreach ($expectedEntries as $index => $expectedEntry) {
$this->assertEquals($expectedEntry['delta'], $logs[$index]->getDelta());
$this->assertEquals($expectedEntry['groupId'], $logs[$index]->getGroup()->getId());
}
}
private function createContact(string $email): Lead
{
$contact = new Lead();
$contact->setEmail($email);
$this->em->persist($contact);
return $contact;
}
private function createGroup(
string $name,
): Group {
$group = new Group();
$group->setName($name);
$this->em->persist($group);
return $group;
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Mautic\PointBundle\Tests\Controller;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
final class PointControllerTest extends MauticMysqlTestCase
{
public function testIndexActionWithoutPage(): void
{
$this->client->request(Request::METHOD_GET, '/s/points');
$this->assertEquals(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
}
public function testIndexActionWithPage(): void
{
$this->client->request(Request::METHOD_GET, '/s/points/1');
$this->assertEquals(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
}
public function testNewAction(): void
{
$this->client->request(Request::METHOD_GET, '/s/points/new');
$this->assertEquals(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace Mautic\PointBundle\Tests\Controller;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\PointBundle\Model\TriggerModel;
use Mautic\PointBundle\Tests\Functional\TriggerTrait;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
final class TriggerControllerTest extends MauticMysqlTestCase
{
use TriggerTrait;
public function testIndexActionWithoutPage(): void
{
$this->client->request(Request::METHOD_GET, '/s/points/triggers');
$this->assertEquals(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
}
public function testIndexActionWithPage(): void
{
$this->client->request(Request::METHOD_GET, '/s/points/triggers/1');
$this->assertEquals(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
}
public function testCloneAction(): void
{
/** @var TriggerModel $triggerModel */
$triggerModel = self::getContainer()->get('mautic.point.model.trigger');
$triggerRepo = $triggerModel->getRepository();
$triggerEventRepo = $triggerModel->getEventRepository();
$trigger = $this->createTrigger('Trigger', 5);
$this->createAddTagEvent('tag1', $trigger);
$this->createAddTagEvent('tag2', $trigger);
$this->em->flush();
$this->em->clear();
$this->assertCount(1, $triggerRepo->findAll());
$this->assertCount(2, $triggerEventRepo->findAll());
$crawler = $this->client->request(Request::METHOD_GET, '/s/points/triggers/clone/'.$trigger->getId());
$this->assertTrue($this->client->getResponse()->isOk(), $this->client->getResponse()->getContent());
$form = $crawler->selectButton('Save')->form();
$this->client->submit($form);
$this->assertCount(2, $triggerRepo->findAll());
$this->assertCount(4, $triggerEventRepo->findAll());
}
}

View File

@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace Mautic\PointBundle\Tests\Entity;
use Doctrine\Persistence\Mapping\MappingException;
use Mautic\CoreBundle\Helper\IntHelper;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\PointBundle\Entity\Point;
use Symfony\Component\DomCrawler\Form;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class PointEntityValidationTest extends MauticMysqlTestCase
{
/**
* @throws MappingException
*/
#[\PHPUnit\Framework\Attributes\DataProvider('deltaScenariosProvider')]
public function testDeltaValidationOnCreate(int $delta, string $errorMessage = ''): void
{
$crawler = $this->client->request(Request::METHOD_GET, '/s/points/new');
$buttonCrawler = $crawler->selectButton('Save & Close');
$form = $buttonCrawler->form();
$form['point[name]']->setValue('Add point');
$this->testPointData($form, $delta, $errorMessage);
}
#[\PHPUnit\Framework\Attributes\DataProvider('deltaScenariosProvider')]
public function testDeltaValidationOnCreateViaAPI(int $delta, string $errorMessage = ''): void
{
$this->client->request(
Request::METHOD_POST,
'/api/points/new',
[
'name' => 'Point1',
'delta' => $delta,
'isPublished' => true,
'type' => 'form.submit',
]
);
$response = $this->client->getResponse();
if ($errorMessage) {
self::assertResponseStatusCodeSame(Response::HTTP_BAD_REQUEST);
self::assertStringContainsString('error', $response->getContent());
self::assertStringContainsString($errorMessage, $response->getContent());
} else {
self::assertResponseStatusCodeSame(Response::HTTP_CREATED);
self::assertStringNotContainsString('error', $response->getContent());
}
}
/**
* @throws MappingException
*/
#[\PHPUnit\Framework\Attributes\DataProvider('deltaScenariosProvider')]
public function testDeltaValidationOnUpdate(int $delta, string $errorMessage = ''): void
{
$point = new Point();
$point->setName('Edit point');
$point->setDelta(5);
$point->setType('form.submit');
$point->setIsPublished(true);
$this->em->persist($point);
$this->em->flush();
$pointId = $point->getId();
$crawler = $this->client->request(Request::METHOD_GET, '/s/points/edit/'.$pointId);
$buttonCrawler = $crawler->selectButton('Save & Close');
$form = $buttonCrawler->form();
$form['point[name]']->setValue('Edit point');
$this->testPointData($form, $delta, $errorMessage);
}
/**
* @return iterable<string, array<mixed>>
*/
public static function deltaScenariosProvider(): iterable
{
yield 'within range positive number' => [3000, ''];
yield 'within range negative number' => [-7857, ''];
yield 'within range zero' => [0, ''];
yield 'upper limit' => [IntHelper::MAX_INTEGER_VALUE, ''];
yield 'lower limit' => [IntHelper::MIN_INTEGER_VALUE, ''];
yield 'above upper limit' => [IntHelper::MAX_INTEGER_VALUE + 10, 'This value should be between -2147483648 and 2147483647.'];
yield 'below lower limit' => [IntHelper::MIN_INTEGER_VALUE - 10, 'This value should be between -2147483648 and 2147483647.'];
}
/**
* @throws MappingException
*/
private function testPointData(Form $form, int $delta, string $errorMessage): void
{
$form['point[delta]']->setValue((string) $delta);
$form['point[isPublished]']->setValue('1');
$form['point[type]']->setValue('form.submit');
$this->client->submit($form);
self::assertTrue($this->client->getResponse()->isOk());
$response = $this->client->getResponse()->getContent();
self::assertStringContainsString($errorMessage, (string) $response);
$pointDetail = $this->em->getRepository(Point::class)->findOneBy(['delta' => $delta]);
'' == $errorMessage ? self::assertNotNull($pointDetail) : self::assertNull($pointDetail);
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Mautic\PointBundle\Tests\Functional\Controller;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\PointBundle\Entity\Point;
use Mautic\ProjectBundle\Entity\Project;
use PHPUnit\Framework\Assert;
final class PointControllerTest extends MauticMysqlTestCase
{
public function testPointWithProject(): void
{
$point = new Point();
$point->setName('test');
$point->setType('url.hit');
$this->em->persist($point);
$project = new Project();
$project->setName('Test Project');
$this->em->persist($project);
$this->em->flush();
$this->em->clear();
$crawler = $this->client->request('GET', '/s/points/edit/'.$point->getId());
$form = $crawler->selectButton('Save')->form();
$form['point[projects]']->setValue((string) $project->getId());
$this->client->submit($form);
$this->assertResponseIsSuccessful();
$savedAsset = $this->em->find(Point::class, $point->getId());
Assert::assertSame($project->getId(), $savedAsset->getProjects()->first()->getId());
}
}

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace Mautic\PointBundle\Tests\Functional\Controller;
use Mautic\PointBundle\Entity\Point;
use Mautic\ProjectBundle\Tests\Functional\AbstractProjectSearchTestCase;
final class PointProjectSearchFunctionalTest extends AbstractProjectSearchTestCase
{
#[\PHPUnit\Framework\Attributes\DataProvider('searchDataProvider')]
public function testProjectSearch(string $searchTerm, array $expectedEntities, array $unexpectedEntities): void
{
$projectOne = $this->createProject('Project One');
$projectTwo = $this->createProject('Project Two');
$projectThree = $this->createProject('Project Three');
$pointAlpha = $this->createPoint('Point Alpha');
$pointBeta = $this->createPoint('Point Beta');
$this->createPoint('Point Gamma');
$this->createPoint('Point Delta');
$pointAlpha->addProject($projectOne);
$pointAlpha->addProject($projectTwo);
$pointBeta->addProject($projectTwo);
$pointBeta->addProject($projectThree);
$this->em->flush();
$this->em->clear();
$this->searchAndAssert($searchTerm, $expectedEntities, $unexpectedEntities, ['/api/points', '/s/points']);
}
/**
* @return \Generator<string, array{searchTerm: string, expectedEntities: array<string>, unexpectedEntities: array<string>}>
*/
public static function searchDataProvider(): \Generator
{
yield 'search by one project' => [
'searchTerm' => 'project:"Project Two"',
'expectedEntities' => ['Point Alpha', 'Point Beta'],
'unexpectedEntities' => ['Point Gamma', 'Point Delta'],
];
yield 'search by one project AND point name' => [
'searchTerm' => 'project:"Project Two" AND Beta',
'expectedEntities' => ['Point Beta'],
'unexpectedEntities' => ['Point Alpha', 'Point Gamma', 'Point Delta'],
];
yield 'search by one project OR point name' => [
'searchTerm' => 'project:"Project Two" OR Gamma',
'expectedEntities' => ['Point Alpha', 'Point Beta', 'Point Gamma'],
'unexpectedEntities' => ['Point Delta'],
];
yield 'search by NOT one project' => [
'searchTerm' => '!project:"Project Two"',
'expectedEntities' => ['Point Gamma', 'Point Delta'],
'unexpectedEntities' => ['Point Alpha', 'Point Beta'],
];
yield 'search by two projects with AND' => [
'searchTerm' => 'project:"Project Two" AND project:"Project Three"',
'expectedEntities' => ['Point Beta'],
'unexpectedEntities' => ['Point Alpha', 'Point Gamma', 'Point Delta'],
];
yield 'search by two projects with NOT AND' => [
'searchTerm' => '!project:"Project Two" AND !project:"Project Three"',
'expectedEntities' => ['Point Gamma', 'Point Delta'],
'unexpectedEntities' => ['Point Alpha', 'Point Beta'],
];
yield 'search by two projects with OR' => [
'searchTerm' => 'project:"Project Two" OR project:"Project Three"',
'expectedEntities' => ['Point Alpha', 'Point Beta'],
'unexpectedEntities' => ['Point Gamma', 'Point Delta'],
];
yield 'search by two projects with NOT OR' => [
'searchTerm' => '!project:"Project Two" OR !project:"Project Three"',
'expectedEntities' => ['Point Alpha', 'Point Gamma', 'Point Delta'],
'unexpectedEntities' => ['Point Beta'],
];
}
private function createPoint(string $name): Point
{
$point = new Point();
$point->setName($name);
$point->setType('url.hit');
$this->em->persist($point);
return $point;
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Mautic\PointBundle\Tests\Functional\Controller;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\PointBundle\Entity\Trigger;
use Mautic\ProjectBundle\Entity\Project;
use PHPUnit\Framework\Assert;
final class TriggerControllerTest extends MauticMysqlTestCase
{
public function testPointTriggerWithProject(): void
{
$trigger = new Trigger();
$trigger->setName('test');
$this->em->persist($trigger);
$project = new Project();
$project->setName('Test Project');
$this->em->persist($project);
$this->em->flush();
$this->em->clear();
$crawler = $this->client->request('GET', '/s/points/triggers/edit/'.$trigger->getId());
$form = $crawler->selectButton('Save')->form();
$form['pointtrigger[projects]']->setValue((string) $project->getId());
$this->client->submit($form);
$this->assertResponseIsSuccessful();
$savedAsset = $this->em->find(Trigger::class, $trigger->getId());
Assert::assertSame($project->getId(), $savedAsset->getProjects()->first()->getId());
}
}

View File

@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace Mautic\PointBundle\Tests\Functional\Controller;
use Mautic\PointBundle\Entity\Trigger;
use Mautic\ProjectBundle\Tests\Functional\AbstractProjectSearchTestCase;
final class TriggerProjectSearchFunctionalTest extends AbstractProjectSearchTestCase
{
#[\PHPUnit\Framework\Attributes\DataProvider('searchDataProvider')]
public function testProjectSearch(string $searchTerm, array $expectedEntities, array $unexpectedEntities): void
{
$projectOne = $this->createProject('Project One');
$projectTwo = $this->createProject('Project Two');
$projectThree = $this->createProject('Project Three');
$triggerAlpha = $this->createTrigger('Trigger Alpha');
$triggerBeta = $this->createTrigger('Trigger Beta');
$this->createTrigger('Trigger Gamma');
$this->createTrigger('Trigger Delta');
$triggerAlpha->addProject($projectOne);
$triggerAlpha->addProject($projectTwo);
$triggerBeta->addProject($projectTwo);
$triggerBeta->addProject($projectThree);
$this->em->flush();
$this->em->clear();
$this->searchAndAssert($searchTerm, $expectedEntities, $unexpectedEntities, ['/api/points/triggers', '/s/points/triggers']);
}
/**
* @return \Generator<string, array{searchTerm: string, expectedEntities: array<string>, unexpectedEntities: array<string>}>
*/
public static function searchDataProvider(): \Generator
{
yield 'search by one project' => [
'searchTerm' => 'project:"Project Two"',
'expectedEntities' => ['Trigger Alpha', 'Trigger Beta'],
'unexpectedEntities' => ['Trigger Gamma', 'Trigger Delta'],
];
yield 'search by one project AND trigger name' => [
'searchTerm' => 'project:"Project Two" AND Beta',
'expectedEntities' => ['Trigger Beta'],
'unexpectedEntities' => ['Trigger Alpha', 'Trigger Gamma', 'Trigger Delta'],
];
yield 'search by one project OR trigger name' => [
'searchTerm' => 'project:"Project Two" OR Gamma',
'expectedEntities' => ['Trigger Alpha', 'Trigger Beta', 'Trigger Gamma'],
'unexpectedEntities' => ['Trigger Delta'],
];
yield 'search by NOT one project' => [
'searchTerm' => '!project:"Project Two"',
'expectedEntities' => ['Trigger Gamma', 'Trigger Delta'],
'unexpectedEntities' => ['Trigger Alpha', 'Trigger Beta'],
];
yield 'search by two projects with AND' => [
'searchTerm' => 'project:"Project Two" AND project:"Project Three"',
'expectedEntities' => ['Trigger Beta'],
'unexpectedEntities' => ['Trigger Alpha', 'Trigger Gamma', 'Trigger Delta'],
];
yield 'search by two projects with NOT AND' => [
'searchTerm' => '!project:"Project Two" AND !project:"Project Three"',
'expectedEntities' => ['Trigger Gamma', 'Trigger Delta'],
'unexpectedEntities' => ['Trigger Alpha', 'Trigger Beta'],
];
yield 'search by two projects with OR' => [
'searchTerm' => 'project:"Project Two" OR project:"Project Three"',
'expectedEntities' => ['Trigger Alpha', 'Trigger Beta'],
'unexpectedEntities' => ['Trigger Gamma', 'Trigger Delta'],
];
yield 'search by two projects with NOT OR' => [
'searchTerm' => '!project:"Project Two" OR !project:"Project Three"',
'expectedEntities' => ['Trigger Alpha', 'Trigger Gamma', 'Trigger Delta'],
'unexpectedEntities' => ['Trigger Beta'],
];
}
private function createTrigger(string $name): Trigger
{
$trigger = new Trigger();
$trigger->setName($name);
$this->em->persist($trigger);
return $trigger;
}
}

View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace Mautic\PointBundle\Tests\Functional;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\EmailBundle\Entity\Email;
use Mautic\PointBundle\Entity\Trigger;
use Mautic\PointBundle\Entity\TriggerEvent;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Component\DomCrawler\Form;
use Symfony\Component\HttpFoundation\Request;
class EmailTriggerTest extends MauticMysqlTestCase
{
#[\PHPUnit\Framework\Attributes\PreserveGlobalState(false)]
#[\PHPUnit\Framework\Attributes\RunInSeparateProcess]
public function testButtonsAreEnabledOnEditSendEmailToUserWhenEmailIsSelected(): void
{
$email = new Email();
$email->setName('Some name');
$email->setSubject('Some subject');
$email->setTemplate('Blank');
$email->setCustomHtml('Some html');
$this->em->persist($email);
$this->em->flush();
$trigger = $this->createTrigger();
$triggerEvent = $this->createTriggerEvent($trigger);
$triggerEvent->setProperties(['useremail' => ['email' => $email->getId()]]);
$this->em->flush();
$this->em->detach($trigger);
$this->em->detach($triggerEvent);
[$crawler, $form] = $this->fetchForm($trigger, $triggerEvent);
self::assertEquals($email->getId(), $form->get('pointtriggerevent[properties][useremail][email]')->getValue(), 'Current email should be selected.');
self::assertNull($crawler->selectButton('Preview')->attr('disabled'), 'Preview button should not be disabled.');
self::assertNull($crawler->selectButton('Edit Email')->attr('disabled'), 'Edit Email button should not be disabled.');
self::assertStringContainsString('"origin":"#pointtriggerevent_properties_useremail_email"', $crawler->selectButton('Preview')->attr('onclick'), 'The origin value should be correct.');
}
#[\PHPUnit\Framework\Attributes\PreserveGlobalState(false)]
#[\PHPUnit\Framework\Attributes\RunInSeparateProcess]
public function testButtonsAreDisabledWhenEmailIsNotSelected(): void
{
$trigger = $this->createTrigger();
$triggerEvent = $this->createTriggerEvent($trigger);
$this->em->flush();
$this->em->detach($trigger);
$this->em->detach($triggerEvent);
[$crawler, $form] = $this->fetchForm($trigger, $triggerEvent);
self::assertEmpty($form->get('pointtriggerevent[properties][useremail][email]')->getValue(), 'No email should be selected.');
self::assertNotNull($crawler->selectButton('Preview')->attr('disabled'), 'Preview button should be disabled.');
self::assertNotNull($crawler->selectButton('Edit Email')->attr('disabled'), 'Edit Email button should be disabled.');
}
/**
* @return array{Crawler,Form}
*/
private function fetchForm(Trigger $trigger, TriggerEvent $triggerEvent): array
{
$this->client->request(Request::METHOD_GET, '/s/points/triggers/edit/'.$trigger->getId());
self::assertTrue($this->client->getResponse()->isSuccessful());
$uri = sprintf('/s/points/triggers/events/edit/%s?triggerId=%s', $triggerEvent->getId(), $trigger->getId());
$this->client->xmlHttpRequest(Request::METHOD_GET, $uri);
self::assertTrue($this->client->getResponse()->isSuccessful());
$responseData = json_decode($this->client->getResponse()->getContent(), true);
$crawler = new Crawler($responseData['newContent'], $this->client->getInternalRequest()->getUri());
$form = $crawler->filterXPath('//form[@name="pointtriggerevent"]')->form();
return [$crawler, $form];
}
private function createTrigger(): Trigger
{
$trigger = new Trigger();
$trigger->setName('Email Trigger');
$this->em->persist($trigger);
return $trigger;
}
private function createTriggerEvent(Trigger $trigger): TriggerEvent
{
$triggerEvent = new TriggerEvent();
$triggerEvent->setTrigger($trigger);
$triggerEvent->setName('Send email to user');
$triggerEvent->setType('email.send_to_user');
$triggerEvent->setProperties([]);
$this->em->persist($triggerEvent);
return $triggerEvent;
}
}

View File

@@ -0,0 +1,93 @@
<?php
namespace Mautic\PointBundle\Tests\Functional;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\PointBundle\Entity\Group;
use Mautic\PointBundle\Entity\GroupContactScore;
use Mautic\PointBundle\Entity\GroupContactScoreRepository;
class GroupScoreRepositoryFunctionalTest extends MauticMysqlTestCase
{
protected $useCleanupRollback = false;
protected GroupContactScoreRepository $repository;
public function setUp(): void
{
parent::setUp();
$this->repository = $this->em->getRepository(GroupContactScore::class);
}
public function testCompareScore(): void
{
$contact = $this->createContact('score@example.com');
$group = $this->createGroup('A');
$this->addGroupContactScore($contact, $group, 7);
$this->em->flush();
$this->assertTrue($this->repository->compareScore($contact->getId(), $group->getId(), 7, 'eq'));
$this->assertFalse($this->repository->compareScore($contact->getId(), $group->getId(), 8, 'eq'));
$this->assertTrue($this->repository->compareScore($contact->getId(), $group->getId(), 8, 'neq'));
$this->assertFalse($this->repository->compareScore($contact->getId(), $group->getId(), 7, 'neq'));
$this->assertTrue($this->repository->compareScore($contact->getId(), $group->getId(), 6, 'gt'));
$this->assertFalse($this->repository->compareScore($contact->getId(), $group->getId(), 7, 'gt'));
$this->assertTrue($this->repository->compareScore($contact->getId(), $group->getId(), 8, 'lt'));
$this->assertFalse($this->repository->compareScore($contact->getId(), $group->getId(), 7, 'lt'));
$this->assertTrue($this->repository->compareScore($contact->getId(), $group->getId(), 7, 'gte'));
$this->assertTrue($this->repository->compareScore($contact->getId(), $group->getId(), 6, 'gte'));
$this->assertFalse($this->repository->compareScore($contact->getId(), $group->getId(), 8, 'gte'));
$this->assertTrue($this->repository->compareScore($contact->getId(), $group->getId(), 7, 'lte'));
$this->assertTrue($this->repository->compareScore($contact->getId(), $group->getId(), 8, 'lte'));
$this->assertFalse($this->repository->compareScore($contact->getId(), $group->getId(), 6, 'lte'));
}
public function testCompareScoreContactWithoutScoreInGroup(): void
{
$contactWithoutScore = $this->createContact('no-score@example.com');
$group = $this->createGroup('A');
$this->em->flush();
$this->assertFalse($this->repository->compareScore($contactWithoutScore->getId(), $group->getId(), 0, 'eq'));
$this->assertFalse($this->repository->compareScore($contactWithoutScore->getId(), $group->getId(), 1, 'eq'));
}
private function createContact(
string $email,
): Lead {
$lead = new Lead();
$lead->setEmail($email);
$this->em->persist($lead);
return $lead;
}
private function createGroup(
string $name,
): Group {
$group = new Group();
$group->setName($name);
$this->em->persist($group);
return $group;
}
private function addGroupContactScore(
Lead $lead,
Group $group,
int $score,
): void {
$groupContactScore = new GroupContactScore();
$groupContactScore->setContact($lead);
$groupContactScore->setGroup($group);
$groupContactScore->setScore($score);
$lead->addGroupScore($groupContactScore);
}
}

View File

@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace Mautic\PointBundle\Tests\Functional;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\EmailBundle\Entity\Email;
use Mautic\EmailBundle\Entity\Stat;
use Mautic\EmailBundle\Entity\StatRepository;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Model\LeadModel;
use Mautic\PointBundle\Entity\Group;
use Mautic\PointBundle\Entity\Point;
class PointActionFunctionalTest extends MauticMysqlTestCase
{
public function testPointActionReadEmail(): void
{
$this->logoutUser();
/** @var LeadModel $leadModel */
$leadModel = static::getContainer()->get('mautic.lead.model.lead');
$lead = $this->createLead('john@doe.email');
$email = $this->createEmail();
$trackingHash = 'tracking_hash_123';
$this->createEmailStat($lead, $email, $trackingHash);
$pointAction = $this->createReadEmailAction(5);
$this->client->request('GET', '/email/'.$trackingHash.'.gif');
$lead = $leadModel->getEntity($lead->getId());
$this->assertEquals($pointAction->getDelta(), $lead->getPoints());
}
public function testPointActionWithGroupReadEmail(): void
{
$this->logoutUser();
/** @var LeadModel $leadModel */
$leadModel = static::getContainer()->get('mautic.lead.model.lead');
$lead = $this->createLead('john@doe.email');
$email = $this->createEmail();
$group = $this->createGroup('Group A');
$trackingHash = 'tracking_hash_123';
$this->createEmailStat($lead, $email, $trackingHash);
$pointAction = $this->createReadEmailAction(5, $group);
$this->client->request('GET', '/email/'.$trackingHash.'.gif');
$this->em->clear(Lead::class);
$lead = $leadModel->getEntity($lead->getId());
$groupScore = $lead->getGroupScores()->first();
$this->assertEquals($pointAction->getDelta(), $groupScore->getScore());
// group point action shouldn't update main contact points
$this->assertEquals(0, $lead->getPoints());
}
public function testPointActionEarlyReturnWhenNoPointsAvailable(): void
{
/** @var LeadModel $leadModel */
$leadModel = static::getContainer()->get('mautic.lead.model.lead');
$lead = $this->createLead('jane@doe.email');
$email = $this->createEmail();
$trackingHash = 'tracking_hash_no_points_456';
$this->createEmailStat($lead, $email, $trackingHash);
// Note: No point actions created for email.open type
$initialPoints = $lead->getPoints();
$this->client->request('GET', '/email/'.$trackingHash.'.gif');
$lead = $leadModel->getEntity($lead->getId());
// Points should remain unchanged as no point actions are available
$this->assertEquals($initialPoints, $lead->getPoints());
$this->assertEquals(0, $lead->getPoints());
}
private function createReadEmailAction(int $delta, ?Group $group = null): Point
{
$pointAction = new Point();
$pointAction->setName('Read email action');
$pointAction->setDelta($delta);
$pointAction->setType('email.open');
if ($group) {
$pointAction->setGroup($group);
}
$this->em->persist($pointAction);
$this->em->flush();
return $pointAction;
}
private function createEmailStat(
Lead $lead,
Email $email,
string $trackingHash,
): Stat {
/** @var StatRepository $statRepository */
$statRepository = static::getContainer()->get('mautic.email.repository.stat');
$stat = new Stat();
$stat->setTrackingHash($trackingHash);
$stat->setEmailAddress($lead->getEmail());
$stat->setLead($lead);
$stat->setDateSent(new \DateTime());
$stat->setEmail($email);
$statRepository->saveEntity($stat);
return $stat;
}
private function createLead(
string $email,
): Lead {
$lead = new Lead();
$lead->setEmail($email);
$this->em->persist($lead);
return $lead;
}
private function createEmail(): Email
{
$email = new Email();
$email->setName('Test email');
$email->setSubject('Test email subject');
$email->setEmailType('template');
$email->setCustomHtml('<h1>Email content</h1><br>{signature}');
$email->setIsPublished(true);
$email->setFromAddress('from@api.test');
$email->setFromName('API Test');
$this->em->persist($email);
return $email;
}
private function createGroup(
string $name,
): Group {
$group = new Group();
$group->setName($name);
$this->em->persist($group);
return $group;
}
}

View File

@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
namespace Mautic\PointBundle\Tests\Functional;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Entity\Tag;
use Mautic\LeadBundle\Model\LeadModel;
use Mautic\PointBundle\Entity\Group;
use Mautic\PointBundle\Model\PointGroupModel;
use Mautic\PointBundle\Model\TriggerModel;
class PointTriggerFunctionalTest extends MauticMysqlTestCase
{
use TriggerTrait;
public function testPointsTriggerWithTagAction(): void
{
/** @var LeadModel $model */
$model = self::getContainer()->get('mautic.lead.model.lead');
$trigger = $this->createTrigger('Trigger', 5);
$this->createAddTagEvent('tag5', $trigger);
$trigger = $this->createTrigger('Trigger', 6);
$this->createAddTagEvent('tag6', $trigger);
$lead = new Lead();
$data = ['email' => 'pointtest@example.com', 'points' => 5];
$model->setFieldValues($lead, $data, false, true, true);
$model->saveEntity($lead);
$this->em->clear(Lead::class);
$lead = $model->getEntity($lead->getId());
$this->assertFalse($lead->getTags()->isEmpty());
$this->assertTrue($this->leadHasTag($lead, 'tag5'));
$this->assertFalse($this->leadHasTag($lead, 'tag6'));
}
public function testGroupPointsTriggerWithTagAction(): void
{
/** @var LeadModel $model */
$model = self::getContainer()->get('mautic.lead.model.lead');
/** @var PointGroupModel $pointGroupModel */
$pointGroupModel = self::getContainer()->get('mautic.point.model.group');
$groupA = $this->createGroup('Group A');
$groupB = $this->createGroup('Group B');
$triggerA = $this->createTrigger('Group A Trigger (should trigger)', 5, $groupA);
$this->createAddTagEvent('tagA', $triggerA);
$triggerB = $this->createTrigger('Group B Trigger (should not trigger)', 5, $groupB);
$this->createAddTagEvent('tagB', $triggerB);
$lead = new Lead();
$data = ['email' => 'pointtest@example.com', 'points' => 0];
$model->setFieldValues($lead, $data, false, true, true);
$model->saveEntity($lead);
$this->em->clear(Lead::class);
$lead = $model->getEntity($lead->getId());
$pointGroupModel->adjustPoints($lead, $groupA, 5);
$lead = $model->getEntity($lead->getId());
$this->assertFalse($this->leadHasTag($lead, 'tagB'));
$this->assertTrue($this->leadHasTag($lead, 'tagA'));
}
public function testTriggerForExistingContacts(): void
{
/** @var LeadModel $leadModel */
$leadModel = self::getContainer()->get('mautic.lead.model.lead');
/** @var TriggerModel $triggerModel */
$triggerModel = self::getContainer()->get('mautic.point.model.trigger');
$lead = new Lead();
$data = ['email' => 'pointtest@example.com', 'points' => 5];
$leadModel->setFieldValues($lead, $data, false, true, true);
$leadModel->saveEntity($lead);
$this->em->clear(Lead::class);
$triggerA = $this->createTrigger('Group A Trigger (should trigger)', 5, null, true);
$triggerEventA = $this->createAddTagEvent('tagA', $triggerA);
$triggerA->addTriggerEvent(0, $triggerEventA);
$triggerModel->saveEntity($triggerA);
$triggerB = $this->createTrigger('Group B Trigger (should not trigger)', 6, null, true);
$triggerEventB = $this->createAddTagEvent('tagB', $triggerB);
$triggerB->addTriggerEvent(0, $triggerEventB);
$triggerModel->saveEntity($triggerB);
$lead = $leadModel->getEntity($lead->getId());
$this->assertFalse($this->leadHasTag($lead, 'tagB'));
$this->assertTrue($this->leadHasTag($lead, 'tagA'));
}
public function testTriggerWithGroupForExistingContacts(): void
{
/** @var LeadModel $leadModel */
$leadModel = self::getContainer()->get('mautic.lead.model.lead');
/** @var TriggerModel $triggerModel */
$triggerModel = self::getContainer()->get('mautic.point.model.trigger');
/** @var PointGroupModel $pointGroupModel */
$pointGroupModel = self::getContainer()->get('mautic.point.model.group');
$groupA = $this->createGroup('Group A');
$groupB = $this->createGroup('Group B');
$lead = new Lead();
$data = ['email' => 'pointtest@example.com'];
$leadModel->setFieldValues($lead, $data, false, true, true);
$leadModel->saveEntity($lead);
$pointGroupModel->adjustPoints($lead, $groupA, 5);
$triggerA = $this->createTrigger('Group A Trigger (should trigger)', 5, $groupA, true);
$triggerEventA = $this->createAddTagEvent('tagA', $triggerA);
$triggerA->addTriggerEvent(0, $triggerEventA);
$triggerModel->saveEntity($triggerA);
$triggerB = $this->createTrigger('Group B Trigger (should not trigger)', 5, $groupB, true);
$triggerEventB = $this->createAddTagEvent('tagB', $triggerB);
$triggerB->addTriggerEvent(0, $triggerEventB);
$triggerModel->saveEntity($triggerB);
$lead = $leadModel->getEntity($lead->getId());
$triggerC = $this->createTrigger('General Trigger (should not trigger)', 5, $groupB, true);
$triggerEventB = $this->createAddTagEvent('tagC', $triggerC);
$triggerC->addTriggerEvent(0, $triggerEventB);
$triggerModel->saveEntity($triggerC);
$lead = $leadModel->getEntity($lead->getId());
$this->assertFalse($this->leadHasTag($lead, 'tagC'));
$this->assertFalse($this->leadHasTag($lead, 'tagB'));
$this->assertTrue($this->leadHasTag($lead, 'tagA'));
}
private function createGroup(
string $name,
): Group {
$group = new Group();
$group->setName($name);
$this->em->persist($group);
return $group;
}
private function leadHasTag(
Lead $lead,
string $tagName,
): bool {
/** @var Tag $tag */
foreach ($lead->getTags() as $tag) {
if ($tag->getTag() === $tagName) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,234 @@
<?php
declare(strict_types=1);
namespace Mautic\PointBundle\Tests\Functional;
use Mautic\CoreBundle\Entity\IpAddress;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\PointBundle\Entity\Group;
use Mautic\PointBundle\Entity\GroupContactScore;
use Mautic\ReportBundle\Entity\Report;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Component\HttpFoundation\Request;
class ReportSubscriberFunctionalTest extends MauticMysqlTestCase
{
protected function setUp(): void
{
$this->useCleanupRollback = false;
parent::setUp();
}
public function testContactPointLogReportWithGroup(): void
{
$this->createTestContactWithGroupPoints();
$report = new Report();
$report->setName('Contact point log');
$report->setSource('lead.pointlog');
$report->setColumns(['lp.type', 'lp.event_name', 'l.email', 'lp.delta', 'pl.name']);
$report->setTableOrder([[
'column' => 'lp.delta',
'direction' => 'DESC',
]]);
$this->em->persist($report);
$this->em->flush();
$this->em->clear();
// -- test report table in mautic panel
$crawler = $this->client->request(Request::METHOD_GET, "/s/reports/view/{$report->getId()}");
$crawlerReportTable = $crawler->filterXPath('//table[@id="reportTable"]')->first();
// convert html table to php array
$crawlerReportTable = $this->domTableToArray($crawlerReportTable);
$this->assertSame([
// no., event_type, event_name, email, points_delta, group_name
['1', 'test type', 'Adjust points', 'test2@example.com', '15', 'Group A'],
['2', 'test type', 'Adjust points', 'test3@example.com', '10', 'Group A'],
['3', 'test type', 'Adjust points', 'test1@example.com', '5', 'Group A'],
['4', 'test type', 'Adjust points', 'test3@example.com', '2', 'Group B'],
['5', 'test type', 'Adjust points', 'test2@example.com', '1', 'Group B'],
], array_slice($crawlerReportTable, 1, 5));
// -- test API report data
$this->client->request(Request::METHOD_GET, "/api/reports/{$report->getId()}");
$clientResponse = $this->client->getResponse();
$result = json_decode($clientResponse->getContent(), true);
$this->assertSame([
[
'type' => 'test type',
'event_name' => 'Adjust points',
'email' => 'test2@example.com',
'delta' => '15',
'group_name' => 'Group A',
],
[
'type' => 'test type',
'event_name' => 'Adjust points',
'email' => 'test3@example.com',
'delta' => '10',
'group_name' => 'Group A',
],
[
'type' => 'test type',
'event_name' => 'Adjust points',
'email' => 'test1@example.com',
'delta' => '5',
'group_name' => 'Group A',
],
[
'type' => 'test type',
'event_name' => 'Adjust points',
'email' => 'test3@example.com',
'delta' => '2',
'group_name' => 'Group B',
],
[
'type' => 'test type',
'event_name' => 'Adjust points',
'email' => 'test2@example.com',
'delta' => '1',
'group_name' => 'Group B',
],
], $result['data']);
}
public function testGroupScoreReport(): void
{
$this->createTestContactWithGroupPoints();
$report = new Report();
$report->setName('Group score report');
$report->setSource('group.score');
$report->setColumns(['pl.name', 'ls.score', 'l.email']);
$report->setTableOrder([[
'column' => 'ls.score',
'direction' => 'DESC',
]]);
$this->em->persist($report);
$this->em->flush();
$this->em->clear();
// -- test report table in mautic panel
$crawler = $this->client->request(Request::METHOD_GET, "/s/reports/view/{$report->getId()}");
$crawlerReportTable = $crawler->filterXPath('//table[@id="reportTable"]')->first();
// convert html table to php array
$crawlerReportTable = $this->domTableToArray($crawlerReportTable);
$this->assertSame([
// no., group_name, group_score, email
['1', 'Group A', '15', 'test2@example.com'],
['2', 'Group A', '10', 'test3@example.com'],
['3', 'Group A', '5', 'test1@example.com'],
['4', 'Group B', '2', 'test3@example.com'],
['5', 'Group B', '1', 'test2@example.com'],
], array_slice($crawlerReportTable, 1, 5));
// -- test API report data
$this->client->request(Request::METHOD_GET, "/api/reports/{$report->getId()}");
$clientResponse = $this->client->getResponse();
$result = json_decode($clientResponse->getContent(), true);
$this->assertSame([
[
'group_name' => 'Group A',
'group_score' => '15',
'email' => 'test2@example.com',
],
[
'group_name' => 'Group A',
'group_score' => '10',
'email' => 'test3@example.com',
],
[
'group_name' => 'Group A',
'group_score' => '5',
'email' => 'test1@example.com',
],
[
'group_name' => 'Group B',
'group_score' => '2',
'email' => 'test3@example.com',
],
[
'group_name' => 'Group B',
'group_score' => '1',
'email' => 'test2@example.com',
],
], $result['data']);
}
private function createTestContactWithGroupPoints(): void
{
$contactModel = static::getContainer()->get('mautic.lead.model.lead');
$groupA = $this->createGroup('Group A');
$groupB = $this->createGroup('Group B');
$this->em->flush();
$contacts = [
$this->createContact('test1@example.com'),
$this->createContact('test2@example.com'),
$this->createContact('test3@example.com'),
];
$contactModel->saveEntities($contacts);
$this->adjustContactPoints($contacts[0], 5, $groupA);
$this->adjustContactPoints($contacts[1], 15, $groupA);
$this->adjustContactPoints($contacts[2], 10, $groupA);
$this->adjustContactPoints($contacts[2], 2, $groupB);
$this->adjustContactPoints($contacts[1], 1, $groupB);
$contactModel->saveEntities($contacts);
}
private function createContact(string $email): Lead
{
$contact = new Lead();
$contact->setEmail($email);
$this->em->persist($contact);
return $contact;
}
private function adjustContactPoints(Lead $contact, int $points, Group $group): void
{
$ipAddress = new IpAddress();
$ipAddress->setIpAddress('127.0.0.1');
$contact->addPointsChangeLogEntry(
'test type',
'Adjust points',
'test action',
$points,
$ipAddress,
$group
);
$contact->adjustPoints($points);
$groupContactScore = new GroupContactScore();
$groupContactScore->setContact($contact);
$groupContactScore->setGroup($group);
$groupContactScore->setScore($points);
$contact->addGroupScore($groupContactScore);
}
private function createGroup(
string $name,
): Group {
$group = new Group();
$group->setName($name);
$this->em->persist($group);
return $group;
}
/**
* @return array<int,array<int,mixed>>
*/
private function domTableToArray(Crawler $crawler): array
{
return $crawler->filter('tr')->each(fn ($tr) => $tr->filter('td')->each(fn ($td) => trim($td->text())));
}
}

View File

@@ -0,0 +1,110 @@
<?php
namespace Mautic\PointBundle\Tests\Functional;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Entity\LeadList;
use Mautic\PointBundle\Entity\Group;
use Mautic\PointBundle\Entity\GroupContactScore;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Tester\ApplicationTester;
class SegmentFilterFunctionalTest extends MauticMysqlTestCase
{
protected $useCleanupRollback = false;
public function testGroupPointSegmentFilter(): void
{
$application = new Application(self::$kernel);
$application->setAutoExit(false);
$applicationTester = new ApplicationTester($application);
$contactA = $this->createContact('contact-a@example.com');
$contactB = $this->createContact('contact-b@example.com');
$contactC = $this->createContact('contact-c@example.com');
$groupA = $this->createGroup('Group A');
$this->em->flush();
$this->addGroupContactScore($contactA, $groupA, 1);
$this->addGroupContactScore($contactB, $groupA, 0);
$this->em->persist($contactA);
$this->em->persist($contactB);
$this->em->flush();
$segmentA = new LeadList();
$segmentA->setName('Group A points >= 1');
$segmentA->setPublicName('Group A points >= 1');
$segmentA->setAlias('group-a-points-gte1');
$segmentA->setIsPublished(true);
$segmentA->setFilters([
[
'glue' => 'and',
'field' => 'group_points_'.$groupA->getId(),
'object' => 'groups',
'type' => 'number',
'operator' => 'gte',
'properties' => [
'filter' => '1',
],
],
]);
$this->em->persist($segmentA);
$this->em->flush();
// Force Doctrine to re-fetch the entities otherwise the campaign won't know about any events.
$this->em->clear();
// Execute segment update command.
$exitCode = $applicationTester->run(
[
'command' => 'mautic:segments:update',
'-i' => $segmentA->getId(),
]
);
$this->assertSame(0, $exitCode, $applicationTester->getDisplay());
$this->client->request('GET', '/api/contacts?search=segment:group-a-points-gte1');
$clientResponse = $this->client->getResponse();
$this->assertTrue($this->client->getResponse()->isOk());
$response = json_decode($clientResponse->getContent(), true);
$this->assertEquals(1, (int) $response['total']);
$contactIds = array_column($response['contacts'], 'id');
$this->assertContains((int) $contactA->getId(), $contactIds);
$this->assertNotContains((int) $contactB->getId(), $contactIds);
$this->assertNotContains((int) $contactC->getId(), $contactIds);
}
private function createContact(
string $email,
): Lead {
$lead = new Lead();
$lead->setEmail($email);
$this->em->persist($lead);
return $lead;
}
private function createGroup(
string $name,
): Group {
$group = new Group();
$group->setName($name);
$this->em->persist($group);
return $group;
}
private function addGroupContactScore(
Lead $lead,
Group $group,
int $score,
): void {
$groupContactScore = new GroupContactScore();
$groupContactScore->setContact($lead);
$groupContactScore->setGroup($group);
$groupContactScore->setScore($score);
$lead->addGroupScore($groupContactScore);
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace Mautic\PointBundle\Tests\Functional;
use Mautic\PointBundle\Entity\Group;
use Mautic\PointBundle\Entity\Trigger;
use Mautic\PointBundle\Entity\TriggerEvent;
trait TriggerTrait
{
private function createTrigger(
string $name,
int $points = 0,
?Group $group = null,
bool $triggerExistingLeads = false,
): Trigger {
$trigger = new Trigger();
$trigger->setName($name);
$trigger->setPoints($points);
if (isset($group)) {
$trigger->setGroup($group);
}
if ($triggerExistingLeads) {
$trigger->setTriggerExistingLeads($triggerExistingLeads);
}
$this->em->persist($trigger);
return $trigger;
}
private function createAddTagEvent(
string $tag,
Trigger $trigger,
): TriggerEvent {
$triggerEvent = new TriggerEvent();
$triggerEvent->setTrigger($trigger);
$triggerEvent->setName('Add '.$tag);
$triggerEvent->setType('lead.changetags');
$triggerEvent->setProperties([
'add_tags' => [$tag],
'remove_tags' => [],
]);
$this->em->persist($triggerEvent);
return $triggerEvent;
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Mautic\PointBundle\Tests\Unit\Helper;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\PointBundle\Helper\EventHelper;
use PHPUnit\Framework\TestCase;
class EventHelperTest extends TestCase
{
public function testEngagePointAction(): void
{
$lead = new Lead();
// Define the action array
$action = ['id' => 1, 'type' => 'helloworld.action.custom_action', 'name' => 'My custom point action', 'properties' => [], 'points' => 50];
$points = EventHelper::engagePointAction($lead, $action);
$this->assertEquals(50, $points);
$points = EventHelper::engagePointAction($lead, $action);
$this->assertEquals(0, $points, 'Second call should return 0 points because the action is already initiated for this lead and type and session.');
}
}

View File

@@ -0,0 +1,191 @@
<?php
declare(strict_types=1);
namespace Mautic\PointBundle\Tests\Unit\Model;
use Doctrine\ORM\EntityManager;
use Mautic\CoreBundle\Entity\IpAddress;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\IpLookupHelper;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Model\LeadModel;
use Mautic\LeadBundle\Tracker\ContactTracker;
use Mautic\PageBundle\Entity\Hit;
use Mautic\PageBundle\Helper\PointActionHelper;
use Mautic\PointBundle\Entity\Point;
use Mautic\PointBundle\Entity\PointRepository;
use Mautic\PointBundle\Event\PointActionEvent;
use Mautic\PointBundle\Event\PointBuilderEvent;
use Mautic\PointBundle\Model\PointGroupModel;
use Mautic\PointBundle\Model\PointModel;
use Mautic\PointBundle\PointEvents;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Contracts\EventDispatcher\Event;
class PointModelTest extends TestCase
{
private RequestStack&MockObject $requestStack;
private IpLookupHelper&MockObject $ipLookupHelper;
private LeadModel&MockObject $leadModel;
private ContactTracker&MockObject $contactTracker;
private EntityManager&MockObject $em;
private CorePermissions&MockObject $security;
private EventDispatcherInterface&MockObject $dispatcher;
private UrlGeneratorInterface&MockObject $router;
private Translator&MockObject $translator;
private UserHelper&MockObject $userHelper;
private LoggerInterface&MockObject $mauticLogger;
private CoreParametersHelper&MockObject $coreParametersHelper;
private PointGroupModel&MockObject $pointGroupModel;
private PointModel $pointModel;
protected function setUp(): void
{
$this->requestStack = $this->createMock(RequestStack::class);
$this->ipLookupHelper = $this->createMock(IpLookupHelper::class);
$this->leadModel = $this->createMock(LeadModel::class);
$this->contactTracker = $this->createMock(ContactTracker::class);
$this->em = $this->createMock(EntityManager::class);
$this->security = $this->createMock(CorePermissions::class);
$this->dispatcher = $this->createMock(EventDispatcherInterface::class);
$this->router = $this->createMock(RouterInterface::class);
$this->translator = $this->createMock(Translator::class);
$this->userHelper = $this->createMock(UserHelper::class);
$this->mauticLogger = $this->createMock(LoggerInterface::class);
$this->coreParametersHelper = $this->createMock(CoreParametersHelper::class);
$this->pointGroupModel = $this->createMock(PointGroupModel::class);
$this->pointModel = new PointModel(
$this->requestStack,
$this->ipLookupHelper,
$this->leadModel,
$this->contactTracker,
$this->em,
$this->security,
$this->dispatcher,
$this->router,
$this->translator,
$this->userHelper,
$this->mauticLogger,
$this->coreParametersHelper,
$this->pointGroupModel,
);
}
public function testTriggerUrlHitWithCallbackObject(): void
{
$type = 'url.hit';
$pointId = 98783;
$pointName = 'Point name';
$pointProperties = ['property' => 'value'];
$pointDelta = 7;
$pointGroup = null;
$ip = $this->createMock(IpAddress::class);
$this->security->method('isAnonymous')->willReturn(true);
$this->ipLookupHelper->method('getIpAddress')->willReturn($ip);
$lead = $this->createMock(Lead::class);
$lead->expects(self::once())
->method('adjustPoints')
->with($pointDelta);
$lead->expects(self::once())
->method('addPointsChangeLogEntry')
->with(
'url',
$pointId.': '.$pointName,
'hit',
$pointDelta,
$ip,
$pointGroup
);
$eventDetails = $this->createMock(Hit::class);
$repository = $this->createMock(PointRepository::class);
$this->em->expects(self::once())
->method('getRepository')
->with(Point::class)
->willReturn($repository);
$pointActionHelper = $this->createMock(PointActionHelper::class);
$pointActionHelper->expects(self::once())
->method('validateUrlHit')
->with(
$eventDetails,
[
'id' => $pointId,
'type' => $type,
'name' => $pointName,
'properties' => $pointProperties,
'points' => $pointDelta,
]
)
->willReturn(true);
$point = $this->createMock(Point::class);
$point->method('getRepeatable')->willReturn(true);
$point->method('getType')->willReturn($type);
$point->method('getId')->willReturn($pointId);
$point->method('getName')->willReturn($pointName);
$point->method('getProperties')->willReturn($pointProperties);
$point->method('getDelta')->willReturn($pointDelta);
$point->method('getGroup')->willReturn($pointGroup);
$repository->expects(self::once())
->method('getPublishedByType')
->with($type)
->willReturn([$point]);
$repository->expects(self::once())
->method('getCompletedLeadActions')
->willReturn([]);
$repository->expects(self::never())
->method('saveEntities');
$repository->expects(self::never())
->method('detachEntities');
$this->dispatcher->expects(self::exactly(2))
->method('dispatch')
->willReturnCallback(function (Event $event, string $eventName) use ($pointActionHelper, $type, $lead, $point): Event {
if (PointEvents::POINT_ON_BUILD === $eventName) {
self::assertInstanceOf(PointBuilderEvent::class, $event);
self::assertEquals(new PointBuilderEvent($this->translator), $event);
$event->addAction(
$type,
[
'callback' => [
$pointActionHelper,
'validateUrlHit',
],
'group' => 'group',
'label' => 'label',
],
);
return $event;
}
if (PointEvents::POINT_ON_ACTION === $eventName) {
$pointActionEvent = new PointActionEvent($point, $lead);
self::assertEquals($pointActionEvent, $event);
return $pointActionEvent;
}
self::fail('Unknown event called: '.$eventName);
});
$this->leadModel->expects(self::once())
->method('saveEntity')
->with($lead);
$this->pointModel->triggerAction($type, $eventDetails, null, $lead);
}
}

View File

@@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
namespace Mautic\PointBundle\Tests\Unit\Model;
use Doctrine\ORM\EntityManager;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\IpLookupHelper;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\EmailBundle\EmailEvents;
use Mautic\EmailBundle\Form\Type\EmailToUserType;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Model\LeadModel;
use Mautic\LeadBundle\Tracker\ContactTracker;
use Mautic\PointBundle\Entity\TriggerEvent;
use Mautic\PointBundle\Entity\TriggerEventRepository;
use Mautic\PointBundle\Model\TriggerEventModel;
use Mautic\PointBundle\Model\TriggerModel;
use Mautic\PointBundle\PointEvents;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
final class TriggerModelTest extends \PHPUnit\Framework\TestCase
{
/**
* @var IpLookupHelper&MockObject
*/
private MockObject $ipLookupHelper;
/**
* @var LeadModel&MockObject
*/
private MockObject $leadModel;
/**
* @var TriggerEventModel&MockObject
*/
private MockObject $triggerEventModel;
/**
* @var EventDispatcherInterface&MockObject
*/
private MockObject $dispatcher;
/**
* @var TranslatorInterface&MockObject
*/
private MockObject $translator;
/**
* @var EntityManager&MockObject
*/
private MockObject $entityManager;
/**
* @var TriggerEventRepository&MockObject
*/
private MockObject $triggerEventRepository;
private TriggerModel $triggerModel;
/**
* @var ContactTracker&MockObject
*/
private MockObject $contactTracker;
public function setUp(): void
{
parent::setUp();
$this->ipLookupHelper = $this->createMock(IpLookupHelper::class);
$this->leadModel = $this->createMock(LeadModel::class);
$this->triggerEventModel = $this->createMock(TriggerEventModel::class);
$this->contactTracker = $this->createMock(ContactTracker::class);
$this->dispatcher = $this->createMock(EventDispatcherInterface::class);
$this->translator = $this->createMock(Translator::class);
$this->entityManager = $this->createMock(EntityManager::class);
$this->triggerEventRepository = $this->createMock(TriggerEventRepository::class);
$this->triggerModel = new TriggerModel(
$this->ipLookupHelper,
$this->leadModel,
$this->triggerEventModel,
$this->contactTracker,
$this->entityManager,
$this->createMock(CorePermissions::class),
$this->dispatcher,
$this->createMock(UrlGeneratorInterface::class),
$this->translator,
$this->createMock(UserHelper::class),
$this->createMock(LoggerInterface::class),
$this->createMock(CoreParametersHelper::class)
);
// reset private property cachedEvents in TriggerModel instance
$reflectionClass = new \ReflectionClass(TriggerModel::class);
$property = $reflectionClass->getProperty('cachedEvents');
$property->setAccessible(true);
$property->setValue($this->triggerModel, []);
}
public function testTriggerEvent(): void
{
$triggerEvent = new TriggerEvent();
$contact = new Lead();
$dispatchCalls = new \ArrayObject();
$triggerEvent->setType('email.send_to_user');
$this->entityManager->expects($this->once())
->method('getRepository')
->willReturn($this->triggerEventRepository);
$this->triggerEventRepository->expects($this->once())
->method('find')
->willReturn($triggerEvent);
$this->dispatcher->expects($this->exactly(2))
->method('dispatch')
->willReturnCallback(function ($event, $eventName) use ($dispatchCalls, $contact, $triggerEvent) {
$dispatchCalls->append($eventName);
if (PointEvents::TRIGGER_ON_BUILD === $eventName) {
// Emulate a subscriber:
$event->addEvent(
'email.send_to_user',
[
'group' => 'mautic.email.point.trigger',
'label' => 'mautic.email.point.trigger.send_email_to_user',
'formType' => EmailToUserType::class,
'formTypeOptions' => ['update_select' => 'pointtriggerevent_properties_useremail_email'],
'formTheme' => 'MauticEmailBundle:FormTheme\EmailSendList',
'eventName' => EmailEvents::ON_SENT_EMAIL_TO_USER,
]
);
return $event;
} elseif (EmailEvents::ON_SENT_EMAIL_TO_USER === $eventName) {
Assert::assertSame($contact, $event->getLead());
Assert::assertSame($triggerEvent, $event->getTriggerEvent());
return $event;
} else {
$this->fail("Unexpected event name: $eventName");
}
});
$this->triggerModel->triggerEvent($triggerEvent->convertToArray(), $contact, true);
// Assert both expected events were dispatched
Assert::assertContains(PointEvents::TRIGGER_ON_BUILD, $dispatchCalls);
Assert::assertContains(EmailEvents::ON_SENT_EMAIL_TO_USER, $dispatchCalls);
Assert::assertCount(2, $dispatchCalls);
}
}

View File

@@ -0,0 +1,4 @@
mautic.point.error.notfound="No point action with an id of %id% was found!"
mautic.point.notice.batch_deleted="%count% point actions have been deleted!"
mautic.point.trigger.error.notfound="No trigger with an id of %id% was found!"
mautic.point.trigger.notice.batch_deleted="%count% point triggers have been deleted!"

View File

@@ -0,0 +1,91 @@
mautic.point.point="Point"
mautic.point.action.delta="Change points (+/-)"
mautic.point.action.delta.help="Set the number of points to be added or deducted for this action. If it is a positive number, it will be added to the contact's points. If negative, it will be deducted."
mautic.point.action.noresults.tip="Use point actions to adjust a contact's point score based on defined events. For example, give a contact 10 points if he/she opens a campaign email."
mautic.point.actions.header.index="Point Actions"
mautic.point.event.gained="Point gained"
mautic.point.event.manual_change="Manual change"
mautic.point.form.score_not_set="Score not set"
mautic.point.form.no_point_groups="There are no point groups available."
mautic.point.form.addaction="Use the list to the right to add an action."
mautic.point.form.confirmbatchdelete="Delete the selected point actions?"
mautic.point.form.confirmdelete="Delete the point action, %name%?"
mautic.point.form.repeat="Is repeatable"
mautic.protip.points.decay="Implement a lead scoring decay model to accurately capture intent."
mautic.protip.points.reduce="Reduce scores systematically over time if contacts become inactive."
mautic.protip.points.limit="Use a limit to prevent scores from getting too high or low."
mautic.protip.triggers.behavioral="Use behavioral triggers to send timely, relevant emails based on user actions."
mautic.point.form.type="Action taken by contact"
mautic.point.form.repeat.help="Enable to score leads multiple times for this action; disable for one-time scoring."
mautic.point.form.repeat.yes="Allow multiple scores"
mautic.point.form.repeat.no="One-time scoring only"
mautic.point.menu.edit="Edit Point Action"
mautic.point.menu.index="Manage Actions"
mautic.point.menu.new="New Point Action"
mautic.point.permissions.header="Point Permissions"
mautic.point.permissions.points="Point Actions - User has access to"
mautic.point.permissions.triggers="Triggers - User has access to"
mautic.point.permissions.groups="Groups - User has access to"
mautic.point.thead.action="Action"
mautic.point.thead.delta="Points +/-"
mautic.point.thead.group="Group"
mautic.point.trigger.addevent="Select an event from the 'Add an event' list."
mautic.point.trigger.event.add="Add an event"
mautic.point.trigger.form.color="Contact color"
mautic.point.trigger.form.color_descr="Contacts with at least the number of points above will be designated this color."
mautic.point.trigger.form.confirmbatchdelete="Delete the selected triggers?"
mautic.point.trigger.form.confirmdelete="Delete the trigger, %name%?"
mautic.point.trigger.form.existingleads="Trigger for existing applicable contacts upon saving (if published)?"
mautic.point.trigger.form.modalheader="Trigger Action Details"
mautic.point.trigger.form.points="Minimum number of points"
mautic.point.trigger.form.points_descr="Minimum number of points required in order to trigger associated actions."
mautic.point.trigger.header.edit="Edit Trigger"
mautic.point.trigger.header.index="Point Triggers"
mautic.point.trigger.header.new="New Trigger"
mautic.point.trigger.menu.edit="Edit Trigger Event"
mautic.point.trigger.menu.index="Manage Triggers"
mautic.point.trigger.noresults.tip="Create a trigger to take some action with the contact once a certain point level is reached. You can adjust a contact's campaign, push to a CRM, send an email, etc. Define a color to easily see where your contacts are at a glance when viewing contact lists!"
mautic.point.trigger.tab.events="Events"
mautic.point.trigger.thead.points="Point Total"
mautic.point.trigger.thead.pointstrigger="Trigger at Points"
mautic.point.group.menu.index="Manage Groups"
mautic.point.group.menu.new="New Group"
mautic.point.group.menu.edit="Edit Group"
mautic.point.group.header.index="Point Groups"
mautic.point.group.form.group="Point group"
mautic.point.group.form.group_descr="Choose the point group to which this applies. If no groups are selected, it will apply to the main contact points."
mautic.point.group.form.nogroup="None"
mautic.point.group.form.confirmdelete="Delete the point group, %name%?"
mautic.points.menu.root="Points"
mautic.point.action.empty.heading="Unlock automated lead scoring with Actions"
mautic.point.action.empty.subheading="Stop guessing which leads are engaged! Focus your follow-up efforts purely on closing deals."
mautic.point.action.empty.understand="Understand"
mautic.point.action.empty.what_are_point_actions="What are point actions?"
mautic.point.action.empty.what_are_point_actions_desc="These are rules you create to automatically adjust contact scores. You control the point value (positive or negative) for each action, tailoring the system to your business."
mautic.point.action.empty.purpose="Purpose"
mautic.point.action.empty.score_contacts_heading="Score contacts based on specific behaviors"
mautic.point.action.empty.score_contacts_desc="Assign points automatically when contacts download an asset, visit your pricing page, open a crucial email, or submit a key form."
mautic.point.trigger.empty.heading="Trigger automations when engagement peaks"
mautic.point.trigger.empty.subheading="Automatically respond when a contact's engagement level, measured by points, hits a key threshold."
mautic.point.dashboard.widgets="Point Widgets"
mautic.widget.points.in.time="Points in time"
mautic.point.changes="Point changes"
mautic.point.group.report.table="Group score"
mautic.point.report.group_id="Group ID"
mautic.point.report.group_name="Group name"
mautic.point.report.group_score="Group score"
mautic.point.trigger.onboarding.eyebrow="Understand"
mautic.point.trigger.onboarding.heading="How point triggers work?"
mautic.point.trigger.onboarding.copy="Triggers execute predefined actions like changing a segment, adding a tag, or pushing to your CRM the moment a contact reaches a specific score."
mautic.point.trigger.onboarding.step1="Create a new trigger and set the minimum number of points a contact must reach."
mautic.point.trigger.onboarding.step2="Select one or more events to execute when the point threshold is met."
mautic.point.trigger.onboarding.step3="Publish the trigger. You can choose whether to apply it immediately to existing contacts who already meet the criteria."
mautic.point.trigger.onboarding.notification.title="Triggers act differently of campaign-based events"
mautic.point.trigger.onboarding.notification.content="They activate whenever the score threshold is met, ensuring immediate action based on engagement level."
mautic.point.trigger.onboarding.group.heading="Setting up score-triggered events"
mautic.point.trigger.onboarding.cta="Create new trigger"
mautic.point.trigger.onboarding.usecase.eyebrow="Use case"
mautic.point.trigger.onboarding.usecase.heading="Applying practically"
mautic.point.group.onboarding.heading="Gain deeper insight with granular lead scoring"
mautic.point.group.onboarding.subheading="Go beyond a single engagement score. Groups allow you to categorize points based on different types of interactions or interests."
mautic.point.group.onboarding.copy="Track separate scores for engagement with specific product lines, content topics, or funnel stages. This gives you a much richer understanding of what your contacts are interested in, not just how engaged they are overall."

View File

@@ -0,0 +1,3 @@
mautic.point.form.actions.notempty="At least one action is required."
mautic.point.type.notblank="A point action must be selected."
mautic.point.delta.notblank="A point change (delta) must be selected."