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