• File: NotificationProcessor.php
  • Full Path: /home/slvgwrnz/public_html/wp-content/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationProcessor.php
  • Date Modified: 10/26/2026 10:38 AM
  • File size: 16.75 KB
  • MIME-type: text/x-php
  • Charset: utf-8
<?php

declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\PushNotifications\Services;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Internal\PushNotifications\DataStores\PushTokensDataStore;
use Automattic\WooCommerce\Internal\PushNotifications\Dispatchers\WpcomNotificationDispatcher;
use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken;
use Automattic\WooCommerce\Internal\PushNotifications\Enums\SuppressionReason;
use Automattic\WooCommerce\Internal\PushNotifications\Notifications\Notification;
use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
use Exception;

/**
 * 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)
 *
 * @since 10.7.0
 */
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 recording when the notification was triggered.
	 *
	 * Captured by {@see PendingNotificationStore::add()} at the moment the store
	 * event fires, persisted on shutdown, and read back when the payload is
	 * built, which can happen much later (ActionScheduler safety net, retries).
	 * Persisting it on the resource means every send path reports the true
	 * event time. Cleared alongside the claimed marker by
	 * {@see Notification::reset_processing_meta()} once the notification is finished.
	 *
	 * @since 11.2.0
	 */
	const TRIGGERED_META_KEY = '_wc_push_notification_triggered';

	/**
	 * 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 most recipient token IDs recorded on a `recipients` line.
	 *
	 * Set to the number the Remote Push Notification Proxy delivers to, since
	 * it drops everything after the first 100 tokens in a request, so IDs past
	 * that one could not have received the notification. The full count is
	 * recorded beside the list either way.
	 */
	const RECIPIENT_ID_CAP = 100;

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

	/**
	 * The step logger.
	 *
	 * @var NotificationStepLogger
	 */
	private NotificationStepLogger $step_logger;

	/**
	 * 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.
	 * @param NotificationStepLogger         $step_logger         The step logger.
	 *
	 * @since 10.7.0
	 */
	final public function init(
		WpcomNotificationDispatcher $dispatcher,
		PushTokensDataStore $data_store,
		NotificationPreferencesService $preferences_service,
		NotificationRetryHandler $retry_handler,
		NotificationStepLogger $step_logger
	): void {
		$this->dispatcher          = $dispatcher;
		$this->data_store          = $data_store;
		$this->preferences_service = $preferences_service;
		$this->retry_handler       = $retry_handler;
		$this->step_logger         = $step_logger;
	}

	/**
	 * 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 {
		$step_context = array(
			'attempt'  => $attempt,
			'is_retry' => $is_retry,
		);

		/**
		 * This notification has already been sent - don't continue.
		 */
		if ( $notification->has_meta( self::SENT_META_KEY ) ) {
			$this->step_logger->log_notification_step( $notification, 'skipped', 'already_sent', $step_context );
			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 ) ) {
				$this->step_logger->log_notification_step( $notification, 'skipped', 'already_claimed', $step_context );
				return true;
			}

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

		/**
		 * Non-paginated result from get_tokens_for_roles.
		 *
		 * @var PushToken[] $eligible_tokens
		 */
		$eligible_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::get_suppression_reason()} so per-type preference
		 * shapes (simple bool today, parametrized arrays in the future) stay
		 * encapsulated alongside the type's resource access.
		 */
		list( $tokens, $suppression_reasons ) = $this->filter_tokens_by_preferences( $eligible_tokens, $notification );

		$this->log_recipients( $notification, $eligible_tokens, $tokens, $suppression_reasons, $step_context );

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

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

		$this->log_send_outcome( $notification, $tokens, $result, $step_context );

		if ( ! empty( $result['success'] ) ) {
			// Success only, for the reason {@see PushToken::get_last_sent_at_gmt()} gives.
			$this->data_store->record_last_sent_at( $tokens );

			$notification->write_meta( self::SENT_META_KEY );
			$notification->reset_processing_meta();
			$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::get_suppression_reason()}
	 * 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.
	 *
	 * Each dropped token is returned with the reason, so the step log can say
	 * why a device did not receive the notification.
	 *
	 * @param PushToken[]  $tokens       The tokens to filter.
	 * @param Notification $notification The notification being processed.
	 *
	 * @return array{0: PushToken[], 1: array<int, string>} The tokens whose owner wants the notification, and the reason each dropped token was excluded, as token ID => reason.
	 *
	 * @since 10.9.0
	 */
	private function filter_tokens_by_preferences( array $tokens, Notification $notification ): array {
		$type                = $notification->get_type();
		$decision_cache      = array();
		$kept                = array();
		$suppression_reasons = array();

		foreach ( $tokens as $token ) {
			$user_id = $token->get_user_id();
			if ( ! $user_id ) {
				$suppression_reasons[ (int) $token->get_id() ] = SuppressionReason::NO_ACCOUNT;
				continue;
			}

			if ( ! array_key_exists( $user_id, $decision_cache ) ) {
				$pref_value                 = $this->preferences_service->get_preferences( $user_id )[ $type ] ?? null;
				$decision_cache[ $user_id ] = $notification->get_suppression_reason( $pref_value );
			}

			if ( null === $decision_cache[ $user_id ] ) {
				$kept[] = $token;
			} else {
				$suppression_reasons[ (int) $token->get_id() ] = $decision_cache[ $user_id ];
			}
		}

		return array( $kept, $suppression_reasons );
	}

	/**
	 * Writes the recipient decision to the step log: one line naming the
	 * recipients, and one line naming the tokens that were excluded.
	 *
	 * A store can hold thousands of tokens, and a log source has a daily size
	 * ceiling past which its oldest lines are overwritten without a trace, so
	 * both lines name their tokens in arrays.
	 *
	 * @param Notification       $notification        The notification being processed.
	 * @param PushToken[]        $eligible_tokens     Tokens whose owner has a role that receives push notifications.
	 * @param PushToken[]        $recipients          The tokens the notification will be sent to.
	 * @param array<int, string> $suppression_reasons The reason each dropped token was excluded, as token ID => reason.
	 * @param array              $step_context        Fields shared by every line of this attempt.
	 * @return void
	 */
	private function log_recipients(
		Notification $notification,
		array $eligible_tokens,
		array $recipients,
		array $suppression_reasons,
		array $step_context
	): void {
		if ( ! $this->step_logger->is_active() ) {
			return;
		}

		$recipient_ids = array_map( fn( PushToken $token ) => (int) $token->get_id(), $recipients );

		$context = array_merge(
			$step_context,
			array(
				'tokens_total'      => $this->data_store->count_tokens(),
				'tokens_eligible'   => count( $eligible_tokens ),
				'recipients'        => count( $recipients ),
				'held_back'         => count( $suppression_reasons ),
				'held_back_reasons' => array_count_values( $suppression_reasons ),
			)
		);

		if ( empty( $eligible_tokens ) ) {
			$this->step_logger->log_notification_step( $notification, 'recipients', 'none', $context );
		} elseif ( empty( $recipients ) ) {
			$this->step_logger->log_notification_step( $notification, 'recipients', 'all_excluded', $context );
		} else {
			$context['token_ids'] = array_slice( $recipient_ids, 0, self::RECIPIENT_ID_CAP );
			$this->step_logger->log_notification_step( $notification, 'recipients', 'resolved', $context );
		}

		$this->log_excluded_tokens( $notification, $suppression_reasons, $step_context );
	}

	/**
	 * Writes one line naming every token the notification was not sent to,
	 * grouped by the reason each was excluded.
	 *
	 * Grouping by reason rather than naming a reason per token keeps the line
	 * small on a store where most tokens are excluded.
	 *
	 * @param Notification       $notification        The notification being processed.
	 * @param array<int, string> $suppression_reasons The reason each dropped token was excluded, as token ID => reason.
	 * @param array              $step_context        Fields shared by every line of this attempt.
	 * @return void
	 */
	private function log_excluded_tokens( Notification $notification, array $suppression_reasons, array $step_context ): void {
		if ( empty( $suppression_reasons ) ) {
			return;
		}

		$by_reason = array();
		foreach ( $suppression_reasons as $token_id => $reason ) {
			$by_reason[ $reason ][] = (int) $token_id;
		}

		$outcome = 'various';
		if ( 1 === count( $by_reason ) ) {
			$outcome = (string) array_key_first( $by_reason );
		}

		$this->step_logger->log_suppressed_step(
			$notification,
			'token_excluded',
			$outcome,
			array_merge( $step_context, array( 'excluded_tokens' => $by_reason ) )
		);
	}

	/**
	 * Writes the send outcome WPCOM reported, naming the tokens it refused.
	 *
	 * Every recipient carries the notification's own outcome except the ones
	 * WPCOM named as invalid, so the refused IDs are the whole difference.
	 *
	 * @param Notification $notification The notification being processed.
	 * @param PushToken[]  $recipients   The tokens the notification was sent to.
	 * @param array        $result       The dispatcher's return value.
	 * @param array        $step_context Fields shared by every line of this attempt.
	 * @return void
	 */
	private function log_send_outcome( Notification $notification, array $recipients, array $result, array $step_context ): void {
		if ( ! $this->step_logger->is_active() ) {
			return;
		}

		$invalid_tokens = array_flip( $result['invalid_tokens'] ?? array() );

		if ( empty( $invalid_tokens ) ) {
			return;
		}

		$invalid_ids = array();
		foreach ( $recipients as $token ) {
			if ( isset( $invalid_tokens[ $token->get_token() ] ) ) {
				$invalid_ids[] = (int) $token->get_id();
			}
		}

		$this->step_logger->log_notification_step(
			$notification,
			'dispatched',
			'invalid_token',
			array_merge( $step_context, array( 'invalid_token_ids' => $invalid_ids ) )
		);
	}

	/**
	 * 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       Identity fields from {@see Notification::get_identity_data()}.
	 *                            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 ) {
			$this->step_logger->log_unattributed_failure(
				'fallback',
				'invalid_notification',
				'error',
				sprintf( 'Fallback failed: %s', $e->getMessage() ),
				array(
					'type'        => $type,
					'resource_id' => $resource_id,
				)
			);
			return;
		}

		$this->step_logger->log_notification_step( $notification, 'fallback', 'fired' );

		try {
			$this->process( $notification, true );
		} catch ( Exception $e ) {
			$this->step_logger->log_failure(
				$notification,
				'fallback',
				'exception',
				'error',
				sprintf( 'Fallback failed: %s', $e->getMessage() )
			);
			$this->retry_handler->schedule( $notification, null, 0 );
		}
	}
}