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
fluent-support / app / Models / Traits / CustomerTrait.php

CustomerTrait.php in Fluent Support – Helpdesk & Customer Support Ticket System 2.4.5, at app/Models/Traits/CustomerTrait.php

648 lines 20.5 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\Models\Traits;
4
5 use Exception;
6 use FluentSupport\App\Models\Ticket;
7 use FluentSupport\App\Models\Conversation;
8 use FluentSupport\App\Services\Helper;
9 use FluentSupport\Framework\Support\Arr;
10 use FluentSupport\App\Services\ProfileInfoService;
11 use FluentSupport\App\Services\Tickets\AgentTicketAccess;
12 use FluentSupport\App\Services\Tickets\TicketService;
13
14 trait CustomerTrait
15 {
16 /**
17 * Identity columns that must never be mass-assigned on update. The
18 * `creating` hook sets them on create, but update() bypasses it.
19 *
20 * @var array
21 */
22 private static $immutableUpdateKeys = [
23 'hash', // portal identity token
24 'user_id', // WordPress user linkage
25 'person_type', // customer/agent record type, enforces the global scope
26 ];
27
28 /**
29 * Whether this customer is barred from support altogether. Only 'active'
30 * is allowed, so any new status blocks until explicitly permitted.
31 *
32 * @return bool
33 */
34 public function isBlocked()
35 {
36 // Some imported rows have an empty status. Treat it as the column default.
37 $status = $this->status ?: 'active';
38
39 return $status !== 'active';
40 }
41
42 /**
43 * Records that no WordPress account is linked to. Integrations can store
44 * 0 instead of NULL, so both count as unlinked.
45 *
46 * @param \FluentSupport\Framework\Database\Orm\Builder $query
47 * @return \FluentSupport\Framework\Database\Orm\Builder
48 */
49 public function scopeUnclaimed($query)
50 {
51 return $query->where(function ($q) {
52 $q->whereNull('user_id')->orWhere('user_id', 0);
53 });
54 }
55
56 /**
57 * Records that belong to a WordPress account.
58 *
59 * @param \FluentSupport\Framework\Database\Orm\Builder $query
60 * @return \FluentSupport\Framework\Database\Orm\Builder
61 */
62 public function scopeClaimed($query)
63 {
64 return $query->whereNotNull('user_id')->where('user_id', '!=', 0);
65 }
66
67 /**
68 * Whether this customer may use the customer portal.
69 *
70 * @return bool
71 */
72 public function canAccessPortal()
73 {
74 return !$this->isBlocked();
75 }
76
77 /**
78 * Whether Fluent Support may send this customer a notification. Same check
79 * as portal access for now, kept separate so an email verification policy
80 * can change one without the other.
81 *
82 * @return bool
83 */
84 public function canReceiveNotifications()
85 {
86 return !$this->isBlocked();
87 }
88
89 /**
90 * @param string $search
91 * @param string $status
92 * @return object
93 */
94 public function getCustomers($search, $status=false)
95 {
96 $customersQuery = static::latest();
97
98 if ( $search ) {
99 $customersQuery = $customersQuery->searchBy($search);
100 }
101
102 if ($status && $status != 'all') {
103 $customersQuery->filterByStatues([$status]);
104 }
105
106 $customers = $customersQuery->paginate();
107
108 if ( defined( 'FLUENTCRM' ) && $customers->isEmpty() && !$status ) {
109 $customers = $this->findInCrmAndMakeCustomer ( $search );
110 ! is_null( $customers ) ? $customers : $customers = $customersQuery->paginate();
111 }
112
113 if ( defined('FLUENT_SUPPORT_PRO_DIR_FILE') && $customers->isEmpty() && !$status ) {
114 $customers = $this->findUserInWPAndMakeCustomer( $search );
115 ! is_null( $customers ) ? $customers : $customers = $customersQuery->paginate();
116 }
117
118 return $this->attachCustomersMetaData( $customers );
119 }
120
121 public function getCustomerField($customer_id,$userID = null)
122 {
123 $basicFields = [
124 'first_name' => [
125 'label' => __('First Name', 'fluent-support'),
126 'data_type' => 'text',
127 'placeholder' => __('First Name', 'fluent-support'),
128 'type' => 'input-text',
129 'wrapper_class' => 'fs_half_field required',
130 ],
131 'last_name' => [
132 'label' => __('Last Name', 'fluent-support'),
133 'data_type' => 'text',
134 'placeholder' => __('Last Name', 'fluent-support'),
135 'type' => 'input-text',
136 'wrapper_class' => 'fs_half_field',
137 ],
138 'email' => [
139 'label' => __('Email', 'fluent-support'),
140 'data_type' => 'email',
141 'placeholder' => __('Email', 'fluent-support'),
142 'type' => 'input-text',
143 'wrapper_class' => 'fs_half_field required',
144 'disabled' => !empty($customer_id),
145 ],
146 'title' => [
147 'label' => __('Job Title', 'fluent-support'),
148 'data_type' => 'text',
149 'placeholder' => __('Job Title', 'fluent-support'),
150 'type' => 'input-text',
151 'wrapper_class' => 'fs_half_field',
152 ],
153 'note' => [
154 'label' => __('Note', 'fluent-support'),
155 'data_type' => 'textarea',
156 'placeholder' => __('Note', 'fluent-support'),
157 'type' => 'input-text',
158 ],
159 'status' => [
160 'label' => __('Status', 'fluent-support'),
161 'data_type' => 'text',
162 'type' => 'input-radio',
163 'options' => [
164 'active' => ['id' => 'active', 'value' => 'active', 'label' => __('Active', 'fluent-support')],
165 'inactive' => ['id' => 'inactive', 'value' => 'inactive', 'label' => __('Blocked', 'fluent-support')],
166 ],
167 ],
168 'status_html' => [
169 'dependency' => [
170 'depends_on' => 'status',
171 'operator' => '=',
172 'value' => 'inactive',
173 ],
174 'type' => 'html-viewer',
175 'wrapper_class' => 'fs_warn_alert_wrapper',
176 'html' => __('If you select the <b>Blocked</b> status, this customer will not be able to submit a ticket or any response.', 'fluent-support'),
177 ],
178 ];
179
180 $addressFields = [
181 'address_line_1' => [
182 'label' => __('Address Line 1', 'fluent-support'),
183 'data_type' => 'text',
184 'placeholder' => __('Address Line 1', 'fluent-support'),
185 'type' => 'input-text',
186 'wrapper_class' => 'fs_half_field',
187 ],
188 'address_line_2' => [
189 'label' => __('Address Line 2', 'fluent-support'),
190 'data_type' => 'text',
191 'placeholder' => __('Address Line 2', 'fluent-support'),
192 'type' => 'input-text',
193 'wrapper_class' => 'fs_half_field',
194 ],
195 'city' => [
196 'label' => __('City', 'fluent-support'),
197 'data_type' => 'text',
198 'placeholder' => __('City', 'fluent-support'),
199 'type' => 'input-text',
200 'wrapper_class' => 'fs_half_field',
201 ],
202 'state' => [
203 'label' => __('State', 'fluent-support'),
204 'data_type' => 'text',
205 'placeholder' => __('State', 'fluent-support'),
206 'type' => 'input-text',
207 'wrapper_class' => 'fs_half_field',
208 ],
209 'zip' => [
210 'label' => __('Zip Code', 'fluent-support'),
211 'data_type' => 'text',
212 'placeholder' => __('Zip Code', 'fluent-support'),
213 'type' => 'input-text',
214 'wrapper_class' => 'fs_half_field',
215 ],
216 'country' => [
217 'label' => __('Country', 'fluent-support'),
218 'placeholder' => __('Country', 'fluent-support'),
219 'type' => 'country-selector',
220 'wrapper_class' => 'fs_half_field',
221 ],
222 ];
223
224 if ($userID) {
225 $addressFields = apply_filters('fluent_support/custom_registration_form_fields',$addressFields);
226
227 foreach ($addressFields as $key => $field) {
228 if (isset($field['type']) && $field['type'] === 'country-selector') {
229 continue;
230 }
231
232 // _formBuilder.vue renders custom fields as 'input-text' with the real type in data_type.
233 $addressFields[$key]['data_type'] = $addressFields[$key]['type'];
234 $addressFields[$key]['type'] = 'input-text';
235 }
236 }
237
238 $loading = false;
239
240 $state = [
241 'basic_fields' => $basicFields,
242 'address_fields' => $addressFields,
243 'loading' => $loading,
244 ];
245
246 return $state;
247
248 }
249
250 /**
251 * Create a customer from the WP user with this email.
252 *
253 * @param string $search
254 * @return object
255 */
256 public function findUserInWPAndMakeCustomer ( $search )
257 {
258 if ( is_email( $search ) ){
259 $user = get_user_by( 'email', $search );
260
261 if( $user ) {
262 unset( $user->user_pass );
263 $customerData = [
264 'user_id' => $user->ID,
265 'email' => $user->user_email,
266 'first_name' => $user->first_name,
267 'last_name' => $user->last_name
268 ];
269 $customer = static::maybeCreateCustomer( $customerData );
270 return static::where('email', $user->user_email)->paginate();
271 }
272 }
273 }
274
275 /**
276 * Create a customer from the FluentCRM contact with this email.
277 *
278 * @param string $search
279 * @return object
280 */
281 public function findInCrmAndMakeCustomer ( $search )
282 {
283 $customer = \FluentCrm\App\Models\Subscriber::where( 'email', $search )->first();
284 if( $customer ) {
285 $customerData = [
286 'email' => $customer->email,
287 'first_name' => $customer->first_name,
288 'last_name' => $customer->last_name
289 ];
290 $customer = static::maybeCreateCustomer( $customerData );
291 return static::where('email', $customer->email)->paginate();
292 }
293 }
294
295 /**
296 * @param int $id
297 * @param array $with
298 * @return object
299 */
300
301 public function getCustomer($id, $with)
302 {
303 $customer = static::find($id);
304
305 $data = [
306 'customer' => $customer
307 ];
308
309 if (!$with) {
310 return $data;
311 }
312
313 return $this->getCustomerAdditionalData($with, $customer, $data);
314 }
315
316 /**
317 * @param array $data
318 * @return object
319 */
320
321 public function createCustomer($data)
322 {
323 $data = Arr::only($data, $this->getFillable());
324
325 // Link a WP user only through the email, never a client user_id, or a new
326 // record could become another account's portal identity.
327 unset($data['user_id']);
328
329 $user = get_user_by('email', $data['email']);
330
331 if ($user) {
332 $data['user_id'] = $user->ID;
333 if (empty($data['first_name'])) {
334 $data['first_name'] = $user->first_name;
335 }
336 if (empty($data['last_name'])) {
337 $data['last_name'] = $user->last_name;
338 }
339 }
340
341 return static::create($data);
342 }
343
344 /**
345 * @since 1.5.7
346 * @param int $customerId
347 * @param array $data
348 * @return object
349 */
350
351 public function updateCustomer($customerId, $data)
352 {
353 $customFieldsKeys = Helper::getCustomRegistrationFieldKeys();
354 $customFormValue = Arr::only($data, $customFieldsKeys);
355
356 $data = $this->takeValidKeysForUpdate($data);
357
358 if (isset($data['last_response_at']) && empty($data['last_response_at'])) {
359 unset($data['last_response_at']);
360 }
361
362 $customer = static::findOrFail($customerId);
363
364 if($this->customerExists($customerId, $data['email']))
365 {
366 throw new \Exception('Another Customer has same email address');
367 }
368
369 $user = get_user_by('email', $data['email']);
370
371 if ($user && !empty($customFieldsKeys)) {
372 // The submitted email alone must not re-bind this record to the account that owns it.
373 $linkedUserId = $this->resolveUserLinkage($customer, (int) $user->ID);
374
375 if ($linkedUserId) {
376 $data['user_id'] = $linkedUserId;
377
378 foreach ($customFieldsKeys as $key) {
379 if (isset($customFormValue[$key]) && is_scalar($customFormValue[$key])) {
380 update_user_meta($linkedUserId, $key, sanitize_text_field($customFormValue[$key]));
381 }
382 }
383 }
384 }
385
386 $previousEmail = (string) $customer->email;
387
388 static::where('id', $customer->id)->update($data);
389
390 $updated = static::findOrFail($customerId);
391
392 // Revoke signed ticket links sent to the old address. The update above
393 // is a query-builder write and fires no model events, so do it here.
394 if (strtolower(trim($previousEmail)) !== strtolower(trim((string) $updated->email))) {
395 ProfileInfoService::onProvenEmailChange($updated, $previousEmail, 'agent');
396 }
397
398 return $updated;
399 }
400
401 /**
402 * Delete a customer and all their tickets.
403 *
404 * @since 1.5.7
405 * @param int $customerId
406 * @return array
407 */
408
409 public function deleteCustomer($customerId)
410 {
411 $this->deleteWithTickets(static::findOrFail($customerId));
412
413 return [
414 'message' => __('Customer Deleted Successfully', 'fluent-support')
415 ];
416 }
417
418 /**
419 * Delete a customer after their tickets. Tickets go through TicketService
420 * so `fluent_support/ticket_deleted` fires, as for any other ticket delete.
421 *
422 * @param \FluentSupport\App\Models\Customer $customer
423 * @return void
424 */
425 protected function deleteWithTickets($customer)
426 {
427 $ticketService = new TicketService();
428
429 foreach (Ticket::where('customer_id', $customer->id)->get() as $ticket) {
430 $ticketService->deleteTicket($ticket);
431 }
432
433 $customer->delete();
434 }
435
436 /**
437 * @since 1.9.3
438 * @param array $customerIds
439 * @return array
440 */
441 public function bulkDeleteCustomers($customerIds)
442 {
443 if (empty($customerIds) || !is_array($customerIds)) {
444 return [
445 'message' => __('No customers selected for deletion', 'fluent-support')
446 ];
447 }
448
449 $deletedCount = 0;
450 $errors = [];
451
452 foreach ($customerIds as $customerId) {
453 try {
454 $this->deleteWithTickets(static::findOrFail($customerId));
455 $deletedCount++;
456
457 } catch (\Exception $e) {
458 /* translators: %1$d: customer ID, %2$s: error message */
459 $errors[] = sprintf(__('Failed to delete customer ID %1$d: %2$s', 'fluent-support'), $customerId, $e->getMessage());
460 }
461 }
462
463 if ($deletedCount > 0) {
464 $message = $deletedCount === 1
465 ? __('Customer deleted successfully', 'fluent-support')
466 /* translators: %1$d: number of customers deleted */
467 : sprintf(__('%1$d customers deleted successfully', 'fluent-support'), $deletedCount);
468 } else {
469 $message = __('No customers were deleted', 'fluent-support');
470 }
471
472 $response = [
473 'message' => $message,
474 'deleted_count' => $deletedCount,
475 'total_requested' => count($customerIds)
476 ];
477
478 if (!empty($errors)) {
479 $response['errors'] = $errors;
480 }
481
482 return $response;
483 }
484
485 /**
486 * Attach ticket/response counts and profile link.
487 *
488 * @since 1.5.7
489 * @param object $customers
490 * @return object
491 */
492 private function attachCustomersMetaData($customers)
493 {
494 $customerIds = [];
495 foreach ($customers as $customer) {
496 $customerIds[] = $customer->id;
497 }
498
499 $ticketCounts = [];
500 $responseCounts = [];
501
502 if ($customerIds) {
503 // Count only tickets the current agent may open.
504 $access = new AgentTicketAccess();
505
506 $ticketsQuery = Ticket::select('customer_id')
507 ->selectRaw('COUNT(*) as total')
508 ->whereIn('customer_id', $customerIds)
509 ->groupBy('customer_id');
510 $access->applyAccessScope($ticketsQuery);
511 $ticketCounts = $ticketsQuery->pluck('total', 'customer_id')->all();
512
513 $responsesQuery = Conversation::select('person_id')
514 ->selectRaw('COUNT(*) as total')
515 ->whereIn('person_id', $customerIds)
516 ->groupBy('person_id');
517 $access->applyAccessScopeViaTicket($responsesQuery);
518 $responseCounts = $responsesQuery->pluck('total', 'person_id')->all();
519 }
520
521 foreach ($customers as $customer) {
522 $customer->total_tickets = (int) Arr::get($ticketCounts, $customer->id, 0);
523 $customer->total_responses = (int) Arr::get($responseCounts, $customer->id, 0);
524 if ($customer->user_id) {
525 $customer->user_profile = admin_url('user-edit.php?user_id=' . $customer->user_id);
526 }
527 }
528
529 return $customers;
530 }
531
532 /**
533 * @since 1.5.7
534 * @param array $with
535 * @param object $customer
536 * @param array $data
537 * @return array
538 */
539
540 private function getCustomerAdditionalData($with, $customer, $data)
541 {
542 $customFieldKeys = apply_filters('fluent_support/custom_registration_form_fields_key', []);
543
544 $userMetaData = get_user_meta($customer['user_id']);
545
546 // Custom field values live in WP user meta
547 foreach ($customFieldKeys as $fieldKey) {
548 if (isset($userMetaData[$fieldKey][0])) {
549 $customer[$fieldKey] = $userMetaData[$fieldKey][0];
550 }
551 }
552
553 if (in_array('widgets', $with)) {
554 $data['widgets'] = ProfileInfoService::getProfileExtraWidgets($customer);
555 }
556
557 if (in_array('tickets', $with)) {
558 /*
559 * Filter ticket limit to show ticket in customer page sidebar
560 * @since 1.5.6
561 * @param int $limit
562 */
563 $limit = apply_filters('fluent_support/customer_page_ticket_widgets_limit', 20);
564
565 $ticketsQuery = Ticket::select(['id', 'title', 'status', 'customer_id', 'created_at'])
566 ->where('customer_id', $customer->id);
567
568 // Only list tickets this agent may open (visibility + mailbox restrictions).
569 (new AgentTicketAccess())->applyAccessScope($ticketsQuery);
570
571 $data['tickets'] = $ticketsQuery
572 ->latest('id')
573 ->limit($limit)
574 ->get();
575 }
576
577 if(in_array('fluentcrm_profile', $with)) {
578 $data['fluentcrm_profile'] = Helper::getFluentCrmContactData($customer);
579 }
580
581 return $data;
582 }
583
584 /**
585 * Another customer with this email, or false.
586 *
587 * @since 1.5.7
588 * @param int $customerId
589 * @param string $email
590 * @return mixed
591 */
592
593 private function customerExists($customerId, $email)
594 {
595 $customer = static::where('id','!=', $customerId)->where('email', $email)->first();
596 if ($customer) {
597 return $customer;
598 }
599 return false;
600 }
601
602
603 /**
604 * Which WP account this customer may be linked to. The portal resolves
605 * customers by user_id, so re-binding hands over their tickets. Only admins
606 * may re-bind, and not to an account another customer already owns.
607 * Otherwise the existing link is kept.
608 *
609 * @param object $customer
610 * @param int $targetUserId WP user matching the submitted email
611 * @return int user id to link, or 0 for none
612 */
613 private function resolveUserLinkage($customer, $targetUserId)
614 {
615 $currentUserId = (int) $customer->user_id;
616
617 if ($targetUserId === $currentUserId) {
618 return $currentUserId;
619 }
620
621 if (!current_user_can('manage_options')) {
622 return $currentUserId;
623 }
624
625 $alreadyLinked = static::where('id', '!=', $customer->id)
626 ->where('user_id', $targetUserId)
627 ->first();
628
629 if ($alreadyLinked) {
630 return $currentUserId;
631 }
632
633 return $targetUserId;
634 }
635
636 /**
637 * @since 1.5.7
638 * @param array $data
639 * @return array
640 */
641 private function takeValidKeysForUpdate($data)
642 {
643 $validKeys = array_diff($this->getFillable(), self::$immutableUpdateKeys);
644
645 return Arr::only($data, $validKeys);
646 }
647 }
648