PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / trunk
Fluent Support – Helpdesk & Customer Support Ticket System vtrunk
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 1.5.5 All 68 releases
fluent-support / app / Services / CustomerPortalService.php

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

665 lines 23.3 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\Models\MailBox;
7 use FluentSupport\App\Models\Meta;
8 use FluentSupport\App\Models\Ticket;
9 use FluentSupport\App\Models\Customer;
10 use FluentSupport\App\Services\Tickets\ResponseService;
11 use FluentSupport\App\Services\Tickets\TicketService;
12 use FluentSupport\Framework\Support\Arr;
13 use FluentSupport\App\Models\Attachment;
14 use FluentSupport\App\Models\Conversation;
15
16 class CustomerPortalService
17 {
18 /**
19 * This `getTickets` method is responsible for getting tickets for customer
20 * @param object $customer
21 * @param string $requestedStatus
22 * @param array|null $options
23 * @return object
24 * @throws Exception
25 * @since 1.8.1
26 */
27 public function getTickets($customer, $requestedStatus, $options = [])
28 {
29 $this->validateCustomer($customer);
30
31 $statuses = $this->getTicketStatues($requestedStatus);
32
33 return $this->ticketsAdditionalData($customer, $statuses, $options);
34 }
35
36 /**
37 * getTicket method will get the ticket information with customer and agent as well as response in a ticket by ticket id
38 * @param array $customerAdditionalData
39 * @param int $ticketId
40 * @return array
41 * @since 1.5.7
42 */
43 public function getTicket($customerAdditionalData, $ticketId)
44 {
45 $ticket = $this->getTicketByID($ticketId);
46 // translators: %s is the time duration (e.g., "2 hours", "3 days")
47 $ticket->human_date = sprintf(__('%s ago', 'fluent-support'), human_time_diff(strtotime($ticket->created_at), current_time('timestamp')));
48
49 $customer = $this->getCustomer($customerAdditionalData, $ticket);
50
51 $this->checkCustomerTicketAccess($customer, $ticket);
52
53 return [
54 'ticket' => $this->syncTicketAdditionData($ticket),
55 'responses' => $this->getResponses($ticket->id),
56 'sign_on_id' => $ticket->customer_id
57 ];
58 }
59
60 /**
61 * This `createTicket` method is responsible for creating ticket for customer
62 * @param object $customer
63 * @param array $data
64 * @param int $mailboxId
65 * @return Ticket
66 * @throws Exception
67 */
68 public function createTicket($customer, $data, $mailboxId)
69 {
70 $this->validateCustomer($customer);
71
72 $data = $this->onlyAllowedPortalTicketFields($data);
73
74 // Input is already unslashed at the request boundary; sanitize only, so
75 // literal backslashes in the title and body survive.
76 $data['title'] = sanitize_text_field($data['title']);
77 $data['content'] = wp_specialchars_decode(wp_kses_post($data['content']));
78 $data['customer_id'] = $customer->id;
79 $data['product_source'] = 'local';
80 $data['mailbox_id'] = $this->resolveMailboxId($mailboxId);
81 $data['source'] = 'web';
82
83 $disabledFields = apply_filters('fluent_support/disabled_ticket_fields', []);
84 $this->validateDisabledFields($data, $disabledFields);
85
86 return $this->storeTicket($data, $customer, $disabledFields);
87 }
88
89 /**
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.
94 * @param array $data
95 * @return array
96 */
97 private function onlyAllowedPortalTicketFields($data)
98 {
99 $allowedKeys = [
100 'title',
101 'content',
102 'product_id',
103 'client_priority',
104 'custom_data',
105 'attachments',
106 'message_id',
107 ];
108
109 return array_intersect_key($data, array_flip($allowedKeys));
110 }
111
112
113 /**
114 * This `createResponse` method is responsible for creating response by customer in a ticket by ticket id, and data
115 * @param array $customerAdditionalData
116 * @param int $ticketId
117 * @param array $data
118 * @return array
119 * @throws Exception
120 * @since 1.5.7
121 */
122 public function createResponse($customerAdditionalData, $ticketId, $data)
123 {
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']);
127 $data['conversation_type'] = 'response';
128
129 $ticket = Ticket::with(['customer'])->wherePublicIdentifier($ticketId)->firstOrFail();
130 $customer = $this->getCustomer($customerAdditionalData, $ticket);
131
132 $this->checkCustomerTicketAccess($customer, $ticket, 'response');
133
134 $responseData = (new ResponseService())->createResponse($data, $customer, $ticket);
135 $responseData['ticket'] = Ticket::find($ticket->id);
136 $responseData['response']->content = Helper::refreshSignedAttachmentUrls($responseData['response']->content, $ticket->id);
137 $responseData['response']->load([
138 'attachments' => function ($query) {
139 $query->where('status', 'active');
140 }
141 ]);
142
143 return [
144 'message' => __('Reply has been added', 'fluent-support'),
145 'response' => $responseData['response'],
146 'ticket' => $responseData['ticket']
147 ];
148 }
149
150
151 /**
152 * This `closeTicket` is responsible for closing ticket by ticket id
153 * @param array $customerAdditionalData
154 * @param int $ticketId
155 * @return array
156 * @throws Exception
157 */
158 public function closeTicket($customerAdditionalData, $ticketId)
159 {
160 $ticket = Ticket::with(['customer'])->wherePublicIdentifier($ticketId)->firstOrFail();
161 $customer = $this->getCustomer($customerAdditionalData, $ticket);
162
163 $this->checkCustomerTicketAccess($customer, $ticket, 'close');
164
165 return [
166 'message' => __('Ticket has been closed', 'fluent-support'),
167 'ticket' => (new TicketService())->close($ticket, $customer)
168 ];
169 }
170
171 /**
172 * This `reOpenTicket` is responsible for reopening ticket by ticket id
173 * @param array $customerAdditionalData
174 * @param int $ticketId
175 * @return array
176 * @throws Exception
177 */
178 public function reOpenTicket($customerAdditionalData, $ticketId)
179 {
180 $ticket = Ticket::with(['customer'])->wherePublicIdentifier($ticketId)->firstOrFail();
181 $customer = $this->getCustomer($customerAdditionalData, $ticket);
182
183 $this->checkCustomerTicketAccess($customer, $ticket, 'reopen');
184
185 return [
186 'message' => __('Ticket has been opened again', 'fluent-support'),
187 'ticket' => (new TicketService())->reopen($ticket, $customer)
188 ];
189 }
190
191 /**
192 * This `validateDisabledFields` method is responsible for validating disabled fields
193 * @param array $data
194 * @param array $disabledFields
195 * @return array $data
196 * @since 1.5.7
197 */
198 private function validateDisabledFields($data, $disabledFields)
199 {
200 if (!in_array('priority', $disabledFields)) {
201 $data['priority'] = sanitize_text_field($data['client_priority'] ?? '');
202 $data['client_priority'] = sanitize_text_field($data['client_priority'] ?? '');
203 }
204
205 if (in_array('product_services', $disabledFields)) {
206 unset($data['product_id']);
207 }
208
209 return $data;
210 }
211
212
213 /**
214 * This `storeTicket` method is responsible for storing a ticket in Ticket Model
215 * @param array $data
216 * @param object $customer
217 * @param array $disabledFields
218 * @return Ticket
219 * @since 1.5.7
220 */
221 private function storeTicket($data, $customer, $disabledFields)
222 {
223 /*
224 * Filter ticket data
225 *
226 * @since v1.0.0
227 * @param array $data
228 * @param object $customer
229 */
230 $data = apply_filters('fluent_support/create_ticket_data', $data, $customer);
231
232 /*
233 * Action before ticket create
234 *
235 * @since v1.0.0
236 * @param array $data
237 * @param object $customer
238 */
239 do_action('fluent_support/before_ticket_create', $data, $customer);
240
241 $ticket = Ticket::create($data);
242
243 TicketService::addTicketAttachments($data, $disabledFields, $ticket, $customer);
244 $this->addCustomData($data, $ticket);
245
246 do_action('fluent_support/ticket_created', $ticket, $customer);
247
248 return $ticket;
249 }
250
251
252 /**
253 * This `addCustomData` method is responsible for adding custom data to ticket
254 * @param array $data
255 * @param object $ticket
256 * @return void
257 */
258 private function addCustomData($data, $ticket)
259 {
260 if (defined('FLUENTSUPPORTPRO')) {
261 $customData = Arr::get($data, 'custom_data');
262 if ($customData) {
263 // Already unslashed at the request boundary.
264 $ticket->syncCustomFields($customData);
265 }
266 }
267 }
268
269 /**
270 * This `validateCustomer` method is responsible for validating customer
271 * @param object|null $customer // It can be null if there's no customer
272 * @since 1.5.7
273 * @throws Exception
274 */
275 private function validateCustomer($customer)
276 {
277 if (!$customer) {
278 throw new \Exception(esc_html__('Customer not found', 'fluent-support'));
279 }
280
281 if (!$customer->canAccessPortal()) {
282 throw new \Exception(esc_html__('Sorry, You do not have access to customer portal', 'fluent-support'));
283 }
284 }
285
286 /**
287 * This `getCustomer` method is responsible for getting customer
288 * @param array $customerAdditionalData
289 * @param object $ticket
290 * @return object $customer
291 * @throws Exception
292 *
293 * @since 1.5.7
294 */
295 public function getCustomer($customerAdditionalData, $ticket)
296 {
297 $intendedHash = Arr::get($customerAdditionalData, 'intended_ticket_hash');
298 if ($intendedHash && Helper::isPublicSignedTicketEnabled()) {
299 if ($ticket->hash !== $intendedHash) {
300 throw new \Exception(esc_html__('Sorry, You do not have permission to this support ticket', 'fluent-support'));
301 }
302 $customer = $ticket->customer;
303 } else {
304 $customer = $this->resolveCustomer(Arr::get($customerAdditionalData, 'on_behalf'), Arr::get($customerAdditionalData, 'user_ip'));
305 }
306
307 if (!$customer) {
308 throw new \Exception(esc_html__('Sorry! No customer found', 'fluent-support'));
309 }
310
311 return $customer;
312 }
313
314 /**
315 * This `getTicketStatues` method is responsible for getting ticket statuses
316 * @param string $requestedStatus
317 * @return array
318 * @since 1.8.1
319 */
320 private function getTicketStatues($requestedStatus)
321 {
322 $statuses = [
323 'open' => ['new', 'active', 'on-hold'],
324 'all' => [],
325 'closed' => ['closed']
326 ];
327
328 return Arr::get($statuses, $requestedStatus, []);
329 }
330
331
332 /**
333 * This `ticketsAdditionalData` method is responsible for getting tickets with additional data
334 * @param object $customer
335 * @param array $statuses
336 * @param array|null $options
337 * @return object $tickets
338 * @since 1.5.7
339 */
340 private function ticketsAdditionalData($customer, $statuses, $options = [])
341 {
342 $defaultOptions = [
343 'search' => null,
344 'sorting' => null,
345 'filters' => null
346 ];
347
348 $ticketOptions = wp_parse_args($options, $defaultOptions);
349
350 $tickets = Ticket::with([
351 'customer' => function ($query) {
352 $query->select(['first_name', 'last_name', 'id']);
353 }, 'agent' => function ($query) {
354 $query->select(['first_name', 'last_name', 'id']);
355 }
356 ])->where('customer_id', $customer->id)
357 ->when(!empty($ticketOptions['sorting'] && !empty($ticketOptions['sorting']['sort_by'])), function ($query) use ($ticketOptions) {
358 return $query->orderBy(sanitize_sql_orderby($ticketOptions['sorting']['sort_by']), sanitize_sql_orderby($ticketOptions['sorting']['sort_type']));
359 })
360 ->when(!empty($options['filters']['product_id']), function ($query) use ($ticketOptions) {
361 return $query->where('product_id', $ticketOptions['filters']['product_id']);
362 })
363 ->when($statuses, function ($query) use ($statuses) {
364 return $query->whereIn('status', $statuses);
365 })
366 ->when($ticketOptions['search'], function ($query) use ($ticketOptions) {
367 return $query->searchBy($ticketOptions['search']);
368 })
369 ->when(empty($ticketOptions['sorting']), function ($query) {
370 return $query->latest('updated_at');
371 })
372 ->paginate();
373
374 foreach ($tickets as $ticket) {
375 // translators: %s is the time duration (e.g., "2 hours", "3 days")
376 $ticket->human_date = sprintf(__('%s ago', 'fluent-support'), human_time_diff(strtotime($ticket->created_at), current_time('timestamp')));
377 $ticket->preview_response = $ticket->getLastResponse();
378 }
379
380 return $tickets;
381 }
382
383 /**
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.
386 * @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 //
390 */
391 public function resolveCustomer($onBehalf, $userIp, $forceCreate = false)
392 {
393 if (!$onBehalf) {
394 $user = get_user_by('ID', get_current_user_id());
395 if (!$user) {
396 return false;
397 }
398
399 $onBehalf = [
400 'user_id' => $user->ID,
401 'email' => $user->user_email,
402 'last_ip_address' => $userIp
403 ];
404 }
405
406 if ($forceCreate) {
407 return Customer::maybeCreateCustomer($onBehalf);
408 }
409
410 return Customer::getCustomerFromData($onBehalf);
411 }
412
413 /**
414 * resolveMailboxId method will either get information of the mailbox added by user or default and return the id
415 * @param int $mailboxId
416 * @return null
417 */
418 private function resolveMailboxId($mailboxId)
419 {
420 $mailbox = MailBox::find($mailboxId);
421 if ($mailbox) {
422 return $mailbox->id;
423 }
424
425 $mailbox = Helper::getDefaultMailBox();
426
427 if ($mailbox) {
428 return $mailbox->id;
429 }
430 return null;
431 }
432
433 // Supportive methods for getTicket
434
435 /**
436 * This `getTicketByID` method is responsible for getting a ticket by id
437 * @param $ticketId
438 * @return object $ticket
439 */
440 private function getTicketByID($ticketId)
441 {
442 $ticket = Ticket::wherePublicIdentifier($ticketId)
443 ->with([
444 'customer' => function ($query) {
445 $query->select(['first_name', 'email', 'person_type', 'last_name', 'id', 'avatar']);
446 }, 'agent' => function ($query) {
447 $query->select(['first_name', 'email', 'person_type', 'last_name', 'id', 'title', 'avatar']);
448 },
449 'product',
450 'attachments' => function ($q) {
451 $q->where('status', 'active');
452 }
453 ])
454 ->first();
455
456 return $ticket;
457 }
458
459 /**
460 * This `checkCustomerTicketAccess` method is responsible for checking customer ticket access
461 * @param object $customer
462 * @param object $ticket
463 * @return bool true if access is granted
464 * @throws Exception
465 */
466 public function checkCustomerTicketAccess($customer, $ticket, $action = false)
467 {
468 if (!$customer) {
469 throw new \Exception(esc_html__('Sorry, You do not have permission to this support ticket', 'fluent-support'));
470 }
471
472 if (!$customer->canAccessPortal()) {
473 throw new \Exception(esc_html__('Sorry, You do not have access to customer portal', 'fluent-support'));
474 }
475
476 if ($ticket->privacy == 'private' && $customer->id != $ticket->customer_id) {
477 if ($action) {
478 throw new \Exception(sprintf(
479 // translators: %s is the action being performed (e.g., "view", "edit", "delete")
480 esc_html__("Sorry! You cannot %s this ticket", 'fluent-support'),
481 esc_html($action)
482 ));
483 } else {
484 throw new \Exception(esc_html__('You do not have permission to view this support ticket', 'fluent-support'));
485 }
486 }
487
488 $result = apply_filters('fluent_support/can_customer_access_ticket', true, $customer, $ticket, $action);
489
490 if ($result && !is_wp_error($result)) {
491 return $result;
492 }
493
494 if (!$result) {
495 throw new \Exception(esc_html__('Sorry, You cannot access this ticket', 'fluent-support'));
496 }
497
498 throw new \Exception(esc_html($result->get_error_message()));
499 }
500
501
502 /**
503 * This `getResponses` method is responsible for getting a ticket's responses by ticket id
504 * @param int $ticketId
505 * @return mixed
506 */
507 private function getResponses($ticketId)
508 {
509 $responses = Conversation::where('ticket_id', $ticketId)
510 ->with([
511 'person' => function ($query) {
512 $query->select(['first_name', 'email', 'person_type', 'last_name', 'id', 'title', 'avatar']);
513 },
514 'attachments' => function ($query) {
515 $query->where('status', 'active');
516 }
517 ])
518 ->filterByType(['response', 'ticket_merge_activity', 'ticket_split_activity'])
519 ->orderBy('created_at', 'desc')
520 ->orderBy('id', 'desc')
521 ->get();
522
523 $contents = [];
524 foreach ($responses as $response) {
525 $contents[$response->id] = $response->content;
526 }
527
528 $contents = Helper::refreshSignedAttachmentUrlsInContents($contents, $ticketId);
529
530 $feedbacks = [];
531 if (defined('FLUENTSUPPORTPRO_PLUGIN_VERSION') && Helper::isAgentFeedbackEnabled()) {
532 $responseIds = $responses->pluck('id')->toArray();
533 if ($responseIds) {
534 $feedbacks = Meta::where('object_type', 'conversation_meta')
535 ->where('key', 'agent_feedback_ratings')
536 ->whereIn('object_id', $responseIds)
537 ->get()
538 ->keyBy('object_id');
539 }
540 }
541
542 foreach ($responses as $response) {
543 if ($feedbacks && $feedbacks->has($response->id)) {
544 $response->agent_feedback = $feedbacks->get($response->id)->value;
545 }
546
547 // translators: %s is the time duration (e.g., "2 hours", "3 days")
548 $response->human_date = sprintf(__('%s ago', 'fluent-support'), human_time_diff(strtotime($response->created_at), current_time('timestamp')));
549 if (isset($contents[$response->id])) {
550 $response->content = $contents[$response->id];
551 }
552
553 $responseContent = apply_filters('fluent_support/response_content_before_render', $response->content, $response, null);
554 $responseContent = links_add_target(make_clickable($responseContent));
555 $response->content = apply_filters('fluent_support/response_content_after_render', $responseContent, $response, null);
556
557 if ($response->person) {
558 $response->person->setHidden(['email']);
559 }
560 }
561
562 return $responses;
563 }
564
565 /**
566 * This `syncTicketAdditionData` method is responsible for syncing ticket additional data
567 * @param object $ticket
568 * @return object $ticket
569 */
570 private function syncTicketAdditionData($ticket)
571 {
572 $ticket->content = Helper::refreshSignedAttachmentUrls($ticket->content, $ticket->id);
573 $ticketContent = apply_filters('fluent_support/ticket_content_before_render', $ticket->content, $ticket);
574 $ticketContent = links_add_target(make_clickable($ticketContent));
575 $ticket->content = apply_filters('fluent_support/ticket_content_after_render', $ticketContent, $ticket);
576
577 if ($ticket->customer) {
578 $ticket->customer->setHidden(['email']);
579 }
580
581 if ($ticket->agent) {
582 $ticket->agent->setHidden(['email']);
583 }
584
585 if ($ticket->status == 'closed') {
586 $ticket->load('closed_by_person');
587 if ($ticket->closed_by_person) {
588 $ticket->closed_by_person->setVisible(['first_name', 'last_name', 'id', 'full_name', 'photo']);
589 }
590 }
591
592 if (defined('FLUENTSUPPORTPRO')) {
593 $ticket->custom_fields = $ticket->customData('public', true);
594 }
595
596 // Load agent info if ticket was created on behalf of customer
597 if ($ticket->created_by) {
598 $ticket->load('created_by_person');
599 if ($ticket->created_by_person) {
600 $ticket->created_by_agent = [
601 'full_name' => $ticket->created_by_person->full_name,
602 'photo' => $ticket->created_by_person->photo,
603 ];
604 }
605 }
606
607 return $ticket;
608 }
609
610 /**
611 * @param string $approvalStatus
612 * @param int $conversationID
613 * @param int $ticketId The authorized ticket ID the conversation must belong to
614 * @throws Exception
615 */
616 public function addUserFeedback($approvalStatus, $conversationID, $ticketId)
617 {
618 if (!in_array($approvalStatus, ['like', 'dislike'], true)) {
619 throw new Exception(esc_html__('Invalid feedback value provided', 'fluent-support'));
620 }
621
622 $conversation = Conversation::with('person')
623 ->where('id', $conversationID)
624 ->where('ticket_id', $ticketId)
625 ->where('conversation_type', 'response')
626 ->first();
627
628 if (!$conversation || !$conversation->person || $conversation->person->person_type !== 'agent') {
629 throw new Exception(esc_html__('Invalid conversation for feedback', 'fluent-support'));
630 }
631
632 $existingAgentFeedback = Meta::where([
633 'object_id' => $conversationID,
634 'object_type' => 'conversation_meta',
635 'key' => 'agent_feedback_ratings',
636 ])->first();
637
638 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;
648 }
649 }
650
651 private function updateExistingFeedback($existingAgentFeedback, $approvalStatus)
652 {
653 if (($existingAgentFeedback->value === 'like' && $approvalStatus === 'like') ||
654 ($existingAgentFeedback->value === 'dislike' && $approvalStatus === 'dislike')) {
655 $existingAgentFeedback->delete();
656 } else {
657 $existingAgentFeedback->update([
658 'value' => $approvalStatus,
659 ]);
660 }
661 return $existingAgentFeedback;
662 }
663
664 }
665