PluginProbe ʕ •ᴥ•ʔ
MailPoet – Newsletters, Email Marketing, and Automation / 5.37.0
MailPoet – Newsletters, Email Marketing, and Automation v5.37.0
5.37.0 5.36.1 5.36.0 5.35.1 5.35.0 5.34.3 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 day ago RestApi 2 months ago SegmentDependencyValidator.php 3 years ago SegmentListingRepository.php 2 months ago SegmentSaveController.php 1 month ago SegmentSubscribersRepository.php 1 month ago SegmentsFinder.php 3 years ago SegmentsRepository.php 1 month ago SegmentsSimpleListRepository.php 3 months ago SubscribersFinder.php 2 months ago WP.php 1 day ago WPUserDeleteNotice.php 4 weeks ago WooCommerce.php 1 day ago index.php 3 years ago
WooCommerce.php
696 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 /**
30 * Per-email record of whether synchronizeGuestCustomer() inserted a brand new
31 * subscriber row this request, or found one that already existed.
32 * insertSubscribers() uses INSERT IGNORE, so a caller looking at the row
33 * afterwards cannot tell "just created" from "already there" — that is
34 * captured at insert time instead. Read by WooCommerce\Subscription, which
35 * runs on the same hook at a later priority and has no other way to know.
36 *
37 * @var array<string, bool>
38 */
39 private $guestSyncCreatedSubscriber = [];
40
41 /** @var SettingsController */
42 private $settings;
43
44 /** @var WPFunctions */
45 private $wp;
46
47 /** @var WP */
48 private $wpSegment;
49
50 /** @var string|null */
51 private $mailpoetEmailCollation;
52
53 /** @var string|null */
54 private $wpPostmetaValueCollation;
55
56 /** @var SubscribersRepository */
57 private $subscribersRepository;
58
59 /** @var SegmentsRepository */
60 private $segmentsRepository;
61
62 /** @var SubscriberSegmentRepository */
63 private $subscriberSegmentRepository;
64
65 /** @var SubscriberSaveController */
66 private $subscriberSaveController;
67
68 /** @var WCHelper */
69 private $woocommerceHelper;
70
71 /** @var EntityManager */
72 private $entityManager;
73
74 /** @var Connection */
75 private $connection;
76
77 /** @var SubscriberChangesNotifier */
78 private $subscriberChangesNotifier;
79
80 /** @var Validator */
81 private $validator;
82
83 /** @var SegmentsCountRecalculator */
84 private $segmentsCountRecalculator;
85
86 public function __construct(
87 SettingsController $settings,
88 WPFunctions $wp,
89 WCHelper $woocommerceHelper,
90 SubscribersRepository $subscribersRepository,
91 SegmentsRepository $segmentsRepository,
92 SubscriberSegmentRepository $subscriberSegmentRepository,
93 SubscriberSaveController $subscriberSaveController,
94 WP $wpSegment,
95 EntityManager $entityManager,
96 Connection $connection,
97 SubscriberChangesNotifier $subscriberChangesNotifier,
98 Validator $validator,
99 SegmentsCountRecalculator $segmentsCountRecalculator
100 ) {
101 $this->settings = $settings;
102 $this->wp = $wp;
103 $this->wpSegment = $wpSegment;
104 $this->subscribersRepository = $subscribersRepository;
105 $this->segmentsRepository = $segmentsRepository;
106 $this->subscriberSegmentRepository = $subscriberSegmentRepository;
107 $this->subscriberSaveController = $subscriberSaveController;
108 $this->woocommerceHelper = $woocommerceHelper;
109 $this->entityManager = $entityManager;
110 $this->connection = $connection;
111 $this->subscriberChangesNotifier = $subscriberChangesNotifier;
112 $this->validator = $validator;
113 $this->segmentsCountRecalculator = $segmentsCountRecalculator;
114 }
115
116 public function shouldShowWooCommerceSegment(): bool {
117 return $this->woocommerceHelper->isWooCommerceActive();
118 }
119
120 public function synchronizeRegisteredCustomer(int $wpUserId, ?string $currentFilter = null): bool {
121 $wcSegment = $this->segmentsRepository->getWooCommerceSegment();
122
123 $currentFilter = $currentFilter ?: $this->wp->currentFilter();
124 switch ($currentFilter) {
125 case 'woocommerce_delete_customer':
126 // subscriber should be already deleted in WP users sync
127 // unsubscribeUsersFromSegment() recomputes segments_count for the rows it
128 // removes, so no whole-segment sweep is needed here.
129 $this->unsubscribeUsersFromSegment(); // remove leftover association
130 break;
131 case 'woocommerce_new_customer':
132 case 'woocommerce_created_customer':
133 $newCustomer = true;
134 case 'woocommerce_update_customer':
135 default:
136 $wpUser = $this->wp->getUserdata($wpUserId);
137 $subscriber = $this->subscribersRepository->findOneBy(['wpUserId' => $wpUserId]);
138
139 if ($wpUser === false || $subscriber === null) {
140 // registered customers should exist as WP users and WP segment subscribers
141 return false;
142 }
143
144 $data = [
145 'is_woocommerce_user' => 1,
146 ];
147 if (!empty($newCustomer)) {
148 $data['source'] = Source::WOOCOMMERCE_USER;
149 }
150 $data['id'] = $subscriber->getId();
151 if ($wpUser->first_name) { // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
152 $data['first_name'] = $wpUser->first_name; // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
153 }
154 if ($wpUser->last_name) { // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
155 $data['last_name'] = $wpUser->last_name; // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
156 }
157 $subscriber = $this->subscriberSaveController->createOrUpdate($data, $subscriber);
158 // add subscriber to the WooCommerce Customers segment when relation doesn't exist
159 $subscriberSegment = $this->subscriberSegmentRepository->findOneBy(['subscriber' => $subscriber, 'segment' => $wcSegment]);
160
161 if (!$subscriberSegment && $this->shouldSubscribeToWooSegment()) {
162 $this->subscriberSegmentRepository->subscribeToSegments(
163 $subscriber,
164 [$wcSegment]
165 );
166 }
167 break;
168 }
169
170 return true;
171 }
172
173 /**
174 * Should subscribe to the Woo segment when creating a new woo customer and not on checkout
175 * or when on checkout and MailPoet subscribe optin is enabled and checked.
176 */
177 protected function shouldSubscribeToWooSegment(): bool {
178 $checkoutOptinEnabled = (bool)$this->settings->get(Subscription::OPTIN_ENABLED_SETTING_NAME);
179 $checkoutOptinChecked = !empty($_POST[Subscription::CHECKOUT_OPTIN_INPUT_NAME]);
180
181 return !$this->woocommerceHelper->isCheckoutRequest() || ($checkoutOptinEnabled && $checkoutOptinChecked);
182 }
183
184 /**
185 * Whether the given email's subscriber row did not exist before the most
186 * recent synchronizeGuestCustomer() call in this request. Defaults to false,
187 * meaning "treat as pre-existing", for any email that call never saw: a
188 * missing signal must never cause an unearned overwrite of an earlier
189 * consent choice.
190 */
191 public function wasNewlyCreatedByGuestSync(string $email): bool {
192 return $this->guestSyncCreatedSubscriber[$email] ?? false;
193 }
194
195 public function synchronizeGuestCustomer(int $orderId): void {
196 $wcOrder = $this->woocommerceHelper->wcGetOrder($orderId);
197
198 if (!$wcOrder instanceof \WC_Order) return;
199 $signupConfirmation = $this->settings->get('signup_confirmation');
200 $status = SubscriberEntity::STATUS_UNSUBSCRIBED;
201 if ((bool)$signupConfirmation['enabled'] === false && $this->shouldSubscribeToWooSegment()) {
202 $status = SubscriberEntity::STATUS_SUBSCRIBED;
203 }
204
205 $wasNewlyCreated = false;
206 $email = $this->insertSubscriberFromOrder($wcOrder, $status, $wasNewlyCreated);
207
208 if (empty($email)) {
209 return;
210 }
211 $this->guestSyncCreatedSubscriber[$email] = $wasNewlyCreated;
212 $subscriber = $this->subscribersRepository->findOneBy(['email' => $email]);
213
214 if ($subscriber) {
215 $firstName = $wcOrder->get_billing_first_name(); // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
216 $lastName = $wcOrder->get_billing_last_name(); // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
217 if ($firstName) {
218 $subscriber->setFirstName($firstName);
219 }
220 if ($lastName) {
221 $subscriber->setLastName($lastName);
222 }
223 if ($firstName || $lastName) {
224 $this->subscribersRepository->flush();
225 }
226 }
227 }
228
229 public function synchronizeCustomers(int $lastCheckedOrderId = 0, ?int $highestOrderId = null, int $batchSize = 1000): int {
230
231 $this->wpSegment->synchronizeUsers(); // synchronize registered users
232
233 $this->markRegisteredCustomers();
234
235 $processedOrders = $this->insertSubscribersFromOrders($lastCheckedOrderId, $batchSize);
236 $this->updateNames($processedOrders);
237
238 $lastCheckedOrderId = $lastCheckedOrderId + $batchSize;
239 if (!$highestOrderId || $lastCheckedOrderId >= $highestOrderId) {
240 $this->insertUsersToSegment();
241 $this->unsubscribeUsersFromSegment();
242 $this->removeOrphanedSubscribers();
243 $this->updateStatus();
244 $this->updateGlobalStatus();
245 // The bulk operations above add/remove/restatus the WooCommerce segment's
246 // memberships en masse via raw SQL, so refresh segments_count for all
247 // members regardless of status — some may have just transitioned away
248 // from subscribed and must be recomputed too.
249 $this->segmentsCountRecalculator->recalculateForSegment((int)$this->segmentsRepository->getWooCommerceSegment()->getId(), false);
250 }
251
252 $this->subscribersRepository->invalidateTotalSubscribersCache();
253 return $lastCheckedOrderId;
254 }
255
256 private function ensureColumnCollation(): void {
257 if ($this->mailpoetEmailCollation && $this->wpPostmetaValueCollation) {
258 return;
259 }
260 global $wpdb;
261
262 $mailpoetEmailColumn = $wpdb->get_row($wpdb->prepare(
263 "SHOW FULL COLUMNS FROM %i WHERE Field = 'email'",
264 $this->subscribersRepository->getTableName()
265 ));
266 $this->mailpoetEmailCollation = $mailpoetEmailColumn->Collation; // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
267 $wpPostmetaValueColumn = $wpdb->get_row($wpdb->prepare(
268 "SHOW FULL COLUMNS FROM %i WHERE Field = 'meta_value'",
269 $wpdb->postmeta
270 ));
271 $this->wpPostmetaValueCollation = $wpPostmetaValueColumn->Collation; // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
272 }
273
274 /**
275 * In MySQL, if you have the same charset and collation in joined tables' columns it's perfect;
276 * if you have different charsets, utf8 and utf8mb4, it works too; but if you have the same charset
277 * with different collations, e.g. utf8mb4_unicode_ci and utf8mb4_unicode_520_ci, it will fail
278 * with an 'Illegal mix of collations' error. That's why we need an optional COLLATE clause to fix this.
279 */
280 private function needsCollationChange(): bool {
281 $this->ensureColumnCollation();
282 $collation1 = (string)$this->mailpoetEmailCollation;
283 $collation2 = (string)$this->wpPostmetaValueCollation;
284
285 if ($collation1 === $collation2) {
286 return false;
287 }
288 [$charset1] = explode('_', $collation1);
289 [$charset2] = explode('_', $collation2);
290
291 return $charset1 === $charset2;
292 }
293
294 private function markRegisteredCustomers(): void {
295 // Mark WP users having a customer role as WooCommerce subscribers
296 global $wpdb;
297 $subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
298 $this->connection->executeQuery("
299 UPDATE LOW_PRIORITY {$subscribersTable} mps
300 JOIN {$wpdb->users} wu ON mps.wp_user_id = wu.id
301 JOIN {$wpdb->usermeta} wpum ON wu.id = wpum.user_id AND wpum.meta_key = :capabilities
302 SET is_woocommerce_user = 1, source = :source
303 WHERE wpum.meta_value LIKE '%\"customer\"%'
304 ", ['capabilities' => $wpdb->prefix . 'capabilities', 'source' => Source::WOOCOMMERCE_USER]);
305 }
306
307 private function insertSubscriberFromOrder(\WC_Order $wcOrder, string $status, bool &$wasNewlyCreated = false): ?string {
308 $email = $wcOrder->get_billing_email();
309
310 if (!$email || !$this->validator->validateEmail($email)) {
311 return null;
312 }
313
314 $wasNewlyCreated = $this->insertSubscribers([$email], $status) > 0;
315 return $email;
316 }
317
318 /**
319 * @return array<string, int>
320 */
321 private function insertSubscribersFromOrders(int $lastProcessedOrderId, int $batchSize): array {
322 global $wpdb;
323
324 $parameters = [
325 'lowestOrderId' => $lastProcessedOrderId,
326 'highestOrderId' => $lastProcessedOrderId + $batchSize,
327 ];
328 $parametersType = [
329 'lowestOrderId' => ParameterType::INTEGER,
330 'highestOrderId' => ParameterType::INTEGER,
331 ];
332
333 if ($this->woocommerceHelper->isWooCommerceCustomOrdersTableEnabled()) {
334 $ordersTable = $this->woocommerceHelper->getOrdersTableName();
335 $query = "SELECT id AS order_id, billing_email AS email
336 FROM `{$ordersTable}`
337 WHERE type = 'shop_order' AND billing_email != '' AND (id > :lowestOrderId AND id <= :highestOrderId)
338 ORDER BY id";
339 } else {
340 $query = "SELECT wpp.id AS order_id, wppm.meta_value AS email
341 FROM `{$wpdb->posts}` wpp
342 JOIN `{$wpdb->postmeta}` wppm ON wpp.ID = wppm.post_id AND wppm.meta_key = '_billing_email' AND wppm.meta_value != ''
343 WHERE wpp.post_type = 'shop_order'
344 AND (wpp.ID > :lowestOrderId AND wpp.ID <= :highestOrderId)
345 ORDER BY wpp.id";
346 }
347
348 $result = $this->connection->executeQuery($query, $parameters, $parametersType)->fetchAllAssociative();
349
350 $processedOrders = [];
351 foreach ($result as $item) {
352 if (!is_string($item['email']) || !$this->validator->validateEmail($item['email']) || !is_numeric($item['order_id'])) {
353 continue;
354 }
355 // because data in result are sorted by id, we can replace the previous order id
356 $processedOrders[$item['email']] = (int)$item['order_id'];
357 }
358
359 if (count($processedOrders)) {
360 $this->insertSubscribers(array_keys($processedOrders));
361 }
362
363 return $processedOrders;
364 }
365
366 private function insertSubscribers(array $emails, string $status = SubscriberEntity::STATUS_SUBSCRIBED): int {
367 $subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
368 $subscribersValues = [];
369 $now = Carbon::now()->format('Y-m-d H:i:s');
370 $source = Source::WOOCOMMERCE_USER;
371 foreach ($emails as $email) {
372 /** @var string $email */
373 $email = $this->connection->quote($email);
374 $email = strval($email);
375 $subscribersValues[] = "(1, {$email}, '{$status}', '{$now}', '{$now}', '{$source}')";
376 }
377
378 // Save timestamp about changes before insert
379 $this->subscriberChangesNotifier->subscribersBatchUpdate();
380 // Update existing subscribers
381 $this->connection->executeQuery('
382 UPDATE ' . $subscribersTable . ' mps
383 SET mps.is_woocommerce_user = 1
384 WHERE mps.email IN (:emails)
385 ', ['emails' => $emails], ['emails' => ArrayParameterType::STRING]);
386
387 // Save timestamp about new subscribers before insert
388 $this->subscriberChangesNotifier->subscribersBatchCreate();
389 // Insert new subscribers
390 // executeStatement, not executeQuery: the affected-row count is what tells
391 // a caller whether INSERT IGNORE actually inserted or silently discarded.
392 $insertedCount = $this->connection->executeStatement('
393 INSERT IGNORE INTO ' . $subscribersTable . ' (`is_woocommerce_user`, `email`, `status`, `created_at`, `last_subscribed_at`, `source`) VALUES
394 ' . implode(',', $subscribersValues) . '
395 ');
396
397 return (int)$insertedCount;
398 }
399
400 /**
401 * @param array<string, int> $orders
402 */
403 private function updateNames(array $orders): void {
404 global $wpdb;
405 if (!$orders) {
406 return;
407 }
408 $subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
409
410 if ($this->woocommerceHelper->isWooCommerceCustomOrdersTableEnabled()) {
411 $addressesTableName = $this->woocommerceHelper->getAddressesTableName();
412 $metaData = [];
413 $results = $this->connection->executeQuery(
414 "
415 SELECT order_id, first_name, last_name
416 FROM {$addressesTableName}
417 WHERE order_id IN (:orderIds) and address_type = 'billing'",
418 ['orderIds' => array_values($orders)],
419 ['orderIds' => ArrayParameterType::INTEGER]
420 )->fetchAllAssociative();
421
422 // format data in the same format that is used when querying wp_postmeta (see below).
423 foreach ($results as $result) {
424 $firstNameData['post_id'] = $result['order_id'];
425 $firstNameData['meta_key'] = '_billing_first_name';
426 $firstNameData['meta_value'] = $result['first_name'];
427 $metaData[] = $firstNameData;
428
429 $lastNameData['post_id'] = $result['order_id'];
430 $lastNameData['meta_key'] = '_billing_last_name';
431 $lastNameData['meta_value'] = $result['last_name'];
432 $metaData[] = $lastNameData;
433 }
434 } else {
435 $metaKeys = [
436 '_billing_first_name',
437 '_billing_last_name',
438 ];
439 $metaData = $this->connection->executeQuery(
440 "
441 SELECT post_id, meta_key, meta_value
442 FROM {$wpdb->postmeta}
443 WHERE meta_key IN ('_billing_first_name', '_billing_last_name') AND post_id IN (:postIds)
444 ",
445 ['metaKeys' => $metaKeys, 'postIds' => array_values($orders)],
446 ['metaKeys' => ArrayParameterType::STRING, 'postIds' => ArrayParameterType::INTEGER]
447 )->fetchAllAssociative();
448 }
449
450 $subscribersData = [];
451 foreach ($orders as $email => $postId) {
452 $subscribersData[$postId]['email'] = $email;
453 }
454
455 foreach ($metaData as $row) {
456 if (!$row['meta_value']) {
457 continue;
458 }
459 $postId = is_numeric($row['post_id']) ? (int)$row['post_id'] : 0;
460 $metaKey = is_string($row['meta_key']) ? $row['meta_key'] : '';
461 if ($postId === 0 || $metaKey === '') {
462 continue;
463 }
464 $subscribersData[$postId][$metaKey] = $row['meta_value'];
465 }
466
467 $now = (Carbon::now())->format('Y-m-d H:i:s');
468 foreach ($subscribersData as $subscriber) {
469 $data = [];
470 $data['woocommerce_synced_at'] = $now;
471 if (!empty($subscriber['_billing_first_name'])) $data['first_name'] = $subscriber['_billing_first_name'];
472 if (!empty($subscriber['_billing_last_name'])) $data['last_name'] = $subscriber['_billing_last_name'];
473 $this->connection->update($subscribersTable, $data, ['email' => $subscriber['email']]);
474 }
475 }
476
477 private function insertUsersToSegment(): void {
478 $wcSegment = $this->segmentsRepository->getWooCommerceSegment();
479 $subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
480 $subscriberSegmentsTable = $this->entityManager->getClassMetadata(SubscriberSegmentEntity::class)->getTableName();
481 // Subscribe WC users to segment
482 $this->connection->executeQuery(
483 "
484 INSERT IGNORE INTO {$subscriberSegmentsTable} (subscriber_id, segment_id, created_at)
485 SELECT id, :segmentId, CURRENT_TIMESTAMP()
486 FROM {$subscribersTable}
487 WHERE is_woocommerce_user = 1
488 ",
489 ['segmentId' => $wcSegment->getId()],
490 ['segmentId' => ParameterType::INTEGER]
491 );
492 }
493
494 private function unsubscribeUsersFromSegment(): void {
495 $wcSegment = $this->segmentsRepository->getWooCommerceSegment();
496 $subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
497 $subscriberSegmentsTable = $this->entityManager->getClassMetadata(SubscriberSegmentEntity::class)->getTableName();
498
499 // Capture the affected subscriber ids before the DELETE: once the membership
500 // rows are gone, recalculateForSegment() can no longer see these subscribers,
501 // so a surviving subscriber would keep a stale segments_count. Recompute them
502 // explicitly afterwards (same pattern as SegmentsRepository::bulkDelete()).
503 $affectedIds = $this->connection->executeQuery(
504 "
505 SELECT mpss.subscriber_id FROM {$subscriberSegmentsTable} mpss
506 LEFT JOIN {$subscribersTable} mps ON mpss.subscriber_id = mps.id
507 WHERE mpss.segment_id = :segmentId AND mpss.status = :subscribedStatus
508 AND (mps.is_woocommerce_user = 0 OR mps.email = '' OR mps.email IS NULL)
509 ",
510 ['segmentId' => $wcSegment->getId(), 'subscribedStatus' => SubscriberEntity::STATUS_SUBSCRIBED],
511 ['segmentId' => ParameterType::INTEGER, 'subscribedStatus' => ParameterType::STRING]
512 )->fetchFirstColumn();
513
514 // Unsubscribe non-WC or invalid users from segment
515 $this->connection->executeQuery(
516 "
517 DELETE mpss FROM {$subscriberSegmentsTable} mpss
518 LEFT JOIN {$subscribersTable} mps ON mpss.subscriber_id = mps.id
519 WHERE mpss.segment_id = :segmentId AND (mps.is_woocommerce_user = 0 OR mps.email = '' OR mps.email IS NULL)
520 ",
521 ['segmentId' => $wcSegment->getId()],
522 ['segmentId' => ParameterType::INTEGER]
523 );
524
525 $subscriberIds = array_map(function ($id): int {
526 return is_numeric($id) ? (int)$id : 0;
527 }, $affectedIds);
528 $this->segmentsCountRecalculator->recalculateForSubscribers($subscriberIds);
529 }
530
531 private function updateGlobalStatus(): void {
532 $subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
533 $subscriberSegmentsTable = $this->entityManager->getClassMetadata(SubscriberSegmentEntity::class)->getTableName();
534 $wcSegment = $this->segmentsRepository->getWooCommerceSegment();
535 // Set global status unsubscribed to all woocommerce users without any segment
536 $this->connection->executeQuery(
537 "
538 UPDATE {$subscribersTable} mps
539 LEFT JOIN {$subscriberSegmentsTable} mpss ON mpss.subscriber_id = mps.id
540 SET mps.status = :statusUnsubscribed
541 WHERE mpss.id IS NULL
542 AND mps.is_woocommerce_user = 1
543 ",
544 ['statusUnsubscribed' => SubscriberEntity::STATUS_UNSUBSCRIBED],
545 ['statusUnsubscribed' => ParameterType::INTEGER]
546 );
547 // SET global status unsubscribed to all woocommerce users who have only 1 segment and it is woocommerce segment and they are not subscribed
548 // You can't specify target table 'mps' for update in FROM clause
549 $this->connection->executeQuery(
550 "
551 UPDATE {$subscribersTable} mps
552 JOIN {$subscriberSegmentsTable} mpss ON mps.id = mpss.subscriber_id AND mpss.segment_id = :segmentId AND mpss.status = :statusUnsubscribed
553 SET mps.status = :statusUnsubscribed
554 WHERE mps.id IN (
555 SELECT s.id -- get all subscribers with exactly 1 segment
556 FROM (SELECT id FROM {$subscribersTable} WHERE is_woocommerce_user = 1) s
557 JOIN {$subscriberSegmentsTable} ss on s.id = ss.subscriber_id
558 GROUP BY s.id
559 HAVING COUNT(ss.id) = 1
560 )
561 ",
562 ['statusUnsubscribed' => SubscriberEntity::STATUS_UNSUBSCRIBED, 'segmentId' => $wcSegment->getId()],
563 ['statusUnsubscribed' => ParameterType::STRING, 'segmentId' => ParameterType::INTEGER]
564 );
565 }
566
567 private function removeOrphanedSubscribers(): void {
568 // Remove orphaned WooCommerce segment subscribers (not having a matching WC customer email),
569 // e.g. if WC orders were deleted directly from the database
570 // or a customer role was revoked and a user has no orders
571 global $wpdb;
572
573 $wcSegment = $this->segmentsRepository->getWooCommerceSegment();
574 $subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
575 $subscriberSegmentsTable = $this->entityManager->getClassMetadata(SubscriberSegmentEntity::class)->getTableName();
576
577 // Unmark registered customers
578
579 // Insert WC customer IDs to a temporary table for left join to use an index
580 $tmpTableName = Env::$dbPrefix . 'tmp_wc_ids';
581 // Registered users with orders
582 if ($this->woocommerceHelper->isWooCommerceCustomOrdersTableEnabled()) {
583 $ordersTable = $this->woocommerceHelper->getOrdersTableName();
584 // Exclude guest orders (customer_id = 0) as they are not registered users
585 $registeredCustomersSubQuery = "SELECT DISTINCT customer_id AS id FROM `{$ordersTable}` WHERE type = 'shop_order' AND customer_id > 0";
586 } else {
587 // Exclude guest orders (meta_value = 0 or empty) as they are not registered users
588 $registeredCustomersSubQuery = "SELECT DISTINCT wppm.meta_value AS id FROM {$wpdb->postmeta} wppm
589 JOIN {$wpdb->posts} wpp ON wppm.post_id = wpp.ID
590 AND wpp.post_type = 'shop_order'
591 WHERE wppm.meta_key = '_customer_user' AND wppm.meta_value > 0";
592 }
593
594 $this->connection->executeQuery("
595 CREATE TEMPORARY TABLE {$tmpTableName}
596 (`id` int(11) unsigned NOT NULL, UNIQUE(`id`), PRIMARY KEY (`id`)) AS
597 {$registeredCustomersSubQuery}
598 ");
599 // Registered users with a customer role
600 $this->connection->executeQuery("
601 INSERT IGNORE INTO {$tmpTableName}
602 SELECT DISTINCT wpum.user_id AS id FROM {$wpdb->usermeta} wpum
603 WHERE wpum.meta_key = :capabilities AND wpum.meta_value LIKE '%\"customer\"%'
604 ", ['capabilities' => $wpdb->prefix . 'capabilities']);
605
606 // Unmark WC list registered users which aren't WC customers anymore
607 $subQb = $this->connection->createQueryBuilder();
608 $subQb->select('mps.id')
609 ->from($subscribersTable, 'mps')
610 ->join('mps', $subscriberSegmentsTable, 'mpss', 'mps.id = mpss.subscriber_id AND mpss.segment_id = :segmentId')
611 ->leftJoin('mps', $tmpTableName, 'wctmp', 'mps.wp_user_id = wctmp.id')
612 ->where('mps.is_woocommerce_user = 1')
613 ->andWhere('wctmp.id IS NULL')
614 ->andWhere('mps.wp_user_id IS NOT NULL');
615 $qb = $this->connection->createQueryBuilder();
616 $qb->update($subscribersTable)
617 ->set('is_woocommerce_user', '0')
618 ->where("id IN (SELECT id FROM ({$subQb->getSQL()}) AS sq) ")
619 ->setParameter('segmentId', $wcSegment->getId());
620 $qb->execute();
621
622 $this->connection->executeQuery("DROP TABLE {$tmpTableName}");
623
624 // Remove guest customers
625
626 // Insert WC customer emails to a temporary table and ensure matching collations
627 // between MailPoet and WooCommerce emails for left join to use an index
628 $tmpTableName = Env::$dbPrefix . 'tmp_wc_emails';
629 if ($this->needsCollationChange()) {
630 $collation = "COLLATE $this->mailpoetEmailCollation";
631 } else {
632 $collation = "COLLATE $this->wpPostmetaValueCollation";
633 }
634
635 if ($this->woocommerceHelper->isWooCommerceCustomOrdersTableEnabled()) {
636 $ordersTable = $this->woocommerceHelper->getOrdersTableName();
637 $guestCustomersSubQuery = "SELECT DISTINCT billing_email AS email FROM `{$ordersTable}` WHERE type = 'shop_order' AND billing_email IS NOT NULL AND billing_email != ''";
638 } else {
639 $guestCustomersSubQuery = "SELECT DISTINCT wppm.meta_value AS email FROM {$wpdb->postmeta} wppm
640 JOIN {$wpdb->posts} wpp ON wppm.post_id = wpp.ID
641 AND wpp.post_type = 'shop_order'
642 WHERE wppm.meta_key = '_billing_email'";
643 }
644
645 $this->connection->executeQuery("
646 CREATE TEMPORARY TABLE {$tmpTableName}
647 (`email` varchar(150) NOT NULL, UNIQUE(`email`), PRIMARY KEY (`email`)) {$collation}
648 {$guestCustomersSubQuery}
649 ");
650
651 // Remove WC list guest users which aren't WC customers anymore
652 $subQb = $this->connection->createQueryBuilder();
653 $subQb->select('mps.id')
654 ->from($subscribersTable, 'mps')
655 ->join('mps', $subscriberSegmentsTable, 'mpss', 'mps.id = mpss.subscriber_id AND mpss.segment_id = :segmentId')
656 ->leftJoin('mps', $tmpTableName, 'wctmp', 'mps.email = wctmp.email')
657 ->where('mps.is_woocommerce_user = 1')
658 ->andWhere('wctmp.email IS NULL')
659 ->andWhere('mps.wp_user_id IS NULL');
660 $qb = $this->connection->createQueryBuilder();
661 $qb->delete($subscribersTable)
662 ->where("id IN (SELECT id FROM ({$subQb->getSQL()}) AS sq) ")
663 ->setParameter('segmentId', $wcSegment->getId());
664 $qb->execute();
665
666 $this->connection->executeQuery("DROP TABLE {$tmpTableName}");
667 }
668
669 private function updateStatus(): void {
670 $subscribeOldCustomers = $this->settings->get('mailpoet_subscribe_old_woocommerce_customers.enabled', false);
671 if ($subscribeOldCustomers !== "1") {
672 $status = SubscriberEntity::STATUS_UNSUBSCRIBED;
673 } else {
674 $status = SubscriberEntity::STATUS_SUBSCRIBED;
675 }
676 $subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
677 $subscriberSegmentsTable = $this->entityManager->getClassMetadata(SubscriberSegmentEntity::class)->getTableName();
678 $wcSegment = $this->segmentsRepository->getWooCommerceSegment();
679
680 $this->connection->executeQuery(
681 "
682 UPDATE LOW_PRIORITY {$subscriberSegmentsTable} AS mpss
683 JOIN {$subscribersTable} AS mps ON mpss.subscriber_id = mps.id
684 SET mpss.status = :status
685 WHERE
686 mpss.segment_id = :segmentId
687 AND mps.confirmed_at IS NULL
688 AND mps.confirmed_ip IS NULL
689 AND mps.is_woocommerce_user = 1
690 ",
691 ['status' => $status, 'segmentId' => $wcSegment->getId()],
692 ['status' => ParameterType::STRING, 'segmentId' => ParameterType::INTEGER]
693 );
694 }
695 }
696