PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 2.1.1
Fluent Support – Helpdesk & Customer Support Ticket System v2.1.1
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 1.5.5 All 68 releases
← All changes | app/Http/Controllers/TicketController.php +104 -641 2.3.22.1.1 View file →
@@ -10,10 +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 -use FluentSupport\App\Models\Product;
16 14 use FluentSupport\App\Models\Ticket;
17 15 use FluentSupport\App\Services\FluentCRMServices;
18 16 use FluentSupport\App\Services\Helper;
19 17 use FluentSupport\App\Services\ProfileInfoService;
@@ -19,13 +17,11 @@
19 17 use FluentSupport\App\Services\ProfileInfoService;
20 18 use FluentSupport\App\Services\TicketHelper;
21 19 use FluentSupport\App\Services\TicketQueryService;
22 20 use FluentSupport\App\Modules\PermissionManager;
23 -use FluentSupport\App\Services\Tickets\AgentTicketAccess;
24 21 use FluentSupport\App\Services\Tickets\ResponseService;
25 22 use FluentSupport\App\Models\AgentGroup;
26 23 use FluentSupport\App\Services\Tickets\TicketService;
27 -use FluentSupport\App\Services\Integrations\FluentBooking\FluentBookingService;
28 24
29 25 /**
30 26 * TicketController class for REST API related to ticket
31 27 * This class is responsible for getting / inserting/ modifying data for all request related to ticket
@@ -249,9 +245,9 @@
249 245 $ticketWith = is_array($ticketWith) ? map_deep($ticketWith, 'sanitize_text_field') : null;
250 246
251 247 if (!$ticketWith) {
252 248 $ticketWith = ['customer', 'agent', 'product', 'mailbox', 'tags', 'attachments' => function ($q) {
253 - $q->where('status', 'active');
249 + $q->whereIn('status', ['active', 'inline']);
254 250 }];
255 251 }
256 252
257 253 //Get ticket by id
@@ -258,15 +254,9 @@
258 254 $ticket = Ticket::with($ticketWith)->findOrFail($ticket_id);
259 255
260 256 //Eager load responses with their nested relations to avoid N+1 queries
261 257 $ticket->load(['responses' => function ($q) {
262 - $q->with([
263 - 'person',
264 - 'ccinfo',
265 - 'attachments' => function ($q) {
266 - $q->where('status', 'active');
267 - }
268 - ]);
258 + $q->with('person', 'attachments', 'ccinfo');
269 259 }]);
270 260
271 261 //Check if ticket is in a restricted mailbox
272 262 $restrictedBusinessBoxes = PermissionManager::getRestrictedMailboxIds();
@@ -317,44 +307,11 @@
317 307 }
318 308 }
319 309 }
320 310
321 - $contents = ['ticket' => $ticket->content];
322 - foreach ($ticket->responses as $response) {
323 - $contents['response_' . $response->id] = $response->content;
324 - }
325 -
326 - $contents = Helper::refreshSignedAttachmentUrlsInContents($contents, $ticket->id);
327 - $ticket->content = $contents['ticket'];
328 -
329 311 //Format response content
330 312 foreach ($ticket->responses as $response) {
331 - $responseKey = 'response_' . $response->id;
332 - if (isset($contents[$responseKey])) {
333 - $response->content = $contents[$responseKey];
334 - }
335 -
336 - $responseContent = apply_filters(
337 - 'fluent_support/response_content_before_render',
338 - $response->content,
339 - $response,
340 - $ticket
341 - );
342 -
343 - if ($response->conversation_type === 'note') {
344 - $responseContent = wpautop($responseContent, false);
345 - } else {
346 - $responseContent = links_add_target(make_clickable(wpautop($responseContent, false)));
347 - }
348 -
349 -
350 - $response->content = apply_filters(
351 - 'fluent_support/response_content_after_render',
352 - $responseContent,
353 - $response,
354 - $ticket
355 - );
356 -
313 + $response->content = links_add_target(make_clickable(wpautop($response->content, false)));
357 314 if (!empty($response->ccinfo)) {
358 315 $val = Helper::safeUnserialize($response->ccinfo->value);
359 316 if (isset($val['cc_email']) && !empty($val['cc_email'])) {
360 317 $response->cc_info = $val['cc_email'];
@@ -365,22 +322,10 @@
365 322 $response->cc_info = '';
366 323 }
367 324 }
368 325
369 - $ticketContent = apply_filters(
370 - 'fluent_support/ticket_content_before_render',
371 - $ticket->content,
372 - $ticket
373 - );
326 + $ticket->content = links_add_target(make_clickable(wpautop($ticket->content, false)));
374 327
375 - $ticketContent = links_add_target(make_clickable(wpautop($ticketContent, false)));
376 -
377 - $ticket->content = apply_filters(
378 - 'fluent_support/ticket_content_after_render',
379 - $ticketContent,
380 - $ticket
381 - );
382 -
383 328 //Get last activity by agent
384 329 $ticket->live_activity = TicketHelper::getActivity($ticket->id, $agent->id);
385 330
386 331 //Get all carbon copy customer
@@ -428,87 +373,8 @@
428 373 ]);
429 374 }
430 375 }
431 376
432 - public function getMentionableAgents(Request $request, $ticket_id)
433 - {
434 - try {
435 - $ticket = Ticket::findOrFail($ticket_id);
436 -
437 - if (in_array($ticket->mailbox_id, PermissionManager::getRestrictedMailboxIds())) {
438 - throw new \Exception(esc_html__('Ticket cannot be fetched due to restricted mailbox', 'fluent-support'));
439 - }
440 -
441 - $this->ensureCanAccessTicket($ticket);
442 -
443 - $search = trim($request->getSafe('search', 'sanitize_text_field', ''));
444 - $limit = min(max(absint($request->getSafe('limit', 'intval', 20)), 1), 50);
445 -
446 - return [
447 - 'agents' => $this->getMentionableAgentList($ticket, $search, $limit)
448 - ];
449 - } catch (\Exception $e) {
450 - return $this->sendError([
451 - 'message' => Helper::getSafeErrorMessage($e)
452 - ]);
453 - }
454 - }
455 -
456 - protected function getMentionableAgentList($ticket, $search, $limit)
457 - {
458 - $allAgents = Agent::select(['id', 'first_name', 'last_name', 'email', 'user_id'])
459 - ->mentionBy($search)
460 - ->orderBy('first_name')
461 - ->orderBy('last_name')
462 - ->get();
463 -
464 - if ($allAgents->isEmpty()) {
465 - return [];
466 - }
467 -
468 - $restrictions = $this->getAgentRestrictionsMap($allAgents->pluck('id')->all());
469 - $ticketAccess = new AgentTicketAccess();
470 - $results = [];
471 -
472 - foreach ($allAgents as $agent) {
473 - if (!$ticketAccess->canAccess($agent, $ticket, $restrictions[$agent->id] ?? [])) {
474 - continue;
475 - }
476 -
477 - $results[] = [
478 - 'id' => strval($agent->id),
479 - 'first_name' => $agent->first_name,
480 - 'last_name' => $agent->last_name,
481 - 'email' => $agent->email,
482 - ];
483 -
484 - if (count($results) >= $limit) {
485 - break;
486 - }
487 - }
488 -
489 - return $results;
490 - }
491 -
492 - protected function getAgentRestrictionsMap(array $agentIds)
493 - {
494 - if (!$agentIds) {
495 - return [];
496 - }
497 -
498 - $metas = Meta::where('object_type', 'person_meta')
499 - ->where('key', 'agent_restrictions')
500 - ->whereIn('object_id', $agentIds)
501 - ->get();
502 -
503 - $restrictions = [];
504 - foreach ($metas as $meta) {
505 - $restrictions[$meta->object_id] = Helper::safeUnserialize($meta->value) ?: [];
506 - }
507 -
508 - return $restrictions;
509 - }
510 -
511 377 /**
512 378 * createResponse method will create response by agent for the ticket
513 379 * @param Request $request
514 380 * @param Ticket $ticket
@@ -542,14 +408,8 @@
542 408 $this->ensureCanAccessTicket($ticket);
543 409
544 410 $responseData = (new ResponseService())->createResponse($data, $agent, $ticket);
545 411
546 - $responseData['response']->content = Helper::refreshSignedAttachmentUrls($responseData['response']->content, $ticket->id);
547 - $responseData['response']->load([
548 - 'attachments' => function ($q) {
549 - $q->where('status', 'active');
550 - }
551 - ]);
552 412 $responseData['response']->content = wp_specialchars_decode(wpautop($responseData['response']->content, false));
553 413
554 414 return [
555 415 'message' => __('Response has been added', 'fluent-support'),
@@ -563,115 +423,8 @@
563 423 ]);
564 424 }
565 425 }
566 426
567 - public function getFluentBookingEventTypes()
568 - {
569 - try {
570 - // All FluentBooking endpoints require manage permission; view-only agents cannot call a meeting.
571 - $this->ensureCanManageTickets();
572 -
573 - $service = new FluentBookingService();
574 - $eventTypes = $service->getEventTypes();
575 -
576 - return [
577 - 'status' => $service->getStatus($eventTypes),
578 - 'event_types' => $eventTypes
579 - ];
580 - } catch (\Exception $e) {
581 - return $this->sendError([
582 - 'message' => Helper::getSafeErrorMessage($e)
583 - ]);
584 - }
585 - }
586 -
587 - public function createFluentBookingLink(Request $request, $ticket_id)
588 - {
589 - try {
590 - // All FluentBooking endpoints require manage permission; view-only agents cannot call a meeting.
591 - $this->ensureCanManageTickets();
592 -
593 - $ticket = Ticket::with('customer')->findOrFail($ticket_id);
594 -
595 - // Enforces per-ticket visibility (e.g. own-tickets-only agents cannot access unassigned tickets).
596 - $this->ensureCanAccessTicket($ticket);
597 -
598 - $eventId = $request->getSafe('event_type_id', 'intval');
599 -
600 - if (!$eventId) {
601 - throw new \Exception(esc_html__('Please select a FluentBooking event type.', 'fluent-support'));
602 - }
603 -
604 - return (new FluentBookingService())->createBookingLink(
605 - $ticket,
606 - $eventId,
607 - $request->getSafe('message', 'wp_kses_post'),
608 - $request->get('selected_slots', []),
609 - $request->getSafe('timezone', 'sanitize_text_field', '')
610 - );
611 - } catch (\Exception $e) {
612 - return $this->sendError([
613 - 'message' => Helper::getSafeErrorMessage($e)
614 - ]);
615 - }
616 - }
617 -
618 - public function getFluentBookingAvailability(Request $request, $ticket_id)
619 - {
620 - try {
621 - // All FluentBooking endpoints require manage permission; view-only agents cannot call a meeting.
622 - $this->ensureCanManageTickets();
623 -
624 - $ticket = Ticket::with('customer')->findOrFail($ticket_id);
625 -
626 - // Enforces per-ticket visibility (e.g. own-tickets-only agents cannot access unassigned tickets).
627 - $this->ensureCanAccessTicket($ticket);
628 -
629 - $eventId = $request->getSafe('event_type_id', 'intval');
630 -
631 - if (!$eventId) {
632 - throw new \Exception(esc_html__('Please select a FluentBooking event type.', 'fluent-support'));
633 - }
634 -
635 - return [
636 - 'availability' => (new FluentBookingService())->getAvailabilitySlots(
637 - $eventId,
638 - $request->getSafe('range', 'sanitize_key', 'next_3_days'),
639 - $request->getSafe('timezone', 'sanitize_text_field'),
640 - $request->getSafe('duration', 'intval'),
641 - $ticket,
642 - $request->get('selected_dates', []),
643 - $request->getSafe('calendar_month', 'sanitize_text_field', '')
644 - )
645 - ];
646 - } catch (\Exception $e) {
647 - return $this->sendError([
648 - 'message' => Helper::getSafeErrorMessage($e)
649 - ]);
650 - }
651 - }
652 -
653 - public function getFluentBookingMeetings($ticket_id)
654 - {
655 - try {
656 - // All FluentBooking endpoints require manage permission; view-only agents cannot call a meeting.
657 - $this->ensureCanManageTickets();
658 -
659 - $ticket = Ticket::with('customer')->findOrFail($ticket_id);
660 -
661 - // Enforces per-ticket visibility (e.g. own-tickets-only agents cannot access unassigned tickets).
662 - $this->ensureCanAccessTicket($ticket);
663 -
664 - return [
665 - 'meetings' => (new FluentBookingService())->getTicketMeetings($ticket)
666 - ];
667 - } catch (\Exception $e) {
668 - return $this->sendError([
669 - 'message' => Helper::getSafeErrorMessage($e)
670 - ]);
671 - }
672 - }
673 -
674 427 /**
675 428 * createDraft method will create draft by agent for the ticket
676 429 * @param Request $request
677 430 * @param Ticket $ticket
@@ -788,14 +541,8 @@
788 541 'message' => __('Draft not found', 'fluent-support'),
789 542 ]);
790 543 }
791 544
792 - // Authorize the ticket this draft belongs to (closes the mailbox/visibility
793 - // dimension for managers deleting other agents' drafts).
794 - $ticket = Ticket::findOrFail($draft->object_id);
795 -
796 - $this->ensureCanAccessTicket($ticket);
797 -
798 545 // Verify ownership: draft key contains agent_id, only managers can delete others' drafts
799 546 $isOwnDraft = strpos($draft->key, '_agent_id_' . $agent->id . '_') !== false;
800 547
801 548 if (!$isOwnDraft && !PermissionManager::canManageTickets()) {
@@ -821,9 +568,9 @@
821 568 * @param Ticket $ticket
822 569 * @param $ticket_id
823 570 * @return array
824 571 */
825 - public function getTicketWidgets(Request $request, $ticket_id)
572 + public function getTicketWidgets($ticket_id)
826 573 {
827 574 try {
828 575 //Get ticket with customer by ticket id
829 576 $ticket = Ticket::with('customer')->findOrFail($ticket_id);
@@ -829,37 +576,22 @@
829 576 $ticket = Ticket::with('customer')->findOrFail($ticket_id);
830 577
831 578 $this->ensureCanAccessTicket($ticket);
832 579
833 - $perPage = max(1, absint(apply_filters('fluent_support/previous_ticket_widgets_limit', 5)));
834 - $page = max(1, absint($request->get('page', 1)));
835 - $offset = ($page - 1) * $perPage;
580 + //Get last N tickets of this customer except this
581 + $limit = apply_filters('fluent_support/previous_ticket_widgets_limit', 10);
836 582
837 - $baseQuery = Ticket::where('id', '!=', $ticket_id)
838 - ->where('customer_id', $ticket->customer_id);
839 -
840 - (new AgentTicketAccess())->applyAccessScope($baseQuery);
841 -
842 - $total = $baseQuery->count();
843 -
844 - $otherTickets = (clone $baseQuery)
583 + $otherTickets = Ticket::where('id', '!=', $ticket_id)
845 584 ->select(['id', 'title', 'status', 'created_at'])
585 + ->where('customer_id', $ticket->customer_id)
846 586 ->latest('id')
847 - ->limit($perPage)
848 - ->offset($offset)
587 + ->limit($limit)
849 588 ->get();
850 589
851 - $response = [
852 - 'other_tickets' => $otherTickets,
853 - 'other_tickets_total' => $total,
854 - 'other_tickets_more' => ($offset + $perPage) < $total,
590 + return [
591 + 'other_tickets' => $otherTickets,
592 + 'extra_widgets' => ProfileInfoService::getProfileExtraWidgets($ticket->customer)
855 593 ];
856 -
857 - if (in_array('extra_widgets', $request->get('with', []))) {
858 - $response['extra_widgets'] = ProfileInfoService::getProfileExtraWidgets($ticket->customer);
859 - }
860 -
861 - return $response;
862 594 } catch (\Exception $e) {
863 595 return $this->sendError([
864 596 'message' => Helper::getSafeErrorMessage($e)
865 597 ]);
@@ -882,49 +614,30 @@
882 614 $this->ensureCanAccessTicket($ticket);
883 615
884 616 $propName = $request->getSafe('prop_name', 'sanitize_text_field');
885 617 $propValue = $request->getSafe('prop_value', 'sanitize_text_field');
618 + $prevValue = $ticket->{$propName};
886 619
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.
894 - if (!in_array($propName, $this->updatableTicketProperties(), true)) {
895 - throw new \Exception(esc_html__('This ticket property cannot be updated.', 'fluent-support'), 403);
896 - }
620 + //Validate agent assignment restrictions
621 + if ($propName === 'agent_id') {
622 + if (!PermissionManager::currentUserCan('fst_assign_agents')) {
623 + throw new \Exception(esc_html__('Permission denied to assign agent', 'fluent-support'), 403);
624 + }
897 625
898 - $propValue = $this->sanitizeTicketProperty($ticket, $propName, $propValue);
626 + $agent = Agent::findOrFail($propValue);
627 + $restrictions = $agent->getMeta('agent_restrictions', []);
899 628
900 - $prevValue = $ticket->{$propName};
629 + if (!empty($restrictions['restrictedBusinessBoxes'])) {
630 + $mailboxId = (int) $ticket->mailbox_id;
631 + if (in_array($mailboxId, $restrictions['restrictedBusinessBoxes'], true)) {
632 + throw new \Exception(esc_html__('Agent is restricted for this mailbox ticket', 'fluent-support'), 403);
633 + }
634 + }
635 + }
901 636
902 - if ($propName && $propValue !== null && $prevValue != $propValue) {
637 + if ($propName && $propValue && $prevValue != $propValue) {
903 638 $ticket->{$propName} = $propValue;
904 639 $ticket->save();
905 -
906 - // Log an internal note for status changes so the activity is
907 - // traceable, mirroring the close/reopen flows.
908 - if ($propName === 'status') {
909 - $statuses = Helper::ticketStatuses();
910 - $fromLabel = isset($statuses[$prevValue]) ? $statuses[$prevValue] : $prevValue;
911 - $toLabel = isset($statuses[$propValue]) ? $statuses[$propValue] : $propValue;
912 -
913 - $internalNote = sprintf(
914 - /* translators: 1: previous status, 2: new status */
915 - __('Ticket status changed from %1$s to %2$s', 'fluent-support'),
916 - esc_html($fromLabel),
917 - esc_html($toLabel)
918 - );
919 -
920 - Conversation::create([
921 - 'ticket_id' => $ticket->id,
922 - 'person_id' => $assigner->id,
923 - 'conversation_type' => 'internal_info',
924 - 'content' => $internalNote
925 - ]);
926 - }
927 640 }
928 641
929 642 $updateData = [];
930 643
@@ -931,14 +644,13 @@
931 644 if ($propName == 'product_id') {
932 645 $ticket->load('product');
933 646 $updateData['product'] = $ticket->product;
934 647 } else if ($propName == 'agent_id') {
935 - $previousAgentId = (int) $prevValue;
936 648 $ticket->load('agent');
937 649 $updateData['agent'] = $ticket->agent;
938 650 $updateData['assigner'] = (new TicketService())->onAgentChange($ticket, $assigner);
939 651 if ($prevValue != $ticket->{$propName}) {
940 - do_action('fluent_support/agent_assigned_to_ticket', $ticket->agent, $ticket, $assigner, $previousAgentId);
652 + do_action('fluent_support/agent_assigned_to_ticket', $ticket->agent, $ticket, $assigner);
941 653 }
942 654 }
943 655
944 656 $message = sprintf(
@@ -958,142 +670,8 @@
958 670 }
959 671 }
960 672
961 673 /**
962 - * The only ticket columns that may be changed through updateTicketProperty.
963 - * This mirrors exactly what the admin UI edits (agent, title, mailbox,
964 - * product, status and the two priority fields). Ownership, audit,
965 - * public-identifier and other sensitive columns are intentionally absent
966 - * and must go through their dedicated workflows (FS-SEC-007).
967 - *
968 - * @return array
969 - */
970 - protected function updatableTicketProperties()
971 - {
972 - return [
973 - 'agent_id',
974 - 'title',
975 - 'mailbox_id',
976 - 'product_id',
977 - 'status',
978 - 'priority',
979 - 'client_priority',
980 - ];
981 - }
982 -
983 - /**
984 - * Validate and normalize a single ticket-property update. Each allowlisted
985 - * property is checked against its own value domain and capability, so a
986 - * caller can neither set an out-of-range value nor perform a change the UI
987 - * gates behind a stronger permission (FS-SEC-007).
988 - *
989 - * @param Ticket $ticket
990 - * @param string $propName Already confirmed to be in the allowlist.
991 - * @param string $propValue Raw (text-sanitized) value from the request.
992 - * @return mixed Normalized value ready to assign to the model.
993 - * @throws \Exception When the value is invalid or the caller lacks permission.
994 - */
995 - protected function sanitizeTicketProperty(Ticket $ticket, $propName, $propValue)
996 - {
997 - switch ($propName) {
998 - case 'title':
999 - $propValue = trim(sanitize_text_field($propValue));
1000 - if ($propValue === '') {
1001 - throw new \Exception(esc_html__('Ticket title cannot be empty.', 'fluent-support'), 422);
1002 - }
1003 - return $propValue;
1004 -
1005 - case 'status':
1006 - // Mirror the ticket-view status dropdown, which is built from
1007 - // changeable_ticket_statuses. The dropdown submits the group
1008 - // KEY as the status value (getTicketStatus in ViewTicket.vue
1009 - // keys the options by group name and el-option binds :value to
1010 - // that key), and only groups with a non-empty value list are
1011 - // shown. Validate against those same keys so the endpoint honors
1012 - // the fluent_support/changeable_ticket_statuses filter exactly.
1013 - $allowedStatuses = [];
1014 - foreach (Helper::changeableTicketStatuses() as $statusKey => $statusGroup) {
1015 - if (!empty($statusGroup)) {
1016 - $allowedStatuses[] = $statusKey;
1017 - }
1018 - }
1019 -
1020 - if (!in_array($propValue, $allowedStatuses, true)) {
1021 - throw new \Exception(esc_html__('Invalid ticket status.', 'fluent-support'), 422);
1022 - }
1023 - return $propValue;
1024 -
1025 - case 'priority':
1026 - if (!array_key_exists($propValue, Helper::adminTicketPriorities())) {
1027 - throw new \Exception(esc_html__('Invalid ticket priority.', 'fluent-support'), 422);
1028 - }
1029 - return $propValue;
1030 -
1031 - case 'client_priority':
1032 - if (!array_key_exists($propValue, Helper::customerTicketPriorities())) {
1033 - throw new \Exception(esc_html__('Invalid client priority.', 'fluent-support'), 422);
1034 - }
1035 - return $propValue;
1036 -
1037 - case 'product_id':
1038 - $productId = (int) $propValue;
1039 - if (!$productId || !Product::where('id', $productId)->exists()) {
1040 - throw new \Exception(esc_html__('Invalid product.', 'fluent-support'), 422);
1041 - }
1042 - return $productId;
1043 -
1044 - case 'agent_id':
1045 - if (!PermissionManager::currentUserCan('fst_assign_agents')) {
1046 - throw new \Exception(esc_html__('Permission denied to assign agent', 'fluent-support'), 403);
1047 - }
1048 -
1049 - $agentId = (int) $propValue;
1050 - $agent = Agent::findOrFail($agentId);
1051 - $restrictedBoxes = (new AgentTicketAccess())->getRestrictedMailboxIds($agent);
1052 -
1053 - if (in_array((int) $ticket->mailbox_id, $restrictedBoxes, true)) {
1054 - throw new \Exception(esc_html__('Agent is restricted for this mailbox ticket', 'fluent-support'), 403);
1055 - }
1056 - 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 - }
1089 -
1090 - // Unreachable: updateTicketProperty already rejected non-allowlisted
1091 - // properties before calling this method. Fail closed regardless.
1092 - throw new \Exception(esc_html__('This ticket property cannot be updated.', 'fluent-support'), 403);
1093 - }
1094 -
1095 - /**
1096 674 * closeTicket method close the ticket by id
1097 675 * @param Ticket $ticket
1098 676 * @param int $ticket_id
1099 677 * @return array
@@ -1157,14 +735,16 @@
1157 735 try {
1158 736 $action = $request->getSafe('bulk_action', 'sanitize_text_field');
1159 737 $ticketIds = array_map('intval', $request->get('ticket_ids', null, []));
1160 738
739 + $hasAllPermission = PermissionManager::currentUserCan('fst_manage_other_tickets');
1161 740 $agent = Helper::getAgentByUserId();
1162 741 $query = Ticket::whereIn('id', $ticketIds);
1163 742
1164 - //Scope selected tickets to what the agent can access, matching the
1165 - //per-ticket ensureCanAccessTicket() check on the single-ticket routes
1166 - (new AgentTicketAccess())->applyAccessScope($query, $agent);
743 + //If agent do not have permission to manage other tickets
744 + if (!$hasAllPermission) {
745 + $query->where('agent_id', $agent->id);
746 + }
1167 747
1168 748 //If bulk action is close tickets
1169 749 if ($action == 'close_tickets') {
1170 750 $tickets = $query->get();
@@ -1209,15 +789,13 @@
1209 789 $tickets = $query->get();
1210 790 $assignedCount = 0;
1211 791 $skippedCount = 0;
1212 792
1213 - $restrictedBoxes = (new AgentTicketAccess())->getRestrictedMailboxIds($assignAgent);
793 + $tickets->each(function ($ticket) use ($assignAgent, $agent, &$assignedCount, &$skippedCount) {
794 + $restrictions = $assignAgent->getMeta('agent_restrictions', []);
1214 795
1215 - $tickets->each(function ($ticket) use ($assignAgent, $agent, $restrictedBoxes, &$assignedCount, &$skippedCount) {
1216 - $previousAgentId = (int) $ticket->agent_id;
1217 -
1218 796 //Skip ticket if mailbox is restricted for the agent
1219 - if (!empty($ticket->mailbox_id) && in_array((int) $ticket->mailbox_id, $restrictedBoxes, true)) {
797 + if (!empty($restrictions) && in_array($ticket->mailbox_id, $restrictions['restrictedBusinessBoxes'])) {
1220 798 $skippedCount++;
1221 799 return;
1222 800 }
1223 801
@@ -1224,9 +802,9 @@
1224 802 $ticket->agent_id = $assignAgent->id;
1225 803 $ticket->save();
1226 804 $assignedCount++;
1227 805
1228 - do_action('fluent_support/agent_assigned_to_ticket', $assignAgent, $ticket, $agent, $previousAgentId);
806 + do_action('fluent_support/agent_assigned_to_ticket', $assignAgent, $ticket, $agent);
1229 807 });
1230 808
1231 809 $assignedMessage = sprintf(
1232 810 /* translators: %1$d is the number of tickets assigned, %2$s is the agent's name. */
@@ -1263,9 +841,8 @@
1263 841 $skippedCount = 0;
1264 842 $currentCounts = [];
1265 843
1266 844 foreach ($tickets as $ticket) {
1267 - $previousAgentId = (int) $ticket->agent_id;
1268 845 $selectedAgent = $group->getLeastLoadedAgent(
1269 846 $ticket->mailbox_id, $currentCounts
1270 847 );
1271 848
@@ -1279,9 +856,9 @@
1279 856 $assignedCount++;
1280 857 $currentCounts[$selectedAgent->id]++;
1281 858
1282 859 as_enqueue_async_action('fluent_support/async_agent_assigned_to_ticket', [
1283 - $selectedAgent->id, $ticket->id, $agent->id, $previousAgentId
860 + $selectedAgent->id, $ticket->id, $agent->id
1284 861 ], 'fluent-support');
1285 862 }
1286 863
1287 864 return [
@@ -1325,10 +902,8 @@
1325 902 {
1326 903 try {
1327 904 $ticket = Ticket::findOrFail($ticket_id);
1328 905
1329 - $this->ensureCanAccessTicket($ticket);
1330 -
1331 906 (new TicketService())->deleteTicket($ticket);
1332 907
1333 908 return [
1334 909 'message' => __('Ticket has been deleted successfully', 'fluent-support')
@@ -1376,12 +951,15 @@
1376 951 //Get logged in agent information
1377 952 $agent = Helper::getAgentByUserId();
1378 953 $ticketIds = array_filter($data['ticket_ids'], 'absint');
1379 954
955 + $hasAllPermission = PermissionManager::currentUserCan('fst_manage_other_tickets');
1380 956 $query = Ticket::whereIn('id', $ticketIds)->where('status', '!=', 'closed');
1381 957
1382 - // Scope to tickets the agent may access (visibility + mailbox restrictions).
1383 - (new AgentTicketAccess())->applyAccessScope($query, $agent);
958 + //If the agent does not have permission
959 + if (!$hasAllPermission) {
960 + $query->where('agent_id', $agent->id);
961 + }
1384 962
1385 963 $tickets = $query->get();
1386 964
1387 965 if ($tickets->isEmpty()) {
@@ -1453,29 +1031,17 @@
1453 1031 public function deleteResponse($ticket_id, $response_id)
1454 1032 {
1455 1033 try {
1456 1034 $ticket = Ticket::findOrFail($ticket_id);
1035 + $response = Conversation::findOrFail($response_id);
1036 + $agent = Helper::getAgentByUserId();
1457 1037
1458 - if (in_array($ticket->mailbox_id, PermissionManager::getRestrictedMailboxIds())) {
1459 - throw new \Exception(esc_html__('Ticket cannot be fetched due to restricted mailbox', 'fluent-support'));
1460 - }
1461 -
1462 - // The caller must have access to this specific ticket (visibility +
1463 - // ownership + mailbox), not merely a global manage capability.
1464 - $this->ensureCanAccessTicket($ticket);
1465 -
1466 - // Deleting a response always requires the explicit delete capability,
1467 - // mirroring deleteTicket(). Assignment alone is not sufficient.
1468 - if (!PermissionManager::currentUserCan('fst_delete_tickets')) {
1038 + if (!PermissionManager::currentUserCan('fst_delete_tickets') && $ticket->agent_id !== $agent->id) {
1469 1039 throw new \Exception(
1470 1040 esc_html__('Sorry, you do not have permission to delete this response.', 'fluent-support')
1471 1041 );
1472 1042 }
1473 1043
1474 - $response = Conversation::where('id', $response_id)
1475 - ->where('ticket_id', $ticket_id)
1476 - ->firstOrFail();
1477 -
1478 1044 $response->delete();
1479 1045 $response->ccinfo()->delete();
1480 1046
1481 1047 return [
@@ -1499,67 +1065,31 @@
1499 1065 public function updateResponse(TicketResponseRequest $request, $ticket_id, $response_id)
1500 1066 {
1501 1067 try {
1502 1068 $ticket = Ticket::findOrFail($ticket_id);
1503 -
1504 - if (in_array($ticket->mailbox_id, PermissionManager::getRestrictedMailboxIds())) {
1505 - throw new \Exception(esc_html__('Ticket cannot be fetched due to restricted mailbox', 'fluent-support'));
1506 - }
1507 -
1508 - // The caller must have access to this specific ticket (visibility +
1509 - // ownership + mailbox), not merely a global manage capability.
1510 - $this->ensureCanAccessTicket($ticket);
1511 -
1512 - $response = Conversation::where('id', $response_id)
1513 - ->where('ticket_id', $ticket_id)
1514 - ->with('person')
1515 - ->firstOrFail();
1069 + $response = Conversation::findOrFail($response_id);
1516 1070 $agent = Helper::getAgentByUserId();
1517 1071
1518 - // Only agent-authored conversation types may be edited here. Customer
1519 - // replies and system entries must not be rewritten via this endpoint.
1520 - $editableTypes = ['response', 'draft_response', 'note', 'internal_info'];
1521 - if (!in_array($response->conversation_type, $editableTypes, true)) {
1072 + if (!PermissionManager::currentUserCan('fst_manage_other_tickets') && $ticket->agent_id !== $agent->id) {
1522 1073 throw new \Exception(
1523 - esc_html__('This response type cannot be edited.', 'fluent-support')
1524 - );
1525 - }
1526 -
1527 - // Customer messages share the 'response' type but are authored by a
1528 - // customer person; they are never editable by an agent.
1529 - if ($response->person && $response->person->person_type !== 'agent') {
1530 - throw new \Exception(
1531 1074 esc_html__('Sorry, you do not have permission to update this response.', 'fluent-support')
1532 1075 );
1533 1076 }
1534 1077
1535 - $isDraft = $response->conversation_type == 'draft_response';
1536 - $isAuthor = (int) $response->person_id === (int) $agent->id;
1537 - $canApproveDraft = PermissionManager::currentUserCan('fst_approve_draft_reply');
1078 + $response->content = wp_unslash(wp_kses_post($request->getSafe('content', 'wp_kses_post')));
1538 1079
1539 - if ($isDraft && !$isAuthor) {
1540 - // Another agent's draft can only be edited/approved by an approver.
1541 - if (!$canApproveDraft) {
1080 + //If updating a draft response by someone other than the author, check approval permission
1081 + if ($response->conversation_type == 'draft_response' && $response->person_id != $agent->id) {
1082 + if (!PermissionManager::currentUserCan('fst_approve_draft_reply')) {
1542 1083 throw new \Exception(
1543 1084 esc_html__('Sorry, You do not have permission to approve this draft response', 'fluent-support')
1544 1085 );
1545 1086 }
1546 - } elseif (!$isAuthor && !PermissionManager::currentUserCan('fst_manage_other_tickets')) {
1547 - // Editing another agent's response requires manage-others capability.
1548 - throw new \Exception(
1549 - esc_html__('Sorry, you do not have permission to update this response.', 'fluent-support')
1550 - );
1087 + $response->conversation_type = 'response';
1551 1088 }
1552 1089
1553 - $content = wp_unslash(wp_kses_post($request->getSafe('content', 'wp_kses_post')));
1554 - $response->content = $content;
1090 + $response->save();
1555 1091
1556 - if ($isDraft && !$isAuthor && $canApproveDraft) {
1557 - $response = $this->approveDraftConversation($ticket, $response, $agent, $content);
1558 - } else {
1559 - $response->save();
1560 - }
1561 -
1562 1092 return [
1563 1093 'message' => __('Selected response has been updated', 'fluent-support'),
1564 1094 'response' => $response
1565 1095 ];
@@ -1580,10 +1110,8 @@
1580 1110 }
1581 1111
1582 1112 $ticket = Ticket::findOrFail($ticket_id);
1583 1113
1584 - $this->ensureCanAccessTicket($ticket);
1585 -
1586 1114 $response = Conversation::where('id', $response_id)
1587 1115 ->where('ticket_id', $ticket_id)
1588 1116 ->where('conversation_type', 'draft_response')
1589 1117 ->firstOrFail();
@@ -1589,15 +1117,34 @@
1589 1117 ->firstOrFail();
1590 1118
1591 1119 $person = Helper::getAgentByUserId();
1592 1120
1593 - $response = $this->approveDraftConversation(
1594 - $ticket,
1595 - $response,
1596 - $person,
1597 - wp_unslash(wp_kses_post($request->getSafe('content', 'wp_kses_post')))
1598 - );
1121 + $content = wp_unslash(wp_kses_post($request->getSafe('content', 'wp_kses_post')));
1122 + $resetWaitingSince = apply_filters('fluent_support/reset_waiting_since', true, $content);
1599 1123
1124 + $response->conversation_type = 'response';
1125 + $response->created_at = current_time('mysql');
1126 + $response->save();
1127 +
1128 + if ($person->person_type == 'agent' && $ticket->status == 'new') {
1129 + $ticket->status = 'active';
1130 + if ($ticket->created_at) {
1131 + $ticket->first_response_time = strtotime(current_time('mysql')) - strtotime($ticket->created_at);
1132 + } else {
1133 + $ticket->first_response_time = 300;
1134 + }
1135 + }
1136 +
1137 + if ($resetWaitingSince) {
1138 + $ticket->last_agent_response = current_time('mysql');
1139 + $ticket->waiting_since = current_time('mysql');
1140 + }
1141 +
1142 + $ticket->response_count += 1;
1143 + $ticket->save();
1144 +
1145 + do_action('fluent_support/response_added_by_' . $person->person_type, $response, $ticket, $person);
1146 +
1600 1147 return [
1601 1148 'message' => __('Draft response has been successfully approved.', 'fluent-support'),
1602 1149 'response' => $response,
1603 1150 ];
@@ -1607,39 +1154,8 @@
1607 1154 ]);
1608 1155 }
1609 1156 }
1610 1157
1611 - protected function approveDraftConversation($ticket, $response, $person, $content)
1612 - {
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;
1640 - }
1641 -
1642 1158 /**
1643 1159 * getLiveActivity method will return the activity in a ticket by agents
1644 1160 * @param Request $request
1645 1161 * @param $ticket_id
@@ -1646,23 +1162,13 @@
1646 1162 * @return array
1647 1163 */
1648 1164 public function getLiveActivity(Request $request, $ticket_id)
1649 1165 {
1650 - try {
1651 - $ticket = Ticket::findOrFail($ticket_id);
1166 + $agent = Helper::getAgentByUserId();
1652 1167
1653 - $this->ensureCanAccessTicket($ticket);
1654 -
1655 - $agent = Helper::getAgentByUserId();
1656 -
1657 - return [
1658 - 'live_activity' => TicketHelper::getActivity($ticket_id, $agent->id)
1659 - ];
1660 - } catch (\Exception $e) {
1661 - return $this->sendError([
1662 - 'message' => Helper::getSafeErrorMessage($e)
1663 - ]);
1664 - }
1168 + return [
1169 + 'live_activity' => TicketHelper::getActivity($ticket_id, $agent->id)
1170 + ];
1665 1171 }
1666 1172
1667 1173 /**
1668 1174 * removeLiveActivity method will remove activities that
@@ -1671,24 +1177,14 @@
1671 1177 * @return array
1672 1178 */
1673 1179 public function removeLiveActivity(Request $request, $ticket_id)
1674 1180 {
1675 - try {
1676 - $ticket = Ticket::findOrFail($ticket_id);
1181 + $agent = Helper::getAgentByUserId();
1677 1182
1678 - $this->ensureCanAccessTicket($ticket);
1679 -
1680 - $agent = Helper::getAgentByUserId();
1681 -
1682 - return [
1683 - 'result' => TicketHelper::removeFromActivities($ticket_id, $agent->id),
1684 - 'agent_id' => $agent->id
1685 - ];
1686 - } catch (\Exception $e) {
1687 - return $this->sendError([
1688 - 'message' => Helper::getSafeErrorMessage($e)
1689 - ]);
1690 - }
1183 + return [
1184 + 'result' => TicketHelper::removeFromActivities($ticket_id, $agent->id),
1185 + 'agent_id' => $agent->id
1186 + ];
1691 1187 }
1692 1188
1693 1189 /**
1694 1190 * addTag method will add tag in ticket by ticket id
@@ -1699,11 +1195,8 @@
1699 1195 public function addTag(Request $request, $ticket_id)
1700 1196 {
1701 1197 try {
1702 1198 $ticket = Ticket::findOrFail($ticket_id);
1703 -
1704 - $this->ensureCanAccessTicket($ticket);
1705 -
1706 1199 $ticket->applyTags($request->getSafe('tag_id', 'intval'));
1707 1200
1708 1201 return [
1709 1202 'message' => __('Tag has been added to this ticket', 'fluent-support'),
@@ -1725,11 +1218,8 @@
1725 1218 public function detachTag($ticket_id, $tag_id)
1726 1219 {
1727 1220 try {
1728 1221 $ticket = Ticket::findOrFail($ticket_id);
1729 -
1730 - $this->ensureCanAccessTicket($ticket);
1731 -
1732 1222 $ticket->detachTags($tag_id);
1733 1223
1734 1224 return [
1735 1225 'message' => __('Tag has been removed from this ticket', 'fluent-support'),
@@ -1747,11 +1237,11 @@
1747 1237 * This method will get ticket id and customer id as parameter, it will replace existing customer id with new
1748 1238 * @param Request $request
1749 1239 * @return array
1750 1240 */
1751 - public function changeTicketCustomer(Request $request, $ticket_id)
1241 + public function changeTicketCustomer(Request $request)
1752 1242 {
1753 - $ticketId = (int) $ticket_id;
1243 + $ticketId = $request->getSafe('ticket_id', 'intval');
1754 1244 $newCustomerId = $request->getSafe('customer', 'intval');
1755 1245
1756 1246 if (!$newCustomerId) {
1757 1247 return $this->sendError(__('Invalid customer selected.', 'fluent-support'));
@@ -1756,37 +1246,17 @@
1756 1246 if (!$newCustomerId) {
1757 1247 return $this->sendError(__('Invalid customer selected.', 'fluent-support'));
1758 1248 }
1759 1249
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.
1763 - if (!PermissionManager::currentUserCan('fst_sensitive_data')) {
1764 - return $this->sendError(__('You do not have permission to change the ticket customer.', 'fluent-support'));
1765 - }
1766 -
1767 1250 try {
1768 - $ticket = Ticket::findOrFail($ticketId);
1251 + $updated = Ticket::where('id', $ticketId)
1252 + ->where('customer_id', '!=', $newCustomerId)
1253 + ->update(['customer_id' => $newCustomerId]);
1769 1254
1770 - $this->ensureCanAccessTicket($ticket);
1255 + return $updated
1256 + ? ['message' => __('Customer has been updated', 'fluent-support')]
1257 + : $this->sendError(__('Ticket not found or customer already assigned.', 'fluent-support'));
1771 1258
1772 - $targetCustomer = Customer::where('id', $newCustomerId)
1773 - ->where('person_type', 'customer')
1774 - ->first();
1775 -
1776 - if (!$targetCustomer) {
1777 - return $this->sendError(__('Invalid customer selected.', 'fluent-support'));
1778 - }
1779 -
1780 - if ($ticket->customer_id == $newCustomerId) {
1781 - return $this->sendError(__('Customer already assigned to this ticket.', 'fluent-support'));
1782 - }
1783 -
1784 - $ticket->customer_id = $newCustomerId;
1785 - $ticket->save();
1786 -
1787 - return ['message' => __('Customer has been updated', 'fluent-support')];
1788 -
1789 1259 } catch (\Exception $e) {
1790 1260 return $this->sendError([
1791 1261 'message' => Helper::getSafeErrorMessage($e)
1792 1262 ]);
@@ -1807,22 +1277,14 @@
1807 1277 'rendered_fields' => []
1808 1278 ];
1809 1279 }
1810 1280
1811 - try {
1812 - $ticket = Ticket::findOrFail($ticket_id);
1281 + $ticket = Ticket::findOrFail($ticket_id);
1813 1282
1814 - $this->ensureCanAccessTicket($ticket);
1815 -
1816 - return [
1817 - 'custom_data' => (object)$ticket->customData(),
1818 - 'rendered_fields' => \FluentSupportPro\App\Services\CustomFieldsService::getRenderedPublicFields($ticket->customer, 'admin')
1819 - ];
1820 - } catch (\Exception $e) {
1821 - return $this->sendError([
1822 - 'message' => Helper::getSafeErrorMessage($e)
1823 - ]);
1824 - }
1283 + return [
1284 + 'custom_data' => (object)$ticket->customData(),
1285 + 'rendered_fields' => \FluentSupportPro\App\Services\CustomFieldsService::getRenderedPublicFields($ticket->customer, 'admin')
1286 + ];
1825 1287 }
1826 1288
1827 1289 /**
1828 1290 * syncFluentCrmTags method will synchronize the tags with Fluent CRM by contact id
@@ -1941,4 +1403,5 @@
1941 1403 ]);
1942 1404 }
1943 1405 }
1944 1406 }
1407 +