Commit 98f7bfd92e6 for woocommerce
commit 98f7bfd92e6daf21d23272436c8ebafd64cf32eb
Author: Chris Lilitsas <1105590+xristos3490@users.noreply.github.com>
Date: Mon Nov 2 10:53:18 2026 +0200
[BIS] Fire ID-based lifecycle actions for stock notifications (#69878)
* feat: fire lifecycle actions for stock notifications
Fire ID-based actions from the stock notifications data store so every
path that creates, changes the status of or deletes a notification is
covered: woocommerce_new_stock_notification,
woocommerce_stock_notification_status_changed and
woocommerce_delete_stock_notification. The privacy eraser also fires
woocommerce_stock_notification_erased with the original email before
the row is anonymized, so integrations can locate the external profile.
* feat: fire woocommerce_update_stock_notification on data store update
Completes the new / update / delete set the other core data stores
expose, firing after apply_changes() and before the status_changed
action so a listener sees the persisted row first.
* fix: capture persisted status before firing the update action
A listener on woocommerce_update_stock_notification that calls set_status()
repopulates the object's pending changes, so reading get_status( 'edit' )
after the action would compare against the listener's unsaved value and
could suppress or misreport woocommerce_stock_notification_status_changed.
Snapshot the status right after apply_changes() instead.
* fix: stop the stock email saving the notification mid-render
CustomerStockNotificationEmail saved the notification while building the
unsubscribe link, firing woocommerce_update_stock_notification inside the
processor's send try. A listener that threw there turned into a failed
send, cancelled the sign-up, and threw again on the final save, ending the
batch before the next one was scheduled.
Generate the key once in prepare_email() without saving; both callers
already save after sending. This also keeps the HTML and plain parts of a
multipart email on the same key.
* docs: drop caller lists from stock notification lifecycle action docblocks
The lists had already drifted: retention cleanup only deletes, yet it was listed under update and status_changed. The data store fires these actions for every caller, so naming callers adds nothing.
* refactor: rename the stock notification eraser action to a before hook
woocommerce_stock_notification_erased fires before the notification is anonymized and saved, and that save can still fail. Rename it to woocommerce_privacy_before_remove_stock_notification_personal_data, matching core's woocommerce_privacy_before_remove_order_personal_data. Its @since moves to 11.4.0.
* docs: target 11.4.0 and note pending sign-ups on the new stock notification action
release/11.3 has been cut, so the lifecycle actions ship in 11.4.0. With double opt-in a new notification starts as pending, so point integrations that want confirmed sign-ups at status_changed to active.
* chore: drop stale PHPStan baseline entry for the stock email unsubscribe key
* chore: set @since to 11.3.0 for stock notification lifecycle hooks
* docs: qualify when status_changed fires after the stock notification eraser
diff --git a/plugins/woocommerce/changelog/add-stock-notification-lifecycle-actions b/plugins/woocommerce/changelog/add-stock-notification-lifecycle-actions
new file mode 100644
index 00000000000..769388a76b9
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-stock-notification-lifecycle-actions
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Fire ID-based lifecycle actions when a stock notification is created, updated, changes status, is deleted or is about to be anonymized by the privacy eraser.
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 2caaad452f7..6ddad26f090 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -64122,12 +64122,6 @@ parameters:
count: 1
path: src/Internal/StockNotifications/Emails/CustomerStockNotificationEmail.php
- -
- message: '#^Cannot call method get_unsubscribe_key\(\) on bool\|object\.$#'
- identifier: method.nonObject
- count: 1
- path: src/Internal/StockNotifications/Emails/CustomerStockNotificationEmail.php
-
-
message: '#^Method Automattic\\WooCommerce\\Internal\\StockNotifications\\Emails\\CustomerStockNotificationEmail\:\:maybe_restore_notification_locale\(\) has no return type specified\.$#'
identifier: missingType.return
diff --git a/plugins/woocommerce/src/Internal/DataStores/StockNotifications/StockNotificationsDataStore.php b/plugins/woocommerce/src/Internal/DataStores/StockNotifications/StockNotificationsDataStore.php
index da9d24b99a3..0ec8e589734 100644
--- a/plugins/woocommerce/src/Internal/DataStores/StockNotifications/StockNotificationsDataStore.php
+++ b/plugins/woocommerce/src/Internal/DataStores/StockNotifications/StockNotificationsDataStore.php
@@ -198,7 +198,22 @@ CREATE TABLE $meta_table_name (
$notification->save_meta_data();
$notification->apply_changes();
- return $notification->get_id();
+ /**
+ * Action: woocommerce_new_stock_notification
+ *
+ * Fires after a stock notification is created, from any code path.
+ * With double opt-in the new notification is `pending` until the shopper verifies it, so
+ * integrations that only want confirmed sign-ups can wait for
+ * `woocommerce_stock_notification_status_changed` to `active` instead.
+ *
+ * @since 11.3.0
+ *
+ * @param int $notification_id The notification ID.
+ * @param Notification $notification The notification.
+ */
+ do_action( 'woocommerce_new_stock_notification', $notification_id, $notification );
+
+ return $notification_id;
}
/**
@@ -263,8 +278,9 @@ CREATE TABLE $meta_table_name (
return new \WP_Error( 'invalid_stock_notification', 'Invalid notification ID.' );
}
- $changes = $notification->get_changes();
- $result = 0;
+ $changes = $notification->get_changes();
+ $result = 0;
+ $status_from = $notification->get_data()['status'];
if ( array_intersect( array( 'product_id', 'user_id', 'user_email', 'status', 'date_modified', 'date_confirmed', 'date_last_attempt', 'date_notified', 'date_cancelled', 'cancellation_source' ), array_keys( $changes ) ) ) {
@@ -303,6 +319,37 @@ CREATE TABLE $meta_table_name (
$notification->apply_changes();
}
+ $status_to = $notification->get_status( 'edit' );
+
+ /**
+ * Action: woocommerce_update_stock_notification
+ *
+ * Fires after a stock notification is updated, from any code path, whether or not anything changed.
+ * Also fires inside the back-in-stock send batch, so defer remote calls to Action Scheduler.
+ *
+ * @since 11.3.0
+ *
+ * @param int $notification_id The notification ID.
+ * @param Notification $notification The notification.
+ */
+ do_action( 'woocommerce_update_stock_notification', $notification->get_id(), $notification );
+
+ if ( array_key_exists( 'status', $changes ) && $status_from !== $status_to ) {
+ /**
+ * Action: woocommerce_stock_notification_status_changed
+ *
+ * Fires after a stock notification's status is persisted with a different value, from any code path.
+ *
+ * @since 11.3.0
+ *
+ * @param int $notification_id The notification ID.
+ * @param string $status_from The previous status.
+ * @param string $status_to The new status.
+ * @param Notification $notification The notification.
+ */
+ do_action( 'woocommerce_stock_notification_status_changed', $notification->get_id(), $status_from, $status_to, $notification );
+ }
+
return $result;
}
@@ -316,10 +363,23 @@ CREATE TABLE $meta_table_name (
public function delete( &$notification, $args = array() ) {
global $wpdb;
- $deleted = $wpdb->delete( $this->get_table_name(), array( 'id' => $notification->get_id() ), array( '%d' ) );
+ $notification_id = $notification->get_id();
+ $deleted = $wpdb->delete( $this->get_table_name(), array( 'id' => $notification_id ), array( '%d' ) );
if ( $deleted > 0 ) {
- $this->data_store_meta->delete_by_notification_id( $notification->get_id() );
+ $this->data_store_meta->delete_by_notification_id( $notification_id );
+
+ /**
+ * Action: woocommerce_delete_stock_notification
+ *
+ * Fires after a stock notification is deleted, from any code path.
+ *
+ * @since 11.3.0
+ *
+ * @param int $notification_id The deleted notification ID.
+ * @param Notification $notification The notification, as it was before deletion.
+ */
+ do_action( 'woocommerce_delete_stock_notification', $notification_id, $notification );
}
}
diff --git a/plugins/woocommerce/src/Internal/StockNotifications/Emails/CustomerStockNotificationEmail.php b/plugins/woocommerce/src/Internal/StockNotifications/Emails/CustomerStockNotificationEmail.php
index ec522ca6b39..3e937fafbdf 100644
--- a/plugins/woocommerce/src/Internal/StockNotifications/Emails/CustomerStockNotificationEmail.php
+++ b/plugins/woocommerce/src/Internal/StockNotifications/Emails/CustomerStockNotificationEmail.php
@@ -14,6 +14,13 @@ use WC_Email;
*/
class CustomerStockNotificationEmail extends WC_Email {
+ /**
+ * Unsubscribe key for the email being sent. The caller's save persists its hash.
+ *
+ * @var string
+ */
+ private $unsubscribe_key = '';
+
/**
* Constructor.
*/
@@ -170,9 +177,8 @@ class CustomerStockNotificationEmail extends WC_Email {
$product
);
- $unsubscribe_key = $notification->get_unsubscribe_key( true );
- $user = $notification instanceof Notification && $notification->get_user_id() ? get_user_by( 'id', $notification->get_user_id() ) : false;
- $is_guest = ! is_a( $user, 'WP_User' );
+ $user = $notification instanceof Notification && $notification->get_user_id() ? get_user_by( 'id', $notification->get_user_id() ) : false;
+ $is_guest = ! is_a( $user, 'WP_User' );
return array(
'button_text' => $button_text,
@@ -181,7 +187,7 @@ class CustomerStockNotificationEmail extends WC_Email {
add_query_arg(
array(
'email_link_action' => EmailActionController::ACTION_UNSUBSCRIBE,
- 'email_link_action_key' => $unsubscribe_key,
+ 'email_link_action_key' => $this->unsubscribe_key,
'notification_id' => $notification->get_id(),
),
home_url( '/' )
@@ -236,6 +242,7 @@ class CustomerStockNotificationEmail extends WC_Email {
public function prepare_email( Notification $notification ): void {
$this->object = $notification;
$this->recipient = $notification->get_user_email();
+ $this->unsubscribe_key = $notification->get_unsubscribe_key( false );
$product = $notification->get_product();
$this->placeholders['{product_name}'] = preg_replace( $this->plain_search, $this->plain_replace, $product->get_name() );
$this->placeholders['{site_title}'] = preg_replace( $this->plain_search, $this->plain_replace, $this->get_blogname() );
diff --git a/plugins/woocommerce/src/Internal/StockNotifications/Privacy/PrivacyEraser.php b/plugins/woocommerce/src/Internal/StockNotifications/Privacy/PrivacyEraser.php
index 1e21205e3cc..f6a5658fb12 100644
--- a/plugins/woocommerce/src/Internal/StockNotifications/Privacy/PrivacyEraser.php
+++ b/plugins/woocommerce/src/Internal/StockNotifications/Privacy/PrivacyEraser.php
@@ -233,6 +233,23 @@ class PrivacyEraser extends \WC_Abstract_Privacy {
continue;
}
+ /**
+ * Action: woocommerce_privacy_before_remove_stock_notification_personal_data
+ *
+ * Fires before a stock notification is anonymized by the privacy eraser, while it still
+ * holds the original email and user ID. The save that follows fires
+ * `woocommerce_update_stock_notification` with the anonymized row, plus
+ * `woocommerce_stock_notification_status_changed` only when an active or pending
+ * notification is cancelled. Neither can identify the shopper any more.
+ *
+ * @since 11.3.0
+ *
+ * @param int $notification_id The notification ID.
+ * @param string $original_email The email stored on the notification before anonymization.
+ * @param Notification $notification The notification, not yet anonymized.
+ */
+ do_action( 'woocommerce_privacy_before_remove_stock_notification_personal_data', $notification->get_id(), $notification->get_user_email(), $notification );
+
$anonymous_email = wp_privacy_anonymize_data( 'email', $email_address );
$notification->set_user_email( $anonymous_email );
$notification->set_user_id( 0 );
diff --git a/plugins/woocommerce/tests/php/src/Internal/DataStores/StockNotifications/StockNotificationsDataStoreTests.php b/plugins/woocommerce/tests/php/src/Internal/DataStores/StockNotifications/StockNotificationsDataStoreTests.php
index a2d4902de50..70f82a28ae4 100644
--- a/plugins/woocommerce/tests/php/src/Internal/DataStores/StockNotifications/StockNotificationsDataStoreTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/DataStores/StockNotifications/StockNotificationsDataStoreTests.php
@@ -214,6 +214,178 @@ class StockNotificationsDataStoreTests extends \WC_Unit_Test_Case {
new Notification( $notification_id );
}
+ /**
+ * @testdox Should fire woocommerce_new_stock_notification with the ID once a notification is created.
+ */
+ public function test_create_fires_new_stock_notification_action(): void {
+ $fired = array();
+ add_action(
+ 'woocommerce_new_stock_notification',
+ function ( $id, $notification ) use ( &$fired ) {
+ $fired[] = array( $id, $notification );
+ },
+ 10,
+ 2
+ );
+
+ $notification = new Notification();
+ $notification->set_product_id( 1 );
+ $notification->set_user_email( 'test@test.com' );
+ $notification->save();
+
+ $this->assertCount( 1, $fired );
+ $this->assertSame( $notification->get_id(), $fired[0][0] );
+ $this->assertSame( $notification, $fired[0][1] );
+
+ // Updating the same notification does not fire it again.
+ $notification->set_status( NotificationStatus::ACTIVE );
+ $notification->save();
+ $this->assertCount( 1, $fired );
+ }
+
+ /**
+ * @testdox Should fire woocommerce_update_stock_notification with the ID once a notification is updated.
+ */
+ public function test_update_fires_update_stock_notification_action(): void {
+ $fired = array();
+ add_action(
+ 'woocommerce_update_stock_notification',
+ function ( $id, $notification ) use ( &$fired ) {
+ $fired[] = array( 'update', $id, $notification );
+ },
+ 10,
+ 2
+ );
+ add_action(
+ 'woocommerce_stock_notification_status_changed',
+ function ( $id ) use ( &$fired ) {
+ $fired[] = array( 'status_changed', $id );
+ }
+ );
+
+ $notification = new Notification();
+ $notification->set_product_id( 1 );
+ $notification->set_user_email( 'test@test.com' );
+ $notification->save();
+ $this->assertCount( 0, $fired, 'Creating does not fire an update.' );
+
+ $notification->set_user_email( 'other@test.com' );
+ $notification->save();
+ $this->assertSame( array( array( 'update', $notification->get_id(), $notification ) ), $fired );
+
+ // A status change fires the update action first, then the status change.
+ $fired = array();
+ $notification->set_status( NotificationStatus::ACTIVE );
+ $notification->save();
+ $this->assertSame( array( 'update', 'status_changed' ), array_column( $fired, 0 ) );
+
+ // The admin list table updates through the data store directly.
+ $fired = array();
+ $notification->set_product_id( 2 );
+ $this->data_store->update( $notification );
+ $this->assertCount( 1, $fired );
+ $this->assertSame( $notification->get_id(), $fired[0][1] );
+ }
+
+ /**
+ * @testdox Should fire woocommerce_stock_notification_status_changed only when a different status is persisted.
+ */
+ public function test_update_fires_status_changed_action(): void {
+ $fired = array();
+ add_action(
+ 'woocommerce_stock_notification_status_changed',
+ function ( $id, $from, $to, $notification ) use ( &$fired ) {
+ $fired[] = array( $id, $from, $to, $notification );
+ },
+ 10,
+ 4
+ );
+
+ $notification = new Notification();
+ $notification->set_product_id( 1 );
+ $notification->set_user_email( 'test@test.com' );
+ $notification->save();
+ $this->assertCount( 0, $fired, 'Creating does not fire a status change.' );
+
+ // A non-status change does not fire it.
+ $notification->set_product_id( 2 );
+ $notification->save();
+ $this->assertCount( 0, $fired );
+
+ // Re-setting the current status does not fire it.
+ $notification->set_status( NotificationStatus::PENDING );
+ $notification->save();
+ $this->assertCount( 0, $fired );
+
+ $notification->set_status( NotificationStatus::ACTIVE );
+ $notification->save();
+ $this->assertCount( 1, $fired );
+ $this->assertSame( array( $notification->get_id(), NotificationStatus::PENDING, NotificationStatus::ACTIVE, $notification ), $fired[0] );
+
+ // The admin list table updates through the data store directly.
+ $notification->set_status( NotificationStatus::CANCELLED );
+ $this->data_store->update( $notification );
+ $this->assertCount( 2, $fired );
+ $this->assertSame( NotificationStatus::ACTIVE, $fired[1][1] );
+ $this->assertSame( NotificationStatus::CANCELLED, $fired[1][2] );
+ $this->assertSame( NotificationStatus::CANCELLED, $fired[1][3]->get_status() );
+
+ // An update-action listener that changes the status in memory does not affect the persisted pair.
+ add_action(
+ 'woocommerce_update_stock_notification',
+ function ( $id, $notification ) {
+ $notification->set_status( NotificationStatus::CANCELLED );
+ },
+ 10,
+ 2
+ );
+ $notification->set_status( NotificationStatus::ACTIVE );
+ $notification->save();
+ $this->assertCount( 3, $fired );
+ $this->assertSame( NotificationStatus::CANCELLED, $fired[2][1] );
+ $this->assertSame( NotificationStatus::ACTIVE, $fired[2][2] );
+ }
+
+ /**
+ * @testdox Should fire woocommerce_delete_stock_notification with the ID once a notification is deleted.
+ */
+ public function test_delete_fires_delete_stock_notification_action(): void {
+ $fired = array();
+ add_action(
+ 'woocommerce_delete_stock_notification',
+ function ( $id, $notification ) use ( &$fired ) {
+ $fired[] = array( $id, $notification );
+ },
+ 10,
+ 2
+ );
+
+ $notification = new Notification();
+ $notification->set_product_id( 1 );
+ $notification->set_user_email( 'test@test.com' );
+ $notification->save();
+ $notification_id = $notification->get_id();
+
+ $notification->delete();
+ $this->assertCount( 1, $fired );
+ $this->assertSame( $notification_id, $fired[0][0] );
+ $this->assertSame( $notification, $fired[0][1] );
+
+ // Deleting a row that is already gone does not fire it.
+ $notification->set_id( $notification_id );
+ $this->data_store->delete( $notification );
+ $this->assertCount( 1, $fired );
+
+ // The admin list table deletes through the data store directly.
+ $other = new Notification();
+ $other->set_product_id( 1 );
+ $other->set_user_email( 'other@test.com' );
+ $other->save();
+ $this->data_store->delete( $other );
+ $this->assertCount( 2, $fired );
+ $this->assertSame( $other->get_id(), $fired[1][0] );
+ }
+
/**
* Test reading a non-existent notification.
*/
diff --git a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Emails/EmailManagerTests.php b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Emails/EmailManagerTests.php
index c9d4060554a..8f9e0ec4c53 100644
--- a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Emails/EmailManagerTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Emails/EmailManagerTests.php
@@ -209,6 +209,29 @@ class EmailManagerTests extends \WC_Unit_Test_Case {
$this->assertArrayHasKey( $notification->get_user_email(), $this->sent_to );
}
+ /**
+ * @testdox Should not save the notification while sending the stock email, leaving the unsubscribe key to the caller's save.
+ */
+ public function test_send_stock_notification_email_leaves_saving_the_unsubscribe_key_to_the_caller() {
+ $notification = $this->build_notification();
+ $updates = 0;
+ $count = function () use ( &$updates ) {
+ ++$updates;
+ };
+ add_action( 'woocommerce_update_stock_notification', $count );
+
+ $this->sut->send_stock_notification_email( $notification );
+
+ remove_action( 'woocommerce_update_stock_notification', $count );
+ $this->assertSame( 0, $updates );
+ $this->assertCount( 1, $this->sent_messages );
+ $this->assertSame( 1, preg_match( '/email_link_action_key=([A-Za-z0-9]+)/', $this->sent_messages[0], $matches ) );
+
+ $notification->save();
+ $notification = new Notification( $notification->get_id() );
+ $this->assertTrue( $notification->check_unsubscribe_key( $matches[1] ) );
+ }
+
/**
* @testdox Should build the $email_method action link on home_url() when WordPress core lives in a subdirectory.
* @dataProvider provider_email_send_methods
diff --git a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Privacy/PrivacyEraserTests.php b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Privacy/PrivacyEraserTests.php
index 26d7dcc19ce..62be7fc3960 100644
--- a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Privacy/PrivacyEraserTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Privacy/PrivacyEraserTests.php
@@ -56,6 +56,42 @@ class PrivacyEraserTests extends \WC_Unit_Test_Case {
$this->assertNotNull( $anonymous_notification->get_date_cancelled() );
}
+ /**
+ * @testdox Should fire woocommerce_privacy_before_remove_stock_notification_personal_data with the original email before anonymizing.
+ */
+ public function test_privacy_eraser_fires_before_remove_action_before_anonymizing(): void {
+ $notification = new Notification();
+ $notification->set_user_email( 'jon@doe.com' );
+ $notification->set_user_id( 5 );
+ $notification->set_product_id( 1 );
+ $notification->set_status( NotificationStatus::ACTIVE );
+ $notification_id = $notification->save();
+
+ $erased = array();
+ add_action(
+ 'woocommerce_privacy_before_remove_stock_notification_personal_data',
+ function ( $id, $original_email, $notification ) use ( &$erased ) {
+ $erased[] = array( $id, $original_email, $notification->get_user_email(), $notification->get_user_id(), $notification->get_status() );
+ },
+ 10,
+ 3
+ );
+ $status_changes = array();
+ add_action(
+ 'woocommerce_stock_notification_status_changed',
+ function ( $id, $from, $to, $notification ) use ( &$status_changes ) {
+ $status_changes[] = array( $id, $from, $to, $notification->get_user_email() );
+ },
+ 10,
+ 4
+ );
+
+ PrivacyEraser::erase_notification_data( 'jon@doe.com' );
+
+ $this->assertSame( array( array( $notification_id, 'jon@doe.com', 'jon@doe.com', 5, NotificationStatus::ACTIVE ) ), $erased );
+ $this->assertSame( array( array( $notification_id, NotificationStatus::ACTIVE, NotificationStatus::CANCELLED, wp_privacy_anonymize_data( 'email', '' ) ) ), $status_changes );
+ }
+
/**
* @testdox Should keep a sent notification sent, with its sent date, while still anonymizing it.
*/