getMessage() ) ); throw $e; } AffiliateBoxes_Logger::log( sprintf( 'Successfully updated product %s (Post ID: %d)', $product_slug, $post_id ) ); /** * Fires after a product has been synced from affiliateboxes.com. * * @param int $post_id The affiliate_product post ID. * @param string $product_slug The product slug. * @param string $html The stored markup, before sanitization on output. */ do_action( 'affiliateboxes_product_updated', $post_id, $product_slug, get_post_meta( $post_id, '_affprod_html', true ) ); } /** * Remove a product that no longer exists on affiliateboxes.com. * * @param string $product_slug The product slug. */ public function handle_product_delete( $product_slug ) { $product_slug = sanitize_text_field( $product_slug ); AffiliateBoxes_Utils::delete_product_by_slug( $product_slug ); AffiliateBoxes_Logger::log( sprintf( 'Successfully deleted product %s', $product_slug ) ); do_action( 'affiliateboxes_product_deleted', $product_slug ); } /** * Cancel queued jobs for a product so a newer one supersedes them. * * @param string $product_slug The product slug. * @param string $hook The Action Scheduler hook to cancel. */ private static function cancel_pending_actions( $product_slug, $hook ) { global $wpdb; $hook = sanitize_key( $hook ); $product_slug = sanitize_text_field( $product_slug ); $table_actions = $wpdb->actionscheduler_actions; // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name cannot use placeholders. $action_ids = $wpdb->get_col( $wpdb->prepare( "SELECT action_id FROM {$table_actions} WHERE hook = %s AND args LIKE %s AND status IN ('pending','in-progress')", $hook, '%' . $wpdb->esc_like( '"' . $product_slug . '"' ) . '%' ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared foreach ( $action_ids as $action_id ) { as_unschedule_action( $hook, null, 'affiliateboxes' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->update( $table_actions, array( 'status' => 'canceled' ), array( 'action_id' => (int) $action_id ), array( '%s' ), array( '%d' ) ); } if ( ! empty( $action_ids ) ) { AffiliateBoxes_Logger::log( sprintf( 'Canceled %d pending action(s) for product %s', count( $action_ids ), $product_slug ) ); } } /** * Queue a sync for a single product. * * @param string $product_slug The product slug. * @param string|null $product_title The product title. * @param string|null $product_type The product type. * @param string|null $product_identifier The product identifier. * @return int The scheduled action ID. */ public static function schedule_product_update( $product_slug, $product_title = null, $product_type = null, $product_identifier = null ) { $product_slug = sanitize_text_field( $product_slug ); self::cancel_pending_actions( $product_slug, 'affiliateboxes_update_product' ); return as_schedule_single_action( time(), 'affiliateboxes_update_product', array( $product_slug, $product_title, $product_type, $product_identifier ), 'affiliateboxes' ); } /** * Queue a delete for a single product. * * @param string $product_slug The product slug. * @return int The scheduled action ID. */ public static function schedule_product_delete( $product_slug ) { $product_slug = sanitize_text_field( $product_slug ); self::cancel_pending_actions( $product_slug, 'affiliateboxes_delete_product' ); return as_schedule_single_action( time(), 'affiliateboxes_delete_product', array( $product_slug ), 'affiliateboxes' ); } /** * Queue a sync for every product in a catalogue listing. * * @param array $products Product records from the remote API. * @return string The batch ID used to track progress. */ public static function schedule_bulk_sync( $products ) { $batch_id = 'sync_' . time() . '_' . wp_generate_password( 8, false ); $scheduled_count = 0; foreach ( $products as $product ) { if ( empty( $product['slug'] ) ) { continue; } $product_slug = sanitize_text_field( $product['slug'] ); $product_title = $product['name'] ?? null; $product_type = $product['type'] ?? null; $product_identifier = $product['identifier'] ?? null; as_schedule_single_action( time(), 'affiliateboxes_update_product', array( $product_slug, $product_title, $product_type, $product_identifier, $batch_id ), 'affiliateboxes' ); ++$scheduled_count; } set_transient( 'affiliateboxes_sync_batch_' . $batch_id, array( 'total' => $scheduled_count, 'start_time' => current_time( 'mysql' ), 'products' => array_column( $products, 'slug' ), ), HOUR_IN_SECONDS ); return $batch_id; } /** * Report progress for one bulk sync batch. * * @param string $batch_id The batch ID returned by schedule_bulk_sync(). * @return array Counts and completion state for the batch. */ public static function get_sync_queue_status( $batch_id ) { global $wpdb; $batch_id = sanitize_text_field( $batch_id ); $batch_data = get_transient( 'affiliateboxes_sync_batch_' . $batch_id ); if ( ! $batch_data ) { return array( 'error' => 'Batch not found or expired', 'batch_id' => $batch_id, ); } $table_actions = $wpdb->actionscheduler_actions; // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name cannot use placeholders. $pending = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table_actions} WHERE hook = %s AND args LIKE %s AND status IN ('pending','in-progress')", 'affiliateboxes_update_product', '%' . $wpdb->esc_like( $batch_id ) . '%' ) ); $completed = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table_actions} WHERE hook = %s AND args LIKE %s AND status = 'complete'", 'affiliateboxes_update_product', '%' . $wpdb->esc_like( $batch_id ) . '%' ) ); $failed = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table_actions} WHERE hook = %s AND args LIKE %s AND status = 'failed'", 'affiliateboxes_update_product', '%' . $wpdb->esc_like( $batch_id ) . '%' ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $is_complete = ( 0 === $pending ); if ( $is_complete ) { delete_transient( 'affiliateboxes_sync_batch_' . $batch_id ); } return array( 'batch_id' => $batch_id, 'total' => (int) $batch_data['total'], 'completed' => $completed, 'pending' => $pending, 'failed' => $failed, 'is_complete' => $is_complete, 'start_time' => $batch_data['start_time'], ); } /** * Force run pending actions in the queue. */ public static function force_run_queue() { if ( class_exists( 'ActionScheduler_QueueRunner' ) ) { ActionScheduler_QueueRunner::instance()->run(); } } /** * Get overall queue status (not batch-specific). * * @return array Counts and completion state across all queued product jobs. */ public static function get_overall_queue_status() { global $wpdb; $table_actions = $wpdb->actionscheduler_actions; // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name cannot use placeholders. $pending = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table_actions} WHERE hook IN (%s, %s) AND status IN ('pending','in-progress')", 'affiliateboxes_update_product', 'affiliateboxes_delete_product' ) ); $completed = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table_actions} WHERE hook IN (%s, %s) AND status = 'complete'", 'affiliateboxes_update_product', 'affiliateboxes_delete_product' ) ); $failed = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table_actions} WHERE hook IN (%s, %s) AND status = 'failed'", 'affiliateboxes_update_product', 'affiliateboxes_delete_product' ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared return array( 'pending' => $pending, 'completed' => $completed, 'failed' => $failed, 'is_complete' => ( 0 === $pending ), ); } }