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 / Services / CustomerPortalService.php

CustomerPortalService.php in Fluent Support – Helpdesk & Customer Support Ticket System 2.4.5, at app/Services/CustomerPortalService.php

819 lines 28.0 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\Services;
4
5 use Exception;
6 use FluentSupport\App\Http\Policies\PortalPolicy;
7 use FluentSupport\App\Models\MailBox;
8 use FluentSupport\App\Models\Meta;
9 use FluentSupport\App\Models\Ticket;
10 use FluentSupport\App\Models\Customer;
11 use FluentSupport\App\Services\Tickets\AgentTicketAccess;
12 use FluentSupport\App\Services\Tickets\ResponseService;
13 use FluentSupport\App\Services\Tickets\TicketService;
14 use FluentSupport\Framework\Support\Arr;
15 use FluentSupport\App\Models\Attachment;
16 use FluentSupport\App\Models\Conversation;
17
18 class CustomerPortalService
19 {
20 /**
21 * Ticket columns a customer never sees: the agent-set priority and the
22 * email threading / dedupe internals.
23 */
24 const PRIVATE_TICKET_FIELDS = ['priority', 'message_id', 'content_hash', 'secret_content'];
25
26 /**
27 * Hide PRIVATE_TICKET_FIELDS on a ticket, or on each ticket of a list or page.
28 *
29 * @param mixed $tickets
30 * @return mixed
31 */
32 public static function hidePrivateTicketFields($tickets)
33 {
34 if ($tickets instanceof Ticket) {
35 $tickets->makeHidden(self::PRIVATE_TICKET_FIELDS);
36 } elseif (is_iterable($tickets)) {
37 foreach ($tickets as $ticket) {
38 if ($ticket instanceof Ticket) {
39 $ticket->makeHidden(self::PRIVATE_TICKET_FIELDS);
40 }
41 }
42 }
43
44 return $tickets;
45 }
46
47 /**
48 * @param object $customer
49 * @param string $requestedStatus
50 * @param array|null $options
51 * @return object
52 * @throws Exception
53 * @since 1.8.1
54 */
55 public function getTickets($customer, $requestedStatus, $options = [])
56 {
57 $this->validateCustomer($customer);
58
59 $statuses = $this->getTicketStatues($requestedStatus);
60
61 return $this->ticketsAdditionalData($customer, $statuses, $options);
62 }
63
64 /**
65 * @param array $customerAdditionalData
66 * @param int $ticketId
67 * @return array
68 * @since 1.5.7
69 */
70 public function getTicket($customerAdditionalData, $ticketId)
71 {
72 $ticket = $this->getTicketByID($ticketId);
73
74 // Same message as a denied ticket, so an unknown number reveals nothing.
75 if (!$ticket) {
76 throw new \Exception(esc_html__('Sorry, You do not have permission to this support ticket', 'fluent-support'));
77 }
78
79 // translators: %s is the time duration (e.g., "2 hours", "3 days")
80 $ticket->human_date = sprintf(__('%s ago', 'fluent-support'), human_time_diff(strtotime($ticket->created_at), current_time('timestamp')));
81
82 $customer = $this->getCustomer($customerAdditionalData, $ticket);
83
84 $this->checkCustomerTicketAccess($customer, $ticket);
85
86 return [
87 'ticket' => $this->syncTicketAdditionData($ticket),
88 'responses' => $this->getResponses($ticket->id),
89 'sign_on_id' => $ticket->customer_id
90 ];
91 }
92
93 /**
94 * @param object $customer
95 * @param array $data
96 * @param int $mailboxId
97 * @param bool $onBehalf Filed by a remote portal for the customer; their uploads are the customer's.
98 * @return Ticket
99 * @throws Exception
100 */
101 public function createTicket($customer, $data, $mailboxId, $onBehalf = false)
102 {
103 $this->validateCustomer($customer);
104
105 $data = $this->onlyAllowedPortalTicketFields($data);
106
107 // Already unslashed at the request boundary. Don't unslash again or literal backslashes are lost.
108 $data['title'] = sanitize_text_field($data['title']);
109 $data['content'] = wp_kses_post($data['content']);
110 $data['customer_id'] = $customer->id;
111 $data['product_source'] = 'local';
112 $data['mailbox_id'] = $this->resolveMailboxId($mailboxId);
113 $data['source'] = 'web';
114
115 $disabledFields = apply_filters('fluent_support/disabled_ticket_fields', []);
116 $this->validateDisabledFields($data, $disabledFields);
117
118 return $this->storeTicket($data, $customer, $disabledFields, $onBehalf);
119 }
120
121 /**
122 * The validator returns the whole payload, and many fillable Ticket columns (agent_id,
123 * status, privacy, ...) are agent-only, so keep only what a customer may set.
124 *
125 * @param array $data
126 * @return array
127 */
128 private function onlyAllowedPortalTicketFields($data)
129 {
130 $allowedKeys = [
131 'title',
132 'content',
133 'product_id',
134 'client_priority',
135 'custom_data',
136 'attachments',
137 'message_id',
138 ];
139
140 return array_intersect_key($data, array_flip($allowedKeys));
141 }
142
143
144 /**
145 * @param array $customerAdditionalData
146 * @param int $ticketId
147 * @param array $data
148 * @return array
149 * @throws Exception
150 * @since 1.5.7
151 */
152 public function createResponse($customerAdditionalData, $ticketId, $data)
153 {
154 // Already unslashed at the request boundary; ResponseService sanitizes it.
155 $data['conversation_type'] = 'response';
156
157 $ticket = Ticket::with(['customer'])->wherePublicIdentifier($ticketId)->firstOrFail();
158 $customer = $this->getCustomer($customerAdditionalData, $ticket);
159
160 $this->checkCustomerTicketAccess($customer, $ticket, 'response');
161
162 // Checked before anything changes, so a blank reply neither reopens nor saves.
163 if (!$this->hasReplyContent(Arr::get($data, 'content'))) {
164 throw new \Exception(esc_html__('Please write a reply before sending.', 'fluent-support'));
165 }
166
167 // A reply reopens a closed ticket, as an emailed reply does, so it is never left
168 // closed with an unanswered customer message in it. Reopened before the reply is
169 // saved, as email piping does, so the reply hooks see an active ticket. Not when
170 // the customer asked to close it with this reply.
171 if ($ticket->status == 'closed' && Arr::get($data, 'close_ticket') != 'yes') {
172 (new TicketService())->reopen($ticket, $customer);
173 }
174
175 $responseData = (new ResponseService())->createResponse($data, $customer, $ticket);
176
177 if (!$responseData) {
178 throw new \Exception(esc_html__('Please write a reply before sending.', 'fluent-support'));
179 }
180
181 $responseData['ticket'] = Ticket::find($ticket->id);
182 $responseData['response']->content = Helper::refreshSignedAttachmentUrls($responseData['response']->content, $ticket->id);
183 $responseData['response']->load([
184 'attachments' => function ($query) {
185 $query->where('status', 'active');
186 }
187 ]);
188
189 return [
190 'message' => __('Reply has been added', 'fluent-support'),
191 'response' => $responseData['response'],
192 'ticket' => self::hidePrivateTicketFields($responseData['ticket'])
193 ];
194 }
195
196
197 /**
198 * @param array $customerAdditionalData
199 * @param int $ticketId
200 * @return array
201 * @throws Exception
202 */
203 public function closeTicket($customerAdditionalData, $ticketId)
204 {
205 $ticket = Ticket::with(['customer'])->wherePublicIdentifier($ticketId)->firstOrFail();
206 $customer = $this->getCustomer($customerAdditionalData, $ticket);
207
208 $this->checkCustomerTicketAccess($customer, $ticket, 'close');
209
210 return [
211 'message' => __('Ticket has been closed', 'fluent-support'),
212 'ticket' => self::hidePrivateTicketFields((new TicketService())->close($ticket, $customer))
213 ];
214 }
215
216 /**
217 * @param array $customerAdditionalData
218 * @param int $ticketId
219 * @return array
220 * @throws Exception
221 */
222 public function reOpenTicket($customerAdditionalData, $ticketId)
223 {
224 $ticket = Ticket::with(['customer'])->wherePublicIdentifier($ticketId)->firstOrFail();
225 $customer = $this->getCustomer($customerAdditionalData, $ticket);
226
227 $this->checkCustomerTicketAccess($customer, $ticket, 'reopen');
228
229 return [
230 'message' => __('Ticket has been opened again', 'fluent-support'),
231 'ticket' => self::hidePrivateTicketFields((new TicketService())->reopen($ticket, $customer))
232 ];
233 }
234
235 /**
236 * @param array $data
237 * @param array $disabledFields
238 * @return array $data
239 * @since 1.5.7
240 */
241 private function validateDisabledFields($data, $disabledFields)
242 {
243 if (!in_array('priority', $disabledFields)) {
244 $data['priority'] = sanitize_text_field($data['client_priority'] ?? '');
245 $data['client_priority'] = sanitize_text_field($data['client_priority'] ?? '');
246 }
247
248 if (in_array('product_services', $disabledFields)) {
249 unset($data['product_id']);
250 }
251
252 return $data;
253 }
254
255
256 /**
257 * @param array $data
258 * @param object $customer
259 * @param array $disabledFields
260 * @param bool $onBehalf
261 * @return Ticket
262 * @since 1.5.7
263 */
264 private function storeTicket($data, $customer, $disabledFields, $onBehalf = false)
265 {
266 /*
267 * Filter ticket data before it's saved.
268 *
269 * @since v1.0.0
270 * @param array $data
271 * @param object $customer
272 */
273 $data = apply_filters('fluent_support/create_ticket_data', $data, $customer);
274
275 /*
276 * Fires before the ticket is created.
277 *
278 * @since v1.0.0
279 * @param array $data
280 * @param object $customer
281 */
282 do_action('fluent_support/before_ticket_create', $data, $customer);
283
284 $ticket = Ticket::create($data);
285
286 TicketService::addTicketAttachments($data, $disabledFields, $ticket, $customer, $onBehalf);
287 $this->addCustomData($data, $ticket);
288
289 do_action('fluent_support/ticket_created', $ticket, $customer);
290
291 return $ticket;
292 }
293
294
295 /**
296 * @param array $data
297 * @param object $ticket
298 * @return void
299 */
300 private function addCustomData($data, $ticket)
301 {
302 if (defined('FLUENTSUPPORTPRO')) {
303 $customData = Arr::get($data, 'custom_data');
304 if ($customData) {
305 $ticket->syncCustomFields($customData);
306 }
307 }
308 }
309
310 /**
311 * @param object|null $customer
312 * @since 1.5.7
313 * @throws Exception
314 */
315 private function validateCustomer($customer)
316 {
317 if (!$customer) {
318 throw new \Exception(esc_html__('Customer not found', 'fluent-support'));
319 }
320
321 if (!$customer->canAccessPortal()) {
322 throw new \Exception(esc_html__('Sorry, You do not have access to customer portal', 'fluent-support'));
323 }
324 }
325
326 /**
327 * With a valid signed ticket hash the ticket's own customer is used, otherwise the current user.
328 *
329 * @param array $customerAdditionalData
330 * @param object $ticket
331 * @return object $customer
332 * @throws Exception
333 *
334 * @since 1.5.7
335 */
336 public function getCustomer($customerAdditionalData, $ticket)
337 {
338 $intendedHash = Arr::get($customerAdditionalData, 'intended_ticket_hash');
339 if ($intendedHash && Helper::isPublicSignedTicketEnabled()) {
340 if (!is_string($intendedHash) || !hash_equals((string) $ticket->hash, $intendedHash)) {
341 throw new \Exception(esc_html__('Sorry, You do not have permission to this support ticket', 'fluent-support'));
342 }
343 $customer = $ticket->customer;
344 } else {
345 $onBehalf = Arr::get($customerAdditionalData, 'on_behalf');
346
347 // Signed links are off: see PortalPolicy::maybePublicSignedRequest().
348 if ($intendedHash && !$onBehalf && PortalPolicy::canActOnBehalf()) {
349 throw new \Exception(esc_html__('Sorry, You do not have permission to this support ticket', 'fluent-support'));
350 }
351
352 // An agent acting for a customer keeps their own mailbox and visibility limits.
353 // Site admins have full access and may not have an agent record at all.
354 if ($onBehalf && !current_user_can('manage_options') && !(new AgentTicketAccess())->currentAgentCanAccess($ticket)) {
355 throw new \Exception(esc_html__('Sorry, You do not have permission to this support ticket', 'fluent-support'));
356 }
357
358 $customer = $this->resolveCustomer($onBehalf, Arr::get($customerAdditionalData, 'user_ip'));
359 }
360
361 if (!$customer) {
362 throw new \Exception(esc_html__('Sorry! No customer found', 'fluent-support'));
363 }
364
365 return $customer;
366 }
367
368 /**
369 * @param string $requestedStatus
370 * @return array
371 * @since 1.8.1
372 */
373 private function getTicketStatues($requestedStatus)
374 {
375 $statuses = [
376 'open' => ['new', 'active', 'on-hold'],
377 'all' => [],
378 'closed' => ['closed']
379 ];
380
381 return Arr::get($statuses, $requestedStatus, []);
382 }
383
384
385 /**
386 * @param object $customer
387 * @param array $statuses
388 * @param array|null $options
389 * @return object $tickets
390 * @since 1.5.7
391 */
392 private function ticketsAdditionalData($customer, $statuses, $options = [])
393 {
394 $defaultOptions = [
395 'search' => null,
396 'sorting' => null,
397 'filters' => null
398 ];
399
400 $ticketOptions = wp_parse_args($options, $defaultOptions);
401
402 $tickets = Ticket::with([
403 'customer' => function ($query) {
404 $query->select(['first_name', 'last_name', 'id']);
405 }, 'agent' => function ($query) {
406 $query->select(['first_name', 'last_name', 'id']);
407 }
408 ])->where('customer_id', $customer->id)
409 ->when(!empty($ticketOptions['sorting'] && !empty($ticketOptions['sorting']['sort_by'])), function ($query) use ($ticketOptions) {
410 return $query->orderBy(sanitize_sql_orderby($ticketOptions['sorting']['sort_by']), sanitize_sql_orderby($ticketOptions['sorting']['sort_type']));
411 })
412 ->when(!empty($options['filters']['product_id']), function ($query) use ($ticketOptions) {
413 return $query->where('product_id', $ticketOptions['filters']['product_id']);
414 })
415 ->when($statuses, function ($query) use ($statuses) {
416 return $query->whereIn('status', $statuses);
417 })
418 ->when($ticketOptions['search'], function ($query) use ($ticketOptions) {
419 return $query->searchBy($ticketOptions['search']);
420 })
421 ->when(empty($ticketOptions['sorting']), function ($query) {
422 return $query->latest('updated_at');
423 })
424 ->paginate();
425
426 foreach ($tickets as $ticket) {
427 // translators: %s is the time duration (e.g., "2 hours", "3 days")
428 $ticket->human_date = sprintf(__('%s ago', 'fluent-support'), human_time_diff(strtotime($ticket->created_at), current_time('timestamp')));
429 $ticket->preview_response = $ticket->getLastResponse();
430 }
431
432 return self::hidePrivateTicketFields($tickets);
433 }
434
435 /**
436 * Keeps only scalar values, so sanitize_text_field() never gets an array.
437 * An empty result makes resolveCustomer() use the logged-in user.
438 *
439 * @param mixed $onBehalf
440 * @return array
441 */
442 public function sanitizeOnBehalf($onBehalf)
443 {
444 if (!is_array($onBehalf)) {
445 return [];
446 }
447
448 $sanitized = [];
449
450 foreach ($onBehalf as $key => $value) {
451 if (is_scalar($value)) {
452 $sanitized[sanitize_text_field($key)] = sanitize_text_field($value);
453 }
454 }
455
456 return $sanitized;
457 }
458
459 /**
460 * Find the customer for $onBehalf (or the current user), creating it when $forceCreate is set.
461 *
462 * @param array $onBehalf
463 * @param string $userIp
464 * @param bool $forceCreate
465 * @return Customer|false
466 */
467 public function resolveCustomer($onBehalf, $userIp, $forceCreate = false)
468 {
469 if (!$onBehalf) {
470 $user = get_user_by('ID', get_current_user_id());
471 if (!$user) {
472 return false;
473 }
474
475 $onBehalf = [
476 'user_id' => $user->ID,
477 'email' => $user->user_email,
478 'last_ip_address' => $userIp
479 ];
480 }
481
482 if ($forceCreate) {
483 return Customer::maybeCreateCustomer($onBehalf);
484 }
485
486 return Customer::getCustomerFromData($onBehalf);
487 }
488
489 /**
490 * Whether a reply has something to read: text, or an image. Editors leave markup
491 * such as <p>&nbsp;</p> behind when the text is deleted.
492 *
493 * @param mixed $content
494 * @return bool
495 */
496 private function hasReplyContent($content)
497 {
498 // ResponseService refuses whatever empty() treats as empty, "0" included.
499 if (!is_string($content) || empty($content)) {
500 return false;
501 }
502
503 if (stripos($content, '<img') !== false) {
504 return true;
505 }
506
507 $text = wp_strip_all_tags(str_ireplace(['&nbsp;', '&#160;'], ' ', $content));
508
509 return trim(html_entity_decode($text, ENT_QUOTES, 'UTF-8'), " \t\n\r\0\x0B\xC2\xA0") !== '';
510 }
511
512 /**
513 * The given mailbox's id, falling back to the default mailbox.
514 *
515 * @param int $mailboxId
516 * @return int|null
517 */
518 public function resolveMailboxId($mailboxId)
519 {
520 $mailbox = MailBox::find($mailboxId);
521 if ($mailbox) {
522 return $mailbox->id;
523 }
524
525 $mailbox = Helper::getDefaultMailBox();
526
527 if ($mailbox) {
528 return $mailbox->id;
529 }
530 return null;
531 }
532
533 /**
534 * @param int|string $ticketId
535 * @return object $ticket
536 */
537 private function getTicketByID($ticketId)
538 {
539 $ticket = Ticket::wherePublicIdentifier($ticketId)
540 ->with([
541 'customer' => function ($query) {
542 $query->select(['first_name', 'email', 'person_type', 'last_name', 'id', 'avatar']);
543 }, 'agent' => function ($query) {
544 $query->select(['first_name', 'email', 'person_type', 'last_name', 'id', 'title', 'avatar']);
545 },
546 'product',
547 'attachments' => function ($q) {
548 $q->where('status', 'active');
549 }
550 ])
551 ->first();
552
553 return $ticket;
554 }
555
556 /**
557 * @param object $customer
558 * @param object $ticket
559 * @param string|false $action
560 * @return bool true if access is granted
561 * @throws Exception
562 */
563 public function checkCustomerTicketAccess($customer, $ticket, $action = false)
564 {
565 if (!$customer) {
566 throw new \Exception(esc_html__('Sorry, You do not have permission to this support ticket', 'fluent-support'));
567 }
568
569 if (!$customer->canAccessPortal()) {
570 throw new \Exception(esc_html__('Sorry, You do not have access to customer portal', 'fluent-support'));
571 }
572
573 if ($ticket->privacy == 'private' && $customer->id != $ticket->customer_id) {
574 if ($action) {
575 throw new \Exception(sprintf(
576 // translators: %s is the action being performed (e.g., "view", "edit", "delete")
577 esc_html__("Sorry! You cannot %s this ticket", 'fluent-support'),
578 esc_html($action)
579 ));
580 } else {
581 throw new \Exception(esc_html__('You do not have permission to view this support ticket', 'fluent-support'));
582 }
583 }
584
585 $result = apply_filters('fluent_support/can_customer_access_ticket', true, $customer, $ticket, $action);
586
587 if ($result && !is_wp_error($result)) {
588 return $result;
589 }
590
591 if (!$result) {
592 throw new \Exception(esc_html__('Sorry, You cannot access this ticket', 'fluent-support'));
593 }
594
595 throw new \Exception(esc_html($result->get_error_message()));
596 }
597
598
599 /**
600 * @param int $ticketId
601 * @return mixed
602 */
603 private function getResponses($ticketId)
604 {
605 $responses = Conversation::where('ticket_id', $ticketId)
606 ->with([
607 'person' => function ($query) {
608 $query->select(['first_name', 'email', 'person_type', 'last_name', 'id', 'title', 'avatar']);
609 },
610 'attachments' => function ($query) {
611 $query->where('status', 'active');
612 }
613 ])
614 ->filterByType(['response', 'ticket_merge_activity', 'ticket_split_activity'])
615 ->orderBy('created_at', 'desc')
616 ->orderBy('id', 'desc')
617 ->get();
618
619 $contents = [];
620 foreach ($responses as $response) {
621 $contents[$response->id] = $response->content;
622 }
623
624 $contents = Helper::refreshSignedAttachmentUrlsInContents($contents, $ticketId);
625
626 $feedbacks = [];
627 if (defined('FLUENTSUPPORTPRO_PLUGIN_VERSION') && Helper::isAgentFeedbackEnabled()) {
628 $responseIds = $responses->pluck('id')->toArray();
629 if ($responseIds) {
630 $feedbacks = Meta::where('object_type', 'conversation_meta')
631 ->where('key', 'agent_feedback_ratings')
632 ->whereIn('object_id', $responseIds)
633 ->get()
634 ->keyBy('object_id');
635 }
636 }
637
638 foreach ($responses as $response) {
639 if ($feedbacks && $feedbacks->has($response->id)) {
640 $response->agent_feedback = $feedbacks->get($response->id)->value;
641 }
642
643 // translators: %s is the time duration (e.g., "2 hours", "3 days")
644 $response->human_date = sprintf(__('%s ago', 'fluent-support'), human_time_diff(strtotime($response->created_at), current_time('timestamp')));
645 if (isset($contents[$response->id])) {
646 $response->content = $contents[$response->id];
647 }
648
649 $responseContent = apply_filters('fluent_support/response_content_before_render', $response->content, $response, null);
650 $responseContent = links_add_target(make_clickable($responseContent));
651 $response->content = apply_filters('fluent_support/response_content_after_render', $responseContent, $response, null);
652
653 if ($response->person) {
654 $response->person->setHidden(['email']);
655 }
656 }
657
658 return $responses;
659 }
660
661 /**
662 * @param object $ticket
663 * @return object $ticket
664 */
665 private function syncTicketAdditionData($ticket)
666 {
667 $ticket->content = Helper::refreshSignedAttachmentUrls($ticket->content, $ticket->id);
668 $ticketContent = apply_filters('fluent_support/ticket_content_before_render', $ticket->content, $ticket);
669 $ticketContent = links_add_target(make_clickable($ticketContent));
670 $ticket->content = apply_filters('fluent_support/ticket_content_after_render', $ticketContent, $ticket);
671
672 if ($ticket->customer) {
673 $ticket->customer->setHidden(['email']);
674 }
675
676 if ($ticket->agent) {
677 $ticket->agent->setHidden(['email']);
678 }
679
680 if ($ticket->status == 'closed') {
681 $ticket->load('closed_by_person');
682 if ($ticket->closed_by_person) {
683 $ticket->closed_by_person->setVisible(['first_name', 'last_name', 'id', 'full_name', 'photo']);
684 }
685 }
686
687 if (defined('FLUENTSUPPORTPRO')) {
688 $ticket->custom_fields = $ticket->customData('public', true);
689 }
690
691 // Set when an agent opened the ticket on the customer's behalf. Only the
692 // display fields leave the server: the loaded Agent row carries the
693 // agent's email, IP and user id, so it is dropped again after reading.
694 if ($ticket->created_by) {
695 $ticket->load('created_by_person');
696 if ($ticket->created_by_person) {
697 $ticket->created_by_agent = [
698 'full_name' => $ticket->created_by_person->full_name,
699 'photo' => $ticket->created_by_person->photo,
700 // The agent's first response is the opening message; skip the placeholder body.
701 'agent_initiated' => $ticket->isAgentInitiated(),
702 ];
703 }
704 $ticket->unsetRelation('created_by_person');
705 }
706
707 return self::hidePrivateTicketFields($ticket);
708 }
709
710 /**
711 * @param string $approvalStatus
712 * @param int $conversationID
713 * @param int $ticketId The authorized ticket ID the conversation must belong to
714 * @throws Exception
715 */
716 public function addUserFeedback($approvalStatus, $conversationID, $ticketId)
717 {
718 if (!in_array($approvalStatus, ['like', 'dislike'], true)) {
719 throw new Exception(esc_html__('Invalid feedback value provided', 'fluent-support'));
720 }
721
722 $conversation = Conversation::with('person')
723 ->where('id', $conversationID)
724 ->where('ticket_id', $ticketId)
725 ->where('conversation_type', 'response')
726 ->first();
727
728 if (!$conversation || !$conversation->person || $conversation->person->person_type !== 'agent') {
729 throw new Exception(esc_html__('Invalid conversation for feedback', 'fluent-support'));
730 }
731
732 $existingAgentFeedback = Meta::where([
733 'object_id' => $conversationID,
734 'object_type' => 'conversation_meta',
735 'key' => 'agent_feedback_ratings',
736 ])->first();
737
738 if ($existingAgentFeedback) {
739 $wasRemoved = $existingAgentFeedback->value === $approvalStatus;
740 $result = $this->updateExistingFeedback($existingAgentFeedback, $approvalStatus);
741 if (!$wasRemoved) {
742 $this->fireFeedbackReceived($approvalStatus, $conversation);
743 }
744 return $result;
745 }
746
747 $agentFeedback = Meta::create([
748 'object_id' => $conversationID,
749 'key' => 'agent_feedback_ratings',
750 'object_type' => 'conversation_meta',
751 'value' => $approvalStatus,
752 ]);
753
754 $this->fireFeedbackReceived($approvalStatus, $conversation);
755
756 return $agentFeedback;
757 }
758
759 /**
760 * A customer rated an agent's reply (or changed the rating). Clicking the
761 * same rating again removes it and fires nothing. Each rating fires at most
762 * once per reply, so clicking back and forth can't rerun workflows.
763 *
764 * @param string $rating 'like' or 'dislike'
765 * @param Conversation $conversation The rated reply.
766 * @return void
767 */
768 private function fireFeedbackReceived($rating, $conversation)
769 {
770 $ticket = Ticket::with('customer')->find($conversation->ticket_id);
771
772 if (!$ticket) {
773 return;
774 }
775
776 $firedKey = '_agent_feedback_fired_' . $rating;
777 $alreadyFired = Meta::where('object_type', 'conversation_meta')
778 ->where('object_id', $conversation->id)
779 ->where('key', $firedKey)
780 ->exists();
781
782 if ($alreadyFired) {
783 return;
784 }
785
786 Meta::create([
787 'object_type' => 'conversation_meta',
788 'object_id' => $conversation->id,
789 'key' => $firedKey,
790 'value' => current_time('mysql'),
791 ]);
792
793 /*
794 * Fires when a customer likes or dislikes an agent's reply.
795 *
796 * @since 2.4.1
797 * @param string $rating 'like' or 'dislike'
798 * @param Conversation $conversation The rated reply.
799 * @param Ticket $ticket
800 * @param Customer $customer
801 */
802 do_action('fluent_support/agent_feedback_received', $rating, $conversation, $ticket, $ticket->customer);
803 }
804
805 private function updateExistingFeedback($existingAgentFeedback, $approvalStatus)
806 {
807 if (($existingAgentFeedback->value === 'like' && $approvalStatus === 'like') ||
808 ($existingAgentFeedback->value === 'dislike' && $approvalStatus === 'dislike')) {
809 $existingAgentFeedback->delete();
810 } else {
811 $existingAgentFeedback->update([
812 'value' => $approvalStatus,
813 ]);
814 }
815 return $existingAgentFeedback;
816 }
817
818 }
819