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,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;
}
}
}