PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / trunk
Fluent Support – Helpdesk & Customer Support Ticket System vtrunk
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
fluent-support / app / Http / Controllers / TicketController.php

TicketController.php in Fluent Support – Helpdesk & Customer Support Ticket System trunk, at app/Http/Controllers/TicketController.php

1,955 lines 73.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentSupport\App\Http\Controllers;
4
5 use FluentSupport\App\Models\Agent;
6 use FluentSupport\App\Models\Attachment;
7 use FluentSupport\App\Models\Meta;
8 use FluentSupport\App\Models\Customer;
9 use FluentSupport\Framework\Http\Request\Request;
10 use FluentSupport\Framework\Support\Arr;
11 use FluentSupport\App\Http\Requests\TicketRequest;
12 use FluentSupport\App\Http\Requests\TicketResponseRequest;
13 use FluentSupport\App\Models\Conversation;
14 use FluentSupport\App\Models\MailBox;
15 use FluentSupport\App\Models\Product;
16 use FluentSupport\App\Models\Ticket;
17 use FluentSupport\App\Services\FluentCRMServices;
18 use FluentSupport\App\Services\Helper;
19 use FluentSupport\App\Services\ProfileInfoService;
20 use FluentSupport\App\Services\TicketHelper;
21 use FluentSupport\App\Services\TicketQueryService;
22 use FluentSupport\App\Modules\PermissionManager;
23 use FluentSupport\App\Services\Tickets\AgentTicketAccess;
24 use FluentSupport\App\Services\Tickets\ResponseService;
25 use FluentSupport\App\Models\AgentGroup;
26 use FluentSupport\App\Services\Tickets\TicketService;
27 use FluentSupport\App\Services\Integrations\FluentBooking\FluentBookingService;
28
29 /**
30 * TicketController class for REST API related to ticket
31 * This class is responsible for getting / inserting/ modifying data for all request related to ticket
32 * @package FluentSupport\App\Http\Controllers
33 *
34 * @version 1.0.0
35 */
36 class TicketController extends Controller
37 {
38 /**
39 * This `me` method will return the current user profile info
40 * @param Request $request
41 * @return array
42 */
43 public function me(Request $request)
44 {
45 $user = wp_get_current_user();
46 $requestData = $request->all();
47 $sanitizedRequest = [];
48 foreach ($requestData as $key => $value) {
49 if (is_array($value)) {
50 $sanitizedRequest[$key] = map_deep($value, 'sanitize_text_field');
51 } else {
52 $sanitizedRequest[$key] = sanitize_text_field($value);
53 }
54 }
55
56 $settings = [
57 'user_id' => $user->ID,
58 'email' => $user->user_email,
59 'person' => Helper::getAgentByUserId($user->ID),
60 'permissions' => PermissionManager::currentUserPermissions(),
61 'request' => $sanitizedRequest
62 ];
63
64 if ($request->getSafe('with_portal_settings', 'sanitize_text_field')) {
65 $mimeHeadings = Helper::getAcceptedMimeHeadings();
66 $businessSettings = (new \FluentSupport\App\Services\EmailNotification\Settings())->globalBusinessSettings();
67 $maxFileSize = absint($businessSettings['max_file_size']);
68
69 $portalSettings = [
70 'support_products' => \FluentSupport\App\Models\Product::select(['id', 'title'])->orderedByTitle()->get(),
71 'customer_ticket_priorities' => Helper::customerTicketPriorities(),
72 'has_file_upload' => !!Helper::ticketAcceptedFileMiles(),
73 'has_rich_text_editor' => true,
74 'max_file_size' => $maxFileSize,
75 'mime_headings' => $mimeHeadings
76 ];
77
78 $portalSettings = apply_filters('fluent_support/customer_portal_vars', $portalSettings);
79 $settings['portal_settings'] = $portalSettings;
80 }
81
82 return $settings;
83 }
84
85 /**
86 * index method will return the list of ticket based on the selected filter
87 * @param Request $request
88 * @return array
89 */
90 public function index(Request $request)
91 {
92 //Selected filter type, either simple or Advanced
93 $filterType = $request->getSafe('filter_type', 'sanitize_text_field', 'simple');
94
95 /*Prepare Query Arguments*/
96 $queryArgs = [
97 'with' => [],
98 'filter_type' => $filterType,
99 'sort_by' => sanitize_sql_orderby($request->getSafe('order_by', 'sanitize_text_field', 'id')),
100 'sort_type' => $request->getSafe('order_type', 'sanitize_text_field', 'DESC') == 'DESC' ? 'DESC' : 'ASC',
101 ];
102
103 //If the selected filter type is advanced
104 if ($filterType == 'advanced') {
105 $advanced_filters = map_deep($request->get('advanced_filters', []), 'sanitize_text_field');
106 //Get the selected query params for advanced filter
107 $queryArgs['filters_groups_raw'] = json_decode($advanced_filters, true);
108 } else {
109 //Selected filter type is simple
110 $queryArgs['simple_filters'] = map_deep($request->get('filters', []), 'sanitize_text_field');
111 $queryArgs['search'] = trim($request->getSafe('search', 'sanitize_text_field', ''));
112
113 if ($customerId = $request->getSafe('customer_id', 'intval')) {
114 $queryArgs['customer_id'] = $customerId;
115 }
116 }
117 /*End Prepare Query Arguments*/
118
119 $ticketsModel = (new TicketQueryService($queryArgs))->getModel();
120
121 $ticketsModel = $ticketsModel->with([
122 'customer' => function ($query) {
123 $query->select(['first_name', 'last_name', 'email', 'id', 'avatar']);
124 }, 'agent' => function ($query) {
125 $query->select(['first_name', 'last_name', 'email', 'avatar', 'id']);
126 },
127 'mailbox',
128 'product',
129 'tags',
130 'preview_response' => function ($query) {
131 $query->latest('id');
132 }
133 ]);
134
135 // apply filters by access level
136 do_action_ref_array('fluent_support/tickets_query_by_permission_ref', [&$ticketsModel, false]);
137
138 $tickets = $ticketsModel->paginate();
139
140 $perPage = $request->getSafe('per_page', 'intval', 15);
141
142 // Load live activity for small page sizes (board/kanban view)
143 if ($perPage < 15) {
144 TicketHelper::loadBatchLiveActivities($tickets);
145 }
146
147 return [
148 'tickets' => $tickets
149 ];
150 }
151
152 /**
153 * createTicket method will create new ticket as well as customer or WP user
154 * @param TicketRequest $request
155 * @return array
156 */
157 public function createTicket(TicketRequest $request)
158 {
159 try {
160 //Sanitize and validate request data via TicketRequest
161 $data = $request->sanitize();
162 $ticketData = $data['ticket'];
163 $maybeNewCustomer = Arr::get($data, 'newCustomer', []);
164
165 //Include attachments if provided
166 if (!empty($data['attachments'])) {
167 $ticketData['attachments'] = $data['attachments'];
168 }
169
170 /*
171 * If customer_id is not provided, attempt to create a new customer
172 * This handles WP user creation and customer creation
173 */
174 if (empty($ticketData['customer_id'])) {
175 $createdUserId = false;
176
177 //If user selected create WP user during ticket creation
178 if (Arr::get($ticketData, 'create_wp_user') == 'yes' && !empty($maybeNewCustomer['username'])) {
179 //Check if username already in use, if not create new user
180 if (!username_exists($maybeNewCustomer['username'])) {
181 $authController = new AuthController();
182 $createdUserId = $authController->createUser($maybeNewCustomer);
183 $authController->maybeUpdateUser($createdUserId, $maybeNewCustomer);
184 }
185 }
186
187 $email = Arr::get($maybeNewCustomer, 'email');
188 if (!$email || !is_email($email)) {
189 return $this->sendError([
190 'message' => __('A valid email is required to create a ticket', 'fluent-support')
191 ]);
192 }
193
194 //Check if customer already exists by email
195 $existingCustomer = Customer::where('email', $email)->first();
196
197 if ($existingCustomer) {
198 $ticketData['customer_id'] = $existingCustomer->id;
199 } else {
200 //Create the customer now
201 $customerData = Arr::only($maybeNewCustomer, (new Customer())->getFillable());
202 $customerData['user_id'] = $createdUserId;
203 $customerData = array_filter($customerData);
204
205 $createCustomer = Customer::create($customerData);
206
207 do_action('fluent_support/customer_created', $createCustomer);
208
209 if (!$createCustomer) {
210 return $this->sendError([
211 'message' => __('Customer could not be created', 'fluent-support')
212 ]);
213 }
214
215 $ticketData['customer_id'] = $createCustomer->id;
216 }
217 }
218
219 //Get customer information from db
220 $customer = Customer::findOrFail($ticketData['customer_id']);
221
222 //Sanitize, store ticket, handle attachments, fire hooks
223 $createdTicket = (new TicketService())->storeTicket($ticketData, $customer);
224
225 return [
226 'message' => __('Ticket has been created successfully', 'fluent-support'),
227 'ticket' => $createdTicket
228 ];
229 } catch (\Exception $e) {
230 return $this->sendError([
231 'message' => Helper::getSafeErrorMessage($e)
232 ]);
233 }
234 }
235
236 /**
237 * getTicket method will return ticket information by ticket id
238 * @param Request $request
239 * @param $ticket_id
240 * @return array
241 */
242 public function getTicket(Request $request, $ticket_id)
243 {
244 try {
245 //Get logged in agent information
246 $agent = Helper::getAgentByUserId();
247
248 $ticketWith = $request->get('with');
249 $ticketWith = is_array($ticketWith) ? map_deep($ticketWith, 'sanitize_text_field') : null;
250
251 if (!$ticketWith) {
252 $ticketWith = ['customer', 'agent', 'product', 'mailbox', 'tags', 'attachments' => function ($q) {
253 $q->where('status', 'active');
254 }];
255 }
256
257 //Get ticket by id
258 $ticket = Ticket::with($ticketWith)->findOrFail($ticket_id);
259
260 //Eager load responses with their nested relations to avoid N+1 queries
261 $ticket->load(['responses' => function ($q) {
262 $q->with([
263 'person',
264 'ccinfo',
265 'attachments' => function ($q) {
266 $q->where('status', 'active');
267 }
268 ]);
269 }]);
270
271 //Check if ticket is in a restricted mailbox
272 $restrictedBusinessBoxes = PermissionManager::getRestrictedMailboxIds();
273
274 if (in_array($ticket->mailbox_id, $restrictedBusinessBoxes)) {
275 throw new \Exception(esc_html__('Ticket cannot be fetched due to restricted mailbox', 'fluent-support'));
276 }
277
278 $this->ensureCanAccessTicket($ticket);
279
280 //If ticket has customer, set custom fields and profile url
281 if ($ticket->customer) {
282 $customFieldsKey = apply_filters('fluent_support/custom_registration_form_fields_key', Helper::getBusinessSettings('custom_registration_form_field'));
283 $ticket->customer->custom_field_keys = $customFieldsKey;
284
285 if ($ticket->customer->user_id) {
286 $customFieldKeysUsingHook = apply_filters('fluent_support/custom_registration_form_fields_key', []);
287 if (!empty($customFieldKeysUsingHook)) {
288 $allUserMeta = get_user_meta($ticket->customer->user_id);
289 foreach ($customFieldKeysUsingHook as $key) {
290 if (isset($allUserMeta[$key][0]) && $allUserMeta[$key][0]) {
291 $ticket->customer->$key = $allUserMeta[$key][0];
292 }
293 }
294 }
295 }
296
297 $ticket->customer->profile_edit_url = $ticket->customer->getUserProfileEditUrl();
298 }
299
300 //If ticket is closed, load closed by person
301 if ($ticket->status == 'closed') {
302 $ticket->load('closed_by_person');
303 }
304
305 //Load agent feedback ratings if pro is active and feature is enabled
306 if (defined('FLUENTSUPPORTPRO_PLUGIN_VERSION') && Helper::isAgentFeedbackEnabled()) {
307 $responseIds = $ticket->responses->pluck('id')->toArray();
308 $feedbacks = Meta::where('object_type', 'conversation_meta')
309 ->where('key', 'agent_feedback_ratings')
310 ->whereIn('object_id', $responseIds)
311 ->get()
312 ->keyBy('object_id');
313
314 foreach ($ticket->responses as $response) {
315 if ($feedbacks->has($response->id)) {
316 $response->agent_feedback = $feedbacks->get($response->id)->value;
317 }
318 }
319 }
320
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 //Format response content
330 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
357 if (!empty($response->ccinfo)) {
358 $val = Helper::safeUnserialize($response->ccinfo->value);
359 if (isset($val['cc_email']) && !empty($val['cc_email'])) {
360 $response->cc_info = $val['cc_email'];
361 } else {
362 $response->cc_info = '';
363 }
364 } else {
365 $response->cc_info = '';
366 }
367 }
368
369 $ticketContent = apply_filters(
370 'fluent_support/ticket_content_before_render',
371 $ticket->content,
372 $ticket
373 );
374
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 //Get last activity by agent
384 $ticket->live_activity = TicketHelper::getActivity($ticket->id, $agent->id);
385
386 //Get all carbon copy customer
387 $ccInfo = $ticket->getSettingsValue('cc_email', []);
388 $ticket->carbon_copy = !empty($ccInfo) ? implode(', ', $ccInfo) : '';
389
390 if (defined('FLUENTSUPPORTPRO')) {
391 $ticket->custom_fields = $ticket->customData('admin', true);
392 }
393
394 // Load agent info if ticket was created on behalf of customer
395 if ($ticket->created_by) {
396 $ticket->load('created_by_person');
397 if ($ticket->created_by_person) {
398 $isAgentInitiated = strpos($ticket->content, __(' initialized this ticket', 'fluent-support')) !== false;
399 $ticket->created_by_agent = [
400 'id' => $ticket->created_by_person->id,
401 'full_name' => $ticket->created_by_person->full_name,
402 'agent_initiated' => $isAgentInitiated,
403 ];
404 }
405 }
406
407 $data = [
408 'ticket' => $ticket,
409 'responses' => $ticket->responses,
410 'agent_id' => $agent->id
411 ];
412
413 if (defined('FLUENTSUPPORTPRO') && $ticket->watchers) {
414 $data['watchers'] = TicketHelper::getWatchers($ticket->watchers);
415 }
416
417 $withData = $request->get('with_data', null);
418 $withDataArray = is_array($withData) ? map_deep($withData, 'sanitize_text_field') : [];
419
420 if (defined('FLUENTCRM') && in_array('fluentcrm_profile', $withDataArray)) {
421 $data['fluentcrm_profile'] = Helper::getFluentCrmContactData($ticket->customer);
422 }
423
424 return $data;
425 } catch (\Exception $e) {
426 return $this->sendError([
427 'message' => Helper::getSafeErrorMessage($e)
428 ]);
429 }
430 }
431
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 /**
512 * createResponse method will create response by agent for the ticket
513 * @param Request $request
514 * @param Ticket $ticket
515 * @param int $ticket_id
516 * @return array
517 * @throws \FluentSupport\Framework\Validator\ValidationException
518 */
519 public function createResponse(TicketResponseRequest $request, $ticket_id)
520 {
521 $data = $request->sanitize();
522
523 try {
524 $convoType = Arr::get($data, 'conversation_type', 'response');
525 $isDraft = $convoType === 'draft_response';
526
527 if (!$isDraft) {
528 $this->ensureCanManageTickets();
529 }
530
531 //Get logged-in agent information
532 $agent = Helper::getAgentByUserId();
533
534 if (!$agent) {
535 return $this->sendError([
536 'message' => __('Sorry, You do not have permission. Please add yourself as support agent first', 'fluent-support')
537 ]);
538 }
539
540 $ticket = Ticket::findOrFail($ticket_id);
541
542 $this->ensureCanAccessTicket($ticket);
543
544 $responseData = (new ResponseService())->createResponse($data, $agent, $ticket);
545
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 $responseData['response']->content = wp_specialchars_decode(wpautop($responseData['response']->content, false));
553
554 return [
555 'message' => __('Response has been added', 'fluent-support'),
556 'response' => $responseData['response'],
557 'ticket' => $responseData['ticket'],
558 'update_data' => $responseData['update_data']
559 ];
560 } catch (\Exception $e) {
561 return $this->sendError([
562 'message' => Helper::getSafeErrorMessage($e)
563 ]);
564 }
565 }
566
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 /**
675 * createDraft method will create draft by agent for the ticket
676 * @param Request $request
677 * @param Ticket $ticket
678 * @param int $ticket_id
679 * @return array
680 * @throws \FluentSupport\Framework\Validator\ValidationException
681 */
682 public function createOrUpdatDraft(TicketResponseRequest $request, $ticket_id)
683 {
684 $data = $request->sanitize();
685
686 try {
687 //Get logged-in agent information
688 $agent = Helper::getAgentByUserId();
689
690 if (!$agent) {
691 return $this->sendError([
692 'message' => __('Sorry, You do not have permission. Please add yourself as support agent first', 'fluent-support')
693 ]);
694 }
695
696 $ticket = Ticket::findOrFail($ticket_id);
697
698 $this->ensureCanAccessTicket($ticket);
699
700 $key = 'ticket_no_' . $ticket_id . '_agent_id_' . $agent->id . '_response_draft';
701 $previousDraft = Meta::where('key', $key)->first();
702
703 if ($data['draftID'] || $previousDraft) {
704 Meta::where('key', $key)->update([
705 'value' => maybe_serialize($data)
706 ]);
707
708 return [
709 'message' => __('Draft has been updated', 'fluent-support'),
710 'draftID' => $data['draftID']
711 ];
712 }
713
714 $draftID = Meta::insertGetId([
715 'object_type' => '_fs_auto_draft',
716 'object_id' => $ticket_id,
717 'key' => $key,
718 'value' => maybe_serialize($data)
719 ]);
720
721 return [
722 'message' => __('Draft has been added', 'fluent-support'),
723 'draftID' => $draftID
724 ];
725 } catch (\Exception $e) {
726 return $this->sendError([
727 'message' => Helper::getSafeErrorMessage($e)
728 ]);
729 }
730 }
731
732 public function getDraft($ticket_id)
733 {
734 try {
735 //Get logged-in agent information
736 $agent = Helper::getAgentByUserId();
737
738 if (!$agent) {
739 return $this->sendError([
740 'message' => __('Sorry, You do not have permission. Please add yourself as support agent first', 'fluent-support')
741 ]);
742 }
743
744 $ticket = Ticket::findOrFail($ticket_id);
745
746 $this->ensureCanAccessTicket($ticket);
747
748 $key = 'ticket_no_' . $ticket_id . '_agent_id_' . $agent->id . '_response_draft';
749
750 $draft = Meta::where([
751 'object_type' => '_fs_auto_draft',
752 'key' => $key,
753 ])->first();
754
755 if ($draft) {
756 $draft->value = Helper::safeUnserialize($draft->value);
757 }
758
759 return [
760 'draft' => $draft
761 ];
762 } catch (\Exception $e) {
763 return $this->sendError([
764 'message' => Helper::getSafeErrorMessage($e)
765 ]);
766 }
767 }
768
769 public function deleteDraft($draft_id)
770 {
771 $draft_id = intval($draft_id);
772
773 try {
774 $agent = Helper::getAgentByUserId();
775
776 if (!$agent) {
777 return $this->sendError([
778 'message' => __('You do not have permission to perform this action', 'fluent-support'),
779 ]);
780 }
781
782 $draft = Meta::where('id', $draft_id)
783 ->where('object_type', '_fs_auto_draft')
784 ->first();
785
786 if (!$draft) {
787 return $this->sendError([
788 'message' => __('Draft not found', 'fluent-support'),
789 ]);
790 }
791
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 // Verify ownership: draft key contains agent_id, only managers can delete others' drafts
799 $isOwnDraft = strpos($draft->key, '_agent_id_' . $agent->id . '_') !== false;
800
801 if (!$isOwnDraft && !PermissionManager::canManageTickets()) {
802 return $this->sendError([
803 'message' => __('You do not have permission to delete this draft', 'fluent-support'),
804 ]);
805 }
806
807 $draft->delete();
808
809 return [
810 'message' => __('Discard draft successfully', 'fluent-support'),
811 ];
812 } catch (\Exception $e) {
813 return $this->sendError([
814 'message' => Helper::getSafeErrorMessage($e)
815 ]);
816 }
817 }
818
819 /**
820 * getTicketWidgets method generate additional information for a ticket by customer
821 * @param Ticket $ticket
822 * @param $ticket_id
823 * @return array
824 */
825 public function getTicketWidgets(Request $request, $ticket_id)
826 {
827 try {
828 //Get ticket with customer by ticket id
829 $ticket = Ticket::with('customer')->findOrFail($ticket_id);
830
831 $this->ensureCanAccessTicket($ticket);
832
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;
836
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)
845 ->select(['id', 'title', 'status', 'created_at'])
846 ->latest('id')
847 ->limit($perPage)
848 ->offset($offset)
849 ->get();
850
851 $response = [
852 'other_tickets' => $otherTickets,
853 'other_tickets_total' => $total,
854 'other_tickets_more' => ($offset + $perPage) < $total,
855 ];
856
857 if (in_array('extra_widgets', $request->get('with', []))) {
858 $response['extra_widgets'] = ProfileInfoService::getProfileExtraWidgets($ticket->customer);
859 }
860
861 return $response;
862 } catch (\Exception $e) {
863 return $this->sendError([
864 'message' => Helper::getSafeErrorMessage($e)
865 ]);
866 }
867 }
868
869 /**
870 * updateTicketProperty method will update ticket property
871 * @param Request $request
872 * @param Ticket $ticket
873 * @param $ticket_id
874 * @return array
875 */
876 public function updateTicketProperty(Request $request, $ticket_id)
877 {
878 try {
879 $assigner = Helper::getAgentByUserId();
880 $ticket = Ticket::findOrFail($ticket_id);
881
882 $this->ensureCanAccessTicket($ticket);
883
884 $propName = $request->getSafe('prop_name', 'sanitize_text_field');
885 $propValue = $request->getSafe('prop_value', 'sanitize_text_field');
886
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 }
897
898 $propValue = $this->sanitizeTicketProperty($ticket, $propName, $propValue);
899
900 $prevValue = $ticket->{$propName};
901
902 if ($propName && $propValue !== null && $prevValue != $propValue) {
903 $ticket->{$propName} = $propValue;
904 $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 }
928
929 $updateData = [];
930
931 if ($propName == 'product_id') {
932 $ticket->load('product');
933 $updateData['product'] = $ticket->product;
934 } else if ($propName == 'agent_id') {
935 $previousAgentId = (int) $prevValue;
936 $ticket->load('agent');
937 $updateData['agent'] = $ticket->agent;
938 $updateData['assigner'] = (new TicketService())->onAgentChange($ticket, $assigner);
939 if ($prevValue != $ticket->{$propName}) {
940 do_action('fluent_support/agent_assigned_to_ticket', $ticket->agent, $ticket, $assigner, $previousAgentId);
941 }
942 }
943
944 $message = sprintf(
945 /* translators: %s: The name of the property that was updated */
946 __('%s has been updated', 'fluent-support'),
947 esc_html(str_replace('_', ' ', ucwords((string) $propName)))
948 );
949
950 return [
951 'message' => $message,
952 'update_data' => $updateData
953 ];
954 } catch (\Exception $e) {
955 return $this->sendError([
956 'message' => Helper::getSafeErrorMessage($e)
957 ]);
958 }
959 }
960
961 /**
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 * closeTicket method close the ticket by id
1105 * @param Ticket $ticket
1106 * @param int $ticket_id
1107 * @return array
1108 */
1109 public function closeTicket(Request $request, $ticket_id)
1110 {
1111 try {
1112 $agent = Helper::getAgentByUserId();
1113 $ticket = Ticket::findOrFail($ticket_id);
1114
1115 $this->ensureCanAccessTicket($ticket);
1116
1117 $closeSilently = $request->getSafe('close_ticket_silently', 'sanitize_text_field');
1118
1119 return [
1120 'message' => __('Ticket has been closed', 'fluent-support'),
1121 'ticket' => (new TicketService())->close($ticket, $agent, '', $closeSilently)
1122 ];
1123 } catch (\Exception $e) {
1124 return $this->sendError([
1125 'message' => Helper::getSafeErrorMessage($e)
1126 ]);
1127 }
1128 }
1129
1130 /**
1131 * reOpenTicket method will reopen a closed ticket
1132 * @param Request $request
1133 * @param $ticket_id
1134 * @return array
1135 */
1136 public function reOpenTicket($ticket_id)
1137 {
1138 try {
1139 $agent = Helper::getAgentByUserId();
1140 $ticket = Ticket::findOrFail($ticket_id);
1141
1142 $this->ensureCanAccessTicket($ticket);
1143
1144 return [
1145 'message' => __('Ticket has been opened again', 'fluent-support'),
1146 'ticket' => (new TicketService())->reopen($ticket, $agent)
1147 ];
1148 } catch (\Exception $e) {
1149 return $this->sendError([
1150 'message' => Helper::getSafeErrorMessage($e)
1151 ]);
1152 }
1153 }
1154
1155 /**
1156 * doBulkActions method is responsible for bulk action
1157 * This function will get ticket ids and action as parameter and perform action based on the selection
1158 * @param Request $request
1159 * @param Ticket $ticket
1160 * @return array|string[]|void
1161 * @throws \Exception
1162 */
1163 public function doBulkActions(Request $request)
1164 {
1165 try {
1166 $action = $request->getSafe('bulk_action', 'sanitize_text_field');
1167 $ticketIds = array_map('intval', $request->get('ticket_ids', null, []));
1168
1169 $agent = Helper::getAgentByUserId();
1170 $query = Ticket::whereIn('id', $ticketIds);
1171
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);
1175
1176 //If bulk action is close tickets
1177 if ($action == 'close_tickets') {
1178 $tickets = $query->get();
1179 $tickets->each(function ($ticket) use ($agent) {
1180 (new TicketService())->close($ticket, $agent);
1181 });
1182
1183 return [
1184 'message' => sprintf(
1185 /* translators: %d represents the number of closed tickets. */
1186 __('%d tickets have been closed.', 'fluent-support'),
1187 count($tickets)
1188 )
1189 ];
1190 } else if ($action == 'delete_tickets') {
1191 $tickets = $query->get();
1192 $ticketService = new TicketService();
1193
1194 foreach ($tickets as $ticket) {
1195 $ticketService->deleteTicket($ticket, $agent);
1196 }
1197
1198 return [
1199 'message' => sprintf(
1200 /* translators: %d is the number of tickets that were deleted */
1201 __('%d tickets have been deleted', 'fluent-support'),
1202 count($tickets)
1203 )
1204 ];
1205 } else if ($action == 'assign_agent') {
1206 if (!$request->has('agent_id')) {
1207 throw new \Exception(esc_html__('agent_id param is required', 'fluent-support'));
1208 }
1209
1210 $assignAgent = Agent::findOrFail($request->getSafe('agent_id', 'intval'));
1211
1212 $query->where(function ($q) use ($assignAgent) {
1213 $q->where('agent_id', '!=', $assignAgent->id)
1214 ->orWhereNull('agent_id');
1215 });
1216
1217 $tickets = $query->get();
1218 $assignedCount = 0;
1219 $skippedCount = 0;
1220
1221 $restrictedBoxes = (new AgentTicketAccess())->getRestrictedMailboxIds($assignAgent);
1222
1223 $tickets->each(function ($ticket) use ($assignAgent, $agent, $restrictedBoxes, &$assignedCount, &$skippedCount) {
1224 $previousAgentId = (int) $ticket->agent_id;
1225
1226 //Skip ticket if mailbox is restricted for the agent
1227 if (!empty($ticket->mailbox_id) && in_array((int) $ticket->mailbox_id, $restrictedBoxes, true)) {
1228 $skippedCount++;
1229 return;
1230 }
1231
1232 $ticket->agent_id = $assignAgent->id;
1233 $ticket->save();
1234 $assignedCount++;
1235
1236 do_action('fluent_support/agent_assigned_to_ticket', $assignAgent, $ticket, $agent, $previousAgentId);
1237 });
1238
1239 $assignedMessage = sprintf(
1240 /* translators: %1$d is the number of tickets assigned, %2$s is the agent's name. */
1241 __('%1$d tickets have been assigned to %2$s.', 'fluent-support'),
1242 $assignedCount,
1243 $assignAgent->full_name
1244 );
1245
1246 $skippedMessage = $skippedCount > 0
1247 ? sprintf(
1248 /* translators: %1$d is the number of skipped tickets due to mailbox restrictions. */
1249 __('%1$d tickets were skipped due to mailbox restrictions or already being assigned.', 'fluent-support'),
1250 $skippedCount
1251 )
1252 : '';
1253
1254 return [
1255 'message' => trim($assignedMessage . ' ' . $skippedMessage)
1256 ];
1257 } else if ($action == 'assign_agent_group') {
1258 if (!$request->has('agent_group_id')) {
1259 throw new \Exception(esc_html__('agent_group_id param is required', 'fluent-support'));
1260 }
1261
1262 $groupId = $request->getSafe('agent_group_id', 'intval');
1263 $group = AgentGroup::findOrFail($groupId);
1264
1265 if ($group->agents()->count() === 0) {
1266 throw new \Exception(esc_html__('No agents found in this group', 'fluent-support'));
1267 }
1268
1269 $tickets = $query->get();
1270 $assignedCount = 0;
1271 $skippedCount = 0;
1272 $currentCounts = [];
1273
1274 foreach ($tickets as $ticket) {
1275 $previousAgentId = (int) $ticket->agent_id;
1276 $selectedAgent = $group->getLeastLoadedAgent(
1277 $ticket->mailbox_id, $currentCounts
1278 );
1279
1280 if (!$selectedAgent) {
1281 $skippedCount++;
1282 continue;
1283 }
1284
1285 $ticket->agent_id = $selectedAgent->id;
1286 $ticket->save();
1287 $assignedCount++;
1288 $currentCounts[$selectedAgent->id]++;
1289
1290 as_enqueue_async_action('fluent_support/async_agent_assigned_to_ticket', [
1291 $selectedAgent->id, $ticket->id, $agent->id, $previousAgentId
1292 ], 'fluent-support');
1293 }
1294
1295 return [
1296 'message' => sprintf(
1297 /* translators: %1$d is tickets assigned, %2$d is tickets skipped. */
1298 __('%1$d tickets assigned via agent group. %2$d skipped.', 'fluent-support'),
1299 $assignedCount,
1300 $skippedCount
1301 )
1302 ];
1303 } else if ($action == 'assign_tags') {
1304 $tagIds = $request->get('tag_ids', null);
1305 if (!is_array($tagIds)) {
1306 $tagIds = [];
1307 }
1308 $tags = array_filter(array_map('absint', $tagIds));
1309
1310 $query->get()->each(function ($ticket) use ($tags) {
1311 $ticket->applyTags($tags);
1312 });
1313
1314 return [
1315 'message' => __('Selected tags has been added to tickets', 'fluent-support')
1316 ];
1317 }
1318
1319 throw new \Exception(esc_html__('Sorry no action found as available', 'fluent-support'));
1320 } catch (\Exception $e) {
1321 return $this->sendError([
1322 'message' => Helper::getSafeErrorMessage($e)
1323 ]);
1324 }
1325 }
1326
1327 /**
1328 * deleteTicket method will delete a ticket
1329 * @param int $ticket_id
1330 * @return array
1331 */
1332 public function deleteTicket($ticket_id)
1333 {
1334 try {
1335 $ticket = Ticket::findOrFail($ticket_id);
1336
1337 $this->ensureCanAccessTicket($ticket);
1338
1339 (new TicketService())->deleteTicket($ticket);
1340
1341 return [
1342 'message' => __('Ticket has been deleted successfully', 'fluent-support')
1343 ];
1344 } catch (\Exception $e) {
1345 return $this->sendError([
1346 'message' => Helper::getSafeErrorMessage($e)
1347 ]);
1348 }
1349 }
1350
1351 /**
1352 * doBulkReplies method will create response for bulk tickets
1353 * This function will get ticket ids, content, attachment etc and create response for tickets
1354 * @param Request $request
1355 * @param Conversation $conversation
1356 * @return array
1357 * @throws \Exception
1358 */
1359 public function doBulkReplies(Request $request)
1360 {
1361 try {
1362 // Sanitize all request data before validation
1363 $requestData = $request->all();
1364 $data = [];
1365 foreach ($requestData as $key => $value) {
1366 if (is_array($value)) {
1367 if ($key === 'ticket_ids') {
1368 $data[$key] = array_map('intval', $value);
1369 } elseif ($key === 'content') {
1370 $data[$key] = wp_kses_post($value);
1371 } else {
1372 $data[$key] = map_deep($value, 'sanitize_text_field');
1373 }
1374 } else {
1375 $data[$key] = sanitize_text_field($value);
1376 }
1377 }
1378
1379 $this->validate($data, [
1380 'content' => 'required',
1381 'ticket_ids' => 'required|array'
1382 ]);
1383
1384 //Get logged in agent information
1385 $agent = Helper::getAgentByUserId();
1386 $ticketIds = array_filter($data['ticket_ids'], 'absint');
1387
1388 $query = Ticket::whereIn('id', $ticketIds)->where('status', '!=', 'closed');
1389
1390 // Scope to tickets the agent may access (visibility + mailbox restrictions).
1391 (new AgentTicketAccess())->applyAccessScope($query, $agent);
1392
1393 $tickets = $query->get();
1394
1395 if ($tickets->isEmpty()) {
1396 throw new \Exception(esc_html__('Sorry no tickets found based on your filter and bulk actions', 'fluent-support'));
1397 }
1398
1399 $responseData = [
1400 'content' => wp_kses_post(Arr::get($data, 'content', '')),
1401 'conversation_type' => 'response',
1402 'close_ticket' => Arr::get($data, 'close_ticket'),
1403 ];
1404
1405 //If request with file attachments
1406 $attachmentHashes = Arr::get($data, 'attachments', []);
1407 $attachments = false;
1408 if ($attachmentHashes) {
1409 $attachments = Attachment::whereNull('ticket_id')
1410 ->orderBy('id', 'asc')
1411 ->whereIn('file_hash', $attachmentHashes)
1412 ->get();
1413 }
1414
1415 $responseService = new ResponseService();
1416
1417 foreach ($tickets as $ticket) {
1418 if ($attachments) {
1419 $responseData['attachments'] = [];
1420 $attachmentRecords = [];
1421 foreach ($attachments as $attachment) {
1422 $fileHash = bin2hex(random_bytes(16));
1423 $attachmentRecords[] = [
1424 'ticket_id' => $ticket->id,
1425 'file_path' => $attachment->file_path,
1426 'full_url' => $attachment->full_url,
1427 'title' => $attachment->title,
1428 'driver' => $attachment->driver,
1429 'file_size' => $attachment->file_size,
1430 'status' => $attachment->status,
1431 'file_hash' => $fileHash,
1432 ];
1433 $responseData['attachments'][] = $fileHash;
1434 }
1435 if ($attachmentRecords) {
1436 Attachment::insert($attachmentRecords);
1437 }
1438 }
1439
1440 $responseService->createResponse($responseData, $agent, $ticket);
1441 }
1442
1443 return [
1444 'message' => __('Response has been added to the selected tickets', 'fluent-support')
1445 ];
1446 } catch (\Exception $e) {
1447 return $this->sendError([
1448 'message' => Helper::getSafeErrorMessage($e)
1449 ]);
1450 }
1451 }
1452
1453 /**
1454 * deleteResponse method will remove a response from ticket by ticket id and response id
1455 * @param Request $request
1456 * @param Conversation $conversation
1457 * @param $ticket_id
1458 * @param $response_id
1459 * @return array
1460 */
1461 public function deleteResponse($ticket_id, $response_id)
1462 {
1463 try {
1464 $ticket = Ticket::findOrFail($ticket_id);
1465
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')) {
1477 throw new \Exception(
1478 esc_html__('Sorry, you do not have permission to delete this response.', 'fluent-support')
1479 );
1480 }
1481
1482 $response = Conversation::where('id', $response_id)
1483 ->where('ticket_id', $ticket_id)
1484 ->firstOrFail();
1485
1486 $response->delete();
1487 $response->ccinfo()->delete();
1488
1489 return [
1490 'message' => __('Selected response has been deleted', 'fluent-support')
1491 ];
1492 } catch (\Exception $e) {
1493 return $this->sendError([
1494 'message' => Helper::getSafeErrorMessage($e)
1495 ]);
1496 }
1497 }
1498
1499 /**
1500 * updateResponse method will update ticket response using ticket and response id
1501 * @param Request $request
1502 * @param int $ticket_id
1503 * @param int $response_id
1504 * @return array
1505 * @throws \Exception
1506 */
1507 public function updateResponse(TicketResponseRequest $request, $ticket_id, $response_id)
1508 {
1509 try {
1510 $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();
1524 $agent = Helper::getAgentByUserId();
1525
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)) {
1530 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 esc_html__('Sorry, you do not have permission to update this response.', 'fluent-support')
1540 );
1541 }
1542
1543 $isDraft = $response->conversation_type == 'draft_response';
1544 $isAuthor = (int) $response->person_id === (int) $agent->id;
1545 $canApproveDraft = PermissionManager::currentUserCan('fst_approve_draft_reply');
1546
1547 if ($isDraft && !$isAuthor) {
1548 // Another agent's draft can only be edited/approved by an approver.
1549 if (!$canApproveDraft) {
1550 throw new \Exception(
1551 esc_html__('Sorry, You do not have permission to approve this draft response', 'fluent-support')
1552 );
1553 }
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 );
1559 }
1560
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;
1565
1566 if ($isDraft && !$isAuthor && $canApproveDraft) {
1567 $response = $this->approveDraftConversation($ticket, $response, $agent, $content);
1568 } else {
1569 $response->save();
1570 }
1571
1572 return [
1573 'message' => __('Selected response has been updated', 'fluent-support'),
1574 'response' => $response
1575 ];
1576 } catch (\Exception $e) {
1577 return $this->sendError([
1578 'message' => Helper::getSafeErrorMessage($e)
1579 ]);
1580 }
1581 }
1582
1583 public function approveDraftResponse(TicketResponseRequest $request, $ticket_id, $response_id)
1584 {
1585 try {
1586 if (!PermissionManager::currentUserCan('fst_approve_draft_reply')) {
1587 throw new \Exception(
1588 esc_html__('You do not have permission to approve draft responses.', 'fluent-support')
1589 );
1590 }
1591
1592 $ticket = Ticket::findOrFail($ticket_id);
1593
1594 $this->ensureCanAccessTicket($ticket);
1595
1596 $response = Conversation::where('id', $response_id)
1597 ->where('ticket_id', $ticket_id)
1598 ->where('conversation_type', 'draft_response')
1599 ->firstOrFail();
1600
1601 $person = Helper::getAgentByUserId();
1602
1603 $response = $this->approveDraftConversation(
1604 $ticket,
1605 $response,
1606 $person,
1607 wp_kses_post($request->getSafe('content', 'wp_kses_post'))
1608 );
1609
1610 return [
1611 'message' => __('Draft response has been successfully approved.', 'fluent-support'),
1612 'response' => $response,
1613 ];
1614 } catch (\Exception $e) {
1615 return $this->sendError([
1616 'message' => Helper::getSafeErrorMessage($e)
1617 ]);
1618 }
1619 }
1620
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 /**
1653 * getLiveActivity method will return the activity in a ticket by agents
1654 * @param Request $request
1655 * @param $ticket_id
1656 * @return array
1657 */
1658 public function getLiveActivity(Request $request, $ticket_id)
1659 {
1660 try {
1661 $ticket = Ticket::findOrFail($ticket_id);
1662
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 }
1675 }
1676
1677 /**
1678 * removeLiveActivity method will remove activities that
1679 * @param Request $request
1680 * @param $ticket_id
1681 * @return array
1682 */
1683 public function removeLiveActivity(Request $request, $ticket_id)
1684 {
1685 try {
1686 $ticket = Ticket::findOrFail($ticket_id);
1687
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 }
1701 }
1702
1703 /**
1704 * addTag method will add tag in ticket by ticket id
1705 * @param Request $request
1706 * @param $ticket_id
1707 * @return array
1708 */
1709 public function addTag(Request $request, $ticket_id)
1710 {
1711 try {
1712 $ticket = Ticket::findOrFail($ticket_id);
1713
1714 $this->ensureCanAccessTicket($ticket);
1715
1716 $ticket->applyTags($request->getSafe('tag_id', 'intval'));
1717
1718 return [
1719 'message' => __('Tag has been added to this ticket', 'fluent-support'),
1720 'tags' => $ticket->tags
1721 ];
1722 } catch (\Exception $e) {
1723 return $this->sendError([
1724 'message' => Helper::getSafeErrorMessage($e)
1725 ]);
1726 }
1727 }
1728
1729 /**
1730 * detachTag method will remove all tags from tickets
1731 * @param $ticket_id
1732 * @param $tag_id
1733 * @return array
1734 */
1735 public function detachTag($ticket_id, $tag_id)
1736 {
1737 try {
1738 $ticket = Ticket::findOrFail($ticket_id);
1739
1740 $this->ensureCanAccessTicket($ticket);
1741
1742 $ticket->detachTags($tag_id);
1743
1744 return [
1745 'message' => __('Tag has been removed from this ticket', 'fluent-support'),
1746 'tags' => $ticket->tags
1747 ];
1748 } catch (\Exception $e) {
1749 return $this->sendError([
1750 'message' => Helper::getSafeErrorMessage($e)
1751 ]);
1752 }
1753 }
1754
1755 /**
1756 * changeTicketCustomer method will update customer in a ticket
1757 * This method will get ticket id and customer id as parameter, it will replace existing customer id with new
1758 * @param Request $request
1759 * @return array
1760 */
1761 public function changeTicketCustomer(Request $request, $ticket_id)
1762 {
1763 $ticketId = (int) $ticket_id;
1764 $newCustomerId = $request->getSafe('customer', 'intval');
1765
1766 if (!$newCustomerId) {
1767 return $this->sendError(__('Invalid customer selected.', 'fluent-support'));
1768 }
1769
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 try {
1778 $ticket = Ticket::findOrFail($ticketId);
1779
1780 $this->ensureCanAccessTicket($ticket);
1781
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 } catch (\Exception $e) {
1800 return $this->sendError([
1801 'message' => Helper::getSafeErrorMessage($e)
1802 ]);
1803 }
1804 }
1805
1806 /**
1807 * getTicketCustomData method will return the custom data by ticket id
1808 * @param Request $request
1809 * @param $ticket_id
1810 * @return array|array[]
1811 */
1812 public function getTicketCustomData(Request $request, $ticket_id)
1813 {
1814 if (!defined('FLUENTSUPPORTPRO')) {
1815 return [
1816 'custom_data' => [],
1817 'rendered_fields' => []
1818 ];
1819 }
1820
1821 try {
1822 $ticket = Ticket::findOrFail($ticket_id);
1823
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 }
1835 }
1836
1837 /**
1838 * syncFluentCrmTags method will synchronize the tags with Fluent CRM by contact id
1839 *This function will get contact id and tags as parameter, get existing tags from crm and updated added/removed tags
1840 * @param Request $request
1841 * @param FluentCRMServices $fluentCRMServices
1842 * @return array
1843 */
1844 public function syncFluentCrmTags(Request $request, FluentCRMServices $fluentCRMServices)
1845 {
1846 $data = [
1847 'contact_id' => $request->getSafe('contact_id', 'intval'),
1848 'tags' => $request->get('tags', null)
1849 ];
1850
1851 // Sanitize tags array if it's an array
1852 if (is_array($data['tags'])) {
1853 $data['tags'] = array_map('intval', $data['tags']);
1854 }
1855
1856 try {
1857 return $fluentCRMServices->syncCrmTags($data);
1858 } catch (\Exception $e) {
1859 return $this->sendError([
1860 'message' => Helper::getSafeErrorMessage($e)
1861 ]);
1862 }
1863 }
1864
1865 /**
1866 * This `syncFluentCrmLists` method will synchronize the lists with Fluent CRM by contact id
1867 * This method will get contact id and lists as parameter, get existing lists from crm and updated added/removed lists
1868 * @param Request $request
1869 * @param FluentCRMServices $fluentCRMServices
1870 * @return array
1871 */
1872
1873 public function syncFluentCrmLists(Request $request, FluentCRMServices $fluentCRMServices)
1874 {
1875 $data = [
1876 'contact_id' => $request->getSafe('contact_id', 'intval'),
1877 'lists' => $request->get('lists', null, [])
1878 ];
1879
1880 // Sanitize lists array if it's an array
1881 if (is_array($data['lists'])) {
1882 $data['lists'] = array_map('intval', $data['lists']);
1883 }
1884
1885 try {
1886 return $fluentCRMServices->syncCrmLists($data);
1887 } catch (\Exception $e) {
1888 return $this->sendError([
1889 'message' => Helper::getSafeErrorMessage($e)
1890 ]);
1891 }
1892 }
1893
1894 /**
1895 * Get ticket essentials data based on the provided types.
1896 *
1897 * @param \Illuminate\Http\Request $request
1898 * @return array The ticket essentials data.
1899 */
1900 public function getTicketEssentials(Request $request)
1901 {
1902 $type = $request->getSafe('type', 'sanitize_text_field');
1903
1904 return TicketHelper::getTicketEssentials($type);
1905 }
1906
1907 public function fetchLabelSearch()
1908 {
1909 try {
1910 $agent_id = get_current_user_id();
1911 return TicketHelper::getLabelSearch($agent_id);
1912 } catch (\Exception $e) {
1913 return $this->sendError([
1914 'message' => Helper::getSafeErrorMessage($e)
1915 ]);
1916 }
1917 }
1918
1919 public function storeOrUpdateLabelSearch(Request $request)
1920 {
1921 try {
1922 $agent_id = get_current_user_id();
1923 $searchData = $request->get('query', null, []);
1924 if (is_array($searchData)) {
1925 $searchData = map_deep($searchData, 'sanitize_text_field');
1926 }
1927 $filterType = Arr::get($searchData, 'filter_type', '');
1928 if ($filterType == 'advanced') {
1929 return TicketHelper::saveSearchLabel($agent_id, $searchData, $filterType);
1930 }
1931
1932 return [
1933 'message' => __('Invalid filter type.', 'fluent-support'),
1934 ];
1935
1936 } catch (\Exception $e) {
1937 return $this->sendError([
1938 'message' => Helper::getSafeErrorMessage($e)
1939 ]);
1940 }
1941 }
1942
1943 public function deleteLabelSearch(Request $request, $search_id)
1944 {
1945 try {
1946 $agent_id = get_current_user_id();
1947 return TicketHelper::deleteSavedSearch($search_id);
1948 } catch (\Exception $e) {
1949 return $this->sendError([
1950 'message' => Helper::getSafeErrorMessage($e)
1951 ]);
1952 }
1953 }
1954 }
1955