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 +203 -50 2.3.0 → 2.4.5 View file →
@@ -3,17 +3,91 @@
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 {
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 + ];
13 27
14 28 /**
15 - * 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 + /**
16 90 * @param string $search
17 91 * @param string $status
18 92 * @return object
19 93 */
@@ -151,12 +225,12 @@
151 225 $addressFields = apply_filters('fluent_support/custom_registration_form_fields',$addressFields);
152 226
153 227 foreach ($addressFields as $key => $field) {
154 228 if (isset($field['type']) && $field['type'] === 'country-selector') {
155 - continue; // Skip conversion for country-selector
229 + continue;
156 230 }
157 231
158 - //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.
159 233 $addressFields[$key]['data_type'] = $addressFields[$key]['type'];
160 234 $addressFields[$key]['type'] = 'input-text';
161 235 }
162 236 }
@@ -173,9 +247,10 @@
173 247
174 248 }
175 249
176 250 /**
177 - * 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 + *
178 253 * @param string $search
179 254 * @return object
180 255 */
181 256 public function findUserInWPAndMakeCustomer ( $search )
@@ -197,9 +272,10 @@
197 272 }
198 273 }
199 274
200 275 /**
201 - * 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 + *
202 278 * @param string $search
203 279 * @return object
204 280 */
205 281 public function findInCrmAndMakeCustomer ( $search )
@@ -216,9 +292,8 @@
216 292 }
217 293 }
218 294
219 295 /**
220 - * This getCustomer method will return a single customer
221 296 * @param int $id
222 297 * @param array $with
223 298 * @return object
224 299 */
@@ -238,9 +313,8 @@
238 313 return $this->getCustomerAdditionalData($with, $customer, $data);
239 314 }
240 315
241 316 /**
242 - * This createCustomer method will create a new customer
243 317 * @param array $data
244 318 * @return object
245 319 */
246 320
@@ -247,8 +321,12 @@
247 321 public function createCustomer($data)
248 322 {
249 323 $data = Arr::only($data, $this->getFillable());
250 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 +
251 329 $user = get_user_by('email', $data['email']);
252 330
253 331 if ($user) {
254 332 $data['user_id'] = $user->ID;
@@ -263,9 +341,8 @@
263 341 return static::create($data);
264 342 }
265 343
266 344 /**
267 - * This updateCustomer method will update a customer
268 345 * @since 1.5.7
269 346 * @param int $customerId
270 347 * @param array $data
271 348 * @return object
@@ -272,10 +349,9 @@
272 349 */
273 350
274 351 public function updateCustomer($customerId, $data)
275 352 {
276 - $customFieldsKeys = apply_filters('fluent_support/custom_registration_form_fields_key', Helper::getBusinessSettings('custom_registration_form_field'));
277 - $customFieldsKeys = is_array($customFieldsKeys) ? $customFieldsKeys : [];
353 + $customFieldsKeys = Helper::getCustomRegistrationFieldKeys();
278 354 $customFormValue = Arr::only($data, $customFieldsKeys);
279 355
280 356 $data = $this->takeValidKeysForUpdate($data);
281 357
@@ -292,26 +368,40 @@
292 368
293 369 $user = get_user_by('email', $data['email']);
294 370
295 371 if ($user && !empty($customFieldsKeys)) {
296 - $data['user_id'] = $user->ID;
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);
297 374
298 - //Update Custom field data for user
299 - foreach ($customFieldsKeys as $key) {
300 - if (isset($customFormValue[$key])) {
301 - $fieldValue = $customFormValue[$key];
302 - update_user_meta($user->ID, $key, $fieldValue);
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 + }
303 382 }
304 383 }
305 384 }
306 385
386 + $previousEmail = (string) $customer->email;
387 +
307 388 static::where('id', $customer->id)->update($data);
308 389
309 - 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;
310 399 }
311 400
312 401 /**
313 - * deleteCustomer method will delete a customer and all tickets by that customer
402 + * Delete a customer and all their tickets.
403 + *
314 404 * @since 1.5.7
315 405 * @param int $customerId
316 406 * @return array
317 407 */
@@ -317,25 +407,34 @@
317 407 */
318 408
319 409 public function deleteCustomer($customerId)
320 410 {
321 - $customer = static::findOrFail($customerId);
411 + $this->deleteWithTickets(static::findOrFail($customerId));
322 412
323 - $tickets = Ticket::where('customer_id', $customer->id)->get();
413 + return [
414 + 'message' => __('Customer Deleted Successfully', 'fluent-support')
415 + ];
416 + }
324 417
325 - foreach ($tickets as $ticket) {
326 - $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);
327 431 }
328 432
329 433 $customer->delete();
330 -
331 - return [
332 - 'message' => __('Customer Deleted Successfully', 'fluent-support')
333 - ];
334 434 }
335 435
336 436 /**
337 - * bulkDeleteCustomers method will delete multiple customers and all their tickets
338 437 * @since 1.9.3
339 438 * @param array $customerIds
340 439 * @return array
341 440 */
@@ -351,17 +450,9 @@
351 450 $errors = [];
352 451
353 452 foreach ($customerIds as $customerId) {
354 453 try {
355 - $customer = static::findOrFail($customerId);
356 -
357 - $tickets = Ticket::where('customer_id', $customer->id)->get();
358 -
359 - foreach ($tickets as $ticket) {
360 - $ticket->deleteTicket();
361 - }
362 -
363 - $customer->delete();
454 + $this->deleteWithTickets(static::findOrFail($customerId));
364 455 $deletedCount++;
365 456
366 457 } catch (\Exception $e) {
367 458 /* translators: %1$d: customer ID, %2$s: error message */
@@ -369,11 +460,11 @@
369 460 }
370 461 }
371 462
372 463 if ($deletedCount > 0) {
373 - /* translators: %1$d: number of customers deleted */
374 464 $message = $deletedCount === 1
375 465 ? __('Customer deleted successfully', 'fluent-support')
466 + /* translators: %1$d: number of customers deleted */
376 467 : sprintf(__('%1$d customers deleted successfully', 'fluent-support'), $deletedCount);
377 468 } else {
378 469 $message = __('No customers were deleted', 'fluent-support');
379 470 }
@@ -391,9 +482,10 @@
391 482 return $response;
392 483 }
393 484
394 485 /**
395 - * This attachCustomersMetaData method will attach meta data to customers
486 + * Attach ticket/response counts and profile link.
487 + *
396 488 * @since 1.5.7
397 489 * @param object $customers
398 490 * @return object
399 491 */
@@ -398,13 +490,39 @@
398 490 * @return object
399 491 */
400 492 private function attachCustomersMetaData($customers)
401 493 {
494 + $customerIds = [];
402 495 foreach ($customers as $customer) {
403 - $customer->total_tickets = $customer->getTicketCounts();
404 - $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);
405 524 if ($customer->user_id) {
406 - //Get profile link, if they are WP user
407 525 $customer->user_profile = admin_url('user-edit.php?user_id=' . $customer->user_id);
408 526 }
409 527 }
410 528
@@ -411,9 +529,8 @@
411 529 return $customers;
412 530 }
413 531
414 532 /**
415 - * This getCustomerAdditionalData method will return additional data for a customer
416 533 * @since 1.5.7
417 534 * @param array $with
418 535 * @param object $customer
419 536 * @param array $data
@@ -425,9 +542,9 @@
425 542 $customFieldKeys = apply_filters('fluent_support/custom_registration_form_fields_key', []);
426 543
427 544 $userMetaData = get_user_meta($customer['user_id']);
428 545
429 - //Custom field data from wp user_meta
546 + // Custom field values live in WP user meta
430 547 foreach ($customFieldKeys as $fieldKey) {
431 548 if (isset($userMetaData[$fieldKey][0])) {
432 549 $customer[$fieldKey] = $userMetaData[$fieldKey][0];
433 550 }
@@ -444,10 +561,15 @@
444 561 * @param int $limit
445 562 */
446 563 $limit = apply_filters('fluent_support/customer_page_ticket_widgets_limit', 20);
447 564
448 - $data['tickets'] = Ticket::select(['id', 'title', 'status', 'customer_id', 'created_at'])
449 - ->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
450 572 ->latest('id')
451 573 ->limit($limit)
452 574 ->get();
453 575 }
@@ -459,9 +581,10 @@
459 581 return $data;
460 582 }
461 583
462 584 /**
463 - * This customerExists method will check if customer exists
585 + * Another customer with this email, or false.
586 + *
464 587 * @since 1.5.7
465 588 * @param int $customerId
466 589 * @param string $email
467 590 * @return mixed
@@ -477,9 +600,41 @@
477 600 }
478 601
479 602
480 603 /**
481 - * This takeValidKeysForUpdate method will take valid keys data for update
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 + /**
482 637 * @since 1.5.7
483 638 * @param array $data
484 639 * @return array
485 640 */
@@ -484,11 +639,9 @@
484 639 * @return array
485 640 */
486 641 private function takeValidKeysForUpdate($data)
487 642 {
488 - $validKeys = $this->getFillable();
489 - unset($validKeys['hash']);
490 - unset($validKeys['user_id']);
643 + $validKeys = array_diff($this->getFillable(), self::$immutableUpdateKeys);
491 644
492 645 return Arr::only($data, $validKeys);
493 646 }
494 647 }