PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 2.1.2
Fluent Support – Helpdesk & Customer Support Ticket System v2.1.2
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 +105 -652 2.4.02.1.2 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
@@ -66,9 +62,9 @@
66 62 $businessSettings = (new \FluentSupport\App\Services\EmailNotification\Settings())->globalBusinessSettings();
67 63 $maxFileSize = absint($businessSettings['max_file_size']);
68 64
69 65 $portalSettings = [
70 - 'support_products' => \FluentSupport\App\Models\Product::select(['id', 'title'])->orderedByTitle()->get(),
66 + 'support_products' => \FluentSupport\App\Models\Product::select(['id', 'title'])->get(),
71 67 'customer_ticket_priorities' => Helper::customerTicketPriorities(),
72 68 'has_file_upload' => !!Helper::ticketAcceptedFileMiles(),
73 69 'has_rich_text_editor' => true,
74 70 'max_file_size' => $maxFileSize,
@@ -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 - // 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,150 +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.
967 - *
968 - * @return array
969 - */
970 - protected function updatableTicketProperties()
971 - {
972 - return [
973 - 'agent_id',
974 - 'title',
975 - 'mailbox_id',
976 - 'product_id',
977 - 'status',
978 - 'priority',
979 - 'client_priority',
980 - ];
981 - }
982 -
983 - /**
984 - * Validate and normalize a single ticket-property update. Each allowlisted
985 - * property is checked against its own value domain and capability, so a
986 - * caller can neither set an out-of-range value nor perform a change the UI
987 - * gates behind a stronger permission.
988 - *
989 - * @param Ticket $ticket
990 - * @param string $propName Already confirmed to be in the allowlist.
991 - * @param string $propValue Raw (text-sanitized) value from the request.
992 - * @return mixed Normalized value ready to assign to the model.
993 - * @throws \Exception When the value is invalid or the caller lacks permission.
994 - */
995 - protected function sanitizeTicketProperty(Ticket $ticket, $propName, $propValue)
996 - {
997 - switch ($propName) {
998 - case 'title':
999 - $propValue = trim(sanitize_text_field($propValue));
1000 - if ($propValue === '') {
1001 - throw new \Exception(esc_html__('Ticket title cannot be empty.', 'fluent-support'), 422);
1002 - }
1003 - return $propValue;
1004 -
1005 - case 'status':
1006 - // Mirror the ticket-view status dropdown, which is built from
1007 - // changeable_ticket_statuses. The dropdown submits the group
1008 - // KEY as the status value (getTicketStatus in ViewTicket.vue
1009 - // keys the options by group name and el-option binds :value to
1010 - // that key), and only groups with a non-empty value list are
1011 - // shown. Validate against those same keys so the endpoint honors
1012 - // the fluent_support/changeable_ticket_statuses filter exactly.
1013 - $allowedStatuses = [];
1014 - foreach (Helper::changeableTicketStatuses() as $statusKey => $statusGroup) {
1015 - if (!empty($statusGroup)) {
1016 - $allowedStatuses[] = $statusKey;
1017 - }
1018 - }
1019 -
1020 - if (!in_array($propValue, $allowedStatuses, true)) {
1021 - throw new \Exception(esc_html__('Invalid ticket status.', 'fluent-support'), 422);
1022 - }
1023 -
1024 - // This route only assigns the column and saves, so closing or
1025 - // reopening here would skip TicketService's closure fields, hooks and cleanup.
1026 - // Use closeTicket() / reOpenTicket(); the status dropdown already does.
1027 - if ($propValue === 'closed' || $ticket->status === 'closed') {
1028 - throw new \Exception(esc_html__('Closing or reopening a ticket must use the dedicated close and re-open actions.', 'fluent-support'), 422);
1029 - }
1030 -
1031 - return $propValue;
1032 -
1033 - case 'priority':
1034 - if (!array_key_exists($propValue, Helper::adminTicketPriorities())) {
1035 - throw new \Exception(esc_html__('Invalid ticket priority.', 'fluent-support'), 422);
1036 - }
1037 - return $propValue;
1038 -
1039 - case 'client_priority':
1040 - if (!array_key_exists($propValue, Helper::customerTicketPriorities())) {
1041 - throw new \Exception(esc_html__('Invalid client priority.', 'fluent-support'), 422);
1042 - }
1043 - return $propValue;
1044 -
1045 - case 'product_id':
1046 - $productId = (int) $propValue;
1047 - if (!$productId || !Product::where('id', $productId)->exists()) {
1048 - throw new \Exception(esc_html__('Invalid product.', 'fluent-support'), 422);
1049 - }
1050 - return $productId;
1051 -
1052 - case 'agent_id':
1053 - if (!PermissionManager::currentUserCan('fst_assign_agents')) {
1054 - throw new \Exception(esc_html__('Permission denied to assign agent', 'fluent-support'), 403);
1055 - }
1056 -
1057 - $agentId = (int) $propValue;
1058 - $agent = Agent::findOrFail($agentId);
1059 - $restrictedBoxes = (new AgentTicketAccess())->getRestrictedMailboxIds($agent);
1060 -
1061 - if (in_array((int) $ticket->mailbox_id, $restrictedBoxes, true)) {
1062 - throw new \Exception(esc_html__('Agent is restricted for this mailbox ticket', 'fluent-support'), 403);
1063 - }
1064 - return $agentId;
1065 -
1066 - case 'mailbox_id':
1067 - // The admin UI only exposes the mailbox switcher to agents with
1068 - // fst_manage_settings; enforce the same gate on the API so the
1069 - // permission can't be bypassed by calling the endpoint directly.
1070 - if (!PermissionManager::currentUserCan('fst_manage_settings')) {
1071 - throw new \Exception(esc_html__('Permission denied to move this ticket to another mailbox.', 'fluent-support'), 403);
1072 - }
1073 -
1074 - $mailboxId = (int) $propValue;
1075 - $restrictedBoxes = array_map('intval', PermissionManager::getRestrictedMailboxIds());
1076 -
1077 - if (!MailBox::where('id', $mailboxId)->exists() || in_array($mailboxId, $restrictedBoxes, true)) {
1078 - throw new \Exception(esc_html__('Invalid or restricted mailbox.', 'fluent-support'), 422);
1079 - }
1080 -
1081 - // Preserve the agent/mailbox compatibility invariant that the
1082 - // agent_id branch enforces on assignment: a ticket must not be
1083 - // moved into a mailbox its currently assigned agent is restricted
1084 - // from, which would otherwise persist an assignment the assign
1085 - // flow would have rejected.
1086 - if ($ticket->agent_id) {
1087 - $assignedAgent = Agent::find($ticket->agent_id);
1088 - if ($assignedAgent) {
1089 - $agentRestrictedBoxes = (new AgentTicketAccess())->getRestrictedMailboxIds($assignedAgent);
1090 - if (in_array($mailboxId, $agentRestrictedBoxes, true)) {
1091 - throw new \Exception(esc_html__('The assigned agent is restricted from the selected mailbox. Reassign the ticket before moving it.', 'fluent-support'), 403);
1092 - }
1093 - }
1094 - }
1095 - return $mailboxId;
1096 - }
1097 -
1098 - // Unreachable: updateTicketProperty already rejected non-allowlisted
1099 - // properties before calling this method. Fail closed regardless.
1100 - throw new \Exception(esc_html__('This ticket property cannot be updated.', 'fluent-support'), 403);
1101 - }
1102 -
1103 - /**
1104 674 * closeTicket method close the ticket by id
1105 675 * @param Ticket $ticket
1106 676 * @param int $ticket_id
1107 677 * @return array
@@ -1165,14 +735,16 @@
1165 735 try {
1166 736 $action = $request->getSafe('bulk_action', 'sanitize_text_field');
1167 737 $ticketIds = array_map('intval', $request->get('ticket_ids', null, []));
1168 738
739 + $hasAllPermission = PermissionManager::currentUserCan('fst_manage_other_tickets');
1169 740 $agent = Helper::getAgentByUserId();
1170 741 $query = Ticket::whereIn('id', $ticketIds);
1171 742
1172 - //Scope selected tickets to what the agent can access, matching the
1173 - //per-ticket ensureCanAccessTicket() check on the single-ticket routes
1174 - (new AgentTicketAccess())->applyAccessScope($query, $agent);
743 + //If agent do not have permission to manage other tickets
744 + if (!$hasAllPermission) {
745 + $query->where('agent_id', $agent->id);
746 + }
1175 747
1176 748 //If bulk action is close tickets
1177 749 if ($action == 'close_tickets') {
1178 750 $tickets = $query->get();
@@ -1217,15 +789,13 @@
1217 789 $tickets = $query->get();
1218 790 $assignedCount = 0;
1219 791 $skippedCount = 0;
1220 792
1221 - $restrictedBoxes = (new AgentTicketAccess())->getRestrictedMailboxIds($assignAgent);
793 + $tickets->each(function ($ticket) use ($assignAgent, $agent, &$assignedCount, &$skippedCount) {
794 + $restrictions = $assignAgent->getMeta('agent_restrictions', []);
1222 795
1223 - $tickets->each(function ($ticket) use ($assignAgent, $agent, $restrictedBoxes, &$assignedCount, &$skippedCount) {
1224 - $previousAgentId = (int) $ticket->agent_id;
1225 -
1226 796 //Skip ticket if mailbox is restricted for the agent
1227 - if (!empty($ticket->mailbox_id) && in_array((int) $ticket->mailbox_id, $restrictedBoxes, true)) {
797 + if (!empty($restrictions) && in_array($ticket->mailbox_id, $restrictions['restrictedBusinessBoxes'])) {
1228 798 $skippedCount++;
1229 799 return;
1230 800 }
1231 801
@@ -1232,9 +802,9 @@
1232 802 $ticket->agent_id = $assignAgent->id;
1233 803 $ticket->save();
1234 804 $assignedCount++;
1235 805
1236 - do_action('fluent_support/agent_assigned_to_ticket', $assignAgent, $ticket, $agent, $previousAgentId);
806 + do_action('fluent_support/agent_assigned_to_ticket', $assignAgent, $ticket, $agent);
1237 807 });
1238 808
1239 809 $assignedMessage = sprintf(
1240 810 /* translators: %1$d is the number of tickets assigned, %2$s is the agent's name. */
@@ -1271,9 +841,8 @@
1271 841 $skippedCount = 0;
1272 842 $currentCounts = [];
1273 843
1274 844 foreach ($tickets as $ticket) {
1275 - $previousAgentId = (int) $ticket->agent_id;
1276 845 $selectedAgent = $group->getLeastLoadedAgent(
1277 846 $ticket->mailbox_id, $currentCounts
1278 847 );
1279 848
@@ -1287,9 +856,9 @@
1287 856 $assignedCount++;
1288 857 $currentCounts[$selectedAgent->id]++;
1289 858
1290 859 as_enqueue_async_action('fluent_support/async_agent_assigned_to_ticket', [
1291 - $selectedAgent->id, $ticket->id, $agent->id, $previousAgentId
860 + $selectedAgent->id, $ticket->id, $agent->id
1292 861 ], 'fluent-support');
1293 862 }
1294 863
1295 864 return [
@@ -1333,10 +902,8 @@
1333 902 {
1334 903 try {
1335 904 $ticket = Ticket::findOrFail($ticket_id);
1336 905
1337 - $this->ensureCanAccessTicket($ticket);
1338 -
1339 906 (new TicketService())->deleteTicket($ticket);
1340 907
1341 908 return [
1342 909 'message' => __('Ticket has been deleted successfully', 'fluent-support')
@@ -1384,12 +951,15 @@
1384 951 //Get logged in agent information
1385 952 $agent = Helper::getAgentByUserId();
1386 953 $ticketIds = array_filter($data['ticket_ids'], 'absint');
1387 954
955 + $hasAllPermission = PermissionManager::currentUserCan('fst_manage_other_tickets');
1388 956 $query = Ticket::whereIn('id', $ticketIds)->where('status', '!=', 'closed');
1389 957
1390 - // Scope to tickets the agent may access (visibility + mailbox restrictions).
1391 - (new AgentTicketAccess())->applyAccessScope($query, $agent);
958 + //If the agent does not have permission
959 + if (!$hasAllPermission) {
960 + $query->where('agent_id', $agent->id);
961 + }
1392 962
1393 963 $tickets = $query->get();
1394 964
1395 965 if ($tickets->isEmpty()) {
@@ -1461,29 +1031,17 @@
1461 1031 public function deleteResponse($ticket_id, $response_id)
1462 1032 {
1463 1033 try {
1464 1034 $ticket = Ticket::findOrFail($ticket_id);
1035 + $response = Conversation::findOrFail($response_id);
1036 + $agent = Helper::getAgentByUserId();
1465 1037
1466 - if (in_array($ticket->mailbox_id, PermissionManager::getRestrictedMailboxIds())) {
1467 - throw new \Exception(esc_html__('Ticket cannot be fetched due to restricted mailbox', 'fluent-support'));
1468 - }
1469 -
1470 - // The caller must have access to this specific ticket (visibility +
1471 - // ownership + mailbox), not merely a global manage capability.
1472 - $this->ensureCanAccessTicket($ticket);
1473 -
1474 - // Deleting a response always requires the explicit delete capability,
1475 - // mirroring deleteTicket(). Assignment alone is not sufficient.
1476 - if (!PermissionManager::currentUserCan('fst_delete_tickets')) {
1038 + if (!PermissionManager::currentUserCan('fst_delete_tickets') && $ticket->agent_id !== $agent->id) {
1477 1039 throw new \Exception(
1478 1040 esc_html__('Sorry, you do not have permission to delete this response.', 'fluent-support')
1479 1041 );
1480 1042 }
1481 1043
1482 - $response = Conversation::where('id', $response_id)
1483 - ->where('ticket_id', $ticket_id)
1484 - ->firstOrFail();
1485 -
1486 1044 $response->delete();
1487 1045 $response->ccinfo()->delete();
1488 1046
1489 1047 return [
@@ -1507,69 +1065,31 @@
1507 1065 public function updateResponse(TicketResponseRequest $request, $ticket_id, $response_id)
1508 1066 {
1509 1067 try {
1510 1068 $ticket = Ticket::findOrFail($ticket_id);
1511 -
1512 - if (in_array($ticket->mailbox_id, PermissionManager::getRestrictedMailboxIds())) {
1513 - throw new \Exception(esc_html__('Ticket cannot be fetched due to restricted mailbox', 'fluent-support'));
1514 - }
1515 -
1516 - // The caller must have access to this specific ticket (visibility +
1517 - // ownership + mailbox), not merely a global manage capability.
1518 - $this->ensureCanAccessTicket($ticket);
1519 -
1520 - $response = Conversation::where('id', $response_id)
1521 - ->where('ticket_id', $ticket_id)
1522 - ->with('person')
1523 - ->firstOrFail();
1069 + $response = Conversation::findOrFail($response_id);
1524 1070 $agent = Helper::getAgentByUserId();
1525 1071
1526 - // Only agent-authored conversation types may be edited here. Customer
1527 - // replies and system entries must not be rewritten via this endpoint.
1528 - $editableTypes = ['response', 'draft_response', 'note', 'internal_info'];
1529 - if (!in_array($response->conversation_type, $editableTypes, true)) {
1072 + if (!PermissionManager::currentUserCan('fst_manage_other_tickets') && $ticket->agent_id !== $agent->id) {
1530 1073 throw new \Exception(
1531 - esc_html__('This response type cannot be edited.', 'fluent-support')
1532 - );
1533 - }
1534 -
1535 - // Customer messages share the 'response' type but are authored by a
1536 - // customer person; they are never editable by an agent.
1537 - if ($response->person && $response->person->person_type !== 'agent') {
1538 - throw new \Exception(
1539 1074 esc_html__('Sorry, you do not have permission to update this response.', 'fluent-support')
1540 1075 );
1541 1076 }
1542 1077
1543 - $isDraft = $response->conversation_type == 'draft_response';
1544 - $isAuthor = (int) $response->person_id === (int) $agent->id;
1545 - $canApproveDraft = PermissionManager::currentUserCan('fst_approve_draft_reply');
1078 + $response->content = wp_unslash(wp_kses_post($request->getSafe('content', 'wp_kses_post')));
1546 1079
1547 - if ($isDraft && !$isAuthor) {
1548 - // Another agent's draft can only be edited/approved by an approver.
1549 - 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')) {
1550 1083 throw new \Exception(
1551 1084 esc_html__('Sorry, You do not have permission to approve this draft response', 'fluent-support')
1552 1085 );
1553 1086 }
1554 - } elseif (!$isAuthor && !PermissionManager::currentUserCan('fst_manage_other_tickets')) {
1555 - // Editing another agent's response requires manage-others capability.
1556 - throw new \Exception(
1557 - esc_html__('Sorry, you do not have permission to update this response.', 'fluent-support')
1558 - );
1087 + $response->conversation_type = 'response';
1559 1088 }
1560 1089
1561 - // Request input is already unslashed at the boundary; unslashing again
1562 - // would strip literal backslashes out of the edited reply.
1563 - $content = wp_kses_post($request->getSafe('content', 'wp_kses_post'));
1564 - $response->content = $content;
1090 + $response->save();
1565 1091
1566 - if ($isDraft && !$isAuthor && $canApproveDraft) {
1567 - $response = $this->approveDraftConversation($ticket, $response, $agent, $content);
1568 - } else {
1569 - $response->save();
1570 - }
1571 -
1572 1092 return [
1573 1093 'message' => __('Selected response has been updated', 'fluent-support'),
1574 1094 'response' => $response
1575 1095 ];
@@ -1590,10 +1110,8 @@
1590 1110 }
1591 1111
1592 1112 $ticket = Ticket::findOrFail($ticket_id);
1593 1113
1594 - $this->ensureCanAccessTicket($ticket);
1595 -
1596 1114 $response = Conversation::where('id', $response_id)
1597 1115 ->where('ticket_id', $ticket_id)
1598 1116 ->where('conversation_type', 'draft_response')
1599 1117 ->firstOrFail();
@@ -1599,15 +1117,34 @@
1599 1117 ->firstOrFail();
1600 1118
1601 1119 $person = Helper::getAgentByUserId();
1602 1120
1603 - $response = $this->approveDraftConversation(
1604 - $ticket,
1605 - $response,
1606 - $person,
1607 - wp_kses_post($request->getSafe('content', 'wp_kses_post'))
1608 - );
1121 + $content = wp_unslash(wp_kses_post($request->getSafe('content', 'wp_kses_post')));
1122 + $resetWaitingSince = apply_filters('fluent_support/reset_waiting_since', true, $content);
1609 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 +
1610 1147 return [
1611 1148 'message' => __('Draft response has been successfully approved.', 'fluent-support'),
1612 1149 'response' => $response,
1613 1150 ];
@@ -1617,39 +1154,8 @@
1617 1154 ]);
1618 1155 }
1619 1156 }
1620 1157
1621 - protected function approveDraftConversation($ticket, $response, $person, $content)
1622 - {
1623 - $resetWaitingSince = apply_filters('fluent_support/reset_waiting_since', true, $content);
1624 -
1625 - $response->content = $content;
1626 - $response->conversation_type = 'response';
1627 - $response->created_at = current_time('mysql');
1628 - $response->save();
1629 -
1630 - if ($person->person_type == 'agent' && $ticket->status == 'new') {
1631 - $ticket->status = 'active';
1632 - if ($ticket->created_at) {
1633 - $ticket->first_response_time = strtotime(current_time('mysql')) - strtotime($ticket->created_at);
1634 - } else {
1635 - $ticket->first_response_time = 300;
1636 - }
1637 - }
1638 -
1639 - if ($resetWaitingSince) {
1640 - $ticket->last_agent_response = current_time('mysql');
1641 - $ticket->waiting_since = current_time('mysql');
1642 - }
1643 -
1644 - $ticket->response_count += 1;
1645 - $ticket->save();
1646 -
1647 - do_action('fluent_support/response_added_by_' . $person->person_type, $response, $ticket, $person);
1648 -
1649 - return $response;
1650 - }
1651 -
1652 1158 /**
1653 1159 * getLiveActivity method will return the activity in a ticket by agents
1654 1160 * @param Request $request
1655 1161 * @param $ticket_id
@@ -1656,23 +1162,13 @@
1656 1162 * @return array
1657 1163 */
1658 1164 public function getLiveActivity(Request $request, $ticket_id)
1659 1165 {
1660 - try {
1661 - $ticket = Ticket::findOrFail($ticket_id);
1166 + $agent = Helper::getAgentByUserId();
1662 1167
1663 - $this->ensureCanAccessTicket($ticket);
1664 -
1665 - $agent = Helper::getAgentByUserId();
1666 -
1667 - return [
1668 - 'live_activity' => TicketHelper::getActivity($ticket_id, $agent->id)
1669 - ];
1670 - } catch (\Exception $e) {
1671 - return $this->sendError([
1672 - 'message' => Helper::getSafeErrorMessage($e)
1673 - ]);
1674 - }
1168 + return [
1169 + 'live_activity' => TicketHelper::getActivity($ticket_id, $agent->id)
1170 + ];
1675 1171 }
1676 1172
1677 1173 /**
1678 1174 * removeLiveActivity method will remove activities that
@@ -1681,24 +1177,14 @@
1681 1177 * @return array
1682 1178 */
1683 1179 public function removeLiveActivity(Request $request, $ticket_id)
1684 1180 {
1685 - try {
1686 - $ticket = Ticket::findOrFail($ticket_id);
1181 + $agent = Helper::getAgentByUserId();
1687 1182
1688 - $this->ensureCanAccessTicket($ticket);
1689 -
1690 - $agent = Helper::getAgentByUserId();
1691 -
1692 - return [
1693 - 'result' => TicketHelper::removeFromActivities($ticket_id, $agent->id),
1694 - 'agent_id' => $agent->id
1695 - ];
1696 - } catch (\Exception $e) {
1697 - return $this->sendError([
1698 - 'message' => Helper::getSafeErrorMessage($e)
1699 - ]);
1700 - }
1183 + return [
1184 + 'result' => TicketHelper::removeFromActivities($ticket_id, $agent->id),
1185 + 'agent_id' => $agent->id
1186 + ];
1701 1187 }
1702 1188
1703 1189 /**
1704 1190 * addTag method will add tag in ticket by ticket id
@@ -1709,11 +1195,8 @@
1709 1195 public function addTag(Request $request, $ticket_id)
1710 1196 {
1711 1197 try {
1712 1198 $ticket = Ticket::findOrFail($ticket_id);
1713 -
1714 - $this->ensureCanAccessTicket($ticket);
1715 -
1716 1199 $ticket->applyTags($request->getSafe('tag_id', 'intval'));
1717 1200
1718 1201 return [
1719 1202 'message' => __('Tag has been added to this ticket', 'fluent-support'),
@@ -1735,11 +1218,8 @@
1735 1218 public function detachTag($ticket_id, $tag_id)
1736 1219 {
1737 1220 try {
1738 1221 $ticket = Ticket::findOrFail($ticket_id);
1739 -
1740 - $this->ensureCanAccessTicket($ticket);
1741 -
1742 1222 $ticket->detachTags($tag_id);
1743 1223
1744 1224 return [
1745 1225 'message' => __('Tag has been removed from this ticket', 'fluent-support'),
@@ -1757,11 +1237,11 @@
1757 1237 * This method will get ticket id and customer id as parameter, it will replace existing customer id with new
1758 1238 * @param Request $request
1759 1239 * @return array
1760 1240 */
1761 - public function changeTicketCustomer(Request $request, $ticket_id)
1241 + public function changeTicketCustomer(Request $request)
1762 1242 {
1763 - $ticketId = (int) $ticket_id;
1243 + $ticketId = $request->getSafe('ticket_id', 'intval');
1764 1244 $newCustomerId = $request->getSafe('customer', 'intval');
1765 1245
1766 1246 if (!$newCustomerId) {
1767 1247 return $this->sendError(__('Invalid customer selected.', 'fluent-support'));
@@ -1766,37 +1246,17 @@
1766 1246 if (!$newCustomerId) {
1767 1247 return $this->sendError(__('Invalid customer selected.', 'fluent-support'));
1768 1248 }
1769 1249
1770 - // Rebinding a ticket to another customer exposes that customer's private
1771 - // data (profile, custom fields) through the ticket, so it requires the same
1772 - // sensitive-data capability that gates the customer routes.
1773 - if (!PermissionManager::currentUserCan('fst_sensitive_data')) {
1774 - return $this->sendError(__('You do not have permission to change the ticket customer.', 'fluent-support'));
1775 - }
1776 -
1777 1250 try {
1778 - $ticket = Ticket::findOrFail($ticketId);
1251 + $updated = Ticket::where('id', $ticketId)
1252 + ->where('customer_id', '!=', $newCustomerId)
1253 + ->update(['customer_id' => $newCustomerId]);
1779 1254
1780 - $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'));
1781 1258
1782 - $targetCustomer = Customer::where('id', $newCustomerId)
1783 - ->where('person_type', 'customer')
1784 - ->first();
1785 -
1786 - if (!$targetCustomer) {
1787 - return $this->sendError(__('Invalid customer selected.', 'fluent-support'));
1788 - }
1789 -
1790 - if ($ticket->customer_id == $newCustomerId) {
1791 - return $this->sendError(__('Customer already assigned to this ticket.', 'fluent-support'));
1792 - }
1793 -
1794 - $ticket->customer_id = $newCustomerId;
1795 - $ticket->save();
1796 -
1797 - return ['message' => __('Customer has been updated', 'fluent-support')];
1798 -
1799 1259 } catch (\Exception $e) {
1800 1260 return $this->sendError([
1801 1261 'message' => Helper::getSafeErrorMessage($e)
1802 1262 ]);
@@ -1817,22 +1277,14 @@
1817 1277 'rendered_fields' => []
1818 1278 ];
1819 1279 }
1820 1280
1821 - try {
1822 - $ticket = Ticket::findOrFail($ticket_id);
1281 + $ticket = Ticket::findOrFail($ticket_id);
1823 1282
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 - }
1283 + return [
1284 + 'custom_data' => (object)$ticket->customData(),
1285 + 'rendered_fields' => \FluentSupportPro\App\Services\CustomFieldsService::getRenderedPublicFields($ticket->customer, 'admin')
1286 + ];
1835 1287 }
1836 1288
1837 1289 /**
1838 1290 * syncFluentCrmTags method will synchronize the tags with Fluent CRM by contact id
@@ -1951,4 +1403,5 @@
1951 1403 ]);
1952 1404 }
1953 1405 }
1954 1406 }
1407 +