PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 2.4.0
Fluent Support – Helpdesk & Customer Support Ticket System v2.4.0
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/Models/Traits/CustomerTrait.php +95 -96 trunk → 2.4.0 View file →
@@ -3,21 +3,20 @@
3 3 namespace FluentSupport\App\Models\Traits;
4 4
5 5 use Exception;
6 6 use FluentSupport\App\Models\Ticket;
7 -use FluentSupport\App\Models\Conversation;
8 7 use FluentSupport\App\Services\Helper;
9 8 use FluentSupport\Framework\Support\Arr;
10 9 use FluentSupport\App\Services\ProfileInfoService;
11 -use FluentSupport\App\Services\Tickets\AgentTicketAccess;
12 -use FluentSupport\App\Services\Tickets\TicketService;
13 10
14 11 trait CustomerTrait
15 12 {
16 13 /**
17 - * Identity columns that must never be mass-assigned on update. The
18 - * `creating` hook sets them on create, but update() bypasses it.
14 + * Identity columns that must never be mass-assigned on update.
19 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 + *
20 19 * @var array
21 20 */
22 21 private static $immutableUpdateKeys = [
23 22 'hash', // portal identity token
@@ -25,16 +24,22 @@
25 24 'person_type', // customer/agent record type, enforces the global scope
26 25 ];
27 26
28 27 /**
29 - * Whether this customer is barred from support altogether. Only 'active'
30 - * is allowed, so any new status blocks until explicitly permitted.
28 + * Whether this customer is barred from support altogether.
31 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 + *
32 36 * @return bool
33 37 */
34 38 public function isBlocked()
35 39 {
36 - // Some imported rows have an empty status. Treat it as the column default.
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.
37 42 $status = $this->status ?: 'active';
38 43
39 44 return $status !== 'active';
40 45 }
@@ -39,11 +44,17 @@
39 44 return $status !== 'active';
40 45 }
41 46
42 47 /**
43 - * Records that no WordPress account is linked to. Integrations can store
44 - * 0 instead of NULL, so both count as unlinked.
48 + * Records that no WordPress account is linked to.
45 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 + *
46 57 * @param \FluentSupport\Framework\Database\Orm\Builder $query
47 58 * @return \FluentSupport\Framework\Database\Orm\Builder
48 59 */
49 60 public function scopeUnclaimed($query)
@@ -74,12 +85,16 @@
74 85 return !$this->isBlocked();
75 86 }
76 87
77 88 /**
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.
89 + * Whether Fluent Support may send this customer a notification.
81 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 + *
82 97 * @return bool
83 98 */
84 99 public function canReceiveNotifications()
85 100 {
@@ -86,8 +101,9 @@
86 101 return !$this->isBlocked();
87 102 }
88 103
89 104 /**
105 + * This getCustomers method will return all customers
90 106 * @param string $search
91 107 * @param string $status
92 108 * @return object
93 109 */
@@ -225,12 +241,12 @@
225 241 $addressFields = apply_filters('fluent_support/custom_registration_form_fields',$addressFields);
226 242
227 243 foreach ($addressFields as $key => $field) {
228 244 if (isset($field['type']) && $field['type'] === 'country-selector') {
229 - continue;
245 + continue; // Skip conversion for country-selector
230 246 }
231 247
232 - // _formBuilder.vue renders custom fields as 'input-text' with the real type in data_type.
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...).
233 249 $addressFields[$key]['data_type'] = $addressFields[$key]['type'];
234 250 $addressFields[$key]['type'] = 'input-text';
235 251 }
236 252 }
@@ -247,10 +263,9 @@
247 263
248 264 }
249 265
250 266 /**
251 - * Create a customer from the WP user with this email.
252 - *
267 + * This `findUserInWPAndMakeCustomer` method will find customer in WP Users and make it as a customer
253 268 * @param string $search
254 269 * @return object
255 270 */
256 271 public function findUserInWPAndMakeCustomer ( $search )
@@ -272,10 +287,9 @@
272 287 }
273 288 }
274 289
275 290 /**
276 - * Create a customer from the FluentCRM contact with this email.
277 - *
291 + * This `findInCrmAndMakeCustomer` method will find customer in Fluent CRM and make it as a customer
278 292 * @param string $search
279 293 * @return object
280 294 */
281 295 public function findInCrmAndMakeCustomer ( $search )
@@ -292,8 +306,9 @@
292 306 }
293 307 }
294 308
295 309 /**
310 + * This getCustomer method will return a single customer
296 311 * @param int $id
297 312 * @param array $with
298 313 * @return object
299 314 */
@@ -313,8 +328,9 @@
313 328 return $this->getCustomerAdditionalData($with, $customer, $data);
314 329 }
315 330
316 331 /**
332 + * This createCustomer method will create a new customer
317 333 * @param array $data
318 334 * @return object
319 335 */
320 336
@@ -321,12 +337,8 @@
321 337 public function createCustomer($data)
322 338 {
323 339 $data = Arr::only($data, $this->getFillable());
324 340
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 341 $user = get_user_by('email', $data['email']);
330 342
331 343 if ($user) {
332 344 $data['user_id'] = $user->ID;
@@ -341,8 +353,9 @@
341 353 return static::create($data);
342 354 }
343 355
344 356 /**
357 + * This updateCustomer method will update a customer
345 358 * @since 1.5.7
346 359 * @param int $customerId
347 360 * @param array $data
348 361 * @return object
@@ -349,9 +362,10 @@
349 362 */
350 363
351 364 public function updateCustomer($customerId, $data)
352 365 {
353 - $customFieldsKeys = Helper::getCustomRegistrationFieldKeys();
366 + $customFieldsKeys = apply_filters('fluent_support/custom_registration_form_fields_key', Helper::getBusinessSettings('custom_registration_form_field'));
367 + $customFieldsKeys = is_array($customFieldsKeys) ? $customFieldsKeys : [];
354 368 $customFormValue = Arr::only($data, $customFieldsKeys);
355 369
356 370 $data = $this->takeValidKeysForUpdate($data);
357 371
@@ -368,17 +382,20 @@
368 382
369 383 $user = get_user_by('email', $data['email']);
370 384
371 385 if ($user && !empty($customFieldsKeys)) {
372 - // The submitted email alone must not re-bind this record to the account that owns it.
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.
373 388 $linkedUserId = $this->resolveUserLinkage($customer, (int) $user->ID);
374 389
375 390 if ($linkedUserId) {
376 391 $data['user_id'] = $linkedUserId;
377 392
393 + //Update Custom field data for user
378 394 foreach ($customFieldsKeys as $key) {
379 - if (isset($customFormValue[$key]) && is_scalar($customFormValue[$key])) {
380 - update_user_meta($linkedUserId, $key, sanitize_text_field($customFormValue[$key]));
395 + if (isset($customFormValue[$key])) {
396 + $fieldValue = $customFormValue[$key];
397 + update_user_meta($linkedUserId, $key, $fieldValue);
381 398 }
382 399 }
383 400 }
384 401 }
@@ -388,10 +405,17 @@
388 405 static::where('id', $customer->id)->update($data);
389 406
390 407 $updated = static::findOrFail($customerId);
391 408
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.
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.
394 418 if (strtolower(trim($previousEmail)) !== strtolower(trim((string) $updated->email))) {
395 419 ProfileInfoService::onProvenEmailChange($updated, $previousEmail, 'agent');
396 420 }
397 421
@@ -398,10 +422,9 @@
398 422 return $updated;
399 423 }
400 424
401 425 /**
402 - * Delete a customer and all their tickets.
403 - *
426 + * deleteCustomer method will delete a customer and all tickets by that customer
404 427 * @since 1.5.7
405 428 * @param int $customerId
406 429 * @return array
407 430 */
@@ -407,34 +430,25 @@
407 430 */
408 431
409 432 public function deleteCustomer($customerId)
410 433 {
411 - $this->deleteWithTickets(static::findOrFail($customerId));
434 + $customer = static::findOrFail($customerId);
412 435
413 - return [
414 - 'message' => __('Customer Deleted Successfully', 'fluent-support')
415 - ];
416 - }
436 + $tickets = Ticket::where('customer_id', $customer->id)->get();
417 437
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);
438 + foreach ($tickets as $ticket) {
439 + $ticket->deleteTicket();
431 440 }
432 441
433 442 $customer->delete();
443 +
444 + return [
445 + 'message' => __('Customer Deleted Successfully', 'fluent-support')
446 + ];
434 447 }
435 448
436 449 /**
450 + * bulkDeleteCustomers method will delete multiple customers and all their tickets
437 451 * @since 1.9.3
438 452 * @param array $customerIds
439 453 * @return array
440 454 */
@@ -450,9 +464,17 @@
450 464 $errors = [];
451 465
452 466 foreach ($customerIds as $customerId) {
453 467 try {
454 - $this->deleteWithTickets(static::findOrFail($customerId));
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();
455 477 $deletedCount++;
456 478
457 479 } catch (\Exception $e) {
458 480 /* translators: %1$d: customer ID, %2$s: error message */
@@ -460,11 +482,11 @@
460 482 }
461 483 }
462 484
463 485 if ($deletedCount > 0) {
486 + /* translators: %1$d: number of customers deleted */
464 487 $message = $deletedCount === 1
465 488 ? __('Customer deleted successfully', 'fluent-support')
466 - /* translators: %1$d: number of customers deleted */
467 489 : sprintf(__('%1$d customers deleted successfully', 'fluent-support'), $deletedCount);
468 490 } else {
469 491 $message = __('No customers were deleted', 'fluent-support');
470 492 }
@@ -482,10 +504,9 @@
482 504 return $response;
483 505 }
484 506
485 507 /**
486 - * Attach ticket/response counts and profile link.
487 - *
508 + * This attachCustomersMetaData method will attach meta data to customers
488 509 * @since 1.5.7
489 510 * @param object $customers
490 511 * @return object
491 512 */
@@ -490,39 +511,13 @@
490 511 * @return object
491 512 */
492 513 private function attachCustomersMetaData($customers)
493 514 {
494 - $customerIds = [];
495 515 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);
516 + $customer->total_tickets = $customer->getTicketCounts();
517 + $customer->total_responses = $customer->getResponseCounts();
524 518 if ($customer->user_id) {
519 + //Get profile link, if they are WP user
525 520 $customer->user_profile = admin_url('user-edit.php?user_id=' . $customer->user_id);
526 521 }
527 522 }
528 523
@@ -529,8 +524,9 @@
529 524 return $customers;
530 525 }
531 526
532 527 /**
528 + * This getCustomerAdditionalData method will return additional data for a customer
533 529 * @since 1.5.7
534 530 * @param array $with
535 531 * @param object $customer
536 532 * @param array $data
@@ -542,9 +538,9 @@
542 538 $customFieldKeys = apply_filters('fluent_support/custom_registration_form_fields_key', []);
543 539
544 540 $userMetaData = get_user_meta($customer['user_id']);
545 541
546 - // Custom field values live in WP user meta
542 + //Custom field data from wp user_meta
547 543 foreach ($customFieldKeys as $fieldKey) {
548 544 if (isset($userMetaData[$fieldKey][0])) {
549 545 $customer[$fieldKey] = $userMetaData[$fieldKey][0];
550 546 }
@@ -561,15 +557,10 @@
561 557 * @param int $limit
562 558 */
563 559 $limit = apply_filters('fluent_support/customer_page_ticket_widgets_limit', 20);
564 560
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
561 + $data['tickets'] = Ticket::select(['id', 'title', 'status', 'customer_id', 'created_at'])
562 + ->where('customer_id', $customer->id)
572 563 ->latest('id')
573 564 ->limit($limit)
574 565 ->get();
575 566 }
@@ -581,10 +572,9 @@
581 572 return $data;
582 573 }
583 574
584 575 /**
585 - * Another customer with this email, or false.
586 - *
576 + * This customerExists method will check if customer exists
587 577 * @since 1.5.7
588 578 * @param int $customerId
589 579 * @param string $email
590 580 * @return mixed
@@ -600,13 +590,17 @@
600 590 }
601 591
602 592
603 593 /**
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.
594 + * Decide which WordPress account this customer record may be linked to.
608 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 + *
609 603 * @param object $customer
610 604 * @param int $targetUserId WP user matching the submitted email
611 605 * @return int user id to link, or 0 for none
612 606 */
@@ -613,8 +607,9 @@
613 607 private function resolveUserLinkage($customer, $targetUserId)
614 608 {
615 609 $currentUserId = (int) $customer->user_id;
616 610
611 + // Already linked to this account, nothing to authorize.
617 612 if ($targetUserId === $currentUserId) {
618 613 return $currentUserId;
619 614 }
620 615
@@ -621,8 +616,9 @@
621 616 if (!current_user_can('manage_options')) {
622 617 return $currentUserId;
623 618 }
624 619
620 + // Conflict: that account is already bound to a different customer record.
625 621 $alreadyLinked = static::where('id', '!=', $customer->id)
626 622 ->where('user_id', $targetUserId)
627 623 ->first();
628 624
@@ -633,8 +629,9 @@
633 629 return $targetUserId;
634 630 }
635 631
636 632 /**
633 + * This takeValidKeysForUpdate method will take valid keys data for update
637 634 * @since 1.5.7
638 635 * @param array $data
639 636 * @return array
640 637 */
@@ -639,8 +636,10 @@
639 636 * @return array
640 637 */
641 638 private function takeValidKeysForUpdate($data)
642 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().
643 642 $validKeys = array_diff($this->getFillable(), self::$immutableUpdateKeys);
644 643
645 644 return Arr::only($data, $validKeys);
646 645 }