From a47b074a0f24dde8c87a77cc85227d13cc066c48 Mon Sep 17 00:00:00 2001 From: Rasmy Nguyen Date: Tue, 26 May 2026 12:48:10 -0400 Subject: [PATCH 01/18] feat(audience): add Action Scheduler action and logs helpers Co-Authored-By: Claude Opus 4.7 (1M context) --- includes/class-action-scheduler.php | 60 +++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/includes/class-action-scheduler.php b/includes/class-action-scheduler.php index 9d831af5a4..56fb93c99f 100644 --- a/includes/class-action-scheduler.php +++ b/includes/class-action-scheduler.php @@ -405,4 +405,64 @@ public static function get_hooks() { return $hooks; } + + /** + * Fetch a single ActionScheduler action by ID. + * + * Returns null when AS resolves the ID to a NullAction (action does not exist). + * + * @param int $action_id The action ID. + * + * @return \ActionScheduler_Action|null + */ + public static function get_action( $action_id ) { + if ( ! self::is_available() ) { + return null; + } + $action = \ActionScheduler_Store::instance()->fetch_action( (int) $action_id ); + if ( ! $action || $action instanceof \ActionScheduler_NullAction ) { + return null; + } + return $action; + } + + /** + * Fetch per-action log entries for a single ActionScheduler action. + * + * Each entry is normalized to: + * [ 'log_id' => int, 'date_gmt' => string, 'message' => string ] + * + * Sorted ascending by date. + * + * @param int $action_id The action ID. + * + * @return array + */ + public static function get_action_logs( $action_id ) { + if ( ! self::is_available() || ! class_exists( '\ActionScheduler_Logger' ) ) { + return []; + } + $entries = \ActionScheduler_Logger::instance()->get_logs( (int) $action_id ); + if ( empty( $entries ) ) { + return []; + } + $normalized = array_map( + function ( $entry ) { + $date = $entry->get_date(); + return [ + 'log_id' => (int) $entry->get_log_id(), + 'date_gmt' => $date ? $date->format( 'Y-m-d\TH:i:s' ) : '', + 'message' => (string) $entry->get_message(), + ]; + }, + $entries + ); + usort( + $normalized, + static function ( $a, $b ) { + return strcmp( $a['date_gmt'], $b['date_gmt'] ); + } + ); + return $normalized; + } } From 4962066bb579e970cc6bbebd710af1c5e9c3fb3b Mon Sep 17 00:00:00 2001 From: Rasmy Nguyen Date: Tue, 26 May 2026 12:54:10 -0400 Subject: [PATCH 02/18] fix(audience): drop invalid LogEntry::get_log_id call Co-Authored-By: Claude Opus 4.7 (1M context) --- includes/class-action-scheduler.php | 30 +++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/includes/class-action-scheduler.php b/includes/class-action-scheduler.php index 56fb93c99f..35fc5731da 100644 --- a/includes/class-action-scheduler.php +++ b/includes/class-action-scheduler.php @@ -430,13 +430,15 @@ public static function get_action( $action_id ) { * Fetch per-action log entries for a single ActionScheduler action. * * Each entry is normalized to: - * [ 'log_id' => int, 'date_gmt' => string, 'message' => string ] + * [ 'date_gmt' => string, 'message' => string ] * - * Sorted ascending by date. + * NullLogEntry sentinels are filtered out. Entries are sorted ascending + * by date. ActionScheduler_LogEntry does not expose the log-row PK, so + * consumers use the array index as a stable key. * * @param int $action_id The action ID. * - * @return array + * @return array */ public static function get_action_logs( $action_id ) { if ( ! self::is_available() || ! class_exists( '\ActionScheduler_Logger' ) ) { @@ -446,17 +448,17 @@ public static function get_action_logs( $action_id ) { if ( empty( $entries ) ) { return []; } - $normalized = array_map( - function ( $entry ) { - $date = $entry->get_date(); - return [ - 'log_id' => (int) $entry->get_log_id(), - 'date_gmt' => $date ? $date->format( 'Y-m-d\TH:i:s' ) : '', - 'message' => (string) $entry->get_message(), - ]; - }, - $entries - ); + $normalized = []; + foreach ( $entries as $entry ) { + if ( $entry instanceof \ActionScheduler_NullLogEntry ) { + continue; + } + $date = $entry->get_date(); + $normalized[] = [ + 'date_gmt' => $date ? $date->format( 'Y-m-d\TH:i:s' ) : '', + 'message' => (string) $entry->get_message(), + ]; + } usort( $normalized, static function ( $a, $b ) { From 79dbfec0b9aa36834bc8874c3733cf25f321dbb3 Mon Sep 17 00:00:00 2001 From: Rasmy Nguyen Date: Tue, 26 May 2026 12:59:49 -0400 Subject: [PATCH 03/18] feat(audience): add ownership check for per-integration AS actions Co-Authored-By: Claude Opus 4.7 (1M context) --- .../reader-activation/class-integrations.php | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/includes/reader-activation/class-integrations.php b/includes/reader-activation/class-integrations.php index 60ad43d1d4..ffd91b2397 100644 --- a/includes/reader-activation/class-integrations.php +++ b/includes/reader-activation/class-integrations.php @@ -169,6 +169,28 @@ public static function get_all_action_groups() { return \Newspack\Action_Scheduler::get_groups_by_prefix( \Newspack\Action_Scheduler::GROUP_PREFIX . 'integration-' ); } + /** + * Whether a given AS action belongs to the given integration. + * + * Resolves the action's group to the integration's expected group slug. + * Used as the ownership gate for the per-integration detail and run REST + * endpoints, so other-group actions can't be probed through them. + * + * @param int $action_id The AS action ID. + * @param string $integration_id The integration identifier. + * + * @return bool + */ + public static function action_belongs_to_integration( $action_id, $integration_id ) { + $action = \Newspack\Action_Scheduler::get_action( (int) $action_id ); + if ( ! $action ) { + return false; + } + $expected_group = self::get_action_group( $integration_id ); + $group = method_exists( $action, 'get_group' ) ? $action->get_group() : ''; + return $group === $expected_group; + } + /** * Get ActionScheduler actions for Newspack integrations. * From 3fe07bd7ec90c89ebffe450eab578057bcb6cb20 Mon Sep 17 00:00:00 2001 From: Rasmy Nguyen Date: Tue, 26 May 2026 13:05:09 -0400 Subject: [PATCH 04/18] feat(audience): add REST endpoint for integration action detail Co-Authored-By: Claude Opus 4.7 (1M context) --- .../audience/class-audience-integrations.php | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/includes/wizards/audience/class-audience-integrations.php b/includes/wizards/audience/class-audience-integrations.php index 5c637ad3f6..fca827650b 100644 --- a/includes/wizards/audience/class-audience-integrations.php +++ b/includes/wizards/audience/class-audience-integrations.php @@ -179,6 +179,26 @@ public function register_api_endpoints() { ], ] ); + + register_rest_route( + NEWSPACK_API_NAMESPACE, + '/wizard/' . $this->slug . '/settings/(?P[a-zA-Z0-9_-]+)/logs/(?P[0-9]+)', + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [ $this, 'api_get_integration_log_detail' ], + 'permission_callback' => [ $this, 'api_permissions_check' ], + 'args' => [ + 'integration_id' => [ + 'type' => 'string', + 'sanitize_callback' => 'sanitize_key', + ], + 'action_id' => [ + 'type' => 'integer', + 'sanitize_callback' => 'absint', + ], + ], + ] + ); } /** @@ -322,4 +342,90 @@ function ( $action ) use ( $hook_labels ) { ] ); } + + /** + * Get the full detail (payload + per-action logs) for a single scheduled action. + * + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response|WP_Error + */ + public function api_get_integration_log_detail( WP_REST_Request $request ) { + $integration_id = $request->get_param( 'integration_id' ); + $action_id = (int) $request->get_param( 'action_id' ); + + $integration = Integrations::get_integration( $integration_id ); + if ( ! $integration ) { + return new WP_Error( + 'newspack_integration_not_found', + esc_html__( 'Integration not found.', 'newspack-plugin' ), + [ 'status' => 404 ] + ); + } + + if ( ! Integrations::action_belongs_to_integration( $action_id, $integration_id ) ) { + return new WP_Error( + 'newspack_action_not_found', + esc_html__( 'Action not found.', 'newspack-plugin' ), + [ 'status' => 404 ] + ); + } + + $action = Action_Scheduler::get_action( $action_id ); + if ( ! $action ) { + return new WP_Error( + 'newspack_action_not_found', + esc_html__( 'Action not found.', 'newspack-plugin' ), + [ 'status' => 404 ] + ); + } + + $store = \ActionScheduler_Store::instance(); + $status = $store->get_status( $action_id ); + + $schedule = $action->get_schedule(); + $scheduled_at = $schedule && method_exists( $schedule, 'get_date' ) ? $schedule->get_date() : null; + $scheduled_at_gmt = $scheduled_at ? $scheduled_at->format( 'Y-m-d\TH:i:s' ) : ''; + + // Resolve payload: prefer extended_args (full JSON) when present, else args. + // We read from the AS DB directly because the ActionScheduler_Action object + // only exposes the parsed args, with no signal as to whether they were + // truncated. Reading the row tells us whether extended_args carries the + // full JSON. + global $wpdb; + $row = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->prepare( + "SELECT args, extended_args, attempts, last_attempt_gmt FROM {$wpdb->prefix}actionscheduler_actions WHERE action_id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $action_id + ) + ); + + $payload_raw = ''; + if ( $row ) { + $payload_raw = ! empty( $row->extended_args ) ? $row->extended_args : (string) $row->args; + } + $decoded = json_decode( $payload_raw, true ); + $args = ( null === $decoded && JSON_ERROR_NONE !== json_last_error() ) ? $payload_raw : $decoded; + + $hook_labels = Action_Scheduler::get_hook_labels(); + $hook = $action->get_hook(); + $event = $hook_labels[ $hook ] ?? $hook; + + return rest_ensure_response( + [ + 'action' => [ + 'id' => $action_id, + 'hook' => $hook, + 'event' => $event, + 'status' => $status, + 'scheduled_date_gmt' => $scheduled_at_gmt, + 'attempts' => $row ? (int) $row->attempts : 0, + 'last_attempt_gmt' => $row && ! empty( $row->last_attempt_gmt ) && '0000-00-00 00:00:00' !== $row->last_attempt_gmt ? $row->last_attempt_gmt : '', + 'group' => method_exists( $action, 'get_group' ) ? $action->get_group() : '', + 'priority' => method_exists( $action, 'get_priority' ) ? (int) $action->get_priority() : 10, + 'args' => $args, + ], + 'logs' => Action_Scheduler::get_action_logs( $action_id ), + ] + ); + } } From b20fedf765160c9ba2153138f16ae2210642d4ed Mon Sep 17 00:00:00 2001 From: Rasmy Nguyen Date: Tue, 26 May 2026 13:10:07 -0400 Subject: [PATCH 05/18] fix(audience): normalize last_attempt_gmt to ISO 8601 in detail endpoint Co-Authored-By: Claude Opus 4.7 (1M context) --- includes/wizards/audience/class-audience-integrations.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/wizards/audience/class-audience-integrations.php b/includes/wizards/audience/class-audience-integrations.php index fca827650b..0c2d15169e 100644 --- a/includes/wizards/audience/class-audience-integrations.php +++ b/includes/wizards/audience/class-audience-integrations.php @@ -419,7 +419,7 @@ public function api_get_integration_log_detail( WP_REST_Request $request ) { 'status' => $status, 'scheduled_date_gmt' => $scheduled_at_gmt, 'attempts' => $row ? (int) $row->attempts : 0, - 'last_attempt_gmt' => $row && ! empty( $row->last_attempt_gmt ) && '0000-00-00 00:00:00' !== $row->last_attempt_gmt ? $row->last_attempt_gmt : '', + 'last_attempt_gmt' => $row && ! empty( $row->last_attempt_gmt ) && '0000-00-00 00:00:00' !== $row->last_attempt_gmt ? gmdate( 'Y-m-d\TH:i:s', strtotime( $row->last_attempt_gmt . ' UTC' ) ) : '', 'group' => method_exists( $action, 'get_group' ) ? $action->get_group() : '', 'priority' => method_exists( $action, 'get_priority' ) ? (int) $action->get_priority() : 10, 'args' => $args, From ef9b5c772455d2944ec527bd39b954af675f3515 Mon Sep 17 00:00:00 2001 From: Rasmy Nguyen Date: Tue, 26 May 2026 13:12:32 -0400 Subject: [PATCH 06/18] feat(audience): add REST endpoint to run a pending integration action Co-Authored-By: Claude Opus 4.7 (1M context) --- .../audience/class-audience-integrations.php | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/includes/wizards/audience/class-audience-integrations.php b/includes/wizards/audience/class-audience-integrations.php index 0c2d15169e..0d7ffd928a 100644 --- a/includes/wizards/audience/class-audience-integrations.php +++ b/includes/wizards/audience/class-audience-integrations.php @@ -199,6 +199,26 @@ public function register_api_endpoints() { ], ] ); + + register_rest_route( + NEWSPACK_API_NAMESPACE, + '/wizard/' . $this->slug . '/settings/(?P[a-zA-Z0-9_-]+)/logs/(?P[0-9]+)/run', + [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => [ $this, 'api_run_integration_action' ], + 'permission_callback' => [ $this, 'api_permissions_check' ], + 'args' => [ + 'integration_id' => [ + 'type' => 'string', + 'sanitize_callback' => 'sanitize_key', + ], + 'action_id' => [ + 'type' => 'integer', + 'sanitize_callback' => 'absint', + ], + ], + ] + ); } /** @@ -428,4 +448,67 @@ public function api_get_integration_log_detail( WP_REST_Request $request ) { ] ); } + + /** + * Run a pending scheduled action immediately. + * + * Mirrors the WooCommerce Action Scheduler admin "Run" behavior: the action is + * processed synchronously and the post-run status is returned. Errors thrown by + * the action's callback are not surfaced as HTTP errors — AS already marks the + * action `failed` and writes a log entry, so we report `status: 'failed'` in a + * 200 response and let the UI surface the last log message. + * + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response|WP_Error + */ + public function api_run_integration_action( WP_REST_Request $request ) { + $integration_id = $request->get_param( 'integration_id' ); + $action_id = (int) $request->get_param( 'action_id' ); + + $integration = Integrations::get_integration( $integration_id ); + if ( ! $integration ) { + return new WP_Error( + 'newspack_integration_not_found', + esc_html__( 'Integration not found.', 'newspack-plugin' ), + [ 'status' => 404 ] + ); + } + + if ( ! Integrations::action_belongs_to_integration( $action_id, $integration_id ) ) { + return new WP_Error( + 'newspack_action_not_found', + esc_html__( 'Action not found.', 'newspack-plugin' ), + [ 'status' => 404 ] + ); + } + + $store = \ActionScheduler_Store::instance(); + $status = $store->get_status( $action_id ); + + if ( 'pending' !== $status ) { + return new WP_Error( + 'newspack_action_not_pending', + esc_html__( 'This action is no longer pending.', 'newspack-plugin' ), + [ 'status' => 409 ] + ); + } + + try { + \ActionScheduler::runner()->process_action( $action_id, 'Newspack' ); + } catch ( \Throwable $e ) { + // Swallow: AS will have marked the action failed and recorded a log entry. + unset( $e ); + } + + $new_status = $store->get_status( $action_id ); + $logs = Action_Scheduler::get_action_logs( $action_id ); + $last_log = ! empty( $logs ) ? end( $logs )['message'] : ''; + + return rest_ensure_response( + [ + 'status' => $new_status, + 'message' => $last_log, + ] + ); + } } From 0508cb93666ae3d7e074f03c98cb845887b55843 Mon Sep 17 00:00:00 2001 From: Rasmy Nguyen Date: Tue, 26 May 2026 13:17:33 -0400 Subject: [PATCH 07/18] chore(audience): clarify run-action concurrency behavior and drop dead unset Co-Authored-By: Claude Opus 4.7 (1M context) --- .../wizards/audience/class-audience-integrations.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/includes/wizards/audience/class-audience-integrations.php b/includes/wizards/audience/class-audience-integrations.php index 0d7ffd928a..01a480dda3 100644 --- a/includes/wizards/audience/class-audience-integrations.php +++ b/includes/wizards/audience/class-audience-integrations.php @@ -493,11 +493,14 @@ public function api_run_integration_action( WP_REST_Request $request ) { ); } + // Run synchronously like the WooCommerce AS admin "Run" button. No claim is taken, + // so two concurrent requests for the same action could in theory both execute — same + // limitation as the WC admin button, accepted at this scope. try { \ActionScheduler::runner()->process_action( $action_id, 'Newspack' ); - } catch ( \Throwable $e ) { - // Swallow: AS will have marked the action failed and recorded a log entry. - unset( $e ); + } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch + // Swallow: AS marks the action failed and writes a log entry inside process_action's + // own error handler. We re-read the post-run status below and surface that to the UI. } $new_status = $store->get_status( $action_id ); From e0fbb153626247b01dab3a2a3bac5469376ce1e4 Mon Sep 17 00:00:00 2001 From: Rasmy Nguyen Date: Tue, 26 May 2026 13:19:31 -0400 Subject: [PATCH 08/18] feat(audience): add log details modal component Co-Authored-By: Claude Opus 4.7 (1M context) --- .../views/integrations/log-details-modal.js | 157 ++++++++++++++++++ .../audience/views/integrations/style.scss | 78 +++++++++ 2 files changed, 235 insertions(+) create mode 100644 src/wizards/audience/views/integrations/log-details-modal.js diff --git a/src/wizards/audience/views/integrations/log-details-modal.js b/src/wizards/audience/views/integrations/log-details-modal.js new file mode 100644 index 0000000000..a30b8bad97 --- /dev/null +++ b/src/wizards/audience/views/integrations/log-details-modal.js @@ -0,0 +1,157 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { useState, useEffect } from '@wordpress/element'; +import apiFetch from '@wordpress/api-fetch'; +import { Spinner, Notice } from '@wordpress/components'; +import { dateI18n, getSettings } from '@wordpress/date'; + +/** + * Internal dependencies + */ +import { Badge } from '../../../../../packages/components/src'; + +const API_BASE = '/newspack/v1/wizard/newspack-audience-integrations/settings'; + +const STATUS_MAP = { + complete: { label: __( 'Success', 'newspack-plugin' ), level: 'success' }, + failed: { label: __( 'Failed', 'newspack-plugin' ), level: 'error' }, + pending: { label: __( 'Pending', 'newspack-plugin' ), level: 'info' }, + canceled: { label: __( 'Canceled', 'newspack-plugin' ), level: 'warning' }, +}; + +function formatTimestamp( gmt ) { + if ( ! gmt ) { + return ''; + } + const dateFormat = getSettings().formats.datetime || 'F j, Y, g:i a'; + return dateI18n( dateFormat, `${ gmt }+00:00` ); +} + +function formatArgs( args ) { + if ( null === args || undefined === args ) { + return ''; + } + if ( typeof args === 'string' ) { + return args; + } + try { + return JSON.stringify( args, null, 2 ); + } catch ( e ) { + return String( args ); + } +} + +export const LogDetailsModal = ( { integrationId, actionId } ) => { + const [ data, setData ] = useState( null ); + const [ isLoading, setIsLoading ] = useState( true ); + const [ error, setError ] = useState( null ); + + useEffect( () => { + let cancelled = false; + setIsLoading( true ); + setError( null ); + + apiFetch( { path: `${ API_BASE }/${ integrationId }/logs/${ actionId }` } ) + .then( response => { + if ( ! cancelled ) { + setData( response ); + } + } ) + .catch( err => { + if ( cancelled ) { + return; + } + if ( err && err.data && err.data.status === 404 ) { + setError( __( 'This action no longer exists.', 'newspack-plugin' ) ); + } else { + setError( __( 'Failed to load action details.', 'newspack-plugin' ) ); + } + } ) + .finally( () => { + if ( ! cancelled ) { + setIsLoading( false ); + } + } ); + + return () => { + cancelled = true; + }; + }, [ integrationId, actionId ] ); + + if ( isLoading ) { + return ( +
+ +
+ ); + } + + if ( error ) { + return ( + + { error } + + ); + } + + if ( ! data || ! data.action ) { + return null; + } + + const { action, logs } = data; + const status = STATUS_MAP[ action.status ] || { label: action.status, level: 'default' }; + const formattedArgs = formatArgs( action.args ); + + return ( +
+
+

{ action.event }

+ +
+ +
+
{ __( 'Action ID', 'newspack-plugin' ) }
+
{ action.id }
+ +
{ __( 'Scheduled', 'newspack-plugin' ) }
+
{ formatTimestamp( action.scheduled_date_gmt ) }
+ +
{ __( 'Group', 'newspack-plugin' ) }
+
{ action.group }
+ +
{ __( 'Attempts', 'newspack-plugin' ) }
+
{ action.attempts }
+ +
{ __( 'Last attempt', 'newspack-plugin' ) }
+
{ action.last_attempt_gmt ? formatTimestamp( action.last_attempt_gmt ) : __( '—', 'newspack-plugin' ) }
+
+ +
+

{ __( 'Arguments', 'newspack-plugin' ) }

+ { formattedArgs ? ( +
{ formattedArgs }
+ ) : ( +

{ __( 'No arguments.', 'newspack-plugin' ) }

+ ) } +
+ +
+

{ __( 'Logs', 'newspack-plugin' ) }

+ { logs && logs.length > 0 ? ( +
    + { logs.map( ( log, index ) => ( +
  • + { formatTimestamp( log.date_gmt ) } + { log.message } +
  • + ) ) } +
+ ) : ( +

{ __( 'No log entries.', 'newspack-plugin' ) }

+ ) } +
+
+ ); +}; diff --git a/src/wizards/audience/views/integrations/style.scss b/src/wizards/audience/views/integrations/style.scss index 3bb8dea538..b3d31f06aa 100644 --- a/src/wizards/audience/views/integrations/style.scss +++ b/src/wizards/audience/views/integrations/style.scss @@ -32,3 +32,81 @@ } } } + +.newspack-integration-log-details { + &--loading { + display: flex; + justify-content: center; + padding: 24px 0; + } + + &__header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; + + h3 { + margin: 0; + } + } + + &__meta { + display: grid; + grid-template-columns: max-content 1fr; + column-gap: 16px; + row-gap: 4px; + margin: 0 0 16px; + + dt { + font-weight: 600; + } + + dd { + margin: 0; + } + } + + &__section { + margin-top: 16px; + + h4 { + margin: 0 0 8px; + } + } + + &__args { + background: var(--wp-admin-theme-color-background, #f0f0f1); + padding: 12px; + border-radius: 4px; + max-height: 240px; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + font-family: Menlo, Consolas, monospace; + font-size: 12px; + } + + &__logs { + list-style: none; + margin: 0; + padding: 0; + + li { + display: grid; + grid-template-columns: max-content 1fr; + column-gap: 12px; + padding: 4px 0; + border-bottom: 1px solid #e0e0e0; + + &:last-child { + border-bottom: none; + } + } + } + + &__log-date { + color: #757575; + font-variant-numeric: tabular-nums; + } +} From 5617ffaffa07b2279fc69f1689ce8edf18d6988d Mon Sep 17 00:00:00 2001 From: Rasmy Nguyen Date: Tue, 26 May 2026 13:25:25 -0400 Subject: [PATCH 09/18] refactor(audience): extract shared integration-logs constants Co-Authored-By: Claude Opus 4.7 (1M context) --- .../audience/views/integrations/constants.js | 27 +++++++++++++++++++ .../views/integrations/log-details-modal.js | 21 ++------------- .../audience/views/integrations/logs-view.js | 19 +------------ .../audience/views/integrations/style.scss | 4 +-- 4 files changed, 32 insertions(+), 39 deletions(-) create mode 100644 src/wizards/audience/views/integrations/constants.js diff --git a/src/wizards/audience/views/integrations/constants.js b/src/wizards/audience/views/integrations/constants.js new file mode 100644 index 0000000000..6befb07324 --- /dev/null +++ b/src/wizards/audience/views/integrations/constants.js @@ -0,0 +1,27 @@ +/** + * Shared module-level constants and helpers for the integration activity logs + * view and its detail modal. + */ + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { dateI18n, getSettings } from '@wordpress/date'; + +export const API_BASE = '/newspack/v1/wizard/newspack-audience-integrations/settings'; + +export const STATUS_MAP = { + complete: { label: __( 'Success', 'newspack-plugin' ), level: 'success' }, + failed: { label: __( 'Failed', 'newspack-plugin' ), level: 'error' }, + pending: { label: __( 'Pending', 'newspack-plugin' ), level: 'info' }, + canceled: { label: __( 'Canceled', 'newspack-plugin' ), level: 'warning' }, +}; + +export function formatTimestamp( gmt ) { + if ( ! gmt ) { + return ''; + } + const dateFormat = getSettings().formats.datetime || 'F j, Y, g:i a'; + return dateI18n( dateFormat, `${ gmt }+00:00` ); +} diff --git a/src/wizards/audience/views/integrations/log-details-modal.js b/src/wizards/audience/views/integrations/log-details-modal.js index a30b8bad97..95df85d689 100644 --- a/src/wizards/audience/views/integrations/log-details-modal.js +++ b/src/wizards/audience/views/integrations/log-details-modal.js @@ -5,29 +5,12 @@ import { __ } from '@wordpress/i18n'; import { useState, useEffect } from '@wordpress/element'; import apiFetch from '@wordpress/api-fetch'; import { Spinner, Notice } from '@wordpress/components'; -import { dateI18n, getSettings } from '@wordpress/date'; /** * Internal dependencies */ import { Badge } from '../../../../../packages/components/src'; - -const API_BASE = '/newspack/v1/wizard/newspack-audience-integrations/settings'; - -const STATUS_MAP = { - complete: { label: __( 'Success', 'newspack-plugin' ), level: 'success' }, - failed: { label: __( 'Failed', 'newspack-plugin' ), level: 'error' }, - pending: { label: __( 'Pending', 'newspack-plugin' ), level: 'info' }, - canceled: { label: __( 'Canceled', 'newspack-plugin' ), level: 'warning' }, -}; - -function formatTimestamp( gmt ) { - if ( ! gmt ) { - return ''; - } - const dateFormat = getSettings().formats.datetime || 'F j, Y, g:i a'; - return dateI18n( dateFormat, `${ gmt }+00:00` ); -} +import { API_BASE, STATUS_MAP, formatTimestamp } from './constants'; function formatArgs( args ) { if ( null === args || undefined === args ) { @@ -125,7 +108,7 @@ export const LogDetailsModal = ( { integrationId, actionId } ) => {
{ action.attempts }
{ __( 'Last attempt', 'newspack-plugin' ) }
-
{ action.last_attempt_gmt ? formatTimestamp( action.last_attempt_gmt ) : __( '—', 'newspack-plugin' ) }
+
{ action.last_attempt_gmt ? formatTimestamp( action.last_attempt_gmt ) : '—' }
diff --git a/src/wizards/audience/views/integrations/logs-view.js b/src/wizards/audience/views/integrations/logs-view.js index 6c90a83c74..e45bde67d1 100644 --- a/src/wizards/audience/views/integrations/logs-view.js +++ b/src/wizards/audience/views/integrations/logs-view.js @@ -8,32 +8,15 @@ import apiFetch from '@wordpress/api-fetch'; import { addQueryArgs } from '@wordpress/url'; import { Spinner } from '@wordpress/components'; import { DataViews as WPDataViews } from '@wordpress/dataviews'; -import { dateI18n, getSettings } from '@wordpress/date'; /** * Internal dependencies */ import { Badge, DataViews } from '../../../../../packages/components/src'; import { WIZARD_STORE_NAMESPACE } from '../../../../../packages/components/src/wizard/store'; +import { API_BASE, STATUS_MAP, formatTimestamp } from './constants'; import './style.scss'; -const API_BASE = '/newspack/v1/wizard/newspack-audience-integrations/settings'; - -const STATUS_MAP = { - complete: { label: __( 'Success', 'newspack-plugin' ), level: 'success' }, - failed: { label: __( 'Failed', 'newspack-plugin' ), level: 'error' }, - pending: { label: __( 'Pending', 'newspack-plugin' ), level: 'info' }, - canceled: { label: __( 'Canceled', 'newspack-plugin' ), level: 'warning' }, -}; - -function formatTimestamp( gmt ) { - if ( ! gmt ) { - return ''; - } - const dateFormat = getSettings().formats.datetime || 'F j, Y, g:i a'; - return dateI18n( dateFormat, `${ gmt }+00:00` ); -} - const DEFAULT_VIEW = { type: 'table', page: 1, diff --git a/src/wizards/audience/views/integrations/style.scss b/src/wizards/audience/views/integrations/style.scss index b3d31f06aa..15aaf6459f 100644 --- a/src/wizards/audience/views/integrations/style.scss +++ b/src/wizards/audience/views/integrations/style.scss @@ -76,14 +76,14 @@ } &__args { - background: var(--wp-admin-theme-color-background, #f0f0f1); + background: #f0f0f1; padding: 12px; border-radius: 4px; max-height: 240px; overflow: auto; white-space: pre-wrap; word-break: break-word; - font-family: Menlo, Consolas, monospace; + font-family: Consolas, monaco, monospace; font-size: 12px; } From 8b1c1112fe41dcbd835850ee6201d072f6891cf4 Mon Sep 17 00:00:00 2001 From: Rasmy Nguyen Date: Tue, 26 May 2026 13:32:00 -0400 Subject: [PATCH 10/18] feat(audience): add View details and Run now row actions to logs Co-Authored-By: Claude Opus 4.7 (1M context) --- .../audience/views/integrations/logs-view.js | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/src/wizards/audience/views/integrations/logs-view.js b/src/wizards/audience/views/integrations/logs-view.js index e45bde67d1..f55afd671b 100644 --- a/src/wizards/audience/views/integrations/logs-view.js +++ b/src/wizards/audience/views/integrations/logs-view.js @@ -15,6 +15,7 @@ import { DataViews as WPDataViews } from '@wordpress/dataviews'; import { Badge, DataViews } from '../../../../../packages/components/src'; import { WIZARD_STORE_NAMESPACE } from '../../../../../packages/components/src/wizard/store'; import { API_BASE, STATUS_MAP, formatTimestamp } from './constants'; +import { LogDetailsModal } from './log-details-modal'; import './style.scss'; const DEFAULT_VIEW = { @@ -141,6 +142,64 @@ export const LogsView = ( { integrations, match } ) => { [] ); + const runAction = useCallback( + actionId => { + apiFetch( { + path: `${ API_BASE }/${ integrationId }/logs/${ actionId }/run`, + method: 'POST', + } ) + .then( response => { + let message; + if ( response.status === 'complete' ) { + message = __( 'Action completed.', 'newspack-plugin' ); + } else if ( response.status === 'failed' ) { + message = response.message || __( 'Action failed.', 'newspack-plugin' ); + } else { + message = response.message || __( 'Action processed.', 'newspack-plugin' ); + } + addNotice( { + message, + type: response.status === 'failed' ? 'error' : 'success', + id: `integration-action-run-${ actionId }`, + } ); + } ) + .catch( err => { + const message = err && err.message ? err.message : __( 'Could not run action.', 'newspack-plugin' ); + addNotice( { + message, + type: 'error', + id: `integration-action-run-${ actionId }`, + } ); + } ) + .finally( () => { + fetchLogs(); + } ); + }, + [ integrationId, addNotice, fetchLogs ] + ); + + const actions = useMemo( + () => [ + { + id: 'view-details', + label: __( 'View details', 'newspack-plugin' ), + modalHeader: __( 'Action details', 'newspack-plugin' ), + RenderModal: ( { items } ) => , + }, + { + id: 'run-now', + label: __( 'Run now', 'newspack-plugin' ), + isEligible: item => item.status === 'pending', + callback: items => { + if ( items[ 0 ] ) { + runAction( items[ 0 ].id ); + } + }, + }, + ], + [ integrationId, runAction ] + ); + const paginationInfo = useMemo( () => ( { totalItems: total, @@ -166,6 +225,7 @@ export const LogsView = ( { integrations, match } ) => { className="newspack-integration-logs" data={ data } fields={ fields } + actions={ actions } view={ view } onChangeView={ setView } paginationInfo={ paginationInfo } From be269bb928becd370ae3b40a9705267e641fa7d7 Mon Sep 17 00:00:00 2001 From: Rasmy Nguyen Date: Tue, 26 May 2026 13:36:31 -0400 Subject: [PATCH 11/18] chore(audience): drop dead non-empty-items guard on run-now action Co-Authored-By: Claude Opus 4.7 (1M context) --- src/wizards/audience/views/integrations/logs-view.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/wizards/audience/views/integrations/logs-view.js b/src/wizards/audience/views/integrations/logs-view.js index f55afd671b..bd450a5194 100644 --- a/src/wizards/audience/views/integrations/logs-view.js +++ b/src/wizards/audience/views/integrations/logs-view.js @@ -190,11 +190,7 @@ export const LogsView = ( { integrations, match } ) => { id: 'run-now', label: __( 'Run now', 'newspack-plugin' ), isEligible: item => item.status === 'pending', - callback: items => { - if ( items[ 0 ] ) { - runAction( items[ 0 ].id ); - } - }, + callback: items => runAction( items[ 0 ].id ), }, ], [ integrationId, runAction ] From ec2f6446c0d6c6e10bb9ce5314af3297cc571c8c Mon Sep 17 00:00:00 2001 From: Rasmy Nguyen Date: Tue, 26 May 2026 13:46:16 -0400 Subject: [PATCH 12/18] feat(audience): handle in-progress action status in logs view Co-Authored-By: Claude Opus 4.7 (1M context) --- src/wizards/audience/views/integrations/constants.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/wizards/audience/views/integrations/constants.js b/src/wizards/audience/views/integrations/constants.js index 6befb07324..390754795a 100644 --- a/src/wizards/audience/views/integrations/constants.js +++ b/src/wizards/audience/views/integrations/constants.js @@ -15,6 +15,7 @@ export const STATUS_MAP = { complete: { label: __( 'Success', 'newspack-plugin' ), level: 'success' }, failed: { label: __( 'Failed', 'newspack-plugin' ), level: 'error' }, pending: { label: __( 'Pending', 'newspack-plugin' ), level: 'info' }, + 'in-progress': { label: __( 'In progress', 'newspack-plugin' ), level: 'info' }, canceled: { label: __( 'Canceled', 'newspack-plugin' ), level: 'warning' }, }; From ead821681ab3426120784ec2ea01bdeae6423deb Mon Sep 17 00:00:00 2001 From: Rasmy Nguyen Date: Tue, 26 May 2026 14:02:12 -0400 Subject: [PATCH 13/18] fix(audience): show pointer cursor on integration log row actions Co-Authored-By: Claude Opus 4.7 (1M context) --- src/wizards/audience/views/integrations/style.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/wizards/audience/views/integrations/style.scss b/src/wizards/audience/views/integrations/style.scss index 15aaf6459f..4a8a383e9a 100644 --- a/src/wizards/audience/views/integrations/style.scss +++ b/src/wizards/audience/views/integrations/style.scss @@ -31,6 +31,10 @@ fill: currentcolor; } } + + .dataviews-item-actions .components-button { + cursor: pointer; + } } .newspack-integration-log-details { From aca9c6d69bdfc67a6ecfd94257b628c93b493c10 Mon Sep 17 00:00:00 2001 From: Rasmy Nguyen Date: Tue, 26 May 2026 17:38:56 -0400 Subject: [PATCH 14/18] feat(audience): harden Run now flow and refine integration logs UX Co-Authored-By: Claude Opus 4.7 (1M context) --- includes/class-action-scheduler.php | 4 +- .../reader-activation/class-integrations.php | 22 +++-- .../audience/class-audience-integrations.php | 93 ++++++++++++++----- .../audience/views/integrations/logs-view.js | 23 ++++- .../audience/views/integrations/style.scss | 2 +- 5 files changed, 104 insertions(+), 40 deletions(-) diff --git a/includes/class-action-scheduler.php b/includes/class-action-scheduler.php index 35fc5731da..66390c7357 100644 --- a/includes/class-action-scheduler.php +++ b/includes/class-action-scheduler.php @@ -415,7 +415,7 @@ public static function get_hooks() { * * @return \ActionScheduler_Action|null */ - public static function get_action( $action_id ) { + public static function get_action( $action_id ): ?\ActionScheduler_Action { if ( ! self::is_available() ) { return null; } @@ -440,7 +440,7 @@ public static function get_action( $action_id ) { * * @return array */ - public static function get_action_logs( $action_id ) { + public static function get_action_logs( $action_id ): array { if ( ! self::is_available() || ! class_exists( '\ActionScheduler_Logger' ) ) { return []; } diff --git a/includes/reader-activation/class-integrations.php b/includes/reader-activation/class-integrations.php index ffd91b2397..5fdac2e311 100644 --- a/includes/reader-activation/class-integrations.php +++ b/includes/reader-activation/class-integrations.php @@ -170,25 +170,27 @@ public static function get_all_action_groups() { } /** - * Whether a given AS action belongs to the given integration. + * Get the AS action for the given ID if it belongs to the given integration. * - * Resolves the action's group to the integration's expected group slug. - * Used as the ownership gate for the per-integration detail and run REST - * endpoints, so other-group actions can't be probed through them. + * Combines existence + group-ownership checks so callers can do both in a + * single DB read. Returns null for missing actions and for actions in other + * groups — both cases the REST endpoints translate to a generic 404 so + * other-group actions can't be probed. * * @param int $action_id The AS action ID. * @param string $integration_id The integration identifier. * - * @return bool + * @return \ActionScheduler_Action|null */ - public static function action_belongs_to_integration( $action_id, $integration_id ) { + public static function get_integration_action( $action_id, $integration_id ) { $action = \Newspack\Action_Scheduler::get_action( (int) $action_id ); if ( ! $action ) { - return false; + return null; + } + if ( $action->get_group() !== self::get_action_group( $integration_id ) ) { + return null; } - $expected_group = self::get_action_group( $integration_id ); - $group = method_exists( $action, 'get_group' ) ? $action->get_group() : ''; - return $group === $expected_group; + return $action; } /** diff --git a/includes/wizards/audience/class-audience-integrations.php b/includes/wizards/audience/class-audience-integrations.php index 01a480dda3..7e2ebff7f6 100644 --- a/includes/wizards/audience/class-audience-integrations.php +++ b/includes/wizards/audience/class-audience-integrations.php @@ -174,7 +174,7 @@ public function register_api_endpoints() { 'status' => [ 'type' => 'string', 'default' => '', - 'enum' => [ '', 'pending', 'complete', 'failed', 'canceled' ], + 'enum' => [ '', 'pending', 'in-progress', 'complete', 'failed', 'canceled' ], ], ], ] @@ -369,7 +369,7 @@ function ( $action ) use ( $hook_labels ) { * @param WP_REST_Request $request Request object. * @return WP_REST_Response|WP_Error */ - public function api_get_integration_log_detail( WP_REST_Request $request ) { + public function api_get_integration_log_detail( WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $integration_id = $request->get_param( 'integration_id' ); $action_id = (int) $request->get_param( 'action_id' ); @@ -382,15 +382,7 @@ public function api_get_integration_log_detail( WP_REST_Request $request ) { ); } - if ( ! Integrations::action_belongs_to_integration( $action_id, $integration_id ) ) { - return new WP_Error( - 'newspack_action_not_found', - esc_html__( 'Action not found.', 'newspack-plugin' ), - [ 'status' => 404 ] - ); - } - - $action = Action_Scheduler::get_action( $action_id ); + $action = Integrations::get_integration_action( $action_id, $integration_id ); if ( ! $action ) { return new WP_Error( 'newspack_action_not_found', @@ -402,8 +394,14 @@ public function api_get_integration_log_detail( WP_REST_Request $request ) { $store = \ActionScheduler_Store::instance(); $status = $store->get_status( $action_id ); - $schedule = $action->get_schedule(); - $scheduled_at = $schedule && method_exists( $schedule, 'get_date' ) ? $schedule->get_date() : null; + // The schedule's DateTime comes back in the server timezone despite the + // column being named *_gmt. Normalize to UTC so the API contract matches + // the field name and the frontend's '+00:00' parsing. + $schedule = $action->get_schedule(); + $scheduled_at = $schedule ? $schedule->get_date() : null; + if ( $scheduled_at ) { + $scheduled_at->setTimezone( new \DateTimeZone( 'UTC' ) ); + } $scheduled_at_gmt = $scheduled_at ? $scheduled_at->format( 'Y-m-d\TH:i:s' ) : ''; // Resolve payload: prefer extended_args (full JSON) when present, else args. @@ -440,8 +438,8 @@ public function api_get_integration_log_detail( WP_REST_Request $request ) { 'scheduled_date_gmt' => $scheduled_at_gmt, 'attempts' => $row ? (int) $row->attempts : 0, 'last_attempt_gmt' => $row && ! empty( $row->last_attempt_gmt ) && '0000-00-00 00:00:00' !== $row->last_attempt_gmt ? gmdate( 'Y-m-d\TH:i:s', strtotime( $row->last_attempt_gmt . ' UTC' ) ) : '', - 'group' => method_exists( $action, 'get_group' ) ? $action->get_group() : '', - 'priority' => method_exists( $action, 'get_priority' ) ? (int) $action->get_priority() : 10, + 'group' => $action->get_group(), + 'priority' => (int) $action->get_priority(), 'args' => $args, ], 'logs' => Action_Scheduler::get_action_logs( $action_id ), @@ -461,7 +459,7 @@ public function api_get_integration_log_detail( WP_REST_Request $request ) { * @param WP_REST_Request $request Request object. * @return WP_REST_Response|WP_Error */ - public function api_run_integration_action( WP_REST_Request $request ) { + public function api_run_integration_action( WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $integration_id = $request->get_param( 'integration_id' ); $action_id = (int) $request->get_param( 'action_id' ); @@ -474,7 +472,8 @@ public function api_run_integration_action( WP_REST_Request $request ) { ); } - if ( ! Integrations::action_belongs_to_integration( $action_id, $integration_id ) ) { + $action = Integrations::get_integration_action( $action_id, $integration_id ); + if ( ! $action ) { return new WP_Error( 'newspack_action_not_found', esc_html__( 'Action not found.', 'newspack-plugin' ), @@ -485,7 +484,7 @@ public function api_run_integration_action( WP_REST_Request $request ) { $store = \ActionScheduler_Store::instance(); $status = $store->get_status( $action_id ); - if ( 'pending' !== $status ) { + if ( \ActionScheduler_Store::STATUS_PENDING !== $status ) { return new WP_Error( 'newspack_action_not_pending', esc_html__( 'This action is no longer pending.', 'newspack-plugin' ), @@ -493,6 +492,19 @@ public function api_run_integration_action( WP_REST_Request $request ) { ); } + // Read pre-run attempts so we can detect cases where process_action throws + // before log_execution() — e.g. another worker claimed the action between + // our pending check and process_action. AS doesn't always mark such cases + // failed, so we surface a generic retry message instead of an empty + // success-looking response. + global $wpdb; + $pre_attempts = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->prepare( + "SELECT attempts FROM {$wpdb->prefix}actionscheduler_actions WHERE action_id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $action_id + ) + ); + // Run synchronously like the WooCommerce AS admin "Run" button. No claim is taken, // so two concurrent requests for the same action could in theory both execute — same // limitation as the WC admin button, accepted at this scope. @@ -500,18 +512,49 @@ public function api_run_integration_action( WP_REST_Request $request ) { \ActionScheduler::runner()->process_action( $action_id, 'Newspack' ); } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch // Swallow: AS marks the action failed and writes a log entry inside process_action's - // own error handler. We re-read the post-run status below and surface that to the UI. + // own error handler when the callback throws. We re-read state below and surface + // it to the UI. } - $new_status = $store->get_status( $action_id ); - $logs = Action_Scheduler::get_action_logs( $action_id ); - $last_log = ! empty( $logs ) ? end( $logs )['message'] : ''; + $new_status = $store->get_status( $action_id ); + $post_attempts = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->prepare( + "SELECT attempts FROM {$wpdb->prefix}actionscheduler_actions WHERE action_id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $action_id + ) + ); - return rest_ensure_response( - [ + $ran = $new_status !== $status || $post_attempts > $pre_attempts; + + if ( $ran ) { + $logs = Action_Scheduler::get_action_logs( $action_id ); + $last_entry = end( $logs ); + $last_log = ( is_array( $last_entry ) && isset( $last_entry['message'] ) ) ? $last_entry['message'] : ''; + $response = [ 'status' => $new_status, 'message' => $last_log, - ] + ]; + $audit_result = $new_status; + } else { + $response = [ + 'status' => $new_status, + 'message' => esc_html__( 'Could not run; please refresh and try again.', 'newspack-plugin' ), + ]; + $audit_result = 'no-run'; + } + + Logger::newspack_log( + 'newspack_integration_action_run', + sprintf( 'Manual run of integration action %d (%s).', $action_id, $integration_id ), + [ + 'action_id' => $action_id, + 'integration_id' => $integration_id, + 'user_id' => get_current_user_id(), + 'result' => $audit_result, + ], + 'info' ); + + return rest_ensure_response( $response ); } } diff --git a/src/wizards/audience/views/integrations/logs-view.js b/src/wizards/audience/views/integrations/logs-view.js index bd450a5194..877e8c86a7 100644 --- a/src/wizards/audience/views/integrations/logs-view.js +++ b/src/wizards/audience/views/integrations/logs-view.js @@ -45,6 +45,7 @@ export const LogsView = ( { integrations, match } ) => { const [ isLoading, setIsLoading ] = useState( true ); const [ hasLoadedOnce, setHasLoadedOnce ] = useState( false ); const [ view, setView ] = useState( DEFAULT_VIEW ); + const [ runningActionIds, setRunningActionIds ] = useState( () => new Set() ); useEffect( () => { if ( integration ) { @@ -132,6 +133,7 @@ export const LogsView = ( { integrations, match } ) => { { value: 'complete', label: __( 'Success', 'newspack-plugin' ) }, { value: 'failed', label: __( 'Failed', 'newspack-plugin' ) }, { value: 'pending', label: __( 'Pending', 'newspack-plugin' ) }, + { value: 'in-progress', label: __( 'In progress', 'newspack-plugin' ) }, { value: 'canceled', label: __( 'Canceled', 'newspack-plugin' ) }, ], filterBy: { @@ -144,6 +146,18 @@ export const LogsView = ( { integrations, match } ) => { const runAction = useCallback( actionId => { + setRunningActionIds( prev => { + const next = new Set( prev ); + next.add( actionId ); + return next; + } ); + // Synchronous "running" notice — reuses the per-action ID so the + // final success/failure notice replaces it in place. + addNotice( { + message: __( 'Running action…', 'newspack-plugin' ), + type: 'info', + id: `integration-action-run-${ actionId }`, + } ); apiFetch( { path: `${ API_BASE }/${ integrationId }/logs/${ actionId }/run`, method: 'POST', @@ -172,6 +186,11 @@ export const LogsView = ( { integrations, match } ) => { } ); } ) .finally( () => { + setRunningActionIds( prev => { + const next = new Set( prev ); + next.delete( actionId ); + return next; + } ); fetchLogs(); } ); }, @@ -189,11 +208,11 @@ export const LogsView = ( { integrations, match } ) => { { id: 'run-now', label: __( 'Run now', 'newspack-plugin' ), - isEligible: item => item.status === 'pending', + isEligible: item => item.status === 'pending' && ! runningActionIds.has( item.id ), callback: items => runAction( items[ 0 ].id ), }, ], - [ integrationId, runAction ] + [ integrationId, runAction, runningActionIds ] ); const paginationInfo = useMemo( diff --git a/src/wizards/audience/views/integrations/style.scss b/src/wizards/audience/views/integrations/style.scss index 4a8a383e9a..74417336af 100644 --- a/src/wizards/audience/views/integrations/style.scss +++ b/src/wizards/audience/views/integrations/style.scss @@ -32,7 +32,7 @@ } } - .dataviews-item-actions .components-button { + .dataviews-item-actions .components-button:not(:disabled) { cursor: pointer; } } From 6161580823aa1ed0156c6d7df4bc8b58825f2ddd Mon Sep 17 00:00:00 2001 From: Rasmy Nguyen Date: Fri, 29 May 2026 09:55:20 -0400 Subject: [PATCH 15/18] fix(audience): replace running notice via removeNotice before result --- .../audience/views/integrations/logs-view.js | 33 ++++++++++++------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/src/wizards/audience/views/integrations/logs-view.js b/src/wizards/audience/views/integrations/logs-view.js index 877e8c86a7..7c48a3c1eb 100644 --- a/src/wizards/audience/views/integrations/logs-view.js +++ b/src/wizards/audience/views/integrations/logs-view.js @@ -24,13 +24,14 @@ const DEFAULT_VIEW = { perPage: 25, sort: { field: 'timestamp', direction: 'desc' }, search: '', - fields: [ 'timestamp', 'event', 'status' ], + fields: [ 'timestamp', 'email', 'event', 'status' ], filters: [], layout: { styles: { - timestamp: { width: '75%' }, - event: { width: '15%' }, - status: { width: '10%' }, + timestamp: { width: '35%' }, + email: { width: '30%' }, + event: { width: '20%' }, + status: { width: '15%' }, }, }, }; @@ -38,7 +39,7 @@ const DEFAULT_VIEW = { export const LogsView = ( { integrations, match } ) => { const integrationId = match?.params?.integrationId; const integration = integrationId ? integrations[ integrationId ] : null; - const { addNotice, setHeaderData } = useDispatch( WIZARD_STORE_NAMESPACE ); + const { addNotice, removeNotice, setHeaderData } = useDispatch( WIZARD_STORE_NAMESPACE ); const [ data, setData ] = useState( [] ); const [ total, setTotal ] = useState( 0 ); @@ -115,6 +116,12 @@ export const LogsView = ( { integrations, match } ) => { render: ( { item } ) => formatTimestamp( item.timestamp ), enableSorting: true, }, + { + id: 'email', + label: __( 'Email', 'newspack-plugin' ), + render: ( { item } ) => item.email || '—', + enableSorting: false, + }, { id: 'event', label: __( 'Event', 'newspack-plugin' ), @@ -151,12 +158,14 @@ export const LogsView = ( { integrations, match } ) => { next.add( actionId ); return next; } ); - // Synchronous "running" notice — reuses the per-action ID so the - // final success/failure notice replaces it in place. + // Synchronous "running" notice. addNotice appends without deduping by + // id, so the final success/failure notice removes this one first + // (see removeNotice calls below) to replace it in place. + const noticeId = `integration-action-run-${ actionId }`; addNotice( { message: __( 'Running action…', 'newspack-plugin' ), type: 'info', - id: `integration-action-run-${ actionId }`, + id: noticeId, } ); apiFetch( { path: `${ API_BASE }/${ integrationId }/logs/${ actionId }/run`, @@ -171,18 +180,20 @@ export const LogsView = ( { integrations, match } ) => { } else { message = response.message || __( 'Action processed.', 'newspack-plugin' ); } + removeNotice( noticeId ); addNotice( { message, type: response.status === 'failed' ? 'error' : 'success', - id: `integration-action-run-${ actionId }`, + id: noticeId, } ); } ) .catch( err => { const message = err && err.message ? err.message : __( 'Could not run action.', 'newspack-plugin' ); + removeNotice( noticeId ); addNotice( { message, type: 'error', - id: `integration-action-run-${ actionId }`, + id: noticeId, } ); } ) .finally( () => { @@ -194,7 +205,7 @@ export const LogsView = ( { integrations, match } ) => { fetchLogs(); } ); }, - [ integrationId, addNotice, fetchLogs ] + [ integrationId, addNotice, removeNotice, fetchLogs ] ); const actions = useMemo( From 2714a32bb9b7209865706eecd9625e408c698d34 Mon Sep 17 00:00:00 2001 From: Rasmy Nguyen Date: Fri, 29 May 2026 09:55:20 -0400 Subject: [PATCH 16/18] feat(audience): surface contact email in integration logs --- .../audience/class-audience-integrations.php | 60 +++++++++++++++++++ .../views/integrations/log-details-modal.js | 3 + 2 files changed, 63 insertions(+) diff --git a/includes/wizards/audience/class-audience-integrations.php b/includes/wizards/audience/class-audience-integrations.php index 7e2ebff7f6..97f141c600 100644 --- a/includes/wizards/audience/class-audience-integrations.php +++ b/includes/wizards/audience/class-audience-integrations.php @@ -343,11 +343,13 @@ public function api_get_integration_logs( WP_REST_Request $request ) { $items = array_map( function ( $action ) use ( $hook_labels ) { + $args = self::decode_action_args( $action->args ?? '', $action->extended_args ?? '' ); return [ 'id' => $action->action_id, 'timestamp' => $action->scheduled_date_gmt, 'event' => $hook_labels[ $action->hook ] ?? $action->hook, 'status' => $action->status, + 'email' => self::extract_email_from_payload( $args ), ]; }, $actions @@ -434,6 +436,7 @@ public function api_get_integration_log_detail( WP_REST_Request $request ): \WP_ 'id' => $action_id, 'hook' => $hook, 'event' => $event, + 'email' => self::extract_email_from_payload( $args ), 'status' => $status, 'scheduled_date_gmt' => $scheduled_at_gmt, 'attempts' => $row ? (int) $row->attempts : 0, @@ -557,4 +560,61 @@ public function api_run_integration_action( WP_REST_Request $request ): \WP_REST return rest_ensure_response( $response ); } + + /** + * Decode a scheduled action's args payload into a PHP value. + * + * Prefers extended_args (full JSON) over args, mirroring the detail + * endpoint. Returns null when the payload is empty or not valid JSON. + * + * @param string $args The actionscheduler_actions.args column value. + * @param string $extended_args The actionscheduler_actions.extended_args column value. + * + * @return mixed The decoded payload, or null. + */ + private static function decode_action_args( $args, $extended_args ) { + $raw = ! empty( $extended_args ) ? $extended_args : (string) $args; + if ( '' === $raw ) { + return null; + } + $decoded = json_decode( $raw, true ); + if ( null === $decoded && JSON_ERROR_NONE !== json_last_error() ) { + return null; + } + return $decoded; + } + + /** + * Best-effort extraction of a contact email from a scheduled action's payload. + * + * Integration actions carry the originating data event payload, which for + * most reader/contact events includes the contact email — but its depth + * varies by event (e.g. retry wrappers nest it under a 'data' key). + * Recursively scans for the first 'email'/'user_email' key holding a valid + * address. Returns '' when no email is present in the metadata. + * + * @param mixed $payload Decoded args payload (array, scalar, or null). + * @param int $depth Current recursion depth (internal guard). + * + * @return string The first valid email found, or ''. + */ + private static function extract_email_from_payload( $payload, $depth = 0 ) { + if ( $depth > 6 || ! is_array( $payload ) ) { + return ''; + } + foreach ( [ 'email', 'user_email' ] as $key ) { + if ( isset( $payload[ $key ] ) && is_string( $payload[ $key ] ) && is_email( $payload[ $key ] ) ) { + return sanitize_email( $payload[ $key ] ); + } + } + foreach ( $payload as $value ) { + if ( is_array( $value ) ) { + $found = self::extract_email_from_payload( $value, $depth + 1 ); + if ( '' !== $found ) { + return $found; + } + } + } + return ''; + } } diff --git a/src/wizards/audience/views/integrations/log-details-modal.js b/src/wizards/audience/views/integrations/log-details-modal.js index 95df85d689..4efdc98b00 100644 --- a/src/wizards/audience/views/integrations/log-details-modal.js +++ b/src/wizards/audience/views/integrations/log-details-modal.js @@ -98,6 +98,9 @@ export const LogDetailsModal = ( { integrationId, actionId } ) => {
{ __( 'Action ID', 'newspack-plugin' ) }
{ action.id }
+
{ __( 'Email', 'newspack-plugin' ) }
+
{ action.email || '—' }
+
{ __( 'Scheduled', 'newspack-plugin' ) }
{ formatTimestamp( action.scheduled_date_gmt ) }
From 6eb6c7dc650b4cbce3ba42f6720d7b2c5e42ef81 Mon Sep 17 00:00:00 2001 From: Rasmy Nguyen Date: Fri, 29 May 2026 10:37:10 -0400 Subject: [PATCH 17/18] fix(audience): label complete status as Complete not Success --- src/wizards/audience/views/integrations/constants.js | 2 +- src/wizards/audience/views/integrations/logs-view.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wizards/audience/views/integrations/constants.js b/src/wizards/audience/views/integrations/constants.js index 390754795a..fadd5f3592 100644 --- a/src/wizards/audience/views/integrations/constants.js +++ b/src/wizards/audience/views/integrations/constants.js @@ -12,7 +12,7 @@ import { dateI18n, getSettings } from '@wordpress/date'; export const API_BASE = '/newspack/v1/wizard/newspack-audience-integrations/settings'; export const STATUS_MAP = { - complete: { label: __( 'Success', 'newspack-plugin' ), level: 'success' }, + complete: { label: __( 'Complete', 'newspack-plugin' ), level: 'success' }, failed: { label: __( 'Failed', 'newspack-plugin' ), level: 'error' }, pending: { label: __( 'Pending', 'newspack-plugin' ), level: 'info' }, 'in-progress': { label: __( 'In progress', 'newspack-plugin' ), level: 'info' }, diff --git a/src/wizards/audience/views/integrations/logs-view.js b/src/wizards/audience/views/integrations/logs-view.js index 7c48a3c1eb..5d1ce1aefe 100644 --- a/src/wizards/audience/views/integrations/logs-view.js +++ b/src/wizards/audience/views/integrations/logs-view.js @@ -137,7 +137,7 @@ export const LogsView = ( { integrations, match } ) => { }, enableSorting: true, elements: [ - { value: 'complete', label: __( 'Success', 'newspack-plugin' ) }, + { value: 'complete', label: __( 'Complete', 'newspack-plugin' ) }, { value: 'failed', label: __( 'Failed', 'newspack-plugin' ) }, { value: 'pending', label: __( 'Pending', 'newspack-plugin' ) }, { value: 'in-progress', label: __( 'In progress', 'newspack-plugin' ) }, From ca60d161a173d8849e12d74b6d4c1e6ddde16334 Mon Sep 17 00:00:00 2001 From: Rasmy Nguyen Date: Fri, 29 May 2026 10:43:14 -0400 Subject: [PATCH 18/18] fix(audience): resolve log contact email from user_id --- .../audience/class-audience-integrations.php | 78 ++++++++++++++++--- 1 file changed, 68 insertions(+), 10 deletions(-) diff --git a/includes/wizards/audience/class-audience-integrations.php b/includes/wizards/audience/class-audience-integrations.php index 97f141c600..b86026e71e 100644 --- a/includes/wizards/audience/class-audience-integrations.php +++ b/includes/wizards/audience/class-audience-integrations.php @@ -341,15 +341,30 @@ public function api_get_integration_logs( WP_REST_Request $request ) { $total = Integrations::count_scheduled_actions( $count_args ); $hook_labels = Action_Scheduler::get_hook_labels(); + // Decode payloads once, then prime the user cache in a single query so + // the per-row email resolution below doesn't issue a query per action. + $decoded_args = []; + $user_ids = []; + foreach ( $actions as $action ) { + $args = self::decode_action_args( $action->args ?? '', $action->extended_args ?? '' ); + $decoded_args[ $action->action_id ] = $args; + $user_id = self::get_payload_user_id( $args ); + if ( $user_id ) { + $user_ids[] = $user_id; + } + } + if ( ! empty( $user_ids ) ) { + cache_users( array_values( array_unique( $user_ids ) ) ); + } + $items = array_map( - function ( $action ) use ( $hook_labels ) { - $args = self::decode_action_args( $action->args ?? '', $action->extended_args ?? '' ); + function ( $action ) use ( $hook_labels, $decoded_args ) { return [ 'id' => $action->action_id, 'timestamp' => $action->scheduled_date_gmt, 'event' => $hook_labels[ $action->hook ] ?? $action->hook, 'status' => $action->status, - 'email' => self::extract_email_from_payload( $args ), + 'email' => self::extract_email_from_payload( $decoded_args[ $action->action_id ] ?? null ), ]; }, $actions @@ -585,28 +600,42 @@ private static function decode_action_args( $args, $extended_args ) { } /** - * Best-effort extraction of a contact email from a scheduled action's payload. + * Best-effort resolution of the contact email for a scheduled action's payload. * - * Integration actions carry the originating data event payload, which for - * most reader/contact events includes the contact email — but its depth - * varies by event (e.g. retry wrappers nest it under a 'data' key). - * Recursively scans for the first 'email'/'user_email' key holding a valid - * address. Returns '' when no email is present in the metadata. + * Integration retry actions reference the contact by WordPress user ID + * (e.g. `[ { "integration_id": "esp", "user_id": 1, ... } ]`) rather than + * carrying the email directly, so the current account email is resolved + * from that ID. An explicit `email`/`user_email` key is preferred when a + * payload does carry one, and a `previous_email` (set on email-change + * retries) is used as a last resort when the user can no longer be + * resolved. Returns '' when no email can be determined. * * @param mixed $payload Decoded args payload (array, scalar, or null). * @param int $depth Current recursion depth (internal guard). * - * @return string The first valid email found, or ''. + * @return string The resolved email, or ''. */ private static function extract_email_from_payload( $payload, $depth = 0 ) { if ( $depth > 6 || ! is_array( $payload ) ) { return ''; } + // Prefer an explicit email carried in the payload. foreach ( [ 'email', 'user_email' ] as $key ) { if ( isset( $payload[ $key ] ) && is_string( $payload[ $key ] ) && is_email( $payload[ $key ] ) ) { return sanitize_email( $payload[ $key ] ); } } + // Otherwise resolve the current account email from the user ID. + if ( isset( $payload['user_id'] ) && is_numeric( $payload['user_id'] ) ) { + $user = get_userdata( (int) $payload['user_id'] ); + if ( $user && is_email( $user->user_email ) ) { + return sanitize_email( $user->user_email ); + } + } + // Fall back to a previous email (email-change retries) when the user is gone. + if ( isset( $payload['previous_email'] ) && is_string( $payload['previous_email'] ) && is_email( $payload['previous_email'] ) ) { + return sanitize_email( $payload['previous_email'] ); + } foreach ( $payload as $value ) { if ( is_array( $value ) ) { $found = self::extract_email_from_payload( $value, $depth + 1 ); @@ -617,4 +646,33 @@ private static function extract_email_from_payload( $payload, $depth = 0 ) { } return ''; } + + /** + * Find the first WordPress user ID referenced in a scheduled action's payload. + * + * Used to prime the user cache before bulk email resolution. Mirrors the + * structure walked by extract_email_from_payload(). + * + * @param mixed $payload Decoded args payload (array, scalar, or null). + * @param int $depth Current recursion depth (internal guard). + * + * @return int The first user ID found, or 0. + */ + private static function get_payload_user_id( $payload, $depth = 0 ) { + if ( $depth > 6 || ! is_array( $payload ) ) { + return 0; + } + if ( isset( $payload['user_id'] ) && is_numeric( $payload['user_id'] ) ) { + return (int) $payload['user_id']; + } + foreach ( $payload as $value ) { + if ( is_array( $value ) ) { + $user_id = self::get_payload_user_id( $value, $depth + 1 ); + if ( $user_id ) { + return $user_id; + } + } + } + return 0; + } }