PluginProbe
ElasticPress / 5.3.5
ElasticPress v5.3.5
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 5.3.5, at includes/classes/Feature/WooCommerce/OrdersAutosuggest.php

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