PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 2.4.0
Fluent Support – Helpdesk & Customer Support Ticket System v2.4.0
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 1.5.5 All 68 releases
← All changes | app/Http/Controllers/TicketController.php +309 -63 2.3.02.4.0 View file →
@@ -10,8 +10,10 @@
10 10 use FluentSupport\Framework\Support\Arr;
11 11 use FluentSupport\App\Http\Requests\TicketRequest;
12 12 use FluentSupport\App\Http\Requests\TicketResponseRequest;
13 13 use FluentSupport\App\Models\Conversation;
14 +use FluentSupport\App\Models\MailBox;
15 +use FluentSupport\App\Models\Product;
14 16 use FluentSupport\App\Models\Ticket;
15 17 use FluentSupport\App\Services\FluentCRMServices;
16 18 use FluentSupport\App\Services\Helper;
17 19 use FluentSupport\App\Services\ProfileInfoService;
@@ -64,9 +66,9 @@
64 66 $businessSettings = (new \FluentSupport\App\Services\EmailNotification\Settings())->globalBusinessSettings();
65 67 $maxFileSize = absint($businessSettings['max_file_size']);
66 68
67 69 $portalSettings = [
68 - 'support_products' => \FluentSupport\App\Models\Product::select(['id', 'title'])->get(),
70 + 'support_products' => \FluentSupport\App\Models\Product::select(['id', 'title'])->orderedByTitle()->get(),
69 71 'customer_ticket_priorities' => Helper::customerTicketPriorities(),
70 72 'has_file_upload' => !!Helper::ticketAcceptedFileMiles(),
71 73 'has_rich_text_editor' => true,
72 74 'max_file_size' => $maxFileSize,
@@ -786,8 +788,14 @@
786 788 'message' => __('Draft not found', 'fluent-support'),
787 789 ]);
788 790 }
789 791
792 + // Authorize the ticket this draft belongs to (closes the mailbox/visibility
793 + // dimension for managers deleting other agents' drafts).
794 + $ticket = Ticket::findOrFail($draft->object_id);
795 +
796 + $this->ensureCanAccessTicket($ticket);
797 +
790 798 // Verify ownership: draft key contains agent_id, only managers can delete others' drafts
791 799 $isOwnDraft = strpos($draft->key, '_agent_id_' . $agent->id . '_') !== false;
792 800
793 801 if (!$isOwnDraft && !PermissionManager::canManageTickets()) {
@@ -874,28 +882,25 @@
874 882 $this->ensureCanAccessTicket($ticket);
875 883
876 884 $propName = $request->getSafe('prop_name', 'sanitize_text_field');
877 885 $propValue = $request->getSafe('prop_value', 'sanitize_text_field');
878 - $prevValue = $ticket->{$propName};
879 886
880 - //Validate agent assignment restrictions
881 - if ($propName === 'agent_id') {
882 - if (!PermissionManager::currentUserCan('fst_assign_agents')) {
883 - throw new \Exception(esc_html__('Permission denied to assign agent', 'fluent-support'), 403);
884 - }
887 + // This generic endpoint may only touch a fixed set of
888 + // ticket columns. Previously prop_name was assigned straight onto the
889 + // model ($ticket->{$propName} = $propValue), letting a caller rewrite
890 + // ownership, mailbox, privacy, hash, serial_number, created_by and
891 + // other sensitive columns and bypass $fillable entirely. Every
892 + // property is now allowlisted and its value validated/capability-
893 + // gated below; anything else is rejected outright.
894 + if (!in_array($propName, $this->updatableTicketProperties(), true)) {
895 + throw new \Exception(esc_html__('This ticket property cannot be updated.', 'fluent-support'), 403);
896 + }
885 897
886 - $agent = Agent::findOrFail($propValue);
887 - $restrictions = $agent->getMeta('agent_restrictions', []);
898 + $propValue = $this->sanitizeTicketProperty($ticket, $propName, $propValue);
888 899
889 - if (!empty($restrictions['restrictedBusinessBoxes'])) {
890 - $mailboxId = (int) $ticket->mailbox_id;
891 - if (in_array($mailboxId, $restrictions['restrictedBusinessBoxes'], true)) {
892 - throw new \Exception(esc_html__('Agent is restricted for this mailbox ticket', 'fluent-support'), 403);
893 - }
894 - }
895 - }
900 + $prevValue = $ticket->{$propName};
896 901
897 - if ($propName && $propValue && $prevValue != $propValue) {
902 + if ($propName && $propValue !== null && $prevValue != $propValue) {
898 903 $ticket->{$propName} = $propValue;
899 904 $ticket->save();
900 905
901 906 // Log an internal note for status changes so the activity is
@@ -953,8 +958,150 @@
953 958 }
954 959 }
955 960
956 961 /**
962 + * The only ticket columns that may be changed through updateTicketProperty.
963 + * This mirrors exactly what the admin UI edits (agent, title, mailbox,
964 + * product, status and the two priority fields). Ownership, audit,
965 + * public-identifier and other sensitive columns are intentionally absent
966 + * and must go through their dedicated workflows.
967 + *
968 + * @return array
969 + */
970 + protected function updatableTicketProperties()
971 + {
972 + return [
973 + 'agent_id',
974 + 'title',
975 + 'mailbox_id',
976 + 'product_id',
977 + 'status',
978 + 'priority',
979 + 'client_priority',
980 + ];
981 + }
982 +
983 + /**
984 + * Validate and normalize a single ticket-property update. Each allowlisted
985 + * property is checked against its own value domain and capability, so a
986 + * caller can neither set an out-of-range value nor perform a change the UI
987 + * gates behind a stronger permission.
988 + *
989 + * @param Ticket $ticket
990 + * @param string $propName Already confirmed to be in the allowlist.
991 + * @param string $propValue Raw (text-sanitized) value from the request.
992 + * @return mixed Normalized value ready to assign to the model.
993 + * @throws \Exception When the value is invalid or the caller lacks permission.
994 + */
995 + protected function sanitizeTicketProperty(Ticket $ticket, $propName, $propValue)
996 + {
997 + switch ($propName) {
998 + case 'title':
999 + $propValue = trim(sanitize_text_field($propValue));
1000 + if ($propValue === '') {
1001 + throw new \Exception(esc_html__('Ticket title cannot be empty.', 'fluent-support'), 422);
1002 + }
1003 + return $propValue;
1004 +
1005 + case 'status':
1006 + // Mirror the ticket-view status dropdown, which is built from
1007 + // changeable_ticket_statuses. The dropdown submits the group
1008 + // KEY as the status value (getTicketStatus in ViewTicket.vue
1009 + // keys the options by group name and el-option binds :value to
1010 + // that key), and only groups with a non-empty value list are
1011 + // shown. Validate against those same keys so the endpoint honors
1012 + // the fluent_support/changeable_ticket_statuses filter exactly.
1013 + $allowedStatuses = [];
1014 + foreach (Helper::changeableTicketStatuses() as $statusKey => $statusGroup) {
1015 + if (!empty($statusGroup)) {
1016 + $allowedStatuses[] = $statusKey;
1017 + }
1018 + }
1019 +
1020 + if (!in_array($propValue, $allowedStatuses, true)) {
1021 + throw new \Exception(esc_html__('Invalid ticket status.', 'fluent-support'), 422);
1022 + }
1023 +
1024 + // This route only assigns the column and saves, so closing or
1025 + // reopening here would skip TicketService's closure fields, hooks and cleanup.
1026 + // Use closeTicket() / reOpenTicket(); the status dropdown already does.
1027 + if ($propValue === 'closed' || $ticket->status === 'closed') {
1028 + throw new \Exception(esc_html__('Closing or reopening a ticket must use the dedicated close and re-open actions.', 'fluent-support'), 422);
1029 + }
1030 +
1031 + return $propValue;
1032 +
1033 + case 'priority':
1034 + if (!array_key_exists($propValue, Helper::adminTicketPriorities())) {
1035 + throw new \Exception(esc_html__('Invalid ticket priority.', 'fluent-support'), 422);
1036 + }
1037 + return $propValue;
1038 +
1039 + case 'client_priority':
1040 + if (!array_key_exists($propValue, Helper::customerTicketPriorities())) {
1041 + throw new \Exception(esc_html__('Invalid client priority.', 'fluent-support'), 422);
1042 + }
1043 + return $propValue;
1044 +
1045 + case 'product_id':
1046 + $productId = (int) $propValue;
1047 + if (!$productId || !Product::where('id', $productId)->exists()) {
1048 + throw new \Exception(esc_html__('Invalid product.', 'fluent-support'), 422);
1049 + }
1050 + return $productId;
1051 +
1052 + case 'agent_id':
1053 + if (!PermissionManager::currentUserCan('fst_assign_agents')) {
1054 + throw new \Exception(esc_html__('Permission denied to assign agent', 'fluent-support'), 403);
1055 + }
1056 +
1057 + $agentId = (int) $propValue;
1058 + $agent = Agent::findOrFail($agentId);
1059 + $restrictedBoxes = (new AgentTicketAccess())->getRestrictedMailboxIds($agent);
1060 +
1061 + if (in_array((int) $ticket->mailbox_id, $restrictedBoxes, true)) {
1062 + throw new \Exception(esc_html__('Agent is restricted for this mailbox ticket', 'fluent-support'), 403);
1063 + }
1064 + return $agentId;
1065 +
1066 + case 'mailbox_id':
1067 + // The admin UI only exposes the mailbox switcher to agents with
1068 + // fst_manage_settings; enforce the same gate on the API so the
1069 + // permission can't be bypassed by calling the endpoint directly.
1070 + if (!PermissionManager::currentUserCan('fst_manage_settings')) {
1071 + throw new \Exception(esc_html__('Permission denied to move this ticket to another mailbox.', 'fluent-support'), 403);
1072 + }
1073 +
1074 + $mailboxId = (int) $propValue;
1075 + $restrictedBoxes = array_map('intval', PermissionManager::getRestrictedMailboxIds());
1076 +
1077 + if (!MailBox::where('id', $mailboxId)->exists() || in_array($mailboxId, $restrictedBoxes, true)) {
1078 + throw new \Exception(esc_html__('Invalid or restricted mailbox.', 'fluent-support'), 422);
1079 + }
1080 +
1081 + // Preserve the agent/mailbox compatibility invariant that the
1082 + // agent_id branch enforces on assignment: a ticket must not be
1083 + // moved into a mailbox its currently assigned agent is restricted
1084 + // from, which would otherwise persist an assignment the assign
1085 + // flow would have rejected.
1086 + if ($ticket->agent_id) {
1087 + $assignedAgent = Agent::find($ticket->agent_id);
1088 + if ($assignedAgent) {
1089 + $agentRestrictedBoxes = (new AgentTicketAccess())->getRestrictedMailboxIds($assignedAgent);
1090 + if (in_array($mailboxId, $agentRestrictedBoxes, true)) {
1091 + throw new \Exception(esc_html__('The assigned agent is restricted from the selected mailbox. Reassign the ticket before moving it.', 'fluent-support'), 403);
1092 + }
1093 + }
1094 + }
1095 + return $mailboxId;
1096 + }
1097 +
1098 + // Unreachable: updateTicketProperty already rejected non-allowlisted
1099 + // properties before calling this method. Fail closed regardless.
1100 + throw new \Exception(esc_html__('This ticket property cannot be updated.', 'fluent-support'), 403);
1101 + }
1102 +
1103 + /**
957 1104 * closeTicket method close the ticket by id
958 1105 * @param Ticket $ticket
959 1106 * @param int $ticket_id
960 1107 * @return array
@@ -1018,16 +1165,14 @@
1018 1165 try {
1019 1166 $action = $request->getSafe('bulk_action', 'sanitize_text_field');
1020 1167 $ticketIds = array_map('intval', $request->get('ticket_ids', null, []));
1021 1168
1022 - $hasAllPermission = PermissionManager::currentUserCan('fst_manage_other_tickets');
1023 1169 $agent = Helper::getAgentByUserId();
1024 1170 $query = Ticket::whereIn('id', $ticketIds);
1025 1171
1026 - //If agent do not have permission to manage other tickets
1027 - if (!$hasAllPermission) {
1028 - $query->where('agent_id', $agent->id);
1029 - }
1172 + //Scope selected tickets to what the agent can access, matching the
1173 + //per-ticket ensureCanAccessTicket() check on the single-ticket routes
1174 + (new AgentTicketAccess())->applyAccessScope($query, $agent);
1030 1175
1031 1176 //If bulk action is close tickets
1032 1177 if ($action == 'close_tickets') {
1033 1178 $tickets = $query->get();
@@ -1072,14 +1217,15 @@
1072 1217 $tickets = $query->get();
1073 1218 $assignedCount = 0;
1074 1219 $skippedCount = 0;
1075 1220
1076 - $tickets->each(function ($ticket) use ($assignAgent, $agent, &$assignedCount, &$skippedCount) {
1221 + $restrictedBoxes = (new AgentTicketAccess())->getRestrictedMailboxIds($assignAgent);
1222 +
1223 + $tickets->each(function ($ticket) use ($assignAgent, $agent, $restrictedBoxes, &$assignedCount, &$skippedCount) {
1077 1224 $previousAgentId = (int) $ticket->agent_id;
1078 - $restrictions = $assignAgent->getMeta('agent_restrictions', []);
1079 1225
1080 1226 //Skip ticket if mailbox is restricted for the agent
1081 - if (!empty($restrictions) && in_array($ticket->mailbox_id, $restrictions['restrictedBusinessBoxes'])) {
1227 + if (!empty($ticket->mailbox_id) && in_array((int) $ticket->mailbox_id, $restrictedBoxes, true)) {
1082 1228 $skippedCount++;
1083 1229 return;
1084 1230 }
1085 1231
@@ -1187,8 +1333,10 @@
1187 1333 {
1188 1334 try {
1189 1335 $ticket = Ticket::findOrFail($ticket_id);
1190 1336
1337 + $this->ensureCanAccessTicket($ticket);
1338 +
1191 1339 (new TicketService())->deleteTicket($ticket);
1192 1340
1193 1341 return [
1194 1342 'message' => __('Ticket has been deleted successfully', 'fluent-support')
@@ -1236,15 +1384,12 @@
1236 1384 //Get logged in agent information
1237 1385 $agent = Helper::getAgentByUserId();
1238 1386 $ticketIds = array_filter($data['ticket_ids'], 'absint');
1239 1387
1240 - $hasAllPermission = PermissionManager::currentUserCan('fst_manage_other_tickets');
1241 1388 $query = Ticket::whereIn('id', $ticketIds)->where('status', '!=', 'closed');
1242 1389
1243 - //If the agent does not have permission
1244 - if (!$hasAllPermission) {
1245 - $query->where('agent_id', $agent->id);
1246 - }
1390 + // Scope to tickets the agent may access (visibility + mailbox restrictions).
1391 + (new AgentTicketAccess())->applyAccessScope($query, $agent);
1247 1392
1248 1393 $tickets = $query->get();
1249 1394
1250 1395 if ($tickets->isEmpty()) {
@@ -1316,19 +1461,29 @@
1316 1461 public function deleteResponse($ticket_id, $response_id)
1317 1462 {
1318 1463 try {
1319 1464 $ticket = Ticket::findOrFail($ticket_id);
1320 - $response = Conversation::where('id', $response_id)
1321 - ->where('ticket_id', $ticket_id)
1322 - ->firstOrFail();
1323 - $agent = Helper::getAgentByUserId();
1324 1465
1325 - if (!PermissionManager::currentUserCan('fst_delete_tickets') && $ticket->agent_id !== $agent->id) {
1466 + if (in_array($ticket->mailbox_id, PermissionManager::getRestrictedMailboxIds())) {
1467 + throw new \Exception(esc_html__('Ticket cannot be fetched due to restricted mailbox', 'fluent-support'));
1468 + }
1469 +
1470 + // The caller must have access to this specific ticket (visibility +
1471 + // ownership + mailbox), not merely a global manage capability.
1472 + $this->ensureCanAccessTicket($ticket);
1473 +
1474 + // Deleting a response always requires the explicit delete capability,
1475 + // mirroring deleteTicket(). Assignment alone is not sufficient.
1476 + if (!PermissionManager::currentUserCan('fst_delete_tickets')) {
1326 1477 throw new \Exception(
1327 1478 esc_html__('Sorry, you do not have permission to delete this response.', 'fluent-support')
1328 1479 );
1329 1480 }
1330 1481
1482 + $response = Conversation::where('id', $response_id)
1483 + ->where('ticket_id', $ticket_id)
1484 + ->firstOrFail();
1485 +
1331 1486 $response->delete();
1332 1487 $response->ccinfo()->delete();
1333 1488
1334 1489 return [
@@ -1352,30 +1507,65 @@
1352 1507 public function updateResponse(TicketResponseRequest $request, $ticket_id, $response_id)
1353 1508 {
1354 1509 try {
1355 1510 $ticket = Ticket::findOrFail($ticket_id);
1511 +
1512 + if (in_array($ticket->mailbox_id, PermissionManager::getRestrictedMailboxIds())) {
1513 + throw new \Exception(esc_html__('Ticket cannot be fetched due to restricted mailbox', 'fluent-support'));
1514 + }
1515 +
1516 + // The caller must have access to this specific ticket (visibility +
1517 + // ownership + mailbox), not merely a global manage capability.
1518 + $this->ensureCanAccessTicket($ticket);
1519 +
1356 1520 $response = Conversation::where('id', $response_id)
1357 1521 ->where('ticket_id', $ticket_id)
1522 + ->with('person')
1358 1523 ->firstOrFail();
1359 1524 $agent = Helper::getAgentByUserId();
1360 1525
1361 - if (!PermissionManager::currentUserCan('fst_manage_other_tickets') && $ticket->agent_id !== $agent->id) {
1526 + // Only agent-authored conversation types may be edited here. Customer
1527 + // replies and system entries must not be rewritten via this endpoint.
1528 + $editableTypes = ['response', 'draft_response', 'note', 'internal_info'];
1529 + if (!in_array($response->conversation_type, $editableTypes, true)) {
1362 1530 throw new \Exception(
1531 + esc_html__('This response type cannot be edited.', 'fluent-support')
1532 + );
1533 + }
1534 +
1535 + // Customer messages share the 'response' type but are authored by a
1536 + // customer person; they are never editable by an agent.
1537 + if ($response->person && $response->person->person_type !== 'agent') {
1538 + throw new \Exception(
1363 1539 esc_html__('Sorry, you do not have permission to update this response.', 'fluent-support')
1364 1540 );
1365 1541 }
1366 1542
1367 - $content = wp_unslash(wp_kses_post($request->getSafe('content', 'wp_kses_post')));
1368 - $response->content = $content;
1543 + $isDraft = $response->conversation_type == 'draft_response';
1544 + $isAuthor = (int) $response->person_id === (int) $agent->id;
1545 + $canApproveDraft = PermissionManager::currentUserCan('fst_approve_draft_reply');
1369 1546
1370 - if ($response->conversation_type == 'draft_response' && $response->person_id != $agent->id && PermissionManager::currentUserCan('fst_approve_draft_reply')) {
1371 - $response = $this->approveDraftConversation($ticket, $response, $agent, $content);
1372 - } else if ($response->conversation_type == 'draft_response' && $response->person_id != $agent->id) {
1373 - if (!PermissionManager::currentUserCan('fst_approve_draft_reply')) {
1547 + if ($isDraft && !$isAuthor) {
1548 + // Another agent's draft can only be edited/approved by an approver.
1549 + if (!$canApproveDraft) {
1374 1550 throw new \Exception(
1375 1551 esc_html__('Sorry, You do not have permission to approve this draft response', 'fluent-support')
1376 1552 );
1377 1553 }
1554 + } elseif (!$isAuthor && !PermissionManager::currentUserCan('fst_manage_other_tickets')) {
1555 + // Editing another agent's response requires manage-others capability.
1556 + throw new \Exception(
1557 + esc_html__('Sorry, you do not have permission to update this response.', 'fluent-support')
1558 + );
1559 + }
1560 +
1561 + // Request input is already unslashed at the boundary; unslashing again
1562 + // would strip literal backslashes out of the edited reply.
1563 + $content = wp_kses_post($request->getSafe('content', 'wp_kses_post'));
1564 + $response->content = $content;
1565 +
1566 + if ($isDraft && !$isAuthor && $canApproveDraft) {
1567 + $response = $this->approveDraftConversation($ticket, $response, $agent, $content);
1378 1568 } else {
1379 1569 $response->save();
1380 1570 }
1381 1571
@@ -1400,8 +1590,10 @@
1400 1590 }
1401 1591
1402 1592 $ticket = Ticket::findOrFail($ticket_id);
1403 1593
1594 + $this->ensureCanAccessTicket($ticket);
1595 +
1404 1596 $response = Conversation::where('id', $response_id)
1405 1597 ->where('ticket_id', $ticket_id)
1406 1598 ->where('conversation_type', 'draft_response')
1407 1599 ->firstOrFail();
@@ -1411,9 +1603,9 @@
1411 1603 $response = $this->approveDraftConversation(
1412 1604 $ticket,
1413 1605 $response,
1414 1606 $person,
1415 - wp_unslash(wp_kses_post($request->getSafe('content', 'wp_kses_post')))
1607 + wp_kses_post($request->getSafe('content', 'wp_kses_post'))
1416 1608 );
1417 1609
1418 1610 return [
1419 1611 'message' => __('Draft response has been successfully approved.', 'fluent-support'),
@@ -1464,13 +1656,23 @@
1464 1656 * @return array
1465 1657 */
1466 1658 public function getLiveActivity(Request $request, $ticket_id)
1467 1659 {
1468 - $agent = Helper::getAgentByUserId();
1660 + try {
1661 + $ticket = Ticket::findOrFail($ticket_id);
1469 1662
1470 - return [
1471 - 'live_activity' => TicketHelper::getActivity($ticket_id, $agent->id)
1472 - ];
1663 + $this->ensureCanAccessTicket($ticket);
1664 +
1665 + $agent = Helper::getAgentByUserId();
1666 +
1667 + return [
1668 + 'live_activity' => TicketHelper::getActivity($ticket_id, $agent->id)
1669 + ];
1670 + } catch (\Exception $e) {
1671 + return $this->sendError([
1672 + 'message' => Helper::getSafeErrorMessage($e)
1673 + ]);
1674 + }
1473 1675 }
1474 1676
1475 1677 /**
1476 1678 * removeLiveActivity method will remove activities that
@@ -1479,14 +1681,24 @@
1479 1681 * @return array
1480 1682 */
1481 1683 public function removeLiveActivity(Request $request, $ticket_id)
1482 1684 {
1483 - $agent = Helper::getAgentByUserId();
1685 + try {
1686 + $ticket = Ticket::findOrFail($ticket_id);
1484 1687
1485 - return [
1486 - 'result' => TicketHelper::removeFromActivities($ticket_id, $agent->id),
1487 - 'agent_id' => $agent->id
1488 - ];
1688 + $this->ensureCanAccessTicket($ticket);
1689 +
1690 + $agent = Helper::getAgentByUserId();
1691 +
1692 + return [
1693 + 'result' => TicketHelper::removeFromActivities($ticket_id, $agent->id),
1694 + 'agent_id' => $agent->id
1695 + ];
1696 + } catch (\Exception $e) {
1697 + return $this->sendError([
1698 + 'message' => Helper::getSafeErrorMessage($e)
1699 + ]);
1700 + }
1489 1701 }
1490 1702
1491 1703 /**
1492 1704 * addTag method will add tag in ticket by ticket id
@@ -1497,8 +1709,11 @@
1497 1709 public function addTag(Request $request, $ticket_id)
1498 1710 {
1499 1711 try {
1500 1712 $ticket = Ticket::findOrFail($ticket_id);
1713 +
1714 + $this->ensureCanAccessTicket($ticket);
1715 +
1501 1716 $ticket->applyTags($request->getSafe('tag_id', 'intval'));
1502 1717
1503 1718 return [
1504 1719 'message' => __('Tag has been added to this ticket', 'fluent-support'),
@@ -1520,8 +1735,11 @@
1520 1735 public function detachTag($ticket_id, $tag_id)
1521 1736 {
1522 1737 try {
1523 1738 $ticket = Ticket::findOrFail($ticket_id);
1739 +
1740 + $this->ensureCanAccessTicket($ticket);
1741 +
1524 1742 $ticket->detachTags($tag_id);
1525 1743
1526 1744 return [
1527 1745 'message' => __('Tag has been removed from this ticket', 'fluent-support'),
@@ -1548,17 +1766,37 @@
1548 1766 if (!$newCustomerId) {
1549 1767 return $this->sendError(__('Invalid customer selected.', 'fluent-support'));
1550 1768 }
1551 1769
1770 + // Rebinding a ticket to another customer exposes that customer's private
1771 + // data (profile, custom fields) through the ticket, so it requires the same
1772 + // sensitive-data capability that gates the customer routes.
1773 + if (!PermissionManager::currentUserCan('fst_sensitive_data')) {
1774 + return $this->sendError(__('You do not have permission to change the ticket customer.', 'fluent-support'));
1775 + }
1776 +
1552 1777 try {
1553 - $updated = Ticket::where('id', $ticketId)
1554 - ->where('customer_id', '!=', $newCustomerId)
1555 - ->update(['customer_id' => $newCustomerId]);
1778 + $ticket = Ticket::findOrFail($ticketId);
1556 1779
1557 - return $updated
1558 - ? ['message' => __('Customer has been updated', 'fluent-support')]
1559 - : $this->sendError(__('Ticket not found or customer already assigned.', 'fluent-support'));
1780 + $this->ensureCanAccessTicket($ticket);
1560 1781
1782 + $targetCustomer = Customer::where('id', $newCustomerId)
1783 + ->where('person_type', 'customer')
1784 + ->first();
1785 +
1786 + if (!$targetCustomer) {
1787 + return $this->sendError(__('Invalid customer selected.', 'fluent-support'));
1788 + }
1789 +
1790 + if ($ticket->customer_id == $newCustomerId) {
1791 + return $this->sendError(__('Customer already assigned to this ticket.', 'fluent-support'));
1792 + }
1793 +
1794 + $ticket->customer_id = $newCustomerId;
1795 + $ticket->save();
1796 +
1797 + return ['message' => __('Customer has been updated', 'fluent-support')];
1798 +
1561 1799 } catch (\Exception $e) {
1562 1800 return $this->sendError([
1563 1801 'message' => Helper::getSafeErrorMessage($e)
1564 1802 ]);
@@ -1579,14 +1817,22 @@
1579 1817 'rendered_fields' => []
1580 1818 ];
1581 1819 }
1582 1820
1583 - $ticket = Ticket::findOrFail($ticket_id);
1821 + try {
1822 + $ticket = Ticket::findOrFail($ticket_id);
1584 1823
1585 - return [
1586 - 'custom_data' => (object)$ticket->customData(),
1587 - 'rendered_fields' => \FluentSupportPro\App\Services\CustomFieldsService::getRenderedPublicFields($ticket->customer, 'admin')
1588 - ];
1824 + $this->ensureCanAccessTicket($ticket);
1825 +
1826 + return [
1827 + 'custom_data' => (object)$ticket->customData(),
1828 + 'rendered_fields' => \FluentSupportPro\App\Services\CustomFieldsService::getRenderedPublicFields($ticket->customer, 'admin')
1829 + ];
1830 + } catch (\Exception $e) {
1831 + return $this->sendError([
1832 + 'message' => Helper::getSafeErrorMessage($e)
1833 + ]);
1834 + }
1589 1835 }
1590 1836
1591 1837 /**
1592 1838 * syncFluentCrmTags method will synchronize the tags with Fluent CRM by contact id