diff --git a/includes/API.php b/includes/API.php index 78693d2a7..8111547bb 100644 --- a/includes/API.php +++ b/includes/API.php @@ -754,4 +754,22 @@ public function update_commerce_integration( $this->set_response_handler( API\CommerceIntegration\Configuration\Update\Response::class ); return $this->perform_request( $request ); } + + public function get_batch_status( string $catalog_id, string $batch_handle ) { + $endpoint = sprintf( '/%s/check_batch_request_status', $catalog_id ); + + $query_params = array( 'handle' => $batch_handle ); + + $url = $endpoint . '?' . http_build_query( $query_params ); + + $request = new Request( + $url, + 'GET' + ); + + $this->set_response_handler( API\ProductCatalog\ProductGroups\Read\Response::class ); + $response = $this->perform_request( $request ); + + return $response; + } } diff --git a/includes/API/ProductCatalog/ProductGroups/Read/Response.php b/includes/API/ProductCatalog/ProductGroups/Read/Response.php index 563f4d16c..ac172c0a6 100644 --- a/includes/API/ProductCatalog/ProductGroups/Read/Response.php +++ b/includes/API/ProductCatalog/ProductGroups/Read/Response.php @@ -32,4 +32,13 @@ public function get_ids(): array { } return $product_item_ids; } + + /** + * Returns the full response data, including warnings, errors, and other metadata. + * + * @return array + */ + public function get_full_response(): array { + return $this->response_data; + } } diff --git a/includes/Admin.php b/includes/Admin.php index b38791a28..8878394a2 100644 --- a/includes/Admin.php +++ b/includes/Admin.php @@ -613,10 +613,18 @@ private function add_query_vars_to_find_products_with_sync_enabled( array $query */ private function add_query_vars_to_find_products_with_sync_disabled( array $query_vars ) { $meta_query = array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'relation' => 'OR', + array( 'key' => Products::get_product_sync_meta_key(), 'value' => 'no', ), + + array( + 'key' => '_fb_sync_issues', + 'value' => '"warnings";a:0', + 'compare' => 'NOT LIKE', + ), ); if ( empty( $query_vars['meta_query'] ) ) { diff --git a/includes/ProductSync/ProductValidator.php b/includes/ProductSync/ProductValidator.php index 27f3f90bb..48a608758 100644 --- a/includes/ProductSync/ProductValidator.php +++ b/includes/ProductSync/ProductValidator.php @@ -114,6 +114,7 @@ public function validate() { $this->validate_product_status(); $this->validate_product_visibility(); $this->validate_product_terms(); + $this->validate_product_sync_issues(); } /** @@ -398,4 +399,22 @@ protected function validate_variation_structure() { throw new ProductInvalidException( __( 'Too many attributes selected for product. Use 4 or less.', 'facebook-for-woocommerce' ) ); } } + + /** + * Validates the product for any sync issues before attempting to synchronize with Facebook. + * + * This function checks the product for various conditions that may prevent it from being properly synced, + * such as missing required fields or invalid data. If any issues are found, they are collected and can be + * used to prevent the sync or notify the user. + * + * @throws ProductExcludedException If the product is excluded from synchronization. + */ + protected function validate_product_sync_issues() { + $issues = get_post_meta( $this->product->get_id(), '_fb_sync_issues', true ); + if ( ! empty( $issues['warnings'] ) && is_array( $issues['warnings'] ) ) { + $messages = implode( '; ', $issues['warnings'] ); + /* translators: %s: List of sync issue messages. */ + throw new ProductExcludedException( sprintf( __( 'Sync issues: %s', 'facebook-for-woocommerce' ), $messages ) ); + } + } } diff --git a/includes/Products/Sync/Background.php b/includes/Products/Sync/Background.php index 5b22317e4..fd6973080 100644 --- a/includes/Products/Sync/Background.php +++ b/includes/Products/Sync/Background.php @@ -25,6 +25,12 @@ class Background extends BackgroundJobHandler { /** @var string data key */ protected $data_key = 'requests'; + public function __construct() { + parent::__construct(); + + add_action( 'facebook_sync_poll_handle', array( $this, 'handle_poll_action' ), 10, 1 ); + } + /** * Processes a job. * @@ -251,7 +257,171 @@ private function send_item_updates( array $requests ): array { $facebook_catalog_id = facebook_for_woocommerce()->get_integration()->get_product_catalog_id(); $response = facebook_for_woocommerce()->get_api()->send_item_updates( $facebook_catalog_id, $requests ); $response_handles = $response->handles; - $handles = ( isset( $response_handles ) && is_array( $response_handles ) ) ? $response_handles : array(); + $handles = ( isset( $response_handles ) && is_array( $response_handles ) ) ? $response_handles : array(); + + foreach ( $handles as $handle ) { + set_transient( 'facebook_batch_handle_' . $handle, $requests, 12 * HOUR_IN_SECONDS ); + + if ( function_exists( 'as_schedule_single_action' ) ) { + as_schedule_single_action( time() + 60, 'facebook_sync_poll_handle', array( $handle ) ); + } else { + wp_schedule_single_event( time() + 60, 'facebook_sync_poll_handle', array( $handle ) ); + } + } + return $handles; } + + + /** + * Poll Facebook API for the batch job status using the handle. + * + * @param string $handle The batch handle returned by Facebook. + * @return array|null Returns status data array on success, or null on failure. + */ + public function poll_batch_status( string $handle ): ?array { + try { + $catalog_id = facebook_for_woocommerce()->get_integration()->get_product_catalog_id(); + $response = facebook_for_woocommerce()->get_api()->get_batch_status( $catalog_id, $handle ); + + return $response->get_full_response(); + + } catch ( \Exception $e ) { + error_log( 'Exception in poll_batch_status: ' . $e->getMessage() ); + facebook_for_woocommerce()->log( 'Error polling batch status for handle ' . $handle . ': ' . $e->getMessage() ); + return null; + } + } + + + /** + * Callback triggered by scheduled action to poll batch status. + * + * @param string $handle The batch handle to poll. + */ + public function handle_poll_action( string $handle ) { + $statuses = $this->poll_batch_status( $handle ); + + if ( ! is_array( $statuses ) || empty( $statuses ) ) { + return; + } + + foreach ( $statuses as $status ) { + + if ( isset( $status['status'] ) && 'IN_PROGRESS' === $status['status'] ) { + if ( function_exists( 'as_schedule_single_action' ) ) { + as_schedule_single_action( time() + 60, 'facebook_sync_poll_handle', array( $handle ) ); + } else { + wp_schedule_single_event( time() + 60, 'facebook_sync_poll_handle', array( $handle ) ); + } + return; + } + + $this->process_batch_status_results( $status, $handle ); + } + + delete_transient( 'facebook_batch_handle_' . $handle ); + } + + + /** + * Processes the results of a batch status update for product synchronization. + * + * This method handles the status array returned from a batch operation, + * performing any necessary actions based on the results and the provided handle. + * + * @param array $status The status results from the batch operation. + * @param string $handle The unique identifier for the batch process. + */ + protected function process_batch_status_results( array $status, string $handle ) { + if ( isset( $status[0] ) && is_array( $status[0] ) && isset( $status[0]['status'] ) ) { + foreach ( $status as $single_status ) { + $this->process_batch_status_results( $single_status, $handle ); + } + return; + } + + $warnings = $status['warnings'] ?? []; + $errors = $status['errors'] ?? []; + $issues_by_id = []; + + foreach ( array_merge( $warnings, $errors ) as $entry ) { + $product_id_str = $entry['id'] ?? ''; + $post_id = null; + + if ( preg_match( '/wc_post_id_(\d+)/', $product_id_str, $matches ) ) { + $post_id = (int) $matches[1]; + } elseif ( is_numeric( $product_id_str ) ) { + $post_id = (int) $product_id_str; + } else { + $sku_parts = explode( '_', $product_id_str ); + $last_part = end( $sku_parts ); + if ( count( $sku_parts ) > 1 && is_numeric( $last_part ) ) { + $post_id = (int) $last_part; + } else { + $post_id = wc_get_product_id_by_sku( $product_id_str ); + } + } + + if ( $post_id ) { + $issues_by_id[ $post_id ]['warnings'][] = $entry['message']; + } + } + + foreach ( $issues_by_id as $post_id => $issues ) { + update_post_meta( + $post_id, + '_fb_sync_issues', + array( + 'status' => $status['status'] ?? 'UNKNOWN', + 'warnings' => $issues['warnings'] ?? [], + 'handle' => $handle, + ) + ); + } + + $posts_with_issues = get_posts( + array( + 'post_type' => 'product', + 'posts_per_page' => -1, + 'post_status' => 'any', + 'meta_key' => '_fb_sync_issues', + 'fields' => 'ids', + ) + ); + + $batch_requests = get_transient( 'facebook_batch_handle_' . $handle ); + $batched_product_ids = []; + + if ( is_array( $batch_requests ) ) { + foreach ( $batch_requests as $req ) { + if ( isset( $req['data']['id'] ) ) { + $id = $req['data']['id']; + + if ( preg_match( '/wc_post_id_(\d+)/', $id, $matches ) ) { + $batched_product_ids[] = (int) $matches[1]; + } elseif ( is_numeric( $id ) ) { + $batched_product_ids[] = (int) $id; + } else { + $sku_parts = explode( '_', $id ); + $last_part = end( $sku_parts ); + if ( count( $sku_parts ) > 1 && is_numeric( $last_part ) ) { + $batched_product_ids[] = (int) $last_part; + } else { + $product_id = wc_get_product_id_by_sku( $id ); + if ( $product_id ) { + $batched_product_ids[] = (int) $product_id; + } + } + } + } + } + } + + foreach ( $posts_with_issues as $post_id ) { + if ( in_array( $post_id, $batched_product_ids, true ) && ! isset( $issues_by_id[ $post_id ] ) ) { + delete_post_meta( $post_id, '_fb_sync_issues' ); + } + } + } }