<?php
namespace App\Command;
use App\DBAL\Types\LeadType;
use App\DBAL\Types\MessageAutomationEventType;
use App\Entity\Customer\Customer;
use App\Entity\Lead\Lead;
use App\Entity\Lead\Prospect;
use App\Entity\MessageAutomation;
use App\Entity\MessageAutomationLog;
use App\Events;
use App\Repository\CronScheduleRepository;
use Doctrine\Persistence\ManagerRegistry;
use EWZ\SymfonyAdminBundle\Command\AbstractCronCommand;
use EWZ\SymfonyAdminBundle\Util\CommandRunner;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\HttpKernel\KernelInterface;
/**
* Run message automations with offsetDays > 0 (follow-ups).
*/
class AutomationsCommand extends AbstractCronCommand
{
/** @var KernelInterface */
private $kernel;
/** @var ManagerRegistry */
private $managerRegistry;
/**
* @param CronScheduleRepository $cronScheduleRepository
* @param KernelInterface $kernel
* @param ManagerRegistry $managerRegistry
*/
public function __construct(
CronScheduleRepository $cronScheduleRepository,
KernelInterface $kernel,
ManagerRegistry $managerRegistry
) {
parent::__construct($cronScheduleRepository);
$this->kernel = $kernel;
$this->managerRegistry = $managerRegistry;
}
/**
* {@inheritdoc}
*/
public function getDefaultDefinition(): string
{
return '*/30 * * * *';
}
/**
* {@inheritdoc}
*/
public function isActive(): bool
{
return true;
}
/**
* {@inheritdoc}
*/
protected function configure()
{
$this
->setName('admin:cron:automations:run')
->setDescription('Run message automations follow-ups (offsetDays > 0)')
;
}
/**
* {@inheritdoc}
*/
protected function processCommand(InputInterface $input, OutputInterface $output): void
{
// always run retries (log-driven)
$this->runRetries();
/** @var MessageAutomation[] $automations */
$automations = $this->managerRegistry
->getRepository(MessageAutomation::class)
->searchAll([
'enabled' => true,
]);
if (empty($automations)) {
return;
}
$now = new \DateTime();
// normalize seconds to avoid cron jitter skipping exact times
$now->setTime((int) $now->format('H'), (int) $now->format('i'), 0);
$today = (clone $now)->setTime(0, 0, 0); // anchor day windows at midnight (prevents sliding 24h bugs)
// define a range around the current time
$windowSlack = 5; // adjust the range: ±5 minutes
$from = (clone $now)->modify(sprintf('-%d minutes', $windowSlack)); // start of the time window
$to = (clone $now)->modify(sprintf('+%d minutes', $windowSlack)); // end of the time window
// group by event + offsetDays (only automations due in current sendTime window)
$groups = [];
foreach ($automations as $automation) {
$offsetDays = (int) ($automation->getOffsetDays() ?? 0);
if ($offsetDays < 0) {
continue;
}
$sendTime = $automation->getSendTime();
// check that sendTime is set (it's required for time-window scheduling)
if (null === $sendTime) {
continue;
}
// "Synchronize" sendTime to today, because sendTime doesn't include a date
$sendTime = (clone $today)->setTime((int) $sendTime->format('H'), (int) $sendTime->format('i'), 0);
// skip if sendTime is outside the current ±slack window
if ($sendTime < $from || $sendTime > $to) {
continue;
}
$eventType = (string) $automation->getEvent();
if ('' === $eventType) {
continue;
}
$key = sprintf('%s|%d', $eventType, $offsetDays);
if (!isset($groups[$key])) {
$groups[$key] = [
'eventType' => $eventType,
'offsetDays' => $offsetDays,
'automationIds' => [],
];
}
$groups[$key]['automationIds'][] = (int) $automation->getId();
}
if (empty($groups)) {
return;
}
// process highest offset first so the newest follow-up wins
$groups = array_values($groups);
usort($groups, static function (array $a, array $b): int {
$o = ((int) $b['offsetDays']) <=> ((int) $a['offsetDays']); // DESC
return 0 !== $o ? $o : strcmp((string) $a['eventType'], (string) $b['eventType']);
});
// prevent the same entity from being processed multiple times in one run (highest offset wins)
$handledByEvent = [];
foreach ($groups as $group) {
$eventType = (string) $group['eventType'];
$offsetDays = (int) $group['offsetDays'];
$automationIds = $group['automationIds'];
if (!isset($handledByEvent[$eventType])) {
$handledByEvent[$eventType] = [];
}
$fromDate = (clone $today)->modify(sprintf('-%d day', $offsetDays));
$toDate = (clone $fromDate)->modify('+1 day');
if (MessageAutomationEventType::PROSPECT_CREATED === $eventType) {
$this->runProspectCreated($fromDate, $toDate, $offsetDays, $automationIds, $handledByEvent[$eventType]);
continue;
}
if (MessageAutomationEventType::LEAD_CREATED === $eventType) {
$this->runLeadCreated($fromDate, $toDate, $offsetDays, $automationIds, $handledByEvent[$eventType]);
continue;
}
if (MessageAutomationEventType::OPPORTUNITY_CREATED === $eventType) {
$this->runOpportunityCreated($fromDate, $toDate, $offsetDays, $automationIds, $handledByEvent[$eventType]);
continue;
}
if (MessageAutomationEventType::CUSTOMER_CREATED === $eventType) {
$this->runCustomerCreated($fromDate, $toDate, $offsetDays, $automationIds, $handledByEvent[$eventType]);
continue;
}
}
}
/**
* Prospect created follow-ups: enabled = true (use Prospect.createdAt).
*
* @param \DateTime $from
* @param \DateTime $to
* @param int $offsetDays
* @param array $automationIds
* @param array $handledIds
*/
private function runProspectCreated(
\DateTime $from,
\DateTime $to,
int $offsetDays,
array $automationIds,
array &$handledIds
): void {
/** @var Prospect[] $items */
$items = $this->managerRegistry
->getRepository(Prospect::class)
->searchAll([
'enabled' => true,
'createdAt' => [
'from' => $from,
'to' => $to,
],
]);
foreach ($items as $prospect) {
$id = (string) $prospect->getId();
if (isset($handledIds[$id])) {
continue;
}
$handledIds[$id] = true;
CommandRunner::runCommand(
'admin:automation:run-one',
array_merge(
[
str_replace('\\', '\\\\', Prospect::class),
$prospect->getId(),
$offsetDays,
implode(',', $automationIds),
Events::EMAIL_PROSPECT_CREATED,
],
['--env' => $this->kernel->getEnvironment()]
)
);
}
}
/**
* Lead created follow-ups: enabled = true and customer = null (use Lead.createdAt).
*
* @param \DateTime $from
* @param \DateTime $to
* @param int $offsetDays
* @param array $automationIds
* @param array $handledIds
*/
private function runLeadCreated(
\DateTime $from,
\DateTime $to,
int $offsetDays,
array $automationIds,
array &$handledIds
): void {
/** @var Lead[] $items */
$items = $this->managerRegistry
->getRepository(Lead::class)
->searchAll([
'enabled' => true,
'customer' => null,
'createdAt' => [
'from' => $from,
'to' => $to,
],
]);
foreach ($items as $lead) {
// skip form leads (same rule as real-time dispatch)
if (LeadType::FORM === $lead->getType()) {
continue;
}
$id = (string) $lead->getId();
if (isset($handledIds[$id])) {
continue;
}
$handledIds[$id] = true;
CommandRunner::runCommand(
'admin:automation:run-one',
array_merge(
[
str_replace('\\', '\\\\', Lead::class),
$lead->getId(),
$offsetDays,
implode(',', $automationIds),
Events::EMAIL_LEAD_CREATED,
],
['--env' => $this->kernel->getEnvironment()]
)
);
}
}
/**
* Opportunity created follow-ups: opportunity createdAt is within range and convertedAt is null.
*
* @param \DateTime $from
* @param \DateTime $to
* @param int $offsetDays
* @param array $automationIds
* @param array $handledIds
*/
private function runOpportunityCreated(
\DateTime $from,
\DateTime $to,
int $offsetDays,
array $automationIds,
array &$handledIds
): void {
/** @var Customer[] $items */
$items = $this->managerRegistry
->getRepository(Customer::class)
->searchAll([
'convertedAt' => null,
'createdAt' => [
'from' => $from,
'to' => $to,
],
]);
foreach ($items as $customer) {
if ($customer->isCustomer()) {
continue;
}
$id = (string) $customer->getId();
if (isset($handledIds[$id])) {
continue;
}
$handledIds[$id] = true;
CommandRunner::runCommand(
'admin:automation:run-one',
array_merge(
[
str_replace('\\', '\\\\', Customer::class),
$customer->getId(),
$offsetDays,
implode(',', $automationIds),
Events::EMAIL_OPPORTUNITY_CREATED,
],
['--env' => $this->kernel->getEnvironment()]
)
);
}
}
/**
* Customer created follow-ups: Customer.convertedAt is within range (only if the record is a real customer).
*
* @param \DateTime $from
* @param \DateTime $to
* @param int $offsetDays
* @param array $automationIds
* @param array $handledIds
*/
private function runCustomerCreated(
\DateTime $from,
\DateTime $to,
int $offsetDays,
array $automationIds,
array &$handledIds
): void {
/** @var Customer[] $items */
$items = $this->managerRegistry
->getRepository(Customer::class)
->searchAll([
'convertedAt' => [
'from' => $from,
'to' => $to,
],
]);
foreach ($items as $customer) {
if (!$customer->isCustomer()) {
continue;
}
$id = (string) $customer->getId();
if (isset($handledIds[$id])) {
continue;
}
$handledIds[$id] = true;
CommandRunner::runCommand(
'admin:automation:run-one',
array_merge(
[
str_replace('\\', '\\\\', Customer::class),
$customer->getId(),
$offsetDays,
implode(',', $automationIds),
Events::EMAIL_CUSTOMER_CREATED,
],
['--env' => $this->kernel->getEnvironment()]
)
);
}
}
/**
* Spawn retry jobs for FAILED logs that are due (attempts < 3).
*/
private function runRetries(): void
{
/** @var MessageAutomationLog[] $logs */
$logs = $this->managerRegistry
->getRepository(MessageAutomationLog::class)
->findRetryable();
if (empty($logs)) {
return;
}
foreach ($logs as $log) {
if ($log->getAttempts() >= 3 || !$log->getAutomation()) {
continue;
}
$eventName = $this->eventNameFromEventType((string) $log->getEvent());
if (!$eventName) {
continue;
}
CommandRunner::runCommand(
'admin:automation:run-one',
array_merge(
[
str_replace('\\', '\\\\', (string) $log->getEntityClass()),
$log->getEntityId(),
$log->getOffsetDays(),
$log->getAutomation()->getId(),
$eventName,
],
['--env' => $this->kernel->getEnvironment()]
)
);
}
}
/**
* Map MessageAutomationEventType -> dispatched Events::* name.
*
* @param string $eventType
*
* @return string|null
*/
private function eventNameFromEventType(string $eventType): ?string
{
switch ($eventType) {
case MessageAutomationEventType::PROSPECT_CREATED:
return Events::EMAIL_PROSPECT_CREATED;
case MessageAutomationEventType::LEAD_CREATED:
return Events::EMAIL_LEAD_CREATED;
case MessageAutomationEventType::OPPORTUNITY_CREATED:
return Events::EMAIL_OPPORTUNITY_CREATED;
case MessageAutomationEventType::CUSTOMER_CREATED:
return Events::EMAIL_CUSTOMER_CREATED;
default:
return null;
}
}
}