From 4fc6676e0c62ac539633c8767656cf6a288dad66 Mon Sep 17 00:00:00 2001 From: ben Date: Sat, 20 Dec 2025 09:06:59 -0400 Subject: [PATCH] fix: Zoho scheduled sync persistence issue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Load HVAC_Zoho_Scheduled_Sync on ALL requests (not just admin) so WP-Cron can find custom schedules and action hooks - Add add_option hook for first-time setting creation - Explicitly call schedule_sync() in save_settings() to ensure scheduling works even when option value hasn't changed 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- includes/admin/class-zoho-admin.php | 280 +++++++++++--- includes/class-hvac-plugin.php | 33 +- includes/zoho/class-zoho-scheduled-sync.php | 389 ++++++++++++++++++++ 3 files changed, 650 insertions(+), 52 deletions(-) create mode 100644 includes/zoho/class-zoho-scheduled-sync.php diff --git a/includes/admin/class-zoho-admin.php b/includes/admin/class-zoho-admin.php index 8f3ee018..ec285f9a 100644 --- a/includes/admin/class-zoho-admin.php +++ b/includes/admin/class-zoho-admin.php @@ -43,6 +43,8 @@ class HVAC_Zoho_Admin { add_action('wp_ajax_hvac_zoho_sync_data', array($this, 'sync_data')); add_action('wp_ajax_hvac_zoho_save_credentials', array($this, 'save_credentials')); add_action('wp_ajax_hvac_zoho_flush_rewrite_rules', array($this, 'flush_rewrite_rules_ajax')); + add_action('wp_ajax_hvac_zoho_save_settings', array($this, 'save_settings')); + add_action('wp_ajax_hvac_zoho_run_scheduled_sync', array($this, 'run_scheduled_sync_now')); // Add simple test handler add_action('wp_ajax_hvac_zoho_simple_test', array($this, 'simple_test')); // Add OAuth callback handler - only use one method to prevent duplicates @@ -50,8 +52,22 @@ class HVAC_Zoho_Admin { add_filter('query_vars', array($this, 'add_oauth_query_vars'), 10, 1); add_action('template_redirect', array($this, 'handle_oauth_template_redirect')); + // Fallback: Check for OAuth params on init (in case rewrite rules fail and we land on homepage) + add_action('init', array($this, 'check_for_oauth_params')); + // Ensure rewrite rules are flushed when plugin is activated register_activation_hook(HVAC_PLUGIN_FILE, array($this, 'flush_rewrite_rules_on_activation')); + + // Initialize scheduled sync if enabled + $this->init_scheduled_sync(); + } + + /** + * Initialize scheduled sync + */ + private function init_scheduled_sync() { + require_once HVAC_PLUGIN_DIR . 'includes/zoho/class-zoho-scheduled-sync.php'; + HVAC_Zoho_Scheduled_Sync::instance(); } /** @@ -125,27 +141,13 @@ class HVAC_Zoho_Admin { // Debug logging - // More robust production detection - $parsed_url = parse_url($site_url); - $host = isset($parsed_url['host']) ? $parsed_url['host'] : ''; - - // Remove www prefix for comparison - $clean_host = preg_replace('/^www\./', '', $host); - - // Check if this is production - $is_production = ($clean_host === 'upskillhvac.com'); - - // Double-check with string comparison as fallback - if (!$is_production) { - $is_production = (strpos($site_url, 'upskillhvac.com') !== false && - strpos($site_url, 'staging') === false && - strpos($site_url, 'test') === false && - strpos($site_url, 'dev') === false && - strpos($site_url, 'cloudwaysapps.com') === false); + // Ensure Auth class is loaded for staging detection + if (!class_exists('HVAC_Zoho_CRM_Auth')) { + require_once HVAC_PLUGIN_DIR . 'includes/zoho/class-zoho-crm-auth.php'; } - // Set staging as opposite of production - $is_staging = !$is_production; + // Use central logic for staging detection + $is_staging = HVAC_Zoho_CRM_Auth::is_staging_mode(); // Load secure storage class @@ -268,6 +270,9 @@ class HVAC_Zoho_Admin { 🚀 Authorize with Zoho +

