PluginProbe
AI Chatbot for WooCommerce & Live Chat – onWebChat / 3.8.2
AI Chatbot for WooCommerce & Live Chat – onWebChat v3.8.2
3.10.0 3.9.2 3.9.3 3.9.1 3.9.0 3.8.4 3.8.2 3.8.1 3.8.0 3.7.2 3.7.1 3.7.0 3.6.0 3.5.5 trunk 1.0 1.0.1 1.0.10 1.0.11 1.0.12 1.0.13 1.0.2 1.0.3 1.0.4 1.0.5 All 49 releases
onwebchat / includes / woocommerce-sync.php

woocommerce-sync.php in AI Chatbot for WooCommerce & Live Chat – onWebChat 3.8.2, at includes/woocommerce-sync.php

1,688 lines 66.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WooCommerce Product Sync Module
4 * Syncs WooCommerce products to onWebChat for AI bot training
5 */
6
7 if (!defined('ABSPATH')) {
8 exit; // Exit if accessed directly
9 }
10
11 class OnWebChat_WooCommerce_Sync {
12
13 private $api_endpoint_prod = 'https://www.onwebchat.com/api/integrations/woocommerce';
14 private $api_endpoint_dev = 'http://127.0.0.1:81/api/integrations/woocommerce';
15 private $max_description_length = 1500;
16 private $batch_size = 50;
17 private $use_testing_mode;
18
19 // Large-catalogue sync scope.
20 // Stores with more than CATEGORY_SELECT_THRESHOLD published products get a
21 // category picker so the merchant can choose what to sync. The sync is
22 // hard-capped at MAX_SYNC_PRODUCTS so we never try to embed an unbounded
23 // catalogue. Above the cap a category selection is required.
24 const CATEGORY_SELECT_THRESHOLD = 2000;
25 const MAX_SYNC_PRODUCTS = 15000;
26
27 /**
28 * Get the API endpoint based on testing mode
29 * @return string
30 */
31 private function get_api_endpoint() {
32 return $this->use_testing_mode ? $this->api_endpoint_dev : $this->api_endpoint_prod;
33 }
34
35 public function __construct() {
36 // Read testing mode from global constant (defined in onwebchat.php)
37 $this->use_testing_mode = defined('ONWEBCHAT_WC_TESTING_MODE') ? ONWEBCHAT_WC_TESTING_MODE : false;
38 // Initialize settings
39 add_action('admin_init', array($this, 'register_settings'));
40
41 // Show authentication error notice globally (not just on WooCommerce tab)
42 add_action('admin_notices', array($this, 'show_auth_error_notice'));
43
44 // Product hooks - use WooCommerce hooks that fire AFTER meta data is saved
45 add_action('woocommerce_update_product', array($this, 'on_product_update'), 10, 1);
46 add_action('woocommerce_new_product', array($this, 'on_product_update'), 10, 1);
47
48 // Lightweight availability hook: fires whenever a product's stock STATUS flips
49 // (including order-driven stock reductions that may not trigger a full product save).
50 // It pushes only the in/out-of-stock boolean to onWebChat, which updates it without
51 // re-embedding. Variations are intentionally not hooked: WooCommerce recomputes the
52 // parent product's stock status from its variations and fires this action for the
53 // parent, which is the entity synced to onWebChat.
54 add_action('woocommerce_product_set_stock_status', array($this, 'on_stock_status_change'), 10, 3);
55
56 // Handle product deletion (both trash and permanent delete)
57 add_action('wp_trash_post', array($this, 'on_product_trash'), 10, 1);
58 add_action('before_delete_post', array($this, 'on_product_delete'), 10, 2);
59
60 // Bulk sync via WP Cron
61 add_action('onwebchat_wc_bulk_sync_batch', array($this, 'process_bulk_sync_batch'));
62
63 // Admin AJAX handlers
64 add_action('wp_ajax_onwebchat_wc_sync_now', array($this, 'ajax_sync_existing_products'));
65 // Client-driven chunked bulk sync: the browser starts a run, then calls
66 // the batch action repeatedly (one page per request) until it completes.
67 // This replaces the single long request that timed out on large
68 // catalogues and reported a false "sync failed" while products kept
69 // syncing.
70 add_action('wp_ajax_onwebchat_wc_sync_start', array($this, 'ajax_start_bulk_sync'));
71 add_action('wp_ajax_onwebchat_wc_sync_batch', array($this, 'ajax_sync_next_batch'));
72 add_action('wp_ajax_onwebchat_wc_regenerate_secret', array($this, 'ajax_regenerate_secret'));
73 add_action('wp_ajax_onwebchat_wc_reset_sync_status', array($this, 'ajax_reset_sync_status'));
74 add_action('wp_ajax_onwebchat_wc_connect', array($this, 'ajax_connect_woocommerce'));
75 add_action('wp_ajax_onwebchat_wc_manual_process_batch', array($this, 'ajax_manual_process_batch'));
76 add_action('wp_ajax_onwebchat_wc_get_sync_status', array($this, 'ajax_get_sync_status'));
77 add_action('wp_ajax_onwebchat_wc_save_sync_enabled', array($this, 'ajax_save_sync_enabled'));
78 }
79
80 /**
81 * AJAX: Connect WooCommerce with authentication
82 */
83 public function ajax_connect_woocommerce() {
84 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
85
86 if (!current_user_can('manage_options')) {
87 wp_send_json_error('Insufficient permissions');
88 }
89
90 $email = isset($_POST['email']) ? sanitize_email($_POST['email']) : '';
91 $password = isset($_POST['password']) ? sanitize_text_field($_POST['password']) : '';
92
93 if (empty($email) || empty($password)) {
94 wp_send_json_error('Email and password are required');
95 }
96
97 $result = $this->request_secret_with_auth($email, $password);
98
99 if ($result['success']) {
100 // Clear any previous authentication errors
101 delete_transient('onwebchat_wc_auth_error');
102
103 wp_send_json_success(array(
104 'message' => 'WooCommerce sync connected successfully!'
105 ));
106 } else {
107 wp_send_json_error($result['error']);
108 }
109 }
110
111 /**
112 * Show authentication error notice globally across all admin pages
113 * (Hidden when already on WooCommerce tab since it has its own error message)
114 */
115 public function show_auth_error_notice() {
116 $auth_error = get_transient('onwebchat_wc_auth_error');
117
118 // Don't show if we're already on the WooCommerce tab (it has its own error message)
119 $is_woocommerce_tab = isset($_GET['page']) && $_GET['page'] === 'onwebchat_settings'
120 && isset($_GET['tab']) && $_GET['tab'] === 'woocommerce';
121
122 if ($auth_error && class_exists('WooCommerce') && !$is_woocommerce_tab) {
123 ?>
124 <div class="notice notice-error">
125 <p>
126 <strong>⚠️ onWebChat WooCommerce Sync Error:</strong>
127 <?php echo esc_html($auth_error); ?>
128 <a href="<?php echo esc_url(admin_url('admin.php?page=onwebchat_settings&tab=woocommerce')); ?>" class="button button-small" style="margin-left: 10px;">
129 Fix Authentication
130 </a>
131 </p>
132 </div>
133 <?php
134 }
135 }
136
137 /**
138 * Register WooCommerce sync settings
139 */
140 public function register_settings() {
141 register_setting('onwebchat_wc_sync', 'onwebchat_wc_sync_enabled');
142 register_setting('onwebchat_wc_sync', 'onwebchat_wc_sync_mode');
143 register_setting('onwebchat_wc_sync', 'onwebchat_wc_sync_secret');
144 register_setting('onwebchat_wc_sync', 'onwebchat_wc_last_bulk_sync');
145 register_setting('onwebchat_wc_sync', 'onwebchat_wc_excluded_categories');
146 // Persisted sync scope: comma-separated product_cat term IDs. Empty means
147 // the whole catalogue is in scope. Drives both bulk sync and ongoing
148 // per-product auto-sync.
149 register_setting('onwebchat_wc_sync', 'onwebchat_wc_sync_categories');
150 }
151
152 /**
153 * Hook: Product update (WooCommerce specific hook - fires AFTER all meta is saved)
154 */
155 public function on_product_update($product_id) {
156 // Check if sync is enabled
157 if (!get_option('onwebchat_wc_sync_enabled', false)) {
158 return;
159 }
160
161 // Get product object (at this point all meta data including SKU is already saved)
162 $product = wc_get_product($product_id);
163 if (!$product) {
164 return;
165 }
166
167 // Only sync published products
168 if ($product->get_status() !== 'publish') {
169 return;
170 }
171
172 // Check if product category is excluded
173 if ($this->is_product_excluded($product)) {
174 return;
175 }
176
177 // Respect the merchant's sync scope. When a category selection is active
178 // and this product belongs to none of the scoped categories, remove it
179 // (it may have been moved out of a scoped category after being synced)
180 // and stop. Deletes always remove, regardless of scope.
181 if (!$this->product_in_scope($product)) {
182 $this->send_product_delete($product_id);
183 return;
184 }
185
186 // Prepare and send product data
187 $product_data = $this->prepare_product_data($product);
188 $this->send_product_upsert($product_data, $product_id);
189 }
190
191 /**
192 * Hook: product stock STATUS changed (in stock / out of stock / on backorder).
193 * Pushes only the availability boolean to onWebChat (no re-embed). "onbackorder"
194 * is treated as available since the store still accepts orders.
195 *
196 * @param int $product_id
197 * @param string $status 'instock' | 'outofstock' | 'onbackorder'
198 * @param WC_Product|null $product
199 */
200 public function on_stock_status_change($product_id, $status, $product = null) {
201 if (!get_option('onwebchat_wc_sync_enabled', false)) {
202 return;
203 }
204
205 if (!$product || !is_object($product)) {
206 $product = wc_get_product($product_id);
207 }
208 if (!$product) {
209 return;
210 }
211
212 // Only products that would actually be synced: published, not excluded, in scope.
213 // Out-of-scope / excluded products are not in onWebChat, so there is nothing to update.
214 if ($product->get_status() !== 'publish') {
215 return;
216 }
217 if ($this->is_product_excluded($product) || !$this->product_in_scope($product)) {
218 return;
219 }
220
221 $in_stock = ($status !== 'outofstock');
222 $this->send_product_stock($product_id, $in_stock);
223 }
224
225 /**
226 * Hook: Product trash (when moved to trash)
227 */
228 public function on_product_trash($post_id) {
229 // Check if it's a product
230 if (get_post_type($post_id) !== 'product') {
231 return;
232 }
233
234 if (!get_option('onwebchat_wc_sync_enabled', false)) {
235 return;
236 }
237
238 // Send delete request when product is trashed
239 $this->send_product_delete($post_id);
240 }
241
242 /**
243 * Hook: Product permanent delete
244 */
245 public function on_product_delete($post_id, $post) {
246 if ($post->post_type !== 'product') {
247 return;
248 }
249
250 if (!get_option('onwebchat_wc_sync_enabled', false)) {
251 return;
252 }
253
254 // Send delete request when product is permanently deleted
255 $this->send_product_delete($post_id);
256 }
257
258 /**
259 * Check if product is in excluded categories
260 */
261 private function is_product_excluded($product) {
262 $excluded_categories = get_option('onwebchat_wc_excluded_categories', array());
263 if (empty($excluded_categories)) {
264 return false;
265 }
266
267 $product_categories = $product->get_category_ids();
268 foreach ($product_categories as $cat_id) {
269 if (in_array($cat_id, $excluded_categories)) {
270 return true;
271 }
272 }
273
274 return false;
275 }
276
277 /**
278 * Get the saved sync scope as an array of product_cat term IDs.
279 * An empty array means the whole catalogue is in scope.
280 */
281 private function get_sync_scope() {
282 $raw = (string) get_option('onwebchat_wc_sync_categories', '');
283 if ($raw === '') {
284 return array();
285 }
286
287 $ids = array();
288 foreach (explode(',', $raw) as $id) {
289 $id = (int) trim($id);
290 if ($id > 0) {
291 $ids[$id] = $id; // de-duplicate
292 }
293 }
294
295 return array_values($ids);
296 }
297
298 /**
299 * Persist the sync scope. Pass an empty array to clear it (whole catalogue).
300 */
301 private function save_sync_scope($category_ids) {
302 $clean = array();
303 foreach ((array) $category_ids as $id) {
304 $id = (int) $id;
305 if ($id > 0) {
306 $clean[$id] = $id;
307 }
308 }
309
310 update_option('onwebchat_wc_sync_categories', implode(',', array_values($clean)));
311 }
312
313 /**
314 * Is the product within the current sync scope?
315 * No scope set means everything is in scope. The picker only offers
316 * top-level categories, and selecting one covers its whole subtree, so a
317 * product is in scope when any of its categories is a scoped category OR a
318 * descendant of one. This mirrors the bulk sync tax query
319 * (include_children = true).
320 */
321 private function product_in_scope($product) {
322 $scope = $this->get_sync_scope();
323 if (empty($scope)) {
324 return true;
325 }
326
327 foreach ($product->get_category_ids() as $cat_id) {
328 $cat_id = (int) $cat_id;
329 if (in_array($cat_id, $scope, true)) {
330 return true;
331 }
332 // Walk up to the root: a scoped ancestor puts the product in scope.
333 foreach (get_ancestors($cat_id, 'product_cat', 'taxonomy') as $ancestor_id) {
334 if (in_array((int) $ancestor_id, $scope, true)) {
335 return true;
336 }
337 }
338 }
339
340 return false;
341 }
342
343 /**
344 * Count published products within the given scope (empty = whole catalogue).
345 * Uses found_posts so we do not load every ID into memory.
346 */
347 private function count_products_in_scope($category_ids) {
348 $args = array(
349 'post_type' => 'product',
350 'post_status' => 'publish',
351 'posts_per_page' => 1,
352 'fields' => 'ids',
353 'no_found_rows' => false,
354 );
355
356 if (!empty($category_ids)) {
357 $args['tax_query'] = array(array(
358 'taxonomy' => 'product_cat',
359 'field' => 'term_id',
360 'terms' => array_map('intval', $category_ids),
361 'include_children' => true,
362 ));
363 }
364
365 $query = new WP_Query($args);
366 return (int) $query->found_posts;
367 }
368
369 /**
370 * Prepare product data for sync
371 */
372 private function prepare_product_data($product) {
373 $sync_mode = get_option('onwebchat_wc_sync_mode', 'short_fallback_full');
374
375 // Get description based on sync mode
376 $description = '';
377 $short_description = strip_tags($product->get_short_description());
378
379 if ($sync_mode === 'short_only') {
380 $description = $short_description;
381 } else if ($sync_mode === 'short_fallback_full') {
382 if (!empty($short_description)) {
383 $description = $short_description;
384 } else {
385 // Fallback to the first 200 words of the full description.
386 // Split with a Unicode-aware regex: str_word_count() does not
387 // recognize non-latin (e.g. Greek) words, so the old word cut
388 // was unreliable on multibyte text.
389 $full_description = strip_tags($product->get_description());
390 $words = preg_split('/\s+/u', trim($full_description), -1, PREG_SPLIT_NO_EMPTY);
391
392 if (is_array($words) && count($words) > 200) {
393 $description = implode(' ', array_slice($words, 0, 200)) . '...';
394 } else {
395 $description = $full_description;
396 }
397 }
398 }
399
400 // Enforce max length by characters, not bytes: a byte-based substr()
401 // can cut a multibyte UTF-8 character (e.g. Greek text) in half.
402 if (mb_strlen($description, 'UTF-8') > $this->max_description_length) {
403 $description = mb_substr($description, 0, $this->max_description_length, 'UTF-8') . '...';
404 }
405
406 $sku = $product->get_sku();
407 $categories = $this->get_product_category_names($product);
408 $url = get_permalink($product->get_id());
409
410 // Structured fields. The server rebuilds the embedding text from these,
411 // so there is no need to send a pre-formatted "text" blob.
412 $data = array(
413 'product_id' => $product->get_id(),
414 'name' => $product->get_name(),
415 'short_description' => trim($description),
416 'url' => $url,
417 'sku' => $sku,
418 'categories' => $categories,
419 'currency' => get_woocommerce_currency(),
420 );
421
422 // Price (always sent). Variable products carry a min/max range.
423 $data['price'] = $product->get_price();
424
425 if ($product->is_type('variable')) {
426 // Raw min/max prices, consistent with get_price() used for simple products.
427 $data['price_min'] = $product->get_variation_price('min', false);
428 $data['price_max'] = $product->get_variation_price('max', false);
429 } else {
430 $regular_price = $product->get_regular_price();
431 if ($regular_price !== '') {
432 $data['regular_price'] = $regular_price;
433 }
434 // Only advertise a sale price while the sale is actually active.
435 if ($product->is_on_sale()) {
436 $data['sale_price'] = $product->get_sale_price();
437 }
438 }
439
440 // Stock availability
441 $data['in_stock'] = $product->is_in_stock();
442 if ($product->managing_stock()) {
443 $stock_qty = $product->get_stock_quantity();
444 if ($stock_qty !== null) {
445 $data['quantity'] = (int) $stock_qty;
446 }
447 }
448
449 // Brand (renders as "Brand:" on the server). Detect the common brand taxonomies.
450 $brand = $this->get_product_brand($product);
451 if (!empty($brand)) {
452 $data['manufacturer'] = $brand;
453 }
454
455 // Variation attributes / options (Color, Size, ...)
456 $attributes = $this->get_product_attributes($product);
457 if (!empty($attributes)) {
458 $data['attributes'] = $attributes;
459 }
460
461 // Tags
462 $tags = $this->get_product_tags($product);
463 if (!empty($tags)) {
464 $data['tags'] = $tags;
465 }
466
467 // Average rating and review count
468 $rating = (float) $product->get_average_rating();
469 if ($rating > 0) {
470 $data['rating'] = $rating;
471 $data['review_count'] = (int) $product->get_review_count();
472 }
473
474 return $data;
475 }
476
477 /**
478 * Get product category names
479 */
480 private function get_product_category_names($product) {
481 $categories = array();
482 $category_ids = $product->get_category_ids();
483
484 foreach ($category_ids as $cat_id) {
485 $term = get_term($cat_id, 'product_cat');
486 if ($term && !is_wp_error($term)) {
487 $categories[] = $term->name;
488 }
489 }
490
491 return $categories;
492 }
493
494 /**
495 * Get the product's brand name from whichever brand taxonomy is available.
496 * Supports WooCommerce 9.6+ native brands and the common brand plugins.
497 */
498 private function get_product_brand($product) {
499 $taxonomies = array('product_brand', 'pwb-brand', 'yith_product_brand', 'pa_brand');
500
501 foreach ($taxonomies as $taxonomy) {
502 if (!taxonomy_exists($taxonomy)) {
503 continue;
504 }
505
506 $terms = wp_get_post_terms($product->get_id(), $taxonomy, array('fields' => 'names'));
507 if (!is_wp_error($terms) && !empty($terms)) {
508 return $terms[0];
509 }
510 }
511
512 return '';
513 }
514
515 /**
516 * Get visible product attributes as an array of { name, options }.
517 * Works for both custom and taxonomy-based (global) attributes.
518 */
519 private function get_product_attributes($product) {
520 $result = array();
521
522 foreach ($product->get_attributes() as $attribute) {
523 if (!is_object($attribute) || !$attribute->get_visible()) {
524 continue;
525 }
526
527 $name = wc_attribute_label($attribute->get_name());
528
529 if ($attribute->is_taxonomy()) {
530 $options = wc_get_product_terms($product->get_id(), $attribute->get_name(), array('fields' => 'names'));
531 } else {
532 $options = $attribute->get_options();
533 }
534
535 $options = array_values(array_filter(array_map('trim', (array) $options)));
536
537 if (!empty($name) && !empty($options)) {
538 $result[] = array(
539 'name' => $name,
540 'options' => $options,
541 );
542 }
543 }
544
545 return $result;
546 }
547
548 /**
549 * Get product tag names.
550 */
551 private function get_product_tags($product) {
552 $tags = wp_get_post_terms($product->get_id(), 'product_tag', array('fields' => 'names'));
553
554 if (is_wp_error($tags) || empty($tags)) {
555 return array();
556 }
557
558 return $tags;
559 }
560
561 /**
562 * Send batch of products to API (optimized)
563 * @param array $products - Array of product data
564 * @param int $sync_total - Total products in the current bulk run (0 = not a bulk run)
565 * @param int $sync_done - Products pushed so far in the run, including this batch
566 *
567 * When $sync_total is > 0 the batch is tagged with the run total/progress so
568 * the server can relay a live progress bar to open dashboards.
569 */
570 private function send_product_batch($products, $sync_total = 0, $sync_done = 0) {
571 $chatId = get_option('onwebchat_plugin_option');
572 $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : '';
573
574 if (empty($chatId)) {
575 error_log('onWebChat WooCommerce Sync - Chat ID not configured');
576 return false;
577 }
578
579 // Ensure we have a secret (must be obtained via authenticated connection in WooCommerce settings)
580 $secret = $this->get_secret(false);
581 if (empty($secret)) {
582 error_log('onWebChat WooCommerce Sync - No secret configured. Please connect WooCommerce in the plugin settings.');
583 return array(
584 'success' => false,
585 'error' => 'No secret configured. Please connect WooCommerce integration.',
586 'needs_reconnect' => true
587 );
588 }
589
590 // Extract key part (before first slash if present)
591 $chatIdKey = explode('/', $chatId)[0];
592
593 $endpoint = $this->get_api_endpoint() . '/product/batch';
594 $payload = array(
595 'site_id' => $chatIdKey,
596 'site_url' => get_site_url(),
597 'products' => $products
598 );
599
600 // Tag bulk-run batches with the run total + progress so the server can
601 // relay a live progress bar to open dashboards. Omitted for incremental
602 // single-product syncs (which pass no total).
603 if ((int) $sync_total > 0) {
604 $payload['sync_total'] = (int) $sync_total;
605 $payload['sync_done'] = min((int) $sync_done, (int) $sync_total);
606 }
607
608 // Generate authentication headers (same as send_authenticated_request)
609 $timestamp = time();
610 $nonce = base64_encode(random_bytes(16));
611 $body_json = wp_json_encode($payload);
612
613 // Create signature: HMAC_SHA256(secret, site_id.timestamp.nonce.body)
614 $message = $chatIdKey . '.' . $timestamp . '.' . $nonce . '.' . $body_json;
615 $signature = hash_hmac('sha256', $message, $secret);
616
617 // Send request
618 $request_args = array(
619 'method' => 'POST',
620 'timeout' => 30, // Longer timeout for batch operations
621 'headers' => array(
622 'Content-Type' => 'application/json',
623 'X-OWC-SiteId' => $chatIdKey,
624 'X-OWC-Timestamp' => $timestamp,
625 'X-OWC-Nonce' => $nonce,
626 'X-OWC-Signature' => $signature,
627 ),
628 'body' => $body_json,
629 );
630
631 // Disable SSL verification for local dev server
632 if ($this->use_testing_mode) {
633 $request_args['sslverify'] = false;
634 }
635
636 $response = wp_remote_post($endpoint, $request_args);
637
638 if (is_wp_error($response)) {
639 error_log('onWebChat WooCommerce Sync - Batch sync error: ' . $response->get_error_message());
640 return array(
641 'success' => false,
642 'error' => 'Network error: ' . $response->get_error_message()
643 );
644 }
645
646 $response_code = wp_remote_retrieve_response_code($response);
647 $body = json_decode(wp_remote_retrieve_body($response), true);
648
649 // If authentication failed (401), the secret is invalid or out of sync
650 if ($response_code === 401) {
651 // Clear the invalid secret
652 delete_option('onwebchat_wc_sync_secret');
653
654 error_log('onWebChat WooCommerce Sync - Authentication failed (401): Secret is invalid or out of sync. Please reconnect WooCommerce in the plugin settings.');
655
656 // Store admin notice about authentication failure
657 set_transient('onwebchat_wc_auth_error', 'Authentication failed. Your WooCommerce secret is invalid or out of sync. Please reconnect WooCommerce integration in the plugin settings.', 3600);
658
659 return array(
660 'success' => false,
661 'error' => 'Authentication failed. Secret is invalid. Please reconnect WooCommerce integration.',
662 'needs_reconnect' => true
663 );
664 }
665
666 if ($response_code === 200 && isset($body['success']) && $body['success']) {
667 // Clear any previous auth errors on success
668 delete_transient('onwebchat_wc_auth_error');
669 return $body; // Return full response with stats
670 }
671
672 $error_msg = 'Batch sync failed';
673 if (isset($body['error'])) {
674 $error_msg .= ': ' . $body['error'];
675 }
676 error_log('onWebChat WooCommerce Sync - ' . $error_msg . ' - Response: ' . print_r($body, true));
677
678 return array(
679 'success' => false,
680 'error' => $error_msg
681 );
682 }
683
684 /**
685 * Send sync completion notification to server (triggers Angular modal)
686 */
687 private function send_sync_completion_notification($total_stats) {
688 $chatId = get_option('onwebchat_plugin_option');
689 $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : '';
690
691 if (empty($chatId)) {
692 error_log('onWebChat WooCommerce Sync - Chat ID not configured');
693 return false;
694 }
695
696 $secret = $this->get_secret(false);
697 if (empty($secret)) {
698 error_log('onWebChat WooCommerce Sync - No secret configured');
699 return false;
700 }
701
702 $chatIdKey = explode('/', $chatId)[0];
703
704 $endpoint = $this->get_api_endpoint() . '/product/sync-complete';
705 $payload = array(
706 'site_id' => $chatIdKey,
707 'stats' => $total_stats
708 );
709
710 // Generate authentication headers
711 $timestamp = time();
712 $nonce = base64_encode(random_bytes(16));
713 $body_json = wp_json_encode($payload);
714 $message = $chatIdKey . '.' . $timestamp . '.' . $nonce . '.' . $body_json;
715 $signature = hash_hmac('sha256', $message, $secret);
716
717 $request_args = array(
718 'method' => 'POST',
719 'timeout' => 10,
720 'headers' => array(
721 'Content-Type' => 'application/json',
722 'X-OWC-SiteId' => $chatIdKey,
723 'X-OWC-Timestamp' => $timestamp,
724 'X-OWC-Nonce' => $nonce,
725 'X-OWC-Signature' => $signature,
726 ),
727 'body' => $body_json,
728 );
729
730 if ($this->use_testing_mode) {
731 $request_args['sslverify'] = false;
732 }
733
734 $response = wp_remote_post($endpoint, $request_args);
735
736 if (is_wp_error($response)) {
737 error_log('onWebChat WooCommerce Sync - Completion notification failed: ' . $response->get_error_message());
738 return false;
739 }
740
741 $response_code = wp_remote_retrieve_response_code($response);
742
743 // If authentication failed (401), the secret is invalid or out of sync
744 if ($response_code === 401) {
745 delete_option('onwebchat_wc_sync_secret');
746 error_log('onWebChat WooCommerce Sync - Completion notification authentication failed (401): Secret is invalid. Please reconnect WooCommerce.');
747 set_transient('onwebchat_wc_auth_error', 'Authentication failed. Your WooCommerce secret is invalid or out of sync. Please reconnect WooCommerce integration in the plugin settings.', 3600);
748 return false;
749 }
750
751 if ($response_code >= 200 && $response_code < 300) {
752 error_log('onWebChat WooCommerce Sync - Completion notification sent successfully');
753 return true;
754 }
755
756 error_log('onWebChat WooCommerce Sync - Completion notification failed with code: ' . $response_code);
757 return false;
758 }
759
760 /**
761 * Send product upsert to server (uses batch endpoint with single product)
762 */
763 private function send_product_upsert($product_data, $product_id) {
764 // Use batch endpoint with single product
765 $result = $this->send_product_batch(array($product_data));
766
767 if ($result && isset($result['success']) && $result['success']) {
768 // Clear any previous errors
769 delete_post_meta($product_id, '_onwebchat_sync_error');
770 update_post_meta($product_id, '_onwebchat_last_sync', current_time('timestamp'));
771 return true;
772 } else {
773 // Log error if batch failed
774 $error_message = 'Failed to sync product';
775 if ($result && isset($result['error'])) {
776 $error_message = $result['error'];
777 } elseif (!$result) {
778 $error_message = 'Batch sync request failed';
779 }
780 $this->log_error($product_id, $error_message);
781 return false;
782 }
783 }
784
785 /**
786 * Send product delete to server
787 */
788 private function send_product_delete($product_id) {
789 $chatId = get_option('onwebchat_plugin_option');
790 $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : '';
791
792 if (empty($chatId)) {
793 return false;
794 }
795
796 // Extract key part (before first slash if present)
797 $chatIdKey = explode('/', $chatId)[0];
798
799 $endpoint = $this->get_api_endpoint() . '/product/delete';
800 $payload = array(
801 'site_id' => $chatIdKey, // Use key part only
802 'site_url' => get_site_url(),
803 'product_id' => $product_id
804 );
805
806 $this->send_authenticated_request($endpoint, $payload, $product_id);
807 }
808
809 /**
810 * Send a lightweight availability update to onWebChat (no re-embed on the server).
811 */
812 private function send_product_stock($product_id, $in_stock) {
813 $chatId = get_option('onwebchat_plugin_option');
814 $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : '';
815
816 if (empty($chatId)) {
817 return false;
818 }
819
820 // Extract key part (before first slash if present)
821 $chatIdKey = explode('/', $chatId)[0];
822
823 $endpoint = $this->get_api_endpoint() . '/product/stock';
824 $payload = array(
825 'site_id' => $chatIdKey, // Use key part only
826 'site_url' => get_site_url(),
827 'product_id' => $product_id,
828 'in_stock' => (bool) $in_stock,
829 );
830
831 $this->send_authenticated_request($endpoint, $payload, $product_id);
832 }
833
834 /**
835 * Get cached secret from local options
836 * @param {bool} force_refresh - Not used (kept for compatibility), secret must be obtained via authenticated request
837 */
838 private function get_secret($force_refresh = false) {
839 // Always return cached secret - never fetch automatically
840 // Secret must be obtained via authenticated request in WooCommerce settings
841 $secret = get_option('onwebchat_wc_sync_secret');
842
843 if (!empty($secret)) {
844 return $secret;
845 }
846
847 // No secret available - user must authenticate in WooCommerce settings
848 return null;
849 }
850
851 /**
852 * Request secret from server with authentication
853 * This is called when user clicks "Connect WooCommerce" with their password
854 *
855 * @param {string} email - User's onWebChat email
856 * @param {string} password - User's onWebChat password
857 * @return {array} - ['success' => bool, 'secret' => string, 'error' => string]
858 */
859 public function request_secret_with_auth($email, $password) {
860 $chatId = get_option('onwebchat_plugin_option');
861 $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : '';
862
863 if (empty($chatId)) {
864 return array('success' => false, 'error' => 'No Chat ID configured');
865 }
866
867 // Extract key part (before first slash if present)
868 $key = explode('/', $chatId)[0];
869
870 // Request secret from server with authentication
871 $secret_endpoint = $this->get_api_endpoint() . '/secret';
872
873 $response = wp_remote_post($secret_endpoint, array(
874 'timeout' => 15,
875 'sslverify' => !$this->use_testing_mode,
876 'headers' => array(
877 'Content-Type' => 'application/json',
878 ),
879 'body' => wp_json_encode(array(
880 'email' => $email,
881 'password' => $password,
882 'site_key' => $key,
883 'version' => defined('ONWEBCHAT_PLUGIN_VERSION') ? ONWEBCHAT_PLUGIN_VERSION : '',
884 )),
885 ));
886
887 if (is_wp_error($response)) {
888 $error_message = $response->get_error_message();
889 error_log('onWebChat WooCommerce Sync - Connection error: ' . $error_message);
890 return array('success' => false, 'error' => 'Connection failed: ' . $error_message);
891 }
892
893 $status_code = wp_remote_retrieve_response_code($response);
894 $response_body_raw = wp_remote_retrieve_body($response);
895 $body = json_decode($response_body_raw, true);
896
897 // Log response for debugging. Redact the secret so it never lands in server/debug logs
898 // (a successful response body contains the HMAC secret).
899 $log_body = preg_replace('/("secret"\s*:\s*")[^"]*(")/i', '$1[REDACTED]$2', (string) $response_body_raw);
900 error_log('onWebChat WooCommerce Sync - API response: Status=' . $status_code . ', Body=' . substr($log_body, 0, 500));
901
902 // Handle specific HTTP status codes
903 if ($status_code === 401) {
904 return array('success' => false, 'error' => 'Invalid email or password');
905 }
906
907 if ($status_code === 403) {
908 return array('success' => false, 'error' => 'You do not have access to this site');
909 }
910
911 // Success case
912 if ($status_code >= 200 && $status_code < 300 && isset($body['success']) && $body['success']) {
913 $secret = isset($body['secret']) ? $body['secret'] : null;
914 if (empty($secret)) {
915 error_log('onWebChat WooCommerce Sync - Success response but no secret provided');
916 return array('success' => false, 'error' => 'Server response missing secret');
917 }
918 update_option('onwebchat_wc_sync_secret', $secret);
919
920 // Enable AI order-status lookup by default on connect and register our
921 // callback URL with onWebChat (best effort; the merchant can toggle it off).
922 update_option('onwebchat_wc_order_lookup_enabled', true);
923 global $onwebchat_wc_orders;
924 if (isset($onwebchat_wc_orders) && is_object($onwebchat_wc_orders)) {
925 $onwebchat_wc_orders->push_order_lookup_config(true);
926 }
927
928 return array('success' => true, 'secret' => $secret);
929 }
930
931 // Extract error message from various possible response formats
932 $error_message = 'Unknown error';
933
934 if (is_array($body)) {
935 // Try different possible error fields
936 if (isset($body['error'])) {
937 $error_message = is_string($body['error']) ? $body['error'] : json_encode($body['error']);
938 } elseif (isset($body['message'])) {
939 $error_message = is_string($body['message']) ? $body['message'] : json_encode($body['message']);
940 } elseif (isset($body['errors']) && is_array($body['errors'])) {
941 $error_message = implode(', ', $body['errors']);
942 }
943 } elseif (!empty($response_body_raw)) {
944 // If body is not JSON or empty, use raw response (truncated)
945 $error_message = 'Server returned: ' . substr(strip_tags($response_body_raw), 0, 200);
946 }
947
948 // Defense in depth: the remote error is shown in the admin UI, so strip any markup here too
949 // (the client also renders it as text). Prevents a malicious/MITM'd API response carrying HTML.
950 $error_message = sanitize_text_field($error_message);
951
952 // Include status code in error message if not already included
953 if ($status_code && strpos($error_message, 'HTTP') === false) {
954 $error_message = 'HTTP ' . $status_code . ': ' . $error_message;
955 }
956
957 error_log('onWebChat WooCommerce Sync - Connection failed: ' . $error_message);
958 return array('success' => false, 'error' => $error_message);
959 }
960
961 /**
962 * Send authenticated request with HMAC signature
963 */
964 private function send_authenticated_request($endpoint, $payload, $product_id = null) {
965 // Get cached secret (must be obtained via authenticated connection in WooCommerce settings)
966 $secret = $this->get_secret(false);
967
968 if (empty($secret)) {
969 if ($product_id) {
970 $this->log_error($product_id, 'No secret configured. Please connect WooCommerce in the plugin settings.');
971 }
972 return array('success' => false, 'error' => 'Secret not available. Please connect WooCommerce in plugin settings.');
973 }
974
975 $chatId = get_option('onwebchat_plugin_option');
976 $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : '';
977
978 // Extract key part (before first slash if present) for consistency with server
979 // e.g., "5f02c87b60726a4663b25463a424a034/1/1" -> "5f02c87b60726a4663b25463a424a034"
980 $chatIdKey = explode('/', $chatId)[0];
981
982 // Generate authentication headers
983 $timestamp = time();
984 $nonce = base64_encode(random_bytes(16));
985 $body_json = wp_json_encode($payload);
986
987 // Create signature: HMAC_SHA256(secret, site_id.timestamp.nonce.body)
988 // IMPORTANT: Use the key part (not full chat_id) to match server-side verification
989 $message = $chatIdKey . '.' . $timestamp . '.' . $nonce . '.' . $body_json;
990 $signature = hash_hmac('sha256', $message, $secret);
991
992 // Send request
993 $request_args = array(
994 'method' => 'POST',
995 'timeout' => 10,
996 'headers' => array(
997 'Content-Type' => 'application/json',
998 'X-OWC-SiteId' => $chatIdKey, // Use key part only
999 'X-OWC-Timestamp' => $timestamp,
1000 'X-OWC-Nonce' => $nonce,
1001 'X-OWC-Signature' => $signature,
1002 ),
1003 'body' => $body_json,
1004 );
1005
1006 // Disable SSL verification for local dev server
1007 if ($this->use_testing_mode) {
1008 $request_args['sslverify'] = false;
1009 }
1010
1011 $response = wp_remote_post($endpoint, $request_args);
1012
1013 // Handle response
1014 if (is_wp_error($response)) {
1015 $error_message = $response->get_error_message();
1016 if ($product_id) {
1017 $this->log_error($product_id, $error_message);
1018 }
1019 return array('success' => false, 'error' => $error_message);
1020 }
1021
1022 $status_code = wp_remote_retrieve_response_code($response);
1023
1024 // Success
1025 if ($status_code >= 200 && $status_code < 300) {
1026 return array('success' => true);
1027 }
1028
1029 // If authentication failed (401), the secret may be invalid
1030 if ($status_code === 401) {
1031 // Clear the invalid secret
1032 delete_option('onwebchat_wc_sync_secret');
1033
1034 if ($product_id) {
1035 $this->log_error($product_id, 'Authentication failed. Please reconnect WooCommerce in the plugin settings.');
1036 }
1037 return array('success' => false, 'error' => 'Authentication failed. Please reconnect WooCommerce in plugin settings.');
1038 }
1039
1040 // Error
1041 $error_body = wp_remote_retrieve_body($response);
1042 if ($product_id) {
1043 $this->log_error($product_id, "HTTP $status_code: $error_body");
1044 }
1045
1046 return array('success' => false, 'error' => "HTTP $status_code", 'status_code' => $status_code);
1047 }
1048
1049 /**
1050 * Log sync error to product meta
1051 */
1052 private function log_error($product_id, $error_message) {
1053 update_post_meta($product_id, '_onwebchat_sync_error', array(
1054 'message' => $error_message,
1055 'timestamp' => current_time('timestamp')
1056 ));
1057 }
1058
1059 /**
1060 * AJAX: Start bulk sync
1061 */
1062 public function ajax_sync_existing_products() {
1063 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
1064
1065 if (!current_user_can('manage_options')) {
1066 wp_send_json_error('Insufficient permissions');
1067 }
1068
1069 // Check if sync is already in progress
1070 if (get_option('onwebchat_wc_bulk_in_progress', false)) {
1071 wp_send_json_error('A sync is already in progress. Please wait for it to complete.');
1072 }
1073
1074 // Rate limiting: prevent syncing more than once every 5 minutes
1075 $last_sync_time = get_option('onwebchat_wc_last_sync_start', 0);
1076 $cooldown_period = 5 * 60; // 5 minutes in seconds //also in the file woocommerce.php // 5 * 60
1077 $time_since_last_sync = time() - $last_sync_time;
1078
1079 if ($time_since_last_sync < $cooldown_period) {
1080 $wait_time = $cooldown_period - $time_since_last_sync;
1081 $minutes = ceil($wait_time / 60);
1082 wp_send_json_error('Please wait ' . $minutes . ' minute(s) before syncing again.');
1083 }
1084
1085 // Read the chosen sync scope (product_cat term IDs). Empty = whole catalogue.
1086 $category_ids = array();
1087 if (isset($_POST['categories']) && $_POST['categories'] !== '') {
1088 foreach (explode(',', sanitize_text_field(wp_unslash($_POST['categories']))) as $id) {
1089 $id = (int) trim($id);
1090 if ($id > 0) {
1091 $category_ids[] = $id;
1092 }
1093 }
1094 }
1095
1096 // Above the hard cap a category selection is required: refuse an
1097 // unrestricted "sync all" when the catalogue is larger than the cap.
1098 $published_total = $this->count_products_in_scope(array());
1099 if (empty($category_ids) && $published_total > self::MAX_SYNC_PRODUCTS) {
1100 wp_send_json_error(sprintf(
1101 'Your store has %s products, which is more than can be synced at once (%s). Please select specific categories to sync.',
1102 number_format_i18n($published_total),
1103 number_format_i18n(self::MAX_SYNC_PRODUCTS)
1104 ));
1105 }
1106
1107 // Remember the merchant's choice so ongoing auto-sync stays within it:
1108 // selected categories become the sync scope; an unrestricted "sync all"
1109 // clears the scope (the whole catalogue is in scope again).
1110 $this->save_sync_scope($category_ids);
1111
1112 // Store the current sync start time
1113 update_option('onwebchat_wc_last_sync_start', time());
1114
1115 // Reset bulk sync progress
1116 update_option('onwebchat_wc_bulk_page', 0);
1117 update_option('onwebchat_wc_bulk_done', 0);
1118
1119 // Count total products within scope, capped at the hard limit.
1120 $total = $this->count_products_in_scope($category_ids);
1121 if ($total > self::MAX_SYNC_PRODUCTS) {
1122 $total = self::MAX_SYNC_PRODUCTS;
1123 }
1124
1125 update_option('onwebchat_wc_bulk_total', $total);
1126 update_option('onwebchat_wc_bulk_done', 0); // Initialize progress counter
1127 update_option('onwebchat_wc_bulk_in_progress', true);
1128
1129 // Process sync directly instead of using unreliable WP Cron
1130 $sync_result = $this->do_bulk_sync_all($category_ids);
1131
1132 wp_send_json_success(array(
1133 'message' => 'Bulk sync completed',
1134 'total' => $total,
1135 'result' => $sync_result
1136 ));
1137 }
1138
1139 /**
1140 * AJAX: begin a client-driven bulk sync.
1141 *
1142 * Sets up the progress state and returns the total number of products to
1143 * sync. The browser then calls ajax_sync_next_batch() repeatedly (one page
1144 * per request) until the run reports it is complete. Because each request is
1145 * short, the whole sync no longer rides on a single request that outran the
1146 * web server timeout and reported a false failure while products kept
1147 * syncing.
1148 */
1149 public function ajax_start_bulk_sync() {
1150 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
1151
1152 if (!current_user_can('manage_options')) {
1153 wp_send_json_error('Insufficient permissions');
1154 }
1155
1156 if (get_option('onwebchat_wc_bulk_in_progress', false)) {
1157 wp_send_json_error('A sync is already in progress. Please wait for it to complete.');
1158 }
1159
1160 // Read the chosen sync scope (product_cat term IDs). Empty = whole catalogue.
1161 $category_ids = array();
1162 if (isset($_POST['categories']) && $_POST['categories'] !== '') {
1163 foreach (explode(',', sanitize_text_field(wp_unslash($_POST['categories']))) as $id) {
1164 $id = (int) trim($id);
1165 if ($id > 0) {
1166 $category_ids[] = $id;
1167 }
1168 }
1169 }
1170
1171 // Above the hard cap a category selection is required: refuse an
1172 // unrestricted "sync all" when the catalogue is larger than the cap.
1173 $published_total = $this->count_products_in_scope(array());
1174 if (empty($category_ids) && $published_total > self::MAX_SYNC_PRODUCTS) {
1175 wp_send_json_error(sprintf(
1176 'Your store has %s products, which is more than can be synced at once (%s). Please select specific categories to sync.',
1177 number_format_i18n($published_total),
1178 number_format_i18n(self::MAX_SYNC_PRODUCTS)
1179 ));
1180 }
1181
1182 // Remember the merchant's choice so ongoing auto-sync stays within it:
1183 // selected categories become the sync scope; an unrestricted "sync all"
1184 // clears the scope (the whole catalogue is in scope again).
1185 $this->save_sync_scope($category_ids);
1186
1187 // Count total products within scope, capped at the hard limit.
1188 $total = $this->count_products_in_scope($category_ids);
1189 if ($total > self::MAX_SYNC_PRODUCTS) {
1190 $total = self::MAX_SYNC_PRODUCTS;
1191 }
1192
1193 // Reset progress state for a fresh run.
1194 update_option('onwebchat_wc_last_sync_start', time());
1195 update_option('onwebchat_wc_bulk_page', 0);
1196 update_option('onwebchat_wc_bulk_done', 0);
1197 update_option('onwebchat_wc_bulk_total', $total);
1198 update_option('onwebchat_wc_bulk_stats', array('created' => 0, 'updated' => 0, 'skipped' => 0, 'errors' => 0));
1199 // Only enter the "in progress" state when there is actually something to
1200 // sync, so a 0-product start (empty scope) can't leave the store stuck at
1201 // "a sync is already in progress".
1202 update_option('onwebchat_wc_bulk_in_progress', $total > 0);
1203
1204 wp_send_json_success(array('total' => $total));
1205 }
1206
1207 /**
1208 * AJAX: process the next page of the in-progress bulk sync and report progress.
1209 */
1210 public function ajax_sync_next_batch() {
1211 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
1212
1213 if (!current_user_can('manage_options')) {
1214 wp_send_json_error('Insufficient permissions');
1215 }
1216
1217 wp_send_json_success($this->sync_next_page());
1218 }
1219
1220 /**
1221 * Process exactly one page (batch_size products) of the in-progress bulk
1222 * sync, advancing the persisted progress. The browser calls this once per
1223 * request (via ajax_sync_next_batch) until it reports the run is complete,
1224 * so no single request has to stay open for the whole catalogue.
1225 *
1226 * Unlike do_bulk_sync_all()/the WP-Cron path this does NOT sleep and does
1227 * NOT schedule a follow-up cron event: the browser drives the loop. Stats
1228 * are accumulated in an option across pages so the completion notification
1229 * (which drives the dashboard notice) carries the full run totals.
1230 *
1231 * @return array Progress snapshot: in_progress, complete, done, total, stats.
1232 */
1233 private function sync_next_page() {
1234 $total = (int) get_option('onwebchat_wc_bulk_total', 0);
1235
1236 if (!get_option('onwebchat_wc_bulk_in_progress', false)) {
1237 return array(
1238 'in_progress' => false,
1239 'complete' => true,
1240 'done' => (int) get_option('onwebchat_wc_bulk_done', 0),
1241 'total' => $total,
1242 'stats' => $this->get_bulk_stats(),
1243 );
1244 }
1245
1246 $page = (int) get_option('onwebchat_wc_bulk_page', 0);
1247 $done = (int) get_option('onwebchat_wc_bulk_done', 0);
1248 $stats = $this->get_bulk_stats();
1249
1250 $args = array(
1251 'post_type' => 'product',
1252 'post_status' => 'publish',
1253 'posts_per_page' => $this->batch_size,
1254 'paged' => $page + 1,
1255 'orderby' => 'ID',
1256 'order' => 'ASC',
1257 );
1258
1259 // Restrict to the saved sync scope and its subcategories, consistent
1260 // with the per-product scope check and the counts shown in the picker.
1261 $scope = $this->get_sync_scope();
1262 if (!empty($scope)) {
1263 $args['tax_query'] = array(array(
1264 'taxonomy' => 'product_cat',
1265 'field' => 'term_id',
1266 'terms' => array_map('intval', $scope),
1267 'include_children' => true,
1268 ));
1269 }
1270
1271 $query = new WP_Query($args);
1272 $complete = false;
1273
1274 if ($query->have_posts()) {
1275 $products_batch = array();
1276 foreach ($query->posts as $post) {
1277 $product = wc_get_product($post->ID);
1278 if ($product && !$this->is_product_excluded($product)) {
1279 $products_batch[] = $this->prepare_product_data($product);
1280 }
1281 }
1282
1283 $batch_done = 0;
1284 if (!empty($products_batch)) {
1285 // Tag with run total + running progress so the dashboard bar advances.
1286 $result = $this->send_product_batch($products_batch, $total, $done + count($products_batch));
1287 if ($result && isset($result['stats'])) {
1288 $batch_done = (int) $result['stats']['created'] + (int) $result['stats']['updated'] + (int) $result['stats']['skipped'];
1289 $stats['created'] += (int) $result['stats']['created'];
1290 $stats['updated'] += (int) $result['stats']['updated'];
1291 $stats['skipped'] += (int) $result['stats']['skipped'];
1292 $stats['errors'] += (int) $result['stats']['errors'];
1293 } else {
1294 // Batch failed outright: count the products as errors so the
1295 // summary reflects reality rather than silently skipping them.
1296 $batch_done = count($products_batch);
1297 $stats['errors'] += count($products_batch);
1298 }
1299 }
1300
1301 $done += $batch_done;
1302 $page += 1;
1303
1304 update_option('onwebchat_wc_bulk_page', $page);
1305 update_option('onwebchat_wc_bulk_done', $done);
1306 update_option('onwebchat_wc_bulk_stats', $stats);
1307
1308 // Stop once we have covered the counted total or reached the hard cap.
1309 if ($done >= $total || ($page * $this->batch_size) >= self::MAX_SYNC_PRODUCTS) {
1310 $complete = true;
1311 }
1312 } else {
1313 // No more products in scope.
1314 $complete = true;
1315 }
1316
1317 wp_reset_postdata();
1318
1319 if ($complete) {
1320 $this->send_sync_completion_notification($stats);
1321 update_option('onwebchat_wc_bulk_in_progress', false);
1322 update_option('onwebchat_wc_last_bulk_sync', current_time('timestamp'));
1323 // Show 100% when the counted total was reached; otherwise leave the
1324 // real processed figure (e.g. an early empty page or the hard cap).
1325 if ($total > 0 && $done >= $total) {
1326 $done = $total;
1327 }
1328 update_option('onwebchat_wc_bulk_done', $done);
1329 }
1330
1331 return array(
1332 'in_progress' => !$complete,
1333 'complete' => $complete,
1334 'done' => $done,
1335 'total' => $total,
1336 'stats' => $stats,
1337 );
1338 }
1339
1340 /**
1341 * Read the accumulated bulk-sync stats option, normalised to the four keys.
1342 */
1343 private function get_bulk_stats() {
1344 $stats = get_option('onwebchat_wc_bulk_stats', array());
1345 if (!is_array($stats)) {
1346 $stats = array();
1347 }
1348 return array(
1349 'created' => isset($stats['created']) ? (int) $stats['created'] : 0,
1350 'updated' => isset($stats['updated']) ? (int) $stats['updated'] : 0,
1351 'skipped' => isset($stats['skipped']) ? (int) $stats['skipped'] : 0,
1352 'errors' => isset($stats['errors']) ? (int) $stats['errors'] : 0,
1353 );
1354 }
1355
1356 /**
1357 * Process all products in bulk sync directly (not via cron).
1358 *
1359 * @param array $category_ids Sync scope (product_cat term IDs). Empty = whole catalogue.
1360 */
1361 private function do_bulk_sync_all($category_ids = array()) {
1362 $total = get_option('onwebchat_wc_bulk_total', 0);
1363 $page = 0;
1364 $total_done = 0;
1365 $considered = 0; // products fetched so far, used to enforce the hard cap
1366 $all_stats = array('created' => 0, 'updated' => 0, 'skipped' => 0, 'errors' => 0);
1367 $max = self::MAX_SYNC_PRODUCTS;
1368
1369 // Process all products in batches
1370 while (true) {
1371 $args = array(
1372 'post_type' => 'product',
1373 'post_status' => 'publish',
1374 'posts_per_page' => $this->batch_size,
1375 'paged' => $page + 1,
1376 'orderby' => 'ID',
1377 'order' => 'ASC',
1378 );
1379
1380 // Restrict to the chosen top-level categories and their subtrees, to
1381 // match the per-product scope check and the counts shown in the picker.
1382 if (!empty($category_ids)) {
1383 $args['tax_query'] = array(array(
1384 'taxonomy' => 'product_cat',
1385 'field' => 'term_id',
1386 'terms' => array_map('intval', $category_ids),
1387 'include_children' => true,
1388 ));
1389 }
1390
1391 $query = new WP_Query($args);
1392
1393 if (!$query->have_posts()) {
1394 break;
1395 }
1396
1397 // Collect products in this batch, honoring the hard cap.
1398 $products_batch = array();
1399 $reached_cap = false;
1400 foreach ($query->posts as $post) {
1401 if ($considered >= $max) {
1402 $reached_cap = true;
1403 break;
1404 }
1405 $considered++;
1406 $product = wc_get_product($post->ID);
1407 if ($product && !$this->is_product_excluded($product)) {
1408 $products_batch[] = $this->prepare_product_data($product);
1409 }
1410 }
1411
1412 // Send batch
1413 if (!empty($products_batch)) {
1414 $result = $this->send_product_batch($products_batch);
1415 if ($result && isset($result['stats'])) {
1416 $total_done += $result['stats']['created'] + $result['stats']['updated'] + $result['stats']['skipped'];
1417 $all_stats['created'] += $result['stats']['created'];
1418 $all_stats['updated'] += $result['stats']['updated'];
1419 $all_stats['skipped'] += $result['stats']['skipped'];
1420 $all_stats['errors'] += $result['stats']['errors'];
1421 } else {
1422 // Fallback
1423 $total_done += count($products_batch);
1424 }
1425
1426 // Update progress after each batch so AJAX polling can see it
1427 update_option('onwebchat_wc_bulk_done', $total_done);
1428
1429 // Wait 4 seconds before next batch
1430 sleep(4);
1431 }
1432
1433 wp_reset_postdata();
1434 $page++;
1435
1436 // Stop once the hard cap is reached.
1437 if ($reached_cap || $considered >= $max) {
1438 break;
1439 }
1440
1441 // Safety check - don't loop forever. The cap allows up to
1442 // MAX_SYNC_PRODUCTS / batch_size batches, so keep a generous guard.
1443 if ($page > ($max / $this->batch_size) + 10) {
1444 break;
1445 }
1446 }
1447
1448 // Send completion notification to Angular dashboard with total stats
1449 $this->send_sync_completion_notification($all_stats);
1450
1451 // Mark sync as complete
1452 update_option('onwebchat_wc_bulk_in_progress', false);
1453 update_option('onwebchat_wc_bulk_done', $total_done);
1454 update_option('onwebchat_wc_last_bulk_sync', current_time('timestamp'));
1455
1456 return array(
1457 'done' => $total_done,
1458 'total' => $total,
1459 'stats' => $all_stats
1460 );
1461 }
1462
1463 /**
1464 * Process bulk sync batch (via WP Cron) - Uses batch API endpoint
1465 */
1466 public function process_bulk_sync_batch() {
1467 error_log('onWebChat WooCommerce Sync - process_bulk_sync_batch called');
1468
1469 if (!get_option('onwebchat_wc_bulk_in_progress', false)) {
1470 error_log('onWebChat WooCommerce Sync - Sync not in progress, exiting');
1471 return;
1472 }
1473
1474 $page = get_option('onwebchat_wc_bulk_page', 0);
1475 $done = get_option('onwebchat_wc_bulk_done', 0);
1476 $total = get_option('onwebchat_wc_bulk_total', 0);
1477
1478 error_log('onWebChat WooCommerce Sync - Starting batch: page=' . $page . ', done=' . $done . ', total=' . $total);
1479
1480 // Get batch of products
1481 $args = array(
1482 'post_type' => 'product',
1483 'post_status' => 'publish',
1484 'posts_per_page' => $this->batch_size,
1485 'paged' => $page + 1,
1486 'orderby' => 'ID',
1487 'order' => 'ASC',
1488 );
1489
1490 // Restrict to the saved sync scope and its subcategories, consistent
1491 // with the synchronous bulk sync path.
1492 $scope = $this->get_sync_scope();
1493 if (!empty($scope)) {
1494 $args['tax_query'] = array(array(
1495 'taxonomy' => 'product_cat',
1496 'field' => 'term_id',
1497 'terms' => array_map('intval', $scope),
1498 'include_children' => true,
1499 ));
1500 }
1501
1502 $query = new WP_Query($args);
1503
1504 if ($query->have_posts()) {
1505 // Collect all products in this batch
1506 $products_batch = array();
1507
1508 foreach ($query->posts as $post) {
1509 $product = wc_get_product($post->ID);
1510 if ($product && !$this->is_product_excluded($product)) {
1511 $products_batch[] = $this->prepare_product_data($product);
1512 }
1513 }
1514
1515 // Send entire batch in one request
1516 $batch_done = 0;
1517 if (!empty($products_batch)) {
1518 $result = $this->send_product_batch($products_batch);
1519 error_log('onWebChat WooCommerce Sync - Batch result: ' . print_r($result, true));
1520 if ($result && isset($result['stats'])) {
1521 // Count created + updated + skipped as "done"
1522 $batch_done = $result['stats']['created'] + $result['stats']['updated'] + $result['stats']['skipped'];
1523 error_log('onWebChat WooCommerce Sync - Batch done: ' . $batch_done);
1524 } else {
1525 // Fallback: assume all sent
1526 $batch_done = count($products_batch);
1527 error_log('onWebChat WooCommerce Sync - No stats in result, using fallback count: ' . $batch_done);
1528 }
1529 }
1530
1531 // Update progress
1532 $new_done = $done + $batch_done;
1533 error_log('onWebChat WooCommerce Sync - Progress update: done=' . $done . ' + batch_done=' . $batch_done . ' = new_done=' . $new_done . ' / total=' . $total);
1534 update_option('onwebchat_wc_bulk_page', $page + 1);
1535 update_option('onwebchat_wc_bulk_done', $new_done);
1536
1537 // Check if we've processed all products
1538 if ($new_done >= $total || !$query->have_posts()) {
1539 // Sync complete
1540 error_log('onWebChat WooCommerce Sync - Marking sync as complete');
1541 update_option('onwebchat_wc_bulk_in_progress', false);
1542 update_option('onwebchat_wc_last_bulk_sync', current_time('timestamp'));
1543 update_option('onwebchat_wc_bulk_done', $total); // Ensure it shows 100%
1544 } else {
1545 // Schedule next batch
1546 error_log('onWebChat WooCommerce Sync - Scheduling next batch in 60 seconds');
1547 wp_schedule_single_event(time() + 60, 'onwebchat_wc_bulk_sync_batch');
1548 }
1549 } else {
1550 // No more products - sync complete
1551 update_option('onwebchat_wc_bulk_in_progress', false);
1552 update_option('onwebchat_wc_last_bulk_sync', current_time('timestamp'));
1553 update_option('onwebchat_wc_bulk_done', $total); // Ensure it shows 100%
1554 }
1555
1556 wp_reset_postdata();
1557 }
1558
1559 /**
1560 * AJAX: Regenerate secret (fetch from server)
1561 */
1562 public function ajax_regenerate_secret() {
1563 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
1564
1565 if (!current_user_can('manage_options')) {
1566 wp_send_json_error('Insufficient permissions');
1567 }
1568
1569 // Clear cached secret - user will need to re-authenticate
1570 delete_option('onwebchat_wc_sync_secret');
1571
1572 // Clear any authentication error notices
1573 delete_transient('onwebchat_wc_auth_error');
1574
1575 wp_send_json_success(array(
1576 'message' => 'Secret cleared. Please reconnect WooCommerce with your credentials.',
1577 'needs_reconnect' => true
1578 ));
1579 }
1580
1581 /**
1582 * AJAX: Reset sync status
1583 */
1584 public function ajax_reset_sync_status() {
1585 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
1586
1587 if (!current_user_can('manage_options')) {
1588 wp_send_json_error('Insufficient permissions');
1589 }
1590
1591 $total = get_option('onwebchat_wc_bulk_total', 0);
1592
1593 // Mark sync as complete
1594 update_option('onwebchat_wc_bulk_in_progress', false);
1595 update_option('onwebchat_wc_bulk_done', $total);
1596 update_option('onwebchat_wc_last_bulk_sync', current_time('timestamp'));
1597
1598 wp_send_json_success(array(
1599 'message' => 'Sync status reset successfully'
1600 ));
1601 }
1602
1603 /**
1604 * AJAX handler to get current sync status
1605 */
1606 public function ajax_get_sync_status() {
1607 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
1608
1609 if (!current_user_can('manage_options')) {
1610 wp_send_json_error('Insufficient permissions');
1611 }
1612
1613 $in_progress = get_option('onwebchat_wc_bulk_in_progress', false);
1614 $done = get_option('onwebchat_wc_bulk_done', 0);
1615 $total = get_option('onwebchat_wc_bulk_total', 0);
1616
1617 wp_send_json_success(array(
1618 'in_progress' => $in_progress,
1619 'done' => $done,
1620 'total' => $total
1621 ));
1622 }
1623
1624 /**
1625 * AJAX handler to save sync enabled setting
1626 */
1627 public function ajax_save_sync_enabled() {
1628 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
1629
1630 if (!current_user_can('manage_options')) {
1631 wp_send_json_error('Insufficient permissions');
1632 }
1633
1634 $sync_enabled = isset($_POST['sync_enabled']) && $_POST['sync_enabled'] === '1';
1635
1636 update_option('onwebchat_wc_sync_enabled', $sync_enabled);
1637
1638 wp_send_json_success(array(
1639 'message' => $sync_enabled ? 'WooCommerce product sync enabled' : 'WooCommerce product sync disabled',
1640 'enabled' => $sync_enabled
1641 ));
1642 }
1643
1644 /**
1645 * Get sync status for admin display
1646 */
1647 public function get_sync_status() {
1648 $last_sync = get_option('onwebchat_wc_last_bulk_sync', 0);
1649 $in_progress = get_option('onwebchat_wc_bulk_in_progress', false);
1650 $done = get_option('onwebchat_wc_bulk_done', 0);
1651 $total = get_option('onwebchat_wc_bulk_total', 0);
1652
1653 return array(
1654 'last_sync' => $last_sync,
1655 'in_progress' => $in_progress,
1656 'done' => $done,
1657 'total' => $total,
1658 );
1659 }
1660
1661 /**
1662 * AJAX: Manually process batch (for debugging)
1663 */
1664 public function ajax_manual_process_batch() {
1665 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
1666
1667 if (!current_user_can('manage_options')) {
1668 wp_send_json_error('Insufficient permissions');
1669 }
1670
1671 // Manually trigger the cron job
1672 error_log('onWebChat WooCommerce Sync - Manual batch process triggered via AJAX');
1673 $this->process_bulk_sync_batch();
1674
1675 // Return current status
1676 $status = $this->get_sync_status();
1677 wp_send_json_success(array(
1678 'message' => 'Batch processed',
1679 'status' => $status
1680 ));
1681 }
1682 }
1683
1684 // Initialize the sync module
1685 global $onwebchat_wc_sync;
1686 $onwebchat_wc_sync = new OnWebChat_WooCommerce_Sync();
1687
1688