Commit 226ca670d97 for woocommerce
commit 226ca670d9796fc6649ca617e3257fd76de02993
Author: Adam Grzybkowski <agrzybkowski@outlook.com>
Date: Wed May 13 16:57:38 2026 +0200
Filter push notifications by per-user preferences (#64487)
Filter NotificationProcessor by per-user preferences
Adds Notification::should_send_to_user(), a per-type extension hook
the processor calls before dispatch to drop tokens whose owning user
opted out. The decision lives on the Notification subclass so future
parametrized preferences (e.g. store_order with a min_value threshold)
extend without re-touching the processor.
The default base-class implementation reads $pref_value['enabled']
from the wrapped preference shape today's storage produces, with a
defensive fallback for null and unexpected scalars; subclasses override
to read richer sub-fields. Per-user decisions are memoized for the
duration of one process() call since one user can have several
registered tokens (iOS, iPad, Android, browser).
diff --git a/plugins/woocommerce/changelog/issue-RSM-695 b/plugins/woocommerce/changelog/issue-RSM-695
new file mode 100644
index 00000000000..67b2e358c9d
--- /dev/null
+++ b/plugins/woocommerce/changelog/issue-RSM-695
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Filter push notifications by per-user preferences so opted-out merchants don't receive disabled notification types on their devices.
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Notifications/Notification.php b/plugins/woocommerce/src/Internal/PushNotifications/Notifications/Notification.php
index b0dc38fc077..1ed640290d7 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Notifications/Notification.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Notifications/Notification.php
@@ -164,4 +164,44 @@ abstract class Notification {
public function get_resource_id(): int {
return $this->resource_id;
}
+
+ /**
+ * Decide whether this notification should be delivered to a user given
+ * their stored preference value for {@see static::get_type()}.
+ *
+ * `$pref_value` is whatever the user has stored under this notification
+ * type's preference key, or `null` if they have nothing stored. The
+ * {@see NotificationPreferencesService} stores each preference as an
+ * object so future sub-fields (thresholds, sub-toggles) can be added
+ * without bumping the schema version — today's shape is
+ * `['enabled' => bool]`, future shapes might add e.g.
+ * `['enabled' => true, 'min_value' => 500]` for an order threshold.
+ *
+ * Default: read the universal `enabled` sub-field, defaulting to `true`
+ * when the value is missing or has no `enabled` key (so newly-added
+ * notification types are opt-in by default). Subclasses override to
+ * read richer sub-fields and to consult their own resource (e.g.
+ * compare an order total to the user's `min_value`).
+ *
+ * Subclasses must keep this side-effect-free — the {@see NotificationProcessor}
+ * may call it once per recipient user per notification.
+ *
+ * @param mixed $pref_value The user's stored preference value, or null.
+ * @return bool True if this notification should be sent to that user.
+ *
+ * @since 10.8.0
+ */
+ public function should_send_to_user( $pref_value ): bool {
+ if ( null === $pref_value ) {
+ return true;
+ }
+
+ if ( is_array( $pref_value ) ) {
+ return (bool) ( $pref_value['enabled'] ?? true );
+ }
+
+ // Defensive fallback for unexpected scalar values; the service
+ // always normalises stored prefs to the array shape above.
+ return (bool) $pref_value;
+ }
}
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationProcessor.php b/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationProcessor.php
index b98f67fb654..558183ffc39 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationProcessor.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationProcessor.php
@@ -63,22 +63,32 @@ class NotificationProcessor {
*/
private PushTokensDataStore $data_store;
+ /**
+ * The notification preferences service.
+ *
+ * @var NotificationPreferencesService
+ */
+ private NotificationPreferencesService $preferences_service;
+
/**
* Initialize dependencies.
*
* @internal
*
- * @param WpcomNotificationDispatcher $dispatcher The WPCOM dispatcher.
- * @param PushTokensDataStore $data_store The push tokens data store.
+ * @param WpcomNotificationDispatcher $dispatcher The WPCOM dispatcher.
+ * @param PushTokensDataStore $data_store The push tokens data store.
+ * @param NotificationPreferencesService $preferences_service The notification preferences service.
*
* @since 10.7.0
*/
final public function init(
WpcomNotificationDispatcher $dispatcher,
- PushTokensDataStore $data_store
+ PushTokensDataStore $data_store,
+ NotificationPreferencesService $preferences_service
): void {
- $this->dispatcher = $dispatcher;
- $this->data_store = $data_store;
+ $this->dispatcher = $dispatcher;
+ $this->data_store = $data_store;
+ $this->preferences_service = $preferences_service;
}
/**
@@ -133,8 +143,19 @@ class NotificationProcessor {
);
/**
- * There are no recipients to send to. We don't want to retry as this
- * isn't a 'recoverable error', so mark as sent and return.
+ * Filter out tokens whose owning user does not want this notification.
+ * The decision is delegated to the notification itself via
+ * {@see Notification::should_send_to_user()} so per-type preference
+ * shapes (simple bool today, parametrized arrays in the future) stay
+ * encapsulated alongside the type's resource access.
+ */
+ $tokens = $this->filter_tokens_by_preferences( $tokens, $notification );
+
+ /**
+ * There are no recipients to send to (either no tokens at all, or
+ * every owning user opted out of this notification type). We don't
+ * want to retry as this isn't a 'recoverable error', so mark as sent
+ * and return.
*/
if ( empty( $tokens ) ) {
$notification->write_meta( self::SENT_META_KEY );
@@ -157,6 +178,51 @@ class NotificationProcessor {
return false;
}
+ /**
+ * Returns the subset of $tokens whose owning user wants $notification.
+ *
+ * The decision is delegated to {@see Notification::should_send_to_user()}
+ * so per-type preference shapes (simple bool today, parametrized arrays
+ * in the future) stay encapsulated alongside the type's resource access.
+ * Tokens with no owning user are dropped — there are no preferences to
+ * consult.
+ *
+ * Decisions are memoized per user for the duration of one call, since
+ * the same user can have several registered tokens (iOS, iPad, Android,
+ * browser) and we don't want to re-read user meta or re-fetch the
+ * resource for every token.
+ *
+ * @param PushToken[] $tokens The tokens to filter.
+ * @param Notification $notification The notification being processed.
+ *
+ * @return PushToken[] The tokens whose owner wants the notification.
+ *
+ * @since 10.8.0
+ */
+ private function filter_tokens_by_preferences( array $tokens, Notification $notification ): array {
+ $type = $notification->get_type();
+ $decision_cache = array();
+
+ return array_values(
+ array_filter(
+ $tokens,
+ function ( PushToken $token ) use ( $notification, $type, &$decision_cache ) {
+ $user_id = $token->get_user_id();
+ if ( ! $user_id ) {
+ return false;
+ }
+
+ if ( ! isset( $decision_cache[ $user_id ] ) ) {
+ $prefs = $this->preferences_service->get_preferences( $user_id );
+ $decision_cache[ $user_id ] = $notification->should_send_to_user( $prefs[ $type ] ?? null );
+ }
+
+ return $decision_cache[ $user_id ];
+ }
+ )
+ );
+ }
+
/**
* ActionScheduler callback for the safety net job. This will be scheduled
* for 60 seconds in the future when a notification is added to the
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NotificationTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NotificationTest.php
index 7f416a7f171..4b3e91aa3c1 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NotificationTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NotificationTest.php
@@ -102,4 +102,46 @@ class NotificationTest extends WC_Unit_Test_Case {
)
);
}
+
+ /**
+ * Default `should_send_to_user` should:
+ * - treat `null` (no stored value) as opt-in,
+ * - read `enabled` from the array shape today's storage produces,
+ * - default to `true` when the array shape is missing the `enabled`
+ * key (so newly-added notification types are opt-in by default),
+ * - and fall back to a defensive bool cast for unexpected scalars.
+ *
+ * @return array<string, array<mixed>>
+ */
+ public function provider_should_send_to_user_default(): array {
+ return array(
+ 'null pref means opt-in by default' => array( null, true ),
+ 'array with enabled true' => array( array( 'enabled' => true ), true ),
+ 'array with enabled false' => array( array( 'enabled' => false ), false ),
+ 'array missing enabled defaults to true' => array( array( 'min_value' => 500 ), true ),
+ 'empty array defaults to true' => array( array(), true ),
+ 'array with truthy enabled (1) is true' => array( array( 'enabled' => 1 ), true ),
+ 'array with falsy enabled (0) is false' => array( array( 'enabled' => 0 ), false ),
+ 'scalar bool true (defensive fallback)' => array( true, true ),
+ 'scalar bool false (defensive fallback)' => array( false, false ),
+ 'scalar truthy string (defensive fallback)' => array( '1', true ),
+ 'scalar empty string (defensive fallback)' => array( '', false ),
+ );
+ }
+
+ /**
+ * @testdox Default should_send_to_user with $_dataName returns $expected.
+ * @dataProvider provider_should_send_to_user_default
+ *
+ * @param mixed $pref_value The stored preference value.
+ * @param bool $expected The expected decision.
+ */
+ public function test_should_send_to_user_default_behavior( $pref_value, bool $expected ): void {
+ $notification = $this->getMockBuilder( NewOrderNotification::class )
+ ->setConstructorArgs( array( 1 ) )
+ ->onlyMethods( array( 'to_payload', 'has_meta', 'write_meta', 'delete_meta' ) )
+ ->getMock();
+
+ $this->assertSame( $expected, $notification->should_send_to_user( $pref_value ) );
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationProcessorTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationProcessorTest.php
index 632dad27a7d..8b6e66996cb 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationProcessorTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationProcessorTest.php
@@ -9,7 +9,9 @@ use Automattic\WooCommerce\Internal\PushNotifications\Dispatchers\WpcomNotificat
use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken;
use Automattic\WooCommerce\Internal\PushNotifications\Notifications\NewOrderNotification;
use Automattic\WooCommerce\Internal\PushNotifications\Notifications\NewReviewNotification;
+use Automattic\WooCommerce\Internal\PushNotifications\Notifications\Notification;
use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationPreferencesService;
use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationProcessor;
use WC_Helper_Product;
use WC_Unit_Test_Case;
@@ -40,6 +42,13 @@ class NotificationProcessorTest extends WC_Unit_Test_Case {
*/
private $data_store;
+ /**
+ * Mock preferences service.
+ *
+ * @var NotificationPreferencesService
+ */
+ private $preferences_service;
+
/**
* A test order ID.
*
@@ -53,12 +62,23 @@ class NotificationProcessorTest extends WC_Unit_Test_Case {
public function setUp(): void {
parent::setUp();
- $this->dispatcher = $this->createMock( WpcomNotificationDispatcher::class );
- $this->data_store = $this->createMock( PushTokensDataStore::class );
- $this->order_id = wc_create_order( array( 'status' => 'processing' ) )->get_id();
+ $this->dispatcher = $this->createMock( WpcomNotificationDispatcher::class );
+ $this->data_store = $this->createMock( PushTokensDataStore::class );
+ $this->preferences_service = $this->createMock( NotificationPreferencesService::class );
+ $this->order_id = wc_create_order( array( 'status' => 'processing' ) )->get_id();
$this->sut = new NotificationProcessor();
- $this->sut->init( $this->dispatcher, $this->data_store );
+ $this->sut->init( $this->dispatcher, $this->data_store, $this->preferences_service );
+
+ // By default every user has every notification type enabled, so existing
+ // tests behave as before. Per-user/per-type filtering is exercised in
+ // the dedicated preferences tests below.
+ $this->preferences_service->method( 'get_preferences' )->willReturn(
+ array(
+ 'store_order' => array( 'enabled' => true ),
+ 'store_review' => array( 'enabled' => true ),
+ )
+ );
$this->data_store->method( 'get_tokens_for_roles' )->willReturn(
array(
@@ -203,7 +223,7 @@ class NotificationProcessorTest extends WC_Unit_Test_Case {
$this->dispatcher->expects( $this->never() )->method( 'dispatch' );
$sut = new NotificationProcessor();
- $sut->init( $this->dispatcher, $data_store );
+ $sut->init( $this->dispatcher, $data_store, $this->preferences_service );
$notification = new NewOrderNotification( $this->order_id );
$result = $sut->process( $notification );
@@ -215,6 +235,258 @@ class NotificationProcessorTest extends WC_Unit_Test_Case {
$this->assertNotEmpty( $order->get_meta( NotificationProcessor::SENT_META_KEY ) );
}
+ /**
+ * @testdox Should mark as sent and skip dispatch when every owning user has the type disabled.
+ */
+ public function test_process_skips_dispatch_when_all_users_opted_out(): void {
+ $preferences_service = $this->createMock( NotificationPreferencesService::class );
+ $preferences_service->method( 'get_preferences' )->willReturn(
+ array(
+ 'store_order' => array( 'enabled' => false ),
+ 'store_review' => array( 'enabled' => true ),
+ )
+ );
+
+ $this->dispatcher->expects( $this->never() )->method( 'dispatch' );
+
+ $sut = new NotificationProcessor();
+ $sut->init( $this->dispatcher, $this->data_store, $preferences_service );
+
+ $notification = new NewOrderNotification( $this->order_id );
+ $result = $sut->process( $notification );
+
+ $this->assertTrue( $result );
+
+ $order = wc_get_order( $this->order_id );
+
+ $this->assertNotEmpty( $order->get_meta( NotificationProcessor::SENT_META_KEY ) );
+ }
+
+ /**
+ * @testdox Should dispatch only to tokens whose owning user has the notification type enabled.
+ */
+ public function test_process_filters_tokens_by_user_preferences(): void {
+ $enabled_token = new PushToken(
+ array(
+ 'user_id' => 1,
+ 'token' => 'enabled-token',
+ 'origin' => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+ 'platform' => PushToken::PLATFORM_APPLE,
+ 'device_locale' => 'en_US',
+ 'device_uuid' => 'enabled-uuid',
+ )
+ );
+ $disabled_token = new PushToken(
+ array(
+ 'user_id' => 2,
+ 'token' => 'disabled-token',
+ 'origin' => PushToken::ORIGIN_WOOCOMMERCE_ANDROID,
+ 'platform' => PushToken::PLATFORM_ANDROID,
+ 'device_locale' => 'en_US',
+ 'device_uuid' => 'disabled-uuid',
+ )
+ );
+
+ $data_store = $this->createMock( PushTokensDataStore::class );
+ $data_store->method( 'get_tokens_for_roles' )->willReturn(
+ array( $enabled_token, $disabled_token )
+ );
+
+ $preferences_service = $this->createMock( NotificationPreferencesService::class );
+ $preferences_service->method( 'get_preferences' )->willReturnCallback(
+ function ( int $user_id ) {
+ return array(
+ 'store_order' => array( 'enabled' => 1 === $user_id ),
+ 'store_review' => array( 'enabled' => true ),
+ );
+ }
+ );
+
+ $this->dispatcher
+ ->expects( $this->once() )
+ ->method( 'dispatch' )
+ ->with(
+ $this->anything(),
+ $this->callback(
+ function ( array $tokens ) use ( $enabled_token ) {
+ return 1 === count( $tokens ) && $tokens[0] === $enabled_token;
+ }
+ )
+ )
+ ->willReturn(
+ array(
+ 'success' => true,
+ 'retry_after' => null,
+ )
+ );
+
+ $sut = new NotificationProcessor();
+ $sut->init( $this->dispatcher, $data_store, $preferences_service );
+
+ $notification = new NewOrderNotification( $this->order_id );
+ $result = $sut->process( $notification );
+
+ $this->assertTrue( $result );
+ }
+
+ /**
+ * @testdox Should respect the notification type when filtering by preferences.
+ */
+ public function test_process_respects_preferences_per_notification_type(): void {
+ $preferences_service = $this->createMock( NotificationPreferencesService::class );
+ $preferences_service->method( 'get_preferences' )->willReturn(
+ array(
+ 'store_order' => array( 'enabled' => true ),
+ 'store_review' => array( 'enabled' => false ),
+ )
+ );
+
+ $this->dispatcher
+ ->expects( $this->once() )
+ ->method( 'dispatch' )
+ ->willReturn(
+ array(
+ 'success' => true,
+ 'retry_after' => null,
+ )
+ );
+
+ $sut = new NotificationProcessor();
+ $sut->init( $this->dispatcher, $this->data_store, $preferences_service );
+
+ // store_order is enabled — should dispatch.
+ $order_notification = new NewOrderNotification( $this->order_id );
+ $this->assertTrue( $sut->process( $order_notification ) );
+
+ // store_review is disabled — should mark sent without dispatching.
+ $product = WC_Helper_Product::create_simple_product();
+ $comment_id = wp_insert_comment(
+ array(
+ 'comment_post_ID' => $product->get_id(),
+ 'comment_type' => 'review',
+ 'comment_content' => 'Great!',
+ 'comment_author' => 'Tester',
+ )
+ );
+ $review_notification = new NewReviewNotification( $comment_id );
+
+ $this->assertTrue( $sut->process( $review_notification ) );
+ $this->assertNotEmpty(
+ get_comment_meta( $comment_id, NotificationProcessor::SENT_META_KEY, true )
+ );
+ }
+
+ /**
+ * @testdox Should look up preferences and decide once per user even when one user has multiple tokens.
+ */
+ public function test_process_memoizes_filter_decision_per_user(): void {
+ $tokens = array(
+ new PushToken(
+ array(
+ 'user_id' => 7,
+ 'token' => 'ios-token',
+ 'origin' => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+ 'platform' => PushToken::PLATFORM_APPLE,
+ 'device_locale' => 'en_US',
+ 'device_uuid' => 'ios-uuid',
+ )
+ ),
+ new PushToken(
+ array(
+ 'user_id' => 7,
+ 'token' => 'android-token',
+ 'origin' => PushToken::ORIGIN_WOOCOMMERCE_ANDROID,
+ 'platform' => PushToken::PLATFORM_ANDROID,
+ 'device_locale' => 'en_US',
+ 'device_uuid' => 'android-uuid',
+ )
+ ),
+ );
+
+ $data_store = $this->createMock( PushTokensDataStore::class );
+ $data_store->method( 'get_tokens_for_roles' )->willReturn( $tokens );
+
+ $preferences_service = $this->createMock( NotificationPreferencesService::class );
+ // One user, two tokens — preferences must be read at most once for that user.
+ $preferences_service->expects( $this->once() )
+ ->method( 'get_preferences' )
+ ->with( 7 )
+ ->willReturn( array( 'store_order' => array( 'enabled' => true ) ) );
+
+ $this->dispatcher
+ ->expects( $this->once() )
+ ->method( 'dispatch' )
+ ->with(
+ $this->anything(),
+ $this->callback(
+ static function ( array $dispatched ) {
+ return 2 === count( $dispatched );
+ }
+ )
+ )
+ ->willReturn(
+ array(
+ 'success' => true,
+ 'retry_after' => null,
+ )
+ );
+
+ $sut = new NotificationProcessor();
+ $sut->init( $this->dispatcher, $data_store, $preferences_service );
+
+ $this->assertTrue( $sut->process( new NewOrderNotification( $this->order_id ) ) );
+ }
+
+ /**
+ * Locks the delegation contract: the processor must consult
+ * {@see Notification::should_send_to_user()} for the per-user decision and
+ * pass it the raw stored preference value (so parametrized prefs like
+ * `['enabled' => true, 'min_value' => 500]` reach the subclass intact
+ * once the storage layer is widened to support them).
+ *
+ * @testdox Should delegate the filter decision to the notification, passing the user's stored pref value.
+ */
+ public function test_process_delegates_filter_decision_to_notification(): void {
+ $pref_value = array(
+ 'enabled' => true,
+ 'min_value' => 500,
+ );
+
+ $preferences_service = $this->createMock( NotificationPreferencesService::class );
+ $preferences_service->method( 'get_preferences' )->willReturn(
+ array( 'store_order' => $pref_value )
+ );
+
+ $notification = $this->getMockBuilder( Notification::class )
+ ->setConstructorArgs( array( $this->order_id ) )
+ ->onlyMethods(
+ array(
+ 'get_type',
+ 'to_payload',
+ 'has_meta',
+ 'write_meta',
+ 'delete_meta',
+ 'should_send_to_user',
+ )
+ )
+ ->getMock();
+ $notification->method( 'get_type' )->willReturn( 'store_order' );
+ $notification->method( 'has_meta' )->willReturn( false );
+ $notification->expects( $this->once() )
+ ->method( 'should_send_to_user' )
+ ->with( $this->equalTo( $pref_value ) )
+ ->willReturn( false );
+
+ $this->dispatcher->expects( $this->never() )->method( 'dispatch' );
+
+ $sut = new NotificationProcessor();
+ $sut->init( $this->dispatcher, $this->data_store, $preferences_service );
+
+ $result = $sut->process( $notification );
+
+ $this->assertTrue( $result );
+ }
+
/**
* @testdox Should write comment meta for review notifications.
*/