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,29 @@
Mautic.messagesOnLoad = function(container) {
mQuery(container + ' .sortable-panel-wrapper .modal').each(function() {
// Move modals outside of the wrapper
mQuery(this).closest('.panel').append(mQuery(this));
});
};
Mautic.toggleChannelFormDisplay = function (el, channel) {
Mautic.toggleTabPublished(el);
if (mQuery(el).val() === "1" && mQuery(el).prop('checked')) {
mQuery(el).closest('.tab-pane').find('.message_channel_properties_' + channel).removeClass('hide')
} else {
mQuery(el).closest('.tab-pane').find('.message_channel_properties_' + channel).addClass('hide');
}
};
Mautic.cancelQueuedMessageEvent = function (channelId) {
Mautic.ajaxActionRequest('channel:cancelQueuedMessageEvent',
{
channelId: channelId
}, function (response) {
if (response.success) {
mQuery('#queued-message-'+channelId).addClass('disabled');
mQuery('#queued-status-'+channelId).html(Mautic.translate('mautic.message.queue.status.cancelled'));
}
}, false
);
};

View File

@@ -0,0 +1,100 @@
<?php
namespace Mautic\ChannelBundle;
final class ChannelEvents
{
/**
* The mautic.add_channel event registers communication channels.
*
* The event listener receives a Mautic\ChannelBundle\Event\ChannelEvent instance.
*
* @var string
*/
public const ADD_CHANNEL = 'mautic.add_channel';
/**
* The mautic.channel_broadcast event is dispatched by the mautic:send:broadcast command to process communication to pending contacts.
*
* The event listener receives a Mautic\ChannelBundle\Event\ChannelBroadcastEvent instance.
*
* @var string
*/
public const CHANNEL_BROADCAST = 'mautic.channel_broadcast';
/**
* The mautic.message_queued event is dispatched to save a message to the queue.
*
* The event listener receives a Mautic\ChannelBundle\Event\MessageQueueEvent instance.
*
* @var string
*/
public const MESSAGE_QUEUED = 'mautic.message_queued';
/**
* The mautic.process_message_queue event is dispatched to be processed by a listener.
*
* The event listener receives a Mautic\ChannelBundle\Event\MessageQueueProcessEvent instance.
*
* @var string
*/
public const PROCESS_MESSAGE_QUEUE = 'mautic.process_message_queue';
/**
* The mautic.process_message_queue_batch event is dispatched to process a batch of messages by channel and channel ID.
*
* The event listener receives a Mautic\ChannelBundle\Event\MessageQueueBatchProcessEvent instance.
*
* @var string
*/
public const PROCESS_MESSAGE_QUEUE_BATCH = 'mautic.process_message_queue_batch';
/**
* The mautic.channel.on_campaign_batch_action event is dispatched when the campaign action triggers.
*
* The event listener receives a Mautic\CampaignBundle\Event\PendingEvent
*
* @var string
*/
public const ON_CAMPAIGN_BATCH_ACTION = 'mautic.channel.on_campaign_batch_action';
/**
* The mautic.message_pre_save event is dispatched right before a form is persisted.
*
* The event listener receives a
* Mautic\ChannelEvent\Event\MessageEvent instance.
*
* @var string
*/
public const MESSAGE_PRE_SAVE = 'mautic.message_pre_save';
/**
* The mautic.message_post_save event is dispatched right after a form is persisted.
*
* The event listener receives a
* Mautic\ChannelEvent\Event\MessageEvent instance.
*
* @var string
*/
public const MESSAGE_POST_SAVE = 'mautic.message_post_save';
/**
* The mautic.message_pre_delete event is dispatched before a form is deleted.
*
* The event listener receives a
* Mautic\ChannelEvent\Event\MessageEvent instance.
*
* @var string
*/
public const MESSAGE_PRE_DELETE = 'mautic.message_pre_delete';
/**
* The mautic.message_post_delete event is dispatched after a form is deleted.
*
* The event listener receives a
* Mautic\ChannelEvent\Event\MessageEvent instance.
*
* @var string
*/
public const MESSAGE_POST_DELETE = 'mautic.message_post_delete';
}

View File

@@ -0,0 +1,101 @@
<?php
namespace Mautic\ChannelBundle\Command;
use Mautic\ChannelBundle\Model\MessageQueueModel;
use Mautic\CoreBundle\Command\ModeratedCommand;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\PathsHelper;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
#[AsCommand(
name: 'mautic:messages:send',
description: 'Process sending of messages queue.',
aliases: [
'mautic:campaigns:messagequeue',
'mautic:campaigns:messages',
]
)]
class ProcessMarketingMessagesQueueCommand extends ModeratedCommand
{
public function __construct(
private TranslatorInterface $translator,
private MessageQueueModel $messageQueueModel,
PathsHelper $pathsHelper,
CoreParametersHelper $coreParametersHelper,
) {
parent::__construct($pathsHelper, $coreParametersHelper);
}
protected function configure()
{
$this
->addOption(
'--channel',
'-c',
InputOption::VALUE_OPTIONAL,
'Channel to use for sending messages i.e. email, sms.',
null
)
->addOption('--channel-id', '-i', InputOption::VALUE_REQUIRED, 'The ID of the message i.e. email ID, sms ID.')
->addOption('--message-id', '-m', InputOption::VALUE_REQUIRED, 'ID of a specific queued message')
->addOption(
'--limit',
'-l',
InputOption::VALUE_OPTIONAL,
'Maximum number of messages to process',
null
)
->addOption(
'--batch',
'-b',
InputOption::VALUE_OPTIONAL,
'Number of messages to process in each batch',
50
);
parent::configure();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$processed = 0;
$channel = $input->getOption('channel');
$channelId = $input->getOption('channel-id');
$messageId = $input->getOption('message-id');
$limit = $input->getOption('limit') ? (int) $input->getOption('limit') : null;
$batch = (int) $input->getOption('batch');
$key = $channel.$channelId.$messageId;
if (!$this->checkRunStatus($input, $output, $key)) {
return \Symfony\Component\Console\Command\Command::SUCCESS;
}
$output->writeln('<info>'.$this->translator->trans('mautic.campaign.command.process.messages').'</info>');
if ($messageId) {
if ($message = $this->messageQueueModel->getEntity($messageId)) {
$processed = intval($this->messageQueueModel->processMessageQueue($message));
}
} else {
// Process messages in batches until the limit is reached or no more messages
do {
$remainingBatch = $limit ? min($batch, $limit - $processed) : $batch;
$batchProcessed = $this->messageQueueModel->sendMessages($channel, $channelId, $remainingBatch);
$processed += $batchProcessed;
// Continue only if messages were processed and limit not reached
} while ($batchProcessed > 0 && (!$limit || $processed < $limit));
}
$output->writeln('<comment>'.$this->translator->trans('mautic.campaign.command.messages.sent', ['%events%' => $processed]).'</comment>'."\n");
$this->completeRun();
return \Symfony\Component\Console\Command\Command::SUCCESS;
}
}

View File

@@ -0,0 +1,186 @@
<?php
namespace Mautic\ChannelBundle\Command;
use Mautic\ChannelBundle\ChannelEvents;
use Mautic\ChannelBundle\Event\ChannelBroadcastEvent;
use Mautic\CoreBundle\Command\ModeratedCommand;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\PathsHelper;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* CLI Command to send a scheduled broadcast.
*/
#[AsCommand(
name: 'mautic:broadcasts:send',
description: 'Process contacts pending to receive a channel broadcast.'
)]
class SendChannelBroadcastCommand extends ModeratedCommand
{
public function __construct(
private TranslatorInterface $translator,
private EventDispatcherInterface $dispatcher,
PathsHelper $pathsHelper,
CoreParametersHelper $coreParametersHelper,
) {
parent::__construct($pathsHelper, $coreParametersHelper);
}
protected function configure()
{
$this
->setHelp(
<<<'EOT'
The <info>%command.name%</info> command is send a channel broadcast to pending contacts.
<info>php %command.full_name% --channel=email --id=3</info>
EOT
)
->setDefinition(
[
new InputOption(
'channel', 'c', InputOption::VALUE_OPTIONAL,
'A specific channel to process broadcasts for pending contacts.'
),
new InputOption(
'id', 'i', InputOption::VALUE_OPTIONAL,
'The ID for a specifc channel to process broadcasts for pending contacts.'
),
new InputOption(
'min-contact-id', null, InputOption::VALUE_OPTIONAL,
'Min contact ID to filter recipients.'
),
new InputOption(
'max-contact-id', null, InputOption::VALUE_OPTIONAL,
'Max contact ID to filter recipients.'
),
new InputOption(
'limit', 'l', InputOption::VALUE_OPTIONAL,
'Limit how many contacts to load from database to process.'
),
new InputOption(
'batch', 'b', InputOption::VALUE_OPTIONAL,
'Limit how many messages to send at once.'
),
]
)->addOption(
'--thread-id',
null,
InputOption::VALUE_OPTIONAL,
'The number of this current process if running multiple in parallel.'
)
->addOption(
'--max-threads',
null,
InputOption::VALUE_OPTIONAL,
'The maximum number of processes you intend to run in parallel.'
);
parent::configure();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$channel = $input->getOption('channel');
$channelId = $input->getOption('id');
$limit = $input->getOption('limit');
$batch = $input->getOption('batch');
$minContactId = $input->getOption('min-contact-id');
$maxContactId = $input->getOption('max-contact-id');
$threadId = $input->getOption('thread-id');
$maxThreads = $input->getOption('max-threads');
$key = sprintf('%s-%s-%s-%s', $channel, $channelId, $threadId, $maxThreads);
if (is_numeric($limit)) {
$limit = (int) $limit;
}
if (is_numeric($batch)) {
$batch = (int) $batch;
}
if (is_numeric($minContactId)) {
$minContactId = (int) $minContactId;
}
if (is_numeric($maxContactId)) {
$maxContactId = (int) $maxContactId;
}
if (is_numeric($threadId)) {
$threadId = (int) $threadId;
}
if (is_numeric($maxThreads)) {
$maxThreads = (int) $maxThreads;
}
if ($threadId && $maxThreads) {
if ((int) $threadId > (int) $maxThreads) {
$output->writeln('--thread-id cannot be larger than --max-thread');
return \Symfony\Component\Console\Command\Command::FAILURE;
}
}
if (!$this->checkRunStatus($input, $output, $key)) {
return \Symfony\Component\Console\Command\Command::SUCCESS;
}
$event = new ChannelBroadcastEvent($channel, $channelId, $output);
if ($limit) {
$event->setLimit((int) $limit);
}
if ($batch) {
$event->setBatch((int) $batch);
}
if ($minContactId) {
$event->setMinContactIdFilter((int) $minContactId);
}
if ($maxContactId) {
$event->setMaxContactIdFilter((int) $maxContactId);
}
if ($threadId) {
$event->setThreadId((int) $threadId);
}
if ($maxThreads) {
$event->setMaxThreads((int) $maxThreads);
}
$this->dispatcher->dispatch($event, ChannelEvents::CHANNEL_BROADCAST);
$results = $event->getResults();
$rows = [];
foreach ($results as $channel => $counts) {
$rows[] = [$channel, $counts['success'], $counts['failed']];
}
// Put a blank line after anything the event spits out
$output->writeln('');
$output->writeln('');
$table = new Table($output);
$table
->setHeaders([$this->translator->trans('mautic.core.channel'), $this->translator->trans('mautic.core.channel.broadcast_success_count'), $this->translator->trans('mautic.core.channel.broadcast_failed_count')])
->setRows($rows);
$table->render();
$this->completeRun();
return \Symfony\Component\Console\Command\Command::SUCCESS;
}
}

View File

@@ -0,0 +1,77 @@
<?php
return [
'routes' => [
'main' => [
'mautic_message_index' => [
'path' => '/messages/{page}',
'controller' => 'Mautic\ChannelBundle\Controller\MessageController::indexAction',
],
'mautic_message_contacts' => [
'path' => '/messages/contacts/{objectId}/{channel}/{page}',
'controller' => 'Mautic\ChannelBundle\Controller\MessageController::contactsAction',
],
'mautic_message_action' => [
'path' => '/messages/{objectAction}/{objectId}',
'controller' => 'Mautic\ChannelBundle\Controller\MessageController::executeAction',
],
'mautic_channel_batch_contact_set' => [
'path' => '/channels/batch/contact/set',
'controller' => 'Mautic\ChannelBundle\Controller\BatchContactController::setAction',
],
'mautic_channel_batch_contact_view' => [
'path' => '/channels/batch/contact/view',
'controller' => 'Mautic\ChannelBundle\Controller\BatchContactController::indexAction',
],
],
'api' => [
'mautic_api_messagetandard' => [
'standard_entity' => true,
'name' => 'messages',
'path' => '/messages',
'controller' => Mautic\ChannelBundle\Controller\Api\MessageApiController::class,
],
],
'public' => [
],
],
'menu' => [
'main' => [
'mautic.channel.messages' => [
'route' => 'mautic_message_index',
'access' => ['channel:messages:viewown', 'channel:messages:viewother'],
'parent' => 'mautic.core.channels',
'priority' => 110,
],
],
'admin' => [
],
'profile' => [
],
'extra' => [
],
],
'categories' => [
'messages' => [
'class' => Mautic\ChannelBundle\Entity\Message::class,
],
],
'services' => [
'helpers' => [
'mautic.channel.helper.channel_list' => [
'class' => Mautic\ChannelBundle\Helper\ChannelListHelper::class,
'arguments' => [
'event_dispatcher',
'translator',
],
'alias' => 'channel',
],
],
],
'parameters' => [
],
];

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 = [
'PreferenceBuilder/ChannelPreferences.php',
'PreferenceBuilder/PreferenceBuilder.php',
];
$services->load('Mautic\\ChannelBundle\\', '../')
->exclude('../{'.implode(',', array_merge(MauticCoreExtension::DEFAULT_EXCLUDES, $excludes)).'}');
$services->load('Mautic\\ChannelBundle\\Entity\\', '../Entity/*Repository.php')
->tag(Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\ServiceRepositoryCompilerPass::REPOSITORY_SERVICE_TAG);
$services->alias('mautic.channel.model.message', Mautic\ChannelBundle\Model\MessageModel::class);
$services->alias('mautic.channel.model.queue', Mautic\ChannelBundle\Model\MessageQueueModel::class);
$services->alias('mautic.channel.model.channel.action', Mautic\ChannelBundle\Model\ChannelActionModel::class);
$services->alias('mautic.channel.model.frequency.action', Mautic\ChannelBundle\Model\FrequencyActionModel::class);
$services->alias('mautic.channel.repository.message_queue', Mautic\ChannelBundle\Entity\MessageQueueRepository::class);
};

View File

@@ -0,0 +1,30 @@
<?php
namespace Mautic\ChannelBundle\Controller;
use Mautic\ChannelBundle\Model\MessageQueueModel;
use Mautic\CoreBundle\Controller\AjaxController as CommonAjaxController;
use Mautic\CoreBundle\Controller\AjaxLookupControllerTrait;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
class AjaxController extends CommonAjaxController
{
use AjaxLookupControllerTrait;
public function cancelQueuedMessageEventAction(Request $request): JsonResponse
{
$dataArray = ['success' => 0];
$messageQueueId = (int) $request->request->get('channelId');
$queueModel = $this->getModel('channel.queue');
\assert($queueModel instanceof MessageQueueModel);
$queuedMessage = $queueModel->getEntity($messageQueueId);
if ($queuedMessage) {
$queuedMessage->setStatus('cancelled');
$queueModel->saveEntity($queuedMessage);
$dataArray = ['success' => 1];
}
return $this->sendJsonResponse($dataArray);
}
}

View File

@@ -0,0 +1,93 @@
<?php
namespace Mautic\ChannelBundle\Controller\Api;
use Doctrine\Persistence\ManagerRegistry;
use Mautic\ApiBundle\Controller\CommonApiController;
use Mautic\ApiBundle\Helper\EntityResultHelper;
use Mautic\ChannelBundle\ChannelEvents;
use Mautic\ChannelBundle\Entity\Message;
use Mautic\ChannelBundle\Event\ChannelEvent;
use Mautic\ChannelBundle\Model\MessageModel;
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 Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Routing\RouterInterface;
/**
* @extends CommonApiController<Message>
*/
class MessageApiController extends CommonApiController
{
/**
* @var MessageModel|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,
) {
$messageModel = $modelFactory->getModel('channel.message');
\assert($messageModel instanceof MessageModel);
$this->model = $messageModel;
$this->entityClass = Message::class;
$this->entityNameOne = 'message';
$this->entityNameMulti = 'messages';
$this->serializerGroups = ['messageDetails', 'messageChannelList', 'categoryList', 'publishDetails'];
parent::__construct($security, $translator, $entityResultHelper, $router, $formFactory, $appVersion, $requestStack, $doctrine, $modelFactory, $dispatcher, $coreParametersHelper);
}
protected function prepareParametersFromRequest(FormInterface $form, array &$params, ?object $entity = null, array $masks = [], array $fields = []): void
{
parent::prepareParametersFromRequest($form, $params, $entity, $masks);
if ('PATCH' === $this->requestStack->getCurrentRequest()->getMethod() && !isset($params['channels'])) {
return;
} elseif (!isset($params['channels'])) {
$params['channels'] = [];
}
$channels = $this->model->getChannels();
foreach ($channels as $channelType => $channel) {
if (!isset($params['channels'][$channelType])) {
$params['channels'][$channelType] = ['isEnabled' => 0];
} else {
$params['channels'][$channelType]['isEnabled'] = (int) $params['channels'][$channelType]['isEnabled'];
}
$params['channels'][$channelType]['channel'] = $channelType;
}
}
/**
* Load and set channel names to the response.
*/
protected function preSerializeEntity(object $entity, string $action = 'view'): void
{
$event = $this->dispatcher->dispatch(new ChannelEvent(), ChannelEvents::ADD_CHANNEL);
foreach ($entity->getChannels() as $channel) {
$repository = $event->getRepositoryName($channel->getChannel());
$nameColumn = $event->getNameColumn($channel->getChannel());
$name = $this->model->getChannelName($channel->getChannelId(), $repository, $nameColumn);
$channel->setChannelName($name);
}
}
}

View File

@@ -0,0 +1,93 @@
<?php
namespace Mautic\ChannelBundle\Controller;
use Doctrine\Persistence\ManagerRegistry;
use Mautic\ChannelBundle\Model\ChannelActionModel;
use Mautic\ChannelBundle\Model\FrequencyActionModel;
use Mautic\CoreBundle\Controller\AbstractFormController;
use Mautic\CoreBundle\Factory\ModelFactory;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Service\FlashBag;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\LeadBundle\Form\Type\ContactChannelsType;
use Mautic\LeadBundle\Model\LeadModel;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
class BatchContactController extends AbstractFormController
{
public function __construct(
private ChannelActionModel $channelActionModel,
private FrequencyActionModel $frequencyActionModel,
private LeadModel $contactModel,
ManagerRegistry $doctrine,
ModelFactory $modelFactory,
UserHelper $userHelper,
CoreParametersHelper $coreParametersHelper,
EventDispatcherInterface $dispatcher,
Translator $translator,
FlashBag $flashBag,
RequestStack $requestStack,
CorePermissions $security,
) {
parent::__construct($doctrine, $modelFactory, $userHelper, $coreParametersHelper, $dispatcher, $translator, $flashBag, $requestStack, $security);
}
/**
* Execute the batch action.
*/
public function setAction(Request $request): JsonResponse
{
$params = $request->get('contact_channels', []);
$ids = empty($params['ids']) ? [] : json_decode($params['ids']);
if ($ids && is_array($ids)) {
$subscribedChannels = $params['subscribed_channels'] ?? [];
$preferredChannel = $params['preferred_channel'] ?? null;
$this->channelActionModel->update($ids, $subscribedChannels);
$this->frequencyActionModel->update($ids, $params, $preferredChannel);
$this->addFlashMessage('mautic.lead.batch_leads_affected', [
'%count%' => count($ids),
]);
} else {
$this->addFlashMessage('mautic.core.error.ids.missing');
}
return new JsonResponse([
'closeModal' => true,
'flashes' => $this->getFlashContent(),
]);
}
/**
* View for batch action.
*/
public function indexAction(): \Symfony\Component\HttpFoundation\Response
{
$route = $this->generateUrl('mautic_channel_batch_contact_set');
return $this->delegateView([
'viewParameters' => [
'form' => $this->createForm(ContactChannelsType::class, [], [
'action' => $route,
'channels' => $this->contactModel->getPreferenceChannels(),
'public_view' => false,
'save_button' => true,
])->createView(),
],
'contentTemplate' => '@MauticLead/Batch/channel.html.twig',
'passthroughVars' => [
'activeLink' => '#mautic_contact_index',
'mauticContent' => 'leadBatch',
'route' => $route,
],
]);
}
}

View File

