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 Blocked 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); } }