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.
 	 */