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 +229 -122 2.3.0 → 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 {
@@ -498,9 +473,8 @@
498 473 return $baseUrl . 'tickets/' . $ticket->id . '/view';
499 474 }
500 475
501 476 /**
502 - * getPortalBaseUrl will get the portal page id and return link of the page
503 477 * @return mixed
504 478 */
505 479 public static function getPortalBaseUrl()
506 480 {
@@ -549,28 +523,94 @@
549 523 {
550 524 return apply_filters('fluent_support/portal_admin_base_url', admin_url('admin.php?page=fluent-support/#/'));
551 525 }
552 526
553 - 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)
554 536 {
555 537 static $settings;
556 538
557 - if ($settings && $key) {
558 - return Arr::get($settings, $key);
539 + if (!$settings) {
540 + $settings = (new Settings())->globalBusinessSettings();
559 541 }
560 542
561 - if ($settings) {
562 - return $settings;
543 + if ($key) {
544 + return Arr::get($settings, $key, $default);
563 545 }
564 546
565 - $settings = (new Settings())->globalBusinessSettings();
547 + return $settings;
548 + }
566 549
567 - if ($key) {
568 - 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 [];
569 586 }
570 - return $settings;
587 +
588 + return array_values(array_filter($keys, function ($key) {
589 + return is_string($key) && $key !== '' && !self::isProtectedUserMetaKey($key);
590 + }));
571 591 }
572 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 +
573 613 public static function isAgentFeedbackEnabled()
574 614 {
575 615 return self::getBusinessSettings('agent_feedback_rating', 'no') == 'yes';
576 616 }
@@ -645,11 +685,8 @@
645 685 }
646 686
647 687 public static function getCurrentAgent()
648 688 {
649 - // If user is logged in then return the agent by user id.
650 - // This `get_current_user_id` function is WP function and
651 - // it returns user id if user is logged in.
652 689 if (get_current_user_id()) {
653 690 return Agent::where('user_id', get_current_user_id())->first();
654 691 }
655 692 }
@@ -655,21 +692,19 @@
655 692 }
656 693
657 694 public static function getCurrentCustomer()
658 695 {
659 - // If user is logged in then return the customer by user id.
660 - // This `get_current_user_id` function is WP function and
661 - // it returns user id if user is logged in.
662 - if (get_current_user_id()) { //if user is logged in
663 - 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();
664 702 }
665 703 }
666 704
667 705 public static function getCurrentPerson()
668 706 {
669 - // If user is logged in then return the person(agent/customer) by user id.
670 - // This `get_current_user_id` function is WP function and
671 - // it returns user id if user is logged in.
672 707 if (get_current_user_id()) {
673 708 return Person::where('user_id', get_current_user_id())
674 709 ->orderBy('id', 'ASC')
675 710 ->first();
@@ -729,10 +764,9 @@
729 764 return $crmConfigs;
730 765 }
731 766
732 767 /**
733 - * getFluentCrmContactData method will get information from fluent crm using user email
734 - * @param $customer
768 + * @param Customer $customer
735 769 * @return array|false
736 770 */
737 771 public static function getFluentCrmContactData($customer)
738 772 {
@@ -738,9 +772,8 @@
738 772 {
739 773 if (!defined('FLUENTCRM')) {
740 774 return false;
741 775 }
742 - //Get contact info from FluentCRM using customer email
743 776 $contact = \FluentCrmApi('contacts')->getContactByUserRef($customer->email);
744 777 if ($contact) {
745 778 $tags = $contact->tags;
746 779 $lists = $contact->lists;
@@ -746,9 +779,8 @@
746 779 $lists = $contact->lists;
747 780 $urlBase = apply_filters('fluentcrm_menu_url_base', admin_url('admin.php?page=fluentcrm-admin#/'));
748 781 $crmProfileUrl = $urlBase . 'subscribers/' . $contact->id;
749 782
750 - //Return contact data
751 783 return [
752 784 'id' => $contact->id,
753 785 'first_name' => $contact->first_name,
754 786 'last_name' => $contact->last_name,
@@ -777,11 +809,9 @@
777 809 }
778 810
779 811 public static function fluentBotIntegrationStatus()
780 812 {
781 - // Include object_id and order by id desc to always read the latest row; the
782 - // write path (saveFluentBotSettings) does not prune siblings, so duplicates
783 - // may exist and a non-deterministic read can return stale state.
813 + // saveFluentBotSettings() doesn't prune old rows, so read the newest one.
784 814 $meta = Meta::where([
785 815 'object_type' => 'fluent_bot_settings',
786 816 'object_id' => 1,
787 817 'key' => '_fs_fluent_bot_config'
@@ -854,9 +884,9 @@
854 884 public static function getAuthProvider()
855 885 {
856 886 if (defined('FLUENT_AUTH_PLUGIN_PATH')) {
857 887 $settings = \FluentAuth\App\Helpers\Helper::getAuthFormsSettings();
858 - if ($settings['enabled'] == 'yes') {
888 + if ($settings['enabled'] == 'yes' || FluentAuthBridge::shouldAdopt()) {
859 889 return 'fluent_auth';
860 890 }
861 891 }
862 892
@@ -862,8 +892,22 @@
862 892
863 893 return 'fluent_support';
864 894 }
865 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 +
866 910 /** @internal Not called from core controllers — available for Pro/hook usage. */
867 911 public static function getDriversKey(){
868 912 return [
869 913 'dropbox_settings',
@@ -886,11 +930,9 @@
886 930 if ($driver) {
887 931 return $driver;
888 932 }
889 933
890 - // Now guess the driver and save it
891 -
892 - // check if dropbox is enabled
934 + // No driver saved yet: use the first enabled one and remember it.
893 935 $dropboxSettings = self::getIntegrationOption('dropbox_settings', null);
894 936 if ($dropboxSettings) {
895 937 $dropBoxEnabled = Meta::where('object_type', 'enabled_upload_drivers')
896 938 ->where('key', 'dropbox_settings')
@@ -903,9 +945,8 @@
903 945 return $driver;
904 946 }
905 947 }
906 948
907 - // check if google drive is enabled
908 949 $googleDriveSettings = self::getIntegrationOption('google_drive_settings', null);
909 950
910 951 if ($googleDriveSettings) {
911 952 $googleDriveEnabled = Meta::where('object_type', 'enabled_upload_drivers')
@@ -919,9 +960,8 @@
919 960 return $driver;
920 961 }
921 962 }
922 963
923 - // check if cloudflare r2 is enabled
924 964 $cloudflareR2Settings = self::getIntegrationOption('cloudflare_r2_settings', null);
925 965
926 966 if ($cloudflareR2Settings) {
927 967 $cloudflareR2Enabled = Meta::where('object_type', 'enabled_upload_drivers')
@@ -935,9 +975,8 @@
935 975 return $driver;
936 976 }
937 977 }
938 978
939 - // check if amazon s3 is enabled
940 979 $amazonS3Settings = self::getIntegrationOption('amazon_s3_settings', null);
941 980
942 981 if ($amazonS3Settings) {
943 982 $amazonS3Enabled = Meta::where('object_type', 'enabled_upload_drivers')
@@ -1131,9 +1170,9 @@
1131 1170 'title' => __('FluentBoards', 'fluent-support'),
1132 1171 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/fluent-boards.png',
1133 1172 'is_integrated' => defined('FLUENT_BOARDS'),
1134 1173 'description' => __('A project management tool designed to streamline workflows and collaboration through customizable, kanban-style boards.', 'fluent-support'),
1135 - 'doc_url' => '',
1174 + 'doc_url' => 'https://docs.fluentsupport.com/fluentboards-integrations#fluentboards-integration',
1136 1175 ],
1137 1176 'fluent-booking' => [
1138 1177 'title' => __('FluentBooking', 'fluent-support'),
1139 1178 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/fluent-booking.svg',
@@ -1199,8 +1238,13 @@
1199 1238 'route_name' => 'upload_integration',
1200 1239 'icon' => 'fileUpload',
1201 1240 ],
1202 1241 [
1242 + 'title' => __('Remote Portal', 'fluent-support'),
1243 + 'route_name' => 'remote_portal',
1244 + 'icon' => 'remotePortal',
1245 + ],
1246 + [
1203 1247 'title' => __('Auto Close Settings', 'fluent-support'),
1204 1248 'route_name' => 'auto_close',
1205 1249 'icon' => 'autoClose',
1206 1250 ],
@@ -1275,10 +1319,10 @@
1275 1319 private static function checkNotificationIntegrationStatus($settingName)
1276 1320 {
1277 1321 $settings = self::getIntegrationOption($settingName, null);
1278 1322 if ($settings) {
1279 - $status = Arr::get($settings, 'status', false);
1280 - return $status ? true : false;
1323 + // status is stored as 'yes'/'no', and 'no' is truthy.
1324 + return Arr::get($settings, 'status') === 'yes';
1281 1325 }
1282 1326 return false;
1283 1327 }
1284 1328
@@ -1285,10 +1329,9 @@
1285 1329 private static function checkUploadDriverStatus($settingName)
1286 1330 {
1287 1331 $settings = self::getIntegrationOption($settingName, null);
1288 1332 if ($settings) {
1289 - $enabled = Arr::get($settings, 'status', false);
1290 - return $enabled ? true : false;
1333 + return Arr::get($settings, 'status') === 'yes';
1291 1334 }
1292 1335 return false;
1293 1336 }
1294 1337
@@ -1308,8 +1351,19 @@
1308 1351 {
1309 1352 $page = isset($data['page']) ? intval($data['page']) : 1;
1310 1353 $perPage = isset($data['per_page']) ? intval($data['per_page']) : 10;
1311 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 +
1312 1366 $activitiesQuery = AIActivityLogs::with([
1313 1367 'person' => function ($query) {
1314 1368 $query->select(['first_name', 'person_type', 'last_name', 'id', 'avatar']);
1315 1369 },
@@ -1387,10 +1441,9 @@
1387 1441 ];
1388 1442 }
1389 1443
1390 1444 /**
1391 - * Check if activity logs are disabled for a given option key
1392 - * @param string $optionKey The option key to check
1445 + * @param string $optionKey
1393 1446 * @return bool
1394 1447 */
1395 1448 public static function areLogsDisabled($optionKey)
1396 1449 {
@@ -1414,9 +1467,9 @@
1414 1467 return $ipAddress;
1415 1468 }
1416 1469
1417 1470 if (empty($_SERVER['REMOTE_ADDR'])) {
1418 - // It's a local cli request
1471 + // CLI request.
1419 1472 return '127.0.0.1';
1420 1473 }
1421 1474
1422 1475 $ipAddress = '';
@@ -1422,18 +1475,16 @@
1422 1475 $ipAddress = '';
1423 1476 $remoteAddr = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : '';
1424 1477
1425 1478 if (isset($_SERVER["HTTP_CF_CONNECTING_IP"])) {
1426 - //If it's a valid Cloudflare request
1479 + // Only trust CF-Connecting-IP when the request really came from Cloudflare.
1427 1480 if (self::isCfIp($remoteAddr)) {
1428 - //Use the CF-Connecting-IP header.
1429 1481 $ipAddress = sanitize_text_field(wp_unslash($_SERVER['HTTP_CF_CONNECTING_IP']));
1430 1482 } else {
1431 - //If it isn't valid, then use REMOTE_ADDR.
1432 1483 $ipAddress = $remoteAddr;
1433 1484 }
1434 1485 } else if ($remoteAddr == '127.0.0.1') {
1435 - // most probably it's local reverse proxy
1486 + // Likely a local reverse proxy.
1436 1487 if (isset($_SERVER["HTTP_CLIENT_IP"])) {
1437 1488 $ipAddress = sanitize_text_field(wp_unslash($_SERVER["HTTP_CLIENT_IP"]));
1438 1489 } else if (isset($_SERVER['HTTP_X_FORWARDED_FOR'])) {
1439 1490 $forwardedFor = sanitize_text_field(wp_unslash($_SERVER['HTTP_X_FORWARDED_FOR']));
@@ -1461,8 +1512,51 @@
1461 1512
1462 1513 return $ipAddress;
1463 1514 }
1464 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 +
1465 1559 /** @internal Not called from core controllers — available for Pro/hook usage. */
1466 1560 public static function isCfIp($ip = '')
1467 1561 {
1468 1562 if (!$ip) {
@@ -1485,13 +1579,10 @@
1485 1579 '197.234.240.0/22',
1486 1580 '198.41.128.0/17'
1487 1581 );
1488 1582 $validCFRequest = false;
1489 - //Make sure that the request came via Cloudflare.
1490 1583 foreach ($cloudflareIPRanges as $range) {
1491 - //Use the ip_in_range function from Joomla.
1492 1584 if (self::ipInRange($ip, $range)) {
1493 - //IP is valid. Belongs to Cloudflare.
1494 1585 return true;
1495 1586 }
1496 1587 }
1497 1588
@@ -1569,8 +1660,39 @@
1569 1660 $settings = Helper::getOption('_ticket_form_settings', []);
1570 1661 return Arr::get($settings, 'product_required_field') === 'yes';
1571 1662 }
1572 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 +
1573 1695 /** @internal Not called from core controllers — available for Pro/hook usage. */
1574 1696 public static function getBusinessBox()
1575 1697 {
1576 1698 $businessEmailBoxes = MailBox::select(['id', 'name', 'email', 'mapped_email'])
@@ -1580,27 +1702,21 @@
1580 1702 }
1581 1703
1582 1704 public static function tempImageMoveUploadDir($ticketId, $contentType, $replyId = null, $personId = null)
1583 1705 {
1584 - // Fetch content based on the content type
1585 1706 $content = self::getContentByType($ticketId, $contentType , $replyId);
1586 1707 if (empty($content)) {
1587 1708 return;
1588 1709 }
1589 1710
1590 - // Extract image URLs from the content
1591 1711 $imageUrls = self::extractImageUrls($content);
1592 1712 if (empty($imageUrls)) {
1593 1713 return;
1594 1714 }
1595 1715
1596 - // Move images to the upload directory and update content
1597 1716 self::moveImagesAndUpdateContent($imageUrls, $ticketId, $contentType, $content, $replyId, $personId);
1598 1717 }
1599 1718
1600 - /**
1601 - * Fetch content based on the content type.
1602 - */
1603 1719 private static function getContentByType($ticketId, $contentType, $replyId)
1604 1720 {
1605 1721 if ($contentType == 'ticket-create') {
1606 1722 $ticket = Ticket::find($ticketId);
@@ -1610,11 +1726,8 @@
1610 1726 $conversation = Conversation::find($replyId);
1611 1727 return $conversation ? $conversation->content : null;
1612 1728 }
1613 1729
1614 - /**
1615 - * Extract image URLs from the content.
1616 - */
1617 1730 private static function extractImageUrls($content)
1618 1731 {
1619 1732 preg_match_all('/<img[^>]+src=(["\'])(.*?)\1/i', (string) $content, $matches);
1620 1733 return $matches[2] ?? [];
@@ -1620,13 +1733,12 @@
1620 1733 return $matches[2] ?? [];
1621 1734 }
1622 1735
1623 1736 /**
1624 - * 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.
1625 1738 */
1626 1739 private static function moveImagesAndUpdateContent($imageUrls, $ticketId, $contentType, $content, $replyId, $personId)
1627 1740 {
1628 - // Get the current site's upload directory
1629 1741 $uploadDirInfo = wp_upload_dir();
1630 1742 $uploadsDir = $uploadDirInfo['basedir'];
1631 1743 $tempDir = $uploadsDir . '/fluent-support/temp_files/';
1632 1744 $signedAttachments = self::getSignedImageAttachments($imageUrls, $ticketId, $personId);
@@ -1637,28 +1749,22 @@
1637 1749 $content = str_replace($imageUrl, $referenceUrl, $content);
1638 1750 continue;
1639 1751 }
1640 1752
1641 - // Build the absolute path for the temporary file
1642 1753 $imageRelativePath = $tempDir . basename($imageUrl);
1643 1754 $absolutePath = $imageRelativePath;
1644 1755
1645 - // Move the file to the ticket-specific folder
1646 1756 $newFileInfo = UploadService::copyFileTicketFolder($absolutePath, $ticketId);
1647 1757
1648 - // Check if the move was successful
1649 1758 if (empty($newFileInfo['file_path'])) {
1650 - continue; // Skip if the file couldn't be copied
1759 + continue;
1651 1760 }
1652 1761
1653 - // Ensure the new URL is correctly constructed
1654 1762 $newFileInfo['url'] = trailingslashit($uploadDirInfo['baseurl']) . 'fluent-support/ticket_' . $ticketId . '/' . basename($newFileInfo['file_path']);
1655 1763
1656 - // Replace the old URL with the new one in the content
1657 1764 $content = str_replace($imageUrl, $newFileInfo['url'], $content);
1658 1765 }
1659 1766
1660 - // Save the updated content
1661 1767 self::saveUpdatedContent($ticketId, $contentType, $content, $replyId);
1662 1768 }
1663 1769
1664 1770 private static function getSignedImageAttachments($imageUrls, $ticketId, $personId)
@@ -1818,11 +1924,8 @@
1818 1924
1819 1925 return $contents;
1820 1926 }
1821 1927
1822 - /**
1823 - * Save the updated content based on the content type.
1824 - */
1825 1928 private static function saveUpdatedContent($ticketId, $contentType, $content, $replyId)
1826 1929 {
1827 1930 if ($contentType == 'ticket-create') {
1828 1931 $ticket = Ticket::find($ticketId);
@@ -1839,12 +1942,10 @@
1839 1942 }
1840 1943 }
1841 1944
1842 1945 /**
1843 - * Throw a ValidationException with a safe error message.
1844 - * In debug mode the real exception message is used; in production a generic message is returned.
1845 - * Throwing instead of returning ensures the error bypasses the framework's HTTP exception
1846 - * 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.
1847 1948 *
1848 1949 * @param \Throwable $e The original exception.
1849 1950 * @throws \FluentSupport\Framework\Validator\ValidationException Always thrown.
1850 1951 */
@@ -1851,27 +1952,33 @@
1851 1952 public static function getSafeErrorMessage($e)
1852 1953 {
1853 1954 $message = $e->getMessage() ?: __('Something went wrong. Please try again later.', 'fluent-support');
1854 1955
1855 - // ValidationException carries the message through the framework's JSON error
1856 - // handler — it is never echoed directly. $e is the chained previous exception,
1857 - // 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.
1858 1967 // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped
1859 1968 throw new \FluentSupport\Framework\Validator\ValidationException(
1860 - '', 422, ($e instanceof \Exception ? $e : null), ['message' => $message]
1969 + '', $status, ($e instanceof \Exception ? $e : null), ['message' => $message]
1861 1970 );
1862 1971 // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
1863 1972 }
1864 1973
1865 1974 /**
1866 - * Safely unserialize data.
1867 - *
1868 1975 * @param string $data The serialized data.
1869 1976 * @return mixed The unserialized data or the original data if not serialized.
1870 1977 */
1871 1978 public static function safeUnserialize($data)
1872 1979 {
1873 - if (is_serialized($data)) { // Don't attempt to unserialize data that wasn't serialized going in.
1980 + if (is_serialized($data)) {
1874 1981 return @unserialize(trim($data), ['allowed_classes' => false]);
1875 1982 }
1876 1983
1877 1984 return $data;