<?php
/**
* WooCommerce Orders Feature
*
* @since 4.5.0
* @package elasticpress
*/
namespace ElasticPress\Feature\WooCommerce;
use ElasticPress\Elasticsearch;
use ElasticPress\Features;
use ElasticPress\Indexables;
use ElasticPress\REST;
use ElasticPress\Utils;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* WooCommerce OrdersAutosuggest Feature
*/
class OrdersAutosuggest {
/**
* The name of the index.
*
* @var string
*/
protected $index;
/**
* The search template.
*
* @var string
*/
protected $search_template;
/**
* WooCommerce feature object instance
*
* @since 5.1.0
* @var WooCommerce
*/
protected $woocommerce;
/**
* Class constructor
*
* @param WooCommerce|null $woocommerce WooCommerce feature object instance
*/
public function __construct( WooCommerce $woocommerce = null ) {
$this->index = Indexables::factory()->get( 'post' )->get_index_name();
$this->woocommerce = $woocommerce ?
$woocommerce :
Features::factory()->get_registered_feature( 'woocommerce' );
}
/**
* Setup feature functionality.
*
* @return void
*/
public function setup() {
add_filter( 'ep_woocommerce_settings_schema', [ $this, 'add_settings_schema' ] );
// Orders Autosuggest feature.
if ( ! $this->is_enabled() ) {
return;
}
add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_admin_assets' ] );
add_filter( 'ep_after_update_feature', [ $this, 'after_update_feature' ], 10, 3 );
add_filter( 'ep_after_sync_index', [ $this, 'epio_save_search_template' ] );
add_filter( 'ep_saved_weighting_configuration', [ $this, 'epio_save_search_template' ] );
add_filter( 'ep_indexable_post_status', [ $this, 'post_statuses' ] );
add_filter( 'ep_indexable_post_types', [ $this, 'post_types' ] );
add_action( 'rest_api_init', [ $this, 'rest_api_init' ] );
add_filter( 'ep_post_sync_args', [ $this, 'filter_term_suggest' ], 10 );
add_filter( 'ep_post_mapping', [ $this, 'mapping' ] );
add_action( 'ep_woocommerce_shop_order_search_fields', [ $this, 'set_search_fields' ], 10, 2 );
add_filter( 'ep_index_posts_args', [ $this, 'maybe_query_password_protected_posts' ] );
add_filter( 'posts_where', [ $this, 'maybe_set_posts_where' ], 10, 2 );
}
/**
* Un-setup feature functionality.
*
* @since 5.0.0
*/
public function tear_down() {
remove_action( 'admin_enqueue_scripts', [ $this, 'enqueue_admin_assets' ] );
remove_filter( 'ep_after_update_feature', [ $this, 'after_update_feature' ] );
remove_filter( 'ep_after_sync_index', [ $this, 'epio_save_search_template' ] );
remove_filter( 'ep_saved_weighting_configuration', [ $this, 'epio_save_search_template' ] );
remove_filter( 'ep_indexable_post_status', [ $this, 'post_statuses' ] );
remove_filter( 'ep_indexable_post_types', [ $this, 'post_types' ] );
remove_action( 'rest_api_init', [ $this, 'rest_api_init' ] );
remove_filter( 'ep_post_sync_args', [ $this, 'filter_term_suggest' ] );
remove_filter( 'ep_post_mapping', [ $this, 'mapping' ] );
remove_action( 'ep_woocommerce_shop_order_search_fields', [ $this, 'set_search_fields' ] );
remove_filter( 'ep_index_posts_args', [ $this, 'maybe_query_password_protected_posts' ] );
remove_filter( 'posts_where', [ $this, 'maybe_set_posts_where' ] );
}
/**
* Get the endpoint for WooCommerce Orders search.
*
* @return string WooCommerce orders search endpoint.
*/
public function get_search_endpoint() {
/**
* Filters the WooCommerce Orders search endpoint.
*
* @since 4.5.0
* @hook ep_woocommerce_order_search_endpoint
* @param {string} $endpoint Endpoint path.
* @param {string} $index Elasticsearch index.
*/
return apply_filters( 'ep_woocommerce_order_search_endpoint', "api/v1/search/orders/{$this->index}", $this->index );
}
/**
* Get the endpoint for the WooCommerce Orders search template.
*
* @return string WooCommerce Orders search template endpoint.
*/
public function get_template_endpoint() {
/**
* Filters the WooCommerce Orders search template API endpoint.
*
* @since 4.5.0
* @hook ep_woocommerce_order_search_template_endpoint
* @param {string} $endpoint Endpoint path.
* @param {string} $index Elasticsearch index.
* @returns {string} Search template API endpoint.
*/
return apply_filters( 'ep_woocommerce_order_search_template_endpoint', "api/v1/search/orders/{$this->index}/template", $this->index );
}
/**
* Registers the API endpoint to get a token.
*
* @return void
*/
public function rest_api_init() {
$controller = new REST\Token();
$controller->register_routes();
}
/**
* Enqueue admin assets.
*
* @param string $hook_suffix The current admin page.
*/
public function enqueue_admin_assets( $hook_suffix ) {
if ( ! in_array( $hook_suffix, [ 'edit.php', 'woocommerce_page_wc-orders' ], true ) ) {
return;
}
if ( 'edit.php' === $hook_suffix ) {
if ( ! isset( $_GET['post_type'] ) || 'shop_order' !== $_GET['post_type'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
return;
}
}
wp_enqueue_style(
'elasticpress-woocommerce-order-search',
EP_URL . 'dist/css/woocommerce-order-search-styles.css',
Utils\get_asset_info( 'woocommerce-order-search-styles', 'dependencies' ),
Utils\get_asset_info( 'woocommerce-order-search-styles', 'version' )
);
wp_enqueue_script(
'elasticpress-woocommerce-order-search',
EP_URL . 'dist/js/woocommerce-order-search-script.js',
Utils\get_asset_info( 'woocommerce-order-search-script', 'dependencies' ),
Utils\get_asset_info( 'woocommerce-order-search-script', 'version' ),
true
);
wp_set_script_translations( 'elasticpress-woocommerce-order-search', 'elasticpress' );
$api_endpoint = $this->get_search_endpoint();
$api_host = Utils\get_host();
wp_localize_script(
'elasticpress-woocommerce-order-search',
'epWooCommerceOrderSearch',
array(
'adminUrl' => admin_url( 'post.php' ),
'apiEndpoint' => $api_endpoint,
'apiHost' => ( 0 !== strpos( $api_endpoint, 'http' ) ) ? trailingslashit( esc_url_raw( $api_host ) ) : '',
'argsSchema' => $this->get_args_schema(),
'credentialsApiUrl' => rest_url( 'elasticpress/v1/token' ),
'credentialsNonce' => wp_create_nonce( 'wp_rest' ),
'dateFormat' => wc_date_format(),
'statusLabels' => wc_get_order_statuses(),
'timeFormat' => wc_time_format(),
'requestIdBase' => Utils\get_request_id_base(),
)
);
}
/**
* Save or delete the search template on ElasticPress.io based on whether
* the WooCommerce feature is being activated or deactivated.
*
* @param string $feature Feature slug
* @param array $settings Feature settings
* @param array $data Feature activation data
*
* @return void
*/
public function after_update_feature( $feature, $settings, $data ) {
if ( 'woocommerce' !== $feature ) {
return;
}
if ( true === $data['active'] ) {
$this->epio_save_search_template();
} else {
$this->epio_delete_search_template();
}
}
/**
* Save the search template to ElasticPress.io.
*
* @return void
*/
public function epio_save_search_template() {
$endpoint = $this->get_template_endpoint();
$template = $this->get_search_template();
Elasticsearch::factory()->remote_request(
$endpoint,
[
'blocking' => false,
'body' => $template,
'method' => 'PUT',
]
);
/**
* Fires after the request is sent the search template API endpoint.
*
* @since 4.5.0
* @hook ep_woocommerce_order_search_template_saved
* @param {string} $template The search template (JSON).
* @param {string} $index Index name.
*/
do_action( 'ep_woocommerce_order_search_template_saved', $template, $this->index );
}
/**
* Delete the search template from ElasticPress.io.
*
* @return void
*/
public function epio_delete_search_template() {
$endpoint = $this->get_template_endpoint();
Elasticsearch::factory()->remote_request(
$endpoint,
[
'blocking' => false,
'method' => 'DELETE',
]
);
/**
* Fires after the request is sent the search template API endpoint.
*
* @since 4.5.0
* @hook ep_woocommerce_order_search_template_deleted
* @param {string} $index Index name.
*/
do_action( 'ep_woocommerce_order_search_template_deleted', $this->index );
}
/**
* Get the saved search template from ElasticPress.io.
*
* @return string|WP_Error Search template if found, WP_Error on error.
*/
public function epio_get_search_template() {
$endpoint = $this->get_template_endpoint();
$request = Elasticsearch::factory()->remote_request( $endpoint );
if ( is_wp_error( $request ) ) {
return $request;
}
$response = wp_remote_retrieve_body( $request );
return $response;
}
/**
* Generate a search template.
*
* A search template is the JSON for an Elasticsearch query with a
* placeholder search term. The template is sent to ElasticPress.io where
* it's used to make Elasticsearch queries using search terms sent from
* the front end.
*
* @return string The search template as JSON.
*/
public function get_search_template() {
$order_statuses = wc_get_order_statuses();
add_filter( 'ep_bypass_exclusion_from_search', '__return_true', 10 );
add_filter( 'ep_intercept_remote_request', '__return_true' );
add_filter( 'ep_do_intercept_request', [ $this, 'intercept_search_request' ], 10, 4 );
add_filter( 'ep_is_integrated_request', [ $this, 'is_integrated_request' ], 10, 2 );
$query = new \WP_Query(
array(
'ep_integrate' => true,
'ep_order_search_template' => true,
'post_status' => array_keys( $order_statuses ),
'post_type' => 'shop_order',
's' => '{{ep_placeholder}}',
)
);
remove_filter( 'ep_bypass_exclusion_from_search', '__return_true', 10 );
remove_filter( 'ep_intercept_remote_request', '__return_true' );
remove_filter( 'ep_do_intercept_request', [ $this, 'intercept_search_request' ], 10 );
remove_filter( 'ep_is_integrated_request', [ $this, 'is_integrated_request' ], 10 );
return $this->search_template;
}
/**
* Return true if a given feature is supported by WooCommerce Orders.
*
* Applied as a filter on Utils\is_integrated_request() so that features
* are enabled for the query that is used to generate the search template,
* regardless of the request type. This avoids the need to send a request
* to the front end.
*
* @param bool $is_integrated Whether queries for the request will be
* integrated.
* @param string $context Context for the original check. Usually the
* slug of the feature doing the check.
* @return bool True if the check is for a feature supported by WooCommerce
* Order search.
*/
public function is_integrated_request( $is_integrated, $context ) {
$supported_contexts = [
'search',
'woocommerce',
];
return in_array( $context, $supported_contexts, true );
}
/**
* Store intercepted request body and return request result.
*
* @param object $response Response
* @param array $query Query
* @param array $args WP_Query argument array
* @param int $failures Count of failures in request loop
* @return object $response Response
*/
public function intercept_search_request( $response, $query = [], $args = [], $failures = 0 ) {
$this->search_template = $query['args']['body'];
return wp_remote_request( $query['url'], $args );
}
/**
* Get schema for search args.
*
* @return array Search args schema.
*/
public function get_args_schema() {
$args = array(
'customer' => array(
'type' => 'number',
),
'm' => array(
'type' => 'string',
),
'offset' => array(
'type' => 'number',
'default' => 0,
),
'per_page' => array(
'type' => 'number',
'default' => 6,
),
'search' => array(
'type' => 'string',
'default' => '',
),
);
return $args;
}
/**
* Index shop orders.
*
* @param array $post_types Indexable post types.
* @return array Indexable post types.
*/
public function post_types( $post_types ) {
$post_types['shop_order'] = 'shop_order';
return $post_types;
}
/**
* Index order statuses.
*
* @param array $post_statuses Indexable post statuses.
* @return array Indexable post statuses.
*/
public function post_statuses( $post_statuses ) {
$order_statuses = wc_get_order_statuses();
return array_unique( array_merge( $post_statuses, array_keys( $order_statuses ) ) );
}
/**
* Add term suggestions to be indexed
*
* @param array $post_args Array of ES args.
* @return array
*/
public function filter_term_suggest( $post_args ) {
if ( empty( $post_args['post_type'] ) || 'shop_order' !== $post_args['post_type'] ) {
return $post_args;
}
if ( empty( $post_args['meta'] ) ) {
return $post_args;
}
/**
* Add the order number as a meta (text) field, so we can freely search on it.
*/
$order_id = $post_args['ID'];
if ( function_exists( 'wc_get_order' ) ) {
$order = wc_get_order( $post_args['ID'] );
if ( $order && is_a( $order, 'WC_Order' ) && method_exists( $order, 'get_order_number' ) ) {
$order_id = $order->get_order_number();
}
}
$post_args['meta']['order_number'] = [
[
'raw' => $order_id,
'value' => $order_id,
],
];
$suggest = [];
$fields_to_ngram = [
'_billing_email',
'_billing_last_name',
'_billing_first_name',
];
foreach ( $fields_to_ngram as $field_to_ngram ) {
if ( ! empty( $post_args['meta'][ $field_to_ngram ] )
&& ! empty( $post_args['meta'][ $field_to_ngram ][0] )
&& ! empty( $post_args['meta'][ $field_to_ngram ][0]['value'] ) ) {
$suggest[] = $post_args['meta'][ $field_to_ngram ][0]['value'];
}
}
if ( ! empty( $suggest ) ) {
$post_args['term_suggest'] = $suggest;
}
return $post_args;
}
/**
* Add mapping for suggest fields
*
* @param array $mapping ES mapping.
* @return array
*/
public function mapping( $mapping ) {
$post_indexable = Indexables::factory()->get( 'post' );
$mapping = $post_indexable->add_ngram_analyzer( $mapping );
$mapping = $post_indexable->add_term_suggest_field( $mapping );
return $mapping;
}
/**
* Set the search_fields parameter in the search template.
*
* @param array $search_fields Current search fields
* @param \WP_Query $query Query being executed
* @return array New search fields
*/
public function set_search_fields( array $search_fields, \WP_Query $query ): array {
$is_orders_search_template = (bool) $query->get( 'ep_order_search_template' );
if ( $is_orders_search_template ) {
$search_fields = [
'meta.order_number.value',
'term_suggest',
'meta' => [
'_billing_email',
'_billing_last_name',
'_billing_first_name',
],
];
}
return $search_fields;
}
/**
* Allow password protected to be indexed.
*
* If Protected Content is enabled, do nothing. Otherwise, allow pw protected posts to be indexed.
* The feature restricts it back in maybe_set_posts_where()
*
* @see maybe_set_posts_where()
* @param array $args WP_Query args
* @return array
*/
public function maybe_query_password_protected_posts( $args ) {
// Password protected posts are already being indexed, no need to do anything.
if ( isset( $args['has_password'] ) && is_null( $args['has_password'] ) ) {
return $args;
}
/**
* Set a flag in the query but allow it to index all password protected posts for now,
* so WP does not inject its own where clause.
*/
$args['ep_orders_has_password'] = true;
$args['has_password'] = null;
return $args;
}
/**
* Restrict password protected posts back but allow orders.
*
* @see maybe_query_password_protected_posts
* @param string $where Current where clause
* @param WP_Query $query WP_Query
* @return string
*/
public function maybe_set_posts_where( $where, $query ) {
global $wpdb;
if ( ! $query->get( 'ep_orders_has_password' ) ) {
return $where;
}
$where .= " AND ( {$wpdb->posts}.post_password = '' OR {$wpdb->posts}.post_type = 'shop_order' )";
return $where;
}
/**
* Whether orders autosuggest is available or not
*
* @since 5.1.0
* @return boolean
*/
public function is_available(): bool {
/**
* Whether the autosuggest feature is available for non
* ElasticPress.io customers.
*
* @since 4.5.0
* @hook ep_woocommerce_orders_autosuggest_available
* @param {boolean} $available Whether the feature is available.
*/
return apply_filters( 'ep_woocommerce_orders_autosuggest_available', Utils\is_epio() && $this->is_hpos_compatible() );
}
/**
* Whether orders autosuggest is enabled or not
*
* @since 5.1.0
* @return boolean
*/
public function is_enabled(): bool {
return $this->is_available() && '1' === $this->woocommerce->get_setting( 'orders' );
}
/**
* Whether the current setup is compatible with WooCommerce's HPOS or not
*
* @since 5.1.0
* @return boolean
*/
public function is_hpos_compatible() {
if (
! class_exists( '\Automattic\WooCommerce\Utilities\OrderUtil' )
|| ! method_exists( '\Automattic\WooCommerce\Utilities\OrderUtil', 'custom_orders_table_usage_is_enabled' ) ) {
return true;
}
if ( ! \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled() ) {
return true;
}
if ( wc_get_container()->get( \Automattic\WooCommerce\Internal\DataStores\Orders\DataSynchronizer::class )->data_sync_is_enabled() ) {
return true;
}
return false;
}
/**
* Add the orders autosuggest field to the WooCommerce feature schema
*
* @since 5.1.0
* @param array $settings_schema Current settings schema
* @return array
*/
public function add_settings_schema( array $settings_schema ): array {
$available = $this->is_available();
$settings_schema[] = [
'default' => '0',
'disabled' => ! $available,
'help' => $this->get_setting_help_message(),
'key' => 'orders',
'label' => __( 'Show suggestions when searching for Orders', 'elasticpress' ),
'requires_sync' => true,
'type' => 'checkbox',
];
return $settings_schema;
}
/**
* Return the help message for the setting schema field
*
* @since 5.1.0
* @return string
*/
protected function get_setting_help_message(): string {
$available = $this->is_available();
$epio_autosuggest_kb_link = 'https://www.elasticpress.io/documentation/article/configuring-elasticpress-io-order-autosuggest/';
if ( $available ) {
/* translators: 1: <a> tag (ElasticPress.io); 2. </a>; 3: <a> tag (KB article); 4. </a>; */
$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' );
return sprintf(
wp_kses( $message, 'ep-html' ),
'<a href="https://elasticpress.io/" target="_blank">',
'</a>',
'<a href="' . esc_url( $epio_autosuggest_kb_link ) . '" target="_blank">',
'</a>'
);
}
if ( ! $this->is_hpos_compatible() ) {
return esc_html__( 'Currently, autosuggest for orders is only available if WooCommerce order data storage is set in legacy or compatibility mode.', 'elasticpress' );
}
/* translators: 1: <a> tag (ElasticPress.io); 2. </a>; 3: <a> tag (KB article); 4. </a>; */
$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' );
$message = sprintf(
wp_kses( $message, 'ep-html' ),
'<a href="https://elasticpress.io/" target="_blank">',
'</a>',
'<a href="' . esc_url( $epio_autosuggest_kb_link ) . '" target="_blank">',
'</a>'
);
return $message;
}
}