Commit 8c800a4a84e for woocommerce
commit 8c800a4a84ed799e83002ffc21fbad73d5e93273
Author: Chris Lilitsas <1105590+xristos3490@users.noreply.github.com>
Date: Thu Oct 15 19:30:02 2026 +0300
Fix the stock notification threshold description and name products in the restock admin notice (#69813)
* fix: name restocked products in the stock notification admin notice
Collect restocked product IDs in memory and write them once per request on
shutdown into a per-user transient, so a CSV import lists every product and
only the admin who restocked sees the notice. Also fix the wording of the
unverified sign-up deletion threshold description.
* fix: store the restock notice after scheduling jobs and only for store managers
process_queue() scheduled the Action Scheduler jobs after storing the
admin notice, so a throwing transient filter or object-cache drop-in
could stop the customer notification jobs from being queued. The jobs
are now scheduled first and the notice is stored last, inside a
try/catch that logs instead of escaping the shutdown callback.
The notice is only stored when the current user can manage_woocommerce,
so an admin-ajax restock made by a customer or a logged-out request no
longer stores a notice nobody will see.
Tests now assert the product-filtered link for a single restocked
product and its absence when several are listed.
* fix: keep the restock notice for a day, drop the old notice option, and test the skip guards
The per-user notice transient expired after five minutes, so a Quick Edit
restock could expire unseen while the merchant stayed on the list. It now
lasts a day; the transient is deleted when shown, so the TTL only bounds
stale entries.
A db update deletes the autoloaded option that used to hold the notice,
which nothing reads or deletes any more.
A data-provider test covers the guards that skip storing the notice:
front-end requests, no user, and users who cannot manage the store.
* fix: satisfy PHPStan for the restock notice changes
Declare the void return type on the new update function and drop an
is_array() check on a property that is already typed as array.
* fix: describe the restock notice as a completed event
The notice is read once and deleted, so it can be seen hours after the
restock. Say the notifications have been queued instead of "now being
processed" and "over the next few minutes".
diff --git a/plugins/woocommerce/changelog/fix-wooplug-7928-bis-threshold-restock-copy b/plugins/woocommerce/changelog/fix-wooplug-7928-bis-threshold-restock-copy
new file mode 100644
index 00000000000..1b07db46c2a
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-wooplug-7928-bis-threshold-restock-copy
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Name the restocked products in the back-in-stock notification admin notice, show it only to the store manager who restocked them, and fix the wording of the unverified sign-up deletion setting.
diff --git a/plugins/woocommerce/includes/class-wc-install.php b/plugins/woocommerce/includes/class-wc-install.php
index b1c125c8607..bb781806ff2 100644
--- a/plugins/woocommerce/includes/class-wc-install.php
+++ b/plugins/woocommerce/includes/class-wc-install.php
@@ -369,6 +369,7 @@ class WC_Install {
'wc_update_1130_set_legacy_variation_price_hash_option',
'wc_update_1130_delete_unpublished_variation_lookup_rows',
'wc_update_1130_split_tax_lookup_taxable_amount',
+ 'wc_update_1130_delete_stock_notification_sync_notice_option',
),
);
diff --git a/plugins/woocommerce/includes/wc-update-functions.php b/plugins/woocommerce/includes/wc-update-functions.php
index 1fbdda9ad2d..92ffdc7be76 100644
--- a/plugins/woocommerce/includes/wc-update-functions.php
+++ b/plugins/woocommerce/includes/wc-update-functions.php
@@ -4117,3 +4117,15 @@ function wc_update_1130_delete_unpublished_variation_lookup_rows() {
return false;
}
+
+/**
+ * Delete the option that used to hold the back-in-stock restock admin notice.
+ *
+ * The notice is now stored per user in a transient. A notice pending at upgrade time would otherwise leave
+ * an autoloaded row behind that nothing reads or deletes any more.
+ *
+ * @since 11.3.0
+ */
+function wc_update_1130_delete_stock_notification_sync_notice_option(): void {
+ delete_option( 'wc_customer_stock_notifications_product_sync_notice' );
+}
diff --git a/plugins/woocommerce/src/Internal/StockNotifications/Admin/SettingsController.php b/plugins/woocommerce/src/Internal/StockNotifications/Admin/SettingsController.php
index 8cc0bd07de1..204957fe046 100644
--- a/plugins/woocommerce/src/Internal/StockNotifications/Admin/SettingsController.php
+++ b/plugins/woocommerce/src/Internal/StockNotifications/Admin/SettingsController.php
@@ -175,7 +175,7 @@ class SettingsController {
array(
'title' => __( 'Delete unverified notification sign-ups after (in days)', 'woocommerce' ),
- 'desc' => __( 'Controls how long the plugin will store unverified notification sign-ups in the database. Enter zero, or leave this field empty if you would like to store expired sign-up requests indefinitey.', 'woocommerce' ),
+ 'desc' => __( 'Controls how long unverified notification sign-ups are stored in the database. Enter zero, or leave this field empty, to store unverified sign-up requests indefinitely.', 'woocommerce' ),
'id' => 'woocommerce_customer_stock_notifications_unverified_deletions_days_threshold',
'default' => Config::get_unverified_deletion_days_threshold(),
'type' => 'number',
diff --git a/plugins/woocommerce/src/Internal/StockNotifications/StockSyncController.php b/plugins/woocommerce/src/Internal/StockNotifications/StockSyncController.php
index bf4509336c7..b81f9c78158 100644
--- a/plugins/woocommerce/src/Internal/StockNotifications/StockSyncController.php
+++ b/plugins/woocommerce/src/Internal/StockNotifications/StockSyncController.php
@@ -20,6 +20,13 @@ class StockSyncController {
*/
private array $queue = array();
+ /**
+ * Product IDs restocked during this request, using product IDs as keys. Named in the admin notice.
+ *
+ * @var array<int, bool>
+ */
+ private array $notice_product_ids = array();
+
/**
* The eligibility service instance.
*
@@ -111,7 +118,7 @@ class StockSyncController {
$this->queue[ $target_product_id ] = true;
}
- $this->store_admin_notice( $product->get_id() );
+ $this->notice_product_ids[ $product->get_id() ] = true;
} catch ( \Throwable $e ) {
$this->logger->error(
@@ -130,63 +137,100 @@ class StockSyncController {
* @return void
*/
public function process_queue(): void {
- if ( empty( $this->queue ) || ! is_array( $this->queue ) ) {
- $this->queue = array();
- return;
- }
-
$product_ids = array_filter( array_keys( $this->queue ) );
- if ( empty( $product_ids ) ) {
- return;
- }
+ $this->queue = array();
- foreach ( $product_ids as $product_id ) {
- $this->job_manager->schedule_initial_job_for_product( $product_id );
+ if ( ! empty( $product_ids ) ) {
+ foreach ( $product_ids as $product_id ) {
+ $this->job_manager->schedule_initial_job_for_product( $product_id );
+ }
+
+ /**
+ * Allows for additional processing of the product IDs after they have been queued.
+ *
+ * @since 10.2.0
+ *
+ * @param array $product_ids The product IDs to process.
+ */
+ do_action( 'woocommerce_customer_stock_notifications_product_sync', $product_ids );
}
- /**
- * Allows for additional processing of the product IDs after they have been queued.
- *
- * @since 10.2.0
- *
- * @param array $product_ids The product IDs to process.
- */
- do_action( 'woocommerce_customer_stock_notifications_product_sync', $product_ids );
- $this->queue = array();
+ // Store the notice last, so it can neither block the jobs above nor claim they are queued before they are.
+ $this->store_admin_notice();
}
/**
- * Store the admin notice.
+ * Store the admin notice for the current user.
+ *
+ * Runs once per request on shutdown, so a request that restocks several products (for example, a CSV
+ * import batch) writes once and lists all of them. Pending IDs from earlier requests are kept, so the
+ * notice covers every product restocked since the user last loaded an admin page.
+ *
+ * Only store managers get a notice: a restock made by another user (for example, through an admin-ajax
+ * action from the storefront or a logged-out request) stores nothing.
*
- * @param int $product_id The product ID to sync.
* @return void
*/
- private function store_admin_notice( $product_id ): void {
- if ( ! is_admin() || ! function_exists( 'wp_admin_notice' ) ) {
+ private function store_admin_notice(): void {
+ $product_ids = array_keys( $this->notice_product_ids );
+ $this->notice_product_ids = array();
+
+ if ( empty( $product_ids ) || ! is_admin() || ! get_current_user_id() || ! current_user_can( 'manage_woocommerce' ) ) {
return;
}
- /* translators: 1 = URL of the Back in Stock Notifications page */
- $notice_message = sprintf( __( 'Back-in-stock notifications for this product are now being processed. Subscribed customers will receive these emails over the next few minutes. You can monitor or manage individual subscriptions on the <a href="%s">Stock Notifications page</a>.', 'woocommerce' ), sprintf( admin_url( 'admin.php?page=wc-customer-stock-notifications&customer_stock_notifications_product_filter=%d&status=active_customer_stock_notifications&filter_action=Filter' ), $product_id ) );
+ try {
+ $transient_key = $this->get_notice_transient_key();
+ $pending_ids = array_filter( array_map( 'absint', (array) get_transient( $transient_key ) ) );
- update_option( 'wc_customer_stock_notifications_product_sync_notice', $notice_message );
+ set_transient( $transient_key, array_values( array_unique( array_merge( $pending_ids, $product_ids ) ) ), DAY_IN_SECONDS );
+ } catch ( \Throwable $e ) {
+ $this->logger->error(
+ sprintf( 'StockSyncController: Failed to store the restock admin notice: %s', $e->getMessage() ),
+ array( 'source' => 'wc-customer-stock-notifications' )
+ );
+ }
}
/**
- * Add admin notices.
+ * Output the admin notice for the current user, naming the restocked products.
*
* @return void
*/
public function output_admin_notice(): void {
- if ( ! function_exists( 'wp_admin_notice' ) ) {
+ if ( ! get_current_user_id() || ! function_exists( 'wp_admin_notice' ) ) {
return;
}
- $notice_message = get_option( 'wc_customer_stock_notifications_product_sync_notice' );
- if ( empty( $notice_message ) ) {
+ $transient_key = $this->get_notice_transient_key();
+ $product_ids = array_filter( array_map( 'absint', (array) get_transient( $transient_key ) ) );
+ if ( empty( $product_ids ) ) {
return;
}
+ delete_transient( $transient_key );
+
+ $product_names = array();
+ foreach ( array_slice( $product_ids, 0, 3 ) as $product_id ) {
+ $product = \wc_get_product( $product_id );
+ if ( $product ) {
+ $product_names[] = esc_html( $product->get_name() );
+ }
+ }
+
+ $remaining = count( $product_ids ) - count( $product_names );
+ if ( $remaining > 0 ) {
+ /* translators: %d: number of additional restocked products */
+ $product_names[] = sprintf( _n( '%d more product', '%d more products', $remaining, 'woocommerce' ), $remaining );
+ }
+
+ $list_url = 1 === count( $product_ids )
+ ? sprintf( admin_url( 'admin.php?page=wc-customer-stock-notifications&customer_stock_notifications_product_filter=%d&status=active_customer_stock_notifications&filter_action=Filter' ), reset( $product_ids ) )
+ : admin_url( 'admin.php?page=wc-customer-stock-notifications&status=active_customer_stock_notifications' );
+
+ /* translators: 1: restocked product names, 2: URL of the Back in Stock Notifications page */
+ $notice_message = sprintf( __( 'Back-in-stock notifications for %1$s have been queued. Subscribed customers will receive these emails shortly. You can monitor or manage individual subscriptions on the <a href="%2$s">Stock Notifications page</a>.', 'woocommerce' ), wp_sprintf( '%l', $product_names ), esc_url( $list_url ) );
+
\wp_admin_notice(
$notice_message,
array(
@@ -195,7 +239,14 @@ class StockSyncController {
'dismissible' => false,
)
);
+ }
- delete_option( 'wc_customer_stock_notifications_product_sync_notice' );
+ /**
+ * Get the transient key holding the current user's pending notice product IDs.
+ *
+ * @return string
+ */
+ private function get_notice_transient_key(): string {
+ return 'wc_customer_stock_notifications_product_sync_notice_' . get_current_user_id();
}
}
diff --git a/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php b/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php
index 44899f598e2..358e4e05473 100644
--- a/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php
@@ -1159,4 +1159,15 @@ class WC_Update_Functions_Test extends \WC_Unit_Test_Case {
delete_option( 'woocommerce_use_legacy_get_variations_price_hash' );
}
+
+ /**
+ * @testdox wc_update_1130_delete_stock_notification_sync_notice_option deletes the old restock notice option.
+ */
+ public function test_wc_update_1130_delete_stock_notification_sync_notice_option(): void {
+ include_once WC_ABSPATH . 'includes/wc-update-functions.php';
+
+ update_option( 'wc_customer_stock_notifications_product_sync_notice', '<p>pending</p>' );
+ wc_update_1130_delete_stock_notification_sync_notice_option();
+ $this->assertFalse( get_option( 'wc_customer_stock_notifications_product_sync_notice' ) );
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/StockSyncControllerTests.php b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/StockSyncControllerTests.php
index 9ee7e6f783b..1127327f6f2 100644
--- a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/StockSyncControllerTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/StockSyncControllerTests.php
@@ -209,6 +209,152 @@ class StockSyncControllerTests extends \WC_Unit_Test_Case {
$this->assertArrayNotHasKey( $variation_id, $this->get_private_property( $this->sut, 'queue' ) );
}
+ /**
+ * Test that the admin notice names the restocked products, accumulating across requests and capping at three names.
+ */
+ public function test_admin_notice_names_restocked_products() {
+ $user_id = $this->factory->user->create( array( 'role' => 'administrator' ) );
+ wp_set_current_user( $user_id );
+ set_current_screen( 'dashboard' );
+
+ $products = array();
+ for ( $i = 1; $i <= 4; $i++ ) {
+ $product = \WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'name' => 'Restocked product ' . $i,
+ 'stock_status' => ProductStockStatus::OUT_OF_STOCK,
+ )
+ );
+
+ $notification = new Notification();
+ $notification->set_product_id( $product->get_id() );
+ $notification->set_user_id( 1 );
+ $notification->set_status( NotificationStatus::ACTIVE );
+ $notification->save();
+
+ $products[] = $product;
+ }
+
+ // First request restocks one product, second request (e.g. the next CSV import batch) restocks three.
+ $products[0]->set_stock_status( ProductStockStatus::IN_STOCK );
+ $products[0]->save();
+ $this->sut->process_queue();
+
+ foreach ( array_slice( $products, 1 ) as $product ) {
+ $product->set_stock_status( ProductStockStatus::IN_STOCK );
+ $product->save();
+ }
+ $this->sut->process_queue();
+
+ ob_start();
+ $this->sut->output_admin_notice();
+ $output = ob_get_clean();
+
+ $this->assertStringContainsString( 'Restocked product 1', $output );
+ $this->assertStringContainsString( 'Restocked product 2', $output );
+ $this->assertStringContainsString( 'Restocked product 3', $output );
+ $this->assertStringNotContainsString( 'Restocked product 4', $output );
+ $this->assertStringContainsString( '1 more product', $output );
+ $this->assertStringContainsString( 'page=wc-customer-stock-notifications', $output );
+ $this->assertStringNotContainsString( 'customer_stock_notifications_product_filter', $output );
+
+ // The notice is shown once.
+ ob_start();
+ $this->sut->output_admin_notice();
+ $this->assertSame( '', ob_get_clean() );
+
+ set_current_screen( 'front' );
+ wp_set_current_user( 0 );
+ }
+
+ /**
+ * Test that the admin notice is only shown to the user who restocked the product.
+ */
+ public function test_admin_notice_is_per_user() {
+ $user_id = $this->factory->user->create( array( 'role' => 'administrator' ) );
+ wp_set_current_user( $user_id );
+ set_current_screen( 'dashboard' );
+
+ $product = \WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'stock_status' => ProductStockStatus::OUT_OF_STOCK,
+ )
+ );
+
+ $notification = new Notification();
+ $notification->set_product_id( $product->get_id() );
+ $notification->set_user_id( 1 );
+ $notification->set_status( NotificationStatus::ACTIVE );
+ $notification->save();
+
+ $product->set_stock_status( ProductStockStatus::IN_STOCK );
+ $product->save();
+ $this->sut->process_queue();
+
+ wp_set_current_user( $this->factory->user->create( array( 'role' => 'administrator' ) ) );
+ ob_start();
+ $this->sut->output_admin_notice();
+ $this->assertSame( '', ob_get_clean() );
+
+ wp_set_current_user( $user_id );
+ ob_start();
+ $this->sut->output_admin_notice();
+ $output = ob_get_clean();
+ $this->assertStringContainsString( $product->get_name(), $output );
+ $this->assertStringContainsString( 'customer_stock_notifications_product_filter=' . $product->get_id(), $output );
+
+ set_current_screen( 'front' );
+ wp_set_current_user( 0 );
+ }
+
+ /**
+ * Test that no notice is stored for restocks made outside wp-admin, without a user, or by a user who cannot manage the store.
+ *
+ * @testWith [ "front", "administrator" ]
+ * [ "dashboard", null ]
+ * [ "dashboard", "customer" ]
+ *
+ * @param string $screen The current screen during the restock.
+ * @param string|null $role The role of the user making the restock, or null for no user.
+ */
+ public function test_admin_notice_is_not_stored_outside_manager_admin_requests( string $screen, ?string $role ) {
+ $admin_id = $this->factory->user->create( array( 'role' => 'administrator' ) );
+
+ $product = \WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'stock_status' => ProductStockStatus::OUT_OF_STOCK,
+ )
+ );
+
+ $notification = new Notification();
+ $notification->set_product_id( $product->get_id() );
+ $notification->set_user_id( 1 );
+ $notification->set_status( NotificationStatus::ACTIVE );
+ $notification->save();
+
+ $actor_id = null === $role ? 0 : ( 'administrator' === $role ? $admin_id : $this->factory->user->create( array( 'role' => $role ) ) );
+ wp_set_current_user( $actor_id );
+ set_current_screen( $screen );
+
+ $product->set_stock_status( ProductStockStatus::IN_STOCK );
+ $product->save();
+ $this->sut->process_queue();
+
+ $this->assertFalse( get_transient( 'wc_customer_stock_notifications_product_sync_notice_' . $actor_id ) );
+
+ wp_set_current_user( $admin_id );
+ set_current_screen( 'dashboard' );
+ ob_start();
+ $this->sut->output_admin_notice();
+ $this->assertSame( '', ob_get_clean() );
+
+ set_current_screen( 'front' );
+ wp_set_current_user( 0 );
+ }
+
/**
* Get a private property of an object.
*