upskill-event-manager/includes/zoho/class-zoho-sync.php
ben 03b9bce52d fix(zoho): Fix silent sync failures with API response validation and hash reset
Zoho CRM sync appeared connected but silently failed to write data due to
unvalidated API responses. Sync methods now validate Zoho responses before
updating hashes, ensuring failed records re-sync on next run. Also fixes
staging detection to use wp_parse_url hostname parsing instead of fragile
strpos matching, adds admin UI for resetting sync hashes, and bumps
HVAC_PLUGIN_VERSION to 2.2.11 to bust browser cache for updated JS.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 11:25:26 -04:00

1537 lines
No EOL
61 KiB
PHP

<?php
/**
* Zoho CRM Sync Handler
*
* @package HVACCommunityEvents
*/
if (!defined('ABSPATH')) {
exit;
}
/**
* Zoho Sync Class
*/
class HVAC_Zoho_Sync {
/**
* Zoho Auth instance
*
* @var HVAC_Zoho_CRM_Auth
*/
private $auth;
/**
* Staging mode flag
*
* @var bool
*/
private $is_staging;
/**
* Last contact creation error for debugging
*
* @var string
*/
private $last_contact_error = '';
/**
* Constructor
*/
public function __construct() {
require_once HVAC_PLUGIN_DIR . 'includes/zoho/class-zoho-crm-auth.php';
$this->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();
}
/**
* Validate a Zoho API response to determine if the operation succeeded
*
* @param mixed $response Response from make_api_request()
* @return array ['success' => bool, 'id' => string|null, 'error' => string|null]
*/
private function validate_api_response($response) {
// Check for WP_Error
if (is_wp_error($response)) {
return array(
'success' => false,
'id' => null,
'error' => $response->get_error_message()
);
}
// Check for staging mode simulation
if (isset($response['data'][0]['code']) && $response['data'][0]['code'] === 'STAGING_MODE') {
return array(
'success' => false,
'id' => null,
'error' => 'Staging mode: write operations blocked'
);
}
// Check for HTTP-level errors
if (isset($response['error'])) {
return array(
'success' => false,
'id' => null,
'error' => $response['error']
);
}
// Check for Zoho API error codes
if (isset($response['data'][0]['code']) && !in_array($response['data'][0]['code'], array('SUCCESS', 'DUPLICATE_DATA'))) {
$error_msg = isset($response['data'][0]['message']) ? $response['data'][0]['message'] : $response['data'][0]['code'];
return array(
'success' => false,
'id' => null,
'error' => $error_msg
);
}
// Check for successful response with ID
if (isset($response['data'][0]['details']['id'])) {
return array(
'success' => true,
'id' => $response['data'][0]['details']['id'],
'error' => null
);
}
// Check for duplicate record handling (also a success case)
if (isset($response['data'][0]['code']) && $response['data'][0]['code'] === 'DUPLICATE_DATA'
&& isset($response['data'][0]['details']['duplicate_record']['id'])) {
return array(
'success' => true,
'id' => $response['data'][0]['details']['duplicate_record']['id'],
'error' => null
);
}
// Check for success status without ID (e.g., PUT updates)
if (isset($response['data'][0]['status']) && $response['data'][0]['status'] === 'success') {
return array(
'success' => true,
'id' => isset($response['data'][0]['details']['id']) ? $response['data'][0]['details']['id'] : null,
'error' => null
);
}
// Unknown response structure - treat as failure
return array(
'success' => false,
'id' => null,
'error' => 'Unexpected API response: ' . json_encode(array_slice($response, 0, 3))
);
}
/**
* 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;
$sync_succeeded = false;
// 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)
));
$validated = $this->validate_api_response($update_response);
// Check if update failed due to invalid ID (e.g. campaign deleted in Zoho)
if (!$validated['success'] && 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)
));
$validated = $this->validate_api_response($create_response);
$results['responses'][] = array('type' => 'create_fallback', 'id' => $event->ID, 'response' => $create_response);
if ($validated['success'] && $validated['id']) {
$campaign_id = $validated['id'];
$sync_succeeded = true;
}
} elseif ($validated['success']) {
$campaign_id = $stored_campaign_id;
$sync_succeeded = true;
$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)
));
$validated = $this->validate_api_response($create_response);
$results['responses'][] = array('type' => 'create', 'id' => $event->ID, 'response' => $create_response);
if ($validated['success'] && $validated['id']) {
$campaign_id = $validated['id'];
$sync_succeeded = true;
}
}
if ($sync_succeeded) {
$results['synced']++;
// Only update hash and Zoho ID on confirmed success
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));
} else {
$results['failed']++;
$error_msg = isset($validated['error']) ? $validated['error'] : 'Unknown API error';
$results['errors'][] = sprintf('Event %s: %s', $event->ID, $error_msg);
}
} catch (\Throwable $e) {
$results['failed']++;
$results['errors'][] = sprintf('Event %s: [%s] %s', $event->ID, get_class($e), $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;
}
$contact_id = null;
$sync_succeeded = false;
// 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)
));
$validated = $this->validate_api_response($update_response);
$sync_succeeded = $validated['success'];
} else {
// Create new contact
$create_response = $this->auth->make_api_request('/Contacts', 'POST', array(
'data' => array($contact_data)
));
$validated = $this->validate_api_response($create_response);
$sync_succeeded = $validated['success'];
if ($validated['success'] && $validated['id']) {
$contact_id = $validated['id'];
}
}
if ($sync_succeeded) {
$results['synced']++;
// Only update hash and Zoho ID on confirmed success
if (!empty($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));
} else {
$results['failed']++;
$error_msg = isset($validated['error']) ? $validated['error'] : 'Unknown API error';
$results['errors'][] = sprintf('User %s: %s', $user->ID, $error_msg);
}
} catch (\Throwable $e) {
$results['failed']++;
$results['errors'][] = sprintf('User %s: [%s] %s', $user->ID, get_class($e), $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;
}
$invoice_id = null;
$sync_succeeded = false;
// 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'];
$update_response = $this->auth->make_api_request("/Invoices/{$invoice_id}", 'PUT', array(
'data' => array($invoice_data)
));
$validated = $this->validate_api_response($update_response);
$sync_succeeded = $validated['success'];
} else {
// Create new invoice
$create_response = $this->auth->make_api_request('/Invoices', 'POST', array(
'data' => array($invoice_data)
));
$validated = $this->validate_api_response($create_response);
$sync_succeeded = $validated['success'];
if ($validated['success'] && $validated['id']) {
$invoice_id = $validated['id'];
}
}
if ($sync_succeeded) {
$results['synced']++;
// Only update hash and Zoho ID on confirmed success
if (!empty($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));
} else {
$results['failed']++;
$error_msg = isset($validated['error']) ? $validated['error'] : 'Unknown API error';
$results['errors'][] = sprintf('Order %s: %s', $order->ID, $error_msg);
}
} catch (\Throwable $e) {
$results['failed']++;
$results['errors'][] = sprintf('Order %s: [%s] %s', $order->ID, get_class($e), $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);
}
// If contact creation failed, count as failed and skip hash update
if (!$contact_id) {
$results['failed']++;
$error_detail = $this->last_contact_error ?: 'Unknown error';
$results['errors'][] = sprintf('Attendee %s: No contact_id created. %s', $attendee->ID, $error_detail);
continue;
}
$attendee_sync_ok = true;
// Step 2: Create Campaign Member (link Contact to Campaign)
if (!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 (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);
}
}
// Contact was created/found successfully - count as synced
$results['synced']++;
// Only update hash on confirmed contact success
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['failed']++;
$results['errors'][] = sprintf('RSVP %s: Failed to create/find lead', $rsvp->ID);
continue;
}
$results['leads_created']++;
// Step 2: Create Campaign Member (link Lead to Campaign)
if (!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']++;
// Only update hash on confirmed lead creation success
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 (\Throwable $e) {
$results['failed']++;
$results['errors'][] = sprintf('RSVP %s: [%s] %s', $rsvp->ID, get_class($e), $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',
)
);
}
}