@@ -329,24 +334,82 @@ class HVAC_Zoho_Admin {
-

Sync Settings

+

Scheduled Sync Settings

+ get_status(); + $current_frequency = get_option('hvac_zoho_sync_frequency', 'every_5_minutes'); + ?>
- -

- -

- + + + + + + + + + + + + + +
Enable Scheduled Sync + +

When enabled, a background process will sync changes on the selected interval.

+
Sync Interval + +

How often to check for and sync new/modified records.

+
Sync Status +

+ Status: + + ✓ Scheduled + + ✗ Not Scheduled + +

+

+ Last Sync: + +

+ +

+ Next Sync: + +

+ + +

+ Last Result: + +

+ +
+ +

+ + +

+
@@ -359,8 +422,23 @@ class HVAC_Zoho_Admin { * Simple test handler to isolate issues */ public function simple_test() { + // Check permissions + if (!current_user_can('manage_options')) { + wp_send_json_error(array('message' => 'Unauthorized access')); + return; + } - wp_send_json_success(array('message' => 'Simple test works!')); + $payload_status = 'No payload received'; + if (!empty($_POST['test_payload'])) { + $payload_status = 'Payload received (' . strlen($_POST['test_payload']) . ' chars)'; + } + + wp_send_json_success(array( + 'message' => 'Simple test works!', + 'server_time' => date('Y-m-d H:i:s'), + 'payload_status' => $payload_status, + 'request_method' => $_SERVER['REQUEST_METHOD'] + )); } /** @@ -488,9 +566,6 @@ class HVAC_Zoho_Admin { } } - /** - * Parse OAuth request using parse_request hook - */ public function parse_oauth_request($wp) { // Check if this is an OAuth callback request @@ -506,6 +581,33 @@ class HVAC_Zoho_Admin { } } } + + /** + * Manual Router for OAuth Callback + * + * Catches the request on 'init' before WordPress internal routing can 404 it. + * This is a robust fallback for when rewrite rules fail or haven't flushed. + */ + public function check_for_oauth_params() { + // Check if we are at the oauth callback URL path + $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); + + // Check strict path match or if regex matches + if (strpos($path, '/oauth/callback') !== false) { + + // We are at the right URL. Do we have the code? + if (isset($_GET['code'])) { + + // We have a code. + // We MUST process this, otherwise WP will display a 404. + // Even if state matches or not, we should handle it here. + // The process_oauth_callback method handles validation. + + $this->process_oauth_callback(); + // process_oauth_callback exits, so we won't continue to 404. + } + } + } /** * Add OAuth callback rewrite rule @@ -794,10 +896,14 @@ class HVAC_Zoho_Admin { return; } + // Success! // Success! wp_send_json_success(array( 'message' => 'Connection successful!', 'modules' => isset($response['modules']) ? count($response['modules']) . ' modules available' : 'API connected', + 'client_id' => substr($client_id, 0, 10) . '...', + 'client_secret_exists' => true, + 'refresh_token_exists' => true, 'credentials_status' => array( 'client_id' => substr($client_id, 0, 10) . '...', 'client_secret_exists' => true, @@ -847,6 +953,8 @@ class HVAC_Zoho_Admin { } $type = sanitize_text_field($_POST['type']); + $offset = isset($_POST['offset']) ? absint($_POST['offset']) : 0; + $limit = isset($_POST['limit']) ? absint($_POST['limit']) : 50; try { require_once HVAC_PLUGIN_DIR . 'includes/zoho/class-zoho-sync.php'; @@ -854,19 +962,19 @@ class HVAC_Zoho_Admin { switch ($type) { case 'events': - $result = $sync->sync_events(); + $result = $sync->sync_events($offset, $limit); break; case 'users': - $result = $sync->sync_users(); + $result = $sync->sync_users($offset, $limit); break; case 'attendees': - $result = $sync->sync_attendees(); + $result = $sync->sync_attendees($offset, $limit); break; case 'rsvps': - $result = $sync->sync_rsvps(); + $result = $sync->sync_rsvps($offset, $limit); break; case 'purchases': - $result = $sync->sync_purchases(); + $result = $sync->sync_purchases($offset, $limit); break; default: throw new Exception('Invalid sync type'); @@ -880,5 +988,89 @@ class HVAC_Zoho_Admin { )); } } + + /** + * Save scheduled sync settings + */ + public function save_settings() { + check_ajax_referer('hvac_zoho_nonce', 'nonce'); + + if (!current_user_can('manage_options')) { + wp_send_json_error(array('message' => 'Unauthorized access')); + return; + } + + $auto_sync = isset($_POST['auto_sync']) && $_POST['auto_sync'] === '1' ? '1' : '0'; + $sync_frequency = sanitize_text_field($_POST['sync_frequency'] ?? 'every_5_minutes'); + + // Validate frequency value + $valid_frequencies = array( + 'every_5_minutes', + 'every_15_minutes', + 'every_30_minutes', + 'hourly', + 'every_6_hours', + 'daily' + ); + + if (!in_array($sync_frequency, $valid_frequencies)) { + $sync_frequency = 'every_5_minutes'; + } + + // Save settings + update_option('hvac_zoho_auto_sync', $auto_sync); + update_option('hvac_zoho_sync_frequency', $sync_frequency); + + // Get scheduled sync instance and explicitly schedule/unschedule + // This ensures scheduling works even if option value didn't change + require_once HVAC_PLUGIN_DIR . 'includes/zoho/class-zoho-scheduled-sync.php'; + $scheduled_sync = HVAC_Zoho_Scheduled_Sync::instance(); + + if ($auto_sync === '1') { + $scheduled_sync->schedule_sync($sync_frequency); + } else { + $scheduled_sync->unschedule_sync(); + } + + $status = $scheduled_sync->get_status(); + + wp_send_json_success(array( + 'message' => 'Settings saved successfully', + 'auto_sync' => $auto_sync, + 'sync_frequency' => $sync_frequency, + 'is_scheduled' => $status['is_scheduled'], + 'next_sync' => $status['next_sync_formatted'] + )); + } + + /** + * Run scheduled sync manually + */ + public function run_scheduled_sync_now() { + check_ajax_referer('hvac_zoho_nonce', 'nonce'); + + if (!current_user_can('manage_options')) { + wp_send_json_error(array('message' => 'Unauthorized access')); + return; + } + + try { + require_once HVAC_PLUGIN_DIR . 'includes/zoho/class-zoho-scheduled-sync.php'; + $scheduled_sync = HVAC_Zoho_Scheduled_Sync::instance(); + + $result = $scheduled_sync->run_now(); + + wp_send_json_success(array( + 'message' => 'Scheduled sync completed', + 'result' => $result + )); + } catch (Exception $e) { + wp_send_json_error(array( + 'message' => 'Sync failed', + 'error' => $e->getMessage() + )); + } + } + } ?> \ No newline at end of file diff --git a/includes/class-hvac-plugin.php b/includes/class-hvac-plugin.php index 0725865d..c9ba3593 100644 --- a/includes/class-hvac-plugin.php +++ b/includes/class-hvac-plugin.php @@ -337,8 +337,11 @@ final class HVAC_Plugin { 'admin/class-hvac-enhanced-settings.php', ]; + // Check if this is an OAuth callback request + $is_oauth_callback = isset($_SERVER['REQUEST_URI']) && strpos($_SERVER['REQUEST_URI'], '/oauth/callback') !== false; + // Load admin files conditionally - if (is_admin() || wp_doing_ajax()) { + if (is_admin() || wp_doing_ajax() || $is_oauth_callback) { foreach ($this->loadFeatureFiles($adminFiles) as $file => $status) { if ($status === 'loaded') { $this->componentStatus["admin_{$file}"] = true; @@ -548,8 +551,10 @@ final class HVAC_Plugin { // Schedule non-critical components for lazy loading // Use 'init' instead of 'wp_loaded' so components can register wp_enqueue_scripts hooks add_action('init', [$this, 'initializeSecondaryComponents'], 5); - // Use admin_menu (not admin_init) so components can register their menus in time - add_action('admin_menu', [$this, 'initializeAdminComponents'], 5); + + // Use 'init' for admin components too, ensuring AJAX handlers are registered + // (admin_menu hook doesn't fire on AJAX requests, causing 400 Bad Request/0 response) + add_action('init', [$this, 'initializeAdminComponents'], 5); } /** @@ -701,7 +706,16 @@ final class HVAC_Plugin { if (class_exists('HVAC_Communication_Scheduler')) { hvac_communication_scheduler(); } - + + // Initialize Zoho scheduled sync (must load on ALL requests for WP-Cron to work) + // This registers custom cron schedules and the cron action hook + if (file_exists(HVAC_PLUGIN_DIR . 'includes/zoho/class-zoho-scheduled-sync.php')) { + require_once HVAC_PLUGIN_DIR . 'includes/zoho/class-zoho-scheduled-sync.php'; + if (class_exists('HVAC_Zoho_Scheduled_Sync')) { + HVAC_Zoho_Scheduled_Sync::instance(); + } + } + // Initialize Master Trainer manager classes (fix for missing shortcode registrations) if (class_exists('HVAC_Master_Events_Overview')) { HVAC_Master_Events_Overview::instance(); @@ -731,19 +745,22 @@ final class HVAC_Plugin { * Only loads admin components in admin context to improve frontend performance. */ public function initializeAdminComponents(): void { + // Check if this is an OAuth callback request + $is_oauth_callback = isset($_SERVER['REQUEST_URI']) && strpos($_SERVER['REQUEST_URI'], '/oauth/callback') !== false; + // Initialize admin components only when needed - if (class_exists('HVAC_Zoho_Admin')) { + if (class_exists('HVAC_Zoho_Admin') && (is_admin() || wp_doing_ajax() || $is_oauth_callback)) { HVAC_Zoho_Admin::instance(); } - if (class_exists('HVAC_Admin_Dashboard')) { + if (class_exists('HVAC_Admin_Dashboard') && is_admin()) { new HVAC_Admin_Dashboard(); } - if (class_exists('HVAC_Enhanced_Settings')) { + if (class_exists('HVAC_Enhanced_Settings') && is_admin()) { HVAC_Enhanced_Settings::instance(); } // Initialize trainer certification admin interface - if (class_exists('HVAC_Certification_Admin') && current_user_can('manage_hvac_certifications')) { + if (class_exists('HVAC_Certification_Admin') && current_user_can('manage_hvac_certifications') && is_admin()) { HVAC_Certification_Admin::instance(); } } diff --git a/includes/zoho/class-zoho-scheduled-sync.php b/includes/zoho/class-zoho-scheduled-sync.php new file mode 100644 index 00000000..409f7e84 --- /dev/null +++ b/includes/zoho/class-zoho-scheduled-sync.php @@ -0,0 +1,389 @@ + 5 * MINUTE_IN_SECONDS, + 'display' => __('Every 5 Minutes', 'hvac-community-events') + ); + + $schedules['every_15_minutes'] = array( + 'interval' => 15 * MINUTE_IN_SECONDS, + 'display' => __('Every 15 Minutes', 'hvac-community-events') + ); + + $schedules['every_30_minutes'] = array( + 'interval' => 30 * MINUTE_IN_SECONDS, + 'display' => __('Every 30 Minutes', 'hvac-community-events') + ); + + $schedules['every_6_hours'] = array( + 'interval' => 6 * HOUR_IN_SECONDS, + 'display' => __('Every 6 Hours', 'hvac-community-events') + ); + + return $schedules; + } + + /** + * Schedule the sync cron event + * + * @param string|null $interval Optional interval override + * @return bool True if scheduled successfully + */ + public function schedule_sync($interval = null) { + // Unschedule any existing event first + $this->unschedule_sync(); + + // Get interval from option if not provided + if (null === $interval) { + $interval = get_option(self::OPTION_INTERVAL, 'every_5_minutes'); + } + + // Schedule the event + $result = wp_schedule_event(time(), $interval, self::CRON_HOOK); + + if ($result !== false) { + if (class_exists('HVAC_Logger')) { + HVAC_Logger::info("Scheduled Zoho sync with interval: {$interval}", 'ZohoScheduledSync'); + } + return true; + } + + return false; + } + + /** + * Unschedule the sync cron event + * + * @return bool True if unscheduled successfully + */ + public function unschedule_sync() { + $timestamp = wp_next_scheduled(self::CRON_HOOK); + + if ($timestamp) { + wp_unschedule_event($timestamp, self::CRON_HOOK); + + if (class_exists('HVAC_Logger')) { + HVAC_Logger::info('Unscheduled Zoho sync', 'ZohoScheduledSync'); + } + return true; + } + + return false; + } + + /** + * Check if sync is currently scheduled + * + * @return bool + */ + public function is_scheduled() { + return wp_next_scheduled(self::CRON_HOOK) !== false; + } + + /** + * Get next scheduled sync time + * + * @return int|false Timestamp or false if not scheduled + */ + public function get_next_scheduled() { + return wp_next_scheduled(self::CRON_HOOK); + } + + /** + * Handle first-time option creation + * + * @param string $option Option name + * @param mixed $value Option value + */ + public function on_setting_added($option, $value) { + if ($value === '1' || $value === 1 || $value === true) { + $this->schedule_sync(); + } + } + + /** + * Handle setting enabled/disabled change + * + * @param mixed $old_value Old option value + * @param mixed $new_value New option value + */ + public function on_setting_change($old_value, $new_value) { + if ($new_value === '1' || $new_value === 1 || $new_value === true) { + $this->schedule_sync(); + } else { + $this->unschedule_sync(); + } + } + + /** + * Handle interval change + * + * @param mixed $old_value Old interval value + * @param mixed $new_value New interval value + */ + public function on_interval_change($old_value, $new_value) { + // Only reschedule if currently enabled + if (get_option(self::OPTION_ENABLED) === '1') { + $this->schedule_sync($new_value); + } + } + + /** + * Run the scheduled sync + * + * This is the main cron callback that syncs all data types. + */ + public function run_scheduled_sync() { + $start_time = time(); + + if (class_exists('HVAC_Logger')) { + HVAC_Logger::info('Starting scheduled Zoho sync', 'ZohoScheduledSync'); + } + + // Get last sync time for incremental sync + $last_sync = $this->get_last_sync_time(); + + // Initialize results + $results = array( + 'started_at' => date('Y-m-d H:i:s', $start_time), + 'last_sync_from' => $last_sync ? date('Y-m-d H:i:s', $last_sync) : 'Never', + 'events' => null, + 'users' => null, + 'attendees' => null, + 'rsvps' => null, + 'purchases' => null, + 'errors' => array(), + ); + + try { + // Load dependencies + require_once HVAC_PLUGIN_DIR . 'includes/zoho/class-zoho-sync.php'; + $sync = new HVAC_Zoho_Sync(); + + // Sync each type with since_timestamp for incremental sync + // Events + $results['events'] = $this->sync_all_batches($sync, 'sync_events', $last_sync); + + // Users/Trainers + $results['users'] = $this->sync_all_batches($sync, 'sync_users', $last_sync); + + // Attendees + $results['attendees'] = $this->sync_all_batches($sync, 'sync_attendees', $last_sync); + + // RSVPs + $results['rsvps'] = $this->sync_all_batches($sync, 'sync_rsvps', $last_sync); + + // Purchases/Orders + $results['purchases'] = $this->sync_all_batches($sync, 'sync_purchases', $last_sync); + + } catch (Exception $e) { + $results['errors'][] = $e->getMessage(); + + if (class_exists('HVAC_Logger')) { + HVAC_Logger::error('Scheduled sync error: ' . $e->getMessage(), 'ZohoScheduledSync'); + } + } + + // Update last sync time + $this->set_last_sync_time($start_time); + + // Calculate totals + $total_synced = 0; + $total_failed = 0; + foreach (array('events', 'users', 'attendees', 'rsvps', 'purchases') as $type) { + if (isset($results[$type]['synced'])) { + $total_synced += $results[$type]['synced']; + } + if (isset($results[$type]['failed'])) { + $total_failed += $results[$type]['failed']; + } + } + + $results['completed_at'] = date('Y-m-d H:i:s'); + $results['duration_seconds'] = time() - $start_time; + $results['total_synced'] = $total_synced; + $results['total_failed'] = $total_failed; + + // Save last result + update_option(self::OPTION_LAST_RESULT, $results); + + if (class_exists('HVAC_Logger')) { + HVAC_Logger::info("Scheduled sync completed: {$total_synced} synced, {$total_failed} failed", 'ZohoScheduledSync'); + } + + return $results; + } + + /** + * Sync all batches for a given sync method + * + * @param HVAC_Zoho_Sync $sync Sync instance + * @param string $method Method name (e.g., 'sync_events') + * @param int|null $since_timestamp Optional timestamp for incremental sync + * @return array Aggregated results + */ + private function sync_all_batches($sync, $method, $since_timestamp = null) { + $offset = 0; + $limit = 50; + $aggregated = array( + 'total' => 0, + 'synced' => 0, + 'failed' => 0, + 'errors' => array(), + ); + + do { + // Call the sync method with since_timestamp support + $result = $sync->$method($offset, $limit, $since_timestamp); + + // Aggregate results + $aggregated['total'] = $result['total'] ?? 0; + $aggregated['synced'] += $result['synced'] ?? 0; + $aggregated['failed'] += $result['failed'] ?? 0; + + if (!empty($result['errors'])) { + $aggregated['errors'] = array_merge($aggregated['errors'], $result['errors']); + } + + // Check for more batches + $has_more = $result['has_more'] ?? false; + $offset = $result['next_offset'] ?? ($offset + $limit); + + } while ($has_more); + + return $aggregated; + } + + /** + * Get the last sync timestamp + * + * @return int|null Timestamp or null if never synced + */ + public function get_last_sync_time() { + $time = get_option(self::OPTION_LAST_SYNC); + return $time ? (int) $time : null; + } + + /** + * Set the last sync timestamp + * + * @param int $timestamp Timestamp + */ + public function set_last_sync_time($timestamp) { + update_option(self::OPTION_LAST_SYNC, $timestamp); + } + + /** + * Get the last sync result + * + * @return array|null Result array or null + */ + public function get_last_sync_result() { + return get_option(self::OPTION_LAST_RESULT); + } + + /** + * Get sync status summary + * + * @return array Status information + */ + public function get_status() { + $is_enabled = get_option(self::OPTION_ENABLED) === '1'; + $interval = get_option(self::OPTION_INTERVAL, 'every_5_minutes'); + $last_sync = $this->get_last_sync_time(); + $next_sync = $this->get_next_scheduled(); + $last_result = $this->get_last_sync_result(); + + return array( + 'enabled' => $is_enabled, + 'interval' => $interval, + 'is_scheduled' => $this->is_scheduled(), + 'last_sync_time' => $last_sync, + 'last_sync_formatted' => $last_sync ? date('Y-m-d H:i:s', $last_sync) : 'Never', + 'next_sync_time' => $next_sync, + 'next_sync_formatted' => $next_sync ? date('Y-m-d H:i:s', $next_sync) : 'Not scheduled', + 'last_result' => $last_result, + ); + } + + /** + * Run sync manually (for "Run Now" button) + * + * @return array Sync results + */ + public function run_now() { + return $this->run_scheduled_sync(); + } +}