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 +259 -42 1.10.2 → 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 */
@@ -51,9 +125,9 @@
51 125 'label' => __('First Name', 'fluent-support'),
52 126 'data_type' => 'text',
53 127 'placeholder' => __('First Name', 'fluent-support'),
54 128 'type' => 'input-text',
55 - 'wrapper_class' => 'fs_half_field',
129 + 'wrapper_class' => 'fs_half_field required',
56 130 ],
57 131 'last_name' => [
58 132 'label' => __('Last Name', 'fluent-support'),
59 133 'data_type' => 'text',
@@ -65,9 +139,9 @@
65 139 'label' => __('Email', 'fluent-support'),
66 140 'data_type' => 'email',
67 141 'placeholder' => __('Email', 'fluent-support'),
68 142 'type' => 'input-text',
69 - 'wrapper_class' => 'fs_half_field',
143 + 'wrapper_class' => 'fs_half_field required',
70 144 'disabled' => !empty($customer_id),
71 145 ],
72 146 'title' => [
73 147 'label' => __('Job Title', 'fluent-support'),
@@ -80,9 +154,8 @@
80 154 'label' => __('Note', 'fluent-support'),
81 155 'data_type' => 'textarea',
82 156 'placeholder' => __('Note', 'fluent-support'),
83 157 'type' => 'input-text',
84 - 'wrapper_class' => 'fs_half_field',
85 158 ],
86 159 'status' => [
87 160 'label' => __('Status', 'fluent-support'),
88 161 'data_type' => 'text',
@@ -90,9 +163,8 @@
90 163 'options' => [
91 164 'active' => ['id' => 'active', 'value' => 'active', 'label' => __('Active', 'fluent-support')],
92 165 'inactive' => ['id' => 'inactive', 'value' => 'inactive', 'label' => __('Blocked', 'fluent-support')],
93 166 ],
94 - 'wrapper_class' => 'fs_half_field',
95 167 ],
96 168 'status_html' => [
97 169 'dependency' => [
98 170 'depends_on' => 'status',
@@ -99,10 +171,10 @@
99 171 'operator' => '=',
100 172 'value' => 'inactive',
101 173 ],
102 174 'type' => 'html-viewer',
103 - 'wrapper_class' => 'fs_warn',
104 - 'html' => __('block_instruction', 'fluent-support'),
175 + 'wrapper_class' => 'fs_warn_alert_wrapper',
176 + 'html' => __('If you select the <b>Blocked</b> status, this customer will not be able to submit a ticket or any response.', 'fluent-support'),
105 177 ],
106 178 ];
107 179
108 180 $addressFields = [
@@ -152,9 +224,13 @@
152 224 if ($userID) {
153 225 $addressFields = apply_filters('fluent_support/custom_registration_form_fields',$addressFields);
154 226
155 227 foreach ($addressFields as $key => $field) {
156 - //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...).
228 + if (isset($field['type']) && $field['type'] === 'country-selector') {
229 + continue;
230 + }
231 +
232 + // _formBuilder.vue renders custom fields as 'input-text' with the real type in data_type.
157 233 $addressFields[$key]['data_type'] = $addressFields[$key]['type'];
158 234 $addressFields[$key]['type'] = 'input-text';
159 235 }
160 236 }
@@ -171,9 +247,10 @@
171 247
172 248 }
173 249
174 250 /**
175 - * 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 + *
176 253 * @param string $search
177 254 * @return object
178 255 */
179 256 public function findUserInWPAndMakeCustomer ( $search )
@@ -195,9 +272,10 @@
195 272 }
196 273 }
197 274
198 275 /**
199 - * 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 + *
200 278 * @param string $search
201 279 * @return object
202 280 */
203 281 public function findInCrmAndMakeCustomer ( $search )
@@ -214,9 +292,8 @@
214 292 }
215 293 }
216 294
217 295 /**
218 - * This getCustomer method will return a single customer
219 296 * @param int $id
220 297 * @param array $with
221 298 * @return object
222 299 */
@@ -236,9 +313,8 @@
236 313 return $this->getCustomerAdditionalData($with, $customer, $data);
237 314 }
238 315
239 316 /**
240 - * This createCustomer method will create a new customer
241 317 * @param array $data
242 318 * @return object
243 319 */
244 320
@@ -245,8 +321,12 @@
245 321 public function createCustomer($data)
246 322 {
247 323 $data = Arr::only($data, $this->getFillable());
248 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 +
249 329 $user = get_user_by('email', $data['email']);
250 330
251 331 if ($user) {
252 332 $data['user_id'] = $user->ID;
@@ -261,9 +341,8 @@
261 341 return static::create($data);
262 342 }
263 343
264 344 /**
265 - * This updateCustomer method will update a customer
266 345 * @since 1.5.7
267 346 * @param int $customerId
268 347 * @param array $data
269 348 * @return object
@@ -270,14 +349,17 @@
270 349 */
271 350
272 351 public function updateCustomer($customerId, $data)
273 352 {
274 - $customFieldsKeys = apply_filters('fluent_support/custom_registration_form_fields_key', Helper::getBusinessSettings('custom_registration_form_field'));
275 - $customFieldsKeys = is_array($customFieldsKeys) ? $customFieldsKeys : [];
353 + $customFieldsKeys = Helper::getCustomRegistrationFieldKeys();
276 354 $customFormValue = Arr::only($data, $customFieldsKeys);
277 355
278 356 $data = $this->takeValidKeysForUpdate($data);
279 357
358 + if (isset($data['last_response_at']) && empty($data['last_response_at'])) {
359 + unset($data['last_response_at']);
360 + }
361 +
280 362 $customer = static::findOrFail($customerId);
281 363
282 364 if($this->customerExists($customerId, $data['email']))
283 365 {
@@ -286,26 +368,40 @@
286 368
287 369 $user = get_user_by('email', $data['email']);
288 370
289 371 if ($user && !empty($customFieldsKeys)) {
290 - $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);
291 374
292 - //Update Custom field data for user
293 - foreach ($customFieldsKeys as $key) {
294 - if (isset($customFormValue[$key])) {
295 - $fieldValue = $customFormValue[$key];
296 - 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 + }
297 382 }
298 383 }
299 384 }
300 385
386 + $previousEmail = (string) $customer->email;
387 +
301 388 static::where('id', $customer->id)->update($data);
302 389
303 - 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;
304 399 }
305 400
306 401 /**
307 - * deleteCustomer method will delete a customer and all tickets by that customer
402 + * Delete a customer and all their tickets.
403 + *
308 404 * @since 1.5.7
309 405 * @param int $customerId
310 406 * @return array
311 407 */
@@ -311,25 +407,85 @@
311 407 */
312 408
313 409 public function deleteCustomer($customerId)
314 410 {
315 - $customer = static::findOrFail($customerId);
411 + $this->deleteWithTickets(static::findOrFail($customerId));
316 412
317 - $tickets = Ticket::where('customer_id', $customer->id)->get();
413 + return [
414 + 'message' => __('Customer Deleted Successfully', 'fluent-support')
415 + ];
416 + }
318 417
319 - foreach ($tickets as $ticket) {
320 - $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);
321 431 }
322 432
323 433 $customer->delete();
434 + }
324 435
325 - return [
326 - 'message' => __('Customer Deleted Successfully', 'fluent-support')
436 + /**
437 + * @since 1.9.3
438 + * @param array $customerIds
439 + * @return array
440 + */
441 + public function bulkDeleteCustomers($customerIds)
442 + {
443 + if (empty($customerIds) || !is_array($customerIds)) {
444 + return [
445 + 'message' => __('No customers selected for deletion', 'fluent-support')
446 + ];
447 + }
448 +
449 + $deletedCount = 0;
450 + $errors = [];
451 +
452 + foreach ($customerIds as $customerId) {
453 + try {
454 + $this->deleteWithTickets(static::findOrFail($customerId));
455 + $deletedCount++;
456 +
457 + } catch (\Exception $e) {
458 + /* translators: %1$d: customer ID, %2$s: error message */
459 + $errors[] = sprintf(__('Failed to delete customer ID %1$d: %2$s', 'fluent-support'), $customerId, $e->getMessage());
460 + }
461 + }
462 +
463 + if ($deletedCount > 0) {
464 + $message = $deletedCount === 1
465 + ? __('Customer deleted successfully', 'fluent-support')
466 + /* translators: %1$d: number of customers deleted */
467 + : sprintf(__('%1$d customers deleted successfully', 'fluent-support'), $deletedCount);
468 + } else {
469 + $message = __('No customers were deleted', 'fluent-support');
470 + }
471 +
472 + $response = [
473 + 'message' => $message,
474 + 'deleted_count' => $deletedCount,
475 + 'total_requested' => count($customerIds)
327 476 ];
477 +
478 + if (!empty($errors)) {
479 + $response['errors'] = $errors;
480 + }
481 +
482 + return $response;
328 483 }
329 484
330 485 /**
331 - * This attachCustomersMetaData method will attach meta data to customers
486 + * Attach ticket/response counts and profile link.
487 + *
332 488 * @since 1.5.7
333 489 * @param object $customers
334 490 * @return object
335 491 */
@@ -334,13 +490,39 @@
334 490 * @return object
335 491 */
336 492 private function attachCustomersMetaData($customers)
337 493 {
494 + $customerIds = [];
338 495 foreach ($customers as $customer) {
339 - $customer->total_tickets = $customer->getTicketCounts();
340 - $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);
341 524 if ($customer->user_id) {
342 - //Get profile link, if they are WP user
343 525 $customer->user_profile = admin_url('user-edit.php?user_id=' . $customer->user_id);
344 526 }
345 527 }
346 528
@@ -347,9 +529,8 @@
347 529 return $customers;
348 530 }
349 531
350 532 /**
351 - * This getCustomerAdditionalData method will return additional data for a customer
352 533 * @since 1.5.7
353 534 * @param array $with
354 535 * @param object $customer
355 536 * @param array $data
@@ -361,9 +542,9 @@
361 542 $customFieldKeys = apply_filters('fluent_support/custom_registration_form_fields_key', []);
362 543
363 544 $userMetaData = get_user_meta($customer['user_id']);
364 545
365 - //Custom field data from wp user_meta
546 + // Custom field values live in WP user meta
366 547 foreach ($customFieldKeys as $fieldKey) {
367 548 if (isset($userMetaData[$fieldKey][0])) {
368 549 $customer[$fieldKey] = $userMetaData[$fieldKey][0];
369 550 }
@@ -380,10 +561,15 @@
380 561 * @param int $limit
381 562 */
382 563 $limit = apply_filters('fluent_support/customer_page_ticket_widgets_limit', 20);
383 564
384 - $data['tickets'] = Ticket::select(['id', 'title', 'status', 'customer_id', 'created_at'])
385 - ->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
386 572 ->latest('id')
387 573 ->limit($limit)
388 574 ->get();
389 575 }
@@ -395,9 +581,10 @@
395 581 return $data;
396 582 }
397 583
398 584 /**
399 - * This customerExists method will check if customer exists
585 + * Another customer with this email, or false.
586 + *
400 587 * @since 1.5.7
401 588 * @param int $customerId
402 589 * @param string $email
403 590 * @return mixed
@@ -413,9 +600,41 @@
413 600 }
414 601
415 602
416 603 /**
417 - * 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 + /**
418 637 * @since 1.5.7
419 638 * @param array $data
420 639 * @return array
421 640 */
@@ -420,11 +639,9 @@
420 639 * @return array
421 640 */
422 641 private function takeValidKeysForUpdate($data)
423 642 {
424 - $validKeys = $this->getFillable();
425 - unset($validKeys['hash']);
426 - unset($validKeys['user_id']);
643 + $validKeys = array_diff($this->getFillable(), self::$immutableUpdateKeys);
427 644
428 645 return Arr::only($data, $validKeys);
429 646 }
430 647 }