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,73 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\Core\Actions;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Control\StepRunController;
use MailPoet\Automation\Engine\Data\Step;
use MailPoet\Automation\Engine\Data\StepRunArgs;
use MailPoet\Automation\Engine\Data\StepValidationArgs;
use MailPoet\Automation\Engine\Integration\Action;
use MailPoet\Automation\Engine\Integration\ValidationException;
use MailPoet\Validator\Builder;
use MailPoet\Validator\Schema\ObjectSchema;
class DelayAction implements Action {
public const KEY = 'core:delay';
public function getKey(): string {
return self::KEY;
}
public function getName(): string {
// translators: automation action title
return _x('Delay', 'noun', 'mailpoet');
}
public function getArgsSchema(): ObjectSchema {
return Builder::object([
'delay' => Builder::integer()->required()->minimum(1),
'delay_type' => Builder::string()->required()->pattern('^(MINUTES|DAYS|HOURS|WEEKS)$')->default('HOURS'),
]);
}
public function getSubjectKeys(): array {
return [];
}
public function validate(StepValidationArgs $args): void {
$seconds = $this->calculateSeconds($args->getStep());
if ($seconds <= 0) {
throw ValidationException::create()
->withError('delay', __('A delay must have a positive value', 'mailpoet'));
}
if ($seconds > 2 * YEAR_IN_SECONDS) {
throw ValidationException::create()
->withError('delay', __("A delay can't be longer than two years", 'mailpoet'));
}
}
public function run(StepRunArgs $args, StepRunController $controller): void {
if ($args->isFirstRun()) {
$controller->scheduleProgress(time() + self::calculateSeconds($args->getStep()));
}
}
public static function calculateSeconds(Step $step): int {
$delay = (int)($step->getArgs()['delay'] ?? null);
switch ($step->getArgs()['delay_type']) {
case "MINUTES":
return $delay * MINUTE_IN_SECONDS;
case "HOURS":
return $delay * HOUR_IN_SECONDS;
case "DAYS":
return $delay * DAY_IN_SECONDS;
case "WEEKS":
return $delay * WEEK_IN_SECONDS;
default:
return 0;
}
}
}
@@ -0,0 +1,77 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\Core\Actions;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Control\FilterHandler;
use MailPoet\Automation\Engine\Control\StepRunController;
use MailPoet\Automation\Engine\Data\FilterGroup;
use MailPoet\Automation\Engine\Data\StepRunArgs;
use MailPoet\Automation\Engine\Data\StepValidationArgs;
use MailPoet\Automation\Engine\Integration\Action;
use MailPoet\Automation\Engine\Integration\ValidationException;
use MailPoet\Validator\Builder;
use MailPoet\Validator\Schema\ObjectSchema;
class IfElseAction implements Action {
public const KEY = 'core:if-else';
/** @var FilterHandler */
private $filterHandler;
public function __construct(
FilterHandler $filterHandler
) {
$this->filterHandler = $filterHandler;
}
public function getKey(): string {
return self::KEY;
}
public function getName(): string {
// translators: automation action title
return __('If/Else', 'mailpoet');
}
public function getArgsSchema(): ObjectSchema {
return Builder::object();
}
public function getSubjectKeys(): array {
return [];
}
public function validate(StepValidationArgs $args): void {
$step = $args->getStep();
// validate next steps
$nextSteps = $step->getNextSteps();
if (count($nextSteps) !== 2) {
throw ValidationException::create()->withError(
'if_else_next_steps_count',
__('If/Else action must have exactly two next steps.', 'mailpoet')
);
}
// validate conditions
$groups = $step->getFilters() ? $step->getFilters()->getGroups() : [];
$conditions = array_map(function (FilterGroup $group) {
return $group->getFilters();
}, $groups);
if (count($conditions) === 0) {
throw ValidationException::create()->withError(
'if_else_conditions_count',
__('If/Else action must have at least one condition set.', 'mailpoet')
);
}
}
public function run(StepRunArgs $args, StepRunController $controller): void {
$matches = $this->filterHandler->matchesFilters($args);
$controller->scheduleNextStepByIndex($matches ? 0 : 1);
}
}
@@ -0,0 +1,46 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\Core;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Integration;
use MailPoet\Automation\Engine\Registry;
use MailPoet\Automation\Engine\WordPress;
use MailPoet\Automation\Integrations\Core\Actions\DelayAction;
use MailPoet\Automation\Integrations\Core\Actions\IfElseAction;
class CoreIntegration implements Integration {
/** @var DelayAction */
private $delayAction;
/** @var WordPress */
private $wordPress;
/** @var IfElseAction */
private $ifElseAction;
public function __construct(
DelayAction $delayAction,
IfElseAction $ifElseAction,
WordPress $wordPress
) {
$this->delayAction = $delayAction;
$this->ifElseAction = $ifElseAction;
$this->wordPress = $wordPress;
}
public function register(Registry $registry): void {
$registry->addAction($this->delayAction);
$registry->addAction($this->ifElseAction);
$registry->addFilter(new Filters\BooleanFilter());
$registry->addFilter(new Filters\NumberFilter());
$registry->addFilter(new Filters\IntegerFilter());
$registry->addFilter(new Filters\StringFilter());
$registry->addFilter(new Filters\DateTimeFilter($this->wordPress->wpTimezone()));
$registry->addFilter(new Filters\EnumFilter());
$registry->addFilter(new Filters\EnumArrayFilter());
}
}
@@ -0,0 +1,44 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\Core\Filters;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Field;
use MailPoet\Automation\Engine\Data\Filter as FilterData;
use MailPoet\Automation\Engine\Integration\Filter;
use MailPoet\Validator\Builder;
use MailPoet\Validator\Schema\ObjectSchema;
class BooleanFilter implements Filter {
public const CONDITION_IS = 'is';
public function getFieldType(): string {
return Field::TYPE_BOOLEAN;
}
public function getConditions(): array {
return [
self::CONDITION_IS => __('is', 'mailpoet'),
];
}
public function getArgsSchema(string $condition): ObjectSchema {
return Builder::object([
'value' => Builder::boolean()->required(),
]);
}
public function getFieldParams(FilterData $data): array {
return [];
}
public function matches(FilterData $data, $value): bool {
$filterValue = $data->getArgs()['value'] ?? null;
if (!is_bool($value) || !is_bool($filterValue)) {
return false;
}
return $data->getCondition() === self::CONDITION_IS && $value === $filterValue;
}
}
@@ -0,0 +1,198 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\Core\Filters;
if (!defined('ABSPATH')) exit;
use DateTimeImmutable;
use DateTimeInterface;
use DateTimeZone;
use MailPoet\Automation\Engine\Data\Field;
use MailPoet\Automation\Engine\Data\Filter as FilterData;
use MailPoet\Automation\Engine\Exceptions\InvalidStateException;
use MailPoet\Automation\Engine\Integration\Filter;
use MailPoet\Validator\Builder;
use MailPoet\Validator\Schema\ObjectSchema;
class DateTimeFilter implements Filter {
public const CONDITION_BEFORE = 'before';
public const CONDITION_AFTER = 'after';
public const CONDITION_ON = 'on';
public const CONDITION_NOT_ON = 'not-on';
public const CONDITION_IN_THE_LAST = 'in-the-last';
public const CONDITION_NOT_IN_THE_LAST = 'not-in-the-last';
public const CONDITION_IS_SET = 'is-set';
public const CONDITION_IS_NOT_SET = 'is-not-set';
public const CONDITION_ON_THE_DAYS_OF_THE_WEEK = 'on-the-days-of-the-week';
public const FORMAT_DATETIME = 'Y-m-d\TH:i:s';
public const FORMAT_DATE = 'Y-m-d';
public const REGEX_DATETIME = '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$';
public const REGEX_DATE = '^\d{4}-\d{2}-\d{2}$';
/** @var DateTimeZone */
private $localTimezone;
public function __construct(
DateTimeZone $localTimezone
) {
$this->localTimezone = $localTimezone;
}
public function getFieldType(): string {
return Field::TYPE_DATETIME;
}
public function getConditions(): array {
return [
self::CONDITION_BEFORE => __('before', 'mailpoet'),
self::CONDITION_AFTER => __('after', 'mailpoet'),
self::CONDITION_ON => __('on', 'mailpoet'),
self::CONDITION_NOT_ON => __('not on', 'mailpoet'),
self::CONDITION_IN_THE_LAST => __('in the last', 'mailpoet'),
self::CONDITION_NOT_IN_THE_LAST => __('not in the last', 'mailpoet'),
self::CONDITION_IS_SET => __('is set', 'mailpoet'),
self::CONDITION_IS_NOT_SET => __('is not set', 'mailpoet'),
self::CONDITION_ON_THE_DAYS_OF_THE_WEEK => __('on the day(s) of the week', 'mailpoet'),
];
}
public function getArgsSchema(string $condition): ObjectSchema {
switch ($condition) {
case self::CONDITION_BEFORE:
case self::CONDITION_AFTER:
return Builder::object([
'value' => Builder::string()->pattern(self::REGEX_DATETIME)->required(),
]);
case self::CONDITION_ON:
case self::CONDITION_NOT_ON:
return Builder::object([
'value' => Builder::string()->pattern(self::REGEX_DATE)->required(),
]);
case self::CONDITION_IN_THE_LAST:
case self::CONDITION_NOT_IN_THE_LAST:
return Builder::object([
'value' => Builder::object([
'number' => Builder::integer()->minimum(1)->required(),
'unit' => Builder::string()->pattern('^days|weeks|months$')->required(),
])->required(),
]);
case self::CONDITION_IS_SET:
case self::CONDITION_IS_NOT_SET:
return Builder::object([]);
case self::CONDITION_ON_THE_DAYS_OF_THE_WEEK:
return Builder::object([
'value' => Builder::array(Builder::integer()->minimum(0)->maximum(6))->minItems(1)->required(),
]);
default:
throw new InvalidStateException();
}
}
public function getFieldParams(FilterData $data): array {
return [];
}
public function matches(FilterData $data, $value): bool {
$filterValue = $data->getArgs()['value'] ?? null;
$condition = $data->getCondition();
// is set/is not set
if (in_array($condition, [self::CONDITION_IS_SET, self::CONDITION_IS_NOT_SET], true)) {
return $this->matchesSet($condition, $value);
}
// in the last/not in the last
if (in_array($condition, [self::CONDITION_IN_THE_LAST, self::CONDITION_NOT_IN_THE_LAST], true)) {
return $this->matchesInTheLast($condition, $filterValue, $value);
}
// on the day(s) of the week
if ($condition === self::CONDITION_ON_THE_DAYS_OF_THE_WEEK) {
return $this->matchesOnTheDaysOfTheWeek($filterValue, $value);
}
// other conditions
if (!is_string($filterValue) || !$value instanceof DateTimeInterface) {
return false;
}
$datetime = $this->convertToLocalTimezone($value);
switch ($condition) {
case 'before':
$ref = DateTimeImmutable::createFromFormat(self::FORMAT_DATETIME, $filterValue, $this->localTimezone);
return $ref && $datetime < $ref;
case 'after':
$ref = DateTimeImmutable::createFromFormat(self::FORMAT_DATETIME, $filterValue, $this->localTimezone);
return $ref && $datetime > $ref;
case 'on':
return $datetime->format(self::FORMAT_DATE) === $filterValue;
case 'not-on':
return $datetime->format(self::FORMAT_DATE) !== $filterValue;
default:
return false;
}
}
/** @param mixed $value */
private function matchesSet(string $condition, $value): bool {
switch ($condition) {
case self::CONDITION_IS_SET:
return $value !== null;
case self::CONDITION_IS_NOT_SET:
return $value === null;
default:
return false;
}
}
/**
* @param mixed $filterValue
* @param mixed $value
*/
private function matchesInTheLast(string $condition, $filterValue, $value): bool {
if (!is_array($filterValue) || !isset($filterValue['number']) || !isset($filterValue['unit']) || !$value instanceof DateTimeInterface) {
return false;
}
$number = $filterValue['number'];
$unit = $filterValue['unit'];
if (!is_integer($number) || !in_array($unit, ['days', 'weeks', 'months'], true)) {
return false;
}
$now = new DateTimeImmutable('now', $this->localTimezone);
$ref = $now->modify("-$number $unit");
$matches = $ref <= $value && $value <= $now;
return $condition === self::CONDITION_IN_THE_LAST ? $matches : !$matches;
}
/**
* @param mixed $filterValue
* @param mixed $value
*/
private function matchesOnTheDaysOfTheWeek($filterValue, $value): bool {
if (!is_array($filterValue) || !$value instanceof DateTimeInterface) {
return false;
}
foreach ($filterValue as $day) {
if (!is_integer($day) || $day < 0 || $day > 6) {
return false;
}
}
$date = $this->convertToLocalTimezone($value);
$day = (int)$date->format('w');
return in_array($day, $filterValue, true);
}
private function convertToLocalTimezone(DateTimeInterface $datetime): DateTimeImmutable {
$value = DateTimeImmutable::createFromFormat('U', (string)$datetime->getTimestamp(), $this->localTimezone);
if (!$value) {
throw new InvalidStateException('Failed to convert datetime to WP timezone');
}
return $value;
}
}
@@ -0,0 +1,83 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\Core\Filters;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Field;
use MailPoet\Automation\Engine\Data\Filter as FilterData;
use MailPoet\Automation\Engine\Integration\Filter;
use MailPoet\Validator\Builder;
use MailPoet\Validator\Schema\ObjectSchema;
class EnumArrayFilter implements Filter {
public const CONDITION_MATCHES_ANY_OF = 'matches-any-of';
public const CONDITION_MATCHES_ALL_OF = 'matches-all-of';
public const CONDITION_MATCHES_NONE_OF = 'matches-none-of';
public function getFieldType(): string {
return Field::TYPE_ENUM_ARRAY;
}
public function getConditions(): array {
return [
self::CONDITION_MATCHES_ANY_OF => __('matches any of', 'mailpoet'),
self::CONDITION_MATCHES_ALL_OF => __('matches all of', 'mailpoet'),
self::CONDITION_MATCHES_NONE_OF => __('matches none of', 'mailpoet'),
];
}
public function getArgsSchema(string $condition): ObjectSchema {
$paramsSchema = Builder::object([
'in_the_last' => Builder::object([
'number' => Builder::integer()->required()->minimum(1),
'unit' => Builder::string()->required()->pattern('^(days)$')->default('days'),
]),
]);
return Builder::object([
'value' => Builder::oneOf([
Builder::array(Builder::string())->minItems(1),
Builder::array(Builder::integer())->minItems(1),
])->required(),
'params' => $paramsSchema,
]);
}
public function getFieldParams(FilterData $data): array {
$paramData = $data->getArgs()['params'] ?? [];
$params = [];
$inTheLastUnit = $paramData['in_the_last']['unit'] ?? null;
$inTheLastNumber = $paramData['in_the_last']['number'] ?? null;
if ($inTheLastUnit === 'days' && $inTheLastNumber !== null) {
$params['in_the_last'] = $inTheLastNumber * DAY_IN_SECONDS;
}
return $params;
}
public function matches(FilterData $data, $value): bool {
$filterValue = $data->getArgs()['value'] ?? null;
if (!is_array($value) || !is_array($filterValue)) {
return false;
}
$filterValue = array_unique($filterValue, SORT_REGULAR);
$value = array_unique($value, SORT_REGULAR);
$filterCount = count($filterValue);
$matchedCount = count(array_intersect($value, $filterValue));
switch ($data->getCondition()) {
case self::CONDITION_MATCHES_ANY_OF:
return $filterCount > 0 && $matchedCount > 0;
case self::CONDITION_MATCHES_ALL_OF:
return $filterCount > 0 && $matchedCount === count($filterValue);
case self::CONDITION_MATCHES_NONE_OF:
return $matchedCount === 0;
default:
return false;
}
}
}
@@ -0,0 +1,75 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\Core\Filters;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Field;
use MailPoet\Automation\Engine\Data\Filter as FilterData;
use MailPoet\Automation\Engine\Integration\Filter;
use MailPoet\Validator\Builder;
use MailPoet\Validator\Schema\ObjectSchema;
class EnumFilter implements Filter {
public const IS_ANY_OF = 'is-any-of';
public const IS_NONE_OF = 'is-none-of';
public function getFieldType(): string {
return Field::TYPE_ENUM;
}
public function getConditions(): array {
return [
self::IS_ANY_OF => __('is any of', 'mailpoet'),
self::IS_NONE_OF => __('is none of', 'mailpoet'),
];
}
public function getArgsSchema(string $condition): ObjectSchema {
$paramsSchema = Builder::object([
'in_the_last' => Builder::object([
'number' => Builder::integer()->required()->minimum(1),
'unit' => Builder::string()->required()->pattern('^(days)$')->default('days'),
]),
]);
return Builder::object([
'value' => Builder::oneOf([
Builder::array(Builder::string())->minItems(1),
Builder::array(Builder::integer())->minItems(1),
])->required(),
'params' => $paramsSchema,
]);
}
public function getFieldParams(FilterData $data): array {
$paramData = $data->getArgs()['params'] ?? [];
$params = [];
$inTheLastUnit = $paramData['in_the_last']['unit'] ?? null;
$inTheLastNumber = $paramData['in_the_last']['number'] ?? null;
if ($inTheLastUnit === 'days' && $inTheLastNumber !== null) {
$params['in_the_last'] = $inTheLastNumber * DAY_IN_SECONDS;
}
return $params;
}
public function matches(FilterData $data, $value): bool {
$filterValue = $data->getArgs()['value'] ?? null;
if (!is_scalar($value) || !is_array($filterValue)) {
return false;
}
$filterValue = array_unique($filterValue, SORT_REGULAR);
switch ($data->getCondition()) {
case self::IS_ANY_OF:
return in_array($value, $filterValue, true);
case self::IS_NONE_OF:
return !in_array($value, $filterValue, true);
default:
return false;
}
}
}
@@ -0,0 +1,89 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\Core\Filters;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Field;
use MailPoet\Automation\Engine\Data\Filter as FilterData;
use MailPoet\Validator\Builder;
use MailPoet\Validator\Schema\ObjectSchema;
class IntegerFilter extends NumberFilter {
public function getFieldType(): string {
return Field::TYPE_INTEGER;
}
public function getArgsSchema(string $condition): ObjectSchema {
$paramsSchema = Builder::object([
'in_the_last' => Builder::object([
'number' => Builder::integer()->required()->minimum(1),
'unit' => Builder::string()->required()->pattern('^(days)$')->default('days'),
]),
]);
switch ($condition) {
case self::CONDITION_BETWEEN:
case self::CONDITION_NOT_BETWEEN:
return Builder::object([
'value' => Builder::array(Builder::integer())->minItems(2)->maxItems(2)->required(),
'params' => $paramsSchema,
]);
case self::CONDITION_IS_SET:
case self::CONDITION_IS_NOT_SET:
return Builder::object([
'params' => $paramsSchema,
]);
default:
return Builder::object([
'value' => Builder::integer()->required(),
'params' => $paramsSchema,
]);
}
}
public function getFieldParams(FilterData $data): array {
$paramData = $data->getArgs()['params'] ?? [];
$params = [];
$inTheLastUnit = $paramData['in_the_last']['unit'] ?? null;
$inTheLastNumber = $paramData['in_the_last']['number'] ?? null;
if ($inTheLastUnit === 'days' && $inTheLastNumber !== null) {
$params['in_the_last'] = $inTheLastNumber * DAY_IN_SECONDS;
}
return $params;
}
public function matches(FilterData $data, $value): bool {
$matches = parent::matches($data, $value);
if (!$matches) {
return false;
}
if (isset($value) && !$this->isWholeNumber($value)) {
return false;
}
$filterValue = $data->getArgs()['value'] ?? null;
if (is_array($filterValue)) {
foreach ($filterValue as $filterValueItem) {
if (!$this->isWholeNumber($filterValueItem)) {
return false;
}
}
return true;
}
if (isset($filterValue) && !$this->isWholeNumber($filterValue)) {
return false;
}
return true;
}
/** @param mixed $value */
private function isWholeNumber($value): bool {
return is_int($value) || (is_float($value) && $value === floor($value));
}
}
@@ -0,0 +1,176 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\Core\Filters;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Field;
use MailPoet\Automation\Engine\Data\Filter as FilterData;
use MailPoet\Automation\Engine\Integration\Filter;
use MailPoet\Validator\Builder;
use MailPoet\Validator\Schema\ObjectSchema;
class NumberFilter implements Filter {
public const CONDITION_EQUALS = 'equals';
public const CONDITION_NOT_EQUAL = 'not-equal';
public const CONDITION_GREATER_THAN = 'greater-than';
public const CONDITION_LESS_THAN = 'less-than';
public const CONDITION_BETWEEN = 'between';
public const CONDITION_NOT_BETWEEN = 'not-between';
public const CONDITION_IS_MULTIPLE_OF = 'is-multiple-of';
public const CONDITION_IS_NOT_MULTIPLE_OF = 'is-not-multiple-of';
public const CONDITION_IS_SET = 'is-set';
public const CONDITION_IS_NOT_SET = 'is-not-set';
public function getFieldType(): string {
return Field::TYPE_NUMBER;
}
public function getConditions(): array {
return [
self::CONDITION_EQUALS => __('equals', 'mailpoet'),
self::CONDITION_NOT_EQUAL => __('not equal', 'mailpoet'),
self::CONDITION_GREATER_THAN => __('greater than', 'mailpoet'),
self::CONDITION_LESS_THAN => __('less than', 'mailpoet'),
self::CONDITION_BETWEEN => __('between', 'mailpoet'),
self::CONDITION_NOT_BETWEEN => __('not between', 'mailpoet'),
self::CONDITION_IS_MULTIPLE_OF => __('is multiple of', 'mailpoet'),
self::CONDITION_IS_NOT_MULTIPLE_OF => __('is not multiple of', 'mailpoet'),
self::CONDITION_IS_SET => __('is set', 'mailpoet'),
self::CONDITION_IS_NOT_SET => __('is not set', 'mailpoet'),
];
}
public function getArgsSchema(string $condition): ObjectSchema {
$paramsSchema = Builder::object([
'in_the_last' => Builder::object([
'number' => Builder::integer()->required()->minimum(1),
'unit' => Builder::string()->required()->pattern('^(days)$')->default('days'),
]),
]);
switch ($condition) {
case self::CONDITION_BETWEEN:
case self::CONDITION_NOT_BETWEEN:
return Builder::object([
'value' => Builder::array(Builder::number())->minItems(2)->maxItems(2)->required(),
'params' => $paramsSchema,
]);
case self::CONDITION_IS_SET:
case self::CONDITION_IS_NOT_SET:
return Builder::object([
'params' => $paramsSchema,
]);
default:
return Builder::object([
'value' => Builder::number()->required(),
'params' => $paramsSchema,
]);
}
}
public function getFieldParams(FilterData $data): array {
$paramData = $data->getArgs()['params'] ?? [];
$params = [];
$inTheLastUnit = $paramData['in_the_last']['unit'] ?? null;
$inTheLastNumber = $paramData['in_the_last']['number'] ?? null;
if ($inTheLastUnit === 'days' && $inTheLastNumber !== null) {
$params['in_the_last'] = $inTheLastNumber * DAY_IN_SECONDS;
}
return $params;
}
/**
* @param float $value
*/
public function matches(FilterData $data, $value): bool {
$filterValue = $data->getArgs()['value'] ?? null;
$condition = $data->getCondition();
// is between/not between
if (in_array($condition, [self::CONDITION_BETWEEN, self::CONDITION_NOT_BETWEEN], true)) {
return $this->matchesBetween($condition, $value, $filterValue);
}
// is set/is not set
if (in_array($condition, [self::CONDITION_IS_SET, self::CONDITION_IS_NOT_SET], true)) {
return $this->matchesSet($condition, $value);
}
if (!$this->isNumber($value) || !$this->isNumber($filterValue)) {
return false;
}
$value = floatval($value);
$filterValue = floatval($filterValue);
switch ($condition) {
case self::CONDITION_EQUALS:
return $value === $filterValue;
case self::CONDITION_NOT_EQUAL:
return $value !== $filterValue;
case self::CONDITION_GREATER_THAN:
return $value > $filterValue;
case self::CONDITION_LESS_THAN:
return $value < $filterValue;
case self::CONDITION_IS_MULTIPLE_OF:
return fmod($value, $filterValue) === 0.0;
case self::CONDITION_IS_NOT_MULTIPLE_OF:
return fmod($value, $filterValue) !== 0.0;
default:
return false;
}
}
/**
* @param float|null $value
* @param mixed $filterValue
*/
private function matchesBetween(string $condition, $value, $filterValue): bool {
if (!is_array($filterValue) || count($filterValue) !== 2) {
return false;
}
if (!$this->isNumber($filterValue[0]) || !$this->isNumber($filterValue[1]) || $filterValue[0] >= $filterValue[1]) {
return false;
}
if (!$this->isNumber($value)) {
return false;
}
/** @var float $value */
$value = floatval($value);
$from = floatval($filterValue[0]);
$to = floatval($filterValue[1]);
switch ($condition) {
case self::CONDITION_BETWEEN:
return $value > $from && $value < $to;
case self::CONDITION_NOT_BETWEEN:
return $value <= $from || $value >= $to;
default:
return false;
}
}
/** @param mixed $value */
private function matchesSet(string $condition, $value): bool {
switch ($condition) {
case self::CONDITION_IS_SET:
return $value !== null;
case self::CONDITION_IS_NOT_SET:
return $value === null;
default:
return false;
}
}
/** @param mixed $value */
private function isNumber($value): bool {
return is_integer($value) || is_float($value);
}
}
@@ -0,0 +1,113 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\Core\Filters;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Field;
use MailPoet\Automation\Engine\Data\Filter as FilterData;
use MailPoet\Automation\Engine\Exceptions\InvalidStateException;
use MailPoet\Automation\Engine\Integration\Filter;
use MailPoet\Validator\Builder;
use MailPoet\Validator\Schema\ObjectSchema;
class StringFilter implements Filter {
public const CONDITION_CONTAINS = 'contains';
public const CONDITION_DOES_NOT_CONTAIN = 'does-not-contain';
public const CONDITION_IS = 'is';
public const CONDITION_IS_NOT = 'is-not';
public const CONDITION_STARTS_WITH = 'starts-with';
public const CONDITION_ENDS_WITH = 'ends-with';
public const CONDITION_IS_BLANK = 'is-blank';
public const CONDITION_IS_NOT_BLANK = 'is-not-blank';
public const CONDITION_MATCHES_REGEX = 'matches-regex';
public function getFieldType(): string {
return Field::TYPE_STRING;
}
public function getConditions(): array {
return [
self::CONDITION_IS => __('is', 'mailpoet'),
self::CONDITION_IS_NOT => __('is not', 'mailpoet'),
self::CONDITION_CONTAINS => __('contains', 'mailpoet'),
self::CONDITION_DOES_NOT_CONTAIN => __('does not contain', 'mailpoet'),
self::CONDITION_STARTS_WITH => __('starts with', 'mailpoet'),
self::CONDITION_ENDS_WITH => __('ends with', 'mailpoet'),
self::CONDITION_IS_BLANK => __('is blank', 'mailpoet'),
self::CONDITION_IS_NOT_BLANK => __('is not blank', 'mailpoet'),
self::CONDITION_MATCHES_REGEX => __('matches regex', 'mailpoet'),
];
}
public function getArgsSchema(string $condition): ObjectSchema {
switch ($condition) {
case self::CONDITION_IS_BLANK:
case self::CONDITION_IS_NOT_BLANK:
return Builder::object([]);
default:
return Builder::object(['value' => Builder::string()->required()]);
}
}
public function getFieldParams(FilterData $data): array {
return [];
}
public function matches(FilterData $data, $value): bool {
$filterValue = $data->getArgs()['value'] ?? null;
if (!is_string($value) || !is_string($filterValue)) {
return false;
}
// match regex as it is
$condition = $data->getCondition();
if ($condition === self::CONDITION_MATCHES_REGEX) {
return $this->matchesRegex($filterValue, $value);
}
// match all other conditions case insensitively
$value = mb_strtolower($value);
$filterValue = mb_strtolower($filterValue);
switch ($data->getCondition()) {
case self::CONDITION_IS:
return $value === $filterValue;
case self::CONDITION_IS_NOT:
return $value !== $filterValue;
case self::CONDITION_CONTAINS:
return str_contains($value, $filterValue);
case self::CONDITION_DOES_NOT_CONTAIN:
return !str_contains($value, $filterValue);
case self::CONDITION_STARTS_WITH:
return str_starts_with($value, $filterValue);
case self::CONDITION_ENDS_WITH:
return str_ends_with($value, $filterValue);
case self::CONDITION_IS_BLANK:
return strlen($value) === 0;
case self::CONDITION_IS_NOT_BLANK:
return strlen($value) > 0;
default:
return false;
}
}
protected function matchesRegex(string $regex, string $value): bool {
// add '/' delimiters, if missing
if (!@preg_match('#^/.*/[a-z]*$#ui', $regex)) {
$regex = '/' . str_replace('/', '\\/', $regex) . '/u';
}
// add unicode flag, if not present
if (!@preg_match('#/.*u.*$#ui', $regex)) {
$regex .= 'u';
}
if (@preg_match($regex, '') === false) {
throw new InvalidStateException("Invalid regular expression: '$regex'");
}
return @preg_match($regex, $value) === 1;
}
}
@@ -0,0 +1 @@
<?php
@@ -0,0 +1,515 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\MailPoet\Actions;
if (!defined('ABSPATH')) exit;
use MailPoet\AutomaticEmails\WooCommerce\Events\AbandonedCart;
use MailPoet\Automation\Engine\Control\AutomationController;
use MailPoet\Automation\Engine\Control\StepRunController;
use MailPoet\Automation\Engine\Data\Automation;
use MailPoet\Automation\Engine\Data\Step;
use MailPoet\Automation\Engine\Data\StepRunArgs;
use MailPoet\Automation\Engine\Data\StepValidationArgs;
use MailPoet\Automation\Engine\Exceptions\NotFoundException;
use MailPoet\Automation\Engine\Integration\Action;
use MailPoet\Automation\Engine\Integration\ValidationException;
use MailPoet\Automation\Integrations\MailPoet\Payloads\SegmentPayload;
use MailPoet\Automation\Integrations\MailPoet\Payloads\SubscriberPayload;
use MailPoet\Automation\Integrations\WooCommerce\Payloads\AbandonedCartPayload;
use MailPoet\Entities\NewsletterEntity;
use MailPoet\Entities\NewsletterOptionEntity;
use MailPoet\Entities\NewsletterOptionFieldEntity;
use MailPoet\Entities\ScheduledTaskSubscriberEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\InvalidStateException;
use MailPoet\Newsletter\NewslettersRepository;
use MailPoet\Newsletter\Options\NewsletterOptionFieldsRepository;
use MailPoet\Newsletter\Options\NewsletterOptionsRepository;
use MailPoet\Newsletter\Scheduler\AutomationEmailScheduler;
use MailPoet\Segments\SegmentsRepository;
use MailPoet\Settings\SettingsController;
use MailPoet\Subscribers\SubscriberSegmentRepository;
use MailPoet\Subscribers\SubscribersRepository;
use MailPoet\Validator\Builder;
use MailPoet\Validator\Schema\ObjectSchema;
use Throwable;
class SendEmailAction implements Action {
const KEY = 'mailpoet:send-email';
// Intervals to poll for email status after sending. These are only
// used when immediate status sync fails or the email is never sent.
private const POLL_INTERVALS = [
5 * MINUTE_IN_SECONDS, // ~5 minutes
10 * MINUTE_IN_SECONDS, // ~15 minutes
45 * MINUTE_IN_SECONDS, // ~1 hour
4 * HOUR_IN_SECONDS, // ~5 hours ...from email scheduling
19 * HOUR_IN_SECONDS, // ~1 day
4 * DAY_IN_SECONDS, // ~5 days
25 * DAY_IN_SECONDS, // ~1 month
];
// Retry intervals for sending. These are used when the email address
// is not confirmed, and we need send non-transactional emails.
private const OPTIN_RETRY_INTERVALS = [
1 * MINUTE_IN_SECONDS, // ~1 minute
5 * MINUTE_IN_SECONDS, // ~5 minutes
20 * MINUTE_IN_SECONDS, // ~20 minutes
1 * HOUR_IN_SECONDS, // ~1 hour
12 * HOUR_IN_SECONDS, // ~12 hours
1 * DAY_IN_SECONDS, // ~1 day
];
private const WAIT_OPTIN = 'wait_optin';
private const OPTIN_RETRIES = 'optin_retries';
private const TRANSACTIONAL_TRIGGERS = [
'woocommerce:order-status-changed',
'woocommerce:order-created',
'woocommerce:order-completed',
'woocommerce:order-cancelled',
'woocommerce:abandoned-cart',
'woocommerce-subscriptions:subscription-created',
'woocommerce-subscriptions:subscription-expired',
'woocommerce-subscriptions:subscription-payment-failed',
'woocommerce-subscriptions:subscription-renewed',
'woocommerce-subscriptions:subscription-status-changed',
'woocommerce-subscriptions:trial-ended',
'woocommerce-subscriptions:trial-started',
'woocommerce:buys-from-a-tag',
'woocommerce:buys-from-a-category',
'woocommerce:buys-a-product',
];
private AutomationController $automationController;
private SettingsController $settings;
private NewslettersRepository $newslettersRepository;
private SubscriberSegmentRepository $subscriberSegmentRepository;
private SubscribersRepository $subscribersRepository;
private SegmentsRepository $segmentsRepository;
private AutomationEmailScheduler $automationEmailScheduler;
private NewsletterOptionsRepository $newsletterOptionsRepository;
private NewsletterOptionFieldsRepository $newsletterOptionFieldsRepository;
public function __construct(
AutomationController $automationController,
SettingsController $settings,
NewslettersRepository $newslettersRepository,
SubscriberSegmentRepository $subscriberSegmentRepository,
SubscribersRepository $subscribersRepository,
SegmentsRepository $segmentsRepository,
AutomationEmailScheduler $automationEmailScheduler,
NewsletterOptionsRepository $newsletterOptionsRepository,
NewsletterOptionFieldsRepository $newsletterOptionFieldsRepository
) {
$this->automationController = $automationController;
$this->settings = $settings;
$this->newslettersRepository = $newslettersRepository;
$this->subscriberSegmentRepository = $subscriberSegmentRepository;
$this->subscribersRepository = $subscribersRepository;
$this->segmentsRepository = $segmentsRepository;
$this->automationEmailScheduler = $automationEmailScheduler;
$this->newsletterOptionsRepository = $newsletterOptionsRepository;
$this->newsletterOptionFieldsRepository = $newsletterOptionFieldsRepository;
}
public function getKey(): string {
return self::KEY;
}
public function getName(): string {
// translators: automation action title
return __('Send email', 'mailpoet');
}
public function getArgsSchema(): ObjectSchema {
$nameDefault = $this->settings->get('sender.name');
$addressDefault = $this->settings->get('sender.address');
$replyToNameDefault = $this->settings->get('reply_to.name');
$replyToAddressDefault = $this->settings->get('reply_to.address');
$nonEmptyString = Builder::string()->required()->minLength(1);
return Builder::object([
// required fields
'email_id' => Builder::integer()->required(),
'name' => $nonEmptyString->default(__('Send email', 'mailpoet')),
'subject' => $nonEmptyString->default(__('Subject', 'mailpoet')),
'preheader' => Builder::string()->required()->default(''),
'sender_name' => $nonEmptyString->default($nameDefault),
'sender_address' => $nonEmptyString->formatEmail()->default($addressDefault),
// optional fields
'reply_to_name' => ($replyToNameDefault && $replyToNameDefault !== $nameDefault)
? Builder::string()->minLength(1)->default($replyToNameDefault)
: Builder::string()->minLength(1),
'reply_to_address' => ($replyToAddressDefault && $replyToAddressDefault !== $addressDefault)
? Builder::string()->formatEmail()->default($replyToAddressDefault)
: Builder::string()->formatEmail(),
'ga_campaign' => Builder::string()->minLength(1),
]);
}
public function getSubjectKeys(): array {
return [
'mailpoet:subscriber',
];
}
public function validate(StepValidationArgs $args): void {
try {
$this->getEmailForStep($args->getStep());
} catch (InvalidStateException $exception) {
$exception = ValidationException::create()
->withMessage(__('Cannot send the email because it was not found. Please, go to the automation editor and update the email contents.', 'mailpoet'));
$emailId = $args->getStep()->getArgs()['email_id'] ?? '';
if (empty($emailId)) {
$exception->withError('email_id', __("Automation email not found.", 'mailpoet'));
} else {
$exception->withError(
'email_id',
// translators: %s is the ID of email.
sprintf(__("Automation email with ID '%s' not found.", 'mailpoet'), $emailId)
);
}
throw $exception;
}
}
public function run(StepRunArgs $args, StepRunController $controller): void {
$newsletter = $this->getEmailForStep($args->getStep());
$subscriber = $this->getSubscriber($args);
$state = null;
if ($args->isFirstRun()) {
$subscriberStatus = $subscriber->getStatus();
if ($subscriberStatus === SubscriberEntity::STATUS_BOUNCED) {
// translators: %s is the subscriber's status.
throw InvalidStateException::create()->withMessage(sprintf(__("Cannot send the email because the subscriber's status is '%s'.", 'mailpoet'), $subscriberStatus));
}
if ($this->isOptInRequired($newsletter, $subscriber)) {
$controller->getRunLog()->saveLogData([self::WAIT_OPTIN => 1]);
$this->rerunLater($args->getRunNumber(), $controller, $newsletter, $subscriber);
return;
}
$this->scheduleEmail($args, $newsletter, $subscriber);
} else {
// Re-running for opt-in?
$state = $this->getRunLogData($controller);
if (array_key_exists(self::WAIT_OPTIN, $state) && $state[self::WAIT_OPTIN] === 1) {
if ($this->isOptInRequired($newsletter, $subscriber)) {
$this->rerunLater($args->getRunNumber(), $controller, $newsletter, $subscriber);
return;
}
// Subscriber is now confirmed, so we can schedule an email.
$controller->getRunLog()->saveLogData([
self::WAIT_OPTIN => 0,
self::OPTIN_RETRIES => $args->getRunNumber(),
]);
$this->scheduleEmail($args, $newsletter, $subscriber);
}
// Check/sync sending status with the automation step
$success = $this->checkSendingStatus($args, $newsletter, $subscriber);
if ($success) {
return;
}
}
// At this point, we're re-running to check sending status. We need
// to offset opt-in reruns count from sending reruns.
$runNumber = $args->getRunNumber();
$state = $state ?? $this->getRunLogData($controller);
$optinRetryCount = $state[self::OPTIN_RETRIES] ?? 0;
$runNumber -= $optinRetryCount;
$this->rerunLater($runNumber, $controller, $newsletter, $subscriber);
}
private function scheduleEmail(StepRunArgs $args, NewsletterEntity $newsletter, SubscriberEntity $subscriber): void {
$meta = $this->getNewsletterMeta($args);
try {
$this->automationEmailScheduler->createSendingTask($newsletter, $subscriber, $meta);
} catch (Throwable $e) {
throw InvalidStateException::create()->withMessage(__('Could not create sending task.', 'mailpoet'));
}
}
private function getRunLogData(StepRunController $controller): array {
$runLog = $controller->getRunLog()->getLog();
return $runLog->getData();
}
/**
* Schedule a progress run to sync the email sending status to the automation step.
* Normally, a progress run is executed immediately after sending; we're scheduling
* these runs to poll for the status if sync fails or email never sends (timeout),
* or if we need to wait for subscriber opt-in.
*/
private function rerunLater(int $runNumber, StepRunController $controller, NewsletterEntity $newsletter, SubscriberEntity $subscriber): void {
$nextInterval = self::POLL_INTERVALS[$runNumber - 1] ?? 0;
// Use different intervals when retrying for opt-in.
if ($this->isOptInRequired($newsletter, $subscriber)) {
if ($runNumber > count(self::OPTIN_RETRY_INTERVALS)) {
$subscriberStatus = $subscriber->getStatus();
// translators: %s is the subscriber's status.
throw InvalidStateException::create()->withMessage(sprintf(__("Cannot send the email because the subscriber's status is '%s'.", 'mailpoet'), $subscriberStatus));
}
$nextInterval = self::OPTIN_RETRY_INTERVALS[$runNumber - 1];
}
$controller->scheduleProgress(time() + $nextInterval);
}
private function isOptInRequired(NewsletterEntity $newsletter, SubscriberEntity $subscriber): bool {
$subscriberStatus = $subscriber->getStatus();
if ($newsletter->getType() === NewsletterEntity::TYPE_AUTOMATION_TRANSACTIONAL) return false;
return $subscriberStatus !== SubscriberEntity::STATUS_SUBSCRIBED;
}
/** @param mixed $data */
public function handleEmailSent($data): void {
if (!is_array($data)) {
throw InvalidStateException::create()->withMessage(
// translators: %s is the type of $data.
sprintf(__('Invalid automation step data. Array expected, got: %s', 'mailpoet'), gettype($data))
);
}
$runId = $data['run_id'] ?? null;
if (!is_int($runId)) {
throw InvalidStateException::create()->withMessage(
// translators: %s is the type of $runId.
sprintf(__("Invalid automation step data. Expected 'run_id' to be an integer, got: %s", 'mailpoet'), gettype($runId))
);
}
$stepId = $data['step_id'] ?? null;
if (!is_string($stepId)) {
throw InvalidStateException::create()->withMessage(
// translators: %s is the type of $runId.
sprintf(__("Invalid automation step data. Expected 'step_id' to be a string, got: %s", 'mailpoet'), gettype($runId))
);
}
$this->automationController->enqueueProgress($runId, $stepId);
}
private function checkSendingStatus(StepRunArgs $args, NewsletterEntity $newsletter, SubscriberEntity $subscriber): bool {
$scheduledTaskSubscriber = $this->automationEmailScheduler->getScheduledTaskSubscriber($newsletter, $subscriber, $args->getAutomationRun());
if (!$scheduledTaskSubscriber) {
throw InvalidStateException::create()->withMessage(__('Email failed to schedule.', 'mailpoet'));
}
// email sending failed
if ($scheduledTaskSubscriber->getFailed() === ScheduledTaskSubscriberEntity::FAIL_STATUS_FAILED) {
throw InvalidStateException::create()->withMessage(
// translators: %s is the error message.
sprintf(__('Email failed to send. Error: %s', 'mailpoet'), $scheduledTaskSubscriber->getError() ?: 'Unknown error')
);
}
$wasSent = $scheduledTaskSubscriber->getProcessed() === ScheduledTaskSubscriberEntity::STATUS_PROCESSED;
$isLastRun = $args->getRunNumber() >= 1 + count(self::POLL_INTERVALS);
// email was never sent
if (!$wasSent && $isLastRun) {
$error = __('Email sending process timed out.', 'mailpoet');
$this->automationEmailScheduler->saveError($scheduledTaskSubscriber, $error);
throw InvalidStateException::create()->withMessage($error);
}
return $wasSent;
}
private function getNewsletterMeta(StepRunArgs $args): array {
$meta = [
'automation' => [
'id' => $args->getAutomation()->getId(),
'run_id' => $args->getAutomationRun()->getId(),
'step_id' => $args->getStep()->getId(),
'run_number' => $args->getRunNumber(),
],
];
if ($this->automationHasAbandonedCartTrigger($args->getAutomation())) {
$payload = $args->getSinglePayloadByClass(AbandonedCartPayload::class);
$meta[AbandonedCart::TASK_META_NAME] = $payload->getProductIds();
}
return $meta;
}
private function getSubscriber(StepRunArgs $args): SubscriberEntity {
$subscriberId = $args->getSinglePayloadByClass(SubscriberPayload::class)->getId();
try {
$segmentId = $args->getSinglePayloadByClass(SegmentPayload::class)->getId();
} catch (NotFoundException $e) {
$segmentId = null;
}
// Without segment, fetch subscriber by ID (needed e.g. for "mailpoet:custom-trigger").
// Transactional emails don't need to be checked against segment, no matter if it's set.
if (!$segmentId || $this->isTransactional($args->getStep(), $args->getAutomation())) {
$subscriber = $this->subscribersRepository->findOneById($subscriberId);
if (!$subscriber) {
throw InvalidStateException::create();
}
return $subscriber;
}
// With segment, fetch subscriber segment and check if they are subscribed.
$subscriberSegment = $this->subscriberSegmentRepository->findOneBy([
'subscriber' => $subscriberId,
'segment' => $segmentId,
'status' => SubscriberEntity::STATUS_SUBSCRIBED,
]);
if (!$subscriberSegment) {
$segment = $this->segmentsRepository->findOneById($segmentId);
if (!$segment) { // This state should not happen because it is checked in the validation.
throw InvalidStateException::create()->withMessage(__('Cannot send the email because the list was not found.', 'mailpoet'));
}
// translators: %s is the name of the list.
throw InvalidStateException::create()->withMessage(sprintf(__("Cannot send the email because the subscriber is not subscribed to the '%s' list.", 'mailpoet'), $segment->getName()));
}
$subscriber = $subscriberSegment->getSubscriber();
if (!$subscriber) {
throw InvalidStateException::create();
}
return $subscriber;
}
public function saveEmailSettings(Step $step, Automation $automation): void {
$args = $step->getArgs();
if (!isset($args['email_id']) || !$args['email_id']) {
return;
}
$email = $this->getEmailForStep($step);
$email->setType($this->isTransactional($step, $automation) ? NewsletterEntity::TYPE_AUTOMATION_TRANSACTIONAL : NewsletterEntity::TYPE_AUTOMATION);
$email->setStatus(NewsletterEntity::STATUS_ACTIVE);
$email->setSubject($args['subject'] ?? '');
$email->setPreheader($args['preheader'] ?? '');
$email->setSenderName($args['sender_name'] ?? '');
$email->setSenderAddress($args['sender_address'] ?? '');
$email->setReplyToName($args['reply_to_name'] ?? '');
$email->setReplyToAddress($args['reply_to_address'] ?? '');
$email->setGaCampaign($args['ga_campaign'] ?? '');
$this->storeNewsletterOption(
$email,
NewsletterOptionFieldEntity::NAME_GROUP,
$this->automationHasWooCommerceTrigger($automation) ? 'woocommerce' : null
);
$this->storeNewsletterOption(
$email,
NewsletterOptionFieldEntity::NAME_EVENT,
$this->automationHasAbandonedCartTrigger($automation) ? 'woocommerce_abandoned_shopping_cart' : null
);
$this->newslettersRepository->persist($email);
$this->newslettersRepository->flush();
}
private function storeNewsletterOption(NewsletterEntity $newsletter, string $optionName, string $optionValue = null): void {
$options = $newsletter->getOptions()->toArray();
foreach ($options as $key => $option) {
if ($option->getName() === $optionName) {
if ($optionValue) {
$option->setValue($optionValue);
return;
}
$newsletter->getOptions()->remove($key);
$this->newsletterOptionsRepository->remove($option);
return;
}
}
if (!$optionValue) {
return;
}
$field = $this->newsletterOptionFieldsRepository->findOneBy([
'name' => $optionName,
'newsletterType' => $newsletter->getType(),
]);
if (!$field) {
return;
}
$option = new NewsletterOptionEntity($newsletter, $field);
$option->setValue($optionValue);
$this->newsletterOptionsRepository->persist($option);
$newsletter->getOptions()->add($option);
}
private function isTransactional(Step $step, Automation $automation): bool {
$triggers = $automation->getTriggers();
$transactionalTriggers = array_filter(
$triggers,
function(Step $step): bool {
return in_array($step->getKey(), self::TRANSACTIONAL_TRIGGERS, true);
}
);
if (!$triggers || count($transactionalTriggers) !== count($triggers)) {
return false;
}
foreach ($transactionalTriggers as $trigger) {
if (!in_array($step->getId(), $trigger->getNextStepIds(), true)) {
return false;
}
}
return true;
}
private function automationHasWooCommerceTrigger(Automation $automation): bool {
return (bool)array_filter(
$automation->getTriggers(),
function(Step $step): bool {
return strpos($step->getKey(), 'woocommerce:') === 0;
}
);
}
private function automationHasAbandonedCartTrigger(Automation $automation): bool {
return (bool)array_filter(
$automation->getTriggers(),
function(Step $step): bool {
return in_array($step->getKey(), ['woocommerce:abandoned-cart'], true);
}
);
}
private function getEmailForStep(Step $step): NewsletterEntity {
$emailId = $step->getArgs()['email_id'] ?? null;
if (!$emailId) {
throw InvalidStateException::create();
}
$email = $this->newslettersRepository->findOneBy([
'id' => $emailId,
]);
if (!$email || !in_array($email->getType(), [NewsletterEntity::TYPE_AUTOMATION, NewsletterEntity::TYPE_AUTOMATION_TRANSACTIONAL], true)) {
throw InvalidStateException::create()->withMessage(
// translators: %s is the ID of email.
sprintf(__("Automation email with ID '%s' not found.", 'mailpoet'), $emailId)
);
}
return $email;
}
}
@@ -0,0 +1,31 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\MailPoet\Analytics;
if (!defined('ABSPATH')) exit;
use MailPoet\API\REST\API;
use MailPoet\Automation\Engine\Hooks;
use MailPoet\Automation\Engine\WordPress;
use MailPoet\Automation\Integrations\MailPoet\Analytics\Endpoints\AutomationFlowEndpoint;
use MailPoet\Automation\Integrations\MailPoet\Analytics\Endpoints\OverviewEndpoint;
class Analytics {
/** @var WordPress */
private $wordPress;
public function __construct(
WordPress $wordPress
) {
$this->wordPress = $wordPress;
}
public function register(): void {
$this->wordPress->addAction(Hooks::API_INITIALIZE, function (API $api) {
$api->registerGetRoute('automation/analytics/automation_flow', AutomationFlowEndpoint::class);
$api->registerGetRoute('automation/analytics/overview', OverviewEndpoint::class);
});
}
}
@@ -0,0 +1,101 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\MailPoet\Analytics\Controller;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Automation;
use MailPoet\Automation\Engine\Storage\AutomationStorage;
use MailPoet\Automation\Integrations\MailPoet\Actions\SendEmailAction;
use MailPoet\Entities\NewsletterEntity;
use MailPoet\Newsletter\NewslettersRepository;
class AutomationTimeSpanController {
/** @var AutomationStorage */
private $automationStorage;
/** @var NewslettersRepository */
private $newslettersRepository;
public function __construct(
AutomationStorage $automationStorage,
NewslettersRepository $newslettersRepository
) {
$this->automationStorage = $automationStorage;
$this->newslettersRepository = $newslettersRepository;
}
public function getAutomationsInTimespan(Automation $automation, \DateTimeImmutable $after, \DateTimeImmutable $before): array {
$automationVersions = $this->automationStorage->getAutomationVersionDates($automation->getId());
usort(
$automationVersions,
function (array $a, array $b) {
return $a['created_at'] <=> $b['created_at'];
}
);
// Find all versions, which could have been active in the given time span
$versionIds = [];
foreach ($automationVersions as $automationVersion) {
if ($automationVersion['created_at'] > $before) {
// We are past the time span
break;
}
if (!$versionIds || $automationVersion['created_at'] <= $after) {
// This is the first version in the time span
$versionIds = [(int)$automationVersion['id']];
continue;
}
$versionIds[] = (int)$automationVersion['id'];
}
return count($versionIds) > 0 ? $this->automationStorage->getAutomationWithDifferentVersions($versionIds) : [];
}
/**
* @param Automation $automation
* @param \DateTimeImmutable $after
* @param \DateTimeImmutable $before
* @return NewsletterEntity[]
*/
public function getAutomationEmailsInTimeSpan(Automation $automation, \DateTimeImmutable $after, \DateTimeImmutable $before): array {
$automations = $this->getAutomationsInTimespan($automation, $after, $before);
return count($automations) > 0 ? $this->getEmailsFromAutomations($automations) : [];
}
/**
* @param Automation[] $automations
* @return NewsletterEntity[]
*/
public function getEmailsFromAutomations(array $automations): array {
$emailSteps = [];
foreach ($automations as $automation) {
$emailSteps = array_merge(
$emailSteps,
array_values(
array_filter(
$automation->getSteps(),
function($step) {
return $step->getKey() === SendEmailAction::KEY;
}
)
)
);
}
$emailIds = array_unique(
array_filter(
array_map(
function($step) {
$args = $step->getArgs();
return isset($args['email_id']) ? absint($args['email_id']) : null;
},
$emailSteps
)
)
);
return $this->newslettersRepository->findBy(['id' => $emailIds]);
}
}
@@ -0,0 +1,117 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\MailPoet\Analytics\Controller;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Automation;
use MailPoet\Automation\Integrations\MailPoet\Analytics\Entities\QueryWithCompare;
use MailPoet\Entities\StatisticsClickEntity;
use MailPoet\Entities\StatisticsOpenEntity;
use MailPoet\Newsletter\NewslettersRepository;
use MailPoet\Newsletter\Statistics\NewsletterStatisticsRepository;
use MailPoet\Newsletter\Statistics\WooCommerceRevenue;
use MailPoet\Newsletter\Url as NewsletterUrl;
class OverviewStatisticsController {
/** @var NewslettersRepository */
private $newslettersRepository;
/** @var NewsletterStatisticsRepository */
private $newsletterStatisticsRepository;
/** @var NewsletterUrl */
private $newsletterUrl;
/** @var AutomationTimeSpanController */
private $automationTimeSpanController;
public function __construct(
NewslettersRepository $newslettersRepository,
NewsletterStatisticsRepository $newsletterStatisticsRepository,
NewsletterUrl $newsletterUrl,
AutomationTimeSpanController $automationTimeSpanController
) {
$this->newslettersRepository = $newslettersRepository;
$this->newsletterStatisticsRepository = $newsletterStatisticsRepository;
$this->newsletterUrl = $newsletterUrl;
$this->automationTimeSpanController = $automationTimeSpanController;
}
public function getStatisticsForAutomation(Automation $automation, QueryWithCompare $query): array {
$currentEmails = $this->automationTimeSpanController->getAutomationEmailsInTimeSpan($automation, $query->getAfter(), $query->getBefore());
$previousEmails = $this->automationTimeSpanController->getAutomationEmailsInTimeSpan($automation, $query->getCompareWithAfter(), $query->getCompareWithBefore());
$data = [
'sent' => ['current' => 0, 'previous' => 0],
'opened' => ['current' => 0, 'previous' => 0],
'clicked' => ['current' => 0, 'previous' => 0],
'orders' => ['current' => 0, 'previous' => 0],
'unsubscribed' => ['current' => 0, 'previous' => 0],
'revenue' => ['current' => 0, 'previous' => 0],
'emails' => [],
];
if (!$currentEmails) {
return $data;
}
$requiredData = [
'totals',
StatisticsClickEntity::class,
StatisticsOpenEntity::class,
WooCommerceRevenue::class,
];
$currentStatistics = $this->newsletterStatisticsRepository->getBatchStatistics(
$currentEmails,
$query->getAfter(),
$query->getBefore(),
$requiredData
);
foreach ($currentStatistics as $newsletterId => $statistic) {
$data['sent']['current'] += $statistic->getTotalSentCount();
$data['opened']['current'] += $statistic->getOpenCount();
$data['clicked']['current'] += $statistic->getClickCount();
$data['unsubscribed']['current'] += $statistic->getUnsubscribeCount();
$data['orders']['current'] += $statistic->getWooCommerceRevenue() ? $statistic->getWooCommerceRevenue()->getOrdersCount() : 0;
$data['revenue']['current'] += $statistic->getWooCommerceRevenue() ? $statistic->getWooCommerceRevenue()->getValue() : 0;
$newsletter = $this->newslettersRepository->findOneById($newsletterId);
$data['emails'][$newsletterId]['id'] = $newsletterId;
$data['emails'][$newsletterId]['name'] = $newsletter ? $newsletter->getSubject() : '';
$data['emails'][$newsletterId]['sent']['current'] = $statistic->getTotalSentCount();
$data['emails'][$newsletterId]['sent']['previous'] = 0;
$data['emails'][$newsletterId]['opened'] = $statistic->getOpenCount();
$data['emails'][$newsletterId]['clicked'] = $statistic->getClickCount();
$data['emails'][$newsletterId]['unsubscribed'] = $statistic->getUnsubscribeCount();
$data['emails'][$newsletterId]['orders'] = $statistic->getWooCommerceRevenue() ? $statistic->getWooCommerceRevenue()->getOrdersCount() : 0;
$data['emails'][$newsletterId]['revenue'] = $statistic->getWooCommerceRevenue() ? $statistic->getWooCommerceRevenue()->getValue() : 0;
$data['emails'][$newsletterId]['previewUrl'] = $newsletter ? $this->newsletterUrl->getViewInBrowserUrl($newsletter) : '';
$data['emails'][$newsletterId]['order'] = count($data['emails']);
}
$previousStatistics = $this->newsletterStatisticsRepository->getBatchStatistics(
$previousEmails,
$query->getCompareWithAfter(),
$query->getCompareWithBefore(),
$requiredData
);
foreach ($previousStatistics as $newsletterId => $statistic) {
$data['sent']['previous'] += $statistic->getTotalSentCount();
$data['opened']['previous'] += $statistic->getOpenCount();
$data['clicked']['previous'] += $statistic->getClickCount();
$data['unsubscribed']['previous'] += $statistic->getUnsubscribeCount();
$data['orders']['previous'] += $statistic->getWooCommerceRevenue() ? $statistic->getWooCommerceRevenue()->getOrdersCount() : 0;
$data['revenue']['previous'] += $statistic->getWooCommerceRevenue() ? $statistic->getWooCommerceRevenue()->getValue() : 0;
if (isset($data['emails'][$newsletterId])) {
$data['emails'][$newsletterId]['sent']['previous'] = $statistic->getTotalSentCount();
}
}
usort($data['emails'], function ($a, $b) {
return $a['order'] <=> $b['order'];
});
return $data;
}
}
@@ -0,0 +1,94 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\MailPoet\Analytics\Controller;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Automation;
use MailPoet\Automation\Engine\Data\AutomationRun;
use MailPoet\Automation\Engine\Data\AutomationRunLog;
use MailPoet\Automation\Engine\Data\Step;
use MailPoet\Automation\Engine\Storage\AutomationRunLogStorage;
use MailPoet\Automation\Engine\Storage\AutomationRunStorage;
use MailPoet\Automation\Integrations\MailPoet\Analytics\Entities\Query;
class StepStatisticController {
/** @var AutomationRunStorage */
private $automationRunStorage;
/** @var AutomationRunLogStorage */
private $automationRunLogStorage;
public function __construct(
AutomationRunStorage $automationRunStorage,
AutomationRunLogStorage $automationRunLogStorage
) {
$this->automationRunStorage = $automationRunStorage;
$this->automationRunLogStorage = $automationRunLogStorage;
}
public function getWaitingStatistics(Automation $automation, Query $query): array {
$rawData = $this->automationRunStorage->getAutomationStepStatisticForTimeFrame(
$automation->getId(),
AutomationRun::STATUS_RUNNING,
$query->getAfter(),
$query->getBefore()
);
$data = [];
foreach ($automation->getSteps() as $step) {
foreach ($rawData as $rawDatum) {
if ($rawDatum['next_step_id'] === $step->getId()) {
$data[$step->getId()] = (int)$rawDatum['count'];
}
}
}
return $data;
}
public function getFailedStatistics(Automation $automation, Query $query): array {
$rawData = $this->automationRunStorage->getAutomationStepStatisticForTimeFrame(
$automation->getId(),
AutomationRun::STATUS_FAILED,
$query->getAfter(),
$query->getBefore()
);
$data = [];
foreach ($automation->getSteps() as $step) {
foreach ($rawData as $rawDatum) {
if ($rawDatum['next_step_id'] === $step->getId()) {
$data[$step->getId()] = (int)$rawDatum['count'];
}
}
}
return $data;
}
public function getCompletedStatistics(Automation $automation, Query $query): array {
$statistics = $this->automationRunLogStorage->getAutomationRunStatisticsForAutomationInTimeFrame(
$automation->getId(),
AutomationRunLog::STATUS_COMPLETE,
$query->getAfter(),
$query->getBefore()
);
$data = [];
foreach ($automation->getSteps() as $step) {
if ($step->getType() === Step::TYPE_ROOT) {
continue;
}
$data[$step->getId()] = 0;
foreach ($statistics as $stat) {
if ($stat['step_id'] === $step->getId()) {
$data[$step->getId()] = (int)$stat['count'];
}
}
}
return $data;
}
}
@@ -0,0 +1,124 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\MailPoet\Analytics\Endpoints;
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\Automation;
use MailPoet\Automation\Engine\Exceptions;
use MailPoet\Automation\Engine\Mappers\AutomationMapper;
use MailPoet\Automation\Engine\Storage\AutomationStatisticsStorage;
use MailPoet\Automation\Engine\Storage\AutomationStorage;
use MailPoet\Automation\Integrations\MailPoet\Analytics\Controller\AutomationTimeSpanController;
use MailPoet\Automation\Integrations\MailPoet\Analytics\Controller\StepStatisticController;
use MailPoet\Automation\Integrations\MailPoet\Analytics\Entities\Query;
use MailPoet\Validator\Builder;
class AutomationFlowEndpoint extends Endpoint {
/** @var AutomationStorage */
private $automationStorage;
/** @var AutomationStatisticsStorage */
private $automationStatisticsStorage;
/** @var AutomationMapper */
private $automationMapper;
/** @var AutomationTimeSpanController */
private $automationTimeSpanController;
/** @var StepStatisticController */
private $stepStatisticController;
public function __construct(
AutomationStorage $automationStorage,
AutomationStatisticsStorage $automationStatisticsStorage,
AutomationMapper $automationMapper,
AutomationTimeSpanController $automationTimeSpanController,
StepStatisticController $stepStatisticController
) {
$this->automationStorage = $automationStorage;
$this->automationStatisticsStorage = $automationStatisticsStorage;
$this->automationMapper = $automationMapper;
$this->automationTimeSpanController = $automationTimeSpanController;
$this->stepStatisticController = $stepStatisticController;
}
public function handle(Request $request): Response {
$id = absint(is_numeric($request->getParam('id')) ? $request->getParam('id') : 0);
$automation = $this->automationStorage->getAutomation($id);
if (!$automation) {
throw Exceptions::automationNotFound($id);
}
$query = Query::fromRequest($request);
$automations = $this->automationTimeSpanController->getAutomationsInTimespan($automation, $query->getAfter(), $query->getBefore());
if (!count($automations)) {
throw Exceptions::automationNotFoundInTimeSpan($id);
}
$automation = current($automations);
$shortStatistics = $this->automationStatisticsStorage->getAutomationStats(
$automation->getId(),
null,
$query->getAfter(),
$query->getBefore()
);
$waitingData = $this->stepStatisticController->getWaitingStatistics($automation, $query);
$failedData = $this->stepStatisticController->getFailedStatistics($automation, $query);
try {
$completedData = $this->stepStatisticController->getCompletedStatistics($automation, $query);
} catch (\Throwable $e) {
return new Response([$e->getMessage()], 500);
}
$stepData = [
'total' => $shortStatistics->getEntered(),
];
if ($waitingData) {
$stepData['waiting'] = $waitingData;
}
if ($failedData) {
$stepData['failed'] = $failedData;
}
if ($completedData) {
$stepData['completed'] = $completedData;
}
$data = [
'automation' => $this->automationMapper->buildAutomation($automation, $shortStatistics),
'step_data' => $stepData,
'tree_is_inconsistent' => !$this->isTreeConsistent(...$automations),
];
return new Response($data);
}
private function isTreeConsistent(Automation ...$automations): bool {
if (count($automations) === 1) {
return true;
}
$stepIds = array_map(function (Automation $automation) {
return array_keys($automation->getSteps());
}, $automations);
$compareTo = array_shift($stepIds);
if (!$compareTo) {
return true;
}
foreach ($stepIds as $stepId) {
if (count(array_diff($stepId, $compareTo)) !== 0) {
return false;
}
}
return true;
}
public static function getRequestSchema(): array {
return [
'id' => Builder::integer()->required(),
'query' => Query::getRequestSchema(),
];
}
}
@@ -0,0 +1,51 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\MailPoet\Analytics\Endpoints;
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\Storage\AutomationStorage;
use MailPoet\Automation\Integrations\MailPoet\Analytics\Controller\OverviewStatisticsController;
use MailPoet\Automation\Integrations\MailPoet\Analytics\Entities\QueryWithCompare;
use MailPoet\Validator\Builder;
class OverviewEndpoint extends Endpoint {
/** @var AutomationStorage */
private $automationStorage;
/** @var OverviewStatisticsController */
private $overviewStatisticsController;
public function __construct(
AutomationStorage $automationStorage,
OverviewStatisticsController $overviewStatisticsController
) {
$this->automationStorage = $automationStorage;
$this->overviewStatisticsController = $overviewStatisticsController;
}
public function handle(Request $request): Response {
$id = absint(is_numeric($request->getParam('id')) ? $request->getParam('id') : 0);
$automation = $this->automationStorage->getAutomation($id);
if (!$automation) {
throw Exceptions::automationNotFound($id);
}
$query = QueryWithCompare::fromRequest($request);
$result = $this->overviewStatisticsController->getStatisticsForAutomation($automation, $query);
return new Response($result);
}
public static function getRequestSchema(): array {
return [
'id' => Builder::integer()->required(),
'query' => QueryWithCompare::getRequestSchema(),
];
}
}
@@ -0,0 +1,151 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\MailPoet\Analytics\Entities;
if (!defined('ABSPATH')) exit;
use MailPoet\API\REST\Request;
use MailPoet\Automation\Engine\Exceptions\UnexpectedValueException;
use MailPoet\Validator\Builder;
use MailPoet\Validator\Schema;
class Query {
/** @var \DateTimeImmutable */
private $primaryAfter;
/** @var \DateTimeImmutable */
private $primaryBefore;
/** @var int */
private $limit;
/** @var string */
private $orderBy;
/** @var string */
private $orderDirection;
/** @var int */
private $page;
/** @var array */
private $filter;
/** @var string | null */
private $search;
public function __construct(
\DateTimeImmutable $primaryAfter,
\DateTimeImmutable $primaryBefore,
int $limit = 25,
string $orderBy = '',
string $orderDirection = 'asc',
int $page = 1,
array $filter = [],
string $search = null
) {
$this->primaryAfter = $primaryAfter;
$this->primaryBefore = $primaryBefore;
$this->limit = $limit;
$this->orderBy = $orderBy;
$this->orderDirection = $orderDirection;
$this->page = $page;
$this->filter = $filter;
$this->search = $search;
}
public function getAfter(): \DateTimeImmutable {
return $this->primaryAfter;
}
public function getBefore(): \DateTimeImmutable {
return $this->primaryBefore;
}
public function getLimit(): int {
return $this->limit;
}
public function getOrderBy(): string {
return $this->orderBy;
}
public function getOrderDirection(): string {
return $this->orderDirection;
}
public function getPage(): int {
return $this->page;
}
public function getFilter(): array {
return $this->filter;
}
public function getSearch(): ?string {
return $this->search;
}
/**
* @param Request $request
* @return Query
* @throws UnexpectedValueException
*/
public static function fromRequest(Request $request) {
$query = $request->getParam('query');
if (!is_array($query)) {
throw new UnexpectedValueException('Invalid query parameters');
}
$primary = $query['primary'] ?? null;
if (!is_array($primary)) {
throw new UnexpectedValueException('Invalid query parameters');
}
$primaryAfter = $primary['after'] ?? null;
$primaryBefore = $primary['before'] ?? null;
if (
!is_string($primaryAfter) ||
!is_string($primaryBefore)
) {
throw new UnexpectedValueException('Invalid query parameters');
}
$limit = $query['limit'] ?? 25;
$orderBy = $query['order_by'] ?? '';
$orderDirection = isset($query['order']) && strtolower($query['order']) === 'asc' ? 'asc' : 'desc';
$page = $query['page'] ?? 1;
$filter = $query['filter'] ?? [];
$search = $query['search'] ?? null;
return new self(
new \DateTimeImmutable($primaryAfter),
new \DateTimeImmutable($primaryBefore),
$limit,
$orderBy,
$orderDirection,
$page,
$filter,
$search
);
}
public static function getRequestSchema(): Schema {
return Builder::object(
[
'primary' => Builder::object(
[
'after' => Builder::string()->formatDateTime()->required(),
'before' => Builder::string()->formatDateTime()->required(),
]
),
'limit' => Builder::integer()->minimum(1)->maximum(100),
'order_by' => Builder::string(),
'order' => Builder::string(),
'page' => Builder::integer()->minimum(1),
'filter' => Builder::object([]),
'search' => Builder::string()->nullable(),
]
);
}
}
@@ -0,0 +1,116 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\MailPoet\Analytics\Entities;
if (!defined('ABSPATH')) exit;
use MailPoet\API\REST\Request;
use MailPoet\Automation\Engine\Exceptions\UnexpectedValueException;
use MailPoet\Validator\Builder;
use MailPoet\Validator\Schema;
class QueryWithCompare extends Query {
/** @var \DateTimeImmutable */
private $secondaryAfter;
/** @var \DateTimeImmutable */
private $secondaryBefore;
public function __construct(
\DateTimeImmutable $primaryAfter,
\DateTimeImmutable $primaryBefore,
\DateTimeImmutable $secondaryAfter,
\DateTimeImmutable $secondaryBefore,
int $limit = 25,
string $orderBy = '',
string $orderDirection = 'asc',
int $page = 0,
array $filter = [],
string $search = null
) {
parent::__construct($primaryAfter, $primaryBefore, $limit, $orderBy, $orderDirection, $page, $filter, $search);
$this->secondaryAfter = $secondaryAfter;
$this->secondaryBefore = $secondaryBefore;
}
public function getCompareWithAfter(): \DateTimeImmutable {
return $this->secondaryAfter;
}
public function getCompareWithBefore(): \DateTimeImmutable {
return $this->secondaryBefore;
}
/**
* @param Request $request
* @return QueryWithCompare
* @throws UnexpectedValueException
*/
public static function fromRequest(Request $request) {
$query = $request->getParam('query');
if (!is_array($query)) {
throw new UnexpectedValueException('Invalid query parameters');
}
$primary = $query['primary'] ?? null;
$secondary = $query['secondary'] ?? null;
if (!is_array($primary) || !is_array($secondary)) {
throw new UnexpectedValueException('Invalid query parameters');
}
$primaryAfter = $primary['after'] ?? null;
$primaryBefore = $primary['before'] ?? null;
$secondaryAfter = $secondary['after'] ?? null;
$secondaryBefore = $secondary['before'] ?? null;
if (
!is_string($primaryAfter) ||
!is_string($primaryBefore) ||
!is_string($secondaryAfter) ||
!is_string($secondaryBefore)
) {
throw new UnexpectedValueException('Invalid query parameters');
}
$limit = $query['limit'] ?? 25;
$orderBy = $query['orderBy'] ?? '';
$orderDirection = $query['orderDirection'] ?? 'asc';
$page = $query['page'] ?? 0;
return new self(
new \DateTimeImmutable($primaryAfter),
new \DateTimeImmutable($primaryBefore),
new \DateTimeImmutable($secondaryAfter),
new \DateTimeImmutable($secondaryBefore),
$limit,
$orderBy,
$orderDirection,
$page
);
}
public static function getRequestSchema(): Schema {
return Builder::object(
[
'primary' => Builder::object(
[
'after' => Builder::string()->formatDateTime()->required(),
'before' => Builder::string()->formatDateTime()->required(),
]
),
'secondary' => Builder::object(
[
'after' => Builder::string()->formatDateTime()->required(),
'before' => Builder::string()->formatDateTime()->required(),
]
),
'limit' => Builder::integer()->minimum(1)->maximum(100),
'orderBy' => Builder::string(),
'orderDirection' => Builder::string(),
'page' => Builder::integer()->minimum(1),
'filter' => Builder::object(),
'search' => Builder::string()->nullable(),
]
);
}
}
@@ -0,0 +1,91 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\MailPoet;
if (!defined('ABSPATH')) exit;
use MailPoet\Config\ServicesChecker;
use MailPoet\Segments\SegmentsRepository;
use MailPoet\Services\AuthorizedEmailsController;
use MailPoet\Services\AuthorizedSenderDomainController;
use MailPoet\Services\Bridge;
class ContextFactory {
/** @var SegmentsRepository */
private $segmentsRepository;
/** @var Bridge */
private $bridge;
/** @var ServicesChecker */
private $servicesChecker;
/** @var AuthorizedSenderDomainController */
private $authorizedSenderDomainController;
/** @var AuthorizedEmailsController */
private $authorizedEmailsController;
public function __construct(
SegmentsRepository $segmentsRepository,
Bridge $bridge,
ServicesChecker $servicesChecker,
AuthorizedSenderDomainController $authorizedSenderDomainController,
AuthorizedEmailsController $authorizedEmailsController
) {
$this->segmentsRepository = $segmentsRepository;
$this->servicesChecker = $servicesChecker;
$this->bridge = $bridge;
$this->authorizedSenderDomainController = $authorizedSenderDomainController;
$this->authorizedEmailsController = $authorizedEmailsController;
}
/** @return mixed[] */
public function getContextData(): array {
$data = [
'segments' => $this->getSegments(),
'userRoles' => $this->getUserRoles(),
];
if ($this->isMSSEnabled()) {
$data['senderDomainsConfig'] = $this->getSenderDomainsConfig();
}
return $data;
}
private function getSenderDomainsConfig(): array {
$senderDomainsConfig = $this->authorizedSenderDomainController->getContextDataForAutomations();
$senderDomainsConfig['authorizedEmails'] = $this->authorizedEmailsController->getAuthorizedEmailAddresses();
return $senderDomainsConfig;
}
private function isMSSEnabled(): bool {
$mpApiKeyValid = $this->servicesChecker->isMailPoetAPIKeyValid(false, true);
return $mpApiKeyValid && $this->bridge->isMailpoetSendingServiceEnabled();
}
private function getSegments(): array {
$segments = [];
foreach ($this->segmentsRepository->findAll() as $segment) {
$segments[] = [
'id' => $segment->getId(),
'name' => $segment->getName(),
'type' => $segment->getType(),
];
}
return $segments;
}
private function getUserRoles(): array {
$userRoles = [];
foreach (wp_roles()->roles as $role => $details) {
$userRoles[] = [
'id' => $role,
'name' => $details['name'],
];
}
return $userRoles;
}
}
@@ -0,0 +1,40 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\MailPoet\Fields;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Field;
use MailPoet\Automation\Integrations\MailPoet\Payloads\NewsletterLinkPayload;
class NewsletterLinkFieldsFactory {
public function getFields(): array {
return [
new Field(
'mailpoet:email-link:url',
Field::TYPE_STRING,
__('Link URL', 'mailpoet'),
function(NewsletterLinkPayload $payload) {
return $payload->getLink()->getUrl();
}
),
new Field(
'mailpoet:email-link:created',
Field::TYPE_DATETIME,
__('Created', 'mailpoet'),
function(NewsletterLinkPayload $payload) {
return $payload->getLink()->getCreatedAt();
}
),
new Field(
'mailpoet:email-link:id',
Field::TYPE_INTEGER,
__('Link ID', 'mailpoet'),
function(NewsletterLinkPayload $payload) {
return $payload->getLink()->getId();
}
),
];
}
}
@@ -0,0 +1,77 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\MailPoet\Fields;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Automation;
use MailPoet\Automation\Engine\Data\AutomationRun;
use MailPoet\Automation\Engine\Data\Field;
use MailPoet\Automation\Engine\Data\Subject;
use MailPoet\Automation\Engine\Storage\AutomationStorage;
use MailPoet\Automation\Integrations\MailPoet\Payloads\SubscriberPayload;
use MailPoet\Automation\Integrations\MailPoet\Subjects\SubscriberSubject;
class SubscriberAutomationFieldsFactory {
/** @var AutomationStorage */
private $automationStorage;
public function __construct(
AutomationStorage $automationStorage
) {
$this->automationStorage = $automationStorage;
}
/** @return Field[] */
public function getFields(): array {
$automations = $this->automationStorage->getAutomations(
array_diff(Automation::STATUS_ALL, [Automation::STATUS_TRASH])
);
$args = [
'options' => array_map(function (Automation $automation) {
return [
'id' => $automation->getId(),
'name' => $automation->getName() . " (#{$automation->getId()})",
];
}, $automations),
'params' => ['in_the_last'],
];
return [
new Field(
'mailpoet:subscriber:automations-entered',
Field::TYPE_ENUM_ARRAY,
__('Automations — entered', 'mailpoet'),
function (SubscriberPayload $payload, array $params = []) {
return $this->getAutomationIds($payload, null, $params);
},
$args
),
new Field(
'mailpoet:subscriber:automations-processing',
Field::TYPE_ENUM_ARRAY,
__('Automations — processing', 'mailpoet'),
function (SubscriberPayload $payload, array $params = []) {
return $this->getAutomationIds($payload, [AutomationRun::STATUS_RUNNING], $params);
},
$args
),
new Field(
'mailpoet:subscriber:automations-exited',
Field::TYPE_ENUM_ARRAY,
__('Automations — exited', 'mailpoet'),
function (SubscriberPayload $payload, array $params = []) {
return $this->getAutomationIds($payload, [AutomationRun::STATUS_COMPLETE], $params);
},
$args
),
];
}
private function getAutomationIds(SubscriberPayload $payload, array $status = null, array $params = []): array {
$inTheLastSeconds = isset($params['in_the_last']) ? (int)$params['in_the_last'] : null;
$subject = new Subject(SubscriberSubject::KEY, ['subscriber_id' => $payload->getId()]);
return $this->automationStorage->getAutomationIdsBySubject($subject, $status, $inTheLastSeconds);
}
}
@@ -0,0 +1,172 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\MailPoet\Fields;
if (!defined('ABSPATH')) exit;
use DateTimeImmutable;
use MailPoet\Automation\Engine\Data\Field;
use MailPoet\Automation\Engine\Exceptions\InvalidStateException;
use MailPoet\Automation\Engine\WordPress;
use MailPoet\Automation\Integrations\MailPoet\Payloads\SubscriberPayload;
use MailPoet\CustomFields\CustomFieldsRepository;
use MailPoet\Entities\CustomFieldEntity;
use MailPoet\Entities\SubscriberCustomFieldEntity;
use MailPoet\Util\DateConverter;
class SubscriberCustomFieldsFactory {
/** @var CustomFieldsRepository */
private $customFieldsRepository;
/** @var WordPress */
private $wordPress;
public function __construct(
CustomFieldsRepository $customFieldsRepository,
WordPress $wordPress
) {
$this->customFieldsRepository = $customFieldsRepository;
$this->wordPress = $wordPress;
}
/** @return Field[] */
public function getFields(): array {
return array_map(function (CustomFieldEntity $customField) {
return $this->getField($customField);
}, $this->customFieldsRepository->findAll());
}
private function getField(CustomFieldEntity $customField): Field {
switch ($customField->getType()) {
case CustomFieldEntity::TYPE_TEXT:
case CustomFieldEntity::TYPE_TEXTAREA:
$validate = $customField->getParams()['validate'] ?? null;
return $validate === 'number'
? $this->createNumberField($customField)
: $this->createStringField($customField);
case CustomFieldEntity::TYPE_CHECKBOX:
return $this->createBooleanField($customField);
case CustomFieldEntity::TYPE_RADIO:
case CustomFieldEntity::TYPE_SELECT:
return $this->createEnumField($customField);
case CustomFieldEntity::TYPE_DATE:
$type = $customField->getParams()['date_type'] ?? null;
if ($type === 'year_month_day' || $type === 'year_month') {
return $this->createDateTimeField($customField);
} elseif ($type === 'year') {
return $this->createYearField($customField);
} elseif ($type === 'month') {
return $this->createMonthField($customField);
} elseif ($type === 'day') {
return $this->createDayField($customField);
} else {
throw new InvalidStateException(sprintf('Unknown date type "%s"', $type));
}
default:
throw new InvalidStateException(sprintf('Unknown custom field type "%s"', $customField->getType()));
}
}
private function createStringField(CustomFieldEntity $customField): Field {
$factory = function (SubscriberPayload $payload) use ($customField) {
return $this->getCustomFieldValue($payload, $customField);
};
return $this->createField($customField, Field::TYPE_STRING, $factory);
}
private function createNumberField(CustomFieldEntity $customField): Field {
$factory = function (SubscriberPayload $payload) use ($customField) {
$value = $this->getCustomFieldValue($payload, $customField);
return is_numeric($value) ? (float)$value : null;
};
return $this->createField($customField, Field::TYPE_NUMBER, $factory);
}
private function createBooleanField(CustomFieldEntity $customField): Field {
$factory = function (SubscriberPayload $payload) use ($customField) {
$value = $this->getCustomFieldValue($payload, $customField);
return $value === null ? null : (bool)$value;
};
return $this->createField($customField, Field::TYPE_BOOLEAN, $factory);
}
private function createEnumField(CustomFieldEntity $customField): Field {
$factory = function (SubscriberPayload $payload) use ($customField) {
$value = $this->getCustomFieldValue($payload, $customField);
return $value === null ? null : $value;
};
return $this->createField($customField, Field::TYPE_ENUM, $factory, [
'options' => array_map(function (array $value) {
return ['id' => $value['value'], 'name' => $value['value']];
}, $customField->getParams()['values'] ?? []),
]);
}
private function createDateTimeField(CustomFieldEntity $customField): Field {
$factory = function (SubscriberPayload $payload) use ($customField) {
return $this->getDateTimeValue($customField, $this->getCustomFieldValue($payload, $customField) ?? '');
};
return $this->createField($customField, Field::TYPE_DATETIME, $factory);
}
private function createYearField(CustomFieldEntity $customField): Field {
$factory = function (SubscriberPayload $payload) use ($customField) {
$value = $this->getDateTimeValue($customField, $this->getCustomFieldValue($payload, $customField) ?? '');
return $value ? (int)$value->format('Y') : null;
};
return $this->createField($customField, Field::TYPE_INTEGER, $factory);
}
private function createMonthField(CustomFieldEntity $customField): Field {
$factory = function (SubscriberPayload $payload) use ($customField) {
$value = $this->getDateTimeValue($customField, $this->getCustomFieldValue($payload, $customField) ?? '');
return $value ? (int)$value->format('n') : null;
};
return $this->createField($customField, Field::TYPE_ENUM, $factory, [
'options' => array_map(function (int $value) {
return ['id' => $value, 'name' => $this->wordPress->getWpLocale()->get_month($value)];
}, range(1, 12)),
]);
}
private function createDayField(CustomFieldEntity $customField): Field {
$factory = function (SubscriberPayload $payload) use ($customField) {
$value = $this->getDateTimeValue($customField, $this->getCustomFieldValue($payload, $customField) ?? '');
return $value ? (int)$value->format('j') : null;
};
return $this->createField($customField, Field::TYPE_ENUM, $factory, [
'options' => array_map(function (int $value) {
return ['id' => $value, 'name' => "$value"];
}, range(1, 31)),
]);
}
private function getCustomFieldValue(SubscriberPayload $payload, CustomFieldEntity $customField): ?string {
$subscriberCustomField = $payload->getSubscriber()->getSubscriberCustomFields()->filter(
function (SubscriberCustomFieldEntity $subscriberCustomField = null) use ($customField) {
return $subscriberCustomField && $subscriberCustomField->getCustomField() === $customField;
}
)->first() ?: null;
return $subscriberCustomField ? $subscriberCustomField->getValue() : null;
}
private function createField(CustomFieldEntity $customField, string $type, callable $factory, array $args = []): Field {
$key = 'mailpoet:subscriber:custom-field:' . $customField->getName();
$name = sprintf(
// translators: %s is the name of the custom field
__('Custom field: %s', 'mailpoet'),
$customField->getParams()['label'] ?? $customField->getName()
);
return new Field($key, $type, $name, $factory, $args);
}
private function getDateTimeValue(CustomFieldEntity $customField, ?string $value): ?DateTimeImmutable {
$dateFormat = $customField->getParams()['date_format'] ?? null;
if (!$dateFormat || !$value) {
return null;
}
$dateString = (new DateConverter())->convertDateToDatetime($value, $dateFormat) ?: null;
return $dateString ? new DateTimeImmutable($dateString, $this->wordPress->wpTimezone()) : null;
}
}
@@ -0,0 +1,259 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\MailPoet\Fields;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Field;
use MailPoet\Automation\Integrations\MailPoet\Payloads\SubscriberPayload;
use MailPoet\Entities\SegmentEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Segments\SegmentsFinder;
use MailPoet\Segments\SegmentsRepository;
use MailPoet\Tags\TagRepository;
class SubscriberFieldsFactory {
/** @var SegmentsFinder */
private $segmentsFinder;
/** @var SegmentsRepository */
private $segmentsRepository;
/** @var SubscriberAutomationFieldsFactory */
private $automationFieldsFactory;
/** @var SubscriberCustomFieldsFactory */
private $customFieldsFactory;
/** @var TagRepository */
private $tagRepository;
/** @var SubscriberStatisticFieldsFactory */
private $statisticFieldsFactory;
public function __construct(
SegmentsFinder $segmentsFinder,
SegmentsRepository $segmentsRepository,
SubscriberAutomationFieldsFactory $automationFieldsFactory,
SubscriberCustomFieldsFactory $customFieldsFactory,
SubscriberStatisticFieldsFactory $statisticFieldsFactory,
TagRepository $tagRepository
) {
$this->segmentsFinder = $segmentsFinder;
$this->segmentsRepository = $segmentsRepository;
$this->automationFieldsFactory = $automationFieldsFactory;
$this->customFieldsFactory = $customFieldsFactory;
$this->statisticFieldsFactory = $statisticFieldsFactory;
$this->tagRepository = $tagRepository;
}
/** @return Field[] */
public function getFields(): array {
return array_merge(
$this->customFieldsFactory->getFields(),
[
new Field(
'mailpoet:subscriber:email',
Field::TYPE_STRING,
__('Email address', 'mailpoet'),
function (SubscriberPayload $payload) {
return $payload->getEmail();
}
),
new Field(
'mailpoet:subscriber:engagement-score',
Field::TYPE_NUMBER,
__('Engagement score', 'mailpoet'),
function (SubscriberPayload $payload) {
return $payload->getSubscriber()->getEngagementScore();
}
),
new Field(
'mailpoet:subscriber:first-name',
Field::TYPE_STRING,
__('First name', 'mailpoet'),
function (SubscriberPayload $payload) {
return $payload->getSubscriber()->getFirstName();
}
),
new Field(
'mailpoet:subscriber:last-name',
Field::TYPE_STRING,
__('Last name', 'mailpoet'),
function (SubscriberPayload $payload) {
return $payload->getSubscriber()->getLastName();
}
),
new Field(
'mailpoet:subscriber:is-globally-subscribed',
Field::TYPE_BOOLEAN,
__('Is globally subscribed', 'mailpoet'),
function (SubscriberPayload $payload) {
return $payload->getStatus() === SubscriberEntity::STATUS_SUBSCRIBED;
}
),
new Field(
'mailpoet:subscriber:last-engagement-at',
Field::TYPE_DATETIME,
__('Last engaged', 'mailpoet'),
function (SubscriberPayload $payload) {
return $payload->getSubscriber()->getLastEngagementAt();
}
),
new Field(
'mailpoet:subscriber:status',
Field::TYPE_ENUM,
__('Status', 'mailpoet'),
function (SubscriberPayload $payload) {
return $payload->getStatus();
},
[
'options' => [
[
'id' => SubscriberEntity::STATUS_SUBSCRIBED,
'name' => __('Subscribed', 'mailpoet'),
],
[
'id' => SubscriberEntity::STATUS_UNCONFIRMED,
'name' => __('Unconfirmed', 'mailpoet'),
],
[
'id' => SubscriberEntity::STATUS_UNSUBSCRIBED,
'name' => __('Unsubscribed', 'mailpoet'),
],
[
'id' => SubscriberEntity::STATUS_INACTIVE,
'name' => __('Inactive', 'mailpoet'),
],
[
'id' => SubscriberEntity::STATUS_BOUNCED,
'name' => __('Bounced', 'mailpoet'),
],
],
]
),
new Field(
'mailpoet:subscriber:subscription-source',
Field::TYPE_ENUM,
__('Subscription source', 'mailpoet'),
function (SubscriberPayload $payload) {
return $payload->getSubscriber()->getSource();
},
[
'options' => [
[
'id' => 'api',
'name' => __('API', 'mailpoet'),
],
[
'id' => 'form',
'name' => __('Form', 'mailpoet'),
],
[
'id' => 'unknown',
'name' => __('Unknown', 'mailpoet'),
],
[
'id' => 'imported',
'name' => __('Imported', 'mailpoet'),
],
[
'id' => 'administrator',
'name' => __('Administrator', 'mailpoet'),
],
[
'id' => 'wordpress_user',
'name' => __('WordPress user', 'mailpoet'),
],
[
'id' => 'woocommerce_user',
'name' => __('WooCommerce user', 'mailpoet'),
],
[
'id' => 'woocommerce_checkout',
'name' => __('WooCommerce checkout', 'mailpoet'),
],
],
]
),
new Field(
'mailpoet:subscriber:last-subscribed-at',
Field::TYPE_DATETIME,
__('Subscribed date', 'mailpoet'),
function (SubscriberPayload $payload) {
return $payload->getSubscriber()->getLastSubscribedAt();
}
),
new Field(
'mailpoet:subscriber:lists',
Field::TYPE_ENUM_ARRAY,
__('Subscribed lists', 'mailpoet'),
function (SubscriberPayload $payload) {
$value = [];
foreach ($payload->getSubscriber()->getSegments() as $list) {
if ($list->getType() !== SegmentEntity::TYPE_DYNAMIC) {
$value[] = $list->getId();
}
}
return $value;
},
[
'options' => array_map(function ($segment) {
return [
'id' => $segment->getId(),
'name' => $segment->getName(),
];
}, $this->segmentsRepository->findByTypeNotIn([SegmentEntity::TYPE_DYNAMIC])),
]
),
new Field(
'mailpoet:subscriber:tags',
Field::TYPE_ENUM_ARRAY,
__('Tags', 'mailpoet'),
function (SubscriberPayload $payload) {
$value = [];
foreach ($payload->getSubscriber()->getSubscriberTags() as $subscriberTag) {
$tag = $subscriberTag->getTag();
if ($tag) {
$value[] = $tag->getId();
}
}
return $value;
},
[
'options' => array_map(function ($tag) {
return [
'id' => $tag->getId(),
'name' => $tag->getName(),
];
}, $this->tagRepository->findAll()),
]
),
new Field(
'mailpoet:subscriber:segments',
Field::TYPE_ENUM_ARRAY,
__('Segments', 'mailpoet'),
function (SubscriberPayload $payload) {
$segments = $this->segmentsFinder->findDynamicSegments($payload->getSubscriber());
$value = [];
foreach ($segments as $segment) {
$value[] = $segment->getId();
}
return $value;
},
[
'options' => array_map(function ($segment) {
return [
'id' => $segment->getId(),
'name' => $segment->getName(),
];
}, $this->segmentsRepository->findBy(['type' => SegmentEntity::TYPE_DYNAMIC])),
]
),
],
$this->statisticFieldsFactory->getFields(),
$this->automationFieldsFactory->getFields()
);
}
}
@@ -0,0 +1,81 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\MailPoet\Fields;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Field;
use MailPoet\Automation\Integrations\MailPoet\Payloads\SubscriberPayload;
use MailPoet\Subscribers\Statistics\SubscriberStatisticsRepository;
use MailPoetVendor\Carbon\Carbon;
class SubscriberStatisticFieldsFactory {
/** @var SubscriberStatisticsRepository */
private $subscriberStatisticsRepository;
public function __construct(
SubscriberStatisticsRepository $subscriberStatisticsRepository
) {
$this->subscriberStatisticsRepository = $subscriberStatisticsRepository;
}
/** @return Field[] */
public function getFields(): array {
return [
new Field(
'mailpoet:subscriber:email-sent-count',
Field::TYPE_INTEGER,
__('Email — sent count', 'mailpoet'),
function (SubscriberPayload $payload, array $params = []) {
$startTime = $this->getStartTime($params);
return $this->subscriberStatisticsRepository->getTotalSentCount($payload->getSubscriber(), $startTime);
},
[
'params' => ['in_the_last'],
]
),
new Field(
'mailpoet:subscriber:email-opened-count',
Field::TYPE_INTEGER,
__('Email — opened count', 'mailpoet'),
function (SubscriberPayload $payload, array $params = []) {
$startTime = $this->getStartTime($params);
return $this->subscriberStatisticsRepository->getStatisticsOpenCount($payload->getSubscriber(), $startTime);
},
[
'params' => ['in_the_last'],
]
),
new Field(
'mailpoet:subscriber:email-machine-opened-count',
Field::TYPE_INTEGER,
__('Email — machine opened count', 'mailpoet'),
function (SubscriberPayload $payload, array $params = []) {
$startTime = $this->getStartTime($params);
return $this->subscriberStatisticsRepository->getStatisticsMachineOpenCount($payload->getSubscriber(), $startTime);
},
[
'params' => ['in_the_last'],
]
),
new Field(
'mailpoet:subscriber:email-clicked-count',
Field::TYPE_INTEGER,
__('Email — clicked count', 'mailpoet'),
function (SubscriberPayload $payload, array $params = []) {
$startTime = $this->getStartTime($params);
return $this->subscriberStatisticsRepository->getStatisticsClickCount($payload->getSubscriber(), $startTime);
},
[
'params' => ['in_the_last'],
]
),
];
}
private function getStartTime(array $params): ?Carbon {
$inTheLastSeconds = $params['in_the_last'] ?? null;
return $inTheLastSeconds ? Carbon::now()->subSeconds((int)$inTheLastSeconds) : null;
}
}
@@ -0,0 +1,94 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\MailPoet\Hooks;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Automation;
use MailPoet\Automation\Engine\Data\Step;
use MailPoet\Automation\Engine\Hooks;
use MailPoet\Automation\Engine\Storage\AutomationStorage;
use MailPoet\Automation\Engine\WordPress;
use MailPoet\Newsletter\NewsletterDeleteController;
use MailPoet\Newsletter\NewslettersRepository;
class AutomationEditorLoadingHooks {
/** @var WordPress */
private $wp;
/** @var AutomationStorage */
private $automationStorage;
/** @var NewslettersRepository */
private $newslettersRepository;
private NewsletterDeleteController $newsletterDeleteController;
public function __construct(
WordPress $wp,
AutomationStorage $automationStorage,
NewslettersRepository $newslettersRepository,
NewsletterDeleteController $newsletterDeleteController
) {
$this->wp = $wp;
$this->automationStorage = $automationStorage;
$this->newslettersRepository = $newslettersRepository;
$this->newsletterDeleteController = $newsletterDeleteController;
}
public function init(): void {
$this->wp->addAction(Hooks::EDITOR_BEFORE_LOAD, [$this, 'beforeEditorLoad']);
}
public function beforeEditorLoad(int $automationId): void {
$automation = $this->automationStorage->getAutomation($automationId);
if (!$automation) {
return;
}
$this->disconnectEmptyEmailsFromSendEmailStep($automation);
}
private function disconnectEmptyEmailsFromSendEmailStep(Automation $automation): void {
$sendEmailSteps = array_filter(
$automation->getSteps(),
function(Step $step): bool {
return $step->getKey() === 'mailpoet:send-email';
}
);
foreach ($sendEmailSteps as $step) {
$emailId = $step->getArgs()['email_id'] ?? 0;
if (!$emailId) {
continue;
}
$newsletterEntity = $this->newslettersRepository->findOneById($emailId);
if ($newsletterEntity && $newsletterEntity->getBody() !== null) {
continue;
}
$this->newsletterDeleteController->bulkDelete([$emailId]);
$args = $step->getArgs();
unset($args['email_id']);
$updatedStep = new Step(
$step->getId(),
$step->getType(),
$step->getKey(),
$args,
$step->getNextSteps()
);
$steps = array_merge(
$automation->getSteps(),
[$updatedStep->getId() => $updatedStep]
);
$automation->setSteps($steps);
//To be valid, an email would need to be associated to an active automation.
if ($automation->getStatus() === Automation::STATUS_ACTIVE) {
$automation->setStatus(Automation::STATUS_DRAFT);
}
$this->automationStorage->updateAutomation($automation);
}
}
}
@@ -0,0 +1,67 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\MailPoet\Hooks;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\StepRunArgs;
use MailPoet\Automation\Engine\Hooks;
use MailPoet\Automation\Engine\Storage\AutomationRunStorage;
use MailPoet\Automation\Integrations\MailPoet\Subjects\SubscriberSubject;
use MailPoet\Util\Security;
use MailPoet\WP\Functions as WPFunctions;
class CreateAutomationRunHook {
private AutomationRunStorage $automationRunStorage;
private WPFunctions $wp;
public function __construct(
AutomationRunStorage $automationRunStorage,
WPFunctions $wp
) {
$this->automationRunStorage = $automationRunStorage;
$this->wp = $wp;
}
public function init(): void {
$this->wp->addAction(Hooks::AUTOMATION_RUN_CREATE, [$this, 'createAutomationRun'], 5, 2);
}
public function createAutomationRun(bool $result, StepRunArgs $args): bool {
if (!$result) {
return $result;
}
$automation = $args->getAutomation();
$runOnlyOnce = $automation->getMeta('mailpoet:run-once-per-subscriber');
if (!$runOnlyOnce) {
return true;
}
$subscriberSubject = array_values($args->getAutomationRun()->getSubjects(SubscriberSubject::KEY))[0] ?? null;
if (!$subscriberSubject) {
return true;
}
// Use locking mechanism to minimize the risk of race conditions.
// WP transients don't provide atomic operations, so we can't guarantee
// race-condition safety with a 100% certainty, but we can significantly
// minimize the risk by generating and re-checking a unique lock value.
$key = sprintf('mailpoet:run-once-per-subscriber:[%s][%s]', $automation->getId(), $subscriberSubject->getHash());
// 1. If lock already exists, do not create automation run.
$value = $this->wp->getTransient($key);
if ($value) {
return false;
}
// 2. If lock does not exist, create it with a unique value.
$value = Security::generateRandomString(16);
$this->wp->setTransient($key, $value, MINUTE_IN_SECONDS);
// 3. If no automation run exist, ensure that the lock wasn't updated by another process.
$count = $this->automationRunStorage->getCountByAutomationAndSubject($automation, $subscriberSubject);
return $count === 0 && $this->wp->getTransient($key) === $value;
}
}
@@ -0,0 +1,145 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\MailPoet;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Integration;
use MailPoet\Automation\Engine\Registry;
use MailPoet\Automation\Engine\WordPress;
use MailPoet\Automation\Integrations\MailPoet\Actions\SendEmailAction;
use MailPoet\Automation\Integrations\MailPoet\Analytics\Analytics;
use MailPoet\Automation\Integrations\MailPoet\Hooks\AutomationEditorLoadingHooks;
use MailPoet\Automation\Integrations\MailPoet\Hooks\CreateAutomationRunHook;
use MailPoet\Automation\Integrations\MailPoet\Subjects\NewsletterLinkSubject;
use MailPoet\Automation\Integrations\MailPoet\Subjects\SegmentSubject;
use MailPoet\Automation\Integrations\MailPoet\Subjects\SubscriberSubject;
use MailPoet\Automation\Integrations\MailPoet\SubjectTransformers\CommentSubjectToSubscriberSubjectTransformer;
use MailPoet\Automation\Integrations\MailPoet\SubjectTransformers\OrderSubjectToSegmentSubjectTransformer;
use MailPoet\Automation\Integrations\MailPoet\SubjectTransformers\OrderSubjectToSubscriberSubjectTransformer;
use MailPoet\Automation\Integrations\MailPoet\SubjectTransformers\SubscriberSubjectToWordPressUserSubjectTransformer;
use MailPoet\Automation\Integrations\MailPoet\Templates\TemplatesFactory;
use MailPoet\Automation\Integrations\MailPoet\Triggers\SomeoneSubscribesTrigger;
use MailPoet\Automation\Integrations\MailPoet\Triggers\UserRegistrationTrigger;
class MailPoetIntegration implements Integration {
/** @var ContextFactory */
private $contextFactory;
/** @var SegmentSubject */
private $segmentSubject;
/** @var SubscriberSubject */
private $subscriberSubject;
/** @var NewsletterLinkSubject */
private $emailLinkSubject;
/** @var SomeoneSubscribesTrigger */
private $someoneSubscribesTrigger;
/** @var UserRegistrationTrigger */
private $userRegistrationTrigger;
/** @var SendEmailAction */
private $sendEmailAction;
/** @var AutomationEditorLoadingHooks */
private $automationEditorLoadingHooks;
/** @var CreateAutomationRunHook */
private $createAutomationRunHook;
/** @var OrderSubjectToSubscriberSubjectTransformer */
private $orderToSubscriberTransformer;
/** @var OrderSubjectToSegmentSubjectTransformer */
private $orderToSegmentTransformer;
/** @var SubscriberSubjectToWordPressUserSubjectTransformer */
private $subscriberToWordPressUserTransformer;
/** @var CommentSubjectToSubscriberSubjectTransformer */
private $commentToSubscriberTransformer;
/** @var TemplatesFactory */
private $templatesFactory;
/** @var Analytics */
private $registerAnalytics;
/** @var WordPress */
private $wordPress;
public function __construct(
ContextFactory $contextFactory,
SegmentSubject $segmentSubject,
SubscriberSubject $subscriberSubject,
NewsletterLinkSubject $emailLinkSubject,
OrderSubjectToSubscriberSubjectTransformer $orderToSubscriberTransformer,
OrderSubjectToSegmentSubjectTransformer $orderToSegmentTransformer,
SubscriberSubjectToWordPressUserSubjectTransformer $subscriberToWordPressUserTransformer,
CommentSubjectToSubscriberSubjectTransformer $commentToSubscriberTransformer,
SomeoneSubscribesTrigger $someoneSubscribesTrigger,
UserRegistrationTrigger $userRegistrationTrigger,
SendEmailAction $sendEmailAction,
AutomationEditorLoadingHooks $automationEditorLoadingHooks,
CreateAutomationRunHook $createAutomationRunHook,
TemplatesFactory $templatesFactory,
Analytics $registerAnalytics,
WordPress $wordPress
) {
$this->contextFactory = $contextFactory;
$this->segmentSubject = $segmentSubject;
$this->subscriberSubject = $subscriberSubject;
$this->emailLinkSubject = $emailLinkSubject;
$this->orderToSubscriberTransformer = $orderToSubscriberTransformer;
$this->orderToSegmentTransformer = $orderToSegmentTransformer;
$this->subscriberToWordPressUserTransformer = $subscriberToWordPressUserTransformer;
$this->commentToSubscriberTransformer = $commentToSubscriberTransformer;
$this->someoneSubscribesTrigger = $someoneSubscribesTrigger;
$this->userRegistrationTrigger = $userRegistrationTrigger;
$this->sendEmailAction = $sendEmailAction;
$this->automationEditorLoadingHooks = $automationEditorLoadingHooks;
$this->createAutomationRunHook = $createAutomationRunHook;
$this->templatesFactory = $templatesFactory;
$this->registerAnalytics = $registerAnalytics;
$this->wordPress = $wordPress;
}
public function register(Registry $registry): void {
$registry->addContextFactory('mailpoet', function () {
return $this->contextFactory->getContextData();
});
$registry->addSubject($this->segmentSubject);
$registry->addSubject($this->subscriberSubject);
$registry->addSubject($this->emailLinkSubject);
$registry->addTrigger($this->someoneSubscribesTrigger);
$registry->addTrigger($this->userRegistrationTrigger);
$registry->addAction($this->sendEmailAction);
$registry->addSubjectTransformer($this->orderToSubscriberTransformer);
$registry->addSubjectTransformer($this->orderToSegmentTransformer);
$registry->addSubjectTransformer($this->subscriberToWordPressUserTransformer);
$registry->addSubjectTransformer($this->commentToSubscriberTransformer);
foreach ($this->templatesFactory->createTemplates() as $template) {
$registry->addTemplate($template);
}
// sync step args (subject, preheader, etc.) to email settings
$registry->onBeforeAutomationStepSave(
[$this->sendEmailAction, 'saveEmailSettings'],
$this->sendEmailAction->getKey()
);
// execute send email step progress when email is sent
$this->wordPress->addAction('mailpoet_automation_email_sent', [$this->sendEmailAction, 'handleEmailSent']);
$this->automationEditorLoadingHooks->init();
$this->createAutomationRunHook->init();
$this->registerAnalytics->register();
}
}
@@ -0,0 +1,30 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\MailPoet\Payloads;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Integration\Payload;
use MailPoet\Entities\NewsletterLinkEntity;
class NewsletterLinkPayload implements Payload {
/** @var NewsletterLinkEntity */
private $linkEntity;
public function __construct(
NewsletterLinkEntity $linkEntity
) {
$this->linkEntity = $linkEntity;
}
public function getId(): ?int {
return $this->linkEntity->getId();
}
public function getLink(): NewsletterLinkEntity {
return $this->linkEntity;
}
}
@@ -0,0 +1,37 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\MailPoet\Payloads;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Integration\Payload;
use MailPoet\Entities\SegmentEntity;
use MailPoet\InvalidStateException;
class SegmentPayload implements Payload {
/** @var SegmentEntity */
private $segment;
public function __construct(
SegmentEntity $segment
) {
$this->segment = $segment;
}
public function getId(): int {
$id = $this->segment->getId();
if (!$id) {
throw new InvalidStateException();
}
return $id;
}
public function getName(): string {
return $this->segment->getName();
}
public function getType(): string {
return $this->segment->getType();
}
}
@@ -0,0 +1,49 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\MailPoet\Payloads;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Integration\Payload;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\InvalidStateException;
class SubscriberPayload implements Payload {
/** @var SubscriberEntity */
private $subscriber;
public function __construct(
SubscriberEntity $subscriber
) {
$this->subscriber = $subscriber;
}
public function getId(): int {
$id = $this->subscriber->getId();
if (!$id) {
throw new InvalidStateException();
}
return $id;
}
public function getEmail(): string {
return $this->subscriber->getEmail();
}
public function getStatus(): string {
return $this->subscriber->getStatus();
}
public function isWpUser(): bool {
return $this->subscriber->isWPUser();
}
public function getWpUserId(): ?int {
return $this->subscriber->getWpUserId();
}
public function getSubscriber(): SubscriberEntity {
return $this->subscriber;
}
}
@@ -0,0 +1,67 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\MailPoet\SubjectTransformers;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Subject;
use MailPoet\Automation\Engine\Integration\SubjectTransformer;
use MailPoet\Automation\Engine\WordPress;
use MailPoet\Automation\Integrations\MailPoet\Subjects\SubscriberSubject;
use MailPoet\Automation\Integrations\WordPress\Subjects\CommentSubject;
use MailPoet\Subscribers\SubscribersRepository;
class CommentSubjectToSubscriberSubjectTransformer implements SubjectTransformer {
/** @var WordPress */
private $wp;
/** @var SubscribersRepository */
private $subscribersRepository;
public function __construct(
WordPress $wp,
SubscribersRepository $subscribersRepository
) {
$this->wp = $wp;
$this->subscribersRepository = $subscribersRepository;
}
public function transform(Subject $data): ?Subject {
if ($this->accepts() !== $data->getKey()) {
throw new \InvalidArgumentException('Invalid subject type');
}
$commentId = (int)$data->getArgs()['comment_id'];
$comment = $this->wp->getComment($commentId);
if (!$comment instanceof \WP_Comment) {
return null;
}
//phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
$email = $comment->comment_author_email;
if (!$this->wp->isEmail($email)) {
return null;
}
$subscriber = $this->subscribersRepository->findOneBy(['email' => $email]);
if (!$subscriber) {
return null;
}
return new Subject(
SubscriberSubject::KEY,
[
'subscriber_id' => $subscriber->getId(),
]
);
}
public function returns(): string {
return SubscriberSubject::KEY;
}
public function accepts(): string {
return CommentSubject::KEY;
}
}
@@ -0,0 +1,42 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\MailPoet\SubjectTransformers;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Subject;
use MailPoet\Automation\Engine\Integration\SubjectTransformer;
use MailPoet\Automation\Integrations\MailPoet\Subjects\SegmentSubject;
use MailPoet\Automation\Integrations\WooCommerce\Subjects\OrderSubject;
use MailPoet\Segments\SegmentsRepository;
class OrderSubjectToSegmentSubjectTransformer implements SubjectTransformer {
/** @var SegmentsRepository */
private $segmentRepository;
public function __construct(
SegmentsRepository $segmentRepository
) {
$this->segmentRepository = $segmentRepository;
}
public function accepts(): string {
return OrderSubject::KEY;
}
public function returns(): string {
return SegmentSubject::KEY;
}
public function transform(Subject $data): Subject {
if ($this->accepts() !== $data->getKey()) {
throw new \InvalidArgumentException('Invalid subject type');
}
$wooCommerceSegment = $this->segmentRepository->getWooCommerceSegment();
return new Subject(SegmentSubject::KEY, ['segment_id' => $wooCommerceSegment->getId()]);
}
}
@@ -0,0 +1,88 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\MailPoet\SubjectTransformers;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Subject;
use MailPoet\Automation\Engine\Integration\SubjectTransformer;
use MailPoet\Automation\Integrations\MailPoet\Subjects\SubscriberSubject;
use MailPoet\Automation\Integrations\WooCommerce\Subjects\OrderSubject;
use MailPoet\Automation\Integrations\WooCommerce\WooCommerce;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Segments;
use MailPoet\Subscribers\SubscribersRepository;
class OrderSubjectToSubscriberSubjectTransformer implements SubjectTransformer {
/** @var SubscribersRepository */
private $subscribersRepository;
/** @var Segments\WooCommerce */
private $woocommerce;
/** @var WooCommerce */
private $woocommerceHelper;
public function __construct(
SubscribersRepository $subscribersRepository,
Segments\WooCommerce $woocommerce,
WooCommerce $woocommerceHelper
) {
$this->subscribersRepository = $subscribersRepository;
$this->woocommerce = $woocommerce;
$this->woocommerceHelper = $woocommerceHelper;
}
public function transform(Subject $data): Subject {
if ($this->accepts() !== $data->getKey()) {
throw new \InvalidArgumentException('Invalid subject type');
}
$subscriber = $this->findOrCreateSubscriber($data);
if (!$subscriber instanceof SubscriberEntity) {
throw new \InvalidArgumentException('Subscriber not found');
}
return new Subject(SubscriberSubject::KEY, ['subscriber_id' => $subscriber->getId()]);
}
public function accepts(): string {
return OrderSubject::KEY;
}
public function returns(): string {
return SubscriberSubject::KEY;
}
private function findOrCreateSubscriber(Subject $order): ?SubscriberEntity {
$subscriber = $this->findSubscriber($order);
if ($subscriber) {
return $subscriber;
}
$orderId = $order->getArgs()['order_id'] ?? null;
if (!$orderId) {
return null;
}
$this->woocommerce->synchronizeGuestCustomer($orderId);
return $this->findSubscriber($order);
}
private function findSubscriber(Subject $order): ?SubscriberEntity {
$orderId = $order->getArgs()['order_id'] ?? null;
if (!$orderId) {
return null;
}
$wcOrder = $this->woocommerceHelper->wcGetOrder($orderId);
if (!$wcOrder instanceof \WC_Order) {
return null;
}
$billingEmail = $wcOrder->get_billing_email();
return $billingEmail ?
$this->subscribersRepository->findOneBy(['email' => $billingEmail]) :
$this->subscribersRepository->findOneBy(['wpUserId' => $wcOrder->get_user_id()]);
}
}
@@ -0,0 +1,43 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\MailPoet\SubjectTransformers;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Subject;
use MailPoet\Automation\Engine\Integration\SubjectTransformer;
use MailPoet\Automation\Integrations\MailPoet\Subjects\SubscriberSubject;
use MailPoet\Automation\Integrations\WordPress\Subjects\UserSubject;
use MailPoet\Subscribers\SubscribersRepository;
class SubscriberSubjectToWordPressUserSubjectTransformer implements SubjectTransformer {
/** @var SubscribersRepository */
private $subscribersRepository;
public function __construct(
SubscribersRepository $subscribersRepository
) {
$this->subscribersRepository = $subscribersRepository;
}
public function accepts(): string {
return SubscriberSubject::KEY;
}
public function returns(): string {
return UserSubject::KEY;
}
public function transform(Subject $data): Subject {
if ($this->accepts() !== $data->getKey()) {
throw new \InvalidArgumentException('Invalid subject type');
}
$subscriber = $this->subscribersRepository->findOneById((int)$data->getArgs()['subscriber_id']);
if (!$subscriber) {
throw new \InvalidArgumentException('Subscriber not found');
}
return new Subject(UserSubject::KEY, ['user_id' => $subscriber->getWpUserId()]);
}
}
@@ -0,0 +1,68 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\MailPoet\Subjects;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Subject as SubjectData;
use MailPoet\Automation\Engine\Integration\Payload;
use MailPoet\Automation\Engine\Integration\Subject;
use MailPoet\Automation\Integrations\MailPoet\Fields\NewsletterLinkFieldsFactory;
use MailPoet\Automation\Integrations\MailPoet\Payloads\NewsletterLinkPayload;
use MailPoet\Cron\Workers\StatsNotifications\NewsletterLinkRepository;
use MailPoet\NotFoundException;
use MailPoet\Validator\Builder;
use MailPoet\Validator\Schema\ObjectSchema;
/**
* @implements Subject<NewsletterLinkPayload>
*/
class NewsletterLinkSubject implements Subject {
const KEY = 'mailpoet:email-link';
/** @var NewsletterLinkFieldsFactory */
private $emailLinkFieldsFactory;
/** @var NewsletterLinkRepository */
private $newsletterLinkRepository;
public function __construct(
NewsletterLinkFieldsFactory $emailLinkFieldsFactory,
NewsletterLinkRepository $newsletterLinkRepository
) {
$this->emailLinkFieldsFactory = $emailLinkFieldsFactory;
$this->newsletterLinkRepository = $newsletterLinkRepository;
}
public function getKey(): string {
return self::KEY;
}
public function getName(): string {
// translators: automation subject (entity entering automation) title
return __('Email link', 'mailpoet');
}
public function getArgsSchema(): ObjectSchema {
return Builder::object([
'link_id' => Builder::integer()->minimum(1)->required(),
]);
}
public function getFields(): array {
return $this->emailLinkFieldsFactory->getFields();
}
public function getPayload(SubjectData $subjectData): Payload {
$linkId = $subjectData->getArgs()['link_id'];
$linkEntity = $this->newsletterLinkRepository->findOneById($linkId);
if (!$linkEntity) {
throw NotFoundException::create()->withMessage(sprintf("Email link with ID '%d' not found", $linkId));
}
return new NewsletterLinkPayload($linkEntity);
}
}
@@ -0,0 +1,82 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\MailPoet\Subjects;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Field;
use MailPoet\Automation\Engine\Data\Subject as SubjectData;
use MailPoet\Automation\Engine\Integration\Payload;
use MailPoet\Automation\Engine\Integration\Subject;
use MailPoet\Automation\Integrations\MailPoet\Payloads\SegmentPayload;
use MailPoet\NotFoundException;
use MailPoet\Segments\SegmentsRepository;
use MailPoet\Validator\Builder;
use MailPoet\Validator\Schema\ObjectSchema;
/**
* @implements Subject<SegmentPayload>
*/
class SegmentSubject implements Subject {
const KEY = 'mailpoet:segment';
/** @var SegmentsRepository */
private $segmentsRepository;
public function __construct(
SegmentsRepository $segmentsRepository
) {
$this->segmentsRepository = $segmentsRepository;
}
public function getKey(): string {
return self::KEY;
}
public function getName(): string {
// translators: automation subject (entity entering automation) title
return __('MailPoet segment', 'mailpoet');
}
public function getArgsSchema(): ObjectSchema {
return Builder::object([
'segment_id' => Builder::integer()->required(),
]);
}
public function getPayload(SubjectData $subjectData): Payload {
$id = $subjectData->getArgs()['segment_id'];
$segment = $this->segmentsRepository->findOneById($id);
if (!$segment) {
// translators: %d is the ID.
throw NotFoundException::create()->withMessage(sprintf(__("Segment with ID '%d' not found.", 'mailpoet'), $id));
}
return new SegmentPayload($segment);
}
/** @return Field[] */
public function getFields(): array {
return [
// phpcs:disable Squiz.PHP.CommentedOutCode.Found -- temporarily hide those fields
/*
new Field(
'mailpoet:segment:id',
Field::TYPE_INTEGER,
__('Segment ID', 'mailpoet'),
function (SegmentPayload $payload) {
return $payload->getId();
}
),
new Field(
'mailpoet:segment:name',
Field::TYPE_STRING,
__('Segment name', 'mailpoet'),
function (SegmentPayload $payload) {
return $payload->getName();
}
),
*/
];
}
}
@@ -0,0 +1,68 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\MailPoet\Subjects;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Field;
use MailPoet\Automation\Engine\Data\Subject as SubjectData;
use MailPoet\Automation\Engine\Integration\Payload;
use MailPoet\Automation\Engine\Integration\Subject;
use MailPoet\Automation\Integrations\MailPoet\Fields\SubscriberFieldsFactory;
use MailPoet\Automation\Integrations\MailPoet\Payloads\SubscriberPayload;
use MailPoet\NotFoundException;
use MailPoet\Subscribers\SubscribersRepository;
use MailPoet\Validator\Builder;
use MailPoet\Validator\Schema\ObjectSchema;
/**
* @implements Subject<SubscriberPayload>
*/
class SubscriberSubject implements Subject {
const KEY = 'mailpoet:subscriber';
/** @var SubscriberFieldsFactory */
private $subscriberFieldsFactory;
/** @var SubscribersRepository */
private $subscribersRepository;
public function __construct(
SubscriberFieldsFactory $subscriberFieldsFactory,
SubscribersRepository $subscribersRepository
) {
$this->subscriberFieldsFactory = $subscriberFieldsFactory;
$this->subscribersRepository = $subscribersRepository;
}
public function getKey(): string {
return self::KEY;
}
public function getName(): string {
// translators: automation subject (entity entering automation) title
return __('MailPoet subscriber', 'mailpoet');
}
public function getArgsSchema(): ObjectSchema {
return Builder::object([
'subscriber_id' => Builder::integer()->required(),
]);
}
public function getPayload(SubjectData $subjectData): Payload {
$id = $subjectData->getArgs()['subscriber_id'];
$subscriber = $this->subscribersRepository->findOneById($id);
if (!$subscriber) {
// translators: %d is the ID.
throw NotFoundException::create()->withMessage(sprintf(__("Subscriber with ID '%d' not found.", 'mailpoet'), $id));
}
return new SubscriberPayload($subscriber);
}
/** @return Field[] */
public function getFields(): array {
return $this->subscriberFieldsFactory->getFields();
}
}
@@ -0,0 +1,394 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\MailPoet\Templates;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Automation;
use MailPoet\Automation\Engine\Data\AutomationTemplate;
use MailPoet\Automation\Engine\Templates\AutomationBuilder;
use MailPoet\Automation\Integrations\WooCommerce\WooCommerce;
class TemplatesFactory {
/** @var AutomationBuilder */
private $builder;
/** @var WooCommerce */
private $woocommerce;
public function __construct(
AutomationBuilder $builder,
WooCommerce $woocommerce
) {
$this->builder = $builder;
$this->woocommerce = $woocommerce;
}
public function createTemplates(): array {
$templates = [
$this->createSubscriberWelcomeEmailTemplate(),
$this->createUserWelcomeEmailTemplate(),
$this->createSubscriberWelcomeSeriesTemplate(),
$this->createUserWelcomeSeriesTemplate(),
];
if ($this->woocommerce->isWooCommerceActive()) {
$templates[] = $this->createFirstPurchaseTemplate();
$templates[] = $this->createThankLoyalCustomersTemplate();
$templates[] = $this->createWinBackCustomersTemplate();
$templates[] = $this->createAbandonedCartTemplate();
$templates[] = $this->createAbandonedCartCampaignTemplate();
$templates[] = $this->createPurchasedProductTemplate();
$templates[] = $this->createPurchasedProductWithTagTemplate();
$templates[] = $this->createPurchasedInCategoryTemplate();
}
return $templates;
}
private function createSubscriberWelcomeEmailTemplate(): AutomationTemplate {
return new AutomationTemplate(
'subscriber-welcome-email',
'welcome',
__('Welcome new subscribers', 'mailpoet'),
__(
'Send a welcome email when someone subscribes to your list. Optionally, you can choose to send this email after a specified period.',
'mailpoet'
),
function (): Automation {
return $this->builder->createFromSequence(
__('Welcome new subscribers', 'mailpoet'),
[
['key' => 'mailpoet:someone-subscribes'],
['key' => 'core:delay', 'args' => ['delay' => 1, 'delay_type' => 'MINUTES']],
['key' => 'mailpoet:send-email'],
],
[
'mailpoet:run-once-per-subscriber' => true,
]
);
},
[
'automationSteps' => 1,
],
AutomationTemplate::TYPE_DEFAULT
);
}
private function createUserWelcomeEmailTemplate(): AutomationTemplate {
return new AutomationTemplate(
'user-welcome-email',
'welcome',
__('Welcome new WordPress users', 'mailpoet'),
__(
'Send a welcome email when a new WordPress user registers to your website. Optionally, you can choose to send this email after a specified period.',
'mailpoet'
),
function (): Automation {
return $this->builder->createFromSequence(
__('Welcome new WordPress users', 'mailpoet'),
[
['key' => 'mailpoet:wp-user-registered'],
['key' => 'core:delay', 'args' => ['delay' => 1, 'delay_type' => 'MINUTES']],
['key' => 'mailpoet:send-email'],
],
[
'mailpoet:run-once-per-subscriber' => true,
]
);
},
[
'automationSteps' => 1,
],
AutomationTemplate::TYPE_DEFAULT
);
}
private function createSubscriberWelcomeSeriesTemplate(): AutomationTemplate {
return new AutomationTemplate(
'subscriber-welcome-series',
'welcome',
__('Welcome series for new subscribers', 'mailpoet'),
__(
'Welcome new subscribers and start building a relationship with them. Send an email immediately after someone subscribes to your list to introduce your brand and a follow-up two days later to keep the conversation going.',
'mailpoet'
),
function (): Automation {
return $this->builder->createFromSequence(
__('Welcome series for new subscribers', 'mailpoet'),
[]
);
},
[
'automationSteps' => 2,
],
AutomationTemplate::TYPE_PREMIUM
);
}
private function createUserWelcomeSeriesTemplate(): AutomationTemplate {
return new AutomationTemplate(
'user-welcome-series',
'welcome',
__('Welcome series for new WordPress users', 'mailpoet'),
__(
'Welcome new WordPress users to your site. Send an email immediately after a WordPress user registers. Send a follow-up email two days later with more in-depth information.',
'mailpoet'
),
function (): Automation {
return $this->builder->createFromSequence(
__('Welcome series for new WordPress users', 'mailpoet'),
[]
);
},
[
'automationSteps' => 2,
],
AutomationTemplate::TYPE_PREMIUM
);
}
private function createFirstPurchaseTemplate(): AutomationTemplate {
return new AutomationTemplate(
'first-purchase',
'woocommerce',
__('Celebrate first-time buyers', 'mailpoet'),
__(
'Welcome your first-time customers by sending an email with a special offer for their next purchase. Make them feel appreciated within your brand.',
'mailpoet'
),
function (): Automation {
return $this->builder->createFromSequence(
__('Celebrate first-time buyers', 'mailpoet'),
[
[
'key' => 'woocommerce:order-completed',
'filters' => [
'operator' => 'and',
'groups' => [
[
'operator' => 'and',
'filters' => [
['field' => 'woocommerce:order:is-first-order', 'condition' => 'is', 'value' => true],
],
],
],
],
],
[
'key' => 'mailpoet:send-email',
'args' => [
'name' => __('Thank you', 'mailpoet'),
'subject' => __('Thank You for Choosing Us!', 'mailpoet'),
],
],
],
[
'mailpoet:run-once-per-subscriber' => true,
]
);
},
[
'automationSteps' => 1,
],
AutomationTemplate::TYPE_DEFAULT
);
}
private function createThankLoyalCustomersTemplate(): AutomationTemplate {
return new AutomationTemplate(
'thank-loyal-customers',
'woocommerce',
__('Thank loyal customers', 'mailpoet'),
__(
'These are your most important customers. Make them feel special by sending a thank you note for supporting your brand.',
'mailpoet'
),
function (): Automation {
return $this->builder->createFromSequence(
__('Thank loyal customers', 'mailpoet'),
[]
);
},
[
'automationSteps' => 1,
],
AutomationTemplate::TYPE_PREMIUM
);
}
private function createWinBackCustomersTemplate(): AutomationTemplate {
return new AutomationTemplate(
'win-back-customers',
'woocommerce',
__('Win-back customers', 'mailpoet'),
__(
'Rekindle the relationship with past customers by reminding them of their favorite products and showcasing whats new, encouraging a return to your brand.',
'mailpoet'
),
function (): Automation {
return $this->builder->createFromSequence(
__('Win-back customers', 'mailpoet'),
[]
);
},
[
'automationSteps' => 4,
],
AutomationTemplate::TYPE_PREMIUM
);
}
private function createAbandonedCartTemplate(): AutomationTemplate {
return new AutomationTemplate(
'abandoned-cart',
'abandoned-cart',
__('Abandoned cart reminder', 'mailpoet'),
__(
'Nudge your shoppers to complete the purchase after they have added a product to the cart but havent completed the order.',
'mailpoet'
),
function (): Automation {
return $this->builder->createFromSequence(
__('Abandoned cart reminder', 'mailpoet'),
[
['key' => 'woocommerce:abandoned-cart'],
[
'key' => 'mailpoet:send-email',
'args' => [
'name' => __('Abandoned cart', 'mailpoet'),
'subject' => __('Looks like you forgot something', 'mailpoet'),
],
],
]
);
},
[
'automationSteps' => 1,
],
AutomationTemplate::TYPE_DEFAULT
);
}
private function createAbandonedCartCampaignTemplate(): AutomationTemplate {
return new AutomationTemplate(
'abandoned-cart-campaign',
'abandoned-cart',
__('Abandoned cart campaign', 'mailpoet'),
__(
'Encourage your potential customers to finalize their purchase when they have added items to their cart but havent finished the order yet. Offer a coupon code as a last resort to convert them to customers.',
'mailpoet'
),
function (): Automation {
return $this->builder->createFromSequence(
__('Abandoned cart campaign', 'mailpoet'),
[]
);
},
[
'automationSteps' => 5,
],
AutomationTemplate::TYPE_PREMIUM
);
}
private function createPurchasedProductTemplate(): AutomationTemplate {
return new AutomationTemplate(
'purchased-product',
'woocommerce',
__('Purchased a product', 'mailpoet'),
__(
'Share care instructions or simply thank the customer for making an order.',
'mailpoet'
),
function (): Automation {
return $this->builder->createFromSequence(
__('Purchased a product', 'mailpoet'),
[
[
'key' => 'woocommerce:buys-a-product',
],
[
'key' => 'mailpoet:send-email',
'args' => [
'name' => __('Important information about your order', 'mailpoet'),
'subject' => __('Important information about your order', 'mailpoet'),
],
],
]
);
},
[
'automationSteps' => 1,
],
AutomationTemplate::TYPE_DEFAULT
);
}
private function createPurchasedProductWithTagTemplate(): AutomationTemplate {
return new AutomationTemplate(
'purchased-product-with-tag',
'woocommerce',
__('Purchased a product with a tag', 'mailpoet'),
__(
'Share care instructions or simply thank the customer for making an order.',
'mailpoet'
),
function (): Automation {
return $this->builder->createFromSequence(
__('Purchased a product with a tag', 'mailpoet'),
[
[
'key' => 'woocommerce:buys-from-a-tag',
],
[
'key' => 'mailpoet:send-email',
'args' => [
'name' => __('Important information about your order', 'mailpoet'),
'subject' => __('Important information about your order', 'mailpoet'),
],
],
]
);
},
[
'automationSteps' => 1,
],
AutomationTemplate::TYPE_DEFAULT
);
}
private function createPurchasedInCategoryTemplate(): AutomationTemplate {
return new AutomationTemplate(
'purchased-in-category',
'woocommerce',
__('Purchased in a category', 'mailpoet'),
__(
'Share care instructions or simply thank the customer for making an order.',
'mailpoet'
),
function (): Automation {
return $this->builder->createFromSequence(
__('Purchased in a category', 'mailpoet'),
[
[
'key' => 'woocommerce:buys-from-a-category',
],
[
'key' => 'mailpoet:send-email',
'args' => [
'name' => __('Important information about your order', 'mailpoet'),
'subject' => __('Important information about your order', 'mailpoet'),
],
],
]
);
},
[
'automationSteps' => 1,
],
AutomationTemplate::TYPE_DEFAULT
);
}
}
@@ -0,0 +1,96 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\MailPoet\Triggers;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\StepRunArgs;
use MailPoet\Automation\Engine\Data\StepValidationArgs;
use MailPoet\Automation\Engine\Data\Subject;
use MailPoet\Automation\Engine\Hooks;
use MailPoet\Automation\Engine\Integration\Trigger;
use MailPoet\Automation\Integrations\MailPoet\Payloads\SegmentPayload;
use MailPoet\Automation\Integrations\MailPoet\Subjects\SegmentSubject;
use MailPoet\Automation\Integrations\MailPoet\Subjects\SubscriberSubject;
use MailPoet\Entities\SegmentEntity;
use MailPoet\Entities\SubscriberSegmentEntity;
use MailPoet\InvalidStateException;
use MailPoet\Segments\SegmentsRepository;
use MailPoet\Validator\Builder;
use MailPoet\Validator\Schema\ObjectSchema;
use MailPoet\WP\Functions as WPFunctions;
class SomeoneSubscribesTrigger implements Trigger {
const KEY = 'mailpoet:someone-subscribes';
/** @var WPFunctions */
private $wp;
/** @var SegmentsRepository */
private $segmentsRepository;
public function __construct(
WPFunctions $wp,
SegmentsRepository $segmentsRepository
) {
$this->wp = $wp;
$this->segmentsRepository = $segmentsRepository;
}
public function getKey(): string {
return 'mailpoet:someone-subscribes';
}
public function getName(): string {
// translators: automation trigger title
return __('Someone subscribes', 'mailpoet');
}
public function getArgsSchema(): ObjectSchema {
return Builder::object([
'segment_ids' => Builder::array(Builder::number()),
]);
}
public function getSubjectKeys(): array {
return [
SubscriberSubject::KEY,
SegmentSubject::KEY,
];
}
public function validate(StepValidationArgs $args): void {
}
public function registerHooks(): void {
$this->wp->addAction('mailpoet_segment_subscribed', [$this, 'handleSubscription'], 10, 2);
}
public function handleSubscription(SubscriberSegmentEntity $subscriberSegment): void {
$segment = $subscriberSegment->getSegment();
$subscriber = $subscriberSegment->getSubscriber();
if (!$segment || !$subscriber) {
throw new InvalidStateException();
}
$this->wp->doAction(Hooks::TRIGGER, $this, [
new Subject(SegmentSubject::KEY, ['segment_id' => $segment->getId()]),
new Subject(SubscriberSubject::KEY, ['subscriber_id' => $subscriber->getId()]),
]);
}
public function isTriggeredBy(StepRunArgs $args): bool {
$segmentId = $args->getSinglePayloadByClass(SegmentPayload::class)->getId();
$segment = $this->segmentsRepository->findOneById($segmentId);
if (!$segment || $segment->getType() !== SegmentEntity::TYPE_DEFAULT) {
return false;
}
// Triggers when no segment IDs defined (= any segment) or the current segment paylo.
$triggerArgs = $args->getStep()->getArgs();
$segmentIds = $triggerArgs['segment_ids'] ?? [];
return !is_array($segmentIds) || !$segmentIds || in_array($segmentId, $segmentIds, true);
}
}
@@ -0,0 +1,105 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\MailPoet\Triggers;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\StepRunArgs;
use MailPoet\Automation\Engine\Data\StepValidationArgs;
use MailPoet\Automation\Engine\Data\Subject;
use MailPoet\Automation\Engine\Hooks;
use MailPoet\Automation\Engine\Integration\Trigger;
use MailPoet\Automation\Engine\WordPress;
use MailPoet\Automation\Integrations\MailPoet\Payloads\SegmentPayload;
use MailPoet\Automation\Integrations\MailPoet\Payloads\SubscriberPayload;
use MailPoet\Automation\Integrations\MailPoet\Subjects\SegmentSubject;
use MailPoet\Automation\Integrations\MailPoet\Subjects\SubscriberSubject;
use MailPoet\Entities\SegmentEntity;
use MailPoet\Entities\SubscriberSegmentEntity;
use MailPoet\InvalidStateException;
use MailPoet\Subscribers\SubscribersRepository;
use MailPoet\Validator\Builder;
use MailPoet\Validator\Schema\ObjectSchema;
class UserRegistrationTrigger implements Trigger {
const KEY = 'mailpoet:wp-user-registered';
/** @var WordPress */
private $wp;
private $subscribersRepository;
public function __construct(
WordPress $wp,
SubscribersRepository $subscribersRepository
) {
$this->wp = $wp;
$this->subscribersRepository = $subscribersRepository;
}
public function getKey(): string {
return self::KEY;
}
public function getName(): string {
// translators: automation trigger title
return __('WordPress user registers', 'mailpoet');
}
public function getArgsSchema(): ObjectSchema {
return Builder::object([
'roles' => Builder::array(Builder::string()),
]);
}
public function getSubjectKeys(): array {
return [
SegmentSubject::KEY,
SubscriberSubject::KEY,
];
}
public function validate(StepValidationArgs $args): void {
}
public function registerHooks(): void {
$this->wp->addAction('mailpoet_segment_subscribed', [$this, 'handleSubscription']);
}
public function handleSubscription(SubscriberSegmentEntity $subscriberSegment): void {
$segment = $subscriberSegment->getSegment();
$subscriber = $subscriberSegment->getSubscriber();
if (!$segment || !$subscriber) {
throw new InvalidStateException();
}
$this->wp->doAction(Hooks::TRIGGER, $this, [
new Subject(SegmentSubject::KEY, ['segment_id' => $segment->getId()]),
new Subject(SubscriberSubject::KEY, ['subscriber_id' => $subscriber->getId()]),
]);
}
public function isTriggeredBy(StepRunArgs $args): bool {
$segmentPayload = $args->getSinglePayloadByClass(SegmentPayload::class);
if ($segmentPayload->getType() !== SegmentEntity::TYPE_WP_USERS) {
return false;
}
$subscriberPayload = $args->getSinglePayloadByClass(SubscriberPayload::class);
$this->subscribersRepository->refresh($subscriberPayload->getSubscriber());
if (!$subscriberPayload->isWPUser()) {
return false;
}
$user = $this->wp->getUserBy('id', (int)$subscriberPayload->getWpUserId());
if (!$user) {
return false;
}
$triggerArgs = $args->getStep()->getArgs();
$roles = $triggerArgs['roles'] ?? [];
return !is_array($roles) || !$roles || count(array_intersect($user->roles, $roles)) > 0;
}
}
@@ -0,0 +1,32 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\WooCommerce;
if (!defined('ABSPATH')) exit;
class ContextFactory {
/** @var WooCommerce */
private $woocommerce;
public function __construct(
WooCommerce $woocommerce
) {
$this->woocommerce = $woocommerce;
}
/** @return mixed[] */
public function getContextData(): array {
if (!$this->woocommerce->isWooCommerceActive()) {
return [];
}
$context = [
'order_statuses' => $this->woocommerce->wcGetOrderStatuses(),
'review_ratings_enabled' => $this->woocommerce->wcReviewRatingsEnabled(),
];
return $context;
}
}
@@ -0,0 +1,153 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\WooCommerce\Fields;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Field;
use MailPoet\Automation\Integrations\WooCommerce\Payloads\CustomerPayload;
class CustomerFieldsFactory {
/** @var CustomerOrderFieldsFactory */
private $customerOrderFieldsFactory;
/** @var CustomerReviewFieldsFactory */
private $customerReviewFieldsFactory;
public function __construct(
CustomerOrderFieldsFactory $customerOrderFieldsFactory,
CustomerReviewFieldsFactory $customerReviewFieldsFactory
) {
$this->customerOrderFieldsFactory = $customerOrderFieldsFactory;
$this->customerReviewFieldsFactory = $customerReviewFieldsFactory;
}
/** @return Field[] */
public function getFields(): array {
return array_merge(
[
new Field(
'woocommerce:customer:billing-company',
Field::TYPE_STRING,
__('Billing company', 'mailpoet'),
function (CustomerPayload $payload) {
return $payload->getBillingCompany();
}
),
new Field(
'woocommerce:customer:billing-phone',
Field::TYPE_STRING,
__('Billing phone', 'mailpoet'),
function (CustomerPayload $payload) {
return $payload->getBillingPhone();
}
),
new Field(
'woocommerce:customer:billing-city',
Field::TYPE_STRING,
__('Billing city', 'mailpoet'),
function (CustomerPayload $payload) {
return $payload->getBillingCity();
}
),
new Field(
'woocommerce:customer:billing-postcode',
Field::TYPE_STRING,
__('Billing postcode', 'mailpoet'),
function (CustomerPayload $payload) {
return $payload->getBillingPostcode();
}
),
new Field(
'woocommerce:customer:billing-state',
Field::TYPE_STRING,
__('Billing state/county', 'mailpoet'),
function (CustomerPayload $payload) {
return $payload->getBillingState();
}
),
new Field(
'woocommerce:customer:billing-country',
Field::TYPE_ENUM,
__('Billing country', 'mailpoet'),
function (CustomerPayload $payload) {
return $payload->getBillingCountry();
},
[
'options' => $this->getBillingCountryOptions(),
]
),
new Field(
'woocommerce:customer:shipping-company',
Field::TYPE_STRING,
__('Shipping company', 'mailpoet'),
function (CustomerPayload $payload) {
return $payload->getShippingCompany();
}
),
new Field(
'woocommerce:customer:shipping-phone',
Field::TYPE_STRING,
__('Shipping phone', 'mailpoet'),
function (CustomerPayload $payload) {
return $payload->getShippingPhone();
}
),
new Field(
'woocommerce:customer:shipping-city',
Field::TYPE_STRING,
__('Shipping city', 'mailpoet'),
function (CustomerPayload $payload) {
return $payload->getShippingCity();
}
),
new Field(
'woocommerce:customer:shipping-postcode',
Field::TYPE_STRING,
__('Shipping postcode', 'mailpoet'),
function (CustomerPayload $payload) {
return $payload->getShippingPostcode();
}
),
new Field(
'woocommerce:customer:shipping-state',
Field::TYPE_STRING,
__('Shipping state/county', 'mailpoet'),
function (CustomerPayload $payload) {
return $payload->getShippingState();
}
),
new Field(
'woocommerce:customer:shipping-country',
Field::TYPE_ENUM,
__('Shipping country', 'mailpoet'),
function (CustomerPayload $payload) {
return $payload->getShippingCountry();
},
[
'options' => $this->getShippingCountryOptions(),
]
),
],
$this->customerOrderFieldsFactory->getFields(),
$this->customerReviewFieldsFactory->getFields()
);
}
private function getBillingCountryOptions(): array {
$options = [];
foreach (WC()->countries->get_allowed_countries() as $code => $name) {
$options[] = ['id' => $code, 'name' => $name];
}
return $options;
}
private function getShippingCountryOptions(): array {
$options = [];
foreach (WC()->countries->get_shipping_countries() as $code => $name) {
$options[] = ['id' => $code, 'name' => $name];
}
return $options;
}
}
@@ -0,0 +1,414 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\WooCommerce\Fields;
if (!defined('ABSPATH')) exit;
use DateTimeImmutable;
use DateTimeZone;
use MailPoet\Automation\Engine\Data\Field;
use MailPoet\Automation\Integrations\WooCommerce\Payloads\CustomerPayload;
use MailPoet\Automation\Integrations\WooCommerce\WooCommerce;
use WC_Customer;
use WC_Order;
use WC_Order_Item_Product;
use WC_Product;
class CustomerOrderFieldsFactory {
/** @var WooCommerce */
private $wooCommerce;
/** @var TermOptionsBuilder */
private $termOptionsBuilder;
/** @var TermParentsLoader */
private $termParentsLoader;
public function __construct(
WooCommerce $wooCommerce,
TermOptionsBuilder $termOptionsBuilder,
TermParentsLoader $termParentsLoader
) {
$this->wooCommerce = $wooCommerce;
$this->termOptionsBuilder = $termOptionsBuilder;
$this->termParentsLoader = $termParentsLoader;
}
/** @return Field[] */
public function getFields(): array {
return [
new Field(
'woocommerce:customer:spent-total',
Field::TYPE_NUMBER,
__('Total spent', 'mailpoet'),
function (CustomerPayload $payload, array $params = []) {
$customer = $payload->getCustomer();
$inTheLastSeconds = isset($params['in_the_last']) ? (int)$params['in_the_last'] : null;
if (!$customer) {
$order = $payload->getOrder();
return $order && $this->isInTheLastSeconds($order, $inTheLastSeconds) ? $payload->getTotalSpent() : 0.0;
}
return $inTheLastSeconds === null
? $payload->getTotalSpent()
: $this->getRecentSpentTotal($customer, $inTheLastSeconds);
},
[
'params' => ['in_the_last'],
]
),
new Field(
'woocommerce:customer:spent-average',
Field::TYPE_NUMBER,
__('Average spent', 'mailpoet'),
function (CustomerPayload $payload, array $params = []) {
$customer = $payload->getCustomer();
$inTheLastSeconds = isset($params['in_the_last']) ? (int)$params['in_the_last'] : null;
if (!$customer) {
$order = $payload->getOrder();
return $order && $this->isInTheLastSeconds($order, $inTheLastSeconds) ? $payload->getAverageSpent() : 0.0;
}
if ($inTheLastSeconds === null) {
return $payload->getAverageSpent();
} else {
$totalSpent = $this->getRecentSpentTotal($customer, $inTheLastSeconds);
$orderCount = $this->getRecentOrderCount($customer, $inTheLastSeconds);
return $orderCount > 0 ? ($totalSpent / $orderCount) : 0.0;
}
},
[
'params' => ['in_the_last'],
]
),
new Field(
'woocommerce:customer:order-count',
Field::TYPE_INTEGER,
__('Order count', 'mailpoet'),
function (CustomerPayload $payload, array $params = []) {
$customer = $payload->getCustomer();
$inTheLastSeconds = isset($params['in_the_last']) ? (int)$params['in_the_last'] : null;
if (!$customer) {
$order = $payload->getOrder();
return $order && $this->isInTheLastSeconds($order, $inTheLastSeconds) ? $payload->getOrderCount() : 0;
}
return $inTheLastSeconds === null
? $payload->getOrderCount()
: $this->getRecentOrderCount($customer, $inTheLastSeconds);
},
[
'params' => ['in_the_last'],
]
),
new Field(
'woocommerce:customer:first-paid-order-date',
Field::TYPE_DATETIME,
__('First paid order date', 'mailpoet'),
function (CustomerPayload $payload) {
$customer = $payload->getCustomer();
if (!$customer) {
$order = $payload->getOrder();
return $order && $order->is_paid() ? $order->get_date_created() : null;
}
return $this->getPaidOrderDate($customer, true);
}
),
new Field(
'woocommerce:customer:last-paid-order-date',
Field::TYPE_DATETIME,
__('Last paid order date', 'mailpoet'),
function (CustomerPayload $payload) {
$customer = $payload->getCustomer();
if (!$customer) {
$order = $payload->getOrder();
return $order && $order->is_paid() ? $order->get_date_created() : null;
}
return $this->getPaidOrderDate($customer, false);
}
),
new Field(
'woocommerce:customer:purchased-categories',
Field::TYPE_ENUM_ARRAY,
__('Purchased categories', 'mailpoet'),
function (CustomerPayload $payload, array $params = []) {
$customer = $payload->getCustomer();
$inTheLastSeconds = isset($params['in_the_last']) ? (int)$params['in_the_last'] : null;
if (!$customer) {
$order = $payload->getOrder();
$items = $order && $order->is_paid() && $this->isInTheLastSeconds($order, $inTheLastSeconds) ? $order->get_items() : [];
$ids = [];
foreach ($items as $item) {
$product = $item instanceof WC_Order_Item_Product ? $item->get_product() : null;
$ids = array_merge($ids, $product instanceof WC_Product ? $product->get_category_ids() : []);
}
$ids = array_unique($ids);
} else {
$ids = $this->getOrderProductTermIds($customer, 'product_cat', $inTheLastSeconds);
}
$ids = array_merge($ids, $this->termParentsLoader->getParentIds($ids));
sort($ids);
return $ids;
},
[
'options' => $this->termOptionsBuilder->getTermOptions('product_cat'),
'params' => ['in_the_last'],
]
),
new Field(
'woocommerce:customer:purchased-tags',
Field::TYPE_ENUM_ARRAY,
__('Purchased tags', 'mailpoet'),
function (CustomerPayload $payload, array $params = []) {
$customer = $payload->getCustomer();
$inTheLastSeconds = isset($params['in_the_last']) ? (int)$params['in_the_last'] : null;
if (!$customer) {
$order = $payload->getOrder();
$items = $order && $order->is_paid() && $this->isInTheLastSeconds($order, $inTheLastSeconds) ? $order->get_items() : [];
$ids = [];
foreach ($items as $item) {
$product = $item instanceof WC_Order_Item_Product ? $item->get_product() : null;
$ids = array_merge($ids, $product instanceof WC_Product ? $product->get_tag_ids() : []);
}
$ids = array_unique($ids);
} else {
$ids = $this->getOrderProductTermIds($customer, 'product_tag', $inTheLastSeconds);
}
sort($ids);
return $ids;
},
[
'options' => $this->termOptionsBuilder->getTermOptions('product_tag'),
'params' => ['in_the_last'],
]
),
];
}
private function getRecentSpentTotal(WC_Customer $customer, int $inTheLastSeconds): float {
global $wpdb;
$statuses = array_map(function (string $status) {
return "wc-$status";
}, $this->wooCommerce->wcGetIsPaidStatuses());
if ($this->wooCommerce->isWooCommerceCustomOrdersTableEnabled()) {
return (float)$wpdb->get_var(
$wpdb->prepare(
'
SELECT SUM(o.total_amount)
FROM %i o
WHERE o.customer_id = %d
AND o.status IN (' . implode(',', array_fill(0, count($statuses), '%s')) . ')
AND o.date_created_gmt >= DATE_SUB(current_timestamp, INTERVAL %d SECOND)
',
array_merge(
[
$wpdb->prefix . 'wc_orders',
$customer->get_id(),
],
$statuses,
[$inTheLastSeconds]
)
)
);
}
return (float)$wpdb->get_var(
$wpdb->prepare(
"
SELECT SUM(pm_total.meta_value)
FROM {$wpdb->posts} p
LEFT JOIN {$wpdb->postmeta} pm_user ON p.ID = pm_user.post_id AND pm_user.meta_key = '_customer_user'
LEFT JOIN {$wpdb->postmeta} pm_total ON p.ID = pm_total.post_id AND pm_total.meta_key = '_order_total'
WHERE p.post_type = 'shop_order'
AND p.post_status IN (" . implode(',', array_fill(0, count($statuses), '%s')) . ")
AND pm_user.meta_value = %d
AND p.post_date_gmt >= DATE_SUB(current_timestamp, INTERVAL %d SECOND)
",
array_merge(
$statuses,
[$customer->get_id(), $inTheLastSeconds]
)
)
);
}
private function getRecentOrderCount(WC_Customer $customer, int $inTheLastSeconds): int {
global $wpdb;
$statuses = array_keys($this->wooCommerce->wcGetOrderStatuses());
if ($this->wooCommerce->isWooCommerceCustomOrdersTableEnabled()) {
return (int)$wpdb->get_var(
$wpdb->prepare(
'
SELECT COUNT(o.id)
FROM %i o
WHERE o.customer_id = %d
AND o.status IN (' . implode(',', array_fill(0, count($statuses), '%s')) . ')
AND o.date_created_gmt >= DATE_SUB(current_timestamp, INTERVAL %d SECOND)
',
array_merge(
[
$wpdb->prefix . 'wc_orders',
$customer->get_id(),
],
$statuses,
[$inTheLastSeconds]
)
)
);
}
return (int)$wpdb->get_var(
$wpdb->prepare(
"
SELECT COUNT(p.ID)
FROM {$wpdb->posts} p
LEFT JOIN {$wpdb->postmeta} pm_user ON p.ID = pm_user.post_id AND pm_user.meta_key = '_customer_user'
WHERE p.post_type = 'shop_order'
AND p.post_status IN (" . implode(',', array_fill(0, count($statuses), '%s')) . ")
AND pm_user.meta_value = %d
AND p.post_date_gmt >= DATE_SUB(current_timestamp, INTERVAL %d SECOND)
",
array_merge(
$statuses,
[
$customer->get_id(),
$inTheLastSeconds,
]
)
)
);
}
private function getPaidOrderDate(WC_Customer $customer, bool $fetchFirst): ?DateTimeImmutable {
global $wpdb;
$sorting = $fetchFirst ? 'ASC' : 'DESC';
$statuses = array_map(function (string $status) {
return "wc-$status";
}, $this->wooCommerce->wcGetIsPaidStatuses());
if ($this->wooCommerce->isWooCommerceCustomOrdersTableEnabled()) {
$date = $wpdb->get_var(
$wpdb->prepare(
'
SELECT o.date_created_gmt
FROM %i o
WHERE o.customer_id = %d
AND o.status IN (' . implode(',', array_fill(0, count($statuses), '%s')) . ')
AND o.total_amount > 0
ORDER BY o.date_created_gmt ' . $sorting /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The argument is safe. */ . '
LIMIT 1
',
array_merge(
[
$wpdb->prefix . 'wc_orders',
$customer->get_id(),
],
$statuses
)
)
);
} else {
$date = $wpdb->get_var(
$wpdb->prepare(
"
SELECT p.post_date_gmt
FROM {$wpdb->prefix}posts p
LEFT JOIN {$wpdb->prefix}postmeta pm_total ON p.ID = pm_total.post_id AND pm_total.meta_key = '_order_total'
LEFT JOIN {$wpdb->prefix}postmeta pm_user ON p.ID = pm_user.post_id AND pm_user.meta_key = '_customer_user'
WHERE p.post_type = 'shop_order'
AND p.post_status IN (" . implode(',', array_fill(0, count($statuses), '%s')) . ")
AND pm_user.meta_value = %d
AND pm_total.meta_value > 0
ORDER BY p.post_date_gmt " . $sorting /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The argument is safe. */ . "
LIMIT 1
",
array_merge(
$statuses,
[$customer->get_id()]
)
)
);
}
return $date ? new DateTimeImmutable($date, new DateTimeZone('GMT')) : null;
}
private function getOrderProductTermIds(WC_Customer $customer, string $taxonomy, int $inTheLastSeconds = null): array {
global $wpdb;
$statuses = array_map(function (string $status) {
return "wc-$status";
}, $this->wooCommerce->wcGetIsPaidStatuses());
$statusesPlaceholder = implode(',', array_fill(0, count($statuses), '%s'));
// get all product categories that the customer has purchased
if ($this->wooCommerce->isWooCommerceCustomOrdersTableEnabled()) {
$inTheLastFilter = isset($inTheLastSeconds) ? 'AND o.date_created_gmt >= DATE_SUB(current_timestamp, INTERVAL %d SECOND)' : '';
$orderIdsSubquery = "
SELECT o.id
FROM %i o
WHERE o.status IN ($statusesPlaceholder)
AND o.customer_id = %d
$inTheLastFilter
";
$orderIdsSubqueryArgs = array_merge(
[$wpdb->prefix . 'wc_orders'],
$statuses,
[$customer->get_id()],
$inTheLastSeconds ? [$inTheLastSeconds] : []
);
} else {
$inTheLastFilter = isset($inTheLastSeconds) ? 'AND p.post_date_gmt >= DATE_SUB(current_timestamp, INTERVAL %d SECOND)' : '';
$orderIdsSubquery = "
SELECT p.ID
FROM {$wpdb->posts} p
LEFT JOIN {$wpdb->postmeta} pm_user ON p.ID = pm_user.post_id AND pm_user.meta_key = '_customer_user'
WHERE p.post_type = 'shop_order'
AND p.post_status IN ($statusesPlaceholder)
AND pm_user.meta_value = %d
$inTheLastFilter
";
$orderIdsSubqueryArgs = array_merge(
$statuses,
[$customer->get_id()],
$inTheLastSeconds ? [$inTheLastSeconds] : []
);
}
$result = $wpdb->get_col(
// phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- The number of replacements is dynamic.
$wpdb->prepare(
"
SELECT DISTINCT tt.term_id
FROM {$wpdb->term_taxonomy} tt
JOIN %i AS oi ON oi.order_id IN (" . $orderIdsSubquery . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The subquery uses placeholders. */ ") AND oi.order_item_type = 'line_item'
JOIN %i AS pid ON oi.order_item_id = pid.order_item_id AND pid.meta_key = '_product_id'
JOIN {$wpdb->posts} p ON pid.meta_value = p.ID
JOIN {$wpdb->term_relationships} tr ON IF(p.post_type = 'product_variation', p.post_parent, p.ID) = tr.object_id AND tr.term_taxonomy_id = tt.term_taxonomy_id
WHERE tt.taxonomy = %s
ORDER BY tt.term_id ASC
",
array_merge(
[$wpdb->prefix . 'woocommerce_order_items'],
$orderIdsSubqueryArgs,
[
$wpdb->prefix . 'woocommerce_order_itemmeta',
(string)($taxonomy),
]
)
)
);
return array_map('intval', $result);
}
private function isInTheLastSeconds(WC_Order $order, ?int $inTheLastSeconds): bool {
if ($inTheLastSeconds === null) {
return true;
}
return $order->get_date_created() >= new DateTimeImmutable("-$inTheLastSeconds seconds");
}
}
@@ -0,0 +1,110 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\WooCommerce\Fields;
if (!defined('ABSPATH')) exit;
use DateTimeImmutable;
use MailPoet\Automation\Engine\Data\Field;
use MailPoet\Automation\Engine\WordPress;
use MailPoet\Automation\Integrations\WooCommerce\Payloads\CustomerPayload;
use WC_Customer;
class CustomerReviewFieldsFactory {
/** @var WordPress */
private $wordPress;
public function __construct(
WordPress $wordPress
) {
$this->wordPress = $wordPress;
}
/** @return Field[] */
public function getFields(): array {
return [
new Field(
'woocommerce:customer:review-count',
Field::TYPE_INTEGER,
__('Review count', 'mailpoet'),
function (CustomerPayload $payload, array $params = []) {
$customer = $payload->getCustomer();
if (!$customer) {
return 0;
}
$inTheLastSeconds = isset($params['in_the_last']) ? (int)$params['in_the_last'] : null;
return $this->getUniqueProductReviewCount($customer, $inTheLastSeconds);
},
[
'params' => ['in_the_last'],
]
),
new Field(
'woocommerce:customer:last-review-date',
Field::TYPE_DATETIME,
__('Last review date', 'mailpoet'),
function (CustomerPayload $payload) {
$customer = $payload->getCustomer();
return $customer ? $this->getLastReviewDate($customer) : null;
}
),
];
}
/**
* Calculate the customer's review count excluding multiple reviews on the same product.
* Inspired by AutomateWoo implementation.
*/
private function getUniqueProductReviewCount(WC_Customer $customer, int $inTheLastSeconds = null): int {
global $wpdb;
$inTheLastFilter = isset($inTheLastSeconds) ? 'AND c.comment_date_gmt >= DATE_SUB(current_timestamp, INTERVAL %d SECOND)' : '';
return (int)$wpdb->get_var(
// phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- The number of replacements is dynamic.
$wpdb->prepare(
"
SELECT COUNT(DISTINCT c.comment_post_ID) FROM {$wpdb->comments} c
JOIN {$wpdb->posts} p ON c.comment_post_ID = p.ID
WHERE p.post_type = 'product'
AND c.comment_parent = 0
AND c.comment_approved = 1
AND c.comment_type = 'review'
AND (c.user_ID = %d OR c.comment_author_email = %s)
" . $inTheLastFilter . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The condition uses placeholders. */ "
",
array_merge(
[
$customer->get_id(),
$customer->get_email(),
],
$inTheLastSeconds ? [$inTheLastSeconds] : []
)
)
);
}
private function getLastReviewDate(WC_Customer $customer): ?DateTimeImmutable {
global $wpdb;
$date = $wpdb->get_var(
$wpdb->prepare(
"
SELECT c.comment_date
FROM {$wpdb->comments} c
JOIN {$wpdb->posts} p ON c.comment_post_ID = p.ID
WHERE p.post_type = 'product'
AND c.comment_parent = 0
AND c.comment_approved = 1
AND c.comment_type = 'review'
AND (c.user_ID = %d OR c.comment_author_email = %s)
ORDER BY c.comment_date DESC
LIMIT 1
",
[$customer->get_id(), $customer->get_email()]
)
);
return $date ? new DateTimeImmutable($date, $this->wordPress->wpTimezone()) : null;
}
}
@@ -0,0 +1,403 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\WooCommerce\Fields;
if (!defined('ABSPATH')) exit;
use DateTimeImmutable;
use MailPoet\Automation\Engine\Data\Field;
use MailPoet\Automation\Engine\WordPress;
use MailPoet\Automation\Integrations\WooCommerce\Payloads\OrderPayload;
use MailPoet\Automation\Integrations\WooCommerce\WooCommerce;
use WC_Order;
use WC_Order_Item_Product;
use WC_Payment_Gateway;
use WC_Product;
use WP_Post;
class OrderFieldsFactory {
/** @var TermOptionsBuilder */
private $termOptionsBuilder;
/** @var TermParentsLoader */
private $termParentsLoader;
/** @var WordPress */
private $wordPress;
/** @var WooCommerce */
private $wooCommerce;
public function __construct(
TermOptionsBuilder $termOptionsBuilder,
TermParentsLoader $termParentsLoader,
WordPress $wordPress,
WooCommerce $wooCommerce
) {
$this->termOptionsBuilder = $termOptionsBuilder;
$this->termParentsLoader = $termParentsLoader;
$this->wordPress = $wordPress;
$this->wooCommerce = $wooCommerce;
}
/** @return Field[] */
public function getFields(): array {
return array_merge(
[
new Field(
'woocommerce:order:billing-company',
Field::TYPE_STRING,
__('Billing company', 'mailpoet'),
function (OrderPayload $payload) {
return $payload->getOrder()->get_billing_company();
}
),
new Field(
'woocommerce:order:billing-phone',
Field::TYPE_STRING,
__('Billing phone', 'mailpoet'),
function (OrderPayload $payload) {
return $payload->getOrder()->get_billing_phone();
}
),
new Field(
'woocommerce:order:billing-city',
Field::TYPE_STRING,
__('Billing city', 'mailpoet'),
function (OrderPayload $payload) {
return $payload->getOrder()->get_billing_city();
}
),
new Field(
'woocommerce:order:billing-postcode',
Field::TYPE_STRING,
__('Billing postcode', 'mailpoet'),
function (OrderPayload $payload) {
return $payload->getOrder()->get_billing_postcode();
}
),
new Field(
'woocommerce:order:billing-state',
Field::TYPE_STRING,
__('Billing state/county', 'mailpoet'),
function (OrderPayload $payload) {
return $payload->getOrder()->get_billing_state();
}
),
new Field(
'woocommerce:order:billing-country',
Field::TYPE_ENUM,
__('Billing country', 'mailpoet'),
function (OrderPayload $payload) {
return $payload->getOrder()->get_billing_country();
},
[
'options' => $this->getBillingCountryOptions(),
]
),
new Field(
'woocommerce:order:shipping-company',
Field::TYPE_STRING,
__('Shipping company', 'mailpoet'),
function (OrderPayload $payload) {
return $payload->getOrder()->get_shipping_company();
}
),
new Field(
'woocommerce:order:shipping-phone',
Field::TYPE_STRING,
__('Shipping phone', 'mailpoet'),
function (OrderPayload $payload) {
return $payload->getOrder()->get_shipping_phone();
}
),
new Field(
'woocommerce:order:shipping-city',
Field::TYPE_STRING,
__('Shipping city', 'mailpoet'),
function (OrderPayload $payload) {
return $payload->getOrder()->get_shipping_city();
}
),
new Field(
'woocommerce:order:shipping-postcode',
Field::TYPE_STRING,
__('Shipping postcode', 'mailpoet'),
function (OrderPayload $payload) {
return $payload->getOrder()->get_shipping_postcode();
}
),
new Field(
'woocommerce:order:shipping-state',
Field::TYPE_STRING,
__('Shipping state/county', 'mailpoet'),
function (OrderPayload $payload) {
return $payload->getOrder()->get_shipping_state();
}
),
new Field(
'woocommerce:order:shipping-country',
Field::TYPE_ENUM,
__('Shipping country', 'mailpoet'),
function (OrderPayload $payload) {
return $payload->getOrder()->get_shipping_country();
},
[
'options' => $this->getShippingCountryOptions(),
]
),
new Field(
'woocommerce:order:created-date',
Field::TYPE_DATETIME,
__('Created date', 'mailpoet'),
function (OrderPayload $payload) {
return $payload->getOrder()->get_date_created();
}
),
new Field(
'woocommerce:order:paid-date',
Field::TYPE_DATETIME,
__('Paid date', 'mailpoet'),
function (OrderPayload $payload) {
return $payload->getOrder()->get_date_paid();
}
),
new Field(
'woocommerce:order:customer-note',
Field::TYPE_STRING,
__('Customer provided note', 'mailpoet'),
function (OrderPayload $payload) {
return $payload->getOrder()->get_customer_note();
}
),
new Field(
'woocommerce:order:payment-method',
Field::TYPE_ENUM,
__('Payment method', 'mailpoet'),
function (OrderPayload $payload) {
return $payload->getOrder()->get_payment_method();
},
[
'options' => $this->getOrderPaymentOptions(),
]
),
new Field(
'woocommerce:order:status',
Field::TYPE_ENUM,
__('Status', 'mailpoet'),
function (OrderPayload $payload) {
return $payload->getOrder()->get_status();
},
[
'options' => $this->getOrderStatusOptions(),
]
),
new Field(
'woocommerce:order:total',
Field::TYPE_NUMBER,
__('Total', 'mailpoet'),
function (OrderPayload $payload) {
return (float)$payload->getOrder()->get_total();
}
),
new Field(
'woocommerce:order:coupons',
Field::TYPE_ENUM_ARRAY,
__('Used coupons', 'mailpoet'),
function (OrderPayload $payload) {
return $payload->getOrder()->get_coupon_codes();
},
[
'options' => $this->getCouponOptions(),
]
),
new Field(
'woocommerce:order:is-first-order',
Field::TYPE_BOOLEAN,
__('Is first order', 'mailpoet'),
function (OrderPayload $payload) {
$order = $payload->getOrder();
return !$this->previousOrderExists($order);
}
),
new Field(
'woocommerce:order:categories',
Field::TYPE_ENUM_ARRAY,
__('Categories', 'mailpoet'),
function (OrderPayload $payload) {
$products = $this->getProducts($payload->getOrder());
$categoryIds = [];
foreach ($products as $product) {
$categoryIds = array_merge($categoryIds, $product->get_category_ids());
}
$categoryIds = array_merge($categoryIds, $this->termParentsLoader->getParentIds($categoryIds));
sort($categoryIds);
return array_unique($categoryIds);
},
[
'options' => $this->termOptionsBuilder->getTermOptions('product_cat'),
]
),
new Field(
'woocommerce:order:tags',
Field::TYPE_ENUM_ARRAY,
__('Tags', 'mailpoet'),
function (OrderPayload $payload) {
$products = $this->getProducts($payload->getOrder());
$tagIds = [];
foreach ($products as $product) {
$tagIds = array_merge($tagIds, $product->get_tag_ids());
}
sort($tagIds);
return array_unique($tagIds);
},
[
'options' => $this->termOptionsBuilder->getTermOptions('product_tag'),
]
),
new Field(
'woocommerce:order:products',
Field::TYPE_ENUM_ARRAY,
__('Products', 'mailpoet'),
function (OrderPayload $payload) {
$products = $this->getProducts($payload->getOrder());
return array_map(function (WC_Product $product) {
return $product->get_id();
}, $products);
},
[
'options' => $this->getProductOptions(),
]
),
]
);
}
private function getBillingCountryOptions(): array {
$options = [];
foreach (WC()->countries->get_allowed_countries() as $code => $name) {
$options[] = ['id' => $code, 'name' => $name];
}
return $options;
}
private function getShippingCountryOptions(): array {
$options = [];
foreach (WC()->countries->get_shipping_countries() as $code => $name) {
$options[] = ['id' => $code, 'name' => $name];
}
return $options;
}
private function getOrderPaymentOptions(): array {
$gateways = WC()->payment_gateways()->get_available_payment_gateways();
$options = [];
foreach ($gateways as $gateway) {
if ($gateway instanceof WC_Payment_Gateway && $gateway->enabled === 'yes') {
$options[] = ['id' => $gateway->id, 'name' => $gateway->title];
}
}
return $options;
}
private function getOrderStatusOptions(): array {
$statuses = $this->wooCommerce->wcGetOrderStatuses();
$options = [];
foreach ($statuses as $id => $name) {
$options[] = [
// WooCommerce order statuses are internally saved with 'wc-' prefix:
// https://github.com/woocommerce/woocommerce/blob/9c58f198/plugins/woocommerce/includes/wc-order-functions.php#L98-L109
// However, when getting the status from the order object, it doesn't have the prefix.
// To make the status codes consistent, we remove the prefix here and only work with unprefixed statuses.
'id' => substr($id, 0, 3) === 'wc-' ? substr($id, 3) : $id,
'name' => $name,
];
}
return $options;
}
private function getCouponOptions(): array {
$coupons = $this->wordPress->getPosts([
'post_type' => 'shop_coupon',
'post_status' => 'publish',
'posts_per_page' => -1,
'orderby' => 'name',
'order' => 'asc',
]);
$options = [];
foreach ($coupons as $coupon) {
if ($coupon instanceof WP_Post) {
// phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
$options[] = ['id' => $coupon->post_title, 'name' => $coupon->post_title];
}
}
return $options;
}
private function previousOrderExists(WC_Order $order): bool {
$dateCreated = $order->get_date_created() ?? new DateTimeImmutable('now', $this->wordPress->wpTimezone());
$query = [
'date_created' => '<=' . $dateCreated->getTimestamp(),
'limit' => 2,
'return' => 'ids',
];
if ($order->get_customer_id() > 0) {
$query['customer_id'] = $order->get_customer_id();
} else {
$query['billing_email'] = $order->get_billing_email();
}
$orderIds = (array)$this->wooCommerce->wcGetOrders($query);
return count($orderIds) > 1 && min($orderIds) < $order->get_id();
}
/** @return WC_Product[] */
private function getProducts(WC_Order $order): array {
$products = [];
foreach ($order->get_items() as $item) {
if (!$item instanceof WC_Order_Item_Product) {
continue;
}
$product = $item->get_product();
if (!$product instanceof WC_Product) {
continue;
}
if (!$product->is_type('variation')) {
$products[] = $product;
continue;
}
$parentProduct = $this->wooCommerce->wcGetProduct($product->get_parent_id());
if (!$parentProduct instanceof WC_Product) {
continue;
}
$products[] = $parentProduct;
}
return array_unique($products);
}
private function getProductOptions(): array {
global $wpdb;
$products = $wpdb->get_results(
"
SELECT ID, post_title
FROM {$wpdb->posts}
WHERE post_type = 'product'
AND post_status = 'publish'
ORDER BY post_title ASC
",
ARRAY_A
);
return array_map(function ($product) {
/** @var array{ID:int, post_title:string} $product */
$id = $product['ID'];
$title = $product['post_title'];
return ['id' => (int)$id, 'name' => "$title (#$id)"];
}, (array)$products);
}
}
@@ -0,0 +1,69 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\WooCommerce\Fields;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\WordPress;
use WP_Error;
use WP_Term;
class TermOptionsBuilder {
/** @var WordPress */
private $wordPress;
/** @var array<string, array<array{id: int, name: string}>> */
private $cache = [];
public function __construct(
WordPress $wordPress
) {
$this->wordPress = $wordPress;
}
/** @return array<array{id: int, name: string}> */
public function getTermOptions(string $taxonomy): array {
if (!isset($this->cache[$taxonomy])) {
$this->cache[$taxonomy] = $this->fetchTermOptions($taxonomy);
}
return $this->cache[$taxonomy];
}
public function resetCache(): void {
$this->cache = [];
}
/** @return array<array{id: int, name: string}> */
private function fetchTermOptions(string $taxonomy): array {
/** @var WP_Term[]|WP_Error $terms */
$terms = $this->wordPress->getTerms(['taxonomy' => $taxonomy, 'hide_empty' => false, 'orderby' => 'name']);
if ($terms instanceof WP_Error) {
return [];
}
$termsMap = [];
foreach ($terms as $term) {
$termsMap[$term->parent][] = $term;
}
return $this->buildTermsList($termsMap);
}
/**
* @param array<int, array<WP_Term>> $termsMap
* @return array<array{id: int, name: string}>
*/
private function buildTermsList(array $termsMap, int $parentId = 0): array {
$list = [];
foreach ($termsMap[$parentId] ?? [] as $term) {
$id = $term->term_id; // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
$list[] = ['id' => $id, 'name' => $term->name];
if (isset($termsMap[$id])) {
foreach ($this->buildTermsList($termsMap, $id) as $child) {
$list[] = ['id' => $child['id'], 'name' => "$term->name | {$child['name']}"];
}
}
}
return $list;
}
}
@@ -0,0 +1,40 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\WooCommerce\Fields;
if (!defined('ABSPATH')) exit;
class TermParentsLoader {
/**
* @param int[] $termIds
* @return int[]
*/
public function getParentIds(array $termIds): array {
global $wpdb;
if (count($termIds) === 0) {
return [];
}
$result = $wpdb->get_col(
$wpdb->prepare(
"
SELECT DISTINCT tt.parent
FROM {$wpdb->term_taxonomy} AS tt
WHERE tt.parent != 0
AND tt.term_id IN (" . implode(',', array_fill(0, count($termIds), '%s')) . ")
",
$termIds
)
);
$parentIds = array_map('intval', $result);
if (count($parentIds) === 0) {
return [];
}
return array_values(
array_unique(
array_merge($parentIds, $this->getParentIds($parentIds))
)
);
}
}
@@ -0,0 +1,62 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\WooCommerce\Payloads;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Integration\Payload;
class AbandonedCartPayload implements Payload {
/** @var \WC_Customer */
private $customer;
/** @var \DateTimeImmutable */
private $lastActivityAt;
/** @var int[] */
private $productIds;
/**
* @param \WC_Customer $customer
* @param \DateTimeImmutable $lastActivityAt
* @param int[] $productIds
*/
public function __construct(
\WC_Customer $customer,
\DateTimeImmutable $lastActivityAt,
array $productIds
) {
$this->customer = $customer;
$this->lastActivityAt = $lastActivityAt;
$this->productIds = $productIds;
}
public function getLastActivityAt(): \DateTimeImmutable {
return $this->lastActivityAt;
}
public function getCustomer(): \WC_Customer {
return $this->customer;
}
/**
* @return int[]
*/
public function getProductIds(): array {
return $this->productIds;
}
public function getTotal(): float {
$total = 0.0;
foreach ($this->productIds as $productId) {
$product = wc_get_product($productId);
if ($product) {
$total += (float)$product->get_price();
}
}
return $total;
}
}
@@ -0,0 +1,144 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\WooCommerce\Payloads;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Integration\Payload;
use WC_Customer;
use WC_Order;
class CustomerPayload implements Payload {
private ?WC_Customer $customer;
private ?WC_Order $order;
public function __construct(
WC_Customer $customer = null,
WC_Order $order = null
) {
$this->customer = $customer;
$this->order = $order;
}
public function getBillingCompany(): ?string {
if ($this->isGuest()) {
return $this->order ? (string)$this->order->get_billing_company() : null;
}
return (string)$this->customer->get_billing_company();
}
public function getBillingPhone(): ?string {
if ($this->isGuest()) {
return $this->order ? (string)$this->order->get_billing_phone() : null;
}
return (string)$this->customer->get_billing_phone();
}
public function getBillingCity(): ?string {
if ($this->isGuest()) {
return $this->order ? (string)$this->order->get_billing_city() : null;
}
return (string)$this->customer->get_billing_city();
}
public function getBillingPostcode(): ?string {
if ($this->isGuest()) {
return $this->order ? (string)$this->order->get_billing_postcode() : null;
}
return (string)$this->customer->get_billing_postcode();
}
public function getBillingState(): ?string {
if ($this->isGuest()) {
return $this->order ? (string)$this->order->get_billing_state() : null;
}
return (string)$this->customer->get_billing_state();
}
public function getBillingCountry(): ?string {
if ($this->isGuest()) {
return $this->order ? (string)$this->order->get_billing_country() : null;
}
return (string)$this->customer->get_billing_country();
}
public function getShippingCompany(): ?string {
if ($this->isGuest()) {
return $this->order ? (string)$this->order->get_shipping_company() : null;
}
return (string)$this->customer->get_shipping_company();
}
public function getShippingPhone(): ?string {
if ($this->isGuest()) {
return $this->order ? (string)$this->order->get_shipping_phone() : null;
}
return (string)$this->customer->get_shipping_phone();
}
public function getShippingCity(): ?string {
if ($this->isGuest()) {
return $this->order ? (string)$this->order->get_shipping_city() : null;
}
return (string)$this->customer->get_shipping_city();
}
public function getShippingPostcode(): ?string {
if ($this->isGuest()) {
return $this->order ? (string)$this->order->get_shipping_postcode() : null;
}
return (string)$this->customer->get_shipping_postcode();
}
public function getShippingState(): ?string {
if ($this->isGuest()) {
return $this->order ? (string)$this->order->get_shipping_state() : null;
}
return (string)$this->customer->get_shipping_state();
}
public function getShippingCountry(): ?string {
if ($this->isGuest()) {
return $this->order ? (string)$this->order->get_shipping_country() : null;
}
return (string)$this->customer->get_shipping_country();
}
public function getTotalSpent(): float {
if ($this->isGuest()) {
return $this->order && $this->order->is_paid() ? (float)$this->order->get_total() : 0.0;
}
return (float)$this->customer->get_total_spent();
}
public function getAverageSpent(): float {
$totalSpent = $this->getTotalSpent();
$orderCount = $this->getOrderCount();
return $orderCount > 0 ? ($totalSpent / $orderCount) : 0.0;
}
public function getOrderCount(): int {
if ($this->isGuest()) {
return $this->order ? 1 : 0;
}
return (int)$this->customer->get_order_count();
}
public function getCustomer(): ?WC_Customer {
return $this->customer;
}
public function getOrder(): ?WC_Order {
return $this->order;
}
public function getId(): int {
return $this->customer ? $this->customer->get_id() : 0;
}
/** @phpstan-assert-if-true null $this->customer */
public function isGuest(): bool {
return $this->customer === null;
}
}
@@ -0,0 +1,32 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\WooCommerce\Payloads;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Integration\Payload;
class OrderPayload implements Payload {
/** @var \WC_Order */
private $order;
public function __construct(
\WC_Order $order
) {
$this->order = $order;
}
public function getOrder(): \WC_Order {
return $this->order;
}
public function getEmail(): string {
return $this->order->get_billing_email();
}
public function getId(): int {
return $this->order->get_id();
}
}
@@ -0,0 +1,33 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\WooCommerce\Payloads;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Integration\Payload;
class OrderStatusChangePayload implements Payload {
/** @var string */
private $from;
/** @var string */
private $to;
public function __construct(
string $from,
string $to
) {
$this->from = $from;
$this->to = $to;
}
public function getFrom(): string {
return $this->from;
}
public function getTo(): string {
return $this->to;
}
}
@@ -0,0 +1,28 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\WooCommerce\SubjectTransformers;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Subject;
use MailPoet\Automation\Engine\Integration\SubjectTransformer;
use MailPoet\Automation\Integrations\WooCommerce\Subjects\CustomerSubject;
use MailPoet\Automation\Integrations\WordPress\Subjects\UserSubject;
class WordPressUserSubjectToWooCommerceCustomerSubjectTransformer implements SubjectTransformer {
public function accepts(): string {
return UserSubject::KEY;
}
public function returns(): string {
return CustomerSubject::KEY;
}
public function transform(Subject $data): Subject {
if ($this->accepts() !== $data->getKey()) {
throw new \InvalidArgumentException('Invalid subject type');
}
return new Subject(CustomerSubject::KEY, ['customer_id' => $data->getArgs()['user_id']]);
}
}
@@ -0,0 +1,76 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\WooCommerce\Subjects;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Field;
use MailPoet\Automation\Engine\Data\Subject as SubjectData;
use MailPoet\Automation\Engine\Exceptions\InvalidStateException;
use MailPoet\Automation\Engine\Integration\Payload;
use MailPoet\Automation\Engine\Integration\Subject;
use MailPoet\Automation\Integrations\WooCommerce\Payloads\AbandonedCartPayload;
use MailPoet\Automation\Integrations\WooCommerce\WooCommerce;
use MailPoet\Validator\Builder;
use MailPoet\Validator\Schema\ObjectSchema;
/**
* @implements Subject<AbandonedCartPayload>
*/
class AbandonedCartSubject implements Subject {
const KEY = 'woocommerce:abandoned_cart';
/** @var WooCommerce */
private $woocommerceHelper;
public function __construct(
WooCommerce $woocommerceHelper
) {
$this->woocommerceHelper = $woocommerceHelper;
}
public function getKey(): string {
return self::KEY;
}
public function getName(): string {
// translators: automation subject (entity entering automation) title
return __('WooCommerce abandoned cart', 'mailpoet');
}
public function getArgsSchema(): ObjectSchema {
return Builder::object([
'user_id' => Builder::integer()->required(),
'last_activity_at' => Builder::string()->required()->default(30),
'product_ids' => Builder::array(Builder::integer())->required(),
]);
}
public function getPayload(SubjectData $subjectData): Payload {
if (!$this->woocommerceHelper->isWooCommerceActive()) {
throw InvalidStateException::create()->withMessage('WooCommerce is not active');
}
$lastActivityAt = \DateTimeImmutable::createFromFormat(\DateTime::W3C, $subjectData->getArgs()['last_activity_at']);
if (!$lastActivityAt) {
throw InvalidStateException::create()->withMessage('Invalid abandoned cart time');
}
$customer = new \WC_Customer($subjectData->getArgs()['user_id']);
return new AbandonedCartPayload($customer, $lastActivityAt, $subjectData->getArgs()['product_ids']);
}
public function getFields(): array {
return [
new Field(
'woocommerce:cart:cart-total',
Field::TYPE_NUMBER,
__('Cart total', 'mailpoet'),
function (AbandonedCartPayload $payload) {
return $payload->getTotal();
}
),
];
}
}
@@ -0,0 +1,75 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\WooCommerce\Subjects;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Field;
use MailPoet\Automation\Engine\Data\Subject as SubjectData;
use MailPoet\Automation\Engine\Integration\Payload;
use MailPoet\Automation\Engine\Integration\Subject;
use MailPoet\Automation\Integrations\WooCommerce\Fields\CustomerFieldsFactory;
use MailPoet\Automation\Integrations\WooCommerce\Payloads\CustomerPayload;
use MailPoet\NotFoundException;
use MailPoet\Validator\Builder;
use MailPoet\Validator\Schema\ObjectSchema;
use WC_Customer;
use WC_Order;
/**
* @implements Subject<CustomerPayload>
*/
class CustomerSubject implements Subject {
const KEY = 'woocommerce:customer';
/** @var CustomerFieldsFactory */
private $customerFieldsFactory;
public function __construct(
CustomerFieldsFactory $customerFieldsFactory
) {
$this->customerFieldsFactory = $customerFieldsFactory;
}
public function getName(): string {
// translators: automation subject (entity entering automation) title
return __('WooCommerce customer', 'mailpoet');
}
public function getKey(): string {
return self::KEY;
}
public function getArgsSchema(): ObjectSchema {
return Builder::object([
'customer_id' => Builder::integer()->required(),
]);
}
public function getPayload(SubjectData $subjectData): Payload {
$args = $subjectData->getArgs();
$customerId = isset($args['customer_id']) ? (int)$args['customer_id'] : null;
$orderId = isset($args['order_id']) ? (int)$args['order_id'] : null;
$order = $orderId === null ? null : wc_get_order($orderId);
$order = $order instanceof WC_Order ? $order : null;
if (!$customerId) {
return new CustomerPayload(null, $order);
}
$customer = new WC_Customer($customerId);
if (!$customer->get_id()) {
// translators: %d is the ID of the customer.
throw NotFoundException::create()->withMessage(sprintf(__("Customer with ID '%d' not found.", 'mailpoet'), $customerId));
}
return new CustomerPayload($customer, $order);
}
/** @return Field[] */
public function getFields(): array {
return $this->customerFieldsFactory->getFields();
}
}
@@ -0,0 +1,48 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\WooCommerce\Subjects;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Subject as SubjectData;
use MailPoet\Automation\Engine\Integration\Payload;
use MailPoet\Automation\Engine\Integration\Subject;
use MailPoet\Automation\Integrations\WooCommerce\Payloads\OrderStatusChangePayload;
use MailPoet\Validator\Builder;
use MailPoet\Validator\Schema\ObjectSchema;
/**
* @implements Subject<OrderStatusChangePayload>
*/
class OrderStatusChangeSubject implements Subject {
const KEY = 'woocommerce:order-status-changed';
public function getName(): string {
// translators: automation subject (entity entering automation) title
return __('WooCommerce order status change', 'mailpoet');
}
public function getArgsSchema(): ObjectSchema {
return Builder::object([
'from' => Builder::string()->required(),
'to' => Builder::string()->required(),
]);
}
public function getPayload(SubjectData $subjectData): Payload {
$from = $subjectData->getArgs()['from'];
$to = $subjectData->getArgs()['to'];
return new OrderStatusChangePayload($from, $to);
}
public function getKey(): string {
return self::KEY;
}
public function getFields(): array {
return [];
}
}
@@ -0,0 +1,67 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\WooCommerce\Subjects;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Subject as SubjectData;
use MailPoet\Automation\Engine\Integration\Payload;
use MailPoet\Automation\Engine\Integration\Subject;
use MailPoet\Automation\Integrations\WooCommerce\Fields\OrderFieldsFactory;
use MailPoet\Automation\Integrations\WooCommerce\Payloads\OrderPayload;
use MailPoet\Automation\Integrations\WooCommerce\WooCommerce;
use MailPoet\NotFoundException;
use MailPoet\Validator\Builder;
use MailPoet\Validator\Schema\ObjectSchema;
/**
* @implements Subject<OrderPayload>
*/
class OrderSubject implements Subject {
const KEY = 'woocommerce:order';
/** @var WooCommerce */
private $woocommerce;
/** @var OrderFieldsFactory */
private $orderFieldsFactory;
public function __construct(
OrderFieldsFactory $orderFieldsFactory,
WooCommerce $woocommerce
) {
$this->woocommerce = $woocommerce;
$this->orderFieldsFactory = $orderFieldsFactory;
}
public function getName(): string {
// translators: automation subject (entity entering automation) title
return __('WooCommerce order', 'mailpoet');
}
public function getArgsSchema(): ObjectSchema {
return Builder::object([
'order_id' => Builder::integer()->required(),
]);
}
public function getPayload(SubjectData $subjectData): Payload {
$id = $subjectData->getArgs()['order_id'];
$order = $this->woocommerce->wcGetOrder($id);
if (!$order instanceof \WC_Order) {
// translators: %d is the order ID.
throw NotFoundException::create()->withMessage(sprintf(__("Order with ID '%d' not found.", 'mailpoet'), $id));
}
return new OrderPayload($order);
}
public function getKey(): string {
return self::KEY;
}
public function getFields(): array {
return $this->orderFieldsFactory->getFields();
}
}
@@ -0,0 +1,165 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\WooCommerce\Triggers\AbandonedCart;
if (!defined('ABSPATH')) exit;
use MailPoet\AutomaticEmails\WooCommerce\Events\AbandonedCart;
use MailPoet\Automation\Engine\Data\Automation;
use MailPoet\Automation\Engine\Exceptions\InvalidStateException;
use MailPoet\Automation\Engine\Storage\AutomationStorage;
use MailPoet\Automation\Engine\WordPress;
use MailPoet\Cron\Workers\Automations\AbandonedCartWorker;
use MailPoet\Entities\ScheduledTaskEntity;
use MailPoet\Entities\ScheduledTaskSubscriberEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Newsletter\Sending\ScheduledTasksRepository;
use MailPoet\Newsletter\Sending\ScheduledTaskSubscribersRepository;
use MailPoetVendor\Carbon\Carbon;
class AbandonedCartHandler {
const TASK_ABANDONED_CART = 'automation_abandoned_cart';
/** @var WordPress */
private $wp;
/** @var ScheduledTasksRepository */
private $tasksRepository;
/** @var ScheduledTaskSubscribersRepository */
private $taskSubscribersRepository;
/** @var AutomationStorage */
private $automationStorage;
public function __construct(
WordPress $wp,
ScheduledTasksRepository $tasksRepository,
ScheduledTaskSubscribersRepository $taskSubscribersRepository,
AutomationStorage $automationStorage
) {
$this->wp = $wp;
$this->tasksRepository = $tasksRepository;
$this->taskSubscribersRepository = $taskSubscribersRepository;
$this->automationStorage = $automationStorage;
}
public function registerHooks(): void {
$this->wp->addAction(
AbandonedCart::HOOK_SCHEDULE,
[
$this,
'schedule',
],
10,
2
);
$this->wp->addAction(
AbandonedCart::HOOK_RE_SCHEDULE,
[
$this,
'reschedule',
]
);
$this->wp->addAction(
AbandonedCart::HOOK_CANCEL,
[
$this,
'cancel',
]
);
}
/**
* @param SubscriberEntity $subscriber
* @param int[] $productIds
* @return void
*/
public function schedule(SubscriberEntity $subscriber, array $productIds) {
$abandonedCartAutomations = $this->automationStorage->getActiveAutomationsByTriggerKey(AbandonedCartTrigger::KEY);
$this->cancel($subscriber);
array_map(
function (Automation $automation) use ($subscriber, $productIds) {
$this->scheduleForSingleAutomation($subscriber, $productIds, $automation);
},
$abandonedCartAutomations
);
}
/**
* @param SubscriberEntity $subscriber
* @param int[] $productIds
* @param Automation $automation
* @return void
* @throws InvalidStateException
*/
private function scheduleForSingleAutomation(SubscriberEntity $subscriber, array $productIds, Automation $automation) {
$trigger = $automation->getTrigger(AbandonedCartTrigger::KEY);
if (!$trigger) {
throw new InvalidStateException(sprintf('Abandoned cart trigger is missing from automation %d', $automation->getId()));
}
$wait = $trigger->getArgs()['wait'] * 60;
$scheduledAt = Carbon::now()->millisecond(0)->addSeconds($wait);
$task = new ScheduledTaskEntity();
$task->setType(AbandonedCartWorker::TASK_TYPE);
$lastActivity = Carbon::now()->millisecond(0);
$task->setCreatedAt($lastActivity);
$task->setPriority(ScheduledTaskEntity::PRIORITY_MEDIUM);
$task->setMeta([
'product_ids' => $productIds,
'automation_id' => $automation->getId(),
'automation_version' => $automation->getVersionId(),
]);
$task->setStatus(ScheduledTaskEntity::STATUS_SCHEDULED);
$task->setScheduledAt($scheduledAt);
$this->tasksRepository->persist($task);
$this->tasksRepository->flush();
$taskSubscriber = new ScheduledTaskSubscriberEntity($task, $subscriber);
$task->getSubscribers()->add($taskSubscriber);
$this->taskSubscribersRepository->persist($taskSubscriber);
$this->taskSubscribersRepository->flush();
}
public function reschedule(SubscriberEntity $subscriber): void {
$tasks = $this->tasksRepository->findByTypeAndSubscriber(AbandonedCartWorker::TASK_TYPE, $subscriber);
if (!$tasks) {
return;
}
$this->cancel($subscriber);
foreach ($tasks as $task) {
$meta = $task->getMeta();
$automation = isset($meta['automation_id']) ? $this->automationStorage->getAutomation((int)$meta['automation_id']) : null;
if (!$automation) {
continue;
}
$this->scheduleForSingleAutomation($subscriber, $meta['product_ids'] ?? [], $automation);
}
}
public function cancel(SubscriberEntity $subscriber): void {
$existingTasks = $this->tasksRepository->findByTypeAndSubscriber(AbandonedCartWorker::TASK_TYPE, $subscriber);
if (!$existingTasks) {
return;
}
foreach ($existingTasks as $task) {
if ($task->getStatus() !== ScheduledTaskEntity::STATUS_SCHEDULED) {
continue;
}
foreach ($task->getSubscribers() as $taskSubscriber) {
$this->taskSubscribersRepository->remove($taskSubscriber);
}
$this->tasksRepository->remove($task);
}
$this->tasksRepository->flush();
}
}
@@ -0,0 +1,139 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\WooCommerce\Triggers\AbandonedCart;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\StepRunArgs;
use MailPoet\Automation\Engine\Data\StepValidationArgs;
use MailPoet\Automation\Engine\Data\Subject;
use MailPoet\Automation\Engine\Hooks;
use MailPoet\Automation\Engine\Integration\Trigger;
use MailPoet\Automation\Engine\Storage\AutomationRunStorage;
use MailPoet\Automation\Engine\WordPress;
use MailPoet\Automation\Integrations\MailPoet\Subjects\SegmentSubject;
use MailPoet\Automation\Integrations\MailPoet\Subjects\SubscriberSubject;
use MailPoet\Automation\Integrations\WooCommerce\Payloads\AbandonedCartPayload;
use MailPoet\Automation\Integrations\WooCommerce\Subjects\AbandonedCartSubject;
use MailPoet\Cron\Workers\Automations\AbandonedCartWorker;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Segments\SegmentsRepository;
use MailPoet\Validator\Builder;
use MailPoet\Validator\Schema\ObjectSchema;
use MailPoetVendor\Carbon\Carbon;
class AbandonedCartTrigger implements Trigger {
const KEY = 'woocommerce:abandoned-cart';
/** @var AbandonedCartHandler */
private $abandonedCartHandler;
/** @var WordPress */
private $wp;
/** @var SegmentsRepository */
private $segmentsRepository;
/** @var AutomationRunStorage */
private $automationRunStorage;
public function __construct(
AbandonedCartHandler $abandonedCartHandler,
AutomationRunStorage $automationRunStorage,
SegmentsRepository $segmentsRepository,
WordPress $wp
) {
$this->abandonedCartHandler = $abandonedCartHandler;
$this->automationRunStorage = $automationRunStorage;
$this->segmentsRepository = $segmentsRepository;
$this->wp = $wp;
}
public function getKey(): string {
return self::KEY;
}
public function getName(): string {
// translators: automation trigger title
return __('User abandons cart', 'mailpoet');
}
public function getSubjectKeys(): array {
return [
SubscriberSubject::KEY,
AbandonedCartSubject::KEY,
SegmentSubject::KEY,
];
}
public function registerHooks(): void {
$this->abandonedCartHandler->registerHooks();
$this->wp->addAction(
AbandonedCartWorker::ACTION,
[
$this,
'handle',
],
10,
4
);
}
/**
* @param SubscriberEntity $subscriber
* @param int[] $productIds
* @param \DateTime $lastAcivityAt
* @return void
*/
public function handle(
SubscriberEntity $subscriber,
array $productIds,
\DateTime $lastAcivityAt
): void {
if (!$productIds) {
return;
}
$wooSegment = $this->segmentsRepository->getWooCommerceSegment();
$subjects = [
new Subject(AbandonedCartSubject::KEY, ['user_id' => $subscriber->getWpUserId(), 'last_activity_at' => $lastAcivityAt->format(\DateTime::W3C), 'product_ids' => $productIds]),
new Subject(SubscriberSubject::KEY, ['subscriber_id' => $subscriber->getId()]),
new Subject(SegmentSubject::KEY, ['segment_id' => $wooSegment->getId()]),
];
$this->wp->doAction(Hooks::TRIGGER, $this, $subjects);
}
public function isTriggeredBy(StepRunArgs $args): bool {
$abandonedCartSubject = $args->getSingleSubjectEntryByClass(AbandonedCartSubject::class);
$abandonedCartPayload = $args->getSinglePayloadByClass(AbandonedCartPayload::class);
$lastActivityAt = $abandonedCartPayload->getLastActivityAt();
$compareDate = Carbon::now()->millisecond(0)->subMinutes($args->getStep()->getArgs()['wait']);
if ($lastActivityAt > $compareDate) {
return false;
}
$automation = $args->getAutomation();
$existingRuns = $this->automationRunStorage->getCountByAutomationAndSubject(
$automation,
$abandonedCartSubject->getSubjectData()
);
if ($existingRuns) {
return false;
}
return true;
}
public function getArgsSchema(): ObjectSchema {
return Builder::object([
'wait' => Builder::integer()->required()->minimum(1)->default(30),
]);
}
public function validate(StepValidationArgs $args): void {
}
}
@@ -0,0 +1,158 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\WooCommerce\Triggers;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Control\FilterHandler;
use MailPoet\Automation\Engine\Data\Field;
use MailPoet\Automation\Engine\Data\Filter;
use MailPoet\Automation\Engine\Data\FilterGroup;
use MailPoet\Automation\Engine\Data\StepRunArgs;
use MailPoet\Automation\Engine\Data\StepValidationArgs;
use MailPoet\Automation\Engine\Data\Subject;
use MailPoet\Automation\Engine\Hooks;
use MailPoet\Automation\Engine\Integration\Trigger;
use MailPoet\Automation\Engine\Storage\AutomationRunStorage;
use MailPoet\Automation\Engine\WordPress;
use MailPoet\Automation\Integrations\Core\Filters\EnumArrayFilter;
use MailPoet\Automation\Integrations\Core\Filters\EnumFilter;
use MailPoet\Automation\Integrations\WooCommerce\Subjects\CustomerSubject;
use MailPoet\Automation\Integrations\WooCommerce\Subjects\OrderStatusChangeSubject;
use MailPoet\Automation\Integrations\WooCommerce\Subjects\OrderSubject;
use MailPoet\Validator\Builder;
use MailPoet\Validator\Schema\ObjectSchema;
use MailPoet\WooCommerce\Helper as WooCommerceHelper;
class BuysAProductTrigger implements Trigger {
public const KEY = 'woocommerce:buys-a-product';
/** @var WordPress */
private $wp;
/** @var WooCommerceHelper */
private $wc;
/** @var AutomationRunStorage */
private $automationRunStorage;
/** @var FilterHandler */
private $filterHandler;
public function __construct(
WordPress $wp,
WooCommerceHelper $wc,
AutomationRunStorage $automationRunStorage,
FilterHandler $filterHandler
) {
$this->wp = $wp;
$this->wc = $wc;
$this->automationRunStorage = $automationRunStorage;
$this->filterHandler = $filterHandler;
}
public function getKey(): string {
return self::KEY;
}
public function getName(): string {
// translators: automation trigger title
return __('Customer buys a product', 'mailpoet');
}
public function getArgsSchema(): ObjectSchema {
return Builder::object([
'product_ids' => Builder::array(
Builder::integer()
)->minItems(1)->required(),
'to' => Builder::string()->required()->default('wc-completed'),
]);
}
public function getSubjectKeys(): array {
return [
OrderSubject::KEY,
OrderStatusChangeSubject::KEY,
CustomerSubject::KEY,
];
}
public function validate(StepValidationArgs $args): void {
}
public function registerHooks(): void {
$this->wp->addAction(
'woocommerce_order_status_changed',
[
$this,
'handle',
],
10,
3
);
}
/**
* @param int $orderId
* @param string $from
* @param string $to
* @return void
*/
public function handle($orderId, $from, $to): void {
$order = $this->wc->wcGetOrder($orderId);
if (!$order) {
return;
}
$this->wp->doAction(Hooks::TRIGGER, $this, [
new Subject(OrderSubject::KEY, ['order_id' => $orderId]),
new Subject(CustomerSubject::KEY, ['customer_id' => $order->get_customer_id(), 'order_id' => $orderId]),
new Subject(OrderStatusChangeSubject::KEY, ['from' => $from, 'to' => $to]),
]);
}
public function isTriggeredBy(StepRunArgs $args): bool {
//Trigger the run only once.
$orderSubjectData = $args->getSingleSubjectEntryByClass(OrderSubject::class)->getSubjectData();
if ($this->automationRunStorage->getCountByAutomationAndSubject($args->getAutomation(), $orderSubjectData) > 0) {
return false;
}
$group = new FilterGroup(
'',
FilterGroup::OPERATOR_AND,
$this->getFilters($args)
);
return $this->filterHandler->matchesGroup($group, $args);
}
protected function getFilters(StepRunArgs $args): array {
$triggerArgs = $args->getStep()->getArgs();
$filters = [
Filter::fromArray([
'id' => '',
'field_type' => Field::TYPE_ENUM_ARRAY,
'field_key' => 'woocommerce:order:products',
'condition' => EnumArrayFilter::CONDITION_MATCHES_ANY_OF,
'args' => [
'value' => $triggerArgs['product_ids'] ?? [],
],
]),
];
$status = str_replace('wc-', '', $triggerArgs['to'] ?? 'completed');
if ($status === 'any') {
return $filters;
}
$filters[] = Filter::fromArray([
'id' => '',
'field_type' => Field::TYPE_ENUM,
'field_key' => 'woocommerce:order:status',
'condition' => EnumFilter::IS_ANY_OF,
'args' => [
'value' => [$status],
],
]);
return $filters;
}
}
@@ -0,0 +1,66 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\WooCommerce\Triggers;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Field;
use MailPoet\Automation\Engine\Data\Filter;
use MailPoet\Automation\Engine\Data\StepRunArgs;
use MailPoet\Automation\Integrations\Core\Filters\EnumArrayFilter;
use MailPoet\Automation\Integrations\Core\Filters\EnumFilter;
use MailPoet\Validator\Builder;
use MailPoet\Validator\Schema\ObjectSchema;
class BuysFromACategoryTrigger extends BuysAProductTrigger {
const KEY = 'woocommerce:buys-from-a-category';
public function getKey(): string {
return self::KEY;
}
public function getArgsSchema(): ObjectSchema {
return Builder::object([
'category_ids' => Builder::array(
Builder::integer()
)->minItems(1)->required(),
'to' => Builder::string()->required()->default('wc-completed'),
]);
}
public function getName(): string {
// translators: automation trigger title
return __('Customer buys from a category', 'mailpoet');
}
protected function getFilters(StepRunArgs $args): array {
$triggerArgs = $args->getStep()->getArgs();
$filters = [
Filter::fromArray([
'id' => '',
'field_type' => Field::TYPE_ENUM_ARRAY,
'field_key' => 'woocommerce:order:categories',
'condition' => EnumArrayFilter::CONDITION_MATCHES_ANY_OF,
'args' => [
'value' => $triggerArgs['category_ids'] ?? [],
],
]),
];
$status = str_replace('wc-', '', $triggerArgs['to'] ?? 'completed');
if ($status === 'any') {
return $filters;
}
$filters[] = Filter::fromArray([
'id' => '',
'field_type' => Field::TYPE_ENUM,
'field_key' => 'woocommerce:order:status',
'condition' => EnumFilter::IS_ANY_OF,
'args' => [
'value' => [$status],
],
]);
return $filters;
}
}
@@ -0,0 +1,68 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\WooCommerce\Triggers;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Field;
use MailPoet\Automation\Engine\Data\Filter;
use MailPoet\Automation\Engine\Data\StepRunArgs;
use MailPoet\Automation\Integrations\Core\Filters\EnumArrayFilter;
use MailPoet\Automation\Integrations\Core\Filters\EnumFilter;
use MailPoet\Validator\Builder;
use MailPoet\Validator\Schema\ObjectSchema;
class BuysFromATagTrigger extends BuysAProductTrigger {
const KEY = 'woocommerce:buys-from-a-tag';
public function getKey(): string {
return self::KEY;
}
public function getName(): string {
// translators: automation trigger title
return __('Customer buys from a tag', 'mailpoet');
}
public function getArgsSchema(): ObjectSchema {
return Builder::object([
'tag_ids' => Builder::array(
Builder::integer()
)->minItems(1)->required(),
'to' => Builder::string()->required()->default('wc-completed'),
]);
}
protected function getFilters(StepRunArgs $args): array {
$triggerArgs = $args->getStep()->getArgs();
$filters = [
Filter::fromArray([
'id' => '',
'field_type' => Field::TYPE_ENUM_ARRAY,
'field_key' => 'woocommerce:order:tags',
'condition' => EnumArrayFilter::CONDITION_MATCHES_ANY_OF,
'args' => [
'value' => $triggerArgs['tag_ids'] ?? [],
],
]),
];
$status = str_replace('wc-', '', $triggerArgs['to'] ?? 'completed');
if ($status === 'any') {
return $filters;
}
$filters[] = Filter::fromArray([
'id' => '',
'field_type' => Field::TYPE_ENUM,
'field_key' => 'woocommerce:order:status',
'condition' => EnumFilter::IS_ANY_OF,
'args' => [
'value' => [$status],
],
]);
return $filters;
}
}
@@ -0,0 +1,32 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\WooCommerce\Triggers\Orders;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\StepRunArgs;
use MailPoet\Automation\Integrations\WooCommerce\Payloads\OrderStatusChangePayload;
use MailPoet\Validator\Builder;
use MailPoet\Validator\Schema\ObjectSchema;
class OrderCancelledTrigger extends OrderStatusChangedTrigger {
public function getKey(): string {
return 'woocommerce:order-cancelled';
}
public function getName(): string {
// translators: automation trigger title
return __('Order cancelled', 'mailpoet');
}
public function isTriggeredBy(StepRunArgs $args): bool {
/** @var OrderStatusChangePayload $orderPayload */
$orderPayload = $args->getSinglePayloadByClass(OrderStatusChangePayload::class);
return $orderPayload->getTo() === 'cancelled';
}
public function getArgsSchema(): ObjectSchema {
return Builder::object();
}
}
@@ -0,0 +1,31 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\WooCommerce\Triggers\Orders;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\StepRunArgs;
use MailPoet\Automation\Integrations\WooCommerce\Payloads\OrderStatusChangePayload;
use MailPoet\Validator\Builder;
use MailPoet\Validator\Schema\ObjectSchema;
class OrderCompletedTrigger extends OrderStatusChangedTrigger {
public function getKey(): string {
return 'woocommerce:order-completed';
}
public function getName(): string {
// translators: automation trigger title
return __('Order completed', 'mailpoet');
}
public function isTriggeredBy(StepRunArgs $args): bool {
$orderPayload = $args->getSinglePayloadByClass(OrderStatusChangePayload::class);
return $orderPayload->getTo() === 'completed';
}
public function getArgsSchema(): ObjectSchema {
return Builder::object();
}
}
@@ -0,0 +1,108 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\WooCommerce\Triggers\Orders;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\StepRunArgs;
use MailPoet\Automation\Engine\Data\StepValidationArgs;
use MailPoet\Automation\Engine\Data\Subject;
use MailPoet\Automation\Engine\Hooks;
use MailPoet\Automation\Engine\Integration\Trigger;
use MailPoet\Automation\Engine\WordPress;
use MailPoet\Automation\Integrations\WooCommerce\Subjects\CustomerSubject;
use MailPoet\Automation\Integrations\WooCommerce\Subjects\OrderSubject;
use MailPoet\Validator\Builder;
use MailPoet\Validator\Schema\ObjectSchema;
class OrderCreatedTrigger implements Trigger {
/** @var WordPress */
private $wp;
/** @var int[] */
private $processedOrders = [];
public function __construct(
WordPress $wp
) {
$this->wp = $wp;
}
public function getKey(): string {
return 'woocommerce:order-created';
}
public function getName(): string {
// translators: automation trigger title
return __('Order created', 'mailpoet');
}
public function registerHooks(): void {
$this->wp->addAction(
'woocommerce_new_order',
[
$this,
'handleCreate',
],
10,
2
);
}
/**
* @param int $orderId
* @param \WC_Order $order
* @return void
*/
public function handleCreate($orderId, $order) {
if (in_array($orderId, $this->processedOrders)) {
return;
}
/**
* Creating an order via wc_create_order() does not yet set crucial information like the customer's email address.
* It just creates the order object and saves it to the database. We need therefore to wait for the order to have at least the billing address stored.
**/
if (!$order->get_billing_email()) {
add_action(
'woocommerce_after_order_object_save',
function($order) use ($orderId) {
if ((int)$orderId !== (int)$order->get_id()) {
return;
}
$this->handleCreate($order->get_id(), $order);
}
);
return;
}
$this->processedOrders[] = $orderId;
$this->wp->doAction(Hooks::TRIGGER, $this, [
new Subject(OrderSubject::KEY, ['order_id' => $order->get_id()]),
new Subject(CustomerSubject::KEY, ['customer_id' => $order->get_customer_id(), 'order_id' => $order->get_id()]),
]);
}
public function isTriggeredBy(StepRunArgs $args): bool {
/**
* If we come to this point we always want to trigger the automation.
* The evaluation whether this is a "new" order is done in the handleCreate() method.
*/
return true;
}
public function getArgsSchema(): ObjectSchema {
return Builder::object();
}
public function getSubjectKeys(): array {
return [
OrderSubject::KEY,
CustomerSubject::KEY,
];
}
public function validate(StepValidationArgs $args): void {
}
}
@@ -0,0 +1,104 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\WooCommerce\Triggers\Orders;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\StepRunArgs;
use MailPoet\Automation\Engine\Data\StepValidationArgs;
use MailPoet\Automation\Engine\Data\Subject;
use MailPoet\Automation\Engine\Hooks;
use MailPoet\Automation\Engine\Integration\Trigger;
use MailPoet\Automation\Engine\WordPress;
use MailPoet\Automation\Integrations\WooCommerce\Payloads\OrderStatusChangePayload;
use MailPoet\Automation\Integrations\WooCommerce\Subjects\CustomerSubject;
use MailPoet\Automation\Integrations\WooCommerce\Subjects\OrderStatusChangeSubject;
use MailPoet\Automation\Integrations\WooCommerce\Subjects\OrderSubject;
use MailPoet\Automation\Integrations\WooCommerce\WooCommerce;
use MailPoet\Validator\Builder;
use MailPoet\Validator\Schema\ObjectSchema;
class OrderStatusChangedTrigger implements Trigger {
/** @var WordPress */
protected $wp;
/** @var WooCommerce */
protected $woocommerce;
public function __construct(
WordPress $wp,
WooCommerce $woocommerceHelper
) {
$this->wp = $wp;
$this->woocommerce = $woocommerceHelper;
}
public function getKey(): string {
return 'woocommerce:order-status-changed';
}
public function getName(): string {
// translators: automation trigger title
return __('Order status changed', 'mailpoet');
}
public function getArgsSchema(): ObjectSchema {
return Builder::object([
'from' => Builder::string()->required()->default('any'),
'to' => Builder::string()->required()->default('wc-completed'),
]);
}
public function getSubjectKeys(): array {
return [
OrderSubject::KEY,
OrderStatusChangeSubject::KEY,
CustomerSubject::KEY,
];
}
public function validate(StepValidationArgs $args): void {
}
public function registerHooks(): void {
$this->wp->addAction(
'woocommerce_order_status_changed',
[
$this,
'handle',
],
10,
3
);
}
public function handle(int $orderId, string $oldStatus, string $newStatus): void {
$order = $this->woocommerce->wcGetOrder($orderId);
if (!$order instanceof \WC_Order) {
return;
}
$this->wp->doAction(Hooks::TRIGGER, $this, [
new Subject(OrderStatusChangeSubject::KEY, ['from' => $oldStatus, 'to' => $newStatus]),
new Subject(OrderSubject::KEY, ['order_id' => $order->get_id()]),
new Subject(CustomerSubject::KEY, ['customer_id' => $order->get_customer_id(), 'order_id' => $order->get_id()]),
]);
}
public function isTriggeredBy(StepRunArgs $args): bool {
/** @var OrderStatusChangePayload $orderPayload */
$orderPayload = $args->getSinglePayloadByClass(OrderStatusChangePayload::class);
$triggerArgs = $args->getStep()->getArgs();
$configuredFrom = $triggerArgs['from'] ? str_replace('wc-', '', $triggerArgs['from']) : null;
$configuredTo = $triggerArgs['to'] ? str_replace('wc-', '', $triggerArgs['to']) : null;
if ($configuredFrom !== 'any' && $orderPayload->getFrom() !== $configuredFrom) {
return false;
}
if ($configuredTo !== 'any' && $orderPayload->getTo() !== $configuredTo) {
return false;
}
return true;
}
}
@@ -0,0 +1,62 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\WooCommerce;
if (!defined('ABSPATH')) exit;
use Automattic\WooCommerce\Utilities\OrderUtil;
use stdClass;
use WC_Order;
class WooCommerce {
public function isWooCommerceActive(): bool {
return class_exists('WooCommerce');
}
public function wcGetIsPaidStatuses(): array {
return wc_get_is_paid_statuses();
}
/**
* @return array<string, string>
*/
public function wcGetOrderStatuses(): array {
return wc_get_order_statuses();
}
public function isWooCommerceCustomOrdersTableEnabled(): bool {
return $this->isWooCommerceActive()
&& method_exists(OrderUtil::class, 'custom_orders_table_usage_is_enabled')
&& OrderUtil::custom_orders_table_usage_is_enabled();
}
/** @return WC_Order[]|stdClass */
public function wcGetOrders(array $args = []) {
return wc_get_orders($args);
}
/**
* @param mixed $product
* @return \WC_Product|null|false
*/
public function wcGetProduct($product) {
return wc_get_product($product);
}
/**
* @param int|bool $order
* @return bool|\WC_Order|\WC_Order_Refund
*/
public function wcGetOrder($order = false) {
return wc_get_order($order);
}
public function wcGetOrderStatusName(string $status): string {
return wc_get_order_status_name($status);
}
public function wcReviewRatingsEnabled(): bool {
return wc_review_ratings_enabled();
}
}
@@ -0,0 +1,126 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\WooCommerce;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Registry;
use MailPoet\Automation\Integrations\WooCommerce\Subjects\AbandonedCartSubject;
use MailPoet\Automation\Integrations\WooCommerce\Subjects\CustomerSubject;
use MailPoet\Automation\Integrations\WooCommerce\Subjects\OrderStatusChangeSubject;
use MailPoet\Automation\Integrations\WooCommerce\Subjects\OrderSubject;
use MailPoet\Automation\Integrations\WooCommerce\SubjectTransformers\WordPressUserSubjectToWooCommerceCustomerSubjectTransformer;
use MailPoet\Automation\Integrations\WooCommerce\Triggers\AbandonedCart\AbandonedCartTrigger;
use MailPoet\Automation\Integrations\WooCommerce\Triggers\BuysAProductTrigger;
use MailPoet\Automation\Integrations\WooCommerce\Triggers\BuysFromACategoryTrigger;
use MailPoet\Automation\Integrations\WooCommerce\Triggers\BuysFromATagTrigger;
use MailPoet\Automation\Integrations\WooCommerce\Triggers\Orders\OrderCancelledTrigger;
use MailPoet\Automation\Integrations\WooCommerce\Triggers\Orders\OrderCompletedTrigger;
use MailPoet\Automation\Integrations\WooCommerce\Triggers\Orders\OrderCreatedTrigger;
use MailPoet\Automation\Integrations\WooCommerce\Triggers\Orders\OrderStatusChangedTrigger;
class WooCommerceIntegration {
/** @var OrderStatusChangedTrigger */
private $orderStatusChangedTrigger;
/** @var OrderCreatedTrigger */
private $orderCreatedTrigger;
/** @var OrderCompletedTrigger */
private $orderCompletedTrigger;
private $orderCancelledTrigger;
/** @var AbandonedCartTrigger */
private $abandonedCartTrigger;
/** @var BuysAProductTrigger */
private $buysAProductTrigger;
/** @var BuysFromATagTrigger */
private $buysFromATagTrigger;
/** @var BuysFromACategoryTrigger */
private $buysFromACategoryTrigger;
/** @var AbandonedCartSubject */
private $abandonedCartSubject;
/** @var OrderStatusChangeSubject */
private $orderStatusChangeSubject;
/** @var OrderSubject */
private $orderSubject;
/** @var CustomerSubject */
private $customerSubject;
/** @var ContextFactory */
private $contextFactory;
/** @var WordPressUserSubjectToWooCommerceCustomerSubjectTransformer */
private $wordPressUserToWooCommerceCustomerTransformer;
/** @var WooCommerce */
private $wooCommerce;
public function __construct(
OrderStatusChangedTrigger $orderStatusChangedTrigger,
OrderCreatedTrigger $orderCreatedTrigger,
OrderCompletedTrigger $orderCompletedTrigger,
OrderCancelledTrigger $orderCancelledTrigger,
AbandonedCartTrigger $abandonedCartTrigger,
BuysAProductTrigger $buysAProductTrigger,
BuysFromACategoryTrigger $buysFromACategoryTrigger,
BuysFromATagTrigger $buysFromATagTrigger,
AbandonedCartSubject $abandonedCartSubject,
OrderStatusChangeSubject $orderStatusChangeSubject,
OrderSubject $orderSubject,
CustomerSubject $customerSubject,
ContextFactory $contextFactory,
WordPressUserSubjectToWooCommerceCustomerSubjectTransformer $wordPressUserToWooCommerceCustomerTransformer,
WooCommerce $wooCommerce
) {
$this->orderStatusChangedTrigger = $orderStatusChangedTrigger;
$this->orderCreatedTrigger = $orderCreatedTrigger;
$this->orderCompletedTrigger = $orderCompletedTrigger;
$this->orderCancelledTrigger = $orderCancelledTrigger;
$this->abandonedCartTrigger = $abandonedCartTrigger;
$this->buysAProductTrigger = $buysAProductTrigger;
$this->buysFromACategoryTrigger = $buysFromACategoryTrigger;
$this->buysFromATagTrigger = $buysFromATagTrigger;
$this->abandonedCartSubject = $abandonedCartSubject;
$this->orderStatusChangeSubject = $orderStatusChangeSubject;
$this->orderSubject = $orderSubject;
$this->customerSubject = $customerSubject;
$this->contextFactory = $contextFactory;
$this->wordPressUserToWooCommerceCustomerTransformer = $wordPressUserToWooCommerceCustomerTransformer;
$this->wooCommerce = $wooCommerce;
}
public function register(Registry $registry): void {
if (!$this->wooCommerce->isWooCommerceActive()) {
return;
}
$registry->addContextFactory('woocommerce', function () {
return $this->contextFactory->getContextData();
});
$registry->addSubject($this->abandonedCartSubject);
$registry->addSubject($this->orderSubject);
$registry->addSubject($this->orderStatusChangeSubject);
$registry->addSubject($this->customerSubject);
$registry->addTrigger($this->orderStatusChangedTrigger);
$registry->addTrigger($this->orderCreatedTrigger);
$registry->addTrigger($this->orderCompletedTrigger);
$registry->addTrigger($this->orderCancelledTrigger);
$registry->addTrigger($this->abandonedCartTrigger);
$registry->addTrigger($this->buysAProductTrigger);
$registry->addTrigger($this->buysFromACategoryTrigger);
$registry->addTrigger($this->buysFromATagTrigger);
$registry->addSubjectTransformer($this->wordPressUserToWooCommerceCustomerTransformer);
}
}
@@ -0,0 +1,104 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\WordPress;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\WordPress;
class ContextFactory {
/** @var WordPress */
private $wp;
public function __construct(
WordPress $wp
) {
$this->wp = $wp;
}
/** @return mixed[] */
public function getContextData(): array {
return [
'comment_statuses' => $this->getCommentStatuses(),
'post_types' => $this->getPostTypes(),
'taxonomies' => $this->getTaxonomies(),
];
}
/**
* @return string[][]
*/
private function getCommentStatuses(): array {
$statiMap = $this->wp->getCommentStatuses();
$stati = [];
foreach ($statiMap as $id => $name) {
$stati[] = [
'id' => $id,
'name' => $name,
];
}
return $stati;
}
/**
* @return array<int, array<string, array<string, bool>|bool|string>>
*/
private function getPostTypes(): array {
/** @var \WP_Post_Type[] $postTypes */
$postTypes = $this->wp->getPostTypes([], 'objects');
return array_values(array_map(function(\WP_Post_Type $type): array {
$supports = ['comments' => false];
foreach (array_keys($supports) as $key) {
$supports[$key] = $this->wp->postTypeSupports($type->name, $key);
}
return [
'name' => $type->name,
'label' => $type->label,
'supports' => $supports,
//phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
'show_in_rest' => $type->show_in_rest,
//phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
'rest_base' => $type->rest_base,
//phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
'rest_namespace' => $type->rest_namespace,
'public' => $type->public,
];
},
$postTypes));
}
/**
* @return array<int, array<string, string[]|bool|string>>
*/
private function getTaxonomies(): array {
/** @var \WP_Taxonomy[] $taxonomies */
$taxonomies = array_filter(
$this->wp->getTaxonomies([], 'objects'),
function($object): bool {
return $object instanceof \WP_Taxonomy;
}
);
return array_values(array_map(
function(\WP_Taxonomy $taxonomy): array {
return [
'name' => $taxonomy->name,
'label' => $taxonomy->label,
//phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
'show_in_rest' => $taxonomy->show_in_rest,
'public' => $taxonomy->public,
//phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
'rest_base' => $taxonomy->rest_base,
//phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
'rest_namespace' => $taxonomy->rest_namespace,
//phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
'object_type' => (array)$taxonomy->object_type,
];
},
$taxonomies
));
}
}
@@ -0,0 +1,188 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\WordPress\Fields;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Field;
use MailPoet\Automation\Engine\WordPress;
use MailPoet\Automation\Integrations\WordPress\Payloads\CommentPayload;
class CommentFieldsFactory {
/** @var WordPress */
private $wp;
public function __construct(
WordPress $wp
) {
$this->wp = $wp;
}
/**
* @return Field[]
*/
public function getFields(): array {
return [
new Field(
'wordpress:comment:id',
Field::TYPE_INTEGER,
__('Comment ID', 'mailpoet'),
function (CommentPayload $payload) {
return $payload->getCommentId();
}
),
new Field(
'wordpress:comment:author-name',
Field::TYPE_STRING,
__('Comment author name', 'mailpoet'),
function (CommentPayload $payload) {
$comment = $payload->getComment();
//phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
return $comment ? $comment->comment_author : null;
}
),
new Field(
'wordpress:comment:author-email',
Field::TYPE_STRING,
__('Comment author email', 'mailpoet'),
function (CommentPayload $payload) {
$comment = $payload->getComment();
//phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
return $comment ? $comment->comment_author_email : null;
}
),
new Field(
'wordpress:comment:author-url',
Field::TYPE_STRING,
__('Comment author URL', 'mailpoet'),
function (CommentPayload $payload) {
$comment = $payload->getComment();
//phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
return $comment ? $comment->comment_author_url : null;
}
),
new Field(
'wordpress:comment:author-ip',
Field::TYPE_STRING,
__('Comment author IP', 'mailpoet'),
function (CommentPayload $payload) {
$comment = $payload->getComment();
//phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
return $comment ? $comment->comment_author_IP : null;
}
),
new Field(
'wordpress:comment:date',
Field::TYPE_DATETIME,
__('Comment date', 'mailpoet'),
function (CommentPayload $payload) {
$comment = $payload->getComment();
//phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
return $comment ? $comment->comment_date_gmt : null;
}
),
new Field(
'wordpress:comment:content',
Field::TYPE_STRING,
__('Comment content', 'mailpoet'),
function (CommentPayload $payload) {
$comment = $payload->getComment();
//phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
return $comment ? $comment->comment_content : null;
}
),
new Field(
'wordpress:comment:karma',
Field::TYPE_INTEGER,
__('Comment karma', 'mailpoet'),
function (CommentPayload $payload) {
$comment = $payload->getComment();
//phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
return $comment ? (int)$comment->comment_karma : null;
}
),
new Field(
'wordpress:comment:status',
Field::TYPE_ENUM,
__('Comment status', 'mailpoet'),
function (CommentPayload $payload) {
$status = $this->wp->wpGetCommentStatus($payload->getCommentId());
if (!is_string($status)) {
return null;
}
/**
* wp_get_comment_status returns 'unapproved' and 'approved' where get_comment_statuses returns 'hold' and 'approve'
* We need to normalize the status for matches.
*/
if ($status === 'approved') {
$status = 'approve';
}
if ($status === 'unapproved') {
$status = 'hold';
}
return $status;
},
[
'options' => $this->getCommentStatuses(),
]
),
new Field(
'wordpress:comment:comment-agent',
Field::TYPE_STRING,
__('Comment user agent', 'mailpoet'),
function (CommentPayload $payload) {
$comment = $payload->getComment();
//phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
return $comment ? $comment->comment_agent : null;
}
),
new Field(
'wordpress:comment:comment-type',
Field::TYPE_STRING,
__('Comment type', 'mailpoet'),
function (CommentPayload $payload) {
$comment = $payload->getComment();
//phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
return $comment ? $comment->comment_type : null;
}
),
new Field(
'wordpress:comment:comment-parent',
Field::TYPE_INTEGER,
__('Comment parent ID', 'mailpoet'),
function (CommentPayload $payload) {
$comment = $payload->getComment();
//phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
return $comment ? (int)$comment->comment_parent : null;
}
),
new Field(
'wordpress:comment:has-children',
Field::TYPE_BOOLEAN,
__('Comment has replies', 'mailpoet'),
function (CommentPayload $payload) {
$comment = $payload->getComment();
//phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
return $comment ? count($comment->get_children()) > 0 : false;
}
),
];
}
private function getCommentStatuses(): array {
$statuses = $this->wp->getCommentStatuses();
return array_values(array_map(
function($name, $id): array {
return [
'id' => $id,
'name' => $name,
];
},
$statuses,
array_keys($statuses)
));
}
}
@@ -0,0 +1,246 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\WordPress\Fields;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Field;
use MailPoet\Automation\Engine\WordPress;
use MailPoet\Automation\Integrations\WordPress\Payloads\PostPayload;
class PostFieldsFactory {
/** @var WordPress */
private $wp;
public function __construct(
WordPress $wp
) {
$this->wp = $wp;
}
/**
* @return Field[]
*/
public function getFields(): array {
return [
new Field(
'wordpress:post:id',
Field::TYPE_INTEGER,
__('ID', 'mailpoet'),
function (PostPayload $payload) {
return $payload->getPostId();
}
),
new Field(
'wordpress:post:type',
Field::TYPE_ENUM,
__('Post type', 'mailpoet'),
function (PostPayload $payload) {
$post = $payload->getPost();
//phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
return $post ? $post->post_type : null;
},
[
'options' => $this->getPostTypes(),
]
),
new Field(
'wordpress:post:status',
Field::TYPE_ENUM,
__('Post status', 'mailpoet'),
function (PostPayload $payload) {
$post = $payload->getPost();
//phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
return $post ? $post->post_status : null;
},
[
'options' => $this->getPostStatuses(),
]
),
new Field(
'wordpress:post:content',
Field::TYPE_STRING,
__('Post Content', 'mailpoet'),
function (PostPayload $payload) {
$post = $payload->getPost();
//phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
return $post ? $post->post_content : null;
}
),
new Field(
'wordpress:post:title',
Field::TYPE_STRING,
__('Post title', 'mailpoet'),
function (PostPayload $payload) {
$post = $payload->getPost();
//phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
return $post ? $post->post_title : null;
}
),
new Field(
'wordpress:post:date',
Field::TYPE_DATETIME,
__('Post date', 'mailpoet'),
function (PostPayload $payload) {
$post = $payload->getPost();
//phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
return $post ? $post->post_date_gmt : null;
}
),
new Field(
'wordpress:post:modified',
Field::TYPE_DATETIME,
__('Post last modified', 'mailpoet'),
function (PostPayload $payload) {
$post = $payload->getPost();
//phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
return $post ? $post->post_modified_gmt : null;
}
),
new Field(
'wordpress:post:author',
Field::TYPE_INTEGER,
__('Post author ID', 'mailpoet'),
function (PostPayload $payload) {
$post = $payload->getPost();
//phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
return $post ? (int)$post->post_author : null;
}
),
new Field(
'wordpress:post:excerpt',
Field::TYPE_STRING,
__('Post excerpt', 'mailpoet'),
function (PostPayload $payload) {
$post = $payload->getPost();
//phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
return $post ? $post->post_excerpt : null;
}
),
new Field(
'wordpress:post:comment-status',
Field::TYPE_BOOLEAN,
__('Post open for comments', 'mailpoet'),
function (PostPayload $payload) {
$post = $payload->getPost();
//phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
return $post ? $post->comment_status === 'open' : false;
}
),
new Field(
'wordpress:post:ping-status',
Field::TYPE_BOOLEAN,
__('Post open for pings', 'mailpoet'),
function (PostPayload $payload) {
$post = $payload->getPost();
//phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
return $post ? $post->ping_status === 'open' : false;
}
),
new Field(
'wordpress:post:password',
Field::TYPE_STRING,
__('Post password', 'mailpoet'),
function (PostPayload $payload) {
$post = $payload->getPost();
//phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
return $post ? $post->post_password : null;
}
),
new Field(
'wordpress:post:slug',
Field::TYPE_STRING,
__('Post slug', 'mailpoet'),
function (PostPayload $payload) {
$post = $payload->getPost();
//phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
return $post ? $post->post_name : null;
}
),
new Field(
'wordpress:post:parent',
Field::TYPE_INTEGER,
__('Post parent ID', 'mailpoet'),
function (PostPayload $payload) {
$post = $payload->getPost();
//phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
return $post ? $post->post_parent : null;
}
),
new Field(
'wordpress:post:has-parent',
Field::TYPE_BOOLEAN,
__('Post has parent', 'mailpoet'),
function (PostPayload $payload) {
$post = $payload->getPost();
//phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
return $post ? $post->post_parent > 0 : false;
}
),
new Field(
'wordpress:post:guid',
Field::TYPE_STRING,
__('Post guid', 'mailpoet'),
function (PostPayload $payload) {
$post = $payload->getPost();
return $post ? $post->guid : null;
}
),
new Field(
'wordpress:post:menu-order',
Field::TYPE_INTEGER,
__('Post menu order', 'mailpoet'),
function (PostPayload $payload) {
$post = $payload->getPost();
//phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
return $post ? $post->menu_order : null;
}
),
new Field(
'wordpress:post:comment-count',
Field::TYPE_INTEGER,
__('Number of post comments', 'mailpoet'),
function (PostPayload $payload) {
$post = $payload->getPost();
//phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
return $post ? $post->comment_count : 0;
}
),
];
}
private function getPostStatuses(): array {
$statuses = $this->wp->getPostStatuses();
return array_values(array_map(
function($status, $index): array {
return [
'id' => $index,
'name' => $status,
];
},
$statuses,
array_keys($statuses)
));
}
private function getPostTypes(): array {
/** @var \WP_Post_Type[] $postTypes */
$postTypes = $this->wp->getPostTypes([], 'objects');
return array_values(array_map(
function(\WP_Post_Type $type): array {
return [
'id' => $type->name,
'name' => $type->label,
];
},
array_filter(
$postTypes,
function(\WP_Post_Type $type): bool {
return (bool)$type->public;
}
)
));
}
}
@@ -0,0 +1,34 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\WordPress\Payloads;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Integration\Payload;
use MailPoet\Automation\Engine\WordPress;
class CommentPayload implements Payload {
/** @var int */
private $commentId;
/** @var WordPress */
protected $wp;
public function __construct(
int $commentId,
WordPress $wp
) {
$this->commentId = $commentId;
$this->wp = $wp;
}
public function getCommentId(): int {
return $this->commentId;
}
public function getComment(): ?\WP_Comment {
$comment = $this->wp->getComment($this->commentId);
return $comment instanceof \WP_Comment ? $comment : null;
}
}
@@ -0,0 +1,34 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\WordPress\Payloads;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Integration\Payload;
use MailPoet\Automation\Engine\WordPress;
class PostPayload implements Payload {
/** @var int */
private $postId;
/** @var WordPress */
private $wp;
public function __construct(
int $postId,
WordPress $wp
) {
$this->postId = $postId;
$this->wp = $wp;
}
public function getPostId(): int {
return $this->postId;
}
public function getPost(): ?\WP_Post {
$post = $this->wp->getPost($this->postId);
return $post instanceof \WP_Post ? $post : null;
}
}
@@ -0,0 +1,41 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\WordPress\Payloads;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Integration\Payload;
use WP_User;
class UserPayload implements Payload {
/** @var WP_User */
private $user;
public function __construct(
WP_User $user
) {
$this->user = $user;
}
public function getId(): int {
return $this->user->ID;
}
public function getUser(): WP_User {
return $this->user;
}
public function getEmail(): ?string {
return $this->user->user_email ?: null;
}
public function exists(): bool {
return $this->user->exists();
}
/** @return string[] */
public function getRoles(): array {
return $this->user->roles;
}
}

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