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 +162 -162 2.4.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,19 +692,11 @@
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 - // Ordered for the same reason getCurrentPerson() and
664 - // Customer::getCustomerFromData() are: the user_id column carries a
665 - // plain index, not a unique one, so one account can have more than
666 - // one customer record. Without an order the record that wins is
667 - // whatever the storage engine returns first, and this helper could
668 - // disagree with the other two about which record a request belongs
669 - // to.
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().
670 699 return Customer::where('user_id', get_current_user_id())
671 700 ->orderBy('id', 'ASC')
672 701 ->first();
673 702 }
@@ -674,11 +703,8 @@
674 703 }
675 704
676 705 public static function getCurrentPerson()
677 706 {
678 - // If user is logged in then return the person(agent/customer) by user id.
679 - // This `get_current_user_id` function is WP function and
680 - // it returns user id if user is logged in.
681 707 if (get_current_user_id()) {
682 708 return Person::where('user_id', get_current_user_id())
683 709 ->orderBy('id', 'ASC')
684 710 ->first();
@@ -738,10 +764,9 @@
738 764 return $crmConfigs;
739 765 }
740 766
741 767 /**
742 - * getFluentCrmContactData method will get information from fluent crm using user email
743 - * @param $customer
768 + * @param Customer $customer
744 769 * @return array|false
745 770 */
746 771 public static function getFluentCrmContactData($customer)
747 772 {
@@ -747,9 +772,8 @@
747 772 {
748 773 if (!defined('FLUENTCRM')) {
749 774 return false;
750 775 }
751 - //Get contact info from FluentCRM using customer email
752 776 $contact = \FluentCrmApi('contacts')->getContactByUserRef($customer->email);
753 777 if ($contact) {
754 778 $tags = $contact->tags;
755 779 $lists = $contact->lists;
@@ -755,9 +779,8 @@
755 779 $lists = $contact->lists;
756 780 $urlBase = apply_filters('fluentcrm_menu_url_base', admin_url('admin.php?page=fluentcrm-admin#/'));
757 781 $crmProfileUrl = $urlBase . 'subscribers/' . $contact->id;
758 782
759 - //Return contact data
760 783 return [
761 784 'id' => $contact->id,
762 785 'first_name' => $contact->first_name,
763 786 'last_name' => $contact->last_name,
@@ -786,11 +809,9 @@
786 809 }
787 810
788 811 public static function fluentBotIntegrationStatus()
789 812 {
790 - // Include object_id and order by id desc to always read the latest row; the
791 - // write path (saveFluentBotSettings) does not prune siblings, so duplicates
792 - // may exist and a non-deterministic read can return stale state.
813 + // saveFluentBotSettings() doesn't prune old rows, so read the newest one.
793 814 $meta = Meta::where([
794 815 'object_type' => 'fluent_bot_settings',
795 816 'object_id' => 1,
796 817 'key' => '_fs_fluent_bot_config'
@@ -863,9 +884,9 @@
863 884 public static function getAuthProvider()
864 885 {
865 886 if (defined('FLUENT_AUTH_PLUGIN_PATH')) {
866 887 $settings = \FluentAuth\App\Helpers\Helper::getAuthFormsSettings();
867 - if ($settings['enabled'] == 'yes') {
888 + if ($settings['enabled'] == 'yes' || FluentAuthBridge::shouldAdopt()) {
868 889 return 'fluent_auth';
869 890 }
870 891 }
871 892
@@ -871,8 +892,22 @@
871 892
872 893 return 'fluent_support';
873 894 }
874 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 +
875 910 /** @internal Not called from core controllers — available for Pro/hook usage. */
876 911 public static function getDriversKey(){
877 912 return [
878 913 'dropbox_settings',
@@ -895,11 +930,9 @@
895 930 if ($driver) {
896 931 return $driver;
897 932 }
898 933
899 - // Now guess the driver and save it
900 -
901 - // check if dropbox is enabled
934 + // No driver saved yet: use the first enabled one and remember it.
902 935 $dropboxSettings = self::getIntegrationOption('dropbox_settings', null);
903 936 if ($dropboxSettings) {
904 937 $dropBoxEnabled = Meta::where('object_type', 'enabled_upload_drivers')
905 938 ->where('key', 'dropbox_settings')
@@ -912,9 +945,8 @@
912 945 return $driver;
913 946 }
914 947 }
915 948
916 - // check if google drive is enabled
917 949 $googleDriveSettings = self::getIntegrationOption('google_drive_settings', null);
918 950
919 951 if ($googleDriveSettings) {
920 952 $googleDriveEnabled = Meta::where('object_type', 'enabled_upload_drivers')
@@ -928,9 +960,8 @@
928 960 return $driver;
929 961 }
930 962 }
931 963
932 - // check if cloudflare r2 is enabled
933 964 $cloudflareR2Settings = self::getIntegrationOption('cloudflare_r2_settings', null);
934 965
935 966 if ($cloudflareR2Settings) {
936 967 $cloudflareR2Enabled = Meta::where('object_type', 'enabled_upload_drivers')
@@ -944,9 +975,8 @@
944 975 return $driver;
945 976 }
946 977 }
947 978
948 - // check if amazon s3 is enabled
949 979 $amazonS3Settings = self::getIntegrationOption('amazon_s3_settings', null);
950 980
951 981 if ($amazonS3Settings) {
952 982 $amazonS3Enabled = Meta::where('object_type', 'enabled_upload_drivers')
@@ -1208,8 +1238,13 @@
1208 1238 'route_name' => 'upload_integration',
1209 1239 'icon' => 'fileUpload',
1210 1240 ],
1211 1241 [
1242 + 'title' => __('Remote Portal', 'fluent-support'),
1243 + 'route_name' => 'remote_portal',
1244 + 'icon' => 'remotePortal',
1245 + ],
1246 + [
1212 1247 'title' => __('Auto Close Settings', 'fluent-support'),
1213 1248 'route_name' => 'auto_close',
1214 1249 'icon' => 'autoClose',
1215 1250 ],
@@ -1284,10 +1319,10 @@
1284 1319 private static function checkNotificationIntegrationStatus($settingName)
1285 1320 {
1286 1321 $settings = self::getIntegrationOption($settingName, null);
1287 1322 if ($settings) {
1288 - $status = Arr::get($settings, 'status', false);
1289 - return $status ? true : false;
1323 + // status is stored as 'yes'/'no', and 'no' is truthy.
1324 + return Arr::get($settings, 'status') === 'yes';
1290 1325 }
1291 1326 return false;
1292 1327 }
1293 1328
@@ -1294,10 +1329,9 @@
1294 1329 private static function checkUploadDriverStatus($settingName)
1295 1330 {
1296 1331 $settings = self::getIntegrationOption($settingName, null);
1297 1332 if ($settings) {
1298 - $enabled = Arr::get($settings, 'status', false);
1299 - return $enabled ? true : false;
1333 + return Arr::get($settings, 'status') === 'yes';
1300 1334 }
1301 1335 return false;
1302 1336 }
1303 1337
@@ -1317,8 +1351,19 @@
1317 1351 {
1318 1352 $page = isset($data['page']) ? intval($data['page']) : 1;
1319 1353 $perPage = isset($data['per_page']) ? intval($data['per_page']) : 10;
1320 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 +
1321 1366 $activitiesQuery = AIActivityLogs::with([
1322 1367 'person' => function ($query) {
1323 1368 $query->select(['first_name', 'person_type', 'last_name', 'id', 'avatar']);
1324 1369 },
@@ -1396,10 +1441,9 @@
1396 1441 ];
1397 1442 }
1398 1443
1399 1444 /**
1400 - * Check if activity logs are disabled for a given option key
1401 - * @param string $optionKey The option key to check
1445 + * @param string $optionKey
1402 1446 * @return bool
1403 1447 */
1404 1448 public static function areLogsDisabled($optionKey)
1405 1449 {
@@ -1423,9 +1467,9 @@
1423 1467 return $ipAddress;
1424 1468 }
1425 1469
1426 1470 if (empty($_SERVER['REMOTE_ADDR'])) {
1427 - // It's a local cli request
1471 + // CLI request.
1428 1472 return '127.0.0.1';
1429 1473 }
1430 1474
1431 1475 $ipAddress = '';
@@ -1431,18 +1475,16 @@
1431 1475 $ipAddress = '';
1432 1476 $remoteAddr = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : '';
1433 1477
1434 1478 if (isset($_SERVER["HTTP_CF_CONNECTING_IP"])) {
1435 - //If it's a valid Cloudflare request
1479 + // Only trust CF-Connecting-IP when the request really came from Cloudflare.
1436 1480 if (self::isCfIp($remoteAddr)) {
1437 - //Use the CF-Connecting-IP header.
1438 1481 $ipAddress = sanitize_text_field(wp_unslash($_SERVER['HTTP_CF_CONNECTING_IP']));
1439 1482 } else {
1440 - //If it isn't valid, then use REMOTE_ADDR.
1441 1483 $ipAddress = $remoteAddr;
1442 1484 }
1443 1485 } else if ($remoteAddr == '127.0.0.1') {
1444 - // most probably it's local reverse proxy
1486 + // Likely a local reverse proxy.
1445 1487 if (isset($_SERVER["HTTP_CLIENT_IP"])) {
1446 1488 $ipAddress = sanitize_text_field(wp_unslash($_SERVER["HTTP_CLIENT_IP"]));
1447 1489 } else if (isset($_SERVER['HTTP_X_FORWARDED_FOR'])) {
1448 1490 $forwardedFor = sanitize_text_field(wp_unslash($_SERVER['HTTP_X_FORWARDED_FOR']));
@@ -1471,17 +1513,12 @@
1471 1513 return $ipAddress;
1472 1514 }
1473 1515
1474 1516 /**
1475 - * Atomically increments the counter for $rateLimitKey and reports whether the
1476 - * limit is now exceeded. On sites with a persistent external object cache
1477 - * (Redis/Memcached), wp_cache_incr() is a real atomic increment, so concurrent
1478 - * requests can't race past the limit. Without one, this falls back to a plain
1479 - * transient read/write (same best-effort behavior as the rest of this codebase's
1480 - * rate limiters, e.g. AuthController::incrementLoginAttempts).
1517 + * Count this request against $rateLimitKey and return true if the limit is exceeded.
1481 1518 *
1482 - * Note the counter is incremented before it is compared, so the returned value
1483 - * already accounts for the current request.
1519 + * With a persistent object cache the increment is atomic. Without one it falls
1520 + * back to a best-effort transient read/write.
1484 1521 */
1485 1522 public static function hitRateLimit($rateLimitKey, $limit, $window = null)
1486 1523 {
1487 1524 $window = $window ?: 15 * MINUTE_IN_SECONDS;
@@ -1492,10 +1529,9 @@
1492 1529 wp_cache_add($rateLimitKey, 0, $group, $window);
1493 1530 }
1494 1531 $attempts = wp_cache_incr($rateLimitKey, 1, $group);
1495 1532
1496 - // Fail closed: if the cache backend couldn't increment (evicted key, hiccup),
1497 - // treat the request as rate-limited rather than silently letting it through.
1533 + // Fail closed if the cache couldn't increment.
1498 1534 if ($attempts === false) {
1499 1535 return true;
1500 1536 }
1501 1537
@@ -1504,16 +1540,10 @@
1504 1540
1505 1541 $now = time();
1506 1542 $record = get_transient($rateLimitKey);
1507 1543
1508 - // A record without a live deadline means the window is over (or the record predates
1509 - // this format), so the count starts again. Anything else keeps the deadline it was
1510 - // created with.
1511 - //
1512 - // The `<=` must not be loosened to `<`: it is what guarantees the TTL below is at
1513 - // least 1. A request landing exactly on the deadline would otherwise compute a TTL
1514 - // of 0, and set_transient() reads 0 as "never expires" — wedging this limiter shut
1515 - // permanently.
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.
1516 1546 if (!is_array($record) || empty($record['expires']) || $record['expires'] <= $now) {
1517 1547 $record = ['count' => 0, 'expires' => $now + $window];
1518 1548 }
1519 1549
@@ -1518,12 +1548,10 @@
1518 1548 }
1519 1549
1520 1550 $record['count']++;
1521 1551
1522 - // The TTL is the time left until the original deadline, never the full window:
1523 - // set_transient() rewrites expiry on every call, so passing $window here would let
1524 - // rejected requests push the deadline forward and keep a tripped limiter tripped
1525 - // for as long as traffic kept arriving.
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.
1526 1554 set_transient($rateLimitKey, $record, $record['expires'] - $now);
1527 1555
1528 1556 return $record['count'] > $limit;
1529 1557 }
@@ -1551,13 +1579,10 @@
1551 1579 '197.234.240.0/22',
1552 1580 '198.41.128.0/17'
1553 1581 );
1554 1582 $validCFRequest = false;
1555 - //Make sure that the request came via Cloudflare.
1556 1583 foreach ($cloudflareIPRanges as $range) {
1557 - //Use the ip_in_range function from Joomla.
1558 1584 if (self::ipInRange($ip, $range)) {
1559 - //IP is valid. Belongs to Cloudflare.
1560 1585 return true;
1561 1586 }
1562 1587 }
1563 1588
@@ -1636,20 +1661,11 @@
1636 1661 return Arr::get($settings, 'product_required_field') === 'yes';
1637 1662 }
1638 1663
1639 1664 /**
1640 - * Build a fluentsupport.com upgrade/pricing link tagged with UTM params
1641 - * per the standard "Upgrade to Pro" link spec:
1642 - * utm_source = fluent-support (fixed vocabulary, never the wp.org slug)
1643 - * utm_medium = free_plugin | pro_plugin (acquisition vs cross-sell)
1644 - * utm_campaign= upgrade_pro (override via $args['campaign'])
1645 - * utm_content = the exact placement, e.g. feature_lock_dropbox, upgrade_page
1646 - * utm_term = plugin version that generated the link
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).
1647 1667 *
1648 - * This is the single source of truth for upgrade URLs. The admin SPA does
1649 - * not rebuild these client-side; it re-points utm_content on the URL this
1650 - * method localizes into appVars (see the $upgradeUrl mixin in start.js).
1651 - *
1652 1668 * @param string $content The utm_content placement.
1653 1669 * @param array $args Optional: 'campaign', 'base_url', plus any utm_* override.
1654 1670 * @return string
1655 1671 */
@@ -1661,9 +1677,9 @@
1661 1677 ]);
1662 1678
1663 1679 $params = [
1664 1680 'utm_source' => 'fluent-support',
1665 - // Same constant Menu.php uses for appVars.has_pro — keep the two in sync.
1681 + // Keep in sync with appVars.has_pro in Menu.php.
1666 1682 'utm_medium' => defined('FLUENTSUPPORTPRO_PLUGIN_VERSION') ? 'pro_plugin' : 'free_plugin',
1667 1683 'utm_campaign' => $args['campaign'],
1668 1684 'utm_content' => $content,
1669 1685 'utm_term' => FLUENT_SUPPORT_VERSION,
@@ -1668,9 +1684,8 @@
1668 1684 'utm_content' => $content,
1669 1685 'utm_term' => FLUENT_SUPPORT_VERSION,
1670 1686 ];
1671 1687
1672 - // Drop any blank params (e.g. a missing version) so they never hit the URL.
1673 1688 $params = array_filter($params, function ($value) {
1674 1689 return $value !== '' && $value !== null;
1675 1690 });
1676 1691
@@ -1687,27 +1702,21 @@
1687 1702 }
1688 1703
1689 1704 public static function tempImageMoveUploadDir($ticketId, $contentType, $replyId = null, $personId = null)
1690 1705 {
1691 - // Fetch content based on the content type
1692 1706 $content = self::getContentByType($ticketId, $contentType , $replyId);
1693 1707 if (empty($content)) {
1694 1708 return;
1695 1709 }
1696 1710
1697 - // Extract image URLs from the content
1698 1711 $imageUrls = self::extractImageUrls($content);
1699 1712 if (empty($imageUrls)) {
1700 1713 return;
1701 1714 }
1702 1715
1703 - // Move images to the upload directory and update content
1704 1716 self::moveImagesAndUpdateContent($imageUrls, $ticketId, $contentType, $content, $replyId, $personId);
1705 1717 }
1706 1718
1707 - /**
1708 - * Fetch content based on the content type.
1709 - */
1710 1719 private static function getContentByType($ticketId, $contentType, $replyId)
1711 1720 {
1712 1721 if ($contentType == 'ticket-create') {
1713 1722 $ticket = Ticket::find($ticketId);
@@ -1717,11 +1726,8 @@
1717 1726 $conversation = Conversation::find($replyId);
1718 1727 return $conversation ? $conversation->content : null;
1719 1728 }
1720 1729
1721 - /**
1722 - * Extract image URLs from the content.
1723 - */
1724 1730 private static function extractImageUrls($content)
1725 1731 {
1726 1732 preg_match_all('/<img[^>]+src=(["\'])(.*?)\1/i', (string) $content, $matches);
1727 1733 return $matches[2] ?? [];
@@ -1727,13 +1733,12 @@
1727 1733 return $matches[2] ?? [];
1728 1734 }
1729 1735
1730 1736 /**
1731 - * 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.
1732 1738 */
1733 1739 private static function moveImagesAndUpdateContent($imageUrls, $ticketId, $contentType, $content, $replyId, $personId)
1734 1740 {
1735 - // Get the current site's upload directory
1736 1741 $uploadDirInfo = wp_upload_dir();
1737 1742 $uploadsDir = $uploadDirInfo['basedir'];
1738 1743 $tempDir = $uploadsDir . '/fluent-support/temp_files/';
1739 1744 $signedAttachments = self::getSignedImageAttachments($imageUrls, $ticketId, $personId);
@@ -1744,28 +1749,22 @@
1744 1749 $content = str_replace($imageUrl, $referenceUrl, $content);
1745 1750 continue;
1746 1751 }
1747 1752
1748 - // Build the absolute path for the temporary file
1749 1753 $imageRelativePath = $tempDir . basename($imageUrl);
1750 1754 $absolutePath = $imageRelativePath;
1751 1755
1752 - // Move the file to the ticket-specific folder
1753 1756 $newFileInfo = UploadService::copyFileTicketFolder($absolutePath, $ticketId);
1754 1757
1755 - // Check if the move was successful
1756 1758 if (empty($newFileInfo['file_path'])) {
1757 - continue; // Skip if the file couldn't be copied
1759 + continue;
1758 1760 }
1759 1761
1760 - // Ensure the new URL is correctly constructed
1761 1762 $newFileInfo['url'] = trailingslashit($uploadDirInfo['baseurl']) . 'fluent-support/ticket_' . $ticketId . '/' . basename($newFileInfo['file_path']);
1762 1763
1763 - // Replace the old URL with the new one in the content
1764 1764 $content = str_replace($imageUrl, $newFileInfo['url'], $content);
1765 1765 }
1766 1766
1767 - // Save the updated content
1768 1767 self::saveUpdatedContent($ticketId, $contentType, $content, $replyId);
1769 1768 }
1770 1769
1771 1770 private static function getSignedImageAttachments($imageUrls, $ticketId, $personId)
@@ -1925,11 +1924,8 @@
1925 1924
1926 1925 return $contents;
1927 1926 }
1928 1927
1929 - /**
1930 - * Save the updated content based on the content type.
1931 - */
1932 1928 private static function saveUpdatedContent($ticketId, $contentType, $content, $replyId)
1933 1929 {
1934 1930 if ($contentType == 'ticket-create') {
1935 1931 $ticket = Ticket::find($ticketId);
@@ -1946,12 +1942,10 @@
1946 1942 }
1947 1943 }
1948 1944
1949 1945 /**
1950 - * Throw a ValidationException with a safe error message.
1951 - * In debug mode the real exception message is used; in production a generic message is returned.
1952 - * Throwing instead of returning ensures the error bypasses the framework's HTTP exception
1953 - * 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.
1954 1948 *
1955 1949 * @param \Throwable $e The original exception.
1956 1950 * @throws \FluentSupport\Framework\Validator\ValidationException Always thrown.
1957 1951 */
@@ -1958,27 +1952,33 @@
1958 1952 public static function getSafeErrorMessage($e)
1959 1953 {
1960 1954 $message = $e->getMessage() ?: __('Something went wrong. Please try again later.', 'fluent-support');
1961 1955
1962 - // ValidationException carries the message through the framework's JSON error
1963 - // handler — it is never echoed directly. $e is the chained previous exception,
1964 - // 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.
1965 1967 // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped
1966 1968 throw new \FluentSupport\Framework\Validator\ValidationException(
1967 - '', 422, ($e instanceof \Exception ? $e : null), ['message' => $message]
1969 + '', $status, ($e instanceof \Exception ? $e : null), ['message' => $message]
1968 1970 );
1969 1971 // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
1970 1972 }
1971 1973
1972 1974 /**
1973 - * Safely unserialize data.
1974 - *
1975 1975 * @param string $data The serialized data.
1976 1976 * @return mixed The unserialized data or the original data if not serialized.
1977 1977 */
1978 1978 public static function safeUnserialize($data)
1979 1979 {
1980 - if (is_serialized($data)) { // Don't attempt to unserialize data that wasn't serialized going in.
1980 + if (is_serialized($data)) {
1981 1981 return @unserialize(trim($data), ['allowed_classes' => false]);
1982 1982 }
1983 1983
1984 1984 return $data;