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 +503 -224 2.2.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];
@@ -337,9 +313,13 @@
337 313 $response,
338 314 $ticket
339 315 );
340 316
341 - $responseContent = links_add_target(make_clickable(wpautop($responseContent, false)));
317 + if ($response->conversation_type === 'note') {
318 + $responseContent = wpautop($responseContent, false);
319 + } else {
320 + $responseContent = links_add_target(make_clickable(wpautop($responseContent, false)));
321 + }
342 322
343 323
344 324 $response->content = apply_filters(
345 325 'fluent_support/response_content_after_render',
@@ -373,12 +353,10 @@
373 353 $ticketContent,
374 354 $ticket
375 355 );
376 356
377 - //Get last activity by agent
378 357 $ticket->live_activity = TicketHelper::getActivity($ticket->id, $agent->id);
379 358
380 - //Get all carbon copy customer
381 359 $ccInfo = $ticket->getSettingsValue('cc_email', []);
382 360 $ticket->carbon_copy = !empty($ccInfo) ? implode(', ', $ccInfo) : '';
383 361
384 362 if (defined('FLUENTSUPPORTPRO')) {
@@ -384,13 +362,13 @@
384 362 if (defined('FLUENTSUPPORTPRO')) {
385 363 $ticket->custom_fields = $ticket->customData('admin', true);
386 364 }
387 365
388 - // Load agent info if ticket was created on behalf of customer
366 + // Set when an agent opened the ticket on the customer's behalf.
389 367 if ($ticket->created_by) {
390 368 $ticket->load('created_by_person');
391 369 if ($ticket->created_by_person) {
392 - $isAgentInitiated = strpos($ticket->content, __(' initialized this ticket', 'fluent-support')) !== false;
370 + $isAgentInitiated = $ticket instanceof Ticket && $ticket->isAgentInitiated();
393 371 $ticket->created_by_agent = [
394 372 'id' => $ticket->created_by_person->id,
395 373 'full_name' => $ticket->created_by_person->full_name,
396 374 'agent_initiated' => $isAgentInitiated,
@@ -428,9 +406,9 @@
428 406 try {
429 407 $ticket = Ticket::findOrFail($ticket_id);
430 408
431 409 if (in_array($ticket->mailbox_id, PermissionManager::getRestrictedMailboxIds())) {
432 - 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);
433 411 }
434 412
435 413 $this->ensureCanAccessTicket($ticket);
436 414
@@ -502,11 +480,9 @@
502 480 return $restrictions;
503 481 }
504 482
505 483 /**
506 - * createResponse method will create response by agent for the ticket
507 - * @param Request $request
508 - * @param Ticket $ticket
484 + * @param TicketResponseRequest $request
509 485 * @param int $ticket_id
510 486 * @return array
511 487 * @throws \FluentSupport\Framework\Validator\ValidationException
512 488 */
@@ -517,13 +493,13 @@
517 493 try {
518 494 $convoType = Arr::get($data, 'conversation_type', 'response');
519 495 $isDraft = $convoType === 'draft_response';
520 496
521 - 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') {
522 499 $this->ensureCanManageTickets();
523 500 }
524 501
525 - //Get logged-in agent information
526 502 $agent = Helper::getAgentByUserId();
527 503
528 504 if (!$agent) {
529 505 return $this->sendError([
@@ -534,8 +510,20 @@
534 510 $ticket = Ticket::findOrFail($ticket_id);
535 511
536 512 $this->ensureCanAccessTicket($ticket);
537 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 +
538 526 $responseData = (new ResponseService())->createResponse($data, $agent, $ticket);
539 527
540 528 $responseData['response']->content = Helper::refreshSignedAttachmentUrls($responseData['response']->content, $ticket->id);
541 529 $responseData['response']->load([
@@ -542,9 +530,9 @@
542 530 'attachments' => function ($q) {
543 531 $q->where('status', 'active');
544 532 }
545 533 ]);
546 - $responseData['response']->content = wp_specialchars_decode(wpautop($responseData['response']->content, false));
534 + $responseData['response']->content = wpautop($responseData['response']->content, false);
547 535
548 536 return [
549 537 'message' => __('Response has been added', 'fluent-support'),
550 538 'response' => $responseData['response'],
@@ -560,9 +548,9 @@
560 548
561 549 public function getFluentBookingEventTypes()
562 550 {
563 551 try {
564 - // 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.
565 553 $this->ensureCanManageTickets();
566 554
567 555 $service = new FluentBookingService();
568 556 $eventTypes = $service->getEventTypes();
@@ -580,14 +568,12 @@
580 568
581 569 public function createFluentBookingLink(Request $request, $ticket_id)
582 570 {
583 571 try {
584 - // All FluentBooking endpoints require manage permission; view-only agents cannot call a meeting.
585 572 $this->ensureCanManageTickets();
586 573
587 574 $ticket = Ticket::with('customer')->findOrFail($ticket_id);
588 575
589 - // Enforces per-ticket visibility (e.g. own-tickets-only agents cannot access unassigned tickets).
590 576 $this->ensureCanAccessTicket($ticket);
591 577
592 578 $eventId = $request->getSafe('event_type_id', 'intval');
593 579
@@ -611,14 +597,12 @@
611 597
612 598 public function getFluentBookingAvailability(Request $request, $ticket_id)
613 599 {
614 600 try {
615 - // All FluentBooking endpoints require manage permission; view-only agents cannot call a meeting.
616 601 $this->ensureCanManageTickets();
617 602
618 603 $ticket = Ticket::with('customer')->findOrFail($ticket_id);
619 604
620 - // Enforces per-ticket visibility (e.g. own-tickets-only agents cannot access unassigned tickets).
621 605 $this->ensureCanAccessTicket($ticket);
622 606
623 607 $eventId = $request->getSafe('event_type_id', 'intval');
624 608
@@ -646,14 +630,12 @@
646 630
647 631 public function getFluentBookingMeetings($ticket_id)
648 632 {
649 633 try {
650 - // All FluentBooking endpoints require manage permission; view-only agents cannot call a meeting.
651 634 $this->ensureCanManageTickets();
652 635
653 636 $ticket = Ticket::with('customer')->findOrFail($ticket_id);
654 637
655 - // Enforces per-ticket visibility (e.g. own-tickets-only agents cannot access unassigned tickets).
656 638 $this->ensureCanAccessTicket($ticket);
657 639
658 640 return [
659 641 'meetings' => (new FluentBookingService())->getTicketMeetings($ticket)
@@ -665,11 +647,9 @@
665 647 }
666 648 }
667 649
668 650 /**
669 - * createDraft method will create draft by agent for the ticket
670 - * @param Request $request
671 - * @param Ticket $ticket
651 + * @param TicketResponseRequest $request
672 652 * @param int $ticket_id
673 653 * @return array
674 654 * @throws \FluentSupport\Framework\Validator\ValidationException
675 655 */
@@ -677,9 +657,8 @@
677 657 {
678 658 $data = $request->sanitize();
679 659
680 660 try {
681 - //Get logged-in agent information
682 661 $agent = Helper::getAgentByUserId();
683 662
684 663 if (!$agent) {
685 664 return $this->sendError([
@@ -725,9 +704,8 @@
725 704
726 705 public function getDraft($ticket_id)
727 706 {
728 707 try {
729 - //Get logged-in agent information
730 708 $agent = Helper::getAgentByUserId();
731 709
732 710 if (!$agent) {
733 711 return $this->sendError([
@@ -782,9 +760,14 @@
782 760 'message' => __('Draft not found', 'fluent-support'),
783 761 ]);
784 762 }
785 763
786 - // 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.
787 770 $isOwnDraft = strpos($draft->key, '_agent_id_' . $agent->id . '_') !== false;
788 771
789 772 if (!$isOwnDraft && !PermissionManager::canManageTickets()) {
790 773 return $this->sendError([
@@ -804,35 +787,50 @@
804 787 }
805 788 }
806 789
807 790 /**
808 - * getTicketWidgets method generate additional information for a ticket by customer
809 - * @param Ticket $ticket
810 - * @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
811 795 * @return array
812 796 */
813 - public function getTicketWidgets($ticket_id)
797 + public function getTicketWidgets(Request $request, $ticket_id)
814 798 {
815 799 try {
816 - //Get ticket with customer by ticket id
817 800 $ticket = Ticket::with('customer')->findOrFail($ticket_id);
818 801
819 802 $this->ensureCanAccessTicket($ticket);
820 803
821 - //Get last N tickets of this customer except this
822 - $limit = apply_filters('fluent_support/previous_ticket_widgets_limit', 10);
804 + $perPage = max(1, absint(apply_filters('fluent_support/previous_ticket_widgets_limit', 5)));
805 + $page = max(1, absint($request->get('page', 1)));
806 + $offset = ($page - 1) * $perPage;
823 807
824 - $otherTickets = Ticket::where('id', '!=', $ticket_id)
808 + $baseQuery = Ticket::where('id', '!=', $ticket_id)
809 + ->where('customer_id', $ticket->customer_id);
810 +
811 + (new AgentTicketAccess())->applyAccessScope($baseQuery);
812 +
813 + $total = $baseQuery->count();
814 +
815 + $otherTickets = (clone $baseQuery)
825 816 ->select(['id', 'title', 'status', 'created_at'])
826 - ->where('customer_id', $ticket->customer_id)
827 817 ->latest('id')
828 - ->limit($limit)
818 + ->limit($perPage)
819 + ->offset($offset)
829 820 ->get();
830 821
831 - return [
832 - 'other_tickets' => $otherTickets,
833 - 'extra_widgets' => ProfileInfoService::getProfileExtraWidgets($ticket->customer)
822 + $response = [
823 + 'other_tickets' => $otherTickets,
824 + 'other_tickets_total' => $total,
825 + 'other_tickets_more' => ($offset + $perPage) < $total,
834 826 ];
827 +
828 + if (in_array('extra_widgets', $request->get('with', []))) {
829 + $response['extra_widgets'] = ProfileInfoService::getProfileExtraWidgets($ticket->customer);
830 + }
831 +
832 + return $response;
835 833 } catch (\Exception $e) {
836 834 return $this->sendError([
837 835 'message' => Helper::getSafeErrorMessage($e)
838 836 ]);
@@ -839,12 +837,10 @@
839 837 }
840 838 }
841 839
842 840 /**
843 - * updateTicketProperty method will update ticket property
844 841 * @param Request $request
845 - * @param Ticket $ticket
846 - * @param $ticket_id
842 + * @param int $ticket_id
847 843 * @return array
848 844 */
849 845 public function updateTicketProperty(Request $request, $ticket_id)
850 846 {
@@ -855,30 +851,42 @@
855 851 $this->ensureCanAccessTicket($ticket);
856 852
857 853 $propName = $request->getSafe('prop_name', 'sanitize_text_field');
858 854 $propValue = $request->getSafe('prop_value', 'sanitize_text_field');
859 - $prevValue = $ticket->{$propName};
860 855
861 - //Validate agent assignment restrictions
862 - if ($propName === 'agent_id') {
863 - if (!PermissionManager::currentUserCan('fst_assign_agents')) {
864 - throw new \Exception(esc_html__('Permission denied to assign agent', 'fluent-support'), 403);
865 - }
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 + }
866 860
867 - $agent = Agent::findOrFail($propValue);
868 - $restrictions = $agent->getMeta('agent_restrictions', []);
861 + $propValue = $this->sanitizeTicketProperty($ticket, $propName, $propValue);
869 862
870 - if (!empty($restrictions['restrictedBusinessBoxes'])) {
871 - $mailboxId = (int) $ticket->mailbox_id;
872 - if (in_array($mailboxId, $restrictions['restrictedBusinessBoxes'], true)) {
873 - throw new \Exception(esc_html__('Agent is restricted for this mailbox ticket', 'fluent-support'), 403);
874 - }
875 - }
876 - }
863 + $prevValue = $ticket->{$propName};
877 864
878 - if ($propName && $propValue && $prevValue != $propValue) {
865 + if ($propName && $propValue !== null && $prevValue != $propValue) {
879 866 $ticket->{$propName} = $propValue;
880 867 $ticket->save();
868 +
869 + // Logged like the close/reopen flows do.
870 + if ($propName === 'status') {
871 + $statuses = Helper::ticketStatuses();
872 + $fromLabel = isset($statuses[$prevValue]) ? $statuses[$prevValue] : $prevValue;
873 + $toLabel = isset($statuses[$propValue]) ? $statuses[$propValue] : $propValue;
874 +
875 + $internalNote = sprintf(
876 + /* translators: 1: previous status, 2: new status */
877 + __('Ticket status changed from %1$s to %2$s', 'fluent-support'),
878 + esc_html($fromLabel),
879 + esc_html($toLabel)
880 + );
881 +
882 + Conversation::create([
883 + 'ticket_id' => $ticket->id,
884 + 'person_id' => $assigner->id,
885 + 'conversation_type' => 'internal_info',
886 + 'content' => $internalNote
887 + ]);
888 + }
881 889 }
882 890
883 891 $updateData = [];
884 892
@@ -912,10 +920,155 @@
912 920 }
913 921 }
914 922
915 923 /**
916 - * 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 + *
917 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
918 1071 * @param int $ticket_id
919 1072 * @return array
920 1073 */
921 1074 public function closeTicket(Request $request, $ticket_id)
@@ -939,11 +1092,9 @@
939 1092 }
940 1093 }
941 1094
942 1095 /**
943 - * reOpenTicket method will reopen a closed ticket
944 - * @param Request $request
945 - * @param $ticket_id
1096 + * @param int $ticket_id
946 1097 * @return array
947 1098 */
948 1099 public function reOpenTicket($ticket_id)
949 1100 {
@@ -964,12 +1115,9 @@
964 1115 }
965 1116 }
966 1117
967 1118 /**
968 - * doBulkActions method is responsible for bulk action
969 - * This function will get ticket ids and action as parameter and perform action based on the selection
970 1119 * @param Request $request
971 - * @param Ticket $ticket
972 1120 * @return array|string[]|void
973 1121 * @throws \Exception
974 1122 */
975 1123 public function doBulkActions(Request $request)
@@ -975,20 +1123,24 @@
975 1123 public function doBulkActions(Request $request)
976 1124 {
977 1125 try {
978 1126 $action = $request->getSafe('bulk_action', 'sanitize_text_field');
979 - $ticketIds = array_map('intval', $request->get('ticket_ids', null, []));
1127 + $ticketIds = array_map('intval', (array) $request->get('ticket_ids', null, []));
980 1128
981 - $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 +
982 1137 $agent = Helper::getAgentByUserId();
983 1138 $query = Ticket::whereIn('id', $ticketIds);
984 1139
985 - //If agent do not have permission to manage other tickets
986 - if (!$hasAllPermission) {
987 - $query->where('agent_id', $agent->id);
988 - }
1140 + // Same access rules as ensureCanAccessTicket() on single-ticket routes.
1141 + (new AgentTicketAccess())->applyAccessScope($query, $agent);
989 1142
990 - //If bulk action is close tickets
991 1143 if ($action == 'close_tickets') {
992 1144 $tickets = $query->get();
993 1145 $tickets->each(function ($ticket) use ($agent) {
994 1146 (new TicketService())->close($ticket, $agent);
@@ -1031,14 +1183,14 @@
1031 1183 $tickets = $query->get();
1032 1184 $assignedCount = 0;
1033 1185 $skippedCount = 0;
1034 1186
1035 - $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) {
1036 1190 $previousAgentId = (int) $ticket->agent_id;
1037 - $restrictions = $assignAgent->getMeta('agent_restrictions', []);
1038 1191
1039 - //Skip ticket if mailbox is restricted for the agent
1040 - if (!empty($restrictions) && in_array($ticket->mailbox_id, $restrictions['restrictedBusinessBoxes'])) {
1192 + if (!empty($ticket->mailbox_id) && in_array((int) $ticket->mailbox_id, $restrictedBoxes, true)) {
1041 1193 $skippedCount++;
1042 1194 return;
1043 1195 }
1044 1196
@@ -1097,9 +1249,9 @@
1097 1249
1098 1250 $ticket->agent_id = $selectedAgent->id;
1099 1251 $ticket->save();
1100 1252 $assignedCount++;
1101 - $currentCounts[$selectedAgent->id]++;
1253 + $currentCounts[$selectedAgent->id] = ($currentCounts[$selectedAgent->id] ?? 0) + 1;
1102 1254
1103 1255 as_enqueue_async_action('fluent_support/async_agent_assigned_to_ticket', [
1104 1256 $selectedAgent->id, $ticket->id, $agent->id, $previousAgentId
1105 1257 ], 'fluent-support');
@@ -1126,8 +1278,67 @@
1126 1278
1127 1279 return [
1128 1280 'message' => __('Selected tags has been added to tickets', 'fluent-support')
1129 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 + ];
1130 1341 }
1131 1342
1132 1343 throw new \Exception(esc_html__('Sorry no action found as available', 'fluent-support'));
1133 1344 } catch (\Exception $e) {
@@ -1137,9 +1348,34 @@
1137 1348 }
1138 1349 }
1139 1350
1140 1351 /**
1141 - * 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 + /**
1142 1378 * @param int $ticket_id
1143 1379 * @return array
1144 1380 */
1145 1381 public function deleteTicket($ticket_id)
@@ -1146,8 +1382,10 @@
1146 1382 {
1147 1383 try {
1148 1384 $ticket = Ticket::findOrFail($ticket_id);
1149 1385
1386 + $this->ensureCanAccessTicket($ticket);
1387 +
1150 1388 (new TicketService())->deleteTicket($ticket);
1151 1389
1152 1390 return [
1153 1391 'message' => __('Ticket has been deleted successfully', 'fluent-support')
@@ -1159,12 +1397,9 @@
1159 1397 }
1160 1398 }
1161 1399
1162 1400 /**
1163 - * doBulkReplies method will create response for bulk tickets
1164 - * This function will get ticket ids, content, attachment etc and create response for tickets
1165 1401 * @param Request $request
1166 - * @param Conversation $conversation
1167 1402 * @return array
1168 1403 * @throws \Exception
1169 1404 */
1170 1405 public function doBulkReplies(Request $request)
@@ -1169,9 +1404,8 @@
1169 1404 */
1170 1405 public function doBulkReplies(Request $request)
1171 1406 {
1172 1407 try {
1173 - // Sanitize all request data before validation
1174 1408 $requestData = $request->all();
1175 1409 $data = [];
1176 1410 foreach ($requestData as $key => $value) {
1177 1411 if (is_array($value)) {
@@ -1191,19 +1425,14 @@
1191 1425 'content' => 'required',
1192 1426 'ticket_ids' => 'required|array'
1193 1427 ]);
1194 1428
1195 - //Get logged in agent information
1196 1429 $agent = Helper::getAgentByUserId();
1197 1430 $ticketIds = array_filter($data['ticket_ids'], 'absint');
1198 1431
1199 - $hasAllPermission = PermissionManager::currentUserCan('fst_manage_other_tickets');
1200 1432 $query = Ticket::whereIn('id', $ticketIds)->where('status', '!=', 'closed');
1201 1433
1202 - //If the agent does not have permission
1203 - if (!$hasAllPermission) {
1204 - $query->where('agent_id', $agent->id);
1205 - }
1434 + (new AgentTicketAccess())->applyAccessScope($query, $agent);
1206 1435
1207 1436 $tickets = $query->get();
1208 1437
1209 1438 if ($tickets->isEmpty()) {
@@ -1215,9 +1444,8 @@
1215 1444 'conversation_type' => 'response',
1216 1445 'close_ticket' => Arr::get($data, 'close_ticket'),
1217 1446 ];
1218 1447
1219 - //If request with file attachments
1220 1448 $attachmentHashes = Arr::get($data, 'attachments', []);
1221 1449 $attachments = false;
1222 1450 if ($attachmentHashes) {
1223 1451 $attachments = Attachment::whereNull('ticket_id')
@@ -1264,13 +1492,10 @@
1264 1492 }
1265 1493 }
1266 1494
1267 1495 /**
1268 - * deleteResponse method will remove a response from ticket by ticket id and response id
1269 - * @param Request $request
1270 - * @param Conversation $conversation
1271 - * @param $ticket_id
1272 - * @param $response_id
1496 + * @param int $ticket_id
1497 + * @param int $response_id
1273 1498 * @return array
1274 1499 */
1275 1500 public function deleteResponse($ticket_id, $response_id)
1276 1501 {
@@ -1275,19 +1500,26 @@
1275 1500 public function deleteResponse($ticket_id, $response_id)
1276 1501 {
1277 1502 try {
1278 1503 $ticket = Ticket::findOrFail($ticket_id);
1279 - $response = Conversation::where('id', $response_id)
1280 - ->where('ticket_id', $ticket_id)
1281 - ->firstOrFail();
1282 - $agent = Helper::getAgentByUserId();
1283 1504
1284 - 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')) {
1285 1513 throw new \Exception(
1286 1514 esc_html__('Sorry, you do not have permission to delete this response.', 'fluent-support')
1287 1515 );
1288 1516 }
1289 1517
1518 + $response = Conversation::where('id', $response_id)
1519 + ->where('ticket_id', $ticket_id)
1520 + ->firstOrFail();
1521 +
1290 1522 $response->delete();
1291 1523 $response->ccinfo()->delete();
1292 1524
1293 1525 return [
@@ -1300,10 +1532,9 @@
1300 1532 }
1301 1533 }
1302 1534
1303 1535 /**
1304 - * updateResponse method will update ticket response using ticket and response id
1305 - * @param Request $request
1536 + * @param TicketResponseRequest $request
1306 1537 * @param int $ticket_id
1307 1538 * @param int $response_id
1308 1539 * @return array
1309 1540 * @throws \Exception
@@ -1311,30 +1542,57 @@
1311 1542 public function updateResponse(TicketResponseRequest $request, $ticket_id, $response_id)
1312 1543 {
1313 1544 try {
1314 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 +
1315 1553 $response = Conversation::where('id', $response_id)
1316 1554 ->where('ticket_id', $ticket_id)
1555 + ->with('person')
1317 1556 ->firstOrFail();
1318 1557 $agent = Helper::getAgentByUserId();
1319 1558
1320 - 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)) {
1321 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(
1322 1569 esc_html__('Sorry, you do not have permission to update this response.', 'fluent-support')
1323 1570 );
1324 1571 }
1325 1572
1326 - $content = wp_unslash(wp_kses_post($request->getSafe('content', 'wp_kses_post')));
1327 - $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');
1328 1576
1329 - if ($response->conversation_type == 'draft_response' && $response->person_id != $agent->id && PermissionManager::currentUserCan('fst_approve_draft_reply')) {
1330 - $response = $this->approveDraftConversation($ticket, $response, $agent, $content);
1331 - } else if ($response->conversation_type == 'draft_response' && $response->person_id != $agent->id) {
1332 - if (!PermissionManager::currentUserCan('fst_approve_draft_reply')) {
1577 + if ($isDraft && !$isAuthor) {
1578 + if (!$canApproveDraft) {
1333 1579 throw new \Exception(
1334 1580 esc_html__('Sorry, You do not have permission to approve this draft response', 'fluent-support')
1335 1581 );
1336 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);
1337 1595 } else {
1338 1596 $response->save();
1339 1597 }
1340 1598
@@ -1359,8 +1617,10 @@
1359 1617 }
1360 1618
1361 1619 $ticket = Ticket::findOrFail($ticket_id);
1362 1620
1621 + $this->ensureCanAccessTicket($ticket);
1622 +
1363 1623 $response = Conversation::where('id', $response_id)
1364 1624 ->where('ticket_id', $ticket_id)
1365 1625 ->where('conversation_type', 'draft_response')
1366 1626 ->firstOrFail();
@@ -1370,9 +1630,9 @@
1370 1630 $response = $this->approveDraftConversation(
1371 1631 $ticket,
1372 1632 $response,
1373 1633 $person,
1374 - wp_unslash(wp_kses_post($request->getSafe('content', 'wp_kses_post')))
1634 + wp_kses_post($request->getSafe('content', 'wp_kses_post'))
1375 1635 );
1376 1636
1377 1637 return [
1378 1638 'message' => __('Draft response has been successfully approved.', 'fluent-support'),
@@ -1386,72 +1646,67 @@
1386 1646 }
1387 1647
1388 1648 protected function approveDraftConversation($ticket, $response, $person, $content)
1389 1649 {
1390 - $resetWaitingSince = apply_filters('fluent_support/reset_waiting_since', true, $content);
1391 -
1392 - $response->content = $content;
1393 - $response->conversation_type = 'response';
1394 - $response->created_at = current_time('mysql');
1395 - $response->save();
1396 -
1397 - if ($person->person_type == 'agent' && $ticket->status == 'new') {
1398 - $ticket->status = 'active';
1399 - if ($ticket->created_at) {
1400 - $ticket->first_response_time = strtotime(current_time('mysql')) - strtotime($ticket->created_at);
1401 - } else {
1402 - $ticket->first_response_time = 300;
1403 - }
1404 - }
1405 -
1406 - if ($resetWaitingSince) {
1407 - $ticket->last_agent_response = current_time('mysql');
1408 - $ticket->waiting_since = current_time('mysql');
1409 - }
1410 -
1411 - $ticket->response_count += 1;
1412 - $ticket->save();
1413 -
1414 - do_action('fluent_support/response_added_by_' . $person->person_type, $response, $ticket, $person);
1415 -
1416 - return $response;
1650 + return (new ResponseService())->publishDraft($ticket, $response, $person, $content);
1417 1651 }
1418 1652
1419 1653 /**
1420 - * getLiveActivity method will return the activity in a ticket by agents
1654 + * Agents currently viewing the ticket.
1655 + *
1421 1656 * @param Request $request
1422 - * @param $ticket_id
1657 + * @param int $ticket_id
1423 1658 * @return array
1424 1659 */
1425 1660 public function getLiveActivity(Request $request, $ticket_id)
1426 1661 {
1427 - $agent = Helper::getAgentByUserId();
1662 + try {
1663 + $ticket = Ticket::findOrFail($ticket_id);
1428 1664
1429 - return [
1430 - 'live_activity' => TicketHelper::getActivity($ticket_id, $agent->id)
1431 - ];
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 + }
1432 1677 }
1433 1678
1434 1679 /**
1435 - * removeLiveActivity method will remove activities that
1680 + * Remove the current agent from the ticket's live activity.
1681 + *
1436 1682 * @param Request $request
1437 - * @param $ticket_id
1683 + * @param int $ticket_id
1438 1684 * @return array
1439 1685 */
1440 1686 public function removeLiveActivity(Request $request, $ticket_id)
1441 1687 {
1442 - $agent = Helper::getAgentByUserId();
1688 + try {
1689 + $ticket = Ticket::findOrFail($ticket_id);
1443 1690
1444 - return [
1445 - 'result' => TicketHelper::removeFromActivities($ticket_id, $agent->id),
1446 - 'agent_id' => $agent->id
1447 - ];
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 + }
1448 1704 }
1449 1705
1450 1706 /**
1451 - * addTag method will add tag in ticket by ticket id
1452 1707 * @param Request $request
1453 - * @param $ticket_id
1708 + * @param int $ticket_id
1454 1709 * @return array
1455 1710 */
1456 1711 public function addTag(Request $request, $ticket_id)
1457 1712 {
@@ -1456,8 +1711,11 @@
1456 1711 public function addTag(Request $request, $ticket_id)
1457 1712 {
1458 1713 try {
1459 1714 $ticket = Ticket::findOrFail($ticket_id);
1715 +
1716 + $this->ensureCanAccessTicket($ticket);
1717 +
1460 1718 $ticket->applyTags($request->getSafe('tag_id', 'intval'));
1461 1719
1462 1720 return [
1463 1721 'message' => __('Tag has been added to this ticket', 'fluent-support'),
@@ -1470,11 +1728,12 @@
1470 1728 }
1471 1729 }
1472 1730
1473 1731 /**
1474 - * detachTag method will remove all tags from tickets
1475 - * @param $ticket_id
1476 - * @param $tag_id
1732 + * Remove one tag from the ticket.
1733 + *
1734 + * @param int $ticket_id
1735 + * @param int $tag_id
1477 1736 * @return array
1478 1737 */
1479 1738 public function detachTag($ticket_id, $tag_id)
1480 1739 {
@@ -1479,8 +1738,11 @@
1479 1738 public function detachTag($ticket_id, $tag_id)
1480 1739 {
1481 1740 try {
1482 1741 $ticket = Ticket::findOrFail($ticket_id);
1742 +
1743 + $this->ensureCanAccessTicket($ticket);
1744 +
1483 1745 $ticket->detachTags($tag_id);
1484 1746
1485 1747 return [
1486 1748 'message' => __('Tag has been removed from this ticket', 'fluent-support'),
@@ -1493,16 +1755,15 @@
1493 1755 }
1494 1756 }
1495 1757
1496 1758 /**
1497 - * changeTicketCustomer method will update customer in a ticket
1498 - * This method will get ticket id and customer id as parameter, it will replace existing customer id with new
1499 1759 * @param Request $request
1760 + * @param int $ticket_id
1500 1761 * @return array
1501 1762 */
1502 - public function changeTicketCustomer(Request $request)
1763 + public function changeTicketCustomer(Request $request, $ticket_id)
1503 1764 {
1504 - $ticketId = $request->getSafe('ticket_id', 'intval');
1765 + $ticketId = (int) $ticket_id;
1505 1766 $newCustomerId = $request->getSafe('customer', 'intval');
1506 1767
1507 1768 if (!$newCustomerId) {
1508 1769 return $this->sendError(__('Invalid customer selected.', 'fluent-support'));
@@ -1507,17 +1768,36 @@
1507 1768 if (!$newCustomerId) {
1508 1769 return $this->sendError(__('Invalid customer selected.', 'fluent-support'));
1509 1770 }
1510 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 +
1511 1778 try {
1512 - $updated = Ticket::where('id', $ticketId)
1513 - ->where('customer_id', '!=', $newCustomerId)
1514 - ->update(['customer_id' => $newCustomerId]);
1779 + $ticket = Ticket::findOrFail($ticketId);
1515 1780
1516 - return $updated
1517 - ? ['message' => __('Customer has been updated', 'fluent-support')]
1518 - : $this->sendError(__('Ticket not found or customer already assigned.', 'fluent-support'));
1781 + $this->ensureCanAccessTicket($ticket);
1519 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 +
1520 1800 } catch (\Exception $e) {
1521 1801 return $this->sendError([
1522 1802 'message' => Helper::getSafeErrorMessage($e)
1523 1803 ]);
@@ -1524,11 +1804,10 @@
1524 1804 }
1525 1805 }
1526 1806
1527 1807 /**
1528 - * getTicketCustomData method will return the custom data by ticket id
1529 1808 * @param Request $request
1530 - * @param $ticket_id
1809 + * @param int $ticket_id
1531 1810 * @return array|array[]
1532 1811 */
1533 1812 public function getTicketCustomData(Request $request, $ticket_id)
1534 1813 {
@@ -1538,19 +1817,25 @@
1538 1817 'rendered_fields' => []
1539 1818 ];
1540 1819 }
1541 1820
1542 - $ticket = Ticket::findOrFail($ticket_id);
1821 + try {
1822 + $ticket = Ticket::findOrFail($ticket_id);
1543 1823
1544 - return [
1545 - 'custom_data' => (object)$ticket->customData(),
1546 - 'rendered_fields' => \FluentSupportPro\App\Services\CustomFieldsService::getRenderedPublicFields($ticket->customer, 'admin')
1547 - ];
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 + }
1548 1835 }
1549 1836
1550 1837 /**
1551 - * syncFluentCrmTags method will synchronize the tags with Fluent CRM by contact id
1552 - *This function will get contact id and tags as parameter, get existing tags from crm and updated added/removed tags
1553 1838 * @param Request $request
1554 1839 * @param FluentCRMServices $fluentCRMServices
1555 1840 * @return array
1556 1841 */
@@ -1560,9 +1845,8 @@
1560 1845 'contact_id' => $request->getSafe('contact_id', 'intval'),
1561 1846 'tags' => $request->get('tags', null)
1562 1847 ];
1563 1848
1564 - // Sanitize tags array if it's an array
1565 1849 if (is_array($data['tags'])) {
1566 1850 $data['tags'] = array_map('intval', $data['tags']);
1567 1851 }
1568 1852
@@ -1575,10 +1859,8 @@
1575 1859 }
1576 1860 }
1577 1861
1578 1862 /**
1579 - * This `syncFluentCrmLists` method will synchronize the lists with Fluent CRM by contact id
1580 - * This method will get contact id and lists as parameter, get existing lists from crm and updated added/removed lists
1581 1863 * @param Request $request
1582 1864 * @param FluentCRMServices $fluentCRMServices
1583 1865 * @return array
1584 1866 */
@@ -1589,9 +1871,8 @@
1589 1871 'contact_id' => $request->getSafe('contact_id', 'intval'),
1590 1872 'lists' => $request->get('lists', null, [])
1591 1873 ];
1592 1874
1593 - // Sanitize lists array if it's an array
1594 1875 if (is_array($data['lists'])) {
1595 1876 $data['lists'] = array_map('intval', $data['lists']);
1596 1877 }
1597 1878
@@ -1604,12 +1885,10 @@
1604 1885 }
1605 1886 }
1606 1887
1607 1888 /**
1608 - * Get ticket essentials data based on the provided types.
1609 - *
1610 - * @param \Illuminate\Http\Request $request
1611 - * @return array The ticket essentials data.
1889 + * @param Request $request
1890 + * @return array
1612 1891 */
1613 1892 public function getTicketEssentials(Request $request)
1614 1893 {
1615 1894 $type = $request->getSafe('type', 'sanitize_text_field');