From 3f1e690d906a565cb436043dafb439a75dbd5e6f Mon Sep 17 00:00:00 2001 From: wxa Date: Wed, 30 Jan 2019 16:26:44 +0300 Subject: [PATCH] Add WooCommerce synchronization [MAILPOET-1723] --- lib/Config/Hooks.php | 38 +++- lib/Config/Migrator.php | 2 +- lib/DI/ContainerConfigurator.php | 1 + lib/Segments/WooCommerce.php | 263 ++++++++++++++++++++++++++ tests/integration/Config/MenuTest.php | 5 +- 5 files changed, 305 insertions(+), 4 deletions(-) create mode 100644 lib/Segments/WooCommerce.php diff --git a/lib/Config/Hooks.php b/lib/Config/Hooks.php index 594afbd25e..e0e23b1e09 100644 --- a/lib/Config/Hooks.php +++ b/lib/Config/Hooks.php @@ -4,6 +4,7 @@ namespace MailPoet\Config; use MailPoet\Settings\SettingsController; use MailPoet\Subscription\Form; +use MailPoet\Segments\WooCommerce as WooCommerceSegment; use MailPoet\WP\Functions as WPFunctions; class Hooks { @@ -17,18 +18,24 @@ class Hooks { /** @var WPFunctions */ private $wp; + /** @var WooCommerceSegment */ + private $woocommerce_segment; + function __construct( Form $subscription_form, SettingsController $settings, - WPFunctions $wp + WPFunctions $wp, + WooCommerceSegment $woocommerce_segment ) { $this->subscription_form = $subscription_form; $this->settings = $settings; $this->wp = $wp; + $this->woocommerce_segment = $woocommerce_segment; } function init() { $this->setupWPUsers(); + $this->setupWooCommerceUsers(); $this->setupImageSize(); $this->setupListing(); $this->setupSubscriptionEvents(); @@ -158,6 +165,35 @@ class Hooks { ); } + function setupWooCommerceUsers() { + // WooCommerce Customers synchronization + add_action( + 'woocommerce_new_customer', + [$this->woocommerce_segment, 'synchronizeRegisteredCustomer'], + 7 + ); + add_action( + 'woocommerce_update_customer', + [$this->woocommerce_segment, 'synchronizeRegisteredCustomer'], + 7 + ); + add_action( + 'woocommerce_delete_customer', + [$this->woocommerce_segment, 'synchronizeRegisteredCustomer'], + 7 + ); + add_action( + 'woocommerce_checkout_update_order_meta', + [$this->woocommerce_segment, 'synchronizeGuestCustomer'], + 7 + ); + add_action( + 'woocommerce_process_shop_order_meta', + [$this->woocommerce_segment, 'synchronizeGuestCustomer'], + 7 + ); + } + function setupImageSize() { add_filter( 'image_size_names_choose', diff --git a/lib/Config/Migrator.php b/lib/Config/Migrator.php index c9504b05d3..26fc3cc7c9 100644 --- a/lib/Config/Migrator.php +++ b/lib/Config/Migrator.php @@ -197,7 +197,7 @@ class Migrator { 'updated_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,', 'deleted_at timestamp NULL,', 'unconfirmed_data longtext,', - "source enum('form','imported','administrator','api','wordpress_user','unknown') DEFAULT 'unknown',", + "source enum('form','imported','administrator','api','wordpress_user','woocommerce_user','unknown') DEFAULT 'unknown',", 'count_confirmations int(11) unsigned NOT NULL DEFAULT 0,', 'PRIMARY KEY (id),', 'UNIQUE KEY email (email),', diff --git a/lib/DI/ContainerConfigurator.php b/lib/DI/ContainerConfigurator.php index 7a54be5fe1..6f5d145b55 100644 --- a/lib/DI/ContainerConfigurator.php +++ b/lib/DI/ContainerConfigurator.php @@ -80,6 +80,7 @@ class ContainerConfigurator implements IContainerConfigurator { $container->autowire(\MailPoet\Subscribers\RequiredCustomFieldValidator::class)->setPublic(true); // Segments $container->autowire(\MailPoet\Segments\SubscribersListings::class)->setPublic(true); + $container->autowire(\MailPoet\Segments\WooCommerce::class)->setPublic(true); // Settings $container->autowire(\MailPoet\Settings\SettingsController::class)->setPublic(true); // Subscription diff --git a/lib/Segments/WooCommerce.php b/lib/Segments/WooCommerce.php new file mode 100644 index 0000000000..2f35cfd968 --- /dev/null +++ b/lib/Segments/WooCommerce.php @@ -0,0 +1,263 @@ +unsubscribeUsersFromSegment(); // remove leftover association + break; + case 'woocommerce_new_customer': + $new_customer = true; + case 'woocommerce_update_customer': + default: + $wp_user = \get_userdata($wp_user_id); + $subscriber = Subscriber::where('wp_user_id', $wp_user_id) + ->findOne(); + + if ($wp_user === false || $subscriber === false) { + // registered customers should exist as WP users and WP segment subscribers + return false; + } + + $data = array( + 'is_woocommerce_user' => 1, + ); + if (!empty($new_customer)) { + $data['status'] = Subscriber::STATUS_SUBSCRIBED; + $data['source'] = Source::WOOCOMMERCE_USER; + } + $data['id'] = $subscriber->id(); + $data['deleted_at'] = null; // remove the user from the trash + + $subscriber = Subscriber::createOrUpdate($data); + if($subscriber->getErrors() === false && $subscriber->id > 0) { + // add subscriber to the WooCommerce Customers segment + SubscriberSegment::subscribeToSegments( + $subscriber, + array($wc_segment->id) + ); + } + break; + } + + return true; + } + + function synchronizeGuestCustomer($order_id, $current_filter = null) { + $wc_order = \get_post($order_id); + $wc_segment = Segment::getWooCommerceSegment(); + + if($wc_order === false or $wc_segment === false) return; + + $current_filter = $current_filter ?: current_filter(); + switch($current_filter) { + case 'woocommerce_checkout_update_order_meta': + case 'woocommerce_process_shop_order_meta': + default: + $inserted_emails = $this->insertSubscribersFromOrders($order_id); + if (empty($inserted_emails[0]['email'])) { + return false; + } + $subscriber = Subscriber::where('email', $inserted_emails[0]['email']) + ->findOne(); + + if($subscriber !== false) { + // add subscriber to the WooCommerce Customers segment + SubscriberSegment::subscribeToSegments( + $subscriber, + array($wc_segment->id) + ); + } + break; + } + } + + function synchronizeCustomers() { + + WP::synchronizeUsers(); // synchronize registered users + + $this->markRegisteredCustomers(); + $inserted_users_emails = $this->insertSubscribersFromOrders(); + $this->removeUpdatedSubscribersWithInvalidEmail($inserted_users_emails); + $this->removeFromTrash(); + $this->updateFirstNames(); + $this->updateLastNames(); + $this->insertUsersToSegment(); + $this->unsubscribeUsersFromSegment(); + $this->removeOrphanedSubscribers(); + + return true; + } + + private function markRegisteredCustomers() { + // Mark WP users having a customer role as WooCommerce subscribers + global $wpdb; + $subscribers_table = Subscriber::$_table; + Subscriber::raw_execute(sprintf(' + UPDATE %1$s mps + JOIN %2$s wu ON mps.wp_user_id = wu.id + JOIN %3$s wpum ON wu.id = wpum.user_id AND wpum.meta_key = "wpdev_capabilities" + SET is_woocommerce_user = 1 + WHERE wpum.meta_value LIKE "%%\"customer\"%%" + ', $subscribers_table, $wpdb->users, $wpdb->usermeta)); + } + + private function insertSubscribersFromOrders($order_id = null) { + global $wpdb; + $subscribers_table = Subscriber::$_table; + $order_id = !is_null($order_id) ? (int)$order_id : null; + + $inserted_users_emails = \ORM::for_table($wpdb->users)->raw_query( + 'SELECT DISTINCT wppm.meta_value as email FROM `wpdev_postmeta` wppm + JOIN `wpdev_posts` p ON wppm.post_id = p.ID AND p.post_type = "shop_order" + WHERE wppm.meta_key = "_billing_email" AND wppm.meta_value != "" + ' . ($order_id ? ' AND p.ID = "' . $order_id . '"' : '') . ' + ')->findArray(); + + Subscriber::raw_execute(sprintf(' + INSERT IGNORE INTO %1$s (is_woocommerce_user, email, status, created_at, source) + SELECT 1, wppm.meta_value, "%2$s", CURRENT_TIMESTAMP(), "%3$s" FROM `wpdev_postmeta` wppm + JOIN `wpdev_posts` p ON wppm.post_id = p.ID AND p.post_type = "shop_order" + WHERE wppm.meta_key = "_billing_email" AND wppm.meta_value != "" + ' . ($order_id ? ' AND p.ID = "' . $order_id . '"' : '') . ' + ON DUPLICATE KEY UPDATE is_woocommerce_user = 1 + ', $subscribers_table, Subscriber::STATUS_SUBSCRIBED, Source::WOOCOMMERCE_USER)); + + return $inserted_users_emails; + } + + private function removeUpdatedSubscribersWithInvalidEmail($updated_emails) { + $validator = new ModelValidator(); + $invalid_is_woocommerce_users = array_map(function($item) { + return $item['email']; + }, + array_filter($updated_emails, function($updated_email) use($validator) { + return !$validator->validateEmail($updated_email['email']); + })); + if(!$invalid_is_woocommerce_users) { + return; + } + \ORM::for_table(Subscriber::$_table) + ->whereNull('wp_user_id') + ->where('is_woocommerce_user', 1) + ->whereIn('email', $invalid_is_woocommerce_users) + ->delete_many(); + } + + private function updateFirstNames() { + global $wpdb; + $subscribers_table = Subscriber::$_table; + Subscriber::raw_execute(sprintf(' + UPDATE %1$s mps + JOIN %2$s wppm ON mps.email = wppm.meta_value AND wppm.meta_key = "_billing_email" + JOIN %2$s wppm2 ON wppm2.post_id = wppm.post_id AND wppm2.meta_key = "_billing_first_name" + JOIN (SELECT MAX(post_id) AS max_id FROM %2$s WHERE meta_key = "_billing_email" GROUP BY meta_value) AS tmaxid ON tmaxid.max_id = wppm.post_id + SET mps.first_name = wppm2.meta_value + WHERE mps.first_name = "" + AND mps.is_woocommerce_user = 1 + AND wppm2.meta_value IS NOT NULL + ', $subscribers_table, $wpdb->postmeta)); + } + + private function updateLastNames() { + global $wpdb; + $subscribers_table = Subscriber::$_table; + Subscriber::raw_execute(sprintf(' + UPDATE %1$s mps + JOIN %2$s wppm ON mps.email = wppm.meta_value AND wppm.meta_key = "_billing_email" + JOIN %2$s wppm2 ON wppm2.post_id = wppm.post_id AND wppm2.meta_key = "_billing_last_name" + JOIN (SELECT MAX(post_id) AS max_id FROM %2$s WHERE meta_key = "_billing_email" GROUP BY meta_value) AS tmaxid ON tmaxid.max_id = wppm.post_id + SET mps.last_name = wppm2.meta_value + WHERE mps.last_name = "" + AND mps.is_woocommerce_user = 1 + AND wppm2.meta_value IS NOT NULL + ', $subscribers_table, $wpdb->postmeta)); + } + + private function insertUsersToSegment() { + $wc_segment = Segment::getWooCommerceSegment(); + $subscribers_table = Subscriber::$_table; + $wp_mailpoet_subscriber_segment_table = SubscriberSegment::$_table; + // Subscribe WC users to segment + Subscriber::raw_execute(sprintf(' + INSERT IGNORE INTO %s (subscriber_id, segment_id, created_at) + SELECT mps.id, "%s", CURRENT_TIMESTAMP() FROM %s mps + WHERE mps.is_woocommerce_user = 1 + ', $wp_mailpoet_subscriber_segment_table, $wc_segment->id, $subscribers_table)); + } + + private function unsubscribeUsersFromSegment() { + $wc_segment = Segment::getWooCommerceSegment(); + $subscribers_table = Subscriber::$_table; + $wp_mailpoet_subscriber_segment_table = SubscriberSegment::$_table; + // Unsubscribe non-WC or invalid users from segment + Subscriber::raw_execute(sprintf(' + DELETE mpss FROM %s mpss + LEFT JOIN %s mps ON mpss.subscriber_id = mps.id + WHERE mpss.segment_id = %s AND (mps.is_woocommerce_user = 0 OR mps.email = "" OR mps.email IS NULL) + ', $wp_mailpoet_subscriber_segment_table, $subscribers_table, $wc_segment->id)); + } + + private function removeFromTrash() { + $subscribers_table = Subscriber::$_table; + Subscriber::raw_execute(sprintf(' + UPDATE %1$s + SET %1$s.deleted_at = NULL + WHERE %1$s.is_woocommerce_user = 1 + ', $subscribers_table)); + } + + private function removeOrphanedSubscribers() { + // Remove orphaned WooCommerce segment subscribers (not having a matching WC customer email), + // e.g. if WC orders were deleted directly from the database + // or a customer role was revoked and a user has no orders + global $wpdb; + + $wc_segment = Segment::getWooCommerceSegment(); + + // Unmark registered customers + $set = $wc_segment->subscribers() + ->leftOuterJoin( + $wpdb->postmeta, + 'wppm.meta_key = "_billing_email" AND ' . MP_SUBSCRIBERS_TABLE . '.email = wppm.meta_value', + 'wppm' + ) + ->leftOuterJoin($wpdb->posts, 'wppm.post_id = wpp.ID AND wpp.post_type = "shop_order"', 'wpp') + ->join($wpdb->usermeta, 'wp_user_id = wpum.user_id AND wpum.meta_key = "wpdev_capabilities"', 'wpum') + ->whereRaw('(wppm.meta_value IS NULL AND wpum.meta_value NOT LIKE "%\"customer\"%")') + ->whereNotNull('wp_user_id') + ->findResultSet() + ->set('is_woocommerce_user', 0) + ->save(); + + // Remove guest customers + $wc_segment->subscribers() + ->leftOuterJoin( + $wpdb->postmeta, + 'wppm.meta_key = "_billing_email" AND ' . MP_SUBSCRIBERS_TABLE . '.email = wppm.meta_value', + 'wppm' + ) + ->leftOuterJoin($wpdb->posts, 'wppm.post_id = wpp.ID AND wpp.post_type = "shop_order"', 'wpp') + ->whereRaw('(wppm.meta_value IS NULL OR wpp.ID IS NULL OR ' . MP_SUBSCRIBERS_TABLE . '.email = "")') + ->whereNull('wp_user_id') + ->findResultSet() + ->set('is_woocommerce_user', 0) + ->delete(); + } +} diff --git a/tests/integration/Config/MenuTest.php b/tests/integration/Config/MenuTest.php index 691f268b2b..ae75fd8e6b 100644 --- a/tests/integration/Config/MenuTest.php +++ b/tests/integration/Config/MenuTest.php @@ -8,6 +8,7 @@ use MailPoet\Config\Menu; use MailPoet\Config\Renderer; use MailPoet\Config\ServicesChecker; use MailPoet\Settings\SettingsController; +use MailPoet\WooCommerce\Helper as WooCommerceHelper; use MailPoet\WP\Functions; class MenuTest extends \MailPoetTest { @@ -42,7 +43,7 @@ class MenuTest extends \MailPoetTest { function testItChecksMailpoetAPIKey() { $renderer = Stub::make(new Renderer()); - $menu = new Menu($renderer, new AccessControl(), new SettingsController(), new Functions()); + $menu = new Menu($renderer, new AccessControl(), new SettingsController(), new Functions(), new WooCommerceHelper); $_REQUEST['page'] = 'mailpoet-newsletters'; $checker = Stub::make( @@ -64,7 +65,7 @@ class MenuTest extends \MailPoetTest { function testItChecksPremiumKey() { $renderer = Stub::make(new Renderer()); - $menu = new Menu($renderer, new AccessControl(), new SettingsController(), new Functions()); + $menu = new Menu($renderer, new AccessControl(), new SettingsController(), new Functions(), new WooCommerceHelper); $_REQUEST['page'] = 'mailpoet-newsletters'; $checker = Stub::make(