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 +222 -64 2.3.1 → 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,23 +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 105 $data = $this->onlyAllowedPortalTicketFields($data);
73 106
74 - $data['title'] = sanitize_text_field(wp_unslash($data['title']));
75 - $data['content'] = wp_specialchars_decode(wp_unslash(wp_kses_post($data['content'])));
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']);
76 110 $data['customer_id'] = $customer->id;
77 111 $data['product_source'] = 'local';
78 112 $data['mailbox_id'] = $this->resolveMailboxId($mailboxId);
79 113 $data['source'] = 'web';
@@ -80,16 +114,15 @@
80 114
81 115 $disabledFields = apply_filters('fluent_support/disabled_ticket_fields', []);
82 116 $this->validateDisabledFields($data, $disabledFields);
83 117
84 - return $this->storeTicket($data, $customer, $disabledFields);
118 + return $this->storeTicket($data, $customer, $disabledFields, $onBehalf);
85 119 }
86 120
87 121 /**
88 - * Restricts customer-submitted ticket data to the fields a portal customer is allowed to set.
89 - * The framework validator returns the entire request payload, and several Ticket fillable
90 - * columns (agent_id, status, privacy, created_by, closed_by, serial_number, ticket_number, etc.)
91 - * are agent/system-controlled, so they must never pass through from raw request input.
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 + *
92 125 * @param array $data
93 126 * @return array
94 127 */
95 128 private function onlyAllowedPortalTicketFields($data)
@@ -108,9 +141,8 @@
108 141 }
109 142
110 143
111 144 /**
112 - * This `createResponse` method is responsible for creating response by customer in a ticket by ticket id, and data
113 145 * @param array $customerAdditionalData
114 146 * @param int $ticketId
115 147 * @param array $data
116 148 * @return array
@@ -118,9 +150,9 @@
118 150 * @since 1.5.7
119 151 */
120 152 public function createResponse($customerAdditionalData, $ticketId, $data)
121 153 {
122 - $data['content'] = wp_specialchars_decode(wp_unslash($data['content']));
154 + // Already unslashed at the request boundary; ResponseService sanitizes it.
123 155 $data['conversation_type'] = 'response';
124 156
125 157 $ticket = Ticket::with(['customer'])->wherePublicIdentifier($ticketId)->firstOrFail();
126 158 $customer = $this->getCustomer($customerAdditionalData, $ticket);
@@ -126,9 +158,27 @@
126 158 $customer = $this->getCustomer($customerAdditionalData, $ticket);
127 159
128 160 $this->checkCustomerTicketAccess($customer, $ticket, 'response');
129 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 +
130 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 +
131 181 $responseData['ticket'] = Ticket::find($ticket->id);
132 182 $responseData['response']->content = Helper::refreshSignedAttachmentUrls($responseData['response']->content, $ticket->id);
133 183 $responseData['response']->load([
134 184 'attachments' => function ($query) {
@@ -138,15 +188,14 @@
138 188
139 189 return [
140 190 'message' => __('Reply has been added', 'fluent-support'),
141 191 'response' => $responseData['response'],
142 - 'ticket' => $responseData['ticket']
192 + 'ticket' => self::hidePrivateTicketFields($responseData['ticket'])
143 193 ];
144 194 }
145 195
146 196
147 197 /**
148 - * This `closeTicket` is responsible for closing ticket by ticket id
149 198 * @param array $customerAdditionalData
150 199 * @param int $ticketId
151 200 * @return array
152 201 * @throws Exception
@@ -159,14 +208,13 @@
159 208 $this->checkCustomerTicketAccess($customer, $ticket, 'close');
160 209
161 210 return [
162 211 'message' => __('Ticket has been closed', 'fluent-support'),
163 - 'ticket' => (new TicketService())->close($ticket, $customer)
212 + 'ticket' => self::hidePrivateTicketFields((new TicketService())->close($ticket, $customer))
164 213 ];
165 214 }
166 215
167 216 /**
168 - * This `reOpenTicket` is responsible for reopening ticket by ticket id
169 217 * @param array $customerAdditionalData
170 218 * @param int $ticketId
171 219 * @return array
172 220 * @throws Exception
@@ -179,14 +227,13 @@
179 227 $this->checkCustomerTicketAccess($customer, $ticket, 'reopen');
180 228
181 229 return [
182 230 'message' => __('Ticket has been opened again', 'fluent-support'),
183 - 'ticket' => (new TicketService())->reopen($ticket, $customer)
231 + 'ticket' => self::hidePrivateTicketFields((new TicketService())->reopen($ticket, $customer))
184 232 ];
185 233 }
186 234
187 235 /**
188 - * This `validateDisabledFields` method is responsible for validating disabled fields
189 236 * @param array $data
190 237 * @param array $disabledFields
191 238 * @return array $data
192 239 * @since 1.5.7
@@ -206,19 +253,19 @@
206 253 }
207 254
208 255
209 256 /**
210 - * This `storeTicket` method is responsible for storing a ticket in Ticket Model
211 257 * @param array $data
212 258 * @param object $customer
213 259 * @param array $disabledFields
260 + * @param bool $onBehalf
214 261 * @return Ticket
215 262 * @since 1.5.7
216 263 */
217 - private function storeTicket($data, $customer, $disabledFields)
264 + private function storeTicket($data, $customer, $disabledFields, $onBehalf = false)
218 265 {
219 266 /*
220 - * Filter ticket data
267 + * Filter ticket data before it's saved.
221 268 *
222 269 * @since v1.0.0
223 270 * @param array $data
224 271 * @param object $customer
@@ -225,9 +272,9 @@
225 272 */
226 273 $data = apply_filters('fluent_support/create_ticket_data', $data, $customer);
227 274
228 275 /*
229 - * Action before ticket create
276 + * Fires before the ticket is created.
230 277 *
231 278 * @since v1.0.0
232 279 * @param array $data
233 280 * @param object $customer
@@ -235,9 +282,9 @@
235 282 do_action('fluent_support/before_ticket_create', $data, $customer);
236 283
237 284 $ticket = Ticket::create($data);
238 285
239 - TicketService::addTicketAttachments($data, $disabledFields, $ticket, $customer);
286 + TicketService::addTicketAttachments($data, $disabledFields, $ticket, $customer, $onBehalf);
240 287 $this->addCustomData($data, $ticket);
241 288
242 289 do_action('fluent_support/ticket_created', $ticket, $customer);
243 290
@@ -245,9 +292,8 @@
245 292 }
246 293
247 294
248 295 /**
249 - * This `addCustomData` method is responsible for adding custom data to ticket
250 296 * @param array $data
251 297 * @param object $ticket
252 298 * @return void
253 299 */
@@ -255,9 +301,8 @@
255 301 {
256 302 if (defined('FLUENTSUPPORTPRO')) {
257 303 $customData = Arr::get($data, 'custom_data');
258 304 if ($customData) {
259 - $customData = wp_unslash($customData);
260 305 $ticket->syncCustomFields($customData);
261 306 }
262 307 }
263 308 }
@@ -262,10 +307,9 @@
262 307 }
263 308 }
264 309
265 310 /**
266 - * This `validateCustomer` method is responsible for validating customer
267 - * @param object|null $customer // It can be null if there's no customer
311 + * @param object|null $customer
268 312 * @since 1.5.7
269 313 * @throws Exception
270 314 */
271 315 private function validateCustomer($customer)
@@ -273,15 +317,16 @@
273 317 if (!$customer) {
274 318 throw new \Exception(esc_html__('Customer not found', 'fluent-support'));
275 319 }
276 320
277 - if ($customer->status == 'inactive') {
321 + if (!$customer->canAccessPortal()) {
278 322 throw new \Exception(esc_html__('Sorry, You do not have access to customer portal', 'fluent-support'));
279 323 }
280 324 }
281 325
282 326 /**
283 - * 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 + *
284 329 * @param array $customerAdditionalData
285 330 * @param object $ticket
286 331 * @return object $customer
287 332 * @throws Exception
@@ -291,14 +336,27 @@
291 336 public function getCustomer($customerAdditionalData, $ticket)
292 337 {
293 338 $intendedHash = Arr::get($customerAdditionalData, 'intended_ticket_hash');
294 339 if ($intendedHash && Helper::isPublicSignedTicketEnabled()) {
295 - if ($ticket->hash !== $intendedHash) {
340 + if (!is_string($intendedHash) || !hash_equals((string) $ticket->hash, $intendedHash)) {
296 341 throw new \Exception(esc_html__('Sorry, You do not have permission to this support ticket', 'fluent-support'));
297 342 }
298 343 $customer = $ticket->customer;
299 344 } else {
300 - $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'));
301 359 }
302 360
303 361 if (!$customer) {
304 362 throw new \Exception(esc_html__('Sorry! No customer found', 'fluent-support'));
@@ -307,9 +365,8 @@
307 365 return $customer;
308 366 }
309 367
310 368 /**
311 - * This `getTicketStatues` method is responsible for getting ticket statuses
312 369 * @param string $requestedStatus
313 370 * @return array
314 371 * @since 1.8.1
315 372 */
@@ -325,9 +382,8 @@
325 382 }
326 383
327 384
328 385 /**
329 - * This `ticketsAdditionalData` method is responsible for getting tickets with additional data
330 386 * @param object $customer
331 387 * @param array $statuses
332 388 * @param array|null $options
333 389 * @return object $tickets
@@ -372,18 +428,42 @@
372 428 $ticket->human_date = sprintf(__('%s ago', 'fluent-support'), human_time_diff(strtotime($ticket->created_at), current_time('timestamp')));
373 429 $ticket->preview_response = $ticket->getLastResponse();
374 430 }
375 431
376 - return $tickets;
432 + return self::hidePrivateTicketFields($tickets);
377 433 }
378 434
379 435 /**
380 - * `resolveCustomer` method will create and return or only return existing customer
381 - * 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 + *
382 462 * @param array $onBehalf
383 - * @param string $userIp // IP address of user
384 - * @param bool $forceCreate Default: false // If true, it will create a new customer
385 - * @return Customer | false //
463 + * @param string $userIp
464 + * @param bool $forceCreate
465 + * @return Customer|false
386 466 */
387 467 public function resolveCustomer($onBehalf, $userIp, $forceCreate = false)
388 468 {
389 469 if (!$onBehalf) {
@@ -406,13 +486,37 @@
406 486 return Customer::getCustomerFromData($onBehalf);
407 487 }
408 488
409 489 /**
410 - * 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 + *
411 515 * @param int $mailboxId
412 - * @return null
516 + * @return int|null
413 517 */
414 - private function resolveMailboxId($mailboxId)
518 + public function resolveMailboxId($mailboxId)
415 519 {
416 520 $mailbox = MailBox::find($mailboxId);
417 521 if ($mailbox) {
418 522 return $mailbox->id;
@@ -425,13 +529,10 @@
425 529 }
426 530 return null;
427 531 }
428 532
429 - // Supportive methods for getTicket
430 -
431 533 /**
432 - * This `getTicketByID` method is responsible for getting a ticket by id
433 - * @param $ticketId
534 + * @param int|string $ticketId
434 535 * @return object $ticket
435 536 */
436 537 private function getTicketByID($ticketId)
437 538 {
@@ -452,11 +553,11 @@
452 553 return $ticket;
453 554 }
454 555
455 556 /**
456 - * This `checkCustomerTicketAccess` method is responsible for checking customer ticket access
457 557 * @param object $customer
458 558 * @param object $ticket
559 + * @param string|false $action
459 560 * @return bool true if access is granted
460 561 * @throws Exception
461 562 */
462 563 public function checkCustomerTicketAccess($customer, $ticket, $action = false)
@@ -464,9 +565,9 @@
464 565 if (!$customer) {
465 566 throw new \Exception(esc_html__('Sorry, You do not have permission to this support ticket', 'fluent-support'));
466 567 }
467 568
468 - if ($customer->status == 'inactive') {
569 + if (!$customer->canAccessPortal()) {
469 570 throw new \Exception(esc_html__('Sorry, You do not have access to customer portal', 'fluent-support'));
470 571 }
471 572
472 573 if ($ticket->privacy == 'private' && $customer->id != $ticket->customer_id) {
@@ -495,9 +596,8 @@
495 596 }
496 597
497 598
498 599 /**
499 - * This `getResponses` method is responsible for getting a ticket's responses by ticket id
500 600 * @param int $ticketId
501 601 * @return mixed
502 602 */
503 603 private function getResponses($ticketId)
@@ -558,9 +658,8 @@
558 658 return $responses;
559 659 }
560 660
561 661 /**
562 - * This `syncTicketAdditionData` method is responsible for syncing ticket additional data
563 662 * @param object $ticket
564 663 * @return object $ticket
565 664 */
566 665 private function syncTicketAdditionData($ticket)
@@ -588,20 +687,25 @@
588 687 if (defined('FLUENTSUPPORTPRO')) {
589 688 $ticket->custom_fields = $ticket->customData('public', true);
590 689 }
591 690
592 - // 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.
593 694 if ($ticket->created_by) {
594 695 $ticket->load('created_by_person');
595 696 if ($ticket->created_by_person) {
596 697 $ticket->created_by_agent = [
597 - 'full_name' => $ticket->created_by_person->full_name,
598 - '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(),
599 702 ];
600 703 }
704 + $ticket->unsetRelation('created_by_person');
601 705 }
602 706
603 - return $ticket;
707 + return self::hidePrivateTicketFields($ticket);
604 708 }
605 709
606 710 /**
607 711 * @param string $approvalStatus
@@ -631,18 +735,72 @@
631 735 'key' => 'agent_feedback_ratings',
632 736 ])->first();
633 737
634 738 if ($existingAgentFeedback) {
635 - return $this->updateExistingFeedback($existingAgentFeedback, $approvalStatus);
636 - } else {
637 - $agentFeedback = Meta::create([
638 - 'object_id' => $conversationID,
639 - 'key' => 'agent_feedback_ratings',
640 - 'object_type' => 'conversation_meta',
641 - 'value' => $approvalStatus,
642 - ]);
643 - return $agentFeedback;
739 + $wasRemoved = $existingAgentFeedback->value === $approvalStatus;
740 + $result = $this->updateExistingFeedback($existingAgentFeedback, $approvalStatus);
741 + if (!$wasRemoved) {
742 + $this->fireFeedbackReceived($approvalStatus, $conversation);
743 + }
744 + return $result;
644 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);
645 803 }
646 804
647 805 private function updateExistingFeedback($existingAgentFeedback, $approvalStatus)
648 806 {