PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.7
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.7
1.10.19 1.10.18 1.10.17 1.10.16 1.10.15 1.10.13 1.10.14 1.10.12 1.10.11 1.10.10 1.10.9 1.10.8 untagged-3d9b7ccddc54df87c672 1.10.7 1.10.6 1.10.5 1.10.3 1.10.4 1.10.2 1.10.1 1.10.0 1.9.17 1.9.15 1.9.16 1.9.14 All 163 releases
woocommerce-pos / includes / API / V1 / Customers_Controller.php

Customers_Controller.php in WCPOS – Point of Sale (POS) plugin for WooCommerce 1.10.7, at includes/API/V1/Customers_Controller.php

912 lines 31.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Customers_Controller.
4 *
5 * @package WCPOS\WooCommercePOS
6 */
7
8 namespace WCPOS\WooCommercePOS\API\V1;
9
10 \defined( 'ABSPATH' ) || die;
11
12 if ( ! class_exists( 'WC_REST_Customers_Controller' ) ) {
13 return;
14 }
15
16 use Exception;
17 use WC_Customer;
18 use WC_REST_Customers_Controller;
19 use WCPOS\WooCommercePOS\Logger;
20 use WCPOS\WooCommercePOS\Services\Settings as SettingsService;
21 use WCPOS\WooCommercePOS\Services\Tax_Id_Reader;
22 use WCPOS\WooCommercePOS\Services\Tax_Id_Types;
23 use WCPOS\WooCommercePOS\Services\Tax_Id_Writer;
24 use WCPOS\WooCommercePOS\Sync\Collection_Rules;
25 use WP_Error;
26 use WP_REST_Request;
27 use WP_REST_Response;
28 use WP_User;
29 use WP_User_Query;
30
31 /**
32 * Product Tgas controller class.
33 *
34 * @NOTE: methods not prefixed with wcpos_ will override WC_REST_Customers_Controller methods
35 */
36 class Customers_Controller extends WC_REST_Customers_Controller {
37 use Traits\Query_Helpers;
38 use Traits\Uuid_Handler;
39 use Traits\WCPOS_REST_API;
40
41 /**
42 * Endpoint namespace.
43 *
44 * @var string
45 */
46 protected $namespace = 'wcpos/v1';
47
48 /**
49 * Store user search results for merging with meta_query search results.
50 *
51 * @var array
52 */
53 protected $wcpos_user_search_results = array();
54
55 /**
56 * Store the request object for use in lifecycle methods.
57 *
58 * @var WP_REST_Request
59 */
60 protected $wcpos_request;
61
62 /**
63 * Dispatch request to parent controller, or override if needed.
64 *
65 * @param mixed $dispatch_result Dispatch result, will be used if not empty.
66 * @param WP_REST_Request $request Request used to generate the response.
67 * @param string $route Route matched for the request.
68 * @param array $handler Route handler used for the request.
69 */
70 public function wcpos_dispatch_request( $dispatch_result, WP_REST_Request $request, $route, $handler ) {
71 $this->wcpos_request = $request;
72
73 add_filter( 'woocommerce_rest_prepare_customer', array( $this, 'wcpos_customer_response' ), 10, 3 );
74 add_filter( 'woocommerce_rest_customer_query', array( $this, 'wcpos_customer_query' ), 10, 2 );
75 add_filter( 'is_protected_meta', array( $this, 'wcpos_allow_uuid_meta' ), 10, 3 );
76
77 /*
78 * Check if the request is for all customers and if the 'posts_per_page' is set to -1.
79 * Optimised query for getting all customer IDs.
80 */
81 if ( Bulk_ID_Fast_Path::supports_request( $request ) ) {
82 return $this->wcpos_get_all_posts( $request );
83 }
84
85 return $dispatch_result;
86 }
87
88 /**
89 * Add custom fields to the product schema.
90 */
91 public function get_item_schema() {
92 $schema = parent::get_item_schema();
93
94 // Check and remove email format validation from the billing property.
95 if ( isset( $schema['properties']['billing']['properties']['email']['format'] ) ) {
96 unset( $schema['properties']['billing']['properties']['email']['format'] );
97 }
98
99 // Add structured tax_ids property (TaxId[]).
100 $schema['properties']['tax_ids'] = array(
101 'description' => /* translators: REST API schema field label or error message. */ __( 'Customer tax IDs.', 'woocommerce-pos' ),
102 'type' => 'array',
103 'context' => array( 'view', 'edit' ),
104 'items' => array(
105 'type' => 'object',
106 'properties' => array(
107 'type' => array(
108 'type' => 'string',
109 'enum' => Tax_Id_Types::all_types(),
110 'description' => /* translators: REST API schema field label or error message. */ __( 'Tax ID type.', 'woocommerce-pos' ),
111 ),
112 'value' => array(
113 'type' => 'string',
114 'description' => /* translators: REST API schema field label or error message. */ __( 'Tax ID value.', 'woocommerce-pos' ),
115 ),
116 'country' => array(
117 'type' => array( 'string', 'null' ),
118 'description' => __( 'ISO 3166-1 alpha-2 country code.', 'woocommerce-pos' ),
119 ),
120 'label' => array(
121 'type' => array( 'string', 'null' ),
122 'description' => /* translators: REST API schema field label or error message. */ __( 'Optional human-readable label.', 'woocommerce-pos' ),
123 ),
124 ),
125 ),
126 );
127
128 return $schema;
129 }
130
131 /**
132 * Check if a given request has access to create a customer.
133 *
134 * WC checks promote_users (< 9.9) or create_customers (9.9+). The POS
135 * fallback checks only the version-appropriate capability so it matches
136 * the toggle shown on the Access settings page.
137 *
138 * @param WP_REST_Request $request Full details about the request.
139 *
140 * @return WP_Error|bool
141 */
142 public function create_item_permissions_check( $request ) {
143 $permission = parent::create_item_permissions_check( $request );
144
145 if ( is_wp_error( $permission ) ) {
146 $customer_create_cap = version_compare( WC()->version, '9.9', '>=' )
147 ? 'create_customers'
148 : 'promote_users';
149
150 if ( current_user_can( $customer_create_cap ) ) {
151 return true;
152 }
153 }
154
155 return $permission;
156 }
157
158 /**
159 * Check if a given request has access to update a customer.
160 *
161 * WC checks edit_users. The POS fallback also checks edit_users so the
162 * Access settings page toggle controls this behaviour.
163 *
164 * @param WP_REST_Request $request Full details about the request.
165 *
166 * @return WP_Error|bool
167 */
168 public function update_item_permissions_check( $request ) {
169 $permission = parent::update_item_permissions_check( $request );
170
171 if ( is_wp_error( $permission ) && current_user_can( 'edit_users' ) ) {
172 return true;
173 }
174
175 return $permission;
176 }
177
178 /**
179 * Add extra fields to WP_REST_Controller::get_collection_params().
180 * - add new fields to the 'orderby' enum list.
181 * - default the 'role' filter to every user, matching the proxy Read Lane.
182 */
183 public function get_collection_params() {
184 $params = parent::get_collection_params();
185
186 /*
187 * The POS customer space is every user on this site, not only the users
188 * holding the `customer` role. `wcpos_get_all_posts()` has always enumerated
189 * `wp_users` unfiltered, and the proxy Read Lane defaults `role` to `all`
190 * (see Customers_Proxy_Behavior); only this paged list inherited wc/v3's
191 * `role => customer` default, so the same query answered differently on each
192 * lane and the bulk id set did not match the list it was meant to describe.
193 * Defaulting here is the parity fix; an explicit `role` still narrows.
194 */
195 if ( isset( $params['role'] ) ) {
196 $params['role']['default'] = 'all';
197 }
198
199 // Check if 'orderby' is set and is an array before modifying it.
200 if ( isset( $params['orderby'] ) && \is_array( $params['orderby']['enum'] ) ) {
201 /*
202 * A PROJECTION of the customer sort rows, so this schema enum and the proxy
203 * lane's claim list cannot disagree about which sorts WCPOS implements.
204 */
205 $new_orderby_options = Collection_Rules::orderby_enum( 'customers' );
206 foreach ( $new_orderby_options as $option ) {
207 if ( ! \in_array( $option, $params['orderby']['enum'], true ) ) {
208 $params['orderby']['enum'][] = $option;
209 }
210 }
211 }
212
213 // Add 'roles' filter, this allows us to filter by multiple roles.
214 $params['roles'] = array(
215 'description' => __( 'Filter customers by roles.', 'woocommerce-pos' ),
216 'type' => 'array',
217 'items' => array(
218 'type' => 'string',
219 ),
220 'required' => false,
221 );
222
223 return $params;
224 }
225
226 /**
227 * Create a single item.
228 *
229 * @param WP_REST_Request $request Full details about the request.
230 *
231 * @return WP_Error|WP_REST_Response
232 */
233 public function create_item( $request ) {
234 $invalid_meta = $this->wcpos_sanitize_meta_data_param( $request );
235 if ( is_wp_error( $invalid_meta ) ) {
236 return $invalid_meta;
237 }
238
239 $valid_email = $this->wcpos_validate_billing_email( $request );
240 if ( is_wp_error( $valid_email ) ) {
241 return $valid_email;
242 }
243
244 /*
245 * Generate a password for the new user.
246 * Add filter for get_option key 'woocommerce_registration_generate_password' to ensure it is set to 'yes'.
247 */
248 add_filter(
249 'pre_option_woocommerce_registration_generate_password',
250 function () {
251 return 'yes';
252 }
253 );
254
255 /*
256 * Optionally generate a username from the customer's email address.
257 * Reads the POS 'generate_username' general setting and overrides the
258 * store-level 'woocommerce_registration_generate_username' option so the
259 * POS behaviour is independent of the online-checkout setting.
260 */
261 $generate_username = SettingsService::instance()->generate_username_enabled();
262 add_filter(
263 'pre_option_woocommerce_registration_generate_username',
264 function () use ( $generate_username ) {
265 return $generate_username ? 'yes' : 'no';
266 }
267 );
268
269 // Proceed with the parent method to handle the creation.
270 $response = parent::create_item( $request );
271 $this->wcpos_persist_tax_ids_from_request( $response, $request );
272
273 return $response;
274 }
275
276 /**
277 * Update a single order.
278 *
279 * @param WP_REST_Request $request Full details about the request.
280 *
281 * @return WP_Error|WP_REST_Response
282 */
283 public function update_item( $request ) {
284 $invalid_meta = $this->wcpos_sanitize_meta_data_param( $request );
285 if ( is_wp_error( $invalid_meta ) ) {
286 return $invalid_meta;
287 }
288
289 $valid_email = $this->wcpos_validate_billing_email( $request );
290 if ( is_wp_error( $valid_email ) ) {
291 return $valid_email;
292 }
293
294 // Proceed with the parent method to handle the creation.
295 $response = parent::update_item( $request );
296 $this->wcpos_persist_tax_ids_from_request( $response, $request );
297
298 return $response;
299 }
300
301 /**
302 * Persist `tax_ids` from a create/update request via Tax_Id_Writer.
303 *
304 * No-op when the request did not include `tax_ids`, the response is an
305 * error, or the resolved user ID is invalid.
306 *
307 * @param mixed $response Response from the parent controller.
308 * @param WP_REST_Request $request Original request.
309 */
310 protected function wcpos_persist_tax_ids_from_request( $response, WP_REST_Request $request ): void {
311 if ( ! ( $response instanceof WP_REST_Response ) ) {
312 return;
313 }
314 $tax_ids = $request->get_param( 'tax_ids' );
315 if ( ! \is_array( $tax_ids ) ) {
316 return;
317 }
318
319 $data = $response->get_data();
320 $user_id = isset( $data['id'] ) ? (int) $data['id'] : 0;
321 if ( $user_id <= 0 ) {
322 return;
323 }
324
325 ( new Tax_Id_Writer() )->write_for_user( $user_id, $tax_ids );
326
327 // Reflect persisted list in the response.
328 $data['tax_ids'] = ( new Tax_Id_Reader() )->read_for_user( $user_id );
329 $response->set_data( $data );
330 }
331
332 /**
333 * Validate billing email.
334 * NOTE: we have removed the format check to allow empty email addresses.
335 *
336 * @param WP_REST_Request $request The REST request object.
337 *
338 * @return bool|WP_Error
339 */
340 public function wcpos_validate_billing_email( WP_REST_Request $request ) {
341 // Your custom validation logic for the request data.
342 $billing = $request['billing'] ?? null;
343 $email = \is_array( $billing ) ? ( $billing['email'] ?? null ) : null;
344
345 if ( ! \is_null( $email ) && '' !== $email && ! is_email( $email ) ) {
346 return new WP_Error(
347 'rest_invalid_param',
348 // translators: Use default WordPress translation.
349 __( 'Invalid email address.', 'woocommerce-pos' ),
350 array( 'status' => 400 )
351 );
352 }
353
354 return true;
355 }
356
357 /**
358 * Filter customer data returned from the REST API.
359 *
360 * @param WP_REST_Response $response The response object.
361 * @param WP_User $user_data User object used to create response.
362 * @param WP_REST_Request $request Request object.
363 */
364 public function wcpos_customer_response( WP_REST_Response $response, WP_User $user_data, WP_REST_Request $request ): WP_REST_Response {
365 $data = $response->get_data();
366
367 // Add the uuid to the response.
368 $this->maybe_add_user_uuid( $user_data );
369
370 /*
371 * Add the customer meta data to the response
372 *
373 * In the WC REST Customers Controller -> get_formatted_item_data_core function, the customer's
374 * meta_data is only added for administrators. I assume this is for privacy/security reasons?
375 *
376 * Even for administrators, meta data starting with '_' will be filtered out.
377 * We need to add the uuid meta_data to the response for all cashiers and also non-protected meta.
378 *
379 * This means we let of junk meta_data into the response, but at least we don't block data and allow
380 * saving of meta_data.
381 *
382 * @TODO - add filter settings to block/allow meta_data keys
383 */
384 try {
385 $customer = new WC_Customer( $user_data->ID );
386 $raw_meta_data = $customer->get_meta_data();
387
388 // Monitor meta count.
389 $this->wcpos_monitor_meta_count( $customer, $raw_meta_data );
390
391 $filtered_meta_data = array_filter(
392 $raw_meta_data,
393 function ( $meta ) {
394 return ! is_protected_meta( $meta->key, 'user' );
395 }
396 );
397
398 // Convert to WC REST API expected format.
399 $data['meta_data'] = array_map(
400 function ( $meta ) {
401 return array(
402 'id' => $meta->id,
403 'key' => $meta->key,
404 'value' => $meta->value,
405 );
406 },
407 array_values( $filtered_meta_data )
408 );
409 } catch ( Exception $e ) {
410 Logger::log( 'Error getting customer meta data: ' . $e->getMessage() );
411 }
412
413 // Add structured tax_ids list (read fallback across legacy plugin meta keys).
414 $data['tax_ids'] = ( new Tax_Id_Reader() )->read_for_user( $user_data->ID );
415
416 // Estimate response size and log if excessive.
417 $this->wcpos_estimate_response_size( $data, $user_data->ID, 'Customer' );
418
419 // Set any changes to the response data.
420 $response->set_data( $data );
421
422 return $response;
423 }
424
425 /**
426 * Returns array of all customer ids.
427 *
428 * Note: user queries are a little more complicated than post queries, for example,
429 * multisite would return all users from all sites, not just the current site.
430 * Also, querying by role is not as simple as querying by post type.
431 *
432 * @param WP_REST_Request $request Full details about the request.
433 *
434 * @return WP_Error|WP_REST_Response
435 */
436 public function wcpos_get_all_posts( $request ) {
437 global $wpdb;
438
439 $start_time = microtime( true );
440 $modified_after = Bulk_ID_Fast_Path::modified_after_timestamp( $request );
441 $has_modified_after = null !== $modified_after;
442 $id_with_modified_date = Bulk_ID_Fast_Path::wants_modified_date( $request );
443
444 $args = array(
445 'fields' => array( 'ID', 'user_registered' ), // Return only the ID and registered date.
446 // 'role__in' => 'all', // @TODO: could be an array of roles, like ['customer', 'cashier'].
447 );
448 $args = Bulk_ID_Fast_Path::apply_id_filters_to_args( $args, $request );
449
450 /*
451 * The user query is too complex to do a direct sql query, eg: multisite would return all users from all sites,
452 * not just the current site. Also, querying by role is not as simple as querying by post type.
453 *
454 * For now we get all user ids and all 'last_update' meta values, then combine them into an array of objects.
455 */
456 try {
457 $user_query = new WP_User_Query( $args );
458 $users = $user_query->get_results();
459 $last_updates = array();
460
461 if ( $id_with_modified_date || $has_modified_after ) {
462 $query = "
463 SELECT user_id, meta_value
464 FROM $wpdb->usermeta
465 WHERE meta_key = 'last_update'
466 ";
467
468 // If modified_after param is set, add the condition to the query.
469 if ( $has_modified_after ) {
470 $query .= $wpdb->prepare( ' AND meta_value > %d', (int) $modified_after );
471 }
472
473 $last_update_results = $wpdb->get_results( $query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Query is prepared conditionally above.
474
475 // Manually create the associative array of user_id => last_update.
476 foreach ( $last_update_results as $result ) {
477 $last_updates[ $result->user_id ] = is_numeric( $result->meta_value ) ? gmdate( 'Y-m-d\TH:i:s', (int) $result->meta_value ) : null;
478 }
479 }
480
481 /**
482 * Performance notes:
483 * - Using a generator is faster than array_map when dealing with large datasets.
484 * - If date is in the format 'Y-m-d H:i:s' we just do preg_replace to 'Y-m-d\TH:i:s',
485 * rather than using wc_rest_prepare_date_response
486 *
487 * This resulted in execution time of 10% of the original time.
488 *
489 * If the modified_after param is set, we don't need to loop through the entire user list.
490 * The last_update_results array will only contain the users that have been modified after the given date.
491 * We just need to check they are valid user ids, this sucks, but there could be orphaned last_update meta values.
492 */
493 $formatted_results = array();
494
495 if ( $has_modified_after ) {
496 foreach ( $users as $user ) {
497 if ( isset( $last_updates[ $user->ID ] ) ) {
498 $user_info = array( 'id' => (int) $user->ID );
499 if ( $id_with_modified_date ) {
500 $user_info['date_modified_gmt'] = $last_updates[ $user->ID ];
501 }
502 $formatted_results[] = $user_info;
503 }
504 }
505 } else {
506 $formatted_results = iterator_to_array(
507 ( function () use ( $users, $last_updates, $id_with_modified_date ) {
508 foreach ( $users as $user ) {
509 $user_info = array( 'id' => (int) $user->ID );
510 if ( $id_with_modified_date ) {
511 if ( isset( $last_updates[ $user->ID ] ) && ! empty( $last_updates[ $user->ID ] ) ) {
512 $user_info['date_modified_gmt'] = $last_updates[ $user->ID ];
513 } else {
514 $user_info['date_modified_gmt'] = null; // users can have null date_modified_gmt.
515 }
516 }
517 yield $user_info;
518 }
519 } )()
520 );
521 }
522
523 return Bulk_ID_Fast_Path::response( $this, $formatted_results, $start_time, false );
524 } catch ( Exception $e ) {
525 return Bulk_ID_Fast_Path::fetch_error( 'Error fetching order IDs: ' . $e->getMessage(), 'Error fetching customer IDs.' );
526 }
527 }
528
529 /**
530 * Filter arguments, before passing to WP_User_Query, when querying users via the REST API.
531 *
532 * @param array $prepared_args Array of arguments for WP_User_Query.
533 * @param WP_REST_Request $request The current request.
534 *
535 * @return array $prepared_args Array of arguments for WP_User_Query.
536 */
537 public function wcpos_customer_query( array $prepared_args, WP_REST_Request $request ): array {
538 $query_params = $request->get_query_params();
539
540 // add modified_after date_modified_gmt.
541 if ( isset( $query_params['modified_after'] ) && '' !== $query_params['modified_after'] ) {
542 $timestamp = strtotime( $query_params['modified_after'] );
543
544 /*
545 * `last_update` holds a Unix timestamp, but it is stored as usermeta text and
546 * `WP_Meta_Query` defaults an untyped clause to CHAR — which compares it as a
547 * string. Timestamps only sort the same way as strings while they are the same
548 * length, so a cutoff from before 2001-09-09 (nine digits) drops every current
549 * customer, whose timestamp is ten digits starting with a `1`: `'1787465309' >
550 * '946684800'` is false, character by character. NUMERIC casts to SIGNED and
551 * compares the numbers, which is what the bulk-id fast path below has always
552 * done by binding the same value with `%d`.
553 */
554 $prepared_args['meta_query'] = $this->wcpos_merge_meta_queries(
555 array(
556 array(
557 'key' => 'last_update',
558 'value' => $timestamp ? (string) $timestamp : '',
559 'compare' => '>',
560 'type' => 'NUMERIC',
561 ),
562 ),
563 $prepared_args['meta_query'] ?? array()
564 );
565 }
566
567 // Handle orderby cases.
568 if ( isset( $query_params['orderby'] ) ) {
569 switch ( $query_params['orderby'] ) {
570 case 'first_name':
571 $prepared_args['meta_key'] = 'first_name';
572 $prepared_args['orderby'] = 'meta_value';
573
574 break;
575
576 case 'last_name':
577 $prepared_args['meta_key'] = 'last_name';
578 $prepared_args['orderby'] = 'meta_value';
579
580 break;
581
582 case 'email':
583 $prepared_args['orderby'] = 'user_email';
584
585 break;
586
587 case 'role':
588 /*
589 * Roles live in the serialized `wp_capabilities` usermeta
590 * (eg. a:1:{s:8:"customer";b:1;}), so a plain `orderby =>
591 * meta_value` sorts by that opaque string — dominated by the
592 * s:N: length prefix, not the role. Defer to a pre_user_query
593 * callback that ranks by role hierarchy instead. We leave
594 * `orderby`/`meta_key` untouched (no WP meta join to fight)
595 * and mark the query so the callback only touches ours.
596 */
597 $prepared_args['_wcpos_orderby_role'] = true;
598 add_action( 'pre_user_query', array( $this, 'wcpos_orderby_role' ) );
599
600 break;
601
602 case 'username':
603 $prepared_args['orderby'] = 'user_login';
604
605 break;
606
607 default:
608 break;
609 }
610 }
611
612 // Handle search. A whitespace-only search has no terms, so treat it as no search at all.
613 if ( isset( $query_params['search'] ) && 0 !== preg_match( '/\S/u', (string) $query_params['search'] ) ) {
614 $search_keyword = $query_params['search'];
615
616 /*
617 * It seems that you can't search by user_email, user_login etc and meta_query at the same time.
618 *
619 * We will unset the search param and add a hook to modify the user query to search the user table
620 */
621 unset( $prepared_args['search'] );
622 $prepared_args['_wcpos_search'] = $search_keyword; // store the search keyword for later use.
623 add_action( 'pre_user_query', array( $this, 'wcpos_search_user_table' ) );
624 } elseif ( isset( $query_params['search'] ) ) {
625 unset( $prepared_args['search'] );
626 }
627
628 // Handle include/exclude.
629 if ( isset( $request['wcpos_include'] ) || isset( $request['wcpos_exclude'] ) ) {
630 add_action( 'pre_user_query', array( $this, 'wcpos_include_exclude_users_by_id' ) );
631 }
632
633 // Filter by roles (this is a comma separated list of roles).
634 if ( ! empty( $request['roles'] ) && \is_array( $request['roles'] ) ) {
635 $roles = array_map( 'sanitize_text_field', $request['roles'] );
636 $prepared_args['role__in'] = $roles;
637 // remove $prepared_args['role'] to prevent it from overriding $prepared_args['role__in'].
638 unset( $prepared_args['role'] );
639 }
640
641 return $prepared_args;
642 }
643
644 /**
645 * Combine two meta_query arrays.
646 *
647 * Moved here from the Query_Helpers trait, of which this controller was the only caller.
648 *
649 * @param array $meta_query1 First meta query array.
650 * @param array $meta_query2 Second meta query array.
651 *
652 * @return array Combined meta query array.
653 */
654 private function wcpos_merge_meta_queries( $meta_query1, $meta_query2 ) {
655 // If either meta_query is empty, return the other.
656 if ( empty( $meta_query1 ) ) {
657 return $meta_query2;
658 }
659 if ( empty( $meta_query2 ) ) {
660 return $meta_query1;
661 }
662
663 // Check if both meta_queries have 'AND' as their top-level relation.
664 if ( isset( $meta_query1['relation'] ) && 'AND' === $meta_query1['relation'] &&
665 isset( $meta_query2['relation'] ) && 'AND' === $meta_query2['relation'] ) {
666 // Remove the 'relation' element and combine the arrays.
667 unset( $meta_query1['relation'], $meta_query2['relation'] );
668 $combined = array_merge( $meta_query1, $meta_query2 );
669 array_unshift( $combined, array( 'relation' => 'AND' ) );
670
671 return $combined;
672 }
673
674 // If both meta_queries are not empty and do not both have 'AND', combine them with 'AND' relation.
675 return array(
676 'relation' => 'AND',
677 $meta_query1,
678 $meta_query2,
679 );
680 }
681
682 /**
683 * Add the customer search conditions to the user query.
684 *
685 * @param WP_User_Query $query The WP_User_Query instance (passed by reference).
686 */
687 public function wcpos_search_user_table( $query ): void {
688 global $wpdb;
689
690 // Remove the hook.
691 remove_action( 'pre_user_query', array( $this, 'wcpos_search_user_table' ) );
692
693 $query_params = $query->query_vars;
694
695 /*
696 * Only act on the customer query we prepared. WordPress fires pre_user_query for every
697 * WP_User_Query, and this callback can survive onto an unrelated one if our own query is
698 * short-circuited (eg. via the users_pre_query filter) before it runs. Without our search
699 * marker there is nothing to do, and appending a condition would corrupt that other query.
700 */
701 if ( empty( $query_params['_wcpos_search'] ) ) {
702 return;
703 }
704
705 $terms = preg_split( '/\s+/u', (string) $query_params['_wcpos_search'], -1, PREG_SPLIT_NO_EMPTY );
706
707 /*
708 * Whitespace-only searches are filtered out before the hook is added, so reaching this
709 * point means the string could not be split (eg. malformed UTF-8). We can't honour the
710 * search, and falling through would hand back the entire customer list, so match nothing.
711 */
712 if ( false === $terms || empty( $terms ) ) {
713 $query->query_where .= ' AND 1 = 0';
714
715 return;
716 }
717
718 $terms = array_slice( $terms, 0, 10 );
719
720 $meta_keys = array_merge(
721 array(
722 'first_name',
723 'last_name',
724 'billing_first_name',
725 'billing_last_name',
726 'billing_email',
727 'billing_company',
728 'billing_phone',
729 ),
730 Tax_Id_Reader::fallback_user_meta_keys()
731 );
732
733 $meta_key_placeholders = implode( ', ', array_fill( 0, \count( $meta_keys ), '%s' ) );
734 $term_groups = array();
735
736 foreach ( $terms as $term ) {
737 $like = '%' . $wpdb->esc_like( $term ) . '%';
738 $prepare_args = array_merge( array( $like, $like, $like ), $meta_keys, array( $like ) );
739
740 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names come from $wpdb; $meta_key_placeholders is a generated list of %s placeholders, and the keys themselves are passed to prepare() as arguments.
741 $term_groups[] = $wpdb->prepare(
742 "( {$wpdb->users}.user_email LIKE %s
743 OR {$wpdb->users}.user_login LIKE %s
744 OR {$wpdb->users}.display_name LIKE %s
745 OR EXISTS (
746 SELECT 1
747 FROM {$wpdb->usermeta} AS wcpos_search_meta
748 WHERE wcpos_search_meta.user_id = {$wpdb->users}.ID
749 AND wcpos_search_meta.meta_key IN ($meta_key_placeholders)
750 AND wcpos_search_meta.meta_value LIKE %s
751 )
752 )",
753 $prepare_args
754 );
755 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
756 }
757
758 $query->query_where .= ' AND ( ' . implode( ' AND ', $term_groups ) . ' )';
759 }
760
761 /**
762 * Include or exclude users by ID.
763 *
764 * @param WP_User_Query $query The WP_User_Query instance (passed by reference).
765 */
766 public function wcpos_include_exclude_users_by_id( $query ): void {
767 global $wpdb;
768
769 // Remove the hook.
770 remove_action( 'pre_user_query', array( $this, 'wcpos_include_exclude_users_by_id' ) );
771
772 // Handle 'wcpos_include'.
773 if ( ! empty( $this->wcpos_request['wcpos_include'] ) ) {
774 $include_ids = array_map( 'intval', (array) $this->wcpos_request['wcpos_include'] );
775 $ids_format = implode( ',', array_fill( 0, \count( $include_ids ), '%d' ) );
776 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $ids_format is a safe placeholder string.
777 $query->query_where .= $wpdb->prepare( " AND {$wpdb->users}.ID IN ($ids_format) ", $include_ids );
778 }
779
780 // Handle 'wcpos_exclude'.
781 if ( ! empty( $this->wcpos_request['wcpos_exclude'] ) ) {
782 $exclude_ids = array_map( 'intval', (array) $this->wcpos_request['wcpos_exclude'] );
783 $ids_format = implode( ',', array_fill( 0, \count( $exclude_ids ), '%d' ) );
784 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $ids_format is a safe placeholder string.
785 $query->query_where .= $wpdb->prepare( " AND {$wpdb->users}.ID NOT IN ($ids_format) ", $exclude_ids );
786 }
787 }
788
789 /**
790 * Order the customer query by role hierarchy.
791 *
792 * Roles are stored in the serialized `wp_capabilities` usermeta, which SQL
793 * cannot meaningfully ORDER BY (the value's leading `s:N:` length prefix, not
794 * the role name, dominates a string sort). Instead of extracting the slug we
795 * LEFT JOIN that meta row and rank it with a CASE ladder built from the known
796 * role hierarchy, matching each role by its quoted, serialization-safe slug
797 * (`"customer"`) so the length prefix is irrelevant.
798 *
799 * Design decisions:
800 * - Hierarchy, not alphabetical/label: the POS customer space is every WP
801 * user (#1379) and is overwhelmingly `customer`. A cashier sorting by role
802 * wants staff grouped and predictably separated from buyers, not scattered
803 * across the alphabet, so we rank by privilege (administrator → subscriber),
804 * unknown/custom/no-role users last.
805 * - Multi-role → highest privilege: a user who is both shop_manager and
806 * customer is treated as staff. The ladder tests the most-privileged role
807 * first, so the highest role a user holds fixes their position — which is
808 * more correct than WP core's "first role in the array" (insertion order).
809 * - Ties (same rank) fall back to `user_login` for a deterministic order.
810 *
811 * @param WP_User_Query $query The WP_User_Query instance (passed by reference).
812 */
813 public function wcpos_orderby_role( $query ): void {
814 global $wpdb;
815
816 // Remove the hook.
817 remove_action( 'pre_user_query', array( $this, 'wcpos_orderby_role' ) );
818
819 /*
820 * Only act on the customer query we prepared. WordPress fires
821 * pre_user_query for every WP_User_Query, and this callback can survive
822 * onto an unrelated one if our own query is short-circuited (eg. via the
823 * users_pre_query filter) before it runs. See wcpos_search_user_table.
824 */
825 if ( empty( $query->query_vars['_wcpos_orderby_role'] ) ) {
826 return;
827 }
828
829 $order = ( isset( $query->query_vars['order'] ) && 'DESC' === strtoupper( (string) $query->query_vars['order'] ) )
830 ? 'DESC'
831 : 'ASC';
832
833 // Role privilege hierarchy, highest first. Matched on the quoted slug so
834 // the serialized length prefix (s:N:) never affects the comparison.
835 // `cashier` is WCPOS's own registered POS-staff role (see Activator), so
836 // it groups with staff — above the content roles — rather than falling
837 // into the trailing bucket with customers/subscribers.
838 $hierarchy = array(
839 'administrator',
840 'shop_manager',
841 'cashier',
842 'editor',
843 'author',
844 'contributor',
845 'customer',
846 'subscriber',
847 );
848
849 /*
850 * Capabilities meta is blog-prefixed (wp_capabilities on the main site,
851 * wp_<id>_capabilities on subsites), so resolve it via get_blog_prefix()
852 * rather than hardcoding — the POS customer space is the current site's
853 * users.
854 */
855 $cap_key = $wpdb->get_blog_prefix() . 'capabilities';
856
857 // LEFT JOIN so users with no capabilities row still sort (into the
858 // trailing "everyone else" bucket). Each user has at most one such row,
859 // so the join never multiplies rows or corrupts the total count.
860 //
861 // Guard against a duplicate alias: `woocommerce_rest_customer_query` can
862 // carry more than one registered instance of this controller (each adds
863 // its own pre_user_query action), so this callback may run several times
864 // for one query. Appending the join unconditionally would emit two
865 // `wcpos_role_meta` aliases and fail with "Not unique table/alias".
866 if ( false === strpos( $query->query_from, 'wcpos_role_meta' ) ) {
867 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names come from $wpdb; the meta key is passed to prepare() as %s.
868 $query->query_from .= $wpdb->prepare(
869 " LEFT JOIN {$wpdb->usermeta} AS wcpos_role_meta ON ( {$wpdb->users}.ID = wcpos_role_meta.user_id AND wcpos_role_meta.meta_key = %s )",
870 $cap_key
871 );
872 }
873
874 $when_sql = '';
875 $when_args = array();
876 $rank = 1;
877 foreach ( $hierarchy as $role_slug ) {
878 $when_sql .= ' WHEN wcpos_role_meta.meta_value LIKE %s THEN ' . $rank;
879 $when_args[] = '%' . $wpdb->esc_like( '"' . $role_slug . '"' ) . '%';
880 ++$rank;
881 }
882 // $rank is now the "everyone else" bucket (unknown/custom/no role).
883 $case_sql = "CASE{$when_sql} ELSE {$rank} END";
884
885 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $case_sql is built from a static role whitelist; the only variables are %s LIKE placeholders passed to prepare().
886 $order_by = $wpdb->prepare( $case_sql, $when_args );
887
888 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $order_by is prepared above; $order is a validated ASC|DESC literal; the table/column come from $wpdb.
889 $query->query_orderby = "ORDER BY ( {$order_by} ) {$order}, {$wpdb->users}.user_login ASC";
890 }
891
892 /**
893 * Allow the _woocommerce_pos_uuid meta key to be saved via the REST API.
894 *
895 * By default, meta keys starting with '_' are protected and cannot be set via the REST API.
896 * We need to allow our UUID meta key so that UUIDs sent from the POS app are preserved.
897 *
898 * @param bool $protected Whether the meta key is considered protected.
899 * @param string $meta_key The meta key being checked.
900 * @param string $meta_type The type of object the meta is for (post, user, etc.).
901 *
902 * @return bool Whether the meta key should be protected.
903 */
904 public function wcpos_allow_uuid_meta( $protected, $meta_key, $meta_type ) {
905 if ( '_woocommerce_pos_uuid' === $meta_key && 'user' === $meta_type ) {
906 return false;
907 }
908
909 return $protected;
910 }
911 }
912