PluginProbe
ElasticPress / 4.7.1
ElasticPress v4.7.1
5.3.5 5.3.4 3.6.5 3.6.6 4.0.0 4.0.1 4.1.0 4.2.0 4.2.1 4.2.2 4.3.0 4.3.1 4.4.0 4.4.1 4.5.0 4.5.1 4.5.2 4.6.0 4.6.1 4.7.0 4.7.1 4.7.2 5.0.0 5.0.1 5.0.2 All 108 releases
elasticpress / includes / classes / Feature / WooCommerce / OrdersAutosuggest.php

OrdersAutosuggest.php in ElasticPress 4.7.1, at includes/classes/Feature/WooCommerce/OrdersAutosuggest.php

620 lines 16.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WooCommerce Orders Feature
4 *
5 * @since 4.5.0
6 * @package elasticpress
7 */
8
9 namespace ElasticPress\Feature\WooCommerce;
10
11 use ElasticPress\Elasticsearch;
12 use ElasticPress\Indexables;
13 use ElasticPress\Utils;
14
15 if ( ! defined( 'ABSPATH' ) ) {
16 exit; // Exit if accessed directly.
17 }
18
19 /**
20 * WooCommerce OrdersAutosuggest Feature
21 */
22 class OrdersAutosuggest {
23 /**
24 * The name of the index.
25 *
26 * @var string
27 */
28 protected $index;
29
30 /**
31 * The search template.
32 *
33 * @var string
34 */
35 protected $search_template;
36
37 /**
38 * Initialize feature.
39 *
40 * @return void
41 */
42 public function __construct() {
43 $this->index = Indexables::factory()->get( 'post' )->get_index_name();
44 }
45
46 /**
47 * Setup feature functionality.
48 *
49 * @return void
50 */
51 public function setup() {
52 add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_admin_assets' ] );
53 add_filter( 'ep_after_update_feature', [ $this, 'after_update_feature' ], 10, 3 );
54 add_filter( 'ep_after_sync_index', [ $this, 'epio_save_search_template' ] );
55 add_filter( 'ep_saved_weighting_configuration', [ $this, 'epio_save_search_template' ] );
56 add_filter( 'ep_indexable_post_status', [ $this, 'post_statuses' ] );
57 add_filter( 'ep_indexable_post_types', [ $this, 'post_types' ] );
58 add_action( 'rest_api_init', [ $this, 'rest_api_init' ] );
59 add_filter( 'ep_post_sync_args', [ $this, 'filter_term_suggest' ], 10 );
60 add_filter( 'ep_post_mapping', [ $this, 'mapping' ] );
61 add_action( 'ep_woocommerce_shop_order_search_fields', [ $this, 'set_search_fields' ], 10, 2 );
62 add_filter( 'ep_index_posts_args', [ $this, 'maybe_query_password_protected_posts' ] );
63 add_filter( 'posts_where', [ $this, 'maybe_set_posts_where' ], 10, 2 );
64 }
65
66 /**
67 * Get the endpoint for WooCommerce Orders search.
68 *
69 * @return string WooCommerce orders search endpoint.
70 */
71 public function get_search_endpoint() {
72 /**
73 * Filters the WooCommerce Orders search endpoint.
74 *
75 * @since 4.5.0
76 * @hook ep_woocommerce_order_search_endpoint
77 * @param {string} $endpoint Endpoint path.
78 * @param {string} $index Elasticsearch index.
79 */
80 return apply_filters( 'ep_woocommerce_order_search_endpoint', "api/v1/search/orders/{$this->index}", $this->index );
81 }
82
83 /**
84 * Get the endpoint for the WooCommerce Orders search template.
85 *
86 * @return string WooCommerce Orders search template endpoint.
87 */
88 public function get_template_endpoint() {
89 /**
90 * Filters the WooCommerce Orders search template API endpoint.
91 *
92 * @since 4.5.0
93 * @hook ep_woocommerce_order_search_template_endpoint
94 * @param {string} $endpoint Endpoint path.
95 * @param {string} $index Elasticsearch index.
96 * @returns {string} Search template API endpoint.
97 */
98 return apply_filters( 'ep_woocommerce_order_search_template_endpoint', "api/v1/search/orders/{$this->index}/template", $this->index );
99 }
100
101 /**
102 * Get the endpoint for temporary tokens.
103 *
104 * @return string Temporary token endpoint.
105 */
106 public function get_token_endpoint() {
107 /**
108 * Filters the temporary token API endpoint.
109 *
110 * @since 4.5.0
111 * @hook ep_token_endpoint
112 * @param {string} $endpoint Endpoint path.
113 * @returns {string} Token API endpoint.
114 */
115 return apply_filters( 'ep_token_endpoint', 'api/v1/token' );
116 }
117
118 /**
119 * Registers the API endpoint to get a token.
120 *
121 * @return void
122 */
123 public function rest_api_init() {
124 register_rest_route(
125 'elasticpress/v1',
126 'token',
127 [
128 [
129 'callback' => [ $this, 'get_token' ],
130 'permission_callback' => [ $this, 'check_token_permission' ],
131 'methods' => 'GET',
132 ],
133 [
134 'callback' => [ $this, 'refresh_token' ],
135 'permission_callback' => [ $this, 'check_token_permission' ],
136 'methods' => 'POST',
137 ],
138 ]
139 );
140 }
141
142 /**
143 * Enqueue admin assets.
144 *
145 * @param string $hook_suffix The current admin page.
146 */
147 public function enqueue_admin_assets( $hook_suffix ) {
148 if ( 'edit.php' !== $hook_suffix ) {
149 return;
150 }
151
152 if ( ! isset( $_GET['post_type'] ) || 'shop_order' !== $_GET['post_type'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
153 return;
154 }
155
156 wp_enqueue_style(
157 'elasticpress-woocommerce-order-search',
158 EP_URL . 'dist/css/woocommerce-order-search-styles.css',
159 Utils\get_asset_info( 'woocommerce-order-search-styles', 'dependencies' ),
160 Utils\get_asset_info( 'woocommerce-order-search-styles', 'version' )
161 );
162
163 wp_enqueue_script(
164 'elasticpress-woocommerce-order-search',
165 EP_URL . 'dist/js/woocommerce-order-search-script.js',
166 Utils\get_asset_info( 'woocommerce-order-search-script', 'dependencies' ),
167 Utils\get_asset_info( 'woocommerce-order-search-script', 'version' ),
168 true
169 );
170
171 wp_set_script_translations( 'elasticpress-woocommerce-order-search', 'elasticpress' );
172
173 $api_endpoint = $this->get_search_endpoint();
174 $api_host = Utils\get_host();
175
176 wp_localize_script(
177 'elasticpress-woocommerce-order-search',
178 'epWooCommerceOrderSearch',
179 array(
180 'adminUrl' => admin_url( 'post.php' ),
181 'apiEndpoint' => $api_endpoint,
182 'apiHost' => ( 0 !== strpos( $api_endpoint, 'http' ) ) ? trailingslashit( esc_url_raw( $api_host ) ) : '',
183 'argsSchema' => $this->get_args_schema(),
184 'credentialsApiUrl' => rest_url( 'elasticpress/v1/token' ),
185 'credentialsNonce' => wp_create_nonce( 'wp_rest' ),
186 'dateFormat' => wc_date_format(),
187 'statusLabels' => wc_get_order_statuses(),
188 'timeFormat' => wc_time_format(),
189 'requestIdBase' => Utils\get_request_id_base(),
190 )
191 );
192 }
193
194 /**
195 * Save or delete the search template on ElasticPress.io based on whether
196 * the WooCommerce feature is being activated or deactivated.
197 *
198 * @param string $feature Feature slug
199 * @param array $settings Feature settings
200 * @param array $data Feature activation data
201 *
202 * @return void
203 */
204 public function after_update_feature( $feature, $settings, $data ) {
205 if ( 'woocommerce' !== $feature ) {
206 return;
207 }
208
209 if ( true === $data['active'] ) {
210 $this->epio_save_search_template();
211 } else {
212 $this->epio_delete_search_template();
213 }
214 }
215
216 /**
217 * Save the search template to ElasticPress.io.
218 *
219 * @return void
220 */
221 public function epio_save_search_template() {
222 $endpoint = $this->get_template_endpoint();
223 $template = $this->get_search_template();
224
225 Elasticsearch::factory()->remote_request(
226 $endpoint,
227 [
228 'blocking' => false,
229 'body' => $template,
230 'method' => 'PUT',
231 ]
232 );
233
234 /**
235 * Fires after the request is sent the search template API endpoint.
236 *
237 * @since 4.5.0
238 * @hook ep_woocommerce_order_search_template_saved
239 * @param {string} $template The search template (JSON).
240 * @param {string} $index Index name.
241 */
242 do_action( 'ep_woocommerce_order_search_template_saved', $template, $this->index );
243 }
244
245 /**
246 * Delete the search template from ElasticPress.io.
247 *
248 * @return void
249 */
250 public function epio_delete_search_template() {
251 $endpoint = $this->get_template_endpoint();
252
253 Elasticsearch::factory()->remote_request(
254 $endpoint,
255 [
256 'blocking' => false,
257 'method' => 'DELETE',
258 ]
259 );
260
261 /**
262 * Fires after the request is sent the search template API endpoint.
263 *
264 * @since 4.5.0
265 * @hook ep_woocommerce_order_search_template_deleted
266 * @param {string} $index Index name.
267 */
268 do_action( 'ep_woocommerce_order_search_template_deleted', $this->index );
269 }
270
271 /**
272 * Get the saved search template from ElasticPress.io.
273 *
274 * @return string|WP_Error Search template if found, WP_Error on error.
275 */
276 public function epio_get_search_template() {
277 $endpoint = $this->get_template_endpoint();
278 $request = Elasticsearch::factory()->remote_request( $endpoint );
279
280 if ( is_wp_error( $request ) ) {
281 return $request;
282 }
283
284 $response = wp_remote_retrieve_body( $request );
285
286 return $response;
287 }
288
289 /**
290 * Generate a search template.
291 *
292 * A search template is the JSON for an Elasticsearch query with a
293 * placeholder search term. The template is sent to ElasticPress.io where
294 * it's used to make Elasticsearch queries using search terms sent from
295 * the front end.
296 *
297 * @return string The search template as JSON.
298 */
299 public function get_search_template() {
300 $order_statuses = wc_get_order_statuses();
301
302 add_filter( 'ep_bypass_exclusion_from_search', '__return_true', 10 );
303 add_filter( 'ep_intercept_remote_request', '__return_true' );
304 add_filter( 'ep_do_intercept_request', [ $this, 'intercept_search_request' ], 10, 4 );
305 add_filter( 'ep_is_integrated_request', [ $this, 'is_integrated_request' ], 10, 2 );
306
307 $query = new \WP_Query(
308 array(
309 'ep_integrate' => true,
310 'ep_order_search_template' => true,
311 'post_status' => array_keys( $order_statuses ),
312 'post_type' => 'shop_order',
313 's' => '{{ep_placeholder}}',
314 )
315 );
316
317 remove_filter( 'ep_bypass_exclusion_from_search', '__return_true', 10 );
318 remove_filter( 'ep_intercept_remote_request', '__return_true' );
319 remove_filter( 'ep_do_intercept_request', [ $this, 'intercept_search_request' ], 10 );
320 remove_filter( 'ep_is_integrated_request', [ $this, 'is_integrated_request' ], 10 );
321
322 return $this->search_template;
323 }
324
325 /**
326 * Return true if a given feature is supported by WooCommerce Orders.
327 *
328 * Applied as a filter on Utils\is_integrated_request() so that features
329 * are enabled for the query that is used to generate the search template,
330 * regardless of the request type. This avoids the need to send a request
331 * to the front end.
332 *
333 * @param bool $is_integrated Whether queries for the request will be
334 * integrated.
335 * @param string $context Context for the original check. Usually the
336 * slug of the feature doing the check.
337 * @return bool True if the check is for a feature supported by WooCommerce
338 * Order search.
339 */
340 public function is_integrated_request( $is_integrated, $context ) {
341 $supported_contexts = [
342 'search',
343 'woocommerce',
344 ];
345
346 return in_array( $context, $supported_contexts, true );
347 }
348
349 /**
350 * Store intercepted request body and return request result.
351 *
352 * @param object $response Response
353 * @param array $query Query
354 * @param array $args WP_Query argument array
355 * @param int $failures Count of failures in request loop
356 * @return object $response Response
357 */
358 public function intercept_search_request( $response, $query = [], $args = [], $failures = 0 ) {
359 $this->search_template = $query['args']['body'];
360
361 return wp_remote_request( $query['url'], $args );
362 }
363
364 /**
365 * Get schema for search args.
366 *
367 * @return array Search args schema.
368 */
369 public function get_args_schema() {
370 $args = array(
371 'customer' => array(
372 'type' => 'number',
373 ),
374 'm' => array(
375 'type' => 'string',
376 ),
377 'offset' => array(
378 'type' => 'number',
379 'default' => 0,
380 ),
381 'per_page' => array(
382 'type' => 'number',
383 'default' => 6,
384 ),
385 'search' => array(
386 'type' => 'string',
387 'default' => '',
388 ),
389 );
390
391 return $args;
392 }
393
394 /**
395 * Get a temporary token.
396 *
397 * @return string|false Authorization header, or false on failure.
398 */
399 public function get_token() {
400 $user_id = get_current_user_id();
401
402 $credentials = get_user_meta( $user_id, 'ep_token', true );
403
404 if ( $credentials ) {
405 return $credentials;
406 }
407
408 return $this->refresh_token();
409 }
410
411 /**
412 * Refresh the temporary token.
413 *
414 * @return string|false Authorization header, or false on failure.
415 */
416 public function refresh_token() {
417 $user_id = get_current_user_id();
418
419 $endpoint = $this->get_token_endpoint();
420 $response = Elasticsearch::factory()->remote_request( $endpoint, [ 'method' => 'POST' ] );
421
422 if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
423 return false;
424 }
425
426 $response = wp_remote_retrieve_body( $response );
427 $response = json_decode( $response );
428
429 $credentials = base64_encode( "$response->username:$response->clear_password" ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
430
431 update_user_meta( $user_id, 'ep_token', $credentials );
432
433 return $credentials;
434 }
435
436 /**
437 * Checks if the token API can be used.
438 *
439 * @return boolean Whether the token API can be used.
440 */
441 public function check_token_permission() {
442 /**
443 * Filters the capability required to use the token API.
444 *
445 * @since 4.5.0
446 * @hook ep_token_capability
447 * @param {string} $capability Required capability.
448 */
449 $capability = apply_filters( 'ep_token_capability', 'edit_others_shop_orders' );
450
451 return current_user_can( $capability );
452 }
453
454 /**
455 * Index shop orders.
456 *
457 * @param array $post_types Indexable post types.
458 * @return array Indexable post types.
459 */
460 public function post_types( $post_types ) {
461 $post_types['shop_order'] = 'shop_order';
462
463 return $post_types;
464 }
465
466 /**
467 * Index order statuses.
468 *
469 * @param array $post_statuses Indexable post statuses.
470 * @return array Indexable post statuses.
471 */
472 public function post_statuses( $post_statuses ) {
473 $order_statuses = wc_get_order_statuses();
474
475 return array_unique( array_merge( $post_statuses, array_keys( $order_statuses ) ) );
476 }
477
478 /**
479 * Add term suggestions to be indexed
480 *
481 * @param array $post_args Array of ES args.
482 * @return array
483 */
484 public function filter_term_suggest( $post_args ) {
485 if ( empty( $post_args['post_type'] ) || 'shop_order' !== $post_args['post_type'] ) {
486 return $post_args;
487 }
488
489 if ( empty( $post_args['meta'] ) ) {
490 return $post_args;
491 }
492
493 /**
494 * Add the order number as a meta (text) field, so we can freely search on it.
495 */
496 $order_id = $post_args['ID'];
497 if ( function_exists( 'wc_get_order' ) ) {
498 $order = wc_get_order( $post_args['ID'] );
499 if ( $order && is_a( $order, 'WC_Order' ) && method_exists( $order, 'get_order_number' ) ) {
500 $order_id = $order->get_order_number();
501 }
502 }
503
504 $post_args['meta']['order_number'] = [
505 [
506 'raw' => $order_id,
507 'value' => $order_id,
508 ],
509 ];
510
511 $suggest = [];
512
513 $fields_to_ngram = [
514 '_billing_email',
515 '_billing_last_name',
516 '_billing_first_name',
517 ];
518
519 foreach ( $fields_to_ngram as $field_to_ngram ) {
520 if ( ! empty( $post_args['meta'][ $field_to_ngram ] )
521 && ! empty( $post_args['meta'][ $field_to_ngram ][0] )
522 && ! empty( $post_args['meta'][ $field_to_ngram ][0]['value'] ) ) {
523 $suggest[] = $post_args['meta'][ $field_to_ngram ][0]['value'];
524 }
525 }
526
527 if ( ! empty( $suggest ) ) {
528 $post_args['term_suggest'] = $suggest;
529 }
530
531 return $post_args;
532 }
533
534 /**
535 * Add mapping for suggest fields
536 *
537 * @param array $mapping ES mapping.
538 * @return array
539 */
540 public function mapping( $mapping ) {
541 $post_indexable = Indexables::factory()->get( 'post' );
542
543 $mapping = $post_indexable->add_ngram_analyzer( $mapping );
544 $mapping = $post_indexable->add_term_suggest_field( $mapping );
545
546 return $mapping;
547 }
548
549 /**
550 * Set the search_fields parameter in the search template.
551 *
552 * @param array $search_fields Current search fields
553 * @param \WP_Query $query Query being executed
554 * @return array New search fields
555 */
556 public function set_search_fields( array $search_fields, \WP_Query $query ) : array {
557 $is_orders_search_template = (bool) $query->get( 'ep_order_search_template' );
558
559 if ( $is_orders_search_template ) {
560 $search_fields = [
561 'meta.order_number.value',
562 'term_suggest',
563 'meta' => [
564 '_billing_email',
565 '_billing_last_name',
566 '_billing_first_name',
567 ],
568 ];
569 }
570
571 return $search_fields;
572 }
573
574 /**
575 * Allow password protected to be indexed.
576 *
577 * If Protected Content is enabled, do nothing. Otherwise, allow pw protected posts to be indexed.
578 * The feature restricts it back in maybe_set_posts_where()
579 *
580 * @see maybe_set_posts_where()
581 * @param array $args WP_Query args
582 * @return array
583 */
584 public function maybe_query_password_protected_posts( $args ) {
585 // Password protected posts are already being indexed, no need to do anything.
586 if ( isset( $args['has_password'] ) && is_null( $args['has_password'] ) ) {
587 return $args;
588 }
589
590 /**
591 * Set a flag in the query but allow it to index all password protected posts for now,
592 * so WP does not inject its own where clause.
593 */
594 $args['ep_orders_has_password'] = true;
595 $args['has_password'] = null;
596
597 return $args;
598 }
599
600 /**
601 * Restrict password protected posts back but allow orders.
602 *
603 * @see maybe_query_password_protected_posts
604 * @param string $where Current where clause
605 * @param WP_Query $query WP_Query
606 * @return string
607 */
608 public function maybe_set_posts_where( $where, $query ) {
609 global $wpdb;
610
611 if ( ! $query->get( 'ep_orders_has_password' ) ) {
612 return $where;
613 }
614
615 $where .= " AND ( {$wpdb->posts}.post_password = '' OR {$wpdb->posts}.post_type = 'shop_order' )";
616
617 return $where;
618 }
619 }
620