PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 2.4.5
Fluent Support – Helpdesk & Customer Support Ticket System v2.4.5
2.4.5 2.4.0 2.3.2 2.3.1 2.3.0 2.2.1 2.2.0 trunk 1.10.0 1.10.1 1.10.2 1.10.3 1.10.4 1.10.5 1.4.0 1.4.1 1.4.2 1.4.5 1.4.6 1.4.7 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 All 69 releases
← All changes | app/Services/Helper.php +367 -154 2.2.1 → 2.4.5 View file →
@@ -16,17 +16,8 @@
16 16 use FluentSupport\App\Services\Includes\UploadService;
17 17 use FluentSupport\App\Services\EmailNotification\Settings;
18 18 use FluentSupport\Framework\Support\Arr;
19 19
20 -/**
21 - * Helper - REST API Helper Class
22 - *
23 - * App helper for REST API
24 - *
25 - * @package FluentSupport\App\Services
26 - *
27 - * @version 1.0.0
28 - */
29 20 class Helper
30 21 {
31 22 public static function FluentSupport($module = null)
32 23 {
@@ -33,12 +24,12 @@
33 24 return App::getInstance($module);
34 25 }
35 26
36 27 /**
37 - * Get agent information by user id
38 - * The function will get user id as parameter or get id from session and return agent information
39 - * @param null $userId
40 - * @return false | Agent
28 + * Agent for the given user, or for the current user when $userId is null.
29 + *
30 + * @param int|null $userId
31 + * @return false|Agent|null
41 32 */
42 33 public static function getAgentByUserId($userId = null)
43 34 {
44 35 if ($userId === null) {
@@ -50,10 +41,8 @@
50 41 return Agent::where('user_id', $userId)->first();
51 42 }
52 43
53 44 /**
54 - * This function will return the list of ticket priorities list for customer
55 - *
56 45 * @return mixed
57 46 */
58 47 public static function customerTicketPriorities()
59 48 {
@@ -64,10 +53,8 @@
64 53 ]);
65 54 }
66 55
67 56 /**
68 - * This function will return the list of ticket priorities list for Admin
69 - *
70 57 * @return mixed
71 58 */
72 59 public static function adminTicketPriorities()
73 60 {
@@ -79,10 +66,8 @@
79 66 }
80 67
81 68
82 69 /**
83 - * This function will return ticket status group
84 - *
85 70 * @return mixed
86 71 */
87 72 public static function ticketStatusGroups()
88 73 {
@@ -95,10 +80,8 @@
95 80 ]);
96 81 }
97 82
98 83 /**
99 - * This function will return custom ticket status group
100 - *
101 84 * @return mixed
102 85 */
103 86 public static function changeableTicketStatuses()
104 87 {
@@ -110,10 +93,8 @@
110 93 return apply_filters('fluent_support/changeable_ticket_statuses', $ticketStatus);
111 94 }
112 95
113 96 /**
114 - * This function will return ticket status list
115 - *
116 97 * @return mixed
117 98 */
118 99 public static function ticketStatuses()
119 100 {
@@ -230,17 +211,14 @@
230 211 ]);
231 212 }
232 213
233 214 /**
234 - * getOption method will return settings using key
235 - * This method will get key as parameter, fetch data from database, beautify the data and return
236 - * @param $key
237 - * @param string $default
215 + * @param string $key
216 + * @param mixed $default Returned when the option is missing or empty.
238 217 * @return mixed|string
239 218 */
240 219 public static function getOption($key, $default = '')
241 220 {
242 - //Get settings from meta table using the key
243 221 $data = Meta::where('object_type', 'option')
244 222 ->where('key', $key)
245 223 ->first();
246 224
@@ -254,22 +232,20 @@
254 232 return $default;
255 233 }
256 234
257 235 /**
258 - * updateOption method will update or insert settings
259 - * This method will get key and value as parameter, check exists or not. If exist update value by key, else insert value for the key
260 - * @param $key
261 - * @param $value
236 + * Upsert an option row in the meta table.
237 + *
238 + * @param string $key
239 + * @param mixed $value
262 240 * @return mixed
263 241 */
264 242 public static function updateOption($key, $value)
265 243 {
266 - //Get settings from meta table using the key
267 244 $data = Meta::where('object_type', 'option')
268 245 ->where('key', $key)
269 246 ->first();
270 247
271 - //If data is available, update existing data and return
272 248 if ($data) {
273 249 return Meta::where('id', $data->id)
274 250 ->update([
275 251 'value' => maybe_serialize($value)
@@ -275,9 +251,8 @@
275 251 'value' => maybe_serialize($value)
276 252 ]);
277 253 }
278 254
279 - //If newly submit, create new record and return
280 255 return Meta::insert([
281 256 'object_type' => 'option',
282 257 'key' => $key,
283 258 'value' => maybe_serialize($value)
@@ -292,10 +267,9 @@
292 267 ->delete();
293 268 }
294 269
295 270 /**
296 - * getIntegrationOption method will return the integration settings by integration key
297 - * @param $key
271 + * @param string $key
298 272 * @param string $default
299 273 * @return mixed|string
300 274 */
301 275 public static function getIntegrationOption($key, $default = '')
@@ -314,11 +288,12 @@
314 288 return $default;
315 289 }
316 290
317 291 /**
318 - * updateIntegrationOption method will update existing settings or create new settings by integration key
319 - * @param $key
320 - * @param $value
292 + * Upsert an integration settings row.
293 + *
294 + * @param string $key
295 + * @param mixed $value
321 296 * @return mixed
322 297 */
323 298 public static function updateIntegrationOption($key, $value)
324 299 {
@@ -384,8 +359,98 @@
384 359 }
385 360
386 361 }
387 362
363 + public static function saveAIProviderSettings(array $data)
364 + {
365 + if (!empty($data['api_key'])) {
366 + $data['api_key'] = static::encryptApiKey($data['api_key']);
367 + }
368 +
369 + $serializedData = maybe_serialize($data);
370 + $previous = Meta::where('object_type', '_fs_ai_provider_settings')->first();
371 +
372 + if ($previous) {
373 + return Meta::where('object_type', '_fs_ai_provider_settings')->update([
374 + 'value' => $serializedData,
375 + ]);
376 + }
377 +
378 + return Meta::insert([
379 + 'object_type' => '_fs_ai_provider_settings',
380 + 'key' => '_fs_ai_provider_data',
381 + 'value' => $serializedData,
382 + ]);
383 + }
384 +
385 + public static function getAIProviderSettings(): array
386 + {
387 + $record = Meta::where('object_type', '_fs_ai_provider_settings')->first();
388 +
389 + if (!$record) {
390 + $record = Meta::where('object_type', '_fs_openai_settings')->first();
391 + }
392 +
393 + if (!$record) {
394 + return [];
395 + }
396 +
397 + $settings = static::safeUnserialize($record->value);
398 +
399 + if (empty($settings) || !is_array($settings)) {
400 + return [];
401 + }
402 +
403 + if (!isset($settings['provider'])) {
404 + $settings['provider'] = 'openai';
405 + }
406 +
407 + if (!empty($settings['api_key'])) {
408 + $settings['api_key'] = static::decryptApiKey($settings['api_key']);
409 + }
410 +
411 + return $settings;
412 + }
413 +
414 + public static function encryptApiKey(string $apiKey): string
415 + {
416 + if (!extension_loaded('openssl')) {
417 + return $apiKey;
418 + }
419 +
420 + $cipher = 'AES-256-CBC';
421 + $key = substr(hash('sha256', AUTH_KEY . SECURE_AUTH_KEY, true), 0, 32);
422 + $iv = openssl_random_pseudo_bytes(openssl_cipher_iv_length($cipher));
423 + $encrypted = openssl_encrypt($apiKey, $cipher, $key, 0, $iv);
424 +
425 + if ($encrypted === false) {
426 + return $apiKey;
427 + }
428 +
429 + return 'fsai:' . base64_encode($iv . $encrypted);
430 + }
431 +
432 + public static function decryptApiKey(string $value): string
433 + {
434 + if (!extension_loaded('openssl') || strncmp($value, 'fsai:', 5) !== 0) {
435 + return $value;
436 + }
437 +
438 + $cipher = 'AES-256-CBC';
439 + $decoded = base64_decode(substr($value, 5));
440 + $ivLen = openssl_cipher_iv_length($cipher);
441 +
442 + if (strlen($decoded) <= $ivLen) {
443 + return $value;
444 + }
445 +
446 + $key = substr(hash('sha256', AUTH_KEY . SECURE_AUTH_KEY, true), 0, 32);
447 + $iv = substr($decoded, 0, $ivLen);
448 + $decrypted = openssl_decrypt(substr($decoded, $ivLen), $cipher, $key, 0, $iv);
449 +
450 + return $decrypted !== false ? $decrypted : $value;
451 + }
452 +
388 453 public static function authorizeChatGPTAPIKey($data)
389 454 {
390 455 return wp_remote_get('https://api.openai.com/v1/models', [
391 456 'headers' => [
@@ -408,9 +473,8 @@
408 473 return $baseUrl . 'tickets/' . $ticket->id . '/view';
409 474 }
410 475
411 476 /**
412 - * getPortalBaseUrl will get the portal page id and return link of the page
413 477 * @return mixed
414 478 */
415 479 public static function getPortalBaseUrl()
416 480 {
@@ -459,28 +523,94 @@
459 523 {
460 524 return apply_filters('fluent_support/portal_admin_base_url', admin_url('admin.php?page=fluent-support/#/'));
461 525 }
462 526
463 - public static function getBusinessSettings($key = null)
527 + /**
528 + * Global business settings, or one of them. $default applies only to a key
529 + * missing from the stored settings and their built-in defaults.
530 + *
531 + * @param string|null $key
532 + * @param mixed $default
533 + * @return mixed
534 + */
535 + public static function getBusinessSettings($key = null, $default = null)
464 536 {
465 537 static $settings;
466 538
467 - if ($settings && $key) {
468 - return Arr::get($settings, $key);
539 + if (!$settings) {
540 + $settings = (new Settings())->globalBusinessSettings();
469 541 }
470 542
471 - if ($settings) {
472 - return $settings;
543 + if ($key) {
544 + return Arr::get($settings, $key, $default);
473 545 }
474 546
475 - $settings = (new Settings())->globalBusinessSettings();
547 + return $settings;
548 + }
476 549
477 - if ($key) {
478 - return Arr::get($settings, $key);
550 + /**
551 + * Fields the "Custom Registration Form Field" setting can select. Their
552 + * keys are written to user meta, so the stored setting is limited to these.
553 + *
554 + * @return array key => label
555 + */
556 + public static function getCustomRegistrationFieldOptions()
557 + {
558 + return [
559 + 'address_line_1' => 'Address Line 1',
560 + 'address_line_2' => 'Address Line 2',
561 + 'city' => 'City',
562 + 'zip' => 'Zip Code',
563 + 'state' => 'State',
564 + 'country' => 'Country',
565 + ];
566 + }
567 +
568 + /**
569 + * Registration field keys that may be written to user meta. Keys outside
570 + * the setting's options are dropped before the filter runs, and keys
571 + * WordPress treats as authority are dropped even when a filter adds them.
572 + *
573 + * @return array
574 + */
575 + public static function getCustomRegistrationFieldKeys()
576 + {
577 + $keys = array_intersect(
578 + (array) self::getBusinessSettings('custom_registration_form_field'),
579 + array_keys(self::getCustomRegistrationFieldOptions())
580 + );
581 +
582 + $keys = apply_filters('fluent_support/custom_registration_form_fields_key', array_values($keys));
583 +
584 + if (!is_array($keys)) {
585 + return [];
479 586 }
480 - return $settings;
587 +
588 + return array_values(array_filter($keys, function ($key) {
589 + return is_string($key) && $key !== '' && !self::isProtectedUserMetaKey($key);
590 + }));
481 591 }
482 592
593 + /**
594 + * Whether a user meta key holds roles, capabilities, sessions or plugin
595 + * permissions. meta_key compares case-insensitively and ignores trailing
596 + * spaces in MySQL, so the check does too.
597 + *
598 + * @param string $key
599 + * @return bool
600 + */
601 + public static function isProtectedUserMetaKey($key)
602 + {
603 + $key = strtolower(trim((string) $key));
604 +
605 + if (in_array($key, ['session_tokens', strtolower(\FluentSupport\App\Modules\PermissionManager::META_KEY)], true)) {
606 + return true;
607 + }
608 +
609 + // {prefix}capabilities and {prefix}user_level, for every site prefix.
610 + return (bool) preg_match('/(capabilities|user_level)$/', $key);
611 + }
612 +
483 613 public static function isAgentFeedbackEnabled()
484 614 {
485 615 return self::getBusinessSettings('agent_feedback_rating', 'no') == 'yes';
486 616 }
@@ -555,11 +685,8 @@
555 685 }
556 686
557 687 public static function getCurrentAgent()
558 688 {
559 - // If user is logged in then return the agent by user id.
560 - // This `get_current_user_id` function is WP function and
561 - // it returns user id if user is logged in.
562 689 if (get_current_user_id()) {
563 690 return Agent::where('user_id', get_current_user_id())->first();
564 691 }
565 692 }
@@ -565,21 +692,19 @@
565 692 }
566 693
567 694 public static function getCurrentCustomer()
568 695 {
569 - // If user is logged in then return the customer by user id.
570 - // This `get_current_user_id` function is WP function and
571 - // it returns user id if user is logged in.
572 - if (get_current_user_id()) { //if user is logged in
573 - return Customer::where('user_id', get_current_user_id())->first();
696 + if (get_current_user_id()) {
697 + // user_id is not unique, so order by id to pick the same record as
698 + // getCurrentPerson() and Customer::getCustomerFromData().
699 + return Customer::where('user_id', get_current_user_id())
700 + ->orderBy('id', 'ASC')
701 + ->first();
574 702 }
575 703 }
576 704
577 705 public static function getCurrentPerson()
578 706 {
579 - // If user is logged in then return the person(agent/customer) by user id.
580 - // This `get_current_user_id` function is WP function and
581 - // it returns user id if user is logged in.
582 707 if (get_current_user_id()) {
583 708 return Person::where('user_id', get_current_user_id())
584 709 ->orderBy('id', 'ASC')
585 710 ->first();
@@ -639,10 +764,9 @@
639 764 return $crmConfigs;
640 765 }
641 766
642 767 /**
643 - * getFluentCrmContactData method will get information from fluent crm using user email
644 - * @param $customer
768 + * @param Customer $customer
645 769 * @return array|false
646 770 */
647 771 public static function getFluentCrmContactData($customer)
648 772 {
@@ -648,9 +772,8 @@
648 772 {
649 773 if (!defined('FLUENTCRM')) {
650 774 return false;
651 775 }
652 - //Get contact info from FluentCRM using customer email
653 776 $contact = \FluentCrmApi('contacts')->getContactByUserRef($customer->email);
654 777 if ($contact) {
655 778 $tags = $contact->tags;
656 779 $lists = $contact->lists;
@@ -656,9 +779,8 @@
656 779 $lists = $contact->lists;
657 780 $urlBase = apply_filters('fluentcrm_menu_url_base', admin_url('admin.php?page=fluentcrm-admin#/'));
658 781 $crmProfileUrl = $urlBase . 'subscribers/' . $contact->id;
659 782
660 - //Return contact data
661 783 return [
662 784 'id' => $contact->id,
663 785 'first_name' => $contact->first_name,
664 786 'last_name' => $contact->last_name,
@@ -674,24 +796,22 @@
674 796
675 797 return false;
676 798 }
677 799
678 - public static function openAIIntegrationStatus() {
679 - $chatGPTSettingsData = Meta::where('object_type', '_fs_openai_settings')->value('value');
800 + public static function openAIIntegrationStatus()
801 + {
802 + $settings = static::getAIProviderSettings();
680 803
681 - if ($chatGPTSettingsData) {
682 - $settings = static::safeUnserialize($chatGPTSettingsData);
683 - return !empty($settings['api_key']);
804 + if (($settings['enabled'] ?? 'yes') === 'no') {
805 + return false;
684 806 }
685 807
686 - return false;
808 + return !empty($settings['api_key']);
687 809 }
688 810
689 811 public static function fluentBotIntegrationStatus()
690 812 {
691 - // Include object_id and order by id desc to always read the latest row; the
692 - // write path (saveFluentBotSettings) does not prune siblings, so duplicates
693 - // may exist and a non-deterministic read can return stale state.
813 + // saveFluentBotSettings() doesn't prune old rows, so read the newest one.
694 814 $meta = Meta::where([
695 815 'object_type' => 'fluent_bot_settings',
696 816 'object_id' => 1,
697 817 'key' => '_fs_fluent_bot_config'
@@ -764,9 +884,9 @@
764 884 public static function getAuthProvider()
765 885 {
766 886 if (defined('FLUENT_AUTH_PLUGIN_PATH')) {
767 887 $settings = \FluentAuth\App\Helpers\Helper::getAuthFormsSettings();
768 - if ($settings['enabled'] == 'yes') {
888 + if ($settings['enabled'] == 'yes' || FluentAuthBridge::shouldAdopt()) {
769 889 return 'fluent_auth';
770 890 }
771 891 }
772 892
@@ -772,8 +892,22 @@
772 892
773 893 return 'fluent_support';
774 894 }
775 895
896 + /**
897 + * Whether visitors may create an account, per WordPress's own registration setting.
898 + *
899 + * @return bool
900 + */
901 + public static function isSignupEnabled()
902 + {
903 + if (is_multisite()) {
904 + return in_array(get_site_option('registration'), ['user', 'all'], true);
905 + }
906 +
907 + return (bool)get_option('users_can_register');
908 + }
909 +
776 910 /** @internal Not called from core controllers — available for Pro/hook usage. */
777 911 public static function getDriversKey(){
778 912 return [
779 913 'dropbox_settings',
@@ -796,11 +930,9 @@
796 930 if ($driver) {
797 931 return $driver;
798 932 }
799 933
800 - // Now guess the driver and save it
801 -
802 - // check if dropbox is enabled
934 + // No driver saved yet: use the first enabled one and remember it.
803 935 $dropboxSettings = self::getIntegrationOption('dropbox_settings', null);
804 936 if ($dropboxSettings) {
805 937 $dropBoxEnabled = Meta::where('object_type', 'enabled_upload_drivers')
806 938 ->where('key', 'dropbox_settings')
@@ -813,9 +945,8 @@
813 945 return $driver;
814 946 }
815 947 }
816 948
817 - // check if google drive is enabled
818 949 $googleDriveSettings = self::getIntegrationOption('google_drive_settings', null);
819 950
820 951 if ($googleDriveSettings) {
821 952 $googleDriveEnabled = Meta::where('object_type', 'enabled_upload_drivers')
@@ -829,9 +960,8 @@
829 960 return $driver;
830 961 }
831 962 }
832 963
833 - // check if cloudflare r2 is enabled
834 964 $cloudflareR2Settings = self::getIntegrationOption('cloudflare_r2_settings', null);
835 965
836 966 if ($cloudflareR2Settings) {
837 967 $cloudflareR2Enabled = Meta::where('object_type', 'enabled_upload_drivers')
@@ -845,9 +975,8 @@
845 975 return $driver;
846 976 }
847 977 }
848 978
849 - // check if amazon s3 is enabled
850 979 $amazonS3Settings = self::getIntegrationOption('amazon_s3_settings', null);
851 980
852 981 if ($amazonS3Settings) {
853 982 $amazonS3Enabled = Meta::where('object_type', 'enabled_upload_drivers')
@@ -873,9 +1002,9 @@
873 1002 'title' => __('WooCommerce', 'fluent-support'),
874 1003 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/woocommerce.png',
875 1004 'is_integrated' => defined('WC_PLUGIN_FILE'),
876 1005 'description' => __('The most popular e-commerce platform for WordPress', 'fluent-support'),
877 - 'doc_url' => 'https://fluentsupport.com/docs/woocommerce-integration/',
1006 + 'doc_url' => 'https://docs.fluentsupport.com/woocommerce-integration',
878 1007 ],
879 1008 'fluent-cart' => [
880 1009 'title' => __('FluentCart', 'fluent-support'),
881 1010 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/fluent-cart.webp',
@@ -880,9 +1009,9 @@
880 1009 'title' => __('FluentCart', 'fluent-support'),
881 1010 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/fluent-cart.webp',
882 1011 'is_integrated' => defined('FLUENTCART_VERSION'),
883 1012 'description' => __('A New Era of eCommerce with WordPress', 'fluent-support'),
884 - 'doc_url' => 'https://fluentsupport.com/docs/fluentcart-integration/',
1013 + 'doc_url' => 'https://docs.fluentsupport.com/fluentcart-integration',
885 1014 ],
886 1015 'lifter-lms' => [
887 1016 'title' => __('LifterLMS', 'fluent-support'),
888 1017 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/lifter-lms.png',
@@ -887,9 +1016,9 @@
887 1016 'title' => __('LifterLMS', 'fluent-support'),
888 1017 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/lifter-lms.png',
889 1018 'is_integrated' => defined('LLMS_PLUGIN_FILE'),
890 1019 'description' => __('Course and e-learning platform built for WordPress', 'fluent-support'),
891 - 'doc_url' => 'https://fluentsupport.com/docs/lifterlms-integration/',
1020 + 'doc_url' => 'https://docs.fluentsupport.com/lifterlms-integration',
892 1021 ],
893 1022 'slack' => [
894 1023 'title' => __('Slack', 'fluent-support'),
895 1024 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/slack.png',
@@ -894,9 +1023,9 @@
894 1023 'title' => __('Slack', 'fluent-support'),
895 1024 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/slack.png',
896 1025 'is_integrated' => self::getFSIntegrationStatus('slack_settings'),
897 1026 'description' => __('Business communication platform designed to scale', 'fluent-support'),
898 - 'doc_url' => 'https://fluentsupport.com/docs/managing-tickets-using-slack/',
1027 + 'doc_url' => 'https://docs.fluentsupport.com/managing-tickets-using-slack',
899 1028 ],
900 1029 'pm-pro' => [
901 1030 'title' => __('Paid Memberships Pro', 'fluent-support'),
902 1031 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/pmpro.png',
@@ -901,9 +1030,9 @@
901 1030 'title' => __('Paid Memberships Pro', 'fluent-support'),
902 1031 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/pmpro.png',
903 1032 'is_integrated' => defined('PMPRO_VERSION'),
904 1033 'description' => __('The ultimate platform for any member-focused business', 'fluent-support'),
905 - 'doc_url' => 'https://fluentsupport.com/docs/paid-membership-pro-integration/',
1034 + 'doc_url' => 'https://docs.fluentsupport.com/paid-membership-pro-integration',
906 1035 ],
907 1036 'tutor-lms' => [
908 1037 'title' => __('Tutor LMS', 'fluent-support'),
909 1038 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/tutor-lms.png',
@@ -908,9 +1037,9 @@
908 1037 'title' => __('Tutor LMS', 'fluent-support'),
909 1038 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/tutor-lms.png',
910 1039 'is_integrated' => defined('TUTOR_VERSION'),
911 1040 'description' => __('Course and e-learning platform built for WordPress', 'fluent-support'),
912 - 'doc_url' => 'https://fluentsupport.com/docs/tutorlms-integration/',
1041 + 'doc_url' => 'https://docs.fluentsupport.com/tutorlms-integration',
913 1042 ],
914 1043 'telegram' => [
915 1044 'title' => __('Telegram', 'fluent-support'),
916 1045 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/telegram.jpeg',
@@ -915,9 +1044,9 @@
915 1044 'title' => __('Telegram', 'fluent-support'),
916 1045 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/telegram.jpeg',
917 1046 'is_integrated' => self::getFSIntegrationStatus('telegram_settings'),
918 1047 'description' => __('Business communication platform designed for security', 'fluent-support'),
919 - 'doc_url' => 'https://fluentsupport.com/docs/managing-tickets-using-telegram/',
1048 + 'doc_url' => 'https://docs.fluentsupport.com/managing-tickets-using-telegram',
920 1049 ],
921 1050 'fluent-crm' => [
922 1051 'title' => __('FluentCRM', 'fluent-support'),
923 1052 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/fluent-crm.png',
@@ -922,9 +1051,9 @@
922 1051 'title' => __('FluentCRM', 'fluent-support'),
923 1052 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/fluent-crm.png',
924 1053 'is_integrated' => defined('FLUENTCRM'),
925 1054 'description' => __('Self-hosted email and marketing automation for WordPress', 'fluent-support'),
926 - 'doc_url' => 'https://fluentsupport.com/docs/fluentcrm-integration/',
1055 + 'doc_url' => 'https://docs.fluentsupport.com/fluentcrm-integration',
927 1056 ],
928 1057 'fluent-community' => [
929 1058 'title' => __('FluentCommunity', 'fluent-support'),
930 1059 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/fluent-community.png',
@@ -929,9 +1058,9 @@
929 1058 'title' => __('FluentCommunity', 'fluent-support'),
930 1059 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/fluent-community.png',
931 1060 'is_integrated' => defined('FLUENT_COMMUNITY_PLUGIN_VERSION'),
932 1061 'description' => __('Build and manage vibrant online communities with integrated LMS features directly within WordPress.', 'fluent-support'),
933 - 'doc_url' => 'https://fluentsupport.com/docs/fluentcommunity-integration/',
1062 + 'doc_url' => 'https://docs.fluentsupport.com/fluent-community-integration',
934 1063 ],
935 1064 'fluent-forms' => [
936 1065 'title' => __('Fluent Forms', 'fluent-support'),
937 1066 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/fluent-forms.png',
@@ -936,9 +1065,9 @@
936 1065 'title' => __('Fluent Forms', 'fluent-support'),
937 1066 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/fluent-forms.png',
938 1067 'is_integrated' => defined('FLUENTFORM'),
939 1068 'description' => __('A robust form plugin suitable for any business', 'fluent-support'),
940 - 'doc_url' => 'https://fluentsupport.com/docs/fluent-form-integration/',
1069 + 'doc_url' => 'https://docs.fluentsupport.com/fluent-form-integration',
941 1070 ],
942 1071 'buddy-boss' => [
943 1072 'title' => __('BuddyBoss', 'fluent-support'),
944 1073 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/buddy-boss.png',
@@ -943,9 +1072,9 @@
943 1072 'title' => __('BuddyBoss', 'fluent-support'),
944 1073 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/buddy-boss.png',
945 1074 'is_integrated' => defined('BP_PLUGIN_DIR'),
946 1075 'description' => __('Powerful platform for any member-focused business', 'fluent-support'),
947 - 'doc_url' => 'https://fluentsupport.com/docs/buddyboss-integration/'
1076 + 'doc_url' => 'https://docs.fluentsupport.com/buddyboss-integration'
948 1077 ],
949 1078 'discord' => [
950 1079 'title' => __('Discord', 'fluent-support'),
951 1080 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/discord.png',
@@ -950,9 +1079,9 @@
950 1079 'title' => __('Discord', 'fluent-support'),
951 1080 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/discord.png',
952 1081 'is_integrated' => self::getFSIntegrationStatus('discord_settings'),
953 1082 'description' => __('Business communication platform designed for tech', 'fluent-support'),
954 - 'doc_url' => 'https://fluentsupport.com/docs/managing-tickets-using-discord/',
1083 + 'doc_url' => 'https://docs.fluentsupport.com/managing-tickets-using-discord',
955 1084 ],
956 1085 'wishlist-member' => [
957 1086 'title' => __('WishList Member', 'fluent-support'),
958 1087 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/wishlist-member.png',
@@ -957,9 +1086,9 @@
957 1086 'title' => __('WishList Member', 'fluent-support'),
958 1087 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/wishlist-member.png',
959 1088 'is_integrated' => defined('WLM3_PLUGIN_VERSION'),
960 1089 'description' => __('Powerful platform for any member-focused business', 'fluent-support'),
961 - 'doc_url' => 'https://fluentsupport.com/docs/wishlist-member-integration/',
1090 + 'doc_url' => 'https://docs.fluentsupport.com/wishlist-member-integration',
962 1091 ],
963 1092 'easy-digital-downloads' => [
964 1093 'title' => __('Easy Digital Downloads', 'fluent-support'),
965 1094 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/easy-digital-downloads.png',
@@ -964,9 +1093,9 @@
964 1093 'title' => __('Easy Digital Downloads', 'fluent-support'),
965 1094 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/easy-digital-downloads.png',
966 1095 'is_integrated' => class_exists('\Easy_Digital_Downloads'),
967 1096 'description' => __('The ultimate WordPress platform for digital products', 'fluent-support'),
968 - 'doc_url' => 'https://fluentsupport.com/docs/edd-integration/',
1097 + 'doc_url' => 'https://docs.fluentsupport.com/edd-integration',
969 1098 ],
970 1099 'restrict-content-pro' => [
971 1100 'title' => __('Restrict Content pro', 'fluent-support'),
972 1101 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/restrict-content-pro.png',
@@ -971,9 +1100,9 @@
971 1100 'title' => __('Restrict Content pro', 'fluent-support'),
972 1101 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/restrict-content-pro.png',
973 1102 'is_integrated' => class_exists('\Restrict_Content_Pro' ),
974 1103 'description' => __('Powerful platform for any member-focused business', 'fluent-support'),
975 - 'doc_url' => 'https://fluentsupport.com/docs/restrict-content-pro-integration/',
1104 + 'doc_url' => 'https://docs.fluentsupport.com/restrict-content-pro-integration',
976 1105 ],
977 1106 'better-docs' => [
978 1107 'title' => __('BetterDocs', 'fluent-support'),
979 1108 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/better-docs.png',
@@ -978,9 +1107,9 @@
978 1107 'title' => __('BetterDocs', 'fluent-support'),
979 1108 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/better-docs.png',
980 1109 'is_integrated' => false,
981 1110 'description' => __('The standard plugin for knowledge base and documentation', 'fluent-support'),
982 - 'doc_url' => 'https://fluentsupport.com/docs/betterdocs-integration/',
1111 + 'doc_url' => 'https://docs.fluentsupport.com/betterdocs-integration',
983 1112 ],
984 1113 'whatsapp' => [
985 1114 'title' => __('WhatsApp', 'fluent-support'),
986 1115 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/whatsapp.jpeg',
@@ -985,9 +1114,9 @@
985 1114 'title' => __('WhatsApp', 'fluent-support'),
986 1115 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/whatsapp.jpeg',
987 1116 'is_integrated' => self::getFSIntegrationStatus('twilio_settings'),
988 1117 'description' => __('Business communication platform designed for privacy', 'fluent-support'),
989 - 'doc_url' => 'https://fluentsupport.com/docs/whatsapp-integration-via-twilio/',
1118 + 'doc_url' => 'https://docs.fluentsupport.com/whatsapp-integration-via-twilio',
990 1119 ],
991 1120 'paymattic' => [
992 1121 'title' => __('Paymattic', 'fluent-support'),
993 1122 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/paymattic.png',
@@ -999,9 +1128,9 @@
999 1128 'title' => __('LearnDash', 'fluent-support'),
1000 1129 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/learn-dash.png',
1001 1130 'is_integrated' => defined('LEARNDASH_VERSION'),
1002 1131 'description' => __('The leading course platform built for WordPress', 'fluent-support'),
1003 - 'doc_url' => 'https://fluentsupport.com/docs/learndash-integration/',
1132 + 'doc_url' => 'https://docs.fluentsupport.com/learndash-integration',
1004 1133 ],
1005 1134 'learn-press' => [
1006 1135 'title' => __('LearnPress', 'fluent-support'),
1007 1136 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/learn-press.png',
@@ -1006,9 +1135,9 @@
1006 1135 'title' => __('LearnPress', 'fluent-support'),
1007 1136 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/learn-press.png',
1008 1137 'is_integrated' => defined('LP_PLUGIN_FILE'),
1009 1138 'description' => __('Course and e-learning platform built for WordPress', 'fluent-support'),
1010 - 'doc_url' => 'https://fluentsupport.com/docs/learnpress-integration/',
1139 + 'doc_url' => 'https://docs.fluentsupport.com/learnpress-integration',
1011 1140 ],
1012 1141 'google-drive' => [
1013 1142 'title' => __('Google Drive', 'fluent-support'),
1014 1143 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/google-drive.jpeg',
@@ -1013,9 +1142,9 @@
1013 1142 'title' => __('Google Drive', 'fluent-support'),
1014 1143 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/google-drive.jpeg',
1015 1144 'is_integrated' => self::getFSIntegrationStatus('google_drive_settings'),
1016 1145 'description' => __('A cloud storage service by Google for storing, syncing, and sharing files.', 'fluent-support'),
1017 - 'doc_url' => 'https://fluentsupport.com/docs/google-drive-integration/'
1146 + 'doc_url' => 'https://docs.fluentsupport.com/google-drive-integration'
1018 1147 ],
1019 1148 'dropbox' => [
1020 1149 'title' => __('Dropbox', 'fluent-support'),
1021 1150 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/dropbox.png',
@@ -1020,9 +1149,9 @@
1020 1149 'title' => __('Dropbox', 'fluent-support'),
1021 1150 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/dropbox.png',
1022 1151 'is_integrated' => self::getFSIntegrationStatus('dropbox_settings'),
1023 1152 'description' => __('A cloud-based file storage and sharing service that allows users to store files online and sync them across devices.', 'fluent-support'),
1024 - 'doc_url' => 'https://fluentsupport.com/docs/dropbox-integration/',
1153 + 'doc_url' => 'https://docs.fluentsupport.com/dropbox-integration',
1025 1154 ],
1026 1155 'member-press' => [
1027 1156 'title' => __('MemberPress', 'fluent-support'),
1028 1157 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/member-press.png',
@@ -1027,9 +1156,9 @@
1027 1156 'title' => __('MemberPress', 'fluent-support'),
1028 1157 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/member-press.png',
1029 1158 'is_integrated' => class_exists('MeprUtils'),
1030 1159 'description' => __('A WordPress plugin that enables the creation and management of membership sites, including content access control and subscription billing.', 'fluent-support'),
1031 - 'doc_url' => 'https://fluentsupport.com/docs/memberpress-integration/'
1160 + 'doc_url' => 'https://docs.fluentsupport.com/memberpress-integration'
1032 1161 ],
1033 1162 'google-recaptcha' => [
1034 1163 'title' => __('Google reCAPTCHA', 'fluent-support'),
1035 1164 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/google-recaptcha.png',
@@ -1034,9 +1163,9 @@
1034 1163 'title' => __('Google reCAPTCHA', 'fluent-support'),
1035 1164 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/google-recaptcha.png',
1036 1165 'is_integrated' => self::getFSIntegrationStatus('recaptcha_setting'),
1037 1166 'description' => __('A security service by Google designed to protect websites from bots and abuse by using challenges to distinguish between human and automated access.', 'fluent-support'),
1038 - 'doc_url' => 'https://fluentsupport.com/docs/google-recaptcha-integration/',
1167 + 'doc_url' => 'https://docs.fluentsupport.com/google-recaptcha-integration',
1039 1168 ],
1040 1169 'fluent-boards' => [
1041 1170 'title' => __('FluentBoards', 'fluent-support'),
1042 1171 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/fluent-boards.png',
@@ -1041,10 +1170,17 @@
1041 1170 'title' => __('FluentBoards', 'fluent-support'),
1042 1171 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/fluent-boards.png',
1043 1172 'is_integrated' => defined('FLUENT_BOARDS'),
1044 1173 'description' => __('A project management tool designed to streamline workflows and collaboration through customizable, kanban-style boards.', 'fluent-support'),
1045 - 'doc_url' => '',
1174 + 'doc_url' => 'https://docs.fluentsupport.com/fluentboards-integrations#fluentboards-integration',
1046 1175 ],
1176 + 'fluent-booking' => [
1177 + 'title' => __('FluentBooking', 'fluent-support'),
1178 + 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/fluent-booking.svg',
1179 + 'is_integrated' => defined('FLUENT_BOOKING_VERSION'),
1180 + 'description' => __('Appointment and booking management plugin for WordPress', 'fluent-support'),
1181 + 'doc_url' => 'https://docs.fluentsupport.com/fluentbooking-integration',
1182 + ],
1047 1183 ];
1048 1184
1049 1185 return $connections;
1050 1186 }
@@ -1102,8 +1238,13 @@
1102 1238 'route_name' => 'upload_integration',
1103 1239 'icon' => 'fileUpload',
1104 1240 ],
1105 1241 [
1242 + 'title' => __('Remote Portal', 'fluent-support'),
1243 + 'route_name' => 'remote_portal',
1244 + 'icon' => 'remotePortal',
1245 + ],
1246 + [
1106 1247 'title' => __('Auto Close Settings', 'fluent-support'),
1107 1248 'route_name' => 'auto_close',
1108 1249 'icon' => 'autoClose',
1109 1250 ],
@@ -1122,11 +1263,20 @@
1122 1263 'route_name' => 'integration_statuses',
1123 1264 'icon' => 'status',
1124 1265 ],
1125 1266 [
1126 - 'title' => __('OpenAI Integration', 'fluent-support'),
1127 - 'route_name' => 'openai_integration',
1128 - 'icon' => 'aiIntegration',
1267 + 'title' => __('AI Integration', 'fluent-support'),
1268 + 'icon' => 'aiIntegration',
1269 + 'children' => [
1270 + [
1271 + 'title' => __('AI Model Setup', 'fluent-support'),
1272 + 'route_name' => 'ai_integration',
1273 + ],
1274 + [
1275 + 'title' => __('MCP for AI Agents', 'fluent-support'),
1276 + 'route_name' => 'mcp_settings',
1277 + ],
1278 + ],
1129 1279 ],
1130 1280 ];
1131 1281
1132 1282 if (defined('FLUENT_SUPPORT_PRO_DIR_FILE')) {
@@ -1169,10 +1319,10 @@
1169 1319 private static function checkNotificationIntegrationStatus($settingName)
1170 1320 {
1171 1321 $settings = self::getIntegrationOption($settingName, null);
1172 1322 if ($settings) {
1173 - $status = Arr::get($settings, 'status', false);
1174 - return $status ? true : false;
1323 + // status is stored as 'yes'/'no', and 'no' is truthy.
1324 + return Arr::get($settings, 'status') === 'yes';
1175 1325 }
1176 1326 return false;
1177 1327 }
1178 1328
@@ -1179,10 +1329,9 @@
1179 1329 private static function checkUploadDriverStatus($settingName)
1180 1330 {
1181 1331 $settings = self::getIntegrationOption($settingName, null);
1182 1332 if ($settings) {
1183 - $enabled = Arr::get($settings, 'status', false);
1184 - return $enabled ? true : false;
1333 + return Arr::get($settings, 'status') === 'yes';
1185 1334 }
1186 1335 return false;
1187 1336 }
1188 1337
@@ -1202,8 +1351,19 @@
1202 1351 {
1203 1352 $page = isset($data['page']) ? intval($data['page']) : 1;
1204 1353 $perPage = isset($data['per_page']) ? intval($data['per_page']) : 10;
1205 1354
1355 + if (!AIActivityLogs::tableExists()) {
1356 + return [
1357 + 'data' => [],
1358 + 'total' => 0,
1359 + 'per_page' => $perPage,
1360 + 'current_page' => $page,
1361 + 'last_page' => 1,
1362 + 'settings' => static::getSettings()['ai_activity_settings'],
1363 + ];
1364 + }
1365 +
1206 1366 $activitiesQuery = AIActivityLogs::with([
1207 1367 'person' => function ($query) {
1208 1368 $query->select(['first_name', 'person_type', 'last_name', 'id', 'avatar']);
1209 1369 },
@@ -1281,10 +1441,9 @@
1281 1441 ];
1282 1442 }
1283 1443
1284 1444 /**
1285 - * Check if activity logs are disabled for a given option key
1286 - * @param string $optionKey The option key to check
1445 + * @param string $optionKey
1287 1446 * @return bool
1288 1447 */
1289 1448 public static function areLogsDisabled($optionKey)
1290 1449 {
@@ -1308,9 +1467,9 @@
1308 1467 return $ipAddress;
1309 1468 }
1310 1469
1311 1470 if (empty($_SERVER['REMOTE_ADDR'])) {
1312 - // It's a local cli request
1471 + // CLI request.
1313 1472 return '127.0.0.1';
1314 1473 }
1315 1474
1316 1475 $ipAddress = '';
@@ -1316,18 +1475,16 @@
1316 1475 $ipAddress = '';
1317 1476 $remoteAddr = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : '';
1318 1477
1319 1478 if (isset($_SERVER["HTTP_CF_CONNECTING_IP"])) {
1320 - //If it's a valid Cloudflare request
1479 + // Only trust CF-Connecting-IP when the request really came from Cloudflare.
1321 1480 if (self::isCfIp($remoteAddr)) {
1322 - //Use the CF-Connecting-IP header.
1323 1481 $ipAddress = sanitize_text_field(wp_unslash($_SERVER['HTTP_CF_CONNECTING_IP']));
1324 1482 } else {
1325 - //If it isn't valid, then use REMOTE_ADDR.
1326 1483 $ipAddress = $remoteAddr;
1327 1484 }
1328 1485 } else if ($remoteAddr == '127.0.0.1') {
1329 - // most probably it's local reverse proxy
1486 + // Likely a local reverse proxy.
1330 1487 if (isset($_SERVER["HTTP_CLIENT_IP"])) {
1331 1488 $ipAddress = sanitize_text_field(wp_unslash($_SERVER["HTTP_CLIENT_IP"]));
1332 1489 } else if (isset($_SERVER['HTTP_X_FORWARDED_FOR'])) {
1333 1490 $forwardedFor = sanitize_text_field(wp_unslash($_SERVER['HTTP_X_FORWARDED_FOR']));
@@ -1355,8 +1512,51 @@
1355 1512
1356 1513 return $ipAddress;
1357 1514 }
1358 1515
1516 + /**
1517 + * Count this request against $rateLimitKey and return true if the limit is exceeded.
1518 + *
1519 + * With a persistent object cache the increment is atomic. Without one it falls
1520 + * back to a best-effort transient read/write.
1521 + */
1522 + public static function hitRateLimit($rateLimitKey, $limit, $window = null)
1523 + {
1524 + $window = $window ?: 15 * MINUTE_IN_SECONDS;
1525 +
1526 + if (wp_using_ext_object_cache()) {
1527 + $group = 'fs_rate_limit';
1528 + if (false === wp_cache_get($rateLimitKey, $group)) {
1529 + wp_cache_add($rateLimitKey, 0, $group, $window);
1530 + }
1531 + $attempts = wp_cache_incr($rateLimitKey, 1, $group);
1532 +
1533 + // Fail closed if the cache couldn't increment.
1534 + if ($attempts === false) {
1535 + return true;
1536 + }
1537 +
1538 + return $attempts > $limit;
1539 + }
1540 +
1541 + $now = time();
1542 + $record = get_transient($rateLimitKey);
1543 +
1544 + // Keep `<=`: it guarantees the TTL below is at least 1. A TTL of 0 makes
1545 + // set_transient() never expire, which would lock the limiter forever.
1546 + if (!is_array($record) || empty($record['expires']) || $record['expires'] <= $now) {
1547 + $record = ['count' => 0, 'expires' => $now + $window];
1548 + }
1549 +
1550 + $record['count']++;
1551 +
1552 + // Use the time left, not $window. set_transient() resets expiry on every call,
1553 + // so a full window would let ongoing traffic keep the limiter tripped.
1554 + set_transient($rateLimitKey, $record, $record['expires'] - $now);
1555 +
1556 + return $record['count'] > $limit;
1557 + }
1558 +
1359 1559 /** @internal Not called from core controllers — available for Pro/hook usage. */
1360 1560 public static function isCfIp($ip = '')
1361 1561 {
1362 1562 if (!$ip) {
@@ -1379,13 +1579,10 @@
1379 1579 '197.234.240.0/22',
1380 1580 '198.41.128.0/17'
1381 1581 );
1382 1582 $validCFRequest = false;
1383 - //Make sure that the request came via Cloudflare.
1384 1583 foreach ($cloudflareIPRanges as $range) {
1385 - //Use the ip_in_range function from Joomla.
1386 1584 if (self::ipInRange($ip, $range)) {
1387 - //IP is valid. Belongs to Cloudflare.
1388 1585 return true;
1389 1586 }
1390 1587 }
1391 1588
@@ -1463,8 +1660,39 @@
1463 1660 $settings = Helper::getOption('_ticket_form_settings', []);
1464 1661 return Arr::get($settings, 'product_required_field') === 'yes';
1465 1662 }
1466 1663
1664 + /**
1665 + * Upgrade/pricing link with UTM params. The admin SPA doesn't build its own;
1666 + * it swaps utm_content on the URL localized into appVars ($upgradeUrl in start.js).
1667 + *
1668 + * @param string $content The utm_content placement.
1669 + * @param array $args Optional: 'campaign', 'base_url', plus any utm_* override.
1670 + * @return string
1671 + */
1672 + public static function getUpgradeUrl($content = 'upgrade_page', $args = [])
1673 + {
1674 + $args = wp_parse_args($args, [
1675 + 'campaign' => 'upgrade_pro',
1676 + 'base_url' => apply_filters('fluent_support/pro_upgrade_base_url', 'https://fluentsupport.com/pricing'),
1677 + ]);
1678 +
1679 + $params = [
1680 + 'utm_source' => 'fluent-support',
1681 + // Keep in sync with appVars.has_pro in Menu.php.
1682 + 'utm_medium' => defined('FLUENTSUPPORTPRO_PLUGIN_VERSION') ? 'pro_plugin' : 'free_plugin',
1683 + 'utm_campaign' => $args['campaign'],
1684 + 'utm_content' => $content,
1685 + 'utm_term' => FLUENT_SUPPORT_VERSION,
1686 + ];
1687 +
1688 + $params = array_filter($params, function ($value) {
1689 + return $value !== '' && $value !== null;
1690 + });
1691 +
1692 + return add_query_arg($params, $args['base_url']);
1693 + }
1694 +
1467 1695 /** @internal Not called from core controllers — available for Pro/hook usage. */
1468 1696 public static function getBusinessBox()
1469 1697 {
1470 1698 $businessEmailBoxes = MailBox::select(['id', 'name', 'email', 'mapped_email'])
@@ -1474,27 +1702,21 @@
1474 1702 }
1475 1703
1476 1704 public static function tempImageMoveUploadDir($ticketId, $contentType, $replyId = null, $personId = null)
1477 1705 {
1478 - // Fetch content based on the content type
1479 1706 $content = self::getContentByType($ticketId, $contentType , $replyId);
1480 1707 if (empty($content)) {
1481 1708 return;
1482 1709 }
1483 1710
1484 - // Extract image URLs from the content
1485 1711 $imageUrls = self::extractImageUrls($content);
1486 1712 if (empty($imageUrls)) {
1487 1713 return;
1488 1714 }
1489 1715
1490 - // Move images to the upload directory and update content
1491 1716 self::moveImagesAndUpdateContent($imageUrls, $ticketId, $contentType, $content, $replyId, $personId);
1492 1717 }
1493 1718
1494 - /**
1495 - * Fetch content based on the content type.
1496 - */
1497 1719 private static function getContentByType($ticketId, $contentType, $replyId)
1498 1720 {
1499 1721 if ($contentType == 'ticket-create') {
1500 1722 $ticket = Ticket::find($ticketId);
@@ -1504,11 +1726,8 @@
1504 1726 $conversation = Conversation::find($replyId);
1505 1727 return $conversation ? $conversation->content : null;
1506 1728 }
1507 1729
1508 - /**
1509 - * Extract image URLs from the content.
1510 - */
1511 1730 private static function extractImageUrls($content)
1512 1731 {
1513 1732 preg_match_all('/<img[^>]+src=(["\'])(.*?)\1/i', (string) $content, $matches);
1514 1733 return $matches[2] ?? [];
@@ -1514,13 +1733,12 @@
1514 1733 return $matches[2] ?? [];
1515 1734 }
1516 1735
1517 1736 /**
1518 - * Move images to the upload directory and update content.
1737 + * Move temp images into the ticket's folder and rewrite their URLs in the content.
1519 1738 */
1520 1739 private static function moveImagesAndUpdateContent($imageUrls, $ticketId, $contentType, $content, $replyId, $personId)
1521 1740 {
1522 - // Get the current site's upload directory
1523 1741 $uploadDirInfo = wp_upload_dir();
1524 1742 $uploadsDir = $uploadDirInfo['basedir'];
1525 1743 $tempDir = $uploadsDir . '/fluent-support/temp_files/';
1526 1744 $signedAttachments = self::getSignedImageAttachments($imageUrls, $ticketId, $personId);
@@ -1531,28 +1749,22 @@
1531 1749 $content = str_replace($imageUrl, $referenceUrl, $content);
1532 1750 continue;
1533 1751 }
1534 1752
1535 - // Build the absolute path for the temporary file
1536 1753 $imageRelativePath = $tempDir . basename($imageUrl);
1537 1754 $absolutePath = $imageRelativePath;
1538 1755
1539 - // Move the file to the ticket-specific folder
1540 1756 $newFileInfo = UploadService::copyFileTicketFolder($absolutePath, $ticketId);
1541 1757
1542 - // Check if the move was successful
1543 1758 if (empty($newFileInfo['file_path'])) {
1544 - continue; // Skip if the file couldn't be copied
1759 + continue;
1545 1760 }
1546 1761
1547 - // Ensure the new URL is correctly constructed
1548 1762 $newFileInfo['url'] = trailingslashit($uploadDirInfo['baseurl']) . 'fluent-support/ticket_' . $ticketId . '/' . basename($newFileInfo['file_path']);
1549 1763
1550 - // Replace the old URL with the new one in the content
1551 1764 $content = str_replace($imageUrl, $newFileInfo['url'], $content);
1552 1765 }
1553 1766
1554 - // Save the updated content
1555 1767 self::saveUpdatedContent($ticketId, $contentType, $content, $replyId);
1556 1768 }
1557 1769
1558 1770 private static function getSignedImageAttachments($imageUrls, $ticketId, $personId)
@@ -1712,11 +1924,8 @@
1712 1924
1713 1925 return $contents;
1714 1926 }
1715 1927
1716 - /**
1717 - * Save the updated content based on the content type.
1718 - */
1719 1928 private static function saveUpdatedContent($ticketId, $contentType, $content, $replyId)
1720 1929 {
1721 1930 if ($contentType == 'ticket-create') {
1722 1931 $ticket = Ticket::find($ticketId);
@@ -1733,12 +1942,10 @@
1733 1942 }
1734 1943 }
1735 1944
1736 1945 /**
1737 - * Throw a ValidationException with a safe error message.
1738 - * In debug mode the real exception message is used; in production a generic message is returned.
1739 - * Throwing instead of returning ensures the error bypasses the framework's HTTP exception
1740 - * wrapper (which would prepend a status-code prefix to the message).
1946 + * Throw a ValidationException carrying the exception's message, or a generic one if empty.
1947 + * Throwing skips the framework's HTTP exception wrapper, which would prefix a status code.
1741 1948 *
1742 1949 * @param \Throwable $e The original exception.
1743 1950 * @throws \FluentSupport\Framework\Validator\ValidationException Always thrown.
1744 1951 */
@@ -1745,27 +1952,33 @@
1745 1952 public static function getSafeErrorMessage($e)
1746 1953 {
1747 1954 $message = $e->getMessage() ?: __('Something went wrong. Please try again later.', 'fluent-support');
1748 1955
1749 - // ValidationException carries the message through the framework's JSON error
1750 - // handler — it is never echoed directly. $e is the chained previous exception,
1751 - // not output. phpcs:disable covers the multi-line constructor call.
1956 + // Missing records are 404 and an exception thrown with 401/403/404/409
1957 + // keeps that status; anything else is a 422 as before. The body is the same.
1958 + $status = 422;
1959 + if ($e instanceof \FluentSupport\Framework\Database\Orm\ModelNotFoundException) {
1960 + $status = 404;
1961 + } elseif (in_array((int) $e->getCode(), [401, 403, 404, 409], true)) {
1962 + $status = (int) $e->getCode();
1963 + }
1964 +
1965 + // The message goes out through the framework's JSON error handler, never echoed.
1966 + // phpcs:disable covers the multi-line constructor call.
1752 1967 // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped
1753 1968 throw new \FluentSupport\Framework\Validator\ValidationException(
1754 - '', 422, ($e instanceof \Exception ? $e : null), ['message' => $message]
1969 + '', $status, ($e instanceof \Exception ? $e : null), ['message' => $message]
1755 1970 );
1756 1971 // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
1757 1972 }
1758 1973
1759 1974 /**
1760 - * Safely unserialize data.
1761 - *
1762 1975 * @param string $data The serialized data.
1763 1976 * @return mixed The unserialized data or the original data if not serialized.
1764 1977 */
1765 1978 public static function safeUnserialize($data)
1766 1979 {
1767 - if (is_serialized($data)) { // Don't attempt to unserialize data that wasn't serialized going in.
1980 + if (is_serialized($data)) {
1768 1981 return @unserialize(trim($data), ['allowed_classes' => false]);
1769 1982 }
1770 1983
1771 1984 return $data;