PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / trunk
WCPOS – Point of Sale (POS) plugin for WooCommerce vtrunk
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 1.9.13 1.9.12 1.9.11 1.9.10 All 159 releases
woocommerce-pos / includes / API / V1 / Customers_Controller.php

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

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