This commit is contained in:
emmymayo
2025-02-05 23:15:46 +01:00
commit 7269c99357
16995 changed files with 3389680 additions and 0 deletions
@@ -0,0 +1,52 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\API;
if (!defined('ABSPATH')) exit;
use MailPoet\API\REST\API as MailPoetApi;
use MailPoet\Automation\Engine\Hooks;
use MailPoet\Automation\Engine\WordPress;
class API extends MailPoetApi {
/** @var MailPoetApi */
private $api;
/** @var WordPress */
private $wordPress;
public function __construct(
MailPoetApi $api,
WordPress $wordPress
) {
$this->api = $api;
$this->wordPress = $wordPress;
}
public function initialize(): void {
$this->wordPress->addAction(MailPoetApi::REST_API_INIT_ACTION, function () {
$this->wordPress->doAction(Hooks::API_INITIALIZE, [$this]);
});
}
public function registerGetRoute(string $route, string $endpoint): void {
$this->api->registerGetRoute($route, $endpoint);
}
public function registerPostRoute(string $route, string $endpoint): void {
$this->api->registerPostRoute($route, $endpoint);
}
public function registerPutRoute(string $route, string $endpoint): void {
$this->api->registerPutRoute($route, $endpoint);
}
public function registerPatchRoute(string $route, string $endpoint): void {
$this->api->registerPatchRoute($route, $endpoint);
}
public function registerDeleteRoute(string $route, string $endpoint): void {
$this->api->registerDeleteRoute($route, $endpoint);
}
}
@@ -0,0 +1,15 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\API;
if (!defined('ABSPATH')) exit;
use MailPoet\API\REST\Endpoint as MailPoetEndpoint;
use MailPoet\Automation\Engine\Engine;
abstract class Endpoint extends MailPoetEndpoint {
public function checkPermissions(): bool {
return current_user_can(Engine::CAPABILITY_MANAGE_AUTOMATIONS);
}
}
@@ -0,0 +1 @@
<?php
@@ -0,0 +1,50 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Builder;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Automation;
use MailPoet\Automation\Engine\Exceptions;
use MailPoet\Automation\Engine\Exceptions\InvalidStateException;
use MailPoet\Automation\Engine\Registry;
use MailPoet\Automation\Engine\Storage\AutomationStorage;
use MailPoet\Automation\Engine\Validation\AutomationValidator;
class CreateAutomationFromTemplateController {
/** @var AutomationStorage */
private $storage;
/** @var AutomationValidator */
private $automationValidator;
/** @var Registry */
private $registry;
public function __construct(
AutomationStorage $storage,
AutomationValidator $automationValidator,
Registry $registry
) {
$this->storage = $storage;
$this->automationValidator = $automationValidator;
$this->registry = $registry;
}
public function createAutomation(string $slug): Automation {
$template = $this->registry->getTemplate($slug);
if (!$template) {
throw Exceptions::automationTemplateNotFound($slug);
}
$automation = $template->createAutomation();
$this->automationValidator->validate($automation);
$automationId = $this->storage->createAutomation($automation);
$savedAutomation = $this->storage->getAutomation($automationId);
if (!$savedAutomation) {
throw new InvalidStateException('Automation not found.');
}
return $savedAutomation;
}
}
@@ -0,0 +1,35 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Builder;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Automation;
use MailPoet\Automation\Engine\Exceptions;
use MailPoet\Automation\Engine\Storage\AutomationStorage;
class DeleteAutomationController {
/** @var AutomationStorage */
private $automationStorage;
public function __construct(
AutomationStorage $automationStorage
) {
$this->automationStorage = $automationStorage;
}
public function deleteAutomation(int $id): Automation {
$automation = $this->automationStorage->getAutomation($id);
if (!$automation) {
throw Exceptions::automationNotFound($id);
}
if ($automation->getStatus() !== Automation::STATUS_TRASH) {
throw Exceptions::automationNotTrashed($id);
}
$this->automationStorage->deleteAutomation($automation);
return $automation;
}
}
@@ -0,0 +1,95 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Builder;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Automation;
use MailPoet\Automation\Engine\Data\NextStep;
use MailPoet\Automation\Engine\Data\Step;
use MailPoet\Automation\Engine\Exceptions;
use MailPoet\Automation\Engine\Exceptions\InvalidStateException;
use MailPoet\Automation\Engine\Storage\AutomationStorage;
use MailPoet\Automation\Engine\WordPress;
use MailPoet\Util\Security;
class DuplicateAutomationController {
/** @var WordPress */
private $wordPress;
/** @var AutomationStorage */
private $automationStorage;
public function __construct(
WordPress $wordPress,
AutomationStorage $automationStorage
) {
$this->wordPress = $wordPress;
$this->automationStorage = $automationStorage;
}
public function duplicateAutomation(int $id): Automation {
$automation = $this->automationStorage->getAutomation($id);
if (!$automation) {
throw Exceptions::automationNotFound($id);
}
$duplicate = new Automation(
$this->getName($automation->getName()),
$this->getSteps($automation->getSteps()),
$this->wordPress->wpGetCurrentUser()
);
$duplicate->setStatus(Automation::STATUS_DRAFT);
$automationId = $this->automationStorage->createAutomation($duplicate);
$savedAutomation = $this->automationStorage->getAutomation($automationId);
if (!$savedAutomation) {
throw new InvalidStateException('Automation not found.');
}
return $savedAutomation;
}
private function getName(string $name): string {
// translators: %s is the original automation name.
$newName = sprintf(__('Copy of %s', 'mailpoet'), $name);
$maxLength = $this->automationStorage->getNameColumnLength();
if (strlen($newName) > $maxLength) {
$append = '…';
return substr($newName, 0, $maxLength - strlen($append)) . $append;
}
return $newName;
}
/**
* @param Step[] $steps
* @return Step[]
*/
private function getSteps(array $steps): array {
$newIds = [];
foreach ($steps as $step) {
$id = $step->getId();
$newIds[$id] = $id === 'root' ? 'root' : $this->getId();
}
$newSteps = [];
foreach ($steps as $step) {
$newId = $newIds[$step->getId()];
$newSteps[$newId] = new Step(
$newId,
$step->getType(),
$step->getKey(),
$step->getArgs(),
array_map(function (NextStep $nextStep) use ($newIds): NextStep {
$nextStepId = $nextStep->getId();
return new NextStep($nextStepId ? $newIds[$nextStepId] : null);
}, $step->getNextSteps())
);
}
return $newSteps;
}
private function getId(): string {
return Security::generateRandomString(16);
}
}
@@ -0,0 +1,184 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Builder;
if (!defined('ABSPATH')) exit;
use ActionScheduler_Store;
use MailPoet\Automation\Engine\Control\ActionScheduler;
use MailPoet\Automation\Engine\Data\Automation;
use MailPoet\Automation\Engine\Data\AutomationRun;
use MailPoet\Automation\Engine\Data\Step;
use MailPoet\Automation\Engine\Exceptions;
use MailPoet\Automation\Engine\Exceptions\UnexpectedValueException;
use MailPoet\Automation\Engine\Hooks;
use MailPoet\Automation\Engine\Storage\AutomationRunStorage;
use MailPoet\Automation\Engine\Storage\AutomationStatisticsStorage;
use MailPoet\Automation\Engine\Storage\AutomationStorage;
use MailPoet\Automation\Engine\Validation\AutomationValidator;
class UpdateAutomationController {
/** @var Hooks */
private $hooks;
/** @var AutomationStorage */
private $storage;
/** @var AutomationStatisticsStorage */
private $statisticsStorage;
/** @var AutomationValidator */
private $automationValidator;
/** @var UpdateStepsController */
private $updateStepsController;
private AutomationRunStorage $automationRunStorage;
private ActionScheduler $actionScheduler;
public function __construct(
Hooks $hooks,
AutomationStorage $storage,
AutomationStatisticsStorage $statisticsStorage,
AutomationValidator $automationValidator,
AutomationRunStorage $automationRunStorage,
ActionScheduler $actionScheduler,
UpdateStepsController $updateStepsController
) {
$this->hooks = $hooks;
$this->storage = $storage;
$this->statisticsStorage = $statisticsStorage;
$this->automationValidator = $automationValidator;
$this->updateStepsController = $updateStepsController;
$this->automationRunStorage = $automationRunStorage;
$this->actionScheduler = $actionScheduler;
}
public function updateAutomation(int $id, array $data): Automation {
$automation = $this->storage->getAutomation($id);
if (!$automation) {
throw Exceptions::automationNotFound($id);
}
$this->validateIfAutomationCanBeUpdated($automation, $data);
if (array_key_exists('name', $data)) {
$automation->setName($data['name']);
}
$originalStatus = $automation->getStatus();
if (array_key_exists('status', $data)) {
$this->checkAutomationStatus($data['status']);
$automation->setStatus($data['status']);
}
if (array_key_exists('steps', $data)) {
$this->validateAutomationSteps($automation, $data['steps']);
$this->updateStepsController->updateSteps($automation, $data['steps']);
foreach ($automation->getSteps() as $step) {
$this->hooks->doAutomationStepBeforeSave($step, $automation);
$this->hooks->doAutomationStepByKeyBeforeSave($step, $automation);
}
}
if (($automation->getStatus() === Automation::STATUS_DRAFT) && ($originalStatus === Automation::STATUS_ACTIVE)) {
$this->unscheduleAutomationRuns($automation);
}
if (array_key_exists('meta', $data)) {
$automation->deleteAllMetas();
foreach ($data['meta'] as $key => $value) {
$automation->setMeta($key, $value);
}
}
$this->hooks->doAutomationBeforeSave($automation);
$this->automationValidator->validate($automation);
$this->storage->updateAutomation($automation);
$automation = $this->storage->getAutomation($id);
if (!$automation) {
throw Exceptions::automationNotFound($id);
}
return $automation;
}
/**
* This is a temporary validation, see MAILPOET-4744
*/
private function validateIfAutomationCanBeUpdated(Automation $automation, array $data): void {
if (
!in_array(
$automation->getStatus(),
[
Automation::STATUS_ACTIVE,
Automation::STATUS_DEACTIVATING,
],
true
)
) {
return;
}
$statistics = $this->statisticsStorage->getAutomationStats($automation->getId());
if ($statistics->getInProgress() === 0) {
return;
}
if (!isset($data['status']) || $data['status'] === $automation->getStatus()) {
throw Exceptions::automationHasActiveRuns($automation->getId());
}
}
private function checkAutomationStatus(string $status): void {
if (!in_array($status, Automation::STATUS_ALL, true)) {
// translators: %s is the status.
throw UnexpectedValueException::create()->withMessage(sprintf(__('Invalid status: %s', 'mailpoet'), $status));
}
}
protected function validateAutomationSteps(Automation $automation, array $steps): void {
$existingSteps = $automation->getSteps();
if (count($steps) !== count($existingSteps)) {
throw Exceptions::automationStructureModificationNotSupported();
}
foreach ($steps as $id => $data) {
$existingStep = $existingSteps[$id] ?? null;
if (!$existingStep || !$this->stepChanged(Step::fromArray($data), $existingStep)) {
throw Exceptions::automationStructureModificationNotSupported();
}
}
}
private function stepChanged(Step $a, Step $b): bool {
$aData = $a->toArray();
$bData = $b->toArray();
unset($aData['args']);
unset($bData['args']);
return $aData === $bData;
}
private function unscheduleAutomationRuns(Automation $automation): void {
$runIds = [];
$runs = $this->automationRunStorage->getAutomationRunsForAutomation($automation);
foreach ($runs as $run) {
if ($run->getStatus() === AutomationRun::STATUS_RUNNING) {
$this->automationRunStorage->updateStatus($run->getId(), AutomationRun::STATUS_CANCELLED);
}
$runIds[$run->getId()] = $run;
}
$actions = $this->actionScheduler->getScheduledActions(['hook' => Hooks::AUTOMATION_STEP, 'status' => ActionScheduler_Store::STATUS_PENDING]);
foreach ($actions as $action) {
$args = $action->get_args();
$automationArgs = reset($args);
if (isset($automationArgs['automation_run_id']) && isset($runIds[$automationArgs['automation_run_id']])) {
$this->actionScheduler->unscheduleAction(Hooks::AUTOMATION_STEP, $args);
}
}
}
}
@@ -0,0 +1,41 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Builder;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Automation;
use MailPoet\Automation\Engine\Data\Step;
use MailPoet\Automation\Engine\Exceptions;
use MailPoet\Automation\Engine\Registry;
class UpdateStepsController {
/** @var Registry */
private $registry;
public function __construct(
Registry $registry
) {
$this->registry = $registry;
}
public function updateSteps(Automation $automation, array $data): Automation {
$steps = [];
foreach ($data as $index => $stepData) {
$step = $this->processStep($stepData, $automation->getStep($stepData['id']));
$steps[$index] = $step;
}
$automation->setSteps($steps);
return $automation;
}
private function processStep(array $data, ?Step $existingStep): Step {
$key = $data['key'];
$step = $this->registry->getStep($key);
if (!$step && $existingStep && $data !== $existingStep->toArray()) {
throw Exceptions::automationStepNotFound($key);
}
return Step::fromArray($data);
}
}
@@ -0,0 +1 @@
<?php
@@ -0,0 +1,35 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Control;
if (!defined('ABSPATH')) exit;
use ActionScheduler_Action;
class ActionScheduler {
private const GROUP_ID = 'mailpoet-automation';
public function enqueue(string $hook, array $args = []): int {
$result = as_enqueue_async_action($hook, $args, self::GROUP_ID);
return is_int($result) ? $result : 0;
}
public function schedule(int $timestamp, string $hook, array $args = []): int {
$result = as_schedule_single_action($timestamp, $hook, $args, self::GROUP_ID);
return is_int($result) ? $result : 0;
}
public function hasScheduledAction(string $hook, array $args = []): bool {
return as_has_scheduled_action($hook, $args, self::GROUP_ID);
}
/** @return ActionScheduler_Action[] */
public function getScheduledActions(array $args = []): array {
return as_get_scheduled_actions(array_merge($args, ['group' => self::GROUP_ID]));
}
public function unscheduleAction(string $hook, array $args = []): ?int {
return as_unschedule_action($hook, $args, self::GROUP_ID);
}
}
@@ -0,0 +1,57 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Control;
if (!defined('ABSPATH')) exit;
use ActionScheduler_CanceledAction;
use MailPoet\Automation\Engine\Data\AutomationRunLog;
use MailPoet\Automation\Engine\Exceptions;
use MailPoet\Automation\Engine\Hooks;
use MailPoet\Automation\Engine\Storage\AutomationRunLogStorage;
class AutomationController {
private ActionScheduler $actionScheduler;
private AutomationRunLogStorage $automationRunLogStorage;
public function __construct(
ActionScheduler $actionScheduler,
AutomationRunLogStorage $automationRunLogStorage
) {
$this->actionScheduler = $actionScheduler;
$this->automationRunLogStorage = $automationRunLogStorage;
}
public function enqueueProgress(int $runId, string $stepId): void {
$log = $this->automationRunLogStorage->getAutomationRunLogByRunAndStepId($runId, $stepId);
if (!$log) {
throw Exceptions::stepNotStarted($stepId, $runId);
}
if ($log->getStatus() !== AutomationRunLog::STATUS_RUNNING) {
throw Exceptions::stepNotRunning($stepId, $log->getStatus(), $runId);
}
$runNumber = $log->getRunNumber() + 1;
$args = [
'automation_run_id' => $runId,
'step_id' => $stepId,
'run_number' => $runNumber,
];
// if a pending action exists, unschedule it
$this->actionScheduler->unscheduleAction(Hooks::AUTOMATION_STEP, [$args]);
// if an action still exists (pending, in-progress, complete, failed), it's an error
$actions = $this->actionScheduler->getScheduledActions(['hook' => Hooks::AUTOMATION_STEP, 'args' => [$args]]);
$processedActions = array_filter($actions, function ($action) {
return !$action instanceof ActionScheduler_CanceledAction;
});
if (count($processedActions) > 0) {
throw Exceptions::stepActionProcessed($stepId, $runId, $runNumber);
}
$this->actionScheduler->enqueue(Hooks::AUTOMATION_STEP, [$args]);
}
}
@@ -0,0 +1,68 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Control;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Filter as FilterData;
use MailPoet\Automation\Engine\Data\FilterGroup;
use MailPoet\Automation\Engine\Data\Filters;
use MailPoet\Automation\Engine\Data\StepRunArgs;
use MailPoet\Automation\Engine\Exceptions;
use MailPoet\Automation\Engine\Integration\Filter;
use MailPoet\Automation\Engine\Registry;
class FilterHandler {
/** @var Registry */
private $registry;
public function __construct(
Registry $registry
) {
$this->registry = $registry;
}
public function matchesFilters(StepRunArgs $args): bool {
$filters = $args->getStep()->getFilters();
if (!$filters) {
return true;
}
$operator = $filters->getOperator();
foreach ($filters->getGroups() as $group) {
$matches = $this->matchesGroup($group, $args);
if ($operator === Filters::OPERATOR_AND && !$matches) {
return false;
}
if ($operator === Filters::OPERATOR_OR && $matches) {
return true;
}
}
return $operator === Filters::OPERATOR_AND;
}
public function matchesGroup(FilterGroup $group, StepRunArgs $args): bool {
$operator = $group->getOperator();
foreach ($group->getFilters() as $filterData) {
$filter = $this->getFilter($filterData);
$value = $args->getFieldValue($filterData->getFieldKey(), $filter->getFieldParams($filterData));
$matches = $filter->matches($filterData, $value);
if ($operator === FilterGroup::OPERATOR_AND && !$matches) {
return false;
}
if ($operator === FilterGroup::OPERATOR_OR && $matches) {
return true;
}
}
return $operator === FilterGroup::OPERATOR_AND;
}
private function getFilter(FilterData $data): Filter {
$filter = $this->registry->getFilter($data->getFieldType());
if (!$filter) {
throw Exceptions::filterNotFound($data->getFieldType());
}
return $filter;
}
}
@@ -0,0 +1,32 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Control;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\StepValidationArgs;
use MailPoet\Automation\Engine\Integration\Step;
use MailPoet\Validator\Schema\ObjectSchema;
class RootStep implements Step {
public function getKey(): string {
return 'core:root';
}
public function getName(): string {
// translators: not shown to user, no need to translate
return __('Root step', 'mailpoet');
}
public function getArgsSchema(): ObjectSchema {
return new ObjectSchema();
}
public function getSubjectKeys(): array {
return [];
}
public function validate(StepValidationArgs $args): void {
}
}
@@ -0,0 +1,221 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Control;
if (!defined('ABSPATH')) exit;
use Exception;
use MailPoet\Automation\Engine\Data\Automation;
use MailPoet\Automation\Engine\Data\AutomationRun;
use MailPoet\Automation\Engine\Data\AutomationRunLog;
use MailPoet\Automation\Engine\Data\StepRunArgs;
use MailPoet\Automation\Engine\Data\StepValidationArgs;
use MailPoet\Automation\Engine\Data\SubjectEntry;
use MailPoet\Automation\Engine\Exceptions;
use MailPoet\Automation\Engine\Exceptions\InvalidStateException;
use MailPoet\Automation\Engine\Hooks;
use MailPoet\Automation\Engine\Integration\Action;
use MailPoet\Automation\Engine\Integration\Payload;
use MailPoet\Automation\Engine\Integration\Subject;
use MailPoet\Automation\Engine\Registry;
use MailPoet\Automation\Engine\Storage\AutomationRunStorage;
use MailPoet\Automation\Engine\Storage\AutomationStorage;
use MailPoet\Automation\Engine\WordPress;
use Throwable;
class StepHandler {
/** @var SubjectLoader */
private $subjectLoader;
/** @var WordPress */
private $wordPress;
/** @var AutomationRunStorage */
private $automationRunStorage;
/** @var AutomationStorage */
private $automationStorage;
/** @var Registry */
private $registry;
/** @var StepRunControllerFactory */
private $stepRunControllerFactory;
/** @var StepRunLoggerFactory */
private $stepRunLoggerFactory;
/** @var StepScheduler */
private $stepScheduler;
public function __construct(
SubjectLoader $subjectLoader,
WordPress $wordPress,
AutomationRunStorage $automationRunStorage,
AutomationStorage $automationStorage,
Registry $registry,
StepRunControllerFactory $stepRunControllerFactory,
StepRunLoggerFactory $stepRunLoggerFactory,
StepScheduler $stepScheduler
) {
$this->subjectLoader = $subjectLoader;
$this->wordPress = $wordPress;
$this->automationRunStorage = $automationRunStorage;
$this->automationStorage = $automationStorage;
$this->registry = $registry;
$this->stepRunControllerFactory = $stepRunControllerFactory;
$this->stepRunLoggerFactory = $stepRunLoggerFactory;
$this->stepScheduler = $stepScheduler;
}
public function initialize(): void {
$this->wordPress->addAction(Hooks::AUTOMATION_STEP, [$this, 'handle']);
}
/** @param mixed $args */
public function handle($args): void {
// TODO: better args validation
if (!is_array($args) || !isset($args['automation_run_id']) || !array_key_exists('step_id', $args)) {
throw new InvalidStateException();
}
$runId = (int)$args['automation_run_id'];
$stepId = (string)$args['step_id'];
$runNumber = (int)($args['run_number'] ?? 1);
// BC — complete automation run if "step_id" is empty (was nullable in the past)
if (!$stepId) {
$this->automationRunStorage->updateStatus($runId, AutomationRun::STATUS_COMPLETE);
return;
}
$logger = $this->stepRunLoggerFactory->createLogger($runId, $stepId, AutomationRunLog::TYPE_ACTION, $runNumber);
$logger->logStart();
try {
$this->handleStep($runId, $stepId, $runNumber, $logger);
} catch (Throwable $e) {
$status = $e instanceof InvalidStateException && $e->getErrorCode() === 'mailpoet_automation_not_active'
? AutomationRun::STATUS_CANCELLED
: AutomationRun::STATUS_FAILED;
$this->automationRunStorage->updateStatus((int)$args['automation_run_id'], $status);
$logger->logFailure($e);
// Action Scheduler catches only Exception instances, not other errors.
// We need to convert them to exceptions to be processed and logged.
if (!$e instanceof Exception) {
throw new Exception($e->getMessage(), intval($e->getCode()), $e);
}
throw $e;
} finally {
$this->postProcessAutomationRun($runId);
}
}
private function handleStep(int $runId, string $stepId, int $runNumber, StepRunLogger $logger): void {
$automationRun = $this->automationRunStorage->getAutomationRun($runId);
if (!$automationRun) {
throw Exceptions::automationRunNotFound($runId);
}
if ($automationRun->getStatus() !== AutomationRun::STATUS_RUNNING) {
throw Exceptions::automationRunNotRunning($runId, $automationRun->getStatus());
}
$automation = $this->automationStorage->getAutomation($automationRun->getAutomationId(), $automationRun->getVersionId());
if (!$automation) {
throw Exceptions::automationVersionNotFound($automationRun->getAutomationId(), $automationRun->getVersionId());
}
if (!in_array($automation->getStatus(), [Automation::STATUS_ACTIVE, Automation::STATUS_DEACTIVATING], true)) {
throw Exceptions::automationNotActive($automationRun->getAutomationId());
}
$stepData = $automation->getStep($stepId);
if (!$stepData) {
throw Exceptions::automationStepNotFound($stepId);
}
$logger->logStepData($stepData);
$step = $this->registry->getStep($stepData->getKey());
if (!$step instanceof Action) {
throw new InvalidStateException();
}
$requiredSubjects = $step->getSubjectKeys();
$subjectEntries = $this->getSubjectEntries($automationRun, $requiredSubjects);
$args = new StepRunArgs($automation, $automationRun, $stepData, $subjectEntries, $runNumber);
$validationArgs = new StepValidationArgs($automation, $stepData, array_map(function (SubjectEntry $entry) {
return $entry->getSubject();
}, $subjectEntries));
$step->validate($validationArgs);
$step->run($args, $this->stepRunControllerFactory->createController($args, $logger));
// check if run is not completed by now (e.g., one of if/else branches is empty)
$automationRun = $this->automationRunStorage->getAutomationRun($runId);
if ($automationRun && $automationRun->getStatus() !== AutomationRun::STATUS_RUNNING) {
$logger->logSuccess();
return;
}
// schedule next step if not scheduled by action
if (!$this->stepScheduler->hasScheduledStep($args)) {
$this->stepScheduler->scheduleNextStep($args);
}
// logging
if ($this->stepScheduler->hasScheduledProgress($args)) {
$logger->logProgress();
} else {
$logger->logSuccess();
}
}
/** @return SubjectEntry<Subject<Payload>>[] */
private function getSubjectEntries(AutomationRun $automationRun, array $requiredSubjectKeys): array {
$subjectDataMap = [];
foreach ($automationRun->getSubjects() as $data) {
$subjectDataMap[$data->getKey()] = array_merge($subjectDataMap[$data->getKey()] ?? [], [$data]);
}
$subjectEntries = [];
foreach ($requiredSubjectKeys as $key) {
$subjectData = $subjectDataMap[$key] ?? null;
if (!$subjectData) {
throw Exceptions::subjectDataNotFound($key, $automationRun->getId());
}
}
foreach ($subjectDataMap as $subjectData) {
foreach ($subjectData as $data) {
$subjectEntries[] = $this->subjectLoader->getSubjectEntry($data);
}
}
return $subjectEntries;
}
private function postProcessAutomationRun(int $automationRunId): void {
$automationRun = $this->automationRunStorage->getAutomationRun($automationRunId);
if (!$automationRun) {
return;
}
$automation = $this->automationStorage->getAutomation($automationRun->getAutomationId());
if (!$automation) {
return;
}
$this->postProcessAutomation($automation);
}
private function postProcessAutomation(Automation $automation): void {
if ($automation->getStatus() === Automation::STATUS_DEACTIVATING) {
$activeRuns = $this->automationRunStorage->getCountForAutomation($automation, AutomationRun::STATUS_RUNNING);
// Set a deactivating Automation to draft once all automation runs are finished.
if ($activeRuns === 0) {
$automation->setStatus(Automation::STATUS_DRAFT);
$this->automationStorage->updateAutomation($automation);
}
}
}
}
@@ -0,0 +1,50 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Control;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Control\StepRunLogger;
use MailPoet\Automation\Engine\Data\StepRunArgs;
class StepRunController {
/** @var StepScheduler */
private $stepScheduler;
/** @var StepRunArgs */
private $stepRunArgs;
/** @var StepRunLogger */
private $stepRunLogger;
public function __construct(
StepScheduler $stepScheduler,
StepRunArgs $stepRunArgs,
StepRunLogger $stepRunLogger
) {
$this->stepScheduler = $stepScheduler;
$this->stepRunArgs = $stepRunArgs;
$this->stepRunLogger = $stepRunLogger;
}
public function scheduleProgress(int $timestamp = null): int {
return $this->stepScheduler->scheduleProgress($this->stepRunArgs, $timestamp);
}
public function scheduleNextStep(int $timestamp = null): int {
return $this->stepScheduler->scheduleNextStep($this->stepRunArgs, $timestamp);
}
public function scheduleNextStepByIndex(int $nextStepIndex, int $timestamp = null): int {
return $this->stepScheduler->scheduleNextStepByIndex($this->stepRunArgs, $nextStepIndex, $timestamp);
}
public function hasScheduledNextStep(): bool {
return $this->stepScheduler->hasScheduledNextStep($this->stepRunArgs);
}
public function getRunLog(): StepRunLogger {
return $this->stepRunLogger;
}
}
@@ -0,0 +1,24 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Control;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Control\StepRunLogger;
use MailPoet\Automation\Engine\Data\StepRunArgs;
class StepRunControllerFactory {
/** @var StepScheduler */
private $stepScheduler;
public function __construct(
StepScheduler $stepScheduler
) {
$this->stepScheduler = $stepScheduler;
}
public function createController(StepRunArgs $args, StepRunLogger $logger): StepRunController {
return new StepRunController($this->stepScheduler, $args, $logger);
}
}
@@ -0,0 +1,144 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Control;
if (!defined('ABSPATH')) exit;
use DateTimeImmutable;
use MailPoet\Automation\Engine\Data\AutomationRunLog;
use MailPoet\Automation\Engine\Data\Step;
use MailPoet\Automation\Engine\Hooks;
use MailPoet\Automation\Engine\Storage\AutomationRunLogStorage;
use MailPoet\InvalidStateException;
use Throwable;
class StepRunLogger {
/** @var AutomationRunLogStorage */
private $automationRunLogStorage;
/** @var Hooks */
private $hooks;
/** @var int */
private $runId;
/** @var string */
private $stepId;
/** @var AutomationRunLog|null */
private $log;
/** @var string */
private $stepType;
/** @var int */
private $runNumber;
/** @var bool */
private $isWpDebug;
public function __construct(
AutomationRunLogStorage $automationRunLogStorage,
Hooks $hooks,
int $runId,
string $stepId,
string $stepType,
int $runNumber,
bool $isWpDebug = null
) {
$this->automationRunLogStorage = $automationRunLogStorage;
$this->hooks = $hooks;
$this->runId = $runId;
$this->stepId = $stepId;
$this->stepType = $stepType;
$this->runNumber = $runNumber;
$this->isWpDebug = $isWpDebug !== null ? $isWpDebug : $this->getWpDebug();
}
private function getWpDebug(): bool {
if (!defined('WP_DEBUG')) {
return false;
}
if (!is_bool(WP_DEBUG)) {
return in_array(strtolower((string)WP_DEBUG), ['true', '1'], true);
}
return WP_DEBUG;
}
public function logStart(): void {
$log = $this->getLog();
$log->setStatus(AutomationRunLog::STATUS_RUNNING);
$log->setUpdatedAt(new DateTimeImmutable());
$this->automationRunLogStorage->updateAutomationRunLog($log);
}
public function logStepData(Step $step): void {
$log = $this->getLog();
$log->setStepKey($step->getKey());
$this->automationRunLogStorage->updateAutomationRunLog($log);
}
public function logProgress(): void {
$log = $this->getLog();
$log->setStatus(AutomationRunLog::STATUS_RUNNING);
$log->setUpdatedAt(new DateTimeImmutable());
$this->automationRunLogStorage->updateAutomationRunLog($log);
}
public function logSuccess(): void {
$log = $this->getLog();
$log->setStatus(AutomationRunLog::STATUS_COMPLETE);
$log->setUpdatedAt(new DateTimeImmutable());
$this->triggerAfterRunHook($log);
$this->automationRunLogStorage->updateAutomationRunLog($log);
}
public function logFailure(Throwable $error): void {
$log = $this->getLog();
$log->setStatus(AutomationRunLog::STATUS_FAILED);
$log->setError($error);
$log->setUpdatedAt(new DateTimeImmutable());
$this->triggerAfterRunHook($log);
$this->automationRunLogStorage->updateAutomationRunLog($log);
}
public function saveLogData(array $data): void {
$log = $this->getLog();
foreach ($data as $key => $value) {
$log->setData($key, $value);
}
$this->automationRunLogStorage->updateAutomationRunLog($log);
}
public function getLog(): AutomationRunLog {
if (!$this->log) {
$this->log = $this->automationRunLogStorage->getAutomationRunLogByRunAndStepId($this->runId, $this->stepId);
}
if (!$this->log) {
$log = new AutomationRunLog($this->runId, $this->stepId, $this->stepType);
$log->setRunNumber($this->runNumber);
$id = $this->automationRunLogStorage->createAutomationRunLog($log);
$this->log = $this->automationRunLogStorage->getAutomationRunLog($id);
}
if (!$this->log) {
throw new InvalidStateException('Failed to create automation run log');
}
$this->log->setRunNumber($this->runNumber);
return $this->log;
}
private function triggerAfterRunHook(AutomationRunLog $log): void {
try {
$this->hooks->doAutomationStepAfterRun($log);
} catch (Throwable $e) {
if ($this->isWpDebug) {
throw $e;
}
// ignore integration logging errors
}
}
}
@@ -0,0 +1,29 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Control;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Hooks;
use MailPoet\Automation\Engine\Storage\AutomationRunLogStorage;
class StepRunLoggerFactory {
/** @var AutomationRunLogStorage */
private $automationRunLogStorage;
/** @var Hooks */
private $hooks;
public function __construct(
AutomationRunLogStorage $automationRunLogStorage,
Hooks $hooks
) {
$this->automationRunLogStorage = $automationRunLogStorage;
$this->hooks = $hooks;
}
public function createLogger(int $runId, string $stepId, string $stepType, int $runNumber): StepRunLogger {
return new StepRunLogger($this->automationRunLogStorage, $this->hooks, $runId, $stepId, $stepType, $runNumber);
}
}
@@ -0,0 +1,122 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Control;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\AutomationRun;
use MailPoet\Automation\Engine\Data\StepRunArgs;
use MailPoet\Automation\Engine\Exceptions;
use MailPoet\Automation\Engine\Hooks;
use MailPoet\Automation\Engine\Storage\AutomationRunStorage;
class StepScheduler {
/** @var ActionScheduler */
private $actionScheduler;
/** @var AutomationRunStorage */
private $automationRunStorage;
public function __construct(
ActionScheduler $actionScheduler,
AutomationRunStorage $automationRunStorage
) {
$this->actionScheduler = $actionScheduler;
$this->automationRunStorage = $automationRunStorage;
}
public function scheduleProgress(StepRunArgs $args, int $timestamp = null): int {
$runId = $args->getAutomationRun()->getId();
$data = $this->getActionData($runId, $args->getStep()->getId(), $args->getRunNumber() + 1);
return $this->scheduleStepAction($data, $timestamp);
}
public function scheduleNextStep(StepRunArgs $args, int $timestamp = null): int {
$step = $args->getStep();
$nextSteps = $step->getNextSteps();
// complete the automation run if there are no more steps
if (count($nextSteps) === 0) {
$this->completeAutomationRun($args);
return 0;
}
if (count($nextSteps) > 1) {
throw Exceptions::nextStepNotScheduled($step->getId());
}
return $this->scheduleNextStepByIndex($args, 0, $timestamp);
}
public function scheduleNextStepByIndex(StepRunArgs $args, int $nextStepIndex, int $timestamp = null): int {
$step = $args->getStep();
$nextStep = $step->getNextSteps()[$nextStepIndex] ?? null;
if (!$nextStep) {
throw Exceptions::nextStepNotFound($step->getId(), $nextStepIndex);
}
$runId = $args->getAutomationRun()->getId();
$nextStepId = $nextStep->getId();
if (!$nextStepId) {
$this->completeAutomationRun($args);
return 0;
}
$data = $this->getActionData($runId, $nextStepId);
$id = $this->scheduleStepAction($data, $timestamp);
$this->automationRunStorage->updateNextStep($runId, $nextStepId);
return $id;
}
public function hasScheduledNextStep(StepRunArgs $args): bool {
$runId = $args->getAutomationRun()->getId();
foreach ($args->getStep()->getNextStepIds() as $nextStepId) {
$data = $this->getActionData($runId, $nextStepId);
$hasStep = $this->actionScheduler->hasScheduledAction(Hooks::AUTOMATION_STEP, $data);
if ($hasStep) {
return true;
}
// BC for old steps without run number
unset($data[0]['run_number']);
$hasStep = $this->actionScheduler->hasScheduledAction(Hooks::AUTOMATION_STEP, $data);
if ($hasStep) {
return true;
}
}
return false;
}
public function hasScheduledProgress(StepRunArgs $args): bool {
$runId = $args->getAutomationRun()->getId();
$data = $this->getActionData($runId, $args->getStep()->getId(), $args->getRunNumber() + 1);
return $this->actionScheduler->hasScheduledAction(Hooks::AUTOMATION_STEP, $data);
}
public function hasScheduledStep(StepRunArgs $args): bool {
return $this->hasScheduledNextStep($args) || $this->hasScheduledProgress($args);
}
private function scheduleStepAction(array $data, int $timestamp = null): int {
return $timestamp === null
? $this->actionScheduler->enqueue(Hooks::AUTOMATION_STEP, $data)
: $this->actionScheduler->schedule($timestamp, Hooks::AUTOMATION_STEP, $data);
}
private function completeAutomationRun(StepRunArgs $args): void {
$runId = $args->getAutomationRun()->getId();
$this->automationRunStorage->updateNextStep($runId, null);
$this->automationRunStorage->updateStatus($runId, AutomationRun::STATUS_COMPLETE);
}
private function getActionData(int $runId, string $stepId, int $runNumber = 1): array {
return [
[
'automation_run_id' => $runId,
'step_id' => $stepId,
'run_number' => $runNumber,
],
];
}
}
@@ -0,0 +1,49 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Control;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Subject as SubjectData;
use MailPoet\Automation\Engine\Data\SubjectEntry;
use MailPoet\Automation\Engine\Exceptions;
use MailPoet\Automation\Engine\Integration\Payload;
use MailPoet\Automation\Engine\Integration\Subject;
use MailPoet\Automation\Engine\Registry;
class SubjectLoader {
/** @var Registry */
private $registry;
public function __construct(
Registry $registry
) {
$this->registry = $registry;
}
/**
* @param SubjectData[] $subjectData
* @return SubjectEntry<Subject<Payload>>[]
*/
public function getSubjectsEntries(array $subjectData): array {
$subjectEntries = [];
foreach ($subjectData as $data) {
$subjectEntries[] = $this->getSubjectEntry($data);
}
return $subjectEntries;
}
/**
* @param SubjectData $subjectData
* @return SubjectEntry<Subject<Payload>>
*/
public function getSubjectEntry(SubjectData $subjectData): SubjectEntry {
$key = $subjectData->getKey();
$subject = $this->registry->getSubject($key);
if (!$subject) {
throw Exceptions::subjectNotFound($key);
}
return new SubjectEntry($subject, $subjectData);
}
}
@@ -0,0 +1,103 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Control;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Automation;
use MailPoet\Automation\Engine\Data\Step as StepData;
use MailPoet\Automation\Engine\Data\Subject;
use MailPoet\Automation\Engine\Integration\SubjectTransformer;
use MailPoet\Automation\Engine\Integration\Trigger;
use MailPoet\Automation\Engine\Registry;
class SubjectTransformerHandler {
/* @var Registry */
private $registry;
public function __construct(
Registry $registry
) {
$this->registry = $registry;
}
public function getSubjectKeysForAutomation(Automation $automation): array {
$triggerData = array_values(array_filter(
$automation->getSteps(),
function (StepData $step): bool {
return $step->getType() === StepData::TYPE_TRIGGER;
}
));
$triggers = array_filter(array_map(
function (StepData $step): ?Trigger {
return $this->registry->getTrigger($step->getKey());
},
$triggerData
));
$all = [];
foreach ($triggers as $trigger) {
$all[] = $this->getSubjectKeysForTrigger($trigger);
}
$all = count($all) > 1 ? array_intersect(...$all) : $all[0] ?? [];
return array_values(array_unique($all));
}
public function getSubjectKeysForTrigger(Trigger $trigger): array {
$transformerMap = $this->getTransformerMap();
$all = $trigger->getSubjectKeys();
$queue = $all;
while ($key = array_shift($queue)) {
foreach ($transformerMap[$key] ?? [] as $transformer) {
$newKey = $transformer->returns();
if (!in_array($newKey, $all, true)) {
$all[] = $newKey;
$queue[] = $newKey;
}
}
}
sort($all);
return $all;
}
/**
* @param Subject[] $subjects
* @return Subject[]
*/
public function getAllSubjects(array $subjects): array {
$transformerMap = $this->getTransformerMap();
$all = [];
foreach ($subjects as $subject) {
$all[$subject->getKey()] = $subject;
}
$queue = array_keys($all);
while ($key = array_shift($queue)) {
foreach ($transformerMap[$key] ?? [] as $transformer) {
$newKey = $transformer->returns();
if (!isset($all[$newKey])) {
$newSubject = $transformer->transform($all[$key]);
if (!$newSubject) {
continue;
}
$all[$newKey] = $newSubject;
$queue[] = $newKey;
}
}
}
return array_values($all);
}
/**
* @return SubjectTransformer[][]
*/
private function getTransformerMap(): array {
$transformerMap = [];
foreach ($this->registry->getSubjectTransformers() as $transformer) {
$transformerMap[$transformer->accepts()] = array_merge($transformerMap[$transformer->accepts()] ?? [], [$transformer]);
}
return $transformerMap;
}
}
@@ -0,0 +1,119 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Control;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\AutomationRun;
use MailPoet\Automation\Engine\Data\AutomationRunLog;
use MailPoet\Automation\Engine\Data\StepRunArgs;
use MailPoet\Automation\Engine\Data\Subject;
use MailPoet\Automation\Engine\Exceptions;
use MailPoet\Automation\Engine\Hooks;
use MailPoet\Automation\Engine\Integration\Trigger;
use MailPoet\Automation\Engine\Storage\AutomationRunStorage;
use MailPoet\Automation\Engine\Storage\AutomationStorage;
use MailPoet\Automation\Engine\WordPress;
class TriggerHandler {
/** @var AutomationStorage */
private $automationStorage;
/** @var AutomationRunStorage */
private $automationRunStorage;
/** @var SubjectLoader */
private $subjectLoader;
/** @var SubjectTransformerHandler */
private $subjectTransformerHandler;
/** @var FilterHandler */
private $filterHandler;
/** @var StepScheduler */
private $stepScheduler;
/** @var StepRunLoggerFactory */
private $stepRunLoggerFactory;
/** @var WordPress */
private $wordPress;
public function __construct(
AutomationStorage $automationStorage,
AutomationRunStorage $automationRunStorage,
SubjectLoader $subjectLoader,
SubjectTransformerHandler $subjectTransformerHandler,
FilterHandler $filterHandler,
StepScheduler $stepScheduler,
StepRunLoggerFactory $stepRunLoggerFactory,
WordPress $wordPress
) {
$this->automationStorage = $automationStorage;
$this->automationRunStorage = $automationRunStorage;
$this->subjectLoader = $subjectLoader;
$this->subjectTransformerHandler = $subjectTransformerHandler;
$this->filterHandler = $filterHandler;
$this->stepScheduler = $stepScheduler;
$this->stepRunLoggerFactory = $stepRunLoggerFactory;
$this->wordPress = $wordPress;
}
public function initialize(): void {
$this->wordPress->addAction(Hooks::TRIGGER, [$this, 'processTrigger'], 10, 2);
}
/** @param Subject[] $subjects */
public function processTrigger(Trigger $trigger, array $subjects): void {
$automations = $this->automationStorage->getActiveAutomationsByTrigger($trigger);
if (!$automations) {
return;
}
// expand all subject transformations and load subject entries
$subjects = $this->subjectTransformerHandler->getAllSubjects($subjects);
$subjectEntries = $this->subjectLoader->getSubjectsEntries($subjects);
foreach ($automations as $automation) {
$step = $automation->getTrigger($trigger->getKey());
if (!$step) {
throw Exceptions::automationTriggerNotFound($automation->getId(), $trigger->getKey());
}
$automationRun = new AutomationRun($automation->getId(), $automation->getVersionId(), $trigger->getKey(), $subjects);
$stepRunArgs = new StepRunArgs($automation, $automationRun, $step, $subjectEntries, 1);
$match = false;
try {
$match = $this->filterHandler->matchesFilters($stepRunArgs);
} catch (Exceptions\Exception $e) {
// failed filter evaluation won't match
;
}
if (!$match) {
continue;
}
$createAutomationRun = $trigger->isTriggeredBy($stepRunArgs);
$createAutomationRun = $this->wordPress->applyFilters(
Hooks::AUTOMATION_RUN_CREATE,
$createAutomationRun,
$stepRunArgs
);
if (!$createAutomationRun) {
continue;
}
$automationRunId = $this->automationRunStorage->createAutomationRun($automationRun);
$automationRun->setId($automationRunId);
$this->stepScheduler->scheduleNextStep($stepRunArgs);
$logger = $this->stepRunLoggerFactory->createLogger($automationRunId, $step->getId(), AutomationRunLog::TYPE_TRIGGER, 1);
$logger->logStepData($step);
$logger->logSuccess();
}
}
}
@@ -0,0 +1 @@
<?php
@@ -0,0 +1,259 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Data;
if (!defined('ABSPATH')) exit;
use DateTimeImmutable;
use MailPoet\Automation\Engine\Exceptions\InvalidStateException;
use MailPoet\Automation\Engine\Utils\Json;
class Automation {
public const STATUS_ACTIVE = 'active';
public const STATUS_DEACTIVATING = 'deactivating';
public const STATUS_DRAFT = 'draft';
public const STATUS_TRASH = 'trash';
public const STATUS_ALL = [
self::STATUS_ACTIVE,
self::STATUS_DEACTIVATING,
self::STATUS_DRAFT,
self::STATUS_TRASH,
];
/** @var int|null */
private $id;
/** @var int|null */
private $versionId;
/** @var string */
private $name;
/** @var \WP_User */
private $author;
/** @var string */
private $status = self::STATUS_DRAFT;
/** @var DateTimeImmutable */
private $createdAt;
/** @var DateTimeImmutable */
private $updatedAt;
/** @var ?DateTimeImmutable */
private $activatedAt = null;
/** @var array<string|int, Step> */
private $steps;
/** @var array<string, mixed> */
private $meta = [];
/** @param array<string, Step> $steps */
public function __construct(
string $name,
array $steps,
\WP_User $author,
int $id = null,
int $versionId = null
) {
$this->name = $name;
$this->steps = $steps;
$this->author = $author;
$this->id = $id;
$this->versionId = $versionId;
$now = new DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
}
public function getId(): int {
if ($this->id === null) {
throw InvalidStateException::create()->withMessage('No automation ID was set');
}
return $this->id;
}
public function setId(int $id): void {
$this->id = $id;
}
public function getVersionId(): int {
if (!$this->versionId) {
throw InvalidStateException::create()->withMessage('No automation version ID was set');
}
return $this->versionId;
}
public function getName(): string {
return $this->name;
}
public function setName(string $name): void {
$this->name = $name;
$this->setUpdatedAt();
}
public function getStatus(): string {
return $this->status;
}
public function setStatus(string $status): void {
if ($status === self::STATUS_ACTIVE && $this->status !== self::STATUS_ACTIVE) {
$this->activatedAt = new DateTimeImmutable();
}
$this->status = $status;
$this->setUpdatedAt();
}
public function getCreatedAt(): DateTimeImmutable {
return $this->createdAt;
}
public function setCreatedAt(DateTimeImmutable $createdAt): void {
$this->createdAt = $createdAt;
}
public function getAuthor(): \WP_User {
return $this->author;
}
public function getUpdatedAt(): DateTimeImmutable {
return $this->updatedAt;
}
public function getActivatedAt(): ?DateTimeImmutable {
return $this->activatedAt;
}
/** @return array<string|int, Step> */
public function getSteps(): array {
return $this->steps;
}
/**
* @return array<string|int, Step>
*/
public function getTriggers(): array {
return array_filter(
$this->steps,
function (Step $step) {
return $step->getType() === Step::TYPE_TRIGGER;
}
);
}
/** @param array<string|int, Step> $steps */
public function setSteps(array $steps): void {
$this->steps = $steps;
$this->setUpdatedAt();
}
public function getStep(string $id): ?Step {
return $this->steps[$id] ?? null;
}
public function getTrigger(string $key): ?Step {
foreach ($this->steps as $step) {
if ($step->getType() === Step::TYPE_TRIGGER && $step->getKey() === $key) {
return $step;
}
}
return null;
}
public function equals(Automation $compare): bool {
$compareArray = $compare->toArray();
$currentArray = $this->toArray();
$ignoreValues = [
'created_at',
'updated_at',
];
foreach ($ignoreValues as $ignore) {
unset($compareArray[$ignore]);
unset($currentArray[$ignore]);
}
return $compareArray === $currentArray;
}
public function needsFullValidation(): bool {
return in_array($this->status, [Automation::STATUS_ACTIVE, Automation::STATUS_DEACTIVATING], true);
}
public function toArray(): array {
return [
'id' => $this->id,
'name' => $this->name,
'status' => $this->status,
'author' => $this->author->ID,
'created_at' => $this->createdAt->format(DateTimeImmutable::W3C),
'updated_at' => $this->updatedAt->format(DateTimeImmutable::W3C),
'activated_at' => $this->activatedAt ? $this->activatedAt->format(DateTimeImmutable::W3C) : null,
'steps' => Json::encode(
array_map(function (Step $step) {
return $step->toArray();
}, $this->steps)
),
'meta' => Json::encode($this->meta),
];
}
private function setUpdatedAt(): void {
$this->updatedAt = new DateTimeImmutable();
}
/**
* @param string $key
* @return mixed|null
*/
public function getMeta(string $key) {
return $this->meta[$key] ?? null;
}
public function getAllMetas(): array {
return $this->meta;
}
/**
* @param string $key
* @param mixed $value
* @return void
*/
public function setMeta(string $key, $value): void {
$this->meta[$key] = $value;
$this->setUpdatedAt();
}
public function deleteMeta(string $key): void {
unset($this->meta[$key]);
$this->setUpdatedAt();
}
public function deleteAllMetas(): void {
$this->meta = [];
$this->setUpdatedAt();
}
public static function fromArray(array $data): self {
// TODO: validation
$automation = new self(
$data['name'],
array_map(function (array $stepData): Step {
return Step::fromArray($stepData);
}, Json::decode($data['steps'])),
new \WP_User((int)$data['author'])
);
$automation->id = (int)$data['id'];
$automation->versionId = (int)$data['version_id'];
$automation->status = $data['status'];
$automation->createdAt = new DateTimeImmutable($data['created_at']);
$automation->updatedAt = new DateTimeImmutable($data['updated_at']);
$automation->activatedAt = $data['activated_at'] !== null ? new DateTimeImmutable($data['activated_at']) : null;
$automation->meta = $data['meta'] ? Json::decode($data['meta']) : [];
return $automation;
}
}
@@ -0,0 +1,137 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Data;
if (!defined('ABSPATH')) exit;
use DateTimeImmutable;
class AutomationRun {
public const STATUS_RUNNING = 'running';
public const STATUS_COMPLETE = 'complete';
public const STATUS_CANCELLED = 'cancelled';
public const STATUS_FAILED = 'failed';
/** @var int */
private $id;
/** @var int */
private $automationId;
/** @var int */
private $versionId;
/** @var string */
private $triggerKey;
/** @var string */
private $status = self::STATUS_RUNNING;
/** @var DateTimeImmutable */
private $createdAt;
/** @var DateTimeImmutable */
private $updatedAt;
/** @var Subject[] */
private $subjects;
/**
* @param Subject[] $subjects
*/
public function __construct(
int $automationId,
int $versionId,
string $triggerKey,
array $subjects,
int $id = null
) {
$this->automationId = $automationId;
$this->versionId = $versionId;
$this->triggerKey = $triggerKey;
$this->subjects = $subjects;
if ($id) {
$this->id = $id;
}
$now = new DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
}
public function getId(): int {
return $this->id;
}
public function setId(int $id): void {
$this->id = $id;
}
public function getAutomationId(): int {
return $this->automationId;
}
public function getVersionId(): int {
return $this->versionId;
}
public function getTriggerKey(): string {
return $this->triggerKey;
}
public function getStatus(): string {
return $this->status;
}
public function getCreatedAt(): DateTimeImmutable {
return $this->createdAt;
}
public function getUpdatedAt(): DateTimeImmutable {
return $this->updatedAt;
}
/** @return Subject[] */
public function getSubjects(string $key = null): array {
if ($key) {
return array_values(
array_filter($this->subjects, function (Subject $subject) use ($key) {
return $subject->getKey() === $key;
})
);
}
return $this->subjects;
}
public function toArray(): array {
return [
'automation_id' => $this->automationId,
'version_id' => $this->versionId,
'trigger_key' => $this->triggerKey,
'status' => $this->status,
'created_at' => $this->createdAt->format(DateTimeImmutable::W3C),
'updated_at' => $this->updatedAt->format(DateTimeImmutable::W3C),
'subjects' => array_map(function (Subject $subject): array {
return $subject->toArray();
}, $this->subjects),
];
}
public static function fromArray(array $data): self {
$automationRun = new AutomationRun(
(int)$data['automation_id'],
(int)$data['version_id'],
$data['trigger_key'],
array_map(function (array $subject) {
return Subject::fromArray($subject);
}, $data['subjects'])
);
$automationRun->id = (int)$data['id'];
$automationRun->status = $data['status'];
$automationRun->createdAt = new DateTimeImmutable($data['created_at']);
$automationRun->updatedAt = new DateTimeImmutable($data['updated_at']);
return $automationRun;
}
}
@@ -0,0 +1,216 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Data;
if (!defined('ABSPATH')) exit;
use DateTimeImmutable;
use InvalidArgumentException;
use MailPoet\Automation\Engine\Utils\Json;
use Throwable;
class AutomationRunLog {
public const STATUS_RUNNING = 'running';
public const STATUS_COMPLETE = 'complete';
public const STATUS_FAILED = 'failed';
public const STATUS_ALL = [
self::STATUS_RUNNING,
self::STATUS_COMPLETE,
self::STATUS_FAILED,
];
public const TYPE_ACTION = 'action';
public const TYPE_TRIGGER = 'trigger';
public const KEY_UNKNOWN = 'unknown';
/** @var int */
private $id;
/** @var int */
private $automationRunId;
/** @var string */
private $stepId;
/** @var string */
private $stepType;
/** @var string */
private $stepKey;
/** @var string */
private $status;
/** @var DateTimeImmutable */
private $startedAt;
/** @var DateTimeImmutable */
private $updatedAt;
/** @var int */
private $runNumber = 1;
/** @var array */
private $data = [];
/** @var array|null */
private $error;
public function __construct(
int $automationRunId,
string $stepId,
string $stepType,
int $id = null
) {
$this->automationRunId = $automationRunId;
$this->stepId = $stepId;
$this->stepType = $stepType;
$this->stepKey = self::KEY_UNKNOWN;
$this->status = self::STATUS_RUNNING;
$now = new DateTimeImmutable();
$this->startedAt = $now;
$this->updatedAt = $now;
if ($id) {
$this->id = $id;
}
}
public function getId(): ?int {
return $this->id;
}
public function getAutomationRunId(): int {
return $this->automationRunId;
}
public function getStepId(): string {
return $this->stepId;
}
public function getStepType(): string {
return $this->stepType;
}
public function getStepKey(): string {
return $this->stepKey;
}
public function setStepKey(string $stepKey): void {
$this->stepKey = $stepKey;
$this->updatedAt = new DateTimeImmutable();
}
public function getStatus(): string {
return $this->status;
}
public function setStatus(string $status): void {
if (!in_array($status, self::STATUS_ALL, true)) {
throw new InvalidArgumentException("Invalid status '$status'.");
}
$this->status = $status;
$this->updatedAt = new DateTimeImmutable();
}
public function getStartedAt(): DateTimeImmutable {
return $this->startedAt;
}
public function getUpdatedAt(): DateTimeImmutable {
return $this->updatedAt;
}
public function getRunNumber(): int {
return $this->runNumber;
}
public function setRunNumber(int $runNumber): void {
$this->runNumber = $runNumber;
}
public function setUpdatedAt(DateTimeImmutable $updatedAt): void {
$this->updatedAt = $updatedAt;
}
public function getData(): array {
return $this->data;
}
/** @param mixed $value */
public function setData(string $key, $value): void {
if (!$this->isDataStorable($value)) {
throw new InvalidArgumentException("Invalid data provided for key '$key'. Only scalar values and arrays of scalar values are allowed.");
}
$this->data[$key] = $value;
$this->updatedAt = new DateTimeImmutable();
}
public function getError(): ?array {
return $this->error;
}
public function toArray(): array {
return [
'id' => $this->id,
'automation_run_id' => $this->automationRunId,
'step_id' => $this->stepId,
'step_type' => $this->stepType,
'step_key' => $this->stepKey,
'status' => $this->status,
'started_at' => $this->startedAt->format(DateTimeImmutable::W3C),
'updated_at' => $this->updatedAt->format(DateTimeImmutable::W3C),
'run_number' => $this->runNumber,
'data' => Json::encode($this->data),
'error' => $this->error ? Json::encode($this->error) : null,
];
}
public function setError(Throwable $error): void {
// Normalize all nested objects in error trace to associative arrays.
// Empty objects would then get decoded to "[]" instead of "{}".
$trace = Json::decode(Json::encode($error->getTrace()));
$this->error = [
'message' => $error->getMessage(),
'errorClass' => get_class($error),
'code' => $error->getCode(),
'trace' => $trace,
];
$this->updatedAt = new DateTimeImmutable();
}
public static function fromArray(array $data): self {
$log = new AutomationRunLog((int)$data['automation_run_id'], $data['step_id'], $data['step_type']);
$log->id = (int)$data['id'];
$log->stepKey = $data['step_key'];
$log->status = $data['status'];
$log->startedAt = new DateTimeImmutable($data['started_at']);
$log->updatedAt = new DateTimeImmutable($data['updated_at']);
$log->runNumber = (int)$data['run_number'];
$log->data = Json::decode($data['data']);
$log->error = isset($data['error']) ? Json::decode($data['error']) : null;
return $log;
}
/** @param mixed $data */
private function isDataStorable($data): bool {
if (is_scalar($data)) {
return true;
}
if (!is_array($data)) {
return false;
}
foreach ($data as $value) {
if (!$this->isDataStorable($value)) {
return false;
}
}
return true;
}
}
@@ -0,0 +1,57 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Data;
if (!defined('ABSPATH')) exit;
class AutomationStatistics {
private $automationId;
private $versionId;
private $entered;
private $inProgress;
public function __construct(
int $automationId,
int $entered = 0,
int $inProcess = 0,
?int $versionId = null
) {
$this->automationId = $automationId;
$this->entered = $entered;
$this->inProgress = $inProcess;
$this->versionId = $versionId;
}
public function getAutomationId(): int {
return $this->automationId;
}
public function getVersionId(): ?int {
return $this->versionId;
}
public function getEntered(): int {
return $this->entered;
}
public function getInProgress(): int {
return $this->inProgress;
}
public function getExited(): int {
return $this->getEntered() - $this->getInProgress();
}
public function toArray(): array {
return [
'automation_id' => $this->getAutomationId(),
'totals' => [
'entered' => $this->getEntered(),
'in_progress' => $this->getInProgress(),
'exited' => $this->getExited(),
],
];
}
}
@@ -0,0 +1,95 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Data;
if (!defined('ABSPATH')) exit;
class AutomationTemplate {
public const TYPE_DEFAULT = 'default';
public const TYPE_PREMIUM = 'premium';
public const TYPE_COMING_SOON = 'coming-soon';
/** @var string */
private $slug;
/** @var string */
private $category;
/** @var string */
private $name;
/** @var string */
private $description;
/** @var callable(): Automation */
private $automationFactory;
/** @var array<string, int|bool> */
private $requiredCapabilities;
/** @var string */
private $type;
/**
* @param callable(): Automation $automationFactory
* @param array<string, int|bool> $requiredCapabilities
*/
public function __construct(
string $slug,
string $category,
string $name,
string $description,
callable $automationFactory,
array $requiredCapabilities = [],
string $type = self::TYPE_DEFAULT
) {
$this->slug = $slug;
$this->category = $category;
$this->name = $name;
$this->description = $description;
$this->automationFactory = $automationFactory;
$this->requiredCapabilities = $requiredCapabilities;
$this->type = $type;
}
public function getSlug(): string {
return $this->slug;
}
public function getName(): string {
return $this->name;
}
public function getCategory(): string {
return $this->category;
}
public function getType(): string {
return $this->type;
}
public function getDescription(): string {
return $this->description;
}
/** @return array<string, int|bool> */
public function getRequiredCapabilities(): array {
return $this->requiredCapabilities;
}
public function createAutomation(): Automation {
return ($this->automationFactory)();
}
public function toArray(): array {
return [
'slug' => $this->getSlug(),
'name' => $this->getName(),
'category' => $this->getCategory(),
'type' => $this->getType(),
'required_capabilities' => $this->getRequiredCapabilities(),
'description' => $this->getDescription(),
];
}
}
@@ -0,0 +1,30 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Data;
if (!defined('ABSPATH')) exit;
class AutomationTemplateCategory {
/** @var string */
private $slug;
/** @var string */
private $name;
public function __construct(
string $slug,
string $name
) {
$this->slug = $slug;
$this->name = $name;
}
public function getSlug(): string {
return $this->slug;
}
public function getName(): string {
return $this->name;
}
}
@@ -0,0 +1,73 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Data;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Integration\Payload;
class Field {
public const TYPE_BOOLEAN = 'boolean';
public const TYPE_INTEGER = 'integer';
public const TYPE_NUMBER = 'number';
public const TYPE_STRING = 'string';
public const TYPE_ENUM = 'enum';
public const TYPE_ENUM_ARRAY = 'enum_array';
public const TYPE_DATETIME = 'datetime';
/** @var string */
private $key;
/** @var string */
private $type;
/** @var string */
private $name;
/** @var callable */
private $factory;
/** @var array */
private $args;
public function __construct(
string $key,
string $type,
string $name,
callable $factory,
array $args = []
) {
$this->key = $key;
$this->type = $type;
$this->name = $name;
$this->factory = $factory;
$this->args = $args;
}
public function getKey(): string {
return $this->key;
}
public function getType(): string {
return $this->type;
}
public function getName(): string {
return $this->name;
}
public function getFactory(): callable {
return $this->factory;
}
/** @return mixed */
public function getValue(Payload $payload, array $params = []) {
return $this->getFactory()($payload, $params);
}
public function getArgs(): array {
return $this->args;
}
}
@@ -0,0 +1,77 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Data;
if (!defined('ABSPATH')) exit;
class Filter {
/** @var string */
private $id;
/** @var string */
private $fieldType;
/** @var string */
private $fieldKey;
/** @var string */
private $condition;
/** @var array */
private $args;
public function __construct(
string $id,
string $fieldType,
string $fieldKey,
string $condition,
array $args
) {
$this->id = $id;
$this->fieldType = $fieldType;
$this->fieldKey = $fieldKey;
$this->condition = $condition;
$this->args = $args;
}
public function getId(): string {
return $this->id;
}
public function getFieldType(): string {
return $this->fieldType;
}
public function getFieldKey(): string {
return $this->fieldKey;
}
public function getCondition(): string {
return $this->condition;
}
public function getArgs(): array {
return $this->args;
}
public function toArray(): array {
return [
'id' => $this->id,
'field_type' => $this->fieldType,
'field_key' => $this->fieldKey,
'condition' => $this->condition,
'args' => $this->args,
];
}
public static function fromArray(array $data): self {
return new self(
$data['id'],
$data['field_type'],
$data['field_key'],
$data['condition'],
$data['args']
);
}
}
@@ -0,0 +1,62 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Data;
if (!defined('ABSPATH')) exit;
class FilterGroup {
public const OPERATOR_AND = 'and';
public const OPERATOR_OR = 'or';
/** @var string */
private $id;
/** @var string */
private $operator;
/** @var Filter[] */
private $filters;
public function __construct(
string $id,
string $operator,
array $filters
) {
$this->id = $id;
$this->operator = $operator;
$this->filters = $filters;
}
public function getId(): string {
return $this->id;
}
public function getOperator(): string {
return $this->operator;
}
public function getFilters(): array {
return $this->filters;
}
public function toArray(): array {
return [
'id' => $this->id,
'operator' => $this->operator,
'filters' => array_map(function (Filter $filter): array {
return $filter->toArray();
}, $this->filters),
];
}
public static function fromArray(array $data): self {
return new self(
$data['id'],
$data['operator'],
array_map(function (array $filter) {
return Filter::fromArray($filter);
}, $data['filters'])
);
}
}
@@ -0,0 +1,51 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Data;
if (!defined('ABSPATH')) exit;
class Filters {
public const OPERATOR_AND = 'and';
public const OPERATOR_OR = 'or';
/** @var string */
private $operator;
/** @var FilterGroup[] */
private $groups;
public function __construct(
string $operator,
array $groups
) {
$this->operator = $operator;
$this->groups = $groups;
}
public function getOperator(): string {
return $this->operator;
}
public function getGroups(): array {
return $this->groups;
}
public function toArray(): array {
return [
'operator' => $this->operator,
'groups' => array_map(function (FilterGroup $group): array {
return $group->toArray();
}, $this->groups),
];
}
public static function fromArray(array $data): self {
return new self(
$data['operator'],
array_map(function (array $group) {
return FilterGroup::fromArray($group);
}, $data['groups'])
);
}
}
@@ -0,0 +1,31 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Data;
if (!defined('ABSPATH')) exit;
class NextStep {
/** @var string|null */
protected $id;
public function __construct(
?string $id
) {
$this->id = $id;
}
public function getId(): ?string {
return $this->id;
}
public function toArray(): array {
return [
'id' => $this->id,
];
}
public static function fromArray(array $data): self {
return new self($data['id']);
}
}
@@ -0,0 +1,117 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Data;
if (!defined('ABSPATH')) exit;
class Step {
public const TYPE_ROOT = 'root';
public const TYPE_TRIGGER = 'trigger';
public const TYPE_ACTION = 'action';
/** @var string */
private $id;
/** @var string */
private $type;
/** @var string */
private $key;
/** @var array */
protected $args;
/** @var NextStep[] */
protected $nextSteps;
/** @var Filters|null */
private $filters;
/**
* @param array<string, mixed> $args
* @param NextStep[] $nextSteps
*/
public function __construct(
string $id,
string $type,
string $key,
array $args,
array $nextSteps,
Filters $filters = null
) {
$this->id = $id;
$this->type = $type;
$this->key = $key;
$this->args = $args;
$this->nextSteps = $nextSteps;
$this->filters = $filters;
}
public function getId(): string {
return $this->id;
}
public function getType(): string {
return $this->type;
}
public function getKey(): string {
return $this->key;
}
/** @return NextStep[] */
public function getNextSteps(): array {
return $this->nextSteps;
}
public function getNextStepIds(): array {
$ids = [];
foreach ($this->nextSteps as $nextStep) {
$nextStepId = $nextStep->getId();
if ($nextStepId) {
$ids[] = $nextStep->getId();
}
}
return $ids;
}
/** @param NextStep[] $nextSteps */
public function setNextSteps(array $nextSteps): void {
$this->nextSteps = $nextSteps;
}
public function getArgs(): array {
return $this->args;
}
public function getFilters(): ?Filters {
return $this->filters;
}
public function toArray(): array {
return [
'id' => $this->id,
'type' => $this->type,
'key' => $this->key,
'args' => $this->args,
'next_steps' => array_map(function (NextStep $nextStep) {
return $nextStep->toArray();
}, $this->nextSteps),
'filters' => $this->filters ? $this->filters->toArray() : null,
];
}
public static function fromArray(array $data): self {
return new self(
$data['id'],
$data['type'],
$data['key'],
$data['args'],
array_map(function (array $nextStep) {
return NextStep::fromArray($nextStep);
}, $data['next_steps']),
isset($data['filters']) ? Filters::fromArray($data['filters']) : null
);
}
}
@@ -0,0 +1,166 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Data;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Exceptions;
use MailPoet\Automation\Engine\Exceptions\InvalidStateException;
use MailPoet\Automation\Engine\Integration\Payload;
use MailPoet\Automation\Engine\Integration\Subject;
use Throwable;
class StepRunArgs {
/** @var Automation */
private $automation;
/** @var AutomationRun */
private $automationRun;
/** @var Step */
private $step;
/** @var array<string, SubjectEntry<Subject<Payload>>[]> */
private $subjectEntries = [];
/** @var array<class-string, string> */
private $subjectKeyClassMap = [];
/** @var array<string, Field> */
private $fields = [];
/** @var array<string, string> */
private $fieldToSubjectMap = [];
/** @var int */
private $runNumber;
/** @param SubjectEntry<Subject<Payload>>[] $subjectsEntries */
public function __construct(
Automation $automation,
AutomationRun $automationRun,
Step $step,
array $subjectsEntries,
int $runNumber
) {
$this->automation = $automation;
$this->step = $step;
$this->automationRun = $automationRun;
$this->runNumber = $runNumber;
foreach ($subjectsEntries as $entry) {
$subject = $entry->getSubject();
$key = $subject->getKey();
$this->subjectEntries[$key] = array_merge($this->subjectEntries[$key] ?? [], [$entry]);
$this->subjectKeyClassMap[get_class($subject)] = $key;
foreach ($subject->getFields() as $field) {
$this->fields[$field->getKey()] = $field;
$this->fieldToSubjectMap[$field->getKey()] = $key;
}
}
}
public function getAutomation(): Automation {
return $this->automation;
}
public function getAutomationRun(): AutomationRun {
return $this->automationRun;
}
public function getStep(): Step {
return $this->step;
}
/** @return array<string, SubjectEntry<Subject<Payload>>[]> */
public function getSubjectEntries(): array {
return $this->subjectEntries;
}
/** @return SubjectEntry<Subject<Payload>> */
public function getSingleSubjectEntry(string $key): SubjectEntry {
$subjects = $this->subjectEntries[$key] ?? [];
if (count($subjects) === 0) {
throw Exceptions::subjectDataNotFound($key, $this->automationRun->getId());
}
if (count($subjects) > 1) {
throw Exceptions::multipleSubjectsFound($key, $this->automationRun->getId());
}
return $subjects[0];
}
/**
* @template P of Payload
* @template S of Subject<P>
* @param class-string<S> $class
* @return SubjectEntry<S<P>>
*/
public function getSingleSubjectEntryByClass(string $class): SubjectEntry {
$key = $this->subjectKeyClassMap[$class] ?? null;
if (!$key) {
throw Exceptions::subjectClassNotFound($class);
}
/** @var SubjectEntry<S<P>> $entry -- for PHPStan */
$entry = $this->getSingleSubjectEntry($key);
return $entry;
}
/**
* @template P of Payload
* @param class-string<P> $class
* @return P
*/
public function getSinglePayloadByClass(string $class): Payload {
$payloads = [];
foreach ($this->subjectEntries as $entries) {
foreach ($entries as $entry) {
$payload = $entry->getPayload();
if (get_class($payload) === $class) {
$payloads[] = $payload;
}
}
}
if (count($payloads) === 0) {
throw Exceptions::payloadNotFound($class, $this->automationRun->getId());
}
if (count($payloads) > 1) {
throw Exceptions::multiplePayloadsFound($class, $this->automationRun->getId());
}
// ensure PHPStan we're indeed returning an instance of $class
$payload = $payloads[0];
if (!$payload instanceof $class) {
throw InvalidStateException::create();
}
return $payload;
}
/** @return mixed */
public function getFieldValue(string $key, array $params = []) {
$field = $this->fields[$key] ?? null;
$subjectKey = $this->fieldToSubjectMap[$key] ?? null;
if (!$field || !$subjectKey) {
throw Exceptions::fieldNotFound($key);
}
$entry = $this->getSingleSubjectEntry($subjectKey);
try {
$value = $field->getValue($entry->getPayload(), $params);
} catch (Throwable $e) {
throw Exceptions::fieldLoadFailed($field->getKey(), $field->getArgs());
}
return $value;
}
public function getRunNumber(): int {
return $this->runNumber;
}
public function isFirstRun(): bool {
return $this->runNumber === 1;
}
}
@@ -0,0 +1,79 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Data;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Exceptions;
use MailPoet\Automation\Engine\Integration\Payload;
use MailPoet\Automation\Engine\Integration\Subject;
class StepValidationArgs {
/** @var Automation */
private $automation;
/** @var Step */
private $step;
/** @var array<string, Subject<Payload>> */
private $subjects = [];
/** @var array<class-string, string> */
private $subjectKeyClassMap = [];
/** @param Subject<Payload>[] $subjects */
public function __construct(
Automation $automation,
Step $step,
array $subjects
) {
$this->automation = $automation;
$this->step = $step;
foreach ($subjects as $subject) {
$key = $subject->getKey();
$this->subjects[$key] = $subject;
$this->subjectKeyClassMap[get_class($subject)] = $key;
}
}
public function getAutomation(): Automation {
return $this->automation;
}
public function getStep(): Step {
return $this->step;
}
/** @return Subject<Payload>[] */
public function getSubjects(): array {
return array_values($this->subjects);
}
/** @return Subject<Payload> */
public function getSingleSubject(string $key): Subject {
$subject = $this->subjects[$key] ?? null;
if (!$subject) {
throw Exceptions::subjectNotFound($key);
}
return $subject;
}
/**
* @template P of Payload
* @template S of Subject<P>
* @param class-string<S> $class
* @return S<P>
*/
public function getSingleSubjectByClass(string $class): Subject {
$key = $this->subjectKeyClassMap[$class] ?? null;
if (!$key) {
throw Exceptions::subjectClassNotFound($class);
}
/** @var S<P> $subject -- for PHPStan */
$subject = $this->getSingleSubject($key);
return $subject;
}
}
@@ -0,0 +1,48 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Data;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Utils\Json;
class Subject {
/** @var string */
private $key;
/** @var array */
private $args;
public function __construct(
string $key,
array $args
) {
$this->key = $key;
$this->args = $args;
}
public function getKey(): string {
return $this->key;
}
public function getArgs(): array {
return $this->args;
}
public function getHash(): string {
return md5($this->getKey() . serialize($this->getArgs()));
}
public function toArray(): array {
return [
'key' => $this->getKey(),
'args' => Json::encode($this->getArgs()),
'hash' => $this->getHash(),
];
}
public static function fromArray(array $data): self {
return new self($data['key'], Json::decode($data['args']));
}
}
@@ -0,0 +1,56 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Data;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Subject as SubjectData;
use MailPoet\Automation\Engine\Exceptions;
use MailPoet\Automation\Engine\Integration\Payload;
use MailPoet\Automation\Engine\Integration\Subject;
use Throwable;
/**
* @template-covariant S of Subject<Payload>
*/
class SubjectEntry {
/** @var S */
private $subject;
/** @var SubjectData */
private $subjectData;
/** @var Payload|null */
private $payloadCache;
/** @param S $subject */
public function __construct(
Subject $subject,
SubjectData $subjectData
) {
$this->subject = $subject;
$this->subjectData = $subjectData;
}
/** @return S */
public function getSubject(): Subject {
return $this->subject;
}
public function getSubjectData(): SubjectData {
return $this->subjectData;
}
/** @return Payload */
public function getPayload() {
if ($this->payloadCache === null) {
try {
$this->payloadCache = $this->subject->getPayload($this->subjectData);
} catch (Throwable $e) {
throw Exceptions::subjectLoadFailed($this->subject->getKey(), $this->subjectData->getArgs());
}
}
return $this->payloadCache;
}
}
@@ -0,0 +1 @@
<?php
@@ -0,0 +1,61 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Endpoints\Automations;
if (!defined('ABSPATH')) exit;
use MailPoet\API\REST\Request;
use MailPoet\API\REST\Response;
use MailPoet\Automation\Engine\API\Endpoint;
use MailPoet\Automation\Engine\Exceptions;
use MailPoet\Automation\Engine\Mappers\AutomationMapper;
use MailPoet\Automation\Engine\Registry;
use MailPoet\Automation\Engine\Validation\AutomationValidator;
use MailPoet\Validator\Builder;
class AutomationTemplateGetEndpoint extends Endpoint {
/** @var AutomationMapper */
private $automationMapper;
/** @var AutomationValidator */
private $automationValidator;
/** @var Registry */
private $registry;
public function __construct(
AutomationMapper $automationMapper,
AutomationValidator $automationValidator,
Registry $registry
) {
$this->registry = $registry;
$this->automationValidator = $automationValidator;
$this->automationMapper = $automationMapper;
}
public function handle(Request $request): Response {
/** @var string|null $slug - for PHPStan because strval() doesn't accept a value of mixed */
$slug = $request->getParam('slug');
$slug = strval($slug);
$template = $this->registry->getTemplate($slug);
if (!$template) {
throw Exceptions::automationTemplateNotFound($slug);
}
$automation = $template->createAutomation();
$automation->setId(0);
$this->automationValidator->validate($automation);
$data = $template->toArray() + [
'automation' => $this->automationMapper->buildAutomation($automation),
];
return new Response($data);
}
public static function getRequestSchema(): array {
return [
'slug' => Builder::string()->required(),
];
}
}
@@ -0,0 +1,39 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Endpoints\Automations;
if (!defined('ABSPATH')) exit;
use MailPoet\API\REST\Request;
use MailPoet\API\REST\Response;
use MailPoet\Automation\Engine\API\Endpoint;
use MailPoet\Automation\Engine\Data\AutomationTemplate;
use MailPoet\Automation\Engine\Registry;
use MailPoet\Validator\Builder;
class AutomationTemplatesGetEndpoint extends Endpoint {
/** @var Registry */
private $registry;
public function __construct(
Registry $registry
) {
$this->registry = $registry;
}
public function handle(Request $request): Response {
/** @var string|null $category */
$category = $request->getParam('category');
$templates = array_values($this->registry->getTemplates($category ? strval($category) : null));
return new Response(array_map(function (AutomationTemplate $automation) {
return $automation->toArray();
}, $templates));
}
public static function getRequestSchema(): array {
return [
'category' => Builder::string(),
];
}
}
@@ -0,0 +1,40 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Endpoints\Automations;
if (!defined('ABSPATH')) exit;
use MailPoet\API\REST\Request;
use MailPoet\API\REST\Response;
use MailPoet\Automation\Engine\API\Endpoint;
use MailPoet\Automation\Engine\Builder\CreateAutomationFromTemplateController;
use MailPoet\Automation\Engine\Mappers\AutomationMapper;
use MailPoet\Validator\Builder;
class AutomationsCreateFromTemplateEndpoint extends Endpoint {
/** @var CreateAutomationFromTemplateController */
private $createAutomationFromTemplateController;
/** @var AutomationMapper */
private $automationMapper;
public function __construct(
CreateAutomationFromTemplateController $createAutomationFromTemplateController,
AutomationMapper $automationMapper
) {
$this->createAutomationFromTemplateController = $createAutomationFromTemplateController;
$this->automationMapper = $automationMapper;
}
public function handle(Request $request): Response {
$automation = $this->createAutomationFromTemplateController->createAutomation((string)$request->getParam('slug'));
return new Response($this->automationMapper->buildAutomation($automation));
}
public static function getRequestSchema(): array {
return [
'slug' => Builder::string()->required(),
];
}
}
@@ -0,0 +1,37 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Endpoints\Automations;
if (!defined('ABSPATH')) exit;
use MailPoet\API\REST\Request;
use MailPoet\API\REST\Response;
use MailPoet\Automation\Engine\API\Endpoint;
use MailPoet\Automation\Engine\Builder\DeleteAutomationController;
use MailPoet\Validator\Builder;
class AutomationsDeleteEndpoint extends Endpoint {
/** @var DeleteAutomationController */
private $deleteController;
public function __construct(
DeleteAutomationController $deleteController
) {
$this->deleteController = $deleteController;
}
public function handle(Request $request): Response {
/** @var int $automationId */
$automationId = $request->getParam('id');
$automationId = intval($automationId);
$this->deleteController->deleteAutomation($automationId);
return new Response(null);
}
public static function getRequestSchema(): array {
return [
'id' => Builder::integer()->required(),
];
}
}
@@ -0,0 +1,43 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Endpoints\Automations;
if (!defined('ABSPATH')) exit;
use MailPoet\API\REST\Request;
use MailPoet\API\REST\Response;
use MailPoet\Automation\Engine\API\Endpoint;
use MailPoet\Automation\Engine\Builder\DuplicateAutomationController;
use MailPoet\Automation\Engine\Mappers\AutomationMapper;
use MailPoet\Validator\Builder;
class AutomationsDuplicateEndpoint extends Endpoint {
/** @var AutomationMapper */
private $automationMapper;
/** @var DuplicateAutomationController */
private $duplicateController;
public function __construct(
DuplicateAutomationController $duplicateController,
AutomationMapper $automationMapper
) {
$this->automationMapper = $automationMapper;
$this->duplicateController = $duplicateController;
}
public function handle(Request $request): Response {
/** @var int $automationId */
$automationId = $request->getParam('id');
$automationId = intval($automationId);
$duplicate = $this->duplicateController->duplicateAutomation($automationId);
return new Response($this->automationMapper->buildAutomation($duplicate));
}
public static function getRequestSchema(): array {
return [
'id' => Builder::integer()->required(),
];
}
}
@@ -0,0 +1,41 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Endpoints\Automations;
if (!defined('ABSPATH')) exit;
use MailPoet\API\REST\Request;
use MailPoet\API\REST\Response;
use MailPoet\Automation\Engine\API\Endpoint;
use MailPoet\Automation\Engine\Mappers\AutomationMapper;
use MailPoet\Automation\Engine\Storage\AutomationStorage;
use MailPoet\Validator\Builder;
class AutomationsGetEndpoint extends Endpoint {
/** @var AutomationMapper */
private $automationMapper;
/** @var AutomationStorage */
private $automationStorage;
public function __construct(
AutomationMapper $automationMapper,
AutomationStorage $automationStorage
) {
$this->automationMapper = $automationMapper;
$this->automationStorage = $automationStorage;
}
public function handle(Request $request): Response {
$status = $request->getParam('status') ? (array)$request->getParam('status') : null;
$automations = $this->automationStorage->getAutomations($status);
return new Response($this->automationMapper->buildAutomationList($automations));
}
public static function getRequestSchema(): array {
return [
'status' => Builder::array(Builder::string()),
];
}
}
@@ -0,0 +1,48 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Endpoints\Automations;
if (!defined('ABSPATH')) exit;
use MailPoet\API\REST\Request;
use MailPoet\API\REST\Response;
use MailPoet\Automation\Engine\API\Endpoint;
use MailPoet\Automation\Engine\Builder\UpdateAutomationController;
use MailPoet\Automation\Engine\Mappers\AutomationMapper;
use MailPoet\Automation\Engine\Validation\AutomationSchema;
use MailPoet\Validator\Builder;
class AutomationsPutEndpoint extends Endpoint {
/** @var UpdateAutomationController */
private $updateController;
/** @var AutomationMapper */
private $automationMapper;
public function __construct(
UpdateAutomationController $updateController,
AutomationMapper $automationMapper
) {
$this->updateController = $updateController;
$this->automationMapper = $automationMapper;
}
public function handle(Request $request): Response {
$data = $request->getParams();
/** @var int $automationId */
$automationId = $request->getParam('id');
$automation = $this->updateController->updateAutomation(intval($automationId), $data);
return new Response($this->automationMapper->buildAutomation($automation));
}
public static function getRequestSchema(): array {
return [
'id' => Builder::integer()->required(),
'name' => Builder::string()->minLength(1),
'status' => Builder::string(),
'steps' => AutomationSchema::getStepsSchema(),
'meta' => Builder::object(),
];
}
}
@@ -0,0 +1 @@
<?php
@@ -0,0 +1,103 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\API\API;
use MailPoet\Automation\Engine\Control\StepHandler;
use MailPoet\Automation\Engine\Control\TriggerHandler;
use MailPoet\Automation\Engine\Endpoints\Automations\AutomationsCreateFromTemplateEndpoint;
use MailPoet\Automation\Engine\Endpoints\Automations\AutomationsDeleteEndpoint;
use MailPoet\Automation\Engine\Endpoints\Automations\AutomationsDuplicateEndpoint;
use MailPoet\Automation\Engine\Endpoints\Automations\AutomationsGetEndpoint;
use MailPoet\Automation\Engine\Endpoints\Automations\AutomationsPutEndpoint;
use MailPoet\Automation\Engine\Endpoints\Automations\AutomationTemplateGetEndpoint;
use MailPoet\Automation\Engine\Endpoints\Automations\AutomationTemplatesGetEndpoint;
use MailPoet\Automation\Engine\Storage\AutomationStorage;
use MailPoet\Automation\Integrations\Core\CoreIntegration;
use MailPoet\Automation\Integrations\WordPress\WordPressIntegration;
class Engine {
const CAPABILITY_MANAGE_AUTOMATIONS = 'mailpoet_manage_automations';
/** @var API */
private $api;
/** @var CoreIntegration */
private $coreIntegration;
/** @var WordPressIntegration */
private $wordPressIntegration;
/** @var Registry */
private $registry;
/** @var StepHandler */
private $stepHandler;
/** @var TriggerHandler */
private $triggerHandler;
/** @var WordPress */
private $wordPress;
/** @var AutomationStorage */
private $automationStorage;
public function __construct(
API $api,
CoreIntegration $coreIntegration,
WordPressIntegration $wordPressIntegration,
Registry $registry,
StepHandler $stepHandler,
TriggerHandler $triggerHandler,
WordPress $wordPress,
AutomationStorage $automationStorage
) {
$this->api = $api;
$this->coreIntegration = $coreIntegration;
$this->wordPressIntegration = $wordPressIntegration;
$this->registry = $registry;
$this->stepHandler = $stepHandler;
$this->triggerHandler = $triggerHandler;
$this->wordPress = $wordPress;
$this->automationStorage = $automationStorage;
}
public function initialize(): void {
$this->registerApiRoutes();
$this->api->initialize();
$this->stepHandler->initialize();
$this->triggerHandler->initialize();
$this->coreIntegration->register($this->registry);
$this->wordPressIntegration->register($this->registry);
$this->wordPress->doAction(Hooks::INITIALIZE, [$this->registry]);
$this->registerActiveTriggerHooks();
}
private function registerApiRoutes(): void {
$this->wordPress->addAction(Hooks::API_INITIALIZE, function (API $api) {
$api->registerGetRoute('automations', AutomationsGetEndpoint::class);
$api->registerPutRoute('automations/(?P<id>\d+)', AutomationsPutEndpoint::class);
$api->registerDeleteRoute('automations/(?P<id>\d+)', AutomationsDeleteEndpoint::class);
$api->registerPostRoute('automations/(?P<id>\d+)/duplicate', AutomationsDuplicateEndpoint::class);
$api->registerPostRoute('automations/create-from-template', AutomationsCreateFromTemplateEndpoint::class);
$api->registerGetRoute('automation-templates', AutomationTemplatesGetEndpoint::class);
$api->registerGetRoute('automation-templates/(?P<slug>.+)', AutomationTemplateGetEndpoint::class);
});
}
private function registerActiveTriggerHooks(): void {
$triggerKeys = $this->automationStorage->getActiveTriggerKeys();
foreach ($triggerKeys as $triggerKey) {
$instance = $this->registry->getTrigger($triggerKey);
if ($instance) {
$instance->registerHooks();
}
}
}
}
@@ -0,0 +1,311 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Step;
use MailPoet\Automation\Engine\Exceptions\InvalidStateException;
use MailPoet\Automation\Engine\Exceptions\NotFoundException;
use MailPoet\Automation\Engine\Exceptions\UnexpectedValueException;
use MailPoet\Automation\Engine\Utils\Json;
class Exceptions {
private const DATABASE_ERROR = 'mailpoet_automation_database_error';
private const JSON_NOT_OBJECT = 'mailpoet_automation_json_not_object';
private const AUTOMATION_NOT_FOUND = 'mailpoet_automation_not_found';
private const AUTOMATION_VERSION_NOT_FOUND = 'mailpoet_automation_version_not_found';
private const AUTOMATION_NOT_ACTIVE = 'mailpoet_automation_not_active';
private const AUTOMATION_RUN_NOT_FOUND = 'mailpoet_automation_run_not_found';
private const AUTOMATION_STEP_NOT_FOUND = 'mailpoet_automation_step_not_found';
private const AUTOMATION_TRIGGER_NOT_FOUND = 'mailpoet_automation_trigger_not_found';
private const AUTOMATION_RUN_NOT_RUNNING = 'mailpoet_automation_run_not_running';
private const SUBJECT_NOT_FOUND = 'mailpoet_automation_subject_not_found';
private const SUBJECT_LOAD_FAILED = 'mailpoet_automation_subject_load_failed';
private const SUBJECT_DATA_NOT_FOUND = 'mailpoet_automation_subject_data_not_found';
private const MULTIPLE_SUBJECTS_FOUND = 'mailpoet_automation_multiple_subjects_found';
private const PAYLOAD_NOT_FOUND = 'mailpoet_automation_payload_not_found';
private const MULTIPLE_PAYLOADS_FOUND = 'mailpoet_automation_multiple_payloads_found';
private const FIELD_NOT_FOUND = 'mailpoet_automation_field_not_found';
private const FIELD_LOAD_FAILED = 'mailpoet_automation_field_load_failed';
private const FILTER_NOT_FOUND = 'mailpoet_automation_filter_not_found';
private const NEXT_STEP_NOT_FOUND = 'mailpoet_next_step_not_found';
private const NEXT_STEP_NOT_SCHEDULED = 'mailpoet_next_step_not_scheduled';
private const AUTOMATION_STRUCTURE_MODIFICATION_NOT_SUPPORTED = 'mailpoet_automation_structure_modification_not_supported';
private const AUTOMATION_STRUCTURE_NOT_VALID = 'mailpoet_automation_structure_not_valid';
private const AUTOMATION_STEP_MODIFIED_WHEN_UNKNOWN = 'mailpoet_automation_step_modified_when_unknown';
private const AUTOMATION_NOT_VALID = 'mailpoet_automation_not_valid';
private const MISSING_REQUIRED_SUBJECTS = 'mailpoet_automation_missing_required_subjects';
private const AUTOMATION_NOT_TRASHED = 'mailpoet_automation_not_trashed';
private const AUTOMATION_TEMPLATE_NOT_FOUND = 'mailpoet_automation_template_not_found';
private const AUTOMATION_HAS_ACTIVE_RUNS = 'mailpoet_automation_has_active_runs';
private const AUTOMATION_STEP_NOT_STARTED = 'mailpoet_automation_step_not_started';
private const AUTOMATION_STEP_NOT_RUNNING = 'mailpoet_automation_step_not_running';
private const AUTOMATION_STEP_ACTION_PROCESSED = 'mailpoet_automation_step_action_processed';
public function __construct() {
throw new InvalidStateException(
"This is a static factory class. Use it via 'Exception::someError()' factories."
);
}
public static function databaseError(string $error): InvalidStateException {
return InvalidStateException::create()
->withErrorCode(self::DATABASE_ERROR)
// translators: %s is the error message.
->withMessage(sprintf(__('Database error: %s', 'mailpoet'), $error));
}
public static function jsonNotObject(string $json): UnexpectedValueException {
return UnexpectedValueException::create()
->withErrorCode(self::JSON_NOT_OBJECT)
// translators: %s is the mentioned JSON string.
->withMessage(sprintf(__("JSON string '%s' doesn't encode an object.", 'mailpoet'), $json));
}
public static function automationNotFound(int $id): NotFoundException {
return NotFoundException::create()
->withErrorCode(self::AUTOMATION_NOT_FOUND)
// translators: %d is the ID of the automation.
->withMessage(sprintf(__("Automation with ID '%d' not found.", 'mailpoet'), $id));
}
public static function automationNotFoundInTimeSpan(int $id): NotFoundException {
return NotFoundException::create()
->withErrorCode(self::AUTOMATION_NOT_FOUND)
// translators: %d is the ID of the automation.
->withMessage(sprintf(__("Automation with ID '%d' not found in selected time span.", 'mailpoet'), $id));
}
public static function automationVersionNotFound(int $automation, int $version): NotFoundException {
return NotFoundException::create()
->withErrorCode(self::AUTOMATION_VERSION_NOT_FOUND)
// translators: %1$s is the ID of the automation, %2$s the version.
->withMessage(sprintf(__('Automation with ID "%1$s" in version "%2$s" not found.', 'mailpoet'), $automation, $version));
}
public static function automationNotActive(int $automation): InvalidStateException {
return InvalidStateException::create()
->withErrorCode(self::AUTOMATION_NOT_ACTIVE)
// translators: %1$s is the ID of the automation.
->withMessage(sprintf(__('Automation with ID "%1$s" is no longer active.', 'mailpoet'), $automation));
}
public static function automationRunNotFound(int $id): NotFoundException {
return NotFoundException::create()
->withErrorCode(self::AUTOMATION_RUN_NOT_FOUND)
// translators: %d is the ID of the automation run.
->withMessage(sprintf(__("Automation run with ID '%d' not found.", 'mailpoet'), $id));
}
public static function automationStepNotFound(string $key): NotFoundException {
return NotFoundException::create()
->withErrorCode(self::AUTOMATION_STEP_NOT_FOUND)
// translators: %s is the key of the automation step.
->withMessage(sprintf(__("Automation step with key '%s' not found.", 'mailpoet'), $key));
}
public static function automationTriggerNotFound(int $automationId, string $key): NotFoundException {
return NotFoundException::create()
->withErrorCode(self::AUTOMATION_TRIGGER_NOT_FOUND)
// translators: %1$s is the key, %2$d is the automation ID.
->withMessage(sprintf(__('Automation trigger with key "%1$s" not found in automation ID "%2$d".', 'mailpoet'), $key, $automationId));
}
public static function automationRunNotRunning(int $id, string $status): InvalidStateException {
return InvalidStateException::create()
->withErrorCode(self::AUTOMATION_RUN_NOT_RUNNING)
// translators: %1$d is the ID of the automation run, %2$s its current status.
->withMessage(sprintf(__('Automation run with ID "%1$d" is not running. Status: %2$s', 'mailpoet'), $id, $status));
}
public static function subjectNotFound(string $key): NotFoundException {
return NotFoundException::create()
->withErrorCode(self::SUBJECT_NOT_FOUND)
// translators: %s is the key of the subject not found.
->withMessage(sprintf(__("Subject with key '%s' not found.", 'mailpoet'), $key));
}
public static function subjectClassNotFound(string $class): NotFoundException {
return NotFoundException::create()
->withErrorCode(self::SUBJECT_NOT_FOUND)
// translators: %s is the class name of the subject not found.
->withMessage(sprintf(__("Subject of class '%s' not found.", 'mailpoet'), $class));
}
public static function subjectLoadFailed(string $key, array $args): InvalidStateException {
return InvalidStateException::create()
->withErrorCode(self::SUBJECT_LOAD_FAILED)
// translators: %1$s is the name of the key, %2$s the arguments.
->withMessage(sprintf(__('Subject with key "%1$s" and args "%2$s" failed to load.', 'mailpoet'), $key, Json::encode($args)));
}
public static function subjectDataNotFound(string $key, int $automationRunId): NotFoundException {
return NotFoundException::create()
->withErrorCode(self::SUBJECT_DATA_NOT_FOUND)
// translators: %1$s is the key of the subject, %2$d is automation run ID.
->withMessage(
sprintf(__("Subject data for subject with key '%1\$s' not found for automation run with ID '%2\$d'.", 'mailpoet'), $key, $automationRunId)
);
}
public static function multipleSubjectsFound(string $key, int $automationRunId): InvalidStateException {
return InvalidStateException::create()
->withErrorCode(self::MULTIPLE_SUBJECTS_FOUND)
// translators: %1$s is the key of the subject, %2$d is automation run ID.
->withMessage(
sprintf(__("Multiple subjects with key '%1\$s' found for automation run with ID '%2\$d', only one expected.", 'mailpoet'), $key, $automationRunId)
);
}
public static function payloadNotFound(string $class, int $automationRunId): NotFoundException {
return NotFoundException::create()
->withErrorCode(self::PAYLOAD_NOT_FOUND)
// translators: %1$s is the class of the payload, %2$d is automation run ID.
->withMessage(
sprintf(__("Payload of class '%1\$s' not found for automation run with ID '%2\$d'.", 'mailpoet'), $class, $automationRunId)
);
}
public static function multiplePayloadsFound(string $class, int $automationRunId): NotFoundException {
return NotFoundException::create()
->withErrorCode(self::MULTIPLE_PAYLOADS_FOUND)
// translators: %1$s is the class of the payloads, %2$d is automation run ID.
->withMessage(
sprintf(__("Multiple payloads of class '%1\$s' found for automation run with ID '%2\$d'.", 'mailpoet'), $class, $automationRunId)
);
}
public static function fieldNotFound(string $key): NotFoundException {
return NotFoundException::create()
->withErrorCode(self::FIELD_NOT_FOUND)
// translators: %s is the key of the field not found.
->withMessage(sprintf(__("Field with key '%s' not found.", 'mailpoet'), $key));
}
public static function fieldLoadFailed(string $key, array $args): InvalidStateException {
return InvalidStateException::create()
->withErrorCode(self::FIELD_LOAD_FAILED)
// translators: %1$s is the key of the field, %2$s its arguments.
->withMessage(sprintf(__('Field with key "%1$s" and args "%2$s" failed to load.', 'mailpoet'), $key, Json::encode($args)));
}
public static function filterNotFound(string $fieldType): NotFoundException {
return NotFoundException::create()
->withErrorCode(self::FILTER_NOT_FOUND)
// translators: %s is the type of the field for which a filter was not found.
->withMessage(sprintf(__("Filter for field of type '%s' not found.", 'mailpoet'), $fieldType));
}
public static function nextStepNotFound(string $stepId, int $nextStepId): InvalidStateException {
return InvalidStateException::create()
->withErrorCode(self::NEXT_STEP_NOT_FOUND)
// translators: %1$d is the ID of the automation step, %2$s is the ID of the next step.
->withMessage(sprintf(__("Automation step with ID '%1\$s' doesn't have a next step with index '%2\$d'.", 'mailpoet'), $stepId, $nextStepId));
}
public static function nextStepNotScheduled(string $stepId): InvalidStateException {
return InvalidStateException::create()
->withErrorCode(self::NEXT_STEP_NOT_SCHEDULED)
// translators: %1$d is the ID of the automation step, %2$s is the ID of the next step.
->withMessage(sprintf(__("Automation step with ID '%s' did not schedule a specific next step, even though multiple next steps are possible.", 'mailpoet'), $stepId));
}
public static function automationStructureModificationNotSupported(): UnexpectedValueException {
return UnexpectedValueException::create()
->withErrorCode(self::AUTOMATION_STRUCTURE_MODIFICATION_NOT_SUPPORTED)
->withMessage(__('Automation structure modification not supported.', 'mailpoet'));
}
public static function automationStructureNotValid(string $detail, string $ruleId): UnexpectedValueException {
return UnexpectedValueException::create()
->withErrorCode(self::AUTOMATION_STRUCTURE_NOT_VALID)
// translators: %s is a detailed information
->withMessage(sprintf(__("Invalid automation structure: %s", 'mailpoet'), $detail))
->withErrors(['rule_id' => $ruleId]);
}
public static function automationStepModifiedWhenUnknown(Step $step): UnexpectedValueException {
return UnexpectedValueException::create()
->withErrorCode(self::AUTOMATION_STEP_MODIFIED_WHEN_UNKNOWN)
// translators: %1$s is the key of the step, %2$s is the type of the step, %3\$s is its ID.
->withMessage(
sprintf(
__("Modification of step '%1\$s' of type '%2\$s' with ID '%3\$s' is not supported when the related plugin is not active.", 'mailpoet'),
$step->getKey(),
$step->getType(),
$step->getId()
)
);
}
public static function automationNotValid(string $detail, array $errors): UnexpectedValueException {
return UnexpectedValueException::create()
->withErrorCode(self::AUTOMATION_NOT_VALID)
// translators: %s is a detailed information
->withMessage(sprintf(__("Automation validation failed: %s", 'mailpoet'), $detail))
->withErrors($errors);
}
public static function missingRequiredSubjects(Step $step, array $missingSubjectKeys): UnexpectedValueException {
return UnexpectedValueException::create()
->withErrorCode(self::MISSING_REQUIRED_SUBJECTS)
// translators: %1$s is the key of the step, %2$s are the missing subject keys.
->withMessage(
sprintf(
__("Step with ID '%1\$s' is missing required subjects with keys: %2\$s", 'mailpoet'),
$step->getId(),
implode(', ', $missingSubjectKeys)
)
)
->withErrors(
['general' => __('This step can not be used with the selected trigger.', 'mailpoet')]
);
}
public static function automationNotTrashed(int $id): UnexpectedValueException {
return UnexpectedValueException::create()
->withErrorCode(self::AUTOMATION_NOT_TRASHED)
// translators: %d is the ID of the automation.
->withMessage(sprintf(__("Can't delete automation with ID '%d' because it was not trashed.", 'mailpoet'), $id));
}
public static function automationTemplateNotFound(string $id): NotFoundException {
return NotFoundException::create()
->withErrorCode(self::AUTOMATION_TEMPLATE_NOT_FOUND)
// translators: %d is the ID of the automation template.
->withMessage(sprintf(__("Automation template with ID '%d' not found.", 'mailpoet'), $id));
}
/**
* This is a temporary block, see MAILPOET-4744
*/
public static function automationHasActiveRuns(int $id): InvalidStateException {
return InvalidStateException::create()
->withErrorCode(self::AUTOMATION_HAS_ACTIVE_RUNS)
// translators: %d is the ID of the automation.
->withMessage(sprintf(__("Can not update automation with ID '%d' because users are currently active.", 'mailpoet'), $id));
}
public static function stepNotStarted(string $id, int $runId): InvalidStateException {
return InvalidStateException::create()
->withErrorCode(self::AUTOMATION_STEP_NOT_STARTED)
// translators: %1$s is the ID of the automation step, %2$d is the automation run ID.
->withMessage(sprintf(__("Automation step with ID '%1\$s' was not started in automation run with ID '%2\$d'.", 'mailpoet'), $id, $runId));
}
public static function stepNotRunning(string $id, string $status, int $runId): InvalidStateException {
return InvalidStateException::create()
->withErrorCode(self::AUTOMATION_STEP_NOT_RUNNING)
// translators: %1$s is the ID of the automation step, %2$s its current status, %3$d is the automation run ID.
->withMessage(sprintf(__("Automation step with ID '%1\$s' is not running in automation run with ID '%2\$d'. Status: '%3\$s'", 'mailpoet'), $id, $runId, $status));
}
public static function stepActionProcessed(string $id, int $runId, int $runNumber): InvalidStateException {
return InvalidStateException::create()
->withErrorCode(self::AUTOMATION_STEP_ACTION_PROCESSED)
// translators: %1$d is the automation run ID, %2$s is the ID of the automation step, %3$d is the run number.
->withMessage(sprintf(__("Automation run with ID '%1\$d' already has a processed action for step with ID '%2\$s' and run number '%3\$d'.", 'mailpoet'), $runId, $id, $runNumber));
}
}
@@ -0,0 +1,14 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Exceptions;
if (!defined('ABSPATH')) exit;
/**
* USE: When an action is forbidden for given actor (although generally valid).
* API: 403 Forbidden
*/
class AccessDeniedException extends UnexpectedValueException {
protected $statusCode = 403;
}
@@ -0,0 +1,14 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Exceptions;
if (!defined('ABSPATH')) exit;
/**
* USE: When the main action produces conflict (i.e. duplicate key).
* API: 409 Conflict
*/
class ConflictException extends UnexpectedValueException {
protected $statusCode = 409;
}
@@ -0,0 +1,80 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Exceptions;
if (!defined('ABSPATH')) exit;
use Exception as PhpException;
use MailPoet\API\REST\Exception as RestException;
use Throwable;
/**
* Frames all MailPoet Automation exceptions ("$e instanceof MailPoet\Automation\Exception").
*/
abstract class Exception extends PhpException implements RestException {
/** @var int */
protected $statusCode = 500;
/** @var string */
protected $errorCode;
/** @var string[] */
protected $errors = [];
final public function __construct(
string $message = null,
string $errorCode = null,
Throwable $previous = null
) {
parent::__construct($message ?? __('Unknown error.', 'mailpoet'), 0, $previous);
$this->errorCode = $errorCode ?? 'mailpoet_automation_unknown_error';
}
/** @return static */
public static function create(Throwable $previous = null) {
return new static(null, null, $previous);
}
/** @return static */
public function withStatusCode(int $statusCode) {
$this->statusCode = $statusCode;
return $this;
}
/** @return static */
public function withError(string $id, string $error) {
$this->errors[$id] = $error;
return $this;
}
/** @return static */
public function withErrorCode(string $errorCode) {
$this->errorCode = $errorCode;
return $this;
}
/** @return static */
public function withMessage(string $message) {
$this->message = $message;
return $this;
}
/** @return static */
public function withErrors(array $errors) {
$this->errors = $errors;
return $this;
}
public function getStatusCode(): int {
return $this->statusCode;
}
public function getErrorCode(): string {
return $this->errorCode;
}
public function getErrors(): array {
return $this->errors;
}
}
@@ -0,0 +1,13 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Exceptions;
if (!defined('ABSPATH')) exit;
/**
* USE: An application state that should not occur. Can be subclassed for feature-specific exceptions.
* API: 500 Server Error
*/
class InvalidStateException extends RuntimeException {
}
@@ -0,0 +1,14 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Exceptions;
if (!defined('ABSPATH')) exit;
/**
* USE: When the main resource we're interested in doesn't exist.
* API: 404 Not Found
*/
class NotFoundException extends UnexpectedValueException {
protected $statusCode = 404;
}
@@ -0,0 +1,13 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Exceptions;
if (!defined('ABSPATH')) exit;
/**
* USE: Generic runtime error. When possible, use a more specific exception instead.
* API: 500 Server Error
*/
class RuntimeException extends Exception {
}
@@ -0,0 +1,14 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Exceptions;
if (!defined('ABSPATH')) exit;
/**
* USE: When wrong data VALUE is received.
* API: 400 Bad Request
*/
class UnexpectedValueException extends RuntimeException {
protected $statusCode = 400;
}
@@ -0,0 +1 @@
<?php
@@ -0,0 +1,51 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Automation;
use MailPoet\Automation\Engine\Data\AutomationRunLog;
use MailPoet\Automation\Engine\Data\Step;
class Hooks {
/** @var WordPress */
private $wordPress;
public function __construct(
WordPress $wordPress
) {
$this->wordPress = $wordPress;
}
public const INITIALIZE = 'mailpoet/automation/initialize';
public const API_INITIALIZE = 'mailpoet/automation/api/initialize';
public const TRIGGER = 'mailpoet/automation/trigger';
public const AUTOMATION_STEP = 'mailpoet/automation/step';
public const EDITOR_BEFORE_LOAD = 'mailpoet/automation/editor/before_load';
public const AUTOMATION_BEFORE_SAVE = 'mailpoet/automation/before_save';
public const AUTOMATION_STEP_BEFORE_SAVE = 'mailpoet/automation/step/before_save';
public const AUTOMATION_STEP_LOG_AFTER_RUN = 'mailpoet/automation/step/log_after_run';
public const AUTOMATION_RUN_CREATE = 'mailpoet/automation/run/create';
public function doAutomationBeforeSave(Automation $automation): void {
$this->wordPress->doAction(self::AUTOMATION_BEFORE_SAVE, $automation);
}
public function doAutomationStepBeforeSave(Step $step, Automation $automation): void {
$this->wordPress->doAction(self::AUTOMATION_STEP_BEFORE_SAVE, $step, $automation);
}
public function doAutomationStepByKeyBeforeSave(Step $step, Automation $automation): void {
$this->wordPress->doAction(self::AUTOMATION_STEP_BEFORE_SAVE . '/key=' . $step->getKey(), $step, $automation);
}
public function doAutomationStepAfterRun(AutomationRunLog $automationRunLog): void {
$this->wordPress->doAction(self::AUTOMATION_STEP_LOG_AFTER_RUN, $automationRunLog);
}
}
@@ -0,0 +1,10 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine;
if (!defined('ABSPATH')) exit;
interface Integration {
public function register(Registry $registry): void;
}
@@ -0,0 +1,13 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Integration;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Control\StepRunController;
use MailPoet\Automation\Engine\Data\StepRunArgs;
interface Action extends Step {
public function run(StepRunArgs $args, StepRunController $controller): void;
}
@@ -0,0 +1,23 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Integration;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Filter as FilterData;
use MailPoet\Validator\Schema\ObjectSchema;
interface Filter {
public function getFieldType(): string;
/** @return array<string, string> */
public function getConditions(): array;
public function getArgsSchema(string $condition): ObjectSchema;
public function getFieldParams(FilterData $data): array;
/** @param mixed $value */
public function matches(FilterData $data, $value): bool;
}
@@ -0,0 +1,9 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Integration;
if (!defined('ABSPATH')) exit;
interface Payload {
}
@@ -0,0 +1,22 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Integration;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\StepValidationArgs;
use MailPoet\Validator\Schema\ObjectSchema;
interface Step {
public function getKey(): string;
public function getName(): string;
public function getArgsSchema(): ObjectSchema;
/** @return string[] */
public function getSubjectKeys(): array;
public function validate(StepValidationArgs $args): void;
}
@@ -0,0 +1,27 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Integration;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Field;
use MailPoet\Automation\Engine\Data\Subject as SubjectData;
use MailPoet\Validator\Schema\ObjectSchema;
/**
* @template-covariant T of Payload
*/
interface Subject {
public function getKey(): string;
public function getName(): string;
public function getArgsSchema(): ObjectSchema;
/** @return Field[] */
public function getFields(): array;
/** @return T */
public function getPayload(SubjectData $subjectData): Payload;
}
@@ -0,0 +1,16 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Integration;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Subject;
interface SubjectTransformer {
public function transform(Subject $data): ?Subject;
public function returns(): string;
public function accepts(): string;
}
@@ -0,0 +1,14 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Integration;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\StepRunArgs;
interface Trigger extends Step {
public function registerHooks(): void;
public function isTriggeredBy(StepRunArgs $args): bool;
}
@@ -0,0 +1,11 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Integration;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Exceptions\UnexpectedValueException;
class ValidationException extends UnexpectedValueException {
}
@@ -0,0 +1,78 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Mappers;
if (!defined('ABSPATH')) exit;
use DateTimeImmutable;
use MailPoet\Automation\Engine\Data\Automation;
use MailPoet\Automation\Engine\Data\AutomationStatistics;
use MailPoet\Automation\Engine\Data\NextStep;
use MailPoet\Automation\Engine\Data\Step;
use MailPoet\Automation\Engine\Storage\AutomationStatisticsStorage;
class AutomationMapper {
/** @var AutomationStatisticsStorage */
private $statisticsStorage;
public function __construct(
AutomationStatisticsStorage $statisticsStorage
) {
$this->statisticsStorage = $statisticsStorage;
}
public function buildAutomation(Automation $automation, AutomationStatistics $statistics = null): array {
return [
'id' => $automation->getId(),
'name' => $automation->getName(),
'status' => $automation->getStatus(),
'created_at' => $automation->getCreatedAt()->format(DateTimeImmutable::W3C),
'updated_at' => $automation->getUpdatedAt()->format(DateTimeImmutable::W3C),
'activated_at' => $automation->getActivatedAt() ? $automation->getActivatedAt()->format(DateTimeImmutable::W3C) : null,
'author' => [
'id' => $automation->getAuthor()->ID,
'name' => $automation->getAuthor()->display_name,
],
'stats' => $statistics ? $statistics->toArray() : $this->statisticsStorage->getAutomationStats($automation->getId())->toArray(),
'steps' => array_map(function (Step $step) {
return [
'id' => $step->getId(),
'type' => $step->getType(),
'key' => $step->getKey(),
'args' => $step->getArgs(),
'next_steps' => array_map(function (NextStep $nextStep) {
return $nextStep->toArray();
}, $step->getNextSteps()),
'filters' => $step->getFilters() ? $step->getFilters()->toArray() : null,
];
}, $automation->getSteps()),
'meta' => (object)$automation->getAllMetas(),
];
}
/** @param Automation[] $automations */
public function buildAutomationList(array $automations): array {
$statistics = $this->statisticsStorage->getAutomationStatisticsForAutomations(...$automations);
return array_map(function (Automation $automation) use ($statistics) {
return $this->buildAutomationListItem($automation, $statistics[$automation->getId()]);
}, $automations);
}
private function buildAutomationListItem(Automation $automation, AutomationStatistics $statistics): array {
return [
'id' => $automation->getId(),
'name' => $automation->getName(),
'status' => $automation->getStatus(),
'created_at' => $automation->getCreatedAt()->format(DateTimeImmutable::W3C),
'updated_at' => $automation->getUpdatedAt()->format(DateTimeImmutable::W3C),
'stats' => $statistics->toArray(),
'activated_at' => $automation->getActivatedAt() ? $automation->getActivatedAt()->format(DateTimeImmutable::W3C) : null,
'author' => [
'id' => $automation->getAuthor()->ID,
'name' => $automation->getAuthor()->display_name,
],
];
}
}
@@ -0,0 +1 @@
<?php
@@ -0,0 +1,275 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Control\RootStep;
use MailPoet\Automation\Engine\Data\AutomationTemplate;
use MailPoet\Automation\Engine\Data\AutomationTemplateCategory;
use MailPoet\Automation\Engine\Data\Field;
use MailPoet\Automation\Engine\Exceptions\InvalidStateException;
use MailPoet\Automation\Engine\Integration\Action;
use MailPoet\Automation\Engine\Integration\Filter;
use MailPoet\Automation\Engine\Integration\Payload;
use MailPoet\Automation\Engine\Integration\Step;
use MailPoet\Automation\Engine\Integration\Subject;
use MailPoet\Automation\Engine\Integration\SubjectTransformer;
use MailPoet\Automation\Engine\Integration\Trigger;
class Registry {
/** @var array<string, AutomationTemplate> */
private $templates;
/** @var array<string, AutomationTemplateCategory> */
private $templateCategories = [];
/** @var array<string, Step> */
private $steps = [];
/** @var array<string, Subject<Payload>> */
private $subjects = [];
/** @var SubjectTransformer[] */
private $subjectTransformers = [];
/** @var array<string, Field>|null */
private $fields = null;
/** @var array<string, Filter> */
private $filters = [];
/** @var array<string, Trigger> */
private $triggers = [];
/** @var array<string, Action> */
private $actions = [];
/** @var array<string, callable> */
private $contextFactories = [];
/** @var WordPress */
private $wordPress;
public function __construct(
RootStep $rootStep,
WordPress $wordPress
) {
$this->wordPress = $wordPress;
$this->steps[$rootStep->getKey()] = $rootStep;
}
public function setupTemplateCategories(): void {
$this->templateCategories = [
'welcome' => new AutomationTemplateCategory('welcome', __('Welcome', 'mailpoet')),
'abandoned-cart' => new AutomationTemplateCategory('abandoned-cart', __('Abandoned Cart', 'mailpoet')),
'reengagement' => new AutomationTemplateCategory('reengagement', __('Re-engagement', 'mailpoet')),
'woocommerce' => new AutomationTemplateCategory('woocommerce', __('WooCommerce', 'mailpoet')),
];
}
public function addTemplate(AutomationTemplate $template): void {
$category = $template->getCategory();
$templateCategories = $this->getTemplateCategories();
if (!isset($templateCategories[$category])) {
throw InvalidStateException::create()->withMessage(
sprintf("Category '%s' was not registered", $category)
);
}
$this->templates[$template->getSlug()] = $template;
// keep coming soon templates at the end
uasort(
$this->templates,
function (AutomationTemplate $a, AutomationTemplate $b): int {
if ($a->getType() === AutomationTemplate::TYPE_COMING_SOON) {
return 1;
}
if ($b->getType() === AutomationTemplate::TYPE_COMING_SOON) {
return -1;
}
return 0;
}
);
}
public function getTemplate(string $slug): ?AutomationTemplate {
return $this->getTemplates()[$slug] ?? null;
}
/** @return array<string, AutomationTemplate> */
public function getTemplates(string $category = null): array {
return $category
? array_filter(
$this->templates,
function(AutomationTemplate $template) use ($category): bool {
return $template->getCategory() === $category;
}
)
: $this->templates;
}
public function removeTemplate(string $slug): void {
unset($this->templates[$slug]);
}
/** @return array<string, AutomationTemplateCategory> */
public function getTemplateCategories(): array {
if (empty($this->templateCategories)) {
$this->setupTemplateCategories();
}
return $this->templateCategories;
}
/** @param Subject<Payload> $subject */
public function addSubject(Subject $subject): void {
$key = $subject->getKey();
if (isset($this->subjects[$key])) {
throw new \Exception(); // TODO
}
$this->subjects[$key] = $subject;
// reset fields cache
$this->fields = null;
}
/** @return Subject<Payload>|null */
public function getSubject(string $key): ?Subject {
return $this->subjects[$key] ?? null;
}
/** @return array<string, Subject<Payload>> */
public function getSubjects(): array {
return $this->subjects;
}
public function addSubjectTransformer(SubjectTransformer $transformer): void {
$this->subjectTransformers[] = $transformer;
}
public function getSubjectTransformers(): array {
return $this->subjectTransformers;
}
public function getField(string $key): ?Field {
return $this->getFields()[$key] ?? null;
}
/** @return array<string, Field> */
public function getFields(): array {
// add fields lazily (on the first call)
if ($this->fields === null) {
$this->fields = [];
foreach ($this->subjects as $subject) {
foreach ($subject->getFields() as $field) {
$this->addField($field);
}
}
}
return $this->fields ?? [];
}
public function addFilter(Filter $filter): void {
$fieldType = $filter->getFieldType();
if (isset($this->filters[$fieldType])) {
throw new \Exception(); // TODO
}
$this->filters[$fieldType] = $filter;
}
public function getFilter(string $fieldType): ?Filter {
return $this->filters[$fieldType] ?? null;
}
/** @return array<string, Filter> */
public function getFilters(): array {
return $this->filters;
}
public function addStep(Step $step): void {
if ($step instanceof Trigger) {
$this->addTrigger($step);
} elseif ($step instanceof Action) {
$this->addAction($step);
}
// TODO: allow adding any other step implementations?
}
public function getStep(string $key): ?Step {
return $this->steps[$key] ?? null;
}
/** @return array<string, Step> */
public function getSteps(): array {
return $this->steps;
}
public function addTrigger(Trigger $trigger): void {
$key = $trigger->getKey();
if (isset($this->steps[$key]) || isset($this->triggers[$key])) {
throw new \Exception(); // TODO
}
$this->steps[$key] = $trigger;
$this->triggers[$key] = $trigger;
}
public function getTrigger(string $key): ?Trigger {
return $this->triggers[$key] ?? null;
}
/** @return array<string, Trigger> */
public function getTriggers(): array {
return $this->triggers;
}
public function addAction(Action $action): void {
$key = $action->getKey();
if (isset($this->steps[$key]) || isset($this->actions[$key])) {
throw new \Exception(); // TODO
}
$this->steps[$key] = $action;
$this->actions[$key] = $action;
}
public function getAction(string $key): ?Action {
return $this->actions[$key] ?? null;
}
/** @return array<string, Action> */
public function getActions(): array {
return $this->actions;
}
public function addContextFactory(string $key, callable $factory): void {
$this->contextFactories[$key] = $factory;
}
/** @return callable[] */
public function getContextFactories(): array {
return $this->contextFactories;
}
public function onBeforeAutomationSave(callable $callback, int $priority = 10): void {
$this->wordPress->addAction(Hooks::AUTOMATION_BEFORE_SAVE, $callback, $priority);
}
public function onBeforeAutomationStepSave(callable $callback, string $key = null, int $priority = 10): void {
$keyPart = $key ? "/key=$key" : '';
$this->wordPress->addAction(Hooks::AUTOMATION_STEP_BEFORE_SAVE . $keyPart, $callback, $priority, 2);
}
/**
* This is used only internally. Fields are added lazily from subjects.
*/
private function addField(Field $field): void {
$key = $field->getKey();
if (isset($this->fields[$key])) {
throw new \Exception(); // TODO
}
$this->fields[$key] = $field;
}
}
@@ -0,0 +1,134 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Storage;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\AutomationRunLog;
use MailPoet\Automation\Engine\Exceptions;
use MailPoet\InvalidStateException;
class AutomationRunLogStorage {
/** @var string */
private $table;
public function __construct() {
global $wpdb;
$this->table = $wpdb->prefix . 'mailpoet_automation_run_logs';
}
public function createAutomationRunLog(AutomationRunLog $automationRunLog): int {
global $wpdb;
$result = $wpdb->insert($this->table, $automationRunLog->toArray());
if ($result === false) {
$this->throwDatabaseError();
}
return $wpdb->insert_id; // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
}
public function updateAutomationRunLog(AutomationRunLog $automationRunLog): void {
global $wpdb;
$result = $wpdb->update($this->table, $automationRunLog->toArray(), ['id' => $automationRunLog->getId()]);
if ($result === false) {
$this->throwDatabaseError();
}
}
public function getAutomationRunStatisticsForAutomationInTimeFrame(int $automationId, string $status, \DateTimeImmutable $after, \DateTimeImmutable $before, int $versionId = null): array {
global $wpdb;
$andWhere = $versionId ? 'AND run.version_id = %d' : '';
$results = $wpdb->get_results(
// phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- The number of replacements is dynamic.
$wpdb->prepare(
'
SELECT COUNT(log.id) AS count, log.step_id
FROM %i AS log
JOIN %i AS run ON log.automation_run_id = run.id
WHERE run.automation_id = %d AND log.status = %s AND run.created_at BETWEEN %s AND %s
' . $andWhere . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The condition uses placeholders. */ '
GROUP BY log.step_id
',
array_merge(
[
$this->table,
$wpdb->prefix . 'mailpoet_automation_runs',
$automationId,
$status,
$after->format('Y-m-d H:i:s'),
$before->format('Y-m-d H:i:s'),
],
$versionId ? [$versionId] : []
)
),
ARRAY_A
);
return is_array($results) ? $results : [];
}
public function getAutomationRunLog(int $id): ?AutomationRunLog {
global $wpdb;
$result = $wpdb->get_row(
$wpdb->prepare('SELECT * FROM %i WHERE id = %d', $this->table, $id),
ARRAY_A
);
if ($result) {
$data = (array)$result;
return AutomationRunLog::fromArray($data);
}
return null;
}
public function getAutomationRunLogByRunAndStepId(int $runId, string $stepId): ?AutomationRunLog {
global $wpdb;
$result = $wpdb->get_row(
$wpdb->prepare('SELECT * FROM %i WHERE automation_run_id = %d AND step_id = %s', $this->table, $runId, $stepId),
ARRAY_A
);
return $result ? AutomationRunLog::fromArray((array)$result) : null;
}
/** @return AutomationRunLog[] */
public function getLogsForAutomationRun(int $automationRunId): array {
global $wpdb;
$results = $wpdb->get_results(
$wpdb->prepare(
'
SELECT *
FROM %i
WHERE automation_run_id = %d
ORDER BY id ASC
',
$this->table,
$automationRunId
),
ARRAY_A
);
if (!is_array($results)) {
throw InvalidStateException::create();
}
if ($results) {
return array_map(function($data) {
/** @var array $data - for PHPStan because it conflicts with expected callable(mixed): mixed)|null */
return AutomationRunLog::fromArray($data);
}, $results);
}
return [];
}
public function truncate(): void {
global $wpdb;
$wpdb->query($wpdb->prepare('TRUNCATE %i', $this->table));
}
private function throwDatabaseError(): void {
global $wpdb;
throw Exceptions::databaseError($wpdb->last_error); // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
}
}
@@ -0,0 +1,263 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Storage;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Automation;
use MailPoet\Automation\Engine\Data\AutomationRun;
use MailPoet\Automation\Engine\Data\Subject;
use MailPoet\Automation\Engine\Exceptions;
class AutomationRunStorage {
/** @var string */
private $table;
/** @var string */
private $subjectTable;
public function __construct() {
global $wpdb;
$this->table = $wpdb->prefix . 'mailpoet_automation_runs';
$this->subjectTable = $wpdb->prefix . 'mailpoet_automation_run_subjects';
}
public function createAutomationRun(AutomationRun $automationRun): int {
global $wpdb;
$automationTableData = $automationRun->toArray();
$subjectTableData = $automationTableData['subjects'];
unset($automationTableData['subjects']);
$result = $wpdb->insert($this->table, $automationTableData);
if ($result === false) {
$this->throwDatabaseError();
}
$automationRunId = $wpdb->insert_id; // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
if (!$subjectTableData) {
//We allow for AutomationRuns with no subjects.
return $automationRunId;
}
$values = [];
foreach ($subjectTableData as $entry) {
$values[] = $wpdb->prepare('(%d,%s,%s,%s)', $automationRunId, $entry['key'], $entry['args'], $entry['hash']);
}
$result = $wpdb->query(
$wpdb->prepare('INSERT INTO %i (`automation_run_id`, `key`, `args`, `hash`) VALUES ', $this->subjectTable) . implode(',', $values)
);
if ($result === false) {
$this->throwDatabaseError();
}
return $automationRunId;
}
public function getAutomationRun(int $id): ?AutomationRun {
global $wpdb;
$data = $wpdb->get_row(
$wpdb->prepare('SELECT * FROM %i WHERE id = %d', $this->table, $id),
ARRAY_A
);
if (!is_array($data) || !$data) {
return null;
}
$subjects = $wpdb->get_results(
$wpdb->prepare('SELECT * FROM %i WHERE automation_run_id = %d', $this->subjectTable, $id),
ARRAY_A
);
$data['subjects'] = is_array($subjects) ? $subjects : [];
return AutomationRun::fromArray((array)$data);
}
/**
* @param Automation $automation
* @return AutomationRun[]
*/
public function getAutomationRunsForAutomation(Automation $automation): array {
global $wpdb;
$automationRuns = $wpdb->get_results(
$wpdb->prepare(
'SELECT * FROM %i WHERE automation_id = %d ORDER BY id',
$this->table,
$automation->getId()
),
ARRAY_A
);
if (!is_array($automationRuns) || !$automationRuns) {
return [];
}
$automationRunIds = array_column($automationRuns, 'id');
$subjects = $wpdb->get_results(
$wpdb->prepare(
'
SELECT *
FROM %i
WHERE automation_run_id IN (' . implode(',', array_fill(0, count($automationRunIds), '%s')) . ')
ORDER BY automation_run_id, id
',
array_merge(
[$this->subjectTable],
$automationRunIds,
)
),
ARRAY_A
);
return array_map(
function($runData) use ($subjects): AutomationRun {
/** @var array $runData - PHPStan expects as array_map first parameter (callable(mixed): mixed)|null */
$runData['subjects'] = array_values(array_filter(
is_array($subjects) ? $subjects : [],
function($subjectData) use ($runData): bool {
/** @var array $subjectData - PHPStan expects as array_map first parameter (callable(mixed): mixed)|null */
return (int)$subjectData['automation_run_id'] === (int)$runData['id'];
}
));
return AutomationRun::fromArray($runData);
},
$automationRuns
);
}
/**
* @param Automation $automation
* @return int
*/
public function getCountByAutomationAndSubject(Automation $automation, Subject $subject): int {
global $wpdb;
$result = $wpdb->get_col(
$wpdb->prepare(
'
SELECT count(DISTINCT runs.id) AS count FROM %i AS runs
JOIN %i AS subjects ON runs.id = subjects.automation_run_id
WHERE runs.automation_id = %d
AND subjects.hash = %s
',
$this->table,
$this->subjectTable,
$automation->getId(),
$subject->getHash()
)
);
return $result ? (int)current($result) : 0;
}
public function getCountForAutomation(Automation $automation, string ...$status): int {
global $wpdb;
if (!count($status)) {
$result = $wpdb->get_col(
$wpdb->prepare(
'SELECT COUNT(id) as count FROM %i WHERE automation_id = %d',
$this->table,
$automation->getId()
)
);
return $result ? (int)current($result) : 0;
}
$result = $wpdb->get_col(
// phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- The number of replacements is dynamic.
$wpdb->prepare(
'
SELECT COUNT(id) as count
FROM %i
WHERE automation_id = %d
AND status IN (' . implode(',', array_fill(0, count($status), '%s')) . ')
',
$this->table,
$automation->getId(),
...$status
)
);
return $result ? (int)current($result) : 0;
}
public function updateStatus(int $id, string $status): void {
global $wpdb;
$result = $wpdb->query(
$wpdb->prepare(
'
UPDATE %i
SET status = %s, updated_at = current_timestamp()
WHERE id = %d
',
$this->table,
$status,
$id
)
);
if ($result === false) {
$this->throwDatabaseError();
}
}
public function updateNextStep(int $id, ?string $nextStepId): void {
global $wpdb;
$result = $wpdb->query(
$wpdb->prepare(
'
UPDATE %i
SET next_step_id = %s, updated_at = current_timestamp()
WHERE id = %d
',
$this->table,
$nextStepId,
$id
)
);
if ($result === false) {
$this->throwDatabaseError();
}
}
public function getAutomationStepStatisticForTimeFrame(int $automationId, string $status, \DateTimeImmutable $after, \DateTimeImmutable $before, int $versionId = null): array {
global $wpdb;
$andWhere = $versionId ? 'AND version_id = %d' : '';
$result = $wpdb->get_results(
// phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- The number of replacements is dynamic.
$wpdb->prepare(
'
SELECT COUNT(id) AS count, next_step_id
FROM %i AS log
WHERE automation_id = %d AND status = %s AND created_at BETWEEN %s AND %s
' . $andWhere . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The condition uses placeholders. */ '
GROUP BY next_step_id
',
array_merge(
[
$this->table,
$automationId,
$status,
$after->format('Y-m-d H:i:s'),
$before->format('Y-m-d H:i:s'),
],
$versionId ? [$versionId] : []
)
),
ARRAY_A
);
return is_array($result) ? $result : [];
}
public function truncate(): void {
global $wpdb;
$wpdb->query($wpdb->prepare('TRUNCATE %i', $this->table));
$wpdb->query($wpdb->prepare('TRUNCATE %i', $this->subjectTable));
}
private function throwDatabaseError(): void {
global $wpdb;
throw Exceptions::databaseError($wpdb->last_error); // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
}
}
@@ -0,0 +1,103 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Storage;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Automation;
use MailPoet\Automation\Engine\Data\AutomationRun;
use MailPoet\Automation\Engine\Data\AutomationStatistics;
class AutomationStatisticsStorage {
/** @var string */
private $table;
public function __construct() {
global $wpdb;
$this->table = $wpdb->prefix . 'mailpoet_automation_runs';
}
/** @return AutomationStatistics[] */
public function getAutomationStatisticsForAutomations(Automation ...$automations): array {
if (empty($automations)) {
return [];
}
$automationIds = array_map(
function(Automation $automation): int {
return $automation->getId();
},
$automations
);
$data = $this->getStatistics($automationIds);
$statistics = [];
foreach ($automationIds as $id) {
$statistics[$id] = new AutomationStatistics(
$id,
(int)($data[$id]['total'] ?? 0),
(int)($data[$id]['running'] ?? 0)
);
}
return $statistics;
}
public function getAutomationStats(int $automationId, int $versionId = null, \DateTimeImmutable $after = null, \DateTimeImmutable $before = null): AutomationStatistics {
$data = $this->getStatistics([$automationId], $versionId, $after, $before);
return new AutomationStatistics(
$automationId,
(int)($data[$automationId]['total'] ?? 0),
(int)($data[$automationId]['running'] ?? 0),
$versionId
);
}
/**
* @param int[] $automationIds
* @return array<int, array{id: int, total: int, running: int}>
*/
private function getStatistics(array $automationIds, int $versionId = null, \DateTimeImmutable $after = null, \DateTimeImmutable $before = null): array {
global $wpdb;
$totalSubquery = $this->getStatsQuery($automationIds, $versionId, $after, $before);
$runningSubquery = $this->getStatsQuery($automationIds, $versionId, $after, $before, AutomationRun::STATUS_RUNNING);
$results = (array)$wpdb->get_results(
'
SELECT t.id, t.count AS total, r.count AS running
FROM (' . $totalSubquery /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The subquery was already prepared. */ . ') t
LEFT JOIN (' . $runningSubquery /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The subquery was already prepared. */ . ') r ON t.id = r.id
',
ARRAY_A
);
/** @var array{id: int, total: int, running: int} $results */
return array_combine(array_column($results, 'id'), $results) ?: [];
}
private function getStatsQuery(array $automationIds, int $versionId = null, \DateTimeImmutable $after = null, \DateTimeImmutable $before = null, string $status = null): string {
global $wpdb;
$versionCondition = $versionId ? 'AND version_id = %d' : '';
$statusCondition = $status ? 'AND status = %s' : '';
$dateCondition = $after !== null && $before !== null ? 'AND created_at BETWEEN %s AND %s' : '';
$coditions = "$versionCondition $statusCondition $dateCondition";
$query = $wpdb->prepare(
'
SELECT automation_id AS id, COUNT(*) AS count
FROM %i
WHERE automation_id IN (' . implode(',', array_fill(0, count($automationIds), '%d')) . ')
' . $coditions . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The conditions use placeholders. */ '
GROUP BY automation_id
',
array_merge(
[$this->table],
$automationIds,
$versionId ? [$versionId] : [],
$status ? [$status] : [],
$after !== null && $before !== null ? [$after->format('Y-m-d H:i:s'), $before->format('Y-m-d H:i:s')] : []
)
);
return strval($query);
}
}
@@ -0,0 +1,464 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Storage;
if (!defined('ABSPATH')) exit;
use DateTimeImmutable;
use MailPoet\Automation\Engine\Data\Automation;
use MailPoet\Automation\Engine\Data\Step;
use MailPoet\Automation\Engine\Data\Subject;
use MailPoet\Automation\Engine\Exceptions;
use MailPoet\Automation\Engine\Integration\Trigger;
/**
* @phpstan-type VersionDate array{id: int, created_at: \DateTimeImmutable}
*/
class AutomationStorage {
/** @var string */
private $automationsTable;
/** @var string */
private $versionsTable;
/** @var string */
private $triggersTable;
/** @var string */
private $runsTable;
/** @var string */
private $subjectsTable;
public function __construct() {
global $wpdb;
$this->automationsTable = $wpdb->prefix . 'mailpoet_automations';
$this->versionsTable = $wpdb->prefix . 'mailpoet_automation_versions';
$this->triggersTable = $wpdb->prefix . 'mailpoet_automation_triggers';
$this->runsTable = $wpdb->prefix . 'mailpoet_automation_runs';
$this->subjectsTable = $wpdb->prefix . 'mailpoet_automation_run_subjects';
}
public function createAutomation(Automation $automation): int {
global $wpdb;
$automationHeaderData = $this->getAutomationHeaderData($automation);
unset($automationHeaderData['id']);
$result = $wpdb->insert($this->automationsTable, $automationHeaderData);
if (!$result) {
$this->throwDatabaseError();
}
$id = $wpdb->insert_id; // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
$this->insertAutomationVersion($id, $automation);
$this->insertAutomationTriggers($id, $automation);
return $id;
}
public function updateAutomation(Automation $automation): void {
global $wpdb;
$oldRecord = $this->getAutomation($automation->getId());
if ($oldRecord && $oldRecord->equals($automation)) {
return;
}
$result = $wpdb->update($this->automationsTable, $this->getAutomationHeaderData($automation), ['id' => $automation->getId()]);
if ($result === false) {
$this->throwDatabaseError();
}
$this->insertAutomationVersion($automation->getId(), $automation);
$this->insertAutomationTriggers($automation->getId(), $automation);
}
public function getAutomationVersionDates(int $automationId): array {
global $wpdb;
$data = $wpdb->get_results(
$wpdb->prepare(
'SELECT id, created_at FROM %i WHERE automation_id = %d ORDER BY id DESC',
$this->versionsTable,
$automationId
),
ARRAY_A
);
return is_array($data) ? array_map(
function($row): array {
/** @var array{id: string, created_at: string} $row */
return [
'id' => absint($row['id']),
'created_at' => new \DateTimeImmutable($row['created_at']),
];
},
$data
) : [];
}
/**
* @param int[] $versionIds
* @return Automation[]
*/
public function getAutomationWithDifferentVersions(array $versionIds): array {
global $wpdb;
if (!$versionIds) {
return [];
}
$versionIds = array_map('intval', $versionIds);
$data = $wpdb->get_results(
$wpdb->prepare(
'
SELECT a.*, v.id AS version_id, v.steps
FROM %i as a, %i as v
WHERE v.automation_id = a.id AND v.id IN (' . implode(',', array_fill(0, count($versionIds), '%d')) . ')
ORDER BY v.id DESC
',
array_merge(
[
$this->automationsTable,
$this->versionsTable,
],
$versionIds
)
),
ARRAY_A
);
return is_array($data) ? array_map(
function($row): Automation {
return Automation::fromArray((array)$row);
},
$data
) : [];
}
public function getAutomation(int $automationId, int $versionId = null): ?Automation {
global $wpdb;
if ($versionId) {
$automations = $this->getAutomationWithDifferentVersions([$versionId]);
return $automations ? $automations[0] : null;
}
$data = $wpdb->get_row(
$wpdb->prepare(
'
SELECT a.*, v.id AS version_id, v.steps
FROM %i as a, %i as v
WHERE v.automation_id = a.id AND a.id = %d
ORDER BY v.id DESC
LIMIT 1
',
$this->automationsTable,
$this->versionsTable,
$automationId
),
ARRAY_A
);
return $data ? Automation::fromArray((array)$data) : null;
}
/** @return Automation[] */
public function getAutomations(array $status = null): array {
global $wpdb;
$statusFilter = $status ? 'AND a.status IN (' . implode(',', array_fill(0, count($status), '%s')) . ')' : '';
$data = $wpdb->get_results(
// phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- The number of replacements is dynamic.
$wpdb->prepare(
'
SELECT a.*, v.id AS version_id, v.steps
FROM %i AS a
INNER JOIN %i as v ON (v.automation_id = a.id)
WHERE v.id = (
SELECT MAX(id) FROM %i WHERE automation_id = v.automation_id
)
' . $statusFilter . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The condition uses placeholders. */ '
ORDER BY a.id DESC
',
$this->automationsTable,
$this->versionsTable,
$this->versionsTable,
...($status ?? []),
),
ARRAY_A
);
return array_map(function ($automationData) {
/** @var array $automationData - for PHPStan because it conflicts with expected callable(mixed): mixed)|null */
return Automation::fromArray($automationData);
}, (array)$data);
}
/** @return int[] */
public function getAutomationIdsBySubject(Subject $subject, array $runStatus = null, int $inTheLastSeconds = null): array {
global $wpdb;
$statusFilter = $runStatus ? 'AND r.status IN (' . implode(',', array_fill(0, count($runStatus), '%s')) . ')' : '';
$inTheLastFilter = isset($inTheLastSeconds) ? 'AND r.created_at > DATE_SUB(NOW(), INTERVAL %d SECOND)' : '';
$result = $wpdb->get_col(
// phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- The number of replacements is dynamic.
$wpdb->prepare(
'
SELECT DISTINCT a.id
FROM %i a
INNER JOIN %i r ON r.automation_id = a.id
INNER JOIN %i s ON s.automation_run_id = r.id
WHERE s.hash = %s
' . $statusFilter . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The condition uses placeholders. */ '
' . $inTheLastFilter . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The condition uses placeholders. */ '
ORDER BY a.id DESC
',
array_merge(
[
$this->automationsTable,
$this->runsTable,
$this->subjectsTable,
$subject->getHash(),
],
$runStatus ?? [],
$inTheLastSeconds ? [$inTheLastSeconds] : []
)
)
);
return array_map('intval', $result);
}
public function getAutomationCount(): int {
global $wpdb;
return (int)$wpdb->get_var(
$wpdb->prepare('SELECT COUNT(*) FROM %i', $this->automationsTable)
);
}
/** @return string[] */
public function getActiveTriggerKeys(): array {
global $wpdb;
return $wpdb->get_col(
$wpdb->prepare(
'
SELECT DISTINCT t.trigger_key
FROM %i AS a
JOIN %i as t
WHERE a.status = %s AND a.id = t.automation_id
ORDER BY trigger_key DESC
',
$this->automationsTable,
$this->triggersTable,
Automation::STATUS_ACTIVE
)
);
}
/** @return Automation[] */
public function getActiveAutomationsByTrigger(Trigger $trigger): array {
return $this->getActiveAutomationsByTriggerKey($trigger->getKey());
}
public function getActiveAutomationsByTriggerKey(string $triggerKey): array {
global $wpdb;
$data = $wpdb->get_results(
$wpdb->prepare(
'
SELECT a.*, v.id AS version_id, v.steps
FROM %i AS a
INNER JOIN %i as t ON (t.automation_id = a.id)
INNER JOIN %i as v ON (v.automation_id = a.id)
WHERE a.status = %s
AND t.trigger_key = %s
AND v.id = (
SELECT MAX(id) FROM %i WHERE automation_id = v.automation_id
)
',
$this->automationsTable,
$this->triggersTable,
$this->versionsTable,
Automation::STATUS_ACTIVE,
$triggerKey,
$this->versionsTable
),
ARRAY_A
);
return array_map(function ($automationData) {
/** @var array $automationData - for PHPStan because it conflicts with expected callable(mixed): mixed)|null */
return Automation::fromArray($automationData);
}, (array)$data);
}
public function getCountOfActiveByTriggerKeysAndAction(array $triggerKeys, string $actionKey): int {
global $wpdb;
return (int)$wpdb->get_var(
$wpdb->prepare(
'
SELECT COUNT(*)
FROM %i AS a
INNER JOIN %i as t ON (t.automation_id = a.id) AND t.trigger_key IN (' . implode(',', array_fill(0, count($triggerKeys), '%s')) . ')
INNER JOIN %i as v ON v.id = (SELECT MAX(id) FROM %i WHERE automation_id = a.id)
WHERE a.status = %s
AND v.steps LIKE %s
',
array_merge(
[
$this->automationsTable,
$this->triggersTable,
],
$triggerKeys,
[
$this->versionsTable,
$this->versionsTable,
Automation::STATUS_ACTIVE,
'%"' . $wpdb->esc_like($actionKey) . '"%',
]
)
)
);
}
public function deleteAutomation(Automation $automation): void {
global $wpdb;
$automationId = $automation->getId();
$logsDeleted = $wpdb->query(
$wpdb->prepare(
'
DELETE FROM %i
WHERE automation_run_id IN (
SELECT id
FROM %i
WHERE automation_id = %d
)
',
$wpdb->prefix . 'mailpoet_automation_run_logs',
$this->runsTable,
$automationId
)
);
if ($logsDeleted === false) {
$this->throwDatabaseError();
}
$runsDeleted = $wpdb->delete($this->runsTable, ['automation_id' => $automationId]);
if ($runsDeleted === false) {
$this->throwDatabaseError();
}
$versionsDeleted = $wpdb->delete($this->versionsTable, ['automation_id' => $automationId]);
if ($versionsDeleted === false) {
$this->throwDatabaseError();
}
$triggersDeleted = $wpdb->delete($this->triggersTable, ['automation_id' => $automationId]);
if ($triggersDeleted === false) {
$this->throwDatabaseError();
}
$automationDeleted = $wpdb->delete($this->automationsTable, ['id' => $automationId]);
if ($automationDeleted === false) {
$this->throwDatabaseError();
}
}
public function truncate(): void {
global $wpdb;
$result = $wpdb->query($wpdb->prepare('TRUNCATE %i', $this->automationsTable));
if ($result === false) {
$this->throwDatabaseError();
}
$result = $wpdb->query($wpdb->prepare('TRUNCATE %i', $this->versionsTable));
if ($result === false) {
$this->throwDatabaseError();
}
$result = $wpdb->query($wpdb->prepare('TRUNCATE %i', $this->triggersTable));
if ($result === false) {
$this->throwDatabaseError();
}
}
public function getNameColumnLength(): int {
global $wpdb;
$nameColumnLengthInfo = $wpdb->get_col_length($this->automationsTable, 'name');
return is_array($nameColumnLengthInfo)
? $nameColumnLengthInfo['length'] ?? 255
: 255;
}
private function getAutomationHeaderData(Automation $automation): array {
$automationHeader = $automation->toArray();
unset($automationHeader['steps']);
return $automationHeader;
}
private function insertAutomationVersion(int $automationId, Automation $automation): void {
global $wpdb;
$dateString = (new DateTimeImmutable())->format(DateTimeImmutable::W3C);
$data = [
'automation_id' => $automationId,
'steps' => $automation->toArray()['steps'],
'created_at' => $dateString,
'updated_at' => $dateString,
];
$result = $wpdb->insert($this->versionsTable, $data);
if (!$result) {
$this->throwDatabaseError();
}
}
private function insertAutomationTriggers(int $automationId, Automation $automation): void {
global $wpdb;
$triggerKeys = [];
foreach ($automation->getSteps() as $step) {
if ($step->getType() === Step::TYPE_TRIGGER) {
$triggerKeys[] = $step->getKey();
}
}
// insert/update
if ($triggerKeys) {
$result = $wpdb->query(
// phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- The number of replacements is dynamic.
$wpdb->prepare(
'INSERT IGNORE INTO %i (automation_id, trigger_key) VALUES ' . implode(',', array_fill(0, count($triggerKeys), '(%d, %s)')),
array_merge(
[$this->triggersTable],
...array_map(function (string $key) use ($automationId) {
return [$automationId, $key];
}, $triggerKeys)
)
)
);
if ($result === false) {
$this->throwDatabaseError();
}
}
// delete
if ($triggerKeys) {
$result = $wpdb->query(
$wpdb->prepare(
'DELETE FROM %i WHERE automation_id = %d AND trigger_key NOT IN (' . implode(',', array_fill(0, count($triggerKeys), '%s')) . ')',
array_merge(
[
$this->triggersTable,
$automationId,
],
$triggerKeys
),
)
);
} else {
$result = $wpdb->query(
$wpdb->prepare(
'DELETE FROM %i WHERE automation_id = %d',
$this->triggersTable,
$automationId
)
);
}
if ($result === false) {
$this->throwDatabaseError();
}
}
private function throwDatabaseError(): void {
global $wpdb;
throw Exceptions::databaseError($wpdb->last_error); // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
}
}
@@ -0,0 +1 @@
<?php
@@ -0,0 +1,142 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Templates;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Automation;
use MailPoet\Automation\Engine\Data\Filter;
use MailPoet\Automation\Engine\Data\FilterGroup;
use MailPoet\Automation\Engine\Data\Filters;
use MailPoet\Automation\Engine\Data\NextStep;
use MailPoet\Automation\Engine\Data\Step;
use MailPoet\Automation\Engine\Exceptions\InvalidStateException;
use MailPoet\Automation\Engine\Integration\Trigger;
use MailPoet\Automation\Engine\Registry;
use MailPoet\Util\Security;
use MailPoet\Validator\Schema\ObjectSchema;
class AutomationBuilder {
/** @var Registry */
private $registry;
public function __construct(
Registry $registry
) {
$this->registry = $registry;
}
/**
* @param string $name
* @param array<
* array{
* key: string,
* args?: array<string, mixed>,
* filters?: array{
* operator: 'and' | 'or',
* groups: array{
* operator: 'and' | 'or',
* filters: array{
* field: string,
* condition: string,
* value: mixed,
* }[],
* }[],
* },
* }
* > $sequence
* @param array<string, mixed> $meta
* @return Automation
*/
public function createFromSequence(string $name, array $sequence, array $meta = []): Automation {
$steps = [];
$nextSteps = [];
foreach (array_reverse($sequence) as $data) {
$stepKey = $data['key'];
$automationStep = $this->registry->getStep($stepKey);
if (!$automationStep) {
continue;
}
$args = array_merge($this->getDefaultArgs($automationStep->getArgsSchema()), $data['args'] ?? []);
$filters = isset($data['filters']) ? $this->getFilters($data['filters']) : null;
$step = new Step(
$this->uniqueId(),
in_array(Trigger::class, (array)class_implements($automationStep)) ? Step::TYPE_TRIGGER : Step::TYPE_ACTION,
$stepKey,
$args,
$nextSteps,
$filters
);
$nextSteps = [new NextStep($step->getId())];
$steps[$step->getId()] = $step;
}
$steps['root'] = new Step('root', 'root', 'core:root', [], $nextSteps);
$steps = array_reverse($steps);
$automation = new Automation(
$name,
$steps,
wp_get_current_user()
);
foreach ($meta as $key => $value) {
$automation->setMeta($key, $value);
}
return $automation;
}
private function uniqueId(): string {
return Security::generateRandomString(16);
}
private function getDefaultArgs(ObjectSchema $argsSchema): array {
$args = [];
foreach ($argsSchema->toArray()['properties'] ?? [] as $name => $schema) {
if (array_key_exists('default', $schema)) {
$args[$name] = $schema['default'];
}
}
return $args;
}
/**
* @param array{
* operator: 'and' | 'or',
* groups: array{
* operator: 'and' | 'or',
* filters: array{
* field: string,
* condition: string,
* value: mixed,
* params?: array<string, mixed>,
* }[],
* }[],
* } $filters
* @return Filters
*/
private function getFilters(array $filters): Filters {
$groups = [];
foreach ($filters['groups'] as $group) {
$groups[] = new FilterGroup(
$this->uniqueId(),
$group['operator'],
array_map(
function (array $filter): Filter {
$field = $this->registry->getField($filter['field']);
if (!$field) {
throw new InvalidStateException(sprintf("Field with key '%s' not found", $filter['field']));
}
return new Filter(
$this->uniqueId(),
$field->getType(),
$filter['field'],
$filter['condition'],
array_merge(['value' => $filter['value']], isset($filter['params']) ? ['params' => $filter['params']] : []),
);
},
$group['filters']
)
);
}
return new Filters($filters['operator'], $groups);
}
}
@@ -0,0 +1 @@
<?php
@@ -0,0 +1,32 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Utils;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Exceptions;
use MailPoet\Automation\Engine\Exceptions\InvalidStateException;
class Json {
public static function encode(array $value): string {
$json = json_encode((object)$value, JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION);
$error = json_last_error();
if ($error || $json === false) {
throw new InvalidStateException(json_last_error_msg(), (string)$error);
}
return $json;
}
public static function decode(string $json): array {
$value = json_decode($json, true);
$error = json_last_error();
if ($error) {
throw new InvalidStateException(json_last_error_msg(), (string)$error);
}
if (!is_array($value)) {
throw Exceptions::jsonNotObject($json);
}
return $value;
}
}
@@ -0,0 +1 @@
<?php
@@ -0,0 +1,34 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Validation\AutomationGraph;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Step;
class AutomationNode {
/** @var Step */
private $step;
/** @var array */
private $parents;
/* @param Step[] $parents */
public function __construct(
Step $step,
array $parents
) {
$this->step = $step;
$this->parents = $parents;
}
public function getStep(): Step {
return $this->step;
}
/** @return Step[] */
public function getParents(): array {
return $this->parents;
}
}
@@ -0,0 +1,16 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Validation\AutomationGraph;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Automation;
interface AutomationNodeVisitor {
public function initialize(Automation $automation): void;
public function visitNode(Automation $automation, AutomationNode $node): void;
public function complete(Automation $automation): void;
}
@@ -0,0 +1,88 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Validation\AutomationGraph;
if (!defined('ABSPATH')) exit;
use Generator;
use MailPoet\Automation\Engine\Data\Automation;
use MailPoet\Automation\Engine\Data\Step;
use MailPoet\Automation\Engine\Exceptions;
use MailPoet\Automation\Engine\Exceptions\InvalidStateException;
use MailPoet\Automation\Engine\Exceptions\UnexpectedValueException;
class AutomationWalker {
/** @param AutomationNodeVisitor[] $visitors */
public function walk(Automation $automation, array $visitors = []): void {
$steps = $automation->getSteps();
$root = $steps['root'] ?? null;
if (!$root) {
throw Exceptions::automationStructureNotValid(__("Automation must contain a 'root' step", 'mailpoet'), 'no-root');
}
foreach ($visitors as $visitor) {
$visitor->initialize($automation);
}
foreach ($this->walkStepsDepthFirstPreOrder($steps, $root) as $record) {
[$step, $parents] = $record;
foreach ($visitors as $visitor) {
$visitor->visitNode($automation, new AutomationNode($step, array_values($parents)));
}
}
foreach ($visitors as $visitor) {
$visitor->complete($automation);
}
}
/**
* @param array<string|int, Step> $steps
* @return Generator<array{0: Step, 1: array<string|int, Step>}>
*/
private function walkStepsDepthFirstPreOrder(array $steps, Step $root): Generator {
/** @var array{0: Step, 1: array<string|int, Step>}[] $stack */
$stack = [
[$root, []],
];
do {
$record = array_pop($stack);
if (!$record) {
throw new InvalidStateException();
}
yield $record;
[$step, $parents] = $record;
foreach (array_reverse($step->getNextSteps()) as $nextStepData) {
$nextStepId = $nextStepData->getId();
if (!$nextStepId) {
continue; // empty edge
}
$nextStep = $steps[$nextStepId] ?? null;
if (!$nextStep) {
throw $this->createStepNotFoundException($nextStepId, $step->getId());
}
$nextStepParents = array_merge($parents, [$step->getId() => $step]);
if (isset($nextStepParents[$nextStepId])) {
continue; // cycle detected, do not enter the path again
}
array_push($stack, [$nextStep, $nextStepParents]);
}
} while (count($stack) > 0);
}
private function createStepNotFoundException(string $stepId, string $parentStepId): UnexpectedValueException {
return Exceptions::automationStructureNotValid(
// translators: %1$s is ID of the step not found, %2$s is ID of the step that references it
sprintf(
__("Step with ID '%1\$s' not found (referenced from '%2\$s')", 'mailpoet'),
$stepId,
$parentStepId
),
'step-not-found'
);
}
}
@@ -0,0 +1,40 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Validation\AutomationRules;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Automation;
use MailPoet\Automation\Engine\Data\Step;
use MailPoet\Automation\Engine\Exceptions;
use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNode;
use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNodeVisitor;
class AtLeastOneTriggerRule implements AutomationNodeVisitor {
public const RULE_ID = 'at-least-one-trigger';
/** @var bool */
private $triggerFound = false;
public function initialize(Automation $automation): void {
$this->triggerFound = false;
}
public function visitNode(Automation $automation, AutomationNode $node): void {
if ($node->getStep()->getType() === Step::TYPE_TRIGGER) {
$this->triggerFound = true;
}
}
public function complete(Automation $automation): void {
if (!$automation->needsFullValidation()) {
return;
}
if ($this->triggerFound) {
return;
}
throw Exceptions::automationStructureNotValid(__('There must be at least one trigger in the automation.', 'mailpoet'), self::RULE_ID);
}
}
@@ -0,0 +1,33 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Validation\AutomationRules;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Automation;
use MailPoet\Automation\Engine\Exceptions;
use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNode;
use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNodeVisitor;
class ConsistentStepMapRule implements AutomationNodeVisitor {
public const RULE_ID = 'consistent-step-map';
public function initialize(Automation $automation): void {
foreach ($automation->getSteps() as $id => $step) {
if ((string)$id !== $step->getId()) {
// translators: %1$s is the ID of the step, %2$s is its index in the steps object.
throw Exceptions::automationStructureNotValid(
sprintf(__("Step with ID '%1\$s' stored under a mismatched index '%2\$s'.", 'mailpoet'), $step->getId(), $id),
self::RULE_ID
);
}
}
}
public function visitNode(Automation $automation, AutomationNode $node): void {
}
public function complete(Automation $automation): void {
}
}
@@ -0,0 +1,39 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Validation\AutomationRules;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Automation;
use MailPoet\Automation\Engine\Data\Step;
use MailPoet\Automation\Engine\Exceptions;
use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNode;
use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNodeVisitor;
class NoCycleRule implements AutomationNodeVisitor {
public const RULE_ID = 'no-cycle';
public function initialize(Automation $automation): void {
}
public function visitNode(Automation $automation, AutomationNode $node): void {
$step = $node->getStep();
$parents = $node->getParents();
$parentIdsMap = array_combine(
array_map(function (Step $parent) {
return $parent->getId();
}, $node->getParents()),
$parents
) ?: [];
foreach ($step->getNextStepIds() as $nextStepId) {
if ($nextStepId === $step->getId() || isset($parentIdsMap[$nextStepId])) {
throw Exceptions::automationStructureNotValid(__('Cycle found in automation graph', 'mailpoet'), self::RULE_ID);
}
}
}
public function complete(Automation $automation): void {
}
}
@@ -0,0 +1,31 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Validation\AutomationRules;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Automation;
use MailPoet\Automation\Engine\Exceptions;
use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNode;
use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNodeVisitor;
class NoDuplicateEdgesRule implements AutomationNodeVisitor {
public const RULE_ID = 'no-duplicate-edges';
public function initialize(Automation $automation): void {
}
public function visitNode(Automation $automation, AutomationNode $node): void {
$visitedNextStepIdsMap = [];
foreach ($node->getStep()->getNextStepIds() as $nextStepId) {
if (isset($visitedNextStepIdsMap[$nextStepId])) {
throw Exceptions::automationStructureNotValid(__('Duplicate next step definition found', 'mailpoet'), self::RULE_ID);
}
$visitedNextStepIdsMap[$nextStepId] = true;
}
}
public function complete(Automation $automation): void {
}
}
@@ -0,0 +1,37 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Validation\AutomationRules;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Automation;
use MailPoet\Automation\Engine\Data\Step;
use MailPoet\Automation\Engine\Exceptions;
use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNode;
use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNodeVisitor;
class NoJoinRule implements AutomationNodeVisitor {
public const RULE_ID = 'no-join';
/** @var array<string|int, Step[]> */
private $directParentMap = [];
public function initialize(Automation $automation): void {
$this->directParentMap = [];
}
public function visitNode(Automation $automation, AutomationNode $node): void {
$step = $node->getStep();
foreach ($step->getNextStepIds() as $nextStepId) {
$this->directParentMap[$nextStepId] = array_merge($this->directParentMap[$nextStepId] ?? [], [$step]);
}
if (count($this->directParentMap[$step->getId()] ?? []) > 1) {
throw Exceptions::automationStructureNotValid(__('Path join found in automation graph', 'mailpoet'), self::RULE_ID);
}
}
public function complete(Automation $automation): void {
}
}
@@ -0,0 +1,32 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Validation\AutomationRules;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Automation;
use MailPoet\Automation\Engine\Exceptions;
use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNode;
use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNodeVisitor;
class NoUnreachableStepsRule implements AutomationNodeVisitor {
public const RULE_ID = 'no-unreachable-steps';
/** @var AutomationNode[] */
private $visitedNodes = [];
public function initialize(Automation $automation): void {
$this->visitedNodes = [];
}
public function visitNode(Automation $automation, AutomationNode $node): void {
$this->visitedNodes[$node->getStep()->getId()] = $node;
}
public function complete(Automation $automation): void {
if (count($this->visitedNodes) !== count($automation->getSteps())) {
throw Exceptions::automationStructureNotValid(__('Unreachable steps found in automation graph', 'mailpoet'), self::RULE_ID);
}
}
}
@@ -0,0 +1,46 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Validation\AutomationRules;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Automation;
use MailPoet\Automation\Engine\Data\Step;
use MailPoet\Automation\Engine\Exceptions;
use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNode;
use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNodeVisitor;
class TriggerNeedsToBeFollowedByActionRule implements AutomationNodeVisitor {
public const RULE_ID = 'trigger-needs-to-be-followed-by-action';
public function initialize(Automation $automation): void {
}
public function visitNode(Automation $automation, AutomationNode $node): void {
if (!$automation->needsFullValidation()) {
return;
}
$step = $node->getStep();
if ($step->getType() !== Step::TYPE_TRIGGER) {
return;
}
$nextStepIds = $step->getNextStepIds();
if (!count($nextStepIds)) {
throw Exceptions::automationStructureNotValid(__('A trigger needs to be followed by an action.', 'mailpoet'), self::RULE_ID);
}
foreach ($nextStepIds as $nextStepsId) {
$step = $automation->getStep($nextStepsId);
if ($step && $step->getType() === Step::TYPE_ACTION) {
continue;
}
throw Exceptions::automationStructureNotValid(__('A trigger needs to be followed by an action.', 'mailpoet'), self::RULE_ID);
}
}
public function complete(Automation $automation): void {
}
}
@@ -0,0 +1,44 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Validation\AutomationRules;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Automation;
use MailPoet\Automation\Engine\Data\Step;
use MailPoet\Automation\Engine\Exceptions;
use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNode;
use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNodeVisitor;
class TriggersUnderRootRule implements AutomationNodeVisitor {
public const RULE_ID = 'triggers-under-root';
/** @var array<string, Step> $triggersMap */
private $triggersMap = [];
public function initialize(Automation $automation): void {
$this->triggersMap = [];
foreach ($automation->getSteps() as $step) {
if ($step->getType() === 'trigger') {
$this->triggersMap[$step->getId()] = $step;
}
}
}
public function visitNode(Automation $automation, AutomationNode $node): void {
$step = $node->getStep();
if ($step->getType() === Step::TYPE_ROOT) {
return;
}
foreach ($step->getNextStepIds() as $nextStepId) {
if (isset($this->triggersMap[$nextStepId])) {
throw Exceptions::automationStructureNotValid(__('Trigger must be a direct descendant of automation root', 'mailpoet'), self::RULE_ID);
}
}
}
public function complete(Automation $automation): void {
}
}
@@ -0,0 +1,67 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Validation\AutomationRules;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Automation;
use MailPoet\Automation\Engine\Exceptions;
use MailPoet\Automation\Engine\Exceptions\InvalidStateException;
use MailPoet\Automation\Engine\Registry;
use MailPoet\Automation\Engine\Storage\AutomationStorage;
use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNode;
use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNodeVisitor;
class UnknownStepRule implements AutomationNodeVisitor {
/** @var Registry */
private $registry;
/** @var AutomationStorage */
private $automationStorage;
/** @var Automation|null|false */
private $cachedExistingAutomation = false;
public function __construct(
Registry $registry,
AutomationStorage $automationStorage
) {
$this->registry = $registry;
$this->automationStorage = $automationStorage;
}
public function initialize(Automation $automation): void {
$this->cachedExistingAutomation = false;
}
public function visitNode(Automation $automation, AutomationNode $node): void {
$step = $node->getStep();
$registryStep = $this->registry->getStep($step->getKey());
// step not registered (e.g. plugin was deactivated) - allow saving it only if it hasn't changed
if (!$registryStep) {
$currentAutomation = $this->getCurrentAutomation($automation);
$currentStep = $currentAutomation ? ($currentAutomation->getSteps()[$step->getId()] ?? null) : null;
if (!$currentStep || $step->toArray() !== $currentStep->toArray()) {
throw Exceptions::automationStepModifiedWhenUnknown($step);
}
}
}
public function complete(Automation $automation): void {
}
private function getCurrentAutomation(Automation $automation): ?Automation {
try {
$id = $automation->getId();
if ($this->cachedExistingAutomation === false) {
$this->cachedExistingAutomation = $this->automationStorage->getAutomation($id);
}
} catch (InvalidStateException $e) {
// for new automations, no automation ID is set
$this->cachedExistingAutomation = null;
}
return $this->cachedExistingAutomation;
}
}
@@ -0,0 +1,74 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Validation\AutomationRules;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Automation;
use MailPoet\Automation\Engine\Registry;
use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNode;
use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNodeVisitor;
use MailPoet\Validator\ValidationException;
use MailPoet\Validator\Validator;
class ValidStepArgsRule implements AutomationNodeVisitor {
/** @var Registry */
private $registry;
/** @var Validator */
private $validator;
public function __construct(
Registry $registry,
Validator $validator
) {
$this->registry = $registry;
$this->validator = $validator;
}
public function initialize(Automation $automation): void {
}
public function visitNode(Automation $automation, AutomationNode $node): void {
$step = $node->getStep();
$registryStep = $this->registry->getStep($step->getKey());
if (!$registryStep) {
return;
}
$schema = $registryStep->getArgsSchema();
$properties = $schema->toArray()['properties'] ?? null;
if (!$properties) {
$this->validator->validate($schema, $step->getArgs());
return;
}
$errors = [];
foreach ($properties as $property => $propertySchema) {
$schemaToValidate = array_merge(
$schema->toArray(),
['properties' => [$property => $propertySchema]]
);
try {
$this->validator->validateSchemaArray(
$schemaToValidate,
$step->getArgs(),
$property
);
} catch (ValidationException $e) {
$errors[$property] = $e->getWpError()->get_error_code();
}
}
if ($errors) {
$throwable = ValidationException::create();
foreach ($errors as $errorKey => $errorMsg) {
$throwable->withError((string)$errorKey, (string)$errorMsg);
}
throw $throwable;
}
}
public function complete(Automation $automation): void {
}
}
@@ -0,0 +1,94 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Validation\AutomationRules;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Control\SubjectTransformerHandler;
use MailPoet\Automation\Engine\Data\Automation;
use MailPoet\Automation\Engine\Data\Filter;
use MailPoet\Automation\Engine\Integration\Payload;
use MailPoet\Automation\Engine\Integration\Subject;
use MailPoet\Automation\Engine\Registry;
use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNode;
use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNodeVisitor;
use MailPoet\Validator\ValidationException;
use MailPoet\Validator\Validator;
class ValidStepFiltersRule implements AutomationNodeVisitor {
/** @var Registry */
private $registry;
/** @var SubjectTransformerHandler */
private $subjectTransformerHandler;
/** @var Validator */
private $validator;
public function __construct(
Registry $registry,
SubjectTransformerHandler $subjectTransformerHandler,
Validator $validator
) {
$this->registry = $registry;
$this->subjectTransformerHandler = $subjectTransformerHandler;
$this->validator = $validator;
}
public function initialize(Automation $automation): void {
}
public function visitNode(Automation $automation, AutomationNode $node): void {
$filters = $node->getStep()->getFilters();
$groups = $filters ? $filters->getGroups() : [];
$errors = [];
foreach ($groups as $group) {
foreach ($group->getFilters() as $filter) {
$registryFilter = $this->registry->getFilter($filter->getFieldType());
if (!$registryFilter) {
continue;
}
try {
$this->validator->validate($registryFilter->getArgsSchema($filter->getCondition()), $filter->getArgs());
} catch (ValidationException $e) {
$errors[$filter->getId()] = $e->getWpError()->get_error_code();
continue;
}
// ensure that the field is available with the provided subjects
$subjectKeys = $this->subjectTransformerHandler->getSubjectKeysForAutomation($automation);
$filterSubject = $this->getFilterSubject($filter);
if (!$filterSubject) {
$errors[$filter->getId()] = __('Field not found', 'mailpoet');
} elseif (!in_array($filterSubject->getKey(), $subjectKeys, true)) {
// translators: %s is the name of a subject (data structure) that provides the field
$errors[$filter->getId()] = sprintf(__('A trigger that provides %s is required', 'mailpoet'), $filterSubject->getName());
}
}
}
if ($errors) {
$throwable = ValidationException::create()->withMessage('invalid-automation-filters');
foreach ($errors as $errorKey => $errorMsg) {
$throwable->withError((string)$errorKey, (string)$errorMsg);
}
throw $throwable;
}
}
public function complete(Automation $automation): void {
}
/** @return Subject<Payload> */
private function getFilterSubject(Filter $filter): ?Subject {
foreach ($this->registry->getSubjects() as $subject) {
foreach ($subject->getFields() as $field) {
if ($field->getKey() === $filter->getFieldKey()) {
return $subject;
}
}
}
return null;
}
}
@@ -0,0 +1,60 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Validation\AutomationRules;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Control\SubjectTransformerHandler;
use MailPoet\Automation\Engine\Data\Automation;
use MailPoet\Automation\Engine\Data\Step;
use MailPoet\Automation\Engine\Exceptions;
use MailPoet\Automation\Engine\Registry;
use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNode;
use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNodeVisitor;
class ValidStepOrderRule implements AutomationNodeVisitor {
/** @var Registry */
private $registry;
/** @var SubjectTransformerHandler */
private $subjectTransformerHandler;
public function __construct(
Registry $registry,
SubjectTransformerHandler $subjectTransformerHandler
) {
$this->registry = $registry;
$this->subjectTransformerHandler = $subjectTransformerHandler;
}
public function initialize(Automation $automation): void {
}
public function visitNode(Automation $automation, AutomationNode $node): void {
$step = $node->getStep();
$registryStep = $this->registry->getStep($step->getKey());
if (!$registryStep) {
return;
}
// triggers don't require any subjects (they provide them)
if ($step->getType() === Step::TYPE_TRIGGER) {
return;
}
$requiredSubjectKeys = $registryStep->getSubjectKeys();
if (!$requiredSubjectKeys) {
return;
}
$subjectKeys = $this->subjectTransformerHandler->getSubjectKeysForAutomation($automation);
$missingSubjectKeys = array_diff($requiredSubjectKeys, $subjectKeys);
if (count($missingSubjectKeys) > 0) {
throw Exceptions::missingRequiredSubjects($step, $missingSubjectKeys);
}
}
public function complete(Automation $automation): void {
}
}
@@ -0,0 +1,140 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Validation\AutomationRules;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Automation;
use MailPoet\Automation\Engine\Exceptions;
use MailPoet\Automation\Engine\Exceptions\UnexpectedValueException;
use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNode;
use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNodeVisitor;
use MailPoet\Validator\ValidationException;
use Throwable;
class ValidStepRule implements AutomationNodeVisitor {
/** @var AutomationNodeVisitor[] */
private $rules;
/** @var array<string, array{step_id: string, fields: array<string,string>}> */
private $errors = [];
/** @param AutomationNodeVisitor[] $rules */
public function __construct(
array $rules
) {
$this->rules = $rules;
}
public function initialize(Automation $automation): void {
if (!$automation->needsFullValidation()) {
return;
}
foreach ($this->rules as $rule) {
$rule->initialize($automation);
}
}
public function visitNode(Automation $automation, AutomationNode $node): void {
if (!$automation->needsFullValidation()) {
return;
}
foreach ($this->rules as $rule) {
$stepId = $node->getStep()->getId();
try {
$rule->visitNode($automation, $node);
} catch (UnexpectedValueException $e) {
if (!isset($this->errors[$stepId])) {
$this->errors[$stepId] = ['step_id' => $stepId, 'message' => $e->getMessage(), 'fields' => [], 'filters' => []];
}
$this->errors[$stepId]['fields'] = array_merge(
$this->mapErrorCodesToErrorMessages($e->getErrors()),
$this->errors[$stepId]['fields']
);
} catch (ValidationException $e) {
if (!isset($this->errors[$stepId])) {
$this->errors[$stepId] = ['step_id' => $stepId, 'message' => $e->getMessage(), 'fields' => [], 'filters' => []];
}
$key = $rule instanceof ValidStepFiltersRule ? 'filters' : 'fields';
/** @phpstan-ignore-next-line - PHPStan detects inconsistency in merged array */
$this->errors[$stepId][$key] = array_merge(
$this->mapErrorCodesToErrorMessages($e->getErrors()),
$this->errors[$stepId][$key]
);
} catch (Throwable $e) {
if (!isset($this->errors[$stepId])) {
$this->errors[$stepId] = ['step_id' => $stepId, 'message' => __('Unknown error.', 'mailpoet'), 'fields' => [], 'filters' => []];
}
}
}
}
private function mapErrorCodesToErrorMessages(array $errorCodes): array {
return array_map(
function(string $errorCode): string {
switch ($errorCode) {
case "rest_property_required":
return __('This is a required field.', 'mailpoet');
case "rest_additional_properties_forbidden":
case "rest_too_few_properties":
case "rest_too_many_properties":
return "";
case "rest_invalid_type":
case "rest_invalid_multiple":
case "rest_not_in_enum":
return __('This field is not well formed.', 'mailpoet');
case "rest_too_few_items":
return __('Please add more items.', 'mailpoet');
case "rest_too_many_items":
return __('Please remove some items.', 'mailpoet');
case "rest_duplicate_items":
return __('Please remove duplicate items.', 'mailpoet');
case "rest_out_of_bounds":
return __('This value is out of bounds.', 'mailpoet');
case "rest_too_short":
return __('This value is not long enough.', 'mailpoet');
case "rest_too_long":
return __('This value is too long.', 'mailpoet');
case "rest_invalid_pattern":
return __('This value is not well formed.', 'mailpoet');
case "rest_no_matching_schema":
return __('This value does not match the expected format.', 'mailpoet');
case "rest_one_of_multiple_matches":
return __('This value is not matching the correct times.', 'mailpoet');
case "rest_invalid_hex_color":
return __('This value is not a hex formatted color.', 'mailpoet');
case "rest_invalid_date":
return __('This value is not a date.', 'mailpoet');
case "rest_invalid_email":
return __('This value is not an email address.', 'mailpoet');
case "rest_invalid_ip":
return __('This value is not an IP address.', 'mailpoet');
case "rest_invalid_uuid":
return __('This value is not an UUID.', 'mailpoet');
default:
return $errorCode;
}
},
$errorCodes
);
}
public function complete(Automation $automation): void {
if (!$automation->needsFullValidation()) {
return;
}
foreach ($this->rules as $rule) {
$rule->complete($automation);
}
if ($this->errors) {
throw Exceptions::automationNotValid(__('Some steps are not valid', 'mailpoet'), $this->errors);
}
}
}
@@ -0,0 +1,74 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Engine\Validation\AutomationRules;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Automation;
use MailPoet\Automation\Engine\Data\Step;
use MailPoet\Automation\Engine\Data\StepValidationArgs;
use MailPoet\Automation\Engine\Exceptions;
use MailPoet\Automation\Engine\Integration\Payload;
use MailPoet\Automation\Engine\Integration\Subject;
use MailPoet\Automation\Engine\Registry;
use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNode;
use MailPoet\Automation\Engine\Validation\AutomationGraph\AutomationNodeVisitor;
class ValidStepValidationRule implements AutomationNodeVisitor {
/** @var Registry */
private $registry;
public function __construct(
Registry $registry
) {
$this->registry = $registry;
}
public function initialize(Automation $automation): void {
}
public function visitNode(Automation $automation, AutomationNode $node): void {
$step = $node->getStep();
$registryStep = $this->registry->getStep($step->getKey());
if (!$registryStep) {
return;
}
$subjects = $this->collectSubjects($automation, $node->getParents());
$args = new StepValidationArgs($automation, $step, $subjects);
$registryStep->validate($args);
}
public function complete(Automation $automation): void {
}
/**
* @param Step[] $parents
* @return Subject<Payload>[]
*/
private function collectSubjects(Automation $automation, array $parents): array {
$triggers = array_filter($parents, function (Step $step) {
return $step->getType() === Step::TYPE_TRIGGER;
});
$subjectKeys = [];
foreach ($triggers as $trigger) {
$registryTrigger = $this->registry->getTrigger($trigger->getKey());
if (!$registryTrigger) {
throw Exceptions::automationTriggerNotFound($automation->getId(), $trigger->getKey());
}
$subjectKeys = array_merge($subjectKeys, $registryTrigger->getSubjectKeys());
}
$subjects = [];
foreach (array_unique($subjectKeys) as $key) {
$subject = $this->registry->getSubject($key);
if (!$subject) {
throw Exceptions::subjectNotFound($key);
}
$subjects[] = $subject;
}
return $subjects;
}
}

Some files were not shown because too many files have changed in this diff Show More