NotificationPreferencesService.php
4 weeks ago
NotificationProcessor.php
4 weeks ago
NotificationRetryHandler.php
4 weeks ago
PendingNotificationStore.php
4 weeks ago
NotificationProcessor.php
305 lines
| 1 | <?php |
| 2 | |
| 3 | declare( strict_types = 1 ); |
| 4 | |
| 5 | namespace Automattic\WooCommerce\Internal\PushNotifications\Services; |
| 6 | |
| 7 | defined( 'ABSPATH' ) || exit; |
| 8 | |
| 9 | use Automattic\WooCommerce\Internal\PushNotifications\DataStores\PushTokensDataStore; |
| 10 | use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken; |
| 11 | use Automattic\WooCommerce\Internal\PushNotifications\Dispatchers\WpcomNotificationDispatcher; |
| 12 | use Automattic\WooCommerce\Internal\PushNotifications\Notifications\Notification; |
| 13 | use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications; |
| 14 | use Exception; |
| 15 | |
| 16 | /** |
| 17 | * Shared orchestration for sending a single notification to WPCOM. |
| 18 | * |
| 19 | * Used by three callers: |
| 20 | * 1. PushNotificationRestController — loopback endpoint (is_retry: false) |
| 21 | * 2. ActionScheduler safety net — fallback when shutdown didn't fire (is_retry: true) |
| 22 | * 3. NotificationRetryHandler — retry for failed sends (is_retry: true) |
| 23 | * |
| 24 | * @since 10.7.0 |
| 25 | */ |
| 26 | class NotificationProcessor { |
| 27 | /** |
| 28 | * ActionScheduler group for push notification jobs. |
| 29 | */ |
| 30 | const ACTION_SCHEDULER_GROUP = 'wc-push-notifications'; |
| 31 | |
| 32 | /** |
| 33 | * Safety net delay in seconds. |
| 34 | */ |
| 35 | const SAFETY_NET_DELAY = 60; |
| 36 | |
| 37 | /** |
| 38 | * ActionScheduler hook for the safety net job. |
| 39 | */ |
| 40 | const SAFETY_NET_HOOK = 'wc_push_notification_safety_net'; |
| 41 | |
| 42 | /** |
| 43 | * Meta key written before the WPCOM send attempt. |
| 44 | */ |
| 45 | const CLAIMED_META_KEY = '_wc_push_notification_claimed'; |
| 46 | |
| 47 | /** |
| 48 | * Meta key written after successful WPCOM delivery. |
| 49 | */ |
| 50 | const SENT_META_KEY = '_wc_push_notification_sent'; |
| 51 | |
| 52 | /** |
| 53 | * The WPCOM dispatcher. |
| 54 | * |
| 55 | * @var WpcomNotificationDispatcher |
| 56 | */ |
| 57 | private WpcomNotificationDispatcher $dispatcher; |
| 58 | |
| 59 | /** |
| 60 | * The push tokens data store. |
| 61 | * |
| 62 | * @var PushTokensDataStore |
| 63 | */ |
| 64 | private PushTokensDataStore $data_store; |
| 65 | |
| 66 | /** |
| 67 | * The notification preferences service. |
| 68 | * |
| 69 | * @var NotificationPreferencesService |
| 70 | */ |
| 71 | private NotificationPreferencesService $preferences_service; |
| 72 | |
| 73 | /** |
| 74 | * The retry handler. |
| 75 | * |
| 76 | * @var NotificationRetryHandler |
| 77 | */ |
| 78 | private NotificationRetryHandler $retry_handler; |
| 79 | |
| 80 | /** |
| 81 | * Initialize dependencies. |
| 82 | * |
| 83 | * @internal |
| 84 | * |
| 85 | * @param WpcomNotificationDispatcher $dispatcher The WPCOM dispatcher. |
| 86 | * @param PushTokensDataStore $data_store The push tokens data store. |
| 87 | * @param NotificationPreferencesService $preferences_service The notification preferences service. |
| 88 | * @param NotificationRetryHandler $retry_handler The retry handler. |
| 89 | * |
| 90 | * @since 10.7.0 |
| 91 | */ |
| 92 | final public function init( |
| 93 | WpcomNotificationDispatcher $dispatcher, |
| 94 | PushTokensDataStore $data_store, |
| 95 | NotificationPreferencesService $preferences_service, |
| 96 | NotificationRetryHandler $retry_handler |
| 97 | ): void { |
| 98 | $this->dispatcher = $dispatcher; |
| 99 | $this->data_store = $data_store; |
| 100 | $this->preferences_service = $preferences_service; |
| 101 | $this->retry_handler = $retry_handler; |
| 102 | } |
| 103 | |
| 104 | /** |
| 105 | * Registers the ActionScheduler hook for the safety net job. |
| 106 | * |
| 107 | * @return void |
| 108 | * |
| 109 | * @since 10.7.0 |
| 110 | */ |
| 111 | public function register(): void { |
| 112 | add_action( self::SAFETY_NET_HOOK, array( $this, 'handle_safety_net' ), 10, 3 ); |
| 113 | } |
| 114 | |
| 115 | /** |
| 116 | * Processes a single notification: checks meta, sends to WPCOM, marks sent. |
| 117 | * |
| 118 | * @param Notification $notification The notification to process. |
| 119 | * @param bool $is_retry Whether this is a retry or safety net attempt. |
| 120 | * @param int $attempt The current attempt number (0 = first attempt). |
| 121 | * @return bool True if successfully sent (or already sent). |
| 122 | * |
| 123 | * @since 10.7.0 |
| 124 | */ |
| 125 | public function process( Notification $notification, bool $is_retry = false, int $attempt = 0 ): bool { |
| 126 | /** |
| 127 | * This notification has already been sent - don't continue. |
| 128 | */ |
| 129 | if ( $notification->has_meta( self::SENT_META_KEY ) ) { |
| 130 | return true; |
| 131 | } |
| 132 | |
| 133 | if ( ! $is_retry ) { |
| 134 | /** |
| 135 | * This notification has already been claimed for sending, and since |
| 136 | * this is not a retry, this is not expected and means some other |
| 137 | * process is handling the notification (e.g. race condition) - |
| 138 | * don't continue. |
| 139 | */ |
| 140 | if ( $notification->has_meta( self::CLAIMED_META_KEY ) ) { |
| 141 | return true; |
| 142 | } |
| 143 | |
| 144 | $notification->write_meta( self::CLAIMED_META_KEY ); |
| 145 | } |
| 146 | |
| 147 | /** |
| 148 | * Non-paginated result from get_tokens_for_roles. |
| 149 | * |
| 150 | * @var PushToken[] $tokens |
| 151 | */ |
| 152 | $tokens = $this->data_store->get_tokens_for_roles( |
| 153 | PushNotifications::ROLES_WITH_PUSH_NOTIFICATIONS_ENABLED |
| 154 | ); |
| 155 | |
| 156 | /** |
| 157 | * Filter out tokens whose owning user does not want this notification. |
| 158 | * The decision is delegated to the notification itself via |
| 159 | * {@see Notification::should_send_to_user()} so per-type preference |
| 160 | * shapes (simple bool today, parametrized arrays in the future) stay |
| 161 | * encapsulated alongside the type's resource access. |
| 162 | */ |
| 163 | $tokens = $this->filter_tokens_by_preferences( $tokens, $notification ); |
| 164 | |
| 165 | /** |
| 166 | * There are no recipients to send to (either no tokens at all, or |
| 167 | * every owning user opted out of this notification type). We don't |
| 168 | * want to retry as this isn't a 'recoverable error', so mark as sent |
| 169 | * and return. |
| 170 | */ |
| 171 | if ( empty( $tokens ) ) { |
| 172 | $notification->write_meta( self::SENT_META_KEY ); |
| 173 | $this->cancel_safety_net( $notification ); |
| 174 | return true; |
| 175 | } |
| 176 | |
| 177 | $result = $this->dispatcher->dispatch( $notification, $tokens ); |
| 178 | |
| 179 | if ( ! empty( $result['success'] ) ) { |
| 180 | $notification->write_meta( self::SENT_META_KEY ); |
| 181 | $notification->delete_meta( self::CLAIMED_META_KEY ); |
| 182 | $this->cancel_safety_net( $notification ); |
| 183 | return true; |
| 184 | } |
| 185 | |
| 186 | $this->retry_handler->schedule( $notification, $result['retry_after'] ?? null, $attempt ); |
| 187 | $this->cancel_safety_net( $notification ); |
| 188 | |
| 189 | return false; |
| 190 | } |
| 191 | |
| 192 | /** |
| 193 | * Returns the subset of $tokens whose owning user wants $notification. |
| 194 | * |
| 195 | * The decision is delegated to {@see Notification::should_send_to_user()} |
| 196 | * so per-type preference shapes (simple bool today, parametrized arrays |
| 197 | * in the future) stay encapsulated alongside the type's resource access. |
| 198 | * Tokens with no owning user are dropped — there are no preferences to |
| 199 | * consult. |
| 200 | * |
| 201 | * Decisions are memoized per user for the duration of one call, since |
| 202 | * the same user can have several registered tokens (iOS, iPad, Android, |
| 203 | * browser) and we don't want to re-read user meta or re-fetch the |
| 204 | * resource for every token. |
| 205 | * |
| 206 | * @param PushToken[] $tokens The tokens to filter. |
| 207 | * @param Notification $notification The notification being processed. |
| 208 | * |
| 209 | * @return PushToken[] The tokens whose owner wants the notification. |
| 210 | * |
| 211 | * @since 10.9.0 |
| 212 | */ |
| 213 | private function filter_tokens_by_preferences( array $tokens, Notification $notification ): array { |
| 214 | $type = $notification->get_type(); |
| 215 | $decision_cache = array(); |
| 216 | |
| 217 | return array_values( |
| 218 | array_filter( |
| 219 | $tokens, |
| 220 | function ( PushToken $token ) use ( $notification, $type, &$decision_cache ) { |
| 221 | $user_id = $token->get_user_id(); |
| 222 | if ( ! $user_id ) { |
| 223 | return false; |
| 224 | } |
| 225 | |
| 226 | if ( ! isset( $decision_cache[ $user_id ] ) ) { |
| 227 | $prefs = $this->preferences_service->get_preferences( $user_id ); |
| 228 | $decision_cache[ $user_id ] = $notification->should_send_to_user( $prefs[ $type ] ?? null ); |
| 229 | } |
| 230 | |
| 231 | return $decision_cache[ $user_id ]; |
| 232 | } |
| 233 | ) |
| 234 | ); |
| 235 | } |
| 236 | |
| 237 | /** |
| 238 | * Cancels the pending safety net ActionScheduler job for a notification. |
| 239 | * |
| 240 | * Called after the processor handles the notification (whether success or |
| 241 | * failure with retry scheduled) so the safety net doesn't fire redundantly. |
| 242 | * |
| 243 | * @param Notification $notification The notification whose safety net to cancel. |
| 244 | * @return void |
| 245 | * |
| 246 | * @since 10.9.0 |
| 247 | */ |
| 248 | private function cancel_safety_net( Notification $notification ): void { |
| 249 | // Must match the shape PendingNotificationStore::schedule_safety_net() used; |
| 250 | // both derive the args from Notification::get_safety_net_args() so the |
| 251 | // exact-equality match Action Scheduler performs succeeds. |
| 252 | as_unschedule_all_actions( |
| 253 | self::SAFETY_NET_HOOK, |
| 254 | $notification->get_safety_net_args(), |
| 255 | self::ACTION_SCHEDULER_GROUP |
| 256 | ); |
| 257 | } |
| 258 | |
| 259 | /** |
| 260 | * ActionScheduler callback for the safety net job. This will be scheduled |
| 261 | * for 60 seconds in the future when a notification is added to the |
| 262 | * `PendingNotificationStore`. If the initial send succeeds, or fails and is |
| 263 | * able to schedule a retry, this action will be unscheduled. If the initial |
| 264 | * send does not occur, or fails and cannot schedule a retry (e.g. out of |
| 265 | * memory, retry scheduling error) then this safety net will run. |
| 266 | * |
| 267 | * @param string $type The notification type. |
| 268 | * @param int $resource_id The resource ID. |
| 269 | * @param array $extra Optional subclass-specific extras (e.g. event_type, stock_quantity_at_trigger). |
| 270 | * Empty for notification types whose state is fully described by type + resource_id. |
| 271 | * @return void |
| 272 | * |
| 273 | * @since 10.7.0 |
| 274 | */ |
| 275 | public function handle_safety_net( string $type, int $resource_id, array $extra = array() ): void { |
| 276 | try { |
| 277 | // Use the `+` array union operator (not array_merge) so the positional |
| 278 | // $type and $resource_id always win over any colliding keys in $extra. |
| 279 | // Defends against a malformed payload reconstructing the wrong target. |
| 280 | $data = array( |
| 281 | 'type' => $type, |
| 282 | 'resource_id' => $resource_id, |
| 283 | ) + $extra; |
| 284 | |
| 285 | $notification = Notification::from_array( $data ); |
| 286 | } catch ( Exception $e ) { |
| 287 | wc_get_logger()->error( |
| 288 | sprintf( 'Safety net failed: %s', $e->getMessage() ), |
| 289 | array( 'source' => PushNotifications::FEATURE_NAME ) |
| 290 | ); |
| 291 | return; |
| 292 | } |
| 293 | |
| 294 | try { |
| 295 | $this->process( $notification, true ); |
| 296 | } catch ( Exception $e ) { |
| 297 | wc_get_logger()->error( |
| 298 | sprintf( 'Safety net failed: %s', $e->getMessage() ), |
| 299 | array( 'source' => PushNotifications::FEATURE_NAME ) |
| 300 | ); |
| 301 | $this->retry_handler->schedule( $notification, null, 0 ); |
| 302 | } |
| 303 | } |
| 304 | } |
| 305 |