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

Scheduled notifications using meta handler #2202

Merged
merged 36 commits into from
Jan 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
ce8b86f
Scheduled batched notifications using meta handler
puntope Jan 18, 2024
d8cebdb
Tweak logic for schedule notifications
puntope Jan 23, 2024
832d0dc
Tests for Notification Service
puntope Jan 23, 2024
94c4fb6
Tests for ProductHelper
puntope Jan 24, 2024
0386c74
Update docs
puntope Jan 24, 2024
51b729f
Update tests
puntope Jan 24, 2024
619123f
Add tests for Notification
puntope Jan 24, 2024
d85d7ed
PHPCS
puntope Jan 24, 2024
a60ac81
Fix package path
puntope Jan 24, 2024
c47b7fa
Fix tests dependencies
puntope Jan 24, 2024
b716805
Fix tests dependencies
puntope Jan 24, 2024
d4c2d37
Remove trailing comma in constructor
puntope Jan 25, 2024
545cd22
Refactor route param name
puntope Jan 25, 2024
8ee54d1
Remove identation
puntope Jan 25, 2024
fd11304
Remove identation from functions
puntope Jan 25, 2024
3d9b4bc
Remove unused dependency
puntope Jan 25, 2024
ba7ed8f
Add params in function signature
puntope Jan 25, 2024
b3b0735
Avoid item id to be null
puntope Jan 25, 2024
8940067
Refactor dependencies
puntope Jan 25, 2024
a1eaad6
Refactor tests
puntope Jan 25, 2024
a1d5f22
PHPCS
puntope Jan 25, 2024
d3a641f
Change doc
puntope Jan 26, 2024
b9af9f1
Remove filter notification products
puntope Jan 26, 2024
82795a7
Remove unused dependency
puntope Jan 26, 2024
99f5b9e
Add useful hooks
puntope Jan 26, 2024
08efcce
Add missing return types
puntope Jan 29, 2024
d93c7b7
Remove extra space
puntope Jan 29, 2024
cbb00a2
Add missing docs
puntope Jan 29, 2024
677e4ec
Clarify docs
puntope Jan 29, 2024
6315209
Verify if product is synced in MC
puntope Jan 29, 2024
de3e24c
Update docs
puntope Jan 29, 2024
65dff81
Add Tests for ProductNotificationJob
puntope Jan 29, 2024
ac3f6cc
Remove PENDING_DELETE as a valid status for has_notified_cretion
puntope Jan 29, 2024
75e4c96
Remove PENDING_DELETE as a valid status for has_notified_cretion
puntope Jan 29, 2024
3056459
Remove unused dependency
puntope Jan 29, 2024
2bb00ca
Add return type for void
puntope Jan 29, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 48 additions & 18 deletions src/Google/NotificationsService.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,33 +13,35 @@
* Class NotificationsService
* This class implements a service to Notify a partner about Shop Data Updates
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Google\Notifications
* @since x.x.x
* @package Automattic\WooCommerce\GoogleListingsAndAds\Google
*/
class NotificationsService implements Service {

// List of Topics to be used.
public const TOPIC_PRODUCT_CREATED = 'product.created';
public const TOPIC_PRODUCT_DELETED = 'product.deleted';
public const TOPIC_PRODUCT_UPDATED = 'product.updated';
public const TOPIC_COUPON_CREATED = 'coupon.created';
public const TOPIC_COUPON_DELETED = 'coupon.deleted';
public const TOPIC_COUPON_UPDATED = 'coupon.updated';
public const TOPIC_PRODUCT_CREATED = 'product.create';
public const TOPIC_PRODUCT_DELETED = 'product.delete';
public const TOPIC_PRODUCT_UPDATED = 'product.update';
public const TOPIC_COUPON_CREATED = 'coupon.create';
public const TOPIC_COUPON_DELETED = 'coupon.delete';
public const TOPIC_COUPON_UPDATED = 'coupon.update';
public const TOPIC_SHIPPING_SAVED = 'action.woocommerce_after_shipping_zone_object_save';
public const TOPIC_SHIPPING_DELETED = 'action.woocommerce_delete_shipping_zone';

/**
* The route to send the notification
* The url to send the notification
*
* @var string $route
* @var string $notification_url
*/
private $route;
private $notification_url;


/**
* Class constructor
*/
public function __construct() {
$blog_id = Jetpack_Options::get_option( 'id' );
$this->route = "https://public-api.wordpress.com/wpcom/v2/sites/{$blog_id}/partners/google/notifications";
$blog_id = Jetpack_Options::get_option( 'id' );
$this->notification_url = "https://public-api.wordpress.com/wpcom/v2/sites/{$blog_id}/partners/google/notifications";
}

/**
Expand All @@ -50,7 +52,26 @@ public function __construct() {
* @param string $topic
* @return bool True is the notification is successful. False otherwise.
*/
public function notify( int $item_id, string $topic ) {
public function notify( int $item_id, string $topic ): bool {
/**
* Allow users to disable the notification request.
*
* @since x.x.x
*
* @param bool $value The current filter value. True by default.
* @param int $item_id The item_id for the notification.
* @param string $topic The topic for the notification.
*/
if ( ! apply_filters( 'woocommerce_gla_notify', true, $item_id, $topic ) ) {
return false;
}

do_action(
'woocommerce_gla_debug_message',
sprintf( 'Notification - Item ID: %d - Topic: %s', $item_id, $topic ),
__METHOD__
);

$remote_args = [
'method' => 'POST',
'timeout' => 30,
Expand All @@ -60,12 +81,12 @@ public function notify( int $item_id, string $topic ) {
'body' => [
'item_id' => $item_id,
],
'url' => $this->get_route(),
'url' => $this->get_notification_url(),
];

$response = $this->do_request( $remote_args );

if( is_wp_error( $response ) || wp_remote_retrieve_response_code( $response ) >= 400 ) {
if ( is_wp_error( $response ) || wp_remote_retrieve_response_code( $response ) >= 400 ) {
$error = is_wp_error( $response ) ? $response->get_error_message() : wp_remote_retrieve_body( $response );
$this->notification_error( $item_id, $topic, $error );
return false;
Expand Down Expand Up @@ -95,7 +116,7 @@ private function notification_error( int $item_id, string $topic, string $error
* @param array $args
* @return array|\WP_Error
*/
protected function do_request( $args ) {
protected function do_request( array $args ): \WP_Error|array {
return Client::remote_request( $args, wp_json_encode( $args['body'] ) );
}

Expand All @@ -104,7 +125,16 @@ protected function do_request( $args ) {
*
* @return string The route.
*/
public function get_route(): string {
return $this->route;
public function get_notification_url(): string {
return $this->notification_url;
}

/**
* If the Notifications are enabled
*
* @return bool
*/
public function is_enabled(): bool {
return apply_filters( 'woocommerce_gla_notifications_enabled', true );
}
}
8 changes: 8 additions & 0 deletions src/Internal/DependencyManagement/JobServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\JobInitializer;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\JobInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\JobRepository;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\Notifications\ProductNotificationJob;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\ProductSyncerJobInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\ProductSyncStats;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\ResubmitExpiringProducts;
Expand Down Expand Up @@ -106,6 +107,13 @@ public function register(): void {
$this->share_coupon_syncer_job( UpdateCoupon::class );
$this->share_coupon_syncer_job( DeleteCoupon::class );

// share product notifications job
$this->share_action_scheduler_job(
ProductNotificationJob::class,
NotificationsService::class,
ProductHelper::class
);

$this->share_with_tags(
JobRepository::class,
JobInterface::class
Expand Down
133 changes: 133 additions & 0 deletions src/Jobs/Notifications/ProductNotificationJob.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);

namespace Automattic\WooCommerce\GoogleListingsAndAds\Jobs\Notifications;

use Automattic\WooCommerce\GoogleListingsAndAds\ActionScheduler\ActionSchedulerInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\NotificationsService;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\AbstractActionSchedulerJob;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\ActionSchedulerJobMonitor;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\JobInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\NotificationStatus;


defined( 'ABSPATH' ) || exit;

/**
* Class ProductNotificationJob
* Class for all the Product Notifications Jobs
*
* @since x.x.x
* @package Automattic\WooCommerce\GoogleListingsAndAds\Jobs\Notifications
*/
class ProductNotificationJob extends AbstractActionSchedulerJob implements JobInterface {

/**
* @var NotificationsService $notifications_service
*/
protected $notifications_service;

/**
* @var ProductHelper $product_helper
*/
protected $product_helper;

/**
* Notifications Jobs constructor.
*
* @param ActionSchedulerInterface $action_scheduler
* @param ActionSchedulerJobMonitor $monitor
* @param NotificationsService $notifications_service
* @param ProductHelper $product_helper
*/
public function __construct(
ActionSchedulerInterface $action_scheduler,
ActionSchedulerJobMonitor $monitor,
NotificationsService $notifications_service,
ProductHelper $product_helper
) {
$this->notifications_service = $notifications_service;
$this->product_helper = $product_helper;
parent::__construct( $action_scheduler, $monitor );
}

/**
* Get the job name
*
* @return string
*/
public function get_name(): string {
return 'notifications/products';
}


/**
* Logic when processing the items
*
* @param array $args Arguments with the item id and the topic
*/
protected function process_items( array $args ): void {
if ( ! isset( $args[0] ) || ! isset( $args[1] ) ) {
return;
}

$item = $args[0];
$topic = $args[1];

if ( $this->notifications_service->notify( $item, $topic ) ) {
$this->set_status( $item, $this->get_after_notification_status( $topic ) );
}
}

/**
* Schedule the Product Notification Job
*
* @param array $args
*/
public function schedule( array $args = [] ): void {
/**
* Allow users to disable the notification job schedule.
*
* @since x.x.x
*
* @param bool $value The current filter value. By default, it is the result of `$this->can_schedule` function.
* @param array $args The arguments for the schedule function with the item id and the topic.
*/
$can_schedule = apply_filters( 'woocommerce_gla_product_notification_job_can_schedule', $this->can_schedule( [ $args ] ), $args );

if ( $can_schedule ) {
$this->action_scheduler->schedule_immediate(
$this->get_process_item_hook(),
[ $args ]
);
}
}

/**
* Set the notification status for a product.
*
* @param int $product_id
* @param string $status
*/
protected function set_status( int $product_id, string $status ): void {
$product = $this->product_helper->get_wc_product( $product_id );
$this->product_helper->set_notification_status( $product, $status );
}

/**
* Get the Notification Status after the notification happens
*
* @param string $topic
* @return string
*/
protected function get_after_notification_status( string $topic ): string {
if ( str_contains( $topic, '.create' ) ) {
return NotificationStatus::NOTIFICATION_CREATED;
} elseif ( str_contains( $topic, '.delete' ) ) {
return NotificationStatus::NOTIFICATION_DELETED;
} else {
return NotificationStatus::NOTIFICATION_UPDATED;
}
}
}
90 changes: 90 additions & 0 deletions src/Product/ProductHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\ChannelVisibility;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\NotificationStatus;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\SyncStatus;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\Product as GoogleProduct;
use WC_Product;
Expand Down Expand Up @@ -332,6 +333,95 @@ public function is_product_synced( WC_Product $product ): bool {
return ! empty( $synced_at ) && ! empty( $google_ids );
}

/**
* Indicates if a product is ready for sending Notifications.
* A product is ready to send notifications if DONT_SYNC_AND_SHOW is not enabled and the post status is publish.
*
* @param WC_Product $product
*
* @return bool
*/
public function is_ready_to_notify( WC_Product $product ): bool {
$is_ready = ChannelVisibility::DONT_SYNC_AND_SHOW !== $this->get_channel_visibility( $product ) &&
$product->get_status() === 'publish' &&
in_array( $product->get_type(), ProductSyncer::get_supported_product_types(), true );

/**
* Allow users to filter if a product is ready to notify.
*
* @since x.x.x
*
* @param bool $value The current filter value.
* @param WC_Product $product The product for the notification.
*/
return apply_filters( 'woocommerce_gla_is_ready_to_notify', $is_ready, $product );
Copy link
Contributor

Choose a reason for hiding this comment

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

I thought I could use this filter to stop notifications for a specific product, say 123, but instead of stopping them, it's triggering the product.delete notification for the product in the MC. Is this expected behavior?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I believe yes. If a product has notified creation and is not anymore ready for notify, it should be deleted.

public function should_trigger_delete_notification( WC_Product $product ): bool {
		return ! $this->is_ready_to_notify( $product ) && $this->has_notified_creation( $product );
	}

Copy link
Contributor

Choose a reason for hiding this comment

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

I was imagining a scenario where I have my product in the MC, and I want to keep it and manage it myself. So, I would use this filter to stop the notifications. WDYT?

}

/**
* Indicates if a product is ready for sending a create Notification.
* A product is ready to send create notifications if is ready to notify and has not sent create notification yet.
*
* @param WC_Product $product
*
* @return bool
*/
public function should_trigger_create_notification( WC_Product $product ): bool {
return $this->is_ready_to_notify( $product ) && ! $this->has_notified_creation( $product );
}

/**
* Indicates if a product is ready for sending an update Notification.
* A product is ready to send update notifications if is ready to notify and has sent create notification already.
*
* @param WC_Product $product
*
* @return bool
*/
public function should_trigger_update_notification( WC_Product $product ): bool {
return $this->is_ready_to_notify( $product ) && $this->has_notified_creation( $product );
}

/**
* Indicates if a product is ready for sending a delete Notification.
* A product is ready to send delete notifications if it is not ready to notify and has sent create notification already.
*
* @param WC_Product $product
*
* @return bool
*/
public function should_trigger_delete_notification( WC_Product $product ): bool {
return ! $this->is_ready_to_notify( $product ) && $this->has_notified_creation( $product );
}

/**
* Indicates if a product was already notified about its creation.
* Notice we consider synced products in MC as notified for creation.
*
* @param WC_Product $product
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we add a PHPDoc explaining this function?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Adjusted here cbb00a2

*
* @return bool
*/
public function has_notified_creation( WC_Product $product ): bool {
Copy link
Contributor

@jorgemd24 jorgemd24 Jan 25, 2024

Choose a reason for hiding this comment

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

I got a bit confused with all the statuses but what I understand is that these statuses indicate whether the product has been created/synced, helping us, for example, to avoid sending an update/delete topic if the product has not been notified yet.

Also, I believe we need to clarify that the creation/delete topics are related to the MC status. Sometimes, I found myself mixing the concepts of creation/delete with creating/deleting products in WC, whereas in the MC case, it could be just changing the channel visibility from "syncable" to "not syncable." or vice-versa.

Moreover, I'm trying to understand why we need to track this status. For instance:

  • If the product goes from not syncable to syncable, from my understanding, we could just send the create notification and rely on checking if there are no more jobs with the creation topic for the product id. I'd say the same for update and delete. However, it would be helpful to know the logic Google has in place. For example, if they receive an update notification for a product that is not created in the MC, will they create the product? Or will it be refused? If this logic is handled by Google, we could simplify the notification system significantly. Usually webhooks/notications are responsible for alerting about changes, and it's up to the consumer to handle the various scenarios.

  • Another scenario involves an existing product that is already synced with the MC; the created metadata does not exist because the product was already synced before we started using notifications. If the merchant updates the product, we will send the create notification, which is incorrect because the product was already created in the MC. A similar case would happen when removing a product.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I got a bit confused with all the statuses but what I understand is that these statuses indicate whether the product has been created/synced, helping us, for example, to avoid sending an update/delete topic if the product has not been notified yet.

Correct. For example. Imagine this scenario:

  • Creating a product with DONT SYNC AND SHOW won't trigger product.create notification.
  • Then the user updates the product and set SYNC AND SHOW. The notification should be product.create not product.update for doing this is necessary the metas and some helper functions.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Another scenario involves an existing product that is already synced with the MC; the created metadata does not exist because the product was already synced before we started using notifications. If the merchant updates the product, we will send the create notification, which is incorrect because the product was already created in the MC. A similar case would happen when removing a product

I assume we don't take in consideration the previous statuses of (PUSH based) MC for sending the Notifications

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I will move that question to the P2

Copy link
Contributor

@jorgemd24 jorgemd24 Jan 28, 2024

Choose a reason for hiding this comment

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

I assume we don't take in consideration the previous statuses of (PUSH based) MC for sending the Notifications

Imagine a merchant using the plugin and having products already synced in the MC before the notification system was implemented. Then, they start using the new version of the plugin, which includes the notification system. For that merchant, we'll end up sending the wrong notifications because the initial metadata isn't set.

Another potential concern is that if one of the topic's metadata fields isn't set, we'll begin sending incorrect notifications, leading to an out-of-sync situation. I believe relying on previous notification states seems a bit risky.

$valid_has_notified_creation_statuses = [
NotificationStatus::NOTIFICATION_PENDING_CREATE,
NotificationStatus::NOTIFICATION_CREATED,
NotificationStatus::NOTIFICATION_UPDATED,
NotificationStatus::NOTIFICATION_PENDING_UPDATE
];

return in_array( $this->meta_handler->get_notification_status( $product ), $valid_has_notified_creation_statuses, true ) || $this->is_product_synced( $product );
}

/**
* Set the notification status for a WooCommerce product.
*
* @param WC_Product $product
* @param string $status
*/
public function set_notification_status( WC_Product $product, $status ): void {
$this->meta_handler->update_notification_status( $product, $status );
}

/**
* @param WC_Product $product
*
Expand Down
Loading
Loading