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 +452 -214 2.3.0 → 2.4.5 View file →
@@ -10,8 +10,9 @@
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\Product;
14 15 use FluentSupport\App\Models\Ticket;
15 16 use FluentSupport\App\Services\FluentCRMServices;
16 17 use FluentSupport\App\Services\Helper;
17 18 use FluentSupport\App\Services\ProfileInfoService;
@@ -24,18 +25,21 @@
24 25 use FluentSupport\App\Services\Tickets\TicketService;
25 26 use FluentSupport\App\Services\Integrations\FluentBooking\FluentBookingService;
26 27
27 28 /**
28 - * TicketController class for REST API related to ticket
29 - * This class is responsible for getting / inserting/ modifying data for all request related to ticket
30 - * @package FluentSupport\App\Http\Controllers
31 - *
32 - * @version 1.0.0
29 + * Agent-side REST endpoints for tickets.
33 30 */
34 31 class TicketController extends Controller
35 32 {
36 33 /**
37 - * 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 + *
38 42 * @param Request $request
39 43 * @return array
40 44 */
41 45 public function me(Request $request)
@@ -64,9 +68,9 @@
64 68 $businessSettings = (new \FluentSupport\App\Services\EmailNotification\Settings())->globalBusinessSettings();
65 69 $maxFileSize = absint($businessSettings['max_file_size']);
66 70
67 71 $portalSettings = [
68 - 'support_products' => \FluentSupport\App\Models\Product::select(['id', 'title'])->get(),
72 + 'support_products' => \FluentSupport\App\Models\Product::select(['id', 'title'])->orderedByTitle()->get(),
69 73 'customer_ticket_priorities' => Helper::customerTicketPriorities(),
70 74 'has_file_upload' => !!Helper::ticketAcceptedFileMiles(),
71 75 'has_rich_text_editor' => true,
72 76 'max_file_size' => $maxFileSize,
@@ -80,18 +84,15 @@
80 84 return $settings;
81 85 }
82 86
83 87 /**
84 - * index method will return the list of ticket based on the selected filter
85 88 * @param Request $request
86 89 * @return array
87 90 */
88 91 public function index(Request $request)
89 92 {
90 - //Selected filter type, either simple or Advanced
91 93 $filterType = $request->getSafe('filter_type', 'sanitize_text_field', 'simple');
92 94
93 - /*Prepare Query Arguments*/
94 95 $queryArgs = [
95 96 'with' => [],
96 97 'filter_type' => $filterType,
97 98 'sort_by' => sanitize_sql_orderby($request->getSafe('order_by', 'sanitize_text_field', 'id')),
@@ -97,15 +98,12 @@
97 98 'sort_by' => sanitize_sql_orderby($request->getSafe('order_by', 'sanitize_text_field', 'id')),
98 99 'sort_type' => $request->getSafe('order_type', 'sanitize_text_field', 'DESC') == 'DESC' ? 'DESC' : 'ASC',
99 100 ];
100 101
101 - //If the selected filter type is advanced
102 102 if ($filterType == 'advanced') {
103 103 $advanced_filters = map_deep($request->get('advanced_filters', []), 'sanitize_text_field');
104 - //Get the selected query params for advanced filter
105 104 $queryArgs['filters_groups_raw'] = json_decode($advanced_filters, true);
106 105 } else {
107 - //Selected filter type is simple
108 106 $queryArgs['simple_filters'] = map_deep($request->get('filters', []), 'sanitize_text_field');
109 107 $queryArgs['search'] = trim($request->getSafe('search', 'sanitize_text_field', ''));
110 108
111 109 if ($customerId = $request->getSafe('customer_id', 'intval')) {
@@ -111,9 +109,8 @@
111 109 if ($customerId = $request->getSafe('customer_id', 'intval')) {
112 110 $queryArgs['customer_id'] = $customerId;
113 111 }
114 112 }
115 - /*End Prepare Query Arguments*/
116 113
117 114 $ticketsModel = (new TicketQueryService($queryArgs))->getModel();
118 115
119 116 $ticketsModel = $ticketsModel->with([
@@ -129,9 +126,8 @@
129 126 $query->latest('id');
130 127 }
131 128 ]);
132 129
133 - // apply filters by access level
134 130 do_action_ref_array('fluent_support/tickets_query_by_permission_ref', [&$ticketsModel, false]);
135 131
136 132 $tickets = $ticketsModel->paginate();
137 133
@@ -136,9 +132,9 @@
136 132 $tickets = $ticketsModel->paginate();
137 133
138 134 $perPage = $request->getSafe('per_page', 'intval', 15);
139 135
140 - // Load live activity for small page sizes (board/kanban view)
136 + // Small pages come from the board view, which shows live activity.
141 137 if ($perPage < 15) {
142 138 TicketHelper::loadBatchLiveActivities($tickets);
143 139 }
144 140
@@ -147,9 +143,10 @@
147 143 ];
148 144 }
149 145
150 146 /**
151 - * 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 + *
152 149 * @param TicketRequest $request
153 150 * @return array
154 151 */
155 152 public function createTicket(TicketRequest $request)
@@ -154,28 +151,20 @@
154 151 */
155 152 public function createTicket(TicketRequest $request)
156 153 {
157 154 try {
158 - //Sanitize and validate request data via TicketRequest
159 155 $data = $request->sanitize();
160 156 $ticketData = $data['ticket'];
161 157 $maybeNewCustomer = Arr::get($data, 'newCustomer', []);
162 158
163 - //Include attachments if provided
164 159 if (!empty($data['attachments'])) {
165 160 $ticketData['attachments'] = $data['attachments'];
166 161 }
167 162
168 - /*
169 - * If customer_id is not provided, attempt to create a new customer
170 - * This handles WP user creation and customer creation
171 - */
172 163 if (empty($ticketData['customer_id'])) {
173 164 $createdUserId = false;
174 165
175 - //If user selected create WP user during ticket creation
176 166 if (Arr::get($ticketData, 'create_wp_user') == 'yes' && !empty($maybeNewCustomer['username'])) {
177 - //Check if username already in use, if not create new user
178 167 if (!username_exists($maybeNewCustomer['username'])) {
179 168 $authController = new AuthController();
180 169 $createdUserId = $authController->createUser($maybeNewCustomer);
181 170 $authController->maybeUpdateUser($createdUserId, $maybeNewCustomer);
@@ -188,15 +177,13 @@
188 177 'message' => __('A valid email is required to create a ticket', 'fluent-support')
189 178 ]);
190 179 }
191 180
192 - //Check if customer already exists by email
193 181 $existingCustomer = Customer::where('email', $email)->first();
194 182
195 183 if ($existingCustomer) {
196 184 $ticketData['customer_id'] = $existingCustomer->id;
197 185 } else {
198 - //Create the customer now
199 186 $customerData = Arr::only($maybeNewCustomer, (new Customer())->getFillable());
200 187 $customerData['user_id'] = $createdUserId;
201 188 $customerData = array_filter($customerData);
202 189
@@ -201,24 +188,22 @@
201 188 $customerData = array_filter($customerData);
202 189
203 190 $createCustomer = Customer::create($customerData);
204 191
205 - do_action('fluent_support/customer_created', $createCustomer);
206 -
207 - if (!$createCustomer) {
192 + if (!$createCustomer || !$createCustomer->exists) {
208 193 return $this->sendError([
209 194 'message' => __('Customer could not be created', 'fluent-support')
210 195 ]);
211 196 }
212 197
198 + do_action('fluent_support/customer_created', $createCustomer);
199 +
213 200 $ticketData['customer_id'] = $createCustomer->id;
214 201 }
215 202 }
216 203
217 - //Get customer information from db
218 204 $customer = Customer::findOrFail($ticketData['customer_id']);
219 205
220 - //Sanitize, store ticket, handle attachments, fire hooks
221 206 $createdTicket = (new TicketService())->storeTicket($ticketData, $customer);
222 207
223 208 return [
224 209 'message' => __('Ticket has been created successfully', 'fluent-support'),
@@ -231,17 +216,15 @@
231 216 }
232 217 }
233 218
234 219 /**
235 - * getTicket method will return ticket information by ticket id
236 220 * @param Request $request
237 - * @param $ticket_id
221 + * @param int $ticket_id
238 222 * @return array
239 223 */
240 224 public function getTicket(Request $request, $ticket_id)
241 225 {
242 226 try {
243 - //Get logged in agent information
244 227 $agent = Helper::getAgentByUserId();
245 228
246 229 $ticketWith = $request->get('with');
247 230 $ticketWith = is_array($ticketWith) ? map_deep($ticketWith, 'sanitize_text_field') : null;
@@ -251,12 +234,10 @@
251 234 $q->where('status', 'active');
252 235 }];
253 236 }
254 237
255 - //Get ticket by id
256 238 $ticket = Ticket::with($ticketWith)->findOrFail($ticket_id);
257 239
258 - //Eager load responses with their nested relations to avoid N+1 queries
259 240 $ticket->load(['responses' => function ($q) {
260 241 $q->with([
261 242 'person',
262 243 'ccinfo',
@@ -265,20 +246,18 @@
265 246 }
266 247 ]);
267 248 }]);
268 249
269 - //Check if ticket is in a restricted mailbox
270 250 $restrictedBusinessBoxes = PermissionManager::getRestrictedMailboxIds();
271 251
272 252 if (in_array($ticket->mailbox_id, $restrictedBusinessBoxes)) {
273 - 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);
274 254 }
275 255
276 256 $this->ensureCanAccessTicket($ticket);
277 257
278 - //If ticket has customer, set custom fields and profile url
279 258 if ($ticket->customer) {
280 - $customFieldsKey = apply_filters('fluent_support/custom_registration_form_fields_key', Helper::getBusinessSettings('custom_registration_form_field'));
259 + $customFieldsKey = Helper::getCustomRegistrationFieldKeys();
281 260 $ticket->customer->custom_field_keys = $customFieldsKey;
282 261
283 262 if ($ticket->customer->user_id) {
284 263 $customFieldKeysUsingHook = apply_filters('fluent_support/custom_registration_form_fields_key', []);
@@ -294,14 +273,12 @@
294 273
295 274 $ticket->customer->profile_edit_url = $ticket->customer->getUserProfileEditUrl();
296 275 }
297 276
298 - //If ticket is closed, load closed by person
299 277 if ($ticket->status == 'closed') {
300 278 $ticket->load('closed_by_person');
301 279 }
302 280
303 - //Load agent feedback ratings if pro is active and feature is enabled
304 281 if (defined('FLUENTSUPPORTPRO_PLUGIN_VERSION') && Helper::isAgentFeedbackEnabled()) {
305 282 $responseIds = $ticket->responses->pluck('id')->toArray();
306 283 $feedbacks = Meta::where('object_type', 'conversation_meta')
307 284 ->where('key', 'agent_feedback_ratings')
@@ -323,9 +300,8 @@
323 300
324 301 $contents = Helper::refreshSignedAttachmentUrlsInContents($contents, $ticket->id);
325 302 $ticket->content = $contents['ticket'];
326 303
327 - //Format response content
328 304 foreach ($ticket->responses as $response) {
329 305 $responseKey = 'response_' . $response->id;
330 306 if (isset($contents[$responseKey])) {
331 307 $response->content = $contents[$responseKey];
@@ -377,12 +353,10 @@
377 353 $ticketContent,
378 354 $ticket
379 355 );
380 356
381 - //Get last activity by agent
382 357 $ticket->live_activity = TicketHelper::getActivity($ticket->id, $agent->id);
383 358
384 - //Get all carbon copy customer
385 359 $ccInfo = $ticket->getSettingsValue('cc_email', []);
386 360 $ticket->carbon_copy = !empty($ccInfo) ? implode(', ', $ccInfo) : '';
387 361
388 362 if (defined('FLUENTSUPPORTPRO')) {
@@ -388,13 +362,13 @@
388 362 if (defined('FLUENTSUPPORTPRO')) {
389 363 $ticket->custom_fields = $ticket->customData('admin', true);
390 364 }
391 365
392 - // Load agent info if ticket was created on behalf of customer
366 + // Set when an agent opened the ticket on the customer's behalf.
393 367 if ($ticket->created_by) {
394 368 $ticket->load('created_by_person');
395 369 if ($ticket->created_by_person) {
396 - $isAgentInitiated = strpos($ticket->content, __(' initialized this ticket', 'fluent-support')) !== false;
370 + $isAgentInitiated = $ticket instanceof Ticket && $ticket->isAgentInitiated();
397 371 $ticket->created_by_agent = [
398 372 'id' => $ticket->created_by_person->id,
399 373 'full_name' => $ticket->created_by_person->full_name,
400 374 'agent_initiated' => $isAgentInitiated,
@@ -432,9 +406,9 @@
432 406 try {
433 407 $ticket = Ticket::findOrFail($ticket_id);
434 408
435 409 if (in_array($ticket->mailbox_id, PermissionManager::getRestrictedMailboxIds())) {
436 - 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);
437 411 }
438 412
439 413 $this->ensureCanAccessTicket($ticket);
440 414
@@ -506,11 +480,9 @@
506 480 return $restrictions;
507 481 }
508 482
509 483 /**
510 - * createResponse method will create response by agent for the ticket
511 - * @param Request $request
512 - * @param Ticket $ticket
484 + * @param TicketResponseRequest $request
513 485 * @param int $ticket_id
514 486 * @return array
515 487 * @throws \FluentSupport\Framework\Validator\ValidationException
516 488 */
@@ -521,13 +493,13 @@
521 493 try {
522 494 $convoType = Arr::get($data, 'conversation_type', 'response');
523 495 $isDraft = $convoType === 'draft_response';
524 496
525 - 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') {
526 499 $this->ensureCanManageTickets();
527 500 }
528 501
529 - //Get logged-in agent information
530 502 $agent = Helper::getAgentByUserId();
531 503
532 504 if (!$agent) {
533 505 return $this->sendError([
@@ -538,8 +510,20 @@
538 510 $ticket = Ticket::findOrFail($ticket_id);
539 511
540 512 $this->ensureCanAccessTicket($ticket);
541 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 +
542 526 $responseData = (new ResponseService())->createResponse($data, $agent, $ticket);
543 527
544 528 $responseData['response']->content = Helper::refreshSignedAttachmentUrls($responseData['response']->content, $ticket->id);
545 529 $responseData['response']->load([
@@ -546,9 +530,9 @@
546 530 'attachments' => function ($q) {
547 531 $q->where('status', 'active');
548 532 }
549 533 ]);
550 - $responseData['response']->content = wp_specialchars_decode(wpautop($responseData['response']->content, false));
534 + $responseData['response']->content = wpautop($responseData['response']->content, false);
551 535
552 536 return [
553 537 'message' => __('Response has been added', 'fluent-support'),
554 538 'response' => $responseData['response'],
@@ -564,9 +548,9 @@
564 548
565 549 public function getFluentBookingEventTypes()
566 550 {
567 551 try {
568 - // 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.
569 553 $this->ensureCanManageTickets();
570 554
571 555 $service = new FluentBookingService();
572 556 $eventTypes = $service->getEventTypes();
@@ -584,14 +568,12 @@
584 568
585 569 public function createFluentBookingLink(Request $request, $ticket_id)
586 570 {
587 571 try {
588 - // All FluentBooking endpoints require manage permission; view-only agents cannot call a meeting.
589 572 $this->ensureCanManageTickets();
590 573
591 574 $ticket = Ticket::with('customer')->findOrFail($ticket_id);
592 575
593 - // Enforces per-ticket visibility (e.g. own-tickets-only agents cannot access unassigned tickets).
594 576 $this->ensureCanAccessTicket($ticket);
595 577
596 578 $eventId = $request->getSafe('event_type_id', 'intval');
597 579
@@ -615,14 +597,12 @@
615 597
616 598 public function getFluentBookingAvailability(Request $request, $ticket_id)
617 599 {
618 600 try {
619 - // All FluentBooking endpoints require manage permission; view-only agents cannot call a meeting.
620 601 $this->ensureCanManageTickets();
621 602
622 603 $ticket = Ticket::with('customer')->findOrFail($ticket_id);
623 604
624 - // Enforces per-ticket visibility (e.g. own-tickets-only agents cannot access unassigned tickets).
625 605 $this->ensureCanAccessTicket($ticket);
626 606
627 607 $eventId = $request->getSafe('event_type_id', 'intval');
628 608
@@ -650,14 +630,12 @@
650 630
651 631 public function getFluentBookingMeetings($ticket_id)
652 632 {
653 633 try {
654 - // All FluentBooking endpoints require manage permission; view-only agents cannot call a meeting.
655 634 $this->ensureCanManageTickets();
656 635
657 636 $ticket = Ticket::with('customer')->findOrFail($ticket_id);
658 637
659 - // Enforces per-ticket visibility (e.g. own-tickets-only agents cannot access unassigned tickets).
660 638 $this->ensureCanAccessTicket($ticket);
661 639
662 640 return [
663 641 'meetings' => (new FluentBookingService())->getTicketMeetings($ticket)
@@ -669,11 +647,9 @@
669 647 }
670 648 }
671 649
672 650 /**
673 - * createDraft method will create draft by agent for the ticket
674 - * @param Request $request
675 - * @param Ticket $ticket
651 + * @param TicketResponseRequest $request
676 652 * @param int $ticket_id
677 653 * @return array
678 654 * @throws \FluentSupport\Framework\Validator\ValidationException
679 655 */
@@ -681,9 +657,8 @@
681 657 {
682 658 $data = $request->sanitize();
683 659
684 660 try {
685 - //Get logged-in agent information
686 661 $agent = Helper::getAgentByUserId();
687 662
688 663 if (!$agent) {
689 664 return $this->sendError([
@@ -729,9 +704,8 @@
729 704
730 705 public function getDraft($ticket_id)
731 706 {
732 707 try {
733 - //Get logged-in agent information
734 708 $agent = Helper::getAgentByUserId();
735 709
736 710 if (!$agent) {
737 711 return $this->sendError([
@@ -786,9 +760,14 @@
786 760 'message' => __('Draft not found', 'fluent-support'),
787 761 ]);
788 762 }
789 763
790 - // Verify ownership: draft key contains agent_id, only managers can delete others' drafts
764 + // Managers can delete other agents' drafts, but only on tickets they can access.
765 + $ticket = Ticket::findOrFail($draft->object_id);
766 +
767 + $this->ensureCanAccessTicket($ticket);
768 +
769 + // The draft key embeds the owning agent's id.
791 770 $isOwnDraft = strpos($draft->key, '_agent_id_' . $agent->id . '_') !== false;
792 771
793 772 if (!$isOwnDraft && !PermissionManager::canManageTickets()) {
794 773 return $this->sendError([
@@ -808,17 +787,17 @@
808 787 }
809 788 }
810 789
811 790 /**
812 - * getTicketWidgets method generate additional information for a ticket by customer
813 - * @param Ticket $ticket
814 - * @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
815 795 * @return array
816 796 */
817 797 public function getTicketWidgets(Request $request, $ticket_id)
818 798 {
819 799 try {
820 - //Get ticket with customer by ticket id
821 800 $ticket = Ticket::with('customer')->findOrFail($ticket_id);
822 801
823 802 $this->ensureCanAccessTicket($ticket);
824 803
@@ -858,12 +837,10 @@
858 837 }
859 838 }
860 839
861 840 /**
862 - * updateTicketProperty method will update ticket property
863 841 * @param Request $request
864 - * @param Ticket $ticket
865 - * @param $ticket_id
842 + * @param int $ticket_id
866 843 * @return array
867 844 */
868 845 public function updateTicketProperty(Request $request, $ticket_id)
869 846 {
@@ -874,33 +851,23 @@
874 851 $this->ensureCanAccessTicket($ticket);
875 852
876 853 $propName = $request->getSafe('prop_name', 'sanitize_text_field');
877 854 $propValue = $request->getSafe('prop_value', 'sanitize_text_field');
878 - $prevValue = $ticket->{$propName};
879 855
880 - //Validate agent assignment restrictions
881 - if ($propName === 'agent_id') {
882 - if (!PermissionManager::currentUserCan('fst_assign_agents')) {
883 - throw new \Exception(esc_html__('Permission denied to assign agent', 'fluent-support'), 403);
884 - }
856 + // prop_name is assigned straight onto the model, so it must be allowlisted.
857 + if (!in_array($propName, $this->updatableTicketProperties(), true)) {
858 + throw new \Exception(esc_html__('This ticket property cannot be updated.', 'fluent-support'), 403);
859 + }
885 860
886 - $agent = Agent::findOrFail($propValue);
887 - $restrictions = $agent->getMeta('agent_restrictions', []);
861 + $propValue = $this->sanitizeTicketProperty($ticket, $propName, $propValue);
888 862
889 - if (!empty($restrictions['restrictedBusinessBoxes'])) {
890 - $mailboxId = (int) $ticket->mailbox_id;
891 - if (in_array($mailboxId, $restrictions['restrictedBusinessBoxes'], true)) {
892 - throw new \Exception(esc_html__('Agent is restricted for this mailbox ticket', 'fluent-support'), 403);
893 - }
894 - }
895 - }
863 + $prevValue = $ticket->{$propName};
896 864
897 - if ($propName && $propValue && $prevValue != $propValue) {
865 + if ($propName && $propValue !== null && $prevValue != $propValue) {
898 866 $ticket->{$propName} = $propValue;
899 867 $ticket->save();
900 868
901 - // Log an internal note for status changes so the activity is
902 - // traceable, mirroring the close/reopen flows.
869 + // Logged like the close/reopen flows do.
903 870 if ($propName === 'status') {
904 871 $statuses = Helper::ticketStatuses();
905 872 $fromLabel = isset($statuses[$prevValue]) ? $statuses[$prevValue] : $prevValue;
906 873 $toLabel = isset($statuses[$propValue]) ? $statuses[$propValue] : $propValue;
@@ -953,10 +920,155 @@
953 920 }
954 921 }
955 922
956 923 /**
957 - * closeTicket method close the ticket by id
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 + *
958 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 + /**
963 + * The only ticket columns that may be changed through updateTicketProperty.
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.
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 + *
975 + * @return array
976 + */
977 + protected function updatableTicketProperties()
978 + {
979 + return [
980 + 'agent_id',
981 + 'title',
982 + 'product_id',
983 + 'status',
984 + 'priority',
985 + 'client_priority',
986 + ];
987 + }
988 +
989 + /**
990 + * Validate a property update against its allowed values and the
991 + * capability the UI requires for it.
992 + *
993 + * @param Ticket $ticket
994 + * @param string $propName Already confirmed to be in the allowlist.
995 + * @param string $propValue Raw (text-sanitized) value from the request.
996 + * @return mixed Normalized value ready to assign to the model.
997 + * @throws \Exception When the value is invalid or the caller lacks permission.
998 + */
999 + protected function sanitizeTicketProperty(Ticket $ticket, $propName, $propValue)
1000 + {
1001 + switch ($propName) {
1002 + case 'title':
1003 + $propValue = trim(sanitize_text_field($propValue));
1004 + if ($propValue === '') {
1005 + throw new \Exception(esc_html__('Ticket title cannot be empty.', 'fluent-support'), 422);
1006 + }
1007 + return $propValue;
1008 +
1009 + case 'status':
1010 + // The status dropdown in ViewTicket.vue submits the group key from
1011 + // changeable_ticket_statuses and hides empty groups. Match that.
1012 + $allowedStatuses = [];
1013 + foreach (Helper::changeableTicketStatuses() as $statusKey => $statusGroup) {
1014 + if (!empty($statusGroup)) {
1015 + $allowedStatuses[] = $statusKey;
1016 + }
1017 + }
1018 +
1019 + if (!in_array($propValue, $allowedStatuses, true)) {
1020 + throw new \Exception(esc_html__('Invalid ticket status.', 'fluent-support'), 422);
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 +
1029 + return $propValue;
1030 +
1031 + case 'priority':
1032 + if (!array_key_exists($propValue, Helper::adminTicketPriorities())) {
1033 + throw new \Exception(esc_html__('Invalid ticket priority.', 'fluent-support'), 422);
1034 + }
1035 + return $propValue;
1036 +
1037 + case 'client_priority':
1038 + if (!array_key_exists($propValue, Helper::customerTicketPriorities())) {
1039 + throw new \Exception(esc_html__('Invalid client priority.', 'fluent-support'), 422);
1040 + }
1041 + return $propValue;
1042 +
1043 + case 'product_id':
1044 + $productId = (int) $propValue;
1045 + if (!$productId || !Product::where('id', $productId)->exists()) {
1046 + throw new \Exception(esc_html__('Invalid product.', 'fluent-support'), 422);
1047 + }
1048 + return $productId;
1049 +
1050 + case 'agent_id':
1051 + if (!PermissionManager::currentUserCan('fst_assign_agents')) {
1052 + throw new \Exception(esc_html__('Permission denied to assign agent', 'fluent-support'), 403);
1053 + }
1054 +
1055 + $agentId = (int) $propValue;
1056 + $agent = Agent::findOrFail($agentId);
1057 + $restrictedBoxes = (new AgentTicketAccess())->getRestrictedMailboxIds($agent);
1058 +
1059 + if (in_array((int) $ticket->mailbox_id, $restrictedBoxes, true)) {
1060 + throw new \Exception(esc_html__('Agent is restricted for this mailbox ticket', 'fluent-support'), 403);
1061 + }
1062 + return $agentId;
1063 + }
1064 +
1065 + // Unreachable after the allowlist check. Fail closed anyway.
1066 + throw new \Exception(esc_html__('This ticket property cannot be updated.', 'fluent-support'), 403);
1067 + }
1068 +
1069 + /**
1070 + * @param Request $request
959 1071 * @param int $ticket_id
960 1072 * @return array
961 1073 */
962 1074 public function closeTicket(Request $request, $ticket_id)
@@ -980,11 +1092,9 @@
980 1092 }
981 1093 }
982 1094
983 1095 /**
984 - * reOpenTicket method will reopen a closed ticket
985 - * @param Request $request
986 - * @param $ticket_id
1096 + * @param int $ticket_id
987 1097 * @return array
988 1098 */
989 1099 public function reOpenTicket($ticket_id)
990 1100 {
@@ -1005,12 +1115,9 @@
1005 1115 }
1006 1116 }
1007 1117
1008 1118 /**
1009 - * doBulkActions method is responsible for bulk action
1010 - * This function will get ticket ids and action as parameter and perform action based on the selection
1011 1119 * @param Request $request
1012 - * @param Ticket $ticket
1013 1120 * @return array|string[]|void
1014 1121 * @throws \Exception
1015 1122 */
1016 1123 public function doBulkActions(Request $request)
@@ -1016,20 +1123,24 @@
1016 1123 public function doBulkActions(Request $request)
1017 1124 {
1018 1125 try {
1019 1126 $action = $request->getSafe('bulk_action', 'sanitize_text_field');
1020 - $ticketIds = array_map('intval', $request->get('ticket_ids', null, []));
1127 + $ticketIds = array_map('intval', (array) $request->get('ticket_ids', null, []));
1021 1128
1022 - $hasAllPermission = PermissionManager::currentUserCan('fst_manage_other_tickets');
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 +
1023 1137 $agent = Helper::getAgentByUserId();
1024 1138 $query = Ticket::whereIn('id', $ticketIds);
1025 1139
1026 - //If agent do not have permission to manage other tickets
1027 - if (!$hasAllPermission) {
1028 - $query->where('agent_id', $agent->id);
1029 - }
1140 + // Same access rules as ensureCanAccessTicket() on single-ticket routes.
1141 + (new AgentTicketAccess())->applyAccessScope($query, $agent);
1030 1142
1031 - //If bulk action is close tickets
1032 1143 if ($action == 'close_tickets') {
1033 1144 $tickets = $query->get();
1034 1145 $tickets->each(function ($ticket) use ($agent) {
1035 1146 (new TicketService())->close($ticket, $agent);
@@ -1072,14 +1183,14 @@
1072 1183 $tickets = $query->get();
1073 1184 $assignedCount = 0;
1074 1185 $skippedCount = 0;
1075 1186
1076 - $tickets->each(function ($ticket) use ($assignAgent, $agent, &$assignedCount, &$skippedCount) {
1187 + $restrictedBoxes = (new AgentTicketAccess())->getRestrictedMailboxIds($assignAgent);
1188 +
1189 + $tickets->each(function ($ticket) use ($assignAgent, $agent, $restrictedBoxes, &$assignedCount, &$skippedCount) {
1077 1190 $previousAgentId = (int) $ticket->agent_id;
1078 - $restrictions = $assignAgent->getMeta('agent_restrictions', []);
1079 1191
1080 - //Skip ticket if mailbox is restricted for the agent
1081 - if (!empty($restrictions) && in_array($ticket->mailbox_id, $restrictions['restrictedBusinessBoxes'])) {
1192 + if (!empty($ticket->mailbox_id) && in_array((int) $ticket->mailbox_id, $restrictedBoxes, true)) {
1082 1193 $skippedCount++;
1083 1194 return;
1084 1195 }
1085 1196
@@ -1138,9 +1249,9 @@
1138 1249
1139 1250 $ticket->agent_id = $selectedAgent->id;
1140 1251 $ticket->save();
1141 1252 $assignedCount++;
1142 - $currentCounts[$selectedAgent->id]++;
1253 + $currentCounts[$selectedAgent->id] = ($currentCounts[$selectedAgent->id] ?? 0) + 1;
1143 1254
1144 1255 as_enqueue_async_action('fluent_support/async_agent_assigned_to_ticket', [
1145 1256 $selectedAgent->id, $ticket->id, $agent->id, $previousAgentId
1146 1257 ], 'fluent-support');
@@ -1167,8 +1278,67 @@
1167 1278
1168 1279 return [
1169 1280 'message' => __('Selected tags has been added to tickets', 'fluent-support')
1170 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 + ];
1171 1341 }
1172 1342
1173 1343 throw new \Exception(esc_html__('Sorry no action found as available', 'fluent-support'));
1174 1344 } catch (\Exception $e) {
@@ -1178,9 +1348,34 @@
1178 1348 }
1179 1349 }
1180 1350
1181 1351 /**
1182 - * 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 + /**
1183 1378 * @param int $ticket_id
1184 1379 * @return array
1185 1380 */
1186 1381 public function deleteTicket($ticket_id)
@@ -1187,8 +1382,10 @@
1187 1382 {
1188 1383 try {
1189 1384 $ticket = Ticket::findOrFail($ticket_id);
1190 1385
1386 + $this->ensureCanAccessTicket($ticket);
1387 +
1191 1388 (new TicketService())->deleteTicket($ticket);
1192 1389
1193 1390 return [
1194 1391 'message' => __('Ticket has been deleted successfully', 'fluent-support')
@@ -1200,12 +1397,9 @@
1200 1397 }
1201 1398 }
1202 1399
1203 1400 /**
1204 - * doBulkReplies method will create response for bulk tickets
1205 - * This function will get ticket ids, content, attachment etc and create response for tickets
1206 1401 * @param Request $request
1207 - * @param Conversation $conversation
1208 1402 * @return array
1209 1403 * @throws \Exception
1210 1404 */
1211 1405 public function doBulkReplies(Request $request)
@@ -1210,9 +1404,8 @@
1210 1404 */
1211 1405 public function doBulkReplies(Request $request)
1212 1406 {
1213 1407 try {
1214 - // Sanitize all request data before validation
1215 1408 $requestData = $request->all();
1216 1409 $data = [];
1217 1410 foreach ($requestData as $key => $value) {
1218 1411 if (is_array($value)) {
@@ -1232,19 +1425,14 @@
1232 1425 'content' => 'required',
1233 1426 'ticket_ids' => 'required|array'
1234 1427 ]);
1235 1428
1236 - //Get logged in agent information
1237 1429 $agent = Helper::getAgentByUserId();
1238 1430 $ticketIds = array_filter($data['ticket_ids'], 'absint');
1239 1431
1240 - $hasAllPermission = PermissionManager::currentUserCan('fst_manage_other_tickets');
1241 1432 $query = Ticket::whereIn('id', $ticketIds)->where('status', '!=', 'closed');
1242 1433
1243 - //If the agent does not have permission
1244 - if (!$hasAllPermission) {
1245 - $query->where('agent_id', $agent->id);
1246 - }
1434 + (new AgentTicketAccess())->applyAccessScope($query, $agent);
1247 1435
1248 1436 $tickets = $query->get();
1249 1437
1250 1438 if ($tickets->isEmpty()) {
@@ -1256,9 +1444,8 @@
1256 1444 'conversation_type' => 'response',
1257 1445 'close_ticket' => Arr::get($data, 'close_ticket'),
1258 1446 ];
1259 1447
1260 - //If request with file attachments
1261 1448 $attachmentHashes = Arr::get($data, 'attachments', []);
1262 1449 $attachments = false;
1263 1450 if ($attachmentHashes) {
1264 1451 $attachments = Attachment::whereNull('ticket_id')
@@ -1305,13 +1492,10 @@
1305 1492 }
1306 1493 }
1307 1494
1308 1495 /**
1309 - * deleteResponse method will remove a response from ticket by ticket id and response id
1310 - * @param Request $request
1311 - * @param Conversation $conversation
1312 - * @param $ticket_id
1313 - * @param $response_id
1496 + * @param int $ticket_id
1497 + * @param int $response_id
1314 1498 * @return array
1315 1499 */
1316 1500 public function deleteResponse($ticket_id, $response_id)
1317 1501 {
@@ -1316,19 +1500,26 @@
1316 1500 public function deleteResponse($ticket_id, $response_id)
1317 1501 {
1318 1502 try {
1319 1503 $ticket = Ticket::findOrFail($ticket_id);
1320 - $response = Conversation::where('id', $response_id)
1321 - ->where('ticket_id', $ticket_id)
1322 - ->firstOrFail();
1323 - $agent = Helper::getAgentByUserId();
1324 1504
1325 - if (!PermissionManager::currentUserCan('fst_delete_tickets') && $ticket->agent_id !== $agent->id) {
1505 + if (in_array($ticket->mailbox_id, PermissionManager::getRestrictedMailboxIds())) {
1506 + throw new \Exception(esc_html__('Ticket cannot be fetched due to restricted mailbox', 'fluent-support'), 403);
1507 + }
1508 +
1509 + $this->ensureCanAccessTicket($ticket);
1510 +
1511 + // Being assigned to the ticket isn't enough to delete a response.
1512 + if (!PermissionManager::currentUserCan('fst_delete_tickets')) {
1326 1513 throw new \Exception(
1327 1514 esc_html__('Sorry, you do not have permission to delete this response.', 'fluent-support')
1328 1515 );
1329 1516 }
1330 1517
1518 + $response = Conversation::where('id', $response_id)
1519 + ->where('ticket_id', $ticket_id)
1520 + ->firstOrFail();
1521 +
1331 1522 $response->delete();
1332 1523 $response->ccinfo()->delete();
1333 1524
1334 1525 return [
@@ -1341,10 +1532,9 @@
1341 1532 }
1342 1533 }
1343 1534
1344 1535 /**
1345 - * updateResponse method will update ticket response using ticket and response id
1346 - * @param Request $request
1536 + * @param TicketResponseRequest $request
1347 1537 * @param int $ticket_id
1348 1538 * @param int $response_id
1349 1539 * @return array
1350 1540 * @throws \Exception
@@ -1352,30 +1542,57 @@
1352 1542 public function updateResponse(TicketResponseRequest $request, $ticket_id, $response_id)
1353 1543 {
1354 1544 try {
1355 1545 $ticket = Ticket::findOrFail($ticket_id);
1546 +
1547 + if (in_array($ticket->mailbox_id, PermissionManager::getRestrictedMailboxIds())) {
1548 + throw new \Exception(esc_html__('Ticket cannot be fetched due to restricted mailbox', 'fluent-support'), 403);
1549 + }
1550 +
1551 + $this->ensureCanAccessTicket($ticket);
1552 +
1356 1553 $response = Conversation::where('id', $response_id)
1357 1554 ->where('ticket_id', $ticket_id)
1555 + ->with('person')
1358 1556 ->firstOrFail();
1359 1557 $agent = Helper::getAgentByUserId();
1360 1558
1361 - if (!PermissionManager::currentUserCan('fst_manage_other_tickets') && $ticket->agent_id !== $agent->id) {
1559 + $editableTypes = ['response', 'draft_response', 'note', 'internal_info'];
1560 + if (!in_array($response->conversation_type, $editableTypes, true)) {
1362 1561 throw new \Exception(
1562 + esc_html__('This response type cannot be edited.', 'fluent-support')
1563 + );
1564 + }
1565 +
1566 + // Customer replies also use the 'response' type.
1567 + if ($response->person && $response->person->person_type !== 'agent') {
1568 + throw new \Exception(
1363 1569 esc_html__('Sorry, you do not have permission to update this response.', 'fluent-support')
1364 1570 );
1365 1571 }
1366 1572
1367 - $content = wp_unslash(wp_kses_post($request->getSafe('content', 'wp_kses_post')));
1368 - $response->content = $content;
1573 + $isDraft = $response->conversation_type == 'draft_response';
1574 + $isAuthor = (int) $response->person_id === (int) $agent->id;
1575 + $canApproveDraft = PermissionManager::currentUserCan('fst_approve_draft_reply');
1369 1576
1370 - if ($response->conversation_type == 'draft_response' && $response->person_id != $agent->id && PermissionManager::currentUserCan('fst_approve_draft_reply')) {
1371 - $response = $this->approveDraftConversation($ticket, $response, $agent, $content);
1372 - } else if ($response->conversation_type == 'draft_response' && $response->person_id != $agent->id) {
1373 - if (!PermissionManager::currentUserCan('fst_approve_draft_reply')) {
1577 + if ($isDraft && !$isAuthor) {
1578 + if (!$canApproveDraft) {
1374 1579 throw new \Exception(
1375 1580 esc_html__('Sorry, You do not have permission to approve this draft response', 'fluent-support')
1376 1581 );
1377 1582 }
1583 + } elseif (!$isAuthor && !PermissionManager::currentUserCan('fst_manage_other_tickets')) {
1584 + throw new \Exception(
1585 + esc_html__('Sorry, you do not have permission to update this response.', 'fluent-support')
1586 + );
1587 + }
1588 +
1589 + // Input is already unslashed. Unslashing again strips real backslashes.
1590 + $content = wp_kses_post($request->getSafe('content', 'wp_kses_post'));
1591 + $response->content = $content;
1592 +
1593 + if ($isDraft && !$isAuthor && $canApproveDraft) {
1594 + $response = $this->approveDraftConversation($ticket, $response, $agent, $content);
1378 1595 } else {
1379 1596 $response->save();
1380 1597 }
1381 1598
@@ -1400,8 +1617,10 @@
1400 1617 }
1401 1618
1402 1619 $ticket = Ticket::findOrFail($ticket_id);
1403 1620
1621 + $this->ensureCanAccessTicket($ticket);
1622 +
1404 1623 $response = Conversation::where('id', $response_id)
1405 1624 ->where('ticket_id', $ticket_id)
1406 1625 ->where('conversation_type', 'draft_response')
1407 1626 ->firstOrFail();
@@ -1411,9 +1630,9 @@
1411 1630 $response = $this->approveDraftConversation(
1412 1631 $ticket,
1413 1632 $response,
1414 1633 $person,
1415 - wp_unslash(wp_kses_post($request->getSafe('content', 'wp_kses_post')))
1634 + wp_kses_post($request->getSafe('content', 'wp_kses_post'))
1416 1635 );
1417 1636
1418 1637 return [
1419 1638 'message' => __('Draft response has been successfully approved.', 'fluent-support'),
@@ -1427,72 +1646,67 @@
1427 1646 }
1428 1647
1429 1648 protected function approveDraftConversation($ticket, $response, $person, $content)
1430 1649 {
1431 - $resetWaitingSince = apply_filters('fluent_support/reset_waiting_since', true, $content);
1432 -
1433 - $response->content = $content;
1434 - $response->conversation_type = 'response';
1435 - $response->created_at = current_time('mysql');
1436 - $response->save();
1437 -
1438 - if ($person->person_type == 'agent' && $ticket->status == 'new') {
1439 - $ticket->status = 'active';
1440 - if ($ticket->created_at) {
1441 - $ticket->first_response_time = strtotime(current_time('mysql')) - strtotime($ticket->created_at);
1442 - } else {
1443 - $ticket->first_response_time = 300;
1444 - }
1445 - }
1446 -
1447 - if ($resetWaitingSince) {
1448 - $ticket->last_agent_response = current_time('mysql');
1449 - $ticket->waiting_since = current_time('mysql');
1450 - }
1451 -
1452 - $ticket->response_count += 1;
1453 - $ticket->save();
1454 -
1455 - do_action('fluent_support/response_added_by_' . $person->person_type, $response, $ticket, $person);
1456 -
1457 - return $response;
1650 + return (new ResponseService())->publishDraft($ticket, $response, $person, $content);
1458 1651 }
1459 1652
1460 1653 /**
1461 - * getLiveActivity method will return the activity in a ticket by agents
1654 + * Agents currently viewing the ticket.
1655 + *
1462 1656 * @param Request $request
1463 - * @param $ticket_id
1657 + * @param int $ticket_id
1464 1658 * @return array
1465 1659 */
1466 1660 public function getLiveActivity(Request $request, $ticket_id)
1467 1661 {
1468 - $agent = Helper::getAgentByUserId();
1662 + try {
1663 + $ticket = Ticket::findOrFail($ticket_id);
1469 1664
1470 - return [
1471 - 'live_activity' => TicketHelper::getActivity($ticket_id, $agent->id)
1472 - ];
1665 + $this->ensureCanAccessTicket($ticket);
1666 +
1667 + $agent = Helper::getAgentByUserId();
1668 +
1669 + return [
1670 + 'live_activity' => TicketHelper::getActivity($ticket_id, $agent->id)
1671 + ];
1672 + } catch (\Exception $e) {
1673 + return $this->sendError([
1674 + 'message' => Helper::getSafeErrorMessage($e)
1675 + ]);
1676 + }
1473 1677 }
1474 1678
1475 1679 /**
1476 - * removeLiveActivity method will remove activities that
1680 + * Remove the current agent from the ticket's live activity.
1681 + *
1477 1682 * @param Request $request
1478 - * @param $ticket_id
1683 + * @param int $ticket_id
1479 1684 * @return array
1480 1685 */
1481 1686 public function removeLiveActivity(Request $request, $ticket_id)
1482 1687 {
1483 - $agent = Helper::getAgentByUserId();
1688 + try {
1689 + $ticket = Ticket::findOrFail($ticket_id);
1484 1690
1485 - return [
1486 - 'result' => TicketHelper::removeFromActivities($ticket_id, $agent->id),
1487 - 'agent_id' => $agent->id
1488 - ];
1691 + $this->ensureCanAccessTicket($ticket);
1692 +
1693 + $agent = Helper::getAgentByUserId();
1694 +
1695 + return [
1696 + 'result' => TicketHelper::removeFromActivities($ticket_id, $agent->id),
1697 + 'agent_id' => $agent->id
1698 + ];
1699 + } catch (\Exception $e) {
1700 + return $this->sendError([
1701 + 'message' => Helper::getSafeErrorMessage($e)
1702 + ]);
1703 + }
1489 1704 }
1490 1705
1491 1706 /**
1492 - * addTag method will add tag in ticket by ticket id
1493 1707 * @param Request $request
1494 - * @param $ticket_id
1708 + * @param int $ticket_id
1495 1709 * @return array
1496 1710 */
1497 1711 public function addTag(Request $request, $ticket_id)
1498 1712 {
@@ -1497,8 +1711,11 @@
1497 1711 public function addTag(Request $request, $ticket_id)
1498 1712 {
1499 1713 try {
1500 1714 $ticket = Ticket::findOrFail($ticket_id);
1715 +
1716 + $this->ensureCanAccessTicket($ticket);
1717 +
1501 1718 $ticket->applyTags($request->getSafe('tag_id', 'intval'));
1502 1719
1503 1720 return [
1504 1721 'message' => __('Tag has been added to this ticket', 'fluent-support'),
@@ -1511,11 +1728,12 @@
1511 1728 }
1512 1729 }
1513 1730
1514 1731 /**
1515 - * detachTag method will remove all tags from tickets
1516 - * @param $ticket_id
1517 - * @param $tag_id
1732 + * Remove one tag from the ticket.
1733 + *
1734 + * @param int $ticket_id
1735 + * @param int $tag_id
1518 1736 * @return array
1519 1737 */
1520 1738 public function detachTag($ticket_id, $tag_id)
1521 1739 {
@@ -1520,8 +1738,11 @@
1520 1738 public function detachTag($ticket_id, $tag_id)
1521 1739 {
1522 1740 try {
1523 1741 $ticket = Ticket::findOrFail($ticket_id);
1742 +
1743 + $this->ensureCanAccessTicket($ticket);
1744 +
1524 1745 $ticket->detachTags($tag_id);
1525 1746
1526 1747 return [
1527 1748 'message' => __('Tag has been removed from this ticket', 'fluent-support'),
@@ -1534,11 +1755,10 @@
1534 1755 }
1535 1756 }
1536 1757
1537 1758 /**
1538 - * changeTicketCustomer method will update customer in a ticket
1539 - * This method will get ticket id and customer id as parameter, it will replace existing customer id with new
1540 1759 * @param Request $request
1760 + * @param int $ticket_id
1541 1761 * @return array
1542 1762 */
1543 1763 public function changeTicketCustomer(Request $request, $ticket_id)
1544 1764 {
@@ -1548,17 +1768,36 @@
1548 1768 if (!$newCustomerId) {
1549 1769 return $this->sendError(__('Invalid customer selected.', 'fluent-support'));
1550 1770 }
1551 1771
1772 + // The ticket would expose the new customer's profile data, so this
1773 + // needs the same capability as the customer routes.
1774 + if (!PermissionManager::currentUserCan('fst_sensitive_data')) {
1775 + return $this->sendError(__('You do not have permission to change the ticket customer.', 'fluent-support'));
1776 + }
1777 +
1552 1778 try {
1553 - $updated = Ticket::where('id', $ticketId)
1554 - ->where('customer_id', '!=', $newCustomerId)
1555 - ->update(['customer_id' => $newCustomerId]);
1779 + $ticket = Ticket::findOrFail($ticketId);
1556 1780
1557 - return $updated
1558 - ? ['message' => __('Customer has been updated', 'fluent-support')]
1559 - : $this->sendError(__('Ticket not found or customer already assigned.', 'fluent-support'));
1781 + $this->ensureCanAccessTicket($ticket);
1560 1782
1783 + $targetCustomer = Customer::where('id', $newCustomerId)
1784 + ->where('person_type', 'customer')
1785 + ->first();
1786 +
1787 + if (!$targetCustomer) {
1788 + return $this->sendError(__('Invalid customer selected.', 'fluent-support'));
1789 + }
1790 +
1791 + if ($ticket->customer_id == $newCustomerId) {
1792 + return $this->sendError(__('Customer already assigned to this ticket.', 'fluent-support'));
1793 + }
1794 +
1795 + $ticket->customer_id = $newCustomerId;
1796 + $ticket->save();
1797 +
1798 + return ['message' => __('Customer has been updated', 'fluent-support')];
1799 +
1561 1800 } catch (\Exception $e) {
1562 1801 return $this->sendError([
1563 1802 'message' => Helper::getSafeErrorMessage($e)
1564 1803 ]);
@@ -1565,11 +1804,10 @@
1565 1804 }
1566 1805 }
1567 1806
1568 1807 /**
1569 - * getTicketCustomData method will return the custom data by ticket id
1570 1808 * @param Request $request
1571 - * @param $ticket_id
1809 + * @param int $ticket_id
1572 1810 * @return array|array[]
1573 1811 */
1574 1812 public function getTicketCustomData(Request $request, $ticket_id)
1575 1813 {
@@ -1579,19 +1817,25 @@
1579 1817 'rendered_fields' => []
1580 1818 ];
1581 1819 }
1582 1820
1583 - $ticket = Ticket::findOrFail($ticket_id);
1821 + try {
1822 + $ticket = Ticket::findOrFail($ticket_id);
1584 1823
1585 - return [
1586 - 'custom_data' => (object)$ticket->customData(),
1587 - 'rendered_fields' => \FluentSupportPro\App\Services\CustomFieldsService::getRenderedPublicFields($ticket->customer, 'admin')
1588 - ];
1824 + $this->ensureCanAccessTicket($ticket);
1825 +
1826 + return [
1827 + 'custom_data' => (object)$ticket->customData(),
1828 + 'rendered_fields' => \FluentSupportPro\App\Services\CustomFieldsService::getRenderedPublicFields($ticket->customer, 'admin')
1829 + ];
1830 + } catch (\Exception $e) {
1831 + return $this->sendError([
1832 + 'message' => Helper::getSafeErrorMessage($e)
1833 + ]);
1834 + }
1589 1835 }
1590 1836
1591 1837 /**
1592 - * syncFluentCrmTags method will synchronize the tags with Fluent CRM by contact id
1593 - *This function will get contact id and tags as parameter, get existing tags from crm and updated added/removed tags
1594 1838 * @param Request $request
1595 1839 * @param FluentCRMServices $fluentCRMServices
1596 1840 * @return array
1597 1841 */
@@ -1601,9 +1845,8 @@
1601 1845 'contact_id' => $request->getSafe('contact_id', 'intval'),
1602 1846 'tags' => $request->get('tags', null)
1603 1847 ];
1604 1848
1605 - // Sanitize tags array if it's an array
1606 1849 if (is_array($data['tags'])) {
1607 1850 $data['tags'] = array_map('intval', $data['tags']);
1608 1851 }
1609 1852
@@ -1616,10 +1859,8 @@
1616 1859 }
1617 1860 }
1618 1861
1619 1862 /**
1620 - * This `syncFluentCrmLists` method will synchronize the lists with Fluent CRM by contact id
1621 - * This method will get contact id and lists as parameter, get existing lists from crm and updated added/removed lists
1622 1863 * @param Request $request
1623 1864 * @param FluentCRMServices $fluentCRMServices
1624 1865 * @return array
1625 1866 */
@@ -1630,9 +1871,8 @@
1630 1871 'contact_id' => $request->getSafe('contact_id', 'intval'),
1631 1872 'lists' => $request->get('lists', null, [])
1632 1873 ];
1633 1874
1634 - // Sanitize lists array if it's an array
1635 1875 if (is_array($data['lists'])) {
1636 1876 $data['lists'] = array_map('intval', $data['lists']);
1637 1877 }
1638 1878
@@ -1645,12 +1885,10 @@
1645 1885 }
1646 1886 }
1647 1887
1648 1888 /**
1649 - * Get ticket essentials data based on the provided types.
1650 - *
1651 - * @param \Illuminate\Http\Request $request
1652 - * @return array The ticket essentials data.
1889 + * @param Request $request
1890 + * @return array
1653 1891 */
1654 1892 public function getTicketEssentials(Request $request)
1655 1893 {
1656 1894 $type = $request->getSafe('type', 'sanitize_text_field');