PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 2.3.1
Fluent Support – Helpdesk & Customer Support Ticket System v2.3.1
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 2.3.1, at app/Services/CustomerPortalService.php

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