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 +158 -63 2.3.2 → 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,9 +25,69 @@
24 25 'person_type', // customer/agent record type, enforces the global scope
25 26 ];
26 27
27 28 /**
28 - * This getCustomers method will return all customers
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 + /**
29 90 * @param string $search
30 91 * @param string $status
31 92 * @return object
32 93 */
@@ -164,12 +225,12 @@
164 225 $addressFields = apply_filters('fluent_support/custom_registration_form_fields',$addressFields);
165 226
166 227 foreach ($addressFields as $key => $field) {
167 228 if (isset($field['type']) && $field['type'] === 'country-selector') {
168 - continue; // Skip conversion for country-selector
229 + continue;
169 230 }
170 231
171 - //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.
172 233 $addressFields[$key]['data_type'] = $addressFields[$key]['type'];
173 234 $addressFields[$key]['type'] = 'input-text';
174 235 }
175 236 }
@@ -186,9 +247,10 @@
186 247
187 248 }
188 249
189 250 /**
190 - * 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 + *
191 253 * @param string $search
192 254 * @return object
193 255 */
194 256 public function findUserInWPAndMakeCustomer ( $search )
@@ -210,9 +272,10 @@
210 272 }
211 273 }
212 274
213 275 /**
214 - * 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 + *
215 278 * @param string $search
216 279 * @return object
217 280 */
218 281 public function findInCrmAndMakeCustomer ( $search )
@@ -229,9 +292,8 @@
229 292 }
230 293 }
231 294
232 295 /**
233 - * This getCustomer method will return a single customer
234 296 * @param int $id
235 297 * @param array $with
236 298 * @return object
237 299 */
@@ -251,9 +313,8 @@
251 313 return $this->getCustomerAdditionalData($with, $customer, $data);
252 314 }
253 315
254 316 /**
255 - * This createCustomer method will create a new customer
256 317 * @param array $data
257 318 * @return object
258 319 */
259 320
@@ -260,8 +321,12 @@
260 321 public function createCustomer($data)
261 322 {
262 323 $data = Arr::only($data, $this->getFillable());
263 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 +
264 329 $user = get_user_by('email', $data['email']);
265 330
266 331 if ($user) {
267 332 $data['user_id'] = $user->ID;
@@ -276,9 +341,8 @@
276 341 return static::create($data);
277 342 }
278 343
279 344 /**
280 - * This updateCustomer method will update a customer
281 345 * @since 1.5.7
282 346 * @param int $customerId
283 347 * @param array $data
284 348 * @return object
@@ -285,10 +349,9 @@
285 349 */
286 350
287 351 public function updateCustomer($customerId, $data)
288 352 {
289 - $customFieldsKeys = apply_filters('fluent_support/custom_registration_form_fields_key', Helper::getBusinessSettings('custom_registration_form_field'));
290 - $customFieldsKeys = is_array($customFieldsKeys) ? $customFieldsKeys : [];
353 + $customFieldsKeys = Helper::getCustomRegistrationFieldKeys();
291 354 $customFormValue = Arr::only($data, $customFieldsKeys);
292 355
293 356 $data = $this->takeValidKeysForUpdate($data);
294 357
@@ -305,32 +368,40 @@
305 368
306 369 $user = get_user_by('email', $data['email']);
307 370
308 371 if ($user && !empty($customFieldsKeys)) {
309 - // Never trust the submitted email to pick the account: an email edit
310 - // 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.
311 373 $linkedUserId = $this->resolveUserLinkage($customer, (int) $user->ID);
312 374
313 375 if ($linkedUserId) {
314 376 $data['user_id'] = $linkedUserId;
315 377
316 - //Update Custom field data for user
317 378 foreach ($customFieldsKeys as $key) {
318 - if (isset($customFormValue[$key])) {
319 - $fieldValue = $customFormValue[$key];
320 - 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]));
321 381 }
322 382 }
323 383 }
324 384 }
325 385
386 + $previousEmail = (string) $customer->email;
387 +
326 388 static::where('id', $customer->id)->update($data);
327 389
328 - return static::findOrFail($customerId);
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;
329 399 }
330 400
331 401 /**
332 - * deleteCustomer method will delete a customer and all tickets by that customer
402 + * Delete a customer and all their tickets.
403 + *
333 404 * @since 1.5.7
334 405 * @param int $customerId
335 406 * @return array
336 407 */
@@ -336,25 +407,34 @@
336 407 */
337 408
338 409 public function deleteCustomer($customerId)
339 410 {
340 - $customer = static::findOrFail($customerId);
411 + $this->deleteWithTickets(static::findOrFail($customerId));
341 412
342 - $tickets = Ticket::where('customer_id', $customer->id)->get();
413 + return [
414 + 'message' => __('Customer Deleted Successfully', 'fluent-support')
415 + ];
416 + }
343 417
344 - foreach ($tickets as $ticket) {
345 - $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);
346 431 }
347 432
348 433 $customer->delete();
349 -
350 - return [
351 - 'message' => __('Customer Deleted Successfully', 'fluent-support')
352 - ];
353 434 }
354 435
355 436 /**
356 - * bulkDeleteCustomers method will delete multiple customers and all their tickets
357 437 * @since 1.9.3
358 438 * @param array $customerIds
359 439 * @return array
360 440 */
@@ -370,17 +450,9 @@
370 450 $errors = [];
371 451
372 452 foreach ($customerIds as $customerId) {
373 453 try {
374 - $customer = static::findOrFail($customerId);
375 -
376 - $tickets = Ticket::where('customer_id', $customer->id)->get();
377 -
378 - foreach ($tickets as $ticket) {
379 - $ticket->deleteTicket();
380 - }
381 -
382 - $customer->delete();
454 + $this->deleteWithTickets(static::findOrFail($customerId));
383 455 $deletedCount++;
384 456
385 457 } catch (\Exception $e) {
386 458 /* translators: %1$d: customer ID, %2$s: error message */
@@ -388,11 +460,11 @@
388 460 }
389 461 }
390 462
391 463 if ($deletedCount > 0) {
392 - /* translators: %1$d: number of customers deleted */
393 464 $message = $deletedCount === 1
394 465 ? __('Customer deleted successfully', 'fluent-support')
466 + /* translators: %1$d: number of customers deleted */
395 467 : sprintf(__('%1$d customers deleted successfully', 'fluent-support'), $deletedCount);
396 468 } else {
397 469 $message = __('No customers were deleted', 'fluent-support');
398 470 }
@@ -410,9 +482,10 @@
410 482 return $response;
411 483 }
412 484
413 485 /**
414 - * This attachCustomersMetaData method will attach meta data to customers
486 + * Attach ticket/response counts and profile link.
487 + *
415 488 * @since 1.5.7
416 489 * @param object $customers
417 490 * @return object
418 491 */
@@ -417,13 +490,39 @@
417 490 * @return object
418 491 */
419 492 private function attachCustomersMetaData($customers)
420 493 {
494 + $customerIds = [];
421 495 foreach ($customers as $customer) {
422 - $customer->total_tickets = $customer->getTicketCounts();
423 - $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);
424 524 if ($customer->user_id) {
425 - //Get profile link, if they are WP user
426 525 $customer->user_profile = admin_url('user-edit.php?user_id=' . $customer->user_id);
427 526 }
428 527 }
429 528
@@ -430,9 +529,8 @@
430 529 return $customers;
431 530 }
432 531
433 532 /**
434 - * This getCustomerAdditionalData method will return additional data for a customer
435 533 * @since 1.5.7
436 534 * @param array $with
437 535 * @param object $customer
438 536 * @param array $data
@@ -444,9 +542,9 @@
444 542 $customFieldKeys = apply_filters('fluent_support/custom_registration_form_fields_key', []);
445 543
446 544 $userMetaData = get_user_meta($customer['user_id']);
447 545
448 - //Custom field data from wp user_meta
546 + // Custom field values live in WP user meta
449 547 foreach ($customFieldKeys as $fieldKey) {
450 548 if (isset($userMetaData[$fieldKey][0])) {
451 549 $customer[$fieldKey] = $userMetaData[$fieldKey][0];
452 550 }
@@ -463,10 +561,15 @@
463 561 * @param int $limit
464 562 */
465 563 $limit = apply_filters('fluent_support/customer_page_ticket_widgets_limit', 20);
466 564
467 - $data['tickets'] = Ticket::select(['id', 'title', 'status', 'customer_id', 'created_at'])
468 - ->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
469 572 ->latest('id')
470 573 ->limit($limit)
471 574 ->get();
472 575 }
@@ -478,9 +581,10 @@
478 581 return $data;
479 582 }
480 583
481 584 /**
482 - * This customerExists method will check if customer exists
585 + * Another customer with this email, or false.
586 + *
483 587 * @since 1.5.7
484 588 * @param int $customerId
485 589 * @param string $email
486 590 * @return mixed
@@ -496,17 +600,13 @@
496 600 }
497 601
498 602
499 603 /**
500 - * 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.
501 608 *
502 - * Binding a support record to a WP account is an identity change: the portal
503 - * resolves the logged-in customer by `user_id` (see Helper::getCurrentCustomer),
504 - * so a re-bind hands this record's tickets to the newly bound account. It is
505 - * therefore restricted to administrators and refused when the account already
506 - * belongs to another customer. When a re-bind is not allowed the existing
507 - * linkage is kept, so ordinary email corrections still go through.
508 - *
509 609 * @param object $customer
510 610 * @param int $targetUserId WP user matching the submitted email
511 611 * @return int user id to link, or 0 for none
512 612 */
@@ -513,9 +613,8 @@
513 613 private function resolveUserLinkage($customer, $targetUserId)
514 614 {
515 615 $currentUserId = (int) $customer->user_id;
516 616
517 - // Already linked to this account, nothing to authorize.
518 617 if ($targetUserId === $currentUserId) {
519 618 return $currentUserId;
520 619 }
521 620
@@ -522,9 +621,8 @@
522 621 if (!current_user_can('manage_options')) {
523 622 return $currentUserId;
524 623 }
525 624
526 - // Conflict: that account is already bound to a different customer record.
527 625 $alreadyLinked = static::where('id', '!=', $customer->id)
528 626 ->where('user_id', $targetUserId)
529 627 ->first();
530 628
@@ -535,9 +633,8 @@
535 633 return $targetUserId;
536 634 }
537 635
538 636 /**
539 - * This takeValidKeysForUpdate method will take valid keys data for update
540 637 * @since 1.5.7
541 638 * @param array $data
542 639 * @return array
543 640 */
@@ -542,10 +639,8 @@
542 639 * @return array
543 640 */
544 641 private function takeValidKeysForUpdate($data)
545 642 {
546 - // $fillable is a numerically indexed list, so the immutable columns are
547 - // values rather than keys and have to be removed with array_diff().
548 643 $validKeys = array_diff($this->getFillable(), self::$immutableUpdateKeys);
549 644
550 645 return Arr::only($data, $validKeys);
551 646 }