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,249 @@
<?php declare(strict_types = 1);
namespace MailPoet\Migrations\App;
if (!defined('ABSPATH')) exit;
use MailPoet\Cron\CronTrigger;
use MailPoet\Cron\Workers\StatsNotifications\Worker;
use MailPoet\Entities\FormEntity;
use MailPoet\Form\FormsRepository;
use MailPoet\Migrator\AppMigration;
use MailPoet\Settings\SettingsChangeHandler;
use MailPoet\Settings\SettingsController;
use MailPoet\Settings\TrackingConfig;
use MailPoet\Util\Notices\ChangedTrackingNotice;
use MailPoet\WP\Functions as WPFunctions;
/**
* Extracted from original Migration_20221028_105818.php when separating Db and App migrations.
*/
class Migration_20221028_105818_App extends AppMigration {
/** @var SettingsController */
private $settings;
/** @var SettingsChangeHandler */
private $settingsChangeHandler;
/** @var FormsRepository */
private $formsRepository;
/** @var WPFunctions */
private $wp;
public function run(): void {
$this->settings = $this->container->get(SettingsController::class);
$this->settingsChangeHandler = $this->container->get(SettingsChangeHandler::class);
$this->formsRepository = $this->container->get(FormsRepository::class);
$this->wp = $this->container->get(WPFunctions::class);
$this->updateDefaultInactiveSubscriberTimeRange();
$this->setDefaultValueForLoadingThirdPartyLibrariesForExistingInstalls();
$this->disableMailPoetCronTrigger();
// POPULATOR
$this->enableStatsNotificationsForAutomatedEmails();
$this->addPlacementStatusToForms();
$this->migrateFormPlacement();
$this->updateToUnifiedTrackingSettings();
}
private function updateDefaultInactiveSubscriberTimeRange(): bool {
// Skip if the installed version is newer than the release that preceded this migration, or if it's a fresh install
$currentlyInstalledVersion = (string)$this->settings->get('db_version', '3.78.1');
if (version_compare($currentlyInstalledVersion, '3.78.0', '>')) {
return false;
}
$currentValue = (int)$this->settings->get('deactivate_subscriber_after_inactive_days');
if ($currentValue === 180) {
$this->settings->set('deactivate_subscriber_after_inactive_days', 365);
$this->settingsChangeHandler->onInactiveSubscribersIntervalChange();
}
return true;
}
private function setDefaultValueForLoadingThirdPartyLibrariesForExistingInstalls(): bool {
// skip the migration if the DB version is higher than 3.91.1 or is not set (a new installation)
if (version_compare($this->settings->get('db_version', '3.91.2'), '3.91.1', '>')) {
return false;
}
$thirdPartyScriptsEnabled = $this->settings->get('3rd_party_libs');
if (is_null($thirdPartyScriptsEnabled)) {
// keep loading 3rd party libraries for existing users so the functionality is not broken
$this->settings->set('3rd_party_libs.enabled', '1');
}
return true;
}
private function enableStatsNotificationsForAutomatedEmails() {
if (version_compare((string)$this->settings->get('db_version', '3.31.2'), '3.31.1', '>')) {
return;
}
$settings = $this->settings->get(Worker::SETTINGS_KEY);
$settings['automated'] = true;
$this->settings->set(Worker::SETTINGS_KEY, $settings);
}
private function addPlacementStatusToForms() {
if (version_compare((string)$this->settings->get('db_version', '3.49.0'), '3.48.1', '>')) {
return;
}
$forms = $this->formsRepository->findAll();
foreach ($forms as $form) {
$settings = $form->getSettings();
if (
(isset($settings['place_form_bellow_all_posts']) && $settings['place_form_bellow_all_posts'] === '1')
|| (isset($settings['place_form_bellow_all_pages']) && $settings['place_form_bellow_all_pages'] === '1')
) {
$settings['form_placement_bellow_posts_enabled'] = '1';
} else {
$settings['form_placement_bellow_posts_enabled'] = '';
}
if (
(isset($settings['place_popup_form_on_all_posts']) && $settings['place_popup_form_on_all_posts'] === '1')
|| (isset($settings['place_popup_form_on_all_pages']) && $settings['place_popup_form_on_all_pages'] === '1')
) {
$settings['form_placement_popup_enabled'] = '1';
} else {
$settings['form_placement_popup_enabled'] = '';
}
if (
(isset($settings['place_fixed_bar_form_on_all_posts']) && $settings['place_fixed_bar_form_on_all_posts'] === '1')
|| (isset($settings['place_fixed_bar_form_on_all_pages']) && $settings['place_fixed_bar_form_on_all_pages'] === '1')
) {
$settings['form_placement_fixed_bar_enabled'] = '1';
} else {
$settings['form_placement_fixed_bar_enabled'] = '';
}
if (
(isset($settings['place_slide_in_form_on_all_posts']) && $settings['place_slide_in_form_on_all_posts'] === '1')
|| (isset($settings['place_slide_in_form_on_all_pages']) && $settings['place_slide_in_form_on_all_pages'] === '1')
) {
$settings['form_placement_slide_in_enabled'] = '1';
} else {
$settings['form_placement_slide_in_enabled'] = '';
}
$form->setSettings($settings);
}
$this->formsRepository->flush();
}
private function migrateFormPlacement() {
if (version_compare((string)$this->settings->get('db_version', '3.50.0'), '3.49.1', '>')) {
return;
}
$forms = $this->formsRepository->findAll();
foreach ($forms as $form) {
$settings = $form->getSettings();
if (!is_array($settings)) continue;
$settings['form_placement'] = [
FormEntity::DISPLAY_TYPE_POPUP => [
'enabled' => $settings['form_placement_popup_enabled'],
'delay' => $settings['popup_form_delay'] ?? 0,
'styles' => $settings['popup_styles'] ?? [],
'posts' => [
'all' => $settings['place_popup_form_on_all_posts'] ?? '',
],
'pages' => [
'all' => $settings['place_popup_form_on_all_pages'] ?? '',
],
],
FormEntity::DISPLAY_TYPE_FIXED_BAR => [
'enabled' => $settings['form_placement_fixed_bar_enabled'],
'delay' => $settings['fixed_bar_form_delay'] ?? 0,
'styles' => $settings['fixed_bar_styles'] ?? [],
'position' => $settings['fixed_bar_form_position'] ?? 'top',
'posts' => [
'all' => $settings['place_fixed_bar_form_on_all_posts'] ?? '',
],
'pages' => [
'all' => $settings['place_fixed_bar_form_on_all_pages'] ?? '',
],
],
FormEntity::DISPLAY_TYPE_BELOW_POST => [
'enabled' => $settings['form_placement_bellow_posts_enabled'],
'styles' => $settings['below_post_styles'] ?? [],
'posts' => [
'all' => $settings['place_form_bellow_all_posts'] ?? '',
],
'pages' => [
'all' => $settings['place_form_bellow_all_pages'] ?? '',
],
],
FormEntity::DISPLAY_TYPE_SLIDE_IN => [
'enabled' => $settings['form_placement_slide_in_enabled'],
'delay' => $settings['slide_in_form_delay'] ?? 0,
'position' => $settings['slide_in_form_position'] ?? 'right',
'styles' => $settings['slide_in_styles'] ?? [],
'posts' => [
'all' => $settings['place_slide_in_form_on_all_posts'] ?? '',
],
'pages' => [
'all' => $settings['place_slide_in_form_on_all_pages'] ?? '',
],
],
FormEntity::DISPLAY_TYPE_OTHERS => [
'styles' => $settings['other_styles'] ?? [],
],
];
if (isset($settings['form_placement_slide_in_enabled'])) unset($settings['form_placement_slide_in_enabled']);
if (isset($settings['form_placement_fixed_bar_enabled'])) unset($settings['form_placement_fixed_bar_enabled']);
if (isset($settings['form_placement_popup_enabled'])) unset($settings['form_placement_popup_enabled']);
if (isset($settings['form_placement_bellow_posts_enabled'])) unset($settings['form_placement_bellow_posts_enabled']);
if (isset($settings['place_form_bellow_all_pages'])) unset($settings['place_form_bellow_all_pages']);
if (isset($settings['place_form_bellow_all_posts'])) unset($settings['place_form_bellow_all_posts']);
if (isset($settings['place_popup_form_on_all_pages'])) unset($settings['place_popup_form_on_all_pages']);
if (isset($settings['place_popup_form_on_all_posts'])) unset($settings['place_popup_form_on_all_posts']);
if (isset($settings['popup_form_delay'])) unset($settings['popup_form_delay']);
if (isset($settings['place_fixed_bar_form_on_all_pages'])) unset($settings['place_fixed_bar_form_on_all_pages']);
if (isset($settings['place_fixed_bar_form_on_all_posts'])) unset($settings['place_fixed_bar_form_on_all_posts']);
if (isset($settings['fixed_bar_form_delay'])) unset($settings['fixed_bar_form_delay']);
if (isset($settings['fixed_bar_form_position'])) unset($settings['fixed_bar_form_position']);
if (isset($settings['place_slide_in_form_on_all_pages'])) unset($settings['place_slide_in_form_on_all_pages']);
if (isset($settings['place_slide_in_form_on_all_posts'])) unset($settings['place_slide_in_form_on_all_posts']);
if (isset($settings['slide_in_form_delay'])) unset($settings['slide_in_form_delay']);
if (isset($settings['slide_in_form_position'])) unset($settings['slide_in_form_position']);
if (isset($settings['other_styles'])) unset($settings['other_styles']);
if (isset($settings['slide_in_styles'])) unset($settings['slide_in_styles']);
if (isset($settings['below_post_styles'])) unset($settings['below_post_styles']);
if (isset($settings['fixed_bar_styles'])) unset($settings['fixed_bar_styles']);
if (isset($settings['popup_styles'])) unset($settings['popup_styles']);
$form->setSettings($settings);
}
$this->formsRepository->flush();
}
private function updateToUnifiedTrackingSettings() {
if (version_compare((string)$this->settings->get('db_version', '3.74.3'), '3.74.2', '>')) {
return;
}
$emailTracking = $this->settings->get('tracking.enabled', true);
$wooTrackingCookie = $this->settings->get('woocommerce.accept_cookie_revenue_tracking.enabled');
if ($wooTrackingCookie === null) { // No setting for WooCommerce Cookie Tracking - WooCommerce was not active
$trackingLevel = $emailTracking ? TrackingConfig::LEVEL_FULL : TrackingConfig::LEVEL_BASIC;
} elseif ($wooTrackingCookie) { // WooCommerce Cookie Tracking enabled
$trackingLevel = TrackingConfig::LEVEL_FULL;
// Cookie was enabled but tracking disabled and we are switching to full.
// So we activate an admin notice to let the user know that we activated tracking
if (!$emailTracking) {
$this->wp->setTransient(ChangedTrackingNotice::OPTION_NAME, true);
}
} else { // WooCommerce Tracking Cookie Disabled
$trackingLevel = $emailTracking ? TrackingConfig::LEVEL_PARTIAL : TrackingConfig::LEVEL_BASIC;
}
$this->settings->set('tracking.level', $trackingLevel);
}
private function disableMailPoetCronTrigger() {
$method = $this->settings->get(CronTrigger::SETTING_NAME . '.method');
if ($method !== 'MailPoet') {
return;
}
$this->settings->set(CronTrigger::SETTING_NAME . '.method', CronTrigger::METHOD_WORDPRESS);
}
}
@@ -0,0 +1,24 @@
<?php declare(strict_types = 1);
namespace MailPoet\Migrations\App;
if (!defined('ABSPATH')) exit;
use MailPoet\Mailer\MailerLog;
use MailPoet\Migrator\AppMigration;
class Migration_20230109_144830 extends AppMigration {
/**
* Due to a bug https://mailpoet.atlassian.net/browse/MAILPOET-4940 some users may have
* paused sending without having the error message and they have no way to resume sending.
* This migration will unpause sending for all users who have paused sending and have no error message.
*/
public function run(): void {
$mailerLog = MailerLog::getMailerLog();
if (isset($mailerLog['status']) && $mailerLog['status'] === MailerLog::STATUS_PAUSED && !isset($mailerLog['error'])) {
$mailerLog['status'] = null;
MailerLog::updateMailerLog($mailerLog);
}
}
}
@@ -0,0 +1,44 @@
<?php declare(strict_types = 1);
namespace MailPoet\Migrations\App;
if (!defined('ABSPATH')) exit;
use MailPoet\Homepage\HomepageDataController;
use MailPoet\Migrator\AppMigration;
use MailPoet\Settings\SettingsController;
use MailPoet\WooCommerce\Helper;
class Migration_20230131_121621 extends AppMigration {
/**
* This migration detect whether we should display Task List and Product Discovery sections
* on the homepage for the old users.
*/
public function run(): void {
// Hide task list for users who installed the plugin more than 2 weeks ago
$settings = $this->container->get(SettingsController::class);
$installedAt = strtotime($settings->get('installed_at', date('Y-m-d H:i:s')));
$twoWeeksAgo = strtotime('-2 weeks');
if ($installedAt < $twoWeeksAgo) {
$settings->set('homepage.task_list_dismissed', true);
}
// Hide product discovery for users who completed all tasks
$homepageDataController = $this->container->get(HomepageDataController::class);
$wooCommerceHelper = $this->container->get(Helper::class);
$homepageData = $homepageDataController->getPageData();
$productDiscoveryStatus = $homepageData['productDiscoveryStatus'];
if ($wooCommerceHelper->isWooCommerceActive()) {
$productDiscoveryIsComplete = $productDiscoveryStatus['addSubscriptionForm'] &&
$productDiscoveryStatus['setUpWelcomeCampaign'] &&
$productDiscoveryStatus['setUpAbandonedCartEmail'] &&
$productDiscoveryStatus['brandWooEmails'];
} else {
$productDiscoveryIsComplete = $productDiscoveryStatus['addSubscriptionForm'] &&
$productDiscoveryStatus['setUpWelcomeCampaign'] &&
$productDiscoveryStatus['sendFirstNewsletter'];
}
$settings->set('homepage.product_discovery_dismissed', $productDiscoveryIsComplete);
}
}
@@ -0,0 +1,41 @@
<?php declare(strict_types = 1);
namespace MailPoet\Migrations\App;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\NewsletterEntity;
use MailPoet\Migrator\AppMigration;
use MailPoet\Newsletter\NewslettersRepository;
use MailPoet\Newsletter\Scheduler\PostNotificationScheduler;
class Migration_20230419_080000 extends AppMigration {
/** @var NewslettersRepository */
private $newslettersRepository;
/** @var PostNotificationScheduler */
private $postNotificationScheduler;
public function run(): void {
$this->newslettersRepository = $this->container->get(NewslettersRepository::class);
$this->postNotificationScheduler = $this->container->get(PostNotificationScheduler::class);
$this->fixPostNotificationScheduleTime();
}
/**
* Because we released PostNotificationScheduler that didn't schedule notifications with the minute resolution,
* which was added in version 4.10.0, we need to fix the scheduled time for all notifications.
*
* Ticket with bug: https://mailpoet.atlassian.net/browse/MAILPOET-5244
* Ticket with adding minute resolution: https://mailpoet.atlassian.net/browse/MAILPOET-4602
*
* @return void
*/
private function fixPostNotificationScheduleTime() {
$newsletters = $this->newslettersRepository->findBy(['type' => NewsletterEntity::TYPE_NOTIFICATION]);
foreach ($newsletters as $newsletter) {
$this->postNotificationScheduler->processPostNotificationSchedule($newsletter);
}
}
}
@@ -0,0 +1,26 @@
<?php declare(strict_types = 1);
namespace MailPoet\Migrations\App;
if (!defined('ABSPATH')) exit;
use MailPoet\Migrator\AppMigration;
use MailPoet\Settings\SettingsController;
class Migration_20230425_211517 extends AppMigration {
public function run(): void {
$settingsController = $this->container->get(SettingsController::class);
$possibleKeys = [
'subscribe.on_register.label',
'subscribe.on_comment.label',
];
$default = __('Yes, add me to your mailing list', 'mailpoet');
foreach ($possibleKeys as $key) {
$currentValue = $settingsController->get($key);
if ($currentValue === 'TRANSLATION "yesAddMe" NOT FOUND') {
$settingsController->set($key, $default);
}
}
}
}
@@ -0,0 +1,56 @@
<?php declare(strict_types = 1);
namespace MailPoet\Migrations\App;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\DynamicSegmentFilterData;
use MailPoet\Migrator\AppMigration;
use MailPoet\Segments\DynamicSegments\DynamicSegmentFilterRepository;
use MailPoet\Segments\DynamicSegments\Filters\WooCommerceAverageSpent;
use MailPoet\Segments\DynamicSegments\Filters\WooCommerceNumberOfOrders;
use MailPoet\Segments\DynamicSegments\Filters\WooCommerceSingleOrderValue;
use MailPoet\Segments\DynamicSegments\Filters\WooCommerceTotalSpent;
class Migration_20230712_180341 extends AppMigration {
public function run(): void {
$dynamicSegmentFilterRepository = $this->container->get(DynamicSegmentFilterRepository::class);
$filters = $dynamicSegmentFilterRepository->findBy(
[
'filterData.action' => [
WooCommerceNumberOfOrders::ACTION_NUMBER_OF_ORDERS,
WooCommerceTotalSpent::ACTION_TOTAL_SPENT,
WooCommerceSingleOrderValue::ACTION_SINGLE_ORDER_VALUE,
WooCommerceAverageSpent::ACTION,
],
]
);
foreach ($filters as $filter) {
$filterData = $filter->getFilterData();
$data = $filter->getFilterData()->getData();
if (isset($data['number_of_orders_days'])) {
$days = $data['number_of_orders_days'];
} else if (isset($data['total_spent_days'])) {
$days = $data['total_spent_days'];
} else if (isset($data['single_order_value_days'])) {
$days = $data['single_order_value_days'];
} else if (isset($data['average_spent_days'])) {
$days = $data['average_spent_days'];
}
$filterType = $filterData->getFilterType();
$filterAction = $filterData->getAction();
if (isset($days) && is_string($filterType) && is_string($filterAction)) {
$data['days'] = $days;
$newFilterData = new DynamicSegmentFilterData($filterType, $filterAction, $data);
$filter->setFilterData($newFilterData);
$this->entityManager->persist($filter);
$this->entityManager->flush();
}
}
}
}
@@ -0,0 +1,51 @@
<?php declare(strict_types = 1);
namespace MailPoet\Migrations\App;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\DynamicSegmentFilterData;
use MailPoet\Entities\DynamicSegmentFilterEntity;
use MailPoet\Migrator\AppMigration;
use MailPoet\Segments\DynamicSegments\DynamicSegmentFilterRepository;
use MailPoet\Segments\DynamicSegments\Filters\WooCommerceUsedPaymentMethod;
use MailPoet\Segments\DynamicSegments\Filters\WooCommerceUsedShippingMethod;
class Migration_20230803_200413_App extends AppMigration {
public function run(): void {
$dynamicSegmentFilterRepository = $this->container->get(DynamicSegmentFilterRepository::class);
$filters = $dynamicSegmentFilterRepository->findBy(
[
'filterData.action' => [
WooCommerceUsedPaymentMethod::ACTION,
WooCommerceUsedShippingMethod::ACTION,
],
]
);
/** @var DynamicSegmentFilterEntity $filter */
foreach ($filters as $filter) {
$filterData = $filter->getFilterData();
$data = $filter->getFilterData()->getData();
if (isset($data['used_payment_method_days'])) {
$days = $data['used_payment_method_days'];
} elseif (isset($data['used_shipping_method_days'])) {
$days = $data['used_shipping_method_days'];
}
$filterType = $filterData->getFilterType();
$filterAction = $filterData->getAction();
if (isset($days) && is_string($filterType) && is_string($filterAction)) {
$data['days'] = $days;
$data['timeframe'] = DynamicSegmentFilterData::TIMEFRAME_IN_THE_LAST;
$newFilterData = new DynamicSegmentFilterData($filterType, $filterAction, $data);
$filter->setFilterData($newFilterData);
$this->entityManager->persist($filter);
$this->entityManager->flush();
}
}
}
}
@@ -0,0 +1,71 @@
<?php declare(strict_types = 1);
namespace MailPoet\Migrations\App;
if (!defined('ABSPATH')) exit;
use MailPoet\Doctrine\WPDB\Connection;
use MailPoet\Entities\StatisticsWooCommercePurchaseEntity;
use MailPoet\Migrations\Db\Migration_20230824_054259_Db;
use MailPoet\Migrator\AppMigration;
use MailPoet\WooCommerce\Helper;
class Migration_20230825_093531_App extends AppMigration {
const DEFAULT_STATUS = Migration_20230824_054259_Db::DEFAULT_STATUS;
public function run(): void {
$wooCommerceHelper = $this->container->get(Helper::class);
// If Woo is not active and the table doesn't exist, we can skip this migration
if (!$wooCommerceHelper->isWooCommerceActive() && !$this->tableExists()) {
return;
}
// Temporarily skip the queries in WP Playground.
// The SQLite integration doesn't seem to support them yet.
if (Connection::isSQLite()) {
return;
}
$wooCommerceHelper->isWooCommerceCustomOrdersTableEnabled() ?
$this->populateStatusColumnUsingHpos() : $this->populateStatusColumnUsingPost();
}
private function populateStatusColumnUsingHpos(): void {
global $wpdb;
$revenueTable = esc_sql($this->getTableName());
$ordersTable = esc_sql($wpdb->prefix . 'wc_orders');
$wpdb->query($wpdb->prepare(
"UPDATE %i AS rev, %i AS wc SET rev.status=TRIM(Leading 'wc-' FROM wc.status) WHERE wc.id = rev.order_id AND rev.status= %s",
$revenueTable,
$ordersTable,
self::DEFAULT_STATUS
));
}
private function populateStatusColumnUsingPost(): void {
global $wpdb;
$revenueTable = esc_sql($this->getTableName());
$wpdb->query($wpdb->prepare(
"UPDATE %i AS rev, %i AS wc SET rev.status=TRIM(Leading 'wc-' FROM wc.post_status) WHERE wc.id = rev.order_id AND rev.status= %s",
$revenueTable,
$wpdb->posts,
self::DEFAULT_STATUS
));
}
private function getTableName(): string {
return $this->entityManager->getClassMetadata(StatisticsWooCommercePurchaseEntity::class)->getTableName();
}
private function tableExists(): bool {
global $wpdb;
$revenueTable = $this->getTableName();
return $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($revenueTable))) === $revenueTable;
}
}
@@ -0,0 +1,86 @@
<?php declare(strict_types = 1);
namespace MailPoet\Migrations\App;
if (!defined('ABSPATH')) exit;
use MailPoet\Doctrine\WPDB\Connection as WPDBConnection;
use MailPoet\Entities\NewsletterEntity;
use MailPoet\Entities\ScheduledTaskEntity;
use MailPoet\Entities\SendingQueueEntity;
use MailPoet\Migrator\AppMigration;
use MailPoet\WooCommerce\Helper;
use MailPoetVendor\Doctrine\DBAL\ArrayParameterType;
use MailPoetVendor\Doctrine\DBAL\Connection;
/**
* Due to a bug https://mailpoet.atlassian.net/browse/MAILPOET-5719 we need to fix already existing data.
* The performance optimization we changed the method for updating counts in the sending queue after finishing the scheduled task.
* This change affected counts in automatic emails, because the value of processed emails has min and max value calculated from the total count.
*/
class Migration_20231128_120355_App extends AppMigration {
public function run(): void {
$wooCommerceHelper = $this->container->get(Helper::class);
// If Woo is not active and the table doesn't exist, we can skip this migration
if (!$wooCommerceHelper->isWooCommerceActive()) {
return;
}
// Temporarily skip the queries in WP Playground.
// UPDATE with JOIN is not yet supported by the SQLite integration.
if (WPDBConnection::isSQLite()) {
return;
}
$connection = $this->container->get(Connection::class);
// Fix data for completed tasks
$sendingQueuesTable = $this->getTableName(SendingQueueEntity::class);
$scheduledTasksTable = $this->getTableName(ScheduledTaskEntity::class);
$newslettersTable = $this->getTableName(NewsletterEntity::class);
$newsletterTypes = [NewsletterEntity::TYPE_AUTOMATIC, NewsletterEntity::TYPE_WELCOME];
$statusCompleted = ScheduledTaskEntity::STATUS_COMPLETED;
$connection->executeStatement("
UPDATE {$sendingQueuesTable}
JOIN {$scheduledTasksTable} ON {$scheduledTasksTable}.id = {$sendingQueuesTable}.task_id
JOIN {$newslettersTable} ON {$newslettersTable}.id = {$sendingQueuesTable}.newsletter_id
SET {$sendingQueuesTable}.count_total = 1,
{$sendingQueuesTable}.count_processed = 1,
{$sendingQueuesTable}.count_to_process = 0
WHERE {$newslettersTable}.type IN (:newsletterTypes)
AND {$scheduledTasksTable}.status = :taskStatus
", [
'newsletterTypes' => $newsletterTypes,
'taskStatus' => $statusCompleted,
], [
'newsletterTypes' => ArrayParameterType::STRING,
]);
// Fix data for scheduled tasks
$statusScheduled = ScheduledTaskEntity::STATUS_SCHEDULED;
$connection->executeStatement("
UPDATE {$sendingQueuesTable}
JOIN {$scheduledTasksTable} ON {$scheduledTasksTable}.id = {$sendingQueuesTable}.task_id
JOIN {$newslettersTable} ON {$newslettersTable}.id = {$sendingQueuesTable}.newsletter_id
SET {$sendingQueuesTable}.count_total = 1,
{$sendingQueuesTable}.count_processed = 0,
{$sendingQueuesTable}.count_to_process = 1
WHERE {$newslettersTable}.type IN (:newsletterTypes)
AND {$scheduledTasksTable}.status = :taskStatus
", [
'newsletterTypes' => $newsletterTypes,
'taskStatus' => $statusScheduled,
], [
'newsletterTypes' => ArrayParameterType::STRING,
]);
}
/**
* @param class-string $entityClassName
*/
private function getTableName(string $entityClassName): string {
return $this->entityManager->getClassMetadata($entityClassName)->getTableName();
}
}
@@ -0,0 +1,42 @@
<?php declare(strict_types = 1);
namespace MailPoet\Migrations\App;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\NewsletterEntity;
use MailPoet\Entities\ScheduledTaskEntity;
use MailPoet\Migrator\AppMigration;
/**
* Due to a bug https://mailpoet.atlassian.net/browse/MAILPOET-5886
* The status of newsletters was not updated to sent when the task was completed
* In this migration we find newsletters with status sending and their tasks are completed and update their status to sent
*/
class Migration_20240202_130053_App extends AppMigration {
public function run(): void {
$affectedNewsletterIds = $this->entityManager->createQueryBuilder()
->select('n.id')
->from(NewsletterEntity::class, 'n')
->join('n.queues', 'q')
->join('q.task', 't')
->where('n.status = :status_sending')
->andWhere('t.status = :status_completed')
->setParameter('status_sending', NewsletterEntity::STATUS_SENDING)
->setParameter('status_completed', ScheduledTaskEntity::STATUS_COMPLETED)
->getQuery()
->getArrayResult();
$affectedNewsletterIds = array_column($affectedNewsletterIds, 'id');
$this->entityManager->createQueryBuilder()
->update(NewsletterEntity::class, 'n')
->set('n.status', ':status_sent')
->where('n.id IN (:ids)')
->setParameter('status_sent', NewsletterEntity::STATUS_SENT)
->setParameter('ids', $affectedNewsletterIds)
->getQuery()
->execute();
}
}
@@ -0,0 +1,205 @@
<?php declare(strict_types = 1);
namespace MailPoet\Migrations\App;
if (!defined('ABSPATH')) exit;
use MailPoet\Doctrine\WPDB\Connection;
use MailPoet\Entities\NewsletterEntity;
use MailPoet\Entities\ScheduledTaskEntity;
use MailPoet\Entities\ScheduledTaskSubscriberEntity;
use MailPoet\Entities\SendingQueueEntity;
use MailPoet\Entities\StatisticsNewsletterEntity;
use MailPoet\Migrator\AppMigration;
use MailPoetVendor\Doctrine\DBAL\ArrayParameterType;
/**
* We've had a set of bugs where campaign type newsletters (see NewsletterEntity::CAMPAIGN_TYPES),
* such as post notifications, were getting stuck in the following state:
* - The newsletter was in the "sending" state.
* - The task failed to complete and ended up in the "invalid" state.
*
* This migration completes tasks that sent out all emails
* and pauses those that have unprocessed subscribers.
*/
class Migration_20240207_105912_App extends AppMigration {
public function run(): void {
$this->pauseInvalidTasksWithUnprocessedSubscribers();
$this->completeInvalidTasksWithAllSubscribersProcessed();
$this->backfillMissingDataForMigratedNewsletters();
}
private function pauseInvalidTasksWithUnprocessedSubscribers(): void {
$ids = $this->entityManager->createQueryBuilder()
->select('DISTINCT t.id')
->from(ScheduledTaskEntity::class, 't')
->join('t.subscribers', 's', 'WITH', 's.processed = :unprocessed')
->join('t.sendingQueue', 'q')
->join('q.newsletter', 'n')
->where('t.deletedAt IS NULL')
->andWhere('t.status = :invalid')
->andWhere('n.deletedAt IS NULL')
->andWhere('n.status = :sending')
->andWhere('n.type IN (:campaignTypes)')
->setParameter('unprocessed', ScheduledTaskSubscriberEntity::STATUS_UNPROCESSED)
->setParameter('invalid', ScheduledTaskEntity::STATUS_INVALID)
->setParameter('sending', NewsletterEntity::STATUS_SENDING)
->setParameter('campaignTypes', NewsletterEntity::CAMPAIGN_TYPES)
->getQuery()
->getSingleColumnResult();
$this->entityManager->createQueryBuilder()
->update(ScheduledTaskEntity::class, 't')
->set('t.status', ':paused')
->where('t.id IN (:ids)')
->setParameter('paused', ScheduledTaskEntity::STATUS_PAUSED)
->setParameter('ids', $ids)
->getQuery()
->execute();
}
private function completeInvalidTasksWithAllSubscribersProcessed(): void {
$ids = $this->entityManager->createQueryBuilder()
->select('DISTINCT t.id, n.id AS nid, t.updatedAt')
->from(ScheduledTaskEntity::class, 't')
->leftJoin('t.subscribers', 's', 'WITH', 's.processed = :unprocessed')
->join('t.sendingQueue', 'q')
->join('q.newsletter', 'n')
->where('t.deletedAt IS NULL')
->andWhere('t.status = :invalid')
->andWhere('s.task IS NULL')
->andWhere('n.deletedAt IS NULL')
->andWhere('n.status = :sending')
->andWhere('n.type IN (:campaignTypes)')
->setParameter('unprocessed', ScheduledTaskSubscriberEntity::STATUS_UNPROCESSED)
->setParameter('invalid', ScheduledTaskEntity::STATUS_INVALID)
->setParameter('sending', NewsletterEntity::STATUS_SENDING)
->setParameter('campaignTypes', NewsletterEntity::CAMPAIGN_TYPES)
->getQuery()
->getSingleColumnResult();
// update sending queue counts
$this->entityManager->createQueryBuilder()
->update(SendingQueueEntity::class, 'q')
->set('q.countProcessed', 'q.countTotal')
->set('q.countToProcess', 0)
->where('q.task IN (:ids)')
->setParameter('ids', $ids)
->getQuery()
->execute();
// complete the invalid tasks
$this->entityManager->createQueryBuilder()
->update(ScheduledTaskEntity::class, 't')
->set('t.status', ':completed')
->where('t.id IN (:ids)')
->setParameter('completed', ScheduledTaskEntity::STATUS_COMPLETED)
->setParameter('ids', $ids)
->getQuery()
->execute();
// mark newsletters as sent, update "sentAt" (DBAL needed to be able to use JOIN)
$newslettersTable = $this->entityManager->getClassMetadata(NewsletterEntity::class)->getTableName();
$scheduledTasksTable = $this->entityManager->getClassMetadata(ScheduledTaskEntity::class)->getTableName();
$scheduledTaskSubscribersTable = $this->entityManager->getClassMetadata(ScheduledTaskSubscriberEntity::class)->getTableName();
$sendingQueuesTable = $this->entityManager->getClassMetadata(SendingQueueEntity::class)->getTableName();
// Temporarily skip the query in WP Playground.
// UPDATE with JOIN is not yet supported by the SQLite integration.
if (Connection::isSQLite()) {
return;
}
$this->entityManager->getConnection()->executeStatement(
"
UPDATE $newslettersTable n
JOIN $sendingQueuesTable q ON n.id = q.newsletter_id
JOIN $scheduledTasksTable t ON q.task_id = t.id
SET
n.status = :sent,
n.sent_at = COALESCE(
(
-- use 'updated_at' of processed subscriber with the highest ID ('MAX(subscriber_id)' can use index)
SELECT updated_at FROM $scheduledTaskSubscribersTable WHERE task_id = t.id AND subscriber_id = (
SELECT MAX(subscriber_id) FROM $scheduledTaskSubscribersTable WHERE task_id = t.id
)
),
t.updated_at
)
WHERE t.id IN (:ids)
",
['sent' => NewsletterEntity::STATUS_SENT, 'ids' => $ids],
['ids' => ArrayParameterType::INTEGER]
);
}
private function backfillMissingDataForMigratedNewsletters(): void {
// In https://mailpoet.atlassian.net/browse/MAILPOET-5886 we fixed missing "sent" status
// by https://github.com/mailpoet/mailpoet/pull/5416, but didn't backfill missing data.
// get affected newsletter IDs
$ids = $this->entityManager->createQueryBuilder()
->select('n.id')
->from(NewsletterEntity::class, 'n')
->where('n.status = :sent')
->andWhere('n.sentAt IS NULL')
->setParameter('sent', NewsletterEntity::STATUS_SENT)
->getQuery()
->getSingleColumnResult();
// get missing newsletter statistics IDs
$data = $this->entityManager->createQueryBuilder()
->select('IDENTITY(q.newsletter) AS nid, q.id AS qid, IDENTITY(s.subscriber) AS sid, s.updatedAt AS sentAt')
->from(SendingQueueEntity::class, 'q')
->join('q.task', 't')
->join('t.subscribers', 's')
->leftJoin(StatisticsNewsletterEntity::class, 'ns', 'WITH', 'ns.queue = q AND ns.subscriber = s.subscriber')
->where('q.newsletter IN (:ids)')
->andWhere('ns.id IS NULL')
->andWhere('s.processed = :processed')
->setParameter('ids', $ids)
->setParameter('processed', ScheduledTaskSubscriberEntity::STATUS_PROCESSED)
->getQuery()
->getResult();
// insert missing newsletter statistics
$newsletterStatisticsTable = $this->entityManager->getClassMetadata(StatisticsNewsletterEntity::class)->getTableName();
foreach ($data as $row) {
$this->entityManager->getConnection()->executeStatement("
INSERT IGNORE INTO $newsletterStatisticsTable (newsletter_id, queue_id, subscriber_id, sent_at)
VALUES (?, ?, ?, ?)
", [$row['nid'], $row['qid'], $row['sid'], $row['sentAt']->format('Y-m-d H:i:s')]);
}
// add missing "sentAt" (DBAL needed to be able to use JOIN)
$newslettersTable = $this->entityManager->getClassMetadata(NewsletterEntity::class)->getTableName();
$scheduledTasksTable = $this->entityManager->getClassMetadata(ScheduledTaskEntity::class)->getTableName();
$scheduledTaskSubscribersTable = $this->entityManager->getClassMetadata(ScheduledTaskSubscriberEntity::class)->getTableName();
$sendingQueuesTable = $this->entityManager->getClassMetadata(SendingQueueEntity::class)->getTableName();
// Temporarily skip the query in WP Playground.
// UPDATE with JOIN is not yet supported by the SQLite integration.
if (Connection::isSQLite()) {
return;
}
$this->entityManager->getConnection()->executeStatement(
"
UPDATE $newslettersTable n
JOIN $sendingQueuesTable q ON n.id = q.newsletter_id
JOIN $scheduledTasksTable t ON q.task_id = t.id
SET n.sent_at = COALESCE(
(
-- use 'updated_at' of processed subscriber with the highest ID ('MAX(subscriber_id)' can use index)
SELECT updated_at FROM $scheduledTaskSubscribersTable WHERE task_id = t.id AND subscriber_id = (
SELECT MAX(subscriber_id) FROM $scheduledTaskSubscribersTable WHERE task_id = t.id
)
),
t.updated_at
)
WHERE q.newsletter_id IN (:ids)
",
['ids' => $ids],
['ids' => ArrayParameterType::INTEGER]
);
}
}
@@ -0,0 +1,74 @@
<?php declare(strict_types = 1);
namespace MailPoet\Migrations\App;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\StatisticsWooCommercePurchaseEntity;
use MailPoet\Migrator\AppMigration;
use MailPoet\WooCommerce\Helper;
use MailPoetVendor\Doctrine\DBAL\ArrayParameterType;
class Migration_20240322_110443_App extends AppMigration {
public function run(): void {
$wooCommerceHelper = $this->container->get(Helper::class);
// If Woo is not active and the table doesn't exist, we can skip this migration
if (!$wooCommerceHelper->isWooCommerceActive()) {
return;
}
$purchaseStatisticsTable = $this->getTableName(StatisticsWooCommercePurchaseEntity::class);
$purchaseStatistics = $this->entityManager->getConnection()->fetchAllAssociative("
SELECT order_id
FROM {$purchaseStatisticsTable}
");
global $wpdb;
if ($wooCommerceHelper->isWooCommerceCustomOrdersTableEnabled()) {
$ordersTable = $wooCommerceHelper->getOrdersTableName();
$query = "
SELECT id AS order_id, status AS status
FROM `{$ordersTable}`
WHERE type = 'shop_order' AND id in (:orderIds)
";
} else {
$query = "
SELECT wpp.id AS order_id, wpp.post_status AS status
FROM `{$wpdb->posts}` wpp
WHERE wpp.post_type = 'shop_order'
AND wpp.ID in (:orderIds)
";
}
foreach (array_chunk($purchaseStatistics, 2) as $chunk) {
$orderIds = array_column($chunk, 'order_id');
/** @var array{order_id: int, status: string}[] $orders */
$orders = $this->entityManager->getConnection()->executeQuery(
$query,
['orderIds' => $orderIds],
['orderIds' => ArrayParameterType::INTEGER],
)->fetchAllAssociative();
foreach ($orders as $order) {
$this->entityManager->getConnection()->executeStatement("
UPDATE {$purchaseStatisticsTable}
SET status = :status
WHERE order_id = :orderId
", [
'orderId' => $order['order_id'],
'status' => str_replace('wc-', '', $order['status']), // WC order status in DB is prefixed with 'wc-'
]);
}
}
}
/**
* @param class-string $entityClassName
*/
private function getTableName(string $entityClassName): string {
return $this->entityManager->getClassMetadata($entityClassName)->getTableName();
}
}
@@ -0,0 +1,30 @@
<?php declare(strict_types = 1);
namespace MailPoet\Migrations\App;
if (!defined('ABSPATH')) exit;
use MailPoet\Config\Hooks;
use MailPoet\Migrator\AppMigration;
use MailPoet\Settings\SettingsController;
use MailPoet\WooCommerce\Subscription;
class Migration_20240730_212419_App extends AppMigration {
private SettingsController $settings;
public function run(): void {
$this->settings = $this->container->get(SettingsController::class);
// Skip if the opt-in checkbox is not enabled or the position is already set.
// When enabling, the user chooses the position of the opt-in checkbox
// on the same settings page.
$optInEnabled = $this->settings->get(Subscription::OPTIN_ENABLED_SETTING_NAME, false);
$optInPosition = $this->settings->get(Subscription::OPTIN_POSITION_SETTING_NAME, null);
if (!$optInEnabled || $optInPosition) {
return;
}
// Set previous default value for existing installations
$this->settings->set(Subscription::OPTIN_POSITION_SETTING_NAME, Hooks::OPTIN_POSITION_BEFORE_TERMS_AND_CONDITIONS);
}
}
@@ -0,0 +1,36 @@
<?php declare(strict_types = 1);
namespace MailPoet\Migrations\App;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\NewsletterEntity;
use MailPoet\Migrator\AppMigration;
use MailPoet\Newsletter\NewslettersRepository;
use MailPoet\Settings\SettingsController;
use MailPoet\Subscribers\ConfirmationEmailCustomizer;
use MailPoet\Util\Security;
/**
* Fixes confirmation emails with missing hash.
* These emails were created by plugin before we fixed [MAILPOET-6273]
*/
class Migration_20241015_105511_App extends AppMigration {
public function run(): void {
$settings = $this->container->get(SettingsController::class);
$confirmationEmailTemplateId = (int)$settings->get(ConfirmationEmailCustomizer::SETTING_EMAIL_ID, null);
if (!$confirmationEmailTemplateId) {
return;
}
$repository = $this->container->get(NewslettersRepository::class);
$confirmationEmail = $repository->findOneById($confirmationEmailTemplateId);
if (!$confirmationEmail instanceof NewsletterEntity || $confirmationEmail->getHash()) {
return;
}
$confirmationEmail->setHash(Security::generateHash());
$repository->flush();
}
}
@@ -0,0 +1,72 @@
<?php declare(strict_types = 1);
namespace MailPoet\Migrations\App;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\NewsletterEntity;
use MailPoet\Entities\ScheduledTaskEntity;
use MailPoet\Entities\ScheduledTaskSubscriberEntity;
use MailPoet\Migrator\AppMigration;
use MailPoet\Newsletter\NewslettersRepository;
use MailPoetVendor\Carbon\Carbon;
/**
* Some newsletters might have an incorrect status due to a bug where we set the status 'sending'
* to automation emails.
*
* See https://mailpoet.atlassian.net/browse/MAILPOET-6241
*/
class Migration_20241128_114257_App extends AppMigration {
public function run(): void {
$newsletterRepository = $this->container->get(NewslettersRepository::class);
$newsletters = $newsletterRepository->findBy([
'type' => NewsletterEntity::ACTIVABLE_EMAILS,
'status' => NewsletterEntity::STATUS_SENDING,
]);
foreach ($newsletters as $newsletter) {
$newsletter->setStatus(NewsletterEntity::STATUS_ACTIVE);
// As a consequence of the bug, some tasks might be paused, we need to unpause them
$this->updateTasks($newsletter);
}
}
private function updateTasks(NewsletterEntity $newsletter): void {
$oldTaskThreshold = (new Carbon())->subDays(30);
$queues = $newsletter->getUnfinishedQueues();
foreach ($queues as $queue) {
$task = $queue->getTask();
// Switch relatively new paused tasks to scheduled
if ($task && ($task->getScheduledAt() > $oldTaskThreshold) && $task->getStatus() === ScheduledTaskEntity::STATUS_PAUSED) {
$task->setStatus(ScheduledTaskEntity::STATUS_SCHEDULED);
$this->entityManager->flush();
continue;
}
// Switch old paused tasks to completed and mark scheduled task subscribers as failed
// This will prevent sending outdated automatic emails. When marked as failed, the user still can resend them in Sending Status screen.
if ($task && ($task->getScheduledAt() <= $oldTaskThreshold) && $task->getStatus() === ScheduledTaskEntity::STATUS_PAUSED) {
$task->setStatus(ScheduledTaskEntity::STATUS_COMPLETED);
$task->setProcessedAt(new Carbon());
$this->entityManager->flush();
$scheduledTaskSubscribersTable = $this->entityManager->getClassMetadata(ScheduledTaskSubscriberEntity::class)->getTableName();
$this->entityManager->getConnection()->executeQuery(
"UPDATE $scheduledTaskSubscribersTable
SET `processed` = :processed, `failed` = :failed, `error` = :error
WHERE task_id = :task_id",
[
'processed' => ScheduledTaskSubscriberEntity::STATUS_PROCESSED,
'failed' => ScheduledTaskSubscriberEntity::FAIL_STATUS_FAILED,
'error' => 'Sending timed out for being paused too long.',
'task_id' => $task->getId(),
]
);
$this->entityManager->refresh($task);
}
}
$this->entityManager->flush();
}
}
@@ -0,0 +1,46 @@
<?php declare(strict_types = 1);
namespace MailPoet\Migrations\App;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\NewsletterLinkEntity;
use MailPoet\Entities\SendingQueueEntity;
use MailPoet\Migrator\AppMigration;
/**
* The plugin from the version 5.5.2 to 5.6.1 contained a bug when we stored links containing &amp; and in some cases also links with `&amp;amp;` in the database.
* This migration fixes the issue by replacing `&amp;amp;` with `&amp; and then &amp; with &`.
*
* See https://mailpoet.atlassian.net/browse/MAILPOET-6433
*/
class Migration_20250120_094614_App extends AppMigration {
public function run(): void {
$sendingQueueId = $this->getSendingQueueId();
if ($sendingQueueId) {
$linksTable = $this->entityManager->getClassMetadata(NewsletterLinkEntity::class)->getTableName();
$this->entityManager->getConnection()->executeQuery("
UPDATE {$linksTable}
SET url = REPLACE( REPLACE(url, '&amp;amp;', '&amp;'), '&amp;', '&')
WHERE queue_id >= :queue_id;
", ['queue_id' => $sendingQueueId]);
}
}
private function getSendingQueueId(): ?int {
$qb = $this->entityManager->createQueryBuilder();
/** @var array{id: number}|null $result */
$result = $qb->select('sq.id AS id')
->from(SendingQueueEntity::class, 'sq')
->where(
$qb->expr()->gt('sq.createdAt', ':date')
)
->orderBy('sq.id', 'ASC')
->setMaxResults(1)
->setParameter('date', '2024-12-24:00:00:00')
->getQuery()
->getOneOrNullResult();
return $result ? (int)$result['id'] : null;
}
}
@@ -0,0 +1 @@
<?php