| 1 |
<?php |
| 2 |
/** |
| 3 |
* Customer catalog proxy behavior. |
| 4 |
* |
| 5 |
* @package WCPOS\WooCommercePOS\API\V2\Proxy |
| 6 |
*/ |
| 7 |
|
| 8 |
namespace WCPOS\WooCommercePOS\API\V2\Proxy; |
| 9 |
|
| 10 |
use WCPOS\WooCommercePOS\Services\Tax_Id_Reader; |
| 11 |
use WCPOS\WooCommercePOS\Sync\Collection_Rules; |
| 12 |
use WP_REST_Request; |
| 13 |
use WP_User_Query; |
| 14 |
|
| 15 |
/** |
| 16 |
* Applies the customer search, filters and extended sorts without a v1 controller. |
| 17 |
*/ |
| 18 |
final class Customers_Proxy_Behavior extends Scoped_Proxy_Behavior { |
| 19 |
/** |
| 20 |
* Params this behavior claims and handles itself, rather than forwarding. |
| 21 |
* |
| 22 |
* @var array |
| 23 |
*/ |
| 24 |
private $delegated = array(); |
| 25 |
|
| 26 |
/** |
| 27 |
* Claim the customer params wc/v3 cannot express, and default the role filter. |
| 28 |
* |
| 29 |
* @param array $params Query parameters to forward. |
| 30 |
* @param WP_REST_Request $request Original proxy request. |
| 31 |
* |
| 32 |
* @return array |
| 33 |
*/ |
| 34 |
public function forwarded_params( array $params, WP_REST_Request $request ): array { |
| 35 |
$this->delegated = array(); |
| 36 |
if ( ! isset( $params['role'] ) ) { |
| 37 |
$params['role'] = 'all'; |
| 38 |
} |
| 39 |
if ( isset( $params['search'] ) ) { |
| 40 |
$search = trim( (string) $params['search'] ); |
| 41 |
unset( $params['search'] ); |
| 42 |
if ( 0 !== preg_match( '/\S/u', $search ) ) { |
| 43 |
$this->delegated['search'] = $search; |
| 44 |
} |
| 45 |
} |
| 46 |
if ( isset( $params['orderby'] ) && \in_array( (string) $params['orderby'], Collection_Rules::orderby_enum( 'customers' ), true ) ) { |
| 47 |
$this->delegated['orderby'] = (string) $params['orderby']; |
| 48 |
unset( $params['orderby'] ); |
| 49 |
} |
| 50 |
|
| 51 |
/* |
| 52 |
* `roles` (plural) is WCPOS's multi-role filter, and `modified_after` is how the |
| 53 |
* client asks for the customers touched since its last pull. Neither exists in |
| 54 |
* wc/v3, which drops an unregistered param in silence — so before this claim the |
| 55 |
* proxy lane answered a narrowed request with the UNNARROWED list, and a |
| 56 |
* `modified_after` sync pull re-fetched the whole customer space every tick. |
| 57 |
* `wcpos/v1` is the frozen authority for both, so the reads below reproduce its |
| 58 |
* gates verbatim (`roles` must be a non-empty array; a blank date is no filter). |
| 59 |
*/ |
| 60 |
$roles = $params['roles'] ?? null; |
| 61 |
if ( null !== $roles && ! \is_array( $roles ) ) { |
| 62 |
// v1 gets this for free: its `roles` schema row is `type => array`, and WP's |
| 63 |
// own arg sanitizer runs `wp_parse_list()` on a comma-joined string. The proxy |
| 64 |
// route carries no schema, so a `roles=a,b` request would otherwise be an |
| 65 |
// array on one lane and an ignored string on the other. |
| 66 |
$roles = wp_parse_list( $roles ); |
| 67 |
} |
| 68 |
if ( ! empty( $roles ) ) { |
| 69 |
$this->delegated['roles'] = array_map( 'sanitize_text_field', $roles ); |
| 70 |
} |
| 71 |
unset( $params['roles'] ); |
| 72 |
if ( isset( $params['modified_after'] ) && '' !== $params['modified_after'] ) { |
| 73 |
$this->delegated['modified_after'] = (string) $params['modified_after']; |
| 74 |
} |
| 75 |
unset( $params['modified_after'] ); |
| 76 |
|
| 77 |
return $params; |
| 78 |
} |
| 79 |
|
| 80 |
/** |
| 81 |
* Install this resource's hooks and return their removal tuples. |
| 82 |
* |
| 83 |
* @return array<int, array{0: string, 1: callable, 2: int}> |
| 84 |
*/ |
| 85 |
protected function install(): array { |
| 86 |
if ( array() === $this->delegated ) { |
| 87 |
return array(); |
| 88 |
} |
| 89 |
|
| 90 |
$bindings = array(); |
| 91 |
add_filter( 'woocommerce_rest_customer_query', array( $this, 'customer_query' ) ); |
| 92 |
$bindings[] = array( 'woocommerce_rest_customer_query', array( $this, 'customer_query' ), 10 ); |
| 93 |
if ( isset( $this->delegated['search'] ) ) { |
| 94 |
add_action( 'pre_user_query', array( $this, 'search_user_table' ) ); |
| 95 |
$bindings[] = array( 'pre_user_query', array( $this, 'search_user_table' ), 10 ); |
| 96 |
} |
| 97 |
if ( 'role' === ( $this->delegated['orderby'] ?? null ) ) { |
| 98 |
add_action( 'pre_user_query', array( $this, 'orderby_role' ) ); |
| 99 |
$bindings[] = array( 'pre_user_query', array( $this, 'orderby_role' ), 10 ); |
| 100 |
} |
| 101 |
|
| 102 |
return $bindings; |
| 103 |
} |
| 104 |
|
| 105 |
/** |
| 106 |
* Apply the delegated customer parameters to WP_User_Query arguments. |
| 107 |
* |
| 108 |
* @param array $args Prepared arguments. |
| 109 |
* |
| 110 |
* @return array |
| 111 |
*/ |
| 112 |
public function customer_query( array $args ): array { |
| 113 |
switch ( $this->delegated['orderby'] ?? '' ) { |
| 114 |
case 'first_name': |
| 115 |
case 'last_name': |
| 116 |
$args['meta_key'] = $this->delegated['orderby']; |
| 117 |
$args['orderby'] = 'meta_value'; |
| 118 |
break; |
| 119 |
case 'email': |
| 120 |
$args['orderby'] = 'user_email'; |
| 121 |
break; |
| 122 |
case 'role': |
| 123 |
$args['_wcpos_orderby_role'] = true; |
| 124 |
break; |
| 125 |
case 'username': |
| 126 |
$args['orderby'] = 'user_login'; |
| 127 |
break; |
| 128 |
} |
| 129 |
if ( isset( $this->delegated['search'] ) ) { |
| 130 |
unset( $args['search'] ); |
| 131 |
$args['_wcpos_search'] = $this->delegated['search']; |
| 132 |
} |
| 133 |
if ( isset( $this->delegated['modified_after'] ) ) { |
| 134 |
$timestamp = strtotime( $this->delegated['modified_after'] ); |
| 135 |
|
| 136 |
/* |
| 137 |
* `last_update` holds a Unix timestamp, but it is stored as usermeta text and |
| 138 |
* `WP_Meta_Query` defaults an untyped clause to CHAR — which compares it as a |
| 139 |
* string. Timestamps only sort the same way as strings while they are the same |
| 140 |
* length, so a cutoff from before 2001-09-09 (nine digits) drops every current |
| 141 |
* customer, whose timestamp is ten digits starting with a `1`: `'1787465309' > |
| 142 |
* '946684800'` is false, character by character. NUMERIC casts to SIGNED and |
| 143 |
* compares the numbers. The same controller's bulk-id fast path already binds |
| 144 |
* this value with `%d`, so this makes the two agree. |
| 145 |
*/ |
| 146 |
$last_update = array( |
| 147 |
'key' => 'last_update', |
| 148 |
'value' => $timestamp ? (string) $timestamp : '', |
| 149 |
'compare' => '>', |
| 150 |
'type' => 'NUMERIC', |
| 151 |
); |
| 152 |
|
| 153 |
/* |
| 154 |
* AND our row onto whatever is already there rather than replacing it, so a |
| 155 |
* third party filtering the same query keeps its clauses. `wcpos/v1` flattens |
| 156 |
* two AND-related sets into one level where it can; the nesting differs, the |
| 157 |
* SQL WP_Meta_Query builds from it does not. |
| 158 |
*/ |
| 159 |
$args['meta_query'] = empty( $args['meta_query'] ) // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Mirrors the v1 lane's `last_update` filter; the direct lane is the frozen authority. |
| 160 |
? array( $last_update ) |
| 161 |
: array( |
| 162 |
'relation' => 'AND', |
| 163 |
array( $last_update ), |
| 164 |
$args['meta_query'], |
| 165 |
); |
| 166 |
} |
| 167 |
if ( isset( $this->delegated['roles'] ) ) { |
| 168 |
// `role__in` and `role` are mutually exclusive in WP_User_Query; v1 drops |
| 169 |
// `role` for the same reason, so an explicit `role` cannot re-narrow the set. |
| 170 |
$args['role__in'] = $this->delegated['roles']; |
| 171 |
unset( $args['role'] ); |
| 172 |
} |
| 173 |
|
| 174 |
return $args; |
| 175 |
} |
| 176 |
|
| 177 |
/** |
| 178 |
* Add per-term user-table and customer-meta search clauses. |
| 179 |
* |
| 180 |
* @param WP_User_Query $query User query. |
| 181 |
*/ |
| 182 |
public function search_user_table( WP_User_Query $query ): void { |
| 183 |
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names come from $wpdb; $placeholders is a generated list of %s placeholders, and the keys themselves are passed to prepare() as arguments. |
| 184 |
global $wpdb; |
| 185 |
|
| 186 |
if ( empty( $query->query_vars['_wcpos_search'] ) ) { |
| 187 |
return; |
| 188 |
} |
| 189 |
$terms = preg_split( '/\s+/u', (string) $query->query_vars['_wcpos_search'], -1, PREG_SPLIT_NO_EMPTY ); |
| 190 |
if ( false === $terms || empty( $terms ) ) { |
| 191 |
$query->query_where .= ' AND 1 = 0'; |
| 192 |
return; |
| 193 |
} |
| 194 |
|
| 195 |
$terms = array_slice( $terms, 0, 10 ); |
| 196 |
$meta_keys = array_merge( |
| 197 |
array( 'first_name', 'last_name', 'billing_first_name', 'billing_last_name', 'billing_email', 'billing_company', 'billing_phone' ), |
| 198 |
Tax_Id_Reader::fallback_user_meta_keys() |
| 199 |
); |
| 200 |
$placeholders = implode( ', ', array_fill( 0, \count( $meta_keys ), '%s' ) ); |
| 201 |
$groups = array(); |
| 202 |
foreach ( $terms as $term ) { |
| 203 |
$like = '%' . $wpdb->esc_like( $term ) . '%'; |
| 204 |
$groups[] = $wpdb->prepare( |
| 205 |
"( {$wpdb->users}.user_email LIKE %s |
| 206 |
OR {$wpdb->users}.user_login LIKE %s |
| 207 |
OR {$wpdb->users}.display_name LIKE %s |
| 208 |
OR EXISTS ( |
| 209 |
SELECT 1 FROM {$wpdb->usermeta} AS wcpos_search_meta |
| 210 |
WHERE wcpos_search_meta.user_id = {$wpdb->users}.ID |
| 211 |
AND wcpos_search_meta.meta_key IN ($placeholders) |
| 212 |
AND wcpos_search_meta.meta_value LIKE %s |
| 213 |
) |
| 214 |
)", |
| 215 |
array_merge( array( $like, $like, $like ), $meta_keys, array( $like ) ) |
| 216 |
); |
| 217 |
} |
| 218 |
$query->query_where .= ' AND ( ' . implode( ' AND ', $groups ) . ' )'; |
| 219 |
} |
| 220 |
|
| 221 |
/** |
| 222 |
* Sort customer queries by the existing WCPOS role hierarchy. |
| 223 |
* |
| 224 |
* @param WP_User_Query $query User query. |
| 225 |
*/ |
| 226 |
public function orderby_role( WP_User_Query $query ): void { |
| 227 |
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names come from $wpdb; $when_sql is built from %s placeholders whose values are passed to prepare() as arguments. |
| 228 |
global $wpdb; |
| 229 |
|
| 230 |
if ( empty( $query->query_vars['_wcpos_orderby_role'] ) ) { |
| 231 |
return; |
| 232 |
} |
| 233 |
$order = isset( $query->query_vars['order'] ) && 'DESC' === strtoupper( (string) $query->query_vars['order'] ) ? 'DESC' : 'ASC'; |
| 234 |
$hierarchy = array( 'administrator', 'shop_manager', 'cashier', 'editor', 'author', 'contributor', 'customer', 'subscriber' ); |
| 235 |
$cap_key = $wpdb->get_blog_prefix() . 'capabilities'; |
| 236 |
if ( false === strpos( $query->query_from, 'wcpos_role_meta' ) ) { |
| 237 |
$query->query_from .= $wpdb->prepare( |
| 238 |
" LEFT JOIN {$wpdb->usermeta} AS wcpos_role_meta ON ( {$wpdb->users}.ID = wcpos_role_meta.user_id AND wcpos_role_meta.meta_key = %s )", |
| 239 |
$cap_key |
| 240 |
); |
| 241 |
} |
| 242 |
|
| 243 |
$when_sql = ''; |
| 244 |
$when_args = array(); |
| 245 |
$rank = 1; |
| 246 |
foreach ( $hierarchy as $role ) { |
| 247 |
$when_sql .= ' WHEN wcpos_role_meta.meta_value LIKE %s THEN ' . $rank; |
| 248 |
$when_args[] = '%' . $wpdb->esc_like( '"' . $role . '"' ) . '%'; |
| 249 |
++$rank; |
| 250 |
} |
| 251 |
$order_by = $wpdb->prepare( "CASE{$when_sql} ELSE {$rank} END", $when_args ); |
| 252 |
$query->query_orderby = "ORDER BY ( {$order_by} ) {$order}, {$wpdb->users}.user_login ASC"; |
| 253 |
} |
| 254 |
} |
| 255 |
|