@@ -0,0 +1,308 @@
<?php
namespace Mautic\ChannelBundle\Controller;
use Doctrine\Persistence\ManagerRegistry;
use Mautic\ChannelBundle\Entity\Channel;
use Mautic\ChannelBundle\Model\MessageModel;
use Mautic\CoreBundle\Controller\AbstractStandardFormController;
use Mautic\CoreBundle\Factory\ModelFactory;
use Mautic\CoreBundle\Factory\PageHelperFactoryInterface;
use Mautic\CoreBundle\Helper\Chart\LineChart;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Service\FlashBag;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\FormBundle\Helper\FormFieldHelper;
use Mautic\LeadBundle\Controller\EntityContactsTrait;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\Form;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
class MessageController extends AbstractStandardFormController
{
use EntityContactsTrait;
public function __construct(
FormFactoryInterface $formFactory,
FormFieldHelper $fieldHelper,
ManagerRegistry $doctrine,
ModelFactory $modelFactory,
UserHelper $userHelper,
CoreParametersHelper $coreParametersHelper,
EventDispatcherInterface $dispatcher,
Translator $translator,
FlashBag $flashBag,
private RequestStack $requestStack,
CorePermissions $security,
) {
parent::__construct($formFactory, $fieldHelper, $doctrine, $modelFactory, $userHelper, $coreParametersHelper, $dispatcher, $translator, $flashBag, $requestStack, $security);
}
/**
* @return \Symfony\Component\HttpFoundation\JsonResponse|\Symfony\Component\HttpFoundation\RedirectResponse
*/
public function batchDeleteAction(Request $request)
{
return $this->batchDeleteStandard($request);
}
/**
* @return \Mautic\CoreBundle\Controller\Response|\Symfony\Component\HttpFoundation\JsonResponse|\Symfony\Component\HttpFoundation\RedirectResponse
*/
public function cloneAction(Request $request, $objectId)
{
return $this->cloneStandard($request, $objectId);
}
/**
* @param bool $ignorePost
*
* @return \Mautic\CoreBundle\Controller\Response|\Symfony\Component\HttpFoundation\JsonResponse
*/
public function editAction(Request $request, $objectId, $ignorePost = false)
{
return $this->editStandard($request, $objectId, $ignorePost);
}
/**
* @param int $page
*/
public function indexAction(Request $request, $page = 1): Response
{
return $this->indexStandard($request, $page);
}
public function newAction(Request $request): Response
{
return $this->newStandard($request);
}
/**
* @return array|\Symfony\Component\HttpFoundation\JsonResponse|\Symfony\Component\HttpFoundation\RedirectResponse|Response
*/
public function viewAction(Request $request, $objectId)
{
return $this->viewStandard($request, $objectId, 'message', 'channel');
}
/**
* @return mixed[]
*/
protected function getViewArguments(array $args, $action): array
{
/** @var MessageModel $model */
$model = $this->getModel($this->getModelName());
$viewParameters = [];
switch ($action) {
case 'index':
$viewParameters = [
'headerTitle' => $this->translator->trans('mautic.channel.messages'),
'listHeaders' => [
[
'text' => 'mautic.core.channels',
'class' => 'visible-md visible-lg',
],
],
'listItemTemplate' => '@MauticChannel/Message/list_item.html.twig',
'enableCloneButton' => true,
];
break;
case 'view':
$message = $args['viewParameters']['item'];
// Init the date range filter form
$returnUrl = $this->generateUrl(
'mautic_message_action',
[
'objectAction' => 'view',
'objectId' => $message->getId(),
]
);
[$dateFrom, $dateTo] = $this->getViewDateRange($this->requestStack->getCurrentRequest(), $message->getId(), $returnUrl, 'local', $dateRangeForm);
$chart = new LineChart(null, $dateFrom, $dateTo);
/** @var Channel[] $channels */
$channels = $model->getChannels();
$messageChannels = $message->getChannels();
$chart->setDataset(
$this->translator->trans('mautic.core.all'),
$model->getLeadStatsPost($message->getId(), $dateFrom, $dateTo)
);
$messagedLeads = [
'all' => $this->forward(
'Mautic\ChannelBundle\Controller\MessageController::contactsAction',
[
'objectId' => $message->getId(),
'page' => $this->requestStack->getCurrentRequest()->getSession()->get('mautic.'.$this->getSessionBase('all').'.contact.page', 1),
'ignoreAjax' => true,
'channel' => 'all',
]
)->getContent(),
];
foreach ($messageChannels as $channel) {
if ($channel->isEnabled() && isset($channels[$channel->getChannel()])) {
$chart->setDataset(
$channels[$channel->getChannel()]['label'],
$model->getLeadStatsPost($message->getId(), $dateFrom, $dateTo, $channel->getChannel())
);
$messagedLeads[$channel->getChannel()] = $this->forward(
'Mautic\ChannelBundle\Controller\MessageController::contactsAction',
[
'objectId' => $message->getId(),
'page' => $this->requestStack->getCurrentRequest()->getSession()->get(
'mautic.'.$this->getSessionBase($channel->getChannel()).'.contact.page',
1
),
'ignoreAjax' => true,
'channel' => $channel->getChannel(),
]
)->getContent();
}
}
$viewParameters = [
'channels' => $channels,
'channelContents' => $model->getMessageChannels($message->getId()),
'dateRangeForm' => $dateRangeForm->createView(),
'eventCounts' => $chart->render(),
'messagedLeads' => $messagedLeads,
];
break;
case 'new':
case 'edit':
$viewParameters = [
'channels' => $model->getChannels(),
];
break;
}
$args['viewParameters'] = array_merge($args['viewParameters'], $viewParameters);
return $args;
}
/**
* @return \Symfony\Component\HttpFoundation\JsonResponse|\Symfony\Component\HttpFoundation\RedirectResponse
*/
public function deleteAction(Request $request, $objectId)
{
return $this->deleteStandard($request, $objectId);
}
protected function getTemplateBase(): string
{
return '@MauticChannel/Message';
}
protected function getFormView(FormInterface $form, $view): FormView
{
return $form->createView();
}
protected function getJsLoadMethodPrefix(): string
{
return 'messages';
}
protected function getModelName(): string
{
return 'channel.message';
}
protected function getRouteBase(): string
{
return 'message';
}
/***
*
* @return string
*/
protected function getSessionBase($objectId = null): string
{
return 'message'.(($objectId) ? '.'.$objectId : '');
}
protected function getTranslationBase(): string
{
return 'mautic.channel.message';
}
/**
* @param int $page
*
* @return JsonResponse|\Symfony\Component\HttpFoundation\RedirectResponse|Response
*/
public function contactsAction(
Request $request,
PageHelperFactoryInterface $pageHelperFactory,
$objectId,
$channel,
$page = 1,
) {
$filter = [];
if ('all' !== $channel) {
$returnUrl = $this->generateUrl(
'mautic_message_action',
[
'objectAction' => 'view',
'objectId' => $objectId,
]
);
[$dateFrom, $dateTo] = $this->getViewDateRange($request, $objectId, $returnUrl, 'UTC');
$filter = [
'channel' => $channel,
[
'col' => 'entity.date_triggered',
'expr' => 'between',
'val' => [
$dateFrom->format('Y-m-d H:i:s'),
$dateTo->format('Y-m-d H:i:s'),
],
],
];
}
return $this->generateContactsGrid(
$request,
$pageHelperFactory,
$objectId,
$page,
'channel:messages:view',
'message.'.$channel,
'campaign_lead_event_log',
$channel,
null,
$filter,
[
[
'type' => 'join',
'from_alias' => 'entity',
'table' => 'campaign_events',
'alias' => 'event',
'condition' => "entity.event_id = event.id and event.channel = 'channel.message' and event.channel_id = ".(int) $objectId,
],
],
null,
[
'channel' => $channel ?: 'all',
],
'.message-'.$channel
);
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Mautic\ChannelBundle\DependencyInjection;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\Extension;
use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;
class MauticChannelExtension 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,263 @@
<?php
namespace Mautic\ChannelBundle\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\DBAL\Types\Types;
use Doctrine\ORM\Mapping\ClassMetadata;
use Mautic\ApiBundle\Serializer\Driver\ApiMetadataDriver;
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
use Mautic\CoreBundle\Entity\CommonEntity;
use Mautic\CoreBundle\Entity\UuidInterface;
use Mautic\CoreBundle\Entity\UuidTrait;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(security: "is_granted('channel:messages:viewown')"),
new Post(security: "is_granted('channel:messages:create')"),
new Get(security: "is_granted('channel:messages:viewown')"),
new Put(security: "is_granted('channel:messages:editown')"),
new Patch(security: "is_granted('channel:messages:editother')"),
new Delete(security: "is_granted('channel:messages:deleteown')"),
],
normalizationContext: [
'groups' => ['channel:read'],
'swagger_definition_name' => 'Read',
'api_included' => ['message'],
],
denormalizationContext: [
'groups' => ['channel:write'],
'swagger_definition_name' => 'Write',
]
)]
class Channel extends CommonEntity implements UuidInterface
{
use UuidTrait;
/**
* @var int
*/
#[Groups(['channel:read'])]
private $id;
/**
* @var string
*/
#[Groups(['channel:read', 'channel:write', 'message:read'])]
private $channel;
/**
* @var int|null
*/
#[Groups(['channel:read', 'channel:write'])]
private $channelId;
/**
* @var string
*/
#[Groups(['channel:read', 'message:read'])]
private $channelName;
/**
* @var Message
*/
#[Groups(['channel:read', 'channel:write'])]
private $message;
/**
* @var array
*/
#[Groups(['channel:read', 'channel:write'])]
private $properties = [];
/**
* @var bool
*/
#[Groups(['channel:read', 'channel:write', 'message:read'])]
private $isEnabled = false;
public static function loadMetadata(ClassMetadata $metadata): void
{
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable('message_channels')
->addIndex(['channel', 'channel_id'], 'channel_entity_index')
->addIndex(['channel', 'is_enabled'], 'channel_enabled_index')
->addUniqueConstraint(['message_id', 'channel'], 'channel_index');
$builder
->addId()
->addField('channel', 'string')
->addNamedField('channelId', 'integer', 'channel_id', true)
->addField('properties', Types::JSON)
->createField('isEnabled', 'boolean')
->columnName('is_enabled')
->build();
$builder->createManyToOne('message', Message::class)
->addJoinColumn('message_id', 'id', false, false, 'CASCADE')
->inversedBy('channels')
->build();
static::addUuidField($builder);
}
/**
* Prepares the metadata for API usage.
*/
public static function loadApiMetadata(ApiMetadataDriver $metadata): void
{
$metadata->setGroupPrefix('messageChannel')
->addListProperties(
[
'id',
'channel',
'channelId',
'channelName',
'isEnabled',
]
)
->addProperties(
[
'properties',
'message',
]
)
->build();
}
/**
* @return int
*/
public function getId()
{
return $this->id;
}
/**
* @return string
*/
public function getChannel()
{
return $this->channel;
}
/**
* @param string $channel
*
* @return Channel
*/
public function setChannel($channel)
{
$this->channel = $channel;
return $this;
}
/**
* @return int
*/
public function getChannelId()
{
return $this->channelId;
}
/**
* @param int $channelId
*
* @return Channel
*/
public function setChannelId($channelId)
{
if (empty($channelId)) {
$channelId = null;
}
$this->channelId = $channelId;
return $this;
}
/**
* @return string
*/
public function getChannelName()
{
return $this->channelName;
}
/**
* @param string $channelName
*
* @return Channel
*/
public function setChannelName($channelName)
{
$this->channelName = $channelName;
return $this;
}
/**
* @return Message
*/
public function getMessage()
{
return $this->message;
}
/**
* @return Channel
*/
public function setMessage(Message $message)
{
$this->message = $message;
return $this;
}
/**
* @return array
*/
public function getProperties()
{
return $this->properties;
}
/**
* @return Channel
*/
public function setProperties(array $properties)
{
$this->properties = $properties;
return $this;
}
/**
* @return bool
*/
public function isEnabled()
{
return $this->isEnabled;
}
/**
* @param bool $isEnabled
*
* @return Channel
*/
public function setIsEnabled($isEnabled)
{
$this->isEnabled = $isEnabled;
return $this;
}
}

View File

@@ -0,0 +1,309 @@
<?php
namespace Mautic\ChannelBundle\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\ClassMetadata;
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\NotBlank;
use Symfony\Component\Validator\Mapping\ClassMetadata as ValidationClassMetadata;
#[ApiResource(
operations: [
new GetCollection(security: "is_granted('channel:messages:viewown')"),
new Post(security: "is_granted('channel:messages:create')"),
new Get(security: "is_granted('channel:messages:viewown')"),
new Put(security: "is_granted('channel:messages:editown')"),
new Patch(security: "is_granted('channel:messages:editother')"),
new Delete(security: "is_granted('channel:messages:deleteown')"),
],
normalizationContext: [
'groups' => ['message:read'],
'swagger_definition_name' => 'Read',
'api_included' => ['category', 'channels'],
],
denormalizationContext: [
'groups' => ['message:write'],
'swagger_definition_name' => 'Write',
]
)]
class Message extends FormEntity implements UuidInterface
{
use UuidTrait;
use ProjectTrait;
/**
* @var ?int
*/
#[Groups(['message:read'])]
private $id;
/**
* @var string
*/
#[Groups(['message:read', 'message:write', 'channel:read'])]
private $name;
/**
* @var ?string
*/
#[Groups(['message:read', 'message:write'])]
private $description;
/**
* @var ?\DateTimeInterface
*/
#[Groups(['message:read', 'message:write'])]
private $publishUp;
/**
* @var ?\DateTimeInterface
*/
#[Groups(['message:read', 'message:write'])]
private $publishDown;
/**
* @var ?Category
*/
#[Groups(['message:read', 'message:write'])]
private $category;
/**
* @var ArrayCollection<int,Channel>
*/
#[Groups(['message:read', 'message:write'])]
private $channels;
public function __clone()
{
$this->id = null;
}
public static function loadMetadata(ClassMetadata $metadata): void
{
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable('messages')
->setCustomRepositoryClass(MessageRepository::class)
->addIndex(['date_added'], 'date_message_added');
$builder
->addIdColumns()
->addPublishDates()
->addCategory();
$builder->createOneToMany('channels', Channel::class)
->setIndexBy('channel')
->orphanRemoval()
->mappedBy('message')
->cascadeMerge()
->cascadePersist()
->cascadeDetach()
->build();
static::addUuidField($builder);
self::addProjectsField($builder, 'message_projects_xref', 'message_id');
}
public static function loadValidatorMetadata(ValidationClassMetadata $metadata): void
{
$metadata->addPropertyConstraint('name', new NotBlank([
'message' => 'mautic.core.name.required',
]));
}
public static function loadApiMetadata(ApiMetadataDriver $metadata): void
{
$metadata->setGroupPrefix('message')
->addListProperties(
[
'id',
'name',
'description',
]
)
->addProperties(
[
'publishUp',
'publishDown',
'channels',
'category',
]
)
->build();
self::addProjectsInLoadApiMetadata($metadata, 'message');
}
public function __construct()
{
$this->channels = new ArrayCollection();
$this->initializeProjects();
}
/**
* @return ?int
*/
public function getId()
{
return $this->id;
}
/**
* @return ?string
*/
public function getName()
{
return $this->name;
}
/**
* @param ?string $name
*
* @return Message
*/
public function setName($name)
{
$this->isChanged('name', $name);
$this->name = $name;
return $this;
}
/**
* @return ?string
*/
public function getDescription()
{
return $this->description;
}
/**
* @param ?string $description
*
* @return Message
*/
public function setDescription($description)
{
$this->isChanged('description', $description);
$this->description = $description;
return $this;
}
/**
* @return ?\DateTimeInterface
*/
public function getPublishUp()
{
return $this->publishUp;
}
/**
* @param ?\DateTime $publishUp
*
* @return Message
*/
public function setPublishUp($publishUp)
{
$this->isChanged('publishUp', $publishUp);
$this->publishUp = $publishUp;
return $this;
}
/**
* @return ?\DateTimeInterface
*/
public function getPublishDown()
{
return $this->publishDown;
}
/**
* @param ?\DateTime $publishDown
*
* @return Message
*/
public function setPublishDown($publishDown)
{
$this->isChanged('publishDown', $publishDown);
$this->publishDown = $publishDown;
return $this;
}
/**
* @return ?Category
*/
public function getCategory()
{
return $this->category;
}
/**
* @param ?Category $category
*
* @return Message
*/
public function setCategory($category)
{
$this->isChanged('category', $category);
$this->category = $category;
return $this;
}
/**
* @return ArrayCollection<int,Channel>
*/
public function getChannels()
{
return $this->channels;
}
/**
* @param ArrayCollection<int,Channel> $channels
*
* @return Message
*/
public function setChannels($channels)
{
$this->isChanged('channels', $channels);
$this->channels = $channels;
return $this;
}
public function addChannel(Channel $channel): void
{
if (!$this->channels->contains($channel)) {
$channel->setMessage($this);
$this->isChanged('channels', $channel);
$this->channels[$channel->getChannel()] = $channel;
}
}
public function removeChannel(Channel $channel): void
{
if ($channel->getId()) {
$this->isChanged('channels', $channel->getId());
}
$this->channels->removeElement($channel);
}
}

View File

@@ -0,0 +1,483 @@
<?php
namespace Mautic\ChannelBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Mautic\CampaignBundle\Entity\Event;
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
use Mautic\LeadBundle\Entity\Lead;
class MessageQueue
{
public const STATUS_RESCHEDULED = 'rescheduled';
public const STATUS_PENDING = 'pending';
public const STATUS_SENT = 'sent';
public const STATUS_CANCELLED = 'cancelled';
public const PRIORITY_NORMAL = 2;
public const PRIORITY_HIGH = 1;
/**
* @var string
*/
private $id;
/**
* @var string
*/
private $channel;
private $channelId;
/**
* @var Event|null
*/
private $event;
/**
* @var Lead
*/
private $lead;
/**
* @var int
*/
private $priority = 2;
/**
* @var int
*/
private $maxAttempts = 3;
/**
* @var int
*/
private $attempts = 0;
/**
* @var bool
*/
private $success = false;
/**
* @var string
*/
private $status = self::STATUS_PENDING;
/**
* @var \DateTimeInterface
**/
private $datePublished;
/**
* @var \DateTimeInterface|null
*/
private $scheduledDate;
/**
* @var \DateTimeInterface|null
*/
private $lastAttempt;
/**
* @var \DateTimeInterface|null
*/
private $dateSent;
private $options = [];
/**
* Used by listeners to note if the message had been processed in bulk.
*
* @var bool
*/
private $processed = false;
/**
* Used by listeners to tell the event dispatcher the message needs to be retried in 15 minutes.
*
* @var bool
*/
private $failed = false;
/**
* @var bool
*/
private $metadataUpdated = false;
public static function loadMetadata(ORM\ClassMetadata $metadata): void
{
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable('message_queue')
->setCustomRepositoryClass(MessageQueueRepository::class)
->addIndex(['status'], 'message_status_search')
->addIndex(['date_sent'], 'message_date_sent')
->addIndex(['scheduled_date'], 'message_scheduled_date')
->addIndex(['priority'], 'message_priority')
->addIndex(['success'], 'message_success')
->addIndex(['channel', 'channel_id'], 'message_channel_search')
->addIndex(['date_published'], 'message_queue_date_published');
$builder->addBigIntIdField();
$builder->addField('channel', 'string');
$builder->addNamedField('channelId', 'integer', 'channel_id');
$builder->createManyToOne('event', Event::class)
->addJoinColumn('event_id', 'id', true, false, 'CASCADE')
->build();
$builder->addLead(false, 'CASCADE', false);
$builder->createField('priority', 'smallint')
->columnName('priority')
->build();
$builder->createField('maxAttempts', 'smallint')
->columnName('max_attempts')
->build();
$builder->createField('attempts', 'smallint')
->columnName('attempts')
->build();
$builder->createField('success', 'boolean')
->columnName('success')
->build();
$builder->createField('status', 'string')
->columnName('status')
->build();
$builder->createField('datePublished', 'datetime')
->columnName('date_published')
->nullable()
->build();
$builder->createField('scheduledDate', 'datetime')
->columnName('scheduled_date')
->nullable()
->build();
$builder->createField('lastAttempt', 'datetime')
->columnName('last_attempt')
->nullable()
->build();
$builder->createField('dateSent', 'datetime')
->columnName('date_sent')
->nullable()
->build();
$builder->createField('options', 'array')
->nullable()
->build();
}
public function getId(): int
{
return (int) $this->id;
}
/**
* @return int
*/
public function getAttempts()
{
return $this->attempts;
}
/**
* @param int $attempts
*/
public function setAttempts($attempts): void
{
$this->attempts = $attempts;
}
/**
* @return array
*/
public function getOptions()
{
return $this->options;
}
/**
* @param array $options
*/
public function setOptions($options): void
{
$this->options[] = $options;
}
/**
* @return string
*/
public function getChannel()
{
return $this->channel;
}
/**
* @param string $channel
*/
public function setChannel($channel): void
{
$this->channel = $channel;
}
/**
* @return mixed
*/
public function getChannelId()
{
return $this->channelId;
}
/**
* @param mixed $channelId
*
* @return MessageQueue
*/
public function setChannelId($channelId)
{
$this->channelId = $channelId;
return $this;
}
/**
* @return Event|null
*/
public function getEvent()
{
return $this->event;
}
/**
* @return MessageQueue
*/
public function setEvent(Event $event)
{
$this->event = $event;
return $this;
}
/**
* @return \DateTimeInterface
*/
public function getDatePublished()
{
return $this->datePublished;
}
/**
* @param \DateTime $datePublished
*/
public function setDatePublished($datePublished): void
{
$this->datePublished = $datePublished;
}
/**
* @return \DateTimeInterface
*/
public function getDateSent()
{
return $this->dateSent;
}
/**
* @param \DateTime $dateSent
*/
public function setDateSent($dateSent): void
{
$this->dateSent = $dateSent;
}
/**
* @return \DateTimeInterface
*/
public function getLastAttempt()
{
return $this->lastAttempt;
}
/**
* @param \DateTime $lastAttempt
*/
public function setLastAttempt($lastAttempt): void
{
$this->lastAttempt = $lastAttempt;
}
/**
* @return Lead
*/
public function getLead()
{
return $this->lead;
}
public function setLead(Lead $lead): void
{
$this->lead = $lead;
}
/**
* @return int
*/
public function getMaxAttempts()
{
return $this->maxAttempts;
}
/**
* @param int $maxAttempts
*/
public function setMaxAttempts($maxAttempts): void
{
$this->maxAttempts = $maxAttempts;
}
/**
* @return int
*/
public function getPriority()
{
return $this->priority;
}
/**
* @param int $priority
*/
public function setPriority($priority): void
{
$this->priority = $priority;
}
/**
* @return \DateTimeInterface
*/
public function getScheduledDate()
{
return $this->scheduledDate;
}
/**
* @param mixed $scheduledDate
*/
public function setScheduledDate($scheduledDate): void
{
$this->scheduledDate = $scheduledDate;
}
/**
* @return string
*/
public function getStatus()
{
return $this->status;
}
/**
* @param string $status
*/
public function setStatus($status): void
{
$this->status = $status;
}
/**
* @return bool
*/
public function getSuccess()
{
return $this->success;
}
/**
* @return bool
*/
public function isSuccess()
{
return $this->success;
}
/**
* @param bool $success
*/
public function setSuccess($success = true): void
{
$this->success = $success;
}
/**
* @return bool
*/
public function isFailed()
{
return $this->failed;
}
/**
* @param bool $failed
*
* @return MessageQueue
*/
public function setFailed($failed = true)
{
$this->failed = $failed;
return $this;
}
/**
* @return bool
*/
public function isProcessed()
{
return $this->processed;
}
/**
* @param bool $processed
*
* @return MessageQueue
*/
public function setProcessed($processed = true)
{
$this->processed = $processed;
return $this;
}
/**
* @return array|mixed
*/
public function getMetadata()
{
return $this->options['metadata'] ?? [];
}
public function setMetadata(array $metadata = []): void
{
$this->metadataUpdated = true;
$this->options['metadata'] = $metadata;
}
/**
* @return bool
*/
public function wasMetadataUpdated()
{
return $this->metadataUpdated;
}
}

