diff --git a/includes/API.php b/includes/API.php index 5debedc40..29160aa85 100644 --- a/includes/API.php +++ b/includes/API.php @@ -515,6 +515,18 @@ public function read_feed( string $product_feed_id ) { return $this->perform_request( $request ); } + /** + * @param string $product_catalog_id Facebook Product Catalog ID. + * @return Response + * @throws ApiException + * @throws API\Exceptions\Request_Limit_Reached + */ + public function create_feed( string $product_catalog_id, array $data ) { + $request = new API\ProductCatalog\ProductFeeds\Create\Request( $product_catalog_id, $data ); + $this->set_response_handler( API\ProductCatalog\ProductFeeds\Create\Response::class ); + return $this->perform_request( $request ); + } + /** * @param string $product_feed_upload_id @@ -528,6 +540,18 @@ public function read_upload( string $product_feed_upload_id ) { return $this->perform_request( $request ); } + /** + * @param string $product_feed_id Facebook Product Feed ID. + * @return Response + * @throws ApiException + * @throws API\Exceptions\Request_Limit_Reached + */ + public function create_upload( string $product_feed_id, array $data ) { + $request = new API\ProductCatalog\ProductFeedUploads\Create\Request( $product_feed_id, $data ); + $this->set_response_handler( API\ProductCatalog\ProductFeedUploads\Create\Response::class ); + return $this->perform_request( $request ); + } + /** * @param string $external_merchant_settings_id diff --git a/includes/API/ProductCatalog/ProductFeedUploads/Create/Request.php b/includes/API/ProductCatalog/ProductFeedUploads/Create/Request.php new file mode 100644 index 000000000..207c3aa2b --- /dev/null +++ b/includes/API/ProductCatalog/ProductFeedUploads/Create/Request.php @@ -0,0 +1,25 @@ + Product Feed Upload > Create Graph Api. + * + * @link https://developers.facebook.com/docs/marketing-api/reference/product-feed/uploads/#Creating + */ +class Request extends ApiRequest { + + /** + * @param string $product_feed_id Facebook Product Feed ID. + * @param array $data Facebook Product Feed Data. + */ + public function __construct( string $product_feed_id, array $data ) { + parent::__construct( "/{$product_feed_id}/uploads", 'POST' ); + parent::set_data( $data ); + } +} diff --git a/includes/API/ProductCatalog/ProductFeedUploads/Create/Response.php b/includes/API/ProductCatalog/ProductFeedUploads/Create/Response.php new file mode 100644 index 000000000..a576b2997 --- /dev/null +++ b/includes/API/ProductCatalog/ProductFeedUploads/Create/Response.php @@ -0,0 +1,16 @@ + Product Feed Upload > Create Graph Api. + * + * @link https://developers.facebook.com/docs/marketing-api/reference/product-feed/uploads/#Creating + * @property-read array $data Facebook Product Feeds Upload. + */ +class Response extends ApiResponse {} diff --git a/includes/API/ProductCatalog/ProductFeeds/Read/Request.php b/includes/API/ProductCatalog/ProductFeeds/Read/Request.php index 5066eec97..84a33f1b0 100644 --- a/includes/API/ProductCatalog/ProductFeeds/Read/Request.php +++ b/includes/API/ProductCatalog/ProductFeeds/Read/Request.php @@ -18,6 +18,6 @@ class Request extends ApiRequest { * @param string $product_feed_id Facebook Product Feed ID. */ public function __construct( string $product_feed_id ) { - parent::__construct( "/{$product_feed_id}/?fields=created_time,latest_upload,product_count,schedule,update_schedule", 'GET' ); + parent::__construct( "/{$product_feed_id}/?fields=created_time,latest_upload,product_count,schedule,update_schedule,name", 'GET' ); } } diff --git a/includes/Jobs/GenerateProductFeed.php b/includes/Jobs/GenerateProductFeed.php index 76d489a64..28c4e2bc7 100644 --- a/includes/Jobs/GenerateProductFeed.php +++ b/includes/Jobs/GenerateProductFeed.php @@ -35,6 +35,8 @@ protected function handle_end() { $feed_handler = new \WC_Facebook_Product_Feed(); $feed_handler->rename_temporary_feed_file_to_final_feed_file(); facebook_for_woocommerce()->get_tracker()->save_batch_generation_time(); + + do_action('wc_facebook_feed_generation_completed'); } /** diff --git a/includes/Products/Feed.php b/includes/Products/Feed.php index 318c0a0f3..ccf3fb830 100644 --- a/includes/Products/Feed.php +++ b/includes/Products/Feed.php @@ -13,6 +13,9 @@ defined( 'ABSPATH' ) || exit; +use Error; +use Exception; +use WC_Facebookcommerce_Utils; use WooCommerce\Facebook\Framework\Helper; use WooCommerce\Facebook\Utilities\Heartbeat; use WooCommerce\Facebook\Framework\Plugin\Exception as PluginException; @@ -36,6 +39,8 @@ class Feed { /** @var string the WordPress option name where the secret included in the feed URL is stored */ const OPTION_FEED_URL_SECRET = 'wc_facebook_feed_url_secret'; + /** @var string the feed name for creating a new feed by this plugin */ + const FEED_NAME = 'Product Feed by Facebook for WooCommerce plugin. DO NOT DELETE.'; /** * Feed constructor. @@ -62,6 +67,9 @@ private function add_hooks() { // handle the feed data request add_action( 'woocommerce_api_' . self::REQUEST_FEED_ACTION, array( $this, 'handle_feed_data_request' ) ); + + // Send request for feed one time upload after feed file generated + add_action( 'wc_facebook_feed_generation_completed', array( $this, 'send_request_to_upload_feed' ) ); } @@ -170,7 +178,7 @@ public function schedule_feed_generation() { * @since 1.11.0 * @since 2.5.0 Feed generation interval increased to 24h. * - * @param int $interval the frequency with which the product feed data is generated, in seconds. Defaults to every 15 minutes. + * @param int $interval the frequency with which the product feed data is generated, in seconds. */ $interval = apply_filters( 'wc_facebook_feed_generation_interval', DAY_IN_SECONDS ); if ( ! as_next_scheduled_action( self::GENERATE_FEED_ACTION ) ) { @@ -179,6 +187,191 @@ public function schedule_feed_generation() { } + /** + * Sends request to Meta to start a one-time feed file upload session. + * + * @internal + */ + public function send_request_to_upload_feed() { + $feed_id = self::retrieve_or_create_integration_feed_id(); + if ( empty( $feed_id ) ) { + WC_Facebookcommerce_Utils::log( 'Feed: integration feed ID is null or empty, feed will not be uploaded.' ); + return; + } + + $data = [ + 'url' => Feed::get_feed_data_url(), + ]; + + try { + facebook_for_woocommerce()->get_api()->create_upload( $feed_id, $data ); + } catch ( Exception $exception ) { + facebook_for_woocommerce()->log( 'Failed to create feed upload request: ' . $exception->getMessage() ); + } + } + + /** + * Retrieves or creates an integration feed ID + * + * @return string the integration feed ID + * + * @internal + */ + public function retrieve_or_create_integration_feed_id() { + // Step 1 - Get feed ID if it is already available in local cache + $feed_id = facebook_for_woocommerce()->get_integration()->get_feed_id(); + if ( $feed_id ) { + if ( self::validate_feed_exists($feed_id) ) { + WC_Facebookcommerce_Utils::log( 'Feed: feed_id = '.$feed_id.', from local cache was validated.'); + return $feed_id; + } else { + WC_Facebookcommerce_Utils::log( 'Feed: feed_id = '.$feed_id.', from local cache was invalidated.'); + } + } + + // Step 2 - Query feeds data from Meta and filter the right one + $feed_id = self::query_and_filter_integration_feed_id(); + if ( $feed_id ) { + facebook_for_woocommerce()->get_integration()->update_feed_id($feed_id); + WC_Facebookcommerce_Utils::log( 'Feed: feed_id = '.$feed_id.', queried and filtered from Meta API.'); + return $feed_id; + } + + // Step 3 - Create a new feed + $feed_id = self::create_feed_id(); + if ( $feed_id ) { + facebook_for_woocommerce()->get_integration()->update_feed_id($feed_id); + WC_Facebookcommerce_Utils::log( 'Feed: feed_id = '.$feed_id.', created a new feed via Meta API.'); + return $feed_id; + } + + return ''; + } + + /** + * Validates that provided feed ID still exists on the Meta side + * + * @param string $feed_id the feed ID + * + * @return bool true if the feed ID is valid + * + * @internal + */ + private function validate_feed_exists($feed_id) { + try { + $catalog_id = facebook_for_woocommerce()->get_integration()->get_product_catalog_id(); + if ( '' === $catalog_id ) { + throw new Error( 'No catalog ID' ); + } + $feed_nodes = facebook_for_woocommerce()->get_api()->read_feeds( $catalog_id )->data; + } catch ( Exception $e ) { + $message = sprintf( 'There was an error trying to get feed nodes for catalog: %s', $e->getMessage() ); + WC_Facebookcommerce_Utils::log( $message ); + return ''; + } + + foreach ( $feed_nodes as $feed ) { + if ($feed['id'] == $feed_id) { + return true; + } + } + + return false; + } + + /** + * Queries existing feeds for the integration catalog and filters + * the plugin integration feed ID + * + * @return string the integration feed ID + * + * @internal + */ + private function query_and_filter_integration_feed_id() { + try { + $catalog_id = facebook_for_woocommerce()->get_integration()->get_product_catalog_id(); + if ( '' === $catalog_id ) { + throw new Error( 'No catalog ID' ); + } + $feed_nodes = facebook_for_woocommerce()->get_api()->read_feeds( $catalog_id )->data; + } catch ( Exception $e ) { + $message = sprintf( 'There was an error trying to get feed nodes for catalog: %s', $e->getMessage() ); + WC_Facebookcommerce_Utils::log( $message ); + return ''; + } + + if ( empty( $feed_nodes ) ) { + return ''; + } + + try { + $catalog = facebook_for_woocommerce()->get_api()->get_catalog( $catalog_id ); + } catch ( Exception $e ) { + $message = sprintf( 'There was an error trying to get a catalog: %s', $e->getMessage() ); + WC_Facebookcommerce_Utils::log( $message ); + } + + /* + We need to detect which feed is the one that was created for Facebook for WooCommerce plugin usage. + + We are detecting based on the name. + - Option 1. Plugin can create this feed name currently. + - Option 2 and 3. FBE creates a catalog with feed name '{catalog name} - Feed' or '{catalog name} – Feed' (short vs long dash) + - Option 4. Plugin used to create a feed name 'Initial product sync from WooCommerce. DO NOT DELETE.' + */ + foreach ( $feed_nodes as $feed ) { + try { + $feed_metadata = facebook_for_woocommerce()->get_api()->read_feed( $feed['id'] ); + } catch ( Exception $e ) { + $message = sprintf( 'There was an error trying to get feed metadata: %s', $e->getMessage() ); + WC_Facebookcommerce_Utils::log( $message ); + continue; + } + + $woo_feed_name_option_1 = self::FEED_NAME; + $woo_feed_name_option_2 = sprintf( '%s - Feed', $catalog['name'] ); + $woo_feed_name_option_3 = sprintf( '%s – Feed', $catalog['name'] ); + $woo_feed_name_option_4 = 'Initial product sync from WooCommerce. DO NOT DELETE.'; + + if ( $feed_metadata['name'] === $woo_feed_name_option_1 || + $feed_metadata['name'] === $woo_feed_name_option_2 || + $feed_metadata['name'] === $woo_feed_name_option_3 || + $feed_metadata['name'] === $woo_feed_name_option_4 ) { + return $feed['id']; + } + } + + return ''; + } + + /** + * Makes a request to Meta to create a new feed + * + * @return string the integration feed ID + * + * @internal + */ + private function create_feed_id() { + try { + $catalog_id = facebook_for_woocommerce()->get_integration()->get_product_catalog_id(); + if ( '' === $catalog_id ) { + throw new Error( 'No catalog ID' ); + } + + $data = [ + 'name' => self::FEED_NAME, + ]; + + $feed = facebook_for_woocommerce()->get_api()->create_feed( $catalog_id, $data ); + return $feed['id']; + } catch ( Exception $exception ) { + facebook_for_woocommerce()->log( 'Could not create a feed: ' . $exception->getMessage() ); + } + + return ''; + } + + /** * Checks whether fpassthru has been disabled in PHP. * diff --git a/includes/fbproduct.php b/includes/fbproduct.php index 7f034d492..bf24fdefe 100644 --- a/includes/fbproduct.php +++ b/includes/fbproduct.php @@ -845,7 +845,6 @@ public function prepare_product( $retailer_id = null, $type_to_prepare_for = sel 'custom_fields' => $custom_fields, ); $product_data = $this->add_sale_price( $product_data, true ); - $gpc_field_name = 'google_product_category'; if ( ! empty( $video_urls ) ) { $product_data['video'] = $video_urls; } @@ -883,12 +882,11 @@ public function prepare_product( $retailer_id = null, $type_to_prepare_for = sel $product_data['video'] = $video_urls; } $product_data = $this->add_sale_price( $product_data ); - $gpc_field_name = 'category'; }//end if $google_product_category = Products::get_google_product_category_id( $this->woo_product ); if ( $google_product_category ) { - $product_data[ $gpc_field_name ] = $google_product_category; + $product_data[ 'google_product_category' ] = $google_product_category; } // Currently only items batch and feed support enhanced catalog fields @@ -937,7 +935,7 @@ public function prepare_product( $retailer_id = null, $type_to_prepare_for = sel if ( self::PRODUCT_PREP_TYPE_FEED !== $type_to_prepare_for ) { $this->prepare_variants_for_item( $product_data ); } elseif ( - WC_Facebookcommerce_Utils::is_all_caps( $product_data['description'] ) + WC_Facebookcommerce_Utils::is_all_caps( $product_data['description'] ) ) { $product_data['description'] = mb_strtolower( $product_data['description'] ); diff --git a/includes/fbproductfeed.php b/includes/fbproductfeed.php index 2d4b71352..4f14f625c 100644 --- a/includes/fbproductfeed.php +++ b/includes/fbproductfeed.php @@ -28,7 +28,6 @@ class WC_Facebook_Product_Feed { const FILE_NAME = 'product_catalog_%s.csv'; const FACEBOOK_CATALOG_FEED_FILENAME = 'fae_product_catalog.csv'; const FB_ADDITIONAL_IMAGES_FOR_FEED = 5; - const FEED_NAME = 'Initial product sync from WooCommerce. DO NOT DELETE.'; const FB_PRODUCT_GROUP_ID = 'fb_product_group_id'; const FB_VISIBILITY = 'fb_visibility'; @@ -59,6 +58,8 @@ public function generate_feed() { \WC_Facebookcommerce_Utils::log( 'Product feed file generated' ); + do_action('wc_facebook_feed_generation_completed'); + } catch ( \Exception $exception ) { \WC_Facebookcommerce_Utils::log( $exception->getMessage() ); @@ -320,7 +321,15 @@ public function write_products_feed_to_temp_file( $wp_ids, $temp_feed_file ) { foreach ( $wp_ids as $wp_id ) { - $woo_product = new WC_Facebook_Product( $wp_id ); + $product = wc_get_product( $wp_id ); + if ( $product instanceof WC_Product && $product->get_parent_id() ) { + $parent_product = wc_get_product( $product->get_parent_id() ); + if ( $parent_product instanceof WC_Product ) { + $fb_product_parent = new WC_Facebook_Product( $parent_product ); + } + } + + $woo_product = new WC_Facebook_Product( $wp_id, $fb_product_parent ); // Skip if we don't have a valid product object. if ( ! $woo_product->woo_product instanceof \WC_Product ) { @@ -374,7 +383,7 @@ public function get_product_feed_header_row() { return 'id,title,description,image_link,link,product_type,' . 'brand,price,availability,item_group_id,checkout_url,' . 'additional_image_link,sale_price_effective_date,sale_price,condition,' . - 'visibility,gender,color,size,pattern,google_product_category,default_product,variant' . PHP_EOL; + 'visibility,gender,color,size,pattern,google_product_category,default_product,variant,gtin,quantity_to_sell_on_facebook,rich_text_description' . PHP_EOL; } @@ -500,7 +509,7 @@ private function prepare_product_for_feed( $woo_product, &$attribute_variants ) static::format_string_for_feed( static::get_value_from_product_data( $product_data, 'description' ) ) . ',' . static::get_value_from_product_data( $product_data, 'image_url' ) . ',' . static::get_value_from_product_data( $product_data, 'url' ) . ',' . - static::format_string_for_feed( static::get_value_from_product_data( $product_data, 'category' ) ) . ',' . + static::format_string_for_feed( static::get_value_from_product_data( $product_data, 'product_type' ) ) . ',' . static::format_string_for_feed( static::get_value_from_product_data( $product_data, 'brand' ) ) . ',' . static::format_price_for_feed( static::get_value_from_product_data( $product_data, 'price', 0 ), @@ -520,7 +529,10 @@ private function prepare_product_for_feed( $woo_product, &$attribute_variants ) static::get_value_from_product_data( $product_data, 'pattern' ) . ',' . static::get_value_from_product_data( $product_data, 'google_product_category' ) . ',' . static::get_value_from_product_data( $product_data, 'default_product' ) . ',' . - static::get_value_from_product_data( $product_data, 'variant' ) . PHP_EOL; + static::get_value_from_product_data( $product_data, 'variant' ) . ',' . + static::get_value_from_product_data( $product_data, 'gtin' ) . ',' . + static::get_value_from_product_data( $product_data, 'quantity_to_sell_on_facebook' ) . ',' . + static::format_string_for_feed( static::get_value_from_product_data( $product_data, 'rich_text_description' ) ) . PHP_EOL; } private static function format_additional_image_url( $product_image_urls ) { diff --git a/includes/fbutils.php b/includes/fbutils.php index 9a97b6921..acd89d366 100644 --- a/includes/fbutils.php +++ b/includes/fbutils.php @@ -229,8 +229,7 @@ function( $item ) { ) ); $content_category_slice = array_slice( $content_category, -1 ); - $categories = - empty( $content_category ) ? '""' : implode( ', ', $content_category ); + $categories = empty( $content_category ) ? '""' : implode( ', ', $content_category ); return array( 'name' => array_pop( $content_category_slice ), 'categories' => $categories, diff --git a/tests/Unit/ApiTest.php b/tests/Unit/ApiTest.php index 49677ebcb..e19f6a91c 100644 --- a/tests/Unit/ApiTest.php +++ b/tests/Unit/ApiTest.php @@ -715,4 +715,64 @@ public function test_read_feeds_creates_read_feeds_request() { $response->data ); } + + /** + * Tests create feed request to Facebook. + * + * @return void + * @throws ApiException In case of network request error. + */ + public function test_create_feed_request() { + $facebook_product_catalog_id = '726635365295186'; + + $data = [ + 'name' => "Test feed name.", + ]; + + $response = function( $result, $parsed_args, $url ) use ( $facebook_product_catalog_id ) { + $this->assertEquals( 'POST', $parsed_args['method'] ); + $this->assertEquals( "{$this->endpoint}{$this->version}/{$facebook_product_catalog_id}/product_feeds", $url ); + return [ + 'body' => '{"id":"1068839467367301"}', + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + ]; + }; + add_filter( 'pre_http_request', $response, 10, 3 ); + + $response_feed = $this->api->create_feed( $facebook_product_catalog_id, $data ); + + $this->assertEquals( '1068839467367301', $response_feed['id'] ); + } + + /** + * Tests create feed upload request to Facebook. + * + * @return void + * @throws ApiException In case of network request error. + */ + public function test_create_upload_request() { + $product_feed_id = '1068839467367301'; + + $data = [ + 'url' => 'http://example.com/?wc-api=wc_facebook_get_feed_data&secret=c4b8c3c46145aac6519e3f8a28bc86f2', + ]; + + $response = function( $result, $parsed_args, $url ) use ( $product_feed_id ) { + $this->assertEquals( 'POST', $parsed_args['method'] ); + $this->assertEquals( "{$this->endpoint}{$this->version}/{$product_feed_id}/uploads", $url ); + return [ + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + ]; + }; + add_filter( 'pre_http_request', $response, 10, 3 ); + + $response = $this->api->create_upload( $product_feed_id, $data ); + $this->assertFalse( $response->has_api_error() ); + } } diff --git a/tests/Unit/fbproductTest.php b/tests/Unit/fbproductTest.php index 156814afb..6e4c0c5eb 100644 --- a/tests/Unit/fbproductTest.php +++ b/tests/Unit/fbproductTest.php @@ -522,7 +522,7 @@ public function test_enhanced_catalog_fields_from_attributes( $facebook_product->get_id(), \WC_Facebook_Product::PRODUCT_PREP_TYPE_FEED ); - $this->assertEquals($product_data['category'], 173); + $this->assertEquals($product_data['google_product_category'], $category_id); foreach ($expected_attributes as $key => $value) { $this->assertEquals($product_data[$key], $value); }