diff --git a/includes/class-action-scheduler.php b/includes/class-action-scheduler.php new file mode 100644 index 0000000000..2a934405a4 --- /dev/null +++ b/includes/class-action-scheduler.php @@ -0,0 +1,128 @@ +prefix . 'actionscheduler_groups'; + return $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->prepare( + "SELECT slug FROM {$table} WHERE slug LIKE %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $wpdb->esc_like( $prefix ) . '%' + ) + ); + } + + /** + * Query ActionScheduler actions by group slugs. + * + * @param array $args { + * Query arguments. + * + * @type string[] $groups Array of group slugs to query. + * @type string $status ActionScheduler status (pending, complete, failed, canceled). + * @type int $per_page Number of actions to return. Default 20. + * @type int $offset Offset for pagination. Default 0. + * @type string $orderby Column to order by. Default 'scheduled_date_gmt'. + * @type string $order ASC or DESC. Default 'DESC'. + * } + * + * @return array Array of action row objects. + */ + public static function get_scheduled_actions( $args = [] ) { + if ( ! self::is_available() ) { + return []; + } + global $wpdb; + + $defaults = [ + 'groups' => [], + 'status' => '', + 'per_page' => 20, + 'offset' => 0, + 'orderby' => 'scheduled_date_gmt', + 'order' => 'DESC', + ]; + $args = wp_parse_args( $args, $defaults ); + + $slugs = $args['groups']; + if ( empty( $slugs ) ) { + $slugs = array_merge( + [ self::DEFAULT_GROUP ], + self::get_groups_by_prefix( self::GROUP_PREFIX ) + ); + } + if ( empty( $slugs ) ) { + return []; + } + + $allowed_orderby = [ 'scheduled_date_gmt', 'action_id', 'hook', 'status' ]; + $orderby = in_array( $args['orderby'], $allowed_orderby, true ) ? $args['orderby'] : 'scheduled_date_gmt'; + $order = 'ASC' === strtoupper( $args['order'] ) ? 'ASC' : 'DESC'; + + $actions_table = $wpdb->prefix . 'actionscheduler_actions'; + $groups_table = $wpdb->prefix . 'actionscheduler_groups'; + $slug_placeholders = implode( ',', array_fill( 0, count( $slugs ), '%s' ) ); + $prepare_args = $slugs; + + // Build optional status filter. + $status_clause = ''; + if ( ! empty( $args['status'] ) ) { + $status_clause = 'AND a.status = %s '; + $prepare_args[] = $args['status']; + } + + $prepare_args[] = absint( $args['per_page'] ); + $prepare_args[] = absint( $args['offset'] ); + + // Table names: $wpdb->prefix + hardcoded strings. $orderby/$order: allowlist/ternary validated. + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $sql = "SELECT a.* FROM {$actions_table} a INNER JOIN {$groups_table} g ON a.group_id = g.group_id WHERE g.slug IN ({$slug_placeholders}) {$status_clause}ORDER BY a.{$orderby} {$order} LIMIT %d OFFSET %d"; + + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber + $query = $wpdb->prepare( $sql, ...$prepare_args ); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared + return $wpdb->get_results( $query ); + } +} diff --git a/includes/class-newspack.php b/includes/class-newspack.php index bbe294412e..892a32e80a 100644 --- a/includes/class-newspack.php +++ b/includes/class-newspack.php @@ -105,6 +105,7 @@ private function includes() { include_once NEWSPACK_ABSPATH . 'includes/reader-activation/sync/class-woocommerce.php'; include_once NEWSPACK_ABSPATH . 'includes/reader-activation/sync/class-contact-sync.php'; include_once NEWSPACK_ABSPATH . 'includes/reader-activation/sync/class-contact-sync-admin.php'; + include_once NEWSPACK_ABSPATH . 'includes/class-action-scheduler.php'; include_once NEWSPACK_ABSPATH . 'includes/reader-activation/class-integrations.php'; \Newspack\Reader_Activation\Integrations::init(); include_once NEWSPACK_ABSPATH . 'includes/data-events/class-utils.php'; diff --git a/includes/data-events/class-data-events.php b/includes/data-events/class-data-events.php index 2c7ce08129..bc35db66dd 100644 --- a/includes/data-events/class-data-events.php +++ b/includes/data-events/class-data-events.php @@ -624,6 +624,28 @@ public static function execute_queued_dispatches() { self::$queued_dispatches = []; } + /** + * Get the ActionScheduler group for a handler. + * + * Returns a filterable default of 'newspack'. Integrations or other + * systems can filter this to assign handlers to specific groups. + * + * @param string $class The handler class name. + * @param string $action_name The data event action name. + * + * @return string The ActionScheduler group name. + */ + public static function get_handler_action_group( $class, $action_name ) { + /** + * Filters the ActionScheduler group for a data event handler. + * + * @param string $group The group name. Default 'newspack'. + * @param string $class The handler class name. + * @param string $action_name The data event action name. + */ + return \apply_filters( 'newspack_data_events_handler_action_group', Action_Scheduler::DEFAULT_GROUP, $class, $action_name ); + } + /** * Dispatch queued events via Action Scheduler. * @@ -634,7 +656,7 @@ private static function dispatch_via_action_scheduler() { \as_enqueue_async_action( self::DISPATCH_AS_HOOK, [ self::$queued_dispatches ], - 'newspack' + Action_Scheduler::DEFAULT_GROUP ); self::log( sprintf( 'Scheduled %d dispatch(es) via Action Scheduler.', count( self::$queued_dispatches ) ) ); @@ -810,11 +832,16 @@ private static function schedule_handler_retry( $handler, $action_name, $timesta 'reason' => $error->getMessage(), ]; + $group = self::get_handler_action_group( + is_array( $handler ) ? $handler[0] : '', + $action_name + ); + $action_id = \as_schedule_single_action( time() + $backoff_seconds, self::HANDLER_RETRY_HOOK, [ $retry_data ], - 'newspack' + $group ); if ( $action_id ) { diff --git a/includes/data-events/class-webhooks.php b/includes/data-events/class-webhooks.php index fee742d302..e4be5524c1 100644 --- a/includes/data-events/class-webhooks.php +++ b/includes/data-events/class-webhooks.php @@ -732,7 +732,7 @@ private static function schedule_request( $request_id, $delay = 1 ) { $time, 'newspack_webhooks_as_process_request', [ $request_id ], - 'newspack', + 'newspack-webhooks', false, self::get_request_priority( $request_id ) ); diff --git a/includes/reader-activation/class-integrations.php b/includes/reader-activation/class-integrations.php index 76558804ec..9f7399e475 100644 --- a/includes/reader-activation/class-integrations.php +++ b/includes/reader-activation/class-integrations.php @@ -73,10 +73,105 @@ public static function init() { add_action( 'init', [ __CLASS__, 'register_integrations' ], 5 ); add_action( 'init', [ __CLASS__, 'schedule_health_check' ] ); add_action( self::HEALTH_CHECK_CRON_HOOK, [ __CLASS__, 'run_health_checks' ] ); + add_filter( 'newspack_data_events_handler_action_group', [ __CLASS__, 'filter_handler_action_group' ], 10, 3 ); Integrations\Contact_Pull::init(); } + /** + * Get the ActionScheduler group name for a specific integration. + * + * @param string $integration_id The integration ID. + * + * @return string The group name (e.g., 'newspack-integration-esp'). + */ + public static function get_action_group( $integration_id ) { + return \Newspack\Action_Scheduler::GROUP_PREFIX . 'integration-' . $integration_id; + } + + /** + * Resolve the ActionScheduler group for a data event handler. + * + * Looks up the handler in the internal handler map and returns the + * per-integration group, or null if the handler is not registered + * through an integration. + * + * @param string $class The handler class name. + * @param string $action_name The data event action name. + * + * @return string|null The group name or null if the handler is not registered through an integration. + */ + public static function get_action_group_for_handler( $class, $action_name ) { + $key = $class . '::' . $action_name; + if ( isset( self::$handler_map[ $key ] ) ) { + return self::get_action_group( self::$handler_map[ $key ]['integration_id'] ); + } + return null; + } + + /** + * Filter the ActionScheduler group for a data event handler. + * + * Hooked to 'newspack_data_events_handler_action_group' to assign + * integration-specific groups to handlers registered through integrations. + * + * @param string $group The default group name. + * @param string $class The handler class name. + * @param string $action_name The data event action name. + * + * @return string The filtered group name. + */ + public static function filter_handler_action_group( $group, $class, $action_name ) { + return self::get_action_group_for_handler( $class, $action_name ) ?? $group; + } + + /** + * Get all ActionScheduler group slugs for Newspack integrations. + * + * @return string[] Array of group slug strings. + */ + public static function get_all_action_groups() { + return \Newspack\Action_Scheduler::get_groups_by_prefix( \Newspack\Action_Scheduler::GROUP_PREFIX . 'integration-' ); + } + + /** + * Get ActionScheduler actions for Newspack integrations. + * + * @param array $args { + * Optional. Query arguments. + * + * @type string $integration_id Filter by a single integration ID. + * @type string $status ActionScheduler status (pending, complete, failed, canceled). + * @type int $per_page Number of actions to return. Default 20. + * @type int $offset Offset for pagination. Default 0. + * @type string $orderby Column to order by. Default 'scheduled_date_gmt'. + * @type string $order ASC or DESC. Default 'DESC'. + * } + * + * @return array Array of action row objects. + */ + public static function get_scheduled_actions( $args = [] ) { + $defaults = [ + 'integration_id' => '', + ]; + $args = wp_parse_args( $args, $defaults ); + + // Resolve integration_id to group slugs. + if ( ! empty( $args['integration_id'] ) ) { + $args['groups'] = [ self::get_action_group( $args['integration_id'] ) ]; + } else { + $args['groups'] = self::get_all_action_groups(); + } + unset( $args['integration_id'] ); + + // No groups to query, return empty array. + if ( empty( $args['groups'] ) ) { + return []; + } + + return \Newspack\Action_Scheduler::get_scheduled_actions( $args ); + } + /** * Register integrations. */ diff --git a/includes/reader-activation/integrations/class-contact-pull.php b/includes/reader-activation/integrations/class-contact-pull.php index 66a2f6e96a..0c13bae40a 100644 --- a/includes/reader-activation/integrations/class-contact-pull.php +++ b/includes/reader-activation/integrations/class-contact-pull.php @@ -287,14 +287,16 @@ private static function schedule_async_pulls( $user_id, $integrations ) { ], ]; - if ( function_exists( 'as_has_scheduled_action' ) && \as_has_scheduled_action( self::ASYNC_PULL_HOOK, $args, 'newspack' ) ) { + $group = Integrations::get_action_group( $integration->get_id() ); + + if ( function_exists( 'as_has_scheduled_action' ) && \as_has_scheduled_action( self::ASYNC_PULL_HOOK, $args, $group ) ) { continue; } \as_enqueue_async_action( self::ASYNC_PULL_HOOK, $args, - 'newspack' + $group ); } } diff --git a/includes/reader-activation/integrations/class-integration.php b/includes/reader-activation/integrations/class-integration.php index 54830c5438..60774ffe39 100644 --- a/includes/reader-activation/integrations/class-integration.php +++ b/includes/reader-activation/integrations/class-integration.php @@ -249,6 +249,27 @@ final public function health_check() { return true; } + /** + * Get the ActionScheduler group name for this integration. + * + * @return string The group name (e.g., 'newspack-integration-esp'). + */ + final public function get_action_group() { + return Integrations::get_action_group( $this->id ); + } + + /** + * Get ActionScheduler actions for this integration. + * + * @param array $args Optional. Query arguments (status, per_page, offset, orderby, order). + * + * @return array Array of action row objects. + */ + final public function get_scheduled_actions( $args = [] ) { + $args['integration_id'] = $this->id; + return Integrations::get_scheduled_actions( $args ); + } + /** * Get the enabled outgoing metadata fields for this integration. * diff --git a/includes/reader-activation/sync/class-contact-sync-admin.php b/includes/reader-activation/sync/class-contact-sync-admin.php index eea91ea48c..d84c049ae7 100644 --- a/includes/reader-activation/sync/class-contact-sync-admin.php +++ b/includes/reader-activation/sync/class-contact-sync-admin.php @@ -136,7 +136,7 @@ public static function handle_bulk_actions( $sendback, $doaction, $items ) { \wp_die( \esc_html__( 'You do not have permission to do that.', 'newspack-plugin' ) ); } foreach ( $items as $user_id ) { - as_schedule_single_action( time(), 'newspack_sync_admin_batch', [ 'user_id' => $user_id ] ); + as_schedule_single_action( time(), 'newspack_sync_admin_batch', [ 'user_id' => $user_id ], 'newspack-sync' ); } $sendback = \add_query_arg( [ diff --git a/includes/reader-activation/sync/class-contact-sync.php b/includes/reader-activation/sync/class-contact-sync.php index 43b84e0229..a04a8f3dee 100644 --- a/includes/reader-activation/sync/class-contact-sync.php +++ b/includes/reader-activation/sync/class-contact-sync.php @@ -256,7 +256,7 @@ private static function schedule_integration_retry( $integration_id, $contact, $ time() + $backoff_seconds, self::RETRY_HOOK, [ $retry_data ], - 'newspack' + Integrations::get_action_group( $integration_id ) ); static::log( diff --git a/tests/unit-tests/integrations/class-test-integrations.php b/tests/unit-tests/integrations/class-test-integrations.php index d639514053..28066a2853 100644 --- a/tests/unit-tests/integrations/class-test-integrations.php +++ b/tests/unit-tests/integrations/class-test-integrations.php @@ -849,4 +849,58 @@ public function pull_contact_data( $user_id ) { $stored = get_user_meta( $user_id, 'newspack_reader_data_item_ajax_field', true ); $this->assertSame( wp_json_encode( 'ajax_value' ), $stored ); } + + /** + * Test get_action_group returns prefixed integration ID. + */ + public function test_get_action_group() { + $this->assertSame( 'newspack-integration-esp', Integrations::get_action_group( 'esp' ) ); + $this->assertSame( 'newspack-integration-my-crm', Integrations::get_action_group( 'my-crm' ) ); + } + + /** + * Test get_action_group_for_handler returns group for registered handler. + */ + public function test_get_action_group_for_handler_returns_group() { + $action_name = 'test_group_event'; + Data_Events::register_action( $action_name ); + + $integration = new Sample_Integration( 'test-id', 'Test' ); + Integrations::register( $integration ); + $integration->test_register_handler( $action_name, 'handle_test_event' ); + + $group = Integrations::get_action_group_for_handler( Sample_Integration::class, $action_name ); + $this->assertSame( 'newspack-integration-test-id', $group ); + } + + /** + * Test get_action_group_for_handler returns null for unknown handler. + */ + public function test_get_action_group_for_handler_fallback() { + $group = Integrations::get_action_group_for_handler( 'NonExistent', 'unknown_action' ); + $this->assertNull( $group ); + } + + /** + * Test Data_Events::get_handler_action_group returns 'newspack' by default. + */ + public function test_data_events_get_handler_action_group_default() { + $group = Data_Events::get_handler_action_group( 'SomeClass', 'some_action' ); + $this->assertSame( 'newspack', $group ); + } + + /** + * Test Data_Events::get_handler_action_group is filtered by Integrations. + */ + public function test_data_events_get_handler_action_group_filtered() { + $action_name = 'test_filtered_group_event'; + Data_Events::register_action( $action_name ); + + $integration = new Sample_Integration( 'filtered-id', 'Filtered' ); + Integrations::register( $integration ); + $integration->test_register_handler( $action_name, 'handle_test_event' ); + + $group = Data_Events::get_handler_action_group( Sample_Integration::class, $action_name ); + $this->assertSame( 'newspack-integration-filtered-id', $group ); + } } diff --git a/tests/unit-tests/reader-activation-sync.php b/tests/unit-tests/reader-activation-sync.php index aa473c39c6..cd8b5ca758 100644 --- a/tests/unit-tests/reader-activation-sync.php +++ b/tests/unit-tests/reader-activation-sync.php @@ -327,7 +327,7 @@ public function test_integration_retry_scheduling() { $pending = as_get_scheduled_actions( [ 'hook' => Contact_Sync::RETRY_HOOK, - 'group' => 'newspack', + 'group' => Integrations::get_action_group( 'failing_mock' ), 'status' => \ActionScheduler_Store::STATUS_PENDING, ], 'ARRAY_A' @@ -377,7 +377,7 @@ public function test_integration_retry_success() { $pending = as_get_scheduled_actions( [ 'hook' => Contact_Sync::RETRY_HOOK, - 'group' => 'newspack', + 'group' => Integrations::get_action_group( 'success_mock' ), 'status' => \ActionScheduler_Store::STATUS_PENDING, ], 'ARRAY_A' @@ -420,7 +420,7 @@ public function test_integration_max_retries() { $pending = as_get_scheduled_actions( [ 'hook' => Contact_Sync::RETRY_HOOK, - 'group' => 'newspack', + 'group' => Integrations::get_action_group( 'max_mock' ), 'status' => \ActionScheduler_Store::STATUS_PENDING, ], 'ARRAY_A' @@ -614,7 +614,7 @@ public function test_integration_retry_invalid_data() { $pending = as_get_scheduled_actions( [ 'hook' => Contact_Sync::RETRY_HOOK, - 'group' => 'newspack', + 'group' => Integrations::get_action_group( 'failing_mock' ), 'status' => \ActionScheduler_Store::STATUS_PENDING, ], 'ARRAY_A'