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 +236 -234 2.3.2 → 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)
@@ -66,9 +68,9 @@
66 68 $businessSettings = (new \FluentSupport\App\Services\EmailNotification\Settings())->globalBusinessSettings();
67 69 $maxFileSize = absint($businessSettings['max_file_size']);
68 70
69 71 $portalSettings = [
70 - 'support_products' => \FluentSupport\App\Models\Product::select(['id', 'title'])->get(),
72 + 'support_products' => \FluentSupport\App\Models\Product::select(['id', 'title'])->orderedByTitle()->get(),
71 73 'customer_ticket_priorities' => Helper::customerTicketPriorities(),
72 74 'has_file_upload' => !!Helper::ticketAcceptedFileMiles(),
73 75 'has_rich_text_editor' => true,
74 76 'max_file_size' => $maxFileSize,
@@ -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 - // FS-SEC-007: 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 (FS-SEC-007).
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 (FS-SEC-007).
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;
@@ -1019,8 +1018,15 @@
1019 1018
1020 1019 if (!in_array($propValue, $allowedStatuses, true)) {
1021 1020 throw new \Exception(esc_html__('Invalid ticket status.', 'fluent-support'), 422);
1022 1021 }
1022 +
1023 + // Closing and reopening need TicketService's side effects and hooks.
1024 + // Use closeTicket() / reOpenTicket().
1025 + if ($propValue === 'closed' || $ticket->status === 'closed') {
1026 + throw new \Exception(esc_html__('Closing or reopening a ticket must use the dedicated close and re-open actions.', 'fluent-support'), 422);
1027 + }
1028 +
1023 1029 return $propValue;
1024 1030
1025 1031 case 'priority':
1026 1032 if (!array_key_exists($propValue, Helper::adminTicketPriorities())) {
@@ -1053,49 +1059,16 @@
1053 1059 if (in_array((int) $ticket->mailbox_id, $restrictedBoxes, true)) {
1054 1060 throw new \Exception(esc_html__('Agent is restricted for this mailbox ticket', 'fluent-support'), 403);
1055 1061 }
1056 1062 return $agentId;
1057 -
1058 - case 'mailbox_id':
1059 - // The admin UI only exposes the mailbox switcher to agents with
1060 - // fst_manage_settings; enforce the same gate on the API so the
1061 - // permission can't be bypassed by calling the endpoint directly.
1062 - if (!PermissionManager::currentUserCan('fst_manage_settings')) {
1063 - throw new \Exception(esc_html__('Permission denied to move this ticket to another mailbox.', 'fluent-support'), 403);
1064 - }
1065 -
1066 - $mailboxId = (int) $propValue;
1067 - $restrictedBoxes = array_map('intval', PermissionManager::getRestrictedMailboxIds());
1068 -
1069 - if (!MailBox::where('id', $mailboxId)->exists() || in_array($mailboxId, $restrictedBoxes, true)) {
1070 - throw new \Exception(esc_html__('Invalid or restricted mailbox.', 'fluent-support'), 422);
1071 - }
1072 -
1073 - // Preserve the agent/mailbox compatibility invariant that the
1074 - // agent_id branch enforces on assignment: a ticket must not be
1075 - // moved into a mailbox its currently assigned agent is restricted
1076 - // from, which would otherwise persist an assignment the assign
1077 - // flow would have rejected.
1078 - if ($ticket->agent_id) {
1079 - $assignedAgent = Agent::find($ticket->agent_id);
1080 - if ($assignedAgent) {
1081 - $agentRestrictedBoxes = (new AgentTicketAccess())->getRestrictedMailboxIds($assignedAgent);
1082 - if (in_array($mailboxId, $agentRestrictedBoxes, true)) {
1083 - throw new \Exception(esc_html__('The assigned agent is restricted from the selected mailbox. Reassign the ticket before moving it.', 'fluent-support'), 403);
1084 - }
1085 - }
1086 - }
1087 - return $mailboxId;
1088 1063 }
1089 1064
1090 - // Unreachable: updateTicketProperty already rejected non-allowlisted
1091 - // properties before calling this method. Fail closed regardless.
1065 + // Unreachable after the allowlist check. Fail closed anyway.
1092 1066 throw new \Exception(esc_html__('This ticket property cannot be updated.', 'fluent-support'), 403);
1093 1067 }
1094 1068
1095 1069 /**
1096 - * closeTicket method close the ticket by id
1097 - * @param Ticket $ticket
1070 + * @param Request $request
1098 1071 * @param int $ticket_id
1099 1072 * @return array
1100 1073 */
1101 1074 public function closeTicket(Request $request, $ticket_id)
@@ -1119,11 +1092,9 @@
1119 1092 }
1120 1093 }
1121 1094
1122 1095 /**
1123 - * reOpenTicket method will reopen a closed ticket
1124 - * @param Request $request
1125 - * @param $ticket_id
1096 + * @param int $ticket_id
1126 1097 * @return array
1127 1098 */
1128 1099 public function reOpenTicket($ticket_id)
1129 1100 {
@@ -1144,12 +1115,9 @@
1144 1115 }
1145 1116 }
1146 1117
1147 1118 /**
1148 - * doBulkActions method is responsible for bulk action
1149 - * This function will get ticket ids and action as parameter and perform action based on the selection
1150 1119 * @param Request $request
1151 - * @param Ticket $ticket
1152 1120 * @return array|string[]|void
1153 1121 * @throws \Exception
1154 1122 */
1155 1123 public function doBulkActions(Request $request)
@@ -1155,18 +1123,24 @@
1155 1123 public function doBulkActions(Request $request)
1156 1124 {
1157 1125 try {
1158 1126 $action = $request->getSafe('bulk_action', 'sanitize_text_field');
1159 - $ticketIds = array_map('intval', $request->get('ticket_ids', null, []));
1127 + $ticketIds = array_map('intval', (array) $request->get('ticket_ids', null, []));
1160 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 +
1161 1137 $agent = Helper::getAgentByUserId();
1162 1138 $query = Ticket::whereIn('id', $ticketIds);
1163 1139
1164 - //Scope selected tickets to what the agent can access, matching the
1165 - //per-ticket ensureCanAccessTicket() check on the single-ticket routes
1140 + // Same access rules as ensureCanAccessTicket() on single-ticket routes.
1166 1141 (new AgentTicketAccess())->applyAccessScope($query, $agent);
1167 1142
1168 - //If bulk action is close tickets
1169 1143 if ($action == 'close_tickets') {
1170 1144 $tickets = $query->get();
1171 1145 $tickets->each(function ($ticket) use ($agent) {
1172 1146 (new TicketService())->close($ticket, $agent);
@@ -1214,9 +1188,8 @@
1214 1188
1215 1189 $tickets->each(function ($ticket) use ($assignAgent, $agent, $restrictedBoxes, &$assignedCount, &$skippedCount) {
1216 1190 $previousAgentId = (int) $ticket->agent_id;
1217 1191
1218 - //Skip ticket if mailbox is restricted for the agent
1219 1192 if (!empty($ticket->mailbox_id) && in_array((int) $ticket->mailbox_id, $restrictedBoxes, true)) {
1220 1193 $skippedCount++;
1221 1194 return;
1222 1195 }
@@ -1276,9 +1249,9 @@
1276 1249
1277 1250 $ticket->agent_id = $selectedAgent->id;
1278 1251 $ticket->save();
1279 1252 $assignedCount++;
1280 - $currentCounts[$selectedAgent->id]++;
1253 + $currentCounts[$selectedAgent->id] = ($currentCounts[$selectedAgent->id] ?? 0) + 1;
1281 1254
1282 1255 as_enqueue_async_action('fluent_support/async_agent_assigned_to_ticket', [
1283 1256 $selectedAgent->id, $ticket->id, $agent->id, $previousAgentId
1284 1257 ], 'fluent-support');
@@ -1305,8 +1278,67 @@
1305 1278
1306 1279 return [
1307 1280 'message' => __('Selected tags has been added to tickets', 'fluent-support')
1308 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 + ];
1309 1341 }
1310 1342
1311 1343 throw new \Exception(esc_html__('Sorry no action found as available', 'fluent-support'));
1312 1344 } catch (\Exception $e) {
@@ -1316,9 +1348,34 @@
1316 1348 }
1317 1349 }
1318 1350
1319 1351 /**
1320 - * 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 + /**
1321 1378 * @param int $ticket_id
1322 1379 * @return array
1323 1380 */
1324 1381 public function deleteTicket($ticket_id)
@@ -1340,12 +1397,9 @@
1340 1397 }
1341 1398 }
1342 1399
1343 1400 /**
1344 - * doBulkReplies method will create response for bulk tickets
1345 - * This function will get ticket ids, content, attachment etc and create response for tickets
1346 1401 * @param Request $request
1347 - * @param Conversation $conversation
1348 1402 * @return array
1349 1403 * @throws \Exception
1350 1404 */
1351 1405 public function doBulkReplies(Request $request)
@@ -1350,9 +1404,8 @@
1350 1404 */
1351 1405 public function doBulkReplies(Request $request)
1352 1406 {
1353 1407 try {
1354 - // Sanitize all request data before validation
1355 1408 $requestData = $request->all();
1356 1409 $data = [];
1357 1410 foreach ($requestData as $key => $value) {
1358 1411 if (is_array($value)) {
@@ -1372,15 +1425,13 @@
1372 1425 'content' => 'required',
1373 1426 'ticket_ids' => 'required|array'
1374 1427 ]);
1375 1428
1376 - //Get logged in agent information
1377 1429 $agent = Helper::getAgentByUserId();
1378 1430 $ticketIds = array_filter($data['ticket_ids'], 'absint');
1379 1431
1380 1432 $query = Ticket::whereIn('id', $ticketIds)->where('status', '!=', 'closed');
1381 1433
1382 - // Scope to tickets the agent may access (visibility + mailbox restrictions).
1383 1434 (new AgentTicketAccess())->applyAccessScope($query, $agent);
1384 1435
1385 1436 $tickets = $query->get();
1386 1437
@@ -1393,9 +1444,8 @@
1393 1444 'conversation_type' => 'response',
1394 1445 'close_ticket' => Arr::get($data, 'close_ticket'),
1395 1446 ];
1396 1447
1397 - //If request with file attachments
1398 1448 $attachmentHashes = Arr::get($data, 'attachments', []);
1399 1449 $attachments = false;
1400 1450 if ($attachmentHashes) {
1401 1451 $attachments = Attachment::whereNull('ticket_id')
@@ -1442,13 +1492,10 @@
1442 1492 }
1443 1493 }
1444 1494
1445 1495 /**
1446 - * deleteResponse method will remove a response from ticket by ticket id and response id
1447 - * @param Request $request
1448 - * @param Conversation $conversation
1449 - * @param $ticket_id
1450 - * @param $response_id
1496 + * @param int $ticket_id
1497 + * @param int $response_id
1451 1498 * @return array
1452 1499 */
1453 1500 public function deleteResponse($ticket_id, $response_id)
1454 1501 {
@@ -1455,17 +1502,14 @@
1455 1502 try {
1456 1503 $ticket = Ticket::findOrFail($ticket_id);
1457 1504
1458 1505 if (in_array($ticket->mailbox_id, PermissionManager::getRestrictedMailboxIds())) {
1459 - 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);
1460 1507 }
1461 1508
1462 - // The caller must have access to this specific ticket (visibility +
1463 - // ownership + mailbox), not merely a global manage capability.
1464 1509 $this->ensureCanAccessTicket($ticket);
1465 1510
1466 - // Deleting a response always requires the explicit delete capability,
1467 - // mirroring deleteTicket(). Assignment alone is not sufficient.
1511 + // Being assigned to the ticket isn't enough to delete a response.
1468 1512 if (!PermissionManager::currentUserCan('fst_delete_tickets')) {
1469 1513 throw new \Exception(
1470 1514 esc_html__('Sorry, you do not have permission to delete this response.', 'fluent-support')
1471 1515 );
@@ -1488,10 +1532,9 @@
1488 1532 }
1489 1533 }
1490 1534
1491 1535 /**
1492 - * updateResponse method will update ticket response using ticket and response id
1493 - * @param Request $request
1536 + * @param TicketResponseRequest $request
1494 1537 * @param int $ticket_id
1495 1538 * @param int $response_id
1496 1539 * @return array
1497 1540 * @throws \Exception
@@ -1501,13 +1544,11 @@
1501 1544 try {
1502 1545 $ticket = Ticket::findOrFail($ticket_id);
1503 1546
1504 1547 if (in_array($ticket->mailbox_id, PermissionManager::getRestrictedMailboxIds())) {
1505 - 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);
1506 1549 }
1507 1550
1508 - // The caller must have access to this specific ticket (visibility +
1509 - // ownership + mailbox), not merely a global manage capability.
1510 1551 $this->ensureCanAccessTicket($ticket);
1511 1552
1512 1553 $response = Conversation::where('id', $response_id)
1513 1554 ->where('ticket_id', $ticket_id)
@@ -1514,10 +1555,8 @@
1514 1555 ->with('person')
1515 1556 ->firstOrFail();
1516 1557 $agent = Helper::getAgentByUserId();
1517 1558
1518 - // Only agent-authored conversation types may be edited here. Customer
1519 - // replies and system entries must not be rewritten via this endpoint.
1520 1559 $editableTypes = ['response', 'draft_response', 'note', 'internal_info'];
1521 1560 if (!in_array($response->conversation_type, $editableTypes, true)) {
1522 1561 throw new \Exception(
1523 1562 esc_html__('This response type cannot be edited.', 'fluent-support')
@@ -1523,10 +1562,9 @@
1523 1562 esc_html__('This response type cannot be edited.', 'fluent-support')
1524 1563 );
1525 1564 }
1526 1565
1527 - // Customer messages share the 'response' type but are authored by a
1528 - // customer person; they are never editable by an agent.
1566 + // Customer replies also use the 'response' type.
1529 1567 if ($response->person && $response->person->person_type !== 'agent') {
1530 1568 throw new \Exception(
1531 1569 esc_html__('Sorry, you do not have permission to update this response.', 'fluent-support')
1532 1570 );
@@ -1536,9 +1574,8 @@
1536 1574 $isAuthor = (int) $response->person_id === (int) $agent->id;
1537 1575 $canApproveDraft = PermissionManager::currentUserCan('fst_approve_draft_reply');
1538 1576
1539 1577 if ($isDraft && !$isAuthor) {
1540 - // Another agent's draft can only be edited/approved by an approver.
1541 1578 if (!$canApproveDraft) {
1542 1579 throw new \Exception(
1543 1580 esc_html__('Sorry, You do not have permission to approve this draft response', 'fluent-support')
1544 1581 );
@@ -1543,15 +1580,15 @@
1543 1580 esc_html__('Sorry, You do not have permission to approve this draft response', 'fluent-support')
1544 1581 );
1545 1582 }
1546 1583 } elseif (!$isAuthor && !PermissionManager::currentUserCan('fst_manage_other_tickets')) {
1547 - // Editing another agent's response requires manage-others capability.
1548 1584 throw new \Exception(
1549 1585 esc_html__('Sorry, you do not have permission to update this response.', 'fluent-support')
1550 1586 );
1551 1587 }
1552 1588
1553 - $content = wp_unslash(wp_kses_post($request->getSafe('content', 'wp_kses_post')));
1589 + // Input is already unslashed. Unslashing again strips real backslashes.
1590 + $content = wp_kses_post($request->getSafe('content', 'wp_kses_post'));
1554 1591 $response->content = $content;
1555 1592
1556 1593 if ($isDraft && !$isAuthor && $canApproveDraft) {
1557 1594 $response = $this->approveDraftConversation($ticket, $response, $agent, $content);
@@ -1593,9 +1630,9 @@
1593 1630 $response = $this->approveDraftConversation(
1594 1631 $ticket,
1595 1632 $response,
1596 1633 $person,
1597 - wp_unslash(wp_kses_post($request->getSafe('content', 'wp_kses_post')))
1634 + wp_kses_post($request->getSafe('content', 'wp_kses_post'))
1598 1635 );
1599 1636
1600 1637 return [
1601 1638 'message' => __('Draft response has been successfully approved.', 'fluent-support'),
@@ -1609,41 +1646,16 @@
1609 1646 }
1610 1647
1611 1648 protected function approveDraftConversation($ticket, $response, $person, $content)
1612 1649 {
1613 - $resetWaitingSince = apply_filters('fluent_support/reset_waiting_since', true, $content);
1614 -
1615 - $response->content = $content;
1616 - $response->conversation_type = 'response';
1617 - $response->created_at = current_time('mysql');
1618 - $response->save();
1619 -
1620 - if ($person->person_type == 'agent' && $ticket->status == 'new') {
1621 - $ticket->status = 'active';
1622 - if ($ticket->created_at) {
1623 - $ticket->first_response_time = strtotime(current_time('mysql')) - strtotime($ticket->created_at);
1624 - } else {
1625 - $ticket->first_response_time = 300;
1626 - }
1627 - }
1628 -
1629 - if ($resetWaitingSince) {
1630 - $ticket->last_agent_response = current_time('mysql');
1631 - $ticket->waiting_since = current_time('mysql');
1632 - }
1633 -
1634 - $ticket->response_count += 1;
1635 - $ticket->save();
1636 -
1637 - do_action('fluent_support/response_added_by_' . $person->person_type, $response, $ticket, $person);
1638 -
1639 - return $response;
1650 + return (new ResponseService())->publishDraft($ticket, $response, $person, $content);
1640 1651 }
1641 1652
1642 1653 /**
1643 - * getLiveActivity method will return the activity in a ticket by agents
1654 + * Agents currently viewing the ticket.
1655 + *
1644 1656 * @param Request $request
1645 - * @param $ticket_id
1657 + * @param int $ticket_id
1646 1658 * @return array
1647 1659 */
1648 1660 public function getLiveActivity(Request $request, $ticket_id)
1649 1661 {
@@ -1664,11 +1676,12 @@
1664 1676 }
1665 1677 }
1666 1678
1667 1679 /**
1668 - * removeLiveActivity method will remove activities that
1680 + * Remove the current agent from the ticket's live activity.
1681 + *
1669 1682 * @param Request $request
1670 - * @param $ticket_id
1683 + * @param int $ticket_id
1671 1684 * @return array
1672 1685 */
1673 1686 public function removeLiveActivity(Request $request, $ticket_id)
1674 1687 {
@@ -1690,11 +1703,10 @@
1690 1703 }
1691 1704 }
1692 1705
1693 1706 /**
1694 - * addTag method will add tag in ticket by ticket id
1695 1707 * @param Request $request
1696 - * @param $ticket_id
1708 + * @param int $ticket_id
1697 1709 * @return array
1698 1710 */
1699 1711 public function addTag(Request $request, $ticket_id)
1700 1712 {
@@ -1716,11 +1728,12 @@
1716 1728 }
1717 1729 }
1718 1730
1719 1731 /**
1720 - * detachTag method will remove all tags from tickets
1721 - * @param $ticket_id
1722 - * @param $tag_id
1732 + * Remove one tag from the ticket.
1733 + *
1734 + * @param int $ticket_id
1735 + * @param int $tag_id
1723 1736 * @return array
1724 1737 */
1725 1738 public function detachTag($ticket_id, $tag_id)
1726 1739 {
@@ -1742,11 +1755,10 @@
1742 1755 }
1743 1756 }
1744 1757
1745 1758 /**
1746 - * changeTicketCustomer method will update customer in a ticket
1747 - * This method will get ticket id and customer id as parameter, it will replace existing customer id with new
1748 1759 * @param Request $request
1760 + * @param int $ticket_id
1749 1761 * @return array
1750 1762 */
1751 1763 public function changeTicketCustomer(Request $request, $ticket_id)
1752 1764 {
@@ -1756,11 +1768,10 @@
1756 1768 if (!$newCustomerId) {
1757 1769 return $this->sendError(__('Invalid customer selected.', 'fluent-support'));
1758 1770 }
1759 1771
1760 - // Rebinding a ticket to another customer exposes that customer's private
1761 - // data (profile, custom fields) through the ticket, so it requires the same
1762 - // 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.
1763 1774 if (!PermissionManager::currentUserCan('fst_sensitive_data')) {
1764 1775 return $this->sendError(__('You do not have permission to change the ticket customer.', 'fluent-support'));
1765 1776 }
1766 1777
@@ -1793,11 +1804,10 @@
1793 1804 }
1794 1805 }
1795 1806
1796 1807 /**
1797 - * getTicketCustomData method will return the custom data by ticket id
1798 1808 * @param Request $request
1799 - * @param $ticket_id
1809 + * @param int $ticket_id
1800 1810 * @return array|array[]
1801 1811 */
1802 1812 public function getTicketCustomData(Request $request, $ticket_id)
1803 1813 {
@@ -1824,10 +1834,8 @@
1824 1834 }
1825 1835 }
1826 1836
1827 1837 /**
1828 - * syncFluentCrmTags method will synchronize the tags with Fluent CRM by contact id
1829 - *This function will get contact id and tags as parameter, get existing tags from crm and updated added/removed tags
1830 1838 * @param Request $request
1831 1839 * @param FluentCRMServices $fluentCRMServices
1832 1840 * @return array
1833 1841 */
@@ -1837,9 +1845,8 @@
1837 1845 'contact_id' => $request->getSafe('contact_id', 'intval'),
1838 1846 'tags' => $request->get('tags', null)
1839 1847 ];
1840 1848
1841 - // Sanitize tags array if it's an array
1842 1849 if (is_array($data['tags'])) {
1843 1850 $data['tags'] = array_map('intval', $data['tags']);
1844 1851 }
1845 1852
@@ -1852,10 +1859,8 @@
1852 1859 }
1853 1860 }
1854 1861
1855 1862 /**
1856 - * This `syncFluentCrmLists` method will synchronize the lists with Fluent CRM by contact id
1857 - * This method will get contact id and lists as parameter, get existing lists from crm and updated added/removed lists
1858 1863 * @param Request $request
1859 1864 * @param FluentCRMServices $fluentCRMServices
1860 1865 * @return array
1861 1866 */
@@ -1866,9 +1871,8 @@
1866 1871 'contact_id' => $request->getSafe('contact_id', 'intval'),
1867 1872 'lists' => $request->get('lists', null, [])
1868 1873 ];
1869 1874
1870 - // Sanitize lists array if it's an array
1871 1875 if (is_array($data['lists'])) {
1872 1876 $data['lists'] = array_map('intval', $data['lists']);
1873 1877 }
1874 1878
@@ -1881,12 +1885,10 @@
1881 1885 }
1882 1886 }
1883 1887
1884 1888 /**
1885 - * Get ticket essentials data based on the provided types.
1886 - *
1887 - * @param \Illuminate\Http\Request $request
1888 - * @return array The ticket essentials data.
1889 + * @param Request $request
1890 + * @return array
1889 1891 */
1890 1892 public function getTicketEssentials(Request $request)
1891 1893 {
1892 1894 $type = $request->getSafe('type', 'sanitize_text_field');