PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 2.4.5
Fluent Support – Helpdesk & Customer Support Ticket System v2.4.5
2.4.5 2.4.0 2.3.2 2.3.1 2.3.0 2.2.1 2.2.0 trunk 1.10.0 1.10.1 1.10.2 1.10.3 1.10.4 1.10.5 1.4.0 1.4.1 1.4.2 1.4.5 1.4.6 1.4.7 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 All 69 releases
fluent-support / app / Http / Controllers / TicketController.php

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

1,947 lines 69.5 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\Product;
15 use FluentSupport\App\Models\Ticket;
16 use FluentSupport\App\Services\FluentCRMServices;
17 use FluentSupport\App\Services\Helper;
18 use FluentSupport\App\Services\ProfileInfoService;
19 use FluentSupport\App\Services\TicketHelper;
20 use FluentSupport\App\Services\TicketQueryService;
21 use FluentSupport\App\Modules\PermissionManager;
22 use FluentSupport\App\Services\Tickets\AgentTicketAccess;
23 use FluentSupport\App\Services\Tickets\ResponseService;
24 use FluentSupport\App\Models\AgentGroup;
25 use FluentSupport\App\Services\Tickets\TicketService;
26 use FluentSupport\App\Services\Integrations\FluentBooking\FluentBookingService;
27
28 /**
29 * Agent-side REST endpoints for tickets.
30 */
31 class TicketController extends Controller
32 {
33 /**
34 * Most tickets one bulk request may change. Above the largest list page
35 * size (150), so a full page always fits.
36 */
37 const MAX_BULK_TICKETS = 200;
38
39 /**
40 * Current user's profile, permissions and optionally the portal settings.
41 *
42 * @param Request $request
43 * @return array
44 */
45 public function me(Request $request)
46 {
47 $user = wp_get_current_user();
48 $requestData = $request->all();
49 $sanitizedRequest = [];
50 foreach ($requestData as $key => $value) {
51 if (is_array($value)) {
52 $sanitizedRequest[$key] = map_deep($value, 'sanitize_text_field');
53 } else {
54 $sanitizedRequest[$key] = sanitize_text_field($value);
55 }
56 }
57
58 $settings = [
59 'user_id' => $user->ID,
60 'email' => $user->user_email,
61 'person' => Helper::getAgentByUserId($user->ID),
62 'permissions' => PermissionManager::currentUserPermissions(),
63 'request' => $sanitizedRequest
64 ];
65
66 if ($request->getSafe('with_portal_settings', 'sanitize_text_field')) {
67 $mimeHeadings = Helper::getAcceptedMimeHeadings();
68 $businessSettings = (new \FluentSupport\App\Services\EmailNotification\Settings())->globalBusinessSettings();
69 $maxFileSize = absint($businessSettings['max_file_size']);
70
71 $portalSettings = [
72 'support_products' => \FluentSupport\App\Models\Product::select(['id', 'title'])->orderedByTitle()->get(),
73 'customer_ticket_priorities' => Helper::customerTicketPriorities(),
74 'has_file_upload' => !!Helper::ticketAcceptedFileMiles(),
75 'has_rich_text_editor' => true,
76 'max_file_size' => $maxFileSize,
77 'mime_headings' => $mimeHeadings
78 ];
79
80 $portalSettings = apply_filters('fluent_support/customer_portal_vars', $portalSettings);
81 $settings['portal_settings'] = $portalSettings;
82 }
83
84 return $settings;
85 }
86
87 /**
88 * @param Request $request
89 * @return array
90 */
91 public function index(Request $request)
92 {
93 $filterType = $request->getSafe('filter_type', 'sanitize_text_field', 'simple');
94
95 $queryArgs = [
96 'with' => [],
97 'filter_type' => $filterType,
98 'sort_by' => sanitize_sql_orderby($request->getSafe('order_by', 'sanitize_text_field', 'id')),
99 'sort_type' => $request->getSafe('order_type', 'sanitize_text_field', 'DESC') == 'DESC' ? 'DESC' : 'ASC',
100 ];
101
102 if ($filterType == 'advanced') {
103 $advanced_filters = map_deep($request->get('advanced_filters', []), 'sanitize_text_field');
104 $queryArgs['filters_groups_raw'] = json_decode($advanced_filters, true);
105 } else {
106 $queryArgs['simple_filters'] = map_deep($request->get('filters', []), 'sanitize_text_field');
107 $queryArgs['search'] = trim($request->getSafe('search', 'sanitize_text_field', ''));
108
109 if ($customerId = $request->getSafe('customer_id', 'intval')) {
110 $queryArgs['customer_id'] = $customerId;
111 }
112 }
113
114 $ticketsModel = (new TicketQueryService($queryArgs))->getModel();
115
116 $ticketsModel = $ticketsModel->with([
117 'customer' => function ($query) {
118 $query->select(['first_name', 'last_name', 'email', 'id', 'avatar']);
119 }, 'agent' => function ($query) {
120 $query->select(['first_name', 'last_name', 'email', 'avatar', 'id']);
121 },
122 'mailbox',
123 'product',
124 'tags',
125 'preview_response' => function ($query) {
126 $query->latest('id');
127 }
128 ]);
129
130 do_action_ref_array('fluent_support/tickets_query_by_permission_ref', [&$ticketsModel, false]);
131
132 $tickets = $ticketsModel->paginate();
133
134 $perPage = $request->getSafe('per_page', 'intval', 15);
135
136 // Small pages come from the board view, which shows live activity.
137 if ($perPage < 15) {
138 TicketHelper::loadBatchLiveActivities($tickets);
139 }
140
141 return [
142 'tickets' => $tickets
143 ];
144 }
145
146 /**
147 * Create a ticket, creating the customer and optionally a WP user first.
148 *
149 * @param TicketRequest $request
150 * @return array
151 */
152 public function createTicket(TicketRequest $request)
153 {
154 try {
155 $data = $request->sanitize();
156 $ticketData = $data['ticket'];
157 $maybeNewCustomer = Arr::get($data, 'newCustomer', []);
158
159 if (!empty($data['attachments'])) {
160 $ticketData['attachments'] = $data['attachments'];
161 }
162
163 if (empty($ticketData['customer_id'])) {
164 $createdUserId = false;
165
166 if (Arr::get($ticketData, 'create_wp_user') == 'yes' && !empty($maybeNewCustomer['username'])) {
167 if (!username_exists($maybeNewCustomer['username'])) {
168 $authController = new AuthController();
169 $createdUserId = $authController->createUser($maybeNewCustomer);
170 $authController->maybeUpdateUser($createdUserId, $maybeNewCustomer);
171 }
172 }
173
174 $email = Arr::get($maybeNewCustomer, 'email');
175 if (!$email || !is_email($email)) {
176 return $this->sendError([
177 'message' => __('A valid email is required to create a ticket', 'fluent-support')
178 ]);
179 }
180
181 $existingCustomer = Customer::where('email', $email)->first();
182
183 if ($existingCustomer) {
184 $ticketData['customer_id'] = $existingCustomer->id;
185 } else {
186 $customerData = Arr::only($maybeNewCustomer, (new Customer())->getFillable());
187 $customerData['user_id'] = $createdUserId;
188 $customerData = array_filter($customerData);
189
190 $createCustomer = Customer::create($customerData);
191
192 if (!$createCustomer || !$createCustomer->exists) {
193 return $this->sendError([
194 'message' => __('Customer could not be created', 'fluent-support')
195 ]);
196 }
197
198 do_action('fluent_support/customer_created', $createCustomer);
199
200 $ticketData['customer_id'] = $createCustomer->id;
201 }
202 }
203
204 $customer = Customer::findOrFail($ticketData['customer_id']);
205
206 $createdTicket = (new TicketService())->storeTicket($ticketData, $customer);
207
208 return [
209 'message' => __('Ticket has been created successfully', 'fluent-support'),
210 'ticket' => $createdTicket
211 ];
212 } catch (\Exception $e) {
213 return $this->sendError([
214 'message' => Helper::getSafeErrorMessage($e)
215 ]);
216 }
217 }
218
219 /**
220 * @param Request $request
221 * @param int $ticket_id
222 * @return array
223 */
224 public function getTicket(Request $request, $ticket_id)
225 {
226 try {
227 $agent = Helper::getAgentByUserId();
228
229 $ticketWith = $request->get('with');
230 $ticketWith = is_array($ticketWith) ? map_deep($ticketWith, 'sanitize_text_field') : null;
231
232 if (!$ticketWith) {
233 $ticketWith = ['customer', 'agent', 'product', 'mailbox', 'tags', 'attachments' => function ($q) {
234 $q->where('status', 'active');
235 }];
236 }
237
238 $ticket = Ticket::with($ticketWith)->findOrFail($ticket_id);
239
240 $ticket->load(['responses' => function ($q) {
241 $q->with([
242 'person',
243 'ccinfo',
244 'attachments' => function ($q) {
245 $q->where('status', 'active');
246 }
247 ]);
248 }]);
249
250 $restrictedBusinessBoxes = PermissionManager::getRestrictedMailboxIds();
251
252 if (in_array($ticket->mailbox_id, $restrictedBusinessBoxes)) {
253 throw new \Exception(esc_html__('Ticket cannot be fetched due to restricted mailbox', 'fluent-support'), 403);
254 }
255
256 $this->ensureCanAccessTicket($ticket);
257
258 if ($ticket->customer) {
259 $customFieldsKey = Helper::getCustomRegistrationFieldKeys();
260 $ticket->customer->custom_field_keys = $customFieldsKey;
261
262 if ($ticket->customer->user_id) {
263 $customFieldKeysUsingHook = apply_filters('fluent_support/custom_registration_form_fields_key', []);
264 if (!empty($customFieldKeysUsingHook)) {
265 $allUserMeta = get_user_meta($ticket->customer->user_id);
266 foreach ($customFieldKeysUsingHook as $key) {
267 if (isset($allUserMeta[$key][0]) && $allUserMeta[$key][0]) {
268 $ticket->customer->$key = $allUserMeta[$key][0];
269 }
270 }
271 }
272 }
273
274 $ticket->customer->profile_edit_url = $ticket->customer->getUserProfileEditUrl();
275 }
276
277 if ($ticket->status == 'closed') {
278 $ticket->load('closed_by_person');
279 }
280
281 if (defined('FLUENTSUPPORTPRO_PLUGIN_VERSION') && Helper::isAgentFeedbackEnabled()) {
282 $responseIds = $ticket->responses->pluck('id')->toArray();
283 $feedbacks = Meta::where('object_type', 'conversation_meta')
284 ->where('key', 'agent_feedback_ratings')
285 ->whereIn('object_id', $responseIds)
286 ->get()
287 ->keyBy('object_id');
288
289 foreach ($ticket->responses as $response) {
290 if ($feedbacks->has($response->id)) {
291 $response->agent_feedback = $feedbacks->get($response->id)->value;
292 }
293 }
294 }
295
296 $contents = ['ticket' => $ticket->content];
297 foreach ($ticket->responses as $response) {
298 $contents['response_' . $response->id] = $response->content;
299 }
300
301 $contents = Helper::refreshSignedAttachmentUrlsInContents($contents, $ticket->id);
302 $ticket->content = $contents['ticket'];
303
304 foreach ($ticket->responses as $response) {
305 $responseKey = 'response_' . $response->id;
306 if (isset($contents[$responseKey])) {
307 $response->content = $contents[$responseKey];
308 }
309
310 $responseContent = apply_filters(
311 'fluent_support/response_content_before_render',
312 $response->content,
313 $response,
314 $ticket
315 );
316
317 if ($response->conversation_type === 'note') {
318 $responseContent = wpautop($responseContent, false);
319 } else {
320 $responseContent = links_add_target(make_clickable(wpautop($responseContent, false)));
321 }
322
323
324 $response->content = apply_filters(
325 'fluent_support/response_content_after_render',
326 $responseContent,
327 $response,
328 $ticket
329 );
330
331 if (!empty($response->ccinfo)) {
332 $val = Helper::safeUnserialize($response->ccinfo->value);
333 if (isset($val['cc_email']) && !empty($val['cc_email'])) {
334 $response->cc_info = $val['cc_email'];
335 } else {
336 $response->cc_info = '';
337 }
338 } else {
339 $response->cc_info = '';
340 }
341 }
342
343 $ticketContent = apply_filters(
344 'fluent_support/ticket_content_before_render',
345 $ticket->content,
346 $ticket
347 );
348
349 $ticketContent = links_add_target(make_clickable(wpautop($ticketContent, false)));
350
351 $ticket->content = apply_filters(
352 'fluent_support/ticket_content_after_render',
353 $ticketContent,
354 $ticket
355 );
356
357 $ticket->live_activity = TicketHelper::getActivity($ticket->id, $agent->id);
358
359 $ccInfo = $ticket->getSettingsValue('cc_email', []);
360 $ticket->carbon_copy = !empty($ccInfo) ? implode(', ', $ccInfo) : '';
361
362 if (defined('FLUENTSUPPORTPRO')) {
363 $ticket->custom_fields = $ticket->customData('admin', true);
364 }
365
366 // Set when an agent opened the ticket on the customer's behalf.
367 if ($ticket->created_by) {
368 $ticket->load('created_by_person');
369 if ($ticket->created_by_person) {
370 $isAgentInitiated = $ticket instanceof Ticket && $ticket->isAgentInitiated();
371 $ticket->created_by_agent = [
372 'id' => $ticket->created_by_person->id,
373 'full_name' => $ticket->created_by_person->full_name,
374 'agent_initiated' => $isAgentInitiated,
375 ];
376 }
377 }
378
379 $data = [
380 'ticket' => $ticket,
381 'responses' => $ticket->responses,
382 'agent_id' => $agent->id
383 ];
384
385 if (defined('FLUENTSUPPORTPRO') && $ticket->watchers) {
386 $data['watchers'] = TicketHelper::getWatchers($ticket->watchers);
387 }
388
389 $withData = $request->get('with_data', null);
390 $withDataArray = is_array($withData) ? map_deep($withData, 'sanitize_text_field') : [];
391
392 if (defined('FLUENTCRM') && in_array('fluentcrm_profile', $withDataArray)) {
393 $data['fluentcrm_profile'] = Helper::getFluentCrmContactData($ticket->customer);
394 }
395
396 return $data;
397 } catch (\Exception $e) {
398 return $this->sendError([
399 'message' => Helper::getSafeErrorMessage($e)
400 ]);
401 }
402 }
403
404 public function getMentionableAgents(Request $request, $ticket_id)
405 {
406 try {
407 $ticket = Ticket::findOrFail($ticket_id);
408
409 if (in_array($ticket->mailbox_id, PermissionManager::getRestrictedMailboxIds())) {
410 throw new \Exception(esc_html__('Ticket cannot be fetched due to restricted mailbox', 'fluent-support'), 403);
411 }
412
413 $this->ensureCanAccessTicket($ticket);
414
415 $search = trim($request->getSafe('search', 'sanitize_text_field', ''));
416 $limit = min(max(absint($request->getSafe('limit', 'intval', 20)), 1), 50);
417
418 return [
419 'agents' => $this->getMentionableAgentList($ticket, $search, $limit)
420 ];
421 } catch (\Exception $e) {
422 return $this->sendError([
423 'message' => Helper::getSafeErrorMessage($e)
424 ]);
425 }
426 }
427
428 protected function getMentionableAgentList($ticket, $search, $limit)
429 {
430 $allAgents = Agent::select(['id', 'first_name', 'last_name', 'email', 'user_id'])
431 ->mentionBy($search)
432 ->orderBy('first_name')
433 ->orderBy('last_name')
434 ->get();
435
436 if ($allAgents->isEmpty()) {
437 return [];
438 }
439
440 $restrictions = $this->getAgentRestrictionsMap($allAgents->pluck('id')->all());
441 $ticketAccess = new AgentTicketAccess();
442 $results = [];
443
444 foreach ($allAgents as $agent) {
445 if (!$ticketAccess->canAccess($agent, $ticket, $restrictions[$agent->id] ?? [])) {
446 continue;
447 }
448
449 $results[] = [
450 'id' => strval($agent->id),
451 'first_name' => $agent->first_name,
452 'last_name' => $agent->last_name,
453 'email' => $agent->email,
454 ];
455
456 if (count($results) >= $limit) {
457 break;
458 }
459 }
460
461 return $results;
462 }
463
464 protected function getAgentRestrictionsMap(array $agentIds)
465 {
466 if (!$agentIds) {
467 return [];
468 }
469
470 $metas = Meta::where('object_type', 'person_meta')
471 ->where('key', 'agent_restrictions')
472 ->whereIn('object_id', $agentIds)
473 ->get();
474
475 $restrictions = [];
476 foreach ($metas as $meta) {
477 $restrictions[$meta->object_id] = Helper::safeUnserialize($meta->value) ?: [];
478 }
479
480 return $restrictions;
481 }
482
483 /**
484 * @param TicketResponseRequest $request
485 * @param int $ticket_id
486 * @return array
487 * @throws \FluentSupport\Framework\Validator\ValidationException
488 */
489 public function createResponse(TicketResponseRequest $request, $ticket_id)
490 {
491 $data = $request->sanitize();
492
493 try {
494 $convoType = Arr::get($data, 'conversation_type', 'response');
495 $isDraft = $convoType === 'draft_response';
496
497 // close_ticket always needs manage rights, even on an otherwise-permitted draft save.
498 if (!$isDraft || Arr::get($data, 'close_ticket') == 'yes') {
499 $this->ensureCanManageTickets();
500 }
501
502 $agent = Helper::getAgentByUserId();
503
504 if (!$agent) {
505 return $this->sendError([
506 'message' => __('Sorry, You do not have permission. Please add yourself as support agent first', 'fluent-support')
507 ]);
508 }
509
510 $ticket = Ticket::findOrFail($ticket_id);
511
512 $this->ensureCanAccessTicket($ticket);
513
514 if ($convoType === 'response' && $request->getSafe('force_send', 'sanitize_text_field') !== 'yes') {
515 $lastSeenId = $request->get('last_conversation_id');
516 $newerResponses = $lastSeenId === null || $lastSeenId === '' ? [] : $this->responsesAddedSince($ticket, (int) $lastSeenId, $agent);
517 if ($newerResponses) {
518 return $this->sendError([
519 'code' => 'fs_newer_responses',
520 'message' => __('Someone replied to this ticket while you were writing.', 'fluent-support'),
521 'new_responses' => $newerResponses
522 ], 409);
523 }
524 }
525
526 $responseData = (new ResponseService())->createResponse($data, $agent, $ticket);
527
528 $responseData['response']->content = Helper::refreshSignedAttachmentUrls($responseData['response']->content, $ticket->id);
529 $responseData['response']->load([
530 'attachments' => function ($q) {
531 $q->where('status', 'active');
532 }
533 ]);
534 $responseData['response']->content = wpautop($responseData['response']->content, false);
535
536 return [
537 'message' => __('Response has been added', 'fluent-support'),
538 'response' => $responseData['response'],
539 'ticket' => $responseData['ticket'],
540 'update_data' => $responseData['update_data']
541 ];
542 } catch (\Exception $e) {
543 return $this->sendError([
544 'message' => Helper::getSafeErrorMessage($e)
545 ]);
546 }
547 }
548
549 public function getFluentBookingEventTypes()
550 {
551 try {
552 // All FluentBooking endpoints need manage permission. View-only agents can't book meetings.
553 $this->ensureCanManageTickets();
554
555 $service = new FluentBookingService();
556 $eventTypes = $service->getEventTypes();
557
558 return [
559 'status' => $service->getStatus($eventTypes),
560 'event_types' => $eventTypes
561 ];
562 } catch (\Exception $e) {
563 return $this->sendError([
564 'message' => Helper::getSafeErrorMessage($e)
565 ]);
566 }
567 }
568
569 public function createFluentBookingLink(Request $request, $ticket_id)
570 {
571 try {
572 $this->ensureCanManageTickets();
573
574 $ticket = Ticket::with('customer')->findOrFail($ticket_id);
575
576 $this->ensureCanAccessTicket($ticket);
577
578 $eventId = $request->getSafe('event_type_id', 'intval');
579
580 if (!$eventId) {
581 throw new \Exception(esc_html__('Please select a FluentBooking event type.', 'fluent-support'));
582 }
583
584 return (new FluentBookingService())->createBookingLink(
585 $ticket,
586 $eventId,
587 $request->getSafe('message', 'wp_kses_post'),
588 $request->get('selected_slots', []),
589 $request->getSafe('timezone', 'sanitize_text_field', '')
590 );
591 } catch (\Exception $e) {
592 return $this->sendError([
593 'message' => Helper::getSafeErrorMessage($e)
594 ]);
595 }
596 }
597
598 public function getFluentBookingAvailability(Request $request, $ticket_id)
599 {
600 try {
601 $this->ensureCanManageTickets();
602
603 $ticket = Ticket::with('customer')->findOrFail($ticket_id);
604
605 $this->ensureCanAccessTicket($ticket);
606
607 $eventId = $request->getSafe('event_type_id', 'intval');
608
609 if (!$eventId) {
610 throw new \Exception(esc_html__('Please select a FluentBooking event type.', 'fluent-support'));
611 }
612
613 return [
614 'availability' => (new FluentBookingService())->getAvailabilitySlots(
615 $eventId,
616 $request->getSafe('range', 'sanitize_key', 'next_3_days'),
617 $request->getSafe('timezone', 'sanitize_text_field'),
618 $request->getSafe('duration', 'intval'),
619 $ticket,
620 $request->get('selected_dates', []),
621 $request->getSafe('calendar_month', 'sanitize_text_field', '')
622 )
623 ];
624 } catch (\Exception $e) {
625 return $this->sendError([
626 'message' => Helper::getSafeErrorMessage($e)
627 ]);
628 }
629 }
630
631 public function getFluentBookingMeetings($ticket_id)
632 {
633 try {
634 $this->ensureCanManageTickets();
635
636 $ticket = Ticket::with('customer')->findOrFail($ticket_id);
637
638 $this->ensureCanAccessTicket($ticket);
639
640 return [
641 'meetings' => (new FluentBookingService())->getTicketMeetings($ticket)
642 ];
643 } catch (\Exception $e) {
644 return $this->sendError([
645 'message' => Helper::getSafeErrorMessage($e)
646 ]);
647 }
648 }
649
650 /**
651 * @param TicketResponseRequest $request
652 * @param int $ticket_id
653 * @return array
654 * @throws \FluentSupport\Framework\Validator\ValidationException
655 */
656 public function createOrUpdatDraft(TicketResponseRequest $request, $ticket_id)
657 {
658 $data = $request->sanitize();
659
660 try {
661 $agent = Helper::getAgentByUserId();
662
663 if (!$agent) {
664 return $this->sendError([
665 'message' => __('Sorry, You do not have permission. Please add yourself as support agent first', 'fluent-support')
666 ]);
667 }
668
669 $ticket = Ticket::findOrFail($ticket_id);
670
671 $this->ensureCanAccessTicket($ticket);
672
673 $key = 'ticket_no_' . $ticket_id . '_agent_id_' . $agent->id . '_response_draft';
674 $previousDraft = Meta::where('key', $key)->first();
675
676 if ($data['draftID'] || $previousDraft) {
677 Meta::where('key', $key)->update([
678 'value' => maybe_serialize($data)
679 ]);
680
681 return [
682 'message' => __('Draft has been updated', 'fluent-support'),
683 'draftID' => $data['draftID']
684 ];
685 }
686
687 $draftID = Meta::insertGetId([
688 'object_type' => '_fs_auto_draft',
689 'object_id' => $ticket_id,
690 'key' => $key,
691 'value' => maybe_serialize($data)
692 ]);
693
694 return [
695 'message' => __('Draft has been added', 'fluent-support'),
696 'draftID' => $draftID
697 ];
698 } catch (\Exception $e) {
699 return $this->sendError([
700 'message' => Helper::getSafeErrorMessage($e)
701 ]);
702 }
703 }
704
705 public function getDraft($ticket_id)
706 {
707 try {
708 $agent = Helper::getAgentByUserId();
709
710 if (!$agent) {
711 return $this->sendError([
712 'message' => __('Sorry, You do not have permission. Please add yourself as support agent first', 'fluent-support')
713 ]);
714 }
715
716 $ticket = Ticket::findOrFail($ticket_id);
717
718 $this->ensureCanAccessTicket($ticket);
719
720 $key = 'ticket_no_' . $ticket_id . '_agent_id_' . $agent->id . '_response_draft';
721
722 $draft = Meta::where([
723 'object_type' => '_fs_auto_draft',
724 'key' => $key,
725 ])->first();
726
727 if ($draft) {
728 $draft->value = Helper::safeUnserialize($draft->value);
729 }
730
731 return [
732 'draft' => $draft
733 ];
734 } catch (\Exception $e) {
735 return $this->sendError([
736 'message' => Helper::getSafeErrorMessage($e)
737 ]);
738 }
739 }
740
741 public function deleteDraft($draft_id)
742 {
743 $draft_id = intval($draft_id);
744
745 try {
746 $agent = Helper::getAgentByUserId();
747
748 if (!$agent) {
749 return $this->sendError([
750 'message' => __('You do not have permission to perform this action', 'fluent-support'),
751 ]);
752 }
753
754 $draft = Meta::where('id', $draft_id)
755 ->where('object_type', '_fs_auto_draft')
756 ->first();
757
758 if (!$draft) {
759 return $this->sendError([
760 'message' => __('Draft not found', 'fluent-support'),
761 ]);
762 }
763
764 // Managers can delete other agents' drafts, but only on tickets they can access.
765 $ticket = Ticket::findOrFail($draft->object_id);
766
767 $this->ensureCanAccessTicket($ticket);
768
769 // The draft key embeds the owning agent's id.
770 $isOwnDraft = strpos($draft->key, '_agent_id_' . $agent->id . '_') !== false;
771
772 if (!$isOwnDraft && !PermissionManager::canManageTickets()) {
773 return $this->sendError([
774 'message' => __('You do not have permission to delete this draft', 'fluent-support'),
775 ]);
776 }
777
778 $draft->delete();
779
780 return [
781 'message' => __('Discard draft successfully', 'fluent-support'),
782 ];
783 } catch (\Exception $e) {
784 return $this->sendError([
785 'message' => Helper::getSafeErrorMessage($e)
786 ]);
787 }
788 }
789
790 /**
791 * The customer's other tickets, plus extra profile widgets on request.
792 *
793 * @param Request $request
794 * @param int $ticket_id
795 * @return array
796 */
797 public function getTicketWidgets(Request $request, $ticket_id)
798 {
799 try {
800 $ticket = Ticket::with('customer')->findOrFail($ticket_id);
801
802 $this->ensureCanAccessTicket($ticket);
803
804 $perPage = max(1, absint(apply_filters('fluent_support/previous_ticket_widgets_limit', 5)));
805 $page = max(1, absint($request->get('page', 1)));
806 $offset = ($page - 1) * $perPage;
807
808 $baseQuery = Ticket::where('id', '!=', $ticket_id)
809 ->where('customer_id', $ticket->customer_id);
810
811 (new AgentTicketAccess())->applyAccessScope($baseQuery);
812
813 $total = $baseQuery->count();
814
815 $otherTickets = (clone $baseQuery)
816 ->select(['id', 'title', 'status', 'created_at'])
817 ->latest('id')
818 ->limit($perPage)
819 ->offset($offset)
820 ->get();
821
822 $response = [
823 'other_tickets' => $otherTickets,
824 'other_tickets_total' => $total,
825 'other_tickets_more' => ($offset + $perPage) < $total,
826 ];
827
828 if (in_array('extra_widgets', $request->get('with', []))) {
829 $response['extra_widgets'] = ProfileInfoService::getProfileExtraWidgets($ticket->customer);
830 }
831
832 return $response;
833 } catch (\Exception $e) {
834 return $this->sendError([
835 'message' => Helper::getSafeErrorMessage($e)
836 ]);
837 }
838 }
839
840 /**
841 * @param Request $request
842 * @param int $ticket_id
843 * @return array
844 */
845 public function updateTicketProperty(Request $request, $ticket_id)
846 {
847 try {
848 $assigner = Helper::getAgentByUserId();
849 $ticket = Ticket::findOrFail($ticket_id);
850
851 $this->ensureCanAccessTicket($ticket);
852
853 $propName = $request->getSafe('prop_name', 'sanitize_text_field');
854 $propValue = $request->getSafe('prop_value', 'sanitize_text_field');
855
856 // prop_name is assigned straight onto the model, so it must be allowlisted.
857 if (!in_array($propName, $this->updatableTicketProperties(), true)) {
858 throw new \Exception(esc_html__('This ticket property cannot be updated.', 'fluent-support'), 403);
859 }
860
861 $propValue = $this->sanitizeTicketProperty($ticket, $propName, $propValue);
862
863 $prevValue = $ticket->{$propName};
864
865 if ($propName && $propValue !== null && $prevValue != $propValue) {
866 $ticket->{$propName} = $propValue;
867 $ticket->save();
868
869 // Logged like the close/reopen flows do.
870 if ($propName === 'status') {
871 $statuses = Helper::ticketStatuses();
872 $fromLabel = isset($statuses[$prevValue]) ? $statuses[$prevValue] : $prevValue;
873 $toLabel = isset($statuses[$propValue]) ? $statuses[$propValue] : $propValue;
874
875 $internalNote = sprintf(
876 /* translators: 1: previous status, 2: new status */
877 __('Ticket status changed from %1$s to %2$s', 'fluent-support'),
878 esc_html($fromLabel),
879 esc_html($toLabel)
880 );
881
882 Conversation::create([
883 'ticket_id' => $ticket->id,
884 'person_id' => $assigner->id,
885 'conversation_type' => 'internal_info',
886 'content' => $internalNote
887 ]);
888 }
889 }
890
891 $updateData = [];
892
893 if ($propName == 'product_id') {
894 $ticket->load('product');
895 $updateData['product'] = $ticket->product;
896 } else if ($propName == 'agent_id') {
897 $previousAgentId = (int) $prevValue;
898 $ticket->load('agent');
899 $updateData['agent'] = $ticket->agent;
900 $updateData['assigner'] = (new TicketService())->onAgentChange($ticket, $assigner);
901 if ($prevValue != $ticket->{$propName}) {
902 do_action('fluent_support/agent_assigned_to_ticket', $ticket->agent, $ticket, $assigner, $previousAgentId);
903 }
904 }
905
906 $message = sprintf(
907 /* translators: %s: The name of the property that was updated */
908 __('%s has been updated', 'fluent-support'),
909 esc_html(str_replace('_', ' ', ucwords((string) $propName)))
910 );
911
912 return [
913 'message' => $message,
914 'update_data' => $updateData
915 ];
916 } catch (\Exception $e) {
917 return $this->sendError([
918 'message' => Helper::getSafeErrorMessage($e)
919 ]);
920 }
921 }
922
923 /**
924 * Replies by anyone else (the customer or another agent) added after the
925 * conversation the agent last saw. The admin reply box sends that id so a
926 * reply written against an outdated thread is held back; callers that send
927 * no id (MCP, API, bulk reply) are never checked.
928 *
929 * @param Ticket $ticket
930 * @param int $lastSeenId
931 * @param \FluentSupport\App\Models\Agent $agent
932 * @return array
933 */
934 protected function responsesAddedSince(Ticket $ticket, $lastSeenId, $agent)
935 {
936 if ($lastSeenId < 0) {
937 return [];
938 }
939
940 $newer = Conversation::where('ticket_id', $ticket->id)
941 ->where('id', '>', $lastSeenId)
942 ->where('conversation_type', 'response')
943 ->where('person_id', '!=', $agent->id)
944 ->with(['person' => function ($query) {
945 $query->select(['id', 'first_name', 'last_name', 'person_type']);
946 }])
947 ->orderBy('id', 'ASC')
948 ->limit(10)
949 ->get();
950
951 return $newer->map(function ($conversation) {
952 $person = $conversation->person;
953 return [
954 'id' => (int) $conversation->id,
955 'person_name' => $person ? trim($person->first_name . ' ' . $person->last_name) : '',
956 'person_type' => $person ? $person->person_type : '',
957 'created_at' => (string) $conversation->created_at,
958 ];
959 })->all();
960 }
961
962 /**
963 * The only ticket columns that may be changed through updateTicketProperty.
964 * This mirrors exactly what the admin UI edits here (agent, title, product,
965 * status and the two priority fields). Ownership, audit, public-identifier
966 * and other sensitive columns are intentionally absent and must go through
967 * their dedicated workflows.
968 *
969 * mailbox_id is deliberately NOT listed: moving a ticket between mailboxes
970 * has to fire fluent_support/before_move_tickets and
971 * fluent_support/tickets_moved, which only MailBoxService::moveTickets()
972 * does. Assigning the column here would move the ticket silently, so the
973 * mailbox switcher posts to mailboxes/{id}/move_tickets instead.
974 *
975 * @return array
976 */
977 protected function updatableTicketProperties()
978 {
979 return [
980 'agent_id',
981 'title',
982 'product_id',
983 'status',
984 'priority',
985 'client_priority',
986 ];
987 }
988
989 /**
990 * Validate a property update against its allowed values and the
991 * capability the UI requires for it.
992 *
993 * @param Ticket $ticket
994 * @param string $propName Already confirmed to be in the allowlist.
995 * @param string $propValue Raw (text-sanitized) value from the request.
996 * @return mixed Normalized value ready to assign to the model.
997 * @throws \Exception When the value is invalid or the caller lacks permission.
998 */
999 protected function sanitizeTicketProperty(Ticket $ticket, $propName, $propValue)
1000 {
1001 switch ($propName) {
1002 case 'title':
1003 $propValue = trim(sanitize_text_field($propValue));
1004 if ($propValue === '') {
1005 throw new \Exception(esc_html__('Ticket title cannot be empty.', 'fluent-support'), 422);
1006 }
1007 return $propValue;
1008
1009 case 'status':
1010 // The status dropdown in ViewTicket.vue submits the group key from
1011 // changeable_ticket_statuses and hides empty groups. Match that.
1012 $allowedStatuses = [];
1013 foreach (Helper::changeableTicketStatuses() as $statusKey => $statusGroup) {
1014 if (!empty($statusGroup)) {
1015 $allowedStatuses[] = $statusKey;
1016 }
1017 }
1018
1019 if (!in_array($propValue, $allowedStatuses, true)) {
1020 throw new \Exception(esc_html__('Invalid ticket status.', 'fluent-support'), 422);
1021 }
1022
1023 // Closing and reopening need TicketService's side effects and hooks.
1024 // Use closeTicket() / reOpenTicket().
1025 if ($propValue === 'closed' || $ticket->status === 'closed') {
1026 throw new \Exception(esc_html__('Closing or reopening a ticket must use the dedicated close and re-open actions.', 'fluent-support'), 422);
1027 }
1028
1029 return $propValue;
1030
1031 case 'priority':
1032 if (!array_key_exists($propValue, Helper::adminTicketPriorities())) {
1033 throw new \Exception(esc_html__('Invalid ticket priority.', 'fluent-support'), 422);
1034 }
1035 return $propValue;
1036
1037 case 'client_priority':
1038 if (!array_key_exists($propValue, Helper::customerTicketPriorities())) {
1039 throw new \Exception(esc_html__('Invalid client priority.', 'fluent-support'), 422);
1040 }
1041 return $propValue;
1042
1043 case 'product_id':
1044 $productId = (int) $propValue;
1045 if (!$productId || !Product::where('id', $productId)->exists()) {
1046 throw new \Exception(esc_html__('Invalid product.', 'fluent-support'), 422);
1047 }
1048 return $productId;
1049
1050 case 'agent_id':
1051 if (!PermissionManager::currentUserCan('fst_assign_agents')) {
1052 throw new \Exception(esc_html__('Permission denied to assign agent', 'fluent-support'), 403);
1053 }
1054
1055 $agentId = (int) $propValue;
1056 $agent = Agent::findOrFail($agentId);
1057 $restrictedBoxes = (new AgentTicketAccess())->getRestrictedMailboxIds($agent);
1058
1059 if (in_array((int) $ticket->mailbox_id, $restrictedBoxes, true)) {
1060 throw new \Exception(esc_html__('Agent is restricted for this mailbox ticket', 'fluent-support'), 403);
1061 }
1062 return $agentId;
1063 }
1064
1065 // Unreachable after the allowlist check. Fail closed anyway.
1066 throw new \Exception(esc_html__('This ticket property cannot be updated.', 'fluent-support'), 403);
1067 }
1068
1069 /**
1070 * @param Request $request
1071 * @param int $ticket_id
1072 * @return array
1073 */
1074 public function closeTicket(Request $request, $ticket_id)
1075 {
1076 try {
1077 $agent = Helper::getAgentByUserId();
1078 $ticket = Ticket::findOrFail($ticket_id);
1079
1080 $this->ensureCanAccessTicket($ticket);
1081
1082 $closeSilently = $request->getSafe('close_ticket_silently', 'sanitize_text_field');
1083
1084 return [
1085 'message' => __('Ticket has been closed', 'fluent-support'),
1086 'ticket' => (new TicketService())->close($ticket, $agent, '', $closeSilently)
1087 ];
1088 } catch (\Exception $e) {
1089 return $this->sendError([
1090 'message' => Helper::getSafeErrorMessage($e)
1091 ]);
1092 }
1093 }
1094
1095 /**
1096 * @param int $ticket_id
1097 * @return array
1098 */
1099 public function reOpenTicket($ticket_id)
1100 {
1101 try {
1102 $agent = Helper::getAgentByUserId();
1103 $ticket = Ticket::findOrFail($ticket_id);
1104
1105 $this->ensureCanAccessTicket($ticket);
1106
1107 return [
1108 'message' => __('Ticket has been opened again', 'fluent-support'),
1109 'ticket' => (new TicketService())->reopen($ticket, $agent)
1110 ];
1111 } catch (\Exception $e) {
1112 return $this->sendError([
1113 'message' => Helper::getSafeErrorMessage($e)
1114 ]);
1115 }
1116 }
1117
1118 /**
1119 * @param Request $request
1120 * @return array|string[]|void
1121 * @throws \Exception
1122 */
1123 public function doBulkActions(Request $request)
1124 {
1125 try {
1126 $action = $request->getSafe('bulk_action', 'sanitize_text_field');
1127 $ticketIds = array_map('intval', (array) $request->get('ticket_ids', null, []));
1128
1129 if (count($ticketIds) > self::MAX_BULK_TICKETS) {
1130 throw new \Exception(sprintf(
1131 /* translators: %d is the most tickets one bulk action can change */
1132 esc_html__('You can change at most %d tickets at a time.', 'fluent-support'),
1133 (int) self::MAX_BULK_TICKETS
1134 ), 422);
1135 }
1136
1137 $agent = Helper::getAgentByUserId();
1138 $query = Ticket::whereIn('id', $ticketIds);
1139
1140 // Same access rules as ensureCanAccessTicket() on single-ticket routes.
1141 (new AgentTicketAccess())->applyAccessScope($query, $agent);
1142
1143 if ($action == 'close_tickets') {
1144 $tickets = $query->get();
1145 $tickets->each(function ($ticket) use ($agent) {
1146 (new TicketService())->close($ticket, $agent);
1147 });
1148
1149 return [
1150 'message' => sprintf(
1151 /* translators: %d represents the number of closed tickets. */
1152 __('%d tickets have been closed.', 'fluent-support'),
1153 count($tickets)
1154 )
1155 ];
1156 } else if ($action == 'delete_tickets') {
1157 $tickets = $query->get();
1158 $ticketService = new TicketService();
1159
1160 foreach ($tickets as $ticket) {
1161 $ticketService->deleteTicket($ticket, $agent);
1162 }
1163
1164 return [
1165 'message' => sprintf(
1166 /* translators: %d is the number of tickets that were deleted */
1167 __('%d tickets have been deleted', 'fluent-support'),
1168 count($tickets)
1169 )
1170 ];
1171 } else if ($action == 'assign_agent') {
1172 if (!$request->has('agent_id')) {
1173 throw new \Exception(esc_html__('agent_id param is required', 'fluent-support'));
1174 }
1175
1176 $assignAgent = Agent::findOrFail($request->getSafe('agent_id', 'intval'));
1177
1178 $query->where(function ($q) use ($assignAgent) {
1179 $q->where('agent_id', '!=', $assignAgent->id)
1180 ->orWhereNull('agent_id');
1181 });
1182
1183 $tickets = $query->get();
1184 $assignedCount = 0;
1185 $skippedCount = 0;
1186
1187 $restrictedBoxes = (new AgentTicketAccess())->getRestrictedMailboxIds($assignAgent);
1188
1189 $tickets->each(function ($ticket) use ($assignAgent, $agent, $restrictedBoxes, &$assignedCount, &$skippedCount) {
1190 $previousAgentId = (int) $ticket->agent_id;
1191
1192 if (!empty($ticket->mailbox_id) && in_array((int) $ticket->mailbox_id, $restrictedBoxes, true)) {
1193 $skippedCount++;
1194 return;
1195 }
1196
1197 $ticket->agent_id = $assignAgent->id;
1198 $ticket->save();
1199 $assignedCount++;
1200
1201 do_action('fluent_support/agent_assigned_to_ticket', $assignAgent, $ticket, $agent, $previousAgentId);
1202 });
1203
1204 $assignedMessage = sprintf(
1205 /* translators: %1$d is the number of tickets assigned, %2$s is the agent's name. */
1206 __('%1$d tickets have been assigned to %2$s.', 'fluent-support'),
1207 $assignedCount,
1208 $assignAgent->full_name
1209 );
1210
1211 $skippedMessage = $skippedCount > 0
1212 ? sprintf(
1213 /* translators: %1$d is the number of skipped tickets due to mailbox restrictions. */
1214 __('%1$d tickets were skipped due to mailbox restrictions or already being assigned.', 'fluent-support'),
1215 $skippedCount
1216 )
1217 : '';
1218
1219 return [
1220 'message' => trim($assignedMessage . ' ' . $skippedMessage)
1221 ];
1222 } else if ($action == 'assign_agent_group') {
1223 if (!$request->has('agent_group_id')) {
1224 throw new \Exception(esc_html__('agent_group_id param is required', 'fluent-support'));
1225 }
1226
1227 $groupId = $request->getSafe('agent_group_id', 'intval');
1228 $group = AgentGroup::findOrFail($groupId);
1229
1230 if ($group->agents()->count() === 0) {
1231 throw new \Exception(esc_html__('No agents found in this group', 'fluent-support'));
1232 }
1233
1234 $tickets = $query->get();
1235 $assignedCount = 0;
1236 $skippedCount = 0;
1237 $currentCounts = [];
1238
1239 foreach ($tickets as $ticket) {
1240 $previousAgentId = (int) $ticket->agent_id;
1241 $selectedAgent = $group->getLeastLoadedAgent(
1242 $ticket->mailbox_id, $currentCounts
1243 );
1244
1245 if (!$selectedAgent) {
1246 $skippedCount++;
1247 continue;
1248 }
1249
1250 $ticket->agent_id = $selectedAgent->id;
1251 $ticket->save();
1252 $assignedCount++;
1253 $currentCounts[$selectedAgent->id] = ($currentCounts[$selectedAgent->id] ?? 0) + 1;
1254
1255 as_enqueue_async_action('fluent_support/async_agent_assigned_to_ticket', [
1256 $selectedAgent->id, $ticket->id, $agent->id, $previousAgentId
1257 ], 'fluent-support');
1258 }
1259
1260 return [
1261 'message' => sprintf(
1262 /* translators: %1$d is tickets assigned, %2$d is tickets skipped. */
1263 __('%1$d tickets assigned via agent group. %2$d skipped.', 'fluent-support'),
1264 $assignedCount,
1265 $skippedCount
1266 )
1267 ];
1268 } else if ($action == 'assign_tags') {
1269 $tagIds = $request->get('tag_ids', null);
1270 if (!is_array($tagIds)) {
1271 $tagIds = [];
1272 }
1273 $tags = array_filter(array_map('absint', $tagIds));
1274
1275 $query->get()->each(function ($ticket) use ($tags) {
1276 $ticket->applyTags($tags);
1277 });
1278
1279 return [
1280 'message' => __('Selected tags has been added to tickets', 'fluent-support')
1281 ];
1282 } else if ($action == 'remove_tags') {
1283 $tagIds = $request->get('tag_ids', null);
1284 $tags = array_filter(array_map('absint', is_array($tagIds) ? $tagIds : []));
1285
1286 if (!$tags) {
1287 throw new \Exception(esc_html__('Please select tags to remove', 'fluent-support'), 422);
1288 }
1289
1290 $query->get()->each(function ($ticket) use ($tags) {
1291 $ticket->detachTags($tags);
1292 });
1293
1294 return [
1295 'message' => __('Selected tags have been removed from the tickets', 'fluent-support')
1296 ];
1297 } else if ($action == 'change_priority') {
1298 $priority = $request->getSafe('priority', 'sanitize_text_field');
1299
1300 if (!array_key_exists($priority, Helper::adminTicketPriorities())) {
1301 throw new \Exception(esc_html__('Invalid ticket priority.', 'fluent-support'), 422);
1302 }
1303
1304 return [
1305 'message' => sprintf(
1306 /* translators: %d is the number of tickets whose priority changed */
1307 __('Priority changed on %d tickets.', 'fluent-support'),
1308 $this->bulkSetProperty($query, 'priority', $priority)
1309 )
1310 ];
1311 } else if ($action == 'change_product') {
1312 $productId = $request->getSafe('product_id', 'intval');
1313
1314 if (!$productId || !Product::where('id', $productId)->exists()) {
1315 throw new \Exception(esc_html__('Invalid product.', 'fluent-support'), 422);
1316 }
1317
1318 return [
1319 'message' => sprintf(
1320 /* translators: %d is the number of tickets whose product changed */
1321 __('Product changed on %d tickets.', 'fluent-support'),
1322 $this->bulkSetProperty($query, 'product_id', $productId)
1323 )
1324 ];
1325 } else if ($action == 'reopen_tickets') {
1326 // Only closed tickets reopen; the rest are left as they are.
1327 $tickets = $query->where('status', 'closed')->get();
1328 $ticketService = new TicketService();
1329
1330 foreach ($tickets as $ticket) {
1331 $ticketService->reopen($ticket, $agent);
1332 }
1333
1334 return [
1335 'message' => sprintf(
1336 /* translators: %d is the number of tickets reopened */
1337 __('%d tickets have been reopened.', 'fluent-support'),
1338 count($tickets)
1339 )
1340 ];
1341 }
1342
1343 throw new \Exception(esc_html__('Sorry no action found as available', 'fluent-support'));
1344 } catch (\Exception $e) {
1345 return $this->sendError([
1346 'message' => Helper::getSafeErrorMessage($e)
1347 ]);
1348 }
1349 }
1350
1351 /**
1352 * Set one column on every ticket the bulk query matches, skipping tickets
1353 * that already have the value.
1354 *
1355 * @param \FluentSupport\Framework\Database\Orm\Builder $query
1356 * @param string $column
1357 * @param mixed $value
1358 * @return int Number of tickets changed.
1359 */
1360 protected function bulkSetProperty($query, $column, $value)
1361 {
1362 $changed = 0;
1363
1364 /** @var Ticket $ticket */
1365 foreach ($query->get() as $ticket) {
1366 if ($ticket->{$column} == $value) {
1367 continue;
1368 }
1369 $ticket->{$column} = $value;
1370 $ticket->save();
1371 $changed++;
1372 }
1373
1374 return $changed;
1375 }
1376
1377 /**
1378 * @param int $ticket_id
1379 * @return array
1380 */
1381 public function deleteTicket($ticket_id)
1382 {
1383 try {
1384 $ticket = Ticket::findOrFail($ticket_id);
1385
1386 $this->ensureCanAccessTicket($ticket);
1387
1388 (new TicketService())->deleteTicket($ticket);
1389
1390 return [
1391 'message' => __('Ticket has been deleted successfully', 'fluent-support')
1392 ];
1393 } catch (\Exception $e) {
1394 return $this->sendError([
1395 'message' => Helper::getSafeErrorMessage($e)
1396 ]);
1397 }
1398 }
1399
1400 /**
1401 * @param Request $request
1402 * @return array
1403 * @throws \Exception
1404 */
1405 public function doBulkReplies(Request $request)
1406 {
1407 try {
1408 $requestData = $request->all();
1409 $data = [];
1410 foreach ($requestData as $key => $value) {
1411 if (is_array($value)) {
1412 if ($key === 'ticket_ids') {
1413 $data[$key] = array_map('intval', $value);
1414 } elseif ($key === 'content') {
1415 $data[$key] = wp_kses_post($value);
1416 } else {
1417 $data[$key] = map_deep($value, 'sanitize_text_field');
1418 }
1419 } else {
1420 $data[$key] = sanitize_text_field($value);
1421 }
1422 }
1423
1424 $this->validate($data, [
1425 'content' => 'required',
1426 'ticket_ids' => 'required|array'
1427 ]);
1428
1429 $agent = Helper::getAgentByUserId();
1430 $ticketIds = array_filter($data['ticket_ids'], 'absint');
1431
1432 $query = Ticket::whereIn('id', $ticketIds)->where('status', '!=', 'closed');
1433
1434 (new AgentTicketAccess())->applyAccessScope($query, $agent);
1435
1436 $tickets = $query->get();
1437
1438 if ($tickets->isEmpty()) {
1439 throw new \Exception(esc_html__('Sorry no tickets found based on your filter and bulk actions', 'fluent-support'));
1440 }
1441
1442 $responseData = [
1443 'content' => wp_kses_post(Arr::get($data, 'content', '')),
1444 'conversation_type' => 'response',
1445 'close_ticket' => Arr::get($data, 'close_ticket'),
1446 ];
1447
1448 $attachmentHashes = Arr::get($data, 'attachments', []);
1449 $attachments = false;
1450 if ($attachmentHashes) {
1451 $attachments = Attachment::whereNull('ticket_id')
1452 ->orderBy('id', 'asc')
1453 ->whereIn('file_hash', $attachmentHashes)
1454 ->get();
1455 }
1456
1457 $responseService = new ResponseService();
1458
1459 foreach ($tickets as $ticket) {
1460 if ($attachments) {
1461 $responseData['attachments'] = [];
1462 $attachmentRecords = [];
1463 foreach ($attachments as $attachment) {
1464 $fileHash = bin2hex(random_bytes(16));
1465 $attachmentRecords[] = [
1466 'ticket_id' => $ticket->id,
1467 'file_path' => $attachment->file_path,
1468 'full_url' => $attachment->full_url,
1469 'title' => $attachment->title,
1470 'driver' => $attachment->driver,
1471 'file_size' => $attachment->file_size,
1472 'status' => $attachment->status,
1473 'file_hash' => $fileHash,
1474 ];
1475 $responseData['attachments'][] = $fileHash;
1476 }
1477 if ($attachmentRecords) {
1478 Attachment::insert($attachmentRecords);
1479 }
1480 }
1481
1482 $responseService->createResponse($responseData, $agent, $ticket);
1483 }
1484
1485 return [
1486 'message' => __('Response has been added to the selected tickets', 'fluent-support')
1487 ];
1488 } catch (\Exception $e) {
1489 return $this->sendError([
1490 'message' => Helper::getSafeErrorMessage($e)
1491 ]);
1492 }
1493 }
1494
1495 /**
1496 * @param int $ticket_id
1497 * @param int $response_id
1498 * @return array
1499 */
1500 public function deleteResponse($ticket_id, $response_id)
1501 {
1502 try {
1503 $ticket = Ticket::findOrFail($ticket_id);
1504
1505 if (in_array($ticket->mailbox_id, PermissionManager::getRestrictedMailboxIds())) {
1506 throw new \Exception(esc_html__('Ticket cannot be fetched due to restricted mailbox', 'fluent-support'), 403);
1507 }
1508
1509 $this->ensureCanAccessTicket($ticket);
1510
1511 // Being assigned to the ticket isn't enough to delete a response.
1512 if (!PermissionManager::currentUserCan('fst_delete_tickets')) {
1513 throw new \Exception(
1514 esc_html__('Sorry, you do not have permission to delete this response.', 'fluent-support')
1515 );
1516 }
1517
1518 $response = Conversation::where('id', $response_id)
1519 ->where('ticket_id', $ticket_id)
1520 ->firstOrFail();
1521
1522 $response->delete();
1523 $response->ccinfo()->delete();
1524
1525 return [
1526 'message' => __('Selected response has been deleted', 'fluent-support')
1527 ];
1528 } catch (\Exception $e) {
1529 return $this->sendError([
1530 'message' => Helper::getSafeErrorMessage($e)
1531 ]);
1532 }
1533 }
1534
1535 /**
1536 * @param TicketResponseRequest $request
1537 * @param int $ticket_id
1538 * @param int $response_id
1539 * @return array
1540 * @throws \Exception
1541 */
1542 public function updateResponse(TicketResponseRequest $request, $ticket_id, $response_id)
1543 {
1544 try {
1545 $ticket = Ticket::findOrFail($ticket_id);
1546
1547 if (in_array($ticket->mailbox_id, PermissionManager::getRestrictedMailboxIds())) {
1548 throw new \Exception(esc_html__('Ticket cannot be fetched due to restricted mailbox', 'fluent-support'), 403);
1549 }
1550
1551 $this->ensureCanAccessTicket($ticket);
1552
1553 $response = Conversation::where('id', $response_id)
1554 ->where('ticket_id', $ticket_id)
1555 ->with('person')
1556 ->firstOrFail();
1557 $agent = Helper::getAgentByUserId();
1558
1559 $editableTypes = ['response', 'draft_response', 'note', 'internal_info'];
1560 if (!in_array($response->conversation_type, $editableTypes, true)) {
1561 throw new \Exception(
1562 esc_html__('This response type cannot be edited.', 'fluent-support')
1563 );
1564 }
1565
1566 // Customer replies also use the 'response' type.
1567 if ($response->person && $response->person->person_type !== 'agent') {
1568 throw new \Exception(
1569 esc_html__('Sorry, you do not have permission to update this response.', 'fluent-support')
1570 );
1571 }
1572
1573 $isDraft = $response->conversation_type == 'draft_response';
1574 $isAuthor = (int) $response->person_id === (int) $agent->id;
1575 $canApproveDraft = PermissionManager::currentUserCan('fst_approve_draft_reply');
1576
1577 if ($isDraft && !$isAuthor) {
1578 if (!$canApproveDraft) {
1579 throw new \Exception(
1580 esc_html__('Sorry, You do not have permission to approve this draft response', 'fluent-support')
1581 );
1582 }
1583 } elseif (!$isAuthor && !PermissionManager::currentUserCan('fst_manage_other_tickets')) {
1584 throw new \Exception(
1585 esc_html__('Sorry, you do not have permission to update this response.', 'fluent-support')
1586 );
1587 }
1588
1589 // Input is already unslashed. Unslashing again strips real backslashes.
1590 $content = wp_kses_post($request->getSafe('content', 'wp_kses_post'));
1591 $response->content = $content;
1592
1593 if ($isDraft && !$isAuthor && $canApproveDraft) {
1594 $response = $this->approveDraftConversation($ticket, $response, $agent, $content);
1595 } else {
1596 $response->save();
1597 }
1598
1599 return [
1600 'message' => __('Selected response has been updated', 'fluent-support'),
1601 'response' => $response
1602 ];
1603 } catch (\Exception $e) {
1604 return $this->sendError([
1605 'message' => Helper::getSafeErrorMessage($e)
1606 ]);
1607 }
1608 }
1609
1610 public function approveDraftResponse(TicketResponseRequest $request, $ticket_id, $response_id)
1611 {
1612 try {
1613 if (!PermissionManager::currentUserCan('fst_approve_draft_reply')) {
1614 throw new \Exception(
1615 esc_html__('You do not have permission to approve draft responses.', 'fluent-support')
1616 );
1617 }
1618
1619 $ticket = Ticket::findOrFail($ticket_id);
1620
1621 $this->ensureCanAccessTicket($ticket);
1622
1623 $response = Conversation::where('id', $response_id)
1624 ->where('ticket_id', $ticket_id)
1625 ->where('conversation_type', 'draft_response')
1626 ->firstOrFail();
1627
1628 $person = Helper::getAgentByUserId();
1629
1630 $response = $this->approveDraftConversation(
1631 $ticket,
1632 $response,
1633 $person,
1634 wp_kses_post($request->getSafe('content', 'wp_kses_post'))
1635 );
1636
1637 return [
1638 'message' => __('Draft response has been successfully approved.', 'fluent-support'),
1639 'response' => $response,
1640 ];
1641 } catch (\Exception $e) {
1642 return $this->sendError([
1643 'message' => Helper::getSafeErrorMessage($e)
1644 ]);
1645 }
1646 }
1647
1648 protected function approveDraftConversation($ticket, $response, $person, $content)
1649 {
1650 return (new ResponseService())->publishDraft($ticket, $response, $person, $content);
1651 }
1652
1653 /**
1654 * Agents currently viewing the ticket.
1655 *
1656 * @param Request $request
1657 * @param int $ticket_id
1658 * @return array
1659 */
1660 public function getLiveActivity(Request $request, $ticket_id)
1661 {
1662 try {
1663 $ticket = Ticket::findOrFail($ticket_id);
1664
1665 $this->ensureCanAccessTicket($ticket);
1666
1667 $agent = Helper::getAgentByUserId();
1668
1669 return [
1670 'live_activity' => TicketHelper::getActivity($ticket_id, $agent->id)
1671 ];
1672 } catch (\Exception $e) {
1673 return $this->sendError([
1674 'message' => Helper::getSafeErrorMessage($e)
1675 ]);
1676 }
1677 }
1678
1679 /**
1680 * Remove the current agent from the ticket's live activity.
1681 *
1682 * @param Request $request
1683 * @param int $ticket_id
1684 * @return array
1685 */
1686 public function removeLiveActivity(Request $request, $ticket_id)
1687 {
1688 try {
1689 $ticket = Ticket::findOrFail($ticket_id);
1690
1691 $this->ensureCanAccessTicket($ticket);
1692
1693 $agent = Helper::getAgentByUserId();
1694
1695 return [
1696 'result' => TicketHelper::removeFromActivities($ticket_id, $agent->id),
1697 'agent_id' => $agent->id
1698 ];
1699 } catch (\Exception $e) {
1700 return $this->sendError([
1701 'message' => Helper::getSafeErrorMessage($e)
1702 ]);
1703 }
1704 }
1705
1706 /**
1707 * @param Request $request
1708 * @param int $ticket_id
1709 * @return array
1710 */
1711 public function addTag(Request $request, $ticket_id)
1712 {
1713 try {
1714 $ticket = Ticket::findOrFail($ticket_id);
1715
1716 $this->ensureCanAccessTicket($ticket);
1717
1718 $ticket->applyTags($request->getSafe('tag_id', 'intval'));
1719
1720 return [
1721 'message' => __('Tag has been added to this ticket', 'fluent-support'),
1722 'tags' => $ticket->tags
1723 ];
1724 } catch (\Exception $e) {
1725 return $this->sendError([
1726 'message' => Helper::getSafeErrorMessage($e)
1727 ]);
1728 }
1729 }
1730
1731 /**
1732 * Remove one tag from the ticket.
1733 *
1734 * @param int $ticket_id
1735 * @param int $tag_id
1736 * @return array
1737 */
1738 public function detachTag($ticket_id, $tag_id)
1739 {
1740 try {
1741 $ticket = Ticket::findOrFail($ticket_id);
1742
1743 $this->ensureCanAccessTicket($ticket);
1744
1745 $ticket->detachTags($tag_id);
1746
1747 return [
1748 'message' => __('Tag has been removed from this ticket', 'fluent-support'),
1749 'tags' => $ticket->tags
1750 ];
1751 } catch (\Exception $e) {
1752 return $this->sendError([
1753 'message' => Helper::getSafeErrorMessage($e)
1754 ]);
1755 }
1756 }
1757
1758 /**
1759 * @param Request $request
1760 * @param int $ticket_id
1761 * @return array
1762 */
1763 public function changeTicketCustomer(Request $request, $ticket_id)
1764 {
1765 $ticketId = (int) $ticket_id;
1766 $newCustomerId = $request->getSafe('customer', 'intval');
1767
1768 if (!$newCustomerId) {
1769 return $this->sendError(__('Invalid customer selected.', 'fluent-support'));
1770 }
1771
1772 // The ticket would expose the new customer's profile data, so this
1773 // needs the same capability as the customer routes.
1774 if (!PermissionManager::currentUserCan('fst_sensitive_data')) {
1775 return $this->sendError(__('You do not have permission to change the ticket customer.', 'fluent-support'));
1776 }
1777
1778 try {
1779 $ticket = Ticket::findOrFail($ticketId);
1780
1781 $this->ensureCanAccessTicket($ticket);
1782
1783 $targetCustomer = Customer::where('id', $newCustomerId)
1784 ->where('person_type', 'customer')
1785 ->first();
1786
1787 if (!$targetCustomer) {
1788 return $this->sendError(__('Invalid customer selected.', 'fluent-support'));
1789 }
1790
1791 if ($ticket->customer_id == $newCustomerId) {
1792 return $this->sendError(__('Customer already assigned to this ticket.', 'fluent-support'));
1793 }
1794
1795 $ticket->customer_id = $newCustomerId;
1796 $ticket->save();
1797
1798 return ['message' => __('Customer has been updated', 'fluent-support')];
1799
1800 } catch (\Exception $e) {
1801 return $this->sendError([
1802 'message' => Helper::getSafeErrorMessage($e)
1803 ]);
1804 }
1805 }
1806
1807 /**
1808 * @param Request $request
1809 * @param int $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 * @param Request $request
1839 * @param FluentCRMServices $fluentCRMServices
1840 * @return array
1841 */
1842 public function syncFluentCrmTags(Request $request, FluentCRMServices $fluentCRMServices)
1843 {
1844 $data = [
1845 'contact_id' => $request->getSafe('contact_id', 'intval'),
1846 'tags' => $request->get('tags', null)
1847 ];
1848
1849 if (is_array($data['tags'])) {
1850 $data['tags'] = array_map('intval', $data['tags']);
1851 }
1852
1853 try {
1854 return $fluentCRMServices->syncCrmTags($data);
1855 } catch (\Exception $e) {
1856 return $this->sendError([
1857 'message' => Helper::getSafeErrorMessage($e)
1858 ]);
1859 }
1860 }
1861
1862 /**
1863 * @param Request $request
1864 * @param FluentCRMServices $fluentCRMServices
1865 * @return array
1866 */
1867
1868 public function syncFluentCrmLists(Request $request, FluentCRMServices $fluentCRMServices)
1869 {
1870 $data = [
1871 'contact_id' => $request->getSafe('contact_id', 'intval'),
1872 'lists' => $request->get('lists', null, [])
1873 ];
1874
1875 if (is_array($data['lists'])) {
1876 $data['lists'] = array_map('intval', $data['lists']);
1877 }
1878
1879 try {
1880 return $fluentCRMServices->syncCrmLists($data);
1881 } catch (\Exception $e) {
1882 return $this->sendError([
1883 'message' => Helper::getSafeErrorMessage($e)
1884 ]);
1885 }
1886 }
1887
1888 /**
1889 * @param Request $request
1890 * @return array
1891 */
1892 public function getTicketEssentials(Request $request)
1893 {
1894 $type = $request->getSafe('type', 'sanitize_text_field');
1895
1896 return TicketHelper::getTicketEssentials($type);
1897 }
1898
1899 public function fetchLabelSearch()
1900 {
1901 try {
1902 $agent_id = get_current_user_id();
1903 return TicketHelper::getLabelSearch($agent_id);
1904 } catch (\Exception $e) {
1905 return $this->sendError([
1906 'message' => Helper::getSafeErrorMessage($e)
1907 ]);
1908 }
1909 }
1910
1911 public function storeOrUpdateLabelSearch(Request $request)
1912 {
1913 try {
1914 $agent_id = get_current_user_id();
1915 $searchData = $request->get('query', null, []);
1916 if (is_array($searchData)) {
1917 $searchData = map_deep($searchData, 'sanitize_text_field');
1918 }
1919 $filterType = Arr::get($searchData, 'filter_type', '');
1920 if ($filterType == 'advanced') {
1921 return TicketHelper::saveSearchLabel($agent_id, $searchData, $filterType);
1922 }
1923
1924 return [
1925 'message' => __('Invalid filter type.', 'fluent-support'),
1926 ];
1927
1928 } catch (\Exception $e) {
1929 return $this->sendError([
1930 'message' => Helper::getSafeErrorMessage($e)
1931 ]);
1932 }
1933 }
1934
1935 public function deleteLabelSearch(Request $request, $search_id)
1936 {
1937 try {
1938 $agent_id = get_current_user_id();
1939 return TicketHelper::deleteSavedSearch($search_id);
1940 } catch (\Exception $e) {
1941 return $this->sendError([
1942 'message' => Helper::getSafeErrorMessage($e)
1943 ]);
1944 }
1945 }
1946 }
1947