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