get_parent_id();
if ($parent_id) {
$parent_order = wc_get_order($parent_id);
}
}
if ($parent_order && method_exists($parent_order, 'get_billing_email')) {
$this->log(
'Using parent order for billing data',
array(
'child_order_id' => method_exists($order, 'get_id') ? $order->get_id() : null,
'parent_order_id' => $parent_order->get_id(),
'child_type' => get_class($order),
'was_refund' => $is_refund_like ? 'yes' : 'no'
),
'debug'
);
return $parent_order;
}
$this->log(
'Order missing billing information - skipping',
array(
'order_id' => method_exists($order, 'get_id') ? $order->get_id() : null,
'order_type' => get_class($order)
),
'warning'
);
return null;
}
/**
* Constructor
*
* @param ActiveTrail_API_Client $api_client API client instance.
* @param ActiveTrail_Settings $settings Settings instance.
*/
public function __construct($api_client, $settings)
{
$this->api_client = $api_client;
$this->settings = $settings;
// Instantiate order handler for order sync methods.
require_once ACTIVETRAIL_WC_PLUGIN_DIR . 'includes/services/class-order-handler.php';
$this->order_handler = new ActiveTrail_Order_Handler($api_client, $settings);
$this->init_hooks();
}
/**
* Initialize WordPress hooks
*/
private function init_hooks()
{
// AJAX handlers for historical sync.
add_action('wp_ajax_activetrail_start_historical_sync', array($this, 'ajax_start_historical_sync'));
add_action('wp_ajax_activetrail_get_sync_progress', array($this, 'ajax_get_sync_progress'));
add_action('wp_ajax_activetrail_cancel_historical_sync', array($this, 'ajax_cancel_historical_sync'));
// AJAX handler for batch processing when cron is disabled (removed for simplicity).
// Note: If WordPress cron is disabled, user should set up system cron instead.
// Cron hook for background processing.
add_action('activetrail_process_historical_sync_batch', array($this, 'process_historical_sync_batch'));
// Hook to trigger historical sync when API key changes - REMOVED: sync should only start manually via button click
// add_action('activetrail_api_key_changed', array($this, 'offer_historical_sync'));
}
/**
* Normalize a user supplied date to a full MySQL datetime.
*
* The date pickers submit Y-m-d. Downstream we compare these values against
* user_registered / order dates as strings, so both ends must be full
* datetimes or a record created on the final day would fall outside the range.
*
* @param string $value Date string (Y-m-d or Y-m-d H:i:s).
* @param bool $is_end True to snap to the end of the day.
* @return string Y-m-d H:i:s
*/
private function normalize_range_date($value, $is_end = false)
{
$value = trim((string) $value);
if ('' === $value) {
return $is_end ? current_time('mysql') : gmdate('Y-m-d H:i:s', strtotime('-1 year'));
}
// Already a full datetime - leave as is.
if (preg_match('/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}/', $value)) {
return str_replace('T', ' ', substr($value, 0, 19));
}
// Date only - snap to start or end of that day.
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) {
return $value . ($is_end ? ' 23:59:59' : ' 00:00:00');
}
$timestamp = strtotime($value);
if (false === $timestamp) {
return $is_end ? current_time('mysql') : gmdate('Y-m-d H:i:s', strtotime('-1 year'));
}
return gmdate('Y-m-d H:i:s', $timestamp);
}
/**
* Build the list of WooCommerce order statuses to sync, from settings.
*
* Shared by the counting, paging and syncing paths so they can never drift apart.
*
* @return array Status keys in 'wc-' prefixed form.
*/
private function get_order_status_list()
{
$settings = ActiveTrail_Settings::get_all();
$enabled_statuses = isset($settings['order_statuses']) ? $settings['order_statuses'] : array();
// Backward compatibility: if empty array, use all statuses.
if (empty($enabled_statuses)) {
return array_keys(wc_get_order_statuses());
}
$all_statuses = wc_get_order_statuses();
$status_list = array();
foreach ($enabled_statuses as $status) {
$status_key = strpos($status, 'wc-') === 0 ? $status : 'wc-' . $status;
if (array_key_exists($status_key, $all_statuses)) {
$status_list[] = $status_key;
}
}
return $status_list;
}
/**
* Count users registered in the given range.
*
* Uses a COUNT query - never loads the users themselves.
*
* @param string $date_from Start date.
* @param string $date_to End date.
* @return int
*/
private function count_users_in_range($date_from, $date_to)
{
$user_query = new WP_User_Query(array(
'date_query' => array(
array(
'after' => $date_from,
'before' => $date_to,
'inclusive' => true,
),
),
'number' => 1,
'fields' => 'ID',
'count_total' => true,
));
return (int) $user_query->get_total();
}
/**
* Count orders in the given range.
*
* Uses WooCommerce pagination metadata so only the count comes back,
* not the order IDs. Works on both HPOS and legacy storage.
*
* @param string $date_from Start date.
* @param string $date_to End date.
* @return int
*/
private function count_orders_in_range($date_from, $date_to)
{
$status_list = $this->get_order_status_list();
if (empty($status_list)) {
return 0;
}
$result = wc_get_orders(array(
'status' => $status_list,
'date_created' => $date_from . '...' . $date_to,
'limit' => 1,
'return' => 'ids',
'paginate' => true,
));
if (is_object($result) && isset($result->total)) {
return (int) $result->total;
}
// Very old WooCommerce without 'paginate' support - fall back to counting IDs.
$order_ids = wc_get_orders(array(
'status' => $status_list,
'date_created' => $date_from . '...' . $date_to,
'limit' => -1,
'return' => 'ids',
));
return is_array($order_ids) ? count($order_ids) : 0;
}
/**
* Check whether an email belongs to a user registered inside the sync range.
*
* Reproduces the old "users take priority over orders" de-duplication without
* needing the full contact list in memory: if the email belongs to a user that
* the users pass already sent, the order-derived copy is skipped.
*
* @param string $email Email address.
* @param string $date_from Start date.
* @param string $date_to End date.
* @return bool
*/
private function email_belongs_to_user_in_range($email, $date_from, $date_to)
{
$user = get_user_by('email', $email);
if (!$user || empty($user->user_registered)) {
return false;
}
return ($user->user_registered >= $date_from && $user->user_registered <= $date_to);
}
/**
* Fetch one page of contacts derived from registered users.
*
* @param string $date_from Start date.
* @param string $date_to End date.
* @param int $limit Page size.
* @param int $offset Offset into the user list.
* @return array{contacts: array, scanned: int} Contacts kept, and how many users were read.
*/
private function get_user_contacts_page($date_from, $date_to, $limit, $offset)
{
$locale_switched = $this->switch_to_plugin_language();
try {
$user_query = new WP_User_Query(array(
'date_query' => array(
array(
'after' => $date_from,
'before' => $date_to,
'inclusive' => true,
),
),
'number' => $limit,
'offset' => $offset,
'orderby' => 'ID',
'order' => 'ASC',
'count_total' => false,
'fields' => 'all',
));
$users = $user_query->get_results();
$contacts = array();
foreach ($users as $user) {
$email = strtolower(trim($user->user_email));
if (empty($email)) {
continue;
}
$contact_data = $this->prepare_user_contact_data($user);
if (!$contact_data) {
continue;
}
// Always generate externalId based on email (one-to-one) to ensure consistency.
$contact_data['externalId'] = 'WC-CONTACT-' . md5($email);
$contacts[] = $contact_data;
}
return array(
'contacts' => $contacts,
'scanned' => count($users),
);
} finally {
if ($locale_switched) {
$this->restore_plugin_language();
}
}
}
/**
* Fetch one page of contacts derived from order billing details.
*
* Orders are read one at a time by ID so only a single WC_Order object is
* held in memory at any moment.
*
* @param string $date_from Start date.
* @param string $date_to End date.
* @param int $limit Page size.
* @param int $offset Offset into the order list.
* @return array{contacts: array, scanned: int} Contacts kept, and how many orders were read.
*/
private function get_order_contacts_page($date_from, $date_to, $limit, $offset)
{
$locale_switched = $this->switch_to_plugin_language();
try {
$status_list = $this->get_order_status_list();
if (empty($status_list)) {
return array('contacts' => array(), 'scanned' => 0);
}
$order_ids = wc_get_orders(array(
'status' => $status_list,
'date_created' => $date_from . '...' . $date_to,
'limit' => $limit,
'offset' => $offset,
'orderby' => 'ID',
'order' => 'ASC',
'return' => 'ids',
));
if (!is_array($order_ids)) {
$order_ids = array();
}
$contacts = array();
$seen_in_page = array();
foreach ($order_ids as $order_id) {
$order = wc_get_order($order_id);
if (!$order) {
continue;
}
$order_with_billing = $this->resolve_order_with_billing_details($order);
if (!$order_with_billing) {
continue;
}
$email = strtolower(trim($order_with_billing->get_billing_email()));
if (empty($email) || isset($seen_in_page[$email])) {
continue;
}
// Users take priority: skip if the users pass already covered this email.
if ($this->email_belongs_to_user_in_range($email, $date_from, $date_to)) {
continue;
}
$contact_data = $this->prepare_order_contact_data($order_with_billing);
if (!$contact_data) {
continue;
}
// Always generate externalId based on email (one-to-one) to ensure consistency.
$contact_data['externalId'] = 'WC-CONTACT-' . md5($email);
$contacts[] = $contact_data;
$seen_in_page[$email] = true;
}
return array(
'contacts' => $contacts,
'scanned' => count($order_ids),
);
} finally {
if ($locale_switched) {
$this->restore_plugin_language();
}
}
}
/**
* How many source records to read per batch.
*
* Filterable so a store with plenty of memory can trade a larger page for fewer
* batches (each batch is one cron run, 10s apart). Clamped to the ActiveTrail
* batch-import ceiling of 1000 contacts per API request.
*
* @return int
*/
private function get_contact_page_size()
{
$size = (int) apply_filters('activetrail_historical_contact_page_size', $this->contact_page_size);
return max(1, min(1000, $size));
}
/**
* Fetch one page of contacts from the given source.
*
* @param string $source 'users' or 'orders'.
* @param string $date_from Start date.
* @param string $date_to End date.
* @param int $limit Page size.
* @param int $offset Offset into that source.
* @return array{contacts: array, scanned: int}
*/
private function get_contacts_page($source, $date_from, $date_to, $limit, $offset)
{
if ('orders' === $source) {
return $this->get_order_contacts_page($date_from, $date_to, $limit, $offset);
}
return $this->get_user_contacts_page($date_from, $date_to, $limit, $offset);
}
/**
* Get a slice of historical contacts (users first, then order billing contacts).
*
* Walks the paged sources instead of materialising the whole list, so peak
* memory stays flat no matter how large the store is.
*
* @param string $date_from Start date (default: 1 year ago).
* @param string $date_to End date (default: now).
* @param int $limit Limit number of results.
* @param int $offset Offset for pagination.
* @return array
*/
public function get_historical_contacts($date_from = null, $date_to = null, $limit = 100, $offset = 0)
{
$date_from = $this->normalize_range_date($date_from, false);
$date_to = $this->normalize_range_date($date_to, true);
$limit = max(0, (int) $limit);
$offset = max(0, (int) $offset);
if (0 === $limit) {
return array();
}
$collected = array();
$skipped = 0;
$page_size = $this->get_contact_page_size();
foreach (array('users', 'orders') as $source) {
$source_offset = 0;
while (count($collected) < $limit) {
$page = $this->get_contacts_page($source, $date_from, $date_to, $page_size, $source_offset);
if (empty($page['scanned'])) {
break;
}
$source_offset += (int) $page['scanned'];
foreach ($page['contacts'] as $contact_data) {
if ($skipped < $offset) {
$skipped++;
continue;
}
$collected[] = $contact_data;
if (count($collected) >= $limit) {
break;
}
}
// Short page means this source is exhausted.
if ((int) $page['scanned'] < $page_size) {
break;
}
}
}
return $collected;
}
/**
* Get historical orders from WooCommerce by date range
*
* @param string|null $date_from Start date (Y-m-d H:i:s).
* @param string|null $date_to End date (Y-m-d H:i:s).
* @param int $limit Number of orders to retrieve.
* @param int $offset Offset for pagination.
* @return array Array of WC_Order objects
*/
public function get_historical_orders($date_from = null, $date_to = null, $limit = 100, $offset = 0)
{
// Switch to plugin's language setting for translations.
$locale_switched = $this->switch_to_plugin_language();
try {
$date_from = $this->normalize_range_date($date_from, false);
$date_to = $this->normalize_range_date($date_to, true);
$status_list = $this->get_order_status_list();
if (empty($status_list)) {
return array();
}
$order_args = array(
'status' => $status_list,
'date_created' => $date_from . '...' . $date_to,
'limit' => $limit,
'offset' => $offset,
// Stable ordering so offset-based paging cannot skip or repeat orders.
'orderby' => 'ID',
'order' => 'ASC',
'return' => 'objects',
);
$orders = wc_get_orders($order_args);
return is_array($orders) ? $orders : array();
} finally {
// Restore original locale.
if ($locale_switched) {
$this->restore_plugin_language();
}
}
}
/**
* Get the upper bound of historical contacts in a date range.
*
* This is users-in-range plus orders-in-range, using COUNT queries only - it
* never loads the records themselves. It is an upper bound rather than an exact
* figure because consent filtering and email de-duplication can only be resolved
* while actually walking the records; the sync corrects the total to the real
* number once the contacts phase completes.
*
* Replaces the previous implementation, which queried wp_posts for
* post_type = 'shop_order' directly and therefore returned 0 on stores using
* WooCommerce High-Performance Order Storage.
*
* @param string $date_from Start date.
* @param string $date_to End date.
* @return int
*/
public function get_historical_contacts_count($date_from = null, $date_to = null)
{
$date_from = $this->normalize_range_date($date_from, false);
$date_to = $this->normalize_range_date($date_to, true);
$user_count = $this->count_users_in_range($date_from, $date_to);
$order_count = $this->count_orders_in_range($date_from, $date_to);
$this->log('get_historical_contacts_count result', array(
'date_from' => $date_from,
'date_to' => $date_to,
'users' => $user_count,
'orders' => $order_count
), 'debug');
return $user_count + $order_count;
}
/**
* Get total count of historical orders
*
* @param string|null $date_from Start date (Y-m-d H:i:s).
* @param string|null $date_to End date (Y-m-d H:i:s).
* @return int
*/
public function get_historical_orders_count($date_from = null, $date_to = null)
{
$date_from = $this->normalize_range_date($date_from, false);
$date_to = $this->normalize_range_date($date_to, true);
// Counted via WooCommerce pagination metadata - the order IDs are never
// pulled into memory. Works on both HPOS and legacy post-based storage.
$count = $this->count_orders_in_range($date_from, $date_to);
$this->log('get_historical_orders_count result', array(
'date_from' => $date_from,
'date_to' => $date_to,
'total_orders' => $count
), 'info');
return $count;
}
/**
* Detect opt-in from marketing meta keys
* Checks user meta or post meta for opt-in consent from various email marketing plugins
* Checks ALL keys before making a decision - prioritizes opt-in over opt-out
*
* @param int $object_id User ID or Post ID.
* @param string $meta_type Type of meta: 'user' or 'post'.
* @return array Array with 'optin' (bool|null), 'meta_key' (string), 'meta_value' (mixed)
*/
private function detect_optin_from_meta_keys($object_id, $meta_type = 'user')
{
// Define opt-in values (case-insensitive).
$optin_values = array('1', '1', 'yes', 'true', 'on', 'active', 'subscribe', 'subscribed', 'accepted', 'consent');
// Define opt-out values (case-insensitive).
$optout_values = array('0', '0', 'no', 'false', 'off', 'declined', 'unsubscribed', 'rejected');
$optin_found = null;
$optout_found = null;
// Single pass: Check ALL keys before making a decision.
foreach ($this->marketing_meta_keys as $key) {
// Get meta value based on type.
if ( 'user' === $meta_type ) {
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for opt-in detection
$value = get_user_meta($object_id, $key, true);
} else {
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for opt-in detection
$value = get_post_meta($object_id, $key, true);
}
// Skip if empty or null.
if ($value === '' || $value === null) {
continue;
}
// Convert to string for comparison.
$value_str = is_bool($value) ? ($value ? 'true' : 'false') : (string) $value;
$value_lower = strtolower(trim($value_str));
// Check if it's an opt-in value.
if (in_array($value_lower, array_map('strtolower', $optin_values), true) || $value === 1 || $value === true) {
// Store first opt-in found (we'll use this if no other opt-in is found).
if ($optin_found === null) {
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Meta queries necessary for opt-in detection
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Meta queries necessary for opt-in detection
$optin_found = array(
'optin' => true,
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Meta queries necessary for opt-in detection
'meta_key' => $key,
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Meta queries necessary for opt-in detection
'meta_value' => $value
);
$this->log('Opt-in detected from marketing meta key', array(
'object_id' => $object_id,
'meta_type' => $meta_type,
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for opt-in detection
'meta_key' => $key,
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Meta query necessary for opt-in detection
'meta_value' => $value
), 'debug');
}
// Continue checking all keys - don't break.
}
// Check if it's an opt-out value (only if we haven't found opt-in yet, or continue checking).
elseif (in_array($value_lower, array_map('strtolower', $optout_values), true) || $value === 0 || $value === false) {
// Store first opt-out found (we'll use this only if no opt-in is found).
if ($optout_found === null) {
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Meta queries necessary for opt-in detection
$optout_found = array(
'optin' => false,
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Meta queries necessary for opt-in detection
'meta_key' => $key,
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Meta queries necessary for opt-in detection
'meta_value' => $value
);
$this->log('Opt-out detected from marketing meta key', array(
'object_id' => $object_id,
'meta_type' => $meta_type,
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for opt-in detection
'meta_key' => $key,
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Meta query necessary for opt-in detection
'meta_value' => $value
), 'debug');
}
// Continue checking all keys - don't break.
}
}
// Decision: Prioritize opt-in over opt-out.
// If we found ANY opt-in, return opt-in (even if there's also opt-out).
if ($optin_found !== null) {
return $optin_found;
}
// If no opt-in found but opt-out found, return opt-out.
if ($optout_found !== null) {
return $optout_found;
}
// No relevant meta found.
return array('optin' => null);
}
/**
* Detect opt-in from user's order meta
* Checks all orders for the user to find opt-in consent
*
* @param int $user_id User ID.
* @param string $email User email.
* @return array Array with 'optin' (bool), 'source' (string), 'order_id' (int|null)
*/
private function detect_optin_from_order_meta($user_id, $email)
{
// Get orders for this user (by customer_id or email).
$order_args = array(
'status' => array_keys(wc_get_order_statuses()),
'limit' => 50, // Limit to avoid performance issues
'return' => 'ids',
);
// Try by customer_id first.
if ($user_id > 0) {
$order_args['customer_id'] = $user_id;
$order_ids = wc_get_orders($order_args);
} else {
$order_ids = array();
}
// If no orders found by customer_id, try by email.
if (empty($order_ids) && !empty($email)) {
$order_args = array(
'status' => array_keys(wc_get_order_statuses()),
'billing_email' => $email,
'limit' => 50,
'return' => 'ids',
);
$order_ids = wc_get_orders($order_args);
}
if (empty($order_ids)) {
return array('optin' => false);
}
// Check each order for opt-in.
foreach ($order_ids as $order_id) {
$detection = $this->detect_optin_from_meta_keys($order_id, 'post');
if ($detection['optin'] === true) {
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for opt-in detection
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for opt-in detection
$this->log('Opt-in found in order meta', array(
'user_id' => $user_id,
'email' => $email,
'order_id' => $order_id,
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for opt-in detection
'meta_key' => $detection['meta_key']
), 'debug');
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for opt-in detection
return array(
'optin' => true,
'source' => 'order',
'order_id' => $order_id,
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for opt-in detection
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for opt-in detection
'meta_key' => $detection['meta_key']
);
}
}
return array('optin' => false);
}
/**
* Check if user has consent in any of their orders
*
* @param int $user_id User ID.
* @param string $email User email.
* @return array
*/
private function check_user_orders_for_consent($user_id, $email)
{
$result = array(
'has_consent' => false,
'count' => 0,
'sample_order_ids' => array()
);
// Get orders for this user (by customer_id or email).
$order_args = array(
'status' => array_keys(wc_get_order_statuses()),
'limit' => 50, // Limit to avoid performance issues
'return' => 'ids',
);
// Try by customer_id first.
if ($user_id > 0) {
$order_args['customer_id'] = $user_id;
$order_ids = wc_get_orders($order_args);
} else {
$order_ids = array();
}
// If no orders found by customer_id, try by email.
if (empty($order_ids) && !empty($email)) {
$order_args = array(
'status' => array_keys(wc_get_order_statuses()),
'billing_email' => $email,
'limit' => 50,
'return' => 'ids',
);
$order_ids = wc_get_orders($order_args);
}
if (empty($order_ids)) {
return $result;
}
// Check consent in orders.
$orders_with_consent = array();
foreach ($order_ids as $order_id) {
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for consent detection
$consent = get_post_meta($order_id, '_activetrail_consent', true);
if ( 'yes' === $consent ) {
$orders_with_consent[] = $order_id;
}
}
$result['has_consent'] = !empty($orders_with_consent);
$result['count'] = count($orders_with_consent);
$result['sample_order_ids'] = array_slice($orders_with_consent, 0, 5); // First 5 for logging
return $result;
}
/**
* Prepare user contact data for ActiveTrail
*
* @param WP_User $user User object.
* @return array|null
*/
private function prepare_user_contact_data($user)
{
if (!$user || empty($user->user_email)) {
$this->log('prepare_user_contact_data: Invalid user or empty email', array('user_id' => $user ? $user->ID : 'null'), 'debug');
return null;
}
// Get user meta data.
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta queries necessary for user data retrieval
$first_name = get_user_meta($user->ID, 'first_name', true);
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta queries necessary for user data retrieval
$last_name = get_user_meta($user->ID, 'last_name', true);
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta queries necessary for user data retrieval
$phone = get_user_meta($user->ID, 'billing_phone', true);
// Get billing address data.
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta queries necessary for user data retrieval
$city = get_user_meta($user->ID, 'billing_city', true);
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta queries necessary for user data retrieval
$address_1 = get_user_meta($user->ID, 'billing_address_1', true);
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta queries necessary for user data retrieval
$address_2 = get_user_meta($user->ID, 'billing_address_2', true);
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta queries necessary for user data retrieval
$postcode = get_user_meta($user->ID, 'billing_postcode', true);
// Combine address lines.
$street = trim($address_1 . ' ' . $address_2);
// Check ActiveTrail consent status first.
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta queries necessary for consent detection
$consent = get_user_meta($user->ID, '_activetrail_registration_consent', true);
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta queries necessary for consent detection
$checkout_consent = get_user_meta($user->ID, '_activetrail_consent', true);
// Debug: Get all user meta to see what's actually stored.
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for debugging
$all_user_meta = get_user_meta($user->ID);
$activetrail_meta = array();
foreach ($all_user_meta as $key => $value) {
if (strpos($key, 'activetrail-for-woocommerce') !== false) {
$activetrail_meta[$key] = is_array($value) ? $value[0] : $value;
}
}
// Determine subscription status.
$subscription_status = false;
$consent_source = 'none';
$detected_meta_key = null;
// Priority 1: Check ActiveTrail consent.
if ( 'yes' === $consent ) {
$subscription_status = true;
$consent_source = 'registration';
$this->log('User has ActiveTrail registration consent', array('user_id' => $user->ID, 'email' => $user->user_email), 'debug');
} elseif ( 'yes' === $checkout_consent ) {
$subscription_status = true;
$consent_source = 'checkout';
$this->log('User has ActiveTrail checkout consent', array('user_id' => $user->ID, 'email' => $user->user_email), 'debug');
} else {
// Priority 2: Check marketing meta keys from other plugins.
$detection = $this->detect_optin_from_meta_keys($user->ID, 'user');
if ($detection['optin'] === true) {
$subscription_status = true;
$consent_source = 'marketing_plugin';
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Meta queries necessary for opt-in detection
$detected_meta_key = $detection['meta_key'];
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Meta queries necessary for opt-in detection
$this->log('User has opt-in from marketing plugin', array(
'user_id' => $user->ID,
'email' => $user->user_email,
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for opt-in detection
'meta_key' => $detected_meta_key,
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Meta query necessary for opt-in detection
'meta_value' => $detection['meta_value']
), 'debug');
} elseif ($detection['optin'] === false) {
// Explicit opt-out found.
$subscription_status = false;
$consent_source = 'marketing_plugin_optout';
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for opt-in detection
$detected_meta_key = $detection['meta_key'];
$this->log('User has explicit opt-out from marketing plugin', array(
'user_id' => $user->ID,
'email' => $user->user_email,
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for opt-in detection
'meta_key' => $detected_meta_key
), 'debug');
} else {
// Priority 3: Check order meta.
$order_detection = $this->detect_optin_from_order_meta($user->ID, $user->user_email);
if ($order_detection['optin'] === true) {
$subscription_status = true;
$consent_source = 'order_marketing_plugin';
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for opt-in detection
$detected_meta_key = $order_detection['meta_key'];
$this->log('User has opt-in from order marketing plugin', array(
'user_id' => $user->ID,
'email' => $user->user_email,
'order_id' => $order_detection['order_id'],
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for opt-in detection
'meta_key' => $detected_meta_key
), 'debug');
} elseif ($order_detection['optin'] === false) {
// Explicit opt-out found in order.
$subscription_status = false;
$consent_source = 'order_marketing_plugin_optout';
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for opt-in detection
$detected_meta_key = isset($order_detection['meta_key']) ? $order_detection['meta_key'] : null;
$order_id_for_log = isset($order_detection['order_id']) ? $order_detection['order_id'] : null;
$this->log('User has explicit opt-out from order marketing plugin', array(
'user_id' => $user->ID,
'email' => $user->user_email,
'order_id' => $order_id_for_log,
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for opt-in detection
'meta_key' => $detected_meta_key
), 'debug');
} else {
// No consent found anywhere.
$subscription_status = false;
$consent_source = 'none';
$this->log('User has no consent - syncing as unsubscribed', array('user_id' => $user->ID, 'email' => $user->user_email), 'debug');
}
}
}
$this->log('Final user consent decision', array(
'user_id' => $user->ID,
'email' => $user->user_email,
'subscription_status' => $subscription_status,
'consent_source' => $consent_source,
'detected_meta_key' => $detected_meta_key,
'all_activetrail_meta' => $activetrail_meta
), 'debug');
// Generate external ID - always use email-based format (one-to-one) for consistency.
$email = strtolower(trim($user->user_email));
$external_id = 'WC-CONTACT-' . md5($email);
return array(
'email' => $user->user_email,
'first_name' => $first_name ? $first_name : '',
'last_name' => $last_name ? $last_name : '',
'phone' => $phone ? $phone : '',
'city' => $city ? $city : '',
'street' => $street ? $street : '',
'zip_code' => $postcode ? $postcode : '',
'subscriptionStatus' => $subscription_status,
'tracking_consent' => ( get_user_meta( $user->ID, '_activetrail_tracking_consent', true ) === 'yes' ), // CNIL, always boolean
'registration_date' => $user->user_registered,
'source' => 'woocommerce_historical_sync',
'externalId' => $external_id,
'externalName' => ACTIVETRAIL_EXTERNAL_NAME,
'user_id' => $user->ID,
'consent_source' => $consent_source,
'detected_meta_key' => $detected_meta_key
);
}
/**
* Prepare order contact data for ActiveTrail
*
* @param WC_Order $order Order object.
* @return array|null
*/
private function prepare_order_contact_data($order)
{
$order = $this->resolve_order_with_billing_details($order);
if (!$order || empty($order->get_billing_email())) {
$this->log('prepare_order_contact_data: Invalid order or empty email', array('order_id' => $order ? $order->get_id() : 'null'), 'debug');
return null;
}
// Check ActiveTrail consent status from order first.
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for consent detection
$consent = get_post_meta($order->get_id(), '_activetrail_consent', true);
// Debug: Get all order meta to see what's actually stored.
$all_order_meta = $order->get_meta_data();
$activetrail_meta = array();
foreach ($all_order_meta as $meta) {
$key = $meta->get_data()['key'];
if (strpos($key, 'activetrail-for-woocommerce') !== false) {
$activetrail_meta[$key] = $meta->get_data()['value'];
}
}
// Determine subscription status.
$subscription_status = false;
$consent_source = 'none';
$detected_meta_key = null;
// Priority 1: Check ActiveTrail consent.
if ( 'yes' === $consent ) {
$subscription_status = true;
$consent_source = 'checkout';
$this->log('Order has ActiveTrail consent', array('order_id' => $order->get_id(), 'email' => $order->get_billing_email()), 'debug');
} else {
// Priority 2: Check marketing meta keys from other plugins in order meta.
$detection = $this->detect_optin_from_meta_keys($order->get_id(), 'post');
if ($detection['optin'] === true) {
$subscription_status = true;
$consent_source = 'order_marketing_plugin';
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Meta queries necessary for opt-in detection
$detected_meta_key = $detection['meta_key'];
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Meta queries necessary for opt-in detection
$this->log('Order has opt-in from marketing plugin', array(
'order_id' => $order->get_id(),
'email' => $order->get_billing_email(),
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for opt-in detection
'meta_key' => $detected_meta_key,
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Meta query necessary for opt-in detection
'meta_value' => $detection['meta_value']
), 'debug');
} elseif ($detection['optin'] === false) {
// Explicit opt-out found.
$subscription_status = false;
$consent_source = 'order_marketing_plugin_optout';
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for opt-in detection
$detected_meta_key = $detection['meta_key'];
$this->log('Order has explicit opt-out from marketing plugin', array(
'order_id' => $order->get_id(),
'email' => $order->get_billing_email(),
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta query necessary for opt-in detection
'meta_key' => $detected_meta_key
), 'debug');
} else {
// No consent found.
$subscription_status = false;
$consent_source = 'none';
$this->log('Order has no consent - syncing as unsubscribed', array('order_id' => $order->get_id(), 'email' => $order->get_billing_email()), 'debug');
}
}
$this->log('Final order consent decision', array(
'order_id' => $order->get_id(),
'email' => $order->get_billing_email(),
'subscription_status' => $subscription_status,
'consent_source' => $consent_source,
'detected_meta_key' => $detected_meta_key,
'all_activetrail_meta' => $activetrail_meta
), 'debug');
// Generate external ID - always use email-based format (one-to-one) for consistency.
$email = strtolower(trim($order->get_billing_email()));
$external_id = 'WC-CONTACT-' . md5($email);
$customer_id = $order->get_customer_id();
// Tracking consent (CNIL) - always sent as a boolean. Prefer the order's own
// value; fall back to the customer's stored choice for registered users.
$tracking_consent = ( get_post_meta( $order->get_id(), '_activetrail_tracking_consent', true ) === 'yes' );
if ( ! $tracking_consent && $customer_id ) {
$tracking_consent = ( get_user_meta( $customer_id, '_activetrail_tracking_consent', true ) === 'yes' );
}
return array(
'email' => $order->get_billing_email(),
'first_name' => $order->get_billing_first_name(),
'last_name' => $order->get_billing_last_name(),
'phone' => $order->get_billing_phone(),
'subscriptionStatus' => $subscription_status,
'tracking_consent' => $tracking_consent, // CNIL, always boolean
'registration_date' => $order->get_date_created()->format('Y-m-d H:i:s'),
'source' => 'woocommerce_historical_sync',
'externalId' => $external_id,
'externalName' => ACTIVETRAIL_EXTERNAL_NAME,
'user_id' => $customer_id,
'order_id' => $order->get_id(),
'consent_source' => $consent_source,
'detected_meta_key' => $detected_meta_key
);
}
/**
* Start historical sync process
*
* @param array $options Sync options.
* @return array
*/
public function start_historical_sync($options = array())
{
$this->log('start_historical_sync() called with options: ' . wp_json_encode($options), array(), 'debug');
// Validate API configuration.
if (!ActiveTrail_Settings::is_api_configured()) {
$this->log('API key not configured, aborting historical sync', array(), 'error');
return array(
'success' => false,
'message' => esc_html__('API key not configured', 'activetrail-for-woocommerce')
);
}
// Set default options with sync_contacts and sync_orders flags.
$options = wp_parse_args($options, array(
'date_from' => gmdate('Y-m-d H:i:s', strtotime('-1 year')),
'date_to' => current_time('mysql'),
'batch_size' => $this->batch_size,
'force_sync' => false,
'sync_contacts' => true,
'sync_orders' => true,
));
// Normalize the range once, here, so every downstream query and every
// string date comparison works against the same full datetimes.
$options['date_from'] = $this->normalize_range_date($options['date_from'], false);
$options['date_to'] = $this->normalize_range_date($options['date_to'], true);
// Check if sync is already running.
$sync_status = get_option('activetrail_historical_sync_status', array());
if (isset($sync_status['status']) && $sync_status['status'] === 'running') {
// Cancel previous running sync to allow fresh start.
$this->log('Historical sync already running. Cancelling previous sync to start a new one.', array('last_batch_at' => $sync_status['last_batch_at'] ?? null), 'warning');
// Cancel any scheduled batches and reset status.
$sync_status['status'] = 'cancelled';
$sync_status['cancelled_at'] = current_time('mysql');
update_option('activetrail_historical_sync_status', $sync_status);
wp_clear_scheduled_hook('activetrail_process_historical_sync_batch');
// Allow slight delay before restarting.
sleep(1);
}
$total_contacts = 0;
$total_orders = 0;
$total_items = 0;
// Counts only - COUNT queries, no records loaded. Previously this method
// built the entire contact list (every user and every order as objects)
// before replying, which exhausted PHP's memory/time limit on large stores
// and killed the request, surfacing in the browser as "Network error:".
$orders_in_range = $this->count_orders_in_range($options['date_from'], $options['date_to']);
if ($options['sync_contacts']) {
$total_contacts = $this->count_users_in_range($options['date_from'], $options['date_to']) + $orders_in_range;
// Upper bound: consent filtering and email de-duplication happen while
// walking the records, so the real figure can only be lower. The contacts
// phase replaces this with the actual count when it finishes.
$this->log("Estimated contacts to sync: {$total_contacts} (upper bound, before consent filtering)", array(), 'info');
}
if ($options['sync_orders']) {
$total_orders = $orders_in_range;
$this->log("Total orders to sync: {$total_orders}", array(), 'debug');
}
$total_items = $total_contacts + $total_orders;
$this->log("Total items to sync (contacts + orders): {$total_items}", array(), 'info');
if ($total_items === 0) {
// Build detailed message explaining why no items were found.
$message_parts = array();
if ($options['sync_contacts'] && $options['sync_orders']) {
$message_parts[] = esc_html__('No contacts or orders found', 'activetrail-for-woocommerce');
} elseif ($options['sync_contacts']) {
$message_parts[] = esc_html__('No contacts found', 'activetrail-for-woocommerce');
} elseif ($options['sync_orders']) {
$message_parts[] = esc_html__('No orders found', 'activetrail-for-woocommerce');
}
$date_from_formatted = date_i18n(get_option('date_format'), strtotime($options['date_from']));
$date_to_formatted = date_i18n(get_option('date_format'), strtotime($options['date_to']));
$message = sprintf(
/* translators: 1: sync type (contacts/orders), 2: start date, 3: end date */
esc_html__('%1$s for the date range %2$s to %3$s. Please check:', 'activetrail-for-woocommerce'),
implode(' or ', $message_parts),
$date_from_formatted,
$date_to_formatted
);
$message .= '
';
if ($options['sync_contacts']) {
$message .= '- ' . esc_html__('That there are users or orders created in this date range', 'activetrail-for-woocommerce') . '
';
$message .= '- ' . esc_html__('That users have provided consent (if consent is required)', 'activetrail-for-woocommerce') . '
';
}
if ($options['sync_orders']) {
$message .= '- ' . esc_html__('That there are orders with the selected statuses in this date range', 'activetrail-for-woocommerce') . '
';
}
$message .= '- ' . esc_html__('That the date range is correct', 'activetrail-for-woocommerce') . '
';
$message .= '
';
$this->log('No items found for historical sync in given date range', array(
'date_from' => $options['date_from'],
'date_to' => $options['date_to'],
'sync_contacts' => $options['sync_contacts'],
'sync_orders' => $options['sync_orders'],
'total_contacts' => $total_contacts,
'total_orders' => $total_orders
), 'warning');
return array(
'success' => false,
'message' => $message,
'no_items' => true,
'details' => array(
'date_from' => $options['date_from'],
'date_to' => $options['date_to'],
'sync_contacts' => $options['sync_contacts'],
'sync_orders' => $options['sync_orders'],
'total_contacts' => $total_contacts,
'total_orders' => $total_orders
)
);
}
// Initialize sync status.
$sync_status = array(
'status' => 'running',
'sync_phase' => 'contacts', // Start with contacts phase
'started_at' => current_time('mysql'),
'total_contacts' => $total_contacts,
'total_orders' => $total_orders,
'processed_contacts' => 0,
'processed_orders' => 0,
'successful_contacts' => 0,
'failed_contacts_count' => 0,
'failed_contacts' => 0, // Alias for failed_contacts_count for JavaScript compatibility
'successful_orders' => 0,
'failed_orders' => 0,
'successful_syncs' => 0,
'failed_syncs' => 0,
'current_batch' => 0,
// Cursor for the paged contact walk: which source we are reading and how
// far into it we are. Lets each batch resume without re-reading the rest.
'contacts_source' => 'users',
'contacts_source_offset' => 0,
'total_contacts_is_estimate' => true,
'options' => $options,
'errors' => array(),
'failed_contacts_list' => array() // Store individual failed contacts for CSV export
);
update_option('activetrail_historical_sync_status', $sync_status);
// Check if WordPress cron is disabled.
$cron_disabled = defined('DISABLE_WP_CRON') && DISABLE_WP_CRON;
if ($cron_disabled) {
$this->log('WordPress cron is disabled - sync batches will run when WordPress cron is triggered manually or via system cron', array(), 'warning');
}
// Try to schedule first batch.
$this->log('Scheduling first batch for historical sync', array('batch' => 0), 'debug');
$scheduled = wp_schedule_single_event(time() + 5, 'activetrail_process_historical_sync_batch', array(0));
if ($scheduled === false) {
$this->log('Failed to schedule cron event - sync may not run automatically', array(), 'error');
// Don't fail completely - just warn user.
$sync_status['cron_warning'] = true;
update_option('activetrail_historical_sync_status', $sync_status);
}
$this->log('Historical sync started', array(
'total_contacts' => $total_contacts,
'total_orders' => $total_orders,
'date_range' => $options['date_from'] . ' to ' . $options['date_to']
));
return array(
'success' => true,
'message' => sprintf(
/* translators: %d: Total number of items to sync */
esc_html__('Historical sync started. Processing %d items (contacts + orders).', 'activetrail-for-woocommerce'),
$total_items
),
'data' => $sync_status
);
}
/**
* Process a batch of historical contacts and orders
*
* @param int $batch_number Batch number to process.
*/
public function process_historical_sync_batch($batch_number)
{
// Switch to plugin's language setting for translations.
$locale_switched = $this->switch_to_plugin_language();
try {
$this->log('process_historical_sync_batch() called for batch: ' . $batch_number, array(), 'debug');
$sync_status = get_option('activetrail_historical_sync_status', array());
// Check if sync was cancelled.
if (!isset($sync_status['status']) || $sync_status['status'] !== 'running') {
$this->log('Sync status is not running, exiting batch processing early', array('status' => $sync_status['status'] ?? 'unknown'), 'warning');
return;
}
$options = $sync_status['options'];
$sync_phase = isset($sync_status['sync_phase']) ? $sync_status['sync_phase'] : 'contacts';
// Skip past any phase whose work is switched off. Without this a run with
// sync_contacts disabled would sit in the contacts phase forever, because
// the phase only advances from inside the (skipped) contacts block.
if ('contacts' === $sync_phase && empty($options['sync_contacts'])) {
$sync_phase = !empty($options['sync_orders']) ? 'orders' : 'completed';
$sync_status['sync_phase'] = $sync_phase;
}
if ('orders' === $sync_phase && empty($options['sync_orders'])) {
$sync_phase = 'completed';
$sync_status['sync_phase'] = $sync_phase;
}
$batch_successful = 0;
$batch_failed = 0;
$batch_errors = array();
// PHASE 1: Process contacts.
if ($sync_phase === 'contacts' && !empty($options['sync_contacts'])) {
// Read one page from the current source (users first, then order billing
// contacts). Only this page is ever held in memory.
$contacts_source = isset($sync_status['contacts_source']) ? $sync_status['contacts_source'] : 'users';
$contacts_offset = isset($sync_status['contacts_source_offset']) ? (int) $sync_status['contacts_source_offset'] : 0;
$contacts_page_size = $this->get_contact_page_size();
$page = $this->get_contacts_page(
$contacts_source,
$options['date_from'],
$options['date_to'],
$contacts_page_size,
$contacts_offset
);
$contacts = $page['contacts'];
$scanned = (int) $page['scanned'];
$source_exhausted = ($scanned < $contacts_page_size);
// Advance the cursor by records read, not contacts kept - records filtered
// out for missing consent must not be re-read on the next batch.
$sync_status['contacts_source_offset'] = $contacts_offset + $scanned;
$this->log('Processing contacts batch', array(
'source' => $contacts_source,
'scanned' => $scanned,
'kept' => count($contacts),
'offset' => $contacts_offset
), 'debug');
if (!empty($contacts)) {
// Get group ID for batch import.
$group_id = ActiveTrail_Settings::get('group_id');
if (empty($group_id)) {
$this->log('No group_id configured, skipping contacts batch', array(), 'warning');
$batch_errors[] = array(
'type' => 'contacts',
'error' => esc_html__('Group ID is required for contact import', 'activetrail-for-woocommerce')
);
} else {
// Prepare contacts for batch import.
$contacts_to_import = array();
foreach ($contacts as $contact_data) {
// Ensure external ID is set.
if (empty($contact_data['externalId'])) {
if (function_exists('activetrail_external_id_for_contact')) {
$user_id = isset($contact_data['user_id']) ? $contact_data['user_id'] : 0;
$contact_data['externalId'] = activetrail_external_id_for_contact($user_id, $contact_data['email']);
}
}
// Ensure external name is set.
if (empty($contact_data['externalName'])) {
$contact_data['externalName'] = ACTIVETRAIL_EXTERNAL_NAME;
}
$contacts_to_import[] = $contact_data;
}
// Import contacts in smaller chunks to avoid timeouts.
$chunk_size = apply_filters('activetrail_historical_contacts_chunk_size', $this->contact_chunk_size);
$contact_chunks = array_chunk($contacts_to_import, max(1, intval($chunk_size)));
foreach ($contact_chunks as $chunk_index => $chunk_contacts) {
try {
$this->log('Importing contacts chunk', array(
'chunk_index' => $chunk_index,
'chunk_size' => count($chunk_contacts),
'total_contacts' => count($contacts_to_import)
), 'debug');
$result = $this->api_client->import_contacts_batch($chunk_contacts, intval($group_id));
if ($result['success']) {
$success_count = isset($result['success_count']) ? intval($result['success_count']) : count($chunk_contacts);
$failed_count = isset($result['failed_count']) ? intval($result['failed_count']) : 0;
$batch_successful += $success_count;
$batch_failed += $failed_count;
// Mark users as synced (only for successful contacts).
// Note: We mark all as synced because API doesn't tell us which specific contacts failed.
// If failed_count > 0, we still mark all as processed to avoid infinite retries.
// The failed_count is tracked separately for reporting.
foreach ($chunk_contacts as $contact_data) {
if (!empty($contact_data['user_id'])) {
update_user_meta($contact_data['user_id'], '_activetrail_synced', 'yes');
update_user_meta($contact_data['user_id'], '_activetrail_sync_date', current_time('mysql'));
}
}
if ($failed_count > 0) {
// Extract detailed error information from API response if available.
$api_data = isset($result['data']) ? $result['data'] : array();
$error_details = array();
// Check if API provides detailed failure information.
if (isset($api_data['errors']) && is_array($api_data['errors'])) {
$error_details['api_errors'] = $api_data['errors'];
}
if (isset($api_data['failed_contacts']) && is_array($api_data['failed_contacts'])) {
$error_details['failed_contacts'] = $api_data['failed_contacts'];
}
// Extract error reason from API response.
$error_reason = $this->extract_error_reason($api_data, esc_html__('Partial failure in batch import', 'activetrail-for-woocommerce'));
// Add failed contacts to tracking (if API provides specific failed contacts).
if (isset($api_data['failed_contacts']) && is_array($api_data['failed_contacts']) && !empty($api_data['failed_contacts'])) {
// API provided specific failed contacts - use them.
foreach ($api_data['failed_contacts'] as $failed_contact) {
$email = '';
$user_id = '';
// Try to extract email from various possible structures.
if (isset($failed_contact['email'])) {
$email = $failed_contact['email'];
} elseif (isset($failed_contact['contact']['email'])) {
$email = $failed_contact['contact']['email'];
} elseif (isset($failed_contact['contact']) && is_string($failed_contact['contact'])) {
// Sometimes contact might be just the email string.
$email = $failed_contact['contact'];
}
// Try to extract user_id.
if (isset($failed_contact['user_id'])) {
$user_id = $failed_contact['user_id'];
} elseif (isset($failed_contact['contact']['user_id'])) {
$user_id = $failed_contact['contact']['user_id'];
}
if (!empty($email)) {
$contact_error_reason = isset($failed_contact['error']) ? $failed_contact['error'] :
(isset($failed_contact['message']) ? $failed_contact['message'] : $error_reason);
$this->add_failed_contact($email, $contact_error_reason, array(
'user_id' => $user_id,
'additional_info' => wp_json_encode($failed_contact)
));
}
}
} else {
// API doesn't provide specific failed contacts.
// We can't know which specific ones failed, so we log a general error.
// but don't mark individual contacts as failed (to avoid false positives).
$this->log('Partial failure detected but API did not provide specific failed contacts', array(
'chunk_index' => $chunk_index,
'failed_count' => $failed_count,
'total_in_chunk' => count($chunk_contacts)
), 'warning');
}
$this->log('Contacts chunk imported with partial failures', array(
'chunk_index' => $chunk_index,
'success_count' => $success_count,
'failed_count' => $failed_count,
'total_in_chunk' => count($chunk_contacts),
'api_response_data' => $api_data,
'error_details' => $error_details
), 'warning');
// Log partial failure as error for tracking.
/* translators: 1: Number of succeeded items, 2: Number of failed items */
$error_message = sprintf(esc_html__('Partial failure: %1$d succeeded, %2$d failed', 'activetrail-for-woocommerce'), $success_count, $failed_count);
if (!empty($error_details)) {
$error_message .= ' - ' . esc_html__('Check logs for detailed error information', 'activetrail-for-woocommerce');
}
$batch_errors[] = array(
'type' => 'contacts',
'error' => $error_message,
'chunk_index' => $chunk_index,
'success_count' => $success_count,
'failed_count' => $failed_count,
'error_details' => $error_details
);
} else {
$this->log('Contacts chunk imported successfully', array(
'chunk_index' => $chunk_index,
'success_count' => $success_count,
'failed_count' => $failed_count
), 'info');
}
} else {
$failed_contacts = count($chunk_contacts);
$batch_failed += $failed_contacts;
// Extract detailed error information from API response if available.
$api_data = isset($result['data']) ? $result['data'] : array();
$error_message = isset($result['message']) ? $result['message'] : esc_html__('Unknown error', 'activetrail-for-woocommerce');
$error_reason = $this->extract_error_reason($api_data, $error_message);
// Add all contacts in chunk as failed.
foreach ($chunk_contacts as $contact_data) {
$email = isset($contact_data['email']) ? $contact_data['email'] : '';
if (!empty($email)) {
$this->add_failed_contact($email, $error_reason, array(
'user_id' => isset($contact_data['user_id']) ? $contact_data['user_id'] : '',
'additional_info' => esc_html__('Full chunk import failed', 'activetrail-for-woocommerce')
));
}
}
$this->log('Contacts chunk import failed', array(
'chunk_index' => $chunk_index,
'error' => $error_message,
'api_response_data' => $api_data,
'chunk_size' => count($chunk_contacts),
'sample_emails' => array_slice(array_column($chunk_contacts, 'email'), 0, 5) // Log first 5 emails for debugging
), 'error');
$batch_errors[] = array(
'type' => 'contacts',
'error' => $error_message,
'chunk_index' => $chunk_index,
'api_response_data' => $api_data
);
}
} catch (Exception $e) {
$failed_contacts = count($chunk_contacts);
$batch_failed += $failed_contacts;
// Extract sample emails for debugging.
$sample_emails = array_slice(array_column($chunk_contacts, 'email'), 0, 5);
/* translators: %s: Exception message */
$error_reason = sprintf(esc_html__('Exception: %s', 'activetrail-for-woocommerce'), $e->getMessage());
// Add all contacts in chunk as failed.
foreach ($chunk_contacts as $contact_data) {
$email = isset($contact_data['email']) ? $contact_data['email'] : '';
if (!empty($email)) {
$this->add_failed_contact($email, $error_reason, array(
'user_id' => isset($contact_data['user_id']) ? $contact_data['user_id'] : '',
/* translators: %s: Exception class name */
'additional_info' => sprintf(esc_html__('Exception type: %s', 'activetrail-for-woocommerce'), get_class($e))
));
}
}
$this->log('Exception during contacts chunk import', array(
'chunk_index' => $chunk_index,
'exception' => $e->getMessage(),
'exception_type' => get_class($e),
'chunk_size' => count($chunk_contacts),
'sample_emails' => $sample_emails,
'trace' => $e->getTraceAsString()
), 'error');
$batch_errors[] = array(
'type' => 'contacts',
'error' => $e->getMessage(),
'chunk_index' => $chunk_index,
'exception_type' => get_class($e),
'chunk_size' => count($chunk_contacts)
);
}
}
}
$sync_status['processed_contacts'] += count($contacts);
}
// Source exhausted: move to the next source, or finish the phase.
// Driven by the cursor, not by the estimated total - the estimate is an
// upper bound and would otherwise cut the phase short.
if ($source_exhausted && 'users' === $contacts_source) {
$sync_status['contacts_source'] = 'orders';
$sync_status['contacts_source_offset'] = 0;
$this->log('Users contact source exhausted, switching to order contacts', array(
'processed_contacts' => $sync_status['processed_contacts']
), 'info');
} elseif ($source_exhausted) {
// Both sources done - the running tally is now the real total.
$sync_status['total_contacts'] = $sync_status['processed_contacts'];
$sync_status['total_contacts_is_estimate'] = false;
$this->log('Contacts phase completed, moving to orders phase', array(
'processed_contacts' => $sync_status['processed_contacts'],
'total_contacts' => $sync_status['total_contacts']
), 'info');
// Move to orders phase if orders sync is enabled.
if (!empty($options['sync_orders'])) {
$sync_status['sync_phase'] = 'orders';
$sync_status['current_batch'] = 0; // Reset batch counter for orders phase
} else {
// Nothing left to do. Mark the phase finished and fall through to the
// shared tail below, which records this batch's success/failure counts
// and then calls finish_historical_sync(). Returning early here (as the
// previous code did) discarded the final batch's counters, which is the
// whole report in a "Sync Contacts Only" run.
$sync_status['sync_phase'] = 'completed';
}
}
}
// PHASE 2: Process orders (only after contacts phase is complete).
if ($sync_phase === 'orders' && !empty($options['sync_orders'])) {
$orders_batch_size = $options['batch_size']; // Use configured batch size for orders
$orders_offset = $sync_status['processed_orders'] ?? 0;
$orders = $this->get_historical_orders(
$options['date_from'],
$options['date_to'],
$orders_batch_size,
$orders_offset
);
// A short page means the order source is exhausted. Used below so the
// phase can end even if the total drifts (orders deleted or their status
// changed mid-sync), which would otherwise reschedule batches forever.
$orders_source_exhausted = (count($orders) < $orders_batch_size);
$this->log('Processing orders batch', array('count' => count($orders), 'offset' => $orders_offset), 'debug');
foreach ($orders as $order) {
try {
$resolved_order = $this->resolve_order_with_billing_details($order);
if (!$resolved_order) {
$batch_failed++;
$this->log('Skipping order sync - unable to resolve billing details', array('order_id' => method_exists($order, 'get_id') ? $order->get_id() : null, 'order_type' => is_object($order) ? get_class($order) : gettype($order)), 'warning');
continue;
}
// Check if order status should trigger events (respect status filter).
$order_status = $resolved_order->get_status();
$settings = ActiveTrail_Settings::get_all();
$enabled_statuses = isset($settings['order_statuses']) ? $settings['order_statuses'] : array();
// Backward compatibility: if empty array, treat as all enabled.
if (!empty($enabled_statuses)) {
$normalized_status = str_replace('wc-', '', $order_status);
if (!in_array($normalized_status, $enabled_statuses, true)) {
$this->log('Skipping order sync - status not enabled: ' . $order_status, array('order_id' => $resolved_order->get_id()), 'debug');
continue;
}
}
// Prepare order data using API client's transformer.
$order_data = $this->api_client->transform_wc_order_to_activetrail($resolved_order);
// Get external ID - use same format as contacts phase (email-based, one-to-one).
$customer_email = strtolower(trim($resolved_order->get_billing_email()));
$external_id = 'WC-CONTACT-' . md5($customer_email);
$order_data['externalId'] = $external_id;
$order_data['externalName'] = ACTIVETRAIL_EXTERNAL_NAME;
// Add group ID if configured.
$group_id = ActiveTrail_Settings::get('group_id');
if (!empty($group_id)) {
$order_data['groupId'] = intval($group_id);
}
// Sync order using API client.
$result = $this->api_client->submit_order($order_data);
$order_id = $resolved_order->get_id();
$this->log('Order sync result', array('order_id' => $order_id, 'result' => $result), 'debug');
if ($result['success']) {
$batch_successful++;
// Mark order as synced.
$order->update_meta_data('_activetrail_synced', 'yes');
$order->update_meta_data('_activetrail_sync_date', current_time('mysql'));
$order->save();
} else {
$batch_failed++;
// Extract detailed error information from API response if available.
$api_data = isset($result['data']) ? $result['data'] : array();
$error_message = isset($result['message']) ? $result['message'] : esc_html__('Unknown error', 'activetrail-for-woocommerce');
$this->log('Order sync failed', array(
'order_id' => $order_id,
'error' => $error_message,
'api_response_data' => $api_data,
'order_email' => $resolved_order->get_billing_email(),
'order_total' => $resolved_order->get_total(),
'order_date' => $resolved_order->get_date_created() ? $resolved_order->get_date_created()->format('Y-m-d H:i:s') : 'N/A'
), 'error');
$batch_errors[] = array(
'type' => 'order',
'order_id' => $order_id,
'error' => $error_message,
'api_response_data' => $api_data,
'order_email' => $resolved_order->get_billing_email()
);
}
usleep(100000); // 0.1 second between orders
} catch (Exception $e) {
$batch_failed++;
$log_order_id = isset($resolved_order) && is_object($resolved_order) && method_exists($resolved_order, 'get_id')
? $resolved_order->get_id()
: (is_object($order) && method_exists($order, 'get_id') ? $order->get_id() : null);
$error_details = array(
'order_id' => $log_order_id,
'exception' => $e->getMessage(),
'exception_type' => get_class($e),
'trace' => $e->getTraceAsString()
);
// Add order details if available.
if (isset($resolved_order) && is_object($resolved_order)) {
$error_details['order_email'] = method_exists($resolved_order, 'get_billing_email') ? $resolved_order->get_billing_email() : 'N/A';
$error_details['order_total'] = method_exists($resolved_order, 'get_total') ? $resolved_order->get_total() : 'N/A';
}
$this->log('Exception during order sync', $error_details, 'error');
$batch_errors[] = array(
'type' => 'order',
'order_id' => $log_order_id,
'error' => $e->getMessage(),
'exception_type' => get_class($e)
);
}
}
$sync_status['processed_orders'] += count($orders);
}
// Update sync status.
$sync_status['successful_syncs'] += $batch_successful;
$sync_status['failed_syncs'] += $batch_failed;
// Track contacts and orders separately.
if ( 'contacts' === $sync_phase ) {
if (!isset($sync_status['successful_contacts'])) {
$sync_status['successful_contacts'] = 0;
}
if (!isset($sync_status['failed_contacts_count'])) {
$sync_status['failed_contacts_count'] = 0;
}
$sync_status['successful_contacts'] += $batch_successful;
$sync_status['failed_contacts_count'] += $batch_failed;
// Keep failed_contacts in sync with failed_contacts_count for JavaScript compatibility
$sync_status['failed_contacts'] = $sync_status['failed_contacts_count'];
} elseif ( 'orders' === $sync_phase ) {
if (!isset($sync_status['successful_orders'])) {
$sync_status['successful_orders'] = 0;
}
if (!isset($sync_status['failed_orders'])) {
$sync_status['failed_orders'] = 0;
}
$sync_status['successful_orders'] += $batch_successful;
$sync_status['failed_orders'] += $batch_failed;
}
$sync_status['current_batch'] = $batch_number + 1;
$sync_status['last_batch_at'] = current_time('mysql');
// Add errors (keep only last 50 errors to avoid memory issues).
$sync_status['errors'] = array_merge($sync_status['errors'], $batch_errors);
if (count($sync_status['errors']) > 50) {
$sync_status['errors'] = array_slice($sync_status['errors'], -50);
}
update_option('activetrail_historical_sync_status', $sync_status);
$this->log('Historical sync batch processed', array(
'batch_number' => $batch_number,
'sync_phase' => $sync_status['sync_phase'],
'contacts_processed' => $sync_status['processed_contacts'],
'orders_processed' => $sync_status['processed_orders'],
'successful' => $batch_successful,
'failed' => $batch_failed
));
// Determine if we need to continue.
$should_continue = false;
if ( 'contacts' === $sync_status['sync_phase'] ) {
// Still in the contacts phase means at least one source has more records.
// Driven by the cursor rather than the total, because total_contacts is an
// upper bound until the phase completes - comparing against it would end
// the phase early whenever consent filtering removed records.
$should_continue = true;
} elseif ( 'orders' === $sync_status['sync_phase'] ) {
// Continue if there are more orders to process and the source still has rows.
$orders_exhausted = isset($orders_source_exhausted) ? $orders_source_exhausted : false;
$should_continue = (!$orders_exhausted && $sync_status['processed_orders'] < $sync_status['total_orders']);
}
if ($should_continue) {
$this->log('Scheduling next batch', array('next_batch' => $batch_number + 1, 'phase' => $sync_status['sync_phase']), 'debug');
// Schedule next batch.
$scheduled = wp_schedule_single_event(time() + 10, 'activetrail_process_historical_sync_batch', array($batch_number + 1));
if ($scheduled === false) {
$this->log('Failed to schedule next batch via cron - sync may stop', array(), 'error');
// Mark sync as having issues but don't stop it completely.
$sync_status['cron_warning'] = true;
update_option('activetrail_historical_sync_status', $sync_status);
}
} else {
$this->log('All batches processed, finishing sync', array('phase' => $sync_status['sync_phase']), 'debug');
// All items processed - finish sync.
$this->finish_historical_sync();
}
} finally {
// Restore original locale.
if ($locale_switched) {
$this->restore_plugin_language();
}
}
}
/**
* Finish historical sync process
*/
private function finish_historical_sync()
{
$sync_status = get_option('activetrail_historical_sync_status', array());
$sync_status['status'] = 'completed';
$sync_status['completed_at'] = current_time('mysql');
// Generate CSV file with failed contacts if there are any.
if (!empty($sync_status['failed_contacts_list']) && is_array($sync_status['failed_contacts_list'])) {
$csv_path = $this->generate_failed_contacts_csv($sync_status['failed_contacts_list']);
if ($csv_path) {
$this->log('Generated failed contacts CSV file', array(
'filepath' => $csv_path,
'failed_count' => count($sync_status['failed_contacts_list'])
), 'info');
}
}
update_option('activetrail_historical_sync_status', $sync_status);
$this->log('Historical sync completed', array(
'total_processed' => $sync_status['processed_contacts'],
'successful' => $sync_status['successful_syncs'],
'failed' => $sync_status['failed_syncs'],
'failed_contacts_count' => isset($sync_status['failed_contacts_count']) ? $sync_status['failed_contacts_count'] : 0
));
// Store sync history.
$this->store_sync_history($sync_status);
}
/**
* Store sync history for future reference
*
* @param array $sync_status Final sync status.
*/
private function store_sync_history($sync_status)
{
$history = get_option('activetrail_historical_sync_history', array());
$history[] = array(
'started_at' => $sync_status['started_at'],
'completed_at' => $sync_status['completed_at'],
'total_contacts' => $sync_status['total_contacts'],
'successful_syncs' => $sync_status['successful_syncs'],
'failed_syncs' => $sync_status['failed_syncs'],
'date_range' => $sync_status['options']['date_from'] . ' to ' . $sync_status['options']['date_to']
);
// Keep only last 10 sync records.
if (count($history) > 10) {
$history = array_slice($history, -10);
}
update_option('activetrail_historical_sync_history', $history);
}
/**
* Cancel historical sync process
*
* @return array
*/
public function cancel_historical_sync()
{
$sync_status = get_option('activetrail_historical_sync_status', array());
if (!isset($sync_status['status']) || $sync_status['status'] !== 'running') {
return array(
'success' => false,
'message' => esc_html__('No active sync to cancel', 'activetrail-for-woocommerce')
);
}
$sync_status['status'] = 'cancelled';
$sync_status['cancelled_at'] = current_time('mysql');
update_option('activetrail_historical_sync_status', $sync_status);
// Clear any scheduled batches.
wp_clear_scheduled_hook('activetrail_process_historical_sync_batch');
$this->log('Historical sync cancelled', array(
'processed_before_cancel' => $sync_status['processed_contacts'],
'successful_before_cancel' => $sync_status['successful_syncs']
));
return array(
'success' => true,
'message' => esc_html__('Historical sync cancelled successfully', 'activetrail-for-woocommerce')
);
}
/**
* Get current sync progress
*
* @return array
*/
public function get_sync_progress()
{
$sync_status = get_option('activetrail_historical_sync_status', array());
if (empty($sync_status)) {
return array(
'status' => 'none',
'message' => esc_html__('No sync has been started', 'activetrail-for-woocommerce')
);
}
$sync_phase = isset($sync_status['sync_phase']) ? $sync_status['sync_phase'] : 'contacts';
// Ensure all required fields are set with default values.
$sync_status['total_contacts'] = isset($sync_status['total_contacts']) ? (int) $sync_status['total_contacts'] : 0;
$sync_status['processed_contacts'] = isset($sync_status['processed_contacts']) ? (int) $sync_status['processed_contacts'] : 0;
$sync_status['total_orders'] = isset($sync_status['total_orders']) ? (int) $sync_status['total_orders'] : 0;
$sync_status['processed_orders'] = isset($sync_status['processed_orders']) ? (int) $sync_status['processed_orders'] : 0;
$sync_status['successful_contacts'] = isset($sync_status['successful_contacts']) ? (int) $sync_status['successful_contacts'] : 0;
$sync_status['failed_contacts'] = isset($sync_status['failed_contacts_count']) ? (int) $sync_status['failed_contacts_count'] : (isset($sync_status['failed_contacts']) ? (int) $sync_status['failed_contacts'] : 0);
$sync_status['successful_orders'] = isset($sync_status['successful_orders']) ? (int) $sync_status['successful_orders'] : 0;
$sync_status['failed_orders'] = isset($sync_status['failed_orders']) ? (int) $sync_status['failed_orders'] : 0;
$sync_status['successful_syncs'] = isset($sync_status['successful_syncs']) ? (int) $sync_status['successful_syncs'] : 0;
$sync_status['failed_syncs'] = isset($sync_status['failed_syncs']) ? (int) $sync_status['failed_syncs'] : 0;
// Calculate progress percentage based on current phase.
$progress_percentage = 0;
$contacts_progress = 0;
$orders_progress = 0;
if ($sync_status['total_contacts'] > 0) {
$contacts_progress = round(($sync_status['processed_contacts'] / $sync_status['total_contacts']) * 100, 1);
}
if ($sync_status['total_orders'] > 0) {
$orders_progress = round(($sync_status['processed_orders'] / $sync_status['total_orders']) * 100, 1);
}
// Overall progress: weighted average (contacts + orders).
$total_items = $sync_status['total_contacts'] + $sync_status['total_orders'];
if ($total_items > 0) {
$processed_items = $sync_status['processed_contacts'] + $sync_status['processed_orders'];
$progress_percentage = round(($processed_items / $total_items) * 100, 1);
}
$sync_status['progress_percentage'] = $progress_percentage;
$sync_status['contacts_progress'] = $contacts_progress;
$sync_status['orders_progress'] = $orders_progress;
$sync_status['sync_phase'] = $sync_phase;
// Keep failed_contacts in sync with failed_contacts_count for JavaScript compatibility
if (isset($sync_status['failed_contacts_count'])) {
$sync_status['failed_contacts'] = $sync_status['failed_contacts_count'];
} elseif (!isset($sync_status['failed_contacts'])) {
$sync_status['failed_contacts'] = 0;
}
// Include failed contacts CSV info if available.
if (isset($sync_status['failed_contacts_csv'])) {
// CSV info is already in sync_status.
}
return $sync_status;
}
/**
* Offer historical sync when API key changes
*/
public function offer_historical_sync()
{
// Set a flag to show the historical sync offer in admin.
update_option('activetrail_show_historical_sync_offer', true);
}
/**
* AJAX handler for starting historical sync
*/
public function ajax_start_historical_sync()
{
try {
check_ajax_referer('activetrail_admin_nonce', 'nonce');
} catch (Exception $e) {
wp_send_json_error(array(
'success' => false,
'message' => esc_html__('Security check failed', 'activetrail-for-woocommerce')
));
return;
}
if (!current_user_can('manage_options')) {
wp_send_json_error(array(
'success' => false,
'message' => esc_html__('Insufficient permissions', 'activetrail-for-woocommerce')
));
return;
}
// Check if WordPress cron is disabled and warn user.
$cron_disabled = defined('DISABLE_WP_CRON') && DISABLE_WP_CRON;
if ($cron_disabled) {
$this->log('Historical sync started with WordPress cron disabled', array(), 'warning');
}
$date_from = isset($_POST['date_from']) ? sanitize_text_field(wp_unslash($_POST['date_from'])) : '';
$date_to = isset($_POST['date_to']) ? sanitize_text_field(wp_unslash($_POST['date_to'])) : '';
$force_sync = isset($_POST['force_sync']) && sanitize_text_field(wp_unslash($_POST['force_sync']));
$sync_contacts = isset($_POST['sync_contacts']) ? filter_var(wp_unslash($_POST['sync_contacts']), FILTER_VALIDATE_BOOLEAN) : true;
$sync_orders = isset($_POST['sync_orders']) ? filter_var(wp_unslash($_POST['sync_orders']), FILTER_VALIDATE_BOOLEAN) : false;
$options = array();
if (!empty($date_from)) {
$options['date_from'] = $date_from;
}
if (!empty($date_to)) {
$options['date_to'] = $date_to;
}
$options['force_sync'] = $force_sync;
$options['sync_contacts'] = $sync_contacts;
$options['sync_orders'] = $sync_orders;
try {
$result = $this->start_historical_sync($options);
if ($result && isset($result['success']) && $result['success']) {
// Add cron status to response.
if ($cron_disabled) {
$result['cron_disabled'] = true;
$result['message'] .= ' ' . esc_html__('Note: WordPress cron is disabled, using alternative processing method.', 'activetrail-for-woocommerce');
}
wp_send_json_success($result);
} else {
$error_message = isset($result['message']) ? $result['message'] : esc_html__('Failed to start historical sync', 'activetrail-for-woocommerce');
wp_send_json_error(array(
'success' => false,
'message' => $error_message
));
}
} catch (Exception $e) {
$this->log('Exception in ajax_start_historical_sync', array('error' => $e->getMessage(), 'trace' => $e->getTraceAsString()), 'error');
wp_send_json_error(array(
'success' => false,
'message' => esc_html__('Error starting historical sync: ', 'activetrail-for-woocommerce') . $e->getMessage()
));
} catch (Error $e) {
// PHP 7+ Error handling.
$this->log('Fatal error in ajax_start_historical_sync', array('error' => $e->getMessage(), 'trace' => $e->getTraceAsString()), 'error');
wp_send_json_error(array(
'success' => false,
'message' => esc_html__('Fatal error starting historical sync: ', 'activetrail-for-woocommerce') . $e->getMessage()
));
}
}
/**
* AJAX handler for getting sync progress
*/
public function ajax_get_sync_progress()
{
try {
check_ajax_referer('activetrail_admin_nonce', 'nonce');
} catch (Exception $e) {
wp_send_json_error(array(
'success' => false,
'message' => esc_html__('Security check failed', 'activetrail-for-woocommerce')
));
return;
}
if (!current_user_can('manage_options')) {
wp_send_json_error(array(
'success' => false,
'message' => esc_html__('Insufficient permissions', 'activetrail-for-woocommerce')
));
return;
}
try {
$progress = $this->get_sync_progress();
wp_send_json_success($progress);
} catch (Exception $e) {
$this->log('Exception in ajax_get_sync_progress', array('error' => $e->getMessage(), 'trace' => $e->getTraceAsString()), 'error');
wp_send_json_error(array(
'success' => false,
'message' => esc_html__('Error getting sync progress: ', 'activetrail-for-woocommerce') . $e->getMessage()
));
} catch (Error $e) {
// PHP 7+ Error handling.
$this->log('Fatal error in ajax_get_sync_progress', array('error' => $e->getMessage(), 'trace' => $e->getTraceAsString()), 'error');
wp_send_json_error(array(
'success' => false,
'message' => esc_html__('Fatal error getting sync progress: ', 'activetrail-for-woocommerce') . $e->getMessage()
));
}
}
/**
* AJAX handler for cancelling historical sync
*/
public function ajax_cancel_historical_sync()
{
check_ajax_referer('activetrail_admin_nonce', 'nonce');
if (!current_user_can('manage_options')) {
wp_die(esc_html__('Insufficient permissions', 'activetrail-for-woocommerce'));
}
$result = $this->cancel_historical_sync();
if ($result['success']) {
wp_send_json_success($result);
} else {
wp_send_json_error($result);
}
}
/**
* Add failed contact to tracking array
*
* @param string $email Contact email.
* @param string $error_reason Error reason/message.
* @param array $additional_data Additional data about the failure.
*/
private function add_failed_contact($email, $error_reason, $additional_data = array())
{
$sync_status = get_option('activetrail_historical_sync_status', array());
if (!isset($sync_status['failed_contacts_list'])) {
$sync_status['failed_contacts_list'] = array();
}
// Avoid duplicates - check if email already exists.
$email_lower = strtolower(trim($email));
$exists = false;
foreach ($sync_status['failed_contacts_list'] as $index => $failed) {
if (strtolower(trim($failed['email'])) === $email_lower) {
// Update existing entry if we have more details.
if (!empty($error_reason) && empty($failed['error_reason'])) {
$sync_status['failed_contacts_list'][$index]['error_reason'] = $error_reason;
}
if (!empty($additional_data)) {
$sync_status['failed_contacts_list'][$index] = array_merge($sync_status['failed_contacts_list'][$index], $additional_data);
}
$exists = true;
break;
}
}
if (!$exists) {
$sync_status['failed_contacts_list'][] = array_merge(array(
'email' => $email,
'error_reason' => $error_reason,
'failed_at' => current_time('mysql')
), $additional_data);
}
update_option('activetrail_historical_sync_status', $sync_status);
}
/**
* Generate CSV file with failed contacts
*
* @param array $failed_contacts Array of failed contacts.
* @return string|false File path on success, false on failure
*/
private function generate_failed_contacts_csv($failed_contacts)
{
if (empty($failed_contacts)) {
return false;
}
$upload_dir = wp_upload_dir();
if ($upload_dir['error']) {
$this->log('Failed to get upload directory', array('error' => $upload_dir['error']), 'error');
return false;
}
// Create activetrail directory if it doesn't exist.
$activetrail_dir = $upload_dir['basedir'] . '/activetrail';
if (!file_exists($activetrail_dir)) {
wp_mkdir_p($activetrail_dir);
}
// Generate filename with timestamp.
$timestamp = gmdate('Y-m-d_H-i-s');
$filename = 'failed_contacts_' . $timestamp . '.csv';
$filepath = $activetrail_dir . '/' . $filename;
// Open file for writing.
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- fputcsv requires native fopen
$file = fopen($filepath, 'w');
if (!$file) {
$this->log('Failed to create CSV file', array('filepath' => $filepath), 'error');
return false;
}
// Add BOM for UTF-8 (helps Excel display Hebrew correctly).
fprintf($file, chr(0xEF).chr(0xBB).chr(0xBF));
// Write CSV header.
$headers = array(
esc_html__('Email', 'activetrail-for-woocommerce'),
esc_html__('Error Reason', 'activetrail-for-woocommerce'),
esc_html__('Failed At', 'activetrail-for-woocommerce'),
esc_html__('User ID', 'activetrail-for-woocommerce'),
esc_html__('Additional Info', 'activetrail-for-woocommerce')
);
fputcsv($file, $headers);
// Write failed contacts data.
foreach ($failed_contacts as $failed) {
$row = array(
isset($failed['email']) ? $failed['email'] : '',
isset($failed['error_reason']) ? $failed['error_reason'] : '',
isset($failed['failed_at']) ? $failed['failed_at'] : '',
isset($failed['user_id']) ? $failed['user_id'] : '',
isset($failed['additional_info']) ? $failed['additional_info'] : ''
);
fputcsv($file, $row);
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- fputcsv requires native fclose
fclose($file);
// Store file path in sync status.
$sync_status = get_option('activetrail_historical_sync_status', array());
$sync_status['failed_contacts_csv'] = array(
'filename' => $filename,
'filepath' => $filepath,
'url' => $upload_dir['baseurl'] . '/activetrail/' . $filename,
'count' => count($failed_contacts)
);
update_option('activetrail_historical_sync_status', $sync_status);
$this->log('Generated failed contacts CSV', array(
'filepath' => $filepath,
'count' => count($failed_contacts)
), 'info');
return $filepath;
}
/**
* Extract error reason from API response
*
* @param array $api_data API response data.
* @param string $default_message Default error message.
* @return string Error reason
*/
private function extract_error_reason($api_data, $default_message = '')
{
$error_reason = $default_message;
// Check for common error patterns in API response.
// ActiveTrail API may return errors in different formats.
if (isset($api_data['Message'])) {
// ActiveTrail sometimes uses capital M.
$error_reason = $api_data['Message'];
} elseif (isset($api_data['message'])) {
$error_reason = $api_data['message'];
} elseif (isset($api_data['Error'])) {
// ActiveTrail sometimes uses capital E.
$error_reason = is_array($api_data['Error']) ? wp_json_encode($api_data['Error']) : $api_data['Error'];
} elseif (isset($api_data['error'])) {
$error_reason = is_array($api_data['error']) ? wp_json_encode($api_data['error']) : $api_data['error'];
} elseif (isset($api_data['errors']) && is_array($api_data['errors'])) {
$error_messages = array();
foreach ($api_data['errors'] as $error) {
if (is_array($error)) {
// Check for nested error structures.
if (isset($error['message'])) {
$error_messages[] = $error['message'];
} elseif (isset($error['Message'])) {
$error_messages[] = $error['Message'];
} elseif (isset($error['error'])) {
$error_messages[] = is_array($error['error']) ? wp_json_encode($error['error']) : $error['error'];
} else {
$error_messages[] = wp_json_encode($error);
}
} else {
$error_messages[] = $error;
}
}
$error_reason = implode('; ', $error_messages);
} elseif (isset($api_data['ErrorMessage'])) {
$error_reason = $api_data['ErrorMessage'];
} elseif (isset($api_data['errorMessage'])) {
$error_reason = $api_data['errorMessage'];
}
// Normalize error reason to lowercase for pattern matching.
$error_reason_lower = strtolower($error_reason);
// Check for specific error types and provide user-friendly messages.
// 1. Contact already exists / Duplicate.
if (stripos($error_reason, 'already exists') !== false ||
stripos($error_reason, 'duplicate') !== false ||
stripos($error_reason, 'כבר קיים') !== false ||
stripos($error_reason, 'exists') !== false ||
stripos($error_reason, 'already a member') !== false) {
$error_reason = esc_html__('Contact already exists in ActiveTrail', 'activetrail-for-woocommerce');
}
// 2. Email validation errors.
elseif (stripos($error_reason, 'invalid email') !== false ||
stripos($error_reason, 'email is required') !== false ||
stripos($error_reason, 'email format') !== false ||
stripos($error_reason, 'email address') !== false ||
(stripos($error_reason, 'email') !== false && stripos($error_reason, 'invalid') !== false)) {
$error_reason = esc_html__('Invalid email address', 'activetrail-for-woocommerce');
}
// 3. Validation errors.
elseif (stripos($error_reason, 'validation') !== false ||
stripos($error_reason, 'validate') !== false ||
stripos($error_reason, 'invalid') !== false) {
$error_reason = esc_html__('Validation error', 'activetrail-for-woocommerce');
}
// 4. API authentication errors.
elseif (stripos($error_reason, 'unauthorized') !== false ||
stripos($error_reason, 'authentication') !== false ||
stripos($error_reason, 'api key') !== false ||
stripos($error_reason, 'forbidden') !== false ||
stripos($error_reason, '401') !== false ||
stripos($error_reason, '403') !== false) {
$error_reason = esc_html__('API authentication failed', 'activetrail-for-woocommerce');
}
// 5. Rate limiting / Too many requests.
elseif (stripos($error_reason, 'rate limit') !== false ||
stripos($error_reason, 'too many requests') !== false ||
stripos($error_reason, '429') !== false ||
stripos($error_reason, 'limit') !== false) {
$error_reason = esc_html__('API rate limit exceeded', 'activetrail-for-woocommerce');
}
// 6. Server errors.
elseif (stripos($error_reason, '500') !== false ||
stripos($error_reason, '502') !== false ||
stripos($error_reason, '503') !== false ||
stripos($error_reason, '504') !== false ||
stripos($error_reason, 'server error') !== false ||
stripos($error_reason, 'internal server') !== false) {
$error_reason = esc_html__('ActiveTrail server error', 'activetrail-for-woocommerce');
}
// 7. Network/Connection errors.
elseif (stripos($error_reason, 'connection') !== false ||
stripos($error_reason, 'timeout') !== false ||
stripos($error_reason, 'network') !== false ||
stripos($error_reason, 'curl') !== false) {
$error_reason = esc_html__('Network connection error', 'activetrail-for-woocommerce');
}
// 8. Missing required fields.
elseif (stripos($error_reason, 'required') !== false ||
stripos($error_reason, 'missing') !== false ||
stripos($error_reason, 'mandatory') !== false) {
$error_reason = esc_html__('Missing required field', 'activetrail-for-woocommerce');
}
// 9. Group/List errors.
elseif (stripos($error_reason, 'group') !== false ||
stripos($error_reason, 'list') !== false) {
$error_reason = esc_html__('Group/List error', 'activetrail-for-woocommerce');
}
// 10. Import limit errors.
elseif (stripos($error_reason, 'import limit') !== false ||
stripos($error_reason, 'limit reached') !== false ||
stripos($error_reason, 'limited to') !== false) {
$error_reason = esc_html__('ActiveTrail import limit reached', 'activetrail-for-woocommerce');
}
// If we still have the default message or empty, return unknown error.
if (empty($error_reason) || $error_reason === $default_message) {
// If we have some API data, try to extract more info.
if (!empty($api_data)) {
// Try to get any error-related field.
foreach (array('error', 'Error', 'errors', 'Errors', 'message', 'Message', 'errorMessage', 'ErrorMessage') as $field) {
if (isset($api_data[$field]) && !empty($api_data[$field])) {
$extracted = is_array($api_data[$field]) ? wp_json_encode($api_data[$field]) : $api_data[$field];
if ($extracted !== $error_reason) {
$error_reason = $extracted;
break;
}
}
}
}
// If still empty or default, return unknown.
if (empty($error_reason) || $error_reason === $default_message) {
$error_reason = esc_html__('Unknown error', 'activetrail-for-woocommerce');
}
}
return $error_reason;
}
/**
* Switch to plugin's configured language
*
* @return bool True if locale was switched, false otherwise
*/
private function switch_to_plugin_language()
{
$current_language = ActiveTrail_Settings::get_current_language();
if (empty($current_language)) {
return false;
}
// Support WPML if installed.
if (function_exists('icl_object_id') && function_exists('do_action')) {
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound -- WPML core global variable
global $sitepress;
if ($sitepress) {
// Convert language code format if needed (e.g., en_US -> en).
$wpml_lang = strstr($current_language, '_', true) ?: $current_language;
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WPML hook
do_action('wpml_switch_language', $wpml_lang);
}
}
// Support Polylang if installed.
if (function_exists('pll_current_language') && function_exists('PLL')) {
$polylang = PLL();
if ($polylang) {
// Convert language code format if needed.
$pll_lang = strstr($current_language, '_', true) ?: $current_language;
$polylang->curlang = $polylang->model->get_language($pll_lang);
if (!$polylang->curlang) {
// Try full code.
$polylang->curlang = $polylang->model->get_language($current_language);
}
}
}
// Switch WordPress locale for text domain translations.
if (function_exists('switch_to_locale')) {
return switch_to_locale($current_language);
}
return false;
}
/**
* Restore original locale
*/
private function restore_plugin_language()
{
// Restore WordPress locale. Pop a single level where possible: the paged
// sync reads run inside an outer switch, and restore_current_locale() would
// discard the whole stack and drop the outer switch too.
if (function_exists('restore_previous_locale')) {
restore_previous_locale();
} elseif (function_exists('restore_current_locale')) {
restore_current_locale();
}
// WPML and Polylang don't need explicit restoration as they handle it automatically.
}
/**
* Log message
*
* @param string $message Message to log.
* @param array $data Additional data.
* @param string $level Log level.
*/
private function log($message, $data = array(), $level = 'info')
{
// Always log to WordPress error log for debugging.
$log_message = '[ActiveTrail Historical Sync] ' . $message;
if (!empty($data)) {
$log_message .= ' - ' . wp_json_encode($data);
}
// Use the ActiveTrail logger if available.
if (class_exists('ActiveTrail_Logger')) {
$logger = ActiveTrail_Logger::get_instance();
switch (strtolower($level)) {
case 'error':
$logger->error($message, 'HISTORICAL_SYNC', $data);
break;
case 'warning':
$logger->warning($message, 'HISTORICAL_SYNC', $data);
break;
case 'debug':
$logger->debug($message, 'HISTORICAL_SYNC', $data);
break;
default:
$logger->info($message, 'HISTORICAL_SYNC', $data);
break;
}
}
}
}