PluginProbe
Live Chat & AI Chatbot – onWebChat / 3.8.4
Live Chat & AI Chatbot – onWebChat v3.8.4
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 1.0.6 All 48 releases
onwebchat / includes / woocommerce-sync.php

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

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