300, 'display' => __('Every 5 Minutes', 'ailinkmaster-internal-auto-manager'), ); return $schedules; } /** * Enqueue a rule-change event for async replication to SaaS. * * This method is intentionally lightweight (single INSERT) and never * touches the network. It is safe to call from any code path, including * save_post hooks and AJAX handlers. * * If the SaaS server is not configured (no license key / server URL), * this is a silent no-op — the plugin works fully in standalone mode. * * @param string $action 'upsert' or 'delete'. * @param int $rule_id Rule ID (0 for bulk operations). * @param array $payload Optional. Rule data to replicate (donor_id, * target_id, target_url, anchor, occurrence, * source, status, in_html). Required for 'upsert'. * @return void */ public static function enqueue($action, $rule_id = 0, $payload = array()) { // Graceful degradation: if SaaS is not configured, skip enqueue. $license_key = get_option('ailm_saas_license_key', ''); $server_url = get_option('ailm_saas_server_url', ''); if (empty($license_key) || empty($server_url)) { return; } // Allow third-party code to disable replication entirely. if (apply_filters('ailm_disable_saas_replication', false)) { return; } global $wpdb; $table = AILM_Link_DB::get_sync_queue_table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $wpdb->insert( $table, array( 'action' => sanitize_key($action), 'rule_id' => (int) $rule_id, 'payload' => empty($payload) ? '' : wp_json_encode($payload), 'status' => 'pending', ), array('%s', '%d', '%s', '%s') ); } /** * Process one batch of pending events. * * Called by wp_cron. Reads up to BATCH_SIZE pending events, sends them * to the SaaS server in a single HTTP request, and marks them * 'synced' (success) or increments 'attempts' (failure). * * @return array { * @type int $synced Number of events successfully synced. * @type int $failed Number of events that failed (retry pending). * @type int $dropped Number of events dropped (max attempts reached). * } */ public static function process_batch() { $result = array('synced' => 0, 'failed' => 0, 'dropped' => 0); $license_key = get_option('ailm_saas_license_key', ''); $server_url = get_option('ailm_saas_server_url', ''); if (empty($license_key) || empty($server_url)) { return $result; // SaaS not configured — nothing to do. } global $wpdb; $table = AILM_Link_DB::get_sync_queue_table(); // Fetch a batch of pending events. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $events = $wpdb->get_results( $wpdb->prepare( "SELECT id, action, rule_id, payload, attempts FROM {$table} WHERE status = 'pending' ORDER BY id ASC LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared self::BATCH_SIZE ) ); if (empty($events)) { return $result; } // Build the batch payload for the SaaS server. $batch = array(); $event_ids = array(); foreach ($events as $ev) { $batch[] = array( 'queue_id' => (int) $ev->id, 'action' => $ev->action, 'rule_id' => (int) $ev->rule_id, 'data' => $ev->payload ? json_decode($ev->payload, true) : null, ); $event_ids[] = (int) $ev->id; } // Send to SaaS. $success = self::send_to_saas($batch); if ($success) { // Mark all as synced. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $wpdb->query( $wpdb->prepare( "UPDATE {$table} SET status = 'synced', updated_at = NOW() WHERE id IN (" . implode(',', array_fill(0, count($event_ids), '%d')) . ")", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $event_ids ) ); $result['synced'] = count($event_ids); } else { // Increment attempts; drop if max reached. foreach ($events as $ev) { $new_attempts = (int) $ev->attempts + 1; if ($new_attempts >= self::MAX_ATTEMPTS) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->update( $table, array('status' => 'failed', 'attempts' => $new_attempts, 'updated_at' => current_time('mysql')), array('id' => (int) $ev->id), array('%s', '%d', '%s'), array('%d') ); $result['dropped']++; } else { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->update( $table, array('status' => 'pending', 'attempts' => $new_attempts, 'updated_at' => current_time('mysql')), array('id' => (int) $ev->id), array('%s', '%d', '%s'), array('%d') ); $result['failed']++; } } } return $result; } /** * Send a batch of events to the SaaS server. * * Uses the same HMAC-SHA256 signing as AILM_OpenAI_Provider::request_saas(). * * @param array $batch Array of event payloads. * @return bool True on success, false on failure. */ private static function send_to_saas($batch) { $license_key = get_option('ailm_saas_license_key', ''); $server_url = rtrim(get_option('ailm_saas_server_url', ''), '/'); $site_url = get_site_url(); $endpoint = '/api/v1/sites/0/rules/sync'; $url = $server_url . $endpoint; $body = wp_json_encode(array('events' => $batch)); $signature = hash_hmac('sha256', $body, $license_key); $args = array( 'method' => 'POST', 'timeout' => 30, 'headers' => array( 'Content-Type' => 'application/json', 'X-Site-URL' => $site_url, 'X-Signature' => $signature, ), 'body' => $body, ); $response = wp_remote_request($url, $args); if (is_wp_error($response)) { if (defined('WP_DEBUG') && WP_DEBUG && defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log error_log('AILM Sync Queue: SaaS request failed — ' . $response->get_error_message()); } return false; } $status_code = wp_remote_retrieve_response_code($response); if ($status_code >= 200 && $status_code < 300) { return true; } if (defined('WP_DEBUG') && WP_DEBUG && defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log error_log('AILM Sync Queue: SaaS returned status ' . $status_code); } return false; } /** * Get queue statistics for the admin UI. * * @return array { * @type int $pending Number of pending events. * @type int $synced Number of synced events (all time). * @type int $failed Number of failed events. * } */ public static function get_stats() { global $wpdb; $table = AILM_Link_DB::get_sync_queue_table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $pending = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table} WHERE status = 'pending'"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $synced = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table} WHERE status = 'synced'"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $failed = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table} WHERE status = 'failed'"); return array( 'pending' => $pending, 'synced' => $synced, 'failed' => $failed, ); } /** * Clear synced events older than N days (housekeeping). * * @param int $days Number of days to keep synced events. Default 7. * @return int Number of rows deleted. */ public static function cleanup_old_synced($days = 7) { global $wpdb; $table = AILM_Link_DB::get_sync_queue_table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter return (int) $wpdb->query( $wpdb->prepare( "DELETE FROM {$table} WHERE status = 'synced' AND updated_at < DATE_SUB(NOW(), INTERVAL %d DAY)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $days ) ); } }