View File

@@ -0,0 +1,118 @@
<?php
namespace Mautic\ChannelBundle\Entity;
use Doctrine\Common\Collections\Order;
use Doctrine\DBAL\ArrayParameterType;
use Mautic\CoreBundle\Entity\CommonRepository;
use Mautic\LeadBundle\Entity\TimelineTrait;
/**
* @extends CommonRepository<MessageQueue>
*/
class MessageQueueRepository extends CommonRepository
{
use TimelineTrait;
public function findMessage($channel, $channelId, $leadId)
{
$results = $this->createQueryBuilder('mq')
->where('IDENTITY(mq.lead) = :leadId')
->andWhere('mq.channel = :channel')
->andWhere('mq.channelId = :channelId')
->setParameter('leadId', $leadId)
->setParameter('channel', $channel)
->setParameter('channelId', $channelId)
->getQuery()
->getResult();
return ($results) ? $results[0] : null;
}
/**
* @return array<int, MessageQueue>
*/
public function getQueuedMessages($limit, $processStarted, $channel = null, $channelId = null)
{
$q = $this->createQueryBuilder('mq');
$q->where($q->expr()->eq('mq.success', ':success'))
->andWhere($q->expr()->lt('mq.attempts', 'mq.maxAttempts'))
->andWhere('mq.lastAttempt is null or mq.lastAttempt < :processStarted')
->andWhere('mq.scheduledDate <= :processStarted')
->setParameter('success', false, 'boolean')
->setParameter('processStarted', $processStarted)
->indexBy('mq', 'mq.id');
$q->orderBy('mq.priority, mq.scheduledDate', Order::Ascending->value);
if ($limit) {
$q->setMaxResults((int) $limit);
}
if ($channel) {
$q->andWhere($q->expr()->eq('mq.channel', ':channel'))
->setParameter('channel', $channel);
if ($channelId) {
$q->andWhere($q->expr()->eq('mq.channelId', (int) $channelId));
}
}
return $q->getQuery()->getResult();
}
public function getQueuedChannelCount($channel, ?array $ids = null): int
{
$q = $this->getEntityManager()->getConnection()->createQueryBuilder();
$expr = $q->expr()->and(
$q->expr()->eq($this->getTableAlias().'.channel', ':channel'),
$q->expr()->neq($this->getTableAlias().'.status', ':status')
);
if (!empty($ids)) {
$expr = $expr->with(
$q->expr()->in($this->getTableAlias().'.channel_id', $ids)
);
}
return (int) $q->select('count(*)')
->from(MAUTIC_TABLE_PREFIX.'message_queue', $this->getTableAlias())
->where($expr)
->setParameter('channel', $channel)
->setParameter('status', MessageQueue::STATUS_SENT)
->setParameter('ids', $ids, ArrayParameterType::INTEGER)
->executeQuery()
->fetchOne();
}
/**
* Get a lead's point log.
*
* @param int|null $leadId
*
* @return array
*/
public function getLeadTimelineEvents($leadId = null, array $options = [])
{
$query = $this->getEntityManager()->getConnection()->createQueryBuilder()
->from(MAUTIC_TABLE_PREFIX.'message_queue', 'mq')
->select('mq.id, mq.lead_id, mq.channel as channelName, mq.channel_id as channelId,
mq.priority as priority, mq.attempts, mq.success, mq.status, mq.date_published as dateAdded,
mq.scheduled_date as scheduledDate, mq.last_attempt as lastAttempt, mq.date_sent as dateSent');
if ($leadId) {
$query->where('mq.lead_id = :leadId')
->setParameter('leadId', $leadId);
}
if (isset($options['search']) && $options['search']) {
$query->andWhere(
$query->expr()->like('mq.channel', ':search')
)->setParameter('search', '%'.$options['search'].'%');
}
return $this->getTimelineResults($query, $options, 'mq.channel', 'mq.date_published', [], ['dateAdded'], null, 'mq.id');
}
}

View File

@@ -0,0 +1,139 @@
<?php
namespace Mautic\ChannelBundle\Entity;
use Mautic\CategoryBundle\Entity\Category;
use Mautic\CoreBundle\Entity\CommonRepository;
use Mautic\ProjectBundle\Entity\ProjectRepositoryTrait;
/**
* @extends CommonRepository<Message>
*/
class MessageRepository extends CommonRepository
{
use ProjectRepositoryTrait;
/**
* @return \Doctrine\ORM\Tools\Pagination\Paginator
*/
public function getEntities(array $args = [])
{
$qb = $this->createQueryBuilder($this->getTableAlias());
// Because of this inner join pagination is not working properly. Removing this doesn't seem to break any feature.
// $qb->join(Channel::class, 'channel', 'WITH', 'channel.message = '.$this->getTableAlias().'.id');
$qb->leftJoin(Category::class, 'cat', 'WITH', 'cat.id = '.$this->getTableAlias().'.category');
$qb->groupBy($this->getTableAlias().'.id');
$args['qb'] = $qb;
return parent::getEntities($args);
}
public function getTableAlias(): string
{
return 'm';
}
/**
* @param string $search
* @param int $limit
* @param int $start
*
* @return array
*/
public function getMessageList($search = '', $limit = 10, $start = 0)
{
$alias = $this->getTableAlias();
$q = $this->createQueryBuilder($this->getTableAlias());
$q->select('partial '.$alias.'.{id, name, description}');
if (!empty($search)) {
if (is_array($search)) {
$search = array_map('intval', $search);
$q->andWhere($q->expr()->in($alias.'.id', ':search'))
->setParameter('search', $search);
} else {
$q->andWhere($q->expr()->like($alias.'.name', ':search'))
->setParameter('search', "%{$search}%");
}
}
$q->andWhere($q->expr()->eq($alias.'.isPublished', true));
if (!empty($limit)) {
$q->setFirstResult($start)
->setMaxResults($limit);
}
return $q->getQuery()->getArrayResult();
}
public function getMessageChannels($messageId): array
{
$q = $this->_em->getConnection()->createQueryBuilder();
$q->from(MAUTIC_TABLE_PREFIX.'message_channels', 'mc')
->select('id, channel, channel_id, properties')
->where($q->expr()->eq('message_id', ':messageId'))
->setParameter('messageId', $messageId)
->andWhere($q->expr()->eq('is_enabled', true));
$results = $q->executeQuery()->fetchAllAssociative();
$channels = [];
foreach ($results as $result) {
$result['properties'] = json_decode($result['properties'], true);
$channels[$result['channel']] = $result;
}
return $channels;
}
/**
* @return array
*/
public function getChannelMessageByChannelId($channelId)
{
$q = $this->_em->getConnection()->createQueryBuilder();
$q->from(MAUTIC_TABLE_PREFIX.'message_channels', 'mc')
->select('id, channel, channel_id, properties, message_id')
->where($q->expr()->eq('id', ':channelId'))
->setParameter('channelId', $channelId)
->andWhere($q->expr()->eq('is_enabled', true));
return $q->executeQuery()->fetchAssociative();
}
/**
* @param object $filter
*
* @return mixed[]
*/
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(),
'message_id',
'message_projects_xref',
$this->getTableAlias(),
$filter->string,
$filter->not
),
default => $this->addStandardSearchCommandWhereClause($q, $filter),
};
}
/**
* @return string[]
*/
public function getSearchCommands(): array
{
return array_merge([
'mautic.core.searchcommand.ispublished',
'mautic.core.searchcommand.isunpublished',
'mautic.core.searchcommand.ismine',
'mautic.core.searchcommand.isuncategorized',
'mautic.project.searchcommand.name',
], parent::getSearchCommands());
}
}

View File

@@ -0,0 +1,190 @@
<?php
namespace Mautic\ChannelBundle\Event;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Contracts\EventDispatcher\Event;
class ChannelBroadcastEvent extends Event
{
/**
* Number of contacts successfully processed and/or failed per channel.
*
* @var array
*/
protected $results = [];
/**
* Min contact ID filter can be used for process parallelization.
*
* @var int
*/
private $minContactIdFilter;
/**
* Max contact ID filter can be used for process parallelization.
*
* @var int
*/
private $maxContactIdFilter;
/**
* How many contacts to load from the database.
*/
private int $limit = 100;
/**
* How big batches to use to actually send.
*/
private int $batch = 50;
private ?int $maxThreads = null;
private ?int $threadId = null;
public function __construct(
/**
* Specific channel.
*/
protected ?string $channel,
/**
* Specific ID of a specific channel.
*/
protected string|int|null $id,
protected OutputInterface $output,
) {
}
/**
* @return mixed
*/
public function getChannel()
{
return $this->channel;
}
/**
* @return mixed
*/
public function getId()
{
return $this->id;
}
/**
* @param string $channelLabel
* @param int $successCount
* @param int $failedCount
*/
public function setResults($channelLabel, $successCount, $failedCount = 0, array $failedRecipientsByList = []): void
{
$this->results[$channelLabel] = [
'success' => (int) $successCount,
'failed' => (int) $failedCount,
'failedRecipientsByList' => $failedRecipientsByList,
];
}
/**
* @return array
*/
public function getResults()
{
return $this->results;
}
public function checkContext($channel): bool
{
if ($this->channel && $this->channel !== $channel) {
return false;
}
return true;
}
/**
* @return OutputInterface
*/
public function getOutput()
{
return $this->output;
}
/**
* @param int $minContactIdFilter
*/
public function setMinContactIdFilter($minContactIdFilter): void
{
$this->minContactIdFilter = $minContactIdFilter;
}
/**
* @return int|null
*/
public function getMinContactIdFilter()
{
return $this->minContactIdFilter;
}
/**
* @param int $maxContactIdFilter
*/
public function setMaxContactIdFilter($maxContactIdFilter): void
{
$this->maxContactIdFilter = $maxContactIdFilter;
}
/**
* @return int|null
*/
public function getMaxContactIdFilter()
{
return $this->maxContactIdFilter;
}
/**
* @param int $limit
*/
public function setLimit($limit): void
{
$this->limit = $limit;
}
public function getLimit(): int
{
return $this->limit;
}
/**
* @param int $batch
*/
public function setBatch($batch): void
{
$this->batch = $batch;
}
public function getBatch(): int
{
return $this->batch;
}
public function getMaxThreads(): ?int
{
return $this->maxThreads;
}
public function setMaxThreads(?int $maxThreads): void
{
$this->maxThreads = $maxThreads;
}
public function getThreadId(): ?int
{
return $this->threadId;
}
public function setThreadId(?int $threadId): void
{
$this->threadId = $threadId;
}
}

View File

