PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 2.4.5
Fluent Support – Helpdesk & Customer Support Ticket System v2.4.5
2.4.5 2.4.0 2.3.2 2.3.1 2.3.0 2.2.1 2.2.0 trunk 1.10.0 1.10.1 1.10.2 1.10.3 1.10.4 1.10.5 1.4.0 1.4.1 1.4.2 1.4.5 1.4.6 1.4.7 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 All 69 releases
← All changes | app/Http/Controllers/TicketController.php +228 -236 2.4.0 → 2.4.5 View file →
@@ -10,9 +10,8 @@
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 14 use FluentSupport\App\Models\Product;
16 15 use FluentSupport\App\Models\Ticket;
17 16 use FluentSupport\App\Services\FluentCRMServices;
18 17 use FluentSupport\App\Services\Helper;
@@ -26,18 +25,21 @@
26 25 use FluentSupport\App\Services\Tickets\TicketService;
27 26 use FluentSupport\App\Services\Integrations\FluentBooking\FluentBookingService;
28 27
29 28 /**
30 - * TicketController class for REST API related to ticket
31 - * This class is responsible for getting / inserting/ modifying data for all request related to ticket
32 - * @package FluentSupport\App\Http\Controllers
33 - *
34 - * @version 1.0.0
29 + * Agent-side REST endpoints for tickets.
35 30 */
36 31 class TicketController extends Controller
37 32 {
38 33 /**
39 - * This `me` method will return the current user profile info
34 + * Most tickets one bulk request may change. Above the largest list page
35 + * size (150), so a full page always fits.
36 + */
37 + const MAX_BULK_TICKETS = 200;
38 +
39 + /**
40 + * Current user's profile, permissions and optionally the portal settings.
41 + *
40 42 * @param Request $request
41 43 * @return array
42 44 */
43 45 public function me(Request $request)
@@ -82,18 +84,15 @@
82 84 return $settings;
83 85 }
84 86
85 87 /**
86 - * index method will return the list of ticket based on the selected filter
87 88 * @param Request $request
88 89 * @return array
89 90 */
90 91 public function index(Request $request)
91 92 {
92 - //Selected filter type, either simple or Advanced
93 93 $filterType = $request->getSafe('filter_type', 'sanitize_text_field', 'simple');
94 94
95 - /*Prepare Query Arguments*/
96 95 $queryArgs = [
97 96 'with' => [],
98 97 'filter_type' => $filterType,
99 98 'sort_by' => sanitize_sql_orderby($request->getSafe('order_by', 'sanitize_text_field', 'id')),
@@ -99,15 +98,12 @@
99 98 'sort_by' => sanitize_sql_orderby($request->getSafe('order_by', 'sanitize_text_field', 'id')),
100 99 'sort_type' => $request->getSafe('order_type', 'sanitize_text_field', 'DESC') == 'DESC' ? 'DESC' : 'ASC',
101 100 ];
102 101
103 - //If the selected filter type is advanced
104 102 if ($filterType == 'advanced') {
105 103 $advanced_filters = map_deep($request->get('advanced_filters', []), 'sanitize_text_field');
106 - //Get the selected query params for advanced filter
107 104 $queryArgs['filters_groups_raw'] = json_decode($advanced_filters, true);
108 105 } else {
109 - //Selected filter type is simple
110 106 $queryArgs['simple_filters'] = map_deep($request->get('filters', []), 'sanitize_text_field');
111 107 $queryArgs['search'] = trim($request->getSafe('search', 'sanitize_text_field', ''));
112 108
113 109 if ($customerId = $request->getSafe('customer_id', 'intval')) {
@@ -113,9 +109,8 @@
113 109 if ($customerId = $request->getSafe('customer_id', 'intval')) {
114 110 $queryArgs['customer_id'] = $customerId;
115 111 }
116 112 }
117 - /*End Prepare Query Arguments*/
118 113
119 114 $ticketsModel = (new TicketQueryService($queryArgs))->getModel();
120 115
121 116 $ticketsModel = $ticketsModel->with([
@@ -131,9 +126,8 @@
131 126 $query->latest('id');
132 127 }
133 128 ]);
134 129
135 - // apply filters by access level
136 130 do_action_ref_array('fluent_support/tickets_query_by_permission_ref', [&$ticketsModel, false]);
137 131
138 132 $tickets = $ticketsModel->paginate();
139 133
@@ -138,9 +132,9 @@
138 132 $tickets = $ticketsModel->paginate();
139 133
140 134 $perPage = $request->getSafe('per_page', 'intval', 15);
141 135
142 - // Load live activity for small page sizes (board/kanban view)
136 + // Small pages come from the board view, which shows live activity.
143 137 if ($perPage < 15) {
144 138 TicketHelper::loadBatchLiveActivities($tickets);
145 139 }
146 140
@@ -149,9 +143,10 @@
149 143 ];
150 144 }
151 145
152 146 /**
153 - * createTicket method will create new ticket as well as customer or WP user
147 + * Create a ticket, creating the customer and optionally a WP user first.
148 + *
154 149 * @param TicketRequest $request
155 150 * @return array
156 151 */
157 152 public function createTicket(TicketRequest $request)
@@ -156,28 +151,20 @@
156 151 */
157 152 public function createTicket(TicketRequest $request)
158 153 {
159 154 try {
160 - //Sanitize and validate request data via TicketRequest
161 155 $data = $request->sanitize();
162 156 $ticketData = $data['ticket'];
163 157 $maybeNewCustomer = Arr::get($data, 'newCustomer', []);
164 158
165 - //Include attachments if provided
166 159 if (!empty($data['attachments'])) {
167 160 $ticketData['attachments'] = $data['attachments'];
168 161 }
169 162
170 - /*
171 - * If customer_id is not provided, attempt to create a new customer
172 - * This handles WP user creation and customer creation
173 - */
174 163 if (empty($ticketData['customer_id'])) {
175 164 $createdUserId = false;
176 165
177 - //If user selected create WP user during ticket creation
178 166 if (Arr::get($ticketData, 'create_wp_user') == 'yes' && !empty($maybeNewCustomer['username'])) {
179 - //Check if username already in use, if not create new user
180 167 if (!username_exists($maybeNewCustomer['username'])) {
181 168 $authController = new AuthController();
182 169 $createdUserId = $authController->createUser($maybeNewCustomer);
183 170 $authController->maybeUpdateUser($createdUserId, $maybeNewCustomer);
@@ -190,15 +177,13 @@
190 177 'message' => __('A valid email is required to create a ticket', 'fluent-support')
191 178 ]);
192 179 }
193 180
194 - //Check if customer already exists by email
195 181 $existingCustomer = Customer::where('email', $email)->first();
196 182
197 183 if ($existingCustomer) {
198 184 $ticketData['customer_id'] = $existingCustomer->id;
199 185 } else {
200 - //Create the customer now
201 186 $customerData = Arr::only($maybeNewCustomer, (new Customer())->getFillable());
202 187 $customerData['user_id'] = $createdUserId;
203 188 $customerData = array_filter($customerData);
204 189
@@ -203,24 +188,22 @@
203 188 $customerData = array_filter($customerData);
204 189
205 190 $createCustomer = Customer::create($customerData);
206 191
207 - do_action('fluent_support/customer_created', $createCustomer);
208 -
209 - if (!$createCustomer) {
192 + if (!$createCustomer || !$createCustomer->exists) {
210 193 return $this->sendError([
211 194 'message' => __('Customer could not be created', 'fluent-support')
212 195 ]);
213 196 }
214 197
198 + do_action('fluent_support/customer_created', $createCustomer);
199 +
215 200 $ticketData['customer_id'] = $createCustomer->id;
216 201 }
217 202 }
218 203
219 - //Get customer information from db
220 204 $customer = Customer::findOrFail($ticketData['customer_id']);
221 205
222 - //Sanitize, store ticket, handle attachments, fire hooks
223 206 $createdTicket = (new TicketService())->storeTicket($ticketData, $customer);
224 207
225 208 return [
226 209 'message' => __('Ticket has been created successfully', 'fluent-support'),
@@ -233,17 +216,15 @@
233 216 }
234 217 }
235 218
236 219 /**
237 - * getTicket method will return ticket information by ticket id
238 220 * @param Request $request
239 - * @param $ticket_id
221 + * @param int $ticket_id
240 222 * @return array
241 223 */
242 224 public function getTicket(Request $request, $ticket_id)
243 225 {
244 226 try {
245 - //Get logged in agent information
246 227 $agent = Helper::getAgentByUserId();
247 228
248 229 $ticketWith = $request->get('with');
249 230 $ticketWith = is_array($ticketWith) ? map_deep($ticketWith, 'sanitize_text_field') : null;
@@ -253,12 +234,10 @@
253 234 $q->where('status', 'active');
254 235 }];
255 236 }
256 237
257 - //Get ticket by id
258 238 $ticket = Ticket::with($ticketWith)->findOrFail($ticket_id);
259 239
260 - //Eager load responses with their nested relations to avoid N+1 queries
261 240 $ticket->load(['responses' => function ($q) {
262 241 $q->with([
263 242 'person',
264 243 'ccinfo',
@@ -267,20 +246,18 @@
267 246 }
268 247 ]);
269 248 }]);
270 249
271 - //Check if ticket is in a restricted mailbox
272 250 $restrictedBusinessBoxes = PermissionManager::getRestrictedMailboxIds();
273 251
274 252 if (in_array($ticket->mailbox_id, $restrictedBusinessBoxes)) {
275 - throw new \Exception(esc_html__('Ticket cannot be fetched due to restricted mailbox', 'fluent-support'));
253 + throw new \Exception(esc_html__('Ticket cannot be fetched due to restricted mailbox', 'fluent-support'), 403);
276 254 }
277 255
278 256 $this->ensureCanAccessTicket($ticket);
279 257
280 - //If ticket has customer, set custom fields and profile url
281 258 if ($ticket->customer) {
282 - $customFieldsKey = apply_filters('fluent_support/custom_registration_form_fields_key', Helper::getBusinessSettings('custom_registration_form_field'));
259 + $customFieldsKey = Helper::getCustomRegistrationFieldKeys();
283 260 $ticket->customer->custom_field_keys = $customFieldsKey;
284 261
285 262 if ($ticket->customer->user_id) {
286 263 $customFieldKeysUsingHook = apply_filters('fluent_support/custom_registration_form_fields_key', []);
@@ -296,14 +273,12 @@
296 273
297 274 $ticket->customer->profile_edit_url = $ticket->customer->getUserProfileEditUrl();
298 275 }
299 276
300 - //If ticket is closed, load closed by person
301 277 if ($ticket->status == 'closed') {
302 278 $ticket->load('closed_by_person');
303 279 }
304 280
305 - //Load agent feedback ratings if pro is active and feature is enabled
306 281 if (defined('FLUENTSUPPORTPRO_PLUGIN_VERSION') && Helper::isAgentFeedbackEnabled()) {
307 282 $responseIds = $ticket->responses->pluck('id')->toArray();
308 283 $feedbacks = Meta::where('object_type', 'conversation_meta')
309 284 ->where('key', 'agent_feedback_ratings')
@@ -325,9 +300,8 @@
325 300
326 301 $contents = Helper::refreshSignedAttachmentUrlsInContents($contents, $ticket->id);
327 302 $ticket->content = $contents['ticket'];
328 303
329 - //Format response content
330 304 foreach ($ticket->responses as $response) {
331 305 $responseKey = 'response_' . $response->id;
332 306 if (isset($contents[$responseKey])) {
333 307 $response->content = $contents[$responseKey];
@@ -379,12 +353,10 @@
379 353 $ticketContent,
380 354 $ticket
381 355 );
382 356
383 - //Get last activity by agent
384 357 $ticket->live_activity = TicketHelper::getActivity($ticket->id, $agent->id);
385 358
386 - //Get all carbon copy customer
387 359 $ccInfo = $ticket->getSettingsValue('cc_email', []);
388 360 $ticket->carbon_copy = !empty($ccInfo) ? implode(', ', $ccInfo) : '';
389 361
390 362 if (defined('FLUENTSUPPORTPRO')) {
@@ -390,13 +362,13 @@
390 362 if (defined('FLUENTSUPPORTPRO')) {
391 363 $ticket->custom_fields = $ticket->customData('admin', true);
392 364 }
393 365
394 - // Load agent info if ticket was created on behalf of customer
366 + // Set when an agent opened the ticket on the customer's behalf.
395 367 if ($ticket->created_by) {
396 368 $ticket->load('created_by_person');
397 369 if ($ticket->created_by_person) {
398 - $isAgentInitiated = strpos($ticket->content, __(' initialized this ticket', 'fluent-support')) !== false;
370 + $isAgentInitiated = $ticket instanceof Ticket && $ticket->isAgentInitiated();
399 371 $ticket->created_by_agent = [
400 372 'id' => $ticket->created_by_person->id,
401 373 'full_name' => $ticket->created_by_person->full_name,
402 374 'agent_initiated' => $isAgentInitiated,
@@ -434,9 +406,9 @@
434 406 try {
435 407 $ticket = Ticket::findOrFail($ticket_id);
436 408
437 409 if (in_array($ticket->mailbox_id, PermissionManager::getRestrictedMailboxIds())) {
438 - throw new \Exception(esc_html__('Ticket cannot be fetched due to restricted mailbox', 'fluent-support'));
410 + throw new \Exception(esc_html__('Ticket cannot be fetched due to restricted mailbox', 'fluent-support'), 403);
439 411 }
440 412
441 413 $this->ensureCanAccessTicket($ticket);
442 414
@@ -508,11 +480,9 @@
508 480 return $restrictions;
509 481 }
510 482
511 483 /**
512 - * createResponse method will create response by agent for the ticket
513 - * @param Request $request
514 - * @param Ticket $ticket
484 + * @param TicketResponseRequest $request
515 485 * @param int $ticket_id
516 486 * @return array
517 487 * @throws \FluentSupport\Framework\Validator\ValidationException
518 488 */
@@ -523,13 +493,13 @@
523 493 try {
524 494 $convoType = Arr::get($data, 'conversation_type', 'response');
525 495 $isDraft = $convoType === 'draft_response';
526 496
527 - if (!$isDraft) {
497 + // close_ticket always needs manage rights, even on an otherwise-permitted draft save.
498 + if (!$isDraft || Arr::get($data, 'close_ticket') == 'yes') {
528 499 $this->ensureCanManageTickets();
529 500 }
530 501
531 - //Get logged-in agent information
532 502 $agent = Helper::getAgentByUserId();
533 503
534 504 if (!$agent) {
535 505 return $this->sendError([
@@ -540,8 +510,20 @@
540 510 $ticket = Ticket::findOrFail($ticket_id);
541 511
542 512 $this->ensureCanAccessTicket($ticket);
543 513
514 + if ($convoType === 'response' && $request->getSafe('force_send', 'sanitize_text_field') !== 'yes') {
515 + $lastSeenId = $request->get('last_conversation_id');
516 + $newerResponses = $lastSeenId === null || $lastSeenId === '' ? [] : $this->responsesAddedSince($ticket, (int) $lastSeenId, $agent);
517 + if ($newerResponses) {
518 + return $this->sendError([
519 + 'code' => 'fs_newer_responses',
520 + 'message' => __('Someone replied to this ticket while you were writing.', 'fluent-support'),
521 + 'new_responses' => $newerResponses
522 + ], 409);
523 + }
524 + }
525 +
544 526 $responseData = (new ResponseService())->createResponse($data, $agent, $ticket);
545 527
546 528 $responseData['response']->content = Helper::refreshSignedAttachmentUrls($responseData['response']->content, $ticket->id);
547 529 $responseData['response']->load([
@@ -548,9 +530,9 @@
548 530 'attachments' => function ($q) {
549 531 $q->where('status', 'active');
550 532 }
551 533 ]);
552 - $responseData['response']->content = wp_specialchars_decode(wpautop($responseData['response']->content, false));
534 + $responseData['response']->content = wpautop($responseData['response']->content, false);
553 535
554 536 return [
555 537 'message' => __('Response has been added', 'fluent-support'),
556 538 'response' => $responseData['response'],
@@ -566,9 +548,9 @@
566 548
567 549 public function getFluentBookingEventTypes()
568 550 {
569 551 try {
570 - // All FluentBooking endpoints require manage permission; view-only agents cannot call a meeting.
552 + // All FluentBooking endpoints need manage permission. View-only agents can't book meetings.
571 553 $this->ensureCanManageTickets();
572 554
573 555 $service = new FluentBookingService();
574 556 $eventTypes = $service->getEventTypes();
@@ -586,14 +568,12 @@
586 568
587 569 public function createFluentBookingLink(Request $request, $ticket_id)
588 570 {
589 571 try {
590 - // All FluentBooking endpoints require manage permission; view-only agents cannot call a meeting.
591 572 $this->ensureCanManageTickets();
592 573
593 574 $ticket = Ticket::with('customer')->findOrFail($ticket_id);
594 575
595 - // Enforces per-ticket visibility (e.g. own-tickets-only agents cannot access unassigned tickets).
596 576 $this->ensureCanAccessTicket($ticket);
597 577
598 578 $eventId = $request->getSafe('event_type_id', 'intval');
599 579
@@ -617,14 +597,12 @@
617 597
618 598 public function getFluentBookingAvailability(Request $request, $ticket_id)
619 599 {
620 600 try {
621 - // All FluentBooking endpoints require manage permission; view-only agents cannot call a meeting.
622 601 $this->ensureCanManageTickets();
623 602
624 603 $ticket = Ticket::with('customer')->findOrFail($ticket_id);
625 604
626 - // Enforces per-ticket visibility (e.g. own-tickets-only agents cannot access unassigned tickets).
627 605 $this->ensureCanAccessTicket($ticket);
628 606
629 607 $eventId = $request->getSafe('event_type_id', 'intval');
630 608
@@ -652,14 +630,12 @@
652 630
653 631 public function getFluentBookingMeetings($ticket_id)
654 632 {
655 633 try {
656 - // All FluentBooking endpoints require manage permission; view-only agents cannot call a meeting.
657 634 $this->ensureCanManageTickets();
658 635
659 636 $ticket = Ticket::with('customer')->findOrFail($ticket_id);
660 637
661 - // Enforces per-ticket visibility (e.g. own-tickets-only agents cannot access unassigned tickets).
662 638 $this->ensureCanAccessTicket($ticket);
663 639
664 640 return [
665 641 'meetings' => (new FluentBookingService())->getTicketMeetings($ticket)
@@ -671,11 +647,9 @@
671 647 }
672 648 }
673 649
674 650 /**
675 - * createDraft method will create draft by agent for the ticket
676 - * @param Request $request
677 - * @param Ticket $ticket
651 + * @param TicketResponseRequest $request
678 652 * @param int $ticket_id
679 653 * @return array
680 654 * @throws \FluentSupport\Framework\Validator\ValidationException
681 655 */
@@ -683,9 +657,8 @@
683 657 {
684 658 $data = $request->sanitize();
685 659
686 660 try {
687 - //Get logged-in agent information
688 661 $agent = Helper::getAgentByUserId();
689 662
690 663 if (!$agent) {
691 664 return $this->sendError([
@@ -731,9 +704,8 @@
731 704
732 705 public function getDraft($ticket_id)
733 706 {
734 707 try {
735 - //Get logged-in agent information
736 708 $agent = Helper::getAgentByUserId();
737 709
738 710 if (!$agent) {
739 711 return $this->sendError([
@@ -788,15 +760,14 @@
788 760 'message' => __('Draft not found', 'fluent-support'),
789 761 ]);
790 762 }
791 763
792 - // Authorize the ticket this draft belongs to (closes the mailbox/visibility
793 - // dimension for managers deleting other agents' drafts).
764 + // Managers can delete other agents' drafts, but only on tickets they can access.
794 765 $ticket = Ticket::findOrFail($draft->object_id);
795 766
796 767 $this->ensureCanAccessTicket($ticket);
797 768
798 - // Verify ownership: draft key contains agent_id, only managers can delete others' drafts
769 + // The draft key embeds the owning agent's id.
799 770 $isOwnDraft = strpos($draft->key, '_agent_id_' . $agent->id . '_') !== false;
800 771
801 772 if (!$isOwnDraft && !PermissionManager::canManageTickets()) {
802 773 return $this->sendError([
@@ -816,17 +787,17 @@
816 787 }
817 788 }
818 789
819 790 /**
820 - * getTicketWidgets method generate additional information for a ticket by customer
821 - * @param Ticket $ticket
822 - * @param $ticket_id
791 + * The customer's other tickets, plus extra profile widgets on request.
792 + *
793 + * @param Request $request
794 + * @param int $ticket_id
823 795 * @return array
824 796 */
825 797 public function getTicketWidgets(Request $request, $ticket_id)
826 798 {
827 799 try {
828 - //Get ticket with customer by ticket id
829 800 $ticket = Ticket::with('customer')->findOrFail($ticket_id);
830 801
831 802 $this->ensureCanAccessTicket($ticket);
832 803
@@ -866,12 +837,10 @@
866 837 }
867 838 }
868 839
869 840 /**
870 - * updateTicketProperty method will update ticket property
871 841 * @param Request $request
872 - * @param Ticket $ticket
873 - * @param $ticket_id
842 + * @param int $ticket_id
874 843 * @return array
875 844 */
876 845 public function updateTicketProperty(Request $request, $ticket_id)
877 846 {
@@ -883,15 +852,9 @@
883 852
884 853 $propName = $request->getSafe('prop_name', 'sanitize_text_field');
885 854 $propValue = $request->getSafe('prop_value', 'sanitize_text_field');
886 855
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.
856 + // prop_name is assigned straight onto the model, so it must be allowlisted.
894 857 if (!in_array($propName, $this->updatableTicketProperties(), true)) {
895 858 throw new \Exception(esc_html__('This ticket property cannot be updated.', 'fluent-support'), 403);
896 859 }
897 860
@@ -902,10 +865,9 @@
902 865 if ($propName && $propValue !== null && $prevValue != $propValue) {
903 866 $ticket->{$propName} = $propValue;
904 867 $ticket->save();
905 868
906 - // Log an internal note for status changes so the activity is
907 - // traceable, mirroring the close/reopen flows.
869 + // Logged like the close/reopen flows do.
908 870 if ($propName === 'status') {
909 871 $statuses = Helper::ticketStatuses();
910 872 $fromLabel = isset($statuses[$prevValue]) ? $statuses[$prevValue] : $prevValue;
911 873 $toLabel = isset($statuses[$propValue]) ? $statuses[$propValue] : $propValue;
@@ -958,14 +920,59 @@
958 920 }
959 921 }
960 922
961 923 /**
924 + * Replies by anyone else (the customer or another agent) added after the
925 + * conversation the agent last saw. The admin reply box sends that id so a
926 + * reply written against an outdated thread is held back; callers that send
927 + * no id (MCP, API, bulk reply) are never checked.
928 + *
929 + * @param Ticket $ticket
930 + * @param int $lastSeenId
931 + * @param \FluentSupport\App\Models\Agent $agent
932 + * @return array
933 + */
934 + protected function responsesAddedSince(Ticket $ticket, $lastSeenId, $agent)
935 + {
936 + if ($lastSeenId < 0) {
937 + return [];
938 + }
939 +
940 + $newer = Conversation::where('ticket_id', $ticket->id)
941 + ->where('id', '>', $lastSeenId)
942 + ->where('conversation_type', 'response')
943 + ->where('person_id', '!=', $agent->id)
944 + ->with(['person' => function ($query) {
945 + $query->select(['id', 'first_name', 'last_name', 'person_type']);
946 + }])
947 + ->orderBy('id', 'ASC')
948 + ->limit(10)
949 + ->get();
950 +
951 + return $newer->map(function ($conversation) {
952 + $person = $conversation->person;
953 + return [
954 + 'id' => (int) $conversation->id,
955 + 'person_name' => $person ? trim($person->first_name . ' ' . $person->last_name) : '',
956 + 'person_type' => $person ? $person->person_type : '',
957 + 'created_at' => (string) $conversation->created_at,
958 + ];
959 + })->all();
960 + }
961 +
962 + /**
962 963 * 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.
964 + * This mirrors exactly what the admin UI edits here (agent, title, product,
965 + * status and the two priority fields). Ownership, audit, public-identifier
966 + * and other sensitive columns are intentionally absent and must go through
967 + * their dedicated workflows.
967 968 *
969 + * mailbox_id is deliberately NOT listed: moving a ticket between mailboxes
970 + * has to fire fluent_support/before_move_tickets and
971 + * fluent_support/tickets_moved, which only MailBoxService::moveTickets()
972 + * does. Assigning the column here would move the ticket silently, so the
973 + * mailbox switcher posts to mailboxes/{id}/move_tickets instead.
974 + *
968 975 * @return array
969 976 */
970 977 protected function updatableTicketProperties()
971 978 {
@@ -971,9 +978,8 @@
971 978 {
972 979 return [
973 980 'agent_id',
974 981 'title',
975 - 'mailbox_id',
976 982 'product_id',
977 983 'status',
978 984 'priority',
979 985 'client_priority',
@@ -980,12 +986,10 @@
980 986 ];
981 987 }
982 988
983 989 /**
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.
990 + * Validate a property update against its allowed values and the
991 + * capability the UI requires for it.
988 992 *
989 993 * @param Ticket $ticket
990 994 * @param string $propName Already confirmed to be in the allowlist.
991 995 * @param string $propValue Raw (text-sanitized) value from the request.
@@ -1002,15 +1006,10 @@
1002 1006 }
1003 1007 return $propValue;
1004 1008
1005 1009 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.
1010 + // The status dropdown in ViewTicket.vue submits the group key from
1011 + // changeable_ticket_statuses and hides empty groups. Match that.
1013 1012 $allowedStatuses = [];
1014 1013 foreach (Helper::changeableTicketStatuses() as $statusKey => $statusGroup) {
1015 1014 if (!empty($statusGroup)) {
1016 1015 $allowedStatuses[] = $statusKey;
@@ -1020,11 +1019,10 @@
1020 1019 if (!in_array($propValue, $allowedStatuses, true)) {
1021 1020 throw new \Exception(esc_html__('Invalid ticket status.', 'fluent-support'), 422);
1022 1021 }
1023 1022
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.
1023 + // Closing and reopening need TicketService's side effects and hooks.
1024 + // Use closeTicket() / reOpenTicket().
1027 1025 if ($propValue === 'closed' || $ticket->status === 'closed') {
1028 1026 throw new \Exception(esc_html__('Closing or reopening a ticket must use the dedicated close and re-open actions.', 'fluent-support'), 422);
1029 1027 }
1030 1028
@@ -1061,49 +1059,16 @@
1061 1059 if (in_array((int) $ticket->mailbox_id, $restrictedBoxes, true)) {
1062 1060 throw new \Exception(esc_html__('Agent is restricted for this mailbox ticket', 'fluent-support'), 403);
1063 1061 }
1064 1062 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 1063 }
1097 1064
1098 - // Unreachable: updateTicketProperty already rejected non-allowlisted
1099 - // properties before calling this method. Fail closed regardless.
1065 + // Unreachable after the allowlist check. Fail closed anyway.
1100 1066 throw new \Exception(esc_html__('This ticket property cannot be updated.', 'fluent-support'), 403);
1101 1067 }
1102 1068
1103 1069 /**
1104 - * closeTicket method close the ticket by id
1105 - * @param Ticket $ticket
1070 + * @param Request $request
1106 1071 * @param int $ticket_id
1107 1072 * @return array
1108 1073 */
1109 1074 public function closeTicket(Request $request, $ticket_id)
@@ -1127,11 +1092,9 @@
1127 1092 }
1128 1093 }
1129 1094
1130 1095 /**
1131 - * reOpenTicket method will reopen a closed ticket
1132 - * @param Request $request
1133 - * @param $ticket_id
1096 + * @param int $ticket_id
1134 1097 * @return array
1135 1098 */
1136 1099 public function reOpenTicket($ticket_id)
1137 1100 {
@@ -1152,12 +1115,9 @@
1152 1115 }
1153 1116 }
1154 1117
1155 1118 /**
1156 - * doBulkActions method is responsible for bulk action
1157 - * This function will get ticket ids and action as parameter and perform action based on the selection
1158 1119 * @param Request $request
1159 - * @param Ticket $ticket
1160 1120 * @return array|string[]|void
1161 1121 * @throws \Exception
1162 1122 */
1163 1123 public function doBulkActions(Request $request)
@@ -1163,18 +1123,24 @@
1163 1123 public function doBulkActions(Request $request)
1164 1124 {
1165 1125 try {
1166 1126 $action = $request->getSafe('bulk_action', 'sanitize_text_field');
1167 - $ticketIds = array_map('intval', $request->get('ticket_ids', null, []));
1127 + $ticketIds = array_map('intval', (array) $request->get('ticket_ids', null, []));
1168 1128
1129 + if (count($ticketIds) > self::MAX_BULK_TICKETS) {
1130 + throw new \Exception(sprintf(
1131 + /* translators: %d is the most tickets one bulk action can change */
1132 + esc_html__('You can change at most %d tickets at a time.', 'fluent-support'),
1133 + (int) self::MAX_BULK_TICKETS
1134 + ), 422);
1135 + }
1136 +
1169 1137 $agent = Helper::getAgentByUserId();
1170 1138 $query = Ticket::whereIn('id', $ticketIds);
1171 1139
1172 - //Scope selected tickets to what the agent can access, matching the
1173 - //per-ticket ensureCanAccessTicket() check on the single-ticket routes
1140 + // Same access rules as ensureCanAccessTicket() on single-ticket routes.
1174 1141 (new AgentTicketAccess())->applyAccessScope($query, $agent);
1175 1142
1176 - //If bulk action is close tickets
1177 1143 if ($action == 'close_tickets') {
1178 1144 $tickets = $query->get();
1179 1145 $tickets->each(function ($ticket) use ($agent) {
1180 1146 (new TicketService())->close($ticket, $agent);
@@ -1222,9 +1188,8 @@
1222 1188
1223 1189 $tickets->each(function ($ticket) use ($assignAgent, $agent, $restrictedBoxes, &$assignedCount, &$skippedCount) {
1224 1190 $previousAgentId = (int) $ticket->agent_id;
1225 1191
1226 - //Skip ticket if mailbox is restricted for the agent
1227 1192 if (!empty($ticket->mailbox_id) && in_array((int) $ticket->mailbox_id, $restrictedBoxes, true)) {
1228 1193 $skippedCount++;
1229 1194 return;
1230 1195 }
@@ -1284,9 +1249,9 @@
1284 1249
1285 1250 $ticket->agent_id = $selectedAgent->id;
1286 1251 $ticket->save();
1287 1252 $assignedCount++;
1288 - $currentCounts[$selectedAgent->id]++;
1253 + $currentCounts[$selectedAgent->id] = ($currentCounts[$selectedAgent->id] ?? 0) + 1;
1289 1254
1290 1255 as_enqueue_async_action('fluent_support/async_agent_assigned_to_ticket', [
1291 1256 $selectedAgent->id, $ticket->id, $agent->id, $previousAgentId
1292 1257 ], 'fluent-support');
@@ -1313,8 +1278,67 @@
1313 1278
1314 1279 return [
1315 1280 'message' => __('Selected tags has been added to tickets', 'fluent-support')
1316 1281 ];
1282 + } else if ($action == 'remove_tags') {
1283 + $tagIds = $request->get('tag_ids', null);
1284 + $tags = array_filter(array_map('absint', is_array($tagIds) ? $tagIds : []));
1285 +
1286 + if (!$tags) {
1287 + throw new \Exception(esc_html__('Please select tags to remove', 'fluent-support'), 422);
1288 + }
1289 +
1290 + $query->get()->each(function ($ticket) use ($tags) {
1291 + $ticket->detachTags($tags);
1292 + });
1293 +
1294 + return [
1295 + 'message' => __('Selected tags have been removed from the tickets', 'fluent-support')
1296 + ];
1297 + } else if ($action == 'change_priority') {
1298 + $priority = $request->getSafe('priority', 'sanitize_text_field');
1299 +
1300 + if (!array_key_exists($priority, Helper::adminTicketPriorities())) {
1301 + throw new \Exception(esc_html__('Invalid ticket priority.', 'fluent-support'), 422);
1302 + }
1303 +
1304 + return [
1305 + 'message' => sprintf(
1306 + /* translators: %d is the number of tickets whose priority changed */
1307 + __('Priority changed on %d tickets.', 'fluent-support'),
1308 + $this->bulkSetProperty($query, 'priority', $priority)
1309 + )
1310 + ];
1311 + } else if ($action == 'change_product') {
1312 + $productId = $request->getSafe('product_id', 'intval');
1313 +
1314 + if (!$productId || !Product::where('id', $productId)->exists()) {
1315 + throw new \Exception(esc_html__('Invalid product.', 'fluent-support'), 422);
1316 + }
1317 +
1318 + return [
1319 + 'message' => sprintf(
1320 + /* translators: %d is the number of tickets whose product changed */
1321 + __('Product changed on %d tickets.', 'fluent-support'),
1322 + $this->bulkSetProperty($query, 'product_id', $productId)
1323 + )
1324 + ];
1325 + } else if ($action == 'reopen_tickets') {
1326 + // Only closed tickets reopen; the rest are left as they are.
1327 + $tickets = $query->where('status', 'closed')->get();
1328 + $ticketService = new TicketService();
1329 +
1330 + foreach ($tickets as $ticket) {
1331 + $ticketService->reopen($ticket, $agent);
1332 + }
1333 +
1334 + return [
1335 + 'message' => sprintf(
1336 + /* translators: %d is the number of tickets reopened */
1337 + __('%d tickets have been reopened.', 'fluent-support'),
1338 + count($tickets)
1339 + )
1340 + ];
1317 1341 }
1318 1342
1319 1343 throw new \Exception(esc_html__('Sorry no action found as available', 'fluent-support'));
1320 1344 } catch (\Exception $e) {
@@ -1324,9 +1348,34 @@
1324 1348 }
1325 1349 }
1326 1350
1327 1351 /**
1328 - * deleteTicket method will delete a ticket
1352 + * Set one column on every ticket the bulk query matches, skipping tickets
1353 + * that already have the value.
1354 + *
1355 + * @param \FluentSupport\Framework\Database\Orm\Builder $query
1356 + * @param string $column
1357 + * @param mixed $value
1358 + * @return int Number of tickets changed.
1359 + */
1360 + protected function bulkSetProperty($query, $column, $value)
1361 + {
1362 + $changed = 0;
1363 +
1364 + /** @var Ticket $ticket */
1365 + foreach ($query->get() as $ticket) {
1366 + if ($ticket->{$column} == $value) {
1367 + continue;
1368 + }
1369 + $ticket->{$column} = $value;
1370 + $ticket->save();
1371 + $changed++;
1372 + }
1373 +
1374 + return $changed;
1375 + }
1376 +
1377 + /**
1329 1378 * @param int $ticket_id
1330 1379 * @return array
1331 1380 */
1332 1381 public function deleteTicket($ticket_id)
@@ -1348,12 +1397,9 @@
1348 1397 }
1349 1398 }
1350 1399
1351 1400 /**
1352 - * doBulkReplies method will create response for bulk tickets
1353 - * This function will get ticket ids, content, attachment etc and create response for tickets
1354 1401 * @param Request $request
1355 - * @param Conversation $conversation
1356 1402 * @return array
1357 1403 * @throws \Exception
1358 1404 */
1359 1405 public function doBulkReplies(Request $request)
@@ -1358,9 +1404,8 @@
1358 1404 */
1359 1405 public function doBulkReplies(Request $request)
1360 1406 {
1361 1407 try {
1362 - // Sanitize all request data before validation
1363 1408 $requestData = $request->all();
1364 1409 $data = [];
1365 1410 foreach ($requestData as $key => $value) {
1366 1411 if (is_array($value)) {
@@ -1380,15 +1425,13 @@
1380 1425 'content' => 'required',
1381 1426 'ticket_ids' => 'required|array'
1382 1427 ]);
1383 1428
1384 - //Get logged in agent information
1385 1429 $agent = Helper::getAgentByUserId();
1386 1430 $ticketIds = array_filter($data['ticket_ids'], 'absint');
1387 1431
1388 1432 $query = Ticket::whereIn('id', $ticketIds)->where('status', '!=', 'closed');
1389 1433
1390 - // Scope to tickets the agent may access (visibility + mailbox restrictions).
1391 1434 (new AgentTicketAccess())->applyAccessScope($query, $agent);
1392 1435
1393 1436 $tickets = $query->get();
1394 1437
@@ -1401,9 +1444,8 @@
1401 1444 'conversation_type' => 'response',
1402 1445 'close_ticket' => Arr::get($data, 'close_ticket'),
1403 1446 ];
1404 1447
1405 - //If request with file attachments
1406 1448 $attachmentHashes = Arr::get($data, 'attachments', []);
1407 1449 $attachments = false;
1408 1450 if ($attachmentHashes) {
1409 1451 $attachments = Attachment::whereNull('ticket_id')
@@ -1450,13 +1492,10 @@
1450 1492 }
1451 1493 }
1452 1494
1453 1495 /**
1454 - * deleteResponse method will remove a response from ticket by ticket id and response id
1455 - * @param Request $request
1456 - * @param Conversation $conversation
1457 - * @param $ticket_id
1458 - * @param $response_id
1496 + * @param int $ticket_id
1497 + * @param int $response_id
1459 1498 * @return array
1460 1499 */
1461 1500 public function deleteResponse($ticket_id, $response_id)
1462 1501 {
@@ -1463,17 +1502,14 @@
1463 1502 try {
1464 1503 $ticket = Ticket::findOrFail($ticket_id);
1465 1504
1466 1505 if (in_array($ticket->mailbox_id, PermissionManager::getRestrictedMailboxIds())) {
1467 - throw new \Exception(esc_html__('Ticket cannot be fetched due to restricted mailbox', 'fluent-support'));
1506 + throw new \Exception(esc_html__('Ticket cannot be fetched due to restricted mailbox', 'fluent-support'), 403);
1468 1507 }
1469 1508
1470 - // The caller must have access to this specific ticket (visibility +
1471 - // ownership + mailbox), not merely a global manage capability.
1472 1509 $this->ensureCanAccessTicket($ticket);
1473 1510
1474 - // Deleting a response always requires the explicit delete capability,
1475 - // mirroring deleteTicket(). Assignment alone is not sufficient.
1511 + // Being assigned to the ticket isn't enough to delete a response.
1476 1512 if (!PermissionManager::currentUserCan('fst_delete_tickets')) {
1477 1513 throw new \Exception(
1478 1514 esc_html__('Sorry, you do not have permission to delete this response.', 'fluent-support')
1479 1515 );
@@ -1496,10 +1532,9 @@
1496 1532 }
1497 1533 }
1498 1534
1499 1535 /**
1500 - * updateResponse method will update ticket response using ticket and response id
1501 - * @param Request $request
1536 + * @param TicketResponseRequest $request
1502 1537 * @param int $ticket_id
1503 1538 * @param int $response_id
1504 1539 * @return array
1505 1540 * @throws \Exception
@@ -1509,13 +1544,11 @@
1509 1544 try {
1510 1545 $ticket = Ticket::findOrFail($ticket_id);
1511 1546
1512 1547 if (in_array($ticket->mailbox_id, PermissionManager::getRestrictedMailboxIds())) {
1513 - throw new \Exception(esc_html__('Ticket cannot be fetched due to restricted mailbox', 'fluent-support'));
1548 + throw new \Exception(esc_html__('Ticket cannot be fetched due to restricted mailbox', 'fluent-support'), 403);
1514 1549 }
1515 1550
1516 - // The caller must have access to this specific ticket (visibility +
1517 - // ownership + mailbox), not merely a global manage capability.
1518 1551 $this->ensureCanAccessTicket($ticket);
1519 1552
1520 1553 $response = Conversation::where('id', $response_id)
1521 1554 ->where('ticket_id', $ticket_id)
@@ -1522,10 +1555,8 @@
1522 1555 ->with('person')
1523 1556 ->firstOrFail();
1524 1557 $agent = Helper::getAgentByUserId();
1525 1558
1526 - // Only agent-authored conversation types may be edited here. Customer
1527 - // replies and system entries must not be rewritten via this endpoint.
1528 1559 $editableTypes = ['response', 'draft_response', 'note', 'internal_info'];
1529 1560 if (!in_array($response->conversation_type, $editableTypes, true)) {
1530 1561 throw new \Exception(
1531 1562 esc_html__('This response type cannot be edited.', 'fluent-support')
@@ -1531,10 +1562,9 @@
1531 1562 esc_html__('This response type cannot be edited.', 'fluent-support')
1532 1563 );
1533 1564 }
1534 1565
1535 - // Customer messages share the 'response' type but are authored by a
1536 - // customer person; they are never editable by an agent.
1566 + // Customer replies also use the 'response' type.
1537 1567 if ($response->person && $response->person->person_type !== 'agent') {
1538 1568 throw new \Exception(
1539 1569 esc_html__('Sorry, you do not have permission to update this response.', 'fluent-support')
1540 1570 );
@@ -1544,9 +1574,8 @@
1544 1574 $isAuthor = (int) $response->person_id === (int) $agent->id;
1545 1575 $canApproveDraft = PermissionManager::currentUserCan('fst_approve_draft_reply');
1546 1576
1547 1577 if ($isDraft && !$isAuthor) {
1548 - // Another agent's draft can only be edited/approved by an approver.
1549 1578 if (!$canApproveDraft) {
1550 1579 throw new \Exception(
1551 1580 esc_html__('Sorry, You do not have permission to approve this draft response', 'fluent-support')
1552 1581 );
@@ -1551,16 +1580,14 @@
1551 1580 esc_html__('Sorry, You do not have permission to approve this draft response', 'fluent-support')
1552 1581 );
1553 1582 }
1554 1583 } elseif (!$isAuthor && !PermissionManager::currentUserCan('fst_manage_other_tickets')) {
1555 - // Editing another agent's response requires manage-others capability.
1556 1584 throw new \Exception(
1557 1585 esc_html__('Sorry, you do not have permission to update this response.', 'fluent-support')
1558 1586 );
1559 1587 }
1560 1588
1561 - // Request input is already unslashed at the boundary; unslashing again
1562 - // would strip literal backslashes out of the edited reply.
1589 + // Input is already unslashed. Unslashing again strips real backslashes.
1563 1590 $content = wp_kses_post($request->getSafe('content', 'wp_kses_post'));
1564 1591 $response->content = $content;
1565 1592
1566 1593 if ($isDraft && !$isAuthor && $canApproveDraft) {
@@ -1619,41 +1646,16 @@
1619 1646 }
1620 1647
1621 1648 protected function approveDraftConversation($ticket, $response, $person, $content)
1622 1649 {
1623 - $resetWaitingSince = apply_filters('fluent_support/reset_waiting_since', true, $content);
1624 -
1625 - $response->content = $content;
1626 - $response->conversation_type = 'response';
1627 - $response->created_at = current_time('mysql');
1628 - $response->save();
1629 -
1630 - if ($person->person_type == 'agent' && $ticket->status == 'new') {
1631 - $ticket->status = 'active';
1632 - if ($ticket->created_at) {
1633 - $ticket->first_response_time = strtotime(current_time('mysql')) - strtotime($ticket->created_at);
1634 - } else {
1635 - $ticket->first_response_time = 300;
1636 - }
1637 - }
1638 -
1639 - if ($resetWaitingSince) {
1640 - $ticket->last_agent_response = current_time('mysql');
1641 - $ticket->waiting_since = current_time('mysql');
1642 - }
1643 -
1644 - $ticket->response_count += 1;
1645 - $ticket->save();
1646 -
1647 - do_action('fluent_support/response_added_by_' . $person->person_type, $response, $ticket, $person);
1648 -
1649 - return $response;
1650 + return (new ResponseService())->publishDraft($ticket, $response, $person, $content);
1650 1651 }
1651 1652
1652 1653 /**
1653 - * getLiveActivity method will return the activity in a ticket by agents
1654 + * Agents currently viewing the ticket.
1655 + *
1654 1656 * @param Request $request
1655 - * @param $ticket_id
1657 + * @param int $ticket_id
1656 1658 * @return array
1657 1659 */
1658 1660 public function getLiveActivity(Request $request, $ticket_id)
1659 1661 {
@@ -1674,11 +1676,12 @@
1674 1676 }
1675 1677 }
1676 1678
1677 1679 /**
1678 - * removeLiveActivity method will remove activities that
1680 + * Remove the current agent from the ticket's live activity.
1681 + *
1679 1682 * @param Request $request
1680 - * @param $ticket_id
1683 + * @param int $ticket_id
1681 1684 * @return array
1682 1685 */
1683 1686 public function removeLiveActivity(Request $request, $ticket_id)
1684 1687 {
@@ -1700,11 +1703,10 @@
1700 1703 }
1701 1704 }
1702 1705
1703 1706 /**
1704 - * addTag method will add tag in ticket by ticket id
1705 1707 * @param Request $request
1706 - * @param $ticket_id
1708 + * @param int $ticket_id
1707 1709 * @return array
1708 1710 */
1709 1711 public function addTag(Request $request, $ticket_id)
1710 1712 {
@@ -1726,11 +1728,12 @@
1726 1728 }
1727 1729 }
1728 1730
1729 1731 /**
1730 - * detachTag method will remove all tags from tickets
1731 - * @param $ticket_id
1732 - * @param $tag_id
1732 + * Remove one tag from the ticket.
1733 + *
1734 + * @param int $ticket_id
1735 + * @param int $tag_id
1733 1736 * @return array
1734 1737 */
1735 1738 public function detachTag($ticket_id, $tag_id)
1736 1739 {
@@ -1752,11 +1755,10 @@
1752 1755 }
1753 1756 }
1754 1757
1755 1758 /**
1756 - * changeTicketCustomer method will update customer in a ticket
1757 - * This method will get ticket id and customer id as parameter, it will replace existing customer id with new
1758 1759 * @param Request $request
1760 + * @param int $ticket_id
1759 1761 * @return array
1760 1762 */
1761 1763 public function changeTicketCustomer(Request $request, $ticket_id)
1762 1764 {
@@ -1766,11 +1768,10 @@
1766 1768 if (!$newCustomerId) {
1767 1769 return $this->sendError(__('Invalid customer selected.', 'fluent-support'));
1768 1770 }
1769 1771
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.
1772 + // The ticket would expose the new customer's profile data, so this
1773 + // needs the same capability as the customer routes.
1773 1774 if (!PermissionManager::currentUserCan('fst_sensitive_data')) {
1774 1775 return $this->sendError(__('You do not have permission to change the ticket customer.', 'fluent-support'));
1775 1776 }
1776 1777
@@ -1803,11 +1804,10 @@
1803 1804 }
1804 1805 }
1805 1806
1806 1807 /**
1807 - * getTicketCustomData method will return the custom data by ticket id
1808 1808 * @param Request $request
1809 - * @param $ticket_id
1809 + * @param int $ticket_id
1810 1810 * @return array|array[]
1811 1811 */
1812 1812 public function getTicketCustomData(Request $request, $ticket_id)
1813 1813 {
@@ -1834,10 +1834,8 @@
1834 1834 }
1835 1835 }
1836 1836
1837 1837 /**
1838 - * syncFluentCrmTags method will synchronize the tags with Fluent CRM by contact id
1839 - *This function will get contact id and tags as parameter, get existing tags from crm and updated added/removed tags
1840 1838 * @param Request $request
1841 1839 * @param FluentCRMServices $fluentCRMServices
1842 1840 * @return array
1843 1841 */
@@ -1847,9 +1845,8 @@
1847 1845 'contact_id' => $request->getSafe('contact_id', 'intval'),
1848 1846 'tags' => $request->get('tags', null)
1849 1847 ];
1850 1848
1851 - // Sanitize tags array if it's an array
1852 1849 if (is_array($data['tags'])) {
1853 1850 $data['tags'] = array_map('intval', $data['tags']);
1854 1851 }
1855 1852
@@ -1862,10 +1859,8 @@
1862 1859 }
1863 1860 }
1864 1861
1865 1862 /**
1866 - * This `syncFluentCrmLists` method will synchronize the lists with Fluent CRM by contact id
1867 - * This method will get contact id and lists as parameter, get existing lists from crm and updated added/removed lists
1868 1863 * @param Request $request
1869 1864 * @param FluentCRMServices $fluentCRMServices
1870 1865 * @return array
1871 1866 */
@@ -1876,9 +1871,8 @@
1876 1871 'contact_id' => $request->getSafe('contact_id', 'intval'),
1877 1872 'lists' => $request->get('lists', null, [])
1878 1873 ];
1879 1874
1880 - // Sanitize lists array if it's an array
1881 1875 if (is_array($data['lists'])) {
1882 1876 $data['lists'] = array_map('intval', $data['lists']);
1883 1877 }
1884 1878
@@ -1891,12 +1885,10 @@
1891 1885 }
1892 1886 }
1893 1887
1894 1888 /**
1895 - * Get ticket essentials data based on the provided types.
1896 - *
1897 - * @param \Illuminate\Http\Request $request
1898 - * @return array The ticket essentials data.
1889 + * @param Request $request
1890 + * @return array
1899 1891 */
1900 1892 public function getTicketEssentials(Request $request)
1901 1893 {
1902 1894 $type = $request->getSafe('type', 'sanitize_text_field');