Automattic\WooCommerce\Internal\PushNotifications\Services

NotificationProcessor{}WC 10.7.0

Shared orchestration for sending a single notification to WPCOM.

Used by three callers:

  1. PushNotificationRestController — loopback endpoint (is_retry: false)
  2. ActionScheduler safety net — fallback when shutdown didn't fire (is_retry: true)
  3. NotificationRetryHandler — retry for failed sends (is_retry: true)

No Hooks.

Usage

$NotificationProcessor = new NotificationProcessor();
// use class methods

Methods

  1. public handle_safety_net( string $type, int $resource_id, array $extra = array() )
  2. public init(
  3. public process( Notification $notification, bool $is_retry = false, int $attempt = 0 )
  4. public register()
  5. private cancel_safety_net( Notification $notification )
  6. private filter_tokens_by_preferences( array $tokens, Notification $notification )

Changelog

Since 10.7.0 Introduced.

NotificationProcessor{} code WC 10.9.1

class NotificationProcessor {
	/**
	 * ActionScheduler group for push notification jobs.
	 */
	const ACTION_SCHEDULER_GROUP = 'wc-push-notifications';

	/**
	 * Safety net delay in seconds.
	 */
	const SAFETY_NET_DELAY = 60;

	/**
	 * ActionScheduler hook for the safety net job.
	 */
	const SAFETY_NET_HOOK = 'wc_push_notification_safety_net';

	/**
	 * Meta key written before the WPCOM send attempt.
	 */
	const CLAIMED_META_KEY = '_wc_push_notification_claimed';

	/**
	 * Meta key written after successful WPCOM delivery.
	 */
	const SENT_META_KEY = '_wc_push_notification_sent';

	/**
	 * The WPCOM dispatcher.
	 *
	 * @var WpcomNotificationDispatcher
	 */
	private WpcomNotificationDispatcher $dispatcher;

	/**
	 * The push tokens data store.
	 *
	 * @var PushTokensDataStore
	 */
	private PushTokensDataStore $data_store;

	/**
	 * The notification preferences service.
	 *
	 * @var NotificationPreferencesService
	 */
	private NotificationPreferencesService $preferences_service;

	/**
	 * The retry handler.
	 *
	 * @var NotificationRetryHandler
	 */
	private NotificationRetryHandler $retry_handler;

	/**
	 * Initialize dependencies.
	 *
	 * @internal
	 *
	 * @param WpcomNotificationDispatcher    $dispatcher          The WPCOM dispatcher.
	 * @param PushTokensDataStore            $data_store          The push tokens data store.
	 * @param NotificationPreferencesService $preferences_service The notification preferences service.
	 * @param NotificationRetryHandler       $retry_handler The retry handler.
	 *
	 * @since 10.7.0
	 */
	final public function init(
		WpcomNotificationDispatcher $dispatcher,
		PushTokensDataStore $data_store,
		NotificationPreferencesService $preferences_service,
		NotificationRetryHandler $retry_handler
	): void {
		$this->dispatcher          = $dispatcher;
		$this->data_store          = $data_store;
		$this->preferences_service = $preferences_service;
		$this->retry_handler       = $retry_handler;
	}

	/**
	 * Registers the ActionScheduler hook for the safety net job.
	 *
	 * @return void
	 *
	 * @since 10.7.0
	 */
	public function register(): void {
		add_action( self::SAFETY_NET_HOOK, array( $this, 'handle_safety_net' ), 10, 3 );
	}

	/**
	 * Processes a single notification: checks meta, sends to WPCOM, marks sent.
	 *
	 * @param Notification $notification The notification to process.
	 * @param bool         $is_retry     Whether this is a retry or safety net attempt.
	 * @param int          $attempt      The current attempt number (0 = first attempt).
	 * @return bool True if successfully sent (or already sent).
	 *
	 * @since 10.7.0
	 */
	public function process( Notification $notification, bool $is_retry = false, int $attempt = 0 ): bool {
		/**
		 * This notification has already been sent - don't continue.
		 */
		if ( $notification->has_meta( self::SENT_META_KEY ) ) {
			return true;
		}

		if ( ! $is_retry ) {
			/**
			 * This notification has already been claimed for sending, and since
			 * this is not a retry, this is not expected and means some other
			 * process is handling the notification (e.g. race condition) -
			 * don't continue.
			 */
			if ( $notification->has_meta( self::CLAIMED_META_KEY ) ) {
				return true;
			}

			$notification->write_meta( self::CLAIMED_META_KEY );
		}

		/**
		 * Non-paginated result from get_tokens_for_roles.
		 *
		 * @var PushToken[] $tokens
		 */
		$tokens = $this->data_store->get_tokens_for_roles(
			PushNotifications::ROLES_WITH_PUSH_NOTIFICATIONS_ENABLED
		);

		/**
		 * 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 );
			$this->cancel_safety_net( $notification );
			return true;
		}

		$result = $this->dispatcher->dispatch( $notification, $tokens );

		if ( ! empty( $result['success'] ) ) {
			$notification->write_meta( self::SENT_META_KEY );
			$notification->delete_meta( self::CLAIMED_META_KEY );
			$this->cancel_safety_net( $notification );
			return true;
		}

		$this->retry_handler->schedule( $notification, $result['retry_after'] ?? null, $attempt );
		$this->cancel_safety_net( $notification );

		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.9.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 ];
				}
			)
		);
	}

	/**
	 * Cancels the pending safety net ActionScheduler job for a notification.
	 *
	 * Called after the processor handles the notification (whether success or
	 * failure with retry scheduled) so the safety net doesn't fire redundantly.
	 *
	 * @param Notification $notification The notification whose safety net to cancel.
	 * @return void
	 *
	 * @since 10.9.0
	 */
	private function cancel_safety_net( Notification $notification ): void {
		// Must match the shape PendingNotificationStore::schedule_safety_net() used;
		// both derive the args from Notification::get_safety_net_args() so the
		// exact-equality match Action Scheduler performs succeeds.
		as_unschedule_all_actions(
			self::SAFETY_NET_HOOK,
			$notification->get_safety_net_args(),
			self::ACTION_SCHEDULER_GROUP
		);
	}

	/**
	 * ActionScheduler callback for the safety net job. This will be scheduled
	 * for 60 seconds in the future when a notification is added to the
	 * `PendingNotificationStore`. If the initial send succeeds, or fails and is
	 * able to schedule a retry, this action will be unscheduled. If the initial
	 * send does not occur, or fails and cannot schedule a retry (e.g. out of
	 * memory, retry scheduling error) then this safety net will run.
	 *
	 * @param string $type        The notification type.
	 * @param int    $resource_id The resource ID.
	 * @param array  $extra       Optional subclass-specific extras (e.g. event_type, stock_quantity_at_trigger).
	 *                            Empty for notification types whose state is fully described by type + resource_id.
	 * @return void
	 *
	 * @since 10.7.0
	 */
	public function handle_safety_net( string $type, int $resource_id, array $extra = array() ): void {
		try {
			// Use the `+` array union operator (not array_merge) so the positional
			// $type and $resource_id always win over any colliding keys in $extra.
			// Defends against a malformed payload reconstructing the wrong target.
			$data = array(
				'type'        => $type,
				'resource_id' => $resource_id,
			) + $extra;

			$notification = Notification::from_array( $data );
		} catch ( Exception $e ) {
			wc_get_logger()->error(
				sprintf( 'Safety net failed: %s', $e->getMessage() ),
				array( 'source' => PushNotifications::FEATURE_NAME )
			);
			return;
		}

		try {
			$this->process( $notification, true );
		} catch ( Exception $e ) {
			wc_get_logger()->error(
				sprintf( 'Safety net failed: %s', $e->getMessage() ),
				array( 'source' => PushNotifications::FEATURE_NAME )
			);
			$this->retry_handler->schedule( $notification, null, 0 );
		}
	}
}