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,62 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Command;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\DateTimeHelper;
use Mautic\CoreBundle\Helper\ExitCode;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class CleanupExportedFilesCommand extends Command
{
public const COMMAND_NAME = 'mautic:contacts:cleanup_exported_files';
/**
* @var string
*/
private const CLEANUP_DAYS = 'cleanupAfterDays';
public function __construct(
private CoreParametersHelper $coreParametersHelper,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->setName(self::COMMAND_NAME)
->setDescription('Remove contact export cache files from `contacts_export` directory if file is older than the week/7 days')
->addArgument(self::CLEANUP_DAYS, InputArgument::OPTIONAL, 'Remove exported files after days');
parent::configure();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$days = $input->getArgument(self::CLEANUP_DAYS);
if (!$days) {
$days = $this->coreParametersHelper->get('clear_export_files_after_days');
}
$dateHelper = new DateTimeHelper();
$date = $dateHelper->getUtcDateTime()->modify('-'.(int) $days.' days');
$cleanUpTimestamp = $date->getTimestamp();
$downloadFolder = $this->coreParametersHelper->get('contact_export_dir');
$contactExportedAllFiles = glob($downloadFolder.'/contacts_export_*');
foreach ($contactExportedAllFiles as $file) {
if (filectime($file) <= $cleanUpTimestamp) {
@unlink($file);
}
}
return ExitCode::SUCCESS;
}
}

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Command;
use Mautic\CoreBundle\Helper\ExitCode;
use Mautic\CoreBundle\ProcessSignal\Exception\SignalCaughtException;
use Mautic\CoreBundle\ProcessSignal\ProcessSignalService;
use Mautic\CoreBundle\Twig\Helper\FormatterHelper;
use Mautic\LeadBundle\Event\ContactExportSchedulerEvent;
use Mautic\LeadBundle\LeadEvents;
use Mautic\LeadBundle\Model\ContactExportSchedulerModel;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
#[AsCommand(
name: ContactScheduledExportCommand::COMMAND_NAME,
description: 'Export contacts which are scheduled in `contact_export_scheduler` table.'
)]
class ContactScheduledExportCommand extends Command
{
private const PICK_SCHEDULED_EXPORTS_LIMIT = 10;
public const COMMAND_NAME = 'mautic:contacts:scheduled_export';
public function __construct(
private ContactExportSchedulerModel $contactExportSchedulerModel,
private EventDispatcherInterface $eventDispatcher,
private FormatterHelper $formatterHelper,
private ProcessSignalService $processSignalService,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption(
'--ids',
null,
InputOption::VALUE_REQUIRED,
'Comma separated contact_export_scheduler ids.'
);
parent::configure();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$this->processSignalService->registerSignalHandler(
fn (int $signal) => $output->writeln(sprintf('Signal %d caught.', $signal))
);
$ids = $this->formatterHelper->simpleCsvToArray($input->getOption('ids'), 'int');
if ($ids) {
$contactExportSchedulers = $this->contactExportSchedulerModel->getRepository()->findBy(['id' => $ids]);
} else {
$contactExportSchedulers = $this->contactExportSchedulerModel->getRepository()
->findBy([], [], self::PICK_SCHEDULED_EXPORTS_LIMIT);
}
$count = 0;
try {
foreach ($contactExportSchedulers as $contactExportScheduler) {
$contactExportSchedulerEvent = new ContactExportSchedulerEvent($contactExportScheduler);
$this->eventDispatcher->dispatch($contactExportSchedulerEvent, LeadEvents::CONTACT_EXPORT_PREPARE_FILE);
$this->eventDispatcher->dispatch($contactExportSchedulerEvent, LeadEvents::CONTACT_EXPORT_SEND_EMAIL);
$this->eventDispatcher->dispatch($contactExportSchedulerEvent, LeadEvents::POST_CONTACT_EXPORT_SEND_EMAIL);
++$count;
}
$output->writeln('Contact export email(s) sent: '.$count);
return ExitCode::SUCCESS;
} catch (SignalCaughtException) {
return ExitCode::TERMINATED;
}
}
}

