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

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

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