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 +362 -75 2.2.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,
@@ -337,9 +339,13 @@
337 339 $response,
338 340 $ticket
339 341 );
340 342
341 - $responseContent = links_add_target(make_clickable(wpautop($responseContent, false)));
343 + if ($response->conversation_type === 'note') {
344 + $responseContent = wpautop($responseContent, false);
345 + } else {
346 + $responseContent = links_add_target(make_clickable(wpautop($responseContent, false)));
347 + }
342 348
343 349
344 350 $response->content = apply_filters(
345 351 'fluent_support/response_content_after_render',
@@ -782,8 +788,14 @@
782 788 'message' => __('Draft not found', 'fluent-support'),
783 789 ]);
784 790 }
785 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 +
786 798 // Verify ownership: draft key contains agent_id, only managers can delete others' drafts
787 799 $isOwnDraft = strpos($draft->key, '_agent_id_' . $agent->id . '_') !== false;
788 800
789 801 if (!$isOwnDraft && !PermissionManager::canManageTickets()) {
@@ -809,9 +821,9 @@
809 821 * @param Ticket $ticket
810 822 * @param $ticket_id
811 823 * @return array
812 824 */
813 - public function getTicketWidgets($ticket_id)
825 + public function getTicketWidgets(Request $request, $ticket_id)
814 826 {
815 827 try {
816 828 //Get ticket with customer by ticket id
817 829 $ticket = Ticket::with('customer')->findOrFail($ticket_id);
@@ -817,22 +829,37 @@
817 829 $ticket = Ticket::with('customer')->findOrFail($ticket_id);
818 830
819 831 $this->ensureCanAccessTicket($ticket);
820 832
821 - //Get last N tickets of this customer except this
822 - $limit = apply_filters('fluent_support/previous_ticket_widgets_limit', 10);
833 + $perPage = max(1, absint(apply_filters('fluent_support/previous_ticket_widgets_limit', 5)));
834 + $page = max(1, absint($request->get('page', 1)));
835 + $offset = ($page - 1) * $perPage;
823 836
824 - $otherTickets = Ticket::where('id', '!=', $ticket_id)
837 + $baseQuery = Ticket::where('id', '!=', $ticket_id)
838 + ->where('customer_id', $ticket->customer_id);
839 +
840 + (new AgentTicketAccess())->applyAccessScope($baseQuery);
841 +
842 + $total = $baseQuery->count();
843 +
844 + $otherTickets = (clone $baseQuery)
825 845 ->select(['id', 'title', 'status', 'created_at'])
826 - ->where('customer_id', $ticket->customer_id)
827 846 ->latest('id')
828 - ->limit($limit)
847 + ->limit($perPage)
848 + ->offset($offset)
829 849 ->get();
830 850
831 - return [
832 - 'other_tickets' => $otherTickets,
833 - 'extra_widgets' => ProfileInfoService::getProfileExtraWidgets($ticket->customer)
851 + $response = [
852 + 'other_tickets' => $otherTickets,
853 + 'other_tickets_total' => $total,
854 + 'other_tickets_more' => ($offset + $perPage) < $total,
834 855 ];
856 +
857 + if (in_array('extra_widgets', $request->get('with', []))) {
858 + $response['extra_widgets'] = ProfileInfoService::getProfileExtraWidgets($ticket->customer);
859 + }
860 +
861 + return $response;
835 862 } catch (\Exception $e) {
836 863 return $this->sendError([
837 864 'message' => Helper::getSafeErrorMessage($e)
838 865 ]);
@@ -855,30 +882,49 @@
855 882 $this->ensureCanAccessTicket($ticket);
856 883
857 884 $propName = $request->getSafe('prop_name', 'sanitize_text_field');
858 885 $propValue = $request->getSafe('prop_value', 'sanitize_text_field');
859 - $prevValue = $ticket->{$propName};
860 886
861 - //Validate agent assignment restrictions
862 - if ($propName === 'agent_id') {
863 - if (!PermissionManager::currentUserCan('fst_assign_agents')) {
864 - throw new \Exception(esc_html__('Permission denied to assign agent', 'fluent-support'), 403);
865 - }
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 + }
866 897
867 - $agent = Agent::findOrFail($propValue);
868 - $restrictions = $agent->getMeta('agent_restrictions', []);
898 + $propValue = $this->sanitizeTicketProperty($ticket, $propName, $propValue);
869 899
870 - if (!empty($restrictions['restrictedBusinessBoxes'])) {
871 - $mailboxId = (int) $ticket->mailbox_id;
872 - if (in_array($mailboxId, $restrictions['restrictedBusinessBoxes'], true)) {
873 - throw new \Exception(esc_html__('Agent is restricted for this mailbox ticket', 'fluent-support'), 403);
874 - }
875 - }
876 - }
900 + $prevValue = $ticket->{$propName};
877 901
878 - if ($propName && $propValue && $prevValue != $propValue) {
902 + if ($propName && $propValue !== null && $prevValue != $propValue) {
879 903 $ticket->{$propName} = $propValue;
880 904 $ticket->save();
905 +
906 + // Log an internal note for status changes so the activity is
907 + // traceable, mirroring the close/reopen flows.
908 + if ($propName === 'status') {
909 + $statuses = Helper::ticketStatuses();
910 + $fromLabel = isset($statuses[$prevValue]) ? $statuses[$prevValue] : $prevValue;
911 + $toLabel = isset($statuses[$propValue]) ? $statuses[$propValue] : $propValue;
912 +
913 + $internalNote = sprintf(
914 + /* translators: 1: previous status, 2: new status */
915 + __('Ticket status changed from %1$s to %2$s', 'fluent-support'),
916 + esc_html($fromLabel),
917 + esc_html($toLabel)
918 + );
919 +
920 + Conversation::create([
921 + 'ticket_id' => $ticket->id,
922 + 'person_id' => $assigner->id,
923 + 'conversation_type' => 'internal_info',
924 + 'content' => $internalNote
925 + ]);
926 + }
881 927 }
882 928
883 929 $updateData = [];
884 930
@@ -912,8 +958,150 @@
912 958 }
913 959 }
914 960
915 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 + /**
916 1104 * closeTicket method close the ticket by id
917 1105 * @param Ticket $ticket
918 1106 * @param int $ticket_id
919 1107 * @return array
@@ -977,16 +1165,14 @@
977 1165 try {
978 1166 $action = $request->getSafe('bulk_action', 'sanitize_text_field');
979 1167 $ticketIds = array_map('intval', $request->get('ticket_ids', null, []));
980 1168
981 - $hasAllPermission = PermissionManager::currentUserCan('fst_manage_other_tickets');
982 1169 $agent = Helper::getAgentByUserId();
983 1170 $query = Ticket::whereIn('id', $ticketIds);
984 1171
985 - //If agent do not have permission to manage other tickets
986 - if (!$hasAllPermission) {
987 - $query->where('agent_id', $agent->id);
988 - }
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);
989 1175
990 1176 //If bulk action is close tickets
991 1177 if ($action == 'close_tickets') {
992 1178 $tickets = $query->get();
@@ -1031,14 +1217,15 @@
1031 1217 $tickets = $query->get();
1032 1218 $assignedCount = 0;
1033 1219 $skippedCount = 0;
1034 1220
1035 - $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) {
1036 1224 $previousAgentId = (int) $ticket->agent_id;
1037 - $restrictions = $assignAgent->getMeta('agent_restrictions', []);
1038 1225
1039 1226 //Skip ticket if mailbox is restricted for the agent
1040 - if (!empty($restrictions) && in_array($ticket->mailbox_id, $restrictions['restrictedBusinessBoxes'])) {
1227 + if (!empty($ticket->mailbox_id) && in_array((int) $ticket->mailbox_id, $restrictedBoxes, true)) {
1041 1228 $skippedCount++;
1042 1229 return;
1043 1230 }
1044 1231
@@ -1146,8 +1333,10 @@
1146 1333 {
1147 1334 try {
1148 1335 $ticket = Ticket::findOrFail($ticket_id);
1149 1336
1337 + $this->ensureCanAccessTicket($ticket);
1338 +
1150 1339 (new TicketService())->deleteTicket($ticket);
1151 1340
1152 1341 return [
1153 1342 'message' => __('Ticket has been deleted successfully', 'fluent-support')
@@ -1195,15 +1384,12 @@
1195 1384 //Get logged in agent information
1196 1385 $agent = Helper::getAgentByUserId();
1197 1386 $ticketIds = array_filter($data['ticket_ids'], 'absint');
1198 1387
1199 - $hasAllPermission = PermissionManager::currentUserCan('fst_manage_other_tickets');
1200 1388 $query = Ticket::whereIn('id', $ticketIds)->where('status', '!=', 'closed');
1201 1389
1202 - //If the agent does not have permission
1203 - if (!$hasAllPermission) {
1204 - $query->where('agent_id', $agent->id);
1205 - }
1390 + // Scope to tickets the agent may access (visibility + mailbox restrictions).
1391 + (new AgentTicketAccess())->applyAccessScope($query, $agent);
1206 1392
1207 1393 $tickets = $query->get();
1208 1394
1209 1395 if ($tickets->isEmpty()) {
@@ -1275,19 +1461,29 @@
1275 1461 public function deleteResponse($ticket_id, $response_id)
1276 1462 {
1277 1463 try {
1278 1464 $ticket = Ticket::findOrFail($ticket_id);
1279 - $response = Conversation::where('id', $response_id)
1280 - ->where('ticket_id', $ticket_id)
1281 - ->firstOrFail();
1282 - $agent = Helper::getAgentByUserId();
1283 1465
1284 - 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')) {
1285 1477 throw new \Exception(
1286 1478 esc_html__('Sorry, you do not have permission to delete this response.', 'fluent-support')
1287 1479 );
1288 1480 }
1289 1481
1482 + $response = Conversation::where('id', $response_id)
1483 + ->where('ticket_id', $ticket_id)
1484 + ->firstOrFail();
1485 +
1290 1486 $response->delete();
1291 1487 $response->ccinfo()->delete();
1292 1488
1293 1489 return [
@@ -1311,30 +1507,65 @@
1311 1507 public function updateResponse(TicketResponseRequest $request, $ticket_id, $response_id)
1312 1508 {
1313 1509 try {
1314 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 +
1315 1520 $response = Conversation::where('id', $response_id)
1316 1521 ->where('ticket_id', $ticket_id)
1522 + ->with('person')
1317 1523 ->firstOrFail();
1318 1524 $agent = Helper::getAgentByUserId();
1319 1525
1320 - 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)) {
1321 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(
1322 1539 esc_html__('Sorry, you do not have permission to update this response.', 'fluent-support')
1323 1540 );
1324 1541 }
1325 1542
1326 - $content = wp_unslash(wp_kses_post($request->getSafe('content', 'wp_kses_post')));
1327 - $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');
1328 1546
1329 - if ($response->conversation_type == 'draft_response' && $response->person_id != $agent->id && PermissionManager::currentUserCan('fst_approve_draft_reply')) {
1330 - $response = $this->approveDraftConversation($ticket, $response, $agent, $content);
1331 - } else if ($response->conversation_type == 'draft_response' && $response->person_id != $agent->id) {
1332 - 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) {
1333 1550 throw new \Exception(
1334 1551 esc_html__('Sorry, You do not have permission to approve this draft response', 'fluent-support')
1335 1552 );
1336 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);
1337 1568 } else {
1338 1569 $response->save();
1339 1570 }
1340 1571
@@ -1359,8 +1590,10 @@
1359 1590 }
1360 1591
1361 1592 $ticket = Ticket::findOrFail($ticket_id);
1362 1593
1594 + $this->ensureCanAccessTicket($ticket);
1595 +
1363 1596 $response = Conversation::where('id', $response_id)
1364 1597 ->where('ticket_id', $ticket_id)
1365 1598 ->where('conversation_type', 'draft_response')
1366 1599 ->firstOrFail();
@@ -1370,9 +1603,9 @@
1370 1603 $response = $this->approveDraftConversation(
1371 1604 $ticket,
1372 1605 $response,
1373 1606 $person,
1374 - wp_unslash(wp_kses_post($request->getSafe('content', 'wp_kses_post')))
1607 + wp_kses_post($request->getSafe('content', 'wp_kses_post'))
1375 1608 );
1376 1609
1377 1610 return [
1378 1611 'message' => __('Draft response has been successfully approved.', 'fluent-support'),
@@ -1423,13 +1656,23 @@
1423 1656 * @return array
1424 1657 */
1425 1658 public function getLiveActivity(Request $request, $ticket_id)
1426 1659 {
1427 - $agent = Helper::getAgentByUserId();
1660 + try {
1661 + $ticket = Ticket::findOrFail($ticket_id);
1428 1662
1429 - return [
1430 - 'live_activity' => TicketHelper::getActivity($ticket_id, $agent->id)
1431 - ];
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 + }
1432 1675 }
1433 1676
1434 1677 /**
1435 1678 * removeLiveActivity method will remove activities that
@@ -1438,14 +1681,24 @@
1438 1681 * @return array
1439 1682 */
1440 1683 public function removeLiveActivity(Request $request, $ticket_id)
1441 1684 {
1442 - $agent = Helper::getAgentByUserId();
1685 + try {
1686 + $ticket = Ticket::findOrFail($ticket_id);
1443 1687
1444 - return [
1445 - 'result' => TicketHelper::removeFromActivities($ticket_id, $agent->id),
1446 - 'agent_id' => $agent->id
1447 - ];
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 + }
1448 1701 }
1449 1702
1450 1703 /**
1451 1704 * addTag method will add tag in ticket by ticket id
@@ -1456,8 +1709,11 @@
1456 1709 public function addTag(Request $request, $ticket_id)
1457 1710 {
1458 1711 try {
1459 1712 $ticket = Ticket::findOrFail($ticket_id);
1713 +
1714 + $this->ensureCanAccessTicket($ticket);
1715 +
1460 1716 $ticket->applyTags($request->getSafe('tag_id', 'intval'));
1461 1717
1462 1718 return [
1463 1719 'message' => __('Tag has been added to this ticket', 'fluent-support'),
@@ -1479,8 +1735,11 @@
1479 1735 public function detachTag($ticket_id, $tag_id)
1480 1736 {
1481 1737 try {
1482 1738 $ticket = Ticket::findOrFail($ticket_id);
1739 +
1740 + $this->ensureCanAccessTicket($ticket);
1741 +
1483 1742 $ticket->detachTags($tag_id);
1484 1743
1485 1744 return [
1486 1745 'message' => __('Tag has been removed from this ticket', 'fluent-support'),
@@ -1498,11 +1757,11 @@
1498 1757 * This method will get ticket id and customer id as parameter, it will replace existing customer id with new
1499 1758 * @param Request $request
1500 1759 * @return array
1501 1760 */
1502 - public function changeTicketCustomer(Request $request)
1761 + public function changeTicketCustomer(Request $request, $ticket_id)
1503 1762 {
1504 - $ticketId = $request->getSafe('ticket_id', 'intval');
1763 + $ticketId = (int) $ticket_id;
1505 1764 $newCustomerId = $request->getSafe('customer', 'intval');
1506 1765
1507 1766 if (!$newCustomerId) {
1508 1767 return $this->sendError(__('Invalid customer selected.', 'fluent-support'));
@@ -1507,17 +1766,37 @@
1507 1766 if (!$newCustomerId) {
1508 1767 return $this->sendError(__('Invalid customer selected.', 'fluent-support'));
1509 1768 }
1510 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 +
1511 1777 try {
1512 - $updated = Ticket::where('id', $ticketId)
1513 - ->where('customer_id', '!=', $newCustomerId)
1514 - ->update(['customer_id' => $newCustomerId]);
1778 + $ticket = Ticket::findOrFail($ticketId);
1515 1779
1516 - return $updated
1517 - ? ['message' => __('Customer has been updated', 'fluent-support')]
1518 - : $this->sendError(__('Ticket not found or customer already assigned.', 'fluent-support'));
1780 + $this->ensureCanAccessTicket($ticket);
1519 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 +
1520 1799 } catch (\Exception $e) {
1521 1800 return $this->sendError([
1522 1801 'message' => Helper::getSafeErrorMessage($e)
1523 1802 ]);
@@ -1538,14 +1817,22 @@
1538 1817 'rendered_fields' => []
1539 1818 ];
1540 1819 }
1541 1820
1542 - $ticket = Ticket::findOrFail($ticket_id);
1821 + try {
1822 + $ticket = Ticket::findOrFail($ticket_id);
1543 1823
1544 - return [
1545 - 'custom_data' => (object)$ticket->customData(),
1546 - 'rendered_fields' => \FluentSupportPro\App\Services\CustomFieldsService::getRenderedPublicFields($ticket->customer, 'admin')
1547 - ];
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 + }
1548 1835 }
1549 1836
1550 1837 /**
1551 1838 * syncFluentCrmTags method will synchronize the tags with Fluent CRM by contact id