@@ -0,0 +1,99 @@
<?php
namespace Mautic\ChannelBundle\Event;
use Mautic\ChannelBundle\Model\MessageModel;
use Mautic\CoreBundle\Event\CommonEvent;
class ChannelEvent extends CommonEvent
{
/**
* @var array
*/
protected $channels = [];
/**
* @var array
*/
protected $featureChannels = [];
/**
* Adds a submit action to the list of available actions.
*
* @param string $channel a unique identifier; it is recommended that it be namespaced if there are multiple entities in a channel i.e. something.something
* @param array $config Should be keyed by the feature it supports that contains an array of feature configuration options. i.e.
* $config = [
* MessageModel::CHANNEL_FEATURE => [
* 'lookupFormType' => (optional) Form type class/alias for the channel lookup list,
* 'propertiesFormType' => (optional) Form type class/alias for the channel properties if a lookup list is not used,
*
* 'channelTemplate' => (optional) template to inject UI/DOM into the bottom of the channel's tab
* 'formTheme' => (optional) theme directory for custom form types
*
* ]
* ]
*
* @return $this
*/
public function addChannel($channel, array $config = [])
{
$this->channels[$channel] = $config;
foreach ($config as $feature => $featureConfig) {
$this->featureChannels[$feature][$channel] = $featureConfig;
}
return $this;
}
/**
* Returns registered channels with their configs.
*
* @return array
*/
public function getChannelConfigs()
{
return $this->channels;
}
/**
* Returns repository name for the provided channel. Defaults to classic naming convention.
*
* @param string $channel
*
* @return string
*/
public function getRepositoryName($channel)
{
if (isset($this->channels[$channel][MessageModel::CHANNEL_FEATURE]['repository'])) {
return $this->channels[$channel][MessageModel::CHANNEL_FEATURE]['repository'];
}
// if not defined, try the classic naming convention
$channel = ucfirst($channel);
$class = "\Mautic\\{$channel}Bundle\Entity\\{$channel}";
\assert(class_exists($class));
return $class;
}
/**
* Returns the name of the column holding the channel name for the provided channel. Defaults to 'name'.
*
* @param string $channel
*
* @return string
*/
public function getNameColumn($channel)
{
return $this->channels[$channel][MessageModel::CHANNEL_FEATURE]['nameColumn'] ?? 'name';
}
/**
* @return array
*/
public function getFeatureChannels()
{
return $this->featureChannels;
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace Mautic\ChannelBundle\Event;
use Mautic\ChannelBundle\Entity\Message;
use Mautic\CoreBundle\Event\CommonEvent;
class MessageEvent extends CommonEvent
{
/**
* @param bool $isNew
*/
public function __construct(Message $message, $isNew = false)
{
$this->entity = $message;
$this->isNew = $isNew;
}
/**
* @return Message
*/
public function getMessage()
{
return $this->entity;
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace Mautic\ChannelBundle\Event;
use Mautic\ChannelBundle\Entity\MessageQueue;
use Symfony\Contracts\EventDispatcher\Event;
class MessageQueueBatchProcessEvent extends Event
{
/**
* @param MessageQueue[] $messages
*/
public function __construct(
private array $messages,
private $channel,
private $channelId,
) {
}
public function checkContext($channel): bool
{
return $channel === $this->channel;
}
/**
* @return array
*/
public function getMessages()
{
return $this->messages;
}
/**
* @return mixed
*/
public function getChannel()
{
return $this->channel;
}
/**
* @return mixed
*/
public function getChannelId()
{
return $this->channelId;
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace Mautic\ChannelBundle\Event;
use Mautic\ChannelBundle\Entity\MessageQueue;
use Mautic\CoreBundle\Event\CommonEvent;
class MessageQueueEvent extends CommonEvent
{
/**
* @param bool $isNew
*/
public function __construct(MessageQueue $entity, $isNew = false)
{
$this->entity = $entity;
$this->isNew = $isNew;
}
/**
* @return MessageQueue
*/
public function getMessageQueue()
{
return $this->entity;
}
/**
* @param MessageQueue $entity
*/
public function setMessageQueue($entity): void
{
$this->entity = $entity;
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace Mautic\ChannelBundle\Event;
use Mautic\ChannelBundle\Entity\MessageQueue;
use Mautic\CoreBundle\Event\CommonEvent;
class MessageQueueProcessEvent extends CommonEvent
{
public function __construct(MessageQueue $entity)
{
$this->entity = $entity;
}
/**
* @return MessageQueue
*/
public function getMessageQueue()
{
return $this->entity;
}
public function checkContext($channel): bool
{
return $channel === $this->entity->getChannel();
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace Mautic\ChannelBundle\EventListener;
use Mautic\CoreBundle\CoreEvents;
use Mautic\CoreBundle\Event\CustomButtonEvent;
use Mautic\CoreBundle\Twig\Helper\ButtonHelper;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class ButtonSubscriber implements EventSubscriberInterface
{
public function __construct(
private RouterInterface $router,
private TranslatorInterface $translator,
) {
}
public static function getSubscribedEvents(): array
{
return [
CoreEvents::VIEW_INJECT_CUSTOM_BUTTONS => ['injectContactBulkButtons', 0],
];
}
public function injectContactBulkButtons(CustomButtonEvent $event): void
{
if (str_starts_with($event->getRoute(), 'mautic_contact_')) {
$event->addButton(
[
'attr' => [
'class' => 'btn btn-ghost btn-sm btn-nospin',
'data-toggle' => 'ajaxmodal',
'data-target' => '#MauticSharedModal',
'href' => $this->router->generate('mautic_channel_batch_contact_view'),
'data-header' => $this->translator->trans('mautic.lead.batch.channels'),
],
'btnText' => $this->translator->trans('mautic.lead.batch.channels'),
'iconClass' => 'ri-remote-control-line',
],
ButtonHelper::LOCATION_BULK_ACTIONS
);
}
}
}

View File

@@ -0,0 +1,238 @@
<?php
declare(strict_types=1);
namespace Mautic\ChannelBundle\EventListener;
use Doctrine\Common\Collections\ArrayCollection;
use Mautic\CampaignBundle\CampaignEvents;
use Mautic\CampaignBundle\Entity\Event;
use Mautic\CampaignBundle\Entity\LeadEventLog;
use Mautic\CampaignBundle\Event\CampaignBuilderEvent;
use Mautic\CampaignBundle\Event\PendingEvent;
use Mautic\CampaignBundle\EventCollector\Accessor\Event\ActionAccessor;
use Mautic\CampaignBundle\EventCollector\EventCollector;
use Mautic\CampaignBundle\Executioner\Dispatcher\ActionDispatcher;
use Mautic\CampaignBundle\Executioner\Exception\NoContactsFoundException;
use Mautic\ChannelBundle\ChannelEvents;
use Mautic\ChannelBundle\Form\Type\MessageSendType;
use Mautic\ChannelBundle\Model\MessageModel;
use Mautic\ChannelBundle\PreferenceBuilder\PreferenceBuilder;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class CampaignSubscriber implements EventSubscriberInterface
{
private ?Event $pseudoEvent = null;
private ?ArrayCollection $mmLogs = null;
/**
* @var mixed[]
*/
private array $messageChannels = [];
public function __construct(
private MessageModel $messageModel,
private ActionDispatcher $actionDispatcher,
private EventCollector $eventCollector,
private LoggerInterface $logger,
private TranslatorInterface $translator,
) {
}
public static function getSubscribedEvents(): array
{
return [
CampaignEvents::CAMPAIGN_ON_BUILD => ['onCampaignBuild', 0],
ChannelEvents::ON_CAMPAIGN_BATCH_ACTION => ['onCampaignTriggerAction', 0],
];
}
public function onCampaignBuild(CampaignBuilderEvent $event): void
{
$channels = $this->messageModel->getChannels();
$decisions = [];
foreach ($channels as $channel) {
if (isset($channel['campaignDecisionsSupported'])) {
$decisions = $decisions + $channel['campaignDecisionsSupported'];
}
}
$action = [
'label' => 'mautic.channel.message.send.marketing.message',
'description' => 'mautic.channel.message.send.marketing.message.descr',
'batchEventName' => ChannelEvents::ON_CAMPAIGN_BATCH_ACTION,
'formType' => MessageSendType::class,
'channel' => 'channel.message',
'channelIdField' => 'marketingMessage',
'connectionRestrictions' => [
'target' => [
'decision' => $decisions,
],
],
'timelineTemplate' => '@MauticChannel/SubscribedEvents/Timeline/index.html.twig',
'timelineTemplateVars' => [
'messageSettings' => $channels,
],
];
$event->addAction('message.send', $action);
}
/**
* @throws \Mautic\CampaignBundle\Executioner\Dispatcher\Exception\LogNotProcessedException
* @throws \Mautic\CampaignBundle\Executioner\Dispatcher\Exception\LogPassedAndFailedException
* @throws \ReflectionException
*/
public function onCampaignTriggerAction(PendingEvent $pendingEvent): void
{
$this->pseudoEvent = clone $pendingEvent->getEvent();
$this->pseudoEvent->setCampaign($pendingEvent->getEvent()->getCampaign());
$this->mmLogs = $pendingEvent->getPending();
$campaignEvent = $pendingEvent->getEvent();
$properties = $campaignEvent->getProperties();
$messageSettings = $this->messageModel->getChannels();
$id = (int) $properties['marketingMessage'];
// Set channel for the event logs
$pendingEvent->setChannel('channel.message', $id);
if (!isset($this->messageChannels[$id])) {
$this->messageChannels[$id] = $this->messageModel->getMessageChannels($id);
}
// organize into preferred channels
$preferenceBuilder = new PreferenceBuilder($this->mmLogs, $this->pseudoEvent, $this->messageChannels[$id], $this->logger);
// Loop until we have no more channels
$priority = 1;
$channelPreferences = $preferenceBuilder->getChannelPreferences();
while ($priority <= count($this->messageChannels[$id])) {
foreach ($channelPreferences as $channel => $preferences) {
if (!isset($messageSettings[$channel]['campaignAction'])) {
continue;
}
$channelLogs = $preferences->getLogsByPriority($priority);
if (!$channelLogs->count()) {
continue;
}
// Marketing messages mimick campaign actions so create a pseudo event
$this->pseudoEvent->setEventType(Event::TYPE_ACTION)
->setType($messageSettings[$channel]['campaignAction']);
$successfullyExecuted = $this->sendChannelMessage($channelLogs, $channel, $this->messageChannels[$id][$channel]);
$this->passExecutedLogs($pendingEvent, $successfullyExecuted, $preferenceBuilder);
}
++$priority;
}
// Remove logs from failures if they are also in successful logs
// This handles Marketing Messages with multiple channels where one channel fails but another succeeds.
$this->removeSuccessfulFromFailures($pendingEvent);
$pendingEvent->failRemainingPending($this->translator->trans('mautic.channel.message.failed'));
}
/**
* @param string $channel
*
* @return bool|ArrayCollection
*
* @throws \Mautic\CampaignBundle\Executioner\Dispatcher\Exception\LogNotProcessedException
* @throws \Mautic\CampaignBundle\Executioner\Dispatcher\Exception\LogPassedAndFailedException
* @throws \ReflectionException
*/
private function sendChannelMessage(ArrayCollection $logs, $channel, array $messageChannel)
{
/** @var ActionAccessor $config */
$config = $this->eventCollector->getEventConfig($this->pseudoEvent);
// Set the property set as the channel ID with the message ID
if ($channelIdField = $config->getChannelIdField()) {
$messageChannel['properties'][$channelIdField] = $messageChannel['channel_id'];
}
$this->pseudoEvent->setProperties($messageChannel['properties']);
// Dispatch the mimicked campaign action
$pendingEvent = new PendingEvent($config, $this->pseudoEvent, $logs);
$pendingEvent->setChannel('campaign.event', $messageChannel['channel_id']);
$this->actionDispatcher->dispatchEvent(
$config,
$this->pseudoEvent,
$logs,
$pendingEvent
);
// Record the channel metadata mainly for debugging
$this->recordChannelMetadata($pendingEvent, $channel);
// Remove pseudo failures so we can try the next channel
$success = $pendingEvent->getSuccessful();
$this->removePsuedoFailures($success);
unset($pendingEvent);
return $success;
}
private function passExecutedLogs(PendingEvent $pendingEvent, ArrayCollection $logs, PreferenceBuilder $channelPreferences): void
{
/** @var LeadEventLog $log */
foreach ($logs as $log) {
// Remove those successfully executed from being processed again for lower priorities
$channelPreferences->removeLogFromAllChannels($log);
// Find the Marketing Message log and pass it
$mmLog = $pendingEvent->findLogByContactId($log->getLead()->getId());
// Pass these for the MM campaign event
$pendingEvent->pass($mmLog);
}
}
/**
* @param ArrayCollection<int,LeadEventLog> $success
*/
private function removePsuedoFailures(ArrayCollection $success): void
{
foreach ($success as $key => $log) {
if (!empty($log->getMetadata()['failed'])) {
$success->remove($key);
}
}
}
private function removeSuccessfulFromFailures(PendingEvent $pendingEvent): void
{
$successfulKeys = $pendingEvent->getSuccessful()->getKeys();
foreach ($successfulKeys as $key) {
if ($pendingEvent->getFailures()->containsKey($key)) {
$pendingEvent->getFailures()->remove($key);
}
}
}
private function recordChannelMetadata(PendingEvent $pendingEvent, string $channel): void
{
/** @var LeadEventLog $log */
foreach ($this->mmLogs as $log) {
try {
$channelLog = $pendingEvent->findLogByContactId($log->getLead()->getId());
if ($metadata = $channelLog->getMetadata()) {
$log->appendToMetadata([$channel => $metadata]);
}
} catch (NoContactsFoundException) {
continue;
}
}
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace Mautic\ChannelBundle\EventListener;
use Mautic\ChannelBundle\Entity\MessageQueueRepository;
use Mautic\LeadBundle\Event\LeadTimelineEvent;
use Mautic\LeadBundle\LeadEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class LeadSubscriber implements EventSubscriberInterface
{
public function __construct(
private TranslatorInterface $translator,
private RouterInterface $router,
private MessageQueueRepository $messageQueueRepository,
) {
}
public static function getSubscribedEvents(): array
{
return [
LeadEvents::TIMELINE_ON_GENERATE => ['onTimelineGenerate', 0],
];
}
/**
* Compile events for the lead timeline.
*/
public function onTimelineGenerate(LeadTimelineEvent $event): void
{
$eventTypeKey = 'message.queue';
$eventTypeName = $this->translator->trans('mautic.message.queue');
$event->addEventType($eventTypeKey, $eventTypeName);
$event->addSerializerGroup('messageQueueList');
$label = $this->translator->trans('mautic.queued.channel');
// Decide if those events are filtered
if (!$event->isApplicable($eventTypeKey)) {
return;
}
$logs = $this->messageQueueRepository->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) {
$eventName = [
'label' => $label.$log['channelName'].' '.$log['channelId'],
'href' => $this->router->generate('mautic_'.$log['channelName'].'_action', ['objectAction' => 'view', 'objectId' => $log['channelId']]),
];
$event->addEvent(
[
'eventId' => $eventTypeKey.$log['id'],
'event' => $eventTypeKey,
'eventLabel' => $eventName,
'eventType' => $eventTypeName,
'timestamp' => $log['dateAdded'],
'extra' => [
'log' => $log,
],
'contentTemplate' => '@MauticChannel/SubscribedEvents/Timeline/queued_messages.html.twig',
'icon' => 'ri-question-answer-line',
'contactId' => $log['lead_id'],
]
);
}
}
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace Mautic\ChannelBundle\EventListener;
use Mautic\ChannelBundle\ChannelEvents;
use Mautic\ChannelBundle\Event\MessageEvent;
use Mautic\CoreBundle\Model\AuditLogModel;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class MessageSubscriber implements EventSubscriberInterface
{
public function __construct(
private AuditLogModel $auditLogModel,
) {
}
public static function getSubscribedEvents(): array
{
return [
ChannelEvents::MESSAGE_POST_SAVE => ['onPostSave', 0],
ChannelEvents::MESSAGE_POST_DELETE => ['onDelete', 0],
];
}
/**
* Add an entry to the audit log.
*/
public function onPostSave(MessageEvent $event): void
{
$entity = $event->getMessage();
if ($details = $event->getChanges()) {
$log = [
'bundle' => 'channel',
'object' => 'message',
'objectId' => $entity->getId(),
'action' => ($event->isNew()) ? 'create' : 'update',
'details' => $details,
];
$this->auditLogModel->writeToLog($log);
}
}
/**
* Add a delete entry to the audit log.
*/
public function onDelete(MessageEvent $event): void
{
$entity = $event->getMessage();
$log = [
'bundle' => 'channel',
'object' => 'message',
'objectId' => $entity->deletedId,
'action' => 'delete',
'details' => ['name' => $entity->getName()],
];
$this->auditLogModel->writeToLog($log);
}
}

View File

@@ -0,0 +1,145 @@
<?php
namespace Mautic\ChannelBundle\EventListener;
use Mautic\LeadBundle\Model\CompanyReportData;
use Mautic\ReportBundle\Event\ReportBuilderEvent;
use Mautic\ReportBundle\Event\ReportDataEvent;
use Mautic\ReportBundle\Event\ReportGeneratorEvent;
use Mautic\ReportBundle\ReportEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Routing\RouterInterface;
class ReportSubscriber implements EventSubscriberInterface
{
public const CONTEXT_MESSAGE_CHANNEL = 'message.channel';
public function __construct(
private CompanyReportData $companyReportData,
private RouterInterface $router,
) {
}
public static function getSubscribedEvents(): array
{
return [
ReportEvents::REPORT_ON_BUILD => ['onReportBuilder', 0],
ReportEvents::REPORT_ON_GENERATE => ['onReportGenerate', 0],
ReportEvents::REPORT_ON_DISPLAY => ['onReportDisplay', 0],
];
}
/**
* Add available tables and columns to the report builder lookup.
*/
public function onReportBuilder(ReportBuilderEvent $event): void
{
if (!$event->checkContext([self::CONTEXT_MESSAGE_CHANNEL])) {
return;
}
// message queue
$prefix = 'mq.';
$columns = [
$prefix.'channel' => [
'label' => 'mautic.message.queue.report.channel',
'type' => 'html',
],
$prefix.'channel_id' => [
'label' => 'mautic.message.queue.report.channel_id',
'type' => 'int',
],
$prefix.'priority' => [
'label' => 'mautic.message.queue.report.priority',
'type' => 'string',
],
$prefix.'max_attempts' => [
'label' => 'mautic.message.queue.report.max_attempts',
'type' => 'int',
],
$prefix.'attempts' => [
'label' => 'mautic.message.queue.report.attempts',
'type' => 'int',
],
$prefix.'success' => [
'label' => 'mautic.message.queue.report.success',
'type' => 'boolean',
],
$prefix.'status' => [
'label' => 'mautic.message.queue.report.status',
'type' => 'string',
],
$prefix.'last_attempt' => [
'label' => 'mautic.message.queue.report.last_attempt',
'type' => 'datetime',
],
$prefix.'date_sent' => [
'label' => 'mautic.message.queue.report.date_sent',
'type' => 'datetime',
],
$prefix.'scheduled_date' => [
'label' => 'mautic.message.queue.report.scheduled_date',
'type' => 'datetime',
],
$prefix.'date_published' => [
'label' => 'mautic.message.queue.report.date_published',
'type' => 'datetime',
],
];
$companyColumns = $this->companyReportData->getCompanyData();
$columns = array_merge(
$columns,
$event->getLeadColumns(),
$companyColumns
);
$event->addTable(
self::CONTEXT_MESSAGE_CHANNEL,
[
'display_name' => 'mautic.message.queue',
'columns' => $columns,
]
);
}
/**
* Initialize the QueryBuilder object to generate reports from.
*/
public function onReportGenerate(ReportGeneratorEvent $event): void
{
if (!$event->checkContext([self::CONTEXT_MESSAGE_CHANNEL])) {
return;
}
$queryBuilder = $event->getQueryBuilder();
$queryBuilder->from(MAUTIC_TABLE_PREFIX.'message_queue', 'mq')
->leftJoin('mq', MAUTIC_TABLE_PREFIX.'leads', 'l', 'l.id = mq.lead_id');
if ($this->companyReportData->eventHasCompanyColumns($event)) {
$event->addCompanyLeftJoin($queryBuilder);
}
$event->setQueryBuilder($queryBuilder);
}
public function onReportDisplay(ReportDataEvent $event): void
{
$data = $event->getData();
if ($event->checkContext([self::CONTEXT_MESSAGE_CHANNEL])) {
if (isset($data[0]['channel']) && isset($data[0]['channel_id'])) {
foreach ($data as &$row) {
$href = $this->router->generate('mautic_'.$row['channel'].'_action', ['objectAction' => 'view', 'objectId' => $row['channel_id']]);
if (isset($row['channel'])) {
$row['channel'] = '<a href="'.$href.'">'.$row['channel'].'</a>';
}
unset($row);
}
}
}
$event->setData($data);
unset($data);
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Mautic\ChannelBundle\EventListener;
use Mautic\ChannelBundle\Model\MessageModel;
use Mautic\CoreBundle\CoreEvents;
use Mautic\CoreBundle\DTO\GlobalSearchFilterDTO;
use Mautic\CoreBundle\Event\GlobalSearchEvent;
use Mautic\CoreBundle\Service\GlobalSearch;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class SearchSubscriber implements EventSubscriberInterface
{
public function __construct(
private MessageModel $model,
private GlobalSearch $globalSearch,
) {
}
public static function getSubscribedEvents(): array
{
return [
CoreEvents::GLOBAL_SEARCH => ['onGlobalSearch', 0],
];
}
public function onGlobalSearch(GlobalSearchEvent $event): void
{
$results = $this->globalSearch->performSearch(
new GlobalSearchFilterDTO($event->getSearchString()),
$this->model,
'@MauticChannel/SubscribedEvents/Search/global.html.twig'
);
if (!empty($results)) {
$event->addResults('mautic.messages.header', $results);
}
}
}

View File

@@ -0,0 +1,114 @@
<?php
namespace Mautic\ChannelBundle\Form\Type;
use Mautic\ChannelBundle\Entity\Channel;
use Mautic\CoreBundle\Form\Type\YesNoButtonGroupType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\NotBlank;
/**
* @extends AbstractType<Channel>
*/
class ChannelType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$formModifier = function (FormEvent $event) use ($options): void {
$form = $event->getForm();
$data = $event->getData();
if (is_array($data)) {
$channelName = $data['channel'];
$enabled = $data['isEnabled'];
} elseif ($data instanceof Channel) {
$channelName = $data->getChannel();
$enabled = $data->isEnabled();
} else {
$channelName = $data;
$enabled = false;
}
if (!$data || !$channelName || !isset($options['channels'][$channelName])) {
return;
}
$channelConfig = $options['channels'][$channelName];
$form->add(
'channel',
HiddenType::class
);
$form->add(
'isEnabled',
YesNoButtonGroupType::class,
[
'label' => 'mautic.channel.message.form.enabled',
'attr' => [
'onchange' => 'Mautic.toggleChannelFormDisplay(this, \''.$channelName.'\')',
],
]
);
if (isset($channelConfig['lookupFormType'])) {
$form->add(
'channelId',
$channelConfig['lookupFormType'],
[
'multiple' => false,
'label' => 'mautic.channel.message.form.message',
'constraints' => ($enabled) ? [
new NotBlank(
[
'message' => 'mautic.core.value.required',
]
),
] : [],
]
);
}
if (isset($channelConfig['propertiesFormType'])) {
$propertiesOptions = [
'label' => false,
];
if (!$enabled) {
// Disable validation
$propertiesOptions['validation_groups'] = false;
}
$form->add(
'properties',
$channelConfig['propertiesFormType'],
$propertiesOptions
);
}
};
$builder->addEventListener(FormEvents::PRE_SET_DATA, $formModifier);
$builder->addEventListener(FormEvents::PRE_SUBMIT, $formModifier);
}
public function buildView(FormView $view, FormInterface $form, array $options): void
{
$view->vars['channels'] = $options['channels'];
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setRequired(['channels']);
$resolver->setDefaults(
[
'data_class' => Channel::class,
'label' => false,
]
);
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace Mautic\ChannelBundle\Form\Type;
use Mautic\CoreBundle\Form\Type\EntityLookupType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* @extends AbstractType<mixed>
*/
class MessageListType extends AbstractType
{
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults(
[
'required' => false,
'modal_route' => 'mautic_message_action',
'model' => 'channel.message',
'multiple' => true,
'ajax_lookup_action' => function (Options $options): string {
$query = [
'is_published' => $options['is_published'],
];
return 'channel:getLookupChoiceList&'.http_build_query($query);
},
'model_lookup_method' => 'getLookupResults',
'lookup_arguments' => fn (Options $options): array => [
'type' => 'channel.message',
'filter' => '$data',
'limit' => 0,
'start' => 0,
'options' => [
'is_published' => $options['is_published'],
],
],
'is_published' => true,
]
);
}
public function getParent(): ?string
{
return EntityLookupType::class;
}
}

View File

@@ -0,0 +1,96 @@
<?php
namespace Mautic\ChannelBundle\Form\Type;
use Mautic\ChannelBundle\Model\MessageModel;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ButtonType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Validator\Constraints\NotBlank;
/**
* @extends AbstractType<mixed>
*/
class MessageSendType extends AbstractType
{
public function __construct(
protected RouterInterface $router,
protected MessageModel $messageModel,
) {
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add(
'marketingMessage',
MessageListType::class,
[
'label' => 'mautic.channel.send.selectmessages',
'label_attr' => ['class' => 'control-label'],
'multiple' => false,
'required' => true,
'constraints' => [
new NotBlank(
['message' => 'mautic.channel.choosemessage.notblank']
),
],
]
);
if (!empty($options['update_select'])) {
$windowUrl = $this->router->generate(
'mautic_message_action',
[
'objectAction' => 'new',
'contentOnly' => 1,
'updateSelect' => $options['update_select'],
]
);
$builder->add(
'newMarketingMessageButton',
ButtonType::class,
[
'attr' => [
'class' => 'btn btn-primary btn-nospin',
'onclick' => 'Mautic.loadNewWindow({windowUrl: \''.$windowUrl.'\'})',
'icon' => 'ri-add-line',
],
'label' => 'mautic.channel.create.new.message',
]
);
// create button edit email
$windowUrlEdit = $this->router->generate(
'mautic_message_action',
[
'objectAction' => 'edit',
'objectId' => 'messageId',
'contentOnly' => 1,
'updateSelect' => $options['update_select'],
]
);
$builder->add(
'editMessageButton',
ButtonType::class,
[
'attr' => [
'class' => 'btn btn-primary btn-nospin',
'onclick' => 'Mautic.loadNewWindow({windowUrl: \''.$windowUrlEdit.'\'})',
'disabled' => !isset($options['data']['message']),
'icon' => 'ri-edit-line',
],
'label' => 'mautic.channel.send.edit.message',
]
);
}
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefined(['update_select']);
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace Mautic\ChannelBundle\Form\Type;
use Mautic\ChannelBundle\Entity\Channel;
use Mautic\ChannelBundle\Entity\Message;
use Mautic\ChannelBundle\Model\MessageModel;
use Mautic\CoreBundle\Form\Type\AbstractFormStandardType;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\ProjectBundle\Form\Type\ProjectType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Valid;
class MessageType extends AbstractFormStandardType
{
public function __construct(
protected MessageModel $model,
CorePermissions $security,
) {
$this->security = $security;
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
// Add standard fields
$options = array_merge($options, ['model_name' => 'channel.message', 'permission_base' => 'channel:messages']);
parent::buildForm($builder, $options);
// Ensure that all channels exist
/** @var Message $message */
$message = $options['data'];
$channels = $this->model->getChannels();
$messageChannels = $message->getChannels();
foreach ($channels as $channelType => $channel) {
if (!isset($messageChannels[$channelType])) {
$message->addChannel(
(new Channel())
->setChannel($channelType)
->setMessage($message)
);
}
}
$builder->add(
'channels',
CollectionType::class,
[
'label' => false,
'allow_add' => true,
'allow_delete' => false,
'prototype' => false,
'entry_type' => ChannelType::class,
'by_reference' => false,
'entry_options' => [
'channels' => $channels,
],
'constraints' => [
new Valid(),
],
]
);
$builder->add('projects', ProjectType::class);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults(
[
'data_class' => Message::class,
'category_bundle' => 'messages',
]
);
}
}

View File

@@ -0,0 +1,118 @@
<?php
namespace Mautic\ChannelBundle\Helper;
use Mautic\ChannelBundle\ChannelEvents;
use Mautic\ChannelBundle\Event\ChannelEvent;
use Mautic\CoreBundle\Translation\Translator;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
class ChannelListHelper
{
/**
* @var array<string,string>
*/
private array $channels = [];
/**
* @var array<string,string[]>
*/
private array $featureChannels = [];
public function __construct(
private EventDispatcherInterface $dispatcher,
private Translator $translator,
) {
}
/**
* Get contact channels.
*/
public function getChannelList(): array
{
$channels = [];
foreach ($this->getChannels() as $channel => $details) {
$channelName = isset($details['label']) ? $this->translator->trans($details['label']) : $this->getChannelLabel($channel);
$channels[$channelName] = $channel;
}
return $channels;
}
/**
* @param bool $listOnly
*/
public function getFeatureChannels($features, $listOnly = false): array
{
$this->setupChannels();
if (!is_array($features)) {
$features = [$features];
}
$channels = [];
foreach ($features as $feature) {
$featureChannels = $this->featureChannels[$feature] ?? [];
$returnChannels = [];
foreach ($featureChannels as $channel => $details) {
if (!isset($details['label'])) {
$featureChannels[$channel]['label'] = $this->getChannelLabel($channel);
}
if ($listOnly) {
$returnChannels[$featureChannels[$channel]['label']] = $channel;
} else {
$returnChannels[$channel] = $featureChannels[$channel];
}
}
unset($featureChannels);
$channels[$feature] = $returnChannels;
}
if (1 === count($features)) {
$channels = $channels[$features[0]];
}
return $channels;
}
/**
* @return array
*/
public function getChannels()
{
$this->setupChannels();
return $this->channels;
}
public function getChannelLabel($channel): string
{
return match (true) {
$this->translator->hasId('mautic.channel.'.$channel) => $this->translator->trans('mautic.channel.'.$channel),
$this->translator->hasId('mautic.'.$channel.'.'.$channel) => $this->translator->trans('mautic.'.$channel.'.'.$channel),
default => ucfirst($channel),
};
}
public function getName(): string
{
return 'chanel';
}
/**
* Setup channels.
*
* Done this way to avoid a circular dependency error with LeadModel
*/
private function setupChannels(): void
{
if (!empty($this->channels)) {
return;
}
$event = $this->dispatcher->dispatch(new ChannelEvent(), ChannelEvents::ADD_CHANNEL);
$this->channels = $event->getChannelConfigs();
$this->featureChannels = $event->getFeatureChannels();
unset($event);
}
}

View File

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

View File

@@ -0,0 +1,72 @@
<?php
namespace Mautic\ChannelBundle\Model;
use Mautic\LeadBundle\Entity\DoNotContact as DNC;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Model\DoNotContact;
use Mautic\LeadBundle\Model\LeadModel;
use Symfony\Contracts\Translation\TranslatorInterface;
class ChannelActionModel
{
public function __construct(
private LeadModel $contactModel,
private DoNotContact $doNotContact,
private TranslatorInterface $translator,
) {
}
/**
* Update channels and frequency rules.
*/
public function update(array $contactIds, array $subscribedChannels): void
{
$contacts = $this->contactModel->getLeadsByIds($contactIds);
foreach ($contacts as $contact) {
if (!$this->contactModel->canEditContact($contact)) {
continue;
}
$this->addChannels($contact, $subscribedChannels);
$this->removeChannels($contact, $subscribedChannels);
}
}
/**
* Add contact's channels.
* Only resubscribe if the contact did not opt out themselves.
*/
private function addChannels(Lead $contact, array $subscribedChannels): void
{
$contactChannels = $this->contactModel->getContactChannels($contact);
foreach ($subscribedChannels as $subscribedChannel) {
if (!array_key_exists($subscribedChannel, $contactChannels)) {
$contactable = $this->doNotContact->isContactable($contact, $subscribedChannel);
if (DNC::UNSUBSCRIBED !== $contactable) {
$this->doNotContact->removeDncForContact($contact->getId(), $subscribedChannel);
}
}
}
}
/**
* Remove contact's channels.
*/
private function removeChannels(Lead $contact, array $subscribedChannels): void
{
$allChannels = $this->contactModel->getPreferenceChannels();
$dncChannels = array_diff($allChannels, $subscribedChannels);
foreach ($dncChannels as $channel) {
$this->doNotContact->addDncForContact(
$contact->getId(),
$channel,
DNC::MANUAL,
$this->translator->trans('mautic.lead.event.donotcontact_manual')
);
}
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace Mautic\ChannelBundle\Model;
use Mautic\LeadBundle\Entity\FrequencyRule;
use Mautic\LeadBundle\Entity\FrequencyRuleRepository;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Model\LeadModel;
class FrequencyActionModel
{
public function __construct(
private LeadModel $contactModel,
private FrequencyRuleRepository $frequencyRuleRepository,
) {
}
/**
* Update channels.
*
* @param string $preferredChannel
*/
public function update(array $contactIds, array $params, $preferredChannel): void
{
$contacts = $this->contactModel->getLeadsByIds($contactIds);
foreach ($contacts as $contact) {
if (!$this->contactModel->canEditContact($contact)) {
continue;
}
$this->updateFrequencyRules($contact, $params, $preferredChannel);
}
}
/**
* @param string $preferredChannel
*/
private function updateFrequencyRules(Lead $contact, array $params, $preferredChannel): void
{
$frequencyRules = $contact->getFrequencyRules()->toArray();
$channels = $this->contactModel->getPreferenceChannels();
foreach ($channels as $channel) {
if (is_null($preferredChannel)) {
$preferredChannel = $channel;
}
$frequencyRule = $frequencyRules[$channel] ?? new FrequencyRule();
$frequencyRule->setChannel($channel);
$frequencyRule->setLead($contact);
if (!$frequencyRule->getDateAdded()) {
$frequencyRule->setDateAdded(new \DateTime());
}
if (!empty($params['frequency_number_'.$channel]) && !empty($params['frequency_time_'.$channel])) {
$frequencyRule->setFrequencyNumber($params['frequency_number_'.$channel]);
$frequencyRule->setFrequencyTime($params['frequency_time_'.$channel]);
} else {
$frequencyRule->setFrequencyNumber(null);
$frequencyRule->setFrequencyTime(null);
}
if (!empty($params['contact_pause_start_date_'.$channel])) {
$frequencyRule->setPauseFromDate(new \DateTime($params['contact_pause_start_date_'.$channel]));
}
if (!empty($params['contact_pause_end_date_'.$channel])) {
$frequencyRule->setPauseToDate(new \DateTime($params['contact_pause_end_date_'.$channel]));
}
$frequencyRule->setPreferredChannel($preferredChannel === $channel);
$contact->addFrequencyRule($frequencyRule);
$this->frequencyRuleRepository->saveEntity($frequencyRule);
}
}
}

View File

@@ -0,0 +1,271 @@
<?php
namespace Mautic\ChannelBundle\Model;
use Doctrine\ORM\EntityManager;
use Mautic\CampaignBundle\Model\CampaignModel;
use Mautic\ChannelBundle\ChannelEvents;
use Mautic\ChannelBundle\Entity\Message;
use Mautic\ChannelBundle\Entity\MessageRepository;
use Mautic\ChannelBundle\Event\MessageEvent;
use Mautic\ChannelBundle\Form\Type\MessageType;
use Mautic\ChannelBundle\Helper\ChannelListHelper;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\CoreBundle\Model\AjaxLookupModelInterface;
use Mautic\CoreBundle\Model\FormModel;
use Mautic\CoreBundle\Model\GlobalSearchInterface;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Translation\Translator;
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 FormModel<Message>
*
* @implements AjaxLookupModelInterface<Message>
*/
class MessageModel extends FormModel implements AjaxLookupModelInterface, GlobalSearchInterface
{
public const CHANNEL_FEATURE = 'marketing_messages';
protected static $channels;
public function __construct(
protected ChannelListHelper $channelListHelper,
protected CampaignModel $campaignModel,
EntityManager $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);
}
/**
* @param Message $entity
* @param bool $unlock
*/
public function saveEntity($entity, $unlock = true): void
{
$isNew = $entity->isNew();
parent::saveEntity($entity, $unlock);
if (!$isNew) {
// Update the channels
$channels = $entity->getChannels();
foreach ($channels as $channel) {
$channel->setMessage($entity);
}
$this->getRepository()->saveEntities($channels);
}
}
public function getPermissionBase(): string
{
return 'channel:messages';
}
public function getRepository(): ?MessageRepository
{
return $this->em->getRepository(Message::class);
}
public function getEntity($id = null): ?Message
{
if (null === $id) {
return new Message();
}
return parent::getEntity($id);
}
/**
* @param object $entity
* @param array $options
*
* @return \Symfony\Component\Form\FormInterface<mixed>
*/
public function createForm($entity, FormFactoryInterface $formFactory, $action = null, $options = []): \Symfony\Component\Form\FormInterface
{
if (!empty($action)) {
$options['action'] = $action;
}
return $formFactory->create(MessageType::class, $entity, $options);
}
/**
* @return array
*/
public function getChannels()
{
if (!self::$channels) {
$channels = $this->channelListHelper->getFeatureChannels(self::CHANNEL_FEATURE);
// Validate channel configs
foreach ($channels as $channel => $config) {
if (!isset($config['lookupFormType']) && !isset($config['propertiesFormType'])) {
throw new \InvalidArgumentException('lookupFormType and/or propertiesFormType are required for channel '.$channel);
}
$label = match (true) {
$this->translator->hasId('mautic.channel.'.$channel) => $this->translator->trans('mautic.channel.'.$channel),
$this->translator->hasId('mautic.'.$channel) => $this->translator->trans('mautic.'.$channel),
$this->translator->hasId('mautic.'.$channel.'.'.$channel) => $this->translator->trans('mautic.'.$channel.'.'.$channel),
default => ucfirst($channel),
};
$config['label'] = $label;
$channels[$channel] = $config;
}
self::$channels = $channels;
}
return self::$channels;
}
/**
* @param string $filter
* @param int $limit
* @param int $start
* @param array $options
*/
public function getLookupResults($type, $filter = '', $limit = 10, $start = 0, $options = []): array
{
$results = [];
switch ($type) {
case 'channel.message':
$entities = $this->getRepository()->getMessageList(
$filter,
$limit,
$start
);
foreach ($entities as $entity) {
$results[] = [
'label' => $entity['name'],
'value' => $entity['id'],
];
}
break;
}
return $results;
}
public function getMessageChannels($messageId): array
{
return $this->getRepository()->getMessageChannels($messageId);
}
/**
* @return array
*/
public function getChannelMessageByChannelId($channelId)
{
return $this->getRepository()->getChannelMessageByChannelId($channelId);
}
public function getLeadStatsPost($messageId, $dateFrom = null, $dateTo = null, $channel = null): array
{
$eventLog = $this->campaignModel->getCampaignLeadEventLogRepository();
return $eventLog->getChartQuery(
[
'type' => 'message.send',
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
'channel' => 'channel.message',
'channelId' => $messageId,
'logChannel' => $channel,
]
);
}
/**
* @return mixed
*/
public function getMarketingMessagesEventLogs($messageId, $dateFrom = null, $dateTo = null)
{
$eventLog = $this->campaignModel->getCampaignLeadEventLogRepository();
return $eventLog->getEventLogs(['type' => 'message.send', 'dateFrom' => $dateFrom, 'dateTo' => $dateTo, 'channel' => 'message', 'channelId' => $messageId]);
}
/**
* Get the channel name from the database.
*
* @template T of object
*
* @param int $id
* @param class-string<T> $entityName
* @param string $nameColumn
*
* @return string|null
*/
public function getChannelName($id, $entityName, $nameColumn = 'name')
{
if (!$id || !$entityName || !$nameColumn) {
return null;
}
$repo = $this->em->getRepository($entityName);
$qb = $repo->createQueryBuilder('e')
->select('e.'.$nameColumn)
->where('e.id = :id')
->setParameter('id', (int) $id);
$result = $qb->getQuery()->getOneOrNullResult();
return $result[$nameColumn] ?? null;
}
/**
* @throws MethodNotAllowedHttpException
*/
protected function dispatchEvent($action, &$entity, $isNew = false, ?Event $event = null): ?Event
{
if (!$entity instanceof Message) {
throw new MethodNotAllowedHttpException(['Message']);
}
switch ($action) {
case 'pre_save':
$name = ChannelEvents::MESSAGE_PRE_SAVE;
break;
case 'post_save':
$name = ChannelEvents::MESSAGE_POST_SAVE;
break;
case 'pre_delete':
$name = ChannelEvents::MESSAGE_PRE_DELETE;
break;
case 'post_delete':
$name = ChannelEvents::MESSAGE_POST_DELETE;
break;
default:
return null;
}
if ($this->dispatcher->hasListeners($name)) {
if (empty($event)) {
$event = new MessageEvent($entity, $isNew);
}
$this->dispatcher->dispatch($event, $name);
return $event;
}
return null;
}
}

View File

@@ -0,0 +1,356 @@
<?php
namespace Mautic\ChannelBundle\Model;
use Doctrine\ORM\EntityManagerInterface;
use Mautic\ChannelBundle\ChannelEvents;
use Mautic\ChannelBundle\Entity\MessageQueue;
use Mautic\ChannelBundle\Event\MessageQueueBatchProcessEvent;
use Mautic\ChannelBundle\Event\MessageQueueEvent;
use Mautic\ChannelBundle\Event\MessageQueueProcessEvent;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\CoreBundle\Model\FormModel;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Model\CompanyModel;
use Mautic\LeadBundle\Model\LeadModel;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Contracts\EventDispatcher\Event;
/**
* @extends FormModel<MessageQueue>
*/
class MessageQueueModel extends FormModel
{
/**
* @var string A default message reschedule interval
*/
public const DEFAULT_RESCHEDULE_INTERVAL = 'PT15M';
public function __construct(
protected LeadModel $leadModel,
protected CompanyModel $companyModel,
CoreParametersHelper $coreParametersHelper,
EntityManagerInterface $em,
CorePermissions $security,
EventDispatcherInterface $dispatcher,
UrlGeneratorInterface $router,
Translator $translator,
UserHelper $userHelper,
LoggerInterface $mauticLogger,
) {
parent::__construct($em, $security, $dispatcher, $router, $translator, $userHelper, $mauticLogger, $coreParametersHelper);
}
/**
* @return \Mautic\ChannelBundle\Entity\MessageQueueRepository
*/
public function getRepository()
{
return $this->em->getRepository(MessageQueue::class);
}
/**
* @param int $attempts
* @param int $priority
* @param mixed $messageQueue
* @param string $statTableName
* @param string $statContactColumn
* @param string $statSentColumn
*/
public function processFrequencyRules(
array &$leads,
$channel,
$channelId,
$campaignEventId = null,
$attempts = 3,
$priority = MessageQueue::PRIORITY_NORMAL,
$messageQueue = null,
$statTableName = 'email_stats',
$statContactColumn = 'lead_id',
$statSentColumn = 'date_sent',
): array {
$leadIds = array_keys($leads);
$leadIds = array_combine($leadIds, $leadIds);
/** @var \Mautic\LeadBundle\Entity\FrequencyRuleRepository $frequencyRulesRepo */
$frequencyRulesRepo = $this->em->getRepository(\Mautic\LeadBundle\Entity\FrequencyRule::class);
$defaultFrequencyNumber = $this->coreParametersHelper->get($channel.'_frequency_number');
$defaultFrequencyTime = $this->coreParametersHelper->get($channel.'_frequency_time');
$dontSendTo = $frequencyRulesRepo->getAppliedFrequencyRules(
$channel,
$leadIds,
$defaultFrequencyNumber,
$defaultFrequencyTime,
$statTableName,
$statContactColumn,
$statSentColumn
);
$queuedContacts = [];
foreach ($dontSendTo as $frequencyRuleMet) {
// We only deal with date intervals here (no time intervals) so it's safe to use 'P'
$scheduleInterval = new \DateInterval('P1'.substr($frequencyRuleMet['frequency_time'], 0, 1));
if ($messageQueue && isset($messageQueue[$frequencyRuleMet['lead_id']])) {
$this->reschedule($messageQueue[$frequencyRuleMet['lead_id']], $scheduleInterval);
} else {
// Queue this message to be processed by frequency and priority
$this->queue(
[$leads[$frequencyRuleMet['lead_id']]],
$channel,
$channelId,
$scheduleInterval,
$attempts,
$priority,
$campaignEventId
);
}
$queuedContacts[$frequencyRuleMet['lead_id']] = $frequencyRuleMet['lead_id'];
unset($leads[$frequencyRuleMet['lead_id']]);
}
return $queuedContacts;
}
/**
* Adds messages to the queue.
*
* @param array $leads
* @param string $channel
* @param int $channelId
* @param int $maxAttempts
* @param int $priority
* @param int|null $campaignEventId
* @param array $options
*/
public function queue(
$leads,
$channel,
$channelId,
\DateInterval $scheduledInterval,
$maxAttempts = 1,
$priority = 1,
$campaignEventId = null,
$options = [],
): bool {
$messageQueues = [];
$scheduledDate = (new \DateTime())->add($scheduledInterval);
foreach ($leads as $lead) {
$leadId = (is_array($lead)) ? $lead['id'] : $lead->getId();
if (!empty($this->getRepository()->findMessage($channel, $channelId, $leadId))) {
continue;
}
$messageQueue = new MessageQueue();
if ($campaignEventId) {
$messageQueue->setEvent($this->em->getReference(\Mautic\CampaignBundle\Entity\Event::class, $campaignEventId));
}
$messageQueue->setChannel($channel);
$messageQueue->setChannelId($channelId);
$messageQueue->setDatePublished(new \DateTime());
$messageQueue->setMaxAttempts($maxAttempts);
$messageQueue->setLead(
($lead instanceof Lead) ? $lead : $this->em->getReference(Lead::class, $leadId)
);
$messageQueue->setPriority($priority);
$messageQueue->setScheduledDate($scheduledDate);
$messageQueue->setOptions($options);
$messageQueues[] = $messageQueue;
}
if ($messageQueues) {
$this->saveEntities($messageQueues);
$messageQueueRepository = $this->getRepository();
$messageQueueRepository->detachEntities($messageQueues);
}
return true;
}
public function sendMessages($channel = null, $channelId = null, int $limit = 50): int
{
// Note when the process started for batch purposes
$processStarted = new \DateTime();
$counter = 0;
foreach ($this->getRepository()->getQueuedMessages($limit, $processStarted, $channel, $channelId) as $queue) {
$counter += $this->processMessageQueue($queue);
$event = $queue->getEvent();
if ($event) {
$this->em->detach($event);
}
$this->em->detach($queue);
}
return $counter;
}
public function processMessageQueue($queue): int
{
if (!is_array($queue)) {
if (!$queue instanceof MessageQueue) {
throw new \InvalidArgumentException('$queue must be an instance of '.MessageQueue::class);
}
$queue = [$queue->getId() => $queue];
}
$counter = 0;
$contacts = [];
$byChannel = [];
// Lead entities will not have profile fields populated due to the custom field use - therefore to optimize resources,
// get a list of leads to fetch details all at once along with company details for dynamic email content, etc
/** @var MessageQueue $message */
foreach ($queue as $message) {
if ($message->getLead()) {
$contacts[$message->getId()] = $message->getLead()->getId();
}
}
if (!empty($contacts)) {
$contactData = $this->leadModel->getRepository()->getContacts($contacts);
foreach ($contacts as $messageId => $contactId) {
$queue[$messageId]->getLead()->setFields($contactData[$contactId]);
}
}
// Group queue by channel and channel ID - this make it possible for processing listeners to batch process such as
// sending emails in batches to 3rd party transactional services via HTTP APIs
foreach ($queue as $key => $message) {
if (MessageQueue::STATUS_SENT == $message->getStatus()) {
unset($queue[$key]);
continue;
}
$messageChannel = $message->getChannel();
$messageChannelId = $message->getChannelId();
if (!$messageChannelId) {
$messageChannelId = 0;
}
if (!isset($byChannel[$messageChannel])) {
$byChannel[$messageChannel] = [];
}
if (!isset($byChannel[$messageChannel][$messageChannelId])) {
$byChannel[$messageChannel][$messageChannelId] = [];
}
$byChannel[$messageChannel][$messageChannelId][] = $message;
}
// First try to batch process each channel
foreach ($byChannel as $messageChannel => $channelMessages) {
foreach ($channelMessages as $messageChannelId => $messages) {
$event = new MessageQueueBatchProcessEvent($messages, $messageChannel, $messageChannelId);
$ignore = null;
$this->dispatchEvent('process_batch_message_queue', $ignore, false, $event);
}
}
unset($byChannel);
// Now check to see if the message was processed by the listener and if not
// send it through a single process event listener
foreach ($queue as $message) {
if (!$message->isProcessed()) {
$event = new MessageQueueProcessEvent($message);
$this->dispatchEvent('process_message_queue', $message, false, $event);
}
if ($message->isSuccess()) {
++$counter;
$message->setSuccess();
$message->setLastAttempt(new \DateTime());
$message->setDateSent(new \DateTime());
$message->setStatus(MessageQueue::STATUS_SENT);
} elseif ($message->isFailed()) {
// Failure such as email delivery issue or something so retry in a short time
$this->reschedule($message, new \DateInterval(self::DEFAULT_RESCHEDULE_INTERVAL));
} // otherwise assume the listener did something such as rescheduling the message
}
// add listener
$this->saveEntities($queue);
return $counter;
}
/**
* @param bool $persist
*/
public function reschedule($message, \DateInterval $rescheduleInterval, $leadId = null, $channel = null, $channelId = null, $persist = false): void
{
if (!$message instanceof MessageQueue && $leadId && $channel && $channelId) {
$message = $this->getRepository()->findMessage($channel, $channelId, $leadId);
$persist = true;
}
if (!$message) {
return;
}
$message->setAttempts($message->getAttempts() + 1);
$message->setLastAttempt(new \DateTime());
$rescheduleTo = clone $message->getScheduledDate();
$rescheduleTo->add($rescheduleInterval);
$message->setScheduledDate($rescheduleTo);
$message->setStatus(MessageQueue::STATUS_RESCHEDULED);
if ($persist) {
$this->saveEntity($message);
}
// Mark as processed for listeners
$message->setProcessed();
}
/**
* @param array $channelIds
*/
public function getQueuedChannelCount($channel, $channelIds = []): int
{
return $this->getRepository()->getQueuedChannelCount($channel, $channelIds);
}
/**
* @param ?object $entity
*
* @throws \Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException
*/
protected function dispatchEvent($action, &$entity, $isNew = false, ?Event $event = null): ?Event
{
switch ($action) {
case 'process_message_queue':
$name = ChannelEvents::PROCESS_MESSAGE_QUEUE;
break;
case 'process_batch_message_queue':
$name = ChannelEvents::PROCESS_MESSAGE_QUEUE_BATCH;
break;
case 'post_save':
$name = ChannelEvents::MESSAGE_QUEUED;
break;
default:
return null;
}
if ($this->dispatcher->hasListeners($name)) {
if (empty($event)) {
$event = new MessageQueueEvent($entity, $isNew);
$event->setEntityManager($this->em);
}
$this->dispatcher->dispatch($event, $name);
return $event;
} else {
return null;
}
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace Mautic\ChannelBundle\PreferenceBuilder;
use Doctrine\Common\Collections\ArrayCollection;
use Mautic\CampaignBundle\Entity\Event;
use Mautic\CampaignBundle\Entity\LeadEventLog;
class ChannelPreferences
{
/**
* @var ArrayCollection[]
*/
private array $organizedByPriority = [];
public function __construct(
private Event $event,
) {
}
/**
* @param int $priority
*
* @return $this
*/
public function addPriority($priority)
{
$priority = (int) $priority;
if (!isset($this->organizedByPriority[$priority])) {
$this->organizedByPriority[$priority] = new ArrayCollection();
}
return $this;
}
/**
* @param int $priority
*
* @return $this
*/
public function addLog(LeadEventLog $log, $priority)
{
$priority = (int) $priority;
$this->addPriority($priority);
// We have to clone the log to not affect the original assocaited with the MM event itself
// Clone to remove from Doctrine's ORM memory since we're having to apply a pseudo event
$log = clone $log;
$log->setEvent($this->event);
$this->organizedByPriority[$priority]->set($log->getId(), $log);
return $this;
}
/**
* Removes a log from all prioritized groups.
*
* @return $this
*/
public function removeLog(LeadEventLog $log)
{
foreach ($this->organizedByPriority as $logs) {
/** @var ArrayCollection<int, LeadEventLog> $logs */
$logs->remove($log->getId());
}
return $this;
}
/**
* @param int $priority
*
* @return ArrayCollection|LeadEventLog[]
*/
public function getLogsByPriority($priority)
{
$priority = (int) $priority;
return $this->organizedByPriority[$priority] ?? new ArrayCollection();
}
}

View File

@@ -0,0 +1,106 @@
<?php
namespace Mautic\ChannelBundle\PreferenceBuilder;
use Doctrine\Common\Collections\ArrayCollection;
use Mautic\CampaignBundle\Entity\Event;
use Mautic\CampaignBundle\Entity\LeadEventLog;
use Mautic\LeadBundle\Entity\DoNotContact;
use Psr\Log\LoggerInterface;
class PreferenceBuilder
{
/**
* @var ChannelPreferences[]
*/
private array $channels = [];
public function __construct(
ArrayCollection $logs,
private Event $event,
array $channels,
private LoggerInterface $logger,
) {
$this->buildRules($logs, $channels);
}
/**
* @return ChannelPreferences[]
*/
public function getChannelPreferences()
{
return $this->channels;
}
public function removeLogFromAllChannels(LeadEventLog $log): void
{
foreach ($this->channels as $channelPreferences) {
$channelPreferences->removeLog($log);
}
}
/**
* @param string $channel
* @param int $priority
*/
private function addChannelRule($channel, array $rule, LeadEventLog $log, $priority): void
{
$channelPreferences = $this->getChannelPreferenceObject($channel, $priority);
if (DoNotContact::IS_CONTACTABLE !== $rule['dnc']) {
$log->appendToMetadata(
[
$channel => [
'failed' => 1,
'dnc' => $rule['dnc'],
],
]
);
return;
}
$this->logger->debug("MARKETING MESSAGE: Set $channel as priority $priority for contact ID #".$log->getLead()->getId());
$channelPreferences->addLog($log, $priority);
}
/**
* @param string $channel
*
* @return ChannelPreferences
*/
private function getChannelPreferenceObject($channel, $priority)
{
if (!isset($this->channels[$channel])) {
$this->channels[$channel] = new ChannelPreferences($this->event);
}
$this->channels[$channel]->addPriority($priority);
return $this->channels[$channel];
}
private function buildRules(ArrayCollection $logs, array $channels): void
{
/** @var LeadEventLog $log */
foreach ($logs as $log) {
$channelRules = $log->getLead()->getChannelRules();
$allChannels = $channels;
$priority = 1;
// Build priority based on channel rules
foreach ($channelRules as $channel => $rule) {
$this->addChannelRule($channel, $rule, $log, $priority);
++$priority;
unset($allChannels[$channel]);
}
// Add the rest of the channels as least priority
foreach ($allChannels as $channel => $messageSettings) {
$this->addChannelRule($channel, ['dnc' => DoNotContact::IS_CONTACTABLE], $log, $priority);
++$priority;
}
}
}
}

View File

@@ -0,0 +1,136 @@
{% extends '@MauticCore/Default/content.html.twig' %}
{% block mauicContent %}message
{% endblock %}
{% block headerTitle %}
{{ item.getName() }}
{% endblock %}
{% block preHeader %}
{{- include('@MauticCore/Helper/page_actions.html.twig',
{
'item' : item,
'templateButtons' : {
'close' : securityHasEntityAccess(permissions['channel:messages:viewown'], permissions['channel:messages:viewother'], item.getCreatedBy()),
},
'routeBase' : 'message',
'targetLabel' : 'mautic.channel.messages'|trans
}
) -}}
{{ include('@MauticCore/Modules/category--inline.html.twig', {'category': item.category}) }}
{% endblock %}
{% block actions %}
{{- include('@MauticCore/Helper/page_actions.html.twig', {
'item': item,
'templateButtons': {
'edit': securityHasEntityAccess(permissions['channel:messages:editown'], permissions['channel:messages:editother'], item.getCreatedBy()),
'clone': permissions['channel:messages:create'],
'delete': securityHasEntityAccess(permissions['channel:messages:deleteown'], permissions['channel:messages:deleteother'], item.getCreatedBy()),
},
'routeBase': 'message'
}) -}}
{% endblock %}
{% block publishStatus %}
{{- include('@MauticCore/Helper/publishstatus_badge.html.twig', {'entity' : item}) -}}
{% endblock %}
{% block content %}
<!-- start: box layout -->
<div class="box-layout">
<!-- left section -->
<div class="col-md-9 height-auto">
<div>
<!-- form detail header -->
{% include '@MauticCore/Helper/description--expanded.html.twig' with {'description': item.description} %}
<!--/ form detail header -->
<!-- form detail collapseable -->
<div class="collapse pr-md pl-md" id="focus-details">
<div class="pr-md pl-md pb-md">
<div class="panel shd-none mb-0">
<table class="table table-hover mb-0">
<tbody>
{{- include('@MauticCore/Helper/details.html.twig', {'entity' : item}) -}}
</tbody>
</table>
</div>
</div>
</div>
<!--/ form detail collapseable -->
</div>
<!--/ form detail collapseable toggler -->
<div>
<!-- form detail collapseable toggler -->
<div class="hr-expand nm">
<span data-toggle="tooltip" title="{% trans %}mautic.core.details{% endtrans %}">
<a href="javascript:void(0)" class="arrow text-secondary collapsed" data-toggle="collapse" data-target="#focus-details"><span class="caret"></span> {% trans %}mautic.core.details{% endtrans %}</a>
</span>
</div>
<!-- stats -->
<div class="pa-md">
<div class="row">
<div class="col-sm-12">
<div class="panel">
<div class="panel-body box-layout">
<div class="col-md-6 va-m">
<h5 class="text-white dark-md fw-sb mb-xs">
<div><i class="ri-line-chart-fill pull-left"></i>
<span class="pull-left"> {% trans %}mautic.messages.processed.messages{% endtrans %}</span></div>
</h5>
</div>
<div class="col-md-9 va-m">
{{- include('@MauticCore/Helper/graph_dateselect.html.twig', {'dateRangeForm' : dateRangeForm, 'class' : 'pull-right'}) -}}
</div>
</div>
<div class="d-flex fd-column pt-0 pl-15 pb-15 pr-15 min-h-256">
{{- include('@MauticCore/Helper/chart.html.twig', {'chartData' : eventCounts, 'chartType' : 'line', 'chartHeight' : 300}) -}}
</div>
</div>
</div>
</div>
</div>
<!--/ stats -->
{{ customContent('details.stats.graph.below', _context) }}
<!-- tabs controls -->
<ul class="nav nav-tabs nav-tabs-contained">
{% set active = 'active' %}
{% for channel, contacts in messagedLeads %}
<li class="{{ active }}">
<a href="#contacts-{{ channel }}" role="tab" data-toggle="tab">
{{ ('all' is not same as(channel)) ? channels[channel]['label'] : 'mautic.lead.leads'|trans() }}
</a>
</li>
{% set active = '' %}
{% endfor %}
</ul>
<!--/ tabs controls -->
</div>
<!-- start: tab-content -->
<div class="tab-content pa-md">
{% set active = ' active in' %}
{% for channel, contacts in messagedLeads %}
<div class="tab-pane bdr-w-0 page-list{{ active }}" id="contacts-{{ channel }}">
{% set message = ('all' is same as(channel)) ? 'mautic.channel.message.all_contacts' : 'mautic.channel.message.channel_contacts' %}
<div class="alert alert-info"><strong>{{ message|trans }}</strong></div>
<div class="message-{{ channel }}">
{{ contacts|raw }}
</div>
</div>
{% set active = '' %}
{% endfor %}
</div>
</div>
<!-- right section -->
<div class="col-md-3 bdr-l height-auto">
<!-- recent activity -->
{{- include('@MauticCore/Helper/recentactivity.html.twig', {'logs' : logs}) -}}
{% block rightFormContent %}
{% endblock %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,87 @@
{% extends "@MauticCore/FormTheme/form_tabbed.html.twig" %}
{% form_theme form _self %}
{% block channel_row %}
{% if form.children.channel is defined %}
{% set channel = form.children.channel.vars.data %}
{% set enabled = form.children.isEnabled.vars.data %}
{% set channelContent = customContent('channel.right', _context) %}
{% set leftCol = channelContent ? 6 : 12 %}
{% set enableCol = channelContent ? '' : 'col-md-2' %}
{% set propsCol = channelContent ? '' : 'col-md-10' %}
{{ form_row(form.children.channel) }}
{{ form_errors(form) }}
<div class="row">
<div class="col-md-{{ leftCol }}">
<div class="{{ enableCol }}">
{{ form_row(form.children.isEnabled) }}
</div>
<div class="{{ propsCol }}">
<div class="message_channel_properties_{{ channel }}{% if not enabled %} hide{% endif %}">
{% if form.children.channelId is defined %}
{{ form_row(form.children.channelId) }}
{% endif %}
{% if form.children.properties is defined and form.children.properties is not empty %}
{{ form_row(form.children.properties) }}
{% endif %}
</div>
</div>
</div>
{% if channelContent %}
<div class="col-md-6">
{{ channelContent }}
</div>
{% endif %}
</div>
{% endif %}
{% endblock %}
{# active, id, name, content #}
{% set tabs, active = [], true %}
{% set _channel = '' %}
{% for channel, config in channels %}
{% if form.channels[channel] is defined %}
{% set tab = {
'active' : active,
'id' : 'channel_' ~ channel,
'name' : config.label,
'content' : form_row(form.channels[channel]),
'containerAttr' : {
'style' : 'min-height: 200px;',
},
} %}
{% if formContainsErrors(form.channels[channel]) %}
{% set tab = tab|merge({'class': 'text-danger', 'icon': 'ri-alert-fill'}) %}
{% elseif form.channels[channel].isEnabled.vars.data %}
{% set tab = tab|merge({'published': true}) %}
{% endif %}
{% set tabs, active = tabs|merge([tab]), false %}
{% endif %}
{% endfor %}
{% set formTabs = tabs %}
{% block aboveTabsContent %}
<div class="pa-md row">
<div class="col-md-12">
{{ form_row(form.name) }}
{{ form_row(form.description) }}
</div>
</div>
{% endblock %}
{% block rightFormContent %}
{{ form_row(form.category) }}
{{ form_row(form.projects) }}
{{ form_row(form.isPublished, {
'attr': {
'data-none': 'mautic.core.form.unavailable_regardless_of_scheduling',
'data-start': 'mautic.core.form.available_on_scheduled_date',
'data-both': 'mautic.core.form.available_during_scheduled_period',
'data-end': 'mautic.core.form.available_until_scheduled_end'
}
}) }}
{{ form_row(form.publishUp, {'label': 'mautic.core.form.available.available_from'}) }}
{{ form_row(form.publishDown, {'label': 'mautic.core.form.available.unavailable_from'}) }}
{% endblock %}
{% block _content %}{% endblock %}

View File

@@ -0,0 +1,13 @@
{% set messageChannels, channels = item.getChannels(), [] %}
{% if messageChannels %}
{% for channelName, channel in messageChannels %}
{% if channel.isEnabled() %}
{% set channels = channels|merge({0: translatorHasId('mautic.channel.' ~ channelName) ? ('mautic.channel.' ~ channelName)|trans : channelName|title, }) %}
{% endif %}
{% endfor %}
{% endif %}
<td class="visible-md visible-lg">
{% for channel in channels %}
<span size="sm" class="label label-gray">{{ channel }}</span>
{% endfor %}
</td>

View File

@@ -0,0 +1,16 @@
{% if showMore is defined %}
<a href="{{ url('mautic_message_index', {'search': searchString}) }}" data-toggle="ajax">
<span>{{ 'mautic.core.search.more'|trans({'%count%': remaining}) }}</span>
</a>
{% else %}
<a href="{{ url('mautic_message_action', {'objectAction': 'view', 'objectId': item.id}) }}" data-toggle="ajax">
<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': 'available',
'simplified': 'true'
}) -}}
</a>
<div class="clearfix"></div>
{% endif %}

View File

@@ -0,0 +1,40 @@
{% set extra = event.extra %}
{% set log = extra.log %}
{% set eventType = log.type %}
{% set eventSettings = extra.campaignEventSettings %}
{% set messageSettings = eventSettings.action[eventType].timelineTemplateVars.messageSettings %}
{% set template = '@MauticCampaign/SubscribedEvents/Timeline/index.html.twig' %}
{% set channelEvent = event %}
{% set channelEvent = channelEvent|merge({extra: {log: log}}) %}
{% set vars = {'event' : channelEvent} %}
{% set counter = extra.log.metadata|length %}
{% for channel, results in extra.log.metadata %}
{% if messageSettings[channel] is defined %}
<h4>{{ messageSettings[channel]['label'] }}</h4>
{% if log.metadata[channel].dnc is defined %}
{{ getChannelDncText(log.metadata[channel].dnc) }}
{% endif %}
{# Successful send through this channel #}
{% if messageSettings[channel].campaignAction is not empty %}
{% set eventType = messageSettings[channel]['campaignAction'] %}
{% if eventSettings.action[eventType].timelineTemplate is defined and eventSettings.action[eventType].timelineTemplate is not empty %}
{% set template = eventSettings.action[eventType].timelineTemplate %}
{% endif %}
{% if eventSettings.action[eventType].timelineTemplateVars is defined and eventSettings.action[eventType].timelineTemplateVars is not empty %}
{% set extra = vars.event.extra|merge(eventSettings.action[eventType].timelineTemplateVars) %}
{% set vars = {
'event': {
'extra' : extra
}
} %}
{% endif %}
{% endif %}
{% set counter = counter - 1 %}
{% if counter > 0 %}
<hr/>
{% endif %}
{% endif %}
{{- include(template, vars) -}}
{% endfor %}

View File

@@ -0,0 +1,33 @@
{% set item = event.extra.log is not empty ? event.extra.log : null %}
{% if item is not empty %}
<table class="table table-hover table-sm table-condensed">
<thead>
<tr>
<th>{% trans %}mautic.queued.message.timeline.channel{% endtrans %}</th>
<th>{% trans %}mautic.queued.message.timeline.attempts{% endtrans %}</th>
<th>{% trans %}mautic.queued.message.timeline.date.added{% endtrans %}</th>
<th>{% trans %}mautic.queued.message.timeline.rescheduled{% endtrans %}</th>
<th>{% trans %}mautic.queued.message.timeline.status{% endtrans %}</th>
<th></th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">{{ getChannelLabel(item.channelName) }}</th>
<td>{{ item.attempts }}</td>
<td>{% if item.dateAdded is not empty %}{{ dateToFullConcat(item.dateAdded, 'UTC') }}{% endif %}</td>
<td>{% if item.scheduledDate is not empty %}{{ dateToFullConcat(item.scheduledDate, 'UTC') }}{% endif %}</td>
<td id="queued-status-{{ item.id }}">
{{ ('mautic.message.queue.status.' ~ item.status) | trans({},'javascript') }}
</td>
<td>
{% if ('pending' == item.status) %}
<button type="button" id="queued-message-{{ item.id }}" class="btn btn-ghost btn-nospin" onclick="Mautic.cancelQueuedMessageEvent({{ item.id }})" data-toggle="tooltip" title="{% trans %}mautic.queued.message.event.cancel{% endtrans %}">
<i class="ri-close-line text-danger"></i>
</button>
{% endif %}
</td>
</tr>
</tbody>
</table>
{% endif %}

View File

@@ -0,0 +1,28 @@
<?php
namespace Mautic\ChannelBundle\Security\Permissions;
use Mautic\CoreBundle\Security\Permissions\AbstractPermissions;
use Symfony\Component\Form\FormBuilderInterface;
class ChannelPermissions extends AbstractPermissions
{
public function __construct($params)
{
parent::__construct($params);
$this->addStandardPermissions('categories');
$this->addExtendedPermissions('messages');
}
public function getName(): string
{
return 'channel';
}
public function buildForm(FormBuilderInterface &$builder, array $options, array $data): void
{
$this->addStandardFormFields($this->getName(), 'categories', $builder, $data);
$this->addExtendedFormFields($this->getName(), 'messages', $builder, $data);
}
}

View File

@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace Mautic\ChannelBundle\Tests\Command;
use Mautic\ChannelBundle\Entity\MessageQueue;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\CoreBundle\Tests\Functional\CreateTestEntitiesTrait;
use Mautic\EmailBundle\Entity\Email;
use Mautic\EmailBundle\Entity\Stat;
use Mautic\EmailBundle\Entity\StatRepository;
use Mautic\LeadBundle\Entity\Lead;
use PHPUnit\Framework\Assert;
final class ProcessMarketingMessagesQueueCommandFunctionalTest extends MauticMysqlTestCase
{
use CreateTestEntitiesTrait;
public function testIdleCommand(): void
{
$commandTester = $this->testSymfonyCommand('mautic:messages:send');
Assert::assertSame(0, $commandTester->getStatusCode());
}
public function testCommandWithEmailQueue(): void
{
$email = $this->createEmail('Test Email');
$this->em->flush();
$scheduledDate = new \DateTime('-10 minutes');
$datePublished = new \DateTime('-1 day');
// Create 60 different leads and message queue items
$leads = [];
$messages = [];
for ($i = 0; $i < 60; ++$i) {
$leads[$i] = $this->createLead("John{$i}", "Doe{$i}", "jd{$i}@example.com");
$this->em->persist($leads[$i]);
}
$this->em->flush();
// Create a message for each lead
foreach ($leads as $lead) {
$messages[] = $this->createMessageQueue($email, $lead, $scheduledDate, $datePublished);
}
foreach ($messages as $message) {
$this->em->persist($message);
}
$this->em->flush();
$commandTester = $this->testSymfonyCommand('mautic:messages:send');
Assert::assertSame(0, $commandTester->getStatusCode());
Assert::assertStringContainsString('Messages sent: 60', $commandTester->getDisplay());
// Verify that stats were created for a sample of leads
$this->assertEmailStatCreated($email, $leads[0]);
$this->assertEmailStatCreated($email, $leads[29]);
$this->assertEmailStatCreated($email, $leads[59]);
}
public function testCommandWithLimitParameter(): void
{
$lead = $this->createLead('John', 'Doe', 'jd@example.com');
$email1 = $this->createEmail('Test Email 1');
$email2 = $this->createEmail('Test Email 2');
$email3 = $this->createEmail('Test Email 3');
$this->em->flush();
$scheduledDate = new \DateTime('-10 minutes');
$datePublished = new \DateTime('-1 day');
$messages = [
$this->createMessageQueue($email1, $lead, $scheduledDate, $datePublished),
$this->createMessageQueue($email2, $lead, $scheduledDate, $datePublished),
$this->createMessageQueue($email3, $lead, $scheduledDate, $datePublished),
];
foreach ($messages as $message) {
$this->em->persist($message);
}
$this->em->flush();
$commandTester = $this->testSymfonyCommand('mautic:messages:send', ['--limit' => 2]);
Assert::assertSame(0, $commandTester->getStatusCode());
Assert::assertStringContainsString('Messages sent: 2', $commandTester->getDisplay());
}
private function createMessageQueue(Email $email, Lead $lead, \DateTime $scheduledDate, \DateTime $datePublished): MessageQueue
{
$message = new MessageQueue();
$message->setScheduledDate($scheduledDate);
$message->setDatePublished($datePublished);
$message->setChannel('email');
$message->setChannelId($email->getId());
$message->setLead($lead);
$message->setPriority(MessageQueue::PRIORITY_NORMAL);
$message->setMaxAttempts(3);
$message->setAttempts(0);
$message->setStatus(MessageQueue::STATUS_PENDING);
return $message;
}
private function assertEmailStatCreated(Email $email, Lead $lead): void
{
/** @var StatRepository $emailStatRepository */
$emailStatRepository = $this->em->getRepository(Stat::class);
/** @var Stat|null $emailStat */
$emailStat = $emailStatRepository->findOneBy([
'email' => $email->getId(),
'lead' => $lead->getId(),
]);
Assert::assertNotNull($emailStat, "Email stat not created for email ID {$email->getId()} and lead ID {$lead->getId()}");
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Mautic\ChannelBundle\Tests\Command;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use PHPUnit\Framework\Assert;
final class SendChannelBroadcastCommandTest extends MauticMysqlTestCase
{
public function testBroadcastCommand(): void
{
$commandTester = $this->testSymfonyCommand('mautic:broadcasts:send');
Assert::assertSame(0, $commandTester->getStatusCode());
}
public function testBroadcastCommandWithLimit(): void
{
$commandTester = $this->testSymfonyCommand('mautic:broadcasts:send', ['--limit' => 1]);
Assert::assertSame(0, $commandTester->getStatusCode());
}
}

View File

@@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
namespace Mautic\ChannelBundle\Tests\Controller\Api;
use Mautic\ChannelBundle\Entity\Channel;
use Mautic\ChannelBundle\Entity\Message;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use PHPUnit\Framework\Assert;
final class MessageApiControllerTest extends MauticMysqlTestCase
{
public function testCreateMessage(): void
{
$payloadJson = <<<'JSON'
{
"name": "API message",
"description": "Marketing message created via API functional test",
"channels": {
"email": {
"channel": "email",
"channelId": 12,
"isEnabled": true
}
}
}
JSON;
$payloadArray = json_decode($payloadJson, true);
$this->client->request('POST', '/api/messages/new', $payloadArray);
$responseJson = $this->client->getResponse()->getContent();
self::assertResponseStatusCodeSame(201, $responseJson);
$this->assertMessagePayload($payloadArray, json_decode($responseJson, true)['message'], $responseJson);
}
/**
* @param mixed[] $payload
* @param mixed[] $expectedResponsePayload
*/
#[\PHPUnit\Framework\Attributes\DataProvider('patchProvider')]
public function testEditMessageWithPatch(array $payload, array $expectedResponsePayload): void
{
$channel = new Channel();
$channel->setChannel('email');
$channel->setChannelId(12);
$channel->setIsEnabled(true);
$message = new Message();
$message->setName('API message');
$message->addChannel($channel);
$this->em->persist($channel);
$this->em->persist($message);
$this->em->flush();
$this->em->detach($channel);
$this->em->detach($message);
$patchPayload = ['id' => $message->getId()] + $payload;
$this->client->request('PATCH', "/api/messages/{$message->getId()}/edit", $patchPayload);
$responseJson = $this->client->getResponse()->getContent();
self::assertResponseIsSuccessful($responseJson);
$this->assertMessagePayload(
['id' => $message->getId()] + $expectedResponsePayload,
json_decode($responseJson, true)['message'],
$responseJson
);
}
/**
* Note: the ID is added to the payload automatically in the test.
*
* @return iterable<mixed[]>
*/
public static function patchProvider(): iterable
{
yield [
[
'name' => 'API message (updated)',
],
[
'name' => 'API message (updated)',
'description' => null,
'channels' => [
'email' => [
'channel' => 'email',
'channelId' => 12,
'isEnabled' => true,
],
],
],
];
yield [
[
'description' => 'Description (updated)',
'channels' => [
'email' => [
'channel' => 'email',
'channelId' => 13,
'isEnabled' => false,
],
],
],
[
'name' => 'API message',
'description' => 'Description (updated)',
'channels' => [
'email' => [
'channel' => 'email',
'channelId' => 13,
'isEnabled' => false,
],
],
],
];
}
public function testEditMessagesWithPatch(): void
{
$channel1 = new Channel();
$channel1->setChannel('email');
$channel1->setChannelId(12);
$channel1->setIsEnabled(true);
$message1 = new Message();
$message1->setName('API message 1');
$message1->addChannel($channel1);
$channel2 = new Channel();
$channel2->setChannel('email');
$channel2->setChannelId(13);
$channel2->setIsEnabled(true);
$message2 = new Message();
$message2->setName('API message 2');
$message2->addChannel($channel2);
$this->em->persist($channel1);
$this->em->persist($channel2);
$this->em->persist($message1);
$this->em->persist($message2);
$this->em->flush();
$this->em->detach($channel1);
$this->em->detach($channel2);
$this->em->detach($message1);
$this->em->detach($message2);
$patchPayload = [
['id' => $message1->getId(), 'name' => 'API message 1 (updated)'],
['id' => $message2->getId(), 'channels' => ['email' => ['channelId' => 14, 'isEnabled' => false]]],
];
$this->client->request('PATCH', '/api/messages/batch/edit', $patchPayload);
$responseJson = $this->client->getResponse()->getContent();
self::assertResponseIsSuccessful($responseJson);
$responseArray = json_decode($responseJson, true);
$this->assertMessagePayload(
[
'id' => $message1->getId(),
'name' => 'API message 1 (updated)',
'description' => null,
'channels' => [
'email' => [
'channel' => 'email',
'channelId' => 12,
'isEnabled' => true,
],
],
],
$responseArray['messages'][0],
$responseJson
);
$this->assertMessagePayload(
[
'id' => $message2->getId(),
'name' => 'API message 2',
'description' => null,
'channels' => [
'email' => [
'channel' => 'email',
'channelId' => 14,
'isEnabled' => false,
],
],
],
$responseArray['messages'][1],
$responseJson
);
}
/**
* @param mixed[] $expectedPayload
* @param mixed[] $actualPayload
*/
private function assertMessagePayload(array $expectedPayload, array $actualPayload, string $deliveredPayloadJson): void
{
Assert::assertSame($expectedPayload['name'], $actualPayload['name'], $deliveredPayloadJson);
Assert::assertSame($expectedPayload['description'], $actualPayload['description'], $deliveredPayloadJson);
Assert::assertCount(count($expectedPayload['channels']), $actualPayload['channels'], $deliveredPayloadJson);
Assert::assertGreaterThan(0, $actualPayload['id'], $deliveredPayloadJson);
Assert::assertSame($expectedPayload['channels']['email']['channel'], $actualPayload['channels'][0]['channel'], $deliveredPayloadJson);
Assert::assertSame($expectedPayload['channels']['email']['channelId'], $actualPayload['channels'][0]['channelId'], $deliveredPayloadJson);
Assert::assertSame($expectedPayload['channels']['email']['isEnabled'], $actualPayload['channels'][0]['isEnabled'], $deliveredPayloadJson);
Assert::assertGreaterThan(0, $actualPayload['channels'][0]['id'], $deliveredPayloadJson);
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Mautic\ChannelBundle\Tests\Controller;
use Mautic\ChannelBundle\Entity\Message;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\ProjectBundle\Entity\Project;
use PHPUnit\Framework\Assert;
class MessageControllerFunctionalTest extends MauticMysqlTestCase
{
public function testFormWithProject(): void
{
$message = new Message();
$message->setName('Test message');
$this->em->persist($message);
$project = new Project();
$project->setName('Test Project');
$this->em->persist($project);
$this->em->flush();
$this->em->clear();
$crawler = $this->client->request('GET', '/s/messages/edit/'.$message->getId());
$form = $crawler->selectButton('Save')->form();
$form['message[projects]']->setValue((string) $project->getId());
$this->client->submit($form);
$this->assertResponseIsSuccessful();
$savedMessage = $this->em->find(Message::class, $message->getId());
Assert::assertSame($project->getId(), $savedMessage->getProjects()->first()->getId());
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Mautic\ChannelBundle\Tests\Controller;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Symfony\Component\HttpFoundation\Request;
final class MessageControllerTest extends MauticMysqlTestCase
{
public function testMMUiWorkflow(): void
{
$crawler = $this->client->request(Request::METHOD_GET, '/s/messages/new');
$this->assertResponseIsSuccessful();
$form = $crawler->selectButton('Save & Close')->form([
'message[name]' => 'Test message',
'message[description]' => 'Test message description',
]);
$this->client->submit($form);
$this->assertResponseIsSuccessful();
}
}

View File

@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace Mautic\ChannelBundle\Tests\Controller;
use Mautic\ChannelBundle\Entity\Channel;
use Mautic\ChannelBundle\Entity\Message;
use Mautic\ProjectBundle\Tests\Functional\AbstractProjectSearchTestCase;
final class MessageProjectSearchFunctionalTest 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');
$messageAlpha = $this->createMessage('Message Alpha');
$messageBeta = $this->createMessage('Message Beta');
$this->createMessage('Message Gamma');
$this->createMessage('Message Delta');
$messageAlpha->addProject($projectOne);
$messageAlpha->addProject($projectTwo);
$messageBeta->addProject($projectTwo);
$messageBeta->addProject($projectThree);
$this->em->flush();
$this->em->clear();
$this->searchAndAssert($searchTerm, $expectedEntities, $unexpectedEntities, ['/api/messages', '/s/messages']);
}
/**
* @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' => ['Message Alpha', 'Message Beta'],
'unexpectedEntities' => ['Message Gamma', 'Message Delta'],
];
yield 'search by one project AND message name' => [
'searchTerm' => 'project:"Project Two" AND Beta',
'expectedEntities' => ['Message Beta'],
'unexpectedEntities' => ['Message Alpha', 'Message Gamma', 'Message Delta'],
];
yield 'search by one project OR message name' => [
'searchTerm' => 'project:"Project Two" OR Gamma',
'expectedEntities' => ['Message Alpha', 'Message Beta', 'Message Gamma'],
'unexpectedEntities' => ['Message Delta'],
];
yield 'search by NOT one project' => [
'searchTerm' => '!project:"Project Two"',
'expectedEntities' => ['Message Gamma', 'Message Delta'],
'unexpectedEntities' => ['Message Alpha', 'Message Beta'],
];
yield 'search by two projects with AND' => [
'searchTerm' => 'project:"Project Two" AND project:"Project Three"',
'expectedEntities' => ['Message Beta'],
'unexpectedEntities' => ['Message Alpha', 'Message Gamma', 'Message Delta'],
];
yield 'search by two projects with NOT AND' => [
'searchTerm' => '!project:"Project Two" AND !project:"Project Three"',
'expectedEntities' => ['Message Gamma', 'Message Delta'],
'unexpectedEntities' => ['Message Alpha', 'Message Beta'],
];
yield 'search by two projects with OR' => [
'searchTerm' => 'project:"Project Two" OR project:"Project Three"',
'expectedEntities' => ['Message Alpha', 'Message Beta'],
'unexpectedEntities' => ['Message Gamma', 'Message Delta'],
];
yield 'search by two projects with NOT OR' => [
'searchTerm' => '!project:"Project Two" OR !project:"Project Three"',
'expectedEntities' => ['Message Alpha', 'Message Gamma', 'Message Delta'],
'unexpectedEntities' => ['Message Beta'],
];
}
private function createMessage(string $name): Message
{
$message = new Message();
$message->setName($name);
$message->addChannel((new Channel())
->setChannel('email')
->setMessage($message));
$this->em->persist($message);
return $message;
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace Mautic\ChannelBundle\Tests\Entity;
use Mautic\CategoryBundle\Entity\Category;
use Mautic\ChannelBundle\Entity\Message;
use PHPUnit\Framework\TestCase;
class MessageTest extends TestCase
{
public function testMessageUpdatesReflectsInChanges(): void
{
$category = new Category();
$category->setTitle('New Category');
$category->setAlias('category');
$category->setBundle('bundle');
$message = new Message();
$message->setName('New Message');
$message->setDescription('random text string for description');
$message->setCategory($category);
$message->setPublishDown(new \DateTime());
$message->setPublishUp(new \DateTime());
$this->assertIsArray($message->getChanges());
$this->assertNotEmpty($message->getChanges());
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace Mautic\ChannelBundle\Tests\Event;
use Mautic\ChannelBundle\Event\ChannelBroadcastEvent;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Output\BufferedOutput;
use Symfony\Component\Console\Output\OutputInterface;
class ChannelBroadcastEventTest extends TestCase
{
private string $channel;
private int $channelId;
private OutputInterface $output;
protected function setUp(): void
{
$this->channel = 'email';
$this->channelId = 1;
$this->output = new BufferedOutput();
}
public function testConstructorAndGetters(): void
{
$event = new ChannelBroadcastEvent($this->channel, $this->channelId, $this->output);
$this->assertSame($this->channel, $event->getChannel());
$this->assertSame($this->channelId, $event->getId());
$this->assertSame($this->output, $event->getOutput());
}
public function testResults(): void
{
$event = new ChannelBroadcastEvent($this->channel, $this->channelId, $this->output);
$successCount = 10;
$failedCount = 2;
$failedRecipientsByList = ['list1' => ['user1@example.com', 'user2@example.com']];
$event->setResults($this->channel, $successCount, $failedCount, $failedRecipientsByList);
$this->assertSame([
$this->channel => [
'success' => $successCount,
'failed' => $failedCount,
'failedRecipientsByList' => $failedRecipientsByList,
],
], $event->getResults());
}
public function testCheckContext(): void
{
$event = new ChannelBroadcastEvent($this->channel, $this->channelId, $this->output);
$this->assertTrue($event->checkContext('email'));
$this->assertFalse($event->checkContext('sms'));
}
}

View File

@@ -0,0 +1,368 @@
<?php
namespace Mautic\ChannelBundle\Tests\EventListener;
use Doctrine\Common\Collections\ArrayCollection;
use Mautic\CampaignBundle\Entity\Campaign;
use Mautic\CampaignBundle\Entity\Event;
use Mautic\CampaignBundle\Entity\LeadEventLog;
use Mautic\CampaignBundle\Event\CampaignExecutionEvent;
use Mautic\CampaignBundle\Event\PendingEvent;
use Mautic\CampaignBundle\EventCollector\Accessor\Event\ActionAccessor;
use Mautic\CampaignBundle\EventCollector\EventCollector;
use Mautic\CampaignBundle\Executioner\Dispatcher\ActionDispatcher;
use Mautic\CampaignBundle\Executioner\Dispatcher\LegacyEventDispatcher;
use Mautic\CampaignBundle\Executioner\Scheduler\EventScheduler;
use Mautic\ChannelBundle\ChannelEvents;
use Mautic\ChannelBundle\EventListener\CampaignSubscriber;
use Mautic\ChannelBundle\Form\Type\MessageSendType;
use Mautic\ChannelBundle\Model\MessageModel;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\EmailBundle\EmailEvents;
use Mautic\EmailBundle\Form\Type\EmailListType;
use Mautic\EmailBundle\Form\Type\EmailSendType;
use Mautic\LeadBundle\Entity\DoNotContact;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Tracker\ContactTracker;
use Mautic\SmsBundle\Form\Type\SmsSendType;
use Mautic\SmsBundle\SmsEvents;
use Psr\Log\NullLogger;
use Symfony\Component\EventDispatcher\EventDispatcher;
class CampaignSubscriberTest extends \PHPUnit\Framework\TestCase
{
private EventDispatcher $dispatcher;
/**
* @var \PHPUnit\Framework\MockObject\MockObject|MessageModel
*/
private \PHPUnit\Framework\MockObject\MockObject $messageModel;
private ActionDispatcher $eventDispatcher;
/**
* @var \PHPUnit\Framework\MockObject\MockObject|EventCollector
*/
private \PHPUnit\Framework\MockObject\MockObject $eventCollector;
/**
* @var \PHPUnit\Framework\MockObject\MockObject|Translator
*/
private \PHPUnit\Framework\MockObject\MockObject $translator;
/**
* @var \PHPUnit\Framework\MockObject\MockObject|EventScheduler
*/
private \PHPUnit\Framework\MockObject\MockObject $scheduler;
private LegacyEventDispatcher $legacyDispatcher;
protected function setUp(): void
{
$this->dispatcher = new EventDispatcher();
$this->messageModel = $this->createMock(MessageModel::class);
$this->messageModel->method('getChannels')
->willReturn(
[
'email' => [
'campaignAction' => 'email.send',
'campaignDecisionsSupported' => [
'email.open',
'page.pagehit',
'asset.download',
'form.submit',
],
'lookupFormType' => EmailListType::class,
],
'sms' => [
'campaignAction' => 'sms.send_text_sms',
'campaignDecisionsSupported' => [
'page.pagehit',
'asset.download',
'form.submit',
],
'lookupFormType' => 'sms_list',
'repository' => \Mautic\SmsBundle\Entity\Sms::class,
],
]
);
$this->messageModel->method('getMessageChannels')
->willReturn(
[
'email' => [
'id' => 2,
'channel' => 'email',
'channel_id' => 2,
'properties' => [],
],
'sms' => [
'id' => 1,
'channel' => 'sms',
'channel_id' => 1,
'properties' => [],
],
]
);
$this->scheduler = $this->createMock(EventScheduler::class);
$contactTracker = $this->createMock(ContactTracker::class);
$this->legacyDispatcher = new LegacyEventDispatcher(
$this->dispatcher,
$this->scheduler,
new NullLogger(),
$contactTracker
);
$this->eventDispatcher = new ActionDispatcher(
$this->dispatcher,
new NullLogger(),
$this->scheduler,
$this->legacyDispatcher
);
$this->eventCollector = $this->createMock(EventCollector::class);
$this->eventCollector->method('getEventConfig')
->willReturnCallback(
function (Event $event) {
switch ($event->getType()) {
case 'email.send':
return new ActionAccessor(
[
'label' => 'mautic.email.campaign.event.send',
'description' => 'mautic.email.campaign.event.send_descr',
'batchEventName' => EmailEvents::ON_CAMPAIGN_BATCH_ACTION,
'formType' => EmailSendType::class,
'formTypeOptions' => ['update_select' => 'campaignevent_properties_email', 'with_email_types' => true],
'formTheme' => 'MauticEmailBundle:FormTheme\EmailSendList',
'channel' => 'email',
'channelIdField' => 'email',
]
);
case 'sms.send_text_sms':
return new ActionAccessor(
[
'label' => 'mautic.campaign.sms.send_text_sms',
'description' => 'mautic.campaign.sms.send_text_sms.tooltip',
'eventName' => SmsEvents::ON_CAMPAIGN_TRIGGER_ACTION,
'formType' => SmsSendType::class,
'formTypeOptions' => ['update_select' => 'campaignevent_properties_sms'],
'formTheme' => 'MauticSmsBundle:FormTheme\SmsSendList',
'timelineTemplate' => '@MauticSms/SubscribedEvents/Timeline/index.html.twig',
'channel' => 'sms',
'channelIdField' => 'sms',
]
);
}
}
);
$this->translator = $this->createMock(Translator::class);
$campaignSubscriber = new CampaignSubscriber(
$this->messageModel,
$this->eventDispatcher,
$this->eventCollector,
new NullLogger(),
$this->translator
);
$this->dispatcher->addSubscriber($campaignSubscriber);
$this->dispatcher->addListener(EmailEvents::ON_CAMPAIGN_BATCH_ACTION, [$this, 'sendMarketingMessageEmail']);
$this->dispatcher->addListener(SmsEvents::ON_CAMPAIGN_TRIGGER_ACTION, [$this, 'sendMarketingMessageSms']);
}
public function testCorrectChannelIsUsed(): void
{
$event = $this->getEvent();
$config = new ActionAccessor(
[
'label' => 'mautic.channel.message.send.marketing.message',
'description' => 'mautic.channel.message.send.marketing.message.descr',
'batchEventName' => ChannelEvents::ON_CAMPAIGN_BATCH_ACTION,
'formType' => MessageSendType::class,
'formTheme' => 'MauticChannelBundle:FormTheme\MessageSend',
'channel' => 'channel.message',
'channelIdField' => 'marketingMessage',
'connectionRestrictions' => [
'target' => [
'decision' => [
'email.open',
'page.pagehit',
'asset.download',
'form.submit',
],
],
],
'timelineTemplate' => '@MauticChannel/SubscribedEvents/Timeline/index.html.twig',
'timelineTemplateVars' => [
'messageSettings' => [],
],
]
);
$logs = $this->getLogs();
$pendingEvent = new PendingEvent($config, $event, $logs);
$this->dispatcher->dispatch($pendingEvent, ChannelEvents::ON_CAMPAIGN_BATCH_ACTION);
$this->assertCount(0, $pendingEvent->getFailures());
$successful = $pendingEvent->getSuccessful();
// SMS should be noted as DNC
$this->assertFalse(empty($successful->get(2)->getMetadata()['sms']['dnc']));
// Nothing recorded for success
$this->assertTrue(empty($successful->get(1)->getMetadata()));
}
public function sendMarketingMessageEmail(PendingEvent $event): void
{
$contacts = $event->getContacts();
$logs = $event->getPending();
$this->assertCount(1, $logs);
if (1 === $contacts->first()->getId()) {
// Processing priority 1 for contact 1, let's fail this one so that SMS is used
$event->fail($logs->first(), 'just because');
return;
}
if (2 === $contacts->first()->getId()) {
// Processing priority 1 for contact 2 so let's pass it
$event->pass($logs->first());
return;
}
}
/**
* BC support for old campaign.
*/
public function sendMarketingMessageSms(CampaignExecutionEvent $event): void
{
$lead = $event->getLead();
if (1 === $lead->getId()) {
$event->setResult(true);
return;
}
if (2 === $lead->getId()) {
$this->fail('Lead ID 2 is unsubscribed from SMS so this shouldn not have happened.');
}
}
/**
* @return Event|\PHPUnit\Framework\MockObject\MockObject
*/
private function getEvent()
{
$event = $this->getMockBuilder(Event::class)
->onlyMethods(['getId'])
->getMock();
$event->method('getId')
->willReturn(1);
$event->setEventType(Event::TYPE_ACTION);
$event->setType('message.send');
$event->setChannel('channel.message');
$event->setChannelId('1');
$event->setProperties(
[
'canvasSettings' => [
'droppedX' => '337',
'droppedY' => '155',
],
'name' => '',
'triggerMode' => 'immediate',
'triggerDate' => null,
'triggerInterval' => '1',
'triggerIntervalUnit' => 'd',
'anchor' => 'leadsource',
'properties' => [
'marketingMessage' => '1',
],
'type' => 'message.send',
'eventType' => 'action',
'anchorEventType' => 'source',
'campaignId' => '1',
'_token' => 'q7FpcDX7iye6fBuBzsqMvQWKqW75lcD77jSmuNAEDXg',
'buttons' => [
'save' => '',
],
'marketingMessage' => '1',
]
);
$campaign = $this->createMock(Campaign::class);
$campaign->method('getId')
->willReturn(1);
$event->setCampaign($campaign);
return $event;
}
/**
* @return ArrayCollection
*/
private function getLogs()
{
$lead = $this->createMock(Lead::class);
$lead->method('getId')
->willReturn(1);
$lead->expects($this->once())
->method('getChannelRules')
->willReturn(
[
'sms' => [
'dnc' => DoNotContact::IS_CONTACTABLE,
],
'email' => [
'dnc' => DoNotContact::IS_CONTACTABLE,
],
]
);
$log = $this->getMockBuilder(LeadEventLog::class)
->onlyMethods(['getLead', 'getId'])
->getMock();
$log->method('getLead')
->willReturn($lead);
$log->method('getId')
->willReturn(1);
$lead2 = $this->createMock(Lead::class);
$lead2->method('getId')
->willReturn(2);
$lead2->expects($this->once())
->method('getChannelRules')
->willReturn(
[
'email' => [
'dnc' => DoNotContact::IS_CONTACTABLE,
],
'sms' => [
'dnc' => DoNotContact::UNSUBSCRIBED,
],
]
);
$log2 = $this->getMockBuilder(LeadEventLog::class)
->onlyMethods(['getLead', 'getId'])
->getMock();
$log2->method('getLead')
->willReturn($lead2);
$log2->method('getId')
->willReturn(2);
return new ArrayCollection([1 => $log, 2 => $log2]);
}
}

View File

@@ -0,0 +1,197 @@
<?php
declare(strict_types=1);
namespace Mautic\ChannelBundle\Tests\Model;
use Mautic\ChannelBundle\Model\ChannelActionModel;
use Mautic\LeadBundle\Entity\DoNotContact as DNC;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Model\DoNotContact;
use Mautic\LeadBundle\Model\LeadModel;
use Symfony\Contracts\Translation\TranslatorInterface;
class ChannelActionModelTest extends \PHPUnit\Framework\TestCase
{
private \PHPUnit\Framework\MockObject\MockObject $contactMock5;
private \PHPUnit\Framework\MockObject\MockObject $contactMock6;
private \PHPUnit\Framework\MockObject\MockObject $contactModelMock;
private \PHPUnit\Framework\MockObject\MockObject $doNotContactMock;
private \PHPUnit\Framework\MockObject\MockObject $translatorMock;
private ChannelActionModel $actionModel;
protected function setUp(): void
{
parent::setUp();
$this->contactMock5 = $this->createMock(Lead::class);
$this->contactMock6 = $this->createMock(Lead::class);
$this->contactModelMock = $this->createMock(LeadModel::class);
$this->doNotContactMock = $this->createMock(DoNotContact::class);
$this->translatorMock = $this->createMock(TranslatorInterface::class);
$this->actionModel = new ChannelActionModel(
$this->contactModelMock,
$this->doNotContactMock,
$this->translatorMock
);
$this->contactMock5->method('getId')->willReturn(5);
}
public function testUpdateEntityAccess(): void
{
$contacts = [5, 6];
$this->contactModelMock->expects($this->once())
->method('getLeadsByIds')
->with($contacts)
->willReturn([$this->contactMock5, $this->contactMock6]);
$matcher = $this->exactly(2);
$this->contactModelMock->expects($matcher)
->method('canEditContact')->willReturnCallback(function (...$parameters) use ($matcher) {
if (1 === $matcher->numberOfInvocations()) {
$this->assertSame($this->contactMock5, $parameters[0]);
}
if (2 === $matcher->numberOfInvocations()) {
$this->assertSame($this->contactMock6, $parameters[0]);
}
return false;
});
$this->contactModelMock->expects($this->never())
->method('getContactChannels');
$this->actionModel->update($contacts, []);
}
public function testSubscribeContactToEmailChannel(): void
{
$contacts = [5];
$subscribedChannels = ['email', 'sms']; // Subscribe contact to these channels
$this->contactModelMock->expects($this->once())
->method('getLeadsByIds')
->with($contacts)
->willReturn([$this->contactMock5]);
$this->contactModelMock->expects($this->once())
->method('canEditContact')
->with($this->contactMock5)
->willReturn(true);
// Contact is already subscribed to the SMS channel but not to email
$this->contactModelMock->expects($this->once())
->method('getContactChannels')
->with($this->contactMock5)
->willReturn(['sms' => 'sms']);
$this->doNotContactMock->expects($this->once())
->method('isContactable')
->with($this->contactMock5, 'email')
->willReturn(DNC::IS_CONTACTABLE);
$this->doNotContactMock->expects($this->once())
->method('removeDncForContact')
->with(5, 'email');
$this->contactModelMock->expects($this->once())
->method('getPreferenceChannels')
->willReturn(['Email' => 'email', 'Text Message' => 'sms']);
$this->doNotContactMock->expects($this->never())
->method('addDncForContact');
$this->actionModel->update($contacts, $subscribedChannels);
}
public function testSubscribeContactWhoUnsubscribedToEmailChannel(): void
{
$contacts = [5];
$subscribedChannels = ['email', 'sms']; // Subscribe contact to these channels
$this->contactModelMock->expects($this->once())
->method('getLeadsByIds')
->with($contacts)
->willReturn([$this->contactMock5]);
$this->contactModelMock->expects($this->once())
->method('canEditContact')
->with($this->contactMock5)
->willReturn(true);
// Contact is already subscribed to the SMS channel but not to email
$this->contactModelMock->expects($this->once())
->method('getContactChannels')
->with($this->contactMock5)
->willReturn(['sms' => 'sms']);
$this->doNotContactMock->expects($this->once())
->method('isContactable')
->with($this->contactMock5, 'email')
->willReturn(DNC::UNSUBSCRIBED);
$this->doNotContactMock->expects($this->never())
->method('removeDncForContact');
$this->contactModelMock->expects($this->once())
->method('getPreferenceChannels')
->willReturn(['Email' => 'email', 'Text Message' => 'sms']);
$this->doNotContactMock->expects($this->never())
->method('addDncForContact');
$this->actionModel->update($contacts, $subscribedChannels);
}
public function testUnsubscribeContactFromSmsChannel(): void
{
$contacts = [5];
$subscribedChannels = []; // Unsubscribe contact from missing
$this->contactModelMock->expects($this->once())
->method('getLeadsByIds')
->with($contacts)
->willReturn([$this->contactMock5]);
$this->contactModelMock->expects($this->once())
->method('canEditContact')
->with($this->contactMock5)
->willReturn(true);
$this->contactModelMock->expects($this->once())
->method('getContactChannels')
->with($this->contactMock5)
->willReturn(['sms' => 'sms']);
$this->doNotContactMock->expects($this->never())
->method('isContactable');
$this->contactModelMock->expects($this->once())
->method('getPreferenceChannels')
->willReturn(['Email' => 'email', 'Text Message' => 'sms']);
$matcher = $this->exactly(2);
$this->doNotContactMock->expects($matcher)
->method('addDncForContact')->willReturnCallback(function (...$parameters) use ($matcher) {
if (1 === $matcher->numberOfInvocations()) {
$this->assertSame(5, $parameters[0]);
$this->assertSame('email', $parameters[1]);
$this->assertSame(DNC::MANUAL, $parameters[2]);
}
if (2 === $matcher->numberOfInvocations()) {
$this->assertSame(5, $parameters[0]);
$this->assertSame('sms', $parameters[1]);
$this->assertSame(DNC::MANUAL, $parameters[2]);
}
});
$this->actionModel->update($contacts, $subscribedChannels);
}
}

View File

@@ -0,0 +1,203 @@
<?php
namespace Mautic\ChannelBundle\Tests\Model;
use Doctrine\Common\Collections\AbstractLazyCollection;
use Mautic\ChannelBundle\Model\FrequencyActionModel;
use Mautic\LeadBundle\Entity\FrequencyRule;
use Mautic\LeadBundle\Entity\FrequencyRuleRepository;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Model\LeadModel;
use PHPUnit\Framework\MockObject\MockObject;
class FrequencyActionModelTest extends \PHPUnit\Framework\TestCase
{
/**
* @var MockObject|Lead
*/
private MockObject $contactMock5;
/**
* @var MockObject|LeadModel
*/
private MockObject $contactModelMock;
/**
* @var MockObject|FrequencyRuleRepository
*/
private MockObject $frequencyRepoMock;
/**
* @var MockObject|FrequencyRule
*/
private MockObject $frequencyRuleEmailMock;
/**
* @var MockObject|FrequencyRule
*/
private MockObject $frequencyRuleSmsMock;
private FrequencyActionModel $actionModel;
protected function setUp(): void
{
parent::setUp();
$this->contactMock5 = $this->createMock(Lead::class);
$this->contactModelMock = $this->createMock(LeadModel::class);
$this->frequencyRepoMock = $this->createMock(FrequencyRuleRepository::class);
$this->frequencyRuleEmailMock = $this->createMock(FrequencyRule::class);
$this->frequencyRuleSmsMock = $this->createMock(FrequencyRule::class);
$collectionMock = $this->createMock(AbstractLazyCollection::class);
$this->actionModel = new FrequencyActionModel(
$this->contactModelMock,
$this->frequencyRepoMock
);
$collectionMock->method('toArray')
->willReturn([
'email' => $this->frequencyRuleEmailMock,
'sms' => $this->frequencyRuleSmsMock,
]);
$this->contactMock5->method('getFrequencyRules')->willReturn($collectionMock);
}
public function testUpdateWhenEntityAccess(): void
{
$contacts = [5];
$this->contactModelMock->expects($this->once())
->method('getLeadsByIds')
->with($contacts)
->willReturn([$this->contactMock5]);
$this->contactModelMock->expects($this->once())
->method('canEditContact')
->with($this->contactMock5)
->willReturn(false);
$this->contactModelMock->expects($this->never())
->method('getPreferenceChannels');
$this->actionModel->update($contacts, [], '');
}
public function testUpdate(): void
{
$contacts = [5];
$params = [
'subscribed_channels' => ['email', 'sms'],
'frequency_number_email' => '2',
'frequency_time_email' => 'WEEK',
'preferred_channel' => 'email',
'contact_pause_start_date_email' => '2018-05-13',
'contact_pause_end_date_email' => '2018-05-26',
'frequency_number_sms' => '',
'frequency_time_sms' => '',
'contact_pause_start_date_sms' => '',
'contact_pause_end_date_sms' => '',
];
$this->contactModelMock->expects($this->once())
->method('getLeadsByIds')
->with($contacts)
->willReturn([$this->contactMock5]);
$this->contactModelMock->expects($this->once())
->method('canEditContact')
->with($this->contactMock5)
->willReturn(true);
$this->contactModelMock->expects($this->once())
->method('getPreferenceChannels')
->willReturn([
'Email' => 'email',
'Text Message' => 'sms',
]);
$this->frequencyRuleEmailMock->expects($this->once())
->method('setChannel')
->with('email');
$this->frequencyRuleEmailMock->expects($this->once())
->method('setLead')
->with($this->contactMock5);
$this->frequencyRuleEmailMock->expects($this->once())
->method('setDateAdded');
$this->frequencyRuleEmailMock->expects($this->once())
->method('setFrequencyNumber')
->with('2');
$this->frequencyRuleEmailMock->expects($this->once())
->method('setFrequencyTime')
->with('WEEK');
$this->frequencyRuleEmailMock->expects($this->once())
->method('setPauseFromDate')
->with(new \DateTime('2018-05-13T00:00:00.000000+0000'));
$this->frequencyRuleEmailMock->expects($this->once())
->method('setPauseToDate')
->with(new \DateTime('2018-05-26T00:00:00.000000+0000'));
$this->frequencyRuleEmailMock->expects($this->once())
->method('setPreferredChannel')
->with(true);
$matcher = $this->exactly(2);
$this->contactMock5->expects($matcher)
->method('addFrequencyRule')->willReturnCallback(function (...$parameters) use ($matcher) {
if (1 === $matcher->numberOfInvocations()) {
$this->assertEquals($this->frequencyRuleEmailMock, $parameters[0]);
}
if (2 === $matcher->numberOfInvocations()) {
$this->assertEquals($this->frequencyRuleEmailMock, $parameters[0]);
}
});
$matcher = $this->exactly(2);
$this->frequencyRepoMock->expects($matcher)
->method('saveEntity')->willReturnCallback(function (...$parameters) use ($matcher) {
if (1 === $matcher->numberOfInvocations()) {
$this->assertSame($this->frequencyRuleEmailMock, $parameters[0]);
}
if (2 === $matcher->numberOfInvocations()) {
$this->assertSame($this->frequencyRuleSmsMock, $parameters[0]);
}
});
$this->frequencyRuleSmsMock->expects($this->once())
->method('setChannel')
->with('sms');
$this->frequencyRuleSmsMock->expects($this->once())
->method('setLead')
->with($this->contactMock5);
$this->frequencyRuleSmsMock->expects($this->once())
->method('setDateAdded');
$this->frequencyRuleSmsMock->expects($this->once())
->method('setFrequencyNumber')
->with(null);
$this->frequencyRuleSmsMock->expects($this->once())
->method('setFrequencyTime')
->with(null);
$this->frequencyRuleSmsMock->expects($this->never())
->method('setPauseFromDate');
$this->frequencyRuleSmsMock->expects($this->never())
->method('setPauseToDate');
$this->frequencyRuleSmsMock->expects($this->once())
->method('setPreferredChannel')
->with(false);
$this->actionModel->update($contacts, $params, 'email');
}
}

View File

@@ -0,0 +1,166 @@
<?php
namespace Mautic\ChannelBundle\Tests\Model;
use Doctrine\ORM\EntityManagerInterface;
use Mautic\ChannelBundle\Entity\MessageQueue;
use Mautic\ChannelBundle\Entity\MessageQueueRepository;
use Mautic\ChannelBundle\Model\MessageQueueModel;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\UserHelper;
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\CompanyModel;
use Mautic\LeadBundle\Model\LeadModel;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class MessageQueueModelTest extends \PHPUnit\Framework\TestCase
{
/**
* @var string
*/
public const DATE = '2019-07-07 15:00:00';
/**
* @var MessageQueueModel
*/
protected $messageQueue;
/**
* @var MessageQueue
*/
protected $message;
/** @var MockObject|LeadModel */
protected $leadModel;
/** @var MockObject|CompanyModel */
protected $companyModel;
/** @var MockObject|EntityManagerInterface */
protected $entityManager;
/** @var MockObject|MessageQueueRepository */
protected $messageQueueRepository;
protected function setUp(): void
{
$this->leadModel = $this->createMock(LeadModel::class);
$this->companyModel = $this->createMock(CompanyModel::class);
$this->entityManager = $this->createMock(EntityManagerInterface::class);
$this->messageQueueRepository = $this->createMock(MessageQueueRepository::class);
$coreHelper = $this->createMock(CoreParametersHelper::class);
$this->messageQueue = new MessageQueueModel(
$this->leadModel,
$this->companyModel,
$coreHelper,
$this->entityManager,
$this->createMock(CorePermissions::class),
$this->createMock(EventDispatcherInterface::class),
$this->createMock(UrlGeneratorInterface::class),
$this->createMock(Translator::class),
$this->createMock(UserHelper::class),
$this->createMock(LoggerInterface::class)
);
$this->entityManager->method('getRepository')->willReturn($this->messageQueueRepository);
$message = new MessageQueue();
$scheduleDate = new \DateTime(self::DATE);
$message->setScheduledDate($scheduleDate);
$this->message = $message;
}
public function testRescheduleMessageIntervalDay(): void
{
$interval = new \DateInterval('P2D');
$this->prepareRescheduleMessageIntervalTest($interval);
}
public function testRescheduleMessageIntervalWeek(): void
{
$interval = new \DateInterval('P4W');
$this->prepareRescheduleMessageIntervalTest($interval);
}
public function testRescheduleMessageIntervalMonth(): void
{
$interval = new \DateInterval('P8M');
$this->prepareRescheduleMessageIntervalTest($interval);
}
public function testRescheduleMessageNoInterval(): void
{
$interval = new \DateInterval('PT0S');
$this->prepareRescheduleMessageIntervalTest($interval);
}
protected function prepareRescheduleMessageIntervalTest(\DateInterval $interval)
{
$oldScheduleDate = $this->message->getScheduledDate();
$this->messageQueue->reschedule($this->message, $interval);
$scheduleDate = $this->message->getScheduledDate();
/** @var \DateTime $oldScheduleDate */
$oldScheduleDate->add($interval);
$this->assertEquals($oldScheduleDate, $scheduleDate);
$this->assertNotSame($oldScheduleDate, $scheduleDate);
}
public function testSendMessagesWithNullEvent(): void
{
$queue = $this->message;
$lead = new Lead();
$lead->setId(1);
$queue->setLead($lead);
$contactData = [
1 => [
'firstname' => 'John',
'email' => 'john.doe@example.com',
],
];
$leadRepository = $this->createMock(LeadRepository::class);
$this->leadModel->method('getRepository')->willReturn($leadRepository);
$leadRepository->method('getContacts')->willReturn($contactData);
$this->entityManager->expects($this->exactly(1))
->method('detach');
$this->messageQueueRepository->method('getQueuedMessages')
->willReturn([$queue]);
$this->messageQueue->sendMessages('email', 1);
}
public function testProcessMessageQueueLeadFieldsShouldNotContainCompany(): void
{
$queue = $this->message;
$lead = new Lead();
$lead->setId(1);
$queue->setLead($lead);
$contactData = [
1 => [
'firstname' => 'John',
'email' => 'john.doe@example.com',
],
];
$leadRepository = $this->createMock(LeadRepository::class);
$this->leadModel->method('getRepository')->willReturn($leadRepository);
$leadRepository->method('getContacts')->willReturn($contactData);
$this->messageQueue->processMessageQueue($queue);
$this->assertArrayNotHasKey('companies', $queue->getLead()->getFields());
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace Mautic\ChannelBundle\Tests\PreferenceBuilder;
use Mautic\CampaignBundle\Entity\Campaign;
use Mautic\CampaignBundle\Entity\Event;
use Mautic\CampaignBundle\Entity\LeadEventLog;
use Mautic\ChannelBundle\PreferenceBuilder\ChannelPreferences;
class ChannelPreferencesTest extends \PHPUnit\Framework\TestCase
{
public function testLogsAreOrganizedByPriority(): void
{
$campaign = new Campaign();
$event = new Event();
$event->setCampaign($campaign);
$channelPreferences = $this->getChannelPreference('email', $event);
$log1 = new LeadEventLog();
$log1->setEvent($event);
$log1->setCampaign($campaign);
$log1->setMetadata(['log' => 1]);
$channelPreferences->addLog($log1, 1);
$log2 = new LeadEventLog();
$log2->setEvent($event);
$log2->setCampaign($campaign);
$log2->setMetadata(['log' => 2]);
$channelPreferences->addLog($log2, 2);
$organized = $channelPreferences->getLogsByPriority(1);
$this->assertEquals($organized->first()->getMetadata()['log'], 1);
$organized = $channelPreferences->getLogsByPriority(2);
$this->assertEquals($organized->first()->getMetadata()['log'], 2);
}
/**
* @return ChannelPreferences
*/
private function getChannelPreference($channel, Event $event)
{
return new ChannelPreferences($event);
}
}

View File

@@ -0,0 +1,152 @@
<?php
namespace Mautic\ChannelBundle\Tests\PreferenceBuilder;
use Doctrine\Common\Collections\ArrayCollection;
use Mautic\CampaignBundle\Entity\Event;
use Mautic\CampaignBundle\Entity\LeadEventLog;
use Mautic\ChannelBundle\PreferenceBuilder\ChannelPreferences;
use Mautic\ChannelBundle\PreferenceBuilder\PreferenceBuilder;
use Mautic\LeadBundle\Entity\DoNotContact;
use Mautic\LeadBundle\Entity\Lead;
use Psr\Log\NullLogger;
class PreferenceBuilderTest extends \PHPUnit\Framework\TestCase
{
public function testChannelsArePrioritized(): void
{
$lead = $this->createMock(Lead::class);
$lead->expects($this->once())
->method('getChannelRules')
->willReturn(
[
'sms' => [
'dnc' => DoNotContact::IS_CONTACTABLE,
],
'email' => [
'dnc' => DoNotContact::IS_CONTACTABLE,
],
]
);
$log = $this->createMock(LeadEventLog::class);
$log->method('getLead')
->willReturn($lead);
$log->method('getId')
->willReturn(1);
$lead2 = $this->createMock(Lead::class);
$lead2->expects($this->once())
->method('getChannelRules')
->willReturn(
[
'email' => [
'dnc' => DoNotContact::IS_CONTACTABLE,
],
'sms' => [
'dnc' => DoNotContact::UNSUBSCRIBED,
],
]
);
$log2 = $this->createMock(LeadEventLog::class);
$log2->method('getLead')
->willReturn($lead2);
$log2->method('getId')
->willReturn(2);
$logs = new ArrayCollection([$log, $log2]);
$event = new Event();
$builder = new PreferenceBuilder($logs, $event, ['email' => [], 'sms' => [], 'push' => []], new NullLogger());
$preferences = $builder->getChannelPreferences();
$this->assertCount(3, $preferences);
$this->assertTrue(isset($preferences['email']));
$this->assertTrue(isset($preferences['sms']));
$this->assertTrue(isset($preferences['push']));
/** @var ChannelPreferences $emailLogs */
$email = $preferences['email'];
// First priority
$emailLogs = $email->getLogsByPriority(1);
$this->assertCount(1, $emailLogs);
$this->assertEquals(2, $emailLogs->first()->getId());
// Second priority
$emailLogs = $email->getLogsByPriority(2);
$this->assertCount(1, $emailLogs);
$this->assertEquals(1, $emailLogs->first()->getId());
// First priority for SMS which should just be one
/** @var ChannelPreferences $smsLogs */
$sms = $preferences['sms'];
$smsLogs = $sms->getLogsByPriority(1);
$this->assertCount(1, $smsLogs);
$this->assertEquals(1, $smsLogs->first()->getId());
// None for second priority because of DNC
$smsLogs = $sms->getLogsByPriority(2);
$this->assertCount(0, $smsLogs);
// No one had push enabled but it should be defined
$push = $preferences['push'];
$pushLogs = $push->getLogsByPriority(1);
$this->assertCount(0, $pushLogs);
}
public function testLogIsRemovedFromAllChannels(): void
{
$lead = $this->createMock(Lead::class);
$lead->expects($this->once())
->method('getChannelRules')
->willReturn(
[
'sms' => [
'dnc' => DoNotContact::IS_CONTACTABLE,
],
'email' => [
'dnc' => DoNotContact::IS_CONTACTABLE,
],
]
);
$log = $this->createMock(LeadEventLog::class);
$log->method('getLead')
->willReturn($lead);
$log->method('getId')
->willReturn(1);
$logs = new ArrayCollection([$log]);
$event = new Event();
$builder = new PreferenceBuilder($logs, $event, ['email' => [], 'sms' => [], 'push' => []], new NullLogger());
$preferences = $builder->getChannelPreferences();
/** @var ChannelPreferences $sms */
$sms = $preferences['sms'];
$smsLogs = $sms->getLogsByPriority(1);
$this->assertCount(1, $smsLogs);
/** @var ChannelPreferences $email */
$email = $preferences['email'];
$emailLogs = $email->getLogsByPriority(2);
$this->assertCount(1, $emailLogs);
$builder->removeLogFromAllChannels($log);
$preferences = $builder->getChannelPreferences();
/** @var ChannelPreferences $sms */
$sms = $preferences['sms'];
$smsLogs = $sms->getLogsByPriority(1);
$this->assertCount(0, $smsLogs);
/** @var ChannelPreferences $email */
$email = $preferences['email'];
$emailLogs = $email->getLogsByPriority(2);
$this->assertCount(0, $emailLogs);
}
}

View File

@@ -0,0 +1,4 @@
mautic.message.queue.status.cancelled="Cancelled"
mautic.message.queue.status.rescheduled="Rescheduled"
mautic.message.queue.status.pending="Pending"
mautic.message.queue.status.sent="Sent"

View File

@@ -0,0 +1,50 @@
mautic.channel.messages="Marketing Messages"
mautic.channel.message.all_contacts="This message has been sent to the following contacts."
mautic.channel.message.channel_contacts="This channel was used for the following contacts during the timeframe selected above:"
mautic.channel.message.header.new="New Marketing Message"
mautic.channel.message.failed="No channel was successful in sending the message."
mautic.channel.message.form.message="Message"
mautic.channel.message.form.enabled="Enabled?"
mautic.channel.message.send.attempts="Attempts"
mautic.channel.message.send.attempts.tooltip="Number of attempts if an message has failed"
mautic.channel.message.send.priority="Priority"
mautic.channel.message.send.priority.high="High"
mautic.channel.message.send.priority.normal="Normal"
mautic.channel.message.send.priority.tooltip="Messages with priority set as high will be handled before rescheduled messages"
mautic.channel.permissions.header="Channel Permissions"
mautic.channel.permissions.messages="Marketing Messages - Users have access to"
mautic.channel.form.additem="Nothing found here! Change that by adding a new item."
mautic.channel.campaign.event.send="Send a marketing message"
mautic.channel.campaign.event.send_descr="Select from the list of marketing messages to send in this campaign"
mautic.channel.send.selectmessages="Select a marketing message"
mautic.channel.choose.messages_descr="Marketing messages"
mautic.email.send.edit.message="Edit Message"
mautic.channel.message.send.marketing.message="Send marketing message"
mautic.channel.message.send.marketing.message.descr="Send a message through the configured channels within the marketing message selected."
mautic.messages.processed.messages="Messages Sent by Channel"
mautic.channel.message.form.confirmdelete="Delete this message?"
mautic.queued.channel="Message Queued for channel - "
mautic.message.queue="Message Queued"
mautic.queued.message.timeline.status="Status"
mautic.queued.message.event.cancel="Cancel"
mautic.queued.message.timeline.attempts="Attempts"
mautic.queued.message.timeline.channel="Channel"
mautic.queued.message.timeline.date.added="Date Added"
mautic.queued.message.timeline.rescheduled="Rescheduled Date"
mautic.message.form.confirmdelete="Delete the marketing message, %name%?"
mautic.message.queue.report.channel="Message channel"
mautic.message.queue.report.channel_id="Message ID"
mautic.message.queue.report.priority="Priority"
mautic.message.queue.report.max_attempts="Max attempts"
mautic.message.queue.report.attempts="Total attempts"
mautic.message.queue.report.success="Success"
mautic.message.queue.report.status="Status"
mautic.message.queue.report.last_attempt="Last attempt date"
mautic.message.queue.report.date_sent="Date sent"
mautic.message.queue.report.scheduled_date="Scheduled Date"
mautic.message.queue.report.date_published="Date published"
mautic.report.group.message.channel="Channels Messages"
mautic.message.queue="Message Queue"
mautic.messages.messages="Marketing Messages"
mautic.messages.header="Marketing Messages"
mautic.report.source.message.channel="Message Queue"

View File

@@ -0,0 +1 @@
mautic.channel.choosemessage.notblank="A message is required."

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace Mautic\ChannelBundle\Twig;
use Mautic\ChannelBundle\Helper\ChannelListHelper;
use Mautic\LeadBundle\Exception\UnknownDncReasonException;
use Mautic\LeadBundle\Twig\Helper\DncReasonHelper;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
class ChannelExtension extends AbstractExtension
{
public function __construct(
private DncReasonHelper $dncReasonHelper,
private ChannelListHelper $channelListHelper,
) {
}
/**
* @return TwigFunction[]
*/
public function getFunctions(): array
{
return [
new TwigFunction('getChannelDncText', [$this, 'getChannelDncText']),
new TwigFunction('getChannelLabel', [$this, 'getChannelLabel']),
];
}
public function getChannelDncText(int $reasonId): string
{
try {
return $this->dncReasonHelper->toText($reasonId);
} catch (UnknownDncReasonException $e) {
return $e->getMessage();
}
}
public function getChannelLabel(string $channel): string
{
return $this->channelListHelper->getChannelLabel($channel);
}
}