View File

@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Command;
use Mautic\CoreBundle\Service\ProcessQueue;
use Mautic\LeadBundle\Deduplicate\ContactDeduper;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\Process\Process;
use Symfony\Component\Stopwatch\Stopwatch;
#[AsCommand(
name: DeduplicateCommand::NAME,
description: 'Merge contacts based on same unique identifiers'
)]
class DeduplicateCommand extends Command
{
public const NAME = 'mautic:contacts:deduplicate';
public function __construct(
private ContactDeduper $contactDeduper,
private ParameterBagInterface $params,
) {
parent::__construct();
}
public function configure(): void
{
parent::configure();
$this
->addOption(
'--newer-into-older',
null,
InputOption::VALUE_NONE,
'By default, this command will merge older contacts and activity into the newer. Use this flag to reverse that behavior.'
)
->addOption(
'--batch',
null,
InputOption::VALUE_REQUIRED,
'How many contact duplicates to process at once. Defaults to 100.',
100
)
->addOption(
'--processes',
null,
InputOption::VALUE_REQUIRED,
'The commands can run in multiple PHP processes. This option defines how many processes to run. Defaults to 1.',
1
)
->setHelp(
<<<'EOT'
The <info>%command.name%</info> command will dedpulicate contacts based on unique identifier values.
<info>php %command.full_name%</info>
EOT
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$newerIntoOlder = (bool) $input->getOption('newer-into-older');
$batch = (int) $input->getOption('batch');
$processes = (int) $input->getOption('processes');
$uniqueFields = $this->contactDeduper->getUniqueFields('lead');
$duplicateCount = $this->contactDeduper->countDuplicatedContacts(array_keys($uniqueFields));
$stopwatch = new Stopwatch();
if (!$duplicateCount) {
$output->writeln('<error>No contacts to deduplicate.</error>');
return Command::FAILURE;
}
$stopwatch->start('deduplicate');
$output->writeln('Deduplicating contacts based on unique identifiers: '.implode(', ', $uniqueFields));
$output->writeln("{$duplicateCount} contacts found to deduplicate");
$processQueue = new ProcessQueue($processes);
$processCount = (int) ceil($duplicateCount / $batch);
$output->writeln('');
$output->writeln("Finding duplicates and creating processes for deduplication. {$processCount} processes will be queued.");
$contactIds = $this->contactDeduper->getDuplicateContactIds(array_keys($uniqueFields));
$contactIdChunks = array_chunk($contactIds, $batch);
foreach ($contactIdChunks as $contactIdBatch) {
$command = [
$this->params->get('kernel.project_dir').'/bin/console',
DeduplicateIdsCommand::NAME,
'--contact-ids',
implode(',', $contactIdBatch),
'-e',
MAUTIC_ENV,
];
if ($newerIntoOlder) {
$command[] = '--newer-into-older';
}
$envParams = [
'db_table_prefix' => MAUTIC_TABLE_PREFIX,
'contact_unique_identifiers_operator' => $this->params->get('mautic.contact_unique_identifiers_operator'),
];
$processQueue->enqueue(new Process($command, null, ['MAUTIC_CONFIG_PARAMETERS' => json_encode($envParams)]));
}
$output->writeln('');
$output->writeln("Starting to execute the {$processCount} processes for deduplication. {$processes} processes will be executed in parallel.");
$progressBar = new ProgressBar($output, $processCount);
$progressBar->setFormat('debug');
$progressBar->start();
$processQueue->refresh();
while ($processQueue->isProcessing()) {
usleep(100);
$processQueue->refresh();
$progressBar->setProgress($processQueue->getProcessedCount());
}
$output->writeln('');
$output->writeln('');
$output->writeln('All processes have finished. The output of each process is below.');
foreach ($processQueue->getProcessed() as $process) {
$output->writeln("<comment>{$process->getCommandLine()}</comment>");
if (0 === $process->getExitCode()) {
$output->writeln("<info>{$process->getOutput()}</info>");
} else {
$output->writeln("<error>{$process->getErrorOutput()}</error>");
}
}
$progressBar->finish();
$event = $stopwatch->stop('deduplicate');
$output->writeln('');
$output->writeln("Duration: {$event->getDuration()} ms, Memory: {$event->getMemory()} bytes");
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Command;
use Mautic\LeadBundle\Deduplicate\ContactDeduper;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Stopwatch\Stopwatch;
#[AsCommand(
name: DeduplicateIdsCommand::NAME,
description: 'Merge contacts based on same unique identifiers'
)]
class DeduplicateIdsCommand extends Command
{
public const NAME = 'mautic:contacts:deduplicate:ids';
public function __construct(
private ContactDeduper $contactDeduper,
) {
parent::__construct();
}
public function configure(): void
{
parent::configure();
$this
->addOption(
'--newer-into-older',
null,
InputOption::VALUE_NONE,
'By default, this command will merge older contacts and activity into the newer. Use this flag to reverse that behavior.'
)
->addOption(
'--contact-ids',
null,
InputOption::VALUE_REQUIRED,
'Comma separated list of contact IDs to deduplicate. If not provided, all contacts will be deduplicated. Example: --contact-ids=23,3,11'
)
->setHelp(
<<<'EOT'
The <info>%command.name%</info> command will dedpulicate contacts based on unique identifier values.
<info>php %command.full_name%</info>
EOT
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$newerIntoOlder = (bool) $input->getOption('newer-into-older');
$contactIds = array_filter(explode(',', $input->getOption('contact-ids')));
$duplicateCount = count($contactIds);
$progressBar = new ProgressBar($output, $duplicateCount);
$stopwatch = new Stopwatch();
if (!$contactIds) {
$output->writeln('<error>No contacts to deduplicate.</error>');
return Command::FAILURE;
}
$output->writeln("{$duplicateCount} contacts passed to deduplicate");
$progressBar->setFormat('debug');
$progressBar->start();
$stopwatch->start('deduplicate');
$contacts = $this->contactDeduper->getContactsByIds($contactIds);
$this->contactDeduper->deduplicateContactBatch($contacts, $newerIntoOlder, fn () => $progressBar->advance());
$progressBar->finish();
$event = $stopwatch->stop('deduplicate');
$output->writeln("Duration: {$event->getDuration()} ms, Memory: {$event->getMemory()} bytes");
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Command;
use Doctrine\ORM\Exception\ORMException;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\LeadBundle\Entity\CompanyLeadRepository;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
#[AsCommand(
name: DeleteContactSecondaryCompaniesCommand::NAME,
description: "Deletes all contact\'s secondary companies."
)]
class DeleteContactSecondaryCompaniesCommand extends Command
{
public const NAME = 'mautic:contact:delete:secondary-companies';
public function __construct(private LoggerInterface $logger, private TranslatorInterface $translator, private CoreParametersHelper $coreParametersHelper, private CompanyLeadRepository $companyLeadsRepository)
{
parent::__construct();
}
/**
* {@inheritdoc}
*/
protected function configure(): void
{
$this
->setHelp(
<<<'EOT'
The <info>%command.name%</info> command deletes non-primary companies of every contact.
<info>php %command.full_name%</info>
EOT
);
}
/**
* {@inheritdoc}
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$allowMultiple = $this->coreParametersHelper->get('contact_allow_multiple_companies');
// We process only if the config is set to false
if ($allowMultiple) {
$output->writeln($this->translator->trans('mautic.lead.command.delete_contact_secondary_company.allow_multiple_enabled'));
return Command::SUCCESS;
}
try {
$this->companyLeadsRepository->removeAllSecondaryCompanies();
} catch (ORMException $e) {
$errorMessage = $this->translator->trans('mautic.lead.command.error', ['%name%' => self::NAME, '%error%' => $e->getMessage()]);
$output->writeln($errorMessage);
$this->logger->error($errorMessage);
}
$output->writeln($this->translator->trans('mautic.lead.command.delete_contact_secondary_company.success'));
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,187 @@
<?php
namespace Mautic\LeadBundle\Command;
use Mautic\CoreBundle\Model\NotificationModel;
use Mautic\CoreBundle\ProcessSignal\ProcessSignalService;
use Mautic\LeadBundle\Entity\Import;
use Mautic\LeadBundle\Exception\ImportDelayedException;
use Mautic\LeadBundle\Exception\ImportFailedException;
use Mautic\LeadBundle\Helper\Progress;
use Mautic\LeadBundle\Model\ImportModel;
use Mautic\UserBundle\Security\UserTokenSetter;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* CLI Command to import data.
*/
#[AsCommand(
name: ImportCommand::COMMAND_NAME,
description: 'Imports data to Mautic'
)]
class ImportCommand extends Command
{
public const COMMAND_NAME = 'mautic:import';
public function __construct(
private TranslatorInterface $translator,
private ImportModel $importModel,
private ProcessSignalService $processSignalService,
private UserTokenSetter $userTokenSetter,
private LoggerInterface $logger,
private NotificationModel $notificationModel,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('--id', '-i', InputOption::VALUE_OPTIONAL, 'Specific ID to import. Defaults to next in the queue.', false)
->addOption('--limit', '-l', InputOption::VALUE_OPTIONAL, 'Maximum number of records to import for this script execution.', 0)
->setHelp(
<<<'EOT'
The <info>%command.name%</info> command starts to import CSV files when some are created.
<info>php %command.full_name%</info>
EOT
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$start = microtime(true);
$progress = new Progress($output);
$id = (int) $input->getOption('id');
$limit = (int) $input->getOption('limit');
$this->processSignalService->registerSignalHandler(fn (int $signal) => $output->writeln(sprintf('Signal %d caught.', $signal)));
if ($id) {
$import = $this->importModel->getEntity($id);
// This specific import was not found
if (!$import) {
$output->writeln('<error>'.$this->translator->trans('mautic.core.error.notfound', [], 'flashes').'</error>');
return Command::FAILURE;
}
} else {
$import = $this->importModel->getImportToProcess();
// No import waiting in the queue. Finish silently.
if (null === $import) {
return Command::SUCCESS;
}
}
$user = $import->getModifiedBy();
if (!$user) {
throw new \RuntimeException('Import does not have "modifiedBy" property set.');
}
$this->userTokenSetter->setUser($user);
$output->writeln('<info>'.$this->translator->trans(
'mautic.lead.import.is.starting',
[
'%id%' => $import->getId(),
'%lines%' => $import->getLineCount(),
]
).'</info>');
try {
$this->importModel->beginImport($import, $progress, $limit, $start);
} catch (ImportFailedException $e) {
$output->writeln('<error>'.$this->translator->trans(
'mautic.lead.import.failed',
[
'%reason%' => $import->getStatusInfo(),
]
).'</error>');
$this->logError($import, $e);
$this->notify(
$import,
$start,
$this->translator->trans('mautic.lead.import.failed', ['%reason%' => $import->getStatusInfo()]),
'error'
);
return Command::FAILURE;
} catch (ImportDelayedException $e) {
$output->writeln('<info>'.$this->translator->trans(
'mautic.lead.import.delayed',
[
'%reason%' => $import->getStatusInfo(),
]
).'</info>');
$this->logError($import, $e);
$this->notify(
$import,
$start,
$this->translator->trans('mautic.lead.import.delayed', ['%reason%' => $import->getStatusInfo()]),
'warning'
);
return Command::FAILURE;
}
// Success
$output->writeln('<info>'.$this->translator->trans(
'mautic.lead.import.result',
[
'%lines%' => $import->getProcessedRows(),
'%created%' => $import->getInsertedCount(),
'%updated%' => $import->getUpdatedCount(),
'%ignored%' => $import->getIgnoredCount(),
'%time%' => round(microtime(true) - $start, 2),
]
).'</info>');
// Notification is now handled in ImportModel::beginImport to avoid duplicates
// and to include the link to the imported file
return Command::SUCCESS;
}
private function logError(Import $import, \Exception $exception): void
{
$message = ' Import id: '.$import->getId();
$message .= ' Import Status: '.$import->getStatus();
$message .= ' Reason: '.$import->getStatusInfo();
$message .= ' Exception: '.$exception;
$this->logger->warning($message);
}
private function notify(Import $import, float $start, string $header, string $type = 'info'): void
{
$this->notificationModel->addNotification(
$this->translator->trans(
'mautic.lead.import.result',
[
'%lines%' => $import->getProcessedRows(),
'%created%' => $import->getInsertedCount(),
'%updated%' => $import->getUpdatedCount(),
'%ignored%' => $import->getIgnoredCount(),
'%time%' => round(microtime(true) - $start, 2),
]
),
$type,
false,
$header,
'ri-download-line'
);
}
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Command;
use Mautic\CoreBundle\Helper\ExitCode;
use Mautic\LeadBundle\Entity\LeadListRepository;
use Mautic\LeadBundle\Helper\SegmentCountCacheHelper;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class SegmentCountCacheCommand extends Command
{
public const COMMAND_NAME = 'lead:list:count-cache-update';
public function __construct(
private LeadListRepository $leadListRepository,
private SegmentCountCacheHelper $segmentCountCacheHelper,
) {
parent::__construct();
}
/**
* {@inheritdoc}
*/
protected function configure(): void
{
$this->setName(self::COMMAND_NAME)
->setDescription('Update segment count cache for changed segments.');
}
/**
* @throws InvalidArgumentException
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$segmentsForRecount = $this->getAllSegmentsForRecount();
if (count($segmentsForRecount) > 0) {
$totalLeadCount = $this->leadListRepository->getLeadCount($segmentsForRecount);
if (!is_array($totalLeadCount)) {
$totalLeadCount = [$segmentsForRecount[0] => $totalLeadCount];
}
foreach ($totalLeadCount as $segmentId => $leadCount) {
$this->segmentCountCacheHelper->setSegmentContactCount((int) $segmentId, (int) $leadCount);
}
}
$output->writeln(sprintf('<info>%s segment\'s contact count have been updated.</info>', count($segmentsForRecount)));
return ExitCode::SUCCESS;
}
/**
* @return int[]
*/
private function getAllSegmentsForRecount(): array
{
$segmentsForRecount = [];
$segmentIds = $this->leadListRepository->getLists();
foreach ($segmentIds as $segment) {
$segmentId = $segment['id'];
if ($this->segmentCountCacheHelper->hasSegmentIdForReCount($segmentId)) {
$segmentsForRecount[] = $segmentId;
}
}
return $segmentsForRecount;
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Command;
use Mautic\CoreBundle\Command\ModeratedCommand;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\PathsHelper;
use Mautic\LeadBundle\Event\GetStatDataEvent;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
final class SegmentStatCommand extends ModeratedCommand
{
public function __construct(
private EventDispatcherInterface $dispatcher,
PathsHelper $pathsHelper,
CoreParametersHelper $coreParametersHelper,
) {
parent::__construct($pathsHelper, $coreParametersHelper);
}
protected function configure(): void
{
$this
->setName('mautic:segments:stat')
->setDescription('Gather Segment Statistics');
parent::configure();
}
/**
* {@inheritdoc}
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$event = new GetStatDataEvent();
$this->dispatcher->dispatch($event);
if (empty($event->getResults())) {
$io->write('There is no segment to show!!');
} else {
$io->table([
'Title',
'Id',
'IsPublished',
'IsUsed',
],
$event->getResults()
);
}
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,266 @@
<?php
namespace Mautic\LeadBundle\Command;
use Mautic\CoreBundle\Command\ModeratedCommand;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\PathsHelper;
use Mautic\LeadBundle\Entity\LeadList;
use Mautic\LeadBundle\Model\ListModel;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\NullOutput;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
#[AsCommand(
name: UpdateLeadListsCommand::NAME,
description: 'Update contacts in smart segments based on new contact data.',
aliases: ['mautic:segments:rebuild']
)]
class UpdateLeadListsCommand extends ModeratedCommand
{
public const NAME = 'mautic:segments:update';
public function __construct(
private ListModel $listModel,
private TranslatorInterface $translator,
PathsHelper $pathsHelper,
CoreParametersHelper $coreParametersHelper,
) {
parent::__construct($pathsHelper, $coreParametersHelper);
}
protected function configure()
{
$this
->addOption(
'--batch-limit',
'-b',
InputOption::VALUE_OPTIONAL,
'Set batch size of contacts to process per round. Defaults to 300.',
300
)
->addOption(
'--max-contacts',
'-m',
InputOption::VALUE_OPTIONAL,
'Set max number of contacts to process per segment for this script execution. Defaults to all.',
false
)
->addOption(
'--list-id',
'-i',
InputOption::VALUE_OPTIONAL,
'Specific ID to rebuild. Defaults to all.',
false
)
->addOption(
'--timing',
'-tm',
InputOption::VALUE_OPTIONAL,
'Measure timing of build with output to CLI .',
false
)
->addOption(
'exclude',
'd',
InputOption::VALUE_IS_ARRAY | InputOption::VALUE_OPTIONAL,
'Exclude a specific segment from being rebuilt. Otherwise, all segments will be rebuilt.',
[]
);
parent::configure();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$id = $input->getOption('list-id');
$batch = $input->getOption('batch-limit');
$max = $input->getOption('max-contacts') ? (int) $input->getOption('max-contacts') : null;
$enableTimeMeasurement = (bool) $input->getOption('timing');
$output = ($input->getOption('quiet')) ? new NullOutput() : $output;
$excludeSegments = $input->getOption('exclude');
if (!$this->checkRunStatus($input, $output, $id)) {
return \Symfony\Component\Console\Command\Command::SUCCESS;
}
if ($enableTimeMeasurement) {
$startTime = microtime(true);
}
if ($id) {
$list = $this->listModel->getEntity($id);
if (!$list) {
$output->writeln('<error>'.$this->translator->trans('mautic.lead.list.rebuild.not_found', ['%id%' => $id]).'</error>');
return \Symfony\Component\Console\Command\Command::FAILURE;
}
// Track already rebuilt lists to avoid rebuilding them multiple times
$rebuiltLists = [];
// First check if this segment has dependencies and rebuild them
if ($list->hasFilterTypeOf('leadlist')) {
$this->rebuildDependentSegments($list, $rebuiltLists, $batch, $max, $output, $enableTimeMeasurement, [], $excludeSegments);
}
// Add the current list ID to the rebuilt lists to avoid rebuilding it again
$rebuiltLists[] = (int) $list->getId();
$this->rebuildSegment($list, $batch, $max, $output, $enableTimeMeasurement);
} else {
$filter = [
'iterable_mode' => true,
];
if (is_array($excludeSegments) && count($excludeSegments) > 0) {
$filter['filter'] = [
'force' => [
[
'expr' => 'notIn',
'column' => $this->listModel->getRepository()->getTableAlias().'.id',
'value' => $excludeSegments,
],
],
];
}
$rebuiltLists = [];
$leadLists = $this->listModel->getEntities($filter);
/** @var LeadList $leadList */
foreach ($leadLists as $leadList) {
$listId = $leadList->getId();
// Skip if already rebuilt
if (in_array($listId, $rebuiltLists)) {
continue;
}
// Process any dependent segments first (segments that are used as filters in this segment)
if ($leadList->hasFilterTypeOf('leadlist')) {
$this->rebuildDependentSegments($leadList, $rebuiltLists, $batch, $max, $output, $enableTimeMeasurement, [], $excludeSegments);
}
// Add the current list ID to the rebuilt lists to avoid rebuilding it again
$rebuiltLists[] = $listId;
// Rebuild the current segment
$this->rebuildSegment($leadList, $batch, $max, $output, $enableTimeMeasurement);
}
}
$this->completeRun();
if ($enableTimeMeasurement) {
$totalTime = round(microtime(true) - $startTime, 2);
$output->writeln('<fg=magenta>'.$this->translator->trans('mautic.lead.list.rebuild.total.time', ['%time%' => $totalTime]).'</>'."\n");
}
return \Symfony\Component\Console\Command\Command::SUCCESS;
}
/**
* @param array<int> $rebuiltLists List of segment IDs that have already been rebuilt
* @param array<int> $dependencyChain Chain of segment IDs to detect circular dependencies
* @param array<int|string> $excludeSegments List of segment IDs to exclude from rebuilding
*
* @param-out array<int> $rebuiltLists Updated list of segment IDs that have been rebuilt
*/
private function rebuildDependentSegments(
LeadList $leadList,
array &$rebuiltLists,
int $batch,
?int $max,
OutputInterface $output,
bool $enableTimeMeasurement,
array $dependencyChain = [],
array $excludeSegments = [],
): void {
// Track the current segment in our dependency chain
$currentId = $leadList->getId();
$dependencyChain[] = $currentId;
foreach ($leadList->getFilters() as $filter) {
if ('leadlist' === $filter['type']) {
foreach ($filter['filter'] ?? [] as $dependentListId) {
$dependentListId = (int) $dependentListId;
// Skip if already rebuilt or in exclude list
if (in_array($dependentListId, $rebuiltLists) || in_array($dependentListId, $excludeSegments)) {
continue;
}
// Check for circular dependency
if (in_array($dependentListId, $dependencyChain)) {
$output->writeln(
'<error>'.$this->translator->trans(
'Circular dependency detected in segment chain: %chain%',
['%chain%' => implode(' → ', array_merge($dependencyChain, [$dependentListId]))]
).'</error>'
);
continue; // Skip this dependency to prevent infinite recursion
}
$dependentLeadList = $this->listModel->getEntity($dependentListId);
if (!$dependentLeadList) {
continue; // Skip if the dependent segment doesn't exist - it may have been deleted
}
// Check if this dependent segment has its own dependencies
if ($dependentLeadList->hasFilterTypeOf('leadlist')) {
// Recursively process this segment's dependencies first, passing the current chain
$this->rebuildDependentSegments(
$dependentLeadList,
$rebuiltLists,
$batch,
$max,
$output,
$enableTimeMeasurement,
$dependencyChain,
$excludeSegments
);
}
// Now rebuild this dependent segment
$this->rebuildSegment($dependentLeadList, $batch, $max, $output, $enableTimeMeasurement);
$rebuiltLists[] = $dependentListId;
}
}
}
}
private function rebuildSegment(LeadList $segment, int $batch, ?int $max, OutputInterface $output, bool $enableTimeMeasurement = false): void
{
if (!$segment->isPublished()) {
return;
}
$output->writeln('<info>'.$this->translator->trans('mautic.lead.list.rebuild.rebuilding', ['%id%' => $segment->getId()]).'</info>');
$startTime = microtime(true);
$processed = $this->listModel->rebuildListLeads($segment, $batch, $max, $output);
$rebuildTime = round(microtime(true) - $startTime, 2);
if (0 >= (int) $max) {
// Only full segment rebuilds count
$segment->setLastBuiltDateToCurrentDatetime();
$segment->setLastBuiltTime($rebuildTime);
$this->listModel->saveEntity($segment);
}
$output->writeln(
'<comment>'.$this->translator->trans('mautic.lead.list.rebuild.leads_affected', ['%leads%' => $processed]).'</comment>'
);
if ($enableTimeMeasurement) {
$output->writeln('<fg=cyan>'.$this->translator->trans(
'mautic.lead.list.rebuild.contacts.time',
['%time%' => $rebuildTime]
).'</>'."\n");
}
}
}