# fluent-support/trunk/app/Models/Traits/CustomerTrait.php

Fluent Support – Helpdesk &amp; Customer Support Ticket System, version trunk. 648 lines.

- Page: https://pluginprobe.com/plugins/fluent-support/trunk/code/app/Models/Traits/CustomerTrait.php
- Raw: https://pluginprobe.com/plugins/fluent-support/trunk/raw/app/Models/Traits/CustomerTrait.php
- Modified: 2026-10-09T15:31:16+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/fluent-support/trunk/code/app/Models/Traits/CustomerTrait.php#L10-L20`.

```php
<?php

namespace FluentSupport\App\Models\Traits;

use Exception;
use FluentSupport\App\Models\Ticket;
use FluentSupport\App\Models\Conversation;
use FluentSupport\App\Services\Helper;
use FluentSupport\Framework\Support\Arr;
use FluentSupport\App\Services\ProfileInfoService;
use FluentSupport\App\Services\Tickets\AgentTicketAccess;
use FluentSupport\App\Services\Tickets\TicketService;

trait CustomerTrait
{
    /**
     * Identity columns that must never be mass-assigned on update. The
     * `creating` hook sets them on create, but update() bypasses it.
     *
     * @var array
     */
    private static $immutableUpdateKeys = [
        'hash',        // portal identity token
        'user_id',     // WordPress user linkage
        'person_type', // customer/agent record type, enforces the global scope
    ];

    /**
     * Whether this customer is barred from support altogether. Only 'active'
     * is allowed, so any new status blocks until explicitly permitted.
     *
     * @return bool
     */
    public function isBlocked()
    {
        // Some imported rows have an empty status. Treat it as the column default.
        $status = $this->status ?: 'active';

        return $status !== 'active';
    }

    /**
     * Records that no WordPress account is linked to. Integrations can store
     * 0 instead of NULL, so both count as unlinked.
     *
     * @param \FluentSupport\Framework\Database\Orm\Builder $query
     * @return \FluentSupport\Framework\Database\Orm\Builder
     */
    public function scopeUnclaimed($query)
    {
        return $query->where(function ($q) {
            $q->whereNull('user_id')->orWhere('user_id', 0);
        });
    }

    /**
     * Records that belong to a WordPress account.
     *
     * @param \FluentSupport\Framework\Database\Orm\Builder $query
     * @return \FluentSupport\Framework\Database\Orm\Builder
     */
    public function scopeClaimed($query)
    {
        return $query->whereNotNull('user_id')->where('user_id', '!=', 0);
    }

    /**
     * Whether this customer may use the customer portal.
     *
     * @return bool
     */
    public function canAccessPortal()
    {
        return !$this->isBlocked();
    }

    /**
     * Whether Fluent Support may send this customer a notification. Same check
     * as portal access for now, kept separate so an email verification policy
     * can change one without the other.
     *
     * @return bool
     */
    public function canReceiveNotifications()
    {
        return !$this->isBlocked();
    }

    /**
     * @param string $search
     * @param string $status
     * @return object
     */
    public function getCustomers($search, $status=false)
    {
        $customersQuery = static::latest();

        if ( $search ) {
            $customersQuery = $customersQuery->searchBy($search);
        }

        if ($status && $status != 'all') {
            $customersQuery->filterByStatues([$status]);
        }

        $customers = $customersQuery->paginate();

        if ( defined( 'FLUENTCRM' ) && $customers->isEmpty() && !$status ) {
            $customers = $this->findInCrmAndMakeCustomer ( $search );
            ! is_null( $customers ) ? $customers : $customers = $customersQuery->paginate();
        }

        if ( defined('FLUENT_SUPPORT_PRO_DIR_FILE') && $customers->isEmpty() && !$status ) {
            $customers = $this->findUserInWPAndMakeCustomer( $search );
            ! is_null( $customers ) ? $customers : $customers = $customersQuery->paginate();
        }

        return $this->attachCustomersMetaData( $customers );
    }

    public function getCustomerField($customer_id,$userID = null)
    {
        $basicFields = [
            'first_name' => [
                'label' => __('First Name', 'fluent-support'),
                'data_type' => 'text',
                'placeholder' => __('First Name', 'fluent-support'),
                'type' => 'input-text',
                'wrapper_class' => 'fs_half_field required',
            ],
            'last_name' => [
                'label' => __('Last Name', 'fluent-support'),
                'data_type' => 'text',
                'placeholder' => __('Last Name', 'fluent-support'),
                'type' => 'input-text',
                'wrapper_class' => 'fs_half_field',
            ],
            'email' => [
                'label' => __('Email', 'fluent-support'),
                'data_type' => 'email',
                'placeholder' => __('Email', 'fluent-support'),
                'type' => 'input-text',
                'wrapper_class' => 'fs_half_field required',
                'disabled' => !empty($customer_id),
            ],
            'title' => [
                'label' => __('Job Title', 'fluent-support'),
                'data_type' => 'text',
                'placeholder' => __('Job Title', 'fluent-support'),
                'type' => 'input-text',
                'wrapper_class' => 'fs_half_field',
            ],
            'note' => [
                'label' => __('Note', 'fluent-support'),
                'data_type' => 'textarea',
                'placeholder' => __('Note', 'fluent-support'),
                'type' => 'input-text',
            ],
            'status' => [
                'label' => __('Status', 'fluent-support'),
                'data_type' => 'text',
                'type' => 'input-radio',
                'options' => [
                    'active' => ['id' => 'active', 'value' => 'active', 'label' => __('Active', 'fluent-support')],
                    'inactive' => ['id' => 'inactive', 'value' => 'inactive', 'label' => __('Blocked', 'fluent-support')],
                ],
            ],
            'status_html' => [
                'dependency' => [
                    'depends_on' => 'status',
                    'operator' => '=',
                    'value' => 'inactive',
                ],
                'type' => 'html-viewer',
                'wrapper_class' => 'fs_warn_alert_wrapper',
                'html' => __('If you select the <b>Blocked</b> status, this customer will not be able to submit a ticket or any response.', 'fluent-support'),
            ],
        ];

        $addressFields = [
            'address_line_1' => [
                'label' => __('Address Line 1', 'fluent-support'),
                'data_type' => 'text',
                'placeholder' => __('Address Line 1', 'fluent-support'),
                'type' => 'input-text',
                'wrapper_class' => 'fs_half_field',
            ],
            'address_line_2' => [
                'label' => __('Address Line 2', 'fluent-support'),
                'data_type' => 'text',
                'placeholder' => __('Address Line 2', 'fluent-support'),
                'type' => 'input-text',
                'wrapper_class' => 'fs_half_field',
            ],
            'city' => [
                'label' => __('City', 'fluent-support'),
                'data_type' => 'text',
                'placeholder' => __('City', 'fluent-support'),
                'type' => 'input-text',
                'wrapper_class' => 'fs_half_field',
            ],
            'state' => [
                'label' => __('State', 'fluent-support'),
                'data_type' => 'text',
                'placeholder' => __('State', 'fluent-support'),
                'type' => 'input-text',
                'wrapper_class' => 'fs_half_field',
            ],
            'zip' => [
                'label' => __('Zip Code', 'fluent-support'),
                'data_type' => 'text',
                'placeholder' => __('Zip Code', 'fluent-support'),
                'type' => 'input-text',
                'wrapper_class' => 'fs_half_field',
            ],
            'country' => [
                'label' => __('Country', 'fluent-support'),
                'placeholder' => __('Country', 'fluent-support'),
                'type' => 'country-selector',
                'wrapper_class' => 'fs_half_field',
            ],
        ];

        if ($userID) {
            $addressFields =  apply_filters('fluent_support/custom_registration_form_fields',$addressFields);

            foreach ($addressFields as $key => $field) {
                if (isset($field['type']) && $field['type'] === 'country-selector') {
                    continue;
                }

                // _formBuilder.vue renders custom fields as 'input-text' with the real type in data_type.
                $addressFields[$key]['data_type'] = $addressFields[$key]['type'];
                $addressFields[$key]['type'] = 'input-text';
            }
        }

        $loading = false;

        $state = [
            'basic_fields' => $basicFields,
            'address_fields' => $addressFields,
            'loading' => $loading,
        ];

        return $state;

    }

    /**
     * Create a customer from the WP user with this email.
     *
     * @param string $search
     * @return object
     */
    public function findUserInWPAndMakeCustomer ( $search )
    {
        if ( is_email( $search ) ){
            $user = get_user_by( 'email', $search );

            if( $user ) {
                unset( $user->user_pass );
                $customerData = [
                    'user_id' => $user->ID,
                    'email' => $user->user_email,
                    'first_name' => $user->first_name,
                    'last_name' => $user->last_name
                ];
                $customer = static::maybeCreateCustomer( $customerData );
                return static::where('email', $user->user_email)->paginate();
            }
        }
    }

    /**
     * Create a customer from the FluentCRM contact with this email.
     *
     * @param string $search
     * @return object
     */
    public function findInCrmAndMakeCustomer ( $search )
    {
        $customer = \FluentCrm\App\Models\Subscriber::where( 'email', $search )->first();
        if( $customer ) {
            $customerData = [
                'email' => $customer->email,
                'first_name' => $customer->first_name,
                'last_name' => $customer->last_name
            ];
            $customer = static::maybeCreateCustomer( $customerData );
            return static::where('email', $customer->email)->paginate();
        }
    }

    /**
     * @param int $id
     * @param array $with
     * @return object
     */

    public function getCustomer($id, $with)
    {
        $customer = static::find($id);

        $data = [
            'customer' => $customer
        ];

        if (!$with) {
            return $data;
        }

        return $this->getCustomerAdditionalData($with, $customer, $data);
    }

    /**
     * @param array $data
     * @return object
     */

    public function createCustomer($data)
    {
        $data = Arr::only($data, $this->getFillable());

        // Link a WP user only through the email, never a client user_id, or a new
        // record could become another account's portal identity.
        unset($data['user_id']);

        $user = get_user_by('email', $data['email']);

        if ($user) {
            $data['user_id'] = $user->ID;
            if (empty($data['first_name'])) {
                $data['first_name'] = $user->first_name;
            }
            if (empty($data['last_name'])) {
                $data['last_name'] = $user->last_name;
            }
        }

        return static::create($data);
    }

    /**
     * @since 1.5.7
     * @param int $customerId
     * @param array $data
     * @return object
     */

    public function updateCustomer($customerId, $data)
    {
        $customFieldsKeys = Helper::getCustomRegistrationFieldKeys();
        $customFormValue = Arr::only($data, $customFieldsKeys);

        $data = $this->takeValidKeysForUpdate($data);

        if (isset($data['last_response_at']) && empty($data['last_response_at'])) {
            unset($data['last_response_at']);
        }

        $customer = static::findOrFail($customerId);

        if($this->customerExists($customerId, $data['email']))
        {
            throw new \Exception('Another Customer has same email address');
        }

        $user = get_user_by('email', $data['email']);

        if ($user && !empty($customFieldsKeys)) {
            // The submitted email alone must not re-bind this record to the account that owns it.
            $linkedUserId = $this->resolveUserLinkage($customer, (int) $user->ID);

            if ($linkedUserId) {
                $data['user_id'] = $linkedUserId;

                foreach ($customFieldsKeys as $key) {
                    if (isset($customFormValue[$key]) && is_scalar($customFormValue[$key])) {
                        update_user_meta($linkedUserId, $key, sanitize_text_field($customFormValue[$key]));
                    }
                }
            }
        }

        $previousEmail = (string) $customer->email;

        static::where('id', $customer->id)->update($data);

        $updated = static::findOrFail($customerId);

        // Revoke signed ticket links sent to the old address. The update above
        // is a query-builder write and fires no model events, so do it here.
        if (strtolower(trim($previousEmail)) !== strtolower(trim((string) $updated->email))) {
            ProfileInfoService::onProvenEmailChange($updated, $previousEmail, 'agent');
        }

        return $updated;
    }

    /**
     * Delete a customer and all their tickets.
     *
     * @since 1.5.7
     * @param int $customerId
     * @return array
     */

    public function deleteCustomer($customerId)
    {
        $this->deleteWithTickets(static::findOrFail($customerId));

        return [
            'message' => __('Customer Deleted Successfully', 'fluent-support')
        ];
    }

    /**
     * Delete a customer after their tickets. Tickets go through TicketService
     * so `fluent_support/ticket_deleted` fires, as for any other ticket delete.
     *
     * @param \FluentSupport\App\Models\Customer $customer
     * @return void
     */
    protected function deleteWithTickets($customer)
    {
        $ticketService = new TicketService();

        foreach (Ticket::where('customer_id', $customer->id)->get() as $ticket) {
            $ticketService->deleteTicket($ticket);
        }

        $customer->delete();
    }

    /**
     * @since 1.9.3
     * @param array $customerIds
     * @return array
     */
    public function bulkDeleteCustomers($customerIds)
    {
        if (empty($customerIds) || !is_array($customerIds)) {
            return [
                'message' => __('No customers selected for deletion', 'fluent-support')
            ];
        }

        $deletedCount = 0;
        $errors = [];

        foreach ($customerIds as $customerId) {
            try {
                $this->deleteWithTickets(static::findOrFail($customerId));
                $deletedCount++;

            } catch (\Exception $e) {
                /* translators: %1$d: customer ID, %2$s: error message */
                $errors[] = sprintf(__('Failed to delete customer ID %1$d: %2$s', 'fluent-support'), $customerId, $e->getMessage());
            }
        }

        if ($deletedCount > 0) {
            $message = $deletedCount === 1
                ? __('Customer deleted successfully', 'fluent-support')
                /* translators: %1$d: number of customers deleted */
                : sprintf(__('%1$d customers deleted successfully', 'fluent-support'), $deletedCount);
        } else {
            $message = __('No customers were deleted', 'fluent-support');
        }

        $response = [
            'message' => $message,
            'deleted_count' => $deletedCount,
            'total_requested' => count($customerIds)
        ];

        if (!empty($errors)) {
            $response['errors'] = $errors;
        }

        return $response;
    }

    /**
     * Attach ticket/response counts and profile link.
     *
     * @since 1.5.7
     * @param object $customers
     * @return object
     */
    private function attachCustomersMetaData($customers)
    {
        $customerIds = [];
        foreach ($customers as $customer) {
            $customerIds[] = $customer->id;
        }

        $ticketCounts = [];
        $responseCounts = [];

        if ($customerIds) {
            // Count only tickets the current agent may open.
            $access = new AgentTicketAccess();

            $ticketsQuery = Ticket::select('customer_id')
                ->selectRaw('COUNT(*) as total')
                ->whereIn('customer_id', $customerIds)
                ->groupBy('customer_id');
            $access->applyAccessScope($ticketsQuery);
            $ticketCounts = $ticketsQuery->pluck('total', 'customer_id')->all();

            $responsesQuery = Conversation::select('person_id')
                ->selectRaw('COUNT(*) as total')
                ->whereIn('person_id', $customerIds)
                ->groupBy('person_id');
            $access->applyAccessScopeViaTicket($responsesQuery);
            $responseCounts = $responsesQuery->pluck('total', 'person_id')->all();
        }

        foreach ($customers as $customer) {
            $customer->total_tickets = (int) Arr::get($ticketCounts, $customer->id, 0);
            $customer->total_responses = (int) Arr::get($responseCounts, $customer->id, 0);
            if ($customer->user_id) {
                $customer->user_profile = admin_url('user-edit.php?user_id=' . $customer->user_id);
            }
        }

        return $customers;
    }

    /**
     * @since 1.5.7
     * @param array $with
     * @param object $customer
     * @param array $data
     * @return array
     */

    private function getCustomerAdditionalData($with, $customer, $data)
    {
        $customFieldKeys = apply_filters('fluent_support/custom_registration_form_fields_key', []);

        $userMetaData = get_user_meta($customer['user_id']);

        // Custom field values live in WP user meta
        foreach ($customFieldKeys as $fieldKey) {
            if (isset($userMetaData[$fieldKey][0])) {
                $customer[$fieldKey] = $userMetaData[$fieldKey][0];
            }
        }

        if (in_array('widgets', $with)) {
            $data['widgets'] = ProfileInfoService::getProfileExtraWidgets($customer);
        }

        if (in_array('tickets', $with)) {
            /*
             * Filter ticket limit to show ticket in customer page sidebar
             * @since 1.5.6
             * @param int $limit
             */
            $limit = apply_filters('fluent_support/customer_page_ticket_widgets_limit', 20);

            $ticketsQuery = Ticket::select(['id', 'title', 'status', 'customer_id', 'created_at'])
                ->where('customer_id', $customer->id);

            // Only list tickets this agent may open (visibility + mailbox restrictions).
            (new AgentTicketAccess())->applyAccessScope($ticketsQuery);

            $data['tickets'] = $ticketsQuery
                ->latest('id')
                ->limit($limit)
                ->get();
        }

        if(in_array('fluentcrm_profile', $with)) {
            $data['fluentcrm_profile'] = Helper::getFluentCrmContactData($customer);
        }

        return $data;
    }

    /**
     * Another customer with this email, or false.
     *
     * @since 1.5.7
     * @param int $customerId
     * @param string $email
     * @return mixed
     */

    private function customerExists($customerId, $email)
    {
        $customer = static::where('id','!=', $customerId)->where('email', $email)->first();
        if ($customer) {
            return $customer;
        }
        return false;
    }


    /**
     * Which WP account this customer may be linked to. The portal resolves
     * customers by user_id, so re-binding hands over their tickets. Only admins
     * may re-bind, and not to an account another customer already owns.
     * Otherwise the existing link is kept.
     *
     * @param object $customer
     * @param int $targetUserId WP user matching the submitted email
     * @return int user id to link, or 0 for none
     */
    private function resolveUserLinkage($customer, $targetUserId)
    {
        $currentUserId = (int) $customer->user_id;

        if ($targetUserId === $currentUserId) {
            return $currentUserId;
        }

        if (!current_user_can('manage_options')) {
            return $currentUserId;
        }

        $alreadyLinked = static::where('id', '!=', $customer->id)
            ->where('user_id', $targetUserId)
            ->first();

        if ($alreadyLinked) {
            return $currentUserId;
        }

        return $targetUserId;
    }

    /**
     * @since 1.5.7
     * @param array $data
     * @return array
     */
    private function takeValidKeysForUpdate($data)
    {
        $validKeys = array_diff($this->getFillable(), self::$immutableUpdateKeys);

        return Arr::only($data, $validKeys);
    }
}

```
