diff --git a/class-wc-facebookcommerce.php b/class-wc-facebookcommerce.php index e45a2ce82..759f47f4a 100644 --- a/class-wc-facebookcommerce.php +++ b/class-wc-facebookcommerce.php @@ -91,6 +91,9 @@ class WC_Facebookcommerce extends WooCommerce\Facebook\Framework\Plugin { private $sync_background_handler; /** @var WooCommerce\Facebook\ProductSets\Sync product sets sync handler */ + private $legacy_product_sets_sync_handler; + + /** @var WooCommerce\Facebook\ProductSets\ProductSetSync product sets sync handler */ private $product_sets_sync_handler; /** @var WooCommerce\Facebook\Handlers\Connection connection handler */ @@ -199,17 +202,18 @@ public function init() { $this->heartbeat = new Heartbeat( WC()->queue() ); $this->heartbeat->init(); - $this->feed_manager = new WooCommerce\Facebook\Feed\FeedManager(); - $this->checkout = new WooCommerce\Facebook\Checkout(); - $this->product_feed = new WooCommerce\Facebook\Products\Feed(); - $this->products_stock_handler = new WooCommerce\Facebook\Products\Stock(); - $this->products_sync_handler = new WooCommerce\Facebook\Products\Sync(); - $this->sync_background_handler = new WooCommerce\Facebook\Products\Sync\Background(); - $this->configuration_detection = new WooCommerce\Facebook\Feed\FeedConfigurationDetection(); - $this->product_sets_sync_handler = new WooCommerce\Facebook\ProductSets\Sync(); - $this->commerce_handler = new WooCommerce\Facebook\Commerce(); - $this->fb_categories = new WooCommerce\Facebook\Products\FBCategories(); - $this->external_version_update = new WooCommerce\Facebook\ExternalVersionUpdate\Update(); + $this->feed_manager = new WooCommerce\Facebook\Feed\FeedManager(); + $this->checkout = new WooCommerce\Facebook\Checkout(); + $this->product_feed = new WooCommerce\Facebook\Products\Feed(); + $this->products_stock_handler = new WooCommerce\Facebook\Products\Stock(); + $this->products_sync_handler = new WooCommerce\Facebook\Products\Sync(); + $this->sync_background_handler = new WooCommerce\Facebook\Products\Sync\Background(); + $this->configuration_detection = new WooCommerce\Facebook\Feed\FeedConfigurationDetection(); + $this->legacy_product_sets_sync_handler = new WooCommerce\Facebook\ProductSets\Sync(); + $this->product_sets_sync_handler = new WooCommerce\Facebook\ProductSets\ProductSetSync(); + $this->commerce_handler = new WooCommerce\Facebook\Commerce(); + $this->fb_categories = new WooCommerce\Facebook\Products\FBCategories(); + $this->external_version_update = new WooCommerce\Facebook\ExternalVersionUpdate\Update(); if ( wp_doing_ajax() ) { $this->ajax = new WooCommerce\Facebook\AJAX(); @@ -617,6 +621,17 @@ public function get_products_sync_handler() { return $this->products_sync_handler; } + /** + * Gets the products sync handler. + * + * @since 3.4.9 + * + * @return WooCommerce\Facebook\ProductSets\ProductSetSync + */ + public function get_product_sets_sync_handler() { + return $this->product_sets_sync_handler; + } + /** * Gets the products sync background handler. diff --git a/includes/API.php b/includes/API.php index b1068e8a4..36fadd923 100644 --- a/includes/API.php +++ b/includes/API.php @@ -516,6 +516,19 @@ public function delete_product_set_item( string $product_set_id, bool $allow_liv return $this->perform_request( $request ); } + /** + * @param string $product_catalog_id + * @param array $data + * @return API\Response|API\ProductCatalog\ProductSets\Read\Response + * @throws ApiException + * @throws API\Exceptions\Request_Limit_Reached + */ + public function read_product_set_item( string $product_catalog_id, string $retailer_id ): API\ProductCatalog\ProductSets\Read\Response { + $request = new API\ProductCatalog\ProductSets\Read\Request( $product_catalog_id, $retailer_id ); + $this->set_response_handler( API\ProductCatalog\ProductSets\Read\Response::class ); + return $this->perform_request( $request ); + } + /** * @param string $product_catalog_id * @return API\Response|API\ProductCatalog\ProductFeeds\ReadAll\Response diff --git a/includes/API/ProductCatalog/ProductSets/Read/Request.php b/includes/API/ProductCatalog/ProductSets/Read/Request.php new file mode 100644 index 000000000..349557bc0 --- /dev/null +++ b/includes/API/ProductCatalog/ProductSets/Read/Request.php @@ -0,0 +1,28 @@ + Product Sets > Get Graph Api. + * + * @link https://developers.facebook.com/docs/marketing-api/reference/product-catalog/product_sets/ + */ +class Request extends ApiRequest { + + /** + * @param string $product_catalog_id Facebook Product Catalog ID. + * @param string $retailer_id Facebook Product Set Retailer ID. + */ + public function __construct( string $product_catalog_id, string $retailer_id ) { + parent::__construct( "/{$product_catalog_id}/product_sets", 'GET' ); + parent::set_params( + array( 'retailer_id' => $retailer_id ) + ); + } +} diff --git a/includes/API/ProductCatalog/ProductSets/Read/Response.php b/includes/API/ProductCatalog/ProductSets/Read/Response.php new file mode 100644 index 000000000..88be20974 --- /dev/null +++ b/includes/API/ProductCatalog/ProductSets/Read/Response.php @@ -0,0 +1,29 @@ + Product Groups > Get Graph Api. + * + * @link https://developers.facebook.com/docs/marketing-api/reference/product-catalog/product_sets/ + * @property-read string id Facebook Product Set ID. + * + * @since 3.4.9 + */ +class Response extends ApiResponse { + + /** + * Returns the fb product set ID. + * + * @return ?string + * @since 3.4.9 + */ + public function get_product_set_id(): ?string { + return $this->data[0]['id'] ?? null; + } +} diff --git a/includes/Handlers/Connection.php b/includes/Handlers/Connection.php index 6b32a7053..d6ecd65d7 100644 --- a/includes/Handlers/Connection.php +++ b/includes/Handlers/Connection.php @@ -423,6 +423,7 @@ public function handle_connect() { else { facebook_for_woocommerce()->log( 'Initial full product sync disabled by filter hook `facebook_for_woocommerce_allow_full_batch_api_sync`', 'facebook_for_woocommerce_connect' ); } + facebook_for_woocommerce()->get_product_sets_sync_handler()->sync_all_product_sets(); update_option( 'wc_facebook_has_connected_fbe_2', 'yes' ); update_option( 'wc_facebook_has_authorized_pages_read_engagement', 'yes' ); // redirect to the Commerce onboarding if directed to do so diff --git a/includes/Lifecycle.php b/includes/Lifecycle.php index ed490e14d..4c51d3523 100644 --- a/includes/Lifecycle.php +++ b/includes/Lifecycle.php @@ -53,7 +53,8 @@ public function __construct( Framework\Plugin $plugin ) { '2.0.4', '2.4.0', '2.5.0', - '3.2.0' + '3.2.0', + '3.4.9' ); } @@ -336,4 +337,13 @@ protected function upgrade_to_3_2_0() { delete_option( self::SETTING_MESSENGER_COLOR_HEX ); } + /** + * Trigger sync of all WooCommerce categories + * + * @since 3.4.9 + */ + protected function upgrade_to_3_4_9() { + facebook_for_woocommerce()->get_product_sets_sync_handler()->sync_all_product_sets(); + } + } diff --git a/includes/ProductSets/ProductSetSync.php b/includes/ProductSets/ProductSetSync.php new file mode 100644 index 000000000..091ca6fc0 --- /dev/null +++ b/includes/ProductSets/ProductSetSync.php @@ -0,0 +1,246 @@ +add_hooks(); + } + + + /** + * Adds needed hooks to support product set sync. + */ + private function add_hooks() { + /** + * Sets up hooks to synchronize WooCommerce category mutations (create, update, delete) with Meta catalog's product sets in real-time. + */ + add_action( 'create_' . self::WC_PRODUCT_CATEGORY_TAXONOMY, array( $this, 'on_create_or_update_product_wc_category_callback' ), 99, 3 ); + add_action( 'edited_' . self::WC_PRODUCT_CATEGORY_TAXONOMY, array( $this, 'on_create_or_update_product_wc_category_callback' ), 99, 3 ); + add_action( 'delete_' . self::WC_PRODUCT_CATEGORY_TAXONOMY, array( $this, 'on_delete_wc_product_category_callback' ), 99, 4 ); + + /** + * Schedules a daily sync of all WooCommerce categories to ensure any missed real-time updates are captured. + */ + add_action( Heartbeat::DAILY, array( $this, 'sync_all_product_sets' ) ); + } + + /** + * @since 3.4.9 + * + * @param int $term_id Term ID. + * @param int $tt_id Term taxonomy ID. + * @param array $args Arguments. + */ + public function on_create_or_update_product_wc_category_callback( $term_id, $tt_id, $args ) { + try { + if ( ! $this->is_sync_enabled() ) { + return; + } + + $wc_category = get_term( $term_id, self::WC_PRODUCT_CATEGORY_TAXONOMY ); + $fb_product_set_id = $this->get_fb_product_set_id( $wc_category ); + if ( ! empty( $fb_product_set_id ) ) { + $this->update_fb_product_set( $wc_category, $fb_product_set_id ); + } else { + $this->create_fb_product_set( $wc_category ); + } + } catch ( \Exception $exception ) { + $this->log_exception( $exception ); + } + } + + /** + * @since 3.4.9 + * + * @param int $term_id Term ID. + * @param int $tt_id Term taxonomy ID. + * @param WP_Term $deleted_term Copy of the already-deleted term. + * @param array $object_ids List of term object IDs. + */ + public function on_delete_wc_product_category_callback( $term_id, $tt_id, $deleted_term, $object_ids ) { + try { + if ( ! $this->is_sync_enabled() ) { + return; + } + + $fb_product_set_id = $this->get_fb_product_set_id( $deleted_term ); + if ( ! empty( $fb_product_set_id ) ) { + $this->delete_fb_product_set( $fb_product_set_id ); + } + } catch ( \Exception $exception ) { + $this->log_exception( $exception ); + } + } + + /** + * @since 3.4.9 + */ + public function sync_all_product_sets() { + try { + if ( ! $this->is_sync_enabled() ) { + return; + } + + $this->sync_all_wc_product_categories(); + } catch ( \Exception $exception ) { + $this->log_exception( $exception ); + } + } + + protected function is_sync_enabled() { + return facebook_for_woocommerce()->get_rollout_switches()->is_switch_enabled( + RolloutSwitches::SWITCH_PRODUCT_SETS_SYNC_ENABLED + ); + } + + private function log_exception( \Exception $exception ) { + facebook_for_woocommerce()->log( + 'ProductSetSync exception' . + ': exception_code : ' . $exception->getCode() . + '; exception_class : ' . get_class( $exception ) . + ': exception_message : ' . $exception->getMessage() . + '; exception_trace : ' . $exception->getTraceAsString(), + null, + \WC_Log_Levels::ERROR + ); + } + + /** + * Important. This is ID from the WC category to be used as a retailer ID for the FB product set + * + * @param WP_Term $wc_category The WooCommerce category object. + */ + private function get_retailer_id( $wc_category ) { + return $wc_category->term_taxonomy_id; + } + + protected function get_fb_product_set_id( $wc_category ) { + $retailer_id = $this->get_retailer_id( $wc_category ); + $fb_catalog_id = facebook_for_woocommerce()->get_integration()->get_product_catalog_id(); + + try { + $response = facebook_for_woocommerce()->get_api()->read_product_set_item( $fb_catalog_id, $retailer_id ); + } catch ( \Exception $e ) { + $message = sprintf( 'There was an error trying to get product set data in a catalog: %s', $e->getMessage() ); + facebook_for_woocommerce()->log( $message ); + + /** + * Re-throw the exception to prevent potential issues, such as creating duplicate sets. + */ + throw $e; + } + + return $response->get_product_set_id(); + } + + protected function build_fb_product_set_data( $wc_category ) { + $wc_category_name = get_term_field( 'name', $wc_category, self::WC_PRODUCT_CATEGORY_TAXONOMY ); + $wc_category_description = get_term_field( 'description', $wc_category, self::WC_PRODUCT_CATEGORY_TAXONOMY ); + $wc_category_url = get_term_link( $wc_category, self::WC_PRODUCT_CATEGORY_TAXONOMY ); + $wc_category_thumbnail_id = get_term_meta( $wc_category, 'thumbnail_id', true ); + $wc_category_thumbnail_url = wp_get_attachment_image_src( $wc_category_thumbnail_id ); + + $fb_product_set_metadata = array(); + if ( ! empty( $wc_category_thumbnail_url ) ) { + $fb_product_set_metadata['cover_image_url'] = $wc_category_thumbnail_url; + } + if ( ! empty( $wc_category_description ) ) { + $fb_product_set_metadata['description'] = $wc_category_description; + } + if ( ! empty( $wc_category_url ) ) { + $fb_product_set_metadata['external_url'] = $wc_category_url; + } + + $fb_product_set_data = array( + 'name' => $wc_category_name, + 'filter' => wp_json_encode( array( 'and' => array( array( 'product_type' => array( 'i_contains' => $wc_category_name ) ) ) ) ), + 'retailer_id' => $this->get_retailer_id( $wc_category ), + 'metadata' => wp_json_encode( $fb_product_set_metadata ), + ); + + return $fb_product_set_data; + } + + protected function create_fb_product_set( $wc_category ) { + $fb_product_set_data = $this->build_fb_product_set_data( $wc_category ); + $fb_catalog_id = facebook_for_woocommerce()->get_integration()->get_product_catalog_id(); + + try { + facebook_for_woocommerce()->get_api()->create_product_set_item( $fb_catalog_id, $fb_product_set_data ); + } catch ( \Exception $e ) { + $message = sprintf( 'There was an error trying to create product set: %s', $e->getMessage() ); + facebook_for_woocommerce()->log( $message ); + } + } + + protected function update_fb_product_set( $wc_category, $fb_product_set_id ) { + $fb_product_set_data = $this->build_fb_product_set_data( $wc_category ); + + try { + facebook_for_woocommerce()->get_api()->update_product_set_item( $fb_product_set_id, $fb_product_set_data ); + } catch ( \Exception $e ) { + $message = sprintf( 'There was an error trying to update product set: %s', $e->getMessage() ); + facebook_for_woocommerce()->log( $message ); + } + } + + protected function delete_fb_product_set( $fb_product_set_id ) { + try { + $allow_live_deletion = true; + facebook_for_woocommerce()->get_api()->delete_product_set_item( $fb_product_set_id, $allow_live_deletion ); + } catch ( \Exception $e ) { + $message = sprintf( 'There was an error trying to delete product set in a catalog: %s', $e->getMessage() ); + facebook_for_woocommerce()->log( $message ); + } + } + + private function sync_all_wc_product_categories() { + $wc_product_categories = get_terms( + array( + 'taxonomy' => self::WC_PRODUCT_CATEGORY_TAXONOMY, + 'hide_empty' => false, + 'orderby' => 'ID', + 'order' => 'ASC', + ) + ); + + foreach ( $wc_product_categories as $wc_category ) { + try { + $fb_product_set_id = $this->get_fb_product_set_id( $wc_category ); + if ( ! empty( $fb_product_set_id ) ) { + $this->update_fb_product_set( $wc_category, $fb_product_set_id ); + } else { + $this->create_fb_product_set( $wc_category ); + } + } catch ( \Exception $exception ) { + $this->log_exception( $exception ); + } + } + } +} diff --git a/includes/RolloutSwitches.php b/includes/RolloutSwitches.php index 1c925c269..dfb77f411 100644 --- a/includes/RolloutSwitches.php +++ b/includes/RolloutSwitches.php @@ -25,10 +25,12 @@ class RolloutSwitches { public const SWITCH_ROLLOUT_FEATURES = 'rollout_enabled'; public const WHATSAPP_UTILITY_MESSAGING = 'whatsapp_utility_messages_enabled'; + public const SWITCH_PRODUCT_SETS_SYNC_ENABLED = 'product_sets_sync_enabled'; private const ACTIVE_SWITCHES = array( self::SWITCH_ROLLOUT_FEATURES, self::WHATSAPP_UTILITY_MESSAGING, + self::SWITCH_PRODUCT_SETS_SYNC_ENABLED, ); /** * Stores the rollout switches and their enabled/disabled states. diff --git a/tests/Unit/ApiTest.php b/tests/Unit/ApiTest.php index a84703fd9..024fa016f 100644 --- a/tests/Unit/ApiTest.php +++ b/tests/Unit/ApiTest.php @@ -1000,4 +1000,31 @@ public function test_update_commerce_integration_request() { $this->assertTrue( $response->success ); } + + /** + * Tests read product set request to Facebook. + * + * @return void + * @throws ApiException In case of network request error. + */ + public function test_read_product_set_item() { + $product_catalog_id = '726635365295186'; + $retailer_id = '29'; + + $response = function( $result, $parsed_args, $url ) use ( $product_catalog_id, $retailer_id ) { + $this->assertEquals( 'GET', $parsed_args['method'] ); + $this->assertEquals( "{$this->endpoint}{$this->version}/{$product_catalog_id}/product_sets?retailer_id={$retailer_id}", $url ); + return [ + 'body' => '{"id":"325235346346546"}', + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + ]; + }; + $this->add_filter_with_safe_teardown( 'pre_http_request', $response, 10, 3 ); + + $response = $this->api->read_product_set_item( $product_catalog_id, $retailer_id ); + $this->assertFalse( $response->has_api_error() ); + } } diff --git a/tests/Unit/ProductSets/ProductSetSyncTest.php b/tests/Unit/ProductSets/ProductSetSyncTest.php new file mode 100644 index 000000000..36fc30564 --- /dev/null +++ b/tests/Unit/ProductSets/ProductSetSyncTest.php @@ -0,0 +1,202 @@ +createWPCategory(); + + $product_set_sync = $this->getMockBuilder( ProductSetSyncTestable::class ) + ->setMethods(['is_sync_enabled', 'get_fb_product_set_id','create_fb_product_set']) + ->getMock(); + + $product_set_sync->expects( $this->once() ) + ->method( 'is_sync_enabled' ) + ->willReturn(true); + $product_set_sync->expects( $this->once() ) + ->method( 'get_fb_product_set_id' ) + ->with($wc_category) + ->willReturn(null); + $product_set_sync->expects( $this->once() ) + ->method( 'create_fb_product_set' ); + + $product_set_sync->on_create_or_update_product_wc_category_callback( + $wc_category->term_id, + $wc_category->term_taxonomy_id, + array() + ); + } + + public function testUpdate() { + $wc_category = $this->createWPCategory(); + + $product_set_sync = $this->getMockBuilder( ProductSetSyncTestable::class ) + ->setMethods(['is_sync_enabled', 'get_fb_product_set_id','update_fb_product_set']) + ->getMock(); + + $product_set_sync->expects( $this->once() ) + ->method( 'is_sync_enabled' ) + ->willReturn(true); + $product_set_sync->expects( $this->once() ) + ->method( 'get_fb_product_set_id' ) + ->with($wc_category) + ->willReturn(self::FB_PRODUCT_SET_ID); + $product_set_sync->expects( $this->once() ) + ->method( 'update_fb_product_set' ) + ->with($wc_category, self::FB_PRODUCT_SET_ID); + + $product_set_sync->on_create_or_update_product_wc_category_callback( + $wc_category->term_id, + $wc_category->term_taxonomy_id, + array() + ); + } + + public function testDelete() { + $wc_category = $this->createWPCategory(); + + $product_set_sync = $this->getMockBuilder( ProductSetSyncTestable::class ) + ->setMethods(['is_sync_enabled', 'get_fb_product_set_id','delete_fb_product_set']) + ->getMock(); + + $product_set_sync->expects( $this->once() ) + ->method( 'is_sync_enabled' ) + ->willReturn(true); + $product_set_sync->expects( $this->once() ) + ->method( 'get_fb_product_set_id' ) + ->with($wc_category) + ->willReturn(self::FB_PRODUCT_SET_ID); + $product_set_sync->expects( $this->once() ) + ->method( 'delete_fb_product_set' ) + ->with(self::FB_PRODUCT_SET_ID); + + $product_set_sync->on_delete_wc_product_category_callback( + $wc_category->term_id, + $wc_category->term_taxonomy_id, + $wc_category, + array() + ); + } + + public function testSyncDisabled() { + $wc_category = $this->createWPCategory(); + + $product_set_sync = $this->getMockBuilder( ProductSetSyncTestable::class ) + ->setMethods(['is_sync_enabled', 'get_fb_product_set_id','create_fb_product_set']) + ->getMock(); + + $product_set_sync->expects( $this->once() ) + ->method( 'is_sync_enabled' ) + ->willReturn(false); + $product_set_sync->expects( $this->never() ) + ->method( 'get_fb_product_set_id' ); + $product_set_sync->expects( $this->never() ) + ->method( 'create_fb_product_set' ); + + $product_set_sync->on_create_or_update_product_wc_category_callback( + $wc_category->term_id, + $wc_category->term_taxonomy_id, + array() + ); + } + + public function testSyncAllProductSets() { + $this->createWPCategory( self::WC_CATEGORY_NAME_1 ); + $this->createWPCategory( self::WC_CATEGORY_NAME_2 ); + + $product_set_sync = $this->getMockBuilder( ProductSetSyncTestable::class ) + ->setMethods(['is_sync_enabled', 'get_fb_product_set_id','create_fb_product_set']) + ->getMock(); + + $product_set_sync->expects( $this->exactly(1) ) + ->method( 'is_sync_enabled' ) + ->willReturn(true); + $product_set_sync->expects( $this->atLeast(2) ) + ->method( 'get_fb_product_set_id' ) + ->willReturn(null); + $product_set_sync->expects( $this->atLeast(2) ) + ->method( 'create_fb_product_set' ); + + $product_set_sync->sync_all_product_sets(); + } + + public function testProductSetData() { + $wc_category = $this->createWPCategory(); + + $product_set_sync = $this->getMockBuilder( ProductSetSyncTestable::class ) + ->setMethods(['is_sync_enabled', 'get_fb_product_set_id','create_fb_product_set']) + ->getMock(); + + $data = $product_set_sync->build_fb_product_set_data( $wc_category ); + $this->assertEquals( self::WC_CATEGORY_NAME_1, $data['name'] ); + $this->assertEquals( $wc_category->term_taxonomy_id, $data['retailer_id'] ); + $this->assertEquals('{"and":[{"product_type":{"i_contains":"Test Category 1"}}]}', $data['filter'] ); + $this->assertEquals( '{"description":"
This is a test category<\/p>\n","external_url":"http:\/\/example.org\/?product_cat=test-category"}', $data['metadata'] ); + } + + /* ------------------ Utils Methods ------------------ */ + + private function createWPCategory( $name = self::WC_CATEGORY_NAME_1 ) { + $wc_category = wp_insert_term( + $name, + 'product_cat', // taxonomy + array( + 'description' => 'This is a test category', + 'slug' => 'test-category', + ) + ); + + return get_term( $wc_category['term_id'], ProductSetSync::WC_PRODUCT_CATEGORY_TAXONOMY ); + } +} + +/** + * A test-specific subclass of ProductSetSync to expose private methods for mocking. + */ +class ProductSetSyncTestable extends ProductSetSync { + + public function is_sync_enabled() { + return parent::is_sync_enabled(); + } + + public function get_fb_product_set_id( $wc_category ) { + return parent::get_fb_product_set_id( $wc_category ); + } + + public function create_fb_product_set( $wc_category ) { + return parent::create_fb_product_set( $wc_category ); + } + + public function update_fb_product_set( $wc_category, $fb_product_set_id ) { + return parent::update_fb_product_set( $wc_category, $fb_product_set_id ); + } + + public function delete_fb_product_set( $fb_product_set_id ) { + return parent::delete_fb_product_set( $fb_product_set_id ); + } + + public function build_fb_product_set_data( $wc_category ) { + return parent::build_fb_product_set_data( $wc_category ); + } +}