Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix Product Syncing via Feeds #2841

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
24 changes: 24 additions & 0 deletions includes/API.php
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,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
Expand All @@ -529,6 +541,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
Expand Down
25 changes: 25 additions & 0 deletions includes/API/ProductCatalog/ProductFeedUploads/Create/Request.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php
declare( strict_types=1 );

namespace WooCommerce\Facebook\API\ProductCatalog\ProductFeedUploads\Create;

use WooCommerce\Facebook\API\Request as ApiRequest;

defined( 'ABSPATH' ) || exit;

/**
* Request object for Product Catalog > 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 );
}
}
16 changes: 16 additions & 0 deletions includes/API/ProductCatalog/ProductFeedUploads/Create/Response.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php
declare( strict_types=1 );

namespace WooCommerce\Facebook\API\ProductCatalog\ProductFeedUploads\Create;

use WooCommerce\Facebook\API\Response as ApiResponse;

defined( 'ABSPATH' ) || exit;

/**
* Response object for Product Catalog > 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 {}
2 changes: 1 addition & 1 deletion includes/API/ProductCatalog/ProductFeeds/Read/Request.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' );
}
}
2 changes: 2 additions & 0 deletions includes/Jobs/GenerateProductFeed.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}

/**
Expand Down
194 changes: 194 additions & 0 deletions includes/Products/Feed.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand All @@ -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 fir feed one time upload after feed file generated
mshymon marked this conversation as resolved.
Show resolved Hide resolved
add_action( 'wc_facebook_feed_generation_completed', array( $this, 'send_request_to_upload_feed' ) );
}


Expand Down Expand Up @@ -179,6 +187,192 @@ public function schedule_feed_generation() {
}


/**
mshymon marked this conversation as resolved.
Show resolved Hide resolved
* 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();
mshymon marked this conversation as resolved.
Show resolved Hide resolved
if ($feed_id === null || $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( 'Could not send create feed upload via request: ' . $exception->getMessage() );
mshymon marked this conversation as resolved.
Show resolved Hide resolved
mshymon marked this conversation as resolved.
Show resolved Hide resolved
}
}

/**
* 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 !== null && $feed_id !== '') {
if ( self::validate_feed_exists($feed_id) ) {
WC_Facebookcommerce_Utils::log( 'Feed: retrieve_integration_feed_id(): feed_id = '.$feed_id.', from local cache.');
mshymon marked this conversation as resolved.
Show resolved Hide resolved
return $feed_id;
} else {
WC_Facebookcommerce_Utils::log( 'Feed: retrieve_integration_feed_id(): 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();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could throw an error that is currently not being caught

if ($feed_id !== null && $feed_id !== '') {
mshymon marked this conversation as resolved.
Show resolved Hide resolved
facebook_for_woocommerce()->get_integration()->update_feed_id($feed_id);
WC_Facebookcommerce_Utils::log( 'Feed: retrieve_integration_feed_id(): 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 !== null && $feed_id !== '') {
facebook_for_woocommerce()->get_integration()->update_feed_id($feed_id);
WC_Facebookcommerce_Utils::log( 'Feed: retrieve_integration_feed_id(): feed_id = '.$feed_id.', created a new feed via Meta API.');
return $feed_id;
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we can throw an error or log here. Something like 'couldn't create feed'

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) {
$catalog_id = facebook_for_woocommerce()->get_integration()->get_product_catalog_id();
if ( '' === $catalog_id ) {
throw new Error( 'No catalog ID' );
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are the errors stored in the logs or was it a conscious decision to not add any logs here

}

try {
$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() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method seems like a lot of logic
Can we have it broken out a bit

$catalog_id = facebook_for_woocommerce()->get_integration()->get_product_catalog_id();
if ( '' === $catalog_id ) {
throw new Error( 'No catalog ID' );
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not being caught in retrieve_or_create_integrationFeed_id()

}

try {
$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 ) ) {
mshymon marked this conversation as resolved.
Show resolved Hide resolved

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;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggest factor out to private function named something like private function pattern_match_feed_names()

$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.';
mshymon marked this conversation as resolved.
Show resolved Hide resolved

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.
*
Expand Down
6 changes: 2 additions & 4 deletions includes/fbproduct.php
Original file line number Diff line number Diff line change
Expand Up @@ -677,7 +677,6 @@ public function prepare_product( $retailer_id = null, $type_to_prepare_for = sel
'visibility' => Products::is_product_visible( $this->woo_product ) ? \WC_Facebookcommerce_Integration::FB_SHOP_PRODUCT_VISIBLE : \WC_Facebookcommerce_Integration::FB_SHOP_PRODUCT_HIDDEN,
);
$product_data = $this->add_sale_price( $product_data, true );
$gpc_field_name = 'google_product_category';
if ( ! empty( $video_urls ) ) {
$product_data['video'] = $video_urls;
}
Expand Down Expand Up @@ -712,12 +711,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
Expand Down Expand Up @@ -766,7 +764,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'] );
Expand Down
11 changes: 7 additions & 4 deletions includes/fbproductfeed.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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() );
Expand Down Expand Up @@ -374,7 +375,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' . PHP_EOL;
}


Expand Down Expand Up @@ -500,7 +501,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 ),
Expand All @@ -520,7 +521,9 @@ 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' ) . PHP_EOL;
}

private static function format_additional_image_url( $product_image_urls ) {
Expand Down
Loading
Loading