PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 2.4.5
Fluent Support – Helpdesk & Customer Support Ticket System v2.4.5
2.4.5 2.4.0 2.3.2 2.3.1 2.3.0 2.2.1 2.2.0 trunk 1.10.0 1.10.1 1.10.2 1.10.3 1.10.4 1.10.5 1.4.0 1.4.1 1.4.2 1.4.5 1.4.6 1.4.7 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 All 69 releases
← All changes | app/Services/CustomerPortalService.php +271 -63 2.2.0 → 2.4.5 View file →
@@ -2,12 +2,14 @@
2 2
3 3 namespace FluentSupport\App\Services;
4 4
5 5 use Exception;
6 +use FluentSupport\App\Http\Policies\PortalPolicy;
6 7 use FluentSupport\App\Models\MailBox;
7 8 use FluentSupport\App\Models\Meta;
8 9 use FluentSupport\App\Models\Ticket;
9 10 use FluentSupport\App\Models\Customer;
11 +use FluentSupport\App\Services\Tickets\AgentTicketAccess;
10 12 use FluentSupport\App\Services\Tickets\ResponseService;
11 13 use FluentSupport\App\Services\Tickets\TicketService;
12 14 use FluentSupport\Framework\Support\Arr;
13 15 use FluentSupport\App\Models\Attachment;
@@ -15,9 +17,35 @@
15 17
16 18 class CustomerPortalService
17 19 {
18 20 /**
19 - * This `getTickets` method is responsible for getting tickets for customer
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 + /**
20 48 * @param object $customer
21 49 * @param string $requestedStatus
22 50 * @param array|null $options
23 51 * @return object
@@ -33,9 +61,8 @@
33 61 return $this->ticketsAdditionalData($customer, $statuses, $options);
34 62 }
35 63
36 64 /**
37 - * getTicket method will get the ticket information with customer and agent as well as response in a ticket by ticket id
38 65 * @param array $customerAdditionalData
39 66 * @param int $ticketId
40 67 * @return array
41 68 * @since 1.5.7
@@ -42,8 +69,14 @@
42 69 */
43 70 public function getTicket($customerAdditionalData, $ticketId)
44 71 {
45 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 +
46 79 // translators: %s is the time duration (e.g., "2 hours", "3 days")
47 80 $ticket->human_date = sprintf(__('%s ago', 'fluent-support'), human_time_diff(strtotime($ticket->created_at), current_time('timestamp')));
48 81
49 82 $customer = $this->getCustomer($customerAdditionalData, $ticket);
@@ -57,21 +90,24 @@
57 90 ];
58 91 }
59 92
60 93 /**
61 - * This `createTicket` method is responsible for creating ticket for customer
62 94 * @param object $customer
63 95 * @param array $data
64 96 * @param int $mailboxId
97 + * @param bool $onBehalf Filed by a remote portal for the customer; their uploads are the customer's.
65 98 * @return Ticket
66 99 * @throws Exception
67 100 */
68 - public function createTicket($customer, $data, $mailboxId)
101 + public function createTicket($customer, $data, $mailboxId, $onBehalf = false)
69 102 {
70 103 $this->validateCustomer($customer);
71 104
72 - $data['title'] = sanitize_text_field(wp_unslash($data['title']));
73 - $data['content'] = wp_specialchars_decode(wp_unslash(wp_kses_post($data['content'])));
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']);
74 110 $data['customer_id'] = $customer->id;
75 111 $data['product_source'] = 'local';
76 112 $data['mailbox_id'] = $this->resolveMailboxId($mailboxId);
77 113 $data['source'] = 'web';
@@ -78,14 +114,35 @@
78 114
79 115 $disabledFields = apply_filters('fluent_support/disabled_ticket_fields', []);
80 116 $this->validateDisabledFields($data, $disabledFields);
81 117
82 - return $this->storeTicket($data, $customer, $disabledFields);
118 + return $this->storeTicket($data, $customer, $disabledFields, $onBehalf);
83 119 }
84 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 + ];
85 139
140 + return array_intersect_key($data, array_flip($allowedKeys));
141 + }
142 +
143 +
86 144 /**
87 - * This `createResponse` method is responsible for creating response by customer in a ticket by ticket id, and data
88 145 * @param array $customerAdditionalData
89 146 * @param int $ticketId
90 147 * @param array $data
91 148 * @return array
@@ -93,9 +150,9 @@
93 150 * @since 1.5.7
94 151 */
95 152 public function createResponse($customerAdditionalData, $ticketId, $data)
96 153 {
97 - $data['content'] = wp_specialchars_decode(wp_unslash($data['content']));
154 + // Already unslashed at the request boundary; ResponseService sanitizes it.
98 155 $data['conversation_type'] = 'response';
99 156
100 157 $ticket = Ticket::with(['customer'])->wherePublicIdentifier($ticketId)->firstOrFail();
101 158 $customer = $this->getCustomer($customerAdditionalData, $ticket);
@@ -101,9 +158,27 @@
101 158 $customer = $this->getCustomer($customerAdditionalData, $ticket);
102 159
103 160 $this->checkCustomerTicketAccess($customer, $ticket, 'response');
104 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 +
105 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 +
106 181 $responseData['ticket'] = Ticket::find($ticket->id);
107 182 $responseData['response']->content = Helper::refreshSignedAttachmentUrls($responseData['response']->content, $ticket->id);
108 183 $responseData['response']->load([
109 184 'attachments' => function ($query) {
@@ -113,15 +188,14 @@
113 188
114 189 return [
115 190 'message' => __('Reply has been added', 'fluent-support'),
116 191 'response' => $responseData['response'],
117 - 'ticket' => $responseData['ticket']
192 + 'ticket' => self::hidePrivateTicketFields($responseData['ticket'])
118 193 ];
119 194 }
120 195
121 196
122 197 /**
123 - * This `closeTicket` is responsible for closing ticket by ticket id
124 198 * @param array $customerAdditionalData
125 199 * @param int $ticketId
126 200 * @return array
127 201 * @throws Exception
@@ -134,14 +208,13 @@
134 208 $this->checkCustomerTicketAccess($customer, $ticket, 'close');
135 209
136 210 return [
137 211 'message' => __('Ticket has been closed', 'fluent-support'),
138 - 'ticket' => (new TicketService())->close($ticket, $customer)
212 + 'ticket' => self::hidePrivateTicketFields((new TicketService())->close($ticket, $customer))
139 213 ];
140 214 }
141 215
142 216 /**
143 - * This `reOpenTicket` is responsible for reopening ticket by ticket id
144 217 * @param array $customerAdditionalData
145 218 * @param int $ticketId
146 219 * @return array
147 220 * @throws Exception
@@ -154,14 +227,13 @@
154 227 $this->checkCustomerTicketAccess($customer, $ticket, 'reopen');
155 228
156 229 return [
157 230 'message' => __('Ticket has been opened again', 'fluent-support'),
158 - 'ticket' => (new TicketService())->reopen($ticket, $customer)
231 + 'ticket' => self::hidePrivateTicketFields((new TicketService())->reopen($ticket, $customer))
159 232 ];
160 233 }
161 234
162 235 /**
163 - * This `validateDisabledFields` method is responsible for validating disabled fields
164 236 * @param array $data
165 237 * @param array $disabledFields
166 238 * @return array $data
167 239 * @since 1.5.7
@@ -181,19 +253,19 @@
181 253 }
182 254
183 255
184 256 /**
185 - * This `storeTicket` method is responsible for storing a ticket in Ticket Model
186 257 * @param array $data
187 258 * @param object $customer
188 259 * @param array $disabledFields
260 + * @param bool $onBehalf
189 261 * @return Ticket
190 262 * @since 1.5.7
191 263 */
192 - private function storeTicket($data, $customer, $disabledFields)
264 + private function storeTicket($data, $customer, $disabledFields, $onBehalf = false)
193 265 {
194 266 /*
195 - * Filter ticket data
267 + * Filter ticket data before it's saved.
196 268 *
197 269 * @since v1.0.0
198 270 * @param array $data
199 271 * @param object $customer
@@ -200,9 +272,9 @@
200 272 */
201 273 $data = apply_filters('fluent_support/create_ticket_data', $data, $customer);
202 274
203 275 /*
204 - * Action before ticket create
276 + * Fires before the ticket is created.
205 277 *
206 278 * @since v1.0.0
207 279 * @param array $data
208 280 * @param object $customer
@@ -210,9 +282,9 @@
210 282 do_action('fluent_support/before_ticket_create', $data, $customer);
211 283
212 284 $ticket = Ticket::create($data);
213 285
214 - TicketService::addTicketAttachments($data, $disabledFields, $ticket, $customer);
286 + TicketService::addTicketAttachments($data, $disabledFields, $ticket, $customer, $onBehalf);
215 287 $this->addCustomData($data, $ticket);
216 288
217 289 do_action('fluent_support/ticket_created', $ticket, $customer);
218 290
@@ -220,9 +292,8 @@
220 292 }
221 293
222 294
223 295 /**
224 - * This `addCustomData` method is responsible for adding custom data to ticket
225 296 * @param array $data
226 297 * @param object $ticket
227 298 * @return void
228 299 */
@@ -230,9 +301,8 @@
230 301 {
231 302 if (defined('FLUENTSUPPORTPRO')) {
232 303 $customData = Arr::get($data, 'custom_data');
233 304 if ($customData) {
234 - $customData = wp_unslash($customData);
235 305 $ticket->syncCustomFields($customData);
236 306 }
237 307 }
238 308 }
@@ -237,10 +307,9 @@
237 307 }
238 308 }
239 309
240 310 /**
241 - * This `validateCustomer` method is responsible for validating customer
242 - * @param object|null $customer // It can be null if there's no customer
311 + * @param object|null $customer
243 312 * @since 1.5.7
244 313 * @throws Exception
245 314 */
246 315 private function validateCustomer($customer)
@@ -248,15 +317,16 @@
248 317 if (!$customer) {
249 318 throw new \Exception(esc_html__('Customer not found', 'fluent-support'));
250 319 }
251 320
252 - if ($customer->status == 'inactive') {
321 + if (!$customer->canAccessPortal()) {
253 322 throw new \Exception(esc_html__('Sorry, You do not have access to customer portal', 'fluent-support'));
254 323 }
255 324 }
256 325
257 326 /**
258 - * This `getCustomer` method is responsible for getting customer
327 + * With a valid signed ticket hash the ticket's own customer is used, otherwise the current user.
328 + *
259 329 * @param array $customerAdditionalData
260 330 * @param object $ticket
261 331 * @return object $customer
262 332 * @throws Exception
@@ -264,12 +334,29 @@
264 334 * @since 1.5.7
265 335 */
266 336 public function getCustomer($customerAdditionalData, $ticket)
267 337 {
268 - if (Arr::get($customerAdditionalData, 'intended_ticket_hash') && Helper::isPublicSignedTicketEnabled()) {
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 + }
269 343 $customer = $ticket->customer;
270 344 } else {
271 - $customer = $this->resolveCustomer(Arr::get($customerAdditionalData, 'on_behalf'), Arr::get($customerAdditionalData, 'user_ip'));
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'));
272 359 }
273 360
274 361 if (!$customer) {
275 362 throw new \Exception(esc_html__('Sorry! No customer found', 'fluent-support'));
@@ -278,9 +365,8 @@
278 365 return $customer;
279 366 }
280 367
281 368 /**
282 - * This `getTicketStatues` method is responsible for getting ticket statuses
283 369 * @param string $requestedStatus
284 370 * @return array
285 371 * @since 1.8.1
286 372 */
@@ -296,9 +382,8 @@
296 382 }
297 383
298 384
299 385 /**
300 - * This `ticketsAdditionalData` method is responsible for getting tickets with additional data
301 386 * @param object $customer
302 387 * @param array $statuses
303 388 * @param array|null $options
304 389 * @return object $tickets
@@ -343,18 +428,42 @@
343 428 $ticket->human_date = sprintf(__('%s ago', 'fluent-support'), human_time_diff(strtotime($ticket->created_at), current_time('timestamp')));
344 429 $ticket->preview_response = $ticket->getLastResponse();
345 430 }
346 431
347 - return $tickets;
432 + return self::hidePrivateTicketFields($tickets);
348 433 }
349 434
350 435 /**
351 - * `resolveCustomer` method will create and return or only return existing customer
352 - * This method will get customer id or customer info or option to force create as parameter.
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 + *
353 462 * @param array $onBehalf
354 - * @param string $userIp // IP address of user
355 - * @param bool $forceCreate Default: false // If true, it will create a new customer
356 - * @return Customer | false //
463 + * @param string $userIp
464 + * @param bool $forceCreate
465 + * @return Customer|false
357 466 */
358 467 public function resolveCustomer($onBehalf, $userIp, $forceCreate = false)
359 468 {
360 469 if (!$onBehalf) {
@@ -377,13 +486,37 @@
377 486 return Customer::getCustomerFromData($onBehalf);
378 487 }
379 488
380 489 /**
381 - * resolveMailboxId method will either get information of the mailbox added by user or default and return the id
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 + *
382 515 * @param int $mailboxId
383 - * @return null
516 + * @return int|null
384 517 */
385 - private function resolveMailboxId($mailboxId)
518 + public function resolveMailboxId($mailboxId)
386 519 {
387 520 $mailbox = MailBox::find($mailboxId);
388 521 if ($mailbox) {
389 522 return $mailbox->id;
@@ -396,13 +529,10 @@
396 529 }
397 530 return null;
398 531 }
399 532
400 - // Supportive methods for getTicket
401 -
402 533 /**
403 - * This `getTicketByID` method is responsible for getting a ticket by id
404 - * @param $ticketId
534 + * @param int|string $ticketId
405 535 * @return object $ticket
406 536 */
407 537 private function getTicketByID($ticketId)
408 538 {
@@ -423,11 +553,11 @@
423 553 return $ticket;
424 554 }
425 555
426 556 /**
427 - * This `checkCustomerTicketAccess` method is responsible for checking customer ticket access
428 557 * @param object $customer
429 558 * @param object $ticket
559 + * @param string|false $action
430 560 * @return bool true if access is granted
431 561 * @throws Exception
432 562 */
433 563 public function checkCustomerTicketAccess($customer, $ticket, $action = false)
@@ -435,9 +565,9 @@
435 565 if (!$customer) {
436 566 throw new \Exception(esc_html__('Sorry, You do not have permission to this support ticket', 'fluent-support'));
437 567 }
438 568
439 - if ($customer->status == 'inactive') {
569 + if (!$customer->canAccessPortal()) {
440 570 throw new \Exception(esc_html__('Sorry, You do not have access to customer portal', 'fluent-support'));
441 571 }
442 572
443 573 if ($ticket->privacy == 'private' && $customer->id != $ticket->customer_id) {
@@ -466,9 +596,8 @@
466 596 }
467 597
468 598
469 599 /**
470 - * This `getResponses` method is responsible for getting a ticket's responses by ticket id
471 600 * @param int $ticketId
472 601 * @return mixed
473 602 */
474 603 private function getResponses($ticketId)
@@ -529,9 +658,8 @@
529 658 return $responses;
530 659 }
531 660
532 661 /**
533 - * This `syncTicketAdditionData` method is responsible for syncing ticket additional data
534 662 * @param object $ticket
535 663 * @return object $ticket
536 664 */
537 665 private function syncTicketAdditionData($ticket)
@@ -559,40 +687,120 @@
559 687 if (defined('FLUENTSUPPORTPRO')) {
560 688 $ticket->custom_fields = $ticket->customData('public', true);
561 689 }
562 690
563 - // Load agent info if ticket was created on behalf of customer
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.
564 694 if ($ticket->created_by) {
565 695 $ticket->load('created_by_person');
566 696 if ($ticket->created_by_person) {
567 697 $ticket->created_by_agent = [
568 - 'full_name' => $ticket->created_by_person->full_name,
569 - 'photo' => $ticket->created_by_person->photo,
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(),
570 702 ];
571 703 }
704 + $ticket->unsetRelation('created_by_person');
572 705 }
573 706
574 - return $ticket;
707 + return self::hidePrivateTicketFields($ticket);
575 708 }
576 709
577 - public function addUserFeedback($approvalStatus, $conversationID)
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)
578 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 +
579 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([
580 748 'object_id' => $conversationID,
581 749 'key' => 'agent_feedback_ratings',
582 - ])->first();
750 + 'object_type' => 'conversation_meta',
751 + 'value' => $approvalStatus,
752 + ]);
583 753
584 - if ($existingAgentFeedback) {
585 - return $this->updateExistingFeedback($existingAgentFeedback, $approvalStatus);
586 - } else {
587 - $agentFeedback = Meta::create([
588 - 'object_id' => $conversationID,
589 - 'key' => 'agent_feedback_ratings',
590 - 'object_type' => 'conversation_meta',
591 - 'value' => $approvalStatus,
592 - ]);
593 - return $agentFeedback;
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;
594 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);
595 803 }
596 804
597 805 private function updateExistingFeedback($existingAgentFeedback, $approvalStatus)
598 806 {