upskill-event-manager/includes/zoho/class-zoho-sync.php

1327 lines
No EOL
53 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();
}
/**
* 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,
'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);
$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
if (!empty($campaign_id)) {
update_post_meta($event->ID, '_zoho_campaign_id', $campaign_id);
}
} 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,
'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 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
if (isset($contact_id)) {
update_user_meta($user->ID, '_zoho_contact_id', $contact_id);
}
} 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,
'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 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
if (isset($invoice_id)) {
update_post_meta($order->ID, '_zoho_invoice_id', $invoice_id);
}
} 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,
'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 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);
$processed++;
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
update_post_meta($attendee->ID, '_zoho_contact_id', $contact_id);
} 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,
'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);
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
update_post_meta($rsvp->ID, '_zoho_lead_id', $lead_id);
} 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',
)
);
}
}