PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 2.3.2
Fluent Support – Helpdesk & Customer Support Ticket System v2.3.2
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 1.5.6 All 67 releases
fluent-support / app / Http / Controllers / TicketController.php

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

1,945 lines 72.6 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'])->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 // FS-SEC-007: this generic endpoint may only touch a fixed set of
888 // ticket columns. Previously prop_name was assigned straight onto the
889 // model ($ticket->{$propName} = $propValue), letting a caller rewrite
890 // ownership, mailbox, privacy, hash, serial_number, created_by and
891 // other sensitive columns and bypass $fillable entirely. Every
892 // property is now allowlisted and its value validated/capability-
893 // gated below; anything else is rejected outright.
894 if (!in_array($propName, $this->updatableTicketProperties(), true)) {
895 throw new \Exception(esc_html__('This ticket property cannot be updated.', 'fluent-support'), 403);
896 }
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 (FS-SEC-007).
967 *
968 * @return array
969 */
970 protected function updatableTicketProperties()
971 {
972 return [
973 'agent_id',
974 'title',
975 'mailbox_id',
976 'product_id',
977 'status',
978 'priority',
979 'client_priority',
980 ];
981 }
982
983 /**
984 * Validate and normalize a single ticket-property update. Each allowlisted
985 * property is checked against its own value domain and capability, so a
986 * caller can neither set an out-of-range value nor perform a change the UI
987 * gates behind a stronger permission (FS-SEC-007).
988 *
989 * @param Ticket $ticket
990 * @param string $propName Already confirmed to be in the allowlist.
991 * @param string $propValue Raw (text-sanitized) value from the request.
992 * @return mixed Normalized value ready to assign to the model.
993 * @throws \Exception When the value is invalid or the caller lacks permission.
994 */
995 protected function sanitizeTicketProperty(Ticket $ticket, $propName, $propValue)
996 {
997 switch ($propName) {
998 case 'title':
999 $propValue = trim(sanitize_text_field($propValue));
1000 if ($propValue === '') {
1001 throw new \Exception(esc_html__('Ticket title cannot be empty.', 'fluent-support'), 422);
1002 }
1003 return $propValue;
1004
1005 case 'status':
1006 // Mirror the ticket-view status dropdown, which is built from
1007 // changeable_ticket_statuses. The dropdown submits the group
1008 // KEY as the status value (getTicketStatus in ViewTicket.vue
1009 // keys the options by group name and el-option binds :value to
1010 // that key), and only groups with a non-empty value list are
1011 // shown. Validate against those same keys so the endpoint honors
1012 // the fluent_support/changeable_ticket_statuses filter exactly.
1013 $allowedStatuses = [];
1014 foreach (Helper::changeableTicketStatuses() as $statusKey => $statusGroup) {
1015 if (!empty($statusGroup)) {
1016 $allowedStatuses[] = $statusKey;
1017 }
1018 }
1019
1020 if (!in_array($propValue, $allowedStatuses, true)) {
1021 throw new \Exception(esc_html__('Invalid ticket status.', 'fluent-support'), 422);
1022 }
1023 return $propValue;
1024
1025 case 'priority':
1026 if (!array_key_exists($propValue, Helper::adminTicketPriorities())) {
1027 throw new \Exception(esc_html__('Invalid ticket priority.', 'fluent-support'), 422);
1028 }
1029 return $propValue;
1030
1031 case 'client_priority':
1032 if (!array_key_exists($propValue, Helper::customerTicketPriorities())) {
1033 throw new \Exception(esc_html__('Invalid client priority.', 'fluent-support'), 422);
1034 }
1035 return $propValue;
1036
1037 case 'product_id':
1038 $productId = (int) $propValue;
1039 if (!$productId || !Product::where('id', $productId)->exists()) {
1040 throw new \Exception(esc_html__('Invalid product.', 'fluent-support'), 422);
1041 }
1042 return $productId;
1043
1044 case 'agent_id':
1045 if (!PermissionManager::currentUserCan('fst_assign_agents')) {
1046 throw new \Exception(esc_html__('Permission denied to assign agent', 'fluent-support'), 403);
1047 }
1048
1049 $agentId = (int) $propValue;
1050 $agent = Agent::findOrFail($agentId);
1051 $restrictedBoxes = (new AgentTicketAccess())->getRestrictedMailboxIds($agent);
1052
1053 if (in_array((int) $ticket->mailbox_id, $restrictedBoxes, true)) {
1054 throw new \Exception(esc_html__('Agent is restricted for this mailbox ticket', 'fluent-support'), 403);
1055 }
1056 return $agentId;
1057
1058 case 'mailbox_id':
1059 // The admin UI only exposes the mailbox switcher to agents with
1060 // fst_manage_settings; enforce the same gate on the API so the
1061 // permission can't be bypassed by calling the endpoint directly.
1062 if (!PermissionManager::currentUserCan('fst_manage_settings')) {
1063 throw new \Exception(esc_html__('Permission denied to move this ticket to another mailbox.', 'fluent-support'), 403);
1064 }
1065
1066 $mailboxId = (int) $propValue;
1067 $restrictedBoxes = array_map('intval', PermissionManager::getRestrictedMailboxIds());
1068
1069 if (!MailBox::where('id', $mailboxId)->exists() || in_array($mailboxId, $restrictedBoxes, true)) {
1070 throw new \Exception(esc_html__('Invalid or restricted mailbox.', 'fluent-support'), 422);
1071 }
1072
1073 // Preserve the agent/mailbox compatibility invariant that the
1074 // agent_id branch enforces on assignment: a ticket must not be
1075 // moved into a mailbox its currently assigned agent is restricted
1076 // from, which would otherwise persist an assignment the assign
1077 // flow would have rejected.
1078 if ($ticket->agent_id) {
1079 $assignedAgent = Agent::find($ticket->agent_id);
1080 if ($assignedAgent) {
1081 $agentRestrictedBoxes = (new AgentTicketAccess())->getRestrictedMailboxIds($assignedAgent);
1082 if (in_array($mailboxId, $agentRestrictedBoxes, true)) {
1083 throw new \Exception(esc_html__('The assigned agent is restricted from the selected mailbox. Reassign the ticket before moving it.', 'fluent-support'), 403);
1084 }
1085 }
1086 }
1087 return $mailboxId;
1088 }
1089
1090 // Unreachable: updateTicketProperty already rejected non-allowlisted
1091 // properties before calling this method. Fail closed regardless.
1092 throw new \Exception(esc_html__('This ticket property cannot be updated.', 'fluent-support'), 403);
1093 }
1094
1095 /**
1096 * closeTicket method close the ticket by id
1097 * @param Ticket $ticket
1098 * @param int $ticket_id
1099 * @return array
1100 */
1101 public function closeTicket(Request $request, $ticket_id)
1102 {
1103 try {
1104 $agent = Helper::getAgentByUserId();
1105 $ticket = Ticket::findOrFail($ticket_id);
1106
1107 $this->ensureCanAccessTicket($ticket);
1108
1109 $closeSilently = $request->getSafe('close_ticket_silently', 'sanitize_text_field');
1110
1111 return [
1112 'message' => __('Ticket has been closed', 'fluent-support'),
1113 'ticket' => (new TicketService())->close($ticket, $agent, '', $closeSilently)
1114 ];
1115 } catch (\Exception $e) {
1116 return $this->sendError([
1117 'message' => Helper::getSafeErrorMessage($e)
1118 ]);
1119 }
1120 }
1121
1122 /**
1123 * reOpenTicket method will reopen a closed ticket
1124 * @param Request $request
1125 * @param $ticket_id
1126 * @return array
1127 */
1128 public function reOpenTicket($ticket_id)
1129 {
1130 try {
1131 $agent = Helper::getAgentByUserId();
1132 $ticket = Ticket::findOrFail($ticket_id);
1133
1134 $this->ensureCanAccessTicket($ticket);
1135
1136 return [
1137 'message' => __('Ticket has been opened again', 'fluent-support'),
1138 'ticket' => (new TicketService())->reopen($ticket, $agent)
1139 ];
1140 } catch (\Exception $e) {
1141 return $this->sendError([
1142 'message' => Helper::getSafeErrorMessage($e)
1143 ]);
1144 }
1145 }
1146
1147 /**
1148 * doBulkActions method is responsible for bulk action
1149 * This function will get ticket ids and action as parameter and perform action based on the selection
1150 * @param Request $request
1151 * @param Ticket $ticket
1152 * @return array|string[]|void
1153 * @throws \Exception
1154 */
1155 public function doBulkActions(Request $request)
1156 {
1157 try {
1158 $action = $request->getSafe('bulk_action', 'sanitize_text_field');
1159 $ticketIds = array_map('intval', $request->get('ticket_ids', null, []));
1160
1161 $agent = Helper::getAgentByUserId();
1162 $query = Ticket::whereIn('id', $ticketIds);
1163
1164 //Scope selected tickets to what the agent can access, matching the
1165 //per-ticket ensureCanAccessTicket() check on the single-ticket routes
1166 (new AgentTicketAccess())->applyAccessScope($query, $agent);
1167
1168 //If bulk action is close tickets
1169 if ($action == 'close_tickets') {
1170 $tickets = $query->get();
1171 $tickets->each(function ($ticket) use ($agent) {
1172 (new TicketService())->close($ticket, $agent);
1173 });
1174
1175 return [
1176 'message' => sprintf(
1177 /* translators: %d represents the number of closed tickets. */
1178 __('%d tickets have been closed.', 'fluent-support'),
1179 count($tickets)
1180 )
1181 ];
1182 } else if ($action == 'delete_tickets') {
1183 $tickets = $query->get();
1184 $ticketService = new TicketService();
1185
1186 foreach ($tickets as $ticket) {
1187 $ticketService->deleteTicket($ticket, $agent);
1188 }
1189
1190 return [
1191 'message' => sprintf(
1192 /* translators: %d is the number of tickets that were deleted */
1193 __('%d tickets have been deleted', 'fluent-support'),
1194 count($tickets)
1195 )
1196 ];
1197 } else if ($action == 'assign_agent') {
1198 if (!$request->has('agent_id')) {
1199 throw new \Exception(esc_html__('agent_id param is required', 'fluent-support'));
1200 }
1201
1202 $assignAgent = Agent::findOrFail($request->getSafe('agent_id', 'intval'));
1203
1204 $query->where(function ($q) use ($assignAgent) {
1205 $q->where('agent_id', '!=', $assignAgent->id)
1206 ->orWhereNull('agent_id');
1207 });
1208
1209 $tickets = $query->get();
1210 $assignedCount = 0;
1211 $skippedCount = 0;
1212
1213 $restrictedBoxes = (new AgentTicketAccess())->getRestrictedMailboxIds($assignAgent);
1214
1215 $tickets->each(function ($ticket) use ($assignAgent, $agent, $restrictedBoxes, &$assignedCount, &$skippedCount) {
1216 $previousAgentId = (int) $ticket->agent_id;
1217
1218 //Skip ticket if mailbox is restricted for the agent
1219 if (!empty($ticket->mailbox_id) && in_array((int) $ticket->mailbox_id, $restrictedBoxes, true)) {
1220 $skippedCount++;
1221 return;
1222 }
1223
1224 $ticket->agent_id = $assignAgent->id;
1225 $ticket->save();
1226 $assignedCount++;
1227
1228 do_action('fluent_support/agent_assigned_to_ticket', $assignAgent, $ticket, $agent, $previousAgentId);
1229 });
1230
1231 $assignedMessage = sprintf(
1232 /* translators: %1$d is the number of tickets assigned, %2$s is the agent's name. */
1233 __('%1$d tickets have been assigned to %2$s.', 'fluent-support'),
1234 $assignedCount,
1235 $assignAgent->full_name
1236 );
1237
1238 $skippedMessage = $skippedCount > 0
1239 ? sprintf(
1240 /* translators: %1$d is the number of skipped tickets due to mailbox restrictions. */
1241 __('%1$d tickets were skipped due to mailbox restrictions or already being assigned.', 'fluent-support'),
1242 $skippedCount
1243 )
1244 : '';
1245
1246 return [
1247 'message' => trim($assignedMessage . ' ' . $skippedMessage)
1248 ];
1249 } else if ($action == 'assign_agent_group') {
1250 if (!$request->has('agent_group_id')) {
1251 throw new \Exception(esc_html__('agent_group_id param is required', 'fluent-support'));
1252 }
1253
1254 $groupId = $request->getSafe('agent_group_id', 'intval');
1255 $group = AgentGroup::findOrFail($groupId);
1256
1257 if ($group->agents()->count() === 0) {
1258 throw new \Exception(esc_html__('No agents found in this group', 'fluent-support'));
1259 }
1260
1261 $tickets = $query->get();
1262 $assignedCount = 0;
1263 $skippedCount = 0;
1264 $currentCounts = [];
1265
1266 foreach ($tickets as $ticket) {
1267 $previousAgentId = (int) $ticket->agent_id;
1268 $selectedAgent = $group->getLeastLoadedAgent(
1269 $ticket->mailbox_id, $currentCounts
1270 );
1271
1272 if (!$selectedAgent) {
1273 $skippedCount++;
1274 continue;
1275 }
1276
1277 $ticket->agent_id = $selectedAgent->id;
1278 $ticket->save();
1279 $assignedCount++;
1280 $currentCounts[$selectedAgent->id]++;
1281
1282 as_enqueue_async_action('fluent_support/async_agent_assigned_to_ticket', [
1283 $selectedAgent->id, $ticket->id, $agent->id, $previousAgentId
1284 ], 'fluent-support');
1285 }
1286
1287 return [
1288 'message' => sprintf(
1289 /* translators: %1$d is tickets assigned, %2$d is tickets skipped. */
1290 __('%1$d tickets assigned via agent group. %2$d skipped.', 'fluent-support'),
1291 $assignedCount,
1292 $skippedCount
1293 )
1294 ];
1295 } else if ($action == 'assign_tags') {
1296 $tagIds = $request->get('tag_ids', null);
1297 if (!is_array($tagIds)) {
1298 $tagIds = [];
1299 }
1300 $tags = array_filter(array_map('absint', $tagIds));
1301
1302 $query->get()->each(function ($ticket) use ($tags) {
1303 $ticket->applyTags($tags);
1304 });
1305
1306 return [
1307 'message' => __('Selected tags has been added to tickets', 'fluent-support')
1308 ];
1309 }
1310
1311 throw new \Exception(esc_html__('Sorry no action found as available', 'fluent-support'));
1312 } catch (\Exception $e) {
1313 return $this->sendError([
1314 'message' => Helper::getSafeErrorMessage($e)
1315 ]);
1316 }
1317 }
1318
1319 /**
1320 * deleteTicket method will delete a ticket
1321 * @param int $ticket_id
1322 * @return array
1323 */
1324 public function deleteTicket($ticket_id)
1325 {
1326 try {
1327 $ticket = Ticket::findOrFail($ticket_id);
1328
1329 $this->ensureCanAccessTicket($ticket);
1330
1331 (new TicketService())->deleteTicket($ticket);
1332
1333 return [
1334 'message' => __('Ticket has been deleted successfully', 'fluent-support')
1335 ];
1336 } catch (\Exception $e) {
1337 return $this->sendError([
1338 'message' => Helper::getSafeErrorMessage($e)
1339 ]);
1340 }
1341 }
1342
1343 /**
1344 * doBulkReplies method will create response for bulk tickets
1345 * This function will get ticket ids, content, attachment etc and create response for tickets
1346 * @param Request $request
1347 * @param Conversation $conversation
1348 * @return array
1349 * @throws \Exception
1350 */
1351 public function doBulkReplies(Request $request)
1352 {
1353 try {
1354 // Sanitize all request data before validation
1355 $requestData = $request->all();
1356 $data = [];
1357 foreach ($requestData as $key => $value) {
1358 if (is_array($value)) {
1359 if ($key === 'ticket_ids') {
1360 $data[$key] = array_map('intval', $value);
1361 } elseif ($key === 'content') {
1362 $data[$key] = wp_kses_post($value);
1363 } else {
1364 $data[$key] = map_deep($value, 'sanitize_text_field');
1365 }
1366 } else {
1367 $data[$key] = sanitize_text_field($value);
1368 }
1369 }
1370
1371 $this->validate($data, [
1372 'content' => 'required',
1373 'ticket_ids' => 'required|array'
1374 ]);
1375
1376 //Get logged in agent information
1377 $agent = Helper::getAgentByUserId();
1378 $ticketIds = array_filter($data['ticket_ids'], 'absint');
1379
1380 $query = Ticket::whereIn('id', $ticketIds)->where('status', '!=', 'closed');
1381
1382 // Scope to tickets the agent may access (visibility + mailbox restrictions).
1383 (new AgentTicketAccess())->applyAccessScope($query, $agent);
1384
1385 $tickets = $query->get();
1386
1387 if ($tickets->isEmpty()) {
1388 throw new \Exception(esc_html__('Sorry no tickets found based on your filter and bulk actions', 'fluent-support'));
1389 }
1390
1391 $responseData = [
1392 'content' => wp_kses_post(Arr::get($data, 'content', '')),
1393 'conversation_type' => 'response',
1394 'close_ticket' => Arr::get($data, 'close_ticket'),
1395 ];
1396
1397 //If request with file attachments
1398 $attachmentHashes = Arr::get($data, 'attachments', []);
1399 $attachments = false;
1400 if ($attachmentHashes) {
1401 $attachments = Attachment::whereNull('ticket_id')
1402 ->orderBy('id', 'asc')
1403 ->whereIn('file_hash', $attachmentHashes)
1404 ->get();
1405 }
1406
1407 $responseService = new ResponseService();
1408
1409 foreach ($tickets as $ticket) {
1410 if ($attachments) {
1411 $responseData['attachments'] = [];
1412 $attachmentRecords = [];
1413 foreach ($attachments as $attachment) {
1414 $fileHash = bin2hex(random_bytes(16));
1415 $attachmentRecords[] = [
1416 'ticket_id' => $ticket->id,
1417 'file_path' => $attachment->file_path,
1418 'full_url' => $attachment->full_url,
1419 'title' => $attachment->title,
1420 'driver' => $attachment->driver,
1421 'file_size' => $attachment->file_size,
1422 'status' => $attachment->status,
1423 'file_hash' => $fileHash,
1424 ];
1425 $responseData['attachments'][] = $fileHash;
1426 }
1427 if ($attachmentRecords) {
1428 Attachment::insert($attachmentRecords);
1429 }
1430 }
1431
1432 $responseService->createResponse($responseData, $agent, $ticket);
1433 }
1434
1435 return [
1436 'message' => __('Response has been added to the selected tickets', 'fluent-support')
1437 ];
1438 } catch (\Exception $e) {
1439 return $this->sendError([
1440 'message' => Helper::getSafeErrorMessage($e)
1441 ]);
1442 }
1443 }
1444
1445 /**
1446 * deleteResponse method will remove a response from ticket by ticket id and response id
1447 * @param Request $request
1448 * @param Conversation $conversation
1449 * @param $ticket_id
1450 * @param $response_id
1451 * @return array
1452 */
1453 public function deleteResponse($ticket_id, $response_id)
1454 {
1455 try {
1456 $ticket = Ticket::findOrFail($ticket_id);
1457
1458 if (in_array($ticket->mailbox_id, PermissionManager::getRestrictedMailboxIds())) {
1459 throw new \Exception(esc_html__('Ticket cannot be fetched due to restricted mailbox', 'fluent-support'));
1460 }
1461
1462 // The caller must have access to this specific ticket (visibility +
1463 // ownership + mailbox), not merely a global manage capability.
1464 $this->ensureCanAccessTicket($ticket);
1465
1466 // Deleting a response always requires the explicit delete capability,
1467 // mirroring deleteTicket(). Assignment alone is not sufficient.
1468 if (!PermissionManager::currentUserCan('fst_delete_tickets')) {
1469 throw new \Exception(
1470 esc_html__('Sorry, you do not have permission to delete this response.', 'fluent-support')
1471 );
1472 }
1473
1474 $response = Conversation::where('id', $response_id)
1475 ->where('ticket_id', $ticket_id)
1476 ->firstOrFail();
1477
1478 $response->delete();
1479 $response->ccinfo()->delete();
1480
1481 return [
1482 'message' => __('Selected response has been deleted', 'fluent-support')
1483 ];
1484 } catch (\Exception $e) {
1485 return $this->sendError([
1486 'message' => Helper::getSafeErrorMessage($e)
1487 ]);
1488 }
1489 }
1490
1491 /**
1492 * updateResponse method will update ticket response using ticket and response id
1493 * @param Request $request
1494 * @param int $ticket_id
1495 * @param int $response_id
1496 * @return array
1497 * @throws \Exception
1498 */
1499 public function updateResponse(TicketResponseRequest $request, $ticket_id, $response_id)
1500 {
1501 try {
1502 $ticket = Ticket::findOrFail($ticket_id);
1503
1504 if (in_array($ticket->mailbox_id, PermissionManager::getRestrictedMailboxIds())) {
1505 throw new \Exception(esc_html__('Ticket cannot be fetched due to restricted mailbox', 'fluent-support'));
1506 }
1507
1508 // The caller must have access to this specific ticket (visibility +
1509 // ownership + mailbox), not merely a global manage capability.
1510 $this->ensureCanAccessTicket($ticket);
1511
1512 $response = Conversation::where('id', $response_id)
1513 ->where('ticket_id', $ticket_id)
1514 ->with('person')
1515 ->firstOrFail();
1516 $agent = Helper::getAgentByUserId();
1517
1518 // Only agent-authored conversation types may be edited here. Customer
1519 // replies and system entries must not be rewritten via this endpoint.
1520 $editableTypes = ['response', 'draft_response', 'note', 'internal_info'];
1521 if (!in_array($response->conversation_type, $editableTypes, true)) {
1522 throw new \Exception(
1523 esc_html__('This response type cannot be edited.', 'fluent-support')
1524 );
1525 }
1526
1527 // Customer messages share the 'response' type but are authored by a
1528 // customer person; they are never editable by an agent.
1529 if ($response->person && $response->person->person_type !== 'agent') {
1530 throw new \Exception(
1531 esc_html__('Sorry, you do not have permission to update this response.', 'fluent-support')
1532 );
1533 }
1534
1535 $isDraft = $response->conversation_type == 'draft_response';
1536 $isAuthor = (int) $response->person_id === (int) $agent->id;
1537 $canApproveDraft = PermissionManager::currentUserCan('fst_approve_draft_reply');
1538
1539 if ($isDraft && !$isAuthor) {
1540 // Another agent's draft can only be edited/approved by an approver.
1541 if (!$canApproveDraft) {
1542 throw new \Exception(
1543 esc_html__('Sorry, You do not have permission to approve this draft response', 'fluent-support')
1544 );
1545 }
1546 } elseif (!$isAuthor && !PermissionManager::currentUserCan('fst_manage_other_tickets')) {
1547 // Editing another agent's response requires manage-others capability.
1548 throw new \Exception(
1549 esc_html__('Sorry, you do not have permission to update this response.', 'fluent-support')
1550 );
1551 }
1552
1553 $content = wp_unslash(wp_kses_post($request->getSafe('content', 'wp_kses_post')));
1554 $response->content = $content;
1555
1556 if ($isDraft && !$isAuthor && $canApproveDraft) {
1557 $response = $this->approveDraftConversation($ticket, $response, $agent, $content);
1558 } else {
1559 $response->save();
1560 }
1561
1562 return [
1563 'message' => __('Selected response has been updated', 'fluent-support'),
1564 'response' => $response
1565 ];
1566 } catch (\Exception $e) {
1567 return $this->sendError([
1568 'message' => Helper::getSafeErrorMessage($e)
1569 ]);
1570 }
1571 }
1572
1573 public function approveDraftResponse(TicketResponseRequest $request, $ticket_id, $response_id)
1574 {
1575 try {
1576 if (!PermissionManager::currentUserCan('fst_approve_draft_reply')) {
1577 throw new \Exception(
1578 esc_html__('You do not have permission to approve draft responses.', 'fluent-support')
1579 );
1580 }
1581
1582 $ticket = Ticket::findOrFail($ticket_id);
1583
1584 $this->ensureCanAccessTicket($ticket);
1585
1586 $response = Conversation::where('id', $response_id)
1587 ->where('ticket_id', $ticket_id)
1588 ->where('conversation_type', 'draft_response')
1589 ->firstOrFail();
1590
1591 $person = Helper::getAgentByUserId();
1592
1593 $response = $this->approveDraftConversation(
1594 $ticket,
1595 $response,
1596 $person,
1597 wp_unslash(wp_kses_post($request->getSafe('content', 'wp_kses_post')))
1598 );
1599
1600 return [
1601 'message' => __('Draft response has been successfully approved.', 'fluent-support'),
1602 'response' => $response,
1603 ];
1604 } catch (\Exception $e) {
1605 return $this->sendError([
1606 'message' => Helper::getSafeErrorMessage($e)
1607 ]);
1608 }
1609 }
1610
1611 protected function approveDraftConversation($ticket, $response, $person, $content)
1612 {
1613 $resetWaitingSince = apply_filters('fluent_support/reset_waiting_since', true, $content);
1614
1615 $response->content = $content;
1616 $response->conversation_type = 'response';
1617 $response->created_at = current_time('mysql');
1618 $response->save();
1619
1620 if ($person->person_type == 'agent' && $ticket->status == 'new') {
1621 $ticket->status = 'active';
1622 if ($ticket->created_at) {
1623 $ticket->first_response_time = strtotime(current_time('mysql')) - strtotime($ticket->created_at);
1624 } else {
1625 $ticket->first_response_time = 300;
1626 }
1627 }
1628
1629 if ($resetWaitingSince) {
1630 $ticket->last_agent_response = current_time('mysql');
1631 $ticket->waiting_since = current_time('mysql');
1632 }
1633
1634 $ticket->response_count += 1;
1635 $ticket->save();
1636
1637 do_action('fluent_support/response_added_by_' . $person->person_type, $response, $ticket, $person);
1638
1639 return $response;
1640 }
1641
1642 /**
1643 * getLiveActivity method will return the activity in a ticket by agents
1644 * @param Request $request
1645 * @param $ticket_id
1646 * @return array
1647 */
1648 public function getLiveActivity(Request $request, $ticket_id)
1649 {
1650 try {
1651 $ticket = Ticket::findOrFail($ticket_id);
1652
1653 $this->ensureCanAccessTicket($ticket);
1654
1655 $agent = Helper::getAgentByUserId();
1656
1657 return [
1658 'live_activity' => TicketHelper::getActivity($ticket_id, $agent->id)
1659 ];
1660 } catch (\Exception $e) {
1661 return $this->sendError([
1662 'message' => Helper::getSafeErrorMessage($e)
1663 ]);
1664 }
1665 }
1666
1667 /**
1668 * removeLiveActivity method will remove activities that
1669 * @param Request $request
1670 * @param $ticket_id
1671 * @return array
1672 */
1673 public function removeLiveActivity(Request $request, $ticket_id)
1674 {
1675 try {
1676 $ticket = Ticket::findOrFail($ticket_id);
1677
1678 $this->ensureCanAccessTicket($ticket);
1679
1680 $agent = Helper::getAgentByUserId();
1681
1682 return [
1683 'result' => TicketHelper::removeFromActivities($ticket_id, $agent->id),
1684 'agent_id' => $agent->id
1685 ];
1686 } catch (\Exception $e) {
1687 return $this->sendError([
1688 'message' => Helper::getSafeErrorMessage($e)
1689 ]);
1690 }
1691 }
1692
1693 /**
1694 * addTag method will add tag in ticket by ticket id
1695 * @param Request $request
1696 * @param $ticket_id
1697 * @return array
1698 */
1699 public function addTag(Request $request, $ticket_id)
1700 {
1701 try {
1702 $ticket = Ticket::findOrFail($ticket_id);
1703
1704 $this->ensureCanAccessTicket($ticket);
1705
1706 $ticket->applyTags($request->getSafe('tag_id', 'intval'));
1707
1708 return [
1709 'message' => __('Tag has been added to this ticket', 'fluent-support'),
1710 'tags' => $ticket->tags
1711 ];
1712 } catch (\Exception $e) {
1713 return $this->sendError([
1714 'message' => Helper::getSafeErrorMessage($e)
1715 ]);
1716 }
1717 }
1718
1719 /**
1720 * detachTag method will remove all tags from tickets
1721 * @param $ticket_id
1722 * @param $tag_id
1723 * @return array
1724 */
1725 public function detachTag($ticket_id, $tag_id)
1726 {
1727 try {
1728 $ticket = Ticket::findOrFail($ticket_id);
1729
1730 $this->ensureCanAccessTicket($ticket);
1731
1732 $ticket->detachTags($tag_id);
1733
1734 return [
1735 'message' => __('Tag has been removed from this ticket', 'fluent-support'),
1736 'tags' => $ticket->tags
1737 ];
1738 } catch (\Exception $e) {
1739 return $this->sendError([
1740 'message' => Helper::getSafeErrorMessage($e)
1741 ]);
1742 }
1743 }
1744
1745 /**
1746 * changeTicketCustomer method will update customer in a ticket
1747 * This method will get ticket id and customer id as parameter, it will replace existing customer id with new
1748 * @param Request $request
1749 * @return array
1750 */
1751 public function changeTicketCustomer(Request $request, $ticket_id)
1752 {
1753 $ticketId = (int) $ticket_id;
1754 $newCustomerId = $request->getSafe('customer', 'intval');
1755
1756 if (!$newCustomerId) {
1757 return $this->sendError(__('Invalid customer selected.', 'fluent-support'));
1758 }
1759
1760 // Rebinding a ticket to another customer exposes that customer's private
1761 // data (profile, custom fields) through the ticket, so it requires the same
1762 // sensitive-data capability that gates the customer routes.
1763 if (!PermissionManager::currentUserCan('fst_sensitive_data')) {
1764 return $this->sendError(__('You do not have permission to change the ticket customer.', 'fluent-support'));
1765 }
1766
1767 try {
1768 $ticket = Ticket::findOrFail($ticketId);
1769
1770 $this->ensureCanAccessTicket($ticket);
1771
1772 $targetCustomer = Customer::where('id', $newCustomerId)
1773 ->where('person_type', 'customer')
1774 ->first();
1775
1776 if (!$targetCustomer) {
1777 return $this->sendError(__('Invalid customer selected.', 'fluent-support'));
1778 }
1779
1780 if ($ticket->customer_id == $newCustomerId) {
1781 return $this->sendError(__('Customer already assigned to this ticket.', 'fluent-support'));
1782 }
1783
1784 $ticket->customer_id = $newCustomerId;
1785 $ticket->save();
1786
1787 return ['message' => __('Customer has been updated', 'fluent-support')];
1788
1789 } catch (\Exception $e) {
1790 return $this->sendError([
1791 'message' => Helper::getSafeErrorMessage($e)
1792 ]);
1793 }
1794 }
1795
1796 /**
1797 * getTicketCustomData method will return the custom data by ticket id
1798 * @param Request $request
1799 * @param $ticket_id
1800 * @return array|array[]
1801 */
1802 public function getTicketCustomData(Request $request, $ticket_id)
1803 {
1804 if (!defined('FLUENTSUPPORTPRO')) {
1805 return [
1806 'custom_data' => [],
1807 'rendered_fields' => []
1808 ];
1809 }
1810
1811 try {
1812 $ticket = Ticket::findOrFail($ticket_id);
1813
1814 $this->ensureCanAccessTicket($ticket);
1815
1816 return [
1817 'custom_data' => (object)$ticket->customData(),
1818 'rendered_fields' => \FluentSupportPro\App\Services\CustomFieldsService::getRenderedPublicFields($ticket->customer, 'admin')
1819 ];
1820 } catch (\Exception $e) {
1821 return $this->sendError([
1822 'message' => Helper::getSafeErrorMessage($e)
1823 ]);
1824 }
1825 }
1826
1827 /**
1828 * syncFluentCrmTags method will synchronize the tags with Fluent CRM by contact id
1829 *This function will get contact id and tags as parameter, get existing tags from crm and updated added/removed tags
1830 * @param Request $request
1831 * @param FluentCRMServices $fluentCRMServices
1832 * @return array
1833 */
1834 public function syncFluentCrmTags(Request $request, FluentCRMServices $fluentCRMServices)
1835 {
1836 $data = [
1837 'contact_id' => $request->getSafe('contact_id', 'intval'),
1838 'tags' => $request->get('tags', null)
1839 ];
1840
1841 // Sanitize tags array if it's an array
1842 if (is_array($data['tags'])) {
1843 $data['tags'] = array_map('intval', $data['tags']);
1844 }
1845
1846 try {
1847 return $fluentCRMServices->syncCrmTags($data);
1848 } catch (\Exception $e) {
1849 return $this->sendError([
1850 'message' => Helper::getSafeErrorMessage($e)
1851 ]);
1852 }
1853 }
1854
1855 /**
1856 * This `syncFluentCrmLists` method will synchronize the lists with Fluent CRM by contact id
1857 * This method will get contact id and lists as parameter, get existing lists from crm and updated added/removed lists
1858 * @param Request $request
1859 * @param FluentCRMServices $fluentCRMServices
1860 * @return array
1861 */
1862
1863 public function syncFluentCrmLists(Request $request, FluentCRMServices $fluentCRMServices)
1864 {
1865 $data = [
1866 'contact_id' => $request->getSafe('contact_id', 'intval'),
1867 'lists' => $request->get('lists', null, [])
1868 ];
1869
1870 // Sanitize lists array if it's an array
1871 if (is_array($data['lists'])) {
1872 $data['lists'] = array_map('intval', $data['lists']);
1873 }
1874
1875 try {
1876 return $fluentCRMServices->syncCrmLists($data);
1877 } catch (\Exception $e) {
1878 return $this->sendError([
1879 'message' => Helper::getSafeErrorMessage($e)
1880 ]);
1881 }
1882 }
1883
1884 /**
1885 * Get ticket essentials data based on the provided types.
1886 *
1887 * @param \Illuminate\Http\Request $request
1888 * @return array The ticket essentials data.
1889 */
1890 public function getTicketEssentials(Request $request)
1891 {
1892 $type = $request->getSafe('type', 'sanitize_text_field');
1893
1894 return TicketHelper::getTicketEssentials($type);
1895 }
1896
1897 public function fetchLabelSearch()
1898 {
1899 try {
1900 $agent_id = get_current_user_id();
1901 return TicketHelper::getLabelSearch($agent_id);
1902 } catch (\Exception $e) {
1903 return $this->sendError([
1904 'message' => Helper::getSafeErrorMessage($e)
1905 ]);
1906 }
1907 }
1908
1909 public function storeOrUpdateLabelSearch(Request $request)
1910 {
1911 try {
1912 $agent_id = get_current_user_id();
1913 $searchData = $request->get('query', null, []);
1914 if (is_array($searchData)) {
1915 $searchData = map_deep($searchData, 'sanitize_text_field');
1916 }
1917 $filterType = Arr::get($searchData, 'filter_type', '');
1918 if ($filterType == 'advanced') {
1919 return TicketHelper::saveSearchLabel($agent_id, $searchData, $filterType);
1920 }
1921
1922 return [
1923 'message' => __('Invalid filter type.', 'fluent-support'),
1924 ];
1925
1926 } catch (\Exception $e) {
1927 return $this->sendError([
1928 'message' => Helper::getSafeErrorMessage($e)
1929 ]);
1930 }
1931 }
1932
1933 public function deleteLabelSearch(Request $request, $search_id)
1934 {
1935 try {
1936 $agent_id = get_current_user_id();
1937 return TicketHelper::deleteSavedSearch($search_id);
1938 } catch (\Exception $e) {
1939 return $this->sendError([
1940 'message' => Helper::getSafeErrorMessage($e)
1941 ]);
1942 }
1943 }
1944 }
1945