auth = new HVAC_Zoho_CRM_Auth(); // Determine if we're in staging mode $this->is_staging = HVAC_Zoho_CRM_Auth::is_staging_mode(); } /** * Check if sync is allowed * * @return bool */ private function is_sync_allowed() { // Use consistent logic from Auth class return !HVAC_Zoho_CRM_Auth::is_staging_mode(); } /** * Generate a hash for sync data to detect changes * * @param array $data Data being synced * @return string MD5 hash */ private function generate_sync_hash($data) { // Sort keys for consistent hashing ksort($data); return md5(json_encode($data)); } /** * Check if a post record should be synced based on hash comparison * * @param int $post_id Post ID * @param array $data Data being synced * @param string $meta_key Meta key for storing hash (default: _zoho_sync_hash) * @return bool True if data has changed and should be synced */ private function should_sync($post_id, $data, $meta_key = '_zoho_sync_hash') { $new_hash = $this->generate_sync_hash($data); $old_hash = get_post_meta($post_id, $meta_key, true); return $new_hash !== $old_hash; } /** * Check if a user should be synced based on hash comparison * * @param int $user_id User ID * @param array $data Data being synced * @return bool True if data has changed and should be synced */ private function should_sync_user($user_id, $data) { $new_hash = $this->generate_sync_hash($data); $old_hash = get_user_meta($user_id, '_zoho_sync_hash', true); return $new_hash !== $old_hash; } /** * Sync events to Zoho Campaigns * * @param int $offset Starting offset for pagination * @param int $limit Number of records per batch * @param int|null $since_timestamp Optional timestamp to filter modified records * @return array Sync results */ public function sync_events($offset = 0, $limit = 50, $since_timestamp = null) { $results = array( 'total' => 0, 'synced' => 0, 'skipped' => 0, 'failed' => 0, 'errors' => array(), 'staging_mode' => $this->is_staging, 'debug_info' => HVAC_Zoho_CRM_Auth::get_debug_mode_info(), 'has_more' => false, 'next_offset' => 0, 'batch_offset' => $offset, 'incremental_sync' => !is_null($since_timestamp) ); // Build base query args $base_event_args = array( 'posts_per_page' => -1, 'eventDisplay' => 'custom', 'start_date' => '2020-01-01', ); $base_series_args = array( 'post_type' => 'tribe_event_series', 'posts_per_page' => -1, 'post_status' => 'publish', ); // Add date filter for incremental sync if ($since_timestamp) { $since_date = date('Y-m-d H:i:s', $since_timestamp); $date_query = array( array( 'column' => 'post_modified', 'after' => $since_date, 'inclusive' => true, ) ); $base_event_args['date_query'] = $date_query; $base_series_args['date_query'] = $date_query; } // First, get total count $all_events = tribe_get_events($base_event_args); $event_series = get_posts($base_series_args); $total_count = count($all_events) + count($event_series); $results['total'] = $total_count; // Get paginated events $paginated_args = array_merge($base_event_args, array( 'posts_per_page' => $limit, 'offset' => $offset, )); $events = tribe_get_events($paginated_args); // Also get event series (paginated based on remaining limit) $remaining = max(0, $limit - count($events)); $series_offset = max(0, $offset - count($all_events)); if ($remaining > 0 && $series_offset >= 0) { $event_series_batch = get_posts(array( 'post_type' => 'tribe_event_series', 'posts_per_page' => $remaining, 'offset' => $series_offset, 'post_status' => 'publish', )); $events = array_merge($events, $event_series_batch); } // Calculate pagination $results['has_more'] = ($offset + count($events)) < $total_count; $results['next_offset'] = $offset + count($events); // If staging mode, simulate the sync if ($this->is_staging) { $results['message'] = 'STAGING MODE: Sync simulation only. No data sent to Zoho.'; $results['synced'] = $results['total']; $results['test_data'] = array(); foreach ($events as $event) { $campaign_data = $this->prepare_campaign_data($event); $results['test_data'][] = array( 'event_id' => $event->ID, 'event_title' => get_the_title($event), 'zoho_data' => $campaign_data ); } return $results; } // Production sync if (!$this->is_sync_allowed()) { $results['errors'][] = 'Sync not allowed on this domain. Only upskillhvac.com can sync to production.'; return $results; } foreach ($events as $event) { try { $campaign_data = $this->prepare_campaign_data($event); // Check if data has changed using hash comparison if (!$this->should_sync($event->ID, $campaign_data)) { $results['skipped']++; continue; } $campaign_id = null; // FIRST: Check if we already have a stored Zoho Campaign ID $stored_campaign_id = get_post_meta($event->ID, '_zoho_campaign_id', true); if (!empty($stored_campaign_id)) { // We have a stored ID - try to update $update_response = $this->auth->make_api_request("/Campaigns/{$stored_campaign_id}", 'PUT', array( 'data' => array($campaign_data) )); // Check if update failed due to invalid ID (e.g. campaign deleted in Zoho) if (isset($update_response['code']) && $update_response['code'] === 'INVALID_DATA') { // Fallback: Create new campaign $create_response = $this->auth->make_api_request('/Campaigns', 'POST', array( 'data' => array($campaign_data) )); $results['responses'][] = array('type' => 'create_fallback', 'id' => $event->ID, 'response' => $create_response); if (!empty($create_response['data'][0]['details']['id'])) { $campaign_id = $create_response['data'][0]['details']['id']; } } else { $campaign_id = $stored_campaign_id; $results['responses'][] = array('type' => 'update', 'id' => $event->ID, 'response' => $update_response); } } else { // No stored ID - create new campaign $create_response = $this->auth->make_api_request('/Campaigns', 'POST', array( 'data' => array($campaign_data) )); $results['responses'][] = array('type' => 'create', 'id' => $event->ID, 'response' => $create_response); // Extract campaign ID from create response if (!empty($create_response['data'][0]['details']['id'])) { $campaign_id = $create_response['data'][0]['details']['id']; } } $results['synced']++; // Update event meta with Zoho Campaign ID and sync hash if (!empty($campaign_id)) { update_post_meta($event->ID, '_zoho_campaign_id', $campaign_id); } update_post_meta($event->ID, '_zoho_sync_hash', $this->generate_sync_hash($campaign_data)); } catch (Exception $e) { $results['failed']++; $results['errors'][] = sprintf('Event %s: %s', $event->ID, $e->getMessage()); } } return $results; } /** * Sync users to Zoho Contacts * * @param int $offset Starting offset for pagination * @param int $limit Number of records per batch * @param int|null $since_timestamp Optional timestamp to filter modified records * @return array Sync results */ public function sync_users($offset = 0, $limit = 50, $since_timestamp = null) { $results = array( 'total' => 0, 'synced' => 0, 'skipped' => 0, 'failed' => 0, 'errors' => array(), 'staging_mode' => $this->is_staging, 'has_more' => false, 'next_offset' => 0, 'batch_offset' => $offset, 'incremental_sync' => !is_null($since_timestamp) ); // Base query args $base_args = array( 'role__in' => array('hvac_trainer', 'hvac_master_trainer'), 'meta_query' => array( 'relation' => 'OR', array( 'key' => '_sync_to_zoho', 'value' => '1', 'compare' => '=' ), array( 'key' => '_sync_to_zoho', 'compare' => 'NOT EXISTS' ) ) ); // Note: WP_User_Query doesn't support date_query for user_registered // For users, we'll sync all and let Zoho handle updates // Future enhancement: track _zoho_last_synced per user // Get total count first $total_query = new WP_User_Query(array_merge($base_args, array('count_total' => true, 'number' => -1))); $total_count = $total_query->get_total(); $results['total'] = $total_count; // Get paginated users $users = get_users(array_merge($base_args, array( 'number' => $limit, 'offset' => $offset ))); // Calculate pagination $results['has_more'] = ($offset + count($users)) < $total_count; $results['next_offset'] = $offset + count($users); // If staging mode, simulate the sync if ($this->is_staging) { $results['message'] = 'STAGING MODE: Sync simulation only. No data sent to Zoho.'; $results['synced'] = $results['total']; $results['test_data'] = array(); foreach ($users as $user) { $contact_data = $this->prepare_contact_data($user); $results['test_data'][] = array( 'user_id' => $user->ID, 'user_email' => $user->user_email, 'user_role' => implode(', ', $user->roles), 'zoho_data' => $contact_data ); } return $results; } // Production sync if (!$this->is_sync_allowed()) { $results['errors'][] = 'Sync not allowed on this domain. Only upskillhvac.com can sync to production.'; return $results; } foreach ($users as $user) { try { $contact_data = $this->prepare_contact_data($user); // Check if data has changed using hash comparison if (!$this->should_sync_user($user->ID, $contact_data)) { $results['skipped']++; continue; } // Check if contact already exists in Zoho $search_response = $this->auth->make_api_request('/Contacts/search', 'GET', array( 'criteria' => "(Email:equals:{$contact_data['Email']})" )); if (!empty($search_response['data'])) { // Update existing contact $contact_id = $search_response['data'][0]['id']; $update_response = $this->auth->make_api_request("/Contacts/{$contact_id}", 'PUT', array( 'data' => array($contact_data) )); } else { // Create new contact $create_response = $this->auth->make_api_request('/Contacts', 'POST', array( 'data' => array($contact_data) )); if (!empty($create_response['data'][0]['details']['id'])) { $contact_id = $create_response['data'][0]['details']['id']; } } $results['synced']++; // Update user meta with Zoho ID and sync hash if (isset($contact_id)) { update_user_meta($user->ID, '_zoho_contact_id', $contact_id); } update_user_meta($user->ID, '_zoho_sync_hash', $this->generate_sync_hash($contact_data)); } catch (Exception $e) { $results['failed']++; $results['errors'][] = sprintf('User %s: %s', $user->ID, $e->getMessage()); } } return $results; } /** * Sync Event Tickets orders to Zoho Invoices * * @param int $offset Starting offset for pagination * @param int $limit Number of records per batch * @param int|null $since_timestamp Optional timestamp to filter modified records * @return array Sync results */ public function sync_purchases($offset = 0, $limit = 50, $since_timestamp = null) { $results = array( 'total' => 0, 'synced' => 0, 'skipped' => 0, 'failed' => 0, 'errors' => array(), 'staging_mode' => $this->is_staging, 'has_more' => false, 'next_offset' => 0, 'batch_offset' => $offset, 'incremental_sync' => !is_null($since_timestamp) ); // Build query args $query_args = array( 'post_type' => 'tec_tc_order', 'post_status' => 'tec-tc-completed', 'posts_per_page' => -1, 'fields' => 'ids' ); // Add date filter for incremental sync if ($since_timestamp) { $query_args['date_query'] = array( array( 'column' => 'post_modified', 'after' => date('Y-m-d H:i:s', $since_timestamp), 'inclusive' => true, ) ); } // Get total count first $count_query = new WP_Query($query_args); $total_count = $count_query->found_posts; $results['total'] = $total_count; if ($total_count === 0) { $results['message'] = 'No completed ticket orders found.'; return $results; } // Get paginated orders $paginated_args = array_merge($query_args, array( 'posts_per_page' => $limit, 'offset' => $offset, 'fields' => 'all', )); $orders = get_posts($paginated_args); // Calculate pagination $results['has_more'] = ($offset + count($orders)) < $total_count; $results['next_offset'] = $offset + count($orders); // If staging mode, simulate the sync if ($this->is_staging) { $results['message'] = 'STAGING MODE: Sync simulation only. No data sent to Zoho.'; $results['synced'] = $results['total']; $results['test_data'] = array(); foreach ($orders as $order) { $invoice_data = $this->prepare_tc_invoice_data($order); $results['test_data'][] = array( 'order_id' => $order->ID, 'purchaser_email' => get_post_meta($order->ID, '_tec_tc_order_purchaser_email', true), 'gateway' => get_post_meta($order->ID, '_tec_tc_order_gateway', true), 'date' => $order->post_date, 'zoho_data' => $invoice_data ); } return $results; } // Production sync if (!$this->is_sync_allowed()) { $results['errors'][] = 'Sync not allowed on this domain. Only upskillhvac.com can sync to production.'; return $results; } foreach ($orders as $order) { try { $invoice_data = $this->prepare_tc_invoice_data($order); // Check if data has changed using hash comparison if (!$this->should_sync($order->ID, $invoice_data)) { $results['skipped']++; continue; } // Check if invoice already exists in Zoho (by WordPress Order ID) $search_response = $this->auth->make_api_request( '/Invoices/search?criteria=(WordPress_Order_ID:equals:' . $order->ID . ')', 'GET' ); if (!empty($search_response['data'])) { // Update existing invoice $invoice_id = $search_response['data'][0]['id']; $this->auth->make_api_request("/Invoices/{$invoice_id}", 'PUT', array( 'data' => array($invoice_data) )); } else { // Create new invoice $create_response = $this->auth->make_api_request('/Invoices', 'POST', array( 'data' => array($invoice_data) )); if (!empty($create_response['data'][0]['details']['id'])) { $invoice_id = $create_response['data'][0]['details']['id']; } } $results['synced']++; // Update order meta with Zoho ID and sync hash if (isset($invoice_id)) { update_post_meta($order->ID, '_zoho_invoice_id', $invoice_id); } update_post_meta($order->ID, '_zoho_sync_hash', $this->generate_sync_hash($invoice_data)); } catch (Exception $e) { $results['failed']++; $results['errors'][] = sprintf('Order %s: %s', $order->ID, $e->getMessage()); } } return $results; } /** * Sync ticket attendees to Zoho Contacts + Campaign Members * * @param int $offset Starting offset for pagination * @param int $limit Number of records per batch * @param int|null $since_timestamp Optional timestamp to filter modified records * @return array Sync results */ public function sync_attendees($offset = 0, $limit = 50, $since_timestamp = null) { $results = array( 'total' => 0, 'synced' => 0, 'skipped' => 0, 'failed' => 0, 'errors' => array(), 'staging_mode' => $this->is_staging, 'contacts_created' => 0, 'campaign_members_created' => 0, 'responses' => array(), 'debug_info' => HVAC_Zoho_CRM_Auth::get_debug_mode_info(), 'version' => 'BATCH_V1', 'has_more' => false, 'next_offset' => 0, 'batch_offset' => $offset, 'incremental_sync' => !is_null($since_timestamp) ); // Build base query args $base_tc_args = array( 'post_type' => 'tec_tc_attendee', 'post_status' => 'any', 'posts_per_page' => -1, 'fields' => 'ids' ); $base_tpp_args = array( 'post_type' => 'tribe_tpp_attendees', 'post_status' => 'any', 'posts_per_page' => -1, 'fields' => 'ids' ); // Add date filter for incremental sync if ($since_timestamp) { $date_query = array( array( 'column' => 'post_date', 'after' => date('Y-m-d H:i:s', $since_timestamp), 'inclusive' => true, ) ); $base_tc_args['date_query'] = $date_query; $base_tpp_args['date_query'] = $date_query; } // Get total count for all attendee types $tc_count_query = new WP_Query($base_tc_args); $tpp_count_query = new WP_Query($base_tpp_args); $total_count = $tc_count_query->found_posts + $tpp_count_query->found_posts; $results['total'] = $total_count; if ($total_count === 0) { $results['message'] = 'No ticket attendees found.'; return $results; } // Get paginated attendees - first from tec_tc_attendee $tc_paginated_args = array_merge($base_tc_args, array( 'posts_per_page' => $limit, 'offset' => $offset, 'fields' => 'all', )); $tc_attendees = get_posts($tc_paginated_args); // If we need more to fill the batch, get from PayPal attendees $remaining = max(0, $limit - count($tc_attendees)); $tpp_offset = max(0, $offset - $tc_count_query->found_posts); $tpp_attendees = array(); if ($remaining > 0 && $tpp_offset >= 0) { $tpp_paginated_args = array_merge($base_tpp_args, array( 'posts_per_page' => $remaining, 'offset' => $tpp_offset, 'fields' => 'all', )); $tpp_attendees = get_posts($tpp_paginated_args); } $all_attendees = array_merge($tc_attendees, $tpp_attendees); // Calculate pagination $results['has_more'] = ($offset + count($all_attendees)) < $total_count; $results['next_offset'] = $offset + count($all_attendees); if (count($all_attendees) === 0) { $results['message'] = 'No ticket attendees found.'; return $results; } // If staging mode, simulate the sync if ($this->is_staging) { $results['message'] = 'STAGING MODE: Sync simulation only. No data sent to Zoho.'; $results['synced'] = $results['total']; $results['test_data'] = array(); foreach ($all_attendees as $attendee) { $attendee_data = $this->prepare_attendee_data($attendee); $results['test_data'][] = $attendee_data; } return $results; } // Production sync if (!$this->is_sync_allowed()) { $results['errors'][] = 'Sync not allowed on this domain. Only upskillhvac.com can sync to production.'; return $results; } $results['version'] = 'PRODUCTION_BATCH_V1'; // Wrap entire loop in try-catch for safety (catches PHP 7+ errors) try { foreach ($all_attendees as $attendee) { try { $attendee_data = $this->prepare_attendee_data($attendee); // Check if data has changed using hash comparison if (!$this->should_sync($attendee->ID, $attendee_data)) { $results['skipped']++; continue; } // Check for any usable email (fallback to mq_email if email is empty) $has_email = !empty($attendee_data['email']) || !empty($attendee_data['mq_email']); if (!$has_email) { $results['failed']++; $results['errors'][] = sprintf('Attendee %s: No email address', $attendee->ID); continue; } // Step 1: Create/Update Contact $contact_id = $this->ensure_contact_exists($attendee_data); if ($contact_id) { $results['contacts_created']++; } if (count($results['responses']) < 5) { $cid_debug = $contact_id ? $contact_id : 'NULL'; $results['responses'][] = array('type' => 'debug', 'msg' => "Debug: Attendee {$attendee->ID} found Contact ID: " . $cid_debug); } // Step 2: Create Campaign Member (link Contact to Campaign) if ($contact_id && !empty($attendee_data['event_id'])) { $campaign_id = get_post_meta($attendee_data['event_id'], '_zoho_campaign_id', true); // Debug: Log event_id and campaign_id for troubleshooting if (count($results['responses']) < 10) { $results['responses'][] = array( 'type' => 'campaign_lookup', 'attendee_id' => $attendee->ID, 'event_id' => $attendee_data['event_id'], 'campaign_id' => $campaign_id ?: 'NOT_SET' ); } if ($campaign_id) { $assoc_response = $this->create_campaign_member($contact_id, $campaign_id, 'Attended'); // ALWAYS capture the first link attempt for debugging if (!isset($results['first_link_attempt'])) { $results['first_link_attempt'] = array( 'attendee_id' => $attendee->ID, 'contact_id' => $contact_id, 'campaign_id' => $campaign_id, 'response' => $assoc_response ); } // Debug: Add responses (increased limit to 20) if (count($results['responses']) < 20) { $results['responses'][] = array('type' => 'link_attempt', 'id' => $attendee->ID, 'response' => $assoc_response); } if (isset($assoc_response['status']) && $assoc_response['status'] === 'success') { $results['campaign_members_created']++; } elseif (isset($assoc_response['data'][0]['status']) && $assoc_response['data'][0]['status'] === 'success') { $results['campaign_members_created']++; } else { $results['errors'][] = sprintf('Attendee %s: Failed to link to campaign. Response: %s', $attendee->ID, json_encode($assoc_response)); if (isset($assoc_response['data'])) { $results['responses'][] = array('type' => 'link_error', 'id' => $attendee->ID, 'response' => $assoc_response); } } } else { $results['errors'][] = sprintf('Attendee %s: Event %s has no Zoho Campaign ID', $attendee->ID, $attendee_data['event_id']); } } elseif (!$contact_id) { $error_detail = $this->last_contact_error ?: 'Unknown error'; $results['errors'][] = sprintf('Attendee %s: No contact_id created. %s', $attendee->ID, $error_detail); } elseif (empty($attendee_data['event_id'])) { // Debug: Log when event_id is missing if (count($results['responses']) < 10) { $results['responses'][] = array('type' => 'missing_event_id', 'attendee_id' => $attendee->ID); } } $results['synced']++; // Update attendee meta with Zoho Contact ID and sync hash update_post_meta($attendee->ID, '_zoho_contact_id', $contact_id); update_post_meta($attendee->ID, '_zoho_sync_hash', $this->generate_sync_hash($attendee_data)); } catch (\Throwable $e) { // Catch both Exception and Error (PHP 7+) $results['failed']++; $results['errors'][] = sprintf('Attendee %s: [%s] %s', $attendee->ID, get_class($e), $e->getMessage()); } } } catch (\Throwable $e) { // Outer catch for any loop-level errors $results['errors'][] = 'Loop error: [' . get_class($e) . '] ' . $e->getMessage(); } return $results; } /** * Sync RSVPs to Zoho Leads + Campaign Members * * @param int $offset Starting offset for pagination * @param int $limit Number of records per batch * @param int|null $since_timestamp Optional timestamp to filter modified records * @return array Sync results */ public function sync_rsvps($offset = 0, $limit = 50, $since_timestamp = null) { $results = array( 'total' => 0, 'synced' => 0, 'skipped' => 0, 'failed' => 0, 'errors' => array(), 'staging_mode' => $this->is_staging, 'leads_created' => 0, 'campaign_members_created' => 0, 'debug_info' => HVAC_Zoho_CRM_Auth::get_debug_mode_info(), 'has_more' => false, 'next_offset' => 0, 'batch_offset' => $offset, 'incremental_sync' => !is_null($since_timestamp) ); // Build query args $query_args = array( 'post_type' => 'tribe_rsvp_attendees', 'post_status' => 'any', 'posts_per_page' => -1, 'fields' => 'ids' ); // Add date filter for incremental sync if ($since_timestamp) { $query_args['date_query'] = array( array( 'column' => 'post_date', 'after' => date('Y-m-d H:i:s', $since_timestamp), 'inclusive' => true, ) ); } // Get total count first $count_query = new WP_Query($query_args); $total_count = $count_query->found_posts; $results['total'] = $total_count; if ($total_count === 0) { $results['message'] = 'No RSVP attendees found.'; return $results; } // Get paginated RSVPs $paginated_args = array_merge($query_args, array( 'posts_per_page' => $limit, 'offset' => $offset, 'fields' => 'all', )); $rsvps = get_posts($paginated_args); // Calculate pagination $results['has_more'] = ($offset + count($rsvps)) < $total_count; $results['next_offset'] = $offset + count($rsvps); // If staging mode, simulate the sync if ($this->is_staging) { $results['message'] = 'STAGING MODE: Sync simulation only. No data sent to Zoho.'; $results['synced'] = count($rsvps); $results['test_data'] = array(); foreach ($rsvps as $rsvp) { $rsvp_data = $this->prepare_rsvp_data($rsvp); $results['test_data'][] = $rsvp_data; } return $results; } // Production sync if (!$this->is_sync_allowed()) { $results['errors'][] = 'Sync not allowed on this domain. Only upskillhvac.com can sync to production.'; return $results; } foreach ($rsvps as $rsvp) { try { $rsvp_data = $this->prepare_rsvp_data($rsvp); // Check if data has changed using hash comparison if (!$this->should_sync($rsvp->ID, $rsvp_data)) { $results['skipped']++; continue; } if (empty($rsvp_data['email'])) { $results['failed']++; $results['errors'][] = sprintf('RSVP %s: No email address', $rsvp->ID); continue; } // Step 1: Create/Update Lead $lead_id = $this->ensure_lead_exists($rsvp_data); if ($lead_id) { $results['leads_created']++; } // Step 2: Create Campaign Member (link Lead to Campaign) if ($lead_id && !empty($rsvp_data['event_id'])) { $campaign_id = get_post_meta($rsvp_data['event_id'], '_zoho_campaign_id', true); if ($campaign_id) { $assoc_response = $this->create_campaign_member($lead_id, $campaign_id, 'Responded', 'Leads'); if (isset($assoc_response['status']) && $assoc_response['status'] === 'success') { $results['campaign_members_created']++; } elseif (isset($assoc_response['data'][0]['status']) && $assoc_response['data'][0]['status'] === 'success') { $results['campaign_members_created']++; } else { $results['errors'][] = sprintf('RSVP %s: Failed to link to campaign. Response: %s', $rsvp->ID, json_encode($assoc_response)); if (isset($assoc_response['data'])) { $results['responses'][] = array('type' => 'link_error', 'id' => $rsvp->ID, 'response' => $assoc_response); } } } else { $results['errors'][] = sprintf('RSVP %s: Event %s has no Zoho Campaign ID', $rsvp->ID, $rsvp_data['event_id']); } } $results['synced']++; // Update RSVP meta with Zoho Lead ID and sync hash update_post_meta($rsvp->ID, '_zoho_lead_id', $lead_id); update_post_meta($rsvp->ID, '_zoho_sync_hash', $this->generate_sync_hash($rsvp_data)); } catch (Exception $e) { $results['failed']++; $results['errors'][] = sprintf('RSVP %s: %s', $rsvp->ID, $e->getMessage()); } } return $results; } /** * Ensure a Contact exists in Zoho (create or get existing, update fields) * * @param array $data Attendee data * @return string|null Zoho Contact ID */ private function ensure_contact_exists($data) { // Determine primary email for lookup // Prefer measureQuick email, fallback to attendee email if mq_email is empty // If both are empty, we can't create a contact $attendee_email = !empty($data['email']) ? $data['email'] : ''; $mq_email = !empty($data['mq_email']) ? $data['mq_email'] : ''; // Use mq_email as primary if available, otherwise use attendee_email $primary_email = !empty($mq_email) ? $mq_email : $attendee_email; // Set secondary email (only if different from primary and not empty) $other_email = ''; if (!empty($mq_email) && !empty($attendee_email) && $mq_email !== $attendee_email) { $other_email = $attendee_email; } // Build contact data for create or update $contact_data = array( 'First_Name' => $data['first_name'] ?: 'Unknown', 'Last_Name' => $data['last_name'] ?: 'Attendee', 'Email' => $primary_email, 'Lead_Source' => 'Event Tickets', 'Contact_Type' => 'Attendee', 'WordPress_Attendee_ID' => $data['attendee_id'], ); // Add Other Email if we have both emails if (!empty($other_email)) { $contact_data['Secondary_Email'] = $other_email; } // Add Mobile phone if available if (!empty($data['phone'])) { $contact_data['Mobile'] = $data['phone']; } // Add Primary Role if available if (!empty($data['company_role'])) { $contact_data['Primary_Role'] = $data['company_role']; } // Search for existing contact by primary email $search_response = $this->auth->make_api_request( '/Contacts/search?criteria=(Email:equals:' . urlencode($primary_email) . ')', 'GET' ); if (!empty($search_response['data'])) { $contact_id = $search_response['data'][0]['id']; // UPDATE existing contact with new field data $this->auth->make_api_request("/Contacts/{$contact_id}", 'PUT', array( 'data' => array($contact_data) )); return $contact_id; } // Create new contact $create_response = $this->auth->make_api_request('/Contacts', 'POST', array( 'data' => array($contact_data) )); if (!empty($create_response['data'][0]['details']['id'])) { return $create_response['data'][0]['details']['id']; } // Handle DUPLICATE_DATA: Contact exists but with different email // Extract existing contact ID from the error response and use it if (!empty($create_response['data'][0]['code']) && $create_response['data'][0]['code'] === 'DUPLICATE_DATA' && !empty($create_response['data'][0]['details']['duplicate_record']['id'])) { $existing_id = $create_response['data'][0]['details']['duplicate_record']['id']; // Update the existing contact with new data $this->auth->make_api_request("/Contacts/{$existing_id}", 'PUT', array( 'data' => array($contact_data) )); return $existing_id; } // Store error for debugging and log it $this->last_contact_error = "Email: {$primary_email}, Response: " . json_encode($create_response); $this->auth->log_debug("Failed to create contact: " . $this->last_contact_error); return null; } /** * Ensure a Lead exists in Zoho (create or get existing) * * @param array $data RSVP data * @return string|null Zoho Lead ID */ private function ensure_lead_exists($data) { // Search for existing lead by email $search_response = $this->auth->make_api_request( '/Leads/search?criteria=(Email:equals:' . urlencode($data['email']) . ')', 'GET' ); if (!empty($search_response['data'])) { return $search_response['data'][0]['id']; } // Create new lead $lead_data = array( 'First_Name' => $data['first_name'] ?: 'Unknown', 'Last_Name' => $data['last_name'] ?: 'RSVP', 'Email' => $data['email'], 'Lead_Source' => 'Event RSVP', 'Lead_Status' => 'Contacted', 'WordPress_RSVP_ID' => $data['rsvp_id'], ); $create_response = $this->auth->make_api_request('/Leads', 'POST', array( 'data' => array($lead_data) )); if (!empty($create_response['data'][0]['details']['id'])) { return $create_response['data'][0]['details']['id']; } return null; } /** * Create a Campaign Member (link Contact/Lead to Campaign) * * @param string $record_id Contact or Lead ID * @param string $campaign_id Campaign ID * @param string $status Member status (Invited, Responded, Attended, etc.) * @param string $type 'Contacts' or 'Leads' */ private function create_campaign_member($record_id, $campaign_id, $status = 'Attended', $type = 'Contacts') { // v6 API: Associate Contact/Lead with Campaign via related list // PUT /{Contacts|Leads}/{record_id}/Campaigns // The Contact/Lead is the parent, Campaigns is the related list // Campaign ID goes in the request body, not the URL $endpoint = "/{$type}/{$record_id}/Campaigns"; // Debug: Log the association attempt $this->auth->log_debug("Attempting to associate {$type} {$record_id} with Campaign {$campaign_id} via {$endpoint}"); $response = $this->auth->make_api_request($endpoint, 'PUT', array( 'data' => array( array( 'id' => $campaign_id, 'Member_Status' => $status ) ) )); return $response; } /** * Prepare campaign data for Zoho * * @param WP_Post $event Event post object * @return array Campaign data */ private function prepare_campaign_data($event) { $trainer_id = get_post_meta($event->ID, '_trainer_id', true); $trainer = get_user_by('id', $trainer_id); $venue_id = tribe_get_venue_id($event->ID); // Get venue details $venue_name = ''; $venue_city = ''; $venue_state = ''; $venue_details = ''; if ($venue_id) { $venue_name = html_entity_decode(get_the_title($venue_id), ENT_QUOTES | ENT_HTML5, 'UTF-8'); $venue_city = tribe_get_city($event->ID); $venue_state = tribe_get_state($event->ID); $venue_address = tribe_get_address($event->ID); // Build venue details string $venue_parts = array_filter(array($venue_name, $venue_address, $venue_city, $venue_state)); $venue_details = implode(', ', $venue_parts); } // Sanitize description: strip tags and limit length, then decode entities $description = wp_strip_all_tags(get_the_content(null, false, $event)); $description = html_entity_decode($description, ENT_QUOTES | ENT_HTML5, 'UTF-8'); if (strlen($description) > 30000) { $description = substr($description, 0, 30000) . '...'; } $campaign_name = html_entity_decode(get_the_title($event->ID), ENT_QUOTES | ENT_HTML5, 'UTF-8'); $trainer_name = $trainer ? html_entity_decode($trainer->display_name, ENT_QUOTES | ENT_HTML5, 'UTF-8') : ''; // Get trainer's Zoho Contact ID (if they've been synced) $instructor_zoho_id = ''; if ($trainer) { $instructor_zoho_id = get_user_meta($trainer->ID, '_zoho_contact_id', true); } // Calculate ticket counts $total_capacity = intval(get_post_meta($event->ID, '_stock', true)); $tickets_sold = $this->get_attendee_count($event->ID); $tickets_available = max(0, $total_capacity - $tickets_sold); // Build campaign data array $data = array( 'Campaign_Name' => substr($campaign_name, 0, 200), 'Start_Date' => tribe_get_start_date($event->ID, false, 'Y-m-d'), 'End_Date' => tribe_get_end_date($event->ID, false, 'Y-m-d'), 'Status' => (tribe_get_end_date($event->ID, false, 'U') < time()) ? 'Completed' : 'Active', 'Description' => $description, 'Type' => 'Training Event', 'Expected_Revenue' => floatval(get_post_meta($event->ID, '_price', true)), 'Total_Capacity' => $total_capacity, 'Tickets_Sold' => $tickets_sold, 'Tickets_Available' => $tickets_available, 'Event_City' => substr($venue_city, 0, 100), 'Event_State' => substr($venue_state, 0, 100), 'Event_URL' => get_permalink($event->ID), 'Venue_Details' => substr($venue_details, 0, 250), 'Instructor_Email' => $trainer ? $trainer->user_email : '', ); // Add Instructor lookup only if we have a valid Zoho Contact ID if (!empty($instructor_zoho_id)) { $data['Instructor'] = $instructor_zoho_id; } return $data; } /** * Get count of attendees for an event * * @param int $event_id Event post ID * @return int Attendee count */ private function get_attendee_count($event_id) { // Check Tickets Commerce attendees $tc_attendees = get_posts(array( 'post_type' => 'tec_tc_attendee', 'posts_per_page' => -1, 'post_status' => 'any', 'meta_query' => array( array( 'key' => '_tec_tickets_commerce_event', 'value' => $event_id, 'compare' => '=' ) ), 'fields' => 'ids' )); // Also check PayPal attendees $tpp_attendees = get_posts(array( 'post_type' => 'tribe_tpp_attendees', 'posts_per_page' => -1, 'post_status' => 'any', 'meta_query' => array( array( 'key' => '_tribe_tpp_event', 'value' => $event_id, 'compare' => '=' ) ), 'fields' => 'ids' )); return count($tc_attendees) + count($tpp_attendees); } /** * Prepare contact data for Zoho * * @param WP_User $user User object * @return array Contact data */ private function prepare_contact_data($user) { // Map WordPress roles to Zoho Contact_Type if (in_array('hvac_master_trainer', $user->roles)) { $role = 'Master Trainer'; } elseif (in_array('hvac_trainer', $user->roles)) { $role = 'Trainer'; } else { $role = 'Trainee'; } return array( 'First_Name' => html_entity_decode(get_user_meta($user->ID, 'first_name', true), ENT_QUOTES | ENT_HTML5, 'UTF-8'), 'Last_Name' => html_entity_decode(get_user_meta($user->ID, 'last_name', true), ENT_QUOTES | ENT_HTML5, 'UTF-8'), 'Email' => $user->user_email, 'Phone' => get_user_meta($user->ID, 'phone_number', true), 'Title' => html_entity_decode(get_user_meta($user->ID, 'hvac_professional_title', true), ENT_QUOTES | ENT_HTML5, 'UTF-8'), 'Company' => html_entity_decode(get_user_meta($user->ID, 'hvac_company_name', true), ENT_QUOTES | ENT_HTML5, 'UTF-8'), 'Lead_Source' => 'HVAC Community Events', 'Contact_Type' => $role, 'WordPress_User_ID' => $user->ID, 'License_Number' => get_user_meta($user->ID, 'hvac_license_number', true), 'Years_Experience' => get_user_meta($user->ID, 'hvac_years_experience', true), 'Certification' => get_user_meta($user->ID, 'hvac_certifications', true) ); } /** * Prepare invoice data for Tickets Commerce order * * @param WP_Post $order TC Order post object * @return array Invoice data */ private function prepare_tc_invoice_data($order) { $purchaser_email = get_post_meta($order->ID, '_tec_tc_order_purchaser_email', true); $purchaser_name = get_post_meta($order->ID, '_tec_tc_order_purchaser_name', true); $gateway = get_post_meta($order->ID, '_tec_tc_order_gateway', true); $gateway_order_id = get_post_meta($order->ID, '_tec_tc_order_gateway_order_id', true); $order_items = get_post_meta($order->ID, '_tec_tc_order_items', true); // Parse order items to get event info and line items $items = array(); $event_titles = array(); $total = 0; if (is_array($order_items)) { foreach ($order_items as $item) { $event_id = isset($item['event_id']) ? $item['event_id'] : 0; $ticket_id = isset($item['ticket_id']) ? $item['ticket_id'] : 0; $quantity = isset($item['quantity']) ? intval($item['quantity']) : 1; $price = isset($item['price']) ? floatval($item['price']) : 0; if ($event_id) { $event_titles[] = get_the_title($event_id); } $ticket_name = $ticket_id ? get_the_title($ticket_id) : 'Event Ticket'; $items[] = array( 'product' => array('name' => $ticket_name), 'quantity' => $quantity, 'list_price' => $price, 'total' => $price * $quantity ); $total += $price * $quantity; } } $event_summary = !empty($event_titles) ? implode(', ', array_unique($event_titles)) : 'Event Tickets'; return array( 'Subject' => "Ticket Purchase - {$event_summary}", 'Invoice_Date' => date('Y-m-d', strtotime($order->post_date)), 'Status' => 'Paid', 'Account_Name' => $purchaser_name, 'Description' => "Payment via {$gateway}. Transaction: {$gateway_order_id}", 'Grand_Total' => $total, 'WordPress_Order_ID' => $order->ID, 'Product_Details' => $items ); } /** * Prepare attendee data from Event Tickets attendee post * * @param WP_Post $attendee Attendee post object * @return array Attendee data */ private function prepare_attendee_data($attendee) { $post_type = $attendee->post_type; // Handle different attendee post types if ($post_type === 'tec_tc_attendee') { // Tickets Commerce attendee (meta keys: _tec_tickets_commerce_*) $event_id = get_post_meta($attendee->ID, '_tec_tickets_commerce_event', true); $ticket_id = get_post_meta($attendee->ID, '_tec_tickets_commerce_ticket', true); $order_id = get_post_meta($attendee->ID, '_tec_tickets_commerce_order', true); $full_name = get_post_meta($attendee->ID, '_tec_tickets_commerce_full_name', true); $email = get_post_meta($attendee->ID, '_tec_tickets_commerce_email', true); $checkin = get_post_meta($attendee->ID, '_tec_tickets_commerce_checked_in', true); // Custom attendee fields stored in _tec_tickets_commerce_attendee_fields (serialized array) // Field names use hyphens: attendee-cell-phone, company-role, measurequick-email $attendee_fields = get_post_meta($attendee->ID, '_tec_tickets_commerce_attendee_fields', true); $phone = ''; $company_role = ''; $mq_email = ''; if (is_array($attendee_fields)) { $phone = isset($attendee_fields['attendee-cell-phone']) ? $attendee_fields['attendee-cell-phone'] : ''; $company_role = isset($attendee_fields['company-role']) ? $attendee_fields['company-role'] : ''; $mq_email = isset($attendee_fields['measurequick-email']) ? $attendee_fields['measurequick-email'] : ''; } } else { // PayPal or other attendee types (tribe_tpp_attendees) $event_id = get_post_meta($attendee->ID, '_tribe_tpp_event', true); $ticket_id = get_post_meta($attendee->ID, '_tribe_tpp_product', true); $order_id = get_post_meta($attendee->ID, '_tribe_tpp_order', true); $full_name = get_post_meta($attendee->ID, '_tribe_tickets_full_name', true); $email = get_post_meta($attendee->ID, '_tribe_tickets_email', true); $checkin = get_post_meta($attendee->ID, '_tribe_tpp_checkin', true); // Legacy attendee meta for custom fields (hyphenated keys) $attendee_meta = get_post_meta($attendee->ID, '_tribe_tickets_meta', true); $phone = is_array($attendee_meta) && isset($attendee_meta['attendee-cell-phone']) ? $attendee_meta['attendee-cell-phone'] : ''; $company_role = is_array($attendee_meta) && isset($attendee_meta['company-role']) ? $attendee_meta['company-role'] : ''; $mq_email = is_array($attendee_meta) && isset($attendee_meta['measurequick-email']) ? $attendee_meta['measurequick-email'] : ''; } // Parse full name into first/last $name_parts = explode(' ', trim($full_name), 2); $first_name = isset($name_parts[0]) ? $name_parts[0] : ''; $last_name = isset($name_parts[1]) ? $name_parts[1] : ''; $event_title = $event_id ? get_the_title($event_id) : ''; $ticket_name = $ticket_id ? get_the_title($ticket_id) : ''; return array( 'attendee_id' => $attendee->ID, 'post_type' => $post_type, 'event_id' => $event_id, 'event_title' => $event_title, 'ticket_id' => $ticket_id, 'ticket_name' => $ticket_name, 'order_id' => $order_id, 'full_name' => $full_name, 'first_name' => $first_name, 'last_name' => $last_name, 'email' => $email, 'phone' => $phone, 'company_role' => $company_role, 'mq_email' => $mq_email, 'checked_in' => !empty($checkin), 'zoho_contact' => array( 'First_Name' => $first_name ?: 'Unknown', 'Last_Name' => $last_name ?: 'Attendee', 'Email' => $email, 'Lead_Source' => 'Event Tickets', 'Contact_Type' => 'Attendee', ), 'zoho_campaign_member' => array( 'Member_Status' => !empty($checkin) ? 'Attended' : 'Registered', ) ); } /** * Prepare RSVP data from Event Tickets RSVP attendee post * * @param WP_Post $rsvp RSVP attendee post object * @return array RSVP data */ private function prepare_rsvp_data($rsvp) { $event_id = get_post_meta($rsvp->ID, '_tribe_rsvp_event', true); $ticket_id = get_post_meta($rsvp->ID, '_tribe_rsvp_product', true); $full_name = get_post_meta($rsvp->ID, '_tribe_rsvp_full_name', true); $email = get_post_meta($rsvp->ID, '_tribe_rsvp_email', true); $rsvp_status = get_post_meta($rsvp->ID, '_tribe_rsvp_status', true); // yes, no // Parse full name into first/last $name_parts = explode(' ', trim($full_name), 2); $first_name = isset($name_parts[0]) ? $name_parts[0] : ''; $last_name = isset($name_parts[1]) ? $name_parts[1] : ''; $event_title = $event_id ? get_the_title($event_id) : ''; return array( 'rsvp_id' => $rsvp->ID, 'event_id' => $event_id, 'event_title' => $event_title, 'ticket_id' => $ticket_id, 'full_name' => $full_name, 'first_name' => $first_name, 'last_name' => $last_name, 'email' => $email, 'rsvp_status' => $rsvp_status, 'zoho_lead' => array( 'First_Name' => $first_name ?: 'Unknown', 'Last_Name' => $last_name ?: 'RSVP', 'Email' => $email, 'Lead_Source' => 'Event RSVP', 'Lead_Status' => $rsvp_status === 'yes' ? 'Contacted' : 'Not Interested', ), 'zoho_campaign_member' => array( 'Member_Status' => $rsvp_status === 'yes' ? 'Responded' : 'Not Responded', ) ); } }