| @@ -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 |