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