wcpos_request = $request; add_filter( 'woocommerce_rest_prepare_customer', array( $this, 'wcpos_customer_response' ), 10, 3 ); add_filter( 'woocommerce_rest_customer_query', array( $this, 'wcpos_customer_query' ), 10, 2 ); add_filter( 'is_protected_meta', array( $this, 'wcpos_allow_uuid_meta' ), 10, 3 ); /* * Check if the request is for all customers and if the 'posts_per_page' is set to -1. * Optimised query for getting all customer IDs. */ if ( Bulk_ID_Fast_Path::supports_request( $request ) ) { return $this->wcpos_get_all_posts( $request ); } return $dispatch_result; } /** * Add custom fields to the product schema. */ public function get_item_schema() { $schema = parent::get_item_schema(); // Check and remove email format validation from the billing property. if ( isset( $schema['properties']['billing']['properties']['email']['format'] ) ) { unset( $schema['properties']['billing']['properties']['email']['format'] ); } // Add structured tax_ids property (TaxId[]). $schema['properties']['tax_ids'] = array( 'description' => /* translators: REST API schema field label or error message. */ __( 'Customer tax IDs.', 'woocommerce-pos' ), 'type' => 'array', 'context' => array( 'view', 'edit' ), 'items' => array( 'type' => 'object', 'properties' => array( 'type' => array( 'type' => 'string', 'enum' => Tax_Id_Types::all_types(), 'description' => /* translators: REST API schema field label or error message. */ __( 'Tax ID type.', 'woocommerce-pos' ), ), 'value' => array( 'type' => 'string', 'description' => /* translators: REST API schema field label or error message. */ __( 'Tax ID value.', 'woocommerce-pos' ), ), 'country' => array( 'type' => array( 'string', 'null' ), 'description' => __( 'ISO 3166-1 alpha-2 country code.', 'woocommerce-pos' ), ), 'label' => array( 'type' => array( 'string', 'null' ), 'description' => /* translators: REST API schema field label or error message. */ __( 'Optional human-readable label.', 'woocommerce-pos' ), ), ), ), ); return $schema; } /** * Check if a given request has access to create a customer. * * WC checks promote_users (< 9.9) or create_customers (9.9+). The POS * fallback checks only the version-appropriate capability so it matches * the toggle shown on the Access settings page. * * @param WP_REST_Request $request Full details about the request. * * @return WP_Error|bool */ public function create_item_permissions_check( $request ) { $permission = parent::create_item_permissions_check( $request ); if ( is_wp_error( $permission ) ) { $customer_create_cap = version_compare( WC()->version, '9.9', '>=' ) ? 'create_customers' : 'promote_users'; if ( current_user_can( $customer_create_cap ) ) { return true; } } return $permission; } /** * Check if a given request has access to update a customer. * * WCPOS never widens WooCommerce's credential fence, which refuses * email/password changes on non-customer roles. The guard additionally * keeps non-admins off staff accounts, testing capabilities rather than * WooCommerce's first-role-only test. * * @param WP_REST_Request $request Full details about the request. * * @return WP_Error|bool */ public function update_item_permissions_check( $request ) { return $this->wcpos_guarded_permissions_check( (int) $request['id'], function () use ( $request ) { return parent::update_item_permissions_check( $request ); } ); } /** * Check if a given request has access to delete a customer. * * WooCommerce refuses deleting a user whose role is outside its allowed * list, but it reads only the FIRST role, so an administrator who also * holds the customer role is deleted by any POS user with delete_users. * The guard closes that by capability. * * @param WP_REST_Request $request Full details about the request. * * @return WP_Error|bool */ public function delete_item_permissions_check( $request ) { return $this->wcpos_guarded_permissions_check( (int) $request['id'], function () use ( $request ) { return parent::delete_item_permissions_check( $request ); } ); } /** * Run WooCommerce's own check behind the staff account guard. * * The guard runs first and can only refuse. When it clears the target, * that target's roles are allowed through WooCommerce's shop_manager * role-name restriction for the duration of the check, so a cleared * subscriber or membership-plugin role is judged by capability. * * @param int $target_id Target user ID. * @param callable $check Returns WooCommerce's verdict. * * @return WP_Error|bool */ private function wcpos_guarded_permissions_check( int $target_id, callable $check ) { if ( ! Customer_Account_Guard::can_modify( get_current_user_id(), $target_id ) ) { return Customer_Account_Guard::denial(); } $restore = Customer_Account_Guard::allow_target_roles( $target_id ); try { return $check(); } finally { $restore(); } } /** * Add extra fields to WP_REST_Controller::get_collection_params(). * - add new fields to the 'orderby' enum list. * - default the 'role' filter to every user, matching the proxy Read Lane. */ public function get_collection_params() { $params = parent::get_collection_params(); /* * The POS customer space is every user on this site, not only the users * holding the `customer` role. `wcpos_get_all_posts()` has always enumerated * `wp_users` unfiltered, and the proxy Read Lane defaults `role` to `all` * (see Customers_Proxy_Behavior); only this paged list inherited wc/v3's * `role => customer` default, so the same query answered differently on each * lane and the bulk id set did not match the list it was meant to describe. * Defaulting here is the parity fix; an explicit `role` still narrows. */ if ( isset( $params['role'] ) ) { $params['role']['default'] = 'all'; } // Check if 'orderby' is set and is an array before modifying it. if ( isset( $params['orderby'] ) && \is_array( $params['orderby']['enum'] ) ) { /* * A PROJECTION of the customer sort rows, so this schema enum and the proxy * lane's claim list cannot disagree about which sorts WCPOS implements. */ $new_orderby_options = Collection_Rules::orderby_enum( 'customers' ); foreach ( $new_orderby_options as $option ) { if ( ! \in_array( $option, $params['orderby']['enum'], true ) ) { $params['orderby']['enum'][] = $option; } } } // Add 'roles' filter, this allows us to filter by multiple roles. $params['roles'] = array( 'description' => __( 'Filter customers by roles.', 'woocommerce-pos' ), 'type' => 'array', 'items' => array( 'type' => 'string', ), 'required' => false, ); return $params; } /** * Create a single item. * * @param WP_REST_Request $request Full details about the request. * * @return WP_Error|WP_REST_Response */ public function create_item( $request ) { $invalid_meta = $this->wcpos_sanitize_meta_data_param( $request ); if ( is_wp_error( $invalid_meta ) ) { return $invalid_meta; } $valid_email = $this->wcpos_validate_billing_email( $request ); if ( is_wp_error( $valid_email ) ) { return $valid_email; } /* * Generate a password for the new user. * Add filter for get_option key 'woocommerce_registration_generate_password' to ensure it is set to 'yes'. */ add_filter( 'pre_option_woocommerce_registration_generate_password', function () { return 'yes'; } ); /* * Optionally generate a username from the customer's email address. * Reads the POS 'generate_username' general setting and overrides the * store-level 'woocommerce_registration_generate_username' option so the * POS behaviour is independent of the online-checkout setting. */ $generate_username = SettingsService::instance()->generate_username_enabled(); add_filter( 'pre_option_woocommerce_registration_generate_username', function () use ( $generate_username ) { return $generate_username ? 'yes' : 'no'; } ); // Proceed with the parent method to handle the creation. $response = parent::create_item( $request ); $this->wcpos_persist_tax_ids_from_request( $response, $request ); return $response; } /** * Update a single order. * * @param WP_REST_Request $request Full details about the request. * * @return WP_Error|WP_REST_Response */ public function update_item( $request ) { $invalid_meta = $this->wcpos_sanitize_meta_data_param( $request ); if ( is_wp_error( $invalid_meta ) ) { return $invalid_meta; } $valid_email = $this->wcpos_validate_billing_email( $request ); if ( is_wp_error( $valid_email ) ) { return $valid_email; } // Proceed with the parent method to handle the creation. $response = parent::update_item( $request ); $this->wcpos_persist_tax_ids_from_request( $response, $request ); return $response; } /** * Persist `tax_ids` from a create/update request via Tax_Id_Writer. * * No-op when the request did not include `tax_ids`, the response is an * error, or the resolved user ID is invalid. * * @param mixed $response Response from the parent controller. * @param WP_REST_Request $request Original request. */ protected function wcpos_persist_tax_ids_from_request( $response, WP_REST_Request $request ): void { if ( ! ( $response instanceof WP_REST_Response ) ) { return; } $tax_ids = $request->get_param( 'tax_ids' ); if ( ! \is_array( $tax_ids ) ) { return; } $data = $response->get_data(); $user_id = isset( $data['id'] ) ? (int) $data['id'] : 0; if ( $user_id <= 0 ) { return; } ( new Tax_Id_Writer() )->write_for_user( $user_id, $tax_ids ); // Reflect persisted list in the response. $data['tax_ids'] = ( new Tax_Id_Reader() )->read_for_user( $user_id ); $response->set_data( $data ); } /** * Validate billing email. * NOTE: we have removed the format check to allow empty email addresses. * * @param WP_REST_Request $request The REST request object. * * @return bool|WP_Error */ public function wcpos_validate_billing_email( WP_REST_Request $request ) { // Your custom validation logic for the request data. $billing = $request['billing'] ?? null; $email = \is_array( $billing ) ? ( $billing['email'] ?? null ) : null; if ( ! \is_null( $email ) && '' !== $email && ! is_email( $email ) ) { return new WP_Error( 'rest_invalid_param', // translators: Use default WordPress translation. __( 'Invalid email address.', 'woocommerce-pos' ), array( 'status' => 400 ) ); } return true; } /** * Filter customer data returned from the REST API. * * @param WP_REST_Response $response The response object. * @param WP_User $user_data User object used to create response. * @param WP_REST_Request $request Request object. */ public function wcpos_customer_response( WP_REST_Response $response, WP_User $user_data, WP_REST_Request $request ): WP_REST_Response { $data = $response->get_data(); // Add the uuid to the response. $this->maybe_add_user_uuid( $user_data ); /* * Add the customer meta data to the response * * In the WC REST Customers Controller -> get_formatted_item_data_core function, the customer's * meta_data is only added for administrators. I assume this is for privacy/security reasons? * * Even for administrators, meta data starting with '_' will be filtered out. * We need to add the uuid meta_data to the response for all cashiers and also non-protected meta. * * This means we let of junk meta_data into the response, but at least we don't block data and allow * saving of meta_data. * * @TODO - add filter settings to block/allow meta_data keys */ try { $customer = new WC_Customer( $user_data->ID ); $raw_meta_data = $customer->get_meta_data(); // Monitor meta count. $this->wcpos_monitor_meta_count( $customer, $raw_meta_data ); $filtered_meta_data = array_filter( $raw_meta_data, function ( $meta ) { if ( is_protected_meta( $meta->key, 'user' ) ) { return false; } // A single enormous value fatals the response encoder no matter how few // entries the customer has; same budget as the v2 sync lane. if ( Meta_Normalizer::exceeds_value_budget( $meta->value ) ) { Meta_Normalizer::note_oversized_meta( (string) $meta->key, (int) $meta->id ); return false; } return true; } ); // Convert to WC REST API expected format. $data['meta_data'] = array_map( function ( $meta ) { return array( 'id' => $meta->id, 'key' => $meta->key, 'value' => $meta->value, ); }, array_values( $filtered_meta_data ) ); } catch ( Exception $e ) { Logger::log( 'Error getting customer meta data: ' . $e->getMessage() ); } // Add structured tax_ids list (read fallback across legacy plugin meta keys). $data['tax_ids'] = ( new Tax_Id_Reader() )->read_for_user( $user_data->ID ); // Estimate response size and log if excessive. $this->wcpos_estimate_response_size( $data, $user_data->ID, 'Customer' ); // Set any changes to the response data. $response->set_data( $data ); return $response; } /** * Returns array of all customer ids. * * Note: user queries are a little more complicated than post queries, for example, * multisite would return all users from all sites, not just the current site. * Also, querying by role is not as simple as querying by post type. * * @param WP_REST_Request $request Full details about the request. * * @return WP_Error|WP_REST_Response */ public function wcpos_get_all_posts( $request ) { global $wpdb; $start_time = microtime( true ); $modified_after = Bulk_ID_Fast_Path::modified_after_timestamp( $request ); $has_modified_after = null !== $modified_after; $id_with_modified_date = Bulk_ID_Fast_Path::wants_modified_date( $request ); $args = array( 'fields' => array( 'ID', 'user_registered' ), // Return only the ID and registered date. // 'role__in' => 'all', // @TODO: could be an array of roles, like ['customer', 'cashier']. ); $args = Bulk_ID_Fast_Path::apply_id_filters_to_args( $args, $request ); /* * The user query is too complex to do a direct sql query, eg: multisite would return all users from all sites, * not just the current site. Also, querying by role is not as simple as querying by post type. * * For now we get all user ids and all 'last_update' meta values, then combine them into an array of objects. */ try { $user_query = new WP_User_Query( $args ); $users = $user_query->get_results(); $last_updates = array(); if ( $id_with_modified_date || $has_modified_after ) { $query = " SELECT user_id, meta_value FROM $wpdb->usermeta WHERE meta_key = 'last_update' "; // If modified_after param is set, add the condition to the query. if ( $has_modified_after ) { $query .= $wpdb->prepare( ' AND meta_value > %d', (int) $modified_after ); } $last_update_results = $wpdb->get_results( $query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Query is prepared conditionally above. // Manually create the associative array of user_id => last_update. foreach ( $last_update_results as $result ) { $last_updates[ $result->user_id ] = is_numeric( $result->meta_value ) ? gmdate( 'Y-m-d\TH:i:s', (int) $result->meta_value ) : null; } } /** * Performance notes: * - Using a generator is faster than array_map when dealing with large datasets. * - If date is in the format 'Y-m-d H:i:s' we just do preg_replace to 'Y-m-d\TH:i:s', * rather than using wc_rest_prepare_date_response * * This resulted in execution time of 10% of the original time. * * If the modified_after param is set, we don't need to loop through the entire user list. * The last_update_results array will only contain the users that have been modified after the given date. * We just need to check they are valid user ids, this sucks, but there could be orphaned last_update meta values. */ $formatted_results = array(); if ( $has_modified_after ) { foreach ( $users as $user ) { if ( isset( $last_updates[ $user->ID ] ) ) { $user_info = array( 'id' => (int) $user->ID ); if ( $id_with_modified_date ) { $user_info['date_modified_gmt'] = $last_updates[ $user->ID ]; } $formatted_results[] = $user_info; } } } else { $formatted_results = iterator_to_array( ( function () use ( $users, $last_updates, $id_with_modified_date ) { foreach ( $users as $user ) { $user_info = array( 'id' => (int) $user->ID ); if ( $id_with_modified_date ) { if ( isset( $last_updates[ $user->ID ] ) && ! empty( $last_updates[ $user->ID ] ) ) { $user_info['date_modified_gmt'] = $last_updates[ $user->ID ]; } else { $user_info['date_modified_gmt'] = null; // users can have null date_modified_gmt. } } yield $user_info; } } )() ); } return Bulk_ID_Fast_Path::response( $this, $formatted_results, $start_time, false ); } catch ( Exception $e ) { return Bulk_ID_Fast_Path::fetch_error( 'Error fetching order IDs: ' . $e->getMessage(), 'Error fetching customer IDs.' ); } } /** * Filter arguments, before passing to WP_User_Query, when querying users via the REST API. * * @param array $prepared_args Array of arguments for WP_User_Query. * @param WP_REST_Request $request The current request. * * @return array $prepared_args Array of arguments for WP_User_Query. */ public function wcpos_customer_query( array $prepared_args, WP_REST_Request $request ): array { $query_params = $request->get_query_params(); // add modified_after date_modified_gmt. if ( isset( $query_params['modified_after'] ) && '' !== $query_params['modified_after'] ) { $timestamp = strtotime( $query_params['modified_after'] ); /* * `last_update` holds a Unix timestamp, but it is stored as usermeta text and * `WP_Meta_Query` defaults an untyped clause to CHAR — which compares it as a * string. Timestamps only sort the same way as strings while they are the same * length, so a cutoff from before 2001-09-09 (nine digits) drops every current * customer, whose timestamp is ten digits starting with a `1`: `'1787465309' > * '946684800'` is false, character by character. NUMERIC casts to SIGNED and * compares the numbers, which is what the bulk-id fast path below has always * done by binding the same value with `%d`. */ $prepared_args['meta_query'] = $this->wcpos_merge_meta_queries( array( array( 'key' => 'last_update', 'value' => $timestamp ? (string) $timestamp : '', 'compare' => '>', 'type' => 'NUMERIC', ), ), $prepared_args['meta_query'] ?? array() ); } // Handle orderby cases. if ( isset( $query_params['orderby'] ) ) { switch ( $query_params['orderby'] ) { case 'first_name': $prepared_args['meta_key'] = 'first_name'; $prepared_args['orderby'] = 'meta_value'; break; case 'last_name': $prepared_args['meta_key'] = 'last_name'; $prepared_args['orderby'] = 'meta_value'; break; case 'email': $prepared_args['orderby'] = 'user_email'; break; case 'role': /* * Roles live in the serialized `wp_capabilities` usermeta * (eg. a:1:{s:8:"customer";b:1;}), so a plain `orderby => * meta_value` sorts by that opaque string — dominated by the * s:N: length prefix, not the role. Defer to a pre_user_query * callback that ranks by role hierarchy instead. We leave * `orderby`/`meta_key` untouched (no WP meta join to fight) * and mark the query so the callback only touches ours. */ $prepared_args['_wcpos_orderby_role'] = true; add_action( 'pre_user_query', array( $this, 'wcpos_orderby_role' ) ); break; case 'username': $prepared_args['orderby'] = 'user_login'; break; default: break; } } // Handle search. A whitespace-only search has no terms, so treat it as no search at all. if ( isset( $query_params['search'] ) && 0 !== preg_match( '/\S/u', (string) $query_params['search'] ) ) { $search_keyword = $query_params['search']; /* * It seems that you can't search by user_email, user_login etc and meta_query at the same time. * * We will unset the search param and add a hook to modify the user query to search the user table */ unset( $prepared_args['search'] ); $prepared_args['_wcpos_search'] = $search_keyword; // store the search keyword for later use. add_action( 'pre_user_query', array( $this, 'wcpos_search_user_table' ) ); } elseif ( isset( $query_params['search'] ) ) { unset( $prepared_args['search'] ); } // Handle include/exclude. if ( isset( $request['wcpos_include'] ) || isset( $request['wcpos_exclude'] ) ) { add_action( 'pre_user_query', array( $this, 'wcpos_include_exclude_users_by_id' ) ); } // Filter by roles (this is a comma separated list of roles). if ( ! empty( $request['roles'] ) && \is_array( $request['roles'] ) ) { $roles = array_map( 'sanitize_text_field', $request['roles'] ); $prepared_args['role__in'] = $roles; // remove $prepared_args['role'] to prevent it from overriding $prepared_args['role__in']. unset( $prepared_args['role'] ); } return $prepared_args; } /** * Combine two meta_query arrays. * * Moved here from the Query_Helpers trait, of which this controller was the only caller. * * @param array $meta_query1 First meta query array. * @param array $meta_query2 Second meta query array. * * @return array Combined meta query array. */ private function wcpos_merge_meta_queries( $meta_query1, $meta_query2 ) { // If either meta_query is empty, return the other. if ( empty( $meta_query1 ) ) { return $meta_query2; } if ( empty( $meta_query2 ) ) { return $meta_query1; } // Check if both meta_queries have 'AND' as their top-level relation. if ( isset( $meta_query1['relation'] ) && 'AND' === $meta_query1['relation'] && isset( $meta_query2['relation'] ) && 'AND' === $meta_query2['relation'] ) { // Remove the 'relation' element and combine the arrays. unset( $meta_query1['relation'], $meta_query2['relation'] ); $combined = array_merge( $meta_query1, $meta_query2 ); array_unshift( $combined, array( 'relation' => 'AND' ) ); return $combined; } // If both meta_queries are not empty and do not both have 'AND', combine them with 'AND' relation. return array( 'relation' => 'AND', $meta_query1, $meta_query2, ); } /** * Add the customer search conditions to the user query. * * @param WP_User_Query $query The WP_User_Query instance (passed by reference). */ public function wcpos_search_user_table( $query ): void { global $wpdb; // Remove the hook. remove_action( 'pre_user_query', array( $this, 'wcpos_search_user_table' ) ); $query_params = $query->query_vars; /* * Only act on the customer query we prepared. WordPress fires pre_user_query for every * WP_User_Query, and this callback can survive onto an unrelated one if our own query is * short-circuited (eg. via the users_pre_query filter) before it runs. Without our search * marker there is nothing to do, and appending a condition would corrupt that other query. */ if ( empty( $query_params['_wcpos_search'] ) ) { return; } $terms = preg_split( '/\s+/u', (string) $query_params['_wcpos_search'], -1, PREG_SPLIT_NO_EMPTY ); /* * Whitespace-only searches are filtered out before the hook is added, so reaching this * point means the string could not be split (eg. malformed UTF-8). We can't honour the * search, and falling through would hand back the entire customer list, so match nothing. */ if ( false === $terms || empty( $terms ) ) { $query->query_where .= ' AND 1 = 0'; return; } $terms = array_slice( $terms, 0, 10 ); $meta_keys = array_merge( array( 'first_name', 'last_name', 'billing_first_name', 'billing_last_name', 'billing_email', 'billing_company', 'billing_phone', ), Tax_Id_Reader::fallback_user_meta_keys() ); $meta_key_placeholders = implode( ', ', array_fill( 0, \count( $meta_keys ), '%s' ) ); $term_groups = array(); foreach ( $terms as $term ) { $like = '%' . $wpdb->esc_like( $term ) . '%'; $prepare_args = array_merge( array( $like, $like, $like ), $meta_keys, array( $like ) ); // 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. $term_groups[] = $wpdb->prepare( "( {$wpdb->users}.user_email LIKE %s OR {$wpdb->users}.user_login LIKE %s OR {$wpdb->users}.display_name LIKE %s OR EXISTS ( SELECT 1 FROM {$wpdb->usermeta} AS wcpos_search_meta WHERE wcpos_search_meta.user_id = {$wpdb->users}.ID AND wcpos_search_meta.meta_key IN ($meta_key_placeholders) AND wcpos_search_meta.meta_value LIKE %s ) )", $prepare_args ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared } $query->query_where .= ' AND ( ' . implode( ' AND ', $term_groups ) . ' )'; } /** * Include or exclude users by ID. * * @param WP_User_Query $query The WP_User_Query instance (passed by reference). */ public function wcpos_include_exclude_users_by_id( $query ): void { global $wpdb; // Remove the hook. remove_action( 'pre_user_query', array( $this, 'wcpos_include_exclude_users_by_id' ) ); // Handle 'wcpos_include'. if ( ! empty( $this->wcpos_request['wcpos_include'] ) ) { $include_ids = array_map( 'intval', (array) $this->wcpos_request['wcpos_include'] ); $ids_format = implode( ',', array_fill( 0, \count( $include_ids ), '%d' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $ids_format is a safe placeholder string. $query->query_where .= $wpdb->prepare( " AND {$wpdb->users}.ID IN ($ids_format) ", $include_ids ); } // Handle 'wcpos_exclude'. if ( ! empty( $this->wcpos_request['wcpos_exclude'] ) ) { $exclude_ids = array_map( 'intval', (array) $this->wcpos_request['wcpos_exclude'] ); $ids_format = implode( ',', array_fill( 0, \count( $exclude_ids ), '%d' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $ids_format is a safe placeholder string. $query->query_where .= $wpdb->prepare( " AND {$wpdb->users}.ID NOT IN ($ids_format) ", $exclude_ids ); } } /** * Order the customer query by role hierarchy. * * Roles are stored in the serialized `wp_capabilities` usermeta, which SQL * cannot meaningfully ORDER BY (the value's leading `s:N:` length prefix, not * the role name, dominates a string sort). Instead of extracting the slug we * LEFT JOIN that meta row and rank it with a CASE ladder built from the known * role hierarchy, matching each role by its quoted, serialization-safe slug * (`"customer"`) so the length prefix is irrelevant. * * Design decisions: * - Hierarchy, not alphabetical/label: the POS customer space is every WP * user (#1379) and is overwhelmingly `customer`. A cashier sorting by role * wants staff grouped and predictably separated from buyers, not scattered * across the alphabet, so we rank by privilege (administrator → subscriber), * unknown/custom/no-role users last. * - Multi-role → highest privilege: a user who is both shop_manager and * customer is treated as staff. The ladder tests the most-privileged role * first, so the highest role a user holds fixes their position — which is * more correct than WP core's "first role in the array" (insertion order). * - Ties (same rank) fall back to `user_login` for a deterministic order. * * @param WP_User_Query $query The WP_User_Query instance (passed by reference). */ public function wcpos_orderby_role( $query ): void { global $wpdb; // Remove the hook. remove_action( 'pre_user_query', array( $this, 'wcpos_orderby_role' ) ); /* * Only act on the customer query we prepared. WordPress fires * pre_user_query for every WP_User_Query, and this callback can survive * onto an unrelated one if our own query is short-circuited (eg. via the * users_pre_query filter) before it runs. See wcpos_search_user_table. */ if ( empty( $query->query_vars['_wcpos_orderby_role'] ) ) { return; } $order = ( isset( $query->query_vars['order'] ) && 'DESC' === strtoupper( (string) $query->query_vars['order'] ) ) ? 'DESC' : 'ASC'; // Role privilege hierarchy, highest first. Matched on the quoted slug so // the serialized length prefix (s:N:) never affects the comparison. // `cashier` is WCPOS's own registered POS-staff role (see Activator), so // it groups with staff — above the content roles — rather than falling // into the trailing bucket with customers/subscribers. $hierarchy = array( 'administrator', 'shop_manager', 'cashier', 'editor', 'author', 'contributor', 'customer', 'subscriber', ); /* * Capabilities meta is blog-prefixed (wp_capabilities on the main site, * wp__capabilities on subsites), so resolve it via get_blog_prefix() * rather than hardcoding — the POS customer space is the current site's * users. */ $cap_key = $wpdb->get_blog_prefix() . 'capabilities'; // LEFT JOIN so users with no capabilities row still sort (into the // trailing "everyone else" bucket). Each user has at most one such row, // so the join never multiplies rows or corrupts the total count. // // Guard against a duplicate alias: `woocommerce_rest_customer_query` can // carry more than one registered instance of this controller (each adds // its own pre_user_query action), so this callback may run several times // for one query. Appending the join unconditionally would emit two // `wcpos_role_meta` aliases and fail with "Not unique table/alias". if ( false === strpos( $query->query_from, 'wcpos_role_meta' ) ) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names come from $wpdb; the meta key is passed to prepare() as %s. $query->query_from .= $wpdb->prepare( " LEFT JOIN {$wpdb->usermeta} AS wcpos_role_meta ON ( {$wpdb->users}.ID = wcpos_role_meta.user_id AND wcpos_role_meta.meta_key = %s )", $cap_key ); } $when_sql = ''; $when_args = array(); $rank = 1; foreach ( $hierarchy as $role_slug ) { $when_sql .= ' WHEN wcpos_role_meta.meta_value LIKE %s THEN ' . $rank; $when_args[] = '%' . $wpdb->esc_like( '"' . $role_slug . '"' ) . '%'; ++$rank; } // $rank is now the "everyone else" bucket (unknown/custom/no role). $case_sql = "CASE{$when_sql} ELSE {$rank} END"; // 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(). $order_by = $wpdb->prepare( $case_sql, $when_args ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $order_by is prepared above; $order is a validated ASC|DESC literal; the table/column come from $wpdb. $query->query_orderby = "ORDER BY ( {$order_by} ) {$order}, {$wpdb->users}.user_login ASC"; } /** * Allow the _woocommerce_pos_uuid meta key to be saved via the REST API. * * By default, meta keys starting with '_' are protected and cannot be set via the REST API. * We need to allow our UUID meta key so that UUIDs sent from the POS app are preserved. * * @param bool $protected Whether the meta key is considered protected. * @param string $meta_key The meta key being checked. * @param string $meta_type The type of object the meta is for (post, user, etc.). * * @return bool Whether the meta key should be protected. */ public function wcpos_allow_uuid_meta( $protected, $meta_key, $meta_type ) { if ( '_woocommerce_pos_uuid' === $meta_key && 'user' === $meta_type ) { return false; } return $protected; } }