PluginProbe ʕ •ᴥ•ʔ
MailPoet – Newsletters, Email Marketing, and Automation / 5.34.2
MailPoet – Newsletters, Email Marketing, and Automation v5.34.2
5.34.2 5.34.1 5.34.0 5.33.1 5.33.0 5.32.0 5.31.0 5.30.0 5.29.0 5.28.1 5.28.0 5.27.0 5.26.0 5.26.1 5.25.0 5.24.0 4.43.0 4.43.1 4.44.0 4.44.1 4.45.0 4.46.0 4.47.0 4.48.0 4.48.1 4.48.2 4.49.0 4.49.1 4.5.0 4.5.1 4.5.2 4.50.0 4.50.1 4.51.0 4.51.1 4.51.2 4.52.0 4.53.0 4.54.0 4.55.0 4.56.0 4.57.0 4.58.0 4.58.1 4.58.2 4.6.0 4.6.1 4.6.2 4.7.0 4.7.1 4.8.0 4.8.1 4.9.0 5.0.0 5.0.1 5.0.2 5.1.0 5.1.1 5.10.0 5.10.1 5.11.0 5.12.0 5.12.1 5.12.10 5.12.11 5.12.12 5.12.13 5.12.2 5.12.3 5.12.4 5.12.5 5.12.6 5.12.7 5.12.8 5.12.9 5.13.0 5.13.1 5.13.2 5.14.0 5.14.1 5.14.2 5.14.3 5.15.0 5.15.1 5.16.0 5.16.1 5.16.2 5.16.3 5.16.4 5.17.0 5.17.1 5.17.2 5.17.3 5.17.4 5.17.5 5.17.6 5.18.0 5.19.0 5.2.0 5.2.1 5.2.2 5.2.3 5.20.0 5.21.0 5.21.1 5.21.2 5.21.3 5.22.0 5.22.1 5.22.2 5.22.3 5.22.4 5.23.0 5.23.1 5.23.2 5.3.0 5.3.1 5.3.2 5.3.3 5.3.4 5.3.5 5.3.6 5.3.7 5.4.0 5.4.1 5.4.2 5.5.0 5.5.1 5.5.2 5.6.0 5.6.1 5.6.2 5.6.3 5.6.4 5.7.0 5.7.1 5.8.0 5.8.1 5.9.0 3.0.0-beta.15 3.7.1 3.0.0-beta.16 3.7.2 3.0.0-beta.17 3.7.3 3.0.0-beta.18 3.7.4 3.0.0-beta.19 3.7.5 3.0.0-beta.2 3.7.6 3.0.0-beta.20 3.7.8 3.0.0-beta.21 3.70.0 3.0.0-beta.22 3.71.0 3.0.0-beta.23 3.71.1 3.0.0-beta.23.1 3.71.2 3.0.0-beta.23.2 3.71.3 3.0.0-beta.24 3.72.0 3.0.0-beta.25 3.73.0 3.0.0-beta.26 3.73.1 3.0.0-beta.27 3.73.2 3.0.0-beta.28 3.74.0 3.0.0-beta.29 3.74.1 3.0.0-beta.3 3.74.2 3.0.0-beta.30 3.74.3 3.0.0-beta.31 3.75.0 3.0.0-beta.32 3.75.1 3.0.0-beta.33 3.76.0 3.0.0-beta.33.1 3.77.0 3.0.0-beta.34.0.0 3.77.1 3.0.0-beta.36.0.0 3.78.0 3.0.0-beta.36.0.1 3.79.0 3.0.0-beta.36.2.0 3.8 3.0.0-beta.36.3.0 3.8.1 3.0.0-beta.36.3.1 3.8.2 3.0.0-beta.37.0.0 3.8.3 3.0.0-beta.4 3.8.4 3.0.0-beta.5 3.8.5 3.0.0-beta.6 3.8.6 3.0.0-beta.7 3.80.0 3.0.0-beta.7.1 3.81.0 3.0.0-beta.8 3.82.0 3.0.0-beta.9 3.83.0 3.0.0-rc.1.0.0 3.84.0 3.0.0-rc.1.0.1 3.84.1 3.0.0-rc.1.0.2 3.85.0 3.0.0-rc.1.0.3 3.85.1 3.0.0-rc.1.0.4 3.86.0 3.0.0-rc.2.0.0 3.87.0 3.0.0-rc.2.0.1 3.87.1 3.0.0-rc.2.0.2 3.87.2 3.0.0-rc.2.0.3 3.88.0 3.0.1 3.88.1 3.0.2 3.88.2 3.0.3 3.89.0 3.0.4 3.89.1 3.0.5 3.89.2 3.0.6 3.89.3 3.0.7 3.89.4 3.0.8 3.9.0 3.0.9 3.9.1 3.1.0 3.90.0 3.10 3.90.1 3.10.1 3.90.2 3.100.0 3.91.0 3.100.1 3.91.1 3.100.2 3.92.0 3.101.0 3.92.1 3.101.1 3.93.0 3.102.0 3.93.1 3.102.1 3.94.0 3.103.0 3.95.0 3.103.1 3.95.1 3.11.0 3.96.0 3.11.1 3.96.1 3.11.2 3.97.0 3.11.3 3.98.0 3.11.4 3.98.1 3.11.5 3.99.0 3.12.0 3.99.1 3.12.1 4.0.0 3.13.0 4.0.1 3.14.0 4.1.0 3.14.1 4.1.1 3.15.0 4.10.0 3.16.0 4.11.0 3.16.1 4.11.1 3.16.2 4.12.0 3.16.3 4.12.1 3.17.0 4.12.2 3.17.1 4.13.0 3.17.2 4.14.0 3.18.0 4.15.0 3.18.1 4.16.0 3.18.2 4.17.0 3.19.0 4.17.1 3.19.1 4.18.0 3.19.2 4.18.1 3.19.3 4.19.0 3.2.0 4.2.0 3.2.1 4.20.0 3.2.2 4.20.1 3.2.3 4.20.2 3.2.4 4.21.0 3.2.5 4.22.0 3.20.0 4.22.1 3.21.0 4.22.2 3.21.1 4.23.0 3.22.0 4.24.0 3.23.0 4.25.0 3.23.1 4.26.0 3.23.2 4.26.1 3.24.0 4.27.0 3.25.0 4.28.0 3.25.1 4.29.0 3.26.0 4.3.0 3.26.1 4.3.1 3.27.0 4.30.0 3.28.0 4.31.0 3.29.0 4.31.1 3.3.0 4.32.0 3.3.1 4.33.0 3.3.2 4.34.0 3.3.3 4.35.0 3.3.4 4.35.1 3.3.5 4.36.0 3.3.6 4.37.0 3.30.0 4.38.0 3.31.0 4.39.0 3.31.1 4.4.0 3.32.0 4.40.0 3.32.1 4.41.0 3.32.2 4.41.1 3.33.0 4.41.2 3.34.0 4.41.3 3.34.1 4.42.0 3.34.2 4.42.1 3.34.3 3.34.4 3.35.0 3.35.1 3.35.3 3.35.4 3.36.0 3.37.0 3.37.1 3.37.2 3.37.3 3.38.0 3.38.1 3.39.0 3.39.1 3.39.2 3.4.0 3.4.1 3.4.2 3.4.3 3.4.4 3.40.0 3.40.1 3.41.0 3.41.1 3.41.2 3.42.0 3.42.1 3.42.2 3.42.3 3.43.0 3.43.1 3.44.0 3.45.0 3.45.1 3.46.0 3.46.1 3.46.10 3.46.11 3.46.12 3.46.13 3.46.14 3.46.2 3.46.3 3.46.4 3.46.5 3.46.6 3.46.7 3.46.8 3.46.9 3.47.0 3.47.1 3.47.10 3.47.11 3.47.2 3.47.3 3.47.5 3.47.6 3.47.7 3.47.9 3.48.0 3.48.1 3.49.0 3.49.1 3.5.0 3.5.1 3.50.0 3.51.0 3.51.1 3.51.2 3.52.0 3.53.0 3.54.0 3.54.1 3.54.2 3.54.3 3.55.0 3.55.1 3.56.0 3.56.1 3.56.2 3.57.0 3.57.1 3.58.0 3.59.0 3.59.1 3.59.2 3.6.0 3.6.1 3.6.2 3.6.3 3.6.4 3.6.5 3.6.6 3.6.7 3.60.0 3.60.1 3.60.10 3.60.11 3.60.12 3.60.2 3.60.3 3.60.4 3.60.6 3.60.7 3.60.8 3.60.9 3.61.0 3.62.0 3.62.1 3.63.0 3.64.0 3.64.1 3.64.2 3.64.3 3.65.0 trunk 3.65.1 3.0.0 3.66.0 3.0.0-beta.1 3.67.0 3.0.0-beta.10 3.67.1 3.0.0-beta.11 3.68.0 3.0.0-beta.12 3.69.0 3.0.0-beta.13 3.69.1 3.0.0-beta.14 3.7.0
mailpoet / lib / Segments / WooCommerce.php
mailpoet / lib / Segments Last commit date
DynamicSegments 1 month ago RestApi 1 month ago SegmentDependencyValidator.php 3 years ago SegmentListingRepository.php 1 month ago SegmentSaveController.php 2 weeks ago SegmentSubscribersRepository.php 2 weeks ago SegmentsFinder.php 3 years ago SegmentsRepository.php 2 weeks ago SegmentsSimpleListRepository.php 2 months ago SubscribersFinder.php 1 month ago WP.php 2 weeks ago WooCommerce.php 2 weeks ago index.php 3 years ago
WooCommerce.php
669 lines
1 <?php declare(strict_types = 1);
2
3 namespace MailPoet\Segments;
4
5 if (!defined('ABSPATH')) exit;
6
7
8 use MailPoet\Config\Env;
9 use MailPoet\Config\SubscriberChangesNotifier;
10 use MailPoet\Entities\SubscriberEntity;
11 use MailPoet\Entities\SubscriberSegmentEntity;
12 use MailPoet\Services\Validator;
13 use MailPoet\Settings\SettingsController;
14 use MailPoet\Subscribers\SegmentsCountRecalculator;
15 use MailPoet\Subscribers\Source;
16 use MailPoet\Subscribers\SubscriberSaveController;
17 use MailPoet\Subscribers\SubscriberSegmentRepository;
18 use MailPoet\Subscribers\SubscribersRepository;
19 use MailPoet\WooCommerce\Helper as WCHelper;
20 use MailPoet\WooCommerce\Subscription;
21 use MailPoet\WP\Functions as WPFunctions;
22 use MailPoetVendor\Carbon\Carbon;
23 use MailPoetVendor\Doctrine\DBAL\ArrayParameterType;
24 use MailPoetVendor\Doctrine\DBAL\Connection;
25 use MailPoetVendor\Doctrine\DBAL\ParameterType;
26 use MailPoetVendor\Doctrine\ORM\EntityManager;
27
28 class WooCommerce {
29 /** @var SettingsController */
30 private $settings;
31
32 /** @var WPFunctions */
33 private $wp;
34
35 /** @var WP */
36 private $wpSegment;
37
38 /** @var string|null */
39 private $mailpoetEmailCollation;
40
41 /** @var string|null */
42 private $wpPostmetaValueCollation;
43
44 /** @var SubscribersRepository */
45 private $subscribersRepository;
46
47 /** @var SegmentsRepository */
48 private $segmentsRepository;
49
50 /** @var SubscriberSegmentRepository */
51 private $subscriberSegmentRepository;
52
53 /** @var SubscriberSaveController */
54 private $subscriberSaveController;
55
56 /** @var WCHelper */
57 private $woocommerceHelper;
58
59 /** @var EntityManager */
60 private $entityManager;
61
62 /** @var Connection */
63 private $connection;
64
65 /** @var SubscriberChangesNotifier */
66 private $subscriberChangesNotifier;
67
68 /** @var Validator */
69 private $validator;
70
71 /** @var SegmentsCountRecalculator */
72 private $segmentsCountRecalculator;
73
74 public function __construct(
75 SettingsController $settings,
76 WPFunctions $wp,
77 WCHelper $woocommerceHelper,
78 SubscribersRepository $subscribersRepository,
79 SegmentsRepository $segmentsRepository,
80 SubscriberSegmentRepository $subscriberSegmentRepository,
81 SubscriberSaveController $subscriberSaveController,
82 WP $wpSegment,
83 EntityManager $entityManager,
84 Connection $connection,
85 SubscriberChangesNotifier $subscriberChangesNotifier,
86 Validator $validator,
87 SegmentsCountRecalculator $segmentsCountRecalculator
88 ) {
89 $this->settings = $settings;
90 $this->wp = $wp;
91 $this->wpSegment = $wpSegment;
92 $this->subscribersRepository = $subscribersRepository;
93 $this->segmentsRepository = $segmentsRepository;
94 $this->subscriberSegmentRepository = $subscriberSegmentRepository;
95 $this->subscriberSaveController = $subscriberSaveController;
96 $this->woocommerceHelper = $woocommerceHelper;
97 $this->entityManager = $entityManager;
98 $this->connection = $connection;
99 $this->subscriberChangesNotifier = $subscriberChangesNotifier;
100 $this->validator = $validator;
101 $this->segmentsCountRecalculator = $segmentsCountRecalculator;
102 }
103
104 public function shouldShowWooCommerceSegment(): bool {
105 return $this->woocommerceHelper->isWooCommerceActive();
106 }
107
108 public function synchronizeRegisteredCustomer(int $wpUserId, ?string $currentFilter = null): bool {
109 $wcSegment = $this->segmentsRepository->getWooCommerceSegment();
110
111 $currentFilter = $currentFilter ?: $this->wp->currentFilter();
112 switch ($currentFilter) {
113 case 'woocommerce_delete_customer':
114 // subscriber should be already deleted in WP users sync
115 // unsubscribeUsersFromSegment() recomputes segments_count for the rows it
116 // removes, so no whole-segment sweep is needed here.
117 $this->unsubscribeUsersFromSegment(); // remove leftover association
118 break;
119 case 'woocommerce_new_customer':
120 case 'woocommerce_created_customer':
121 $newCustomer = true;
122 case 'woocommerce_update_customer':
123 default:
124 $wpUser = $this->wp->getUserdata($wpUserId);
125 $subscriber = $this->subscribersRepository->findOneBy(['wpUserId' => $wpUserId]);
126
127 if ($wpUser === false || $subscriber === null) {
128 // registered customers should exist as WP users and WP segment subscribers
129 return false;
130 }
131
132 $data = [
133 'is_woocommerce_user' => 1,
134 ];
135 if (!empty($newCustomer)) {
136 $data['source'] = Source::WOOCOMMERCE_USER;
137 }
138 $data['id'] = $subscriber->getId();
139 if ($wpUser->first_name) { // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
140 $data['first_name'] = $wpUser->first_name; // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
141 }
142 if ($wpUser->last_name) { // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
143 $data['last_name'] = $wpUser->last_name; // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
144 }
145 $subscriber = $this->subscriberSaveController->createOrUpdate($data, $subscriber);
146 // add subscriber to the WooCommerce Customers segment when relation doesn't exist
147 $subscriberSegment = $this->subscriberSegmentRepository->findOneBy(['subscriber' => $subscriber, 'segment' => $wcSegment]);
148
149 if (!$subscriberSegment && $this->shouldSubscribeToWooSegment()) {
150 $this->subscriberSegmentRepository->subscribeToSegments(
151 $subscriber,
152 [$wcSegment]
153 );
154 }
155 break;
156 }
157
158 return true;
159 }
160
161 /**
162 * Should subscribe to the Woo segment when creating a new woo customer and not on checkout
163 * or when on checkout and MailPoet subscribe optin is enabled and checked.
164 */
165 protected function shouldSubscribeToWooSegment(): bool {
166 $checkoutOptinEnabled = (bool)$this->settings->get(Subscription::OPTIN_ENABLED_SETTING_NAME);
167 $checkoutOptinChecked = !empty($_POST[Subscription::CHECKOUT_OPTIN_INPUT_NAME]);
168
169 return !$this->woocommerceHelper->isCheckoutRequest() || ($checkoutOptinEnabled && $checkoutOptinChecked);
170 }
171
172 public function synchronizeGuestCustomer(int $orderId): void {
173 $wcOrder = $this->woocommerceHelper->wcGetOrder($orderId);
174
175 if (!$wcOrder instanceof \WC_Order) return;
176 $signupConfirmation = $this->settings->get('signup_confirmation');
177 $status = SubscriberEntity::STATUS_UNSUBSCRIBED;
178 if ((bool)$signupConfirmation['enabled'] === false && $this->shouldSubscribeToWooSegment()) {
179 $status = SubscriberEntity::STATUS_SUBSCRIBED;
180 }
181
182 $email = $this->insertSubscriberFromOrder($wcOrder, $status);
183
184 if (empty($email)) {
185 return;
186 }
187 $subscriber = $this->subscribersRepository->findOneBy(['email' => $email]);
188
189 if ($subscriber) {
190 $firstName = $wcOrder->get_billing_first_name(); // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
191 $lastName = $wcOrder->get_billing_last_name(); // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
192 if ($firstName) {
193 $subscriber->setFirstName($firstName);
194 }
195 if ($lastName) {
196 $subscriber->setLastName($lastName);
197 }
198 if ($firstName || $lastName) {
199 $this->subscribersRepository->flush();
200 }
201 }
202 }
203
204 public function synchronizeCustomers(int $lastCheckedOrderId = 0, ?int $highestOrderId = null, int $batchSize = 1000): int {
205
206 $this->wpSegment->synchronizeUsers(); // synchronize registered users
207
208 $this->markRegisteredCustomers();
209
210 $processedOrders = $this->insertSubscribersFromOrders($lastCheckedOrderId, $batchSize);
211 $this->updateNames($processedOrders);
212
213 $lastCheckedOrderId = $lastCheckedOrderId + $batchSize;
214 if (!$highestOrderId || $lastCheckedOrderId >= $highestOrderId) {
215 $this->insertUsersToSegment();
216 $this->unsubscribeUsersFromSegment();
217 $this->removeOrphanedSubscribers();
218 $this->updateStatus();
219 $this->updateGlobalStatus();
220 // The bulk operations above add/remove/restatus the WooCommerce segment's
221 // memberships en masse via raw SQL, so refresh segments_count for all
222 // members regardless of status — some may have just transitioned away
223 // from subscribed and must be recomputed too.
224 $this->segmentsCountRecalculator->recalculateForSegment((int)$this->segmentsRepository->getWooCommerceSegment()->getId(), false);
225 }
226
227 $this->subscribersRepository->invalidateTotalSubscribersCache();
228 return $lastCheckedOrderId;
229 }
230
231 private function ensureColumnCollation(): void {
232 if ($this->mailpoetEmailCollation && $this->wpPostmetaValueCollation) {
233 return;
234 }
235 global $wpdb;
236
237 $mailpoetEmailColumn = $wpdb->get_row($wpdb->prepare(
238 "SHOW FULL COLUMNS FROM %i WHERE Field = 'email'",
239 $this->subscribersRepository->getTableName()
240 ));
241 $this->mailpoetEmailCollation = $mailpoetEmailColumn->Collation; // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
242 $wpPostmetaValueColumn = $wpdb->get_row($wpdb->prepare(
243 "SHOW FULL COLUMNS FROM %i WHERE Field = 'meta_value'",
244 $wpdb->postmeta
245 ));
246 $this->wpPostmetaValueCollation = $wpPostmetaValueColumn->Collation; // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
247 }
248
249 /**
250 * In MySQL, if you have the same charset and collation in joined tables' columns it's perfect;
251 * if you have different charsets, utf8 and utf8mb4, it works too; but if you have the same charset
252 * with different collations, e.g. utf8mb4_unicode_ci and utf8mb4_unicode_520_ci, it will fail
253 * with an 'Illegal mix of collations' error. That's why we need an optional COLLATE clause to fix this.
254 */
255 private function needsCollationChange(): bool {
256 $this->ensureColumnCollation();
257 $collation1 = (string)$this->mailpoetEmailCollation;
258 $collation2 = (string)$this->wpPostmetaValueCollation;
259
260 if ($collation1 === $collation2) {
261 return false;
262 }
263 [$charset1] = explode('_', $collation1);
264 [$charset2] = explode('_', $collation2);
265
266 return $charset1 === $charset2;
267 }
268
269 private function markRegisteredCustomers(): void {
270 // Mark WP users having a customer role as WooCommerce subscribers
271 global $wpdb;
272 $subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
273 $this->connection->executeQuery("
274 UPDATE LOW_PRIORITY {$subscribersTable} mps
275 JOIN {$wpdb->users} wu ON mps.wp_user_id = wu.id
276 JOIN {$wpdb->usermeta} wpum ON wu.id = wpum.user_id AND wpum.meta_key = :capabilities
277 SET is_woocommerce_user = 1, source = :source
278 WHERE wpum.meta_value LIKE '%\"customer\"%'
279 ", ['capabilities' => $wpdb->prefix . 'capabilities', 'source' => Source::WOOCOMMERCE_USER]);
280 }
281
282 private function insertSubscriberFromOrder(\WC_Order $wcOrder, string $status): ?string {
283 $email = $wcOrder->get_billing_email();
284
285 if (!$email || !$this->validator->validateEmail($email)) {
286 return null;
287 }
288
289 $this->insertSubscribers([$email], $status);
290 return $email;
291 }
292
293 /**
294 * @return array<string, int>
295 */
296 private function insertSubscribersFromOrders(int $lastProcessedOrderId, int $batchSize): array {
297 global $wpdb;
298
299 $parameters = [
300 'lowestOrderId' => $lastProcessedOrderId,
301 'highestOrderId' => $lastProcessedOrderId + $batchSize,
302 ];
303 $parametersType = [
304 'lowestOrderId' => ParameterType::INTEGER,
305 'highestOrderId' => ParameterType::INTEGER,
306 ];
307
308 if ($this->woocommerceHelper->isWooCommerceCustomOrdersTableEnabled()) {
309 $ordersTable = $this->woocommerceHelper->getOrdersTableName();
310 $query = "SELECT id AS order_id, billing_email AS email
311 FROM `{$ordersTable}`
312 WHERE type = 'shop_order' AND billing_email != '' AND (id > :lowestOrderId AND id <= :highestOrderId)
313 ORDER BY id";
314 } else {
315 $query = "SELECT wpp.id AS order_id, wppm.meta_value AS email
316 FROM `{$wpdb->posts}` wpp
317 JOIN `{$wpdb->postmeta}` wppm ON wpp.ID = wppm.post_id AND wppm.meta_key = '_billing_email' AND wppm.meta_value != ''
318 WHERE wpp.post_type = 'shop_order'
319 AND (wpp.ID > :lowestOrderId AND wpp.ID <= :highestOrderId)
320 ORDER BY wpp.id";
321 }
322
323 $result = $this->connection->executeQuery($query, $parameters, $parametersType)->fetchAllAssociative();
324
325 $processedOrders = [];
326 foreach ($result as $item) {
327 if (!is_string($item['email']) || !$this->validator->validateEmail($item['email']) || !is_numeric($item['order_id'])) {
328 continue;
329 }
330 // because data in result are sorted by id, we can replace the previous order id
331 $processedOrders[$item['email']] = (int)$item['order_id'];
332 }
333
334 if (count($processedOrders)) {
335 $this->insertSubscribers(array_keys($processedOrders));
336 }
337
338 return $processedOrders;
339 }
340
341 private function insertSubscribers(array $emails, string $status = SubscriberEntity::STATUS_SUBSCRIBED): int {
342 $subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
343 $subscribersValues = [];
344 $now = Carbon::now()->format('Y-m-d H:i:s');
345 $source = Source::WOOCOMMERCE_USER;
346 foreach ($emails as $email) {
347 /** @var string $email */
348 $email = $this->connection->quote($email);
349 $email = strval($email);
350 $subscribersValues[] = "(1, {$email}, '{$status}', '{$now}', '{$now}', '{$source}')";
351 }
352
353 // Save timestamp about changes before insert
354 $this->subscriberChangesNotifier->subscribersBatchUpdate();
355 // Update existing subscribers
356 $this->connection->executeQuery('
357 UPDATE ' . $subscribersTable . ' mps
358 SET mps.is_woocommerce_user = 1
359 WHERE mps.email IN (:emails)
360 ', ['emails' => $emails], ['emails' => ArrayParameterType::STRING]);
361
362 // Save timestamp about new subscribers before insert
363 $this->subscriberChangesNotifier->subscribersBatchCreate();
364 // Insert new subscribers
365 $this->connection->executeQuery('
366 INSERT IGNORE INTO ' . $subscribersTable . ' (`is_woocommerce_user`, `email`, `status`, `created_at`, `last_subscribed_at`, `source`) VALUES
367 ' . implode(',', $subscribersValues) . '
368 ');
369
370 return count($emails);
371 }
372
373 /**
374 * @param array<string, int> $orders
375 */
376 private function updateNames(array $orders): void {
377 global $wpdb;
378 if (!$orders) {
379 return;
380 }
381 $subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
382
383 if ($this->woocommerceHelper->isWooCommerceCustomOrdersTableEnabled()) {
384 $addressesTableName = $this->woocommerceHelper->getAddressesTableName();
385 $metaData = [];
386 $results = $this->connection->executeQuery(
387 "
388 SELECT order_id, first_name, last_name
389 FROM {$addressesTableName}
390 WHERE order_id IN (:orderIds) and address_type = 'billing'",
391 ['orderIds' => array_values($orders)],
392 ['orderIds' => ArrayParameterType::INTEGER]
393 )->fetchAllAssociative();
394
395 // format data in the same format that is used when querying wp_postmeta (see below).
396 foreach ($results as $result) {
397 $firstNameData['post_id'] = $result['order_id'];
398 $firstNameData['meta_key'] = '_billing_first_name';
399 $firstNameData['meta_value'] = $result['first_name'];
400 $metaData[] = $firstNameData;
401
402 $lastNameData['post_id'] = $result['order_id'];
403 $lastNameData['meta_key'] = '_billing_last_name';
404 $lastNameData['meta_value'] = $result['last_name'];
405 $metaData[] = $lastNameData;
406 }
407 } else {
408 $metaKeys = [
409 '_billing_first_name',
410 '_billing_last_name',
411 ];
412 $metaData = $this->connection->executeQuery(
413 "
414 SELECT post_id, meta_key, meta_value
415 FROM {$wpdb->postmeta}
416 WHERE meta_key IN ('_billing_first_name', '_billing_last_name') AND post_id IN (:postIds)
417 ",
418 ['metaKeys' => $metaKeys, 'postIds' => array_values($orders)],
419 ['metaKeys' => ArrayParameterType::STRING, 'postIds' => ArrayParameterType::INTEGER]
420 )->fetchAllAssociative();
421 }
422
423 $subscribersData = [];
424 foreach ($orders as $email => $postId) {
425 $subscribersData[$postId]['email'] = $email;
426 }
427
428 foreach ($metaData as $row) {
429 if (!$row['meta_value']) {
430 continue;
431 }
432 $postId = is_numeric($row['post_id']) ? (int)$row['post_id'] : 0;
433 $metaKey = is_string($row['meta_key']) ? $row['meta_key'] : '';
434 if ($postId === 0 || $metaKey === '') {
435 continue;
436 }
437 $subscribersData[$postId][$metaKey] = $row['meta_value'];
438 }
439
440 $now = (Carbon::now())->format('Y-m-d H:i:s');
441 foreach ($subscribersData as $subscriber) {
442 $data = [];
443 $data['woocommerce_synced_at'] = $now;
444 if (!empty($subscriber['_billing_first_name'])) $data['first_name'] = $subscriber['_billing_first_name'];
445 if (!empty($subscriber['_billing_last_name'])) $data['last_name'] = $subscriber['_billing_last_name'];
446 $this->connection->update($subscribersTable, $data, ['email' => $subscriber['email']]);
447 }
448 }
449
450 private function insertUsersToSegment(): void {
451 $wcSegment = $this->segmentsRepository->getWooCommerceSegment();
452 $subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
453 $subscriberSegmentsTable = $this->entityManager->getClassMetadata(SubscriberSegmentEntity::class)->getTableName();
454 // Subscribe WC users to segment
455 $this->connection->executeQuery(
456 "
457 INSERT IGNORE INTO {$subscriberSegmentsTable} (subscriber_id, segment_id, created_at)
458 SELECT id, :segmentId, CURRENT_TIMESTAMP()
459 FROM {$subscribersTable}
460 WHERE is_woocommerce_user = 1
461 ",
462 ['segmentId' => $wcSegment->getId()],
463 ['segmentId' => ParameterType::INTEGER]
464 );
465 }
466
467 private function unsubscribeUsersFromSegment(): void {
468 $wcSegment = $this->segmentsRepository->getWooCommerceSegment();
469 $subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
470 $subscriberSegmentsTable = $this->entityManager->getClassMetadata(SubscriberSegmentEntity::class)->getTableName();
471
472 // Capture the affected subscriber ids before the DELETE: once the membership
473 // rows are gone, recalculateForSegment() can no longer see these subscribers,
474 // so a surviving subscriber would keep a stale segments_count. Recompute them
475 // explicitly afterwards (same pattern as SegmentsRepository::bulkDelete()).
476 $affectedIds = $this->connection->executeQuery(
477 "
478 SELECT mpss.subscriber_id FROM {$subscriberSegmentsTable} mpss
479 LEFT JOIN {$subscribersTable} mps ON mpss.subscriber_id = mps.id
480 WHERE mpss.segment_id = :segmentId AND mpss.status = :subscribedStatus
481 AND (mps.is_woocommerce_user = 0 OR mps.email = '' OR mps.email IS NULL)
482 ",
483 ['segmentId' => $wcSegment->getId(), 'subscribedStatus' => SubscriberEntity::STATUS_SUBSCRIBED],
484 ['segmentId' => ParameterType::INTEGER, 'subscribedStatus' => ParameterType::STRING]
485 )->fetchFirstColumn();
486
487 // Unsubscribe non-WC or invalid users from segment
488 $this->connection->executeQuery(
489 "
490 DELETE mpss FROM {$subscriberSegmentsTable} mpss
491 LEFT JOIN {$subscribersTable} mps ON mpss.subscriber_id = mps.id
492 WHERE mpss.segment_id = :segmentId AND (mps.is_woocommerce_user = 0 OR mps.email = '' OR mps.email IS NULL)
493 ",
494 ['segmentId' => $wcSegment->getId()],
495 ['segmentId' => ParameterType::INTEGER]
496 );
497
498 $subscriberIds = array_map(function ($id): int {
499 return is_numeric($id) ? (int)$id : 0;
500 }, $affectedIds);
501 $this->segmentsCountRecalculator->recalculateForSubscribers($subscriberIds);
502 }
503
504 private function updateGlobalStatus(): void {
505 $subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
506 $subscriberSegmentsTable = $this->entityManager->getClassMetadata(SubscriberSegmentEntity::class)->getTableName();
507 $wcSegment = $this->segmentsRepository->getWooCommerceSegment();
508 // Set global status unsubscribed to all woocommerce users without any segment
509 $this->connection->executeQuery(
510 "
511 UPDATE {$subscribersTable} mps
512 LEFT JOIN {$subscriberSegmentsTable} mpss ON mpss.subscriber_id = mps.id
513 SET mps.status = :statusUnsubscribed
514 WHERE mpss.id IS NULL
515 AND mps.is_woocommerce_user = 1
516 ",
517 ['statusUnsubscribed' => SubscriberEntity::STATUS_UNSUBSCRIBED],
518 ['statusUnsubscribed' => ParameterType::INTEGER]
519 );
520 // SET global status unsubscribed to all woocommerce users who have only 1 segment and it is woocommerce segment and they are not subscribed
521 // You can't specify target table 'mps' for update in FROM clause
522 $this->connection->executeQuery(
523 "
524 UPDATE {$subscribersTable} mps
525 JOIN {$subscriberSegmentsTable} mpss ON mps.id = mpss.subscriber_id AND mpss.segment_id = :segmentId AND mpss.status = :statusUnsubscribed
526 SET mps.status = :statusUnsubscribed
527 WHERE mps.id IN (
528 SELECT s.id -- get all subscribers with exactly 1 segment
529 FROM (SELECT id FROM {$subscribersTable} WHERE is_woocommerce_user = 1) s
530 JOIN {$subscriberSegmentsTable} ss on s.id = ss.subscriber_id
531 GROUP BY s.id
532 HAVING COUNT(ss.id) = 1
533 )
534 ",
535 ['statusUnsubscribed' => SubscriberEntity::STATUS_UNSUBSCRIBED, 'segmentId' => $wcSegment->getId()],
536 ['statusUnsubscribed' => ParameterType::STRING, 'segmentId' => ParameterType::INTEGER]
537 );
538 }
539
540 private function removeOrphanedSubscribers(): void {
541 // Remove orphaned WooCommerce segment subscribers (not having a matching WC customer email),
542 // e.g. if WC orders were deleted directly from the database
543 // or a customer role was revoked and a user has no orders
544 global $wpdb;
545
546 $wcSegment = $this->segmentsRepository->getWooCommerceSegment();
547 $subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
548 $subscriberSegmentsTable = $this->entityManager->getClassMetadata(SubscriberSegmentEntity::class)->getTableName();
549
550 // Unmark registered customers
551
552 // Insert WC customer IDs to a temporary table for left join to use an index
553 $tmpTableName = Env::$dbPrefix . 'tmp_wc_ids';
554 // Registered users with orders
555 if ($this->woocommerceHelper->isWooCommerceCustomOrdersTableEnabled()) {
556 $ordersTable = $this->woocommerceHelper->getOrdersTableName();
557 // Exclude guest orders (customer_id = 0) as they are not registered users
558 $registeredCustomersSubQuery = "SELECT DISTINCT customer_id AS id FROM `{$ordersTable}` WHERE type = 'shop_order' AND customer_id > 0";
559 } else {
560 // Exclude guest orders (meta_value = 0 or empty) as they are not registered users
561 $registeredCustomersSubQuery = "SELECT DISTINCT wppm.meta_value AS id FROM {$wpdb->postmeta} wppm
562 JOIN {$wpdb->posts} wpp ON wppm.post_id = wpp.ID
563 AND wpp.post_type = 'shop_order'
564 WHERE wppm.meta_key = '_customer_user' AND wppm.meta_value > 0";
565 }
566
567 $this->connection->executeQuery("
568 CREATE TEMPORARY TABLE {$tmpTableName}
569 (`id` int(11) unsigned NOT NULL, UNIQUE(`id`), PRIMARY KEY (`id`)) AS
570 {$registeredCustomersSubQuery}
571 ");
572 // Registered users with a customer role
573 $this->connection->executeQuery("
574 INSERT IGNORE INTO {$tmpTableName}
575 SELECT DISTINCT wpum.user_id AS id FROM {$wpdb->usermeta} wpum
576 WHERE wpum.meta_key = :capabilities AND wpum.meta_value LIKE '%\"customer\"%'
577 ", ['capabilities' => $wpdb->prefix . 'capabilities']);
578
579 // Unmark WC list registered users which aren't WC customers anymore
580 $subQb = $this->connection->createQueryBuilder();
581 $subQb->select('mps.id')
582 ->from($subscribersTable, 'mps')
583 ->join('mps', $subscriberSegmentsTable, 'mpss', 'mps.id = mpss.subscriber_id AND mpss.segment_id = :segmentId')
584 ->leftJoin('mps', $tmpTableName, 'wctmp', 'mps.wp_user_id = wctmp.id')
585 ->where('mps.is_woocommerce_user = 1')
586 ->andWhere('wctmp.id IS NULL')
587 ->andWhere('mps.wp_user_id IS NOT NULL');
588 $qb = $this->connection->createQueryBuilder();
589 $qb->update($subscribersTable)
590 ->set('is_woocommerce_user', '0')
591 ->where("id IN (SELECT id FROM ({$subQb->getSQL()}) AS sq) ")
592 ->setParameter('segmentId', $wcSegment->getId());
593 $qb->execute();
594
595 $this->connection->executeQuery("DROP TABLE {$tmpTableName}");
596
597 // Remove guest customers
598
599 // Insert WC customer emails to a temporary table and ensure matching collations
600 // between MailPoet and WooCommerce emails for left join to use an index
601 $tmpTableName = Env::$dbPrefix . 'tmp_wc_emails';
602 if ($this->needsCollationChange()) {
603 $collation = "COLLATE $this->mailpoetEmailCollation";
604 } else {
605 $collation = "COLLATE $this->wpPostmetaValueCollation";
606 }
607
608 if ($this->woocommerceHelper->isWooCommerceCustomOrdersTableEnabled()) {
609 $ordersTable = $this->woocommerceHelper->getOrdersTableName();
610 $guestCustomersSubQuery = "SELECT DISTINCT billing_email AS email FROM `{$ordersTable}` WHERE type = 'shop_order' AND billing_email IS NOT NULL AND billing_email != ''";
611 } else {
612 $guestCustomersSubQuery = "SELECT DISTINCT wppm.meta_value AS email FROM {$wpdb->postmeta} wppm
613 JOIN {$wpdb->posts} wpp ON wppm.post_id = wpp.ID
614 AND wpp.post_type = 'shop_order'
615 WHERE wppm.meta_key = '_billing_email'";
616 }
617
618 $this->connection->executeQuery("
619 CREATE TEMPORARY TABLE {$tmpTableName}
620 (`email` varchar(150) NOT NULL, UNIQUE(`email`), PRIMARY KEY (`email`)) {$collation}
621 {$guestCustomersSubQuery}
622 ");
623
624 // Remove WC list guest users which aren't WC customers anymore
625 $subQb = $this->connection->createQueryBuilder();
626 $subQb->select('mps.id')
627 ->from($subscribersTable, 'mps')
628 ->join('mps', $subscriberSegmentsTable, 'mpss', 'mps.id = mpss.subscriber_id AND mpss.segment_id = :segmentId')
629 ->leftJoin('mps', $tmpTableName, 'wctmp', 'mps.email = wctmp.email')
630 ->where('mps.is_woocommerce_user = 1')
631 ->andWhere('wctmp.email IS NULL')
632 ->andWhere('mps.wp_user_id IS NULL');
633 $qb = $this->connection->createQueryBuilder();
634 $qb->delete($subscribersTable)
635 ->where("id IN (SELECT id FROM ({$subQb->getSQL()}) AS sq) ")
636 ->setParameter('segmentId', $wcSegment->getId());
637 $qb->execute();
638
639 $this->connection->executeQuery("DROP TABLE {$tmpTableName}");
640 }
641
642 private function updateStatus(): void {
643 $subscribeOldCustomers = $this->settings->get('mailpoet_subscribe_old_woocommerce_customers.enabled', false);
644 if ($subscribeOldCustomers !== "1") {
645 $status = SubscriberEntity::STATUS_UNSUBSCRIBED;
646 } else {
647 $status = SubscriberEntity::STATUS_SUBSCRIBED;
648 }
649 $subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
650 $subscriberSegmentsTable = $this->entityManager->getClassMetadata(SubscriberSegmentEntity::class)->getTableName();
651 $wcSegment = $this->segmentsRepository->getWooCommerceSegment();
652
653 $this->connection->executeQuery(
654 "
655 UPDATE LOW_PRIORITY {$subscriberSegmentsTable} AS mpss
656 JOIN {$subscribersTable} AS mps ON mpss.subscriber_id = mps.id
657 SET mpss.status = :status
658 WHERE
659 mpss.segment_id = :segmentId
660 AND mps.confirmed_at IS NULL
661 AND mps.confirmed_ip IS NULL
662 AND mps.is_woocommerce_user = 1
663 ",
664 ['status' => $status, 'segmentId' => $wcSegment->getId()],
665 ['status' => ParameterType::STRING, 'segmentId' => ParameterType::INTEGER]
666 );
667 }
668 }
669